Replaces the single-host local model settings page with a full model
registry interface at /settings/local.
Hosts section:
- List existing hosts with inline edit + save + remove
- Collapsible "Add host" form
- Per-host "Fetch models" button
Models section:
- List all models with label, model name, host, context_k badge, tags
- Remove button
Add Model section:
- Host dropdown, label, model name, context_k, tags (comma-separated)
- "Fetch models from host" with auto-fill picker
Role Assignments section:
- One row per defined role (chat, orchestrator, distill, coder, research)
- Primary + backup_1 + backup_2 dropdowns per role
- Dropdowns pre-filled from registry on load
- AJAX save on change (POST /api/models/role) with toast confirmation
- Built-in models (claude_cli, gemini_cli, gemini_api) always available in dropdowns
Backend:
- All user_settings references replaced with model_registry
- host/{id}/remove route added
- fetch-models now accepts host_id query param
- POST /api/models/role for AJAX role assignment
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
475 lines
19 KiB
HTML
475 lines
19 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Cortex — Model Registry</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
body {
|
|
min-height: 100vh;
|
|
background: #0f1117;
|
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
|
font-weight: 450;
|
|
-webkit-font-smoothing: antialiased;
|
|
color: #e2e8f0;
|
|
padding: 2rem 1.5rem 4rem;
|
|
}
|
|
|
|
.page { max-width: 700px; margin: 0 auto; }
|
|
|
|
/* ── Nav ── */
|
|
.page-nav {
|
|
display: flex; align-items: center; gap: 0.25rem;
|
|
margin-bottom: 1.75rem; flex-wrap: wrap;
|
|
}
|
|
.nav-link {
|
|
display: inline-flex; align-items: center;
|
|
padding: 0.3rem 0.6rem; border-radius: 6px;
|
|
font-size: 0.8rem; font-weight: 500; color: #64748b;
|
|
text-decoration: none; transition: color 0.15s, background 0.15s;
|
|
white-space: nowrap;
|
|
}
|
|
.nav-link:hover { color: #cbd5e1; background: rgba(255,255,255,0.05); }
|
|
.nav-link.active { color: #a78bfa; }
|
|
.nav-spacer { flex: 1; min-width: 0.5rem; }
|
|
.nav-link.nav-logout { color: #475569; }
|
|
.nav-link.nav-logout:hover { color: #94a3b8; background: none; }
|
|
|
|
/* ── Page header ── */
|
|
.page-header { margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 1px solid #2d3148; }
|
|
.page-header h1 { font-size: 1.4rem; font-weight: 700; color: #a78bfa; }
|
|
.page-header p { font-size: 0.82rem; color: #94a3b8; margin-top: 0.25rem; }
|
|
|
|
/* ── Section cards ── */
|
|
.section {
|
|
background: #1a1d27; border: 1px solid #2d3148;
|
|
border-radius: 10px; padding: 1.5rem; margin-bottom: 1.25rem;
|
|
}
|
|
.section h2 {
|
|
font-size: 0.85rem; font-weight: 600; color: #94a3b8;
|
|
text-transform: uppercase; letter-spacing: 0.05em;
|
|
margin-bottom: 1.1rem; padding-bottom: 0.5rem;
|
|
border-bottom: 1px solid #2d3148;
|
|
}
|
|
.section-note {
|
|
font-size: 0.8rem; color: #64748b; margin-bottom: 1rem; line-height: 1.5;
|
|
}
|
|
|
|
/* ── Form elements ── */
|
|
.field { margin-bottom: 0.9rem; }
|
|
label {
|
|
display: block; font-size: 0.78rem; font-weight: 500;
|
|
color: #94a3b8; margin-bottom: 0.35rem;
|
|
}
|
|
input[type="text"], input[type="password"], input[type="url"],
|
|
input[type="number"], select {
|
|
width: 100%; padding: 0.6rem 0.8rem;
|
|
background: #0f1117; border: 1px solid #2d3148; border-radius: 6px;
|
|
color: #e2e8f0; font-size: 0.9rem; font-family: inherit;
|
|
outline: none; transition: border-color 0.15s;
|
|
}
|
|
input:focus, select:focus { border-color: #7c3aed; }
|
|
select { cursor: pointer; }
|
|
input[type="number"] { width: 6rem; }
|
|
|
|
.field-row { display: flex; gap: 0.75rem; }
|
|
.field-row .field { flex: 1; margin-bottom: 0; }
|
|
|
|
.key-status { font-size: 0.75rem; color: #94a3b8; margin-top: 0.35rem; }
|
|
|
|
/* ── Buttons ── */
|
|
.btn {
|
|
padding: 0.6rem 1.1rem; border: none; border-radius: 6px;
|
|
font-size: 0.88rem; font-weight: 600; cursor: pointer;
|
|
transition: background 0.15s, opacity 0.15s; font-family: inherit;
|
|
}
|
|
.btn-primary { background: #7c3aed; color: #fff; }
|
|
.btn-primary:hover { background: #6d28d9; }
|
|
.btn-secondary {
|
|
background: #1a1d27; color: #94a3b8;
|
|
border: 1px solid #2d3148;
|
|
}
|
|
.btn-secondary:hover { border-color: #94a3b8; color: #e2e8f0; }
|
|
.btn-sm { padding: 0.35rem 0.7rem; font-size: 0.8rem; font-weight: 500; }
|
|
.btn-row { display: flex; gap: 0.6rem; align-items: center; margin-top: 0.75rem; flex-wrap: wrap; }
|
|
.btn-link {
|
|
background: none; border: none; cursor: pointer; font-family: inherit;
|
|
font-size: 0.78rem; color: #64748b; padding: 0; text-decoration: underline;
|
|
text-underline-offset: 2px;
|
|
}
|
|
.btn-link:hover { color: #94a3b8; }
|
|
.btn-link.danger { color: #7f1d1d; }
|
|
.btn-link.danger:hover { color: #f87171; }
|
|
|
|
/* ── Host rows ── */
|
|
.host-row {
|
|
background: #0f1117; border: 1px solid #2d3148; border-radius: 8px;
|
|
padding: 1rem; margin-bottom: 0.75rem;
|
|
}
|
|
.host-form .field-row { margin-bottom: 0.6rem; }
|
|
.fetch-status { font-size: 0.78rem; color: #94a3b8; }
|
|
.fetch-status.ok { color: #4ade80; }
|
|
.fetch-status.err { color: #f87171; }
|
|
|
|
/* ── Model rows ── */
|
|
.model-row {
|
|
display: flex; align-items: flex-start; justify-content: space-between;
|
|
gap: 0.75rem; padding: 0.75rem 0.9rem;
|
|
background: #0f1117; border: 1px solid #2d3148; border-radius: 8px;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.model-info { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; }
|
|
.model-label { font-size: 0.9rem; font-weight: 600; color: #e2e8f0; }
|
|
.model-name { font-size: 0.75rem; color: #64748b; font-family: monospace; word-break: break-all; }
|
|
.model-host { font-size: 0.72rem; color: #475569; }
|
|
.ctx-badge {
|
|
display: inline-block; margin-left: 0.4rem;
|
|
padding: 0.1rem 0.35rem; border-radius: 3px;
|
|
background: #1e293b; color: #64748b;
|
|
font-size: 0.67rem; font-weight: 600;
|
|
}
|
|
.tag-row { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.2rem; }
|
|
.tag {
|
|
padding: 0.1rem 0.4rem; border-radius: 3px;
|
|
background: #1e1b4b; color: #818cf8;
|
|
font-size: 0.68rem; font-weight: 500;
|
|
}
|
|
.model-actions { display: flex; gap: 0.4rem; flex-shrink: 0; }
|
|
.row-btn {
|
|
padding: 0.3rem 0.65rem; border-radius: 5px; font-size: 0.78rem;
|
|
font-weight: 500; cursor: pointer; font-family: inherit;
|
|
border: 1px solid #2d3148; background: #1a1d27; color: #94a3b8;
|
|
transition: border-color 0.15s, color 0.15s;
|
|
}
|
|
.row-btn.danger { color: #f87171; }
|
|
.row-btn.danger:hover { border-color: #f87171; }
|
|
|
|
/* ── Role assignment rows ── */
|
|
.role-row {
|
|
display: flex; align-items: flex-start; gap: 1rem;
|
|
padding: 0.6rem 0; border-bottom: 1px solid #1e2030;
|
|
}
|
|
.role-row:last-child { border-bottom: none; }
|
|
.role-name {
|
|
font-size: 0.82rem; font-weight: 600; color: #a78bfa;
|
|
min-width: 6rem; padding-top: 0.45rem;
|
|
}
|
|
.role-slots { display: flex; flex-wrap: wrap; gap: 0.5rem; flex: 1; }
|
|
.role-slot { display: flex; flex-direction: column; gap: 0.2rem; flex: 1; min-width: 8rem; }
|
|
.slot-label { font-size: 0.68rem; color: #475569; font-weight: 500; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
.role-select {
|
|
padding: 0.4rem 0.6rem; font-size: 0.8rem;
|
|
background: #0f1117; border: 1px solid #2d3148; border-radius: 6px;
|
|
color: #e2e8f0; font-family: inherit; cursor: pointer; outline: none;
|
|
transition: border-color 0.15s;
|
|
}
|
|
.role-select:focus { border-color: #7c3aed; }
|
|
.role-select.saved { border-color: #166534; }
|
|
.role-select.saving { border-color: #92400e; }
|
|
.role-select.err { border-color: #7f1d1d; }
|
|
|
|
/* ── Add model section ── */
|
|
#add-section .field-row { margin-bottom: 0.5rem; }
|
|
#model-select-wrap { display: none; margin-bottom: 0.75rem; }
|
|
.tags-hint { font-size: 0.72rem; color: #475569; margin-top: 0.3rem; }
|
|
|
|
/* ── Messages ── */
|
|
.msg {
|
|
font-size: 0.85rem; text-align: center;
|
|
padding: 0.6rem 1rem; border-radius: 6px; margin-bottom: 1rem;
|
|
}
|
|
.msg.success { color: #4ade80; background: #052e16; border: 1px solid #166534; }
|
|
.msg.error { color: #f87171; background: #2d0a0a; border: 1px solid #7f1d1d; }
|
|
|
|
/* ── Toast ── */
|
|
#toast {
|
|
position: fixed; bottom: 1.5rem; right: 1.5rem;
|
|
background: #1a1d27; border: 1px solid #166534; color: #4ade80;
|
|
padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.82rem;
|
|
opacity: 0; transition: opacity 0.2s; pointer-events: none;
|
|
z-index: 100;
|
|
}
|
|
#toast.show { opacity: 1; }
|
|
#toast.err { border-color: #7f1d1d; color: #f87171; }
|
|
|
|
.empty-note { font-size: 0.85rem; color: #475569; padding: 0.3rem 0; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="page">
|
|
<nav class="page-nav">
|
|
<a href="/" class="nav-link">← Chat</a>
|
|
<a href="/help" class="nav-link">Help</a>
|
|
<a href="/settings" class="nav-link">Settings</a>
|
|
<a href="/settings/local" class="nav-link active">Models</a>
|
|
<span class="nav-spacer"></span>
|
|
<a href="/logout" class="nav-link nav-logout">Sign out</a>
|
|
</nav>
|
|
|
|
<div class="page-header">
|
|
<h1>Model Registry</h1>
|
|
<p>Configure hosts, models, and which model handles each task type.</p>
|
|
</div>
|
|
|
|
<!-- SUCCESS -->
|
|
<!-- ERROR -->
|
|
|
|
<!-- ── Hosts ── -->
|
|
<div class="section">
|
|
<h2>Hosts</h2>
|
|
<p class="section-note">OpenAI-compatible API servers (Open WebUI, Ollama, LM Studio, etc.)</p>
|
|
{{ host_rows }}
|
|
<details style="margin-top:0.75rem">
|
|
<summary style="font-size:0.82rem; color:#64748b; cursor:pointer; user-select:none">+ Add host</summary>
|
|
<div style="margin-top:0.75rem">
|
|
<form method="POST" action="/settings/local/host">
|
|
<input type="hidden" name="host_id" value="">
|
|
<div class="field-row">
|
|
<div class="field">
|
|
<label for="new-host-label">Label</label>
|
|
<input type="text" id="new-host-label" name="label"
|
|
placeholder="e.g. Gaming Laptop"
|
|
autocomplete="off" data-form-type="other">
|
|
</div>
|
|
<div class="field" style="flex:2">
|
|
<label for="new-host-url">API URL</label>
|
|
<input type="text" id="new-host-url" name="api_url"
|
|
placeholder="http://192.168.x.x:3000"
|
|
autocomplete="off" spellcheck="false" data-form-type="other">
|
|
</div>
|
|
</div>
|
|
<div class="field">
|
|
<label for="new-host-key">API Key</label>
|
|
<input type="password" id="new-host-key" name="api_key"
|
|
placeholder="sk-… (leave blank if not required)"
|
|
autocomplete="new-password" data-1p-ignore data-lpignore="true" data-form-type="other">
|
|
</div>
|
|
<div class="btn-row">
|
|
<button type="submit" class="btn btn-primary btn-sm">Add Host</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
|
|
<!-- ── Models ── -->
|
|
<div class="section">
|
|
<h2>Models</h2>
|
|
{{ model_rows }}
|
|
</div>
|
|
|
|
<!-- ── Add Model ── -->
|
|
<div class="section" id="add-section"{{ add_model_hidden }}>
|
|
<h2>Add Model</h2>
|
|
<div id="model-select-wrap">
|
|
<div class="field">
|
|
<label for="model-picker">Available on host</label>
|
|
<select id="model-picker">
|
|
<option value="">— select to auto-fill —</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<form method="POST" action="/settings/local/models/add" id="add-form">
|
|
<input type="hidden" name="host_id" id="add-host-id" value="">
|
|
<div class="field">
|
|
<label for="add-host-select">Host</label>
|
|
<select id="add-host-select" onchange="document.getElementById('add-host-id').value=this.value">
|
|
{{ host_options }}
|
|
</select>
|
|
</div>
|
|
<div class="field-row">
|
|
<div class="field">
|
|
<label for="add-label">Label</label>
|
|
<input type="text" id="add-label" name="label"
|
|
placeholder="e.g. Gemma 4 E4B"
|
|
autocomplete="off" data-form-type="other">
|
|
</div>
|
|
<div class="field" style="flex:2">
|
|
<label for="add-model-name">Model name</label>
|
|
<input type="text" id="add-model-name" name="model_name"
|
|
placeholder="e.g. gemma4:e4b"
|
|
autocomplete="off" spellcheck="false" data-form-type="other">
|
|
</div>
|
|
</div>
|
|
<div class="field-row">
|
|
<div class="field" style="flex:0 0 auto">
|
|
<label for="add-context-k">Context (k tokens)</label>
|
|
<input type="number" id="add-context-k" name="context_k"
|
|
value="0" min="0" max="10000">
|
|
</div>
|
|
<div class="field">
|
|
<label for="add-tags">Tags <span style="color:#475569; font-weight:400">(comma-separated)</span></label>
|
|
<input type="text" id="add-tags" name="tags"
|
|
placeholder="fast, distill, coding"
|
|
autocomplete="off" data-form-type="other">
|
|
<p class="tags-hint">Informational labels — used for display and future filtering.</p>
|
|
</div>
|
|
</div>
|
|
<div class="btn-row">
|
|
<button type="submit" class="btn btn-primary btn-sm">Add Model</button>
|
|
<button type="button" id="fetch-btn" class="btn btn-secondary btn-sm">
|
|
Fetch models from host
|
|
</button>
|
|
<span id="fetch-status" class="fetch-status"></span>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- ── Role Assignments ── -->
|
|
<div class="section">
|
|
<h2>Role Assignments</h2>
|
|
<p class="section-note">
|
|
Choose which model handles each task type.
|
|
Backups are tried in order if the primary fails or is unavailable.
|
|
Leave a slot empty to use the server default (.env).
|
|
</p>
|
|
{{ role_rows }}
|
|
</div>
|
|
</div>
|
|
|
|
<div id="toast"></div>
|
|
|
|
<script>
|
|
// ── Pre-fill role selects ─────────────────────────────────────────────────
|
|
const ROLE_DATA = {{ role_data_js }};
|
|
|
|
document.querySelectorAll('.role-select').forEach(sel => {
|
|
const role = sel.dataset.role;
|
|
const slot = sel.dataset.slot;
|
|
const val = (ROLE_DATA[role] || {})[slot] || '';
|
|
for (const opt of sel.options) {
|
|
if (opt.value === val) { opt.selected = true; break; }
|
|
}
|
|
});
|
|
|
|
// ── Role select change → AJAX save ───────────────────────────────────────
|
|
const toast = document.getElementById('toast');
|
|
let toastTimer = null;
|
|
|
|
function showToast(msg, err = false) {
|
|
toast.textContent = msg;
|
|
toast.className = 'show' + (err ? ' err' : '');
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => { toast.className = ''; }, 2000);
|
|
}
|
|
|
|
document.querySelectorAll('.role-select').forEach(sel => {
|
|
sel.addEventListener('change', async () => {
|
|
const role = sel.dataset.role;
|
|
const slot = sel.dataset.slot;
|
|
const model_id = sel.value || null;
|
|
|
|
sel.classList.add('saving');
|
|
try {
|
|
const res = await fetch('/api/models/role', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({role, slot, model_id}),
|
|
});
|
|
const data = await res.json();
|
|
if (data.ok) {
|
|
sel.classList.replace('saving', 'saved');
|
|
showToast(`${role} → ${slot} saved`);
|
|
setTimeout(() => sel.classList.remove('saved'), 1200);
|
|
} else {
|
|
sel.classList.replace('saving', 'err');
|
|
showToast(data.error || 'Save failed', true);
|
|
setTimeout(() => sel.classList.remove('err'), 2000);
|
|
}
|
|
} catch (e) {
|
|
sel.classList.replace('saving', 'err');
|
|
showToast(e.message, true);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Fetch models from host ────────────────────────────────────────────────
|
|
// Per-host "Fetch models" buttons in the host rows
|
|
document.querySelectorAll('.fetch-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => fetchModels(btn.dataset.hostId, btn));
|
|
});
|
|
|
|
// "Fetch models from host" in Add Model section (uses selected host)
|
|
const globalFetchBtn = document.getElementById('fetch-btn');
|
|
if (globalFetchBtn) {
|
|
globalFetchBtn.addEventListener('click', () => {
|
|
const hostSel = document.getElementById('add-host-select');
|
|
const hostId = hostSel ? hostSel.value : '';
|
|
fetchModels(hostId, globalFetchBtn, true);
|
|
});
|
|
}
|
|
|
|
async function fetchModels(hostId, btn, fillAddForm = false) {
|
|
const statusEl = fillAddForm
|
|
? document.getElementById('fetch-status')
|
|
: document.getElementById('fetch-' + hostId);
|
|
|
|
btn.disabled = true;
|
|
if (statusEl) { statusEl.textContent = 'Fetching…'; statusEl.className = 'fetch-status'; }
|
|
|
|
const url = '/api/local-llm/fetch-models' + (hostId ? '?host_id=' + encodeURIComponent(hostId) : '');
|
|
try {
|
|
const res = await fetch(url);
|
|
const data = await res.json();
|
|
|
|
if (data.error) {
|
|
if (statusEl) { statusEl.textContent = '✗ ' + data.error; statusEl.className = 'fetch-status err'; }
|
|
return;
|
|
}
|
|
|
|
if (fillAddForm) {
|
|
const picker = document.getElementById('model-picker');
|
|
const wrap = document.getElementById('model-select-wrap');
|
|
picker.innerHTML = '<option value="">— select to auto-fill —</option>';
|
|
for (const m of data.models) {
|
|
const opt = document.createElement('option');
|
|
opt.value = m.id;
|
|
opt.textContent = m.name !== m.id ? `${m.name} (${m.id})` : m.id;
|
|
opt.dataset.id = m.id;
|
|
opt.dataset.name = m.name;
|
|
picker.appendChild(opt);
|
|
}
|
|
wrap.style.display = 'block';
|
|
}
|
|
|
|
if (statusEl) {
|
|
statusEl.textContent = `✓ ${data.models.length} model${data.models.length !== 1 ? 's' : ''}`;
|
|
statusEl.className = 'fetch-status ok';
|
|
}
|
|
} catch (e) {
|
|
if (statusEl) { statusEl.textContent = '✗ ' + e.message; statusEl.className = 'fetch-status err'; }
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// Auto-fill label + model name when a model is selected from the picker
|
|
const picker = document.getElementById('model-picker');
|
|
if (picker) {
|
|
picker.addEventListener('change', () => {
|
|
const opt = picker.options[picker.selectedIndex];
|
|
if (!opt.value) return;
|
|
const nameInput = document.getElementById('add-model-name');
|
|
const labelInput = document.getElementById('add-label');
|
|
nameInput.value = opt.dataset.id || opt.value;
|
|
labelInput.value = (opt.dataset.name && opt.dataset.name !== opt.dataset.id)
|
|
? opt.dataset.name : '';
|
|
nameInput.focus();
|
|
});
|
|
}
|
|
|
|
// Sync hidden host_id input from the visible select
|
|
const addHostSel = document.getElementById('add-host-select');
|
|
const addHostId = document.getElementById('add-host-id');
|
|
if (addHostSel && addHostId) {
|
|
addHostId.value = addHostSel.value;
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|