diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 9926024c..69e3f11a 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -83,6 +83,7 @@ interface MarkdownProps { isDark?: boolean size?: "base" | "sm" | "tight" disableHighlight?: boolean + escapeRawHtml?: boolean onRendered?: () => void } @@ -103,11 +104,12 @@ export function Markdown(props: MarkdownProps) { const text = decodeHtmlEntitiesLocally(rawText) const themeKey = Boolean(props.isDark) ? "dark" : "light" const highlightEnabled = !props.disableHighlight + const escapeRawHtml = Boolean(props.escapeRawHtml) const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined const cacheId = resolvePartCacheId(part, text) const version = resolvePartVersion(part, text) - const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}` - return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey } + const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${version}` + return { part, text, themeKey, highlightEnabled, escapeRawHtml, partId, cacheId, version, requestKey } }) const cacheHandle = useGlobalCache({ @@ -116,7 +118,7 @@ export function Markdown(props: MarkdownProps) { scope: "markdown", cacheId: () => { const { cacheId, themeKey, highlightEnabled } = resolved() - return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}` + return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${resolved().escapeRawHtml ? 1 : 0}` }, version: () => resolved().version, }) @@ -126,7 +128,7 @@ export function Markdown(props: MarkdownProps) { text: snapshot.text, html: renderedHtml, theme: snapshot.themeKey, - mode: snapshot.version, + mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`, } setHtml(renderedHtml) cacheHandle.set(cacheEntry) @@ -138,6 +140,7 @@ export function Markdown(props: MarkdownProps) { markdown.setMarkdownTheme(snapshot.themeKey === "dark") const rendered = await markdown.renderMarkdown(snapshot.text, { suppressHighlight: !snapshot.highlightEnabled, + escapeRawHtml: snapshot.escapeRawHtml, }) if (latestRequestKey === snapshot.requestKey) { @@ -148,10 +151,11 @@ export function Markdown(props: MarkdownProps) { createEffect(() => { const snapshot = resolved() latestRequestKey = snapshot.requestKey + const cacheMode = `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}` const cacheMatches = (cache: RenderCache | undefined) => { if (!cache) return false - return cache.theme === snapshot.themeKey && cache.mode === snapshot.version + return cache.theme === snapshot.themeKey && cache.mode === cacheMode } const localCache = snapshot.part.renderCache diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index aa65104f..c0bd07cf 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -146,6 +146,7 @@ export default function MessagePart(props: MessagePartProps) { sessionId={props.sessionId} isDark={isDark()} size={isAssistantMessage() ? "tight" : "base"} + escapeRawHtml={props.messageType === "user"} onRendered={props.onRendered} /> diff --git a/packages/ui/src/lib/markdown.ts b/packages/ui/src/lib/markdown.ts index 6d041a1f..f544405f 100644 --- a/packages/ui/src/lib/markdown.ts +++ b/packages/ui/src/lib/markdown.ts @@ -11,6 +11,7 @@ let highlighterPromise: Promise | null = null let currentTheme: "light" | "dark" = "light" let isInitialized = false let highlightSuppressed = false +let escapeRawHtmlEnabled = false let rendererSetup = false let shikiModulePromise: Promise | null = null let bundledLanguagesCache: typeof import("shiki/bundle/full")["bundledLanguages"] | null = null @@ -285,6 +286,14 @@ function setupRenderer(isDark: boolean) { return `${escapeHtml(decoded)}` } + renderer.html = (html: string) => { + if (!escapeRawHtmlEnabled) { + return html + } + + return escapeHtml(decodeHtmlEntities(html)) + } + marked.use({ renderer }) rendererSetup = true } @@ -308,6 +317,7 @@ export async function renderMarkdown( content: string, options?: { suppressHighlight?: boolean + escapeRawHtml?: boolean }, ): Promise { if (!isInitialized) { @@ -316,6 +326,7 @@ export async function renderMarkdown( } const suppressHighlight = options?.suppressHighlight ?? false + const escapeRawHtml = options?.escapeRawHtml ?? false const decoded = decodeHtmlEntities(content) if (!suppressHighlight) { @@ -324,13 +335,16 @@ export async function renderMarkdown( } const previousSuppressed = highlightSuppressed + const previousEscapeRawHtml = escapeRawHtmlEnabled highlightSuppressed = suppressHighlight + escapeRawHtmlEnabled = escapeRawHtml try { // Proceed to parse immediately - highlighting will be available on next render return marked.parse(decoded) as Promise } finally { highlightSuppressed = previousSuppressed + escapeRawHtmlEnabled = previousEscapeRawHtml } }