Teach the chat component what the Scheduler dock needs
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.
This commit is contained in:
@@ -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(`<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`
|
||||
+ `<div class="vv-ai-body">${fmt(m.content)}</div></div>`));
|
||||
+ `<div class="vv-ai-body">${fmt(m.content)}</div>`
|
||||
+ (m.offer ? offerHtml(m.offer, i) : '') + `</div>`));
|
||||
});
|
||||
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 `<div class="vv-ai-offer-done" data-offer-row="${i}">`
|
||||
+ (off.state === 'taken' ? 'yes' : 'no thanks') + `</div>`;
|
||||
}
|
||||
return `<div class="vv-ai-offer" data-offer-row="${i}">`
|
||||
+ `<button class="vv-ai-btn" type="button" data-offer="${i}" data-yes="1">Yes</button>`
|
||||
+ `<button class="vv-ai-btn ghost" type="button" data-offer="${i}" data-yes="0">No</button>`
|
||||
+ `</div>`;
|
||||
}
|
||||
|
||||
// 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(`<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`
|
||||
+ `<div class="vv-ai-body">${fmt(text)}</div>`
|
||||
+ offerHtml(messages[i].offer, i) + `</div>`));
|
||||
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 = `<div class="vv-ai-empty">${esc(o.empty || 'Ask Varaverk about itself.')}</div>`;
|
||||
}
|
||||
@@ -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('<div class="vv-ai-switch">' + esc(note) + '</div>'));
|
||||
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('<div class="vv-ai-switch">' + esc(text) + '</div>'));
|
||||
scroll();
|
||||
},
|
||||
teardown() {
|
||||
clearInterval(pendingTimer);
|
||||
window.removeEventListener('error', onErr);
|
||||
|
||||
Reference in New Issue
Block a user