Files
gott/wwwroot/js/tool-ui.js
T
MelekiandClaude Opus 5 f38f3a42d1 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>
2026-07-27 07:27:31 +02:00

577 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── CLI Tool Name / Input Normalization ──
const CLI_TOOL_NAME_MAP = {
'Edit': 'edit_file',
'Write': 'write_file',
'Read': 'read_file',
'Bash': 'execute',
'Glob': 'list_files',
'Grep': 'search_files',
'WebSearch': 'web_search',
'WebFetch': 'web_fetch',
'NotebookEdit': 'notebook_edit',
'TodoWrite': 'todo_write',
'ToolSearch': 'tool_search',
'mcp__gott-remote__remote_execute': 'remote_execute'
};
/** Normalize CLI tool name to gott convention (Edit → edit_file, etc.) */
function normalizeToolName(name) {
if (CLI_TOOL_NAME_MAP[name]) return CLI_TOOL_NAME_MAP[name];
// Generisch: mcp__server__tool → tool
if (name && name.startsWith('mcp__')) {
var parts = name.split('__');
return parts[parts.length - 1] || name;
}
return name;
}
/** Normalize CLI tool input fields (file_path → path, old_string → old_text, etc.) */
function normalizeToolInput(name, parsedInput) {
if (!parsedInput || typeof parsedInput !== 'object') return parsedInput;
var normalized = name === 'edit_file' || name === 'Edit';
var isWrite = name === 'write_file' || name === 'Write';
var isRead = name === 'read_file' || name === 'Read';
var isBash = name === 'execute' || name === 'Bash';
if (normalized) {
return {
path: parsedInput.path || parsedInput.file_path,
old_text: parsedInput.old_text || parsedInput.old_string,
new_text: parsedInput.new_text || parsedInput.new_string
};
}
if (isWrite || isRead) {
var result = Object.assign({}, parsedInput);
if (parsedInput.file_path && !parsedInput.path) result.path = parsedInput.file_path;
return result;
}
if (isBash && parsedInput.command === undefined && parsedInput.description) {
// CLI Bash tool hat command direkt
return parsedInput;
}
return parsedInput;
}
// ── Tool Call Visibility ──
const MODIFICATION_TOOLS = ['write_file', 'edit_file', 'delete_file', 'memory_write', 'remote_execute'];
const TOOL_DISPLAY_NAMES = {
list_files: 'Dateien auflisten',
search_files: 'Dateien durchsuchen',
read_file: 'Datei lesen',
write_file: 'Datei schreiben',
edit_file: 'Datei bearbeiten',
delete_file: 'Datei löschen',
glob: 'Dateien durchsuchen',
grep: 'Dateien durchsuchen (Volltext)',
memory_search: 'Erinnerungen durchsuchen',
memory_read: 'Erinnerungen lesen',
memory_write: 'Erinnerungen festhalten',
web_fetch: 'Web-Fetch durchführen',
web_search: 'Web durchsuchen',
execute: 'Befehl ausführen',
background_execute: 'Befehl ausführen (Hintergrund)',
remote_execute: 'Befehl ausführen (Remote)',
send_to_session: 'Nachricht an Session senden',
tool_search: 'Tools durchsuchen',
todo_write: 'Aufgabenliste aktualisieren',
github_code_search: 'Code auf Github suchen',
serper_web_search: 'Web per Serper durchsuchen',
};
function isToolVisible(toolName) {
const normalized = normalizeToolName(toolName);
const mode = loadSettings().toolCallDisplay;
if (mode === 'all') return true;
if (mode === 'hidden') return false;
// 'modifications' – nur modifizierende Tools + web_search anzeigen
if (normalized === 'web_search') return true;
return MODIFICATION_TOOLS.includes(normalized);
}
// ── Chain Meta Visibility ──
function isChainMetaVisible(chainData) {
const mode = loadSettings().chainMetaDisplay;
if (mode === 'always') return true;
if (mode === 'never') return false;
// 'sometimes' – nur bei auffälligen Chains anzeigen
if (!chainData) return false;
const inOut = (chainData.inputTokens || 0) + (chainData.outputTokens || 0);
if (inOut >= 10000) return true;
if ((chainData.iterations || 0) >= 25) return true;
if ((chainData.durationMs || 0) >= 90000) return true;
return false;
}
// ── Tool Boxes ──
function addToolBoxLoading(rawName) {
const name = normalizeToolName(rawName);
const div = document.createElement('div');
div.className = 'tool-box streamed';
div.dataset.toolName = name;
const displayName = TOOL_DISPLAY_NAMES[name] || name;
div.innerHTML = '<span class="tool-name">' + esc(displayName) + '</span> <span class="tool-spinner"><i class="fa fa-hourglass-half"></i></span>';
chatEl().appendChild(div);
scrollDown();
return div;
}
function renderToolBoxContent(div, rawName, inputJson, result, isError, sources) {
const name = normalizeToolName(rawName);
let parsedInput = null;
try { parsedInput = normalizeToolInput(rawName, JSON.parse(inputJson)); } catch {}
const header = document.createElement('div');
header.className = 'tool-box-header';
const nameSpan = document.createElement('span');
nameSpan.className = 'tool-name';
nameSpan.textContent = TOOL_DISPLAY_NAMES[name] || name;
header.appendChild(nameSpan);
if (isError) {
const errSpan = document.createElement('span');
errSpan.className = 'tool-error-badge';
errSpan.textContent = 'error';
header.appendChild(errSpan);
}
div.innerHTML = '';
div.appendChild(header);
let foldTarget = null;
// Web-Search Sources – immer sichtbar, kein Toggle
if (name === 'web_search' && sources && sources.length > 0) {
const sourcesList = document.createElement('div');
sourcesList.className = 'web-search-sources';
sources.forEach(function(s) {
const link = document.createElement('a');
link.href = s.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.className = 'web-search-source';
var domain = '';
try { domain = new URL(s.url).hostname.replace('www.', ''); } catch(e) { domain = s.url; }
link.innerHTML = '<i class="fa fa-external-link"></i> <span class="source-domain">' + esc(domain) + '</span>' +
(s.title ? '<span class="source-title">' + esc(s.title) + '</span>' : '');
sourcesList.appendChild(link);
});
div.appendChild(sourcesList);
}
if (name === 'edit_file' && parsedInput && parsedInput.path) {
const pathSpan = document.createElement('span');
pathSpan.className = 'tool-input';
pathSpan.textContent = parsedInput.path;
header.appendChild(pathSpan);
const diff = buildEditDiff(parsedInput.old_text || '', parsedInput.new_text || '');
diff.hidden = true;
div.appendChild(diff);
foldTarget = diff;
} else if (name === 'write_file' && parsedInput && parsedInput.path) {
const pathSpan = document.createElement('span');
pathSpan.className = 'tool-input';
pathSpan.textContent = parsedInput.path;
header.appendChild(pathSpan);
const content = parsedInput.content || '';
const lines = content.split('\n');
const lineCount = lines.length;
const charCount = content.length;
const meta = document.createElement('span');
meta.className = 'tool-write-meta';
meta.textContent = lineCount + ' Zeilen, ' + charCount + ' Zeichen';
header.appendChild(meta);
const preview = buildWritePreview(content);
preview.hidden = true;
div.appendChild(preview);
foldTarget = preview;
} else if (name === 'todo_write') {
// Todo-Liste: aus Input bauen (CLI hat nur generisches Result, API formatiert Result)
// Priorität: parsedInput.todos (immer vorhanden) > result (nur bei API-Pfad formatiert)
if (parsedInput && parsedInput.todos && Array.isArray(parsedInput.todos)) {
const todoList = buildTodoListFromInput(parsedInput.todos, isError);
div.appendChild(todoList);
foldTarget = todoList;
} else if (result) {
const todoList = buildTodoList(result, isError);
div.appendChild(todoList);
foldTarget = todoList;
}
} else {
// Standard: path/command als input anzeigen
let inputLabel = '';
if (parsedInput) {
inputLabel = parsedInput.path || parsedInput.command || JSON.stringify(parsedInput).substring(0, 120);
} else {
inputLabel = truncate(inputJson, 120);
}
if (inputLabel) {
const inputSpan = document.createElement('span');
inputSpan.className = 'tool-input';
inputSpan.textContent = inputLabel;
header.appendChild(inputSpan);
}
if (result) {
const resultDiv = document.createElement('div');
resultDiv.className = 'tool-result' + (isError ? ' tool-error' : '');
resultDiv.textContent = result;
resultDiv.hidden = true;
div.appendChild(resultDiv);
foldTarget = resultDiv;
}
}
if (foldTarget && name !== 'todo_write') {
header.classList.add('clickable');
header.addEventListener('click', () => {
foldTarget.hidden = !foldTarget.hidden;
});
}
}
function _buildDiffLines(container, lines, type) {
const cssClass = type === 'added' ? 'diff-added' : 'diff-removed';
const markerChar = type === 'added' ? '+' : '-';
for (const line of lines) {
const row = document.createElement('div');
row.className = 'diff-line ' + cssClass;
const marker = document.createElement('span');
marker.className = 'diff-marker';
marker.textContent = markerChar;
const content = document.createElement('span');
content.className = 'diff-content';
content.textContent = line;
row.appendChild(marker);
row.appendChild(content);
container.appendChild(row);
}
}
function buildEditDiff(oldText, newText) {
const container = document.createElement('div');
container.className = 'tool-diff';
_buildDiffLines(container, oldText.split('\n'), 'removed');
_buildDiffLines(container, newText.split('\n'), 'added');
return container;
}
function buildWritePreview(content) {
const container = document.createElement('div');
container.className = 'tool-diff';
_buildDiffLines(container, content.split('\n'), 'added');
return container;
}
function buildTodoList(result, isError) {
const container = document.createElement('div');
container.className = 'todo-list' + (isError ? ' tool-error' : '');
const lines = result.split('\n').map(l => l.replace(/\r$/, '')).filter(l => l.trim());
for (const line of lines) {
// Format: ICON [id] [priority] content
const m = line.match(/^(\S+)\s+\[([^\]]+)\]\s+(?:\[([^\]]+)\]\s+)?(.+)$/);
const row = document.createElement('div');
row.className = 'todo-item';
if (m) {
const icon = m[1]; // ✅ / 🔄 / ⬜
const id = m[2];
const prio = m[3] || null;
const text = m[4];
const statusClass =
icon === '✅' ? 'todo-done' :
icon === '🔄' ? 'todo-progress' :
'todo-pending';
row.classList.add(statusClass);
const iconSpan = document.createElement('span');
iconSpan.className = 'todo-icon';
iconSpan.textContent = icon;
const idSpan = document.createElement('span');
idSpan.className = 'todo-id';
idSpan.textContent = id + '.';
const textSpan = document.createElement('span');
textSpan.className = 'todo-text';
textSpan.textContent = text;
row.appendChild(iconSpan);
row.appendChild(idSpan);
row.appendChild(textSpan);
if (prio) {
const prioSpan = document.createElement('span');
prioSpan.className = 'todo-prio todo-prio-' + prio.toLowerCase();
prioSpan.textContent = prio;
row.appendChild(prioSpan);
}
} else {
row.textContent = line;
}
container.appendChild(row);
}
parseFluentEmojis(container);
return container;
}
/** Baut Todo-Liste direkt aus dem Input-Array (für CLI-Pfad, wo Result nur generischer Text ist). */
function buildTodoListFromInput(todos, isError) {
const container = document.createElement('div');
container.className = 'todo-list' + (isError ? ' tool-error' : '');
for (var i = 0; i < todos.length; i++) {
const todo = todos[i];
const row = document.createElement('div');
row.className = 'todo-item';
const status = todo.status || 'pending';
const icon = status === 'completed' ? '✅' : status === 'in_progress' ? '🔄' : '⬜';
const statusClass = status === 'completed' ? 'todo-done' : status === 'in_progress' ? 'todo-progress' : 'todo-pending';
row.classList.add(statusClass);
const iconSpan = document.createElement('span');
iconSpan.className = 'todo-icon';
iconSpan.textContent = icon;
const idSpan = document.createElement('span');
idSpan.className = 'todo-id';
idSpan.textContent = (todo.id || (i + 1)) + '.';
const textSpan = document.createElement('span');
textSpan.className = 'todo-text';
// CLI hat "activeForm" statt nur "content" – zeige content (Imperativ-Form)
textSpan.textContent = todo.content || todo.activeForm || '';
row.appendChild(iconSpan);
row.appendChild(idSpan);
row.appendChild(textSpan);
if (todo.priority) {
const prioSpan = document.createElement('span');
prioSpan.className = 'todo-prio todo-prio-' + todo.priority.toLowerCase();
prioSpan.textContent = todo.priority;
row.appendChild(prioSpan);
}
container.appendChild(row);
}
parseFluentEmojis(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) {
if (!div) return;
renderToolBoxContent(div, data.name, data.input, data.result, data.isError, data.sources);
scrollDown();
}
/** Fügt ein Tool-Result nachträglich an eine bestehende Tool-Box an (CLI tool_result Events). */
function appendToolResult(div, result, isError) {
if (!div || !result) return;
var toolName = div.dataset.toolName;
// todo_write: Liste wurde bereits aus Input gebaut (in renderToolBoxContent)
if (toolName === 'todo_write') {
if (div.querySelector('.todo-list')) return; // Bereits vorhanden
// Fallback: versuche aus Result zu parsen (API-Pfad)
var todoList = buildTodoList(result, isError);
div.appendChild(todoList);
scrollDown();
return;
}
// Nur anfügen wenn noch kein Result vorhanden
if (div.querySelector('.tool-result')) return;
// Bei edit_file/write_file: Diff reicht, kein extra Result nötig
if (toolName === 'edit_file' || toolName === 'write_file') return;
var rd = document.createElement('div');
rd.className = 'tool-result' + (isError ? ' tool-error' : '');
rd.textContent = truncate(result, 300);
rd.hidden = true;
div.appendChild(rd);
// Header clickable machen falls noch nicht
var hdr = div.querySelector('.tool-box-header');
if (hdr && !hdr.classList.contains('clickable')) {
hdr.classList.add('clickable');
hdr.addEventListener('click', function() { rd.hidden = !rd.hidden; });
}
}