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 { - - - + + + + + + + + + + + +