diff --git a/frontend-web/src/roundtable-planning/api/chains.ts b/frontend-web/src/roundtable-planning/api/chains.ts index dce9b6ca..85d6a8ac 100644 --- a/frontend-web/src/roundtable-planning/api/chains.ts +++ b/frontend-web/src/roundtable-planning/api/chains.ts @@ -174,3 +174,34 @@ export async function deleteChain(id: string): Promise { 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 { + 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 ?? ""; +} diff --git a/frontend-web/src/roundtable-planning/api/drafts.ts b/frontend-web/src/roundtable-planning/api/drafts.ts index 1906a07b..9ce15956 100644 --- a/frontend-web/src/roundtable-planning/api/drafts.ts +++ b/frontend-web/src/roundtable-planning/api/drafts.ts @@ -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 diff --git a/frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx b/frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx new file mode 100644 index 00000000..25ec4815 --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx @@ -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 ( +
+ +
+ + + {meta.label} + +
+
{node.label}
+ +
+ ); +} + +// 必须模块级常量:每次 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(null); + // 用户手动拖过的节点位置 —— 数据增量更新重新布局时保留,不被布局覆盖。 + const manualPosRef = useRef>(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(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 ( +
+ {/* 头部:标题 + 工具栏 */} +
+
+ + + {data.title || "方案流程图"} + + + {data.nodes.length} 节点 · {data.edges.length} 连线 + + {streaming && ( + + + 绘制中 + + )} +
+
+ void rf.zoomIn({ duration: 150 })}> + + + void rf.zoomOut({ duration: 150 })}> + + + void rf.fitView({ duration: 250, padding: 0.18 })} + > + + + + {fullscreen ? : } + + + + +
+
+ + {/* 画布 */} +
+ setSelectedId(n.id)} + fitView + fitViewOptions={{ padding: 0.18 }} + minZoom={0.2} + maxZoom={2.5} + panOnDrag + zoomOnScroll + nodesConnectable={false} + deleteKeyCode={null} + proOptions={{ hideAttribution: true }} + > + + + + {/* 图例 */} +
+ {Object.entries(FLOW_NODE_TYPE_META).map(([type, meta]) => ( + + + {meta.label} + + ))} +
+
+ + {/* 节点详情弹窗 */} + {selectedNode && ( + setSelectedId(null)} + onJump={(id) => setSelectedId(id)} + /> + )} +
+ ); +} + +function ToolButton({ + title, + onClick, + children, +}: { + title: string; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +// ── 节点详情弹窗(类型 / 说明 / 上下游连接)───────────────────────────────── + +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 ( +
+
e.stopPropagation()} + > +
+
+ + + {meta.label} + +

+ {node.label} +

+
+ +
+
+
+
+ 详细说明 +
+

+ {node.detail?.trim() || "(该节点未提供详细说明)"} +

+
+ {incoming.length > 0 && ( + ({ id: e.from, label: labelOf(e.from), edgeLabel: e.label }))} onJump={onJump} /> + )} + {outgoing.length > 0 && ( + ({ id: e.to, label: labelOf(e.to), edgeLabel: e.label }))} onJump={onJump} /> + )} +
+
+
+ ); +} + +function ConnectionList({ + title, + entries, + onJump, +}: { + title: string; + entries: Array<{ id: string; label: string; edgeLabel?: string }>; + onJump: (id: string) => void; +}) { + return ( +
+
+ {title} +
+
+ {entries.map((entry) => ( + + ))} +
+
+ ); +} + +// ── 外壳:覆盖右栏 / 全屏切换 ─────────────────────────────────────────────── + +export function FlowGraphPanel({ data, streaming, onClose }: FlowGraphPanelProps) { + const [fullscreen, setFullscreen] = useState(false); + return ( +
+ + setFullscreen((f) => !f)} + onClose={onClose} + /> + +
+ ); +} diff --git a/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx b/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx index 65abc900..29e63db9 100644 --- a/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3ReportMode.tsx @@ -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 (
@@ -196,6 +202,17 @@ export function Step3ReportMode({
+ {(phase === "creating" || phase === "streaming") && ( + + )}
)} + {phase === "paused" && ( +
+ + 已暂停。可在下方输入修改要求继续,或点右上角「重新绘制」。 +
+ )} + {phase === "done" && (
已生成可视化方案总览,右侧可查看 / 全屏 / 下载。
)} + + {themeMismatch && ( +
+ + 界面主题已切换,这份报告还是旧主题的配色。 + + +
+ )} {!atBottom && }
+ )} + + {/* 流程图画布(覆盖右栏;全屏在组件内部切换) */} + {flowGraph && flowView && ( + setFlowView(false)} + /> + )} diff --git a/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts b/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts index b6102430..f8bbebda 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts @@ -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>({}); const runningRef = useRef(false); const aborterRef = useRef(null); + // 报告线程是否仍「存活」(本会话里跑过 run/interject 建的真线程)。存活时插话 + // 直接续线程(免带原料与旧 HTML);草稿恢复的合成线程不算存活。 + const liveThreadRef = useRef(null); + const agentNameRef = useRef("roundtable-report"); + // 当前展示的报告是用哪个主题绘制的(null = 未知,旧草稿没存该字段)。 + const [reportTheme, setReportTheme] = useState<"light" | "dark" | null>(null); // 当前报告气泡 id —— 流式 delta / 工具步骤 / finalize 都按它定位(绝不用数组下标)。 const bubbleIdRef = useRef(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, diff --git a/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts b/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts index 30e19df9..8ae53352 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts @@ -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"); } diff --git a/frontend-web/src/roundtable-planning/lib/flow-graph.test.ts b/frontend-web/src/roundtable-planning/lib/flow-graph.test.ts new file mode 100644 index 00000000..4886d5ec --- /dev/null +++ b/frontend-web/src/roundtable-planning/lib/flow-graph.test.ts @@ -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))); +}); diff --git a/frontend-web/src/roundtable-planning/lib/flow-graph.ts b/frontend-web/src/roundtable-planning/lib/flow-graph.ts new file mode 100644 index 00000000..b921134d --- /dev/null +++ b/frontend-web/src/roundtable-planning/lib/flow-graph.ts @@ -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(Object.keys(FLOW_NODE_TYPE_META)); + +export const FLOW_FENCE_OPEN = "```flow-json"; + +/** + * 平衡括号扫描:从 `key`(如 `"nodes"`)后面的 `[` 开始,把数组里**已完整闭合**的 + * 顶层 `{…}` 逐个 JSON.parse 收集;跳过字符串内的括号与转义;数组未闭合(流式半截) + * 时收集到哪算哪,截断的尾对象忽略。 + */ +function collectObjects(text: string, key: string): Record[] { + const keyIdx = text.indexOf(key); + if (keyIdx < 0) return []; + const arrStart = text.indexOf("[", keyIdx); + if (arrStart < 0) return []; + const out: Record[] = []; + 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); + } + } 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): 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, 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; + 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): 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(); + 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(); + 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(); + 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(); + const visiting = new Set(); + 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(); + 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(); + 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)! })); +} diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx index 9be1df56..283338e8 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx @@ -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(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() { >
总控提示词 - - 写给总控:怎么派活、做什么(仅分层研讨生效,可选) - +
+ + 写给总控:怎么派活、做什么(仅分层研讨生效,可选) + + +