Move the chat into an include, because the second one was going to be a copy

This commit is contained in:
Gmer4Lfe
2026-08-08 22:01:06 -04:00
parent 1e324636e0
commit 4d00e37ca1
2 changed files with 768 additions and 394 deletions
+55 -394
View File
@@ -56,9 +56,11 @@
// source viewer overlay
//
// DEPENDS ON
// api/ai.php stats / ask / poll / clear
// include/ai_chat.php the shared conversation surface, also used by the Monitor tab's AI row
// api/ai.php stats / tokens / bugs / ask / poll / clear / chats
// api/readscript.php source viewer contents
// ═══════════════════════════════════════════════════════════════════════════════════════════════
require_once dirname(__DIR__) . '/include/ai_chat.php';
// Build stamp. The tab bar uses Unraid's localURL, which swaps content by AJAX without tearing
// down the previous page's JavaScript — so a stale copy of this script can keep running, and
@@ -81,56 +83,9 @@ if (is_dir('/var/log/varaverk')) {
.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; }
/* 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.
@@ -199,18 +154,6 @@ if (is_dir('/var/log/varaverk')) {
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; }
.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; }
/* ── 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.
@@ -231,27 +174,8 @@ if (is_dir('/var/log/varaverk')) {
.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; }
.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>
<?php vv_ai_chat_assets(); ?>
<div id="vv-ai-wrap">
@@ -295,31 +219,34 @@ if (is_dir('/var/log/varaverk')) {
</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.
<!-- 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>
<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">
<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>
<?php
vv_ai_chat_markup('vv-ai', [
'profile' => 'varaverk',
'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?',
// The build stamp and the liveness marker stay on this tab. 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 — the marker answers it from the
// browser, and it is where every deploy on this tab gets checked.
'controls' => '<button class="vv-ai-btn ghost" id="vv-ai-mem" type="button">Memory</button>'
. '<span class="vv-ai-hint">build ' . $_vv_ai_build
. ' <span id="vv-ai-live" style="color:#e57">· JS NOT RUNNING</span></span>',
]);
?>
<!-- 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
@@ -376,42 +303,19 @@ if (is_dir('/var/log/varaverk')) {
</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
const API = '/plugins/varaverk/api/ai.php';
// 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 = [];
// The conversation itself — profiles, transcript, composer, source viewer, storage — is
// include/ai_chat.php. What remains on this page is everything that surrounds it and exists
// only here: the banner, the token ledger, filed bug reports and the memory editor.
let chat = null, chatList = null;
const $ = id => document.getElementById(id);
const esc = s => String(s == null ? '' : s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
// ── Banner ──────────────────────────────────────────────────────────────
function stat(label, value, sub, cls) {
@@ -714,240 +618,6 @@ if (is_dir('/var/log/varaverk')) {
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.
@@ -993,31 +663,21 @@ if (is_dir('/var/log/varaverk')) {
$('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(); }
// The full-size instance. onTurn is why the banner and the ledger stay current without either
// of them polling for it: the totals only move when a turn completes, and the chat is the
// thing that knows when that was.
chat = VvAiChat({
prefix: 'vv-ai',
profile: 'varaverk', // the strict profile is the one you land on
kindEl: 'vv-ai-kind',
thinkEl: 'vv-ai-think',
empty: "Ask Varaverk about itself. Answers come only from this installation's own "
+ 'documentation, with sources.',
onTurn: () => { loadBanner(); loadTokens(); },
onChats: id => { if (chatList) chatList.setActive(id); },
});
$('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();
});
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
@@ -1026,14 +686,15 @@ if (is_dir('/var/log/varaverk')) {
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.
// one down stops N banners polling in parallel. The chat instance tears itself down the same
// way, keyed on its prefix, so it is not handled here.
if (window.__vvAiTeardown) { try { window.__vvAiTeardown(); } catch (e) {} }
const bannerTimer = setInterval(loadBanner, 30000);
window.__vvAiTeardown = function () { clearInterval(bannerTimer); clearInterval(pendingTimer); };
window.__vvAiTeardown = function () { clearInterval(bannerTimer); };
loadBanner();
loadTokens();
loadBugs();
chatList.reload();
})();
</script>