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:
parent
044c809ff3
commit
007eef6887
5 changed files with 253 additions and 3 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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); });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue