diff --git a/frontend/src/components/ai-elements/streamdown.tsx b/frontend/src/components/ai-elements/streamdown.tsx index cec2521e3..e5c9730c2 100644 --- a/frontend/src/components/ai-elements/streamdown.tsx +++ b/frontend/src/components/ai-elements/streamdown.tsx @@ -3,6 +3,7 @@ import { Component, type ComponentProps, type ReactNode } from "react"; import { Streamdown } from "streamdown"; +import { stripLeakedSystemTags } from "@/core/streamdown/preprocess"; import { installClipboardFallback } from "@/core/clipboard"; export type ClipboardSafeStreamdownProps = ComponentProps; @@ -57,9 +58,15 @@ export function ClipboardSafeStreamdown({ children, ...props }: ClipboardSafeStreamdownProps) { + // Strip leaked system-internal tags (, , etc.) + // that would cause React to log "unrecognized tag" console errors when + // the markdown renderer passes them through as raw HTML. + const sanitizedChildren = + typeof children === "string" ? stripLeakedSystemTags(children) : children; + return ( - - {children} + + {sanitizedChildren} ); } diff --git a/frontend/src/core/streamdown/preprocess.ts b/frontend/src/core/streamdown/preprocess.ts index 7aaefc57f..9584c1e19 100644 --- a/frontend/src/core/streamdown/preprocess.ts +++ b/frontend/src/core/streamdown/preprocess.ts @@ -1,3 +1,5 @@ +import { INTERNAL_MARKER_TAGS } from "@/core/messages/utils"; + import { normalizeMermaidMarkdown } from "./mermaid"; const MERMAID_BLOCK_HINT_RE = /mermaid/i; @@ -317,6 +319,67 @@ export function normalizeStreamdownMathMarkdown(markdown: string): string { return compactDisplayMathBlocks(normalizeLatexMathDelimiters(markdown)); } +// Regex matching any opening, closing, or self-closing internal marker tag. +// e.g. , , , +const _INTERNAL_TAG_RE = new RegExp( + `]*)?/?>`, + "g", +); + +// Regex matching the start/end of a fenced code block (3+ backticks or tildes). +// Captures the marker string so we can compare character and length. +const FENCE_MARKER_RE = /^ {0,3}(`{3,}|~{3,})/; + +/** + * Strip leaked system-internal HTML tags from markdown content. + * + * Backend-injected markers like ```` can occasionally + * reach the UI renderer (e.g. when a ``hide_from_ui`` reminder leaks through + * the filter). React's DOM renderer logs "unrecognized tag" console errors + * for unknown HTML elements. This function strips the tag markers while + * preserving the inner content — unlike {@link stripInternalMarkers} in + * ``utils.ts``, which removes the entire block. + * + * Code-aware: tags inside fenced code blocks (````` `````) and indented code + * blocks (4-space indent) are left untouched, so user-written meta-discussions + * about the memory system are not silently stripped. + * + * Fence tracking is marker-aware (tracking the opening character and run + * length) so that a tilde-fenced block containing a shorter backtick run, or a + * 4-backtick block containing a 3-backtick run, does not prematurely close the + * fence. + */ +export function stripLeakedSystemTags(markdown: string): string { + const lines = markdown.split("\n"); + let fenceMarker: string | null = null; + + return lines + .map((line) => { + const fenceMatch = FENCE_MARKER_RE.exec(line); + if (fenceMatch) { + const marker = fenceMatch[1]!; + if (fenceMarker === null) { + // Opening a fenced code block + fenceMarker = marker; + } else if ( + marker.startsWith(fenceMarker.charAt(0)) && + marker.length >= fenceMarker.length + ) { + // Closing fence: same character and at least as long as opener + fenceMarker = null; + } + // Otherwise: different fence type or shorter run inside a fence + // (e.g. ``` inside ~~~~, or `` inside `````) — stay inside. + return line; + } + if (fenceMarker !== null || INDENTED_CODE_RE.test(line)) { + return line; + } + return line.replace(_INTERNAL_TAG_RE, ""); + }) + .join("\n"); +} + export function preprocessStreamdownMarkdown(markdown: string): string { if (!MERMAID_BLOCK_HINT_RE.test(markdown) || !markdown.includes("-.->")) { return markdown; diff --git a/frontend/tests/unit/core/streamdown/preprocess.test.ts b/frontend/tests/unit/core/streamdown/preprocess.test.ts index 579120bbe..2996625e4 100644 --- a/frontend/tests/unit/core/streamdown/preprocess.test.ts +++ b/frontend/tests/unit/core/streamdown/preprocess.test.ts @@ -7,6 +7,7 @@ import { compactDisplayMathBlocks, normalizeStreamdownMathMarkdown, preprocessStreamdownMarkdown, + stripLeakedSystemTags, } from "@/core/streamdown/preprocess"; test("capBlockquoteNesting returns normal content unchanged", () => { @@ -247,3 +248,200 @@ test("normalizeStreamdownMathMarkdown requires matching backtick run to close co const expected = "Use ``\\(literal\\)` and still code`` then $x$"; expect(normalizeStreamdownMathMarkdown(input)).toBe(expected); }); + +// --------------------------------------------------------------------------- +// stripLeakedSystemTags +// --------------------------------------------------------------------------- + +test("stripLeakedSystemTags strips tags preserving content", () => { + expect(stripLeakedSystemTags("hello")).toBe("hello"); +}); + +test("stripLeakedSystemTags strips all internal marker tags", () => { + expect( + stripLeakedSystemTags( + "reminder 2024", + ), + ).toBe("reminder 2024"); +}); + +test("stripLeakedSystemTags strips self-closing tags", () => { + expect(stripLeakedSystemTags("textmore")).toBe("textmore"); +}); + +test("stripLeakedSystemTags strips tags with attributes", () => { + expect(stripLeakedSystemTags('text')).toBe("text"); +}); + +test("stripLeakedSystemTags handles multiple occurrences", () => { + expect( + stripLeakedSystemTags( + "a b c", + ), + ).toBe("a b c"); +}); + +test("stripLeakedSystemTags leaves fenced code content untouched", () => { + const input = [ + "outside", + "```text", + "inside code", + "```", + "after", + ].join("\n"); + const expected = [ + "outside", + "```text", + "inside code", + "```", + "after", + ].join("\n"); + expect(stripLeakedSystemTags(input)).toBe(expected); +}); + +test("stripLeakedSystemTags leaves indented code content untouched", () => { + const input = [ + "outside", + " indented code", + ].join("\n"); + const expected = ["outside", " indented code"].join("\n"); + expect(stripLeakedSystemTags(input)).toBe(expected); +}); + +test("stripLeakedSystemTags passes plain text unchanged", () => { + expect(stripLeakedSystemTags("plain text")).toBe("plain text"); +}); + +test("stripLeakedSystemTags returns empty string unchanged", () => { + expect(stripLeakedSystemTags("")).toBe(""); +}); + +test("stripLeakedSystemTags handles no tags present", () => { + const input = "normal text with **bold** and `code`"; + expect(stripLeakedSystemTags(input)).toBe(input); +}); + +test("stripLeakedSystemTags strips tag", () => { + expect( + stripLeakedSystemTags("file.pdf"), + ).toBe("file.pdf"); +}); + +test("stripLeakedSystemTags strips tag", () => { + expect( + stripLeakedSystemTags( + "skill", + ), + ).toBe("skill"); +}); + +test("stripLeakedSystemTags handles mixed tags on same line", () => { + expect( + stripLeakedSystemTags( + "ab", + ), + ).toBe("ab"); +}); + +test("stripLeakedSystemTags handles multiple fences correctly", () => { + const input = [ + "a", + "```", + "inside 1", + "```", + "b", + "```", + "inside 2", + "```", + ].join("\n"); + const expected = [ + "a", + "```", + "inside 1", + "```", + "b", + "```", + "inside 2", + "```", + ].join("\n"); + expect(stripLeakedSystemTags(input)).toBe(expected); +}); + +test("stripLeakedSystemTags preserves tags inside tilde fence with inner backtick fence", () => { + const input = [ + "outside", + "~~~~", + "```", + "inside tilde", + "```", + "~~~~", + "after", + ].join("\n"); + const expected = [ + "outside", + "~~~~", + "```", + "inside tilde", + "```", + "~~~~", + "after", + ].join("\n"); + expect(stripLeakedSystemTags(input)).toBe(expected); +}); + +test("stripLeakedSystemTags preserves tags inside 4-backtick fence with inner 3-backtick fence", () => { + const input = [ + "outside", + "````", + "```", + "inside 4-backtick", + "```", + "````", + "after", + ].join("\n"); + const expected = [ + "outside", + "````", + "```", + "inside 4-backtick", + "```", + "````", + "after", + ].join("\n"); + expect(stripLeakedSystemTags(input)).toBe(expected); +}); + +test("stripLeakedSystemTags handles backtick fence inside tilde fence with shorter tilde closing", () => { + // A 4-tilde fence containing a 3-backtick sub-fence; the closing tilde run + // is shorter (3 vs 4) so it should NOT close the fence. + const input = [ + "outside", + "~~~~", + "```", + "inside", + "```", + "~~~", + ].join("\n"); + const expected = [ + "outside", + "~~~~", + "```", + "inside", + "```", + "~~~", + ].join("\n"); + expect(stripLeakedSystemTags(input)).toBe(expected); +}); + +test("stripLeakedSystemTags strips tags after real closing fence", () => { + const input = [ + "~~~~", + "inside", + "~~~~", + "after", + ].join("\n"); + const expected = ["~~~~", "inside", "~~~~", "after"].join( + "\n", + ); + expect(stripLeakedSystemTags(input)).toBe(expected); +});