// ============================================ // 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 all panels first, then toggle debug closeAllPanels(); if (isOpening) { debugPanel.classList.add('active'); debugBtn.classList.add('active'); appContainer.classList.add('panel-open'); loadDebugMessages(); } }); } // 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', () => { closeAllPanels(); openSettings(); 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');
        closeAllPanels();

        if (isOpening) {
            filesPanel.classList.add('active');
            filesBtn.classList.add('active');
            appContainer.classList.add('files-panel-open');
            loadFileTree();
        }
    });
}

// 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');
        closeAllPanels();

        if (isOpening) {
            sessionsPanel.classList.add('active');
            sessionsBtn.classList.add('active');
            appContainer.classList.add('sessions-panel-open');
            refreshSessionsList();
        }
    });
}

if (sessionsPanelClose) {
    sessionsPanelClose.addEventListener('click', () => {
        sessionsPanel.classList.remove('active');
        sessionsBtn.classList.remove('active');
        appContainer.classList.remove('sessions-panel-open');
    });
}