Compare commits

...
3 Commits
Author SHA1 Message Date
Gmer4Lfe 6be765a9bf Say there is one chat component, in the places that claimed otherwise
Several headers argued the Scheduler dock was deliberately separate, and the Scheduler's help
never mentioned the assistant at all — including the fix flow that just changed shape.
2026-08-09 12:22:25 -04:00
Gmer4Lfe 8eeb4c3d6c Make the Scheduler panel an instance, not a second chat
It kept its own bar, send loop and poll, which is why a thread there died on reload while the
other two were saved and why its controls had drifted from the same controls everywhere else.
2026-08-09 12:22:07 -04:00
Gmer4Lfe d0ff0c7d5c Teach the chat component what the Scheduler dock needs
Offers, a settable subject, runtime heights and a profile hook — everything the dock does that
the shared component could not, so it can stop being a second implementation.
2026-08-09 11:50:02 -04:00
9 changed files with 425 additions and 388 deletions
+16 -9
View File
@@ -121,16 +121,23 @@ soon a page notices the writer's update.
> cache miss. A key added to the endpoint and not to the writer leaves its card loading forever > cache miss. A key added to the endpoint and not to the writer leaves its card loading forever
> on every ordinary page load and working only on the request that happens to miss. > on every ordinary page load and working only on the request that happens to miss.
**One widget, rendered twice.** `include/ai_chat.php` owns the conversation surface — profile **One widget, rendered three times.** `include/ai_chat.php` owns the conversation surface —
bar, transcript, composer, source viewer, stored-chat list — and both the AI tab and the Monitor transcript, composer, profile and history pickers, source viewer, stored-chat list — and the AI
tab's AI row construct it. Every id is composed from a prefix so instances can coexist, and each tab, the Monitor tab's AI row and the Scheduler's right-hand panel all construct it. Every id is
tears down the previous holder of its prefix, because Unraid swaps tab content by AJAX without composed from a prefix so instances can coexist, and each tears down the previous holder of its
unloading the old page's JavaScript. prefix, because Unraid swaps tab content by AJAX without unloading the old page's JavaScript.
The Scheduler tab's dock is deliberately *not* built on it: a one-line bar that follows the view A placement chooses how much room it gets and which extra controls it needs. It does not choose
you have open, with its own scope chip, fix flow and incident capture, is a different component the shape: the control row is fixed everywhere — profile chip hard left, `⤢` `▾` `New` `Ask` hard
that happens to share an endpoint. Folding it in would produce one widget with two personalities right — because a chat that rearranges itself per tab is three components wearing one name.
and a mode flag choosing between them.
The Scheduler panel was exactly that until it was folded in, and it proved the cost: a thread
there died on reload while the other two were saved, and its bar had drifted into a different
shape from the same control everywhere else. What was genuinely particular to it became 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, and `setHeights()` for a placement sized as a share of a panel, which is not a
number that exists until layout has run.
**Timezone.** `include/config.php` adopts Unraid's own `timeZone` from `ident.cfg` for the whole **Timezone.** `include/config.php` adopts Unraid's own `timeZone` from `ident.cfg` for the whole
PHP layer. PHP here has no `date.timezone` and therefore ran in UTC while the server ran local, PHP layer. PHP here has no `date.timezone` and therefore ran in UTC while the server ran local,
+14 -36
View File
@@ -667,37 +667,19 @@ body.vv-fullscreen #displaybox { padding-left: 1rem !important; padding-top: .5r
.vv-sug-configured { color: #4caf50; font-size: 11px; } .vv-sug-configured { color: #4caf50; font-size: 11px; }
/* Info sections inside Scheduler Information panel */ /* Info sections inside Scheduler Information panel */
/* ── Assistant dock (scheduler right panel) ────────────────────────────────────────────────── */ /* ── Assistant dock (scheduler right panel) ────────────────────────────────────────────────── */
/* The frame only. Everything the dock used to style for itself — its bar, input, transcript,
question and answer rows, sources, separators, its own grow button — is the shared chat
component's now, drawn from the same rules as the AI tab and the Monitor card. What is left is
the box the panel puts it in. */
#vv-ai-dock { border: 1px solid #262626; border-radius: 6px; background: #0e0e0e; #vv-ai-dock { border: 1px solid #262626; border-radius: 6px; background: #0e0e0e;
margin-top: 6px; flex-shrink: 0; } margin-top: 6px; flex-shrink: 0; padding: 6px 8px; }
#vv-ai-dock-bar { display: flex; align-items: center; gap: 7px; padding: 6px 8px; } /* The chip states profile and subject together here, and a long script name would otherwise push
#vv-ai-dock-chip { white-space: nowrap; max-width: 240px; overflow: hidden; the action group off the end of a narrow panel. */
text-overflow: ellipsis; } #vv-ai-dock .vv-ai-chip { max-width: 240px; overflow: hidden; text-overflow: ellipsis; }
/* The button around it is .vv-ai-chip and carries the colour, border and padding. */ /* Flashed when the subject moves under text already typed — see vvAiDockScope(). */
.vv-ai-chip-flash { animation: vvAiChipFlash .9s ease-out 2; } .vv-ai-chip-flash { animation: vvAiChipFlash .9s ease-out 2; }
@keyframes vvAiChipFlash { 0%,100% { background:#12191d; color:#5a7a8a; } @keyframes vvAiChipFlash { 0%,100% { background:#12191d; color:#5a7a8a; }
50% { background:#2a2312; color:#d8b25a; } } 50% { background:#2a2312; color:#d8b25a; } }
#vv-ai-dock-input { flex: 1; min-width: 0; background: #0a0a0a; border: 1px solid #262626;
border-radius: 4px; color: #c8c8c8; font-family: inherit; font-size: 12px;
padding: 5px 9px; }
#vv-ai-dock-input:focus { outline: none; border-color: #2d4a6a; }
#vv-ai-dock-body { border-bottom: 1px solid #1c1c1c; padding: 9px 10px; overflow-y: auto;
font-size: 12px; line-height: 1.55; }
/* Always present, unlike the collapse button next to it — the size of the conversation is worth
changing before there is one, when you already know the answer will be long. */
.vv-ai-dock-grow { flex-shrink: 0; min-width: 26px; }
.vv-ai-dock-grow-on { color: #8fb0c4; border-color: #2d4a6a; background: #12191d; }
.vv-ai-dock-q { color: #8fb0c4; margin: 0 0 5px; }
.vv-ai-dock-q::before { content: ' '; color: #3a5a6a; }
.vv-ai-dock-a { color: #b8b8b8; margin: 0 0 11px; white-space: normal; }
.vv-ai-dock-a code { background: #1a1a1a; border: 1px solid #333; border-radius: 2px;
padding: 0 4px; font-size: 11px; color: #9ab; }
.vv-ai-dock-a strong { color: #ddd; }
.vv-ai-dock-wait { color: #5a5a5a; font-style: italic; }
.vv-ai-dock-err { color: #e57; }
.vv-ai-dock-src { margin-top: 6px; padding-top: 5px; border-top: 1px solid #1c1c1c;
font-size: 10px; color: #4a4a4a; font-family: monospace; }
.vv-ai-dock-sep { margin: 4px 0 9px; font-size: 10px; color: #4a4a4a; text-align: center;
border-top: 1px solid #1c1c1c; padding-top: 6px; }
.vv-info-block .vv-sug-title { color: #9ab; } .vv-info-block .vv-sug-title { color: #9ab; }
/* Depth switch on the help header. Outlined rather than filled — it changes what you are /* Depth switch on the help header. Outlined rather than filled — it changes what you are
@@ -1208,15 +1190,11 @@ code.vv-unknown-var { color: #ff9800; background: #1f130d; }
.vv-rsync-save-btn { background: #1a2e1a; border-color: #2a4a2a; color: #6aaa6a; } .vv-rsync-save-btn { background: #1a2e1a; border-color: #2a4a2a; color: #6aaa6a; }
.vv-rsync-save-btn:hover { background: #22382a; } .vv-rsync-save-btn:hover { background: #22382a; }
/* ── AI profile picker ──────────────────────────────────────────────────────── /* ── Profile picker ───────────────────────────────────────────────────────────
Global rather than emitted with the chat widget, because both surfaces that show it are Global rather than emitted with the chat component. It was shared out here when the Scheduler
styled differently in every other respect: the Monitor card renders a full transcript from drew its own bar and loaded none of the component's stylesheet; that is no longer true — all
include/ai_chat.php, the Scheduler dock renders its own one-line bar and loads none of that three surfaces render the same markup now — but the picker is also used by the menus around it
stylesheet. The picker is the one control they share, so it lives where .vv-ai-dock-* already and reads more naturally beside them than inside the component's own block. */
does instead of being emitted twice. */
/* ── Profile picker ─────────────────────────────────────────────────────── */
/* The chip borrows the Scheduler dock's language deliberately — same muted blue, same weight,
same 10px — so the two surfaces read as one control that happens to appear in two places. */
.vv-ai-picker { position:relative; flex-shrink:0; } .vv-ai-picker { position:relative; flex-shrink:0; }
.vv-ai-chip { display:inline-flex; align-items:center; gap:5px; font-size:10px; color:#5a7a8a; .vv-ai-chip { display:inline-flex; align-items:center; gap:5px; font-size:10px; color:#5a7a8a;
background:#12191d; border:1px solid #24343d; border-radius:3px; padding:3px 8px; background:#12191d; border:1px solid #24343d; border-radius:3px; padding:3px 8px;
+5 -5
View File
@@ -90,7 +90,7 @@ require_once __DIR__ . '/config.php';
const VV_AI_KINDS = ['header', 'readme', 'manual', 'template', 'doc', 'ui']; const VV_AI_KINDS = ['header', 'readme', 'manual', 'template', 'doc', 'ui'];
// Profiles — what each one is, and what it is allowed to see and do. One table, in one file, // Profiles — what each one is, and what it is allowed to see and do. One table, in one file,
// read by everything: this endpoint, the worker, the shared chat include and the Scheduler dock. // read by everything: this endpoint, the worker, and the shared chat include every page renders.
// vv_ai_profile_can() and friends come from there. // vv_ai_profile_can() and friends come from there.
require_once __DIR__ . '/ai_profiles.php'; require_once __DIR__ . '/ai_profiles.php';
@@ -816,7 +816,7 @@ function vv_ai_token_stats(): array {
return $out; return $out;
} }
// ── Scoped lookups for the WebGUI dock ─────────────────────────────────────────────────────── // ── Scoped lookups for the Scheduler's assistant ──────────────────────────────────────────────
// The tail of one named script's log, for the troubleshooting profile. vv_ai_recent_logs() // The tail of one named script's log, for the troubleshooting profile. vv_ai_recent_logs()
// answers "is anything wrong anywhere"; this answers "why did THIS fail", which is a different // answers "is anything wrong anywhere"; this answers "why did THIS fail", which is a different
@@ -1308,11 +1308,11 @@ function vv_ai_chat_save(string $id, string $profile, array $messages, string $s
$prev = vv_ai_chat_read($id); $prev = vv_ai_chat_read($id);
$created = (int)($prev['created'] ?? time()); $created = (int)($prev['created'] ?? time());
// Scope travels with the conversation. A Scheduler dock thread is bound to what the operator // Scope travels with the conversation. A Scheduler thread is bound to what the operator had
// had open — a script, a log, a conf key — and its turns carry log excerpts chosen for that // open — a script, a log, a conf key — and its turns carry log excerpts chosen for that
// thing. Storing the scope means reopening the thread anywhere restores the context it was // thing. Storing the scope means reopening the thread anywhere restores the context it was
// reasoned in, rather than silently continuing a troubleshooting conversation against // reasoned in, rather than silently continuing a troubleshooting conversation against
// whatever happens to be on screen. The isolation the dock enforces in memory becomes a // whatever happens to be on screen. The isolation the page enforces in memory becomes a
// property of the record instead of something lost the moment it is saved. // property of the record instead of something lost the moment it is saved.
$rec = [ $rec = [
'id' => $id, 'id' => $id,
+163 -29
View File
@@ -1,9 +1,9 @@
<?php <?php
// ═══════════════════════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE // PURPOSE
// The conversation surface itself — profile bar, transcript, composer, source viewer and the // The conversation surface itself — transcript, composer, profile and history pickers, source
// stored-chat list — rendered wherever a full chat belongs. Currently the AI tab and the // viewer and the stored-chat list — rendered wherever a chat belongs. All three surfaces use
// Monitor tab's AI row. // it: the AI tab, the Monitor tab's AI row, and the Scheduler's right-hand panel.
// //
// WHY THIS IS AN INCLUDE // WHY THIS IS AN INCLUDE
// There were two places that needed a real transcript and one implementation, which meant the // There were two places that needed a real transcript and one implementation, which meant the
@@ -12,10 +12,18 @@
// teardown of a stale instance across an Unraid tab swap — is subtle because each of them // 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. // 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 Scheduler panel used to be exactly that copy: its own bar, send loop, poll and store
// the view you have open, with its own scope chip, fix flow and incident capture; it is a // handling. It proved the point — a thread there died on reload while the other two were saved,
// different component that happens to talk to the same endpoint. Folding it in here would mean // and its bar drifted into a different shape from the same control everywhere else. It is an
// one widget with two personalities and a mode flag deciding which. // 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 // INSTANCES
// Every id is composed from a prefix, so two chats can coexist on one page. Each instance // Every id is composed from a prefix, so two chats can coexist on one page. Each instance
@@ -69,10 +77,10 @@ require_once __DIR__ . '/ai_profiles.php';
// The conversation store, on its own, with no styling and no chat widget attached. // 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 // Emitted separately because a page may want the store without the component — a list of saved
// rest: the Scheduler dock draws its own one-line bar, with its own scope chip and fix flow, and // conversations, or a save, with no transcript rendered. Every surface currently takes both, but
// pulling in the full transcript stylesheet to reach a save function would restyle a component // the two are not the same dependency and pulling in a stylesheet to reach a save function is how
// that was deliberately built to look different. // they would become one.
// //
// Where a conversation lives is not a presentation decision, so it gets one answer for every // 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 // surface. Anything added to the store — a new field, a new cap, a changed prune rule — arrives
@@ -132,8 +140,8 @@ function vv_ai_chat_assets(): void {
.vv-ai-input:focus { outline:none; border-color:#2d4a6a; } .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; } .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 /* Profile picker styling lives in css/varaverk.css, alongside the menus it shares its shape
and loads none of this stylesheet. */ with, rather than here. */
.vv-ai-switch { text-align:center; font-size:10px; color:#3a3a3a; margin:10px 0; .vv-ai-switch { text-align:center; font-size:10px; color:#3a3a3a; margin:10px 0;
border-top:1px dashed #1e1e1e; padding-top:8px; } border-top:1px dashed #1e1e1e; padding-top:8px; }
@@ -174,6 +182,13 @@ function vv_ai_chat_assets(): void {
.vv-ai-src-s { color:#333; font-family:monospace; margin-left:auto; 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-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; } .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; } .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;} } @keyframes vvAiPulse { 0%,100%{opacity:.25;} 50%{opacity:1;} }
@@ -215,9 +230,9 @@ function vv_ai_chat_assets(): void {
background:#161616; border-color:#333; } background:#161616; border-color:#333; }
.vv-ai-c .vv-ai-input:focus { border-color:#6495ed; } .vv-ai-c .vv-ai-input:focus { border-color:#6495ed; }
/* Styled after the Scheduler dock: 12px, tighter leading, questions marked with a chevron rather /* 12px, tighter leading, questions marked with a chevron rather than a role label. The role
than a role label. The role labels cost a line each and say the same two things forever — on a labels cost a line each and say the same two things forever — in a space this size that is a
card this size that is a third of the visible transcript spent on "You" and "Varaverk". */ third of the visible transcript spent on "You" and "Varaverk". */
.vv-ai-c .vv-ai-role { display:none; } .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-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 { color:#8fb0c4; }
@@ -293,7 +308,7 @@ function vv_ai_chat_assets(): void {
<?php <?php
// The profile registry, served rather than restated. This used to be a literal table in the // 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 // 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 // knew four, which is why troubleshoot could not be offered here and the Scheduler had to
// hand-roll its own labels. // hand-roll its own labels.
vv_ai_profiles_script(); vv_ai_profiles_script();
?> ?>
@@ -344,6 +359,12 @@ vv_ai_profiles_script();
const $ = sfx => document.getElementById(P + '-' + sfx); const $ = sfx => document.getElementById(P + '-' + sfx);
const onTurn = o.onTurn || function () {}; const onTurn = o.onTurn || function () {};
const onChats = o.onChats || 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 () {};
const store = o.chats !== false; const store = o.chats !== false;
const POLL_MS = 1200; const POLL_MS = 1200;
const POLL_CEIL = 300000; // stop polling a worker that never wrote a terminal state const POLL_CEIL = 300000; // stop polling a worker that never wrote a terminal state
@@ -356,6 +377,7 @@ vv_ai_profiles_script();
let messages = []; // whole transcript — displayed and stored let messages = []; // whole transcript — displayed and stored
let sendFrom = 0; // index the model is allowed to see from let sendFrom = 0; // index the model is allowed to see from
let chatId = ''; // '' until the store mints one let chatId = ''; // '' until the store mints one
let scopeLabel = o.scopeLabel || ''; // subject shown on the chip; '' on surfaces without one
let busy = false; let busy = false;
let lastSources = []; let lastSources = [];
let pendingTimer = null; let pendingTimer = null;
@@ -459,7 +481,10 @@ vv_ai_profiles_script();
if (cite) { if (cite) {
const s = lastSources[Number(cite.dataset.cite) - 1]; const s = lastSources[Number(cite.dataset.cite) - 1];
if (s && s.path) vvAiOpen(s.path); 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');
}); });
// ── Ask / poll ─────────────────────────────────────────────────────── // ── Ask / poll ───────────────────────────────────────────────────────
@@ -476,6 +501,12 @@ vv_ai_profiles_script();
const q = $('input').value.trim(); const q = $('input').value.trim();
if (!q) return; 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; busy = true;
$('send').disabled = true; $('send').disabled = true;
addUser(q); addUser(q);
@@ -499,7 +530,12 @@ vv_ai_profiles_script();
question: q, question: q,
history: JSON.stringify(sendable()), history: JSON.stringify(sendable()),
kind: (PROFILES[profile].kind && kindEl) ? kindEl.value : '', kind: (PROFILES[profile].kind && kindEl) ? kindEl.value : '',
think: (thinkEl ? thinkEl.checked : true) ? '1' : '0', // 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',
}); });
res = fetch(API, { method: 'POST', headers: POST_HEAD, body }); res = fetch(API, { method: 'POST', headers: POST_HEAD, body });
} catch (e) { } catch (e) {
@@ -534,7 +570,9 @@ vv_ai_profiles_script();
// that exists in both places on purpose. // that exists in both places on purpose.
function sendable() { function sendable() {
const floor = Math.max(sendFrom, messages.length - PROFILES[profile].turns * 2); const floor = Math.max(sendFrom, messages.length - PROFILES[profile].turns * 2);
return messages.slice(floor); // 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() { function finish() {
@@ -593,14 +631,55 @@ vv_ai_profiles_script();
const c = chatEl(); const c = chatEl();
if (!messages.length) { reset(); return; } if (!messages.length) { reset(); return; }
c.innerHTML = ''; c.innerHTML = '';
messages.forEach(m => { messages.forEach((m, i) => {
if (m.role === 'user') { addUser(m.content); return; } if (m.role === 'user') { addUser(m.content); return; }
c.appendChild(el(`<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>` 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>`)); + `<div class="vv-ai-body">${fmt(m.content)}</div>`
+ (m.offer ? offerHtml(m.offer, i) : '') + `</div>`));
}); });
scroll(); 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() { function reset() {
chatEl().innerHTML = `<div class="vv-ai-empty">${esc(o.empty || 'Ask Varaverk about itself.')}</div>`; chatEl().innerHTML = `<div class="vv-ai-empty">${esc(o.empty || 'Ask Varaverk about itself.')}</div>`;
} }
@@ -631,7 +710,16 @@ vv_ai_profiles_script();
function applyProfile(p) { function applyProfile(p) {
profile = p; profile = p;
const chipL = $('chip-l'); const chipL = $('chip-l');
if (chipL) chipL.textContent = PROFILES[p] ? PROFILES[p].label : p; // 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'); const menu = $('menu');
if (menu) menu.querySelectorAll('.vv-ai-opt').forEach(b => if (menu) menu.querySelectorAll('.vv-ai-opt').forEach(b =>
b.classList.toggle('active', b.dataset.prof === p)); b.classList.toggle('active', b.dataset.prof === p));
@@ -641,6 +729,7 @@ vv_ai_profiles_script();
if (chip) chip.title = PROFILES[p] ? PROFILES[p].hint : ''; if (chip) chip.title = PROFILES[p] ? PROFILES[p].hint : '';
const kindEl = o.kindEl ? document.getElementById(o.kindEl) : null; const kindEl = o.kindEl ? document.getElementById(o.kindEl) : null;
if (kindEl) kindEl.style.display = (PROFILES[p] && PROFILES[p].kind) ? '' : 'none'; if (kindEl) kindEl.style.display = (PROFILES[p] && PROFILES[p].kind) ? '' : 'none';
onProfile(p);
} }
// Switching moves the floor rather than clearing the transcript. Carrying cited, // Switching moves the floor rather than clearing the transcript. Carrying cited,
@@ -764,20 +853,26 @@ vv_ai_profiles_script();
// Scroll position is pinned to the bottom afterwards. Growing the box leaves the transcript // 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 // scrolled where it was, which puts the newest answer off-screen at the exact moment you
// asked for more room to read it. // 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().
let big = false;
const growBtn = $('grow'); const growBtn = $('grow');
if (growBtn) { function applyHeights() {
const el = chatEl(); const el = chatEl();
const base = el.dataset.h || ''; const base = el.dataset.h || '';
const tall = el.dataset.hTall || ''; const tall = el.dataset.hTall || '';
const apply = big => {
if (!base || !tall) return; if (!base || !tall) return;
el.style.height = big ? tall : base; el.style.height = big ? tall : base;
if (growBtn) {
growBtn.textContent = big ? '⤡' : '⤢'; growBtn.textContent = big ? '⤡' : '⤢';
growBtn.title = big ? 'Back to the smaller view' : 'Give the conversation more room'; growBtn.title = big ? 'Back to the smaller view' : 'Give the conversation more room';
growBtn.classList.toggle('vv-ai-grow-on', big); growBtn.classList.toggle('vv-ai-grow-on', big);
scroll(); }
}; }
growBtn.addEventListener('click', () => apply(el.style.height !== tall)); if (growBtn) {
growBtn.addEventListener('click', () => { big = !big; applyHeights(); scroll(); });
} }
// Surface any script error into the transcript. Without it a throw anywhere on the page is // Surface any script error into the transcript. Without it a throw anywhere on the page is
@@ -820,8 +915,47 @@ vv_ai_profiles_script();
const inst = { const inst = {
prefix: P, prefix: P,
setProfile, newChat, loadChat, send, setProfile, newChat, loadChat, send, offer,
busy: () => busy,
expanded: () => big,
currentId: () => chatId, 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, tall) {
const el = chatEl();
el.dataset.h = base; el.dataset.hTall = tall;
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(text) {
chatEl().appendChild(el('<div class="vv-ai-switch">' + esc(text) + '</div>'));
scroll();
},
teardown() { teardown() {
clearInterval(pendingTimer); clearInterval(pendingTimer);
window.removeEventListener('error', onErr); window.removeEventListener('error', onErr);
@@ -851,7 +985,7 @@ vv_ai_profiles_script();
box.innerHTML = '<div class="vv-ai-none">no saved conversations yet</div>'; box.innerHTML = '<div class="vv-ai-none">no saved conversations yet</div>';
return; return;
} }
// The scope is shown, not just stored. A Scheduler dock thread is about one script or log, // 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" // and a list of titles alone makes "why does this one talk about a log I never opened"
// an unanswerable question. // an unanswerable question.
// Tagged only when the tag says something. A scope always does; a profile does unless it // Tagged only when the tag says something. A scope always does; a profile does unless it
+5 -4
View File
@@ -2,7 +2,8 @@
// ═══════════════════════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════════════════════
// PURPOSE // PURPOSE
// The one definition of what an AI profile is. Every consumer — the endpoint, the worker, the // The one definition of what an AI profile is. Every consumer — the endpoint, the worker, the
// shared chat include, the Scheduler dock — reads it from here instead of restating it. // shared chat include and every page that renders it — reads it from here instead of
// restating it.
// //
// WHY THIS EXISTS // WHY THIS EXISTS
// A profile used to be defined in five places: history depth in api/ai.php, capabilities in // A profile used to be defined in five places: history depth in api/ai.php, capabilities in
@@ -187,9 +188,9 @@ function vv_ai_profiles_client(): array {
// surfaces ask for it. Emitted as its own tag so any script block that consumes it stays pure // surfaces ask for it. Emitted as its own tag so any script block that consumes it stays pure
// JavaScript and remains syntax-checkable outside PHP. // JavaScript and remains syntax-checkable outside PHP.
// //
// Both the shared chat include and the Scheduler dock call this. Before it existed the dock had // The shared chat include calls this, as does any page emitting the registry ahead of it. Before
// its own literal `{ code: 'Code', troubleshoot: 'Troubleshoot' }` map, which is how a fourth // it existed the Scheduler had its own literal `{ code: 'Code', troubleshoot: 'Troubleshoot' }`
// copy of the profile list came to exist in the first place. // map, which is how a fourth copy of the profile list came to exist in the first place.
function vv_ai_profiles_script(): void { function vv_ai_profiles_script(): void {
static $done = false; static $done = false;
if ($done) return; if ($done) return;
+3 -2
View File
@@ -356,8 +356,9 @@ $_vv_doc_vars = array_merge(vv_conf_vars(), ['SCRIPTS_DIR' => SCRIPTS_DIR]);
<?php vv_ai_chat_list_markup('vv-mon-ai', true); ?> <?php vv_ai_chat_list_markup('vv-mon-ai', true); ?>
</div> </div>
<!-- Named for what it is, not "dock" — the Scheduler tab's vv-ai-dock is a different <!-- Named for what it is, not "dock". The Scheduler renders the same component inside a
component and vvAiDockOn() there tests for that id by name. --> wrapper that still carries the #vv-ai-dock id, and two elements answering to that name
on one page would be a trap the day anything queries it globally. -->
<div class="vv-card" id="vv-ai-assistant-card"> <div class="vv-card" id="vv-ai-assistant-card">
<h3> <h3>
<span style="display:flex;align-items:center;gap:5px;"> <span style="display:flex;align-items:center;gap:5px;">
+25 -4
View File
@@ -56,11 +56,13 @@ as the GPU and UPS cards.
| Card | What it tells you | | Card | What it tells you |
|---|---| |---|---|
| AI | Model residency, context size, VRAM, index size and staleness, tokens used today | | AI | Model residency, context size, VRAM, index size and staleness |
| Tokens | What the assistant has spent — per node, and for today, the last 7 days and all time |
| Conversations | The last `$AI_CHAT_HISTORY_MAX` chats. Click to reopen, × to delete | | Conversations | The last `$AI_CHAT_HISTORY_MAX` chats. Click to reopen, × to delete |
| Assistant | Ask a question without leaving the dashboard | | Assistant | Ask a question without leaving the dashboard |
| ⤢ | Expands the conversation to about two and a half times its height. Remembered | | Profile chip | Which contract answers — General Chat, Varaverk Assistant, Code Sketcher, Troubleshoot |
| Profile buttons | Which contract answers — General Chat, Varaverk Assistant, Code Sketcher, Troubleshoot | | ⤢ | Trades more room for the conversation, and back. Always opens at the smaller size |
| ▾ | Saved conversations, the same ones the Conversations card lists |
--- ---
@@ -79,6 +81,25 @@ implied.
These figures come from the same one-minute collection the AI tab's banner uses, so the two These figures come from the same one-minute collection the AI tab's banner uses, so the two
cannot disagree about whether the model is loaded. cannot disagree about whether the model is loaded.
## Tokens is its own card, not a line on the AI one
The AI card answers whether the model is healthy right now. What it has spent over a month is a
different question, and it was crowding that card out one line at a time.
`Nodes` lists each host with its total. A host with no rows reads **not collected here**, never
zero — each host writes to its own `data/` and only `ai_token_sync.sh` moves a ledger between
them, so a zero would claim the partner sat idle when the truth is that this host cannot see its
ledger at all. Click a node to scope the figures below it to that host; click **All hosts** to go
back.
The totals cover today, the last 7 days and all time, each with the turn count and the prompt /
completion split. The footer adds how long the ledger has been running, the best throughput seen,
and — under All hosts only — the split by profile and by source. Those last two describe the whole
ledger, so they are not shown under a single host's heading where they would not describe it.
This is the same ledger the AI tab shows, read from the same endpoint. It refreshes when a turn
finishes here, and otherwise once a minute, because the numbers only move when a turn completes.
## The assistant here starts on General Chat ## The assistant here starts on General Chat
The AI tab starts on Varaverk Assistant, which answers only from this installation's The AI tab starts on Varaverk Assistant, which answers only from this installation's
@@ -91,7 +112,7 @@ to the strict profile automatically, and handed back if the documentation turns
it. Starting strict would refuse ordinary questions to guard against a mistake the server already it. Starting strict would refuse ordinary questions to guard against a mistake the server already
prevents. prevents.
Switching profile with the buttons keeps what is on screen but stops sending earlier turns to the Switching profile with the chip keeps what is on screen but stops sending earlier turns to the
model. Carrying cited, retrieval-grounded answers into a mode with no retrieval makes the model model. Carrying cited, retrieval-grounded answers into a mode with no retrieval makes the model
keep referring to sources it can no longer see. keep referring to sources it can no longer see.
@@ -80,6 +80,42 @@ Two blocks on the Scheduler Info panel get you there faster:
The status dot in the Orch tree is green, orange or red for the last run's outcome, dim if the The status dot in the Orch tree is green, orange or red for the last run's outcome, dim if the
script has never run. Hover for detail. script has never run. Hover for detail.
## Asking the assistant about what is on screen
The panel below the right-hand views is the same chat component the AI tab and the Monitor
dashboard use, at this panel's size. What is particular to this tab is that its subject follows
you: open a log, a conf or a script and the chip states both the profile answering and the thing
it is pointed at. Nothing is inferred silently — if what the chip says is wrong, that costs a
glance rather than a confidently wrong answer.
Every row in **Recent Activity** carries a **why?** (failed) or **recap** (clean) button. It opens
that script's log first and then asks, because the question is only worth asking with the log
attached — the profile that reads it is the one that gets the log tail. The question is posted as
visible text, so what was asked on your behalf is never a mystery.
Moving to a different subject keeps the transcript on screen but stops sending the earlier turns
to the model, and starts a new stored conversation. A troubleshooting thread carrying log excerpts
must not bleed into a question about a conf key; hiding that the earlier exchange happened would
be worse than carrying it visibly.
Reasoning is enabled only for Troubleshoot. Working out what a log means is worth waiting for; a
lookup like "what does this setting do" is not, and an inline answer that stalls reads as broken.
## Recording what actually fixed something
When a diagnosis is followed by that same script running clean, the assistant offers to record it:
*"`<script>` just ran clean. Want me to record what fixed it?"* Answer **Yes** and the next thing
you type is stored as the fix rather than asked as a question.
The trigger is the run record, not the conversation. It fires once, at the moment the trouble is
demonstrably over, rather than after every answer — an offer that appears constantly is one you
learn to skip past.
What gets stored is the symptom you asked about and the fix you describe. The model's diagnosis is
deliberately not stored: it is a reading of evidence, and writing a hypothesis into institutional
memory as settled fact is how a wrong answer outlives the incident it came from. What is
remembered is what actually worked, and it is shown the next time the same thing is diagnosed.
## Stopping a script that is stuck ## Stopping a script that is stuck
**Stop** sends SIGTERM, waits 3 seconds, then SIGKILL, and clears any lock files the script left **Stop** sends SIGTERM, waits 3 seconds, then SIGKILL, and clears any lock files the script left
@@ -138,6 +174,11 @@ on disk is never touched until you press Save.
| **Auto Scroll** | Toolbar | Follows the newest line; pauses if you scroll up | | **Auto Scroll** | Toolbar | Follows the newest line; pauses if you scroll up |
| **Cancel** | Toolbar, editing | Discards changes and returns here. The file is untouched until Save | | **Cancel** | Toolbar, editing | Discards changes and returns here. The file is untouched until Save |
| **+ Folder** | Custom Scripts | A collapsible subfolder you can drag scripts into | | **+ Folder** | Custom Scripts | A collapsible subfolder you can drag scripts into |
| **why? / recap** | Recent Activity | Opens that run's log and asks the assistant about it in one click |
| **Profile chip** | Assistant | What is answering, and what it is pointed at. Click to change the profile |
| **⤢** | Assistant | Trades more of the panel for the conversation, and back |
| **▾** | Assistant | Saved conversations, shared with the AI tab and the Monitor dashboard |
| **New** | Assistant | Starts a fresh conversation; the old one stays in the store |
## Reference — orchestrator and step behaviour ## Reference — orchestrator and step behaviour
+145 -291
View File
@@ -65,8 +65,8 @@
require_once dirname(__DIR__) . '/include/scheduler.php'; require_once dirname(__DIR__) . '/include/scheduler.php';
require_once dirname(__DIR__) . '/include/docs.php'; require_once dirname(__DIR__) . '/include/docs.php';
require_once dirname(__DIR__) . '/include/ai_profiles.php'; require_once dirname(__DIR__) . '/include/ai_profiles.php';
// For vv_ai_chat_store_script() only — this page renders no chat widget. The store is shared; // The chat component itself now, not only the store: the assistant panel in the right-hand pane
// the presentation deliberately is not. // is an instance of it rather than a second implementation.
require_once dirname(__DIR__) . '/include/ai_chat.php'; require_once dirname(__DIR__) . '/include/ai_chat.php';
// Live values for the `$VAR` markers in pages/readme/*.md. Conf variables, plus the derived // Live values for the `$VAR` markers in pages/readme/*.md. Conf variables, plus the derived
@@ -1060,49 +1060,36 @@ Still the same two servers, two households, the same media stack running itself.
them: the Scheduler Info card is only on screen in the suggestions view, so a chat box them: the Scheduler Info card is only on screen in the suggestions view, so a chat box
living there would vanish exactly when the context — a conf, a log, a script — is most living there would vanish exactly when the context — a conf, a log, a script — is most
worth asking about. worth asking about.
At rest it is one input row. Answers expand it upward and the views above shrink to It takes a share of the panel and the views above shrink to suit, which is why
suit, which is why vvFitRight() subtracts its height. It pushes rather than overlays vvFitRight() subtracts its height. It pushes rather than overlays, so the thing being
so the thing you are asking about stays on screen. Collapsing keeps the conversation. --> asked about stays on screen; ⤢ trades more of the panel for the conversation and back. -->
<?php <?php
// The registry and the conversation store — not the chat widget. This dock draws its own // The registry, the store, and now the component itself. vv_ai_chat_assets() is what
// bar and must keep looking like itself; what it must not have is its own answer to where // defines VvAiChat and carries the transcript and composer styling — without it the markup
// a conversation lives. // below renders as inert boxes with no factory to bring them to life. It was absent while
// this panel drew its own bar.
vv_ai_profiles_script(); vv_ai_profiles_script();
vv_ai_chat_store_script(); vv_ai_chat_store_script();
vv_ai_chat_assets();
?> ?>
<!-- The same component the AI tab and the Monitor card render, at this panel's size. It
used to be a second implementation with its own bar, send loop and poll — which is how
a thread here died on reload while the other two were stored, and how this bar drifted
into a different shape from the same control everywhere else.
The wrapper keeps the #vv-ai-dock id: vvAiDockOn() reads its presence to decide whether
to draw the "why?" buttons on the activity rows, and vvFitRight() measures it.
Heights are set at runtime, not here — see vvFitRight(). This panel's share of the page
is not a number that exists until layout has run. -->
<div id="vv-ai-dock"> <div id="vv-ai-dock">
<div id="vv-ai-dock-body" style="display:none"></div> <?php vv_ai_chat_markup('vv-sched-ai', [
<div id="vv-ai-dock-bar"> 'profile' => 'varaverk',
<!-- The chip is now the trigger for the profile picker as well as the scope readout. 'compact' => true,
#vv-ai-dock-chip stays the label span it always was, so every existing write to 'scopeLabel' => 'Scheduler',
its textContent still lands; the button around it carries the decoration. --> 'empty' => 'Ask about what is on screen.',
<div class="vv-ai-picker" id="vv-ai-dock-picker"> 'placeholder' => 'Ask about what is on screen…',
<button class="vv-ai-chip" id="vv-ai-dock-chipbtn" type="button" ]); ?>
aria-haspopup="listbox" aria-expanded="false"
title="What the assistant will answer about — follows the view you have open. Click to change profile.">
<span id="vv-ai-dock-chip">Assistant · Scheduler</span><span class="vv-ai-chip-c">▾</span>
</button>
<div class="vv-ai-menu" id="vv-ai-dock-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>
<input type="text" id="vv-ai-dock-input" autocomplete="off"
placeholder="Ask about what is on screen…"
onkeydown="if(event.key==='Enter'){event.preventDefault();vvAiDockSend();}">
<button class="vv-btn-sm" id="vv-ai-dock-send" onclick="vvAiDockSend()">Ask</button>
<button class="vv-btn-sm" id="vv-ai-dock-fix" onclick="vvAiFixStart()"
style="display:none" title="Record what actually fixed this, against this script">Save fix</button>
<button class="vv-btn-sm" id="vv-ai-dock-hide" onclick="vvAiDockCollapse()"
style="display:none" title="Collapse — the conversation is kept">▾</button>
<button class="vv-btn-sm vv-ai-dock-grow" id="vv-ai-dock-grow" onclick="vvAiDockGrow()"
title="Give the conversation more of the panel">⤢</button>
</div>
</div> </div>
<?php endif; ?> <?php endif; ?>
<div class="vv-sched-footer vv-sched-info vv-snap-footer" id="vv-sched-info-footer"> <div class="vv-sched-footer vv-sched-info vv-snap-footer" id="vv-sched-info-footer">
@@ -1205,11 +1192,6 @@ function vvPost(url, data) {
}).then(r => r.json()); }).then(r => r.json());
} }
// Whether the assistant dock is holding the larger share of the panel. Persisted: someone working
// through a long answer wants it to still be big after a reload, not to re-expand every visit.
// Declared here rather than with the other dock state because vvFitRight() below reads it.
let vvAiDockBig = localStorage.getItem('vv-ai-dock-big') === '1';
function vvFitRight() { function vvFitRight() {
const right = document.getElementById('vv-sched-right'); const right = document.getElementById('vv-sched-right');
if (!right.classList.contains('vv-panel-visible')) return; if (!right.classList.contains('vv-panel-visible')) return;
@@ -1245,10 +1227,14 @@ function vvFitRight() {
// Expanded takes most of the panel rather than all of it. The view above is what the question // Expanded takes most of the panel rather than all of it. The view above is what the question
// is usually about — a log, a conf, a script — and a chat that covers it entirely turns every // is usually about — a log, a conf, a script — and a chat that covers it entirely turns every
// follow-up into a round trip through the collapse button. // follow-up into a round trip through the collapse button.
const dockBody = document.getElementById('vv-ai-dock-body'); // Both heights handed to the chat rather than one cap applied to it, because the component owns
const dockCap = vvAiDockBig ? 0.82 : 0.4; // which of the two it is currently at. Recomputed on every fit so a resize while expanded stays
if (dockBody) dockBody.style.maxHeight = Math.max(90, Math.round(availH * dockCap)) + 'px'; // expanded at the new panel size instead of snapping back to the resting share.
// Measured after the cap is applied, so this reads the clamped height, not the natural one. if (vvSchedChat) {
vvSchedChat.setHeights(Math.max(90, Math.round(availH * 0.40)) + 'px',
Math.max(90, Math.round(availH * 0.82)) + 'px');
}
// Measured after the heights are applied, so this reads the clamped height, not the natural one.
const dock = document.getElementById('vv-ai-dock'); const dock = document.getElementById('vv-ai-dock');
const dockH = dock ? dock.offsetHeight : 0; const dockH = dock ? dock.offsetHeight : 0;
const contentH = Math.max(80, availH - dockH); const contentH = Math.max(80, availH - dockH);
@@ -2377,48 +2363,42 @@ function vvClickCog(el) {
let vvAiProfile = 'varaverk'; let vvAiProfile = 'varaverk';
let vvAiScope = 'Scheduler'; let vvAiScope = 'Scheduler';
let vvAiScopeLabel = 'Scheduler'; // what the chip reads; the target is the id behind it let vvAiScopeLabel = 'Scheduler'; // what the chip reads; the target is the id behind it
let vvAiHist = []; // what the model is told; reset when the scope changes let vvSchedChat = null; // the shared chat instance, once the row has rendered
let vvAiChatId = ""; // the stored conversation this thread is appending to
let vvAiBusy = false;
let vvAiPoll = null;
function vvAiDockOn() { return !!document.getElementById('vv-ai-dock'); } // Presence of the instance, not of an element. Everything that guards on this — the "why?" buttons
// on the activity rows, the panel fit — needs a working chat, and on a host without the AI row
// there is neither.
function vvAiDockOn() { return !!vvSchedChat; }
// ── Profile picker ─────────────────────────────────────────────────────────── // The profile picker, the send loop, the poll, the transcript and the store are all the component's
// The chip states the contract and the thing being asked about; clicking it changes the first // now. What stays here is the part that is genuinely this tab's: which script, log or conf the
// without moving the second. Same control as the Monitor card's, same stylesheet — the dock keeps // question is about, and how that follows the view the operator has open.
// its own bar because that is a real difference, but which profile answers is not a per-surface if (document.getElementById('vv-sched-ai-chat')) {
// idea and should not have a per-surface control. vvSchedChat = VvAiChat({
// prefix: 'vv-sched-ai',
// Routes through vvAiDockScope() rather than assigning vvAiProfile, so a profile change gets the profile: vvAiProfile,
// history reset and the on-screen marker that a scope change already gets. Picking a new contract scopeLabel: vvAiScopeLabel,
// mid-thread and silently feeding it the previous one's turns is the bug that rule exists for. // Re-read at send time rather than captured, because the operator moves around this tab
(function vvAiPickerInit() { // between asking and sending.
const picker = document.getElementById('vv-ai-dock-picker'); scope: () => vvAiScope,
if (!picker) return; // Reasoning for diagnosis only — see the note in the component's ask payload.
const btn = document.getElementById('vv-ai-dock-chipbtn'); think: p => p === 'troubleshoot',
const menu = document.getElementById('vv-ai-dock-menu'); // The picker is the component's, so this is how the page learns the contract changed.
const close = () => { picker.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); }; onProfile: p => { vvAiProfile = p; },
// Claims what was typed rather than asking it, once, after a fix offer is accepted. Also the
btn.addEventListener('click', e => { // one place that reliably sees every question, so it is where the symptom is remembered.
e.stopPropagation(); beforeSend: q => {
const open = picker.classList.toggle('open'); if (vvAiFixPending) { vvAiFixSave(q); return true; }
btn.setAttribute('aria-expanded', open ? 'true' : 'false'); vvAiLastQ = q;
menu.querySelectorAll('.vv-ai-opt').forEach(o => return false;
o.classList.toggle('active', o.dataset.prof === vvAiProfile)); },
onTurn: () => { vvAiFixArm(); requestAnimationFrame(vvFitRight); },
onOffer: (kind, yes) => { if (kind === 'fix') vvAiFixAnswer(yes); },
}); });
menu.addEventListener('click', e => { }
const o = e.target.closest('.vv-ai-opt');
if (!o) return;
close();
vvAiDockScope(o.dataset.prof, vvAiScopeLabel, vvAiScope);
});
document.addEventListener('click', e => { if (!picker.contains(e.target)) close(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') close(); });
})();
// Called by every view switch. Profile and scope are derived from what is open and shown on the // Called by every view switch. Profile and scope are derived from what is open and shown on the
// chip — never chosen, never hidden. If the user can see what it thinks it is looking at, a // chip — never chosen, never hidden. If the operator can see what it thinks it is looking at, a
// wrong inference costs a glance instead of a confidently wrong answer. // wrong inference costs a glance instead of a confidently wrong answer.
// label is what the chip shows; target is what the worker resolves. They differ for logs, where // label is what the chip shows; target is what the worker resolves. They differ for logs, where
// the chip wants "daily_sync_maintenance log" and the worker needs the script id it can turn // the chip wants "daily_sync_maintenance log" and the worker needs the script id it can turn
@@ -2428,179 +2408,107 @@ function vvAiDockScope(profile, label, target) {
target = target || label; target = target || label;
if (profile === vvAiProfile && target === vvAiScope) return; if (profile === vvAiProfile && target === vvAiScope) return;
const had = vvAiHist.length > 0;
vvAiProfile = profile; vvAiProfile = profile;
vvAiScope = target; vvAiScope = target;
vvAiScopeLabel = label; // kept so the picker can change profile without moving the scope vvAiScopeLabel = label;
// Short label from the registry, not a literal map. The map that used to be here was the // One call for both, so changing profile and subject together leaves one line in the transcript
// fifth place a profile got defined, and it silently fell back to "Assistant" for any id it // rather than two saying nearly the same thing. The component keeps the transcript and moves
// had not been told about — so a new profile would have shown up in the chip as the strict one. // only the floor the model is told about.
const _p = (window.VvAiProfiles || {})[profile]; vvSchedChat.retarget(profile, label, 'now looking at ' + label);
document.getElementById('vv-ai-dock-chip').textContent = // A fix offer belongs to the thing it was diagnosed against; moving the subject abandons it.
(_p ? _p.short : profile) + ' · ' + label; vvAiFixWatch = null;
// Transcript stays, history sent to the model resets — the same rule the AI tab's profile
// buttons already use. A troubleshooting thread carrying log excerpts must not bleed into a
// question about a conf key, but hiding that the earlier exchange happened is worse.
if (had) {
vvAiHist = [];
// A new stored conversation too, not a continuation of the last one. The scope is part of
// the record, so appending turns about a different thing to the same row would produce a
// conversation whose stored scope describes only its first half.
vvAiChatId = '';
vvAiDockAppend('<div class="vv-ai-dock-sep">now looking at ' + vvEscHtml(label) + '</div>');
}
// The scope moved under text already typed. Not blocked — just never silent. // The scope moved under text already typed. Not blocked — just never silent.
const input = document.getElementById('vv-ai-dock-input'); const input = document.getElementById('vv-sched-ai-input');
if (input.value.trim() !== '') { if (input && input.value.trim() !== '') {
// The button, not the label span inside it. The flash animates a background, and the // The button, not the label span inside it. The flash animates a background, and the
// background moved to the button when the chip became the picker's trigger. // background lives on the button that triggers the picker.
const chip = document.getElementById('vv-ai-dock-chipbtn'); const chip = document.getElementById('vv-sched-ai-chip');
if (chip) {
chip.classList.remove('vv-ai-chip-flash'); chip.classList.remove('vv-ai-chip-flash');
void chip.offsetWidth; void chip.offsetWidth;
chip.classList.add('vv-ai-chip-flash'); chip.classList.add('vv-ai-chip-flash');
} }
}
} }
// Anything that changes what is in the dock has to re-run the fit, or the dock keeps the height
// the new content wants and pushes the panel past the bottom of the page.
function vvAiDockAppend(html) {
const body = document.getElementById('vv-ai-dock-body');
body.insertAdjacentHTML('beforeend', html);
body.scrollTop = body.scrollHeight;
requestAnimationFrame(vvFitRight);
}
function vvAiDockExpand() {
document.getElementById('vv-ai-dock-body').style.display = '';
document.getElementById('vv-ai-dock-hide').style.display = '';
vvAiDockApplyGrow();
requestAnimationFrame(vvFitRight);
}
// One control, two jobs, decided by what is on screen: grow the conversation, or — once it is
// already holding the panel — close it. A dedicated shrink-back would be a third state nobody
// asked for; at full size the only thing left to want is the view above.
//
// Closing from expanded also gives the size back. Without that, 82% is a one-way door: the next
// question reopens at 82%, the button is a collapse again, and nothing ever restores the 40%.
function vvAiDockGrow() {
const open = vvAiDockBodyOpen();
vvAiDockBig = !(vvAiDockBig && open);
localStorage.setItem('vv-ai-dock-big', vvAiDockBig ? '1' : '0');
if (vvAiDockBig) vvAiDockExpand();
else vvAiDockCollapse();
}
function vvAiDockBodyOpen() {
const b = document.getElementById('vv-ai-dock-body');
return !!b && b.style.display !== 'none';
}
// Called by expand and collapse as well as on load, so the button always describes the action it
// will actually perform. A stored "expanded" preference with the conversation shut still reads as
// grow — collapsing something already closed is not an action worth offering.
function vvAiDockApplyGrow() {
const b = document.getElementById('vv-ai-dock-grow');
if (!b) return;
const collapses = vvAiDockBig && vvAiDockBodyOpen();
b.textContent = collapses ? '▾' : '⤢';
b.title = collapses ? 'Collapse — the conversation is kept'
: 'Give the conversation more of the panel';
b.classList.toggle('vv-ai-dock-grow-on', collapses);
// Two collapse buttons side by side is one too many. While this one collapses, the other hides.
const hide = document.getElementById('vv-ai-dock-hide');
if (hide && collapses) hide.style.display = 'none';
}
// Collapse is not clear. Re-expanding shows the same conversation, so you can shrink it to read
// the conf the answer was about and open it again without paying for the turn twice.
function vvAiDockCollapse() {
document.getElementById('vv-ai-dock-body').style.display = 'none';
document.getElementById('vv-ai-dock-hide').style.display = 'none';
// Closing hands the panel back, whichever button did it, so the next question opens at the
// ordinary size instead of inheriting a decision made about a different answer.
vvAiDockBig = false;
localStorage.setItem('vv-ai-dock-big', '0');
vvAiDockApplyGrow();
requestAnimationFrame(vvFitRight);
}
// ── Recording a fix ─────────────────────────────────────────────────────────── // ── Recording a fix ───────────────────────────────────────────────────────────
// The symptom is taken from the question that was being asked; the fix is typed by the operator. // The symptom is taken from the question that was being asked; the fix is typed by the operator.
// The model's diagnosis is deliberately not saved — it is a reading of evidence, and writing a // The model's diagnosis is deliberately not saved — it is a reading of evidence, and writing a
// hypothesis into institutional memory as settled fact is how a wrong answer outlives the // hypothesis into institutional memory as settled fact is how a wrong answer outlives the
// incident it came from. What gets remembered is what actually worked. // incident it came from. What gets remembered is what actually worked.
let vvAiFixMode = false; //
let vvAiLastQ = ''; // Offered, not buttoned. This used to be a "Save fix" button that appeared after any answer that
// was not an error, which meant it was on screen after nearly every question — so it read as
// furniture and was mostly ignored. The offer instead waits for the one fact that says the
// trouble is actually over: the script that was being diagnosed runs again and exits clean. That
// arrives once per episode, at the moment it is true, and it comes from the run record rather
// than from a guess about the conversation.
let vvAiFixWatch = null; // { scope, symptom, since } while a diagnosis is waiting on a good run
let vvAiFixAsk = null; // { scope, symptom } while the offer is open and awaiting an answer
function vvAiFixStart() { // Armed when a troubleshoot turn finishes. The question is the symptom; the scope is whatever was
const input = document.getElementById('vv-ai-dock-input'); // being diagnosed. `since` guards against a run that had already completed before the diagnosis
vvAiFixMode = true; // started being read as proof that the diagnosis worked.
input.placeholder = 'What actually fixed it? (saved against ' + vvAiScope + ')'; function vvAiFixArm() {
input.value = ''; if (vvAiProfile !== 'troubleshoot' || !vvAiScope) return;
vvAiFixWatch = {
scope: vvAiScope,
symptom: vvAiLastQ || 'diagnosed from the log',
since: Math.floor(Date.now() / 1000),
};
}
// Called from the activity poll with each completed run. One offer per episode: the watch is
// cleared as it fires.
function vvAiFixRunOk(id, startedAt) {
const w = vvAiFixWatch;
if (!w || !vvAiDockOn()) return;
if (id !== w.scope || startedAt < w.since) return;
vvAiFixWatch = null;
vvAiFixAsk = { scope: w.scope, symptom: w.symptom };
// Raw, not escaped — offer() renders through the component's fmt(), which escapes first.
vvSchedChat.offer('fix', id + ' just ran clean. Want me to record what fixed it?');
}
function vvAiFixAnswer(yes) {
const ask = vvAiFixAsk;
vvAiFixAsk = null;
if (!yes || !ask) return;
// The fix itself is still typed, because only the operator knows what they actually changed.
// The next thing sent is captured as the fix rather than asked as a question.
vvAiFixPending = ask;
const input = document.getElementById('vv-sched-ai-input');
if (input) {
input.placeholder = 'What actually fixed it? (saved against ' + ask.scope + ')';
input.focus(); input.focus();
document.getElementById('vv-ai-dock-send').textContent = 'Save'; }
document.getElementById('vv-ai-dock-fix').style.display = 'none';
} }
function vvAiFixCancel() { let vvAiFixPending = null;
vvAiFixMode = false;
const input = document.getElementById('vv-ai-dock-input');
input.placeholder = 'Ask about what is on screen…';
document.getElementById('vv-ai-dock-send').textContent = 'Ask';
if (vvAiLastQ) document.getElementById('vv-ai-dock-fix').style.display = '';
}
function vvAiFixSave(text) { function vvAiFixSave(text) {
const ask = vvAiFixPending;
vvAiFixPending = null;
const input = document.getElementById('vv-sched-ai-input');
if (input) input.placeholder = 'Ask about what is on screen…';
if (!ask) return;
fetch('/plugins/varaverk/api/ai.php', { fetch('/plugins/varaverk/api/ai.php', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({ action: 'incident_add', scope: vvAiScope, body: new URLSearchParams({ action: 'incident_add', scope: ask.scope,
symptom: vvAiLastQ, fix: text }), symptom: ask.symptom, fix: text }),
}).then(r => r.json()).then(d => { }).then(r => r.json()).then(d => {
vvAiDockAppend(d.ok vvSchedChat.note(d.ok
? '<div class="vv-ai-dock-sep">saved against ' + vvEscHtml(vvAiScope) ? 'saved against ' + ask.scope + ' — it will be shown next time this is diagnosed'
+ ' — it will be shown next time this is diagnosed</div>' : 'could not save: ' + (d.error || 'unknown'));
: '<div class="vv-ai-dock-a vv-ai-dock-err">Could not save: ' }).catch(e => vvSchedChat.note('save failed: ' + e));
+ vvEscHtml(d.error || 'unknown') + '</div>');
}).catch(e => vvAiDockAppend('<div class="vv-ai-dock-a vv-ai-dock-err">Save failed: '
+ vvEscHtml(String(e)) + '</div>'));
vvAiFixCancel();
} }
function vvAiDockSend() { // The last question asked, kept so a fix filed later has a symptom to go with it. Written by the
if (vvAiBusy) return; // component's beforeSend hook, which is the one place every question passes through.
const input = document.getElementById('vv-ai-dock-input'); let vvAiLastQ = '';
const q = input.value.trim();
if (!q) return;
if (vvAiFixMode) { input.value = ''; vvAiFixSave(q); return; }
input.value = '';
vvAiBusy = true;
document.getElementById('vv-ai-dock-send').disabled = true;
vvAiDockExpand();
vvAiDockAppend('<div class="vv-ai-dock-q">' + vvEscHtml(q) + '</div>'
+ '<div class="vv-ai-dock-a vv-ai-dock-wait" id="vv-ai-dock-pending">thinking…</div>');
fetch('/plugins/varaverk/api/ai.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({
action: 'ask', question: q, profile: vvAiProfile, scope: vvAiScope,
// Thinking on for diagnosis only. Working out what a log means is reasoning, and it is
// the one thing here worth waiting ~15s for; a lookup like "what does this setting do"
// is not, and inline answers that stall feel broken.
think: vvAiProfile === 'troubleshoot' ? '1' : '0',
history: JSON.stringify(vvAiHist),
}),
}).then(r => r.json()).then(d => {
if (!d.ok || !d.token) return vvAiDockDone(d.error || 'Could not start.', true);
vvAiHist.push({ role: 'user', content: q });
vvAiLastQ = q;
vvAiDockPoll(d.token);
}).catch(e => vvAiDockDone('Request failed: ' + e, true));
}
// Asks about one run from the Recent Activity list, in a single click. // Asks about one run from the Recent Activity list, in a single click.
// //
@@ -2614,68 +2522,11 @@ function vvAiDockSend() {
// request, so what was asked on their behalf is never a mystery. // request, so what was asked on their behalf is never a mystery.
function vvAiAskRun(id, failed, ev) { function vvAiAskRun(id, failed, ev) {
if (ev) ev.stopPropagation(); if (ev) ev.stopPropagation();
if (!vvAiDockOn() || vvAiBusy) return; if (!vvAiDockOn() || vvSchedChat.busy()) return;
vvOpenRight(id); vvOpenRight(id);
const input = document.getElementById('vv-ai-dock-input'); const input = document.getElementById('vv-sched-ai-input');
input.value = failed ? 'Why did this run fail?' : 'How did this run go?'; input.value = failed ? 'Why did this run fail?' : 'How did this run go?';
vvAiDockSend(); vvSchedChat.send();
}
function vvAiDockPoll(token, started) {
started = started || Date.now();
clearTimeout(vvAiPoll);
if (Date.now() - started > 300000) return vvAiDockDone('Timed out waiting for a response.', true);
vvAiPoll = setTimeout(() => {
fetch('/plugins/varaverk/api/ai.php?action=poll&token=' + encodeURIComponent(token))
.then(r => r.json()).then(d => {
if (!d.ok) return vvAiDockDone(d.error || 'Poll failed', true);
const j = d.job || {};
if (j.status === 'done') {
vvAiHist.push({ role: 'assistant', content: j.answer || '' });
vvAiDockDone(j.answer || '(empty answer)', false, j.sources || []);
// Same store as the AI tab and the Monitor row, through the same writer. This dock
// kept its conversation in a JavaScript array and nothing else, so a thread died on
// reload — including the troubleshooting ones, which are the most expensive to have
// had and the most annoying to lose. The scope goes with it so reopening the thread
// restores what it was reasoned about rather than continuing it against whatever
// happens to be on screen.
if (window.VvAiChatSave) {
window.VvAiChatSave(vvAiHist, vvAiProfile, vvAiScope, vvAiChatId)
.then(id => { if (id) vvAiChatId = id; });
}
fetch('/plugins/varaverk/api/ai.php', { method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({ action: 'clear', token }) }).catch(() => {});
return;
}
if (j.status === 'error') return vvAiDockDone(j.error || 'Unknown error', true);
vvAiDockPoll(token, started);
}).catch(e => vvAiDockDone('Poll failed: ' + e, true));
}, 1000);
}
function vvAiDockDone(text, isErr, sources) {
const pending = document.getElementById('vv-ai-dock-pending');
let html = vvEscHtml(text)
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\n/g, '<br>');
if (sources && sources.length) {
html += '<div class="vv-ai-dock-src">' + sources.slice(0, 4).map(s =>
vvEscHtml([s.path, s.section, s.heading].filter(Boolean).join(' '))).join('<br>') + '</div>';
}
if (pending) {
pending.classList.remove('vv-ai-dock-wait');
if (isErr) pending.classList.add('vv-ai-dock-err');
pending.innerHTML = html;
pending.removeAttribute('id');
}
vvAiBusy = false;
document.getElementById('vv-ai-dock-send').disabled = false;
// Offered only once there is something to attach a fix to.
if (!isErr && vvAiLastQ) document.getElementById('vv-ai-dock-fix').style.display = '';
const body = document.getElementById('vv-ai-dock-body');
requestAnimationFrame(() => { vvFitRight(); body.scrollTop = body.scrollHeight; });
} }
// ── Advanced mode toggle ────────────────────────────────────────────────────── // ── Advanced mode toggle ──────────────────────────────────────────────────────
@@ -3315,6 +3166,10 @@ function vvLoadRecentActivity() {
const cls = r.status === 'ok' ? 'vv-stat-ok' : r.status === 'warn' ? 'vv-stat-warn' : 'vv-stat-error'; const cls = r.status === 'ok' ? 'vv-stat-ok' : r.status === 'warn' ? 'vv-stat-warn' : 'vv-stat-error';
const bad = r.status !== 'ok' && r.status !== 'skipped'; const bad = r.status !== 'ok' && r.status !== 'skipped';
if (bad) errors++; if (bad) errors++;
// The one fact that says a diagnosis worked: the script it was about ran again and exited
// clean. This poll is already watching for it, so the offer costs a comparison rather than
// a second source of truth.
if (r.status === 'ok') vvAiFixRunOk(r.id, r.start);
// The question is on the row because that is where the operator already is when they want // The question is on the row because that is where the operator already is when they want
// it. Reaching the same answer otherwise means noticing the red dot, clicking through to // it. Reaching the same answer otherwise means noticing the red dot, clicking through to
// the log, finding the dock, and typing out what the row already knows. // the log, finding the dock, and typing out what the row already knows.
@@ -5058,7 +4913,6 @@ document.querySelectorAll('.vv-sched-card').forEach(card => {
requestAnimationFrame(function() { requestAnimationFrame(function() {
vvRestoreInvert(); vvRestoreInvert();
vvAiDockApplyGrow();
vvRestoreAdvancedMode(); vvRestoreAdvancedMode();
vvRestoreSugStates(); vvRestoreSugStates();
vvApplyHighlighting(); vvApplyHighlighting();