Files
Varaverk/Plugin/unraid/pages/ai.php
T
Gmer4Lfe e0925b9d38 Make a failed AI send visible instead of hanging
GET reaches the endpoint and POST does not, with nothing in the request log,
no CSRF termination and no PHP error — so it fails in the browser before the
request goes out, and the only symptom was the pending indicator sitting
there. Reads the response as text before parsing so an empty body reports as
rejected-before-execution rather than a JSON error, wraps the synchronous
path, and surfaces script errors into the transcript.
2026-08-02 18:00:53 -04:00

515 lines
27 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// AI tab. Asks Varaverk about itself — a grounded chat over the documentation index, with a
// status banner covering index health, model residency and GPU state.
//
// OPERATIONAL MODEL
// Token and poll, not streaming. A turn takes 25-76 seconds, so the composer POSTs to
// api/ai.php, receives a token, and polls until the job reaches done or error. That keeps
// the api layer on one response convention; see api/ai.php for why SSE was declined.
//
// The tab is only reachable when AI_ENABLED is true. Varaverk.page omits it from the tab
// list and rejects it server-side, and api/ai.php refuses ask independently — hiding a link
// is not access control.
//
// 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
// ═══════════════════════════════════════════════════════════════════════════════════════════════
?>
<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; }
/* ── 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-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 ─────────────────────────────────────────────── */
.vv-ai-diag { display:grid; grid-template-columns:1fr 1fr; 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; }
.vv-ai-chk { display:flex; align-items:flex-start; gap:7px; font-size:11px; padding:2px 0; line-height:1.5; }
.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-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-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-clear" type="button">Clear</button>
<span class="vv-ai-hint">Ctrl+Enter to send · history capped at 3 turns</span>
<button class="vv-ai-btn" id="vv-ai-send" type="button">Ask</button>
</div>
</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 MAXTURN = 3;
const POLL_MS = 1200;
const POLL_CEIL = 300000; // stop polling a worker that never wrote a terminal state
let history = []; // {role, content} — trimmed to MAXTURN pairs
let busy = false;
let lastSources = [];
const $ = id => document.getElementById(id);
const esc = s => String(s == null ? '' : s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
// ── 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;
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(() => {});
}
// ── 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();
}
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></div></div>`);
chat().appendChild(n); scroll();
}
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>`;
}
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() {
if (busy) 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.
let res;
try {
const fd = new FormData();
fd.append('action', 'ask');
fd.append('question', q);
fd.append('history', JSON.stringify(history.slice(-MAXTURN * 2)));
fd.append('kind', $('vv-ai-kind').value);
fd.append('think', $('vv-ai-think').checked ? '1' : '0');
res = fetch(API, { method: 'POST', body: fd });
} 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; }
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 });
if (history.length > MAXTURN * 2) history = history.slice(-MAXTURN * 2);
const fd = new FormData(); fd.append('action','clear'); fd.append('token',token);
fetch(API, { method:'POST', body: fd }).catch(() => {});
finish(); loadBanner(); 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);
};
// ── 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();
});
loadBanner();
setInterval(loadBanner, 30000);
})();
</script>