Previously only the final proposer layer was persisted, so after a reload the intermediate MoA rounds and their "Runde k/N" dividers were gone — the history showed only the last refined round, not the full live view. Persist every proposer round instead, tagging each member's JSON transcript with its layer index, and rebuild the round structure on reload. - AnthropicService.Panel.cs: persist every round unconditionally; drop the now-dead persist parameter and isFinalLayer local; write layer/totalLayers into the transcript; iteration = layer*100+index+1 so DB rows are layer-aware (no UNIQUE constraint on chain_id/iteration, id-based ordering) - history.js: group panel bubbles by layer on reload and insert the "Runde k / N" divider on round change for MoA (totalLayers > 1), matching the live startPanelLayer view; back-compat for old format (default layer 1) - TODO.md: check off the MoA persistence item Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
671 lines
26 KiB
JavaScript
671 lines
26 KiB
JavaScript
// ── Web Search Sources Extraction ──
|
||
// Unterstützt zwei Formate:
|
||
// - Alt: JSON-Array von {url, title} (nur Sources)
|
||
// - Neu: Raw web_search_tool_result JSON (content[].type === 'web_search_result')
|
||
function _extractWebSearchSources(content) {
|
||
try {
|
||
var parsed = JSON.parse(content || '[]');
|
||
// Altes Format: direktes Array von Sources
|
||
if (Array.isArray(parsed)) return parsed;
|
||
// Neues Format: Raw web_search_tool_result mit content-Array
|
||
if (parsed.content && Array.isArray(parsed.content)) {
|
||
var sources = [];
|
||
parsed.content.forEach(function(item) {
|
||
if (item.type === 'web_search_result' && item.url) {
|
||
sources.push({ url: item.url, title: item.title || '' });
|
||
}
|
||
});
|
||
return sources;
|
||
}
|
||
return [];
|
||
} catch(e) { return []; }
|
||
}
|
||
|
||
// ── Image Attachments Auto-Loading ──
|
||
|
||
async function _autoLoadImageAttachments(messageId) {
|
||
// Finde die User-Bubble mit dieser messageId
|
||
const bubble = document.querySelector('.msg.user[data-message-id="' + messageId + '"]');
|
||
if (!bubble) return;
|
||
const placeholders = bubble.querySelectorAll('.inline-image-placeholder');
|
||
if (placeholders.length === 0) return;
|
||
|
||
try {
|
||
const resp = await fetch('/api/attachments/' + messageId);
|
||
const attachments = await resp.json();
|
||
const imageAtts = attachments.filter(function(a) { return a.type === 'image'; });
|
||
|
||
placeholders.forEach(function(ph) {
|
||
const filename = ph.dataset.filename;
|
||
// Bild anhand Dateiname zuordnen (verbrauchend via splice – bei identischen
|
||
// Dateinamen würde find() sonst immer das erste Bild liefern), oder der Reihe nach
|
||
const idx = imageAtts.findIndex(function(a) { return a.fileName === filename; });
|
||
const att = idx !== -1 ? imageAtts.splice(idx, 1)[0] : imageAtts.shift();
|
||
if (att) {
|
||
const img = document.createElement('img');
|
||
img.className = 'inline-user-image';
|
||
img.src = 'data:' + att.mediaType + ';base64,' + att.data;
|
||
img.alt = filename;
|
||
img.title = filename;
|
||
img.addEventListener('click', function() { showKreaImageModal(filename, img.src); });
|
||
ph.replaceWith(img);
|
||
}
|
||
});
|
||
} catch (e) {
|
||
// Platzhalter bleiben stehen bei Fehler
|
||
}
|
||
}
|
||
|
||
// ── History State ──
|
||
|
||
let lastKnownMessageId = 0;
|
||
let _oldestLoadedSortKey = 0;
|
||
let _hasMoreOlderMessages = false;
|
||
let historyPollInterval = null;
|
||
|
||
|
||
// ── History Loading ──
|
||
|
||
async function loadHistory() {
|
||
try {
|
||
const sid = getCurrentSessionId();
|
||
|
||
// Auto-Scroll initialisieren (einmalig pro Session-Load)
|
||
initAutoScroll();
|
||
|
||
const data = await apiHistory(sid, null, isDeletedVisible());
|
||
const entries = data.entries || [];
|
||
|
||
// Älteste geladene Message-ID merken für Nachladen
|
||
_hasMoreOlderMessages = data.hasMore;
|
||
_oldestLoadedSortKey = _findOldestSortKey(entries);
|
||
|
||
if (data.hasMore) {
|
||
showOlderMessagesButton(data.remainingCount);
|
||
} else {
|
||
// Keine älteren Nachrichten mehr → Archiv-Button anzeigen falls vorhanden
|
||
const archives = await apiListArchives(sid);
|
||
if (archives.length > 0) {
|
||
showArchiveLoadButton();
|
||
}
|
||
}
|
||
|
||
for (const entry of entries) {
|
||
renderMessages(entry.messages, true);
|
||
if (entry.chain) addChainMetaBubble(entry.chain, false);
|
||
}
|
||
|
||
// Beim initialen Laden immer ganz nach unten scrollen
|
||
scrollDownForce();
|
||
setMeta('Bereit');
|
||
startHistoryPolling();
|
||
} catch (e) {
|
||
console.error('History laden fehlgeschlagen:', e);
|
||
}
|
||
}
|
||
|
||
async function loadOlderMessages() {
|
||
if (!_hasMoreOlderMessages || !_oldestLoadedSortKey) return;
|
||
|
||
const btn = chatEl().querySelector('.older-messages-btn');
|
||
if (btn) {
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<i class="fa fa-hourglass-half"></i> Wird geladen...';
|
||
}
|
||
|
||
try {
|
||
const sid = getCurrentSessionId();
|
||
const data = await apiHistory(sid, _oldestLoadedSortKey, isDeletedVisible());
|
||
const entries = data.entries || [];
|
||
|
||
_hasMoreOlderMessages = data.hasMore;
|
||
const newOldest = _findOldestSortKey(entries);
|
||
if (newOldest) _oldestLoadedSortKey = newOldest;
|
||
|
||
// Button entfernen
|
||
removeOlderMessagesButton();
|
||
|
||
// Scroll-Position merken
|
||
const chat = chatEl();
|
||
const oldScrollHeight = chat.scrollHeight;
|
||
|
||
// Nachrichten oben einfügen (vor dem ersten .msg Element)
|
||
const fragment = document.createDocumentFragment();
|
||
const imageMessageIds = [];
|
||
for (const entry of entries) {
|
||
_renderMessagesIntoFragment(fragment, _mergeConsecutiveAssistantTexts(entry.messages), imageMessageIds);
|
||
if (entry.chain && isChainMetaVisible(entry.chain)) {
|
||
fragment.appendChild(_createChainMetaDiv(entry.chain));
|
||
}
|
||
}
|
||
|
||
// Einfügen: vor dem ersten .msg oder .chain-meta Element
|
||
const firstMsg = chat.querySelector('.msg, .chain-meta, .tool-box');
|
||
if (firstMsg) {
|
||
chat.insertBefore(fragment, firstMsg);
|
||
} else {
|
||
chat.appendChild(fragment);
|
||
}
|
||
|
||
// Bild-Attachments automatisch laden
|
||
for (const mid of imageMessageIds) _autoLoadImageAttachments(mid);
|
||
|
||
// Neuen Button oder Archiv-Button anzeigen
|
||
if (data.hasMore) {
|
||
showOlderMessagesButton(data.remainingCount);
|
||
} else {
|
||
const archives = await apiListArchives(sid);
|
||
if (archives.length > 0) {
|
||
showArchiveLoadButton();
|
||
}
|
||
}
|
||
|
||
// Scroll-Position beibehalten
|
||
const newScrollHeight = chat.scrollHeight;
|
||
chat.scrollTop += (newScrollHeight - oldScrollHeight);
|
||
|
||
} catch (e) {
|
||
console.error('Ältere Nachrichten laden fehlgeschlagen:', e);
|
||
if (_hasMoreOlderMessages) {
|
||
showOlderMessagesButton(0);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Helper Functions ──
|
||
|
||
function _findOldestSortKey(entries) {
|
||
for (const entry of entries) {
|
||
if (entry.messages && entry.messages.length > 0) {
|
||
return entry.messages[0].sortKey;
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
function _mergeConsecutiveAssistantTexts(msgs) {
|
||
const result = [];
|
||
for (const msg of msgs) {
|
||
if (msg.type === 'text' && msg.role === 'assistant') {
|
||
const prev = result.length > 0 ? result[result.length - 1] : null;
|
||
if (prev && prev.type === 'text' && prev.role === 'assistant'
|
||
&& prev.iteration === msg.iteration
|
||
&& !msg.tag && !prev.tag
|
||
&& !_isReviewMessage(msg.content) && !_isReviewMessage(prev.content)) {
|
||
prev.content = (prev.content || '') + ' ' + (msg.content || '');
|
||
if (msg.id) prev.id = msg.id;
|
||
if (msg.sortKey && (!prev.sortKey || msg.sortKey > prev.sortKey)) prev.sortKey = msg.sortKey;
|
||
if (msg.createdAt) prev.createdAt = msg.createdAt;
|
||
if (msg.isMarked) prev.isMarked = true;
|
||
continue;
|
||
}
|
||
}
|
||
result.push(Object.assign({}, msg));
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// ── Fragment Rendering (shared between loadOlderMessages + loadLatestArchive) ──
|
||
|
||
/**
|
||
* Renders a merged message list into a DocumentFragment.
|
||
* @param {DocumentFragment} fragment
|
||
* @param {Array} msgs - Already merged messages
|
||
* @param {Array} imageMessageIds - Collects message IDs with image attachments
|
||
* @param {object} opts - { archived: bool }
|
||
*/
|
||
function _renderMessagesIntoFragment(fragment, msgs, imageMessageIds, opts) {
|
||
const archived = opts && opts.archived;
|
||
for (var _msgIdx = 0; _msgIdx < msgs.length; _msgIdx++) {
|
||
const msg = msgs[_msgIdx];
|
||
|
||
// ── Grouped Review/Preparer/Finalizer Bubble (tag-based) ──
|
||
if (msg.tag && (msg.tag === 'review' || msg.tag === 'preparer' || msg.tag === 'finalizer')) {
|
||
// Collect all consecutive messages with the same tag
|
||
const tagGroup = [msg];
|
||
while (_msgIdx + 1 < msgs.length && msgs[_msgIdx + 1].tag === msg.tag) {
|
||
_msgIdx++;
|
||
tagGroup.push(msgs[_msgIdx]);
|
||
}
|
||
fragment.appendChild(_renderReviewGroup(tagGroup, archived));
|
||
continue;
|
||
}
|
||
|
||
// ── Panel (Fusion/MoA): je Member eine eigene Bubble; bei MoA nach Runden gruppiert mit Divider ──
|
||
if (msg.tag === 'panel') {
|
||
const panelGroup = [msg];
|
||
while (_msgIdx + 1 < msgs.length && msgs[_msgIdx + 1].tag === 'panel') {
|
||
_msgIdx++;
|
||
panelGroup.push(msgs[_msgIdx]);
|
||
}
|
||
// Layer-Info aus dem JSON-Transkript lesen (Alt-Format / kein layer → Runde 1).
|
||
var _panelMaxLayers = 1;
|
||
var _panelLayers = [];
|
||
for (var _pli = 0; _pli < panelGroup.length; _pli++) {
|
||
var _pLayer = 1;
|
||
var _pContent = panelGroup[_pli].content || '';
|
||
if (_pContent.charAt(0) === '{') {
|
||
try {
|
||
var _pData = JSON.parse(_pContent);
|
||
if (_pData.layer) _pLayer = _pData.layer;
|
||
if (_pData.totalLayers > _panelMaxLayers) _panelMaxLayers = _pData.totalLayers;
|
||
} catch (e) {}
|
||
}
|
||
_panelLayers.push(_pLayer);
|
||
}
|
||
// In id-Reihenfolge rendern; bei MoA (>1 Runde) einen Divider beim Runden-Wechsel einfügen
|
||
// (spiegelt startPanelLayer aus stream-ui.js). Da die Runden SEQUENZIELL persistiert werden,
|
||
// ist panelGroup bereits nach Runde geordnet (Layer-1-ids < Layer-2-ids).
|
||
var _pLastLayer = null;
|
||
for (var _pi = 0; _pi < panelGroup.length; _pi++) {
|
||
if (_panelMaxLayers > 1 && _panelLayers[_pi] !== _pLastLayer) {
|
||
var _pDiv = document.createElement('div');
|
||
_pDiv.className = 'panel-layer-divider';
|
||
_pDiv.innerHTML = '<span><i class="fa fa-clone"></i> Runde ' + _panelLayers[_pi] + ' / ' + _panelMaxLayers + '</span>';
|
||
fragment.appendChild(_pDiv);
|
||
_pLastLayer = _panelLayers[_pi];
|
||
}
|
||
fragment.appendChild(_renderPanelMemberBubble(panelGroup[_pi], archived));
|
||
}
|
||
continue;
|
||
}
|
||
switch (msg.type) {
|
||
case 'text': {
|
||
const isReviewMsg = _isReviewMessage(msg.content);
|
||
if (msg.role === 'user' && !isReviewMsg) {
|
||
const div = _createMessageBubble({ role: 'user', timestamp: msg.createdAt, messageId: msg.id, isMarked: msg.isMarked, deletedOn: msg.deletedOn, archived });
|
||
if (msg.tag === 'openclaw-import') _addOpenClawBadge(div);
|
||
renderUserContent(div, msg.content);
|
||
_addBubbleActions(div, { timestamp: msg.createdAt, messageId: msg.id, deletedOn: msg.deletedOn, isMarked: msg.isMarked });
|
||
fragment.appendChild(div);
|
||
if (msg.attachmentCount > 0) {
|
||
if (/\[Bild: [^\]]+\]/.test(msg.content) && imageMessageIds) {
|
||
imageMessageIds.push(msg.id);
|
||
}
|
||
div.appendChild(createAttachmentIndicator(msg.id, msg.attachmentCount, msg.attachmentNames));
|
||
}
|
||
} else if (isReviewMsg) {
|
||
const div = _createMessageBubble({ role: msg.role, extraClass: 'review-bubble', timestamp: msg.createdAt, messageId: msg.id, isMarked: msg.isMarked, deletedOn: msg.deletedOn, archived });
|
||
div.innerHTML = renderMarkdown(msg.content);
|
||
parseFluentEmojis(div);
|
||
_addBubbleActions(div, { timestamp: msg.createdAt, messageId: msg.id, deletedOn: msg.deletedOn, isMarked: msg.isMarked });
|
||
fragment.appendChild(div);
|
||
} else if (msg.role === 'assistant') {
|
||
// Whitespace-only Content nicht rendern (z.B. "\n" nach </think>-Extraktion)
|
||
if (!msg.content || !msg.content.trim()) break;
|
||
const div = _createMessageBubble({ role: 'assistant', timestamp: msg.createdAt, messageId: msg.id, isMarked: msg.isMarked, deletedOn: msg.deletedOn, archived });
|
||
div.innerHTML = renderMarkdown(msg.content);
|
||
parseFluentEmojis(div);
|
||
if (msg.tag === 'openclaw-import') _addOpenClawBadge(div);
|
||
_addBubbleActions(div, { timestamp: msg.createdAt, messageId: msg.id, deletedOn: msg.deletedOn, isMarked: msg.isMarked });
|
||
fragment.appendChild(div);
|
||
}
|
||
break;
|
||
}
|
||
case 'steer': {
|
||
// Live-Einwurf des Users während die Chain lief (User-Stil + Einwurf-Label)
|
||
if (msg.role === 'user' && msg.content) {
|
||
fragment.appendChild(_buildSteerBubble({
|
||
text: msg.content,
|
||
timestamp: msg.createdAt,
|
||
messageId: msg.id,
|
||
isMarked: msg.isMarked,
|
||
deletedOn: msg.deletedOn,
|
||
archived: archived
|
||
}));
|
||
}
|
||
break;
|
||
}
|
||
case 'auto_context': {
|
||
if (msg.content) {
|
||
const acDiv = document.createElement('div');
|
||
acDiv.className = 'tool-box auto-context-box' + (archived ? ' archived' : '');
|
||
const acHeader = document.createElement('div');
|
||
acHeader.className = 'tool-box-header clickable';
|
||
acHeader.innerHTML = '<span class="tool-name">Auto-Context</span>';
|
||
const acContent = document.createElement('div');
|
||
acContent.className = 'auto-context-content markdown';
|
||
acContent.innerHTML = renderMarkdown(msg.content.replace(/<\/?recalled_context>/g, '').trim());
|
||
acContent.hidden = true;
|
||
acHeader.addEventListener('click', function() { acContent.hidden = !acContent.hidden; });
|
||
acDiv.appendChild(acHeader);
|
||
acDiv.appendChild(acContent);
|
||
fragment.appendChild(acDiv);
|
||
}
|
||
break;
|
||
}
|
||
case 'thinking': {
|
||
if (msg.role === 'assistant' && msg.content) {
|
||
const thinkDiv = document.createElement('div');
|
||
thinkDiv.className = 'tool-box thinking-box' + (archived ? ' archived' : '');
|
||
const thinkHeader = document.createElement('div');
|
||
thinkHeader.className = 'tool-box-header clickable';
|
||
thinkHeader.innerHTML = '<span class="tool-name">Denkprozess</span>';
|
||
const thinkContent = document.createElement('div');
|
||
thinkContent.className = 'thinking-content markdown';
|
||
thinkContent.innerHTML = renderMarkdown(msg.content);
|
||
thinkContent.hidden = true;
|
||
thinkHeader.addEventListener('click', function() { thinkContent.hidden = !thinkContent.hidden; });
|
||
thinkDiv.appendChild(thinkHeader);
|
||
thinkDiv.appendChild(thinkContent);
|
||
fragment.appendChild(thinkDiv);
|
||
}
|
||
break;
|
||
}
|
||
case 'tool_use':
|
||
if (msg.toolName === 'ask_user_choice') {
|
||
_renderChoiceBubbleInFragment(fragment, msgs, _msgIdx);
|
||
continue;
|
||
}
|
||
if (!isToolVisible(msg.toolName)) continue;
|
||
const toolDiv = document.createElement('div');
|
||
toolDiv.className = 'tool-box' + (archived ? ' archived' : '');
|
||
toolDiv.dataset.toolName = msg.toolName;
|
||
if (msg.toolCallId) toolDiv.dataset.toolCallId = msg.toolCallId;
|
||
renderToolBoxContent(toolDiv, msg.toolName, msg.content, null, false);
|
||
fragment.appendChild(toolDiv);
|
||
break;
|
||
case 'web_search_result': {
|
||
if (!isToolVisible('web_search')) continue;
|
||
const wsSources = _extractWebSearchSources(msg.content);
|
||
if (wsSources.length === 0) continue;
|
||
const wsDiv = document.createElement('div');
|
||
wsDiv.className = 'tool-box' + (archived ? ' archived' : '');
|
||
wsDiv.dataset.toolName = 'web_search';
|
||
renderToolBoxContent(wsDiv, 'web_search', null, null, false, wsSources);
|
||
fragment.appendChild(wsDiv);
|
||
break;
|
||
}
|
||
case 'tool_result': {
|
||
if (msg._choiceHandled) 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;
|
||
if (msg.toolCallId) {
|
||
lastToolBox = fragment.querySelector('.tool-box[data-tool-call-id="' + msg.toolCallId + '"]');
|
||
}
|
||
if (!lastToolBox) {
|
||
const prevBoxes = fragment.querySelectorAll('.tool-box');
|
||
lastToolBox = prevBoxes[prevBoxes.length - 1];
|
||
}
|
||
if (lastToolBox) {
|
||
const existingResult = lastToolBox.querySelector('.tool-result');
|
||
// todo_write: nachträglich die Todo-Liste rendern (sichtbar)
|
||
if (lastToolBox.dataset.toolName === 'todo_write' && !lastToolBox.querySelector('.todo-list')) {
|
||
lastToolBox.appendChild(buildTodoList(msg.content || '', msg.isError));
|
||
} else if (existingResult && !existingResult.textContent) {
|
||
// Leeres Result-Div updaten (von renderToolBoxContent mit result=null erstellt)
|
||
existingResult.textContent = truncate(msg.content || '', 300);
|
||
if (msg.isError) existingResult.classList.add('tool-error');
|
||
} else if (!existingResult) {
|
||
// Neues Result-Div erstellen
|
||
const rd = document.createElement('div');
|
||
rd.className = 'tool-result' + (msg.isError ? ' tool-error' : '');
|
||
rd.textContent = truncate(msg.content || '', 300);
|
||
rd.hidden = true;
|
||
lastToolBox.appendChild(rd);
|
||
// Header clickable machen
|
||
const hdr = lastToolBox.querySelector('.tool-box-header');
|
||
if (hdr && !hdr.classList.contains('clickable')) {
|
||
hdr.classList.add('clickable');
|
||
hdr.addEventListener('click', function() { rd.hidden = !rd.hidden; });
|
||
}
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
case 'error': {
|
||
var errDiv = document.createElement('div');
|
||
errDiv.className = 'msg system-info' + (archived ? ' archived' : '');
|
||
errDiv.classList.add('text-danger');
|
||
errDiv.textContent = (msg.content || '').trim();
|
||
fragment.appendChild(errDiv);
|
||
break;
|
||
}
|
||
default:
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Renders a group of consecutive messages with the same tag (review/preparer/finalizer)
|
||
* into a single Review-Bubble matching the streaming UI structure.
|
||
*/
|
||
function _renderReviewGroup(tagGroup, archived) {
|
||
var tag = tagGroup[0].tag;
|
||
var bubble = document.createElement('div');
|
||
bubble.className = 'msg review-bubble' + (archived ? ' archived' : '');
|
||
if (tag === 'finalizer') bubble.classList.add('finalizer-bubble');
|
||
if (tag === 'preparer') bubble.classList.add('preparer-bubble');
|
||
|
||
// Extract agent name from first text message: "[Review: AgentName] text..."
|
||
var agentName = null;
|
||
var firstText = tagGroup.find(function(m) { return m.type === 'text' && m.role === 'assistant'; });
|
||
if (firstText) {
|
||
var nameMatch = firstText.content.match(/^\[(Review|Preparer|Finalizer): ([^\]]+)\]/);
|
||
if (nameMatch) agentName = nameMatch[2];
|
||
}
|
||
|
||
bubble.appendChild(_createSubAgentHeader(tag, agentName));
|
||
|
||
// Content: collect text (strip prefix) and tool-use indicators
|
||
var textParts = [];
|
||
|
||
function flushText() {
|
||
if (textParts.length === 0) return;
|
||
var div = document.createElement('div');
|
||
div.className = 'review-content markdown';
|
||
div.innerHTML = renderMarkdown(textParts.join('\n\n'));
|
||
parseFluentEmojis(div);
|
||
bubble.appendChild(div);
|
||
textParts = [];
|
||
}
|
||
|
||
for (var i = 0; i < tagGroup.length; i++) {
|
||
var m = tagGroup[i];
|
||
if (m.type === 'text' && m.role === 'assistant') {
|
||
var text = m.content;
|
||
// Strip [Tag: AgentName] prefix
|
||
text = text.replace(/^\[(Review|Preparer|Finalizer): [^\]]+\]\s*/, '');
|
||
if (text) textParts.push(text);
|
||
} else if (m.type === 'tool_use') {
|
||
if (!isToolVisible(m.toolName)) continue;
|
||
flushText();
|
||
// Full tool-box (same as normal tool boxes)
|
||
var toolDiv = document.createElement('div');
|
||
toolDiv.className = 'tool-box review-tool-box' + (archived ? ' archived' : '');
|
||
// Look ahead for matching tool_result
|
||
var toolResult = null;
|
||
var toolIsError = false;
|
||
for (var j = i + 1; j < tagGroup.length; j++) {
|
||
if (tagGroup[j].type === 'tool_result' && tagGroup[j].toolCallId === m.toolCallId) {
|
||
toolResult = tagGroup[j].content;
|
||
toolIsError = tagGroup[j].isError;
|
||
break;
|
||
}
|
||
}
|
||
renderToolBoxContent(toolDiv, m.toolName, m.content, toolResult, toolIsError);
|
||
bubble.appendChild(toolDiv);
|
||
} else if (m.type === 'web_search_result') {
|
||
if (!isToolVisible('web_search')) continue;
|
||
flushText();
|
||
var wsSources = _extractWebSearchSources(m.content);
|
||
if (wsSources.length > 0) {
|
||
var wsDiv = document.createElement('div');
|
||
wsDiv.className = 'tool-box review-tool-box' + (archived ? ' archived' : '');
|
||
renderToolBoxContent(wsDiv, 'web_search', null, null, false, wsSources);
|
||
bubble.appendChild(wsDiv);
|
||
}
|
||
}
|
||
// tool_result is consumed by tool_use look-ahead above
|
||
}
|
||
|
||
flushText();
|
||
|
||
return bubble;
|
||
}
|
||
|
||
/**
|
||
* Renders a single panel member (tag="panel") as its own panel bubble – matches the live
|
||
* panel_member bubble. Two persisted formats:
|
||
* - NEW: JSON transcript {name, model, segments:[{t:"text",v} | {t:"tool",name,input,result,error}]}
|
||
* → restores text AND tool boxes interleaved (just like live).
|
||
* - OLD (back-compat): plaintext "[Panel: Name]\n..." → text only.
|
||
*/
|
||
function _renderPanelMemberBubble(msg, archived) {
|
||
var bubble = document.createElement('div');
|
||
bubble.className = 'msg review-bubble panel-bubble' + (archived ? ' archived' : '');
|
||
var content = msg.content || '';
|
||
|
||
// ── Neues Format: JSON-Transkript (Text + Tool-Boxen) ──
|
||
var data = null;
|
||
if (content.charAt(0) === '{') {
|
||
try { data = JSON.parse(content); } catch (e) { data = null; }
|
||
}
|
||
if (data && Array.isArray(data.segments)) {
|
||
var model = (typeof prettyModelName === 'function') ? prettyModelName(data.model) : data.model;
|
||
bubble.appendChild(_createSubAgentHeader('panel', data.name, model ? { model: model } : undefined));
|
||
var rendered = false;
|
||
for (var i = 0; i < data.segments.length; i++) {
|
||
var seg = data.segments[i];
|
||
if (!seg) continue;
|
||
if (seg.t === 'text') {
|
||
if (!seg.v) continue;
|
||
var tdiv = document.createElement('div');
|
||
tdiv.className = 'review-content markdown';
|
||
tdiv.innerHTML = renderMarkdown(seg.v);
|
||
parseFluentEmojis(tdiv);
|
||
bubble.appendChild(tdiv);
|
||
rendered = true;
|
||
} else if (seg.t === 'tool') {
|
||
if (typeof isToolVisible === 'function' && !isToolVisible(seg.name)) continue;
|
||
var box = document.createElement('div');
|
||
box.className = 'tool-box review-tool-box';
|
||
renderToolBoxContent(box, seg.name, seg.input || null, seg.result, seg.error, null);
|
||
bubble.appendChild(box);
|
||
rendered = true;
|
||
}
|
||
}
|
||
if (!rendered) {
|
||
var empty = document.createElement('div');
|
||
empty.className = 'review-content markdown';
|
||
empty.innerHTML = renderMarkdown('_(keine Antwort)_');
|
||
bubble.appendChild(empty);
|
||
}
|
||
return bubble;
|
||
}
|
||
|
||
// ── Alt-Format (Back-Compat): "[Panel: Name]\n..." Plaintext ──
|
||
var name = null;
|
||
var nameMatch = content.match(/^\[Panel: ([^\]]+)\]/);
|
||
if (nameMatch) name = nameMatch[1];
|
||
bubble.appendChild(_createSubAgentHeader('panel', name));
|
||
var text = content.replace(/^\[Panel: [^\]]+\]\s*/, '');
|
||
var div = document.createElement('div');
|
||
div.className = 'review-content markdown';
|
||
div.innerHTML = renderMarkdown(text || '_(keine Antwort)_');
|
||
parseFluentEmojis(div);
|
||
bubble.appendChild(div);
|
||
return bubble;
|
||
}
|
||
|
||
function _addOpenClawBadge(div) {
|
||
var badge = document.createElement('span');
|
||
badge.className = 'openclaw-badge';
|
||
badge.innerHTML = '<i class="fa fa-cloud-download"></i> OC';
|
||
badge.title = 'Importiert aus OpenClaw';
|
||
div.insertBefore(badge, div.firstChild);
|
||
}
|
||
|
||
// _isReviewMessage → moved to ui-helpers.js
|
||
|
||
function _createChainMetaDiv(chain, extraClass) {
|
||
const badges = buildMetaBadges(chain);
|
||
const div = document.createElement('div');
|
||
div.className = 'chain-meta' + (extraClass ? ' ' + extraClass : '');
|
||
div.innerHTML = badges.map(b => '<span class="chain-meta-badge">' + esc(b) + '</span>').join('');
|
||
return div;
|
||
}
|
||
|
||
function _renderChoiceBubbleInFragment(fragment, msgs, msgIdx) {
|
||
var msg = msgs[msgIdx];
|
||
try {
|
||
var choiceInput = typeof msg.content === 'string' ? JSON.parse(msg.content) : msg.content;
|
||
var choiceQuestion = choiceInput.question || '';
|
||
var choiceOptions = Array.isArray(choiceInput.choices) ? choiceInput.choices : [String(choiceInput.choices || '')];
|
||
var selectedChoice = null;
|
||
for (var ci = msgIdx + 1; ci < msgs.length; ci++) {
|
||
if (msgs[ci].type === 'tool_result' && msgs[ci].toolCallId === msg.toolCallId) {
|
||
selectedChoice = msgs[ci].content;
|
||
msgs[ci]._choiceHandled = true;
|
||
break;
|
||
}
|
||
}
|
||
var choiceDiv = _createChoiceBubble({
|
||
toolCallId: msg.toolCallId,
|
||
question: choiceQuestion,
|
||
choices: choiceOptions,
|
||
selectedChoice: selectedChoice,
|
||
timestamp: msg.createdAt,
|
||
messageId: msg.id
|
||
});
|
||
fragment.appendChild(choiceDiv);
|
||
} catch(e) {
|
||
console.error('Choice-Bubble Rendering fehlgeschlagen:', e);
|
||
}
|
||
}
|
||
|
||
// ── Render Messages (for pollHistory + loadHistory) ──
|
||
|
||
function renderMessages(msgs, addToConversation) {
|
||
msgs = _mergeConsecutiveAssistantTexts(msgs);
|
||
const fragment = document.createDocumentFragment();
|
||
const imageMessageIds = [];
|
||
_renderMessagesIntoFragment(fragment, msgs, imageMessageIds);
|
||
chatEl().appendChild(fragment);
|
||
// Track lastKnownMessageId
|
||
for (const msg of msgs) {
|
||
const sk = msg.sortKey || msg.id;
|
||
if (sk > lastKnownMessageId) lastKnownMessageId = sk;
|
||
}
|
||
// Auto-load image attachments
|
||
for (const mid of imageMessageIds) _autoLoadImageAttachments(mid);
|
||
scrollDown();
|
||
}
|
||
|
||
// ── Polling ──
|
||
|
||
let _pollLock = false;
|
||
|
||
function startHistoryPolling(intervalMs = 3000) {
|
||
if (historyPollInterval) return;
|
||
historyPollInterval = setInterval(pollHistory, intervalMs);
|
||
}
|
||
|
||
function stopHistoryPolling() {
|
||
if (historyPollInterval) {
|
||
clearInterval(historyPollInterval);
|
||
historyPollInterval = null;
|
||
}
|
||
}
|
||
|
||
async function pollHistory(force = false) {
|
||
if (isStreaming && !force) return;
|
||
if (_pollLock) return;
|
||
_pollLock = true;
|
||
|
||
try {
|
||
const newMessages = await apiHistoryPoll(getCurrentSessionId(), lastKnownMessageId, isDeletedVisible());
|
||
if (newMessages.length > 0) {
|
||
renderMessages(newMessages, true);
|
||
scrollDown();
|
||
if (typeof _refreshChatUsageBadge === 'function') _refreshChatUsageBadge();
|
||
}
|
||
} catch (e) {
|
||
console.error('History poll fehlgeschlagen:', e);
|
||
} finally {
|
||
_pollLock = false;
|
||
}
|
||
}
|