| async function streamChatResponse(messages, chatContainer, agentType, tabId) { |
| const currentSettings = getSettings(); |
| const backendEndpoint = '/api'; |
|
|
| |
| let modelConfig = resolveModelConfig(agentType); |
| if (!modelConfig) { |
| modelConfig = getDefaultModelConfig(); |
| } |
|
|
| if (!modelConfig) { |
| |
| 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; |
| } |
|
|
| |
| 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 |
| }; |
| } |
| } |
| } |
|
|
| |
| const imageGenModelId = currentSettings.imageGenModel |
| ? currentSettings.models?.[currentSettings.imageGenModel]?.modelId || null |
| : null; |
| const imageEditModelId = currentSettings.imageEditModel |
| ? currentSettings.models?.[currentSettings.imageEditModel]?.modelId || null |
| : null; |
|
|
| |
| const abortController = new AbortController(); |
| if (tabId !== undefined) { |
| activeAbortControllers[tabId] = abortController; |
| } |
|
|
| try { |
| |
| 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(), |
| parent_agent_id: parentAgentId, |
| frontend_context: getFrontendContext() |
| }) |
| }); |
|
|
| 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; |
|
|
| |
| |
| 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)); |
|
|
| |
| if (!progressHidden && data.type !== 'generating' && data.type !== 'retry' && !data.type.startsWith('debug_')) { |
| hideProgressWidget(chatContainer); |
| progressHidden = true; |
| } |
|
|
| |
| if (data.type === 'thinking') { |
| |
| if (!currentMessageEl) { |
| currentMessageEl = createAssistantMessage(chatContainer); |
| } |
| fullResponse += data.content; |
| appendToMessage(currentMessageEl, resolveGlobalFigureRefs(parseMarkdown(fullResponse))); |
| scrollChatToBottom(chatContainer); |
|
|
| } else if (data.type === 'code') { |
| |
| updateLastCodeCell(chatContainer, data.output, data.error, data.images); |
| currentMessageEl = null; |
| scrollChatToBottom(chatContainer); |
|
|
| } else if (data.type === 'code_start') { |
| |
| flushResponseToTimeline(); |
| createCodeCell(chatContainer, data.code, null, false, true); |
| currentMessageEl = null; |
| scrollChatToBottom(chatContainer); |
| |
| 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') { |
| |
| flushResponseToTimeline(); |
| createUploadMessage(chatContainer, data.paths, data.output); |
| currentMessageEl = null; |
| scrollChatToBottom(chatContainer); |
| |
| const upEvIdx = addTimelineEvent(tabId, 'assistant', data.paths?.join(', ') || 'files', null, { tag: 'UPLOAD' }); |
| chatContainer.lastElementChild.dataset.timelineIndex = upEvIdx; |
|
|
| } else if (data.type === 'download') { |
| |
| flushResponseToTimeline(); |
| createDownloadMessage(chatContainer, data.paths, data.output); |
| currentMessageEl = null; |
| scrollChatToBottom(chatContainer); |
| |
| const dlEvIdx = addTimelineEvent(tabId, 'assistant', data.paths?.join(', ') || 'files', null, { tag: 'DOWNLOAD' }); |
| chatContainer.lastElementChild.dataset.timelineIndex = dlEvIdx; |
|
|
| } else if (data.type === 'generating') { |
| |
|
|
| } else if (data.type === 'result') { |
| |
| const resultText = data.content || ''; |
|
|
| |
| if (data.figures) { |
| for (const [name, figData] of Object.entries(data.figures)) { |
| if (new RegExp(`</?${name}>`, 'i').test(resultText)) { |
| globalFigureRegistry[name] = figData; |
| } |
| } |
| } |
|
|
| |
| updateActionWidgetWithResult(tabId, resultText, data.figures || {}); |
|
|
| } else if (data.type === 'result_preview') { |
| |
| flushResponseToTimeline(); |
| |
| let previewContent = data.content; |
| const figurePlaceholders = {}; |
|
|
| if (data.figures) { |
| for (const [figureName, figureData] of Object.entries(data.figures)) { |
| |
| const placeholderId = `%%%FIGURE_${figureName}%%%`; |
| figurePlaceholders[placeholderId] = figureData; |
|
|
| |
| |
| const pairedTag = new RegExp(`<${figureName}></${figureName}>`, 'gi'); |
| previewContent = previewContent.replace(pairedTag, `\n\n${placeholderId}\n\n`); |
|
|
| |
| const singleTag = new RegExp(`</?${figureName}>`, 'gi'); |
| previewContent = previewContent.replace(singleTag, `\n\n${placeholderId}\n\n`); |
| } |
| } |
|
|
| |
| let html = parseMarkdown(previewContent); |
|
|
| |
| 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>`; |
| } |
| |
| html = html.replace(new RegExp(`<p>${placeholderId}</p>`, 'g'), imageHtml); |
| html = html.replace(new RegExp(placeholderId, 'g'), imageHtml); |
| } |
|
|
| |
| 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); |
|
|
| |
| 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); |
| |
| 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') { |
| |
| createStatusMessage(chatContainer, data.message, data.iteration, data.total_iterations); |
| scrollChatToBottom(chatContainer); |
|
|
| } else if (data.type === 'queries') { |
| |
| createQueriesMessage(chatContainer, data.queries, data.iteration); |
| scrollChatToBottom(chatContainer); |
| |
| 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') { |
| |
| updateProgress(chatContainer, data.message, data.websites_visited, data.max_websites); |
| scrollChatToBottom(chatContainer); |
|
|
| } else if (data.type === 'source') { |
| |
| createSourceMessage(chatContainer, data); |
| scrollChatToBottom(chatContainer); |
| |
| const sourceVirtualId = researchQueryTabIds[data.query_index]; |
| if (sourceVirtualId) { |
| addTimelineEvent(sourceVirtualId, 'assistant', data.title || data.url || 'source'); |
| } else if (data.query_index === -1) { |
| |
| 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') { |
| |
| updateQueryStats(data.query_index, { |
| relevant: data.relevant_count, |
| irrelevant: data.irrelevant_count, |
| error: data.error_count |
| }); |
| |
| const statsVirtualId = researchQueryTabIds[data.query_index]; |
| if (statsVirtualId) { |
| setTimelineGenerating(statsVirtualId, false); |
| } |
|
|
| } else if (data.type === 'assessment') { |
| |
| createAssessmentMessage(chatContainer, data.sufficient, data.missing_aspects, data.findings_count, data.reasoning); |
| scrollChatToBottom(chatContainer); |
|
|
| } else if (data.type === 'report') { |
| |
| flushResponseToTimeline(); |
| createReportMessage(chatContainer, data.content, data.sources_count, data.websites_visited); |
| scrollChatToBottom(chatContainer); |
| |
| 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') { |
| |
| 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] || ''; |
|
|
| |
| |
| 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 || '' |
| })); |
|
|
| |
| 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') { |
| |
| const lastToolCell = chatContainer.querySelector('.tool-cell:last-of-type'); |
|
|
| |
| if (lastToolCell) { |
| const spinner = lastToolCell.querySelector('.tool-spinner'); |
| if (spinner) spinner.remove(); |
| } |
|
|
| |
| 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); |
|
|
| |
| 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) { } |
| } 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) { |
| |
| 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') { |
| |
| if (!currentMessageEl) { |
| currentMessageEl = createAssistantMessage(chatContainer); |
| } |
| fullResponse += data.content; |
| appendToMessage(currentMessageEl, resolveGlobalFigureRefs(parseMarkdown(fullResponse))); |
| scrollChatToBottom(chatContainer); |
|
|
| } else if (data.type === 'launch') { |
| |
| pendingAgentLaunches++; |
| const agentType = data.agent_type; |
| const initialMessage = data.initial_message; |
| const taskId = data.task_id; |
| const toolCallId = data.tool_call_id; |
|
|
| |
| |
| 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 |
| })); |
|
|
| |
| 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); |
|
|
| |
| handleActionToken(agentType, initialMessage, (targetTabId) => { |
| showActionWidget(chatContainer, agentType, initialMessage, targetTabId, taskId); |
| |
| toolCallIds[targetTabId] = toolCallId; |
| }, taskId, tabId); |
|
|
| |
| currentMessageEl = null; |
|
|
| } else if (data.type === 'debug_call_input') { |
| |
| 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') { |
| |
| |
| 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') { |
| |
| hideProgressWidget(chatContainer); |
| removeRetryIndicator(chatContainer); |
|
|
| |
| 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); |
| } |
| } |
|
|
| |
| if (timelineData[tabId]) { |
| timelineData[tabId].aborted = true; |
| } |
|
|
| break; |
|
|
| } else if (data.type === 'done') { |
| |
| removeRetryIndicator(chatContainer); |
|
|
| |
| if (agentType === 'research' && typeof resetResearchState === 'function') { |
| |
| for (const virtualId of Object.values(researchQueryTabIds)) { |
| setTimelineGenerating(virtualId, false); |
| } |
| researchQueryTabIds = {}; |
| resetResearchState(); |
| } |
|
|
| |
| 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(); |
|
|
| |
| const cleanedResponse = fullResponse.replace(/<action:(agent|code|research|chat)>[\s\S]*?<\/action>/i, '').trim(); |
|
|
| |
| if (cleanedResponse.length === 0 && currentMessageEl) { |
| currentMessageEl.remove(); |
| } else if (currentMessageEl) { |
| |
| 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') { |
| |
| 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') { |
| |
| showRetryIndicator(chatContainer, data); |
|
|
| } else if (data.type === '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); |
|
|
| |
| updateActionWidgetWithResult(tabId, `Error: ${data.content}`, {}); |
| const errorWidget = actionWidgets[tabId]; |
| if (errorWidget) { |
| const doneIndicator = errorWidget.querySelector('.done-indicator'); |
| if (doneIndicator) { |
| doneIndicator.classList.add('errored'); |
| } |
| } |
| } |
| } |
| } |
| } |
|
|
| |
| if (fullResponse && tabId !== undefined) { |
| const finalEvIdx = addTimelineEvent(tabId, 'assistant', fullResponse); |
| if (currentMessageEl) currentMessageEl.dataset.timelineIndex = finalEvIdx; |
| } |
| } catch (error) { |
| hideProgressWidget(chatContainer); |
| if (error.name === 'AbortError') { |
| |
| 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); |
|
|
| |
| updateActionWidgetWithResult(tabId, abortResultText, {}); |
|
|
| |
| const widget = actionWidgets[tabId]; |
| if (widget) { |
| const doneIndicator = widget.querySelector('.done-indicator'); |
| if (doneIndicator) { |
| doneIndicator.classList.add('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 { |
| |
| 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); |
|
|
| |
| 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:'); |
|
|
| |
| const indicatorHtml = isExecuting |
| ? `<div class="orbit-indicator"><span></span><span></span><span></span></div>` |
| : `<div class="done-indicator"></div>`; |
|
|
| |
| 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'); |
| }); |
| |
| 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]; |
|
|
| |
| const spinner = lastCell.querySelector('.tool-spinner'); |
| if (spinner) { |
| spinner.remove(); |
| } |
|
|
| |
| const existingOutput = lastCell.querySelector('.code-cell-output'); |
| if (existingOutput) { |
| existingOutput.remove(); |
| } |
|
|
| |
| if (images && images.length > 0) { |
| for (const img of images) { |
| const imgDiv = document.createElement('div'); |
| imgDiv.className = 'code-cell-image'; |
|
|
| |
| 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); |
| } |
| } |
|
|
| |
| 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; |
|
|
| |
| 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> |
| `; |
|
|
| |
| const collapseToggle = widget.querySelector('.widget-collapse-toggle'); |
| collapseToggle.addEventListener('click', (e) => { |
| e.stopPropagation(); |
| widget.classList.toggle('collapsed'); |
| collapseToggle.classList.toggle('collapsed'); |
| }); |
|
|
| |
| const clickableArea = widget.querySelector('.action-widget-clickable'); |
|
|
| const clickHandler = () => { |
| const tabId = parseInt(targetTabId); |
| |
| const tab = document.querySelector(`.tab[data-tab-id="${tabId}"]`); |
| if (tab) { |
| |
| switchToTab(tabId); |
| } else { |
| |
| const notebook = timelineData[tabId]; |
| if (notebook) { |
| reopenClosedTab(tabId, notebook); |
| } |
| } |
| }; |
|
|
| clickableArea.addEventListener('click', clickHandler); |
|
|
| chatContainer.appendChild(widget); |
| scrollChatToBottom(chatContainer); |
|
|
| |
| actionWidgets[targetTabId] = widget; |
| } |
|
|
| async function updateActionWidgetWithResult(tabId, resultContent, figures) { |
| const widget = actionWidgets[tabId]; |
| if (!widget) return; |
|
|
| |
| const orbitIndicator = widget.querySelector('.orbit-indicator'); |
| if (orbitIndicator) { |
| const doneIndicator = document.createElement('div'); |
| doneIndicator.className = 'done-indicator'; |
| orbitIndicator.replaceWith(doneIndicator); |
| } |
|
|
| |
| |
| let processedContent = resultContent; |
| const figurePlaceholders = {}; |
|
|
| if (figures) { |
| for (const [figureName, figureData] of Object.entries(figures)) { |
| |
| const placeholderId = `%%%FIGURE_${figureName}%%%`; |
| figurePlaceholders[placeholderId] = figureData; |
|
|
| |
| |
| const pairedTag = new RegExp(`<${figureName}></${figureName}>`, 'gi'); |
| processedContent = processedContent.replace(pairedTag, `\n\n${placeholderId}\n\n`); |
|
|
| |
| const singleTag = new RegExp(`</?${figureName}>`, 'gi'); |
| processedContent = processedContent.replace(singleTag, `\n\n${placeholderId}\n\n`); |
| } |
| } |
|
|
| |
| let html = parseMarkdown(processedContent); |
|
|
| |
| 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>`; |
| } |
| |
| html = html.replace(new RegExp(`<p>${placeholderId}</p>`, 'g'), imageHtml); |
| html = html.replace(new RegExp(placeholderId, 'g'), imageHtml); |
| } |
|
|
| |
| 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); |
| } |
|
|
| |
| const toolCallId = toolCallIds[tabId]; |
| if (toolCallId) { |
| |
| 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) { } |
| } |
| } |
|
|
| |
| 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) { |
| |
| const content = document.querySelector(`[data-content-id="${tabId}"]`); |
| if (!content) return; |
|
|
| const input = content.querySelector('textarea') || content.querySelector('input[type="text"]'); |
| if (!input) return; |
|
|
| |
| input.value = message; |
| sendMessage(tabId); |
| } |
|
|
| function handleActionToken(action, message, callback, taskId = null, parentTabId = null) { |
| |
| if (taskId && taskIdToTabId[taskId]) { |
| const existingTabId = taskIdToTabId[taskId]; |
| const existingContent = document.querySelector(`[data-content-id="${existingTabId}"]`); |
|
|
| if (existingContent) { |
| |
| const existingType = existingContent.querySelector('.chat-container')?.dataset?.agentType; |
| if (existingType === action) { |
| |
| sendMessageToTab(existingTabId, message); |
| if (callback) { |
| callback(existingTabId); |
| } |
| return; |
| } |
| } else { |
| |
| delete taskIdToTabId[taskId]; |
| } |
| } |
|
|
| |
| |
| 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'); |
| |
| setTimeout(() => { |
| statusIndicator.style.display = 'none'; |
| }, 300); |
| } |
|
|
| |
| 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; |
| } else { |
| sendBtn.textContent = 'SEND'; |
| sendBtn.classList.remove('stop-btn'); |
| } |
| } |
| } |
|
|
| |
| setTimelineGenerating(tabId, isGenerating); |
|
|
| |
| if (isGenerating && pendingAgentLaunches > 0 && tabId !== 0 && timelineData[tabId]?.parentTabId === 0) { |
| pendingAgentLaunches--; |
| } |
|
|
| |
| 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); |
| |
| 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) { |
| |
| 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); |
|
|
| |
| 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); |
|
|
| |
| retryDiv.dataset.countdownInterval = countdownInterval; |
| } |
|
|
| function removeRetryIndicator(chatContainer) { |
| const existing = chatContainer.querySelector('.retry-indicator'); |
| if (existing) { |
| |
| if (existing.dataset.countdownInterval) { |
| clearInterval(parseInt(existing.dataset.countdownInterval)); |
| } |
| existing.remove(); |
| } |
| } |
|
|
| |
| if (typeof marked !== 'undefined') { |
| |
| const renderer = new marked.Renderer(); |
|
|
| |
| 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) { |
| |
| const code = typeof codeOrToken === 'object' ? codeOrToken.text : codeOrToken; |
| const lang = typeof codeOrToken === 'object' ? codeOrToken.lang : language; |
|
|
| |
| 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, |
| breaks: false, |
| pedantic: false, |
| renderer: renderer |
| }); |
| } |
|
|
| |
| function resolveGlobalFigureRefs(html) { |
| return html.replace(/<\/?(figure_(?:T\d+_)?\d+|image_(?:T\d+_)?\d+)>/gi, (match) => { |
| |
| const name = match.replace(/[<>/]/g, ''); |
| const data = globalFigureRegistry[name]; |
| if (!data) return match; |
| 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) { |
| |
| let html; |
| if (typeof marked !== 'undefined') { |
| html = marked.parse(text); |
| } else { |
| |
| html = `<p>${escapeHtml(text).replace(/\n\n/g, '</p><p>').replace(/\n/g, '<br>')}</p>`; |
| } |
|
|
| |
| if (typeof katex !== 'undefined') { |
| |
| 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>`; |
| } |
| }); |
|
|
| |
| 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; |
| } |
|
|
|
|