refactor
This commit is contained in:
@@ -21,7 +21,7 @@ const darkMode = ref(
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
: localStorage.getItem('darkMode') === 'true',
|
||||
)
|
||||
const EMPTY_BLOCKS = { ready: false, generating: false, progress: null, error: null, partial: false, steps: [], feine_steps: [] }
|
||||
const EMPTY_BLOCKS = { ready: false, generating: false, progress: null, error: null, partial: false }
|
||||
const blocks = ref({ ...EMPTY_BLOCKS })
|
||||
const activeBlocks = ref([])
|
||||
const provider = ref(localStorage.getItem('provider') || 'claude')
|
||||
@@ -41,6 +41,13 @@ async function guard(label, fn) {
|
||||
try { return await fn() } catch (e) { console.error(label, e) }
|
||||
}
|
||||
|
||||
// Aktion ausführen und einen Backend-Fehler (409/400/500) in der Sidebar-Fehlerzeile
|
||||
// zeigen statt als unhandled rejection zu verschlucken. → true bei Erfolg.
|
||||
async function withUiError(fn) {
|
||||
uiError.value = null
|
||||
try { await fn(); return true } catch (e) { uiError.value = e.message; return false }
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
await guard('Failed to load stats:', async () => { stats.value = await fetchStats() })
|
||||
}
|
||||
@@ -192,14 +199,12 @@ watch(previewGuide, (g) => {
|
||||
|
||||
async function handleCancelBlocks() {
|
||||
if (!selectedTopic.value) return
|
||||
await apiCancelBausteine(selectedTopic.value)
|
||||
await loadBlocks()
|
||||
if (await withUiError(() => apiCancelBausteine(selectedTopic.value))) await loadBlocks()
|
||||
}
|
||||
|
||||
async function handleResetBlocks() {
|
||||
if (!selectedTopic.value) return
|
||||
await apiDeleteBausteine(selectedTopic.value)
|
||||
await loadBlocks()
|
||||
if (await withUiError(() => apiDeleteBausteine(selectedTopic.value))) await loadBlocks()
|
||||
}
|
||||
|
||||
async function handleResetStage({ board, stage, restart = false }) {
|
||||
@@ -231,8 +236,11 @@ async function handleAddResearch() {
|
||||
|
||||
async function handleRequeueDead() {
|
||||
if (!selectedTopic.value) return
|
||||
await apiRequeueDead(selectedTopic.value)
|
||||
await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false)
|
||||
const ok = await withUiError(async () => {
|
||||
await apiRequeueDead(selectedTopic.value)
|
||||
await apiCreateBausteine(selectedTopic.value, '', provider.value, undefined, undefined, false)
|
||||
})
|
||||
if (!ok) return
|
||||
await loadBlocks()
|
||||
startPolling()
|
||||
}
|
||||
@@ -407,17 +415,12 @@ const polling = usePolling(
|
||||
const startPolling = polling.start
|
||||
|
||||
async function handleCancel(guideId) {
|
||||
await apiCancel(guideId)
|
||||
await loadGuides()
|
||||
if (await withUiError(() => apiCancel(guideId))) await loadGuides()
|
||||
}
|
||||
|
||||
async function handleDeleteTopic(topic) {
|
||||
const topicGuides = guides.value.filter((g) => g.topic === topic)
|
||||
for (const g of topicGuides) {
|
||||
await deleteGuide(g.id)
|
||||
}
|
||||
await apiDeleteBausteine(topic)
|
||||
await apiDeleteTopic(topic)
|
||||
// Das Backend löscht Guides/Board/Kanban selbst und wehrt laufende Generierungen ab (409).
|
||||
if (!await withUiError(() => apiDeleteTopic(topic))) return
|
||||
await loadTopics()
|
||||
if (selectedTopic.value === topic) {
|
||||
selectedTopic.value = null
|
||||
|
||||
@@ -1,315 +1,165 @@
|
||||
const BASE = '/api'
|
||||
|
||||
// Backend-Fehler (400/409 mit detail) als Error werfen statt sie zu verschlucken
|
||||
async function jsonOrThrow(res) {
|
||||
function qs(query) {
|
||||
if (!query) return ''
|
||||
const p = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v !== undefined && v !== null) p.set(k, v)
|
||||
}
|
||||
const s = p.toString()
|
||||
return s ? `?${s}` : ''
|
||||
}
|
||||
|
||||
// Ein Request-Weg für ALLE Aufrufe: Backend-Fehler (400/409/500) werfen statt sie zu
|
||||
// verschlucken, mit err.status für Aufrufer, die 404 ("noch nichts da") gesondert behandeln.
|
||||
async function req(path, { method = 'GET', body, query } = {}) {
|
||||
const opts = { method }
|
||||
if (body !== undefined) {
|
||||
opts.headers = { 'Content-Type': 'application/json' }
|
||||
opts.body = JSON.stringify(body)
|
||||
}
|
||||
const res = await fetch(`${BASE}${path}${qs(query)}`, opts)
|
||||
if (!res.ok) {
|
||||
let detail = `Fehler (HTTP ${res.status})`
|
||||
try {
|
||||
const data = await res.json()
|
||||
if (data.detail) detail = typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail)
|
||||
} catch { /* kein JSON-Body */ }
|
||||
throw new Error(detail)
|
||||
const err = new Error(detail)
|
||||
err.status = res.status
|
||||
throw err
|
||||
}
|
||||
return res.json()
|
||||
const text = await res.text() // DELETE/manche POSTs liefern keinen Body
|
||||
return text ? JSON.parse(text) : null
|
||||
}
|
||||
|
||||
export async function fetchGuides() {
|
||||
const res = await fetch(`${BASE}/guides`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchGuides = () => req('/guides')
|
||||
|
||||
export async function createGuide(topic, format, instructions = '', provider = 'claude', abStep = null) {
|
||||
const res = await fetch(`${BASE}/guides`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, format, instructions, provider, ab_step: abStep }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const createGuide = (topic, format, instructions = '', provider = 'claude', abStep = null) =>
|
||||
req('/guides', { method: 'POST', body: { topic, format, instructions, provider, ab_step: abStep } })
|
||||
|
||||
export async function fetchActiveBlocks() {
|
||||
const res = await fetch(`${BASE}/blocks/active`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchActiveBlocks = () => req('/blocks/active')
|
||||
|
||||
export async function fetchBlocksStatus(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/status?topic=${encodeURIComponent(topic)}`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchBlocksStatus = (topic) => req('/blocks/status', { query: { topic } })
|
||||
|
||||
export async function createBlocks(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '', research = true, qaForce = false) {
|
||||
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, research, qa_force: qaForce }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
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 } })
|
||||
|
||||
// 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)
|
||||
}
|
||||
export const fetchBlocksBoard = (topic) => req('/blocks/board', { query: { topic } })
|
||||
|
||||
// Manueller QA-Lauf (wie das Gate, inkl. LLM-Stichprobe); Badge liest den neuen Report.
|
||||
export async function runQa(topic, llm = true) {
|
||||
const res = await fetch(`${BASE}/blocks/qa`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, llm }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
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 async function runRepair(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/repair`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const runRepair = (topic) => req('/blocks/repair', { method: 'POST', body: { topic } })
|
||||
|
||||
// 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, board, stage }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const resetBlocksStage = (topic, board, stage) =>
|
||||
req('/blocks/reset-stage', { method: 'POST', body: { topic, board, stage } })
|
||||
|
||||
// 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 const addBlocksResearch = (topic, provider = 'claude') =>
|
||||
req('/blocks/research', { method: 'POST', query: { topic, provider } })
|
||||
|
||||
export async function restartBlocksCard(topic, cardId) {
|
||||
const res = await fetch(`${BASE}/blocks/card-restart`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, card_id: cardId }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const restartBlocksCard = (topic, cardId) =>
|
||||
req('/blocks/card-restart', { method: 'POST', body: { topic, card_id: cardId } })
|
||||
|
||||
export async function removeGuideFormat(topic, format) {
|
||||
const res = await fetch(`${BASE}/guides/board/remove`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, format }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const removeGuideFormat = (topic, format) =>
|
||||
req('/guides/board/remove', { method: 'POST', body: { topic, format } })
|
||||
|
||||
export async function resetGuideCard(topic, format, blockNorm, abStage) {
|
||||
const res = await fetch(`${BASE}/guides/board/card-reset`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, format, block_norm: blockNorm, ab_stage: abStage }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const resetGuideCard = (topic, format, blockNorm, abStage) =>
|
||||
req('/guides/board/card-reset', { method: 'POST', body: { topic, format, block_norm: blockNorm, ab_stage: abStage } })
|
||||
|
||||
export async function requeueBlocksDead(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/requeue-dead?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const requeueBlocksDead = (topic) =>
|
||||
req('/blocks/requeue-dead', { method: 'POST', query: { topic } })
|
||||
|
||||
// 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 const fetchGuideBoard = (topic, format = 'Guide') =>
|
||||
req('/guides/board', { query: { topic, format } })
|
||||
|
||||
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)
|
||||
}
|
||||
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 async function repairGuideBoard(topic, format) {
|
||||
const res = await fetch(`${BASE}/guides/board/repair`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, format, ab_stage: 0 }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const repairGuideBoard = (topic, format) =>
|
||||
req('/guides/board/repair', { method: 'POST', body: { topic, format, ab_stage: 0 } })
|
||||
|
||||
export async function cancelBlocks(topic) {
|
||||
await fetch(`${BASE}/blocks/cancel?topic=${encodeURIComponent(topic)}`, { method: 'POST' })
|
||||
}
|
||||
export const cancelBlocks = (topic) => req('/blocks/cancel', { method: 'POST', query: { topic } })
|
||||
|
||||
export async function deleteBlocks(topic) {
|
||||
await fetch(`${BASE}/blocks?topic=${encodeURIComponent(topic)}`, { method: 'DELETE' })
|
||||
}
|
||||
export const deleteBlocks = (topic) => req('/blocks', { method: 'DELETE', query: { topic } })
|
||||
|
||||
// Lauf-Historie (Blocks + Guide): Zeitspanne, Agenten, Tokens, Fehler je run_id.
|
||||
export const fetchRuns = (topic, limit = 10) => req('/runs', { query: { topic, limit } })
|
||||
|
||||
// --- Block-Learning: Chat, Exam ---
|
||||
|
||||
export async function fetchBlockLearnState(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/learnstate?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchBlockLearnState = (topic) => req('/blocks/learnstate', { query: { topic } })
|
||||
|
||||
export async function chatBlock({ topic, block, section, section_compact = '', messages, provider }) {
|
||||
const res = await fetch(`${BASE}/blocks/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, block, section, section_compact, messages, provider }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const chatBlock = ({ topic, block, section, section_compact = '', messages, provider }) =>
|
||||
req('/blocks/chat', { method: 'POST', body: { topic, block, section, section_compact, messages, provider } })
|
||||
|
||||
export async function examBlock({
|
||||
export const examBlock = ({
|
||||
topic, block, section, section_compact = '', provider,
|
||||
action = 'question', question = '', last_rating = '', avoid = [],
|
||||
asked_again = false, reason = '', pattern = '', cap = 6, messages = [], thorough = false,
|
||||
selection = [], correct = [], solution = '', alternatives = [], input = '', schwer = false,
|
||||
}) {
|
||||
const res = await fetch(`${BASE}/blocks/exam`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, block, section, section_compact, action, question, last_rating, avoid, asked_again, reason, pattern, cap, messages, provider, thorough, selection, correct, solution, alternatives, input, schwer }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
}) => req('/blocks/exam', {
|
||||
method: 'POST',
|
||||
body: { topic, block, section, section_compact, action, question, last_rating, avoid, asked_again, reason, pattern, cap, messages, provider, thorough, selection, correct, solution, alternatives, input, schwer },
|
||||
})
|
||||
|
||||
export async function fetchQuestionPattern(topic, block) {
|
||||
const res = await fetch(`${BASE}/blocks/question-pattern?topic=${encodeURIComponent(topic)}&block=${encodeURIComponent(block)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchQuestionPattern = (topic, block) =>
|
||||
req('/blocks/question-pattern', { query: { topic, block } })
|
||||
|
||||
export async function fetchTopicProgress(topic) {
|
||||
const res = await fetch(`${BASE}/topics/progress?topic=${encodeURIComponent(topic)}`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchTopicProgress = (topic) => req('/topics/progress', { query: { topic } })
|
||||
|
||||
export async function fetchStats() {
|
||||
const res = await fetch(`${BASE}/stats`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchStats = () => req('/stats')
|
||||
|
||||
export async function fetchProviders() {
|
||||
const res = await fetch(`${BASE}/providers`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchProviders = () => req('/providers')
|
||||
|
||||
export async function fetchFolders(kind) {
|
||||
const res = await fetch(`${BASE}/folders?kind=${encodeURIComponent(kind)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchFolders = (kind) => req('/folders', { query: { kind } })
|
||||
|
||||
export async function fetchSource(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/source?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchSource = (topic) => req('/blocks/source', { query: { topic } })
|
||||
|
||||
export async function updateSource(topic, { type, ort = '', spec = '' }) {
|
||||
const res = await fetch(`${BASE}/blocks/source`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, type, location: ort, spec }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const updateSource = (topic, { type, ort = '', spec = '' }) =>
|
||||
req('/blocks/source', { method: 'PUT', body: { topic, type, location: ort, spec } })
|
||||
|
||||
export async function fetchBlocksCompleteness(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/completeness?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchBlocksCompleteness = (topic) => req('/blocks/completeness', { query: { topic } })
|
||||
|
||||
export async function fetchBlocksOverview(topic) {
|
||||
const res = await fetch(`${BASE}/blocks/overview?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchBlocksOverview = (topic) => req('/blocks/overview', { query: { topic } })
|
||||
|
||||
export async function cancelGuide(id) {
|
||||
await fetch(`${BASE}/guides/${id}/cancel`, { method: 'POST' })
|
||||
}
|
||||
export const cancelGuide = (id) => req(`/guides/${id}/cancel`, { method: 'POST' })
|
||||
|
||||
export async function deleteGuide(id, slots = false) {
|
||||
await fetch(`${BASE}/guides/${id}${slots ? '?slots=1' : ''}`, { method: 'DELETE' })
|
||||
}
|
||||
export const deleteGuide = (id, slots = false) =>
|
||||
req(`/guides/${id}`, { method: 'DELETE', query: slots ? { slots: 1 } : undefined })
|
||||
|
||||
export async function fetchGuideContent(id, level = 4) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/content?level=${level}`)
|
||||
if (!res.ok) throw new Error(`Content not available (${res.status})`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchGuideContent = (id, level = 4) => req(`/guides/${id}/content`, { query: { level } })
|
||||
|
||||
// Übungspool: fällige + neue Flashcards des Themas (Leitner, ein Stapel).
|
||||
export async function fetchPracticeDeck(topic) {
|
||||
const res = await fetch(`${BASE}/practice/deck?topic=${encodeURIComponent(topic)}`)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const fetchPracticeDeck = (topic) => req('/practice/deck', { query: { topic } })
|
||||
|
||||
// Leitner-Schritt buchen (correct = „Gewusst").
|
||||
export async function answerPracticeCard({ topic, block_norm, sub_norm, correct }) {
|
||||
const res = await fetch(`${BASE}/practice/answer`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ topic, block_norm, sub_norm, correct }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const answerPracticeCard = ({ topic, block_norm, sub_norm, correct }) =>
|
||||
req('/practice/answer', { method: 'POST', body: { topic, block_norm, sub_norm, correct } })
|
||||
|
||||
// Einen Markdown-Block on-demand gegen die Guide-Rules prüfen (Fokus, Rechtsklick).
|
||||
export async function pruefeBlock(id, { block, spot, snippet, hint = '', provider }) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/block/pruefen`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ block, spot, snippet, hint, provider }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const pruefeBlock = (id, { block, spot, snippet, hint = '', provider }) =>
|
||||
req(`/guides/${id}/block/pruefen`, { method: 'POST', body: { block, spot, snippet, hint, provider } })
|
||||
|
||||
// Geprüften Block persistent übernehmen (alt → new im jeweiligen Feld).
|
||||
export async function uebernehmeBlock(id, { block, spot, alt, revised, provider }) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/block/uebernehmen`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ block, spot, alt, revised, provider }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const uebernehmeBlock = (id, { block, spot, alt, revised, provider }) =>
|
||||
req(`/guides/${id}/block/uebernehmen`, { method: 'POST', body: { block, spot, alt, revised, provider } })
|
||||
|
||||
// Reset a block's learning progress to zero (score/streak/flags/open question).
|
||||
export async function resetBlockProgress(topic, block) {
|
||||
const res = await fetch(`${BASE}/blocks/progress?topic=${encodeURIComponent(topic)}&block=${encodeURIComponent(block)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
export const resetBlockProgress = (topic, block) =>
|
||||
req('/blocks/progress', { method: 'DELETE', query: { topic, block } })
|
||||
|
||||
export async function fetchTopics() {
|
||||
const res = await fetch(`${BASE}/topics`)
|
||||
return res.json()
|
||||
}
|
||||
export const fetchTopics = () => req('/topics')
|
||||
|
||||
export async function createTopic(name) {
|
||||
await fetch(`${BASE}/topics`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
}
|
||||
export const createTopic = (name) => req('/topics', { method: 'POST', body: { name } })
|
||||
|
||||
export async function deleteTopic(name) {
|
||||
await fetch(`${BASE}/topics?topic=${encodeURIComponent(name)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function chatGuide(id, { section, outline, messages, provider = 'claude' }) {
|
||||
const res = await fetch(`${BASE}/guides/${id}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ section, outline, messages, provider }),
|
||||
})
|
||||
return res.json()
|
||||
}
|
||||
export const deleteTopic = (name) => req('/topics', { method: 'DELETE', query: { topic: name } })
|
||||
|
||||
export const chatGuide = (id, { section, outline, messages, provider = 'claude' }) =>
|
||||
req(`/guides/${id}/chat`, { method: 'POST', body: { section, outline, messages, provider } })
|
||||
|
||||
7
frontend/src/assets/shared.css
Normal file
7
frontend/src/assets/shared.css
Normal file
@@ -0,0 +1,7 @@
|
||||
/* Geteilte, komponentenübergreifende Stile (global, NICHT scoped). */
|
||||
|
||||
/* Live-Puls für „läuft"-Indikatoren — vorher als bk-/gb-/gen-/gen-side-pulse 4× kopiert. */
|
||||
@keyframes pulse-soft {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
@@ -207,12 +207,12 @@ watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}
|
||||
<p v-if="suggestions[b.i].error" class="bv-fehler">{{ suggestions[b.i].error }}</p>
|
||||
<div class="markdown bv-new" v-html="renderMarkdown(suggestions[b.i].revised)"></div>
|
||||
<div class="bv-aktionen">
|
||||
<button class="bv-btn ja" title="Apply" @click="applyBlock(b.i)">✓</button>
|
||||
<button class="bv-btn" title="Discard" @click="discardBlock(b.i)">✗</button>
|
||||
<button class="bv-btn ja" title="Übernehmen" @click="applyBlock(b.i)">✓</button>
|
||||
<button class="bv-btn" title="Verwerfen" @click="discardBlock(b.i)">✗</button>
|
||||
<button class="bv-btn" :class="{ aktiv: suggestions[b.i].editOpen }" title="Add hint" @click="editBlock(b.i)">✏️</button>
|
||||
</div>
|
||||
<div v-if="suggestions[b.i].editOpen" class="bv-edit">
|
||||
<input v-model="suggestions[b.i].hint" class="bv-input" placeholder="Extra info → check again" @keyup.enter="sendBlockEdit(b.i)" />
|
||||
<input v-model="suggestions[b.i].hint" class="bv-input" placeholder="Zusatzinfo → erneut prüfen" @keyup.enter="sendBlockEdit(b.i)" />
|
||||
<button class="bv-btn ja" title="Check again" @click="sendBlockEdit(b.i)">↻</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -282,10 +282,6 @@ watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}
|
||||
font-size: 0.72rem; font-weight: 600;
|
||||
border-radius: 999px; border: 1px solid; white-space: nowrap;
|
||||
}
|
||||
.stand-badge.gruen { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
|
||||
.stand-badge.lila { background: color-mix(in srgb, #8b5cf6 16%, var(--panel)); border-color: #8b5cf6; color: #6d28d9; }
|
||||
.stand-badge.gold { background: color-mix(in srgb, #d4af37 20%, var(--panel)); border-color: #d4af37; color: #8a6d12; }
|
||||
|
||||
/* Experience bar on top: fills from the left — gold (mastered) → purple (understood) → green (completed). */
|
||||
.fokus-xp { position: relative; display: flex; height: 8px; background: var(--panel-soft); }
|
||||
/* 9 divider lines every 10% → 10 visible segments (fill stays continuous). */
|
||||
@@ -302,9 +298,6 @@ watch(() => `${props.block.title}|${props.block.md}|${props.block.compact || ''}
|
||||
);
|
||||
}
|
||||
.xp-seg { height: 100%; transition: width 0.3s ease; }
|
||||
.xp-seg.gold { background: #d4af37; }
|
||||
.xp-seg.lila { background: #8b5cf6; }
|
||||
.xp-seg.gruen { background: var(--success-border); }
|
||||
.fokus-title { font-weight: 600; font-size: 0.95rem; margin-left: 0.5rem; }
|
||||
.fokus-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
|
||||
@@ -5,12 +5,13 @@ import { usePruefSlot } from '../pruefungCache.js'
|
||||
import { renderMarkdown, renderMarkdownInline } from '../markdown.js'
|
||||
import { stufeFuer, malusRegel } from '../levels.js'
|
||||
import { useChat, istUnten } from '../composables/useChat.js'
|
||||
import ChatTranscript from './ChatTranscript.vue'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
block: { type: String, required: true },
|
||||
section: { type: String, default: '' }, // detailed version
|
||||
sectionKompakt: { type: String, default: '' }, // compact version (key points) — exam/chat context
|
||||
sectionCompact: { type: String, default: '' }, // compact version (key points) — exam/chat context
|
||||
provider: { type: String, default: 'claude' },
|
||||
status: { type: Object, default: null }, // {good_answers, streak, completed, understood, mastered}
|
||||
cap: { type: Number, default: 6 }, // score cap = max of the highest format (6/12/18/30)
|
||||
@@ -70,7 +71,7 @@ function tabClick(tab) {
|
||||
|
||||
// --- Block chat (ephemeral) ---
|
||||
const chat = useChat((msgs) => chatBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
|
||||
messages: msgs, provider: props.provider,
|
||||
}))
|
||||
|
||||
@@ -130,7 +131,7 @@ async function examSend(payload, onOk) {
|
||||
examScroll()
|
||||
try {
|
||||
const res = await examBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
|
||||
provider: props.provider, messages: examDialog(), ...payload,
|
||||
})
|
||||
if (run !== examRun) return
|
||||
@@ -243,7 +244,7 @@ function buildSingleQuestion(mode = nextMode()) {
|
||||
const pattern = takePattern()
|
||||
const base = {
|
||||
topic: props.topic, block: props.block, section: props.section,
|
||||
section_compact: props.sectionKompakt, provider: props.provider,
|
||||
section_compact: props.sectionCompact, provider: props.provider,
|
||||
}
|
||||
if (form === 'quiz' && pattern) {
|
||||
return examBlock({ ...base, action: 'quiz_question', pattern }) // single choice, level controls
|
||||
@@ -381,7 +382,7 @@ async function quizAnswer() {
|
||||
try {
|
||||
const correct = q.options.map((o, i) => (o.correct ? i : -1)).filter((i) => i >= 0)
|
||||
const res = await examBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
|
||||
provider: props.provider, action: 'quiz_answer', question: q.question, cap: props.cap,
|
||||
selection: q.gewaehlt, correct, schwer: q.schwer,
|
||||
})
|
||||
@@ -412,7 +413,7 @@ async function clozeAnswer() {
|
||||
? { schwer: true, solution: l.solution, alternatives: l.alternatives, input: l.input }
|
||||
: { schwer: false, selection: l.gewaehlt, correct: l.options.map((o, i) => (o.correct ? i : -1)).filter((i) => i >= 0) }
|
||||
const res = await examBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
|
||||
provider: props.provider, action: 'gap_answer', question: l.sentence, cap: props.cap, ...specific,
|
||||
})
|
||||
l.done = true; l.points = res.points; l.rating = res.rating; l.feedback = res.feedback
|
||||
@@ -451,7 +452,7 @@ async function quickEvaluate() {
|
||||
examScroll()
|
||||
try {
|
||||
const res = await examBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
|
||||
provider: props.provider, messages: examDialog(), action: 'answer', ...ratingPayload(),
|
||||
})
|
||||
if (mine !== evalRun) return
|
||||
@@ -477,7 +478,7 @@ async function preciseEvaluate(thorough = false, reason = '') {
|
||||
if (thorough) examLoading.value = true
|
||||
try {
|
||||
const res = await examBlock({
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionKompakt,
|
||||
topic: props.topic, block: props.block, section: props.section, section_compact: props.sectionCompact,
|
||||
provider: props.provider, messages: examDialog(), action: 'answer_check', ...ratingPayload(), thorough, reason,
|
||||
})
|
||||
applyExam(res)
|
||||
@@ -629,26 +630,8 @@ function onExamKey(e) {
|
||||
<div v-if="mode === 'full' && activeTab" class="bp-panel">
|
||||
<!-- Block chat -->
|
||||
<div v-if="activeTab === 'chat'">
|
||||
<div :ref="chat.messagesEl" class="bp-messages" @scroll="chat.onScroll">
|
||||
<p v-if="!chat.messages.value.length" class="bp-hint">Ask something about this block. The history is not saved.</p>
|
||||
<template v-for="(m, i) in chat.messages.value" :key="i">
|
||||
<div v-if="m.role === 'assistant'" class="bp-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
|
||||
<div v-else class="bp-msg user">{{ m.content }}</div>
|
||||
</template>
|
||||
<div v-if="chat.loading.value" class="bp-msg assistant bp-typing">Thinking…</div>
|
||||
</div>
|
||||
<div class="bp-input">
|
||||
<textarea
|
||||
:ref="chat.inputEl"
|
||||
v-model="chat.input.value"
|
||||
rows="2"
|
||||
placeholder="Question about the block…"
|
||||
@keydown.enter.exact.prevent="chat.send"
|
||||
></textarea>
|
||||
<button :disabled="!chat.input.value.trim() && !chat.loading.value" :class="{ cancel: chat.loading.value }" @click="chat.send">
|
||||
{{ chat.loading.value ? '✕' : '➤' }}
|
||||
</button>
|
||||
</div>
|
||||
<ChatTranscript :chat="chat" hint="Frag etwas zu diesem Baustein. Der Verlauf wird nicht gespeichert."
|
||||
placeholder="Frage zum Baustein…" />
|
||||
</div>
|
||||
|
||||
<!-- Exam: guided dialog -->
|
||||
@@ -663,7 +646,7 @@ function onExamKey(e) {
|
||||
<!-- Quiz: question + multiple choice (widget stays even at the cap — practice without points) -->
|
||||
<template v-if="shownForm === 'quiz'">
|
||||
<div v-if="!quizCurrent" class="bp-actions">
|
||||
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button>
|
||||
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Abbrechen</button>
|
||||
<button v-else class="bp-action primary" @click="requestQuestion">Request question</button>
|
||||
</div>
|
||||
<div v-else class="bp-quiz">
|
||||
@@ -696,7 +679,7 @@ function onExamKey(e) {
|
||||
<!-- Cloze: sentence with gap + input -->
|
||||
<template v-else-if="shownForm === 'gaptext'">
|
||||
<div v-if="!clozeCurrent" class="bp-actions">
|
||||
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button>
|
||||
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Abbrechen</button>
|
||||
<button v-else class="bp-action primary" @click="requestQuestion">Request question</button>
|
||||
</div>
|
||||
<div v-else class="bp-gap">
|
||||
@@ -707,7 +690,7 @@ function onExamKey(e) {
|
||||
id="bp-gap-input"
|
||||
v-model="clozeCurrent.input"
|
||||
:disabled="clozeCurrent.done"
|
||||
placeholder="Term for the gap…"
|
||||
placeholder="Begriff für die Lücke…"
|
||||
@keyup.enter="clozeAnswer"
|
||||
/>
|
||||
<p v-if="clozeCurrent.schwer && clozeCurrent.done && !clozeCurrent.feedback.startsWith('Correct')" class="bp-gap-solution">Solution: <span class="markdown" v-html="renderMarkdownInline(clozeCurrent.solution)"></span></p>
|
||||
@@ -744,7 +727,7 @@ function onExamKey(e) {
|
||||
<div v-if="m.kind === 'feedback'" class="bp-feedback" :class="m.rating" title="Click: check thoroughly" @click="openThorough(m)">
|
||||
<span v-if="m.points != null" class="bp-tier">{{ pointsLabel(m.points) }}</span>{{ m.content }}<span v-if="!m.checked" class="bp-pruefend"> · being checked…</span>
|
||||
<div v-if="thoroughMsg === m" class="bp-thorough" @click.stop>
|
||||
<input id="bp-thorough-input" v-model="thoroughText" placeholder="Why unsatisfied? (optional)" @keyup.enter="submitThorough" />
|
||||
<input id="bp-thorough-input" v-model="thoroughText" placeholder="Warum unzufrieden? (optional)" @keyup.enter="submitThorough" />
|
||||
<button class="bp-action primary" @click="submitThorough">Check thoroughly</button>
|
||||
<button class="bp-action" @click="thoroughMsg = null">×</button>
|
||||
</div>
|
||||
@@ -757,7 +740,7 @@ function onExamKey(e) {
|
||||
</div>
|
||||
|
||||
<div v-if="examPhase === 'idle'" class="bp-actions">
|
||||
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button>
|
||||
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Abbrechen</button>
|
||||
<button v-else class="bp-action primary" @click="requestQuestion">Request question</button>
|
||||
</div>
|
||||
|
||||
@@ -767,11 +750,11 @@ function onExamKey(e) {
|
||||
ref="examInputEl"
|
||||
v-model="examInput"
|
||||
rows="2"
|
||||
placeholder="Answer — or ask if unclear…"
|
||||
placeholder="Antwort — oder nachfragen…"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="bp-actions">
|
||||
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Cancel</button>
|
||||
<button v-if="examLoading" class="bp-action cancel" title="ESC" @click="examCancel">Abbrechen</button>
|
||||
<template v-else-if="examPhase === 'question_offen'">
|
||||
<button class="bp-action" title="Alt+1" :disabled="!examInput.trim()" @click="askFollowUp"><span class="bp-kbd">1</span>Ask</button>
|
||||
<button class="bp-action primary" title="Alt+2" :disabled="!examInput.trim()" @click="submitAnswer"><span class="bp-kbd">2</span>Submit answer</button>
|
||||
@@ -831,8 +814,8 @@ function onExamKey(e) {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.bp-chip.done { background: var(--success-soft); border-color: var(--success-border); color: var(--success); }
|
||||
.bp-chip.lila { background: color-mix(in srgb, #8b5cf6 16%, var(--panel)); border-color: #8b5cf6; color: #6d28d9; }
|
||||
.bp-chip.gold { background: color-mix(in srgb, #d4af37 20%, var(--panel)); border-color: #d4af37; color: #8a6d12; }
|
||||
.bp-chip.lila { background: color-mix(in srgb, var(--level-expert) 16%, var(--panel)); border-color: var(--level-expert); color: #6d28d9; }
|
||||
.bp-chip.gold { background: color-mix(in srgb, var(--level-master) 20%, var(--panel)); border-color: var(--level-master); color: #8a6d12; }
|
||||
|
||||
.bp-panel {
|
||||
margin-top: 0.6rem;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { fetchBlocksOverview, fetchBlocksCompleteness } from '../api.js'
|
||||
import { usePolling } from '../composables/usePolling.js'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
@@ -26,13 +27,10 @@ async function loadCompleteness() {
|
||||
watch(() => [props.topic, props.ready, props.generating], loadCompleteness, { immediate: true })
|
||||
|
||||
// Während einer Generierung wächst das Grid live nach (leichter Overview-Poll,
|
||||
// das Kanban-Board selbst lebt in der Generierungs-View).
|
||||
let timer = null
|
||||
function startPoll() { stopPoll(); timer = setInterval(load, 5000) }
|
||||
function stopPoll() { if (timer) { clearInterval(timer); timer = null } }
|
||||
// das Kanban-Board selbst lebt in der Generierungs-View). Visibility-Pause via usePolling.
|
||||
const { start: startPoll } = usePolling(load, () => props.generating, 5000)
|
||||
watch(() => props.topic, () => { items.value = []; load() }, { immediate: true })
|
||||
watch(() => props.generating, (g) => { if (g) startPoll(); else { stopPoll(); load() } }, { immediate: true })
|
||||
onUnmounted(stopPoll)
|
||||
watch(() => props.generating, (g) => { if (g) startPoll(); else load() }, { immediate: true })
|
||||
|
||||
// ── Fertige Blöcke (Grid) ──────────────────────────────────────────────────────
|
||||
const LEVELS = [
|
||||
@@ -73,10 +71,10 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
|
||||
<div class="bk-view">
|
||||
<header class="bk-head">
|
||||
<h1>{{ topic }}</h1>
|
||||
<span class="bk-sub">Blocks overview</span>
|
||||
<span class="bk-sub">Bausteine-Übersicht</span>
|
||||
<span v-if="items.length" class="bk-count">{{ items.length }} Blocks · {{ subTotal }} Subblocks</span>
|
||||
<span class="bk-spacer"></span>
|
||||
<button class="bk-close" title="Close" @click="emit('close')">✕</button>
|
||||
<button class="bk-close" title="Schließen" @click="emit('close')">✕</button>
|
||||
</header>
|
||||
|
||||
<button v-if="generating" class="bk-banner" @click="emit('openGeneration')">
|
||||
@@ -107,9 +105,9 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="loading" class="bk-empty-state">Loading…</div>
|
||||
<div v-if="loading" class="bk-empty-state">Lädt…</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-if="!items.length" class="bk-empty-state">Noch keine Bausteine.</div>
|
||||
|
||||
<div v-else class="bk-grid">
|
||||
<article
|
||||
@@ -126,12 +124,12 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
|
||||
<span class="bk-level-label">{{ g.label }}</span>
|
||||
<ul>
|
||||
<li v-for="s in g.subs" :key="s.title" :class="{ rand: s.relevance === 'peripheral' }">
|
||||
{{ s.title }}<span v-if="s.relevance === 'peripheral'" class="rand-tag" title="Peripheral topic — comes later in the 'Rest'">Edge</span>
|
||||
{{ s.title }}<span v-if="s.relevance === 'peripheral'" class="rand-tag" title="Randthema — kommt später im „Rest">Rand</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="bk-no-subs">No subblocks.</p>
|
||||
<p v-else class="bk-no-subs">Keine Subbausteine.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,9 +178,8 @@ const subTotal = computed(() => items.value.reduce((n, b) => n + (b.subblocks?.l
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: bk-pulse 1.2s ease-in-out infinite;
|
||||
animation: pulse-soft 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes bk-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
|
||||
|
||||
|
||||
|
||||
88
frontend/src/components/ChatTranscript.vue
Normal file
88
frontend/src/components/ChatTranscript.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup>
|
||||
// Präsentationaler Chat (Nachrichtenliste + Typing + Eingabe). Die Mechanik (send/cancel/
|
||||
// scroll/focus) lebt in useChat; hier wird nur das `chat`-Objekt gerendert. Ersetzt die
|
||||
// zeichengleichen Chat-Blöcke in BlockPanel und TopicDetail.
|
||||
import { renderMarkdown } from '../markdown.js'
|
||||
|
||||
const props = defineProps({
|
||||
chat: { type: Object, required: true }, // Rückgabe von useChat()
|
||||
hint: { type: String, default: '' }, // Platzhaltertext bei leerer Historie
|
||||
placeholder: { type: String, default: '' },
|
||||
rows: { type: Number, default: 2 },
|
||||
autoGrow: { type: Boolean, default: false },
|
||||
maxHeight: { type: String, default: '320px' },
|
||||
})
|
||||
|
||||
function onInput() {
|
||||
if (props.autoGrow) props.chat.autoGrow()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :ref="chat.messagesEl" class="ct-messages" :style="{ maxHeight }" @scroll="chat.onScroll">
|
||||
<p v-if="!chat.messages.value.length" class="ct-hint">{{ hint }}</p>
|
||||
<template v-for="(m, i) in chat.messages.value" :key="i">
|
||||
<div v-if="m.role === 'assistant'" class="ct-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
|
||||
<div v-else class="ct-msg user">{{ m.content }}</div>
|
||||
</template>
|
||||
<div v-if="chat.loading.value" class="ct-msg assistant ct-typing">Denkt nach…</div>
|
||||
</div>
|
||||
<div class="ct-input">
|
||||
<textarea
|
||||
:ref="chat.inputEl"
|
||||
v-model="chat.input.value"
|
||||
:rows="rows"
|
||||
:placeholder="placeholder"
|
||||
@input="onInput"
|
||||
@keydown.enter.exact.prevent="chat.send"
|
||||
></textarea>
|
||||
<button
|
||||
:disabled="!chat.input.value.trim() && !chat.loading.value"
|
||||
:class="{ cancel: chat.loading.value }"
|
||||
:title="chat.loading.value ? 'Abbrechen' : 'Senden'"
|
||||
@click="chat.send"
|
||||
>{{ chat.loading.value ? '✕' : '➤' }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* flex:1 füllt ein Panel mit fester Höhe (TopicDetail); maxHeight begrenzt sonst (BlockPanel-Tab). */
|
||||
.ct-messages { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 0.4rem; overflow-y: auto; padding: 0.2rem; }
|
||||
.ct-hint { font-size: 0.85rem; color: var(--text-muted); margin: 0 0 0.5rem; }
|
||||
.ct-msg {
|
||||
max-width: 85%;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
.ct-msg.user { align-self: flex-end; background: var(--accent); color: var(--on-accent); white-space: pre-wrap; }
|
||||
.ct-msg.assistant { align-self: flex-start; background: var(--panel); border: 1px solid var(--border); }
|
||||
.ct-typing { color: var(--text-faint); font-style: italic; }
|
||||
.ct-input { display: flex; gap: 0.4rem; margin-top: 0.55rem; align-items: flex-end; }
|
||||
.ct-input textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--input-bg, var(--panel));
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.ct-input textarea:focus { outline: none; border-color: var(--accent); }
|
||||
.ct-input button {
|
||||
flex: 0 0 auto;
|
||||
width: 38px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.ct-input button:disabled { opacity: 0.5; cursor: default; }
|
||||
.ct-input button.cancel { background: var(--danger); }
|
||||
</style>
|
||||
@@ -1,8 +1,12 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { fetchBlocksBoard, runQa, runRepair } from '../api.js'
|
||||
import { fetchBlocksBoard, fetchRuns, runQa, runRepair } from '../api.js'
|
||||
import { usePolling } from '../composables/usePolling.js'
|
||||
import { useConfirm } from '../composables/useConfirm.js'
|
||||
import { fmtRuntime, fmtTokens } from '../format.js'
|
||||
import KanbanBoard from './KanbanBoard.vue'
|
||||
import GuideBoardSection from './GuideBoardSection.vue'
|
||||
import ProgressBar from './ProgressBar.vue'
|
||||
|
||||
const props = defineProps({
|
||||
topic: { type: String, required: true },
|
||||
@@ -15,48 +19,101 @@ const props = defineProps({
|
||||
const emit = defineEmits(['close', 'resetStage', 'restartAll', 'continueAll', 'addResearch',
|
||||
'requeueDead', 'removeAll', 'cancel', 'cancelGuide', 'startGuide', 'resetGuideStage', 'preview', 'removeFormat', 'restartCard', 'resetGuideCard'])
|
||||
|
||||
// ── Blocks-Pipeline (Poll 1.2s solange generiert) ──────────────────────────────
|
||||
// ── Blocks-Pipeline (Poll 1.2s solange generiert; Visibility-Pause via usePolling) ──
|
||||
const board = ref(null)
|
||||
let timer = null
|
||||
const pollError = ref(null)
|
||||
|
||||
async function pollBoard() {
|
||||
try {
|
||||
board.value = await fetchBlocksBoard(props.topic)
|
||||
} catch { /* Board noch leer */ }
|
||||
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
|
||||
}
|
||||
}
|
||||
function startPoll() { stopPoll(); timer = setInterval(pollBoard, 1200) }
|
||||
function stopPoll() { if (timer) { clearInterval(timer); timer = null } }
|
||||
const { start: startPoll } = usePolling(pollBoard, () => props.generating, 1200)
|
||||
|
||||
watch(() => props.topic, () => { board.value = null; pollBoard() }, { immediate: true })
|
||||
watch(() => props.generating, (g) => {
|
||||
if (g) startPoll()
|
||||
else { stopPoll(); pollBoard() } // Endstand nachladen
|
||||
else pollBoard() // Endstand 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 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)
|
||||
if (!total) return { value: 0, done: 0, total: 0 }
|
||||
const last = Math.max(1, cols.length - 1)
|
||||
let acc = 0
|
||||
cols.forEach((c, i) => { acc += (TERMINAL.has(c.key) ? 1 : i / last) * c.total })
|
||||
const done = cols.filter((c) => TERMINAL.has(c.key)).reduce((n, c) => n + c.total, 0)
|
||||
return { value: acc / total, done, total }
|
||||
}
|
||||
const inventoryProgress = computed(() => boardProgress(inventoryCols.value))
|
||||
const artefactProgress = computed(() => boardProgress(artefactCols.value))
|
||||
const scopeGrowing = computed(() => props.generating && (board.value?.agents?.length || 0) > 0)
|
||||
|
||||
// ── Laufzeit + Tokens aus /api/runs (5s-Takt, unabhängig vom 1,2s-Board-Poll) ──────
|
||||
const run = ref(null)
|
||||
const now = ref(Date.now())
|
||||
let clock = null
|
||||
async function loadRun() {
|
||||
try {
|
||||
const { runs } = await fetchRuns(props.topic, 1)
|
||||
run.value = runs[0] || null
|
||||
} catch { run.value = null }
|
||||
}
|
||||
const { start: startRunPoll } = usePolling(loadRun, () => props.generating, 5000)
|
||||
const runLaufzeit = computed(() => {
|
||||
if (!run.value?.start) return null
|
||||
const start = Date.parse(run.value.start)
|
||||
const ende = run.value.aktiv ? now.value : Date.parse(run.value.ende || run.value.start)
|
||||
return fmtRuntime((ende - start) / 1000)
|
||||
})
|
||||
const runTokens = computed(() => {
|
||||
const t = run.value?.tokens
|
||||
return t ? fmtTokens((t.input || 0) + (t.output || 0)) : null
|
||||
})
|
||||
const runTokenTitel = computed(() => {
|
||||
const t = run.value?.tokens || {}
|
||||
return `Input ${t.input || 0} · Output ${t.output || 0} · Cache ${(t.cache_read || 0) + (t.cache_write || 0)}`
|
||||
})
|
||||
watch(() => props.generating, (g) => {
|
||||
if (g) { startRunPoll(); if (!clock) clock = setInterval(() => { now.value = Date.now() }, 1000) }
|
||||
else { loadRun(); if (clock) { clearInterval(clock); clock = null } } // Endstand
|
||||
}, { immediate: true })
|
||||
watch(() => props.topic, () => { run.value = null; loadRun() })
|
||||
onUnmounted(() => { if (clock) clearInterval(clock) })
|
||||
|
||||
// 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 confirm = ref(null) // 2-Klick-Bestätigung für destruktive Aktionen
|
||||
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
|
||||
confirm.value = null
|
||||
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
|
||||
confirm.value = null
|
||||
resetConfirm()
|
||||
sel.value = null
|
||||
selCard.value = selCard.value?.card_id === k.card_id ? null : k
|
||||
}
|
||||
@@ -64,13 +121,9 @@ function cardClick(k) {
|
||||
function restartCard() {
|
||||
const k = selCard.value
|
||||
selCard.value = null
|
||||
confirm.value = null
|
||||
resetConfirm()
|
||||
later(() => emit('restartCard', k.card_id))
|
||||
}
|
||||
function arm(action, fn) {
|
||||
if (confirm.value === action) { confirm.value = null; fn() }
|
||||
else confirm.value = action
|
||||
}
|
||||
function later(fn) { // Aktion emitten, Board kurz danach neu laden (kein generating-Poll aktiv)
|
||||
fn()
|
||||
setTimeout(pollBoard, 600)
|
||||
@@ -79,7 +132,7 @@ function later(fn) { // Aktion emitten, Board kurz danach neu laden (kein gener
|
||||
function resetHere(restart) {
|
||||
const s = sel.value
|
||||
sel.value = null
|
||||
confirm.value = null
|
||||
resetConfirm()
|
||||
later(() => emit('resetStage', { board: s.board, stage: s.key, restart }))
|
||||
}
|
||||
|
||||
@@ -125,18 +178,21 @@ async function repairClick() {
|
||||
<h1>{{ topic }}</h1>
|
||||
<span class="gen-sub">Generierung</span>
|
||||
<span class="gen-spacer"></span>
|
||||
<button class="gen-close" title="Close" @click="emit('close')">✕</button>
|
||||
<button class="gen-close" title="Schließen" @click="emit('close')">✕</button>
|
||||
</header>
|
||||
|
||||
<div v-if="qa && qa.pausiert" class="qa-pause">
|
||||
<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>
|
||||
<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>
|
||||
@@ -144,18 +200,18 @@ async function repairClick() {
|
||||
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 ? '+ Research' : 'Generate' }}</button>
|
||||
<button v-if="partial" class="gen-act" @click="emit('continueAll')">Continue</button>
|
||||
<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"
|
||||
class="gen-act danger"
|
||||
:class="{ armed: confirm === 'remove' }"
|
||||
@click="arm('remove', () => later(() => emit('removeAll')))"
|
||||
>{{ confirm === 'remove' ? 'Sure?' : 'Remove' }}</button>
|
||||
:class="{ armed: isArmed('remove') }"
|
||||
@click="armOrRun('remove', () => later(() => emit('removeAll')))"
|
||||
>{{ isArmed('remove') ? 'Sicher?' : 'Entfernen' }}</button>
|
||||
</div>
|
||||
<div v-else class="gen-actions">
|
||||
<button class="gen-act" @click="emit('addResearch')">+ Research</button>
|
||||
<button class="gen-act danger" @click="emit('cancel')">Cancel</button>
|
||||
<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"
|
||||
@@ -167,9 +223,12 @@ async function repairClick() {
|
||||
|
||||
<div class="gen-board-label">
|
||||
Inventar
|
||||
<span v-if="qa" class="qa-note" :class="qa.note >= qa.schwelle ? 'ok' : 'bad'"
|
||||
<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>
|
||||
</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 || []"
|
||||
@@ -184,6 +243,9 @@ async function repairClick() {
|
||||
:class="qa.note_artefakte >= qa.schwelle ? 'ok' : 'bad'"
|
||||
title="Beleg-Quote + verwaiste Artefakte">QA {{ qa.note_artefakte.toFixed(1) }}/10</span>
|
||||
</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"
|
||||
@@ -196,14 +258,14 @@ async function repairClick() {
|
||||
|
||||
<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: confirm === 'card' }" @click="confirm === 'card' ? restartCard() : confirm = 'card'">{{ confirm === 'card' ? 'Sure?' : '↻ Karte neu generieren' }}</button>
|
||||
<button class="gen-act ghost" @click="selCard = null; confirm = null">Abbrechen</button>
|
||||
<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: confirm === 'reset' }" @click="arm('reset', () => resetHere(false))">{{ confirm === 'reset' ? 'Sure?' : '✕ nur zurücksetzen' }}</button>
|
||||
<button class="gen-act ghost" @click="sel = null; confirm = null">Abbrechen</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>
|
||||
</section>
|
||||
|
||||
@@ -286,9 +348,8 @@ async function repairClick() {
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: gen-pulse 1.2s ease-in-out infinite;
|
||||
animation: pulse-soft 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes gen-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
|
||||
.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; }
|
||||
@@ -328,6 +389,9 @@ async function repairClick() {
|
||||
.qa-note.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #16a34a; }
|
||||
.qa-note.bad { background: color-mix(in srgb, #ef4444 18%, transparent); color: #dc2626; }
|
||||
.repair-info { font-size: 0.78rem; color: var(--text-muted); }
|
||||
.gen-poll-error { color: var(--danger); font-size: 0.82rem; padding: 0.3rem 0; }
|
||||
.gen-run { font-size: 0.8rem; color: var(--text-muted); white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.gen-run-fail { font-size: 0.78rem; color: var(--danger); white-space: nowrap; }
|
||||
.qa-pause {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { fetchGuideBoard, repairGuideBoard } from '../api.js'
|
||||
import { fetchGuideBoard, fetchRuns, repairGuideBoard } 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 },
|
||||
@@ -11,42 +15,76 @@ const props = defineProps({
|
||||
const emit = defineEmits(['cancelGuide', 'startGuide', 'resetStage', 'preview', 'removeFormat', 'resetCard'])
|
||||
|
||||
const board = ref(null)
|
||||
let timer = null
|
||||
const pollError = ref(null)
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
board.value = await fetchGuideBoard(props.topic, props.format)
|
||||
} catch { /* Board noch leer */ }
|
||||
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
|
||||
}
|
||||
}
|
||||
function startPoll() { stopPoll(); timer = setInterval(poll, 1200) }
|
||||
function stopPoll() { if (timer) { clearInterval(timer); timer = null } }
|
||||
// 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 })
|
||||
watch(() => props.refresh, () => poll())
|
||||
watch(() => board.value?.generating, (g) => { if (g) startPoll(); else stopPoll() })
|
||||
onUnmounted(stopPoll)
|
||||
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) })
|
||||
|
||||
// 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 confirm = ref(null)
|
||||
const { isArmed, armOrRun, reset: resetConfirm } = useConfirm() // 2-Klick-Bestätigung (mit 3s-Auto-Reset)
|
||||
|
||||
function stageClick(c) {
|
||||
if (generating.value || !STAGES.includes(c.key)) return
|
||||
confirm.value = null
|
||||
resetConfirm()
|
||||
selCard.value = null
|
||||
sel.value = sel.value?.key === c.key ? null : { key: c.key, label: c.label, idx: STAGES.indexOf(c.key) }
|
||||
}
|
||||
|
||||
function cardClick(k) {
|
||||
if (generating.value || !k.card_id) return
|
||||
confirm.value = null
|
||||
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 }
|
||||
@@ -55,14 +93,10 @@ function cardClick(k) {
|
||||
function resetCardHere() {
|
||||
const k = selCard.value
|
||||
selCard.value = null
|
||||
confirm.value = null
|
||||
resetConfirm()
|
||||
emit('resetCard', { format: props.format, blockNorm: k.card_id, abStage: 0 })
|
||||
setTimeout(poll, 400)
|
||||
}
|
||||
function arm(action, fn) {
|
||||
if (confirm.value === action) { confirm.value = null; fn() }
|
||||
else confirm.value = action
|
||||
}
|
||||
const repairBusy = ref(false)
|
||||
const repairInfo = ref('')
|
||||
async function repairClick() {
|
||||
@@ -101,8 +135,10 @@ function resetHere() {
|
||||
<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="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>
|
||||
@@ -113,11 +149,14 @@ function resetHere() {
|
||||
<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 v-if="total" class="gb-act danger" :class="{ armed: confirm === 'delete' }" @click="arm('delete', () => { emit('removeFormat', format); setTimeout(poll, 600) })">{{ confirm === 'delete' ? 'Sure?' : 'Remove' }}</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 || []"
|
||||
@@ -131,14 +170,14 @@ function resetHere() {
|
||||
|
||||
<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: confirm === 'card' }" @click="confirm === 'card' ? resetCardHere() : confirm = 'card'">{{ confirm === 'card' ? 'Sure?' : '↻ Karte neu (ab Lernziele)' }}</button>
|
||||
<button class="gb-act ghost" @click="selCard = null; confirm = null">Abbrechen</button>
|
||||
<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: 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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
@@ -171,9 +210,8 @@ function resetHere() {
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: gb-pulse 1.2s ease-in-out infinite;
|
||||
animation: pulse-soft 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; }
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup>
|
||||
// Gemeinsame Live-Board-Komponente (Blocks + Guide): Spalten mit Count-Badge und
|
||||
// Karten-Titeln. Spaltenkopf-Klick (wenn erlaubt) → stageClick für Reset-Aktionen.
|
||||
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}]
|
||||
@@ -8,17 +10,8 @@ const props = defineProps({
|
||||
selectable: { type: Boolean, default: false }, // Spaltenkopf klickbar (Reset ab Spalte)
|
||||
cardSelectable: { type: Boolean, default: false }, // Karten klickbar (Einzel-Restart)
|
||||
selectedKey: { type: String, default: null },
|
||||
hideEmpty: { type: Boolean, default: false }, // leere Spalten ausblenden (Terminal-Spalten)
|
||||
})
|
||||
const emit = defineEmits(['stageClick', 'cardClick'])
|
||||
|
||||
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>
|
||||
@@ -29,7 +22,7 @@ function fmtRuntime(s) {
|
||||
</div>
|
||||
<div class="kb-cols">
|
||||
<div
|
||||
v-for="c in columns.filter(visible)"
|
||||
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 }"
|
||||
|
||||
26
frontend/src/components/ProgressBar.vue
Normal file
26
frontend/src/components/ProgressBar.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
// Schmaler Fortschrittsbalken (0–1) mit Beschriftung und optionalem Hinweis.
|
||||
defineProps({
|
||||
value: { type: Number, default: 0 }, // 0..1
|
||||
label: { type: String, default: '' },
|
||||
hint: { type: String, default: '' }, // z. B. „Umfang wächst noch"
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pb">
|
||||
<div class="pb-track">
|
||||
<div class="pb-fill" :style="{ width: Math.round(Math.min(1, Math.max(0, value)) * 100) + '%' }"></div>
|
||||
</div>
|
||||
<span v-if="label" class="pb-label">{{ label }}</span>
|
||||
<span v-if="hint" class="pb-hint">{{ hint }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pb { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.pb-track { flex: 1; height: 6px; border-radius: 999px; background: var(--panel-soft); overflow: hidden; }
|
||||
.pb-fill { height: 100%; background: var(--accent); border-radius: 999px; transition: width 0.4s ease; }
|
||||
.pb-label { font-size: 0.78rem; color: var(--text-muted); white-space: nowrap; }
|
||||
.pb-hint { font-size: 0.74rem; color: var(--text-faint); white-space: nowrap; }
|
||||
</style>
|
||||
72
frontend/src/components/SourceForm.vue
Normal file
72
frontend/src/components/SourceForm.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup>
|
||||
// Quellenauswahl (Typ-Buttons + Link-Feld/Ordnerwahl), geteilt zwischen dem Anlegen-Panel
|
||||
// und dem Bearbeiten-Panel der Sidebar. v-model trägt { type, ort }.
|
||||
const props = defineProps({
|
||||
modelValue: { type: Object, required: true }, // { type, ort }
|
||||
folders: { type: Object, default: () => ({}) }, // { projekt: [...], uni: [...] }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'submit'])
|
||||
|
||||
const TYPES = [
|
||||
{ key: 'thema', label: 'Thema' },
|
||||
{ key: 'link', label: 'Link' },
|
||||
{ key: 'projekt', label: 'Projekt' },
|
||||
{ key: 'uni', label: 'Uni' },
|
||||
]
|
||||
|
||||
function setType(key) {
|
||||
emit('update:modelValue', { type: key, ort: '' }) // Typwechsel verwirft den alten Ort
|
||||
}
|
||||
function setOrt(ort) {
|
||||
emit('update:modelValue', { ...props.modelValue, ort })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dlg-sources">
|
||||
<button v-for="t in TYPES" :key="t.key" :class="{ active: modelValue.type === t.key }"
|
||||
@click="setType(t.key)">{{ t.label }}</button>
|
||||
</div>
|
||||
<input
|
||||
v-if="modelValue.type === 'link'"
|
||||
class="dlg-input" :value="modelValue.ort"
|
||||
placeholder="https://…"
|
||||
@input="setOrt($event.target.value)"
|
||||
@keyup.enter="emit('submit')"
|
||||
/>
|
||||
<select
|
||||
v-else-if="modelValue.type === 'projekt' || modelValue.type === 'uni'"
|
||||
class="dlg-input" :value="modelValue.ort"
|
||||
@change="setOrt($event.target.value)"
|
||||
>
|
||||
<option value="" disabled>Ordner wählen…</option>
|
||||
<option v-for="fo in (folders[modelValue.type] || [])" :key="fo.location" :value="fo.location">{{ fo.name }}</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dlg-sources { display: flex; gap: 0.3rem; }
|
||||
.dlg-sources button {
|
||||
flex: 1;
|
||||
padding: 0.35rem 0.2rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--panel-soft);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.dlg-sources button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||
.dlg-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
.dlg-input:focus { outline: none; border-color: var(--accent); }
|
||||
select.dlg-input { cursor: pointer; }
|
||||
</style>
|
||||
@@ -6,6 +6,7 @@ import { stufeFuer, schwelle, SUB_RANK, VIEW_KURZ, VIEW_FARBE, viewLevelFuer } f
|
||||
import { useChat } from '../composables/useChat.js'
|
||||
import BlockPanel from './BlockPanel.vue'
|
||||
import BlockFocus from './BlockFocus.vue'
|
||||
import ChatTranscript from './ChatTranscript.vue'
|
||||
|
||||
const props = defineProps({
|
||||
previewGuide: { type: Object, default: null },
|
||||
@@ -199,8 +200,7 @@ const chat = useChat((msgs) => {
|
||||
section, outline, messages: msgs, provider: props.provider,
|
||||
})
|
||||
})
|
||||
const { messages, input, loading, messagesEl, inputEl, onScroll, send } = chat
|
||||
const autoGrow = () => chat.autoGrow()
|
||||
const { inputEl } = chat // fürs Fokussieren beim Öffnen; Rendering übernimmt ChatTranscript
|
||||
const chatOpen = ref(false)
|
||||
const panelEl = ref(null)
|
||||
|
||||
@@ -355,30 +355,8 @@ function extractContext() {
|
||||
<span>Questions about the guide</span>
|
||||
<button class="chat-close" title="Close chat" @click="closeChat">×</button>
|
||||
</header>
|
||||
<div ref="messagesEl" class="chat-messages" @scroll="onScroll">
|
||||
<p v-if="!messages.length" class="chat-hint">Ask a question about the current section.</p>
|
||||
<template v-for="(m, i) in messages" :key="i">
|
||||
<div v-if="m.role === 'assistant'" class="chat-msg assistant markdown" v-html="renderMarkdown(m.content)"></div>
|
||||
<div v-else class="chat-msg user">{{ m.content }}</div>
|
||||
</template>
|
||||
<div v-if="loading" class="chat-msg assistant chat-typing">Thinking…</div>
|
||||
</div>
|
||||
<div class="chat-input">
|
||||
<textarea
|
||||
ref="inputEl"
|
||||
v-model="input"
|
||||
rows="3"
|
||||
placeholder="Ask a question…"
|
||||
@input="autoGrow"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
></textarea>
|
||||
<button
|
||||
:disabled="!input.trim() && !loading"
|
||||
:class="{ cancel: loading }"
|
||||
:title="loading ? 'Cancel' : 'Send'"
|
||||
@click="send"
|
||||
>{{ loading ? '✕' : '➤' }}</button>
|
||||
</div>
|
||||
<ChatTranscript :chat="chat" hint="Stelle eine Frage zum aktuellen Abschnitt."
|
||||
placeholder="Frage stellen…" :rows="3" auto-grow max-height="none" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -466,8 +444,8 @@ function extractContext() {
|
||||
font-weight: 600;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, #d4af37 20%, var(--panel));
|
||||
border: 1px solid #d4af37;
|
||||
background: color-mix(in srgb, var(--level-master) 20%, var(--panel));
|
||||
border: 1px solid var(--level-master);
|
||||
color: #8a6d12;
|
||||
}
|
||||
|
||||
@@ -530,26 +508,26 @@ function extractContext() {
|
||||
|
||||
/* Understood blocks (10/10): purple */
|
||||
.block-done.understood {
|
||||
background: color-mix(in srgb, #8b5cf6 16%, var(--panel));
|
||||
border-color: #8b5cf6;
|
||||
background: color-mix(in srgb, var(--level-expert) 16%, var(--panel));
|
||||
border-color: var(--level-expert);
|
||||
color: #6d28d9;
|
||||
}
|
||||
.guide-content .section-card.understood {
|
||||
border-color: #8b5cf6;
|
||||
border-top: 3px solid #8b5cf6;
|
||||
background: color-mix(in srgb, #8b5cf6 7%, var(--panel));
|
||||
border-color: var(--level-expert);
|
||||
border-top: 3px solid var(--level-expert);
|
||||
background: color-mix(in srgb, var(--level-expert) 7%, var(--panel));
|
||||
}
|
||||
|
||||
/* Mastered blocks (master path 25/25): gold */
|
||||
.block-done.mastered {
|
||||
background: color-mix(in srgb, #d4af37 20%, var(--panel));
|
||||
border-color: #d4af37;
|
||||
background: color-mix(in srgb, var(--level-master) 20%, var(--panel));
|
||||
border-color: var(--level-master);
|
||||
color: #8a6d12;
|
||||
}
|
||||
.guide-content .section-card.mastered {
|
||||
border-color: #d4af37;
|
||||
border-top: 3px solid #d4af37;
|
||||
background: color-mix(in srgb, #d4af37 8%, var(--panel));
|
||||
border-color: var(--level-master);
|
||||
border-top: 3px solid var(--level-master);
|
||||
background: color-mix(in srgb, var(--level-master) 8%, var(--panel));
|
||||
}
|
||||
|
||||
/* Guides: cards carry the chapter accent color */
|
||||
@@ -672,101 +650,7 @@ function extractContext() {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.9rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-hint {
|
||||
color: var(--text-faint);
|
||||
font-size: 0.82rem;
|
||||
text-align: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
max-width: 85%;
|
||||
padding: 7px 11px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-msg.user {
|
||||
align-self: flex-end;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border-bottom-right-radius: 3px;
|
||||
}
|
||||
|
||||
.chat-msg.assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--panel-soft);
|
||||
color: var(--text);
|
||||
border-bottom-left-radius: 3px;
|
||||
}
|
||||
|
||||
.chat-msg.markdown {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.chat-typing {
|
||||
color: var(--text-faint);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
padding: 0.6rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-input textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
min-height: 72px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-input textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
width: 38px;
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-input button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chat-input button.cancel {
|
||||
background: var(--danger);
|
||||
}
|
||||
/* Chat-Transkript + Eingabe leben jetzt in ChatTranscript.vue (geteilt mit BlockPanel). */
|
||||
|
||||
/* .sub-neu/.sub-stufe: global in assets/markdown.css — scoped greift nicht auf v-html-Inhalt. */
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useConfirm } from '../composables/useConfirm.js'
|
||||
import { fetchSource } from '../api.js'
|
||||
import SourceForm from './SourceForm.vue'
|
||||
|
||||
const props = defineProps({
|
||||
topics: { type: Array, required: true },
|
||||
@@ -179,10 +180,15 @@ async function toggleTopicPanel(t) {
|
||||
}
|
||||
}
|
||||
|
||||
function setEditType(t) {
|
||||
editForm.value.type = t
|
||||
editForm.value.ort = ''
|
||||
}
|
||||
// v-model-Brücken für SourceForm ({type, ort}) auf die beiden State-Objekte
|
||||
const createSource = computed({
|
||||
get: () => ({ type: form.value.sourceType, ort: form.value.sourceOrt }),
|
||||
set: (v) => { form.value.sourceType = v.type; form.value.sourceOrt = v.ort },
|
||||
})
|
||||
const editSource = computed({
|
||||
get: () => ({ type: editForm.value.type, ort: editForm.value.ort }),
|
||||
set: (v) => { editForm.value.type = v.type; editForm.value.ort = v.ort },
|
||||
})
|
||||
|
||||
function saveSource() {
|
||||
if (!canSave.value || !editTopic.value) return
|
||||
@@ -241,29 +247,12 @@ function saveSource() {
|
||||
|
||||
<!-- Create: inline expandable (no modal) -->
|
||||
<div v-if="dlg" class="thema-panel">
|
||||
<input class="dlg-input" v-model="form.name" placeholder="Topic name…" @keyup.enter="createTopic" autofocus />
|
||||
<textarea class="dlg-textarea" v-model="form.instructions" rows="2" placeholder="More info (optional)…"></textarea>
|
||||
<div class="dlg-sources">
|
||||
<button :class="{ active: form.sourceType === 'thema' }" @click="form.sourceType = 'thema'; form.sourceOrt = ''">Topic</button>
|
||||
<button :class="{ active: form.sourceType === 'link' }" @click="form.sourceType = 'link'; form.sourceOrt = ''">Link</button>
|
||||
<button :class="{ active: form.sourceType === 'projekt' }" @click="form.sourceType = 'projekt'; form.sourceOrt = ''">Project</button>
|
||||
<button :class="{ active: form.sourceType === 'uni' }" @click="form.sourceType = 'uni'; form.sourceOrt = ''">Uni</button>
|
||||
</div>
|
||||
<input
|
||||
v-if="form.sourceType === 'link'"
|
||||
class="dlg-input" v-model="form.sourceOrt"
|
||||
placeholder="https://…" @keyup.enter="createTopic"
|
||||
/>
|
||||
<select
|
||||
v-else-if="form.sourceType === 'projekt' || form.sourceType === 'uni'"
|
||||
class="dlg-input" v-model="form.sourceOrt"
|
||||
>
|
||||
<option value="" disabled>Choose folder…</option>
|
||||
<option v-for="fo in (folders[form.sourceType] || [])" :key="fo.location" :value="fo.location">{{ fo.name }}</option>
|
||||
</select>
|
||||
<input class="dlg-input" v-model="form.name" placeholder="Themenname…" @keyup.enter="createTopic" autofocus />
|
||||
<textarea class="dlg-textarea" v-model="form.instructions" rows="2" placeholder="Mehr Infos (optional)…"></textarea>
|
||||
<SourceForm v-model="createSource" :folders="folders" @submit="createTopic" />
|
||||
<div class="dlg-actions">
|
||||
<button class="dlg-cancel" @click="dlg = false">Cancel</button>
|
||||
<button class="dlg-create" :disabled="!canCreate" @click="createTopic">Create</button>
|
||||
<button class="dlg-cancel" @click="dlg = false">Abbrechen</button>
|
||||
<button class="dlg-create" :disabled="!canCreate" @click="createTopic">Anlegen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-toggle" v-if="providers.length">
|
||||
@@ -279,7 +268,7 @@ function saveSource() {
|
||||
<div class="format-section" v-if="selectedTopic">
|
||||
<div class="format-error ui-error" v-if="uiError">
|
||||
<span class="format-error-text">{{ uiError }}</span>
|
||||
<button class="format-error-x" title="Hide" @click="emit('dismissUiError')">×</button>
|
||||
<button class="format-error-x" title="Ausblenden" @click="emit('dismissUiError')">×</button>
|
||||
</div>
|
||||
<div class="progress-info" v-if="activeGenerations.length">
|
||||
<div v-for="(line, i) in activeGenerations" :key="i">{{ line }}</div>
|
||||
@@ -329,7 +318,7 @@ function saveSource() {
|
||||
>{{ latestByFormat[f.key]?.progress || 'Waiting…' }}</div>
|
||||
<div v-if="errorMsg(f.key)" class="format-error">
|
||||
<span class="format-error-text">{{ errorMsg(f.key) }}</span>
|
||||
<button class="format-error-x" title="Hide" @click="dismissError(f.key)">×</button>
|
||||
<button class="format-error-x" title="Ausblenden" @click="dismissError(f.key)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="format-row ord-exam">
|
||||
@@ -352,37 +341,20 @@ function saveSource() {
|
||||
>
|
||||
<div class="topic-row">
|
||||
<span class="topic-name" @click="emit('select', t)">{{ t }}</span>
|
||||
<button class="panel-toggle" :class="{ open: isOpen('topic-' + t) }" title="Options" @click.stop="toggleTopicPanel(t)">▾</button>
|
||||
<button class="panel-toggle" :class="{ open: isOpen('topic-' + t) }" title="Optionen" @click.stop="toggleTopicPanel(t)">▾</button>
|
||||
</div>
|
||||
<div v-if="isOpen('topic-' + t)" class="thema-panel edit-panel" @click.stop>
|
||||
<p v-if="editLoading" class="dlg-hint">Loading…</p>
|
||||
<p v-if="editLoading" class="dlg-hint">Lädt…</p>
|
||||
<template v-else>
|
||||
<textarea class="dlg-textarea" v-model="editForm.spec" rows="2" placeholder="More info (optional)…"></textarea>
|
||||
<div class="dlg-sources">
|
||||
<button :class="{ active: editForm.type === 'thema' }" @click="setEditType('thema')">Topic</button>
|
||||
<button :class="{ active: editForm.type === 'link' }" @click="setEditType('link')">Link</button>
|
||||
<button :class="{ active: editForm.type === 'projekt' }" @click="setEditType('projekt')">Project</button>
|
||||
<button :class="{ active: editForm.type === 'uni' }" @click="setEditType('uni')">Uni</button>
|
||||
</div>
|
||||
<input
|
||||
v-if="editForm.type === 'link'"
|
||||
class="dlg-input" v-model="editForm.ort"
|
||||
placeholder="https://…"
|
||||
/>
|
||||
<select
|
||||
v-else-if="editForm.type === 'projekt' || editForm.type === 'uni'"
|
||||
class="dlg-input" v-model="editForm.ort"
|
||||
>
|
||||
<option value="" disabled>Choose folder…</option>
|
||||
<option v-for="fo in (folders[editForm.type] || [])" :key="fo.location" :value="fo.location">{{ fo.name }}</option>
|
||||
</select>
|
||||
<textarea class="dlg-textarea" v-model="editForm.spec" rows="2" placeholder="Mehr Infos (optional)…"></textarea>
|
||||
<SourceForm v-model="editSource" :folders="folders" @submit="saveSource" />
|
||||
<div class="dlg-actions">
|
||||
<button
|
||||
class="dlg-delete"
|
||||
:class="{ armed: pendingConfirm === 'topic-' + t }"
|
||||
@click="confirmDeleteTopic(t)"
|
||||
>{{ pendingConfirm === 'topic-' + t ? 'Sure?' : 'Delete' }}</button>
|
||||
<button class="dlg-create" :disabled="!canSave" @click="saveSource">Update</button>
|
||||
>{{ pendingConfirm === 'topic-' + t ? 'Sicher?' : 'Löschen' }}</button>
|
||||
<button class="dlg-create" :disabled="!canSave" @click="saveSource">Aktualisieren</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -626,21 +598,6 @@ function saveSource() {
|
||||
}
|
||||
|
||||
|
||||
/* Coarse phases as numbered pills (1–5) — display + clickable for re-run from here. */
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@keyframes dot-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.format-section {
|
||||
flex-shrink: 0;
|
||||
@@ -658,9 +615,8 @@ function saveSource() {
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
animation: gen-side-pulse 1.2s ease-in-out infinite;
|
||||
animation: pulse-soft 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes gen-side-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||
|
||||
.ord-blocks {
|
||||
order: 2;
|
||||
@@ -771,28 +727,6 @@ function saveSource() {
|
||||
.panel-btn.danger:hover { border-color: var(--danger); }
|
||||
.panel-btn.armed { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||
|
||||
.format-x {
|
||||
display: none;
|
||||
color: var(--danger);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.format-name:hover .format-x {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Running/paused: always show × — there is no hover on touch */
|
||||
.fmt-generating .format-x,
|
||||
.fmt-queued .format-x,
|
||||
.fmt-paused .format-x,
|
||||
.blocks-row.is-active .format-x {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.format-x.armed,
|
||||
.format-error-x.armed,
|
||||
.delete-topic.armed {
|
||||
display: inline-block;
|
||||
@@ -855,45 +789,6 @@ function saveSource() {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.format-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.action-btn.play {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.action-btn.play:hover {
|
||||
background: var(--success-soft);
|
||||
border-color: var(--success-border);
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-btn:disabled:hover {
|
||||
background: none;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.65; }
|
||||
@@ -938,18 +833,6 @@ function saveSource() {
|
||||
}
|
||||
.dlg-input:focus, .dlg-textarea:focus { outline: none; border-color: var(--accent); }
|
||||
.dlg-textarea { resize: vertical; min-height: 2rem; }
|
||||
.dlg-sources { display: flex; gap: 0.3rem; }
|
||||
.dlg-sources button {
|
||||
flex: 1;
|
||||
padding: 0.35rem 0.2rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--panel-soft);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.dlg-sources button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
||||
.dlg-hint { margin: 0; font-size: 0.75rem; color: var(--text-faint); }
|
||||
.dlg-actions { display: flex; justify-content: flex-end; gap: 0.4rem; margin-top: 0.1rem; }
|
||||
.dlg-actions button {
|
||||
|
||||
19
frontend/src/format.js
Normal file
19
frontend/src/format.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// Anzeige-Formatierer, geteilt über Board-Ansichten.
|
||||
|
||||
// Sekunden → „m:ss" / „h:mm:ss" (Laufzeit eines Laufs oder Agenten).
|
||||
export function fmtRuntime(s) {
|
||||
s = Math.max(0, Math.round(s || 0))
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
const sec = s % 60
|
||||
if (h) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
|
||||
return `${m}:${String(sec).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Token-Zahl kompakt (48.234 → „48,2k", 1.2M → „1,2M").
|
||||
export function fmtTokens(n) {
|
||||
n = n || 0
|
||||
if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace('.', ',')}M`
|
||||
if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace('.', ',')}k`
|
||||
return String(n)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './assets/markdown.css'
|
||||
import './assets/shared.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
|
||||
Reference in New Issue
Block a user