This commit is contained in:
Team3
2026-07-05 15:26:22 +02:00
parent 07e14fb82e
commit 250ea0b764
45 changed files with 1468 additions and 1452 deletions

View File

@@ -1,8 +1,12 @@
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchBlocksBoard, runQa, runRepair } from '../api.js'
import { fetchBlocksBoard, fetchRuns, runQa, runRepair } from '../api.js'
import { usePolling } from '../composables/usePolling.js'
import { useConfirm } from '../composables/useConfirm.js'
import { fmtRuntime, fmtTokens } from '../format.js'
import KanbanBoard from './KanbanBoard.vue'
import GuideBoardSection from './GuideBoardSection.vue'
import ProgressBar from './ProgressBar.vue'
const props = defineProps({
topic: { type: String, required: true },
@@ -15,48 +19,101 @@ const props = defineProps({
const emit = defineEmits(['close', 'resetStage', 'restartAll', 'continueAll', 'addResearch',
'requeueDead', 'removeAll', 'cancel', 'cancelGuide', 'startGuide', 'resetGuideStage', 'preview', 'removeFormat', 'restartCard', 'resetGuideCard'])
// ── Blocks-Pipeline (Poll 1.2s solange generiert) ──────────────────────────────
// ── Blocks-Pipeline (Poll 1.2s solange generiert; Visibility-Pause via usePolling) ──
const board = ref(null)
let timer = null
const pollError = ref(null)
async function pollBoard() {
try {
board.value = await fetchBlocksBoard(props.topic)
} catch { /* Board noch leer */ }
pollError.value = null
} catch (e) {
// 404 = Board noch nicht gebaut (kein Fehler); alles andere (500/Netz) sichtbar machen,
// statt es als „leeres Board" zu tarnen
if (e.status === 404) board.value = null
else pollError.value = e.message
}
}
function startPoll() { stopPoll(); timer = setInterval(pollBoard, 1200) }
function stopPoll() { if (timer) { clearInterval(timer); timer = null } }
const { start: startPoll } = usePolling(pollBoard, () => props.generating, 1200)
watch(() => props.topic, () => { board.value = null; pollBoard() }, { immediate: true })
watch(() => props.generating, (g) => {
if (g) startPoll()
else { stopPoll(); pollBoard() } // Endstand nachladen
else pollBoard() // Endstand nachladen
}, { immediate: true })
onUnmounted(stopPoll)
const inventoryCols = computed(() => (board.value?.columns || []).filter((c) => c.board === 'inventory'))
const artefactCols = computed(() => (board.value?.columns || []).filter((c) => c.board === 'artefacts'))
const dead = computed(() => board.value?.dead || [])
const qa = computed(() => board.value?.qa || null)
// Ehrlicher Fortschritt aus den Spaltenzahlen: gewichteter Spaltenindex (Terminal = 1,0).
// Kein einzelner „100%"-Wert wird geraten — wächst der Umfang (Research legt Karten nach),
// SINKT der Wert; das ist die Wahrheit und wird mit „Umfang wächst noch" gekennzeichnet.
const TERMINAL = new Set(['done_block', 'done_artefact', 'rejected', 'grouped'])
function boardProgress(cols) {
const total = cols.reduce((n, c) => n + c.total, 0)
if (!total) return { value: 0, done: 0, total: 0 }
const last = Math.max(1, cols.length - 1)
let acc = 0
cols.forEach((c, i) => { acc += (TERMINAL.has(c.key) ? 1 : i / last) * c.total })
const done = cols.filter((c) => TERMINAL.has(c.key)).reduce((n, c) => n + c.total, 0)
return { value: acc / total, done, total }
}
const inventoryProgress = computed(() => boardProgress(inventoryCols.value))
const artefactProgress = computed(() => boardProgress(artefactCols.value))
const scopeGrowing = computed(() => props.generating && (board.value?.agents?.length || 0) > 0)
// ── Laufzeit + Tokens aus /api/runs (5s-Takt, unabhängig vom 1,2s-Board-Poll) ──────
const run = ref(null)
const now = ref(Date.now())
let clock = null
async function loadRun() {
try {
const { runs } = await fetchRuns(props.topic, 1)
run.value = runs[0] || null
} catch { run.value = null }
}
const { start: startRunPoll } = usePolling(loadRun, () => props.generating, 5000)
const runLaufzeit = computed(() => {
if (!run.value?.start) return null
const start = Date.parse(run.value.start)
const ende = run.value.aktiv ? now.value : Date.parse(run.value.ende || run.value.start)
return fmtRuntime((ende - start) / 1000)
})
const runTokens = computed(() => {
const t = run.value?.tokens
return t ? fmtTokens((t.input || 0) + (t.output || 0)) : null
})
const runTokenTitel = computed(() => {
const t = run.value?.tokens || {}
return `Input ${t.input || 0} · Output ${t.output || 0} · Cache ${(t.cache_read || 0) + (t.cache_write || 0)}`
})
watch(() => props.generating, (g) => {
if (g) { startRunPoll(); if (!clock) clock = setInterval(() => { now.value = Date.now() }, 1000) }
else { loadRun(); if (clock) { clearInterval(clock); clock = null } } // Endstand
}, { immediate: true })
watch(() => props.topic, () => { run.value = null; loadRun() })
onUnmounted(() => { if (clock) clearInterval(clock) })
// Spalten, auf die zurückgesetzt werden kann (Terminal-Spalten sind kein Reset-Ziel).
const RESETTABLE = new Set(['ingest', 'cluster', 'pair_check', 'consensus_gate', 'clarify', 'naming',
'naming_check', 'fragment_filter', 'grouping', 'gap_check', 'done',
'subblocks', 'facts', 'konsolidierung', 'levels', 'relevance', 'question_pattern', 'artefacts', 'finalize', 'outline'])
const sel = ref(null) // gewählte Spalte {board, key, label}
const selCard = ref(null) // gewählte Karte (Einzel-Restart, nur artefacts)
const confirm = ref(null) // 2-Klick-Bestätigung für destruktive Aktionen
const { isArmed, armOrRun, reset: resetConfirm } = useConfirm() // 2-Klick-Bestätigung (mit 3s-Auto-Reset)
function stageClick(c) {
if (props.generating || !RESETTABLE.has(c.key)) return
confirm.value = null
resetConfirm()
selCard.value = null
sel.value = sel.value?.key === c.key ? null : { board: c.board, key: c.key, label: c.label }
}
function cardClick(k) {
if (props.generating || k.kind !== 'ablock') return // Einzel-Restart nur für Artefakt-Karten
confirm.value = null
resetConfirm()
sel.value = null
selCard.value = selCard.value?.card_id === k.card_id ? null : k
}
@@ -64,13 +121,9 @@ function cardClick(k) {
function restartCard() {
const k = selCard.value
selCard.value = null
confirm.value = null
resetConfirm()
later(() => emit('restartCard', k.card_id))
}
function arm(action, fn) {
if (confirm.value === action) { confirm.value = null; fn() }
else confirm.value = action
}
function later(fn) { // Aktion emitten, Board kurz danach neu laden (kein generating-Poll aktiv)
fn()
setTimeout(pollBoard, 600)
@@ -79,7 +132,7 @@ function later(fn) { // Aktion emitten, Board kurz danach neu laden (kein gener
function resetHere(restart) {
const s = sel.value
sel.value = null
confirm.value = null
resetConfirm()
later(() => emit('resetStage', { board: s.board, stage: s.key, restart }))
}
@@ -125,18 +178,21 @@ async function repairClick() {
<h1>{{ topic }}</h1>
<span class="gen-sub">Generierung</span>
<span class="gen-spacer"></span>
<button class="gen-close" title="Close" @click="emit('close')"></button>
<button class="gen-close" title="Schließen" @click="emit('close')"></button>
</header>
<div v-if="qa && qa.pausiert" class="qa-pause">
<div v-if="pollError" class="gen-poll-error">Board nicht erreichbar: {{ pollError }}</div>
<div v-if="qa && qa.pausiert && qa.note != null" class="qa-pause">
<strong>QA-Gate: Note {{ qa.note.toFixed(1) }} unter Schwelle {{ qa.schwelle }} pausiert.</strong>
<span v-if="qa.befunde.length"> Befunde: {{ qa.befunde.join(' · ') }}</span>
<span v-if="qa.befunde?.length"> Befunde: {{ qa.befunde.join(' · ') }}</span>
<button class="gen-act" @click="emit('continueAll', { qaForce: true })">Trotzdem fortsetzen</button>
</div>
<section class="gen-section">
<div class="gen-steps-top">
<span class="gen-title">Bausteine</span>
<div v-if="progress" class="gen-progress"><span class="gen-progress-dot"></span>{{ progress }}</div>
<span v-if="runLaufzeit" class="gen-run" :title="runTokenTitel">⏱ {{ runLaufzeit }}<template v-if="runTokens"> · {{ runTokens }} Tokens</template></span>
<span v-if="run?.fails?.length" class="gen-run-fail" :title="run.fails.map((f) => f.key + ': ' + f.error).join('\n')"> {{ run.fails.length }} Fehler</span>
<div v-if="!generating" class="gen-actions">
<button class="gen-act" :disabled="qaBusy" title="QA-Lauf wie am Gate (inkl. LLM-Stichprobe)"
@click="runQaClick">{{ qaBusy ? 'QA läuft' : 'QA' }}</button>
@@ -144,18 +200,18 @@ async function repairClick() {
title="QA-Befunde gezielt beheben: Hygiene, bestätigte Dubletten mergen, Fremd/Unecht nach Gegen-Judge entfernen"
@click="repairClick">{{ repairBusy ? 'Repariert' : 'Befunde beheben' }}</button>
<span v-if="repairInfo" class="repair-info">{{ repairInfo }}</span>
<button class="gen-act play" @click="emit('restartAll')">{{ ready || partial ? '+ Research' : 'Generate' }}</button>
<button v-if="partial" class="gen-act" @click="emit('continueAll')">Continue</button>
<button class="gen-act play" @click="emit('restartAll')">{{ ready || partial ? '+ Recherche' : 'Generieren' }}</button>
<button v-if="partial" class="gen-act" @click="emit('continueAll')">Fortsetzen</button>
<button
v-if="ready || partial"
class="gen-act danger"
:class="{ armed: confirm === 'remove' }"
@click="arm('remove', () => later(() => emit('removeAll')))"
>{{ confirm === 'remove' ? 'Sure?' : 'Remove' }}</button>
:class="{ armed: isArmed('remove') }"
@click="armOrRun('remove', () => later(() => emit('removeAll')))"
>{{ isArmed('remove') ? 'Sicher?' : 'Entfernen' }}</button>
</div>
<div v-else class="gen-actions">
<button class="gen-act" @click="emit('addResearch')">+ Research</button>
<button class="gen-act danger" @click="emit('cancel')">Cancel</button>
<button class="gen-act" @click="emit('addResearch')">+ Recherche</button>
<button class="gen-act danger" @click="emit('cancel')">Abbrechen</button>
</div>
<button
v-if="dead.length"
@@ -167,9 +223,12 @@ async function repairClick() {
<div class="gen-board-label">
Inventar
<span v-if="qa" class="qa-note" :class="qa.note >= qa.schwelle ? 'ok' : 'bad'"
<span v-if="qa && qa.note != null" class="qa-note" :class="qa.note >= qa.schwelle ? 'ok' : 'bad'"
:title="'QA-Schwelle ' + qa.schwelle">QA {{ qa.note.toFixed(1) }}/10</span>
</div>
<ProgressBar v-if="inventoryProgress.total" :value="inventoryProgress.value"
:label="`${inventoryProgress.done}/${inventoryProgress.total} Karten fertig · ${Math.round(inventoryProgress.value * 100)} %`"
:hint="scopeGrowing ? 'Umfang wächst noch' : ''" />
<KanbanBoard
:columns="inventoryCols"
:agents="board?.agents || []"
@@ -184,6 +243,9 @@ async function repairClick() {
:class="qa.note_artefakte >= qa.schwelle ? 'ok' : 'bad'"
title="Beleg-Quote + verwaiste Artefakte">QA {{ qa.note_artefakte.toFixed(1) }}/10</span>
</div>
<ProgressBar v-if="artefactProgress.total" :value="artefactProgress.value"
:label="`${artefactProgress.done}/${artefactProgress.total} Karten fertig · ${Math.round(artefactProgress.value * 100)} %`"
:hint="scopeGrowing ? 'Umfang wächst noch' : ''" />
<KanbanBoard
:columns="artefactCols"
:generating="generating"
@@ -196,14 +258,14 @@ async function repairClick() {
<div v-if="selCard && !generating" class="gen-step-actions">
<span class="gen-step-actions-label">Karte «{{ selCard.title }}»:</span>
<button class="gen-act play" :class="{ armed: confirm === 'card' }" @click="confirm === 'card' ? restartCard() : confirm = 'card'">{{ confirm === 'card' ? 'Sure?' : ' Karte neu generieren' }}</button>
<button class="gen-act ghost" @click="selCard = null; confirm = null">Abbrechen</button>
<button class="gen-act play" :class="{ armed: isArmed('card') }" @click="armOrRun('card', restartCard)">{{ isArmed('card') ? 'Sicher?' : ' Karte neu generieren' }}</button>
<button class="gen-act ghost" @click="selCard = null; resetConfirm()">Abbrechen</button>
</div>
<div v-if="sel && !generating" class="gen-step-actions">
<span class="gen-step-actions-label">Ab «{{ sel.label }}»:</span>
<button class="gen-act play" @click="resetHere(true)"> neu generieren</button>
<button class="gen-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', () => resetHere(false))">{{ confirm === 'reset' ? 'Sure?' : ' nur zurücksetzen' }}</button>
<button class="gen-act ghost" @click="sel = null; confirm = null">Abbrechen</button>
<button class="gen-act danger" :class="{ armed: isArmed('reset') }" @click="armOrRun('reset', () => resetHere(false))">{{ isArmed('reset') ? 'Sicher?' : ' nur zurücksetzen' }}</button>
<button class="gen-act ghost" @click="sel = null; resetConfirm()">Abbrechen</button>
</div>
</section>
@@ -286,9 +348,8 @@ async function repairClick() {
height: 8px;
border-radius: 50%;
background: var(--accent);
animation: gen-pulse 1.2s ease-in-out infinite;
animation: pulse-soft 1.2s ease-in-out infinite;
}
@keyframes gen-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.gen-steps-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.4rem; }
.gen-actions { margin-left: auto; display: flex; gap: 0.4rem; }
@@ -328,6 +389,9 @@ async function repairClick() {
.qa-note.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #16a34a; }
.qa-note.bad { background: color-mix(in srgb, #ef4444 18%, transparent); color: #dc2626; }
.repair-info { font-size: 0.78rem; color: var(--text-muted); }
.gen-poll-error { color: var(--danger); font-size: 0.82rem; padding: 0.3rem 0; }
.gen-run { font-size: 0.8rem; color: var(--text-muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
.gen-run-fail { font-size: 0.78rem; color: var(--danger); white-space: nowrap; }
.qa-pause {
display: flex;
align-items: center;