Files
Cortex-Inara/cortex/static/local_llm.html
Scott Idem 7a27190ffe feat: custom roles, Tailwind settings pages, pg.css fixes, doc cleanup
Model Registry:
- Add per-user custom roles (add/remove via UI); required roles chat/orchestrator/distill
  are always present and cannot be removed
- Auto-migrate legacy .env-defined roles to custom_roles on first access
- Role config panel (gear): Remove role button moved inside panel; required badge below name
- Role select: Primary + Backup slots only (was three)

Settings pages — Tailwind CSS migration (CDN, preflight: false):
- local_llm.html, settings.html, help.html, notifications.html, tools_settings.html,
  crons.html, integrations.html all migrated; pg-* color tokens; dark/light via data-theme

pg.css fixes:
- input[type=checkbox/radio]: width: auto — prevents pg.css width:100% from stretching checkboxes
- btn-submit: responsive sizing via Tailwind w-full md:w-96 on each button (no longer full-width on desktop)

Documentation:
- MASTER.md, TODO__Agents.md: remove "/ Inara" from titles; description updated to "per-user AI personas"
- HELP.md: persona-agnostic language throughout (NC Talk, Google Chat, push, schedules, HA sections);
  roles section restructured to show required vs. custom roles with examples
- notifications.html: subtitle and HA description use "your persona" not "Inara"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:03:11 -04:00

