Changeset 0.7.0 (#37)
This commit is contained in:
@@ -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 ───────────────────────
|
||||
|
||||
|
||||
251
src/js/app.js
251
src/js/app.js
@@ -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() {
|
||||
|
||||
255
src/js/ui.js
255
src/js/ui.js
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user