mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-28 05:11:24 +08:00
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:
parent
182ab6092c
commit
b660e9d524
103
src/renderer/src/hooks/useSmoothStream.ts
Normal file
103
src/renderer/src/hooks/useSmoothStream.ts
Normal 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 }
|
||||
}
|
||||
@ -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[] = []
|
||||
|
||||
Loading…
Reference in New Issue
Block a user