update
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
<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, resetBlocksFromStep as apiResetBausteineAbStep, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchGuideLocks, fetchGuideSteps, 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, resetBlocksStage as apiResetBlocksStage, addBlocksResearch as apiAddResearch, requeueBlocksDead as apiRequeueDead, resetGuideBoard as apiResetGuideBoard, cancelBlocks as apiCancelBausteine, deleteBlocks as apiDeleteBausteine, fetchProviders, fetchStats, fetchTopicProgress, fetchGuideLocks, fetchGuideSteps, 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'
|
||||
import BlocksOverview from './components/BlocksOverview.vue'
|
||||
import GuideBoard from './components/GuideBoard.vue'
|
||||
import ElementsSidebar from './components/elements/ElementsSidebar.vue'
|
||||
import ElementsOverview from './components/ElementsOverview.vue'
|
||||
import GeneralExamPanel from './components/GeneralExamPanel.vue'
|
||||
@@ -27,7 +28,8 @@ const activeBlocks = ref([])
|
||||
const provider = ref(localStorage.getItem('provider') || 'claude')
|
||||
const providers = ref([])
|
||||
const folders = ref({ projekt: [], uni: [] }) // folders for the sources picker
|
||||
const mainView = ref('blocks') // blocks | elements | general | detail — exclusive main-area view
|
||||
const mainView = ref('blocks') // blocks | guideboard | elements | general | detail — exclusive main-area view
|
||||
const guideBoardFormat = ref('Guide')
|
||||
const viewMode = ref('compact') // compact | erklärend — per topic, default compact
|
||||
const levelView = ref(Number(localStorage.getItem('level')) || 4) // 1=A · 2=F · 3=E · 4=V (levels view)
|
||||
const stats = ref(null)
|
||||
@@ -212,24 +214,47 @@ async function handleResetBlocks() {
|
||||
await loadBlocks()
|
||||
}
|
||||
|
||||
async function handleResetFromStep(step) {
|
||||
async function handleResetStage({ board, stage, restart = false }) {
|
||||
if (!selectedTopic.value) return
|
||||
uiError.value = null
|
||||
try {
|
||||
await apiResetBausteineAbStep(selectedTopic.value, step) // only reset, no regeneration
|
||||
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 handleBlocksClick({ instructions, abPhase = null, abStep = null, toStep = null }) {
|
||||
async function handleAddResearch() {
|
||||
if (!selectedTopic.value) return
|
||||
uiError.value = null
|
||||
try {
|
||||
// Source is already fixed here; abPhase/abStep set the start, toStep an optional end limit.
|
||||
await apiCreateBausteine(selectedTopic.value, instructions, provider.value, undefined, undefined, abPhase, abStep, toStep)
|
||||
await apiAddResearch(selectedTopic.value, provider.value)
|
||||
} catch (e) {
|
||||
uiError.value = e.message
|
||||
return
|
||||
}
|
||||
await loadBlocks()
|
||||
startPolling()
|
||||
}
|
||||
|
||||
async function handleRequeueDead() {
|
||||
if (!selectedTopic.value) return
|
||||
await apiRequeueDead(selectedTopic.value)
|
||||
await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false)
|
||||
await loadBlocks()
|
||||
startPolling()
|
||||
}
|
||||
|
||||
async function handleBlocksClick({ instructions = '', research = true }) {
|
||||
if (!selectedTopic.value) return
|
||||
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)
|
||||
} catch (e) {
|
||||
uiError.value = e.message
|
||||
return
|
||||
@@ -278,14 +303,14 @@ function handleOpenBlocksView() {
|
||||
previewGuide.value = null
|
||||
}
|
||||
|
||||
async function handleFormatClick({ format, instructions, abStep = null }) {
|
||||
async function handleFormatClick({ format, instructions = '', abStep = null }) {
|
||||
if (!selectedTopic.value) return
|
||||
// No duplicate start: if a generation is already running for topic+format, ignore
|
||||
const running = guides.value.some(
|
||||
(g) => g.topic === selectedTopic.value && g.format === format
|
||||
&& (g.status === 'generating' || g.status === 'queued'),
|
||||
)
|
||||
if (running) return
|
||||
if (running) { handleOpenGuideBoard(format); return }
|
||||
uiError.value = null
|
||||
try {
|
||||
await apiCreate(selectedTopic.value, format, instructions, provider.value, abStep)
|
||||
@@ -293,10 +318,32 @@ async function handleFormatClick({ format, instructions, abStep = null }) {
|
||||
uiError.value = e.message
|
||||
return
|
||||
}
|
||||
handleOpenGuideBoard(format) // Start → direkt aufs Live-Board
|
||||
await loadGuides()
|
||||
startPolling()
|
||||
}
|
||||
|
||||
function handleOpenGuideBoard(format = 'Guide') {
|
||||
if (!selectedTopic.value) return
|
||||
guideBoardFormat.value = format
|
||||
mainView.value = 'guideboard'
|
||||
previewGuide.value = null
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function handlePreview(guide) {
|
||||
previewGuide.value = guide
|
||||
mainView.value = 'detail'
|
||||
@@ -399,6 +446,7 @@ onMounted(async () => {
|
||||
@createThema="handleCreateTopic"
|
||||
@updateSource="handleUpdateSource"
|
||||
@openBausteineView="handleOpenBlocksView"
|
||||
@openGuideBoard="handleOpenGuideBoard"
|
||||
@formatClick="handleFormatClick"
|
||||
@bausteineClick="handleBlocksClick"
|
||||
@cancelBlocks="handleCancelBlocks"
|
||||
@@ -416,18 +464,29 @@ onMounted(async () => {
|
||||
<BlocksOverview
|
||||
v-if="selectedTopic && mainView === 'blocks'"
|
||||
:topic="selectedTopic"
|
||||
:steps="blocks.feine_steps || []"
|
||||
:generating="blocks.generating"
|
||||
:progress="blocks.progress"
|
||||
:ready="blocks.ready"
|
||||
:partial="blocks.partial"
|
||||
@close="mainView = 'detail'"
|
||||
@restartFrom="(r) => handleBlocksClick({ instructions: '', abStep: r.from, toStep: r.to })"
|
||||
@resetFrom="handleResetFromStep"
|
||||
@restartAll="() => handleBlocksClick({ abPhase: blocks.ready ? 1 : null })"
|
||||
@resetStage="handleResetStage"
|
||||
@restartAll="() => handleBlocksClick({ research: true })"
|
||||
@continueAll="() => handleBlocksClick({ research: false })"
|
||||
@addResearch="handleAddResearch"
|
||||
@requeueDead="handleRequeueDead"
|
||||
@removeAll="handleResetBlocks"
|
||||
@cancel="handleCancelBlocks"
|
||||
/>
|
||||
<GuideBoard
|
||||
v-else-if="selectedTopic && mainView === 'guideboard'"
|
||||
:topic="selectedTopic"
|
||||
:format="guideBoardFormat"
|
||||
@close="mainView = 'blocks'"
|
||||
@cancelGuide="handleCancel"
|
||||
@startGuide="handleFormatClick"
|
||||
@resetStage="handleGuideBoardReset"
|
||||
@preview="handleGuideBoardPreview"
|
||||
/>
|
||||
<ElementsOverview
|
||||
v-else-if="selectedTopic && mainView === 'elements'"
|
||||
:topic="selectedTopic"
|
||||
|
||||
@@ -47,20 +47,53 @@ export async function fetchBlocksStatus(topic) {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function createBlocks(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', abPhase = null, abStep = null, toStep = null) {
|
||||
export async function createBlocks(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', research = true) {
|
||||
const res = await fetch(`${BASE}/blocks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, instructions, provider, source_type: sourceType, source_location: sourceOrt, ab_phase: abPhase, ab_step: abStep, to_step: toStep }),
|
||||
body: JSON.stringify({ topic, instructions, provider, source_type: sourceType, source_location: sourceOrt, research }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function resetBlocksFromStep(topic, abStep) {
|
||||
const res = await fetch(`${BASE}/blocks/reset-step`, {
|
||||
// Live-Kanban-Board der Blocks-Erzeugung (Spalten + Karten + Agenten + Dead-Letter).
|
||||
export async function fetchBlocksBoard(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/board?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Karten ab Spalte zurücksetzen (keine Generierung).
|
||||
export async function resetBlocksStage(topic, board, stage) {
|
||||
const res = await fetch(`${BASE}/blocks/reset-stage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, ab_step: abStep }),
|
||||
body: JSON.stringify({ topic, board, stage }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Einen weiteren Research-Agenten anhängen (Attach-or-Start).
|
||||
export async function addBlocksResearch(topic, provider = 'claude') {
|
||||
const res = await fetch(`${BASE}/blocks/research?topic=${encodeURIComponent(topic)}&provider=${encodeURIComponent(provider)}`, { method: 'POST' })
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function requeueBlocksDead(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/requeue-dead?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// Live-Board der Guide-Erzeugung.
|
||||
export async function fetchGuideBoard(topic, format = 'Guide') {
|
||||
const res = await fetch(`${BASE}/guides/board?topic=${encodeURIComponent(topic)}&format=${encodeURIComponent(format)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function resetGuideBoard(topic, format, abStage) {
|
||||
const res = await fetch(`${BASE}/guides/board/reset`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, format, ab_stage: abStage }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
187
frontend/src/components/GuideBoard.vue
Normal file
187
frontend/src/components/GuideBoard.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { fetchGuideBoard } from '../api.js'
|
||||
import KanbanBoard from './KanbanBoard.vue'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
format: { type: String, default: 'Guide' },
|
||||
})
|
||||
const emit = defineEmits(['close', 'cancelGuide', 'startGuide', 'resetStage', 'preview'])
|
||||
|
||||
const board = ref(null)
|
||||
let timer = null
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
board.value = await fetchGuideBoard(props.topic, props.format)
|
||||
} catch { /* Board noch leer */ }
|
||||
}
|
||||
function startPoll() { stopPoll(); timer = setInterval(poll, 1200) }
|
||||
function stopPoll() { if (timer) { clearInterval(timer); timer = null } }
|
||||
|
||||
watch(() => props.topic, () => { board.value = null; poll() }, { immediate: true })
|
||||
watch(() => board.value?.generating, (g) => { if (g) startPoll(); else stopPoll() })
|
||||
onUnmounted(stopPoll)
|
||||
|
||||
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)
|
||||
|
||||
// Stage-Index für ab_step (Reihenfolge = Spalten ohne "done").
|
||||
const STAGES = ['lernziele', 'zuweisung', 'writer', 'fakten_gate', 'coverage', 'lesbarkeit']
|
||||
const sel = ref(null)
|
||||
const confirm = ref(null)
|
||||
|
||||
function stageClick(c) {
|
||||
if (generating.value || !STAGES.includes(c.key)) return
|
||||
confirm.value = null
|
||||
sel.value = sel.value?.key === c.key ? null : { key: c.key, label: c.label, idx: STAGES.indexOf(c.key) }
|
||||
}
|
||||
function arm(action, fn) {
|
||||
if (confirm.value === action) { confirm.value = null; fn() }
|
||||
else confirm.value = action
|
||||
}
|
||||
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>
|
||||
<div class="gb-view">
|
||||
<header class="gb-head">
|
||||
<h1>{{ topic }}</h1>
|
||||
<span class="gb-sub">Guide-Board · {{ format }}</span>
|
||||
<span v-if="total" class="gb-count">{{ done }}/{{ total }} Karten fertig</span>
|
||||
<span class="gb-spacer"></span>
|
||||
<button class="gb-close" title="Close" @click="emit('close')">✕</button>
|
||||
</header>
|
||||
|
||||
<section class="gb-board">
|
||||
<div class="gb-top">
|
||||
<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 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 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>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<KanbanBoard
|
||||
:columns="columns"
|
||||
:agents="board?.agents || []"
|
||||
:generating="generating"
|
||||
:selectable="!generating"
|
||||
:selectedKey="sel?.key || null"
|
||||
@stageClick="stageClick"
|
||||
/>
|
||||
|
||||
<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: confirm === 'reset' }" @click="arm('reset', resetHere)">{{ confirm === 'reset' ? 'Sure?' : '✕ nur zurücksetzen' }}</button>
|
||||
<button class="gb-act ghost" @click="sel = null; confirm = null">Abbrechen</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gb-view {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-preview);
|
||||
}
|
||||
.gb-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
padding: 1.25rem 2rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
.gb-head h1 { font-size: 1.5rem; }
|
||||
.gb-sub { color: var(--text-faint); font-size: 0.9rem; font-weight: 600; }
|
||||
.gb-count { color: var(--text-muted); font-size: 0.82rem; }
|
||||
.gb-spacer { flex: 1; }
|
||||
.gb-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;
|
||||
}
|
||||
.gb-close:hover { border-color: var(--accent); }
|
||||
|
||||
.gb-board { padding: 0.85rem 2rem; }
|
||||
.gb-top { display: flex; align-items: center; gap: 1rem; min-height: 1.9rem; margin-bottom: 0.6rem; }
|
||||
.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: gb-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes gb-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
.gb-error { color: var(--danger); font-size: 0.82rem; }
|
||||
.gb-actions { margin-left: auto; display: flex; gap: 0.4rem; }
|
||||
|
||||
.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 {
|
||||
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.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>
|
||||
191
frontend/src/components/KanbanBoard.vue
Normal file
191
frontend/src/components/KanbanBoard.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<script setup>
|
||||
// Gemeinsame Live-Board-Komponente (Blocks + Guide): Spalten mit Count-Badge und
|
||||
// Karten-Titeln. Spaltenkopf-Klick (wenn erlaubt) → stageClick für Reset-Aktionen.
|
||||
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)
|
||||
selectedKey: { type: String, default: null },
|
||||
hideEmpty: { type: Boolean, default: false }, // leere Spalten ausblenden (Terminal-Spalten)
|
||||
})
|
||||
const emit = defineEmits(['stageClick'])
|
||||
|
||||
function visible(c) {
|
||||
return !props.hideEmpty || c.total > 0
|
||||
}
|
||||
|
||||
function fmtRuntime(s) {
|
||||
return s >= 60 ? `${Math.floor(s / 60)}m${String(Math.round(s % 60)).padStart(2, '0')}s` : `${Math.round(s)}s`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kb">
|
||||
<div v-if="agents.length" class="kb-agents">
|
||||
<span class="kb-agents-label">{{ agents.length }} Agent(en):</span>
|
||||
<span v-for="a in agents" :key="a.label" class="kb-agent">{{ a.label }} · {{ fmtRuntime(a.runtime) }}</span>
|
||||
</div>
|
||||
<div class="kb-cols">
|
||||
<div
|
||||
v-for="c in columns.filter(visible)"
|
||||
:key="(c.board || '') + c.key"
|
||||
class="kb-col"
|
||||
:class="{ active: c.total > 0, sel: selectedKey === c.key }"
|
||||
>
|
||||
<button
|
||||
class="kb-col-head"
|
||||
:disabled="!selectable"
|
||||
:title="selectable ? `Aktionen ab «${c.label}»` : c.label"
|
||||
@click="selectable && emit('stageClick', c)"
|
||||
>
|
||||
<span class="kb-col-label">{{ c.label }}</span>
|
||||
<span class="kb-col-count" :class="{ zero: !c.total }">{{ c.total }}</span>
|
||||
</button>
|
||||
<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" :title="k.info || k.title">
|
||||
<div class="kb-card-row">
|
||||
<span class="kb-dot" :class="[k.status, { pulse: generating && k.status === 'active' }]"></span>
|
||||
<span class="kb-card-title">{{ k.title }}</span>
|
||||
<span v-if="k.rounds" class="kb-badge" title="Writer-Runden">R{{ k.rounds }}</span>
|
||||
<span v-if="k.ziele" class="kb-badge ziele" title="Lernziele abgedeckt">{{ k.ziele }}</span>
|
||||
</div>
|
||||
<div v-if="k.info && k.status === 'active'" class="kb-card-info">{{ k.info }}</div>
|
||||
</li>
|
||||
<li v-if="c.total > c.cards.length" class="kb-more">+{{ c.total - c.cards.length }} weitere</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kb { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
|
||||
.kb-agents {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 0.7rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.kb-agents-label { font-weight: 700; color: var(--accent); }
|
||||
.kb-agent {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
padding: 0 0.35rem;
|
||||
background: var(--panel);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kb-cols {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
overflow-x: auto;
|
||||
align-items: flex-start;
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
.kb-col {
|
||||
flex: 0 0 150px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.kb-col.active { opacity: 1; border-color: var(--border-strong); }
|
||||
.kb-col.sel { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
|
||||
|
||||
.kb-col-head {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.3rem;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel-soft);
|
||||
border-radius: 8px 8px 0 0;
|
||||
padding: 0.3rem 0.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.kb-col-head:disabled { cursor: default; }
|
||||
.kb-col-head:hover:not(:disabled) { color: var(--accent); }
|
||||
.kb-col-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.kb-col-count {
|
||||
min-width: 1.3rem;
|
||||
text-align: center;
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
.kb-col-count.zero { background: var(--border-strong); color: var(--text-faint); }
|
||||
|
||||
.kb-cards {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.kb-card {
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.25;
|
||||
padding: 0.2rem 0.3rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.kb-card.error { border-color: var(--danger); }
|
||||
.kb-card.active { border-color: var(--accent-border); }
|
||||
.kb-card-row { display: flex; align-items: center; gap: 0.35rem; }
|
||||
.kb-card-info {
|
||||
margin: 0.15rem 0 0 1rem;
|
||||
font-size: 0.66rem;
|
||||
color: var(--text-faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.kb-card-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.kb-dot {
|
||||
flex: 0 0 auto;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-faint);
|
||||
}
|
||||
.kb-dot.error { background: var(--danger); }
|
||||
.kb-dot.pulse { background: var(--accent); animation: kb-pulse 1.2s ease-in-out infinite; }
|
||||
@keyframes kb-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
|
||||
|
||||
.kb-badge {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
border: 1px solid var(--warning-border);
|
||||
color: var(--warning);
|
||||
border-radius: 4px;
|
||||
padding: 0 3px;
|
||||
}
|
||||
.kb-badge.ziele { border-color: var(--success-border); color: var(--success); }
|
||||
.kb-more { font-size: 0.68rem; color: var(--text-faint); padding: 0.1rem 0.3rem; }
|
||||
</style>
|
||||
@@ -26,7 +26,7 @@ const props = defineProps({
|
||||
stufeAnsicht: { type: Number, default: 4 }, // 1=A · 2=F · 3=E · 4=V
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'createThema', 'updateSource', 'formatClick', 'bausteineClick', 'cancelBlocks', 'resetBausteine', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', 'setStufe', 'generalExam', 'setProvider'])
|
||||
const emit = defineEmits(['select', 'createThema', 'updateSource', 'formatClick', 'bausteineClick', 'cancelBlocks', 'resetBausteine', 'deleteTopic', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openBausteineView', 'openGuideBoard', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setAnsicht', 'setStufe', 'generalExam', 'setProvider'])
|
||||
|
||||
// Accordion: at most one panel open. IDs: 'blocks', 'fmt-<Format>', 'topic-<Name>'.
|
||||
const openPanel = ref(null)
|
||||
@@ -108,11 +108,10 @@ function guideStatus(format) {
|
||||
return latest.status
|
||||
}
|
||||
|
||||
// Step dots of the guide pipeline
|
||||
const GUIDE_STEPS = ['Outline', 'Content', 'Content check', 'Writing', 'Reading exam']
|
||||
// Stage dots of the guide board (display only — restart/reset lives on the board)
|
||||
const GUIDE_STEPS = ['Lernziele', 'Zuweisung', 'Writer', 'Fakten', 'Coverage', 'Lesbarkeit']
|
||||
|
||||
// Dots from the artifact-based "done" marker (like blocks, not the DB counter):
|
||||
// ≤ done = done. Running → the next step (done+1) is active.
|
||||
// Dots from the card-based "done" marker: ≤ done = done. Running → done+1 active.
|
||||
function guideSteps(format) {
|
||||
const labels = GUIDE_STEPS
|
||||
const done = props.guideStepsDone[format] ?? -1
|
||||
@@ -124,20 +123,9 @@ function guideSteps(format) {
|
||||
}))
|
||||
}
|
||||
|
||||
// Re-run from a guide step (1-based dot per format). null = full/resume.
|
||||
const selectedStep = reactive({})
|
||||
// Dots clickable once artifacts exist (marker ≥ 0 or done) and not generating.
|
||||
function guideSelectable(format) {
|
||||
const st = guideStatus(format)
|
||||
if (st === 'generating' || st === 'queued') return false
|
||||
return (props.guideStepsDone[format] ?? -1) >= 0 || st === 'done'
|
||||
}
|
||||
function guideStepClick(format, n) {
|
||||
if (!guideSelectable(format)) return
|
||||
selectedStep[format] = selectedStep[format] === n ? null : n
|
||||
}
|
||||
function selectedStepLabel(format) {
|
||||
return GUIDE_STEPS[(selectedStep[format] || 0) - 1] || ''
|
||||
// Dot click → open the live guide board (the board hosts restart/reset per column).
|
||||
function guideStepClick(format) {
|
||||
emit('openGuideBoard', format)
|
||||
}
|
||||
|
||||
function errorMsg(format) {
|
||||
@@ -169,10 +157,7 @@ function playLock(format) {
|
||||
|
||||
function handlePlay(format) {
|
||||
if (playLock(format)) return
|
||||
// Selected dot (1-based) → ab_step (0-based). Only for a (partially) built guide.
|
||||
const abStep = guideSelectable(format) && selectedStep[format] ? selectedStep[format] - 1 : null
|
||||
emit('formatClick', { format, instructions: '', abStep })
|
||||
selectedStep[format] = null
|
||||
emit('formatClick', { format, instructions: '', abStep: null }) // Restart-ab-Stage lebt auf dem Board
|
||||
}
|
||||
|
||||
// Flash-message behavior: × only hides, nothing is deleted
|
||||
@@ -408,10 +393,10 @@ function saveSource() {
|
||||
<span
|
||||
v-for="(s, i) in guideSteps(f.key)"
|
||||
:key="s.label"
|
||||
class="step-pill"
|
||||
:class="[s.state, { sel: selectedStep[f.key] === i + 1, klickbar: guideSelectable(f.key) }]"
|
||||
:title="(s.state === 'active' ? (latestByFormat[f.key]?.progress || s.label) : s.label) + (guideSelectable(f.key) ? ' — Click: regenerate from here' : '')"
|
||||
@click.stop="guideStepClick(f.key, i + 1)"
|
||||
class="step-pill klickbar"
|
||||
:class="[s.state]"
|
||||
:title="(s.state === 'active' ? (latestByFormat[f.key]?.progress || s.label) : s.label) + ' — Klick: Live-Board öffnen'"
|
||||
@click.stop="guideStepClick(f.key)"
|
||||
>{{ i + 1 }}</span>
|
||||
</span>
|
||||
</button>
|
||||
@@ -427,7 +412,7 @@ function saveSource() {
|
||||
:title="playLock(f.key) || (aborted(f.key) ? 'Resume' : 'Generate')"
|
||||
:disabled="!!playLock(f.key)"
|
||||
@click="handlePlay(f.key)"
|
||||
>{{ selectedStep[f.key] ? `Restart from «${selectedStepLabel(f.key)}»` : aborted(f.key) ? 'Resume' : guideStatus(f.key) === 'done' ? 'Regenerate' : 'Generate' }}</button>
|
||||
>{{ aborted(f.key) ? 'Resume' : guideStatus(f.key) === 'done' ? 'Regenerate' : 'Generate' }}</button>
|
||||
<button
|
||||
v-if="guideStatus(f.key) !== 'none' || aborted(f.key)"
|
||||
class="panel-btn danger"
|
||||
|
||||
Reference in New Issue
Block a user