diff --git "a/frontend/script.js" "b/frontend/script.js" deleted file mode 100644--- "a/frontend/script.js" +++ /dev/null @@ -1,5460 +0,0 @@ -// ============================================================ -// Multi-user session management -// ============================================================ -let SESSION_ID = localStorage.getItem('agentui_username') || ''; - -function apiFetch(url, options = {}) { - if (SESSION_ID) { - options.headers = { ...options.headers, 'X-Session-ID': SESSION_ID }; - } - return fetch(url, options); -} - -function sanitizeUsername(name) { - return name.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9\-]/g, '').substring(0, 30); -} - -// ============================================================ -// Agent Type Registry — populated from backend /api/agents at startup -// To add a new agent type, add an entry in backend/agents.py (single source of truth) -// ============================================================ -let AGENT_REGISTRY = {}; -// Virtual types used only in timeline rendering (not real agents) -const VIRTUAL_TYPE_LABELS = { search: 'SEARCH', browse: 'BROWSE' }; - -// Derived helpers from registry -function getTypeLabel(type) { - if (AGENT_REGISTRY[type]) return AGENT_REGISTRY[type].label; - if (VIRTUAL_TYPE_LABELS[type]) return VIRTUAL_TYPE_LABELS[type]; - return type.toUpperCase(); -} -function getPlaceholder(type) { - return AGENT_REGISTRY[type]?.placeholder || 'Enter message...'; -} -function getDefaultCounters() { - const counters = {}; - for (const [key, agent] of Object.entries(AGENT_REGISTRY)) { - if (agent.hasCounter) counters[key] = 0; - } - return counters; -} - -// State management -let tabCounter = 1; -let activeTabId = 0; -let currentSession = null; // Name of the current session -const collapsedAgents = new Set(); // Track collapsed agent tab IDs -let researchQueryTabIds = {}; // queryIndex -> virtual tabId for research timeline -let showAllTurns = true; // Toggle to show/hide individual assistant dots - -// Fetch random isotope name from backend -async function generateSessionName() { - try { - const response = await apiFetch('/api/sessions/random-name'); - const data = await response.json(); - return data.name; - } catch (error) { - // Fallback to timestamp if API fails - return `session-${Date.now()}`; - } -} - -let settings = { - // New provider/model structure - providers: {}, // providerId -> {name, endpoint, token} - models: {}, // modelId -> {name, providerId, modelId (API model string)} - agents: {}, // Populated after AGENT_REGISTRY is fetched - // Service API keys - e2bKey: '', - serperKey: '', - hfToken: '', - // Image model selections (model IDs from the models list) - imageGenModel: '', - imageEditModel: '', - // Research settings - researchSubAgentModel: '', - researchParallelWorkers: null, - researchMaxWebsites: null, - // UI settings - themeColor: 'forest', - // Schema version for migrations - settingsVersion: 2 -}; - -// Track action widgets for result updates (maps tabId -> widget element) -const actionWidgets = {}; - -// Track tool call IDs for result updates (maps tabId -> tool_call_id) -const toolCallIds = {}; - -// Global figure/image registry populated by sub-agents for cross-agent reference resolution -// Maps "figure_1" -> {type, data} and "image_1" -> {type: "png", data: base64} -const globalFigureRegistry = {}; - -// Debug: per-tab LLM call history (populated by SSE debug_call_input/output events) -// Maps tabId -> [{call_number, timestamp, input, output, error}] -const debugHistory = {}; - -// Track agents by task_id for reuse (maps task_id -> tabId) -const taskIdToTabId = {}; - -// Whether command center input is blocked waiting for agents to finish -let commandInputBlocked = false; - -// Count of agent launches that haven't started generating yet (handles race condition) -let pendingAgentLaunches = 0; - -// Track agent counters for each type (derived from registry) -let agentCounters = getDefaultCounters(); - -// Debounce timer for workspace saving -let saveWorkspaceTimer = null; - -// Abort controllers for in-flight fetch requests (tabId -> AbortController) -const activeAbortControllers = {}; - -// Timeline data structure for sidebar -// Maps tabId -> { type, title, events: [{type: 'user'|'assistant'|'agent', content, childTabId?}], parentTabId?, isGenerating } -const timelineData = { - 0: { type: 'command', title: 'Task Center', events: [], parentTabId: null, isGenerating: false } -}; - -// Reset all local state for session switching (without page reload) -function resetLocalState() { - // Reset counters - tabCounter = 1; - activeTabId = 0; - currentSession = null; - collapsedAgents.clear(); - - // Clear object maps - Object.keys(actionWidgets).forEach(k => delete actionWidgets[k]); - Object.keys(toolCallIds).forEach(k => delete toolCallIds[k]); - Object.keys(globalFigureRegistry).forEach(k => delete globalFigureRegistry[k]); - Object.keys(debugHistory).forEach(k => delete debugHistory[k]); - Object.keys(taskIdToTabId).forEach(k => delete taskIdToTabId[k]); - researchQueryTabIds = {}; - showAllTurns = true; - agentCounters = getDefaultCounters(); - - // Reset sidebar checkboxes - const compactCb = document.getElementById('compactViewCheckbox'); - if (compactCb) compactCb.checked = false; - const collapseAgentsCb = document.getElementById('collapseAgentsCheckbox'); - if (collapseAgentsCb) collapseAgentsCb.checked = false; - const collapseToolsCb = document.getElementById('collapseToolsCheckbox'); - if (collapseToolsCb) collapseToolsCb.checked = false; - - // Reset timeline data - Object.keys(timelineData).forEach(k => delete timelineData[k]); - timelineData[0] = { type: 'command', title: 'Task Center', events: [], parentTabId: null, isGenerating: false }; - - // Clear dynamic tabs from DOM - const dynamicTabs = document.getElementById('dynamicTabs'); - if (dynamicTabs) dynamicTabs.innerHTML = ''; - - // Remove all dynamic tab content elements (keep tab-content[data-content-id="0"]) - document.querySelectorAll('.tab-content').forEach(el => { - if (el.dataset.contentId !== '0') el.remove(); - }); - - // Clear command center messages - const commandMessages = document.getElementById('messages-command'); - if (commandMessages) commandMessages.innerHTML = ''; - - // Close sessions panel if open - const sessionsPanel = document.getElementById('sessionsPanel'); - const sessionsBtn = document.getElementById('sessionsBtn'); - const appContainer = document.querySelector('.app-container'); - if (sessionsPanel) sessionsPanel.classList.remove('active'); - if (sessionsBtn) sessionsBtn.classList.remove('active'); - if (appContainer) appContainer.classList.remove('sessions-panel-open'); -} - -// Add event to timeline -function addTimelineEvent(tabId, eventType, content, childTabId = null, meta = null) { - if (!timelineData[tabId]) { - timelineData[tabId] = { type: 'unknown', title: 'Unknown', events: [], parentTabId: null, isGenerating: false }; - } - - // Truncate content for preview (first 80 chars) - const preview = content.length > 80 ? content.substring(0, 80) + '...' : content; - - const eventIndex = timelineData[tabId].events.length; - timelineData[tabId].events.push({ - type: eventType, // 'user', 'assistant', or 'agent' - content: preview, - childTabId: childTabId, - meta: meta, // optional: { tag: 'SEARCH' } for tool-like entries - timestamp: Date.now(), - index: eventIndex, - }); - - renderTimeline(); - return eventIndex; -} - -// Register a new agent in timeline -function registerAgentInTimeline(tabId, type, title, parentTabId = null) { - timelineData[tabId] = { - type: type, - title: title, - events: [], - parentTabId: parentTabId, - isGenerating: false - }; - - // If this agent was launched from another, add an agent event to parent - if (parentTabId !== null && timelineData[parentTabId]) { - addTimelineEvent(parentTabId, 'agent', title, tabId); - } - - renderTimeline(); -} - -// Update generating state -function setTimelineGenerating(tabId, isGenerating) { - if (timelineData[tabId]) { - timelineData[tabId].isGenerating = isGenerating; - renderTimeline(); - } -} - -// Update agent title in timeline -function updateTimelineTitle(tabId, title) { - if (timelineData[tabId]) { - timelineData[tabId].title = title; - renderTimeline(); - } -} - -// Remove agent from timeline -function removeFromTimeline(tabId) { - // Remove from parent's events if it was a child - const notebook = timelineData[tabId]; - if (notebook && notebook.parentTabId !== null) { - const parent = timelineData[notebook.parentTabId]; - if (parent) { - parent.events = parent.events.filter(e => e.childTabId !== tabId); - } - } - delete timelineData[tabId]; - renderTimeline(); -} - -// Open a closed tab or switch to an existing one -function openOrSwitchToTab(tabId) { - // Check for actual tab element (not timeline elements which also have data-tab-id) - const existingTab = document.querySelector(`.tab[data-tab-id="${tabId}"]`); - if (existingTab) { - // Tab exists, just switch to it - switchToTab(tabId); - } else { - // Tab was closed, need to recreate it with the SAME tabId - const notebook = timelineData[tabId]; - if (notebook) { - reopenClosedTab(tabId, notebook); - } - } -} - -// Recreate a closed tab using its existing tabId (doesn't create new timeline entry) -function reopenClosedTab(tabId, notebook) { - const type = notebook.type; - const title = notebook.savedTitle || notebook.title; - - // Create tab element - const tab = document.createElement('div'); - tab.className = 'tab'; - tab.dataset.tabId = tabId; - tab.innerHTML = ` - ${title} - - × - `; - - // Insert into the dynamic tabs container - const dynamicTabs = document.getElementById('dynamicTabs'); - dynamicTabs.appendChild(tab); - - // Create content element - restore saved content if available - const content = document.createElement('div'); - content.className = 'tab-content'; - content.dataset.contentId = tabId; - - if (notebook.savedContent) { - // Restore the saved content (includes all messages) - content.innerHTML = notebook.savedContent; - } else { - // Fallback: create fresh agent content - content.innerHTML = createAgentContent(type, tabId, title); - } - - document.querySelector('.main-content').appendChild(content); - - // Mark as no longer closed and clear saved content - notebook.isClosed = false; - delete notebook.savedContent; - delete notebook.savedTitle; - - // Switch to the reopened tab - switchToTab(tabId); - - // Re-attach event listeners for the restored content - if (type !== 'command-center') { - const input = content.querySelector('textarea'); - const sendBtn = content.querySelector('.input-container button'); - - if (input && sendBtn) { - sendBtn.addEventListener('click', () => sendMessage(tabId)); - - // Auto-resize textarea - input.addEventListener('input', () => { - input.style.height = 'auto'; - input.style.height = Math.min(input.scrollHeight, 200) + 'px'; - input.style.overflowY = input.scrollHeight > 200 ? 'auto' : 'hidden'; - }); - - // Enter to send, Shift+Enter for newline - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - sendMessage(tabId); - } - }); - } - - // If this is a code agent, start the sandbox proactively - if (type === 'code') { - startSandbox(tabId); - } - } - - // Update timeline to remove "closed" indicator - renderTimeline(); - - // Save workspace state - saveWorkspaceDebounced(); -} - -// Render the full timeline widget -function renderTimeline() { - const sidebarContent = document.getElementById('sidebarAgents'); - if (!sidebarContent) return; - - // Get root agents (those without parents) - always include command center for workspace name - const rootAgents = Object.entries(timelineData) - .filter(([id, data]) => data.parentTabId === null); - - let html = ''; - - for (const [tabId, notebook] of rootAgents) { - html += renderAgentTimeline(parseInt(tabId), notebook); - } - - sidebarContent.innerHTML = html; - - // Update timeline line heights - updateTimelineLines(); - - // Add click handlers - sidebarContent.querySelectorAll('.tl-row[data-tab-id]').forEach(row => { - row.style.cursor = 'pointer'; - row.addEventListener('click', (e) => { - if (!e.target.closest('.collapse-toggle')) { - const clickTabId = parseInt(row.dataset.tabId); - openOrSwitchToTab(clickTabId); - scrollToTimelineEvent(clickTabId, row.dataset.eventIndex); - } - }); - }); - - sidebarContent.querySelectorAll('.agent-box[data-tab-id]').forEach(box => { - box.style.cursor = 'pointer'; - box.addEventListener('click', (e) => { - e.stopPropagation(); // Prevent double-firing from parent tl-row - const clickTabId = parseInt(box.dataset.tabId); - openOrSwitchToTab(clickTabId); - }); - }); - - // Add click handler to workspace block header - sidebarContent.querySelectorAll('.tl-widget[data-tab-id]').forEach(widget => { - const workspaceBlock = widget.querySelector('.workspace-block'); - if (workspaceBlock) { - workspaceBlock.style.cursor = 'pointer'; - workspaceBlock.addEventListener('click', (e) => { - e.stopPropagation(); - const clickTabId = parseInt(widget.dataset.tabId); - openOrSwitchToTab(clickTabId); - }); - } - }); - - sidebarContent.querySelectorAll('.collapse-toggle').forEach(toggle => { - toggle.addEventListener('click', (e) => { - e.stopPropagation(); - // Toggle is now in .tl-row.has-agent, find the sibling .tl-nested - const row = toggle.closest('.tl-row.has-agent'); - if (row) { - const nested = row.nextElementSibling; - if (nested && nested.classList.contains('tl-nested')) { - const childTabId = nested.dataset.childTabId; - nested.classList.toggle('collapsed'); - toggle.classList.toggle('collapsed'); - // Track collapsed state - if (childTabId) { - if (nested.classList.contains('collapsed')) { - collapsedAgents.add(childTabId); - } else { - collapsedAgents.delete(childTabId); - } - } - updateTimelineLines(); - } - } - }); - }); -} - -// Render a single agent's timeline (recursive for nested) -function renderAgentTimeline(tabId, notebook, isNested = false) { - const isActive = activeTabId === tabId; - const isClosed = notebook.isClosed || false; - const typeLabel = getTypeLabel(notebook.type); - - let html = `
`; - - if (!isNested) { - // Workspace header - left edge aligned with vertical line - html += `
-
PROJECT
-
${escapeHtml(notebook.title)}
-
`; - } - - html += `
`; - - // Group events: consecutive assistant events form a group - const groups = []; - for (const event of notebook.events) { - if (event.type === 'assistant') { - const lastGroup = groups[groups.length - 1]; - if (lastGroup && lastGroup.type === 'assistant') { - lastGroup.events.push(event); - } else { - groups.push({ type: 'assistant', events: [event] }); - } - } else { - groups.push({ type: event.type, events: [event] }); - } - } - - // Render groups - for (const group of groups) { - if (group.type === 'assistant') { - if (!showAllTurns && group.events.length > 1) { - // Collapsed: single summary dot with turn count label — use first event's index - const firstEvent = group.events[0]; - html += ` -
-
- ${group.events.length} turns -
`; - } else { - // Expanded or single: render each dot individually - for (const event of group.events) { - if (event.meta?.tag) { - html += ` -
-
-
${event.meta.tag}${event.content ? `${escapeHtml(event.content)}` : ''}
-
`; - } else { - html += ` -
-
- ${escapeHtml(event.content)} -
`; - } - } - } - } else if (group.type === 'user') { - const event = group.events[0]; - html += ` -
-
- ${escapeHtml(event.content)} -
`; - } else if (group.type === 'agent') { - const event = group.events[0]; - if (event.childTabId !== null) { - const childNotebook = timelineData[event.childTabId]; - if (childNotebook) { - const childTypeLabel = getTypeLabel(childNotebook.type); - const childIsGenerating = childNotebook.isGenerating; - const turnCount = childNotebook.events.length; - - const hasEvents = childNotebook.events.length > 0; - const isCollapsed = collapsedAgents.has(String(event.childTabId)); - const isChildActive = activeTabId === event.childTabId; - html += ` -
-
-
-
- ${hasEvents ? `
` : ''} - ${childTypeLabel} -
-
- ${escapeHtml(childNotebook.title)} -
- ${turnCount} turns - ${childIsGenerating ? ` -
- ` : ` -
- `} -
-
-
-
`; - - // Render nested timeline if child has events - if (hasEvents) { - const isComplete = !childIsGenerating; - html += ` -
- ${renderAgentTimeline(event.childTabId, childNotebook, true)} -
`; - // Return row with dot on parent line - only when subagent is complete - if (isComplete) { - html += ` -
-
-
-
`; - } - } - } - } - } - } - - // Show generating indicator if currently generating and no events yet - if (notebook.isGenerating && notebook.events.length === 0) { - html += ` -
-
-
`; - } - - html += `
`; - - return html; -} - -// Update timeline line heights and return line positions -function updateTimelineLines() { - document.querySelectorAll('.tl').forEach(tl => { - const rows = Array.from(tl.children).filter(el => el.classList.contains('tl-row')); - if (rows.length < 1) return; - - const firstRow = rows[0]; - const firstDot = firstRow.querySelector('.tl-dot'); - if (!firstDot) return; - - const lastRow = rows[rows.length - 1]; - const lastDot = lastRow.querySelector('.tl-dot'); - if (!lastDot) return; - - const tlRect = tl.getBoundingClientRect(); - const firstDotRect = firstDot.getBoundingClientRect(); - const lastDotRect = lastDot.getBoundingClientRect(); - - const dotOffset = 2; - const isNested = tl.closest('.tl-nested') !== null; - const lineTop = isNested ? -6 : (firstDotRect.top - tlRect.top + dotOffset); - const lineBottom = lastDotRect.top - tlRect.top + dotOffset; - - tl.style.setProperty('--line-top', lineTop + 'px'); - tl.style.setProperty('--line-height', (lineBottom - lineTop) + 'px'); - }); - - // Position return rows to align with last nested dot or agent-box - document.querySelectorAll('.tl-nested').forEach(nested => { - const returnRow = nested.nextElementSibling; - if (!returnRow || !returnRow.classList.contains('tl-return')) return; - - const isCollapsed = nested.classList.contains('collapsed'); - const connector = returnRow.querySelector('.tl-return-connector'); - const returnDot = returnRow.querySelector('.tl-dot'); - if (!returnDot) return; - - // Reset position first to get accurate baseline measurement - returnRow.style.top = '0'; - - // Find the agent-box in the previous sibling row - const agentRow = nested.previousElementSibling; - const agentBox = agentRow?.querySelector('.agent-box'); - - if (isCollapsed && agentBox) { - // When collapsed: align return dot with bottom of agent-box - const agentBoxRect = agentBox.getBoundingClientRect(); - const returnDotRect = returnDot.getBoundingClientRect(); - - // Align return dot's top with agent-box bottom - const yOffset = agentBoxRect.bottom - returnDotRect.top - 3; - returnRow.style.top = yOffset + 'px'; - - if (connector) { - // Connector goes from return dot to agent-box - const connectorWidth = agentBoxRect.left - returnDotRect.right; - connector.style.width = Math.max(0, connectorWidth) + 'px'; - connector.style.background = 'var(--theme-accent)'; - } - } else { - // When expanded: align return dot with last nested dot - const nestedTl = nested.querySelector('.tl'); - if (!nestedTl) return; - - const nestedRows = Array.from(nestedTl.children).filter(el => el.classList.contains('tl-row')); - if (nestedRows.length === 0) return; - - const lastNestedRow = nestedRows[nestedRows.length - 1]; - const lastNestedDot = lastNestedRow.querySelector('.tl-dot'); - - if (lastNestedDot && returnDot) { - const lastNestedRect = lastNestedDot.getBoundingClientRect(); - const returnDotRect = returnDot.getBoundingClientRect(); - - // Calculate offset to align Y positions - const yOffset = lastNestedRect.top - returnDotRect.top; - returnRow.style.top = yOffset + 'px'; - - // Connector width: from return dot to nested timeline's vertical line - if (connector) { - const nestedTlRect = nestedTl.getBoundingClientRect(); - // Nested vertical line is at left: 2px from nested .tl - const nestedLineX = nestedTlRect.left + 2; - const connectorWidth = nestedLineX - returnDotRect.right; - - connector.style.width = Math.max(0, connectorWidth) + 'px'; - connector.style.background = 'var(--border-primary)'; - } - } - } - }); -} - -let IS_MULTI_USER = false; - -function showUsernameOverlay() { - return new Promise(resolve => { - const overlay = document.getElementById('usernameOverlay'); - const input = document.getElementById('usernameInput'); - const submit = document.getElementById('usernameSubmit'); - const warning = document.getElementById('usernameWarning'); - if (!overlay) { resolve(); return; } - overlay.style.display = 'flex'; - input.value = ''; - warning.style.display = 'none'; - input.focus(); - - // Check if username exists on input change (debounced) - let checkTimeout; - const checkExists = async () => { - const name = sanitizeUsername(input.value); - if (!name) { warning.style.display = 'none'; return; } - try { - const resp = await fetch(`/api/user/exists/${encodeURIComponent(name)}`); - const data = await resp.json(); - if (data.exists) { - warning.textContent = `"${name}" already has a workspace — you'll share it`; - warning.style.display = 'block'; - } else { - warning.style.display = 'none'; - } - } catch { warning.style.display = 'none'; } - }; - input.oninput = () => { clearTimeout(checkTimeout); checkTimeout = setTimeout(checkExists, 300); }; - - const doSubmit = () => { - const name = sanitizeUsername(input.value); - if (!name) return; - SESSION_ID = name; - localStorage.setItem('agentui_username', name); - overlay.style.display = 'none'; - updateUserIndicator(); - input.oninput = null; - resolve(); - }; - submit.onclick = doSubmit; - input.onkeydown = (e) => { if (e.key === 'Enter') doSubmit(); }; - }); -} - -function updateUserIndicator() { - const indicator = document.getElementById('userIndicator'); - const nameEl = document.getElementById('userIndicatorName'); - if (!indicator || !nameEl) return; - if (IS_MULTI_USER && SESSION_ID) { - nameEl.textContent = SESSION_ID; - indicator.title = 'Click to switch user'; - indicator.style.display = 'flex'; - indicator.onclick = async () => { - await showUsernameOverlay(); - window.location.reload(); - }; - } else { - indicator.style.display = 'none'; - } -} - -// Initialize -document.addEventListener('DOMContentLoaded', async () => { - // Check if multi-user mode is enabled (retry if server not ready yet) - for (let attempt = 0; attempt < 5; attempt++) { - try { - const configResp = await fetch('/api/config'); - const config = await configResp.json(); - IS_MULTI_USER = config.multiUser; - if (config.multiUser && !SESSION_ID) { - await showUsernameOverlay(); - } - break; - } catch (e) { - if (attempt < 4) { - await new Promise(r => setTimeout(r, 1000)); - } - // After all retries fail, continue in single-user mode - } - } - - // Fetch agent registry from backend (single source of truth) - try { - const agentsResp = await apiFetch('/api/agents'); - const agentsData = await agentsResp.json(); - for (const agent of agentsData.agents) { - AGENT_REGISTRY[agent.key] = agent; - } - } catch (e) { - console.error('Failed to fetch agent registry, using fallback'); - // Minimal fallback so the app still works if backend is slow - AGENT_REGISTRY = { - command: { label: 'MAIN', hasCounter: false, inMenu: false, inLauncher: false, placeholder: 'Enter message...' }, - agent: { label: 'AGENT', hasCounter: true, inMenu: true, inLauncher: true, placeholder: 'Enter message...' }, - code: { label: 'CODE', hasCounter: true, inMenu: true, inLauncher: true, placeholder: 'Enter message...' }, - research: { label: 'RESEARCH', hasCounter: true, inMenu: true, inLauncher: true, placeholder: 'Enter message...' }, - image: { label: 'IMAGE', hasCounter: true, inMenu: true, inLauncher: true, placeholder: 'Describe an image or paste a URL...' }, - }; - } - // Initialize settings.agents with registry keys (before loadSettings merges saved values) - settings.agents = Object.fromEntries(Object.keys(AGENT_REGISTRY).map(k => [k, ''])); - - await loadSettings(); - applyTheme(settings.themeColor || 'forest'); - initializeEventListeners(); - initializeSessionListeners(); - updateUserIndicator(); - - // Always show session selector on load (don't auto-resume last session) - const sessionsData = await fetchSessions(); - showSessionSelector(sessionsData.sessions); -}); - -// ============================================================ -// Dynamic HTML generation from AGENT_REGISTRY -// ============================================================ - -function renderNewTabMenu() { - const menu = document.getElementById('newTabMenu'); - if (!menu) return; - menu.innerHTML = ''; - for (const [key, agent] of Object.entries(AGENT_REGISTRY)) { - if (!agent.inMenu) continue; - const item = document.createElement('div'); - item.className = 'menu-item'; - item.dataset.type = key; - item.textContent = agent.label; - menu.appendChild(item); - } -} - -function renderLauncherButtons() { - const container = document.getElementById('launcherButtons'); - if (!container) return; - // Insert before the debug button (keep it at the end) - const debugBtn = container.querySelector('#debugBtn'); - for (const [key, agent] of Object.entries(AGENT_REGISTRY)) { - if (!agent.inLauncher) continue; - const btn = document.createElement('button'); - btn.className = 'launcher-btn'; - btn.dataset.type = key; - btn.textContent = agent.label; - container.insertBefore(btn, debugBtn); - } -} - -function renderAgentModelSelectors() { - const grid = document.getElementById('agentModelsGrid'); - if (!grid) return; - grid.innerHTML = ''; - for (const [key, agent] of Object.entries(AGENT_REGISTRY)) { - const label = document.createElement('label'); - label.textContent = `${agent.label}:`; - const select = document.createElement('select'); - select.id = `setting-agent-${key}`; - select.className = 'settings-select'; - grid.appendChild(label); - grid.appendChild(select); - } -} - -function initializeEventListeners() { - // Generate dynamic UI elements from registry - renderLauncherButtons(); - renderNewTabMenu(); - renderAgentModelSelectors(); - - // Launcher buttons in command center - document.querySelectorAll('.launcher-btn').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const type = btn.dataset.type; - createAgentTab(type); - }); - }); - - // Command center chat functionality - const commandInput = document.getElementById('input-command'); - const sendCommand = document.getElementById('sendCommand'); - if (commandInput && sendCommand) { - sendCommand.addEventListener('click', () => sendMessage(0)); - - // Auto-resize textarea - commandInput.addEventListener('input', () => { - commandInput.style.height = 'auto'; - commandInput.style.height = Math.min(commandInput.scrollHeight, 200) + 'px'; - commandInput.style.overflowY = commandInput.scrollHeight > 200 ? 'auto' : 'hidden'; - }); - - // Enter to send, Shift+Enter for newline - commandInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - sendMessage(0); - } - }); - } - - // Sidebar checkboxes - const compactViewCheckbox = document.getElementById('compactViewCheckbox'); - const collapseAgentsCheckbox = document.getElementById('collapseAgentsCheckbox'); - const collapseToolsCheckbox = document.getElementById('collapseToolsCheckbox'); - - // Compact view: affects timeline only (collapse agents + hide turns) - if (compactViewCheckbox) { - compactViewCheckbox.addEventListener('change', () => { - const compact = compactViewCheckbox.checked; - if (compact) { - // Collapse all agent boxes in timeline - Object.entries(timelineData).forEach(([id, data]) => { - if (data.parentTabId !== null) { - collapsedAgents.add(String(id)); - } - }); - showAllTurns = false; - } else { - collapsedAgents.clear(); - showAllTurns = true; - } - renderTimeline(); - }); - } - - // Chat collapse: agents — affects action-widgets in chat only - if (collapseAgentsCheckbox) { - collapseAgentsCheckbox.addEventListener('change', () => { - const collapse = collapseAgentsCheckbox.checked; - document.querySelectorAll('.action-widget').forEach(w => { - const toggle = w.querySelector('.widget-collapse-toggle'); - if (collapse) { - w.classList.add('collapsed'); - if (toggle) toggle.classList.add('collapsed'); - } else { - w.classList.remove('collapsed'); - if (toggle) toggle.classList.remove('collapsed'); - } - }); - }); - } - - // Chat collapse: tools — affects tool-cells in chat only - if (collapseToolsCheckbox) { - collapseToolsCheckbox.addEventListener('change', () => { - const collapse = collapseToolsCheckbox.checked; - document.querySelectorAll('.tool-cell').forEach(w => { - const toggle = w.querySelector('.widget-collapse-toggle'); - if (collapse) { - w.classList.add('collapsed'); - if (toggle) toggle.classList.add('collapsed'); - } else { - w.classList.remove('collapsed'); - if (toggle) toggle.classList.remove('collapsed'); - } - }); - }); - } - - // New tab button - toggle menu - const newTabBtn = document.getElementById('newTabBtn'); - const newTabMenu = document.getElementById('newTabMenu'); - - if (newTabBtn && newTabMenu) { - newTabBtn.addEventListener('click', (e) => { - e.stopPropagation(); - e.preventDefault(); - - // Position the menu below the button - const rect = newTabBtn.getBoundingClientRect(); - newTabMenu.style.top = `${rect.bottom}px`; - newTabMenu.style.left = `${rect.left}px`; - - newTabMenu.classList.toggle('active'); - }); - } - - // Close menu when clicking outside - document.addEventListener('click', (e) => { - if (!e.target.closest('.new-tab-wrapper')) { - newTabMenu.classList.remove('active'); - } - }); - - // Menu items - document.querySelectorAll('.menu-item').forEach(item => { - item.addEventListener('click', () => { - const type = item.dataset.type; - createAgentTab(type); - newTabMenu.classList.remove('active'); - }); - }); - - // Tab switching - document.addEventListener('click', (e) => { - const tab = e.target.closest('.tab'); - if (tab && !e.target.closest('.new-tab-wrapper')) { - if (e.target.classList.contains('tab-close')) { - closeTab(parseInt(tab.dataset.tabId)); - } else { - switchToTab(parseInt(tab.dataset.tabId)); - } - } - }); - - // Settings button - now handled in settings panel section below - // (removed old openSettings() call) - - // Save settings button - const saveSettingsBtn = document.getElementById('saveSettingsBtn'); - if (saveSettingsBtn) { - saveSettingsBtn.addEventListener('click', () => { - saveSettings(); - }); - } - - // Cancel settings button - const cancelSettingsBtn = document.getElementById('cancelSettingsBtn'); - if (cancelSettingsBtn) { - cancelSettingsBtn.addEventListener('click', () => { - const settingsPanel = document.getElementById('settingsPanel'); - const settingsBtn = document.getElementById('settingsBtn'); - const appContainer = document.querySelector('.app-container'); - if (settingsPanel) settingsPanel.classList.remove('active'); - if (settingsBtn) settingsBtn.classList.remove('active'); - if (appContainer) appContainer.classList.remove('panel-open'); - }); - } - - // Theme color picker - const themePicker = document.getElementById('theme-color-picker'); - if (themePicker) { - themePicker.addEventListener('click', (e) => { - const themeOption = e.target.closest('.theme-option'); - if (themeOption) { - // Remove selected from all - themePicker.querySelectorAll('.theme-option').forEach(opt => { - opt.classList.remove('selected'); - }); - // Add selected to clicked - themeOption.classList.add('selected'); - // Update hidden input - const themeValue = themeOption.dataset.theme; - document.getElementById('setting-theme-color').value = themeValue; - } - }); - } - - // Double-click on collapse toggle: collapse/uncollapse all widgets in the same chat - document.addEventListener('dblclick', (e) => { - const toggle = e.target.closest('.widget-collapse-toggle'); - if (!toggle) return; - e.stopPropagation(); - const chatContainer = toggle.closest('.chat-container'); - if (!chatContainer) return; - const widgets = chatContainer.querySelectorAll('.tool-cell, .code-cell, .action-widget'); - if (widgets.length === 0) return; - // If clicked widget is now collapsed, collapse all; otherwise uncollapse all - const clickedWidget = toggle.closest('.tool-cell, .code-cell, .action-widget'); - const shouldCollapse = !clickedWidget?.classList.contains('collapsed'); - widgets.forEach(w => { - w.classList.toggle('collapsed', shouldCollapse); - const t = w.querySelector('.widget-collapse-toggle'); - if (t) t.classList.toggle('collapsed', shouldCollapse); - }); - }); - - // Resizable sidebar - const sidebar = document.getElementById('agentsSidebar'); - const resizeHandle = document.getElementById('sidebarResizeHandle'); - if (sidebar && resizeHandle) { - const savedWidth = localStorage.getItem('agentui_sidebar_width'); - if (savedWidth) { - const w = parseInt(savedWidth); - if (w >= 140 && w <= 400) { - sidebar.style.setProperty('--sidebar-width', w + 'px'); - sidebar.style.width = w + 'px'; - } - } - - let dragging = false; - resizeHandle.addEventListener('mousedown', (e) => { - e.preventDefault(); - dragging = true; - resizeHandle.classList.add('dragging'); - document.body.style.cursor = 'col-resize'; - document.body.style.userSelect = 'none'; - }); - document.addEventListener('mousemove', (e) => { - if (!dragging) return; - const w = Math.min(400, Math.max(140, e.clientX)); - sidebar.style.setProperty('--sidebar-width', w + 'px'); - sidebar.style.width = w + 'px'; - }); - document.addEventListener('mouseup', () => { - if (!dragging) return; - dragging = false; - resizeHandle.classList.remove('dragging'); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - const w = parseInt(sidebar.style.width); - if (w) localStorage.setItem('agentui_sidebar_width', w); - }); - } -} - -// ============================================ -// Session Management -// ============================================ - -async function fetchSessions() { - try { - const response = await apiFetch('/api/sessions'); - if (response.ok) { - return await response.json(); - } - } catch (e) { - console.error('Failed to fetch sessions:', e); - } - return { sessions: [], current: null }; -} - -function showSessionSelector(sessions) { - const selector = document.getElementById('sessionSelector'); - const welcome = document.getElementById('welcomeMessage'); - const sessionIndicator = document.getElementById('sessionIndicator'); - const inputArea = document.getElementById('commandInputArea'); - - // Show welcome message and session selector - if (welcome) welcome.style.display = 'block'; - if (selector) selector.style.display = 'block'; - if (sessionIndicator) sessionIndicator.style.display = 'none'; - if (inputArea) inputArea.style.display = 'none'; - - // Populate existing sessions dropdown - const select = document.getElementById('existingSessionSelect'); - if (select) { - select.innerHTML = ''; - sessions.forEach(session => { - const option = document.createElement('option'); - option.value = session.name; - option.textContent = `${session.name} (${formatDate(session.modified)})`; - select.appendChild(option); - }); - } -} - -function hideSessionSelector() { - const selector = document.getElementById('sessionSelector'); - const welcome = document.getElementById('welcomeMessage'); - const sessionIndicator = document.getElementById('sessionIndicator'); - const inputArea = document.getElementById('commandInputArea'); - - // Hide session selector, keep welcome visible, show session indicator and input - if (selector) selector.style.display = 'none'; - if (welcome) welcome.style.display = 'block'; - if (sessionIndicator) sessionIndicator.style.display = 'block'; - if (inputArea) inputArea.style.display = 'block'; -} - -async function onSessionSelected(sessionName) { - currentSession = sessionName; - hideSessionSelector(); - - // Update session name display - const nameEl = document.getElementById('currentSessionName'); - if (nameEl) nameEl.textContent = sessionName; - - const renameInput = document.getElementById('currentSessionRename'); - if (renameInput) renameInput.value = sessionName; - - // Update timeline title to show session name - if (timelineData[0]) { - timelineData[0].title = sessionName; - renderTimeline(); - } - - // Load workspace for this session - await loadWorkspace(); - - // Refresh sessions panel list - await refreshSessionsList(); -} - -async function createSession(name) { - try { - const response = await apiFetch('/api/sessions', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name }) - }); - if (response.ok) { - const data = await response.json(); - await onSessionSelected(data.name); - return true; - } else { - const error = await response.json(); - alert(error.detail || 'Failed to create session'); - } - } catch (e) { - console.error('Failed to create session:', e); - alert('Failed to create session'); - } - return false; -} - -async function selectSession(name) { - try { - const response = await apiFetch('/api/sessions/select', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name }) - }); - if (response.ok) { - // Reset local state and load the selected session - resetLocalState(); - await onSessionSelected(name); - return true; - } else { - const error = await response.json(); - alert(error.detail || 'Failed to select session'); - } - } catch (e) { - console.error('Failed to select session:', e); - alert('Failed to select session'); - } - return false; -} - -async function renameSession(oldName, newName) { - try { - const response = await apiFetch('/api/sessions/rename', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ oldName, newName }) - }); - if (response.ok) { - const data = await response.json(); - currentSession = data.name; - - // Update displays - const nameEl = document.getElementById('currentSessionName'); - if (nameEl) nameEl.textContent = data.name; - - // Update timeline title - if (timelineData[0]) { - timelineData[0].title = data.name; - renderTimeline(); - } - - await refreshSessionsList(); - return true; - } else { - const error = await response.json(); - alert(error.detail || 'Failed to rename session'); - } - } catch (e) { - console.error('Failed to rename session:', e); - alert('Failed to rename session'); - } - return false; -} - -function openSessionsPanel() { - const sessionsBtn = document.getElementById('sessionsBtn'); - if (sessionsBtn) { - sessionsBtn.click(); - } -} - -async function refreshSessionsList() { - const sessionsData = await fetchSessions(); - const listEl = document.getElementById('sessionsList'); - - // Update the current session rename input - const renameInput = document.getElementById('currentSessionRename'); - if (renameInput && currentSession) { - renameInput.value = currentSession; - } - - if (!listEl) return; - - if (sessionsData.sessions.length === 0) { - listEl.innerHTML = '
No other sessions
'; - return; - } - - listEl.innerHTML = ''; - sessionsData.sessions.forEach(session => { - const item = document.createElement('div'); - item.className = 'sessions-list-item' + (session.name === currentSession ? ' current' : ''); - - const isCurrent = session.name === currentSession; - item.innerHTML = ` - ${escapeHtml(session.name)} - ${formatDate(session.modified)} - ${!isCurrent ? `` : ''} - `; - - if (!isCurrent) { - // Click on name/date to select - item.querySelector('.sessions-list-item-name').addEventListener('click', () => selectSession(session.name)); - item.querySelector('.sessions-list-item-date').addEventListener('click', () => selectSession(session.name)); - - // Click on delete button to delete - const deleteBtn = item.querySelector('.sessions-delete-btn'); - if (deleteBtn) { - deleteBtn.addEventListener('click', (e) => { - e.stopPropagation(); - deleteSession(session.name); - }); - } - } - - listEl.appendChild(item); - }); -} - -async function deleteSession(sessionName) { - if (!confirm(`Delete session "${sessionName}"? This cannot be undone.`)) { - return; - } - - try { - const response = await apiFetch(`/api/sessions/${encodeURIComponent(sessionName)}`, { - method: 'DELETE' - }); - - if (!response.ok) { - try { - const error = await response.json(); - alert(error.detail || 'Failed to delete session'); - } catch { - alert('Failed to delete session'); - } - return; - } - - // Refresh the sessions list in the panel - refreshSessionsList(); - - // Also refresh the welcome page dropdown - const sessionsData = await fetchSessions(); - const select = document.getElementById('existingSessionSelect'); - if (select) { - select.innerHTML = ''; - sessionsData.sessions.forEach(session => { - const option = document.createElement('option'); - option.value = session.name; - option.textContent = `${session.name} (${formatDate(session.modified)})`; - select.appendChild(option); - }); - } - } catch (error) { - console.error('Error deleting session:', error); - alert('Failed to delete session'); - } -} - -function formatDate(timestamp) { - const date = new Date(timestamp * 1000); - const now = new Date(); - const diff = now - date; - - if (diff < 60000) return 'just now'; - if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago'; - if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago'; - if (diff < 604800000) return Math.floor(diff / 86400000) + 'd ago'; - - return date.toLocaleDateString(); -} - -function initializeSessionListeners() { - // Welcome page session selector - const createBtn = document.getElementById('createSessionBtn'); - const newNameInput = document.getElementById('newSessionName'); - const existingSelect = document.getElementById('existingSessionSelect'); - - // Pre-populate with a cool random name - if (newNameInput) { - generateSessionName().then(name => newNameInput.value = name); - } - - // Regenerate button - const regenerateBtn = document.getElementById('regenerateNameBtn'); - if (regenerateBtn && newNameInput) { - regenerateBtn.addEventListener('click', async () => { - newNameInput.value = await generateSessionName(); - }); - } - - if (createBtn) { - createBtn.addEventListener('click', async () => { - const name = newNameInput?.value.trim(); - if (name) { - createSession(name); - } else { - // Auto-generate name - createSession(await generateSessionName()); - } - }); - } - - if (newNameInput) { - newNameInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - createBtn?.click(); - } - }); - } - - if (existingSelect) { - existingSelect.addEventListener('change', () => { - const name = existingSelect.value; - if (name) { - selectSession(name); - } - }); - } - - // Sessions panel handlers are set up at the end of the file with other panels - - // Panel rename button - const renameBtn = document.getElementById('renameSessionBtn'); - const renameInput = document.getElementById('currentSessionRename'); - - if (renameBtn && renameInput) { - renameBtn.addEventListener('click', () => { - const newName = renameInput.value.trim(); - if (newName && newName !== currentSession) { - renameSession(currentSession, newName); - } - }); - } - - // Panel create new session - const panelCreateBtn = document.getElementById('panelCreateSessionBtn'); - const panelNewNameInput = document.getElementById('panelNewSessionName'); - const panelRegenerateBtn = document.getElementById('panelRegenerateNameBtn'); - - // Pre-populate panel input with cool name too - if (panelNewNameInput) { - generateSessionName().then(name => panelNewNameInput.value = name); - } - - // Panel regenerate button - if (panelRegenerateBtn && panelNewNameInput) { - panelRegenerateBtn.addEventListener('click', async () => { - panelNewNameInput.value = await generateSessionName(); - }); - } - - if (panelCreateBtn) { - panelCreateBtn.addEventListener('click', async () => { - const name = panelNewNameInput?.value.trim(); - if (name) { - resetLocalState(); - await createSession(name); - // Pre-populate a new name for next time - if (panelNewNameInput) { - panelNewNameInput.value = await generateSessionName(); - } - } - }); - } -} - -function createAgentTab(type, initialMessage = null, autoSwitch = true, taskId = null, parentTabId = null) { - const tabId = tabCounter++; - - // Use task_id if provided, otherwise generate default title - let title; - if (taskId) { - // Convert dashes to spaces and title case for display - title = taskId; - // Register this agent for task_id reuse - taskIdToTabId[taskId] = tabId; - } else if (type !== 'command-center') { - agentCounters[type]++; - title = `New ${type} task`; - } else { - title = getTypeLabel(type); - } - - // Register in timeline - registerAgentInTimeline(tabId, type, title, parentTabId); - - // Create tab - const tab = document.createElement('div'); - tab.className = 'tab'; - tab.dataset.tabId = tabId; - tab.innerHTML = ` - ${title} - - × - `; - - // Insert into the dynamic tabs container - const dynamicTabs = document.getElementById('dynamicTabs'); - dynamicTabs.appendChild(tab); - - // Hide tab for subagents until the user clicks on them - if (!autoSwitch && parentTabId !== null) { - tab.style.display = 'none'; - } - - // Create content - const content = document.createElement('div'); - content.className = 'tab-content'; - content.dataset.contentId = tabId; - content.innerHTML = createAgentContent(type, tabId, title); - - document.querySelector('.main-content').appendChild(content); - - // Only switch to new tab if autoSwitch is true - if (autoSwitch) { - switchToTab(tabId); - } - - // Add event listeners for the new content - if (type !== 'command-center') { - const input = content.querySelector('textarea'); - const sendBtn = content.querySelector('.input-container button'); - - if (input && sendBtn) { - sendBtn.addEventListener('click', () => sendMessage(tabId)); - - // Auto-resize textarea - input.addEventListener('input', () => { - input.style.height = 'auto'; - input.style.height = Math.min(input.scrollHeight, 200) + 'px'; - input.style.overflowY = input.scrollHeight > 200 ? 'auto' : 'hidden'; - }); - - // Enter to send, Shift+Enter for newline - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - sendMessage(tabId); - } - }); - } - - // If this is a code agent, start the sandbox proactively - if (type === 'code') { - startSandbox(tabId); - } - - // If there's an initial message, automatically send it - if (initialMessage && input) { - input.value = initialMessage; - // Small delay to ensure everything is set up - setTimeout(() => { - sendMessage(tabId); - }, 100); - } - } - - // Save workspace state after creating new tab - saveWorkspaceDebounced(); - - return tabId; // Return the tabId so we can reference it -} - -function createAgentContent(type, tabId, title = null) { - if (type === 'command-center') { - return document.querySelector('[data-content-id="0"]').innerHTML; - } - - // Use unique ID combining type and tabId to ensure unique container IDs - const uniqueId = `${type}-${tabId}`; - - // Display title or default - const displayTitle = title || `New ${type} task`; - - return ` -
-
-
-
${getTypeLabel(type)}
-

${escapeHtml(displayTitle)}

-
-
-
-
-
-
-
-
- - -
-
-
- `; -} - -function switchToTab(tabId) { - // Deactivate all tabs - document.querySelectorAll('.tab').forEach(tab => { - tab.classList.remove('active'); - }); - document.querySelectorAll('.tab-content').forEach(content => { - content.classList.remove('active'); - }); - - // Activate selected tab (use .tab selector to avoid matching timeline elements) - const tab = document.querySelector(`.tab[data-tab-id="${tabId}"]`); - const content = document.querySelector(`.tab-content[data-content-id="${tabId}"]`); - - if (content) { - // For settings, there's no tab, just show the content - if (tabId === 'settings') { - content.classList.add('active'); - activeTabId = tabId; - } else if (tab) { - tab.style.display = ''; - tab.classList.add('active'); - content.classList.add('active'); - activeTabId = tabId; - - // Save workspace state after switching tabs - saveWorkspaceDebounced(); - } - } - - // Update timeline to reflect active tab - renderTimeline(); -} - -function closeTab(tabId) { - if (tabId === 0) return; // Can't close command center - - const tab = document.querySelector(`[data-tab-id="${tabId}"]`); - const content = document.querySelector(`[data-content-id="${tabId}"]`); - - if (tab && content) { - // Check if this is a code agent and stop its sandbox - const chatContainer = content.querySelector('.chat-container'); - const agentType = chatContainer?.dataset.agentType || 'chat'; - - if (agentType === 'code') { - stopSandbox(tabId); - } - - // Save the tab's content BEFORE removing from DOM so we can restore it later - if (timelineData[tabId]) { - timelineData[tabId].savedContent = content.innerHTML; - timelineData[tabId].savedTitle = tab.querySelector('.tab-title')?.textContent; - } - - // Clean up task_id mapping - for (const [taskId, mappedTabId] of Object.entries(taskIdToTabId)) { - if (mappedTabId === tabId) { - delete taskIdToTabId[taskId]; - break; - } - } - - tab.remove(); - content.remove(); - - // Mark as closed in timeline (don't remove - allow reopening) - if (timelineData[tabId]) { - timelineData[tabId].isClosed = true; - renderTimeline(); - } - - // Switch to command center if closing active tab - if (activeTabId === tabId) { - switchToTab(0); - } - - // Save workspace state after closing tab - saveWorkspaceDebounced(); - } -} - -function showProgressWidget(chatContainer) { - // Remove any existing progress widget - hideProgressWidget(chatContainer); - - const widget = document.createElement('div'); - widget.className = 'progress-widget'; - widget.innerHTML = ` -
- - - -
- Generating... - `; - chatContainer.appendChild(widget); - scrollChatToBottom(chatContainer, true); - return widget; -} - -function hideProgressWidget(chatContainer) { - const widget = chatContainer.querySelector('.progress-widget'); - if (widget) { - widget.remove(); - } -} - -function scrollChatToBottom(chatContainer, force = false) { - // The actual scrolling container is .agent-body - const agentBody = chatContainer.closest('.agent-body'); - if (!agentBody) return; - - // If not forced, only scroll if user is already near the bottom - if (!force) { - const distanceFromBottom = agentBody.scrollHeight - agentBody.scrollTop - agentBody.clientHeight; - if (distanceFromBottom > 150) return; - } - - agentBody.scrollTop = agentBody.scrollHeight; -} - -function scrollToTimelineEvent(tabId, eventIndex) { - if (eventIndex === undefined || eventIndex === null) return; - - // Find the tab's content area and chat container - const content = document.querySelector(`[data-content-id="${tabId}"]`); - if (!content) return; - const chatContainer = content.querySelector('.chat-container'); - if (!chatContainer) return; - - // Find the chat element tagged with this timeline index - const target = chatContainer.querySelector(`[data-timeline-index="${eventIndex}"]`); - if (!target) return; - - // Scroll the agent-body so the target is near the top - const agentBody = chatContainer.closest('.agent-body'); - if (!agentBody) return; - - // Use a small delay to ensure tab switch has rendered - requestAnimationFrame(() => { - target.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }); -} - -async function abortAgent(tabId) { - // Abort the frontend fetch - const controller = activeAbortControllers[tabId]; - if (controller) { - controller.abort(); - } - - // For command center (tab 0): also abort all generating child tabs - if (tabId === 0) { - for (const [childId, td] of Object.entries(timelineData)) { - if (td.parentTabId === 0 && td.isGenerating) { - // Abort frontend fetch - const childController = activeAbortControllers[childId]; - if (childController) { - childController.abort(); - } - // Abort backend agent - try { - await apiFetch('/api/abort', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ agent_id: childId.toString() }) - }); - } catch (e) { /* ignore */ } - } - } - // Unblock command center input and restore SEND button - commandInputBlocked = false; - pendingAgentLaunches = 0; - setCommandCenterStopState(false); - const commandInput = document.getElementById('input-command'); - if (commandInput) { - commandInput.disabled = false; - } - } - - // Tell the backend to set the abort flag for this agent - try { - await apiFetch('/api/abort', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ agent_id: tabId.toString() }) - }); - } catch (e) { - // Ignore abort endpoint errors - } -} - -async function sendMessage(tabId) { - // If tab is currently generating, abort instead of sending - // For command center: also abort if input is blocked (sub-agents still running) - if (timelineData[tabId]?.isGenerating || (tabId === 0 && commandInputBlocked)) { - abortAgent(tabId); - return; - } - - const content = document.querySelector(`[data-content-id="${tabId}"]`); - if (!content) return; - - // Support both textarea and input - const input = content.querySelector('textarea') || content.querySelector('input[type="text"]'); - const chatContainer = content.querySelector('.chat-container'); - - if (!input || !chatContainer) return; - - const message = input.value.trim(); - if (!message) return; - - // Remove welcome message if it exists (only on first user message) - const welcomeMsg = chatContainer.querySelector('.welcome-message'); - const isFirstMessage = welcomeMsg !== null; - if (welcomeMsg) { - welcomeMsg.remove(); - } - - // Add user message - const userMsg = document.createElement('div'); - userMsg.className = 'message user'; - userMsg.innerHTML = `
${parseMarkdown(message.trim())}
`; - linkifyFilePaths(userMsg); - chatContainer.appendChild(userMsg); - - // Add to timeline and tag DOM element for scroll-to-turn - const evIdx = addTimelineEvent(tabId, 'user', message); - userMsg.dataset.timelineIndex = evIdx; - - // Scroll the agent body (the actual scrolling container) to bottom - const agentBody = chatContainer.closest('.agent-body'); - if (agentBody) { - agentBody.scrollTop = agentBody.scrollHeight; - } - - // Show progress widget while waiting for response - showProgressWidget(chatContainer); - - // Generate a title for the agent if this is the first message and not command center - if (isFirstMessage && tabId !== 0) { - generateAgentTitle(tabId, message); - } - - // Clear input and disable it during processing - input.value = ''; - input.style.height = 'auto'; // Reset textarea height - input.disabled = true; - - // Set tab to generating state - setTabGenerating(tabId, true); - - // Determine agent type from chat container ID - const agentType = getAgentTypeFromContainer(chatContainer); - - // Send full conversation history for all agent types (stateless backend) - const messages = getConversationHistory(chatContainer); - - // Stream response from backend - await streamChatResponse(messages, chatContainer, agentType, tabId); - - // Re-enable input and mark generation as complete - setTabGenerating(tabId, false); - - if (tabId === 0) { - // Command center: keep input blocked if launched agents are still running or pending - const anyChildGenerating = Object.values(timelineData).some( - td => td.parentTabId === 0 && td.isGenerating - ); - if (anyChildGenerating || pendingAgentLaunches > 0) { - commandInputBlocked = true; - // Keep STOP button visible and input disabled while children run - input.disabled = true; - setCommandCenterStopState(true); - } else { - input.disabled = false; - input.focus(); - } - } else { - input.disabled = false; - input.focus(); - } - - // Save workspace state after message exchange completes - saveWorkspaceDebounced(); -} - -function setCommandCenterStopState(showStop) { - const content = document.querySelector('[data-content-id="0"]'); - if (!content) return; - const sendBtn = content.querySelector('.input-container button'); - if (!sendBtn) return; - if (showStop) { - sendBtn.textContent = 'STOP'; - sendBtn.classList.add('stop-btn'); - sendBtn.disabled = false; - } else { - sendBtn.textContent = 'SEND'; - sendBtn.classList.remove('stop-btn'); - } -} - -async function continueCommandCenter() { - // Called when all launched agents finish — re-run command center with actual results in history - const chatContainer = document.getElementById('messages-command'); - const commandInput = document.getElementById('input-command'); - if (!chatContainer) return; - - setTabGenerating(0, true); - - const messages = getConversationHistory(chatContainer); - await streamChatResponse(messages, chatContainer, 'command', 0); - - setTabGenerating(0, false); - - // Check if new agents were launched (recursive blocking) - const anyChildGenerating = Object.values(timelineData).some( - td => td.parentTabId === 0 && td.isGenerating - ); - if (anyChildGenerating || pendingAgentLaunches > 0) { - commandInputBlocked = true; - if (commandInput) commandInput.disabled = true; - setCommandCenterStopState(true); - } else if (commandInput) { - commandInput.disabled = false; - commandInput.focus(); - } - - saveWorkspaceDebounced(); -} - -async function generateAgentTitle(tabId, query) { - const currentSettings = getSettings(); - const backendEndpoint = '/api'; - const llmEndpoint = currentSettings.endpoint || 'https://api.openai.com/v1'; - const modelToUse = currentSettings.model || 'gpt-4'; - - try { - const response = await apiFetch(`${backendEndpoint}/generate-title`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - query: query, - endpoint: llmEndpoint, - token: currentSettings.token || null, - model: modelToUse - }) - }); - - if (response.ok) { - const result = await response.json(); - const title = result.title; - - // Update the tab title - const tab = document.querySelector(`[data-tab-id="${tabId}"]`); - if (tab) { - const titleEl = tab.querySelector('.tab-title'); - if (titleEl) { - titleEl.textContent = title.toUpperCase(); - // Save workspace after title update - saveWorkspaceDebounced(); - // Update timeline to reflect new title - updateTimelineTitle(tabId, title.toUpperCase()); - } - } - } - } catch (error) { - console.error('Failed to generate title:', error); - // Don't show error to user, just keep the default title - } -} - -function getAgentTypeFromContainer(chatContainer) { - // Try to get type from data attribute first (for dynamically created agents) - const typeFromData = chatContainer.dataset.agentType; - if (typeFromData) { - return typeFromData; - } - - // Fallback: Extract agent type from the container ID (e.g., "messages-command" -> "command") - const containerId = chatContainer.id; - if (containerId && containerId.startsWith('messages-')) { - const type = containerId.replace('messages-', ''); - // Map to agent type - if (type === 'command') return 'command'; - if (type.startsWith('agent')) return 'agent'; - if (type.startsWith('code')) return 'code'; - if (type.startsWith('research')) return 'research'; - if (type.startsWith('chat')) return 'chat'; - } - return 'command'; // Default fallback -} - -function getConversationHistory(chatContainer) { - const messages = []; - const messageElements = chatContainer.querySelectorAll('.message'); - - messageElements.forEach(msg => { - if (msg.classList.contains('user')) { - // User messages use .message-content - const contentEl = msg.querySelector('.message-content'); - const content = contentEl?.textContent.trim() || msg.textContent.trim(); - if (content) { - messages.push({ role: 'user', content: content }); - } - } else if (msg.classList.contains('assistant')) { - // Assistant messages use .message-content - const contentEl = msg.querySelector('.message-content'); - const content = contentEl?.textContent.trim(); - - // Check if this message has a tool call - const toolCallData = msg.getAttribute('data-tool-call'); - if (toolCallData) { - const toolCall = JSON.parse(toolCallData); - let funcName, funcArgs; - - if (toolCall.function_name) { - // Agent-style tool call (web_search, read_url, etc.) - funcName = toolCall.function_name; - funcArgs = toolCall.arguments; - } else { - // Command center-style tool call (launch_*_agent) - funcName = `launch_${toolCall.agent_type}_agent`; - funcArgs = JSON.stringify({ - task: toolCall.message, - topic: toolCall.message, - message: toolCall.message - }); - } - - messages.push({ - role: 'assistant', - content: toolCall.thinking || content || '', - tool_calls: [{ - id: toolCall.tool_call_id || 'tool_' + Date.now(), - type: 'function', - function: { - name: funcName, - arguments: funcArgs - } - }] - }); - } else if (content && !content.includes('msg-') && !content.includes('→ Launched')) { - // Regular assistant message (exclude launch notifications) - messages.push({ role: 'assistant', content: content }); - } - } else if (msg.classList.contains('tool')) { - // Tool response message - const toolResponseData = msg.getAttribute('data-tool-response'); - if (toolResponseData) { - const toolResponse = JSON.parse(toolResponseData); - messages.push({ - role: 'tool', - tool_call_id: toolResponse.tool_call_id, - content: toolResponse.content - }); - } - } - }); - - return messages; -} - -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 = `
No models configured. Please open Settings and add a provider and model.
`; - 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') { - // Populate global figure/image registry only for items referenced in result content - const resultText = data.content || ''; - if (data.figures) { - for (const [name, figData] of Object.entries(data.figures)) { - if (new RegExp(``, 'i').test(resultText)) { - globalFigureRegistry[name] = figData; - } - } - } - if (data.images) { - for (const [name, imgBase64] of Object.entries(data.images)) { - if (new RegExp(``, 'i').test(resultText)) { - globalFigureRegistry[name] = { type: 'png', data: imgBase64 }; - } - } - } - // Agent result - update command center widget - updateActionWidgetWithResult(tabId, data.content, data.figures, data.images); - - } else if (data.type === 'result_preview') { - // Show result preview - flushResponseToTimeline(); - // Replace 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 self-closing and pairs - // First replace paired tags, preserving them as block elements - const pairedTag = new RegExp(`<${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(``, 'gi'); - previewContent = previewContent.replace(singleTag, `\n\n${placeholderId}\n\n`); - } - } - - // Handle references from image agent - if (data.images) { - for (const [imageName, imageBase64] of Object.entries(data.images)) { - const placeholderId = `%%%IMAGE_${imageName}%%%`; - figurePlaceholders[placeholderId] = { type: 'png', data: imageBase64, isGenerated: true }; - - const pairedTag = new RegExp(`<${imageName}>`, 'gi'); - previewContent = previewContent.replace(pairedTag, `\n\n${placeholderId}\n\n`); - const singleTag = new RegExp(``, '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 = ``; - } else if (figureData.type === 'svg') { - imageHtml = `
${atob(figureData.data)}
`; - } - // Replace both the placeholder and any paragraph-wrapped version - html = html.replace(new RegExp(`

