diff --git a/Plugin/unraid/include/ai_chat.php b/Plugin/unraid/include/ai_chat.php
index c81e632..2e6b7d2 100644
--- a/Plugin/unraid/include/ai_chat.php
+++ b/Plugin/unraid/include/ai_chat.php
@@ -174,6 +174,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;} }
@@ -344,6 +351,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 +369,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 +473,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 ───────────────────────────────────────────────────────
@@ -534,7 +551,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 +612,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(`
Varaverk
`
- + `
${fmt(m.content)}
`));
+ + `
${fmt(m.content)}
`
+ + (m.offer ? offerHtml(m.offer, i) : '') + ``));
});
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 `
`;
+ }
+
+ // 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(`
`;
}
@@ -631,7 +691,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 +710,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 +834,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 (!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 +896,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('
' + esc(note) + '
'));
+ 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('