Files
deerflow-code/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx
T
董宏凯andCursor 4ac7bc21cd feat: 优化对话历史与会商引导体验
列表引导改为手动触发且不跳转创建页;历史 Tab 展示会商步骤/链条与深度研究模板;Step2 增加暂停与内容高度跟随滚动,并修复旧草稿恢复兜底。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 20:25:21 +08:00

1019 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Step 3「结果绘制」模式。
*
* 布局对齐第 2 步:
* - 左栏(~300px,第 2 步白卡风格):把原本的最终方案内容「收起」成三张源卡
* (总控结论 / 各席位交付 / 各席位生成文件),点击才弹窗展开。
* - 右栏:复用 `Step2SandboxLayout`(与主聊天同一套沙箱)。左 60% 显示方案总结
* 智能体的流式绘制进度;右 40% 沙箱在它写 HTML 时显示代码、写完自动切 HTML 预览。
*
* 进入即自动开跑(`useStep3Report.start`),右上角「重新绘制」可重跑。
*/
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
ArrowLeft,
CheckCircle,
Download,
Eye,
FileText,
LayoutDashboard,
ListOrdered,
Loader2,
PanelLeftClose,
PanelLeftOpen,
Pause,
RefreshCw,
SendHorizontal,
Sparkles,
X,
} from "lucide-react";
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { useArtifacts } from "@/components/workspace/artifacts";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import {
buildArtifactDownloadUrl,
listSessionArtifacts,
type SessionArtifactFile,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import { TaskArtifactSubmitButton } from "@/roundtable-planning/components/TaskArtifactSubmitButton";
import { desensitize } from "@/lib/desensitize";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { Step3DashboardMode } from "@/roundtable-planning/components/Step3DashboardMode";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import { isDashboardHtml } from "@/roundtable-planning/lib/report-template";
import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard";
import type {
ReportIntent,
UseStep3ReportReturn,
} from "@/roundtable-planning/hooks/useStep3Report";
import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission";
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;
/** 草稿里已存的上次绘制结果 —— 有则进入时恢复展示,不重新绘制。 */
savedReport?: DraftStep3Snapshot | null;
/** 页面层常驻的经典绘制流(2026-06-13 提升:切走不断流,进入续看)。 */
report: UseStep3ReportReturn;
/** 页面层常驻的大屏多页流(模式二)。 */
dashboard: UseStep3DashboardReturn;
onBack: () => void;
taskArtifactSubmission?: TaskArtifactSubmissionController;
}
type SourceModal = "consensus" | "seats" | "files" | null;
export function Step3ReportMode({
threadIds,
coordinatorName,
consensusText,
hasConsensus,
seatDeliveries,
seatNameMap,
intentReady,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
savedReport,
report,
dashboard,
onBack,
taskArtifactSubmission,
}: Step3ReportModeProps) {
const reportSeatDeliveries = useMemo(
() => seatDeliveries.map((d) => ({ sender: d.sender, content: d.content })),
[seatDeliveries],
);
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]);
// 生成流来自页面层常驻 hook(切走不断流,进入本视图续看进行中过程/结果)。
const {
phase,
error,
reportThreadId,
dialogues,
seatStubMessages,
activeArtifactThreadId,
start,
hydrateSaved,
redraw,
pause,
interject,
themeMismatch,
openArtifactFromStep,
openArtifactPreview,
} = report;
const [modal, setModal] = useState<SourceModal>(null);
// 左栏「研讨原料」收起态:打开沙箱时默认收起成图标条(对齐第 2 步侧栏收起),
// 关闭沙箱时自动展开;中途用户可手动切换(effect 只在沙箱开关变化的边沿触发)。
const { open: artifactsOpen } = useArtifacts();
const [leftCollapsed, setLeftCollapsed] = useState(false);
const prevArtifactsOpenRef = useRef(artifactsOpen);
useEffect(() => {
if (artifactsOpen !== prevArtifactsOpenRef.current) {
prevArtifactsOpenRef.current = artifactsOpen;
setLeftCollapsed(artifactsOpen);
}
}, [artifactsOpen]);
// ── 绘制模式(2026-06-13):classic = 经典单页看板(模型自由写整页 HTML);
// dashboard = 大屏多页(模型只出 report-json,模板渲染)。两条流现在**并行常驻**
// (页面进入第三步即三流齐跑),进入本页默认落经典视图;仅旧版草稿「只有大屏 HTML
// 存在 html 槽位(带 marker)」时直接落大屏。
const [dashboardMode, setDashboardMode] = useState(() => isDashboardHtml(savedReport?.html));
// 预览开关:经典绘制视图在前台(非大屏模式)时本流才允许自动打开沙箱(并行流防抢);
// 进入时若已有产物/进行中会立即接管沙箱续看。
useEffect(() => {
if (dashboardMode) return;
report.setPreviewEnabled(true);
return () => report.setPreviewEnabled(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboardMode]);
// 续看语义(2026-06-13):hook 常驻页面层,进入第三步时页面已统一编排(总结 +
// 经典绘制并行启动)。这里只兜底"从未启动过"(idle)的场景,**卸载不再 abort**——
// 切回总结报告 / 回第二步,绘制流照跑,回头继续看。
useEffect(() => {
if (dashboardMode || phase !== "idle") return;
if (savedReport?.html && !isDashboardHtml(savedReport.html)) {
hydrateSaved(savedReport);
} else {
start();
}
// 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"
? "绘制失败"
: phase === "paused"
? "已暂停"
: "待绘制";
// ── 模式二:大屏多页(模板渲染)。经典模式的所有 hooks 已在上方调用完毕,
// 这里早退渲染不违反 hooks 规则。 ──
if (dashboardMode) {
return (
<Step3DashboardMode
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
savedReport={savedReport}
dashboard={dashboard}
onSwitchClassic={() => {
// 切回经典:**不打断**大屏流(切走不断流);经典从未启动则补一次。
setDashboardMode(false);
if (phase === "idle") {
if (savedReport?.html && !isDashboardHtml(savedReport.html)) {
hydrateSaved(savedReport);
} else {
start();
}
}
}}
onBack={onBack}
/>
);
}
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">
{(phase === "creating" || phase === "streaming") && (
<button
type="button"
onClick={pause}
title="暂停绘制(沙箱保留已生成内容,之后可输入修改要求继续或重新绘制)"
className="bg-white border border-amber-200 text-amber-700 hover:bg-amber-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<Pause className="w-3.5 h-3.5" />
暂停
</button>
)}
<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={() => setDashboardMode(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 shadow-sm shadow-primary/10"
>
<LayoutDashboard className="w-3.5 h-3.5" />
大屏多页
</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">
{/* 左栏:研讨原料源卡。打开沙箱时收起成 52px 图标条(对齐第 2 步侧栏收起样式)。 */}
<aside
className={cn(
"shrink-0 flex flex-col gap-3 overflow-y-auto pr-0.5 transition-[width] duration-200 ease-out",
leftCollapsed ? "w-[52px]" : "w-[280px]",
)}
>
{leftCollapsed ? (
<>
<button
type="button"
onClick={() => setLeftCollapsed(false)}
title="展开研讨原料"
aria-expanded={false}
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>
<CollapsedSourceCard
icon={<CheckCircle className="w-4 h-4 text-emerald-500" />}
title="总控最终结论"
disabled={!consensusText}
onClick={() => setModal("consensus")}
/>
<CollapsedSourceCard
icon={<FileText className="w-4 h-4 text-primary" />}
title="各席位最终交付"
disabled={seatDeliveries.length === 0}
onClick={() => setModal("seats")}
/>
<CollapsedSourceCard
icon={<Download className="w-4 h-4 text-primary" />}
title="各席位生成文件"
onClick={() => setModal("files")}
/>
</>
) : (
<>
<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="收起研讨原料"
aria-expanded
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>
<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}
taskArtifactSubmission={taskArtifactSubmission}
>
<AgentProgressPanel
phase={phase}
dialogues={dialogues}
error={error}
themeMismatch={themeMismatch}
onRetry={() => redraw()}
onPause={pause}
onRevisionSubmit={interject}
onArtifactStepClick={openArtifactFromStep}
onArtifactPreview={openArtifactPreview}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
taskArtifactSubmission={taskArtifactSubmission}
/>
</Step2SandboxLayout>
</section>
</div>
{/* 弹窗 */}
{modal === "consensus" && (
<ContentModal
title="总控最终结论"
onClose={() => setModal(null)}
headerAction={
<ConsensusDocxDownloadButton
markdown={consensusText}
title={consensusDocTitle}
/>
}
>
{consensusText ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{desensitize(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}
taskArtifactSubmission={taskArtifactSubmission}
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>
);
}
// 收起态的源卡:仅图标,居中,点击仍打开对应弹窗(对齐第 2 步 CollapsedSidebarSection 样式)。
function CollapsedSourceCard({
icon,
title,
disabled,
onClick,
}: {
icon: React.ReactNode;
title: string;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
title={title}
disabled={disabled}
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 disabled:opacity-40 disabled:cursor-not-allowed"
>
{icon}
</button>
);
}
// ── 右栏:智能体进度面板(沙箱的 60% 区域)────────────────────────────────
function AgentProgressPanel({
phase,
dialogues,
error,
themeMismatch,
onRetry,
onPause,
onRevisionSubmit,
onArtifactStepClick,
onArtifactPreview,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
taskArtifactSubmission,
}: {
phase: UseStep3ReportReturn["phase"];
dialogues: UseStep3ReportReturn["dialogues"];
error: string | null;
themeMismatch: boolean;
onRetry: () => void;
/** 输入框「暂停」键 —— 绘制中显示,中断在途请求并把当前气泡收尾为「已暂停」。 */
onPause: () => void;
onRevisionSubmit: (revisionInstruction: string) => void;
onArtifactStepClick: UseStep3ReportReturn["openArtifactFromStep"];
onArtifactPreview: UseStep3ReportReturn["openArtifactPreview"];
selectedModel: string;
availableModels: ReadonlyArray<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
taskArtifactSubmission?: TaskArtifactSubmissionController;
}) {
const busy = phase === "creating" || phase === "streaming";
// 只有「正在建线程」时锁输入(线程没建好没法插话);绘制中允许输入并发送——
// 发送会先打断当前轮,再按新要求继续(与第 3 步总结报告的随时插话一致)。
const inputLocked = phase === "creating";
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const dialogueMetrics = useDialogueSpeedMetrics(dialogues);
const [revisionText, setRevisionText] = useState("");
const handleRevisionSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const text = revisionText.trim();
if (!text || inputLocked) return;
setRevisionText("");
onRevisionSubmit(text);
};
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 overflow-x-hidden 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}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
onArtifactStepClick={(step) => onArtifactStepClick(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
}}
taskArtifactSubmission={taskArtifactSubmission}
/>
))}
{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 === "paused" && (
<div className="flex items-center gap-2 text-xs font-bold text-amber-600">
<Pause className="w-4 h-4" />
已暂停。可在下方输入修改要求继续,或点右上角「重新绘制」。
</div>
)}
{phase === "done" && (
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
<CheckCircle className="w-4 h-4" />
已生成可视化方案总览,右侧可查看 / 全屏 / 下载。
</div>
)}
{themeMismatch && (
<div className="flex items-center justify-between gap-2 rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-700">
<span className="leading-relaxed">
界面主题已切换,这份报告还是旧主题的配色。
</span>
<button
type="button"
onClick={onRetry}
className="shrink-0 inline-flex items-center gap-1 text-[11px] font-bold text-amber-700 underline hover:text-amber-800"
>
<RefreshCw className="w-3 h-3" />
按当前主题重新绘制
</button>
</div>
)}
</div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
<form
onSubmit={handleRevisionSubmit}
className="shrink-0 border-t border-slate-100 bg-white px-3 py-3"
>
<div className="flex items-end gap-2 rounded-2xl border border-slate-200 bg-slate-50/70 px-3 py-2 focus-within:border-primary/60 focus-within:bg-white focus-within:ring-2 focus-within:ring-[var(--brand-200)]/40">
<AutoGrowTextarea
maxHeight={224}
value={revisionText}
onChange={(e) => setRevisionText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
}}
disabled={inputLocked}
rows={1}
placeholder={
busy
? "随时输入修改要求——发送会打断当前绘制并按新要求继续"
: "输入修改要求,例如:增加风险矩阵,执行路线改成时间轴"
}
className="min-h-8 max-h-56 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
/>
{busy && (
<button
type="button"
onClick={onPause}
title="暂停当前绘制(沙箱保留已生成内容,之后可输入修改要求继续)"
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-amber-100 border border-amber-200/60 text-amber-700 hover:bg-amber-200 transition active:scale-95"
>
<Pause className="h-4 w-4" />
</button>
)}
<button
type="submit"
disabled={inputLocked || revisionText.trim().length === 0}
title={
inputLocked
? "正在初始化线程,请稍候"
: busy
? "发送修改要求(将打断当前绘制并按新要求继续)"
: "发送修改要求"
}
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
>
{inputLocked ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<SendHorizontal className="h-4 w-4" />
)}
</button>
</div>
</form>
</div>
</div>
);
}
// ── 弹窗外壳 ─────────────────────────────────────────────────────────────
function ContentModal({
title,
children,
onClose,
headerAction,
}: {
title: string;
children: React.ReactNode;
onClose: () => void;
headerAction?: React.ReactNode;
}) {
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 gap-2 px-5 py-3.5 border-b border-slate-100 bg-slate-50">
<h3 className="text-sm font-bold text-slate-900 min-w-0 truncate">{title}</h3>
<div className="flex items-center gap-2 shrink-0">
{headerAction}
<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>
<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">{desensitize(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}>
{desensitize(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|mmd|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,
taskArtifactSubmission,
onClose,
}: {
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
seatNameMap: Record<string, string>;
taskArtifactSubmission?: TaskArtifactSubmissionController;
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>
<TaskArtifactSubmitButton
compact
controller={taskArtifactSubmission}
target={taskArtifactSubmission?.targetFor({
threadId: seat.threadId,
path: f.path,
name: f.name,
mimeType: f.mimeType,
agentId: seat.agentName,
agentName: seat.displayName,
}) ?? {
threadId: seat.threadId,
path: f.path,
name: f.name,
mimeType: f.mimeType,
agentId: seat.agentName,
agentName: seat.displayName,
}}
/>
<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)}
/>
)}
</>
);
}