${placeholderId}

`, 'g'), imageHtml); - html = html.replace(new RegExp(placeholderId, 'g'), imageHtml); - } - - // Create result block - const resultDiv = document.createElement('div'); - resultDiv.className = 'agent-result'; - resultDiv.innerHTML = ` -
Result
-
${html}
- `; - 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', - '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 || '', - '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 ? `${escapeHtml(description)}` : ''; - toolCell.innerHTML = ` -
${label}${descHtml}${createSpinnerHtml()}
-
${escapeHtml(description)}
- `; - toolCell.querySelector('.tool-cell-label').addEventListener('click', () => { - toolCell.classList.toggle('collapsed'); - toolCell.querySelector('.widget-collapse-toggle').classList.toggle('collapsed'); - }); - 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 = '
' + - results.map(r => - `
${escapeHtml(r.title)}${escapeHtml(r.snippet)}
` - ).join('') + '
'; - } - } 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 = `
${summaryText}
${parseMarkdown(markdown)}
`; - } else { - outputHtml = `
${summaryText}
`; - } - } else if (data.tool === 'screenshot_url' && data.image) { - outputHtml = `Screenshot`; - } 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 = `${escapeHtml(imgName)}`; - } 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 = `
${escapeHtml(errMsg)}
`; - } 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 = `
${escapeHtml(errMsg)}
`; - } - - 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 = '
'; - 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(/([\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(/[\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 = `${escapeHtml(data.content)}`; - 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 = `
Error: ${escapeHtml(data.content)}
`; - 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 = ` -
Result
-

