Files
Cortex-Inara/cortex/static/local_llm.html
Scott Idem 6ad7597db8 feat: per-role inject_datetime toggle for system prompt
Each role can now disable the current date/time header injected into the
system prompt. Default is true (all existing roles unchanged). Useful for
pure processing roles (summarizer, classifier, translator) where temporal
context is irrelevant or could cause unexpected model behavior.

Changes:
- model_registry: set_role_config/get_role_config gain inject_datetime field
- context_loader: load_context gains inject_datetime param (default True)
- orchestrator router: passes inject_datetime from role_cfg to load_context
- local_llm router: reads inject_datetime from POST body, passes to registry;
  role_config_data_js includes the field
- local_llm.html: checkbox in role config panel; populate on open, save on submit

Session logs still timestamp every turn (HH:MM header in YYYY-MM-DD.md files)
regardless of this setting — the toggle only affects the system prompt header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:53:35 -04:00

936 lines
42 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) — no API key needed</div>
</div>
</div>
<p class="section-note" style="margin-bottom:0">
Claude models are accessed through the Claude CLI using your existing OAuth login.
Run <code style="font-family:monospace;color:#94a3b8">claude auth login</code> to authenticate.
</p>
<div id="claude-auth-status" class="auth-status" style="margin-top:0.6rem">
<span class="dot"></span><span id="claude-auth-msg">Checking…</span>
</div>
</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>
<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>Claude model</label>
<select id="add-claude-model"></select>
</div>
<p class="section-note" style="margin-top:-0.25rem">Uses Claude CLI (OAuth)</p>
</div>
<!-- Hidden: cloud model name (set by JS from catalog pickers) -->
<input type="hidden" id="cloud-model-name" name="cloud_model_name" value="">
<input type="hidden" 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="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 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;
// 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 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}),
});
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;
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';
});
});
// ── 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');
populateSelect(geminiSel, GOOGLE_CATALOG, 'id', 'label');
populateSelect(claudeSel, ANTHROPIC_CATALOG, 'id', 'label');
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>