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-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; }
|
.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-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; }
|
.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;} }
|
@keyframes vvAiPulse { 0%,100%{opacity:.25;} 50%{opacity:1;} }
|
||||||
@@ -344,6 +351,12 @@ vv_ai_profiles_script();
|
|||||||
const $ = sfx => document.getElementById(P + '-' + sfx);
|
const $ = sfx => document.getElementById(P + '-' + sfx);
|
||||||
const onTurn = o.onTurn || function () {};
|
const onTurn = o.onTurn || function () {};
|
||||||
const onChats = o.onChats || 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 store = o.chats !== false;
|
||||||
const POLL_MS = 1200;
|
const POLL_MS = 1200;
|
||||||
const POLL_CEIL = 300000; // stop polling a worker that never wrote a terminal state
|
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 messages = []; // whole transcript — displayed and stored
|
||||||
let sendFrom = 0; // index the model is allowed to see from
|
let sendFrom = 0; // index the model is allowed to see from
|
||||||
let chatId = ''; // '' until the store mints one
|
let chatId = ''; // '' until the store mints one
|
||||||
|
let scopeLabel = o.scopeLabel || ''; // subject shown on the chip; '' on surfaces without one
|
||||||
let busy = false;
|
let busy = false;
|
||||||
let lastSources = [];
|
let lastSources = [];
|
||||||
let pendingTimer = null;
|
let pendingTimer = null;
|
||||||
@@ -459,7 +473,10 @@ vv_ai_profiles_script();
|
|||||||
if (cite) {
|
if (cite) {
|
||||||
const s = lastSources[Number(cite.dataset.cite) - 1];
|
const s = lastSources[Number(cite.dataset.cite) - 1];
|
||||||
if (s && s.path) vvAiOpen(s.path);
|
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 ───────────────────────────────────────────────────────
|
// ── Ask / poll ───────────────────────────────────────────────────────
|
||||||
@@ -534,7 +551,9 @@ vv_ai_profiles_script();
|
|||||||
// that exists in both places on purpose.
|
// that exists in both places on purpose.
|
||||||
function sendable() {
|
function sendable() {
|
||||||
const floor = Math.max(sendFrom, messages.length - PROFILES[profile].turns * 2);
|
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() {
|
function finish() {
|
||||||
@@ -593,14 +612,55 @@ vv_ai_profiles_script();
|
|||||||
const c = chatEl();
|
const c = chatEl();
|
||||||
if (!messages.length) { reset(); return; }
|
if (!messages.length) { reset(); return; }
|
||||||
c.innerHTML = '';
|
c.innerHTML = '';
|
||||||
messages.forEach(m => {
|
messages.forEach((m, i) => {
|
||||||
if (m.role === 'user') { addUser(m.content); return; }
|
if (m.role === 'user') { addUser(m.content); return; }
|
||||||
c.appendChild(el(`<div class="vv-ai-msg bot"><div class="vv-ai-role">Varaverk</div>`
|
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();
|
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() {
|
function reset() {
|
||||||
chatEl().innerHTML = `<div class="vv-ai-empty">${esc(o.empty || 'Ask Varaverk about itself.')}</div>`;
|
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) {
|
function applyProfile(p) {
|
||||||
profile = p;
|
profile = p;
|
||||||
const chipL = $('chip-l');
|
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');
|
const menu = $('menu');
|
||||||
if (menu) menu.querySelectorAll('.vv-ai-opt').forEach(b =>
|
if (menu) menu.querySelectorAll('.vv-ai-opt').forEach(b =>
|
||||||
b.classList.toggle('active', b.dataset.prof === p));
|
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 : '';
|
if (chip) chip.title = PROFILES[p] ? PROFILES[p].hint : '';
|
||||||
const kindEl = o.kindEl ? document.getElementById(o.kindEl) : null;
|
const kindEl = o.kindEl ? document.getElementById(o.kindEl) : null;
|
||||||
if (kindEl) kindEl.style.display = (PROFILES[p] && PROFILES[p].kind) ? '' : 'none';
|
if (kindEl) kindEl.style.display = (PROFILES[p] && PROFILES[p].kind) ? '' : 'none';
|
||||||
|
onProfile(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switching moves the floor rather than clearing the transcript. Carrying cited,
|
// 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
|
// 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
|
// scrolled where it was, which puts the newest answer off-screen at the exact moment you
|
||||||
// asked for more room to read it.
|
// 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');
|
const growBtn = $('grow');
|
||||||
if (growBtn) {
|
function applyHeights() {
|
||||||
const el = chatEl();
|
const el = chatEl();
|
||||||
const base = el.dataset.h || '';
|
const base = el.dataset.h || '';
|
||||||
const tall = el.dataset.hTall || '';
|
const tall = el.dataset.hTall || '';
|
||||||
const apply = big => {
|
if (!base || !tall) return;
|
||||||
if (!base || !tall) return;
|
el.style.height = big ? tall : base;
|
||||||
el.style.height = big ? tall : base;
|
if (growBtn) {
|
||||||
growBtn.textContent = big ? '⤡' : '⤢';
|
growBtn.textContent = big ? '⤡' : '⤢';
|
||||||
growBtn.title = big ? 'Back to the smaller view' : 'Give the conversation more room';
|
growBtn.title = big ? 'Back to the smaller view' : 'Give the conversation more room';
|
||||||
growBtn.classList.toggle('vv-ai-grow-on', big);
|
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
|
// 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 = {
|
const inst = {
|
||||||
prefix: P,
|
prefix: P,
|
||||||
setProfile, newChat, loadChat, send,
|
setProfile, newChat, loadChat, send, offer,
|
||||||
|
busy: () => busy,
|
||||||
|
expanded: () => big,
|
||||||
currentId: () => chatId,
|
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() {
|
teardown() {
|
||||||
clearInterval(pendingTimer);
|
clearInterval(pendingTimer);
|
||||||
window.removeEventListener('error', onErr);
|
window.removeEventListener('error', onErr);
|
||||||
|
|||||||
Reference in New Issue
Block a user