Add a scope-aware assistant dock to the scheduler right panel

This commit is contained in:
Gmer4Lfe
2026-08-05 19:56:02 -04:00
parent 60994c7667
commit 185abdb442
4 changed files with 233 additions and 4 deletions
+18 -2
View File
@@ -82,8 +82,8 @@ if (PHP_SAPI !== 'cli') {
require_once dirname(__DIR__) . '/include/ai.php';
[$jobFile, $question, $historyJson, $kind, $think, $profile] =
array_slice($argv, 1, 6) + array_fill(0, 6, '');
[$jobFile, $question, $historyJson, $kind, $think, $profile, $scope] =
array_slice($argv, 1, 7) + array_fill(0, 7, '');
if ($jobFile === '' || $question === '') exit(1);
if (!preg_match('#/[0-9a-f]{32}\.json$#', $jobFile)) exit(1);
@@ -240,6 +240,22 @@ if ($profile === 'chat') {
// has already been decided — so it should frame everything that follows rather than read as one
// more retrieved document. Marked as operator-authored so the model treats it as fact about the
// installation rather than as a source to cite.
// Where the operator is standing in the WebGUI. Sent by the scheduler page, absent from the AI
// tab, which has no location to speak of. It is what lets "what does this do" resolve — without
// it the model has to guess which of 120 settings "this" means, and it will guess confidently.
//
// Stated as context, never as an instruction to trust: the file named here is where to look
// first, not proof that the answer is in it. That distinction is the whole point of the
// fallback — a setting the operator expects in master.conf may actually live in the host conf,
// and saying so is more useful than failing to find it.
if ($scope !== '') {
$system .= "WHERE THE OPERATOR IS RIGHT NOW\n"
. "They have " . $scope . " open in the WebGUI. Read bare references — \"this "
. "setting\", \"this script\", \"here\" — as meaning that unless they clearly mean "
. "something else. Look there first. If what they are asking about is genuinely "
. "somewhere else, answer anyway and tell them plainly where it actually is.\n\n";
}
$mem = vv_ai_memory_read();
if ($mem['exists'] && trim($mem['text']) !== '') {
$system .= "WHAT YOU ALREADY KNOW ABOUT THIS OPERATOR AND INSTALLATION\n"
+12 -1
View File
@@ -210,6 +210,16 @@ if ($action === 'ask') {
}
$maxTurns = VV_AI_PROFILES[$profile] ?? VV_AI_MAX_TURNS;
// Where the caller is standing — "master.conf", "daily_sync_maintenance.sh", a log name.
// The scheduler page sends it so a question can say "this setting" and mean something; the
// AI tab sends nothing and the worker simply omits the location line.
//
// Whitelisted hard, not escaped and hoped for. It reaches the model as text, so anything
// richer than a file name is an instruction-injection surface for no benefit — a scope is
// only ever a name from this page's own view state.
$scope = trim($_POST['scope'] ?? '');
if ($scope !== '' && !preg_match('#^[A-Za-z0-9 ._/-]{1,80}$#', $scope)) $scope = '';
// The retrieval filter only means anything to the profile that retrieves.
$kind = $profile === 'varaverk' ? trim($_POST['kind'] ?? '') : '';
if ($kind !== '' && !in_array($kind, VV_AI_KINDS, true)) {
@@ -259,7 +269,8 @@ if ($action === 'ask') {
. escapeshellarg(json_encode($clean)) . ' '
. escapeshellarg($kind) . ' '
. escapeshellarg(($_POST['think'] ?? '1') === '1' ? '1' : '0') . ' '
. escapeshellarg($profile)
. escapeshellarg($profile) . ' '
. escapeshellarg($scope)
. ' >/dev/null 2>&1 </dev/null &';
$out = []; $rc = 0;
exec($cmd, $out, $rc);
+29
View File
@@ -520,6 +520,35 @@ body.vv-fullscreen #displaybox { padding-left: 1rem !important; padding-top: .5r
.vv-sug-path { color: #888; font-family: monospace; }
.vv-sug-configured { color: #4caf50; font-size: 11px; }
/* Info sections inside Scheduler Information panel */
/* ── Assistant dock (scheduler right panel) ────────────────────────────────────────────────── */
#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 { flex-shrink: 0; font-size: 10px; color: #5a7a8a; background: #12191d;
border: 1px solid #24343d; border-radius: 3px; padding: 2px 7px;
white-space: nowrap; max-width: 40%; overflow: hidden; text-overflow: ellipsis; }
.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; }
.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
reading, it does not do anything to the machine, and every button on this page that does
+174 -1
View File
@@ -69,6 +69,12 @@ $_vv_doc_vars = array_merge(vv_conf_vars(), [
'SCRIPTS_DIR' => SCRIPTS_DIR,
]);
// The assistant dock renders only where the AI tab itself would: HOST1, with AI_ENABLED true.
// Same two conditions, checked the same way, so the page cannot offer a chat the endpoint will
// refuse — api/ai.php 404s every action off HOST1 regardless of what this page draws.
$_vv_ai_on = vv_is_ai_host()
&& strtolower(trim(vv_conf_vars()['AI_ENABLED'] ?? 'false')) === 'true';
// Setup mode — auto-open a conf file and force the editing sequence
$vv_setup_conf = preg_match('/^[\w.]+\.conf$/', $_GET['vv_setup'] ?? '')
? $_GET['vv_setup'] : '';
@@ -1040,6 +1046,27 @@ Still the same two servers, two households, the same media stack running itself.
</div>
</div>
</div>
<?php if ($_vv_ai_on): ?>
<!-- Assistant dock. Sits below every view in the right panel rather than inside any one of
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. -->
<div id="vv-ai-dock">
<div id="vv-ai-dock-body" style="display:none"></div>
<div id="vv-ai-dock-bar">
<span id="vv-ai-dock-chip" title="What the assistant will answer about — follows the view you have open">Assistant · Scheduler</span>
<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-hide" onclick="vvAiDockCollapse()"
style="display:none" title="Collapse — the conversation is kept">▾</button>
</div>
</div>
<?php endif; ?>
<div class="vv-sched-footer vv-sched-info vv-snap-footer" id="vv-sched-info-footer">
<span class="vv-snap-item">
<span class="vv-snap-label">CPU</span>
@@ -1162,7 +1189,16 @@ function vvFitRight() {
rf.style.height = lf.offsetHeight + 'px';
right.style.height = left.offsetHeight + 'px';
const contentH = Math.max(80, lf.getBoundingClientRect().top - 32 - toolbar.getBoundingClientRect().bottom);
// The assistant dock sits below every view, so its height comes off the space the views get.
// Measured, not assumed — it is one row at rest and taller once an answer is open.
const dock = document.getElementById('vv-ai-dock');
const dockH = dock ? dock.offsetHeight : 0;
const contentH = Math.max(80, lf.getBoundingClientRect().top - 32
- toolbar.getBoundingClientRect().bottom - dockH);
// Cap the conversation at roughly 40% of the panel and let it scroll internally, so a long
// answer shrinks the view above by a bounded amount instead of swallowing it.
const dockBody = document.getElementById('vv-ai-dock-body');
if (dockBody) dockBody.style.maxHeight = Math.max(90, Math.round(contentH * 0.4)) + 'px';
if (pre.style.display !== 'none') {
pre.style.maxHeight = 'none';
pre.style.height = contentH + 'px';
@@ -1207,6 +1243,7 @@ window.addEventListener('resize', vvFitRight);
new ResizeObserver(vvFitRight).observe(document.getElementById('vv-sched-left'));
function vvShowLogMode(id) {
vvAiDockScope('varaverk', String(id).replace(/\.sh$/, '') + ' log');
document.getElementById('vv-suggestions').style.display = 'none';
document.getElementById('vv-editor').style.display = 'none';
document.getElementById('vv-si-view').style.display = 'none';
@@ -1257,6 +1294,7 @@ function vvRestoreInvert() {
}
function vvBackToSuggestions() {
vvAiDockScope('varaverk', 'Scheduler');
vvEditorReset();
document.getElementById('vv-editor-status').classList.remove('vv-ed-active');
document.getElementById('vv-undo-btn').style.display = 'none';
@@ -1730,6 +1768,7 @@ function vvEditScript(id) {
}
function vvShowEditorMode(title) {
vvAiDockScope('code', title || 'a custom script');
vvEditorReset();
document.getElementById('vv-editor').classList.add('vv-editor-hl');
document.getElementById('vv-editor-status').classList.add('vv-ed-active');
@@ -1985,6 +2024,7 @@ function _vvImpDoImport() {
}
function vvShowConfMode(title) {
vvAiDockScope('varaverk', (title || 'a script') + ' settings');
document.getElementById('vv-suggestions').style.display = 'none';
document.getElementById('vv-log-pre').style.display = 'none';
document.getElementById('vv-si-view').style.display = 'none';
@@ -2242,6 +2282,137 @@ function vvClickCog(el) {
vvShowScriptInfoMode(id.replace(/\.sh$/, '').split('/').pop(), '', id);
}
// ── Assistant dock ────────────────────────────────────────────────────────────
// One component, one instance. Every view calls vvAiDockScope() to say where the user now is;
// nothing else about it is per-view. Building a chat per card would mean N implementations of
// the same thing, which is how the multipart POST bug became 21 call sites across 7 pages.
let vvAiProfile = 'varaverk';
let vvAiScope = 'Scheduler';
let vvAiHist = []; // what the model is told; reset when the scope changes
let vvAiBusy = false;
let vvAiPoll = null;
function vvAiDockOn() { return !!document.getElementById('vv-ai-dock'); }
// 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
// wrong inference costs a glance instead of a confidently wrong answer.
function vvAiDockScope(profile, label) {
if (!vvAiDockOn()) return;
if (profile === vvAiProfile && label === vvAiScope) return;
const had = vvAiHist.length > 0;
vvAiProfile = profile;
vvAiScope = label;
document.getElementById('vv-ai-dock-chip').textContent =
(profile === 'code' ? 'Code' : 'Assistant') + ' · ' + label;
// 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 = [];
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 chip = document.getElementById('vv-ai-dock-chip');
chip.classList.remove('vv-ai-chip-flash');
void chip.offsetWidth;
chip.classList.add('vv-ai-chip-flash');
}
}
function vvAiDockAppend(html) {
const body = document.getElementById('vv-ai-dock-body');
body.insertAdjacentHTML('beforeend', html);
body.scrollTop = body.scrollHeight;
}
function vvAiDockExpand() {
document.getElementById('vv-ai-dock-body').style.display = '';
document.getElementById('vv-ai-dock-hide').style.display = '';
requestAnimationFrame(vvFitRight);
}
// 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';
requestAnimationFrame(vvFitRight);
}
function vvAiDockSend() {
if (vvAiBusy) return;
const input = document.getElementById('vv-ai-dock-input');
const q = input.value.trim();
if (!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,
think: '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 });
vvAiDockPoll(d.token);
}).catch(e => vvAiDockDone('Request failed: ' + e, true));
}
function vvAiDockPoll(token) {
clearTimeout(vvAiPoll);
vvAiPoll = setTimeout(() => {
fetch('/plugins/varaverk/api/ai.php?action=poll&token=' + encodeURIComponent(token))
.then(r => r.json()).then(j => {
if (j.status === 'done') {
vvAiHist.push({ role: 'assistant', content: j.answer || '' });
vvAiDockDone(j.answer || '(empty answer)', false, j.sources || []);
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);
}).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;
const body = document.getElementById('vv-ai-dock-body');
body.scrollTop = body.scrollHeight;
}
// ── Advanced mode toggle ──────────────────────────────────────────────────────
let vvAdvancedMode = false;
@@ -3027,6 +3198,7 @@ function vvEditRawConf(file) {
}
function vvShowRawConfMode(title) {
vvAiDockScope('varaverk', title || 'a conf file');
vvEditorReset();
vvRestoreEditorPrefs();
document.getElementById('vv-editor-status').classList.add('vv-ed-active');
@@ -3851,6 +4023,7 @@ function vvSiRetitle(hdr, suffix) {
}
function vvShowScriptInfoMode(name, hdr, id) {
vvAiDockScope('varaverk', name || 'a script');
document.getElementById('vv-suggestions').style.display = 'none';
document.getElementById('vv-log-pre').style.display = 'none';
document.getElementById('vv-editor').style.display = 'none';