From 007eef68870670a92c20bf5530b781688eaeb222 Mon Sep 17 00:00:00 2001 From: Daniel Onyejesi Date: Sat, 28 Mar 2026 22:07:16 +0000 Subject: [PATCH] =?UTF-8?q?Add=20admin=20model=20management=20dashboard=20?= =?UTF-8?q?=E2=80=94=20enable/disable,=20custom=20models,=20default=20over?= =?UTF-8?q?ride?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Full model management UI in admin panel: toggle models on/off, add custom model IDs (any OpenRouter/Bedrock ID), set admin-configured default model - /api/models now returns admin-set default model, frontend respects it - Toggle switch CSS for clean enable/disable UX - Backend already had the API endpoints, this adds the missing UI --- public/components/admin.html | 58 ++++++++++++ public/css/styles.css | 8 ++ public/js/admin.js | 176 +++++++++++++++++++++++++++++++++++ public/js/app.js | 9 +- server.js | 5 +- 5 files changed, 253 insertions(+), 3 deletions(-) diff --git a/public/components/admin.html b/public/components/admin.html index d116755..eb3bb5e 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -202,6 +202,64 @@ + +
+
+

AI Models

+ Loading... +
+
+ +
+ + + +
+ + +
+ +
+ + + + + + + + + + + + + +
ModelCategoryCostTagEnabled
Loading...
+
+
+ + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+

Reset Settings

