Make the Scheduler panel an instance, not a second chat
It kept its own bar, send loop and poll, which is why a thread there died on reload while the other two were saved and why its controls had drifted from the same controls everywhere else.
This commit is contained in:
+150
-296
@@ -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;
|
||||
vvAiProfile = profile;
|
||||
vvAiScope = target;
|
||||
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');
|
||||
chip.classList.remove('vv-ai-chip-flash');
|
||||
void chip.offsetWidth;
|
||||
chip.classList.add('vv-ai-chip-flash');
|
||||
// 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 = '';
|
||||
input.focus();
|
||||
document.getElementById('vv-ai-dock-send').textContent = 'Save';
|
||||
document.getElementById('vv-ai-dock-fix').style.display = 'none';
|
||||
// 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),
|
||||
};
|
||||
}
|
||||
|
||||
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 = '';
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
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