Files
Varaverk/Plugin/unraid/pages/ai.php
T
Gmer4Lfe 264ba57cbb Add the AI tab — grounded chat over the documentation index
Token and poll rather than SSE, so the api layer keeps one response
convention and reuses the pattern manual_sync already proved. History is
capped at three turns because the model is only fully offloaded at 16384
context and unbounded history would cross that silently. The tab exists
only while AI_ENABLED is true, rejected server-side and not merely hidden.
2026-08-02 17:32:17 -04:00

401 lines
21 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; }
.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-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;
}
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();
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');
fetch(API, { method: 'POST', body: fd }).then(r => r.json()).then(d => {
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); 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(); });
loadBanner();
setInterval(loadBanner, 30000);
})();
</script>