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, onMounted } from 'vue'
import { fetchGuides, fetchTopics, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBlocksStatus, fetchActiveBlocks, createBlocks as apiCreateBausteine, resetBlocksStage as apiResetBlocksStage, addBlocksResearch as apiAddResearch, requeueBlocksDead as apiRequeueDead, resetGuideBoard as apiResetGuideBoard, restartBlocksCard as apiRestartBlocksCard, resetGuideCard as apiResetGuideCard, removeGuideFormat as apiRemoveGuideFormat, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchFolders, updateSource as apiUpdateQuelle } from './api.js'
import { fetchGuides, fetchTopics, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBlocksStatus, fetchActiveBlocks, createBlocks as apiCreateBausteine, addBlocksResearch as apiAddResearch, requeueBlocksDead as apiRequeueDead, removeGuideFormat as apiRemoveGuideFormat, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchFolders, updateSource as apiUpdateQuelle } from './api.js'
import { usePolling } from './composables/usePolling.js'
import TopicSidebar from './components/TopicSidebar.vue'
import TopicDetail from './components/TopicDetail.vue'
@@ -182,12 +182,31 @@ async function loadBlocks() {
}
}
// „Auto"-Ebenen-Flags pro Topic (localStorage): nach der QA „Befunde beheben" bis 100 %.
const autoInventory = ref(true)
const autoArtefacts = ref(true)
const autoGuide = ref(true)
function ladeAuto(topic) {
const g = (e) => { const v = localStorage.getItem(`auto_${topic}_${e}`); return v === null ? true : v === 'true' }
autoInventory.value = g('inventory')
autoArtefacts.value = g('artefacts')
autoGuide.value = g('guide')
}
function handleSetAuto({ ebene, value }) {
const r = { inventory: autoInventory, artefacts: autoArtefacts, guide: autoGuide }[ebene]
if (!r) return
r.value = value
if (selectedTopic.value) localStorage.setItem(`auto_${selectedTopic.value}_${ebene}`, value ? 'true' : 'false')
}
const autoObj = () => ({ inventory: autoInventory.value, artefacts: autoArtefacts.value, guide: autoGuide.value })
function selectTopic(topic) {
selectedTopic.value = topic
previewGuide.value = null
sidebarSticky.value = false
mainView.value = 'generation' // topic click → generation board (guide only on pill click)
viewMode.value = localStorage.getItem('ansicht_' + topic) === 'erklärend' ? 'erklärend' : 'compact'
ladeAuto(topic)
localStorage.setItem('lastTopic', topic)
loadBlocks()
}
@@ -207,20 +226,6 @@ async function handleResetBlocks() {
if (await withUiError(() => apiDeleteBausteine(selectedTopic.value))) await loadBlocks()
}
async function handleResetStage({ board, stage, restart = false }) {
if (!selectedTopic.value) return
uiError.value = null
try {
await apiResetBlocksStage(selectedTopic.value, board, stage)
if (restart) await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false) // Continue: Queue abarbeiten
} catch (e) {
uiError.value = e.message
return
}
await loadBlocks()
if (restart) startPolling()
}
async function handleAddResearch() {
if (!selectedTopic.value) return
uiError.value = null
@@ -238,7 +243,7 @@ async function handleRequeueDead() {
if (!selectedTopic.value) return
const ok = await withUiError(async () => {
await apiRequeueDead(selectedTopic.value)
await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false)
await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false, false, autoObj())
})
if (!ok) return
await loadBlocks()
@@ -250,7 +255,7 @@ async function handleBlocksClick({ instructions = '', research = true, qaForce =
uiError.value = null
try {
// research=true = Start/mehr Research anhängen; false = Continue (Queue abarbeiten).
await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, research, qaForce)
await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, research, qaForce, autoObj())
} catch (e) {
uiError.value = e.message
return
@@ -300,7 +305,7 @@ function handleOpenBlocksView() {
previewGuide.value = null
}
async function handleFormatClick({ format, instructions = '', abStep = null }) {
async function handleFormatClick({ format, instructions = '' }) {
if (!selectedTopic.value) return
// No duplicate start: if a generation is already running for topic+format, ignore
const running = guides.value.some(
@@ -310,7 +315,7 @@ async function handleFormatClick({ format, instructions = '', abStep = null }) {
if (running) { handleOpenGuideBoard(format); return }
uiError.value = null
try {
await apiCreate(selectedTopic.value, format, instructions, provider.value, abStep)
await apiCreate(selectedTopic.value, format, instructions, provider.value)
} catch (e) {
uiError.value = e.message
return
@@ -344,34 +349,6 @@ async function handleRemoveGuideFormat(format) {
await loadGuides()
}
async function handleRestartCard(cardId) {
uiError.value = null
try {
await apiRestartBlocksCard(selectedTopic.value, cardId)
await handleBlocksClick({ research: false }) // Continue: der Flow zieht die Karte
} catch (e) {
uiError.value = e.message
}
}
async function handleResetGuideCard({ format, blockNorm, abStage }) {
uiError.value = null
try {
await apiResetGuideCard(selectedTopic.value, format, blockNorm, abStage)
} catch (e) {
uiError.value = e.message
}
}
async function handleGuideBoardReset({ format, abStage }) {
uiError.value = null
try {
await apiResetGuideBoard(selectedTopic.value, format, abStage)
} catch (e) {
uiError.value = e.message
}
}
function handleGuideBoardPreview() {
const g = doneByFormat.value[guideBoardFormat.value]
if (g) handlePreview(g)
@@ -502,8 +479,10 @@ onMounted(async () => {
:partial="blocks.partial"
:error="blocks.error"
:guideFormat="guideBoardFormat"
:autoInventory="autoInventory"
:autoArtefacts="autoArtefacts"
:autoGuide="autoGuide"
@close="mainView = 'blocks'"
@resetStage="handleResetStage"
@restartAll="() => handleBlocksClick({ research: true })"
@continueAll="(opts) => handleBlocksClick({ research: false, qaForce: !!(opts && opts.qaForce) })"
@addResearch="handleAddResearch"
@@ -512,11 +491,9 @@ onMounted(async () => {
@cancel="handleCancelBlocks"
@cancelGuide="handleCancel"
@startGuide="handleFormatClick"
@resetGuideStage="handleGuideBoardReset"
@preview="handleGuideBoardPreview"
@removeFormat="handleRemoveGuideFormat"
@restartCard="handleRestartCard"
@resetGuideCard="handleResetGuideCard"
@setAuto="handleSetAuto"
/>
<GeneralExamPanel
v-else-if="selectedTopic && mainView === 'general'"

View File

@@ -35,15 +35,18 @@ async function req(path, { method = 'GET', body, query } = {}) {
export const fetchGuides = () => req('/guides')
export const createGuide = (topic, format, instructions = '', provider = 'claude', abStep = null) =>
req('/guides', { method: 'POST', body: { topic, format, instructions, provider, ab_step: abStep } })
export const createGuide = (topic, format, instructions = '', provider = 'claude') =>
req('/guides', { method: 'POST', body: { topic, format, instructions, provider } })
export const fetchActiveBlocks = () => req('/blocks/active')
export const fetchBlocksStatus = (topic) => req('/blocks/status', { query: { topic } })
export const createBlocks = (topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', research = true, qaForce = false) =>
req('/blocks', { method: 'POST', body: { topic, instructions, provider, source_type: sourceType, source_location: sourceOrt, research, qa_force: qaForce } })
export const createBlocks = (topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', research = true, qaForce = false, auto = {}) =>
req('/blocks', { method: 'POST', body: {
topic, instructions, provider, source_type: sourceType, source_location: sourceOrt, research, qa_force: qaForce,
auto_inventory: auto.inventory ?? true, auto_artefacts: auto.artefacts ?? true, auto_guide: auto.guide ?? true,
} })
// Live-Kanban-Board der Blocks-Erzeugung (Spalten + Karten + Agenten + Dead-Letter).
export const fetchBlocksBoard = (topic) => req('/blocks/board', { query: { topic } })
@@ -51,26 +54,19 @@ export const fetchBlocksBoard = (topic) => req('/blocks/board', { query: { topic
// Manueller QA-Lauf (wie das Gate, inkl. LLM-Stichprobe); Badge liest den neuen Report.
export const runQa = (topic, llm = true) => req('/blocks/qa', { method: 'POST', body: { topic, llm } })
// QA-Befunde gezielt beheben (Hygiene, bestätigte Dubletten, Fremd/Unecht nach Gegen-Judge).
export const runRepair = (topic) => req('/blocks/repair', { method: 'POST', body: { topic } })
// QA-Befunde gezielt beheben; ebene = 'inventory' | 'artefacts' | undefined (alle).
export const runRepair = (topic, ebene = null) => req('/blocks/repair', { method: 'POST', body: { topic, ebene } })
// Karten ab Spalte zurücksetzen (keine Generierung).
export const resetBlocksStage = (topic, board, stage) =>
req('/blocks/reset-stage', { method: 'POST', body: { topic, board, stage } })
// Artefakte-Ebene leeren (Board-2-Daten weg, Inventar bleibt).
export const removeArtefacts = (topic) => req('/blocks/artefacts', { method: 'DELETE', query: { topic } })
// Einen weiteren Research-Agenten anhängen (Attach-or-Start).
export const addBlocksResearch = (topic, provider = 'claude') =>
req('/blocks/research', { method: 'POST', query: { topic, provider } })
export const restartBlocksCard = (topic, cardId) =>
req('/blocks/card-restart', { method: 'POST', body: { topic, card_id: cardId } })
export const removeGuideFormat = (topic, format) =>
req('/guides/board/remove', { method: 'POST', body: { topic, format } })
export const resetGuideCard = (topic, format, blockNorm, abStage) =>
req('/guides/board/card-reset', { method: 'POST', body: { topic, format, block_norm: blockNorm, ab_stage: abStage } })
export const requeueBlocksDead = (topic) =>
req('/blocks/requeue-dead', { method: 'POST', query: { topic } })
@@ -78,12 +74,9 @@ export const requeueBlocksDead = (topic) =>
export const fetchGuideBoard = (topic, format = 'Guide') =>
req('/guides/board', { query: { topic, format } })
export const resetGuideBoard = (topic, format, abStage) =>
req('/guides/board/reset', { method: 'POST', body: { topic, format, ab_stage: abStage } })
// Befunde beheben: Karten mit QA-Befunden zurück auf Prüfen + Resume-Lauf.
export const repairGuideBoard = (topic, format) =>
req('/guides/board/repair', { method: 'POST', body: { topic, format, ab_stage: 0 } })
req('/guides/board/repair', { method: 'POST', body: { topic, format } })
export const cancelBlocks = (topic) => req('/blocks/cancel', { method: 'POST', query: { topic } })

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>

View File

@@ -1,6 +1,6 @@
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchGuideBoard, fetchRuns, repairGuideBoard } from '../api.js'
import { fetchGuideBoard, fetchRuns, repairGuideBoard, runQa } from '../api.js'
import { usePolling } from '../composables/usePolling.js'
import { useConfirm } from '../composables/useConfirm.js'
import { fmtRuntime, fmtTokens } from '../format.js'
@@ -11,8 +11,9 @@ const props = defineProps({
topic: { type: String, required: true },
format: { type: String, default: 'Guide' },
refresh: { type: Number, default: 0 }, // Eltern-Signal: QA/Repair schrieben einen Guide-Report
auto: { type: Boolean, default: true },
})
const emit = defineEmits(['cancelGuide', 'startGuide', 'resetStage', 'preview', 'removeFormat', 'resetCard'])
const emit = defineEmits(['cancelGuide', 'startGuide', 'preview', 'removeFormat', 'setAuto'])
const board = ref(null)
const pollError = ref(null)
@@ -22,13 +23,10 @@ async function poll() {
board.value = await fetchGuideBoard(props.topic, props.format)
pollError.value = null
} catch (e) {
// 404 = Board noch nicht gebaut; echte Fehler (500/Netz) sichtbar machen
if (e.status === 404) board.value = null
else pollError.value = e.message
}
}
// startPoll bleibt für die manuellen Starts nach startGuide/repairClick exponiert;
// usePolling pausiert im Hintergrund-Tab und stoppt selbst, sobald generating false wird.
const { start: startPoll } = usePolling(poll, () => !!board.value?.generating, 1200)
watch(() => props.topic, () => { board.value = null; poll() }, { immediate: true })
@@ -69,34 +67,16 @@ watch(() => generating.value, (g) => {
watch(() => props.topic, () => { run.value = null; loadRun() })
onUnmounted(() => { if (clock) clearInterval(clock) })
// Stage-Index für ab_step (Reihenfolge = Spalten ohne "done").
const STAGES = ['lernziele', 'zuweisung', 'writer', 'pruefer', 'fix']
const sel = ref(null)
const selCard = ref(null)
const { isArmed, armOrRun, reset: resetConfirm } = useConfirm() // 2-Klick-Bestätigung (mit 3s-Auto-Reset)
const { isArmed, armOrRun } = useConfirm() // 2-Klick-Bestätigung
function stageClick(c) {
if (generating.value || !STAGES.includes(c.key)) return
resetConfirm()
selCard.value = null
sel.value = sel.value?.key === c.key ? null : { key: c.key, label: c.label, idx: STAGES.indexOf(c.key) }
const qaBusy = ref(false)
async function runQaClick() {
if (qaBusy.value) return
qaBusy.value = true
try { await runQa(props.topic) }
finally { qaBusy.value = false; poll() }
}
function cardClick(k) {
if (generating.value || !k.card_id) return
resetConfirm()
sel.value = null
const idx = Math.max(0, STAGES.indexOf(k.column))
selCard.value = selCard.value?.card_id === k.card_id ? null : { ...k, idx }
}
function resetCardHere() {
const k = selCard.value
selCard.value = null
resetConfirm()
emit('resetCard', { format: props.format, blockNorm: k.card_id, abStage: 0 })
setTimeout(poll, 400)
}
const repairBusy = ref(false)
const repairInfo = ref('')
async function repairClick() {
@@ -114,26 +94,17 @@ async function repairClick() {
repairBusy.value = false
}
}
function restartHere() {
const s = sel.value
sel.value = null
emit('startGuide', { format: props.format, abStep: s.idx })
startPoll()
}
function resetHere() {
const s = sel.value
sel.value = null
emit('resetStage', { format: props.format, abStage: s.idx })
setTimeout(poll, 400)
}
</script>
<template>
<section class="gb-board">
<div class="gb-top">
<span class="gb-title">Guide · {{ format }}</span>
<span v-if="board?.qa_guide != null" class="gb-qa" :class="board.qa_guide >= 9 ? 'ok' : 'bad'"
title="Guide-QA (make qa-guide)">QA {{ board.qa_guide.toFixed(1) }}/10</span>
<span v-if="board?.qa_guide != null" class="gb-qa" :class="board.qa_guide >= 10 ? 'ok' : 'bad'"
title="Guide-QA">QA {{ Math.round(board.qa_guide * 10) }} %</span>
<label class="auto-box" title="Nach der QA automatisch „Befunde beheben“, bis 100 % (max 10×)">
<input type="checkbox" :checked="auto" @change="emit('setAuto', $event.target.checked)"> Auto
</label>
<span v-if="total" class="gb-count">{{ done }}/{{ total }} Karten fertig</span>
<span v-if="runLaufzeit" class="gb-count">⏱ {{ runLaufzeit }}<template v-if="runTokens"> · {{ runTokens }} Tokens</template></span>
<div v-if="board?.progress && generating" class="gb-progress"><span class="gb-progress-dot"></span>{{ board.progress }}</div>
@@ -144,11 +115,12 @@ function resetHere() {
<button class="gb-act danger" @click="emit('cancelGuide', board?.guide_id)">Abbrechen</button>
</template>
<template v-else>
<button class="gb-act play" @click="emit('startGuide', { format, abStep: null }); startPoll()">{{ total && done < total ? 'Fortsetzen' : total ? 'Neu generieren' : 'Generieren' }}</button>
<button v-if="done === total && total" class="gb-act" @click="emit('preview')">Guide öffnen</button>
<button class="gb-act" :disabled="qaBusy" @click="runQaClick">{{ qaBusy ? 'QA läuft' : 'QA' }}</button>
<button v-if="total && board?.qa_guide != null && board.qa_guide < 10" class="gb-act"
:disabled="repairBusy" @click="repairClick">{{ repairBusy ? 'Repariert' : 'Befunde beheben' }}</button>
<span v-if="repairInfo" class="gb-count">{{ repairInfo }}</span>
<button class="gb-act play" @click="emit('startGuide', { format }); startPoll()">{{ total && done < total ? 'Fortsetzen' : total ? 'Neu generieren' : 'Generieren' }}</button>
<button v-if="done === total && total" class="gb-act" @click="emit('preview')">Guide öffnen</button>
<button v-if="total" class="gb-act danger" :class="{ armed: isArmed('delete') }" @click="armOrRun('delete', () => { emit('removeFormat', format); setTimeout(poll, 600) })">{{ isArmed('delete') ? 'Sicher?' : 'Entfernen' }}</button>
</template>
</div>
@@ -157,28 +129,7 @@ function resetHere() {
<ProgressBar v-if="total" :value="progressValue"
:label="`${done}/${total} Karten fertig · ${Math.round(progressValue * 100)} %`" />
<KanbanBoard
:columns="columns"
:agents="board?.agents || []"
:generating="generating"
:selectable="!generating"
:cardSelectable="!generating"
:selectedKey="sel?.key || null"
@stageClick="stageClick"
@cardClick="cardClick"
/>
<div v-if="selCard && !generating" class="gb-stage-actions">
<span class="gb-stage-label">Karte «{{ selCard.title }}»:</span>
<button class="gb-act play" :class="{ armed: isArmed('card') }" @click="armOrRun('card', resetCardHere)">{{ isArmed('card') ? 'Sicher?' : ' Karte neu (ab Lernziele)' }}</button>
<button class="gb-act ghost" @click="selCard = null; resetConfirm()">Abbrechen</button>
</div>
<div v-if="sel && !generating" class="gb-stage-actions">
<span class="gb-stage-label">Ab «{{ sel.label }}»:</span>
<button class="gb-act play" @click="restartHere"> neu generieren</button>
<button class="gb-act danger" :class="{ armed: isArmed('reset') }" @click="armOrRun('reset', resetHere)">{{ isArmed('reset') ? 'Sicher?' : ' nur zurücksetzen' }}</button>
<button class="gb-act ghost" @click="sel = null; resetConfirm()">Abbrechen</button>
</div>
<KanbanBoard :columns="columns" :agents="board?.agents || []" :generating="generating" />
<div v-if="!total && !generating" class="gb-empty">Noch kein Board «Generieren» erzeugt eine Karte je Baustein und schiebt sie live durch die Spalten.</div>
</section>
@@ -186,7 +137,7 @@ function resetHere() {
<style scoped>
.gb-board { padding: 0.85rem 0 0; }
.gb-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.6rem; }
.gb-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.6rem; flex-wrap: wrap; }
.gb-title { font-size: 0.9rem; font-weight: 700; }
.gb-count { color: var(--text-muted); font-size: 0.82rem; }
.gb-qa {
@@ -197,6 +148,16 @@ function resetHere() {
}
.gb-qa.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #16a34a; }
.gb-qa.bad { background: color-mix(in srgb, #ef4444 18%, transparent); color: #dc2626; }
.auto-box {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.72rem;
font-weight: 600;
color: var(--text-muted);
cursor: pointer;
}
.auto-box input { cursor: pointer; margin: 0; }
.gb-progress {
display: flex;
align-items: center;
@@ -213,17 +174,8 @@ function resetHere() {
animation: pulse-soft 1.2s ease-in-out infinite;
}
.gb-error { color: var(--danger); font-size: 0.82rem; }
.gb-actions { margin-left: auto; display: flex; gap: 0.4rem; }
.gb-actions { margin-left: auto; display: flex; gap: 0.4rem; flex-wrap: wrap; }
.gb-stage-actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.7rem;
border-top: 1px dashed var(--border-strong);
}
.gb-stage-label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); }
.gb-empty { color: var(--text-faint); font-size: 0.85rem; padding: 1rem 0; }
.gb-act {
@@ -237,9 +189,9 @@ function resetHere() {
font-weight: 600;
}
.gb-act:hover { border-color: var(--accent); }
.gb-act:disabled { opacity: 0.55; cursor: default; }
.gb-act.play { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.gb-act.play:hover { background: var(--accent-hover); }
.gb-act.danger { color: var(--danger); border-color: var(--danger); background: transparent; }
.gb-act.danger.armed { background: var(--danger); color: #fff; }
.gb-act.ghost { color: var(--text-muted); }
</style>

View File

@@ -1,17 +1,13 @@
<script setup>
// Gemeinsame Live-Board-Komponente (Blocks + Guide): Spalten mit Count-Badge und
// Karten-Titeln. Spaltenkopf-Klick (wenn erlaubt) → stageClick für Reset-Aktionen.
// Karten-Titeln. Reines Display — Steuerung läuft über die Ebenen-Buttons.
import { fmtRuntime } from '../format.js'
const props = defineProps({
columns: { type: Array, default: () => [] }, // [{key, board?, label, total, cards:[{title,status,info,retries?,rounds?,ziele?}]}]
agents: { type: Array, default: () => [] }, // [{label, runtime}]
generating: { type: Boolean, default: false },
selectable: { type: Boolean, default: false }, // Spaltenkopf klickbar (Reset ab Spalte)
cardSelectable: { type: Boolean, default: false }, // Karten klickbar (Einzel-Restart)
selectedKey: { type: String, default: null },
})
const emit = defineEmits(['stageClick', 'cardClick'])
</script>
<template>
@@ -25,23 +21,17 @@ const emit = defineEmits(['stageClick', 'cardClick'])
v-for="c in columns"
:key="(c.board || '') + c.key"
class="kb-col"
:class="{ active: c.total > 0, sel: selectedKey === c.key, collapsed: !c.total }"
:class="{ active: c.total > 0, collapsed: !c.total }"
>
<button
class="kb-col-head"
:disabled="!selectable"
:title="selectable ? `Aktionen ab «${c.label}»` : c.label"
@click="selectable && emit('stageClick', c)"
>
<div class="kb-col-head" :title="c.label">
<span class="kb-col-label">{{ c.label }}</span>
<span class="kb-col-count" :class="{ zero: !c.total }">{{ c.total }}</span>
</button>
</div>
<ul v-if="c.cards && c.cards.length" class="kb-cards">
<li
v-for="(k, i) in c.cards" :key="i" class="kb-card"
:class="[k.status, { klickbar: cardSelectable && k.card_id }]"
:title="cardSelectable && k.card_id ? `${k.title} — Klick: Karte neu generieren` : (k.info || k.title)"
@click="cardSelectable && k.card_id && emit('cardClick', { ...k, column: c.key, colBoard: c.board })"
:class="k.status"
:title="k.info || k.title"
>
<div class="kb-card-row">
<span class="kb-dot" :class="[k.status, { pulse: generating && k.status === 'active' }]"></span>