圆桌第三步与业务链条增强:主题跟随/插话暂停/提示词优化/结构化流程图

- 结果绘制跟随日/夜间主题(深色按深蓝 UI 规范令牌绘制),提示词注入真实生成时间
- 绘制支持暂停与随时插话(线程存活时免带原料续线程省 token)、失败自动重试一次、草稿主题不匹配提示条
- 总结报告优先执行用户最终总结要求(结构化输出/重点席位可整体替换默认章节)
- 业务链条总控提示词与各阶段目标加「AI 优化」按钮(新接口 POST /api/roundtable-chains/optimize-prompt)
- 总结报告 flow-json 结构化流程图:流式增量绘制、最新连线蚂蚁线动画、拖拽/缩放/复位/全屏、节点详情弹窗、沙箱切换按钮/无沙箱自动打开
- roundtable-report / roundtable-summary 两个内置智能体 SOUL 更新(双主题、多轮修改允许读底稿、KPI 估算标注、流程数据契约)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
董宏凯andClaude Fable 5 committed 2026-06-12 00:37:45 +08:00
1 parent a4a9a4d26e
commit df8bf895ef
13 files changed
+1600 -83

No files matched your search

@@ -174,3 +174,34 @@ export async function deleteChain(id: string): Promise<void> {
throw new Error(`delete chain failed (${res.status}): ${text}`);
}
}
// ── 提示词优化 ───────────────────────────────────────────────────────────────
export interface OptimizeChainPromptRequest {
/** 用户当前写的提示词原文(非空)。 */
promptText: string;
/** coordinator = 总控提示词;stage_goal = 某个 Stage 的阶段目标。 */
kind: "coordinator" | "stage_goal";
/** 链条结构上下文(标题 / 各 Stage 席位与目标),帮助模型贴着链条优化。 */
context?: string;
/** 可选模型覆盖,缺省由后端用 config models[0]。 */
modelName?: string;
signal?: AbortSignal;
}
/** 调后端单次 LLM 重写提示词,返回优化后的全文(失败抛错,错误信息可直接 toast)。 */
export async function optimizeChainPrompt(req: OptimizeChainPromptRequest): Promise<string> {
const res = await apiFetch(`${BASE()}/optimize-prompt`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt_text: req.promptText,
kind: req.kind,
context: req.context ?? "",
...(req.modelName ? { model_name: req.modelName } : {}),
}),
signal: req.signal,
});
const data = await readJson<{ optimized: string }>(res, "optimize chain prompt");
return data.optimized ?? "";
}
@@ -115,6 +115,11 @@ export interface DraftStep3Snapshot {
model?: string;
/** The HTML agent's closing one-liner (rendered as the report message bubble). */
summary?: string;
/**
* 绘制这份 HTML 时的界面主题(light=浅红 / dark=深蓝)。恢复草稿时与当前主题
* 比对,不一致则提示「重新绘制以匹配当前主题」。旧草稿没有该字段 → 不提示。
*/
themeMode?: "light" | "dark";
/**
* Step 3「总结报告」snapshot — output of the `roundtable-summary` agent
* (Markdown report). Stored alongside (not instead of) the HTML report so the
@@ -0,0 +1,400 @@
/**
* Step 3「总结报告」结构化流程图画布。
*
* 数据来自总结智能体回复末尾的 ```flow-json 块(见 lib/flow-graph.ts),流式期间
* 随解析增量重绘:每出现一个新节点,它的入边挂蚂蚁线动画(React Flow `animated`),
* 画布自动 fitView 跟随生长;结束后全部转实线。
*
* 交互:画布拖动(pan)/ 滚轮缩放 / 节点可拖拽(手动位置在数据更新间保留)/
* 工具栏 放大·缩小·复位·全屏 / 点节点弹详情(类型、说明、上下游)。
*/
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
applyNodeChanges,
Background,
BackgroundVariant,
Handle,
MarkerType,
Position,
ReactFlow,
ReactFlowProvider,
useReactFlow,
type Edge,
type Node,
type NodeChange,
type NodeProps,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import {
Loader2,
LocateFixed,
Maximize2,
Minimize2,
Workflow,
X,
ZoomIn,
ZoomOut,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
FLOW_NODE_TYPE_META,
FLOW_NODE_W,
layoutFlowGraph,
type FlowGraphData,
type FlowGraphNode,
} from "@/roundtable-planning/lib/flow-graph";
interface FlowGraphPanelProps {
data: FlowGraphData;
/** 流式绘制中(决定蚂蚁线 + 自动 fitView 跟随)。 */
streaming: boolean;
onClose: () => void;
}
// ── 自定义节点(按 type 着色的卡片)────────────────────────────────────────
function FlowCardNode({ data }: NodeProps) {
const node = (data as { node?: FlowGraphNode }).node;
if (!node) return null;
const meta = FLOW_NODE_TYPE_META[node.type];
return (
<div
style={{
background: meta.bg,
borderColor: meta.border,
color: meta.text,
width: FLOW_NODE_W,
}}
className="cursor-pointer rounded-xl border-2 px-3 py-2 shadow-sm transition hover:shadow-md"
title="点击查看详情"
>
<Handle type="target" position={Position.Top} className="!h-1.5 !w-1.5 !bg-slate-400" />
<div className="mb-0.5 flex items-center gap-1.5">
<span style={{ background: meta.dot }} className="size-2 shrink-0 rounded-full" />
<span className="text-[9px] font-bold uppercase tracking-wider opacity-70">
{meta.label}
</span>
</div>
<div className="break-words text-[11.5px] font-bold leading-snug">{node.label}</div>
<Handle type="source" position={Position.Bottom} className="!h-1.5 !w-1.5 !bg-slate-400" />
</div>
);
}
// 必须模块级常量:每次 render 新建会让 React Flow 整树重挂。
const NODE_TYPES = { flowCard: FlowCardNode };
// ── 画布内层(需在 ReactFlowProvider 内才能 useReactFlow)──────────────────
function FlowCanvasInner({
data,
streaming,
fullscreen,
onToggleFullscreen,
onClose,
}: FlowGraphPanelProps & { fullscreen: boolean; onToggleFullscreen: () => void }) {
const rf = useReactFlow();
const [selectedId, setSelectedId] = useState<string | null>(null);
// 用户手动拖过的节点位置 —— 数据增量更新重新布局时保留,不被布局覆盖。
const manualPosRef = useRef<Map<string, { x: number; y: number }>>(new Map());
const layoutNodes = useMemo(() => layoutFlowGraph(data), [data]);
const derivedNodes: Node[] = useMemo(
() =>
layoutNodes.map((n) => ({
id: n.id,
type: "flowCard",
position: manualPosRef.current.get(n.id) ?? { x: n.x, y: n.y },
data: { node: n },
})),
[layoutNodes],
);
const [nodes, setNodes] = useState<Node[]>(derivedNodes);
useEffect(() => setNodes(derivedNodes), [derivedNodes]);
const onNodesChange = useCallback((changes: NodeChange[]) => {
setNodes((nds) => applyNodeChanges(changes, nds));
for (const c of changes) {
if (c.type === "position" && c.position) manualPosRef.current.set(c.id, c.position);
}
}, []);
// 流式增量绘制:最新节点的连线用蚂蚁线动画(React Flow animated = marching ants)。
const newestNodeId = data.nodes[data.nodes.length - 1]?.id;
const edges: Edge[] = useMemo(
() =>
data.edges.map((e) => {
const active = streaming && (e.to === newestNodeId || e.from === newestNodeId);
const color = active ? "#3b82f6" : "#94a3b8";
return {
id: `e-${e.from}->${e.to}`,
source: e.from,
target: e.to,
label: e.label,
animated: active,
style: { stroke: color, strokeWidth: active ? 2 : 1.5 },
markerEnd: { type: MarkerType.ArrowClosed, color, width: 16, height: 16 },
labelStyle: { fontSize: 10, fill: "#64748b", fontWeight: 700 },
labelBgStyle: { fill: "#ffffff", fillOpacity: 0.85 },
} satisfies Edge;
}),
[data.edges, streaming, newestNodeId],
);
// 流式中节点增加 → 画布平滑 fitView 跟随生长(用户暂停流后不再打扰)。
useEffect(() => {
if (!streaming) return;
const t = setTimeout(() => {
void rf.fitView({ duration: 250, padding: 0.18 });
}, 80);
return () => clearTimeout(t);
}, [data.nodes.length, streaming, rf]);
const selectedNode = selectedId ? data.nodes.find((n) => n.id === selectedId) ?? null : null;
return (
<div className="flex h-full min-h-0 flex-col">
{/* 头部:标题 + 工具栏 */}
<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 min-w-0 items-center gap-2">
<Workflow className="h-4 w-4 shrink-0 text-primary" />
<span className="truncate text-xs font-bold text-slate-800">
{data.title || "方案流程图"}
</span>
<span className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 font-mono text-[10px] text-slate-500">
{data.nodes.length} 节点 · {data.edges.length} 连线
</span>
{streaming && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-blue-200 bg-blue-50 px-2 py-0.5 text-[10px] font-bold text-blue-600">
<Loader2 className="h-3 w-3 animate-spin" />
绘制中
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
<ToolButton title="放大" onClick={() => void rf.zoomIn({ duration: 150 })}>
<ZoomIn className="h-3.5 w-3.5" />
</ToolButton>
<ToolButton title="缩小" onClick={() => void rf.zoomOut({ duration: 150 })}>
<ZoomOut className="h-3.5 w-3.5" />
</ToolButton>
<ToolButton
title="恢复原位置"
onClick={() => void rf.fitView({ duration: 250, padding: 0.18 })}
>
<LocateFixed className="h-3.5 w-3.5" />
</ToolButton>
<ToolButton title={fullscreen ? "退出全屏" : "全屏"} onClick={onToggleFullscreen}>
{fullscreen ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</ToolButton>
<ToolButton title="关闭流程图" onClick={onClose}>
<X className="h-3.5 w-3.5" />
</ToolButton>
</div>
</div>
{/* 画布 */}
<div className="relative min-h-0 flex-1">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={NODE_TYPES}
onNodesChange={onNodesChange}
onNodeClick={(_, n) => setSelectedId(n.id)}
fitView
fitViewOptions={{ padding: 0.18 }}
minZoom={0.2}
maxZoom={2.5}
panOnDrag
zoomOnScroll
nodesConnectable={false}
deleteKeyCode={null}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={18} size={1} color="#cbd5e1" />
</ReactFlow>
{/* 图例 */}
<div className="pointer-events-none absolute bottom-2 left-2 flex flex-wrap items-center gap-x-2.5 gap-y-1 rounded-lg border border-slate-200/70 bg-white/85 px-2.5 py-1.5 backdrop-blur-sm">
{Object.entries(FLOW_NODE_TYPE_META).map(([type, meta]) => (
<span key={type} className="inline-flex items-center gap-1 text-[9.5px] font-semibold text-slate-500">
<span style={{ background: meta.dot }} className="size-1.5 rounded-full" />
{meta.label}
</span>
))}
</div>
</div>
{/* 节点详情弹窗 */}
{selectedNode && (
<NodeDetailModal
node={selectedNode}
data={data}
onClose={() => setSelectedId(null)}
onJump={(id) => setSelectedId(id)}
/>
)}
</div>
);
}
function ToolButton({
title,
onClick,
children,
}: {
title: string;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
title={title}
onClick={onClick}
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200/70 bg-white text-slate-500 transition hover:border-primary/40 hover:text-primary"
>
{children}
</button>
);
}
// ── 节点详情弹窗(类型 / 说明 / 上下游连接)─────────────────────────────────
function NodeDetailModal({
node,
data,
onClose,
onJump,
}: {
node: FlowGraphNode;
data: FlowGraphData;
onClose: () => void;
onJump: (id: string) => void;
}) {
const meta = FLOW_NODE_TYPE_META[node.type];
const labelOf = (id: string) => data.nodes.find((n) => n.id === id)?.label ?? id;
const incoming = data.edges.filter((e) => e.to === node.id);
const outgoing = data.edges.filter((e) => e.from === node.id);
return (
<div
className="absolute inset-0 z-30 flex items-center justify-center bg-black/30 p-4 backdrop-blur-[1px]"
onClick={onClose}
>
<div
className="w-full max-w-md overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div
className="flex items-center justify-between gap-2 border-b px-4 py-3"
style={{ background: meta.bg, borderColor: meta.border }}
>
<div className="flex min-w-0 items-center gap-2">
<span style={{ background: meta.dot }} className="size-2.5 shrink-0 rounded-full" />
<span
className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold"
style={{ background: "#ffffffaa", color: meta.text }}
>
{meta.label}
</span>
<h3 className="min-w-0 truncate text-sm font-bold" style={{ color: meta.text }}>
{node.label}
</h3>
</div>
<button
type="button"
onClick={onClose}
className="shrink-0 rounded-md p-1 text-slate-500 hover:bg-white/60 hover:text-slate-700"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[50vh] space-y-3 overflow-y-auto p-4">
<div>
<div className="mb-1 text-[10px] font-bold uppercase tracking-widest text-slate-400">
详细说明
</div>
<p className="text-xs leading-relaxed text-slate-700">
{node.detail?.trim() || "(该节点未提供详细说明)"}
</p>
</div>
{incoming.length > 0 && (
<ConnectionList title="上游(来自)" entries={incoming.map((e) => ({ id: e.from, label: labelOf(e.from), edgeLabel: e.label }))} onJump={onJump} />
)}
{outgoing.length > 0 && (
<ConnectionList title="下游(去往)" entries={outgoing.map((e) => ({ id: e.to, label: labelOf(e.to), edgeLabel: e.label }))} onJump={onJump} />
)}
</div>
</div>
</div>
);
}
function ConnectionList({
title,
entries,
onJump,
}: {
title: string;
entries: Array<{ id: string; label: string; edgeLabel?: string }>;
onJump: (id: string) => void;
}) {
return (
<div>
<div className="mb-1 text-[10px] font-bold uppercase tracking-widest text-slate-400">
{title}
</div>
<div className="flex flex-wrap gap-1.5">
{entries.map((entry) => (
<button
key={`${title}-${entry.id}`}
type="button"
onClick={() => onJump(entry.id)}
className="inline-flex items-center gap-1 rounded-lg border border-slate-200 bg-slate-50 px-2 py-1 text-[11px] font-semibold text-slate-700 transition hover:border-primary/40 hover:text-primary"
title="查看该节点详情"
>
{entry.label}
{entry.edgeLabel && (
<span className="rounded bg-slate-200/80 px-1 text-[9px] text-slate-500">
{entry.edgeLabel}
</span>
)}
</button>
))}
</div>
</div>
);
}
// ── 外壳:覆盖右栏 / 全屏切换 ───────────────────────────────────────────────
export function FlowGraphPanel({ data, streaming, onClose }: FlowGraphPanelProps) {
const [fullscreen, setFullscreen] = useState(false);
return (
<div
className={cn(
"flex flex-col bg-white",
fullscreen ? "fixed inset-0 z-[80]" : "absolute inset-0 z-20 rounded-2xl",
)}
>
<ReactFlowProvider>
<FlowCanvasInner
data={data}
streaming={streaming}
fullscreen={fullscreen}
onToggleFullscreen={() => setFullscreen((f) => !f)}
onClose={onClose}
/>
</ReactFlowProvider>
</div>
);
}
@@ -22,6 +22,7 @@ import {
Loader2,
PanelLeftClose,
PanelLeftOpen,
Pause,
RefreshCw,
SendHorizontal,
Sparkles,
@@ -119,6 +120,9 @@ export function Step3ReportMode({
start,
hydrateSaved,
redraw,
pause,
interject,
themeMismatch,
abort,
openArtifactFromStep,
openArtifactPreview,
@@ -178,7 +182,9 @@ export function Step3ReportMode({
? "绘制完成"
: phase === "error"
? "绘制失败"
: "待绘制";
: phase === "paused"
? "已暂停"
: "待绘制";
return (
<div className="flex h-full min-h-0 flex-col text-slate-800">
@@ -196,6 +202,17 @@ export function Step3ReportMode({
</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()}
@@ -311,8 +328,9 @@ export function Step3ReportMode({
phase={phase}
dialogues={dialogues}
error={error}
themeMismatch={themeMismatch}
onRetry={redraw}
onRevisionSubmit={redraw}
onRevisionSubmit={interject}
onArtifactStepClick={openArtifactFromStep}
onArtifactPreview={openArtifactPreview}
selectedModel={selectedModel}
@@ -433,6 +451,7 @@ function AgentProgressPanel({
phase,
dialogues,
error,
themeMismatch,
onRetry,
onRevisionSubmit,
onArtifactStepClick,
@@ -445,6 +464,7 @@ function AgentProgressPanel({
phase: ReturnType<typeof useStep3Report>["phase"];
dialogues: ReturnType<typeof useStep3Report>["dialogues"];
error: string | null;
themeMismatch: boolean;
onRetry: () => void;
onRevisionSubmit: (revisionInstruction: string) => void;
onArtifactStepClick: ReturnType<typeof useStep3Report>["openArtifactFromStep"];
@@ -455,6 +475,9 @@ function AgentProgressPanel({
setSelectedModel: (model: string) => void;
}) {
const busy = phase === "creating" || phase === "streaming";
// 只有「正在建线程」时锁输入(线程没建好没法插话);绘制中允许输入并发送——
// 发送会先打断当前轮,再按新要求继续(与第 3 步总结报告的随时插话一致)。
const inputLocked = phase === "creating";
const scrollRef = useRef<HTMLDivElement>(null);
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
const [revisionText, setRevisionText] = useState("");
@@ -462,7 +485,7 @@ function AgentProgressPanel({
const handleRevisionSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const text = revisionText.trim();
if (!text || busy) return;
if (!text || inputLocked) return;
setRevisionText("");
onRevisionSubmit(text);
};
@@ -554,12 +577,35 @@ function AgentProgressPanel({
</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
@@ -576,18 +622,28 @@ function AgentProgressPanel({
e.currentTarget.form?.requestSubmit();
}
}}
disabled={busy}
disabled={inputLocked}
rows={1}
placeholder="输入修改要求,例如:把右侧页面改成深色风格,并增加风险矩阵"
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 disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="submit"
disabled={busy || revisionText.trim().length === 0}
title={busy ? "正在绘制,请稍后发送" : "发送修改要求"}
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"
>
{busy ? (
{inputLocked ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<SendHorizontal className="h-4 w-4" />
@@ -26,6 +26,7 @@ import {
RefreshCw,
SendHorizontal,
Sparkles,
Workflow,
X,
} from "lucide-react";
@@ -39,6 +40,12 @@ import {
} 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 {
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";
@@ -137,6 +144,41 @@ export function Step3SummaryMode({
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]);
// 气泡里不展示生 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 prevArtifactsOpenRef = useRef(artifactsOpen);
useEffect(() => {
@@ -295,8 +337,8 @@ export function Step3SummaryMode({
)}
</aside>
{/* 右栏:智能体进度 + 沙箱(复用第 2 步) */}
<section className="flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-white overflow-hidden">
{/* 右栏:智能体进度 + 沙箱(复用第 2 步)。流程图画布作为覆盖层叠在其上。 */}
<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}
@@ -304,7 +346,7 @@ export function Step3SummaryMode({
>
<AgentProgressPanel
phase={phase}
dialogues={dialogues}
dialogues={displayDialogues}
error={error}
busy={busy}
onRetry={redraw}
@@ -318,6 +360,35 @@ export function Step3SummaryMode({
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>
)}
{/* 流程图画布(覆盖右栏;全屏在组件内部切换) */}
{flowGraph && flowView && (
<FlowGraphPanel
data={flowGraph.graph}
streaming={flowGraph.streaming}
onClose={() => setFlowView(false)}
/>
)}
</section>
</div>
@@ -17,6 +17,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useArtifacts } from "@/components/workspace/artifacts";
import { useTheme } from "@/contexts/ThemeContext";
import { apiFetch } from "@/core/api";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import {
@@ -48,7 +49,13 @@ export interface ReportSeatDelivery {
content: string;
}
export type ReportPhase = "idle" | "creating" | "streaming" | "done" | "error";
export type ReportPhase =
| "idle"
| "creating"
| "streaming"
| "done"
| "error"
| "paused";
interface UseStep3ReportOptions {
intentReady: ReportIntent | null;
@@ -77,6 +84,15 @@ export interface UseStep3ReportReturn {
hydrateSaved: (snapshot: DraftStep3Snapshot) => void;
/** 重新绘制(先中断在途请求再重跑)。 */
redraw: (revisionInstruction?: string) => void;
/** 暂停:中断在途绘制并把气泡冻结为「已暂停」(沙箱保留已写内容)。 */
pause: () => void;
/**
* 插话 / 修改要求(随时可发):绘制中先打断再发;报告线程存活时免带研讨原料
* 与旧 HTML 直接续同一线程,线程不在(草稿恢复等)才回落全量 prompt。
*/
interject: (message: string) => void;
/** 当前展示的报告主题与界面主题不一致(建议重新绘制以匹配)。 */
themeMismatch: boolean;
/** 中断在途请求(卸载 / 切回普通视图时调用)。 */
abort: () => void;
/** 点击气泡内工具步骤时打开沙箱(与第 2 步 openArtifactFromStep 同义)。 */
@@ -92,15 +108,22 @@ function buildReportPrompt(opts: {
intentReady: ReportIntent | null;
seatDeliveries: ReportSeatDelivery[];
consensusText: string;
/** 当前界面主题——深色时报告改用深蓝设计令牌绘制(与 SOUL 的 4a/4b 主题约定对应)。 */
themeMode: "light" | "dark";
revisionInstruction?: string;
previousHtml?: string;
}): string {
const { intentReady, seatDeliveries, consensusText, revisionInstruction, previousHtml } = opts;
const { intentReady, seatDeliveries, consensusText, themeMode, revisionInstruction, previousHtml } =
opts;
const parts: string[] = [];
parts.push(
"请基于以下圆桌多智能体研讨的成果,撰写一份图文并茂、以文字叙述为主线的 HTML「方案研讨汇报」," +
"用 write_file 工具写入 /mnt/user-data/outputs/方案总览.html。",
);
// 模型不知道当下日期——报告头的「生成时间」由前端注入,避免它编一个占位日期。
parts.push(
`本次报告生成时间:${new Date().toLocaleString("zh-CN", { dateStyle: "long", timeStyle: "short" })}(请原样写进报告头的生成时间处)。`,
);
parts.push(
"目标:让用户能轻松读懂这群智能体讨论出了什么方案、为什么是它。请逐个席位提炼其核心观点与交付写成文字," +
"再综合成最终方案;图表用来点睛(风险矩阵、时间线、资源占比、贡献雷达等),详略得当——" +
@@ -154,10 +177,33 @@ function buildReportPrompt(opts: {
}
}
// 页面主题跟随当前界面的日/夜间模式:浅色保持原浅红效果,深色按「深蓝 UI 规范」令牌绘制。
// 令牌在 prompt 里再列一遍(而不只靠 SOUL 的 4b 节),确保已落盘 SOUL 未同步时深色也生效。
parts.push("# 页面主题");
if (themeMode === "dark") {
parts.push(
"本次绘制使用【深色(深蓝)主题】——用户当前界面为深色模式。整页必须深蓝底、浅色字," +
"严格按你职责设定「深色主题(深蓝)」的设计令牌取色:" +
"背景层级 页面 #060E24 → 容器 #0C1836 → 卡片 #122347(边框 1px #1A2F5A,圆角 4px,阴影 0 2px 8px rgba(22,119,255,.12));" +
"文字 标题 #FFFFFF / 正文 #C5CFE0 / 次级 #6E88B0 / 弱化 #4A6491;" +
"主色 #1677FF、辅色青 #13C2C2、操作强调 #4096FF;报告头标题下用发光分割线 linear-gradient(90deg, transparent, #1677FF, transparent),普通分割线 #2D4473;" +
"图表系列色序依次 #1677FF #13C2C2 #4096FF #36CFC9 #69B1FF #5CDBD3 #91CAFF #87E8DE,坐标轴/网格线 #2D4473、轴文字 #6E88B0;" +
"语义色 成功 #52C41A / 警告 #FAAD14 / 风险·错误 #F5222D / 信息 #1677FF / 处理中 #13C2C2;" +
"KPI 卡 卡底 #122347、大数值 #FFFFFF、标签 #C5CFE0。" +
"禁止使用浅红体系(#c25555 / #fef4f3);若底稿 HTML 是浅色配色,必须把整页配色重绘成上述深色。",
);
} else {
parts.push(
"本次绘制使用【浅色(浅红)主题】:主色 #c25555、浅底 #fef4f3、描边 #ede9e9、文字 #1f2937" +
"(即你职责设定里的默认浅色主题)。若底稿 HTML 是深色配色,必须把整页配色重绘成浅色。",
);
}
parts.push("");
parts.push(
"请严格遵循你的职责设定:自包含离线(纯 inline SVG 图表、禁止任何外链 CDN)、研讨叙事型结构" +
"(结论+KPI → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线)、" +
"文字为主图表点睛(全篇穿插 3–5 个有信息量的图、每图配一两句解读)、浅红主题(主色 #c25555);" +
"文字为主图表点睛(全篇穿插 3–5 个有信息量的图、每图配一两句解读)、页面配色严格按上文「页面主题」执行;" +
"写完文件后只用一两句话总结,不要把 HTML 源码贴进对话。",
);
// 语言 + 编码硬约束:偶发整篇输出英文 / 中文乱码的两个根因都在这里堵住。
@@ -168,6 +214,23 @@ function buildReportPrompt(opts: {
return parts.join("\n");
}
/**
* 报告线程仍存活时的插话 / 修订 prompt:不重复贴研讨原料与旧 HTML——线程上下文里
* 都有,token 直接省一半以上;让智能体必要时 read_file 自己写过的底稿找回细节。
*/
function buildInterjectPrompt(message: string, themeMode: "light" | "dark"): string {
const themeLine = themeMode === "dark" ? "深色(深蓝)" : "浅色(浅红)";
return [
"用户对当前的方案总览 HTML 提出了新的修改要求:",
message,
"",
"请在你本线程里已写的 /mnt/user-data/outputs/方案总览.html 基础上落实修改" +
"(必要时可先 read_file 该文件找回最新内容;若该文件尚不存在或只写了一半,就按原始要求重新完整绘制一份)," +
`然后用 write_file 把修改后的完整 HTML 整文件重写到同一路径。页面保持【${themeLine}】主题配色。` +
"写完只用一两句话说明本次改了什么,不要把 HTML 源码贴进对话。",
].join("\n");
}
/** 把 SSE 阶段映射成报告智能体的中文状态标签(对齐第 2 步 phaseLabel)。 */
function reportPhaseLabel(phase: StreamPhase, toolName?: string): string {
switch (phase) {
@@ -195,6 +258,9 @@ export function useStep3Report(
const { intentReady, seatDeliveries, consensusText, selectedModel, savedReport, onReportSaved } =
options;
// 页面主题:浅色沿用浅红效果,深色让报告智能体按「深蓝 UI 规范」绘制。
const { theme } = useTheme();
// onReportSaved 身份可能每次 render 变 —— 打到 ref,避免进 run() 的 deps 反复重建。
const onReportSavedRef = useRef(onReportSaved);
useEffect(() => {
@@ -221,6 +287,12 @@ export function useStep3Report(
const stubRef = useRef<Record<string, StubAiMessage[]>>({});
const runningRef = useRef(false);
const aborterRef = useRef<AbortController | null>(null);
// 报告线程是否仍「存活」(本会话里跑过 run/interject 建的真线程)。存活时插话
// 直接续线程(免带原料与旧 HTML);草稿恢复的合成线程不算存活。
const liveThreadRef = useRef<string | null>(null);
const agentNameRef = useRef<string>("roundtable-report");
// 当前展示的报告是用哪个主题绘制的(null = 未知,旧草稿没存该字段)。
const [reportTheme, setReportTheme] = useState<"light" | "dark" | null>(null);
// 当前报告气泡 id —— 流式 delta / 工具步骤 / finalize 都按它定位(绝不用数组下标)。
const bubbleIdRef = useRef<number | null>(null);
// 上一次「自动」打开沙箱的 virtualUrl —— 流式一秒回调几十次,url 没变就跳过;
@@ -305,6 +377,30 @@ export function useStep3Report(
[],
);
const appendUserDialogue = useCallback((text: string) => {
const id = Date.now() + Math.random();
setDialogues((prev) => [
...prev,
{
id,
sender: "指挥官(修改要求)",
avatarType: "human",
content: text,
time: nowTime(),
confidence: "已发送",
streaming: false,
steps: [],
},
]);
}, []);
/** 把所有仍在流式的气泡冻结(暂停 / 插话打断时用),正文保留已收到的部分。 */
const freezeStreaming = useCallback((note: string) => {
setDialogues((prev) =>
prev.map((d) => (d.streaming ? { ...d, streaming: false, confidence: note } : d)),
);
}, []);
const finalizeDialogue = useCallback(
(id: number, finalContent: string, confidence?: string) => {
setDialogues((prev) =>
@@ -620,6 +716,93 @@ export function useStep3Report(
runningRef.current = false;
}, []);
/** 单轮 special 流式(带一次自动重试):失败且非用户中断时同线程重发一次再抛。 */
const streamRound = useCallback(
async (args: {
bubbleId: number;
threadId: string;
agentName: string;
prompt: string;
aborter: AbortController;
}) => {
const { bubbleId, threadId, agentName, prompt, aborter } = args;
for (let attempt = 0; ; attempt += 1) {
try {
return await streamMultiAgent({
agentType: "special",
agentName,
dAgentThreadId: { [agentName]: threadId },
displayName: "方案总结",
newMessage: prompt,
model: selectedModel || undefined,
signal: aborter.signal,
onTextDelta: (text) => appendToDialogue(bubbleId, text),
onPhaseChange: (p, toolName, toolArgs) => {
const label = reportPhaseLabel(p, toolName);
setDialogueConfidence(bubbleId, label);
appendStepIfNew(bubbleId, label, `${p}:${toolName ?? ""}`, toolArgs);
handleArtifactToolPhase(p, toolName, toolArgs, threadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, threadId),
});
} catch (err) {
// 写大 HTML 偶发超时 / 断流:非用户中断时自动重试一次,重试仍失败才抛给上层。
if (aborter.signal.aborted || attempt >= 1) throw err;
appendStepIfNew(bubbleId, "生成中断,自动重试中", `retry:${attempt + 1}`);
setDialogueConfidence(bubbleId, "自动重试中");
}
}
},
[
selectedModel,
appendToDialogue,
setDialogueConfidence,
appendStepIfNew,
handleArtifactToolPhase,
handleUpstreamFrame,
],
);
/** 一轮结束后的公共收尾:兜底开沙箱 → 对账真实文件回灌 stub → 完成气泡 → 存草稿。 */
const finishRound = useCallback(
async (args: {
bubbleId: number;
threadId: string;
aborter: AbortController;
closing: string;
}) => {
const { bubbleId, threadId, aborter, closing } = args;
// stub 里有 write_file 就说明流式期间已抓到产物;否则收尾从 outputs 兜底打开。
const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0;
if (!hasStubFile) await openRealArtifactFallback(threadId);
// 以沙箱真实文件回灌 stub(修复 append 续写只剩后半段的问题),并拿到完整 HTML。
const html = hasStubFile
? await reconcileStubFromRealFile(threadId, aborter.signal)
: "";
if (aborter.signal.aborted) return;
finalizeDialogue(bubbleId, closing, "绘制完成");
setPhase("done");
setReportTheme(theme);
// 绘制成功 → 把完整 HTML 等抛给上层存进草稿(step3),连同绘制主题。
if (html.trim()) {
onReportSavedRef.current?.({
html,
generatedAt: new Date().toISOString(),
model: selectedModel || "",
summary: closing || "",
themeMode: theme,
});
}
},
[
theme,
selectedModel,
openRealArtifactFallback,
reconcileStubFromRealFile,
finalizeDialogue,
],
);
const run = useCallback(async (revisionInstruction?: string) => {
if (runningRef.current) return;
runningRef.current = true;
@@ -628,6 +811,7 @@ export function useStep3Report(
setDialogues([]);
bubbleIdRef.current = null;
lastAutoOpenedUrlRef.current = null;
liveThreadRef.current = null;
stubRef.current = {};
setSeatStubMessages({});
@@ -644,6 +828,8 @@ export function useStep3Report(
try {
const { agentName, threadId } = await initReportThread({ signal: aborter.signal });
if (aborter.signal.aborted) return;
liveThreadRef.current = threadId;
agentNameRef.current = agentName;
setReportThreadId(threadId);
setActiveArtifactThreadId(threadId);
setDialogues((prev) =>
@@ -655,50 +841,13 @@ export function useStep3Report(
intentReady,
seatDeliveries,
consensusText,
themeMode: theme,
revisionInstruction,
previousHtml: savedReport?.html,
});
const res = await streamMultiAgent({
agentType: "special",
agentName,
dAgentThreadId: { [agentName]: threadId },
displayName: "方案总结",
newMessage: prompt,
model: selectedModel || undefined,
signal: aborter.signal,
onTextDelta: (text) => appendToDialogue(bubbleId, text),
onPhaseChange: (p, toolName, args) => {
const label = reportPhaseLabel(p, toolName);
setDialogueConfidence(bubbleId, label);
appendStepIfNew(bubbleId, label, `${p}:${toolName ?? ""}`, args);
handleArtifactToolPhase(p, toolName, args, threadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, threadId),
});
const res = await streamRound({ bubbleId, threadId, agentName, prompt, aborter });
if (aborter.signal.aborted) return;
// stub 里有 write_file 就说明流式期间已抓到产物;否则收尾从 outputs 兜底打开。
const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0;
if (!hasStubFile) await openRealArtifactFallback(threadId);
// 以沙箱真实文件回灌 stub(修复 append 续写只剩后半段的问题),并拿到完整 HTML。
const html = hasStubFile
? await reconcileStubFromRealFile(threadId, aborter.signal)
: "";
if (aborter.signal.aborted) return;
finalizeDialogue(bubbleId, res.content, "绘制完成");
setPhase("done");
// 绘制成功 → 把完整 HTML 等抛给上层存进草稿(step3)。
if (html.trim()) {
onReportSavedRef.current?.({
html,
generatedAt: new Date().toISOString(),
model: selectedModel || "",
summary: res.content || "",
});
}
await finishRound({ bubbleId, threadId, aborter, closing: res.content });
} catch (err) {
if (aborter.signal.aborted) return;
const msg = err instanceof Error ? err.message : String(err);
@@ -706,25 +855,118 @@ export function useStep3Report(
finalizeDialogue(bubbleId, `(绘制失败) ${msg}`, "绘制失败");
setPhase("error");
} finally {
runningRef.current = false;
// 插话可能已打断本轮并接管 runningRef —— 只有自己还是当前请求时才释放。
if (aborterRef.current === aborter) runningRef.current = false;
}
}, [
intentReady,
seatDeliveries,
consensusText,
theme,
savedReport?.html,
selectedModel,
appendStreamingDialogue,
appendToDialogue,
setDialogueConfidence,
appendStepIfNew,
streamRound,
finishRound,
finalizeDialogue,
handleArtifactToolPhase,
handleUpstreamFrame,
openRealArtifactFallback,
reconcileStubFromRealFile,
]);
/** 暂停:中断在途绘制,流式气泡冻结为「已暂停」,沙箱保留已写内容。 */
const pause = useCallback(() => {
if (!runningRef.current) return;
abort();
freezeStreaming("已暂停");
setPhase("paused");
}, [abort, freezeStreaming]);
/**
* 插话 / 修改要求(随时可发):
* - 绘制中:先打断当前轮(气泡冻结为「已打断」),再发修改要求;
* - 报告线程存活:免带研讨原料与旧 HTML,直接续同一线程(智能体上下文里都有,
* 必要时它会 read_file 自己写过的底稿);
* - 线程不在(草稿恢复 / 首轮线程没建成):新建线程,回落「原料 + 旧 HTML 底稿」全量 prompt。
*/
const interject = useCallback(
async (message: string) => {
const text = message.trim();
if (!text) return;
const wasBusy = runningRef.current;
abort();
if (wasBusy) freezeStreaming("已打断");
runningRef.current = true;
setError(null);
const aborter = new AbortController();
aborterRef.current = aborter;
appendUserDialogue(text);
const bubbleId = appendStreamingDialogue({
sender: "方案总结智能体",
avatarType: "blue",
});
bubbleIdRef.current = bubbleId;
try {
let threadId = liveThreadRef.current;
let prompt: string;
if (threadId) {
prompt = buildInterjectPrompt(text, theme);
} else {
setPhase("creating");
const init = await initReportThread({ signal: aborter.signal });
if (aborter.signal.aborted) return;
threadId = init.threadId;
liveThreadRef.current = threadId;
agentNameRef.current = init.agentName;
setReportThreadId(threadId);
setActiveArtifactThreadId(threadId);
prompt = buildReportPrompt({
intentReady,
seatDeliveries,
consensusText,
themeMode: theme,
revisionInstruction: text,
previousHtml: savedReport?.html,
});
}
setDialogues((prev) =>
prev.map((d) => (d.id === bubbleId ? { ...d, agentThreadId: threadId } : d)),
);
setPhase("streaming");
const res = await streamRound({
bubbleId,
threadId,
agentName: agentNameRef.current,
prompt,
aborter,
});
if (aborter.signal.aborted) return;
await finishRound({ bubbleId, threadId, aborter, closing: res.content });
} catch (err) {
if (aborter.signal.aborted) return;
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
finalizeDialogue(bubbleId, `(处理失败) ${msg}`, "处理失败");
setPhase("error");
} finally {
if (aborterRef.current === aborter) runningRef.current = false;
}
},
[
abort,
freezeStreaming,
appendUserDialogue,
appendStreamingDialogue,
theme,
intentReady,
seatDeliveries,
consensusText,
savedReport?.html,
streamRound,
finishRound,
finalizeDialogue,
],
);
const start = useCallback((revisionInstruction?: string) => {
void run(revisionInstruction);
}, [run]);
@@ -735,6 +977,10 @@ export function useStep3Report(
(snap: DraftStep3Snapshot) => {
abort();
runningRef.current = false;
// 合成线程不是真线程:插话时走「新建线程 + 旧 HTML 底稿」的回落路径。
liveThreadRef.current = null;
// 记录快照绘制主题(旧草稿可能没存 → null,不提示不匹配)。
setReportTheme(snap.themeMode ?? null);
const synthThreadId = "roundtable-report-restored";
const messageId = `roundtable-report-stub-${synthThreadId}`;
const toolCallId = `tc-${synthThreadId}-0`;
@@ -797,6 +1043,10 @@ export function useStep3Report(
void run(revisionInstruction);
}, [abort, run]);
// 报告已展示(done)且记录过绘制主题、而界面主题已切换 → 提示重新绘制。
const themeMismatch =
phase === "done" && reportTheme !== null && reportTheme !== theme;
return {
phase,
error,
@@ -807,6 +1057,9 @@ export function useStep3Report(
start,
hydrateSaved,
redraw,
pause,
interject,
themeMismatch,
abort,
openArtifactFromStep,
openArtifactPreview,
@@ -108,6 +108,14 @@ function buildMaterials(opts: {
parts.push("# 业务链总控编排提示(最高优先级,按它的关注点/结构/口径来组织报告)");
parts.push(cp);
parts.push("");
// 编排提示里常藏着用户对"最终总结"的明确要求——让模型先提取再动笔,
// 否则结构化输出/重点席位这类要求容易被默认章节结构盖掉。
parts.push(
"> 动笔前请先从上面这段编排提示里提取用户对最终总结报告的明确要求(输出结构/格式、重点总结哪些席位、口径风格、详略取舍),并逐条满足:" +
"用户指定了输出结构(章节、表格、编号清单、固定模板等)就完全按用户结构输出、放弃默认章节结构;" +
"点名了重点席位就把它们前置并显著加大篇幅,未点名席位一两句带过;要求了详略取舍就照办,未被要求的章节可直接省略。",
);
parts.push("");
}
if (intentReady?.objective) {
@@ -157,9 +165,11 @@ function buildSummaryPrompt(opts: {
parts.push("");
parts.push(buildMaterials(opts));
parts.push(
"请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、叙事型结构" +
"(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线)、全程简体中文;" +
"若上面给了「业务链总控编排提示」,以它的要求优先组织报告。",
"请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、全程简体中文。" +
"报告结构:默认用叙事型结构(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线);" +
"但若上面的「业务链总控编排提示」对最终总结提出了要求(输出结构/格式、重点席位、口径、详略)," +
"那些要求优先于默认结构——用户指定的结构可以完全替换默认章节。" +
"若用户要求了结构化输出/流程图,按你职责设定在对话回复末尾追加 ```flow-json 代码块(前端据此实时绘制流程图)。",
);
return parts.join("\n");
}
@@ -0,0 +1,122 @@
/**
* flow-graph.ts 纯函数单测。
* 运行方式(项目无 vitest,对齐 dag-types.test.ts):
* npx esbuild src/roundtable-planning/lib/flow-graph.test.ts --bundle --format=esm \
* --platform=node --outfile=.tmp-tests/flow-graph.test.mjs
* node --test .tmp-tests/flow-graph.test.mjs
*/
import assert from "node:assert/strict";
import test from "node:test";
import {
extractFlowGraph,
layoutFlowGraph,
stripFlowFence,
} from "./flow-graph";
const FULL = [
"报告正文……",
"```flow-json",
'{"title":"方案执行流程","nodes":[',
'{"id":"n1","label":"环境评估","type":"stage","detail":"评估现场环境"},',
'{"id":"n2","label":"编制方案","type":"task","from":["n1"]},',
'{"id":"n3","label":"是否可行","type":"decision","from":[{"id":"n2","label":"评审"}]}',
"]}",
"```",
].join("\n");
test("extractFlowGraph:完整块解析出节点/边/标题", () => {
const g = extractFlowGraph(FULL);
assert.ok(g);
assert.equal(g.title, "方案执行流程");
assert.equal(g.nodes.length, 3);
assert.equal(g.nodes[0].type, "stage");
assert.deepEqual(g.edges, [
{ from: "n1", to: "n2" },
{ from: "n2", to: "n3", label: "评审" },
]);
});
test("extractFlowGraph:流式半截(fence 未闭合 + 尾对象截断)只取完整节点", () => {
const partial = [
"正文",
"```flow-json",
'{"title":"T","nodes":[',
'{"id":"a","label":"A","type":"start"},',
'{"id":"b","label":"B","type":"task","from":["a"]},',
'{"id":"c","label":"C","ty', // 截断
].join("\n");
const g = extractFlowGraph(partial);
assert.ok(g);
assert.equal(g.nodes.length, 2);
assert.deepEqual(g.edges, [{ from: "a", to: "b" }]);
});
test("extractFlowGraph:未知 type 落 task、重复 id 去重、悬空边丢弃", () => {
const text = [
"```flow-json",
'{"nodes":[',
'{"id":"x","label":"X","type":"banana"},',
'{"id":"x","label":"X-dup","type":"task"},',
'{"id":"y","label":"Y","type":"risk","from":["x","ghost"]}',
"]}",
"```",
].join("\n");
const g = extractFlowGraph(text);
assert.ok(g);
assert.equal(g.nodes.length, 2);
assert.equal(g.nodes[0].type, "task");
assert.equal(g.nodes[0].label, "X");
assert.deepEqual(g.edges, [{ from: "x", to: "y" }]);
});
test("extractFlowGraph:无 fence / 无完整节点返回 null", () => {
assert.equal(extractFlowGraph("普通回复,没有结构化数据"), null);
assert.equal(extractFlowGraph('```flow-json\n{"nodes":[{"id":"a"'), null);
});
test("extractFlowGraph:兼容独立 edges 数组(source/target 别名)", () => {
const text = [
"```flow-json",
'{"nodes":[{"id":"a","label":"A"},{"id":"b","label":"B"}],',
'"edges":[{"source":"a","target":"b","label":"走"}]}',
"```",
].join("\n");
const g = extractFlowGraph(text);
assert.ok(g);
assert.deepEqual(g.edges, [{ from: "a", to: "b", label: "走" }]);
});
test("stripFlowFence:闭合块替换为引导语并保留前后文", () => {
const out = stripFlowFence(FULL, false);
assert.ok(out.includes("报告正文"));
assert.ok(!out.includes("```flow-json"));
assert.ok(out.includes("已生成结构化流程数据"));
});
test("stripFlowFence:未闭合(流式中)用「正在输出」措辞", () => {
const out = stripFlowFence('正文\n```flow-json\n{"nodes":[', true);
assert.ok(out.includes("正在输出结构化流程数据"));
assert.ok(!out.includes("```flow-json"));
});
test("layoutFlowGraph:按前驱分层(根 0 层,子层递增),保持原始顺序", () => {
const g = extractFlowGraph(FULL)!;
const positioned = layoutFlowGraph(g);
assert.equal(positioned[0].y, 0);
assert.ok(positioned[1].y > positioned[0].y);
assert.ok(positioned[2].y > positioned[1].y);
assert.deepEqual(positioned.map((n) => n.id), ["n1", "n2", "n3"]);
});
test("layoutFlowGraph:环不死循环", () => {
const text = [
"```flow-json",
'{"nodes":[{"id":"a","label":"A","from":["b"]},{"id":"b","label":"B","from":["a"]}]}',
"```",
].join("\n");
const g = extractFlowGraph(text)!;
const positioned = layoutFlowGraph(g);
assert.equal(positioned.length, 2);
positioned.forEach((n) => assert.ok(Number.isFinite(n.x) && Number.isFinite(n.y)));
});
@@ -0,0 +1,282 @@
/**
* Step 3「总结报告」结构化流程图 —— flow-json 契约的纯函数层。
*
* 契约(与 roundtable-summary SOUL 对齐):用户要求结构化输出/流程图时,总结智能体
* 在对话回复末尾追加一个 ```flow-json 代码块:
*
* ```flow-json
* {"title":"…","nodes":[
* {"id":"n1","label":"…","type":"stage","detail":"…"},
* {"id":"n2","label":"…","type":"task","detail":"…","from":["n1"]},
* {"id":"n3","label":"…","type":"decision","detail":"…","from":[{"id":"n2","label":"是"}]}
* ]}
* ```
*
* 关键设计:**边长在节点身上**(`from` = 前驱列表)——流式输出时每解析出一个完整
* 节点,它的连线随之出现,前端给「最新节点的连线」挂蚂蚁线动画即可实现「画到哪、
* 哪条线在爬」。解析必须**容错半截 JSON**(流式中代码块尚未闭合):用平衡括号扫描
* 把 nodes 数组里**已完整**的对象逐个 JSON.parse,截断的尾对象直接忽略。
*/
export type FlowNodeType =
| "start"
| "end"
| "stage"
| "task"
| "decision"
| "risk"
| "deliverable"
| "role";
export interface FlowGraphNode {
id: string;
label: string;
type: FlowNodeType;
detail?: string;
}
export interface FlowGraphEdge {
from: string;
to: string;
label?: string;
}
export interface FlowGraphData {
title: string;
nodes: FlowGraphNode[];
edges: FlowGraphEdge[];
}
/** 各类型节点的配色(浅底 + 深字 + 强调点),前端按 type 区分颜色。 */
export const FLOW_NODE_TYPE_META: Record<
FlowNodeType,
{ label: string; bg: string; border: string; text: string; dot: string }
> = {
start: { label: "开始", bg: "#f1f5f9", border: "#94a3b8", text: "#334155", dot: "#64748b" },
end: { label: "结束", bg: "#f1f5f9", border: "#94a3b8", text: "#334155", dot: "#64748b" },
stage: { label: "阶段", bg: "#eff6ff", border: "#60a5fa", text: "#1d4ed8", dot: "#3b82f6" },
task: { label: "任务", bg: "#ecfeff", border: "#22d3ee", text: "#0e7490", dot: "#06b6d4" },
decision: { label: "判断", bg: "#fffbeb", border: "#fbbf24", text: "#b45309", dot: "#f59e0b" },
risk: { label: "风险", bg: "#fef2f2", border: "#f87171", text: "#b91c1c", dot: "#ef4444" },
deliverable: { label: "交付物", bg: "#f0fdf4", border: "#4ade80", text: "#15803d", dot: "#22c55e" },
role: { label: "角色", bg: "#faf5ff", border: "#c084fc", text: "#7e22ce", dot: "#a855f7" },
};
const VALID_TYPES = new Set<string>(Object.keys(FLOW_NODE_TYPE_META));
export const FLOW_FENCE_OPEN = "```flow-json";
/**
* 平衡括号扫描:从 `key`(如 `"nodes"`)后面的 `[` 开始,把数组里**已完整闭合**的
* 顶层 `{…}` 逐个 JSON.parse 收集;跳过字符串内的括号与转义;数组未闭合(流式半截)
* 时收集到哪算哪,截断的尾对象忽略。
*/
function collectObjects(text: string, key: string): Record<string, unknown>[] {
const keyIdx = text.indexOf(key);
if (keyIdx < 0) return [];
const arrStart = text.indexOf("[", keyIdx);
if (arrStart < 0) return [];
const out: Record<string, unknown>[] = [];
let depth = 0;
let objStart = -1;
let inStr = false;
let esc = false;
for (let i = arrStart + 1; i < text.length; i++) {
const ch = text[i];
if (inStr) {
if (esc) esc = false;
else if (ch === "\\") esc = true;
else if (ch === '"') inStr = false;
continue;
}
if (ch === '"') {
inStr = true;
continue;
}
if (ch === "{") {
if (depth === 0) objStart = i;
depth++;
continue;
}
if (ch === "}") {
depth--;
if (depth === 0 && objStart >= 0) {
try {
const parsed = JSON.parse(text.slice(objStart, i + 1)) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
out.push(parsed as Record<string, unknown>);
}
} catch {
/* 截断/非法对象:跳过 */
}
objStart = -1;
}
continue;
}
if (ch === "]" && depth === 0) break; // 数组正常闭合
}
return out;
}
function asString(v: unknown): string {
return typeof v === "string" ? v.trim() : "";
}
/** 把 raw 节点对象规范化;id/label 缺失返回 null。未知 type 落到 task。 */
function toNode(raw: Record<string, unknown>): FlowGraphNode | null {
const id = asString(raw.id);
const label = asString(raw.label) || asString(raw.name);
if (!id || !label) return null;
const rawType = asString(raw.type).toLowerCase();
const type = (VALID_TYPES.has(rawType) ? rawType : "task") as FlowNodeType;
const detail = asString(raw.detail) || asString(raw.description) || undefined;
return { id, label, type, detail };
}
/** 节点 `from` 字段(前驱列表,元素是 "n1" 或 {id:"n1",label:"是"})→ 入边列表。 */
function edgesFromNode(raw: Record<string, unknown>, nodeId: string): FlowGraphEdge[] {
const from = raw.from ?? raw.prev;
if (!Array.isArray(from)) return [];
const out: FlowGraphEdge[] = [];
for (const entry of from) {
if (typeof entry === "string" && entry.trim()) {
out.push({ from: entry.trim(), to: nodeId });
} else if (entry && typeof entry === "object") {
const o = entry as Record<string, unknown>;
const fromId = asString(o.id) || asString(o.from);
if (!fromId) continue;
const label = asString(o.label) || undefined;
out.push({ from: fromId, to: nodeId, label });
}
}
return out;
}
/** 独立 `edges` 数组(兼容形态:{from,to,label} / {source,target})→ 边。 */
function toEdge(raw: Record<string, unknown>): FlowGraphEdge | null {
const from = asString(raw.from) || asString(raw.source);
const to = asString(raw.to) || asString(raw.target);
if (!from || !to) return null;
const label = asString(raw.label) || undefined;
return { from, to, label };
}
/**
* 从(可能流式半截的)回复文本里提取最后一个 ```flow-json 块并解析。
* 没有 fence 或一个完整节点都没有 → null。
*/
export function extractFlowGraph(text: string): FlowGraphData | null {
const start = text.lastIndexOf(FLOW_FENCE_OPEN);
if (start < 0) return null;
let body = text.slice(start + FLOW_FENCE_OPEN.length);
const close = body.indexOf("```");
if (close >= 0) body = body.slice(0, close);
const titleMatch = /"title"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(body);
let title = "";
if (titleMatch) {
try {
title = JSON.parse(`"${titleMatch[1]}"`) as string;
} catch {
title = titleMatch[1];
}
}
const rawNodes = collectObjects(body, '"nodes"');
const nodes: FlowGraphNode[] = [];
const seen = new Set<string>();
const edges: FlowGraphEdge[] = [];
for (const raw of rawNodes) {
const node = toNode(raw);
if (!node || seen.has(node.id)) continue;
seen.add(node.id);
nodes.push(node);
edges.push(...edgesFromNode(raw, node.id));
}
// 兼容独立 edges 数组(contract 主推 from-on-node,但模型可能两种都写)。
for (const raw of collectObjects(body, '"edges"')) {
const edge = toEdge(raw);
if (edge) edges.push(edge);
}
if (nodes.length === 0) return null;
// 只保留两端节点都已出现的边 + 去重(流式中前驱未出现的边等下一轮解析再进来)。
const edgeKeys = new Set<string>();
const validEdges = edges.filter((e) => {
if (!seen.has(e.from) || !seen.has(e.to) || e.from === e.to) return false;
const key = `${e.from}->${e.to}`;
if (edgeKeys.has(key)) return false;
edgeKeys.add(key);
return true;
});
return { title, nodes, edges: validEdges };
}
/**
* 把回复里的 flow-json 块替换成一句人话(气泡里不展示生 JSON)。
* 块未闭合(流式中)→「正在输出」措辞;已闭合 → 引导去看流程图画布。
*/
export function stripFlowFence(text: string, streaming: boolean): string {
const start = text.lastIndexOf(FLOW_FENCE_OPEN);
if (start < 0) return text;
const after = text.slice(start + FLOW_FENCE_OPEN.length);
const close = after.indexOf("```");
const tail = close >= 0 ? after.slice(close + 3) : "";
const placeholder =
close < 0 && streaming
? "\n\n> 📊 正在输出结构化流程数据,流程图画布同步绘制中…"
: "\n\n> 📊 已生成结构化流程数据,可在右侧「流程图」画布查看(点击节点看详情)。";
return `${text.slice(0, start).trimEnd()}${placeholder}${tail}`;
}
// ── 分层布局(自上而下:前驱层 +1,同层横向铺开)────────────────────────────
export interface PositionedFlowNode extends FlowGraphNode {
x: number;
y: number;
}
export const FLOW_NODE_W = 176;
const X_GAP = 224;
const Y_GAP = 132;
/** 最长路径分层(环保护:访问中的节点按 0 层处理,保证终止)。 */
export function layoutFlowGraph(data: FlowGraphData): PositionedFlowNode[] {
const preds = new Map<string, string[]>();
for (const e of data.edges) {
const list = preds.get(e.to);
if (list) list.push(e.from);
else preds.set(e.to, [e.from]);
}
const memo = new Map<string, number>();
const visiting = new Set<string>();
const layerOf = (id: string): number => {
const cached = memo.get(id);
if (cached !== undefined) return cached;
if (visiting.has(id)) return 0; // 环:斩断,按根层处理
visiting.add(id);
const ps = preds.get(id) ?? [];
const layer = ps.length === 0 ? 0 : Math.max(...ps.map(layerOf)) + 1;
visiting.delete(id);
memo.set(id, layer);
return layer;
};
const byLayer = new Map<number, FlowGraphNode[]>();
for (const n of data.nodes) {
const layer = layerOf(n.id);
const list = byLayer.get(layer);
if (list) list.push(n);
else byLayer.set(layer, [n]);
}
const pos = new Map<string, { x: number; y: number }>();
for (const [layer, nodes] of byLayer) {
const rowWidth = (nodes.length - 1) * X_GAP;
nodes.forEach((n, i) => {
pos.set(n.id, { x: i * X_GAP - rowWidth / 2, y: layer * Y_GAP });
});
}
// 保持原始(流式)顺序输出,方便调用方做「最新节点」判断。
return data.nodes.map((n) => ({ ...n, ...pos.get(n.id)! }));
}
@@ -39,6 +39,7 @@ import {
SplitSquareVertical,
Trash2,
UserPlus,
Wand2,
X,
} from "lucide-react";
import {
@@ -79,6 +80,7 @@ import {
createChain,
deleteChain,
listChains,
optimizeChainPrompt,
setChainPublic,
updateChain,
} from "@/roundtable-planning/api/chains";
@@ -317,6 +319,80 @@ export default function BusinessChainEditorPage() {
setStageGroups((prev) => setStageGoal(prev, stageIdx, goal));
}, []);
// ── 提示词 AI 优化(总控提示词 / 各 Stage 阶段目标)────────────────────────
const [optimizingCoordinator, setOptimizingCoordinator] = useState(false);
const [goalOptimizingIdx, setGoalOptimizingIdx] = useState<number | null>(null);
// 把链条当前结构拼成上下文,让模型贴着「这条链有哪些阶段/席位」来优化。
const buildChainContextText = useCallback((): string => {
const lines: string[] = [];
if (title.trim()) lines.push(`链条标题:${title.trim()}`);
if (description.trim()) lines.push(`链条描述:${description.trim()}`);
stageGroups.forEach((g, i) => {
const seatNames = g.seats.map((s) => s.name || s.agent_id).join("、") || "(空)";
const goal = g.goal?.trim() ? `;阶段目标:${g.goal.trim()}` : "";
lines.push(`Stage ${i + 1}(${g.seats.length > 1 ? "并行" : "串行"}):${seatNames}${goal}`);
});
return lines.join("\n");
}, [title, description, stageGroups]);
const handleOptimizeCoordinatorPrompt = useCallback(async () => {
if (optimizingCoordinator) return;
const text = coordinatorPrompt.trim();
if (!text) {
toast.warning("请先写一版总控提示词,再让 AI 优化");
return;
}
setOptimizingCoordinator(true);
try {
const optimized = await optimizeChainPrompt({
promptText: text,
kind: "coordinator",
context: buildChainContextText(),
});
if (optimized.trim()) {
setCoordinatorPrompt(optimized.trim().slice(0, 4000));
toast.success("总控提示词已优化,可继续手动调整");
} else {
toast.error("优化失败:模型未返回内容");
}
} catch (err) {
toast.error(`优化失败:${err instanceof Error ? err.message : String(err)}`);
} finally {
setOptimizingCoordinator(false);
}
}, [optimizingCoordinator, coordinatorPrompt, buildChainContextText]);
const handleOptimizeStageGoal = useCallback(
async (stageIdx: number) => {
if (goalOptimizingIdx !== null) return;
const goal = stageGroups[stageIdx]?.goal?.trim() ?? "";
if (!goal) {
toast.warning("请先写一版本阶段目标,再让 AI 优化");
return;
}
setGoalOptimizingIdx(stageIdx);
try {
const optimized = await optimizeChainPrompt({
promptText: goal,
kind: "stage_goal",
context: `${buildChainContextText()}\n本次优化的是 Stage ${stageIdx + 1} 的阶段目标。`,
});
if (optimized.trim()) {
setStageGroups((prev) => setStageGoal(prev, stageIdx, optimized.trim().slice(0, 500)));
toast.success(`Stage ${stageIdx + 1} 阶段目标已优化`);
} else {
toast.error("优化失败:模型未返回内容");
}
} catch (err) {
toast.error(`优化失败:${err instanceof Error ? err.message : String(err)}`);
} finally {
setGoalOptimizingIdx(null);
}
},
[goalOptimizingIdx, stageGroups, buildChainContextText],
);
const splitSeat = useCallback((agentId: string) => {
setStageGroups((prev) => splitSeatToNewStage(prev, agentId));
}, []);
@@ -759,9 +835,32 @@ export default function BusinessChainEditorPage() {
>
<div className="flex items-center justify-between border-b border-border bg-muted/50 px-4 py-2">
<span className="select-none text-sm font-medium text-muted-foreground">总控提示词</span>
<span className="select-none text-[10px] text-muted-foreground/70">
写给总控:怎么派活、做什么(仅分层研讨生效,可选)
</span>
<div className="flex items-center gap-2">
<span className="select-none text-[10px] text-muted-foreground/70">
写给总控:怎么派活、做什么(仅分层研讨生效,可选)
</span>
<button
type="button"
onClick={handleOptimizeCoordinatorPrompt}
disabled={optimizingCoordinator || !coordinatorPrompt.trim()}
title={
coordinatorPrompt.trim()
? "让 AI 按链条结构重写这段提示词(更明确的派活方式、交付口径与总结要求)"
: "先写一版提示词,AI 才能在它基础上优化"
}
className={cn(
"inline-flex shrink-0 items-center gap-1 border border-primary/30 bg-primary/5 px-2 py-0.5 text-[10px] font-semibold text-primary transition hover:bg-primary/10 disabled:cursor-not-allowed disabled:opacity-50",
R_CTRL,
)}
>
{optimizingCoordinator ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Wand2 className="size-3" />
)}
{optimizingCoordinator ? "优化中…" : "AI 优化"}
</button>
</div>
</div>
<textarea
value={coordinatorPrompt}
@@ -856,6 +955,8 @@ export default function BusinessChainEditorPage() {
onMergeSeat={mergeSeatToStage}
onSplitSeat={splitSeat}
onStageGoalChange={setGoal}
goalOptimizingIdx={goalOptimizingIdx}
onOptimizeGoal={handleOptimizeStageGoal}
onSeatDetail={showDetail}
/>
</main>
@@ -1049,6 +1150,8 @@ function StagesColumn({
onMergeSeat,
onSplitSeat,
onStageGoalChange,
goalOptimizingIdx,
onOptimizeGoal,
onSeatDetail,
}: {
stageGroups: StageGroups;
@@ -1062,6 +1165,9 @@ function StagesColumn({
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
onSplitSeat: (agentId: string) => void;
onStageGoalChange: (stageIdx: number, goal: string) => void;
/** 正在 AI 优化阶段目标的 stage 下标(null = 没有进行中的优化)。 */
goalOptimizingIdx: number | null;
onOptimizeGoal: (stageIdx: number) => void;
onSeatDetail: (agentId: string, name?: string) => void;
}) {
// 跨 Stage 的全局序号(头像编号):每个 Stage 起始累加。
@@ -1156,6 +1262,9 @@ function StagesColumn({
onMergeSeat={onMergeSeat}
onSplitSeat={onSplitSeat}
onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)}
goalOptimizing={goalOptimizingIdx === stageIdx}
anyGoalOptimizing={goalOptimizingIdx !== null}
onOptimizeGoal={() => onOptimizeGoal(stageIdx)}
onSeatDetail={onSeatDetail}
/>
))}
@@ -1181,6 +1290,9 @@ function StageSection({
onMergeSeat,
onSplitSeat,
onGoalChange,
goalOptimizing,
anyGoalOptimizing,
onOptimizeGoal,
onSeatDetail,
}: {
stageIdx: number;
@@ -1195,6 +1307,11 @@ function StageSection({
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
onSplitSeat: (agentId: string) => void;
onGoalChange: (goal: string) => void;
/** 本 Stage 的阶段目标正在 AI 优化中。 */
goalOptimizing: boolean;
/** 任意 Stage 的优化在途(互斥,避免并发请求互相覆盖)。 */
anyGoalOptimizing: boolean;
onOptimizeGoal: () => void;
onSeatDetail: (agentId: string, name?: string) => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: STAGE_PREFIX + stageIdx });
@@ -1292,11 +1409,36 @@ function StageSection({
R_CTRL,
)}
/>
{!hasParallelStage && (group.goal?.trim()?.length ?? 0) > 0 && (
<p className="mt-1 text-[10px] text-muted-foreground">
提示:填写本阶段目标后,本链条将按阶段顺序由总控逐阶段派活(分层研讨),并把该目标注入总控。
</p>
)}
<div className="mt-1 flex items-start justify-between gap-2">
{!hasParallelStage && (group.goal?.trim()?.length ?? 0) > 0 ? (
<p className="text-[10px] text-muted-foreground">
提示:填写本阶段目标后,本链条将按阶段顺序由总控逐阶段派活(分层研讨),并把该目标注入总控。
</p>
) : (
<span />
)}
<button
type="button"
onClick={onOptimizeGoal}
disabled={anyGoalOptimizing || !(group.goal?.trim())}
title={
group.goal?.trim()
? "让 AI 重写本阶段目标(明确产出物与对上一阶段的承接)"
: "先写一版阶段目标,AI 才能在它基础上优化"
}
className={cn(
"inline-flex shrink-0 items-center gap-1 border border-primary/30 bg-primary/5 px-2 py-0.5 text-[10px] font-semibold text-primary transition hover:bg-primary/10 disabled:cursor-not-allowed disabled:opacity-50",
R_CTRL,
)}
>
{goalOptimizing ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Wand2 className="size-3" />
)}
{goalOptimizing ? "优化中…" : "AI 优化"}
</button>
</div>
</div>
)}