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-24 14:50:53 +00:00

2739 lines
117 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
// ==========================================
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 =
`<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();
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 = '&#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 (!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 = '<option value="">Loading...</option>';
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 = '<option value="">Enter user ID or ask sys-admin</option>';
} catch (e) { sel.innerHTML = '<option value="">Error</option>'; }
});
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 = '<option value="">Select base model...</option>';
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: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>', action: () => newChat() },
{ id: 'search-chats', label: 'Search Chats', group: 'Navigation', hint: '⌘⇧S', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>', 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: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>', action: () => UI.toggleSidebar() },
// Tools
{ id: 'notes', label: 'Open Notes', group: 'Tools', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>', action: () => openNotes() },
{ id: 'export-md', label: 'Export Chat (Markdown)', group: 'Tools', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>', action: () => UI.exportChat('md') },
{ id: 'export-json', label: 'Export Chat (JSON)', group: 'Tools', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>', action: () => UI.exportChat('json') },
// Settings
{ id: 'settings', label: 'Settings', group: 'Settings', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>', action: () => UI.openSettings() },
{ id: 'admin', label: 'Admin Panel', group: 'Settings', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>', action: () => UI.openAdmin(), visible: () => API.user?.role === 'admin' },
{ id: 'debug', label: 'Debug Log', group: 'Settings', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 11V6a2 2 0 0 0-2-2h-1a2 2 0 0 0-2 2"/><path d="M9 6a2 2 0 0 0-2 2v3"/><circle cx="12" cy="14" r="6"/></svg>', action: () => openDebugModal() },
// Account
{ id: 'sign-out', label: 'Sign Out', group: 'Account', hint: '', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>', 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: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
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 = '<div class="cmd-group-label" style="padding:16px 12px;text-transform:none;letter-spacing:0">No matching commands</div>';
return;
}
// Clamp active index
_cmdActiveIndex = Math.min(_cmdActiveIndex, commands.length - 1);
// Render grouped
let html = '';
let lastGroup = '';
commands.forEach((c, i) => {
if (c.group !== lastGroup) {
lastGroup = c.group;
html += `<div class="cmd-group-label">${c.group}</div>`;
}
html += `<div class="cmd-item${i === _cmdActiveIndex ? ' active' : ''}" data-cmd="${c.id}">
${c.icon}
<span class="cmd-item-label">${c.label}</span>
${c.hint ? `<span class="cmd-item-hint">${c.hint}</span>` : ''}
</div>`;
});
el.innerHTML = html;
// Attach click handlers
el.querySelectorAll('.cmd-item').forEach((item, i) => {
item.addEventListener('click', () => {
closeCmdPalette();
commands[i].action();
});
item.addEventListener('mouseenter', () => {
_cmdActiveIndex = i;
_highlightCmdItem(el.querySelectorAll('.cmd-item'));
});
});
}
// ── Settings Handlers ────────────────────────
function handleSaveSettings() {
App.settings.model = document.getElementById('settingsModel').value || App.settings.model;
App.settings.systemPrompt = document.getElementById('settingsSystemPrompt').value.trim();
App.settings.maxTokens = parseInt(document.getElementById('settingsMaxTokens').value) || 0; // 0 = auto
App.settings.temperature = parseFloat(document.getElementById('settingsTemp').value) || 0.7;
App.settings.showThinking = document.getElementById('settingsThinking').checked;
saveSettings();
UI.updateModelSelector();
UI.updateCapabilityBadges();
UI.closeSettings();
UI.toast('Settings saved', 'success');
}
async function handleChangePassword() {
const cur = document.getElementById('profileCurrentPw').value;
const pw = document.getElementById('profileNewPw').value;
if (!cur || !pw) return UI.toast('Fill in both fields', 'warning');
if (pw.length < 8) return UI.toast('Min 8 characters', 'warning');
try {
await API.changePassword(cur, pw);
UI.toast('Password updated', 'success');
document.getElementById('profileChangePwForm').style.display = 'none';
document.getElementById('profileCurrentPw').value = '';
document.getElementById('profileNewPw').value = '';
} catch (e) { UI.toast(e.message, 'error'); }
}
async function handleCreateProvider() {
const name = document.getElementById('providerName').value.trim();
const provider = document.getElementById('providerType').value;
const endpoint = document.getElementById('providerEndpoint').value.trim();
const apiKey = document.getElementById('providerApiKey').value.trim();
const model = document.getElementById('providerDefaultModel').value.trim();
const editId = UI._editingProviderId;
if (editId) {
// Update mode — api_key optional (blank = keep existing)
if (!name || !endpoint) return UI.toast('Fill in required fields', 'warning');
const patch = { name, provider, endpoint, model_default: model };
if (apiKey) patch.api_key = apiKey;
try {
await API.updateConfig(editId, patch);
UI.toast('Provider updated', 'success');
UI._editingProviderId = null;
document.getElementById('providerApiKey').placeholder = 'API key';
UI.hideProviderForm();
UI.loadProviderList();
fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
} else {
// Create mode
if (!name || !endpoint || !apiKey) return UI.toast('Fill in required fields', 'warning');
try {
const result = await API.createConfig(name, provider, endpoint, apiKey, model);
if (result.warning) {
UI.toast(`Provider added — ${result.warning}`, 'warning');
} else {
const count = result.models_fetched || 0;
UI.toast(`Provider added — ${count} model${count !== 1 ? 's' : ''} synced`, 'success');
}
UI.hideProviderForm();
UI.loadProviderList();
fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
}
async function deleteProvider(id, name) {
if (!confirm(`Remove provider "${name}"?`)) return;
try {
await API.deleteConfig(id);
UI.toast('Provider removed', 'success');
UI.loadProviderList();
fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
async function refreshProviderModels(id, name) {
try {
UI.toast(`Fetching models for ${name}...`, 'info');
const result = await API.fetchProviderModels(id);
const total = result.total || 0;
UI.toast(`${name}: ${total} model${total !== 1 ? 's' : ''} synced`, 'success');
fetchModels(); // refresh the model selector
} catch (e) {
UI.toast(`Failed to fetch models: ${e.message}`, 'error');
}
}
async function editProvider(id) {
try {
const cfg = await API.getConfig(id);
// Populate the add form with existing values
document.getElementById('providerName').value = cfg.name || '';
document.getElementById('providerType').value = cfg.provider || 'openai';
document.getElementById('providerEndpoint').value = cfg.endpoint || '';
document.getElementById('providerApiKey').value = ''; // Don't expose key
document.getElementById('providerApiKey').placeholder = cfg.has_key ? '(unchanged — leave blank to keep)' : 'API key';
document.getElementById('providerDefaultModel').value = cfg.model_default || '';
// Show form and switch to edit mode
UI.showProviderForm();
UI._editingProviderId = id;
} catch (e) { UI.toast(e.message, 'error'); }
}
async function handleSaveAdminSettings() {
try {
// Policies — send as string "true"/"false" so backend routes to platform_policies
const reg = document.getElementById('adminRegToggle').checked;
await API.adminUpdateSetting('allow_registration', { value: reg ? 'true' : 'false' });
// Registration default state → default_user_active policy
const regState = document.getElementById('adminRegDefaultState').value;
await API.adminUpdateSetting('default_user_active', { value: regState === 'active' ? 'true' : 'false' });
// User BYOK providers → allow_user_byok policy
const userProviders = document.getElementById('adminUserProvidersToggle').checked;
await API.adminUpdateSetting('allow_user_byok', { value: userProviders ? 'true' : 'false' });
// User presets → allow_user_personas policy
const userPresets = document.getElementById('adminUserPresetsToggle').checked;
await API.adminUpdateSetting('allow_user_personas', { value: userPresets ? 'true' : 'false' });
// Default model → default_model policy
const defaultModel = document.getElementById('adminDefaultModel').value;
await API.adminUpdateSetting('default_model', { value: defaultModel });
// Banner → global_settings (JSON value)
const banner = {
enabled: document.getElementById('adminBannerEnabled').checked,
text: document.getElementById('adminBannerText').value,
position: document.getElementById('adminBannerPosition').value,
bg: document.getElementById('adminBannerBg').value,
fg: document.getElementById('adminBannerFg').value,
};
await API.adminUpdateSetting('banner', { value: banner });
UI.toast('Settings saved', 'success');
// Live-apply: refresh policies and dependent UI
await initBanners();
UI.checkUserProvidersAllowed();
UI.checkUserPresetsAllowed();
fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Admin Actions ────────────────────────────
function _adminScroll() { return document.querySelector('#adminModal .modal-body'); }
function _restoreScroll(el, pos) { if (el) requestAnimationFrame(() => { el.scrollTop = pos; }); }
async function toggleUserActive(id, active) {
try {
const el = _adminScroll(), pos = el?.scrollTop || 0;
await API.adminToggleActive(id, active); UI.toast(`User ${active ? 'enabled' : 'disabled'}`, 'success'); await UI.loadAdminUsers();
_restoreScroll(el, pos);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function showApproveForm(userId, username) {
const form = document.getElementById(`approveForm-${userId}`);
const teamsEl = document.getElementById(`approveTeams-${userId}`);
if (!form || !teamsEl) return;
// Load teams for checkboxes
teamsEl.innerHTML = '<span class="text-muted">Loading teams...</span>';
form.style.display = '';
try {
const resp = await API.adminListTeams();
const teams = (resp.data || []).filter(t => t.is_active);
if (teams.length === 0) {
teamsEl.innerHTML = '<span class="text-muted">No teams — user will be activated without team assignment</span>';
} else {
teamsEl.innerHTML = '<label class="form-label" style="font-size:12px;margin-bottom:4px">Assign to teams:</label>' +
teams.map(t => `<label class="checkbox-label" style="font-size:13px"><input type="checkbox" value="${t.id}" class="approve-team-cb"> ${esc(t.name)}</label>`).join('');
}
} catch (e) { teamsEl.innerHTML = `<span class="text-muted">Could not load teams</span>`; }
}
function hideApproveForm(userId) {
const form = document.getElementById(`approveForm-${userId}`);
if (form) form.style.display = 'none';
}
async function submitApproval(userId) {
const form = document.getElementById(`approveForm-${userId}`);
const teamIds = [...(form?.querySelectorAll('.approve-team-cb:checked') || [])].map(cb => cb.value);
try {
const el = _adminScroll(), pos = el?.scrollTop || 0;
await API.adminToggleActive(userId, true, teamIds, 'member');
const msg = teamIds.length ? `User activated & assigned to ${teamIds.length} team(s)` : 'User activated';
UI.toast(msg, 'success');
await UI.loadAdminUsers();
_restoreScroll(el, pos);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function toggleUserRole(id, role) {
try {
const el = _adminScroll(), pos = el?.scrollTop || 0;
await API.adminUpdateRole(id, role); UI.toast(`Role updated to ${role}`, 'success'); await UI.loadAdminUsers();
_restoreScroll(el, pos);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteUser(id, username) {
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
try { await API.adminDeleteUser(id); UI.toast('User deleted', 'success'); await UI.loadAdminUsers(); }
catch (e) { UI.toast(e.message, 'error'); }
}
async function createAdminUser() {
try {
const u = document.getElementById('adminNewUsername').value.trim();
const e = document.getElementById('adminNewEmail').value.trim();
const p = document.getElementById('adminNewPassword').value;
const r = document.getElementById('adminNewRole').value;
if (!u || !e || !p) { UI.toast('All fields required', 'warning'); return; }
await API.adminCreateUser(u, e, p, r);
document.getElementById('adminAddUserForm').style.display = 'none';
UI.toast('User created', 'success');
await UI.loadAdminUsers();
} catch (e) { UI.toast(e.message, 'error'); }
}
async function adminResetUserPassword(id, username) {
const pw = prompt(
`Reset password for "${username}"?\n\n` +
`⚠️ WARNING: This will DESTROY the user's personal vault.\n` +
`All personal API keys (BYOK) will be permanently deleted.\n` +
`The user will need to re-add any personal provider keys.\n\n` +
`Enter new password (min 8 chars):`
);
if (!pw) return;
if (pw.length < 8) { UI.toast('Password must be at least 8 characters', 'warning'); return; }
if (!confirm(`Final confirmation: Reset password AND destroy personal vault for "${username}"?`)) return;
try {
await API.adminResetPassword(id, pw);
UI.toast(`Password reset for ${username}. Vault destroyed.`, 'success');
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteGlobalProvider(id) {
if (!confirm('Delete this global provider?')) return;
try { await API.adminDeleteGlobalConfig(id); UI.toast('Provider deleted', 'success'); await UI.loadAdminProviders(); }
catch (e) { UI.toast(e.message, 'error'); }
}
var _editingProviderId = null;
function editGlobalProvider(id) {
const c = UI._providerCache?.[id];
if (!c) return;
_editingProviderId = id;
const form = document.getElementById('adminAddProviderForm');
form.style.display = '';
document.getElementById('adminProvName').value = c.name || '';
document.getElementById('adminProvType').value = c.provider || 'openai';
document.getElementById('adminProvEndpoint').value = c.endpoint || '';
document.getElementById('adminProvKey').value = '';
document.getElementById('adminProvKey').placeholder = c.has_key ? '(unchanged)' : 'sk-...';
document.getElementById('adminProvModel').value = c.model_default || '';
document.getElementById('adminProvPrivate').checked = !!c.is_private;
document.getElementById('adminCreateProvBtn').textContent = 'Update';
}
async function createGlobalProvider() {
try {
const name = document.getElementById('adminProvName').value.trim();
const prov = document.getElementById('adminProvType').value;
const ep = document.getElementById('adminProvEndpoint').value.trim();
const key = document.getElementById('adminProvKey').value;
const model = document.getElementById('adminProvModel').value.trim();
const isPrivate = document.getElementById('adminProvPrivate').checked;
if (_editingProviderId) {
// Update mode
const updates = {};
if (name) updates.name = name;
if (ep) updates.endpoint = ep;
if (key) updates.api_key = key;
updates.model_default = model;
updates.is_private = isPrivate;
await API.adminUpdateGlobalConfig(_editingProviderId, updates);
UI.toast('Provider updated', 'success');
} else {
// Create mode
if (!name || !ep || !key) { UI.toast('Name, endpoint, and API key required', 'warning'); return; }
await API.adminCreateGlobalConfig(name, prov, ep, key, model, isPrivate);
UI.toast('Provider added', 'success');
}
_editingProviderId = null;
document.getElementById('adminAddProviderForm').style.display = 'none';
document.getElementById('adminCreateProvBtn').textContent = 'Save';
document.getElementById('adminProvKey').placeholder = 'sk-...';
await UI.loadAdminProviders();
} catch (e) { UI.toast(e.message, 'error'); }
}
async function fetchAdminModels() {
const hint = document.getElementById('adminModelsHint');
hint.textContent = 'Fetching...';
try {
const resp = await API.adminFetchModels();
const errs = resp.errors || [];
if (errs.length > 0) {
UI.toast('Fetch errors: ' + errs.join('; '), 'error');
} else {
UI.toast(`Models synced — ${resp.added || 0} added, ${resp.updated || 0} updated`, 'success');
}
hint.textContent = '';
await UI.loadAdminModels();
}
catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
}
async function cycleModelVisibility(id, current) {
// Cycle: disabled → enabled → team → disabled
const next = current === 'disabled' ? 'enabled' : current === 'enabled' ? 'team' : 'disabled';
try {
const el = _adminScroll(), pos = el?.scrollTop || 0;
// Send both for backward compat with pre-0.8.3 backends
await API.adminUpdateModel(id, { visibility: next, is_enabled: next === 'enabled' });
await UI.loadAdminModels();
_restoreScroll(el, pos);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function bulkSetVisibility(visibility) {
const hint = document.getElementById('adminModelsHint');
const labels = { enabled: 'Enabling', disabled: 'Disabling', team: 'Setting team-only for' };
hint.textContent = `${labels[visibility] || 'Updating'} all...`;
try {
// Send both for backward compat with pre-0.8.3 backends
const resp = await API.adminBulkUpdateModels(visibility);
const doneLabels = { enabled: 'enabled', disabled: 'disabled', team: 'set to team-only' };
hint.textContent = `${resp.count || 'All'} models ${doneLabels[visibility]}`;
setTimeout(() => { hint.textContent = ''; }, 3000);
await UI.loadAdminModels();
} catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
}
// ── Admin Roles ────────────────────────────
async function adminRoleProviderChanged(role, slot) {
const provId = document.getElementById(`role-${role}-${slot}-provider`)?.value;
const modelSelect = document.getElementById(`role-${role}-${slot}-model`);
if (!modelSelect) return;
modelSelect.innerHTML = '<option value="">— select model —</option>';
if (!provId || !window._adminModelList) return;
const models = window._adminModelList.filter(m => m.provider_config_id === provId);
models.forEach(m => {
const opt = document.createElement('option');
opt.value = m.model_id;
opt.textContent = m.display_name || m.model_id;
modelSelect.appendChild(opt);
});
}
async function adminSaveRole(role) {
const status = document.getElementById(`role-${role}-status`);
try {
const getBinding = (slot) => {
const prov = document.getElementById(`role-${role}-${slot}-provider`)?.value;
const model = document.getElementById(`role-${role}-${slot}-model`)?.value;
return (prov && model) ? { provider_config_id: prov, model_id: model } : null;
};
await API.adminUpdateRole(role, {
primary: getBinding('primary'),
fallback: getBinding('fallback')
});
if (status) { status.textContent = '✓ Saved'; status.style.color = 'var(--success)'; }
setTimeout(() => { if (status) status.textContent = ''; }, 3000);
} catch (e) {
if (status) { status.textContent = '✗ ' + e.message; status.style.color = 'var(--error)'; }
}
}
async function adminTestRole(role) {
const status = document.getElementById(`role-${role}-status`);
if (status) { status.textContent = 'Testing...'; status.style.color = 'var(--text-muted)'; }
try {
const result = await API.adminTestRole(role);
if (status) {
const fb = result.used_fallback ? ' (fallback)' : '';
status.textContent = `${result.model}${fb}`;
status.style.color = 'var(--success)';
}
} catch (e) {
if (status) { status.textContent = '✗ ' + e.message; status.style.color = 'var(--error)'; }
}
}
// ── User Model Preferences ──────────────────
async function toggleUserModelVisibility(modelId, currentlyHidden) {
try {
await API.setModelPreference(modelId, !currentlyHidden);
if (currentlyHidden) App.hiddenModels.delete(modelId);
else App.hiddenModels.add(modelId);
await UI.loadUserModels();
await fetchModels(); // refresh model selector
} catch (e) { UI.toast(e.message, 'error'); }
}
async function bulkSetUserModelVisibility(show) {
const models = App.models.filter(m => !m.isPreset);
if (!models.length) return;
const shouldHide = !show;
// Only update models not already in the desired state
const toUpdate = models
.map(m => m.baseModelId || m.id)
.filter(mid => App.hiddenModels.has(mid) !== shouldHide);
if (!toUpdate.length) { UI.toast(show ? 'All already visible' : 'All already hidden'); return; }
try {
await API.bulkSetModelPreferences(toUpdate, shouldHide);
toUpdate.forEach(mid => shouldHide ? App.hiddenModels.add(mid) : App.hiddenModels.delete(mid));
await UI.loadUserModels();
await fetchModels();
UI.toast(show ? `${toUpdate.length} model(s) shown` : `${toUpdate.length} model(s) hidden`);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteUserPreset(id, name) {
if (!confirm(`Delete preset "${name}"?`)) return;
try {
await API.deleteUserPreset(id);
UI.toast('Preset deleted');
await UI.loadUserPresets();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Admin Presets ────────────────────────────
var _adminPresetForm = null;
function ensureAdminPresetForm() {
if (_adminPresetForm) return _adminPresetForm;
const container = document.getElementById('adminAddPresetForm');
if (!container) return null;
_adminPresetForm = renderPresetForm(container, {
prefix: 'adminPreset',
showAvatar: true,
showProviderConfig: true,
onSubmit: (vals) => createAdminPreset(vals),
onCancel: () => {
container.style.display = 'none';
_editingPresetId = null;
_adminPresetForm.setSubmitLabel('Create');
_adminPresetForm.clearForm();
}
});
// Override avatar handlers for edit-mode (upload immediately for existing presets)
const fileInput = document.getElementById('adminPreset_avatarFileInput');
if (fileInput) {
// Remove default handler set by renderPresetForm, add edit-aware one
const newInput = fileInput.cloneNode(true);
fileInput.parentNode.replaceChild(newInput, fileInput);
newInput.addEventListener('change', async function() {
const file = this.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async () => {
if (_editingPresetId) {
try {
const data = await API.adminUploadPresetAvatar(_editingPresetId, reader.result);
if (data.avatar) {
_adminPresetForm.updateAvatarPreview(data.avatar);
await UI.loadAdminPresets(true);
await fetchModels();
UI.toast('Preset avatar updated');
}
} catch (e) { UI.toast(e.message || 'Upload failed', 'error'); }
} else {
_adminPresetForm.updateAvatarPreview(reader.result);
}
};
reader.readAsDataURL(file);
this.value = '';
});
document.getElementById('adminPreset_avatarUploadBtn')?.addEventListener('click', () => newInput.click());
}
const removeBtn = document.getElementById('adminPreset_avatarRemoveBtn');
if (removeBtn) {
const newBtn = removeBtn.cloneNode(true);
removeBtn.parentNode.replaceChild(newBtn, removeBtn);
newBtn.addEventListener('click', async () => {
if (_editingPresetId) {
try {
await API.adminDeletePresetAvatar(_editingPresetId);
_adminPresetForm.updateAvatarPreview(null);
await UI.loadAdminPresets(true);
await fetchModels();
UI.toast('Preset avatar removed');
} catch (e) { UI.toast(e.message || 'Remove failed', 'error'); }
} else {
_adminPresetForm.updateAvatarPreview(null);
}
});
}
return _adminPresetForm;
}
var _editingPresetId = null;
function editAdminPreset(id) {
const p = UI._presetCache?.[id];
if (!p) return;
_editingPresetId = id;
ensureAdminPresetForm();
const form = _adminPresetForm;
if (!form) return;
document.getElementById('adminAddPresetForm').style.display = '';
form.setValues(p);
form.setSubmitLabel('Update');
}
async function createAdminPreset(vals) {
if (!vals) {
if (_adminPresetForm) vals = _adminPresetForm.getValues();
else return;
}
if (!vals.name || !vals.base_model_id) { UI.toast('Name and base model are required', 'warning'); return; }
const preset = {
name: vals.name,
base_model_id: vals.base_model_id,
description: vals.description || '',
system_prompt: vals.system_prompt || '',
};
if (vals.provider_config_id) preset.provider_config_id = vals.provider_config_id;
if (vals.temperature != null) preset.temperature = vals.temperature;
if (vals.max_tokens != null) preset.max_tokens = vals.max_tokens;
try {
let presetId = _editingPresetId;
if (_editingPresetId) {
await API.adminUpdatePreset(_editingPresetId, preset);
UI.toast('Preset updated', 'success');
} else {
const result = await API.adminCreatePreset(preset);
presetId = result?.id || result?.preset?.id;
UI.toast('Preset created', 'success');
}
// Upload pending avatar for new presets
if (!_editingPresetId && presetId && vals._pendingAvatar) {
try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); }
catch (e) { console.warn('Preset avatar upload failed:', e.message); }
}
_editingPresetId = null;
document.getElementById('adminAddPresetForm').style.display = 'none';
if (_adminPresetForm) {
_adminPresetForm.setSubmitLabel('Create');
_adminPresetForm.clearForm();
}
await UI.loadAdminPresets();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
async function toggleAdminPreset(id, active) {
try {
const el = _adminScroll(), pos = el?.scrollTop || 0;
await API.adminUpdatePreset(id, { is_active: active });
await UI.loadAdminPresets(true);
_restoreScroll(el, pos);
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteAdminPreset(id, name) {
if (!confirm(`Delete preset "${name}"?`)) return;
try {
await API.adminDeletePreset(id);
UI.toast('Preset deleted', 'success');
await UI.loadAdminPresets();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Team Admin Functions ────────────────────
async function toggleTeamActive(id, active) {
try {
await API.adminUpdateTeam(id, { is_active: active });
UI.toast(active ? 'Team activated' : 'Team deactivated');
await UI.loadAdminTeams(true);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteTeam(id, name) {
if (!confirm(`Delete team "${name}"? All members will be removed.`)) return;
try {
await API.adminDeleteTeam(id);
UI.toast('Team deleted');
await UI.loadAdminTeams(true);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function updateTeamMember(teamId, memberId, role) {
try {
await API.adminUpdateMember(teamId, memberId, role);
UI.toast('Role updated');
} catch (e) {
UI.toast(e.message, 'error');
await UI.loadTeamMembers(teamId);
}
}
async function removeTeamMember(teamId, memberId, email) {
if (!confirm(`Remove ${email} from team?`)) return;
try {
await API.adminRemoveMember(teamId, memberId);
UI.toast('Member removed');
await UI.loadTeamMembers(teamId);
} catch (e) { UI.toast(e.message, 'error'); }
}
// Settings-side team management (uses team admin API, not sys-admin)
async function settingsUpdateTeamMember(teamId, memberId, role) {
try {
await API.teamUpdateMember(teamId, memberId, role);
UI.toast('Role updated');
} catch (e) {
UI.toast(e.message, 'error');
await UI.loadTeamManageMembers(teamId);
}
}
async function settingsRemoveTeamMember(teamId, memberId, email) {
if (!confirm(`Remove ${email} from team?`)) return;
try {
await API.teamRemoveMember(teamId, memberId);
UI.toast('Member removed');
await UI.loadTeamManageMembers(teamId);
} catch (e) { UI.toast(e.message, 'error'); }
}
async function settingsDeleteTeamPreset(teamId, presetId, name) {
if (!confirm(`Delete team preset "${name}"?`)) return;
try {
await API.teamDeletePreset(teamId, presetId);
UI.toast('Preset deleted');
await UI.loadTeamManagePresets(teamId);
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
async function settingsToggleTeamProvider(teamId, providerId, currentlyActive) {
try {
await API.teamUpdateProvider(teamId, providerId, { is_active: !currentlyActive });
UI.toast(currentlyActive ? 'Provider deactivated' : 'Provider activated');
await UI.loadTeamManageProviders(teamId);
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
async function settingsDeleteTeamProvider(teamId, providerId, name) {
if (!confirm(`Delete team provider "${name}"? This will remove all models from this provider for your team.`)) return;
try {
await API.teamDeleteProvider(teamId, providerId);
UI.toast('Provider deleted');
await UI.loadTeamManageProviders(teamId);
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
function settingsEditTeamProvider(teamId, provId, name, endpoint, defaultModel, isPrivate) {
UI._editingTeamProviderId = provId;
document.getElementById('settingsTeamAddProvider').style.display = 'none';
const form = document.getElementById('settingsTeamEditProvider');
form.style.display = '';
document.getElementById('teamProviderEditName').value = name;
document.getElementById('teamProviderEditEndpoint').value = endpoint;
document.getElementById('teamProviderEditKey').value = '';
document.getElementById('teamProviderEditDefault').value = defaultModel;
document.getElementById('teamProviderEditPrivate').checked = isPrivate;
}
// ── Notes Panel ─────────────────────────────
var _editingNoteId = null;
var _notesSelectMode = false;
var _selectedNoteIds = new Set();
var _notesSort = 'updated_desc';
async function openNotes() {
openSidePanel('notes');
_exitSelectMode();
await loadNotesList();
await loadNoteFolders();
}
async function loadNotesList(folder, searchQuery) {
const list = document.getElementById('notesList');
list.innerHTML = '<div class="notes-loading">Loading…</div>';
try {
let data;
if (searchQuery) {
data = await API.searchNotes(searchQuery);
const results = data.data || [];
if (results.length === 0) {
list.innerHTML = '<div class="notes-empty">No results found</div>';
return;
}
list.innerHTML = results.map(n => _noteListItem(n, true)).join('');
} else {
const folderVal = folder || document.getElementById('notesFolderFilter')?.value || '';
data = await API.listNotes(100, 0, folderVal, '', _notesSort);
const notes = data.data || [];
if (notes.length === 0) {
list.innerHTML = `<div class="notes-empty">${folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.'}</div>`;
return;
}
list.innerHTML = notes.map(n => _noteListItem(n, false)).join('');
}
} catch (e) {
list.innerHTML = `<div class="notes-empty">Failed to load: ${esc(e.message)}</div>`;
}
}
function _noteListItem(note, isSearch) {
const tags = (note.tags || []).map(t => `<span class="note-tag">${esc(t)}</span>`).join('');
const folder = note.folder_path && note.folder_path !== '/' ? `<span class="note-folder">${esc(note.folder_path)}</span>` : '';
const preview = isSearch && note.headline
? `<div class="note-headline">${_highlightHeadline(note.headline)}</div>`
: `<div class="note-preview">${esc((note.preview || note.content || '').slice(0, 120))}</div>`;
const time = _relativeTime(note.updated_at);
const checked = _selectedNoteIds.has(note.id) ? 'checked' : '';
return `
<div class="note-item ${_selectedNoteIds.has(note.id) ? 'selected' : ''}" data-note-id="${note.id}">
<div class="note-select-col" style="display:${_notesSelectMode ? '' : 'none'}">
<input type="checkbox" class="note-checkbox" data-id="${note.id}" ${checked}
onclick="event.stopPropagation(); _toggleNoteSelect('${note.id}', this.checked)">
</div>
<div class="note-content-col" onclick="${_notesSelectMode ? `_toggleNoteSelect('${note.id}')` : `openNoteEditor('${note.id}')`}">
<div class="note-item-header">
<span class="note-item-title">${esc(note.title)}</span>
<span class="note-item-time">${time}</span>
</div>
${preview}
<div class="note-item-meta">${folder}${tags}</div>
</div>
</div>`;
}
function _highlightHeadline(headline) {
const escaped = esc(headline);
return escaped.replace(/\*\*(.+?)\*\*/g, '<mark>$1</mark>');
}
// ── Multi-select ────────────────
function _enterSelectMode() {
_notesSelectMode = true;
_selectedNoteIds.clear();
document.getElementById('notesSelectionBar').style.display = '';
document.querySelectorAll('.note-select-col').forEach(el => el.style.display = '');
_updateSelectedCount();
}
function _exitSelectMode() {
_notesSelectMode = false;
_selectedNoteIds.clear();
document.getElementById('notesSelectionBar').style.display = 'none';
document.querySelectorAll('.note-select-col').forEach(el => el.style.display = 'none');
document.querySelectorAll('.note-item.selected').forEach(el => el.classList.remove('selected'));
document.querySelectorAll('.note-checkbox').forEach(cb => cb.checked = false);
const sa = document.getElementById('notesSelectAll');
if (sa) sa.checked = false;
}
function _toggleNoteSelect(id, forceState) {
if (!_notesSelectMode) {
_enterSelectMode();
}
const has = _selectedNoteIds.has(id);
const newState = forceState !== undefined ? forceState : !has;
if (newState) _selectedNoteIds.add(id);
else _selectedNoteIds.delete(id);
const item = document.querySelector(`.note-item[data-note-id="${id}"]`);
if (item) {
item.classList.toggle('selected', newState);
const cb = item.querySelector('.note-checkbox');
if (cb) cb.checked = newState;
}
_updateSelectedCount();
}
function _toggleSelectAll(checked) {
document.querySelectorAll('.note-checkbox').forEach(cb => {
const id = cb.dataset.id;
if (checked) _selectedNoteIds.add(id);
else _selectedNoteIds.delete(id);
cb.checked = checked;
cb.closest('.note-item')?.classList.toggle('selected', checked);
});
_updateSelectedCount();
}
function _updateSelectedCount() {
const el = document.getElementById('notesSelectedCount');
if (el) el.textContent = _selectedNoteIds.size;
}
async function _bulkDeleteSelected() {
if (_selectedNoteIds.size === 0) return;
const count = _selectedNoteIds.size;
if (!confirm(`Delete ${count} note${count > 1 ? 's' : ''}?`)) return;
try {
const resp = await API.bulkDeleteNotes([..._selectedNoteIds]);
UI.toast(`Deleted ${resp.deleted} note${resp.deleted !== 1 ? 's' : ''}`, 'success');
_exitSelectMode();
await loadNotesList();
await loadNoteFolders();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Folders / Editor / CRUD ─────
async function loadNoteFolders() {
const sel = document.getElementById('notesFolderFilter');
if (!sel) return;
try {
const data = await API.listNoteFolders();
const folders = data.folders || [];
sel.innerHTML = '<option value="">All folders</option>';
folders.forEach(f => {
sel.innerHTML += `<option value="${esc(f.path)}">${esc(f.path)} (${f.count})</option>`;
});
} catch (e) { /* non-critical */ }
}
function showNotesList() {
document.getElementById('notesListView').style.display = '';
document.getElementById('notesEditorView').style.display = 'none';
_editingNoteId = null;
}
var _currentNote = null; // cached note for read mode
function copyNoteContent() {
if (!_currentNote) return;
const text = `# ${_currentNote.title || ''}\n\n${_currentNote.content || ''}`;
navigator.clipboard.writeText(text).then(() => UI.toast('Copied', 'success'));
}
async function openNoteEditor(noteId) {
document.getElementById('notesListView').style.display = 'none';
document.getElementById('notesEditorView').style.display = '';
if (noteId) {
_editingNoteId = noteId;
try {
_currentNote = await API.getNote(noteId);
_populateEditFields(_currentNote);
_showNoteReadMode();
} catch (e) {
UI.toast('Failed to load note: ' + e.message, 'error');
showNotesList();
}
} else {
// New note — straight to edit mode
_editingNoteId = null;
_currentNote = null;
_clearEditFields();
_showNoteEditMode();
document.getElementById('noteEditorTitle').focus();
}
}
function _populateEditFields(note) {
document.getElementById('noteEditorTitle').value = note.title || '';
document.getElementById('noteEditorFolder').value = note.folder_path || '';
document.getElementById('noteEditorTags').value = (note.tags || []).join(', ');
document.getElementById('noteEditorContent').value = note.content || '';
}
function _clearEditFields() {
document.getElementById('noteEditorTitle').value = '';
document.getElementById('noteEditorFolder').value = '';
document.getElementById('noteEditorTags').value = '';
document.getElementById('noteEditorContent').value = '';
}
function _showNoteReadMode() {
if (!_currentNote) return;
const n = _currentNote;
// Title
document.getElementById('noteReadTitle').textContent = n.title || '';
// Meta: folder + tags
const parts = [];
if (n.folder_path && n.folder_path !== '/') parts.push(`<span class="note-folder">${esc(n.folder_path)}</span>`);
(n.tags || []).forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
// Rendered markdown content — reuse the chat formatter
document.getElementById('noteReadContent').innerHTML = formatMessage(n.content || '');
// Show/hide delete
document.getElementById('noteDeleteBtn2').style.display = _editingNoteId ? '' : 'none';
// Toggle visibility
document.getElementById('noteReadMode').style.display = '';
document.getElementById('noteEditMode').style.display = 'none';
document.getElementById('noteEditBtn').style.display = '';
document.getElementById('notePreviewBtn').style.display = 'none';
}
function _showNoteEditMode() {
document.getElementById('noteReadMode').style.display = 'none';
document.getElementById('noteEditMode').style.display = '';
document.getElementById('noteDeleteBtn').style.display = _editingNoteId ? '' : 'none';
document.getElementById('noteCancelEditBtn').style.display = _editingNoteId ? '' : 'none';
document.getElementById('noteEditBtn').style.display = 'none';
document.getElementById('notePreviewBtn').style.display = '';
}
function _showNotePreview() {
// Live preview from textarea without saving
const title = document.getElementById('noteEditorTitle').value.trim();
const content = document.getElementById('noteEditorContent').value;
const folderVal = document.getElementById('noteEditorFolder').value.trim();
const tagsStr = document.getElementById('noteEditorTags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
document.getElementById('noteReadTitle').textContent = title || 'Untitled';
const parts = [];
if (folderVal) parts.push(`<span class="note-folder">${esc(folderVal)}</span>`);
tags.forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
document.getElementById('noteReadContent').innerHTML = formatMessage(content || '');
document.getElementById('noteReadMode').style.display = '';
document.getElementById('noteEditMode').style.display = 'none';
document.getElementById('noteEditBtn').style.display = '';
document.getElementById('notePreviewBtn').style.display = 'none';
// Keep delete hidden in preview-from-edit
document.getElementById('noteDeleteBtn2').style.display = 'none';
}
async function saveNote() {
const title = document.getElementById('noteEditorTitle').value.trim();
const content = document.getElementById('noteEditorContent').value;
const folder = document.getElementById('noteEditorFolder').value.trim();
const tagsStr = document.getElementById('noteEditorTags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
if (!title) { UI.toast('Title is required', 'warning'); return; }
if (!content) { UI.toast('Content is required', 'warning'); return; }
try {
if (_editingNoteId) {
const updated = await API.updateNote(_editingNoteId, { title, content, folder_path: folder, tags, mode: 'replace' });
_currentNote = updated;
UI.toast('Note updated', 'success');
_showNoteReadMode();
} else {
const created = await API.createNote(title, content, folder, tags);
_editingNoteId = created.id;
_currentNote = created;
UI.toast('Note created', 'success');
_showNoteReadMode();
}
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteNote() {
if (!_editingNoteId) return;
if (!confirm('Delete this note?')) return;
try {
await API.deleteNote(_editingNoteId);
UI.toast('Note deleted', 'success');
showNotesList();
await loadNotesList();
await loadNoteFolders();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── 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 || {};
// 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);