feat: implement useSmoothStream hook for dynamic text rendering

- Added a new custom hook `useSmoothStream` to manage smooth text streaming with adjustable delays.
- Integrated the `useSmoothStream` hook into the `Markdown` component to enhance content display during streaming.
- Improved state management for displayed content and stream completion status in the `Markdown` component.
This commit is contained in:
MyPrototypeWhat 2025-07-01 17:21:57 +08:00
parent 182ab6092c
commit b660e9d524
2 changed files with 149 additions and 6 deletions

View File

@ -0,0 +1,103 @@
import { useCallback, useEffect, useRef, useState } from 'react'
interface UseSmoothStreamOptions {
onUpdate: (text: string) => void
streamDone: boolean
// 我们不再需要固定的interval但可以保留一个最小延迟以保证动画感
minDelay?: number
initialText?: string
}
export const useSmoothStream = ({ onUpdate, streamDone, minDelay = 10, initialText = '' }: UseSmoothStreamOptions) => {
const [chunkQueue, setChunkQueue] = useState<string[]>([])
const animationFrameRef = useRef<number | null>(null)
const displayedTextRef = useRef<string>(initialText)
const lastUpdateTimeRef = useRef<number>(0)
const addChunk = useCallback((chunk: string) => {
// 将文本块拆分为单个字符,或者更智能的单位
setChunkQueue((prev) => [...prev, ...chunk.split('')])
}, [])
const reset = useCallback(
(newText = '') => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
setChunkQueue([])
displayedTextRef.current = newText
onUpdate(newText)
},
[onUpdate]
)
const renderLoop = useCallback(
(currentTime: number) => {
// 1. 如果队列为空,等待下一帧
if (chunkQueue.length === 0) {
// 如果流还没结束但队列空了,就等待下一帧
if (!streamDone) {
animationFrameRef.current = requestAnimationFrame(renderLoop)
}
return
}
// 2. 时间控制,确保最小延迟
if (currentTime - lastUpdateTimeRef.current < minDelay) {
animationFrameRef.current = requestAnimationFrame(renderLoop)
return
}
lastUpdateTimeRef.current = currentTime
setChunkQueue((prevQueue) => {
// 3. 动态计算本次渲染的字符数
// 如果队列积压严重,就一次性渲染更多字符来"追赶"
const charsToRenderCount = Math.max(1, Math.floor(prevQueue.length / 5)) // 每次至少渲染1个最多渲染队列的1/5
const charsToRender = prevQueue.slice(0, charsToRenderCount)
displayedTextRef.current += charsToRender.join('')
// 4. 立即更新UI
onUpdate(displayedTextRef.current)
// 返回新的队列
return prevQueue.slice(charsToRenderCount)
})
// 5. 请求下一帧动画
animationFrameRef.current = requestAnimationFrame(renderLoop)
},
[chunkQueue, streamDone, onUpdate, minDelay]
)
useEffect(() => {
// 启动渲染循环
animationFrameRef.current = requestAnimationFrame(renderLoop)
// 组件卸载时清理
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
}
}, [renderLoop]) // 依赖 renderLoop
// 当外部流结束,且队列即将变空时,进行最后一次"瞬移"渲染
useEffect(() => {
if (streamDone && chunkQueue.length > 0) {
const remainingText = chunkQueue.join('')
const finalText = displayedTextRef.current + remainingText
// 取消正在进行的动画循环
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
// 直接更新到最终状态
onUpdate(finalText)
setChunkQueue([]) // 清空队列
}
}, [streamDone, chunkQueue, onUpdate])
return { addChunk, reset }
}

View File

@ -5,13 +5,14 @@ import 'katex/dist/contrib/mhchem'
import ImageViewer from '@renderer/components/ImageViewer'
import MarkdownShadowDOMRenderer from '@renderer/components/MarkdownShadowDOMRenderer'
import { useSettings } from '@renderer/hooks/useSettings'
import { useSmoothStream } from '@renderer/hooks/useSmoothStream'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import type { MainTextMessageBlock, ThinkingMessageBlock, TranslationMessageBlock } from '@renderer/types/newMessage'
import { parseJSON } from '@renderer/utils'
import { removeSvgEmptyLines } from '@renderer/utils/formats'
import { findCitationInChildren, getCodeBlockId, processLatexBrackets } from '@renderer/utils/markdown'
import { isEmpty } from 'lodash'
import { type FC, memo, useCallback, useMemo } from 'react'
import { type FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown'
import rehypeKatex from 'rehype-katex'
@ -40,6 +41,45 @@ const Markdown: FC<Props> = ({ block }) => {
const { t } = useTranslation()
const { mathEngine } = useSettings()
// 修复1: 根据你的提示,更精确地判断消息是否已完成
const isTrulyDone = 'status' in block && block.status === 'success'
const [displayedContent, setDisplayedContent] = useState(block.content)
const [isStreamDone, setIsStreamDone] = useState(isTrulyDone)
const prevContentRef = useRef(block.content)
const prevBlockIdRef = useRef(block.id)
const { addChunk, reset } = useSmoothStream({
onUpdate: setDisplayedContent,
streamDone: isStreamDone,
initialText: block.content
})
useEffect(() => {
const newContent = block.content || ''
const oldContent = prevContentRef.current || ''
const isDifferentBlock = block.id !== prevBlockIdRef.current
const isContentReset = oldContent && newContent && !newContent.startsWith(oldContent)
if (isDifferentBlock || isContentReset) {
reset(newContent)
} else {
const delta = newContent.substring(oldContent.length)
if (delta) {
addChunk(delta)
}
}
prevContentRef.current = newContent
prevBlockIdRef.current = block.id
// 更新 stream 状态
const isStreaming = 'status' in block && block.status === 'streaming'
setIsStreamDone(!isStreaming)
}, [block.content, block.id, block.status, addChunk, reset])
const remarkPlugins = useMemo(() => {
const plugins = [remarkGfm, remarkCjkFriendly, remarkDisableConstructs(['codeIndented'])]
if (mathEngine !== 'none') {
@ -49,11 +89,11 @@ const Markdown: FC<Props> = ({ block }) => {
}, [mathEngine])
const messageContent = useMemo(() => {
const empty = isEmpty(block.content)
const paused = block.status === 'paused'
const content = empty && paused ? t('message.chat.completion.paused') : block.content
return removeSvgEmptyLines(processLatexBrackets(content))
}, [block, t])
if ('status' in block && block.status === 'paused' && isEmpty(block.content)) {
return t('message.chat.completion.paused')
}
return removeSvgEmptyLines(processLatexBrackets(displayedContent))
}, [block, t, displayedContent])
const rehypePlugins = useMemo(() => {
const plugins: any[] = []