Compare commits
3
Commits
c431370a68
...
6be765a9bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6be765a9bf | ||
|
|
8eeb4c3d6c | ||
|
|
d0ff0c7d5c |
@@ -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
|
||||
> 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
|
||||
bar, transcript, composer, source viewer, stored-chat list — and both the AI tab and the Monitor
|
||||
tab's AI row construct it. Every id is composed from a prefix so instances can coexist, and each
|
||||
tears down the previous holder of its prefix, because Unraid swaps tab content by AJAX without
|
||||
unloading the old page's JavaScript.
|
||||
**One widget, rendered three times.** `include/ai_chat.php` owns the conversation surface —
|
||||
transcript, composer, profile and history pickers, source viewer, stored-chat list — and the AI
|
||||
tab, the Monitor tab's AI row and the Scheduler's right-hand panel all construct it. Every id is
|
||||
composed from a prefix so instances can coexist, and each tears down the previous holder of its
|
||||
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
|
||||
you have open, with its own scope chip, fix flow and incident capture, is a different component
|
||||
that happens to share an endpoint. Folding it in would produce one widget with two personalities
|
||||
and a mode flag choosing between them.
|
||||
A placement chooses how much room it gets and which extra controls it needs. It does not choose
|
||||
the shape: the control row is fixed everywhere — profile chip hard left, `⤢` `▾` `New` `Ask` hard
|
||||
right — because a chat that rearranges itself per tab is three components wearing one name.
|
||||
|
||||
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
|
||||
PHP layer. PHP here has no `date.timezone` and therefore ran in UTC while the server ran local,
|
||||
|
||||
@@ -667,37 +667,19 @@ body.vv-fullscreen #displaybox { padding-left: 1rem !important; padding-top: .5r
|
||||
.vv-sug-configured { color: #4caf50; font-size: 11px; }
|
||||
/* Info sections inside Scheduler Information 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;
|
||||
margin-top: 6px; flex-shrink: 0; }
|
||||
#vv-ai-dock-bar { display: flex; align-items: center; gap: 7px; padding: 6px 8px; }
|
||||
#vv-ai-dock-chip { white-space: nowrap; max-width: 240px; overflow: hidden;
|
||||
text-overflow: ellipsis; }
|
||||
/* The button around it is .vv-ai-chip and carries the colour, border and padding. */
|
||||
margin-top: 6px; flex-shrink: 0; padding: 6px 8px; }
|
||||
/* The chip states profile and subject together here, and a long script name would otherwise push
|
||||
the action group off the end of a narrow panel. */
|
||||
#vv-ai-dock .vv-ai-chip { max-width: 240px; overflow: hidden; text-overflow: ellipsis; }
|
||||
/* Flashed when the subject moves under text already typed — see vvAiDockScope(). */
|
||||
.vv-ai-chip-flash { animation: vvAiChipFlash .9s ease-out 2; }
|
||||
@keyframes vvAiChipFlash { 0%,100% { background:#12191d; color:#5a7a8a; }
|
||||
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; }
|
||||
/* 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:hover { background: #22382a; }
|
||||
|
||||
/* ── AI profile picker ────────────────────────────────────────────────────────
|
||||
Global rather than emitted with the chat widget, because both surfaces that show it are
|
||||
styled differently in every other respect: the Monitor card renders a full transcript from
|
||||
include/ai_chat.php, the Scheduler dock renders its own one-line bar and loads none of that
|
||||
stylesheet. The picker is the one control they share, so it lives where .vv-ai-dock-* already
|
||||
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. */
|
||||
/* ── Profile picker ───────────────────────────────────────────────────────────
|
||||
Global rather than emitted with the chat component. It was shared out here when the Scheduler
|
||||
drew its own bar and loaded none of the component's stylesheet; that is no longer true — all
|
||||
three surfaces render the same markup now — but the picker is also used by the menus around it
|
||||
and reads more naturally beside them than inside the component's own block. */
|
||||
.vv-ai-picker { position:relative; flex-shrink:0; }
|
||||
.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;
|
||||
|
||||
@@ -90,7 +90,7 @@ require_once __DIR__ . '/config.php';
|
||||
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,
|
||||
// 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.
|
||||
require_once __DIR__ . '/ai_profiles.php';
|
||||
|
||||
@@ -816,7 +816,7 @@ function vv_ai_token_stats(): array {
|
||||
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()
|
||||
// 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);
|
||||
$created = (int)($prev['created'] ?? time());
|
||||
|
||||
// Scope travels with the conversation. A Scheduler dock thread is bound to what the operator
|
||||
// had open — a script, a log, a conf key — and its turns carry log excerpts chosen for that
|
||||
// Scope travels with the conversation. A Scheduler thread is bound to what the operator had
|
||||
// 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
|
||||
// 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.
|
||||
$rec = [
|
||||
'id' => $id,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<?php
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
// PURPOSE
|
||||
// The conversation surface itself — profile bar, transcript, composer, source viewer and the
|
||||
// stored-chat list — rendered wherever a full chat belongs. Currently the AI tab and the
|
||||
// Monitor tab's AI row.
|
||||
// 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
|
||||
@@ -12,10 +12,18 @@
|
||||
// teardown of a stale instance across an Unraid tab swap — is subtle because each of them
|
||||
// already cost a diagnosis session once. A copy inherits none of the fixes that come after it.
|
||||
//
|
||||
// The Scheduler tab's dock is deliberately NOT built on this. It is a one-line bar that follows
|
||||
// the view you have open, with its own scope chip, fix flow and incident capture; it is a
|
||||
// different component that happens to talk to the same endpoint. Folding it in here would mean
|
||||
// one widget with two personalities and a mode flag deciding which.
|
||||
// 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
|
||||
@@ -69,10 +77,10 @@ require_once __DIR__ . '/ai_profiles.php';
|
||||
|
||||
// The conversation store, on its own, with no styling and no chat widget attached.
|
||||
//
|
||||
// Emitted separately because the surface that most needs it is the one that does not want the
|
||||
// rest: the Scheduler dock draws its own one-line bar, with its own scope chip and fix flow, and
|
||||
// pulling in the full transcript stylesheet to reach a save function would restyle a component
|
||||
// that was deliberately built to look different.
|
||||
// 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
|
||||
@@ -132,8 +140,8 @@ function vv_ai_chat_assets(): void {
|
||||
.vv-ai-input:focus { outline:none; border-color:#2d4a6a; }
|
||||
.vv-ai-toggle { font-size:11px; color:#6a6a6a; display:flex; align-items:center; gap:5px; cursor:pointer; }
|
||||
|
||||
/* Profile picker styling lives in css/varaverk.css — the Scheduler dock uses the same control
|
||||
and loads none of this stylesheet. */
|
||||
/* 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; }
|
||||
|
||||
@@ -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-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-dot { width:6px; height:6px; border-radius:50%; background:#6fcf97; animation:vvAiPulse 1.1s infinite; }
|
||||
@keyframes vvAiPulse { 0%,100%{opacity:.25;} 50%{opacity:1;} }
|
||||
@@ -215,9 +230,9 @@ function vv_ai_chat_assets(): void {
|
||||
background:#161616; border-color:#333; }
|
||||
.vv-ai-c .vv-ai-input:focus { border-color:#6495ed; }
|
||||
|
||||
/* Styled after the Scheduler dock: 12px, tighter leading, questions marked with a chevron rather
|
||||
than a role label. The role labels cost a line each and say the same two things forever — on a
|
||||
card this size that is a third of the visible transcript spent on "You" and "Varaverk". */
|
||||
/* 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; }
|
||||
@@ -293,7 +308,7 @@ function vv_ai_chat_assets(): void {
|
||||
<?php
|
||||
// The profile registry, served rather than restated. This used to be a literal table in the
|
||||
// script below that had already drifted from the PHP — it knew three profiles where the server
|
||||
// knew four, which is why troubleshoot could not be offered here and the Scheduler dock had to
|
||||
// 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();
|
||||
?>
|
||||
@@ -344,6 +359,12 @@ vv_ai_profiles_script();
|
||||
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 () {};
|
||||
const store = o.chats !== false;
|
||||
const POLL_MS = 1200;
|
||||
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 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;
|
||||
@@ -459,7 +481,10 @@ vv_ai_profiles_script();
|
||||
if (cite) {
|
||||
const s = lastSources[Number(cite.dataset.cite) - 1];
|
||||
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 ───────────────────────────────────────────────────────
|
||||
@@ -476,6 +501,12 @@ vv_ai_profiles_script();
|
||||
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;
|
||||
$('send').disabled = true;
|
||||
addUser(q);
|
||||
@@ -499,7 +530,12 @@ vv_ai_profiles_script();
|
||||
question: q,
|
||||
history: JSON.stringify(sendable()),
|
||||
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 });
|
||||
} catch (e) {
|
||||
@@ -534,7 +570,9 @@ vv_ai_profiles_script();
|
||||
// that exists in both places on purpose.
|
||||
function sendable() {
|
||||
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() {
|
||||
@@ -593,14 +631,55 @@ vv_ai_profiles_script();
|
||||
const c = chatEl();
|
||||
if (!messages.length) { reset(); return; }
|
||||
c.innerHTML = '';
|
||||
messages.forEach(m => {
|
||||
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></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>`;
|
||||
}
|
||||
@@ -631,7 +710,16 @@ vv_ai_profiles_script();
|
||||
function applyProfile(p) {
|
||||
profile = p;
|
||||
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');
|
||||
if (menu) menu.querySelectorAll('.vv-ai-opt').forEach(b =>
|
||||
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 : '';
|
||||
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,
|
||||
@@ -764,20 +853,26 @@ vv_ai_profiles_script();
|
||||
// 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().
|
||||
let big = false;
|
||||
const growBtn = $('grow');
|
||||
if (growBtn) {
|
||||
function applyHeights() {
|
||||
const el = chatEl();
|
||||
const base = el.dataset.h || '';
|
||||
const tall = el.dataset.hTall || '';
|
||||
const apply = big => {
|
||||
if (!base || !tall) return;
|
||||
el.style.height = big ? tall : base;
|
||||
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);
|
||||
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
|
||||
@@ -820,8 +915,47 @@ vv_ai_profiles_script();
|
||||
|
||||
const inst = {
|
||||
prefix: P,
|
||||
setProfile, newChat, loadChat, send,
|
||||
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, 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() {
|
||||
clearInterval(pendingTimer);
|
||||
window.removeEventListener('error', onErr);
|
||||
@@ -851,7 +985,7 @@ vv_ai_profiles_script();
|
||||
box.innerHTML = '<div class="vv-ai-none">no saved conversations yet</div>';
|
||||
return;
|
||||
}
|
||||
// The scope is shown, not just stored. A Scheduler dock thread is about one script or log,
|
||||
// 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
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
// PURPOSE
|
||||
// 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
|
||||
// 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
|
||||
// JavaScript and remains syntax-checkable outside PHP.
|
||||
//
|
||||
// Both the shared chat include and the Scheduler dock call this. Before it existed the dock had
|
||||
// its own literal `{ code: 'Code', troubleshoot: 'Troubleshoot' }` map, which is how a fourth
|
||||
// copy of the profile list came to exist in the first place.
|
||||
// The shared chat include calls this, as does any page emitting the registry ahead of it. Before
|
||||
// it existed the Scheduler had its own literal `{ code: 'Code', troubleshoot: 'Troubleshoot' }`
|
||||
// map, which is how a fourth copy of the profile list came to exist in the first place.
|
||||
function vv_ai_profiles_script(): void {
|
||||
static $done = false;
|
||||
if ($done) return;
|
||||
|
||||
@@ -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); ?>
|
||||
</div>
|
||||
|
||||
<!-- Named for what it is, not "dock" — the Scheduler tab's vv-ai-dock is a different
|
||||
component and vvAiDockOn() there tests for that id by name. -->
|
||||
<!-- Named for what it is, not "dock". The Scheduler renders the same component inside a
|
||||
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">
|
||||
<h3>
|
||||
<span style="display:flex;align-items:center;gap:5px;">
|
||||
|
||||
@@ -56,11 +56,13 @@ as the GPU and UPS cards.
|
||||
|
||||
| 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 |
|
||||
| Assistant | Ask a question without leaving the dashboard |
|
||||
| ⤢ | Expands the conversation to about two and a half times its height. Remembered |
|
||||
| Profile buttons | Which contract answers — General Chat, Varaverk Assistant, Code Sketcher, Troubleshoot |
|
||||
| Profile chip | 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
|
||||
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 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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
**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 |
|
||||
| **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 |
|
||||
| **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
|
||||
|
||||
|
||||
+144
-290
@@ -65,8 +65,8 @@
|
||||
require_once dirname(__DIR__) . '/include/scheduler.php';
|
||||
require_once dirname(__DIR__) . '/include/docs.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 presentation deliberately is not.
|
||||
// The chat component itself now, not only the store: the assistant panel in the right-hand pane
|
||||
// is an instance of it rather than a second implementation.
|
||||
require_once dirname(__DIR__) . '/include/ai_chat.php';
|
||||
|
||||
// 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
|
||||
living there would vanish exactly when the context — a conf, a log, a script — is most
|
||||
worth asking about.
|
||||
At rest it is one input row. Answers expand it upward and the views above shrink to
|
||||
suit, which is why vvFitRight() subtracts its height. It pushes rather than overlays
|
||||
so the thing you are asking about stays on screen. Collapsing keeps the conversation. -->
|
||||
It takes a share of the panel and the views above shrink to suit, which is why
|
||||
vvFitRight() subtracts its height. It pushes rather than overlays, so the thing being
|
||||
asked about stays on screen; ⤢ trades more of the panel for the conversation and back. -->
|
||||
<?php
|
||||
// The registry and the conversation store — not the chat widget. This dock draws its own
|
||||
// bar and must keep looking like itself; what it must not have is its own answer to where
|
||||
// a conversation lives.
|
||||
// The registry, the store, and now the component itself. vv_ai_chat_assets() is what
|
||||
// defines VvAiChat and carries the transcript and composer styling — without it the markup
|
||||
// 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_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-body" style="display:none"></div>
|
||||
<div id="vv-ai-dock-bar">
|
||||
<!-- The chip is now the trigger for the profile picker as well as the scope readout.
|
||||
#vv-ai-dock-chip stays the label span it always was, so every existing write to
|
||||
its textContent still lands; the button around it carries the decoration. -->
|
||||
<div class="vv-ai-picker" id="vv-ai-dock-picker">
|
||||
<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>
|
||||
<?php vv_ai_chat_markup('vv-sched-ai', [
|
||||
'profile' => 'varaverk',
|
||||
'compact' => true,
|
||||
'scopeLabel' => 'Scheduler',
|
||||
'empty' => 'Ask about what is on screen.',
|
||||
'placeholder' => 'Ask about what is on screen…',
|
||||
]); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<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());
|
||||
}
|
||||
|
||||
// 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() {
|
||||
const right = document.getElementById('vv-sched-right');
|
||||
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
|
||||
// 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.
|
||||
const dockBody = document.getElementById('vv-ai-dock-body');
|
||||
const dockCap = vvAiDockBig ? 0.82 : 0.4;
|
||||
if (dockBody) dockBody.style.maxHeight = Math.max(90, Math.round(availH * dockCap)) + 'px';
|
||||
// Measured after the cap is applied, so this reads the clamped height, not the natural one.
|
||||
// Both heights handed to the chat rather than one cap applied to it, because the component owns
|
||||
// which of the two it is currently at. Recomputed on every fit so a resize while expanded stays
|
||||
// expanded at the new panel size instead of snapping back to the resting share.
|
||||
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 dockH = dock ? dock.offsetHeight : 0;
|
||||
const contentH = Math.max(80, availH - dockH);
|
||||
@@ -2377,48 +2363,42 @@ function vvClickCog(el) {
|
||||
let vvAiProfile = 'varaverk';
|
||||
let vvAiScope = 'Scheduler';
|
||||
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 vvAiChatId = ""; // the stored conversation this thread is appending to
|
||||
let vvAiBusy = false;
|
||||
let vvAiPoll = null;
|
||||
let vvSchedChat = null; // the shared chat instance, once the row has rendered
|
||||
|
||||
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 chip states the contract and the thing being asked about; clicking it changes the first
|
||||
// without moving the second. Same control as the Monitor card's, same stylesheet — the dock keeps
|
||||
// its own bar because that is a real difference, but which profile answers is not a per-surface
|
||||
// idea and should not have a per-surface control.
|
||||
//
|
||||
// Routes through vvAiDockScope() rather than assigning vvAiProfile, so a profile change gets the
|
||||
// history reset and the on-screen marker that a scope change already gets. Picking a new contract
|
||||
// mid-thread and silently feeding it the previous one's turns is the bug that rule exists for.
|
||||
(function vvAiPickerInit() {
|
||||
const picker = document.getElementById('vv-ai-dock-picker');
|
||||
if (!picker) return;
|
||||
const btn = document.getElementById('vv-ai-dock-chipbtn');
|
||||
const menu = document.getElementById('vv-ai-dock-menu');
|
||||
const close = () => { picker.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };
|
||||
|
||||
btn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const open = picker.classList.toggle('open');
|
||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
menu.querySelectorAll('.vv-ai-opt').forEach(o =>
|
||||
o.classList.toggle('active', o.dataset.prof === vvAiProfile));
|
||||
// The profile picker, the send loop, the poll, the transcript and the store are all the component's
|
||||
// now. What stays here is the part that is genuinely this tab's: which script, log or conf the
|
||||
// question is about, and how that follows the view the operator has open.
|
||||
if (document.getElementById('vv-sched-ai-chat')) {
|
||||
vvSchedChat = VvAiChat({
|
||||
prefix: 'vv-sched-ai',
|
||||
profile: vvAiProfile,
|
||||
scopeLabel: vvAiScopeLabel,
|
||||
// Re-read at send time rather than captured, because the operator moves around this tab
|
||||
// between asking and sending.
|
||||
scope: () => vvAiScope,
|
||||
// Reasoning for diagnosis only — see the note in the component's ask payload.
|
||||
think: p => p === 'troubleshoot',
|
||||
// The picker is the component's, so this is how the page learns the contract changed.
|
||||
onProfile: p => { vvAiProfile = p; },
|
||||
// Claims what was typed rather than asking it, once, after a fix offer is accepted. Also the
|
||||
// one place that reliably sees every question, so it is where the symptom is remembered.
|
||||
beforeSend: q => {
|
||||
if (vvAiFixPending) { vvAiFixSave(q); return true; }
|
||||
vvAiLastQ = q;
|
||||
return false;
|
||||
},
|
||||
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
|
||||
// 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.
|
||||
// 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
|
||||
@@ -2428,179 +2408,107 @@ function vvAiDockScope(profile, label, target) {
|
||||
target = target || label;
|
||||
if (profile === vvAiProfile && target === vvAiScope) return;
|
||||
|
||||
const had = vvAiHist.length > 0;
|
||||
vvAiProfile = profile;
|
||||
vvAiScope = target;
|
||||
vvAiScopeLabel = label; // kept so the picker can change profile without moving the scope
|
||||
// Short label from the registry, not a literal map. The map that used to be here was the
|
||||
// fifth place a profile got defined, and it silently fell back to "Assistant" for any id it
|
||||
// had not been told about — so a new profile would have shown up in the chip as the strict one.
|
||||
const _p = (window.VvAiProfiles || {})[profile];
|
||||
document.getElementById('vv-ai-dock-chip').textContent =
|
||||
(_p ? _p.short : profile) + ' · ' + label;
|
||||
vvAiScopeLabel = label;
|
||||
// One call for both, so changing profile and subject together leaves one line in the transcript
|
||||
// rather than two saying nearly the same thing. The component keeps the transcript and moves
|
||||
// only the floor the model is told about.
|
||||
vvSchedChat.retarget(profile, label, 'now looking at ' + label);
|
||||
// A fix offer belongs to the thing it was diagnosed against; moving the subject abandons it.
|
||||
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.
|
||||
const input = document.getElementById('vv-ai-dock-input');
|
||||
if (input.value.trim() !== '') {
|
||||
const input = document.getElementById('vv-sched-ai-input');
|
||||
if (input && input.value.trim() !== '') {
|
||||
// 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.
|
||||
const chip = document.getElementById('vv-ai-dock-chipbtn');
|
||||
// background lives on the button that triggers the picker.
|
||||
const chip = document.getElementById('vv-sched-ai-chip');
|
||||
if (chip) {
|
||||
chip.classList.remove('vv-ai-chip-flash');
|
||||
void chip.offsetWidth;
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
// 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
|
||||
// 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.
|
||||
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() {
|
||||
const input = document.getElementById('vv-ai-dock-input');
|
||||
vvAiFixMode = true;
|
||||
input.placeholder = 'What actually fixed it? (saved against ' + vvAiScope + ')';
|
||||
input.value = '';
|
||||
// Armed when a troubleshoot turn finishes. The question is the symptom; the scope is whatever was
|
||||
// being diagnosed. `since` guards against a run that had already completed before the diagnosis
|
||||
// started being read as proof that the diagnosis worked.
|
||||
function vvAiFixArm() {
|
||||
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();
|
||||
document.getElementById('vv-ai-dock-send').textContent = 'Save';
|
||||
document.getElementById('vv-ai-dock-fix').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function vvAiFixCancel() {
|
||||
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 = '';
|
||||
}
|
||||
let vvAiFixPending = null;
|
||||
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||||
body: new URLSearchParams({ action: 'incident_add', scope: vvAiScope,
|
||||
symptom: vvAiLastQ, fix: text }),
|
||||
body: new URLSearchParams({ action: 'incident_add', scope: ask.scope,
|
||||
symptom: ask.symptom, fix: text }),
|
||||
}).then(r => r.json()).then(d => {
|
||||
vvAiDockAppend(d.ok
|
||||
? '<div class="vv-ai-dock-sep">saved against ' + vvEscHtml(vvAiScope)
|
||||
+ ' — it will be shown next time this is diagnosed</div>'
|
||||
: '<div class="vv-ai-dock-a vv-ai-dock-err">Could not save: '
|
||||
+ 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();
|
||||
vvSchedChat.note(d.ok
|
||||
? 'saved against ' + ask.scope + ' — it will be shown next time this is diagnosed'
|
||||
: 'could not save: ' + (d.error || 'unknown'));
|
||||
}).catch(e => vvSchedChat.note('save failed: ' + e));
|
||||
}
|
||||
|
||||
function vvAiDockSend() {
|
||||
if (vvAiBusy) return;
|
||||
const input = document.getElementById('vv-ai-dock-input');
|
||||
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));
|
||||
}
|
||||
// The last question asked, kept so a fix filed later has a symptom to go with it. Written by the
|
||||
// component's beforeSend hook, which is the one place every question passes through.
|
||||
let vvAiLastQ = '';
|
||||
|
||||
// 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.
|
||||
function vvAiAskRun(id, failed, ev) {
|
||||
if (ev) ev.stopPropagation();
|
||||
if (!vvAiDockOn() || vvAiBusy) return;
|
||||
if (!vvAiDockOn() || vvSchedChat.busy()) return;
|
||||
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?';
|
||||
vvAiDockSend();
|
||||
}
|
||||
|
||||
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; });
|
||||
vvSchedChat.send();
|
||||
}
|
||||
|
||||
// ── 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 bad = r.status !== 'ok' && r.status !== 'skipped';
|
||||
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
|
||||
// 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.
|
||||
@@ -5058,7 +4913,6 @@ document.querySelectorAll('.vv-sched-card').forEach(card => {
|
||||
|
||||
requestAnimationFrame(function() {
|
||||
vvRestoreInvert();
|
||||
vvAiDockApplyGrow();
|
||||
vvRestoreAdvancedMode();
|
||||
vvRestoreSugStates();
|
||||
vvApplyHighlighting();
|
||||
|
||||
Reference in New Issue
Block a user