Files
Varaverk/Plugin/unraid/include/ai_chat.php
T
Gmer4Lfe 44cf4e7516 Open the dashboard chat collapsed, every time
A size remembered from days ago would push the cards below it down on every load, with nothing
on the page to explain why.
2026-08-09 10:38:21 -04:00

926 lines
48 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
// 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
// include/ai_profiles.php the profile registry, served to the browser rather than restated
// api/ai.php ask / poll / clear / chats / chat_get / chat_save / chat_delete
// api/readscript.php source viewer contents
// ═══════════════════════════════════════════════════════════════════════════════════════════════
// Required directly, not assumed. The Monitor tab pulls this file in without include/ai.php,
// so relying on something else having loaded the registry first works on the AI tab and fatals
// on the dashboard.
require_once __DIR__ . '/ai_profiles.php';
// The conversation store, on its own, with no styling and no chat widget attached.
//
// Emitted separately because the surface that most needs it is the one that does not want the
// rest: the Scheduler dock draws its own one-line bar, with its own scope chip and fix flow, and
// pulling in the full transcript stylesheet to reach a save function would restyle a component
// that was deliberately built to look different.
//
// Where a conversation lives is not a presentation decision, so it gets one answer for every
// surface. Anything added to the store — a new field, a new cap, a changed prune rule — arrives
// everywhere at once because there is only one writer.
function vv_ai_chat_store_script(): void {
static $done = false;
if ($done) return;
$done = true;
?>
<script>
(function () {
const API = '/plugins/varaverk/api/ai.php';
// Returns the id so a caller can keep appending to one conversation instead of minting a row
// per turn. Fire and forget on failure: a store that cannot be written is not a reason to lose
// the answer already on screen.
window.VvAiChatSave = function (msgs, profile, scope, id) {
if (!msgs || !msgs.length) return Promise.resolve(null);
return fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({
action: 'chat_save', id: id || '', profile: profile || 'chat',
scope: scope || '', messages: JSON.stringify(msgs),
}),
})
.then(r => r.json())
.then(d => (d && d.ok) ? d.id : null)
.catch(() => null);
};
})();
</script>
<?php
}
// 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;
vv_ai_chat_store_script();
?>
<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; }
/* Profile picker styling lives in css/varaverk.css — the Scheduler dock uses the same control
and loads none of this stylesheet. */
.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 the
heading and the border, so the transcript drops its own and the hint text goes away rather
than wrapping to three lines at this width.
Surfaces are re-based on the card, not merely un-bordered. The standalone palette paints a
#0b0b0b transcript because on the AI tab it sits on the page ground with nothing beside it
to compare against; dropped into a .vv-card (#1e1e1e) that same fill reads as a hole cut in
the card rather than as part of it. Transparent here, and the remaining controls move to the
values the plugin already uses inside a card — .vv-btn-sm is #2a2a2a on #555 — so the whole
thing reads as one object.
This is the second hardcoded dark palette in the plugin, which is exactly one too many. When
theming happens it wants tokens (--vv-surface, --vv-line) defined once in varaverk.css, and
this block becomes a token swap instead of a second set of literals. */
.vv-ai-c .vv-ai-chat { border:none; border-radius:0; padding:10px 2px; min-height:0;
background:transparent; }
.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;
background:#161616; border-color:#333; }
.vv-ai-c .vv-ai-input:focus { border-color:#6495ed; }
.vv-ai-c .vv-ai-prof-hint { display:none; }
/* Styled after the Scheduler dock: 12px, tighter leading, questions marked with a chevron rather
than a role label. The role labels cost a line each and say the same two things forever — on a
card this size that is a third of the visible transcript spent on "You" and "Varaverk". */
.vv-ai-c .vv-ai-role { display:none; }
.vv-ai-c .vv-ai-body { font-size:12px; line-height:1.55; }
.vv-ai-c .vv-ai-msg.user .vv-ai-body { color:#8fb0c4; }
.vv-ai-c .vv-ai-msg.user .vv-ai-body::before { content:' '; color:#3a5a6a; }
.vv-ai-c .vv-ai-msg.user { margin-bottom:5px; }
.vv-ai-c .vv-ai-msg.bot { margin-bottom:11px; }
.vv-ai-c .vv-ai-body code { background:#1a1a1a; border:1px solid #333; border-radius:2px;
padding:0 4px; font-size:11px; color:#9ab; }
.vv-ai-c .vv-ai-body strong { color:#ddd; }
.vv-ai-c .vv-ai-src { font-size:10px; color:#4a4a4a; font-family:monospace; }
.vv-ai-c .vv-ai-src-h { display:none; }
.vv-ai-c .vv-ai-meta { font-size:9px; }
.vv-ai-c .vv-ai-think-t { font-size:9px; padding:1px 6px; }
.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-btn.ghost { border-color:#555; color:#ccc; }
.vv-ai-c .vv-ai-msg { margin-bottom:12px; }
.vv-ai-c .vv-ai-think { background:#161616; border-left-color:#3a3a3a; }
.vv-ai-c .vv-ai-body pre { background:#161616; border-color:#3a3a3a; }
.vv-ai-c .vv-ai-body code { background:#2a2a2a; }
.vv-ai-c .vv-ai-src { border-top-color:#333; }
.vv-ai-c .vv-ai-switch { border-top-color:#333; }
/* ── 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-chat { transition:height .14s ease; }
.vv-ai-grow { font-size:13px !important; line-height:1; padding:4px 9px !important; }
.vv-ai-grow-on { color:#9bd !important; border-color:#2d4a6a !important; }
.vv-ai-crow-s { font-size:9px; color:#5c7cfa; font-family:monospace; }
.vv-ai-c .vv-ai-crow-s { color:#7a9ae0; }
.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; }
/* Hover lifts off the card rather than sinking into it. #141414 is a highlight against the AI
tab's #0e0e0e panel and a shadow against a #1e1e1e card — the same value reads as the
opposite gesture depending on what it sits on. */
.vv-ai-c .vv-ai-crow:hover { background:#2a2a2a; }
.vv-ai-c .vv-ai-crow-t { color:#ccc; }
.vv-ai-c .vv-ai-crow-m { color:#666; }
.vv-ai-c .vv-ai-crow-x { color:#666; }
.vv-ai-c .vv-ai-none { color:#666; }
.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>
<?php
// The profile registry, served rather than restated. This used to be a literal table in the
// script below that had already drifted from the PHP — it knew three profiles where the server
// knew four, which is why troubleshoot could not be offered here and the Scheduler dock had to
// hand-roll its own labels.
vv_ai_profiles_script();
?>
<script>
(function () {
const API = '/plugins/varaverk/api/ai.php';
// The server is the authority. It re-derives depth and capability on every request regardless
// of what is here — these values draw buttons and decide whether to show the kind filter, they
// do not make policy.
const PROFILES = window.VvAiProfiles;
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,
scope: (typeof o.scope === 'function' ? o.scope() : (o.scope || '')),
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 chipL = $('chip-l');
if (chipL) chipL.textContent = PROFILES[p] ? PROFILES[p].label : p;
const menu = $('menu');
if (menu) menu.querySelectorAll('.vv-ai-opt').forEach(b =>
b.classList.toggle('active', b.dataset.prof === p));
const hint = $('prof-hint');
if (hint) hint.textContent = PROFILES[p] ? PROFILES[p].hint : '';
const kindEl = o.kindEl ? document.getElementById(o.kindEl) : null;
if (kindEl) kindEl.style.display = (PROFILES[p] && 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();
}
// Picker. Closing on any outside click is registered once per instance and removed by
// teardown — a listener on document that outlives its menu is how a torn-down tab keeps
// reacting to clicks on the one that replaced it.
const picker = $('profiles') ? $('profiles').querySelector('.vv-ai-picker') : null;
const closeMenu = () => { if (picker) picker.classList.remove('open');
const c = $('chip'); if (c) c.setAttribute('aria-expanded', 'false'); };
const onDocClick = e => { if (picker && !picker.contains(e.target)) closeMenu(); };
if (picker) {
const chip = $('chip');
chip.addEventListener('click', e => {
e.stopPropagation();
const open = picker.classList.toggle('open');
chip.setAttribute('aria-expanded', open ? 'true' : 'false');
});
$('menu').addEventListener('click', e => {
const b = e.target.closest('.vv-ai-opt');
if (!b) return;
closeMenu();
setProfile(b.dataset.prof);
});
document.addEventListener('click', onDocClick);
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMenu(); });
}
// ── 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);
// ── Expand / collapse ────────────────────────────────────────────────
// Both heights come from the markup, so the page decides them and this only switches
// between them.
//
// Deliberately not remembered. The only instance with this control lives on the Monitor
// dashboard, which is a page you open to glance at something else — a chat left expanded
// days ago would push every card below it down on each load, and the cause would be
// invisible to whoever eventually wondered why. Expanding is for the reading being done
// now, so the page always opens collapsed.
//
// Scroll position is pinned to the bottom afterwards. Growing the box leaves the transcript
// scrolled where it was, which puts the newest answer off-screen at the exact moment you
// asked for more room to read it.
const growBtn = $('grow');
if (growBtn) {
const el = chatEl();
const base = el.dataset.h || '';
const tall = el.dataset.hTall || '';
const apply = big => {
if (!base || !tall) return;
el.style.height = big ? tall : base;
growBtn.textContent = big ? '⤡' : '⤢';
growBtn.title = big ? 'Back to the smaller view' : 'Give the conversation more room';
growBtn.classList.toggle('vv-ai-grow-on', big);
scroll();
};
growBtn.addEventListener('click', () => apply(el.style.height !== tall));
}
// 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);
// ── Resume ───────────────────────────────────────────────────────────
// Opens on the conversation last spoken to, rather than an empty box. Chosen by `updated`
// across the whole store, not per surface: the point of one store was that a thread started
// on the dashboard is the one you carry on in the tab, and resuming per-surface would give
// each of them its own idea of where you left off — which is the thing it was meant to stop.
//
// Silent on failure. Nothing here is worth an error message: the fallback is the empty box
// the operator would otherwise have got, and an empty box is a working chat.
if (store && o.resume !== false) {
fetch(API + '?action=chats').then(r => r.json()).then(d => {
if (!d.ok || !(d.chats || []).length) return;
// Do not clobber a conversation the operator has already started. The list arrives after
// construction, so by the time it lands they may have typed and sent something.
if (messages.length || busy) return;
const last = d.chats.slice().sort((a, b) => (b.updated || 0) - (a.updated || 0))[0];
if (last) loadChat(last.id);
}).catch(() => {});
}
const inst = {
prefix: P,
setProfile, newChat, loadChat, send,
currentId: () => chatId,
teardown() {
clearInterval(pendingTimer);
window.removeEventListener('error', onErr);
window.removeEventListener('unhandledrejection', onRej);
document.removeEventListener('click', onDocClick);
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;
}
// The scope is shown, not just stored. A Scheduler dock thread is about one script or log,
// and a list of titles alone makes "why does this one talk about a log I never opened"
// an unanswerable question.
// Tagged only when the tag says something. A scope always does; a profile does unless it
// is the ordinary one — labelling every General Chat row "Chat" is a column of noise that
// makes the rows that are genuinely different harder to pick out, which is the opposite
// of the point.
const P = window.VvAiProfiles || {};
box.innerHTML = '<div class="vv-ai-clist">' + rows.map(c => {
const prof = (c.profile && c.profile !== 'chat' && P[c.profile]) ? P[c.profile].short : '';
const tag = [prof, c.scope || ''].filter(Boolean).join(' · ');
return `<div class="vv-ai-crow${c.id === activeId ? ' active' : ''}" data-id="${esc(c.id)}">`
+ `<span class="vv-ai-crow-t" title="${esc(tag ? tag + ' — ' + c.title : c.title)}">`
+ (tag ? `<span class="vv-ai-crow-s">${esc(tag)}</span> ` : '')
+ `${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.
// Setting it also adds the expand control — a fixed height is exactly the situation
// where you sometimes want more room, and there is nothing to expand without one.
// tall the expanded height. Defaults to 2.5x height, which is the point where a long
// answer stops needing a scroll for most questions without the card swallowing the
// page it sits on
// 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.';
// 2.5x by default, computed here so the page can override with a value that suits its layout
// rather than the include guessing at one it cannot see.
$tall = $o['tall'] ?? '';
if ($height !== '' && $tall === '' && preg_match('/^(\d+(?:\.\d+)?)(px|vh|em|rem)$/', $height, $hm)) {
$n = (float)$hm[1] * 2.5;
// vh is a share of the viewport, so 2.5x runs off the bottom of the screen — an expand
// that puts the composer out of reach is worse than no expand. Clamped to something that
// still leaves the page scrollable to its own controls.
if ($hm[2] === 'vh') $n = min($n, 85);
$tall = rtrim(rtrim(number_format($n, 2, '.', ''), '0'), '.') . $hm[2];
}
$style = $height !== '' ? ' style="height:' . htmlspecialchars($height, ENT_QUOTES)
. ';max-height:none;"'
. ' data-h="' . htmlspecialchars($height, ENT_QUOTES) . '"'
. ' data-h-tall="' . htmlspecialchars($tall, ENT_QUOTES) . '"' : '';
?>
<div class="vv-ai-chatwrap<?= $compact ? ' vv-ai-c' : '' ?>" style="display:flex;flex-direction:column;gap:<?= $compact ? '4px' : '12px' ?>;min-width:0;">
<!-- One chip, not a row of buttons. Four profiles as four always-visible buttons spend a whole
line restating three choices you are not making, and the row only grows as profiles are
added. The chip states what is answering; clicking it offers the rest. -->
<div class="vv-ai-profiles" id="<?= $p ?>-profiles">
<div class="vv-ai-picker">
<button class="vv-ai-chip" id="<?= $p ?>-chip" type="button" aria-haspopup="listbox"
aria-expanded="false"><span id="<?= $p ?>-chip-l"></span><span class="vv-ai-chip-c">▾</span></button>
<div class="vv-ai-menu" id="<?= $p ?>-menu" role="listbox">
<?php foreach (vv_ai_profiles_ui() as $key => $def): ?>
<button class="vv-ai-opt" data-prof="<?= htmlspecialchars($key, ENT_QUOTES) ?>"
type="button" role="option">
<span class="vv-ai-opt-l"><?= htmlspecialchars($def['label']) ?></span>
<span class="vv-ai-opt-h"><?= htmlspecialchars($def['hint']) ?></span>
</button>
<?php endforeach; ?>
</div>
</div>
<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'] ?? '' ?>
<?php if ($height !== ''): ?>
<button class="vv-ai-btn ghost vv-ai-grow" id="<?= $p ?>-grow" type="button"
title="Give the conversation more room">⤢</button>
<?php endif; ?>
<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.
//
// Takes compact for the same reason the chat does: these rows are painted against whatever they
// sit on, and a card and a panel are not the same ground.
function vv_ai_chat_list_markup(string $prefix, bool $compact = false): void {
$p = htmlspecialchars($prefix, ENT_QUOTES);
?>
<div id="<?= $p ?>-chats"<?= $compact ? ' class="vv-ai-c"' : '' ?>><div class="vv-ai-none">loading…</div></div>
<?php
}