2548 lines
111 KiB
JavaScript
2548 lines
111 KiB
JavaScript
// ==========================================
|
||
// 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: true,
|
||
systemPrompt: '',
|
||
maxTokens: 0, // 0 = auto from model capabilities
|
||
temperature: 0.7,
|
||
},
|
||
};
|
||
|
||
// ── Init ─────────────────────────────────────
|
||
|
||
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);
|
||
document.getElementById('splashError').textContent = 'Cannot reach server — check connection';
|
||
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 ───────────────────────────────────
|
||
|
||
// Client-side known model capabilities — used when backend hasn't synced yet.
|
||
// Mirrors server/providers/capabilities.go knownModels table.
|
||
const KNOWN_MODELS = {
|
||
'claude-opus-4': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:200000, max_output_tokens:32000 },
|
||
'claude-sonnet-4': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:200000, max_output_tokens:16000 },
|
||
'claude-3-5-sonnet': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:8192 },
|
||
'claude-3-5-haiku': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:8192 },
|
||
'claude-3-opus': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:4096 },
|
||
'claude-3-haiku': { streaming:true, tool_calling:true, vision:true, max_context:200000, max_output_tokens:4096 },
|
||
'gpt-4o': { streaming:true, tool_calling:true, vision:true, max_context:128000, max_output_tokens:16384 },
|
||
'gpt-4o-mini': { streaming:true, tool_calling:true, vision:true, max_context:128000, max_output_tokens:16384 },
|
||
'gpt-4-turbo': { streaming:true, tool_calling:true, vision:true, max_context:128000, max_output_tokens:4096 },
|
||
'o1': { streaming:true, reasoning:true, max_context:200000, max_output_tokens:100000 },
|
||
'o3': { streaming:true, tool_calling:true, reasoning:true, max_context:200000, max_output_tokens:100000 },
|
||
'o3-mini': { streaming:true, tool_calling:true, reasoning:true, max_context:200000, max_output_tokens:65536 },
|
||
'o4-mini': { streaming:true, tool_calling:true, reasoning:true, max_context:200000, max_output_tokens:100000 },
|
||
'gemini-2.5-pro': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:1048576, max_output_tokens:65536 },
|
||
'gemini-2.5-flash': { streaming:true, tool_calling:true, vision:true, thinking:true, max_context:1048576, max_output_tokens:65536 },
|
||
'gemini-2.0-flash': { streaming:true, tool_calling:true, vision:true, max_context:1048576, max_output_tokens:8192 },
|
||
'deepseek-r1': { streaming:true, reasoning:true, max_context:65536, max_output_tokens:8192 },
|
||
'deepseek-v3': { streaming:true, tool_calling:true, max_context:65536, max_output_tokens:8192 },
|
||
'deepseek-chat': { streaming:true, tool_calling:true, max_context:65536, max_output_tokens:8192 },
|
||
'llama-3.1-405b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:4096 },
|
||
'llama-3.1-70b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:4096 },
|
||
'llama-3.3-70b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:4096 },
|
||
'llama-4-maverick': { streaming:true, tool_calling:true, vision:true, max_context:1048576, max_output_tokens:16384 },
|
||
'llama-4-scout': { streaming:true, tool_calling:true, vision:true, max_context:524288, max_output_tokens:16384 },
|
||
'mistral-large': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:8192 },
|
||
'codestral': { streaming:true, tool_calling:true, code_optimized:true, max_context:262144, max_output_tokens:8192 },
|
||
'qwen-2.5-72b': { streaming:true, tool_calling:true, max_context:131072, max_output_tokens:8192 },
|
||
'qwq-32b': { streaming:true, reasoning:true, max_context:131072, max_output_tokens:8192 },
|
||
};
|
||
|
||
// Look up client-side capabilities by model ID with prefix matching
|
||
function lookupKnownCaps(modelId) {
|
||
const id = modelId.toLowerCase().replace(/^[^/]+\//, ''); // strip provider prefix
|
||
// Exact match
|
||
if (KNOWN_MODELS[id]) return { ...KNOWN_MODELS[id] };
|
||
// Prefix match (longest wins)
|
||
let best = null, bestLen = 0;
|
||
for (const key of Object.keys(KNOWN_MODELS)) {
|
||
if (id.startsWith(key) && key.length > bestLen) {
|
||
best = key; bestLen = key.length;
|
||
}
|
||
}
|
||
return best ? { ...KNOWN_MODELS[best] } : null;
|
||
}
|
||
|
||
// Merge: backend/provider caps are authoritative, client-side fills gaps only
|
||
function resolveCapabilities(backendCaps, modelId) {
|
||
const known = lookupKnownCaps(modelId) || {};
|
||
if (!backendCaps || Object.keys(backendCaps).length === 0) return known;
|
||
|
||
// Backend is authoritative — start with it
|
||
const caps = { ...backendCaps };
|
||
|
||
// Fill gaps (fields backend didn't report) from known table
|
||
for (const [k, v] of Object.entries(known)) {
|
||
if (caps[k] === undefined || caps[k] === null) {
|
||
caps[k] = v;
|
||
}
|
||
}
|
||
return caps;
|
||
}
|
||
|
||
async function fetchModels() {
|
||
try {
|
||
const data = await API.listEnabledModels();
|
||
// 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,
|
||
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'));
|
||
}
|
||
|
||
async function newChat() {
|
||
App.currentChatId = null;
|
||
UI.renderChatList();
|
||
UI.showEmptyState();
|
||
UI.showRegenerate(false);
|
||
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 (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,
|
||
siblingCount: m.sibling_count || 1,
|
||
siblingIndex: m.sibling_index || 0,
|
||
}));
|
||
chat.messageCount = chat.messages.length;
|
||
UI.renderMessages(chat.messages);
|
||
} 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;
|
||
|
||
App.isGenerating = true;
|
||
App.abortController = new AbortController();
|
||
UI.setGenerating(true);
|
||
|
||
try {
|
||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||
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 (msg.includes('provider error') && msg.includes('401')) {
|
||
UI.toast('Provider API key rejected — check Settings', 'error');
|
||
} else { UI.toast(msg, 'error'); }
|
||
}
|
||
} finally {
|
||
App.isGenerating = false;
|
||
App.abortController = null;
|
||
UI.setGenerating(false);
|
||
}
|
||
}
|
||
|
||
// Legacy: bottom-bar regenerate button targets the last assistant message
|
||
async function regenerate() {
|
||
if (App.isGenerating || !App.currentChatId) return;
|
||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||
if (!chat || chat.messages.length === 0) return;
|
||
|
||
// Find the last assistant message with an ID
|
||
for (let i = chat.messages.length - 1; i >= 0; i--) {
|
||
if (chat.messages[i].role === 'assistant' && chat.messages[i].id) {
|
||
return regenerateMessage(chat.messages[i].id);
|
||
}
|
||
}
|
||
UI.toast('No assistant message to regenerate', 'warning');
|
||
}
|
||
|
||
// ── Edit Message (creates sibling, triggers completion) ──
|
||
|
||
async function editMessage(messageId) {
|
||
if (App.isGenerating || !App.currentChatId) return;
|
||
|
||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||
if (!chat) return;
|
||
|
||
const msg = chat.messages.find(m => m.id === messageId);
|
||
if (!msg || msg.role !== 'user') return;
|
||
|
||
// Show inline edit UI
|
||
UI.showEditInline(messageId, msg.content);
|
||
}
|
||
|
||
async function submitEdit(messageId, newContent) {
|
||
if (App.isGenerating || !App.currentChatId) return;
|
||
if (!newContent.trim()) return;
|
||
|
||
const selectedId = UI.getModelValue();
|
||
const modelInfo = App.findModel(selectedId);
|
||
const model = modelInfo?.baseModelId || selectedId;
|
||
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
|
||
|
||
App.isGenerating = true;
|
||
App.abortController = new AbortController();
|
||
UI.setGenerating(true);
|
||
|
||
try {
|
||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||
|
||
// Step 1: Create sibling user message via edit endpoint
|
||
const editResp = await API.editMessage(App.currentChatId, messageId, newContent.trim());
|
||
|
||
// Step 2: Reload path to show the new edited message
|
||
await reloadActivePath();
|
||
|
||
// Step 3: Generate assistant response as child of the new edit.
|
||
// Uses regenerate endpoint (which handles user messages by creating
|
||
// a child response, not a sibling). This avoids the duplicate user
|
||
// message that streamCompletion would create.
|
||
const resp = await API.streamRegenerate(
|
||
App.currentChatId, editResp.id, App.abortController.signal,
|
||
model, presetId, modelInfo?.configId
|
||
);
|
||
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
|
||
|
||
// Step 4: Final reload to get the complete tree state
|
||
await reloadActivePath();
|
||
UI.showRegenerate(true);
|
||
} catch (e) {
|
||
if (e.name === 'AbortError') {
|
||
UI.toast('Generation stopped', 'warning');
|
||
await reloadActivePath();
|
||
} else {
|
||
console.error('Edit error:', e);
|
||
UI.toast(e.message || 'Edit failed', 'error');
|
||
await reloadActivePath();
|
||
}
|
||
} finally {
|
||
App.isGenerating = false;
|
||
App.abortController = null;
|
||
UI.setGenerating(false);
|
||
}
|
||
}
|
||
|
||
function cancelEdit() {
|
||
// Re-render to remove the edit form
|
||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||
if (chat) UI.renderMessages(chat.messages);
|
||
}
|
||
|
||
// ── Switch Branch (sibling navigation) ──────
|
||
|
||
async function switchSibling(messageId, direction) {
|
||
if (App.isGenerating || !App.currentChatId) return;
|
||
|
||
try {
|
||
// Get siblings for this message
|
||
const data = await API.listSiblings(App.currentChatId, messageId);
|
||
const siblings = data.siblings || [];
|
||
const currentIdx = data.current_index ?? 0;
|
||
|
||
const targetIdx = currentIdx + direction;
|
||
if (targetIdx < 0 || targetIdx >= siblings.length) return;
|
||
|
||
const targetSibling = siblings[targetIdx];
|
||
|
||
// Update cursor to the target sibling (backend walks to leaf)
|
||
const resp = await API.updateCursor(App.currentChatId, targetSibling.id);
|
||
|
||
// Update local state with the new path
|
||
const chat = App.chats.find(c => c.id === App.currentChatId);
|
||
if (chat && resp.path) {
|
||
chat.messages = resp.path.map(m => ({
|
||
id: m.id,
|
||
parent_id: m.parent_id || null,
|
||
role: m.role,
|
||
content: m.content,
|
||
model: m.model || '',
|
||
modelName: '',
|
||
timestamp: m.created_at,
|
||
siblingCount: m.sibling_count || 1,
|
||
siblingIndex: m.sibling_index || 0,
|
||
}));
|
||
chat.messageCount = chat.messages.length;
|
||
UI.renderMessages(chat.messages);
|
||
UI.showRegenerate(chat.messages.some(m => m.role === 'assistant'));
|
||
}
|
||
} catch (e) {
|
||
console.error('Branch switch failed:', e.message);
|
||
UI.toast('Failed to switch branch', 'error');
|
||
}
|
||
}
|
||
|
||
// ── Modal Helpers ───────────────────────────
|
||
|
||
function openModal(id) {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
el.classList.add('active');
|
||
// Defer overflow check — browser needs a frame to lay out the modal
|
||
requestAnimationFrame(() => {
|
||
el.querySelectorAll('.modal-tabs').forEach(checkTabsOverflow);
|
||
});
|
||
}
|
||
|
||
function closeModal(id) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.classList.remove('active');
|
||
}
|
||
|
||
// Toggle .is-scrollable on tab bars that overflow horizontally
|
||
// and inject arrow navigation buttons when needed.
|
||
function checkTabsOverflow(tabs) {
|
||
if (!tabs) return;
|
||
const scrollable = tabs.scrollWidth > tabs.clientWidth + 1;
|
||
|
||
if (!scrollable) {
|
||
// Remove wrapper if overflow went away (e.g. zoom decreased)
|
||
const wrap = tabs.parentElement;
|
||
if (wrap && wrap.classList.contains('modal-tabs-wrap')) {
|
||
wrap.parentElement.insertBefore(tabs, wrap);
|
||
wrap.remove();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Wrap if not already wrapped
|
||
if (!tabs.parentElement.classList.contains('modal-tabs-wrap')) {
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'modal-tabs-wrap';
|
||
tabs.parentElement.insertBefore(wrap, tabs);
|
||
wrap.appendChild(tabs);
|
||
|
||
// Inject arrows
|
||
const left = document.createElement('button');
|
||
left.className = 'tab-arrow tab-arrow-left';
|
||
left.innerHTML = '‹';
|
||
left.addEventListener('click', () => { tabs.scrollLeft -= 120; });
|
||
|
||
const right = document.createElement('button');
|
||
right.className = 'tab-arrow tab-arrow-right';
|
||
right.innerHTML = '›';
|
||
right.addEventListener('click', () => { tabs.scrollLeft += 120; });
|
||
|
||
wrap.appendChild(left);
|
||
wrap.appendChild(right);
|
||
|
||
// Update arrow visibility on scroll
|
||
tabs.addEventListener('scroll', () => updateTabArrows(tabs), { passive: true });
|
||
}
|
||
|
||
updateTabArrows(tabs);
|
||
}
|
||
|
||
function updateTabArrows(tabs) {
|
||
const wrap = tabs.parentElement;
|
||
if (!wrap || !wrap.classList.contains('modal-tabs-wrap')) return;
|
||
|
||
const left = wrap.querySelector('.tab-arrow-left');
|
||
const right = wrap.querySelector('.tab-arrow-right');
|
||
if (!left || !right) return;
|
||
|
||
const atStart = tabs.scrollLeft <= 2;
|
||
const atEnd = tabs.scrollLeft + tabs.clientWidth >= tabs.scrollWidth - 2;
|
||
|
||
left.classList.toggle('visible', !atStart);
|
||
right.classList.toggle('visible', !atEnd);
|
||
}
|
||
|
||
// ── Auth Flow ────────────────────────────────
|
||
|
||
function showSplash(health) {
|
||
document.getElementById('splashGate').style.display = 'flex';
|
||
document.getElementById('appContainer').style.display = 'none';
|
||
if (health && health.registration_enabled === false) {
|
||
document.getElementById('authTabRegister').style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function hideSplash() {
|
||
document.getElementById('splashGate').style.display = 'none';
|
||
document.getElementById('appContainer').style.display = '';
|
||
}
|
||
|
||
async function handleLogin() {
|
||
const login = document.getElementById('authLogin').value.trim();
|
||
const password = document.getElementById('authPassword').value;
|
||
if (!login || !password) return setAuthError('Fill in all fields');
|
||
setAuthLoading(true);
|
||
try { await API.login(login, password); await startApp(); }
|
||
catch (e) { setAuthError(e.message); }
|
||
finally { setAuthLoading(false); }
|
||
}
|
||
|
||
async function handleRegister() {
|
||
const username = document.getElementById('authUsername').value.trim();
|
||
const email = document.getElementById('authEmail').value.trim();
|
||
const password = document.getElementById('authRegPassword').value;
|
||
if (!username || !email || !password) return setAuthError('Fill in all fields');
|
||
if (password.length < 8) return setAuthError('Password must be at least 8 characters');
|
||
setAuthLoading(true);
|
||
try {
|
||
const resp = await API.register(username, email, password);
|
||
if (resp.pending) {
|
||
setAuthError('');
|
||
const errEl = document.getElementById('authError');
|
||
errEl.textContent = 'Account created — pending admin approval. You will be able to sign in once approved.';
|
||
errEl.style.color = 'var(--accent)';
|
||
setTimeout(() => { errEl.style.color = ''; switchAuthTab('login'); }, 5000);
|
||
} else {
|
||
await startApp();
|
||
}
|
||
}
|
||
catch (e) { setAuthError(e.message); }
|
||
finally { setAuthLoading(false); }
|
||
}
|
||
|
||
async function handleLogout() {
|
||
if (!confirm('Sign out?')) return;
|
||
Events.disconnect();
|
||
Events.clear();
|
||
await API.logout();
|
||
App.chats = [];
|
||
App.currentChatId = null;
|
||
location.reload();
|
||
}
|
||
|
||
function switchAuthTab(tab) {
|
||
document.getElementById('authTabLogin').classList.toggle('active', tab === 'login');
|
||
document.getElementById('authTabRegister').classList.toggle('active', tab === 'register');
|
||
document.getElementById('authLoginForm').style.display = tab === 'login' ? '' : 'none';
|
||
document.getElementById('authRegisterForm').style.display = tab === 'register' ? '' : 'none';
|
||
document.getElementById('authLoginBtn').style.display = tab === 'login' ? '' : 'none';
|
||
document.getElementById('authRegisterBtn').style.display = tab === 'register' ? '' : 'none';
|
||
const hdr = document.querySelector('.auth-card-header');
|
||
if (hdr) {
|
||
hdr.querySelector('h2').textContent = tab === 'login' ? 'Welcome back' : 'Create account';
|
||
hdr.querySelector('p').textContent = tab === 'login'
|
||
? 'Sign in to continue to your workspace'
|
||
: 'Get started with Chat Switchboard';
|
||
}
|
||
setAuthError('');
|
||
}
|
||
|
||
function setAuthError(msg) { document.getElementById('authError').textContent = msg; }
|
||
function setAuthLoading(on) {
|
||
document.querySelectorAll('#authLoginBtn, #authRegisterBtn').forEach(btn => {
|
||
btn.disabled = on;
|
||
btn.textContent = on ? 'Please wait...' : btn.dataset.label;
|
||
});
|
||
}
|
||
|
||
// ── Event Listeners ──────────────────────────
|
||
|
||
let _listenersInit = false;
|
||
function initListeners() {
|
||
if (_listenersInit) return;
|
||
_listenersInit = true;
|
||
|
||
// Sidebar
|
||
document.getElementById('sidebarToggle').addEventListener('click', UI.toggleSidebar);
|
||
document.getElementById('newChatBtn').addEventListener('click', newChat);
|
||
|
||
// Split button dropdown
|
||
document.getElementById('newChatDropBtn').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
document.getElementById('newChatDropdown').classList.toggle('open');
|
||
});
|
||
document.addEventListener('click', (e) => {
|
||
if (!e.target.closest('.split-btn')) {
|
||
document.getElementById('newChatDropdown').classList.remove('open');
|
||
}
|
||
});
|
||
|
||
// User flyout
|
||
document.getElementById('userMenuBtn').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
UI.toggleUserMenu();
|
||
});
|
||
document.addEventListener('click', (e) => {
|
||
if (!e.target.closest('.sidebar-bottom')) UI.closeUserMenu();
|
||
});
|
||
|
||
// Flyout items
|
||
document.getElementById('menuSettings').addEventListener('click', () => { UI.closeUserMenu(); UI.openSettings(); });
|
||
document.getElementById('menuAdmin')?.addEventListener('click', () => { UI.closeUserMenu(); UI.openAdmin(); });
|
||
document.getElementById('menuDebug')?.addEventListener('click', () => { UI.closeUserMenu(); openDebugModal(); });
|
||
document.getElementById('menuSignout').addEventListener('click', () => { UI.closeUserMenu(); handleLogout(); });
|
||
|
||
// Notes panel
|
||
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
|
||
document.getElementById('notesNewBtn')?.addEventListener('click', () => openNoteEditor(null));
|
||
document.getElementById('notesSelectModeBtn')?.addEventListener('click', () => {
|
||
if (_notesSelectMode) _exitSelectMode();
|
||
else _enterSelectMode();
|
||
});
|
||
document.getElementById('notesBackBtn')?.addEventListener('click', () => { showNotesList(); loadNotesList(); loadNoteFolders(); });
|
||
document.getElementById('noteSaveBtn')?.addEventListener('click', saveNote);
|
||
document.getElementById('noteDeleteBtn')?.addEventListener('click', deleteNote);
|
||
document.getElementById('noteDeleteBtn2')?.addEventListener('click', deleteNote);
|
||
document.getElementById('noteEditBtn')?.addEventListener('click', _showNoteEditMode);
|
||
document.getElementById('noteEditBtn2')?.addEventListener('click', _showNoteEditMode);
|
||
document.getElementById('notePreviewBtn')?.addEventListener('click', _showNotePreview);
|
||
document.getElementById('noteCancelEditBtn')?.addEventListener('click', () => {
|
||
if (_currentNote) { _populateEditFields(_currentNote); _showNoteReadMode(); }
|
||
else showNotesList();
|
||
});
|
||
document.getElementById('notesFolderFilter')?.addEventListener('change', (e) => {
|
||
document.getElementById('notesSearchInput').value = '';
|
||
_exitSelectMode();
|
||
loadNotesList(e.target.value);
|
||
});
|
||
document.getElementById('notesSortSelect')?.addEventListener('change', (e) => {
|
||
_notesSort = e.target.value;
|
||
_exitSelectMode();
|
||
loadNotesList();
|
||
});
|
||
document.getElementById('notesSelectAll')?.addEventListener('change', (e) => {
|
||
_toggleSelectAll(e.target.checked);
|
||
});
|
||
document.getElementById('notesDeleteSelectedBtn')?.addEventListener('click', _bulkDeleteSelected);
|
||
document.getElementById('notesCancelSelectBtn')?.addEventListener('click', _exitSelectMode);
|
||
|
||
// Notes search with debounce
|
||
let _notesSearchTimer;
|
||
document.getElementById('notesSearchInput')?.addEventListener('input', (e) => {
|
||
clearTimeout(_notesSearchTimer);
|
||
const q = e.target.value.trim();
|
||
_notesSearchTimer = setTimeout(() => {
|
||
_exitSelectMode();
|
||
if (q.length >= 2) {
|
||
document.getElementById('notesFolderFilter').value = '';
|
||
loadNotesList(null, q);
|
||
} else if (q.length === 0) {
|
||
loadNotesList();
|
||
}
|
||
}, 300);
|
||
});
|
||
|
||
// Sidebar chat search
|
||
const _chatSearchInput = document.getElementById('chatSearchInput');
|
||
_chatSearchInput?.addEventListener('input', () => UI.renderChatList());
|
||
document.getElementById('chatSearchClear')?.addEventListener('click', () => {
|
||
if (_chatSearchInput) { _chatSearchInput.value = ''; UI.renderChatList(); _chatSearchInput.focus(); }
|
||
});
|
||
|
||
// Chat actions
|
||
document.getElementById('sendBtn').addEventListener('click', sendMessage);
|
||
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
|
||
|
||
// Model selector (custom dropdown)
|
||
UI.initModelDropdown();
|
||
UI.initAppearance();
|
||
|
||
// Mobile hamburger
|
||
document.getElementById('mobileMenuBtn')?.addEventListener('click', UI.toggleSidebar);
|
||
document.getElementById('sidebarOverlay')?.addEventListener('click', () => {
|
||
document.getElementById('sidebar').classList.add('collapsed');
|
||
document.getElementById('sidebarOverlay').style.display = 'none';
|
||
localStorage.setItem('sb_sidebar', '1');
|
||
});
|
||
document.getElementById('fetchModelsBtn')?.addEventListener('click', async () => {
|
||
await fetchModels();
|
||
UI.toast(`Loaded ${App.models.length} models`, '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 || lookupKnownCaps(this.value) || {};
|
||
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));
|
||
|
||
// 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';
|
||
});
|
||
|
||
// 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 topmost modal
|
||
if (e.key === 'Escape') {
|
||
if (App.isGenerating) { stopGeneration(); return; }
|
||
if (document.getElementById('cmdPalette')?.classList.contains('active')) {
|
||
closeCmdPalette(); 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' });
|
||
|
||
// 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 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'; }
|
||
}
|
||
|
||
// ── 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() {
|
||
openModal('notesModal');
|
||
_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
|
||
|
||
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);
|