This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/app.js
2026-02-25 21:38:49 +00:00

590 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==========================================
// 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,
// 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) { 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 =
`<strong>Network proxy blocked this request</strong><br>` +
`Proxy response: "${API._esc(e.proxyTitle)}"<br>` +
`<span class="splash-error-hint">Ask your network admin to whitelist this domain. ` +
`<a href="#" onclick="openDebugModal();return false">Run diagnostics</a></span>`;
} else if (e.name === 'TimeoutError' || e.name === 'AbortError') {
splashErr.innerHTML =
`<strong>Connection timed out</strong><br>` +
`<span class="splash-error-hint">Server may be starting up, or a proxy is blocking the connection. ` +
`<a href="#" onclick="openDebugModal();return false">Run diagnostics</a></span>`;
} else {
splashErr.innerHTML =
`<strong>Cannot reach server</strong><br>` +
`<span class="splash-error-hint">${API._esc(e.message)}. ` +
`<a href="#" onclick="openDebugModal();return false">Run diagnostics</a></span>`;
}
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 <script> tags
await Extensions.initAll(); // build ctx, call init(), set up tool bridge
} catch (e) {
console.warn('Extension init error:', e.message);
}
await loadChats();
await fetchModels();
// Guard: if token was invalidated during startup (401 → refresh fail → clearTokens),
// kick back to login instead of rendering a broken UI.
if (!API.isAuthed) {
console.warn('⚠️ Auth lost during startup, returning to login');
showSplash(null);
return;
}
await initBanners();
initAttachments();
UI.renderChatList();
UI.updateModelSelector();
UI.updateUser();
UI.showAdminButton(API.isAdmin);
initListeners();
// Connect EventBus WebSocket (non-blocking, graceful if /ws not available yet)
try {
Events.connect((window.__BASE__ || '') + '/ws');
} catch (e) {
console.warn('EventBus WebSocket not available:', e.message);
}
console.log('✅ Chat Switchboard ready');
}
// ── Modal Helpers ───────────────────────────
function openModal(id) {
const el = document.getElementById(id);
if (!el) return;
el.classList.add('active');
// Defer overflow check — browser needs a frame to lay out the modal
requestAnimationFrame(() => {
el.querySelectorAll('.modal-tabs').forEach(checkTabsOverflow);
});
}
function closeModal(id) {
const el = document.getElementById(id);
if (el) el.classList.remove('active');
}
// Toggle .is-scrollable on tab bars that overflow horizontally
// and inject arrow navigation buttons when needed.
function checkTabsOverflow(tabs) {
if (!tabs) return;
const scrollable = tabs.scrollWidth > tabs.clientWidth + 1;
if (!scrollable) {
// Remove wrapper if overflow went away (e.g. zoom decreased)
const wrap = tabs.parentElement;
if (wrap && wrap.classList.contains('modal-tabs-wrap')) {
wrap.parentElement.insertBefore(tabs, wrap);
wrap.remove();
}
return;
}
// Wrap if not already wrapped
if (!tabs.parentElement.classList.contains('modal-tabs-wrap')) {
const wrap = document.createElement('div');
wrap.className = 'modal-tabs-wrap';
tabs.parentElement.insertBefore(wrap, tabs);
wrap.appendChild(tabs);
// Inject arrows
const left = document.createElement('button');
left.className = 'tab-arrow tab-arrow-left';
left.innerHTML = '&#x2039;';
left.addEventListener('click', () => { tabs.scrollLeft -= 120; });
const right = document.createElement('button');
right.className = 'tab-arrow tab-arrow-right';
right.innerHTML = '&#x203A;';
right.addEventListener('click', () => { tabs.scrollLeft += 120; });
wrap.appendChild(left);
wrap.appendChild(right);
// Update arrow visibility on scroll
tabs.addEventListener('scroll', () => updateTabArrows(tabs), { passive: true });
}
updateTabArrows(tabs);
}
function updateTabArrows(tabs) {
const wrap = tabs.parentElement;
if (!wrap || !wrap.classList.contains('modal-tabs-wrap')) return;
const left = wrap.querySelector('.tab-arrow-left');
const right = wrap.querySelector('.tab-arrow-right');
if (!left || !right) return;
const atStart = tabs.scrollLeft <= 2;
const atEnd = tabs.scrollLeft + tabs.clientWidth >= tabs.scrollWidth - 2;
left.classList.toggle('visible', !atStart);
right.classList.toggle('visible', !atEnd);
}
// ── Auth Flow ────────────────────────────────
function showSplash(health) {
document.getElementById('splashGate').style.display = 'flex';
document.getElementById('appContainer').style.display = 'none';
if (health && health.registration_enabled === false) {
document.getElementById('authTabRegister').style.display = 'none';
}
}
function hideSplash() {
document.getElementById('splashGate').style.display = 'none';
document.getElementById('appContainer').style.display = '';
}
async function handleLogin() {
const login = document.getElementById('authLogin').value.trim();
const password = document.getElementById('authPassword').value;
if (!login || !password) return setAuthError('Fill in all fields');
setAuthLoading(true);
try { await API.login(login, password); await startApp(); }
catch (e) { setAuthError(e.message); }
finally { setAuthLoading(false); }
}
async function handleRegister() {
const username = document.getElementById('authUsername').value.trim();
const email = document.getElementById('authEmail').value.trim();
const password = document.getElementById('authRegPassword').value;
if (!username || !email || !password) return setAuthError('Fill in all fields');
if (password.length < 8) return setAuthError('Password must be at least 8 characters');
setAuthLoading(true);
try {
const resp = await API.register(username, email, password);
if (resp.pending) {
setAuthError('');
const errEl = document.getElementById('authError');
errEl.textContent = 'Account created — pending admin approval. You will be able to sign in once approved.';
errEl.style.color = 'var(--accent)';
setTimeout(() => { errEl.style.color = ''; switchAuthTab('login'); }, 5000);
} else {
await startApp();
}
}
catch (e) { setAuthError(e.message); }
finally { setAuthLoading(false); }
}
async function handleLogout() {
if (!await showConfirm('Sign out?')) return;
Events.disconnect();
Events.clear();
await API.logout();
App.chats = [];
App.currentChatId = null;
location.reload();
}
function switchAuthTab(tab) {
document.getElementById('authTabLogin').classList.toggle('active', tab === 'login');
document.getElementById('authTabRegister').classList.toggle('active', tab === 'register');
document.getElementById('authLoginForm').style.display = tab === 'login' ? '' : 'none';
document.getElementById('authRegisterForm').style.display = tab === 'register' ? '' : 'none';
document.getElementById('authLoginBtn').style.display = tab === 'login' ? '' : 'none';
document.getElementById('authRegisterBtn').style.display = tab === 'register' ? '' : 'none';
const hdr = document.querySelector('.auth-card-header');
if (hdr) {
hdr.querySelector('h2').textContent = tab === 'login' ? 'Welcome back' : 'Create account';
hdr.querySelector('p').textContent = tab === 'login'
? 'Sign in to continue to your workspace'
: 'Get started with Chat Switchboard';
}
setAuthError('');
}
function setAuthError(msg) { document.getElementById('authError').textContent = msg; }
function setAuthLoading(on) {
document.querySelectorAll('#authLoginBtn, #authRegisterBtn').forEach(btn => {
btn.disabled = on;
btn.textContent = on ? 'Please wait...' : btn.dataset.label;
});
}
// ── Event Listeners ─────────────────────────
// Thin dispatcher — domain listeners extracted to their own files.
let _listenersInit = false;
function initListeners() {
if (_listenersInit) return;
_listenersInit = true;
_initChatListeners(); // from chat.js
_initSettingsListeners(); // from settings-handlers.js
_initAdminListeners(); // from admin-handlers.js
_initNotesListeners(); // from notes.js
_initAttachmentListeners(); // from attachments.js
_initGlobalKeyboard(); // local: Escape, Ctrl+K, resize
_initSidePanelResize(); // from ui-format.js
}
function _initGlobalKeyboard() {
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Escape: stop generation → close command palette → close side panel → close topmost modal
if (e.key === 'Escape') {
if (App.isGenerating) { stopGeneration(); return; }
if (document.getElementById('lightbox')?.classList.contains('active')) {
closeLightbox(); return;
}
if (document.getElementById('cmdPalette')?.classList.contains('active')) {
closeCmdPalette(); return;
}
if (document.getElementById('sidePanel')?.classList.contains('open')) {
closeSidePanel(); return;
}
const open = [...document.querySelectorAll('.modal-overlay.active')];
if (open.length) closeModal(open[open.length - 1].id);
return;
}
// Ctrl/Cmd+K: command palette
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
toggleCmdPalette();
return;
}
// Ctrl/Cmd+Shift+S: focus sidebar search
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'S') {
e.preventDefault();
const sb = document.getElementById('sidebar');
if (sb.classList.contains('collapsed')) UI.toggleSidebar();
document.getElementById('chatSearchInput')?.focus();
return;
}
});
// Recheck tab overflow on resize / zoom changes
window.addEventListener('resize', () => {
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(checkTabsOverflow);
});
}
// ── Branding ────────────────────────────────
function initBranding() {
const b = window.__BRANDING__;
if (!b || typeof b !== 'object') return;
const brandBase = (window.__BASE__ || '') + '/branding/';
// Accent color → CSS custom property
if (b.accent_color) {
document.documentElement.style.setProperty('--accent-color', b.accent_color);
}
// Page title
if (b.org_name) {
document.title = b.org_name;
}
// Sidebar brand text
const sidebarText = document.getElementById('brandSidebarText');
if (sidebarText && b.org_name) sidebarText.textContent = b.org_name;
// Splash hero wordmark
const wordmark = document.getElementById('brandWordmark');
if (wordmark && b.org_name) wordmark.textContent = b.org_name;
// Splash headline — replace if branding provides it
if (b.headline) {
const headline = document.getElementById('brandHeadline');
if (headline) headline.textContent = b.headline;
}
// Splash tagline
const tagline = document.getElementById('brandTagline');
if (tagline && b.tagline) tagline.textContent = b.tagline;
// Auth card text
if (b.org_name) {
const authHeader = document.querySelector('.auth-card-header h2');
if (authHeader) authHeader.textContent = 'Welcome to ' + b.org_name;
const authSub = document.querySelector('.auth-card-header p');
if (authSub) authSub.textContent = 'Sign in to continue';
}
const authFooter = document.getElementById('brandAuthFooter');
if (authFooter && b.tagline) authFooter.textContent = b.tagline;
// Logo — replace SVG with <img> in splash hero
if (b.logo) {
const logoUrl = brandBase + b.logo;
const logoEl = document.getElementById('brandLogo');
if (logoEl) {
const img = document.createElement('img');
img.src = logoUrl;
img.alt = b.org_name || 'Logo';
img.className = 'hero-logo-img';
img.onerror = function() {
this.style.display = 'none';
const svg = logoEl.querySelector('svg');
if (svg) svg.style.display = '';
};
const svg = logoEl.querySelector('svg');
if (svg) svg.style.display = 'none';
logoEl.appendChild(img);
}
// Sidebar logo — replace emoji with small img
const sidebarLogo = document.getElementById('brandSidebarLogo');
if (sidebarLogo) {
const originalEmoji = sidebarLogo.textContent;
const img = document.createElement('img');
img.src = logoUrl;
img.alt = '';
img.className = 'brand-logo-img';
img.onerror = function() {
this.remove();
sidebarLogo.textContent = originalEmoji;
};
sidebarLogo.textContent = '';
sidebarLogo.appendChild(img);
}
}
// Favicon
if (b.favicon) {
const favUrl = brandBase + b.favicon;
document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]').forEach(link => {
link.href = favUrl;
});
}
// Feature pills — custom array replaces defaults, empty array hides them
if (b.pills !== undefined) {
const pillsEl = document.getElementById('brandPills');
if (pillsEl) {
if (!Array.isArray(b.pills) || b.pills.length === 0) {
pillsEl.style.display = 'none';
} else {
pillsEl.innerHTML = b.pills.map(p => {
const style = p.style === 'accent' ? ' accent' : p.style === 'purple' ? ' purple' : '';
return `<div class="hero-pill${style}"><span class="pill-icon">${p.icon || ''}</span> ${p.text}</div>`;
}).join('');
}
}
}
console.log('🎨 Branding applied:', b.org_name || '(defaults)');
}
// ── Banners ──────────────────────────────────
async function initBanners() {
try {
const data = await API.getPublicSettings?.() || {};
// Store policies for user-facing checks (allow_user_byok, etc.)
App.policies = data.policies || {};
// Storage capabilities (file upload, vision)
App.storageConfigured = !!data.storage_configured;
// Also flatten into serverSettings for backward compat
App.serverSettings = {};
if (data.banner) App.serverSettings.banner = data.banner;
if (data.branding) App.serverSettings.branding = data.branding;
const root = document.documentElement;
const banner = data.banner;
// Clear previous banner state
['bannerTop', 'bannerBottom'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.classList.remove('active'); el.textContent = ''; }
});
root.style.setProperty('--banner-top-height', '0px');
root.style.setProperty('--banner-bottom-height', '0px');
if (!banner || !banner.enabled) return;
root.style.setProperty('--banner-bg', banner.bg || '#007a33');
root.style.setProperty('--banner-fg', banner.fg || '#ffffff');
const text = banner.text || '';
const pos = banner.position || 'both';
if (pos === 'top' || pos === 'both') {
const el = document.getElementById('bannerTop');
el.textContent = text;
el.classList.add('active');
root.style.setProperty('--banner-top-height', '22px');
}
if (pos === 'bottom' || pos === 'both') {
const el = document.getElementById('bannerBottom');
el.textContent = text;
el.classList.add('active');
root.style.setProperty('--banner-bottom-height', '22px');
}
} catch (e) {
// Banners are optional — non-admin users may not have access to settings
console.debug('Banner init skipped:', e.message);
}
}
// ── Boot ─────────────────────────────────────
document.addEventListener('DOMContentLoaded', init);