agent-ui / frontend /streaming.js
lvwerra's picture
lvwerra HF Staff
Unify figure store globally, enable cross-agent figure references
4c24c65
Raw
History Blame Contribute Delete
63.3 kB
async function streamChatResponse(messages, chatContainer, agentType, tabId) {
const currentSettings = getSettings();
const backendEndpoint = '/api';
// Resolve model configuration for this agent type
let modelConfig = resolveModelConfig(agentType);
if (!modelConfig) {
modelConfig = getDefaultModelConfig();
}
if (!modelConfig) {
// No models configured - show error
const errorDiv = document.createElement('div');
errorDiv.className = 'message assistant error';
errorDiv.innerHTML = `<div class="message-content">No models configured. Please open Settings and add a provider and model.</div>`;
chatContainer.appendChild(errorDiv);
return;
}
// Resolve research sub-agent model if configured
let researchSubAgentConfig = null;
if (currentSettings.researchSubAgentModel) {
const subAgentModel = currentSettings.models?.[currentSettings.researchSubAgentModel];
if (subAgentModel) {
const subAgentProvider = currentSettings.providers?.[subAgentModel.providerId];
if (subAgentProvider) {
researchSubAgentConfig = {
endpoint: subAgentProvider.endpoint,
token: subAgentProvider.token,
model: subAgentModel.modelId
};
}
}
}
// Resolve image model selections to HF model ID strings
const imageGenModelId = currentSettings.imageGenModel
? currentSettings.models?.[currentSettings.imageGenModel]?.modelId || null
: null;
const imageEditModelId = currentSettings.imageEditModel
? currentSettings.models?.[currentSettings.imageEditModel]?.modelId || null
: null;
// Set up AbortController for this request
const abortController = new AbortController();
if (tabId !== undefined) {
activeAbortControllers[tabId] = abortController;
}
try {
// Determine parent agent ID for abort propagation
const parentAgentId = timelineData[tabId]?.parentTabId != null
? timelineData[tabId].parentTabId.toString()
: null;
const response = await apiFetch(`${backendEndpoint}/chat/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: abortController.signal,
body: JSON.stringify({
messages: messages,
agent_type: agentType,
stream: true,
endpoint: modelConfig.endpoint,
token: modelConfig.token || null,
model: modelConfig.model,
extra_params: modelConfig.extraParams || null,
multimodal: modelConfig.multimodal || false,
e2b_key: currentSettings.e2bKey || null,
serper_key: currentSettings.serperKey || null,
hf_token: currentSettings.hfToken || null,
image_gen_model: imageGenModelId,
image_edit_model: imageEditModelId,
research_sub_agent_model: researchSubAgentConfig?.model || null,
research_sub_agent_endpoint: researchSubAgentConfig?.endpoint || null,
research_sub_agent_token: researchSubAgentConfig?.token || null,
research_sub_agent_extra_params: researchSubAgentConfig?.extraParams || null,
research_parallel_workers: currentSettings.researchParallelWorkers || null,
research_max_websites: currentSettings.researchMaxWebsites || null,
agent_id: tabId.toString(), // Send unique tab ID for sandbox sessions
parent_agent_id: parentAgentId, // Parent agent ID for abort propagation
frontend_context: getFrontendContext() // Dynamic context for system prompts
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let fullResponse = '';
let currentMessageEl = null;
let progressHidden = false;
// Flush any accumulated assistant text to the timeline
// Call before adding tool/code/report timeline events to preserve ordering
function flushResponseToTimeline() {
if (fullResponse && tabId !== undefined) {
const evIdx = addTimelineEvent(tabId, 'assistant', fullResponse);
if (currentMessageEl) currentMessageEl.dataset.timelineIndex = evIdx;
fullResponse = '';
}
}
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6));
// Hide progress widget on first meaningful response
if (!progressHidden && data.type !== 'generating' && data.type !== 'retry' && !data.type.startsWith('debug_')) {
hideProgressWidget(chatContainer);
progressHidden = true;
}
// Handle different message types from backend
if (data.type === 'thinking') {
// Assistant thinking - create message if not exists
if (!currentMessageEl) {
currentMessageEl = createAssistantMessage(chatContainer);
}
fullResponse += data.content;
appendToMessage(currentMessageEl, resolveGlobalFigureRefs(parseMarkdown(fullResponse)));
scrollChatToBottom(chatContainer);
} else if (data.type === 'code') {
// Code execution result - update the last code cell
updateLastCodeCell(chatContainer, data.output, data.error, data.images);
currentMessageEl = null; // Reset for next thinking
scrollChatToBottom(chatContainer);
} else if (data.type === 'code_start') {
// Code cell starting execution - show with spinner
flushResponseToTimeline();
createCodeCell(chatContainer, data.code, null, false, true);
currentMessageEl = null;
scrollChatToBottom(chatContainer);
// Add to timeline - show code snippet preview
const codePreview = data.code ? data.code.split('\n')[0] : 'code execution';
const codeEvIdx = addTimelineEvent(tabId, 'assistant', codePreview, null, { tag: 'CODE' });
chatContainer.lastElementChild.dataset.timelineIndex = codeEvIdx;
} else if (data.type === 'upload') {
// File upload notification
flushResponseToTimeline();
createUploadMessage(chatContainer, data.paths, data.output);
currentMessageEl = null;
scrollChatToBottom(chatContainer);
// Add to timeline
const upEvIdx = addTimelineEvent(tabId, 'assistant', data.paths?.join(', ') || 'files', null, { tag: 'UPLOAD' });
chatContainer.lastElementChild.dataset.timelineIndex = upEvIdx;
} else if (data.type === 'download') {
// File download notification
flushResponseToTimeline();
createDownloadMessage(chatContainer, data.paths, data.output);
currentMessageEl = null;
scrollChatToBottom(chatContainer);
// Add to timeline
const dlEvIdx = addTimelineEvent(tabId, 'assistant', data.paths?.join(', ') || 'files', null, { tag: 'DOWNLOAD' });
chatContainer.lastElementChild.dataset.timelineIndex = dlEvIdx;
} else if (data.type === 'generating') {
// Still generating - no action needed
} else if (data.type === 'result') {
// References are globally namespaced by the backend (e.g., figure_T3_1)
const resultText = data.content || '';
// Populate global registry
if (data.figures) {
for (const [name, figData] of Object.entries(data.figures)) {
if (new RegExp(`</?${name}>`, 'i').test(resultText)) {
globalFigureRegistry[name] = figData;
}
}
}
// Agent result - update command center widget
updateActionWidgetWithResult(tabId, resultText, data.figures || {});
} else if (data.type === 'result_preview') {
// Show result preview
flushResponseToTimeline();
// Replace <figure_x> tags with placeholders BEFORE markdown processing
let previewContent = data.content;
const figurePlaceholders = {};
if (data.figures) {
for (const [figureName, figureData] of Object.entries(data.figures)) {
// Use %%% delimiters to avoid markdown interpretation
const placeholderId = `%%%FIGURE_${figureName}%%%`;
figurePlaceholders[placeholderId] = figureData;
// Handle both <figure_x> self-closing and <figure_x></figure_x> pairs
// First replace paired tags, preserving them as block elements
const pairedTag = new RegExp(`<${figureName}></${figureName}>`, 'gi');
previewContent = previewContent.replace(pairedTag, `\n\n${placeholderId}\n\n`);
// Then replace remaining self-closing tags or orphaned closing tags
const singleTag = new RegExp(`</?${figureName}>`, 'gi');
previewContent = previewContent.replace(singleTag, `\n\n${placeholderId}\n\n`);
}
}
// Process markdown
let html = parseMarkdown(previewContent);
// Replace placeholders with actual images AFTER markdown processing
for (const [placeholderId, figureData] of Object.entries(figurePlaceholders)) {
let imageHtml = '';
if (figureData.type === 'png' || figureData.type === 'jpeg') {
imageHtml = `<img src="data:image/${figureData.type};base64,${figureData.data}" style="max-width: 400px; max-height: 400px; height: auto; border-radius: 4px; margin: 12px 0; display: block;" onclick="openImageModal(this.src)">`;
} else if (figureData.type === 'svg') {
imageHtml = `<div style="margin: 12px 0;">${atob(figureData.data)}</div>`;
}
// Replace both the placeholder and any paragraph-wrapped version
html = html.replace(new RegExp(`<p>${placeholderId}</p>`, 'g'), imageHtml);
html = html.replace(new RegExp(placeholderId, 'g'), imageHtml);
}
// Store raw result as hidden assistant message for history reconstruction
const resultMsg = document.createElement('div');
resultMsg.className = 'message assistant';
resultMsg.style.display = 'none';
resultMsg.innerHTML = `<div class="message-content">${escapeHtml(data.content)}</div>`;
chatContainer.appendChild(resultMsg);
// Create result block
const resultDiv = document.createElement('div');
resultDiv.className = 'agent-result';
resultDiv.innerHTML = `
<div class="result-header">Result</div>
<div class="result-content">${html}</div>
`;
chatContainer.appendChild(resultDiv);
scrollChatToBottom(chatContainer);
// Add result dot to parent research timeline
const figCount = data.figures ? Object.keys(data.figures).length : 0;
const reportDesc = figCount > 0 ? `${figCount} figures` : (data.content?.replace(/<[^>]+>/g, '').trim().substring(0, 60) || 'done');
const resEvIdx = addTimelineEvent(tabId, 'assistant', reportDesc, null, { tag: 'RESULT' });
resultDiv.dataset.timelineIndex = resEvIdx;
} else if (data.type === 'status') {
// Research status update
createStatusMessage(chatContainer, data.message, data.iteration, data.total_iterations);
scrollChatToBottom(chatContainer);
} else if (data.type === 'queries') {
// Research queries generated
createQueriesMessage(chatContainer, data.queries, data.iteration);
scrollChatToBottom(chatContainer);
// Register each query as a virtual sub-agent in the timeline
const startIdx = Object.keys(researchQueryTabIds).length;
for (let qi = 0; qi < data.queries.length; qi++) {
const globalIdx = startIdx + qi;
const virtualId = `research-${tabId}-q${globalIdx}`;
researchQueryTabIds[globalIdx] = virtualId;
registerAgentInTimeline(virtualId, 'search', data.queries[qi], tabId);
setTimelineGenerating(virtualId, true);
}
} else if (data.type === 'progress') {
// Research progress
updateProgress(chatContainer, data.message, data.websites_visited, data.max_websites);
scrollChatToBottom(chatContainer);
} else if (data.type === 'source') {
// Research source found - now includes query grouping
createSourceMessage(chatContainer, data);
scrollChatToBottom(chatContainer);
// Add source as dot in virtual sub-agent timeline
const sourceVirtualId = researchQueryTabIds[data.query_index];
if (sourceVirtualId) {
addTimelineEvent(sourceVirtualId, 'assistant', data.title || data.url || 'source');
} else if (data.query_index === -1) {
// Browse result — create a virtual browse entry if needed
const browseId = `research-${tabId}-browse-${Date.now()}`;
registerAgentInTimeline(browseId, 'browse', data.url || 'webpage', tabId);
addTimelineEvent(browseId, 'assistant', data.title || data.url || 'page');
setTimelineGenerating(browseId, false);
}
} else if (data.type === 'query_stats') {
// Update query statistics
updateQueryStats(data.query_index, {
relevant: data.relevant_count,
irrelevant: data.irrelevant_count,
error: data.error_count
});
// Mark search sub-agent as done
const statsVirtualId = researchQueryTabIds[data.query_index];
if (statsVirtualId) {
setTimelineGenerating(statsVirtualId, false);
}
} else if (data.type === 'assessment') {
// Research completeness assessment
createAssessmentMessage(chatContainer, data.sufficient, data.missing_aspects, data.findings_count, data.reasoning);
scrollChatToBottom(chatContainer);
} else if (data.type === 'report') {
// Final research report
flushResponseToTimeline();
createReportMessage(chatContainer, data.content, data.sources_count, data.websites_visited);
scrollChatToBottom(chatContainer);
// Add to timeline
const rptEvIdx = addTimelineEvent(tabId, 'assistant', `${data.sources_count || 0} sources, ${data.websites_visited || 0} sites`, null, { tag: 'RESULT' });
chatContainer.lastElementChild.dataset.timelineIndex = rptEvIdx;
} else if (data.type === 'tool_start') {
// Agent tool execution starting — create a tool-cell box (like code cells)
flushResponseToTimeline();
currentMessageEl = null;
const toolLabels = {
'web_search': 'SEARCH',
'read_url': 'READ',
'screenshot_url': 'SCREENSHOT',
'generate_image': 'GENERATE',
'edit_image': 'EDIT',
'read_image_url': 'LOAD IMAGE',
'read_image': 'LOAD IMAGE',
'save_image': 'SAVE IMAGE',
'show_html': 'HTML'
};
const toolDescriptions = {
'web_search': data.args?.query || '',
'read_url': data.args?.url || '',
'screenshot_url': data.args?.url || '',
'generate_image': data.args?.prompt || '',
'edit_image': `${data.args?.prompt || ''} (from ${data.args?.source || ''})`,
'read_image_url': data.args?.url || '',
'read_image': data.args?.source || '',
'save_image': `${data.args?.source || ''}${data.args?.filename || ''}`,
'show_html': data.args?.source?.substring(0, 80) || ''
};
const label = toolLabels[data.tool] || data.tool.toUpperCase();
const description = toolDescriptions[data.tool] || '';
// Store tool call in DOM for history reconstruction
// Reuse currentMessageEl (from thinking) if it exists, like launch events do
let toolCallMsg = currentMessageEl;
if (!toolCallMsg) {
toolCallMsg = document.createElement('div');
toolCallMsg.className = 'message assistant';
toolCallMsg.style.display = 'none';
chatContainer.appendChild(toolCallMsg);
}
toolCallMsg.setAttribute('data-tool-call', JSON.stringify({
tool_call_id: data.tool_call_id,
function_name: data.tool,
arguments: data.arguments,
thinking: data.thinking || ''
}));
// Create tool-cell box (similar to code-cell)
const toolCell = document.createElement('div');
toolCell.className = 'tool-cell';
if (document.getElementById('collapseToolsCheckbox')?.checked) {
toolCell.classList.add('collapsed');
}
toolCell.setAttribute('data-tool-name', data.tool);
const descHtml = description ? `<span class="tool-cell-desc">${escapeHtml(description)}</span>` : '';
toolCell.innerHTML = `
<div class="tool-cell-label"><div class="widget-collapse-toggle${document.getElementById('collapseToolsCheckbox')?.checked ? ' collapsed' : ''}"></div><span>${label}</span>${descHtml}${createSpinnerHtml()}</div>
<div class="tool-cell-input">${escapeHtml(description)}</div>
`;
setupCollapseToggle(toolCell, '.tool-cell-label');
chatContainer.appendChild(toolCell);
scrollChatToBottom(chatContainer);
const toolEvIdx = addTimelineEvent(tabId, 'assistant', description, null, { tag: label });
toolCell.dataset.timelineIndex = toolEvIdx;
} else if (data.type === 'tool_result') {
// Agent tool result — populate the last tool-cell with output
const lastToolCell = chatContainer.querySelector('.tool-cell:last-of-type');
// Remove spinner
if (lastToolCell) {
const spinner = lastToolCell.querySelector('.tool-spinner');
if (spinner) spinner.remove();
}
// Store tool response in DOM for history reconstruction
const toolResponseMsg = document.createElement('div');
toolResponseMsg.className = 'message tool';
toolResponseMsg.style.display = 'none';
toolResponseMsg.setAttribute('data-tool-response', JSON.stringify({
tool_call_id: data.tool_call_id,
content: data.response || ''
}));
chatContainer.appendChild(toolResponseMsg);
// Build output HTML based on tool type
let outputHtml = '';
if (data.tool === 'web_search' && data.result?.results) {
try {
const results = typeof data.result.results === 'string' ? JSON.parse(data.result.results) : data.result.results;
if (Array.isArray(results)) {
outputHtml = '<div class="search-results-content">' +
results.map(r =>
`<div class="search-result-item"><a href="${escapeHtml(r.url)}" target="_blank">${escapeHtml(r.title)}</a><span class="search-snippet">${escapeHtml(r.snippet)}</span></div>`
).join('') + '</div>';
}
} catch(e) { /* ignore parse errors */ }
} else if (data.tool === 'read_url') {
const len = data.result?.length || 0;
const markdown = data.result?.markdown || '';
const summaryText = len > 0 ? `Extracted ${(len / 1000).toFixed(1)}k chars` : 'No content extracted';
if (markdown) {
const toggleId = `read-content-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
outputHtml = `<div class="tool-cell-read-summary">${summaryText} <button class="read-content-toggle" onclick="const el=document.getElementById('${toggleId}');el.classList.toggle('expanded');this.textContent=el.classList.contains('expanded')?'Hide':'Show content'">Show content</button></div><div id="${toggleId}" class="read-content-body">${parseMarkdown(markdown)}</div>`;
} else {
outputHtml = `<div class="tool-cell-read-summary">${summaryText}</div>`;
}
} else if (data.tool === 'screenshot_url' && data.image) {
outputHtml = `<img src="data:image/png;base64,${data.image}" alt="Screenshot" class="screenshot-img" />`;
} else if ((data.tool === 'generate_image' || data.tool === 'edit_image' || data.tool === 'read_image_url' || data.tool === 'read_image') && data.image) {
const imgName = data.image_name || 'image';
outputHtml = `<img src="data:image/png;base64,${data.image}" alt="${escapeHtml(imgName)}" class="generated-img" />`;
} else if ((data.tool === 'generate_image' || data.tool === 'edit_image' || data.tool === 'read_image_url' || data.tool === 'read_image') && !data.image) {
const errMsg = data.response || 'Failed to process image';
outputHtml = `<div class="tool-cell-read-summary">${escapeHtml(errMsg)}</div>`;
} else if (data.tool === 'save_image' && data.image) {
const filename = data.result?.filename || '';
outputHtml = `<img src="data:image/png;base64,${data.image}" alt="${escapeHtml(filename)}" class="generated-img" /><div class="tool-cell-read-summary">Saved as ${escapeHtml(filename)}</div>`;
} else if (data.tool === 'save_image' && !data.image) {
const errMsg = data.response || 'Failed to save image';
outputHtml = `<div class="tool-cell-read-summary">${escapeHtml(errMsg)}</div>`;
} else if (data.tool === 'show_html' && data.result?.html) {
// Create iframe programmatically to avoid escaping issues with srcdoc
if (lastToolCell) {
const outputEl = document.createElement('div');
outputEl.className = 'tool-cell-output';
const iframe = document.createElement('iframe');
iframe.className = 'show-html-iframe';
iframe.sandbox = 'allow-scripts allow-same-origin';
iframe.srcdoc = data.result.html;
outputEl.appendChild(iframe);
lastToolCell.appendChild(outputEl);
}
} else if (data.tool === 'show_html' && !data.result?.html) {
const errMsg = data.response || 'Failed to load HTML';
outputHtml = `<div class="tool-cell-read-summary">${escapeHtml(errMsg)}</div>`;
}
if (outputHtml && lastToolCell) {
const outputEl = document.createElement('div');
outputEl.className = 'tool-cell-output';
outputEl.innerHTML = outputHtml;
lastToolCell.appendChild(outputEl);
}
scrollChatToBottom(chatContainer);
} else if (data.type === 'content') {
// Regular streaming content (non-code agents)
if (!currentMessageEl) {
currentMessageEl = createAssistantMessage(chatContainer);
}
fullResponse += data.content;
appendToMessage(currentMessageEl, resolveGlobalFigureRefs(parseMarkdown(fullResponse)));
scrollChatToBottom(chatContainer);
} else if (data.type === 'launch') {
// Tool-based agent launch from command center
pendingAgentLaunches++;
const agentType = data.agent_type;
const initialMessage = data.initial_message;
const taskId = data.task_id;
const toolCallId = data.tool_call_id;
// Add tool call data to existing message (from thinking) or create new one
// This keeps content + tool_calls together for proper history reconstruction
let toolCallMsg = currentMessageEl;
if (!toolCallMsg) {
toolCallMsg = document.createElement('div');
toolCallMsg.className = 'message assistant';
toolCallMsg.innerHTML = '<div class="message-content"></div>';
chatContainer.appendChild(toolCallMsg);
}
toolCallMsg.setAttribute('data-tool-call', JSON.stringify({
agent_type: agentType,
message: initialMessage,
tool_call_id: toolCallId
}));
// Add tool response so LLM knows the tool was executed
const toolResponseMsg = document.createElement('div');
toolResponseMsg.className = 'message tool';
toolResponseMsg.style.display = 'none';
toolResponseMsg.setAttribute('data-tool-response', JSON.stringify({
tool_call_id: toolCallId,
content: `Launched ${agentType} agent with task: ${initialMessage}`
}));
chatContainer.appendChild(toolResponseMsg);
// The action widget will show the launch visually
handleActionToken(agentType, initialMessage, (targetTabId) => {
showActionWidget(chatContainer, agentType, initialMessage, targetTabId, taskId);
// Store tool call ID for this agent tab so we can send result back
toolCallIds[targetTabId] = toolCallId;
}, taskId, tabId);
// Reset current message element so any subsequent thinking starts fresh
currentMessageEl = null;
} else if (data.type === 'debug_call_input') {
// Debug: LLM call input (before API call)
if (!debugHistory[tabId]) debugHistory[tabId] = [];
debugHistory[tabId].push({
call_number: data.call_number,
timestamp: new Date().toLocaleTimeString(),
input: data.messages,
output: null,
error: null
});
if (document.getElementById('debugPanel')?.classList.contains('active')) loadDebugMessages();
} else if (data.type === 'debug_call_output') {
// Debug: LLM call output (after API call)
// Match the last pending call (call_numbers reset per streaming request)
const calls = debugHistory[tabId] || [];
const call = calls.findLast(c => c.output === null && c.error === null);
if (call) {
call.output = data.response || null;
call.error = data.error || null;
}
if (document.getElementById('debugPanel')?.classList.contains('active')) loadDebugMessages();
} else if (data.type === 'aborted') {
// Agent was aborted by user
hideProgressWidget(chatContainer);
removeRetryIndicator(chatContainer);
// Mark the action widget as aborted (show × instead of ✓)
const widget = actionWidgets[tabId];
if (widget) {
const orbitIndicator = widget.querySelector('.orbit-indicator');
if (orbitIndicator) {
const abortedIndicator = document.createElement('div');
abortedIndicator.className = 'done-indicator aborted';
orbitIndicator.replaceWith(abortedIndicator);
}
}
// For timeline agent boxes, mark as aborted
if (timelineData[tabId]) {
timelineData[tabId].aborted = true;
}
break; // Stop reading stream
} else if (data.type === 'done') {
// Remove retry indicator on success
removeRetryIndicator(chatContainer);
// Reset research state when research agent completes
if (agentType === 'research' && typeof resetResearchState === 'function') {
// Mark all research virtual sub-agents as done
for (const virtualId of Object.values(researchQueryTabIds)) {
setTimelineGenerating(virtualId, false);
}
researchQueryTabIds = {};
resetResearchState();
}
// Check for action tokens in regular agents (legacy support)
if (fullResponse) {
const actionMatch = fullResponse.match(/<action:(agent|code|research|chat)>([\s\S]*?)<\/action>/i);
if (actionMatch) {
const action = actionMatch[1].toLowerCase();
const actionMessage = actionMatch[2].trim();
// Remove action token from fullResponse
const cleanedResponse = fullResponse.replace(/<action:(agent|code|research|chat)>[\s\S]*?<\/action>/i, '').trim();
// Update the display to remove action tags
if (cleanedResponse.length === 0 && currentMessageEl) {
currentMessageEl.remove();
} else if (currentMessageEl) {
// Clear and replace the entire message content
const messageContent = currentMessageEl.querySelector('.message-content');
if (messageContent) {
messageContent.innerHTML = parseMarkdown(cleanedResponse);
linkifyFilePaths(messageContent);
}
}
handleActionToken(action, actionMessage, (targetTabId) => {
showActionWidget(chatContainer, action, actionMessage, targetTabId);
}, null, tabId);
}
}
} else if (data.type === 'info') {
// Info message (e.g., sandbox restart)
const infoDiv = document.createElement('div');
infoDiv.className = 'system-message';
infoDiv.innerHTML = `<em>${escapeHtml(data.content)}</em>`;
infoDiv.style.color = 'var(--theme-accent)';
chatContainer.appendChild(infoDiv);
scrollChatToBottom(chatContainer);
} else if (data.type === 'retry') {
// Show retry indicator
showRetryIndicator(chatContainer, data);
} else if (data.type === 'error') {
// Remove any retry indicator before showing error
removeRetryIndicator(chatContainer);
const errorDiv = document.createElement('div');
errorDiv.className = 'message assistant';
errorDiv.innerHTML = `<div class="message-content" style="color: #c62828;">Error: ${escapeHtml(data.content)}</div>`;
chatContainer.appendChild(errorDiv);
scrollChatToBottom(chatContainer);
// Propagate error to parent action widget
updateActionWidgetWithResult(tabId, `Error: ${data.content}`, {});
const errorWidget = actionWidgets[tabId];
if (errorWidget) {
const doneIndicator = errorWidget.querySelector('.done-indicator');
if (doneIndicator) {
doneIndicator.classList.add('errored');
}
}
}
}
}
}
// Add assistant response to timeline
if (fullResponse && tabId !== undefined) {
const finalEvIdx = addTimelineEvent(tabId, 'assistant', fullResponse);
if (currentMessageEl) currentMessageEl.dataset.timelineIndex = finalEvIdx;
}
} catch (error) {
hideProgressWidget(chatContainer);
if (error.name === 'AbortError') {
// User-initiated abort — show as a result block
const abortResultText = 'Generation aborted by user.';
const resultDiv = document.createElement('div');
resultDiv.className = 'agent-result';
resultDiv.innerHTML = `
<div class="result-header">Result</div>
<div class="result-content"><p>${abortResultText}</p></div>
`;
chatContainer.appendChild(resultDiv);
// Send abort result to parent action widget (so command center knows it was aborted)
updateActionWidgetWithResult(tabId, abortResultText, {});
// Override the done indicator to show × instead of ✓
const widget = actionWidgets[tabId];
if (widget) {
const doneIndicator = widget.querySelector('.done-indicator');
if (doneIndicator) {
doneIndicator.classList.add('aborted');
}
}
// Mark timeline data as aborted
if (timelineData[tabId]) {
timelineData[tabId].aborted = true;
}
} else {
const errorDiv = document.createElement('div');
errorDiv.className = 'message assistant';
errorDiv.innerHTML = `<div class="message-content" style="color: #c62828;">Connection error: ${escapeHtml(error.message)}</div>`;
chatContainer.appendChild(errorDiv);
}
if (tabId) {
setTabGenerating(tabId, false);
}
} finally {
// Clean up abort controller
delete activeAbortControllers[tabId];
}
}
function createAssistantMessage(chatContainer) {
const msg = document.createElement('div');
msg.className = 'message assistant';
msg.innerHTML = '<div class="message-content"></div>';
chatContainer.appendChild(msg);
return msg;
}
function appendToMessage(messageEl, content) {
const contentEl = messageEl.querySelector('.message-content');
if (contentEl) {
contentEl.innerHTML = content;
linkifyFilePaths(contentEl);
}
}
function createSpinnerHtml() {
return `<div class="tool-spinner"><span></span><span></span><span></span></div>`;
}
function createCodeCell(chatContainer, code, output, isError, isExecuting = false) {
const codeCell = document.createElement('div');
codeCell.className = 'code-cell';
let outputHtml = '';
const cleanedOutput = cleanCodeOutput(output);
if (cleanedOutput && !isExecuting) {
outputHtml = `<div class="code-cell-output${isError ? ' error' : ''}">${escapeHtml(cleanedOutput)}</div>`;
}
const spinnerHtml = isExecuting ? createSpinnerHtml() : '';
codeCell.innerHTML = `
<div class="code-cell-label"><div class="widget-collapse-toggle"></div><span>CODE</span>${spinnerHtml}</div>
<div class="code-cell-code"><pre><code class="language-python">${escapeHtml(code)}</code></pre></div>
${outputHtml}
`;
setupCollapseToggle(codeCell, '.code-cell-label');
chatContainer.appendChild(codeCell);
// Apply syntax highlighting
if (typeof Prism !== 'undefined') {
const codeBlock = codeCell.querySelector('code.language-python');
if (codeBlock) {
Prism.highlightElement(codeBlock);
}
}
}
function createFileTransferCell(chatContainer, type, paths, output, isExecuting = false) {
const cell = document.createElement('div');
cell.className = 'action-widget';
const label = type === 'upload' ? 'UPLOAD' : 'DOWNLOAD';
const hasError = output && output.includes('Error:');
// Indicator: spinner while executing, checkmark when done
const indicatorHtml = isExecuting
? `<div class="orbit-indicator"><span></span><span></span><span></span></div>`
: `<div class="done-indicator"></div>`;
// Format paths as list items (make local paths clickable for downloads)
const pathsList = paths.map(p => {
if (type === 'download') {
const arrowIdx = p.indexOf(' -> ');
if (arrowIdx !== -1) {
const localPath = p.substring(arrowIdx + 4);
return `<li>${escapeHtml(p.substring(0, arrowIdx + 4))}<a class="transfer-path-link" href="#" data-path="${escapeHtml(localPath)}">${escapeHtml(localPath)}</a></li>`;
}
}
return `<li>${escapeHtml(p)}</li>`;
}).join('');
let outputHtml = '';
if (output && !isExecuting) {
const outputClass = hasError ? 'transfer-output error' : 'transfer-output';
outputHtml = `<div class="${outputClass}">${escapeHtml(output)}</div>`;
}
cell.innerHTML = `
<div class="action-widget-header">
<div class="widget-collapse-toggle"></div>
<span class="action-widget-type">${label}</span>
<div class="action-widget-bar-right">${indicatorHtml}</div>
</div>
<div class="action-widget-body">
<ul class="transfer-paths">${pathsList}</ul>
${outputHtml}
</div>
`;
cell.querySelector('.action-widget-header').style.cursor = 'pointer';
cell.querySelector('.action-widget-header').addEventListener('click', () => {
cell.classList.toggle('collapsed');
cell.querySelector('.widget-collapse-toggle').classList.toggle('collapsed');
});
// Make download path links clickable to navigate in file explorer
cell.querySelectorAll('.transfer-path-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
navigateToFileInExplorer(link.dataset.path);
});
});
chatContainer.appendChild(cell);
return cell;
}
function createUploadMessage(chatContainer, paths, output) {
createFileTransferCell(chatContainer, 'upload', paths, output, false);
}
function createDownloadMessage(chatContainer, paths, output) {
createFileTransferCell(chatContainer, 'download', paths, output, false);
}
function updateLastCodeCell(chatContainer, output, isError, images) {
const codeCells = chatContainer.querySelectorAll('.code-cell');
if (codeCells.length === 0) return;
const lastCell = codeCells[codeCells.length - 1];
// Remove spinner if present
const spinner = lastCell.querySelector('.tool-spinner');
if (spinner) {
spinner.remove();
}
// Remove existing output if any
const existingOutput = lastCell.querySelector('.code-cell-output');
if (existingOutput) {
existingOutput.remove();
}
// Add images if any
if (images && images.length > 0) {
for (const img of images) {
const imgDiv = document.createElement('div');
imgDiv.className = 'code-cell-image';
// Add figure label if available
if (img.name) {
const label = document.createElement('div');
label.className = 'figure-label';
label.textContent = img.name;
imgDiv.appendChild(label);
}
if (img.type === 'png' || img.type === 'jpeg') {
const imgEl = document.createElement('img');
imgEl.src = `data:image/${img.type};base64,${img.data}`;
imgEl.onclick = function() { openImageModal(this.src); };
imgDiv.appendChild(imgEl);
} else if (img.type === 'svg') {
const svgContainer = document.createElement('div');
svgContainer.innerHTML = atob(img.data);
imgDiv.appendChild(svgContainer);
}
lastCell.appendChild(imgDiv);
}
}
// Add text output
const cleanedOut = cleanCodeOutput(output);
if (cleanedOut) {
const outputDiv = document.createElement('div');
outputDiv.className = `code-cell-output${isError ? ' error' : ''}`;
outputDiv.textContent = cleanedOut;
lastCell.appendChild(outputDiv);
}
}
function showActionWidget(chatContainer, action, message, targetTabId, taskId = null) {
const widget = document.createElement('div');
widget.className = 'action-widget';
if (document.getElementById('collapseAgentsCheckbox')?.checked) {
widget.classList.add('collapsed');
}
widget.dataset.targetTabId = targetTabId;
// Display task_id as title if provided
const titleDisplay = taskId ? taskId : action.toUpperCase();
const agentCollapsed = document.getElementById('collapseAgentsCheckbox')?.checked;
widget.innerHTML = `
<div class="action-widget-clickable">
<div class="action-widget-header">
<div class="widget-collapse-toggle${agentCollapsed ? ' collapsed' : ''}"></div>
<span class="action-widget-type">${action.toUpperCase()}: ${escapeHtml(titleDisplay)}</span>
<div class="action-widget-bar-right">
<div class="orbit-indicator">
<span></span><span></span><span></span>
</div>
</div>
</div>
</div>
<div class="action-widget-body">
<div class="section-label">QUERY</div>
<div class="section-content">${parseMarkdown(message)}</div>
</div>
`;
// Collapse toggle — stop propagation so it doesn't navigate to the agent tab
const collapseToggle = widget.querySelector('.widget-collapse-toggle');
collapseToggle.addEventListener('click', (e) => {
e.stopPropagation();
widget.classList.toggle('collapsed');
collapseToggle.classList.toggle('collapsed');
});
// Make header clickable to jump to the agent
const clickableArea = widget.querySelector('.action-widget-clickable');
const clickHandler = () => {
const tabId = parseInt(targetTabId);
// Check if the tab still exists (use .tab to avoid matching timeline elements)
const tab = document.querySelector(`.tab[data-tab-id="${tabId}"]`);
if (tab) {
// Tab exists, just switch to it
switchToTab(tabId);
} else {
// Tab was closed - restore from timeline data
const notebook = timelineData[tabId];
if (notebook) {
reopenClosedTab(tabId, notebook);
}
}
};
clickableArea.addEventListener('click', clickHandler);
chatContainer.appendChild(widget);
scrollChatToBottom(chatContainer);
// Store widget for later updates
actionWidgets[targetTabId] = widget;
}
async function updateActionWidgetWithResult(tabId, resultContent, figures) {
const widget = actionWidgets[tabId];
if (!widget) return;
// Replace orbiting dots with checkmark
const orbitIndicator = widget.querySelector('.orbit-indicator');
if (orbitIndicator) {
const doneIndicator = document.createElement('div');
doneIndicator.className = 'done-indicator';
orbitIndicator.replaceWith(doneIndicator);
}
// Process and display result content FIRST (before async operations)
// Replace <figure_x> tags with placeholders BEFORE markdown processing
let processedContent = resultContent;
const figurePlaceholders = {};
if (figures) {
for (const [figureName, figureData] of Object.entries(figures)) {
// Use %%% delimiters to avoid markdown interpretation
const placeholderId = `%%%FIGURE_${figureName}%%%`;
figurePlaceholders[placeholderId] = figureData;
// Handle both <figure_x> self-closing and <figure_x></figure_x> pairs
// First replace paired tags, preserving them as block elements
const pairedTag = new RegExp(`<${figureName}></${figureName}>`, 'gi');
processedContent = processedContent.replace(pairedTag, `\n\n${placeholderId}\n\n`);
// Then replace remaining self-closing tags or orphaned closing tags
const singleTag = new RegExp(`</?${figureName}>`, 'gi');
processedContent = processedContent.replace(singleTag, `\n\n${placeholderId}\n\n`);
}
}
// Process markdown
let html = parseMarkdown(processedContent);
// Replace placeholders with actual images AFTER markdown processing
for (const [placeholderId, figureData] of Object.entries(figurePlaceholders)) {
let imageHtml = '';
if (figureData.type === 'png' || figureData.type === 'jpeg') {
imageHtml = `<img src="data:image/${figureData.type};base64,${figureData.data}" style="max-width: 400px; max-height: 400px; height: auto; border-radius: 4px; margin: 12px 0; display: block;" onclick="openImageModal(this.src)">`;
} else if (figureData.type === 'svg') {
imageHtml = `<div style="margin: 12px 0;">${atob(figureData.data)}</div>`;
}
// Replace both the placeholder and any paragraph-wrapped version
html = html.replace(new RegExp(`<p>${placeholderId}</p>`, 'g'), imageHtml);
html = html.replace(new RegExp(placeholderId, 'g'), imageHtml);
}
// Add result section inside the body
const body = widget.querySelector('.action-widget-body');
if (body) {
const resultSection = document.createElement('div');
resultSection.className = 'action-widget-result-section';
resultSection.innerHTML = `
<div class="section-label" style="margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-primary);">RESULT</div>
<div class="section-content">${html}</div>
`;
body.appendChild(resultSection);
}
// Update the tool response DOM element so getConversationHistory picks up actual results
const toolCallId = toolCallIds[tabId];
if (toolCallId) {
// Find the hidden tool response element with this tool_call_id in the command center
const commandContainer = document.getElementById('messages-command');
if (commandContainer) {
const toolMsgs = commandContainer.querySelectorAll('.message.tool[data-tool-response]');
for (const toolMsg of toolMsgs) {
try {
const data = JSON.parse(toolMsg.getAttribute('data-tool-response'));
if (data.tool_call_id === toolCallId) {
data.content = resultContent;
toolMsg.setAttribute('data-tool-response', JSON.stringify(data));
break;
}
} catch (e) { /* ignore parse errors */ }
}
}
// Also send to backend (non-blocking)
apiFetch('/api/conversation/add-tool-response', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tab_id: '0',
tool_call_id: toolCallId,
content: resultContent
})
}).catch(error => {
console.error('Failed to update conversation history with result:', error);
});
}
}
function sendMessageToTab(tabId, message) {
// Programmatically send a message to an existing agent tab
const content = document.querySelector(`[data-content-id="${tabId}"]`);
if (!content) return;
const input = content.querySelector('textarea') || content.querySelector('input[type="text"]');
if (!input) return;
// Set the message and trigger send
input.value = message;
sendMessage(tabId);
}
function handleActionToken(action, message, callback, taskId = null, parentTabId = null) {
// Check if an agent with this task_id already exists
if (taskId && taskIdToTabId[taskId]) {
const existingTabId = taskIdToTabId[taskId];
const existingContent = document.querySelector(`[data-content-id="${existingTabId}"]`);
if (existingContent) {
// Only reuse if the agent type matches — different type with same task_id should create a new tab
const existingType = existingContent.querySelector('.chat-container')?.dataset?.agentType;
if (existingType === action) {
// Send the message to the existing agent
sendMessageToTab(existingTabId, message);
if (callback) {
callback(existingTabId);
}
return;
}
} else {
// Tab no longer exists, clean up the mapping
delete taskIdToTabId[taskId];
}
}
// Open the agent with the extracted message as initial prompt
// Don't auto-switch to the new tab (autoSwitch = false)
setTimeout(() => {
const newTabId = createAgentTab(action, message, false, taskId, parentTabId);
if (callback) {
callback(newTabId);
}
}, 500);
}
function setTabGenerating(tabId, isGenerating) {
const tab = document.querySelector(`[data-tab-id="${tabId}"]`);
if (!tab) return;
const statusIndicator = tab.querySelector('.tab-status');
if (!statusIndicator) return;
if (isGenerating) {
statusIndicator.style.display = 'block';
statusIndicator.classList.add('generating');
} else {
statusIndicator.classList.remove('generating');
// Keep visible but stop animation
setTimeout(() => {
statusIndicator.style.display = 'none';
}, 300);
}
// Toggle SEND/STOP button
const content = document.querySelector(`[data-content-id="${tabId}"]`);
if (content) {
const sendBtn = content.querySelector('.input-container button');
if (sendBtn) {
if (isGenerating) {
sendBtn.textContent = 'STOP';
sendBtn.classList.add('stop-btn');
sendBtn.disabled = false; // Keep enabled so user can click STOP
} else {
sendBtn.textContent = 'SEND';
sendBtn.classList.remove('stop-btn');
}
}
}
// Update timeline to reflect generating state
setTimelineGenerating(tabId, isGenerating);
// Track when a pending agent launch actually starts generating
if (isGenerating && pendingAgentLaunches > 0 && tabId !== 0 && timelineData[tabId]?.parentTabId === 0) {
pendingAgentLaunches--;
}
// When a child agent finishes and command center is blocked, check if all agents are done
if (!isGenerating && commandInputBlocked && tabId !== 0) {
const anyStillGenerating = Object.values(timelineData).some(
td => td.parentTabId === 0 && td.isGenerating
);
if (!anyStillGenerating && pendingAgentLaunches === 0) {
commandInputBlocked = false;
setCommandCenterStopState(false);
// Auto-continue: call command center again with agent results now in history
continueCommandCenter();
}
}
}
function cleanCodeOutput(text) {
if (!text) return text;
return text.split('\n')
.filter(line => !line.match(/^\[Plot\/Image generated\]$/) && !line.match(/^\[Generated figures:.*\]$/))
.join('\n')
.trim();
}
function showRetryIndicator(chatContainer, data) {
// Remove existing retry indicator if present
removeRetryIndicator(chatContainer);
const retryDiv = document.createElement('div');
retryDiv.className = 'retry-indicator';
retryDiv.innerHTML = `
<div class="retry-content">
<div class="retry-spinner"></div>
<div class="retry-text">
<div class="retry-message">${escapeHtml(data.message)}</div>
<div class="retry-status">Retrying (${data.attempt}/${data.max_attempts}) in ${data.delay}s...</div>
</div>
</div>
`;
chatContainer.appendChild(retryDiv);
scrollChatToBottom(chatContainer);
// Start countdown
let remaining = data.delay;
const statusEl = retryDiv.querySelector('.retry-status');
const countdownInterval = setInterval(() => {
remaining--;
if (remaining > 0 && statusEl) {
statusEl.textContent = `Retrying (${data.attempt}/${data.max_attempts}) in ${remaining}s...`;
} else {
clearInterval(countdownInterval);
if (statusEl) {
statusEl.textContent = `Retrying (${data.attempt}/${data.max_attempts})...`;
}
}
}, 1000);
// Store interval ID for cleanup
retryDiv.dataset.countdownInterval = countdownInterval;
}
function removeRetryIndicator(chatContainer) {
const existing = chatContainer.querySelector('.retry-indicator');
if (existing) {
// Clear countdown interval if exists
if (existing.dataset.countdownInterval) {
clearInterval(parseInt(existing.dataset.countdownInterval));
}
existing.remove();
}
}
// Configure marked options once
if (typeof marked !== 'undefined') {
// Custom renderer to add target="_blank" to links and syntax highlighting
const renderer = new marked.Renderer();
// Handle both old API (separate args) and new API (token object)
renderer.link = function(hrefOrToken, title, text) {
const href = typeof hrefOrToken === 'object' ? hrefOrToken.href : hrefOrToken;
const titleVal = typeof hrefOrToken === 'object' ? hrefOrToken.title : title;
const textVal = typeof hrefOrToken === 'object' ? hrefOrToken.text : text;
const titleAttr = titleVal ? ` title="${titleVal}"` : '';
return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${textVal}</a>`;
};
renderer.code = function(codeOrToken, language) {
// Handle both old API (separate args) and new API (token object)
const code = typeof codeOrToken === 'object' ? codeOrToken.text : codeOrToken;
const lang = typeof codeOrToken === 'object' ? codeOrToken.lang : language;
// Use Prism for syntax highlighting if available
if (typeof Prism !== 'undefined' && lang && Prism.languages[lang]) {
const highlighted = Prism.highlight(code, Prism.languages[lang], lang);
return `<pre><code class="language-${lang}">${highlighted}</code></pre>`;
}
return `<pre><code>${escapeHtml(code)}</code></pre>`;
};
marked.setOptions({
gfm: true, // GitHub Flavored Markdown
breaks: false, // Don't convert \n to <br>
pedantic: false,
renderer: renderer
});
}
// Resolve <figure_N> and <image_N> references using the global registry
function resolveGlobalFigureRefs(html) {
return html.replace(/<\/?(figure_(?:T\d+_)?\d+|image_(?:T\d+_)?\d+)>/gi, (match) => {
// Extract the name (strip < > and /)
const name = match.replace(/[<>/]/g, '');
const data = globalFigureRegistry[name];
if (!data) return match; // Leave unresolved refs as-is
if (data.type === 'png' || data.type === 'jpeg') {
return `<img src="data:image/${data.type};base64,${data.data}" style="max-width: 400px; max-height: 400px; height: auto; border-radius: 4px; margin: 12px 0; display: block;" onclick="openImageModal(this.src)">`;
} else if (data.type === 'svg') {
return `<div style="margin: 12px 0;">${atob(data.data)}</div>`;
}
return match;
});
}
function parseMarkdown(text) {
// Use marked library for proper markdown parsing
let html;
if (typeof marked !== 'undefined') {
html = marked.parse(text);
} else {
// Fallback: just escape HTML and convert newlines to paragraphs
html = `<p>${escapeHtml(text).replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br>')}</p>`;
}
// Render LaTeX with KaTeX if available
if (typeof katex !== 'undefined') {
// Block math: $$ ... $$ (must handle newlines)
html = html.replace(/\$\$([\s\S]*?)\$\$/g, (match, latex) => {
try {
return katex.renderToString(latex.trim(), { displayMode: true, throwOnError: false });
} catch (e) {
return `<span class="katex-error">${escapeHtml(match)}</span>`;
}
});
// Inline math: $ ... $ (but not $$ or escaped \$)
html = html.replace(/(?<!\$)\$(?!\$)([^\$\n]+?)\$(?!\$)/g, (match, latex) => {
try {
return katex.renderToString(latex.trim(), { displayMode: false, throwOnError: false });
} catch (e) {
return `<span class="katex-error">${escapeHtml(match)}</span>`;
}
});
}
return html;
}