Files
Varaverk/Plugin/unraid/pages/ai.php
T
Gmer4Lfe c3ed7262cd Separate whether the chat is expanded from how big expanded is
One is an action taken constantly and the other a preference set once, so the banner names the
size and the composer keeps the toggle.
2026-08-09 12:59:30 -04:00

710 lines
39 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.
//
// Read-only with respect to the system. Nothing here runs a script, edits conf, or changes
// any Varaverk state — it asks questions about documentation.
//
// RENDERS
// Status banner (index, model residency, GPU, staleness), 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 / ask / poll / clear / 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; }
/* ── 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>
<!-- 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>
<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(() => {});
};
// ── 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');
$('vv-ai-set-sum').textContent = bits.join(' · ');
}
$('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);
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',
empty: "Ask Varaverk about itself. Answers come only from this installation's own "
+ 'documentation, with sources.',
onTurn: () => { loadBanner(true); loadTokens(); },
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);
window.__vvAiTeardown = function () { clearInterval(bannerTimer); };
loadBanner();
loadTokens();
loadBugs();
chatList.reload();
})();
</script>