feat: model registry UI — hosts, models, role assignments

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>
This commit is contained in:
Scott Idem
2026-04-05 21:31:32 -04:00
parent 6a1a1c2686
commit 608e1de246
2 changed files with 466 additions and 215 deletions

View File

@@ -1,13 +1,14 @@
""" """
Local LLM settings — per-user host and model configuration. Model Registry settings — hosts, models, and role assignments.
Routes: Routes:
GET /settings/local → settings page GET /settings/local → settings page
POST /settings/local/host → save/create host POST /settings/local/host → save/create a host
POST /settings/local/models/add → add model entry POST /settings/local/host/{id}/remove → remove a host (and its models)
POST /settings/local/models/{id}/activate → set active model POST /settings/local/models/add → add a model entry
POST /settings/local/models/{id}/remove → remove model entry POST /settings/local/models/{id}/remove → remove a model
GET /api/local-llm/fetch-models → proxy to host /api/models (JSON) POST /api/models/role → AJAX: set a role assignment
GET /api/local-llm/fetch-models → proxy to host /api/models (JSON)
""" """
import logging import logging
from pathlib import Path from pathlib import Path
@@ -19,7 +20,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from auth_utils import COOKIE_NAME, decode_token from auth_utils import COOKIE_NAME, decode_token
from config import settings as app_settings from config import settings as app_settings
import user_settings as us import model_registry as reg
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -42,81 +43,141 @@ def _get_user(request: Request) -> str | None:
# ── Page renderer ───────────────────────────────────────────────────────────── # ── Page renderer ─────────────────────────────────────────────────────────────
def _render(username: str, success: str = "", error: str = "") -> str: def _render(username: str, success: str = "", error: str = "") -> str:
cfg = us.get_config(username) registry = reg.get_registry(username)
hosts = cfg["hosts"] hosts = registry.get("hosts", [])
models = cfg["models"] models = registry.get("models", [])
active = cfg.get("active_model_id") roles = registry.get("roles", {})
builtins = reg._builtins()
# Build a host lookup for model rows
host_by_id = {h["id"]: h for h in hosts} host_by_id = {h["id"]: h for h in hosts}
# ── Host section ────────────────────────────────────────────────────────── # ── Host rows ─────────────────────────────────────────────────────────────
if hosts: host_rows = ""
h = hosts[0] # one host for now for h in hosts:
host_id_val = h["id"] key_hint = f"{h['api_key'][-4:]}" if h.get("api_key") else "not set"
host_label = h.get("label", "") host_rows += f'''
host_url = h.get("api_url", "") <div class="host-row">
host_key_hint = f"{h['api_key'][-4:]}" if h.get("api_key") else "not set" <form method="POST" action="/settings/local/host" class="host-form">
else: <input type="hidden" name="host_id" value="{h["id"]}">
host_id_val = "" <div class="field-row">
host_label = "" <div class="field">
host_url = app_settings.local_api_url <label>Label</label>
host_key_hint = f"server default (…{app_settings.local_api_key[-4:]})" \ <input type="text" name="label" value="{h.get("label","")}"
if app_settings.local_api_key else "not set" placeholder="Home ML Laptop" autocomplete="off" data-form-type="other">
</div>
<div class="field" style="flex:2">
<label>API URL</label>
<input type="text" name="api_url" value="{h.get("api_url","")}"
placeholder="http://192.168.x.x:3000"
autocomplete="off" spellcheck="false" data-form-type="other">
</div>
</div>
<div class="field">
<label>API Key</label>
<input type="password" name="api_key" placeholder="Leave blank to keep existing"
autocomplete="new-password" data-1p-ignore data-lpignore="true" data-form-type="other">
<p class="key-status">Current: {key_hint}</p>
</div>
<div class="btn-row">
<button type="submit" class="btn btn-secondary btn-sm">Save host</button>
<button type="button" class="btn btn-secondary btn-sm fetch-btn"
data-host-id="{h["id"]}">Fetch models</button>
<span class="fetch-status" id="fetch-{h["id"]}"></span>
</div>
</form>
<form method="POST" action="/settings/local/host/{h["id"]}/remove"
onsubmit="return confirm('Remove host and all its models?')" style="margin-top:0.5rem">
<button type="submit" class="btn-link danger">Remove host</button>
</form>
</div>'''
if not host_rows:
host_rows = '<p class="empty-note">No hosts configured yet. Add one below.</p>'
# ── Host options for add-model form ───────────────────────────────────────
host_options = "".join(
f'<option value="{h["id"]}">{h.get("label") or h["api_url"]}</option>'
for h in hosts
)
add_model_hidden = "" if hosts else ' style="display:none"'
# ── Model rows ──────────────────────────────────────────────────────────── # ── Model rows ────────────────────────────────────────────────────────────
model_rows = "" model_rows = ""
for m in models: for m in models:
is_active = m["id"] == active resolved = reg._resolve_model(registry, m["id"])
host = host_by_id.get(m["host_id"], {}) if not resolved:
host_name = host.get("label") or host.get("api_url") or "unknown host" continue
badge = '<span class="active-badge">active</span>' if is_active else "" host_name = ""
activate_btn = ( if m.get("type") == "local_openai" and m.get("host_id"):
'<span class="active-label">✓ Active</span>' h = host_by_id.get(m["host_id"], {})
if is_active else host_name = h.get("label") or h.get("api_url", "")
f'''<form method="POST" action="/settings/local/models/{m["id"]}/activate" style="display:inline">
<button type="submit" class="row-btn">Set active</button> ctx_badge = f'<span class="ctx-badge">{m.get("context_k",0)}k ctx</span>' if m.get("context_k") else ""
</form>''' tags_html = " ".join(
f'<span class="tag">{t}</span>' for t in (m.get("tags") or [])
) )
host_html = f'<span class="model-host">{host_name}</span>' if host_name else ""
model_rows += f''' model_rows += f'''
<div class="model-row{" model-active" if is_active else ""}"> <div class="model-row" id="model-{m["id"]}">
<div class="model-info"> <div class="model-info">
<span class="model-label">{m.get("label") or m["model_name"]}</span>{badge} <span class="model-label">{m.get("label") or m.get("model_name","")}</span>
<span class="model-name">{m["model_name"]}</span> <span class="model-name">{m.get("model_name","")}</span>
<span class="model-host">{host_name}</span> {host_html}{ctx_badge}
<div class="tag-row">{tags_html}</div>
</div> </div>
<div class="model-actions"> <div class="model-actions">
{activate_btn} <form method="POST" action="/settings/local/models/{m["id"]}/remove"
<form method="POST" action="/settings/local/models/{m["id"]}/remove" style="display:inline" onsubmit="return confirm('Remove this model?')" style="display:inline">
onsubmit="return confirm('Remove {m.get('label') or m['model_name']}?')">
<button type="submit" class="row-btn danger">Remove</button> <button type="submit" class="row-btn danger">Remove</button>
</form> </form>
</div> </div>
</div>''' </div>'''
if not model_rows: if not model_rows:
model_rows = '<p class="empty-note">No models added yet. Use "Add Model" below.</p>' model_rows = '<p class="empty-note">No models added yet.</p>'
# ── Host select for Add Model ───────────────────────────────────────────── # ── Role assignment rows ──────────────────────────────────────────────────
host_options = "".join( # Build option list: (none) + built-ins + user models
f'<option value="{h["id"]}">{h.get("label") or h["api_url"]}</option>' model_opts = '<option value="">— .env default —</option>\n'
for h in hosts model_opts += '<optgroup label="Built-in">\n'
) for bid, bm in builtins.items():
add_section_hidden = "" if hosts else ' style="display:none"' model_opts += f' <option value="{bid}">{bm["label"]}</option>\n'
model_opts += '</optgroup>\n'
if models:
model_opts += '<optgroup label="Local models">\n'
for m in models:
lbl = m.get("label") or m.get("model_name", m["id"])
model_opts += f' <option value="{m["id"]}">{lbl}</option>\n'
model_opts += '</optgroup>\n'
role_rows = ""
for role in app_settings.get_defined_roles():
role_cfg = roles.get(role, {})
role_rows += f'<div class="role-row" data-role="{role}"><span class="role-name">{role.title()}</span><div class="role-slots">'
for slot in reg.PRIORITY_KEYS[:3]: # primary + backup_1 + backup_2
current = role_cfg.get(slot) or ""
slot_label = slot.replace("_", " ").title()
sel_html = f'<select class="role-select" data-role="{role}" data-slot="{slot}" title="{slot_label}">\n{model_opts}\n</select>'
# Pre-select current value via JS (simpler than string-building selected attrs)
role_rows += f'<div class="role-slot"><span class="slot-label">{slot_label}</span>{sel_html}</div>'
role_rows += '</div></div>'
# JS data for pre-selecting current role values
import json as _json
role_data_js = _json.dumps({
role: {slot: (roles.get(role, {}).get(slot) or "") for slot in reg.PRIORITY_KEYS[:3]}
for role in app_settings.get_defined_roles()
})
html = (_STATIC / "local_llm.html").read_text() html = (_STATIC / "local_llm.html").read_text()
first_host_id = hosts[0]["id"] if hosts else ""
html = html.replace("{{ username }}", username) html = html.replace("{{ username }}", username)
html = html.replace("{{ host_id }}", host_id_val) html = html.replace("{{ host_rows }}", host_rows)
html = html.replace("{{ host_label }}", host_label) html = html.replace("{{ model_rows }}", model_rows)
html = html.replace("{{ host_url }}", host_url) html = html.replace("{{ host_options }}", host_options)
html = html.replace("{{ host_key_hint }}", host_key_hint) html = html.replace("{{ add_model_hidden }}", add_model_hidden)
html = html.replace("{{ model_rows }}", model_rows) html = html.replace("{{ role_rows }}", role_rows)
html = html.replace("{{ host_options }}", host_options) html = html.replace("{{ role_data_js }}", role_data_js)
html = html.replace("{{ first_host_id }}", first_host_id)
html = html.replace("{{ add_section_hidden }}", add_section_hidden)
html = html.replace("{{ has_host }}", "true" if hosts else "false")
if success: if success:
html = html.replace("<!-- SUCCESS -->", f'<p class="msg success">{success}</p>') html = html.replace("<!-- SUCCESS -->", f'<p class="msg success">{success}</p>')
if error: if error:
@@ -127,7 +188,7 @@ def _render(username: str, success: str = "", error: str = "") -> str:
# ── Routes ──────────────────────────────────────────────────────────────────── # ── Routes ────────────────────────────────────────────────────────────────────
@router.get("/settings/local", include_in_schema=False) @router.get("/settings/local", include_in_schema=False)
async def local_llm_page(request: Request): async def models_page(request: Request):
username = _get_user(request) username = _get_user(request)
if not username: if not username:
return RedirectResponse("/login", status_code=302) return RedirectResponse("/login", status_code=302)
@@ -145,45 +206,40 @@ async def save_host(
username = _get_user(request) username = _get_user(request)
if not username: if not username:
return RedirectResponse("/login", status_code=302) return RedirectResponse("/login", status_code=302)
if not api_url.strip(): if not api_url.strip():
return HTMLResponse(_render(username, error="API URL is required.")) return HTMLResponse(_render(username, error="API URL is required."))
reg.save_host(username, host_id or None, label, api_url, api_key)
us.save_host(username, host_id or None, label, api_url, api_key) logger.info("model registry host saved: %s", username)
logger.info("local LLM host saved: %s", username)
return HTMLResponse(_render(username, success="Host saved.")) return HTMLResponse(_render(username, success="Host saved."))
@router.post("/settings/local/host/{host_id}/remove", include_in_schema=False)
async def remove_host(request: Request, host_id: str):
username = _get_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
reg.remove_host(username, host_id)
return HTMLResponse(_render(username, success="Host removed."))
@router.post("/settings/local/models/add", include_in_schema=False) @router.post("/settings/local/models/add", include_in_schema=False)
async def add_model( async def add_model(
request: Request, request: Request,
host_id: str = Form(...), host_id: str = Form(...),
label: str = Form(""), label: str = Form(""),
model_name: str = Form(...), model_name: str = Form(...),
context_k: int = Form(0),
tags: str = Form(""),
): ):
username = _get_user(request) username = _get_user(request)
if not username: if not username:
return RedirectResponse("/login", status_code=302) return RedirectResponse("/login", status_code=302)
if not model_name.strip(): if not model_name.strip():
return HTMLResponse(_render(username, error="Model name is required.")) return HTMLResponse(_render(username, error="Model name is required."))
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
us.add_model(username, host_id, label, model_name) reg.save_model(username, None, host_id, label, model_name, context_k, tag_list)
logger.info("local model added: %s / %s", username, model_name) logger.info("model added to registry: %s / %s", username, model_name)
return HTMLResponse(_render(username, success=f"Model \"{label or model_name}\" added.")) return HTMLResponse(_render(username, success=f'Model "{label or model_name}" added.'))
@router.post("/settings/local/models/{model_id}/activate", include_in_schema=False)
async def activate_model(request: Request, model_id: str):
username = _get_user(request)
if not username:
return RedirectResponse("/login", status_code=302)
if not us.set_active_model(username, model_id):
return HTMLResponse(_render(username, error="Model not found."))
logger.info("active local model set: %s / %s", username, model_id)
return HTMLResponse(_render(username, success="Active model updated."))
@router.post("/settings/local/models/{model_id}/remove", include_in_schema=False) @router.post("/settings/local/models/{model_id}/remove", include_in_schema=False)
@@ -191,30 +247,58 @@ async def remove_model(request: Request, model_id: str):
username = _get_user(request) username = _get_user(request)
if not username: if not username:
return RedirectResponse("/login", status_code=302) return RedirectResponse("/login", status_code=302)
reg.remove_model(username, model_id)
us.remove_model(username, model_id)
logger.info("local model removed: %s / %s", username, model_id)
return HTMLResponse(_render(username, success="Model removed.")) return HTMLResponse(_render(username, success="Model removed."))
@router.get("/api/local-llm/fetch-models") @router.post("/api/models/role")
async def fetch_models(request: Request) -> JSONResponse: async def set_role(request: Request) -> JSONResponse:
"""Proxy to the configured host's /api/models endpoint. """AJAX: assign a model to a role priority slot.
Returns [{id, name}] sorted by name, or an error dict. Body: {"role": "chat", "slot": "primary", "model_id": "abc123" | ""}
""" """
username = _get_user(request) username = _get_user(request)
if not username: if not username:
return JSONResponse({"error": "Not authenticated"}, status_code=401) return JSONResponse({"error": "Not authenticated"}, status_code=401)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
cfg = us.get_config(username) role = body.get("role", "").strip()
hosts = cfg.get("hosts", []) slot = body.get("slot", "").strip()
model_id = body.get("model_id", "").strip() or None
# Fall back to .env if no host configured yet if not role or not slot:
if hosts: return JSONResponse({"error": "role and slot are required"}, status_code=400)
h = hosts[0]
api_url = h.get("api_url", "") ok = reg.set_role(username, role, slot, model_id)
api_key = h.get("api_key", "") if not ok:
return JSONResponse({"error": f"Invalid slot or model_id not found"}, status_code=400)
logger.info("role set: %s %s.%s = %s", username, role, slot, model_id)
return JSONResponse({"ok": True})
@router.get("/api/local-llm/fetch-models")
async def fetch_models(request: Request, host_id: str = "") -> JSONResponse:
"""Proxy to the host's /api/models endpoint. host_id selects which host."""
username = _get_user(request)
if not username:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
registry = reg.get_registry(username)
hosts = registry.get("hosts", [])
if host_id:
host = next((h for h in hosts if h["id"] == host_id), None)
else:
host = hosts[0] if hosts else None
# Fall back to .env
if host:
api_url = host.get("api_url", "")
api_key = host.get("api_key", "")
else: else:
api_url = app_settings.local_api_url api_url = app_settings.local_api_url
api_key = app_settings.local_api_key api_key = app_settings.local_api_key

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <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">
<title>Cortex — Local Models</title> <title>Cortex — Model Registry</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">
@@ -20,7 +20,7 @@
padding: 2rem 1.5rem 4rem; padding: 2rem 1.5rem 4rem;
} }
.page { max-width: 640px; margin: 0 auto; } .page { max-width: 700px; margin: 0 auto; }
/* ── Nav ── */ /* ── Nav ── */
.page-nav { .page-nav {
@@ -56,6 +56,9 @@
margin-bottom: 1.1rem; padding-bottom: 0.5rem; margin-bottom: 1.1rem; padding-bottom: 0.5rem;
border-bottom: 1px solid #2d3148; border-bottom: 1px solid #2d3148;
} }
.section-note {
font-size: 0.8rem; color: #64748b; margin-bottom: 1rem; line-height: 1.5;
}
/* ── Form elements ── */ /* ── Form elements ── */
.field { margin-bottom: 0.9rem; } .field { margin-bottom: 0.9rem; }
@@ -63,7 +66,8 @@
display: block; font-size: 0.78rem; font-weight: 500; display: block; font-size: 0.78rem; font-weight: 500;
color: #94a3b8; margin-bottom: 0.35rem; color: #94a3b8; margin-bottom: 0.35rem;
} }
input[type="text"], input[type="password"], input[type="url"], select { input[type="text"], input[type="password"], input[type="url"],
input[type="number"], select {
width: 100%; padding: 0.6rem 0.8rem; width: 100%; padding: 0.6rem 0.8rem;
background: #0f1117; border: 1px solid #2d3148; border-radius: 6px; background: #0f1117; border: 1px solid #2d3148; border-radius: 6px;
color: #e2e8f0; font-size: 0.9rem; font-family: inherit; color: #e2e8f0; font-size: 0.9rem; font-family: inherit;
@@ -71,11 +75,12 @@
} }
input:focus, select:focus { border-color: #7c3aed; } input:focus, select:focus { border-color: #7c3aed; }
select { cursor: pointer; } select { cursor: pointer; }
input[type="number"] { width: 6rem; }
.field-row { display: flex; gap: 0.75rem; } .field-row { display: flex; gap: 0.75rem; }
.field-row .field { flex: 1; margin-bottom: 0; } .field-row .field { flex: 1; margin-bottom: 0; }
.hint { font-size: 0.75rem; color: #94a3b8; margin-top: 0.35rem; } .key-status { font-size: 0.75rem; color: #94a3b8; margin-top: 0.35rem; }
/* ── Buttons ── */ /* ── Buttons ── */
.btn { .btn {
@@ -91,28 +96,49 @@
} }
.btn-secondary:hover { border-color: #94a3b8; color: #e2e8f0; } .btn-secondary:hover { border-color: #94a3b8; color: #e2e8f0; }
.btn-sm { padding: 0.35rem 0.7rem; font-size: 0.8rem; font-weight: 500; } .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.5rem; } .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; }
/* ── Model list ── */ /* ── 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 { .model-row {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: flex-start; justify-content: space-between;
gap: 0.75rem; padding: 0.75rem 0.9rem; gap: 0.75rem; padding: 0.75rem 0.9rem;
background: #0f1117; border: 1px solid #2d3148; border-radius: 8px; background: #0f1117; border: 1px solid #2d3148; border-radius: 8px;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.model-row.model-active { border-color: #7c3aed; background: #13102a; }
.model-info { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; } .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-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-name { font-size: 0.75rem; color: #64748b; font-family: monospace; word-break: break-all; }
.model-host { font-size: 0.72rem; color: #475569; } .model-host { font-size: 0.72rem; color: #475569; }
.active-badge { .ctx-badge {
display: inline-block; margin-left: 0.5rem; display: inline-block; margin-left: 0.4rem;
padding: 0.1rem 0.45rem; border-radius: 3px; padding: 0.1rem 0.35rem; border-radius: 3px;
background: #4c1d95; color: #c4b5fd; background: #1e293b; color: #64748b;
font-size: 0.68rem; font-weight: 600; text-transform: uppercase; font-size: 0.67rem; font-weight: 600;
vertical-align: middle; }
.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;
} }
.active-label { font-size: 0.8rem; color: #a78bfa; font-weight: 500; }
.model-actions { display: flex; gap: 0.4rem; flex-shrink: 0; } .model-actions { display: flex; gap: 0.4rem; flex-shrink: 0; }
.row-btn { .row-btn {
padding: 0.3rem 0.65rem; border-radius: 5px; font-size: 0.78rem; padding: 0.3rem 0.65rem; border-radius: 5px; font-size: 0.78rem;
@@ -120,16 +146,37 @@
border: 1px solid #2d3148; background: #1a1d27; color: #94a3b8; border: 1px solid #2d3148; background: #1a1d27; color: #94a3b8;
transition: border-color 0.15s, color 0.15s; transition: border-color 0.15s, color 0.15s;
} }
.row-btn:hover { border-color: #7c3aed; color: #a78bfa; } .row-btn.danger { color: #f87171; }
.row-btn.danger { color: #f87171; border-color: #2d3148; }
.row-btn.danger:hover { border-color: #f87171; } .row-btn.danger:hover { border-color: #f87171; }
.empty-note { font-size: 0.85rem; color: #475569; padding: 0.5rem 0; }
/* ── Fetch models ── */ /* ── Role assignment rows ── */
#fetch-status { font-size: 0.8rem; color: #94a3b8; margin-top: 0.5rem; min-height: 1.2rem; } .role-row {
#fetch-status.ok { color: #4ade80; } display: flex; align-items: flex-start; gap: 1rem;
#fetch-status.err { color: #f87171; } padding: 0.6rem 0; border-bottom: 1px solid #1e2030;
#model-select-wrap { display: none; margin-top: 0.75rem; } }
.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 ── */ /* ── Messages ── */
.msg { .msg {
@@ -139,8 +186,18 @@
.msg.success { color: #4ade80; background: #052e16; border: 1px solid #166534; } .msg.success { color: #4ade80; background: #052e16; border: 1px solid #166534; }
.msg.error { color: #f87171; background: #2d0a0a; border: 1px solid #7f1d1d; } .msg.error { color: #f87171; background: #2d0a0a; border: 1px solid #7f1d1d; }
/* ── Key hint ── */ /* ── Toast ── */
.key-status { font-size: 0.75rem; color: #94a3b8; margin-top: 0.35rem; } #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> </style>
</head> </head>
<body> <body>
@@ -149,159 +206,269 @@
<a href="/" class="nav-link">← Chat</a> <a href="/" class="nav-link">← Chat</a>
<a href="/help" class="nav-link">Help</a> <a href="/help" class="nav-link">Help</a>
<a href="/settings" class="nav-link">Settings</a> <a href="/settings" class="nav-link">Settings</a>
<a href="/settings/local" class="nav-link active">Local Models</a> <a href="/settings/local" class="nav-link active">Models</a>
<span class="nav-spacer"></span> <span class="nav-spacer"></span>
<a href="/logout" class="nav-link nav-logout">Sign out</a> <a href="/logout" class="nav-link nav-logout">Sign out</a>
</nav> </nav>
<div class="page-header"> <div class="page-header">
<h1>Local Models</h1> <h1>Model Registry</h1>
<p>Configure your OpenAI-compatible host and models (Open WebUI, Ollama, LM Studio, etc.)</p> <p>Configure hosts, models, and which model handles each task type.</p>
</div> </div>
<!-- SUCCESS --> <!-- SUCCESS -->
<!-- ERROR --> <!-- ERROR -->
<!-- ── Host ── --> <!-- ── Hosts ── -->
<div class="section"> <div class="section">
<h2>Host</h2> <h2>Hosts</h2>
<p style="font-size:0.82rem; color:#94a3b8; margin-bottom:1rem; line-height:1.55;"> <p class="section-note">OpenAI-compatible API servers (Open WebUI, Ollama, LM Studio, etc.)</p>
The API server that hosts your local models. Leave the key blank to keep the existing one. {{ host_rows }}
</p> <details style="margin-top:0.75rem">
<form method="POST" action="/settings/local/host"> <summary style="font-size:0.82rem; color:#64748b; cursor:pointer; user-select:none">+ Add host</summary>
<input type="hidden" name="host_id" value="{{ host_id }}"> <div style="margin-top:0.75rem">
<div class="field-row"> <form method="POST" action="/settings/local/host">
<div class="field"> <input type="hidden" name="host_id" value="">
<label for="host_label">Label</label> <div class="field-row">
<input type="text" id="host_label" name="label" <div class="field">
value="{{ host_label }}" placeholder="e.g. Home ML Laptop" <label for="new-host-label">Label</label>
autocomplete="off" data-form-type="other"> <input type="text" id="new-host-label" name="label"
</div> placeholder="e.g. Gaming Laptop"
<div class="field" style="flex:2"> autocomplete="off" data-form-type="other">
<label for="host_url">API URL</label> </div>
<input type="text" id="host_url" name="api_url" <div class="field" style="flex:2">
value="{{ host_url }}" placeholder="http://192.168.x.x:3000" <label for="new-host-url">API URL</label>
autocomplete="off" spellcheck="false" data-form-type="other"> <input type="text" id="new-host-url" name="api_url"
</div> 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> </div>
<div class="field"> </details>
<label for="host_key">API Key</label>
<input type="password" id="host_key" name="api_key"
placeholder="{{ host_key_hint }}"
autocomplete="new-password"
data-1p-ignore data-lpignore="true" data-form-type="other">
<p class="key-status">Current: {{ host_key_hint }}</p>
</div>
<div class="btn-row">
<button type="submit" class="btn btn-primary btn-sm">Save Host</button>
<button type="button" id="fetch-btn" class="btn btn-secondary btn-sm"
{{ has_host == 'false' and 'disabled title="Save a host first"' or '' }}>
Fetch models from host
</button>
<span id="fetch-status"></span>
</div>
</form>
</div> </div>
<!-- ── Configured models ── --> <!-- ── Models ── -->
<div class="section"> <div class="section">
<h2>Models</h2> <h2>Models</h2>
{{ model_rows }} {{ model_rows }}
</div> </div>
<!-- ── Add model ── --> <!-- ── Add Model ── -->
<div class="section" id="add-section"{{ add_section_hidden }}> <div class="section" id="add-section"{{ add_model_hidden }}>
<h2>Add Model</h2> <h2>Add Model</h2>
<div id="model-select-wrap"> <div id="model-select-wrap">
<div class="field"> <div class="field">
<label for="model-picker">Available on host</label> <label for="model-picker">Available on host</label>
<select id="model-picker"> <select id="model-picker">
<option value="">— select a model —</option> <option value="">— select to auto-fill —</option>
</select> </select>
</div> </div>
</div> </div>
<form method="POST" action="/settings/local/models/add" id="add-form"> <form method="POST" action="/settings/local/models/add" id="add-form">
<input type="hidden" name="host_id" value="{{ first_host_id }}"> <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-row">
<div class="field"> <div class="field">
<label for="add-label">Label <span style="color:#475569; font-weight:400">(friendly name)</span></label> <label for="add-label">Label</label>
<input type="text" id="add-label" name="label" <input type="text" id="add-label" name="label"
placeholder="e.g. Qwen3 8B" placeholder="e.g. Gemma 4 E4B"
autocomplete="off" data-form-type="other"> autocomplete="off" data-form-type="other">
</div> </div>
<div class="field" style="flex:2"> <div class="field" style="flex:2">
<label for="add-model-name">Model name</label> <label for="add-model-name">Model name</label>
<input type="text" id="add-model-name" name="model_name" <input type="text" id="add-model-name" name="model_name"
placeholder="e.g. test-agent-simple" placeholder="e.g. gemma4:e4b"
autocomplete="off" spellcheck="false" data-form-type="other"> autocomplete="off" spellcheck="false" data-form-type="other">
</div> </div>
</div> </div>
<button type="submit" class="btn btn-primary btn-sm">Add Model</button> <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> </form>
</div> </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>
<div id="toast"></div>
<script> <script>
const fetchBtn = document.getElementById('fetch-btn'); // ── Pre-fill role selects ─────────────────────────────────────────────────
const fetchStatus = document.getElementById('fetch-status'); const ROLE_DATA = {{ role_data_js }};
const picker = document.getElementById('model-picker');
const pickerWrap = document.getElementById('model-select-wrap');
const labelInput = document.getElementById('add-label');
const nameInput = document.getElementById('add-model-name');
if (fetchBtn) { document.querySelectorAll('.role-select').forEach(sel => {
fetchBtn.addEventListener('click', async () => { const role = sel.dataset.role;
fetchBtn.disabled = true; const slot = sel.dataset.slot;
fetchStatus.textContent = 'Fetching…'; const val = (ROLE_DATA[role] || {})[slot] || '';
fetchStatus.className = ''; 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 { try {
const res = await fetch('/api/local-llm/fetch-models'); 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(); const data = await res.json();
if (data.ok) {
if (data.error) { sel.classList.replace('saving', 'saved');
fetchStatus.textContent = '✗ ' + data.error; showToast(`${role}${slot} saved`);
fetchStatus.className = 'err'; setTimeout(() => sel.classList.remove('saved'), 1200);
return; } 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);
}
});
});
picker.innerHTML = '<option value="">— select a model —</option>'; // ── 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) { for (const m of data.models) {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = m.id; opt.value = m.id;
opt.textContent = m.name !== m.id ? `${m.name} (${m.id})` : m.id; opt.textContent = m.name !== m.id ? `${m.name} (${m.id})` : m.id;
opt.dataset.id = m.id; opt.dataset.id = m.id;
opt.dataset.name = m.name; opt.dataset.name = m.name;
picker.appendChild(opt); picker.appendChild(opt);
} }
wrap.style.display = 'block';
pickerWrap.style.display = 'block';
fetchStatus.textContent = `${data.models.length} model${data.models.length !== 1 ? 's' : ''} found`;
fetchStatus.className = 'ok';
} catch (e) {
fetchStatus.textContent = '✗ ' + e.message;
fetchStatus.className = 'err';
} finally {
fetchBtn.disabled = false;
} }
});
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 // Auto-fill label + model name when a model is selected from the picker
picker.addEventListener('change', () => { const picker = document.getElementById('model-picker');
const opt = picker.options[picker.selectedIndex]; if (picker) {
if (!opt.value) return; picker.addEventListener('change', () => {
nameInput.value = opt.dataset.id || opt.value; const opt = picker.options[picker.selectedIndex];
// Only pre-fill label if it looks different from the model id if (!opt.value) return;
if (opt.dataset.name && opt.dataset.name !== opt.dataset.id) { const nameInput = document.getElementById('add-model-name');
labelInput.value = opt.dataset.name; const labelInput = document.getElementById('add-label');
} else { nameInput.value = opt.dataset.id || opt.value;
labelInput.value = ''; labelInput.value = (opt.dataset.name && opt.dataset.name !== opt.dataset.id)
} ? opt.dataset.name : '';
nameInput.focus(); 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> </script>
</body> </body>
</html> </html>