memberUsage was already populated for CLI members (from the result event) and Anthropic-API members (via the tool loop), but it was never written to the panel_member_done SSE event or the persisted content JSON, so the frontend had no token data to render. Add the token fields (input/output/cacheRead/cacheCreation) to both the SSE event and the persisted JSON, and render them via a shared buildPanelMemberMeta helper used by both the live stream and history, so the badge survives F5. OpenAI members (no per-turn tokens), error members, and pre-existing history without token fields fall through to no badge. The badge is display-only; aggregation into the worker/chain total via seedUsage is unchanged, so there is no double counting and no DB migration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
379 lines
14 KiB
JavaScript
379 lines
14 KiB
JavaScript
// ── Streaming Bubble ──
|
||
|
||
let _streamDiv = null;
|
||
let _streamText = '';
|
||
|
||
function startStreamBubble() {
|
||
_streamDiv = document.createElement('div');
|
||
_streamDiv.className = 'msg assistant markdown streamed';
|
||
_streamDiv.innerHTML = '<p><span class="cursor"></span></p>';
|
||
_streamText = '';
|
||
chatEl().appendChild(_streamDiv);
|
||
scrollDown();
|
||
}
|
||
|
||
function appendToStream(text) {
|
||
if (!_streamDiv) startStreamBubble();
|
||
_streamText += text;
|
||
|
||
_streamDiv.innerHTML = renderMarkdown(_streamText);
|
||
// Cursor ins letzte Inline/Text-Element einfügen, damit er nicht auf eine neue Zeile rutscht
|
||
const cursor = document.createElement('span');
|
||
cursor.className = 'cursor';
|
||
const lastBlock = _streamDiv.lastElementChild;
|
||
if (lastBlock) {
|
||
lastBlock.appendChild(cursor);
|
||
} else {
|
||
_streamDiv.appendChild(cursor);
|
||
}
|
||
scrollDown();
|
||
}
|
||
|
||
function pauseStreamBubble() {
|
||
// Cursor entfernen, aber Bubble + Text behalten (z.B. bei Web-Search-Pause)
|
||
if (_streamDiv) {
|
||
const cursor = _streamDiv.querySelector('.cursor');
|
||
if (cursor) cursor.remove();
|
||
}
|
||
}
|
||
|
||
function endStreamBubble() {
|
||
if (_streamDiv) {
|
||
const cursor = _streamDiv.querySelector('.cursor');
|
||
if (cursor) cursor.remove();
|
||
if (!_streamText.trim()) _streamDiv.remove();
|
||
else {
|
||
_streamDiv.innerHTML = renderMarkdown(_streamText);
|
||
parseFluentEmojis(_streamDiv);
|
||
wrapKreaGalleries(_streamDiv);
|
||
}
|
||
}
|
||
_streamDiv = null;
|
||
_streamText = '';
|
||
}
|
||
|
||
// ── Thinking Bubble (Nvidia Reasoning) ──
|
||
|
||
let _thinkingDiv = null;
|
||
let _thinkingText = '';
|
||
|
||
function startThinkingBubble() {
|
||
_thinkingDiv = document.createElement('div');
|
||
_thinkingDiv.className = 'tool-box thinking-box streamed';
|
||
var header = document.createElement('div');
|
||
header.className = 'tool-box-header clickable';
|
||
header.innerHTML = '<span class="tool-name">Denkprozess</span>';
|
||
var content = document.createElement('div');
|
||
content.className = 'thinking-content markdown';
|
||
content.hidden = true;
|
||
content.innerHTML = '<p><span class="cursor"></span></p>';
|
||
header.addEventListener('click', function() { content.hidden = !content.hidden; });
|
||
_thinkingDiv.appendChild(header);
|
||
_thinkingDiv.appendChild(content);
|
||
_thinkingText = '';
|
||
chatEl().appendChild(_thinkingDiv);
|
||
scrollDown();
|
||
}
|
||
|
||
function appendToThinking(text) {
|
||
if (!_thinkingDiv) startThinkingBubble();
|
||
_thinkingText += text;
|
||
var contentEl = _thinkingDiv.querySelector('.thinking-content');
|
||
contentEl.innerHTML = renderMarkdown(_thinkingText);
|
||
var cursor = document.createElement('span');
|
||
cursor.className = 'cursor';
|
||
var lastBlock = contentEl.lastElementChild;
|
||
if (lastBlock) lastBlock.appendChild(cursor);
|
||
else contentEl.appendChild(cursor);
|
||
scrollDown();
|
||
}
|
||
|
||
function endThinkingBubble() {
|
||
if (_thinkingDiv) {
|
||
var cursor = _thinkingDiv.querySelector('.cursor');
|
||
if (cursor) cursor.remove();
|
||
var contentEl = _thinkingDiv.querySelector('.thinking-content');
|
||
if (!_thinkingText.trim()) _thinkingDiv.remove();
|
||
else {
|
||
contentEl.innerHTML = renderMarkdown(_thinkingText);
|
||
contentEl.hidden = true;
|
||
}
|
||
}
|
||
_thinkingDiv = null;
|
||
_thinkingText = '';
|
||
}
|
||
|
||
// ── Auto-Context Bubble ──
|
||
|
||
function addAutoContextBubble(contextText) {
|
||
if (!contextText) return;
|
||
var box = document.createElement('div');
|
||
box.className = 'tool-box auto-context-box streamed';
|
||
var header = document.createElement('div');
|
||
header.className = 'tool-box-header clickable';
|
||
header.innerHTML = '<span class="tool-name">Auto-Context</span>';
|
||
var content = document.createElement('div');
|
||
content.className = 'auto-context-content markdown';
|
||
content.hidden = true;
|
||
// recalled_context aus XML-Tags extrahieren
|
||
var inner = contextText.replace(/<\/?recalled_context>/g, '').trim();
|
||
content.innerHTML = renderMarkdown(inner);
|
||
header.addEventListener('click', function() { content.hidden = !content.hidden; });
|
||
box.appendChild(header);
|
||
box.appendChild(content);
|
||
chatEl().appendChild(box);
|
||
scrollDown();
|
||
}
|
||
|
||
// ── Review Bubbles (Streaming) ──
|
||
|
||
let _reviewBubble = null;
|
||
let _reviewTextEl = null;
|
||
let _reviewText = '';
|
||
|
||
function addReviewBubble(type, data, agentType) {
|
||
_reviewBubble = document.createElement('div');
|
||
_reviewBubble.className = 'msg review-bubble streamed';
|
||
if (agentType === 'finalizer') _reviewBubble.classList.add('finalizer-bubble');
|
||
if (agentType === 'preparer') _reviewBubble.classList.add('preparer-bubble');
|
||
_reviewBubble.appendChild(_createSubAgentHeader(agentType || 'review', data.agentName, data));
|
||
_reviewTextEl = document.createElement('div');
|
||
_reviewTextEl.className = 'review-content markdown';
|
||
_reviewBubble.appendChild(_reviewTextEl);
|
||
_reviewText = '';
|
||
chatEl().appendChild(_reviewBubble);
|
||
scrollDown();
|
||
}
|
||
|
||
function appendToReviewText(text) {
|
||
if (!_reviewBubble) return;
|
||
_reviewText += text;
|
||
_reviewTextEl.innerHTML = renderMarkdown(_reviewText);
|
||
scrollDown();
|
||
}
|
||
|
||
let _currentReviewToolBox = null;
|
||
|
||
function addReviewToolUse(data) {
|
||
if (!_reviewBubble) return;
|
||
if (!isToolVisible(data.toolName)) {
|
||
_currentReviewToolBox = null;
|
||
return;
|
||
}
|
||
var toolDiv = document.createElement('div');
|
||
toolDiv.className = 'tool-box review-tool-box streamed';
|
||
toolDiv.dataset.toolName = data.toolName;
|
||
var displayName = TOOL_DISPLAY_NAMES[data.toolName] || data.toolName;
|
||
toolDiv.innerHTML = '<span class="tool-name">' + esc(displayName) + '</span> <span class="tool-spinner"><i class="fa fa-hourglass-half"></i></span>';
|
||
_reviewBubble.appendChild(toolDiv);
|
||
_currentReviewToolBox = toolDiv;
|
||
scrollDown();
|
||
}
|
||
|
||
function finishReviewToolBox(data) {
|
||
if (_currentReviewToolBox) {
|
||
renderToolBoxContent(_currentReviewToolBox, data.toolName, data.input, data.result, data.isError, data.sources);
|
||
_currentReviewToolBox = null;
|
||
scrollDown();
|
||
}
|
||
}
|
||
|
||
function finalizeReviewBubble(type, data, agentType) {
|
||
if (!_reviewBubble) return;
|
||
const status = document.createElement('div');
|
||
status.className = 'review-status';
|
||
if (agentType === 'preparer' && type === 'forwarded') {
|
||
status.className += ' review-approved';
|
||
status.innerHTML = '<i class="fa fa-arrow-right"></i> Weitergeleitet an Session-Agent';
|
||
} else if (agentType === 'preparer' && type === 'query') {
|
||
status.className += ' review-rejected';
|
||
status.innerHTML = '<i class="fa fa-question-circle"></i> Rückfrage an User';
|
||
} else if (agentType === 'finalizer' || type === 'done') {
|
||
status.className += ' review-approved';
|
||
status.innerHTML = '<i class="fa fa-flag-checkered"></i> Abgeschlossen';
|
||
} else if (type === 'approved') {
|
||
status.className += ' review-approved';
|
||
status.innerHTML = '<i class="fa fa-check"></i> Approved';
|
||
} else {
|
||
status.className += ' review-rejected';
|
||
status.innerHTML = '<i class="fa fa-repeat"></i> Feedback – Worker korrigiert...';
|
||
}
|
||
_reviewBubble.appendChild(status);
|
||
_reviewBubble = null;
|
||
_reviewTextEl = null;
|
||
_reviewText = '';
|
||
_currentReviewToolBox = null;
|
||
scrollDown();
|
||
}
|
||
|
||
// ── Panel Bubbles (Fusion/MoA: mehrere Member streamen parallel LIVE) ──
|
||
// Pro Member eine eigene Bubble. Bei MoA wiederholen sich die Member über mehrere Runden
|
||
// (gleicher index, andere layer) → Bubbles werden per "layer_index" gekeyt. Da die Runden
|
||
// SEQUENZIELL laufen, genügt es, die aktive Runde pro index zu merken (gesetzt bei
|
||
// panel_member_start); die Live-Text/Tool-Events tragen nur den index und finden so ihre Bubble.
|
||
|
||
let _panelBubbles = {};
|
||
let _panelActiveLayer = {}; // index -> aktuell laufende Runde (MoA)
|
||
|
||
function _panelKey(index, layer) { return (layer || 1) + '_' + index; }
|
||
function _panelGetBubble(index) { return _panelBubbles[_panelKey(index, _panelActiveLayer[index])]; }
|
||
|
||
function startPanel(data) {
|
||
endStreamBubble();
|
||
_panelBubbles = {};
|
||
_panelActiveLayer = {};
|
||
}
|
||
|
||
// MoA: Runden-Trenner (nur bei mehr als einer Runde sichtbar).
|
||
function startPanelLayer(data) {
|
||
if (!data || !data.total || data.total <= 1) return;
|
||
var div = document.createElement('div');
|
||
div.className = 'panel-layer-divider';
|
||
div.innerHTML = '<span><i class="fa fa-clone"></i> Runde ' + data.layer + ' / ' + data.total + '</span>';
|
||
chatEl().appendChild(div);
|
||
scrollDown();
|
||
}
|
||
|
||
function addPanelMemberBubble(data) {
|
||
var layer = data.layer || 1;
|
||
var bubble = document.createElement('div');
|
||
bubble.className = 'msg review-bubble panel-bubble streamed';
|
||
var model = (typeof prettyModelName === 'function') ? prettyModelName(data.model) : data.model;
|
||
bubble.appendChild(_createSubAgentHeader('panel', data.name, { model: model }));
|
||
var spinner = document.createElement('div');
|
||
spinner.className = 'review-content markdown';
|
||
spinner.innerHTML = '<span class="tool-spinner"><i class="fa fa-hourglass-half"></i></span> denkt nach…';
|
||
bubble.appendChild(spinner);
|
||
bubble._panelSpinner = spinner; // wird beim ersten Text/Tool entfernt
|
||
bubble._segText = null; // aktuell offenes Text-Segment (oder null)
|
||
bubble._segTextStr = ''; // dessen akkumulierter Text
|
||
bubble._toolBoxes = {}; // toolId -> offene Tool-Box (CLI: per ID korreliert → robust bei parallelen Tools)
|
||
bubble._fullText = ''; // gesamter gestreamter Text (für Finalize-Fallback)
|
||
_panelActiveLayer[data.index] = layer; // ab jetzt zeigen Events für diesen index auf diese Runde
|
||
_panelBubbles[_panelKey(data.index, layer)] = bubble;
|
||
chatEl().appendChild(bubble);
|
||
scrollDown();
|
||
}
|
||
|
||
function _panelClearSpinner(bubble) {
|
||
if (bubble._panelSpinner) { bubble._panelSpinner.remove(); bubble._panelSpinner = null; }
|
||
}
|
||
|
||
function _panelRemoveCursor(bubble) {
|
||
var c = bubble.querySelector('.cursor');
|
||
if (c) c.remove();
|
||
}
|
||
|
||
function appendPanelMemberText(data) {
|
||
var bubble = _panelGetBubble(data.index);
|
||
if (!bubble || !data.text) return;
|
||
_panelClearSpinner(bubble);
|
||
// Neues Text-Segment beginnen, wenn keins offen ist (z.B. direkt nach einer Tool-Box)
|
||
if (!bubble._segText) {
|
||
bubble._segText = document.createElement('div');
|
||
bubble._segText.className = 'review-content markdown';
|
||
bubble._segTextStr = '';
|
||
bubble.appendChild(bubble._segText);
|
||
}
|
||
bubble._segTextStr += data.text;
|
||
bubble._fullText += data.text;
|
||
bubble._segText.innerHTML = renderMarkdown(bubble._segTextStr);
|
||
// Blinkender Cursor ans Ende des Segments (wie beim Worker-Stream) → "schreibt gerade"
|
||
var cursor = document.createElement('span');
|
||
cursor.className = 'cursor';
|
||
var lastBlock = bubble._segText.lastElementChild;
|
||
if (lastBlock) lastBlock.appendChild(cursor);
|
||
else bubble._segText.appendChild(cursor);
|
||
scrollDown();
|
||
}
|
||
|
||
function addPanelMemberToolUse(data) {
|
||
var bubble = _panelGetBubble(data.index);
|
||
if (!bubble) return;
|
||
_panelClearSpinner(bubble);
|
||
_panelRemoveCursor(bubble); // Cursor aus dem laufenden Text-Segment entfernen
|
||
bubble._segText = null; // Text-Segment schließen → Folge-Text kommt UNTER die Tool-Box
|
||
var nm = normalizeToolName(data.name); // CLI-Name (Bash) → gott-Konvention (execute) – konsistent zur fertigen Box
|
||
if (!isToolVisible(nm)) return;
|
||
var toolDiv = document.createElement('div');
|
||
toolDiv.className = 'tool-box review-tool-box streamed';
|
||
toolDiv.dataset.toolName = nm;
|
||
var displayName = TOOL_DISPLAY_NAMES[nm] || nm;
|
||
toolDiv.innerHTML = '<span class="tool-name">' + esc(displayName) + '</span> <span class="tool-spinner"><i class="fa fa-hourglass-half"></i></span>';
|
||
bubble.appendChild(toolDiv);
|
||
if (data.id) bubble._toolBoxes[data.id] = toolDiv; // per ID korreliert (mehrere Tools parallel)
|
||
scrollDown();
|
||
}
|
||
|
||
function finishPanelMemberTool(data) {
|
||
var bubble = _panelGetBubble(data.index);
|
||
if (!bubble) return;
|
||
_panelClearSpinner(bubble);
|
||
bubble._segText = null; // nach Tool-Result neuen Text-Block beginnen
|
||
// CLI: offene Box per ID finden; Anthropic-Loop (kombiniert, ohne ID) → neue Box anlegen
|
||
var box = (data.id && bubble._toolBoxes && bubble._toolBoxes[data.id]) ? bubble._toolBoxes[data.id] : null;
|
||
if (!box) {
|
||
if (!isToolVisible(normalizeToolName(data.name))) return;
|
||
box = document.createElement('div');
|
||
box.className = 'tool-box review-tool-box streamed';
|
||
bubble.appendChild(box);
|
||
}
|
||
// data.name = roher Name, data.input = roher Input-JSON → renderToolBoxContent normalisiert beides
|
||
renderToolBoxContent(box, data.name, data.input || null, data.result, data.isError, null);
|
||
if (data.id && bubble._toolBoxes) delete bubble._toolBoxes[data.id];
|
||
scrollDown();
|
||
}
|
||
|
||
// Baut ein kompaktes Token-Badge für eine Panel-Member-Bubble (live + History, gleiche Quelle).
|
||
// Gibt ein DOM-Element zurück oder null, wenn keine Token-Daten vorliegen: OpenAI-Member
|
||
// (liefert keine per-turn Tokens), Fehler-Member oder Alt-History ohne Token-Felder → kein Badge.
|
||
function buildPanelMemberMeta(d) {
|
||
if (!d) return null;
|
||
var inT = d.inputTokens || 0, outT = d.outputTokens || 0;
|
||
var cacheR = d.cacheReadTokens || 0, cacheC = d.cacheCreationTokens || 0;
|
||
if (!inT && !outT && !cacheR && !cacheC) return null;
|
||
var badges = ['Tokens: ' + inT + ' / ' + outT];
|
||
if (cacheR > 0 || cacheC > 0) badges.push('Cache: ' + cacheR + ' / ' + cacheC);
|
||
var meta = document.createElement('div');
|
||
meta.className = 'panel-member-meta';
|
||
meta.innerHTML = badges.map(function (b) {
|
||
return '<span class="chain-meta-badge">' + esc(b) + '</span>';
|
||
}).join('');
|
||
return meta;
|
||
}
|
||
|
||
function finalizePanelMember(data) {
|
||
var bubble = _panelGetBubble(data.index);
|
||
if (!bubble) return;
|
||
_panelClearSpinner(bubble);
|
||
_panelRemoveCursor(bubble);
|
||
// Verwaiste Tool-Boxen (Tool ohne Result, z.B. in Plan-Mode geblockt) → Spinner entfernen,
|
||
// damit keine Box mit endlosem "⏳" stehen bleibt.
|
||
if (bubble._toolBoxes) {
|
||
for (var tid in bubble._toolBoxes) {
|
||
var sp = bubble._toolBoxes[tid].querySelector('.tool-spinner');
|
||
if (sp) sp.remove();
|
||
}
|
||
bubble._toolBoxes = {};
|
||
}
|
||
// Wenn LIVE nichts gestreamt wurde (z.B. OpenAI-Member = one-shot): finalen Text rendern.
|
||
if (!bubble._fullText || !bubble._fullText.trim()) {
|
||
var text = (data.text && data.text.trim()) ? data.text : '_(keine Antwort)_';
|
||
if (!bubble._segText) {
|
||
bubble._segText = document.createElement('div');
|
||
bubble._segText.className = 'review-content markdown';
|
||
bubble.appendChild(bubble._segText);
|
||
}
|
||
bubble._segText.innerHTML = renderMarkdown(text);
|
||
}
|
||
var meta = buildPanelMemberMeta(data);
|
||
if (meta) bubble.appendChild(meta);
|
||
delete _panelBubbles[_panelKey(data.index, _panelActiveLayer[data.index])];
|
||
scrollDown();
|
||
}
|
||
|
||
function endPanel(data) {
|
||
_panelBubbles = {};
|
||
_panelActiveLayer = {};
|
||
}
|