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) => {children}
    , 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 */}
    {name}
    {/* Body */}
    {loading ? (
    正在加载文件内容…
    ) : error ? (
    加载失败:{error}
    ) : content === null ? null : renderAsMermaid ? ( mermaidSvg ? (
    ) : (
    正在生成图表预览
    ) ) : renderAsMarkdown ? ( {content} ) : (
    {content}
    )}
    , document.body, ); };