Changeset 0.7.0 (#37)
This commit is contained in:
@@ -54,7 +54,7 @@ a:hover { text-decoration: underline; }
|
||||
|
||||
/* ── App Shell ───────────────────────────── */
|
||||
|
||||
.app { display: flex; height: 100vh; flex-direction: column; }
|
||||
.app { display: flex; height: 100vh; height: 100dvh; flex-direction: column; }
|
||||
.app-body { display: flex; flex: 1; min-height: 0; }
|
||||
|
||||
/* ── Environment Banners ──────────────────── */
|
||||
@@ -279,9 +279,11 @@ a:hover { text-decoration: underline; }
|
||||
.avatar-bug {
|
||||
position: absolute; bottom: -3px; right: -3px;
|
||||
font-size: 10px; line-height: 1;
|
||||
display: none;
|
||||
transition: filter var(--transition);
|
||||
}
|
||||
.avatar-bug.has-errors {
|
||||
display: block;
|
||||
filter: drop-shadow(0 0 4px var(--danger));
|
||||
animation: bug-pulse 1.5s ease infinite;
|
||||
}
|
||||
@@ -328,6 +330,45 @@ a:hover { text-decoration: underline; }
|
||||
.model-bar select:focus { outline: none; border-color: var(--accent); }
|
||||
.model-bar select option { background: var(--bg-surface); color: var(--text); }
|
||||
|
||||
/* ── Custom Model Dropdown ───────────────── */
|
||||
|
||||
.model-dropdown { position: relative; }
|
||||
.model-dropdown-btn {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
background: none; border: 1px solid transparent;
|
||||
color: var(--text); font-size: 14px; font-weight: 500;
|
||||
padding: 4px 8px; border-radius: var(--radius); cursor: pointer;
|
||||
font-family: var(--font); max-width: 340px;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
.model-dropdown-btn:hover { border-color: var(--border); }
|
||||
.model-dropdown-btn svg { flex-shrink: 0; opacity: 0.5; }
|
||||
.model-dropdown-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.model-dropdown-menu {
|
||||
display: none; position: absolute; top: 100%; left: 0; z-index: 200;
|
||||
min-width: 280px; max-width: 400px; max-height: 400px; overflow-y: auto;
|
||||
background: var(--bg-raised); border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius); padding: 4px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.model-dropdown-menu.open { display: block; }
|
||||
.model-dropdown-group {
|
||||
padding: 6px 10px 4px; font-size: 11px; font-weight: 600;
|
||||
color: var(--text-3); text-transform: uppercase; letter-spacing: 0.5px;
|
||||
}
|
||||
.model-dropdown-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 7px 10px; border-radius: 4px;
|
||||
cursor: pointer; font-size: 13px;
|
||||
color: var(--text-2); white-space: nowrap;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
.model-dropdown-item:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.model-dropdown-item.selected { background: var(--bg-hover); color: var(--accent); }
|
||||
.model-dropdown-item .item-label { flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
||||
.model-dropdown-item .item-provider { margin-left: auto; font-size: 10px; color: var(--text-3); }
|
||||
|
||||
.model-caps {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
margin-left: 4px; flex-wrap: wrap;
|
||||
@@ -386,7 +427,7 @@ a:hover { text-decoration: underline; }
|
||||
}
|
||||
.msg-action-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
|
||||
.msg-text { font-size: 14px; line-height: 1.7; }
|
||||
.msg-text { font-size: var(--msg-font, 14px); line-height: 1.7; }
|
||||
|
||||
/* ── Markdown Content ────────────────────── */
|
||||
|
||||
@@ -651,8 +692,8 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
|
||||
/* Splash responsive */
|
||||
@media (max-width: 860px) {
|
||||
.splash { flex-direction: column; }
|
||||
.splash-hero { padding: 2.5rem 1.75rem 2rem; min-height: auto; }
|
||||
.splash { flex-direction: column; min-height: 100vh; min-height: 100dvh; overflow-y: auto; }
|
||||
.splash-hero { padding: 2.5rem 1.75rem 2rem; min-height: auto; flex: none; }
|
||||
.hero-headline { font-size: 1.8rem; }
|
||||
.hero-sub { font-size: 0.95rem; margin-bottom: 1.5rem; }
|
||||
.hero-version { margin-top: 1.5rem; }
|
||||
@@ -660,14 +701,19 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
.auth-card { max-width: 400px; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.splash-hero { padding: 2rem 1.25rem 1.5rem; }
|
||||
.hero-logo-row { gap: 10px; margin-bottom: 1rem; }
|
||||
.splash-hero { padding: 1.5rem 1.25rem 1rem; }
|
||||
.hero-logo-row { gap: 10px; margin-bottom: 0.75rem; }
|
||||
.hero-logo-mark { width: 40px; height: 40px; }
|
||||
.hero-wordmark { font-size: 1.3rem; }
|
||||
.hero-headline { font-size: 1.5rem; }
|
||||
.hero-sub { font-size: 0.88rem; }
|
||||
.hero-headline { font-size: 1.35rem; margin-bottom: 0.5rem; }
|
||||
.hero-sub { font-size: 0.85rem; margin-bottom: 1rem; line-height: 1.5; }
|
||||
.hero-features { gap: 6px; }
|
||||
.hero-pill { padding: 5px 10px; font-size: 0.72rem; }
|
||||
.hero-version { margin-top: 1rem; }
|
||||
.splash-auth { padding: 1.5rem 1.25rem; }
|
||||
.auth-card-header { margin-bottom: 1rem; }
|
||||
.auth-card-header h2 { font-size: 1.1rem; }
|
||||
.auth-actions { margin-top: 1rem; }
|
||||
}
|
||||
|
||||
/* ── Forms ────────────────────────────────── */
|
||||
@@ -686,6 +732,20 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
.form-row { display: flex; gap: 0.75rem; }
|
||||
.form-row .form-group { flex: 1; }
|
||||
.form-hint { font-weight: 400; color: var(--text-3); margin-left: 4px; }
|
||||
.range-input {
|
||||
width: 100%; height: 4px; -webkit-appearance: none; appearance: none;
|
||||
background: var(--border); border-radius: 2px; outline: none;
|
||||
margin: 8px 0 4px; cursor: pointer;
|
||||
}
|
||||
.range-input::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%;
|
||||
background: var(--accent); border: 2px solid var(--bg-surface); cursor: pointer;
|
||||
}
|
||||
.range-input::-moz-range-thumb {
|
||||
width: 16px; height: 16px; border-radius: 50%;
|
||||
background: var(--accent); border: 2px solid var(--bg-surface); cursor: pointer;
|
||||
}
|
||||
.range-labels { display: flex; justify-content: space-between; font-size: 11px; color: var(--text-3); }
|
||||
.checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; margin: 0.5rem 0; color: var(--text-2); }
|
||||
|
||||
/* ── Modal ────────────────────────────────── */
|
||||
@@ -797,9 +857,20 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
}
|
||||
.admin-provider-row .provider-name { font-weight: 500; flex: 1; }
|
||||
.admin-provider-row .provider-endpoint { font-size: 12px; color: var(--text-3); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.btn-edit { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 16px; padding: 2px 6px; }
|
||||
.btn-edit:hover { color: var(--accent); }
|
||||
.admin-provider-row .btn-delete { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 16px; padding: 2px 6px; }
|
||||
.admin-provider-row .btn-delete:hover { color: var(--danger); }
|
||||
|
||||
.admin-preset-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||
.admin-preset-row .preset-info { flex: 1; min-width: 0; }
|
||||
.admin-preset-row .preset-meta { font-size: 12px; color: var(--text-3); margin-top: 2px; }
|
||||
.admin-preset-row .preset-desc { font-size: 12px; color: var(--text-2); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.admin-preset-row .preset-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
|
||||
.admin-preset-row .btn-delete { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 16px; padding: 2px 6px; }
|
||||
.admin-preset-row .btn-delete:hover { color: var(--danger); }
|
||||
.badge-user { font-size: 10px; padding: 1px 6px; border-radius: 3px; background: var(--accent-bg); color: var(--accent); }
|
||||
|
||||
/* Stats cards */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; }
|
||||
.stat-card {
|
||||
@@ -888,9 +959,24 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
|
||||
/* ── Responsive ──────────────────────────── */
|
||||
|
||||
.mobile-menu-btn {
|
||||
display: none; align-items: center; justify-content: center;
|
||||
background: none; border: none; color: var(--text-2);
|
||||
cursor: pointer; padding: 4px; border-radius: var(--radius);
|
||||
transition: color var(--transition);
|
||||
}
|
||||
.mobile-menu-btn:hover { color: var(--text); }
|
||||
.sidebar-overlay {
|
||||
display: none; position: fixed; inset: 0; z-index: 99;
|
||||
background: rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { position: fixed; z-index: 100; height: 100%; }
|
||||
.sidebar.collapsed { width: 0; border: none; }
|
||||
.sidebar { position: fixed; z-index: 100; height: 100%; transition: width 0.2s ease; }
|
||||
.sidebar.collapsed { width: 0; border: none; overflow: hidden; }
|
||||
.msg-inner { padding: 0 1rem; }
|
||||
.model-caps { display: none; }
|
||||
.input-area { padding: 0 0.5rem 0.5rem; }
|
||||
.input-wrap textarea { font-size: 16px; } /* prevent iOS zoom */
|
||||
.mobile-menu-btn { display: flex; }
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
|
||||
<base href="%%BASE_HREF%%">
|
||||
<script>window.__BASE__ = '%%BASE_PATH%%';</script>
|
||||
<title>Chat Switchboard</title>
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="favicon-192.png">
|
||||
<link rel="stylesheet" href="css/styles.css?v=0.6.2">
|
||||
<link rel="stylesheet" href="css/styles.css?v=0.7.0.12">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -84,11 +84,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="sidebar-overlay" id="sidebarOverlay"></div>
|
||||
|
||||
<!-- Main Chat Area -->
|
||||
<main class="chat-area">
|
||||
<div class="model-bar">
|
||||
<select id="modelSelect" title="Select model"><option value="">Select a model</option></select>
|
||||
<button class="mobile-menu-btn" id="mobileMenuBtn" title="Menu">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="model-dropdown" id="modelDropdown">
|
||||
<button class="model-dropdown-btn" id="modelDropdownBtn" title="Select model">
|
||||
<span class="model-dropdown-label" id="modelDropdownLabel">Select a model</span>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3.5L5 6.5L8 3.5"/></svg>
|
||||
</button>
|
||||
<div class="model-dropdown-menu" id="modelDropdownMenu"></div>
|
||||
</div>
|
||||
<button class="icon-btn" id="fetchModelsBtn" title="Refresh models">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
</button>
|
||||
@@ -192,6 +202,7 @@
|
||||
<div class="modal-header"><h2>Settings</h2><button class="modal-close" id="settingsCloseBtn">✕</button></div>
|
||||
<div class="settings-tabs">
|
||||
<button class="settings-tab active" data-stab="general" onclick="UI.switchSettingsTab('general')">General</button>
|
||||
<button class="settings-tab" data-stab="appearance" onclick="UI.switchSettingsTab('appearance')">Appearance</button>
|
||||
<button class="settings-tab" data-stab="providers" onclick="UI.switchSettingsTab('providers')">Providers</button>
|
||||
<button class="settings-tab" data-stab="models" onclick="UI.switchSettingsTab('models')">Models</button>
|
||||
</div>
|
||||
@@ -223,6 +234,22 @@
|
||||
<label class="checkbox-label"><input type="checkbox" id="settingsThinking" checked> Show thinking blocks</label>
|
||||
</section>
|
||||
</div>
|
||||
<!-- Appearance Tab -->
|
||||
<div class="settings-tab-content" id="settingsAppearanceTab" style="display:none">
|
||||
<section class="settings-section">
|
||||
<h3>Display</h3>
|
||||
<div class="form-group">
|
||||
<label>UI Scale <span class="form-hint" id="scaleValue">100%</span></label>
|
||||
<input type="range" id="settingsScale" min="80" max="130" step="5" value="100" class="range-input">
|
||||
<div class="range-labels"><span>80%</span><span>100%</span><span>130%</span></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Message Font Size <span class="form-hint" id="msgFontValue">14px</span></label>
|
||||
<input type="range" id="settingsMsgFont" min="12" max="20" step="1" value="14" class="range-input">
|
||||
<div class="range-labels"><span>12px</span><span>14px</span><span>20px</span></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<!-- Providers Tab -->
|
||||
<div class="settings-tab-content" id="settingsProvidersTab" style="display:none">
|
||||
<section class="settings-section">
|
||||
@@ -259,6 +286,7 @@
|
||||
<button class="admin-tab active" data-tab="users">Users</button>
|
||||
<button class="admin-tab" data-tab="providers">Providers</button>
|
||||
<button class="admin-tab" data-tab="models">Models</button>
|
||||
<button class="admin-tab" data-tab="presets">Presets</button>
|
||||
<button class="admin-tab" data-tab="settings">Settings</button>
|
||||
<button class="admin-tab" data-tab="stats">Stats</button>
|
||||
</div>
|
||||
@@ -307,6 +335,29 @@
|
||||
</div>
|
||||
<div id="adminModelList"></div>
|
||||
</div>
|
||||
<div class="admin-tab-content" id="adminPresetsTab" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
<button class="btn-small btn-primary" id="adminAddPresetBtn">+ New Global Preset</button>
|
||||
</div>
|
||||
<div id="adminAddPresetForm" style="display:none" class="admin-inline-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Name</label><input type="text" id="adminPresetName" placeholder="Code Reviewer"></div>
|
||||
<div class="form-group"><label>Icon</label><input type="text" id="adminPresetIcon" placeholder="🔍" maxlength="4" style="width:60px"></div>
|
||||
</div>
|
||||
<div class="form-group"><label>Description</label><input type="text" id="adminPresetDesc" placeholder="Reviews code for best practices"></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Base Model</label><select id="adminPresetModel"></select></div>
|
||||
<div class="form-group"><label>Provider Config</label><select id="adminPresetConfig"><option value="">Auto-resolve</option></select></div>
|
||||
</div>
|
||||
<div class="form-group"><label>System Prompt</label><textarea id="adminPresetPrompt" rows="3" placeholder="You are a code reviewer..."></textarea></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Temperature</label><input type="number" id="adminPresetTemp" step="0.1" min="0" max="2" placeholder="default"></div>
|
||||
<div class="form-group"><label>Max Tokens</label><input type="number" id="adminPresetMaxTokens" placeholder="default"></div>
|
||||
</div>
|
||||
<div class="form-row"><button class="btn-small btn-primary" id="adminCreatePresetBtn">Create</button><button class="btn-small" id="adminCancelPresetBtn">Cancel</button></div>
|
||||
</div>
|
||||
<div id="adminPresetList"></div>
|
||||
</div>
|
||||
<div class="admin-tab-content" id="adminSettingsTab" style="display:none">
|
||||
<section class="settings-section">
|
||||
<h3>Registration</h3>
|
||||
@@ -389,10 +440,10 @@
|
||||
<script src="vendor/marked.min.js" onerror="this.onerror=null;this.src='https://cdnjs.cloudflare.com/ajax/libs/marked/16.3.0/lib/marked.umd.min.js'"></script>
|
||||
<script src="vendor/purify.min.js" onerror="this.onerror=null;this.src='https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.4/purify.min.js'"></script>
|
||||
|
||||
<script src="js/debug.js?v=0.6.2"></script>
|
||||
<script src="js/events.js?v=0.6.2"></script>
|
||||
<script src="js/api.js?v=0.6.2"></script>
|
||||
<script src="js/ui.js?v=0.6.2"></script>
|
||||
<script src="js/app.js?v=0.6.2"></script>
|
||||
<script src="js/debug.js?v=0.7.0.12"></script>
|
||||
<script src="js/events.js?v=0.7.0.12"></script>
|
||||
<script src="js/api.js?v=0.7.0.12"></script>
|
||||
<script src="js/ui.js?v=0.7.0.12"></script>
|
||||
<script src="js/app.js?v=0.7.0.12"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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