Auto Min Thinking Display when done

This commit is contained in:
Raven Scott
2026-07-30 22:27:59 -04:00
parent 90ef577dbf
commit 367483dac5
8 changed files with 133 additions and 11 deletions
@@ -464,6 +464,28 @@ node peardata-branding/scripts/generate-social-cards.mjs
Also: \`avatar-*.png\` and \`social-banner.png\` in the parent \`social/\` folder for profile/cover.
`
// Open Graph / Twitter embeds (1200×630) for website + branding package
const heroPng = path.join(OUT, 'linkedin-hero-1200x627.png')
if (fs.existsSync(heroPng)) {
const ogBuf = await sharp(heroPng)
.resize(1200, 630, {
fit: 'contain',
background: { r: 10, g: 12, b: 16, alpha: 1 },
})
.png({ compressionLevel: 9, adaptiveFiltering: true })
.toBuffer()
const ogTargets = [
path.join(OUT, 'og-image-1200x630.png'),
path.join(ROOT, 'website', 'og-image.png'),
path.join(ROOT, 'website', 'twitter-card.png'),
]
for (const t of ogTargets) {
fs.mkdirSync(path.dirname(t), { recursive: true })
fs.writeFileSync(t, ogBuf)
}
console.log(' ✓ og-image / twitter-card (1200×630) → website/ + social/cards/')
}
fs.writeFileSync(path.join(OUT, 'README.md'), readme)
console.log('Done.')
}
+17
View File
@@ -31,3 +31,20 @@ node peardata-branding/scripts/generate-social-cards.mjs
```
Also: `avatar-*.png` and `social-banner.png` in the parent `social/` folder for profile/cover.
## Open Graph / Twitter embed
`og-image-1200x630.png` is the **linkedin-hero** card padded to 1200×630 for link previews.
Synced copies:
- `peardata-branding/website/og-image.png`
- `peardata-branding/website/twitter-card.png`
- Website: `public/assets/brand/og-image.png` + `twitter-card.png` (on peardata_website)
Meta URLs (unchanged paths):
```
https://peardata.rest/assets/brand/og-image.png
https://peardata.rest/assets/brand/twitter-card.png
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 548 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 534 KiB

After

Width:  |  Height:  |  Size: 144 KiB

