diff --git a/src/renderer/src/hooks/useSmoothStream.ts b/src/renderer/src/hooks/useSmoothStream.ts new file mode 100644 index 0000000000..386d85cd1b --- /dev/null +++ b/src/renderer/src/hooks/useSmoothStream.ts @@ -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([]) + const animationFrameRef = useRef(null) + const displayedTextRef = useRef(initialText) + const lastUpdateTimeRef = useRef(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 } +} diff --git a/src/renderer/src/pages/home/Markdown/Markdown.tsx b/src/renderer/src/pages/home/Markdown/Markdown.tsx index 454550c5c8..5a78fbf114 100644 --- a/src/renderer/src/pages/home/Markdown/Markdown.tsx +++ b/src/renderer/src/pages/home/Markdown/Markdown.tsx @@ -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 = ({ 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 = ({ 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[] = []