Files
Varaverk/Plugin/unraid/pages/ai.php
T

1099 lines
60 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// AI tab. Asks Varaverk about itself — a grounded chat over the documentation index, with a
// status banner covering index health, model residency and GPU state.
//
// OPERATIONAL MODEL
// Token and poll, not streaming. A turn takes 25-76 seconds, so the composer POSTs to
// api/ai.php, receives a token, and polls until the job reaches done or error. That keeps
// the api layer on one response convention; see api/ai.php for why SSE was declined.
//
// The tab is only reachable on HOST1, and only when AI_ENABLED is true. Varaverk.page omits
// it from the tab list and rejects it server-side, and api/ai.php refuses every action on
// the same two conditions independently — hiding a link is not access control. HOST1 is the
// node with the GPU, the Ollama process and the index; include/ai.php reads only the local
// {HOST}_OLLAMA_URL, so the tab could not function anywhere else regardless.
//
// DESIGN PRINCIPLES
// The banner leads with offload, not with size.
// 41/41 layers at 100% GPU is the difference between 74 tok/s and 19 on this card, and
// nothing else in the WebGUI surfaces it. Index size is interesting; residency is
// actionable.
//
// Staleness is stated, not implied.
// An index older than the newest tracked file will answer confidently from code that has
// since changed — the one failure a grounded answer cannot reveal on its own.
//
// Sources are the point, not a footnote.
// Every answer lists what it was built from, with scores, and each source opens the file
// it came from. Retrieval you can audit is the reason to build this here rather than use
// a general chat client.
//
// Reasoning is kept and collapsed.
// qwen3 emits substantial thinking, often more useful than the answer for a judgement
// call. Hidden by default so it does not bury the answer; one click away because
// discarding it would lose the best part.
//
// OPERATIONAL SAFEGUARDS
// Every rendered string is escaped before it reaches the DOM.
// Answers, reasoning, source paths and headings are all model or file derived. The
// minimal markdown pass runs strictly after escaping, so no input can introduce markup.
//
// Conversation history is bounded client-side and again server-side.
// The page sends the last few turns; api/ai.php caps them regardless. The model is only
// fully offloaded at 16384 context, and unbounded history would cross that silently.
//
// Polling stops on a terminal state, on error, and on a wall-clock ceiling.
// A worker that dies without writing would otherwise be polled forever.
//
// The conversation is read-only with respect to the system. The findings card is not.
// Asking questions changes nothing, and every profile reachable from the composer holds
// zero capabilities. The one control on this page that changes Varaverk state is Fix on a
// repair finding, which writes a single conf key through vv_conf_edit()'s guarded path —
// lock, backup, syntax check, read-back, rollback. It is confirmed first, it names the
// file, key and both values before it is clicked, and it is the only way a toggle is ever
// written by this subsystem, because reaching it means the operator asked for it by name.
//
// The page never decides what a finding may do.
// Buttons are rendered from the actions api/ai.php returned for that row, and the endpoint
// checks the same list again before acting. A tab left open overnight holds buttons the
// store has moved past, so the row the operator sees is not the authority on what is legal.
//
// RENDERS
// Status banner (index, model residency, GPU, staleness), repair findings with their actions,
// assistant-filed bug reports, chat transcript with collapsible reasoning and audited sources,
// composer with retrieval-scope and reasoning controls, source viewer overlay
//
// DEPENDS ON
// include/ai_chat.php the shared conversation surface, also used by the Monitor tab's AI row
// api/ai.php stats / tokens / bugs / findings / finding_action / ask / poll / chats
// api/readscript.php source viewer contents
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once dirname(__DIR__) . '/include/ai_chat.php';
// Build stamp. The tab bar uses Unraid's localURL, which swaps content by AJAX without tearing
// down the previous page's JavaScript — so a stale copy of this script can keep running, and
// its timers keep firing, while a new copy is injected. That makes "is the browser executing
// the code I just deployed" unanswerable from the server alone. Rendered into the DOM and
// logged on every render so both sides can be compared.
$_vv_ai_build = substr(md5_file(__FILE__), 0, 8);
if (is_dir('/var/log/varaverk')) {
@file_put_contents('/var/log/varaverk/ai.log',
date('Y-m-d H:i:s') . ' RENDER page build=' . $_vv_ai_build . "\n", FILE_APPEND | LOCK_EX);
}
?>
<style>
#vv-ai-wrap { display:flex; flex-direction:column; gap:12px; }
/* ── Banner ─────────────────────────────────────────────────────────────── */
.vv-ai-banner { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:1px;
background:#1a1a1a; border:1px solid #262626; border-radius:6px; overflow:hidden; }
.vv-ai-stat { background:#0e0e0e; padding:10px 12px; display:flex; flex-direction:column; gap:3px; }
.vv-ai-stat-l { font-size:9px; letter-spacing:.08em; text-transform:uppercase; color:#4a4a4a; }
.vv-ai-stat-v { font-size:15px; font-weight:bold; color:#c8c8c8; font-family:monospace; }
.vv-ai-stat-s { font-size:10px; color:#5a5a5a; }
/* Profiles, transcript, composer and the source overlay are styled by include/ai_chat.php,
which the Monitor tab's AI row shares. Only what is unique to this tab lives below. */
/* ── Health + loaded models ─────────────────────────────────────────────── */
/* The split lines the System checks card up with the right edge of the third banner stat.
Both rows carry a 1px border and a 1px gap, so their track origins coincide and the
fractions match exactly. The default suits the usual five stats; renderBanner() overrides
--vv-diag-split when the banner renders a different number. */
.vv-ai-diag { display:grid; grid-template-columns:var(--vv-diag-split,3fr 2fr); gap:1px; background:#1a1a1a;
border:1px solid #262626; border-radius:6px; overflow:hidden; }
@media (max-width:900px) { .vv-ai-diag { grid-template-columns:1fr; } }
.vv-ai-diag-col { background:#0e0e0e; padding:9px 12px; }
.vv-ai-diag-h { font-size:9px; letter-spacing:.08em; text-transform:uppercase; color:#4a4a4a;
margin-bottom:6px; display:flex; align-items:center; gap:7px; }
/* The checks fill top to bottom, up to three columns. Multi-column, not grid: grid fills
row-major, which would put consecutive checks side by side and scatter the reading order
across the split. `columns: 220px 3` is a ceiling, not a count — the browser uses as many
columns as fit at 240px and drops to two, then one, as the card narrows, so neither the
900px collapse above nor the diag split needs a matching breakpoint.
240px is picked against both ends, not by eye. Above: at 220 the third column arrives around
1160px of content, which is a laptop, and the model tag then wraps in every column — 240
holds it back to ~1300. Below: 900px is where the diag un-stacks and this card drops from
full width to 3/5, its narrowest point at ~516px; 240 still fits two there, where 250 falls
to one and leaves a dead band just above the breakpoint. */
#vv-ai-health { columns:240px 3; column-gap:18px; }
.vv-ai-chk { display:flex; align-items:flex-start; gap:7px; font-size:11px; padding:2px 0; line-height:1.5;
break-inside:avoid; }
.vv-ai-chk-i { flex-shrink:0; font-size:11px; width:12px; }
.vv-ai-chk-l { color:#8a8a8a; flex-shrink:0; }
.vv-ai-chk-d { color:#5a5a5a; }
.vv-ai-chk-f { color:#8a6a3a; display:block; font-size:10px; margin-top:1px; }
.vv-ai-mdl { display:flex; align-items:center; gap:7px; font-size:11px; padding:2px 0; }
.vv-ai-mdl-n { color:#8a8a8a; font-family:monospace; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.vv-ai-mdl-m { color:#444; font-family:monospace; margin-left:auto; flex-shrink:0; font-size:10px; }
.vv-ai-none { font-size:11px; color:#3a3a3a; font-style:italic; }
.vv-ai-buildline { font-size:10px; color:#3a3a3a; font-family:monospace; margin:6px 2px 0; }
/* ── Token accounting ───────────────────────────────────────────────────── */
/* Splits on the second banner stat rather than the third, so the host window stays narrow and
the tiles get the room — still a line the banner already draws, so the rows read as a grid
rather than three unrelated cards. renderBanner() keeps --vv-tok-split in step. */
.vv-ai-tok { display:grid; grid-template-columns:var(--vv-tok-split,2fr 3fr); gap:1px; background:#1a1a1a;
border:1px solid #262626; border-radius:6px; overflow:hidden; }
@media (max-width:900px) { .vv-ai-tok { grid-template-columns:1fr; } }
/* The rows and tiles this panel is built from live in css/varaverk.css, not here. The Monitor
tab renders the same ledger in a card of its own, and a style block on this page is only
loaded when this page is — which is why they had to leave. What stays above is this panel's
own chrome: the two-column split and the border around it, which the Monitor card does not
share because it is a .vv-card and already has one.
.vv-ai-hostrow* host rows, including the active state the scope click sets
.vv-ai-tok-* the Today / 7-day / All-time tiles and the footer line */
/* ── Assistant-filed bug reports ─────────────────────────────────────────── */
.vv-ai-bug { border-left:2px solid #5a3a2a; background:#140f0c; border-radius:0 3px 3px 0;
padding:8px 10px; margin-bottom:8px; }
.vv-ai-bug-h { display:flex; align-items:center; gap:9px; margin-bottom:4px; }
.vv-ai-bug-c { font-family:monospace; font-size:11px; color:#c8a87a; }
.vv-ai-bug-m { font-size:10px; color:#4a4a4a; font-family:monospace; margin-left:auto; }
.vv-ai-bug-s { font-size:12px; color:#b8b8b8; line-height:1.5; margin-bottom:5px; }
.vv-ai-bug-e { margin:0; padding:6px 8px; background:#0b0b0b; border:1px solid #1e1e1e;
border-radius:3px; font-size:10px; line-height:1.5; color:#8a8a8a;
white-space:pre-wrap; overflow-x:auto; max-height:110px; overflow-y:auto; }
.vv-ai-bug-q { font-size:10px; color:#4a4a4a; margin-top:5px; font-style:italic; }
/* ── Repair findings ────────────────────────────────────────────────────── */
/* Deliberately not styled like the bug reports above. A bug report is a note to send someone
else; a finding is a decision waiting on the operator, and the row carries buttons that write
conf. The left border colours by severity so a page of them is scannable without reading. */
.vv-ai-fnd { border-left:2px solid #3a3a3a; background:#0d0d0d; border-radius:0 3px 3px 0;
padding:8px 10px; margin-bottom:8px; }
.vv-ai-fnd.sev-error { border-left-color:#7a3040; background:#140c0e; }
.vv-ai-fnd.sev-warn { border-left-color:#6a5228; background:#130f0a; }
.vv-ai-fnd.closed { opacity:.55; }
.vv-ai-fnd-h { display:flex; align-items:center; gap:9px; margin-bottom:4px; flex-wrap:wrap; }
.vv-ai-fnd-s { font-family:monospace; font-size:11px; color:#c8c8c8; }
.vv-ai-fnd-k { font-size:9px; letter-spacing:.06em; text-transform:uppercase; color:#4a4a4a;
border:1px solid #262626; border-radius:3px; padding:1px 5px; }
.vv-ai-fnd-m { font-size:10px; color:#4a4a4a; font-family:monospace; margin-left:auto; }
.vv-ai-fnd-r { font-size:12px; color:#b8b8b8; line-height:1.5; margin-bottom:5px; }
.vv-ai-fnd-e { margin:0; padding:6px 8px; background:#0b0b0b; border:1px solid #1e1e1e;
border-radius:3px; font-size:10px; line-height:1.5; color:#8a8a8a;
white-space:pre-wrap; overflow-x:auto; max-height:110px; overflow-y:auto; }
/* The proposed write, spelled out in full before anything is clicked. This is the one line that
says what Fix will actually do to conf, so it is not abbreviated and not hidden. */
.vv-ai-fnd-w { font-size:11px; font-family:monospace; color:#8a8a8a; margin-top:6px;
padding:5px 8px; background:#0b0b0b; border:1px solid #1e1e1e; border-radius:3px; }
.vv-ai-fnd-w b { color:#c8c8c8; font-weight:normal; }
.vv-ai-fnd-w .arrow { color:#4a4a4a; }
.vv-ai-fnd-n { font-size:10px; color:#5a5a5a; margin-top:5px; font-style:italic; }
.vv-ai-fnd-a { display:flex; gap:7px; margin-top:7px; align-items:center; flex-wrap:wrap; }
/* Each button explains itself on hover from the server's own text, so the page never has to
restate what an action means and cannot restate it differently. */
.vv-ai-fnd-a .vv-ai-btn { padding:3px 11px; font-size:11px; }
/* Waiting for its second press. Confirmation is in the page rather than a browser dialog —
confirm() can be switched off from inside itself, after which it answers no forever without
drawing anything, and the button reads as dead. This state has to be loud enough that a
second press is obviously a second press and not a first one that failed. */
.vv-ai-fnd-a .vv-ai-btn.armed { background:#3a2410; border-color:#8a6a3a; color:#ffb74d; }
.vv-ai-fnd-a .vv-ai-btn.armed:hover:not(:disabled) { background:#4a2e14; }
.vv-ai-fnd-msg { font-size:10px; color:#5a5a5a; margin-left:4px; }
/* ── Settings card ──────────────────────────────────────────────────────── */
/* Collapsed by default and by markup, not by JS: the card is closed because the class is
simply absent, so it cannot flash open on a slow load or stick open if a script throws.
Same toggle idiom as the reasoning block in the transcript. */
.vv-ai-set { border:1px solid #262626; border-radius:6px; background:#0e0e0e; }
.vv-ai-set-t { display:flex; align-items:center; gap:8px; padding:7px 11px; cursor:pointer;
user-select:none; font-size:9px; letter-spacing:.08em; text-transform:uppercase;
color:#4a4a4a; }
.vv-ai-set-t:hover { color:#777; }
.vv-ai-set-c { color:#333; font-size:9px; transition:transform .12s; }
.vv-ai-set-t.open .vv-ai-set-c { transform:rotate(90deg); }
.vv-ai-set-sum { margin-left:auto; color:#3a3a3a; text-transform:none; letter-spacing:0;
font-size:10px; font-family:monospace; }
.vv-ai-set-b { display:none; padding:2px 11px 11px; }
.vv-ai-set-b.open { display:block; }
.vv-ai-set-r { display:flex; align-items:flex-start; gap:10px; padding:7px 0;
border-top:1px solid #1a1a1a; }
.vv-ai-set-r:first-child { border-top:none; }
.vv-ai-set-l { font-size:11px; color:#8a8a8a; min-width:120px; flex-shrink:0; }
.vv-ai-set-d { font-size:10px; color:#4a4a4a; line-height:1.5; }
</style>
<?php vv_ai_chat_assets(); ?>
<div id="vv-ai-wrap">
<div class="vv-ai-banner" id="vv-ai-banner"></div>
<div class="vv-ai-tok">
<div class="vv-ai-diag-col">
<div class="vv-ai-diag-h">Hosts</div>
<div id="vv-ai-tok-hosts"></div>
</div>
<div class="vv-ai-diag-col">
<div class="vv-ai-diag-h">Token usage — <span id="vv-ai-tok-scope">all hosts</span></div>
<div id="vv-ai-tok-stats"></div>
</div>
</div>
<!-- What the repair sweep found. Above the bug reports because these are decisions waiting on
the operator rather than notes to file somewhere, and because until this card existed the
only way to see a finding was to run the sweep tool with --status over SSH.
Unlike the bugs card it does not hide itself when empty. "Nothing found, last swept 6
minutes ago" is the single most useful thing this card ever says, and a card that vanishes
on good news cannot say it — it just leaves a gap that reads as broken. -->
<div class="vv-ai-tok" id="vv-ai-fnd-wrap">
<div class="vv-ai-diag-col" style="grid-column:1/-1">
<div class="vv-ai-diag-h">
<span id="vv-ai-fnd-sum"></span> Self-repair findings
<span class="vv-ai-set-sum" style="margin-left:auto" id="vv-ai-fnd-gate"></span>
<button class="vv-ai-btn ghost" id="vv-ai-fnd-all" type="button"
style="padding:2px 9px;font-size:10px;text-transform:none;letter-spacing:0">Show closed</button>
</div>
<div id="vv-ai-fnd"></div>
</div>
</div>
<!-- Proposed memory. Sits beside findings deliberately: both are the assistant asking for a
decision rather than reporting a fact, and both are worthless if the answer is a click the
operator never sees. Shown even when empty for the same reason the findings card is — the
gate line is the news when there is nothing else. -->
<div class="vv-ai-tok" id="vv-ai-mem-wrap">
<div class="vv-ai-diag-col" style="grid-column:1/-1">
<div class="vv-ai-diag-h">
<span id="vv-ai-mem-sum"></span> Proposed memory
<span class="vv-ai-set-sum" style="margin-left:auto" id="vv-ai-mem-gate"></span>
<button class="vv-ai-btn ghost" id="vv-ai-mem-all" type="button"
style="padding:2px 9px;font-size:10px;text-transform:none;letter-spacing:0">Show decided</button>
</div>
<div id="vv-ai-mem-list"></div>
</div>
</div>
<!-- Bug reports the scheduler's troubleshooter filed. Hidden entirely when there are none:
an empty card here would be a permanent reminder of nothing, and this row already
competes for the space above the transcript. -->
<div class="vv-ai-tok" id="vv-ai-bugs-wrap" style="display:none">
<div class="vv-ai-diag-col" style="grid-column:1/-1">
<div class="vv-ai-diag-h">
<span id="vv-ai-bugs-sum"></span> Reported by the assistant
<span class="vv-ai-set-sum" style="margin-left:auto">filed from the Scheduler troubleshooter</span>
</div>
<div id="vv-ai-bugs"></div>
<!-- Manual-copy fallback for plain-http WebGUIs, where the clipboard API is unavailable. -->
<textarea id="vv-ai-bug-copybox" class="vv-ai-input" rows="8" readonly spellcheck="false"
style="display:none;margin-top:8px" onclick="this.select()"></textarea>
</div>
</div>
<div class="vv-ai-diag">
<div class="vv-ai-diag-col">
<div class="vv-ai-diag-h"><span id="vv-ai-health-sum"></span> System checks</div>
<div id="vv-ai-health"></div>
</div>
<div class="vv-ai-diag-col">
<div class="vv-ai-diag-h">Loaded models</div>
<div id="vv-ai-models"></div>
</div>
</div>
<!-- Saved conversations. Above the transcript rather than beside it: this column is already
narrow at the width the diag row wants, and a sidebar here would take that width from the
thing being read. The same rows render on the Monitor tab off the same store. -->
<div class="vv-ai-tok">
<div class="vv-ai-diag-col" style="grid-column:1/-1">
<div class="vv-ai-chead">
<span class="vv-ai-diag-h" style="margin:0">Saved conversations</span>
<span class="vv-ai-set-sum" style="margin-left:auto">newest first · oldest drop off automatically</span>
</div>
<?php vv_ai_chat_list_markup('vv-ai'); ?>
</div>
</div>
<?php
// Heights are this tab's to choose; the component's shape is not. This is the surface with the
// most room, so it rests taller than the Monitor card and expands to most of the viewport.
vv_ai_chat_markup('vv-ai', [
'profile' => 'varaverk',
// All three stated rather than derived. Large is at the viewport cap here, and leaving medium
// to be worked out from the resting height would land it on that same cap — two settings
// doing the same thing.
'height' => '52vh',
'tall' => '68vh',
'tallLarge' => '85vh',
'empty' => "Ask Varaverk about itself. Answers come only from this installation's own "
. 'documentation, with sources.',
'placeholder' => 'e.g. what stops rsync and the mover running at once?',
'controls' => '<button class="vv-ai-btn ghost" id="vv-ai-mem" type="button">Memory</button>',
]);
?>
<!-- The build stamp and the liveness marker stay on this tab, below the composer rather than in
it. Unraid swaps tab content by AJAX without tearing down the previous page's JavaScript, so
"is the browser running the code I just deployed" is not answerable from the server — this
marker answers it from the browser, and it is where every deploy on this tab gets checked.
It sits here because the control row above is for controls: it was the one piece of prose in
a line of buttons, and it is read deliberately rather than glanced at. -->
<div class="vv-ai-buildline">build <?= $_vv_ai_build ?><span id="vv-ai-live" style="color:#e57"> · JS NOT RUNNING</span></div>
<!-- Settings. Below the composer and closed by default: these change how the next answer is
built, not what it is asked, so they should be reachable without being in the way. The
header carries the current values so the state is legible while collapsed. -->
<div class="vv-ai-set">
<div class="vv-ai-set-t" id="vv-ai-set-t">
<span class="vv-ai-set-c">▶</span> Settings
<span class="vv-ai-set-sum" id="vv-ai-set-sum"></span>
</div>
<div class="vv-ai-set-b" id="vv-ai-set-b">
<div class="vv-ai-set-r">
<span class="vv-ai-set-l">Retrieval source</span>
<div>
<select id="vv-ai-kind" title="Restrict retrieval to one kind of source">
<option value="">All sources</option>
<option value="ui">WebUI — how the pages work</option>
<option value="readme">README — what things are</option>
<option value="manual">Manual — how to do things</option>
<option value="header">Script headers</option>
<option value="template">Conf templates</option>
<option value="doc">Design notes</option>
</select>
<div class="vv-ai-set-d">Intent routing favours per-script headers, which buries the
top-level prose. Pick README for "what is X" and "why does this exist" questions.
Varaverk Assistant only — the other profiles do not retrieve.</div>
</div>
</div>
<!-- Shown only for profiles that hold web_search, which today means General Chat alone.
Hidden rather than disabled for the others: a control that is visible and does nothing
reads as broken, and the reason it does nothing is a capability boundary that takes a
paragraph to explain. -->
<div class="vv-ai-set-r" id="vv-ai-web-row" style="display:none">
<span class="vv-ai-set-l">Web search</span>
<div>
<label class="vv-ai-toggle"><input type="checkbox" id="vv-ai-web"> search the web for
this question</label>
<div class="vv-ai-set-d" id="vv-ai-web-state">General Chat only — it is the one profile
that cannot change anything, which is why it is the one allowed to look outside. Your
question is sent to the configured search provider.</div>
</div>
</div>
<div class="vv-ai-set-r">
<span class="vv-ai-set-l">Reasoning</span>
<div>
<label class="vv-ai-toggle"><input type="checkbox" id="vv-ai-think" checked> show the
model's thinking</label>
<div class="vv-ai-set-d">Kept and collapsed under each answer. Thinking time scales
with the question — seconds for a greeting, ~18s for a substantive one.</div>
</div>
</div>
</div>
</div>
</div>
<div id="vv-ai-memwrap" style="display:none;border:1px solid #262626;border-radius:6px;
background:#0e0e0e;padding:10px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<span style="font-size:9px;letter-spacing:.08em;text-transform:uppercase;color:#4a4a4a;">
Standing memory — given to the assistant at the start of every conversation</span>
<span id="vv-ai-mem-count" style="margin-left:auto;font-size:10px;color:#3a3a3a;font-family:monospace;"></span>
</div>
<textarea id="vv-ai-mem-text" class="vv-ai-input" rows="10" spellcheck="false"
placeholder="Who you are, how this install is set up, decisions already made, things it should stop asking.&#10;&#10;e.g.&#10;- HOST2 (unRAID-Jayred36) is being rebuilt and is offline. Do not suggest syncing to it.&#10;- RSYNC_ENABLED is deliberately false until HOST2 is onboarded.&#10;- I verify everything before trusting it. Show your sources."></textarea>
<div style="display:flex;gap:8px;align-items:center;margin-top:7px;">
<span id="vv-ai-mem-status" style="font-size:10px;color:#4a4a4a;"></span>
<button class="vv-ai-btn ghost" style="margin-left:auto" id="vv-ai-mem-cancel" type="button">Close</button>
<button class="vv-ai-btn" id="vv-ai-mem-save" type="button">Save</button>
</div>
</div>
<script>
(function () {
const API = '/plugins/varaverk/api/ai.php';
// The conversation itself — profiles, transcript, composer, source viewer, storage — is
// include/ai_chat.php. What remains on this page is everything that surrounds it and exists
// only here: the banner, the token ledger, filed bug reports and the memory editor.
let chat = null, chatList = null;
const $ = id => document.getElementById(id);
const esc = s => String(s == null ? '' : s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
// ── Banner ──────────────────────────────────────────────────────────────
function stat(label, value, sub, cls) {
return `<div class="vv-ai-stat"><div class="vv-ai-stat-l">${esc(label)}</div>`
+ `<div class="vv-ai-stat-v ${cls||''}">${esc(value)}</div>`
+ `<div class="vv-ai-stat-s">${esc(sub||'')}</div></div>`;
}
function ago(ts) {
if (!ts) return '—';
const d = Math.floor(Date.now()/1000) - ts;
if (d < 60) return 'just now';
if (d < 3600) return Math.floor(d/60)+'m ago';
if (d < 86400) return Math.floor(d/3600)+'h ago';
return Math.floor(d/86400)+'d ago';
}
function renderBanner(s) {
const ix = s.index || {}, rt = s.runtime || {};
let html = '';
// Residency first — it is the number that silently costs 4x throughput.
if (!rt.reachable) {
html += stat('Model', 'unreachable', s.url || '', 'vv-ai-bad');
} else if (!rt.loaded) {
html += stat('Model', 'not loaded', 'loads on first question', 'vv-ai-warn');
} else {
const p = rt.offload_pct;
html += stat('GPU offload', p === null ? '—' : p + '%',
p === 100 ? 'fully resident' : 'layers on CPU — slow',
p === 100 ? 'vv-ai-ok' : 'vv-ai-warn');
}
html += stat('Context', rt.context ? rt.context.toLocaleString() : '—',
(s.model || '').replace(/^hf\.co\/[^/]+\//,''));
if (!ix.exists) {
html += stat('Index', 'not built', 'run AI/ai_index.sh', 'vv-ai-bad');
} else {
html += stat('Index', ix.chunks.toLocaleString() + ' chunks',
ix.files + ' files · ' + (ix.size/1048576).toFixed(1) + ' MB');
html += stat('Built', ago(ix.built),
ix.stale ? 'source newer — reindex' : 'current',
ix.stale ? 'vv-ai-warn' : 'vv-ai-ok');
}
if (rt.gpu) {
html += stat('VRAM', (rt.gpu.mem_used/1024).toFixed(1) + '/' + (rt.gpu.mem_total/1024).toFixed(1) + ' GB',
rt.gpu.name + ' · ' + rt.gpu.util + '% util');
}
$('vv-ai-banner').innerHTML = html;
// Keep the diag row's split on the third stat's edge. The stat count is not fixed — VRAM
// needs a readable GPU, Built needs an index, and an unreachable Ollama collapses the first
// two into one — so it is read off what actually rendered rather than assumed.
//
// Written as a custom property, never as grid-template-columns: an inline value would
// outrank the 900px media query that collapses this row to a single column. Below four
// stats there is no third edge to meet, so it falls back to the default.
const diag = document.querySelector('.vv-ai-diag');
const nStat = $('vv-ai-banner').children.length;
if (diag) diag.style.setProperty('--vv-diag-split',
nStat > 3 ? `3fr ${nStat - 3}fr` : '3fr 2fr');
const tok = document.querySelector('.vv-ai-tok');
if (tok) tok.style.setProperty('--vv-tok-split',
nStat > 2 ? `2fr ${nStat - 2}fr` : '2fr 3fr');
renderHealth(s.health || []);
renderModels(s.loaded, s.model);
}
// ✓ / ! / ✗ per check. Remedy shown inline on anything not ok — the point is to name the
// setting that is wrong, not to report that something failed.
const ICON = { ok: ['✓','vv-ai-ok'], warn: ['!','vv-ai-warn'], bad: ['✗','vv-ai-bad'] };
function renderHealth(checks) {
if (!checks.length) { $('vv-ai-health').innerHTML = '<div class="vv-ai-none">no checks</div>'; return; }
let bad = 0, warn = 0, html = '';
checks.forEach(c => {
if (c.state === 'bad') bad++; else if (c.state === 'warn') warn++;
const [ic, cl] = ICON[c.state] || ICON.warn;
html += `<div class="vv-ai-chk"><span class="vv-ai-chk-i ${cl}">${ic}</span>`
+ `<span><span class="vv-ai-chk-l">${esc(c.label)}</span> `
+ `<span class="vv-ai-chk-d">— ${esc(c.detail)}</span>`
+ (c.fix ? `<span class="vv-ai-chk-f">→ ${esc(c.fix)}</span>` : '')
+ `</span></div>`;
});
$('vv-ai-health').innerHTML = html;
const sum = $('vv-ai-health-sum');
if (bad) sum.innerHTML = `<span class="vv-ai-bad">✗ ${bad} problem${bad>1?'s':''}</span>`;
else if (warn) sum.innerHTML = `<span class="vv-ai-warn">! ${warn} warning${warn>1?'s':''}</span>`;
else sum.innerHTML = `<span class="vv-ai-ok">✓ all good</span>`;
}
function renderModels(loaded, active) {
const box = $('vv-ai-models');
if (loaded === null || loaded === undefined) {
box.innerHTML = '<div class="vv-ai-none">Ollama unreachable</div>'; return;
}
if (!loaded.length) {
box.innerHTML = '<div class="vv-ai-none">none resident — loads on first use</div>'; return;
}
let html = '';
loaded.forEach(m => {
const full = m.offload === 100;
const [ic, cl] = full ? ICON.ok : ICON.warn;
html += `<div class="vv-ai-mdl"><span class="vv-ai-chk-i ${cl}">${ic}</span>`
+ `<span class="vv-ai-mdl-n"${m.name===active?' style="color:#a8c8a8"':''}>`
+ esc(m.name.replace(/^hf\.co\/[^/]+\//,'')) + `</span>`
+ `<span class="vv-ai-mdl-m">${m.offload===null?'—':m.offload+'% GPU'}`
+ `${m.context?' · '+m.context.toLocaleString()+' ctx':''} · ${(m.vram/1073741824).toFixed(1)}GB</span></div>`;
});
box.innerHTML = html;
}
// The 30-second tick reads the shared cache; a completed turn does not. Finishing a turn is
// precisely the event that changes what this banner reports — a cold model becomes resident,
// VRAM moves, context is now allocated — so re-rendering it from a payload written before the
// turn would show the operator the state they had just watched themselves leave.
function loadBanner(live) {
fetch(API + '?action=stats' + (live ? '&live=1' : '')).then(r => r.json())
.then(d => { if (d.ok) renderBanner(d.stats); })
.catch(() => {});
}
// ── Bug reports ─────────────────────────────────────────────────────────
// Read-only here plus dismissal. These are filed by the troubleshooter on the Scheduler tab;
// this card is where they are actually seen, since a chat window closes and a finding that
// only ever existed in one is a finding you do not have.
function loadBugs() {
fetch(API + '?action=bugs').then(r => r.json())
.then(d => { if (d.ok) renderBugs(d.bugs || []); })
.catch(() => {});
}
function renderBugs(bugs) {
const wrap = $('vv-ai-bugs-wrap');
vvBugs = bugs;
if (!bugs.length) { wrap.style.display = 'none'; return; }
wrap.style.display = '';
$('vv-ai-bugs-sum').innerHTML =
`<span class="vv-ai-warn">${bugs.length} open</span>`;
$('vv-ai-bugs').innerHTML = bugs.map(b => {
const when = ago(b.last);
// The seen count is the triage signal — once is a curiosity, forty times is a pattern.
const seen = b.seen > 1 ? ` · seen ${b.seen}×` : '';
return `<div class="vv-ai-bug">
<div class="vv-ai-bug-h">
<span class="vv-ai-bug-c">${esc(b.component)}</span>
<span class="vv-ai-bug-m">${esc(b.id)}${esc(seen)} · ${esc(when)}${
b.context && b.context.verified === null
? ' · <span class="vv-ai-warn">evidence unverified</span>' : ''}</span>
<button class="vv-ai-btn ghost" onclick="vvAiBugCopy('${esc(b.id)}')">Copy issue</button>
<button class="vv-ai-btn ghost" onclick="vvAiBugClose('${esc(b.id)}')">Dismiss</button>
</div>
<div class="vv-ai-bug-s">${esc(b.summary)}</div>
<pre class="vv-ai-bug-e">${esc(b.evidence)}</pre>
${b.context && b.context.asked
? `<div class="vv-ai-bug-q">asked: ${esc(b.context.asked)}</div>` : ''}
</div>`;
}).join('');
}
// Copy out, never transmit. The operator reads the text before it goes anywhere, which is the
// whole reason this is a button and not a webhook — evidence is quoted log lines, and Varaverk
// logs carry share names, container names and paths. Redaction by inspection beats redaction
// by rule, and it cannot be unpublished later.
let vvBugs = [];
function vvAiBugMarkdown(b) {
const d = t => t ? new Date(t * 1000).toISOString().slice(0, 10) : '—';
return `### ${b.component} — ${b.summary}\n\n`
+ `| | |\n|---|---|\n`
+ `| Component | \`${b.component}\` |\n`
+ `| Varaverk | \`${b.commit || 'unknown'}\` |\n`
+ `| Host slot | ${b.host || '—'} |\n`
+ `| Seen | ${b.seen}× · ${d(b.first)} → ${d(b.last)} |\n\n`
+ `**Evidence**\n\n\`\`\`\n${b.evidence}\n\`\`\`\n\n`
+ (b.context && b.context.asked ? `**Asked while diagnosing:** ${b.context.asked}\n\n` : '')
+ (b.context && b.context.log ? `**Log:** \`${b.context.log}\`\n\n` : '')
+ `_Filed automatically by the Varaverk assistant. Report ${b.id}._\n`;
}
window.vvAiBugCopy = function (id) {
const b = vvBugs.find(x => x.id === id);
if (!b) return;
const text = vvAiBugMarkdown(b);
const done = ok => {
const btn = event && event.target;
if (btn) { btn.textContent = ok ? 'Copied' : 'Select below'; setTimeout(() => btn.textContent = 'Copy issue', 1800); }
if (!ok) {
// The clipboard API needs a secure context and the WebGUI is often plain http on the
// LAN. Falling back to a selectable box means the button always does something useful
// rather than failing silently on exactly the setups this is built for.
const box = document.getElementById('vv-ai-bug-copybox');
box.style.display = ''; box.value = text; box.focus(); box.select();
}
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => done(true)).catch(() => done(false));
} else {
done(false);
}
};
window.vvAiBugClose = function (id) {
fetch(API, { method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({ action: 'bug_close', id, open: '0' }) })
.then(() => loadBugs()).catch(() => {});
};
// ── Repair findings ─────────────────────────────────────────────────────
// The sweep files these every 15 minutes and, until this card, the only way to read one was
// ai_repair_sweep.sh --status over SSH. Every button here is an answer the operator is giving
// to something the system noticed — including Fix, which writes conf through the guarded path.
let fndAll = false, fndRows = [];
// Labels are the page's; meanings are not. The title on each button is the server's own
// description of that action, so the wording an operator hovers is the same wording the chat
// uses for the same row.
const FND_LABEL = { fix: 'Fix', ack: 'I know', dismiss: 'Never a problem', reopen: 'Reopen' };
// Which actions take a second press, and what the button says while it is waiting for it.
// Fix writes conf; dismiss is the one state the sweep will never reopen on its own however
// many times the fault comes back. Ack and reopen are both reversible and go on one press.
const FND_ARM = { fix: 'Confirm write', dismiss: 'Confirm — permanent' };
let fndArmTimer = null;
// Put every armed button back, optionally sparing the one being pressed right now.
function fndDisarm(keep) {
clearTimeout(fndArmTimer);
$('vv-ai-fnd').querySelectorAll('button[data-armed="1"]').forEach(b => {
if (b === keep) return;
b.dataset.armed = '';
if (b.dataset.label) b.textContent = b.dataset.label;
b.classList.remove('armed');
// Cleared the way it was set. textContent would also empty it, but the arming message is
// written as innerHTML and a clear that does not match its write is the kind of pairing
// that quietly stops matching later.
const m = b.parentNode.querySelector('[data-msg]');
if (m) m.innerHTML = '';
});
}
function loadFindings() {
fetch(API + '?action=findings' + (fndAll ? '&all=1' : '')).then(r => r.json())
.then(d => { if (d.ok) renderFindings(d); })
.catch(() => {});
}
function renderFindings(d) {
fndRows = d.findings || [];
const rep = d.repair || {}, c = d.counts || {};
// Gate state is stated on the card rather than left to be inferred from an empty list. An
// empty list means "nothing found" when repair is on and "nothing is looking" when it is off,
// and those are opposite pieces of news.
const gate = [];
gate.push(rep.enabled ? (rep.autofix ? 'repair on · autofix on'
: 'repair on · detect only')
: 'repair off — nothing is looking');
if (rep.last) gate.push('swept ' + ago(rep.last));
$('vv-ai-fnd-gate').innerHTML = rep.enabled
? esc(gate.join(' · '))
: `<span class="vv-ai-warn">${esc(gate.join(' · '))}</span>`;
// needs_operator is counted separately from open because it is the one that is actually
// waiting on a person — an open finding may still be repaired by the next sweep.
const sum = $('vv-ai-fnd-sum');
if (c.needs_operator) sum.innerHTML = `<span class="vv-ai-warn">${c.needs_operator} need${c.needs_operator > 1 ? '' : 's'} you</span>`;
else if (c.open) sum.innerHTML = `<span class="vv-ai-warn">${c.open} open</span>`;
else sum.innerHTML = `<span class="vv-ai-ok">✓ nothing open</span>`;
if (!fndRows.length) {
$('vv-ai-fnd').innerHTML = fndAll
? '<div class="vv-ai-none">nothing filed yet</div>'
: '<div class="vv-ai-none">nothing open — the last sweep found no new faults</div>';
return;
}
$('vv-ai-fnd').innerHTML = fndRows.map(f => {
const closed = ['open', 'needs_operator'].indexOf(f.state) === -1;
const meta = [f.id, f.seen > 1 ? 'seen ' + f.seen + '×' : null, ago(f.last),
closed ? f.state : null].filter(Boolean).join(' · ');
// Buttons come from what the server offered for this row and nothing else. cancel is the
// exception and is dropped: "leave it alone for now" writes nothing by design, which in a
// page is spelled "do not click anything".
const acts = Object.keys(f.actions || {}).filter(a => a !== 'cancel').map(a =>
`<button class="vv-ai-btn${a === 'fix' ? '' : ' ghost'}" data-act="${esc(a)}" `
+ `data-id="${esc(f.id)}" title="${esc(f.actions[a])}">${esc(FND_LABEL[a] || a)}</button>`
).join('');
return `<div class="vv-ai-fnd sev-${esc(f.severity || 'warn')}${closed ? ' closed' : ''}">
<div class="vv-ai-fnd-h">
<span class="vv-ai-fnd-s">${esc(f.subject)}</span>
<span class="vv-ai-fnd-k" title="${esc(f.kind_label || '')}">${esc(f.kind)}</span>
<span class="vv-ai-fnd-m">${esc(meta)}</span>
</div>
<div class="vv-ai-fnd-r">${esc(f.ref)}</div>
<pre class="vv-ai-fnd-e">${esc(f.evidence)}</pre>
${f.proposed !== null && f.proposed !== undefined
? `<div class="vv-ai-fnd-w">${esc(f.conf_file)} · <b>${esc(f.conf_key)}</b> `
+ `${esc(f.observed || '(empty)')} <span class="arrow">→</span> <b>${esc(f.proposed)}</b>`
+ (f.proven ? '' : ' <span class="vv-ai-warn">· unproven</span>') + `</div>`
: ''}
${f.note ? `<div class="vv-ai-fnd-n">${esc(f.note)}</div>` : ''}
<div class="vv-ai-fnd-a">${acts}<span class="vv-ai-fnd-msg" data-msg="${esc(f.id)}"></span></div>
</div>`;
}).join('');
}
// Delegated, so the markup carries no inline handler and nothing has to be escaped into an
// attribute that runs as code.
$('vv-ai-fnd').addEventListener('click', e => {
const btn = e.target.closest('button[data-act]');
if (!btn) return;
const id = btn.dataset.id, act = btn.dataset.act;
const f = fndRows.find(x => x.id === id);
if (!f) return;
const msg = $('vv-ai-fnd').querySelector(`[data-msg="${id}"]`);
// Fix and dismiss are confirmed in the page, never with confirm(). Both browsers offer
// "prevent this page from creating additional dialogs" inside the dialog itself, and once
// that is ticked every later confirm() returns false without showing anything — so the
// buttons silently decline and read as dead. Unraid swaps tabs by AJAX without reloading,
// so the suppression outlives leaving the tab and only a full reload clears it. That is
// exactly what happened here: not a broken button, a dialog answering no on its own.
if (FND_ARM[act] && btn.dataset.armed !== '1') {
fndDisarm();
btn.dataset.armed = '1';
btn.dataset.label = btn.textContent;
btn.textContent = FND_ARM[act];
btn.classList.add('armed');
if (msg) msg.innerHTML = act === 'fix'
? `<span class="vv-ai-warn">Writes the change above to ${esc(f.conf_file)}`
+ `${f.proven ? '' : ', and nothing has probed it'}. Click again to confirm.</span>`
: `<span class="vv-ai-warn">Stays closed even when it is seen again. `
+ `Use “I know” to be told if it changes. Click again to confirm.</span>`;
// Arming expires on its own so a half-pressed button cannot sit there waiting to be
// completed by an unrelated click later.
fndArmTimer = setTimeout(fndDisarm, 8000);
return;
}
clearTimeout(fndArmTimer);
const btns = Array.from(btn.parentNode.querySelectorAll('button'));
btns.forEach(b => b.disabled = true);
if (msg) msg.textContent = 'working…';
// Reload on success, never on failure. A refused conf write is the case that must not
// disappear quietly: the guarded path rolled back, the finding is unchanged, and a reload
// would repaint the row identically half a second later and take the error with it. So the
// failed row keeps its message, and its buttons go back to reading Fix rather than sitting
// at "Confirm write" — a second press should have to arm again, not fire again.
const failed = text => {
btn.dataset.armed = '';
if (btn.dataset.label) btn.textContent = btn.dataset.label;
btn.classList.remove('armed');
btns.forEach(b => b.disabled = false);
if (msg) msg.innerHTML = `<span class="vv-ai-bad">${esc(text)}</span>`;
};
fetch(API, { method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({ action: 'finding_action', id, act }) })
.then(r => r.json())
.then(d => { if (d.ok) loadFindings(); else failed(d.error || 'failed'); })
.catch(e => failed(String(e)));
});
$('vv-ai-fnd-all').addEventListener('click', () => {
fndAll = !fndAll;
$('vv-ai-fnd-all').textContent = fndAll ? 'Open only' : 'Show closed';
loadFindings();
});
// ── Proposed memory ───────────────────────────────────────────────────────
// Accepting is the only route by which text the model wrote reaches a future prompt, so it is
// the one control here that has to be deliberate. Same two-press arming the findings card uses
// for a conf write, for the same reason.
let memAll = false, memRows = [];
function loadMemProposals() {
fetch(API + '?action=mem_proposals').then(r => r.json())
.then(d => { if (d.ok) renderMemProposals(d); })
.catch(() => {});
}
function renderMemProposals(d) {
memRows = memAll ? (d.recent || []) : (d.open || []);
const L = d.learned || {};
// The gate is the news when the list is empty. "Nothing proposed" and "nothing is proposing"
// look identical otherwise, and they are opposite states.
const gate = [];
gate.push(d.enabled ? (d.auto ? 'learning on · auto-accept on'
: 'learning on · proposals held for you')
: 'learning off — nothing is proposing');
if (L.max) gate.push('learned ' + (L.chars || 0) + '/' + L.max + ' chars');
$('vv-ai-mem-gate').innerHTML = (d.enabled && !d.auto)
? esc(gate.join(' · '))
: `<span class="vv-ai-warn">${esc(gate.join(' · '))}</span>`;
const openN = (d.open || []).length;
$('vv-ai-mem-sum').innerHTML = openN
? `<span class="vv-ai-warn">${openN} to review</span>`
: `<span class="vv-ai-ok">✓ none waiting</span>`;
if (!memRows.length) {
$('vv-ai-mem-list').innerHTML = memAll
? '<div class="vv-ai-none">nothing proposed yet</div>'
: '<div class="vv-ai-none">nothing waiting on you</div>';
return;
}
$('vv-ai-mem-list').innerHTML = memRows.map(r => {
const decided = r.state !== 'open';
const meta = [r.id, ago(r.created), r.profile || null,
decided ? r.state + (r.auto ? ' (auto)' : '') : null].filter(Boolean).join(' · ');
// The question is the provenance. A line read three weeks from now is judged by what was
// being asked when the model decided it was worth keeping.
const asked = r.asked ? `<div class="vv-ai-fnd-r">asked: ${esc(r.asked)}</div>` : '';
const acts = decided ? '' :
`<button class="vv-ai-btn" data-mem-act="accept" data-mem-id="${esc(r.id)}" `
+ `title="Add this to learned memory">Keep</button>`
+ `<button class="vv-ai-btn ghost" data-mem-act="dismiss" data-mem-id="${esc(r.id)}" `
+ `title="Never propose this again">Dismiss</button>`;
return `<div class="vv-ai-fnd${decided ? ' closed' : ''}">
<div class="vv-ai-fnd-h"><span class="vv-ai-fnd-m">${esc(meta)}</span></div>
<pre class="vv-ai-fnd-e">${esc(r.text)}</pre>
${asked}
<div class="vv-ai-fnd-a">${acts}<span data-msg></span></div>
</div>`;
}).join('');
}
$('vv-ai-mem-list').addEventListener('click', e => {
const btn = e.target.closest('[data-mem-act]');
if (!btn) return;
const act = btn.dataset.memAct, id = btn.dataset.memId;
const msg = btn.parentNode.querySelector('[data-msg]');
// Keep arms first. Dismiss does not: it writes nothing to any prompt and is undone by the
// model proposing something similar again, so a confirm there would be ceremony.
if (act === 'accept' && btn.dataset.armed !== '1') {
btn.dataset.armed = '1';
btn.dataset.label = btn.dataset.label || btn.textContent;
btn.textContent = 'Confirm keep';
btn.classList.add('armed');
if (msg) msg.textContent = 'this text joins every future prompt';
return;
}
const btns = Array.from(btn.parentNode.querySelectorAll('button'));
btns.forEach(b => b.disabled = true);
if (msg) msg.textContent = 'working…';
// Reload on success only — same rule as findings. A refused keep (learned memory full) is
// the case that must not vanish, and a reload would repaint the row and take the reason away.
const failed = text => {
btn.dataset.armed = '';
if (btn.dataset.label) btn.textContent = btn.dataset.label;
btn.classList.remove('armed');
btns.forEach(b => b.disabled = false);
if (msg) msg.innerHTML = `<span class="vv-ai-bad">${esc(text)}</span>`;
};
fetch(API, { method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({ action: 'mem_proposal_action', id, act }) })
.then(r => r.json())
.then(d => { if (d.ok) loadMemProposals(); else failed(d.error || 'failed'); })
.catch(e => failed(String(e)));
});
$('vv-ai-mem-all').addEventListener('click', () => {
memAll = !memAll;
$('vv-ai-mem-all').textContent = memAll ? 'Waiting only' : 'Show decided';
loadMemProposals();
});
// ── Token accounting ────────────────────────────────────────────────────
// Fetched on load and after each completed turn, never on the 30s banner tick: the totals
// only move when a turn finishes, and the page is the thing that knows when that was.
let tokData = null, tokScope = 'all';
const num = n => (n || 0).toLocaleString();
function loadTokens() {
fetch(API + '?action=tokens').then(r => r.json())
.then(d => { if (d.ok) { tokData = d.tokens; renderTokens(); } })
.catch(() => {});
}
function renderTokens() {
if (!tokData) return;
const hosts = tokData.hosts || {};
// A host with no rows reads "not collected here", never 0. Each host writes to its own
// data/ and nothing syncs it, so a zero would claim the partner did no work when the
// truth is that this host cannot see the partner's ledger at all.
let hh = `<div class="vv-ai-hostrow${tokScope === 'all' ? ' active' : ''}" data-scope="all">`
+ `<span class="vv-ai-hostrow-n">All hosts</span>`
+ `<span class="vv-ai-hostrow-v">${num(tokData.all.total)}</span></div>`;
Object.keys(hosts).forEach(id => {
const x = hosts[id];
// Three states, not two. "synced" is a partner whose ledger ai_token_sync.sh pulled;
// "not collected here" is a partner we have never seen a ledger for. Collapsing the
// second into a zero would report the partner as idle when the truth is we cannot see it.
const tag = x.self ? ' · this host'
: x.synced ? ' · synced ' + ago(x.synced)
: '';
hh += `<div class="vv-ai-hostrow${tokScope === id ? ' active' : ''}" data-scope="${esc(id)}">`
+ `<span class="vv-ai-hostrow-n">${esc(x.name)}</span>`
+ `<span class="vv-ai-hostrow-h">${esc(id)}${tag}</span>`
+ (x.seen ? `<span class="vv-ai-hostrow-v">${num(x.all.total)}</span>`
: `<span class="vv-ai-hostrow-v none">not collected here</span>`)
+ `</div>`;
});
$('vv-ai-tok-hosts').innerHTML = hh;
const sel = tokScope === 'all' ? tokData : hosts[tokScope];
$('vv-ai-tok-scope').textContent =
tokScope === 'all' ? 'all hosts' : (hosts[tokScope] ? hosts[tokScope].name : tokScope);
if (!sel || !sel.all.turns) {
$('vv-ai-tok-stats').innerHTML = tokScope === 'all'
? '<div class="vv-ai-none">no turns recorded yet — ask something below</div>'
: '<div class="vv-ai-none">no rows from this host in the local ledger</div>';
return;
}
const cell = (label, b) =>
`<div><div class="vv-ai-tok-l">${label}</div>`
+ `<div class="vv-ai-tok-v">${num(b.total)}</div>`
+ `<div class="vv-ai-tok-s">${num(b.turns)} turn${b.turns === 1 ? '' : 's'}`
+ ` · ${num(b.prompt)} in / ${num(b.completion)} out</div></div>`;
let html = `<div class="vv-ai-tok-grid">`
+ cell('Today', sel.today) + cell('Last 7 days', sel.week) + cell('All time', sel.all)
+ `</div>`;
const foot = [];
if (tokData.first) foot.push(`since ${esc(tokData.first)} · ${tokData.days} day${tokData.days === 1 ? '' : 's'}`);
if (tokData.best_tok_s) foot.push(`best ${tokData.best_tok_s} tok/s`);
// Profile and source splits are whole-ledger figures, so they are shown only under the
// all-hosts scope rather than sitting under a host heading they do not describe.
if (tokScope === 'all') {
const by = o => Object.keys(o || {}).map(k => `${esc(k)} ${num(o[k])}`).join(' · ');
if (Object.keys(tokData.profiles || {}).length) foot.push('profile: ' + by(tokData.profiles));
if (Object.keys(tokData.sources || {}).length) foot.push('source: ' + by(tokData.sources));
}
if (foot.length) html += `<div class="vv-ai-tok-foot">`
+ foot.map(f => `<span>${f}</span>`).join('') + `</div>`;
$('vv-ai-tok-stats').innerHTML = html;
}
// ── Settings card ───────────────────────────────────────────────────────
// The summary exists so the card is honest while shut. A collapsed panel that hides a
// non-default setting is how someone ends up puzzling over why answers changed.
function setSummary() {
const kind = $('vv-ai-kind');
const bits = [kind.options[kind.selectedIndex].text.replace(/ —.*$/, '')];
if (!$('vv-ai-think').checked) bits.push('no reasoning');
if ($('vv-ai-web').checked && $('vv-ai-web-row').style.display !== 'none') bits.push('web search');
$('vv-ai-set-sum').textContent = bits.join(' · ');
}
// The row follows the profile, off the same capability the server publishes. Chat is not the
// profile you land on, so this starts hidden and appears when you switch to it.
function webRowFor(profile) {
const p = (window.VvAiProfiles || {})[profile];
const row = $('vv-ai-web-row');
row.style.display = (p && p.web) ? '' : 'none';
setSummary();
}
$('vv-ai-set-t').addEventListener('click', () => {
$('vv-ai-set-t').classList.toggle('open');
$('vv-ai-set-b').classList.toggle('open');
});
$('vv-ai-kind').addEventListener('change', setSummary);
$('vv-ai-think').addEventListener('change', setSummary);
$('vv-ai-web').addEventListener('change', setSummary);
setSummary();
$('vv-ai-tok-hosts').addEventListener('click', e => {
const row = e.target.closest('.vv-ai-hostrow');
if (!row) return;
tokScope = row.dataset.scope;
renderTokens();
});
// ── Memory panel ────────────────────────────────────────────────────────
// Live character count against the cap, because the budget is the whole point: this text is
// prepended to every single turn and competes with retrieval for a 16k context.
let memMax = 4000;
function memCount() {
const n = $('vv-ai-mem-text').value.length;
const el = $('vv-ai-mem-count');
el.textContent = n + ' / ' + memMax;
el.style.color = n > memMax ? '#e57' : (n > memMax * 0.8 ? '#ffb74d' : '#3a3a3a');
$('vv-ai-mem-save').disabled = n > memMax;
}
function memOpen() {
const w = $('vv-ai-memwrap');
if (w.style.display !== 'none') { w.style.display = 'none'; return; }
$('vv-ai-mem-status').textContent = 'loading…';
w.style.display = '';
fetch(API + '?action=memory_get').then(r => r.json()).then(d => {
if (!d.ok) { $('vv-ai-mem-status').textContent = d.error || 'failed to load'; return; }
memMax = d.max || 4000;
$('vv-ai-mem-text').value = d.memory || '';
$('vv-ai-mem-status').textContent = d.exists ? d.path : 'not created yet — ' + d.path;
memCount();
}).catch(e => { $('vv-ai-mem-status').textContent = 'failed to load: ' + e; });
}
function memSave() {
$('vv-ai-mem-save').disabled = true;
$('vv-ai-mem-status').textContent = 'saving…';
fetch(API, { method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({ action: 'memory_set', memory: $('vv-ai-mem-text').value }) })
.then(r => r.json()).then(d => {
$('vv-ai-mem-save').disabled = false;
$('vv-ai-mem-status').textContent = d.ok
? 'saved — applies from your next message'
: (d.error || 'save failed');
})
.catch(e => { $('vv-ai-mem-save').disabled = false;
$('vv-ai-mem-status').textContent = 'save failed: ' + e; });
}
$('vv-ai-mem').addEventListener('click', memOpen);
$('vv-ai-mem-cancel').addEventListener('click', () => { $('vv-ai-memwrap').style.display = 'none'; });
$('vv-ai-mem-save').addEventListener('click', memSave);
$('vv-ai-mem-text').addEventListener('input', memCount);
// ── Wiring ──────────────────────────────────────────────────────────────
// The full-size instance. onTurn is why the banner and the ledger stay current without either
// of them polling for it: the totals only move when a turn completes, and the chat is the
// thing that knows when that was.
chat = VvAiChat({
prefix: 'vv-ai',
profile: 'varaverk', // the strict profile is the one you land on
kindEl: 'vv-ai-kind',
thinkEl: 'vv-ai-think',
webEl: 'vv-ai-web',
empty: "Ask Varaverk about itself. Answers come only from this installation's own "
+ 'documentation, with sources.',
onTurn: () => { loadBanner(true); loadTokens(); },
onProfile: p => webRowFor(p),
onChats: id => { if (chatList) chatList.setActive(id); },
});
chatList = VvAiChatList({ into: 'vv-ai-chats', chat });
// Proves to the page itself that this copy of the script is the one running, and that its
// click handler is attached. If the marker still reads NOT RUNNING, the browser is executing
// an older copy and no amount of server-side deployment will change what the button does.
const live = $('vv-ai-live');
if (live) { live.style.color = '#4a4a4a'; live.textContent = '· JS live'; }
// Old copies of this script survive a tab swap and keep their timers. Tearing the previous
// one down stops N banners polling in parallel. The chat instance tears itself down the same
// way, keyed on its prefix, so it is not handled here.
if (window.__vvAiTeardown) { try { window.__vvAiTeardown(); } catch (e) {} }
const bannerTimer = setInterval(loadBanner, 30000);
// The sweep runs every 15 minutes, so anything faster than this is polling for news that
// cannot have arrived. Five minutes keeps "swept N ago" honest without the card costing
// anything to leave open.
const fndTimer = setInterval(loadFindings, 300000);
window.__vvAiTeardown = function () { clearInterval(bannerTimer); clearInterval(fndTimer); };
loadBanner();
loadTokens();
loadBugs();
loadFindings();
loadMemProposals();
chatList.reload();
})();
</script>