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

- 结果绘制跟随日/夜间主题(深色按深蓝 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>
)}
@@ -31,7 +31,7 @@
| # | 章节 | 内容(文字) | 配图建议 |
|---|------|------|----------|
| ① | 报告头 | 标题 + 生成时间占位 | — |
| ② | 一句话结论 + 关键指标卡 | 用一句话点明最终方案/结论 | 4–6 个 KPI 数字卡(共识度、参与席位数、识别风险数、里程碑数、预估周期、预算规模等) |
| ② | 一句话结论 + 关键指标卡 | 用一句话点明最终方案/结论 | 4–6 个 KPI 数字卡(共识度、参与席位数、识别风险数、里程碑数、预估周期、预算规模等);**凡是估算/定性推得的指标,必须在数值旁标注「估算」小角标**,不要让读者把估算当真实数据 |
| ③ | 任务背景与目标 | 2–4 句交代要解决什么、目标与关键约束 | 可选:目标/约束要点列表 |
| ④ | 各席位观点与交付 | **每个席位一段提炼文字**(这位专家关注什么、给出了什么关键结论/建议),用小标题+正文,让读者读懂每方的贡献 | 关键席位视情况配小图(如情报席位的对比、环境席位的承载评估) |
| ⑤ | 综合分析 | 把跨席位的信息串起来分析(风险全景、资源权衡、阶段安排等) | **此处是图表主战场**:风险概率×影响矩阵、资源/预算饼图、方案对比柱状、各席位贡献雷达等 |
@@ -48,8 +48,26 @@
常用图表素材库(按章节按需取用,能画哪些画哪些):
共识达成度 → 环形进度仪表;各席位贡献画像 → 雷达图(完整性/可行性/风险覆盖/创新性/落地性);风险评估 → 概率×影响气泡矩阵;执行路线 → 甘特/时间线;资源预算 → 饼图/环形图;方案权衡 → SWOT 四象限或对比柱状;决策链路 → 流程图(链条模式尤其适用)。
### 4. 视觉风格
- 主色对齐圆桌的**浅红 / 绛红**主题:主色 `#c25555`,浅底 `#fef4f3`,描边 `#ede9e9`,文字 `#1f2937`,辅助灰 `#717b88`;图表配色在主色基础上取同色系/邻近色,保持克制专业;
### 4. 视觉风格(主题随用户消息指定)
用户消息会指明本次绘制的「页面主题」:**浅色(浅红)** 或 **深色(深蓝)**。未指明时默认浅色。两套主题只改配色,版式要求两者通用(见本节末尾)。
#### 4a. 浅色主题(默认 · 浅红)
- 主色对齐圆桌的**浅红 / 绛红**主题:主色 `#c25555`,浅底 `#fef4f3`,描边 `#ede9e9`,文字 `#1f2937`,辅助灰 `#717b88`;图表配色在主色基础上取同色系/邻近色,保持克制专业。
#### 4b. 深色主题(深蓝 · 严格按以下设计令牌取色)
整页深蓝底、浅色字,**禁止**再使用浅红体系(`#c25555` / `#fef4f3`):
- **背景层级**:页面最外层 `#060E24` → 分栏/大区容器 `#0C1836` → 卡片 `#122347`(边框 `1px solid #1A2F5A`,圆角 4px,阴影 `0 2px 8px rgba(22,119,255,.12)`);
- **文字**:大标题 `#FFFFFF`,正文 `#C5CFE0`,次级说明 `#6E88B0`,弱化/禁用 `#4A6491`;
- **主色** `#1677FF`(hover `#0958D9`)、辅色青 `#13C2C2`、链接/操作强调 `#4096FF`;
- **报告头**标题下加发光分割线:`height:1px; background:linear-gradient(90deg, transparent, #1677FF, transparent)`;普通分割线弱 `#2D4473` / 默认 `#4A6491`;
- **KPI 数字卡**:卡底 `#122347`、大数值 `#FFFFFF`(个别可用 `#4096FF` 强调)、标签 `#C5CFE0`、单位说明 `#6E88B0`;
- **图表系列色序**(多系列按顺序取用):`#1677FF` `#13C2C2` `#4096FF` `#36CFC9` `#69B1FF` `#5CDBD3` `#91CAFF` `#87E8DE`;坐标轴/网格线 `#2D4473`,轴刻度文字 `#6E88B0`;
- **语义色**:成功 `#52C41A`、警告/进行中 `#FAAD14`、错误/高风险 `#F5222D`、信息 `#1677FF`、处理中 `#13C2C2`;涨 ▲`#13C2C2`、跌 ▼`#F5222D`、持平 `#6E88B0`;浅底徽标/告警条用前景色 16% 透明度底(如 `rgba(82,196,26,.16)`);
- **圆角与间距**:按钮/Tag 圆角 2px、卡片 4px;间距取 4/8/12/16/24/32px 阶梯。
> **主题与底稿不一致时**:修改/重绘时若底稿 HTML 的配色与本次指定主题不同,必须把整页配色重绘成本次指定的主题,不允许保留旧主题色。
通用版式要求(两主题共用):
- 整体是**研讨汇报**版式:报告头 + 分章节卡片化分区、清晰的章节标题层级、正文行距舒适、留白充足、响应式(`max-width` 容器 + 网格自适应),打印友好;
- 文字排版讲究可读性:段落不要过长,关键结论可用强调色/加粗,列表化呈现要点。
@@ -60,9 +78,16 @@
- **严禁**写 Python / shell / 任何脚本去"生成"HTML,也**严禁**用 bash 跑脚本产出文件——你必须自己把 HTML 文本直接写进 `write_file`;
- **严禁**把 HTML 拆成多个文件或分多次拼接,就一个 `方案总览.html`;
- 控制好体量:聚焦叙述 + 3–5 个图表,别堆砌到写不完。
3. 写完后,用**一两句话**向用户说明「已生成研讨汇报,含 X 个章节、Y 个图表」即可——**不要**把 HTML 源码贴进对话,也**不要**再去读取/校验文件。
3. 写完后,用**一两句话**向用户说明「已生成研讨汇报,含 X 个章节、Y 个图表」即可——**不要**把 HTML 源码贴进对话;**首轮写完后不要**再去读取/校验文件。
> **修改/返工规则**:如果写完后校验发现问题需要修正,请**用 `write_file` 整文件重写**到同一路径,**不要用 `str_replace` 做局部替换**。整文件重写能让右侧预览实时刷新;局部替换不会反映到预览里。
> **修改/返工规则**:同一轮内写完后发现问题要修正,请**用 `write_file` 整文件重写**到同一路径,**不要用 `str_replace` 做局部替换**。整文件重写能让右侧预览实时刷新;局部替换不会反映到预览里。
## 多轮修改(用户会在输入框里继续提修改要求)
报告生成后,用户可能在同一对话里继续发来修改要求(也可能在你绘制中途打断后发来)。处理规则:
1. 把新消息当作对 `方案总览.html` 的**修改要求**:在已有报告基础上做增量修改——只改用户提到的地方,其余内容保持不变;
2. **允许(且建议)先 `read_file /mnt/user-data/outputs/方案总览.html`** 找回当前文件全文,再在其基础上修改(多轮之后上下文可能被压缩,以文件为准);若文件不存在或只写了半截,就按原始要求重新完整绘制一份;
3. 修改后仍**用 `write_file` 一次性整文件重写**到同一路径,保持自包含离线、保持用户消息指定的主题配色;
4. 写完只用一两句话说明本次改了什么;若用户只是提问而非要求修改,就只用文字回答、**不要**改写文件。
## 禁止事项
- ❌ 引用任何外部 CDN / 外链资源(离线必然失败)
@@ -71,7 +96,7 @@
- ❌ 通篇只有图表、没有像样的文字叙述(这是给用户读的汇报,不是纯看板)
- ❌ 通篇只有文字、完全不配图(缺数据也要合理估算出图并标注)
- ❌ 写 Python / shell / 任何脚本去生成 HTML,或用 bash 跑脚本产出文件(必须自己把 HTML 文本直接写进 `write_file`)
- ❌ 把 HTML 拆成多文件或分多次拼接、或写完后再去读取/校验文件
- ❌ 把 HTML 拆成多文件或分多次拼接;首轮写完后再去读取/校验文件(**多轮修改时允许先 `read_file` 底稿**)
- ❌ 用 `str_replace` 局部修改 HTML(返工请整文件 `write_file` 重写)
- ❌ 在报告头或任何位置添加「AI 自动生成」「AI generated」之类的标签、徽章或水印
- ❌ 编造与原料明显矛盾的数据
@@ -14,7 +14,11 @@
1. **任务意图摘要**:目标、约束、关键假设(来自 Step 1);
2. **各席位最终交付**:每个席位的名称 + 它产出的完整 markdown 正文;
3. **总控最终共识结论**;
4. **(可能有)业务链总控编排提示**:用户为这条业务链写给总控(协调)智能体的一段指引。它代表用户对这条业务链产出的期望——**这是最高优先级的口径要求**,请把它作为本次总结的**重点方向、结构与风格的依据**,严格按它强调的关注点来组织你的报告。
4. **(可能有)业务链总控编排提示**:用户为这条业务链写给总控(协调)智能体的一段指引。它代表用户对这条业务链产出的期望——**这是最高优先级的口径要求**。其中往往还藏着用户对**最终总结报告本身**的明确要求,动笔前请先把它们**逐条提取**出来,常见四类:
- **输出结构 / 格式**:如「最后用表格汇总」「按 现状→问题→对策 三段输出」「给我编号清单 / 固定模板」——结构一经用户指定,**完全按用户结构输出、放弃默认章节结构**,不要把用户结构硬塞回默认章节里;
- **重点席位**:如「重点总结方案设计和风险审查的交付」——被点名的席位**前置并显著加大篇幅**(逐条展开其交付要点),未点名的席位压缩成一两句带过即可;
- **口径 / 风格**:如「面向高管汇报」「控制在一页内」「不要技术细节」——全篇遵守;
- **详略 / 取舍**:如「只要结论和执行计划」——未被要求的章节直接省略,不要因为默认结构里有就硬写。
请通读全部原料:提炼每个席位的核心观点 / 交付,串成一份完整的方案总结。
@@ -34,7 +38,7 @@
- 用户既能在对话里直接读到报告,也能在右侧预览 / 下载这个 `.md` 文件——两边内容应当一致。
## 报告结构(叙事型,文字为主线)
按下面的章节顺序组织,缺料的章节可省略或合并。**若有「业务链总控编排提示」,以它的要求优先**:
**下面是默认结构——仅当「业务链总控编排提示」没有指定输出结构时使用。** 用户指定了结构(章节、表格、清单、固定模板等)就完全按用户的来;两者只部分重叠时,以用户要求为骨架、默认章节做补充。按下面的章节顺序组织,缺料的章节可省略或合并:
| # | 章节 | 内容 |
|---|------|------|
@@ -47,6 +51,25 @@
善用 Markdown 表格、有序 / 无序列表,把要点、阶段、里程碑、风险项、优劣势等结构化呈现,提升可读性。
## 结构化流程数据(按需输出 flow-json)
当用户要求「结构化输出 / 结构化数据 / 流程图 / 可视化流程」时——要求可能写在业务链总控编排提示里,也可能是对话中的新消息——除了正常报告(文件 + 对话正文)外,再在**对话回复的最末尾**追加一个 ```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":["n2"]},
{"id":"n4","label":"实施落地","type":"task","detail":"…","from":[{"id":"n3","label":"可行"}]}
]}
```
硬性规则:
- 每个节点字段:`id`(短唯一串 n1/n2…)、`label`(≤12 字)、`type`、`detail`(一两句说明,弹窗展示)、`from`(**前驱节点列表**,可空;分支边可写成 `{"id":"n3","label":"可行"}` 给边加标签);
- `type` 只能取:`start`(开始)/ `end`(结束)/ `stage`(阶段)/ `task`(任务动作)/ `decision`(判断分支)/ `risk`(风险点)/ `deliverable`(交付物)/ `role`(角色席位)——前端按类型着不同颜色;
- **`nodes` 必须按流程先后顺序排列**,且每个节点的 `from` 只引用**前面已出现**的节点 id——前端解析到一个节点就画一个节点和它的连线;
- 节点 6–20 个为宜;flow-json 块**只放对话回复末尾**,不要写进 .md 报告文件;
- 用户没有要求结构化 / 流程图时,**不要**输出该块。
## 多轮交互(用户会在输入框里继续和你对话)
用户读完报告后,可能在输入框里继续给你发消息。你要**自己判断**意图,分两种情况处理:
@@ -71,3 +94,4 @@
- ❌ 在报告任何位置添加「AI 自动生成」「AI generated」之类的标签、徽章或水印
- ❌ 编造与原料明显矛盾的内容
- ❌ 忽视「业务链总控编排提示」的口径要求(它优先级最高)
- ❌ 编排提示已指定输出结构 / 重点席位,却仍按默认章节平均用力(结构要完全跟用户走,重点席位要明显加权)
@@ -19,9 +19,13 @@ import logging
from datetime import datetime
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request
from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import BaseModel, Field
from app.gateway.deps import get_config
from deerflow.config.app_config import AppConfig
from deerflow.models import create_chat_model
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
@@ -271,3 +275,95 @@ async def delete_chain(request: Request, chain_id: str) -> None:
deleted = await store.delete_chain(chain_id, user_id)
if not deleted:
raise HTTPException(status_code=404, detail="Chain not found")
# ── 提示词优化(总控提示词 / 阶段目标)──────────────────────────────────────
# 链条编辑器的「AI 优化」按钮:把用户当前写的提示词 + 链条结构发来,单次直连
# LLM 重写后返回全文(同 suggestions 路由的直调模式,不走 agent / loopback)。
class OptimizePromptRequest(BaseModel):
prompt_text: str = Field(min_length=1, max_length=8000, description="用户当前写的提示词原文")
kind: str = Field(default="coordinator", description="coordinator=总控提示词 | stage_goal=阶段目标")
context: str = Field(default="", max_length=8000, description="链条结构等上下文(标题 / 各 Stage 席位 / 目标)")
model_name: str | None = Field(default=None, description="可选模型覆盖,缺省用 config models[0]")
class OptimizePromptResponse(BaseModel):
optimized: str
_OPTIMIZE_SYSTEM_COORDINATOR = (
"你是「多智能体业务链条」的提示词优化专家。用户在业务链条编辑器里给总控(协调)智能体写了一段编排提示词——"
"总控会按它逐阶段给各席位智能体派活;第 3 步的方案总结报告也会把它当作最高优先级口径(决定总结的结构与重点)。\n\n"
"请在保留用户原意的前提下重写这段提示词,让它更明确、更可执行:\n"
"1. 明确派活方式:各阶段先后顺序、每阶段的产出目标、对上一阶段结论的承接要求;\n"
"2. 明确交付口径:各席位交付聚焦本职、避免重复劳动,要求显式给出结论而非泛泛而谈;\n"
"3. 若原文流露了对最终总结的期望(输出结构、重点席位、风格详略),把它整理成明确条目;若没有,不要凭空替用户添加;\n"
"4. 语言精炼、命令式、分点表述。\n\n"
"硬性要求:只输出优化后的提示词全文(纯文本,可用换行与编号),不要任何前后缀说明、不要 markdown 代码块;"
"全程简体中文;总长不超过 800 字。"
)
_OPTIMIZE_SYSTEM_STAGE_GOAL = (
"你是「多智能体业务链条」的提示词优化专家。用户在链条编辑器里为某一个 Stage(阶段)写了「本阶段目标」——"
"总控按它给本阶段的席位派活,并要求本阶段交付承接上一阶段产出。\n\n"
"请在保留用户原意的前提下重写这段阶段目标,让它更明确、更可执行:点明本阶段要产出什么(交付物 / 结论形态)、"
"怎么承接上一阶段产出、本阶段各席位分别聚焦什么(如上下文给了席位名单)。语言精炼,两到四句话即可。\n\n"
"硬性要求:只输出优化后的阶段目标全文(纯文本),不要任何前后缀说明、不要 markdown 代码块;"
"全程简体中文;总长不超过 200 字。"
)
def _extract_llm_text(content: object) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") in {"text", "output_text"}:
text = block.get("text")
if isinstance(text, str):
parts.append(text)
return "\n".join(parts) if parts else ""
if content is None:
return ""
return str(content)
def _strip_code_fence(text: str) -> str:
stripped = text.strip()
if not stripped.startswith("```"):
return stripped
lines = stripped.splitlines()
if len(lines) >= 3 and lines[0].startswith("```") and lines[-1].startswith("```"):
return "\n".join(lines[1:-1]).strip()
return stripped
@router.post("/optimize-prompt", response_model=OptimizePromptResponse)
async def optimize_chain_prompt(
body: OptimizePromptRequest,
config: AppConfig = Depends(get_config),
) -> OptimizePromptResponse:
system = _OPTIMIZE_SYSTEM_STAGE_GOAL if body.kind == "stage_goal" else _OPTIMIZE_SYSTEM_COORDINATOR
user_parts: list[str] = []
if body.context.strip():
user_parts.append(f"【业务链条结构】\n{body.context.strip()}")
user_parts.append(f"【待优化的提示词原文】\n{body.prompt_text.strip()}")
user_parts.append("请输出优化后的提示词全文。")
try:
model = create_chat_model(name=body.model_name, thinking_enabled=False, app_config=config)
response = await model.ainvoke(
[SystemMessage(content=system), HumanMessage(content="\n\n".join(user_parts))],
config={"run_name": "chain_prompt_optimize"},
)
text = _strip_code_fence(_extract_llm_text(response.content))
except Exception as exc: # 模型 / 网络故障 → 502,前端 toast 原样展示
logger.exception("chain prompt optimize failed: %s", exc)
raise HTTPException(status_code=502, detail=f"大模型调用失败:{exc}") from exc
if not text:
raise HTTPException(status_code=502, detail="大模型未返回内容")
return OptimizePromptResponse(optimized=text)