Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

529 lines
17 KiB
TypeScript

"use client";
import React, { useMemo } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
citationAnchorIdFor,
markdownUrlTransform,
normalizeMarkdownForDisplay,
} from "@/lib/markdown-display";
import {
InlineFileCard,
makeFileLinkRemarkPlugin,
parseAttachmentHref,
useInlineFileCardContext,
} from "@/components/common/InlineFileCard";
import type { MarkdownRendererProps } from "./MarkdownRenderer";
function extractText(children: React.ReactNode): string {
return React.Children.toArray(children)
.map((child) => {
if (typeof child === "string" || typeof child === "number") {
return String(child);
}
if (React.isValidElement<{ children?: React.ReactNode }>(child)) {
return extractText(child.props.children);
}
return "";
})
.join("");
}
function headingId(children: React.ReactNode): string | undefined {
const text = extractText(children)
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-");
return text || undefined;
}
function hasRenderableChildren(children: React.ReactNode): boolean {
return (
extractText(children).replace(/[\s\u200B-\u200D\uFEFF]/g, "").length > 0
);
}
function hasRenderableDetailsBody(children: React.ReactNode): boolean {
return React.Children.toArray(children).some((child) => {
if (typeof child === "string" || typeof child === "number") {
return String(child).replace(/[\s\u200B-\u200D\uFEFF]/g, "").length > 0;
}
if (!React.isValidElement(child)) return false;
if (
typeof child.type === "string" &&
child.type.toLowerCase() === "summary"
) {
return false;
}
return true;
});
}
function stripLeadingHashes(children: React.ReactNode): React.ReactNode {
const arr = React.Children.toArray(children);
if (arr.length > 0 && typeof arr[0] === "string") {
const cleaned = arr[0].replace(/^#{1,6}\s+/, "");
if (cleaned !== arr[0]) return [cleaned, ...arr.slice(1)];
}
return children;
}
export default function SimpleMarkdownRenderer({
content,
className = "",
variant = "default",
}: MarkdownRendererProps) {
const normalizedContent = useMemo(
() => normalizeMarkdownForDisplay(content),
[content],
);
const isTrace = variant === "trace";
const gap = isTrace ? "my-1" : variant === "compact" ? "my-2" : "my-4";
const cellPad = isTrace
? "px-1.5 py-1"
: variant === "compact"
? "px-2 py-1.5"
: "px-3 py-2";
const headingSpacing = variant === "compact" ? "mt-4 mb-2" : "mt-6 mb-3";
const textColor = "text-[var(--foreground)]";
const traceComponents: Record<string, React.ComponentType<any>> = {
p: ({ node, ...props }: any) => (
<p className="mb-1.5 last:mb-0" {...props} />
),
h1: ({ node, children }: any) => (
<p className="mb-1.5 font-semibold">{children}</p>
),
h2: ({ node, children }: any) => (
<p className="mb-1.5 font-semibold">{children}</p>
),
h3: ({ node, children }: any) => (
<p className="mb-1.5 font-semibold">{children}</p>
),
h4: ({ node, children }: any) => (
<p className="mb-1.5 font-semibold">{children}</p>
),
h5: ({ node, children }: any) => (
<p className="mb-1.5 font-semibold">{children}</p>
),
h6: ({ node, children }: any) => (
<p className="mb-1.5 font-semibold">{children}</p>
),
strong: ({ node, children }: any) => (
<strong className="font-semibold text-[var(--foreground)]">
{children}
</strong>
),
em: ({ node, children }: any) => <em className="italic">{children}</em>,
a: ({ node, children }: any) => (
<span className="underline underline-offset-2">{children}</span>
),
blockquote: ({ node, children }: any) => (
<div className="border-l border-current/20 pl-3 opacity-80">
{children}
</div>
),
pre: ({ children }: any) => <>{children}</>,
code: ({ node, children }: any) => (
<code className="rounded bg-[var(--muted)] px-1 py-0.5 font-mono text-[0.95em] text-[var(--foreground)]">
{String(children).replace(/\n$/, "")}
</code>
),
img: () => null,
hr: () => <div className="my-1 h-px bg-current opacity-10" />,
ul: ({ node, ...props }: any) => (
<ul className="my-1 ml-4 list-disc" {...props} />
),
ol: ({ node, ...props }: any) => (
<ol className="my-1 ml-4 list-decimal" {...props} />
),
li: ({ node, ...props }: any) => <li className="my-0.5 pl-0" {...props} />,
table: ({ node, children, ...props }: any) =>
hasRenderableChildren(children) ? (
<div className="my-1 overflow-x-auto rounded border border-[var(--border)]/50">
<table className="min-w-full text-[inherit]" {...props}>
{children}
</table>
</div>
) : null,
thead: ({ node, ...props }: any) => (
<thead className="bg-[var(--muted)]/50" {...props} />
),
th: ({ node, ...props }: any) => (
<th
className="border-b border-[var(--border)]/50 px-1.5 py-0.5 text-left font-medium"
{...props}
/>
),
tbody: ({ node, ...props }: any) => <tbody {...props} />,
td: ({ node, ...props }: any) => (
<td
className="border-b border-[var(--border)]/30 px-1.5 py-0.5"
{...props}
/>
),
tr: ({ node, ...props }: any) => <tr {...props} />,
input: ({ node, type, ...props }: any) =>
type === "checkbox" ? (
<input
type="checkbox"
readOnly
className="mr-1 align-middle"
{...props}
/>
) : null,
progress: () => null,
meter: () => null,
button: () => null,
select: () => null,
option: () => null,
textarea: () => null,
details: ({ node, children }: any) =>
hasRenderableDetailsBody(children) ? <div>{children}</div> : null,
summary: ({ node, children }: any) =>
hasRenderableChildren(children) ? <span>{children}</span> : null,
};
const headingComponents = {
h1: ({ node, children, className: headingClassName, ...props }: any) => {
const clean = stripLeadingHashes(children);
return (
<h1
id={headingId(clean)}
className={`scroll-mt-20 font-sans text-2xl font-bold tracking-tight ${textColor} ${
variant === "compact" ? "mt-5 mb-2" : "mt-8 mb-4"
} ${headingClassName || ""}`}
{...props}
>
{clean}
</h1>
);
},
h2: ({ node, children, className: headingClassName, ...props }: any) => {
const clean = stripLeadingHashes(children);
return (
<h2
id={headingId(clean)}
className={`scroll-mt-20 font-sans text-xl font-semibold tracking-tight ${textColor} ${
variant === "compact" ? "mt-4 mb-2" : "mt-7 mb-3"
} ${headingClassName || ""}`}
{...props}
>
{clean}
</h2>
);
},
h3: ({ node, children, className: headingClassName, ...props }: any) => {
const clean = stripLeadingHashes(children);
return (
<h3
id={headingId(clean)}
className={`scroll-mt-20 font-sans text-lg font-semibold tracking-tight ${textColor} ${
variant === "compact" ? "mt-4 mb-1.5" : "mt-6 mb-2.5"
} ${headingClassName || ""}`}
{...props}
>
{clean}
</h3>
);
},
h4: ({ node, children, className: headingClassName, ...props }: any) => {
const clean = stripLeadingHashes(children);
return (
<h4
id={headingId(clean)}
className={`scroll-mt-20 font-sans text-base font-semibold ${textColor} ${
variant === "compact" ? "mt-3 mb-1.5" : "mt-5 mb-2"
} ${headingClassName || ""}`}
{...props}
>
{clean}
</h4>
);
},
h5: ({ node, children, className: headingClassName, ...props }: any) => {
const clean = stripLeadingHashes(children);
return (
<h5
id={headingId(clean)}
className={`scroll-mt-20 font-sans text-sm font-semibold ${textColor} ${
variant === "compact" ? "mt-3 mb-1.5" : "mt-4 mb-2"
} ${headingClassName || ""}`}
{...props}
>
{clean}
</h5>
);
},
h6: ({ node, children, className: headingClassName, ...props }: any) => {
const clean = stripLeadingHashes(children);
return (
<h6
id={headingId(clean)}
className={`scroll-mt-20 font-sans text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] ${
variant === "compact" ? "mt-3 mb-1.5" : "mt-4 mb-2"
} ${headingClassName || ""}`}
{...props}
>
{clean}
</h6>
);
},
};
const normalComponents: Record<string, React.ComponentType<any>> = {
...headingComponents,
table: ({ node, children, ...props }: any) =>
hasRenderableChildren(children) ? (
<div
className={`overflow-x-auto rounded-lg border border-[var(--border)] shadow-sm ${gap}`}
>
<table
className="min-w-full divide-y divide-[var(--border)] text-sm"
{...props}
>
{children}
</table>
</div>
) : null,
thead: ({ node, ...props }: any) => (
<thead className="bg-[var(--muted)]" {...props} />
),
th: ({ node, ...props }: any) => (
<th
className={`border-b border-[var(--border)] text-left font-semibold text-[var(--foreground)] ${cellPad}`}
{...props}
/>
),
tbody: ({ node, ...props }: any) => (
<tbody
className="divide-y divide-[var(--border)] bg-[var(--card)]"
{...props}
/>
),
td: ({ node, ...props }: any) => (
<td
className={`border-b border-[var(--border)] text-[var(--muted-foreground)] ${cellPad}`}
{...props}
/>
),
tr: ({ node, ...props }: any) => (
<tr className="transition-colors hover:bg-[var(--muted)]/60" {...props} />
),
pre: ({ children }: any) => <>{children}</>,
code: ({ node, children, ...props }: any) => {
const raw = String(children).replace(/\n$/, "");
if (raw.includes("\n")) {
// Use the same #1f2937 / #e5e7eb palette as RichMarkdownRenderer and
// RichCodeBlock so any stream-time fallback from Rich → Simple (or
// first-paint via Simple before the rich lock engages) looks visually
// identical to its rich counterpart.
return (
<div
className={`md-code-block ${gap} overflow-hidden rounded-xl border border-[var(--border)] bg-[#1f2937]`}
>
<pre className="overflow-x-auto p-4 text-sm leading-relaxed text-[#e5e7eb]">
<code className="md-code-block__code" {...props}>
{raw}
</code>
</pre>
</div>
);
}
return (
<code
className="md-inline-code rounded bg-[var(--muted)] px-1.5 py-0.5 font-mono text-[0.875em] text-[var(--foreground)]"
{...props}
>
{children}
</code>
);
},
a: ({ node, href, children, title, ...props }: any) => {
const attachmentName = parseAttachmentHref(href);
if (attachmentName) {
return <InlineFileCard name={attachmentName} fallback={children} />;
}
const isCitation = title === "citation";
const isHashLink = href?.startsWith("#");
const external =
href?.startsWith("http://") || href?.startsWith("https://");
if (isCitation) {
const label = extractText(children);
const ids = label.split(/\s*,\s*/);
const scrollToRef = (event: React.MouseEvent, id?: string) => {
event.preventDefault();
const hashTarget =
id && citationAnchorIdFor(id)
? citationAnchorIdFor(id)
: href?.startsWith("#")
? decodeURIComponent(href.slice(1))
: "references";
const target =
document.getElementById(hashTarget || "") ??
document.getElementById("references");
const parentDetails = target?.closest("details");
if (parentDetails instanceof HTMLDetailsElement) {
parentDetails.open = true;
}
target?.scrollIntoView({ block: "start", behavior: "smooth" });
};
return (
<span
className="citation-group mx-0.5 text-[0.78em] leading-snug text-[var(--muted-foreground)]"
{...props}
>
[
{ids.map((id, idx) => {
const prefixMatch = id.match(/^(web|rag|code|src)-/);
const prefix = prefixMatch?.[1] ?? "";
const num =
prefix && prefixMatch ? id.slice(prefixMatch[0].length) : id;
const citationAnchor = citationAnchorIdFor(id);
return (
<React.Fragment key={`${id}-${idx}`}>
{idx > 0 && ", "}
<a
href={citationAnchor ? `#${citationAnchor}` : href}
onClick={(event) => scrollToRef(event, id)}
className="cursor-pointer text-[var(--primary)] no-underline transition-colors hover:text-[var(--primary)]/70 hover:underline"
>
{prefix ? (
<>
<span className="text-[0.85em] font-semibold uppercase tracking-wide">
{prefix}
</span>
{num}
</>
) : (
num
)}
</a>
</React.Fragment>
);
})}
]
</span>
);
}
return (
<a
href={href}
{...(external
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
onClick={(event) => {
if (!isHashLink || !href) return;
event.preventDefault();
const targetId = decodeURIComponent(href.slice(1));
const target = document.getElementById(targetId);
target?.scrollIntoView({ block: "start", behavior: "smooth" });
}}
className="text-[var(--primary)] underline decoration-[var(--primary)]/40 underline-offset-2 transition-colors hover:decoration-[var(--primary)]"
{...props}
>
{children}
</a>
);
},
img: ({ node, src, alt, ...props }: any) => (
<img
src={src}
alt={alt || ""}
loading="lazy"
className={`${gap} inline-block max-w-full rounded-lg border border-[var(--border)]`}
{...props}
/>
),
blockquote: ({ node, ...props }: any) => (
<blockquote
className={`${gap} border-l-[3px] border-[var(--muted-foreground)] pl-4 italic text-[var(--muted-foreground)] [&>p]:mb-1`}
{...props}
/>
),
hr: ({ node, ...props }: any) => (
<hr className={`${gap} h-px border-none bg-[var(--border)]`} {...props} />
),
input: ({ node, type, checked, ...props }: any) =>
type === "checkbox" ? (
<input
type="checkbox"
checked={checked}
readOnly
className="mr-2 h-4 w-4 rounded border-[var(--border)] align-middle accent-[var(--primary)]"
{...props}
/>
) : null,
progress: () => null,
meter: () => null,
button: () => null,
select: () => null,
option: () => null,
textarea: () => null,
details: ({ node, children, ...props }: any) =>
hasRenderableDetailsBody(children) ? (
<details
className={`${gap} rounded-lg border border-[var(--border)] bg-[var(--card)] px-4 py-2`}
{...props}
>
{children}
</details>
) : null,
summary: ({ node, children, ...props }: any) =>
hasRenderableChildren(children) ? (
<summary
className="cursor-pointer select-none font-medium text-[var(--foreground)]"
{...props}
>
{children}
</summary>
) : null,
};
const components = useMemo(
() => (isTrace ? traceComponents : normalComponents),
// eslint-disable-next-line react-hooks/exhaustive-deps -- components only change with variant
[isTrace, variant],
);
// Linkify exact generated-filename mentions in the assistant's prose into
// clickable file links (no-op outside a chat message — fileCtx is null).
const fileCtx = useInlineFileCardContext();
const fileLinkPlugin = useMemo(
() => makeFileLinkRemarkPlugin(fileCtx?.files ?? []),
[fileCtx?.files],
);
const remarkPlugins = useMemo(
() => (fileLinkPlugin ? [remarkGfm, fileLinkPlugin] : [remarkGfm]),
[fileLinkPlugin],
);
const rootClasses = isTrace
? "md-renderer max-w-none font-sans text-[11px] leading-[1.55] text-[var(--muted-foreground)]"
: variant === "prose"
? "md-renderer prose max-w-none font-serif"
: "md-renderer prose prose-sm max-w-none font-serif";
return (
<div className={`${rootClasses} ${className}`}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
components={components}
urlTransform={markdownUrlTransform}
>
{normalizedContent}
</ReactMarkdown>
</div>
);
}