新增 iframe 结果接口与圆桌 Step3 结果绘制

- 公开 API:GET /api/public/embed/sessions/{sessionId}/result 返回 messages、generated_text、generated_files
- 圆桌 Step3:内置 roundtable-report 智能体、report/init 与结果绘制 UI

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-06-03 13:57:37 +08:00
1 parent 0727b4564b
commit 40ba81ee6a
16 files changed
+1771 -50

No files matched your search

@@ -0,0 +1,682 @@
/**
* Step 3「结果绘制」模式。
*
* 布局对齐第 2 步:
* - 左栏(~300px,第 2 步白卡风格):把原本的最终方案内容「收起」成三张源卡
* (总控结论 / 各席位交付 / 各席位生成文件),点击才弹窗展开。
* - 右栏:复用 `Step2SandboxLayout`(与主聊天同一套沙箱)。左 60% 显示方案总结
* 智能体的流式绘制进度;右 40% 沙箱在它写 HTML 时显示代码、写完自动切 HTML 预览。
*
* 进入即自动开跑(`useStep3Report.start`),右上角「重新绘制」可重跑。
*/
import { useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
ArrowLeft,
CheckCircle,
Download,
Eye,
FileText,
ListOrdered,
Loader2,
RefreshCw,
Sparkles,
X,
} from "lucide-react";
import {
buildArtifactDownloadUrl,
listSessionArtifacts,
type SessionArtifactFile,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import {
useStep3Report,
type ReportIntent,
} from "@/roundtable-planning/hooks/useStep3Report";
interface SeatDelivery {
sender: string;
avatarType: string;
content: string;
}
export interface ModelOption {
name: string;
display_name?: string;
}
interface Step3ReportModeProps {
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
consensusText: string;
hasConsensus: boolean;
seatDeliveries: SeatDelivery[];
seatNameMap: Record<string, string>;
intentReady: ReportIntent | null;
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
onBack: () => void;
}
type SourceModal = "consensus" | "seats" | "files" | null;
export function Step3ReportMode({
threadIds,
coordinatorName,
consensusText,
hasConsensus,
seatDeliveries,
seatNameMap,
intentReady,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
onBack,
}: Step3ReportModeProps) {
const reportSeatDeliveries = useMemo(
() => seatDeliveries.map((d) => ({ sender: d.sender, content: d.content })),
[seatDeliveries],
);
const {
phase,
error,
reportThreadId,
dialogues,
seatStubMessages,
activeArtifactThreadId,
start,
redraw,
abort,
openArtifactFromStep,
openArtifactPreview,
} = useStep3Report({
intentReady,
seatDeliveries: reportSeatDeliveries,
consensusText,
selectedModel,
});
const [modal, setModal] = useState<SourceModal>(null);
// 进入即自动开跑;卸载(含返回普通视图)中断在途请求。
// 不加"永久闸门"——否则 StrictMode 的 mount→unmount→remount 会在 cleanup 里 abort
// 掉首次 run,remount 又因闸门跳过重启,导致永远不绘制。run() 自带 runningRef 去重,
// remount 重新 start() 即可(开发期至多多建一个空线程,生产无 StrictMode 只跑一次)。
useEffect(() => {
start();
return () => abort();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 与第 2 步一致:编排中或有任意气泡仍在流式 → 沙箱开启 artifacts 轮询 + HTML 显示代码态。
const isStreaming =
phase === "creating" ||
phase === "streaming" ||
dialogues.some((d) => d.streaming === true);
const activeSeatMessages = activeArtifactThreadId
? seatStubMessages[activeArtifactThreadId]
: undefined;
const statusLabel =
phase === "creating"
? "正在初始化绘制线程…"
: phase === "streaming"
? "方案总结智能体绘制中…"
: phase === "done"
? "绘制完成"
: phase === "error"
? "绘制失败"
: "待绘制";
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">
<Sparkles className="w-5 h-5 text-primary" />
<h3 className="text-sm font-black text-slate-900 tracking-tight">结果绘制 · 方案可视化</h3>
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold text-primary bg-[var(--brand-200)]/60 border border-red-200/50 px-2 py-0.5 rounded-full">
{(phase === "creating" || phase === "streaming") && (
<Loader2 className="w-3 h-3 animate-spin" />
)}
{phase === "done" && <CheckCircle className="w-3 h-3 text-emerald-600" />}
{statusLabel}
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={redraw}
disabled={phase === "creating" || phase === "streaming"}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新绘制
</button>
<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"
>
<ArrowLeft className="w-3.5 h-3.5 text-slate-500" />
返回报告
</button>
</div>
</div>
{/* 主体:左源卡 + 右沙箱 */}
<div className="flex-1 min-h-0 flex gap-3 p-3">
{/* 左栏:收起的源内容 */}
<aside className="w-[280px] shrink-0 flex flex-col gap-3 overflow-y-auto pr-0.5">
<SourceCard
icon={<CheckCircle className="w-4 h-4 text-emerald-500" />}
title="总控最终结论"
hint={consensusText ? "点击查看结论全文" : "暂无结论"}
badge={hasConsensus ? "已共识" : undefined}
disabled={!consensusText}
onClick={() => setModal("consensus")}
/>
<SourceCard
icon={<FileText className="w-4 h-4 text-primary" />}
title="各席位最终交付"
hint="点击逐席位查看交付"
badge={String(seatDeliveries.length)}
disabled={seatDeliveries.length === 0}
onClick={() => setModal("seats")}
/>
<SourceCard
icon={<Download className="w-4 h-4 text-primary" />}
title="各席位生成文件"
hint="点击查看/预览产出文件"
onClick={() => setModal("files")}
/>
<div className="mt-1 rounded-2xl border border-dashed border-slate-200 bg-slate-50/60 p-3 text-[11px] leading-relaxed text-slate-400">
原研讨内容已收起到这里,随时点开查阅。右侧由「方案总结智能体」据此绘制可视化看板。
</div>
</aside>
{/* 右栏:智能体进度 + 沙箱(复用第 2 步) */}
<section className="flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-white overflow-hidden">
<Step2SandboxLayout
activeThreadId={activeArtifactThreadId ?? reportThreadId}
isStreaming={isStreaming}
seatMessages={activeSeatMessages}
>
<AgentProgressPanel
phase={phase}
dialogues={dialogues}
error={error}
onRetry={redraw}
onArtifactStepClick={openArtifactFromStep}
onArtifactPreview={openArtifactPreview}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
/>
</Step2SandboxLayout>
</section>
</div>
{/* 弹窗 */}
{modal === "consensus" && (
<ContentModal title="总控最终结论" onClose={() => setModal(null)}>
{consensusText ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText}
</ReactMarkdown>
) : (
<p className="text-xs text-slate-400 italic">暂无总控结论。</p>
)}
</ContentModal>
)}
{modal === "seats" && (
<ContentModal title={`各席位最终交付(${seatDeliveries.length})`} onClose={() => setModal(null)}>
<SeatDeliveriesView seatDeliveries={seatDeliveries} />
</ContentModal>
)}
{modal === "files" && (
<SeatFilesModal
threadIds={threadIds}
coordinatorName={coordinatorName}
seatNameMap={seatNameMap}
onClose={() => setModal(null)}
/>
)}
</div>
);
}
// ── 左栏源卡 ─────────────────────────────────────────────────────────────
function SourceCard({
icon,
title,
hint,
badge,
disabled,
onClick,
}: {
icon: React.ReactNode;
title: string;
hint: string;
badge?: string;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="text-left bg-white border border-slate-200/60 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:border-primary/40 hover:shadow-[0_4px_14px_rgba(0,0,0,0.04)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-slate-200/60"
>
<div className="flex items-center gap-2 mb-1.5">
{icon}
<h4 className="text-xs font-bold text-slate-900 tracking-wide flex-1 truncate">{title}</h4>
{badge && (
<span className="text-[10px] font-mono font-semibold text-primary bg-[var(--brand-200)]/50 px-1.5 py-0.5 rounded">
{badge}
</span>
)}
</div>
<p className="text-[11px] text-slate-400 leading-normal">{hint}</p>
</button>
);
}
// ── 右栏:智能体进度面板(沙箱的 60% 区域)────────────────────────────────
function AgentProgressPanel({
phase,
dialogues,
error,
onRetry,
onArtifactStepClick,
onArtifactPreview,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
}: {
phase: ReturnType<typeof useStep3Report>["phase"];
dialogues: ReturnType<typeof useStep3Report>["dialogues"];
error: string | null;
onRetry: () => void;
onArtifactStepClick: ReturnType<typeof useStep3Report>["openArtifactFromStep"];
onArtifactPreview: ReturnType<typeof useStep3Report>["openArtifactPreview"];
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
}) {
const busy = phase === "creating" || phase === "streaming";
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
return (
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">
<div className="shrink-0 flex items-center justify-between gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 shrink-0 rounded-lg bg-gradient-to-tr from-primary to-[var(--brand-200)] flex items-center justify-center text-white text-xs shadow-sm">
📊
</div>
<div className="leading-tight min-w-0">
<div className="text-xs font-bold text-slate-800 truncate">方案总结智能体</div>
<div className="text-[10px] text-slate-400 truncate">据各席位成果绘制可视化 HTML 看板</div>
</div>
</div>
<select
id="step3-report-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={busy || modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: busy
? "绘制中不可切换;完成后或重新绘制时生效"
: "切换绘制使用的推理模型(下次绘制生效)"
}
className="shrink-0 max-w-[180px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[11px] 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>
</div>
{/* 消息列表 —— 与第 2 步同款气泡(流式正文 + 工具步骤卡 + 文件卡)。 */}
<div className="relative flex-1 min-h-0 flex flex-col">
<div
ref={scrollRef}
className="flex-1 min-h-0 overflow-y-auto p-4 space-y-4 bg-white"
>
{phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
正在初始化绘制线程…
</div>
)}
{dialogues.map((msg) => (
<Step2Message
key={msg.id}
msg={msg}
onArtifactStepClick={(step) => onArtifactStepClick(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
}}
/>
))}
{busy && (
<div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500">
<span className="bg-primary size-1.5 animate-pulse rounded-full" />
右侧沙箱将实时显示绘制代码,完成后自动切换 HTML 预览
</div>
)}
{phase === "error" && (
<div className="rounded-xl border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">
<div className="font-bold mb-1">绘制失败</div>
<div className="leading-relaxed break-all">{error}</div>
<button
type="button"
onClick={onRetry}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-bold text-rose-700 underline hover:text-rose-800"
>
<RefreshCw className="w-3 h-3" />
重试
</button>
</div>
)}
{phase === "done" && (
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
<CheckCircle className="w-4 h-4" />
已生成可视化方案总览,右侧可查看 / 全屏 / 下载。
</div>
)}
</div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
</div>
</div>
);
}
// ── 弹窗外壳 ─────────────────────────────────────────────────────────────
function ContentModal({
title,
children,
onClose,
}: {
title: string;
children: React.ReactNode;
onClose: () => void;
}) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-[1px] p-4"
onClick={onClose}
>
<div
className="bg-white border border-slate-200 rounded-2xl shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="shrink-0 flex items-center justify-between px-5 py-3.5 border-b border-slate-100 bg-slate-50">
<h3 className="text-sm font-bold text-slate-900">{title}</h3>
<button
type="button"
onClick={onClose}
className="p-1 text-slate-400 hover:text-slate-600 rounded-md hover:bg-slate-100"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-5">{children}</div>
</div>
</div>
);
}
// ── 各席位交付(弹窗内的折叠列表)────────────────────────────────────────
function SeatDeliveriesView({ seatDeliveries }: { seatDeliveries: SeatDelivery[] }) {
const [openIdx, setOpenIdx] = useState<number | null>(0);
if (seatDeliveries.length === 0) {
return <p className="text-xs text-slate-400 italic">本次研讨尚未产生子智能体交付。</p>;
}
return (
<div className="space-y-2">
{seatDeliveries.map((seat, idx) => {
const open = openIdx === idx;
return (
<div key={`${seat.sender}-${idx}`} className="border border-slate-150 rounded-xl overflow-hidden">
<button
type="button"
onClick={() => setOpenIdx(open ? null : idx)}
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">
<ListOrdered className="w-3.5 h-3.5 text-slate-400 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>
{open && (
<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>
);
}
// ── 各席位生成文件(弹窗,按需拉取 outputs 列表)──────────────────────────
interface SeatArtifacts {
agentName: string;
displayName: string;
threadId: string;
files: SessionArtifactFile[];
loading: boolean;
error: string | null;
}
function shouldPreviewInModal(file: { name: string; mimeType: string | null }): boolean {
const mime = file.mimeType ?? "";
if (mime.startsWith("text/")) return true;
if (["application/json"].includes(mime)) return true;
return /\.(md|markdown|txt|json|csv|log|yml|yaml|xml|html?)$/i.test(file.name);
}
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`;
}
function SeatFilesModal({
threadIds,
coordinatorName,
seatNameMap,
onClose,
}: {
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
seatNameMap: Record<string, string>;
onClose: () => void;
}) {
const seatEntries = 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]);
const [bySeat, setBySeat] = useState<SeatArtifacts[]>([]);
const [previewing, setPreviewing] = useState<{ threadId: string; file: SessionArtifactFile } | null>(
null,
);
useEffect(() => {
if (seatEntries.length === 0) {
setBySeat([]);
return;
}
setBySeat(seatEntries.map((s) => ({ ...s, files: [], loading: true, error: null })));
const abort = new AbortController();
let cancelled = false;
(async () => {
const results = await Promise.all(
seatEntries.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;
setBySeat(results.map(({ seat, files, error }) => ({ ...seat, files, loading: false, error })));
})();
return () => {
cancelled = true;
abort.abort();
};
}, [seatEntries]);
const handlePreview = (threadId: string, file: SessionArtifactFile) => {
if (shouldPreviewInModal({ name: file.name, mimeType: file.mimeType })) {
setPreviewing({ threadId, file });
return;
}
if (typeof window !== "undefined") {
window.open(buildArtifactDownloadUrl(threadId, file.path), "_blank", "noopener,noreferrer");
}
};
const handleDownload = (threadId: string, file: SessionArtifactFile) => {
if (typeof window !== "undefined") {
window.open(
buildArtifactDownloadUrl(threadId, file.path, { download: true }),
"_blank",
"noopener,noreferrer",
);
}
};
return (
<>
<ContentModal title="各席位生成文件" onClose={onClose}>
{bySeat.length === 0 ? (
<p className="text-xs text-slate-400 italic">尚未初始化席位线程,无法列出文件。</p>
) : (
<div className="space-y-3">
{bySeat.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" />
正在查询 outputs 目录…
</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={() => handlePreview(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>
)}
</ContentModal>
{previewing && (
<ArtifactPreviewModal
threadId={previewing.threadId}
path={previewing.file.path}
name={previewing.file.name}
mimeType={previewing.file.mimeType}
onClose={() => setPreviewing(null)}
/>
)}
</>
);
}