Stream the answer as it is written, and let the operator stop it
This commit is contained in:
@@ -190,6 +190,14 @@ function vv_ai_chat_assets(): void {
|
||||
.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; }
|
||||
/* The streaming body sits in the pending bubble and is replaced wholesale by the finished message,
|
||||
so it has to match .vv-ai-body or the answer visibly reflows the moment it completes. The caret
|
||||
marks text as still arriving — without it a stream that pauses mid-sentence reads as finished. */
|
||||
.vv-ai-stream { margin-top:6px; }
|
||||
/* Stop reads as the destructive-ish action it is, and the colour change is what tells the operator
|
||||
the button's job swapped — the label alone is easy to miss mid-answer. */
|
||||
.vv-ai-btn.vv-ai-stop { background:#5a2b2b; border-color:#7a3b3b; color:#f0d8d8; }
|
||||
.vv-ai-stream::after { content:'▌'; margin-left:1px; opacity:.55; 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;} }
|
||||
|
||||
@@ -402,6 +410,10 @@ vv_ai_profiles_script();
|
||||
const onResize = o.onResize || function () {};
|
||||
const store = o.chats !== false;
|
||||
const POLL_MS = 1200;
|
||||
// Once tokens are actually arriving, 1200ms delivers them in visible lumps and the stream reads
|
||||
// as stuttering rather than writing. The endpoint only reads one small file off tmpfs, so the
|
||||
// extra requests are cheap — and this rate only applies while a generation is in flight.
|
||||
const POLL_FAST_MS = 350;
|
||||
const POLL_CEIL = 300000; // stop polling a worker that never wrote a terminal state
|
||||
|
||||
// An instance already holding this prefix is a leftover from a tab swap, still holding
|
||||
@@ -444,7 +456,8 @@ vv_ai_profiles_script();
|
||||
const n = el(`<div class="vv-ai-msg bot" id="${P}-pending"><div class="vv-ai-role">Varaverk</div>`
|
||||
+ `<div class="vv-ai-pending"><span class="vv-ai-dot"></span>`
|
||||
+ `<span id="${P}-phase">starting…</span>`
|
||||
+ `<span id="${P}-elapsed" style="color:#333;font-family:monospace"></span></div></div>`);
|
||||
+ `<span id="${P}-elapsed" style="color:#333;font-family:monospace"></span></div>`
|
||||
+ `<div class="vv-ai-body vv-ai-stream" id="${P}-stream" hidden></div></div>`);
|
||||
chatEl().appendChild(n); scroll();
|
||||
// An elapsed counter distinguishes "working" from "wedged" at a glance. Without it a
|
||||
// stalled turn and a slow one look identical, and the slow case here is legitimately ~40s.
|
||||
@@ -458,6 +471,37 @@ vv_ai_profiles_script();
|
||||
}
|
||||
function phase(t) { const p = $('phase'); if (p) p.textContent = t; }
|
||||
|
||||
// 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. Lives in the closure rather
|
||||
// than only on the instance because the poll loop needs it too, and a bare note() there would
|
||||
// throw: the instance object is not in scope from inside its own factory.
|
||||
function noteLine(text) {
|
||||
chatEl().appendChild(el('<div class="vv-ai-switch">' + esc(text) + '</div>'));
|
||||
scroll();
|
||||
}
|
||||
|
||||
// Streaming is only worth having if it does not fight the reader. Answers routinely outrun the
|
||||
// panel, and the moment someone scrolls up to re-read a line, an unconditional scroll() on
|
||||
// every delta drags them back down eight times a second. Stick to the bottom only while they
|
||||
// are already there.
|
||||
const nearBottom = () => {
|
||||
const c = chatEl();
|
||||
return (c.scrollHeight - c.scrollTop - c.clientHeight) < 40;
|
||||
};
|
||||
|
||||
// Rendered through the same fmt() as a finished answer, so a fence that is still being written
|
||||
// formats as it arrives rather than snapping from plain text to a code block at the end. fmt
|
||||
// escapes, so a half-written tag cannot break out of the bubble mid-stream.
|
||||
function streamInto(text) {
|
||||
const s = $('stream');
|
||||
if (!s) return;
|
||||
if (!text) { s.hidden = true; return; }
|
||||
const stick = nearBottom();
|
||||
s.hidden = false;
|
||||
s.innerHTML = fmt(text);
|
||||
if (stick) scroll();
|
||||
}
|
||||
|
||||
// Paths ride in data attributes rather than an onclick. They come out of the index, and a
|
||||
// path carrying a quote interpolated into an attribute is code execution, not a display bug.
|
||||
function sourcesHtml(sources) {
|
||||
@@ -566,7 +610,10 @@ vv_ai_profiles_script();
|
||||
if (o.beforeSend && o.beforeSend(q)) { $('input').value = ''; return; }
|
||||
|
||||
busy = true;
|
||||
$('send').disabled = true;
|
||||
// Ask becomes Stop rather than greying out. The one stretch of the interaction that takes
|
||||
// 25-75s is exactly when the operator most wants a control, and the composer row has no
|
||||
// room for a fourth button that is dead 95% of the time.
|
||||
setSendMode('stop');
|
||||
addUser(q);
|
||||
$('input').value = '';
|
||||
addPending();
|
||||
@@ -625,6 +672,8 @@ vv_ai_profiles_script();
|
||||
finish(); return; }
|
||||
if (!d.ok) { addError(d.error || 'Failed to start'); finish(); return; }
|
||||
messages.push({ role: 'user', content: q });
|
||||
// Held for Stop. Set before the first poll so a press in the first second has a target.
|
||||
curToken = d.token;
|
||||
poll(d.token, Date.now());
|
||||
})
|
||||
.catch(e => { addError('Request failed: ' + (e && e.message ? e.message : e)); finish(); });
|
||||
@@ -642,10 +691,39 @@ vv_ai_profiles_script();
|
||||
|
||||
function finish() {
|
||||
busy = false;
|
||||
const b = $('send'); if (b) b.disabled = false;
|
||||
curToken = null;
|
||||
setSendMode('ask');
|
||||
clearInterval(pendingTimer);
|
||||
}
|
||||
|
||||
// The token of the turn in flight. Held so Stop knows what to cancel, and cleared by finish()
|
||||
// so a Stop pressed against an already-terminal job is impossible rather than merely harmless.
|
||||
let curToken = null;
|
||||
|
||||
function setSendMode(mode) {
|
||||
const b = $('send');
|
||||
if (!b) return;
|
||||
const stopping = (mode === 'stop');
|
||||
b.disabled = false;
|
||||
b.textContent = stopping ? 'Stop' : 'Ask';
|
||||
b.classList.toggle('vv-ai-stop', stopping);
|
||||
}
|
||||
|
||||
function stopTurn() {
|
||||
if (!curToken) return;
|
||||
const t = curToken;
|
||||
// Disabled immediately: the kill is quick but the poll that renders the result is up to
|
||||
// POLL_FAST_MS away, and a second press in that window would signal a dead pid.
|
||||
const b = $('send'); if (b) b.disabled = true;
|
||||
phase('stopping…');
|
||||
fetch(API, { method: 'POST', headers: POST_HEAD,
|
||||
body: new URLSearchParams({ action: 'stop', token: t }) })
|
||||
.then(r => r.json())
|
||||
.catch(() => {});
|
||||
// Nothing is rendered from the response. The poll already owns turning a terminal state into
|
||||
// a message, and having two paths do it is how a turn ends up in the transcript twice.
|
||||
}
|
||||
|
||||
function poll(token, started) {
|
||||
if (Date.now() - started > POLL_CEIL) {
|
||||
addError('Timed out waiting for a response.'); finish(); return;
|
||||
@@ -653,6 +731,25 @@ vv_ai_profiles_script();
|
||||
fetch(API + '?action=poll&token=' + encodeURIComponent(token)).then(r => r.json()).then(d => {
|
||||
if (!d.ok) { addError(d.error || 'Poll failed'); finish(); return; }
|
||||
const j = d.job || {};
|
||||
// Stopped is terminal and keeps whatever had been written. A turn cancelled at 80% is
|
||||
// usually cancelled because there was already enough on screen, so discarding it would
|
||||
// punish the operator for the one control that exists to save them time.
|
||||
if (j.status === 'stopped') {
|
||||
if ((j.answer || '').trim()) {
|
||||
addAnswer(j);
|
||||
messages.push({ role: 'assistant', content: j.answer });
|
||||
noteLine('Stopped — the part already written is kept.');
|
||||
} else {
|
||||
const p = $('pending'); if (p) p.remove();
|
||||
noteLine('Stopped before anything was written.');
|
||||
}
|
||||
fetch(API, { method: 'POST', headers: POST_HEAD,
|
||||
body: new URLSearchParams({ action: 'clear', token }) }).catch(() => {});
|
||||
finish();
|
||||
save();
|
||||
return;
|
||||
}
|
||||
|
||||
if (j.status === 'done') {
|
||||
addAnswer(j);
|
||||
messages.push({ role: 'assistant', content: j.answer });
|
||||
@@ -667,10 +764,21 @@ vv_ai_profiles_script();
|
||||
return;
|
||||
}
|
||||
if (j.status === 'error') { addError(j.error || 'Unknown error'); finish(); return; }
|
||||
|
||||
// Partial text arrives on the same envelope as the status, so the transcript fills in
|
||||
// without a second channel. Empty while the model is still reasoning — thinking_chars is
|
||||
// what moves then, and a page watching only partial would look wedged for ~15s.
|
||||
if (j.status === 'generating') streamInto(j.partial || '');
|
||||
|
||||
phase(j.status === 'generating'
|
||||
? 'generating… (' + ((j.sources||[]).length) + ' sources retrieved)'
|
||||
? (j.partial
|
||||
? 'writing… (' + ((j.sources||[]).length) + ' sources)'
|
||||
: (j.thinking_chars
|
||||
? 'reasoning… (' + j.thinking_chars.toLocaleString() + ' chars)'
|
||||
: 'generating… (' + ((j.sources||[]).length) + ' sources retrieved)'))
|
||||
: j.status === 'retrieving' ? 'searching the index…' : 'starting…');
|
||||
setTimeout(() => poll(token, started), POLL_MS);
|
||||
setTimeout(() => poll(token, started),
|
||||
j.status === 'generating' ? POLL_FAST_MS : POLL_MS);
|
||||
}).catch(e => { addError('Poll failed: ' + e); finish(); });
|
||||
}
|
||||
|
||||
@@ -905,7 +1013,9 @@ vv_ai_profiles_script();
|
||||
// Ctrl+Enter is not bound here. It lives with the rest of the shortcuts on the wrapper below,
|
||||
// and a second binding on the input would send the same question twice — the second landing
|
||||
// on the busy guard and reporting the first as stuck.
|
||||
$('send').addEventListener('click', send);
|
||||
// One button, two jobs — which one is decided by busy rather than by what the label happens to
|
||||
// say, so a stale label can never send a question into a turn already running.
|
||||
$('send').addEventListener('click', () => { busy ? stopTurn() : send(); });
|
||||
const newBtn = $('new'); if (newBtn) newBtn.addEventListener('click', newChat);
|
||||
|
||||
// ── Expand / collapse ────────────────────────────────────────────────
|
||||
@@ -1169,10 +1279,7 @@ vv_ai_profiles_script();
|
||||
|
||||
// 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();
|
||||
},
|
||||
note: noteLine,
|
||||
teardown() {
|
||||
clearInterval(pendingTimer);
|
||||
// Would otherwise fire a re-layout at a page whose instance no longer exists.
|
||||
|
||||
Reference in New Issue
Block a user