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
+713
View File
@@ -0,0 +1,713 @@
<?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE
// The conversation surface itself — profile bar, transcript, composer, source viewer and the
// stored-chat list — rendered wherever a full chat belongs. Currently the AI tab and the
// Monitor tab's AI row.
//
// WHY THIS IS AN INCLUDE
// There were two places that needed a real transcript and one implementation, which meant the
// second one was going to be a copy. Everything subtle here — the busy flag that must never
// stick, URLSearchParams instead of FormData, the poll ceiling, escaping before markdown, the
// teardown of a stale instance across an Unraid tab swap — is subtle because each of them
// already cost a diagnosis session once. A copy inherits none of the fixes that come after it.
//
// The Scheduler tab's dock is deliberately NOT built on this. It is a one-line bar that follows
// the view you have open, with its own scope chip, fix flow and incident capture; it is a
// different component that happens to talk to the same endpoint. Folding it in here would mean
// one widget with two personalities and a mode flag deciding which.
//
// INSTANCES
// Every id is composed from a prefix, so two chats can coexist on one page. Each instance
// registers under window.__vvAiChat[prefix] and tears down the previous holder of that prefix
// on construction — Unraid swaps tab content by AJAX without unloading the old page's
// JavaScript, so the previous copy's timers are still running when the new one arrives.
//
// DESIGN PRINCIPLES
// The transcript and what the model is sent are not the same list.
// messages[] is everything on screen and everything stored. sendFrom is the index the
// model is allowed to see from. Switching profile moves sendFrom to the end rather than
// emptying the transcript: carrying cited, retrieval-grounded turns into a mode with no
// retrieval makes the model refer to sources it can no longer see, but erasing what the
// operator just read to achieve that is worse.
//
// Citations and sources are wired by delegation, not by onclick.
// Both carry a file path that came from the index. Interpolating one into an onclick
// attribute means a path containing a quote executes; data attributes plus one listener on
// the transcript removes the whole class rather than escaping around it.
//
// Storage is automatic and capped.
// A conversation saves itself when a turn completes and the oldest drops off past
// AI_CHAT_HISTORY_MAX. There is no Save button, because a chat worth keeping is not
// reliably one you knew was worth keeping while you were having it.
//
// OPERATIONAL SAFEGUARDS
// Every rendered string is escaped, and the minimal markdown pass runs strictly afterwards, so
// no model or file derived input can introduce markup.
//
// Every failure path ends in a visible message in the transcript. A hang is the one failure
// that reports nothing, and this endpoint has produced one: a multipart POST that left the
// browser and never reached PHP, with no status, no fatal and no entry in any log.
//
// Read-only with respect to the system. Nothing here runs a script or edits conf.
//
// EXPORTS
// vv_ai_chat_assets() styles + the VvAiChat / VvAiChatList factories, once per page
// vv_ai_chat_markup($prefix,$o) profile bar, transcript, composer for one instance
// vv_ai_chat_list_markup($prefix) the stored-conversations list container
//
// DEPENDS ON
// api/ai.php ask / poll / clear / chats / chat_get / chat_save / chat_delete
// api/readscript.php source viewer contents
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// Emitted once even if two instances are rendered. A second copy of the script would re-register
// the factories harmlessly but would also install a second Escape handler and a second copy of
// every keyframe, so the guard is cheaper than reasoning about whether it matters.
function vv_ai_chat_assets(): void {
static $done = false;
if ($done) return;
$done = true;
?>
<style>
/* ── Shared tone ─────────────────────────────────────────────────────────── */
.vv-ai-ok { color:#6fcf97 !important; }
.vv-ai-warn { color:#ffb74d !important; }
.vv-ai-bad { color:#e57 !important; }
.vv-ai-none { font-size:11px; color:#3a3a3a; font-style:italic; }
.vv-ai-btn { background:#152238; border:1px solid #2d4a6a; color:#8ab; font-size:12px; padding:5px 14px;
border-radius:3px; cursor:pointer; font-family:inherit; }
.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-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-toggle { font-size:11px; color:#6a6a6a; display:flex; align-items:center; gap:5px; cursor:pointer; }
/* ── 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; }
/* ── Transcript ─────────────────────────────────────────────────────────── */
.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; }
.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-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; }
/* ── Compact form, for a chat living inside a Monitor card ──────────────── */
/* Not a different component — the same markup with the chrome pulled in. The card supplies its
own heading and border, so the transcript drops its own and the hint text goes away rather
than wrapping to three lines at this width. */
.vv-ai-c .vv-ai-chat { border:none; border-radius:0; padding:10px 2px; min-height:0; }
.vv-ai-c .vv-ai-empty { padding:26px 14px; }
.vv-ai-c .vv-ai-composer { border:none; padding:8px 0 0; background:none; }
.vv-ai-c .vv-ai-input { min-height:44px; font-size:12px; padding:7px; }
.vv-ai-c .vv-ai-prof { padding:3px 9px; font-size:10px; }
.vv-ai-c .vv-ai-prof-hint { display:none; }
.vv-ai-c .vv-ai-hint { display:none; }
.vv-ai-c .vv-ai-btn { padding:4px 11px; font-size:11px; }
.vv-ai-c .vv-ai-msg { margin-bottom:12px; }
/* ── Stored conversations ───────────────────────────────────────────────── */
.vv-ai-clist { display:flex; flex-direction:column; gap:1px; }
.vv-ai-crow { display:flex; align-items:baseline; gap:8px; padding:5px 7px; border-radius:4px;
cursor:pointer; border:1px solid transparent; }
.vv-ai-crow:hover { background:#141414; }
.vv-ai-crow.active { background:#14202c; border-color:#2d4a6a; }
.vv-ai-crow-t { font-size:11px; color:#8a8a8a; overflow:hidden; text-overflow:ellipsis;
white-space:nowrap; flex:1; min-width:0; }
.vv-ai-crow.active .vv-ai-crow-t { color:#9bd; }
.vv-ai-crow-m { font-size:9px; color:#3a3a3a; font-family:monospace; flex-shrink:0; }
.vv-ai-crow-x { font-size:11px; color:#333; flex-shrink:0; padding:0 2px; visibility:hidden; }
.vv-ai-crow:hover .vv-ai-crow-x { visibility:visible; }
.vv-ai-crow-x:hover { color:#e57; }
.vv-ai-chead { display:flex; align-items:center; gap:8px; margin-bottom:5px; }
/* ── 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-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';
// Server-side is the authority on retrieval depth and history; these are for the UI only, and
// they must not drift from VV_AI_PROFILES in api/ai.php.
const PROFILES = {
varaverk: { label: 'Varaverk Assistant', turns: 3, kind: true,
hint: 'Answers only from Varaverk\'s own docs, with sources. Says so when they don\'t cover it.' },
chat: { label: 'General Chat', turns: 8, kind: false,
hint: 'Ordinary conversation. Hands anything about this install to the assistant on its own.' },
code: { label: 'Code Sketcher', turns: 4, kind: false,
hint: 'Drafts short scripts for Custom Scripts. First drafts — test before trusting.' },
};
window.VvAiProfiles = PROFILES;
const esc = s => String(s == null ? '' : s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/"/g,'&quot;').replace(/'/g,'&#39;');
const POST_HEAD = { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' };
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';
if (d < 86400) return Math.floor(d/3600)+'h';
return Math.floor(d/86400)+'d';
}
// ── Source viewer, one per page ─────────────────────────────────────────
const $g = id => document.getElementById(id);
window.vvAiOpen = function (path) {
$g('vv-ai-view-t').textContent = path;
$g('vv-ai-view-b').textContent = 'Loading…';
$g('vv-ai-view').classList.add('open');
fetch('/plugins/varaverk/api/readscript.php?id=' + encodeURIComponent(path))
.then(r => r.json())
.then(d => { $g('vv-ai-view-b').textContent = d.ok ? d.content
: (d.error || 'Could not read this file.'); })
.catch(e => { $g('vv-ai-view-b').textContent = 'Could not read this file: ' + e; });
};
window.vvAiCloseView = function () { $g('vv-ai-view').classList.remove('open'); };
document.addEventListener('keydown', e => { if (e.key === 'Escape') vvAiCloseView(); });
// ── Chat instance ───────────────────────────────────────────────────────
window.__vvAiChat = window.__vvAiChat || {};
window.VvAiChat = function (o) {
const P = o.prefix;
const $ = sfx => document.getElementById(P + '-' + sfx);
const onTurn = o.onTurn || function () {};
const onChats = o.onChats || function () {};
const store = o.chats !== false;
const POLL_MS = 1200;
const POLL_CEIL = 300000; // stop polling a worker that never wrote a terminal state
// An instance already holding this prefix is a leftover from a tab swap, still holding
// timers and a busy flag over DOM nodes that no longer exist.
if (window.__vvAiChat[P]) { try { window.__vvAiChat[P].teardown(); } catch (e) {} }
let profile = PROFILES[o.profile] ? o.profile : 'varaverk';
let messages = []; // whole transcript — displayed and stored
let sendFrom = 0; // index the model is allowed to see from
let chatId = ''; // '' until the store mints one
let busy = false;
let lastSources = [];
let pendingTimer = null;
const chatEl = () => $('chat');
const scroll = () => { const c = chatEl(); c.scrollTop = c.scrollHeight; };
function el(html) { const d = document.createElement('div'); d.innerHTML = html; return d.firstElementChild; }
function clearEmpty() { const e = chatEl().querySelector('.vv-ai-empty'); if (e) e.remove(); }
// ── 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" data-cite="$1">[$1]</span>');
return h;
}
// ── Transcript ───────────────────────────────────────────────────────
function addUser(text) {
clearEmpty();
chatEl().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="${P}-pending"><div class="vv-ai-role">Varaverk</div>`
+ `<div class="vv-ai-pending"><span class="vv-ai-dot"></span>`
+ `<span id="${P}-phase">starting…</span>`
+ `<span id="${P}-elapsed" style="color:#333;font-family:monospace"></span></div></div>`);
chatEl().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 = $('elapsed');
if (!e) { clearInterval(pendingTimer); return; }
e.textContent = Math.round((Date.now() - t0) / 1000) + 's';
}, 1000);
}
function phase(t) { const p = $('phase'); if (p) p.textContent = t; }
// Paths ride in data attributes rather than an onclick. They come out of the index, and a
// path carrying a quote interpolated into an attribute is code execution, not a display bug.
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" data-src="${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 = $('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" data-think="1">`
+ `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 whatever prose warning it may 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>`;
}
chatEl().appendChild(el(h + '</div>')); scroll();
}
function addError(msg) {
const p = $('pending'); if (p) p.remove();
chatEl().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();
}
// One listener for the whole transcript, covering nodes that do not exist yet.
chatEl().addEventListener('click', e => {
const think = e.target.closest('.vv-ai-think-t');
if (think) { think.nextElementSibling.classList.toggle('open'); return; }
const src = e.target.closest('.vv-ai-src-i');
if (src && src.dataset.src) { vvAiOpen(src.dataset.src); return; }
const cite = e.target.closest('.vv-ai-cite');
if (cite) {
const s = lastSources[Number(cite.dataset.cite) - 1];
if (s && s.path) vvAiOpen(s.path);
}
});
// ── 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.
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 = $('input').value.trim();
if (!q) return;
busy = true;
$('send').disabled = true;
addUser(q);
$('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.
// URLSearchParams, not FormData. A multipart POST to this endpoint hangs with no status
// 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.
const kindEl = o.kindEl ? document.getElementById(o.kindEl) : null;
const thinkEl = o.thinkEl ? document.getElementById(o.thinkEl) : null;
let res;
try {
const body = new URLSearchParams({
action: 'ask',
profile: profile,
question: q,
history: JSON.stringify(sendable()),
kind: (PROFILES[profile].kind && kindEl) ? kindEl.value : '',
think: (thinkEl ? thinkEl.checked : true) ? '1' : '0',
});
res = fetch(API, { method: 'POST', headers: POST_HEAD, 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; }
messages.push({ role: 'user', content: q });
poll(d.token, Date.now());
})
.catch(e => { addError('Request failed: ' + (e && e.message ? e.message : e)); finish(); });
}
// What the model may see: never across a profile switch, and never deeper than this
// profile's window. api/ai.php trims again regardless — this is the client half of a cap
// that exists in both places on purpose.
function sendable() {
const floor = Math.max(sendFrom, messages.length - PROFILES[profile].turns * 2);
return messages.slice(floor);
}
function finish() {
busy = false;
const b = $('send'); if (b) b.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);
messages.push({ role: 'assistant', content: j.answer });
fetch(API, { method: 'POST', headers: POST_HEAD,
body: new URLSearchParams({ action: 'clear', token }) }).catch(() => {});
finish();
save();
onTurn();
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(); });
}
// ── Storage ──────────────────────────────────────────────────────────
// Fire and forget, deliberately. A store that cannot be written is not a reason to lose the
// answer that is already on screen, and the list refresh below reveals the failure anyway.
function save() {
if (!store || !messages.length) return;
fetch(API, { method: 'POST', headers: POST_HEAD,
body: new URLSearchParams({
action: 'chat_save', id: chatId, profile,
messages: JSON.stringify(messages),
}) })
.then(r => r.json())
.then(d => { if (d.ok) { chatId = d.id; onChats(chatId); } })
.catch(() => {});
}
// A reopened conversation renders as plain turns. Sources, reasoning and timings are not
// stored: they describe one generation, and redrawing them beside a transcript that may be
// continued under a different profile would be citing evidence for an answer that is no
// longer being made.
function render() {
const c = chatEl();
if (!messages.length) { reset(); return; }
c.innerHTML = '';
messages.forEach(m => {
if (m.role === 'user') { addUser(m.content); return; }
c.appendChild(el(`<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`
+ `<div class="vv-ai-body">${fmt(m.content)}</div></div>`));
});
scroll();
}
function reset() {
chatEl().innerHTML = `<div class="vv-ai-empty">${esc(o.empty || 'Ask Varaverk about itself.')}</div>`;
}
function loadChat(id) {
fetch(API + '?action=chat_get&id=' + encodeURIComponent(id)).then(r => r.json()).then(d => {
if (!d.ok) { addError(d.error || 'Could not open that conversation'); return; }
const c = d.chat || {};
messages = Array.isArray(c.messages) ? c.messages : [];
// Zero, not the length: reopening is meant to continue the thread, and a floor at the
// end would send the model an empty history for a transcript full of context.
sendFrom = 0;
chatId = c.id || '';
lastSources = [];
if (PROFILES[c.profile]) applyProfile(c.profile);
render();
onChats(chatId);
}).catch(e => addError('Could not open that conversation: ' + e));
}
function newChat() {
messages = []; sendFrom = 0; chatId = ''; lastSources = [];
reset();
onChats('');
}
// ── Profiles ─────────────────────────────────────────────────────────
function applyProfile(p) {
profile = p;
const bar = $('profiles');
if (bar) bar.querySelectorAll('.vv-ai-prof').forEach(b =>
b.classList.toggle('active', b.dataset.prof === p));
const hint = $('prof-hint');
if (hint) hint.textContent = PROFILES[p].hint;
const kindEl = o.kindEl ? document.getElementById(o.kindEl) : null;
if (kindEl) kindEl.style.display = PROFILES[p].kind ? '' : 'none';
}
// Switching moves the floor rather than clearing the transcript. Carrying cited,
// retrieval-grounded turns into a mode with no retrieval makes the model keep referring to
// sources it can no longer see; erasing what the operator just read to avoid that is worse.
function setProfile(p) {
if (!PROFILES[p] || p === profile) return;
applyProfile(p);
if (messages.length > sendFrom) {
chatEl().appendChild(el('<div class="vv-ai-switch">switched to ' + esc(PROFILES[p].label)
+ ' — earlier turns are no longer carried</div>'));
scroll();
}
sendFrom = messages.length;
$('input').focus();
}
const bar = $('profiles');
if (bar) bar.addEventListener('click', e => {
const b = e.target.closest('.vv-ai-prof');
if (b) setProfile(b.dataset.prof);
});
// ── Wiring ───────────────────────────────────────────────────────────
$('send').addEventListener('click', send);
$('input').addEventListener('keydown', e => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); send(); }
});
const newBtn = $('new'); if (newBtn) newBtn.addEventListener('click', newChat);
// Surface any script error into the transcript. Without it a throw anywhere on the page is
// invisible unless the console happens to be open, which is how a silent hang survives a
// diagnosis session.
const onErr = e => {
if (!busy) return;
addError('Script error: ' + (e.message || 'unknown')
+ (e.filename ? ' (' + e.filename.split('/').pop() + ':' + e.lineno + ')' : ''));
finish();
};
const onRej = e => {
if (!busy) return;
addError('Unhandled rejection: ' + (e.reason && e.reason.message ? e.reason.message : e.reason));
finish();
};
window.addEventListener('error', onErr);
window.addEventListener('unhandledrejection', onRej);
applyProfile(profile);
const inst = {
prefix: P,
setProfile, newChat, loadChat, send,
currentId: () => chatId,
teardown() {
clearInterval(pendingTimer);
window.removeEventListener('error', onErr);
window.removeEventListener('unhandledrejection', onRej);
if (window.__vvAiChat[P] === inst) delete window.__vvAiChat[P];
},
};
window.__vvAiChat[P] = inst;
return inst;
};
// ── Stored conversations list ───────────────────────────────────────────
// Rendered wherever it is wanted and pointed at a chat instance. Both surfaces show the same
// rows off the same store, so opening a thread on Monitor and finishing it on the AI tab is
// one conversation rather than two.
window.VvAiChatList = function (o) {
const box = document.getElementById(o.into);
if (!box) return null;
let rows = [], activeId = '';
function render() {
if (!rows.length) {
box.innerHTML = '<div class="vv-ai-none">no saved conversations yet</div>';
return;
}
box.innerHTML = '<div class="vv-ai-clist">' + rows.map(c =>
`<div class="vv-ai-crow${c.id === activeId ? ' active' : ''}" data-id="${esc(c.id)}">`
+ `<span class="vv-ai-crow-t" title="${esc(c.title)}">${esc(c.title)}</span>`
+ `<span class="vv-ai-crow-m">${esc(ago(c.ts))}</span>`
+ `<span class="vv-ai-crow-x" data-del="${esc(c.id)}" title="Delete">×</span>`
+ `</div>`).join('') + '</div>';
}
function load() {
fetch(API + '?action=chats').then(r => r.json())
.then(d => { if (d.ok) { rows = d.chats || []; render(); } })
.catch(() => {});
}
box.addEventListener('click', e => {
const del = e.target.closest('[data-del]');
if (del) {
e.stopPropagation();
fetch(API, { method: 'POST', headers: POST_HEAD,
body: new URLSearchParams({ action: 'chat_delete', id: del.dataset.del }) })
.then(() => {
// Deleting the conversation you are in leaves the transcript on screen but detached
// from the store, which would silently resurrect it on the next turn.
if (del.dataset.del === activeId && o.chat) o.chat.newChat();
load();
}).catch(() => {});
return;
}
const row = e.target.closest('.vv-ai-crow');
if (row && o.chat) { activeId = row.dataset.id; render(); o.chat.loadChat(row.dataset.id); }
});
return {
reload: load,
setActive(id) { activeId = id; load(); },
};
};
})();
</script>
<?php
}
// One instance's markup. The prefix composes every id, so a page may render more than one.
// profile which profile button starts active
// compact card-sized chrome, for a chat living inside a Monitor card
// height transcript height; a fixed value in compact mode, since a card in a grid row
// cannot grow with its content without dragging the row's other cards with it
// empty empty-state text
// controls extra markup dropped into the composer's control strip
function vv_ai_chat_markup(string $prefix, array $o = []): void {
$p = htmlspecialchars($prefix, ENT_QUOTES);
$compact = !empty($o['compact']);
$height = $o['height'] ?? '';
$empty = $o['empty'] ?? 'Ask Varaverk about itself.';
$style = $height !== '' ? ' style="height:' . htmlspecialchars($height, ENT_QUOTES)
. ';max-height:none;"' : '';
?>
<div class="vv-ai-chatwrap<?= $compact ? ' vv-ai-c' : '' ?>" style="display:flex;flex-direction:column;gap:<?= $compact ? '4px' : '12px' ?>;min-width:0;">
<div class="vv-ai-profiles" id="<?= $p ?>-profiles">
<?php foreach (['varaverk' => 'Varaverk Assistant', 'chat' => 'General Chat',
'code' => 'Code Sketcher'] as $key => $label): ?>
<button class="vv-ai-prof" data-prof="<?= $key ?>" type="button"><?= $label ?></button>
<?php endforeach; ?>
<span class="vv-ai-prof-hint" id="<?= $p ?>-prof-hint"></span>
</div>
<div class="vv-ai-chat" id="<?= $p ?>-chat"<?= $style ?>>
<div class="vv-ai-empty"><?= htmlspecialchars($empty) ?></div>
</div>
<div class="vv-ai-composer">
<textarea class="vv-ai-input" id="<?= $p ?>-input" rows="<?= $compact ? 1 : 2 ?>"
placeholder="<?= htmlspecialchars($o['placeholder'] ?? 'Ask anything — questions about this install route to the assistant on their own.', ENT_QUOTES) ?>"></textarea>
<div class="vv-ai-ctrls">
<?= $o['controls'] ?? '' ?>
<button class="vv-ai-btn ghost" id="<?= $p ?>-new" type="button">New</button>
<span class="vv-ai-hint">Ctrl+Enter to send</span>
<button class="vv-ai-btn" id="<?= $p ?>-send" type="button">Ask</button>
</div>
</div>
</div>
<?php
}
// The stored-conversations container. Rendered separately from the chat because the two live in
// different cards on Monitor and in different parts of the column on the AI tab.
function vv_ai_chat_list_markup(string $prefix): void {
$p = htmlspecialchars($prefix, ENT_QUOTES);
?>
<div id="<?= $p ?>-chats"><div class="vv-ai-none">loading…</div></div>
<?php
}