Merge branch 'dhk-1' into 'master'

feat: 圆桌第3步大屏多页改用专职智能体 + 健壮 report-json 抽取

See merge request knowledge-base-bj/deerflow-server!134
This commit is contained in:
董宏凯 committed 2026-06-18 06:27:18 +00:00
commit 62549ab976
39 files changed
+3385 -449

No files matched your search

+1 -1
View File
@@ -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
@@ -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<string, unknown>).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<unknown | null> {
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;
}
@@ -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",
@@ -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 <uuid> 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);
}
}
@@ -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,
+11
View File
@@ -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 给沙箱预览) */}
<Route
path="dashboard-agent"
element={
<ChatRuntime>
<DashboardAgentPage />
</ChatRuntime>
}
/>
{/* 需求 0616 #3/#4:zzJC / zzXD 「按类型聚合」模块页(重做,独立路由)。 */}
<Route path="qa/zzjc/*" element={<AgentModuleRoutes module={AGENT_MODULES.zzjc} />} />
<Route path="qa/zzxd/*" element={<AgentModuleRoutes module={AGENT_MODULES.zzxd} />} />
@@ -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<DashboardSession | null> {
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<DashboardSession | null> {
const body: Record<string, unknown> = {};
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);
}
@@ -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;
/** 抽取流式增量(含 `<think>` 思考 + 正文 + JSON,前端原样渲染:思考块 + 代码块)。 */
onExtractDelta?: (text: string) => void;
/** 抽取结束、进入校验阶段。 */
onValidating?: () => void;
/** 校验/修复流式增量(同样含思考 + 正文,前端实时显示校验过程)。 */
onValidateDelta?: (text: string) => void;
}
/**
* 消费 SSE,返回校验后的最终图。抽取阶段通过 `onExtractDelta` 实时回吐;
* 出错(后端 error 帧 / 网络)抛异常。
*/
export async function streamFlowExtract(req: FlowExtractRequest): Promise<FlowGraphData> {
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<string, unknown>;
try {
frame = JSON.parse(data) as Record<string, unknown>;
} 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;
}
@@ -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<ReportThreadResponse> {
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
@@ -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<ModelOption>;
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<HTMLDivElement>(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<HTMLFormElement>) => {
e.preventDefault();
const t = text.trim();
if (!t || inputLocked) return;
setText("");
onAsk(t);
};
return (
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">
{/* 面板头:智能体名 + 模型选择 + 暂停/重画 */}
<div className="shrink-0 flex items-center justify-between gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 shrink-0 rounded-lg bg-gradient-to-tr from-primary to-[var(--brand-200)] flex items-center justify-center text-white text-xs shadow-sm">
📊
</div>
<div className="leading-tight min-w-0">
<div className="text-xs font-bold text-slate-800 truncate">大屏绘制智能体</div>
<div className="text-[10px] text-slate-400 truncate">{subtitle}</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<select
id="step3-dashboard-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={busy || modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: busy
? "绘制中不可切换;完成后或重新绘制时生效"
: "切换绘制使用的推理模型(下次绘制生效)"
}
className="max-w-[160px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[11px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name ? `${m.display_name} (${m.name})` : m.name}
</option>
))}
</select>
{busy ? (
<button
type="button"
onClick={onPause}
title="中止当前绘制"
className="bg-white border border-amber-200 text-amber-700 hover:bg-amber-50 text-[10.5px] font-bold px-2.5 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<Pause className="w-3.5 h-3.5" />
中止
</button>
) : (
<button
type="button"
onClick={onRedraw}
title="丢弃上下文,从头重新绘制"
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-2.5 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新绘制
</button>
)}
</div>
</div>
{/* 聊天记录 */}
<div className="relative flex-1 min-h-0 flex flex-col">
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto p-4 space-y-4 bg-white">
{phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
正在初始化绘制线程…
</div>
)}
{displayDialogues.map((msg) => (
<Step2Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
/>
))}
{busy && (
<div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500">
<span className="bg-primary size-1.5 animate-pulse rounded-full" />
右侧沙箱实时显示数据抽取过程
</div>
)}
{phase === "error" && (
<div className="rounded-xl border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">
<div className="font-bold mb-1">绘制失败</div>
<div className="leading-relaxed break-all">{error}</div>
<button
type="button"
onClick={onRedraw}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-bold text-rose-700 underline hover:text-rose-800"
>
<RefreshCw className="w-3 h-3" />
重试
</button>
</div>
)}
{phase === "done" && (
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
<CheckCircle className="w-4 h-4" />
{doneHint}
{hasResult && (
<button
type="button"
onClick={onOpenPreview}
className="ml-1 inline-flex items-center gap-1 text-[11px] font-bold text-primary underline hover:opacity-80"
>
在沙箱打开 HTML 预览
</button>
)}
</div>
)}
</div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
{/* 问答修改输入框 */}
<form onSubmit={handleSubmit} className="shrink-0 border-t border-slate-100 bg-white px-3 py-3">
<div className="flex items-end gap-2 rounded-2xl border border-slate-200 bg-slate-50/70 px-3 py-2 focus-within:border-primary/60 focus-within:bg-white focus-within:ring-2 focus-within:ring-[var(--brand-200)]/40">
<AutoGrowTextarea
maxHeight={224}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
}}
disabled={inputLocked}
rows={1}
placeholder={
busy
? "随时输入修改要求——发送会打断当前绘制并按新要求重出数据"
: "输入修改要求,例如:把风险分布换成柱状图,新增一个执行路线席位"
}
className="min-h-8 max-h-56 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="submit"
disabled={inputLocked || text.trim().length === 0}
title={inputLocked ? "正在初始化线程,请稍候" : "发送修改要求"}
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
>
{inputLocked ? <Loader2 className="h-4 w-4 animate-spin" /> : <SendHorizontal className="h-4 w-4" />}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -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<string | null>(null);
@@ -276,13 +285,14 @@ function FlowCanvasInner({
</div>
</div>
{/* 节点详情弹窗 */}
{/* 节点详情弹窗(传入 onChange 时可编辑 / 删除节点) */}
{selectedNode && (
<NodeDetailModal
node={selectedNode}
data={data}
onClose={() => setSelectedId(null)}
onJump={(id) => setSelectedId(id)}
onChange={onChange}
/>
)}
</div>
@@ -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 (
<div
className="absolute inset-0 z-30 flex items-center justify-center bg-black/30 p-4 backdrop-blur-[1px]"
@@ -361,27 +392,110 @@ function NodeDetailModal({
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[50vh] space-y-3 overflow-y-auto p-4">
<div>
<div className="mb-1 text-[10px] font-bold uppercase tracking-widest text-slate-400">
详细说明
</div>
<p className="text-xs leading-relaxed text-slate-700">
{node.detail?.trim() || "(该节点未提供详细说明)"}
</p>
{editing ? (
<div className="max-h-[55vh] space-y-3 overflow-y-auto p-4">
<EditField label="节点名称(label)" value={label} onChange={setLabel} />
<EditField label="所属层级(group,如 目的 / 行为体)" value={group} onChange={setGroup} />
<EditField label="详细内容(detail,名称+理由)" value={detail} onChange={setDetail} multiline />
</div>
{incoming.length > 0 && (
<ConnectionList title="上游(来自)" entries={incoming.map((e) => ({ id: e.from, label: labelOf(e.from), edgeLabel: e.label }))} onJump={onJump} />
)}
{outgoing.length > 0 && (
<ConnectionList title="下游(去往)" entries={outgoing.map((e) => ({ id: e.to, label: labelOf(e.to), edgeLabel: e.label }))} onJump={onJump} />
)}
</div>
) : (
<div className="max-h-[50vh] space-y-3 overflow-y-auto p-4">
<div>
<div className="mb-1 text-[10px] font-bold uppercase tracking-widest text-slate-400">详细说明</div>
<p className="text-xs leading-relaxed text-slate-700 whitespace-pre-wrap">
{node.detail?.trim() || "(该节点未提供详细说明)"}
</p>
</div>
{incoming.length > 0 && (
<ConnectionList title="上游(来自)" entries={incoming.map((e) => ({ id: e.from, label: labelOf(e.from), edgeLabel: e.label }))} onJump={onJump} />
)}
{outgoing.length > 0 && (
<ConnectionList title="下游(去往)" entries={outgoing.map((e) => ({ id: e.to, label: labelOf(e.to), edgeLabel: e.label }))} onJump={onJump} />
)}
</div>
)}
{/* 编辑 / 删除 操作栏(仅传入 onChange 时显示) */}
{onChange && (
<div className="flex items-center justify-between gap-2 border-t border-slate-100 bg-slate-50/60 px-4 py-2.5">
{editing ? (
<>
<button
type="button"
onClick={() => setEditing(false)}
className="rounded-lg px-2.5 py-1.5 text-[11px] font-bold text-slate-500 hover:bg-slate-200/60"
>
取消
</button>
<button
type="button"
onClick={handleSave}
className="inline-flex items-center gap-1 rounded-lg bg-primary px-3 py-1.5 text-[11px] font-bold text-white hover:bg-[var(--brand-900)]"
>
<Check className="h-3.5 w-3.5" />
保存
</button>
</>
) : (
<>
<button
type="button"
onClick={handleDelete}
className="inline-flex items-center gap-1 rounded-lg border border-rose-200 px-2.5 py-1.5 text-[11px] font-bold text-rose-600 hover:bg-rose-50"
>
<Trash2 className="h-3.5 w-3.5" />
删除节点
</button>
<button
type="button"
onClick={() => setEditing(true)}
className="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-1.5 text-[11px] font-bold text-slate-700 hover:border-primary/40 hover:text-primary"
>
<Pencil className="h-3.5 w-3.5" />
编辑
</button>
</>
)}
</div>
)}
</div>
</div>
);
}
function EditField({
label,
value,
onChange,
multiline,
}: {
label: string;
value: string;
onChange: (v: string) => void;
multiline?: boolean;
}) {
return (
<div>
<div className="mb-1 text-[10px] font-bold uppercase tracking-widest text-slate-400">{label}</div>
{multiline ? (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={4}
className="w-full resize-none rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-xs leading-relaxed text-slate-800 focus:border-primary focus:outline-none focus:ring-1 focus:ring-[var(--brand-200)]/50"
/>
) : (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-xs text-slate-800 focus:border-primary focus:outline-none focus:ring-1 focus:ring-[var(--brand-200)]/50"
/>
)}
</div>
);
}
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 = (
<div
className={cn(
"flex flex-col bg-white",
// 非全屏=填满所在面板(Step3 放在右侧沙箱位,不覆盖左侧消息列表);全屏才整页覆盖。
fullscreen ? "fixed inset-0 z-[80]" : "relative h-full min-h-0 w-full",
// 非全屏=填满所在面板(Step3 放在右侧沙箱位,不覆盖左侧消息列表);
// 全屏=覆盖整个视口(z 高于 app 顶部导航,保证顶部工具栏可见、可点「退出全屏」)。
fullscreen ? "fixed inset-0 z-[9999]" : "relative h-full min-h-0 w-full",
)}
>
<ReactFlowProvider>
@@ -438,8 +553,15 @@ export function FlowGraphPanel({ data, streaming, onClose, onSwitchToReport }: F
onToggleFullscreen={() => setFullscreen((f) => !f)}
onClose={onClose}
onSwitchToReport={onSwitchToReport}
onChange={onChange}
/>
</ReactFlowProvider>
</div>
);
// 全屏时 portal 到 body:脱离任何带 transform/overflow 的祖先(Step2 沙箱分栏等),
// 否则 `fixed` 会被祖先裁剪、顶部工具栏被 app 导航栏盖住(即本次 bug)。
if (fullscreen && typeof document !== "undefined") {
return createPortal(inner, document.body);
}
return inner;
}
@@ -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({
</div>
);
}
// ── 交互区聊天面板(沙箱的左 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<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
}) {
const inputLocked = phase === "creating";
const scrollRef = useRef<HTMLDivElement>(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<HTMLFormElement>) => {
e.preventDefault();
const t = text.trim();
if (!t || inputLocked) return;
setText("");
onAsk(t);
};
return (
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">
{/* 面板头:智能体名 + 模型选择 + 暂停/重画 */}
<div className="shrink-0 flex items-center justify-between gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 shrink-0 rounded-lg bg-gradient-to-tr from-primary to-[var(--brand-200)] flex items-center justify-center text-white text-xs shadow-sm">
📊
</div>
<div className="leading-tight min-w-0">
<div className="text-xs font-bold text-slate-800 truncate">大屏绘制智能体</div>
<div className="text-[10px] text-slate-400 truncate">提炼研讨成果为大屏结构化数据(右侧实时显示抽取过程)</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<select
id="step3-dashboard-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={busy || modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: busy
? "绘制中不可切换;完成后或重新绘制时生效"
: "切换绘制使用的推理模型(下次绘制生效)"
}
className="max-w-[160px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[11px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name ? `${m.display_name} (${m.name})` : m.name}
</option>
))}
</select>
{busy ? (
<button
type="button"
onClick={onPause}
title="中止当前绘制"
className="bg-white border border-amber-200 text-amber-700 hover:bg-amber-50 text-[10.5px] font-bold px-2.5 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<Pause className="w-3.5 h-3.5" />
中止
</button>
) : (
<button
type="button"
onClick={onRedraw}
title="丢弃上下文,从头重新绘制"
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-2.5 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新绘制
</button>
)}
</div>
</div>
{/* 聊天记录 */}
<div className="relative flex-1 min-h-0 flex flex-col">
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto p-4 space-y-4 bg-white">
{phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
正在初始化绘制线程…
</div>
)}
{displayDialogues.map((msg) => (
<Step2Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
/>
))}
{busy && (
<div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500">
<span className="bg-primary size-1.5 animate-pulse rounded-full" />
右侧沙箱实时显示数据抽取过程,完成后自动切到大屏页面
</div>
)}
{phase === "error" && (
<div className="rounded-xl border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">
<div className="font-bold mb-1">绘制失败</div>
<div className="leading-relaxed break-all">{error}</div>
<button
type="button"
onClick={onRedraw}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-bold text-rose-700 underline hover:text-rose-800"
>
<RefreshCw className="w-3 h-3" />
重试
</button>
</div>
)}
{phase === "done" && (
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
<CheckCircle className="w-4 h-4" />
数据已就绪,已切换到大屏页面。
{hasResult && (
<button
type="button"
onClick={onOpenPreview}
className="ml-1 inline-flex items-center gap-1 text-[11px] font-bold text-primary underline hover:opacity-80"
>
在沙箱打开 HTML 预览
</button>
)}
</div>
)}
</div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
{/* 问答修改输入框 */}
<form onSubmit={handleSubmit} className="shrink-0 border-t border-slate-100 bg-white px-3 py-3">
<div className="flex items-end gap-2 rounded-2xl border border-slate-200 bg-slate-50/70 px-3 py-2 focus-within:border-primary/60 focus-within:bg-white focus-within:ring-2 focus-within:ring-[var(--brand-200)]/40">
<AutoGrowTextarea
maxHeight={224}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
}}
disabled={inputLocked}
rows={1}
placeholder={
busy
? "随时输入修改要求——发送会打断当前绘制并按新要求重出数据"
: "输入修改要求,例如:把风险分布换成柱状图,新增一个执行路线席位"
}
className="min-h-8 max-h-56 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="submit"
disabled={inputLocked || text.trim().length === 0}
title={inputLocked ? "正在初始化线程,请稍候" : "发送修改要求"}
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
>
{inputLocked ? <Loader2 className="h-4 w-4 animate-spin" /> : <SendHorizontal className="h-4 w-4" />}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -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],
);
// 流程图抽取/校验的过程(思考 `<think>` + 文字说明 + 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({
</div>
)}
{/* 后端流程图「抽取+校验」状态条(Phase 1)。 */}
{(flowPhase === "extracting" || flowPhase === "validating") && (
<div className="inline-flex items-center gap-2 rounded-full border border-blue-200 bg-blue-50 px-2.5 py-1 text-[11px] font-bold text-blue-600">
<Loader2 className="w-3 h-3 animate-spin" />
{flowPhase === "extracting" ? "结构化流程图抽取中(JSON 实时输出)…" : "流程图校验 / 修复中…"}
</div>
)}
{flowPhase === "error" && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
<div className="font-bold mb-1">流程图生成失败</div>
<div className="leading-relaxed">模型可能不稳定。可在上方切换模型后重试。</div>
<button
type="button"
onClick={onRegenerateFlow}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-bold text-amber-800 underline hover:text-amber-900"
>
<RefreshCw className="w-3 h-3" />
重新生成流程图
</button>
</div>
)}
{phase === "done" && (
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
<CheckCircle className="w-4 h-4" />
@@ -715,6 +755,35 @@ function AgentProgressPanel({
)}
</div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
{/* 意图模糊 → 让用户自己定(不自作主张) */}
{pendingClarification && (
<div className="shrink-0 border-t border-amber-100 bg-amber-50/60 px-3 py-2.5">
<div className="mb-1.5 text-[11px] font-bold text-amber-800">
没太理解你的意思,你是想——
</div>
<div className="flex flex-wrap gap-1.5">
{(
[
{ k: "regenerate_report", t: "重新生成报告" },
{ k: "modify_flow", t: "修改流程图" },
{ k: "regenerate_flow", t: "重新生成流程图" },
{ k: "qa", t: "只是提问" },
] as const
).map((o) => (
<button
key={o.k}
type="button"
onClick={() => onResolveClarification(o.k)}
className="rounded-lg border border-amber-300 bg-white px-2.5 py-1.5 text-[11px] font-bold text-amber-800 transition hover:bg-amber-100"
>
{o.t}
</button>
))}
</div>
</div>
)}
<form onSubmit={handleAskSubmit} className="shrink-0 border-t border-slate-100 bg-white px-3 py-3">
{/* 已选附件预览(发送时随指令上传到总结线程,供智能体读取) */}
{askFiles.length > 0 && (
@@ -15,7 +15,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useArtifacts } from "@/components/workspace/artifacts";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import {
initReportThread,
initDashboardThread,
streamMultiAgent,
type StreamPhase,
} from "@/roundtable-planning/api/multi-agent";
@@ -35,7 +35,7 @@ import {
} from "@/roundtable-planning/lib/report-template";
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
const DASH_AGENT_FALLBACK = "roundtable-report";
const DASH_AGENT_FALLBACK = "roundtable-dashboard";
export type DashboardPhase = "idle" | "creating" | "streaming" | "done" | "error";
@@ -48,6 +48,11 @@ interface UseStep3DashboardOptions {
themeMode: "light" | "dark";
savedReport?: DraftStep3Snapshot | null;
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
/**
* 自定义初始 prompt 构造(独立大屏页用:输入是任务原始 JSON,让智能体分析转换)。
* 返回空/未提供时回退默认的 buildDashboardPrompt(圆桌研讨成果模式)。
*/
buildInitialPrompt?: () => string | null | undefined;
}
export interface UseStep3DashboardReturn {
@@ -73,6 +78,8 @@ export interface UseStep3DashboardReturn {
hydrateSaved: (html: string) => void;
/** 从草稿/后台作业恢复(dashboardJson 结构化数据):按**当前主题**渲染后展示。 */
hydrateFromJson: (jsonStr: string) => void;
/** 从持久化恢复**真实聊天记录** + 结构化数据(独立大屏页按 taskId 取回时用)。 */
hydrateTranscript: (dialogues: Step2Dialogue[], dashboardJson: string | null) => void;
/** 手动打开大屏沙箱预览(恢复后/用户点气泡)。 */
openDashboardPreview: () => void;
}
@@ -105,6 +112,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
themeMode,
savedReport,
onReportSaved,
buildInitialPrompt,
} = options;
const onSavedRef = useRef(onReportSaved);
@@ -112,6 +120,11 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
onSavedRef.current = onReportSaved;
}, [onReportSaved]);
const buildInitialPromptRef = useRef(buildInitialPrompt);
useEffect(() => {
buildInitialPromptRef.current = buildInitialPrompt;
}, [buildInitialPrompt]);
const { select: selectArtifact, setOpen: setArtifactsOpen, open: artifactsOpen } = useArtifacts();
const artifactsOpenRef = useRef(artifactsOpen);
useEffect(() => {
@@ -269,7 +282,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
const { bubbleId, message, aborter } = args;
let threadId = liveThreadRef.current;
if (!threadId) {
const init = await initReportThread({ signal: aborter.signal });
const init = await initDashboardThread({ signal: aborter.signal });
if (aborter.signal.aborted) return;
threadId = init.threadId;
agentNameRef.current = init.agentName || DASH_AGENT_FALLBACK;
@@ -374,7 +387,13 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
]
.filter(Boolean)
.join("\n");
const prompt = buildDashboardPrompt({ intentText, seatDeliveries, consensusText });
// 独立大屏页:用自定义 prompt(输入任务原始 JSON,让智能体分析转换);
// 否则回退默认的圆桌研讨成果 prompt。
const custom = buildInitialPromptRef.current?.();
const prompt =
custom && custom.trim()
? custom
: buildDashboardPrompt({ intentText, seatDeliveries, consensusText });
await streamOnce({ bubbleId, message: prompt, aborter });
} catch (err) {
if (aborter.signal.aborted) return;
@@ -486,6 +505,38 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
[themeMode, hydrateSaved],
);
/** 从持久化恢复真实聊天记录 + 结构化数据(独立大屏页按 taskId 取回时用)。
* 与 hydrateFromJson 的区别:保留**用户真实的历史对话气泡**,而非造一条合成提示。 */
const hydrateTranscript = useCallback(
(savedDialogues: Step2Dialogue[], dashboardJson: string | null) => {
abort();
runningRef.current = false;
const synthThreadId = "roundtable-dashboard-restored";
liveThreadRef.current = null; // 合成线程非真线程:续问会新建线程重新生成
setDashThreadId(synthThreadId);
setActiveArtifactThreadId(synthThreadId);
const parsed = dashboardJson ? parseReportJson(dashboardJson) : null;
if (parsed) {
const rendered = renderDashboardHtml(parsed, { generatedAt: formatNow(), theme: themeMode });
htmlRef.current = rendered;
setHtml(rendered);
setData(parsed);
dataRef.current = parsed; // 续轮修改的基线
const { messageId, toolCallId } = upsertDashStub(synthThreadId, rendered);
lastAutoOpenRef.current = { threadId: synthThreadId, messageId, toolCallId };
} else {
htmlRef.current = null;
setHtml(null);
setData(null);
dataRef.current = null;
}
setDialogues(Array.isArray(savedDialogues) ? savedDialogues : []);
setError(null);
setPhase(parsed ? "done" : "idle");
},
[abort, themeMode, upsertDashStub],
);
// 恢复路径辅助:savedReport 由组件/页面按字段调用对应 hydrate;这里只消除未用告警。
void savedReport;
void isDashboardHtml;
@@ -519,6 +570,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
setPreviewEnabled,
hydrateSaved,
hydrateFromJson,
hydrateTranscript,
openDashboardPreview,
};
}
@@ -40,7 +40,8 @@ import type {
Step2Dialogue,
StubAiMessage,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import { detectFlowIntent, hasFlowFence } from "@/roundtable-planning/lib/flow-graph";
import { detectFlowIntent, extractFlowGraph, hasFlowFence, serializeFlowGraph, type FlowGraphData } from "@/roundtable-planning/lib/flow-graph";
import { classifyFlowIntent, streamFlowExtract, type FlowIntent } from "@/roundtable-planning/api/flow-extract";
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
/** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */
@@ -79,6 +80,18 @@ export interface UseStep3SummaryReturn {
error: string | null;
/** taskId/深链模式:md 报告生成后自动续跑流程图(组件据此让流程图画布自动聚焦)。 */
autoFlow: boolean;
/** 后端「抽取+校验」得到的校验后结构化流程图(Phase 1)。done 时据此绘制。 */
flowGraph: FlowGraphData | null;
/** 流程图生成阶段:idle / extracting(抽取流式) / validating(校验中) / done / error。 */
flowPhase: "idle" | "extracting" | "validating" | "done" | "error";
/** 仅重新生成流程图(复用当前 md/席位交付,不重跑报告)——模型挂了切模型后重试用。 */
regenerateFlow: () => void;
/** 手动编辑流程图(Phase 3:画布上改/删节点后回写 + 持久化)。 */
setFlowGraph: (next: FlowGraphData) => void;
/** 意图模糊时挂起的原始消息(非空 → 组件展示「重做报告/改图/重画/提问」选项让用户定)。 */
pendingFlowClarification: string | null;
/** 用户在澄清里点选意图后执行。 */
resolveFlowClarification: (choice: FlowIntent) => void;
summaryThreadId: string | null;
dialogues: Step2Dialogue[];
seatStubMessages: Record<string, StubAiMessage[]>;
@@ -220,61 +233,9 @@ const FLOW_STRUCTURE_RULE =
"⑥ **只画**这些层级实体节点;**不要**把『综合分析』『核心洞察』『分层传播策略 / 执行路线 / 建议』『结论』" +
"以及『阶段①②③④』等章节名 / 阶段名 / 汇总段落画成节点。";
/** 把各席位交付拼成一段内联材料(无报告时作流程图的数据来源)。 */
function inlineSeatDeliveries(seatDeliveries?: { sender: string; content: string }[]): string {
const list = (seatDeliveries ?? []).filter((s) => (s.content ?? "").trim());
if (list.length === 0) return "";
return list
.map((s, i) => `## 席位${i + 1}:${s.sender || "(未具名席位)"}\n${s.content.trim()}`)
.join("\n\n");
}
/**
* taskId/深链模式:md 报告生成完后自动续跑的「结构化输出」prompt。
*
* 数据基准(用户拍板):**有报告就基于报告**(报告已把各席位交付清晰总结过,直接提取实体、
* 不要再自己重新总结一遍);没有报告则基于**各席位交付产物**。两种来源都**整段内联**进 prompt
* (不依赖线程上下文),让模型逐条对着抽取节点。层级顺序优先用总控提示词里写明的层级,否则 chainLevels。
*/
function buildAutoFlowPrompt(opts: {
chainLevels?: string[];
coordinatorPrompt?: string | null;
reportMd?: string;
seatDeliveries?: { sender: string; content: string }[];
}): string {
const levels = (opts.chainLevels ?? []).map((s) => s.trim()).filter(Boolean);
const cp = opts.coordinatorPrompt?.trim();
const report = opts.reportMd?.trim() ?? "";
const seatMaterial = report ? "" : inlineSeatDeliveries(opts.seatDeliveries);
const parts: string[] = [];
if (report) {
parts.push(
"本轮只画一张流程图。**数据来源 = 下面这份你已写好的《方案总结报告》**(它已把各席位交付清晰总结过)——" +
"请**逐条对着报告抽取**已有的层级条目作为流程图节点,**不要再自己重新总结 / 概括 / 提炼**,也不要凭空新增报告里没有的内容。",
);
} else {
parts.push(
"本轮只画一张流程图。**数据来源 = 下面各席位的交付产物**——请**逐条对着这些交付抽取**已有的层级条目作为流程图节点,不要自己另起总结。",
);
}
parts.push(FLOW_ONLY_RULE);
// 整段内联数据来源(报告全文 / 席位交付),用分隔线包起来便于模型对照抽取。
if (report) {
parts.push("===== 方案总结报告全文(数据来源,逐条抽取,勿改写)=====\n" + report + "\n===== 报告结束 =====");
} else if (seatMaterial) {
parts.push("===== 各席位交付产物(数据来源,逐条抽取,勿改写)=====\n" + seatMaterial + "\n===== 交付结束 =====");
}
parts.push(FLOW_JSON_SHAPE);
parts.push(FLOW_STRUCTURE_RULE);
if (cp) {
parts.push("层级顺序优先采用「业务链总控编排提示」里写明的层级链(如它写的 任务 → 目的 → 行为体 → …)。");
} else if (levels.length > 0) {
parts.push(`层级顺序(自上而下):任务 → ${levels.join(" → ")}。`);
}
// 收尾再强调(首尾夹击,对抗线程里 coordinatorPrompt 的报告锚定)。
parts.push("再次强调:本轮不写任何 Markdown 报告正文 / 章节,整个回复除一句话说明外,只有一个完整的 ```flow-json 代码块。");
return parts.join("\n");
}
// 注:原 buildAutoFlowPrompt(在总结线程里续问吐 flow-json)已废弃 —— Phase 1 起自动结构化
// 输出改走后端独立端点 /api/flow-extract/stream(streamFlowExtract),抽取+校验在后端做。
// FLOW_ONLY_RULE / FLOW_STRUCTURE_RULE / FLOW_JSON_SHAPE 仍由手动 FLOW_ASK_INSTRUCTION 引用。
/** 首轮总结 prompt。`flowMode`=编排提示里明确要求了结构化输出/流程图 → 跳过 md 文件,直出流程图。 */
function buildSummaryPrompt(opts: {
@@ -452,6 +413,16 @@ export function useStep3Summary(
const [phase, setPhase] = useState<SummaryPhase>("idle");
const [error, setError] = useState<string | null>(null);
// 后端「抽取+校验」流程图(Phase 1):与 SummaryPhase 解耦(md 报告完成后才跑流程图)。
const [flowGraph, setFlowGraphState] = useState<FlowGraphData | null>(null);
const [flowPhase, setFlowPhase] = useState<"idle" | "extracting" | "validating" | "done" | "error">("idle");
// 镜像 flowGraph 供 ask()(问答修改)在 render 外读到最新图作 edit 入参。
const flowGraphRef = useRef<FlowGraphData | null>(null);
useEffect(() => {
flowGraphRef.current = flowGraph;
}, [flowGraph]);
// 意图模糊时挂起待澄清的原始消息(组件渲染选项让用户自己定)。
const [pendingFlowClarification, setPendingFlowClarification] = useState<string | null>(null);
const [summaryThreadId, setSummaryThreadId] = useState<string | null>(null);
const [dialogues, setDialogues] = useState<Step2Dialogue[]>([]);
const [seatStubMessages, setSeatStubMessages] = useState<
@@ -530,6 +501,11 @@ export function useStep3Summary(
);
}, []);
/** 改某气泡的发送方名称(意图分类期间先建中性「调度中」气泡,定了意图再改成对应智能体名复用)。 */
const relabelDialogue = useCallback((id: number, sender: string) => {
setDialogues((prev) => prev.map((d) => (d.id === id ? { ...d, sender } : d)));
}, []);
const appendStepIfNew = useCallback(
(
bubbleId: number,
@@ -1010,6 +986,116 @@ export function useStep3Summary(
],
);
/**
* Phase 1:后端「抽取+校验」生成流程图。独立调 `/api/flow-extract/stream`(不进总结线程,
* 避开 coordinator_prompt 的报告锚定):抽取阶段把 JSON 流式吐进对话气泡(用户实时看),
* 校验完成后用校验后的结构化图绘制画布(done)。模型沿用第三步所选(selectedModel)。
*/
const runFlowExtract = useCallback(
async (
aborter: AbortController,
opts?: { instruction?: string; currentGraph?: FlowGraphData | null; bubbleId?: number },
): Promise<void> => {
const isEdit = !!opts?.instruction && !!opts?.currentGraph;
setFlowPhase("extracting");
if (!isEdit) setFlowGraphState(null); // 编辑模式保留旧图直到新图就绪(防画布闪空)
// 两个气泡:抽取/修改 + 校验——把过程(思考 `<think>` + 文字 + flow-json 代码块)都留着展示,
// 由 Step2Message 渲染(思考折叠块 + markdown 代码块),不折叠成一句话。
// 传入 bubbleId(意图分类期间已建的「调度中」气泡)则复用并改名,避免空窗 + 不重复建气泡。
const extractSender = isEdit ? "结构化流程图 · 修改" : "结构化流程图 · 抽取";
let extractBubbleId: number;
if (opts?.bubbleId != null) {
extractBubbleId = opts.bubbleId;
relabelDialogue(extractBubbleId, extractSender);
} else {
extractBubbleId = appendStreamingDialogue({ sender: extractSender, avatarType: "blue" });
}
let validateBubbleId: number | null = null;
try {
const graph = await streamFlowExtract({
reportMd: currentMdRef.current,
seatDeliveries,
chainLevels,
coordinatorPrompt,
model: selectedModel || undefined,
currentGraph: opts?.currentGraph ?? null,
instruction: opts?.instruction,
signal: aborter.signal,
onExtractDelta: (t) => appendToDialogue(extractBubbleId, t),
onValidating: () => {
setFlowPhase("validating");
// 抽取气泡收尾(保留已流出的内容),另起校验气泡。
finalizeDialogue(extractBubbleId, "", "抽取完成");
validateBubbleId = appendStreamingDialogue({ sender: "结构化流程图 · 校验", avatarType: "blue" });
},
onValidateDelta: (t) => {
if (validateBubbleId != null) appendToDialogue(validateBubbleId, t);
},
});
if (aborter.signal.aborted) return;
setFlowGraphState(graph);
setFlowPhase("done");
flowActiveRef.current = true;
// 收尾两个气泡(保留过程内容)。校验后的图序列化进草稿快照 closing,恢复时重建画布。
if (validateBubbleId != null) finalizeDialogue(validateBubbleId, "", "流程图完成");
else finalizeDialogue(extractBubbleId, "", "流程图完成");
onSummarySavedRef.current?.({
md: currentMdRef.current,
threadId: liveThreadRef.current ?? "",
generatedAt: new Date().toISOString(),
model: selectedModel || "",
closing: serializeFlowGraph(graph),
});
} catch (err) {
if (aborter.signal.aborted) return;
const msg = err instanceof Error ? err.message : String(err);
setFlowPhase("error");
finalizeDialogue(
validateBubbleId ?? extractBubbleId,
`(流程图生成失败) ${msg}\n模型可能不稳定——可在上方切换模型后点「重新生成流程图」重试。`,
"流程图失败",
);
}
},
[
seatDeliveries,
chainLevels,
coordinatorPrompt,
selectedModel,
appendStreamingDialogue,
appendToDialogue,
finalizeDialogue,
relabelDialogue,
],
);
/** 仅重新生成流程图(不重跑 md 报告):模型挂了切模型后重试 / 手动重画。 */
const regenerateFlow = useCallback(() => {
if (!currentMdRef.current.trim() && seatDeliveries.length === 0) return;
aborterRef.current?.abort();
const aborter = new AbortController();
aborterRef.current = aborter;
void runFlowExtract(aborter);
}, [runFlowExtract, seatDeliveries]);
/** Phase 3:画布手动改/删节点后回写图 + 持久化(序列化进草稿快照)。 */
const updateFlowGraph = useCallback(
(next: FlowGraphData) => {
setFlowGraphState(next);
setFlowPhase("done");
flowActiveRef.current = true;
const serialized = serializeFlowGraph(next);
onSummarySavedRef.current?.({
md: currentMdRef.current,
threadId: liveThreadRef.current ?? "",
generatedAt: new Date().toISOString(),
model: selectedModel || "",
closing: serialized,
});
},
[selectedModel],
);
const run = useCallback(async () => {
if (runningRef.current) return;
runningRef.current = true;
@@ -1021,6 +1107,8 @@ export function useStep3Summary(
currentMdRef.current = "";
liveThreadRef.current = null;
setSeatStubMessages({});
setFlowGraphState(null);
setFlowPhase("idle");
const aborter = new AbortController();
aborterRef.current = aborter;
@@ -1044,33 +1132,16 @@ export function useStep3Summary(
});
await streamOnce({ bubbleId, prompt, aborter, flowRound: flowMode });
// taskId/深链模式:报告生成完,**自动**续跑一轮结构化输出(流程图)。
// 数据来源:有报告则内联报告全文、否则内联各席位交付(buildAutoFlowPrompt 处理)。
// 故触发条件放宽为「有报告 **或** 有席位交付」——保证「无报告也能据席位交付出图」。
// 复用同一真线程,与手动「结构化输出」走同一渲染链路。
// taskId/深链模式:报告生成完,**自动**用后端「抽取+校验」端点生成流程图(Phase 1)。
// 不再在总结线程里续问吐 flow-json(那会被 coordinator_prompt 的报告锚定带偏、不稳);
// 改走独立端点:流式抽取(实时显示) → 校验修复 → 用校验后结构化图绘制。
// 有报告或有席位交付即触发(无报告也能据席位交付出图)。
if (
autoFlow &&
!aborter.signal.aborted &&
liveThreadRef.current &&
(currentMdRef.current.trim() || seatDeliveries.length > 0)
) {
const flowBubbleId = appendStreamingDialogue({
sender: "方案总结报告智能体",
avatarType: "blue",
});
const flowPrompt = buildAutoFlowPrompt({
chainLevels,
coordinatorPrompt,
reportMd: currentMdRef.current,
seatDeliveries,
});
await streamOnce({
bubbleId: flowBubbleId,
prompt: flowPrompt,
aborter,
seedThreadId: liveThreadRef.current,
flowRound: true,
});
await runFlowExtract(aborter);
}
} catch (err) {
if (aborter.signal.aborted) return;
@@ -1087,15 +1158,15 @@ export function useStep3Summary(
seatDeliveries,
consensusText,
coordinatorPrompt,
chainLevels,
autoFlow,
appendStreamingDialogue,
finalizeDialogue,
streamOnce,
runFlowExtract,
]);
const ask = useCallback(
async (message: string, files?: File[]) => {
async (message: string, files?: File[], opts?: { forcedIntent?: FlowIntent }) => {
const text = message.trim();
const hasFiles = !!(files && files.length > 0);
if (!text && !hasFiles) return;
@@ -1106,19 +1177,65 @@ export function useStep3Summary(
abort();
if (wasBusy) freezeStreaming("已打断");
// 意图引导分三档(智能体按语义最终裁决,前端只给上下文,不做硬分类):
// ① 显式命中结构化/流程图关键词 → 直出 flow-json、不写 md;
// ② 没命中关键词但上一轮主产物是流程图 → 流程图上下文引导(笼统修改默认改图,太模糊则反问);
// ③ 普通报告语境 → 语义理解修改/提问/结构化意图,太模糊则反问。
const flowIntent = detectFlowIntent(text);
const flowContext = !flowIntent && flowActiveRef.current;
const askInstruction = flowIntent
? FLOW_ASK_INSTRUCTION
: flowContext
? FLOW_CONTEXT_ASK_INSTRUCTION
: SEMANTIC_ASK_INSTRUCTION;
// 流程图语境轮:不主动弹 md 沙箱预览(用户多半要看的是画布;真写了文件预览仍会自动打开)。
const flowRound = flowIntent || flowContext;
// 用户气泡**立刻上屏**(不等意图分类 / 模型返回,避免连接卡顿时显得卡)——对齐第一/二步。
// 后续各分支都不再重复追加用户气泡。forcedIntent(澄清后点选复跑)时原气泡已在、跳过避免重复。
if (!opts?.forcedIntent) {
const fileNote = hasFiles ? `(附 ${files!.length} 个文件)` : "";
appendUserDialogue((text || "(上传了参考文件)") + fileNote);
}
// 意图识别(参考 ai_writing 一次性 JSON 范式):先判断用户想 重做报告 / 改流程图 /
// 重画流程图 / 提问 / 模糊待澄清,再分流——不再用关键词硬路由(避免「说重做报告却出流程图」)。
// 带附件时跳过分类(附件多用于报告 Q&A/改报告);澄清后用户点选则用 forcedIntent 跳过分类。
// reuseBubbleId:意图分类期间先建的「调度中」气泡,定了意图后由对应分支复用(不等接口返回、像第一步)。
let reuseBubbleId: number | null = null;
if (text && !hasFiles) {
let intent: FlowIntent | undefined = opts?.forcedIntent;
let dispatchBubbleId: number | null = null;
if (!intent) {
// **立刻**显示「智能体调度中」气泡(不等意图接口返回),消除连接卡顿时的"卡住"感。
dispatchBubbleId = appendStreamingDialogue({ sender: "智能体", avatarType: "blue" });
const r = await classifyFlowIntent({
message: text,
hasReport: !!currentMdRef.current.trim(),
hasFlow: !!flowGraphRef.current,
model: selectedModel || undefined,
});
intent = r.intent;
}
if (intent === "unclear") {
if (dispatchBubbleId != null) {
finalizeDialogue(dispatchBubbleId, "意图不太明确,请在下方选择你想做的:", "待确认");
}
setPendingFlowClarification(text); // 组件渲染选项让用户自己定
return;
}
if (intent === "regenerate_flow" || intent === "modify_flow") {
const flowAborter = new AbortController();
aborterRef.current = flowAborter;
const cur = flowGraphRef.current;
const reuse = dispatchBubbleId ?? undefined;
if (intent === "modify_flow" && cur) {
await runFlowExtract(flowAborter, { instruction: text, currentGraph: cur, bubbleId: reuse });
} else {
await runFlowExtract(flowAborter, { bubbleId: reuse });
}
return;
}
// regenerate_report | qa → 复用调度气泡走下方总结线程路径。
reuseBubbleId = dispatchBubbleId;
// intent === "regenerate_report" | "qa" → 落到下方总结线程续问路径:
// **追加**用户气泡 + 在原线程上让智能体重写报告 / 增量改 / 回答(**不清空历史**)。
// regenerate_report 不再走 run()(run 会 setDialogues([]) 清空,导致"覆盖聊天记录、
// 不显示用户输入"的 bug);改报告/重写报告由总结智能体在续问里整文件重写。
// intent === "qa" → 落到下方总结线程问答路径(只答不改产物)。
}
// 走到这里只剩 regenerate_report / qa(含带附件场景)—— 流程图操作已在上面意图分流处理。
// 一律走报告/问答语义引导(让总结智能体在原线程上重写报告 / 增量改 / 回答),
// **不再**在总结线程里产 flow-json(流程图统一由后端抽取端点负责)。
const askInstruction = SEMANTIC_ASK_INSTRUCTION;
const flowRound = false;
runningRef.current = true;
setError(null);
@@ -1153,12 +1270,15 @@ export function useStep3Summary(
}));
}
const fileNote = hasFiles ? `(附 ${files!.length} 个文件)` : "";
appendUserDialogue((text || "(上传了参考文件)") + fileNote);
const bubbleId = appendStreamingDialogue({
sender: "方案总结报告智能体",
avatarType: "blue",
});
// 用户气泡已在 ask 开头立刻追加。智能体气泡:复用意图分类期间的「调度中」气泡(改名),
// 没有则新建——这样提问/重做报告时分类阶段就有「调度中」反馈、不等模型返回(像第一步)。
let bubbleId: number;
if (reuseBubbleId != null) {
bubbleId = reuseBubbleId;
relabelDialogue(bubbleId, "方案总结报告智能体");
} else {
bubbleId = appendStreamingDialogue({ sender: "方案总结报告智能体", avatarType: "blue" });
}
setPhase("streaming");
if (hadLiveThread) {
@@ -1214,14 +1334,29 @@ export function useStep3Summary(
seatDeliveries,
consensusText,
coordinatorPrompt,
selectedModel,
abort,
freezeStreaming,
appendUserDialogue,
appendStreamingDialogue,
relabelDialogue,
finalizeDialogue,
streamOnce,
runFlowExtract,
],
);
/** 澄清后用户点选意图 → 用 forcedIntent 复跑(跳过意图分类)。 */
const resolveFlowClarification = useCallback(
(choice: FlowIntent) => {
const msg = pendingFlowClarification;
setPendingFlowClarification(null);
if (!msg) return;
void ask(msg, undefined, { forcedIntent: choice });
},
[pendingFlowClarification, ask],
);
/** 暂停当前生成:中断在途请求 + 把流式气泡收尾为「已暂停」+ 状态回 done。 */
const pause = useCallback(() => {
if (!runningRef.current) return;
@@ -1247,6 +1382,11 @@ export function useStep3Summary(
(snap: DraftStep3SummarySnapshot) => {
abort();
runningRef.current = false;
// 恢复流程图:快照 closing 里若存了 flow-json,解析成结构化图填进 flowGraph 状态,
// 让画布直接用它绘制(也供 Phase 3 手动编辑)。解析不出则回退到对话气泡解析。
const restoredGraph = snap.closing ? extractFlowGraph(snap.closing) : null;
setFlowGraphState(restoredGraph);
setFlowPhase(restoredGraph ? "done" : "idle");
const hasMd = snap.md.trim().length > 0;
const realThreadId = snap.threadId?.trim() || null;
const synthThreadId = realThreadId ?? "roundtable-summary-restored";
@@ -1330,6 +1470,12 @@ export function useStep3Summary(
phase,
error,
autoFlow: !!autoFlow,
flowGraph,
flowPhase,
regenerateFlow,
setFlowGraph: updateFlowGraph,
pendingFlowClarification,
resolveFlowClarification,
summaryThreadId,
dialogues,
seatStubMessages,
@@ -9,10 +9,13 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
applyNodeEdit,
deleteNodeFromGraph,
detectFlowIntent,
extractFlowGraph,
hasFlowFence,
layoutFlowGraph,
serializeFlowGraph,
stripFlowFence,
} from "./flow-graph";
@@ -206,3 +209,51 @@ test("layoutFlowGraph:环不死循环", () => {
assert.equal(positioned.length, 2);
positioned.forEach((n) => assert.ok(Number.isFinite(n.x) && Number.isFinite(n.y)));
});
test("serializeFlowGraph:与 extractFlowGraph 往返一致(含 group/detail/边标签)", () => {
const text = [
"```flow-json",
'{"title":"方案","nodes":[',
'{"id":"t","label":"任务X","type":"start","group":"任务","detail":"目标说明"},',
'{"id":"p1","label":"目的1","type":"task","group":"目的","detail":"名称+理由","from":["t"]},',
'{"id":"d","label":"判断","type":"decision","from":[{"id":"p1","label":"可行"}]}',
"]}",
"```",
].join("\n");
const g = extractFlowGraph(text)!;
const round = extractFlowGraph(serializeFlowGraph(g))!;
assert.equal(round.title, "方案");
assert.deepEqual(
round.nodes.map((n) => [n.id, n.label, n.group, n.detail]),
g.nodes.map((n) => [n.id, n.label, n.group, n.detail]),
);
assert.deepEqual(round.edges, g.edges); // 含 {from:"p1",to:"d",label:"可行"}
});
test("applyNodeEdit:改 label/group/detail,其余节点不变", () => {
const g = extractFlowGraph(
'```flow-json\n{"nodes":[{"id":"a","label":"A","type":"task","group":"目的"},{"id":"b","label":"B","type":"role","group":"行为体","from":["a"]}]}\n```',
)!;
const next = applyNodeEdit(g, "a", { label: "新目的", group: "目标", detail: "改后的详情" });
const a = next.nodes.find((n) => n.id === "a")!;
assert.equal(a.label, "新目的");
assert.equal(a.group, "目标");
assert.equal(a.detail, "改后的详情");
// b 不变;边不变。
assert.equal(next.nodes.find((n) => n.id === "b")!.label, "B");
assert.deepEqual(next.edges, g.edges);
// 空 group/detail → 清空(置 undefined)。
const cleared = applyNodeEdit(g, "a", { group: " ", detail: "" });
const a2 = cleared.nodes.find((n) => n.id === "a")!;
assert.equal(a2.group, undefined);
});
test("deleteNodeFromGraph:删节点同时删掉与它相连的所有边", () => {
const g = extractFlowGraph(
'```flow-json\n{"nodes":[{"id":"t","label":"任务"},{"id":"p","label":"目的","from":["t"]},{"id":"b","label":"行为体","from":["p"]}]}\n```',
)!;
const next = deleteNodeFromGraph(g, "p");
assert.deepEqual(next.nodes.map((n) => n.id), ["t", "b"]);
// t→p 和 p→b 两条边都没了(b 变孤点)。
assert.equal(next.edges.length, 0);
});
@@ -281,6 +281,64 @@ export function extractFlowGraph(text: string): FlowGraphData | null {
return { title, nodes, edges: validEdges };
}
/**
* 把结构化图序列化回一个 ```flow-json 代码块(与 extractFlowGraph 互逆)。
* 用途:后端校验后拿到 FlowGraphData → 序列化存进草稿快照(closing),恢复时仍走
* extractFlowGraph 重建;手动编辑后也用它回存。`from` 由 edges 按 `to` 归集(带边标签)。
*/
export function serializeFlowGraph(data: FlowGraphData): string {
const fromMap = new Map<string, Array<string | { id: string; label: string }>>();
for (const e of data.edges) {
const list = fromMap.get(e.to) ?? [];
list.push(e.label ? { id: e.from, label: e.label } : e.from);
fromMap.set(e.to, list);
}
const nodes = data.nodes.map((n) => {
const o: Record<string, unknown> = { id: n.id, label: n.label, type: n.type };
if (n.group) o.group = n.group;
if (n.detail) o.detail = n.detail;
const from = fromMap.get(n.id);
if (from && from.length > 0) o.from = from;
return o;
});
return `${FLOW_FENCE_OPEN}\n${JSON.stringify({ title: data.title || "方案流程图", nodes })}\n\`\`\``;
}
/**
* Phase 3 画布手动编辑:改某节点的 label/group/detail(其余不变)。返回新图(不可变)。
*/
export function applyNodeEdit(
data: FlowGraphData,
id: string,
patch: { label?: string; group?: string; detail?: string },
): FlowGraphData {
return {
...data,
nodes: data.nodes.map((n) =>
n.id === id
? {
...n,
...(patch.label !== undefined ? { label: patch.label.trim() || n.label } : {}),
// group/detail 允许清空(传空串 → 置 undefined)。
...(patch.group !== undefined ? { group: patch.group.trim() || undefined } : {}),
...(patch.detail !== undefined ? { detail: patch.detail.trim() || undefined } : {}),
}
: n,
),
};
}
/**
* Phase 3 画布手动删除:删一个节点 + 所有与它相连的边。返回新图。
*/
export function deleteNodeFromGraph(data: FlowGraphData, id: string): FlowGraphData {
return {
...data,
nodes: data.nodes.filter((n) => n.id !== id),
edges: data.edges.filter((e) => e.from !== id && e.to !== id),
};
}
/**
* 识别文本(业务链编排提示 / 用户输入框消息)里是否明确要求了结构化输出 / 流程图。
* 命中 → 总结走「流程图模式」:跳过 .md 文件,报告用文字展示 + 末尾输出 flow-json。
@@ -351,6 +351,22 @@ export function coerceReportData(raw: unknown): ReportData | null {
// ── 模型 prompt(只要 report-json,不调工具、不写文件) ───────────────────────────
/** ```report-json 契约说明(buildDashboardPrompt / buildDashboardPromptFromJson 共用)。 */
const DASHBOARD_REPORT_JSON_CONTRACT = [
"```report-json 契约(严格 JSON,全程简体中文):",
'{"title":"大屏主标题(≤20字)","subtitle":"一句话定位",',
' "kpis":[{"label":"指标名","value":"数值","unit":"单位","trend":"up|down|flat"}], // 3-6 个核心指标,从内容提取或合理估算',
' "consensus":{"headline":"一句话最终结论","points":["结论要点,3-6 条"]},',
' "charts":[{"type":"line|bar|donut|radar","title":"图表标题","items":[{"label":"维度","value":数字}]}], // 1-3 个总览图表:line=趋势(销量/渗透率随时间)、donut=占比(份额/构成)、radar=多维对比(竞争力)、bar=排名;3-8 项/图',
' "timeline":[{"label":"阶段名","desc":"一句话说明"}], // 执行路线 3-6 阶段',
' "seats":[{"id":"seat-1","name":"分区/角色名","summary":"该分区结论一两句",',
' "sections":[{"heading":"小节标题","bullets":["要点,每条≤40字"]}], // 2-4 节',
' "metrics":[{"label":"量化指标名","value":0到100}], // 0-3 个',
' "risks":[{"label":"风险点","level":"high|mid|low"}], // 0-4 个',
' "dependsOn":["它承接哪些分区的产物(分区id)"]}]} // 依据内容判断依赖关系',
"硬性要求:id 用 seat-1..seat-N;dependsOn 只引用本清单内的 id;JSON 必须可被 JSON.parse 解析(无注释、无尾逗号)。",
].join("\n");
export function buildDashboardPrompt(opts: {
intentText: string;
seatDeliveries: Array<{ sender: string; content: string }>;
@@ -374,24 +390,45 @@ export function buildDashboardPrompt(opts: {
parts.push((opts.consensusText || "").trim() || "(未提供)");
parts.push("");
parts.push(
[
"```report-json 契约(严格 JSON,全程简体中文):",
'{"title":"大屏主标题(≤20字)","subtitle":"一句话定位",',
' "kpis":[{"label":"指标名","value":"数值","unit":"单位","trend":"up|down|flat"}], // 3-6 个核心指标,从研讨内容提取或合理估算',
' "consensus":{"headline":"一句话最终结论","points":["结论要点,3-6 条"]},',
' "charts":[{"type":"line|bar|donut|radar","title":"图表标题","items":[{"label":"维度","value":数字}]}], // 1-3 个总览图表:line=趋势(销量/渗透率随时间)、donut=占比(份额/构成)、radar=多维对比(竞争力)、bar=排名;3-8 项/图',
' "timeline":[{"label":"阶段名","desc":"一句话说明"}], // 执行路线 3-6 阶段',
' "seats":[{"id":"seat-1","name":"席位名(与交付席位一致)","summary":"该席位结论一两句",',
' "sections":[{"heading":"小节标题","bullets":["要点,每条≤40字"]}], // 2-4 节,提炼该席位交付',
' "metrics":[{"label":"量化指标名","value":0到100}], // 0-3 个',
' "risks":[{"label":"风险点","level":"high|mid|low"}], // 0-4 个',
' "dependsOn":["它承接哪些席位的产物(席位id)"]}]} // 每个交付席位一项;依据交付内容判断依赖关系',
"硬性要求:seats 覆盖**每一个**有交付的席位;id 用 seat-1..seat-N;dependsOn 只引用本清单内的 id;JSON 必须可被 JSON.parse 解析(无注释、无尾逗号)。",
].join("\n"),
DASHBOARD_REPORT_JSON_CONTRACT.replace(
"硬性要求:id",
"硬性要求:seats 覆盖**每一个**有交付的席位;id",
),
);
return parts.join("\n");
}
/**
* 大屏绘制智能体(独立页面)的初始 prompt:输入是外部系统给的**任意结构 JSON**,
* 让智能体**分析/转换**成大屏所需的 report-json 结构。区别于 buildDashboardPrompt
* (输入是圆桌研讨成果),这里输入是登录时拉取的任务原始数据。
*
* 注:现在 JSON 接口未就绪,调用方传的是占位数据(任务名/内容等);接口就绪后改为
* 后台转换格式 + 智能体校验,仍由本智能体产出/校验 report-json(不后台硬抽)。
*/
export function buildDashboardPromptFromJson(inputJson: unknown): string {
let inputText: string;
try {
inputText =
typeof inputJson === "string" ? inputJson : JSON.stringify(inputJson, null, 2);
} catch {
inputText = String(inputJson ?? "");
}
return [
"【结构化数据模式】本轮**不要调用任何工具、不要写任何文件、不要输出 HTML**。",
"下面是本任务的**原始数据(JSON)**。请你**分析并转换**它,提炼成一份用于「研讨大屏」展示的结构化数据:",
"理解原始数据的业务含义,归纳关键指标、结论、图表维度、分阶段路线,以及若干主题分区(seats,按业务维度/模块拆分,不必对应真实席位)。",
"在回复**最末尾**输出一个完整的 ```report-json 代码块(契约见下),代码块之外只允许一两句简短说明。",
"",
"# 任务原始数据(JSON)",
"```json",
inputText.trim() || "(未提供)",
"```",
"",
DASHBOARD_REPORT_JSON_CONTRACT,
].join("\n");
}
// ── 主题(2026-06-13):跟随应用当前主题绘制 ─────────────────────────────────────
// light = 《浅红色 UI 规范》(docs/浅红色UI规范):白/浅灰大底、#F5222D 品牌红强调、
// 卡片 4px 圆角、语义色 成功#189D72/警告#FF9900/错误#F5222D/信息#0079FE;
@@ -0,0 +1,377 @@
/**
* 「大屏绘制智能体」独立页面(带导航 + 侧边栏,挂在 StrategyRoutes 下)。
*
* 按外部 `taskId`(URL `?taskId=` / 路由 state / 登录暂存的任务详情 id)存取:
* - 进入时按 taskId 拉取已存会话(`/api/dashboard-sessions/by-task`,**不按 user 分权**)。
* - **已有有效结果(可解析的 report-json)** → 直接展示大屏页面(应用内 `DashboardApp`)。
* - **没有有效结果** → 进入「生成过程」视图:自动让智能体分析登录拉取的业务 JSON
* (现为占位适配,接口就绪后改后台转换 + 智能体校验)→ 边抽边在右侧沙箱显示,
* **完成不自动跳**;底部输入框可随时改。
* - 聊天记录 + 最新 report-json 防抖回存(按 taskId 共享,跨用户可见)。
*
* 数据抽取/聊天/沙箱/渲染全部复用 `useStep3Dashboard` + `DashboardChatPanel` +
* `DashboardApp`,仅把输入从「圆桌研讨成果」换成「任务原始 JSON」。
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useSearchParams } from "react-router-dom";
import {
ArrowLeft,
Boxes,
Code2,
ExternalLink,
LayoutPanelTop,
Loader2,
Pencil,
Sparkles,
} from "lucide-react";
import { fetchDashboardInputJson } from "@/core/auth/dashboard-input";
import { readCopTaskDetail } from "@/core/auth/task-deeplink";
import { desensitizeDeep } from "@/lib/desensitize";
import { useModels } from "@/core/models/hooks";
import { useTheme } from "@/contexts/ThemeContext";
import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp";
import { adaptReportData } from "@/roundtable-planning/dashboard/data-context";
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 { useStep3Dashboard } from "@/roundtable-planning/hooks/useStep3Dashboard";
import type { Step2Dialogue } from "@/roundtable-planning/hooks/useStep2Orchestration";
import {
buildDashboardPromptFromJson,
parseReportJson,
renderDashboardHtml,
type DashboardTheme,
} from "@/roundtable-planning/lib/report-template";
import {
getDashboardSessionByTask,
putDashboardSessionByTask,
} from "@/roundtable-planning/api/dashboard-sessions";
function formatNow(): string {
const d = new Date();
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
type DashView = "chat" | "page";
type RenderMode = "app" | "html";
/** 从 URL / 路由 state / 登录暂存任务详情解析 taskId。 */
function useTaskId(): string {
const [searchParams] = useSearchParams();
const location = useLocation();
return useMemo(() => {
const fromQuery = searchParams.get("taskId")?.trim();
if (fromQuery) return fromQuery;
const fromState = (location.state as { taskId?: string } | null)?.taskId;
if (fromState && String(fromState).trim()) return String(fromState).trim();
const detail = readCopTaskDetail();
if (detail?.id != null && String(detail.id).trim()) return String(detail.id).trim();
return "";
}, [searchParams, location.state]);
}
export default function DashboardAgentPage() {
const taskId = useTaskId();
const { models: availableModels, isLoading: modelsLoading } = useModels();
const [selectedModel, setSelectedModel] = useState("");
const { theme } = useTheme();
const themeMode: DashboardTheme = theme === "dark" ? "dark" : "light";
// 输入 JSON(登录拉取/占位)——auto-start 时由 buildInitialPrompt 闭包读取。
const inputRef = useRef<unknown>(null);
const dashboard = useStep3Dashboard({
intentReady: null,
seatDeliveries: [],
consensusText: "",
selectedModel,
themeMode,
buildInitialPrompt: () => {
const inp = inputRef.current;
return inp ? buildDashboardPromptFromJson(inp) : null;
},
});
const {
phase,
dialogues,
data,
seatStubMessages,
activeArtifactThreadId,
busy,
redraw,
ask,
abort,
setPreviewEnabled,
openDashboardPreview,
start,
hydrateTranscript,
} = dashboard;
const [loading, setLoading] = useState(true);
const [view, setView] = useState<DashView>("chat");
const [renderMode, setRenderMode] = useState<RenderMode>("app");
// 默认选中第一个模型。
useEffect(() => {
if (availableModels.length === 0) return;
if (!availableModels.some((m) => m.name === selectedModel)) {
setSelectedModel(availableModels[0].name);
}
}, [availableModels, selectedModel]);
// ── 挂载:按 taskId 取会话 → 决定直显大屏 / 看生成过程(只跑一次) ──
const didInitRef = useRef(false);
useEffect(() => {
if (didInitRef.current) return;
if (!taskId) {
setLoading(false);
return;
}
didInitRef.current = true;
let cancelled = false;
void (async () => {
try {
const session = await getDashboardSessionByTask(taskId).catch(() => null);
if (cancelled) return;
const savedDialogues = (session?.transcript as Step2Dialogue[] | undefined) ?? [];
const validReport = session?.reportJson && parseReportJson(session.reportJson);
if (validReport) {
// 有有效结果 → 恢复并直接展示大屏页面。
hydrateTranscript(savedDialogues, session!.reportJson);
setView("page");
} else if (savedDialogues.length > 0) {
// 有历史聊天但无有效结果 → 进生成过程展示历史,不自动重跑。
hydrateTranscript(savedDialogues, session?.reportJson ?? null);
setView("chat");
} else {
// 无任何记录 → 拉输入 JSON 自动开跑,让用户看生成过程(不自动跳)。
const input = await fetchDashboardInputJson(taskId).catch(() => null);
if (cancelled) return;
inputRef.current = input;
setView("chat");
if (input) start();
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [taskId, hydrateTranscript, start]);
// ── 防抖回存聊天记录 + 最新 report-json(按 taskId 共享) ──
useEffect(() => {
if (!taskId || loading) return;
if (dialogues.length === 0) return;
const t = window.setTimeout(() => {
void putDashboardSessionByTask(taskId, {
transcript: dialogues,
reportJson: data ? JSON.stringify(data) : undefined,
}).catch(() => {
/* 回存失败不打扰用户 */
});
}, 800);
return () => window.clearTimeout(t);
}, [taskId, loading, dialogues, data]);
// 仅在交互区允许本流自动接管沙箱(并行流防抢);离开/卸载时让出。
useEffect(() => {
setPreviewEnabled(view === "chat");
return () => setPreviewEnabled(false);
}, [view, setPreviewEnabled]);
// ── 大屏渲染数据(深度脱敏后渲染,覆盖大屏所有展示文本) ──
const report = useMemo(() => (data ? desensitizeDeep(data) : null), [data]);
const dashboardData = useMemo(
() => (report ? adaptReportData(report, formatNow()) : undefined),
[report],
);
const dashboardHtml = useMemo(
() => (report ? renderDashboardHtml(report, { generatedAt: formatNow(), theme: themeMode }) : null),
[report, themeMode],
);
const hasResult = !!dashboardData || !!dashboardHtml;
// 网页渲染:blob URL 作 iframe src(:target 导航需独立文档 URL,见 Step3DashboardMode 注释)。
const [htmlBlobUrl, setHtmlBlobUrl] = useState<string | null>(null);
useEffect(() => {
if (typeof window === "undefined" || !dashboardHtml) {
setHtmlBlobUrl(null);
return;
}
const url = URL.createObjectURL(new Blob([dashboardHtml], { type: "text/html;charset=utf-8" }));
setHtmlBlobUrl(url);
return () => URL.revokeObjectURL(url);
}, [dashboardHtml]);
const isStreaming =
phase === "creating" || phase === "streaming" || dialogues.some((d) => d.streaming === true);
const handleOpenNewWindow = () => {
if (typeof window === "undefined") return;
if (renderMode === "html" && dashboardHtml) {
const blobUrl = URL.createObjectURL(new Blob([dashboardHtml], { type: "text/html;charset=utf-8" }));
window.open(blobUrl, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
return;
}
const key = `${DASHBOARD_VIEW_STORAGE_PREFIX}${Date.now()}`;
try {
for (let i = window.localStorage.length - 1; i >= 0; i -= 1) {
const k = window.localStorage.key(i);
if (k && k.startsWith(DASHBOARD_VIEW_STORAGE_PREFIX)) window.localStorage.removeItem(k);
}
if (dashboardData) window.localStorage.setItem(key, JSON.stringify(dashboardData));
} catch {
/* localStorage 不可用:新窗口回落 DEMO */
}
const base = `${window.location.origin}${window.location.pathname}`;
window.open(`${base}#/roundtable-dashboard?k=${encodeURIComponent(key)}`, "_blank", "noopener,noreferrer");
};
if (!taskId) {
return (
<div className="flex h-full min-h-0 items-center justify-center p-8 text-center text-sm text-slate-500">
<div>
<Sparkles className="mx-auto mb-3 h-8 w-8 text-primary/40" />
缺少任务标识(taskId)。请通过带 <code className="px-1">?taskId=</code> 的链接进入大屏绘制页。
</div>
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col text-slate-800">
{/* 顶栏 */}
<div className="shrink-0 flex flex-wrap items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3">
<div className="flex items-center gap-2.5">
<Sparkles className="h-4.5 w-4.5 text-primary" />
<h3 className="text-sm font-black tracking-tight text-slate-900">大屏绘制智能体</h3>
<span className="inline-flex items-center gap-1.5 rounded-full border border-red-200/50 bg-[var(--brand-200)]/60 px-2 py-0.5 text-[10px] font-bold text-primary">
{(phase === "creating" || phase === "streaming") && <Loader2 className="h-3 w-3 animate-spin" />}
{view === "chat" ? "生成过程" : "大屏页面"}
</span>
</div>
<div className="flex items-center gap-2">
{view === "page" ? (
<>
<div className="inline-flex items-center gap-0.5 rounded-xl border border-slate-200 bg-slate-50 p-0.5">
<button
type="button"
onClick={() => setRenderMode("app")}
title="用应用内 React 组件渲染(Tab 切换、动画、席位钻取)"
className={`flex items-center gap-1 rounded-lg px-2.5 py-1 text-[10.5px] font-bold transition ${
renderMode === "app"
? "border border-slate-200 bg-white text-primary shadow-sm"
: "text-slate-500 hover:text-slate-700"
}`}
>
<Boxes className="h-3.5 w-3.5" />
应用渲染
</button>
<button
type="button"
onClick={() => setRenderMode("html")}
disabled={!dashboardHtml}
title={dashboardHtml ? "用自包含 HTML 大屏渲染(与新窗口/下载一致)" : "尚无已生成的 HTML,先完成一次绘制"}
className={`flex items-center gap-1 rounded-lg px-2.5 py-1 text-[10.5px] font-bold transition disabled:cursor-not-allowed disabled:opacity-40 ${
renderMode === "html"
? "border border-slate-200 bg-white text-primary shadow-sm"
: "text-slate-500 hover:text-slate-700"
}`}
>
<Code2 className="h-3.5 w-3.5" />
网页渲染
</button>
</div>
<button
type="button"
onClick={() => setView("chat")}
title="回到生成过程,加载聊天记录继续问答修改"
className="flex items-center gap-1 rounded-xl border border-slate-200 bg-white px-3 py-1.5 text-[10.5px] font-bold text-slate-700 transition hover:bg-slate-50"
>
<Pencil className="h-3.5 w-3.5 text-slate-500" />
编辑
</button>
<button
type="button"
onClick={handleOpenNewWindow}
title={renderMode === "html" ? "新标签页打开自包含 HTML 大屏" : "新标签页打开应用内 React 大屏(全屏)"}
className="flex items-center gap-1 rounded-xl border border-slate-200 bg-white px-3 py-1.5 text-[10.5px] font-bold text-slate-700 transition hover:bg-slate-50"
>
<ExternalLink className="h-3.5 w-3.5 text-slate-500" />
新窗口打开
</button>
</>
) : (
hasResult && (
<button
type="button"
onClick={() => setView("page")}
title="查看已生成的大屏页面"
className="flex items-center gap-1 rounded-xl border border-slate-200 bg-white px-3 py-1.5 text-[10.5px] font-bold text-slate-700 transition hover:bg-slate-50"
>
<LayoutPanelTop className="h-3.5 w-3.5 text-slate-500" />
查看大屏
</button>
)
)}
</div>
</div>
{/* 主体 */}
{loading ? (
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-slate-500">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
正在加载任务大屏…
</div>
) : view === "page" ? (
renderMode === "html" && htmlBlobUrl ? (
<iframe
title="任务研讨大屏"
src={htmlBlobUrl}
className="min-h-0 w-full flex-1 border-0 bg-white"
sandbox="allow-scripts allow-popups allow-downloads allow-same-origin"
/>
) : (
<div className="min-h-0 flex-1 overflow-auto">
<DashboardApp data={dashboardData} />
</div>
)
) : (
<div className="min-h-0 flex-1 p-3">
<section className="h-full min-h-0 overflow-hidden rounded-2xl border border-slate-200/60 bg-white">
<Step2SandboxLayout
activeThreadId={activeArtifactThreadId}
isStreaming={isStreaming}
seatMessages={activeArtifactThreadId ? seatStubMessages[activeArtifactThreadId] : undefined}
>
<DashboardChatPanel
phase={phase}
dialogues={dialogues}
busy={busy}
error={dashboard.error}
onAsk={ask}
onRedraw={redraw}
onPause={abort}
onOpenPreview={openDashboardPreview}
hasResult={hasResult}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
subtitle="分析任务原始数据,提炼为大屏结构化数据(右侧实时显示抽取过程)"
doneHint="数据已就绪,点右上「查看大屏」查看。"
/>
</Step2SandboxLayout>
</section>
</div>
)}
</div>
);
}
@@ -1012,8 +1012,9 @@ export default function RoundtablePlanningPage() {
.filter((g) => g.length > 0),
[orchestrationPlan],
);
// taskId/深链模式:进第三步生成 md 后自动续跑结构化输出(流程图)。普通手动会商不自动。
const autoSummaryFlow = !!prefillRef.current?.taskId;
// 第三步首次生成报告后即**自动**续跑结构化输出(流程图)——不再限 taskId 深链,
// 任何会商进第三步首次出报告都自动出流程图(用户 2026-06-17 要求)。
const autoSummaryFlow = true;
const summaryFlow = useStep3Summary({
intentReady: step1.intentReady,
@@ -2500,22 +2501,31 @@ function RoundtableEntryRail({
该业务链暂无智能体席位
</p>
) : (
<div className="flex flex-col items-center gap-1.5">
{stageGroups.map((stage, si) => (
<div
key={si}
className="flex flex-col items-center gap-1"
>
{si > 0 && (
<ChevronDown className="h-3 w-3 text-neutral-300" />
)}
{stage.length > 1 && (
<span className="rounded-full bg-primary/5 px-1.5 text-[8.5px] font-semibold text-primary">
并行 {stage.length}
</span>
)}
<div className="flex flex-wrap items-center justify-center gap-1.5">
{stage.map((seat, sj) => {
<div className="flex flex-row flex-wrap items-center justify-center gap-1">
{stageGroups.flatMap((stage, si) => {
// 横向编排:阶段从左到右排列,阶段之间用 → 串联;并行阶段的
// 席位在该列内竖向堆叠(宽度可控,符合「同层并行·层间串行」示意)。
const items: React.ReactNode[] = [];
if (si > 0) {
items.push(
<ChevronRight
key={`arrow-${si}`}
className="h-3 w-3 shrink-0 text-neutral-300"
/>,
);
}
items.push(
<div
key={`stage-${si}`}
className="flex flex-col items-center gap-1"
>
{stage.length > 1 && (
<span className="rounded-full bg-primary/5 px-1.5 text-[8.5px] font-semibold text-primary">
并行 {stage.length}
</span>
)}
<div className="flex flex-col items-center gap-1.5">
{stage.map((seat, sj) => {
const palette =
SEAT_CHIP_PALETTE[
seatColorIndex++ %
@@ -2550,10 +2560,12 @@ function RoundtableEntryRail({
</div>
</div>
);
})}
</div>
</div>
))}
})}
</div>
</div>,
);
return items;
})}
</div>
)}
</div>
@@ -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.
@@ -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)
@@ -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)
@@ -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,
@@ -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)"
)
@@ -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 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。
@@ -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 自动生成」之类的水印字样
@@ -0,0 +1,3 @@
description: 圆桌大屏多页数据智能体,读取各席位的最终交付与总控共识,逐席位分析并提炼成一份大屏展示用的结构化数据(report-json)。本身不调研、不联网、不派活、不写文件、不输出 HTML,只做分析归纳与结构化抽取。
id: roundtable-dashboard
name: 大屏多页数据
@@ -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。
@@ -0,0 +1,3 @@
description: 圆桌结构化抽取智能体。读取「方案总结报告」或各席位交付内容,分析其层级结构,抽取成可交互流程图的结构化数据(flow-json);也负责按指令修改流程图、校验修复流程图。专职「内容→结构」,不写报告、不联网、不画 HTML。后续可在此智能体上配置「入库技能」,让它分析出结构化结果后直接入库。
id: roundtable-structure
name: 结构化抽取
@@ -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)
@@ -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) 拼成 `<think>…</think>正文` 增量逐帧吐,前端实时显示)
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>"
think_open = True
out += r
if c:
if think_open:
out += "</think>"
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": "</think>"})
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>"
think_open = True
out += r
if c:
if think_open:
out += "</think>"
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": "</think>"})
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": "意图识别失败"}
@@ -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:
+12 -12
View File
@@ -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
@@ -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
@@ -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)
@@ -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),
)
@@ -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)
@@ -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",