Files
gott/wwwroot/js/chat.js
T
MelekiandClaude Opus 4.8 47557c9724 Include panel member tokens in the chain cost badge
Panel member token usage was tracked but discarded, so the cost badge only
ever showed the worker's tokens — misleading for MoA where the real cost is
roughly rounds x agents. Collect each member's usage and add it to the badge.

Each member fills its own TokenUsage in parallel (Anthropic loop via the
existing memberUsage, CLI via the now-captured CliChatResult return value;
OpenAI members stay null, nachruestbar). RunPanel sums them after the
Task.WhenAll barrier — single-threaded, so no lock or Interlocked needed —
across all MoA rounds and emits a panel_usage SSE event before panel_done.
The frontend adds those tokens onto the worker totals in the done handler so
the badge reflects the full chain cost.

- SseHelper: add TokenUsage.Add()
- AnthropicService.Panel.cs: PanelMemberResult.Usage, accumulate per round
  after the barrier, emit panel_usage; RunPanelMemberViaCli returns usage
- chat.js: _panelUsage state, panel_usage case, add to worker tokens on done,
  reset on stream start
- TODO.md: check off the member-token item

v1 limit: live badge only — FinalizeChain still persists worker tokens, so a
reload shows worker-only cost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:08:55 +02:00

627 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.
// ── Helpers ──
function buildMetaBadges(d) {
const badges = [];
if (d.planMode) badges.push('📋 Plan-Modus');
badges.push(prettyModelName(d.model));
if (d.effort) badges.push('Effort: ' + d.effort);
badges.push('Tokens: ' + (d.inputTokens || 0) + ' / ' + (d.outputTokens || 0));
if (d.cacheCreationTokens > 0 || d.cacheReadTokens > 0) {
badges.push('Cache: ' + (d.cacheReadTokens || 0) + ' / ' + (d.cacheCreationTokens || 0));
}
if ((d.iterations || 0) > 1) badges.push('Iterationen: ' + d.iterations);
if (d.durationMs) badges.push((d.durationMs / 1000).toFixed(1) + 's');
if (d.stopReason && d.stopReason !== 'end_turn' && d.stopReason !== 'tool_use') badges.push('Stop: ' + d.stopReason);
return badges;
}
function addChainMetaBubble(d, streamed) {
if (!d || !d.model) return;
// Chain noch nicht finalisiert (läuft noch) → nicht anzeigen
if (!d.inputTokens && !d.outputTokens && !d.durationMs) return;
if (!isChainMetaVisible(d)) return;
const div = _createChainMetaDiv(d, streamed ? 'streamed' : null);
chatEl().appendChild(div);
scrollDown();
}
// ── Stream Event Handler (shared between sendMessage + handleChoiceClick) ──
function handleStreamEvent(eventType, data) {
switch (eventType) {
case 'thinking_start':
startThinkingBubble();
break;
case 'thinking_delta':
appendToThinking(data.text);
break;
case 'thinking_end':
endThinkingBubble();
break;
case 'text_start':
if (!_streamDiv) startStreamBubble();
break;
case 'text_delta':
appendToStream(data.text);
break;
case 'text_end':
pauseStreamBubble();
break;
case 'tool_start':
endStreamBubble();
if (isToolVisible(data.name)) {
_currentToolBox = addToolBoxLoading(data.name);
} else {
_currentToolBox = null;
}
break;
case 'tool_end':
if (_currentToolBox) {
finishToolBox(_currentToolBox, data);
_lastFinishedToolBox = _currentToolBox;
}
_currentToolBox = null;
break;
case 'tool_result':
if (_lastFinishedToolBox && data.result) {
appendToolResult(_lastFinishedToolBox, data.result, data.isError);
}
_lastFinishedToolBox = null;
break;
case 'web_search_start':
pauseStreamBubble();
_currentToolBox = addToolBoxLoading('web_search');
break;
case 'web_search_end':
if (_currentToolBox) {
finishToolBox(_currentToolBox, { name: 'web_search', result: 'Suche abgeschlossen', isError: false, sources: data.sources });
}
_currentToolBox = null;
break;
case 'user_choice':
endStreamBubble();
addChoiceBubble(data.toolCallId, data.question, data.choices, true, data.viaCli);
break;
// Preparer / Review / Finalizer events (shared pattern)
case 'preparer_start': case 'review_start': case 'finalizer_start':
endStreamBubble();
addReviewBubble('start', data, eventType.split('_')[0] === 'review' ? undefined : eventType.split('_')[0]);
break;
case 'preparer_text': case 'review_text': case 'finalizer_text':
appendToReviewText(data.text);
break;
case 'preparer_tool_use': case 'review_tool_use': case 'finalizer_tool_use':
addReviewToolUse(data);
break;
case 'preparer_tool_end': case 'review_tool_end': case 'finalizer_tool_end':
finishReviewToolBox(data);
break;
case 'preparer_done':
finalizeReviewBubble(data.forwarded ? 'forwarded' : 'query', data, 'preparer');
break;
case 'review_feedback':
finalizeReviewBubble('feedback', data);
break;
case 'review_approved':
finalizeReviewBubble('approved', data);
break;
case 'finalizer_done':
finalizeReviewBubble('done', data, 'finalizer');
break;
// Multi-Modell-Panel (Fusion/MoA): mehrere Member streamen parallel LIVE
case 'panel_start':
startPanel(data);
break;
// MoA: Runden-Trenner (nur bei mehreren Runden sichtbar)
case 'panel_layer_start':
startPanelLayer(data);
break;
case 'panel_layer_done':
break;
case 'panel_member_start':
addPanelMemberBubble(data);
break;
case 'panel_member_text':
appendPanelMemberText(data);
break;
case 'panel_member_tool_use':
addPanelMemberToolUse(data);
break;
// tool_end = CLI (offene Box füllen); tool = Anthropic-Loop (kombiniert → fertige Box)
case 'panel_member_tool_end': case 'panel_member_tool':
finishPanelMemberTool(data);
break;
case 'panel_member_error':
appendPanelMemberText({ index: data.index, text: '\n\n_[Fehler: ' + (data.error || 'unbekannt') + ']_' });
break;
case 'panel_member_done':
finalizePanelMember(data);
break;
case 'panel_done':
endPanel(data);
break;
case 'panel_usage':
// Panel-Member-Tokens merken → im done-Event auf die Worker-Tokens addieren.
_panelUsage = data;
break;
case 'auto_context':
addAutoContextBubble(data.context);
break;
case 'auto_continue':
addWarningBubble(`Auto-Continue (${data.attempt}/${data.maxAttempts}): Modell wird fortgesetzt…`, true);
break;
case 'steer_injected':
// Das Modell hat den Einwurf an dieser Stelle erhalten. Die Anzeige übernimmt
// bereits die optimistische Bubble aus sendSteer(); am Stream-Ende ersetzt
// pollHistory() sie durch die persistierte DB-Version (Type=steer).
break;
case 'warning':
case 'info':
addWarningBubble(data.message, true);
break;
case 'error':
endStreamBubble();
addErrorBubble(data.error, true);
break;
case 'done':
endStreamBubble();
if (data.stopReason === 'max_tokens') {
addWarningBubble('Antwort wurde abgeschnitten (max_tokens erreicht). Bitte frage nach dem Rest.', true);
}
// Panel-Member-Tokens (live gesammelt) auf die Worker-Tokens addieren → Badge = Gesamtkosten.
var _pu = _panelUsage;
_panelUsage = null;
_lastDoneChainData = null;
if (data.model) {
_addPanelUsage(data, _pu);
_lastDoneChainData = data;
} else if (data.chainId) {
// Fallback: Chain-Daten vom Server nachladen und Badge direkt rendern
// (async – _lastDoneChainData wäre im finally schon gelesen)
apiGetChain(data.chainId).then(chain => {
if (chain) { _addPanelUsage(chain, _pu); addChainMetaBubble(chain, false); }
}).catch(() => {});
}
setMeta(data.stopReason === 'stopped' ? 'Gestoppt' : 'Bereit');
break;
}
}
// ── Plan Mode ──
let _planModeActive = false;
function togglePlanMode() {
_planModeActive = !_planModeActive;
var btn = $('#planModeBtn');
if (btn) {
btn.classList.toggle('plan-mode-active', _planModeActive);
btn.title = _planModeActive ? 'Plan-Modus deaktivieren' : 'Plan-Modus aktivieren';
}
var input = $('#input');
if (input) {
input.placeholder = _planModeActive ? 'Plannen...' : 'Nachricht...';
}
}
// ── Chat State ──
let isStreaming = false;
let _lastDoneChainData = null; // Chain-Daten vom letzten done-Event
let _panelUsage = null; // Panel-Member-Tokens (panel_usage-Event) → werden auf die Worker-Tokens des done-Events addiert
// Addiert die live gesammelten Panel-Member-Tokens auf ein Chain-/done-Objekt, sodass das
// Token-Badge die Gesamtkosten (Worker + alle Panel-Member aller Runden) zeigt.
function _addPanelUsage(target, pu) {
if (!target || !pu) return;
target.inputTokens = (target.inputTokens || 0) + (pu.inputTokens || 0);
target.outputTokens = (target.outputTokens || 0) + (pu.outputTokens || 0);
target.cacheCreationTokens = (target.cacheCreationTokens || 0) + (pu.cacheCreationTokens || 0);
target.cacheReadTokens = (target.cacheReadTokens || 0) + (pu.cacheReadTokens || 0);
}
let _currentToolBox = null; // Aktuell laufende Tool-Box (für Stream-Events)
let _lastFinishedToolBox = null; // Letzte fertige Tool-Box (für tool_result Zuordnung)
let _chatAbandoned = false; // true wenn User weg-navigiert während Stream läuft
let _abortController = null; // AbortController für laufenden Stream
// ── Typing Indicator ──
let _currentAgentName = null;
let _typingInterval = null;
function setCurrentAgentName(name) {
_currentAgentName = name;
}
function _randomTypingPhrase() {
return TYPING_PHRASES[Math.floor(Math.random() * TYPING_PHRASES.length)];
}
function _buildTypingHtml(phrase) {
var name = _currentAgentName || 'Agent';
return '<span><i class="fa fa-hourglass-half"></i> ' + esc(name) + ' ' + esc(phrase) + '...</span>';
}
function startTypingIndicator() {
setMetaHtml(_buildTypingHtml(_randomTypingPhrase()));
_typingInterval = setInterval(function() {
setMetaHtml(_buildTypingHtml(_randomTypingPhrase()));
}, 3000);
}
function stopTypingIndicator() {
if (_typingInterval) {
clearInterval(_typingInterval);
_typingInterval = null;
}
}
async function clearChat() {
const archive = await apiArchive(getCurrentSessionId());
if (!archive) return;
chatEl().innerHTML = '';
lastKnownMessageId = 0;
loadedArchiveCount = 0;
_oldestLoadedSortKey = 0;
_hasMoreOlderMessages = false;
_autoScroll = true;
clearReply();
setMeta(`Archiviert: ${archive.chainCount} Chains, ${archive.messageCount} Messages`);
showArchiveLoadButton();
}
let _stopClickCount = 0;
function stopStreaming() {
_stopClickCount++;
if (_stopClickCount >= 2) {
// Hard-Stop: Sofortiger Abbruch (Doppel-Klick)
var btn = $('#stop');
if (btn) {
btn.classList.add('stop-hard');
btn.title = 'Hard-Stop gesendet!';
}
// SSE-Stream clientseitig sofort abbrechen
if (_abortController) _abortController.abort();
}
// Backend explizit stoppen (graceful beim 1. Klick, hard beim 2.)
var sid = getCurrentSessionId();
if (sid) apiStopChat(sid).catch(function() {});
}
// ── SSE Stream Runner (shared between sendMessage + handleChoiceClick) ──
async function _runStream(fetchPromise) {
const sendBtn = $('#send');
const stopBtn = $('#stop');
const sid = getCurrentSessionId();
sendBtn.disabled = true;
sendBtn.hidden = true;
stopBtn.hidden = false;
isStreaming = true;
_panelUsage = null; // frische Chain → alte Panel-Tokens verwerfen
_abortController = new AbortController();
// Eine Live-SSE-Antwort rendert ALLEIN. Einen evtl. noch laufenden Recovery-Poll
// (_streamingPollInterval – gestartet beim Betreten des Chats WAEHREND eine Chain
// lief) hier stoppen. Sonst rendert dessen pollHistory(true) dieselben DB-Nachrichten
// ein zweites Mal NEBEN den Live-Bubbles (die lastKnownMessageId nicht vorruecken)
// => Doppel-Nachrichten nach Eintritt in eine laufende Chain.
if (_streamingPollInterval) {
clearInterval(_streamingPollInterval);
_streamingPollInterval = null;
}
_currentToolBox = null;
_chatAbandoned = false;
_stopClickCount = 0;
startTypingIndicator();
// Während des Streams wird die Eingabe zum Steering-Einwurf (Enter wirft ein).
const _steerInputEl = $('#input');
const _prevPlaceholder = _steerInputEl ? _steerInputEl.placeholder : '';
if (_steerInputEl) {
_steerInputEl.placeholder = 'Einwurf...';
_steerInputEl.classList.add('steer-mode');
}
try {
const resp = await fetchPromise(_abortController.signal);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let eventType = null;
while (true) {
const { done, value } = await reader.read();
if (done || _chatAbandoned) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.substring(7).trim();
} else if (line.startsWith('data: ') && eventType) {
let data;
try { data = JSON.parse(line.substring(6)); } catch { continue; }
handleStreamEvent(eventType, data);
eventType = null;
}
}
}
} catch (e) {
if (_chatAbandoned) return;
endStreamBubble();
if (e.name === 'AbortError') {
setMeta('Gestoppt');
} else {
try {
const status = await apiIsStreaming(sid);
if (status.streaming) {
startTypingIndicator();
startStreamingPoll(sid, sendBtn, status.chainId);
return;
}
} catch (_) {}
addErrorBubble('Netzwerk-Fehler: ' + e.message);
}
} finally {
if (_chatAbandoned) return;
stopTypingIndicator();
if (!isStreaming || !_streamingPollInterval) {
sendBtn.disabled = false;
sendBtn.hidden = false;
stopBtn.hidden = true;
stopBtn.classList.remove('stop-hard');
stopBtn.title = 'Antwort abbrechen';
isStreaming = false;
_abortController = null;
// Regulaeres History-Polling idempotent wieder sicherstellen: Falls beim Eintritt
// in eine laufende Chain auf den Recovery-Poll umgeschaltet (und History-Polling
// gestoppt) wurde, ist es nach dem Live-Stream sonst aus. startHistoryPolling()
// hat selbst einen Guard (if (historyPollInterval) return) => kein Doppel-Intervall.
startHistoryPolling();
// Steering-Hinweis zurücksetzen (Eingabe ist wieder normales Senden)
if (_steerInputEl) {
_steerInputEl.placeholder = _prevPlaceholder;
_steerInputEl.classList.remove('steer-mode');
}
// Meta zurücksetzen falls kein done-Event kam (z.B. nach Stop)
var metaEl = $('#meta');
if (metaEl && (metaEl.textContent.includes('Streaming') || metaEl.textContent.includes('...'))) {
setMeta(_stopClickCount > 0 ? 'Gestoppt' : 'Bereit');
}
}
endStreamBubble();
removeStreamedElements();
await pollHistory();
if (_lastDoneChainData) {
addChainMetaBubble(_lastDoneChainData, false);
_lastDoneChainData = null;
}
}
}
// ── Send Message ──
async function sendMessage() {
const input = $('#input');
if (isStreaming) {
// Steering: Während eine Chain läuft, wird die Nachricht als Einwurf in die
// laufende Arbeit eingespeist (statt wie früher hart blockiert zu werden).
const steerText = input.value.trim();
if (steerText) sendSteer(steerText);
return;
}
const text = input.value.trim();
if (!text && pendingFiles.length === 0 && pendingRefs.length === 0) return;
// Content bauen: string oder array mit file-blocks
let content;
let bubbleText = text;
// Reply-Zitat einbauen
let msgText = text;
if (pendingReply) {
const roleLabel = pendingReply.role === 'user' ? 'User' : 'Assistant';
const timeLabel = formatTimestamp(pendingReply.timestamp) || 'Jetzt';
const quoteText = pendingReply.text.split('\n').map(l => '> ' + l).join('\n');
const quoteHeader = `> [Zitat: ${timeLabel}, ${roleLabel}]:`;
msgText = quoteHeader + '\n' + quoteText + '\n\n' + (text || '');
clearReply();
}
// Refs in den Text einbauen
if (pendingRefs.length > 0) {
const refLines = pendingRefs.map(r => {
if (r.from && r.to && r.from !== r.to) return `[Datei: ${r.path}:${r.from}-${r.to}]`;
if (r.from) return `[Datei: ${r.path}:${r.from}]`;
return `[Datei: ${r.path}]`;
});
msgText = refLines.join(' ') + '\n' + msgText;
if (pendingRefs.some(r => r.from)) {
if (pendingRefs.length > 1)
msgText += `\n<hint>Lies referenzierte Dateien mit \`cat -n\`.</hint>`;
else
msgText += `\n<hint>Lies die referenzierte Datei mit \`cat -n\`.</hint>`;
}
clearPendingRefs();
}
if (_planModeActive) {
msgText += `\n<hint>Plan-Modus ist aktiv. KEINE Dateien ändern, KEINE Shell-Befehle (außer read-only), KEINE Commits. Nur analysieren, Rückfragen stellen und Plan erstellen.</hint>`;
}
let inlineImages = []; // Bilder für Inline-Anzeige
if (pendingFiles.length > 0) {
content = [];
const labels = [];
for (const f of pendingFiles) {
const blockType = f.type.startsWith('image/') ? 'image' : 'document';
content.push({
type: blockType,
source: { type: 'base64', media_type: f.type, data: f.base64 },
_file_name: f.name
});
if (blockType === 'image') {
inlineImages.push({ name: f.name, dataUrl: 'data:' + f.type + ';base64,' + f.base64 });
}
labels.push('[' + (blockType === 'image' ? 'Bild' : 'Datei') + ': ' + f.name + ']');
}
if (msgText) content.push({ type: 'text', text: msgText });
bubbleText = labels.join('\n') + (msgText ? '\n' + msgText : '');
clearPendingFiles();
} else {
content = msgText;
bubbleText = msgText;
}
addUserBubble(bubbleText, true, '', null, false, null, inlineImages);
scrollDownForce(); // Beim Senden immer nach unten
input.value = '';
input.dispatchEvent(new Event('input'));
await _runStream((signal) => apiChat(content, getCurrentSessionId(), signal, _planModeActive));
}
// ── Steering (Einwurf in die laufende Chain) ──
async function sendSteer(text) {
const input = $('#input');
// Eingabe leeren + optimistische Bubble (wird am Stream-Ende durch die DB-Version ersetzt)
input.value = '';
input.dispatchEvent(new Event('input'));
const bubble = addSteerBubble(text, true);
scrollDownForce();
let res;
try {
res = await apiChatSteer(getCurrentSessionId(), text);
} catch (e) {
if (bubble) bubble.remove();
input.value = text;
input.dispatchEvent(new Event('input'));
addWarningBubble('Einwurf fehlgeschlagen: ' + (e.message || e));
return;
}
if (!res || !res.injected) {
// Race: die laufende Antwort ist gerade fertig geworden → Text zurücklegen, normal erneut senden
if (bubble) bubble.remove();
input.value = text;
input.dispatchEvent(new Event('input'));
addWarningBubble('Die laufende Antwort ist bereits beendet – bitte erneut senden.');
}
}
// ── Direct Message (für Inline-Choice-Buttons) ──
async function sendMessageDirect(text) {
if (isStreaming || !text) return;
addUserBubble(text, true);
scrollDownForce();
await _runStream((signal) => apiChat(text, getCurrentSessionId(), signal, _planModeActive));
}
// ── Choice Click Handler ──
async function handleChoiceClick(toolCallId, choice, bubbleDiv) {
// Buttons deaktivieren und gewählte Option markieren
const buttons = bubbleDiv.querySelectorAll('.choice-btn');
var matchedButton = false;
buttons.forEach(btn => {
btn.disabled = true;
if (btn.textContent === choice) {
btn.classList.add('choice-selected');
matchedButton = true;
} else {
btn.classList.add('choice-unselected');
}
});
// Freitext-Antwort: Keine der Buttons matched → Antwort als Text anzeigen
if (!matchedButton) {
var customDiv = bubbleDiv.querySelector('.choice-custom');
if (customDiv) {
customDiv.innerHTML = '';
customDiv.classList.add('has-answer');
var answerSpan = document.createElement('span');
answerSpan.className = 'choice-custom-answer';
answerSpan.textContent = '↳ ' + choice;
customDiv.appendChild(answerSpan);
}
}
bubbleDiv.classList.add('choice-answered');
// CLI-Modus: Antwort über internen Endpoint an MCP-Server weiterleiten (CLI läuft weiter)
if (bubbleDiv.dataset.viaCli === 'true') {
try {
await apiChoiceRespond(getCurrentSessionId(), choice);
} catch (e) {
console.error('Choice respond failed:', e);
}
return;
}
// API-Modus: Normaler Chat-Flow (neue Chain)
await _runStream((signal) => apiChatChoice(getCurrentSessionId(), toolCallId, choice, signal, _planModeActive));
}
// ── Inline Choice Blocks (Fallback aus Markdown-Rendering) ──
// Event-Delegation für .inline-choice-btn Buttons die von renderMarkdown() erzeugt wurden
document.addEventListener('click', function(e) {
var btn = e.target.closest('.inline-choice-btn');
if (!btn) return;
var block = btn.closest('.inline-choice-block');
if (!block || block.classList.contains('choice-answered')) return;
var choice = btn.dataset.choice;
// Buttons deaktivieren + markieren
block.querySelectorAll('.inline-choice-btn').forEach(function(b) {
b.disabled = true;
b.classList.add(b === btn ? 'choice-selected' : 'choice-unselected');
});
block.classList.add('choice-answered');
// Inline-Choice als normale User-Nachricht senden (kein tool_use in DB vorhanden)
sendMessageDirect(choice);
});