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
+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';