列表引导改为手动触发且不跳转创建页;历史 Tab 展示会商步骤/链条与深度研究模板;Step2 增加暂停与内容高度跟随滚动,并修复旧草稿恢复兜底。 Co-authored-by: Cursor <cursoragent@cursor.com>
1019 lines
41 KiB
TypeScript
1019 lines
41 KiB
TypeScript
/**
|
||
* 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)}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|