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 <noreply@anthropic.com>
This commit is contained in:
1 parent
be44235ca9
commit
f38f3a42d1
3 files changed
+205
-1
No files matched your search
+18
-1
@@ -42,6 +42,9 @@ function handleStreamEvent(eventType, data) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'text_start':
|
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();
|
if (!_streamDiv) startStreamBubble();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -55,7 +58,13 @@ function handleStreamEvent(eventType, data) {
|
|||||||
|
|
||||||
case 'tool_start':
|
case 'tool_start':
|
||||||
endStreamBubble();
|
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);
|
_currentToolBox = addToolBoxLoading(data.name);
|
||||||
} else {
|
} else {
|
||||||
_currentToolBox = null;
|
_currentToolBox = null;
|
||||||
@@ -63,6 +72,12 @@ function handleStreamEvent(eventType, data) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'tool_end':
|
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) {
|
if (_currentToolBox) {
|
||||||
finishToolBox(_currentToolBox, data);
|
finishToolBox(_currentToolBox, data);
|
||||||
_lastFinishedToolBox = _currentToolBox;
|
_lastFinishedToolBox = _currentToolBox;
|
||||||
@@ -78,6 +93,7 @@ function handleStreamEvent(eventType, data) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'web_search_start':
|
case 'web_search_start':
|
||||||
|
sealLiveTaskList();
|
||||||
pauseStreamBubble();
|
pauseStreamBubble();
|
||||||
_currentToolBox = addToolBoxLoading('web_search');
|
_currentToolBox = addToolBoxLoading('web_search');
|
||||||
break;
|
break;
|
||||||
@@ -473,6 +489,7 @@ async function _runStream(fetchPromise) {
|
|||||||
_streamingPollInterval = null;
|
_streamingPollInterval = null;
|
||||||
}
|
}
|
||||||
_currentToolBox = null;
|
_currentToolBox = null;
|
||||||
|
resetLiveTaskList();
|
||||||
_chatAbandoned = false;
|
_chatAbandoned = false;
|
||||||
_stopClickCount = 0;
|
_stopClickCount = 0;
|
||||||
startTypingIndicator();
|
startTypingIndicator();
|
||||||
|
|||||||
@@ -216,6 +216,10 @@ function _mergeConsecutiveAssistantTexts(msgs) {
|
|||||||
*/
|
*/
|
||||||
function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) {
|
function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) {
|
||||||
const archived = opts && opts.archived;
|
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++) {
|
for (var _msgIdx = 0; _msgIdx < msgs.length; _msgIdx++) {
|
||||||
const msg = msgs[_msgIdx];
|
const msg = msgs[_msgIdx];
|
||||||
|
|
||||||
@@ -357,6 +361,24 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) {
|
|||||||
_renderChoiceBubbleInFragment(fragment, msgs, _msgIdx);
|
_renderChoiceBubbleInFragment(fragment, msgs, _msgIdx);
|
||||||
continue;
|
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;
|
if (!isToolVisible(msg.toolName)) continue;
|
||||||
const toolDiv = document.createElement('div');
|
const toolDiv = document.createElement('div');
|
||||||
toolDiv.className = 'tool-box' + (archived ? ' archived' : '');
|
toolDiv.className = 'tool-box' + (archived ? ' archived' : '');
|
||||||
@@ -378,6 +400,8 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) {
|
|||||||
}
|
}
|
||||||
case 'tool_result': {
|
case 'tool_result': {
|
||||||
if (msg._choiceHandled) continue;
|
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),
|
// Passendes Tool-Box per toolCallId finden (falls verfügbar),
|
||||||
// sonst Fallback auf letztes Tool-Box (ältere DB-Einträge ohne toolCallId)
|
// sonst Fallback auf letztes Tool-Box (ältere DB-Einträge ohne toolCallId)
|
||||||
let lastToolBox = null;
|
let lastToolBox = null;
|
||||||
@@ -425,6 +449,12 @@ function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) {
|
|||||||
default:
|
default:
|
||||||
continue;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -377,6 +377,163 @@ function buildTodoListFromInput(todos, isError) {
|
|||||||
return container;
|
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) {
|
function finishToolBox(div, data) {
|
||||||
if (!div) return;
|
if (!div) return;
|
||||||
renderToolBoxContent(div, data.name, data.input, data.result, data.isError, data.sources);
|
renderToolBoxContent(div, data.name, data.input, data.result, data.isError, data.sources);
|
||||||
|
|||||||
Reference in new issue
Block a user