This commit is contained in:
team3
2026-07-02 03:05:57 +02:00
parent afa8b36105
commit 41c9f29a37
38 changed files with 4671 additions and 2634 deletions

View File

@@ -1,91 +1,106 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { fetchBlocksOverview } from '../api.js'
import { ref, computed, watch, onUnmounted } from 'vue'
import { fetchBlocksOverview, fetchBlocksBoard } from '../api.js'
import KanbanBoard from './KanbanBoard.vue'
const props = defineProps({
topic: { type: String, required: true },
steps: { type: Array, default: () => [] }, // fine sub-steps {label, phase, state}
generating: { type: Boolean, default: false },
progress: { type: String, default: null },
ready: { type: Boolean, default: false },
partial: { type: Boolean, default: false },
})
const emit = defineEmits(['close', 'restartFrom', 'resetFrom', 'restartAll', 'removeAll', 'cancel'])
const emit = defineEmits(['close', 'resetStage', 'restartAll', 'continueAll', 'addResearch', 'requeueDead', 'removeAll', 'cancel'])
// Group sub-steps by phase, carrying the global index for the re-run.
const phaseGroups = computed(() => {
const out = []
props.steps.forEach((s, i) => {
const last = out[out.length - 1]
if (last && last.phase === s.phase) last.steps.push({ ...s, idx: i })
else out.push({ phase: s.phase, steps: [{ ...s, idx: i }] })
})
return out
})
// ── Live-Kanban-Board (Poll 1.2s solange generiert) ────────────────────────────
// State ZUERST deklarieren: die immediate-Watches unten rufen load() synchron beim
// Setup — spätere const-Deklarationen wären dort noch TDZ (ReferenceError).
const board = ref(null)
const items = ref([])
const loading = ref(true)
const error = ref(null)
let timer = null
let lastDone = -1
const startSel = ref(null) // marked start point (step index) — only ≤ current stand
const endSel = ref(null) // optional end point (step index, > start) — generation stops there
const confirm = ref(null) // which destructive action currently shows "Sure?"
const startLabel = computed(() => props.steps[startSel.value]?.label || '')
const endLabel = computed(() => props.steps[endSel.value]?.label || '')
// Start is only valid up to the current stand: done/active steps, never a pending one (never ran).
function startDisabled(idx) { return startSel.value === null && props.steps[idx]?.state === 'pending' }
function inRange(idx) { return startSel.value !== null && endSel.value !== null && idx > startSel.value && idx < endSel.value }
function stepClick(idx) {
if (props.generating || startDisabled(idx)) return
confirm.value = null
if (startSel.value === null) { startSel.value = idx; endSel.value = null } // 1st click → start
else if (idx === startSel.value) { startSel.value = null; endSel.value = null } // re-click start → clear
else if (idx > startSel.value) { endSel.value = endSel.value === idx ? null : idx } // later step → toggle end
else if (props.steps[idx]?.state !== 'pending') { startSel.value = idx; endSel.value = null } // earlier → new start
async function pollBoard() {
try {
board.value = await fetchBlocksBoard(props.topic)
if (board.value.done !== lastDone) { // neue fertige Blöcke → Grid live nachladen
lastDone = board.value.done
load()
}
} catch { /* Board noch leer */ }
}
function startPoll() {
stopPoll()
timer = setInterval(pollBoard, 1200)
}
function stopPoll() {
if (timer) { clearInterval(timer); timer = null }
}
watch(() => props.topic, () => { board.value = null; lastDone = -1; pollBoard(); load() }, { immediate: true })
watch(() => props.generating, (g) => {
if (g) startPoll()
else { stopPoll(); pollBoard(); load() } // Endstand + fertige Blöcke 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 boardEmpty = computed(() => !(board.value?.columns || []).some((c) => c.total > 0))
const dead = computed(() => board.value?.dead || [])
// 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', 'levels', 'relevance', 'question_pattern', 'artefacts', 'finalize', 'outline'])
const sel = ref(null) // gewählte Spalte {board, key, label}
const confirm = ref(null) // 2-Klick-Bestätigung für destruktive Aktionen
function stageClick(c) {
if (props.generating || !RESETTABLE.has(c.key)) return
confirm.value = null
sel.value = sel.value?.key === c.key ? null : { board: c.board, key: c.key, label: c.label }
}
function clearSel() { startSel.value = null; endSel.value = null; confirm.value = null }
// 2-click confirmation for destructive actions: first click "arms", second runs it.
function arm(action, fn) {
if (confirm.value === action) { confirm.value = null; fn() }
else confirm.value = action
}
function regenerateFromHere() { const from = startSel.value, to = endSel.value; clearSel(); emit('restartFrom', { from, to }) }
function deleteFromHere() { const from = startSel.value; clearSel(); emit('resetFrom', from) }
function resetHere(restart) {
const s = sel.value
sel.value = null
confirm.value = null
emit('resetStage', { board: s.board, stage: s.key, restart })
}
const items = ref([])
const loading = ref(true)
const error = ref(null)
// Learning-path levels: order + label (color via CSS class st-<key>).
// ── Fertige Blöcke (Grid) ──────────────────────────────────────────────────────
const LEVELS = [
{ key: 'beginner', label: 'Beginner' },
{ key: 'advanced', label: 'Advanced' },
{ key: 'expert', label: 'Expert' },
]
// Legacy topics still carry einfach/mittel/schwer → map them to the new keys.
const LEGACY_LEVEL = { einfach: 'beginner', mittel: 'advanced', schwer: 'expert' }
watch(() => props.topic, load, { immediate: true })
async function load() {
loading.value = true
if (!items.value.length) loading.value = true // Spinner nur beim Erstladen, Live-Reload flackert nicht
error.value = null
items.value = []
try {
items.value = await fetchBlocksOverview(props.topic)
} catch (e) {
items.value = []
error.value = 'Overview not available — create blocks first.'
} finally {
loading.value = false
}
}
// Block relevant = has ≥1 relevant subblock (same rule as the guide).
// Without relevance data (legacy topics) don't dim.
function relevant(b) {
const withRelevance = (b.subblocks || []).filter((s) => s.relevance)
return !withRelevance.length || withRelevance.some((s) => s.relevance === 'relevant')
}
// Only non-empty level groups per block (v-if + v-for not on one element)
function groups(b) {
return LEVELS
.map((st) => ({ ...st, subs: (b.subblocks || []).filter((s) => (LEGACY_LEVEL[s.level] || s.level) === st.key) }))
@@ -105,11 +120,18 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
<button class="bk-close" title="Close" @click="emit('close')"></button>
</header>
<section v-if="steps.length" class="bk-steps">
<section class="bk-board">
<div class="bk-steps-top">
<div v-if="progress" class="bk-progress"><span class="bk-progress-dot"></span>{{ progress }}</div>
<div v-if="!generating" class="bk-global-actions">
<button class="bk-act play" @click="emit('restartAll')">{{ partial ? 'Continue' : ready ? 'Regenerate' : 'Generate' }}</button>
<button class="bk-act play" @click="emit('restartAll')">{{ ready || partial ? '+ Research' : 'Generate' }}</button>
<button v-if="partial" class="bk-act" @click="emit('continueAll')">Continue</button>
<button
v-if="dead.length"
class="bk-act"
:title="dead.map((d) => d.title + ': ' + d.error).join('\n')"
@click="emit('requeueDead')"
> {{ dead.length }} dead</button>
<button
v-if="ready || partial"
class="bk-act danger"
@@ -118,35 +140,42 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
>{{ confirm === 'remove' ? 'Sure?' : 'Remove' }}</button>
</div>
<div v-else class="bk-global-actions">
<button class="bk-act" @click="emit('addResearch')">+ Research</button>
<button class="bk-act danger" @click="emit('cancel')">Cancel</button>
</div>
</div>
<div class="bk-phasen">
<div v-for="g in phaseGroups" :key="g.phase" class="bk-phase">
<span class="bk-phase-label">{{ g.phase }}</span>
<div class="bk-steps">
<button
v-for="s in g.steps"
:key="s.idx"
class="bk-step"
:class="[s.state, { sel: startSel === s.idx, end: endSel === s.idx, 'in-range': inRange(s.idx) }]"
:disabled="generating || startDisabled(s.idx)"
:title="startDisabled(s.idx) ? `«${s.label}» — not reached yet` : (startSel !== null && s.idx > startSel ? `End at «${s.label}»` : `Start at «${s.label}»`)"
@click="stepClick(s.idx)"
>{{ s.label }}</button>
</div>
</div>
</div>
<div v-if="startSel !== null && !generating" class="bk-step-actions">
<span class="bk-step-actions-label">From «{{ startLabel }}»<span v-if="endSel !== null"> to «{{ endLabel }}»</span>:</span>
<button class="bk-act play" @click="regenerateFromHere"> regenerate</button>
<button class="bk-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', deleteFromHere)">{{ confirm === 'reset' ? 'Sure?' : ' delete all' }}</button>
<button class="bk-act ghost" @click="clearSel">Cancel</button>
<div v-if="boardEmpty && !generating" class="bk-board-empty">No board yet generation streams live cards through the columns here.</div>
<template v-else>
<div class="bk-board-label">Inventar</div>
<KanbanBoard
:columns="inventoryCols"
:agents="board?.agents || []"
:generating="generating"
:selectable="!generating"
:selectedKey="sel?.board === 'inventory' ? sel.key : null"
@stageClick="stageClick"
/>
<div class="bk-board-label">Artefakte</div>
<KanbanBoard
:columns="artefactCols"
:generating="generating"
:selectable="!generating"
:selectedKey="sel?.board === 'artefacts' ? sel.key : null"
@stageClick="stageClick"
/>
</template>
<div v-if="sel && !generating" class="bk-step-actions">
<span class="bk-step-actions-label">Ab «{{ sel.label }}»:</span>
<button class="bk-act play" @click="resetHere(true)"> neu generieren</button>
<button class="bk-act danger" :class="{ armed: confirm === 'reset' }" @click="arm('reset', () => resetHere(false))">{{ confirm === 'reset' ? 'Sure?' : ' nur zurücksetzen' }}</button>
<button class="bk-act ghost" @click="sel = null; confirm = null">Abbrechen</button>
</div>
</section>
<div v-if="loading" class="bk-empty-state">Loading</div>
<div v-else-if="error" class="bk-empty-state">{{ error }}</div>
<div v-else-if="error && !generating" class="bk-empty-state">{{ error }}</div>
<div v-else-if="!items.length" class="bk-empty-state">No blocks yet.</div>
<div v-else class="bk-grid">
@@ -182,10 +211,14 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
height: 100dvh;
display: flex;
flex-direction: column;
overflow-y: auto; /* EIN Seitenfluss: Board scrollt mit, nur der Kopf bleibt stehen */
background: var(--bg-preview);
}
.bk-head {
position: sticky;
top: 0;
z-index: 3;
display: flex;
align-items: baseline;
gap: 0.75rem;
@@ -209,12 +242,21 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
}
.bk-close:hover { border-color: var(--accent); }
/* Step overview above the blocks */
.bk-steps {
/* Live board above the blocks */
.bk-board {
padding: 0.85rem 2rem;
border-bottom: 1px solid var(--border);
background: var(--panel-soft);
}
.bk-board-label {
font-size: 0.64rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
margin: 0.5rem 0 0.3rem;
}
.bk-board-empty { color: var(--text-faint); font-size: 0.82rem; padding: 0.4rem 0; }
.bk-progress {
display: flex;
align-items: center;
@@ -222,7 +264,6 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
font-size: 0.84rem;
color: var(--accent);
font-weight: 600;
margin-bottom: 0.7rem;
}
.bk-progress-dot {
width: 8px;
@@ -233,45 +274,9 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
}
@keyframes bk-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.bk-phasen { display: flex; flex-wrap: wrap; gap: 0.5rem 1.1rem; }
.bk-phase { display: flex; flex-direction: column; gap: 0.3rem; }
.bk-phase-label {
font-size: 0.62rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
}
.bk-steps { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.bk-step {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border: 1px solid var(--border-strong);
border-radius: 6px;
background: var(--panel);
color: var(--text-muted);
font-size: 0.74rem;
padding: 0.22rem 0.5rem;
cursor: pointer;
white-space: nowrap;
}
.bk-step:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
.bk-step:disabled { cursor: default; opacity: 0.7; }
.bk-step.done { border-color: var(--success-border); color: var(--success); }
.bk-step.active { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); font-weight: 600; }
.bk-step.pending { color: var(--text-faint); }
.bk-step.sel,
.bk-step.end { border-color: var(--accent); color: var(--on-accent); background: var(--accent); font-weight: 700; box-shadow: 0 0 0 2px var(--accent-soft); }
.bk-step.in-range { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
.bk-step:disabled:not(.done):not(.active) { opacity: 0.45; }
/* Header: progress left, global buttons right */
.bk-steps-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.7rem; }
.bk-steps-top .bk-progress { margin-bottom: 0; }
.bk-steps-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.4rem; }
.bk-global-actions { margin-left: auto; display: flex; gap: 0.4rem; }
/* Action bar for the selected start point */
.bk-step-actions {
display: flex;
align-items: center;
@@ -309,7 +314,6 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
.bk-grid {
flex: 1;
overflow-y: auto;
padding: 1.5rem 2rem 4rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
@@ -324,7 +328,6 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
border-radius: 10px;
padding: 1rem 1.1rem;
}
/* Non-relevant blocks (no relevant subblock) dimmed */
.bk-card.bk-irrelevant { opacity: 0.5; }
.bk-title {