Changeset 0.7.0 (#37)

This commit is contained in:
2026-02-21 12:43:33 +00:00
parent 925b70f98c
commit a95e246cd7
18 changed files with 1424 additions and 115 deletions

View File

@@ -1,5 +1,5 @@
// ==========================================
// Chat Switchboard API Client (v0.6.2)
// Chat Switchboard API Client (v0.7.0)
// ==========================================
// Backend-only mode. Handles auth tokens and
// all HTTP calls. No offline fallback.
@@ -124,9 +124,15 @@ const API = {
// ── Completions ──────────────────────────
async streamCompletion(channelId, content, model, signal, apiConfigId) {
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId) {
const body = { channel_id: channelId, content, stream: true };
if (model) body.model = model;
if (presetId) {
body.preset_id = presetId;
// Preset resolves the model on backend; still pass model for channel metadata
if (model) body.model = model;
} else {
if (model) body.model = model;
}
if (apiConfigId) body.api_config_id = apiConfigId;
// Only send max_tokens if user explicitly set it (non-zero = override)
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
@@ -203,6 +209,7 @@ const API = {
});
},
adminDeleteGlobalConfig(id) { return this._del(`/api/v1/admin/configs/${id}`); },
adminUpdateGlobalConfig(id, updates) { return this._put(`/api/v1/admin/configs/${id}`, updates); },
adminListModels() { return this._get('/api/v1/admin/models'); },
adminFetchModels() { return this._post('/api/v1/admin/models/fetch', {}); },
@@ -210,8 +217,17 @@ const API = {
adminBulkUpdateModels(isEnabled) { return this._put('/api/v1/admin/models/bulk', { is_enabled: isEnabled }); },
adminDeleteModel(id) { return this._del(`/api/v1/admin/models/${id}`); },
// ── User Models ──────────────────────────
listEnabledModels() { return this._get('/api/v1/models/enabled'); },
// ── Admin Presets ────────────────────────
adminListPresets() { return this._get('/api/v1/admin/presets'); },
adminCreatePreset(preset) { return this._post('/api/v1/admin/presets', preset); },
adminUpdatePreset(id, updates) { return this._put(`/api/v1/admin/presets/${id}`, updates); },
adminDeletePreset(id) { return this._del(`/api/v1/admin/presets/${id}`); },
// ── User Presets ─────────────────────────
listPresets() { return this._get('/api/v1/presets'); },
createPreset(preset) { return this._post('/api/v1/presets', preset); },
updatePreset(id, updates) { return this._put(`/api/v1/presets/${id}`, updates); },
deletePreset(id) { return this._del(`/api/v1/presets/${id}`); },
// ── HTTP Internals ───────────────────────

View File

@@ -173,32 +173,36 @@ async function fetchModels() {
try {
const data = await API.listEnabledModels();
App.models = (data.models || []).map(m => {
const id = m.model_id || m.id;
const isPreset = !!m.is_preset;
// Presets: use preset_id as selector value, base model for caps
// Regular: model_id is both
const id = isPreset ? (m.preset_id || m.id) : (m.model_id || m.id);
const baseModelId = m.model_id || m.id;
return {
id,
name: m.display_name || id,
baseModelId,
name: m.display_name || baseModelId,
provider: m.provider_name || m.provider || '',
configId: m.config_id || null,
capabilities: resolveCapabilities(m.capabilities, id),
capabilities: resolveCapabilities(m.capabilities, baseModelId),
isPreset,
presetId: m.preset_id || null,
presetScope: m.preset_scope || null,
};
});
if (App.models.length === 0) {
const raw = await API.listAllModels();
App.models = (raw.models || []).map(m => {
const id = m.id || m.name;
return {
id,
name: id,
provider: m.owned_by || m.provider || '',
configId: m.config_id || null,
capabilities: resolveCapabilities(m.capabilities, id),
};
});
}
App.models.sort((a, b) => a.id.localeCompare(b.id));
console.log(`📋 Loaded ${App.models.length} models`);
// Sort: presets first (global before personal), then regular models
App.models.sort((a, b) => {
if (a.isPreset && !b.isPreset) return -1;
if (!a.isPreset && b.isPreset) return 1;
if (a.isPreset && b.isPreset) {
if (a.presetScope !== b.presetScope) {
return a.presetScope === 'global' ? -1 : 1;
}
}
return a.name.localeCompare(b.name);
});
console.log(`📋 Loaded ${App.models.length} models (${App.models.filter(m => m.isPreset).length} presets)`);
} catch (e) { console.warn('Model fetch failed:', e.message); }
UI.updateModelSelector();
UI.updateCapabilityBadges();
@@ -227,6 +231,11 @@ async function loadChats() {
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;
@@ -253,6 +262,11 @@ async function newChat() {
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) {
@@ -275,7 +289,11 @@ async function sendMessage() {
input.value = '';
input.style.height = 'auto';
const model = document.getElementById('modelSelect').value || App.settings.model;
const selectedId = UI.getModelValue();
const modelInfo = App.models.find(m => m.id === 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 {
@@ -298,11 +316,10 @@ async function sendMessage() {
App.abortController = new AbortController();
UI.setGenerating(true);
const modelInfo = App.models.find(m => m.id === model);
try {
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId);
const assistantContent = await UI.streamResponse(resp, chat.messages);
chat.messages.push({ role: 'assistant', content: assistantContent, model, timestamp: new Date().toISOString() });
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId);
const assistantContent = await UI.streamResponse(resp, chat.messages, modelInfo?.name);
chat.messages.push({ role: 'assistant', content: assistantContent, model, modelName: modelInfo?.name || model, timestamp: new Date().toISOString() });
chat.messageCount = chat.messages.length;
UI.renderMessages(chat.messages);
UI.showRegenerate(true);
@@ -343,16 +360,18 @@ async function regenerate() {
const lastUser = chat.messages[chat.messages.length - 1];
if (lastUser.role !== 'user') return;
const model = document.getElementById('modelSelect').value || App.settings.model;
const modelInfo = App.models.find(m => m.id === model);
const selectedId = UI.getModelValue();
const modelInfo = App.models.find(m => m.id === 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 resp = await API.streamCompletion(App.currentChatId, lastUser.content, model, App.abortController.signal, modelInfo?.configId);
const content = await UI.streamResponse(resp, chat.messages);
chat.messages.push({ role: 'assistant', content, model, timestamp: new Date().toISOString() });
const resp = await API.streamCompletion(App.currentChatId, lastUser.content, model, App.abortController.signal, modelInfo?.configId, presetId);
const content = await UI.streamResponse(resp, chat.messages, modelInfo?.name);
chat.messages.push({ role: 'assistant', content, model, modelName: modelInfo?.name || model, timestamp: new Date().toISOString() });
UI.renderMessages(chat.messages);
UI.showRegenerate(true);
} catch (e) {
@@ -494,10 +513,16 @@ function initListeners() {
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
document.getElementById('regenerateBtn').addEventListener('click', regenerate);
// Model selector
document.getElementById('modelSelect').addEventListener('change', function() {
if (this.value) { App.settings.model = this.value; saveSettings(); }
UI.updateCapabilityBadges();
// 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();
@@ -557,15 +582,47 @@ function initListeners() {
// 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 = '';
const f = document.getElementById('adminAddProviderForm');
f.style.display = f.style.display === 'none' ? '' : 'none';
});
document.getElementById('adminCancelProvBtn')?.addEventListener('click', () => { document.getElementById('adminAddProviderForm').style.display = '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
document.getElementById('adminAddPresetBtn')?.addEventListener('click', () => {
_editingPresetId = null;
document.getElementById('adminCreatePresetBtn').textContent = 'Create';
document.getElementById('adminPresetName').value = '';
document.getElementById('adminPresetDesc').value = '';
document.getElementById('adminPresetPrompt').value = '';
document.getElementById('adminPresetIcon').value = '';
document.getElementById('adminPresetTemp').value = '';
document.getElementById('adminPresetMaxTokens').value = '';
const f = document.getElementById('adminAddPresetForm');
f.style.display = f.style.display === 'none' ? '' : 'none';
});
document.getElementById('adminCancelPresetBtn')?.addEventListener('click', () => {
document.getElementById('adminAddPresetForm').style.display = 'none';
_editingPresetId = null;
document.getElementById('adminCreatePresetBtn').textContent = 'Create';
});
document.getElementById('adminCreatePresetBtn')?.addEventListener('click', createAdminPreset);
// Admin — banner controls
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none';
@@ -740,6 +797,23 @@ async function deleteGlobalProvider(id) {
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('adminCreateProvBtn').textContent = 'Update';
}
async function createGlobalProvider() {
try {
const name = document.getElementById('adminProvName').value.trim();
@@ -747,10 +821,26 @@ async function createGlobalProvider() {
const ep = document.getElementById('adminProvEndpoint').value.trim();
const key = document.getElementById('adminProvKey').value;
const model = document.getElementById('adminProvModel').value.trim();
if (!name || !ep || !key) { UI.toast('Name, endpoint, and API key required', 'warning'); return; }
await API.adminCreateGlobalConfig(name, prov, ep, key, model);
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;
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);
UI.toast('Provider added', 'success');
}
_editingProviderId = null;
document.getElementById('adminAddProviderForm').style.display = 'none';
UI.toast('Provider added', 'success');
document.getElementById('adminCreateProvBtn').textContent = 'Save';
document.getElementById('adminProvKey').placeholder = 'sk-...';
await UI.loadAdminProviders();
} catch (e) { UI.toast(e.message, 'error'); }
}
@@ -781,6 +871,93 @@ async function bulkToggleModels(enabled) {
} catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
}
// ── Admin Presets ────────────────────────────
var _editingPresetId = null;
function editAdminPreset(id) {
const p = UI._presetCache?.[id];
if (!p) return;
_editingPresetId = id;
const form = document.getElementById('adminAddPresetForm');
form.style.display = '';
document.getElementById('adminPresetName').value = p.name || '';
document.getElementById('adminPresetIcon').value = p.icon || '';
document.getElementById('adminPresetDesc').value = p.description || '';
document.getElementById('adminPresetPrompt').value = p.system_prompt || '';
document.getElementById('adminPresetTemp').value = p.temperature != null ? p.temperature : '';
document.getElementById('adminPresetMaxTokens').value = p.max_tokens != null ? p.max_tokens : '';
// Set base model and config dropdowns if present
const modelSel = document.getElementById('adminPresetModel');
if (modelSel) modelSel.value = p.base_model_id || '';
const cfgSel = document.getElementById('adminPresetConfig');
if (cfgSel) cfgSel.value = p.api_config_id || '';
document.getElementById('adminCreatePresetBtn').textContent = 'Update';
}
async function createAdminPreset() {
const name = document.getElementById('adminPresetName').value.trim();
const baseModelId = document.getElementById('adminPresetModel').value;
if (!name || !baseModelId) { UI.toast('Name and base model are required', 'warning'); return; }
const preset = {
name,
base_model_id: baseModelId,
description: document.getElementById('adminPresetDesc').value.trim(),
icon: document.getElementById('adminPresetIcon').value.trim(),
system_prompt: document.getElementById('adminPresetPrompt').value,
};
const configId = document.getElementById('adminPresetConfig').value;
if (configId) preset.api_config_id = configId;
const temp = parseFloat(document.getElementById('adminPresetTemp').value);
if (!isNaN(temp)) preset.temperature = temp;
const maxTok = parseInt(document.getElementById('adminPresetMaxTokens').value);
if (!isNaN(maxTok) && maxTok > 0) preset.max_tokens = maxTok;
try {
if (_editingPresetId) {
await API.adminUpdatePreset(_editingPresetId, preset);
UI.toast('Preset updated', 'success');
} else {
await API.adminCreatePreset(preset);
UI.toast('Preset created', 'success');
}
_editingPresetId = null;
document.getElementById('adminAddPresetForm').style.display = 'none';
document.getElementById('adminCreatePresetBtn').textContent = 'Create';
document.getElementById('adminPresetName').value = '';
document.getElementById('adminPresetDesc').value = '';
document.getElementById('adminPresetPrompt').value = '';
document.getElementById('adminPresetIcon').value = '';
document.getElementById('adminPresetTemp').value = '';
document.getElementById('adminPresetMaxTokens').value = '';
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'); }
}
// ── Banners ──────────────────────────────────
async function initBanners() {

View File

@@ -7,12 +7,16 @@ const UI = {
// ── Sidebar ──────────────────────────────
toggleSidebar() {
document.getElementById('sidebar').classList.toggle('collapsed');
localStorage.setItem('sb_sidebar', document.getElementById('sidebar').classList.contains('collapsed') ? '1' : '0');
const sb = document.getElementById('sidebar');
sb.classList.toggle('collapsed');
const collapsed = sb.classList.contains('collapsed');
localStorage.setItem('sb_sidebar', collapsed ? '1' : '0');
const overlay = document.getElementById('sidebarOverlay');
if (overlay) overlay.style.display = collapsed ? 'none' : (window.innerWidth <= 768 ? 'block' : 'none');
},
restoreSidebar() {
if (localStorage.getItem('sb_sidebar') === '1') {
if (window.innerWidth <= 768 || localStorage.getItem('sb_sidebar') === '1') {
document.getElementById('sidebar').classList.add('collapsed');
}
},
@@ -96,13 +100,23 @@ const UI = {
_messageHTML(msg, index) {
const isUser = msg.role === 'user';
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
let assistantLabel = 'Assistant';
if (!isUser) {
// Use stored modelName, or resolve from current model list, or fall back to model id
if (msg.modelName) {
assistantLabel = msg.modelName;
} else if (msg.model) {
const m = App.models.find(x => x.id === msg.model || x.baseModelId === msg.model);
assistantLabel = m?.name || msg.model;
}
}
return `
<div class="message ${msg.role}">
<div class="msg-inner">
<div class="msg-avatar">${isUser ? '👤' : '🤖'}</div>
<div class="msg-body">
<div class="msg-head">
<span class="msg-role">${isUser ? 'You' : 'Assistant'}</span>
<span class="msg-role">${isUser ? 'You' : esc(assistantLabel)}</span>
${time ? `<span class="msg-time">${time}</span>` : ''}
<div class="msg-actions">
<button class="msg-action-btn" onclick="UI.copyMessage(${index})" title="Copy">Copy</button>
@@ -125,17 +139,18 @@ const UI = {
// ── Streaming ────────────────────────────
async streamResponse(response, messages) {
async streamResponse(response, messages, modelName) {
const container = document.getElementById('chatMessages');
document.getElementById('typingIndicator')?.remove();
const label = modelName || 'Assistant';
const div = document.createElement('div');
div.className = 'message assistant';
div.innerHTML = `
<div class="msg-inner">
<div class="msg-avatar">🤖</div>
<div class="msg-body">
<div class="msg-head"><span class="msg-role">Assistant</span></div>
<div class="msg-head"><span class="msg-role">${esc(label)}</span></div>
<div class="msg-text" id="streamContent"></div>
</div>
</div>`;
@@ -174,50 +189,111 @@ const UI = {
return content;
},
// ── Model Selector ───────────────────────
// ── Model Selector (Custom Dropdown) ────
_modelValue: '',
getModelValue() { return UI._modelValue || App.settings.model || ''; },
setModelValue(val, label) {
UI._modelValue = val;
if (val) { App.settings.model = val; saveSettings(); }
const btn = document.getElementById('modelDropdownLabel');
if (btn) btn.textContent = label || val || 'Select a model';
// Highlight selected item
document.querySelectorAll('#modelDropdownMenu .model-dropdown-item').forEach(el => {
el.classList.toggle('selected', el.dataset.value === val);
});
UI.updateCapabilityBadges();
},
updateModelSelector() {
const sel = document.getElementById('modelSelect');
const menu = document.getElementById('modelDropdownMenu');
if (!menu) return;
const current = App.settings.model;
sel.innerHTML = '';
menu.innerHTML = '';
if (App.models.length === 0) {
sel.innerHTML = '<option value="">No models loaded</option>';
menu.innerHTML = '<div class="model-dropdown-item" style="color:var(--text-3);cursor:default">No models loaded</div>';
UI.setModelValue('', 'No models loaded');
} else {
App.models.forEach(m => {
const opt = document.createElement('option');
opt.value = m.id;
const label = m.name || m.id;
opt.textContent = label + (m.provider ? ` (${m.provider})` : '');
sel.appendChild(opt);
});
}
const presets = App.models.filter(m => m.isPreset);
const models = App.models.filter(m => !m.isPreset);
if (current) {
const exists = [...sel.options].some(o => o.value === current);
if (exists) {
sel.value = current;
} else {
const opt = document.createElement('option');
opt.value = current;
opt.textContent = current + ' (custom)';
sel.insertBefore(opt, sel.firstChild);
sel.value = current;
if (presets.length > 0) {
const hdr = document.createElement('div');
hdr.className = 'model-dropdown-group';
hdr.textContent = '⚡ Presets';
menu.appendChild(hdr);
presets.forEach(m => menu.appendChild(UI._createDropdownItem(m)));
}
if (models.length > 0) {
const hdr = document.createElement('div');
hdr.className = 'model-dropdown-group';
hdr.textContent = 'Models';
menu.appendChild(hdr);
models.forEach(m => menu.appendChild(UI._createDropdownItem(m)));
}
// Restore selection
const match = App.models.find(m => m.id === current);
if (match) {
UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : ''));
} else if (App.models.length > 0) {
const first = App.models[0];
UI.setModelValue(first.id, first.name + (first.provider ? ` (${first.provider})` : ''));
}
}
// Sync settings modal selector
// Sync settings modal selector (flat select is fine there)
const settingsSel = document.getElementById('settingsModel');
if (settingsSel) {
settingsSel.innerHTML = sel.innerHTML;
settingsSel.innerHTML = '';
App.models.forEach(m => {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
settingsSel.appendChild(opt);
});
if (current) settingsSel.value = current;
}
},
_createDropdownItem(m) {
const div = document.createElement('div');
div.className = 'model-dropdown-item';
div.dataset.value = m.id;
div.innerHTML = `<span class="item-label">${esc(m.name || m.id)}</span>${m.provider ? `<span class="item-provider">${esc(m.provider)}</span>` : ''}`;
div.addEventListener('click', () => {
UI.setModelValue(m.id, (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''));
UI._closeModelDropdown();
});
return div;
},
_closeModelDropdown() {
document.getElementById('modelDropdownMenu')?.classList.remove('open');
},
initModelDropdown() {
const btn = document.getElementById('modelDropdownBtn');
const menu = document.getElementById('modelDropdownMenu');
if (!btn || !menu) return;
btn.addEventListener('click', (e) => {
e.stopPropagation();
menu.classList.toggle('open');
});
document.addEventListener('click', (e) => {
if (!e.target.closest('#modelDropdown')) menu.classList.remove('open');
});
},
// ── Capability Badges ────────────────────
getSelectedModelCaps() {
const modelId = document.getElementById('modelSelect').value || App.settings.model;
const modelId = UI.getModelValue();
if (!modelId) return {};
const model = App.models.find(m => m.id === modelId);
// Model in list has resolved caps; fallback to client-side lookup
@@ -353,6 +429,46 @@ const UI = {
UI.checkUserProvidersAllowed();
}
if (tab === 'models') UI.loadUserModels();
if (tab === 'appearance') UI.loadAppearanceSettings();
},
// ── Appearance Settings ─────────────────
loadAppearanceSettings() {
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
const scale = prefs.scale || 100;
const msgFont = prefs.msgFont || 14;
const scaleEl = document.getElementById('settingsScale');
const msgFontEl = document.getElementById('settingsMsgFont');
if (scaleEl) { scaleEl.value = scale; document.getElementById('scaleValue').textContent = scale + '%'; }
if (msgFontEl) { msgFontEl.value = msgFont; document.getElementById('msgFontValue').textContent = msgFont + 'px'; }
},
initAppearance() {
// Load saved prefs on startup
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
UI.applyAppearance(prefs.scale || 100, prefs.msgFont || 14);
// Live preview on slider change
const scaleEl = document.getElementById('settingsScale');
const msgFontEl = document.getElementById('settingsMsgFont');
if (scaleEl) scaleEl.addEventListener('input', () => {
const v = parseInt(scaleEl.value);
document.getElementById('scaleValue').textContent = v + '%';
UI.applyAppearance(v, parseInt(msgFontEl?.value || 14));
});
if (msgFontEl) msgFontEl.addEventListener('input', () => {
const v = parseInt(msgFontEl.value);
document.getElementById('msgFontValue').textContent = v + 'px';
UI.applyAppearance(parseInt(scaleEl?.value || 100), v);
});
},
applyAppearance(scale, msgFont) {
document.documentElement.style.zoom = scale / 100;
document.documentElement.style.setProperty('--msg-font', msgFont + 'px');
localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont }));
},
async checkUserProvidersAllowed() {
@@ -420,6 +536,7 @@ const UI = {
if (tab === 'stats') await this.loadAdminStats();
if (tab === 'providers') await this.loadAdminProviders();
if (tab === 'models') await this.loadAdminModels();
if (tab === 'presets') await this.loadAdminPresets();
if (tab === 'settings') await this.loadAdminSettings();
},
@@ -468,13 +585,17 @@ const UI = {
const data = await API.adminListGlobalConfigs();
const list = data.configs || data.data || data || [];
const arr = Array.isArray(list) ? list : [];
el.innerHTML = arr.map(c => `
<div class="admin-provider-row">
UI._providerCache = {};
el.innerHTML = arr.map(c => {
UI._providerCache[c.id] = c;
return `<div class="admin-provider-row">
<span class="provider-name">${esc(c.name)}</span>
<span class="provider-meta">${esc(c.provider)}</span>
<span class="provider-endpoint">${esc(c.endpoint || '')}</span>
<button class="btn-edit" onclick="editGlobalProvider('${c.id}')" title="Edit">✎</button>
<button class="btn-delete" onclick="deleteGlobalProvider('${c.id}')" title="Delete">✕</button>
</div>`).join('') || '<div class="empty-hint">No global providers — add one above</div>';
</div>`;
}).join('') || '<div class="empty-hint">No global providers — add one above</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
@@ -502,6 +623,68 @@ const UI = {
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadAdminPresets(quiet) {
const el = document.getElementById('adminPresetList');
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.adminListPresets();
const list = data.presets || [];
// Populate model dropdown from admin model list (all synced models)
const modelSel = document.getElementById('adminPresetModel');
if (modelSel) {
modelSel.innerHTML = '<option value="">Select base model...</option>';
try {
const modelData = await API.adminListModels();
const allModels = modelData.models || modelData.data || [];
const arr = Array.isArray(allModels) ? allModels : [];
arr.forEach(m => {
const opt = document.createElement('option');
const mid = m.model_id || m.id;
opt.value = mid;
opt.textContent = mid + (m.provider_name ? ` (${m.provider_name})` : '');
modelSel.appendChild(opt);
});
} catch (e) { console.warn('Failed to load models for preset form:', e.message); }
}
// Populate config dropdown
const cfgSel = document.getElementById('adminPresetConfig');
if (cfgSel && cfgSel.options.length <= 1) {
try {
const cfgData = await API.adminListGlobalConfigs();
const cfgs = cfgData.configs || cfgData.data || [];
(Array.isArray(cfgs) ? cfgs : []).forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name + ' (' + c.provider + ')';
cfgSel.appendChild(opt);
});
} catch (e) { /* optional */ }
}
UI._presetCache = {};
el.innerHTML = list.map(p => {
UI._presetCache[p.id] = p;
const icon = p.icon ? p.icon + ' ' : '';
const scope = p.scope === 'global' ? '<span class="badge-admin">global</span>' : '<span class="badge-user">personal</span>';
const status = p.is_active ? '' : '<span class="badge-pending">inactive</span>';
return `<div class="admin-preset-row">
<div class="preset-info">
<strong>${icon}${esc(p.name)}</strong> ${scope} ${status}
<div class="preset-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
${p.description ? `<div class="preset-desc">${esc(p.description)}</div>` : ''}
</div>
<div class="preset-actions">
<button class="btn-edit" onclick="editAdminPreset('${p.id}')" title="Edit">✎</button>
<button class="admin-model-toggle ${p.is_active ? 'enabled' : ''}" onclick="toggleAdminPreset('${p.id}', ${!p.is_active})">${p.is_active ? '✓ Active' : 'Inactive'}</button>
<button class="btn-delete" onclick="deleteAdminPreset('${p.id}', '${esc(p.name)}')" title="Delete">✕</button>
</div>
</div>`;
}).join('') || '<div class="empty-hint">No presets — create one to give users preconfigured model experiences</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadAdminSettings() {
try {
const data = await API.adminGetSettings();
@@ -602,10 +785,10 @@ const UI = {
if (format === 'json') { content = JSON.stringify(chat, null, 2); ext = 'json'; mime = 'application/json'; }
else if (format === 'md') {
content = `# ${chat.title}\n\n` + chat.messages.filter(m => m.role !== 'system')
.map(m => `## ${m.role === 'user' ? 'You' : 'Assistant'}\n\n${m.content}`).join('\n\n---\n\n');
.map(m => `## ${m.role === 'user' ? 'You' : (m.modelName || m.model || 'Assistant')}\n\n${m.content}`).join('\n\n---\n\n');
ext = 'md'; mime = 'text/markdown';
} else {
content = chat.messages.filter(m => m.role !== 'system').map(m => `[${m.role === 'user' ? 'You' : 'Assistant'}]\n${m.content}`).join('\n\n');
content = chat.messages.filter(m => m.role !== 'system').map(m => `[${m.role === 'user' ? 'You' : (m.modelName || m.model || 'Assistant')}]\n${m.content}`).join('\n\n');
ext = 'txt'; mime = 'text/plain';
}