Add a scope-aware assistant dock to the scheduler right panel
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user