diff --git a/Plugin/unraid/include/ai_chat.php b/Plugin/unraid/include/ai_chat.php index 84d6251..12fa8ba 100644 --- a/Plugin/unraid/include/ai_chat.php +++ b/Plugin/unraid/include/ai_chat.php @@ -242,9 +242,16 @@ function vv_ai_chat_assets(): void { .vv-ai-think-t { font-size:10px; color:#4a4a4a; cursor:pointer; user-select:none; margin-bottom:6px; display:inline-block; border:1px solid #222; border-radius:3px; padding:2px 7px; } .vv-ai-think-t:hover { color:#777; border-color:#333; } +/* Same caret the sources block uses, and for the same reason: the control has to say which way it + is pointing, or "reasoning (1,284 chars)" reads as a label rather than as something to click. */ +.vv-ai-think-car { display:inline-block; width:10px; } .vv-ai-think { display:none; font-size:11px; line-height:1.6; color:#5a5a5a; background:#0d0d0d; - border-left:2px solid #262626; padding:8px 10px; margin-bottom:8px; white-space:pre-wrap; } + border-left:2px solid #262626; padding:8px 10px; margin-bottom:8px; + white-space:pre-wrap; word-break:break-word; } .vv-ai-think.open { display:block; } +/* The live pair is hidden by its wrapper, never by [hidden] on the pair itself: both carry an + author display rule, and an author rule beats the UA stylesheet's [hidden] — so hiding them + directly would do nothing at all. The wrapper has no display of its own, so [hidden] holds. */ .vv-ai-src { margin-top:9px; border-top:1px solid #1c1c1c; padding-top:7px; } .vv-ai-src-h { font-size:9px; letter-spacing:.07em; text-transform:uppercase; color:#3a3a3a; @@ -269,13 +276,13 @@ 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; } +/* Reasoning in flight has no styling of its own. It used to: a capped, self-scrolling window, + justified as something to glance at rather than read. That made two presentations of one thing — + the block you were watching turned into a different control the moment the answer arrived — and + put a nested scrollbar inside a scrolling transcript. It uses the ordinary toggle now, so the + compact palette and everything else that dresses .vv-ai-think applies to it for free. The cap + was also defending against reasoning pushing the answer off screen, which is what Auto Scroll + is for; that control now actually holds, so the cap was solving it twice. */ .vv-ai-stream { margin-top:6px; } /* Stop reads as the destructive-ish action it is, and the colour change is what tells the operator @@ -788,7 +795,12 @@ vv_ai_profiles_script(); + `
` + `starting…` + `
` - + `` + // The same toggle a finished answer carries, open while it is being written. + // Wrapped, because [hidden] on either of these loses to their own display rule. + + `` + ``); chatEl().appendChild(n); scroll(); // An elapsed counter distinguishes "working" from "wedged" at a glance. Without it a @@ -868,19 +880,20 @@ vv_ai_profiles_script(); // 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. + // + // Only the count is rewritten, never the toggle around it — collapsing the block mid-answer is + // a decision, and redrawing the caret three times a second would undo it. function thinkInto(text) { - const t = $('livethink'); - if (!t) return; - const on = seeThink() && text; - t.hidden = !on; + const w = $('think-w'), t = $('livethink'), n = $('think-n'); + if (!w || !t) return; + const on = seeThink() && !!text; + w.hidden = !on; if (!on) return; // Wrapped for the same reason streamInto's rewrite is: growing this block changes the // transcript's scroll height, and the resulting event is ours rather than the operator's. selfMove(() => { 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; + if (n) n.textContent = text.length.toLocaleString(); }); scrollIfFollowing(); } @@ -923,7 +936,7 @@ vv_ai_profiles_script(); let h = `
Varaverk
`; if (job.thinking) { - h += `
` + h += `
` + `reasoning (${job.thinking.length.toLocaleString()} chars)
` + `
${esc(job.thinking)}
`; } @@ -975,8 +988,15 @@ vv_ai_profiles_script(); // One listener for the whole transcript, covering nodes that do not exist yet. chatEl().addEventListener('click', e => { + // Drives both the live block and a finished answer's — they are the same control now, so + // one handler owns the caret and neither can drift into disagreeing about which way it points. const think = e.target.closest('.vv-ai-think-t'); - if (think) { think.nextElementSibling.classList.toggle('open'); return; } + if (think) { + const open = think.nextElementSibling.classList.toggle('open'); + const car = think.querySelector('.vv-ai-think-car'); + if (car) car.textContent = open ? '▾' : '▸'; + return; + } const src = e.target.closest('.vv-ai-src-i'); // Through vvSafeUrl, which is the global that exists precisely so a URL from outside this // machine cannot become a javascript: href. The server drops anything that is not http(s) @@ -1777,9 +1797,10 @@ vv_ai_profiles_script(); 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; + // usually someone deciding they have seen enough. The wrapper is what hides, for the same + // reason thinkInto uses it: [hidden] on the pair itself loses to their own display rule. + const w = $('think-w'), t = $('livethink'); + if (w) w.hidden = !seeEl.checked || !t || !t.textContent; }); } const newBtn = $('new'); if (newBtn) newBtn.addEventListener('click', newChat);