347 lines
15 KiB
Vue
347 lines
15 KiB
Vue
<script setup>
|
||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||
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'
|
||
import KanbanBoard from './KanbanBoard.vue'
|
||
import GuideBoardSection from './GuideBoardSection.vue'
|
||
import ProgressBar from './ProgressBar.vue'
|
||
|
||
const props = defineProps({
|
||
topic: { type: String, required: true },
|
||
generating: { type: Boolean, default: false },
|
||
progress: { type: String, default: null },
|
||
ready: { type: Boolean, default: false },
|
||
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', '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)
|
||
const pollError = ref(null)
|
||
|
||
async function pollBoard() {
|
||
try {
|
||
board.value = await fetchBlocksBoard(props.topic)
|
||
pollError.value = null
|
||
} catch (e) {
|
||
if (e.status === 404) board.value = null
|
||
else pollError.value = e.message
|
||
}
|
||
}
|
||
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 pollBoard() // Endstand nachladen
|
||
}, { immediate: true })
|
||
|
||
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).
|
||
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)
|
||
|
||
// Score 0–10 → „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())
|
||
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) })
|
||
|
||
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 {
|
||
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 {
|
||
repairBusy.value = ''; pollBoard()
|
||
}
|
||
}
|
||
|
||
function removeArtefactsClick() {
|
||
later(() => removeArtefacts(props.topic).then(pollBoard))
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="gen-view">
|
||
<header class="gen-head">
|
||
<h1>{{ topic }}</h1>
|
||
<span class="gen-sub">Generierung</span>
|
||
<span class="gen-spacer"></span>
|
||
<button class="gen-close" title="Schließen" @click="emit('close')">✕</button>
|
||
</header>
|
||
|
||
<div v-if="pollError" class="gen-poll-error">Board nicht erreichbar: {{ pollError }}</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" @click="emit('addResearch')">+ Recherche</button>
|
||
<button class="gen-act danger" @click="emit('cancel')">Abbrechen</button>
|
||
</div>
|
||
<button
|
||
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 >= 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" />
|
||
|
||
<!-- Ebene Artefakte -->
|
||
<div class="gen-board-label">
|
||
Artefakte
|
||
<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" :agents="board?.agents || []" :generating="generating" />
|
||
</section>
|
||
|
||
<section class="gen-section">
|
||
<GuideBoardSection
|
||
:topic="topic"
|
||
:format="guideFormat"
|
||
:refresh="guideRefresh"
|
||
:auto="autoGuide"
|
||
@cancelGuide="(id) => emit('cancelGuide', id)"
|
||
@startGuide="(p) => emit('startGuide', p)"
|
||
@preview="emit('preview')"
|
||
@removeFormat="(f) => emit('removeFormat', f)"
|
||
@setAuto="(v) => emit('setAuto', { ebene: 'guide', value: v })"
|
||
/>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.gen-view {
|
||
flex: 1;
|
||
min-width: 0;
|
||
height: 100dvh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow-y: auto;
|
||
background: var(--bg-preview);
|
||
}
|
||
.gen-head {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 3;
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 0.75rem;
|
||
padding: 1.25rem 2rem;
|
||
border-bottom: 1px solid var(--border);
|
||
background: var(--panel);
|
||
}
|
||
.gen-head h1 { font-size: 1.5rem; }
|
||
.gen-sub { color: var(--text-faint); font-size: 0.9rem; font-weight: 600; }
|
||
.gen-spacer { flex: 1; }
|
||
.gen-close {
|
||
align-self: center;
|
||
border: 1px solid var(--border-strong);
|
||
border-radius: 6px;
|
||
background: var(--panel);
|
||
color: var(--text);
|
||
width: 2rem;
|
||
height: 2rem;
|
||
cursor: pointer;
|
||
}
|
||
.gen-close:hover { border-color: var(--accent); }
|
||
|
||
.gen-section {
|
||
padding: 0.85rem 2rem;
|
||
border-bottom: 1px solid var(--border);
|
||
background: var(--panel-soft);
|
||
}
|
||
.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.7rem 0 0.3rem;
|
||
}
|
||
.gen-progress {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
font-size: 0.84rem;
|
||
color: var(--accent);
|
||
font-weight: 600;
|
||
}
|
||
.gen-progress-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
animation: pulse-soft 1.2s ease-in-out infinite;
|
||
}
|
||
|
||
.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; }
|
||
|
||
.auto-box {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
font-size: 0.68rem;
|
||
font-weight: 600;
|
||
color: var(--text-muted);
|
||
text-transform: none;
|
||
letter-spacing: 0;
|
||
cursor: pointer;
|
||
}
|
||
.auto-box input { cursor: pointer; margin: 0; }
|
||
|
||
.gen-act {
|
||
border: 1px solid var(--border-strong);
|
||
border-radius: 6px;
|
||
background: var(--panel);
|
||
color: var(--text);
|
||
font-size: 0.8rem;
|
||
padding: 0.3rem 0.7rem;
|
||
cursor: pointer;
|
||
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; }
|
||
.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; }
|
||
.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; }
|
||
</style>
|