多智能问答
This commit is contained in:
1 parent
77db1a6297
commit
05815d9475
11 files changed
+2045
-727
No files matched your search
@@ -0,0 +1,184 @@
|
||||
import React, { useCallback, useEffect, useState } 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 { 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;
|
||||
}
|
||||
|
||||
const MARKDOWN_COMPONENTS = {
|
||||
p: ({ children }: any) => <p className="my-1.5 whitespace-pre-wrap leading-relaxed">{children}</p>,
|
||||
ul: ({ children }: any) => <ul className="list-disc pl-5 my-1.5 space-y-1">{children}</ul>,
|
||||
ol: ({ children }: any) => <ol className="list-decimal pl-5 my-1.5 space-y-1">{children}</ol>,
|
||||
li: ({ children }: any) => <li className="leading-snug">{children}</li>,
|
||||
h1: ({ children }: any) => <h1 className="text-base font-bold mt-3 mb-2">{children}</h1>,
|
||||
h2: ({ children }: any) => <h2 className="text-sm font-bold mt-3 mb-1.5">{children}</h2>,
|
||||
h3: ({ children }: any) => <h3 className="text-xs font-bold mt-2 mb-1">{children}</h3>,
|
||||
h4: ({ children }: any) => <h4 className="text-xs font-bold mt-1.5 mb-1">{children}</h4>,
|
||||
strong: ({ children }: any) => <strong className="font-bold text-slate-900">{children}</strong>,
|
||||
em: ({ children }: any) => <em className="italic">{children}</em>,
|
||||
code: ({ children, className }: any) => {
|
||||
const isBlock = /language-/.test(className ?? "");
|
||||
if (isBlock) {
|
||||
return (
|
||||
<pre className="bg-slate-100 text-slate-800 rounded-md p-3 my-2 overflow-x-auto text-[12px] font-mono">
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
return <code className="bg-slate-100 text-slate-800 rounded px-1 py-0.5 text-[12px] font-mono">{children}</code>;
|
||||
},
|
||||
blockquote: ({ children }: any) => (
|
||||
<blockquote className="border-l-2 border-slate-300 pl-3 my-2 text-slate-600 italic">{children}</blockquote>
|
||||
),
|
||||
a: ({ children, href }: any) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline hover:opacity-80">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
table: ({ children }: any) => <table className="my-2 border-collapse text-[12px] w-full">{children}</table>,
|
||||
th: ({ children }: any) => (
|
||||
<th className="border border-slate-300 px-2 py-1 bg-slate-50 font-bold text-left">{children}</th>
|
||||
),
|
||||
td: ({ children }: any) => <td className="border border-slate-200 px-2 py-1">{children}</td>,
|
||||
hr: () => <hr className="my-3 border-slate-200" />,
|
||||
};
|
||||
|
||||
function isMarkdown(mimeType: string | null, name: string): boolean {
|
||||
if (mimeType === "text/markdown") return true;
|
||||
return /\.(md|markdown)$/i.test(name);
|
||||
}
|
||||
|
||||
export const ArtifactPreviewModal: React.FC<ArtifactPreviewModalProps> = ({
|
||||
threadId,
|
||||
path,
|
||||
name,
|
||||
mimeType,
|
||||
onClose,
|
||||
}) => {
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(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;
|
||||
const url = buildArtifactDownloadUrl(threadId, path, { download: true });
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}, [threadId, path]);
|
||||
|
||||
const renderAsMarkdown = isMarkdown(mimeType, name);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-6"
|
||||
onMouseDown={(e) => {
|
||||
// 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();
|
||||
}}
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[80vh] flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="shrink-0 px-5 py-3.5 border-b border-slate-150 flex items-center justify-between gap-3 bg-gradient-to-r from-slate-50 to-white">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FileText className="w-4 h-4 text-primary shrink-0" />
|
||||
<span className="text-sm font-bold text-slate-800 truncate" title={name}>{name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="bg-white border border-slate-200 text-slate-600 hover:bg-slate-50 text-[11px] font-bold px-2.5 py-1.5 rounded-lg flex items-center gap-1 transition"
|
||||
title="下载原文件"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
下载
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-slate-500 hover:text-slate-800 hover:bg-slate-100 rounded-lg transition"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4 text-sm text-slate-700">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-slate-400">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||
<span className="text-xs">正在加载文件内容…</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-rose-600 text-xs py-6 text-center">
|
||||
加载失败:{error}
|
||||
</div>
|
||||
) : content === null ? null : renderAsMarkdown ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap break-words text-xs text-slate-700 font-mono">{content}</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user