import { Children, cloneElement, createElement, isValidElement, memo, useCallback, useMemo, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from "react"; import ReactMarkdown, { type Components } from "react-markdown"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; import { t } from "./i18n"; import { mentionSpans, type MentionAgent } from "./mentions"; import "./markdown.css"; const protocolPattern = /^([a-z][a-z\d+.-]*):/i; const safeProtocols = new Set(["http", "https", "mailto", "tel"]); export function sanitizeMarkdownUrl(url: string): string | undefined { const value = url.trim(); const protocol = value.match(protocolPattern)?.[1]?.toLowerCase(); if (protocol) return safeProtocols.has(protocol) ? value : undefined; if (value.startsWith("#") && !value.toLowerCase().startsWith("#javascript")) return value; return undefined; } function CopyIcon() { return ( ); } function CheckIcon() { return ( ); } function useCopiedFlag() { const [copied, setCopied] = useState(false); const timerRef = useRef(undefined); const markCopied = useCallback(() => { setCopied(true); window.clearTimeout(timerRef.current); timerRef.current = window.setTimeout(() => setCopied(false), 1500); }, []); return {copied, markCopied}; } export function copyText(text: string): Promise { if (!navigator.clipboard) return Promise.resolve(false); return navigator.clipboard.writeText(text).then(() => true).catch(() => false); } function CodeBlock(props: ComponentPropsWithoutRef<"pre">) { const preRef = useRef(null); const {copied, markCopied} = useCopiedFlag(); const handleCopy = useCallback(() => { void copyText(preRef.current?.textContent ?? "").then((ok) => { if (ok) markCopied(); }); }, [markCopied]); return (
      
    
); } const components: Components = { a({node: _node, ...props}) { return ; }, img({node: _node, ...props}) { return {props.alt; }, pre({node: _node, ...props}) { return ; }, }; export function CopyMessageButton({text}:{text:string}) { const {copied, markCopied} = useCopiedFlag(); if (!text.trim()) return null; return ( ); } export function MentionText({ text, agents, onMention, }:{ text:string; agents:MentionAgent[]; onMention?:(id:string)=>void; }) { const spans=mentionSpans(text,agents); if(!spans.length)return null; if(spans.length===1&&spans[0].kind==="text")return text; return <>{spans.map((span,index)=>{ if(span.kind==="text")return {span.text}; if(span.kind==="everyone")return {span.text}; const name=span.text.slice(1); return ; })}; } const SKIP_TAGS=new Set(["code","pre","a"]); function linkifyMentions(node:ReactNode,agents:MentionAgent[],onMention?:(id:string)=>void):ReactNode { if(node==null||typeof node==="boolean")return node; if(typeof node==="number")return node; if(typeof node==="string")return ; if(Array.isArray(node))return Children.map(node,child=>linkifyMentions(child,agents,onMention)); if(isValidElement(node)){ if(typeof node.type!=="string")return node; if(SKIP_TAGS.has(node.type))return node; const kids=(node.props as {children?:ReactNode}).children; if(kids==null)return node; return cloneElement(node,undefined,linkifyMentions(kids,agents,onMention)); } return node; } function mentionComponents(agents:MentionAgent[],onMention?:(id:string)=>void):Components { const wrap=(tag:"p"|"li"|"td"|"th"|"h1"|"h2"|"h3"|"h4"|"h5"|"h6"|"blockquote")=> function MentionTag({node:_node,children,...props}:{node?:unknown;children?:ReactNode}){ return createElement(tag,props,linkifyMentions(children,agents,onMention)); }; return { ...components, p:wrap("p"), li:wrap("li"), td:wrap("td"), th:wrap("th"), h1:wrap("h1"), h2:wrap("h2"), h3:wrap("h3"), h4:wrap("h4"), h5:wrap("h5"), h6:wrap("h6"), blockquote:wrap("blockquote"), }; } export const ChatMarkdown = memo(function ChatMarkdown({ children, agents=[], onMention, }:{ children:string; agents?:MentionAgent[]; onMention?:(id:string)=>void; }) { const mdComponents=useMemo( ()=>agents.length?mentionComponents(agents,onMention):components, [agents,onMention], ); return (
sanitizeMarkdownUrl(url) ?? ""} > {children}
); });