PR #95 swapped local_encoder's backend from NLI cross-encoding to embedding+centroid and updated config.py's default/docstring, but missed two consumers: the admin controls page's placeholder text (misleadingly showed the old model name for the empty/default-inheriting field) and docs/local-models.md's architecture description (still said "zero-shot NLI encoder"). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0161UAZyxtvmKaVzasJsnqJc
1323 lines
69 KiB
HTML
1323 lines
69 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en" data-bs-theme="dark">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Controls — LLM Router Admin</title>
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/css/tabler.min.css">
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Quicksand:wght@600;700&display=swap">
|
|
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20viewBox%3D%220%200%20256%20256%22%3E%3Ccircle%20cx%3D%22146%22%20cy%3D%2284%22%20r%3D%2245.6%22%20fill%3D%22%23fff%22/%3E%3Cpath%20d%3D%22M124%20110%20Q90%20140%2090%20176%20Q90%20212%20122%20212%20Q156%20212%20172%20186%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%2239.2%22%20stroke-linecap%3D%22round%22/%3E%3Ccircle%20cx%3D%22146%22%20cy%3D%2284%22%20r%3D%2242%22%20fill%3D%22%23f59e0b%22/%3E%3Cpath%20d%3D%22M124%20110%20Q90%20140%2090%20176%20Q90%20212%20122%20212%20Q156%20212%20172%20186%22%20fill%3D%22none%22%20stroke%3D%22%23f59e0b%22%20stroke-width%3D%2232%22%20stroke-linecap%3D%22round%22/%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%2278%22%20r%3D%229%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%22164%22%20cy%3D%2278%22%20r%3D%229%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%22128%22%20cy%3D%2278%22%20r%3D%226%22%20fill%3D%22%231e293b%22/%3E%3Ccircle%20cx%3D%22164%22%20cy%3D%2278%22%20r%3D%226%22%20fill%3D%22%231e293b%22/%3E%3Ccircle%20cx%3D%22131%22%20cy%3D%2274%22%20r%3D%222.2%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%22167%22%20cy%3D%2274%22%20r%3D%222.2%22%20fill%3D%22%23fff%22/%3E%3Cpath%20d%3D%22M134%20100%20Q146%20112%20158%20100%22%20fill%3D%22none%22%20stroke%3D%22%23fff%22%20stroke-width%3D%227%22%20stroke-linecap%3D%22round%22/%3E%3Cpath%20d%3D%22M134%20100%20Q146%20112%20158%20100%22%20fill%3D%22none%22%20stroke%3D%22%231e293b%22%20stroke-width%3D%223.5%22%20stroke-linecap%3D%22round%22/%3E%3Cline%20x1%3D%22150.47%22%20y1%3D%2247.59%22%20x2%3D%22156.01%22%20y2%3D%2227.13%22%20stroke%3D%22%23fff%22%20stroke-width%3D%2210.73%22%20stroke-linecap%3D%22round%22/%3E%3Cline%20x1%3D%22150.47%22%20y1%3D%2247.59%22%20x2%3D%22155.08%22%20y2%3D%2230.54%22%20stroke%3D%22%231e293b%22%20stroke-width%3D%226.26%22%20stroke-linecap%3D%22round%22/%3E%3Ccircle%20cx%3D%2295%22%20cy%3D%22226%22%20r%3D%2214%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%22150%22%20cy%3D%22226%22%20r%3D%2214%22%20fill%3D%22%23fff%22/%3E%3Ccircle%20cx%3D%2295%22%20cy%3D%22226%22%20r%3D%229%22%20fill%3D%22%231e293b%22/%3E%3Ccircle%20cx%3D%22150%22%20cy%3D%22226%22%20r%3D%229%22%20fill%3D%22%231e293b%22/%3E%3C/svg%3E">
|
|
<script src="https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/js/tabler.min.js"></script>
|
|
<link rel="stylesheet" href="navbar.css">
|
|
<script src="navbar.js"></script>
|
|
<style>
|
|
/* ── Liquid glass: soft gradient wash behind everything so the blur has
|
|
something to catch, then translucent+blurred navbar/cards on top. Kept
|
|
subtle — this is a working dashboard, not a marketing page. ── */
|
|
body{
|
|
background:
|
|
radial-gradient(900px circle at 6% -8%, rgba(245,158,11,.10), transparent 55%),
|
|
radial-gradient(800px circle at 94% -6%, rgba(59,130,246,.10), transparent 50%),
|
|
radial-gradient(760px circle at 50% 108%, rgba(139,92,246,.07), transparent 55%),
|
|
#111827;
|
|
background-attachment: fixed;
|
|
}
|
|
header.navbar{
|
|
background: rgba(31,41,55,.6)!important;
|
|
backdrop-filter: blur(16px) saturate(160%);
|
|
-webkit-backdrop-filter: blur(16px) saturate(160%);
|
|
border-bottom: 1px solid rgba(255,255,255,.06);
|
|
}
|
|
.card{
|
|
background: rgba(31,41,55,.55);
|
|
backdrop-filter: blur(18px) saturate(140%);
|
|
-webkit-backdrop-filter: blur(18px) saturate(140%);
|
|
border: 1px solid rgba(255,255,255,.07);
|
|
box-shadow: 0 10px 30px -14px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.04);
|
|
}
|
|
.card-header{border-bottom-color: rgba(255,255,255,.06)}
|
|
/* ── The "gap only at wide windows" bug, root-caused: this Chrome/Linux
|
|
build reports a phantom left margin on <html> exactly equal to the
|
|
scrollbar's width whenever <html> itself is the scrolling element.
|
|
Moving the scroll context down to <body> sidesteps it. ── */
|
|
html{overflow:hidden;height:100%;margin:0!important;padding:0!important}
|
|
body{overflow-y:auto;height:100%;margin:0!important;padding:0!important}
|
|
/* ── Buttons: glass pills instead of flat saturated fills. ── */
|
|
.btn-success{background:rgba(34,197,94,.16)!important;border-color:rgba(74,222,128,.45)!important;color:#4ade80!important}
|
|
.btn-success:hover{background:rgba(34,197,94,.28)!important;border-color:rgba(74,222,128,.7)!important;color:#86efac!important}
|
|
.btn-danger{background:rgba(239,68,68,.16)!important;border-color:rgba(248,113,113,.45)!important;color:#f87171!important}
|
|
.btn-danger:hover{background:rgba(239,68,68,.28)!important;border-color:rgba(248,113,113,.7)!important;color:#fca5a5!important}
|
|
.btn{backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px)}
|
|
/* ── Badges: same glass treatment. ── */
|
|
.badge.bg-success{background:rgba(34,197,94,.20)!important;color:#4ade80!important}
|
|
.badge.bg-warning{background:rgba(245,158,11,.20)!important;color:#fbbf24!important}
|
|
.badge.bg-danger{background:rgba(239,68,68,.20)!important;color:#f87171!important}
|
|
.badge.bg-info{background:rgba(59,130,246,.20)!important;color:#93c5fd!important}
|
|
.badge.bg-secondary{background:rgba(148,163,184,.20)!important;color:#e2e8f0!important}
|
|
.badge.bg-purple{background:rgba(168,85,247,.20)!important;color:#d8b4fe!important}
|
|
/* ── Progress bars: translucent track + glow instead of flat solid. ── */
|
|
.progress{background:rgba(255,255,255,.08)!important;border-radius:999px;overflow:hidden}
|
|
.progress-bar{box-shadow:0 0 6px 0 currentColor;filter:saturate(1.25)}
|
|
header.navbar>.container-fluid{padding-left:20px!important;padding-right:20px!important}
|
|
.navbar-brand, .page-title{font-family:'Quicksand',var(--tblr-font-sans-serif,ui-sans-serif,system-ui,sans-serif);font-weight:700;letter-spacing:.01em}
|
|
.navbar-brand a{text-decoration:none!important;color:inherit}
|
|
/* ── Brand: Tabler's own rule is [data-bs-theme=dark] .navbar-brand-autodark
|
|
.navbar-brand-image{filter:brightness(0) invert(1)} — attribute selector +
|
|
2 classes outranks a plain 2-class override, so `filter:none` alone loses
|
|
the cascade regardless of source order. !important is what actually beats
|
|
it. That filter was stripping the mascot's orange color, and a sub-36px
|
|
cap shrank his face to sub-pixel dots. Restore color + force an explicit size (64px, below). ── */
|
|
.navbar-brand-autodark .navbar-brand-image{filter:none!important;height:64px;width:64px}
|
|
/* ── 64px mascot, same bar height. His face is only ~40% of his height (the
|
|
rest is tail and wheels), so at 45px the eyes were ~4px and the whole point
|
|
of having a mascot was lost. The 19px comes out of padding, not out of the
|
|
navbar: the brand h1 carries 8px top and bottom, and the navbar itself 4px.
|
|
Zero the first and halve the second and the bar lands at 69px — 1px shorter
|
|
than the 45px version it replaces. Measure before changing either number. ── */
|
|
.navbar-brand{padding-top:0!important;padding-bottom:0!important}
|
|
header.navbar{padding-top:2px!important;padding-bottom:2px!important}
|
|
/* ── Speed lines: he is a wheeled rover, so give him a trail. Drawn as their
|
|
own inline SVG rather than baked into the logo, so assets/*.svg stays the
|
|
plain mascot and the lines can drop out on narrow screens (d-none d-md-flex).
|
|
The gradient fades AWAY from him (transparent at the far left, amber where
|
|
he is), which is what reads as speed rather than as three floating dashes;
|
|
it is userSpaceOnUse because an objectBoundingBox gradient on a horizontal
|
|
line has a zero-height bbox and is undefined. ── */
|
|
.navbar-brand a{display:inline-flex;align-items:center;gap:.15rem}
|
|
/* -20px: the logo's own viewBox carries ~18px of empty space to the left of
|
|
his tail at 64px, so the pull has to cover that before it buys any real
|
|
closeness. Measured gap from the middle line's tip to his back: ~4.5px. */
|
|
.brand-speedlines{align-items:center;line-height:0;margin-right:-20px}
|
|
.brand-speedlines svg{filter:none!important}
|
|
/* Motion only on hover — a dashboard that twitches at rest is a dashboard you
|
|
stop looking at. */
|
|
@keyframes brand-dash{
|
|
0%{transform:translateX(-5px);opacity:.2}
|
|
55%{opacity:1}
|
|
100%{transform:translateX(4px);opacity:0}
|
|
}
|
|
.navbar-brand a:hover .brand-speedlines line{animation:brand-dash .75s ease-in infinite}
|
|
.navbar-brand a:hover .brand-speedlines line:nth-child(2){animation-delay:.12s}
|
|
.navbar-brand a:hover .brand-speedlines line:nth-child(3){animation-delay:.24s}
|
|
@media (prefers-reduced-motion: reduce){
|
|
.navbar-brand a:hover .brand-speedlines line{animation:none}
|
|
}
|
|
/* ── Settings rows: ONE row grammar shared by Runtime Knobs and Persisted
|
|
Config — `key ......... [meta] [control]` on a 3-track grid with a fixed
|
|
control track, so every switch/input/select lands on the same right edge
|
|
and the eye scans a single column instead of chasing floating controls.
|
|
The old layout used Bootstrap .row col-5/col-3/col-4, which put the
|
|
control mid-row and spent a whole column restating the value the control
|
|
already shows. ── */
|
|
/* Negative margin == row padding, so the keys line up with the card body's
|
|
own content edge while the hover tint still bleeds to the card's inner edge. */
|
|
.settings-list{margin:0 -.5rem}
|
|
.setting-row{
|
|
display:grid;
|
|
grid-template-columns:minmax(0,1fr) auto 176px;
|
|
align-items:center;
|
|
gap:.75rem;
|
|
/* Fixed row height: a switch and a text input are ~10px apart in natural
|
|
height, which made the list look ragged when the two alternate. */
|
|
min-height:38px;
|
|
padding:.25rem .5rem;
|
|
border-left:2px solid transparent;
|
|
border-radius:6px;
|
|
}
|
|
.setting-row+.setting-row{border-top:1px solid rgba(255,255,255,.05)}
|
|
.setting-row:hover{background:rgba(255,255,255,.025)}
|
|
.setting-row.is-dirty{border-left-color:#fbbf24;background:rgba(245,158,11,.06)}
|
|
.setting-key{
|
|
font-size:.82rem;line-height:1.25;
|
|
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
|
}
|
|
.setting-key .scope{color:var(--tblr-secondary);opacity:.7}
|
|
.setting-meta{font-size:.72rem;white-space:nowrap;color:var(--tblr-secondary);font-variant-numeric:tabular-nums}
|
|
.setting-control{display:flex;justify-content:flex-end;align-items:center;min-width:0}
|
|
.setting-control .form-control,
|
|
.setting-control .form-select{width:100%;font-variant-numeric:tabular-nums}
|
|
/* Bootstrap floats .form-check-input and reserves 1.5em of left padding;
|
|
both fight right-alignment inside the control track. */
|
|
.setting-control .form-check{margin:0;padding:0;min-height:0;display:flex;justify-content:flex-end}
|
|
.setting-control .form-check-input{margin:0;float:none}
|
|
.settings-hint{font-size:.75rem;margin-bottom:.5rem}
|
|
/* Card bodies that are pure lists: let rows own the vertical rhythm. */
|
|
.card-body.settings-body{padding:.75rem 1rem 1rem}
|
|
/* Footer parts are laid out, not joined by a glyph. See
|
|
plans/admin-design-standards.md, "No middot separators". */
|
|
.footer-bits{display:inline-flex;flex-wrap:wrap;gap:.25rem 1.4rem}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- ═══ Navbar ═══ -->
|
|
<header class="navbar navbar-expand-md navbar-dark" data-bs-theme="dark">
|
|
<div class="container-fluid">
|
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu" aria-controls="navbar-menu" aria-expanded="false" aria-label="Toggle navigation"><span class="navbar-toggler-icon"></span></button>
|
|
<h1 class="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
|
|
<a href="/admin/">
|
|
<span class="brand-speedlines d-none d-md-flex" aria-hidden="true">
|
|
<svg viewBox="0 0 40 64" width="40" height="64" xmlns="http://www.w3.org/2000/svg">
|
|
<defs>
|
|
<linearGradient id="speedfade" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="40" y2="0">
|
|
<stop offset="0" stop-color="#f59e0b" stop-opacity="0"/>
|
|
<stop offset="1" stop-color="#f59e0b" stop-opacity=".85"/>
|
|
</linearGradient>
|
|
</defs>
|
|
<g stroke="url(#speedfade)" stroke-linecap="round" fill="none">
|
|
<line x1="14" y1="20" x2="38" y2="20" stroke-width="5"/>
|
|
<line x1="2" y1="33" x2="36" y2="33" stroke-width="6"/>
|
|
<line x1="18" y1="46" x2="34" y2="46" stroke-width="4.5"/>
|
|
</g>
|
|
</svg>
|
|
</span>
|
|
<img class="navbar-brand-image" src="6krrt-logo.webp" width="64" height="64" style="width:64px;height:64px" alt="6krrt logo">
|
|
</a>
|
|
</h1>
|
|
<div class="navbar-nav flex-row order-md-last">
|
|
<div class="d-flex align-items-center gap-3">
|
|
<span id="nav-widgets" class="d-flex align-items-center"></span>
|
|
</div>
|
|
</div>
|
|
<div class="collapse navbar-collapse" id="navbar-menu">
|
|
<div class="d-flex flex-column flex-md-row flex-fill align-items-stretch align-items-md-center">
|
|
<ul class="navbar-nav">
|
|
<li class="nav-item"><a class="nav-link" href="/admin/models">Models</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="/admin/profiles">Profiles</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="/admin/proficiency">Proficiency</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="/admin/decisions">Decisions</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="/admin/quota">Quota</a></li>
|
|
<li class="nav-item active"><a class="nav-link" href="/admin/controls">Controls</a></li>
|
|
<li class="nav-item"><a class="nav-link" href="/admin/providers">Providers</a></li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- ═══ Page shell ═══ -->
|
|
<div class="page">
|
|
<div class="page-wrapper">
|
|
<div class="page-header d-print-none">
|
|
<div class="container-xl">
|
|
<div class="row g-2 align-items-center">
|
|
<div class="col">
|
|
<h2 class="page-title">Controls</h2>
|
|
<div class="text-muted mt-1">Maintenance triggers, runtime knobs, and persisted configuration</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="page-body">
|
|
<div class="container-xl">
|
|
<div class="row row-cards align-items-start">
|
|
|
|
<!-- Row 1: Operational Triggers as a full-width strip. It is one
|
|
line of buttons, so as a half-width card beside the much taller
|
|
Runtime Knobs it left a column of dead space no matter what
|
|
align-items said. Buttons live in .card-actions; the body is a
|
|
single description/status line. -->
|
|
<div class="col-12">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h3 class="card-title"><span class="me-2" data-icon="zap"></span>Operational Triggers</h3>
|
|
<div class="card-actions d-flex gap-2 flex-wrap">
|
|
<button class="btn btn-success btn-sm" onclick="triggerJob('refresh-catalog')">Refresh Catalog</button>
|
|
<button class="btn btn-success btn-sm" onclick="triggerJob('seed-energy')">Seed Energy</button>
|
|
<button class="btn btn-success btn-sm" onclick="triggerJob('apply-feedback')">Apply Feedback</button>
|
|
<button class="btn btn-danger btn-sm" onclick="triggerJob('restart-service')">Restart Service</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body py-2 d-flex flex-wrap align-items-center justify-content-between gap-2">
|
|
<span class="text-muted" style="font-size:.78rem">Fire-and-forget maintenance jobs.</span>
|
|
<span id="job-status" class="text-muted text-end" style="font-size:0.75rem"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Row 1b: Classifier. A dedicated card, not a row in the generic
|
|
Runtime Knobs list -- classifier.mode has cross-field structure
|
|
(cloud_llm needs a pinned primary OR auto; local_encoder needs
|
|
a model id) that a flat scalar/boolean input can't safely
|
|
represent, the same reasoning behind Local Compute's own card
|
|
in the gaming-mode work this mirrors. -->
|
|
<div class="col-12">
|
|
<div class="card" id="classifier-config-card">
|
|
<div class="card-header">
|
|
<h3 class="card-title"><span class="me-2" data-icon="cpu"></span>Classifier</h3>
|
|
<div class="card-actions d-flex align-items-center gap-2">
|
|
<span id="classifier-mode-badge" class="badge bg-secondary">—</span>
|
|
</div>
|
|
</div>
|
|
<div class="card-body py-3">
|
|
<p class="text-muted mb-3" style="font-size:.78rem">
|
|
Which implementation answers PRIMARY. A peer to the fallback cascade, not a
|
|
replacement for it: whichever mode is primary, a failure still walks the same
|
|
cascade (stale session → session history → <code>cloud_fallback</code> →
|
|
the static guess) unmodified.
|
|
</p>
|
|
<div class="row g-3 align-items-end">
|
|
<div class="col-md-4">
|
|
<label class="form-label" for="classifier-mode-select" style="font-size:.78rem">mode</label>
|
|
<select class="form-select form-select-sm" id="classifier-mode-select" onchange="onClassifierModeChange()">
|
|
<option value="local_llm">local_llm</option>
|
|
<option value="cloud_llm">cloud_llm</option>
|
|
<option value="local_encoder">local_encoder</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-8" id="classifier-mode-fields"></div>
|
|
</div>
|
|
<div id="classifier-resolved-primary" class="text-muted mt-2" style="font-size:.75rem;display:none"></div>
|
|
<!-- Read-only: classifier.candidate_categories is a structural
|
|
list (a validated subset of proficiency.categories), not a
|
|
scalar knob, so it gets a readout rather than an editor.
|
|
The excluded half is the part worth showing -- it is the
|
|
answer to "why does nothing ever classify as X". -->
|
|
<div id="classifier-candidate-categories" class="text-muted mt-2" style="font-size:.75rem;display:none"></div>
|
|
<div id="classifier-config-error" class="alert alert-warning mt-3 mb-0 py-2" style="display:none;font-size:.78rem"></div>
|
|
<div class="d-flex align-items-center justify-content-between mt-3">
|
|
<span id="classifier-config-status" class="text-muted" style="font-size:.72rem"></span>
|
|
<button class="btn btn-primary btn-sm" onclick="saveClassifierConfig()">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Row 1c: gaming mode. Deliberately NOT left to the generic knob
|
|
list below: this is the one toggle whose consequences an
|
|
operator has to read before flipping it, and a bare switch in a
|
|
column of eight says nothing about the cloud classifier it
|
|
requires or about what still runs for free ahead of it. -->
|
|
<div class="col-12">
|
|
<div class="card" id="local-compute-card">
|
|
<div class="card-header">
|
|
<h3 class="card-title"><span class="me-2" data-icon="cpu"></span>Local Compute</h3>
|
|
<div class="card-actions d-flex align-items-center gap-2">
|
|
<span id="local-compute-state" class="badge bg-secondary">—</span>
|
|
<div class="form-check form-switch mb-0">
|
|
<input class="form-check-input" type="checkbox" id="local-compute-toggle" onchange="setLocalCompute(this.checked)">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card-body py-2">
|
|
<p class="text-muted mb-2" style="font-size:.78rem">
|
|
On means the router may use this machine's GPU. Turn it <strong>off</strong> when you
|
|
stop Ollama to play a game: every local call is then <em>skipped</em> rather than
|
|
attempted and timed out — classifier, health probe, local verification, the
|
|
local-vision fallback, and local dispatch rows, which drop out of the candidate
|
|
set instead of being picked and then failing.
|
|
</p>
|
|
<p class="text-muted mb-2" style="font-size:.78rem">
|
|
Turning it off <strong>requires <code>classifier.cloud_fallback</code></strong>. Skipping the
|
|
local classifier does not make classification remote — without a cloud
|
|
classifier it stops classifying, and every request falls through to a static
|
|
guess recorded as <code>general_chat</code>, which is indistinguishable from a real
|
|
classification afterwards.
|
|
</p>
|
|
<p class="text-muted mb-0" style="font-size:.78rem">
|
|
The cloud classifier replaces the <em>local model</em>, not the free steps ahead of
|
|
it: this session's cached classification and this session's history in
|
|
<code>route_decisions</code> are both real classifications of this same session, so paying
|
|
to re-derive an answer already held would be spending money for nothing.
|
|
</p>
|
|
<div id="local-compute-error" class="alert alert-warning mt-2 mb-0 py-2" style="display:none;font-size:.78rem"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<!-- Row 1d: cloud_fallback. Independent always-available fallback
|
|
classifier, NOT a mode-choice option. Used only when the local
|
|
classifier is unreachable -- not a primary-mode setting. -->
|
|
<div class="col-12">
|
|
<div class="card" id="cloud-fallback-card">
|
|
<div class="card-header">
|
|
<h3 class="card-title"><span class="me-2" data-icon="cpu"></span>Cloud Fallback</h3>
|
|
<div class="card-actions d-flex align-items-center gap-2">
|
|
<span id="cloud-fallback-state" class="badge bg-secondary">—</span>
|
|
</div>
|
|
</div>
|
|
<div class="card-body py-3">
|
|
<p class="text-muted mb-3" style="font-size:.78rem">
|
|
A cloud classifier used only when the local classifier is unreachable.
|
|
Independent of the mode choice above — not a primary-mode option.
|
|
</p>
|
|
<div class="row g-2">
|
|
<div class="col-md-6">
|
|
<label class="form-label" for="cf-base-url" style="font-size:.78rem">base_url</label>
|
|
<input class="form-control form-control-sm" id="cf-base-url" placeholder="base_url">
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label" for="cf-model" style="font-size:.78rem">model</label>
|
|
<input class="form-control form-control-sm" id="cf-model" placeholder="model">
|
|
</div>
|
|
<div class="col-md-6">
|
|
<label class="form-label" for="cf-api-key-env" style="font-size:.78rem">api_key_env</label>
|
|
<input class="form-control form-control-sm" id="cf-api-key-env" placeholder="api_key_env (optional)">
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label" for="cf-timeout" style="font-size:.78rem">timeout_seconds</label>
|
|
<input class="form-control form-control-sm" type="number" id="cf-timeout" placeholder="timeout_seconds">
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label" for="cf-max-tokens" style="font-size:.78rem">max_output_tokens</label>
|
|
<input class="form-control form-control-sm" type="number" id="cf-max-tokens" placeholder="max_output_tokens">
|
|
</div>
|
|
</div>
|
|
<div id="cloud-fallback-error" class="alert alert-warning mt-3 mb-0 py-2" style="display:none;font-size:.78rem"></div>
|
|
<div class="d-flex align-items-center justify-content-between mt-3">
|
|
<span id="cloud-fallback-status" class="text-muted" style="font-size:.72rem"></span>
|
|
<div class="d-flex gap-2">
|
|
<button class="btn btn-outline-secondary btn-sm" onclick="clearCloudFallback()">Remove</button>
|
|
<button class="btn btn-primary btn-sm" onclick="saveCloudFallback()">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Row 2: the two settings panels side by side. Same row grammar,
|
|
same control column, so they read as one table split in half:
|
|
left is what the process is running now, right is what the file
|
|
says on next start. -->
|
|
<div class="col-xl-6">
|
|
<div class="card">
|
|
<div class="card-header"><h3 class="card-title"><span class="me-2" data-icon="settings"></span>Runtime Knobs</h3></div>
|
|
<div class="card-body settings-body">
|
|
<p class="text-muted settings-hint">Live on the next request — in-memory only, reverts on restart, no <code>config.yaml</code> write.</p>
|
|
<div class="settings-list" id="runtime-list"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-xl-6">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h3 class="card-title"><span class="me-2" data-icon="database"></span>Persisted Config</h3>
|
|
<div class="card-actions d-flex align-items-center gap-2">
|
|
<span id="config-save-status" class="text-muted" style="font-size:.72rem"></span>
|
|
<button class="btn btn-primary btn-sm" id="config-save-btn" onclick="saveAllConfig()" disabled>Save</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body settings-body">
|
|
<p class="text-muted settings-hint">Applied on restart — allowlisted keys, written to <code>config/config.local.yaml</code> (machine-local overlay; <code>config.yaml</code> stays clean).</p>
|
|
<div class="settings-list" id="config-list">
|
|
<div class="text-muted fst-italic small px-2 py-1">Loading config…</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div><!-- /row row-cards -->
|
|
</div><!-- /container-xl -->
|
|
</div><!-- /page-body -->
|
|
<footer class="footer footer-transparent d-print-none">
|
|
<div class="container-xl">
|
|
<span class="footer-bits"><span>© 2026 adLee</span><span>6krrt, local LLM model router</span><span>admin</span></span>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toast (Bootstrap native) -->
|
|
<div id="toast" class="toast align-items-center text-bg-info border-0 position-fixed bottom-0 end-0 p-3" role="alert" aria-live="assertive" aria-atomic="true"></div>
|
|
|
|
<script>
|
|
/* ═══════════════════════════════════════════════════
|
|
admin/frontend/controls.html — single-file controls page
|
|
API base: relative (works under /admin/)
|
|
═══════════════════════════════════════════════════ */
|
|
|
|
const API = ''; // relative to /admin/
|
|
const SSE_URL = '/events/decisions'; // root-level endpoint
|
|
|
|
/* ═─ Inline icon helper (no tabler-icons webfont) ═─ */
|
|
function icon(name, size) {
|
|
const s = size || 18;
|
|
const svgs = {
|
|
zap: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>`,
|
|
settings: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`,
|
|
database: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>`,
|
|
cpu: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg>`,
|
|
chevronDown:`<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`,
|
|
alert: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`,
|
|
info: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`,
|
|
default: `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/></svg>`,
|
|
};
|
|
return svgs[name] || svgs.default;
|
|
}
|
|
|
|
function renderStaticIcons() {
|
|
document.querySelectorAll('[data-icon]').forEach(el => {
|
|
el.innerHTML = icon(el.dataset.icon);
|
|
});
|
|
}
|
|
|
|
/* ── Toast (Bootstrap native via tabler wrapper) ── */
|
|
let _toastEl = null;
|
|
function toast(msg, type = 'info') {
|
|
const bgClass = { success: 'text-bg-success', error: 'text-bg-danger', info: 'text-bg-info' }[type] || 'text-bg-info';
|
|
if (!_toastEl) {
|
|
_toastEl = document.getElementById('toast');
|
|
_toastEl.innerHTML = `<div class="d-flex">
|
|
<div class="toast-body"></div>
|
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
|
</div>`;
|
|
_toastEl._bt = new tabler.Toast(_toastEl, { delay: 4000 });
|
|
}
|
|
_toastEl.querySelector('.toast-body').textContent = msg;
|
|
_toastEl.className = `toast align-items-center ${bgClass} border-0 position-fixed bottom-0 end-0 p-3`;
|
|
_toastEl._bt.show();
|
|
}
|
|
|
|
/* ── Fetch Wrapper ── */
|
|
async function apiFetch(url, opts = {}) {
|
|
try {
|
|
const resp = await fetch(url, opts);
|
|
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
|
|
return await resp.json();
|
|
} catch (e) {
|
|
console.warn('API call failed:', url, e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/* Preserves the server's `detail` on a non-2xx response instead of flattening
|
|
it to a generic status message -- the classifier-config validation errors
|
|
(e.g. "cloud_primary is required when cloud_primary_auto is false") are
|
|
exactly the information a save-time failure needs to show. */
|
|
async function apiFetchDetail(url, opts = {}) {
|
|
const resp = await fetch(url, opts);
|
|
const body = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) {
|
|
throw new Error(body.detail || `${resp.status} ${resp.statusText}`);
|
|
}
|
|
return body;
|
|
}
|
|
|
|
/* ═══════════════════════════════════════
|
|
SSE LIVE STREAM
|
|
═══════════════════════════════════════ */
|
|
let sseConn = null;
|
|
|
|
function connectSSE() {
|
|
try {
|
|
if (sseConn) { sseConn.close(); }
|
|
sseConn = new EventSource(SSE_URL);
|
|
|
|
navbarSse('connecting');
|
|
|
|
sseConn.onopen = () => {
|
|
navbarSse('live');
|
|
};
|
|
|
|
sseConn.onerror = () => {
|
|
navbarSse('offline', 'reconnecting');
|
|
};
|
|
} catch (e) {
|
|
navbarSse('offline');
|
|
}
|
|
}
|
|
|
|
/* ═══════════════════════════════════════
|
|
OPERATIONAL TRIGGERS
|
|
═══════════════════════════════════════ */
|
|
|
|
async function triggerJob(action) {
|
|
const statusEl = document.getElementById('job-status');
|
|
let url;
|
|
switch(action) {
|
|
case 'refresh-catalog': url = `${API}api/refresh-catalog`; break;
|
|
case 'seed-energy': url = `${API}api/seed-energy?samples=5`; break;
|
|
case 'apply-feedback': url = `${API}api/apply-feedback?dry_run=true`; break;
|
|
case 'restart-service': url = `${API}api/restart-service`; break;
|
|
default: return;
|
|
}
|
|
statusEl.innerHTML = `<span style="color:var(--tblr-warning)">Running ${action}…</span>`;
|
|
const resp = await apiFetch(url, { method:'POST' });
|
|
if (resp) {
|
|
if (resp.status === 'restarting') {
|
|
statusEl.innerHTML = `<span style="color:var(--tblr-danger)">Service restarting, page will reload automatically</span>`;
|
|
toast('Service restart triggered', 'info');
|
|
setTimeout(() => location.reload(), 5000);
|
|
} else {
|
|
statusEl.innerHTML = `<span style="color:var(--tblr-success)">${action}: ${resp.status || 'done'}</span>`;
|
|
toast(`${action} completed`, 'success');
|
|
}
|
|
} else {
|
|
statusEl.innerHTML = `<span style="color:var(--tblr-danger)">${action} failed</span>`;
|
|
toast(`${action} failed`, 'error');
|
|
}
|
|
}
|
|
|
|
/* ═══════════════════════════════════════
|
|
RUNTIME KNOBS
|
|
═══════════════════════════════════════ */
|
|
|
|
function knobDisplayValue(v) {
|
|
if (v !== null && typeof v === 'object') {
|
|
if ('enabled' in v) return String(v.enabled);
|
|
try { return JSON.stringify(v); } catch (_) { return String(v); }
|
|
}
|
|
if (typeof v === 'boolean') return String(v);
|
|
return String(v);
|
|
}
|
|
|
|
// Knobs (and Persisted Config keys, below) with only a few legal values get
|
|
// a <select> instead of a free-text input — the values come straight from
|
|
// the Pydantic enum/validator on the backend, so this list stays in sync
|
|
// with config.py by hand, not by inspection.
|
|
const ENUM_VALUES = {
|
|
default_flex_preference: ['no-flex', 'auto', 'prefer-flex', 'force-flex'],
|
|
'routing.default_flex_preference': ['no-flex', 'auto', 'prefer-flex', 'force-flex'],
|
|
'logging.level': ['debug', 'info', 'warning', 'error'],
|
|
};
|
|
|
|
function enumSelect(key, value, { dataAttr = 'data-knob', onchangeExpr = '' } = {}) {
|
|
const opts = ENUM_VALUES[key].map(v =>
|
|
`<option value="${v}"${v === value ? ' selected' : ''}>${v}</option>`
|
|
).join('');
|
|
const onchange = onchangeExpr ? ` onchange="${onchangeExpr}"` : '';
|
|
return `<select class="form-select form-select-sm toggle-input" ${dataAttr}="${key}"${onchange}>${opts}</select>`;
|
|
}
|
|
|
|
// A dotted key reads as scope + leaf; dimming the scope makes a column of
|
|
// `objective.*` / `pinch.*` keys scannable by the part that differs.
|
|
function keyHtml(key) {
|
|
const cut = key.lastIndexOf('.');
|
|
if (cut === -1) return escapeHtml(key);
|
|
return `<span class="scope">${escapeHtml(key.slice(0, cut + 1))}</span>${escapeHtml(key.slice(cut + 1))}`;
|
|
}
|
|
|
|
// Units the config file expresses but the key name doesn't.
|
|
const UNITS = {
|
|
'objective.max_energy_per_request': 'kWh',
|
|
'objective.plan_kwh_per_period': 'kWh',
|
|
};
|
|
|
|
/* A consequence the control itself cannot show. Keyed by both the runtime knob
|
|
name and the persisted-config key, since these knobs appear in both panels.
|
|
Keep `text` to one short line -- the meta track shares a row with the key and
|
|
the control -- and put the rest in `title`.
|
|
`cls` picks the register: bg-warning (amber, alert glyph) where flipping the
|
|
control COSTS something, bg-info (blue, info glyph) for a plain relationship
|
|
between two rows. NOT bg-secondary -- that is the provenance badge's colour,
|
|
and two grey badges in one meta track read as one run-on label. */
|
|
const PROBE_NOTE = {
|
|
text: 'off stops the cache-loss measurement',
|
|
title: 'telemetry only: the evidence for whether context pruning needs rewriting',
|
|
cls: 'bg-warning',
|
|
glyph: 'alert',
|
|
};
|
|
/* The gate and the dial are two rows that only mean anything together, and
|
|
they render adjacent in both panels (admin.py keeps _runtime_state and
|
|
_CONFIG_GET_ORDER in this order). Saying so in four words beats a paragraph
|
|
above the list. */
|
|
const INCUMBENT_GATE_NOTE = {
|
|
text: 'enables the challenger dial',
|
|
title: 'Off: ranking is byte-identical to neutral and the challenger dial has no effect.',
|
|
cls: 'bg-info',
|
|
glyph: 'info',
|
|
};
|
|
const CHALLENGER_DIAL_NOTE = {
|
|
text: 'blank = neutral, 0 = full penalty',
|
|
title: 'Challenger cache rate, 0 to 1 (not a percentage). Blank follows objective.assumed_cache_rate, which costs challengers nothing; 0 prices them as fully cold prompts. Needs incumbent_cache_pricing on.',
|
|
cls: 'bg-info',
|
|
glyph: 'info',
|
|
};
|
|
/* Same pairing as the gate and the dial: a switch and the window it gates,
|
|
rendered adjacent in both panels. The window is the knob that decides how
|
|
long ONE classification keeps steering routing, so its note carries the
|
|
measurement rather than a description. */
|
|
const SESSION_CACHE_GATE_NOTE = {
|
|
text: 'enables the reuse window',
|
|
title: 'Off: every turn is classified fresh and the window below decides nothing.',
|
|
cls: 'bg-info',
|
|
glyph: 'info',
|
|
};
|
|
const SESSION_CACHE_TTL_NOTE = {
|
|
text: 'how long one label steers routing',
|
|
title: 'Seconds a session reuses its last classification, 5 to 7200. No blank and no zero: to stop reusing labels turn session_cache off, because 0 still caches and the classifier-failure cascade still replays the entry. Measured on live traffic: one classification drove 107 consecutive turns.',
|
|
cls: 'bg-info',
|
|
glyph: 'info',
|
|
};
|
|
const KNOB_NOTES = {
|
|
pinch_prefix_probe: PROBE_NOTE,
|
|
'pinch.prefix_probe': PROBE_NOTE,
|
|
session_cache_enabled: SESSION_CACHE_GATE_NOTE,
|
|
'session_cache.enabled': SESSION_CACHE_GATE_NOTE,
|
|
session_cache_staleness_seconds: SESSION_CACHE_TTL_NOTE,
|
|
'session_cache.staleness_seconds': SESSION_CACHE_TTL_NOTE,
|
|
incumbent_cache_pricing: INCUMBENT_GATE_NOTE,
|
|
'objective.incumbent_cache_pricing': INCUMBENT_GATE_NOTE,
|
|
incumbent_challenger_cache_rate: CHALLENGER_DIAL_NOTE,
|
|
'objective.incumbent_challenger_cache_rate': CHALLENGER_DIAL_NOTE,
|
|
};
|
|
|
|
function noteBadge(key) {
|
|
const note = KNOB_NOTES[key];
|
|
if (!note) return '';
|
|
return `<span class="badge ${note.cls}" title="${escapeHtml(note.title)}">${icon(note.glyph || 'alert', 12)} ${escapeHtml(note.text)}</span>`;
|
|
}
|
|
|
|
/* Bounds for the numeric runtime knobs, so the browser refuses out of range
|
|
before the server has to. A knob absent here renders as a plain number
|
|
field. `placeholder` is what an EMPTY field means, and for the dial that is
|
|
neutral -- never zero, which is the other end of the same dial. */
|
|
const NUMBER_BOUNDS = {
|
|
incumbent_challenger_cache_rate: { min: 0, max: 1, step: 0.001, placeholder: 'neutral' },
|
|
// No `placeholder`, deliberately: an empty field means nothing here, and a
|
|
// placeholder is how the dial above says that it does. Blank posts null and
|
|
// the server refuses it, naming session_cache_enabled as the switch the
|
|
// operator was reaching for. min is 5 rather than 0 -- 0 is not "off", it
|
|
// still caches and the classifier-failure cascade still replays the entry.
|
|
session_cache_staleness_seconds: { min: 5, max: 7200, step: 1 },
|
|
};
|
|
|
|
function settingRow({ key, meta = '', control, dirtyAttrs = '' }) {
|
|
return `<div class="setting-row"${dirtyAttrs}>
|
|
<span class="setting-key" title="${escapeHtml(key)}">${keyHtml(key)}</span>
|
|
<span class="setting-meta">${meta}</span>
|
|
<span class="setting-control">${control}</span>
|
|
</div>`;
|
|
}
|
|
|
|
function renderRuntime(state) {
|
|
const el = document.getElementById('runtime-list');
|
|
const knobs = state || {};
|
|
const html = Object.keys(knobs).map(key => {
|
|
const knob = knobs[key];
|
|
const persisted = knob.persisted;
|
|
const runtime = knob.runtime;
|
|
const isDiff = JSON.stringify(persisted) !== JSON.stringify(runtime);
|
|
const runtimeStr = knobDisplayValue(runtime);
|
|
|
|
let control;
|
|
if (typeof persisted === 'boolean') {
|
|
control = `<div class="form-check form-switch">
|
|
<input class="form-check-input toggle-input" type="checkbox" data-knob="${key}" ${runtime ? 'checked' : ''} onchange="toggleKnob('${key}', this.checked)">
|
|
</div>`;
|
|
} else if (ENUM_VALUES[key]) {
|
|
control = enumSelect(key, runtime, { onchangeExpr: `toggleKnob('${key}', this.value)` });
|
|
} else if (typeof persisted === 'number') {
|
|
// Blank posts null, NOT 0. `Number('')` is 0 in JavaScript and 0 is a
|
|
// real, costly setting on both numeric knobs -- the maximum penalty on
|
|
// the challenger dial, and an out-of-range not-quite-off on the session
|
|
// cache window -- so clearing a field must never be read as choosing it.
|
|
// What null MEANS is the server's call: neutral for the dial, a refusal
|
|
// naming session_cache_enabled for the window.
|
|
const b = NUMBER_BOUNDS[key] || {};
|
|
const attrs = [
|
|
b.min !== undefined ? `min="${b.min}"` : '',
|
|
b.max !== undefined ? `max="${b.max}"` : '',
|
|
b.step !== undefined ? `step="${b.step}"` : '',
|
|
b.placeholder ? `placeholder="${escapeHtml(b.placeholder)}"` : '',
|
|
].filter(Boolean).join(' ');
|
|
const shown = (runtime === null || runtime === undefined) ? '' : String(runtime);
|
|
control = `<input class="form-control form-control-sm toggle-input" type="number" ${attrs} data-knob="${key}" value="${escapeHtml(shown)}" onchange="toggleKnob('${key}', this.value.trim() === '' ? null : Number(this.value))">`;
|
|
} else if (typeof persisted === 'string') {
|
|
control = `<input class="form-control form-control-sm toggle-input" type="text" data-knob="${key}" value="${escapeHtml(runtimeStr)}" onchange="toggleKnob('${key}', this.value)">`;
|
|
} else {
|
|
control = `<span class="text-muted small">${escapeHtml(runtimeStr)}</span>`;
|
|
}
|
|
|
|
// The control already shows the live value, so the only facts worth a
|
|
// second column are disagreement with the file and a consequence the
|
|
// switch cannot state for itself.
|
|
const diffBadge = isDiff
|
|
? `<span class="badge bg-warning-subtle text-warning" title="config.yaml says ${escapeHtml(knobDisplayValue(persisted))}; running with ${escapeHtml(runtimeStr)}">file: ${escapeHtml(knobDisplayValue(persisted))}</span>`
|
|
: '';
|
|
const meta = [noteBadge(key), diffBadge].filter(Boolean).join(' ');
|
|
|
|
return settingRow({ key, meta, control });
|
|
}).join('');
|
|
el.innerHTML = html || '<div class="text-muted fst-italic small px-2 py-1">No runtime knobs</div>';
|
|
}
|
|
|
|
/* A knob POST that keeps the server's `detail`. The generic apiFetch swallows
|
|
it, which is fine for a knob that cannot be refused — local_compute_enabled
|
|
can be, and "Failed to toggle" instead of the reason would defeat the whole
|
|
point of refusing rather than warning. */
|
|
async function postKnob(knob, value) {
|
|
const resp = await fetch(`${API}api/runtime/${encodeURIComponent(knob)}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ value }),
|
|
});
|
|
const body = await resp.json().catch(() => ({}));
|
|
if (!resp.ok) throw new Error(body.detail || `${resp.status} ${resp.statusText}`);
|
|
return body;
|
|
}
|
|
|
|
async function toggleKnob(knob, value) {
|
|
const isSwitch = typeof value === 'boolean';
|
|
try {
|
|
await postKnob(knob, value);
|
|
toast(`${knob} ${isSwitch ? 'toggled' : 'set'}`, 'success');
|
|
if (knob === LOCAL_COMPUTE_KNOB) renderLocalCompute(value);
|
|
// A numeric knob can be stored as something other than what was typed --
|
|
// a blank dial posts null and the server resolves it to the neutral rate --
|
|
// so re-read rather than leave the field disagreeing with the router.
|
|
if (NUMBER_BOUNDS[knob] || value === null) loadControls();
|
|
} catch (e) {
|
|
toast(e.message || `Failed to ${isSwitch ? 'toggle' : 'set'} ${knob}`, 'error');
|
|
// Re-read rather than assume the inverse: the request may have failed
|
|
// after the flip, and a switch showing a value the router does not hold
|
|
// is worse than a round-trip.
|
|
loadControls();
|
|
}
|
|
}
|
|
|
|
/* ═══════════════════════════════════════
|
|
LOCAL COMPUTE ("gaming mode")
|
|
═══════════════════════════════════════ */
|
|
|
|
const LOCAL_COMPUTE_KNOB = 'local_compute_enabled';
|
|
|
|
function renderLocalCompute(enabled) {
|
|
const toggle = document.getElementById('local-compute-toggle');
|
|
const state = document.getElementById('local-compute-state');
|
|
if (!toggle || !state) return;
|
|
toggle.checked = enabled === true;
|
|
state.className = enabled ? 'badge bg-success' : 'badge bg-warning';
|
|
state.textContent = enabled ? 'local compute on' : 'gaming mode';
|
|
}
|
|
|
|
function showLocalComputeError(message) {
|
|
const box = document.getElementById('local-compute-error');
|
|
if (!box) return;
|
|
if (!message) {
|
|
box.style.display = 'none';
|
|
box.textContent = '';
|
|
return;
|
|
}
|
|
box.textContent = message;
|
|
box.style.display = 'block';
|
|
}
|
|
|
|
async function setLocalCompute(enabled) {
|
|
showLocalComputeError('');
|
|
try {
|
|
await postKnob(LOCAL_COMPUTE_KNOB, enabled);
|
|
renderLocalCompute(enabled);
|
|
toast(enabled ? 'Local compute enabled' : 'Gaming mode on — local calls skipped', 'success');
|
|
loadControls(); // The generic knob row mirrors this one; keep them agreed.
|
|
} catch (e) {
|
|
// The refusal is the feature. Park it above the toggle where it stays
|
|
// readable, rather than only in a toast that vanishes in four seconds.
|
|
showLocalComputeError(e.message || 'Failed to change local compute');
|
|
renderLocalCompute(!enabled);
|
|
}
|
|
}
|
|
|
|
/* ═══════════════════════════════════════
|
|
PERSISTED CONFIG EDITOR
|
|
═══════════════════════════════════════ */
|
|
|
|
let _profilesCache = null;
|
|
|
|
function renderConfig(config) {
|
|
const list = document.getElementById('config-list');
|
|
if (!config || !Object.keys(config).length) {
|
|
list.innerHTML = '<div class="text-muted fst-italic small px-2 py-1">No config data</div>';
|
|
return;
|
|
}
|
|
const html = Object.entries(config).map(([key, raw]) => {
|
|
const entry = raw || {};
|
|
const val = entry.value;
|
|
const source = entry.source;
|
|
const isBool = typeof val === 'boolean';
|
|
let control;
|
|
if (isBool) {
|
|
// A switch, matching Runtime Knobs — the bare checkbox here was the one
|
|
// control on the page that didn't look like the others.
|
|
control = `<div class="form-check form-switch">
|
|
<input class="form-check-input config-input toggle-input" type="checkbox" ${val ? 'checked' : ''} data-config-input>
|
|
</div>`;
|
|
} else if (key === 'routing.default_profile') {
|
|
control = profileSelect(val);
|
|
} else if (ENUM_VALUES[key]) {
|
|
control = enumSelect(key, val, { dataAttr: 'data-config-input' });
|
|
} else {
|
|
control = `<input class="form-control form-control-sm config-input toggle-input" type="text" value="${val === null ? '' : escapeHtml(String(val))}" placeholder="${val === null ? 'null' : ''}" data-config-input>`;
|
|
}
|
|
|
|
// Provenance badge is legitimate meta: it explains where the effective
|
|
// value came from without restating the value the control already shows.
|
|
let meta = [UNITS[key] ? escapeHtml(UNITS[key]) : '', noteBadge(key)].filter(Boolean).join(' ');
|
|
if (source === 'overlay') {
|
|
meta += `${meta ? ' ' : ''}<span class="badge bg-purple" title="set in config.local.yaml">overlay</span>`;
|
|
} else if (source === 'base') {
|
|
meta += `${meta ? ' ' : ''}<span class="badge bg-secondary" title="from config.yaml">base</span>`;
|
|
}
|
|
|
|
return settingRow({
|
|
key,
|
|
meta,
|
|
control,
|
|
dirtyAttrs: ` data-key="${escapeHtml(key)}" data-orig="${escapeHtml(String(val === null ? '' : val))}"`,
|
|
});
|
|
}).join('');
|
|
list.innerHTML = html;
|
|
updateDefaultProfileHint();
|
|
markDirty();
|
|
}
|
|
|
|
function profileSelect(currentValue) {
|
|
// Always build a fresh option list so a failed /admin/api/profiles fetch or a
|
|
// hand-edited default_profile value never leaves the control blank. If the
|
|
// persisted value is not in the live list, render it as the lone option so
|
|
// the operator still sees (and can save back) the current value.
|
|
const profiles = _profilesCache || [];
|
|
const options = profiles.slice();
|
|
const optionNames = new Set(profiles.map(p => p.name));
|
|
if (currentValue !== null && currentValue !== undefined && currentValue !== '' && !optionNames.has(currentValue)) {
|
|
options.push({ name: currentValue, zero_admit: false, source: 'orphan' });
|
|
}
|
|
const opts = options.map(p =>
|
|
`<option value="${escapeHtml(p.name)}"${p.name === currentValue ? ' selected' : ''}>${escapeHtml(p.name)}</option>`
|
|
).join('');
|
|
return `<select class="form-select form-select-sm config-input toggle-input" data-config-input data-profile-select>${opts}</select>`;
|
|
}
|
|
|
|
function updateDefaultProfileHint() {
|
|
const row = document.querySelector('#config-list .setting-row[data-key="routing.default_profile"]');
|
|
if (!row) return;
|
|
const input = row.querySelector('[data-profile-select]');
|
|
if (!input) return;
|
|
const profiles = _profilesCache || [];
|
|
const selected = profiles.find(p => p.name === input.value);
|
|
const meta = row.querySelector('.setting-meta');
|
|
const existing = meta.querySelector('.default-profile-hint');
|
|
if (existing) existing.remove();
|
|
if (selected && selected.zero_admit) {
|
|
const hint = document.createElement('span');
|
|
hint.className = 'badge bg-warning default-profile-hint ms-2';
|
|
hint.title = 'This profile currently admits no models';
|
|
hint.innerHTML = `${icon('alert', 12)} admits 0 models`;
|
|
meta.appendChild(hint);
|
|
}
|
|
}
|
|
|
|
/* The inputs hold the values, so the only thing worth flagging is an edit that
|
|
hasn't been written yet: an amber rail on the row, a live count on Save. */
|
|
function rowValue(row) {
|
|
const input = row.querySelector('[data-config-input]');
|
|
return input.type === 'checkbox' ? String(input.checked) : input.value;
|
|
}
|
|
|
|
function markDirty() {
|
|
const rows = document.querySelectorAll('#config-list .setting-row[data-key]');
|
|
let dirty = 0;
|
|
rows.forEach(row => {
|
|
const changed = rowValue(row) !== row.getAttribute('data-orig');
|
|
row.classList.toggle('is-dirty', changed);
|
|
if (changed) dirty += 1;
|
|
});
|
|
const btn = document.getElementById('config-save-btn');
|
|
btn.disabled = dirty === 0;
|
|
btn.textContent = dirty ? `Save ${dirty} change${dirty === 1 ? '' : 's'}` : 'Save';
|
|
return dirty;
|
|
}
|
|
|
|
async function saveAllConfig() {
|
|
const rows = [...document.querySelectorAll('#config-list .setting-row[data-key]')]
|
|
.filter(row => rowValue(row) !== row.getAttribute('data-orig'));
|
|
const statusEl = document.getElementById('config-save-status');
|
|
if (!rows.length) return;
|
|
let ok = 0;
|
|
let failed = 0;
|
|
for (const row of rows) {
|
|
const key = row.getAttribute('data-key');
|
|
const input = row.querySelector('[data-config-input]');
|
|
const val = input.type === 'checkbox' ? input.checked : input.value;
|
|
let parsed = val;
|
|
if (typeof val === 'string') {
|
|
if (val.trim() === '') {
|
|
parsed = null;
|
|
} else {
|
|
const num = Number(val);
|
|
if (!isNaN(num)) parsed = num;
|
|
}
|
|
}
|
|
statusEl.textContent = `saving ${key}…`;
|
|
statusEl.style.color = 'var(--tblr-secondary)';
|
|
const res = await apiFetch(
|
|
`${API}api/config/${encodeURIComponent(key)}`,
|
|
{ method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({ value: parsed }) }
|
|
);
|
|
if (res === null) {
|
|
failed += 1;
|
|
row.style.color = 'var(--tblr-danger)';
|
|
} else {
|
|
ok += 1;
|
|
row.style.color = '';
|
|
}
|
|
}
|
|
const allOk = failed === 0;
|
|
statusEl.textContent = allOk ? `saved ${ok}` : `${failed} write(s) failed, ${ok} saved`;
|
|
statusEl.style.color = allOk ? 'var(--tblr-success)' : 'var(--tblr-danger)';
|
|
if (allOk) toast('Config updated', 'success');
|
|
setTimeout(loadControls, 1500);
|
|
}
|
|
|
|
/* ═══════════════════════════════════════
|
|
CLASSIFIER CONFIG
|
|
═══════════════════════════════════════
|
|
|
|
The profiles-page lesson applied here rather than relearned: this must
|
|
show what dispatch actually does, not a static echo of the config. The
|
|
GET response's resolved_primary comes from the SAME function classify()
|
|
calls (routing.cheapest_classifier_candidate), computed server-side --
|
|
this file never re-derives it, it only renders what the server already
|
|
resolved live. */
|
|
|
|
let _classifierConfigData = null;
|
|
|
|
function classifierModeFieldsHtml(mode, data) {
|
|
const cp = (data && data.cloud_primary) || {};
|
|
if (mode === 'cloud_llm') {
|
|
const auto = data ? !!data.cloud_primary_auto : false;
|
|
return `
|
|
<div class="form-check form-switch mb-2">
|
|
<input class="form-check-input" type="checkbox" id="classifier-cloud-auto"
|
|
${auto ? 'checked' : ''} onchange="onClassifierCloudAutoToggle()">
|
|
<label class="form-check-label" for="classifier-cloud-auto" style="font-size:.78rem">
|
|
auto (resolve the cheapest routable model, live)
|
|
</label>
|
|
</div>
|
|
<div id="classifier-cloud-pinned-fields" class="row g-2" style="${auto ? 'display:none' : ''}">
|
|
<div class="col-md-6">
|
|
<input class="form-control form-control-sm" id="classifier-cloud-base-url"
|
|
placeholder="base_url" value="${escapeHtml(cp.base_url || '')}">
|
|
</div>
|
|
<div class="col-md-6">
|
|
<input class="form-control form-control-sm" id="classifier-cloud-model"
|
|
placeholder="model" value="${escapeHtml(cp.model || '')}">
|
|
</div>
|
|
<div class="col-md-6">
|
|
<input class="form-control form-control-sm" id="classifier-cloud-api-key-env"
|
|
placeholder="api_key_env (optional)" value="${escapeHtml(cp.api_key_env || '')}">
|
|
</div>
|
|
<div class="col-md-3">
|
|
<input class="form-control form-control-sm" type="number" id="classifier-cloud-timeout"
|
|
placeholder="timeout_seconds" value="${cp.timeout_seconds != null ? cp.timeout_seconds : ''}">
|
|
</div>
|
|
</div>`;
|
|
}
|
|
if (mode === 'local_encoder') {
|
|
const enc = (data && data.encoder) || {};
|
|
return `
|
|
<div class="row g-2">
|
|
<div class="col-md-6">
|
|
<input class="form-control form-control-sm" id="classifier-encoder-model"
|
|
placeholder="model (default: BAAI/bge-large-en-v1.5)"
|
|
value="${escapeHtml(enc.model || '')}">
|
|
</div>
|
|
<div class="col-md-3">
|
|
<select class="form-select form-select-sm" id="classifier-encoder-device">
|
|
<option value="cpu" ${enc.device !== 'cuda' ? 'selected' : ''}>cpu</option>
|
|
<option value="cuda" ${enc.device === 'cuda' ? 'selected' : ''}>cuda</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="input-group input-group-sm">
|
|
<input class="form-control form-control-sm" type="number" step="1" min="0" max="100"
|
|
id="classifier-encoder-threshold" placeholder="confidence % (0-100)"
|
|
value="${enc.confidence_threshold != null ? Math.round(enc.confidence_threshold * 100) : ''}">
|
|
<span class="input-group-text">%</span>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
return `<span class="text-muted" style="font-size:.78rem">Today's default: the local model configured under <code>classifier.model</code> above.</span>`;
|
|
}
|
|
|
|
function renderClassifierResolvedPrimary(data) {
|
|
const el = document.getElementById('classifier-resolved-primary');
|
|
if (data.mode === 'cloud_llm' && data.cloud_primary_auto) {
|
|
if (data.resolved_primary) {
|
|
el.style.display = '';
|
|
el.innerHTML = `resolves right now to <strong>${escapeHtml(data.resolved_primary.model_id)}</strong>
|
|
<span class="text-muted">(${escapeHtml(data.resolved_primary.provider)}, live)</span>`;
|
|
} else {
|
|
el.style.display = '';
|
|
el.innerHTML = `<span class="text-warning">no routable model currently has both a catalog price and clears the routing filters -- would fall through to the cascade</span>`;
|
|
}
|
|
} else {
|
|
el.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
function onClassifierModeChange() {
|
|
const mode = document.getElementById('classifier-mode-select').value;
|
|
document.getElementById('classifier-mode-fields').innerHTML =
|
|
classifierModeFieldsHtml(mode, _classifierConfigData && _classifierConfigData.mode === mode ? _classifierConfigData : null);
|
|
document.getElementById('classifier-resolved-primary').style.display = 'none';
|
|
showClassifierConfigError('');
|
|
}
|
|
|
|
/* Deliberately NOT a call to onClassifierModeChange(): that fully
|
|
regenerates the fields from _classifierConfigData, which would discard
|
|
the very checkbox state this handler exists to react to (and anything
|
|
already typed into the pinned fields) the instant the box was ticked.
|
|
This only toggles which half is visible. */
|
|
function onClassifierCloudAutoToggle() {
|
|
const auto = document.getElementById('classifier-cloud-auto').checked;
|
|
const pinned = document.getElementById('classifier-cloud-pinned-fields');
|
|
if (pinned) pinned.style.display = auto ? 'none' : '';
|
|
document.getElementById('classifier-resolved-primary').style.display = 'none';
|
|
showClassifierConfigError('');
|
|
}
|
|
|
|
function showClassifierConfigError(message) {
|
|
const box = document.getElementById('classifier-config-error');
|
|
if (!message) { box.style.display = 'none'; box.textContent = ''; return; }
|
|
box.textContent = message;
|
|
box.style.display = 'block';
|
|
}
|
|
|
|
/* Read-only readout of classifier.candidate_categories. textContent rather
|
|
than innerHTML throughout: these strings come from a config file, and a
|
|
readout is not worth an XSS sink. */
|
|
function renderClassifierCandidateCategories(data) {
|
|
const el = document.getElementById('classifier-candidate-categories');
|
|
if (!el) return;
|
|
const candidates = data.candidate_categories || [];
|
|
const excluded = data.excluded_categories || [];
|
|
if (!candidates.length) { el.style.display = 'none'; el.textContent = ''; return; }
|
|
el.textContent = '';
|
|
const label = document.createElement('span');
|
|
label.textContent = 'labels the classifier may return: ';
|
|
el.appendChild(label);
|
|
const list = document.createElement('code');
|
|
list.textContent = candidates.join(', ');
|
|
el.appendChild(list);
|
|
if (excluded.length) {
|
|
const note = document.createElement('div');
|
|
note.className = 'mt-1';
|
|
const head = document.createElement('span');
|
|
head.textContent = 'excluded from the scoring axis, so they stop accumulating outcomes: ';
|
|
note.appendChild(head);
|
|
const ex = document.createElement('code');
|
|
ex.textContent = excluded.join(', ');
|
|
note.appendChild(ex);
|
|
el.appendChild(note);
|
|
}
|
|
el.style.display = '';
|
|
}
|
|
|
|
async function loadClassifierConfig() {
|
|
const data = await apiFetch(`${API}api/classifier-config`);
|
|
if (!data) return;
|
|
_classifierConfigData = data;
|
|
document.getElementById('classifier-mode-select').value = data.mode;
|
|
document.getElementById('classifier-mode-fields').innerHTML = classifierModeFieldsHtml(data.mode, data);
|
|
renderClassifierResolvedPrimary(data);
|
|
renderClassifierCandidateCategories(data);
|
|
const badge = document.getElementById('classifier-mode-badge');
|
|
badge.textContent = data.mode + (data.mode_source === 'overlay' ? ' (overlay)' : '');
|
|
badge.className = data.mode === 'local_llm' ? 'badge bg-secondary' : 'badge bg-info';
|
|
}
|
|
|
|
function collectClassifierConfigBody() {
|
|
const mode = document.getElementById('classifier-mode-select').value;
|
|
const body = { mode };
|
|
if (mode === 'cloud_llm') {
|
|
const auto = document.getElementById('classifier-cloud-auto').checked;
|
|
body.cloud_primary_auto = auto;
|
|
if (!auto) {
|
|
const baseUrl = document.getElementById('classifier-cloud-base-url').value.trim();
|
|
const model = document.getElementById('classifier-cloud-model').value.trim();
|
|
// Send null, not {base_url: "", model: ""}, when either is blank --
|
|
// an object with keys is truthy, so the backend's own "cloud_primary
|
|
// is required" check would not catch an empty-but-present block.
|
|
body.cloud_primary = (baseUrl && model) ? { base_url: baseUrl, model } : null;
|
|
if (body.cloud_primary) {
|
|
const apiKeyEnv = document.getElementById('classifier-cloud-api-key-env').value.trim();
|
|
if (apiKeyEnv) body.cloud_primary.api_key_env = apiKeyEnv;
|
|
const timeout = document.getElementById('classifier-cloud-timeout').value;
|
|
if (timeout !== '') body.cloud_primary.timeout_seconds = parseInt(timeout, 10);
|
|
}
|
|
}
|
|
} else if (mode === 'local_encoder') {
|
|
const encoder = {};
|
|
const model = document.getElementById('classifier-encoder-model').value.trim();
|
|
if (model) encoder.model = model;
|
|
encoder.device = document.getElementById('classifier-encoder-device').value;
|
|
const thresholdPct = document.getElementById('classifier-encoder-threshold').value;
|
|
// The field is 0-100 for a human to type ("80" meaning 80%); the backend
|
|
// wants the 0.0-1.0 probability classify_zero_shot actually returns.
|
|
if (thresholdPct !== '') encoder.confidence_threshold = parseFloat(thresholdPct) / 100;
|
|
body.encoder = encoder;
|
|
}
|
|
return body;
|
|
}
|
|
|
|
async function saveClassifierConfig() {
|
|
showClassifierConfigError('');
|
|
const statusEl = document.getElementById('classifier-config-status');
|
|
try {
|
|
const result = await apiFetchDetail(`${API}api/classifier-config`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(collectClassifierConfigBody()),
|
|
});
|
|
statusEl.textContent = result.message || 'saved';
|
|
statusEl.style.color = 'var(--tblr-success)';
|
|
toast('Classifier config saved', 'success');
|
|
await loadClassifierConfig();
|
|
} catch (e) {
|
|
showClassifierConfigError(e.message || 'Save failed');
|
|
statusEl.textContent = '';
|
|
}
|
|
}
|
|
|
|
|
|
/* ═════════════════════════════════════════
|
|
CLOUD FALLBACK CONFIG
|
|
═════════════════════════════════════════ */
|
|
|
|
let _cloudFallbackConfigured = false;
|
|
|
|
async function loadCloudFallback() {
|
|
const data = await apiFetch(`${API}api/classifier-config`);
|
|
if (!data) return;
|
|
const cf = data.cloud_fallback;
|
|
const badge = document.getElementById('cloud-fallback-state');
|
|
const baseUrlEl = document.getElementById('cf-base-url');
|
|
const modelEl = document.getElementById('cf-model');
|
|
const apiKeyEl = document.getElementById('cf-api-key-env');
|
|
const timeoutEl = document.getElementById('cf-timeout');
|
|
const maxTokensEl = document.getElementById('cf-max-tokens');
|
|
if (cf) {
|
|
baseUrlEl.value = cf.base_url || '';
|
|
modelEl.value = cf.model || '';
|
|
apiKeyEl.value = cf.api_key_env || '';
|
|
timeoutEl.value = cf.timeout_seconds != null ? cf.timeout_seconds : '';
|
|
maxTokensEl.value = cf.max_output_tokens != null ? cf.max_output_tokens : '';
|
|
badge.textContent = 'configured';
|
|
badge.className = 'badge bg-success';
|
|
_cloudFallbackConfigured = true;
|
|
} else {
|
|
baseUrlEl.value = '';
|
|
modelEl.value = '';
|
|
apiKeyEl.value = '';
|
|
timeoutEl.value = '';
|
|
maxTokensEl.value = '';
|
|
badge.textContent = 'not configured';
|
|
badge.className = 'badge bg-secondary';
|
|
_cloudFallbackConfigured = false;
|
|
}
|
|
}
|
|
|
|
function collectCloudFallbackBody() {
|
|
const baseUrl = document.getElementById('cf-base-url').value.trim();
|
|
const model = document.getElementById('cf-model').value.trim();
|
|
// Send null, not {base_url: "", model: ""}, when either is blank --
|
|
// an object with keys is truthy, so the backend would not catch an
|
|
// empty-but-present block (same lesson as cloud_primary).
|
|
if (!baseUrl || !model) return null;
|
|
const body = { base_url: baseUrl, model };
|
|
const apiKeyEnv = document.getElementById('cf-api-key-env').value.trim();
|
|
if (apiKeyEnv) body.api_key_env = apiKeyEnv;
|
|
const timeout = document.getElementById('cf-timeout').value;
|
|
if (timeout !== '') body.timeout_seconds = parseInt(timeout, 10);
|
|
const maxTokens = document.getElementById('cf-max-tokens').value;
|
|
if (maxTokens !== '') body.max_output_tokens = parseInt(maxTokens, 10);
|
|
return body;
|
|
}
|
|
|
|
async function saveCloudFallback() {
|
|
showCloudFallbackError('');
|
|
const statusEl = document.getElementById('cloud-fallback-status');
|
|
try {
|
|
const result = await apiFetchDetail(`${API}api/cloud-fallback-config`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ cloud_fallback: collectCloudFallbackBody() }),
|
|
});
|
|
statusEl.textContent = result.message || 'saved';
|
|
statusEl.style.color = 'var(--tblr-success)';
|
|
toast('Cloud fallback saved', 'success');
|
|
await loadCloudFallback();
|
|
} catch (e) {
|
|
showCloudFallbackError(e.message || 'Save failed');
|
|
statusEl.textContent = '';
|
|
}
|
|
}
|
|
|
|
function showCloudFallbackError(message) {
|
|
const box = document.getElementById('cloud-fallback-error');
|
|
if (!message) { box.style.display = 'none'; box.textContent = ''; return; }
|
|
box.textContent = message;
|
|
box.style.display = 'block';
|
|
}
|
|
|
|
function clearCloudFallback() {
|
|
document.getElementById('cf-base-url').value = '';
|
|
document.getElementById('cf-model').value = '';
|
|
document.getElementById('cf-api-key-env').value = '';
|
|
document.getElementById('cf-timeout').value = '';
|
|
document.getElementById('cf-max-tokens').value = '';
|
|
saveCloudFallback();
|
|
}
|
|
|
|
|
|
|
|
/* ═══════════════════════════════════════
|
|
DATA FETCHING
|
|
═══════════════════════════════════════ */
|
|
|
|
async function loadControls() {
|
|
const runtime = await apiFetch(`${API}api/runtime`);
|
|
if (runtime) {
|
|
renderRuntime(runtime);
|
|
if (runtime[LOCAL_COMPUTE_KNOB]) {
|
|
renderLocalCompute(runtime[LOCAL_COMPUTE_KNOB].runtime === true);
|
|
}
|
|
}
|
|
if (!_profilesCache) {
|
|
const profiles = await apiFetch(`${API}api/profiles`);
|
|
if (profiles) _profilesCache = profiles;
|
|
}
|
|
const config = await apiFetch(`${API}api/config`);
|
|
if (config) renderConfig(config);
|
|
await loadClassifierConfig();
|
|
await loadCloudFallback();
|
|
}
|
|
|
|
/* ═══════════════════════════════════════
|
|
UTILITIES
|
|
═══════════════════════════════════════ */
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
/* ═══════════════════════════════════════
|
|
INIT
|
|
═══════════════════════════════════════ */
|
|
|
|
function init() {
|
|
renderStaticIcons();
|
|
// Delegated so it survives every re-render of the list.
|
|
const configList = document.getElementById('config-list');
|
|
configList.addEventListener('input', markDirty);
|
|
configList.addEventListener('change', (e) => {
|
|
markDirty();
|
|
if (e.target && e.target.dataset && e.target.dataset.profileSelect != null) {
|
|
updateDefaultProfileHint();
|
|
}
|
|
});
|
|
loadControls();
|
|
connectSSE();
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|