This commit is contained in:
team3
2026-07-06 17:32:52 +02:00
parent cc27e53b9e
commit 73ac3e532f
19 changed files with 565 additions and 523 deletions

View File

@@ -1,6 +1,6 @@
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchBlocksBoard, fetchRuns, runQa, runRepair } from '../api.js'
import { fetchBlocksBoard, fetchRuns, runQa, runRepair, removeArtefacts } from '../api.js'
import { usePolling } from '../composables/usePolling.js'
import { useConfirm } from '../composables/useConfirm.js'
import { fmtRuntime, fmtTokens } from '../format.js'
@@ -16,9 +16,12 @@ const props = defineProps({
partial: { type: Boolean, default: false },
error: { type: String, default: null },
guideFormat: { type: String, default: 'Guide' },
autoInventory: { type: Boolean, default: true },
autoArtefacts: { type: Boolean, default: true },
autoGuide: { type: Boolean, default: true },
})
const emit = defineEmits(['close', 'resetStage', 'restartAll', 'continueAll', 'addResearch',
'requeueDead', 'removeAll', 'cancel', 'cancelGuide', 'startGuide', 'resetGuideStage', 'preview', 'removeFormat', 'restartCard', 'resetGuideCard'])
const emit = defineEmits(['close', 'restartAll', 'continueAll', 'addResearch', 'requeueDead',
'removeAll', 'removeArtefacts', 'cancel', 'cancelGuide', 'startGuide', 'preview', 'removeFormat', 'setAuto'])
// ── Blocks-Pipeline (Poll 1.2s solange generiert; Visibility-Pause via usePolling) ──
const board = ref(null)
@@ -29,8 +32,6 @@ async function pollBoard() {
board.value = await fetchBlocksBoard(props.topic)
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
}
@@ -49,8 +50,6 @@ 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)
@@ -65,6 +64,9 @@ const inventoryProgress = computed(() => boardProgress(inventoryCols.value))
const artefactProgress = computed(() => boardProgress(artefactCols.value))
const scopeGrowing = computed(() => props.generating && (board.value?.agents?.length || 0) > 0)
// Score 010 → „100 %"
const pct = (note) => Math.round(note * 10) + ' %'
// ── Laufzeit + Tokens aus /api/runs (5s-Takt, unabhängig vom 1,2s-Board-Poll) ──────
const run = ref(null)
const now = ref(Date.now())
@@ -97,79 +99,40 @@ watch(() => props.generating, (g) => {
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 { isArmed, armOrRun, reset: resetConfirm } = useConfirm() // 2-Klick-Bestätigung (mit 3s-Auto-Reset)
function stageClick(c) {
if (props.generating || !RESETTABLE.has(c.key)) return
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
resetConfirm()
sel.value = null
selCard.value = selCard.value?.card_id === k.card_id ? null : k
}
function restartCard() {
const k = selCard.value
selCard.value = null
resetConfirm()
later(() => emit('restartCard', k.card_id))
}
function later(fn) { // Aktion emitten, Board kurz danach neu laden (kein generating-Poll aktiv)
fn()
setTimeout(pollBoard, 600)
}
function resetHere(restart) {
const s = sel.value
sel.value = null
resetConfirm()
later(() => emit('resetStage', { board: s.board, stage: s.key, restart }))
}
const { isArmed, armOrRun, reset: resetConfirm } = useConfirm() // 2-Klick-Bestätigung
function later(fn) { fn(); setTimeout(pollBoard, 600) }
// ── QA + Befunde beheben (je Ebene) ──────────────────────────────────────────────
const qaBusy = ref(false)
const guideRefresh = ref(0) // QA/Repair schreiben auch den Guide-Report → Badge neu laden
async function runQaClick() {
if (qaBusy.value) return
qaBusy.value = true
try { await runQa(props.topic) }
finally { qaBusy.value = false; pollBoard(); guideRefresh.value++ }
}
const repairBusy = ref('') // '' | 'inventory' | 'artefacts'
const repairInfo = ref({}) // { inventory, artefacts }
async function repairClick(ebene) {
if (repairBusy.value) return
repairBusy.value = ebene
repairInfo.value = { ...repairInfo.value, [ebene]: '' }
try {
await runQa(props.topic)
const r = await runRepair(props.topic, ebene)
const n = (r.hygiene || []).length + (r.merges || []).length + (r.sub_merges || []).length
+ (r.entfernt || []).length + (r.aufgeraeumt || 0) + (r.freigesprochen || []).length
repairInfo.value = { ...repairInfo.value, [ebene]: n === 0 ? 'keine behebbaren Befunde'
: `${(r.hygiene || []).length} Titel · ${(r.merges || []).length} Merges · ${(r.sub_merges || []).length} Sub-Merges · ${(r.entfernt || []).length} entfernt · ${r.aufgeraeumt || 0} aufgeräumt` }
} catch (e) {
repairInfo.value = { ...repairInfo.value, [ebene]: String(e.message || e) }
} finally {
qaBusy.value = false
pollBoard()
guideRefresh.value++
repairBusy.value = ''; pollBoard()
}
}
const repairBusy = ref(false)
const repairInfo = ref('')
async function repairClick() {
if (repairBusy.value) return
repairBusy.value = true
repairInfo.value = ''
try {
const r = await runRepair(props.topic)
const n = (r.hygiene || []).length + (r.merges || []).length + (r.sub_merges || []).length
+ (r.entfernt || []).length + (r.aufgeraeumt || 0) + (r.freigesprochen || []).length
repairInfo.value = n === 0
? 'keine behebbaren Befunde'
: `${(r.hygiene || []).length} Titel · ${(r.merges || []).length} Merges · ${(r.sub_merges || []).length} Sub-Merges · ${(r.entfernt || []).length} entfernt · ${r.aufgeraeumt || 0} aufgeräumt · ${(r.freigesprochen || []).length} freigesprochen`
} catch (e) {
repairInfo.value = String(e.message || e)
} finally {
repairBusy.value = false
pollBoard()
}
function removeArtefactsClick() {
later(() => removeArtefacts(props.topic).then(pollBoard))
}
</script>
@@ -183,92 +146,69 @@ async function repairClick() {
</header>
<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>
<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>
<button v-if="qa" class="gen-act" :disabled="repairBusy"
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 ? '+ Recherche' : 'Generieren' }}</button>
<button v-if="partial" class="gen-act" @click="emit('continueAll')">Fortsetzen</button>
<button
v-if="ready || partial || error"
class="gen-act danger"
:class="{ armed: isArmed('remove') }"
:title="error ? 'Räumt Crawl/Reste auf — danach neu generierbar' : ''"
@click="armOrRun('remove', () => later(() => emit('removeAll')))"
>{{ isArmed('remove') ? 'Sicher?' : 'Entfernen' }}</button>
</div>
<div v-else class="gen-actions">
<div v-if="generating" class="gen-actions">
<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"
v-if="dead.length && generating"
class="gen-act"
:title="dead.map((d) => d.title + ': ' + d.error).join('\n')"
@click="later(() => emit('requeueDead'))"
> {{ dead.length }} dead</button>
</div>
<!-- Ebene Inventar -->
<div class="gen-board-label">
Inventar
<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>
<span v-if="qa && qa.note != null" class="qa-note" :class="qa.note >= 10 ? 'ok' : 'bad'">QA {{ pct(qa.note) }}</span>
<label class="auto-box" title="Nach der QA automatisch „Befunde beheben“, bis 100 % (max 10×)">
<input type="checkbox" :checked="autoInventory" @change="emit('setAuto', { ebene: 'inventory', value: $event.target.checked })"> Auto
</label>
</div>
<div v-if="!generating" class="gen-bar">
<button class="gen-act" :disabled="qaBusy" @click="runQaClick">{{ qaBusy ? 'QA läuft' : 'QA' }}</button>
<button v-if="qa" class="gen-act" :disabled="!!repairBusy" @click="repairClick('inventory')">{{ repairBusy === 'inventory' ? 'Repariert' : 'Befunde beheben' }}</button>
<span v-if="repairInfo.inventory" class="repair-info">{{ repairInfo.inventory }}</span>
<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 || error" class="gen-act danger" :class="{ armed: isArmed('remove') }"
title="Löscht das gesamte Topic-Inventar (Artefakte + Guide hängen daran)"
@click="armOrRun('remove', () => later(() => emit('removeAll')))">{{ isArmed('remove') ? 'Sicher?' : 'Entfernen' }}</button>
</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 || []"
:generating="generating"
:selectable="!generating"
:selectedKey="sel?.board === 'inventory' ? sel.key : null"
@stageClick="stageClick"
/>
<KanbanBoard :columns="inventoryCols" :agents="board?.agents || []" :generating="generating" />
<!-- Ebene Artefakte -->
<div class="gen-board-label">
Artefakte
<span v-if="qa && qa.note_artefakte != null" class="qa-note"
:class="qa.note_artefakte >= qa.schwelle ? 'ok' : 'bad'"
title="Beleg-Quote + verwaiste Artefakte">QA {{ qa.note_artefakte.toFixed(1) }}/10</span>
<span v-if="qa && qa.note_artefakte != null" class="qa-note" :class="qa.note_artefakte >= 10 ? 'ok' : 'bad'">QA {{ pct(qa.note_artefakte) }}</span>
<label class="auto-box" title="Nach der QA automatisch „Befunde beheben“, bis 100 % (max 10×)">
<input type="checkbox" :checked="autoArtefacts" @change="emit('setAuto', { ebene: 'artefacts', value: $event.target.checked })"> Auto
</label>
</div>
<div v-if="!generating" class="gen-bar">
<button class="gen-act" :disabled="qaBusy" @click="runQaClick">{{ qaBusy ? 'QA läuft' : 'QA' }}</button>
<button v-if="qa" class="gen-act" :disabled="!!repairBusy" @click="repairClick('artefacts')">{{ repairBusy === 'artefacts' ? 'Repariert' : 'Befunde beheben' }}</button>
<span v-if="repairInfo.artefacts" class="repair-info">{{ repairInfo.artefacts }}</span>
<button class="gen-act play" title="Board 2 auf dem fertigen Inventar bauen (Fortsetzen)" @click="emit('continueAll')">Generieren</button>
<button v-if="ready || partial" class="gen-act danger" :class="{ armed: isArmed('remove-art') }"
title="Nur die Artefakte leeren — Inventar bleibt"
@click="armOrRun('remove-art', removeArtefactsClick)">{{ isArmed('remove-art') ? 'Sicher?' : 'Entfernen' }}</button>
</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"
:selectable="!generating"
:cardSelectable="!generating"
:selectedKey="sel?.board === 'artefacts' ? sel.key : null"
@stageClick="stageClick"
@cardClick="cardClick"
/>
<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: 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: 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>
<KanbanBoard :columns="artefactCols" :agents="board?.agents || []" :generating="generating" />
</section>
<section class="gen-section">
@@ -276,12 +216,12 @@ async function repairClick() {
:topic="topic"
:format="guideFormat"
:refresh="guideRefresh"
:auto="autoGuide"
@cancelGuide="(id) => emit('cancelGuide', id)"
@startGuide="(p) => emit('startGuide', p)"
@resetStage="(p) => emit('resetGuideStage', p)"
@preview="emit('preview')"
@removeFormat="(f) => emit('removeFormat', f)"
@resetCard="(p) => emit('resetGuideCard', p)"
@setAuto="(v) => emit('setAuto', { ebene: 'guide', value: v })"
/>
</section>
</div>
@@ -330,12 +270,15 @@ async function repairClick() {
}
.gen-title { font-size: 0.9rem; font-weight: 700; }
.gen-board-label {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.64rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
margin: 0.5rem 0 0.3rem;
margin: 0.7rem 0 0.3rem;
}
.gen-progress {
display: flex;
@@ -355,16 +298,20 @@ async function repairClick() {
.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; }
.gen-bar { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0.1rem 0 0.4rem; }
.gen-step-actions {
display: flex;
.auto-box {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.7rem;
border-top: 1px dashed var(--border-strong);
gap: 0.25rem;
font-size: 0.68rem;
font-weight: 600;
color: var(--text-muted);
text-transform: none;
letter-spacing: 0;
cursor: pointer;
}
.gen-step-actions-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); }
.auto-box input { cursor: pointer; margin: 0; }
.gen-act {
border: 1px solid var(--border-strong);
@@ -377,16 +324,18 @@ async function repairClick() {
font-weight: 600;
}
.gen-act:hover { border-color: var(--accent); }
.gen-act:disabled { opacity: 0.55; cursor: default; }
.gen-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.gen-act.play:hover { background: var(--accent-hover); }
.gen-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
.gen-act.danger.armed { background: var(--danger); color: #fff; }
.gen-act.ghost { color: var(--text-muted); }
.qa-note {
font-size: 0.78rem;
font-weight: 600;
padding: 0.1rem 0.5rem;
border-radius: 999px;
text-transform: none;
letter-spacing: 0;
}
.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; }
@@ -394,16 +343,4 @@ async function repairClick() {
.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;
gap: 0.6rem;
flex-wrap: wrap;
padding: 0.5rem 0.8rem;
margin-bottom: 0.8rem;
border: 1px solid color-mix(in srgb, #ef4444 40%, transparent);
border-radius: 8px;
background: color-mix(in srgb, #ef4444 8%, transparent);
font-size: 0.85rem;
}
</style>