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
diff --git a/frontend-web/src/core/auth/dashboard-input.ts b/frontend-web/src/core/auth/dashboard-input.ts
new file mode 100644
index 00000000..c04e59de
--- /dev/null
+++ b/frontend-web/src/core/auth/dashboard-input.ts
@@ -0,0 +1,155 @@
+/**
+ * 大屏绘制智能体「独立页面」的**输入 JSON** 适配 seam。
+ *
+ * 设计意图:外部系统登录时**后续会**提供一个接口返回一份业务 JSON,本页让
+ * 智能体分析这份 JSON → 产出 report-json → 绘制大屏。
+ *
+ * ⚠️ 该接口现在还没开发好。本模块先做**占位适配**让页面尽快可用:
+ * - 配了 `VITE_DASHBOARD_INPUT_URL` → 按 taskId 拉取真实 JSON;
+ * - 没配 → 回退用登录深链already 暂存的任务详情(`login.copTaskDetail`)拼一份占位输入,
+ * 让智能体至少能基于任务名/内容产出一版可看的大屏。
+ *
+ * 接口就绪后:把 `fetchDashboardInputJson` 换成真实拉取;再按规划迁到「后台转换格式
+ * + 智能体校验」(校验仍由智能体做,不在后台硬抽)。
+ */
+
+import { requestCrudService } from "@/service/request-axios-service";
+import { readCopTaskDetail } from "@/core/auth/task-deeplink";
+
+/** 本页输入 JSON 暂存的 sessionStorage key(拉取一次后复用,避免重复请求)。 */
+export const DASHBOARD_INPUT_STORAGE_KEY = "dashboard.inputJson";
+
+/**
+ * 🧪 演示用模拟业务 JSON —— JSON 接口未就绪、也没有登录任务详情时的兜底输入。
+ * 让大屏页可立即跑通:智能体分析这份数据 → report-json → 渲染大屏。
+ * ⚠️ 接口就绪后删除本常量及其兜底引用。
+ */
+export const DEMO_DASHBOARD_INPUT = {
+ taskId: "DEMO-涉台舆情",
+ taskName: "2026 第一季度涉台舆情态势研判",
+ taskContent:
+ "汇总境内外多平台涉台舆情数据,研判热点叙事走势、关键行为体立场与传播渠道分布,识别风险并给出处置建议。",
+ overview:
+ "本季涉台舆情整体平稳但局部波动,'两岸交流''和平发展'类正向叙事占比上升,少数账号借军事议题制造紧张;境外智库与媒体联动放大特定框架。",
+ period: "2026-01-01 ~ 2026-03-31",
+ kpis: {
+ 总信息量: 1284000,
+ 日均增速: "12%",
+ 正向占比: "58%",
+ 负向占比: "19%",
+ 识别风险点: 7,
+ 覆盖平台: 12,
+ },
+ channels: [
+ { platform: "微博", share: 34 },
+ { platform: "境外社媒(X/FB)", share: 27 },
+ { platform: "短视频", share: 18 },
+ { platform: "新闻门户", share: 13 },
+ { platform: "论坛/贴吧", share: 8 },
+ ],
+ narratives: [
+ { theme: "两岸交流与融合发展", sentiment: "正向", volume: 42, trend: "up" },
+ { theme: "和平稳定·避战", sentiment: "正向", volume: 26, trend: "up" },
+ { theme: "军事演训与安全", sentiment: "中性偏负", volume: 21, trend: "flat" },
+ { theme: "外部势力介入", sentiment: "负向", volume: 11, trend: "down" },
+ ],
+ actors: [
+ { name: "主流官媒", stance: "正面引导", influence: 88, note: "议程设置与权威发布" },
+ { name: "岛内民间团体", stance: "中性多元", influence: 64, note: "交流合作类内容活跃" },
+ { name: "境外智库", stance: "负面框架", influence: 57, note: "联动媒体放大紧张叙事" },
+ { name: "自媒体大V", stance: "立场分化", influence: 49, note: "情绪化传播、易被借势" },
+ ],
+ risks: [
+ { label: "军事议题被恶意剪辑放大", level: "high" },
+ { label: "境外智库联动制造紧张框架", level: "high" },
+ { label: "个别大V情绪化带节奏", level: "mid" },
+ { label: "短视频谣言二次传播", level: "mid" },
+ { label: "节假日期间信息量骤增", level: "low" },
+ ],
+ timeline: [
+ { phase: "数据汇聚", desc: "12 平台多源采集与清洗去重" },
+ { phase: "叙事识别", desc: "聚类提炼核心叙事与情感倾向" },
+ { phase: "行为体画像", desc: "关键账号立场与影响力建模" },
+ { phase: "风险研判", desc: "识别高危议题与传播链路" },
+ { phase: "处置建议", desc: "分级响应与正向引导预案" },
+ ],
+ weeklyVolume: [
+ { week: "W1", value: 86 },
+ { week: "W2", value: 92 },
+ { week: "W3", value: 110 },
+ { week: "W4", value: 134 },
+ { week: "W5", value: 121 },
+ { week: "W6", value: 158 },
+ ],
+};
+
+function inputBaseUrl(): string {
+ // TODO: 接口就绪后确认环境变量名/路径。占位:未配则走 copTaskDetail 回退。
+ const value = (import.meta.env as Record).VITE_DASHBOARD_INPUT_URL;
+ return typeof value === "string" ? value.replace(/\/+$/, "") : "";
+}
+
+/** 读取暂存的输入 JSON。 */
+export function readDashboardInputJson(): unknown | null {
+ try {
+ const raw = sessionStorage.getItem(DASHBOARD_INPUT_STORAGE_KEY);
+ return raw ? JSON.parse(raw) : null;
+ } catch {
+ return null;
+ }
+}
+
+/** 暂存输入 JSON(隐私模式/quota 异常静默失败)。 */
+export function persistDashboardInputJson(data: unknown): void {
+ try {
+ sessionStorage.setItem(DASHBOARD_INPUT_STORAGE_KEY, JSON.stringify(data));
+ } catch {
+ /* noop */
+ }
+}
+
+/** 用登录暂存的任务详情拼一份占位输入(接口未就绪时的回退)。 */
+function placeholderFromCopTask(taskId: string): unknown | null {
+ const detail = readCopTaskDetail();
+ if (!detail) return taskId ? { taskId } : null;
+ return {
+ taskId,
+ taskName: detail.taskName,
+ taskContent: detail.taskContent,
+ overview: detail.overview,
+ taskDirection: detail.taskDirection,
+ _note: "占位输入(真实业务 JSON 接口未就绪)",
+ };
+}
+
+/**
+ * 取本页的输入 JSON:优先暂存 → 真实接口(配了地址)→ copTaskDetail 占位。
+ * 任一步失败都静默退化,最终可能返回 null(调用方据此提示用户/不自动开跑)。
+ */
+export async function fetchDashboardInputJson(taskId: string): Promise {
+ const cached = readDashboardInputJson();
+ if (cached) return cached;
+
+ const base = inputBaseUrl();
+ if (base && taskId) {
+ try {
+ // service 拦截器统一注入鉴权并返回 response.data(整包 body)。
+ const body = await requestCrudService.get(`${base}`, { taskId });
+ const data = body && typeof body === "object" ? (body as { data?: unknown }).data ?? body : body;
+ if (data) {
+ persistDashboardInputJson(data);
+ return data;
+ }
+ } catch {
+ /* 拉取失败 → 落占位 */
+ }
+ }
+
+ const placeholder = placeholderFromCopTask(taskId);
+ // 占位里有真实任务内容(来自登录详情)才用它;否则回退演示数据,保证页面能跑通看效果。
+ // ⚠️ 接口就绪后删除 DEMO 兜底。
+ const detail = readCopTaskDetail();
+ const resolved = detail ? placeholder : { ...DEMO_DASHBOARD_INPUT, taskId: taskId || DEMO_DASHBOARD_INPUT.taskId };
+ if (resolved) persistDashboardInputJson(resolved);
+ return resolved;
+}
diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts
index 3c953561..247bdb07 100644
--- a/frontend-web/src/core/page-layout/sidebar-menu.ts
+++ b/frontend-web/src/core/page-layout/sidebar-menu.ts
@@ -65,6 +65,12 @@ export const sidebarMenuItems: MenuItem[] = [
icon: Users,
path: "/page/strategy/qa/multi-agent-roundtable",
},
+ {
+ id: "dashboard-agent",
+ label: "大屏绘制",
+ icon: Gauge,
+ path: "/page/strategy/dashboard-agent",
+ },
// { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
{
id: "qa-zzjc",
diff --git a/frontend-web/src/core/threads/thread-access-error.ts b/frontend-web/src/core/threads/thread-access-error.ts
new file mode 100644
index 00000000..dd803081
--- /dev/null
+++ b/frontend-web/src/core/threads/thread-access-error.ts
@@ -0,0 +1,98 @@
+import { toast } from "sonner";
+
+import { getStoredAuth } from "@/core/auth";
+
+/**
+ * Shared helpers for re-framing LangGraph "thread not accessible" 404s around
+ * the current login identity.
+ *
+ * The most common cause in practice is a login session change: a thread is
+ * owned by the previously logged-in account, so after switching accounts the
+ * backend (``app/gateway/authz.py``) correctly returns
+ * ``HTTP 404: {"detail":"Thread not accessible"}``. Raw-toasting that
+ * 404 just leaves the user staring at a scary error. Both the chat stream
+ * (``core/threads/hooks.ts``) and the AI-writing stream
+ * (``open-canvas/contexts/AIWritingContext.tsx``) funnel their stream errors
+ * through here so the UX is identical: detect the login-change 404 and prompt
+ * the user to refresh against the current identity instead of erroring out.
+ */
+
+/** Normalize an unknown stream error into a human-readable message string. */
+export function getStreamErrorMessage(error: unknown): string {
+ if (typeof error === "string" && error.trim()) {
+ return error;
+ }
+ if (error instanceof Error && error.message.trim()) {
+ return error.message;
+ }
+ if (typeof error === "object" && error !== null) {
+ const message = Reflect.get(error, "message");
+ if (typeof message === "string" && message.trim()) {
+ return message;
+ }
+ const nestedError = Reflect.get(error, "error");
+ if (nestedError instanceof Error && nestedError.message.trim()) {
+ return nestedError.message;
+ }
+ if (typeof nestedError === "string" && nestedError.trim()) {
+ return nestedError;
+ }
+ }
+ return "Request failed.";
+}
+
+/**
+ * Detect "thread not found" / forbidden errors from the LangGraph SDK.
+ *
+ * Backend returns 404 both when a thread truly doesn't exist and when the
+ * currently authenticated user doesn't own it ("not found" = no row + no
+ * checkpoint, "not accessible" = row owned by another user / stale login
+ * session). Both mean the current user can't use this thread.
+ */
+export function isThreadNotAccessibleError(message: string): boolean {
+ const lowered = message.toLowerCase();
+ if (/thread\s+[\w-]{8,}\s+not\s+(found|accessible)/i.test(message)) {
+ return true;
+ }
+ // Fallback: a bare 404/403 from the threads endpoint when the SDK has
+ // wrapped the detail differently.
+ return (
+ (lowered.includes("http 404") || lowered.includes("http 403")) &&
+ lowered.includes("thread")
+ );
+}
+
+/** Build a label for the *current* logged-in identity (email → user_id → 未知). */
+export function buildCurrentUserLabel(): string {
+ const auth = getStoredAuth();
+ if (!auth) {
+ return "未登录";
+ }
+ const email = auth.email?.trim();
+ if (email) {
+ return email;
+ }
+ const userId = auth.user_id?.trim();
+ return userId ? userId : "未知用户";
+}
+
+/**
+ * Re-frame a login-change 404 around the current identity and reload the page.
+ *
+ * Shows a friendly toast naming the current login, then full-reloads after a
+ * short delay so the toast is visible and so TanStack Query cache / context
+ * providers / any state captured under the previous user get rebuilt against
+ * the current login. Used by streams whose session id lives in component state
+ * (not the URL) — a plain reload lands them back on a fresh, valid page.
+ */
+export function notifyThreadAccessChangedAndReload(): void {
+ const userLabel = buildCurrentUserLabel();
+ toast.info(
+ `当前登录用户为 ${userLabel},原会话不存在或无权访问,正在为你刷新页面…`,
+ );
+ if (typeof window !== "undefined") {
+ window.setTimeout(() => {
+ window.location.reload();
+ }, 900);
+ }
+}
diff --git a/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx b/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx
index 11c0ffcb..31c8cdd4 100644
--- a/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx
+++ b/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx
@@ -15,6 +15,11 @@ import {
import { useStream } from '@langchain/langgraph-sdk/react';
import { getAPIClient } from '@/core/api';
+import {
+ getStreamErrorMessage,
+ isThreadNotAccessibleError,
+ notifyThreadAccessChangedAndReload,
+} from '@/core/threads/thread-access-error';
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
@@ -186,10 +191,18 @@ const AIWritingStreamHost = memo(function AIWritingStreamHost({
},
onError(err: unknown) {
eventBatcherRef.current?.cancel();
- dispatchRef.current?.({
- type: 'set_error',
- message: err instanceof Error ? err.message : String(err),
- });
+ const message = getStreamErrorMessage(err);
+ // A "thread not accessible/not found" 404 almost always means the writing
+ // thread belongs to a previous login session — the user switched accounts
+ // (or this tab outlived a re-login), so the current account legitimately
+ // can't access the old thread. Instead of dumping the raw HTTP 404 into
+ // the panel, re-frame it around the current identity and reload — same UX
+ // the chat stream gives (see core/threads/hooks.ts).
+ if (isThreadNotAccessibleError(message)) {
+ notifyThreadAccessChangedAndReload();
+ return;
+ }
+ dispatchRef.current?.({ type: 'set_error', message });
},
onFinish(finishState) {
eventBatcherRef.current?.cancel();
@@ -291,7 +304,12 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
},
);
} catch (err) {
- dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
+ const message = getStreamErrorMessage(err);
+ if (isThreadNotAccessibleError(message)) {
+ notifyThreadAccessChangedAndReload();
+ return;
+ }
+ dispatch({ type: 'set_error', message });
}
}, [state.status]);
@@ -372,7 +390,12 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
streamMode: ['values', 'updates', 'custom', 'messages-tuple'],
});
} catch (err) {
- dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) });
+ const message = getStreamErrorMessage(err);
+ if (isThreadNotAccessibleError(message)) {
+ notifyThreadAccessChangedAndReload();
+ return;
+ }
+ dispatch({ type: 'set_error', message });
}
}, [state.sessionId, state.currentPause, state.materials, state.outline,
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult,
diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx
index 27816fa0..306cc25a 100644
--- a/frontend-web/src/pages/StrategyRoutes.tsx
+++ b/frontend-web/src/pages/StrategyRoutes.tsx
@@ -20,6 +20,7 @@ import { ActionAnalyzes } from "@/strategy-components/components/task-components
import KnowledgeBaseResourceManagement from "@/strategy-components/components/resource-management/KnowledgeBaseResourceManagement";
import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
+import DashboardAgentPage from "@/roundtable-planning/pages/DashboardAgentPage";
import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage";
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
import "@/roundtable-planning/styles/roundtable-planning.css";
@@ -99,6 +100,16 @@ export default function StrategyRoutes() {
}
/>
+ {/* 大屏绘制智能体独立页面(按 taskId 存取;ChatRuntime 提供 ArtifactsProvider 给沙箱预览) */}
+
+
+
+ }
+ />
+
{/* 需求 0616 #3/#4:zzJC / zzXD 「按类型聚合」模块页(重做,独立路由)。 */}
} />
} />
diff --git a/frontend-web/src/roundtable-planning/api/dashboard-sessions.ts b/frontend-web/src/roundtable-planning/api/dashboard-sessions.ts
new file mode 100644
index 00000000..d7ddd688
--- /dev/null
+++ b/frontend-web/src/roundtable-planning/api/dashboard-sessions.ts
@@ -0,0 +1,68 @@
+/**
+ * 大屏绘制智能体「独立页面」会话持久化 —— 按外部 taskId 存取(后端不按 user 分权)。
+ *
+ * 一个 taskId 一条会话:聊天记录(transcript)+ 最新结构化数据(reportJson)。
+ * 后端 `/api/dashboard-sessions/by-task/{taskId}`(GET 取 / PUT upsert)。
+ * 详见后端 `app/gateway/routers/dashboard_sessions.py`。
+ */
+import { apiFetch } from "@/core/api/fetch-client";
+import { getBackendBaseURL } from "@/core/config";
+
+export interface DashboardSession {
+ taskId: string;
+ /** 聊天记录(大屏绘制智能体对话气泡数组;形状与 Step2Dialogue 一致,宽松存取)。 */
+ transcript: unknown[] | null;
+ /** 最新结构化数据(report-json 字符串);可被 parseReportJson 解析则视为「有效结果」。 */
+ reportJson: string | null;
+ updatedAt: string | null;
+}
+
+interface ServerDashboardSession {
+ task_id: string;
+ transcript: unknown[] | null;
+ report_json: string | null;
+ created_at?: string | null;
+ updated_at?: string | null;
+}
+
+const BASE = () => `${getBackendBaseURL()}/api/dashboard-sessions`;
+
+function toClient(s: ServerDashboardSession): DashboardSession {
+ return {
+ taskId: s.task_id,
+ transcript: Array.isArray(s.transcript) ? s.transcript : null,
+ reportJson: s.report_json ?? null,
+ updatedAt: s.updated_at ?? null,
+ };
+}
+
+/** 取该 taskId 的会话;无(404)返回 null。 */
+export async function getDashboardSessionByTask(taskId: string): Promise {
+ const res = await apiFetch(`${BASE()}/by-task/${encodeURIComponent(taskId)}`, { method: "GET" });
+ if (res.status === 404) return null;
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ throw new Error(`get dashboard session failed (${res.status}): ${text}`);
+ }
+ return toClient((await res.json()) as ServerDashboardSession);
+}
+
+/** upsert 该 taskId 的会话(只传需要更新的字段)。 */
+export async function putDashboardSessionByTask(
+ taskId: string,
+ patch: { transcript?: unknown[]; reportJson?: string | null },
+): Promise {
+ const body: Record = {};
+ if (patch.transcript !== undefined) body.transcript = patch.transcript;
+ if (patch.reportJson !== undefined) body.report_json = patch.reportJson;
+ const res = await apiFetch(`${BASE()}/by-task/${encodeURIComponent(taskId)}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ throw new Error(`put dashboard session failed (${res.status}): ${text}`);
+ }
+ return toClient((await res.json()) as ServerDashboardSession);
+}
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..503832d3
--- /dev/null
+++ b/frontend-web/src/roundtable-planning/api/flow-extract.ts
@@ -0,0 +1,141 @@
+/**
+ * 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";
+
+/** 第三步输入框意图分类结果(与后端 /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;
+ /** 各席位交付(无报告时的数据来源)。 */
+ 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/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/components/DashboardChatPanel.tsx b/frontend-web/src/roundtable-planning/components/DashboardChatPanel.tsx
new file mode 100644
index 00000000..b590c90e
--- /dev/null
+++ b/frontend-web/src/roundtable-planning/components/DashboardChatPanel.tsx
@@ -0,0 +1,250 @@
+/**
+ * 大屏绘制智能体「交互区」聊天面板(沙箱左 60%;右 40% 是抽取过程代码预览)。
+ *
+ * 从 `Step3DashboardMode` 抽出共享:圆桌 Step3「结果绘制·大屏」与独立大屏页
+ * (`DashboardAgentPage`)都复用同一套聊天 UI(流式气泡 + 模型选择 + 暂停/重画 +
+ * 修改输入框)。消息里不展示 report-json 本体(已在右侧沙箱实时渲染),用
+ * `stripReportFence` 替换成一句提示。
+ */
+import { useMemo, useRef, useState, type FormEvent } from "react";
+import {
+ CheckCircle,
+ Loader2,
+ Pause,
+ RefreshCw,
+ SendHorizontal,
+} from "lucide-react";
+
+import { useLocalSettings } from "@/core/settings";
+import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
+import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
+import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
+import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
+import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
+import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
+import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard";
+import { stripReportFence } from "@/roundtable-planning/lib/report-template";
+
+export interface DashboardChatPanelProps {
+ phase: UseStep3DashboardReturn["phase"];
+ dialogues: UseStep3DashboardReturn["dialogues"];
+ busy: boolean;
+ error: string | null;
+ onAsk: (message: string) => void;
+ onRedraw: () => void;
+ onPause: () => void;
+ onOpenPreview: () => void;
+ hasResult: boolean;
+ selectedModel: string;
+ availableModels: ReadonlyArray;
+ modelsLoading: boolean;
+ setSelectedModel: (model: string) => void;
+ /** 面板副标题(默认对齐圆桌 Step3;独立大屏页可自定义说明)。 */
+ subtitle?: string;
+ /** 完成提示语(默认「已切换到大屏页面」;独立页不自动跳,可改文案)。 */
+ doneHint?: string;
+}
+
+export function DashboardChatPanel({
+ phase,
+ dialogues,
+ busy,
+ error,
+ onAsk,
+ onRedraw,
+ onPause,
+ onOpenPreview,
+ hasResult,
+ selectedModel,
+ availableModels,
+ modelsLoading,
+ setSelectedModel,
+ subtitle = "提炼研讨成果为大屏结构化数据(右侧实时显示抽取过程)",
+ doneHint = "数据已就绪,已切换到大屏页面。",
+}: DashboardChatPanelProps) {
+ const inputLocked = phase === "creating";
+ const scrollRef = useRef(null);
+ const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
+ const [localSettings] = useLocalSettings();
+ const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
+ const dialogueMetrics = useDialogueSpeedMetrics(dialogues);
+ const [text, setText] = useState("");
+
+ // 消息列表里不展示 report-json 结构化数据本体(它已在右侧沙箱实时渲染),
+ // 把数据块替换成一句「看右侧沙箱」的提示,避免左右两边重复刷整段 JSON。
+ const displayDialogues = useMemo(
+ () =>
+ dialogues.map((d) => {
+ const content = d.content ?? "";
+ if (!content) return d;
+ const stripped = stripReportFence(content, d.streaming === true);
+ return stripped === content ? d : { ...d, content: stripped };
+ }),
+ [dialogues],
+ );
+
+ const handleSubmit = (e: FormEvent) => {
+ e.preventDefault();
+ const t = text.trim();
+ if (!t || inputLocked) return;
+ setText("");
+ onAsk(t);
+ };
+
+ return (
+
+ {/* 面板头:智能体名 + 模型选择 + 暂停/重画 */}
+
+
+
+
+ {busy ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* 聊天记录 */}
+
+
+ {phase === "creating" && dialogues.length === 0 && (
+
+
+ 正在初始化绘制线程…
+
+ )}
+
+ {displayDialogues.map((msg) => (
+
+ ))}
+
+ {busy && (
+
+
+ 右侧沙箱实时显示数据抽取过程
+
+ )}
+
+ {phase === "error" && (
+
+
绘制失败
+
{error}
+
+
+ )}
+
+ {phase === "done" && (
+
+
+ {doneHint}
+ {hasResult && (
+
+ )}
+
+ )}
+
+ {!atBottom &&
}
+
+ {/* 问答修改输入框 */}
+
+
+
+ );
+}
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/Step3DashboardMode.tsx b/frontend-web/src/roundtable-planning/components/Step3DashboardMode.tsx
index 69fe7b5a..084d93ed 100644
--- a/frontend-web/src/roundtable-planning/components/Step3DashboardMode.tsx
+++ b/frontend-web/src/roundtable-planning/components/Step3DashboardMode.tsx
@@ -15,9 +15,7 @@
import {
useEffect,
useMemo,
- useRef,
useState,
- type FormEvent,
} from "react";
import {
ArrowLeft,
@@ -27,31 +25,22 @@ import {
ExternalLink,
LayoutPanelTop,
Loader2,
- Pause,
Pencil,
- RefreshCw,
- SendHorizontal,
Sparkles,
} from "lucide-react";
-import { useLocalSettings } from "@/core/settings";
-import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { desensitizeDeep } from "@/lib/desensitize";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp";
import { adaptReportData } from "@/roundtable-planning/dashboard/data-context";
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
-import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
-import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
+import { DashboardChatPanel } from "@/roundtable-planning/components/DashboardChatPanel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import { DASHBOARD_VIEW_STORAGE_PREFIX } from "@/roundtable-planning/pages/DashboardStandalonePage";
-import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
-import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard";
import {
parseReportJson,
renderDashboardHtml,
- stripReportFence,
type DashboardTheme,
} from "@/roundtable-planning/lib/report-template";
import { useTheme } from "@/contexts/ThemeContext";
@@ -361,221 +350,3 @@ export function Step3DashboardMode({
);
}
-
-// ── 交互区聊天面板(沙箱的左 60%;右 40% 是抽取过程代码预览) ──────────────────────────
-
-function DashboardChatPanel({
- phase,
- dialogues,
- busy,
- error,
- onAsk,
- onRedraw,
- onPause,
- onOpenPreview,
- hasResult,
- selectedModel,
- availableModels,
- modelsLoading,
- setSelectedModel,
-}: {
- phase: UseStep3DashboardReturn["phase"];
- dialogues: UseStep3DashboardReturn["dialogues"];
- busy: boolean;
- error: string | null;
- onAsk: (message: string) => void;
- onRedraw: () => void;
- onPause: () => void;
- onOpenPreview: () => void;
- hasResult: boolean;
- selectedModel: string;
- availableModels: ReadonlyArray;
- modelsLoading: boolean;
- setSelectedModel: (model: string) => void;
-}) {
- const inputLocked = phase === "creating";
- const scrollRef = useRef(null);
- const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
- const [localSettings] = useLocalSettings();
- const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
- const dialogueMetrics = useDialogueSpeedMetrics(dialogues);
- const [text, setText] = useState("");
-
- // 消息列表里不展示 report-json 结构化数据本体(它已在右侧沙箱实时渲染),
- // 把数据块替换成一句「看右侧沙箱」的提示,避免左右两边重复刷整段 JSON。
- const displayDialogues = useMemo(
- () =>
- dialogues.map((d) => {
- const content = d.content ?? "";
- if (!content) return d;
- const stripped = stripReportFence(content, d.streaming === true);
- return stripped === content ? d : { ...d, content: stripped };
- }),
- [dialogues],
- );
-
- const handleSubmit = (e: FormEvent) => {
- e.preventDefault();
- const t = text.trim();
- if (!t || inputLocked) return;
- setText("");
- onAsk(t);
- };
-
- return (
-
- {/* 面板头:智能体名 + 模型选择 + 暂停/重画 */}
-
-
-
- 📊
-
-
-
大屏绘制智能体
-
提炼研讨成果为大屏结构化数据(右侧实时显示抽取过程)
-
-
-
-
- {busy ? (
-
- ) : (
-
- )}
-
-
-
- {/* 聊天记录 */}
-
-
- {phase === "creating" && dialogues.length === 0 && (
-
-
- 正在初始化绘制线程…
-
- )}
-
- {displayDialogues.map((msg) => (
-
- ))}
-
- {busy && (
-
-
- 右侧沙箱实时显示数据抽取过程,完成后自动切到大屏页面
-
- )}
-
- {phase === "error" && (
-
-
绘制失败
-
{error}
-
-
- )}
-
- {phase === "done" && (
-
-
- 数据已就绪,已切换到大屏页面。
- {hasResult && (
-
- )}
-
- )}
-
- {!atBottom &&
}
-
- {/* 问答修改输入框 */}
-
-
-
- );
-}
diff --git a/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx b/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx
index 94467fa5..e5c31fae 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,10 @@ export function Step3SummaryMode({
error={error}
busy={busy}
mdAvailable={!!mdArtifact}
+ flowPhase={summary.flowPhase}
+ onRegenerateFlow={summary.regenerateFlow}
+ pendingClarification={summary.pendingFlowClarification}
+ onResolveClarification={summary.resolveFlowClarification}
onRetry={redraw}
onAsk={handleAsk}
onPause={pause}
@@ -549,6 +556,10 @@ function AgentProgressPanel({
error,
busy,
mdAvailable,
+ flowPhase,
+ onRegenerateFlow,
+ pendingClarification,
+ onResolveClarification,
onRetry,
onAsk,
onPause,
@@ -565,6 +576,13 @@ function AgentProgressPanel({
busy: boolean;
/** 沙箱里是否已有 md 报告 —— 决定提示语(纯流程图轮没有 .md 可预览/下载)。 */
mdAvailable: boolean;
+ /** 后端流程图抽取阶段(Phase 1)。 */
+ flowPhase: UseStep3SummaryReturn["flowPhase"];
+ /** 仅重新生成流程图(模型挂了切模型后重试)。 */
+ onRegenerateFlow: () => void;
+ /** 意图模糊待澄清的原始消息(非空 → 展示选项让用户定)。 */
+ pendingClarification: string | null;
+ onResolveClarification: UseStep3SummaryReturn["resolveFlowClarification"];
onRetry: () => void;
onAsk: (message: string, files?: File[]) => void;
onPause: () => void;
@@ -705,6 +723,28 @@ function AgentProgressPanel({
)}
+ {/* 后端流程图「抽取+校验」状态条(Phase 1)。 */}
+ {(flowPhase === "extracting" || flowPhase === "validating") && (
+
+
+ {flowPhase === "extracting" ? "结构化流程图抽取中(JSON 实时输出)…" : "流程图校验 / 修复中…"}
+
+ )}
+ {flowPhase === "error" && (
+
+
流程图生成失败
+
模型可能不稳定。可在上方切换模型后重试。
+
+
+ )}
+
{phase === "done" && (
@@ -715,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++ %
@@ -2550,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 f624389b..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,
@@ -57,6 +58,7 @@ from app.gateway.routers import (
public_leaderboard,
public_scheduled_tasks,
recommend,
+ flow_extract,
recommended_questions,
roundtable_chains,
roundtable_draft_shares,
@@ -1333,6 +1335,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)
@@ -1348,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/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..166fc90f 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py
@@ -49,8 +49,8 @@ Python 三引号字符串,导致「磁盘上的验证版」与「代码里的内
from __future__ import annotations
import logging
+from collections.abc import Iterable
from pathlib import Path
-from typing import Iterable
from deerflow.config.paths import get_paths
@@ -67,6 +67,17 @@ 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"
+# Step 3「结构化抽取」用的智能体:读「方案总结报告」/ 各席位交付,分析层级结构 → 产出 flow-json
+# (也负责按指令改图 / 校验修复)。供 /api/flow-extract 调用;以后可在它身上配置「入库技能」,
+# 让它分析出结构化结果后直接入库。与 report/summary 并存的另一内置单例。
+STRUCTURE_AGENT_ID = "roundtable-structure"
# 验证版 SOUL.md + config.yaml 的源码资源目录(随 git 发布)。每个子目录名即 agent id,
# 内含该 agent 的 config.yaml 与 SOUL.md,是 seeder 落盘时字节级复制的来源。
@@ -74,13 +85,16 @@ _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,
+ STRUCTURE_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/_roundtable_seed_assets/roundtable-structure/SOUL.md b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/SOUL.md
new file mode 100644
index 00000000..44fccb50
--- /dev/null
+++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/SOUL.md
@@ -0,0 +1,75 @@
+# 结构化抽取智能体(roundtable-structure)
+
+你是圆桌「方案总结」第三步的**结构化抽取智能体**。你的职责:**读懂**已生成的《方案总结报告》或各席位交付内容,**分析**其内在层级结构,把它**抽取**成一张可交互流程图的结构化数据(`flow-json`)。你也负责**按用户指令修改**这张流程图、以及**校验修复**流程图。
+
+你专职「内容 → 结构」:不写报告、不联网、不派活、不画 HTML。
+
+**输出方式(重要)**:你要**先用简短文字说明你的分析过程**——让用户看到进展(尤其模型较慢时也有实时反馈):例如「我识别出本次层级链为 任务→目的→行为体→…;目的层有 3 条、各对应 1 个行为体;据此组织如下」,约 2–4 句即可,不要长篇大论;**然后**在回复**末尾**输出一个完整的 ```flow-json 代码块。前端会把这段分析说明展示给用户、并据末尾的 flow-json 绘制流程图。
+
+> 说明:本轮要做哪件事(首次抽取 / 校验修复 / 按指令修改)由发给你的用户消息指明;数据来源(报告全文 / 各席位交付 / 当前流程图)也在用户消息里给全。你只需专注分析内容、产出结构化结果。
+
+## 一、输出契约(flow-json 形状)
+
+在前述简短分析说明之后,于回复**末尾**输出一个 ```flow-json 代码块,形状如下(示例仅示意结构,勿照抄内容):
+
+```flow-json
+{"title":"流程图标题","nodes":[
+ {"id":"n1","label":"任务简短名","type":"start","group":"任务","detail":"任务目标"},
+ {"id":"n2","label":"目的核心短语","type":"task","group":"目的","detail":"名称+理由要点(2-4句,单行)","from":["n1"]},
+ {"id":"n3","label":"行为体核心短语","type":"role","group":"行为体","detail":"名称+理由要点","from":["n2"]}
+]}
+```
+
+字段:
+- `id`:n1/n2… 唯一短串;
+- `label`:≤12 字,节点标题,取该条目核心短语;
+- `type`:取 `start/end/stage/task/decision/risk/deliverable/role` 之一,**仅决定配色**;
+- `group`:**该节点所在的层级名称**(如 任务 / 目的 / 行为体 / 预期行为 / 驱动因素 / 叙事),**必填**——画布按它给节点打分类标签;
+- `detail`:该条目的**名称 + 完整理由 / 说明**——**原样取自报告、不要简化或精简理由**(理由要完整保留,不要删减内容);只需**写成一行**——内部不要出现真实换行,需要分隔用「;」或「/」(避免 JSON 里出现裸换行);
+- `from`:前驱节点 id 列表,只引用**前面已出现**的节点(也可用顶层 `edges:[{from,to,label}]`,两种都支持)。
+
+节点按先后排列、输出**全量**数据、全程简体中文。
+
+### ✅ 正确节点示例 vs ❌ 错误(必看)
+
+**✅ 正确**(`group` 标对层级,且**带上 `from` 连到前驱**——每个非根节点都必须连上前一层级对应的节点):
+
+```
+{"id":"n2","label":"精准追踪管理公众情绪曲线","type":"task","group":"目的","detail":"目的一:精准追踪并管理公众情绪曲线,识别关键情绪拐点;理由:暑期文旅涉及高频消费场景,情绪易受价格、服务、灾害影响呈多峰多谷震荡,情绪拐点是危机化解窗口,需精细跟踪作为前哨感知维度","from":["n1"]}
+```
+
+**❌ 错误**(缺了 `from`,导致节点孤立、流程图平铺——这是最常见的错误,务必避免):
+
+```
+{"id":"n2","label":"精准追踪管理公众情绪曲线","type":"task","group":"目的","detail":"…"} ← 少了 "from"
+```
+
+**硬性要求**:除根节点(任务,n1)外,**每个节点都必须有 `from`**(连到它在上一层级里对应的那个节点);目的i 连 任务,行为体i 连 目的i,预期行为i 连 行为体i,叙事i 连 预期行为i……一组一条自上而下的链,不要让任何节点悬空。
+
+## 二、画法(严格遵守)
+
+① 根节点 n1 = 本次任务本身(报告 / 任务总览里的任务名称),`label` 用任务简短名,`group` 填「任务」,**不要**用「任务」「目标」这种泛称当 `label`;
+
+② 从第二层起按层级逐层展开:每个层级在报告 / 交付里通常列了**多条具体条目**(如 目的层有 目的1 / 目的2 / 目的3、行为体层有各自对应的行为体,后面还有 预期行为·驱动因素、叙事…),把**每一条具体条目各画成一个节点**,**不要**把一整层合并成一个笼统节点;
+
+③ 每个节点的 `group` 填它所在层级的名称(取自报告里该层级的真实名称);
+
+④ **连线**:你要做对三件事 ——
+ (a) 每个节点的 `group` 标对层级;
+ (b) **各层内的条目按"相同的对应顺序"排列**——报告里相互对应的一组(目的1 ↔ 它对应的行为体 ↔ 对应的预期行为 ↔ 对应的叙事)在各自层里**都排同一位次**(第 1 组都排第 1 位、第 2 组都排第 2 位…);
+ (c) **每个非根节点都填上 `from`**,连到它在上一层级里对应的那个节点(目的i 连 任务、行为体i 连 目的i、预期行为i 连 行为体i、叙事i 连 预期行为i……一组一条自上而下的链)——**最常见的错误就是漏填 from 导致节点平铺,务必每个都填**。
+ 系统还会按 group 层级关系**校正 / 补全连线**作为兜底,但你必须先把 from 填全、填对;
+
+⑤ **务必把每个层级的每一条都输出成节点、不要漏层、不要只输出开头一两个节点就停**(典型规模约 13–16 个节点);
+
+⑥ **只画**这些层级实体节点;**不要**把「综合分析 / 核心洞察 / 执行路线 / 建议 / 结论」以及「阶段①②③④」等章节名 / 阶段名 / 汇总段落画成节点;节点 `label` 也**绝不能**是「阶段1 / 阶段2 / 第N阶段」这类阶段编号。
+
+## 三、层级顺序
+
+- 若数据来源里给了「业务链总控编排提示」并写明了层级链(如 任务 → 目的 → 行为体 → 预期行为 → 驱动因素 → 叙事),**优先按它的层级**组织;
+- 否则按给出的「默认层级」;都没有时,以「任务」为根、按报告 / 交付的逻辑结构合理分层。
+
+## 四、修改 / 校验时
+
+- **修改**:在「当前流程图」基础上**只按用户指令改动**(增 / 删 / 改节点或连线、改 label/group/detail),未被指令涉及的部分**保持原样**;删节点要同时去掉与它相关的连线;节点 id 尽量沿用原有的;输出**修改后的完整** flow-json。
+- **校验修复**:对照数据来源检查——根节点是任务本身、每层每条都在没漏、连边按对应关系成分支无悬空边、每个节点都有 `group`、没有汇总 / 阶段编号节点;发现问题就改对,没问题就原样规范化输出完整 flow-json。
diff --git a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/config.yaml b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/config.yaml
new file mode 100644
index 00000000..781643e1
--- /dev/null
+++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/config.yaml
@@ -0,0 +1,3 @@
+description: 圆桌结构化抽取智能体。读取「方案总结报告」或各席位交付内容,分析其层级结构,抽取成可交互流程图的结构化数据(flow-json);也负责按指令修改流程图、校验修复流程图。专职「内容→结构」,不写报告、不联网、不画 HTML。后续可在此智能体上配置「入库技能」,让它分析出结构化结果后直接入库。
+id: roundtable-structure
+name: 结构化抽取
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/app/gateway/routers/flow_extract.py b/offline-backend-20260512/backend/app/gateway/routers/flow_extract.py
new file mode 100644
index 00000000..c6ceccbb
--- /dev/null
+++ b/offline-backend-20260512/backend/app/gateway/routers/flow_extract.py
@@ -0,0 +1,713 @@
+"""流程图结构化抽取 + 校验 的 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 app.gateway.routers._roundtable_seed import (
+ STRUCTURE_AGENT_ID,
+ ensure_roundtable_functional_agents,
+)
+from deerflow.config.agents_config import load_agent_config, load_agent_soul
+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)
+
+
+class FlowIntentRequest(BaseModel):
+ """第三步输入框意图识别:判断用户想 重做报告 / 改流程图 / 重画流程图 / 提问 / 模糊待澄清。"""
+
+ message: str = ""
+ has_report: bool = False
+ has_flow: bool = False
+ model: str | None = Field(default=None)
+
+
+# 意图枚举(与前端路由一一对应)。
+_INTENTS = {"regenerate_report", "modify_flow", "regenerate_flow", "qa", "unclear"}
+
+
+# ── 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)
+
+ # 连线选取(两全:尊重模型 from + 弱模型兜底):
+ # · 模型把连线**连全了**(除根节点外每个节点都有入边)→ 直接用模型的 from/edges;
+ # · 模型**漏连**(≥2 个节点没入边,最常见就是漏 from 导致平铺)→ 按 group 层级关系
+ # **确定性推导补全**,保证不平铺、连线质量与模型能力无关。
+ incoming = {e["to"] for e in valid_edges}
+ orphans = [n for n in nodes if n["id"] not in incoming]
+ derived = _derive_layered_edges(nodes)
+ if valid_edges and len(orphans) <= 1:
+ final_edges = valid_edges # 模型连全(仅根无入边)→ 用模型连边
+ elif derived is not None:
+ final_edges = derived # 模型漏连 → 按层级推导补全(永不平铺)
+ else:
+ final_edges = valid_edges
+ return {"title": title, "nodes": nodes, "edges": final_edges}
+
+
+def _derive_layered_edges(nodes: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
+ """按节点的 `group`(层级)确定性推导连线,**不依赖模型给的 from/edges**。
+
+ 规则(对应「任务 → 各目的 → 各自对应的行为体 → … → 叙事」这种分层链):
+ · 按 group 首次出现顺序排出层 L0,L1,…(L0 通常是「任务」单节点);
+ · 相邻层 a→b 连边:|a|==1 → a 扇出到 b 全部;|b|==1 → a 全部汇入 b;
+ |a|==|b| → 按下标一一对应(目的i→行为体i);否则该层对不规则 → 整体放弃推导(返回 None)。
+ 返回 None 表示「无法可靠推导」(无 group / 只有一层 / 存在不规则层对),调用方回退模型连边。
+ """
+ # 按 group 分层,保持每层内节点的原始顺序 + 层的首次出现顺序。
+ # 加固:个别节点漏 group(弱模型可能漏标)→ 按发射顺序**就近归入前一个层级**(carry-forward),
+ # 不再因一个漏标就整体放弃;只有连开头节点都没 group / 全程无任何 group 才放弃。
+ order: list[str] = []
+ layers: dict[str, list[str]] = {}
+ last_group = ""
+ any_group = False
+ for n in nodes:
+ g = _as_str(n.get("group"))
+ if g:
+ any_group = True
+ last_group = g
+ else:
+ g = last_group # 漏 group → 沿用前一节点的层级
+ if not g:
+ return None # 开头节点就没 group,无法锚定层级 → 回退模型连边
+ if g not in layers:
+ layers[g] = []
+ order.append(g)
+ layers[g].append(n["id"])
+ if not any_group or len(order) < 2:
+ return None # 全程无 group / 只有一层 → 回退模型连边
+ out: list[dict[str, Any]] = []
+ for k in range(len(order) - 1):
+ a = layers[order[k]]
+ b = layers[order[k + 1]]
+ if len(a) == 1:
+ out.extend({"from": a[0], "to": nid} for nid in b)
+ elif len(b) == 1:
+ out.extend({"from": nid, "to": b[0]} for nid in a)
+ elif len(a) == len(b):
+ out.extend({"from": a[i], "to": b[i]} for i in range(len(a)))
+ else:
+ # 不规则层对(两边都 >1 且不等,多因模型漏/多了某层条目):**不放弃、不平铺**,
+ # 按比例把下层每个节点连到上层对应位置,保证每个节点都有入边(连上 > 连得完美)。
+ for j, nid in enumerate(b):
+ out.append({"from": a[j * len(a) // len(b)], "to": nid})
+ return out
+
+
+# ── 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(该条目的名称+**完整理由/说明,原样取自报告、不要简化或精简理由**;只需写成一行——内部换行用「;」或「/」替代,不要内含真实换行)、"
+ "from(前驱节点 id 列表,只引用前面已出现的节点)。"
+)
+
+_STRUCTURE = (
+ "【画法·严格遵守】"
+ "① 根节点 n1=本次任务本身(报告/任务总览里的任务名称),label 用任务简短名,group 填「任务」,不要用「任务」泛称当 label;"
+ "② 从第二层起按层级逐层展开:每层在报告/交付里通常列了多条具体条目(如 目的层有 目的1/2/3、行为体层有各自对应行为体,后面还有 预期行为·驱动因素、叙事…),"
+ "把每一条具体条目各画成一个节点,不要把整层合并成一个笼统节点;"
+ "③ 每个节点 group 填它所在层级名称(取自报告里该层级真实名称);"
+ "④ **连线**:(a)group 标对层级;(b)各层内条目按**相同对应顺序**排列(相互对应的一组在各层都排同一位次);"
+ "(c)**每个非根节点都必须填 from**,连到上一层级里对应的那个节点(目的i连任务、行为体i连目的i、预期行为i连行为体i、叙事i连预期行为i…)——**最常见错误就是漏 from 导致平铺,务必每个都填**;系统还会按层级校正/补全连线兜底;"
+ "⑤ 务必把每层每一条都输出成节点、不要漏层、不要只输出开头一两个节点就停(典型约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 "层级顺序:以「任务」为根,按报告/交付的逻辑结构合理分层展开。"
+
+
+# 兜底系统提示:当 roundtable-structure 的 SOUL 缺失时用它(与 SOUL 同款契约)。
+_DEFAULT_SYSTEM = (
+ "你是「结构化抽取智能体」。职责:读懂《方案总结报告》/ 各席位交付内容,分析其层级结构,"
+ "抽取成可交互流程图的结构化数据(flow-json);也按指令改图、校验修复。"
+ "请**先用简短文字(2-4句)说明分析过程**(识别的层级链、每层条目数、分支对应)让用户看到进展,"
+ "**再**在回复末尾输出一个完整的 ```flow-json 代码块(除这段简短说明外不写长篇正文)。全程简体中文。\n"
+ + _SHAPE
+ + "\n"
+ + _STRUCTURE
+)
+
+
+def _system_prompt() -> str:
+ """结构化抽取的系统提示 = roundtable-structure 智能体的 SOUL(可在智能体管理页编辑、
+ 未来挂入库技能);SOUL 缺失则回退内置 _DEFAULT_SYSTEM。"""
+ soul = load_agent_soul(STRUCTURE_AGENT_ID)
+ return soul.strip() if soul and soul.strip() else _DEFAULT_SYSTEM
+
+
+def _resolve_model(payload_model: str | None) -> str | None:
+ """模型优先级:前端所选(payload) > 智能体配置的 model > None(用 config 默认模型)。"""
+ if payload_model:
+ return payload_model
+ try:
+ cfg = load_agent_config(STRUCTURE_AGENT_ID)
+ if cfg and cfg.model:
+ return cfg.model
+ except Exception: # noqa: BLE001
+ pass
+ return None
+
+
+def _extract_messages(req: FlowExtractRequest) -> list[Any]:
+ user = (
+ _material(req)
+ + "\n\n"
+ + _hierarchy_hint(req)
+ + "\n\n请分析以上数据来源,按你的职责设定输出一个完整的 ```flow-json 代码块。"
+ )
+ return [SystemMessage(content=_system_prompt()), HumanMessage(content=user)]
+
+
+def _edit_messages(req: FlowExtractRequest) -> list[Any]:
+ """Phase 2 问答修改:按用户指令改当前流程图(增/删/改节点或连线),其余保持不变。"""
+ cur = json.dumps(req.current_graph or {}, ensure_ascii=False)
+ user = (
+ _material(req)
+ + "\n\n===== 当前流程图(flow-json,待按指令修改)=====\n"
+ + cur
+ + "\n===== 当前流程图结束 =====\n\n用户修改指令:\n"
+ + (req.instruction or "").strip()
+ + "\n\n请按指令修改这张流程图:未被指令涉及的部分保持原样、节点 id 尽量沿用、删节点连带删边,"
+ + "输出**修改后的完整** ```flow-json 代码块。"
+ )
+ return [SystemMessage(content=_system_prompt()), HumanMessage(content=user)]
+
+
+def _validate_messages(req: FlowExtractRequest, extracted: str) -> list[Any]:
+ user = (
+ _material(req)
+ + "\n\n===== 已抽取的 flow-json(待校验修复)=====\n"
+ + extracted
+ + "\n===== 待校验结束 =====\n\n请对照数据来源,**逐个节点检查上面这份 flow-json 是不是缺字段**、并修复它:"
+ + "\n**校验要点(务必遵守)**:"
+ + "① **连接关系 `from`(最重要,必查必补)**:**逐个**检查上面每个节点——除根任务外,"
+ + "**只要某个节点缺了 `from`(或 `from` 为空),就必须给它补上正确的 `from`**:连到它在上一层级里对应的那个节点"
+ + "(目的i 连 任务、行为体i 连 目的i、预期行为i 连 行为体i、叙事i 连 预期行为i…)。"
+ + "缺 `from` 是这份 JSON 最常见、最致命的错漏(会导致流程图平铺),**绝不能让任何非根节点悬空**;"
+ + "② **每层每条都在、没漏层没漏条目**,节点数只能 ≥ 原图、不能变少;"
+ + "③ 每个节点的 `group`(层级名)标对;各层内条目按相同对应顺序排列(相互对应的一组在各层排同一位次);"
+ + "④ 每个节点的 `detail` **原样保留、不要简化或精简理由**——理由要完整,不要删减内容;"
+ + "⑤ 没有汇总/阶段编号节点;只在确有错漏时才动,没问题就原样规范化输出。"
+ + "\n输出修复后的完整 ```flow-json 代码块(含全部节点 + 每个非根节点的 from)。"
+ )
+ return [SystemMessage(content=_system_prompt()), 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 + 流式校验修复(抽取/校验过程含思考都实时吐前端)→ 返回最终结构化图。"""
+
+ ensure_roundtable_functional_agents() # 自愈:确保 roundtable-structure 的 SOUL/config 存在
+ resolved_model = _resolve_model(payload.model)
+
+ 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=resolved_model, 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=resolved_model, 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
+ extracted_graph = parse_flow_graph(extracted)
+ # 防回归兜底:校验**绝不能让结构变差**。若校验后的图节点或连线比抽取结果更少
+ # (模型校验时把连线删没了、或漏了节点——实测会发生),直接回退用抽取结果,
+ # 保住所有节点与连线。校验只在「不丢东西」时才采用。
+ if graph is not None and extracted_graph is not None:
+ if len(graph.get("nodes", [])) < len(extracted_graph.get("nodes", [])) or len(
+ graph.get("edges", [])
+ ) < len(extracted_graph.get("edges", [])):
+ logger.info(
+ "flow-extract: validation reduced nodes/edges (%d/%d → %d/%d), keep extraction",
+ len(extracted_graph.get("nodes", [])),
+ len(extracted_graph.get("edges", [])),
+ len(graph.get("nodes", [])),
+ len(graph.get("edges", [])),
+ )
+ graph = extracted_graph
+ if graph is None:
+ graph = extracted_graph
+ 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"},
+ )
+
+
+# ── 意图识别(一次性 JSON,参考 ai_writing/intent_parser 范式)─────────────────
+
+
+def _parse_intent_json(text: str) -> dict[str, Any] | None:
+ """从模型输出里容错解析 {intent, reason}。strict → 去围栏/去尾逗号 → 平衡括号。"""
+ candidates: list[str] = []
+ t = text.strip()
+ candidates.append(t)
+ m = re.search(r"```(?:json)?\s*([\s\S]+?)\s*```", t)
+ if m:
+ candidates.append(m.group(1).strip())
+ s = t.find("{")
+ e = t.rfind("}")
+ if s >= 0 and e > s:
+ candidates.append(t[s : e + 1])
+ for c in candidates:
+ for cand in (c, re.sub(r",(\s*[}\]])", r"\1", c)):
+ try:
+ obj = json.loads(cand)
+ if isinstance(obj, dict):
+ return obj
+ except Exception: # noqa: BLE001
+ continue
+ return None
+
+
+_INTENT_SYS = (
+ "你是圆桌「方案总结」第三步输入框的**意图识别器**。第三步有两类产物:方案总结报告(Markdown) 和 结构化流程图。"
+ "用户在输入框发了一条消息,请判断他的**真实意图**,只输出一个 JSON 对象(不要解释、不要 markdown 代码块、用英文双引号):\n"
+ '{"intent":"<枚举>","reason":"<一句话依据>"}\n'
+ "intent 只能取其一:\n"
+ "- regenerate_report:想重新分析 / 重写【方案总结报告】(如「重新生成报告」「报告再写一版」「重新分析一下」);\n"
+ "- modify_flow:想修改**当前流程图**的某些节点/连线(如「把目的1改成X」「删掉第3个行为体」「加个验收节点」「流程图这里调整下」);\n"
+ "- regenerate_flow:想**重新生成整张**流程图(如「重新画流程图」「流程图重来」「重新结构化输出」);\n"
+ "- qa:只是提问 / 咨询,不需要改任何产物(如「为什么选 OTA 平台」「这个目的什么意思」);\n"
+ "- unclear:意图不明确、无法判断要改报告还是改流程图还是提问(如只说「改一下」「不对」「重来」而没指明对象)。\n"
+ "判断要点:结合下面给出的上下文(是否已有报告 / 是否已有流程图);当前已有流程图时,含「节点/连线/这一层/这个目的/行为体…改/删/加」之类多半是 modify_flow;"
+ "明确提到「报告」才是 regenerate_report;只有完全无法判断对象时才用 unclear。\n"
+ "示例:\n"
+ '「再帮我生成一次报告」→ {"intent":"regenerate_report","reason":"明确要求重新生成报告"}\n'
+ '「把目的1的名称改短点」→ {"intent":"modify_flow","reason":"要求修改流程图某节点"}\n'
+ '「流程图重新画一张」→ {"intent":"regenerate_flow","reason":"要求重画整张流程图"}\n'
+ '「OTA 平台为什么是直接行为体」→ {"intent":"qa","reason":"纯提问不改产物"}\n'
+ '「改一下」→ {"intent":"unclear","reason":"没说改报告还是流程图"}'
+)
+
+
+@router.post("/intent")
+async def classify_flow_intent(request: Request, payload: FlowIntentRequest = Body(...)) -> dict[str, str]:
+ """识别第三步输入框消息的意图(一次模型调用)。失败 / 解析不出 → unclear,让前端发起澄清。"""
+ msg = (payload.message or "").strip()
+ if not msg:
+ return {"intent": "unclear", "reason": "空消息"}
+ ctx = f"当前上下文:{'已生成报告' if payload.has_report else '尚无报告'};{'已生成流程图' if payload.has_flow else '尚无流程图'}。"
+ try:
+ model = create_chat_model(name=_resolve_model(payload.model), thinking_enabled=False)
+ res = await model.ainvoke(
+ [SystemMessage(content=_INTENT_SYS), HumanMessage(content=ctx + "\n\n用户消息:" + msg + "\n\n请输出 JSON:")]
+ )
+ text = res.content if isinstance(res.content, str) else _as_str(getattr(res, "text", ""))
+ obj = _parse_intent_json(text) or {}
+ intent = _as_str(obj.get("intent")).lower()
+ if intent not in _INTENTS:
+ intent = "unclear"
+ return {"intent": intent, "reason": _as_str(obj.get("reason"))}
+ except Exception: # noqa: BLE001 — 分类失败不致命,回退澄清让用户自己定
+ logger.warning("flow intent classify failed", exc_info=True)
+ return {"intent": "unclear", "reason": "意图识别失败"}
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/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
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
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",