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 rehypeRaw from 'rehype-raw'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
......@@ -8,6 +8,8 @@ import { Image } from '@heroui/react'
interface MarkdownDetailProps {
children: ReactNode
/** Disable mermaid/pie rendering (fallback to normal code block). */
disableMermaid?: boolean
}
const sanitizeSchema = {
......@@ -63,13 +65,18 @@ function MermaidBlock({ chart }: { chart: string }) {
const renderId = useMemo(() => `mermaid-${reactId.replace(/:/g, '-')}`, [reactId])
const [svg, setSvg] = useState<string>('')
const [hidden, setHidden] = useState<boolean>(false)
const svgContainerRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
let cancelled = false
const code = normalizeMermaid(chart)
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('')
setHidden(true)
}
return () => {
cancelled = true
}
......@@ -116,17 +123,24 @@ function MermaidBlock({ chart }: { chart: string }) {
}
}, [chart, renderId])
useEffect(() => {
const el = svgContainerRef.current
if (!el)
return
el.innerHTML = svg
}, [svg])
if (hidden || !svg)
return null
return (
<div className="my-[8px] overflow-x-auto">
<div dangerouslySetInnerHTML={{ __html: svg }} />
<div ref={svgContainerRef} />
</div>
)
}
export const MarkdownDetail: React.FC<MarkdownDetailProps> = memo(({ children }) => {
export const MarkdownDetail: React.FC<MarkdownDetailProps> = memo(({ children, disableMermaid }) => {
return (
<ReactMarkdown
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema as any]]}
......@@ -154,7 +168,7 @@ export const MarkdownDetail: React.FC<MarkdownDetailProps> = memo(({ children })
if (isValidElement(first)) {
const className = String((first.props as any)?.className || '')
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)
return <MermaidBlock chart={chart} />
}
......
......@@ -22,6 +22,16 @@ import ScrollBtoIcon from '@/assets/svg/scrollBto.svg?react'
import { setIsAsking } from '@/store/chatSlice'
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 = () => {
const { id } = useParams<{ id: string }>()
const location = useLocation()
......@@ -200,6 +210,7 @@ export const Chat: React.FC = () => {
if (isEmptyQuestion && (lastIndex < 0 || newItems[lastIndex].role !== 'ai' || shouldAlwaysAppendAi)) {
newItems.push({
role: 'ai',
groupId: makeLocalGroupId('ai'),
answerList: [{ answer: '' }],
} as ChatRecord)
}
......@@ -253,6 +264,7 @@ export const Chat: React.FC = () => {
if (isEmptyQuestion && (lastIndex < 0 || newItems[lastIndex].role !== 'ai')) {
newItems.push({
role: 'ai',
groupId: makeLocalGroupId('ai'),
answerList: [{ answer: '' }],
} as ChatRecord)
}
......@@ -316,10 +328,12 @@ export const Chat: React.FC = () => {
...prevItems,
{
role: 'user',
groupId: makeLocalGroupId('user'),
question,
} as ChatRecord,
{
role: 'ai',
groupId: makeLocalGroupId('ai'),
answerList: [{ answer: '' }],
} as ChatRecord,
])
......@@ -1032,7 +1046,7 @@ export const Chat: React.FC = () => {
const recordId = record.answerList?.[0]?.recordId || record.groupId
const uniqueKey = recordId
? `${record.role}-${recordId}`
: `${record.role}-${record.question || record.answerList?.[0]?.answer || ''}-${index}`
: `${record.role}-${index}`
// 如果历史记录中 firstQaFlag 为 true,隐藏 UI 但保留组件渲染(确保自动提交逻辑能执行)
const shouldHideHistoryItem = record.role === 'ai' && record.answerList?.[0]?.firstQaFlag === true
......@@ -1048,7 +1062,6 @@ export const Chat: React.FC = () => {
&& hasQuestion
&& (hasAnswer || hasCardList)
&& firstAnswer?.firstQaFlag !== true
console.log('9yyyyyyyyyy', record.answerList?.[0]?.answer, uniqueKey)
return (
<div
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 { ChatAnswerAttachment } from './ChatAnswerAttchment'
import { ChatAnswerOperate } from './ChatAnswerOperate'
......@@ -43,11 +43,10 @@ function CheckIcon({ ...props }) {
export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer, onTyping, onComplate, answer, isStopTyping, onSubmitQuestion, hideOperate, onWorkflowSubmit, isWorkflowSubmitting, isAsking = false }) => {
const formatAnswer = formatMarkdown(answer.answer || '')
const [displayedText, setDisplayedText] = useState('')
const [currentIndex, setCurrentIndex] = useState(0)
const [isTyping, setIsTyping] = useState(false)
const [hideOperateByCard, setHideOperateByCard] = useState(false)
const [isImageAnswer, setIsImageAnswer] = useState(false)
const [hasProcessedCardList, setHasProcessedCardList] = useState(false) // 添加标记,避免重复处理cardList
const completedOnceRef = useRef(false)
function extractImageSources(htmlString: string): string[] {
const imgRegex = /<img[^>]+src="([^">]+)"/gi
......@@ -119,44 +118,34 @@ export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer
}
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
}
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()
setIsTyping(true)
}
if (currentIndex < formatAnswer.length) {
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) {
setIsTyping(false)
if (formatAnswer.includes('<img')) {
handleImageAnswer()
}
else {
setIsTyping(false)
setDisplayedText(formatAnswer)
onComplate()
}
// 流式输出结束时检查 cardList 中的 URL
handleCardListUrls()
}
}
}, [answer, currentIndex])
}, [answer.endAnswerFlag, answer.answer])
const handleStopTyping = async () => {
const res = await fetchTerminateQuestion(answer)
......@@ -197,9 +186,17 @@ export const ChatAnswerParser: React.FC<ChatAnswerParserProps> = ({ isLastAnswer
{!!displayedText.length && (
<div style={{ background: '#F7FAFD' }} className={answer.cardList?.length ? 'mb-[20px]' : ''}>
{answer.endAnswerFlag
? (
<MarkdownDetail>
{displayedText}
</MarkdownDetail>
)
: (
<pre className="whitespace-pre-wrap break-words">
{displayedText}
</pre>
)}
</div>
)}
{!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