Files
Varaverk/Plugin/unraid/pages/ai.php
T
Gmer4Lfe 60971f1af4 Let a misfiled series be moved from the finding that reported it
The triage could say Narcos is filed under anime and offer nothing to do about
it. Findings that recorded a destination now carry a Move action, which the AI
tab renders as a button on its own — actions are read from the server, so no UI
knew this existed. Armed like Fix and Dismiss, because it is the only action in
that card that relocates files rather than settings and the undo is another move.
Offered only where the triage said misfiled, never for uncertain: three
narrowings before a file is touched. Re-reads the series from Sonarr first, so a
finding gone stale cannot undo a move made by hand in the meantime.
2026-08-14 22:13:25 -04:00

1375 lines
77 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';
require_once dirname(__DIR__) . '/include/confui.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; }
/* ── Findings & proposals ───────────────────────────────────────────────── */
/* One card, two sources. A repair finding and a proposed memory are the same kind of object from
where the operator sits — the assistant asking for a decision rather than reporting a fact —
and two cards meant two places to check for the same errand. They keep separate stores, gates
and endpoints; only the presentation is shared.
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. */
/* Which source a row came from, said on the row rather than inferred from its shape. Colour
carries it at a glance and the word carries it for anyone the colour does not reach. */
.vv-ai-src-tag { font-size:9px; letter-spacing:.06em; text-transform:uppercase; border-radius:3px;
padding:1px 6px; border:1px solid; flex-shrink:0; }
.vv-ai-src-tag.repair { color:#d8a15a; border-color:#4a3a1e; background:#1a1408; }
.vv-ai-src-tag.memory { color:#7d9be8; border-color:#26324a; background:#0d1220; }
/* What was decided, in a word. A decided row is greyed, which says it is settled but not which
way — and "kept" is the one that matters, because it means this text is in every prompt now. */
.vv-ai-mem-state { font-size:9px; letter-spacing:.06em; text-transform:uppercase;
border-radius:3px; padding:1px 6px; border:1px solid; flex-shrink:0; }
.vv-ai-mem-state.accepted { color:#6fcf97; border-color:#264a26; background:#0c1a0c; }
.vv-ai-mem-state.dismissed { color:#6a6a6a; border-color:#2a2a2a; background:#131313; }
/* Filter pills, loosely the shape the Monitor scripts card uses: a count you can click. All is an
explicit pill rather than "click the active one again to clear" — deselecting to get everything
back is a gesture you have to already know. A source with nothing in it still shows its pill, so
"no proposals" and "proposals are switched off" do not look like the same empty card. */
.vv-ai-qf { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:9px; }
.vv-ai-qf-b { font-size:10px; padding:2px 9px; border-radius:10px; cursor:pointer;
background:#111; border:1px solid #262626; color:#6a6a6a; font-family:inherit; }
.vv-ai-qf-b:hover { color:#9a9a9a; border-color:#3a3a3a; }
.vv-ai-qf-b.on { color:#c8c8c8; border-color:#4a4a4a; background:#1c1c1c; font-weight:700; }
.vv-ai-qf-b.on.repair { color:#d8a15a; border-color:#6a4f26; background:#1a1408; }
.vv-ai-qf-b.on.memory { color:#7d9be8; border-color:#33456a; background:#0d1220; }
.vv-ai-qf-n { opacity:.7; }
/* One gate line per source, because they are genuinely separate switches and joining them into a
sentence would invent a relationship that does not exist. Each hides when its source is filtered
out, so the line you are reading always describes the rows underneath it. */
.vv-ai-qg { font-size:10px; color:#4a4a4a; margin-bottom:8px; line-height:1.7; }
.vv-ai-qg > div { display:flex; gap:7px; align-items:baseline; }
.vv-ai-qg b { font-weight:normal; color:#5a5a5a; min-width:52px; flex-shrink:0; }
.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; }
/* The line between "this conversation" and "this installation". Heavier than the row separators
above it, because crossing it is the difference between a checkbox and a conf write. */
.vv-ai-conf-wrap { margin-top:12px; border-top:1px solid #2a2a2a; padding-top:10px; }
.vv-ai-conf-h { display:flex; align-items:center; gap:9px; font-size:9px; letter-spacing:.08em;
text-transform:uppercase; color:#6a6a6a; margin-bottom:9px; }
.vv-ai-conf-sub { text-transform:none; letter-spacing:0; font-size:10px; color:#4a4a4a;
font-family:monospace; }
.vv-ai-conf-h .vv-ai-btn { margin-left:auto; padding:3px 12px; font-size:11px; }
/* Scrolls on its own rather than growing the card past the viewport — forty-three fields is
taller than the screen, and the Save button belongs where it can still be reached. */
#vv-ai-conf { max-height:52vh; overflow-y:auto; padding-right:4px; }
.vv-ai-set-d { font-size:10px; color:#4a4a4a; line-height:1.5; }
</style>
<?php vv_ai_chat_assets(); vv_conf_ui_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-q-wrap">
<div class="vv-ai-diag-col" style="grid-column:1/-1">
<div class="vv-ai-diag-h">
<span id="vv-ai-q-sum"></span> Findings &amp; proposals
<!-- The gate lines used to sit here as one right-aligned span. Two sources means two
gates, and two of them in a header is a sentence; they moved under the pills. -->
<button class="vv-ai-btn ghost" id="vv-ai-q-closed" type="button"
style="margin-left:auto;padding:2px 9px;font-size:10px;text-transform:none;letter-spacing:0">Show decided</button>
</div>
<!-- Rendered rather than written out, because the counts are part of the label and a pill
with no count would be a filter you cannot judge before clicking. -->
<div class="vv-ai-qf" id="vv-ai-q-filters"></div>
<div class="vv-ai-qg" id="vv-ai-q-gates"></div>
<div id="vv-ai-queue"></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. -->
<!-- Review, then send. The box is readonly because the text shown here is the text that
goes — the server re-renders from the store rather than accepting a body from the page,
so an editable box would only create a gap between what was approved and what was sent.
Sending is a separate press from building, and nothing leaves on its own. -->
<div id="vv-ai-bug-send" style="display:none;margin-top:8px;">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<button class="vv-ai-btn" id="vv-ai-bug-local" type="button"
style="display:none" onclick="vvAiBugSendLocal(this)">Send to Gitea</button>
<button class="vv-ai-btn" id="vv-ai-bug-github" type="button"
onclick="vvAiBugGithub()">Open a GitHub issue</button>
<button class="vv-ai-btn ghost" type="button" onclick="vvAiBugCopy()">Copy</button>
</div>
<div class="vv-ai-set-d" id="vv-ai-bug-dest" style="margin-top:6px;"></div>
</div>
<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">Looking things up</span>
<div>
<label class="vv-ai-toggle"><input type="checkbox" id="vv-ai-web"> let it look up what
it does not know</label>
<div class="vv-ai-set-d" id="vv-ai-web-state">Not a web-search mode — it still answers
from what it knows, and reaches for results when it does not know, when the question
turns on something current, or when a version or figure is worth checking rather than
recalling. 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>
<!-- Everything above shapes the next question and is forgotten after it. Everything below
is written to master.conf and outlives the tab. One card, because "where do I change
the AI" has one answer — but a hard divider and its own heading, because a control that
edits a file every script sources should never be mistaken for a per-turn checkbox.
Loaded when the card is first opened rather than on page load: forty-three fields off
two conf files is real work, and this card is shut the overwhelming majority of the
time it exists. -->
<div class="vv-ai-conf-wrap">
<div class="vv-ai-conf-h">
Configuration
<span class="vv-ai-conf-sub">written to master.conf</span>
<span class="vv-ai-set-sum" id="vv-ai-conf-sum"></span>
<button class="vv-ai-btn" id="vv-ai-conf-save" type="button" disabled>Save</button>
</div>
<div id="vv-ai-conf"><div class="vv-ai-none">opens when you do</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 conf form's own endpoint, not this page's. Settings are written by the same guarded path
// the Scheduler uses, so there is one allowlist and one write path rather than an AI-shaped
// copy of both — api/ai.php deliberately has no conf-writing action at all.
const API_CONF = '/plugins/varaverk/api/confform.php';
// Which sections this tab claims, from the one registry that also generates the UI map. Hard
// coding "ai" here again would be a second place for the answer to live, and the map would go
// on describing a route this page had stopped taking.
const CONF_SECTIONS = <?= json_encode(VV_UI_SECTION_SURFACES[0]['match'] ?? 'ai') ?>;
// 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');
// The list used to be cached here for the page's own markdown builder. The server renders the
// report now, fetched by id, so there is nothing left to keep.
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" onclick="vvAiBugReport('${esc(b.id)}')">Report…</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('');
}
// The report itself is rendered by the server — see api/ai.php?action=bug_report. This page
// used to build its own markdown, which meant two formats for the same thing and no way to
// tell which one a given issue had been filed with.
//
// Read-only on purpose. What is reviewed is byte for byte what is sent, so there is no gap
// between the text approved and the text transmitted — and the server re-renders from the
// store when sending rather than trusting anything the page hands back.
window.vvAiBugReport = function (id) {
const box = document.getElementById('vv-ai-bug-copybox');
const bar = document.getElementById('vv-ai-bug-send');
box.value = 'Loading…'; box.style.display = ''; bar.style.display = 'none';
fetch(API + '?action=bug_report&id=' + encodeURIComponent(id))
.then(r => r.json())
.then(d => {
if (!d.ok) throw new Error(d.error || 'could not build the report');
box.value = d.markdown;
bar.dataset.id = id;
bar.dataset.url = d.github;
bar.style.display = '';
const t = d.targets || {};
const local = $('vv-ai-bug-local'), gh = $('vv-ai-bug-github');
// Only offered where it can actually work. A button that reports "local reporting is
// switched off" after being pressed is a button that should not have been drawn.
local.style.display = (t.local_enabled && t.local_configured) ? '' : 'none';
local.textContent = 'Send to ' + (t.local_repo || 'Gitea');
// GitHub is the path when local is off — and stays available when it is on, because
// "my own backlog" and "tell the maintainer" are different intentions, not a fallback.
gh.style.display = '';
$('vv-ai-bug-dest').textContent = t.local_enabled
? (t.local_configured
? 'Local reporting is on — this stays on your Gitea and does not reach the maintainer.'
: 'Local reporting is on but URL, repo or token is blank, so only GitHub is available.')
: 'Opens a prefilled issue you submit under your own GitHub account.';
box.focus(); box.select();
})
.catch(e => { box.value = 'Could not build the report: ' + (e.message || e); });
};
window.vvAiBugSendLocal = function (btn) {
const bar = document.getElementById('vv-ai-bug-send');
const out = $('vv-ai-bug-dest');
btn.disabled = true;
out.textContent = 'sending…';
// URLSearchParams, not FormData — a multipart POST to this endpoint hangs with no status.
fetch(API, { method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({ action: 'bug_send_local', id: bar.dataset.id }) })
.then(r => r.json())
.then(d => {
btn.disabled = false;
if (!d.ok) { out.innerHTML = `<span class="vv-ai-bad">${esc(d.error || 'failed')}</span>`; return; }
// The issue URL is the receipt. Without it "sent" is just a word the page chose.
out.innerHTML = d.url
? `<span class="vv-ai-ok">filed:</span> <a href="${esc(d.url)}" target="_blank"
rel="noopener noreferrer" style="color:#7d9be8">${esc(d.url)}</a>`
: '<span class="vv-ai-ok">filed</span>';
})
.catch(e => { btn.disabled = false; out.innerHTML = `<span class="vv-ai-bad">${esc(String(e))}</span>`; });
};
window.vvAiBugGithub = function () {
const bar = document.getElementById('vv-ai-bug-send');
const url = bar.dataset.url || '';
// GitHub silently truncates an over-long query rather than refusing it, which would file half
// a report and look like it worked. Past that length the copy box is the honest path.
if (url.length > 7500) {
$('vv-ai-bug-dest').innerHTML =
'<span class="vv-ai-warn">Too long for a prefilled URL — copy the text above into a new '
+ 'issue instead.</span>';
return;
}
window.open(url, '_blank', 'noopener,noreferrer');
};
// Kept because the clipboard API needs a secure context and the WebGUI is often plain http on
// the LAN — on exactly the setups this is built for, the box is the path that works.
window.vvAiBugCopy = function (id) {
const box = document.getElementById('vv-ai-bug-copybox');
if (!box.value || box.style.display === 'none') { vvAiBugReport(id); return; }
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(box.value).catch(() => { box.focus(); box.select(); });
} else { box.focus(); box.select(); }
};
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(() => {});
};
// ── Findings & proposals ────────────────────────────────────────────────
// Two stores, two gates, two endpoints, one card. A repair finding and a proposed memory are
// the same object from where the operator sits — the assistant asking for a decision rather
// than reporting a fact — and keeping them in separate cards meant two places to check for the
// same errand, with the empty one still charging a header for saying nothing.
//
// Rows are normalised to a common shape on load and interleaved newest-first. Everything below
// draws from that shape, so a third source would be a loader and a row renderer, not a card.
// What is deliberately NOT shared: the stores, the gates, the endpoints and the confirmations.
// Merging the presentation of two decisions must not merge the decisions.
let qFilter = 'all'; // all | memory | repair
let qClosed = false; // include decided and closed rows
let qRows = []; // normalised, newest first
let qGates = {}; // one line per source, plus whether each reads as healthy
// 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', move: 'Move it', ack: 'I know', dismiss: 'Never a problem', reopen: 'Reopen' };
// Which presses take a second one, and what the button says while it waits. Source-aware on
// purpose: repair's Fix writes conf and its Dismiss is the one state the sweep will never
// reopen on its own, while memory's Keep is the only route by which model-written text reaches
// a future prompt. Memory's Dismiss writes nothing anywhere, so a confirmation there is
// ceremony — a single map keyed on the action name alone would have armed it by accident.
function qArmLabel(row, act) {
if (row.src === 'repair') {
// Move is armed because it is the only action here that touches files rather than settings.
// Sonarr relocates the episodes on disk, and the undo is another move that has to copy
// everything back — so the second press is the cheapest part of the whole operation.
return act === 'fix' ? 'Confirm write'
: act === 'move' ? 'Confirm move'
: act === 'dismiss' ? 'Confirm — permanent' : null;
}
// Keeping is armed because it is the only route by which model-written text reaches a future
// prompt. Forgetting is armed because it drops the record that stops the same line being
// proposed again, and there is no undo for a provenance you no longer have.
//
// Retract and dismiss are not: both only ever remove something, and both are one click from
// being put back.
if (act === 'accept' || act === 'keep') return 'Confirm keep';
if (act === 'forget') return 'Confirm forget';
return null;
}
let fndArmTimer = null;
// Put every armed button back, optionally sparing the one being pressed right now.
function fndDisarm(keep) {
clearTimeout(fndArmTimer);
$('vv-ai-queue').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 = '';
});
}
// Held so a filter click can redraw without refetching. The gate lines and the open counts live
// on the payloads rather than on the normalised rows, so the payloads are what has to be kept.
let qLastF = null, qLastM = null;
// Both in parallel, and a failure of either is survivable: the card renders what it got and the
// missing source reports that it could not be read, rather than the whole card going blank
// because one endpoint was slow.
function loadQueue() {
Promise.all([
fetch(API + '?action=findings' + (qClosed ? '&all=1' : '')).then(r => r.json()).catch(() => null),
fetch(API + '?action=mem_proposals').then(r => r.json()).catch(() => null),
]).then(([f, m]) => { qLastF = f; qLastM = m; renderQueue(); });
}
function renderQueue() {
const f = qLastF, m = qLastM;
const fOk = !!(f && f.ok), mOk = !!(m && m.ok);
const rep = (fOk && f.repair) || {};
const fc = (fOk && f.counts) || {};
const L = (mOk && m.learned) || {};
// Gate state is stated rather than left to be inferred from an empty list. An empty list means
// "nothing found" when the gate is on and "nothing is looking" when it is off, and those are
// opposite pieces of news. Kept as two lines because they are genuinely separate switches —
// joining them into one sentence would invent a relationship that does not exist.
qGates = {
repair: !fOk ? 'could not be read'
: rep.enabled ? ['repair on', rep.autofix ? 'autofix on' : 'detect only',
rep.last ? 'swept ' + ago(rep.last) : null].filter(Boolean).join(' · ')
: 'off — nothing is looking',
repairOk: fOk && !!rep.enabled,
memory: !mOk ? 'could not be read'
: m.enabled ? [m.auto ? 'learning on · auto-accept on' : 'learning on · proposals held for you',
L.max ? 'learned ' + (L.chars || 0) + '/' + L.max + ' chars' : null]
.filter(Boolean).join(' · ')
: 'off — nothing is proposing',
// Auto-accept reads as a warning on purpose: it is the one setting that lets model-written
// text reach a prompt with nobody having looked at it.
memoryOk: mOk && !!m.enabled && !m.auto,
};
const fRows = (fOk && f.findings ? f.findings : []).map(x => ({
src: 'repair', id: x.id, ts: x.last || 0,
closed: ['open', 'needs_operator'].indexOf(x.state) === -1, raw: x,
}));
const mSrc = mOk ? (qClosed ? (m.recent || []) : (m.open || [])) : [];
const mRows = mSrc.map(x => ({
src: 'memory', id: x.id, ts: x.created || 0,
closed: x.state !== 'open', raw: x,
}));
// Interleaved by recency rather than grouped by source. Grouping would rebuild the two cards
// inside one border and put the newest thing anywhere but the top.
qRows = fRows.concat(mRows).sort((a, b) => (b.ts || 0) - (a.ts || 0));
// Counts describe what each pill would actually show, so a pill can be judged before it is
// clicked. A source with nothing in it keeps its pill — "no proposals" and "proposals are
// switched off" must not look like the same empty card.
const nRep = qRows.filter(r => r.src === 'repair').length;
const nMem = qRows.filter(r => r.src === 'memory').length;
$('vv-ai-q-filters').innerHTML =
qPill('all', 'All', qRows.length)
+ qPill('memory', 'Memory', nMem)
+ qPill('repair', 'Repair', nRep);
$('vv-ai-q-gates').innerHTML = ['repair', 'memory'].map(k => {
if (qFilter !== 'all' && qFilter !== k) return '';
const cls = qGates[k + 'Ok'] ? '' : ' class="vv-ai-warn"';
return `<div><b>${k === 'repair' ? 'Repair' : 'Memory'}</b>`
+ `<span${cls}>${esc(qGates[k])}</span></div>`;
}).join('');
// needs_operator is counted apart from open because it is the one genuinely waiting on a
// person — an open finding may still be repaired by the next sweep. Every open proposal is
// waiting on a person by definition, so it joins that number rather than the other.
const needs = (fc.needs_operator || 0) + (mOk ? (m.open || []).length : 0);
const sum = $('vv-ai-q-sum');
if (needs) sum.innerHTML = `<span class="vv-ai-warn">${needs} need${needs > 1 ? '' : 's'} you</span>`;
else if (fc.open) sum.innerHTML = `<span class="vv-ai-warn">${fc.open} open</span>`;
else sum.innerHTML = `<span class="vv-ai-ok">✓ nothing waiting</span>`;
const shown = qFilter === 'all' ? qRows : qRows.filter(r => r.src === qFilter);
if (!shown.length) {
$('vv-ai-queue').innerHTML =
`<div class="vv-ai-none">${esc(qEmptyText())}</div>`;
return;
}
$('vv-ai-queue').innerHTML = shown.map(r =>
r.src === 'repair' ? qFindingHtml(r) : qMemoryHtml(r)).join('');
}
// Says which of several different nothings this is. "Nothing here" over a filtered view that
// has hidden the only row is the kind of empty state that gets reported as a bug.
function qEmptyText() {
if (qFilter === 'memory') return qClosed ? 'nothing proposed yet'
: 'no proposals waiting on you';
if (qFilter === 'repair') return qClosed ? 'nothing filed yet'
: 'nothing open — the last sweep found no new faults';
return qClosed ? 'nothing filed or proposed yet' : 'nothing waiting on you';
}
function qPill(key, label, n) {
const on = qFilter === key;
return `<button type="button" class="vv-ai-qf-b${on ? ' on ' + key : ''}" data-qf="${key}">`
+ `${esc(label)} <span class="vv-ai-qf-n">${n}</span></button>`;
}
function qFindingHtml(row) {
const f = row.raw;
const meta = [f.id, f.seen > 1 ? 'seen ' + f.seen + '×' : null, ago(f.last),
row.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')}${row.closed ? ' closed' : ''}">
<div class="vv-ai-fnd-h">
<span class="vv-ai-src-tag repair">repair</span>
<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></span></div>
</div>`;
}
function qMemoryHtml(row) {
const r = row.raw;
const meta = [r.id, ago(r.created), r.profile || null].filter(Boolean).join(' · ');
// Said as a word rather than left to be read off the greying, because "kept" is the state
// that matters — it means this text is in every prompt right now.
const stateWord = r.state === 'accepted' ? (r.auto ? 'kept automatically' : 'kept')
: r.state === 'dismissed' ? 'dismissed' : '';
const badge = row.closed
? `<span class="vv-ai-mem-state ${esc(r.state)}">${esc(stateWord)}</span>` : '';
// 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-n">asked: ${esc(r.asked)}</div>` : '';
// No severity class: a proposal is not a fault, and colouring it like one would make a card
// of ordinary suggestions read as a card of problems.
//
// A decision is not final. An accepted line joins every future prompt and nothing expires it,
// so a fact that is true today — HOST2 is offline — needs a way back out on the day it stops
// being true. Retract is that way; Keep is the same door in the other direction.
const B = (act, cls, label, tip) =>
`<button class="vv-ai-btn${cls}" data-mem-act="${act}" data-id="${esc(r.id)}" `
+ `title="${esc(tip)}">${esc(label)}</button>`;
let acts;
if (!row.closed) {
acts = B('accept', '', 'Keep', 'Add this to learned memory')
+ B('dismiss', ' ghost', 'Dismiss', 'Do not keep it, and do not propose it again');
} else if (r.state === 'accepted') {
acts = B('retract', '', 'Retract', 'Take this back out of every future prompt')
+ B('forget', ' ghost', 'Forget', 'Remove it and stop tracking it — it may be proposed again');
} else {
acts = B('keep', '', 'Keep', 'Changed your mind — add it to learned memory after all')
+ B('forget', ' ghost', 'Forget', 'Stop tracking it — it may be proposed again');
}
return `<div class="vv-ai-fnd${row.closed ? ' closed' : ''}">
<div class="vv-ai-fnd-h">
<span class="vv-ai-src-tag memory">memory</span>
${badge}
<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 class="vv-ai-fnd-msg" data-msg></span></div>
</div>`;
}
// One delegated listener for both sources. The markup carries no inline handler, so nothing has
// to be escaped into an attribute that would run as code.
$('vv-ai-queue').addEventListener('click', e => {
const fb = e.target.closest('button[data-act]');
if (fb) { qAct(fb, 'finding_action', fb.dataset.act); return; }
const mb = e.target.closest('button[data-mem-act]');
if (mb) { qAct(mb, 'mem_proposal_action', mb.dataset.memAct); return; }
});
// Both sources arm, act and fail the same way, because the shape of the interaction is the same:
// a press that might be irreversible, a press that confirms it, and a failure that must not be
// repainted away before it is read.
function qAct(btn, endpointAction, act) {
const id = btn.dataset.id;
const msg = btn.parentNode.querySelector('[data-msg]');
const row = qRows.find(x => x.id === id);
if (!row) return;
// 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.
const arm = qArmLabel(row, act);
if (arm && btn.dataset.armed !== '1') {
fndDisarm();
btn.dataset.armed = '1';
btn.dataset.label = btn.dataset.label || btn.textContent;
btn.textContent = arm;
btn.classList.add('armed');
if (msg) msg.innerHTML = `<span class="vv-ai-warn">${qArmWarning(row, act)}</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 and a refused keep are both the
// case that must not disappear quietly: nothing changed, and a reload would repaint the row
// identically half a second later and take the reason with it. So the failed row keeps its
// message, and its button goes back to reading Fix or Keep rather than sitting at "Confirm" —
// 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: endpointAction, id, act }) })
.then(r => r.json())
.then(d => { if (d.ok) loadQueue(); else failed(d.error || 'failed'); })
.catch(e => failed(String(e)));
}
// What the second press is actually going to do, in the row's own terms.
function qArmWarning(row, act) {
const f = row.raw;
if (act === 'fix') {
return `Writes the change above to ${esc(f.conf_file)}`
+ `${f.proven ? '' : ', and nothing has probed it'}. Click again to confirm.`;
}
if (act === 'dismiss' && row.src === 'repair') {
return 'Stays closed even when it is seen again. '
+ 'Use “I know” to be told if it changes. Click again to confirm.';
}
if (act === 'forget') {
return 'Drops the record, so nothing stops this being proposed again — dismissed rows are '
+ 'what suppress a repeat.'
+ (row.raw.state === 'accepted' ? ' It is removed from memory first.' : '')
+ ' Click again to confirm.';
}
return 'This text joins every future prompt. Click again to confirm.';
}
$('vv-ai-q-filters').addEventListener('click', e => {
const b = e.target.closest('[data-qf]');
if (!b) return;
qFilter = b.dataset.qf;
// Redrawn from what is already in hand — a filter is a view of rows already fetched, and a
// control this cheap should not cost a request.
renderQueue();
});
$('vv-ai-q-closed').addEventListener('click', () => {
qClosed = !qClosed;
$('vv-ai-q-closed').textContent = qClosed ? 'Waiting only' : 'Show decided';
// This one does refetch: closed findings are not in hand until the endpoint is asked for them.
loadQueue();
});
// ── 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');
const open = $('vv-ai-set-b').classList.toggle('open');
// First open only. Re-reading on every toggle would discard edits in progress the moment
// someone collapsed the card to look at something else.
if (open) loadConf();
});
// ── AI configuration ────────────────────────────────────────────────────
// The conf sections whose header names AI, drawn by the shared renderer and written back
// through the same endpoint the Scheduler's per-script form uses. Nothing about which fields
// exist, what they are called or what control each gets is decided here — all of it is read
// from master.conf, so a key added tomorrow appears with no change to this page.
let confLoaded = false;
function loadConf() {
if (confLoaded) return;
confLoaded = true;
fetch(API_CONF + '?sections=' + encodeURIComponent(CONF_SECTIONS)).then(r => r.json())
.then(d => {
if (!d.ok) { $('vv-ai-conf').innerHTML = `<div class="vv-ai-none">${esc(d.error || 'could not be read')}</div>`; return; }
VvConfUI.render('vv-ai-conf', d.groups || []);
VvConfUI.wire('vv-ai-conf', confDirty);
confDirty();
})
.catch(e => {
// Retryable: the flag goes back so reopening the card tries again rather than leaving a
// permanent error where the settings should be.
confLoaded = false;
$('vv-ai-conf').innerHTML = `<div class="vv-ai-none">could not be read: ${esc(String(e))}</div>`;
});
}
function confDirty() {
const n = VvConfUI.dirtyCount('vv-ai-conf');
$('vv-ai-conf-save').disabled = n === 0;
$('vv-ai-conf-sum').innerHTML = n
? `<span class="vv-ai-warn">${n} unsaved</span>`
: '';
}
$('vv-ai-conf-save').addEventListener('click', () => {
const changes = VvConfUI.collect('vv-ai-conf');
if (!changes.length) return;
const btn = $('vv-ai-conf-save');
btn.disabled = true;
$('vv-ai-conf-sum').innerHTML = 'saving…';
// URLSearchParams, not FormData. A multipart POST to this plugin's endpoints hangs with no
// status ever returned — the request leaves the browser and never reaches PHP.
fetch(API_CONF, { method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({ changes: JSON.stringify(changes) }) })
.then(r => r.text())
.then(t => {
let d;
try { d = JSON.parse(t); }
catch (_) {
// The CSRF prepend terminates with an empty body, so this is the shape that failure
// takes. Naming it beats a bare parse error.
throw new Error(t.trim() ? 'unparseable response: ' + t.slice(0, 120)
: 'empty response — the request was rejected before the endpoint ran');
}
if (!d.ok) throw new Error(d.error || 'save failed');
// Rebased rather than refetched: a reload would repaint every control including the one
// just edited, and the values on screen are now the values on disk.
VvConfUI.commit('vv-ai-conf');
confDirty();
$('vv-ai-conf-sum').innerHTML =
`<span class="vv-ai-ok">saved ${changes.length} change${changes.length > 1 ? 's' : ''}</span>`;
// The banner reads AI_ENABLED and the rest of the gates, so it is now stale.
loadBanner();
loadQueue();
})
.catch(e => {
// Left dirty on purpose. A refused write changed nothing, and clearing the marks would
// say it had — see the same rule on the findings card.
$('vv-ai-conf-sum').innerHTML = `<span class="vv-ai-bad">${esc(e.message || String(e))}</span>`;
confDirty();
});
});
$('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(loadQueue, 300000);
window.__vvAiTeardown = function () { clearInterval(bannerTimer); clearInterval(fndTimer); };
loadBanner();
loadTokens();
loadBugs();
loadQueue();
chatList.reload();
})();
</script>