Findings and proposed memory were two cards asking the same kind of question, so checking one was never enough.
1152 lines
64 KiB
PHP
1152 lines
64 KiB
PHP
<?php
|
||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||
// PURPOSE
|
||
// AI tab. Asks Varaverk about itself — a grounded chat over the documentation index, with a
|
||
// status banner covering index health, model residency and GPU state.
|
||
//
|
||
// OPERATIONAL MODEL
|
||
// Token and poll, not streaming. A turn takes 25-76 seconds, so the composer POSTs to
|
||
// api/ai.php, receives a token, and polls until the job reaches done or error. That keeps
|
||
// the api layer on one response convention; see api/ai.php for why SSE was declined.
|
||
//
|
||
// The tab is only reachable on HOST1, and only when AI_ENABLED is true. Varaverk.page omits
|
||
// it from the tab list and rejects it server-side, and api/ai.php refuses every action on
|
||
// the same two conditions independently — hiding a link is not access control. HOST1 is the
|
||
// node with the GPU, the Ollama process and the index; include/ai.php reads only the local
|
||
// {HOST}_OLLAMA_URL, so the tab could not function anywhere else regardless.
|
||
//
|
||
// DESIGN PRINCIPLES
|
||
// The banner leads with offload, not with size.
|
||
// 41/41 layers at 100% GPU is the difference between 74 tok/s and 19 on this card, and
|
||
// nothing else in the WebGUI surfaces it. Index size is interesting; residency is
|
||
// actionable.
|
||
//
|
||
// Staleness is stated, not implied.
|
||
// An index older than the newest tracked file will answer confidently from code that has
|
||
// since changed — the one failure a grounded answer cannot reveal on its own.
|
||
//
|
||
// Sources are the point, not a footnote.
|
||
// Every answer lists what it was built from, with scores, and each source opens the file
|
||
// it came from. Retrieval you can audit is the reason to build this here rather than use
|
||
// a general chat client.
|
||
//
|
||
// Reasoning is kept and collapsed.
|
||
// qwen3 emits substantial thinking, often more useful than the answer for a judgement
|
||
// call. Hidden by default so it does not bury the answer; one click away because
|
||
// discarding it would lose the best part.
|
||
//
|
||
// OPERATIONAL SAFEGUARDS
|
||
// Every rendered string is escaped before it reaches the DOM.
|
||
// Answers, reasoning, source paths and headings are all model or file derived. The
|
||
// minimal markdown pass runs strictly after escaping, so no input can introduce markup.
|
||
//
|
||
// Conversation history is bounded client-side and again server-side.
|
||
// The page sends the last few turns; api/ai.php caps them regardless. The model is only
|
||
// fully offloaded at 16384 context, and unbounded history would cross that silently.
|
||
//
|
||
// Polling stops on a terminal state, on error, and on a wall-clock ceiling.
|
||
// A worker that dies without writing would otherwise be polled forever.
|
||
//
|
||
// The conversation is read-only with respect to the system. The findings card is not.
|
||
// Asking questions changes nothing, and every profile reachable from the composer holds
|
||
// zero capabilities. The one control on this page that changes Varaverk state is Fix on a
|
||
// repair finding, which writes a single conf key through vv_conf_edit()'s guarded path —
|
||
// lock, backup, syntax check, read-back, rollback. It is confirmed first, it names the
|
||
// file, key and both values before it is clicked, and it is the only way a toggle is ever
|
||
// written by this subsystem, because reaching it means the operator asked for it by name.
|
||
//
|
||
// The page never decides what a finding may do.
|
||
// Buttons are rendered from the actions api/ai.php returned for that row, and the endpoint
|
||
// checks the same list again before acting. A tab left open overnight holds buttons the
|
||
// store has moved past, so the row the operator sees is not the authority on what is legal.
|
||
//
|
||
// RENDERS
|
||
// Status banner (index, model residency, GPU, staleness), repair findings with their actions,
|
||
// assistant-filed bug reports, chat transcript with collapsible reasoning and audited sources,
|
||
// composer with retrieval-scope and reasoning controls, source viewer overlay
|
||
//
|
||
// DEPENDS ON
|
||
// include/ai_chat.php the shared conversation surface, also used by the Monitor tab's AI row
|
||
// api/ai.php stats / tokens / bugs / findings / finding_action / ask / poll / chats
|
||
// api/readscript.php source viewer contents
|
||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||
require_once dirname(__DIR__) . '/include/ai_chat.php';
|
||
|
||
// Build stamp. The tab bar uses Unraid's localURL, which swaps content by AJAX without tearing
|
||
// down the previous page's JavaScript — so a stale copy of this script can keep running, and
|
||
// its timers keep firing, while a new copy is injected. That makes "is the browser executing
|
||
// the code I just deployed" unanswerable from the server alone. Rendered into the DOM and
|
||
// logged on every render so both sides can be compared.
|
||
$_vv_ai_build = substr(md5_file(__FILE__), 0, 8);
|
||
if (is_dir('/var/log/varaverk')) {
|
||
@file_put_contents('/var/log/varaverk/ai.log',
|
||
date('Y-m-d H:i:s') . ' RENDER page build=' . $_vv_ai_build . "\n", FILE_APPEND | LOCK_EX);
|
||
}
|
||
?>
|
||
<style>
|
||
#vv-ai-wrap { display:flex; flex-direction:column; gap:12px; }
|
||
|
||
/* ── Banner ─────────────────────────────────────────────────────────────── */
|
||
.vv-ai-banner { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:1px;
|
||
background:#1a1a1a; border:1px solid #262626; border-radius:6px; overflow:hidden; }
|
||
.vv-ai-stat { background:#0e0e0e; padding:10px 12px; display:flex; flex-direction:column; gap:3px; }
|
||
.vv-ai-stat-l { font-size:9px; letter-spacing:.08em; text-transform:uppercase; color:#4a4a4a; }
|
||
.vv-ai-stat-v { font-size:15px; font-weight:bold; color:#c8c8c8; font-family:monospace; }
|
||
.vv-ai-stat-s { font-size:10px; color:#5a5a5a; }
|
||
|
||
/* Profiles, transcript, composer and the source overlay are styled by include/ai_chat.php,
|
||
which the Monitor tab's AI row shares. Only what is unique to this tab lives below. */
|
||
|
||
/* ── Health + loaded models ─────────────────────────────────────────────── */
|
||
/* The split lines the System checks card up with the right edge of the third banner stat.
|
||
Both rows carry a 1px border and a 1px gap, so their track origins coincide and the
|
||
fractions match exactly. The default suits the usual five stats; renderBanner() overrides
|
||
--vv-diag-split when the banner renders a different number. */
|
||
.vv-ai-diag { display:grid; grid-template-columns:var(--vv-diag-split,3fr 2fr); gap:1px; background:#1a1a1a;
|
||
border:1px solid #262626; border-radius:6px; overflow:hidden; }
|
||
@media (max-width:900px) { .vv-ai-diag { grid-template-columns:1fr; } }
|
||
.vv-ai-diag-col { background:#0e0e0e; padding:9px 12px; }
|
||
.vv-ai-diag-h { font-size:9px; letter-spacing:.08em; text-transform:uppercase; color:#4a4a4a;
|
||
margin-bottom:6px; display:flex; align-items:center; gap:7px; }
|
||
/* The checks fill top to bottom, up to three columns. Multi-column, not grid: grid fills
|
||
row-major, which would put consecutive checks side by side and scatter the reading order
|
||
across the split. `columns: 220px 3` is a ceiling, not a count — the browser uses as many
|
||
columns as fit at 240px and drops to two, then one, as the card narrows, so neither the
|
||
900px collapse above nor the diag split needs a matching breakpoint.
|
||
240px is picked against both ends, not by eye. Above: at 220 the third column arrives around
|
||
1160px of content, which is a laptop, and the model tag then wraps in every column — 240
|
||
holds it back to ~1300. Below: 900px is where the diag un-stacks and this card drops from
|
||
full width to 3/5, its narrowest point at ~516px; 240 still fits two there, where 250 falls
|
||
to one and leaves a dead band just above the breakpoint. */
|
||
#vv-ai-health { columns:240px 3; column-gap:18px; }
|
||
.vv-ai-chk { display:flex; align-items:flex-start; gap:7px; font-size:11px; padding:2px 0; line-height:1.5;
|
||
break-inside:avoid; }
|
||
.vv-ai-chk-i { flex-shrink:0; font-size:11px; width:12px; }
|
||
.vv-ai-chk-l { color:#8a8a8a; flex-shrink:0; }
|
||
.vv-ai-chk-d { color:#5a5a5a; }
|
||
.vv-ai-chk-f { color:#8a6a3a; display:block; font-size:10px; margin-top:1px; }
|
||
.vv-ai-mdl { display:flex; align-items:center; gap:7px; font-size:11px; padding:2px 0; }
|
||
.vv-ai-mdl-n { color:#8a8a8a; font-family:monospace; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||
.vv-ai-mdl-m { color:#444; font-family:monospace; margin-left:auto; flex-shrink:0; font-size:10px; }
|
||
.vv-ai-none { font-size:11px; color:#3a3a3a; font-style:italic; }
|
||
.vv-ai-buildline { font-size:10px; color:#3a3a3a; font-family:monospace; margin:6px 2px 0; }
|
||
|
||
/* ── Token accounting ───────────────────────────────────────────────────── */
|
||
/* Splits on the second banner stat rather than the third, so the host window stays narrow and
|
||
the tiles get the room — still a line the banner already draws, so the rows read as a grid
|
||
rather than three unrelated cards. renderBanner() keeps --vv-tok-split in step. */
|
||
.vv-ai-tok { display:grid; grid-template-columns:var(--vv-tok-split,2fr 3fr); gap:1px; background:#1a1a1a;
|
||
border:1px solid #262626; border-radius:6px; overflow:hidden; }
|
||
@media (max-width:900px) { .vv-ai-tok { grid-template-columns:1fr; } }
|
||
|
||
/* The rows and tiles this panel is built from live in css/varaverk.css, not here. The Monitor
|
||
tab renders the same ledger in a card of its own, and a style block on this page is only
|
||
loaded when this page is — which is why they had to leave. What stays above is this panel's
|
||
own chrome: the two-column split and the border around it, which the Monitor card does not
|
||
share because it is a .vv-card and already has one.
|
||
.vv-ai-hostrow* host rows, including the active state the scope click sets
|
||
.vv-ai-tok-* the Today / 7-day / All-time tiles and the footer line */
|
||
|
||
/* ── Assistant-filed bug reports ─────────────────────────────────────────── */
|
||
.vv-ai-bug { border-left:2px solid #5a3a2a; background:#140f0c; border-radius:0 3px 3px 0;
|
||
padding:8px 10px; margin-bottom:8px; }
|
||
.vv-ai-bug-h { display:flex; align-items:center; gap:9px; margin-bottom:4px; }
|
||
.vv-ai-bug-c { font-family:monospace; font-size:11px; color:#c8a87a; }
|
||
.vv-ai-bug-m { font-size:10px; color:#4a4a4a; font-family:monospace; margin-left:auto; }
|
||
.vv-ai-bug-s { font-size:12px; color:#b8b8b8; line-height:1.5; margin-bottom:5px; }
|
||
.vv-ai-bug-e { margin:0; padding:6px 8px; background:#0b0b0b; border:1px solid #1e1e1e;
|
||
border-radius:3px; font-size:10px; line-height:1.5; color:#8a8a8a;
|
||
white-space:pre-wrap; overflow-x:auto; max-height:110px; overflow-y:auto; }
|
||
.vv-ai-bug-q { font-size:10px; color:#4a4a4a; margin-top:5px; font-style:italic; }
|
||
|
||
/* ── 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; }
|
||
|
||
/* 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; }
|
||
.vv-ai-set-d { font-size:10px; color:#4a4a4a; line-height:1.5; }
|
||
</style>
|
||
<?php vv_ai_chat_assets(); ?>
|
||
|
||
<div id="vv-ai-wrap">
|
||
|
||
<div class="vv-ai-banner" id="vv-ai-banner"></div>
|
||
|
||
<div class="vv-ai-tok">
|
||
<div class="vv-ai-diag-col">
|
||
<div class="vv-ai-diag-h">Hosts</div>
|
||
<div id="vv-ai-tok-hosts"></div>
|
||
</div>
|
||
<div class="vv-ai-diag-col">
|
||
<div class="vv-ai-diag-h">Token usage — <span id="vv-ai-tok-scope">all hosts</span></div>
|
||
<div id="vv-ai-tok-stats"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- What the repair sweep found. Above the bug reports because these are decisions waiting on
|
||
the operator rather than notes to file somewhere, and because until this card existed the
|
||
only way to see a finding was to run the sweep tool with --status over SSH.
|
||
|
||
Unlike the bugs card it does not hide itself when empty. "Nothing found, last swept 6
|
||
minutes ago" is the single most useful thing this card ever says, and a card that vanishes
|
||
on good news cannot say it — it just leaves a gap that reads as broken. -->
|
||
<div class="vv-ai-tok" id="vv-ai-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 & 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. -->
|
||
<textarea id="vv-ai-bug-copybox" class="vv-ai-input" rows="8" readonly spellcheck="false"
|
||
style="display:none;margin-top:8px" onclick="this.select()"></textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="vv-ai-diag">
|
||
<div class="vv-ai-diag-col">
|
||
<div class="vv-ai-diag-h"><span id="vv-ai-health-sum"></span> System checks</div>
|
||
<div id="vv-ai-health"></div>
|
||
</div>
|
||
<div class="vv-ai-diag-col">
|
||
<div class="vv-ai-diag-h">Loaded models</div>
|
||
<div id="vv-ai-models"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Saved conversations. Above the transcript rather than beside it: this column is already
|
||
narrow at the width the diag row wants, and a sidebar here would take that width from the
|
||
thing being read. The same rows render on the Monitor tab off the same store. -->
|
||
<div class="vv-ai-tok">
|
||
<div class="vv-ai-diag-col" style="grid-column:1/-1">
|
||
<div class="vv-ai-chead">
|
||
<span class="vv-ai-diag-h" style="margin:0">Saved conversations</span>
|
||
<span class="vv-ai-set-sum" style="margin-left:auto">newest first · oldest drop off automatically</span>
|
||
</div>
|
||
<?php vv_ai_chat_list_markup('vv-ai'); ?>
|
||
</div>
|
||
</div>
|
||
|
||
<?php
|
||
// Heights are this tab's to choose; the component's shape is not. This is the surface with the
|
||
// most room, so it rests taller than the Monitor card and expands to most of the viewport.
|
||
vv_ai_chat_markup('vv-ai', [
|
||
'profile' => 'varaverk',
|
||
// All three stated rather than derived. Large is at the viewport cap here, and leaving medium
|
||
// to be worked out from the resting height would land it on that same cap — two settings
|
||
// doing the same thing.
|
||
'height' => '52vh',
|
||
'tall' => '68vh',
|
||
'tallLarge' => '85vh',
|
||
'empty' => "Ask Varaverk about itself. Answers come only from this installation's own "
|
||
. 'documentation, with sources.',
|
||
'placeholder' => 'e.g. what stops rsync and the mover running at once?',
|
||
'controls' => '<button class="vv-ai-btn ghost" id="vv-ai-mem" type="button">Memory</button>',
|
||
]);
|
||
?>
|
||
|
||
<!-- The build stamp and the liveness marker stay on this tab, below the composer rather than in
|
||
it. Unraid swaps tab content by AJAX without tearing down the previous page's JavaScript, so
|
||
"is the browser running the code I just deployed" is not answerable from the server — this
|
||
marker answers it from the browser, and it is where every deploy on this tab gets checked.
|
||
It sits here because the control row above is for controls: it was the one piece of prose in
|
||
a line of buttons, and it is read deliberately rather than glanced at. -->
|
||
<div class="vv-ai-buildline">build <?= $_vv_ai_build ?><span id="vv-ai-live" style="color:#e57"> · JS NOT RUNNING</span></div>
|
||
|
||
<!-- Settings. Below the composer and closed by default: these change how the next answer is
|
||
built, not what it is asked, so they should be reachable without being in the way. The
|
||
header carries the current values so the state is legible while collapsed. -->
|
||
<div class="vv-ai-set">
|
||
<div class="vv-ai-set-t" id="vv-ai-set-t">
|
||
<span class="vv-ai-set-c">▶</span> Settings
|
||
<span class="vv-ai-set-sum" id="vv-ai-set-sum"></span>
|
||
</div>
|
||
<div class="vv-ai-set-b" id="vv-ai-set-b">
|
||
<div class="vv-ai-set-r">
|
||
<span class="vv-ai-set-l">Retrieval source</span>
|
||
<div>
|
||
<select id="vv-ai-kind" title="Restrict retrieval to one kind of source">
|
||
<option value="">All sources</option>
|
||
<option value="ui">WebUI — how the pages work</option>
|
||
<option value="readme">README — what things are</option>
|
||
<option value="manual">Manual — how to do things</option>
|
||
<option value="header">Script headers</option>
|
||
<option value="template">Conf templates</option>
|
||
<option value="doc">Design notes</option>
|
||
</select>
|
||
<div class="vv-ai-set-d">Intent routing favours per-script headers, which buries the
|
||
top-level prose. Pick README for "what is X" and "why does this exist" questions.
|
||
Varaverk Assistant only — the other profiles do not retrieve.</div>
|
||
</div>
|
||
</div>
|
||
<!-- Shown only for profiles that hold web_search, which today means General Chat alone.
|
||
Hidden rather than disabled for the others: a control that is visible and does nothing
|
||
reads as broken, and the reason it does nothing is a capability boundary that takes a
|
||
paragraph to explain. -->
|
||
<div class="vv-ai-set-r" id="vv-ai-web-row" style="display:none">
|
||
<span class="vv-ai-set-l">Web search</span>
|
||
<div>
|
||
<label class="vv-ai-toggle"><input type="checkbox" id="vv-ai-web"> search the web for
|
||
this question</label>
|
||
<div class="vv-ai-set-d" id="vv-ai-web-state">General Chat only — it is the one profile
|
||
that cannot change anything, which is why it is the one allowed to look outside. Your
|
||
question is sent to the configured search provider.</div>
|
||
</div>
|
||
</div>
|
||
<div class="vv-ai-set-r">
|
||
<span class="vv-ai-set-l">Reasoning</span>
|
||
<div>
|
||
<label class="vv-ai-toggle"><input type="checkbox" id="vv-ai-think" checked> show the
|
||
model's thinking</label>
|
||
<div class="vv-ai-set-d">Kept and collapsed under each answer. Thinking time scales
|
||
with the question — seconds for a greeting, ~18s for a substantive one.</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="vv-ai-memwrap" style="display:none;border:1px solid #262626;border-radius:6px;
|
||
background:#0e0e0e;padding:10px;">
|
||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
|
||
<span style="font-size:9px;letter-spacing:.08em;text-transform:uppercase;color:#4a4a4a;">
|
||
Standing memory — given to the assistant at the start of every conversation</span>
|
||
<span id="vv-ai-mem-count" style="margin-left:auto;font-size:10px;color:#3a3a3a;font-family:monospace;"></span>
|
||
</div>
|
||
<textarea id="vv-ai-mem-text" class="vv-ai-input" rows="10" spellcheck="false"
|
||
placeholder="Who you are, how this install is set up, decisions already made, things it should stop asking. e.g. - HOST2 (unRAID-Jayred36) is being rebuilt and is offline. Do not suggest syncing to it. - RSYNC_ENABLED is deliberately false until HOST2 is onboarded. - 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,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
.replace(/"/g,'"').replace(/'/g,''');
|
||
|
||
// ── 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(() => {});
|
||
};
|
||
|
||
// ── 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', 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') {
|
||
return act === 'fix' ? 'Confirm write'
|
||
: act === 'dismiss' ? 'Confirm — permanent' : null;
|
||
}
|
||
return act === 'accept' ? 'Confirm keep' : 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,
|
||
row.closed ? r.state + (r.auto ? ' (auto)' : '') : null].filter(Boolean).join(' · ');
|
||
// The question is the provenance. A line read three weeks from now is judged by what was
|
||
// being asked when the model decided it was worth keeping.
|
||
const asked = r.asked ? `<div class="vv-ai-fnd-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.
|
||
const acts = row.closed ? '' :
|
||
`<button class="vv-ai-btn" data-mem-act="accept" data-id="${esc(r.id)}" `
|
||
+ `title="Add this to learned memory">Keep</button>`
|
||
+ `<button class="vv-ai-btn ghost" data-mem-act="dismiss" data-id="${esc(r.id)}" `
|
||
+ `title="Never propose this again">Dismiss</button>`;
|
||
|
||
return `<div class="vv-ai-fnd${row.closed ? ' closed' : ''}">
|
||
<div class="vv-ai-fnd-h">
|
||
<span class="vv-ai-src-tag memory">memory</span>
|
||
<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.';
|
||
}
|
||
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');
|
||
$('vv-ai-set-b').classList.toggle('open');
|
||
});
|
||
$('vv-ai-kind').addEventListener('change', setSummary);
|
||
$('vv-ai-think').addEventListener('change', setSummary);
|
||
$('vv-ai-web').addEventListener('change', setSummary);
|
||
setSummary();
|
||
|
||
$('vv-ai-tok-hosts').addEventListener('click', e => {
|
||
const row = e.target.closest('.vv-ai-hostrow');
|
||
if (!row) return;
|
||
tokScope = row.dataset.scope;
|
||
renderTokens();
|
||
});
|
||
|
||
// ── Memory panel ────────────────────────────────────────────────────────
|
||
// Live character count against the cap, because the budget is the whole point: this text is
|
||
// prepended to every single turn and competes with retrieval for a 16k context.
|
||
let memMax = 4000;
|
||
function memCount() {
|
||
const n = $('vv-ai-mem-text').value.length;
|
||
const el = $('vv-ai-mem-count');
|
||
el.textContent = n + ' / ' + memMax;
|
||
el.style.color = n > memMax ? '#e57' : (n > memMax * 0.8 ? '#ffb74d' : '#3a3a3a');
|
||
$('vv-ai-mem-save').disabled = n > memMax;
|
||
}
|
||
function memOpen() {
|
||
const w = $('vv-ai-memwrap');
|
||
if (w.style.display !== 'none') { w.style.display = 'none'; return; }
|
||
$('vv-ai-mem-status').textContent = 'loading…';
|
||
w.style.display = '';
|
||
fetch(API + '?action=memory_get').then(r => r.json()).then(d => {
|
||
if (!d.ok) { $('vv-ai-mem-status').textContent = d.error || 'failed to load'; return; }
|
||
memMax = d.max || 4000;
|
||
$('vv-ai-mem-text').value = d.memory || '';
|
||
$('vv-ai-mem-status').textContent = d.exists ? d.path : 'not created yet — ' + d.path;
|
||
memCount();
|
||
}).catch(e => { $('vv-ai-mem-status').textContent = 'failed to load: ' + e; });
|
||
}
|
||
function memSave() {
|
||
$('vv-ai-mem-save').disabled = true;
|
||
$('vv-ai-mem-status').textContent = 'saving…';
|
||
fetch(API, { method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||
body: new URLSearchParams({ action: 'memory_set', memory: $('vv-ai-mem-text').value }) })
|
||
.then(r => r.json()).then(d => {
|
||
$('vv-ai-mem-save').disabled = false;
|
||
$('vv-ai-mem-status').textContent = d.ok
|
||
? 'saved — applies from your next message'
|
||
: (d.error || 'save failed');
|
||
})
|
||
.catch(e => { $('vv-ai-mem-save').disabled = false;
|
||
$('vv-ai-mem-status').textContent = 'save failed: ' + e; });
|
||
}
|
||
$('vv-ai-mem').addEventListener('click', memOpen);
|
||
$('vv-ai-mem-cancel').addEventListener('click', () => { $('vv-ai-memwrap').style.display = 'none'; });
|
||
$('vv-ai-mem-save').addEventListener('click', memSave);
|
||
$('vv-ai-mem-text').addEventListener('input', memCount);
|
||
|
||
// ── Wiring ──────────────────────────────────────────────────────────────
|
||
// The full-size instance. onTurn is why the banner and the ledger stay current without either
|
||
// of them polling for it: the totals only move when a turn completes, and the chat is the
|
||
// thing that knows when that was.
|
||
chat = VvAiChat({
|
||
prefix: 'vv-ai',
|
||
profile: 'varaverk', // the strict profile is the one you land on
|
||
kindEl: 'vv-ai-kind',
|
||
thinkEl: 'vv-ai-think',
|
||
webEl: 'vv-ai-web',
|
||
empty: "Ask Varaverk about itself. Answers come only from this installation's own "
|
||
+ 'documentation, with sources.',
|
||
onTurn: () => { loadBanner(true); loadTokens(); },
|
||
onProfile: p => webRowFor(p),
|
||
onChats: id => { if (chatList) chatList.setActive(id); },
|
||
});
|
||
|
||
chatList = VvAiChatList({ into: 'vv-ai-chats', chat });
|
||
|
||
// Proves to the page itself that this copy of the script is the one running, and that its
|
||
// click handler is attached. If the marker still reads NOT RUNNING, the browser is executing
|
||
// an older copy and no amount of server-side deployment will change what the button does.
|
||
const live = $('vv-ai-live');
|
||
if (live) { live.style.color = '#4a4a4a'; live.textContent = '· JS live'; }
|
||
|
||
// Old copies of this script survive a tab swap and keep their timers. Tearing the previous
|
||
// one down stops N banners polling in parallel. The chat instance tears itself down the same
|
||
// way, keyed on its prefix, so it is not handled here.
|
||
if (window.__vvAiTeardown) { try { window.__vvAiTeardown(); } catch (e) {} }
|
||
const bannerTimer = setInterval(loadBanner, 30000);
|
||
// The sweep runs every 15 minutes, so anything faster than this is polling for news that
|
||
// cannot have arrived. Five minutes keeps "swept N ago" honest without the card costing
|
||
// anything to leave open.
|
||
const fndTimer = setInterval(loadQueue, 300000);
|
||
window.__vvAiTeardown = function () { clearInterval(bannerTimer); clearInterval(fndTimer); };
|
||
|
||
loadBanner();
|
||
loadTokens();
|
||
loadBugs();
|
||
loadQueue();
|
||
chatList.reload();
|
||
})();
|
||
</script>
|