${abortResultText}

- `; - 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 = `
Connection error: ${escapeHtml(error.message)}
`; - 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 = '
'; - 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 `
`; -} - -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 = `
${escapeHtml(cleanedOutput)}
`; - } - - const spinnerHtml = isExecuting ? createSpinnerHtml() : ''; - - codeCell.innerHTML = ` -
CODE${spinnerHtml}
-
${escapeHtml(code)}
- ${outputHtml} - `; - codeCell.querySelector('.code-cell-label').addEventListener('click', () => { - codeCell.classList.toggle('collapsed'); - codeCell.querySelector('.widget-collapse-toggle').classList.toggle('collapsed'); - }); - 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 - ? `
` - : `
`; - - // 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 `
  • ${escapeHtml(p.substring(0, arrowIdx + 4))}${escapeHtml(localPath)}
  • `; - } - } - return `
  • ${escapeHtml(p)}
  • `; - }).join(''); - - let outputHtml = ''; - if (output && !isExecuting) { - const outputClass = hasError ? 'transfer-output error' : 'transfer-output'; - outputHtml = `
    ${escapeHtml(output)}
    `; - } - - cell.innerHTML = ` -
    -
    - ${label} -
    ${indicatorHtml}
    -
    -
    -
      ${pathsList}
    - ${outputHtml} -
    - `; - 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 = ` -
    -
    -
    - ${action.toUpperCase()}: ${escapeHtml(titleDisplay)} -
    -
    - -
    -
    -
    -
    -
    - -
    ${parseMarkdown(message)}
    -
    - `; - - // 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, images) { - 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 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 self-closing and pairs - // First replace paired tags, preserving them as block elements - const pairedTag = new RegExp(`<${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(``, 'gi'); - processedContent = processedContent.replace(singleTag, `\n\n${placeholderId}\n\n`); - } - } - - // Handle references from image agent - if (images) { - for (const [imageName, imageBase64] of Object.entries(images)) { - const placeholderId = `%%%IMAGE_${imageName}%%%`; - figurePlaceholders[placeholderId] = { type: 'png', data: imageBase64 }; - - const pairedTag = new RegExp(`<${imageName}>`, 'gi'); - processedContent = processedContent.replace(pairedTag, `\n\n${placeholderId}\n\n`); - const singleTag = new RegExp(``, '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 = ``; - } else if (figureData.type === 'svg') { - imageHtml = `
    ${atob(figureData.data)}
    `; - } - // Replace both the placeholder and any paragraph-wrapped version - html = html.replace(new RegExp(`

    ${placeholderId}

    `, '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 = ` - -
    ${html}
    - `; - 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) { - // 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 escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - -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 = ` -
    -
    -
    -
    ${escapeHtml(data.message)}
    -
    Retrying (${data.attempt}/${data.max_attempts}) in ${data.delay}s...
    -
    -
    - `; - 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 `${textVal}`; - }; - - 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 `
    ${highlighted}
    `; - } - return `
    ${escapeHtml(code)}
    `; - }; - - marked.setOptions({ - gfm: true, // GitHub Flavored Markdown - breaks: false, // Don't convert \n to
    - pedantic: false, - renderer: renderer - }); -} - -// Resolve and references using the global registry -function resolveGlobalFigureRefs(html) { - return html.replace(/<\/?(figure_\d+|image_\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 ``; - } else if (data.type === 'svg') { - return `
    ${atob(data.data)}
    `; - } - 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 = `

    ${escapeHtml(text).replace(/\n\n/g, '

    ').replace(/\n/g, '
    ')}

    `; - } - - // 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 `${escapeHtml(match)}`; - } - }); - - // Inline math: $ ... $ (but not $$ or escaped \$) - html = html.replace(/(? { - try { - return katex.renderToString(latex.trim(), { displayMode: false, throwOnError: false }); - } catch (e) { - return `${escapeHtml(match)}`; - } - }); - } - - return html; -} - -// ============================================ -// Workspace State Persistence -// ============================================ - -async function loadWorkspace() { - try { - const response = await apiFetch('/api/workspace'); - if (response.ok) { - const workspace = await response.json(); - console.log('Workspace loaded:', workspace); - restoreWorkspace(workspace); - } - } catch (e) { - console.log('Could not load workspace from backend:', e); - } -} - -function restoreWorkspace(workspace) { - // Restore counters - tabCounter = workspace.tabCounter || 1; - agentCounters = workspace.agentCounters || workspace.notebookCounters || getDefaultCounters(); - - // Restore timeline data before tabs so renderTimeline works - if (workspace.timelineData) { - Object.keys(timelineData).forEach(k => delete timelineData[k]); - for (const [tabId, data] of Object.entries(workspace.timelineData)) { - timelineData[tabId] = data; - } - } - - // Restore debug history - if (workspace.debugHistory) { - Object.keys(debugHistory).forEach(k => delete debugHistory[k]); - for (const [tabId, calls] of Object.entries(workspace.debugHistory)) { - debugHistory[tabId] = calls; - } - } - - // Restore tabs (skip command center as it already exists in HTML) - const tabs = workspace.tabs || []; - for (const tabData of tabs) { - if (tabData.id === 0) { - // Restore command center messages - restoreTabMessages(tabData); - } else { - // Create and restore other tabs - restoreTab(tabData); - } - } - - // Switch to the active tab - if (workspace.activeTabId !== undefined) { - switchToTab(workspace.activeTabId); - } - - // Render timeline after everything is restored - renderTimeline(); -} - -function restoreTab(tabData) { - // Create tab element - const tab = document.createElement('div'); - tab.className = 'tab'; - tab.dataset.tabId = tabData.id; - tab.innerHTML = ` - ${tabData.title || getTypeLabel(tabData.type) || 'TAB'} - - × - `; - - // Insert into dynamic tabs container - const dynamicTabs = document.getElementById('dynamicTabs'); - dynamicTabs.appendChild(tab); - - // Create content element - const content = document.createElement('div'); - content.className = 'tab-content'; - content.dataset.contentId = tabData.id; - content.innerHTML = createAgentContent(tabData.type, tabData.id); - document.querySelector('.main-content').appendChild(content); - - // Add event listeners for the new content - const input = content.querySelector('textarea'); - const sendBtn = content.querySelector('.input-container button'); - - if (input && sendBtn) { - sendBtn.addEventListener('click', () => sendMessage(tabData.id)); - - input.addEventListener('input', () => { - input.style.height = 'auto'; - input.style.height = Math.min(input.scrollHeight, 200) + 'px'; - }); - - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - sendMessage(tabData.id); - } - }); - } - - // Restore messages - restoreTabMessages(tabData); - - // If this is a code agent, start the sandbox proactively - if (tabData.type === 'code') { - startSandbox(tabData.id); - } -} - -function restoreTabMessages(tabData) { - const content = document.querySelector(`[data-content-id="${tabData.id}"]`); - if (!content) return; - - const chatContainer = content.querySelector('.chat-container'); - if (!chatContainer) return; - - // Remove welcome message if restoring messages - if (tabData.messages && tabData.messages.length > 0) { - const welcomeMsg = chatContainer.querySelector('.welcome-message'); - if (welcomeMsg) { - welcomeMsg.remove(); - } - } - - // Restore each message - messages are now flat, each with its own type - for (const msg of (tabData.messages || [])) { - if (msg.role === 'user') { - const userMsg = document.createElement('div'); - userMsg.className = 'message user'; - userMsg.innerHTML = `
    ${parseMarkdown(msg.content || '')}
    `; - linkifyFilePaths(userMsg); - chatContainer.appendChild(userMsg); - } else if (msg.role === 'assistant') { - // New flat structure: each message has a type field - switch (msg.type) { - case 'text': - const assistantMsg = document.createElement('div'); - assistantMsg.className = 'message assistant'; - const textDiv = document.createElement('div'); - textDiv.className = 'message-content'; - textDiv.innerHTML = msg.html || parseMarkdown(msg.content || ''); - assistantMsg.appendChild(textDiv); - chatContainer.appendChild(assistantMsg); - break; - - case 'action-widget': - const widget = renderActionWidget(msg); - chatContainer.appendChild(widget); - break; - - case 'code-cell': - const codeCell = renderCodeCell(msg); - chatContainer.appendChild(codeCell); - break; - - case 'tool-call': - // Restore hidden tool-call element for getConversationHistory() - const tcMsg = document.createElement('div'); - tcMsg.className = 'message assistant'; - tcMsg.style.display = 'none'; - tcMsg.setAttribute('data-tool-call', JSON.stringify(msg.data)); - chatContainer.appendChild(tcMsg); - break; - - case 'tool-cell': - const toolCell = document.createElement('div'); - toolCell.className = 'tool-cell'; - toolCell.setAttribute('data-tool-name', msg.toolName || ''); - const toolDesc = msg.input ? `${escapeHtml(msg.input)}` : ''; - let toolCellHtml = `
    ${escapeHtml(msg.label || '')}${toolDesc}
    `; - toolCellHtml += `
    ${escapeHtml(msg.input || '')}
    `; - if (msg.outputHtml) { - toolCellHtml += `
    ${msg.outputHtml}
    `; - } - toolCell.innerHTML = toolCellHtml; - toolCell.querySelector('.tool-cell-label').addEventListener('click', () => { - toolCell.classList.toggle('collapsed'); - toolCell.querySelector('.widget-collapse-toggle').classList.toggle('collapsed'); - }); - chatContainer.appendChild(toolCell); - break; - - case 'result-preview': - const preview = renderResultPreview(msg); - chatContainer.appendChild(preview); - break; - - case 'agent-result': - const report = renderResearchReport(msg); - chatContainer.appendChild(report); - break; - - case 'research-container': - const researchWidget = renderResearchContainer(msg); - chatContainer.appendChild(researchWidget); - break; - - default: - // Legacy fallback: messages without type or with parts array - if (msg.parts && msg.parts.length > 0) { - // Old format with parts array - for (const part of msg.parts) { - switch (part.type) { - case 'text': - const oldTextDiv = document.createElement('div'); - oldTextDiv.className = 'message assistant'; - oldTextDiv.innerHTML = `
    ${part.html || parseMarkdown(part.content || '')}
    `; - chatContainer.appendChild(oldTextDiv); - break; - case 'action-widget': - chatContainer.appendChild(renderActionWidget(part)); - break; - case 'code-cell': - chatContainer.appendChild(renderCodeCell(part)); - break; - case 'result-preview': - chatContainer.appendChild(renderResultPreview(part)); - break; - case 'agent-result': - chatContainer.appendChild(renderResearchReport(part)); - break; - } - } - } else if (msg.contentHtml) { - const legacyMsg = document.createElement('div'); - legacyMsg.className = 'message assistant'; - legacyMsg.innerHTML = msg.contentHtml; - chatContainer.appendChild(legacyMsg); - } else if (msg.content) { - const contentMsg = document.createElement('div'); - contentMsg.className = 'message assistant'; - contentMsg.innerHTML = `
    ${parseMarkdown(msg.content)}
    `; - chatContainer.appendChild(contentMsg); - } - break; - } - } else if (msg.role === 'tool' && msg.type === 'tool-response') { - // Restore hidden tool-response element for getConversationHistory() - const trMsg = document.createElement('div'); - trMsg.className = 'message tool'; - trMsg.style.display = 'none'; - trMsg.setAttribute('data-tool-response', JSON.stringify(msg.data)); - chatContainer.appendChild(trMsg); - } - } -} - -// Helper functions to render saved message parts - -function renderActionWidget(data) { - const widget = document.createElement('div'); - widget.className = 'action-widget'; - if (data.targetTabId) { - widget.dataset.targetTabId = data.targetTabId; - } - - const indicatorHtml = data.isDone - ? '
    ' - : '
    '; - - let bodyHtml = ` - -
    ${parseMarkdown(data.query || '')}
    - `; - - // Add result section if present - if (data.resultHtml || data.result) { - bodyHtml += ` -
    - -
    ${data.resultHtml || escapeHtml(data.result || '')}
    -
    - `; - } - - widget.innerHTML = ` -
    -
    -
    - TASK: ${(data.actionType || '').toUpperCase()} -
    - ${indicatorHtml} -
    -
    -
    -
    - ${bodyHtml} -
    - `; - - // Collapse toggle - const collapseToggle = widget.querySelector('.widget-collapse-toggle'); - collapseToggle.addEventListener('click', (e) => { - e.stopPropagation(); - widget.classList.toggle('collapsed'); - collapseToggle.classList.toggle('collapsed'); - }); - - // Make clickable if we have a target tab - if (data.targetTabId) { - const clickableArea = widget.querySelector('.action-widget-clickable'); - clickableArea.addEventListener('click', () => { - const tabId = parseInt(data.targetTabId); - if (!isNaN(tabId)) { - // 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); - } - } - } - }); - } - - return widget; -} - -function renderCodeCell(data) { - const cell = document.createElement('div'); - cell.className = 'code-cell'; - - let outputHtml = ''; - const cleanedData = cleanCodeOutput(data.output); - if (data.outputHtml || cleanedData) { - const errorClass = data.isError ? ' error' : ''; - outputHtml = `
    ${data.outputHtml || escapeHtml(cleanedData)}
    `; - } - - // Build images HTML - let imagesHtml = ''; - if (data.images && data.images.length > 0) { - for (const img of data.images) { - let labelHtml = ''; - if (img.name) { - labelHtml = `
    ${escapeHtml(img.name)}
    `; - } - imagesHtml += `
    ${labelHtml}
    `; - } - } - - cell.innerHTML = ` -
    Code
    -
    -
    ${escapeHtml(data.code || '')}
    -
    - ${outputHtml} - ${imagesHtml} - `; - cell.querySelector('.code-cell-label').addEventListener('click', () => { - cell.classList.toggle('collapsed'); - cell.querySelector('.widget-collapse-toggle').classList.toggle('collapsed'); - }); - - return cell; -} - -function renderResultPreview(data) { - const preview = document.createElement('div'); - preview.className = 'result-preview'; - - preview.innerHTML = ` -
    Result
    -
    ${data.html || escapeHtml(data.content || '')}
    - `; - linkifyFilePaths(preview); - - return preview; -} - -function renderResearchReport(data) { - const report = document.createElement('div'); - report.className = 'agent-result'; - - report.innerHTML = ` -
    Result
    -
    ${data.html || parseMarkdown(data.content || '')}
    - `; - linkifyFilePaths(report); - - return report; -} - -function renderResearchContainer(data) { - const container = document.createElement('div'); - container.className = 'research-container message assistant'; - - container.innerHTML = ` -
    - RESEARCH -
    -
    ${data.html || ''}
    - `; - - return container; -} - -function serializeWorkspace() { - const workspace = { - version: 1, - tabCounter: tabCounter, - activeTabId: activeTabId, - agentCounters: agentCounters, - tabs: [], - timelineData: serializeTimelineData(), - debugHistory: debugHistory - }; - - // Serialize command center (tab 0) - workspace.tabs.push(serializeTab(0, 'command-center')); - - // Serialize all other tabs - const tabElements = document.querySelectorAll('#dynamicTabs .tab'); - for (const tabEl of tabElements) { - const tabId = parseInt(tabEl.dataset.tabId); - const content = document.querySelector(`[data-content-id="${tabId}"]`); - if (content) { - const chatContainer = content.querySelector('.chat-container'); - const agentType = chatContainer?.dataset.agentType || 'chat'; - workspace.tabs.push(serializeTab(tabId, agentType)); - } - } - - return workspace; -} - -function serializeTimelineData() { - const serialized = {}; - for (const [tabId, data] of Object.entries(timelineData)) { - // Clone but strip savedContent (large HTML, not needed for timeline) - serialized[tabId] = { - type: data.type, - title: data.title, - events: data.events, - parentTabId: data.parentTabId, - isGenerating: false, - isClosed: data.isClosed || false - }; - } - return serialized; -} - -function serializeTab(tabId, type) { - const tabEl = document.querySelector(`[data-tab-id="${tabId}"]`); - const content = document.querySelector(`[data-content-id="${tabId}"]`); - - const tabData = { - id: tabId, - type: type, - title: tabEl?.querySelector('.tab-title')?.textContent || getTypeLabel(type) || 'TAB', - messages: [] - }; - - if (!content) return tabData; - - const chatContainer = content.querySelector('.chat-container'); - if (!chatContainer) return tabData; - - // Iterate over ALL direct children of chatContainer in order - // Elements can be: .message.user, .message.assistant, .action-widget, .code-cell, .agent-result, .system-message, .welcome-message - for (const child of chatContainer.children) { - // Skip welcome message and system messages - if (child.classList.contains('welcome-message') || child.classList.contains('system-message')) { - continue; - } - - // User message - if (child.classList.contains('message') && child.classList.contains('user')) { - tabData.messages.push({ - role: 'user', - content: child.textContent.trim() - }); - continue; - } - - // Research container (the search tree widget) - check BEFORE generic assistant message - // because research-container also has .message.assistant classes - if (child.classList.contains('research-container')) { - const bodyEl = child.querySelector('.research-body'); - tabData.messages.push({ - role: 'assistant', - type: 'research-container', - html: bodyEl?.innerHTML || '' - }); - continue; - } - - // Tool-call message (hidden, used for LLM conversation history) - if (child.classList.contains('message') && child.hasAttribute('data-tool-call')) { - try { - tabData.messages.push({ - role: 'assistant', - type: 'tool-call', - data: JSON.parse(child.getAttribute('data-tool-call')) - }); - } catch (e) { /* skip unparseable */ } - continue; - } - - // Tool-response message (hidden, used for LLM conversation history) - if (child.classList.contains('message') && (child.hasAttribute('data-tool-response') || child.classList.contains('tool'))) { - try { - tabData.messages.push({ - role: 'tool', - type: 'tool-response', - data: JSON.parse(child.getAttribute('data-tool-response')) - }); - } catch (e) { /* skip unparseable */ } - continue; - } - - // Assistant message (with .message-content inside) - if (child.classList.contains('message') && child.classList.contains('assistant')) { - const messageContent = child.querySelector('.message-content'); - if (messageContent) { - tabData.messages.push({ - role: 'assistant', - type: 'text', - content: messageContent.textContent.trim(), - html: messageContent.innerHTML - }); - } else { - // Fallback to raw HTML - tabData.messages.push({ - role: 'assistant', - type: 'text', - html: child.innerHTML - }); - } - continue; - } - - // Action widget (appended directly to chatContainer) - if (child.classList.contains('action-widget')) { - const widgetData = { - role: 'assistant', - type: 'action-widget', - targetTabId: child.dataset.targetTabId, - actionType: child.querySelector('.action-widget-type')?.textContent?.replace('TASK: ', '') || '', - query: child.querySelector('.action-widget-body .section-content')?.textContent?.trim() || '', - isDone: !!child.querySelector('.done-indicator'), - result: null, - resultHtml: null - }; - - // Extract result if present - const resultSection = child.querySelector('.action-widget-result-section'); - if (resultSection) { - const resultContent = resultSection.querySelector('.section-content'); - if (resultContent) { - widgetData.result = resultContent.textContent.trim(); - widgetData.resultHtml = resultContent.innerHTML; - } - } - - tabData.messages.push(widgetData); - continue; - } - - // Code cell (appended directly to chatContainer) - if (child.classList.contains('code-cell')) { - const codeEl = child.querySelector('pre code'); - const outputEl = child.querySelector('.code-cell-output'); - - // Collect images from the code cell - const images = []; - const imageEls = child.querySelectorAll('.code-cell-image'); - for (const imgDiv of imageEls) { - const imgEl = imgDiv.querySelector('img'); - const labelEl = imgDiv.querySelector('.figure-label'); - if (imgEl) { - images.push({ - src: imgEl.src, // data URL with base64 - name: labelEl?.textContent || '' - }); - } - } - - tabData.messages.push({ - role: 'assistant', - type: 'code-cell', - code: codeEl?.textContent || '', - output: outputEl?.textContent || '', - outputHtml: outputEl?.innerHTML || '', - isError: outputEl?.classList.contains('error') || false, - images: images - }); - continue; - } - - // Tool cell (web agent / image agent tool calls) - if (child.classList.contains('tool-cell')) { - const labelEl = child.querySelector('.tool-cell-label span'); - const inputEl = child.querySelector('.tool-cell-input'); - const outputEl = child.querySelector('.tool-cell-output'); - - tabData.messages.push({ - role: 'assistant', - type: 'tool-cell', - toolName: child.getAttribute('data-tool-name') || '', - label: labelEl?.textContent || '', - input: inputEl?.textContent || '', - outputHtml: outputEl?.innerHTML || '', - }); - continue; - } - - // Research report (appended directly to chatContainer) - if (child.classList.contains('agent-result')) { - const contentEl = child.querySelector('.result-content'); - tabData.messages.push({ - role: 'assistant', - type: 'agent-result', - content: contentEl?.textContent || '', - html: contentEl?.innerHTML || '' - }); - continue; - } - - // Result preview - if (child.classList.contains('result-preview')) { - const contentEl = child.querySelector('.result-preview-content'); - tabData.messages.push({ - role: 'assistant', - type: 'result-preview', - content: contentEl?.textContent || '', - html: contentEl?.innerHTML || '' - }); - continue; - } - } - - return tabData; -} - -function saveWorkspaceDebounced() { - // Clear any pending save - if (saveWorkspaceTimer) { - clearTimeout(saveWorkspaceTimer); - } - - // Schedule save in 500ms - saveWorkspaceTimer = setTimeout(async () => { - try { - const workspace = serializeWorkspace(); - const response = await apiFetch('/api/workspace', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(workspace) - }); - if (response.ok) { - console.log('Workspace saved'); - } - } catch (e) { - console.error('Failed to save workspace:', e); - } - }, 500); -} - -// ============================================ -// Settings management -// ============================================ - -// Migrate old settings format (v1) to new format (v2) -function migrateSettings(oldSettings) { - // Already migrated or new format - if (oldSettings.settingsVersion >= 2) { - return oldSettings; - } - - console.log('Migrating settings from v1 to v2...'); - - const newSettings = { - providers: {}, - models: {}, - agents: { - command: '', - agent: '', - code: '', - research: '', - chat: '' - }, - e2bKey: oldSettings.e2bKey || '', - serperKey: oldSettings.serperKey || '', - hfToken: oldSettings.hfToken || '', - imageGenModel: oldSettings.imageGenModel || '', - imageEditModel: oldSettings.imageEditModel || '', - researchSubAgentModel: oldSettings.researchSubAgentModel || '', - researchParallelWorkers: oldSettings.researchParallelWorkers || null, - researchMaxWebsites: oldSettings.researchMaxWebsites || null, - themeColor: oldSettings.themeColor || 'forest', - settingsVersion: 2 - }; - - // Create a default provider from old endpoint/token if they exist - if (oldSettings.endpoint) { - const providerId = 'provider_default'; - newSettings.providers[providerId] = { - name: 'Default', - endpoint: oldSettings.endpoint, - token: oldSettings.token || '' - }; - - // Create a default model if old model exists - if (oldSettings.model) { - const modelId = 'model_default'; - newSettings.models[modelId] = { - name: oldSettings.model, - providerId: providerId, - modelId: oldSettings.model - }; - - // Set as default for all agents - newSettings.agents.command = modelId; - newSettings.agents.agent = modelId; - newSettings.agents.code = modelId; - newSettings.agents.research = modelId; - newSettings.agents.chat = modelId; - } - - // Migrate agent-specific models if they existed - const oldModels = oldSettings.models || {}; - const agentTypes = Object.keys(AGENT_REGISTRY).filter(k => AGENT_REGISTRY[k].hasCounter); - agentTypes.forEach(type => { - if (oldModels[type]) { - const specificModelId = `model_${type}`; - newSettings.models[specificModelId] = { - name: `${type.charAt(0).toUpperCase() + type.slice(1)} - ${oldModels[type]}`, - providerId: providerId, - modelId: oldModels[type] - }; - newSettings.agents[type] = specificModelId; - } - }); - } - - console.log('Settings migrated:', newSettings); - return newSettings; -} - -async function loadSettings() { - let loadedSettings = null; - - // Try to load from backend API (file-based) first - try { - const response = await apiFetch('/api/settings'); - if (response.ok) { - loadedSettings = await response.json(); - console.log('Settings loaded from file:', loadedSettings); - } - } catch (e) { - console.log('Could not load settings from backend, falling back to localStorage'); - } - - // Fallback to localStorage if backend is unavailable - if (!loadedSettings) { - const savedSettings = localStorage.getItem('agentui_settings') || localStorage.getItem('productive_settings'); - console.log('Loading settings from localStorage:', savedSettings ? 'found' : 'not found'); - if (savedSettings) { - try { - loadedSettings = JSON.parse(savedSettings); - console.log('Settings loaded from localStorage:', loadedSettings); - } catch (e) { - console.error('Failed to parse settings:', e); - } - } - } - - if (loadedSettings) { - // Migrate old "notebooks" key to "agents" - if (loadedSettings.notebooks && !loadedSettings.agents) { - loadedSettings.agents = loadedSettings.notebooks; - delete loadedSettings.notebooks; - } - // Migrate if needed - if (!loadedSettings.settingsVersion || loadedSettings.settingsVersion < 2) { - loadedSettings = migrateSettings(loadedSettings); - // Save migrated settings - try { - await apiFetch('/api/settings', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(loadedSettings) - }); - console.log('Migrated settings saved to file'); - } catch (e) { - console.log('Could not save migrated settings to file'); - } - } - settings = { ...settings, ...loadedSettings }; - } else { - console.log('Using default settings:', settings); - } -} - -// Generate unique ID for providers/models -function generateId(prefix) { - return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; -} - -// Render providers list in settings -function renderProvidersList() { - const container = document.getElementById('providers-list'); - if (!container) return; - - const providers = settings.providers || {}; - let html = ''; - - Object.entries(providers).forEach(([id, provider]) => { - html += ` -
    -
    - ${escapeHtml(provider.name)} - ${escapeHtml(provider.endpoint)} -
    -
    - - -
    -
    - `; - }); - - if (Object.keys(providers).length === 0) { - html = '
    No providers configured. Add one to get started.
    '; - } - - container.innerHTML = html; -} - -// Render models list in settings -function renderModelsList() { - const container = document.getElementById('models-list'); - if (!container) return; - - const models = settings.models || {}; - const providers = settings.providers || {}; - let html = ''; - - Object.entries(models).forEach(([id, model]) => { - const provider = providers[model.providerId]; - const providerName = provider ? provider.name : 'Unknown'; - html += ` -
    -
    - ${escapeHtml(model.name)} - ${escapeHtml(model.modelId)} @ ${escapeHtml(providerName)} -
    -
    - - -
    -
    - `; - }); - - if (Object.keys(models).length === 0) { - html = '
    No models configured. Add a provider first, then add models.
    '; - } - - container.innerHTML = html; -} - -// Populate model dropdowns for agent selection -function populateModelDropdowns() { - const models = settings.models || {}; - const agents = settings.agents || {}; - - // Build dropdown IDs from registry + special dropdowns - const dropdownIds = [ - ...Object.keys(AGENT_REGISTRY).map(t => `setting-agent-${t}`), - 'setting-research-sub-agent-model', - 'setting-image-gen-model', - 'setting-image-edit-model' - ]; - - dropdownIds.forEach(dropdownId => { - const dropdown = document.getElementById(dropdownId); - if (!dropdown) return; - - // Preserve current selection - const currentValue = dropdown.value; - - // Clear and rebuild options - dropdown.innerHTML = ''; - - Object.entries(models).forEach(([id, model]) => { - const option = document.createElement('option'); - option.value = id; - option.textContent = `${model.name} (${model.modelId})`; - dropdown.appendChild(option); - }); - - // Restore selection - if (currentValue && models[currentValue]) { - dropdown.value = currentValue; - } - }); - - // Set values from settings (driven by registry) - for (const type of Object.keys(AGENT_REGISTRY)) { - const dropdown = document.getElementById(`setting-agent-${type}`); - if (dropdown) dropdown.value = agents[type] || ''; - } - const subAgentDropdown = document.getElementById('setting-research-sub-agent-model'); - if (subAgentDropdown) subAgentDropdown.value = settings.researchSubAgentModel || ''; - const imageGenDropdown = document.getElementById('setting-image-gen-model'); - if (imageGenDropdown) imageGenDropdown.value = settings.imageGenModel || ''; - const imageEditDropdown = document.getElementById('setting-image-edit-model'); - if (imageEditDropdown) imageEditDropdown.value = settings.imageEditModel || ''; -} - -// Show add/edit provider dialog -function showProviderDialog(providerId = null) { - const isEdit = !!providerId; - const provider = isEdit ? settings.providers[providerId] : { name: '', endpoint: '', token: '' }; - - const dialog = document.getElementById('provider-dialog'); - const title = document.getElementById('provider-dialog-title'); - const nameInput = document.getElementById('provider-name'); - const endpointInput = document.getElementById('provider-endpoint'); - const tokenInput = document.getElementById('provider-token'); - - title.textContent = isEdit ? 'Edit Provider' : 'Add Provider'; - nameInput.value = provider.name; - endpointInput.value = provider.endpoint; - tokenInput.value = provider.token; - - dialog.dataset.providerId = providerId || ''; - dialog.classList.add('active'); -} - -// Hide provider dialog -function hideProviderDialog() { - const dialog = document.getElementById('provider-dialog'); - dialog.classList.remove('active'); -} - -// Save provider from dialog -function saveProviderFromDialog() { - const dialog = document.getElementById('provider-dialog'); - const providerId = dialog.dataset.providerId || generateId('provider'); - const name = document.getElementById('provider-name').value.trim(); - const endpoint = document.getElementById('provider-endpoint').value.trim(); - const token = document.getElementById('provider-token').value.trim(); - - if (!name || !endpoint) { - alert('Provider name and endpoint are required'); - return; - } - - settings.providers[providerId] = { name, endpoint, token }; - hideProviderDialog(); - renderProvidersList(); - populateModelDropdowns(); -} - -// Edit provider -function editProvider(providerId) { - showProviderDialog(providerId); -} - -// Delete provider -function deleteProvider(providerId) { - // Check if any models use this provider - const modelsUsingProvider = Object.entries(settings.models || {}) - .filter(([_, model]) => model.providerId === providerId); - - if (modelsUsingProvider.length > 0) { - alert(`Cannot delete provider. ${modelsUsingProvider.length} model(s) are using it.`); - return; - } - - if (confirm('Delete this provider?')) { - delete settings.providers[providerId]; - renderProvidersList(); - } -} - -// Show add/edit model dialog -function showModelDialog(modelId = null) { - const isEdit = !!modelId; - const model = isEdit ? settings.models[modelId] : { name: '', providerId: '', modelId: '', extraParams: null, multimodal: false }; - - const dialog = document.getElementById('model-dialog'); - const title = document.getElementById('model-dialog-title'); - const nameInput = document.getElementById('model-name'); - const providerSelect = document.getElementById('model-provider'); - const modelIdInput = document.getElementById('model-model-id'); - const extraParamsInput = document.getElementById('model-extra-params'); - const multimodalCheckbox = document.getElementById('model-multimodal'); - - title.textContent = isEdit ? 'Edit Model' : 'Add Model'; - nameInput.value = model.name; - modelIdInput.value = model.modelId; - extraParamsInput.value = model.extraParams ? JSON.stringify(model.extraParams, null, 2) : ''; - multimodalCheckbox.checked = !!model.multimodal; - - // Populate provider dropdown - providerSelect.innerHTML = ''; - Object.entries(settings.providers || {}).forEach(([id, provider]) => { - const option = document.createElement('option'); - option.value = id; - option.textContent = provider.name; - if (id === model.providerId) option.selected = true; - providerSelect.appendChild(option); - }); - - dialog.dataset.modelId = modelId || ''; - dialog.classList.add('active'); -} - -// Hide model dialog -function hideModelDialog() { - const dialog = document.getElementById('model-dialog'); - dialog.classList.remove('active'); -} - -// Save model from dialog -function saveModelFromDialog() { - const dialog = document.getElementById('model-dialog'); - const modelId = dialog.dataset.modelId || generateId('model'); - const name = document.getElementById('model-name').value.trim(); - const providerId = document.getElementById('model-provider').value; - const apiModelId = document.getElementById('model-model-id').value.trim(); - const extraParamsStr = document.getElementById('model-extra-params').value.trim(); - - if (!name || !providerId || !apiModelId) { - alert('Name, provider, and model ID are required'); - return; - } - - // Parse extra params if provided - let extraParams = null; - if (extraParamsStr) { - try { - extraParams = JSON.parse(extraParamsStr); - } catch (e) { - alert('Invalid JSON in extra parameters: ' + e.message); - return; - } - } - - const multimodal = document.getElementById('model-multimodal').checked; - settings.models[modelId] = { name, providerId, modelId: apiModelId, extraParams, multimodal }; - hideModelDialog(); - renderModelsList(); - populateModelDropdowns(); -} - -// Edit model -function editModel(modelId) { - showModelDialog(modelId); -} - -// Delete model -function deleteModel(modelId) { - // Check if any agents use this model - const agentsUsingModel = Object.entries(settings.agents || {}) - .filter(([_, mid]) => mid === modelId); - - if (agentsUsingModel.length > 0) { - const warning = `This model is used by: ${agentsUsingModel.map(([t]) => t).join(', ')}. Delete anyway?`; - if (!confirm(warning)) return; - - // Clear the agent assignments - agentsUsingModel.forEach(([type]) => { - settings.agents[type] = ''; - }); - } else if (!confirm('Delete this model?')) { - return; - } - - delete settings.models[modelId]; - renderModelsList(); - populateModelDropdowns(); -} - -function openSettings() { - // Show settings file path - const pathEl = document.getElementById('settingsPath'); - if (pathEl) pathEl.textContent = settings._settingsPath || ''; - - // Render providers and models lists - renderProvidersList(); - renderModelsList(); - populateModelDropdowns(); - - // Populate service keys - document.getElementById('setting-e2b-key').value = settings.e2bKey || ''; - document.getElementById('setting-serper-key').value = settings.serperKey || ''; - document.getElementById('setting-hf-token').value = settings.hfToken || ''; - - // Populate research settings - document.getElementById('setting-research-parallel-workers').value = settings.researchParallelWorkers || ''; - document.getElementById('setting-research-max-websites').value = settings.researchMaxWebsites || ''; - - // Set theme color - const themeColor = settings.themeColor || 'forest'; - document.getElementById('setting-theme-color').value = themeColor; - - // Update selected theme in picker - const themePicker = document.getElementById('theme-color-picker'); - if (themePicker) { - themePicker.querySelectorAll('.theme-option').forEach(opt => { - opt.classList.remove('selected'); - if (opt.dataset.theme === themeColor) { - opt.classList.add('selected'); - } - }); - } - - // Clear any status message - const status = document.getElementById('settingsStatus'); - status.className = 'settings-status'; - status.textContent = ''; -} - -async function saveSettings() { - // Get agent model selections from dropdowns (driven by registry) - const agentModels = {}; - for (const type of Object.keys(AGENT_REGISTRY)) { - agentModels[type] = document.getElementById(`setting-agent-${type}`)?.value || ''; - } - const researchSubAgentModel = document.getElementById('setting-research-sub-agent-model')?.value || ''; - - // Get other settings - const e2bKey = document.getElementById('setting-e2b-key').value.trim(); - const serperKey = document.getElementById('setting-serper-key').value.trim(); - const hfToken = document.getElementById('setting-hf-token').value.trim(); - const imageGenModel = document.getElementById('setting-image-gen-model')?.value || ''; - const imageEditModel = document.getElementById('setting-image-edit-model')?.value || ''; - const researchParallelWorkers = document.getElementById('setting-research-parallel-workers').value.trim(); - const researchMaxWebsites = document.getElementById('setting-research-max-websites').value.trim(); - const themeColor = document.getElementById('setting-theme-color').value || 'forest'; - - // Validate: at least one provider and one model should exist - if (Object.keys(settings.providers || {}).length === 0) { - showSettingsStatus('Please add at least one provider', 'error'); - return; - } - - if (Object.keys(settings.models || {}).length === 0) { - showSettingsStatus('Please add at least one model', 'error'); - return; - } - - // Update settings - settings.agents = agentModels; - settings.e2bKey = e2bKey; - settings.serperKey = serperKey; - settings.hfToken = hfToken; - settings.imageGenModel = imageGenModel; - settings.imageEditModel = imageEditModel; - settings.researchSubAgentModel = researchSubAgentModel; - settings.researchParallelWorkers = researchParallelWorkers ? parseInt(researchParallelWorkers) : null; - settings.researchMaxWebsites = researchMaxWebsites ? parseInt(researchMaxWebsites) : null; - settings.themeColor = themeColor; - settings.settingsVersion = 2; - - // Save to backend API (file-based) first - try { - const response = await apiFetch('/api/settings', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(settings) - }); - if (response.ok) { - console.log('Settings saved to file:', settings); - } else { - console.error('Failed to save settings to file, falling back to localStorage'); - localStorage.setItem('agentui_settings', JSON.stringify(settings)); - } - } catch (e) { - console.error('Could not save settings to backend, falling back to localStorage:', e); - localStorage.setItem('agentui_settings', JSON.stringify(settings)); - } - - // Apply theme - applyTheme(themeColor); - - // Show success message - showSettingsStatus('Settings saved successfully', 'success'); - - // Close settings panel and go back to command center after a short delay - setTimeout(() => { - const settingsPanel = document.getElementById('settingsPanel'); - const settingsBtn = document.getElementById('settingsBtn'); - const appContainer = document.querySelector('.app-container'); - if (settingsPanel) settingsPanel.classList.remove('active'); - if (settingsBtn) settingsBtn.classList.remove('active'); - if (appContainer) appContainer.classList.remove('panel-open'); - }, 1000); -} - -function showSettingsStatus(message, type) { - const status = document.getElementById('settingsStatus'); - status.textContent = message; - status.className = `settings-status ${type}`; -} - -// Theme colors mapping -// Default light surface colors shared by all light themes -const lightSurface = { - bgPrimary: '#ffffff', - bgSecondary: '#f5f5f5', - bgTertiary: '#fafafa', - bgInput: '#ffffff', - bgHover: '#f0f0f0', - bgCard: '#ffffff', - textPrimary: '#1a1a1a', - textSecondary: '#666666', - textMuted: '#999999', - borderPrimary: '#e0e0e0', - borderSubtle: '#f0f0f0' -}; - -const themeColors = { - forest: { - border: '#1b5e20', bg: '#e8f5e9', hoverBg: '#c8e6c9', - accent: '#1b5e20', accentRgb: '27, 94, 32', - ...lightSurface - }, - sapphire: { - border: '#0d47a1', bg: '#e3f2fd', hoverBg: '#bbdefb', - accent: '#0d47a1', accentRgb: '13, 71, 161', - ...lightSurface - }, - ocean: { - border: '#00796b', bg: '#e0f2f1', hoverBg: '#b2dfdb', - accent: '#004d40', accentRgb: '0, 77, 64', - ...lightSurface - }, - midnight: { - border: '#283593', bg: '#e8eaf6', hoverBg: '#c5cae9', - accent: '#1a237e', accentRgb: '26, 35, 126', - ...lightSurface - }, - steel: { - border: '#455a64', bg: '#eceff1', hoverBg: '#cfd8dc', - accent: '#263238', accentRgb: '38, 50, 56', - ...lightSurface - }, - depths: { - border: '#01579b', bg: '#e3f2fd', hoverBg: '#bbdefb', - accent: '#01579b', accentRgb: '1, 87, 155', - ...lightSurface - }, - ember: { - border: '#b71c1c', bg: '#fbe9e7', hoverBg: '#ffccbc', - accent: '#b71c1c', accentRgb: '183, 28, 28', - ...lightSurface - }, - noir: { - border: '#888888', bg: '#1a1a1a', hoverBg: '#2a2a2a', - accent: '#999999', accentRgb: '153, 153, 153', - bgPrimary: '#111111', - bgSecondary: '#1a1a1a', - bgTertiary: '#0d0d0d', - bgInput: '#0d0d0d', - bgHover: '#2a2a2a', - bgCard: '#1a1a1a', - textPrimary: '#e0e0e0', - textSecondary: '#999999', - textMuted: '#666666', - borderPrimary: '#333333', - borderSubtle: '#222222' - }, - eclipse: { - border: '#5c9eff', bg: '#0d1520', hoverBg: '#162030', - accent: '#5c9eff', accentRgb: '92, 158, 255', - bgPrimary: '#0b1118', - bgSecondary: '#111a25', - bgTertiary: '#080e14', - bgInput: '#080e14', - bgHover: '#1a2840', - bgCard: '#111a25', - textPrimary: '#d0d8e8', - textSecondary: '#7088a8', - textMuted: '#4a6080', - borderPrimary: '#1e2e45', - borderSubtle: '#151f30' - }, - terminal: { - border: '#00cc00', bg: '#0a1a0a', hoverBg: '#0d260d', - accent: '#00cc00', accentRgb: '0, 204, 0', - bgPrimary: '#0a0a0a', - bgSecondary: '#0d1a0d', - bgTertiary: '#050505', - bgInput: '#050505', - bgHover: '#1a3a1a', - bgCard: '#0d1a0d', - textPrimary: '#00cc00', - textSecondary: '#009900', - textMuted: '#007700', - borderPrimary: '#1a3a1a', - borderSubtle: '#0d1a0d' - } -}; - -function applyTheme(themeName) { - const theme = themeColors[themeName] || themeColors.forest; - const root = document.documentElement; - - // Accent colors - root.style.setProperty('--theme-border', theme.border); - root.style.setProperty('--theme-bg', theme.bg); - root.style.setProperty('--theme-hover-bg', theme.hoverBg); - root.style.setProperty('--theme-accent', theme.accent); - root.style.setProperty('--theme-accent-rgb', theme.accentRgb); - - // Surface colors - root.style.setProperty('--bg-primary', theme.bgPrimary); - root.style.setProperty('--bg-secondary', theme.bgSecondary); - root.style.setProperty('--bg-tertiary', theme.bgTertiary); - root.style.setProperty('--bg-input', theme.bgInput); - root.style.setProperty('--bg-hover', theme.bgHover); - root.style.setProperty('--bg-card', theme.bgCard); - root.style.setProperty('--text-primary', theme.textPrimary); - root.style.setProperty('--text-secondary', theme.textSecondary); - root.style.setProperty('--text-muted', theme.textMuted); - root.style.setProperty('--border-primary', theme.borderPrimary); - root.style.setProperty('--border-subtle', theme.borderSubtle); - - // Data attribute for any remaining theme-specific overrides - document.body.setAttribute('data-theme', themeName); -} - -// Export settings for use in API calls -function getSettings() { - return settings; -} - -// Resolve model configuration for an agent type -// Returns { endpoint, token, model, extraParams } or null if not configured -function resolveModelConfig(agentType) { - const modelId = settings.agents?.[agentType]; - if (!modelId) return null; - - const model = settings.models?.[modelId]; - if (!model) return null; - - const provider = settings.providers?.[model.providerId]; - if (!provider) return null; - - return { - endpoint: provider.endpoint, - token: provider.token, - model: model.modelId, - extraParams: model.extraParams || null, - multimodal: !!model.multimodal - }; -} - -// Get first available model config as fallback -function getDefaultModelConfig() { - const modelIds = Object.keys(settings.models || {}); - if (modelIds.length === 0) return null; - - const modelId = modelIds[0]; - const model = settings.models[modelId]; - const provider = settings.providers?.[model.providerId]; - if (!provider) return null; - - return { - endpoint: provider.endpoint, - token: provider.token, - model: model.modelId, - extraParams: model.extraParams || null, - multimodal: !!model.multimodal - }; -} - -// Build frontend context for API requests -function getFrontendContext() { - const currentThemeName = settings.themeColor || 'forest'; - const theme = themeColors[currentThemeName]; - - return { - theme: theme ? { - name: currentThemeName, - accent: theme.accent, - bg: theme.bg, - border: theme.border, - bgPrimary: theme.bgPrimary, - bgSecondary: theme.bgSecondary, - textPrimary: theme.textPrimary, - textSecondary: theme.textSecondary - } : null, - open_agents: getOpenAgentTypes() - }; -} - -// Get list of open agent types -function getOpenAgentTypes() { - const tabs = document.querySelectorAll('.tab[data-tab-id]'); - const types = []; - tabs.forEach(tab => { - const tabId = tab.dataset.tabId; - if (tabId === '0') { - types.push('command'); - } else { - const content = document.querySelector(`[data-content-id="${tabId}"]`); - if (content) { - const chatContainer = content.querySelector('.chat-container'); - if (chatContainer && chatContainer.dataset.agentType) { - types.push(chatContainer.dataset.agentType); - } - } - } - }); - return types; -} - -// Sandbox management for code agents -async function startSandbox(tabId) { - const currentSettings = getSettings(); - const backendEndpoint = '/api'; - - if (!currentSettings.e2bKey) { - console.log('No E2B key configured, skipping sandbox start'); - return; - } - - // Add a status message to the agent - const uniqueId = `code-${tabId}`; - const chatContainer = document.getElementById(`messages-${uniqueId}`); - if (chatContainer) { - const statusMsg = document.createElement('div'); - statusMsg.className = 'system-message'; - statusMsg.innerHTML = '⚙️ Starting sandbox...'; - chatContainer.appendChild(statusMsg); - } - - try { - const response = await apiFetch(`${backendEndpoint}/sandbox/start`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - session_id: tabId.toString(), - e2b_key: currentSettings.e2bKey - }) - }); - - const result = await response.json(); - - // Update status message - if (chatContainer) { - const statusMsg = chatContainer.querySelector('.system-message'); - if (statusMsg) { - if (result.success) { - // Sandbox is ready - hide the message - statusMsg.remove(); - } else { - statusMsg.innerHTML = `⚠ Sandbox error: ${result.error}`; - statusMsg.style.color = '#c62828'; - } - } - } - } catch (error) { - console.error('Failed to start sandbox:', error); - if (chatContainer) { - const statusMsg = chatContainer.querySelector('.system-message'); - if (statusMsg) { - statusMsg.innerHTML = `⚠ Failed to start sandbox: ${error.message}`; - statusMsg.style.color = '#c62828'; - } - } - } -} - -async function stopSandbox(tabId) { - const backendEndpoint = '/api'; - - try { - await apiFetch(`${backendEndpoint}/sandbox/stop`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - session_id: tabId.toString() - }) - }); - } catch (error) { - console.error('Failed to stop sandbox:', error); - } -} - -// Image modal for click-to-zoom -function openImageModal(src) { - // Create modal if it doesn't exist - let modal = document.getElementById('imageModal'); - if (!modal) { - modal = document.createElement('div'); - modal.id = 'imageModal'; - modal.style.cssText = ` - display: none; - position: fixed; - z-index: 10000; - left: 0; - top: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.9); - cursor: pointer; - `; - modal.onclick = function() { - modal.style.display = 'none'; - }; - - const img = document.createElement('img'); - img.id = 'imageModalContent'; - img.style.cssText = ` - margin: auto; - display: block; - max-width: 95%; - max-height: 95%; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - `; - modal.appendChild(img); - document.body.appendChild(modal); - } - - // Show modal with image - const modalImg = document.getElementById('imageModalContent'); - modalImg.src = src; - modal.style.display = 'block'; -} - -// ============= DEBUG PANEL ============= - -const debugPanel = document.getElementById('debugPanel'); -const debugBtn = document.getElementById('debugBtn'); -const debugClose = document.getElementById('debugClose'); -const debugContent = document.getElementById('debugContent'); - -// Toggle debug panel -if (debugBtn) { - debugBtn.addEventListener('click', () => { - const isOpening = !debugPanel.classList.contains('active'); - - // Close other panels if open - settingsPanel.classList.remove('active'); - settingsBtn.classList.remove('active'); - if (filesPanel) filesPanel.classList.remove('active'); - if (filesBtn) filesBtn.classList.remove('active'); - appContainer.classList.remove('files-panel-open'); - const sessionsPanel = document.getElementById('sessionsPanel'); - const sessionsBtn = document.getElementById('sessionsBtn'); - if (sessionsPanel) sessionsPanel.classList.remove('active'); - if (sessionsBtn) sessionsBtn.classList.remove('active'); - appContainer.classList.remove('sessions-panel-open'); - - // Toggle debug panel - debugPanel.classList.toggle('active'); - debugBtn.classList.toggle('active'); - - // Shift content when opening, unshift when closing - if (isOpening) { - appContainer.classList.add('panel-open'); - loadDebugMessages(); - } else { - appContainer.classList.remove('panel-open'); - } - }); -} - -// Close debug panel -if (debugClose) { - debugClose.addEventListener('click', () => { - debugPanel.classList.remove('active'); - debugBtn.classList.remove('active'); - appContainer.classList.remove('panel-open'); - }); -} - - -// Load debug messages from backend -function formatDebugJson(obj) { - /** - * Format an object as HTML-escaped JSON, replacing base64 image data - * with clickable placeholders that show a thumbnail on hover. - */ - // Collect base64 images and replace with placeholders before escaping - const images = []; - const json = JSON.stringify(obj, null, 2); - const placeholder = json.replace( - /"(data:image\/[^;]+;base64,)([A-Za-z0-9+/=\n]{200,})"/g, - (match, prefix, b64) => { - const idx = images.length; - const sizeKB = (b64.length * 0.75 / 1024).toFixed(1); - images.push(prefix + b64); - return `"__DEBUG_IMG_${idx}_${sizeKB}KB__"`; - } - ); - // Now HTML-escape the JSON (placeholders are safe ASCII) - let html = escapeHtml(placeholder); - // Replace placeholders with hoverable image thumbnails - html = html.replace(/__DEBUG_IMG_(\d+)_([\d.]+KB)__/g, (match, idx, size) => { - const src = images[parseInt(idx)]; - return `[image ${size}]`; - }); - return html; -} - -function loadDebugMessages() { - const calls = debugHistory[activeTabId] || []; - - if (calls.length === 0) { - debugContent.innerHTML = '
    No LLM calls recorded yet.

    Send a message in this tab to see the call history here.
    '; - return; - } - - debugContent.innerHTML = calls.map((call, i) => { - const isLast = i === calls.length - 1; - const arrow = isLast ? '▼' : '▶'; - const display = isLast ? 'block' : 'none'; - const msgCount = call.input ? call.input.length : 0; - - const inputHtml = call.input ? formatDebugJson(call.input) : 'No input'; - - let outputHtml; - if (call.error) { - outputHtml = `${escapeHtml(call.error)}`; - } else if (call.output) { - outputHtml = formatDebugJson(call.output); - } else { - outputHtml = 'Pending...'; - } - - return `
    ${arrow}Call #${i + 1}${call.timestamp}
    ${inputHtml}
    ${outputHtml}
    `; - }).join(''); -} - -// Toggle debug call expansion -window.toggleDebugCall = function(index) { - const content = document.getElementById(`call-${index}`); - const arrow = document.getElementById(`arrow-${index}`); - const item = document.getElementById(`callitem-${index}`); - if (content.style.display === 'none') { - content.style.display = 'block'; - arrow.textContent = '▼'; - item.classList.add('expanded'); - } else { - content.style.display = 'none'; - arrow.textContent = '▶'; - item.classList.remove('expanded'); - } -} - -// ============= SETTINGS PANEL ============= - -const settingsPanel = document.getElementById('settingsPanel'); -const settingsPanelBody = document.getElementById('settingsPanelBody'); -const settingsPanelClose = document.getElementById('settingsPanelClose'); -const settingsBtn = document.getElementById('settingsBtn'); -const appContainer = document.querySelector('.app-container'); - - -// Open settings panel when SETTINGS button is clicked -if (settingsBtn) { - settingsBtn.addEventListener('click', () => { - // Close other panels if open - debugPanel.classList.remove('active'); - debugBtn.classList.remove('active'); - if (filesPanel) filesPanel.classList.remove('active'); - if (filesBtn) filesBtn.classList.remove('active'); - appContainer.classList.remove('files-panel-open'); - const sessionsPanel = document.getElementById('sessionsPanel'); - const sessionsBtn = document.getElementById('sessionsBtn'); - if (sessionsPanel) sessionsPanel.classList.remove('active'); - if (sessionsBtn) sessionsBtn.classList.remove('active'); - appContainer.classList.remove('sessions-panel-open'); - - openSettings(); // Populate form fields with current values - settingsPanel.classList.add('active'); - settingsBtn.classList.add('active'); - appContainer.classList.add('panel-open'); - }); -} - -// Close settings panel -if (settingsPanelClose) { - settingsPanelClose.addEventListener('click', () => { - settingsPanel.classList.remove('active'); - settingsBtn.classList.remove('active'); - appContainer.classList.remove('panel-open'); - }); -} - - -// ============= FILES PANEL ============= - -const filesPanel = document.getElementById('filesPanel'); -const filesPanelClose = document.getElementById('filesPanelClose'); -const filesBtn = document.getElementById('filesBtn'); -const fileTree = document.getElementById('fileTree'); -const showHiddenFiles = document.getElementById('showHiddenFiles'); -const filesRefresh = document.getElementById('filesRefresh'); -const filesUpload = document.getElementById('filesUpload'); - -// Track expanded folder paths to preserve state on refresh -let expandedPaths = new Set(); -let filesRoot = ''; - -// Load file tree from API -async function loadFileTree() { - const showHidden = showHiddenFiles?.checked || false; - try { - const response = await apiFetch(`/api/files?show_hidden=${showHidden}`); - if (response.ok) { - const data = await response.json(); - filesRoot = data.root; - renderFileTree(data.tree, fileTree, data.root); - } else { - fileTree.innerHTML = '
    Failed to load files
    '; - } - } catch (e) { - console.error('Failed to load file tree:', e); - fileTree.innerHTML = '
    Failed to load files
    '; - } -} - -// Render file tree recursively -function renderFileTree(tree, container, rootPath) { - container.innerHTML = ''; - const rootWrapper = document.createElement('div'); - rootWrapper.className = 'file-tree-root'; - - // Add header with folder name - const header = document.createElement('div'); - header.className = 'file-tree-header'; - const folderName = rootPath.split('/').pop() || rootPath; - header.textContent = './' + folderName; - rootWrapper.appendChild(header); - - // Container with vertical line - const treeContainer = document.createElement('div'); - treeContainer.className = 'file-tree-container'; - renderTreeItems(tree, treeContainer); - rootWrapper.appendChild(treeContainer); - - container.appendChild(rootWrapper); -} - -function renderTreeItems(tree, container) { - const len = tree.length; - for (let i = 0; i < len; i++) { - const item = tree[i]; - const isLast = (i === len - 1); - - const itemEl = document.createElement('div'); - itemEl.className = `file-tree-item ${item.type}`; - if (isLast) itemEl.classList.add('last'); - itemEl.dataset.path = item.path; - - // Check if this folder was previously expanded - const wasExpanded = expandedPaths.has(item.path); - - // Create the clickable line element - const lineEl = document.createElement('div'); - lineEl.className = 'file-tree-line'; - lineEl.draggable = true; - - // Only folders get an icon (arrow), files get empty icon - const icon = item.type === 'folder' ? (wasExpanded ? '▼' : '▶') : ''; - const actionBtn = item.type === 'file' - ? '' - : ''; - lineEl.innerHTML = ` - ${icon} - ${item.name} - ${actionBtn} - `; - itemEl.appendChild(lineEl); - - // Download button (files) - const downloadBtn = lineEl.querySelector('.file-download-btn'); - if (downloadBtn) { - downloadBtn.addEventListener('click', (e) => { - e.stopPropagation(); - window.open(`/api/files/download?path=${encodeURIComponent(item.path)}${SESSION_ID ? '&session_id=' + encodeURIComponent(SESSION_ID) : ''}`, '_blank'); - }); - } - - // Upload button (folders) - const uploadBtn = lineEl.querySelector('.file-upload-btn'); - if (uploadBtn) { - uploadBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const input = document.createElement('input'); - input.type = 'file'; - input.addEventListener('change', async () => { - if (!input.files.length) return; - const formData = new FormData(); - formData.append('file', input.files[0]); - try { - await apiFetch(`/api/files/upload?folder=${encodeURIComponent(item.path)}`, { - method: 'POST', - body: formData - }); - loadFileTree(); - } catch (err) { - console.error('Upload failed:', err); - } - }); - input.click(); - }); - } - - container.appendChild(itemEl); - - // Handle folder expansion - if (item.type === 'folder' && item.children && item.children.length > 0) { - const childrenContainer = document.createElement('div'); - childrenContainer.className = 'file-tree-children'; - if (wasExpanded) { - childrenContainer.classList.add('expanded'); - itemEl.classList.add('expanded'); - } - renderTreeItems(item.children, childrenContainer); - itemEl.appendChild(childrenContainer); - - // Use click delay to distinguish single vs double click - let clickTimer = null; - lineEl.addEventListener('click', (e) => { - e.stopPropagation(); - if (clickTimer) { - // Double click detected - clear timer and expand/collapse - clearTimeout(clickTimer); - clickTimer = null; - const isExpanded = itemEl.classList.toggle('expanded'); - childrenContainer.classList.toggle('expanded'); - const iconEl = lineEl.querySelector('.file-tree-icon'); - if (iconEl) iconEl.textContent = isExpanded ? '▼' : '▶'; - if (isExpanded) { - expandedPaths.add(item.path); - } else { - expandedPaths.delete(item.path); - } - } else { - // Single click - wait to see if it's a double click - clickTimer = setTimeout(() => { - clickTimer = null; - insertPathIntoInput('./' + item.path); - showClickFeedback(lineEl); - }, 250); - } - }); - } else if (item.type === 'file') { - // Single click on file inserts path - lineEl.addEventListener('click', (e) => { - e.stopPropagation(); - insertPathIntoInput('./' + item.path); - showClickFeedback(lineEl); - }); - } - - // Drag start handler for future drag-and-drop - lineEl.addEventListener('dragstart', (e) => { - e.dataTransfer.setData('text/plain', './' + item.path); - e.dataTransfer.setData('application/x-file-path', './' + item.path); - e.dataTransfer.effectAllowed = 'copy'; - }); - } -} - -// Helper to insert path into active input -function insertPathIntoInput(path) { - const inputId = activeTabId === 0 ? 'input-command' : `input-${activeTabId}`; - const inputEl = document.getElementById(inputId); - if (inputEl) { - const start = inputEl.selectionStart; - const end = inputEl.selectionEnd; - const text = inputEl.value; - // Wrap path in backticks and add trailing space - const formattedPath = '`' + path + '` '; - inputEl.value = text.substring(0, start) + formattedPath + text.substring(end); - inputEl.focus(); - inputEl.selectionStart = inputEl.selectionEnd = start + formattedPath.length; - } -} - -// Linkify inline code elements that match existing file paths -async function linkifyFilePaths(container) { - // Find all inline elements (not inside
    )
    -    const codeEls = [...container.querySelectorAll('code')].filter(c => !c.closest('pre'));
    -    if (codeEls.length === 0) return;
    -
    -    // Collect candidate paths (must look like a file path)
    -    const candidates = new Map(); // normalized path -> code element(s)
    -    for (const code of codeEls) {
    -        const text = code.textContent.trim();
    -        if (!text || text.includes(' ') || text.length > 200) continue;
    -        // Must contain a dot (extension) or slash (directory)
    -        if (!text.includes('.') && !text.includes('/')) continue;
    -        const normalized = text.replace(/^\.\//, '');
    -        if (!candidates.has(normalized)) candidates.set(normalized, []);
    -        candidates.get(normalized).push(code);
    -    }
    -    if (candidates.size === 0) return;
    -
    -    // Check which paths exist on the server
    -    try {
    -        const resp = await apiFetch('/api/files/check', {
    -            method: 'POST',
    -            headers: { 'Content-Type': 'application/json' },
    -            body: JSON.stringify({ paths: [...candidates.keys()] })
    -        });
    -        if (!resp.ok) return;
    -        const { existing } = await resp.json();
    -
    -        for (const path of existing) {
    -            for (const code of candidates.get(path) || []) {
    -                if (code.closest('.file-path-link')) continue; // already linked
    -                const link = document.createElement('a');
    -                link.className = 'file-path-link';
    -                link.href = '#';
    -                link.title = 'Open in file explorer';
    -                link.addEventListener('click', (e) => {
    -                    e.preventDefault();
    -                    navigateToFileInExplorer(path);
    -                });
    -                code.parentNode.insertBefore(link, code);
    -                link.appendChild(code);
    -            }
    -        }
    -    } catch (e) {
    -        // Silently fail — linkification is a nice-to-have
    -    }
    -}
    -
    -// Helper to show click feedback
    -function showClickFeedback(el) {
    -    const originalColor = el.style.color;
    -    el.style.color = 'var(--theme-accent)';
    -    setTimeout(() => {
    -        el.style.color = originalColor;
    -    }, 300);
    -}
    -
    -// Navigate to a file in the file explorer and highlight it
    -function navigateToFileInExplorer(path) {
    -    let relPath = path.replace(/^\.\//, '');
    -
    -    // Open files panel if not already open
    -    if (!filesPanel.classList.contains('active')) {
    -        filesBtn.click();
    -    }
    -
    -    // Wait for tree to render, then expand parents and highlight
    -    setTimeout(() => {
    -        const segments = relPath.split('/');
    -        let currentPath = '';
    -        for (let i = 0; i < segments.length - 1; i++) {
    -            currentPath += (i > 0 ? '/' : '') + segments[i];
    -            const folderItem = fileTree.querySelector(`.file-tree-item[data-path="${currentPath}"]`);
    -            if (folderItem && !folderItem.classList.contains('expanded')) {
    -                folderItem.classList.add('expanded');
    -                const children = folderItem.querySelector('.file-tree-children');
    -                if (children) children.classList.add('expanded');
    -                const icon = folderItem.querySelector('.file-tree-icon');
    -                if (icon) icon.textContent = '▼';
    -                expandedPaths.add(currentPath);
    -            }
    -        }
    -        const targetItem = fileTree.querySelector(`.file-tree-item[data-path="${relPath}"]`);
    -        if (targetItem) {
    -            targetItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
    -            const line = targetItem.querySelector('.file-tree-line');
    -            if (line) {
    -                line.classList.add('file-tree-highlight');
    -                setTimeout(() => line.classList.remove('file-tree-highlight'), 2000);
    -            }
    -        }
    -    }, 500);
    -}
    -
    -// Open files panel when FILES button is clicked
    -if (filesBtn) {
    -    filesBtn.addEventListener('click', () => {
    -        const isOpening = !filesPanel.classList.contains('active');
    -
    -        // Close other panels first
    -        settingsPanel.classList.remove('active');
    -        settingsBtn.classList.remove('active');
    -        debugPanel.classList.remove('active');
    -        debugBtn.classList.remove('active');
    -        appContainer.classList.remove('panel-open');
    -        const sessionsPanel = document.getElementById('sessionsPanel');
    -        const sessionsBtn = document.getElementById('sessionsBtn');
    -        if (sessionsPanel) sessionsPanel.classList.remove('active');
    -        if (sessionsBtn) sessionsBtn.classList.remove('active');
    -        appContainer.classList.remove('sessions-panel-open');
    -
    -        // Toggle files panel
    -        filesPanel.classList.toggle('active');
    -        filesBtn.classList.toggle('active');
    -
    -        // Shift content when opening, unshift when closing
    -        if (isOpening) {
    -            appContainer.classList.add('files-panel-open');
    -            loadFileTree();
    -        } else {
    -            appContainer.classList.remove('files-panel-open');
    -        }
    -    });
    -}
    -
    -// Close files panel
    -if (filesPanelClose) {
    -    filesPanelClose.addEventListener('click', () => {
    -        filesPanel.classList.remove('active');
    -        filesBtn.classList.remove('active');
    -        appContainer.classList.remove('files-panel-open');
    -    });
    -}
    -
    -// Refresh button
    -if (filesRefresh) {
    -    filesRefresh.addEventListener('click', () => {
    -        loadFileTree();
    -    });
    -}
    -
    -// Upload to root directory
    -if (filesUpload) {
    -    filesUpload.addEventListener('click', () => {
    -        const input = document.createElement('input');
    -        input.type = 'file';
    -        input.addEventListener('change', async () => {
    -            if (!input.files.length) return;
    -            const formData = new FormData();
    -            formData.append('file', input.files[0]);
    -            try {
    -                await apiFetch('/api/files/upload?folder=', { method: 'POST', body: formData });
    -                loadFileTree();
    -            } catch (err) {
    -                console.error('Upload failed:', err);
    -            }
    -        });
    -        input.click();
    -    });
    -}
    -
    -// Show hidden files toggle
    -if (showHiddenFiles) {
    -    showHiddenFiles.addEventListener('change', () => {
    -        loadFileTree();
    -    });
    -}
    -
    -// Drag & drop upload on files panel
    -if (fileTree) {
    -    let dragOverFolder = null;
    -
    -    fileTree.addEventListener('dragover', (e) => {
    -        // Only handle external file drops (not internal path drags)
    -        if (!e.dataTransfer.types.includes('Files')) return;
    -        e.preventDefault();
    -        e.dataTransfer.dropEffect = 'copy';
    -
    -        // Find folder under cursor
    -        const folderItem = e.target.closest('.file-tree-item.folder');
    -        if (folderItem) {
    -            if (dragOverFolder !== folderItem) {
    -                if (dragOverFolder) dragOverFolder.classList.remove('drag-over');
    -                fileTree.classList.remove('drag-over-root');
    -                folderItem.classList.add('drag-over');
    -                dragOverFolder = folderItem;
    -            }
    -        } else {
    -            if (dragOverFolder) { dragOverFolder.classList.remove('drag-over'); dragOverFolder = null; }
    -            fileTree.classList.add('drag-over-root');
    -        }
    -    });
    -
    -    fileTree.addEventListener('dragleave', (e) => {
    -        // Only clear when leaving the fileTree entirely
    -        if (!fileTree.contains(e.relatedTarget)) {
    -            if (dragOverFolder) { dragOverFolder.classList.remove('drag-over'); dragOverFolder = null; }
    -            fileTree.classList.remove('drag-over-root');
    -        }
    -    });
    -
    -    fileTree.addEventListener('drop', async (e) => {
    -        if (!e.dataTransfer.files.length) return;
    -        e.preventDefault();
    -
    -        // Determine target folder
    -        const folderItem = e.target.closest('.file-tree-item.folder');
    -        const folder = folderItem ? folderItem.dataset.path : '';
    -
    -        // Clear highlights
    -        if (dragOverFolder) { dragOverFolder.classList.remove('drag-over'); dragOverFolder = null; }
    -        fileTree.classList.remove('drag-over-root');
    -
    -        // Upload all files
    -        for (const file of e.dataTransfer.files) {
    -            const formData = new FormData();
    -            formData.append('file', file);
    -            try {
    -                await apiFetch(`/api/files/upload?folder=${encodeURIComponent(folder)}`, { method: 'POST', body: formData });
    -            } catch (err) {
    -                console.error('Upload failed:', err);
    -            }
    -        }
    -        loadFileTree();
    -    });
    -}
    -
    -// Sessions panel (same pattern as Files/Settings/Debug panels)
    -const sessionsPanel = document.getElementById('sessionsPanel');
    -const sessionsPanelClose = document.getElementById('sessionsPanelClose');
    -const sessionsBtn = document.getElementById('sessionsBtn');
    -
    -if (sessionsBtn && sessionsPanel) {
    -    sessionsBtn.addEventListener('click', () => {
    -        const isOpening = !sessionsPanel.classList.contains('active');
    -
    -        // Close other panels first
    -        settingsPanel.classList.remove('active');
    -        settingsBtn.classList.remove('active');
    -        debugPanel.classList.remove('active');
    -        debugBtn.classList.remove('active');
    -        filesPanel.classList.remove('active');
    -        if (filesBtn) filesBtn.classList.remove('active');
    -        appContainer.classList.remove('panel-open');
    -        appContainer.classList.remove('files-panel-open');
    -
    -        // Toggle sessions panel
    -        sessionsPanel.classList.toggle('active');
    -        sessionsBtn.classList.toggle('active');
    -
    -        // Shift content when opening, unshift when closing
    -        if (isOpening) {
    -            appContainer.classList.add('sessions-panel-open');
    -            refreshSessionsList();
    -        } else {
    -            appContainer.classList.remove('sessions-panel-open');
    -        }
    -    });
    -}
    -
    -if (sessionsPanelClose) {
    -    sessionsPanelClose.addEventListener('click', () => {
    -        sessionsPanel.classList.remove('active');
    -        sessionsBtn.classList.remove('active');
    -        appContainer.classList.remove('sessions-panel-open');
    -    });
    -}
    -