Spaces:
Running
Running
| // ============================================ | |
| // 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 += ` | |
| <div class="provider-item" data-provider-id="${id}"> | |
| <div class="provider-info"> | |
| <span class="provider-name">${escapeHtml(provider.name)}</span> | |
| <span class="provider-endpoint">${escapeHtml(provider.endpoint)}</span> | |
| </div> | |
| <div class="provider-actions"> | |
| <button class="provider-edit-btn" onclick="editProvider('${id}')" title="Edit">✎</button> | |
| <button class="provider-delete-btn" onclick="deleteProvider('${id}')" title="Delete">×</button> | |
| </div> | |
| </div> | |
| `; | |
| }); | |
| if (Object.keys(providers).length === 0) { | |
| html = '<div class="empty-list">No providers configured. Add one to get started.</div>'; | |
| } | |
| 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 += ` | |
| <div class="model-item" data-model-id="${id}"> | |
| <div class="model-info"> | |
| <span class="model-name">${escapeHtml(model.name)}</span> | |
| <span class="model-details">${escapeHtml(model.modelId)} @ ${escapeHtml(providerName)}</span> | |
| </div> | |
| <div class="model-actions"> | |
| <button class="model-edit-btn" onclick="editModel('${id}')" title="Edit">✎</button> | |
| <button class="model-delete-btn" onclick="deleteModel('${id}')" title="Delete">×</button> | |
| </div> | |
| </div> | |
| `; | |
| }); | |
| if (Object.keys(models).length === 0) { | |
| html = '<div class="empty-list">No models configured. Add a provider first, then add models.</div>'; | |
| } | |
| 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 = '<option value="">-- Select Model --</option>'; | |
| 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 = '<option value="">-- Select Provider --</option>'; | |
| 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 = '<em>⚙️ Starting sandbox...</em>'; | |
| 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 = `<em>⚠ Sandbox error: ${result.error}</em>`; | |
| 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 = `<em>⚠ Failed to start sandbox: ${error.message}</em>`; | |
| 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 `<span class="debug-image-placeholder" onmouseenter="this.querySelector('.debug-image-tooltip').style.display='block'" onmouseleave="this.querySelector('.debug-image-tooltip').style.display='none'">[image ${size}]<span class="debug-image-tooltip"><img src="${src}"></span></span>`; | |
| }); | |
| return html; | |
| } | |
| function loadDebugMessages() { | |
| const calls = debugHistory[activeTabId] || []; | |
| if (calls.length === 0) { | |
| debugContent.innerHTML = '<div style="padding: 10px; color: var(--text-secondary);">No LLM calls recorded yet.<br><br>Send a message in this tab to see the call history here.</div>'; | |
| 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) : '<em>No input</em>'; | |
| let outputHtml; | |
| if (call.error) { | |
| outputHtml = `<span style="color: #d32f2f;">${escapeHtml(call.error)}</span>`; | |
| } else if (call.output) { | |
| outputHtml = formatDebugJson(call.output); | |
| } else { | |
| outputHtml = '<em>Pending...</em>'; | |
| } | |
| return `<div class="debug-call-item${isLast ? ' expanded' : ''}" id="callitem-${i}"><div class="debug-call-header" onclick="toggleDebugCall(${i})"><span class="debug-call-arrow" id="arrow-${i}">${arrow}</span><span class="debug-call-title">Call #${i + 1}</span><span class="debug-call-time">${call.timestamp}</span></div><div class="debug-call-content" id="call-${i}" style="display: ${display};"><div class="debug-section-label">INPUT (${msgCount} messages)</div><pre>${inputHtml}</pre><div class="debug-section-label">OUTPUT</div><pre>${outputHtml}</pre></div></div>`; | |
| }).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 = '<div class="files-loading">Failed to load files</div>'; | |
| } | |
| } catch (e) { | |
| console.error('Failed to load file tree:', e); | |
| fileTree.innerHTML = '<div class="files-loading">Failed to load files</div>'; | |
| } | |
| } | |
| // 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' | |
| ? '<button class="file-tree-action-btn file-download-btn" title="Download">↓</button>' | |
| : '<button class="file-tree-action-btn file-upload-btn" title="Upload file here">+</button>'; | |
| lineEl.innerHTML = ` | |
| <span class="file-tree-icon">${icon}</span> | |
| <span class="file-tree-name">${item.name}</span> | |
| <span class="file-tree-actions">${actionBtn}</span> | |
| `; | |
| 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 <code> elements (not inside <pre>) | |
| 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'); | |
| }); | |
| } | |