Commit 06679be6 by Liu

fix:优化流式输出渲染与列表 key 稳定性

- 流式阶段改为纯文本展示,结束后一次性渲染 Markdown,减少重渲染与闪烁
- 为消息生成本地 groupId,避免 key 依赖内容导致不稳定
- MarkdownDetail 支持禁用 mermaid,并在图表内容为空时避免闪烁

Made-with: Cursor
parent aba0789f
import { type ReactNode, isValidElement, memo, useEffect, useId, useMemo, useState } from 'react' import { type ReactNode, isValidElement, memo, useEffect, useId, useMemo, useRef, useState } from 'react'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import rehypeRaw from 'rehype-raw' import rehypeRaw from 'rehype-raw'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize' import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
...@@ -8,6 +8,8 @@ import { Image } from '@heroui/react' ...@@ -8,6 +8,8 @@ import { Image } from '@heroui/react'
interface MarkdownDetailProps { interface MarkdownDetailProps {
children: ReactNode children: ReactNode
/** Disable mermaid/pie rendering (fallback to normal code block). */
disableMermaid?: boolean
} }
const sanitizeSchema = { const sanitizeSchema = {
...@@ -63,13 +65,18 @@ function MermaidBlock({ chart }: { chart: string }) { ...@@ -63,13 +65,18 @@ function MermaidBlock({ chart }: { chart: string }) {
const renderId = useMemo(() => `mermaid-${reactId.replace(/:/g, '-')}`, [reactId]) const renderId = useMemo(() => `mermaid-${reactId.replace(/:/g, '-')}`, [reactId])
const [svg, setSvg] = useState<string>('') const [svg, setSvg] = useState<string>('')
const [hidden, setHidden] = useState<boolean>(false) const [hidden, setHidden] = useState<boolean>(false)
const svgContainerRef = useRef<HTMLDivElement | null>(null)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
const code = normalizeMermaid(chart) const code = normalizeMermaid(chart)
if (!code) { if (!code) {
// During streaming, chart text may be temporarily empty.
// Keep the previous SVG (if any) to avoid flicker, and only hide when nothing to show.
if (!svg) {
setSvg('') setSvg('')
setHidden(true) setHidden(true)
}
return () => { return () => {
cancelled = true cancelled = true
} }
...@@ -116,17 +123,24 @@ function MermaidBlock({ chart }: { chart: string }) { ...@@ -116,17 +123,24 @@ function MermaidBlock({ chart }: { chart: string }) {
} }
}, [chart, renderId]) }, [chart, renderId])
useEffect(() => {
const el = svgContainerRef.current
if (!el)
return
el.innerHTML = svg
}, [svg])
if (hidden || !svg) if (hidden || !svg)
return null return null
return ( return (
<div className="my-[8px] overflow-x-auto"> <div className="my-[8px] overflow-x-auto">
<div dangerouslySetInnerHTML={{ __html: svg }} /> <div ref={svgContainerRef} />
</div> </div>
) )
} }
export const MarkdownDetail: React.FC<MarkdownDetailProps> = memo(({ children }) => { export const MarkdownDetail: React.FC<MarkdownDetailProps> = memo(({ children, disableMermaid }) => {
return ( return (
<ReactMarkdown <ReactMarkdown
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema as any]]} rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema as any]]}
...@@ -154,7 +168,7 @@ export const MarkdownDetail: React.FC<MarkdownDetailProps> = memo(({ children }) ...@@ -154,7 +168,7 @@ export const MarkdownDetail: React.FC<MarkdownDetailProps> = memo(({ children })
if (isValidElement(first)) { if (isValidElement(first)) {
const className = String((first.props as any)?.className || '') const className = String((first.props as any)?.className || '')
const lang = className.match(/language-(\S+)/)?.[1]?.toLowerCase() const lang = className.match(/language-(\S+)/)?.[1]?.toLowerCase()
if (lang?.startsWith('mermaid') || lang === 'pie') { if (!disableMermaid && (lang?.startsWith('mermaid') || lang === 'pie')) {
const chart = toText((first.props as any)?.children) const chart = toText((first.props as any)?.children)
return <MermaidBlock chart={chart} /> return <MermaidBlock chart={chart} />
} }
......
...@@ -22,6 +22,16 @@ import ScrollBtoIcon from '@/assets/svg/scrollBto.svg?react' ...@@ -22,6 +22,16 @@ import ScrollBtoIcon from '@/assets/svg/scrollBto.svg?react'
import { setIsAsking } from '@/store/chatSlice' import { setIsAsking } from '@/store/chatSlice'
import SdreamLoading from '@/components/SdreamLoading' import SdreamLoading from '@/components/SdreamLoading'
function makeLocalGroupId(prefix: string) {
try {
// modern browsers
return `${prefix}-${crypto.randomUUID()}`
}
catch {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`
}
}
export const Chat: React.FC = () => { export const Chat: React.FC = () => {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const location = useLocation() const location = useLocation()
...@@ -200,6 +210,7 @@ export const Chat: React.FC = () => { ...@@ -200,6 +210,7 @@ export const Chat: React.FC = () => {
if (isEmptyQuestion && (lastIndex < 0 || newItems[lastIndex].role !== 'ai' || shouldAlwaysAppendAi)) { if (isEmptyQuestion && (lastIndex < 0 || newItems[lastIndex].role !== 'ai' || shouldAlwaysAppendAi)) {
newItems.push({ newItems.push({
role: 'ai', role: 'ai',
groupId: makeLocalGroupId('ai'),
answerList: [{ answer: '' }], answerList: [{ answer: '' }],
} as ChatRecord) } as ChatRecord)
} }
...@@ -253,6 +264,7 @@ export const Chat: React.FC = () => { ...@@ -253,6 +264,7 @@ export const Chat: React.FC = () => {
if (isEmptyQuestion && (lastIndex < 0 || newItems[lastIndex].role !== 'ai')) { if (isEmptyQuestion && (lastIndex < 0 || newItems[lastIndex].role !== 'ai')) {
newItems.push({ newItems.push({
role: 'ai', role: 'ai',
groupId: makeLocalGroupId('ai'),
answerList: [{ answer: '' }], answerList: [{ answer: '' }],
} as ChatRecord) } as ChatRecord)
} }
...@@ -316,10 +328,12 @@ export const Chat: React.FC = () => { ...@@ -316,10 +328,12 @@ export const Chat: React.FC = () => {
...prevItems, ...prevItems,
{ {
role: 'user', role: 'user',
groupId: makeLocalGroupId('user'),
question, question,
} as ChatRecord, } as ChatRecord,
{ {
role: 'ai', role: 'ai',
groupId: makeLocalGroupId('ai'),
answerList: [{ answer: '' }], answerList: [{ answer: '' }],
} as ChatRecord, } as ChatRecord,
]) ])
...@@ -1032,7 +1046,7 @@ export const Chat: React.FC = () => { ...@@ -1032,7 +1046,7 @@ export const Chat: React.FC = () => {
const recordId = record.answerList?.[0]?.recordId || record.groupId const recordId = record.answerList?.[0]?.recordId || record.groupId
const uniqueKey = recordId const uniqueKey = recordId
? `${record.role}-${recordId}` ? `${record.role}-${recordId}`
: `${record.role}-${record.question || record.answerList?.[0]?.answer || ''}-${index}` : `${record.role}-${index}`
// 如果历史记录中 firstQaFlag 为 true,隐藏 UI 但保留组件渲染(确保自动提交逻辑能执行) // 如果历史记录中 firstQaFlag 为 true,隐藏 UI 但保留组件渲染(确保自动提交逻辑能执行)
const shouldHideHistoryItem = record.role === 'ai' && record.answerList?.[0]?.firstQaFlag === true const shouldHideHistoryItem = record.role === 'ai' && record.answerList?.[0]?.firstQaFlag === true
...@@ -1048,7 +1062,6 @@ export const Chat: React.FC = () => { ...@@ -1048,7 +1062,6 @@ export const Chat: React.FC = () => {
&& hasQuestion && hasQuestion
&& (hasAnswer || hasCardList) && (hasAnswer || hasCardList)
&& firstAnswer?.firstQaFlag !== true && firstAnswer?.firstQaFlag !== true
console.log('9yyyyyyyyyy', record.answerList?.[0]?.answer, uniqueKey)
return ( return (
<div <div
className="w-full chatItem mx-auto" className="w-full chatItem mx-auto"
......
import React, { useEffect, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { Chip } from '@heroui/react' import { Chip } from '@heroui/react'
import { ChatAnswerAttachment } from './ChatAnswerAttchment' import { ChatAnswerAttachment } from './ChatAnswerAttchment'
import { ChatAnswerOperate } from './ChatAnswerOperate' import { ChatAnswerOperate } from './ChatAnswerOperate'
...@@ -43,11 +43,10 @@ function CheckIcon({ ...props }) { ...@@ -43,11 +43,10 @@ function CheckIcon({ ...props }) {
export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer, onTyping, onComplate, answer, isStopTyping, onSubmitQuestion, hideOperate, onWorkflowSubmit, isWorkflowSubmitting, isAsking = false }) => { export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer, onTyping, onComplate, answer, isStopTyping, onSubmitQuestion, hideOperate, onWorkflowSubmit, isWorkflowSubmitting, isAsking = false }) => {
const formatAnswer = formatMarkdown(answer.answer || '') const formatAnswer = formatMarkdown(answer.answer || '')
const [displayedText, setDisplayedText] = useState('') const [displayedText, setDisplayedText] = useState('')
const [currentIndex, setCurrentIndex] = useState(0)
const [isTyping, setIsTyping] = useState(false) const [isTyping, setIsTyping] = useState(false)
const [hideOperateByCard, setHideOperateByCard] = useState(false) const [hideOperateByCard, setHideOperateByCard] = useState(false)
const [isImageAnswer, setIsImageAnswer] = useState(false)
const [hasProcessedCardList, setHasProcessedCardList] = useState(false) // 添加标记,避免重复处理cardList const [hasProcessedCardList, setHasProcessedCardList] = useState(false) // 添加标记,避免重复处理cardList
const completedOnceRef = useRef(false)
function extractImageSources(htmlString: string): string[] { function extractImageSources(htmlString: string): string[] {
const imgRegex = /<img[^>]+src="([^">]+)"/gi const imgRegex = /<img[^>]+src="([^">]+)"/gi
...@@ -119,44 +118,34 @@ export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer ...@@ -119,44 +118,34 @@ export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer
} }
useEffect(() => { useEffect(() => {
if (isStopTyping) { // Streaming stage: render plain text only (no markdown parsing / no typewriter),
// to avoid heavy re-rendering and page flicker.
if (!answer.endAnswerFlag) {
setDisplayedText(answer.answer || '')
setIsTyping(false)
completedOnceRef.current = false
return return
} }
if (!isTyping) { // Stream ended: render full markdown once (no typewriter) to avoid heavy re-renders.
if (completedOnceRef.current)
return
completedOnceRef.current = true
// Keep existing contract: notify typing start/end callbacks once.
onTyping() onTyping()
setIsTyping(true) setIsTyping(false)
}
if (currentIndex < formatAnswer.length) { if (formatAnswer.includes('<img')) {
const nextChar = formatAnswer[currentIndex]
if (nextChar === '<' || isImageAnswer) {
setIsImageAnswer(true)
const timer = setTimeout(() => {
setCurrentIndex(prevIndex => prevIndex + 1)
}, 10) // 调整此值以改变打字速度
return () => clearTimeout(timer)
}
else {
const timer = setTimeout(() => {
setDisplayedText(formatAnswer.slice(0, currentIndex + 1))
setCurrentIndex(prevIndex => prevIndex + 1)
}, 10) // 调整此值以改变打字速度
return () => clearTimeout(timer)
}
}
else {
if (answer.endAnswerFlag) {
if (isImageAnswer) {
handleImageAnswer() handleImageAnswer()
} }
else { else {
setIsTyping(false) setDisplayedText(formatAnswer)
onComplate() onComplate()
} }
// 流式输出结束时检查 cardList 中的 URL // 流式输出结束时检查 cardList 中的 URL
handleCardListUrls() handleCardListUrls()
} }, [answer.endAnswerFlag, answer.answer])
}
}, [answer, currentIndex])
const handleStopTyping = async () => { const handleStopTyping = async () => {
const res = await fetchTerminateQuestion(answer) const res = await fetchTerminateQuestion(answer)
...@@ -197,9 +186,17 @@ export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer ...@@ -197,9 +186,17 @@ export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer
{!!displayedText.length && ( {!!displayedText.length && (
<div style={{ background: '#F7FAFD' }} className={answer.cardList?.length ? 'mb-[20px]' : ''}> <div style={{ background: '#F7FAFD' }} className={answer.cardList?.length ? 'mb-[20px]' : ''}>
{answer.endAnswerFlag
? (
<MarkdownDetail> <MarkdownDetail>
{displayedText} {displayedText}
</MarkdownDetail> </MarkdownDetail>
)
: (
<pre className="whitespace-pre-wrap break-words">
{displayedText}
</pre>
)}
</div> </div>
)} )}
{!isTyping {!isTyping
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment