Skip to content

Commit 0db88d6

Browse files
committed
fix(chat): implement HTML rendering for display content
1 parent 92994b4 commit 0db88d6

File tree

3 files changed

+65
-9
lines changed

3 files changed

+65
-9
lines changed

ui/src/components/BubbleAi/index.tsx

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,9 @@ import { FC, useEffect, useState, useRef } from 'react';
2121
import { Button } from 'react-bootstrap';
2222
import { useTranslation } from 'react-i18next';
2323

24-
import { marked } from 'marked';
2524
import copy from 'copy-to-clipboard';
2625

27-
import { voteConversation } from '@/services';
26+
import { markdownToHtml, voteConversation } from '@/services';
2827
import { Icon, htmlRender } from '@/components';
2928

3029
interface IProps {
@@ -40,6 +39,17 @@ interface IProps {
4039
};
4140
}
4241

42+
const escapeHtml = (text: string) =>
43+
text
44+
.replace(/&/g, '&')
45+
.replace(/</g, '&lt;')
46+
.replace(/>/g, '&gt;')
47+
.replace(/"/g, '&quot;')
48+
.replace(/'/g, '&#39;');
49+
50+
const renderPlainTextAsHtml = (text: string) =>
51+
escapeHtml(text).replace(/\r?\n/g, '<br />');
52+
4353
const 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 && (

ui/src/pages/AiAssistant/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ const Index = () => {
328328
canType={isGenerate && isLastMessage}
329329
chatId={item.chat_completion_id}
330330
isLast={isLastMessage}
331-
isCompleted={!isGenerate}
331+
isCompleted={!isGenerate || !isLastMessage}
332332
content={item.content}
333333
actionData={{
334334
helpful: item.helpful,

ui/src/pages/Search/components/AiCard/index.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,10 +149,10 @@ const Index = () => {
149149
<BubbleUser content={item.content} />
150150
) : (
151151
<BubbleAi
152-
canType
152+
canType={isGenerate && isLastMessage}
153153
chatId={item.chat_completion_id}
154-
isLast
155-
isCompleted={!isGenerate}
154+
isLast={isLastMessage}
155+
isCompleted={!isGenerate || !isLastMessage}
156156
content={item.content}
157157
actionData={{
158158
helpful: item.helpful,

0 commit comments

Comments
 (0)