Auto Min Thinking Display when done
This commit is contained in:
@@ -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.')
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user