diff --git a/Plugin/unraid/Tools/ai_chat_worker.php b/Plugin/unraid/Tools/ai_chat_worker.php
index 7db4303..7bc0a17 100644
--- a/Plugin/unraid/Tools/ai_chat_worker.php
+++ b/Plugin/unraid/Tools/ai_chat_worker.php
@@ -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]);
}
diff --git a/Plugin/unraid/include/ai_chat.php b/Plugin/unraid/include/ai_chat.php
index 4718065..e007288 100644
--- a/Plugin/unraid/include/ai_chat.php
+++ b/Plugin/unraid/include/ai_chat.php
@@ -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();
+ `
`
+ `starting…`
+ `
`
+ + ``
+ ``);
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(); });
- // passive: this only reads scroll position, so it must never delay the scroll itself.
- chatEl().addEventListener('scroll', syncJump, { passive: true });
+ // 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', 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 {
= $o['icon'] ?>
= htmlspecialchars($o['title'] ?? 'Assistant') ?>
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+