fix(ui): make streamed markdown feel more continuous (#19404)
parent
af2ccc94eb
commit
a93374c48f
|
|
@ -156,37 +156,75 @@ export type PartComponent = Component<MessagePartProps>
|
|||
|
||||
export const PART_MAPPING: Record<string, PartComponent | undefined> = {}
|
||||
|
||||
const TEXT_RENDER_THROTTLE_MS = 100
|
||||
const TEXT_RENDER_PACE_MS = 24
|
||||
const TEXT_RENDER_SNAP = /[\s.,!?;:)\]]/
|
||||
|
||||
function createThrottledValue(getValue: () => string) {
|
||||
function step(size: number) {
|
||||
if (size <= 12) return 2
|
||||
if (size <= 48) return 4
|
||||
if (size <= 96) return 8
|
||||
return Math.min(24, Math.ceil(size / 8))
|
||||
}
|
||||
|
||||
function next(text: string, start: number) {
|
||||
const end = Math.min(text.length, start + step(text.length - start))
|
||||
const max = Math.min(text.length, end + 8)
|
||||
for (let i = end; i < max; i++) {
|
||||
if (TEXT_RENDER_SNAP.test(text[i] ?? "")) return i + 1
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
function createPacedValue(getValue: () => string, live?: () => boolean) {
|
||||
const [value, setValue] = createSignal(getValue())
|
||||
let shown = getValue()
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
let last = 0
|
||||
|
||||
createEffect(() => {
|
||||
const next = getValue()
|
||||
const now = Date.now()
|
||||
const clear = () => {
|
||||
if (!timeout) return
|
||||
clearTimeout(timeout)
|
||||
timeout = undefined
|
||||
}
|
||||
|
||||
const remaining = TEXT_RENDER_THROTTLE_MS - (now - last)
|
||||
if (remaining <= 0) {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
timeout = undefined
|
||||
}
|
||||
last = now
|
||||
setValue(next)
|
||||
const sync = (text: string) => {
|
||||
shown = text
|
||||
setValue(text)
|
||||
}
|
||||
|
||||
const run = () => {
|
||||
timeout = undefined
|
||||
const text = getValue()
|
||||
if (!live?.()) {
|
||||
sync(text)
|
||||
return
|
||||
}
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
last = Date.now()
|
||||
setValue(next)
|
||||
timeout = undefined
|
||||
}, remaining)
|
||||
if (!text.startsWith(shown) || text.length <= shown.length) {
|
||||
sync(text)
|
||||
return
|
||||
}
|
||||
const end = next(text, shown.length)
|
||||
sync(text.slice(0, end))
|
||||
if (end < text.length) timeout = setTimeout(run, TEXT_RENDER_PACE_MS)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const text = getValue()
|
||||
if (!live?.()) {
|
||||
clear()
|
||||
sync(text)
|
||||
return
|
||||
}
|
||||
if (!text.startsWith(shown) || text.length < shown.length) {
|
||||
clear()
|
||||
sync(text)
|
||||
return
|
||||
}
|
||||
if (text.length === shown.length || timeout) return
|
||||
timeout = setTimeout(run, TEXT_RENDER_PACE_MS)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
clear()
|
||||
})
|
||||
|
||||
return value
|
||||
|
|
@ -1332,11 +1370,11 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
|
|||
return items.filter((x) => !!x).join(" \u00B7 ")
|
||||
})
|
||||
|
||||
const displayText = () => (part().text ?? "").trim()
|
||||
const throttledText = createThrottledValue(displayText)
|
||||
const streaming = createMemo(
|
||||
() => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
|
||||
)
|
||||
const displayText = () => (part().text ?? "").trim()
|
||||
const throttledText = createPacedValue(displayText, streaming)
|
||||
const isLastTextPart = createMemo(() => {
|
||||
const last = (data.store.part?.[props.message.id] ?? [])
|
||||
.filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim())
|
||||
|
|
@ -1395,11 +1433,11 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
|
|||
|
||||
PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
|
||||
const part = () => props.part as ReasoningPart
|
||||
const text = () => part().text.trim()
|
||||
const throttledText = createThrottledValue(text)
|
||||
const streaming = createMemo(
|
||||
() => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
|
||||
)
|
||||
const text = () => part().text.trim()
|
||||
const throttledText = createPacedValue(text, streaming)
|
||||
|
||||
return (
|
||||
<Show when={throttledText()}>
|
||||
|
|
|
|||
Loading…
Reference in New Issue