From 60cc6665cebbf9afb38d1f67ea2fa179555211de Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?=
Date: Wed, 17 Jun 2026 20:38:51 +0800
Subject: [PATCH 1/9] tij
---
frontend-web/.env.production | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend-web/.env.production b/frontend-web/.env.production
index d1fc2180..97415742 100644
--- a/frontend-web/.env.production
+++ b/frontend-web/.env.production
@@ -5,7 +5,7 @@
VITE_USE_REAL_DATA=true
# 任务详情(登录深链 cop-task-detail)数据源:默认真实接口;改成 true 走本地假数据(调试用)。
-VITE_MOCK_COP_TASK_DETAIL=true
+VITE_MOCK_COP_TASK_DETAIL=false
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
From d3880f51b38060aad7435c017306f5bcc338429c Mon Sep 17 00:00:00 2001
From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com>
Date: Wed, 17 Jun 2026 21:31:22 +0800
Subject: [PATCH 2/9] =?UTF-8?q?=E7=AC=AC=E4=B8=89=E6=AD=A5=E7=BB=93?=
=?UTF-8?q?=E6=9E=84=E5=8C=96=E8=BE=93=E5=87=BA=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../roundtable-planning/api/flow-extract.ts | 106 ++++
.../components/FlowGraphPanel.tsx | 162 +++++-
.../components/Step3SummaryMode.tsx | 61 ++-
.../hooks/useStep3Summary.ts | 241 ++++++---
.../lib/flow-graph.test.ts | 51 ++
.../src/roundtable-planning/lib/flow-graph.ts | 58 ++
.../pages/RoundtablePlanningPage.tsx | 5 +-
.../backend/app/gateway/app.py | 4 +
.../app/gateway/routers/flow_extract.py | 510 ++++++++++++++++++
offline-backend-20260512/backend/config.yaml | 24 +-
10 files changed, 1095 insertions(+), 127 deletions(-)
create mode 100644 frontend-web/src/roundtable-planning/api/flow-extract.ts
create mode 100644 offline-backend-20260512/backend/app/gateway/routers/flow_extract.py
diff --git a/frontend-web/src/roundtable-planning/api/flow-extract.ts b/frontend-web/src/roundtable-planning/api/flow-extract.ts
new file mode 100644
index 00000000..ebf2188e
--- /dev/null
+++ b/frontend-web/src/roundtable-planning/api/flow-extract.ts
@@ -0,0 +1,106 @@
+/**
+ * Step3 流程图「后端抽取 + 校验」SSE 客户端。
+ *
+ * 对应后端 `POST /api/flow-extract/stream`:后端独立调模型(不走总结线程,避开 coordinatorPrompt
+ * 的报告锚定)两段式产图——①流式抽取 flow-json(实时吐回,前端原样显示给用户看)→ ②模型校验/修复
+ * → 返回校验后的结构化图数据 `{title, nodes, edges}`。
+ *
+ * 这样把不稳定的「文本→结构」抽取 + 校验放后端,前端只负责实时显示抽取过程 + 拿最终图绘制。
+ */
+import { apiFetch } from "@/core/api/fetch-client";
+import { getBackendBaseURL } from "@/core/config";
+import type { FlowGraphData } from "@/roundtable-planning/lib/flow-graph";
+
+export interface FlowExtractRequest {
+ /** 报告 Markdown(优先数据来源)。 */
+ reportMd?: string | null;
+ /** 各席位交付(无报告时的数据来源)。 */
+ seatDeliveries?: Array<{ sender: string; content: string }>;
+ /** 业务链层级(stages 的 goal)。 */
+ chainLevels?: string[];
+ /** 业务链总控编排提示(写明层级时优先用它)。 */
+ coordinatorPrompt?: string | null;
+ /** 推理模型名(沿用第三步所选)。 */
+ model?: string;
+ /** Phase 2 问答修改:当前流程图 + 指令 → 按指令改图(其余不变)。 */
+ currentGraph?: FlowGraphData | null;
+ instruction?: string;
+ signal?: AbortSignal;
+ /** 抽取流式增量(含 `` 思考 + 正文 + JSON,前端原样渲染:思考块 + 代码块)。 */
+ onExtractDelta?: (text: string) => void;
+ /** 抽取结束、进入校验阶段。 */
+ onValidating?: () => void;
+ /** 校验/修复流式增量(同样含思考 + 正文,前端实时显示校验过程)。 */
+ onValidateDelta?: (text: string) => void;
+}
+
+/**
+ * 消费 SSE,返回校验后的最终图。抽取阶段通过 `onExtractDelta` 实时回吐;
+ * 出错(后端 error 帧 / 网络)抛异常。
+ */
+export async function streamFlowExtract(req: FlowExtractRequest): Promise {
+ const res = await apiFetch(`${getBackendBaseURL()}/api/flow-extract/stream`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ report_md: req.reportMd ?? null,
+ seat_deliveries: (req.seatDeliveries ?? []).map((s) => ({ sender: s.sender, content: s.content })),
+ chain_levels: req.chainLevels ?? [],
+ coordinator_prompt: req.coordinatorPrompt ?? null,
+ model: req.model,
+ current_graph: req.currentGraph ?? null,
+ instruction: req.instruction ?? null,
+ }),
+ signal: req.signal,
+ });
+ if (!res.ok || !res.body) {
+ const text = await res.text().catch(() => "");
+ throw new Error(`flow-extract failed (${res.status}): ${text}`);
+ }
+
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+ let graph: FlowGraphData | null = null;
+ let errored: string | null = null;
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() ?? "";
+ for (const rawLine of lines) {
+ const line = rawLine.trimEnd();
+ if (!line.startsWith("data:")) continue;
+ const data = line.slice(5).trim();
+ if (!data) continue;
+ let frame: Record;
+ try {
+ frame = JSON.parse(data) as Record;
+ } catch {
+ continue;
+ }
+ const event = typeof frame.event === "string" ? frame.event : "";
+ if (event === "extract_delta") {
+ const c = typeof frame.content === "string" ? frame.content : "";
+ if (c) req.onExtractDelta?.(c);
+ } else if (event === "validating") {
+ req.onValidating?.();
+ } else if (event === "validate_delta") {
+ const c = typeof frame.content === "string" ? frame.content : "";
+ if (c) req.onValidateDelta?.(c);
+ } else if (event === "final") {
+ graph = (frame.graph as FlowGraphData) ?? null;
+ } else if (event === "error") {
+ errored = typeof frame.detail === "string" ? frame.detail : "未知错误";
+ }
+ }
+ }
+
+ if (errored) throw new Error(errored);
+ if (!graph || !Array.isArray(graph.nodes) || graph.nodes.length === 0) {
+ throw new Error("flow-extract 未返回有效流程图");
+ }
+ return graph;
+}
diff --git a/frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx b/frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx
index f19cf47b..f49e1425 100644
--- a/frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx
+++ b/frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx
@@ -15,6 +15,7 @@ import {
useRef,
useState,
} from "react";
+import { createPortal } from "react-dom";
import {
applyNodeChanges,
Background,
@@ -32,11 +33,14 @@ import {
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import {
+ Check,
FileText,
Loader2,
LocateFixed,
Maximize2,
Minimize2,
+ Pencil,
+ Trash2,
Workflow,
X,
ZoomIn,
@@ -45,6 +49,8 @@ import {
import { cn } from "@/lib/utils";
import {
+ applyNodeEdit,
+ deleteNodeFromGraph,
FLOW_NODE_TYPE_META,
FLOW_NODE_W,
layoutFlowGraph,
@@ -59,6 +65,8 @@ interface FlowGraphPanelProps {
onClose: () => void;
/** 沙箱里有 md 报告时提供:工具栏显示「MD 报告」按钮,点击切回 Markdown 预览。 */
onSwitchToReport?: () => void;
+ /** Phase 3:画布手动编辑——传入则节点详情弹窗出现「编辑 / 删除」,回写整张新图。 */
+ onChange?: (next: FlowGraphData) => void;
}
// ── 自定义节点(按 type 着色的卡片)────────────────────────────────────────
@@ -106,6 +114,7 @@ function FlowCanvasInner({
onToggleFullscreen,
onClose,
onSwitchToReport,
+ onChange,
}: FlowGraphPanelProps & { fullscreen: boolean; onToggleFullscreen: () => void }) {
const rf = useReactFlow();
const [selectedId, setSelectedId] = useState(null);
@@ -276,13 +285,14 @@ function FlowCanvasInner({
- {/* 节点详情弹窗 */}
+ {/* 节点详情弹窗(传入 onChange 时可编辑 / 删除节点) */}
{selectedNode && (
setSelectedId(null)}
onJump={(id) => setSelectedId(id)}
+ onChange={onChange}
/>
)}
@@ -317,17 +327,38 @@ function NodeDetailModal({
data,
onClose,
onJump,
+ onChange,
}: {
node: FlowGraphNode;
data: FlowGraphData;
onClose: () => void;
onJump: (id: string) => void;
+ /** Phase 3:传入则可编辑 / 删除该节点(回写整张新图)。 */
+ onChange?: (next: FlowGraphData) => 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);
+ const [editing, setEditing] = useState(false);
+ const [label, setLabel] = useState(node.label);
+ const [group, setGroup] = useState(node.group ?? "");
+ const [detail, setDetail] = useState(node.detail ?? "");
+
+ const handleSave = () => {
+ onChange?.(applyNodeEdit(data, node.id, { label, group, detail }));
+ setEditing(false);
+ onClose();
+ };
+ const handleDelete = () => {
+ if (typeof window !== "undefined" && !window.confirm(`删除节点「${node.label}」及其连线?此操作不可撤销。`)) {
+ return;
+ }
+ onChange?.(deleteNodeFromGraph(data, node.id));
+ onClose();
+ };
+
return (
-
-
-
- 详细说明
-
-
- {node.detail?.trim() || "(该节点未提供详细说明)"}
-
+
+ {editing ? (
+
+
+
+
- {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} />
- )}
-
+ ) : (
+
+
+
详细说明
+
+ {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} />
+ )}
+
+ )}
+
+ {/* 编辑 / 删除 操作栏(仅传入 onChange 时显示) */}
+ {onChange && (
+
+ {editing ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+ )}
);
}
+function EditField({
+ label,
+ value,
+ onChange,
+ multiline,
+}: {
+ label: string;
+ value: string;
+ onChange: (v: string) => void;
+ multiline?: boolean;
+}) {
+ return (
+
+
{label}
+ {multiline ? (
+
+ );
+}
+
function ConnectionList({
title,
entries,
@@ -420,14 +534,15 @@ function ConnectionList({
// ── 外壳:覆盖右栏 / 全屏切换 ───────────────────────────────────────────────
-export function FlowGraphPanel({ data, streaming, onClose, onSwitchToReport }: FlowGraphPanelProps) {
+export function FlowGraphPanel({ data, streaming, onClose, onSwitchToReport, onChange }: FlowGraphPanelProps) {
const [fullscreen, setFullscreen] = useState(false);
- return (
+ const inner = (
@@ -438,8 +553,15 @@ export function FlowGraphPanel({ data, streaming, onClose, onSwitchToReport }: F
onToggleFullscreen={() => setFullscreen((f) => !f)}
onClose={onClose}
onSwitchToReport={onSwitchToReport}
+ onChange={onChange}
/>
);
+ // 全屏时 portal 到 body:脱离任何带 transform/overflow 的祖先(Step2 沙箱分栏等),
+ // 否则 `fixed` 会被祖先裁剪、顶部工具栏被 app 导航栏盖住(即本次 bug)。
+ if (fullscreen && typeof document !== "undefined") {
+ return createPortal(inner, document.body);
+ }
+ return inner;
}
diff --git a/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx b/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx
index 94467fa5..6b9e0725 100644
--- a/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx
+++ b/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx
@@ -46,7 +46,6 @@ import { FlowGraphPanel } from "@/roundtable-planning/components/FlowGraphPanel"
import {
detectFlowIntent,
extractFlowGraph,
- stripFlowFence,
FLOW_FENCE_OPEN,
} from "@/roundtable-planning/lib/flow-graph";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
@@ -139,9 +138,18 @@ export function Step3SummaryMode({
const { open: artifactsOpen } = useArtifacts();
- // ── 结构化流程图:流式提取回复末尾的 flow-json,增量绘制 ──────────────────
- // 取最后一条含 flow-json 的智能体气泡(多轮修改后以最新一份为准)。
+ // ── 结构化流程图(Phase 1:后端「抽取+校验」)────────────────────────────
+ // 数据来源优先级:
+ // · flowPhase==="done" → 用后端校验后的结构化图 summary.flowGraph 绘制;
+ // · 抽取/校验进行中 → 不画(用户看对话气泡里实时滚动的 JSON + 状态条),等校验完成再绘制;
+ // · idle/error → 回退到对话气泡里解析 flow-json(手动 flow 续问路径 / 旧逻辑兜底)。
const flowGraph = useMemo(() => {
+ if (summary.flowPhase === "done" && summary.flowGraph) {
+ return { graph: summary.flowGraph, streaming: false };
+ }
+ if (summary.flowPhase === "extracting" || summary.flowPhase === "validating") {
+ return null;
+ }
for (let i = dialogues.length - 1; i >= 0; i--) {
const d = dialogues[i];
if (d.avatarType === "human") continue;
@@ -151,7 +159,7 @@ export function Step3SummaryMode({
if (graph) return { graph, streaming: d.streaming === true };
}
return null;
- }, [dialogues]);
+ }, [dialogues, summary.flowPhase, summary.flowGraph]);
const [flowView, setFlowView] = useState(false);
const flowAutoOpenedRef = useRef(false);
@@ -196,16 +204,10 @@ export function Step3SummaryMode({
ask(message, files);
};
- // 气泡里不展示生 JSON:把 flow-json 块替换成一句引导语。
- const displayDialogues = useMemo(
- () =>
- dialogues.map((d) => {
- const content = d.content ?? "";
- if (!content.includes(FLOW_FENCE_OPEN)) return d;
- return { ...d, content: stripFlowFence(content, d.streaming === true) };
- }),
- [dialogues],
- );
+ // 流程图抽取/校验的过程(思考 `` + 文字说明 + flow-json 代码块)全部原样交给
+ // Step2Message 渲染:它用 splitInlineReasoning 把思考折叠成思考块、其余按 markdown 渲染
+ // (flow-json 显示为代码块)。用户要求显示完整过程、不只显示代码块,故不再 strip。
+ const displayDialogues = dialogues;
const [leftCollapsed, setLeftCollapsed] = useState(false);
// 右侧面板(沙箱预览或流程图画布)露出时自动收起左栏,关闭时复原——边沿触发,手动切换不回弹。
const rightPanelVisible = artifactsOpen || (!!flowGraph && flowView);
@@ -397,6 +399,7 @@ export function Step3SummaryMode({
}
: undefined
}
+ onChange={summary.setFlowGraph}
/>
) : undefined
}
@@ -407,6 +410,8 @@ export function Step3SummaryMode({
error={error}
busy={busy}
mdAvailable={!!mdArtifact}
+ flowPhase={summary.flowPhase}
+ onRegenerateFlow={summary.regenerateFlow}
onRetry={redraw}
onAsk={handleAsk}
onPause={pause}
@@ -549,6 +554,8 @@ function AgentProgressPanel({
error,
busy,
mdAvailable,
+ flowPhase,
+ onRegenerateFlow,
onRetry,
onAsk,
onPause,
@@ -565,6 +572,10 @@ function AgentProgressPanel({
busy: boolean;
/** 沙箱里是否已有 md 报告 —— 决定提示语(纯流程图轮没有 .md 可预览/下载)。 */
mdAvailable: boolean;
+ /** 后端流程图抽取阶段(Phase 1)。 */
+ flowPhase: UseStep3SummaryReturn["flowPhase"];
+ /** 仅重新生成流程图(模型挂了切模型后重试)。 */
+ onRegenerateFlow: () => void;
onRetry: () => void;
onAsk: (message: string, files?: File[]) => void;
onPause: () => void;
@@ -705,6 +716,28 @@ function AgentProgressPanel({
)}
+ {/* 后端流程图「抽取+校验」状态条(Phase 1)。 */}
+ {(flowPhase === "extracting" || flowPhase === "validating") && (
+
+
+ {flowPhase === "extracting" ? "结构化流程图抽取中(JSON 实时输出)…" : "流程图校验 / 修复中…"}
+
+ )}
+ {flowPhase === "error" && (
+
+
流程图生成失败
+
模型可能不稳定。可在上方切换模型后重试。
+
+
+ )}
+
{phase === "done" && (
diff --git a/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts b/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts
index 78e0f563..e54ca5fa 100644
--- a/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts
+++ b/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts
@@ -40,7 +40,8 @@ import type {
Step2Dialogue,
StubAiMessage,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
-import { detectFlowIntent, hasFlowFence } from "@/roundtable-planning/lib/flow-graph";
+import { detectFlowIntent, extractFlowGraph, hasFlowFence, serializeFlowGraph, type FlowGraphData } from "@/roundtable-planning/lib/flow-graph";
+import { streamFlowExtract } from "@/roundtable-planning/api/flow-extract";
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
/** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */
@@ -79,6 +80,14 @@ export interface UseStep3SummaryReturn {
error: string | null;
/** taskId/深链模式:md 报告生成后自动续跑流程图(组件据此让流程图画布自动聚焦)。 */
autoFlow: boolean;
+ /** 后端「抽取+校验」得到的校验后结构化流程图(Phase 1)。done 时据此绘制。 */
+ flowGraph: FlowGraphData | null;
+ /** 流程图生成阶段:idle / extracting(抽取流式) / validating(校验中) / done / error。 */
+ flowPhase: "idle" | "extracting" | "validating" | "done" | "error";
+ /** 仅重新生成流程图(复用当前 md/席位交付,不重跑报告)——模型挂了切模型后重试用。 */
+ regenerateFlow: () => void;
+ /** 手动编辑流程图(Phase 3:画布上改/删节点后回写 + 持久化)。 */
+ setFlowGraph: (next: FlowGraphData) => void;
summaryThreadId: string | null;
dialogues: Step2Dialogue[];
seatStubMessages: Record;
@@ -220,61 +229,9 @@ const FLOW_STRUCTURE_RULE =
"⑥ **只画**这些层级实体节点;**不要**把『综合分析』『核心洞察』『分层传播策略 / 执行路线 / 建议』『结论』" +
"以及『阶段①②③④』等章节名 / 阶段名 / 汇总段落画成节点。";
-/** 把各席位交付拼成一段内联材料(无报告时作流程图的数据来源)。 */
-function inlineSeatDeliveries(seatDeliveries?: { sender: string; content: string }[]): string {
- const list = (seatDeliveries ?? []).filter((s) => (s.content ?? "").trim());
- if (list.length === 0) return "";
- return list
- .map((s, i) => `## 席位${i + 1}:${s.sender || "(未具名席位)"}\n${s.content.trim()}`)
- .join("\n\n");
-}
-
-/**
- * taskId/深链模式:md 报告生成完后自动续跑的「结构化输出」prompt。
- *
- * 数据基准(用户拍板):**有报告就基于报告**(报告已把各席位交付清晰总结过,直接提取实体、
- * 不要再自己重新总结一遍);没有报告则基于**各席位交付产物**。两种来源都**整段内联**进 prompt
- * (不依赖线程上下文),让模型逐条对着抽取节点。层级顺序优先用总控提示词里写明的层级,否则 chainLevels。
- */
-function buildAutoFlowPrompt(opts: {
- chainLevels?: string[];
- coordinatorPrompt?: string | null;
- reportMd?: string;
- seatDeliveries?: { sender: string; content: string }[];
-}): string {
- const levels = (opts.chainLevels ?? []).map((s) => s.trim()).filter(Boolean);
- const cp = opts.coordinatorPrompt?.trim();
- const report = opts.reportMd?.trim() ?? "";
- const seatMaterial = report ? "" : inlineSeatDeliveries(opts.seatDeliveries);
- const parts: string[] = [];
- if (report) {
- parts.push(
- "本轮只画一张流程图。**数据来源 = 下面这份你已写好的《方案总结报告》**(它已把各席位交付清晰总结过)——" +
- "请**逐条对着报告抽取**已有的层级条目作为流程图节点,**不要再自己重新总结 / 概括 / 提炼**,也不要凭空新增报告里没有的内容。",
- );
- } else {
- parts.push(
- "本轮只画一张流程图。**数据来源 = 下面各席位的交付产物**——请**逐条对着这些交付抽取**已有的层级条目作为流程图节点,不要自己另起总结。",
- );
- }
- parts.push(FLOW_ONLY_RULE);
- // 整段内联数据来源(报告全文 / 席位交付),用分隔线包起来便于模型对照抽取。
- if (report) {
- parts.push("===== 方案总结报告全文(数据来源,逐条抽取,勿改写)=====\n" + report + "\n===== 报告结束 =====");
- } else if (seatMaterial) {
- parts.push("===== 各席位交付产物(数据来源,逐条抽取,勿改写)=====\n" + seatMaterial + "\n===== 交付结束 =====");
- }
- parts.push(FLOW_JSON_SHAPE);
- parts.push(FLOW_STRUCTURE_RULE);
- if (cp) {
- parts.push("层级顺序优先采用「业务链总控编排提示」里写明的层级链(如它写的 任务 → 目的 → 行为体 → …)。");
- } else if (levels.length > 0) {
- parts.push(`层级顺序(自上而下):任务 → ${levels.join(" → ")}。`);
- }
- // 收尾再强调(首尾夹击,对抗线程里 coordinatorPrompt 的报告锚定)。
- parts.push("再次强调:本轮不写任何 Markdown 报告正文 / 章节,整个回复除一句话说明外,只有一个完整的 ```flow-json 代码块。");
- return parts.join("\n");
-}
+// 注:原 buildAutoFlowPrompt(在总结线程里续问吐 flow-json)已废弃 —— Phase 1 起自动结构化
+// 输出改走后端独立端点 /api/flow-extract/stream(streamFlowExtract),抽取+校验在后端做。
+// FLOW_ONLY_RULE / FLOW_STRUCTURE_RULE / FLOW_JSON_SHAPE 仍由手动 FLOW_ASK_INSTRUCTION 引用。
/** 首轮总结 prompt。`flowMode`=编排提示里明确要求了结构化输出/流程图 → 跳过 md 文件,直出流程图。 */
function buildSummaryPrompt(opts: {
@@ -452,6 +409,14 @@ export function useStep3Summary(
const [phase, setPhase] = useState("idle");
const [error, setError] = useState(null);
+ // 后端「抽取+校验」流程图(Phase 1):与 SummaryPhase 解耦(md 报告完成后才跑流程图)。
+ const [flowGraph, setFlowGraphState] = useState(null);
+ const [flowPhase, setFlowPhase] = useState<"idle" | "extracting" | "validating" | "done" | "error">("idle");
+ // 镜像 flowGraph 供 ask()(问答修改)在 render 外读到最新图作 edit 入参。
+ const flowGraphRef = useRef(null);
+ useEffect(() => {
+ flowGraphRef.current = flowGraph;
+ }, [flowGraph]);
const [summaryThreadId, setSummaryThreadId] = useState(null);
const [dialogues, setDialogues] = useState([]);
const [seatStubMessages, setSeatStubMessages] = useState<
@@ -1010,6 +975,110 @@ export function useStep3Summary(
],
);
+ /**
+ * Phase 1:后端「抽取+校验」生成流程图。独立调 `/api/flow-extract/stream`(不进总结线程,
+ * 避开 coordinator_prompt 的报告锚定):抽取阶段把 JSON 流式吐进对话气泡(用户实时看),
+ * 校验完成后用校验后的结构化图绘制画布(done)。模型沿用第三步所选(selectedModel)。
+ */
+ const runFlowExtract = useCallback(
+ async (
+ aborter: AbortController,
+ opts?: { instruction?: string; currentGraph?: FlowGraphData | null },
+ ): Promise => {
+ const isEdit = !!opts?.instruction && !!opts?.currentGraph;
+ setFlowPhase("extracting");
+ if (!isEdit) setFlowGraphState(null); // 编辑模式保留旧图直到新图就绪(防画布闪空)
+ // 两个气泡:抽取/修改 + 校验——把过程(思考 `` + 文字 + flow-json 代码块)都留着展示,
+ // 由 Step2Message 渲染(思考折叠块 + markdown 代码块),不折叠成一句话。
+ const extractBubbleId = appendStreamingDialogue({
+ sender: isEdit ? "结构化流程图 · 修改" : "结构化流程图 · 抽取",
+ avatarType: "blue",
+ });
+ let validateBubbleId: number | null = null;
+ try {
+ const graph = await streamFlowExtract({
+ reportMd: currentMdRef.current,
+ seatDeliveries,
+ chainLevels,
+ coordinatorPrompt,
+ model: selectedModel || undefined,
+ currentGraph: opts?.currentGraph ?? null,
+ instruction: opts?.instruction,
+ signal: aborter.signal,
+ onExtractDelta: (t) => appendToDialogue(extractBubbleId, t),
+ onValidating: () => {
+ setFlowPhase("validating");
+ // 抽取气泡收尾(保留已流出的内容),另起校验气泡。
+ finalizeDialogue(extractBubbleId, "", "抽取完成");
+ validateBubbleId = appendStreamingDialogue({ sender: "结构化流程图 · 校验", avatarType: "blue" });
+ },
+ onValidateDelta: (t) => {
+ if (validateBubbleId != null) appendToDialogue(validateBubbleId, t);
+ },
+ });
+ if (aborter.signal.aborted) return;
+ setFlowGraphState(graph);
+ setFlowPhase("done");
+ flowActiveRef.current = true;
+ // 收尾两个气泡(保留过程内容)。校验后的图序列化进草稿快照 closing,恢复时重建画布。
+ if (validateBubbleId != null) finalizeDialogue(validateBubbleId, "", "流程图完成");
+ else finalizeDialogue(extractBubbleId, "", "流程图完成");
+ onSummarySavedRef.current?.({
+ md: currentMdRef.current,
+ threadId: liveThreadRef.current ?? "",
+ generatedAt: new Date().toISOString(),
+ model: selectedModel || "",
+ closing: serializeFlowGraph(graph),
+ });
+ } catch (err) {
+ if (aborter.signal.aborted) return;
+ const msg = err instanceof Error ? err.message : String(err);
+ setFlowPhase("error");
+ finalizeDialogue(
+ validateBubbleId ?? extractBubbleId,
+ `(流程图生成失败) ${msg}\n模型可能不稳定——可在上方切换模型后点「重新生成流程图」重试。`,
+ "流程图失败",
+ );
+ }
+ },
+ [
+ seatDeliveries,
+ chainLevels,
+ coordinatorPrompt,
+ selectedModel,
+ appendStreamingDialogue,
+ appendToDialogue,
+ finalizeDialogue,
+ ],
+ );
+
+ /** 仅重新生成流程图(不重跑 md 报告):模型挂了切模型后重试 / 手动重画。 */
+ const regenerateFlow = useCallback(() => {
+ if (!currentMdRef.current.trim() && seatDeliveries.length === 0) return;
+ aborterRef.current?.abort();
+ const aborter = new AbortController();
+ aborterRef.current = aborter;
+ void runFlowExtract(aborter);
+ }, [runFlowExtract, seatDeliveries]);
+
+ /** Phase 3:画布手动改/删节点后回写图 + 持久化(序列化进草稿快照)。 */
+ const updateFlowGraph = useCallback(
+ (next: FlowGraphData) => {
+ setFlowGraphState(next);
+ setFlowPhase("done");
+ flowActiveRef.current = true;
+ const serialized = serializeFlowGraph(next);
+ onSummarySavedRef.current?.({
+ md: currentMdRef.current,
+ threadId: liveThreadRef.current ?? "",
+ generatedAt: new Date().toISOString(),
+ model: selectedModel || "",
+ closing: serialized,
+ });
+ },
+ [selectedModel],
+ );
+
const run = useCallback(async () => {
if (runningRef.current) return;
runningRef.current = true;
@@ -1021,6 +1090,8 @@ export function useStep3Summary(
currentMdRef.current = "";
liveThreadRef.current = null;
setSeatStubMessages({});
+ setFlowGraphState(null);
+ setFlowPhase("idle");
const aborter = new AbortController();
aborterRef.current = aborter;
@@ -1044,33 +1115,16 @@ export function useStep3Summary(
});
await streamOnce({ bubbleId, prompt, aborter, flowRound: flowMode });
- // taskId/深链模式:报告生成完,**自动**续跑一轮结构化输出(流程图)。
- // 数据来源:有报告则内联报告全文、否则内联各席位交付(buildAutoFlowPrompt 处理)。
- // 故触发条件放宽为「有报告 **或** 有席位交付」——保证「无报告也能据席位交付出图」。
- // 复用同一真线程,与手动「结构化输出」走同一渲染链路。
+ // taskId/深链模式:报告生成完,**自动**用后端「抽取+校验」端点生成流程图(Phase 1)。
+ // 不再在总结线程里续问吐 flow-json(那会被 coordinator_prompt 的报告锚定带偏、不稳);
+ // 改走独立端点:流式抽取(实时显示) → 校验修复 → 用校验后结构化图绘制。
+ // 有报告或有席位交付即触发(无报告也能据席位交付出图)。
if (
autoFlow &&
!aborter.signal.aborted &&
- liveThreadRef.current &&
(currentMdRef.current.trim() || seatDeliveries.length > 0)
) {
- const flowBubbleId = appendStreamingDialogue({
- sender: "方案总结报告智能体",
- avatarType: "blue",
- });
- const flowPrompt = buildAutoFlowPrompt({
- chainLevels,
- coordinatorPrompt,
- reportMd: currentMdRef.current,
- seatDeliveries,
- });
- await streamOnce({
- bubbleId: flowBubbleId,
- prompt: flowPrompt,
- aborter,
- seedThreadId: liveThreadRef.current,
- flowRound: true,
- });
+ await runFlowExtract(aborter);
}
} catch (err) {
if (aborter.signal.aborted) return;
@@ -1087,11 +1141,11 @@ export function useStep3Summary(
seatDeliveries,
consensusText,
coordinatorPrompt,
- chainLevels,
autoFlow,
appendStreamingDialogue,
finalizeDialogue,
streamOnce,
+ runFlowExtract,
]);
const ask = useCallback(
@@ -1106,6 +1160,25 @@ export function useStep3Summary(
abort();
if (wasBusy) freezeStreaming("已打断");
+ // 流程图相关消息 → 走后端「抽取/编辑 + 校验」端点(流式显示过程 → 校验后绘制),
+ // 不再在总结线程吐 flow-json 做实时绘制。判定:
+ // · 已有流程图 且(显式 flow 关键词 或 上一轮主产物是流程图)→ **问答修改**(带当前图+指令,Phase 2);
+ // · 还没有流程图 但显式要流程图 → **首次生成**。
+ // 普通报告修改 / 问答(无 flow 上下文)仍走下方总结线程路径。
+ const flowIntentNow = detectFlowIntent(text);
+ const curGraph = flowGraphRef.current;
+ if (flowIntentNow || (curGraph && flowActiveRef.current)) {
+ appendUserDialogue(text);
+ const flowAborter = new AbortController();
+ aborterRef.current = flowAborter;
+ if (curGraph) {
+ await runFlowExtract(flowAborter, { instruction: text, currentGraph: curGraph });
+ } else {
+ await runFlowExtract(flowAborter);
+ }
+ return;
+ }
+
// 意图引导分三档(智能体按语义最终裁决,前端只给上下文,不做硬分类):
// ① 显式命中结构化/流程图关键词 → 直出 flow-json、不写 md;
// ② 没命中关键词但上一轮主产物是流程图 → 流程图上下文引导(笼统修改默认改图,太模糊则反问);
@@ -1219,6 +1292,7 @@ export function useStep3Summary(
appendUserDialogue,
appendStreamingDialogue,
streamOnce,
+ runFlowExtract,
],
);
@@ -1247,6 +1321,11 @@ export function useStep3Summary(
(snap: DraftStep3SummarySnapshot) => {
abort();
runningRef.current = false;
+ // 恢复流程图:快照 closing 里若存了 flow-json,解析成结构化图填进 flowGraph 状态,
+ // 让画布直接用它绘制(也供 Phase 3 手动编辑)。解析不出则回退到对话气泡解析。
+ const restoredGraph = snap.closing ? extractFlowGraph(snap.closing) : null;
+ setFlowGraphState(restoredGraph);
+ setFlowPhase(restoredGraph ? "done" : "idle");
const hasMd = snap.md.trim().length > 0;
const realThreadId = snap.threadId?.trim() || null;
const synthThreadId = realThreadId ?? "roundtable-summary-restored";
@@ -1330,6 +1409,10 @@ export function useStep3Summary(
phase,
error,
autoFlow: !!autoFlow,
+ flowGraph,
+ flowPhase,
+ regenerateFlow,
+ setFlowGraph: updateFlowGraph,
summaryThreadId,
dialogues,
seatStubMessages,
diff --git a/frontend-web/src/roundtable-planning/lib/flow-graph.test.ts b/frontend-web/src/roundtable-planning/lib/flow-graph.test.ts
index 553b4e18..21fa9ace 100644
--- a/frontend-web/src/roundtable-planning/lib/flow-graph.test.ts
+++ b/frontend-web/src/roundtable-planning/lib/flow-graph.test.ts
@@ -9,10 +9,13 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
+ applyNodeEdit,
+ deleteNodeFromGraph,
detectFlowIntent,
extractFlowGraph,
hasFlowFence,
layoutFlowGraph,
+ serializeFlowGraph,
stripFlowFence,
} from "./flow-graph";
@@ -206,3 +209,51 @@ test("layoutFlowGraph:环不死循环", () => {
assert.equal(positioned.length, 2);
positioned.forEach((n) => assert.ok(Number.isFinite(n.x) && Number.isFinite(n.y)));
});
+
+test("serializeFlowGraph:与 extractFlowGraph 往返一致(含 group/detail/边标签)", () => {
+ const text = [
+ "```flow-json",
+ '{"title":"方案","nodes":[',
+ '{"id":"t","label":"任务X","type":"start","group":"任务","detail":"目标说明"},',
+ '{"id":"p1","label":"目的1","type":"task","group":"目的","detail":"名称+理由","from":["t"]},',
+ '{"id":"d","label":"判断","type":"decision","from":[{"id":"p1","label":"可行"}]}',
+ "]}",
+ "```",
+ ].join("\n");
+ const g = extractFlowGraph(text)!;
+ const round = extractFlowGraph(serializeFlowGraph(g))!;
+ assert.equal(round.title, "方案");
+ assert.deepEqual(
+ round.nodes.map((n) => [n.id, n.label, n.group, n.detail]),
+ g.nodes.map((n) => [n.id, n.label, n.group, n.detail]),
+ );
+ assert.deepEqual(round.edges, g.edges); // 含 {from:"p1",to:"d",label:"可行"}
+});
+
+test("applyNodeEdit:改 label/group/detail,其余节点不变", () => {
+ const g = extractFlowGraph(
+ '```flow-json\n{"nodes":[{"id":"a","label":"A","type":"task","group":"目的"},{"id":"b","label":"B","type":"role","group":"行为体","from":["a"]}]}\n```',
+ )!;
+ const next = applyNodeEdit(g, "a", { label: "新目的", group: "目标", detail: "改后的详情" });
+ const a = next.nodes.find((n) => n.id === "a")!;
+ assert.equal(a.label, "新目的");
+ assert.equal(a.group, "目标");
+ assert.equal(a.detail, "改后的详情");
+ // b 不变;边不变。
+ assert.equal(next.nodes.find((n) => n.id === "b")!.label, "B");
+ assert.deepEqual(next.edges, g.edges);
+ // 空 group/detail → 清空(置 undefined)。
+ const cleared = applyNodeEdit(g, "a", { group: " ", detail: "" });
+ const a2 = cleared.nodes.find((n) => n.id === "a")!;
+ assert.equal(a2.group, undefined);
+});
+
+test("deleteNodeFromGraph:删节点同时删掉与它相连的所有边", () => {
+ const g = extractFlowGraph(
+ '```flow-json\n{"nodes":[{"id":"t","label":"任务"},{"id":"p","label":"目的","from":["t"]},{"id":"b","label":"行为体","from":["p"]}]}\n```',
+ )!;
+ const next = deleteNodeFromGraph(g, "p");
+ assert.deepEqual(next.nodes.map((n) => n.id), ["t", "b"]);
+ // t→p 和 p→b 两条边都没了(b 变孤点)。
+ assert.equal(next.edges.length, 0);
+});
diff --git a/frontend-web/src/roundtable-planning/lib/flow-graph.ts b/frontend-web/src/roundtable-planning/lib/flow-graph.ts
index 4bc14886..c9466334 100644
--- a/frontend-web/src/roundtable-planning/lib/flow-graph.ts
+++ b/frontend-web/src/roundtable-planning/lib/flow-graph.ts
@@ -281,6 +281,64 @@ export function extractFlowGraph(text: string): FlowGraphData | null {
return { title, nodes, edges: validEdges };
}
+/**
+ * 把结构化图序列化回一个 ```flow-json 代码块(与 extractFlowGraph 互逆)。
+ * 用途:后端校验后拿到 FlowGraphData → 序列化存进草稿快照(closing),恢复时仍走
+ * extractFlowGraph 重建;手动编辑后也用它回存。`from` 由 edges 按 `to` 归集(带边标签)。
+ */
+export function serializeFlowGraph(data: FlowGraphData): string {
+ const fromMap = new Map>();
+ for (const e of data.edges) {
+ const list = fromMap.get(e.to) ?? [];
+ list.push(e.label ? { id: e.from, label: e.label } : e.from);
+ fromMap.set(e.to, list);
+ }
+ const nodes = data.nodes.map((n) => {
+ const o: Record = { id: n.id, label: n.label, type: n.type };
+ if (n.group) o.group = n.group;
+ if (n.detail) o.detail = n.detail;
+ const from = fromMap.get(n.id);
+ if (from && from.length > 0) o.from = from;
+ return o;
+ });
+ return `${FLOW_FENCE_OPEN}\n${JSON.stringify({ title: data.title || "方案流程图", nodes })}\n\`\`\``;
+}
+
+/**
+ * Phase 3 画布手动编辑:改某节点的 label/group/detail(其余不变)。返回新图(不可变)。
+ */
+export function applyNodeEdit(
+ data: FlowGraphData,
+ id: string,
+ patch: { label?: string; group?: string; detail?: string },
+): FlowGraphData {
+ return {
+ ...data,
+ nodes: data.nodes.map((n) =>
+ n.id === id
+ ? {
+ ...n,
+ ...(patch.label !== undefined ? { label: patch.label.trim() || n.label } : {}),
+ // group/detail 允许清空(传空串 → 置 undefined)。
+ ...(patch.group !== undefined ? { group: patch.group.trim() || undefined } : {}),
+ ...(patch.detail !== undefined ? { detail: patch.detail.trim() || undefined } : {}),
+ }
+ : n,
+ ),
+ };
+}
+
+/**
+ * Phase 3 画布手动删除:删一个节点 + 所有与它相连的边。返回新图。
+ */
+export function deleteNodeFromGraph(data: FlowGraphData, id: string): FlowGraphData {
+ return {
+ ...data,
+ nodes: data.nodes.filter((n) => n.id !== id),
+ edges: data.edges.filter((e) => e.from !== id && e.to !== id),
+ };
+}
+
/**
* 识别文本(业务链编排提示 / 用户输入框消息)里是否明确要求了结构化输出 / 流程图。
* 命中 → 总结走「流程图模式」:跳过 .md 文件,报告用文字展示 + 末尾输出 flow-json。
diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
index 41796cbd..13a97094 100644
--- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
+++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
@@ -1012,8 +1012,9 @@ export default function RoundtablePlanningPage() {
.filter((g) => g.length > 0),
[orchestrationPlan],
);
- // taskId/深链模式:进第三步生成 md 后自动续跑结构化输出(流程图)。普通手动会商不自动。
- const autoSummaryFlow = !!prefillRef.current?.taskId;
+ // 第三步首次生成报告后即**自动**续跑结构化输出(流程图)——不再限 taskId 深链,
+ // 任何会商进第三步首次出报告都自动出流程图(用户 2026-06-17 要求)。
+ const autoSummaryFlow = true;
const summaryFlow = useStep3Summary({
intentReady: step1.intentReady,
diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py
index f624389b..8af923c8 100644
--- a/offline-backend-20260512/backend/app/gateway/app.py
+++ b/offline-backend-20260512/backend/app/gateway/app.py
@@ -57,6 +57,7 @@ from app.gateway.routers import (
public_leaderboard,
public_scheduled_tasks,
recommend,
+ flow_extract,
recommended_questions,
roundtable_chains,
roundtable_draft_shares,
@@ -1333,6 +1334,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Roundtable-planning drafts + recommendation history at /api/roundtable-drafts
app.include_router(roundtable_drafts.router)
+ # Step3 流程图结构化抽取 + 校验 (SSE) at /api/flow-extract
+ app.include_router(flow_extract.router)
+
# Roundtable-planning business chains at /api/roundtable-chains
app.include_router(roundtable_chains.router)
diff --git a/offline-backend-20260512/backend/app/gateway/routers/flow_extract.py b/offline-backend-20260512/backend/app/gateway/routers/flow_extract.py
new file mode 100644
index 00000000..e2e0c89e
--- /dev/null
+++ b/offline-backend-20260512/backend/app/gateway/routers/flow_extract.py
@@ -0,0 +1,510 @@
+"""流程图结构化抽取 + 校验 的 SSE 端点。
+
+第三步「方案总结」之后,要把报告 / 各席位交付的层级结论抽成一张**流程图**(节点 + 连线)。
+之前让总结智能体在同一对话线程里顺手吐 ``flow-json`` 很不稳:业务链 ``coordinator_prompt``
+常写着「输出结构化结论 / 按阶段产出报告」,模型被线程上下文锚定,要么不出 flow-json、要么
+结构残缺(只剩两三个节点)。
+
+这里改成**独立的一次性模型调用**(不走 lead_agent / 不带线程历史),两段式:
+ ① 抽取(``model.astream``):把报告/席位交付按层级抽成 flow-json,**流式**把过程吐给前端实时显示;
+ ② 校验/修复(``model.ainvoke``):把抽取结果连同源材料再过一遍模型,补全漏层、修正连边、
+ 去掉阶段/汇总节点,产出干净的 flow-json;后端再做容错解析 + 结构校验,返回最终图数据。
+
+SSE 帧(``data: {json}\n\n``):
+ - ``{"event":"extract_delta","content":"…"}`` 抽取流式增量(前端把 JSON 实时显示出来)
+ - ``{"event":"extract_done"}`` 抽取结束,进入校验
+ - ``{"event":"validating"}`` 校验中
+ - ``{"event":"final","graph":{title,nodes,edges}}`` 校验后的最终图(前端据此绘制)
+ - ``{"event":"error","detail":"…"}``
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+from collections.abc import AsyncIterator
+from typing import Any
+
+from fastapi import APIRouter, Body, Request
+from fastapi.responses import StreamingResponse
+from langchain_core.messages import HumanMessage, SystemMessage
+from pydantic import BaseModel, Field
+
+from deerflow.models.factory import create_chat_model
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/flow-extract", tags=["flow-extract"])
+
+
+# ── schemas ──────────────────────────────────────────────────────────────
+
+
+class SeatDelivery(BaseModel):
+ sender: str = ""
+ content: str = ""
+
+
+class FlowExtractRequest(BaseModel):
+ # 数据来源:有报告优先用报告,否则用各席位交付。
+ report_md: str | None = Field(default=None)
+ seat_deliveries: list[SeatDelivery] = Field(default_factory=list)
+ # 层级骨架:优先 coordinator_prompt 里写明的层级,否则用 chain_levels。
+ chain_levels: list[str] = Field(default_factory=list)
+ coordinator_prompt: str | None = Field(default=None)
+ model: str | None = Field(default=None)
+ # Phase 2 问答修改:带上当前图 + 用户指令 → 进 edit 模式(按指令改图,其余不变)。
+ current_graph: dict[str, Any] | None = Field(default=None)
+ instruction: str | None = Field(default=None)
+
+
+# ── flow-json 容错解析(Python 版,对齐前端 lib/flow-graph.ts)──────────────
+
+_FENCE = "```flow-json"
+_VALID_TYPES = {"start", "end", "stage", "task", "decision", "risk", "deliverable", "role"}
+
+
+def _as_str(v: Any) -> str:
+ return v.strip() if isinstance(v, str) else ""
+
+
+def _loose_nodes(body: str) -> list[dict[str, Any]]:
+ """正则逐字段抽取节点(strict json.loads 失败时回退)。容忍裸换行 / 尾逗号。"""
+ # 取 "nodes" 后的 [ ... ](到与之配对的 ] 或文本结尾)。
+ key = body.find('"nodes"')
+ if key < 0:
+ return []
+ arr = body.find("[", key)
+ if arr < 0:
+ return []
+ # 平衡括号切出每个顶层 {…}
+ slices: list[str] = []
+ depth = 0
+ start = -1
+ in_str = False
+ esc = False
+ for i in range(arr + 1, len(body)):
+ ch = body[i]
+ if in_str:
+ if esc:
+ esc = False
+ elif ch == "\\":
+ esc = True
+ elif ch == '"':
+ in_str = False
+ continue
+ if ch == '"':
+ in_str = True
+ elif ch == "{":
+ if depth == 0:
+ start = i
+ depth += 1
+ elif ch == "}":
+ depth -= 1
+ if depth == 0 and start >= 0:
+ slices.append(body[start : i + 1])
+ start = -1
+ elif ch == "]" and depth == 0:
+ break
+ out: list[dict[str, Any]] = []
+ for sl in slices:
+ out.append(_loose_one(sl))
+ return out
+
+
+def _loose_edges(body: str) -> list[dict[str, Any]]:
+ """非法 JSON 回退路径下,从顶层 `"edges":[ {from,to,label} … ]` 容错抽边。"""
+ key = body.find('"edges"')
+ if key < 0:
+ return []
+ arr = body.find("[", key)
+ if arr < 0:
+ return []
+ depth = 0
+ start = -1
+ in_str = False
+ esc = False
+ slices: list[str] = []
+ for i in range(arr + 1, len(body)):
+ ch = body[i]
+ if in_str:
+ if esc:
+ esc = False
+ elif ch == "\\":
+ esc = True
+ elif ch == '"':
+ in_str = False
+ continue
+ if ch == '"':
+ in_str = True
+ elif ch == "{":
+ if depth == 0:
+ start = i
+ depth += 1
+ elif ch == "}":
+ depth -= 1
+ if depth == 0 and start >= 0:
+ slices.append(body[start : i + 1])
+ start = -1
+ elif ch == "]" and depth == 0:
+ break
+
+ def pick(slice_: str, *keys: str) -> str:
+ for k in keys:
+ m = re.search(r'"' + k + r'"\s*:\s*"([^"]*)"', slice_)
+ if m:
+ return m.group(1)
+ return ""
+
+ out: list[dict[str, Any]] = []
+ for sl in slices:
+ frm = pick(sl, "from", "source")
+ to = pick(sl, "to", "target")
+ if frm and to:
+ e: dict[str, Any] = {"from": frm, "to": to}
+ lbl = pick(sl, "label")
+ if lbl:
+ e["label"] = lbl
+ out.append(e)
+ return out
+
+
+def _loose_one(slice_: str) -> dict[str, Any]:
+ def pick(*keys: str) -> str | None:
+ for k in keys:
+ m = re.search(r'"' + k + r'"\s*:\s*"((?:\\.|[^"\\])*)"', slice_)
+ if m:
+ raw = m.group(1)
+ try:
+ return json.loads('"' + raw.replace("\n", "\\n").replace("\r", "") + '"')
+ except Exception: # noqa: BLE001
+ return raw.replace("\\n", "\n").replace('\\"', '"')
+ return None
+
+ obj: dict[str, Any] = {}
+ for k, dst in (("id", "id"), ("type", "type"), ("detail", "detail")):
+ v = pick(k)
+ if v:
+ obj[dst] = v
+ label = pick("label", "name")
+ if label:
+ obj["label"] = label
+ group = pick("group", "level", "tier", "layer")
+ if group:
+ obj["group"] = group
+ fm = re.search(r'"(?:from|prev)"\s*:\s*\[([\s\S]*?)\]', slice_)
+ if fm:
+ inner = fm.group(1)
+ ids: list[Any] = [{"id": x} for x in re.findall(r'\{[^{}]*?"id"\s*:\s*"([^"]+)"[^{}]*?\}', inner)]
+ if not ids:
+ ids = re.findall(r'"([^"]+)"', inner)
+ if ids:
+ obj["from"] = ids
+ return obj
+
+
+def _norm_node(raw: dict[str, Any]) -> dict[str, Any] | None:
+ nid = _as_str(raw.get("id"))
+ label = _as_str(raw.get("label")) or _as_str(raw.get("name"))
+ if not nid or not label:
+ return None
+ rtype = _as_str(raw.get("type")).lower()
+ ntype = rtype if rtype in _VALID_TYPES else "task"
+ node: dict[str, Any] = {"id": nid, "label": label, "type": ntype}
+ detail = _as_str(raw.get("detail")) or _as_str(raw.get("description"))
+ if detail:
+ node["detail"] = detail
+ group = _as_str(raw.get("group")) or _as_str(raw.get("level")) or _as_str(raw.get("tier")) or _as_str(raw.get("layer"))
+ if group:
+ node["group"] = group
+ return node
+
+
+def _edges_from(raw: dict[str, Any], nid: str) -> list[dict[str, Any]]:
+ frm = raw.get("from") or raw.get("prev")
+ if not isinstance(frm, list):
+ return []
+ out: list[dict[str, Any]] = []
+ for e in frm:
+ if isinstance(e, str) and e.strip():
+ out.append({"from": e.strip(), "to": nid})
+ elif isinstance(e, dict):
+ fid = _as_str(e.get("id")) or _as_str(e.get("from"))
+ if fid:
+ edge = {"from": fid, "to": nid}
+ lbl = _as_str(e.get("label"))
+ if lbl:
+ edge["label"] = lbl
+ out.append(edge)
+ return out
+
+
+def parse_flow_graph(text: str) -> dict[str, Any] | None:
+ """从(可能带 fence / 非法 JSON 的)文本里解析出 ``{title, nodes, edges}``。无节点→None。"""
+ start = text.rfind(_FENCE)
+ body = text[start + len(_FENCE) :] if start >= 0 else text
+ close = body.find("```")
+ if close >= 0:
+ body = body[:close]
+ body = body.strip()
+
+ title = ""
+ nodes_raw: list[dict[str, Any]] = []
+ obj: Any = None
+ for candidate in (body, re.sub(r",(\s*[}\]])", r"\1", body)): # 原文 + 去尾逗号
+ try:
+ obj = json.loads(candidate)
+ break
+ except Exception: # noqa: BLE001
+ obj = None
+ if isinstance(obj, dict):
+ title = _as_str(obj.get("title"))
+ raw_list = obj.get("nodes")
+ nodes_raw = raw_list if isinstance(raw_list, list) else []
+ else:
+ m = re.search(r'"title"\s*:\s*"([^"]*)"', body)
+ if m:
+ title = m.group(1)
+ nodes_raw = _loose_nodes(body)
+
+ nodes: list[dict[str, Any]] = []
+ edges: list[dict[str, Any]] = []
+ seen: set[str] = set()
+ for raw in nodes_raw:
+ if not isinstance(raw, dict):
+ continue
+ node = _norm_node(raw)
+ if not node or node["id"] in seen:
+ continue
+ seen.add(node["id"])
+ nodes.append(node)
+ edges.extend(_edges_from(raw, node["id"]))
+ if not nodes:
+ return None
+
+ # 兼容**独立 `edges` 数组**:模型可能把连线写成节点上的 `from`,也可能写成顶层
+ # `edges:[{from,to,label}]`(或 source/target 别名)。后者若不处理,节点全无连线(bug)。
+ raw_edges: Any = obj.get("edges") if isinstance(obj, dict) else None
+ if not isinstance(raw_edges, list):
+ raw_edges = _loose_edges(body)
+ for e in raw_edges or []:
+ if not isinstance(e, dict):
+ continue
+ frm = _as_str(e.get("from")) or _as_str(e.get("source"))
+ to = _as_str(e.get("to")) or _as_str(e.get("target"))
+ if not frm or not to:
+ continue
+ edge: dict[str, Any] = {"from": frm, "to": to}
+ lbl = _as_str(e.get("label"))
+ if lbl:
+ edge["label"] = lbl
+ edges.append(edge)
+ # 只保留两端都存在的边 + 去重。
+ valid_edges: list[dict[str, Any]] = []
+ keys: set[str] = set()
+ for e in edges:
+ if e["from"] not in seen or e["to"] not in seen or e["from"] == e["to"]:
+ continue
+ k = f'{e["from"]}->{e["to"]}'
+ if k in keys:
+ continue
+ keys.add(k)
+ valid_edges.append(e)
+ return {"title": title, "nodes": nodes, "edges": valid_edges}
+
+
+# ── prompts ────────────────────────────────────────────────────────────────
+
+_SHAPE = (
+ "flow-json 严格用下面这个形状(一个 JSON 对象,nodes 是节点数组;示例仅示意结构、勿照抄内容):\n"
+ "```flow-json\n"
+ '{"title":"流程图标题","nodes":[\n'
+ ' {"id":"n1","label":"任务简短名","type":"start","group":"任务","detail":"任务目标"},\n'
+ ' {"id":"n2","label":"目的核心短语","type":"task","group":"目的","detail":"名称+理由要点(2-4句,单行)","from":["n1"]},\n'
+ ' {"id":"n3","label":"行为体核心短语","type":"role","group":"行为体","detail":"名称+理由要点","from":["n2"]}\n'
+ "]}\n"
+ "```\n"
+ "字段:id(n1/n2…唯一)、label(≤12字,节点标题)、type(start/end/stage/task/decision/risk/deliverable/role 之一,仅决定配色)、"
+ "group(该节点所在层级名称,如 任务/目的/行为体/预期行为/驱动因素/叙事,必填)、"
+ "detail(该条目名称+理由要点,约2-4句,**写成一行**不要内含换行)、"
+ "from(前驱节点 id 列表,只引用前面已出现的节点)。"
+)
+
+_STRUCTURE = (
+ "【画法·严格遵守】"
+ "① 根节点 n1=本次任务本身(报告/任务总览里的任务名称),label 用任务简短名,group 填「任务」,不要用「任务」泛称当 label;"
+ "② 从第二层起按层级逐层展开:每层在报告/交付里通常列了多条具体条目(如 目的层有 目的1/2/3、行为体层有各自对应行为体,后面还有 预期行为·驱动因素、叙事…),"
+ "把每一条具体条目各画成一个节点,不要把整层合并成一个笼统节点;"
+ "③ 每个节点 group 填它所在层级名称(取自报告里该层级真实名称);"
+ "④ 连边严格按报告里的对应关系串成分支:根任务→各目的;每个目的→它对应的行为体;每个行为体→对应的预期行为·驱动因素→对应的叙事……每组各成一条自上而下的分支(有几组就几条,组间不混连);"
+ "⑤ 务必把每层每一条都输出成节点、不要漏层、不要只输出开头一两个节点就停(典型约13-16个节点);"
+ "⑥ 只画这些层级实体节点,不要把『综合分析/核心洞察/执行路线/建议/结论』及『阶段①②③④』等汇总段落/阶段名画成节点。"
+)
+
+
+def _material(req: FlowExtractRequest) -> str:
+ report = (req.report_md or "").strip()
+ if report:
+ return "===== 方案总结报告全文(数据来源,逐条抽取,勿改写)=====\n" + report + "\n===== 报告结束 ====="
+ seats = [s for s in req.seat_deliveries if (s.content or "").strip()]
+ if seats:
+ body = "\n\n".join(f"## 席位{i + 1}:{s.sender or '(未具名席位)'}\n{s.content.strip()}" for i, s in enumerate(seats))
+ return "===== 各席位交付产物(数据来源,逐条抽取,勿改写)=====\n" + body + "\n===== 交付结束 ====="
+ return ""
+
+
+def _hierarchy_hint(req: FlowExtractRequest) -> str:
+ if (req.coordinator_prompt or "").strip():
+ return "层级顺序优先采用下面「业务链总控编排提示」里写明的层级链(如它写的 任务→目的→行为体→…):\n" + req.coordinator_prompt.strip()
+ levels = [s.strip() for s in req.chain_levels if s.strip()]
+ if levels:
+ return "层级顺序(自上而下):任务 → " + " → ".join(levels) + "。"
+ return "层级顺序:以「任务」为根,按报告/交付的逻辑结构合理分层展开。"
+
+
+def _extract_messages(req: FlowExtractRequest) -> list[Any]:
+ sys = (
+ "你是「流程图结构化抽取器」。你的唯一产物是一张流程图的 flow-json 数据(节点+连线),"
+ "不是文字报告、不是章节、不是结论清单。只输出一个 ```flow-json 代码块,不要写任何其它正文。全程简体中文。\n"
+ + _SHAPE
+ + "\n"
+ + _STRUCTURE
+ )
+ user = _material(req) + "\n\n" + _hierarchy_hint(req) + "\n\n请据此输出一个完整的 ```flow-json 代码块。"
+ return [SystemMessage(content=sys), HumanMessage(content=user)]
+
+
+def _edit_messages(req: FlowExtractRequest) -> list[Any]:
+ """Phase 2 问答修改:按用户指令改当前流程图(增/删/改节点或连线),其余保持不变。"""
+ cur = json.dumps(req.current_graph or {}, ensure_ascii=False)
+ sys = (
+ "你是「流程图编辑器」。下面给你 当前流程图(flow-json) + 数据来源 + 用户的修改指令。"
+ "请**按用户指令修改流程图**(可增/删/改节点、改连线、改 label/group/detail),"
+ "**未被指令涉及的部分保持原样**,输出**修改后的完整 flow-json**(只输出一个 ```flow-json 代码块,不要解释)。"
+ "节点 id 尽量沿用原有的;删节点要同时去掉与它相关的连线。全程简体中文。\n"
+ + _SHAPE
+ )
+ user = (
+ _material(req)
+ + "\n\n===== 当前流程图(flow-json,待按指令修改)=====\n"
+ + cur
+ + "\n===== 当前流程图结束 =====\n\n用户修改指令:\n"
+ + (req.instruction or "").strip()
+ )
+ return [SystemMessage(content=sys), HumanMessage(content=user)]
+
+
+def _validate_messages(req: FlowExtractRequest, extracted: str) -> list[Any]:
+ sys = (
+ "你是「流程图校验修复器」。下面给你 数据来源 和 一份已抽取的 flow-json。"
+ "请对照数据来源校验并修复这份 flow-json,输出**修复后**的完整 flow-json(只输出一个 ```flow-json 代码块,不要解释)。\n"
+ "校验要点:① 根节点是任务本身(group=任务);② 每个层级的每一条具体条目都在(没漏层、没漏条目);"
+ "③ 连边按对应关系成分支、无悬空边、根可达;④ 每个节点都有 group(层级名),label≤12字,detail 为单行的名称+理由要点;"
+ "⑤ 没有『综合分析/执行路线/结论』及『阶段编号』这类无关节点。发现问题就改对,没问题就原样规范化输出。\n"
+ + _SHAPE
+ )
+ user = _material(req) + "\n\n===== 已抽取的 flow-json(待校验修复)=====\n" + extracted + "\n===== 待校验结束 ====="
+ return [SystemMessage(content=sys), HumanMessage(content=user)]
+
+
+# ── endpoint ─────────────────────────────────────────────────────────────
+
+
+def _frame(data: dict[str, Any]) -> str:
+ return f"data: {json.dumps(data, ensure_ascii=False, default=str)}\n\n"
+
+
+def _chunk_parts(chunk: Any) -> tuple[str, str]:
+ """从流式 chunk 取 (正文增量, 思考增量)。思考走 additional_kwargs.reasoning_content
+ (DeepSeek/兼容 OpenAI 推理模型的单列字段),正文走 content。"""
+ content = chunk.content if isinstance(getattr(chunk, "content", None), str) else ""
+ ak = getattr(chunk, "additional_kwargs", None) or {}
+ reasoning = ""
+ if isinstance(ak, dict):
+ r = ak.get("reasoning_content") or ak.get("reasoning")
+ if isinstance(r, str):
+ reasoning = r
+ return content, reasoning
+
+
+@router.post("/stream")
+async def extract_flow_stream(request: Request, payload: FlowExtractRequest = Body(...)) -> StreamingResponse:
+ """流式抽取流程图 JSON + 流式校验修复(抽取/校验过程含思考都实时吐前端)→ 返回最终结构化图。"""
+
+ async def gen() -> AsyncIterator[str]:
+ try:
+ # 带 instruction + current_graph → Phase 2 问答修改(按指令改当前图);否则首次抽取。
+ is_edit = bool((payload.instruction or "").strip()) and payload.current_graph is not None
+ if not _material(payload).strip() and not is_edit:
+ yield _frame({"event": "error", "detail": "缺少数据来源(报告或各席位交付均为空)"})
+ return
+
+ # ① 抽取 / 编辑(正文 + 思考(reasoning) 拼成 `…正文` 增量逐帧吐,前端实时显示)
+ round1_messages = _edit_messages(payload) if is_edit else _extract_messages(payload)
+ model = create_chat_model(name=payload.model or None, thinking_enabled=True)
+ extracted = ""
+ think_open = False
+ async for chunk in model.astream(round1_messages):
+ c, r = _chunk_parts(chunk)
+ out = ""
+ if r:
+ if not think_open:
+ out += ""
+ think_open = True
+ out += r
+ if c:
+ if think_open:
+ out += ""
+ think_open = False
+ out += c
+ extracted += c
+ if out:
+ yield _frame({"event": "extract_delta", "content": out})
+ if think_open:
+ yield _frame({"event": "extract_delta", "content": ""})
+ yield _frame({"event": "extract_done"})
+
+ # ② 校验 / 修复(同样流式 + 思考实时显示)
+ yield _frame({"event": "validating"})
+ validated = ""
+ graph: dict[str, Any] | None = None
+ try:
+ vmodel = create_chat_model(name=payload.model or None, thinking_enabled=True)
+ think_open = False
+ async for chunk in vmodel.astream(_validate_messages(payload, extracted)):
+ c, r = _chunk_parts(chunk)
+ out = ""
+ if r:
+ if not think_open:
+ out += ""
+ think_open = True
+ out += r
+ if c:
+ if think_open:
+ out += ""
+ think_open = False
+ out += c
+ validated += c
+ if out:
+ yield _frame({"event": "validate_delta", "content": out})
+ if think_open:
+ yield _frame({"event": "validate_delta", "content": ""})
+ graph = parse_flow_graph(validated)
+ except Exception: # noqa: BLE001 — 校验失败不致命,回退抽取结果
+ logger.warning("flow-extract validation failed, fall back to extraction", exc_info=True)
+ graph = None
+ if graph is None:
+ graph = parse_flow_graph(extracted)
+ if graph is None:
+ yield _frame({"event": "error", "detail": "未能从模型输出解析出流程图节点"})
+ return
+ yield _frame({"event": "final", "graph": graph})
+ except Exception as exc: # noqa: BLE001
+ logger.exception("flow-extract stream failed")
+ yield _frame({"event": "error", "detail": str(exc)})
+
+ return StreamingResponse(
+ gen(),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
+ )
diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml
index 953730c2..997d7e2c 100644
--- a/offline-backend-20260512/backend/config.yaml
+++ b/offline-backend-20260512/backend/config.yaml
@@ -10,18 +10,18 @@ gateway:
port: 8001
models:
- # - name: deepseek-chat
- # display_name: DeepSeek Chat
- # use: langchain_openai:ChatOpenAI
- # model: deepseek-chat
- # api_key: sk-e1c22b7931dc446f8b67e57878edd105
- # base_url: https://api.deepseek.com
- # request_timeout: 600.0
- # max_retries: 2
- # max_tokens: 4096
- # temperature: 1.0
- # supports_vision: false
- # supports_thinking: true
+ - name: deepseek-chat
+ display_name: DeepSeek Chat
+ use: langchain_openai:ChatOpenAI
+ model: deepseek-chat
+ api_key: sk-35345154983047aba9e12faa86c5f62c
+ base_url: https://api.deepseek.com
+ request_timeout: 600.0
+ max_retries: 2
+ max_tokens: 4096
+ temperature: 1.0
+ supports_vision: false
+ supports_thinking: true
# - name: zai-org/GLM-5-FP8
# display_name: zai-org/GLM-5-FP8
From ed887f762e1c7f1ac38f2428203f014378522724 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?=
Date: Wed, 17 Jun 2026 21:47:11 +0800
Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=E5=9C=86=E6=A1=8C=E7=AC=AC3?=
=?UTF-8?q?=E6=AD=A5=E5=A4=A7=E5=B1=8F=E5=A4=9A=E9=A1=B5=E6=94=B9=E7=94=A8?=
=?UTF-8?q?=E4=B8=93=E8=81=8C=E6=99=BA=E8=83=BD=E4=BD=93=20+=20=E5=81=A5?=
=?UTF-8?q?=E5=A3=AE=20report-json=20=E6=8A=BD=E5=8F=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
修复「大屏多页抽取 json 又失败」:旧实现复用画 HTML 的 roundtable-report
智能体 + rfind 围栏 + 单次 json.loads 的简单粗暴抽取,弱模型跑偏/格式偏差即失败。
- 新内置专职单例 roundtable-dashboard「大屏多页数据智能体」:SOUL 专讲逐席位
分析 → report-json 契约,禁工具/禁文件/禁 HTML;新 run policy dashboard 禁用一切工具
- harness 新增 report_json.py:多策略抽取(report-json/json/裸 JSON/截断)+ schema
归一化(移植前端 extractReportData/coerceReportData),返回可直接渲染的归一化 JSON
- 后台 run_dashboard_data 改用专职 agent + dashboard policy + 健壮抽取
- 新增 /api/multi-agent/dashboard/init;前端 initDashboardThread + useStep3Dashboard 改用之
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../roundtable-planning/api/multi-agent.ts | 26 ++
.../hooks/useStep3Dashboard.ts | 6 +-
.../gateway/roundtable_inprocess_gateway.py | 49 ++--
.../app/gateway/roundtable_run_policy.py | 32 ++-
.../app/gateway/routers/_roundtable_seed.py | 11 +-
.../roundtable-dashboard/SOUL.md | 57 ++++
.../roundtable-dashboard/config.yaml | 3 +
.../app/gateway/routers/multi_agent.py | 26 ++
.../roundtable_orchestrator/report_json.py | 271 ++++++++++++++++++
9 files changed, 452 insertions(+), 29 deletions(-)
create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-dashboard/SOUL.md
create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-dashboard/config.yaml
create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/report_json.py
diff --git a/frontend-web/src/roundtable-planning/api/multi-agent.ts b/frontend-web/src/roundtable-planning/api/multi-agent.ts
index 91ef55a3..9d2c011b 100644
--- a/frontend-web/src/roundtable-planning/api/multi-agent.ts
+++ b/frontend-web/src/roundtable-planning/api/multi-agent.ts
@@ -835,6 +835,32 @@ export async function initReportThread(
return { agentName: json.agent_name, threadId: json.thread_id };
}
+/**
+ * Create a thread for the Step 3「大屏多页」dashboard-data agent
+ * (`roundtable-dashboard`, a built-in singleton — distinct from `roundtable-report`;
+ * its run policy disables ALL tools so it only emits a ```report-json block). Run it
+ * with {@link streamMultiAgent} (`agentType: "special"`, `agentName: "roundtable-dashboard"`,
+ * `dAgentThreadId: { [agentName]: threadId }`); the frontend then `extractReportData`s
+ * the streamed text. Follow-up Q&A / refinements reuse the SAME thread (do not re-init)
+ * so the agent keeps the prior dashboard context.
+ */
+export async function initDashboardThread(
+ options: { signal?: AbortSignal } = {},
+): Promise {
+ const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/dashboard/init`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: "{}",
+ signal: options.signal,
+ });
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ throw new Error(`init dashboard thread failed (${res.status}): ${text}`);
+ }
+ const json = (await res.json()) as { agent_name: string; thread_id: string };
+ return { agentName: json.agent_name, threadId: json.thread_id };
+}
+
/**
* Create a thread for the Step 3「总结报告」summary agent (`roundtable-summary`,
* a built-in singleton — distinct from `roundtable-report`; this only spins up a
diff --git a/frontend-web/src/roundtable-planning/hooks/useStep3Dashboard.ts b/frontend-web/src/roundtable-planning/hooks/useStep3Dashboard.ts
index cc2e899a..b96ebadf 100644
--- a/frontend-web/src/roundtable-planning/hooks/useStep3Dashboard.ts
+++ b/frontend-web/src/roundtable-planning/hooks/useStep3Dashboard.ts
@@ -15,7 +15,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useArtifacts } from "@/components/workspace/artifacts";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import {
- initReportThread,
+ initDashboardThread,
streamMultiAgent,
type StreamPhase,
} from "@/roundtable-planning/api/multi-agent";
@@ -35,7 +35,7 @@ import {
} from "@/roundtable-planning/lib/report-template";
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
-const DASH_AGENT_FALLBACK = "roundtable-report";
+const DASH_AGENT_FALLBACK = "roundtable-dashboard";
export type DashboardPhase = "idle" | "creating" | "streaming" | "done" | "error";
@@ -269,7 +269,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
const { bubbleId, message, aborter } = args;
let threadId = liveThreadRef.current;
if (!threadId) {
- const init = await initReportThread({ signal: aborter.signal });
+ const init = await initDashboardThread({ signal: aborter.signal });
if (aborter.signal.aborted) return;
threadId = init.threadId;
agentNameRef.current = init.agentName || DASH_AGENT_FALLBACK;
diff --git a/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py b/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py
index 5eee3c38..0617f8d2 100644
--- a/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py
+++ b/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py
@@ -15,7 +15,6 @@
from __future__ import annotations
import asyncio
-import json
import logging
import os
import uuid
@@ -28,10 +27,12 @@ from langgraph.checkpoint.base import empty_checkpoint
from app.gateway.roundtable_run_policy import roundtable_run_policy
from app.gateway.routers._roundtable_seed import (
COORDINATOR_AGENT_ID,
+ DASHBOARD_AGENT_ID,
REPORT_AGENT_ID,
SUMMARY_AGENT_ID,
ensure_roundtable_functional_agents,
)
+from deerflow.agents.roundtable_orchestrator.report_json import extract_report_json
from deerflow.agents.roundtable_orchestrator.types import (
LeaderTurn,
ReportResult,
@@ -331,30 +332,24 @@ _SUMMARY_RETRY_MISSING = (
"先写前半部分、再用 write_file 的 append=true 参数分段续写。写完后把报告正文也输出到对话回复里。"
)
-_DASHBOARD_FENCE = "```report-json"
_DASHBOARD_RETRY = (
- "你刚才没有输出有效的 ```report-json 代码块(或其中的 JSON 无法解析)。"
- "请重新在回复**最末尾**输出一个完整且**严格合法**的 ```report-json 代码块"
- "(标准 JSON:无注释、无尾逗号、字符串用双引号),代码块之外只允许一两句说明;"
+ "你刚才没有输出可用的大屏数据(缺少 ```report-json 代码块,或其中的 JSON 无法解析、"
+ "字段不符合契约)。请重新**逐席位分析**研讨成果,在回复**最末尾**输出一个完整且**严格合法**"
+ "的 ```report-json 代码块:标准 JSON(双引号、无注释、无尾逗号、数字不加引号),seats 覆盖"
+ "每一个有交付的席位,dependsOn 只引用本清单内的席位 id;代码块之外只允许一两句说明;"
"不要调用任何工具、不要写文件、不要输出 HTML。"
)
def _extract_report_json(text: str) -> str | None:
- """从模型回复里提取最后一个 ```report-json 代码块的 JSON 字符串(须可解析)。"""
- start = text.rfind(_DASHBOARD_FENCE)
- if start < 0:
- return None
- after = text[start + len(_DASHBOARD_FENCE) :]
- close = after.find("```")
- body = (after[:close] if close >= 0 else after).strip()
- if not body:
- return None
- try:
- json.loads(body)
- except Exception: # noqa: BLE001
- return None
- return body
+ """从模型回复里健壮抽取大屏 report-json,返回**归一化后的** JSON 字符串(失败 → None)。
+
+ 旧实现是「找最后一个 ```report-json 围栏 + json.loads」的简单粗暴抽取,模型一旦用
+ ```json / 裸 JSON / 流式截断 / 尾逗号 就直接失败(即用户反馈的「大屏多页抽取 json 又失败」)。
+ 现委托给 harness 的 :func:`extract_report_json`(多策略抽取 + schema 归一化,与前端
+ ``report-template.ts`` 同源),返回的 JSON 字段齐全、可被前端直接 JSON.parse。
+ """
+ return extract_report_json(text)
def _html_complete(html: str | None) -> bool:
@@ -569,19 +564,25 @@ class InProcessRoundtableGateway:
return SummaryResult(md=final_md, closing=closing, model=model or self._model, thread_id=summary_thread_id)
async def run_dashboard_data(self, *, prompt, model) -> str | None:
- """大屏多页(模式二)的结构化数据:进程内驱动 roundtable-report 输出
- ```report-json(prompt 已声明禁工具/禁文件),返回可解析的 JSON 字符串。
- 缺失 / 坏 JSON → 同线程纠偏重试(同 run_report/run_summary 的自愈语义)。"""
+ """大屏多页的结构化数据:进程内驱动**专职**「大屏多页数据智能体」
+ (``roundtable-dashboard``) 逐席位分析研讨成果,在对话里输出 ```report-json。
+
+ 与旧版的差异(修复「大屏多页抽取 json 又失败」):
+ - 用**专职** dashboard 智能体(SOUL 专讲「逐席位分析 → 结构化数据」)而非复用
+ 画 HTML 的 report 智能体;``dashboard`` policy **禁用一切工具**,杜绝弱模型跑去写文件。
+ - 抽取走 :func:`_extract_report_json` → harness 健壮多策略抽取 + schema 归一化,
+ 容忍 ```json / 裸 JSON / 流式截断,并保证返回的 JSON 字段齐全可直接渲染。
+ - 仍保留同线程纠偏重试(缺失/坏 JSON 时追加 ``_DASHBOARD_RETRY``)。"""
ensure_roundtable_functional_agents()
thread_id = await _create_thread(self._app)
- policy = roundtable_run_policy("report")
+ policy = roundtable_run_policy("dashboard")
message = prompt
for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
messages = await _run_agent_turn(
self._app,
user_id=self._user_id,
thread_id=thread_id,
- agent_name=REPORT_AGENT_ID,
+ agent_name=DASHBOARD_AGENT_ID,
message=message,
model=model or self._model,
excluded_tools=policy.excluded_tools,
diff --git a/offline-backend-20260512/backend/app/gateway/roundtable_run_policy.py b/offline-backend-20260512/backend/app/gateway/roundtable_run_policy.py
index d1eea26a..7b9b2ac1 100644
--- a/offline-backend-20260512/backend/app/gateway/roundtable_run_policy.py
+++ b/offline-backend-20260512/backend/app/gateway/roundtable_run_policy.py
@@ -119,6 +119,29 @@ _REPORT_EXCLUDED: list[str] = [*_SEAT_EXCLUDED, "str_replace", "read_peer_delive
# 完全一致——可写文件(write_file 整文件重写),禁 str_replace(整文件重写才能让右侧 md 预览
# 实时刷新)/bash/联网/派活/呈现文件/看图/读前序席位。单列出来便于以后独立调优。
_SUMMARY_EXCLUDED: list[str] = [*_SEAT_EXCLUDED, "str_replace", "read_peer_delivery"]
+# dashboard:Step3「大屏多页」数据智能体(roundtable-dashboard)——纯「研讨成果 → report-json」
+# 提炼器,**禁用一切工具**(同 leader 的黑名单全集)。它只需读 prompt、分析各席位、在对话里
+# 输出一个 report-json 代码块;留任何干活类工具(尤其 write_file / bash)只会让弱模型跑偏去
+# 写文件而不输出 JSON(report agent 上已实锤这类行为)。thinking + medium:需要逐席位分析归纳。
+_DASHBOARD_EXCLUDED: list[str] = [
+ "web_search",
+ "view_image",
+ "bash",
+ "ls",
+ "read_file",
+ "write_file",
+ "str_replace",
+ "task",
+ "write_todos",
+ "memory",
+ "hindsight_recall",
+ "hindsight_reflect",
+ "hindsight_retain",
+ "read_peer_delivery",
+ "agent_orchestration",
+ "ask_clarification",
+ "present_files",
+]
# leader 内禀的 skill_stop:``agent_orchestration`` 调用后停图。调用方可在此基础上
# 再叠加自己的 skill_stop(见 ``merge_skill_stop_names``)。
@@ -181,8 +204,15 @@ def roundtable_run_policy(role: str) -> RoundtableRunPolicy:
thinking_enabled=True,
reasoning_effort="medium",
)
+ if role == "dashboard":
+ return RoundtableRunPolicy(
+ excluded_tools=list(_DASHBOARD_EXCLUDED),
+ skill_stop_names=[],
+ thinking_enabled=True,
+ reasoning_effort="medium",
+ )
raise ValueError(
- f"unknown roundtable role: {role!r} (expected leader/seat/seat_parallel/report/summary)"
+ f"unknown roundtable role: {role!r} (expected leader/seat/seat_parallel/report/summary/dashboard)"
)
diff --git a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py
index 43f9eb21..1f0f42cc 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py
@@ -67,6 +67,13 @@ REPORT_AGENT_ID = "roundtable-report"
# 总结报告(写入 outputs/方案总结报告.md),并按业务链总控编排提示组织。与 report 并存
# 的另一内置单例(report 画 HTML 看板,summary 写 Markdown 报告 + 支持问答/增量改)。
SUMMARY_AGENT_ID = "roundtable-summary"
+# Step 3「大屏多页」用的结构化数据智能体:读各席位交付 + 共识,逐席位分析并提炼成
+# 一份大屏展示用的结构化数据(report-json),供前端按模板渲染成多页大屏。**不调任何工具、
+# 不写文件、不输出 HTML**——只在对话里输出一个严格合法的 ```report-json 代码块。与 report
+# (画 HTML 看板)/summary(写 Markdown)并存的第三个 Step3 内置单例,把「大屏抽取」从
+# 旧版「复用 report agent + 简单粗暴字符串抽取」改为专职智能体 + 健壮抽取(见
+# roundtable_orchestrator.report_json)。
+DASHBOARD_AGENT_ID = "roundtable-dashboard"
# 验证版 SOUL.md + config.yaml 的源码资源目录(随 git 发布)。每个子目录名即 agent id,
# 内含该 agent 的 config.yaml 与 SOUL.md,是 seeder 落盘时字节级复制的来源。
@@ -74,13 +81,15 @@ _ASSETS_DIR = Path(__file__).parent / "_roundtable_seed_assets"
# 这几个功能型/内置 agent 任何部署都必须存在;顺序无关紧要(逐个独立补建)。
# 前三个支撑 Step 1 澄清 / 推荐 / Step 2 总控;roundtable-report 支撑 Step 3 结果绘制
-# (HTML 看板);roundtable-summary 支撑 Step 3 总结报告(Markdown + 问答/增量改)。
+# (HTML 看板);roundtable-summary 支撑 Step 3 总结报告(Markdown + 问答/增量改);
+# roundtable-dashboard 支撑 Step 3 大屏多页结构化数据(report-json + 问答/增量改)。
_FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
INTENT_AGENT_ID,
RECOMMENDER_AGENT_ID,
COORDINATOR_AGENT_ID,
REPORT_AGENT_ID,
SUMMARY_AGENT_ID,
+ DASHBOARD_AGENT_ID,
)
# seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。
diff --git a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-dashboard/SOUL.md b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-dashboard/SOUL.md
new file mode 100644
index 00000000..3d002880
--- /dev/null
+++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-dashboard/SOUL.md
@@ -0,0 +1,57 @@
+# 圆桌大屏多页数据智能体
+
+## 核心定位
+你是多智能体圆桌研讨 **Step 3** 的「大屏多页数据智能体」。各席位(情报收集 / 环境评估 / 方案设计 / 风险审查 / 执行规划 / 总结陈述等)已经完成研讨并产出交付,总控也给出了最终共识。
+
+你的**唯一职责**:通读这些文字成果,**逐个席位地分析、提炼**,把整场研讨归纳成一份**大屏展示用的结构化数据**,在回复**最末尾**输出一个**严格合法**的 ```report-json 代码块。
+
+这份数据会被前端用既定模板渲染成「多页大屏」(总览页 + 每个席位一页)。所以数据的**结构正确性与可解析性是第一位的**——宁可内容朴素,也必须是一份能被 `JSON.parse` 解析、字段齐全的 JSON。
+
+你**不**调研、**不**联网搜索、**不**派活、**不**追问用户、**不**写文件、**不**输出 HTML——你是一个「研讨成果 → 结构化数据」的提炼器,只在对话里输出一个 report-json 代码块。
+
+## 输入说明
+用户消息里会给你三部分原料(可能有缺省):
+1. **任务意图摘要**:目标、约束、关键假设(来自 Step 1);
+2. **各席位最终交付**:每个席位的名称 + 它产出的完整正文;
+3. **总控最终共识结论**。
+
+## 工作流程(先分析,再产出)
+1. **逐席位分析**:依次读每个有交付的席位,在思考里为它提炼出——该席位的一句话结论(summary)、2–4 个小节要点(sections)、0–3 个可量化指标(metrics,0–100 评分)、0–4 个风险点(risks,high/mid/low)、它承接了哪些其它席位的产物(dependsOn,只能引用本次清单里的席位 id)。
+2. **抽取全局总览**:从跨席位信息里提炼总标题、一句话定位、3–6 个核心 KPI、1–3 个总览图表(风险分布 / 资源占比 / 方案评分 / 竞争力雷达等)、3–6 段执行路线(timeline),以及总控的最终结论 headline + 要点 points。
+3. **缺数字时合理估算**:没有明确数字时,按研讨内容把定性的「高/中/低」映射成评分、把阶段描述排成时间线等,**合理估算而非凭空编造与原料矛盾的数据**;估算的指标在 label 里可带「(估)」字样。
+4. **输出**:在回复最末尾输出一个 ```report-json 代码块。代码块之外只允许一两句简短说明,**不要**把数据重复贴进正文。
+
+## report-json 契约(严格 JSON,全程简体中文)
+```report-json
+{"title":"大屏主标题(≤20字)","subtitle":"一句话定位",
+ "kpis":[{"label":"指标名","value":"数值","unit":"单位","trend":"up|down|flat"}],
+ "consensus":{"headline":"一句话最终结论","points":["结论要点,3-6 条"]},
+ "charts":[{"type":"line|bar|donut|radar","title":"图表标题","items":[{"label":"维度","value":数字}]}],
+ "timeline":[{"label":"阶段名","desc":"一句话说明"}],
+ "seats":[{"id":"seat-1","name":"席位名(与交付席位一致)","summary":"该席位结论一两句",
+ "sections":[{"heading":"小节标题","bullets":["要点,每条≤40字"]}],
+ "metrics":[{"label":"量化指标名","value":0到100}],
+ "risks":[{"label":"风险点","level":"high|mid|low"}],
+ "dependsOn":["它承接哪些席位的产物(席位id)"]}]}
+```
+
+### 字段硬性要求
+- `seats` 覆盖**每一个有交付的席位**,一个席位一项;`id` 用 `seat-1`..`seat-N`,`name` 与交付席位名一致;
+- `dependsOn` 只能引用本清单内出现过的席位 `id`,不能自指,不能编造不存在的 id;
+- `charts` 的 `type` 只能是 `line` / `bar` / `donut` / `radar`;`kpis` 的 `trend` 只能是 `up` / `down` / `flat`;`risks` 的 `level` 只能是 `high` / `mid` / `low`;
+- 数值字段写数字(不带引号、不带单位);`kpis.value` 可带文字(如「约 30」),单位单独放 `unit`;
+- **JSON 必须严格合法**:双引号、无注释、无尾逗号、无单引号、布尔/数字不加引号。
+
+## 多轮修改(用户会在输入框里继续提要求)
+数据生成后,用户可能继续发来修改要求(如「把风险分布换成柱状图」「新增一个执行路线席位」「第 2 个席位的结论改细些」)。处理规则:
+1. 把新消息当作对**上一份 report-json 的增量修改**:只改用户提到的部分,其余字段尽量保持不变;
+2. 仍在回复最末尾输出**完整的、严格合法的** ```report-json 代码块(不是只给 diff);
+3. 若用户只是提问而非要求改数据,就只用文字回答、不重出代码块。
+
+## 禁止事项
+- ❌ 调用任何工具(write_file / bash / web_search / 派活 / 看图 / 澄清 等一律不用)
+- ❌ 写任何文件、输出任何 HTML / Markdown 表格作为「产物」(产物只有那个 report-json 代码块)
+- ❌ 输出非法 JSON(注释、尾逗号、单引号、数字带引号、缺闭合括号)
+- ❌ 代码块之外大段复述数据,或把 JSON 也贴一份到正文
+- ❌ 编造与原料明显矛盾的数据,或编造不存在的席位 id
+- ❌ 在数据里添加「AI 自动生成」之类的水印字样
diff --git a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-dashboard/config.yaml b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-dashboard/config.yaml
new file mode 100644
index 00000000..d8999d8a
--- /dev/null
+++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-dashboard/config.yaml
@@ -0,0 +1,3 @@
+description: 圆桌大屏多页数据智能体,读取各席位的最终交付与总控共识,逐席位分析并提炼成一份大屏展示用的结构化数据(report-json)。本身不调研、不联网、不派活、不写文件、不输出 HTML,只做分析归纳与结构化抽取。
+id: roundtable-dashboard
+name: 大屏多页数据
diff --git a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py
index ee12623f..502968fc 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py
@@ -65,6 +65,7 @@ from langgraph.checkpoint.base import empty_checkpoint
from app.gateway.roundtable_run_policy import merge_skill_stop_names, roundtable_run_policy
from app.gateway.routers._roundtable_seed import (
COORDINATOR_AGENT_ID,
+ DASHBOARD_AGENT_ID,
REPORT_AGENT_ID,
SUMMARY_AGENT_ID,
ensure_roundtable_functional_agents,
@@ -945,6 +946,28 @@ async def init_summary_thread(request: Request) -> dict[str, str]:
return {"agent_name": SUMMARY_AGENT_ID, "thread_id": thread_id}
+@router.post("/dashboard/init")
+async def init_dashboard_thread(request: Request) -> dict[str, str]:
+ """为 Step 3「大屏多页」创建一个供大屏多页数据智能体(``roundtable-dashboard``)
+ 运行的线程,返回 ``{"agent_name", "thread_id"}``。
+
+ 与 ``/report/init`` / ``/summary/init`` 同构,换成第三个内置单例
+ ``roundtable-dashboard``(专职「逐席位分析 → report-json」,run policy 为 ``dashboard``,
+ **禁用一切工具**):
+ - ``roundtable-dashboard`` 是**内置单例**(``_roundtable_seed`` 种子化,启动即同步进 DB),
+ 这里**只建一个轻量 thread**,绝不重建 agent;
+ - 线程打 ``_ROUNDTABLE_THREAD_METADATA`` 标记成系统会话,从「最近对话」里隐藏;
+ - 入口先 ``ensure_roundtable_functional_agents()`` 自愈(磁盘目录被手删也能补建);
+ - 建好后前端用现有 ``POST /run/stream``(``agent_type="special"``、
+ ``agent_name="roundtable-dashboard"``、``d_agent_thread_id={DASHBOARD_AGENT_ID: thread_id}``)
+ 跑这一轮(后续问答/增量改**复用同一线程**,不重新 init),智能体在对话里输出
+ ```report-json,前端用 ``extractReportData`` 抽取后按当前主题渲染大屏。
+ """
+ ensure_roundtable_functional_agents()
+ thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
+ return {"agent_name": DASHBOARD_AGENT_ID, "thread_id": thread_id}
+
+
# ---------------------------------------------------------------------------
# 上游 LangGraph SSE 透传(intent / recommend / leader / special 共用)
# ---------------------------------------------------------------------------
@@ -1613,6 +1636,9 @@ async def _special_run(
role = "report"
elif agent_name == SUMMARY_AGENT_ID:
role = "summary"
+ elif agent_name == DASHBOARD_AGENT_ID:
+ # 大屏多页数据智能体:纯「研讨成果 → report-json」提炼,禁用一切工具。
+ role = "dashboard"
elif no_file:
role = "seat_parallel"
else:
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/report_json.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/report_json.py
new file mode 100644
index 00000000..aceda701
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/report_json.py
@@ -0,0 +1,271 @@
+"""Step3「大屏多页」report-json 的健壮抽取 + 归一化(schema coercion)。
+
+后台与前端共用同一份契约:内置「大屏多页智能体」(``roundtable-dashboard``) 通读各席位
+交付 + 总控共识,在回复末尾输出一个 ```report-json 代码块描述大屏数据。
+
+模型实际输出常有偏差——用 ```json 而非 ```report-json、裸 JSON 无围栏、流式截断没闭合、
+尾逗号 / 单引号等。旧版后台用单一 ``rfind("```report-json")`` + ``json.loads`` 抽取,
+任何偏差都直接失败(即用户反馈的「大屏多页抽取 json 又失败」)。
+
+本模块把前端 ``report-template.ts`` 里已验证的多策略抽取 + 字段归一化移植到后端:
+依次尝试 report-json / json / 无标注 代码块、未闭合围栏尾串、夹在文字里的平衡 ``{…}``
+对象;每个候选 ``json.loads`` 后过 :func:`coerce_report_data` 校验「像不像本契约」,
+命中即返回**归一化后的 JSON 字符串**——保证可被前端 ``JSON.parse`` 且字段齐全、
+``seats`` 的 ``dependsOn`` 只引用真实存在的席位 id。
+
+纯逻辑、无 app 依赖,放在 harness 供进程内网关与测试复用。
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from typing import Any
+
+_FENCE = "```report-json"
+# 契约关键键:用来判断一段 JSON「像不像」本大屏契约,避免把无关 {} 误当数据。
+_LOOKS_LIKE_RE = re.compile(r'"(seats|consensus|kpis|charts|timeline)"\s*:')
+# 收集所有 ```lang\n...``` 代码块(lang 可空)。
+_FENCE_RE = re.compile(r"```([^\n`]*)\r?\n(.*?)```", re.DOTALL)
+
+
+# ── 标量/容器归一化小工具(对齐前端 str/num/arr) ───────────────────────────────
+
+
+def _as_str(v: Any, fallback: str = "") -> str:
+ return v if isinstance(v, str) else fallback
+
+
+def _as_num(v: Any) -> float | int:
+ if isinstance(v, bool): # bool 是 int 子类,单独挡掉
+ return 0
+ if isinstance(v, (int, float)):
+ n = v
+ else:
+ try:
+ n = float(v)
+ except (TypeError, ValueError):
+ return 0
+ if isinstance(n, float) and n.is_integer():
+ return int(n)
+ return n
+
+
+def _as_list(v: Any) -> list[Any]:
+ return v if isinstance(v, list) else []
+
+
+def _looks_like_report_json(s: str) -> bool:
+ return bool(_LOOKS_LIKE_RE.search(s))
+
+
+def _collect_fenced_blocks(text: str) -> list[tuple[str, str]]:
+ return [(m.group(1).strip().lower(), m.group(2).strip()) for m in _FENCE_RE.finditer(text)]
+
+
+def _balanced_json_objects(text: str) -> list[str]:
+ """扫描出所有顶层平衡的 ``{…}`` 对象(字符串内的花括号不计),用于从夹杂文字里捞裸 JSON。"""
+ out: list[str] = []
+ depth = 0
+ start = -1
+ in_str = False
+ esc = False
+ for i, ch in enumerate(text):
+ if in_str:
+ if esc:
+ esc = False
+ elif ch == "\\":
+ esc = True
+ elif ch == '"':
+ in_str = False
+ continue
+ if ch == '"':
+ in_str = True
+ elif ch == "{":
+ if depth == 0:
+ start = i
+ depth += 1
+ elif ch == "}" and depth > 0:
+ depth -= 1
+ if depth == 0 and start >= 0:
+ out.append(text[start : i + 1])
+ start = -1
+ return out
+
+
+# ── schema coercion(对齐前端 coerceReportData) ───────────────────────────────
+
+
+def coerce_report_data(raw: Any) -> dict | None:
+ """把已 ``json.loads`` 的对象归一化成大屏契约 dict(缺字段给安全默认;非法 → None)。
+
+ 规则与前端 ``coerceReportData`` 一致:seats 至少要有 name 才保留;dependsOn 仅保留
+ 真实存在且非自指的席位 id;kpis/charts/timeline 过滤空项并限量;整体既无 seats 又无
+ 最终结论 headline 时判为「不像本契约」→ None。
+ """
+ if not isinstance(raw, dict):
+ return None
+
+ seats: list[dict] = []
+ for i, s in enumerate(_as_list(raw.get("seats"))):
+ so = s if isinstance(s, dict) else {}
+ name = _as_str(so.get("name")).strip()
+ if not name:
+ continue
+ sid = _as_str(so.get("id")).strip() or f"seat-{i + 1}"
+ sections = []
+ for sec in _as_list(so.get("sections")):
+ co = sec if isinstance(sec, dict) else {}
+ heading = _as_str(co.get("heading"))
+ bullets = [b for b in (_as_str(x) for x in _as_list(co.get("bullets"))) if b]
+ if heading or bullets:
+ sections.append({"heading": heading, "bullets": bullets})
+ metrics = []
+ for m in _as_list(so.get("metrics")):
+ mo = m if isinstance(m, dict) else {}
+ label = _as_str(mo.get("label"))
+ if label:
+ metrics.append({"label": label, "value": min(100, max(0, _as_num(mo.get("value"))))})
+ risks = []
+ for r in _as_list(so.get("risks")):
+ ro = r if isinstance(r, dict) else {}
+ label = _as_str(ro.get("label"))
+ if not label:
+ continue
+ level = _as_str(ro.get("level"))
+ risks.append({"label": label, "level": level if level in ("high", "mid", "low") else "mid"})
+ depends_on = [d for d in (_as_str(x).strip() for x in _as_list(so.get("dependsOn"))) if d]
+ seats.append(
+ {
+ "id": sid,
+ "name": name,
+ "summary": _as_str(so.get("summary")),
+ "sections": sections,
+ "metrics": metrics,
+ "risks": risks,
+ "dependsOn": depends_on,
+ }
+ )
+
+ # dependsOn 只保留真实存在的席位 id(模型可能编 id),且不自指。
+ id_set = {s["id"] for s in seats}
+ for s in seats:
+ s["dependsOn"] = [d for d in s["dependsOn"] if d in id_set and d != s["id"]]
+
+ consensus_raw = raw.get("consensus") if isinstance(raw.get("consensus"), dict) else {}
+ consensus = {
+ "headline": _as_str(consensus_raw.get("headline")),
+ "points": [p for p in (_as_str(x) for x in _as_list(consensus_raw.get("points"))) if p],
+ }
+
+ kpis = []
+ for k in _as_list(raw.get("kpis")):
+ ko = k if isinstance(k, dict) else {}
+ label = _as_str(ko.get("label"))
+ value = _as_str(ko.get("value"), str(ko.get("value")) if ko.get("value") is not None else "")
+ if not (label and value):
+ continue
+ item: dict[str, Any] = {"label": label, "value": value}
+ unit = _as_str(ko.get("unit"))
+ if unit:
+ item["unit"] = unit
+ trend = _as_str(ko.get("trend"))
+ if trend in ("up", "down", "flat"):
+ item["trend"] = trend
+ kpis.append(item)
+ if len(kpis) >= 6:
+ break
+
+ charts = []
+ for c in _as_list(raw.get("charts")):
+ co = c if isinstance(c, dict) else {}
+ ctype = _as_str(co.get("type"))
+ items = []
+ for it in _as_list(co.get("items")):
+ io = it if isinstance(it, dict) else {}
+ label = _as_str(io.get("label"))
+ if label:
+ items.append({"label": label, "value": _as_num(io.get("value"))})
+ if not items:
+ continue
+ charts.append(
+ {
+ "type": ctype if ctype in ("bar", "donut", "radar", "line") else "bar",
+ "title": _as_str(co.get("title")),
+ "items": items,
+ }
+ )
+ if len(charts) >= 3:
+ break
+
+ timeline = []
+ for t in _as_list(raw.get("timeline")):
+ to = t if isinstance(t, dict) else {}
+ label = _as_str(to.get("label"))
+ if label:
+ timeline.append({"label": label, "desc": _as_str(to.get("desc"))})
+
+ data = {
+ "title": _as_str(raw.get("title"), "方案研讨大屏"),
+ "subtitle": _as_str(raw.get("subtitle")),
+ "kpis": kpis,
+ "consensus": consensus,
+ "charts": charts,
+ "timeline": timeline,
+ "seats": seats,
+ }
+ if not data["seats"] and not data["consensus"]["headline"]:
+ return None
+ return data
+
+
+# ── 多策略抽取 ───────────────────────────────────────────────────────────────
+
+
+def extract_report_json(text: str | None) -> str | None:
+ """从模型回复里健壮抽取大屏 report-json,返回**归一化后的 JSON 字符串**(失败 → None)。
+
+ 候选顺序(对齐前端 ``extractReportData``):
+ 1. 代码块,按 lang 优先级 report-json > json/jsonc > 无标注 > 其它,同级取靠后的;
+ 2. 未闭合围栏(流式截断):最后一个 ```report-json 之后到结尾;
+ 3. 夹在文字里的所有平衡 ``{…}`` 对象,契约形状的优先、靠后的先试。
+ 每个候选 ``json.loads`` 成功后过 :func:`coerce_report_data`,命中即返回归一化 JSON。
+ """
+ if not text:
+ return None
+
+ def rank(lang: str) -> int:
+ if lang == "report-json":
+ return 0
+ if lang in ("json", "jsonc"):
+ return 1
+ if lang == "":
+ return 2
+ return 3
+
+ candidates: list[str] = []
+ blocks = list(enumerate(_collect_fenced_blocks(text)))
+ blocks.sort(key=lambda bi: (rank(bi[1][0]), -bi[0]))
+ candidates.extend(body for _, (_, body) in blocks)
+
+ # 未闭合围栏(流式截断):取最后一个 report-json fence 之后到结尾。
+ f = text.rfind(_FENCE)
+ if f >= 0:
+ candidates.append(text[f + len(_FENCE) :].strip())
+
+ # 裸 JSON(无围栏 / 夹在文字里):契约形状的先试,再试其余;都按靠后优先。
+ objs = _balanced_json_objects(text)
+ candidates.extend(o for o in reversed(objs) if _looks_like_report_json(o))
+ candidates.extend(reversed(objs))
+
+ for body in candidates:
+ if not body:
+ continue
+ try:
+ raw = json.loads(body)
+ except (ValueError, TypeError):
+ continue
+ data = coerce_report_data(raw)
+ if data is not None:
+ return json.dumps(data, ensure_ascii=False)
+ return None
From 0890b3c3755bad50d658992d07f25b06fb127fc6 Mon Sep 17 00:00:00 2001
From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com>
Date: Wed, 17 Jun 2026 22:17:48 +0800
Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=E7=AC=AC=E4=B8=89=E6=AD=A5?=
=?UTF-8?q?=E6=B5=81=E7=A8=8B=E5=9B=BE=E5=90=8E=E7=AB=AF=E6=8A=BD=E5=8F=96?=
=?UTF-8?q?=E6=99=BA=E8=83=BD=E4=BD=93=20+=20=E6=84=8F=E5=9B=BE=E8=AF=86?=
=?UTF-8?q?=E5=88=AB=20+=20=E9=97=AE=E7=AD=94/=E7=94=BB=E5=B8=83=E7=BC=96?=
=?UTF-8?q?=E8=BE=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增专职「结构化抽取智能体」roundtable-structure(种子资产 SOUL+config,注册进 seeder
启动 seed+sync 进 DB,可在智能体管理页编辑、未来挂入库技能)。
- /api/flow-extract/stream 改用该 agent 的 SOUL(load_agent_soul,缺失回退内置默认) +
模型(前端所选 > agent 配置 > 默认);抽取/校验/编辑统一。SOUL 先输出简要分析过程再出
flow-json,模型慢时也有实时反馈(含 思考流)。
- 新增 /api/flow-extract/intent 意图识别(参考 ai_writing 一次性 JSON 范式):识别
重做报告/改流程图/重画流程图/提问/模糊;前端按意图分流,模糊则发起澄清让用户点选,
不再用关键词硬路由(修「说重做报告却出流程图」)。
- Phase 2 问答修改:edit 模式(当前图+指令→改图→校验);Phase 3 画布手动改/删节点。
- 修:用户消息发送即上屏(不等模型/分类返回);regenerate_report 不再清空对话历史;
全屏 portal 到 body 工具栏恢复;解析兼容独立 edges 数组。
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../roundtable-planning/api/flow-extract.ts | 35 ++++
.../components/Step3SummaryMode.tsx | 36 ++++
.../hooks/useStep3Summary.ts | 103 +++++++----
.../app/gateway/routers/_roundtable_seed.py | 7 +-
.../roundtable-structure/SOUL.md | 55 ++++++
.../roundtable-structure/config.yaml | 3 +
.../app/gateway/routers/flow_extract.py | 169 +++++++++++++++---
7 files changed, 346 insertions(+), 62 deletions(-)
create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/SOUL.md
create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/config.yaml
diff --git a/frontend-web/src/roundtable-planning/api/flow-extract.ts b/frontend-web/src/roundtable-planning/api/flow-extract.ts
index ebf2188e..503832d3 100644
--- a/frontend-web/src/roundtable-planning/api/flow-extract.ts
+++ b/frontend-web/src/roundtable-planning/api/flow-extract.ts
@@ -11,6 +11,41 @@ import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import type { FlowGraphData } from "@/roundtable-planning/lib/flow-graph";
+/** 第三步输入框意图分类结果(与后端 /intent 对应)。 */
+export type FlowIntent = "regenerate_report" | "modify_flow" | "regenerate_flow" | "qa" | "unclear";
+
+/**
+ * 识别第三步输入框消息的意图(一次模型调用)。失败回退 "unclear"(前端发起澄清)。
+ * 参考 ai_writing 的一次性 JSON 意图识别范式。
+ */
+export async function classifyFlowIntent(req: {
+ message: string;
+ hasReport: boolean;
+ hasFlow: boolean;
+ model?: string;
+ signal?: AbortSignal;
+}): Promise<{ intent: FlowIntent; reason: string }> {
+ try {
+ const res = await apiFetch(`${getBackendBaseURL()}/api/flow-extract/intent`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ message: req.message,
+ has_report: req.hasReport,
+ has_flow: req.hasFlow,
+ model: req.model,
+ }),
+ signal: req.signal,
+ });
+ if (!res.ok) return { intent: "unclear", reason: "" };
+ const data = (await res.json()) as { intent?: string; reason?: string };
+ const intent = (data.intent ?? "unclear") as FlowIntent;
+ return { intent, reason: data.reason ?? "" };
+ } catch {
+ return { intent: "unclear", reason: "" };
+ }
+}
+
export interface FlowExtractRequest {
/** 报告 Markdown(优先数据来源)。 */
reportMd?: string | null;
diff --git a/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx b/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx
index 6b9e0725..e5c31fae 100644
--- a/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx
+++ b/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx
@@ -412,6 +412,8 @@ export function Step3SummaryMode({
mdAvailable={!!mdArtifact}
flowPhase={summary.flowPhase}
onRegenerateFlow={summary.regenerateFlow}
+ pendingClarification={summary.pendingFlowClarification}
+ onResolveClarification={summary.resolveFlowClarification}
onRetry={redraw}
onAsk={handleAsk}
onPause={pause}
@@ -556,6 +558,8 @@ function AgentProgressPanel({
mdAvailable,
flowPhase,
onRegenerateFlow,
+ pendingClarification,
+ onResolveClarification,
onRetry,
onAsk,
onPause,
@@ -576,6 +580,9 @@ function AgentProgressPanel({
flowPhase: UseStep3SummaryReturn["flowPhase"];
/** 仅重新生成流程图(模型挂了切模型后重试)。 */
onRegenerateFlow: () => void;
+ /** 意图模糊待澄清的原始消息(非空 → 展示选项让用户定)。 */
+ pendingClarification: string | null;
+ onResolveClarification: UseStep3SummaryReturn["resolveFlowClarification"];
onRetry: () => void;
onAsk: (message: string, files?: File[]) => void;
onPause: () => void;
@@ -748,6 +755,35 @@ function AgentProgressPanel({
)}
{!atBottom && }
+
+ {/* 意图模糊 → 让用户自己定(不自作主张) */}
+ {pendingClarification && (
+
+
+ 没太理解你的意思,你是想——
+
+
+ {(
+ [
+ { k: "regenerate_report", t: "重新生成报告" },
+ { k: "modify_flow", t: "修改流程图" },
+ { k: "regenerate_flow", t: "重新生成流程图" },
+ { k: "qa", t: "只是提问" },
+ ] as const
+ ).map((o) => (
+
+ ))}
+
+
+ )}
+
) : (
-
- {stageGroups.map((stage, si) => (
-
- {si > 0 && (
-
- )}
- {stage.length > 1 && (
-
- 并行 {stage.length}
-
- )}
-
- {stage.map((seat, sj) => {
+
+ {stageGroups.flatMap((stage, si) => {
+ // 横向编排:阶段从左到右排列,阶段之间用 → 串联;并行阶段的
+ // 席位在该列内竖向堆叠(宽度可控,符合「同层并行·层间串行」示意)。
+ const items: React.ReactNode[] = [];
+ if (si > 0) {
+ items.push(
+
,
+ );
+ }
+ items.push(
+
+ {stage.length > 1 && (
+
+ 并行 {stage.length}
+
+ )}
+
+ {stage.map((seat, sj) => {
const palette =
SEAT_CHIP_PALETTE[
seatColorIndex++ %
@@ -2551,10 +2560,12 @@ function RoundtableEntryRail({
);
- })}
-
-
- ))}
+ })}
+
+
,
+ );
+ return items;
+ })}
)}
diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md
index 827f4398..f0324c80 100644
--- a/offline-backend-20260512/backend/CLAUDE.md
+++ b/offline-backend-20260512/backend/CLAUDE.md
@@ -254,6 +254,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Light Apps** (`/api/light-apps`) | Light-app registry (????? / ????), **global/shared** (no user scoping; `created_by`/`updated_by` audit-only). Two kinds: `window_open` (launched in a new tab from the ???? card grid) and `iframe` (embedded under a parent sidebar menu via `mount_location` and dynamically injected into the sidebar). Both carry `route_params` ? a list of `{key, paramType: login\|theme, source, customValue, themeValue}` descriptors the **frontend** resolves at launch time (login params read from `localStorage.userInfo`; theme params use a literal value) and appends to `url` as query string. `GET /` - list (filters `app_type`/`mount_location`/`enabled_only`/`search`); `GET /{app_id}` - read one (both open to any authenticated user); `POST /`, `PUT /{app_id}`, `DELETE /{app_id}` - **admin only**; iframe apps must carry a `mount_location` (400 otherwise); the unique `name` collides as HTTP 409. Backed by `deerflow.persistence.light_apps` (table `light_apps`, auto-created by `create_all`); store wired in `deps.py` as `app.state.light_app_store`. Tests: `tests/test_light_apps.py`. |
| **Menu Overrides** (`/api/menu-overrides`) | Sidebar-menu overrides (菜单管理), **global/shared** (no user scoping; `updated_by` audit-only). The static menu tree (icons/routes/`adminOnly`) lives in the **frontend** code (`core/page-layout/sidebar-menu.ts`); iframe light-apps are injected at render time. This router persists a per-node **overlay** the frontend merges onto that tree: `parent_id` (re-parent, moves the whole subtree; NULL = top level), `sort_order` (reorder among siblings), `label` (rename; NULL keeps code label), `disabled` (停用 — hides the node **and its subtree**). A node with no row renders exactly as code defines it, so new code menus / newly-registered iframe apps appear automatically and are immediately configurable. `GET /` - list all overrides (open to any authenticated user); `PUT /` - **admin only**, atomically **replaces the whole set** (`{overrides: [...]}`); rejects re-parenting that would exceed **3 levels** or form a cycle (`_validate_depth`, HTTP 400). Backed by `deerflow.persistence.menu_overrides` (table `menu_overrides`, auto-created by `create_all`); store wired in `deps.py` as `app.state.menu_override_store`. Frontend: merge logic `core/page-layout/menu-overrides.ts` (`applyMenuOverrides` in `use-sidebar-menu.ts`), client `strategy-components/api/menu-overrides.ts`, admin page `pages/MenuManagementPage.tsx` (@dnd-kit drag-reorder/re-parent + 「移动到」parent selector + ↑↓ arrows + 改名/停用), entry in the bottom-left settings dropdown (`components/page-sidebar.tsx`, admin-only). Tests: `tests/test_menu_overrides.py`, `frontend-web/src/core/page-layout/menu-overrides.test.ts`. |
| **Sensitive Words** (`/api/sensitive-words`) | Sensitive-word / desensitize map (敏感词管理 / 脱敏词), **global/shared** (no user scoping; `updated_by` audit-only). Each row maps an original `word` → `replacement`, with `enabled` (停用 parks a word without deleting) + `sort_order`. The **frontend** ships a seed map in code (`src/lib/desensitize.ts` `SEED_DESENSITIZE_MAP`) used as the default on a fresh deployment; once an admin saves, this table is the source of truth and the frontend loads it at startup (`DesensitizeWordsLoader` → `replaceDesensitizeMap`, **enabled** rows only). `GET /` - list all words (open to any authenticated user — every page desensitizes); `PUT /` - **admin OR the `lqq` account** (`_require_manager`: `system_role == "admin"` or email `@`-prefix `== "lqq"`), atomically **replaces the whole set** (`{words: [...]}`, de-duped by `word`, last wins). Backed by `deerflow.persistence.sensitive_words` (table `sensitive_words`, PK `word` `String(191)`, auto-created by `create_all`); store wired in `deps.py` as `app.state.sensitive_word_store`. Frontend: client `strategy-components/api/sensitive-words.ts`, page `pages/SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, entry in the bottom-left settings dropdown — **gated on username `lqq`, not admin role**, present in both sidebar modes; 「导入种子词」 merges missing seed entries). Tests: `tests/test_sensitive_words.py`. |
+| **Dashboard Sessions** (`/api/dashboard-sessions`) | 「大屏绘制智能体」独立页面会话,**按外部 `task_id` 存取,不按 user 分权**(open read + shared writes;`user_id` audit-only)。一个 task 一行(upsert):`transcript`(聊天记录 JSON)+ `report_json`(最新结构化数据,可解析 = 「有效结果」)。`GET /by-task/{task_id}` - 取该 task 会话(无则 404);`PUT /by-task/{task_id}` - upsert(`{transcript?, report_json?}`,只覆盖传入项)。Backed by `deerflow.persistence.dashboard_sessions`(table `dashboard_sessions`, auto-created by `create_all`,无迁移);store wired in `deps.py` as `app.state.dashboard_session_store`。前端独立页 `roundtable-planning/pages/DashboardAgentPage.tsx`(路由 `/page/strategy/dashboard-agent?taskId=`,侧边栏「问答管理 → 大屏绘制」)让智能体分析登录拉取的业务 JSON → report-json → 渲染 `DashboardApp`;输入 seam `core/auth/dashboard-input.ts`(JSON 接口未就绪时回退 copTaskDetail 占位)。Tests: `tests/test_dashboard_sessions.py`。 |
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Defaults to `background=true`**: the slow per-thread extraction loop is deferred to a FastAPI background task and the endpoint returns `{queued:true, processed:N}` immediately (each thread's transcript is truncated to `max_input_chars` before the LLM call). Running the loop inline for a large batch outlives the nginx gateway timeout (504), which the frontend's `parseResponse` surfaces as a generic "Request failed"; `background=false` keeps the blocking path (full per-thread `created/skipped/failed` tally) for small batches/scripts. Shared loop: `_run_batch_ingest`. **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` ? `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "???????". The "??????" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector?keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[?]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). `GET/POST /folders` + `PUT/DELETE /folders/{id}` - user-managed directories (notes carry a `folder` path; `GET /notes?folder=` filters a dir + sub-dirs); `GET/POST /extract-templates` + `GET/PUT/DELETE /extract-templates/{id}` - editable extraction-prompt templates (thread/document ingest accept `template_id`; capture/PUT-note accept `folder`). Backed by the `deerflow.knowledge` module (see below). |
Proxied through nginx: `/api/langgraph/*` ? LangGraph, all other `/api/*` ? Gateway.
diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py
index 8af923c8..fa070f6a 100644
--- a/offline-backend-20260512/backend/app/gateway/app.py
+++ b/offline-backend-20260512/backend/app/gateway/app.py
@@ -37,6 +37,7 @@ from app.gateway.routers import (
channels,
checkpoint_migration,
feedback,
+ dashboard_sessions,
html_page_favorites,
intent,
knowledge,
@@ -1352,6 +1353,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Sensitive-word map (敏感词管理 / 脱敏词) at /api/sensitive-words
app.include_router(sensitive_words.router)
+ # 大屏绘制智能体独立页面会话(按 taskId 存取,不分权)at /api/dashboard-sessions
+ app.include_router(dashboard_sessions.router)
+
# Standalone parallel multi-agent panel: fan-out streaming + saved tasks
app.include_router(parallel_agents.router)
app.include_router(parallel_agent_tasks.router)
diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py
index babc3932..81c89156 100644
--- a/offline-backend-20260512/backend/app/gateway/deps.py
+++ b/offline-backend-20260512/backend/app/gateway/deps.py
@@ -247,6 +247,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.sensitive_word_store = make_sensitive_word_store(sf)
+ from deerflow.persistence.dashboard_sessions import make_dashboard_session_store
+
+ app.state.dashboard_session_store = make_dashboard_session_store(sf)
+
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
diff --git a/offline-backend-20260512/backend/app/gateway/routers/dashboard_sessions.py b/offline-backend-20260512/backend/app/gateway/routers/dashboard_sessions.py
new file mode 100644
index 00000000..a5276e7c
--- /dev/null
+++ b/offline-backend-20260512/backend/app/gateway/routers/dashboard_sessions.py
@@ -0,0 +1,84 @@
+"""按 taskId 存取的「大屏绘制智能体」独立页面会话存储 API。
+
+独立大屏页(前端 ``DashboardAgentPage``)按外部 ``task_id`` 存取:聊天记录
+(``transcript``)+ 最新结构化数据(``report_json``)。**按 task 共享、不按 user
+分权**(open read + 写放开),镜像 ``roundtable_drafts`` 的 by-task 语义。
+
+Routes (prefix ``/api/dashboard-sessions``):
+ GET /by-task/{task_id} 取该 task 的会话(无则 404)
+ PUT /by-task/{task_id} upsert(body: {transcript?, report_json?})
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter, HTTPException, Request
+from pydantic import BaseModel
+
+from deerflow.runtime.user_context import get_effective_user_id
+
+router = APIRouter(prefix="/api/dashboard-sessions", tags=["dashboard-sessions"])
+
+
+class DashboardSessionResponse(BaseModel):
+ task_id: str
+ transcript: Any | None = None
+ report_json: str | None = None
+ created_at: str | None = None
+ updated_at: str | None = None
+
+
+class DashboardSessionUpsertRequest(BaseModel):
+ # 只传需要更新的字段:聊天记录 / 结构化数据,二者皆可单独更新。
+ transcript: Any | None = None
+ report_json: str | None = None
+
+
+def _current_user_id(request: Request) -> str:
+ user = getattr(request.state, "user", None)
+ if user is not None:
+ return str(user.id)
+ return get_effective_user_id()
+
+
+def _get_store(request: Request):
+ store = getattr(request.app.state, "dashboard_session_store", None)
+ if store is None:
+ raise HTTPException(status_code=503, detail="Dashboard session store not available")
+ return store
+
+
+def _project(row: dict[str, Any]) -> DashboardSessionResponse:
+ return DashboardSessionResponse(
+ task_id=row.get("task_id", ""),
+ transcript=row.get("transcript"),
+ report_json=row.get("report_json"),
+ created_at=row.get("created_at"),
+ updated_at=row.get("updated_at"),
+ )
+
+
+@router.get("/by-task/{task_id}", response_model=DashboardSessionResponse)
+async def get_dashboard_session(request: Request, task_id: str) -> DashboardSessionResponse:
+ """取该 task 的大屏会话(**不按 user 分权**);无则 404。"""
+ store = _get_store(request)
+ row = await store.get_by_task(task_id)
+ if row is None:
+ raise HTTPException(status_code=404, detail="No dashboard session for this task")
+ return _project(row)
+
+
+@router.put("/by-task/{task_id}", response_model=DashboardSessionResponse)
+async def upsert_dashboard_session(
+ request: Request, task_id: str, body: DashboardSessionUpsertRequest
+) -> DashboardSessionResponse:
+ """按 task upsert 大屏会话(**不按 user 分权**),返回最新快照。"""
+ store = _get_store(request)
+ row = await store.upsert_by_task(
+ task_id,
+ transcript=body.transcript,
+ report_json=body.report_json,
+ user_id=_current_user_id(request),
+ )
+ return _project(row)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/dashboard_sessions/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/dashboard_sessions/__init__.py
new file mode 100644
index 00000000..4ea30510
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/dashboard_sessions/__init__.py
@@ -0,0 +1,18 @@
+"""大屏绘制智能体 session persistence (按 taskId 存取,不分权)."""
+
+from __future__ import annotations
+
+from deerflow.persistence.dashboard_sessions.model import DashboardSessionRow
+from deerflow.persistence.dashboard_sessions.sql import DashboardSessionRepository
+
+__all__ = [
+ "DashboardSessionRepository",
+ "DashboardSessionRow",
+ "make_dashboard_session_store",
+]
+
+
+def make_dashboard_session_store(session_factory):
+ if session_factory is None:
+ return None
+ return DashboardSessionRepository(session_factory)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/dashboard_sessions/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/dashboard_sessions/model.py
new file mode 100644
index 00000000..0a1560aa
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/dashboard_sessions/model.py
@@ -0,0 +1,49 @@
+"""ORM model for the standalone 大屏绘制智能体 session (按 taskId 存取).
+
+One row per external ``task_id`` — the standalone dashboard page persists its
+chat transcript + the latest structured ``report_json`` so that returning to the
+same task either shows the finished dashboard directly (valid report_json) or
+restores the generation process.
+
+**Global / shared by task** — like ``roundtable_drafts`` task-bound drafts, the
+store keys on ``task_id`` and does **not** scope by ``user_id`` (open read +
+shared writes). ``user_id`` is recorded for audit only.
+
+``transcript`` / ``report_json`` are large JSON blobs (chat history + report
+data) stored as serialized strings in ``PortableLongText`` (MySQL ``LONGTEXT``)
+to dodge MySQL's 64 KB ``TEXT`` cap — same approach as ``roundtable_drafts``.
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+from sqlalchemy import String, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column
+
+from deerflow.persistence.base import Base
+from deerflow.persistence.types import BeijingDateTime, PortableLongText
+
+
+class DashboardSessionRow(Base):
+ __tablename__ = "dashboard_sessions"
+ # One session per task — keep it unique so upsert-by-task is unambiguous.
+ __table_args__ = (UniqueConstraint("task_id", name="uq_dashboard_sessions_task_id"),)
+
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
+ # External task id (deep-link). The session is shared by task, not by user.
+ task_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
+ # Audit only — who last wrote. Never used for access control.
+ user_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ # Chat transcript JSON (the dashboard agent's dialogue bubbles).
+ transcript: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
+ # Latest structured dashboard data (report-json string). Presence of a
+ # parseable value = "有效结果" → the page shows the dashboard directly.
+ report_json: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
+ updated_at: Mapped[datetime] = mapped_column(
+ BeijingDateTime(),
+ nullable=False,
+ default=lambda: datetime.now(UTC),
+ onupdate=lambda: datetime.now(UTC),
+ )
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/dashboard_sessions/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/dashboard_sessions/sql.py
new file mode 100644
index 00000000..e3aa93ec
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/dashboard_sessions/sql.py
@@ -0,0 +1,104 @@
+"""SQLAlchemy-backed 大屏绘制智能体 session store (按 taskId,不分权).
+
+``transcript`` / ``report_json`` are JSON serialized into ``PortableLongText``
+and round-tripped here with ``json.dumps`` / ``json.loads`` — same approach as
+``roundtable_drafts``. All access keys on ``task_id`` and ignores ``user_id``
+(open read + shared writes); ``user_id`` is recorded for audit only.
+"""
+
+from __future__ import annotations
+
+import json
+from datetime import UTC, datetime
+from typing import Any
+from uuid import uuid4
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from deerflow.persistence.dashboard_sessions.model import DashboardSessionRow
+
+
+def _loads(value: Any) -> Any:
+ if not isinstance(value, str):
+ return value
+ if not value:
+ return None
+ try:
+ return json.loads(value)
+ except Exception:
+ return None
+
+
+def _dumps(value: Any) -> str | None:
+ if value is None:
+ return None
+ if isinstance(value, str):
+ return value # already a JSON string (report_json) — store verbatim
+ return json.dumps(value, ensure_ascii=False)
+
+
+class DashboardSessionRepository:
+ def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
+ self._sf = session_factory
+
+ @staticmethod
+ def _to_dict(row: DashboardSessionRow) -> dict[str, Any]:
+ return {
+ "id": row.id,
+ "task_id": row.task_id,
+ "user_id": row.user_id,
+ "transcript": _loads(row.transcript),
+ # report_json is a structured-data JSON string; return it as a string
+ # so the frontend parses it with the same robust parser it uses live.
+ "report_json": row.report_json,
+ "created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
+ "updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
+ }
+
+ async def get_by_task(self, task_id: str) -> dict[str, Any] | None:
+ """取该 task 的会话(**忽略 user_id**,不分权)。无则 None。"""
+ stmt = select(DashboardSessionRow).where(DashboardSessionRow.task_id == task_id).limit(1)
+ async with self._sf() as session:
+ result = await session.execute(stmt)
+ row = result.scalars().first()
+ return self._to_dict(row) if row is not None else None
+
+ async def upsert_by_task(
+ self,
+ task_id: str,
+ *,
+ transcript: Any = None,
+ report_json: Any = None,
+ user_id: str | None = None,
+ ) -> dict[str, Any]:
+ """按 task_id upsert:无则建、有则更新(**不分权**)。
+
+ ``transcript`` / ``report_json`` 仅在传入非 None 时覆盖,便于只更新其中一项。
+ """
+ now = datetime.now(UTC)
+ async with self._sf() as session:
+ stmt = select(DashboardSessionRow).where(DashboardSessionRow.task_id == task_id).limit(1)
+ row = (await session.execute(stmt)).scalars().first()
+ if row is None:
+ row = DashboardSessionRow(
+ id=uuid4().hex,
+ task_id=task_id,
+ user_id=user_id,
+ transcript=_dumps(transcript),
+ report_json=_dumps(report_json),
+ created_at=now,
+ updated_at=now,
+ )
+ session.add(row)
+ else:
+ if transcript is not None:
+ row.transcript = _dumps(transcript)
+ if report_json is not None:
+ row.report_json = _dumps(report_json)
+ if user_id is not None:
+ row.user_id = user_id
+ row.updated_at = now
+ await session.commit()
+ await session.refresh(row)
+ return self._to_dict(row)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py
index 849fcb4b..9fac5205 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py
@@ -23,6 +23,7 @@ from deerflow.persistence.agents.model import (
)
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
from deerflow.persistence.business_mapping.model import BusinessMappingRow
+from deerflow.persistence.dashboard_sessions.model import DashboardSessionRow
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.knowledge.models import (
@@ -81,6 +82,7 @@ __all__ = [
"AgentSkillRow",
"AdminLeaderboardDailyStatRow",
"BusinessMappingRow",
+ "DashboardSessionRow",
"KnowledgeEmbeddingRow",
"KnowledgeEntityRow",
"KnowledgeExtractTemplateRow",