import { cn } from "@/src/utils/tailwind"; import { type FC, type ReactNode, type ReactElement, memo, isValidElement, Children, createElement, } from "react"; import ReactMarkdown, { type Options } from "react-markdown"; import Link from "next/link"; import remarkGfm from "remark-gfm"; import { CodeBlock } from "@/src/components/ui/Codeblock"; import { useTheme } from "next-themes"; import { ImageOff, Info } from "lucide-react"; import { usePostHogClientCapture } from "@/src/features/posthog-analytics/usePostHogClientCapture"; import { useMarkdownContext } from "@/src/features/theming/useMarkdownContext"; import { MentionBadge } from "@/src/features/comments/components/MentionBadge"; import { type ExtraProps as ReactMarkdownExtraProps } from "react-markdown"; import { OpenAIUrlImageUrl, MediaReferenceStringSchema, type OpenAIContentParts, type OpenAIContentSchema, type OpenAIOutputAudioType, isOpenAITextContentPart, isOpenAIImageContentPart, } from "@langfuse/shared"; import { type z } from "zod/v4"; import { ResizableImage } from "@/src/components/ui/resizable-image"; import { LangfuseMediaView } from "@/src/components/ui/LangfuseMediaView"; import { type MediaReturnType } from "@/src/features/media/validation"; import { JSONView } from "@/src/components/ui/CodeJsonViewer"; import { MarkdownJsonViewHeader } from "@/src/components/ui/MarkdownJsonView"; import { copyTextToClipboard } from "@/src/utils/clipboard"; import DOMPurify from "dompurify"; import { MENTION_USER_PREFIX } from "@/src/features/comments/lib/mentionParser"; import { useCollapsibleSystemPrompt } from "@/src/hooks/useCollapsibleSystemPrompt"; import { Button } from "@/src/components/ui/button"; type ReactMarkdownNode = ReactMarkdownExtraProps["node"]; type ReactMarkdownNodeChildren = Exclude< ReactMarkdownNode, undefined >["children"]; // ReactMarkdown does not render raw HTML by default for security reasons, to prevent XSS (Cross-Site Scripting) attacks. // html is rendered as plain text by default. const MemoizedReactMarkdown: FC = memo(ReactMarkdown); const getSafeUrl = (href: string | undefined | null): string | null => { if (!href || typeof href !== "string") return null; // DOMPurify's default sanitization is quite permissive but safe // It blocks javascript:, data: with scripts, vbscript:, etc. // But allows http:, https:, ftp:, mailto:, tel:, and many others try { const sanitized = DOMPurify.sanitize(href, { // ALLOWED_TAGS: An array of HTML tags that are explicitly permitted in the output. // Setting this to an empty array means that no HTML tags are allowed. // Any HTML tag found within the 'href' string would be stripped out. ALLOWED_TAGS: [], // ALLOWED_ATTR: An array of HTML attributes that are explicitly permitted on allowed tags. // Setting this to an empty array means that no HTML attributes are allowed. // Similar to ALLOWED_TAGS, this ensures that if any attributes are somehow // embedded within the URL string (e.g., malformed or attempting injection), // they will be removed by DOMPurify. We only expect a pure URL string. ALLOWED_ATTR: [], }); return sanitized || null; } catch { return null; } }; const isTextElement = ( child: ReactNode, ): child is ReactElement<{ className?: string }> => isValidElement(child) && typeof child.type === "string" && ["p", "h1", "h2", "h3", "h4", "h5", "h6"].includes(child.type); const isChecklist = (children: ReactNode) => Array.isArray(children) && children.some( (child) => isValidElement(child) && (child.props as any)?.className === "task-list-item", ); const transformListItemChildren = (children: ReactNode) => Children.map(children, (child) => isTextElement(child) ? createElement("span", { ...child.props, className: cn(child.props.className, "mb-1"), }) : child, ); const isImageNode = (node?: ReactMarkdownNode): boolean => !!node && Array.isArray(node.children) && node.children.some( (child: ReactMarkdownNodeChildren[number]) => "tagName" in child && child.tagName === "img", ); function MarkdownRenderer({ markdown, theme, className, customCodeHeaderClassName, }: { markdown: string; theme?: string; className?: string; customCodeHeaderClassName?: string; }) { // Try to parse markdown content try { // If parsing succeeds, render with ReactMarkdown return (
{children}; } return (

{children}

); }, a({ children, href }) { // Handle mention links if (href?.startsWith(MENTION_USER_PREFIX)) { const userId = href.replace(MENTION_USER_PREFIX, ""); const displayName = String(children); return ( ); } // Handle regular links const safeHref = getSafeUrl(href); if (safeHref) { return ( {children} ); } return ( {children} ); }, ul({ children }) { if (isChecklist(children)) return
    {children}
; return
    {children}
; }, ol({ children }) { return
    {children}
; }, li({ children }) { return (
  • {transformListItemChildren(children)}
  • ); }, pre({ children }) { return
    {children}
    ; }, h1({ children }) { return

    {children}

    ; }, h2({ children }) { return

    {children}

    ; }, h3({ children }) { return

    {children}

    ; }, h4({ children }) { return

    {children}

    ; }, h5({ children }) { return
    {children}
    ; }, h6({ children }) { return
    {children}
    ; }, code({ children, className }) { const languageMatch = /language-(\w+)/.exec(className || ""); const language = languageMatch ? languageMatch[1] : ""; const codeContent = String(children).replace(/\n$/, ""); const isMultiLine = codeContent.includes("\n"); return language || isMultiLine ? ( // code block ) : ( // inline code {codeContent} ); }, blockquote({ children }) { return (
    {children}
    ); }, img({ src, alt }) { return src && typeof src === "string" ? ( ) : null; }, hr() { return
    ; }, table({ children }) { return (
    {children}
    ); }, thead({ children }) { return {children}; }, tbody({ children }) { return ( {children} ); }, tr({ children }) { return {children}; }, th({ children }) { return ( {children} ); }, td({ children }) { return ( {children} ); }, }} > {markdown}
    ); } catch { // fallback to JSON view if markdown parsing fails return ( <>
    Markdown parsing failed. Displaying raw JSON.
    ); } } const parseOpenAIContentParts = ( content: z.infer | null, ): string => { return (content ?? []) .map((item) => { if (item.type === "text") { return item.text; } else if (item.type === "image_url") { return `![image](${item.image_url.url})`; } else if (item.type === "input_audio") { return `![audio](${item.input_audio.data})`; } }) .join("\n"); }; export function MarkdownView({ markdown, title, titleIcon, customCodeHeaderClassName, audio, media, className, controlButtons, afterHeader, }: { markdown: string | z.infer; title?: string; titleIcon?: React.ReactNode; customCodeHeaderClassName?: string; audio?: OpenAIOutputAudioType; media?: MediaReturnType[]; className?: string; controlButtons?: React.ReactNode; /** Content to render between header and main content (e.g., thinking blocks) */ afterHeader?: React.ReactNode; }) { const capture = usePostHogClientCapture(); const { resolvedTheme: theme } = useTheme(); const { setIsMarkdownEnabled } = useMarkdownContext(); const markdownContent = typeof markdown === "string" ? markdown : parseOpenAIContentParts(markdown); const { shouldBeCollapsible, isCollapsed, toggleCollapsed, truncatedContent, } = useCollapsibleSystemPrompt({ role: title ?? "", content: markdownContent, }); const handleOnCopy = () => { void copyTextToClipboard(markdownContent); }; const handleOnValueChange = () => { setIsMarkdownEnabled(false); capture("trace_detail:io_pretty_format_toggle_group", { renderMarkdown: false, }); }; return (
    {title ? ( <>
    ) : null} {afterHeader}
    {typeof markdown === "string" ? ( // plain string <> {shouldBeCollapsible && ( )} ) : ( // content parts (multi-modal) (markdown ?? []).map((content, index) => isOpenAITextContentPart(content) ? ( ) : isOpenAIImageContentPart(content) ? ( OpenAIUrlImageUrl.safeParse(content.image_url.url).success ? (
    ) : MediaReferenceStringSchema.safeParse(content.image_url.url) .success ? ( ) : (
    {content.image_url.url.toString()}
    ) ) : content.type === "input_audio" ? ( ) : null, ) )} {audio ? ( <> ) : null}
    {media && media.length > 0 && ( <>
    Media
    {media.map((m) => ( ))}
    )}
    ); }