Add admin model management dashboard — enable/disable, custom models, default override

- 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
This commit is contained in:
Daniel Onyejesi 2026-03-28 22:07:16 +00:00
parent 044c809ff3
commit 007eef6887
5 changed files with 253 additions and 3 deletions

View file

@ -202,6 +202,64 @@
</div>
</div>
<!-- ── CMS: AI Model Management ─────────────────────────────── -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-microchip"></i> AI Models</h3>
<span id="admin-model-provider" style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--blue-light);color:var(--blue);font-weight:600;">Loading...</span>
</div>
<div style="padding:16px;display:flex;flex-direction:column;gap:16px;">
<!-- Default model override -->
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<label style="font-size:13px;font-weight:600;min-width:120px;">Default Model:</label>
<select id="admin-default-model" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:400px;"></select>
<button id="btn-save-default-model" class="btn-sm btn-primary">Save</button>
</div>
<!-- Built-in models table -->
<div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:8px;">Built-in Models <span style="font-weight:400;color:var(--g500);">(toggle to show/hide from users)</span></label>
<div style="overflow-x:auto;">
<table class="admin-table" id="admin-models-table">
<thead>
<tr>
<th>Model</th>
<th>Category</th>
<th>Cost</th>
<th>Tag</th>
<th style="text-align:center;">Enabled</th>
</tr>
</thead>
<tbody id="admin-models-body">
<tr><td colspan="5" style="text-align:center;color:var(--g400);padding:20px;">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Custom models -->
<div>
<label style="font-size:13px;font-weight:600;display:block;margin-bottom:8px;">Custom Models <span style="font-weight:400;color:var(--g500);">(add any OpenRouter/Bedrock model ID)</span></label>
<div id="admin-custom-models-list" style="margin-bottom:12px;"></div>
<div style="display:grid;grid-template-columns:1fr 1fr auto auto;gap:8px;align-items:end;">
<div>
<label style="font-size:11px;font-weight:600;color:var(--g500);display:block;margin-bottom:2px;">Model ID</label>
<input type="text" id="admin-custom-id" style="width:100%;font-size:12px;padding:6px 8px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;" placeholder="e.g. openai/gpt-4o">
</div>
<div>
<label style="font-size:11px;font-weight:600;color:var(--g500);display:block;margin-bottom:2px;">Display Name</label>
<input type="text" id="admin-custom-name" style="width:100%;font-size:12px;padding:6px 8px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;" placeholder="e.g. GPT-4o">
</div>
<div>
<label style="font-size:11px;font-weight:600;color:var(--g500);display:block;margin-bottom:2px;">Cost</label>
<input type="text" id="admin-custom-cost" style="width:80px;font-size:12px;padding:6px 8px;border:1px solid var(--g300);border-radius:6px;box-sizing:border-box;" placeholder="~$0.01">
</div>
<button id="btn-add-custom-model" class="btn-sm btn-primary" style="margin-bottom:1px;"><i class="fas fa-plus"></i> Add</button>
</div>
</div>
</div>
</div>
<!-- ── CMS: Reset to Defaults ─────────────────────────────────── -->
<div class="card" style="border:1px solid var(--red-light);">
<div class="card-header"><h3 style="color:var(--red);"><i class="fas fa-triangle-exclamation"></i> Reset Settings</h3></div>

View file

@ -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;}

View file

@ -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 = '<tr><td colspan="5" style="text-align:center;color:var(--g400);padding:20px;">No models</td></tr>';
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 '<tr>' +
'<td style="font-size:13px;"><div style="font-weight:600;">' + esc(m.name) + '</div><div style="font-size:11px;color:var(--g400);font-family:monospace;">' + esc(m.id) + '</div></td>' +
'<td><span style="font-size:12px;color:' + catColor + ';font-weight:600;text-transform:uppercase;">' + esc(m.category) + '</span></td>' +
'<td style="font-size:12px;color:var(--g600);">' + esc(m.cost || '?') + '</td>' +
'<td><span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--g100);color:var(--g600);">' + esc(m.tag || '') + '</span></td>' +
'<td style="text-align:center;"><label class="toggle-switch"><input type="checkbox" class="model-toggle" data-model-id="' + esc(m.id) + '" ' + checked + '><span class="toggle-slider"></span></label></td>' +
'</tr>';
}).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 = '<div style="font-size:12px;color:var(--g400);padding:4px 0;">No custom models added</div>';
return;
}
container.innerHTML = custom.map(function(m) {
return '<div style="display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--g50);border-radius:6px;margin-bottom:4px;">' +
'<span style="font-size:13px;font-weight:600;flex:1;">' + esc(m.name) + '</span>' +
'<span style="font-size:11px;color:var(--g500);font-family:monospace;">' + esc(m.id) + '</span>' +
'<span style="font-size:11px;color:var(--g500);">' + esc(m.cost || '?') + '</span>' +
'<button class="btn-sm" style="background:var(--red-light);color:var(--red);border:none;border-radius:4px;padding:2px 8px;font-size:11px;cursor:pointer;" data-delete-model="' + esc(m.id) + '"><i class="fas fa-trash"></i></button>' +
'</div>';
}).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 = '<option value="">(Provider default)</option>';
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) {

View file

@ -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); });

View file

@ -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 });
}