Adds `anthropic_api` model type so users can authenticate with a direct
Anthropic API key instead of (or alongside) the CLI OAuth session.
- model_registry.py: `anthropic_api` type; `save/get/remove_anthropic_api_key()`
mirroring the Google account pattern; `save_cloud_model()` now picks type
based on credential type (cli → claude_cli, api_key → anthropic_api);
`_resolve_model()` merges api_key from the credential entry
- llm_client.py: `_anthropic_api()` backend (AsyncAnthropic SDK); dispatch
and fallback wiring; usage tracking
- routers/local_llm.py: Anthropic API key management routes
(POST /settings/local/anthropic-key, /anthropic-key/{id}/remove);
`anthropic_api` badge and edit-form credential selector
- static/local_llm.html: Anthropic Cloud Provider block now shows API key
management (add/remove); Add Model → Anthropic tab has credential selector
(CLI vs API key)
- requirements.txt: enable anthropic>=0.40.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1010 lines
45 KiB
HTML
1010 lines
45 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">
|
|
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</script>
|
|
<style>
|
|
:root {
|
|
--pg-bg: #0f1117; --pg-surface: #1a1d27;
|
|
--pg-border: #2d3148; --pg-border-deep: #1e2030;
|
|
--pg-text: #e2e8f0; --pg-muted: #94a3b8;
|
|
--pg-dim: #64748b; --pg-dimmer: #475569;
|
|
--pg-bright: #cbd5e1; --pg-nav-hover: rgba(255,255,255,0.05);
|
|
}
|
|
[data-theme="light"] {
|
|
--pg-bg: #f4f2fa; --pg-surface: #ffffff;
|
|
--pg-border: #d0c8e8; --pg-border-deep: #d0c8e8;
|
|
--pg-text: #1a1228; --pg-muted: #5a5478;
|
|
--pg-dim: #7a7290; --pg-dimmer: #9e98b0;
|
|
--pg-bright: #1a1228; --pg-nav-hover: rgba(0,0,0,0.05);
|
|
}
|
|
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body {
|
|
min-height: 100vh; background: var(--pg-bg);
|
|
font-family: 'Inter', system-ui, sans-serif; font-weight: 450;
|
|
-webkit-font-smoothing: antialiased; color: var(--pg-text);
|
|
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: var(--pg-dim);
|
|
text-decoration: none; transition: color 0.15s, background 0.15s; white-space: nowrap;
|
|
}
|
|
.nav-link:hover { color: var(--pg-bright); background: var(--pg-nav-hover); }
|
|
.nav-link.active { color: #a78bfa; }
|
|
.nav-spacer { flex: 1; min-width: 0.5rem; }
|
|
.nav-link.nav-logout { color: var(--pg-dimmer); }
|
|
.nav-link.nav-logout:hover { color: var(--pg-muted); background: none; }
|
|
|
|
/* Page header */
|
|
.page-header { margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 1px solid var(--pg-border); }
|
|
.page-header h1 { font-size: 1.4rem; font-weight: 700; color: #a78bfa; }
|
|
.page-header p { font-size: 0.82rem; color: var(--pg-muted); margin-top: 0.25rem; }
|
|
|
|
/* Section cards */
|
|
.section {
|
|
background: var(--pg-surface); border: 1px solid var(--pg-border);
|
|
border-radius: 10px; padding: 1.5rem; margin-bottom: 1.25rem;
|
|
}
|
|
.section h2 {
|
|
font-size: 0.85rem; font-weight: 600; color: var(--pg-muted);
|
|
text-transform: uppercase; letter-spacing: 0.05em;
|
|
margin-bottom: 1.1rem; padding-bottom: 0.5rem;
|
|
border-bottom: 1px solid var(--pg-border);
|
|
}
|
|
.section-note { font-size: 0.8rem; color: var(--pg-dim); margin-bottom: 1rem; line-height: 1.5; }
|
|
|
|
/* Provider sub-sections */
|
|
.provider-block { margin-bottom: 1.25rem; }
|
|
.provider-block:last-child { margin-bottom: 0; }
|
|
.provider-header {
|
|
display: flex; align-items: center; gap: 0.6rem;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
.provider-icon {
|
|
width: 1.6rem; height: 1.6rem; border-radius: 5px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-size: 0.75rem; font-weight: 700; flex-shrink: 0;
|
|
}
|
|
.pi-anthropic { background: #1e1b4b; color: #818cf8; }
|
|
.pi-google { background: #042f2e; color: #34d399; }
|
|
[data-theme="light"] .pi-anthropic { background: #ede9fe; color: #5b21b6; }
|
|
[data-theme="light"] .pi-google { background: #d1fae5; color: #065f46; }
|
|
.provider-title { font-size: 0.9rem; font-weight: 600; color: var(--pg-text); }
|
|
.provider-subtitle { font-size: 0.78rem; color: var(--pg-dim); }
|
|
|
|
/* Auth status row */
|
|
.auth-status {
|
|
display: flex; align-items: center; gap: 0.5rem;
|
|
font-size: 0.8rem; margin-top: 0.6rem; color: var(--pg-muted);
|
|
}
|
|
.auth-status .dot {
|
|
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
|
|
background: var(--pg-dim);
|
|
}
|
|
.auth-status.ok .dot { background: #4ade80; }
|
|
.auth-status.ok { color: #4ade80; }
|
|
.auth-status.warn { color: #fbbf24; }
|
|
.auth-status.warn .dot { background: #fbbf24; }
|
|
.auth-status.err .dot { background: #f87171; }
|
|
.auth-status.err { color: #f87171; }
|
|
|
|
/* Account rows */
|
|
.account-row {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 0.6rem 0.9rem; background: var(--pg-bg);
|
|
border: 1px solid var(--pg-border); border-radius: 8px; margin-bottom: 0.5rem;
|
|
}
|
|
.account-label { font-size: 0.88rem; font-weight: 600; color: var(--pg-text); }
|
|
.account-hint { font-size: 0.73rem; color: var(--pg-dimmer); margin-left: 0.5rem; font-family: monospace; }
|
|
|
|
/* Form elements */
|
|
.field { margin-bottom: 0.9rem; }
|
|
label { display: block; font-size: 0.78rem; font-weight: 500; color: var(--pg-muted); 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: var(--pg-bg); border: 1px solid var(--pg-border); border-radius: 6px;
|
|
color: var(--pg-text); 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: var(--pg-muted); 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: var(--pg-surface); color: var(--pg-muted); border: 1px solid var(--pg-border); }
|
|
.btn-secondary:hover { border-color: var(--pg-muted); color: var(--pg-text); }
|
|
.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: var(--pg-dim); padding: 0;
|
|
text-decoration: underline; text-underline-offset: 2px;
|
|
}
|
|
.btn-link:hover { color: var(--pg-muted); }
|
|
.btn-link.danger { color: #7f1d1d; }
|
|
.btn-link.danger:hover { color: #f87171; }
|
|
|
|
/* Provider tabs */
|
|
.ptabs { display: flex; gap: 0; margin-bottom: 1.1rem; border-bottom: 1px solid var(--pg-border); }
|
|
.ptab {
|
|
padding: 0.45rem 0.9rem; font-size: 0.82rem; font-weight: 500;
|
|
background: none; border: none; cursor: pointer; color: var(--pg-dim);
|
|
border-bottom: 2px solid transparent; margin-bottom: -1px;
|
|
transition: color 0.15s, border-color 0.15s; font-family: inherit;
|
|
}
|
|
.ptab.active { color: #a78bfa; border-bottom-color: #a78bfa; }
|
|
.ptab:hover:not(.active) { color: var(--pg-bright); }
|
|
|
|
/* Provider badges on model rows */
|
|
.pbadge {
|
|
display: inline-block; padding: 0.1rem 0.35rem; border-radius: 3px;
|
|
font-size: 0.65rem; font-weight: 600; margin-right: 0.35rem;
|
|
vertical-align: middle;
|
|
}
|
|
.pb-anthropic { background: #1e1b4b; color: #818cf8; }
|
|
.pb-google { background: #042f2e; color: #34d399; }
|
|
.pb-local { background: #1e293b; color: #64748b; }
|
|
.pb-notools { background: #3b1a1a; color: #f87171; }
|
|
[data-theme="light"] .pb-anthropic { background: #ede9fe; color: #5b21b6; }
|
|
[data-theme="light"] .pb-google { background: #d1fae5; color: #065f46; }
|
|
[data-theme="light"] .pb-local { background: #e2e8f0; color: #475569; }
|
|
[data-theme="light"] .pb-notools { background: #fee2e2; color: #b91c1c; }
|
|
|
|
/* Host & model rows */
|
|
.host-row {
|
|
background: var(--pg-bg); border: 1px solid var(--pg-border); border-radius: 8px;
|
|
padding: 1rem; margin-bottom: 0.75rem;
|
|
}
|
|
.host-form .field-row { margin-bottom: 0.6rem; }
|
|
.fetch-status { font-size: 0.78rem; color: var(--pg-muted); }
|
|
.fetch-status.ok { color: #4ade80; }
|
|
.fetch-status.err { color: #f87171; }
|
|
|
|
.model-row {
|
|
background: var(--pg-bg); border: 1px solid var(--pg-border); border-radius: 8px;
|
|
margin-bottom: 0.5rem; overflow: hidden;
|
|
}
|
|
.model-row-header {
|
|
display: flex; align-items: flex-start; justify-content: space-between;
|
|
gap: 0.75rem; padding: 0.75rem 0.9rem;
|
|
}
|
|
.model-btns { display: flex; gap: 0.4rem; align-items: flex-start; flex-shrink: 0; }
|
|
.model-edit-form {
|
|
padding: 0.85rem 0.9rem 0.9rem;
|
|
border-top: 1px solid var(--pg-border);
|
|
background: var(--pg-surface);
|
|
}
|
|
.model-info { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; }
|
|
.model-label { font-size: 0.9rem; font-weight: 600; color: var(--pg-text); }
|
|
.model-name { font-size: 0.75rem; color: var(--pg-dim); font-family: monospace; word-break: break-all; }
|
|
.model-host { font-size: 0.72rem; color: var(--pg-dimmer); }
|
|
.ctx-badge {
|
|
display: inline-block; margin-left: 0.35rem;
|
|
padding: 0.1rem 0.3rem; border-radius: 3px;
|
|
background: #1e293b; color: #64748b; font-size: 0.65rem; font-weight: 600;
|
|
vertical-align: middle;
|
|
}
|
|
[data-theme="light"] .ctx-badge { background: #e2e8f0; color: #475569; }
|
|
.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; }
|
|
[data-theme="light"] .tag { background: #ede9fe; color: #5b21b6; }
|
|
.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 var(--pg-border); background: var(--pg-surface); color: var(--pg-muted);
|
|
transition: border-color 0.15s, color 0.15s; flex-shrink: 0;
|
|
}
|
|
.row-btn.danger { color: #f87171; }
|
|
.row-btn.danger:hover { border-color: #f87171; }
|
|
|
|
/* Role assignments */
|
|
.role-row {
|
|
display: flex; align-items: flex-start; gap: 1rem;
|
|
padding: 0.6rem 0; border-bottom: 1px solid var(--pg-border-deep);
|
|
}
|
|
.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: var(--pg-dimmer); font-weight: 500; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
.role-select {
|
|
padding: 0.4rem 0.6rem; font-size: 0.8rem;
|
|
background: var(--pg-bg); border: 1px solid var(--pg-border); border-radius: 6px;
|
|
color: var(--pg-text); 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; }
|
|
.role-cfg-btn {
|
|
flex-shrink: 0; padding: 0.3rem 0.55rem; font-size: 0.8rem;
|
|
background: none; border: 1px solid var(--pg-border); border-radius: 6px;
|
|
color: var(--pg-dim); cursor: pointer; transition: color 0.15s, border-color 0.15s;
|
|
margin-top: 0.35rem;
|
|
}
|
|
.role-cfg-btn:hover { color: #a78bfa; border-color: #a78bfa; }
|
|
.role-cfg-btn.active { color: #a78bfa; border-color: #a78bfa; background: rgba(167,139,250,0.08); }
|
|
/* Role config panel */
|
|
.role-config-panel {
|
|
display: none; margin: 0 0 0.75rem 7rem;
|
|
border: 1px solid var(--pg-border); border-radius: 8px;
|
|
background: var(--pg-surface); padding: 1rem;
|
|
}
|
|
.role-config-panel.open { display: block; }
|
|
.rcp-field { margin-bottom: 0.75rem; }
|
|
.rcp-label { display: block; font-size: 0.75rem; font-weight: 600; color: var(--pg-muted); margin-bottom: 0.35rem; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
.rcp-hint { font-weight: 400; text-transform: none; letter-spacing: 0; color: var(--pg-dimmer); }
|
|
.rcp-textarea {
|
|
width: 100%; resize: vertical; min-height: 4rem;
|
|
background: var(--pg-bg); border: 1px solid var(--pg-border); border-radius: 6px;
|
|
color: var(--pg-text); font-family: inherit; font-size: 0.85rem;
|
|
padding: 0.5rem 0.6rem; outline: none; transition: border-color 0.15s;
|
|
}
|
|
.rcp-textarea:focus { border-color: #7c3aed; }
|
|
.rcp-tools { display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; }
|
|
.rcp-cat { width: 100%; margin: 0.4rem 0 0.1rem; font-size: 0.7rem; font-weight: 600; color: var(--pg-dimmer); text-transform: uppercase; letter-spacing: 0.05em; }
|
|
.rcp-check { display: flex; align-items: center; gap: 0.35rem; font-size: 0.8rem; color: var(--pg-bright); cursor: pointer; }
|
|
.rcp-check input { accent-color: #a78bfa; cursor: pointer; }
|
|
.rcp-actions { display: flex; gap: 0.5rem; padding-top: 0.25rem; }
|
|
|
|
/* Model select picker */
|
|
#model-select-wrap { display: none; margin-bottom: 0.75rem; }
|
|
.tags-hint { font-size: 0.72rem; color: var(--pg-dimmer); margin-top: 0.3rem; }
|
|
|
|
/* Messages & Toast */
|
|
.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 {
|
|
position: fixed; bottom: 1.5rem; right: 1.5rem;
|
|
background: var(--pg-surface); 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: var(--pg-dimmer); padding: 0.3rem 0; }
|
|
details summary { font-size: 0.82rem; color: var(--pg-dim); cursor: pointer; user-select: none; margin-top: 0.75rem; }
|
|
details > div { margin-top: 0.75rem; }
|
|
</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/models" 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 providers, hosts, and model assignments.</p>
|
|
</div>
|
|
|
|
<!-- SUCCESS --><!-- ERROR -->
|
|
|
|
<!-- ── Cloud Providers ── -->
|
|
<div class="section">
|
|
<h2>Cloud Providers</h2>
|
|
|
|
<div class="provider-block">
|
|
<div class="provider-header">
|
|
<div class="provider-icon pi-anthropic">A</div>
|
|
<div>
|
|
<div class="provider-title">Anthropic</div>
|
|
<div class="provider-subtitle">Claude via CLI (OAuth) or direct API key</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-bottom:1rem">
|
|
<p class="section-note" style="margin-bottom:0.3rem">
|
|
<strong>CLI (OAuth):</strong> Uses your existing Claude CLI login — no API key needed.
|
|
Run <code style="font-family:monospace;color:#94a3b8">claude auth login</code> to authenticate.
|
|
</p>
|
|
<div id="claude-auth-status" class="auth-status">
|
|
<span class="dot"></span><span id="claude-auth-msg">Checking…</span>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="section-note" style="margin-bottom:0.4rem"><strong>API Keys:</strong></p>
|
|
{{ anthropic_key_rows }}
|
|
<details style="margin-top:0.5rem">
|
|
<summary>+ Add API key</summary>
|
|
<div>
|
|
<form method="POST" action="/settings/local/anthropic-key">
|
|
<input type="hidden" name="key_id" value="">
|
|
<div class="field-row">
|
|
<div class="field">
|
|
<label>Label <span style="color:#475569;font-weight:400">(e.g. Personal, Work)</span></label>
|
|
<input type="text" name="label" placeholder="Personal"
|
|
autocomplete="off" data-form-type="other">
|
|
</div>
|
|
<div class="field" style="flex:2">
|
|
<label>API Key</label>
|
|
<input type="password" name="api_key" placeholder="sk-ant-…"
|
|
autocomplete="new-password" data-1p-ignore data-lpignore="true"
|
|
data-form-type="other" required>
|
|
</div>
|
|
</div>
|
|
<button type="submit" class="btn btn-secondary btn-sm">Save</button>
|
|
</form>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
|
|
<div class="provider-block" style="border-top:1px solid #2d3148; padding-top:1.25rem">
|
|
<div class="provider-header">
|
|
<div class="provider-icon pi-google">G</div>
|
|
<div>
|
|
<div class="provider-title">Google</div>
|
|
<div class="provider-subtitle">Gemini models via Gemini API</div>
|
|
</div>
|
|
</div>
|
|
{{ google_account_rows }}
|
|
<details>
|
|
<summary>+ Add Google account</summary>
|
|
<div>
|
|
<form method="POST" action="/settings/local/google-account">
|
|
<input type="hidden" name="account_id" value="">
|
|
<div class="field-row">
|
|
<div class="field">
|
|
<label>Label <span style="color:#475569;font-weight:400">(e.g. Work, Personal)</span></label>
|
|
<input type="text" name="label" placeholder="One Sky IT"
|
|
autocomplete="off" data-form-type="other">
|
|
</div>
|
|
<div class="field" style="flex:2">
|
|
<label>API Key</label>
|
|
<input type="password" name="api_key" placeholder="AIza…"
|
|
autocomplete="new-password" data-1p-ignore data-lpignore="true"
|
|
data-form-type="other">
|
|
</div>
|
|
</div>
|
|
<div class="btn-row">
|
|
<button type="submit" class="btn btn-primary btn-sm">Add Account</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Local Hosts ── -->
|
|
<div class="section">
|
|
<h2>Local Hosts</h2>
|
|
<p class="section-note">OpenAI-compatible API servers (Open WebUI, Ollama, LM Studio, OpenRouter, etc.)</p>
|
|
{{ host_rows }}
|
|
<details>
|
|
<summary>+ Add host</summary>
|
|
<div>
|
|
<form method="POST" action="/settings/local/host">
|
|
<input type="hidden" name="host_id" value="">
|
|
<div class="field-row">
|
|
<div class="field">
|
|
<label>Label</label>
|
|
<input type="text" name="label" placeholder="Gaming Laptop"
|
|
autocomplete="off" data-form-type="other">
|
|
</div>
|
|
<div class="field" style="flex:2">
|
|
<label>API URL</label>
|
|
<input type="text" name="api_url" placeholder="http://192.168.x.x:3000"
|
|
autocomplete="off" spellcheck="false" data-form-type="other">
|
|
</div>
|
|
</div>
|
|
<div class="field-row">
|
|
<div class="field">
|
|
<label>API Key</label>
|
|
<input type="password" 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="field" style="flex:0 0 auto">
|
|
<label>Type</label>
|
|
<select name="host_type">
|
|
<option value="openwebui">Open WebUI / Ollama</option>
|
|
<option value="openai">OpenAI-compatible (OpenRouter, etc.)</option>
|
|
</select>
|
|
</div>
|
|
<div class="field" style="flex:0 0 auto; width:6rem">
|
|
<label>Max parallel</label>
|
|
<input type="number" name="max_concurrent" min="1" max="20" value="3" style="width:100%">
|
|
</div>
|
|
</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 }}
|
|
|
|
<details>
|
|
<summary>+ Add model</summary>
|
|
<div>
|
|
|
|
<div class="ptabs" id="provider-tabs">
|
|
<button type="button" class="ptab active" data-p="local">Local</button>
|
|
<button type="button" class="ptab" data-p="google">Google</button>
|
|
<button type="button" class="ptab" data-p="anthropic">Anthropic</button>
|
|
</div>
|
|
|
|
<form method="POST" action="/settings/local/models/add" id="add-form">
|
|
<input type="hidden" name="provider" id="add-provider-val" value="local">
|
|
|
|
<!-- LOCAL fields -->
|
|
<div id="pf-local">
|
|
<div id="model-select-wrap">
|
|
<div class="field">
|
|
<label>Available on host</label>
|
|
<select id="model-picker">
|
|
<option value="">— select to auto-fill —</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="field-row">
|
|
<div class="field" style="flex:0 0 auto">
|
|
<label>Host</label>
|
|
<select id="add-host-select" name="host_id"></select>
|
|
</div>
|
|
<div class="field" style="flex:2">
|
|
<label>Model name / ID</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>
|
|
|
|
<!-- GOOGLE fields -->
|
|
<div id="pf-google" style="display:none">
|
|
<div class="field-row">
|
|
<div class="field">
|
|
<label>Gemini model</label>
|
|
<select id="add-gemini-model"></select>
|
|
</div>
|
|
<div class="field">
|
|
<label>Account</label>
|
|
<select id="add-google-account" name="account_id"></select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ANTHROPIC fields -->
|
|
<div id="pf-anthropic" style="display:none">
|
|
<div class="field">
|
|
<label>Credential</label>
|
|
<select id="add-anthropic-cred">
|
|
<!-- populated by JS from ANTHROPIC_API_KEYS -->
|
|
</select>
|
|
</div>
|
|
<div class="field">
|
|
<label>Claude model</label>
|
|
<select id="add-claude-model"></select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hidden: cloud model name (set by JS from catalog pickers) -->
|
|
<input type="hidden" id="cloud-model-name" name="cloud_model_name" value="">
|
|
<!-- credential_id is set by JS when Anthropic tab is active -->
|
|
<input type="hidden" id="add-credential-id" name="credential_id" value="cli">
|
|
|
|
<!-- Shared fields -->
|
|
<div class="field-row" style="margin-top:0.75rem">
|
|
<div class="field">
|
|
<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:0 0 auto">
|
|
<label title="Context window size in thousands of tokens. 0 = assume 32k.">Context (k tokens)</label>
|
|
<input type="number" id="add-context-k" name="context_k" value="0" min="0" max="10000"
|
|
title="Context window size in thousands of tokens. 0 = assume 32k (compaction budget ~24k tokens).">
|
|
</div>
|
|
<div class="field" style="flex:0 0 auto">
|
|
<label title="Per-model tool loop cap. 0 = use the global default (orchestrator_max_rounds).">Max rounds</label>
|
|
<input type="number" name="max_rounds" value="0" min="0"
|
|
title="Per-model tool loop cap. 0 = use the global default (orchestrator_max_rounds).">
|
|
</div>
|
|
<div class="field" style="flex:0 0 auto">
|
|
<label title="Reasoning depth via OpenRouter's reasoning.budget_tokens. Off = Non-think. Light ~1k, Moderate ~4k, High ~8k, Max ~32k tokens.">Reasoning</label>
|
|
<select name="reasoning_budget_tokens"
|
|
title="Reasoning depth via OpenRouter's reasoning.budget_tokens. Off = Non-think. Light ~1k, Moderate ~4k, High ~8k, Max ~32k tokens.">
|
|
<option value="0" selected>Off — Non-think</option>
|
|
<option value="1024">Light</option>
|
|
<option value="4096">Moderate</option>
|
|
<option value="8192">High</option>
|
|
<option value="32768">Max</option>
|
|
</select>
|
|
</div>
|
|
<div class="field" style="flex:0 0 auto">
|
|
<label title="Whether this model supports tool calling. If not supported, requests skip the tool loop entirely.">Tool calling</label>
|
|
<select name="tools"
|
|
title="Whether this model supports tool calling. If not supported, requests skip the tool loop entirely.">
|
|
<option value="1" selected>Supported</option>
|
|
<option value="0">Not supported</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="field">
|
|
<label>Tags <span style="color:#475569;font-weight:400">(comma-separated)</span></label>
|
|
<input type="text" 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 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 from host</button>
|
|
<span id="fetch-status" class="fetch-status"></span>
|
|
</div>
|
|
</form>
|
|
|
|
</div>
|
|
</details>
|
|
</div>
|
|
|
|
<!-- ── Role Assignments ── -->
|
|
<div class="section">
|
|
<h2>Role Assignments</h2>
|
|
<p class="section-note">
|
|
Map each task type to a model. Primary is tried first; backups are used if primary fails or is unavailable.
|
|
</p>
|
|
{{ role_rows }}
|
|
</div>
|
|
</div>
|
|
|
|
<div id="toast"></div>
|
|
|
|
<script>
|
|
// ── Injected data ─────────────────────────────────────────────────────────
|
|
const ROLE_DATA = {{ role_data_js }};
|
|
const ROLE_CONFIG_DATA = {{ role_config_data_js }};
|
|
const TOOL_CATEGORIES = {{ tool_categories_js }};
|
|
const GOOGLE_ACCOUNTS = {{ google_accounts_js }};
|
|
const ANTHROPIC_API_KEYS = {{ anthropic_keys_js }};
|
|
const GOOGLE_CATALOG = {{ google_catalog_js }};
|
|
const ANTHROPIC_CATALOG = {{ anthropic_catalog_js }};
|
|
const HAS_HOSTS = {{ has_hosts }};
|
|
|
|
// ── Role selects: pre-fill + 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 = ''; }, 2500);
|
|
}
|
|
|
|
document.querySelectorAll('.role-select').forEach(sel => {
|
|
const val = (ROLE_DATA[sel.dataset.role] || {})[sel.dataset.slot] || '';
|
|
for (const opt of sel.options) {
|
|
if (opt.value === val) { opt.selected = true; break; }
|
|
}
|
|
sel.addEventListener('change', async () => {
|
|
const { role, slot } = sel.dataset;
|
|
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);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Role config panels ────────────────────────────────────────────────────
|
|
|
|
// All tool names in category order (for checkbox rendering)
|
|
const ALL_TOOLS_ORDERED = Object.entries(TOOL_CATEGORIES).flatMap(([,tools]) => tools);
|
|
|
|
function buildToolChecklist(role, savedTools) {
|
|
// savedTools: null = all, array = explicit allow-list
|
|
const wrap = document.getElementById(`rcp-tools-${role}`);
|
|
if (!wrap) return;
|
|
wrap.innerHTML = '';
|
|
for (const [cat, tools] of Object.entries(TOOL_CATEGORIES)) {
|
|
const catEl = document.createElement('div');
|
|
catEl.className = 'rcp-cat';
|
|
catEl.style.width = '100%';
|
|
catEl.textContent = cat;
|
|
wrap.appendChild(catEl);
|
|
for (const tool of tools) {
|
|
const label = document.createElement('label');
|
|
label.className = 'rcp-check';
|
|
const cb = document.createElement('input');
|
|
cb.type = 'checkbox';
|
|
cb.value = tool;
|
|
cb.checked = savedTools === null || savedTools.includes(tool);
|
|
label.appendChild(cb);
|
|
label.appendChild(document.createTextNode(tool));
|
|
wrap.appendChild(label);
|
|
}
|
|
}
|
|
}
|
|
|
|
function openRolePanel(role) {
|
|
const panel = document.getElementById(`rcp-${role}`);
|
|
const btn = document.querySelector(`.role-cfg-btn[data-role="${role}"]`);
|
|
const cfg = ROLE_CONFIG_DATA[role] || {};
|
|
if (!panel) return;
|
|
// Populate textarea
|
|
panel.querySelector('.rcp-textarea').value = cfg.system_append || '';
|
|
// Inject datetime checkbox (default true if not set)
|
|
const dtCb = panel.querySelector('.rcp-datetime-cb');
|
|
if (dtCb) dtCb.checked = cfg.inject_datetime !== false;
|
|
// Inject mode checkbox (default true if not set)
|
|
const modeCb = panel.querySelector('.rcp-mode-cb');
|
|
if (modeCb) modeCb.checked = cfg.inject_mode !== false;
|
|
// Build tool checklist
|
|
buildToolChecklist(role, cfg.tools || null);
|
|
panel.classList.add('open');
|
|
btn && btn.classList.add('active');
|
|
}
|
|
|
|
function closeRolePanel(role) {
|
|
const panel = document.getElementById(`rcp-${role}`);
|
|
const btn = document.querySelector(`.role-cfg-btn[data-role="${role}"]`);
|
|
panel && panel.classList.remove('open');
|
|
btn && btn.classList.remove('active');
|
|
}
|
|
|
|
document.querySelectorAll('.role-cfg-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const role = btn.dataset.role;
|
|
const panel = document.getElementById(`rcp-${role}`);
|
|
if (panel.classList.contains('open')) {
|
|
closeRolePanel(role);
|
|
} else {
|
|
// Close any other open panels first
|
|
document.querySelectorAll('.role-config-panel.open').forEach(p => {
|
|
closeRolePanel(p.id.replace('rcp-', ''));
|
|
});
|
|
openRolePanel(role);
|
|
}
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('.rcp-cancel').forEach(btn => {
|
|
btn.addEventListener('click', () => closeRolePanel(btn.dataset.role));
|
|
});
|
|
|
|
document.querySelectorAll('.rcp-save').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const role = btn.dataset.role;
|
|
const panel = document.getElementById(`rcp-${role}`);
|
|
const ta = panel.querySelector('.rcp-textarea');
|
|
const dtCb = panel.querySelector('.rcp-datetime-cb');
|
|
const inject_datetime = dtCb ? dtCb.checked : true;
|
|
const modeCb = panel.querySelector('.rcp-mode-cb');
|
|
const inject_mode = modeCb ? modeCb.checked : true;
|
|
const checks = [...panel.querySelectorAll('.rcp-tools input[type=checkbox]')];
|
|
const allChecked = checks.every(c => c.checked);
|
|
const someChecked = checks.some(c => c.checked);
|
|
const tools = allChecked ? null : (someChecked ? checks.filter(c => c.checked).map(c => c.value) : []);
|
|
|
|
btn.disabled = true;
|
|
try {
|
|
const res = await fetch('/api/models/role-config', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({role, system_append: ta.value, tools, inject_datetime, inject_mode}),
|
|
});
|
|
const data = await res.json();
|
|
if (data.ok) {
|
|
// Update local state so re-open shows current values
|
|
if (!ROLE_CONFIG_DATA[role]) ROLE_CONFIG_DATA[role] = {};
|
|
ROLE_CONFIG_DATA[role].system_append = ta.value;
|
|
ROLE_CONFIG_DATA[role].tools = tools;
|
|
ROLE_CONFIG_DATA[role].inject_datetime = inject_datetime;
|
|
ROLE_CONFIG_DATA[role].inject_mode = inject_mode;
|
|
showToast(`${role} config saved`);
|
|
closeRolePanel(role);
|
|
} else {
|
|
showToast(data.error || 'Save failed', true);
|
|
}
|
|
} catch (e) {
|
|
showToast(e.message, true);
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Provider tabs ─────────────────────────────────────────────────────────
|
|
const providerVal = document.getElementById('add-provider-val');
|
|
const pfields = {
|
|
local: document.getElementById('pf-local'),
|
|
google: document.getElementById('pf-google'),
|
|
anthropic: document.getElementById('pf-anthropic'),
|
|
};
|
|
const fetchBtn = document.getElementById('fetch-btn');
|
|
|
|
document.querySelectorAll('.ptab').forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
document.querySelectorAll('.ptab').forEach(t => t.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
const p = tab.dataset.p;
|
|
providerVal.value = p;
|
|
for (const [key, el] of Object.entries(pfields)) {
|
|
el.style.display = key === p ? '' : 'none';
|
|
}
|
|
fetchBtn.style.display = p === 'local' ? '' : 'none';
|
|
// Sync credential_id when switching to/from Anthropic
|
|
if (p === 'anthropic') {
|
|
credIdInput.value = anthropicCredSel.value || 'cli';
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Populate catalog dropdowns ────────────────────────────────────────────
|
|
function populateSelect(selEl, items, valKey, labelKey) {
|
|
selEl.innerHTML = '<option value="">— select —</option>';
|
|
items.forEach(item => {
|
|
const opt = document.createElement('option');
|
|
opt.value = item[valKey];
|
|
opt.textContent = item[labelKey];
|
|
opt.dataset.label = item.label || '';
|
|
opt.dataset.ctx = item.context_k || 0;
|
|
selEl.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
const geminiSel = document.getElementById('add-gemini-model');
|
|
const claudeSel = document.getElementById('add-claude-model');
|
|
const gAcctSel = document.getElementById('add-google-account');
|
|
const anthropicCredSel = document.getElementById('add-anthropic-cred');
|
|
const credIdInput = document.getElementById('add-credential-id');
|
|
|
|
populateSelect(geminiSel, GOOGLE_CATALOG, 'id', 'label');
|
|
populateSelect(claudeSel, ANTHROPIC_CATALOG, 'id', 'label');
|
|
|
|
// Populate Anthropic credential selector (CLI + any configured API keys)
|
|
anthropicCredSel.innerHTML = '<option value="cli">Claude CLI (OAuth)</option>';
|
|
ANTHROPIC_API_KEYS.forEach(k => {
|
|
const opt = document.createElement('option');
|
|
opt.value = k.id;
|
|
opt.textContent = (k.label || 'API Key') + (k.hint ? ` (${k.hint})` : '');
|
|
anthropicCredSel.appendChild(opt);
|
|
});
|
|
anthropicCredSel.addEventListener('change', () => {
|
|
credIdInput.value = anthropicCredSel.value || 'cli';
|
|
});
|
|
|
|
if (GOOGLE_ACCOUNTS.length) {
|
|
gAcctSel.innerHTML = '<option value="">— select account —</option>';
|
|
GOOGLE_ACCOUNTS.forEach(a => {
|
|
const opt = document.createElement('option');
|
|
opt.value = a.id;
|
|
opt.textContent = a.label || a.hint;
|
|
gAcctSel.appendChild(opt);
|
|
});
|
|
} else {
|
|
gAcctSel.innerHTML = '<option value="">No accounts configured — add one above</option>';
|
|
}
|
|
|
|
function onCatalogChange(sel) {
|
|
const opt = sel.options[sel.selectedIndex];
|
|
if (!opt.value) return;
|
|
document.getElementById('cloud-model-name').value = opt.value;
|
|
document.getElementById('add-context-k').value = opt.dataset.ctx || 0;
|
|
if (!document.getElementById('add-label').value) {
|
|
document.getElementById('add-label').value = opt.dataset.label || '';
|
|
}
|
|
}
|
|
geminiSel.addEventListener('change', () => onCatalogChange(geminiSel));
|
|
claudeSel.addEventListener('change', () => onCatalogChange(claudeSel));
|
|
|
|
// ── Host select + fetch (local) ───────────────────────────────────────────
|
|
const hostSel = document.getElementById('add-host-select');
|
|
const hostOpts = `{{ host_options }}`;
|
|
hostSel.innerHTML = hostOpts || '<option value="">No hosts configured</option>';
|
|
|
|
// Per-host "Fetch" buttons
|
|
document.querySelectorAll('.fetch-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => fetchModels(btn.dataset.hostId, btn));
|
|
});
|
|
|
|
fetchBtn.addEventListener('click', () => {
|
|
const hostId = hostSel ? hostSel.value : '';
|
|
fetchModels(hostId, fetchBtn, true);
|
|
});
|
|
|
|
async function fetchModels(hostId, btn, fillPicker = false) {
|
|
const statusEl = fillPicker ? 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 (fillPicker) {
|
|
const picker = document.getElementById('model-picker');
|
|
const wrap = document.getElementById('model-select-wrap');
|
|
picker.innerHTML = '<option value="">— select to auto-fill —</option>';
|
|
data.models.forEach(m => {
|
|
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;
|
|
}
|
|
}
|
|
|
|
const picker = document.getElementById('model-picker');
|
|
picker.addEventListener('change', () => {
|
|
const opt = picker.options[picker.selectedIndex];
|
|
if (!opt.value) return;
|
|
document.getElementById('add-model-name').value = opt.dataset.id || opt.value;
|
|
if (!document.getElementById('add-label').value) {
|
|
const n = opt.dataset.name;
|
|
document.getElementById('add-label').value = (n && n !== opt.dataset.id) ? n : '';
|
|
}
|
|
document.getElementById('add-model-name').focus();
|
|
});
|
|
|
|
// Hide fetch button initially if no hosts
|
|
if (!HAS_HOSTS) fetchBtn.style.display = 'none';
|
|
|
|
// ── Model inline edit toggle ──────────────────────────────────────────
|
|
document.querySelectorAll('.model-edit-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const id = btn.dataset.id;
|
|
const form = document.getElementById('edit-form-' + id);
|
|
const open = form.style.display !== 'none';
|
|
form.style.display = open ? 'none' : 'block';
|
|
btn.textContent = open ? 'Edit' : 'Close';
|
|
});
|
|
});
|
|
document.querySelectorAll('.model-edit-cancel').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const id = btn.dataset.id;
|
|
document.getElementById('edit-form-' + id).style.display = 'none';
|
|
document.querySelector(`.model-edit-btn[data-id="${id}"]`).textContent = 'Edit';
|
|
});
|
|
});
|
|
|
|
// ── Edit form: fetch models from host ────────────────────────────────
|
|
document.querySelectorAll('.edit-fetch-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const id = btn.dataset.id;
|
|
const hostSel = document.getElementById('edit-host-' + id);
|
|
const hostId = hostSel ? hostSel.value : '';
|
|
const statusEl = document.getElementById('edit-fetch-status-' + id);
|
|
btn.disabled = true;
|
|
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) {
|
|
statusEl.textContent = '✗ ' + data.error; statusEl.className = 'fetch-status err';
|
|
return;
|
|
}
|
|
const picker = document.getElementById('edit-model-picker-' + id);
|
|
const wrap = document.getElementById('edit-model-select-wrap-' + id);
|
|
picker.innerHTML = '<option value="">— select to fill —</option>';
|
|
data.models.forEach(m => {
|
|
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';
|
|
statusEl.textContent = `✓ ${data.models.length} model${data.models.length !== 1 ? 's' : ''}`;
|
|
statusEl.className = 'fetch-status ok';
|
|
} catch (e) {
|
|
statusEl.textContent = '✗ ' + e.message; statusEl.className = 'fetch-status err';
|
|
} finally {
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('[id^="edit-model-picker-"]').forEach(picker => {
|
|
picker.addEventListener('change', () => {
|
|
const opt = picker.options[picker.selectedIndex];
|
|
if (!opt.value) return;
|
|
const id = picker.id.replace('edit-model-picker-', '');
|
|
const form = document.getElementById('edit-form-' + id);
|
|
form.querySelector('input[name="model_name"]').value = opt.dataset.id || opt.value;
|
|
const labelInput = form.querySelector('input[name="label"]');
|
|
if (!labelInput.value) {
|
|
const n = opt.dataset.name;
|
|
labelInput.value = (n && n !== opt.dataset.id) ? n : '';
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Claude CLI auth status ─────────────────────────────────────────────
|
|
(async function() {
|
|
const el = document.getElementById('claude-auth-status');
|
|
const msg = document.getElementById('claude-auth-msg');
|
|
if (!el || !msg) return;
|
|
try {
|
|
const d = await fetch('/auth/status').then(r => r.json());
|
|
const c = d.claude;
|
|
if (!c) return;
|
|
if (c.expired) {
|
|
el.className = 'auth-status err';
|
|
msg.textContent = 'Token expired — run claude auth login on the Cortex host, then restart Cortex.';
|
|
} else if (c.warning) {
|
|
el.className = 'auth-status warn';
|
|
msg.textContent = `Token expires in ${c.access_token_hours_remaining}h — run claude auth login to refresh.`;
|
|
} else {
|
|
el.className = 'auth-status ok';
|
|
msg.textContent = `Authenticated — token valid for ${c.access_token_hours_remaining}h`;
|
|
}
|
|
} catch { msg.textContent = 'Status unavailable'; }
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|