857 lines
47 KiB
PHP
857 lines
47 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.
|
||
//
|
||
// 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
|
||
// api/ai.php stats / ask / poll / clear
|
||
// api/readscript.php source viewer contents
|
||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||
|
||
// 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; }
|
||
.vv-ai-ok { color:#6fcf97 !important; }
|
||
.vv-ai-warn { color:#ffb74d !important; }
|
||
.vv-ai-bad { color:#e57 !important; }
|
||
|
||
/* ── Profiles ───────────────────────────────────────────────────────────── */
|
||
.vv-ai-profiles { display:flex; gap:6px; align-items:center; flex-wrap:wrap; }
|
||
.vv-ai-prof { background:#0e0e0e; border:1px solid #262626; color:#5a5a5a; font-size:11px;
|
||
padding:5px 12px; border-radius:4px; cursor:pointer; font-family:inherit; }
|
||
.vv-ai-prof:hover { color:#8a8a8a; border-color:#333; }
|
||
.vv-ai-prof.active { background:#152238; border-color:#2d4a6a; color:#9bd; }
|
||
.vv-ai-prof-hint { font-size:10px; color:#4a4a4a; margin-left:6px; flex:1; min-width:180px; }
|
||
.vv-ai-switch { text-align:center; font-size:10px; color:#3a3a3a; margin:10px 0;
|
||
border-top:1px dashed #1e1e1e; padding-top:8px; }
|
||
|
||
/* ── Chat ───────────────────────────────────────────────────────────────── */
|
||
.vv-ai-chat { border:1px solid #262626; border-radius:6px; background:#0b0b0b;
|
||
min-height:340px; max-height:60vh; overflow-y:auto; padding:14px; }
|
||
.vv-ai-empty { color:#3a3a3a; font-size:12px; text-align:center; padding:60px 20px; line-height:1.7; }
|
||
.vv-ai-msg { margin-bottom:16px; }
|
||
.vv-ai-role { font-size:9px; letter-spacing:.08em; text-transform:uppercase; margin-bottom:5px; }
|
||
.vv-ai-msg.user .vv-ai-role { color:#5c7cfa; }
|
||
.vv-ai-msg.bot .vv-ai-role { color:#6fcf97; }
|
||
.vv-ai-body { font-size:13px; line-height:1.65; color:#b8b8b8; white-space:pre-wrap; word-wrap:break-word; }
|
||
.vv-ai-msg.user .vv-ai-body { color:#8a9ac8; }
|
||
.vv-ai-body code { background:#151515; padding:1px 5px; border-radius:3px; font-size:12px; color:#d4a; }
|
||
.vv-ai-body pre { background:#131313; border:1px solid #222; border-radius:4px; padding:10px;
|
||
overflow-x:auto; margin:8px 0; }
|
||
.vv-ai-body pre code { background:none; padding:0; color:#9cc; }
|
||
.vv-ai-cite { color:#5c7cfa; font-weight:bold; cursor:pointer; }
|
||
.vv-ai-cite:hover { text-decoration:underline; }
|
||
|
||
.vv-ai-danger { background:#1f0d0d; border:1px solid #4a1f1f; border-left:3px solid #e57;
|
||
border-radius:4px; padding:8px 10px; margin-bottom:9px; font-size:11px;
|
||
line-height:1.55; color:#d99; }
|
||
.vv-ai-danger strong { color:#f88; }
|
||
|
||
.vv-ai-think-t { font-size:10px; color:#4a4a4a; cursor:pointer; user-select:none; margin-bottom:6px;
|
||
display:inline-block; border:1px solid #222; border-radius:3px; padding:2px 7px; }
|
||
.vv-ai-think-t:hover { color:#777; border-color:#333; }
|
||
.vv-ai-think { display:none; font-size:11px; line-height:1.6; color:#5a5a5a; background:#0d0d0d;
|
||
border-left:2px solid #262626; padding:8px 10px; margin-bottom:8px; white-space:pre-wrap; }
|
||
.vv-ai-think.open { display:block; }
|
||
|
||
.vv-ai-src { margin-top:9px; border-top:1px solid #1c1c1c; padding-top:7px; }
|
||
.vv-ai-src-h { font-size:9px; letter-spacing:.07em; text-transform:uppercase; color:#3a3a3a; margin-bottom:4px; }
|
||
.vv-ai-src-i { font-size:11px; color:#5a5a5a; padding:2px 0; cursor:pointer; display:flex; gap:8px; }
|
||
.vv-ai-src-i:hover { color:#8a8a8a; }
|
||
.vv-ai-src-n { color:#3a4a6a; font-family:monospace; flex-shrink:0; }
|
||
.vv-ai-src-s { color:#333; font-family:monospace; margin-left:auto; flex-shrink:0; }
|
||
.vv-ai-meta { font-size:10px; color:#333; margin-top:6px; font-family:monospace; }
|
||
|
||
/* ── 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; }
|
||
|
||
/* ── 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; } }
|
||
.vv-ai-hostrow { display:flex; align-items:center; gap:8px; font-size:11px; padding:4px 6px;
|
||
border-radius:4px; cursor:pointer; border:1px solid transparent; }
|
||
.vv-ai-hostrow:hover { background:#141414; }
|
||
.vv-ai-hostrow.active { background:#14202c; border-color:#2d4a6a; }
|
||
.vv-ai-hostrow-n { color:#8a8a8a; }
|
||
.vv-ai-hostrow-h { color:#4a4a4a; font-family:monospace; font-size:10px; }
|
||
.vv-ai-hostrow-v { margin-left:auto; color:#c8c8c8; font-family:monospace; font-size:11px; flex-shrink:0; }
|
||
.vv-ai-hostrow-v.none { color:#3a3a3a; font-style:italic; font-family:inherit; font-size:10px; }
|
||
.vv-ai-tok-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:12px; }
|
||
.vv-ai-tok-l { font-size:9px; letter-spacing:.08em; text-transform:uppercase; color:#4a4a4a; }
|
||
.vv-ai-tok-v { font-size:17px; font-weight:bold; color:#c8c8c8; font-family:monospace; line-height:1.3; }
|
||
.vv-ai-tok-s { font-size:10px; color:#5a5a5a; }
|
||
.vv-ai-tok-foot { margin-top:9px; padding-top:7px; border-top:1px solid #1a1a1a; font-size:10px;
|
||
color:#4a4a4a; display:flex; gap:14px; flex-wrap:wrap; }
|
||
|
||
.vv-ai-pending { font-size:12px; color:#5a5a5a; display:flex; align-items:center; gap:8px; }
|
||
.vv-ai-dot { width:6px; height:6px; border-radius:50%; background:#6fcf97; animation:vvAiPulse 1.1s infinite; }
|
||
@keyframes vvAiPulse { 0%,100%{opacity:.25;} 50%{opacity:1;} }
|
||
|
||
/* ── Composer ───────────────────────────────────────────────────────────── */
|
||
.vv-ai-composer { display:flex; flex-direction:column; gap:7px; border:1px solid #262626;
|
||
border-radius:6px; padding:10px; background:#0e0e0e; }
|
||
.vv-ai-input { width:100%; background:#0a0a0a; border:1px solid #222; border-radius:4px; color:#c8c8c8;
|
||
font-family:inherit; font-size:13px; padding:9px; resize:vertical; min-height:58px; }
|
||
.vv-ai-input:focus { outline:none; border-color:#2d4a6a; }
|
||
.vv-ai-ctrls { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||
.vv-ai-ctrls select { background:#0a0a0a; border:1px solid #222; color:#8a8a8a; font-size:11px;
|
||
padding:4px 7px; border-radius:3px; }
|
||
.vv-ai-hint { font-size:10px; color:#3a3a3a; margin-left:auto; }
|
||
.vv-ai-btn { background:#152238; border:1px solid #2d4a6a; color:#8ab; font-size:12px; padding:5px 14px;
|
||
border-radius:3px; cursor:pointer; }
|
||
.vv-ai-btn:hover:not(:disabled) { background:#1d2f4d; }
|
||
.vv-ai-btn:disabled { opacity:.4; cursor:default; }
|
||
.vv-ai-btn.ghost { background:none; border-color:#262626; color:#5a5a5a; }
|
||
.vv-ai-toggle { font-size:11px; color:#6a6a6a; display:flex; align-items:center; gap:5px; cursor:pointer; }
|
||
|
||
/* ── Source overlay ─────────────────────────────────────────────────────── */
|
||
#vv-ai-view { display:none; position:fixed; inset:0; background:rgba(0,0,0,.82); z-index:9999;
|
||
padding:36px; }
|
||
#vv-ai-view.open { display:block; }
|
||
.vv-ai-view-box { background:#0b0b0b; border:1px solid #2a2a2a; border-radius:6px; height:100%;
|
||
display:flex; flex-direction:column; }
|
||
.vv-ai-view-h { padding:9px 12px; border-bottom:1px solid #222; display:flex; align-items:center; gap:10px; }
|
||
.vv-ai-view-t { font-size:12px; color:#8a8a8a; font-family:monospace; overflow:hidden; text-overflow:ellipsis; }
|
||
.vv-ai-view-b { flex:1; overflow:auto; margin:0; padding:12px; font-size:12px; line-height:1.5;
|
||
color:#9a9a9a; white-space:pre; }
|
||
</style>
|
||
|
||
<div id="vv-ai-wrap">
|
||
|
||
<div class="vv-ai-banner" id="vv-ai-banner"></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>
|
||
|
||
<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>
|
||
|
||
<div class="vv-ai-profiles">
|
||
<button class="vv-ai-prof active" data-prof="varaverk" type="button">Varaverk Assistant</button>
|
||
<button class="vv-ai-prof" data-prof="chat" type="button">General Chat</button>
|
||
<button class="vv-ai-prof" data-prof="code" type="button">Code Sketcher</button>
|
||
<span class="vv-ai-prof-hint" id="vv-ai-prof-hint"></span>
|
||
</div>
|
||
|
||
<div class="vv-ai-chat" id="vv-ai-chat">
|
||
<div class="vv-ai-empty">
|
||
Ask Varaverk about itself.<br>
|
||
Answers come only from this installation's own documentation, with sources.
|
||
</div>
|
||
</div>
|
||
|
||
<div class="vv-ai-composer">
|
||
<textarea class="vv-ai-input" id="vv-ai-input" rows="2"
|
||
placeholder="e.g. what stops rsync and the mover running at once?"></textarea>
|
||
<div class="vv-ai-ctrls">
|
||
<select id="vv-ai-kind" title="Restrict retrieval to one kind of source">
|
||
<option value="">All sources</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>
|
||
<label class="vv-ai-toggle"><input type="checkbox" id="vv-ai-think" checked> reasoning</label>
|
||
<button class="vv-ai-btn ghost" id="vv-ai-mem" type="button">Memory</button>
|
||
<button class="vv-ai-btn ghost" id="vv-ai-clear" type="button">Clear</button>
|
||
<span class="vv-ai-hint">Ctrl+Enter to send · 3-turn history · build <?=$_vv_ai_build?>
|
||
<span id="vv-ai-live" style="color:#e57">· JS NOT RUNNING</span></span>
|
||
<button class="vv-ai-btn" id="vv-ai-send" type="button">Ask</button>
|
||
</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>
|
||
|
||
<div id="vv-ai-view" onclick="if(event.target===this)vvAiCloseView()">
|
||
<div class="vv-ai-view-box">
|
||
<div class="vv-ai-view-h">
|
||
<span class="vv-ai-view-t" id="vv-ai-view-t"></span>
|
||
<button class="vv-ai-btn ghost" style="margin-left:auto" onclick="vvAiCloseView()">Close</button>
|
||
</div>
|
||
<pre class="vv-ai-view-b" id="vv-ai-view-b"></pre>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
const API = '/plugins/varaverk/api/ai.php';
|
||
const POLL_MS = 1200;
|
||
const POLL_CEIL = 300000; // stop polling a worker that never wrote a terminal state
|
||
|
||
// Server-side is the authority on retrieval and history depth; these are for the UI only.
|
||
// Always starts on varaverk — the strict profile is the one you land on, so a misuse costs a
|
||
// "the docs don't cover that" rather than an invented claim about the system.
|
||
const PROFILES = {
|
||
varaverk: { turns: 3, kind: true,
|
||
hint: 'Answers only from Varaverk\'s own docs, with sources. Says so when they don\'t cover it.' },
|
||
chat: { turns: 8, kind: false,
|
||
hint: 'Ordinary conversation. Knows your memory notes, but not the docs — it\'ll point you back here for specifics.' },
|
||
code: { turns: 4, kind: false,
|
||
hint: 'Drafts short scripts for Custom Scripts. First drafts — it flags flags it isn\'t sure of. Test before trusting.' },
|
||
};
|
||
|
||
let profile = 'varaverk';
|
||
let history = []; // {role, content} — trimmed per profile
|
||
let busy = false;
|
||
let lastSources = [];
|
||
|
||
const $ = id => document.getElementById(id);
|
||
const esc = s => String(s == null ? '' : s)
|
||
.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;
|
||
}
|
||
function loadBanner() {
|
||
fetch(API + '?action=stats').then(r => r.json())
|
||
.then(d => { if (d.ok) renderBanner(d.stats); })
|
||
.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;
|
||
}
|
||
|
||
$('vv-ai-tok-hosts').addEventListener('click', e => {
|
||
const row = e.target.closest('.vv-ai-hostrow');
|
||
if (!row) return;
|
||
tokScope = row.dataset.scope;
|
||
renderTokens();
|
||
});
|
||
|
||
// ── Minimal markdown, applied strictly after escaping ───────────────────
|
||
function fmt(text) {
|
||
let h = esc(text);
|
||
h = h.replace(/```(\w*)\n([\s\S]*?)```/g, (m, l, c) => `<pre><code>${c}</code></pre>`);
|
||
h = h.replace(/`([^`\n]+)`/g, '<code>$1</code>');
|
||
h = h.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
||
h = h.replace(/\[(\d+)\]/g, '<span class="vv-ai-cite" onclick="vvAiCite($1)">[$1]</span>');
|
||
return h;
|
||
}
|
||
|
||
// ── Transcript ──────────────────────────────────────────────────────────
|
||
function el(html) { const d = document.createElement('div'); d.innerHTML = html; return d.firstElementChild; }
|
||
function chat() { return $('vv-ai-chat'); }
|
||
function scroll() { chat().scrollTop = chat().scrollHeight; }
|
||
function clearEmpty() { const e = chat().querySelector('.vv-ai-empty'); if (e) e.remove(); }
|
||
|
||
function addUser(text) {
|
||
clearEmpty();
|
||
chat().appendChild(el(`<div class="vv-ai-msg user"><div class="vv-ai-role">You</div>`
|
||
+ `<div class="vv-ai-body">${esc(text)}</div></div>`));
|
||
scroll();
|
||
}
|
||
let pendingTimer = null;
|
||
function addPending() {
|
||
const n = el(`<div class="vv-ai-msg bot" id="vv-ai-pending"><div class="vv-ai-role">Varaverk</div>`
|
||
+ `<div class="vv-ai-pending"><span class="vv-ai-dot"></span>`
|
||
+ `<span id="vv-ai-phase">starting…</span>`
|
||
+ `<span id="vv-ai-elapsed" style="color:#333;font-family:monospace"></span></div></div>`);
|
||
chat().appendChild(n); scroll();
|
||
// An elapsed counter distinguishes "working" from "wedged" at a glance. Without it a stalled
|
||
// turn and a slow one look identical, and the slow case here is legitimately ~40s.
|
||
const t0 = Date.now();
|
||
clearInterval(pendingTimer);
|
||
pendingTimer = setInterval(() => {
|
||
const e = $('vv-ai-elapsed');
|
||
if (!e) { clearInterval(pendingTimer); return; }
|
||
e.textContent = Math.round((Date.now() - t0) / 1000) + 's';
|
||
}, 1000);
|
||
}
|
||
function phase(t) { const p = $('vv-ai-phase'); if (p) p.textContent = t; }
|
||
|
||
function sourcesHtml(sources) {
|
||
if (!sources || !sources.length) return '';
|
||
let h = '<div class="vv-ai-src"><div class="vv-ai-src-h">Sources</div>';
|
||
sources.forEach((s, i) => {
|
||
const label = [s.path, s.section, s.heading].filter(Boolean).join(' › ');
|
||
h += `<div class="vv-ai-src-i" onclick="vvAiOpen('${esc(s.path)}')">`
|
||
+ `<span class="vv-ai-src-n">[${i+1}]</span><span>${esc(label)}</span>`
|
||
+ `<span class="vv-ai-src-s">${Number(s.score).toFixed(3)}</span></div>`;
|
||
});
|
||
return h + '</div>';
|
||
}
|
||
|
||
function addAnswer(job) {
|
||
const p = $('vv-ai-pending'); if (p) p.remove();
|
||
lastSources = job.sources || [];
|
||
|
||
let h = `<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`;
|
||
if (job.thinking) {
|
||
h += `<div class="vv-ai-think-t" onclick="this.nextElementSibling.classList.toggle('open')">`
|
||
+ `reasoning (${job.thinking.length.toLocaleString()} chars)</div>`
|
||
+ `<div class="vv-ai-think">${esc(job.thinking)}</div>`;
|
||
}
|
||
// Detected from the generated code, not from the model saying so. These scripts run as root
|
||
// on a schedule, so the banner is louder than the prose warning it may or may not have added.
|
||
if (job.warnings && job.warnings.length) {
|
||
h += `<div class="vv-ai-danger"><strong>Destructive — read before running.</strong> `
|
||
+ `This script ${job.warnings.map(esc).join('; ')}. `
|
||
+ `Run any dry-run form first and check the paths are what you expect.</div>`;
|
||
}
|
||
h += `<div class="vv-ai-body">${fmt(job.answer)}</div>`;
|
||
h += sourcesHtml(job.sources);
|
||
const t = job.timing || {};
|
||
if (t.tok_s) {
|
||
h += `<div class="vv-ai-meta">${t.tokens} tok · ${t.tok_s} tok/s · `
|
||
+ `retrieve ${t.retrieve_ms}ms · generate ${(t.generate_ms/1000).toFixed(1)}s</div>`;
|
||
}
|
||
chat().appendChild(el(h + '</div>')); scroll();
|
||
}
|
||
|
||
function addError(msg) {
|
||
const p = $('vv-ai-pending'); if (p) p.remove();
|
||
chat().appendChild(el(`<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`
|
||
+ `<div class="vv-ai-body vv-ai-bad">${esc(msg)}</div></div>`));
|
||
scroll();
|
||
}
|
||
|
||
// ── Ask / poll ──────────────────────────────────────────────────────────
|
||
function send() {
|
||
// Never fail silently on a stuck flag. A turn that ends without finish() — a throw, a poll
|
||
// loop that stopped, a tab left open across a deploy — would otherwise make every later
|
||
// click a no-op with the previous "starting…" still on screen, which reads as a hang that
|
||
// produces no request and therefore no server-side trace at all. That cost a diagnosis
|
||
// session; it now says so and offers the way out.
|
||
if (busy) {
|
||
addError('A previous question is still marked in-flight, so this one was not sent. '
|
||
+ 'Reload the tab to reset it.');
|
||
return;
|
||
}
|
||
const q = $('vv-ai-input').value.trim();
|
||
if (!q) return;
|
||
|
||
busy = true;
|
||
$('vv-ai-send').disabled = true;
|
||
addUser(q);
|
||
$('vv-ai-input').value = '';
|
||
addPending();
|
||
|
||
// Wrapped: a synchronous throw here — from building the request, or from a fetch wrapper
|
||
// installed elsewhere on the page — would escape the promise chain entirely and leave the
|
||
// pending indicator up forever with nothing logged anywhere. A hang is the one failure
|
||
// that tells you nothing, so every path below has to end in a visible message.
|
||
// URLSearchParams, not FormData. FormData sends multipart/form-data, and a multipart POST
|
||
// to this endpoint hangs with no status code ever returned — the request leaves the browser
|
||
// with a valid token and correct body, and never reaches PHP: no CSRF termination, no
|
||
// fatal, no entry log. Every other POST on this host that demonstrably works, including
|
||
// Unraid's own, is application/x-www-form-urlencoded. Same fields, same $_POST on the
|
||
// server; only the encoding changes.
|
||
let res;
|
||
try {
|
||
const body = new URLSearchParams({
|
||
action: 'ask',
|
||
profile: profile,
|
||
question: q,
|
||
history: JSON.stringify(history.slice(-PROFILES[profile].turns * 2)),
|
||
kind: PROFILES[profile].kind ? $('vv-ai-kind').value : '',
|
||
think: $('vv-ai-think').checked ? '1' : '0',
|
||
});
|
||
res = fetch(API, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||
body,
|
||
});
|
||
} catch (e) {
|
||
addError('Could not send the request: ' + (e && e.message ? e.message : e)
|
||
+ ' — this failed in the browser before reaching the server.');
|
||
finish();
|
||
return;
|
||
}
|
||
|
||
res.then(r => r.text().then(t => ({ status: r.status, text: t })))
|
||
.then(({ status, text }) => {
|
||
if (!text.trim()) {
|
||
// The CSRF prepend terminates with an empty body, so this is the shape that failure
|
||
// takes. Naming it beats a bare JSON parse error.
|
||
addError('Empty response (HTTP ' + status + '). This usually means the request was '
|
||
+ 'rejected before the endpoint ran — check the CSRF token shim.');
|
||
finish(); return;
|
||
}
|
||
let d;
|
||
try { d = JSON.parse(text); }
|
||
catch (e) { addError('Unparseable response (HTTP ' + status + '): ' + text.slice(0, 160));
|
||
finish(); return; }
|
||
if (!d.ok) { addError(d.error || 'Failed to start'); finish(); return; }
|
||
history.push({ role: 'user', content: q });
|
||
poll(d.token, Date.now());
|
||
})
|
||
.catch(e => { addError('Request failed: ' + (e && e.message ? e.message : e)); finish(); });
|
||
}
|
||
|
||
function finish() {
|
||
busy = false;
|
||
$('vv-ai-send').disabled = false;
|
||
clearInterval(pendingTimer);
|
||
}
|
||
|
||
function poll(token, started) {
|
||
if (Date.now() - started > POLL_CEIL) {
|
||
addError('Timed out waiting for a response.'); finish(); return;
|
||
}
|
||
fetch(API + '?action=poll&token=' + encodeURIComponent(token)).then(r => r.json()).then(d => {
|
||
if (!d.ok) { addError(d.error || 'Poll failed'); finish(); return; }
|
||
const j = d.job || {};
|
||
if (j.status === 'done') {
|
||
addAnswer(j);
|
||
history.push({ role: 'assistant', content: j.answer });
|
||
const cap = PROFILES[profile].turns * 2;
|
||
if (history.length > cap) history = history.slice(-cap);
|
||
fetch(API, { method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||
body: new URLSearchParams({ action: 'clear', token }) }).catch(() => {});
|
||
finish(); loadBanner(); loadTokens(); return;
|
||
}
|
||
if (j.status === 'error') { addError(j.error || 'Unknown error'); finish(); return; }
|
||
phase(j.status === 'generating'
|
||
? 'generating… (' + ((j.sources||[]).length) + ' sources retrieved)'
|
||
: j.status === 'retrieving' ? 'searching the index…' : 'starting…');
|
||
setTimeout(() => poll(token, started), POLL_MS);
|
||
}).catch(e => { addError('Poll failed: ' + e); finish(); });
|
||
}
|
||
|
||
// ── Source viewer ───────────────────────────────────────────────────────
|
||
window.vvAiOpen = function (path) {
|
||
$('vv-ai-view-t').textContent = path;
|
||
$('vv-ai-view-b').textContent = 'Loading…';
|
||
$('vv-ai-view').classList.add('open');
|
||
fetch('/plugins/varaverk/api/readscript.php?id=' + encodeURIComponent(path))
|
||
.then(r => r.json())
|
||
.then(d => { $('vv-ai-view-b').textContent = d.ok ? d.content
|
||
: (d.error || 'Could not read this file.'); })
|
||
.catch(e => { $('vv-ai-view-b').textContent = 'Could not read this file: ' + e; });
|
||
};
|
||
window.vvAiCloseView = function () { $('vv-ai-view').classList.remove('open'); };
|
||
window.vvAiCite = function (n) {
|
||
const s = lastSources[n - 1];
|
||
if (s && s.path) vvAiOpen(s.path);
|
||
};
|
||
|
||
// ── Profiles ────────────────────────────────────────────────────────────
|
||
// Switching clears the conversation history sent to the model but leaves the transcript on
|
||
// screen. Carrying turns across a profile change would mean feeding cited, retrieval-grounded
|
||
// answers into a mode that has no retrieval — the model would keep referring to sources it can
|
||
// no longer see. The visible marker is so the transcript still reads honestly afterwards.
|
||
function setProfile(p) {
|
||
if (!PROFILES[p] || p === profile) return;
|
||
profile = p;
|
||
document.querySelectorAll('.vv-ai-prof').forEach(b =>
|
||
b.classList.toggle('active', b.dataset.prof === p));
|
||
$('vv-ai-prof-hint').textContent = PROFILES[p].hint;
|
||
$('vv-ai-kind').style.display = PROFILES[p].kind ? '' : 'none';
|
||
|
||
if (history.length) {
|
||
const label = document.querySelector('.vv-ai-prof[data-prof="' + p + '"]').textContent;
|
||
chat().appendChild(el('<div class="vv-ai-switch">switched to ' + esc(label)
|
||
+ ' — earlier turns are no longer carried</div>'));
|
||
scroll();
|
||
}
|
||
history = [];
|
||
$('vv-ai-input').focus();
|
||
}
|
||
document.querySelectorAll('.vv-ai-prof').forEach(b =>
|
||
b.addEventListener('click', () => setProfile(b.dataset.prof)));
|
||
$('vv-ai-prof-hint').textContent = PROFILES[profile].hint;
|
||
|
||
// ── 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 ──────────────────────────────────────────────────────────────
|
||
$('vv-ai-send').addEventListener('click', send);
|
||
$('vv-ai-input').addEventListener('keydown', e => {
|
||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); send(); }
|
||
});
|
||
$('vv-ai-clear').addEventListener('click', () => {
|
||
history = []; lastSources = [];
|
||
chat().innerHTML = '<div class="vv-ai-empty">Ask Varaverk about itself.<br>'
|
||
+ "Answers come only from this installation's own documentation, with sources.</div>";
|
||
});
|
||
document.addEventListener('keydown', e => { if (e.key === 'Escape') vvAiCloseView(); });
|
||
|
||
// Surface any script error on this tab into the transcript. Without it a throw anywhere in
|
||
// the page is invisible unless the console happens to be open, which is how a silent hang
|
||
// survives a diagnosis session.
|
||
window.addEventListener('error', e => {
|
||
if (!busy) return;
|
||
addError('Script error: ' + (e.message || 'unknown')
|
||
+ (e.filename ? ' (' + e.filename.split('/').pop() + ':' + e.lineno + ')' : ''));
|
||
finish();
|
||
});
|
||
window.addEventListener('unhandledrejection', e => {
|
||
if (!busy) return;
|
||
addError('Unhandled rejection: ' + (e.reason && e.reason.message ? e.reason.message : e.reason));
|
||
finish();
|
||
});
|
||
|
||
// 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, and stops a stale closure's busy flag from
|
||
// being the thing the user is actually looking at.
|
||
if (window.__vvAiTeardown) { try { window.__vvAiTeardown(); } catch (e) {} }
|
||
const bannerTimer = setInterval(loadBanner, 30000);
|
||
window.__vvAiTeardown = function () { clearInterval(bannerTimer); clearInterval(pendingTimer); };
|
||
|
||
loadBanner();
|
||
loadTokens();
|
||
})();
|
||
</script>
|