- 区域级脱敏 Hook useDesensitizeSubtree:对左右侧面板、顶部标题、 业务链条选择器/列表弹窗/列表页/配置页/映射页整片脱敏(文本节点, 跳过 input/textarea 等可编辑控件) - 消息列表正文保持原文,仅智能体名称脱敏(senderName / 席位名 / 流程图节点) - Step3 总结/报告/大屏、推荐弹窗等结果面板脱敏 - 沙箱产物预览不脱敏:ArtifactFileDetail 加 disableDesensitize,会商沙箱传入 - desensitize.ts 新增 desensitizeDeep 递归脱敏结构化数据 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1045 lines
42 KiB
TypeScript
1045 lines
42 KiB
TypeScript
/**
|
||
* Step 3「总结报告」模式 —— 第 3 步**主页**(进第三步默认自动跑这个)。
|
||
*
|
||
* 由内置的「方案总结报告智能体」(`roundtable-summary`) 把各席位交付 + 总控共识
|
||
* 总结成一份 **Markdown 报告**(写入 outputs/方案总结报告.md),报告正文同时流式
|
||
* 输出到左侧对话区;右侧沙箱预览 md 文件。底部输入框让用户继续对话——智能体自行
|
||
* 判断是「增量改报告」还是「问答」。
|
||
*
|
||
* 布局对齐 Step3ReportMode(HTML 绘制):左栏研讨原料源卡 + 右栏沙箱。两者并存、
|
||
* 互不影响(report 画 HTML 看板,summary 写 Markdown 报告 + 问答)。
|
||
*/
|
||
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||
import ReactMarkdown from "react-markdown";
|
||
import remarkGfm from "remark-gfm";
|
||
import {
|
||
ArrowRight,
|
||
CheckCircle,
|
||
Download,
|
||
Eye,
|
||
FileText,
|
||
Loader2,
|
||
PanelLeftClose,
|
||
PanelLeftOpen,
|
||
Paperclip,
|
||
Pause,
|
||
RefreshCw,
|
||
SendHorizontal,
|
||
Sparkles,
|
||
Workflow,
|
||
X,
|
||
} from "lucide-react";
|
||
|
||
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 { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel";
|
||
import {
|
||
detectFlowIntent,
|
||
extractFlowGraph,
|
||
stripFlowFence,
|
||
FLOW_FENCE_OPEN,
|
||
} from "@/roundtable-planning/lib/flow-graph";
|
||
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
||
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
|
||
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
||
import { desensitize } from "@/lib/desensitize";
|
||
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
|
||
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
|
||
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
|
||
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
|
||
import { useStep3Summary, type UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary";
|
||
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
|
||
|
||
interface SeatDelivery {
|
||
sender: string;
|
||
avatarType: string;
|
||
content: string;
|
||
}
|
||
|
||
interface Step3SummaryModeProps {
|
||
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;
|
||
/** 业务链写给总控的编排提示 —— 注入总结智能体作为最高优先级口径。 */
|
||
coordinatorPrompt?: string | null;
|
||
/** 草稿里已存的上次总结结果 —— 有则进入时恢复展示,不重新生成。 */
|
||
savedSummary?: DraftStep3SummarySnapshot | null;
|
||
/** 页面层常驻的总结生成流(2026-06-13 提升:切走不断流,进入本视图续看)。 */
|
||
summary: UseStep3SummaryReturn;
|
||
/** 切到「最终方案展示」静态页(次要入口)。 */
|
||
onViewStatic: () => void;
|
||
}
|
||
|
||
type SourceModal = "consensus" | "seats" | "files" | null;
|
||
|
||
export function Step3SummaryMode({
|
||
threadIds,
|
||
coordinatorName,
|
||
consensusText,
|
||
hasConsensus,
|
||
seatDeliveries,
|
||
seatNameMap,
|
||
intentReady,
|
||
selectedModel,
|
||
availableModels,
|
||
modelsLoading,
|
||
setSelectedModel,
|
||
coordinatorPrompt,
|
||
savedSummary,
|
||
summary,
|
||
onViewStatic,
|
||
}: Step3SummaryModeProps) {
|
||
void coordinatorPrompt; // 选项已随 hook 提升到页面层,这里仅保留 prop 兼容
|
||
|
||
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,
|
||
summaryThreadId,
|
||
dialogues,
|
||
seatStubMessages,
|
||
activeArtifactThreadId,
|
||
busy,
|
||
start,
|
||
hydrateSaved,
|
||
redraw,
|
||
ask,
|
||
pause,
|
||
openArtifactFromStep,
|
||
openArtifactPreview,
|
||
} = summary;
|
||
|
||
const [modal, setModal] = useState<SourceModal>(null);
|
||
|
||
const { open: artifactsOpen } = useArtifacts();
|
||
|
||
// ── 结构化流程图:流式提取回复末尾的 flow-json,增量绘制 ──────────────────
|
||
// 取最后一条含 flow-json 的智能体气泡(多轮修改后以最新一份为准)。
|
||
const flowGraph = useMemo(() => {
|
||
for (let i = dialogues.length - 1; i >= 0; i--) {
|
||
const d = dialogues[i];
|
||
if (d.avatarType === "human") continue;
|
||
const content = d.content ?? "";
|
||
if (!content.includes(FLOW_FENCE_OPEN)) continue;
|
||
const graph = extractFlowGraph(content);
|
||
if (graph) return { graph, streaming: d.streaming === true };
|
||
}
|
||
return null;
|
||
}, [dialogues]);
|
||
|
||
const [flowView, setFlowView] = useState(false);
|
||
const flowAutoOpenedRef = useRef(false);
|
||
useEffect(() => {
|
||
if (!flowGraph || flowGraph.graph.nodes.length === 0) return;
|
||
if (flowAutoOpenedRef.current) return;
|
||
flowAutoOpenedRef.current = true;
|
||
// 没开沙箱 → 自动打开流程图画布;沙箱开着 → 不抢占,只露出切换按钮。
|
||
if (!artifactsOpen) setFlowView(true);
|
||
}, [flowGraph, artifactsOpen]);
|
||
|
||
// 沙箱里已写出的 md 报告(流程图画布上提供「MD 报告」切回按钮的锚点)。
|
||
const mdArtifact = useMemo(() => {
|
||
for (const [threadId, msgs] of Object.entries(seatStubMessages)) {
|
||
const msg = msgs?.[0];
|
||
if (!msg) continue;
|
||
for (const tc of msg.tool_calls) {
|
||
if (tc.name !== "write_file") continue;
|
||
const path =
|
||
typeof (tc.args as { path?: unknown })?.path === "string"
|
||
? ((tc.args as { path?: string }).path as string)
|
||
: "";
|
||
const content =
|
||
typeof (tc.args as { content?: unknown })?.content === "string"
|
||
? ((tc.args as { content?: string }).content as string)
|
||
: "";
|
||
if (/\.md$/i.test(path) && content.trim()) return { threadId, path };
|
||
}
|
||
}
|
||
return null;
|
||
}, [seatStubMessages]);
|
||
|
||
// 用户在输入框里明确要求结构化输出/流程图 → 立即切到流程图画布(旧图先顶着,
|
||
// 新 flow-json 流出来就增量重绘;首次还没有图时画布在数据到达后自动渲染)。
|
||
const handleAsk = (message: string, files?: File[]) => {
|
||
if (detectFlowIntent(message)) {
|
||
flowAutoOpenedRef.current = true;
|
||
setFlowView(true);
|
||
}
|
||
ask(message, files);
|
||
};
|
||
|
||
// 气泡里不展示生 JSON:把 flow-json 块替换成一句引导语。
|
||
const displayDialogues = useMemo(
|
||
() =>
|
||
dialogues.map((d) => {
|
||
const content = d.content ?? "";
|
||
if (!content.includes(FLOW_FENCE_OPEN)) return d;
|
||
return { ...d, content: stripFlowFence(content, d.streaming === true) };
|
||
}),
|
||
[dialogues],
|
||
);
|
||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||
// 右侧面板(沙箱预览或流程图画布)露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。
|
||
const rightPanelVisible = artifactsOpen || (!!flowGraph && flowView);
|
||
const prevRightPanelVisibleRef = useRef(rightPanelVisible);
|
||
useEffect(() => {
|
||
if (rightPanelVisible !== prevRightPanelVisibleRef.current) {
|
||
prevRightPanelVisibleRef.current = rightPanelVisible;
|
||
setLeftCollapsed(rightPanelVisible);
|
||
}
|
||
}, [rightPanelVisible]);
|
||
|
||
// 预览开关:本视图在前台时本流才允许自动打开沙箱(并行流防抢);进入时若已有
|
||
// 产物/进行中会立即接管沙箱续看。
|
||
useEffect(() => {
|
||
summary.setPreviewEnabled(true);
|
||
return () => summary.setPreviewEnabled(false);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// 续看语义(2026-06-13):hook 常驻页面层,页面在进入第三步时已统一编排启动。
|
||
// 这里只做"从未启动过"的兜底(idle 才动手),**卸载不再 abort** —— 用户切去
|
||
// 结果绘制 / 回第二步,总结流照跑,回头继续看进行中过程或结果。
|
||
useEffect(() => {
|
||
if (phase !== "idle") return;
|
||
if (savedSummary && (savedSummary.md.trim() || (savedSummary.closing ?? "").trim())) {
|
||
hydrateSaved(savedSummary);
|
||
} else {
|
||
start();
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
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={onViewStatic}
|
||
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"
|
||
>
|
||
最终方案展示
|
||
<ArrowRight 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={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">
|
||
右侧由「方案总结报告智能体」据这些原料撰写 Markdown 总结报告,可在下方输入框继续提修改意见或提问。
|
||
</div>
|
||
</>
|
||
)}
|
||
</aside>
|
||
|
||
{/* 右栏:智能体进度 + 沙箱(复用第 2 步)。流程图画布放在右侧沙箱位
|
||
(sidePanel 插槽),消息列表保持可见,可与 md 沙箱预览互相切换。 */}
|
||
<section className="relative flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-white overflow-hidden">
|
||
<Step2SandboxLayout
|
||
activeThreadId={activeArtifactThreadId ?? summaryThreadId}
|
||
isStreaming={isStreaming}
|
||
seatMessages={activeSeatMessages}
|
||
sidePanel={
|
||
flowGraph && flowView ? (
|
||
<FlowGraphPanel
|
||
data={flowGraph.graph}
|
||
streaming={flowGraph.streaming}
|
||
onClose={() => setFlowView(false)}
|
||
onSwitchToReport={
|
||
mdArtifact
|
||
? () => {
|
||
setFlowView(false);
|
||
openArtifactPreview(mdArtifact.threadId, mdArtifact.path);
|
||
}
|
||
: undefined
|
||
}
|
||
/>
|
||
) : undefined
|
||
}
|
||
>
|
||
<AgentProgressPanel
|
||
phase={phase}
|
||
dialogues={displayDialogues}
|
||
error={error}
|
||
busy={busy}
|
||
mdAvailable={!!mdArtifact}
|
||
onRetry={redraw}
|
||
onAsk={handleAsk}
|
||
onPause={pause}
|
||
onArtifactStepClick={openArtifactFromStep}
|
||
onArtifactPreview={openArtifactPreview}
|
||
selectedModel={selectedModel}
|
||
availableModels={availableModels}
|
||
modelsLoading={modelsLoading}
|
||
setSelectedModel={setSelectedModel}
|
||
/>
|
||
</Step2SandboxLayout>
|
||
|
||
{/* 流程图入口按钮(有结构化数据且画布未开时显示;沙箱开着时即「切换」按钮) */}
|
||
{flowGraph && !flowView && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setFlowView(true)}
|
||
title={artifactsOpen ? "切换到流程图画布" : "打开流程图画布"}
|
||
className="absolute bottom-4 right-4 z-20 inline-flex items-center gap-1.5 rounded-full border border-primary/30 bg-white px-3.5 py-2 text-[11px] font-bold text-primary shadow-lg transition hover:bg-primary/5"
|
||
>
|
||
{flowGraph.streaming ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<Workflow className="h-3.5 w-3.5" />
|
||
)}
|
||
流程图
|
||
<span className="rounded-full bg-primary/10 px-1.5 font-mono text-[10px]">
|
||
{flowGraph.graph.nodes.length}
|
||
</span>
|
||
</button>
|
||
)}
|
||
|
||
</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}
|
||
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>
|
||
);
|
||
}
|
||
|
||
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,
|
||
busy,
|
||
mdAvailable,
|
||
onRetry,
|
||
onAsk,
|
||
onPause,
|
||
onArtifactStepClick,
|
||
onArtifactPreview,
|
||
selectedModel,
|
||
availableModels,
|
||
modelsLoading,
|
||
setSelectedModel,
|
||
}: {
|
||
phase: UseStep3SummaryReturn["phase"];
|
||
dialogues: UseStep3SummaryReturn["dialogues"];
|
||
error: string | null;
|
||
busy: boolean;
|
||
/** 沙箱里是否已有 md 报告 —— 决定提示语(纯流程图轮没有 .md 可预览/下载)。 */
|
||
mdAvailable: boolean;
|
||
onRetry: () => void;
|
||
onAsk: (message: string, files?: File[]) => void;
|
||
onPause: () => void;
|
||
onArtifactStepClick: UseStep3SummaryReturn["openArtifactFromStep"];
|
||
onArtifactPreview: UseStep3SummaryReturn["openArtifactPreview"];
|
||
selectedModel: string;
|
||
availableModels: ReadonlyArray<ModelOption>;
|
||
modelsLoading: boolean;
|
||
setSelectedModel: (model: string) => void;
|
||
}) {
|
||
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 [askText, setAskText] = useState("");
|
||
// 输入框携带的本地附件(点发送时连同指令一起上传到总结线程)。
|
||
const [askFiles, setAskFiles] = useState<File[]>([]);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const pickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const picked = Array.from(e.target.files ?? []);
|
||
if (picked.length > 0) {
|
||
setAskFiles((prev) => {
|
||
const seen = new Set(prev.map((f) => `${f.name}:${f.size}`));
|
||
return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))];
|
||
});
|
||
}
|
||
e.target.value = "";
|
||
};
|
||
const removeFile = (idx: number) => setAskFiles((prev) => prev.filter((_, i) => i !== idx));
|
||
|
||
const handleAskSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||
e.preventDefault();
|
||
const text = askText.trim();
|
||
const hasFiles = askFiles.length > 0;
|
||
if (!text && !hasFiles) return; // 允许「随时」发送(含生成中);空内容不发
|
||
setAskText("");
|
||
setAskFiles([]);
|
||
onAsk(text, hasFiles ? askFiles : undefined);
|
||
};
|
||
|
||
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">据各席位成果撰写 Markdown 总结报告</div>
|
||
</div>
|
||
</div>
|
||
<select
|
||
id="step3-summary-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}
|
||
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
|
||
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" />
|
||
{mdAvailable
|
||
? "报告正文实时输出,右侧沙箱同步预览 Markdown"
|
||
: "报告正文实时输出;输出结构化数据时流程图画布同步绘制"}
|
||
</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" />
|
||
{mdAvailable
|
||
? "已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续修改报告 / 流程图或提问。"
|
||
: "已生成方案总结,流程图可在画布查看;可在下方继续修改总结 / 流程图或提问。"}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
|
||
<form onSubmit={handleAskSubmit} className="shrink-0 border-t border-slate-100 bg-white px-3 py-3">
|
||
{/* 已选附件预览(发送时随指令上传到总结线程,供智能体读取) */}
|
||
{askFiles.length > 0 && (
|
||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||
{askFiles.map((file, idx) => (
|
||
<span
|
||
key={`${file.name}-${file.size}-${idx}`}
|
||
className="inline-flex items-center gap-1.5 bg-white border border-slate-200 rounded-lg pl-2 pr-1 py-1 text-[11px] text-slate-600 max-w-[200px]"
|
||
>
|
||
<FileText className="w-3 h-3 shrink-0 text-primary" />
|
||
<span className="truncate font-medium" title={file.name}>
|
||
{file.name}
|
||
</span>
|
||
<span className="shrink-0 text-slate-400">{formatBytes(file.size)}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => removeFile(idx)}
|
||
className="shrink-0 p-0.5 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition"
|
||
title="移除"
|
||
>
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
<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">
|
||
<textarea
|
||
value={askText}
|
||
onChange={(e) => setAskText(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
e.currentTarget.form?.requestSubmit();
|
||
}
|
||
}}
|
||
rows={1}
|
||
placeholder={
|
||
busy
|
||
? "生成中也可随时插话干预:发送将打断当前轮并据此继续…"
|
||
: "继续对话:改报告(如「执行路线写细些」)/ 改流程图(如「总结成结构化输出」「流程图加上验收环节」)/ 提问"
|
||
}
|
||
className="min-h-8 max-h-28 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none"
|
||
/>
|
||
|
||
{/* 上传文件(参考第一/二步:本地选取,发送时上传到总结线程) */}
|
||
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={pickFiles} />
|
||
<button
|
||
type="button"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
title="上传参考文件(智能体将读取文件内容辅助总结/修改)"
|
||
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-white border border-slate-200 text-slate-500 hover:text-primary hover:bg-slate-50 transition active:scale-95"
|
||
>
|
||
<Paperclip className="h-4 w-4" />
|
||
</button>
|
||
|
||
{/* 暂停(仅生成中显示) */}
|
||
{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={askText.trim().length === 0 && askFiles.length === 0}
|
||
title={busy ? "打断当前轮并发送" : "发送(智能体自行判断改报告 or 回答)"}
|
||
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"
|
||
>
|
||
<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">
|
||
<FileText 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,
|
||
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)}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|