// ==========================================
// Chat Switchboard – Application
// ==========================================
const App = {
chats: [],
currentChatId: null,
models: [],
hiddenModels: new Set(),
isGenerating: false,
abortController: null,
serverSettings: {},
policies: {},
// 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,
},
};
// ── Token Estimation + Context Tracking ─────
const Tokens = {
// Rough heuristic: ~4 chars per token for English (GPT/Claude average).
// Not exact, but good enough for a UI indicator.
estimate(text) {
if (!text) return 0;
return Math.ceil(text.length / 4);
},
// Estimate tokens for the full conversation context sent to the model
estimateConversation(messages, systemPrompt) {
let total = 0;
// System prompt
if (systemPrompt) total += this.estimate(systemPrompt) + 4; // +4 for role/delimiters
// Messages
for (const m of messages) {
total += this.estimate(m.content) + 4; // +4 per message overhead (role, delimiters)
}
return total;
},
// Get context budget for current model
getContextBudget() {
const caps = UI.getSelectedModelCaps();
return {
maxContext: caps.max_context || 0,
maxOutput: caps.max_output_tokens || 0,
};
},
// Format token count for display
format(n) {
if (n >= 100000) return (n / 1000).toFixed(0) + 'K';
if (n >= 10000) return (n / 1000).toFixed(1) + 'K';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return String(n);
},
_warningDismissed: false,
};
// Update the token counter below the input
function updateInputTokens() {
const el = document.getElementById('inputTokenCount');
if (!el) return;
const input = document.getElementById('messageInput');
const inputText = input?.value || '';
const inputTokens = Tokens.estimate(inputText);
if (!inputText.trim()) {
el.textContent = '';
el.className = 'input-token-count';
return;
}
const budget = Tokens.getContextBudget();
if (budget.maxContext > 0) {
// Show relative to available context
const chat = App.chats.find(c => c.id === App.currentChatId);
const convTokens = Tokens.estimateConversation(chat?.messages || [], App.settings.systemPrompt);
const totalWithInput = convTokens + inputTokens;
const pct = totalWithInput / budget.maxContext;
el.textContent = `~${Tokens.format(inputTokens)} tokens · ${Tokens.format(totalWithInput)} / ${Tokens.format(budget.maxContext)} context`;
el.className = 'input-token-count' + (pct > 0.9 ? ' danger' : pct > 0.75 ? ' warning' : '');
} else {
el.textContent = `~${Tokens.format(inputTokens)} tokens`;
el.className = 'input-token-count';
}
}
// Check conversation length and show/hide warning
function updateContextWarning() {
const warning = document.getElementById('contextWarning');
const text = document.getElementById('contextWarningText');
if (!warning || !text) return;
const budget = Tokens.getContextBudget();
if (budget.maxContext <= 0) {
warning.style.display = 'none';
return;
}
const chat = App.chats.find(c => c.id === App.currentChatId);
if (!chat || !chat.messages?.length) {
warning.style.display = 'none';
return;
}
const convTokens = Tokens.estimateConversation(chat.messages, App.settings.systemPrompt);
const pct = convTokens / budget.maxContext;
if (pct >= 0.9 && !Tokens._warningDismissed) {
warning.style.display = 'flex';
warning.className = 'context-warning danger';
text.textContent = `This conversation is using ~${Math.round(pct * 100)}% of the model's context window (~${Tokens.format(convTokens)} / ${Tokens.format(budget.maxContext)} tokens). Responses may lose earlier context. Consider starting a new chat.`;
} else if (pct >= 0.75 && !Tokens._warningDismissed) {
warning.style.display = 'flex';
warning.className = 'context-warning';
text.textContent = `Conversation is getting long (~${Math.round(pct * 100)}% of context window). The model may start losing track of earlier messages.`;
} else {
warning.style.display = 'none';
}
}
function dismissContextWarning() {
Tokens._warningDismissed = true;
const el = document.getElementById('contextWarning');
if (el) el.style.display = 'none';
}
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();
await loadChats();
await fetchModels();
await initBanners();
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');
}
// ── Settings ─────────────────────────────────
async function loadSettings() {
try {
const remote = await API.getSettings();
if (remote && typeof remote === 'object') {
if (remote.model) App.settings.model = remote.model;
if (remote.system_prompt !== undefined) App.settings.systemPrompt = remote.system_prompt;
if (remote.max_tokens) App.settings.maxTokens = remote.max_tokens;
if (remote.temperature !== undefined) App.settings.temperature = remote.temperature;
}
} catch (e) { console.warn('Settings load failed:', e.message); }
}
async function saveSettings() {
try {
await API.updateSettings({
model: App.settings.model,
system_prompt: App.settings.systemPrompt,
max_tokens: App.settings.maxTokens,
temperature: App.settings.temperature,
});
} catch (e) { console.warn('Settings sync failed:', e.message); }
}
// ── Avatar Preview ──────────────────────────
function updateAvatarPreview(dataURI) {
const preview = document.getElementById('avatarPreview');
const letter = document.getElementById('avatarPreviewLetter');
const removeBtn = document.getElementById('avatarRemoveBtn');
const existingImg = preview.querySelector('img');
if (dataURI) {
letter.style.display = 'none';
if (existingImg) {
existingImg.src = dataURI;
} else {
const img = document.createElement('img');
img.src = dataURI;
img.alt = 'Avatar';
preview.insertBefore(img, letter);
}
removeBtn.style.display = '';
} else {
if (existingImg) existingImg.remove();
const name = API.user?.display_name || API.user?.username || '?';
letter.textContent = name[0].toUpperCase();
letter.style.display = '';
removeBtn.style.display = 'none';
}
}
// ── 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,
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();
}
// ── Chat Management ──────────────────────────
async function loadChats() {
try {
const resp = await API.listChannels(1, 100, 'direct');
App.chats = (resp.data || []).map(c => ({
id: c.id,
title: c.title,
type: c.type || 'direct',
model: c.model || '',
messageCount: c.message_count || 0,
messages: [],
updatedAt: c.updated_at
}));
} catch (e) {
console.error('Failed to load chats:', e.message);
UI.toast('Failed to load chats', 'error');
}
}
async function selectChat(chatId) {
App.currentChatId = chatId;
UI.renderChatList();
if (window.innerWidth <= 768) {
document.getElementById('sidebar').classList.add('collapsed');
const ov = document.getElementById('sidebarOverlay');
if (ov) ov.style.display = 'none';
}
const chat = App.chats.find(c => c.id === chatId);
if (!chat) return;
if (chat.messages.length === 0 && chat.messageCount > 0) {
try {
const resp = await API.getActivePath(chatId);
chat.messages = (resp.path || []).map(m => ({
id: m.id,
parent_id: m.parent_id || null,
role: m.role,
content: m.content,
model: m.model || '',
modelName: '',
timestamp: m.created_at,
tool_calls: m.tool_calls || null,
siblingCount: m.sibling_count || 1,
siblingIndex: m.sibling_index || 0,
}));
} catch (e) {
console.error('Failed to load messages:', e.message);
UI.toast('Failed to load messages', 'error');
}
}
UI.renderMessages(chat.messages);
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
Tokens._warningDismissed = false;
updateContextWarning();
updateInputTokens();
}
async function newChat() {
App.currentChatId = null;
UI.renderChatList();
UI.showEmptyState();
UI.showRegenerate(false);
Tokens._warningDismissed = false;
updateContextWarning();
updateInputTokens();
document.getElementById('messageInput').focus();
if (window.innerWidth <= 768) {
document.getElementById('sidebar').classList.add('collapsed');
const ov = document.getElementById('sidebarOverlay');
if (ov) ov.style.display = 'none';
}
}
async function deleteChat(chatId) {
if (!confirm('Delete this chat?')) return;
try {
await API.deleteChannel(chatId);
App.chats = App.chats.filter(c => c.id !== chatId);
if (App.currentChatId === chatId) newChat();
UI.renderChatList();
} catch (e) { UI.toast('Failed to delete: ' + e.message, 'error'); }
}
// ── Send Message ─────────────────────────────
async function sendMessage() {
const input = document.getElementById('messageInput');
const text = input.value.trim();
if (!text || App.isGenerating) return;
input.value = '';
input.style.height = 'auto';
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
// For presets, send the base model ID; for regular models, send the model ID
const model = modelInfo?.baseModelId || selectedId;
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
if (!App.currentChatId) {
try {
const title = text.slice(0, 50) + (text.length > 50 ? '...' : '');
const resp = await API.createChannel(title, model, App.settings.systemPrompt, 'direct');
const chat = { id: resp.id, title: resp.title, type: 'direct', model, messages: [], messageCount: 0, updatedAt: resp.updated_at };
App.chats.unshift(chat);
App.currentChatId = chat.id;
UI.renderChatList();
} catch (e) { UI.toast('Failed to create chat: ' + e.message, 'error'); return; }
}
const chat = App.chats.find(c => c.id === App.currentChatId);
if (!chat) return;
// Optimistic: show user message immediately
chat.messages.push({ role: 'user', content: text, timestamp: new Date().toISOString(), siblingCount: 1, siblingIndex: 0 });
UI.renderMessages(chat.messages);
App.isGenerating = true;
App.abortController = new AbortController();
UI.setGenerating(true);
try {
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId);
await UI.streamResponse(resp, chat.messages, modelInfo?.name);
// Reload active path from server to get proper IDs and sibling metadata
await reloadActivePath();
UI.showRegenerate(true);
} catch (e) {
if (e.name === 'AbortError') {
UI.toast('Generation stopped', 'warning');
await reloadActivePath();
} else {
console.error('Completion error:', e);
const msg = e.message || '';
if (e.proxyBlocked) {
UI.toast('Network proxy blocked this request — contact your network admin', 'error');
} else if (msg.includes('provider error') && msg.includes('401')) {
UI.toast('Provider API key rejected — check Settings → Providers', 'error');
} else if (msg.includes('provider error') && msg.includes('429')) {
UI.toast('Provider rate limit — wait and retry', 'warning');
} else if (msg.includes('no API config') || msg.includes('no model')) {
UI.toast('No provider configured — add one in Settings', 'error');
} else {
UI.toast(msg, 'error');
}
}
} finally {
App.isGenerating = false;
App.abortController = null;
UI.setGenerating(false);
}
}
function stopGeneration() { if (App.abortController) App.abortController.abort(); }
// ── Reload Active Path from Server ──────────
async function reloadActivePath() {
if (!App.currentChatId) return;
const chat = App.chats.find(c => c.id === App.currentChatId);
if (!chat) return;
try {
const resp = await API.getActivePath(App.currentChatId);
chat.messages = (resp.path || []).map(m => ({
id: m.id,
parent_id: m.parent_id || null,
role: m.role,
content: m.content,
model: m.model || '',
modelName: '',
timestamp: m.created_at,
tool_calls: m.tool_calls || null,
siblingCount: m.sibling_count || 1,
siblingIndex: m.sibling_index || 0,
}));
chat.messageCount = chat.messages.length;
UI.renderMessages(chat.messages);
updateContextWarning();
} catch (e) {
console.error('Failed to reload path:', e.message);
}
}
// ── Regenerate (tree-aware: creates sibling) ─
async function regenerateMessage(messageId) {
if (App.isGenerating || !App.currentChatId) return;
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
const model = modelInfo?.baseModelId || selectedId;
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
// Truncate display to the parent of the message being regenerated.
// This makes the stream appear in the correct position — as a fresh
// response to the parent user message, not appended at the bottom.
const chat = App.chats.find(c => c.id === App.currentChatId);
if (chat) {
const msgIdx = chat.messages.findIndex(m => m.id === messageId);
if (msgIdx > 0) {
const truncated = chat.messages.slice(0, msgIdx);
UI.renderMessages(truncated);
}
}
App.isGenerating = true;
App.abortController = new AbortController();
UI.setGenerating(true);
try {
const resp = await API.streamRegenerate(
App.currentChatId, messageId, App.abortController.signal,
model, presetId, modelInfo?.configId
);
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
// Reload from server to get proper tree state
await reloadActivePath();
UI.showRegenerate(true);
} catch (e) {
if (e.name === 'AbortError') {
UI.toast('Generation stopped', 'warning');
await reloadActivePath();
} else {
const msg = e.message || '';
if (e.proxyBlocked) {
UI.toast('Network proxy blocked this request', 'error');
} else if (msg.includes('provider error') && msg.includes('401')) {
UI.toast('Provider API key rejected — check Settings', 'error');
} else { UI.toast(msg, 'error'); }
}
} finally {
App.isGenerating = false;
App.abortController = null;
UI.setGenerating(false);
}
}
// Legacy: bottom-bar regenerate button targets the last assistant message
async function regenerate() {
if (App.isGenerating || !App.currentChatId) return;
const chat = App.chats.find(c => c.id === App.currentChatId);
if (!chat || chat.messages.length === 0) return;
// Find the last assistant message with an ID
for (let i = chat.messages.length - 1; i >= 0; i--) {
if (chat.messages[i].role === 'assistant' && chat.messages[i].id) {
return regenerateMessage(chat.messages[i].id);
}
}
UI.toast('No assistant message to regenerate', 'warning');
}
// ── Edit Message (creates sibling, triggers completion) ──
async function editMessage(messageId) {
if (App.isGenerating || !App.currentChatId) return;
const chat = App.chats.find(c => c.id === App.currentChatId);
if (!chat) return;
const msg = chat.messages.find(m => m.id === messageId);
if (!msg || msg.role !== 'user') return;
// Show inline edit UI
UI.showEditInline(messageId, msg.content);
}
async function submitEdit(messageId, newContent) {
if (App.isGenerating || !App.currentChatId) return;
if (!newContent.trim()) return;
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
const model = modelInfo?.baseModelId || selectedId;
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
App.isGenerating = true;
App.abortController = new AbortController();
UI.setGenerating(true);
try {
const chat = App.chats.find(c => c.id === App.currentChatId);
// Step 1: Create sibling user message via edit endpoint
const editResp = await API.editMessage(App.currentChatId, messageId, newContent.trim());
// Step 2: Reload path to show the new edited message
await reloadActivePath();
// Step 3: Generate assistant response as child of the new edit.
// Uses regenerate endpoint (which handles user messages by creating
// a child response, not a sibling). This avoids the duplicate user
// message that streamCompletion would create.
const resp = await API.streamRegenerate(
App.currentChatId, editResp.id, App.abortController.signal,
model, presetId, modelInfo?.configId
);
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
// Step 4: Final reload to get the complete tree state
await reloadActivePath();
UI.showRegenerate(true);
} catch (e) {
if (e.name === 'AbortError') {
UI.toast('Generation stopped', 'warning');
await reloadActivePath();
} else {
console.error('Edit error:', e);
UI.toast(e.message || 'Edit failed', 'error');
await reloadActivePath();
}
} finally {
App.isGenerating = false;
App.abortController = null;
UI.setGenerating(false);
}
}
function cancelEdit() {
// Re-render to remove the edit form
const chat = App.chats.find(c => c.id === App.currentChatId);
if (chat) UI.renderMessages(chat.messages);
}
// ── Switch Branch (sibling navigation) ──────
async function switchSibling(messageId, direction) {
if (App.isGenerating || !App.currentChatId) return;
try {
// Get siblings for this message
const data = await API.listSiblings(App.currentChatId, messageId);
const siblings = data.siblings || [];
const currentIdx = data.current_index ?? 0;
const targetIdx = currentIdx + direction;
if (targetIdx < 0 || targetIdx >= siblings.length) return;
const targetSibling = siblings[targetIdx];
// Update cursor to the target sibling (backend walks to leaf)
const resp = await API.updateCursor(App.currentChatId, targetSibling.id);
// Update local state with the new path
const chat = App.chats.find(c => c.id === App.currentChatId);
if (chat && resp.path) {
chat.messages = resp.path.map(m => ({
id: m.id,
parent_id: m.parent_id || null,
role: m.role,
content: m.content,
model: m.model || '',
modelName: '',
timestamp: m.created_at,
siblingCount: m.sibling_count || 1,
siblingIndex: m.sibling_index || 0,
}));
chat.messageCount = chat.messages.length;
UI.renderMessages(chat.messages);
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
}
} catch (e) {
console.error('Branch switch failed:', e.message);
UI.toast('Failed to switch branch', 'error');
}
}
// ── 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 = '‹';
left.addEventListener('click', () => { tabs.scrollLeft -= 120; });
const right = document.createElement('button');
right.className = 'tab-arrow tab-arrow-right';
right.innerHTML = '›';
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 (!confirm('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 ──────────────────────────
let _listenersInit = false;
function initListeners() {
if (_listenersInit) return;
_listenersInit = true;
// Sidebar
document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar);
document.getElementById('newChatBtn').addEventListener('click', newChat);
// Split button dropdown
document.getElementById('newChatDropBtn').addEventListener('click', (e) => {
e.stopPropagation();
document.getElementById('newChatDropdown').classList.toggle('open');
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.split-btn')) {
document.getElementById('newChatDropdown').classList.remove('open');
}
});
// User flyout
document.getElementById('userMenuBtn').addEventListener('click', (e) => {
e.stopPropagation();
UI.toggleUserMenu();
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.sidebar-bottom')) UI.closeUserMenu();
});
// Flyout items
document.getElementById('menuSettings').addEventListener('click', () => { UI.closeUserMenu(); UI.openSettings(); });
document.getElementById('menuAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openAdmin(); });
document.getElementById('menuDebug')?.addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); });
document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); });
// Notes panel
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
document.getElementById('notesNewBtn')?.addEventListener('click', () => openNoteEditor(null));
document.getElementById('notesSelectModeBtn')?.addEventListener('click', () => {
if (_notesSelectMode) _exitSelectMode();
else _enterSelectMode();
});
document.getElementById('notesBackBtn')?.addEventListener('click', () => { showNotesList(); loadNotesList(); loadNoteFolders(); });
document.getElementById('noteSaveBtn')?.addEventListener('click', saveNote);
document.getElementById('noteDeleteBtn')?.addEventListener('click', deleteNote);
document.getElementById('noteDeleteBtn2')?.addEventListener('click', deleteNote);
document.getElementById('noteEditBtn')?.addEventListener('click', _showNoteEditMode);
document.getElementById('noteEditBtn2')?.addEventListener('click', _showNoteEditMode);
document.getElementById('notePreviewBtn')?.addEventListener('click', _showNotePreview);
document.getElementById('noteCancelEditBtn')?.addEventListener('click', () => {
if (_currentNote) { _populateEditFields(_currentNote); _showNoteReadMode(); }
else showNotesList();
});
document.getElementById('notesFolderFilter')?.addEventListener('change', (e) => {
document.getElementById('notesSearchInput').value = '';
_exitSelectMode();
loadNotesList(e.target.value);
});
document.getElementById('notesSortSelect')?.addEventListener('change', (e) => {
_notesSort = e.target.value;
_exitSelectMode();
loadNotesList();
});
document.getElementById('notesSelectAll')?.addEventListener('change', (e) => {
_toggleSelectAll(e.target.checked);
});
document.getElementById('notesDeleteSelectedBtn')?.addEventListener('click', _bulkDeleteSelected);
document.getElementById('notesCancelSelectBtn')?.addEventListener('click', _exitSelectMode);
// Notes search with debounce
let _notesSearchTimer;
document.getElementById('notesSearchInput')?.addEventListener('input', (e) => {
clearTimeout(_notesSearchTimer);
const q = e.target.value.trim();
_notesSearchTimer = setTimeout(() => {
_exitSelectMode();
if (q.length >= 2) {
document.getElementById('notesFolderFilter').value = '';
loadNotesList(null, q);
} else if (q.length === 0) {
loadNotesList();
}
}, 300);
});
// Sidebar chat search
const _chatSearchInput = document.getElementById('chatSearchInput');
_chatSearchInput?.addEventListener('input', () => UI.renderChatList());
document.getElementById('chatSearchClear')?.addEventListener('click', () => {
if (_chatSearchInput) { _chatSearchInput.value = ''; UI.renderChatList(); _chatSearchInput.focus(); }
});
// Chat actions
document.getElementById('sendBtn').addEventListener('click', sendMessage);
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
// Model selector (custom dropdown)
UI.initModelDropdown();
UI.initAppearance();
// Mobile hamburger
document.getElementById('mobileMenuBtn')?.addEventListener('click', UI.toggleSidebar);
document.getElementById('sidebarOverlay')?.addEventListener('click', () => {
document.getElementById('sidebar').classList.add('collapsed');
document.getElementById('sidebarOverlay').style.display = 'none';
localStorage.setItem('sb_sidebar', '1');
});
document.getElementById('fetchModelsBtn')?.addEventListener('click', async () => {
await fetchModels();
const visible = App.models.filter(m => !m.hidden).length;
UI.toast(`Loaded ${visible} model${visible !== 1 ? 's' : ''}`, 'success');
});
// Settings modal
document.getElementById('settingsCloseBtn').addEventListener('click', UI.closeSettings);
document.getElementById('settingsSaveBtn').addEventListener('click', handleSaveSettings);
document.querySelectorAll('.settings-tab').forEach(tab => {
tab.addEventListener('click', () => UI.switchSettingsTab(tab.dataset.stab));
});
document.getElementById('settingsModel').addEventListener('change', function() {
const model = App.findModel(this.value);
const caps = model?.capabilities || {};
const hint = document.getElementById('settingsMaxHint');
if (hint) {
hint.textContent = caps.max_output_tokens > 0
? `(model max: ${(caps.max_output_tokens / 1000).toFixed(0)}K)`
: '';
}
});
document.getElementById('profileChangePwBtn').addEventListener('click', () => {
document.getElementById('profileChangePwForm').style.display = '';
});
document.getElementById('profileSavePwBtn').addEventListener('click', handleChangePassword);
// Avatar upload
document.getElementById('avatarUploadBtn').addEventListener('click', () => {
document.getElementById('avatarFileInput').click();
});
document.getElementById('avatarFileInput').addEventListener('change', async function() {
const file = this.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async () => {
try {
const data = await API.uploadAvatar(reader.result);
if (data.avatar) {
API.user.avatar = data.avatar;
API.saveTokens();
updateAvatarPreview(data.avatar);
UI.updateUser();
UI.toast('Avatar updated');
}
} catch (e) { UI.toast(e.message || 'Upload failed', 'error'); }
};
reader.readAsDataURL(file);
this.value = '';
});
document.getElementById('avatarRemoveBtn').addEventListener('click', async () => {
try {
await API.deleteAvatar();
API.user.avatar = null;
API.saveTokens();
updateAvatarPreview(null);
UI.updateUser();
UI.toast('Avatar removed');
} catch (e) { UI.toast(e.message || 'Remove failed', 'error'); }
});
document.getElementById('providerShowAddBtn').addEventListener('click', UI.showProviderForm);
document.getElementById('providerCancelBtn').addEventListener('click', UI.hideProviderForm);
document.getElementById('providerCreateBtn').addEventListener('click', handleCreateProvider);
document.getElementById('providerType').addEventListener('change', function() {
const endpoints = {
openai: 'https://api.openai.com/v1',
anthropic: 'https://api.anthropic.com',
venice: 'https://api.venice.ai/api/v1',
openrouter: 'https://openrouter.ai/api/v1',
};
const ep = document.getElementById('providerEndpoint');
if (!ep.value || Object.values(endpoints).includes(ep.value)) {
ep.value = endpoints[this.value] || '';
}
});
// Admin modal (elements may not exist for non-admin users)
document.getElementById('adminCloseBtn')?.addEventListener('click', UI.closeAdmin);
document.querySelectorAll('.admin-tab').forEach(tab => {
tab.addEventListener('click', () => UI.switchAdminTab(tab.dataset.tab));
});
document.getElementById('adminSaveSettings')?.addEventListener('click', handleSaveAdminSettings);
// Admin — users
document.getElementById('adminAddUserBtn')?.addEventListener('click', () => {
const f = document.getElementById('adminAddUserForm');
f.style.display = f.style.display === 'none' ? '' : 'none';
});
document.getElementById('adminCancelUserBtn')?.addEventListener('click', () => { document.getElementById('adminAddUserForm').style.display = 'none'; });
document.getElementById('adminCreateUserBtn')?.addEventListener('click', createAdminUser);
// Admin — providers
document.getElementById('adminAddProviderBtn')?.addEventListener('click', () => {
_editingProviderId = null;
document.getElementById('adminCreateProvBtn').textContent = 'Save';
document.getElementById('adminProvKey').placeholder = 'sk-...';
document.getElementById('adminProvName').value = '';
document.getElementById('adminProvEndpoint').value = '';
document.getElementById('adminProvKey').value = '';
document.getElementById('adminProvModel').value = '';
document.getElementById('adminProvPrivate').checked = false;
const f = document.getElementById('adminAddProviderForm');
f.style.display = f.style.display === 'none' ? '' : 'none';
});
document.getElementById('adminCancelProvBtn')?.addEventListener('click', () => {
document.getElementById('adminAddProviderForm').style.display = 'none';
_editingProviderId = null;
document.getElementById('adminCreateProvBtn').textContent = 'Save';
document.getElementById('adminProvKey').placeholder = 'sk-...';
});
document.getElementById('adminCreateProvBtn')?.addEventListener('click', createGlobalProvider);
// Admin — models
document.getElementById('adminFetchModelsBtn')?.addEventListener('click', fetchAdminModels);
// Admin — presets (shared form)
document.getElementById('adminAddPresetBtn')?.addEventListener('click', () => {
const form = ensureAdminPresetForm();
if (!form) return;
_editingPresetId = null;
form.setSubmitLabel('Create');
form.clearForm();
const f = document.getElementById('adminAddPresetForm');
f.style.display = f.style.display === 'none' ? '' : 'none';
});
// Admin — Teams
document.getElementById('adminAddTeamBtn')?.addEventListener('click', () => {
document.getElementById('adminAddTeamForm').style.display = '';
document.getElementById('adminTeamName').focus();
});
document.getElementById('adminCancelTeamBtn')?.addEventListener('click', () => {
document.getElementById('adminAddTeamForm').style.display = 'none';
document.getElementById('adminTeamName').value = '';
document.getElementById('adminTeamDesc').value = '';
});
document.getElementById('adminCreateTeamBtn')?.addEventListener('click', async () => {
const name = document.getElementById('adminTeamName').value.trim();
const desc = document.getElementById('adminTeamDesc').value.trim();
if (!name) return UI.toast('Team name required', 'error');
try {
await API.adminCreateTeam(name, desc);
document.getElementById('adminAddTeamForm').style.display = 'none';
document.getElementById('adminTeamName').value = '';
document.getElementById('adminTeamDesc').value = '';
UI.toast('Team created');
await UI.loadAdminTeams(true);
} catch (e) { UI.toast(e.message, 'error'); }
});
document.getElementById('adminTeamBackBtn')?.addEventListener('click', () => {
document.getElementById('adminTeamDetail').style.display = 'none';
document.getElementById('adminTeamList').style.display = '';
UI.loadAdminTeams(true);
});
document.getElementById('adminAddMemberBtn')?.addEventListener('click', async () => {
document.getElementById('adminAddMemberForm').style.display = '';
await UI.loadMemberUserDropdown(UI._teamEditId);
});
document.getElementById('adminTeamPrivatePolicy')?.addEventListener('change', async function() {
try {
await API.adminUpdateTeam(UI._teamEditId, {
settings: JSON.stringify({ require_private_providers: this.checked })
});
UI.toast(this.checked ? 'Private providers required' : 'Private provider policy removed');
} catch (e) { UI.toast(e.message, 'error'); this.checked = !this.checked; }
});
document.getElementById('adminTeamAllowProviders')?.addEventListener('change', async function() {
try {
await API.adminUpdateTeam(UI._teamEditId, {
settings: JSON.stringify({ allow_team_providers: this.checked })
});
UI.toast(this.checked ? 'Team providers enabled' : 'Team providers disabled');
} catch (e) { UI.toast(e.message, 'error'); this.checked = !this.checked; }
});
document.getElementById('adminCancelMemberBtn')?.addEventListener('click', () => {
document.getElementById('adminAddMemberForm').style.display = 'none';
});
document.getElementById('adminAddMemberSubmit')?.addEventListener('click', async () => {
const userId = document.getElementById('adminMemberUser').value;
const role = document.getElementById('adminMemberRole').value;
if (!userId) return UI.toast('Select a user', 'error');
try {
await API.adminAddMember(UI._teamEditId, userId, role);
document.getElementById('adminAddMemberForm').style.display = 'none';
UI.toast('Member added');
await UI.loadTeamMembers(UI._teamEditId);
} catch (e) { UI.toast(e.message, 'error'); }
});
// Settings — Team management (team admin self-service)
document.getElementById('settingsTeamBackBtn')?.addEventListener('click', () => {
UI.loadTeamsTab();
});
document.getElementById('settingsTeamAddMemberBtn')?.addEventListener('click', async () => {
document.getElementById('settingsTeamAddMember').style.display = '';
// Load available users (requires sys-admin; team admins use their own knowledge)
const sel = document.getElementById('settingsTeamMemberUser');
sel.innerHTML = '';
try {
const resp = await API.teamListMembers(UI._managingTeamId);
const existingIds = new Set((resp.data || []).map(m => m.user_id));
// Team admins can't list all users — provide an email input fallback
sel.innerHTML = '';
} catch (e) { sel.innerHTML = ''; }
});
document.getElementById('settingsTeamCancelMember')?.addEventListener('click', () => {
document.getElementById('settingsTeamAddMember').style.display = 'none';
});
var _teamPresetForm = null;
document.getElementById('settingsTeamAddPresetBtn')?.addEventListener('click', () => {
const container = document.getElementById('settingsTeamAddPreset');
container.style.display = '';
if (!_teamPresetForm) {
_teamPresetForm = renderPresetForm(container, {
prefix: 'teamPreset',
showAvatar: true,
showProviderConfig: false,
onSubmit: async (vals) => {
const teamId = UI._managingTeamId;
if (!vals.name) return UI.toast('Name required', 'error');
if (!vals.base_model_id) return UI.toast('Select a base model', 'error');
try {
const result = await API.teamCreatePreset(teamId, vals);
// Upload pending avatar
const presetId = result?.id;
if (presetId && vals._pendingAvatar) {
try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); }
catch (e) { console.warn('Team preset avatar upload failed:', e.message); }
}
container.style.display = 'none';
_teamPresetForm.clearForm();
UI.toast('Team preset created');
await UI.loadTeamManagePresets(teamId);
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
},
onCancel: () => { container.style.display = 'none'; _teamPresetForm.clearForm(); }
});
} else {
_teamPresetForm.clearForm();
}
UI.loadTeamPresetModelDropdown(UI._managingTeamId);
});
// Team — providers
const defaultEndpoints = {
openai: 'https://api.openai.com/v1',
anthropic: 'https://api.anthropic.com',
venice: 'https://api.venice.ai/api/v1',
openrouter: 'https://openrouter.ai/api/v1',
};
document.getElementById('settingsTeamAddProviderBtn')?.addEventListener('click', () => {
const form = document.getElementById('settingsTeamAddProvider');
form.style.display = form.style.display === 'none' ? '' : 'none';
// Populate provider type dropdown
const sel = document.getElementById('teamProviderType');
if (sel && sel.options.length === 0) {
['openai', 'anthropic', 'venice', 'openrouter'].forEach(p => {
const labels = { openai: 'OpenAI-compatible', anthropic: 'Anthropic', venice: 'Venice.ai', openrouter: 'OpenRouter' };
const opt = document.createElement('option');
opt.value = p;
opt.textContent = labels[p] || p;
sel.appendChild(opt);
});
}
});
document.getElementById('teamProviderType')?.addEventListener('change', function() {
const ep = document.getElementById('teamProviderEndpoint');
if (!ep.value || Object.values(defaultEndpoints).includes(ep.value)) {
ep.value = defaultEndpoints[this.value] || '';
}
});
document.getElementById('settingsTeamCancelProvider')?.addEventListener('click', () => {
document.getElementById('settingsTeamAddProvider').style.display = 'none';
});
document.getElementById('settingsTeamAddProviderSubmit')?.addEventListener('click', async () => {
const teamId = UI._managingTeamId;
const name = document.getElementById('teamProviderName').value.trim();
const provider = document.getElementById('teamProviderType').value;
const endpoint = document.getElementById('teamProviderEndpoint').value.trim();
const apiKey = document.getElementById('teamProviderKey').value;
const isPrivate = document.getElementById('teamProviderPrivate').checked;
if (!name) return UI.toast('Name required', 'error');
if (!endpoint) return UI.toast('Endpoint required', 'error');
try {
await API.teamCreateProvider(teamId, { name, provider, endpoint, api_key: apiKey, is_private: isPrivate });
document.getElementById('settingsTeamAddProvider').style.display = 'none';
document.getElementById('teamProviderName').value = '';
document.getElementById('teamProviderEndpoint').value = '';
document.getElementById('teamProviderKey').value = '';
document.getElementById('teamProviderPrivate').checked = false;
UI.toast('Provider added');
await UI.loadTeamManageProviders(teamId);
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
});
// Team — edit provider
document.getElementById('settingsTeamCancelEditProvider')?.addEventListener('click', () => {
document.getElementById('settingsTeamEditProvider').style.display = 'none';
});
document.getElementById('settingsTeamEditProviderSubmit')?.addEventListener('click', async () => {
const teamId = UI._managingTeamId;
const provId = UI._editingTeamProviderId;
if (!provId) return;
const updates = {};
const name = document.getElementById('teamProviderEditName').value.trim();
const endpoint = document.getElementById('teamProviderEditEndpoint').value.trim();
const apiKey = document.getElementById('teamProviderEditKey').value;
const defaultModel = document.getElementById('teamProviderEditDefault').value.trim();
const isPrivate = document.getElementById('teamProviderEditPrivate').checked;
if (name) updates.name = name;
if (endpoint) updates.endpoint = endpoint;
if (apiKey) updates.api_key = apiKey;
if (defaultModel) updates.model_default = defaultModel;
updates.is_private = isPrivate;
try {
await API.teamUpdateProvider(teamId, provId, updates);
document.getElementById('settingsTeamEditProvider').style.display = 'none';
UI.toast('Provider updated');
await UI.loadTeamManageProviders(teamId);
} catch (e) { UI.toast(e.message, 'error'); }
});
// User — personal presets
var _userPresetForm = null;
document.getElementById('userAddPresetBtn')?.addEventListener('click', () => {
const container = document.getElementById('userAddPresetForm');
container.style.display = container.style.display === 'none' ? '' : 'none';
if (!_userPresetForm) {
_userPresetForm = renderPresetForm(container, {
prefix: 'userPreset',
showAvatar: true,
showProviderConfig: false,
onSubmit: async (vals) => {
if (!vals.name) return UI.toast('Name required', 'error');
if (!vals.base_model_id) return UI.toast('Select a base model', 'error');
try {
const result = await API.createUserPreset(vals);
const presetId = result?.id;
if (presetId && vals._pendingAvatar) {
try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); }
catch (e) { console.warn('User preset avatar upload failed:', e.message); }
}
container.style.display = 'none';
_userPresetForm.clearForm();
UI.toast('Preset created');
await UI.loadUserPresets();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
},
onCancel: () => { container.style.display = 'none'; _userPresetForm.clearForm(); }
});
}
// Populate model dropdown from user's available models
const sel = _userPresetForm.getModelSelect();
if (sel) {
sel.innerHTML = '';
App.models.filter(m => !m.isPreset).forEach(m => {
const opt = document.createElement('option');
opt.value = m.baseModelId || m.id;
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
sel.appendChild(opt);
});
}
});
// Admin — banner controls
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none';
});
document.getElementById('adminBannerPreset')?.addEventListener('change', (e) => {
const preset = UI._bannerPresets[e.target.value];
if (preset) {
document.getElementById('adminBannerText').value = preset.text || '';
document.getElementById('adminBannerBg').value = preset.bg || '#007a33';
document.getElementById('adminBannerBgHex').value = preset.bg || '#007a33';
document.getElementById('adminBannerFg').value = preset.fg || '#ffffff';
document.getElementById('adminBannerFgHex').value = preset.fg || '#ffffff';
UI.updateBannerPreview();
}
});
['adminBannerText', 'adminBannerBg', 'adminBannerFg'].forEach(id => {
document.getElementById(id)?.addEventListener('input', UI.updateBannerPreview);
});
// Sync color pickers with hex inputs
document.getElementById('adminBannerBg')?.addEventListener('input', (e) => { document.getElementById('adminBannerBgHex').value = e.target.value; });
document.getElementById('adminBannerFg')?.addEventListener('input', (e) => { document.getElementById('adminBannerFgHex').value = e.target.value; });
document.getElementById('adminBannerBgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerBg').value = e.target.value; UI.updateBannerPreview(); }});
document.getElementById('adminBannerFgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerFg').value = e.target.value; UI.updateBannerPreview(); }});
// Admin — audit log
document.getElementById('auditRefreshBtn')?.addEventListener('click', () => UI.loadAuditLog(1));
document.getElementById('auditFilterAction')?.addEventListener('change', () => UI.loadAuditLog(1));
document.getElementById('auditFilterResource')?.addEventListener('change', () => UI.loadAuditLog(1));
document.getElementById('auditPrevBtn')?.addEventListener('click', () => { if (UI._auditPage > 1) UI.loadAuditLog(UI._auditPage - 1); });
document.getElementById('auditNextBtn')?.addEventListener('click', () => UI.loadAuditLog(UI._auditPage + 1));
// Admin — usage
document.getElementById('usageRefreshBtn')?.addEventListener('click', () => UI.loadAdminUsage());
document.getElementById('usagePeriod')?.addEventListener('change', () => UI.loadAdminUsage());
document.getElementById('usageGroupBy')?.addEventListener('change', () => UI.loadAdminUsage());
// Input
const input = document.getElementById('messageInput');
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
input.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
updateInputTokens();
});
// Close modals on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closeModal(overlay.id);
});
});
// 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('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);
});
}
// ── Command Palette ──────────────────────────
const _cmdCommands = [
// Navigation
{ id: 'new-chat', label: 'New Chat', group: 'Navigation', hint: '', icon: '', action: () => newChat() },
{ id: 'search-chats', label: 'Search Chats', group: 'Navigation', hint: '⌘⇧S', icon: '', action: () => { const sb = document.getElementById('sidebar'); if (sb.classList.contains('collapsed')) UI.toggleSidebar(); document.getElementById('chatSearchInput')?.focus(); } },
{ id: 'toggle-sidebar', label: 'Toggle Sidebar', group: 'Navigation', hint: '', icon: '', action: () => UI.toggleSidebar() },
// Tools
{ id: 'notes', label: 'Open Notes', group: 'Tools', hint: '', icon: '', action: () => openNotes() },
{ id: 'export-md', label: 'Export Chat (Markdown)', group: 'Tools', hint: '', icon: '', action: () => UI.exportChat('md') },
{ id: 'export-json', label: 'Export Chat (JSON)', group: 'Tools', hint: '', icon: '', action: () => UI.exportChat('json') },
// Settings
{ id: 'settings', label: 'Settings', group: 'Settings', hint: '', icon: '', action: () => UI.openSettings() },
{ id: 'admin', label: 'Admin Panel', group: 'Settings', hint: '', icon: '', action: () => UI.openAdmin(), visible: () => API.user?.role === 'admin' },
{ id: 'debug', label: 'Debug Log', group: 'Settings', hint: '', icon: '', action: () => openDebugModal() },
// Account
{ id: 'sign-out', label: 'Sign Out', group: 'Account', hint: '', icon: '', action: () => handleLogout() },
];
let _cmdActiveIndex = 0;
function toggleCmdPalette() {
const el = document.getElementById('cmdPalette');
if (el.classList.contains('active')) { closeCmdPalette(); return; }
openCmdPalette();
}
function openCmdPalette() {
const el = document.getElementById('cmdPalette');
const input = document.getElementById('cmdInput');
el.classList.add('active');
input.value = '';
_cmdActiveIndex = 0;
_renderCmdResults('');
input.focus();
// Wire up input events (use named handler to avoid duplicates)
input.oninput = () => { _cmdActiveIndex = 0; _renderCmdResults(input.value); };
input.onkeydown = _handleCmdKey;
// Close on overlay click
el.onclick = (e) => { if (e.target === el) closeCmdPalette(); };
}
function closeCmdPalette() {
const el = document.getElementById('cmdPalette');
el.classList.remove('active');
document.getElementById('cmdInput').oninput = null;
document.getElementById('cmdInput').onkeydown = null;
}
function _handleCmdKey(e) {
const results = document.getElementById('cmdResults');
const items = results.querySelectorAll('.cmd-item');
if (!items.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
_cmdActiveIndex = (_cmdActiveIndex + 1) % items.length;
_highlightCmdItem(items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
_cmdActiveIndex = (_cmdActiveIndex - 1 + items.length) % items.length;
_highlightCmdItem(items);
} else if (e.key === 'Enter') {
e.preventDefault();
items[_cmdActiveIndex]?.click();
}
}
function _highlightCmdItem(items) {
items.forEach((it, i) => it.classList.toggle('active', i === _cmdActiveIndex));
items[_cmdActiveIndex]?.scrollIntoView({ block: 'nearest' });
}
function _getVisibleCommands() {
return _cmdCommands.filter(c => !c.visible || c.visible());
}
function _renderCmdResults(query) {
const el = document.getElementById('cmdResults');
const q = query.trim().toLowerCase();
let commands = _getVisibleCommands();
// Add recent chats as commands when searching
if (q) {
const chatMatches = App.chats
.filter(c => (c.title || '').toLowerCase().includes(q))
.slice(0, 5)
.map(c => ({
id: 'chat-' + c.id,
label: c.title || 'Untitled',
group: 'Chats',
hint: _relativeTime(c.updatedAt),
icon: '',
action: () => selectChat(c.id),
}));
commands = [...chatMatches, ...commands];
}
// Filter by query
if (q) {
commands = commands.filter(c => c.label.toLowerCase().includes(q));
}
if (commands.length === 0) {
el.innerHTML = '