diff --git a/public/css/styles.css b/public/css/styles.css index 1714f29..df4a3f0 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -269,6 +269,14 @@ textarea.full-input{resize:vertical;} .auth-box{padding:24px;} } +/* TOGGLE SWITCH */ +.toggle-switch{position:relative;display:inline-block;width:36px;height:20px;vertical-align:middle;} +.toggle-switch input{opacity:0;width:0;height:0;} +.toggle-slider{position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;background:var(--g300);border-radius:20px;transition:.2s;} +.toggle-slider::before{content:'';position:absolute;height:16px;width:16px;left:2px;bottom:2px;background:white;border-radius:50%;transition:.2s;} +.toggle-switch input:checked+.toggle-slider{background:var(--blue);} +.toggle-switch input:checked+.toggle-slider::before{transform:translateX(16px);} + .editable-box::-webkit-scrollbar,.output-text::-webkit-scrollbar{width:5px;} .editable-box::-webkit-scrollbar-thumb,.output-text::-webkit-scrollbar-thumb{background:var(--g300);border-radius:3px;} diff --git a/public/js/admin.js b/public/js/admin.js index 48a80cd..136bcf8 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -279,6 +279,8 @@ if (e.target.closest('#btn-clear-smtp')) clearSmtp(); if (e.target.closest('#btn-save-auto-delete')) saveAutoDelete(); if (e.target.closest('#btn-reset-all-defaults')) resetAllDefaults(); + if (e.target.closest('#btn-add-custom-model')) addCustomModel(); + if (e.target.closest('#btn-save-default-model')) saveDefaultModel(); }); // When email template selector changes, repopulate fields @@ -326,6 +328,7 @@ loadPromptList(); loadSmtp(); + loadModels(); } // ---- ANNOUNCEMENT ---- @@ -557,6 +560,179 @@ .catch(function() { showToast('Request failed', 'error'); }); } + // ---- AI MODEL MANAGEMENT ---- + + var modelsLoaded = false; + + function loadModels() { + if (modelsLoaded) return; + modelsLoaded = true; + + fetch('/api/admin/config/models', { headers: getAuthHeaders() }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (!data.success) return; + window._adminModelsData = data; + var providerEl = document.getElementById('admin-model-provider'); + if (providerEl) providerEl.textContent = (data.provider || 'openrouter').toUpperCase(); + renderModelTable(data.models || []); + renderCustomModels(data.custom || []); + populateDefaultModelSelect(data.models || [], data.custom || []); + }) + .catch(function(err) { console.error('[Admin] Models load failed:', err); }); + } + + function renderModelTable(models) { + var tbody = document.getElementById('admin-models-body'); + if (!tbody) return; + if (models.length === 0) { + tbody.innerHTML = 'No models'; + return; + } + + var catColors = { free: 'var(--green)', fast: 'var(--blue)', smart: 'var(--purple)', premium: 'var(--amber)' }; + + tbody.innerHTML = models.map(function(m) { + var catColor = catColors[m.category] || 'var(--g500)'; + var checked = m.enabled ? 'checked' : ''; + return '' + + '
' + esc(m.name) + '
' + esc(m.id) + '
' + + '' + esc(m.category) + '' + + '' + esc(m.cost || '?') + '' + + '' + esc(m.tag || '') + '' + + '' + + ''; + }).join(''); + + // Bind toggle events + tbody.querySelectorAll('.model-toggle').forEach(function(cb) { + cb.addEventListener('change', function() { + var modelId = cb.dataset.modelId; + var enabled = cb.checked; + toggleModel(modelId, enabled); + }); + }); + } + + function toggleModel(modelId, enabled) { + fetch('/api/admin/config/models/toggle', { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify({ modelId: modelId, enabled: enabled }) + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.success) { + showToast((enabled ? 'Enabled' : 'Disabled') + ': ' + modelId, 'success'); + } else { + showToast(data.error || 'Toggle failed', 'error'); + } + }) + .catch(function() { showToast('Request failed', 'error'); }); + } + + function renderCustomModels(custom) { + var container = document.getElementById('admin-custom-models-list'); + if (!container) return; + if (custom.length === 0) { + container.innerHTML = '
No custom models added
'; + return; + } + container.innerHTML = custom.map(function(m) { + return '
' + + '' + esc(m.name) + '' + + '' + esc(m.id) + '' + + '' + esc(m.cost || '?') + '' + + '' + + '
'; + }).join(''); + + container.querySelectorAll('[data-delete-model]').forEach(function(btn) { + btn.addEventListener('click', function() { + var id = btn.dataset.deleteModel; + if (!confirm('Remove custom model "' + id + '"?')) return; + deleteCustomModel(id); + }); + }); + } + + function populateDefaultModelSelect(models, custom) { + var sel = document.getElementById('admin-default-model'); + if (!sel) return; + sel.innerHTML = ''; + var all = models.filter(function(m) { return m.enabled; }).concat(custom || []); + all.forEach(function(m) { + var opt = document.createElement('option'); + opt.value = m.id; + opt.textContent = m.name + ' (' + (m.cost || '?') + ')'; + sel.appendChild(opt); + }); + + // Load current default + fetch('/api/admin/config', { headers: getAuthHeaders() }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (!data.success) return; + var cfg = {}; + (data.config || []).forEach(function(row) { cfg[row.key] = row.value; }); + if (cfg['models.default']) sel.value = cfg['models.default']; + }); + } + + function addCustomModel() { + var id = document.getElementById('admin-custom-id').value.trim(); + var name = document.getElementById('admin-custom-name').value.trim(); + var cost = document.getElementById('admin-custom-cost').value.trim(); + if (!id || !name) { showToast('Model ID and name required', 'error'); return; } + + fetch('/api/admin/config/models/custom', { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ id: id, name: name, cost: cost || '?', category: 'smart' }) + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.success) { + showToast('Custom model added: ' + name, 'success'); + document.getElementById('admin-custom-id').value = ''; + document.getElementById('admin-custom-name').value = ''; + document.getElementById('admin-custom-cost').value = ''; + modelsLoaded = false; + loadModels(); + } else { + showToast(data.error || 'Add failed', 'error'); + } + }) + .catch(function() { showToast('Request failed', 'error'); }); + } + + function deleteCustomModel(modelId) { + fetch('/api/admin/config/models/custom/' + encodeURIComponent(modelId), { + method: 'DELETE', + headers: getAuthHeaders() + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.success) { + showToast('Model removed', 'info'); + modelsLoaded = false; + loadModels(); + } else { + showToast(data.error || 'Delete failed', 'error'); + } + }) + .catch(function() { showToast('Request failed', 'error'); }); + } + + function saveDefaultModel() { + var sel = document.getElementById('admin-default-model'); + if (!sel) return; + var val = sel.value; + putConfig('models.default', val) + .then(function() { showToast('Default model saved' + (val ? ': ' + val : ' (provider default)'), 'success'); }) + .catch(function() { showToast('Save failed', 'error'); }); + } + // ---- HELPERS ---- function putConfig(key, value) { diff --git a/public/js/app.js b/public/js/app.js index 1ac2bae..683b31f 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -196,14 +196,21 @@ document.addEventListener('DOMContentLoaded', function() { }); } + // Determine default model (admin override or first model) + var defaultModelId = data.defaultModel || (window._currentModels.length > 0 ? window._currentModels[0].id : ''); + if (modelSelect && window._currentModels.length > 0) { window._buildModelOptions(modelSelect); - if (costBadge) costBadge.textContent = window._currentProvider.toUpperCase() + ' | ' + window._currentModels[0].cost; + // Select the admin-configured default + if (defaultModelId) modelSelect.value = defaultModelId; + var defaultM = window._currentModels.find(function(x) { return x.id === modelSelect.value; }); + if (costBadge) costBadge.textContent = window._currentProvider.toUpperCase() + ' | ' + (defaultM ? defaultM.cost : ''); } // Populate all per-tab model selectors already in DOM document.querySelectorAll('.tab-model-select').forEach(function(sel) { window._buildModelOptions(sel); + if (defaultModelId) sel.value = defaultModelId; }); }) .catch(function(err) { console.warn('Models load failed:', err); }); diff --git a/server.js b/server.js index b7fda0f..84ddbb2 100644 --- a/server.js +++ b/server.js @@ -121,10 +121,11 @@ app.use('/api/admin', require('./src/routes/adminConfig')); const { getAvailableModels, activeProvider: modelsProvider } = require('./src/utils/models'); app.get('/api/models', async (req, res) => { try { - var { getAvailableModelsWithOverrides } = require('./src/utils/models'); + var { getAvailableModelsWithOverrides, DEFAULT_MODEL } = require('./src/utils/models'); var db = require('./src/db/database'); var models = await getAvailableModelsWithOverrides(db); - res.json({ models: models, provider: modelsProvider }); + var defaultOverride = await db.getSetting('models.default'); + res.json({ models: models, provider: modelsProvider, defaultModel: defaultOverride || DEFAULT_MODEL }); } catch(e) { res.json({ models: getAvailableModels(), provider: modelsProvider }); }