diff --git a/src/renderer/src/hooks/useSmoothStream.ts b/src/renderer/src/hooks/useSmoothStream.ts new file mode 100644 index 0000000000..91a7b1aac2 --- /dev/null +++ b/src/renderer/src/hooks/useSmoothStream.ts @@ -0,0 +1,107 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +interface UseSmoothStreamOptions { + onUpdate: (text: string) => void + streamDone: boolean + // 我们不再需要固定的interval,但可以保留一个最小延迟以保证动画感 + minDelay?: number + initialText?: string +} +// 如果不行还可以使用Array.from(chunk)分割 +// const reg = /[\u4E00-\u9FFF]|[a-zA-Z0-9]+|\s+|[^\s\w]/g + +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) => { + // 英文按照word拆分, 中文按照字拆分,使用正则表达式 + // const words = chunk.match(/[\w\d]+/g) + const chars = Array.from(chunk) + setChunkQueue((prev) => [...prev, ...(chars || [])]) + }, []) + + 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 5b9d79439a..d3a1b74f04 100644 --- a/src/renderer/src/pages/home/Markdown/Markdown.tsx +++ b/src/renderer/src/pages/home/Markdown/Markdown.tsx @@ -5,13 +5,15 @@ 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, useState } from 'react' +import { useRef } from 'react' import { useTranslation } from 'react-i18next' import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown' import rehypeKatex from 'rehype-katex' @@ -35,12 +37,56 @@ const DISALLOWED_ELEMENTS = ['iframe'] interface Props { // message: Message & { content: string } block: MainTextMessageBlock | TranslationMessageBlock | ThinkingMessageBlock + // 可选的后处理函数,用于在流式渲染过程中处理文本(如引用标签转换) + postProcess?: (text: string) => string } -const Markdown: FC = ({ block }) => { +const Markdown: FC = ({ block, postProcess }) => { const { t } = useTranslation() const { mathEngine } = useSettings() + 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: (rawText) => { + // 如果提供了后处理函数就调用,否则直接使用原始文本 + const finalText = postProcess ? postProcess(rawText) : rawText + setDisplayedContent(finalText) + }, + 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, { singleTilde: false }] as Pluggable, @@ -54,11 +100,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, displayedContent, t]) const rehypePlugins = useMemo(() => { const plugins: any[] = [] diff --git a/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx index 0f0d52907d..d16e07a2b6 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/MainTextBlock.tsx @@ -6,7 +6,7 @@ import { type Model } from '@renderer/types' import type { MainTextMessageBlock, Message } from '@renderer/types/newMessage' import { determineCitationSource, withCitationTags } from '@renderer/utils/citation' import { Flex } from 'antd' -import React, { useMemo } from 'react' +import React, { useCallback } from 'react' import { useSelector } from 'react-redux' import styled from 'styled-components' @@ -25,16 +25,20 @@ const MainTextBlock: React.FC = ({ block, citationBlockId, role, mentions const rawCitations = useSelector((state: RootState) => selectFormattedCitationsByBlockId(state, citationBlockId)) - const processedContent = useMemo(() => { - if (!block.citationReferences?.length || !citationBlockId || rawCitations.length === 0) { - return block.content - } + // 创建引用处理函数,传递给 Markdown 组件在流式渲染中使用 + const processContent = useCallback( + (rawText: string) => { + if (!block.citationReferences?.length || !citationBlockId || rawCitations.length === 0) { + return rawText + } - // 确定最适合的 source - const sourceType = determineCitationSource(block.citationReferences) + // 确定最适合的 source + const sourceType = determineCitationSource(block.citationReferences) - return withCitationTags(block.content, rawCitations, sourceType) - }, [block.content, block.citationReferences, citationBlockId, rawCitations]) + return withCitationTags(rawText, rawCitations, sourceType) + }, + [block.citationReferences, citationBlockId, rawCitations] + ) return ( <> @@ -51,7 +55,7 @@ const MainTextBlock: React.FC = ({ block, citationBlockId, role, mentions {block.content}

) : ( - + )} ) diff --git a/src/renderer/src/pages/home/Messages/Blocks/__tests__/MainTextBlock.test.tsx b/src/renderer/src/pages/home/Messages/Blocks/__tests__/MainTextBlock.test.tsx index 4683aae9bb..fd5c27dc3d 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/__tests__/MainTextBlock.test.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/__tests__/MainTextBlock.test.tsx @@ -78,11 +78,14 @@ vi.mock('@renderer/services/ModelService', () => ({ // Mock Markdown component vi.mock('@renderer/pages/home/Markdown/Markdown', () => ({ __esModule: true, - default: ({ block }: any) => ( -
- Markdown: {block.content} -
- ) + default: ({ block, postProcess }: any) => { + const content = postProcess ? postProcess(block.content) : block.content + return ( +
+ Markdown: {content} +
+ ) + } })) describe('MainTextBlock', () => {