Skip to content

Commit cb023ce

Browse files
authored
fix(ui): make streamed markdown feel more continuous (anomalyco#19404)
1 parent 53232f2 commit cb023ce

1 file changed

Lines changed: 63 additions & 25 deletions

File tree

packages/ui/src/components/message-part.tsx

Lines changed: 63 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -156,37 +156,75 @@ export type PartComponent = Component<MessagePartProps>
156156

157157
export const PART_MAPPING: Record<string, PartComponent | undefined> = {}
158158

159-
const TEXT_RENDER_THROTTLE_MS = 100
159+
const TEXT_RENDER_PACE_MS = 24
160+
const TEXT_RENDER_SNAP = /[\s.,!?;:)\]]/
161+
162+
function step(size: number) {
163+
if (size <= 12) return 2
164+
if (size <= 48) return 4
165+
if (size <= 96) return 8
166+
return Math.min(24, Math.ceil(size / 8))
167+
}
168+
169+
function next(text: string, start: number) {
170+
const end = Math.min(text.length, start + step(text.length - start))
171+
const max = Math.min(text.length, end + 8)
172+
for (let i = end; i < max; i++) {
173+
if (TEXT_RENDER_SNAP.test(text[i] ?? "")) return i + 1
174+
}
175+
return end
176+
}
160177

161-
function createThrottledValue(getValue: () => string) {
178+
function createPacedValue(getValue: () => string, live?: () => boolean) {
162179
const [value, setValue] = createSignal(getValue())
180+
let shown = getValue()
163181
let timeout: ReturnType<typeof setTimeout> | undefined
164-
let last = 0
182+
183+
const clear = () => {
184+
if (!timeout) return
185+
clearTimeout(timeout)
186+
timeout = undefined
187+
}
188+
189+
const sync = (text: string) => {
190+
shown = text
191+
setValue(text)
192+
}
193+
194+
const run = () => {
195+
timeout = undefined
196+
const text = getValue()
197+
if (!live?.()) {
198+
sync(text)
199+
return
200+
}
201+
if (!text.startsWith(shown) || text.length <= shown.length) {
202+
sync(text)
203+
return
204+
}
205+
const end = next(text, shown.length)
206+
sync(text.slice(0, end))
207+
if (end < text.length) timeout = setTimeout(run, TEXT_RENDER_PACE_MS)
208+
}
165209

166210
createEffect(() => {
167-
const next = getValue()
168-
const now = Date.now()
169-
170-
const remaining = TEXT_RENDER_THROTTLE_MS - (now - last)
171-
if (remaining <= 0) {
172-
if (timeout) {
173-
clearTimeout(timeout)
174-
timeout = undefined
175-
}
176-
last = now
177-
setValue(next)
211+
const text = getValue()
212+
if (!live?.()) {
213+
clear()
214+
sync(text)
215+
return
216+
}
217+
if (!text.startsWith(shown) || text.length < shown.length) {
218+
clear()
219+
sync(text)
178220
return
179221
}
180-
if (timeout) clearTimeout(timeout)
181-
timeout = setTimeout(() => {
182-
last = Date.now()
183-
setValue(next)
184-
timeout = undefined
185-
}, remaining)
222+
if (text.length === shown.length || timeout) return
223+
timeout = setTimeout(run, TEXT_RENDER_PACE_MS)
186224
})
187225

188226
onCleanup(() => {
189-
if (timeout) clearTimeout(timeout)
227+
clear()
190228
})
191229

192230
return value
@@ -1332,11 +1370,11 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
13321370
return items.filter((x) => !!x).join(" \u00B7 ")
13331371
})
13341372

1335-
const displayText = () => (part().text ?? "").trim()
1336-
const throttledText = createThrottledValue(displayText)
13371373
const streaming = createMemo(
13381374
() => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
13391375
)
1376+
const displayText = () => (part().text ?? "").trim()
1377+
const throttledText = createPacedValue(displayText, streaming)
13401378
const isLastTextPart = createMemo(() => {
13411379
const last = (data.store.part?.[props.message.id] ?? [])
13421380
.filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
@@ -1395,11 +1433,11 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
13951433

13961434
PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
13971435
const part = () => props.part as ReasoningPart
1398-
const text = () => part().text.trim()
1399-
const throttledText = createThrottledValue(text)
14001436
const streaming = createMemo(
14011437
() => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
14021438
)
1439+
const text = () => part().text.trim()
1440+
const throttledText = createPacedValue(text, streaming)
14031441

14041442
return (
14051443
<Show when={throttledText()}>

0 commit comments

Comments
 (0)