Let the user inject a message into a running chain instead of only being
able to abort it. While a chain streams, typing + Enter sends the text to
a new /api/chat/steer endpoint; it is drained at the next iteration
boundary, wrapped with a hint so the model actively classifies it
("complements the task -> keep working" vs. "changes course -> adjust"),
and stored as a Type="steer" user message so it survives reload.
Shared infrastructure (provider-agnostic):
- SteeringQueue: out-of-band queue keyed by sessionId, mirrored from
StreamingTracker (Enqueue/Drain/Clear).
- Program.cs registers it as a singleton and passes it to AnthropicService.
- ChatEndpoint: POST /api/chat/steer enqueues only while IsStreaming,
Clear() in the finally.
- ChatService.BuildHistoryFromDb groups Type="steer" so no two
consecutive user turns are produced on re-chat.
- Frontend api.js/chat.js/bubbles.js/history.js + chat.css render the
optimistic steer bubble and the steer_injected SSE event; Enter during
a stream sends a steer instead of being blocked.
Anthropic API path:
- Drain point B (tool boundary, merged into the tool_result user turn)
and C (turn end -> inject + continue instead of finalize).
Claude CLI path (kill-and-restart):
- The agentic loop runs inside the claude child, so true mid-turn inject
is impossible. On a pending steer the child is killed at the next tool
boundary, progress is rebuilt from the DB as a <work_in_progress>
transcript, and the CLI is restarted with the steer appended.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
566 lines
21 KiB
JavaScript
566 lines
21 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, oder der Reihe nach
|
|
const att = imageAtts.find(function(a) { return a.fileName === filename; }) || 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;
|
|
}
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|