2026-09-09 09:20:39 +00:00
|
|
|
import { Children, cloneElement, createElement, isValidElement, memo, useCallback, useMemo, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from "react";
|
2026-09-05 14:59:07 +00:00
|
|
|
import ReactMarkdown, { type Components } from "react-markdown";
|
|
|
|
|
import remarkBreaks from "remark-breaks";
|
|
|
|
|
import remarkGfm from "remark-gfm";
|
|
|
|
|
import { t } from "./i18n";
|
2026-09-09 09:20:39 +00:00
|
|
|
import { mentionSpans, type MentionAgent } from "./mentions";
|
2026-09-05 14:59:07 +00:00
|
|
|
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 (
|
|
|
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
|
|
|
<rect x="9" y="9" width="12" height="12" rx="2" stroke="currentColor" strokeWidth="2" strokeLinejoin="round"/>
|
|
|
|
|
<path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function CheckIcon() {
|
|
|
|
|
return (
|
|
|
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
|
|
|
<path d="M4 12.5 9.5 18 20 6" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round"/>
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function useCopiedFlag() {
|
|
|
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
|
const timerRef = useRef<number>(undefined);
|
|
|
|
|
const markCopied = useCallback(() => {
|
|
|
|
|
setCopied(true);
|
|
|
|
|
window.clearTimeout(timerRef.current);
|
|
|
|
|
timerRef.current = window.setTimeout(() => setCopied(false), 1500);
|
|
|
|
|
}, []);
|
|
|
|
|
return {copied, markCopied};
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 09:20:39 +00:00
|
|
|
export function copyText(text: string): Promise<boolean> {
|
2026-09-05 14:59:07 +00:00
|
|
|
if (!navigator.clipboard) return Promise.resolve(false);
|
|
|
|
|
return navigator.clipboard.writeText(text).then(() => true).catch(() => false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function CodeBlock(props: ComponentPropsWithoutRef<"pre">) {
|
|
|
|
|
const preRef = useRef<HTMLPreElement>(null);
|
|
|
|
|
const {copied, markCopied} = useCopiedFlag();
|
|
|
|
|
const handleCopy = useCallback(() => {
|
|
|
|
|
void copyText(preRef.current?.textContent ?? "").then((ok) => { if (ok) markCopied(); });
|
|
|
|
|
}, [markCopied]);
|
|
|
|
|
return (
|
|
|
|
|
<div className="md-pre-wrap">
|
|
|
|
|
<pre {...props} ref={preRef}/>
|
|
|
|
|
<button type="button" className="md-copy" onClick={handleCopy} aria-label={copied ? t("copiedCode") : t("copyCode")}>
|
|
|
|
|
{copied ? <CheckIcon/> : <CopyIcon/>}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const components: Components = {
|
|
|
|
|
a({node: _node, ...props}) {
|
|
|
|
|
return <a {...props} target="_blank" rel="noreferrer noopener"/>;
|
|
|
|
|
},
|
|
|
|
|
img({node: _node, ...props}) {
|
|
|
|
|
return <img {...props} alt={props.alt ?? ""} loading="lazy"/>;
|
|
|
|
|
},
|
|
|
|
|
pre({node: _node, ...props}) {
|
|
|
|
|
return <CodeBlock {...props}/>;
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function CopyMessageButton({text}:{text:string}) {
|
|
|
|
|
const {copied, markCopied} = useCopiedFlag();
|
|
|
|
|
if (!text.trim()) return null;
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={`copy-msg ${copied ? "copied" : ""}`}
|
|
|
|
|
title={copied ? t("copiedMessage") : t("copyMessage")}
|
|
|
|
|
aria-label={copied ? t("copiedMessage") : t("copyMessage")}
|
|
|
|
|
onClick={() => { void copyText(text).then((ok) => { if (ok) markCopied(); }); }}
|
|
|
|
|
>
|
|
|
|
|
{copied ? <CheckIcon/> : <CopyIcon/>}
|
|
|
|
|
<span>{copied ? t("copiedMessage") : t("copyMessage")}</span>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 09:20:39 +00:00
|
|
|
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 key={index}>{span.text}</span>;
|
|
|
|
|
if(span.kind==="everyone")return <span key={index} className="mention-link is-everyone">{span.text}</span>;
|
|
|
|
|
const name=span.text.slice(1);
|
|
|
|
|
return <button
|
|
|
|
|
key={index}
|
|
|
|
|
type="button"
|
|
|
|
|
className="mention-link"
|
|
|
|
|
title={t("openAgentChat",{name})}
|
|
|
|
|
aria-label={t("openAgentChat",{name})}
|
|
|
|
|
onClick={event=>{event.preventDefault();event.stopPropagation();onMention?.(span.id);}}
|
|
|
|
|
>{span.text}</button>;
|
|
|
|
|
})}</>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 <MentionText text={node} agents={agents} onMention={onMention}/>;
|
|
|
|
|
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],
|
|
|
|
|
);
|
2026-09-05 14:59:07 +00:00
|
|
|
return (
|
|
|
|
|
<div className="md-body">
|
|
|
|
|
<ReactMarkdown
|
2026-09-09 09:20:39 +00:00
|
|
|
components={mdComponents}
|
2026-09-05 14:59:07 +00:00
|
|
|
remarkPlugins={[remarkGfm, remarkBreaks]}
|
|
|
|
|
skipHtml
|
|
|
|
|
urlTransform={(url) => sanitizeMarkdownUrl(url) ?? ""}
|
|
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
</ReactMarkdown>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
});
|