525 lines
22 KiB
TypeScript
525 lines
22 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
import ReactMarkdown from "react-markdown";
|
|
import remarkGfm from "remark-gfm";
|
|
import {
|
|
CheckCircle,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Download,
|
|
Eye,
|
|
FileText,
|
|
Loader2,
|
|
RefreshCw,
|
|
Sparkles,
|
|
} from "lucide-react";
|
|
|
|
import { useArtifacts } from "@/components/workspace/artifacts";
|
|
import {
|
|
buildArtifactDownloadUrl,
|
|
listSessionArtifacts,
|
|
type SessionArtifactFile,
|
|
type ThreadIdMap,
|
|
} from "@/roundtable-planning/api/multi-agent";
|
|
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
|
|
import { Step3FileBrowseMode } from "@/roundtable-planning/components/Step3FileBrowseMode";
|
|
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
|
|
import {
|
|
Step3ReportMode,
|
|
type ModelOption,
|
|
} from "@/roundtable-planning/components/Step3ReportMode";
|
|
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
|
|
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
|
|
|
|
interface SeatDelivery {
|
|
/** Display name of the seat (e.g. "情报收集"). */
|
|
sender: string;
|
|
/** Avatar color hint inherited from the dialogue card. Unused for layout right now. */
|
|
avatarType: string;
|
|
/** The seat's final markdown delivery from Step 2. */
|
|
content: string;
|
|
}
|
|
|
|
interface HighFidelityReportProps {
|
|
threadIds: ThreadIdMap | null;
|
|
coordinatorName: string | null;
|
|
/** The most recent leader-agent text — rendered as the final consensus. */
|
|
consensusText: string;
|
|
/** True once leader.dispatched === [] was observed (natural convergence). */
|
|
hasConsensus: boolean;
|
|
/** Each sub-agent's final delivery, filtered out of step2 dialogues. */
|
|
seatDeliveries: SeatDelivery[];
|
|
/** lowercase agent name → display name (e.g. "roundtable-intelligence" → "情报收集"). */
|
|
seatNameMap: Record<string, string>;
|
|
/** Step 1 整理出的结构化意图 —— 喂给「结果绘制」的方案可视化智能体。 */
|
|
intentReady: ReportIntent | null;
|
|
/** 当前推理模型 —— 跑可视化智能体用。 */
|
|
selectedModel: string;
|
|
/** 可选模型列表 + 加载态 + 切换器 —— 结果绘制右侧的模型选择(对齐第 2 步)。 */
|
|
availableModels: ReadonlyArray<ModelOption>;
|
|
modelsLoading: boolean;
|
|
setSelectedModel: (model: string) => void;
|
|
/** 草稿里已存的上次绘制结果 —— 透传给「结果绘制」恢复展示。 */
|
|
savedReport?: DraftStep3Snapshot | null;
|
|
/** 一次绘制完成时回调,把结果交给上层存进草稿 step3。 */
|
|
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
|
|
/** Resets state and navigates back to Step 1. */
|
|
onReset: () => void;
|
|
openArtifactPreview: (threadId: string, path: string) => void;
|
|
seatStubMessages: Record<string, unknown[]>;
|
|
}
|
|
|
|
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`;
|
|
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
}
|
|
|
|
function formatGeneratedAt(d: Date): string {
|
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
}
|
|
|
|
export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
|
threadIds,
|
|
coordinatorName,
|
|
consensusText,
|
|
hasConsensus,
|
|
seatDeliveries,
|
|
seatNameMap,
|
|
intentReady,
|
|
selectedModel,
|
|
availableModels,
|
|
modelsLoading,
|
|
setSelectedModel,
|
|
savedReport,
|
|
onReportSaved,
|
|
onReset,
|
|
openArtifactPreview,
|
|
seatStubMessages,
|
|
}) => {
|
|
// 「结果绘制」模式:把现有报告内容收起到左栏,右侧由方案可视化智能体绘制图表 HTML。
|
|
const consensusDocTitle = useMemo(() => {
|
|
const objective = intentReady?.objective?.trim();
|
|
if (!objective) return "圆桌总控最终结论";
|
|
const short = objective.length > 40 ? `${objective.slice(0, 40)}…` : objective;
|
|
return `圆桌总控结论-${short}`;
|
|
}, [intentReady?.objective]);
|
|
|
|
const [drawMode, setDrawMode] = useState(false);
|
|
const [fileSandboxMode, setFileSandboxMode] = useState(false);
|
|
const [fileSandboxTarget, setFileSandboxTarget] = useState<{
|
|
threadId: string;
|
|
file: SessionArtifactFile;
|
|
} | null>(null);
|
|
|
|
// 进入第三步即关闭并清空第二步遗留的沙箱(artifacts 面板全局共享,第二步打开后
|
|
// 不会自动关)。点「结果绘制」后 Step3ReportMode 会按需开自己的沙箱,互不串扰。
|
|
const { deselect: deselectArtifact } = useArtifacts();
|
|
useEffect(() => {
|
|
deselectArtifact();
|
|
}, [deselectArtifact]);
|
|
// Build the per-seat list (excludes the coordinator). useMemo so the file
|
|
// fetch effect doesn't re-fire on every parent render.
|
|
const seatThreadEntries = useMemo(() => {
|
|
if (!threadIds) return [] as Array<{ agentName: string; threadId: string; displayName: string }>;
|
|
return Object.entries(threadIds)
|
|
.filter(([name]) => !coordinatorName || name !== coordinatorName)
|
|
.map(([agentName, threadId]) => ({
|
|
agentName,
|
|
threadId,
|
|
displayName: seatNameMap[agentName.toLowerCase()] ?? agentName,
|
|
}));
|
|
}, [threadIds, coordinatorName, seatNameMap]);
|
|
|
|
// Map sender display name → its delivery content for the accordion rows.
|
|
// Built once per seatDeliveries change; later entries with the same sender
|
|
// win so the *latest* delivery wins.
|
|
const deliveryByDisplay = useMemo(() => {
|
|
const m = new Map<string, string>();
|
|
for (const d of seatDeliveries) m.set(d.sender, d.content);
|
|
return m;
|
|
}, [seatDeliveries]);
|
|
|
|
const [artifactsBySeat, setArtifactsBySeat] = useState<SeatArtifacts[]>([]);
|
|
const [expandedSeats, setExpandedSeats] = useState<Set<string>>(new Set());
|
|
const [reloadKey, setReloadKey] = useState(0);
|
|
const generatedAt = useMemo(() => formatGeneratedAt(new Date()), []);
|
|
|
|
// Fetch artifact listings for every seat thread in parallel. Re-runs when
|
|
// the user clicks "刷新文件清单" (reloadKey ticks) or when the thread set
|
|
// changes (rare — only on a true re-init).
|
|
useEffect(() => {
|
|
if (seatThreadEntries.length === 0) {
|
|
setArtifactsBySeat([]);
|
|
return;
|
|
}
|
|
|
|
const initial: SeatArtifacts[] = seatThreadEntries.map((s) => ({
|
|
...s,
|
|
files: [],
|
|
loading: true,
|
|
error: null,
|
|
}));
|
|
setArtifactsBySeat(initial);
|
|
|
|
const abort = new AbortController();
|
|
let cancelled = false;
|
|
|
|
(async () => {
|
|
const results = await Promise.all(
|
|
seatThreadEntries.map(async (seat) => {
|
|
try {
|
|
const res = await listSessionArtifacts(seat.threadId, { signal: abort.signal });
|
|
return { seat, files: res.files, error: null as string | null };
|
|
} catch (err) {
|
|
if ((err as Error).name === "AbortError") {
|
|
return { seat, files: [] as SessionArtifactFile[], error: null };
|
|
}
|
|
return { seat, files: [] as SessionArtifactFile[], error: (err as Error).message };
|
|
}
|
|
}),
|
|
);
|
|
if (cancelled) return;
|
|
setArtifactsBySeat(
|
|
results.map(({ seat, files, error }) => ({
|
|
...seat,
|
|
files,
|
|
loading: false,
|
|
error,
|
|
})),
|
|
);
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
abort.abort();
|
|
};
|
|
}, [seatThreadEntries, reloadKey]);
|
|
|
|
const totalFileCount = useMemo(
|
|
() => artifactsBySeat.reduce((sum, s) => sum + s.files.length, 0),
|
|
[artifactsBySeat],
|
|
);
|
|
|
|
const toggleSeat = useCallback((key: string) => {
|
|
setExpandedSeats((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(key)) next.delete(key);
|
|
else next.add(key);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const handleViewInSandbox = useCallback((threadId: string, file: SessionArtifactFile) => {
|
|
setFileSandboxTarget({ threadId, file });
|
|
setFileSandboxMode(true);
|
|
}, []);
|
|
|
|
const handleBackFromFileSandbox = useCallback(() => {
|
|
setFileSandboxMode(false);
|
|
setFileSandboxTarget(null);
|
|
deselectArtifact();
|
|
}, [deselectArtifact]);
|
|
|
|
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");
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
if (fileSandboxMode && fileSandboxTarget) {
|
|
return (
|
|
<Step3FileBrowseMode
|
|
consensusText={consensusText}
|
|
hasConsensus={hasConsensus}
|
|
consensusDocTitle={consensusDocTitle}
|
|
seatDeliveries={seatDeliveries}
|
|
artifactsBySeat={artifactsBySeat}
|
|
seatStubMessages={seatStubMessages}
|
|
openArtifactPreview={openArtifactPreview}
|
|
initialFile={fileSandboxTarget}
|
|
onBack={handleBackFromFileSandbox}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (drawMode) {
|
|
return (
|
|
<Step3ReportMode
|
|
threadIds={threadIds}
|
|
coordinatorName={coordinatorName}
|
|
consensusText={consensusText}
|
|
hasConsensus={hasConsensus}
|
|
seatDeliveries={seatDeliveries}
|
|
seatNameMap={seatNameMap}
|
|
intentReady={intentReady}
|
|
selectedModel={selectedModel}
|
|
availableModels={availableModels}
|
|
modelsLoading={modelsLoading}
|
|
setSelectedModel={setSelectedModel}
|
|
savedReport={savedReport}
|
|
onReportSaved={onReportSaved}
|
|
onBack={() => setDrawMode(false)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto text-slate-800">
|
|
{/* Header */}
|
|
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-gradient-to-r from-slate-50 to-white flex-wrap gap-3">
|
|
<div className="flex flex-wrap items-center gap-2.5">
|
|
<div className="flex items-center gap-2">
|
|
<CheckCircle className="w-5 h-5 text-emerald-500" />
|
|
<h3 className="text-sm font-black text-slate-900 tracking-tight">最终方案展示</h3>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
{hasConsensus && (
|
|
<span className="text-[10px] font-black text-emerald-700 bg-emerald-50 border border-emerald-200 px-2 py-0.5 rounded-full shadow-sm">
|
|
● 已达成共识
|
|
</span>
|
|
)}
|
|
<span className="text-[10px] font-mono text-slate-400 font-semibold bg-slate-100/80 px-2 py-0.5 rounded-lg">
|
|
生成时间:{generatedAt}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<select
|
|
id="step3-report-model-select-entry"
|
|
value={selectedModel}
|
|
onChange={(e) => setSelectedModel(e.target.value)}
|
|
disabled={modelsLoading || availableModels.length === 0}
|
|
title={
|
|
modelsLoading
|
|
? "正在加载模型列表…"
|
|
: availableModels.length === 0
|
|
? "未在 config.yaml 中配置任何模型"
|
|
: "切换结果绘制使用的推理模型"
|
|
}
|
|
className="shrink-0 max-w-[180px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[10.5px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
|
|
>
|
|
{availableModels.length === 0 && (
|
|
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
|
|
)}
|
|
{availableModels.map((m) => (
|
|
<option key={m.name} value={m.name}>
|
|
{m.display_name && m.display_name !== m.name
|
|
? `${m.display_name} (${m.name})`
|
|
: m.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
type="button"
|
|
onClick={() => setDrawMode(true)}
|
|
title="把研讨成果交给方案总结智能体,绘制带图表的可视化看板"
|
|
className="bg-primary text-white hover:bg-[var(--brand-900)] text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer shadow-sm shadow-primary/10"
|
|
>
|
|
<Sparkles className="w-3.5 h-3.5" />
|
|
结果绘制
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setReloadKey((k) => k + 1)}
|
|
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 cursor-pointer"
|
|
>
|
|
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
|
|
刷新文件清单
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-6 space-y-6">
|
|
{/* Section 1: Coordinator consensus conclusion */}
|
|
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
|
|
<div className="mb-3 flex items-center justify-between gap-2">
|
|
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest flex items-center gap-2 min-w-0">
|
|
<CheckCircle className="w-4 h-4 text-emerald-500 shrink-0" />
|
|
<span className="truncate">总控最终结论</span>
|
|
</h4>
|
|
<ConsensusDocxDownloadButton
|
|
markdown={consensusText}
|
|
title={consensusDocTitle}
|
|
className="shrink-0"
|
|
/>
|
|
</div>
|
|
{consensusText ? (
|
|
<div className="text-xs text-slate-700 leading-relaxed">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
|
{consensusText}
|
|
</ReactMarkdown>
|
|
</div>
|
|
) : (
|
|
<p className="text-[11px] text-slate-400 italic">
|
|
暂无总控结论。返回 Step 2 完成至少一轮研讨后再查看此页。
|
|
</p>
|
|
)}
|
|
</section>
|
|
|
|
{/* Section 2: Per-seat deliveries (collapsible) */}
|
|
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
|
|
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
|
|
<FileText className="w-4 h-4 text-primary" />
|
|
各席位最终交付
|
|
<span className="text-[10px] font-mono font-semibold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
|
|
{seatDeliveries.length}
|
|
</span>
|
|
</h4>
|
|
|
|
{seatDeliveries.length === 0 ? (
|
|
<p className="text-[11px] text-slate-400 italic">
|
|
本次研讨尚未产生子智能体交付。Leader 派活并收到子智能体回复后,该区块会自动填充。
|
|
</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{seatDeliveries.map((seat, idx) => {
|
|
const key = `${seat.sender}-${idx}`;
|
|
const expanded = expandedSeats.has(key);
|
|
return (
|
|
<div key={key} className="border border-slate-150 rounded-xl overflow-hidden">
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleSeat(key)}
|
|
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-slate-50 hover:bg-slate-100 transition text-left"
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
{expanded ? (
|
|
<ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
|
) : (
|
|
<ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
|
)}
|
|
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.sender}</span>
|
|
</div>
|
|
<span className="text-[10px] text-slate-400 font-mono shrink-0">
|
|
{seat.content.length} 字
|
|
</span>
|
|
</button>
|
|
{expanded && (
|
|
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
|
{seat.content || "(空)"}
|
|
</ReactMarkdown>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Section 3: Generated files per seat —— 点击「查看」进入沙箱分栏预览 */}
|
|
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
|
|
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
|
|
<Download className="w-4 h-4 text-primary" />
|
|
生成的报告文件
|
|
<span className="text-[10px] font-mono font-semibold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
|
|
{totalFileCount}
|
|
</span>
|
|
</h4>
|
|
|
|
{artifactsBySeat.length === 0 ? (
|
|
<p className="text-[11px] text-slate-400 italic">尚未初始化席位线程,无法列出文件。</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{artifactsBySeat.map((seat) => (
|
|
<div key={seat.agentName} className="border border-slate-150 rounded-xl">
|
|
<div className="flex items-center justify-between gap-2 px-3 py-2 bg-slate-50 border-b border-slate-150 rounded-t-xl">
|
|
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.displayName}</span>
|
|
<span className="text-[10px] text-slate-400 font-mono shrink-0">
|
|
{seat.loading ? "加载中…" : `${seat.files.length} 个文件`}
|
|
</span>
|
|
</div>
|
|
|
|
{seat.loading ? (
|
|
<div className="flex items-center gap-2 px-3 py-3 text-[11px] text-slate-500">
|
|
<Loader2 className="w-3 h-3 animate-spin text-primary" />
|
|
<span>正在查询 outputs 目录…</span>
|
|
</div>
|
|
) : seat.error ? (
|
|
<div className="px-3 py-3 text-[11px] text-rose-600">
|
|
读取失败:{seat.error}
|
|
</div>
|
|
) : seat.files.length === 0 ? (
|
|
<div className="px-3 py-3 text-[11px] text-slate-400 italic">
|
|
该席位未产生输出文件
|
|
</div>
|
|
) : (
|
|
<ul className="divide-y divide-slate-100">
|
|
{seat.files.map((f) => (
|
|
<li
|
|
key={f.path}
|
|
className="px-3 py-2 flex items-center justify-between gap-2 hover:bg-slate-50/60"
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
|
<span className="text-[11.5px] font-medium text-slate-800 truncate" title={f.name}>
|
|
{f.name}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<span className="text-[10px] font-mono text-slate-400">
|
|
{formatBytes(f.sizeBytes)}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleViewInSandbox(seat.threadId, f)}
|
|
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
|
|
title="在沙箱中查看"
|
|
>
|
|
<Eye className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDownload(seat.threadId, f)}
|
|
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
|
|
title="下载"
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Footer */}
|
|
<div className="pt-2 border-t border-slate-100 flex items-center justify-between flex-wrap gap-4">
|
|
<span className="text-xs text-slate-400 font-medium">
|
|
如需重新立项,可清空当前研讨,回到任务理解阶段重新起草。
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={onReset}
|
|
className="border border-slate-700 text-slate-700 hover:text-slate-900 hover:bg-slate-50 text-[10.5px] font-bold px-4 py-3 rounded-2xl transition cursor-pointer select-none"
|
|
>
|
|
重新起草新规划
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
);
|
|
};
|