支持浏览器侧素材快照撰写、拒绝报告读时修复、Mermaid PNG 导出与 Word 导出加固。 Co-authored-by: Cursor <cursoragent@cursor.com>
156 lines
4.9 KiB
TypeScript
156 lines
4.9 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useRef } from "react";
|
|
import type {
|
|
AnchorHTMLAttributes,
|
|
HTMLAttributes,
|
|
MouseEvent as ReactMouseEvent,
|
|
ReactNode,
|
|
} from "react";
|
|
|
|
import {
|
|
MessageResponse,
|
|
type MessageResponseProps,
|
|
} from "@/components/ai-elements/message";
|
|
import { streamdownPlugins, useStreamdownMermaidPngDownload } from "@/core/streamdown";
|
|
import {
|
|
createMarkdownAnchorResolver,
|
|
isInPageAnchor,
|
|
scrollToInPageAnchor,
|
|
} from "@/core/utils/markdown";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
import { CitationLink } from "../citations/citation-link";
|
|
|
|
function isExternalUrl(href: string | undefined): boolean {
|
|
return !!href && /^https?:\/\//.test(href);
|
|
}
|
|
|
|
function extractTextFromChildren(children: ReactNode): string {
|
|
if (typeof children === "string" || typeof children === "number") return String(children);
|
|
if (Array.isArray(children)) return children.map(extractTextFromChildren).join("");
|
|
return "";
|
|
}
|
|
|
|
export type MarkdownContentProps = {
|
|
content: string;
|
|
isLoading: boolean;
|
|
rehypePlugins: MessageResponseProps["rehypePlugins"];
|
|
className?: string;
|
|
remarkPlugins?: MessageResponseProps["remarkPlugins"];
|
|
components?: MessageResponseProps["components"];
|
|
linkClassName?: (href: string, label: string) => string | undefined;
|
|
onLinkClick?: (
|
|
event: ReactMouseEvent<HTMLAnchorElement>,
|
|
href: string,
|
|
label: string,
|
|
) => void;
|
|
};
|
|
|
|
/** Renders markdown content. */
|
|
export function MarkdownContent({
|
|
content,
|
|
rehypePlugins,
|
|
className,
|
|
remarkPlugins = streamdownPlugins.remarkPlugins,
|
|
components: componentsFromProps,
|
|
linkClassName,
|
|
onLinkClick,
|
|
}: MarkdownContentProps) {
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
|
|
useStreamdownMermaidPngDownload(rootRef);
|
|
const components = useMemo(() => {
|
|
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
|
|
const Tag = `h${level}` as const;
|
|
return ({ children, id, ...props }: HTMLAttributes<HTMLHeadingElement>) => (
|
|
<Tag id={id ?? anchorResolver.getHeadingId(level, extractTextFromChildren(children))} {...props}>
|
|
{children}
|
|
</Tag>
|
|
);
|
|
};
|
|
|
|
return {
|
|
h1: heading(1),
|
|
h2: heading(2),
|
|
h3: heading(3),
|
|
h4: heading(4),
|
|
h5: heading(5),
|
|
h6: heading(6),
|
|
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
|
if (props.href?.startsWith("#reference-")) {
|
|
return <CitationLink {...props} />;
|
|
}
|
|
if (typeof props.children === "string") {
|
|
const match = /^citation:(.+)$/.exec(props.children);
|
|
if (match) {
|
|
const [, text] = match;
|
|
return <CitationLink {...props}>{text}</CitationLink>;
|
|
}
|
|
}
|
|
const { className, target, rel, ...rest } = props;
|
|
const external = isExternalUrl(props.href);
|
|
const resolvedHref = anchorResolver.resolveHref(
|
|
props.href,
|
|
extractTextFromChildren(props.children),
|
|
);
|
|
if (resolvedHref === null) {
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"text-primary/80 decoration-dotted underline underline-offset-2 cursor-not-allowed",
|
|
className,
|
|
)}
|
|
title="未在当前 Markdown 中找到对应章节"
|
|
>
|
|
{props.children}
|
|
</span>
|
|
);
|
|
}
|
|
const finalHref = resolvedHref ?? props.href;
|
|
const label = extractTextFromChildren(props.children);
|
|
return (
|
|
<a
|
|
{...rest}
|
|
href={finalHref}
|
|
className={cn(
|
|
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
|
|
className,
|
|
linkClassName?.(finalHref || "", label),
|
|
)}
|
|
target={target ?? (external ? "_blank" : undefined)}
|
|
rel={rel ?? (external ? "noopener noreferrer" : undefined)}
|
|
onClick={(event) => {
|
|
// HashRouter would turn a `#section` click into a route change → 404.
|
|
// Scroll to the heading ourselves instead.
|
|
if (isInPageAnchor(finalHref)) {
|
|
event.preventDefault();
|
|
scrollToInPageAnchor(event.currentTarget, finalHref);
|
|
}
|
|
onLinkClick?.(event, finalHref || "", label);
|
|
rest.onClick?.(event);
|
|
}}
|
|
/>
|
|
);
|
|
},
|
|
...componentsFromProps,
|
|
};
|
|
}, [anchorResolver, componentsFromProps, linkClassName, onLinkClick]);
|
|
|
|
if (!content) return null;
|
|
|
|
return (
|
|
<div ref={rootRef} data-markdown-anchor-root className="contents">
|
|
<MessageResponse
|
|
className={className}
|
|
remarkPlugins={remarkPlugins}
|
|
rehypePlugins={rehypePlugins}
|
|
mermaidConfig={streamdownPlugins.mermaidConfig}
|
|
components={components}
|
|
>
|
|
{content}
|
|
</MessageResponse>
|
|
</div>
|
|
);
|
|
}
|