@@ -21,10 +21,9 @@ import { FC, useEffect, useState, useRef } from 'react';
2121import { Button } from 'react-bootstrap' ;
2222import { useTranslation } from 'react-i18next' ;
2323
24- import { marked } from 'marked' ;
2524import copy from 'copy-to-clipboard' ;
2625
27- import { voteConversation } from '@/services' ;
26+ import { markdownToHtml , voteConversation } from '@/services' ;
2827import { Icon , htmlRender } from '@/components' ;
2928
3029interface IProps {
@@ -40,6 +39,17 @@ interface IProps {
4039 } ;
4140}
4241
42+ const escapeHtml = ( text : string ) =>
43+ text
44+ . replace ( / & / g, '&' )
45+ . replace ( / < / g, '<' )
46+ . replace ( / > / g, '>' )
47+ . replace ( / " / g, '"' )
48+ . replace ( / ' / g, ''' ) ;
49+
50+ const renderPlainTextAsHtml = ( text : string ) =>
51+ escapeHtml ( text ) . replace ( / \r ? \n / g, '<br />' ) ;
52+
4353const BubbleAi : FC < IProps > = ( {
4454 canType = false ,
4555 isLast,
@@ -55,6 +65,7 @@ const BubbleAi: FC<IProps> = ({
5565 const [ isHelpful , setIsHelpful ] = useState ( false ) ;
5666 const [ isUnhelpful , setIsUnhelpful ] = useState ( false ) ;
5767 const [ canShowAction , setCanShowAction ] = useState ( false ) ;
68+ const [ safeHtml , setSafeHtml ] = useState ( '' ) ;
5869 const typewriterRef = useRef < {
5970 timer : NodeJS . Timeout | null ;
6071 index : number ;
@@ -64,6 +75,8 @@ const BubbleAi: FC<IProps> = ({
6475 index : 0 ,
6576 isTyping : false ,
6677 } ) ;
78+ const renderTimerRef = useRef < NodeJS . Timeout | null > ( null ) ;
79+ const renderTaskRef = useRef ( 0 ) ;
6780 const fmtContainer = useRef < HTMLDivElement > ( null ) ;
6881 // add ref for ScrollIntoView
6982 const containerRef = useRef < HTMLDivElement > ( null ) ;
@@ -194,13 +207,56 @@ const BubbleAi: FC<IProps> = ({
194207 } ;
195208 } , [ content , isCompleted ] ) ;
196209
210+ useEffect ( ( ) => {
211+ if ( renderTimerRef . current ) {
212+ clearTimeout ( renderTimerRef . current ) ;
213+ renderTimerRef . current = null ;
214+ }
215+ renderTaskRef . current += 1 ;
216+ const currentRenderTask = renderTaskRef . current ;
217+
218+ if ( ! displayContent ) {
219+ setSafeHtml ( '' ) ;
220+ return undefined ;
221+ }
222+
223+ // During streaming, render escaped plain text to avoid executing unsanitized HTML.
224+ if ( ! isCompleted ) {
225+ setSafeHtml ( renderPlainTextAsHtml ( displayContent ) ) ;
226+ return undefined ;
227+ }
228+
229+ renderTimerRef . current = setTimeout ( ( ) => {
230+ markdownToHtml ( displayContent )
231+ . then ( ( resp ) => {
232+ if ( renderTaskRef . current !== currentRenderTask ) {
233+ return ;
234+ }
235+ setSafeHtml ( resp || renderPlainTextAsHtml ( displayContent ) ) ;
236+ } )
237+ . catch ( ( ) => {
238+ if ( renderTaskRef . current !== currentRenderTask ) {
239+ return ;
240+ }
241+ setSafeHtml ( renderPlainTextAsHtml ( displayContent ) ) ;
242+ } ) ;
243+ } , 0 ) ;
244+
245+ return ( ) => {
246+ if ( renderTimerRef . current ) {
247+ clearTimeout ( renderTimerRef . current ) ;
248+ renderTimerRef . current = null ;
249+ }
250+ } ;
251+ } , [ displayContent , isCompleted ] ) ;
252+
197253 useEffect ( ( ) => {
198254 setIsHelpful ( actionData . helpful > 0 ) ;
199255 setIsUnhelpful ( actionData . unhelpful > 0 ) ;
200256 } , [ actionData ] ) ;
201257
202258 useEffect ( ( ) => {
203- if ( fmtContainer . current && isCompleted ) {
259+ if ( fmtContainer . current && isCompleted && safeHtml ) {
204260 htmlRender ( fmtContainer . current , {
205261 copySuccessText : t ( 'copied' , { keyPrefix : 'messages' } ) ,
206262 copyText : t ( 'copy' , { keyPrefix : 'messages' } ) ,
@@ -211,7 +267,7 @@ const BubbleAi: FC<IProps> = ({
211267 } ) ;
212268 setCanShowAction ( true ) ;
213269 }
214- } , [ isCompleted , fmtContainer . current ] ) ;
270+ } , [ isCompleted , safeHtml , t ] ) ;
215271
216272 return (
217273 < div
@@ -223,7 +279,7 @@ const BubbleAi: FC<IProps> = ({
223279 className = "fmt text-break text-wrap"
224280 ref = { fmtContainer }
225281 style = { { transition : 'all 0.2s ease' } }
226- dangerouslySetInnerHTML = { { __html : marked . parse ( displayContent ) } }
282+ dangerouslySetInnerHTML = { { __html : safeHtml } }
227283 />
228284
229285 { canShowAction && (
0 commit comments