perf: reduce streaming render cost and refresh activity page on demand
- Segment streaming Markdown so completed blocks are reused and only the growing tail is re-parsed; fall back to whole-document parsing for document-wide constructs - Memoize conversation sidebar rows and cache per-conversation derived activity, ordering and pending approvals - Activity page loads on entry, refreshes every 15 s while visible, and has a single header refresh button - Include pending settings, model download source, device sharing and knowledge gateway changes in the working tree - Add Electron streaming input benchmark and regression tests
This commit is contained in:
@@ -35,6 +35,14 @@ void app.whenReady().then(async () => {
|
||||
try {
|
||||
await win.loadURL(process.env.GB_SHARING_RENDERER!)
|
||||
await wait(`!![...document.querySelectorAll('article')].find(e => e.textContent.includes('Device Sharing'))`)
|
||||
assert.equal(await js(`[...document.querySelectorAll('article')].find(e=>e.textContent.includes('Device Sharing')).querySelector('button').disabled`), true)
|
||||
await js(`document.querySelector('[aria-label="Device Sharing (Technical Preview) Application settings"]').click()`)
|
||||
await wait(`!!document.querySelector('input[role="switch"]')`)
|
||||
assert.equal(await js(`document.querySelectorAll('input[role="switch"]').length`), 1)
|
||||
await js(`document.querySelector('input[role="switch"]').click()`)
|
||||
await wait(`document.querySelector('input[role="switch"]').checked`)
|
||||
await js(`document.querySelector('[aria-label="Back to application center"]').click()`)
|
||||
await wait(`!![...document.querySelectorAll('article')].find(e => e.textContent.includes('Device Sharing'))`)
|
||||
await js(`[...document.querySelectorAll('article')].find(e=>e.textContent.includes('Device Sharing')).querySelector('button').click()`)
|
||||
await click('Register this device')
|
||||
await wait(`!!document.querySelector('#sharing-devices + article') || document.querySelector('[aria-labelledby="sharing-devices"]').textContent.includes('Sharing smoke device')`)
|
||||
|
||||
@@ -9,12 +9,13 @@ import '../../src/renderer/src/styles.css'
|
||||
const settings = applicationSettingsSchema.parse({ checkUpdatesOnStartup: false, updateSource: 'github', modelDownloadSource: 'modelscope',
|
||||
localToolEnvironment: defaultLocalToolEnvironmentSettings, conversationHtmlRenderingEnabled: true, remoteProjectsEnabled: false })
|
||||
function Harness(): React.JSX.Element {
|
||||
const [currentSettings, setSettings] = useState(settings)
|
||||
const [open, setOpen] = useState(true)
|
||||
const [page, setPage] = useState(false)
|
||||
return <UiLocaleProvider initialPreference="en-US">
|
||||
{open && <ApplicationCenter settings={settings} pending={false} onClose={() => setOpen(false)}
|
||||
{open && <ApplicationCenter settings={currentSettings} pending={false} onClose={() => setOpen(false)}
|
||||
onOpen={id => { if (id === 'device-sharing') { setPage(true); setOpen(false) } }}
|
||||
onUpdate={async () => false} onRetry={() => undefined} />}
|
||||
onUpdate={async patch => { setSettings(current => ({ ...current, ...patch })); return true }} onRetry={() => undefined} />}
|
||||
{page && <DeviceSharingPage notify={input => { document.documentElement.dataset.notification = input.message }} />}
|
||||
</UiLocaleProvider>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import assert from 'node:assert/strict'
|
||||
import { join } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
import { AgentEventBuffer } from '../../src/main/agent-event-buffer'
|
||||
import { longAnswerCorpus } from './streaming-markdown-corpus'
|
||||
|
||||
const directory = process.env.GOODBUDDY_STREAMING_INPUT_DIRECTORY!
|
||||
const epoch = () => performance.timeOrigin + performance.now()
|
||||
const duration = 5_000
|
||||
const chunkPeriod = 8
|
||||
const inputPeriod = 71
|
||||
// End on a complete section and add math, rather than truncating a code fence/table.
|
||||
const sectionEnd = longAnswerCorpus.indexOf('## Deployment review', 22_000)
|
||||
const answer = longAnswerCorpus.slice(0, sectionEnd) + '\nInline \\(x^2 + y^2\\) and display math:\n\n\\[E = mc^2\\]\n'
|
||||
const chunkCount = duration / chunkPeriod
|
||||
const chunks = Array.from({ length: chunkCount }, (_, index) => answer.slice(
|
||||
Math.floor(answer.length * index / chunkCount), Math.floor(answer.length * (index + 1) / chunkCount)))
|
||||
const inputOffsets = Array.from({ length: Math.floor((duration - 200) / inputPeriod) }, (_, index) => 103 + index * inputPeriod)
|
||||
const draft = inputOffsets.map((_, index) => String.fromCharCode(97 + index % 26)).join('')
|
||||
const stats = (values: number[]) => {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return { n: sorted.length, median: (sorted[Math.floor((sorted.length - 1) / 2)]! + sorted[Math.floor(sorted.length / 2)]!) / 2,
|
||||
p95: sorted[Math.ceil(sorted.length * 0.95) - 1]!, max: sorted.at(-1)! }
|
||||
}
|
||||
app.setPath('userData', join(directory, 'profile'))
|
||||
app.commandLine.appendSwitch('force-device-scale-factor', '1')
|
||||
app.whenReady().then(async () => {
|
||||
const win = new BrowserWindow({ show: true, width: 1100, height: 800,
|
||||
webPreferences: { preload: join(directory, 'preload.cjs'), sandbox: true,
|
||||
contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } })
|
||||
const errors: string[] = []
|
||||
win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message) })
|
||||
await win.loadFile(join(directory, 'renderer', 'streaming-input-page.html'))
|
||||
win.focus()
|
||||
const evaluate = (code: string) => win.webContents.executeJavaScript(`window.streamingInput.${code}`)
|
||||
const reference = await evaluate(`reset(${JSON.stringify(answer)})`)
|
||||
assert(reference.includes('Deployment review'))
|
||||
const results: { interval: number; updates: number; longTasks: number;
|
||||
raw: { sendToInput: number[]; inputToFrame: number[] } }[] = []
|
||||
// Warm one full growing answer, then rotate and reverse the measured order.
|
||||
const order = [32, 16, 50, 32, 80, 80, 32, 50, 16]
|
||||
for (const [position, interval] of order.entries()) {
|
||||
await evaluate('reset()')
|
||||
// Estimate process clock offset while idle; minimum RTT bounds the timestamp uncertainty.
|
||||
const calibration = []
|
||||
for (let sample = 0; sample < 12; sample++) {
|
||||
const before = epoch()
|
||||
const renderer = await evaluate('clock()')
|
||||
const after = epoch()
|
||||
calibration.push({ offset: renderer - (before + after) / 2, rtt: after - before })
|
||||
}
|
||||
const clock = calibration.sort((a, b) => a.rtt - b.rtt)[0]!
|
||||
const sent: number[] = []
|
||||
const chunkLateness: number[] = []
|
||||
const inputLateness: number[] = []
|
||||
let flushes = 0
|
||||
let firstFlush = 0
|
||||
const buffer = new AgentEventBuffer({ flushIntervalMs: interval, onEvent(event) {
|
||||
if (event.type !== 'text') return
|
||||
flushes++
|
||||
if (!firstFlush) firstFlush = epoch()
|
||||
win.webContents.send('streaming-input:chunk', event.delta)
|
||||
} })
|
||||
const start = epoch()
|
||||
// All deadlines are scheduled in main up front. Renderer blockage cannot slow the producer or typing.
|
||||
const arrivals = chunks.map((delta, index) => new Promise<void>(resolve => {
|
||||
const deliver = () => {
|
||||
chunkLateness.push(epoch() - start - index * chunkPeriod)
|
||||
buffer.push({ type: 'text', requestId: 'benchmark', delta })
|
||||
if (index === 0) buffer.flush()
|
||||
resolve()
|
||||
}
|
||||
if (index === 0) deliver()
|
||||
else setTimeout(deliver, Math.max(0, start + index * chunkPeriod - epoch()))
|
||||
}))
|
||||
const typing = inputOffsets.map((offset, index) => new Promise<void>(resolve => {
|
||||
setTimeout(() => {
|
||||
sent.push(epoch())
|
||||
inputLateness.push(sent.at(-1)! - start - offset)
|
||||
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: draft[index]! })
|
||||
win.webContents.sendInputEvent({ type: 'char', keyCode: draft[index]! })
|
||||
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: draft[index]! })
|
||||
resolve()
|
||||
}, Math.max(0, start + offset - epoch()))
|
||||
}))
|
||||
await Promise.all([...arrivals, ...typing, delay(Math.max(0, start + duration - epoch()))])
|
||||
buffer.close()
|
||||
const producerDuration = epoch() - start
|
||||
let ready = false
|
||||
for (let poll = 0; poll < 400; poll++) {
|
||||
const status = await evaluate('status()')
|
||||
if (status.length === answer.length && status.inputs === draft.length && status.frames === draft.length) {
|
||||
ready = true
|
||||
break
|
||||
}
|
||||
await delay(10)
|
||||
}
|
||||
assert(ready, 'renderer must drain the entire independently scheduled stream and native input')
|
||||
const result = await evaluate('result()')
|
||||
assert.equal(result.committed, answer)
|
||||
assert.equal(result.domText, reference)
|
||||
assert.equal(result.draft, draft)
|
||||
assert.equal(result.committedDraft, draft)
|
||||
assert.equal(result.focused, true)
|
||||
assert.equal(result.received, flushes)
|
||||
assert.equal(result.inputs.length, sent.length)
|
||||
for (const [index, input] of result.inputs.entries()) {
|
||||
assert.equal(input.value, draft.slice(0, index + 1))
|
||||
assert.equal(input.trusted, true)
|
||||
}
|
||||
const sendToInput = result.inputs.map((input: { at: number }, index: number) => input.at - clock.offset - sent[index]!)
|
||||
const inputToFrame = result.inputs.map((input: { at: number; frame: number }) => input.frame - input.at)
|
||||
const row = { interval, run: position === 0 ? 'warmup' : position <= 4 ? 1 : 2,
|
||||
sendToInputMs: stats(sendToInput), inputToFrameMs: stats(inputToFrame),
|
||||
sendToFrameMs: stats(sendToInput.map((value: number, index: number) => value + inputToFrame[index])),
|
||||
updates: result.updates, flushes, longTasks: result.tasks.length,
|
||||
longTaskMs: result.tasks.length ? stats(result.tasks) : null,
|
||||
longTaskTotalMs: result.tasks.reduce((sum: number, value: number) => sum + value, 0),
|
||||
producerDurationMs: producerDuration, firstFlushMs: firstFlush - start,
|
||||
firstCommitMs: result.firstCommit - clock.offset - start,
|
||||
lastCommitMs: result.lastCommit - clock.offset - start,
|
||||
chunkLatenessMs: stats(chunkLateness), inputLatenessMs: stats(inputLateness),
|
||||
clockUncertaintyMs: clock.rtt / 2, exactTextDraftFocus: true }
|
||||
console.log('Streaming input run ' + JSON.stringify(row))
|
||||
if (position > 0) results.push({ ...row, raw: { sendToInput, inputToFrame } })
|
||||
}
|
||||
assert.deepEqual(errors, [])
|
||||
const aggregate = [16, 32, 50, 80].map(interval => {
|
||||
const runs = results.filter(row => row.interval === interval)
|
||||
return { interval, sendToInputMs: stats(runs.flatMap(row => row.raw.sendToInput)),
|
||||
inputToFrameMs: stats(runs.flatMap(row => row.raw.inputToFrame)),
|
||||
sendToFrameMs: stats(runs.flatMap(row => row.raw.sendToInput.map((value, index) => value + row.raw.inputToFrame[index]!))),
|
||||
updates: runs.map(row => row.updates), longTasks: runs.map(row => row.longTasks) }
|
||||
})
|
||||
console.log('Streaming input summary ' + JSON.stringify({ electron: process.versions.electron,
|
||||
chromium: process.versions.chrome, renderer: 'production ChatTimeline + controlled textarea (not App)',
|
||||
bytes: Buffer.byteLength(answer), durationMs: duration, chunks: chunks.length, chunkPeriodMs: chunkPeriod,
|
||||
inputPeriodMs: inputPeriod, measuredOrder: order.slice(1), aggregate }))
|
||||
app.exit(0)
|
||||
}).catch(error => { console.error(error); app.exit(1) })
|
||||
@@ -0,0 +1,3 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><title>Streaming native input benchmark</title></head>
|
||||
<body><div id="root"></div><script type="module" src="./streaming-input-renderer.tsx"></script></body></html>
|
||||
@@ -0,0 +1,7 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
contextBridge.exposeInMainWorld('streamingInputBridge', {
|
||||
onChunk: (callback: (delta: string) => void) => {
|
||||
ipcRenderer.on('streaming-input:chunk', (_event, delta) => callback(delta))
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useLayoutEffect, useMemo, useState } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ChatTimeline, type Message } from '../../src/renderer/src/ChatTimeline'
|
||||
import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
|
||||
import '../../src/renderer/src/fonts'
|
||||
import '../../src/renderer/src/styles.css'
|
||||
|
||||
const epoch = () => performance.timeOrigin + performance.now()
|
||||
const inputs: { at: number; frame: number; value: string; trusted: boolean }[] = []
|
||||
const tasks: number[] = []
|
||||
let collecting = false
|
||||
let committed = ''
|
||||
let committedDraft = ''
|
||||
let updates = 0
|
||||
let received = 0
|
||||
let firstCommit = 0
|
||||
let lastCommit = 0
|
||||
let setContent: (update: string | ((previous: string) => string)) => void
|
||||
let setDraft: (value: string) => void
|
||||
const observer = new PerformanceObserver(list => {
|
||||
if (collecting) tasks.push(...list.getEntries().map(entry => entry.duration))
|
||||
})
|
||||
observer.observe({ type: 'longtask' })
|
||||
const callbacks = {
|
||||
onArticleRef: () => {}, onCopyMessage: async () => true,
|
||||
onDownloadImage: () => {}, onOpenCitationContext: async () => {},
|
||||
onOpenCitationSource: async () => {}, onOpenImage: () => {},
|
||||
onRespondApproval: async () => {}, onRespondQuestion: async () => {},
|
||||
onRetry: () => {}, onRevealEarlier: () => {}
|
||||
}
|
||||
const artifacts = new Map()
|
||||
const initial: Message = { id: 'answer', role: 'assistant', content: '', createdAt: 1, state: 'streaming' }
|
||||
function Bench() {
|
||||
const [content, updateContent] = useState('')
|
||||
const [draft, updateDraft] = useState('')
|
||||
// Preserve ChatTimeline's production memo boundary when only the composer changes.
|
||||
const messages = useMemo(() => [{ ...initial, content }], [content])
|
||||
setContent = updateContent
|
||||
setDraft = updateDraft
|
||||
useLayoutEffect(() => {
|
||||
committed = content
|
||||
if (collecting && content) {
|
||||
updates++
|
||||
lastCommit = epoch()
|
||||
if (!firstCommit) firstCommit = lastCommit
|
||||
}
|
||||
}, [content])
|
||||
useLayoutEffect(() => { committedDraft = draft }, [draft])
|
||||
return <UiLocaleProvider initialPreference="en-US">
|
||||
<main style={{ height: '100vh', display: 'flex', flexDirection: 'column', padding: 16 }}>
|
||||
<section id="answer" style={{ flex: 1, overflow: 'auto' }}>
|
||||
<ChatTimeline {...callbacks} artifactById={artifacts} conversationId="benchmark"
|
||||
hiddenMessageCount={0} isUnusedConversation={false} locale="en-US"
|
||||
messageStartIndex={0} messages={messages} totalMessageCount={1} />
|
||||
</section>
|
||||
<textarea aria-label="Draft" id="draft" value={draft} rows={3}
|
||||
onInput={event => {
|
||||
if (!collecting) return
|
||||
const sample = { at: epoch(), frame: 0, value: event.currentTarget.value,
|
||||
trusted: event.nativeEvent.isTrusted }
|
||||
inputs.push(sample)
|
||||
// Callback execution time includes work queued before this frame; the rAF timestamp does not.
|
||||
requestAnimationFrame(() => { sample.frame = epoch() })
|
||||
}} onChange={event => updateDraft(event.target.value)} />
|
||||
</main>
|
||||
</UiLocaleProvider>
|
||||
}
|
||||
flushSync(() => createRoot(document.getElementById('root')!).render(<Bench />))
|
||||
const bridge = (window as unknown as {
|
||||
streamingInputBridge: { onChunk: (callback: (delta: string) => void) => void }
|
||||
}).streamingInputBridge
|
||||
bridge.onChunk(delta => { received++; setContent(previous => previous + delta) })
|
||||
const nextFrame = () => new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
|
||||
Object.assign(window, { streamingInput: {
|
||||
async reset(content = '') {
|
||||
collecting = false
|
||||
flushSync(() => { setContent(content); setDraft('') })
|
||||
await document.fonts.ready
|
||||
await nextFrame()
|
||||
await nextFrame()
|
||||
inputs.length = 0
|
||||
tasks.length = 0
|
||||
observer.takeRecords()
|
||||
updates = received = firstCommit = lastCommit = 0
|
||||
document.getElementById('draft')!.focus()
|
||||
collecting = true
|
||||
return document.getElementById('answer')!.textContent
|
||||
},
|
||||
clock: epoch,
|
||||
status: () => ({ length: committed.length, inputs: inputs.length, frames: inputs.filter(input => input.frame).length }),
|
||||
async result() {
|
||||
await nextFrame()
|
||||
await nextFrame()
|
||||
tasks.push(...observer.takeRecords().map(entry => entry.duration))
|
||||
collecting = false
|
||||
return { committed, committedDraft, domText: document.getElementById('answer')!.textContent,
|
||||
draft: (document.getElementById('draft') as HTMLTextAreaElement).value,
|
||||
focused: document.activeElement?.id === 'draft', inputs, tasks, updates, received, firstCommit, lastCommit }
|
||||
}
|
||||
} })
|
||||
@@ -0,0 +1,118 @@
|
||||
# Native input during streaming
|
||||
|
||||
Run with `npx vitest run tests/streaming-input.electron.test.ts`.
|
||||
The final validated run took 53.37 seconds on Windows, Electron 43.2.0,
|
||||
Chromium 150.0.7871.129. Repository typecheck and lint passed.
|
||||
|
||||
## Method
|
||||
|
||||
- Vite production build of the real `ChatTimeline` and `MarkdownRenderer`,
|
||||
production React, bundled fonts/styles, and a controlled React textarea.
|
||||
The message array remains stable across draft-only changes.
|
||||
- Real `AgentEventBuffer`, bundled into Electron main. The first chunk is
|
||||
explicitly flushed immediately for every interval, including warmup.
|
||||
- Identical 22,716-byte growing Markdown answer: prose, headings, lists,
|
||||
fenced TypeScript, tables, quotation, inline math, and display math.
|
||||
- 625 equal-throughput chunks scheduled independently in main over 5 seconds
|
||||
(8 ms target spacing, approximately 4.54 KB/s).
|
||||
- 67 native key sequences per run, independently scheduled in main every
|
||||
71 ms. Input events must be trusted, preserve every draft prefix, and end
|
||||
with the exact draft and focus intact. No provider/model calls.
|
||||
- One full warmup at 32 ms. Measured order: `16, 50, 32, 80`, then
|
||||
`80, 32, 50, 16`. Two runs and 134 input samples per interval.
|
||||
- Send-to-input uses the timestamp immediately before native key dispatch
|
||||
and the renderer input handler timestamp. Twelve idle clock probes precede
|
||||
each run; the minimum-RTT midpoint estimates process clock offset.
|
||||
Final measured clock uncertainty was at most 0.062 ms.
|
||||
- Input-to-frame ends at execution of the next requestAnimationFrame callback,
|
||||
not its supplied frame timestamp. This is a pre-paint responsiveness proxy,
|
||||
not physical screen presentation latency.
|
||||
- Counts include committed answer updates and Chromium long tasks (over 50 ms).
|
||||
Final committed Markdown and DOM text must exactly match the source and an
|
||||
independently rendered static reference, respectively.
|
||||
- Renderer timers never drive arrivals or typing. Output includes main timer
|
||||
lateness, producer duration, first flush/commit, and final commit times.
|
||||
Assertions cover correctness, not machine-dependent performance thresholds.
|
||||
|
||||
## Final Results
|
||||
|
||||
All latency cells are **median / p95 / maximum**, in milliseconds. Pooled
|
||||
percentiles are computed from samples, not averages of per-run percentiles.
|
||||
|
||||
| Interval | Send to input | Input to next frame | Send to next frame | Answer updates, runs 1 / 2 | Long tasks |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 16 ms | 0.80 / 12.54 / 18.92 | 0.80 / 13.30 / 27.90 | 3.23 / 15.57 / 28.64 | 164 / 164 | 0 / 0 |
|
||||
| 32 ms | 0.71 / 11.33 / 24.83 | 0.60 / 16.20 / 25.40 | 2.17 / 17.34 / 26.20 | 110 / 109 | 0 / 0 |
|
||||
| 50 ms | 0.72 / 4.35 / 23.69 | 0.50 / 17.80 / 22.30 | 1.28 / 18.85 / 24.29 | 82 / 82 | 0 / 0 |
|
||||
| 80 ms | 0.71 / 10.17 / 20.81 | 0.40 / 7.70 / 14.20 | 1.16 / 13.63 / 21.51 | 55 / 55 | 0 / 0 |
|
||||
|
||||
Per-run p95 values make the variability visible:
|
||||
|
||||
| Interval | Send to input, runs 1 / 2 | Input to frame, runs 1 / 2 | Send to frame, runs 1 / 2 |
|
||||
| --- | --- | --- | --- |
|
||||
| 16 ms | 6.51 / 13.44 | 13.30 / 6.80 | 14.81 / 16.68 |
|
||||
| 32 ms | 11.32 / 11.33 | 16.60 / 11.40 | 17.82 / 16.04 |
|
||||
| 50 ms | 1.11 / 4.35 | 17.80 / 15.40 | 18.51 / 20.18 |
|
||||
| 80 ms | 10.44 / 4.93 | 10.60 / 7.40 | 13.63 / 12.90 |
|
||||
|
||||
All nine runs, including warmup, preserved exact final Markdown, rendered DOM
|
||||
text, draft, each typed prefix, and textarea focus. Every emitted buffer flush
|
||||
was received. Measured first flushes occurred within 0.08 ms of producer start;
|
||||
first commits within 1.58 ms. Producer durations were 5,002.79-5,013.54 ms.
|
||||
|
||||
## Interpretation
|
||||
|
||||
**50 ms is the smallest candidate with a material send-to-input p95 improvement**
|
||||
in this measurement: 65% below 16 ms, with half as many answer updates. It is
|
||||
not an overall latency win: send-to-next-frame p95 is 21% higher than at 16 ms.
|
||||
|
||||
80 ms has the best pooled send-to-next-frame p95 (13.63 ms versus 15.57 ms at
|
||||
16 ms), and the best input-to-frame tail. That approximately 1.94 ms end-to-end
|
||||
p95 saving costs two thirds of the answer updates. Two short runs do not
|
||||
establish a robust benefit sufficient to recommend raising the production
|
||||
default to 80 ms. This benchmark does not justify claiming 32 ms materially
|
||||
improves tail input latency, either. Preserve the production default unless
|
||||
the chosen target is specifically send-to-input latency; for that target,
|
||||
50 ms is the smallest measured candidate to investigate in the complete App.
|
||||
|
||||
## After segmented Markdown rendering
|
||||
|
||||
`MarkdownRenderer` now splits Markdown at safe top-level block boundaries and
|
||||
memoizes each segment, so a streaming update re-parses only the growing tail.
|
||||
Same benchmark, same machine, same order (`16, 50, 32, 80, 80, 32, 50, 16`),
|
||||
two runs and 134 input samples per interval; exact text, draft, every typed
|
||||
prefix and focus were preserved in all nine runs, with zero long tasks.
|
||||
|
||||
| Interval | Send to input p95, before / after | Send to next frame p95, before / after | Answer updates |
|
||||
| --- | --- | --- | --- |
|
||||
| 16 ms | 12.54 / 1.23 | 15.57 / 7.94 | 163 / 164 |
|
||||
| 32 ms | 11.33 / 1.27 | 17.34 / 8.23 | 109 / 110 |
|
||||
| 50 ms | 4.35 / 1.29 | 18.85 / 7.21 | 82 / 82 |
|
||||
| 80 ms | 10.17 / 1.39 | 13.63 / 7.57 | 55 / 55 |
|
||||
|
||||
With segmentation the interval no longer materially changes input latency,
|
||||
so the production 16 ms default is retained: streaming cadence is unchanged.
|
||||
|
||||
`tests/streaming-markdown.electron.test.ts` adds a streaming-delta case: 120
|
||||
characters appended per update to an approximately 80 KB answer, on persistent
|
||||
roots. Whole-document rendering took median 59.2 ms (p95 65.4 ms); segmented
|
||||
rendering took median 2.4 ms (p95 4.2 ms), with DOM equality checked against
|
||||
whole-document rendering throughout.
|
||||
|
||||
## Limits
|
||||
|
||||
This mounts production ChatTimeline, not App. It excludes App event reduction,
|
||||
conversation history, the production composer, and App auto-scroll. The answer
|
||||
pane stays at its initial scroll position. DOM work occurs for the entire
|
||||
answer, but this does not measure continuously painting its moving bottom edge.
|
||||
The static reference checks DOM text equivalence, not pixel equivalence.
|
||||
|
||||
Windows timers coalesced: chunk scheduling p95 lateness was approximately
|
||||
12.7-13.5 ms, and native-input scheduling p95 lateness 12.4-14.1 ms. Therefore
|
||||
configured 16 ms did not produce 62.5 updates/second: it produced approximately
|
||||
32.8. Lateness is reported separately and excluded from send-to-input latency,
|
||||
which starts at actual dispatch. The producer never waits for the renderer.
|
||||
|
||||
This is one machine without CPU throttling and one moderate-throughput workload.
|
||||
There were no long tasks at any interval. A slower machine, larger history,
|
||||
App auto-scroll, or faster generation may change the tradeoff substantially.
|
||||
@@ -86,7 +86,7 @@ async function run() {
|
||||
for (let round = 0; round < 4; round++) {
|
||||
render(current.MarkdownRenderer, '')
|
||||
flushSync(() => originalRoot.render(<UiLocaleProvider initialPreference="en-US">
|
||||
<original.MarkdownRenderer>{''}</original.MarkdownRenderer>
|
||||
<original.UnsegmentedMarkdownRenderer>{''}</original.UnsegmentedMarkdownRenderer>
|
||||
</UiLocaleProvider>))
|
||||
for (const [index, length] of prefixLengths.entries()) {
|
||||
const prefix = longAnswerCorpus.slice(0, length)
|
||||
@@ -98,7 +98,7 @@ async function run() {
|
||||
const start = performance.now()
|
||||
if (variant === 'original') {
|
||||
flushSync(() => originalRoot.render(<UiLocaleProvider initialPreference="en-US">
|
||||
<original.MarkdownRenderer>{prefix}</original.MarkdownRenderer>
|
||||
<original.UnsegmentedMarkdownRenderer>{prefix}</original.UnsegmentedMarkdownRenderer>
|
||||
</UiLocaleProvider>))
|
||||
void originalHost.offsetHeight
|
||||
} else {
|
||||
@@ -119,6 +119,48 @@ async function run() {
|
||||
flushSync(() => originalRoot.unmount())
|
||||
originalHost.remove()
|
||||
}
|
||||
// Streaming shape: small deltas appended to an already long answer, on
|
||||
// persistent roots, as ChatTimeline receives them. Alternate variant order.
|
||||
const deltaSamples = { original: [] as number[], current: [] as number[] }
|
||||
const deltaHost = document.createElement('div')
|
||||
deltaHost.className = host.className
|
||||
deltaHost.style.cssText = host.style.cssText
|
||||
document.body.append(deltaHost)
|
||||
const deltaRoot = createRoot(deltaHost)
|
||||
const deltaStart = Math.floor(longAnswerCorpus.length * 0.8)
|
||||
const deltaSize = 120
|
||||
try {
|
||||
const renderDelta = (variant: 'original' | 'current', prefix: string) => {
|
||||
if (variant === 'original') {
|
||||
flushSync(() => deltaRoot.render(<UiLocaleProvider initialPreference="en-US">
|
||||
<original.UnsegmentedMarkdownRenderer>{prefix}</original.UnsegmentedMarkdownRenderer>
|
||||
</UiLocaleProvider>))
|
||||
void deltaHost.offsetHeight
|
||||
} else {
|
||||
render(current.MarkdownRenderer, prefix)
|
||||
void host.offsetHeight
|
||||
}
|
||||
}
|
||||
renderDelta('original', longAnswerCorpus.slice(0, deltaStart))
|
||||
renderDelta('current', longAnswerCorpus.slice(0, deltaStart))
|
||||
for (let step = 1; step <= 60; step++) {
|
||||
const prefix = longAnswerCorpus.slice(0, deltaStart + step * deltaSize)
|
||||
const order = step % 2 === 0 ? ['original', 'current'] as const : ['current', 'original'] as const
|
||||
for (const variant of order) {
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
|
||||
const start = performance.now()
|
||||
renderDelta(variant, prefix)
|
||||
if (step > 5) deltaSamples[variant].push(performance.now() - start)
|
||||
}
|
||||
if (step % 10 === 0 && snapshot() !== snapshot(deltaHost)) throw new Error(`Delta DOM mismatch: ${step}`)
|
||||
}
|
||||
} finally {
|
||||
flushSync(() => deltaRoot.unmount())
|
||||
deltaHost.remove()
|
||||
}
|
||||
const streamingDelta = { startCharacters: deltaStart, deltaCharacters: deltaSize,
|
||||
samplesPerVariant: deltaSamples.current.length,
|
||||
wholeDocumentMs: stats(deltaSamples.original), segmentedMs: stats(deltaSamples.current) }
|
||||
const longAnswer = { characters: longAnswerCorpus.length, bytes: new TextEncoder().encode(longAnswerCorpus).length,
|
||||
prefixLengths, warmupSweeps: 1, measuredSweeps: 3, samplesPerVariant: longSamples.current.length,
|
||||
originalRenderMs: stats(longSamples.original), currentRenderMs: stats(longSamples.current),
|
||||
@@ -131,7 +173,7 @@ async function run() {
|
||||
if (normalize(prefix) !== originalNormalize(prefix)) throw new Error(`Normalizer mismatch: ${JSON.stringify(prefix)}`)
|
||||
prefixesChecked++
|
||||
for (const renderHtml of [false, true]) {
|
||||
render(original.MarkdownRenderer, prefix, renderHtml)
|
||||
render(original.UnsegmentedMarkdownRenderer, prefix, renderHtml)
|
||||
const expected = snapshot()
|
||||
render(current.MarkdownRenderer, prefix, renderHtml)
|
||||
if (snapshot() !== expected) throw new Error(`DOM mismatch (${renderHtml}): ${JSON.stringify(prefix)}`)
|
||||
@@ -143,7 +185,7 @@ async function run() {
|
||||
if (!host.querySelector('h1') || !host.querySelector('strong') || !host.querySelector('.katex')) {
|
||||
throw new Error('Real Markdown formatting missing')
|
||||
}
|
||||
return { results, longAnswer, prefixesChecked, domChecks }
|
||||
return { results, longAnswer, streamingDelta, prefixesChecked, domChecks }
|
||||
}
|
||||
|
||||
Object.assign(window, { streamingMarkdown: run })
|
||||
|
||||
@@ -65,6 +65,50 @@ app.whenReady().then(async () => {
|
||||
for (const locale of ['zh-CN', 'en-US']) {
|
||||
for (const theme of ['light', 'dark']) {
|
||||
win.setContentSize(1000, 720)
|
||||
if (process.env.GB_LAYOUT_MODE === 'source') {
|
||||
await win.loadURL(`${process.env.GB_LAYOUT_URL}?surface=settings&locale=${locale}&theme=${theme}`)
|
||||
await wait('!!document.querySelector(".model-download-source-control small") && !document.querySelector(".model-download-source-control [role=status]")')
|
||||
await js('document.fonts.ready')
|
||||
for (const [width, height] of [[640, 420], [960, 720], [1280, 800]]) {
|
||||
win.setContentSize(width, height)
|
||||
await wait(`innerWidth === ${width} && innerHeight === ${height}`)
|
||||
await js("document.querySelector('.settings-panel__content').scrollTop=0")
|
||||
await settle()
|
||||
const layout = await js(`(() => {
|
||||
const row=document.querySelector('.model-connections-navigation'),tabs=row.querySelector('.segmented-control'),button=row.querySelector('.model-download-source-control button');
|
||||
const r=row.getBoundingClientRect(),t=tabs.getBoundingClientRect(),b=button.getBoundingClientRect();
|
||||
return {rightAligned:Math.abs(r.right-b.right)<1,sameRow:Math.abs(t.y+t.height/2-b.y-b.height/2)<1,
|
||||
noOverlap:t.right<=b.left,overflow:row.scrollWidth>row.clientWidth+1,hit:button.contains(document.elementFromPoint(b.x+b.width/2,b.y+b.height/2))};
|
||||
})()`)
|
||||
assert.deepEqual(layout, {rightAligned:true,sameRow:true,noOverlap:true,overflow:false,hit:true}, JSON.stringify({locale,theme,width,layout}))
|
||||
const trigger = '.model-download-source-control button'
|
||||
const dialog = '[aria-labelledby="model-download-source-title"]'
|
||||
await click(trigger)
|
||||
await wait(`!!document.querySelector('${dialog}') && document.activeElement.matches('${dialog} .icon-button')`)
|
||||
await click(`${dialog} input[value="hugging-face"]`)
|
||||
assert.equal(await js('window.sourceFixture.writes.length'), 0)
|
||||
await click(`${dialog} .custom-task-dialog__actions .secondary-button`)
|
||||
await wait(`!document.querySelector('${dialog}') && document.activeElement.matches('${trigger}')`)
|
||||
assert.equal(await js('window.sourceFixture.source'), 'modelscope')
|
||||
await click(trigger)
|
||||
await wait(`!!document.querySelector('${dialog} input[value="modelscope"]:checked')`)
|
||||
await click(`${dialog} input[value="hugging-face"]`)
|
||||
await click(`${dialog} .custom-task-dialog__actions .primary-button`)
|
||||
await wait(`!document.querySelector('${dialog}') && document.activeElement.matches('${trigger}') && document.querySelector('.model-download-source-control small').textContent.includes('Hugging Face')`)
|
||||
assert.deepEqual(await js('window.sourceFixture.writes'), [{modelDownloadSource:'hugging-face'}])
|
||||
await click(trigger)
|
||||
await wait(`!!document.querySelector('${dialog} input[value="hugging-face"]:checked')`)
|
||||
await key('Escape')
|
||||
await wait(`!document.querySelector('${dialog}') && document.activeElement.matches('${trigger}')`)
|
||||
await screenshot(`${locale}-${theme}-source-${width}`)
|
||||
observations.push({locale,theme,width,height,layout,dialog:'native selection, cancel without write, confirm, reopen, Escape and focus restoration'})
|
||||
// Reload the fixture to reset its in-memory settings and write log.
|
||||
await win.loadURL(`${process.env.GB_LAYOUT_URL}?surface=settings&locale=${locale}&theme=${theme}`)
|
||||
await wait('!!document.querySelector(".model-download-source-control small") && !document.querySelector(".model-download-source-control [role=status]")')
|
||||
await js('document.fonts.ready')
|
||||
}
|
||||
continue
|
||||
}
|
||||
await win.loadURL(`${process.env.GB_LAYOUT_URL}?locale=${locale}&theme=${theme}`)
|
||||
await wait('!!document.querySelector(".workbar-shell__scroll-button")')
|
||||
await js('document.fonts.ready')
|
||||
@@ -144,12 +188,13 @@ app.whenReady().then(async () => {
|
||||
return {containerWidth:manager.clientWidth,stacked:detail.top>=list.bottom-1,selectWidth:e.clientWidth,
|
||||
textWidth:c.measureText(e.selectedOptions[0].textContent).width,available:e.clientWidth-parseFloat(s.paddingLeft)-parseFloat(s.paddingRight)-20,
|
||||
overflow:['.settings-panel__content','.model-connection-manager','.model-connection-detail'].filter(sel=>{const n=document.querySelector(sel);return n.scrollWidth>n.clientWidth+1}),
|
||||
hit:e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2)),value:e.value};
|
||||
hit:e.contains(document.elementFromPoint(r.x+r.width/2,r.y+r.height/2)),value:e.value,
|
||||
bounds:r.toJSON(),cover:document.elementFromPoint(r.x+r.width/2,r.y+r.height/2)?.outerHTML.slice(0,500)};
|
||||
})()`)
|
||||
assert.equal(result.stacked, result.containerWidth <= 640, JSON.stringify(result))
|
||||
assert.deepEqual(result.overflow, [], JSON.stringify(result))
|
||||
assert(result.textWidth <= result.available, 'Protocol truncated: ' + JSON.stringify(result))
|
||||
assert(result.hit, 'Protocol is occluded')
|
||||
assert(result.hit, 'Protocol is occluded: ' + JSON.stringify({width,height,...result}))
|
||||
await js("document.querySelector('.model-connection-detail select').closest('.field').previousElementSibling.querySelector('input').focus()")
|
||||
await key('Tab')
|
||||
await wait('document.activeElement.matches(".model-connection-detail select")')
|
||||
|
||||
@@ -8,6 +8,7 @@ import { applyAppearanceTheme } from '../../src/renderer/src/theme'
|
||||
import { installBundledUiFonts } from '../../src/renderer/src/fonts'
|
||||
import { defaultRuntimeSettings, type DesktopApi } from '../../src/shared/contracts'
|
||||
import type { WorkbarTabInstance } from '../../src/shared/workbar-contracts'
|
||||
import type { ApplicationSettingsUpdate, ModelDownloadSource } from '../../src/shared/application-settings-contracts'
|
||||
import '../../src/renderer/src/styles.css'
|
||||
|
||||
const query = new URLSearchParams(location.search)
|
||||
@@ -16,7 +17,18 @@ const longName = locale === 'en-US'
|
||||
? 'Organization shared model connection for document processing and analysis'
|
||||
: '\u7ec4\u7ec7\u5185\u90e8\u6587\u6863\u5904\u7406\u4e0e\u5206\u6790\u4e13\u7528\u5171\u4eab\u6a21\u578b\u8fde\u63a5'
|
||||
const profileId = '10000000-0000-4000-8000-000000000001'
|
||||
const sourceFixture = { source: 'modelscope' as ModelDownloadSource, writes: [] as ApplicationSettingsUpdate[] }
|
||||
Object.defineProperty(window, 'sourceFixture', { value: sourceFixture })
|
||||
Object.defineProperty(window, 'goodbuddy', { value: {
|
||||
updates: {
|
||||
getSettings: async () => ({ modelDownloadSource: sourceFixture.source }),
|
||||
onSettingsChanged: () => () => {},
|
||||
updateSettings: async (input: ApplicationSettingsUpdate) => {
|
||||
sourceFixture.writes.push(input)
|
||||
if (input.modelDownloadSource) sourceFixture.source = input.modelDownloadSource
|
||||
return { modelDownloadSource: sourceFixture.source }
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
getRuntime: async () => ({ ...defaultRuntimeSettings, modelProfiles: [{
|
||||
id: profileId, name: longName, baseUrl: 'http://fixture.invalid/v1', modelName: 'fixture-model',
|
||||
|
||||
Reference in New Issue
Block a user