refactor
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user