+59
View File
@@ -0,0 +1,59 @@
import test from 'brittle'
import {
partitionThink,
shouldOpenThink,
renderAssistantHtml,
} from '../ui/qvac/think.js'
test('partitionThink splits complete think blocks', async (t) => {
const r = partitionThink('<think>step 1</think>\nHello world')
t.is(r.thinking, 'step 1')
t.is(r.answer, 'Hello world')
t.is(r.thinkingOpen, false)
})
test('partitionThink detects open (streaming) think blocks', async (t) => {
const r = partitionThink('<think>\npartial reason')
t.ok(r.thinking.includes('partial reason'))
t.is(r.answer, '')
t.is(r.thinkingOpen, true)
})
test('shouldOpenThink stays open while streaming', async (t) => {
const parts = partitionThink('<think>a</think>\nanswer')
t.is(shouldOpenThink(parts, true), true)
t.is(shouldOpenThink(parts, false), false)
})
test('shouldOpenThink stays open for incomplete think blocks', async (t) => {
const parts = partitionThink('<think>still going')
t.is(shouldOpenThink(parts, true), true)
t.is(shouldOpenThink(parts, false), true)
})
test('shouldOpenThink is false with no thinking', async (t) => {
const parts = partitionThink('just an answer')
t.is(shouldOpenThink(parts, true), false)
t.is(shouldOpenThink(parts, false), false)
})
test('renderAssistantHtml collapses think when openThink is false', async (t) => {
const html = renderAssistantHtml(
'<think>secret</think>\nVisible',
(s) => s,
{ openThink: false }
)
t.ok(html.includes('class="qvac-think"'))
t.absent(html.includes(' open>'))
t.ok(html.includes('Visible'))
t.ok(html.includes('secret'))
})
test('renderAssistantHtml expands think when openThink is true', async (t) => {
const html = renderAssistantHtml(
'<think>secret</think>\nVisible',
(s) => s,
{ openThink: true }
)
t.ok(html.includes('class="qvac-think" open'))
})
+11 -7
View File
@@ -5,7 +5,7 @@ import { createQvacEngine } from './engine.js'
import { createToolRunner } from './tools.js'
import { PROFILE_LIST, getProfile, suggestProfile } from './profiles.js'
import { SAMPLE_PROMPTS } from './prompts.js'
import { partitionThink, renderAssistantHtml } from './think.js'
import { partitionThink, renderAssistantHtml, shouldOpenThink } from './think.js'
import {
shouldUseSubAgents,
runSubAgents,
@@ -732,6 +732,8 @@ export function createQvacView(opts) {
/**
* Paint / update assistant HTML. While streaming with an open think panel,
* updates the think body in place so scroll position can stay pinned.
* When the response finishes, auto-collapses the think panel
* (user can re-open via the summary).
* @param {HTMLElement|null|undefined} bodyEl
* @param {string} raw
* @param {boolean} streaming
@@ -739,6 +741,7 @@ export function createQvacView(opts) {
function paintAssistant(bodyEl, raw, streaming) {
if (!bodyEl) return
const parts = partitionThink(raw)
const openThink = shouldOpenThink(parts, streaming)
let details = bodyEl.querySelector('details.qvac-think')
let thinkBody = bodyEl.querySelector('.qvac-think-body')
let answerEl = bodyEl.querySelector('.qvac-msg-answer')
@@ -746,9 +749,10 @@ export function createQvacView(opts) {
// Fast path: structure already exists and we still have thinking text —
// update DOM in place so the think scroller doesn't jump to top each token.
if (parts.thinking && details && thinkBody) {
details.open = true
details.open = openThink
const summary = details.querySelector('.qvac-think-summary')
let live = summary?.querySelector('.qvac-think-live')
// "live" badge while the model is still generating this turn
const showLive = Boolean(streaming || parts.thinkingOpen)
if (showLive && summary && !live) {
live = document.createElement('span')
@@ -759,7 +763,7 @@ export function createQvacView(opts) {
live.remove()
}
const stick = streaming || isNearBottom(thinkBody)
const stick = openThink && (streaming || isNearBottom(thinkBody))
thinkBody.innerHTML = formatMdLite(parts.thinking)
if (stick) followThinkScroll(thinkBody, { force: true })
@@ -787,13 +791,13 @@ export function createQvacView(opts) {
}
// Full re-render (first paint, or no think block)
bodyEl.innerHTML = renderAssistantHtml(raw, formatMdLite, { openThink: streaming })
bodyEl.innerHTML = renderAssistantHtml(raw, formatMdLite, { openThink })
thinkBody = bodyEl.querySelector('.qvac-think-body')
if (streaming || parts.thinkingOpen) {
if (openThink) {
followThinkScroll(thinkBody, { force: true })
followMessagesScroll({ force: true })
} else if (thinkBody && isNearBottom(thinkBody)) {
followThinkScroll(thinkBody, { force: true })
} else if (streaming) {
followMessagesScroll({ force: true })
}
}
+24 -4
View File
@@ -44,18 +44,38 @@ export function partitionThink(text) {
}
}
/**
* Whether the thinking `<details>` should be expanded.
* Stays open while the model is generating; auto-collapses when the response
* finishes so the answer is front and center. User can re-open via the summary.
*
* @param {{ thinking: string, answer: string, thinkingOpen: boolean }} parts
* @param {boolean} streaming
*/
export function shouldOpenThink(parts, streaming) {
if (!parts.thinking) return false
// Incomplete think block (still streaming tags) always stays open
if (parts.thinkingOpen) return true
// Keep expanded for the whole generation; collapse only when done
return Boolean(streaming)
}
/**
* Render assistant body HTML: optional collapsible think + answer.
* @param {string} raw
* @param {(s: string) => string} formatBody
* @param {{ openThink?: boolean }} [opts]
* @param {{ openThink?: boolean }} [opts] openThink forces expanded/collapsed when set
*/
export function renderAssistantHtml(raw, formatBody, opts = {}) {
const { thinking, answer, thinkingOpen } = partitionThink(raw)
const open = opts.openThink !== false && (thinkingOpen || Boolean(thinking))
const parts = partitionThink(raw)
const { thinking, answer, thinkingOpen } = parts
const isOpen =
typeof opts.openThink === 'boolean'
? opts.openThink && Boolean(thinking)
: shouldOpenThink(parts, false)
let html = ''
if (thinking) {
html += `<details class="qvac-think"${open ? ' open' : ''}>
html += `<details class="qvac-think"${isOpen ? ' open' : ''}>
<summary class="qvac-think-summary">
<span class="qvac-think-ico" aria-hidden="true">◐</span>
<span>Thinking</span>