529 lines
20 KiB
JavaScript
529 lines
20 KiB
JavaScript
// ==========================================
|
||
// Chat Switchboard – Application (Chat Surface)
|
||
// ==========================================
|
||
// Boot, auth, init, listener dispatch.
|
||
// Shared state (App) and model loading (fetchModels) live in app-state.js
|
||
// (loaded by base.html for all surfaces).
|
||
// Domain logic lives in chat.js, notes.js, settings-handlers.js,
|
||
// admin-handlers.js. UI rendering in ui-*.js files.
|
||
|
||
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) {
|
||
try {
|
||
await startApp();
|
||
} catch (e) {
|
||
console.error('💥 startApp crashed:', e);
|
||
// Surface the error visibly (crash banner from chat.html)
|
||
const b = document.getElementById('crashBanner');
|
||
const d = document.getElementById('crashDetail');
|
||
if (b && d) { b.style.display = ''; d.textContent += 'startApp: ' + e.message + '\n' + (e.stack || '') + '\n'; }
|
||
}
|
||
} 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 loadProjects();
|
||
await loadProjectChannelPositions(); // v0.19.2: load channel order per project
|
||
await fetchModels();
|
||
// app-state.js fetchModels populates App.models; UI updates are surface-specific
|
||
if (typeof UI !== 'undefined') {
|
||
UI.updateModelSelector();
|
||
UI.updateCapabilityBadges();
|
||
}
|
||
|
||
// 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();
|
||
initFiles();
|
||
if (typeof ToolsToggle !== 'undefined') ToolsToggle.init();
|
||
if (typeof REPL !== 'undefined') REPL.init(); // v0.21.3: admin-gated REPL tab
|
||
if (typeof KnowledgeUI !== 'undefined') {
|
||
KnowledgeUI.init();
|
||
} else {
|
||
console.error('[App] KnowledgeUI module not defined — knowledge-ui.js failed to load or parse');
|
||
}
|
||
UI.renderChatList();
|
||
UI.updateModelSelector();
|
||
|
||
// Restore last-active chat from sessionStorage
|
||
try {
|
||
const savedChat = sessionStorage.getItem('cs-active-chat');
|
||
if (savedChat && App.chats.some(c => c.id === savedChat)) {
|
||
selectChat(savedChat);
|
||
}
|
||
} catch (_) {}
|
||
|
||
// If no chat was restored, show the welcome/empty state
|
||
if (!App.currentChatId) {
|
||
UI.showEmptyState();
|
||
}
|
||
|
||
UI.updateUser();
|
||
UI.showAdminButton(API.isAdmin);
|
||
initListeners();
|
||
|
||
// Connect EventBus WebSocket (non-blocking, graceful if /ws not available yet)
|
||
try {
|
||
Events.connect((window.__BASE__ || '') + '/ws');
|
||
|
||
// Once WS connects, re-fetch tools so browser extension tools appear
|
||
// (ToolsToggle.init runs before WS is up → server omits browser tools)
|
||
Events.on('ws.connected', () => {
|
||
if (typeof ToolsToggle !== 'undefined') ToolsToggle.refresh();
|
||
});
|
||
|
||
// Admin-only: persistent fallback alert banner (v0.17.0)
|
||
Events.on('role.fallback', (payload) => {
|
||
if (App.user?.role !== 'admin') return;
|
||
const data = typeof payload === 'string' ? JSON.parse(payload) : payload;
|
||
showFallbackBanner(data);
|
||
});
|
||
} catch (e) {
|
||
console.warn('EventBus WebSocket not available:', e.message);
|
||
}
|
||
|
||
console.log('✅ Chat Switchboard ready');
|
||
}
|
||
|
||
// ── Role Fallback Banner (v0.17.0) ──────────
|
||
|
||
function showFallbackBanner(data) {
|
||
let banner = document.getElementById('roleFallbackBanner');
|
||
if (!banner) {
|
||
// Create persistent banner above messages
|
||
banner = document.createElement('div');
|
||
banner.id = 'roleFallbackBanner';
|
||
banner.className = 'role-fallback-banner';
|
||
const msgs = document.getElementById('chatMessages');
|
||
if (msgs) msgs.parentNode.insertBefore(banner, msgs);
|
||
else return;
|
||
}
|
||
const msg = data.message || `Role "${data.role}" primary failed — using fallback`;
|
||
banner.innerHTML = `
|
||
<span class="fallback-icon">⚠️</span>
|
||
<span class="fallback-msg">${esc(msg)}</span>
|
||
<button class="btn-small" onclick="this.parentElement.remove()">Dismiss</button>`;
|
||
banner.style.display = '';
|
||
}
|
||
|
||
// ── Modal Helpers ───────────────────────────
|
||
|
||
// ── Auth Flow ────────────────────────────────
|
||
|
||
function showSplash(health) {
|
||
// v0.22.6: Server-rendered architecture uses /login page.
|
||
// Clear stale cookie so the redirect sticks (no loop).
|
||
document.cookie = 'sb_token=; path=/; max-age=0';
|
||
const base = window.__BASE__ || '';
|
||
window.location.href = base + '/login';
|
||
}
|
||
|
||
function hideSplash() {
|
||
const splash = document.getElementById('splashGate');
|
||
const app = document.getElementById('appContainer');
|
||
if (splash) splash.style.display = 'none';
|
||
if (app) app.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
|
||
_initFileListeners(); // from files.js
|
||
_registerPreviewPanel(); // from ui-format.js — register preview with PanelRegistry
|
||
_registerNotesPanel(); // from notes.js — register notes with PanelRegistry
|
||
_registerProjectPanel(); // from projects-ui.js — register project detail panel
|
||
if (typeof Notifications !== 'undefined') Notifications.init(); // v0.20.0
|
||
_initGlobalKeyboard(); // local: Escape, Ctrl+K, Ctrl+\, resize
|
||
_initWorkspaceResize(); // from panels.js — workspace handle drag
|
||
_initPanelSwipe(); // from panels.js — mobile swipe navigation
|
||
_initPanelResponsive(); // from panels.js — responsive overlay
|
||
_initPanelOverlay(); // from panels.js — mobile tap-to-close overlay
|
||
_initSidebarTabs(); // v0.21.6: Chats/Files tab switching
|
||
}
|
||
|
||
function _initSidebarTabs() {
|
||
const tabBar = document.getElementById('sidebarTabs');
|
||
if (!tabBar) return;
|
||
|
||
tabBar.addEventListener('click', (e) => {
|
||
const btn = e.target.closest('.sidebar-tab');
|
||
if (!btn) return;
|
||
const tabName = btn.dataset.tab;
|
||
|
||
// Update active tab
|
||
tabBar.querySelectorAll('.sidebar-tab').forEach(t => t.classList.toggle('active', t === btn));
|
||
|
||
// Show/hide panels
|
||
document.querySelectorAll('.sidebar-tab-panel').forEach(p => {
|
||
p.style.display = p.dataset.tabPanel === tabName ? '' : 'none';
|
||
});
|
||
});
|
||
}
|
||
|
||
/** Show or hide the Files tab. Called by EditorMode on register/unregister. */
|
||
function showSidebarFilesTab(show) {
|
||
const tab = document.getElementById('sidebarFilesTab');
|
||
if (tab) tab.style.display = show ? '' : 'none';
|
||
// If hiding and Files was active, switch back to Chats
|
||
if (!show && tab?.classList.contains('active')) {
|
||
const chatsTab = document.querySelector('.sidebar-tab[data-tab="chats"]');
|
||
if (chatsTab) chatsTab.click();
|
||
}
|
||
}
|
||
|
||
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 (PanelRegistry.isContainerOpen()) {
|
||
PanelRegistry.closeAll(); 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+\: cycle side panels
|
||
if ((e.ctrlKey || e.metaKey) && e.key === '\\') {
|
||
e.preventDefault();
|
||
PanelRegistry.cycle();
|
||
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;
|
||
|
||
// Paste-to-file threshold (synced from backend, default 2000)
|
||
App.pasteToFileChars = data.paste_to_file_chars || 2000;
|
||
|
||
// 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 top = document.getElementById('bannerTop');
|
||
top.textContent = text;
|
||
top.classList.add('active');
|
||
root.style.setProperty('--banner-top-height', '22px');
|
||
|
||
const bot = document.getElementById('bannerBottom');
|
||
bot.textContent = text;
|
||
bot.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);
|