From f38f3a42d1aeebcff04901f12cf7389612628422 Mon Sep 17 00:00:00 2001 From: Meleki Date: Mon, 27 Jul 2026 07:27:31 +0200 Subject: [PATCH] Frontend: TaskCreate/TaskUpdate als aggregierte Checkliste rendern Die CLI nutzt inzwischen TaskCreate/TaskUpdate statt TodoWrite fuers Todo-Tracking. Diese fielen in den generischen Tool-Box-Pfad und wurden als rohe Tool-Calls mit JSON-Input angezeigt statt als Checkliste. Da die neuen Tools inkrementell sind (ein Call pro Anlage/Statuswechsel), werden die Einzel-Events zu EINER Checkliste aggregiert: - tool-ui.js: Task-Tracker (applyTaskEvent/ingestTaskEvent), Box-Aufbau mit wiederverwendeten todo-list-Styles + Fortschritts-Badge n/m; sealTaskBox/sealLiveTaskList zum Segmentieren - chat.js: Live-Pfad (SSE) haengt Task-Events in die aggregierte Box statt generischer Tool-Boxen; Reset beim Stream-Start - history.js: Replay-Pfad mit eigenem Tracker pro Chain; zugehoerige tool_result-Messages werden uebersprungen (kein Doppel-Render) Segmentierung: ein zusammenhaengender Task-Lauf bleibt EINE Box. Sobald dazwischen echter Inhalt gerendert wird (Fliesstext oder anderes Tool), wird die Box versiegelt und das naechste Task-Event oeffnet eine neue Box darunter - so bleibt die chronologische Reihenfolge erhalten und Narration zwischen den Tasks sichtbar. Der Task-Stand bleibt kumulativ, der Fortschritt zaehlt ueber die Segmente hinweg hoch. Co-Authored-By: Claude Opus 5 --- wwwroot/js/chat.js | 19 ++++- wwwroot/js/history.js | 30 ++++++++ wwwroot/js/tool-ui.js | 157 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 205 insertions(+), 1 deletion(-) diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js index 684d5a7..8988c59 100644 --- a/wwwroot/js/chat.js +++ b/wwwroot/js/chat.js @@ -42,6 +42,9 @@ function handleStreamEvent(eventType, data) { break; case 'text_start': + // Fließtext zwischen Task-Events → aktuelle Task-Box versiegeln, damit das nächste + // Task-Event eine neue Box UNTER dem Text öffnet (chronologische Reihenfolge). + sealLiveTaskList(); if (!_streamDiv) startStreamBubble(); break; @@ -55,7 +58,13 @@ function handleStreamEvent(eventType, data) { case 'tool_start': endStreamBubble(); - if (isToolVisible(data.name)) { + // Task*-Tools werden zu EINER aggregierten Checkliste gebündelt (erst bei tool_end, + // da wir dort Input+Result haben) → keine generische Lade-Box. + if (isTaskListTool(data.name)) { + _currentToolBox = null; + } else if (isToolVisible(data.name)) { + // Anderes Tool zwischen Task-Events → Task-Box versiegeln (nächstes Task-Event = neue Box) + sealLiveTaskList(); _currentToolBox = addToolBoxLoading(data.name); } else { _currentToolBox = null; @@ -63,6 +72,12 @@ function handleStreamEvent(eventType, data) { break; case 'tool_end': + if (isTaskListTool(data.name)) { + if (isToolVisible(data.name)) handleLiveTaskEvent(data.name, data.input, data.result); + _currentToolBox = null; + _lastFinishedToolBox = null; + break; + } if (_currentToolBox) { finishToolBox(_currentToolBox, data); _lastFinishedToolBox = _currentToolBox; @@ -78,6 +93,7 @@ function handleStreamEvent(eventType, data) { break; case 'web_search_start': + sealLiveTaskList(); pauseStreamBubble(); _currentToolBox = addToolBoxLoading('web_search'); break; @@ -473,6 +489,7 @@ async function _runStream(fetchPromise) { _streamingPollInterval = null; } _currentToolBox = null; + resetLiveTaskList(); _chatAbandoned = false; _stopClickCount = 0; startTypingIndicator(); diff --git a/wwwroot/js/history.js b/wwwroot/js/history.js index f5a5dcf..d08a350 100644 --- a/wwwroot/js/history.js +++ b/wwwroot/js/history.js @@ -216,6 +216,10 @@ function _mergeConsecutiveAssistantTexts(msgs) { */ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) { const archived = opts && opts.archived; + // Task*-Tools (TaskCreate/TaskUpdate) → EINE aggregierte Checkliste pro Aufruf. Diese Funktion + // wird pro Chain aufgerufen (siehe loadHistory/loadOlderMessages), daher genügt ein Tracker. + const _taskTracker = makeTaskTracker(); + const _taskResultSkip = {}; for (var _msgIdx = 0; _msgIdx < msgs.length; _msgIdx++) { const msg = msgs[_msgIdx]; @@ -357,6 +361,24 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) { _renderChoiceBubbleInFragment(fragment, msgs, _msgIdx); continue; } + if (isTaskListTool(msg.toolName)) { + if (!isToolVisible(msg.toolName)) continue; + if (msg.toolCallId) _taskResultSkip[msg.toolCallId] = true; + // Result (für die #N-ID des Create) per toolCallId vorausschauend suchen + var _taskResult = null; + if (msg.toolCallId) { + for (var _tj = _msgIdx + 1; _tj < msgs.length; _tj++) { + if (msgs[_tj].type === 'tool_result' && msgs[_tj].toolCallId === msg.toolCallId) { + _taskResult = msgs[_tj].content; break; + } + } + } + ingestTaskEvent(_taskTracker, msg.toolName, msg.content, _taskResult, function(box) { + if (archived) box.classList.add('archived'); + fragment.appendChild(box); + }); + continue; + } if (!isToolVisible(msg.toolName)) continue; const toolDiv = document.createElement('div'); toolDiv.className = 'tool-box' + (archived ? ' archived' : ''); @@ -378,6 +400,8 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) { } case 'tool_result': { if (msg._choiceHandled) continue; + // Task*-Result wurde bereits in die aggregierte Checkliste eingearbeitet → nicht als Box zeigen + if (msg.toolCallId && _taskResultSkip[msg.toolCallId]) continue; // Passendes Tool-Box per toolCallId finden (falls verfügbar), // sonst Fallback auf letztes Tool-Box (ältere DB-Einträge ohne toolCallId) let lastToolBox = null; @@ -425,6 +449,12 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) { default: continue; } + // Es wurde echter Inhalt gerendert (Fließtext, anderes Tool, Ergebnis, …) – Task*-Events + // nutzen oben `continue` und erreichen diese Stelle NICHT. Damit wird die aktuelle Task-Box + // versiegelt: ein folgendes Task-Event öffnet eine NEUE Box UNTER dem Zwischeninhalt → + // chronologische Reihenfolge bleibt erhalten (Text zwischen den Tasks sichtbar). Ein + // zusammenhängender Task-Lauf ohne Zwischeninhalt bleibt weiterhin EINE Box. + sealTaskBox(_taskTracker); } } diff --git a/wwwroot/js/tool-ui.js b/wwwroot/js/tool-ui.js index 6067c6c..a88d5c6 100644 --- a/wwwroot/js/tool-ui.js +++ b/wwwroot/js/tool-ui.js @@ -377,6 +377,163 @@ function buildTodoListFromInput(todos, isError) { return container; } +// ── Task-Liste (CLI Task*-Tools) ── +// Die CLI nutzt inzwischen TaskCreate/TaskUpdate (statt des früheren TodoWrite) für das +// Todo-Tracking. Diese Tools sind inkrementell (ein Call pro Task-Anlage/Statuswechsel). +// Damit die frühere Checklisten-Visualisierung erhalten bleibt, aggregieren wir sie zu +// EINER Box, die in-place aktualisiert wird – statt viele generische Tool-Boxen zu zeigen. + +/** True für die Task-Tracking-Tools, die zur aggregierten Checkliste gehören. */ +function isTaskListTool(rawName) { + return rawName === 'TaskCreate' || rawName === 'TaskUpdate'; +} + +/** Neuer, leerer Task-Tracker (pro Chain bzw. pro Live-Stream). */ +function makeTaskTracker() { + return { tasks: [], byId: {}, box: null }; +} + +/** + * Wendet ein TaskCreate/TaskUpdate-Event auf den Tracker an. + * - TaskCreate: neuer Task; ID sequentiell (die CLI vergibt #1..#N in Erstell-Reihenfolge), + * ein evtl. vorhandenes Result "Task #N ..." bestätigt/überschreibt die ID. + * - TaskUpdate: setzt den Status des per taskId referenzierten Tasks. + */ +function applyTaskEvent(tracker, rawName, inputJson, resultText) { + var input = null; + try { input = JSON.parse(inputJson); } catch (e) {} + if (!input || typeof input !== 'object') input = {}; + + if (rawName === 'TaskCreate') { + var id = tracker.tasks.length + 1; + var m = resultText && /Task #(\d+)/.exec(resultText); + if (m) id = parseInt(m[1], 10); + var task = { + id: String(id), + subject: input.subject || input.description || ('Aufgabe ' + id), + status: 'pending' + }; + if (!tracker.byId[task.id]) tracker.tasks.push(task); + tracker.byId[task.id] = task; + } else if (rawName === 'TaskUpdate') { + var tid = String(input.taskId != null ? input.taskId : (input.id != null ? input.id : '')); + if (!tid) return tracker; + var existing = tracker.byId[tid]; + if (existing) { + if (input.status) existing.status = input.status; + } else { + // Update ohne vorheriges Create (z.B. paginierter History-Ausschnitt) → Platzhalter + var placeholder = { id: tid, subject: 'Aufgabe ' + tid, status: input.status || 'pending' }; + tracker.byId[tid] = placeholder; + tracker.tasks.push(placeholder); + } + } + return tracker; +} + +/** Rendert die Task-Items (Checkboxen) in den Listen-Container (in-place, leert vorher). */ +function renderTaskListItems(list, tracker) { + list.innerHTML = ''; + var done = 0; + for (var i = 0; i < tracker.tasks.length; i++) { + var task = tracker.tasks[i]; + var status = task.status || 'pending'; + if (status === 'completed') done++; + var icon = status === 'completed' ? '✅' : status === 'in_progress' ? '🔄' : '⬜'; + var statusClass = status === 'completed' ? 'todo-done' : status === 'in_progress' ? 'todo-progress' : 'todo-pending'; + + var row = document.createElement('div'); + row.className = 'todo-item ' + statusClass; + + var iconSpan = document.createElement('span'); + iconSpan.className = 'todo-icon'; + iconSpan.textContent = icon; + + var idSpan = document.createElement('span'); + idSpan.className = 'todo-id'; + idSpan.textContent = task.id + '.'; + + var textSpan = document.createElement('span'); + textSpan.className = 'todo-text'; + textSpan.textContent = task.subject; + + row.appendChild(iconSpan); + row.appendChild(idSpan); + row.appendChild(textSpan); + list.appendChild(row); + } + // Fortschritts-Badge im Header aktualisieren + var box = list.parentElement; + var badge = box && box.querySelector('.task-progress'); + if (badge) badge.textContent = done + '/' + tracker.tasks.length; + parseFluentEmojis(list); +} + +/** Baut die komplette Task-Listen-Box (Header + Checkliste) für den aktuellen Tracker-Stand. */ +function buildTaskListBox(tracker) { + var box = document.createElement('div'); + box.className = 'tool-box task-list-box'; + + var header = document.createElement('div'); + header.className = 'tool-box-header'; + var nameSpan = document.createElement('span'); + nameSpan.className = 'tool-name'; + nameSpan.textContent = 'Aufgabenliste'; + header.appendChild(nameSpan); + var progress = document.createElement('span'); + progress.className = 'tool-write-meta task-progress'; + header.appendChild(progress); + box.appendChild(header); + + var list = document.createElement('div'); + list.className = 'todo-list task-list'; + box.appendChild(list); + + renderTaskListItems(list, tracker); + return box; +} + +/** + * Verarbeitet ein Task-Event für einen Tracker und stellt die (einzige) Box sicher: + * beim ersten Event wird sie erzeugt und via appendBox() eingehängt, danach in-place aktualisiert. + */ +function ingestTaskEvent(tracker, rawName, inputJson, resultText, appendBox) { + applyTaskEvent(tracker, rawName, inputJson, resultText); + if (!tracker.box) { + tracker.box = buildTaskListBox(tracker); + appendBox(tracker.box); + } else { + renderTaskListItems(tracker.box.querySelector('.task-list'), tracker); + } + return tracker.box; +} + +/** + * "Versiegelt" die aktuelle Task-Box: der Tracker behält seinen Task-Stand (kumulativ), + * aber die nächste Task-Event erzeugt eine NEUE Box statt die alte in-place zu aktualisieren. + * Wird aufgerufen, sobald zwischen zwei Task-Events echter Inhalt (Fließtext, anderes Tool) + * gerendert wurde → so bleibt die chronologische Reihenfolge (Text zwischen den Tasks sichtbar). + * Ein zusammenhängender Task-Lauf ohne Zwischentext bleibt weiterhin EINE Box. + */ +function sealTaskBox(tracker) { + if (tracker) tracker.box = null; +} + +// Live-Stream-Tracker (ein aktiver Chain zur Zeit; Reset beim Stream-Start). +var _liveTaskTracker = null; +function resetLiveTaskList() { _liveTaskTracker = null; } +/** Versiegelt die Live-Task-Box (nächstes Task-Event → neue Box). No-op wenn keine offen. */ +function sealLiveTaskList() { if (_liveTaskTracker) _liveTaskTracker.box = null; } + +/** Live-Handler: nimmt ein TaskCreate/TaskUpdate aus dem SSE-Stream entgegen. */ +function handleLiveTaskEvent(rawName, inputJson, resultText) { + if (!_liveTaskTracker) _liveTaskTracker = makeTaskTracker(); + ingestTaskEvent(_liveTaskTracker, rawName, inputJson, resultText, function(box) { + chatEl().appendChild(box); + }); + scrollDown(); +} + function finishToolBox(div, data) { if (!div) return; renderToolBoxContent(div, data.name, data.input, data.result, data.isError, data.sources);