import React, { useCallback, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import type { ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Download, FileText, Loader2, X } from "lucide-react";
import { apiFetch } from "@/core/api/fetch-client";
import {
downloadMermaidSvgFile,
isMermaidFile,
renderMermaidToSvg,
} from "@/core/utils/mermaid";
import {
createMarkdownAnchorResolver,
isInPageAnchor,
scrollToInPageAnchor,
} from "@/core/utils/markdown";
import { buildArtifactDownloadUrl } from "@/roundtable-planning/api/multi-agent";
interface ArtifactPreviewModalProps {
/** The thread that owns the artifact. Both fetch and download URLs use it. */
threadId: string;
/** Virtual path under the sandbox (e.g. `mnt/user-data/outputs/foo.md`). */
path: string;
/** Bare filename — shown in the modal header. */
name: string;
/** Mime type from the listing; drives the renderer choice. */
mimeType: string | null;
onClose: () => void;
}
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 "";
}
function createMarkdownComponents(content: string) {
const anchorResolver = createMarkdownAnchorResolver(content);
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6, className: string) => {
const Tag = `h${level}` as const;
return ({ children, id }: any) => (
{children}
);
};
return {
p: ({ children }: any) =>
{children}
,
ul: ({ children }: any) => ,
ol: ({ children }: any) => {children}
,
li: ({ children }: any) => {children},
h1: heading(1, "text-base font-bold mt-3 mb-2 scroll-mt-4"),
h2: heading(2, "text-sm font-bold mt-3 mb-1.5 scroll-mt-4"),
h3: heading(3, "text-xs font-bold mt-2 mb-1 scroll-mt-4"),
h4: heading(4, "text-xs font-bold mt-1.5 mb-1 scroll-mt-4"),
strong: ({ children }: any) => {children},
em: ({ children }: any) => {children},
code: ({ children, className }: any) => {
const isBlock = /language-/.test(className ?? "");
if (isBlock) {
return (
{children}
);
}
return {children};
},
blockquote: ({ children }: any) => (
{children}
),
a: ({ children, href }: any) => {
const resolvedHref = anchorResolver.resolveHref(href, extractTextFromChildren(children));
if (resolvedHref === null) {
return (
{children}
);
}
const finalHref = resolvedHref ?? href;
const isExternal = typeof href === "string" && /^https?:\/\//i.test(href);
return (
{
if (isInPageAnchor(finalHref)) {
event.preventDefault();
scrollToInPageAnchor(event.currentTarget, finalHref);
}
}}
>
{children}
);
},
table: ({ children }: any) => ,
th: ({ children }: any) => (
{children} |
),
td: ({ children }: any) => {children} | ,
hr: () =>
,
};
}
function isMarkdown(mimeType: string | null, name: string): boolean {
if (mimeType === "text/markdown") return true;
return /\.(md|markdown)$/i.test(name);
}
function isMermaid(mimeType: string | null, name: string): boolean {
if (mimeType === "text/vnd.mermaid") return true;
return isMermaidFile(name);
}
export const ArtifactPreviewModal: React.FC = ({
threadId,
path,
name,
mimeType,
onClose,
}) => {
const [content, setContent] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [mermaidSvg, setMermaidSvg] = useState(null);
// Esc closes the modal. Attached once per mount.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
// Fetch the artifact body via the same URL the original download link used.
// The GET endpoint already returns text/* as decoded UTF-8, so res.text()
// gives us a renderable string directly.
useEffect(() => {
let cancelled = false;
const ctrl = new AbortController();
setLoading(true);
setError(null);
setContent(null);
(async () => {
try {
const url = buildArtifactDownloadUrl(threadId, path);
const res = await apiFetch(url, { method: "GET", signal: ctrl.signal });
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const text = await res.text();
if (!cancelled) setContent(text);
} catch (err) {
if (cancelled || (err as Error).name === "AbortError") return;
setError((err as Error).message);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
ctrl.abort();
};
}, [threadId, path]);
const handleDownload = useCallback(() => {
if (typeof window === "undefined") return;
if (isMermaid(mimeType, name) && content) {
void downloadMermaidSvgFile(content, name).catch((err) => {
setError((err as Error).message);
});
return;
}
const url = buildArtifactDownloadUrl(threadId, path, { download: true });
window.open(url, "_blank", "noopener,noreferrer");
}, [content, mimeType, name, path, threadId]);
const renderAsMarkdown = isMarkdown(mimeType, name);
const renderAsMermaid = isMermaid(mimeType, name);
useEffect(() => {
if (!renderAsMermaid || !content) {
setMermaidSvg(null);
return;
}
let cancelled = false;
void renderMermaidToSvg(content)
.then((svg) => {
if (!cancelled) setMermaidSvg(svg);
})
.catch((err) => {
if (!cancelled) setError((err as Error).message);
});
return () => {
cancelled = true;
};
}, [content, renderAsMermaid]);
const markdownComponents = useMemo(
() => createMarkdownComponents(content ?? ""),
[content],
);
// 经 portal 挂到 body:脱离会商页 (`relative z-0`) 的层叠上下文,
// 否则 fixed 弹窗会被会商页头(z-40)/全局顶栏(z-45)盖住顶部。
return createPortal(
{
// Only close on outer-overlay click — clicks inside the modal panel
// bubble up here too, so check the target.
if (e.target === e.currentTarget) onClose();
}}
>
{/* Header */}
{/* Body */}
{loading ? (
正在加载文件内容…
) : error ? (
加载失败:{error}
) : content === null ? null : renderAsMermaid ? (
mermaidSvg ? (
) : (
正在生成图表预览
)
) : renderAsMarkdown ? (
{content}
) : (
{content}
)}
,
document.body,
);
};