358 lines
15 KiB
TypeScript
358 lines
15 KiB
TypeScript
/**
|
|
* Step 3「文件沙箱预览」模式 —— 布局对齐「结果绘制」:左栏研讨内容 + 文件清单,右栏 Step 2 沙箱。
|
|
*/
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useDefaultLayout } from "react-resizable-panels";
|
|
import ReactMarkdown from "react-markdown";
|
|
import remarkGfm from "remark-gfm";
|
|
import {
|
|
ArrowLeft,
|
|
CheckCircle,
|
|
Download,
|
|
Eye,
|
|
FileText,
|
|
Loader2,
|
|
PanelLeftClose,
|
|
PanelLeftOpen,
|
|
} from "lucide-react";
|
|
|
|
import {
|
|
ResizableHandle,
|
|
ResizablePanel,
|
|
ResizablePanelGroup,
|
|
} from "@/components/ui/resizable";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
buildArtifactDownloadUrl,
|
|
type SessionArtifactFile,
|
|
} from "@/roundtable-planning/api/multi-agent";
|
|
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
|
|
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
|
|
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
|
|
|
interface SeatDelivery {
|
|
sender: string;
|
|
content: string;
|
|
}
|
|
|
|
interface SeatArtifacts {
|
|
agentName: string;
|
|
displayName: string;
|
|
threadId: string;
|
|
files: SessionArtifactFile[];
|
|
loading: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
function formatBytes(n: number): string {
|
|
if (n < 1024) return `${n} B`;
|
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
export interface Step3FileBrowseModeProps {
|
|
consensusText: string;
|
|
hasConsensus: boolean;
|
|
consensusDocTitle: string;
|
|
seatDeliveries: SeatDelivery[];
|
|
artifactsBySeat: SeatArtifacts[];
|
|
seatStubMessages: Record<string, unknown[]>;
|
|
openArtifactPreview: (threadId: string, path: string) => void;
|
|
/** 进入模式时默认打开的文件(由「查看」触发)。 */
|
|
initialFile: { threadId: string; file: SessionArtifactFile };
|
|
onBack: () => void;
|
|
}
|
|
|
|
export function Step3FileBrowseMode({
|
|
consensusText,
|
|
hasConsensus,
|
|
consensusDocTitle,
|
|
seatDeliveries,
|
|
artifactsBySeat,
|
|
seatStubMessages,
|
|
openArtifactPreview,
|
|
initialFile,
|
|
onBack,
|
|
}: Step3FileBrowseModeProps) {
|
|
const [activeFile, setActiveFile] = useState(initialFile);
|
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
|
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
|
id: "roundtable-step3-file-browse-layout",
|
|
panelIds: ["content", "artifact"],
|
|
});
|
|
|
|
useEffect(() => {
|
|
openArtifactPreview(activeFile.threadId, activeFile.file.path);
|
|
}, [activeFile.file.path, activeFile.threadId, openArtifactPreview]);
|
|
|
|
const handleSelectFile = useCallback(
|
|
(threadId: string, file: SessionArtifactFile) => {
|
|
setActiveFile({ threadId, file });
|
|
openArtifactPreview(threadId, file.path);
|
|
},
|
|
[openArtifactPreview],
|
|
);
|
|
|
|
const handleDownload = useCallback((threadId: string, file: SessionArtifactFile) => {
|
|
const url = buildArtifactDownloadUrl(threadId, file.path, { download: true });
|
|
if (typeof window !== "undefined") {
|
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
}
|
|
}, []);
|
|
|
|
const activeSeatMessages = useMemo(
|
|
() => seatStubMessages[activeFile.threadId] ?? [],
|
|
[seatStubMessages, activeFile.threadId],
|
|
);
|
|
|
|
const activeDisplayName = useMemo(() => {
|
|
const seat = artifactsBySeat.find((s) => s.threadId === activeFile.threadId);
|
|
return seat?.displayName ?? "席位";
|
|
}, [artifactsBySeat, activeFile.threadId]);
|
|
|
|
return (
|
|
<div className="flex h-full min-h-0 flex-col text-slate-800">
|
|
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3.5 flex-wrap">
|
|
<div className="flex items-center gap-2.5 min-w-0">
|
|
<FileText className="w-5 h-5 text-primary shrink-0" />
|
|
<h3 className="text-sm font-black text-slate-900 tracking-tight truncate">
|
|
文件预览 · 沙箱查看
|
|
</h3>
|
|
<span className="text-[10px] font-mono text-slate-500 bg-slate-100 px-2 py-0.5 rounded-lg truncate max-w-[240px]">
|
|
{activeDisplayName} / {activeFile.file.name}
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onBack}
|
|
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition shrink-0"
|
|
>
|
|
<ArrowLeft className="w-3.5 h-3.5 text-slate-500" />
|
|
返回报告
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 min-h-0 p-3">
|
|
{leftCollapsed ? (
|
|
<div className="h-full min-h-0 flex gap-3">
|
|
<aside className="w-[52px] shrink-0 flex flex-col gap-3 overflow-y-auto pr-0.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => setLeftCollapsed(false)}
|
|
title="展开侧栏"
|
|
className="w-full shrink-0 flex h-10 items-center justify-center rounded-2xl border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
|
|
>
|
|
<PanelLeftOpen className="h-4 w-4" />
|
|
</button>
|
|
<CollapsedIconButton
|
|
icon={<CheckCircle className="w-4 h-4 text-emerald-500" />}
|
|
title="总控最终结论"
|
|
onClick={() => setLeftCollapsed(false)}
|
|
/>
|
|
<CollapsedIconButton
|
|
icon={<FileText className="w-4 h-4 text-primary" />}
|
|
title="生成的报告文件"
|
|
onClick={() => setLeftCollapsed(false)}
|
|
/>
|
|
</aside>
|
|
<section className="flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-[#fffdfa] overflow-hidden">
|
|
<Step2SandboxLayout
|
|
artifactsFill
|
|
activeThreadId={activeFile.threadId}
|
|
seatMessages={activeSeatMessages}
|
|
onArtifactsClose={onBack}
|
|
/>
|
|
</section>
|
|
</div>
|
|
) : (
|
|
<ResizablePanelGroup
|
|
orientation="horizontal"
|
|
className="h-full min-h-0 gap-3"
|
|
defaultLayout={defaultLayout}
|
|
onLayoutChanged={onLayoutChanged}
|
|
>
|
|
<ResizablePanel id="content" defaultSize={42} minSize={26} className="min-w-0">
|
|
<aside className="h-full min-h-0 flex flex-col gap-3 overflow-y-auto pr-0.5">
|
|
<div className="shrink-0 flex items-center justify-between gap-2 px-1">
|
|
<span className="text-[11px] font-bold text-slate-500 uppercase tracking-widest">
|
|
方案内容
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setLeftCollapsed(true)}
|
|
title="收起侧栏"
|
|
className="shrink-0 flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors"
|
|
>
|
|
<PanelLeftClose className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
<section className="rounded-2xl border border-slate-200/60 bg-white p-3.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
|
<div className="mb-2 flex items-center justify-between gap-2">
|
|
<h4 className="text-xs font-bold text-slate-900 flex items-center gap-1.5">
|
|
<CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
|
|
总控最终结论
|
|
</h4>
|
|
{hasConsensus && (
|
|
<span className="text-[9px] font-bold text-emerald-700 bg-emerald-50 border border-emerald-200 px-1.5 py-0.5 rounded-full">
|
|
已共识
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mb-2 flex justify-end">
|
|
<ConsensusDocxDownloadButton
|
|
markdown={consensusText}
|
|
title={consensusDocTitle}
|
|
size="sm"
|
|
/>
|
|
</div>
|
|
{consensusText ? (
|
|
<div className="max-h-32 overflow-y-auto text-[11px] text-slate-700 leading-relaxed">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
|
{consensusText}
|
|
</ReactMarkdown>
|
|
</div>
|
|
) : (
|
|
<p className="text-[11px] text-slate-400 italic">暂无总控结论。</p>
|
|
)}
|
|
</section>
|
|
|
|
{seatDeliveries.length > 0 && (
|
|
<section className="rounded-2xl border border-slate-200/60 bg-white p-3.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
|
<h4 className="text-xs font-bold text-slate-900 mb-2">
|
|
各席位交付
|
|
<span className="ml-1.5 text-[10px] font-mono text-slate-400">
|
|
{seatDeliveries.length}
|
|
</span>
|
|
</h4>
|
|
<ul className="space-y-1 max-h-24 overflow-y-auto">
|
|
{seatDeliveries.map((s, i) => (
|
|
<li key={`${s.sender}-${i}`} className="text-[11px] text-slate-600 truncate">
|
|
{s.sender}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
)}
|
|
|
|
<section className="rounded-2xl border border-slate-200/60 bg-white p-3.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 min-h-0 flex flex-col">
|
|
<h4 className="text-xs font-bold text-slate-900 mb-2 flex items-center gap-1.5 shrink-0">
|
|
<Download className="w-3.5 h-3.5 text-primary" />
|
|
生成的报告文件
|
|
</h4>
|
|
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
|
|
{artifactsBySeat.map((seat) => (
|
|
<div key={seat.agentName} className="border border-slate-150 rounded-xl overflow-hidden">
|
|
<div className="px-2.5 py-1.5 bg-slate-50 border-b border-slate-100 text-[10.5px] font-bold text-slate-800 truncate">
|
|
{seat.displayName}
|
|
</div>
|
|
{seat.loading ? (
|
|
<div className="flex items-center gap-1.5 px-2.5 py-2 text-[10px] text-slate-500">
|
|
<Loader2 className="w-3 h-3 animate-spin" />
|
|
加载中…
|
|
</div>
|
|
) : seat.error ? (
|
|
<p className="px-2.5 py-2 text-[10px] text-rose-600">{seat.error}</p>
|
|
) : seat.files.length === 0 ? (
|
|
<p className="px-2.5 py-2 text-[10px] text-slate-400 italic">无文件</p>
|
|
) : (
|
|
<ul className="divide-y divide-slate-100">
|
|
{seat.files.map((f) => {
|
|
const isActive =
|
|
activeFile.threadId === seat.threadId &&
|
|
activeFile.file.path === f.path;
|
|
return (
|
|
<li key={f.path}>
|
|
<div
|
|
className={cn(
|
|
"px-2 py-1.5 flex items-center justify-between gap-1",
|
|
isActive && "bg-[var(--brand-200)]/25",
|
|
)}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleSelectFile(seat.threadId, f)}
|
|
className="flex items-center gap-1.5 min-w-0 text-left flex-1"
|
|
title={f.name}
|
|
>
|
|
<FileText className="w-3 h-3 text-slate-400 shrink-0" />
|
|
<span className="text-[10.5px] font-medium text-slate-800 truncate">
|
|
{f.name}
|
|
</span>
|
|
</button>
|
|
<div className="flex items-center gap-0.5 shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleSelectFile(seat.threadId, f)}
|
|
className={cn(
|
|
"p-1 rounded transition",
|
|
isActive
|
|
? "text-primary bg-white/80"
|
|
: "text-slate-500 hover:text-primary hover:bg-slate-100",
|
|
)}
|
|
title="在沙箱中查看"
|
|
>
|
|
<Eye className="w-3 h-3" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDownload(seat.threadId, f)}
|
|
className="text-slate-500 hover:text-primary p-1 rounded hover:bg-slate-100 transition"
|
|
title="下载"
|
|
>
|
|
<Download className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</aside>
|
|
</ResizablePanel>
|
|
|
|
<ResizableHandle className="w-2 rounded-full bg-slate-200/70 hover:bg-slate-300 transition" />
|
|
|
|
<ResizablePanel id="artifact" defaultSize={58} minSize={30} className="min-w-0">
|
|
<section className="h-full min-h-0 rounded-2xl border border-slate-200/60 bg-[#fffdfa] overflow-hidden">
|
|
<Step2SandboxLayout
|
|
artifactsFill
|
|
activeThreadId={activeFile.threadId}
|
|
seatMessages={activeSeatMessages}
|
|
onArtifactsClose={onBack}
|
|
/>
|
|
</section>
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CollapsedIconButton({
|
|
icon,
|
|
title,
|
|
onClick,
|
|
}: {
|
|
icon: React.ReactNode;
|
|
title: string;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
title={title}
|
|
onClick={onClick}
|
|
className="w-full shrink-0 flex items-center justify-center rounded-2xl border border-slate-200/50 bg-white p-2.5 text-slate-500 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:text-primary hover:border-primary/30"
|
|
>
|
|
{icon}
|
|
</button>
|
|
);
|
|
}
|