diff --git a/apps/client/src/services/llm_chat.ts b/apps/client/src/services/llm_chat.ts index 47bfa00ebf..67fb728328 100644 --- a/apps/client/src/services/llm_chat.ts +++ b/apps/client/src/services/llm_chat.ts @@ -1,4 +1,5 @@ -import type { LlmMessage, LlmCitation, LlmChatConfig, LlmUsage, LlmModelInfo } from "@triliumnext/commons"; +import type { LlmChatConfig, LlmCitation, LlmMessage, LlmModelInfo,LlmUsage } from "@triliumnext/commons"; + import server from "./server.js"; /** @@ -98,7 +99,7 @@ export async function streamChatCompletion( break; } } catch { - // Ignore JSON parse errors for partial data + console.error("Failed to parse SSE data line:", line, e); } } } diff --git a/apps/client/src/widgets/react/RawHtml.tsx b/apps/client/src/widgets/react/RawHtml.tsx index 4b93f783dd..502fc56f5d 100644 --- a/apps/client/src/widgets/react/RawHtml.tsx +++ b/apps/client/src/widgets/react/RawHtml.tsx @@ -1,3 +1,4 @@ +import DOMPurify from "dompurify"; import type { CSSProperties, HTMLProps, RefObject } from "preact/compat"; type HTMLElementLike = string | HTMLElement | JQuery; @@ -14,16 +15,16 @@ export default function RawHtml({containerRef, ...props}: RawHtmlProps & { conta } export function RawHtmlBlock({containerRef, ...props}: RawHtmlProps & { containerRef?: RefObject}) { - return
+ return
; } function getProps({ className, html, style, onClick }: RawHtmlProps) { return { - className: className, + className, dangerouslySetInnerHTML: getHtml(html ?? ""), style, onClick - } + }; } export function getHtml(html: string | HTMLElement | JQuery) { @@ -39,3 +40,19 @@ export function getHtml(html: string | HTMLElement | JQuery) { __html: html as string }; } + +/** + * Renders HTML content sanitized via DOMPurify to prevent XSS. + * Use this instead of {@link RawHtml} when the HTML originates from + * untrusted sources (e.g. LLM responses, user-generated markdown). + */ +export function SanitizedHtml({ className, html, style }: { className?: string; html: string; style?: CSSProperties }) { + return ( +
+ ); +} diff --git a/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx b/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx index c7560bf7a6..76539667a5 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx +++ b/apps/client/src/widgets/type_widgets/llm_chat/ChatMessage.tsx @@ -1,11 +1,11 @@ import "./LlmChat.css"; -import DOMPurify from "dompurify"; import { marked } from "marked"; import { useMemo } from "preact/hooks"; import { t } from "../../../services/i18n.js"; import utils from "../../../services/utils.js"; +import { SanitizedHtml } from "../../react/RawHtml.js"; import { type ContentBlock, getMessageText, type StoredMessage, type ToolCall } from "./llm_chat_types.js"; function shortenNumber(n: number): string { @@ -20,10 +20,9 @@ marked.setOptions({ gfm: true // GitHub Flavored Markdown }); -/** Parse markdown and sanitize the resulting HTML to prevent XSS. */ +/** Parse markdown to HTML. Sanitization is handled by SanitizedHtml. */ function renderMarkdown(markdown: string): string { - const raw = marked.parse(markdown) as string; - return DOMPurify.sanitize(raw); + return marked.parse(markdown) as string; } interface Props { @@ -75,10 +74,7 @@ function renderContentBlocks(blocks: ContentBlock[], isStreaming?: boolean) { const html = renderMarkdown(block.content); return (
-
+ {isStreaming && idx === blocks.length - 1 && }
); @@ -143,10 +139,7 @@ export default function ChatMessage({ message, isStreaming }: Props) { renderContentBlocks(message.content as ContentBlock[], isStreaming) ) : ( <> -
+ {isStreaming && } )