198 lines
8.0 KiB
Vue
198 lines
8.0 KiB
Vue
<script setup>
|
||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||
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'
|
||
import KanbanBoard from './KanbanBoard.vue'
|
||
import ProgressBar from './ProgressBar.vue'
|
||
|
||
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', 'preview', 'removeFormat', 'setAuto'])
|
||
|
||
const board = ref(null)
|
||
const pollError = ref(null)
|
||
|
||
async function poll() {
|
||
try {
|
||
board.value = await fetchGuideBoard(props.topic, props.format)
|
||
pollError.value = null
|
||
} catch (e) {
|
||
if (e.status === 404) board.value = null
|
||
else pollError.value = e.message
|
||
}
|
||
}
|
||
const { start: startPoll } = usePolling(poll, () => !!board.value?.generating, 1200)
|
||
|
||
watch(() => props.topic, () => { board.value = null; poll() }, { immediate: true })
|
||
watch(() => props.refresh, () => poll())
|
||
watch(() => board.value?.generating, (g) => { if (g) startPoll() })
|
||
|
||
const generating = computed(() => !!board.value?.generating)
|
||
const columns = computed(() => board.value?.columns || [])
|
||
const total = computed(() => columns.value.reduce((n, c) => n + c.total, 0))
|
||
const done = computed(() => columns.value.find((c) => c.key === 'done')?.total || 0)
|
||
const progressValue = computed(() => (total.value ? done.value / total.value : 0))
|
||
|
||
// Laufzeit + Tokens aus /api/runs (deckt auch Guide-Läufe ab — beide setzen run_id)
|
||
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, () => generating.value, 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
|
||
})
|
||
watch(() => generating.value, (g) => {
|
||
if (g) { startRunPoll(); if (!clock) clock = setInterval(() => { now.value = Date.now() }, 1000) }
|
||
else { loadRun(); if (clock) { clearInterval(clock); clock = null } }
|
||
}, { immediate: true })
|
||
watch(() => props.topic, () => { run.value = null; loadRun() })
|
||
onUnmounted(() => { if (clock) clearInterval(clock) })
|
||
|
||
const { isArmed, armOrRun } = useConfirm() // 2-Klick-Bestätigung
|
||
|
||
const qaBusy = ref(false)
|
||
async function runQaClick() {
|
||
if (qaBusy.value) return
|
||
qaBusy.value = true
|
||
try { await runQa(props.topic) }
|
||
finally { qaBusy.value = false; poll() }
|
||
}
|
||
|
||
const repairBusy = ref(false)
|
||
const repairInfo = ref('')
|
||
async function repairClick() {
|
||
repairBusy.value = true
|
||
repairInfo.value = ''
|
||
try {
|
||
const r = await repairGuideBoard(props.topic, props.format)
|
||
repairInfo.value = (r.betroffen || []).length
|
||
? `${r.betroffen.length} Abschnitt(e) → Prüfen`
|
||
: 'keine Befunde im letzten QA-Report'
|
||
if ((r.betroffen || []).length) startPoll()
|
||
} catch (e) {
|
||
repairInfo.value = String(e.message || e)
|
||
} finally {
|
||
repairBusy.value = false
|
||
}
|
||
}
|
||
</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 >= 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>
|
||
<div v-if="board?.error" class="gb-error">{{ board.error }}</div>
|
||
<div v-if="pollError" class="gb-error">Board nicht erreichbar: {{ pollError }}</div>
|
||
<div class="gb-actions">
|
||
<template v-if="generating">
|
||
<button class="gb-act danger" @click="emit('cancelGuide', board?.guide_id)">Abbrechen</button>
|
||
</template>
|
||
<template v-else>
|
||
<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>
|
||
</div>
|
||
|
||
<ProgressBar v-if="total" :value="progressValue"
|
||
:label="`${done}/${total} Karten fertig · ${Math.round(progressValue * 100)} %`" />
|
||
|
||
<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>
|
||
</template>
|
||
|
||
<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; flex-wrap: wrap; }
|
||
.gb-title { font-size: 0.9rem; font-weight: 700; }
|
||
.gb-count { color: var(--text-muted); font-size: 0.82rem; }
|
||
.gb-qa {
|
||
font-size: 0.78rem;
|
||
font-weight: 600;
|
||
padding: 0.1rem 0.5rem;
|
||
border-radius: 999px;
|
||
}
|
||
.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;
|
||
gap: 0.5rem;
|
||
font-size: 0.84rem;
|
||
color: var(--accent);
|
||
font-weight: 600;
|
||
}
|
||
.gb-progress-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
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; flex-wrap: wrap; }
|
||
|
||
.gb-empty { color: var(--text-faint); font-size: 0.85rem; padding: 1rem 0; }
|
||
|
||
.gb-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;
|
||
}
|
||
.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; }
|
||
</style>
|