Watch the reasoning as it is written, and hold the view where you put it
This commit is contained in:
@@ -936,10 +936,13 @@ while (($line = fgets($fh)) !== false) {
|
||||
$now = microtime(true);
|
||||
if ($now - $lastFlush >= $FLUSH_SEC) {
|
||||
$lastFlush = $now;
|
||||
// thinking_chars rather than the reasoning itself: with think on, nothing lands in
|
||||
// content for ~15s, and a page that only watched partial would look hung.
|
||||
// The reasoning rides along as well as its length. The count alone is what keeps the
|
||||
// phase line honest during the ~15s before any content lands; the text is what the
|
||||
// page shows when the operator has asked to watch it happen. Both are cheap here —
|
||||
// the job file is tmpfs and the poll is a loopback request.
|
||||
jw($jobFile, ['status' => 'generating',
|
||||
'partial' => $answer,
|
||||
'thinking' => $thinking,
|
||||
'thinking_chars' => strlen($thinking),
|
||||
'sources' => $sources]);
|
||||
}
|
||||
|
||||
@@ -248,6 +248,14 @@ function vv_ai_chat_assets(): void {
|
||||
/* 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. */
|
||||
/* Reasoning in flight. Capped and self-scrolling so it cannot push the answer off screen — this is
|
||||
something to glance at while waiting, not the thing being read. Visually quieter than the answer
|
||||
because it is not the answer. */
|
||||
.vv-ai-livethink { margin:6px 0 0; max-height:180px; overflow-y:auto; background:#0d0d0d;
|
||||
border-left:2px solid #2a2a2a; border-radius:0 3px 3px 0; padding:7px 9px;
|
||||
font-size:11px; line-height:1.55; color:#5f5f5f; white-space:pre-wrap;
|
||||
word-break:break-word; }
|
||||
|
||||
.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. */
|
||||
@@ -276,9 +284,23 @@ function vv_ai_chat_assets(): void {
|
||||
/* Banner. The title takes the same shape a .vv-card h3 does, so an instance dropped into a card
|
||||
reads as that card's heading rather than as a second one underneath it. */
|
||||
.vv-ai-head { display:flex; align-items:center; gap:6px; margin-bottom:8px; }
|
||||
/* The title takes the slack, so the two controls group together at the right rather than being
|
||||
spread apart — with three children, space-between would strand the size button in the middle. */
|
||||
.vv-ai-head-t { display:flex; align-items:center; gap:5px; flex:1 1 auto; min-width:0;
|
||||
/* flex:1 1 0 on both outer groups, not 1 1 auto — with auto, the basis is the content, so a long
|
||||
title on one side and two buttons on the other take different shares and the middle drifts off
|
||||
centre. A zero basis makes the two claims equal whatever they contain. */
|
||||
.vv-ai-head-c { display:flex; align-items:center; gap:10px; flex:0 0 auto; }
|
||||
.vv-ai-head-r { display:flex; align-items:center; gap:6px; flex:1 1 0; min-width:0;
|
||||
justify-content:flex-end; }
|
||||
/* Checkbox and label move together, and the label is clickable — a 13px box is a poor target on a
|
||||
panel being used from across a room. */
|
||||
.vv-ai-cb { display:flex; align-items:center; gap:4px; font-size:10px; color:#5a5a5a;
|
||||
cursor:pointer; user-select:none; white-space:nowrap; text-transform:none;
|
||||
letter-spacing:0; }
|
||||
.vv-ai-cb input { margin:0; cursor:pointer; }
|
||||
.vv-ai-cb:hover { color:#8a8a8a; }
|
||||
/* Narrow placements drop the labels rather than wrapping the banner onto a second line, which
|
||||
would change the measured chrome height the Scheduler subtracts. */
|
||||
@media (max-width:560px) { .vv-ai-cb span { display:none; } }
|
||||
.vv-ai-head-t { display:flex; align-items:center; gap:5px; flex:1 1 0; min-width:0;
|
||||
font-size:13px; color:#888; text-transform:uppercase; letter-spacing:0.05em;
|
||||
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
|
||||
@@ -543,6 +565,7 @@ vv_ai_profiles_script();
|
||||
+ `<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>`
|
||||
+ `<pre class="vv-ai-livethink" id="${P}-livethink" hidden></pre>`
|
||||
+ `<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
|
||||
@@ -568,13 +591,28 @@ vv_ai_profiles_script();
|
||||
|
||||
// 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.
|
||||
// every delta drags them back down three times a second.
|
||||
const nearBottom = () => {
|
||||
const c = chatEl();
|
||||
return (c.scrollHeight - c.scrollTop - c.clientHeight) < 40;
|
||||
};
|
||||
|
||||
// Follow is the visible form of the same rule, and works exactly as the Scheduler's log panel
|
||||
// does: scrolling up unticks it, returning to the bottom ticks it again, and it can be unticked
|
||||
// by hand to pin the view while an answer is still arriving. Making it a control rather than
|
||||
// an invisible heuristic matters because the heuristic is otherwise indistinguishable from the
|
||||
// page having stopped updating.
|
||||
const following = () => { const f = $('follow'); return !f || f.checked; };
|
||||
|
||||
function syncFollow() {
|
||||
const f = $('follow');
|
||||
if (f) f.checked = nearBottom();
|
||||
syncJump();
|
||||
}
|
||||
|
||||
// Only when following. A plain scroll() here would defeat the control it is meant to obey.
|
||||
function scrollIfFollowing() { if (following()) scroll(); }
|
||||
|
||||
// The other half of stick-to-bottom. Scrolling up during a stream deliberately stops the
|
||||
// transcript chasing you, which means text is now arriving off-screen with nothing saying so.
|
||||
// The pill is that acknowledgement, and the way back.
|
||||
@@ -591,13 +629,32 @@ vv_ai_profiles_script();
|
||||
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();
|
||||
scrollIfFollowing();
|
||||
syncJump();
|
||||
}
|
||||
|
||||
// Live reasoning, shown only when asked for. Rendered as plain escaped text rather than
|
||||
// through fmt(): reasoning is a stream of consciousness full of half-written fences and stray
|
||||
// backticks, and formatting it mid-flight produces flickering code blocks that close
|
||||
// themselves a second later.
|
||||
function thinkInto(text) {
|
||||
const t = $('livethink');
|
||||
if (!t) return;
|
||||
const on = seeThink() && text;
|
||||
t.hidden = !on;
|
||||
if (!on) return;
|
||||
t.textContent = text;
|
||||
// Pinned to its own newest line, so watching reasoning does not require scrolling the
|
||||
// transcript while the answer is still arriving underneath it.
|
||||
t.scrollTop = t.scrollHeight;
|
||||
scrollIfFollowing();
|
||||
}
|
||||
|
||||
const SEE_THINK_KEY = 'vvAiSeeThink:' + P;
|
||||
const seeThink = () => { const c = $('see-think'); return !!c && c.checked; };
|
||||
|
||||
// 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) {
|
||||
@@ -1107,7 +1164,10 @@ vv_ai_profiles_script();
|
||||
// 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 || '');
|
||||
if (j.status === 'generating') {
|
||||
streamInto(j.partial || '');
|
||||
thinkInto(j.thinking || '');
|
||||
}
|
||||
|
||||
phase(j.status === 'generating'
|
||||
? (j.partial
|
||||
@@ -1411,9 +1471,24 @@ vv_ai_profiles_script();
|
||||
|
||||
const jumpEl = $('jump');
|
||||
if (jumpEl) {
|
||||
jumpEl.addEventListener('click', () => { scroll(); syncJump(); });
|
||||
// Ticks Follow again on the way down, because arriving at the bottom is what Follow means.
|
||||
jumpEl.addEventListener('click', () => { scroll(); syncFollow(); });
|
||||
}
|
||||
// passive: this only reads scroll position, so it must never delay the scroll itself.
|
||||
chatEl().addEventListener('scroll', syncJump, { passive: true });
|
||||
chatEl().addEventListener('scroll', syncFollow, { passive: true });
|
||||
|
||||
const seeEl = $('see-think');
|
||||
if (seeEl) {
|
||||
// A display preference, so it is remembered per instance — the Monitor card and the AI tab
|
||||
// are watched in different circumstances and should not share one answer.
|
||||
try { seeEl.checked = localStorage.getItem(SEE_THINK_KEY) === '1'; } catch (_) {}
|
||||
seeEl.addEventListener('change', () => {
|
||||
try { localStorage.setItem(SEE_THINK_KEY, seeEl.checked ? '1' : '0'); } catch (_) {}
|
||||
// Takes effect on the turn in flight, not just the next one — unticking it mid-answer is
|
||||
// usually someone deciding they have seen enough.
|
||||
const t = $('livethink');
|
||||
if (t) t.hidden = !seeEl.checked || !t.textContent;
|
||||
});
|
||||
}
|
||||
const newBtn = $('new'); if (newBtn) newBtn.addEventListener('click', newChat);
|
||||
|
||||
@@ -1859,12 +1934,31 @@ function vv_ai_chat_markup(string $prefix, array $o = []): void {
|
||||
<?php if (!empty($o['icon'])): ?><span class="vv-ico"><?= $o['icon'] ?></span><?php endif; ?>
|
||||
<?= htmlspecialchars($o['title'] ?? 'Assistant') ?>
|
||||
</span>
|
||||
|
||||
<!-- Centred by giving the title and the right-hand group equal flex, not by margins — the
|
||||
title changes width per placement and the buttons wrap on a narrow panel, and either
|
||||
would drag a margin-centred group off centre. Same reasoning as the composer row.
|
||||
|
||||
Both are view controls: neither changes what is asked, only what you watch while it
|
||||
happens. That is why they sit here with the window controls rather than in the composer
|
||||
beside the things that shape the question. -->
|
||||
<span class="vv-ai-head-c">
|
||||
<label class="vv-ai-cb" title="Show the model's reasoning as it is written, not after">
|
||||
<input type="checkbox" id="<?= $p ?>-see-think"> <span>Reasoning</span>
|
||||
</label>
|
||||
<label class="vv-ai-cb" title="Follow the newest line. Unticks when you scroll up, re-ticks at the bottom.">
|
||||
<input type="checkbox" id="<?= $p ?>-follow" checked> <span>Auto Scroll</span>
|
||||
</label>
|
||||
</span>
|
||||
|
||||
<span class="vv-ai-head-r">
|
||||
<button class="vv-ai-btn ghost vv-ai-size" id="<?= $p ?>-size" type="button"
|
||||
style="display:none">Medium</button>
|
||||
<!-- Far right, and there whatever the conversation is doing — unlike the size button beside
|
||||
it, which has nothing to say while collapsed. -->
|
||||
<button class="vv-ai-btn ghost vv-ai-keysbtn" id="<?= $p ?>-keys-t" type="button"
|
||||
aria-expanded="true" title="Keyboard shortcuts">Shortcuts</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Shortcuts. Open on a browser that has never seen it and remembered from then on, which for
|
||||
|
||||
Reference in New Issue
Block a user