// ========================================== // Chat Switchboard – Application // ========================================== // State, init, boot, auth, listener dispatch. // Domain logic lives in chat.js, notes.js, settings-handlers.js, // admin-handlers.js. UI rendering in ui-*.js files. const App = { chats: [], currentChatId: null, models: [], hiddenModels: new Set(), isGenerating: false, abortController: null, serverSettings: {}, policies: {}, stagedAttachments: [], channelAttachments: {}, // messageId → [attachment, ...] — populated on chat load storageConfigured: false, projects: [], // v0.19.0: loaded from /api/v1/projects collapsedProjects: {}, // projectId → bool — sidebar collapse state activeProjectId: null, // v0.19.1: new chats auto-assign to this project showArchivedProjects: false, // v0.19.2: toggle archived projects in sidebar // Find model by composite ID, with fallback to bare model_id match findModel(id) { if (!id) return null; // Exact match on composite ID (configId:modelId) let m = App.models.find(m => m.id === id); if (m) return m; // Fallback: bare model_id (backward compat with stored settings) return App.models.find(m => m.baseModelId === id) || null; }, settings: { model: '', stream: true, showThinking: false, systemPrompt: '', maxTokens: 0, // 0 = auto from model capabilities temperature: 0.7, }, }; // ── Models ─────────────────────────────────── // ── Capability Resolution ─────────────────────────────────────── // The backend is the source of truth for model capabilities. // Resolution chain on the server: catalog (provider API sync) → heuristic. // The frontend does NOT maintain a static model table — the same model // can have different capabilities on different providers (e.g. DeepSeek // on Venice has no tool_calling, same model on OpenRouter does). // resolveCapabilities returns backend caps as-is. No client-side override. function resolveCapabilities(backendCaps, modelId) { return backendCaps && Object.keys(backendCaps).length > 0 ? { ...backendCaps } : {}; } async function fetchModels() { try { const data = await API.listEnabledModels(); App.defaultModel = data.default_model || ''; // Load user model preferences try { const prefData = await API.getModelPreferences(); App.hiddenModels = new Set( (prefData.preferences || []).filter(p => p.hidden).map(p => p.model_id) ); } catch (e) { // Keep existing preferences on failure (auth dead, network issue). // Only init to empty if there's nothing to preserve. if (!App.hiddenModels) App.hiddenModels = new Set(); } App.models = (data.models || []).map(m => { const isPreset = !!m.is_preset; const baseModelId = m.model_id || m.id; // Presets use preset_id; base models use configId:modelId to avoid // collisions when the same model exists across team/personal/global providers const id = isPreset ? (m.preset_id || m.id) : ((m.config_id || m.provider_config_id) ? `${m.config_id || m.provider_config_id}:${baseModelId}` : baseModelId); return { id, baseModelId, name: m.display_name || baseModelId, provider: m.provider_name || m.provider || '', configId: m.config_id || m.provider_config_id || null, model_type: m.model_type || 'chat', capabilities: resolveCapabilities(m.capabilities, baseModelId), isPreset, presetId: m.preset_id || m.persona_id || null, presetScope: m.preset_scope || (isPreset ? m.scope : null) || null, presetAvatar: m.preset_avatar || (isPreset ? m.avatar : null) || null, presetTeamName: m.preset_team_name || (isPreset ? m.team_name : null) || null, source: m.source || (isPreset ? 'preset' : 'global'), teamName: m.preset_team_name || null, hidden: !isPreset && App.hiddenModels.has(baseModelId), }; }); // Sort: presets first (global > team > personal), then regular models const scopeOrder = { global: 0, team: 1, personal: 2 }; App.models.sort((a, b) => { if (a.isPreset && !b.isPreset) return -1; if (!a.isPreset && b.isPreset) return 1; if (a.isPreset && b.isPreset) { const sa = scopeOrder[a.presetScope] ?? 9; const sb = scopeOrder[b.presetScope] ?? 9; if (sa !== sb) return sa - sb; } return a.name.localeCompare(b.name); }); console.log(`📋 Loaded ${App.models.length} models (${App.models.filter(m => m.isPreset).length} presets, ${App.models.filter(m => m.hidden).length} hidden)`); } catch (e) { console.warn('Model fetch failed:', e.message); } UI.updateModelSelector(); UI.updateCapabilityBadges(); } async function init() { console.log('🔀 Chat Switchboard initializing...'); initBranding(); // Apply branding before splash is visible API.loadTokens(); let health = null; try { health = await API.health(); console.log('✅ Backend reachable:', health.version); } catch (e) { console.error('❌ Backend unreachable:', e.message); const splashErr = document.getElementById('splashError'); if (e.proxyBlocked) { splashErr.innerHTML = `Network proxy blocked this request
` + `Proxy response: "${API._esc(e.proxyTitle)}"
` + `Ask your network admin to whitelist this domain. ` + `Run diagnostics`; } else if (e.name === 'TimeoutError' || e.name === 'AbortError') { splashErr.innerHTML = `Connection timed out
` + `Server may be starting up, or a proxy is blocking the connection. ` + `Run diagnostics`; } else { splashErr.innerHTML = `Cannot reach server
` + `${API._esc(e.message)}. ` + `Run diagnostics`; } showSplash(null); return; } if (API.isAuthed) { try { await API.getProfile(); console.log('✅ Session valid for', API.user?.username); } catch (e) { console.warn('⚠️ Session expired, clearing'); API.clearTokens(); } } if (API.isAuthed) { await startApp(); } else { showSplash(health); } } async function startApp() { hideSplash(); UI.restoreSidebar(); await loadSettings(); // Load extensions BEFORE chats so block renderers (mermaid, katex, csv, diff) // are registered when messages are first rendered. try { await Extensions.loadAll(); // fetch manifests + inject