Files
Varaverk/Plugin/unraid/include/ai_chat.php
T

1653 lines
90 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 — transcript, composer, profile and history pickers, source
// viewer and the stored-chat list — rendered wherever a chat belongs. All three surfaces use
// it: the AI tab, the Monitor tab's AI row, and the Scheduler's right-hand panel.
//
// 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 panel used to be exactly that copy: its own bar, send loop, poll and store
// handling. It proved the point — a thread there died on reload while the other two were saved,
// and its bar drifted into a different shape from the same control everywhere else. It is an
// instance now. What was genuinely particular to it turned into options rather than a second
// implementation: `scope` as a function for a subject that follows the open view, `beforeSend`
// for the one reply that is recorded rather than asked, `think` for reasoning on diagnosis only,
// `setHeights()` for a placement whose size is a share of a panel and not knowable until layout.
//
// WHAT A PLACEMENT MAY CHOOSE
// How much room it gets, and which extra controls belong to that surface. Not the shape: the
// control row, its order, and what the buttons do are fixed. A chat that rearranges itself per
// tab is three components wearing one name, which is the state this replaced.
//
// 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 a page may want the store without the component — a list of saved
// conversations, or a save, with no transcript rendered. Every surface currently takes both, but
// the two are not the same dependency and pulling in a stylesheet to reach a save function is how
// they would become one.
//
// 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, alongside the menus it shares its shape
with, rather than here. */
.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; }
/* ── Code cards ─────────────────────────────────────────────────────────────
Controls are always visible, never hover-revealed. The Monitor and Scheduler run full-time on
15" panels with no pointer near them, so a control that only exists on hover does not exist. */
.vv-ai-code { margin:8px 0; border:1px solid #222; border-radius:4px; overflow:hidden; }
.vv-ai-code pre { margin:0; border:none; border-radius:0; }
.vv-ai-code-h { display:flex; align-items:center; gap:8px; padding:3px 8px;
background:#1b1b1b; border-bottom:1px solid #222; font-size:10px; }
.vv-ai-code-lang { color:#7a9; text-transform:uppercase; letter-spacing:.4px; }
.vv-ai-code-n { color:#555; }
.vv-ai-code-sp { flex:1; }
.vv-ai-code-btn { background:#232323; border:1px solid #333; color:#bbb; font-size:10px;
padding:2px 8px; border-radius:3px; cursor:pointer; line-height:1.5; }
.vv-ai-code-btn:hover { background:#2c2c2c; color:#eee; }
.vv-ai-code-btn.ok { background:#264a26; border-color:#356b35; color:#cfe8cf; }
/* Generated content, so it stays out of textContent — Copy and Insert return the code alone. */
.vv-ai-code.numbered code { counter-reset:vvln; }
.vv-ai-cl { counter-increment:vvln; }
.vv-ai-cl::before { content:counter(vvln); display:inline-block; width:2.6em; margin-right:.8em;
text-align:right; color:#3f3f3f; user-select:none; }
.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; }
/* An offer's buttons sit under the message that made them, indented to the body so they read as
part of what was said rather than as composer controls that drifted up the transcript. Once
answered the pair is replaced by the decision, which is quieter than a disabled button and
still says what happened. */
.vv-ai-offer { display:flex; gap:7px; margin-top:8px; }
.vv-ai-offer-done { font-size:10px; color:#4a4a4a; font-style:italic; margin-top:6px; }
.vv-ai-pending { font-size:12px; color:#5a5a5a; display:flex; align-items:center; gap:8px; }
/* The streaming body sits in the pending bubble and is replaced wholesale by the finished message,
so it has to match .vv-ai-body or the answer visibly reflows the moment it completes. The caret
marks text as still arriving — without it a stream that pauses mid-sentence reads as finished. */
.vv-ai-stream { margin-top:6px; }
/* Stop reads as the destructive-ish action it is, and the colour change is what tells the operator
the button's job swapped — the label alone is easy to miss mid-answer. */
.vv-ai-btn.vv-ai-stop { background:#5a2b2b; border-color:#7a3b3b; color:#f0d8d8; }
.vv-ai-stream::after { content:'▌'; margin-left:1px; opacity:.55; animation:vvAiPulse 1.1s infinite; }
.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; }
/* Profile left, conversation actions centred, window control right. The outer groups take equal
flex and the middle takes only what it needs, which centres it against the composer above
rather than against whatever the left group currently measures — the Scheduler's chip changes
width every time the operator opens a different script. Wrapping is allowed because the Monitor
card is narrow, and when it wraps the three groups part cleanly instead of interleaving. */
.vv-ai-ctrls { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
.vv-ai-side { flex:1 1 0; min-width:0; display:flex; align-items:center; }
.vv-ai-tail { justify-content:flex-end; }
.vv-ai-actions { display:flex; gap:8px; align-items:center; flex:0 0 auto; }
.vv-ai-ctrls select { background:#0a0a0a; border:1px solid #222; color:#8a8a8a; font-size:11px;
padding:4px 7px; border-radius:3px; }
/* Square-ish, so the two icon buttons read as a pair distinct from the worded ones. */
.vv-ai-grow { min-width:30px; text-align:center; }
/* Banner. The title takes the same shape a .vv-card h3 does, so an instance dropped into a card
reads as that card's heading rather than as a second one underneath it. */
.vv-ai-head { display:flex; align-items:center; gap:6px; margin-bottom:8px; }
/* The title takes the slack, so the two controls group together at the right rather than being
spread apart — with three children, space-between would strand the size button in the middle. */
.vv-ai-head-t { display:flex; align-items:center; gap:5px; flex:1 1 auto; min-width:0;
font-size:13px; color:#888; text-transform:uppercase; letter-spacing:0.05em;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* Shortcuts. The toggle lives in the banner with the other window controls; this is only the list
it opens. Shut, the block occupies nothing at all — no line, no margin — because the control
that speaks for it is already on screen above. Open, it is a two-column list that wraps to one
on a narrow card, and the transcript below gives up exactly its height. */
.vv-ai-keys { line-height:1; }
.vv-ai-keysbtn { font-size:10px !important; line-height:1; padding:4px 10px !important;
letter-spacing:0.04em; flex-shrink:0; }
/* line-height restored: the wrapper drops it to 1 to tighten the collapsed line, and inheriting
that here would stack the open rows on top of each other. */
.vv-ai-keys-b { display:none; grid-template-columns:repeat(auto-fit,minmax(190px,1fr));
gap:2px 14px; margin-top:6px; padding:7px 9px; background:#0d0d0d;
border:1px solid #1c1c1c; border-radius:4px; line-height:1.6; }
.vv-ai-keys.open .vv-ai-keys-b { display:grid; }
.vv-ai-key { display:flex; align-items:baseline; gap:7px; font-size:10px; min-width:0; }
.vv-ai-key-k { display:flex; gap:3px; flex-shrink:0; }
.vv-ai-key-d { color:#5a5a5a; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.vv-ai-key kbd { font-family:inherit; font-size:8px; color:#8a8a8a; background:#181818;
border:1px solid #2a2a2a; border-radius:3px; padding:1px 4px; line-height:1.5; }
/* ── 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; }
/* 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 — in a space 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-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-size { font-size:10px !important; line-height:1; padding:4px 10px !important;
letter-spacing:0.04em; flex-shrink:0; }
.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 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 () {};
// Called with (kind, yes, message) when the operator answers an offer the page made.
const onOffer = o.onOffer || function () {};
// Called whenever the active profile changes, however it changed — the picker, a reopened
// conversation, or the page retargeting. A page holding its own copy of "which profile" has
// no other way to stay in step with a menu it does not own.
const onProfile = o.onProfile || function () {};
// Called with the new state when the operator expands or collapses, for a page that has to
// re-lay itself out around the new height. See the grow handler.
const onResize = o.onResize || function () {};
const store = o.chats !== false;
const POLL_MS = 1200;
// Once tokens are actually arriving, 1200ms delivers them in visible lumps and the stream reads
// as stuttering rather than writing. The endpoint only reads one small file off tmpfs, so the
// extra requests are cheap — and this rate only applies while a generation is in flight.
const POLL_FAST_MS = 350;
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 scopeLabel = o.scopeLabel || ''; // subject shown on the chip; '' on surfaces without 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(); }
// Long blocks get numbered, because "the error is on line 40" is unusable against a wall of
// unnumbered text. The number is CSS generated content on a per-line span, which keeps it out
// of textContent — so Copy and Insert return the code and never the gutter.
const CODE_NUMBER_FROM = 12;
function codeCard(lang, code) {
const lines = code.split('\n');
const n = lines.length;
const numbered = n >= CODE_NUMBER_FROM;
// Joined on the newline rather than made display:block, so <pre> supplies the line break and
// textContent comes back byte-identical to what the model wrote.
const body = numbered
? lines.map(x => `<span class="vv-ai-cl">${x}</span>`).join('\n')
: code;
let head = '<div class="vv-ai-code-h">';
head += `<span class="vv-ai-code-lang">${lang || 'text'}</span>`;
head += `<span class="vv-ai-code-n">${n} line${n === 1 ? '' : 's'}</span>`;
head += '<span class="vv-ai-code-sp"></span>';
head += '<button type="button" class="vv-ai-code-btn" data-code-copy>Copy</button>';
// Only offered where the page can actually receive it. Without a target this would be a
// button that silently does nothing, which is worse than not having one.
if (o.onInsertCode) {
head += '<button type="button" class="vv-ai-code-btn" data-code-insert>Insert</button>';
}
head += '</div>';
return `<div class="vv-ai-code${numbered ? ' numbered' : ''}">${head}`
+ `<pre><code>${body}</code></pre></div>`;
}
// ── Minimal markdown, applied strictly after escaping ────────────────
function fmt(text) {
let h = esc(text);
h = h.replace(/```(\w*)\n([\s\S]*?)```/g, (m, l, c) => codeCard(l, c));
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 class="vv-ai-body vv-ai-stream" id="${P}-stream" hidden></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; }
// A line in the transcript that is not a turn — something the page did, said where the operator
// is already looking rather than in a banner they have to notice. Lives in the closure rather
// than only on the instance because the poll loop needs it too, and a bare note() there would
// throw: the instance object is not in scope from inside its own factory.
function noteLine(text) {
chatEl().appendChild(el('<div class="vv-ai-switch">' + esc(text) + '</div>'));
scroll();
}
// Streaming is only worth having if it does not fight the reader. Answers routinely outrun the
// panel, and the moment someone scrolls up to re-read a line, an unconditional scroll() on
// every delta drags them back down eight times a second. Stick to the bottom only while they
// are already there.
const nearBottom = () => {
const c = chatEl();
return (c.scrollHeight - c.scrollTop - c.clientHeight) < 40;
};
// Rendered through the same fmt() as a finished answer, so a fence that is still being written
// formats as it arrives rather than snapping from plain text to a code block at the end. fmt
// escapes, so a half-written tag cannot break out of the bubble mid-stream.
function streamInto(text) {
const s = $('stream');
if (!s) return;
if (!text) { s.hidden = true; return; }
const stick = nearBottom();
s.hidden = false;
s.innerHTML = fmt(text);
if (stick) scroll();
}
// 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) => {
// A web result is a page on the internet, not a file in this install: it opens in a tab
// rather than in the source viewer, it leads with its title rather than its URL, and it
// carries no retrieval score because nothing here scored it.
if (s.web && s.url) {
const label = [s.heading, s.url].filter(Boolean).join(' — ');
h += `<div class="vv-ai-src-i" data-web="${esc(s.url)}">`
+ `<span class="vv-ai-src-n">[${i+1}]</span><span>${esc(label)}</span>`
+ `<span class="vv-ai-src-s">web</span></div>`;
return;
}
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');
// Through vvSafeUrl, which is the global that exists precisely so a URL from outside this
// machine cannot become a javascript: href. The server drops anything that is not http(s)
// as well; this is the second of the two, not the only one.
if (src && src.dataset.web) {
const u = vvSafeUrl(src.dataset.web);
if (u) window.open(u, '_blank', 'noopener,noreferrer');
return;
}
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.web && s.url) {
const u = vvSafeUrl(s.url);
if (u) window.open(u, '_blank', 'noopener,noreferrer');
return;
}
if (s && s.path) vvAiOpen(s.path);
return;
}
const off = e.target.closest('[data-offer]');
if (off) { answerOffer(+off.dataset.offer, off.dataset.yes === '1'); return; }
// Code-card buttons. The text is read from the DOM rather than carried in a data attribute:
// a script full of quotes and backslashes interpolated into an attribute is an escaping bug
// waiting to happen, and textContent already holds exactly what the model wrote.
const cbtn = e.target.closest('[data-code-copy],[data-code-insert]');
if (cbtn) {
const card = cbtn.closest('.vv-ai-code');
const code = card ? (card.querySelector('code') || {}).textContent || '' : '';
if (!code) return;
if (cbtn.hasAttribute('data-code-insert')) {
if (o.onInsertCode) o.onInsertCode(code);
flashBtn(cbtn, 'Inserted');
return;
}
// navigator.clipboard needs a secure context. Unraid is routinely reached over plain http
// on the LAN, where it is simply undefined — so the textarea fallback is the path that
// actually runs here, not a legacy nicety.
const done = () => flashBtn(cbtn, 'Copied');
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(code).then(done).catch(() => copyFallback(code, done));
} else {
copyFallback(code, done);
}
}
});
function copyFallback(text, done) {
const t = document.createElement('textarea');
t.value = text;
// Off-screen rather than hidden: display:none and visibility:hidden are not selectable, so
// execCommand copies nothing from them.
t.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0';
document.body.appendChild(t);
t.select();
try { document.execCommand('copy'); done(); } catch (_) {}
t.remove();
}
function flashBtn(btn, msg) {
const was = btn.textContent;
btn.textContent = msg;
btn.classList.add('ok');
setTimeout(() => { btn.textContent = was; btn.classList.remove('ok'); }, 1100);
}
// ── 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;
// A page may claim what was typed instead of asking it. The Scheduler does this once, after
// an offer is accepted, when the next thing typed is the fix being recorded rather than a
// question. Checked here rather than in a wrapper the page calls, because Ask and Ctrl+Enter
// both land here directly and a wrapper would only catch the ones routed through it.
if (o.beforeSend && o.beforeSend(q)) { $('input').value = ''; return; }
busy = true;
// Ask becomes Stop rather than greying out. The one stretch of the interaction that takes
// 25-75s is exactly when the operator most wants a control, and the composer row has no
// room for a fourth button that is dead 95% of the time.
setSendMode('stop');
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;
const webEl = o.webEl ? document.getElementById(o.webEl) : 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 : '',
// A checkbox where the page offers one, otherwise whatever the page decides from the
// profile, otherwise on. The Scheduler reasons only when diagnosing: working out what a
// log means is worth waiting ~15s for, and a lookup like "what does this setting do" is
// not — an inline answer that stalls reads as broken.
think: (typeof o.think === 'function' ? o.think(profile)
: thinkEl ? thinkEl.checked : true) ? '1' : '0',
// Only sent when the profile in force actually holds the capability, so ticking the box
// and then switching to the assistant cannot send a question about this machine to a
// search engine. The worker checks the same thing again — this is the courtesy, not
// the control.
web: (PROFILES[profile] && PROFILES[profile].web && webEl && webEl.checked)
? '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 });
// Held for Stop. Set before the first poll so a press in the first second has a target.
curToken = d.token;
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);
// Reduced to role and content. Messages carry local bookkeeping now — an offer's state is
// ours, not something the model should be reading back as part of the conversation.
return messages.slice(floor).map(m => ({ role: m.role, content: m.content }));
}
function finish() {
busy = false;
curToken = null;
setSendMode('ask');
clearInterval(pendingTimer);
}
// The token of the turn in flight. Held so Stop knows what to cancel, and cleared by finish()
// so a Stop pressed against an already-terminal job is impossible rather than merely harmless.
let curToken = null;
function setSendMode(mode) {
const b = $('send');
if (!b) return;
const stopping = (mode === 'stop');
b.disabled = false;
b.textContent = stopping ? 'Stop' : 'Ask';
b.classList.toggle('vv-ai-stop', stopping);
}
function stopTurn() {
if (!curToken) return;
const t = curToken;
// Disabled immediately: the kill is quick but the poll that renders the result is up to
// POLL_FAST_MS away, and a second press in that window would signal a dead pid.
const b = $('send'); if (b) b.disabled = true;
phase('stopping…');
fetch(API, { method: 'POST', headers: POST_HEAD,
body: new URLSearchParams({ action: 'stop', token: t }) })
.then(r => r.json())
.catch(() => {});
// Nothing is rendered from the response. The poll already owns turning a terminal state into
// a message, and having two paths do it is how a turn ends up in the transcript twice.
}
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 || {};
// Stopped is terminal and keeps whatever had been written. A turn cancelled at 80% is
// usually cancelled because there was already enough on screen, so discarding it would
// punish the operator for the one control that exists to save them time.
if (j.status === 'stopped') {
if ((j.answer || '').trim()) {
addAnswer(j);
messages.push({ role: 'assistant', content: j.answer });
noteLine('Stopped — the part already written is kept.');
} else {
const p = $('pending'); if (p) p.remove();
noteLine('Stopped before anything was written.');
}
fetch(API, { method: 'POST', headers: POST_HEAD,
body: new URLSearchParams({ action: 'clear', token }) }).catch(() => {});
finish();
save();
return;
}
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();
// The answer is passed so a page can react to what was said, not just that a turn
// happened — the Scheduler uses it to spot a code block worth offering to the editor.
// Handlers that take no argument are unaffected.
onTurn(j.answer);
return;
}
if (j.status === 'error') { addError(j.error || 'Unknown error'); finish(); return; }
// Partial text arrives on the same envelope as the status, so the transcript fills in
// without a second channel. Empty while the model is still reasoning — thinking_chars is
// what moves then, and a page watching only partial would look wedged for ~15s.
if (j.status === 'generating') streamInto(j.partial || '');
phase(j.status === 'generating'
? (j.partial
? 'writing… (' + ((j.sources||[]).length) + ' sources)'
: (j.thinking_chars
? 'reasoning… (' + j.thinking_chars.toLocaleString() + ' chars)'
: 'generating… (' + ((j.sources||[]).length) + ' sources retrieved)'))
: j.status === 'retrieving' ? 'searching the index…' : 'starting…');
setTimeout(() => poll(token, started),
j.status === 'generating' ? POLL_FAST_MS : 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, i) => {
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>`
+ (m.offer ? offerHtml(m.offer, i) : '') + `</div>`));
});
scroll();
}
// ── Offers ───────────────────────────────────────────────────────────
// An assistant message that carries a decision rather than only text. The answer is recorded
// on the message itself, so a conversation reopened tomorrow shows what was decided instead of
// asking again — and an offer left unanswered is still answerable, which a transient banner
// would not be.
function offerHtml(off, i) {
if (off.state !== 'open') {
return `<div class="vv-ai-offer-done" data-offer-row="${i}">`
+ (off.state === 'taken' ? 'yes' : 'no thanks') + `</div>`;
}
return `<div class="vv-ai-offer" data-offer-row="${i}">`
+ `<button class="vv-ai-btn" type="button" data-offer="${i}" data-yes="1">Yes</button>`
+ `<button class="vv-ai-btn ghost" type="button" data-offer="${i}" data-yes="0">No</button>`
+ `</div>`;
}
// Appended, not rendered. render() rebuilds the transcript as plain turns and would strip the
// sources and reasoning off the answer the operator is looking at — see the note under
// loadChat. An offer arrives after an answer, so that is exactly when it must not happen.
function offer(kind, text) {
const i = messages.length;
messages.push({ role: 'assistant', content: text, offer: { kind: kind, state: 'open' } });
chatEl().appendChild(el(`<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`
+ `<div class="vv-ai-body">${fmt(text)}</div>`
+ offerHtml(messages[i].offer, i) + `</div>`));
scroll();
save();
}
function answerOffer(i, yes) {
const m = messages[i];
if (!m || !m.offer || m.offer.state !== 'open') return;
m.offer.state = yes ? 'taken' : 'declined';
const row = chatEl().querySelector('[data-offer-row="' + i + '"]');
if (row) row.outerHTML = offerHtml(m.offer, i);
save();
onOffer(m.offer.kind, yes, m);
}
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');
// With a subject, the chip states both — the contract and the thing being asked about — and
// uses the profile's short name to keep the pair readable in a narrow bar. That pairing is
// load-bearing on the Scheduler: if the operator can see what it thinks it is looking at, a
// wrong inference costs a glance instead of a confidently wrong answer.
if (chipL) {
const def = PROFILES[p];
chipL.textContent = scopeLabel
? ((def && def.short ? def.short : p) + ' · ' + scopeLabel)
: (def ? def.label : p);
}
const menu = $('menu');
if (menu) menu.querySelectorAll('.vv-ai-opt').forEach(b =>
b.classList.toggle('active', b.dataset.prof === p));
// The profile's description is the chip's title now. It used to be a sentence sitting beside
// the chip, which cost a line on every surface and was already hidden on the compact ones.
const chip = $('chip');
if (chip) chip.title = 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';
onProfile(p);
}
// 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();
}
// Two pickers now — profile on the left of the control row, saved conversations on the right.
// Both close on an outside click or Escape through one pair of document listeners rather than
// a pair each, and both are 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, and the count
// of them to get right should not grow with the number of menus.
const picker = $('profiles') ? $('profiles').querySelector('.vv-ai-picker') : null;
const histWrap = $('hist');
const closeMenus = () => {
if (picker) { picker.classList.remove('open');
const c = $('chip'); if (c) c.setAttribute('aria-expanded', 'false'); }
if (histWrap) { histWrap.classList.remove('open');
const h = $('hist-b'); if (h) h.setAttribute('aria-expanded', 'false'); }
};
const onDocClick = e => {
if (picker && picker.contains(e.target)) return;
if (histWrap && histWrap.contains(e.target)) return;
closeMenus();
};
const onDocKey = e => { if (e.key === 'Escape') closeMenus(); };
document.addEventListener('click', onDocClick);
document.addEventListener('keydown', onDocKey);
if (picker) {
const chip = $('chip');
chip.addEventListener('click', e => {
e.stopPropagation();
const open = !picker.classList.contains('open');
closeMenus();
picker.classList.toggle('open', open);
chip.setAttribute('aria-expanded', open ? 'true' : 'false');
});
$('menu').addEventListener('click', e => {
const b = e.target.closest('.vv-ai-opt');
if (!b) return;
closeMenus();
setProfile(b.dataset.prof);
});
}
// Saved conversations, off the same store the Conversations card reads — so every placement
// can reach its history whether or not it has a card beside it. Read when opened rather than
// kept in step: a list refreshed on a timer for a menu nobody has opened is work spent on
// nothing, and the moment it is looked at is the moment it must be right.
if (histWrap) {
const histBtn = $('hist-b'), histMenu = $('hist-menu');
histBtn.addEventListener('click', e => {
e.stopPropagation();
const open = !histWrap.classList.contains('open');
closeMenus();
histWrap.classList.toggle('open', open);
histBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
if (!open) return;
histMenu.innerHTML = '<div class="vv-ai-hist-msg">loading…</div>';
fetch(API + '?action=chats').then(r => r.json()).then(d => {
const rows = (d.ok && d.chats) ? d.chats : [];
if (!rows.length) {
histMenu.innerHTML = '<div class="vv-ai-hist-msg">no saved conversations yet</div>';
return;
}
// Same tagging rule as the Conversations card: a scope always says something, a profile
// says something unless it is the ordinary one.
const PR = window.VvAiProfiles || {};
histMenu.innerHTML = rows.map(c => {
const prof = (c.profile && c.profile !== 'chat' && PR[c.profile]) ? PR[c.profile].short : '';
const tag = [prof, c.scope || ''].filter(Boolean).join(' · ');
return `<button class="vv-ai-opt${c.id === chatId ? ' active' : ''}" type="button"`
+ ` role="option" data-chat="${esc(c.id)}">`
+ `<span class="vv-ai-opt-l">${esc(c.title)}</span>`
+ `<span class="vv-ai-opt-h">${esc([tag, ago(c.ts)].filter(Boolean).join(' · '))}</span>`
+ `</button>`;
}).join('');
}).catch(() => {
histMenu.innerHTML = '<div class="vv-ai-hist-msg">could not read the store</div>';
});
});
histMenu.addEventListener('click', e => {
const b = e.target.closest('[data-chat]');
if (!b) return;
closeMenus();
loadChat(b.dataset.chat);
});
}
// ── Wiring ───────────────────────────────────────────────────────────
// Ctrl+Enter is not bound here. It lives with the rest of the shortcuts on the wrapper below,
// and a second binding on the input would send the same question twice — the second landing
// on the busy guard and reporting the first as stuck.
// One button, two jobs — which one is decided by busy rather than by what the label happens to
// say, so a stale label can never send a question into a turn already running.
$('send').addEventListener('click', () => { busy ? stopTurn() : 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.
// The two heights live on the element as data attributes rather than in a closure, so a page
// whose sizes are not knowable when the markup is written can rewrite them later — the
// Scheduler's panel takes its heights as a share of whatever room the panel has, which is a
// number that only exists after layout and changes on every resize. See setHeights().
// Two independent pieces of state, not one three-way. `big` is whether the conversation is
// expanded — an action, taken often. `large` is how much expanded is worth — a setting, chosen
// once. Collapsing and then expanding again returns to the size you picked rather than
// resetting it, which is the whole reason they are separate.
let big = false, large = false;
const growBtn = $('grow'), sizeBtn = $('size');
// What the open shortcuts list costs, including the margin that separates it from the
// transcript. Zero while shut. Measured rather than assumed: it is a grid that reflows to one
// column on a narrow card, so its height is a function of the width it was given.
function keysCost() {
const w = $('keys'), b = $('keys-b');
if (!w || !b || !w.classList.contains('open')) return 0;
const m = parseFloat(getComputedStyle(b).marginTop) || 0;
return b.offsetHeight + m;
}
function applyHeights() {
const el = chatEl();
const base = el.dataset.h || '';
const med = el.dataset.hTall || '';
const big2 = el.dataset.hLarge || med;
if (!base || !med) return;
const want = big ? (large ? big2 : med) : base;
// Subtracted, not added to. The height the page asked for is the height the whole window
// keeps; the list is taken out of the conversation's share of it. calc() because that height
// may be a viewport share while this cost is only ever pixels.
//
// Floored, because on the smallest placements the list is taller than the transcript it is
// being taken from — the Scheduler collapsed at a tenth of its panel is a couple of rows.
// Leaving a transcript of zero would be worse than briefly outgrowing the budget.
const k = keysCost();
el.style.height = k ? 'max(56px, calc(' + want + ' - ' + k + 'px))' : want;
if (growBtn) {
growBtn.textContent = big ? '⤡' : '⤢';
growBtn.title = big ? 'Back to the smaller view' : 'Give the conversation more room';
growBtn.classList.toggle('vv-ai-grow-on', big);
}
if (sizeBtn) {
// Nothing to choose between while collapsed — it would name a size that is not on screen
// and does not become so until the button beside it is pressed.
sizeBtn.style.display = big ? '' : 'none';
// Names the size in force, not the one it would switch to. A button labelled with its own
// effect reads as a statement of current state to about half of everyone who sees it, and
// the two readings disagree about what clicking does — so it states, and the title says
// what happens.
sizeBtn.textContent = large ? 'Large' : 'Medium';
sizeBtn.title = large ? 'Expanded is large — click for medium'
: 'Expanded is medium — click for large';
sizeBtn.classList.toggle('vv-ai-grow-on', large);
}
}
// Named functions, because the buttons are no longer the only way in — the shortcuts below
// drive the same two.
//
// onResize fires from these and never from setHeights(). A page whose layout depends on this
// height calls setHeights() from inside its fit routine, so announcing it there would call
// that routine from within itself, forever.
//
// A placement that is not simply taller than its neighbours has to be told. The Scheduler
// panel is pinned at the bottom and the views above it are sized from what is left, so without
// this the chat grows downward off the end of the panel and takes its own composer with it —
// which is precisely what it did.
// Announced twice: now, so a page that lays out around this tracks the change as it happens,
// and again once it has finished.
//
// The transcript animates its height — see the transition on .vv-ai-chat — so a page that
// measures the moment we change it reads a box that is still moving. The Scheduler sizes the
// view above from the panel's measured height, and re-measuring one frame after a collapse
// read a dock caught part of the way down: the view came out short by roughly the height the
// chat was shrinking from, leaving a dead strip under the content that nothing filled. It only
// ever showed on collapse, because on expand the same error sizes the view too large and flex
// quietly shrinks it back.
let settle = null;
function announceResize() {
onResize(big);
clearTimeout(settle);
// Comfortably past the 140ms transition. A timer rather than transitionend, which does not
// fire at all when the computed height happens not to change.
settle = setTimeout(() => onResize(big), 220);
}
function toggleBig() { big = !big; applyHeights(); scroll(); announceResize(); }
// Only meaningful while expanded, and the button is only reachable then. The shortcut has to
// check for itself rather than silently changing a size nobody can see.
function toggleSize() {
if (!big) return;
large = !large; applyHeights(); scroll(); announceResize();
}
if (growBtn) growBtn.addEventListener('click', toggleBig);
if (sizeBtn) sizeBtn.addEventListener('click', toggleSize);
// ── Shortcuts panel ──────────────────────────────────────────────────
// One key for every instance, not one each. The list is identical everywhere, so remembering
// it per surface would mean closing the same panel three times to be rid of it.
//
// Absent means never seen, which is the only time it opens by itself.
const KEYS_OPEN = 'vv-ai-keys-open';
const keysWrap = $('keys'), keysT = $('keys-t');
if (keysWrap && keysT) {
// applyHeights() after the class, never before: the cost is measured off the rendered list,
// and while it is still display:none that measurement is zero.
const applyKeys = open => {
keysWrap.classList.toggle('open', open);
keysT.setAttribute('aria-expanded', open ? 'true' : 'false');
keysT.classList.toggle('vv-ai-grow-on', open);
applyHeights();
};
applyKeys(localStorage.getItem(KEYS_OPEN) !== '0');
keysT.addEventListener('click', () => {
const open = !keysWrap.classList.contains('open');
localStorage.setItem(KEYS_OPEN, open ? '1' : '0');
applyKeys(open);
scroll();
// The total is meant to hold, but the floor above can break that on a small placement, so
// the page is told either way rather than being left to find out by clipping. Through the
// same settle as the size controls: the list appears at once but the transcript animates
// to its new height, so an immediate measurement is of a box still moving.
announceResize();
});
}
// Bound to the component, not to document. Alt+N and friends are browser menu accelerators;
// claiming them page-wide would break the tab this lives on. Inside the chat they are
// unambiguous, and everything they do is visible from where the operator already is.
const wrapEl = chatEl().closest('.vv-ai-chatwrap');
if (wrapEl) {
wrapEl.addEventListener('keydown', e => {
const inp = $('input');
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); send(); return; }
// Terminal habit: an empty box and Up gets back what you just asked, to edit rather than
// retype. Only when empty, or it would eat cursor movement in a question being written.
if (e.key === 'ArrowUp' && e.target === inp && inp.value === '') {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
e.preventDefault();
inp.value = messages[i].content;
inp.setSelectionRange(inp.value.length, inp.value.length);
break;
}
}
return;
}
if (e.key === 'Escape') {
const wasOpen = (picker && picker.classList.contains('open'))
|| (histWrap && histWrap.classList.contains('open'));
closeMenus();
// Only clears once there is no menu left to close, so one Escape never does two things.
if (!wasOpen && e.target === inp && inp.value !== '') { e.preventDefault(); inp.value = ''; }
return;
}
if (!e.altKey || e.ctrlKey || e.metaKey) return;
const k = e.key.toLowerCase();
if (k === 'n') { e.preventDefault(); newChat(); inp.focus(); }
else if (k === 'e') { e.preventDefault(); toggleBig(); }
else if (k === 's') { e.preventDefault(); toggleSize(); }
else if (k === 'h') { const hb = $('hist-b'); if (hb) { e.preventDefault(); hb.click(); } }
});
}
// 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, offer,
busy: () => busy,
expanded: () => big,
currentId: () => chatId,
// Heights supplied after the fact, for a placement whose room is a share of a panel rather
// than a constant. Re-applies immediately at whichever of the two states is current, so a
// resize while expanded stays expanded instead of snapping back.
setHeights(base, tallMed, tallLarge) {
const el = chatEl();
el.dataset.h = base; el.dataset.hTall = tallMed;
if (tallLarge) el.dataset.hLarge = tallLarge;
applyHeights();
},
// Same contract, different subject — the Scheduler pointing the chat at another script, log
// or conf as the operator moves around the tab. Distinct from setProfile: that changes who
// is answering, this changes what about.
//
// The transcript keeps everything and only the model's floor moves, because a troubleshooting
// thread about one script must not bleed into a question about another, while hiding that the
// earlier exchange happened is worse than carrying it visibly. chatId is dropped with it: the
// scope is part of the stored record, so appending turns about a different thing to the same
// row would produce a conversation whose stored scope describes only its first half.
retarget(prof, label, note) {
if (label !== undefined) scopeLabel = label;
applyProfile(PROFILES[prof] ? prof : profile);
if (messages.length > sendFrom && note) {
chatEl().appendChild(el('<div class="vv-ai-switch">' + esc(note) + '</div>'));
scroll();
}
sendFrom = messages.length;
chatId = '';
lastSources = [];
},
// A line in the transcript that is not a turn — something the page did, said where the
// operator is already looking rather than in a banner they have to notice.
note: noteLine,
teardown() {
clearInterval(pendingTimer);
// Would otherwise fire a re-layout at a page whose instance no longer exists.
clearTimeout(settle);
window.removeEventListener('error', onErr);
window.removeEventListener('unhandledrejection', onRej);
document.removeEventListener('click', onDocClick);
// The keydown pair was previously registered and never removed, so every tab swap left
// another Escape handler bound to a dead menu.
document.removeEventListener('keydown', onDocKey);
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 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
}
// The shortcuts, in one place. Rendered into the panel by the markup below and acted on by the
// keydown handler in the script above, so a key that stops working cannot keep being advertised.
//
// Everything here fires only while focus is inside the chat. Alt combinations are browser menu
// accelerators when nothing has focus, and a chat that stole them page-wide would be a chat that
// broke the tab it lives on.
function vv_ai_chat_keys(): array {
return [
['keys' => ['Ctrl', 'Enter'], 'what' => 'Ask', 'id' => 'send'],
['keys' => ['↑'], 'what' => 'Your last question, to edit', 'id' => 'recall'],
['keys' => ['Esc'], 'what' => 'Close a menu, or clear the box', 'id' => 'esc'],
['keys' => ['Alt', 'N'], 'what' => 'New conversation', 'id' => 'new'],
['keys' => ['Alt', 'E'], 'what' => 'Expand or collapse', 'id' => 'grow'],
['keys' => ['Alt', 'S'], 'what' => 'Medium or large', 'id' => 'size'],
['keys' => ['Alt', 'H'], 'what' => 'Saved conversations', 'id' => 'hist'],
];
}
// One instance's markup. The prefix composes every id, so a page may render more than one.
//
// Every placement renders the same thing. What a page chooses is how much room it gets — width is
// the container's business, the two heights are this function's — and which extra controls belong
// to that surface. Everything else is fixed on purpose: the control row, its order, what the
// buttons do. A chat that rearranges itself per tab is three components wearing one name.
//
// profile which profile starts active
// compact card-sized chrome, for a chat living inside a card
// height resting transcript height, the collapsed state. Defaults per mode rather than being
// left empty: the expand control is standard, and a control with no second height to
// move to is a button that does nothing on the surface that forgot to pass a number.
// tall expanded at the Medium setting. Defaults to 2x height.
// tallLarge expanded at the Large setting. Defaults to 3x height.
// Two expanded sizes because ⤢ and the banner's Medium/Large answer different
// questions: whether the conversation is expanded, and how much that is worth. The
// first is an action taken constantly, the second a preference set once.
// title banner text. Defaults to "Assistant"
// icon raw SVG for the banner, page-supplied and emitted unescaped
// empty empty-state text
// scope what this conversation is about — string, or a function re-read at send time for a
// surface whose subject follows the view the operator has open
// controls extra markup dropped in at the head of the action group
function vv_ai_chat_markup(string $prefix, array $o = []): void {
$p = htmlspecialchars($prefix, ENT_QUOTES);
$compact = !empty($o['compact']);
// A card-sized chat and a full-page one want very different resting heights, and neither wants
// none: see `height` above.
$height = $o['height'] ?? ($compact ? '300px' : '46vh');
$empty = $o['empty'] ?? 'Ask Varaverk about itself.';
// Derived from the resting height, so a page need only state the one number it actually cares
// about. Large is 3x, capped at 85vh — vh is a share of the viewport and an expand that puts
// the composer out of reach is worse than no expand.
//
// Medium is placed between resting and large rather than at a multiple of its own, because a
// fixed multiple collapses under that cap: at 52vh resting, 2x and 3x are both 85vh after
// clamping, and the Medium/Large button would have had two settings that did the same thing.
$sizes = function (string $from): array {
if (!preg_match('/^(\d+(?:\.\d+)?)(px|vh|em|rem)$/', $from, $m)) return ['', ''];
$fmt = fn(float $n) => rtrim(rtrim(number_format($n, 2, '.', ''), '0'), '.') . $m[2];
$base = (float)$m[1];
$lg = $base * 3.0;
if ($m[2] === 'vh') $lg = min($lg, 85.0);
return [$fmt($base + ($lg - $base) * 0.55), $fmt($lg)];
};
[$defMed, $defLarge] = $sizes($height);
$tall = $o['tall'] ?? $defMed;
$large = $o['tallLarge'] ?? $defLarge;
$style = $height !== '' ? ' style="height:' . htmlspecialchars($height, ENT_QUOTES)
. ';max-height:none;"'
. ' data-h="' . htmlspecialchars($height, ENT_QUOTES) . '"'
. ' data-h-tall="' . htmlspecialchars($tall, ENT_QUOTES) . '"'
. ' data-h-large="' . htmlspecialchars($large, ENT_QUOTES) . '"' : '';
?>
<div class="vv-ai-chatwrap<?= $compact ? ' vv-ai-c' : '' ?>" style="display:flex;flex-direction:column;gap:<?= $compact ? '4px' : '12px' ?>;min-width:0;">
<!-- Banner. Names the thing, and carries the setting rather than the action: how big expanded
is, not whether it is expanded. ⤢ below does the expanding, which is a thing you do to the
conversation and belongs with the other things you do to it. This picks what that action
will be worth, which you set once and rarely revisit.
Hidden while collapsed, because at that point it describes nothing on screen. -->
<div class="vv-ai-head">
<span class="vv-ai-head-t">
<?php if (!empty($o['icon'])): ?><span class="vv-ico"><?= $o['icon'] ?></span><?php endif; ?>
<?= htmlspecialchars($o['title'] ?? 'Assistant') ?>
</span>
<button class="vv-ai-btn ghost vv-ai-size" id="<?= $p ?>-size" type="button"
style="display:none">Medium</button>
<!-- Far right, and there whatever the conversation is doing — unlike the size button beside
it, which has nothing to say while collapsed. -->
<button class="vv-ai-btn ghost vv-ai-keysbtn" id="<?= $p ?>-keys-t" type="button"
aria-expanded="true" title="Keyboard shortcuts">Shortcuts</button>
</div>
<!-- Shortcuts. Open on a browser that has never seen it and remembered from then on, which for
most people means it is shut a minute later and never thought about again — which is the
point. A panel that is closed by default is one nobody discovers; one that cannot be closed
is clutter on every load forever.
It is paid for out of the transcript rather than added underneath the banner: the window
keeps the height it was given and the conversation gives up the rows, so opening this never
pushes the composer down or, on the Scheduler, past the end of the panel. Closing hands the
rows straight back. See applyHeights().
The list is data, so the keys shown and the keys handled come from one place and cannot
drift into describing a shortcut that no longer works. -->
<div class="vv-ai-keys" id="<?= $p ?>-keys">
<div class="vv-ai-keys-b" id="<?= $p ?>-keys-b">
<?php foreach (vv_ai_chat_keys() as $k): ?>
<div class="vv-ai-key">
<span class="vv-ai-key-k"><?php foreach ($k['keys'] as $cap): ?><kbd><?= htmlspecialchars($cap) ?></kbd><?php endforeach; ?></span>
<span class="vv-ai-key-d"><?= htmlspecialchars($k['what']) ?></span>
</div>
<?php endforeach; ?>
</div>
</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 ?>"
title="Ctrl+Enter to send"
placeholder="<?= htmlspecialchars($o['placeholder'] ?? 'Ask anything — questions about this install route to the assistant on their own.', ENT_QUOTES) ?>"></textarea>
<!-- Three groups, and everything in all of them is clickable. What is answering holds the
left; what you do to the conversation sits in the middle, where the composer above it
is; what you do to the window holds the right. Grouping by what a control acts on rather
than by importance means the middle is the only part you look at while working.
Centred by giving the two outer groups equal flex, not by margins — the middle stays put
when the chip's text changes length, which on the Scheduler it does on every view switch.
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 only grow as profiles are added. -->
<div class="vv-ai-ctrls">
<div class="vv-ai-side" 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>
</div>
<div class="vv-ai-actions">
<?= $o['controls'] ?? '' ?>
<button class="vv-ai-btn ghost" id="<?= $p ?>-new" type="button">New</button>
<!-- Saved conversations. The same store the Conversations card reads, so a thread opened
from either is the same thread. Worded, not a chevron: a bare ▾ four buttons away from
the chip's ▾ was two dropdowns wearing one glyph for unrelated jobs. -->
<div class="vv-ai-picker" id="<?= $p ?>-hist">
<button class="vv-ai-btn ghost" id="<?= $p ?>-hist-b" type="button"
aria-haspopup="listbox" aria-expanded="false"
title="Reopen a saved conversation">Saved</button>
<div class="vv-ai-menu vv-ai-menu-r" id="<?= $p ?>-hist-menu" role="listbox"></div>
</div>
<button class="vv-ai-btn" id="<?= $p ?>-send" type="button">Ask</button>
</div>
<div class="vv-ai-side vv-ai-tail">
<button class="vv-ai-btn ghost vv-ai-grow" id="<?= $p ?>-grow" type="button"
title="Give the conversation more room">⤢</button>
</div>
</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
}