Files
gott/wwwroot/js/history.js
T
MelekiandClaude Opus 4.8 0f87ccdcab Persist MoA intermediate layers so they survive reload
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>
2026-06-18 18:03:37 +02:00

671 lines
26 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.
// ── 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;
}
}