From d0ff0c7d5c0354cd6a1d699b41743cce8108e2ba Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Sun, 9 Aug 2026 11:50:02 -0400 Subject: [PATCH] Teach the chat component what the Scheduler dock needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offers, a settable subject, runtime heights and a profile hook — everything the dock does that the shared component could not, so it can stop being a second implementation. --- Plugin/unraid/include/ai_chat.php | 139 +++++++++++++++++++++++++++--- 1 file changed, 127 insertions(+), 12 deletions(-) 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 `
` + + (off.state === 'taken' ? 'yes' : 'no thanks') + `
`; + } + 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(`
Varaverk
` + + `
${fmt(text)}
` + + offerHtml(messages[i].offer, i) + `
`)); + scroll(); + save(); + } + + function answerOffer(i, yes) { + const m = messages[i]; + if (!m || !m.offer || m.offer.state !== 'open') return; + m.offer.state = yes ? 'taken' : 'declined'; + const row = chatEl().querySelector('[data-offer-row="' + i + '"]'); + if (row) row.outerHTML = offerHtml(m.offer, i); + save(); + onOffer(m.offer.kind, yes, m); + } + + function reset() { chatEl().innerHTML = `
${esc(o.empty || 'Ask Varaverk about itself.')}
`; } @@ -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('
' + esc(text) + '
')); + scroll(); + }, teardown() { clearInterval(pendingTimer); window.removeEventListener('error', onErr);