1056 lines
59 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">
<link rel="stylesheet" href="/static/pg.css">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
corePlugins: { preflight: false },
darkMode: ['selector', '[data-theme="dark"]'],
theme: {
extend: {
colors: {
pg: {
bg: 'var(--pg-bg)',
surface: 'var(--pg-surface)',
border: 'var(--pg-border)',
text: 'var(--pg-text)',
muted: 'var(--pg-muted)',
dim: 'var(--pg-dim)',
dimmer: 'var(--pg-dimmer)',
bright: 'var(--pg-bright)',
accent: 'var(--pg-accent)',
action: 'var(--pg-action)',
}
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
}
}
}
}
</script>
<script>(function(){var t=localStorage.getItem('theme')||(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');document.documentElement.setAttribute('data-theme',t);})();</script>
<style>
/* Minimal residual CSS — layout primitives Tailwind can't toggle via class alone */
/* Role config panel open/close (display:none can't transition with Tailwind alone) */
.role-config-panel { display: none; }
.role-config-panel.open { display: block; }
/* Role select save/error flash colours */
.role-select.saved { border-color: #166534 !important; }
.role-select.saving { border-color: #92400e !important; }
.role-select.err { border-color: #7f1d1d !important; }
/* Auth status dot colours */
.auth-status.ok .dot { background: #4ade80; }
.auth-status.ok { color: #4ade80; }
.auth-status.warn .dot { background: #fbbf24; }
.auth-status.warn { color: #fbbf24; }
.auth-status.err .dot { background: #f87171; }
.auth-status.err { color: #f87171; }
/* Toast */
#toast {
position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 100;
padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.82rem;
background: var(--pg-surface); border: 1px solid #166534; color: #4ade80;
opacity: 0; pointer-events: none; transition: opacity 0.2s;
}
#toast.show { opacity: 1; }
#toast.err { border-color: #7f1d1d; color: #f87171; }
/* Provider tab active underline */
.ptab.active { color: var(--pg-accent); border-bottom-color: var(--pg-accent); }
/* Role select width override (input[type] rule in pg.css sets width:100%) */
.role-select { width: 100%; }
/* details/summary triangle */
details > summary { list-style: none; }
details > summary::-webkit-details-marker { display: none; }
</style>
</head>
<body>
<nav class="page-nav">
<a href="{{ back_href }}" class="nav-link">← Chat</a>
<a href="{{ help_href }}" class="nav-link">Help</a>
<a href="/settings" class="nav-link">Settings</a>
<a href="/settings/models" class="nav-link active">Models</a>
<a href="/settings/notifications" class="nav-link">Notifications</a>
<a href="/settings/tools" class="nav-link">Tools</a>
<a href="/settings/crons" class="nav-link">Schedules</a>
{{ integrations_nav }}
<span class="nav-spacer"></span>
<a href="/logout" class="nav-link nav-logout">Sign out</a>
</nav>
<div class="page-wrap">
<h1 class="page-title">Model Registry</h1>
<p class="page-subtitle">Configure providers, hosts, and model assignments.</p>
<!-- SUCCESS --><!-- ERROR -->
<!-- ── Main tabs ── -->
<div id="main-tabs" class="flex border-b border-pg-border mb-6">
<button type="button" class="ptab active" data-tab="roles">Roles</button>
<button type="button" class="ptab" data-tab="models">Models</button>
<button type="button" class="ptab" data-tab="hosts">Hosts</button>
<button type="button" class="ptab" data-tab="credentials">Credentials</button>
</div>
<!-- ── TAB: Credentials ── -->
<div id="tab-credentials" style="display:none">
<!-- Cloud Providers -->
<div class="rounded-xl border border-pg-border bg-pg-surface p-6 mb-5">
<h2 class="text-xs font-semibold text-pg-muted uppercase tracking-widest mb-5 pb-2 border-b border-pg-border">Cloud Providers</h2>
<!-- Anthropic -->
<div class="mb-6">
<div class="flex items-center gap-3 mb-3">
<div class="w-7 h-7 rounded-md flex items-center justify-center text-xs font-bold shrink-0 bg-indigo-950 text-indigo-400 dark:bg-indigo-950 dark:text-indigo-400">A</div>
<div>
<div class="text-sm font-semibold text-pg-text">Anthropic</div>
<div class="text-xs text-pg-dim">Claude models — two auth paths with different trade-offs</div>
</div>
</div>
<div class="mb-4 space-y-2">
<p class="text-xs text-pg-muted leading-relaxed">
<strong class="text-pg-text">CLI (OAuth)</strong> — Runs requests through the
<code class="font-mono text-pg-accent bg-pg-bg border border-pg-border rounded px-1">claude --print</code>
subprocess using your Claude.ai subscription (Pro or Max plan). No API key needed; authenticate
with <code class="font-mono text-pg-accent bg-pg-bg border border-pg-border rounded px-1">claude auth login</code> on the Cortex host.
</p>
<p class="text-xs leading-relaxed text-amber-400">
⚠ Anthropic now meters agentic tool use separately on paid plans. Each orchestrator pass
(tool loop + Claude response) counts against a separate monthly credit bucket, not just your
general subscription. CLI is fine for direct chat; for heavy automated workflows, an API key
avoids hitting the agentic cap.
</p>
<div id="claude-auth-status" class="auth-status flex items-center gap-2 text-xs mt-2 text-pg-muted">
<span class="dot w-2 h-2 rounded-full shrink-0 bg-pg-dim"></span>
<span id="claude-auth-msg">Checking…</span>
</div>
</div>
<p class="text-xs text-pg-muted leading-relaxed mb-2">
<strong class="text-pg-text">API Keys</strong> — Uses the Anthropic SDK directly, billed per token at
<a href="https://www.anthropic.com/pricing" target="_blank" class="text-indigo-400 hover:underline">api.anthropic.com</a>
rates. No agentic usage cap; the better choice for orchestrator-heavy or high-volume workloads.
</p>
{{ anthropic_key_rows }}
<details class="mt-2">
<summary class="text-xs text-pg-dim cursor-pointer select-none hover:text-pg-muted transition-colors">+ Add API key</summary>
<div class="mt-3">
<form method="POST" action="/settings/local/anthropic-key">
<input type="hidden" name="key_id" value="">
<div class="flex gap-3 flex-wrap">
<div class="flex-1 min-w-36">
<label class="block text-xs font-medium text-pg-muted mb-1">Label <span class="font-normal text-pg-dim">(e.g. Personal, Work)</span></label>
<input type="text" name="label" placeholder="Personal" autocomplete="off" data-form-type="other"
class="w-full px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action">
</div>
<div class="flex-[2] min-w-48">
<label class="block text-xs font-medium text-pg-muted mb-1">API Key</label>
<input type="password" name="api_key" placeholder="sk-ant-…" required
autocomplete="new-password" data-1p-ignore data-lpignore="true" data-form-type="other"
class="w-full px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action">
</div>
</div>
<button type="submit" class="mt-3 px-4 py-1.5 text-xs font-medium bg-pg-surface border border-pg-border rounded-md text-pg-muted hover:border-pg-muted hover:text-pg-text transition-colors cursor-pointer">Save</button>
</form>
</div>
</details>
</div>
<!-- Divider -->
<div class="border-t border-pg-border pt-6">
<!-- Google -->
<div class="flex items-center gap-3 mb-3">
<div class="w-7 h-7 rounded-md flex items-center justify-center text-xs font-bold shrink-0 bg-emerald-950 text-emerald-400">G</div>
<div>
<div class="text-sm font-semibold text-pg-text">Google</div>
<div class="text-xs text-pg-dim">Gemini models via Gemini API — native SDK, not OpenAI-compatible</div>
</div>
</div>
<p class="text-xs text-pg-muted leading-relaxed mb-4">
Gemini API keys serve two roles in Cortex: the <strong class="text-pg-text">orchestrator reasoning loop</strong>
(Gemini's tool-calling ReAct pass that runs before handing off to Claude) and any
<strong class="text-pg-text">Gemini chat models</strong> you assign in the Models tab. Multiple accounts let you
separate personal and work API usage or stay within free-tier rate limits across accounts.
Keys starting with <code class="font-mono text-pg-accent bg-pg-bg border border-pg-border rounded px-1">AIza…</code> from
<a href="https://aistudio.google.com/apikey" target="_blank" class="text-emerald-400 hover:underline">Google AI Studio</a>.
</p>
{{ google_account_rows }}
<details>
<summary class="text-xs text-pg-dim cursor-pointer select-none hover:text-pg-muted transition-colors">+ Add Google account</summary>
<div class="mt-3">
<form method="POST" action="/settings/local/google-account">
<input type="hidden" name="account_id" value="">
<div class="flex gap-3 flex-wrap">
<div class="flex-1 min-w-36">
<label class="block text-xs font-medium text-pg-muted mb-1">Label <span class="font-normal text-pg-dim">(e.g. Work, Personal)</span></label>
<input type="text" name="label" placeholder="One Sky IT" autocomplete="off" data-form-type="other"
class="w-full px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action">
</div>
<div class="flex-[2] min-w-48">
<label class="block text-xs font-medium text-pg-muted mb-1">API Key</label>
<input type="password" name="api_key" placeholder="AIza…"
autocomplete="new-password" data-1p-ignore data-lpignore="true" data-form-type="other"
class="w-full px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action">
</div>
</div>
<button type="submit" class="mt-3 px-4 py-1.5 text-xs font-semibold bg-pg-action text-white rounded-md hover:opacity-90 transition-opacity cursor-pointer">Add Account</button>
</form>
</div>
</details>
</div>
</div>
</div><!-- /tab-credentials -->
<!-- ── TAB: Hosts ── -->
<div id="tab-hosts" style="display:none">
<!-- Cloud APIs -->
<div class="rounded-xl border border-pg-border bg-pg-surface p-6 mb-5">
<h2 class="text-xs font-semibold text-pg-muted uppercase tracking-widest mb-2 pb-2 border-b border-pg-border">Cloud APIs</h2>
<p class="text-xs text-pg-muted leading-relaxed mb-4">
OpenAI-compatible cloud inference services — OpenRouter, OpenAI, Groq, X.ai, and more.
Add a service here, then go to <strong class="text-pg-text">Models</strong> to register individual models from it.
</p>
{{ cloud_host_rows }}
<details class="mt-3">
<summary class="text-xs text-pg-dim cursor-pointer select-none hover:text-pg-muted transition-colors">+ Add cloud API service</summary>
<div class="mt-3 space-y-3">
<div>
<label class="block text-xs font-medium text-pg-muted mb-1">Provider</label>
<select id="cloud-provider-sel"
class="w-full px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action cursor-pointer">
</select>
</div>
<form method="POST" action="/settings/local/host">
<input type="hidden" name="host_id" value="">
<input type="hidden" name="host_type" value="openai">
<input type="hidden" name="max_concurrent" value="5">
<div class="flex gap-3 flex-wrap mb-3">
<div class="flex-1 min-w-32">
<label class="block text-xs font-medium text-pg-muted mb-1">Label</label>
<input type="text" id="cloud-label" name="label" placeholder="OpenRouter" required
autocomplete="off" data-form-type="other"
class="w-full px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action">
</div>
<div class="flex-[2] min-w-48">
<label class="block text-xs font-medium text-pg-muted mb-1">API URL</label>
<input type="text" id="cloud-api-url" name="api_url" placeholder="https://openrouter.ai/api/v1" required
autocomplete="off" spellcheck="false" data-form-type="other"
class="w-full px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action">
</div>
</div>
<div class="mb-3">
<label class="block text-xs font-medium text-pg-muted mb-1">API Key</label>
<input type="password" name="api_key" placeholder="sk-…"
autocomplete="new-password" data-1p-ignore data-lpignore="true" data-form-type="other"
class="w-full px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action">
</div>
<button type="submit" class="px-4 py-1.5 text-xs font-semibold bg-pg-action text-white rounded-md hover:opacity-90 transition-opacity cursor-pointer">Add Service</button>
</form>
</div>
</details>
</div>
<!-- Local Hosts -->
<div class="rounded-xl border border-pg-border bg-pg-surface p-6 mb-5">
<h2 class="text-xs font-semibold text-pg-muted uppercase tracking-widest mb-2 pb-2 border-b border-pg-border">Local Hosts</h2>
<p class="text-xs text-pg-muted leading-relaxed mb-4">Self-hosted OpenAI-compatible servers — Open WebUI, Ollama, LM Studio, etc.</p>
{{ local_host_rows }}
<details class="mt-3">
<summary class="text-xs text-pg-dim cursor-pointer select-none hover:text-pg-muted transition-colors">+ Add local host</summary>
<div class="mt-3">
<form method="POST" action="/settings/local/host">
<input type="hidden" name="host_id" value="">
<div class="flex gap-3 flex-wrap mb-3">
<div class="flex-1 min-w-32">
<label class="block text-xs font-medium text-pg-muted mb-1">Label</label>
<input type="text" name="label" placeholder="Gaming Laptop"
autocomplete="off" data-form-type="other"
class="w-full px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action">
</div>
<div class="flex-[2] min-w-48">
<label class="block text-xs font-medium text-pg-muted mb-1">API URL</label>
<input type="text" name="api_url" placeholder="http://192.168.x.x:3000"
autocomplete="off" spellcheck="false" data-form-type="other"
class="w-full px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action">
</div>
</div>
<div class="flex gap-3 flex-wrap mb-3">
<div class="flex-1 min-w-48">
<label class="block text-xs font-medium text-pg-muted mb-1">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"
class="w-full px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action">
</div>
<div class="shrink-0">
<label class="block text-xs font-medium text-pg-muted mb-1">Type</label>
<select name="host_type"
class="px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action cursor-pointer">
<option value="openwebui">Open WebUI / Ollama</option>
<option value="openai">OpenAI-compatible API</option>
</select>
</div>
<div class="w-24 shrink-0">
<label class="block text-xs font-medium text-pg-muted mb-1">Max parallel</label>
<input type="number" name="max_concurrent" min="1" max="20" value="3"
class="w-full px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action">
</div>
</div>
<button type="submit" class="px-4 py-1.5 text-xs font-semibold bg-pg-action text-white rounded-md hover:opacity-90 transition-opacity cursor-pointer">Add Host</button>
</form>
</div>
</details>
</div>
</div><!-- /tab-hosts -->
<!-- ── TAB: Models ── -->
<div id="tab-models" style="display:none">
<div class="rounded-xl border border-pg-border bg-pg-surface p-6 mb-5">
<h2 class="text-xs font-semibold text-pg-muted uppercase tracking-widest mb-4 pb-2 border-b border-pg-border">Models</h2>
{{ model_rows }}
<details class="mt-2">
<summary class="text-xs text-pg-dim cursor-pointer select-none hover:text-pg-muted transition-colors">+ Add model</summary>
<div class="mt-4">
<!-- Provider sub-tabs -->
<div id="provider-tabs" class="flex border-b border-pg-border mb-4">
<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" class="space-y-3 mb-3">
<div id="model-select-wrap" style="display:none">
<label class="block text-xs font-medium text-pg-muted mb-1">Available on host</label>
<select id="model-picker"
class="w-full px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action cursor-pointer">
<option value="">— select to auto-fill —</option>
</select>
</div>
<div class="flex gap-3 flex-wrap">
<div class="shrink-0">
<label class="block text-xs font-medium text-pg-muted mb-1">Host</label>
<select id="add-host-select" name="host_id"
class="px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action cursor-pointer">
</select>
</div>
<div class="flex-1 min-w-48">
<label class="block text-xs font-medium text-pg-muted mb-1">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"
class="w-full px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action">
</div>
</div>
</div>
<!-- GOOGLE fields -->
<div id="pf-google" style="display:none" class="mb-3">
<div class="flex gap-3 flex-wrap">
<div class="flex-1">
<label class="block text-xs font-medium text-pg-muted mb-1">Gemini model</label>
<select id="add-gemini-model"
class="w-full px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action cursor-pointer">
</select>
</div>
<div class="flex-1">
<label class="block text-xs font-medium text-pg-muted mb-1">Account</label>
<select id="add-google-account" name="account_id"
class="w-full px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action cursor-pointer">
</select>
</div>
</div>
</div>
<!-- ANTHROPIC fields -->
<div id="pf-anthropic" style="display:none" class="mb-3 space-y-3">
<div>
<label class="block text-xs font-medium text-pg-muted mb-1">Credential</label>
<select id="add-anthropic-cred"
class="w-full px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action cursor-pointer">
</select>
</div>
<div>
<label class="block text-xs font-medium text-pg-muted mb-1">Claude model</label>
<select id="add-claude-model"
class="w-full px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action cursor-pointer">
</select>
</div>
</div>
<!-- Hidden fields -->
<input type="hidden" id="cloud-model-name" name="cloud_model_name" value="">
<input type="hidden" id="add-credential-id" name="credential_id" value="cli">
<!-- Shared fields -->
<div class="flex gap-3 flex-wrap mt-1 mb-3">
<div class="flex-1 min-w-36">
<label class="block text-xs font-medium text-pg-muted mb-1">Label</label>
<input type="text" id="add-label" name="label" placeholder="e.g. Gemma 4 E4B"
autocomplete="off" data-form-type="other"
class="w-full px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action">
</div>
<div class="w-24 shrink-0">
<label class="block text-xs font-medium text-pg-muted mb-1" title="Context window size in thousands of tokens.">Context (k)</label>
<input type="number" id="add-context-k" name="context_k" value="0" min="0" max="10000"
class="w-full px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action">
</div>
<div class="w-24 shrink-0">
<label class="block text-xs font-medium text-pg-muted mb-1" title="Per-model tool loop cap. 0 = global default.">Max rounds</label>
<input type="number" name="max_rounds" value="0" min="0"
class="w-full px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action">
</div>
<div class="shrink-0">
<label class="block text-xs font-medium text-pg-muted mb-1" title="Reasoning depth via OpenRouter's reasoning.budget_tokens.">Reasoning</label>
<select name="reasoning_budget_tokens"
class="px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action cursor-pointer">
<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="shrink-0">
<label class="block text-xs font-medium text-pg-muted mb-1" title="Whether this model supports tool calling.">Tool calling</label>
<select name="tools"
class="px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action cursor-pointer">
<option value="1" selected>Supported</option>
<option value="0">Not supported</option>
</select>
</div>
</div>
<div class="mb-3">
<label class="block text-xs font-medium text-pg-muted mb-1">Tags <span class="font-normal text-pg-dim">(comma-separated)</span></label>
<input type="text" name="tags" placeholder="fast, distill, coding"
autocomplete="off" data-form-type="other"
class="w-full px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action">
<p class="text-xs text-pg-dim mt-1">Informational labels — used for display and future filtering.</p>
</div>
<div class="flex items-center gap-3 flex-wrap">
<button type="submit" class="px-4 py-1.5 text-xs font-semibold bg-pg-action text-white rounded-md hover:opacity-90 transition-opacity cursor-pointer">Add Model</button>
<button type="button" id="fetch-btn" class="px-4 py-1.5 text-xs font-medium bg-pg-surface border border-pg-border rounded-md text-pg-muted hover:border-pg-muted hover:text-pg-text transition-colors cursor-pointer">Fetch from host</button>
<span id="fetch-status" class="text-xs text-pg-muted"></span>
</div>
</form>
</div>
</details>
</div>
</div><!-- /tab-models -->
<!-- ── TAB: Roles ── -->
<div id="tab-roles">
<div class="rounded-xl border border-pg-border bg-pg-surface p-6 mb-5">
<h2 class="text-xs font-semibold text-pg-muted uppercase tracking-widest mb-2 pb-2 border-b border-pg-border">Role Assignments</h2>
<p class="text-xs text-pg-muted leading-relaxed mb-4">
Map each task type to a model. Primary is tried first; Backup is the config-resolution fallback
if the primary model is missing from the registry. Backup does not fire on inference failures.
</p>
{{ role_rows }}
<details class="mt-4">
<summary class="text-xs text-pg-dim cursor-pointer select-none hover:text-pg-muted transition-colors">+ Add custom role</summary>
<div class="mt-3">
<form method="POST" action="/settings/local/roles/add">
<div class="flex gap-3 items-end flex-wrap">
<div class="flex-1 min-w-40">
<label class="block text-xs font-medium text-pg-muted mb-1">Role name <span class="font-normal text-pg-dim">(lowercase, no spaces)</span></label>
<input type="text" name="role_name" placeholder="coder"
pattern="[a-z][a-z0-9_]{0,31}" required
autocomplete="off" data-form-type="other"
class="w-full px-3 py-2 text-sm bg-pg-bg border border-pg-border rounded-md text-pg-text focus:outline-none focus:border-pg-action">
</div>
<button type="submit" class="px-4 py-2 text-xs font-medium bg-pg-surface border border-pg-border rounded-md text-pg-muted hover:border-pg-muted hover:text-pg-text transition-colors cursor-pointer">Add Role</button>
</div>
</form>
</div>
</details>
</div>
</div><!-- /tab-roles -->
</div>
<div id="toast"></div>
<style>
/* Tab pill styles — kept here because .active class is toggled by JS */
.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:hover:not(.active) { color: var(--pg-bright); }
/* Python-generated row classes — kept named for readability in server code */
.host-row { background: var(--pg-bg); border: 1px solid var(--pg-border); border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; }
.key-status { font-size: 0.72rem; color: var(--pg-muted); margin-top: 0.3rem; }
.fetch-status { font-size: 0.75rem; 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); }
.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; }
.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; }
.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; }
.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; }
.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; }
/* Role rows */
.role-row { display: flex; align-items: flex-start; gap: 1rem; padding: 0.6rem 0; border-bottom: 1px solid var(--pg-border); }
.role-name-col { min-width: 6rem; flex-shrink: 0; padding-top: 0.4rem; }
.role-name { font-size: 0.82rem; font-weight: 600; color: var(--pg-accent); display: block; }
.required-badge { display: block; font-size: 0.62rem; color: var(--pg-muted); margin-top: 0.1rem; }
.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-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: var(--pg-accent); border-color: var(--pg-accent); }
.role-cfg-btn.active { color: var(--pg-accent); border-color: var(--pg-accent); background: rgba(167,139,250,0.08); }
/* Role config panel */
.role-config-panel { margin: 0 0 0.75rem 7rem; border: 1px solid var(--pg-border); border-radius: 8px; background: var(--pg-surface); padding: 1rem; }
.rcp-field { margin-bottom: 0.75rem; }
.rcp-label { display: block; font-size: 0.72rem; font-weight: 600; color: var(--pg-muted); margin-bottom: 0.35rem; text-transform: uppercase; letter-spacing: 0.04em; }
.rcp-hint { font-size: 0.75rem; font-weight: 400; color: var(--pg-dim); line-height: 1.5; }
.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; margin-bottom: 0; }
.rcp-check input { flex-shrink: 0; accent-color: var(--pg-accent); cursor: pointer; }
.rcp-actions { display: flex; gap: 0.5rem; padding-top: 0.25rem; }
.rcp-danger { border-top: 1px solid var(--pg-border); margin-top: 0.75rem; padding-top: 0.75rem; }
/* Inline link buttons */
.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: #9b2c2c; }
.btn-link.danger:hover { color: #f87171; }
/* Host/model edit form field rows */
.field-row { display: flex; gap: 0.75rem; }
.field-row .field { flex: 1; margin-bottom: 0; }
.field { margin-bottom: 0.9rem; }
.field label { display: block; font-size: 0.78rem; font-weight: 500; color: var(--pg-muted); margin-bottom: 0.35rem; }
.field input, .field select, .field textarea { 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; }
.field input:focus, .field select:focus { border-color: #7c3aed; }
.field input[type="number"] { width: 6rem; }
.btn-row { display: flex; gap: 0.6rem; align-items: center; margin-top: 0.75rem; flex-wrap: wrap; }
/* Shared small button used inside Python-generated rows */
.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; }
.empty-note { font-size: 0.85rem; color: var(--pg-dimmer); padding: 0.3rem 0; }
details > div { margin-top: 0.75rem; }
/* Msg banners */
.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; }
</style>
<script>
// ── Main tabs ─────────────────────────────────────────────────────────────
const _mainTabPanes = {
roles: document.getElementById('tab-roles'),
models: document.getElementById('tab-models'),
hosts: document.getElementById('tab-hosts'),
credentials: document.getElementById('tab-credentials'),
};
function switchMainTab(name) {
document.querySelectorAll('#main-tabs .ptab').forEach(t => t.classList.remove('active'));
const btn = document.querySelector(`#main-tabs .ptab[data-tab="${name}"]`);
if (btn) btn.classList.add('active');
for (const [key, el] of Object.entries(_mainTabPanes)) {
if (el) el.style.display = key === name ? '' : 'none';
}
history.replaceState(null, '', location.pathname + (name !== 'roles' ? '#' + name : ''));
}
document.querySelectorAll('#main-tabs .ptab').forEach(tab => {
tab.addEventListener('click', () => switchMainTab(tab.dataset.tab));
});
(function() {
const hash = location.hash.replace('#', '');
switchMainTab(['models', 'hosts', 'credentials'].includes(hash) ? hash : 'roles');
})();
// ── 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 CLOUD_API_CATALOG = {{ cloud_catalog_js }};
const HAS_HOSTS = {{ has_hosts }};
// ── Toast ─────────────────────────────────────────────────────────────────
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);
}
// ── Role selects: pre-fill + AJAX save ────────────────────────────────────
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 ────────────────────────────────────────────────────
const ALL_TOOLS_ORDERED = Object.entries(TOOL_CATEGORIES).flatMap(([,tools]) => tools);
function buildToolChecklist(role, savedTools) {
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.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;
panel.querySelector('.rcp-textarea').value = cfg.system_append || '';
const dtCb = panel.querySelector('.rcp-datetime-cb');
if (dtCb) dtCb.checked = cfg.inject_datetime !== false;
const modeCb = panel.querySelector('.rcp-mode-cb');
if (modeCb) modeCb.checked = cfg.inject_mode !== false;
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 {
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) {
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;
}
});
});
// ── Custom role removal confirmation ─────────────────────────────────────
document.querySelectorAll('.remove-role-form').forEach(form => {
form.addEventListener('submit', e => {
const role = form.querySelector('input[name="role_name"]').value;
if (!confirm(`Remove the "${role}" role? Its model assignments will also be cleared.`)) {
e.preventDefault();
}
});
});
// ── Provider tabs (add model form) ────────────────────────────────────────
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');
const credIdInput = document.getElementById('add-credential-id');
const anthropicCredSel = document.getElementById('add-anthropic-cred');
document.querySelectorAll('#provider-tabs .ptab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('#provider-tabs .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';
if (p === 'anthropic') credIdInput.value = anthropicCredSel.value || 'cli';
});
});
// ── 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');
populateSelect(geminiSel, GOOGLE_CATALOG, 'id', 'label');
populateSelect(claudeSel, ANTHROPIC_CATALOG, 'id', 'label');
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));
// ── Cloud provider picker ─────────────────────────────────────────────────
const cloudProvSel = document.getElementById('cloud-provider-sel');
const cloudLabelIn = document.getElementById('cloud-label');
const cloudUrlIn = document.getElementById('cloud-api-url');
if (cloudProvSel) {
CLOUD_API_CATALOG.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id; opt.textContent = p.label;
cloudProvSel.appendChild(opt);
});
function onCloudProvChange() {
const p = CLOUD_API_CATALOG.find(c => c.id === cloudProvSel.value);
if (!p) return;
cloudLabelIn.value = p.id !== 'custom' ? p.label : '';
cloudUrlIn.value = p.api_url;
if (p.id === 'custom') {
cloudUrlIn.removeAttribute('readonly'); cloudUrlIn.style.opacity = '';
cloudUrlIn.placeholder = 'https://…/v1';
} else {
cloudUrlIn.setAttribute('readonly', ''); cloudUrlIn.style.opacity = '0.65';
}
}
cloudProvSel.addEventListener('change', onCloudProvChange);
onCloudProvChange();
}
// ── Host select + fetch ───────────────────────────────────────────────────
const hostSel = document.getElementById('add-host-select');
const hostOpts = `{{ host_options }}`;
hostSel.innerHTML = hostOpts || '<option value="">No hosts configured</option>';
document.querySelectorAll('.fetch-btn').forEach(btn => {
btn.addEventListener('click', () => fetchModels(btn.dataset.hostId, btn));
});
fetchBtn.addEventListener('click', () => {
fetchModels(hostSel ? hostSel.value : '', 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 : '';
}
});
if (!HAS_HOSTS) fetchBtn.style.display = 'none';
// ── Model inline edit ─────────────────────────────────────────────────────
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 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 epicker = document.getElementById('edit-model-picker-' + id);
const ewrap = document.getElementById('edit-model-select-wrap-' + id);
epicker.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;
epicker.appendChild(opt);
});
ewrap.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 flex items-center gap-2 text-xs mt-2 err';
msg.textContent = 'Token expired — run claude auth login on the Cortex host, then restart Cortex.';
} else if (c.warning) {
el.className = 'auth-status flex items-center gap-2 text-xs mt-2 warn';
msg.textContent = `Token expires in ${c.access_token_hours_remaining}h — run claude auth login to refresh.`;
} else {
el.className = 'auth-status flex items-center gap-2 text-xs mt-2 ok';
msg.textContent = `Authenticated — token valid for ${c.access_token_hours_remaining}h`;
}
} catch { msg.textContent = 'Status unavailable'; }
})();
</script>
</body>
</html>