agent-ui / frontend /settings.js
lvwerra's picture
lvwerra HF Staff
Split frontend script.js (5460 lines) into 8 focused modules
78f4d62
// ============================================
// 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');
});
}