From fca39eeaeeaad3d6956b57f536dcacb1c0fdf3af Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Wed, 17 Jun 2026 19:42:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20taskId=20=E6=B7=B1=E9=93=BE=E4=BC=9A?= =?UTF-8?q?=E5=95=86=E6=8C=89=E4=BB=BB=E5=8A=A1=E9=9A=94=E7=A6=BB=E8=8D=89?= =?UTF-8?q?=E7=A8=BF=20+=20=E7=AC=AC=E4=B8=89=E6=AD=A5=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=8C=96=E6=B5=81=E7=A8=8B=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 深链 ?taskId=&goPath=*-A 透传 taskId 进会商,按 taskId 隔离存取草稿(一个 taskId 多条、可新建); 后端新增 GET /api/roundtable-drafts/by-task/{id} 列表 + RoundtableJobRepository.status_map_by_drafts 富化。 - 第三步生成 md 报告后自动结构化输出流程图(仅 taskId 模式):有报告基于报告、无报告基于各席位交付, 整段内联数据来源逐条抽取;层级优先用业务链总控提示词写明的层级,否则用 stages 的 goal。 - 流程图节点加 group(层级名)标签 + detail(条目名称+理由);强 prompt 压制 coordinatorPrompt 报告锚定, 禁阶段编号节点;FLOW_STRUCTURE_RULE 精确画法(根=任务、每层每条成节点、按对应关系串分支、排除汇总段落)。 - flow-graph 解析容错:JSON.parse 失败退回正则逐字段抽取,兼容裸换行/尾逗号,避免节点被整体丢弃。 - 修:重开第二步后第三步基于新一轮分析重新生成(复位 step3 闸门 + 中断旧三流)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- frontend-web/src/core/auth/task-deeplink.ts | 10 +- .../src/roundtable-planning/api/drafts.ts | 10 + .../components/FlowGraphPanel.tsx | 4 +- .../components/Step3Panel.tsx | 30 +-- .../components/Step3SummaryMode.tsx | 18 +- .../hooks/useDraftPersistence.ts | 6 +- .../hooks/useRoundtableDrafts.ts | 11 +- .../hooks/useStep3Summary.ts | 174 +++++++++++++++++- .../lib/flow-graph.test.ts | 56 ++++++ .../src/roundtable-planning/lib/flow-graph.ts | 83 ++++++++- .../pages/RoundtablePlanningPage.tsx | 30 ++- offline-backend-20260512/backend/CLAUDE.md | 2 +- .../roundtable-summary/SOUL.md | 2 + .../app/gateway/routers/roundtable_drafts.py | 26 +++ .../persistence/roundtable_drafts/sql.py | 15 ++ .../persistence/roundtable_jobs/sql.py | 30 +++ 17 files changed, 472 insertions(+), 37 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 49d5b2ee..91f9c7b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE - **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/` - **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js) -- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A (resolved via `/api/business-mapping`, opening message in `sessionStorage.pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management//{roundtable,agent/:agent_id/chats/:thread_id}` in `StrategyRoutes.tsx` (`` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. The agent chat is rendered via `TaskEmbeddedAgentChat`, which passes `AgentChatPage`'s `chatBasePath` prop so the post-send URL rewrite + new-chat button stay in the task-management context (instead of the standalone `/agents/...` route). Any failure (no consumer URL / fetch error / unmapped goPath / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`. +- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A (resolved via `/api/business-mapping`, opening message in `sessionStorage.pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **per-task draft isolation** — drafts save/list/restore by `task_id` (one taskId = many records, shared across users; backend `GET /api/roundtable-drafts/by-task/{id}` + `/latest`); the history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management//{roundtable,agent/:agent_id/chats/:thread_id}` in `StrategyRoutes.tsx` (`` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. The agent chat is rendered via `TaskEmbeddedAgentChat`, which passes `AgentChatPage`'s `chatBasePath` prop so the post-send URL rewrite + new-chat button stay in the task-management context (instead of the standalone `/agents/...` route). Any failure (no consumer URL / fetch error / unmapped goPath / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`. ### Configuration diff --git a/frontend-web/src/core/auth/task-deeplink.ts b/frontend-web/src/core/auth/task-deeplink.ts index 54937c92..89fb37ac 100644 --- a/frontend-web/src/core/auth/task-deeplink.ts +++ b/frontend-web/src/core/auth/task-deeplink.ts @@ -128,10 +128,14 @@ function buildOpeningText(detail: CopTaskDetail): string { /** * 据 goPath + 任务详情解析跳转目标。 - * - 多智能体会商:返回带 ``roundtablePrefill``(businessCode 让会商页自动选用配置的业务链条)的 state。 + * - 多智能体会商:返回带 ``roundtablePrefill``(businessCode 让会商页自动选用配置的业务链条; + * taskId 让会商页按任务隔离存取草稿/聊天记录)的 state。 * - 单智能体问答:解析「配置管理」里该业务配置的智能体;把开场信息写入 * ``sessionStorage.pendingAgentMessage``(AgentChatPage 自动发出)。 * 无法识别 goPath / 未配置智能体时返回 null(调用方回退到默认首页)。 + * + * @param taskId 深链 URL 的原始 ``?taskId=``(外部任务 id,用于按任务隔离草稿;与 + * consumer 返回的 detail.id 区分——后者不保证等于外部任务 id)。 */ export async function resolveTaskDeeplinkTarget( goPath: string, @@ -147,7 +151,8 @@ export async function resolveTaskDeeplinkTarget( clearTaskContext(); if (parsed.roundtable) { - // 多智能体会商:嵌进对应业务的「任务管理」布局;businessCode 让会商页自动选用配置的业务链条。 + // 多智能体会商:嵌进对应业务的「任务管理」布局;businessCode 让会商页自动选用配置的业务链条; + // taskId 让会商页按任务隔离存取草稿(一个 taskId 多条记录),并触发第三步自动结构化输出。 return { path: `${taskBase}/roundtable`, state: { @@ -155,6 +160,7 @@ export async function resolveTaskDeeplinkTarget( taskTitle: detail.taskName, taskGoal: detail.taskContent, businessCode: parsed.businessCode, + taskId: taskId || undefined, autoStart: true, }, }, diff --git a/frontend-web/src/roundtable-planning/api/drafts.ts b/frontend-web/src/roundtable-planning/api/drafts.ts index e6350223..6b14bdee 100644 --- a/frontend-web/src/roundtable-planning/api/drafts.ts +++ b/frontend-web/src/roundtable-planning/api/drafts.ts @@ -309,6 +309,16 @@ export async function getLatestDraftByTask(taskId: string): Promise { + const res = await apiFetch(`${BASE()}/by-task/${encodeURIComponent(taskId)}`, { method: "GET" }); + const data = await readJson<{ drafts: ServerDraftMeta[] }>(res, "list drafts by task"); + return (data.drafts ?? []).map(toMeta); +} + export async function createDraft(payload: DraftCreatePayload): Promise { const res = await apiFetch(BASE(), { method: "POST", diff --git a/frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx b/frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx index bcad46e8..f19cf47b 100644 --- a/frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx +++ b/frontend-web/src/roundtable-planning/components/FlowGraphPanel.tsx @@ -85,7 +85,7 @@ function FlowCardNode({ data }: NodeProps) {
- {meta.label} + {node.group?.trim() || meta.label}
{node.label}
@@ -347,7 +347,7 @@ function NodeDetailModal({ className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold" style={{ background: "#ffffffaa", color: meta.text }} > - {meta.label} + {node.group?.trim() || meta.label}

{node.label} diff --git a/frontend-web/src/roundtable-planning/components/Step3Panel.tsx b/frontend-web/src/roundtable-planning/components/Step3Panel.tsx index 794522f3..efd3f88e 100644 --- a/frontend-web/src/roundtable-planning/components/Step3Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3Panel.tsx @@ -19,6 +19,7 @@ import type { ReportIntent, UseStep3ReportReturn } from "@/roundtable-planning/h import type { UseStep3SummaryReturn } from "@/roundtable-planning/hooks/useStep3Summary"; import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent"; import { stripReasoning } from "@/roundtable-planning/lib/reasoning"; +import { useMemo } from "react"; export interface Step3PanelProps { threadIds: ThreadIdMap | null; @@ -81,18 +82,25 @@ export function Step3Panel(props: Step3PanelProps) { seatStubMessages, } = props; - const seatDeliveries = step2RoundtableDialogues - .filter((d) => d.confidence === "子智能体交付") - .map((d) => ({ - sender: String(d.sender ?? ""), - avatarType: String(d.avatarType ?? "blue"), - // 交付内容只保留模型生成的主体输出,剥离 `...` 推理过程 - // —— 既用于渲染各席位交付卡片,也会喂给总结/报告模型,避免推理噪声污染。 - content: stripReasoning(String(d.content ?? "")), - })); + const seatDeliveries = useMemo( + () => + step2RoundtableDialogues + .filter((d) => d.confidence === "子智能体交付") + .map((d) => ({ + sender: String(d.sender ?? ""), + avatarType: String(d.avatarType ?? "blue"), + // 交付内容只保留模型生成的主体输出,剥离 `...` 推理过程 + // —— 既用于渲染各席位交付卡片,也会喂给总结/报告模型,避免推理噪声污染。 + content: stripReasoning(String(d.content ?? "")), + })), + [step2RoundtableDialogues], + ); - const seatNameMap = Object.fromEntries( - Object.entries(agentNameToRole).map(([k, v]) => [k, v.displayName]), + const seatNameMap = useMemo( + () => Object.fromEntries( + Object.entries(agentNameToRole).map(([k, v]) => [k, v.displayName]), + ), + [agentNameToRole], ); return ( diff --git a/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx b/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx index d0f5b2c4..94467fa5 100644 --- a/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3SummaryMode.tsx @@ -159,9 +159,11 @@ export function Step3SummaryMode({ if (!flowGraph || flowGraph.graph.nodes.length === 0) return; if (flowAutoOpenedRef.current) return; flowAutoOpenedRef.current = true; - // 没开沙箱 → 自动打开流程图画布;沙箱开着 → 不抢占,只露出切换按钮。 - if (!artifactsOpen) setFlowView(true); - }, [flowGraph, artifactsOpen]); + // taskId/深链模式:结构化输出是本模式的主交付 → 流程图一出现就自动聚焦画布 + // (即便此前 md 沙箱已自动打开,也切到流程图)。 + // 普通模式:没开沙箱才自动打开;沙箱开着不抢占,只露出切换按钮。 + if (summary.autoFlow || !artifactsOpen) setFlowView(true); + }, [flowGraph, artifactsOpen, summary.autoFlow]); // 沙箱里已写出的 md 报告(流程图画布上提供「MD 报告」切回按钮的锚点)。 const mdArtifact = useMemo(() => { @@ -575,6 +577,16 @@ function AgentProgressPanel({ }) { const scrollRef = useRef(null); const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]); + // 新一轮气泡出现(如:md 报告完成后自动续跑的结构化输出轮、用户发问)→ 强制滚到底, + // 让用户看到新阶段(修「调用结构化输出时不自动下滑」)。仅在气泡**条数**增加时触发, + // 流式增量内容仍由 useAutoScroll 的「在底部才跟随」逻辑处理,不抢用户向上翻阅。 + const prevDialogueCountRef = useRef(dialogues.length); + useEffect(() => { + if (dialogues.length > prevDialogueCountRef.current) { + scrollToBottom(); + } + prevDialogueCountRef.current = dialogues.length; + }, [dialogues.length, scrollToBottom]); // 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。 const [localSettings] = useLocalSettings(); const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled; diff --git a/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts b/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts index 599b62cb..285458ad 100644 --- a/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts +++ b/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts @@ -26,6 +26,7 @@ import { getDraft, getLatestDraftByTask, listDrafts, + listDraftsByTask, updateDraft, type DraftRecord, type DraftStep2Snapshot, @@ -190,7 +191,10 @@ export function useDraftPersistence( const refreshList = useCallback(async () => { try { - const metas = await listDrafts(); + // taskId 模式:历史下拉只列该任务的草稿(一个 taskId 多条记录、跨用户共享); + // 普通模式:列当前用户全部草稿。 + const tid = taskIdRef.current; + const metas = tid ? await listDraftsByTask(tid) : await listDrafts(); setDrafts( metas.map((m) => ({ id: m.id, diff --git a/frontend-web/src/roundtable-planning/hooks/useRoundtableDrafts.ts b/frontend-web/src/roundtable-planning/hooks/useRoundtableDrafts.ts index ad8fa61c..08166b03 100644 --- a/frontend-web/src/roundtable-planning/hooks/useRoundtableDrafts.ts +++ b/frontend-web/src/roundtable-planning/hooks/useRoundtableDrafts.ts @@ -11,25 +11,30 @@ import { useCallback, useState } from "react"; import { deleteDraft, listDrafts, + listDraftsByTask, updateDraft, type DraftMeta, } from "@/roundtable-planning/api/drafts"; -export function useRoundtableDrafts() { +/** + * @param taskId 外部任务 id(深链 taskId 模式)。非空时只列该任务的草稿 + * (一个 taskId 多条、跨用户共享);否则列当前用户全部草稿。 + */ +export function useRoundtableDrafts(taskId?: string | null) { const [drafts, setDrafts] = useState([]); const [loading, setLoading] = useState(false); const refresh = useCallback(async () => { setLoading(true); try { - const data = await listDrafts(); + const data = taskId ? await listDraftsByTask(taskId) : await listDrafts(); setDrafts(data); } catch { // 历史记录非关键,静默忽略 } finally { setLoading(false); } - }, []); + }, [taskId]); const rename = useCallback(async (id: string, title: string) => { // 只发 title —— 与页面自动保存的「不带 title」互补,是改标题的唯一入口。 diff --git a/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts b/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts index c7f4ed85..78e0f563 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep3Summary.ts @@ -57,6 +57,17 @@ interface UseStep3SummaryOptions { selectedModel: string; /** 业务链写给总控的编排提示 —— 作为最高优先级口径注入总结智能体。 */ coordinatorPrompt?: string | null; + /** + * 业务链条层级(取自 stages 的「本阶段目标」goal,有序)—— taskId 深链模式下 + * 自动结构化输出时,作流程图的层级骨架(任务 → 层级1 → 层级2 → …)。 + * 例:链层级 目的→行为体→驱动因素 → 流程图 任务→目的→行为体→驱动因素。 + */ + chainLevels?: string[]; + /** + * taskId/深链模式:生成 md 报告后**自动**续跑一轮结构化输出(流程图)。 + * 普通手动会商为 false —— 结构化输出仍按需(用户输入框打「流程图」才出)。 + */ + autoFlow?: boolean; /** 草稿里已存的上次总结结果 —— 有则进入时恢复展示,不重新生成。 */ savedSummary?: DraftStep3SummarySnapshot | null; /** 一次总结成功完成时回调(传出 md/thread/model/时间/收尾语),供上层存草稿。 */ @@ -66,6 +77,8 @@ interface UseStep3SummaryOptions { export interface UseStep3SummaryReturn { phase: SummaryPhase; error: string | null; + /** taskId/深链模式:md 报告生成后自动续跑流程图(组件据此让流程图画布自动聚焦)。 */ + autoFlow: boolean; summaryThreadId: string | null; dialogues: Step2Dialogue[]; seatStubMessages: Record; @@ -152,6 +165,117 @@ function buildMaterials(opts: { return parts.join("\n"); } +/** + * flow-json 形状契约(内联)。roundtable-summary 的 runtime SOUL 为空,模型拿不到契约, + * 所有要画流程图的 prompt 都得带上它,否则模型产出的 JSON 可能解析不出节点。 + */ +const FLOW_JSON_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":"该目的的完整内容:名称 + 理由全文","from":["n1"]},\n' + + ' {"id":"n3","label":"行为体核心短语","type":"role","group":"行为体","detail":"该行为体的完整内容:名称 + 理由全文","from":["n2"]}\n' + + "]}\n" + + "```\n" + + "字段:`id`(n1/n2… 唯一短串)、`label`(≤12 字,节点标题,取条目核心短语)、" + + "`type`(取 start/end/stage/task/decision/risk/deliverable/role 之一,仅决定配色)、" + + "**`group`(该节点所在的层级名称,如 任务 / 目的 / 行为体 / 预期行为 / 驱动因素 / 叙事——画布按它给节点打标签,必须填)**、" + + "**`detail`(该条目的名称 + 理由 / 说明,点击节点时展示——要把要点表达完整,但控制在约 2–4 句,**不必**逐字照抄报告整段)**、" + + "`from`(前驱节点 id 列表,只引用**前面已出现**的节点)。" + + "节点按先后排列、输出**全量**数据、全程简体中文。"; + +/** + * 「本轮只画流程图、不写报告」的强约束。关键:有的业务链 coordinatorPrompt 自身写着 + * 「输出结构化结论 / 按阶段产出报告 / 最终方案总结报告按层级结构输出」,模型在同一线程里 + * 会被它锚定,把用户的「结构化输出」当成「继续写结构化的文字报告」→ 产出 Markdown 而非 + * flow-json(实测 deepseek-chat 在这类链上必现)。所以必须显式声明本轮压过那些报告措辞。 + */ +const FLOW_ONLY_RULE = + "【硬性·本轮只画流程图】本轮唯一产物是一张**流程图**(由节点 + 连线组成的图),不是文字、不是报告、" + + "不是章节、不是结论清单、不是 Markdown:①**不要**调用 write_file、**不要**写或改任何 .md 文件;" + + "②回复里**不要**再出现任何报告正文 / 章节标题(如「## 四…」)/ 编号结论,正文最多一句话说明;" + + "③**忽略**此前关于「方案总结报告」「按层级结构输出报告」「输出结构化结论」「分阶段产出」的一切措辞——" + + "那些是给上一轮文字报告用的,本轮一律不适用;④在回复**最末尾**输出一个**完整**的 ```flow-json 代码块。"; + +/** + * 流程图的层级画法(修「结构画错」):直接从已有报告/席位交付里**提取实体**, + * 根=任务名称,每层取具体条目当节点,按报告里的对应关系串成分支,排除汇总/阶段名等无关段落。 + */ +const FLOW_STRUCTURE_RULE = + "【流程图画法·严格遵守】\n" + + "① 根节点(n1) = 本次任务本身(即报告「任务总览」里的任务名称 / 目标),label 用任务的简短名称(≤12 字)," + + "**不要**用「任务」「目标」这种泛称当 label;其 `group` 填「任务」;\n" + + "② 从第二层起按层级顺序逐层展开:每个层级在报告/交付里通常列了**多条具体条目**" + + "(如『目的』层有 目的1 / 目的2 / 目的3,『行为体』层有各自对应的行为体,后面还有 预期行为·驱动因素、叙事…)," + + "把**每一条具体条目各画成一个节点**,label 取该条目核心短语(≤12 字);**不要**把一整层合并成一个笼统节点;\n" + + "③ **每个节点的 `group` 必须填它所在层级的名称**(任务 / 目的 / 行为体 / 预期行为 / 驱动因素 / 叙事…,取自报告里该层级的真实名称)——画布据此给节点打分类标签;\n" + + "④ **每个节点的 `detail`** 放该条目的名称 + 理由 / 说明,把要点表达完整、约 2–4 句即可(不必逐字照抄报告整段);" + + "**`detail` 必须写成一行**——内部不要出现真实换行,需要分隔就用「;」或「/」,避免 JSON 里出现裸换行;\n" + + "⑤ 连边严格按报告里的**对应关系**串成分支:根任务 → 各『目的』节点;每个『目的』→ 它对应的『行为体』节点;" + + "每个『行为体』→ 它对应的『预期行为·驱动因素』节点 → 对应的『叙事』节点……每一组各成一条自上而下的分支" + + "(报告里有几组就有几条分支,组与组之间不要混连);\n" + + "⑦ **务必把每个层级的每一条都输出成节点、不要漏层、不要只输出开头一两个节点就停**——" + + "典型规模约 13–16 个节点(如 任务1 + 目的3 + 行为体3 + 预期行为·驱动因素3 + 叙事3);\n" + + "⑥ **只画**这些层级实体节点;**不要**把『综合分析』『核心洞察』『分层传播策略 / 执行路线 / 建议』『结论』" + + "以及『阶段①②③④』等章节名 / 阶段名 / 汇总段落画成节点。"; + +/** 把各席位交付拼成一段内联材料(无报告时作流程图的数据来源)。 */ +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"); +} + /** 首轮总结 prompt。`flowMode`=编排提示里明确要求了结构化输出/流程图 → 跳过 md 文件,直出流程图。 */ function buildSummaryPrompt(opts: { intentReady: ReportIntent | null; @@ -203,10 +327,15 @@ function buildSummaryPrompt(opts: { * 强调「全量 flow-json + 不写文件」——画图数据必须完整自洽,否则前端增量解析会画出半张图。 */ const FLOW_ASK_INSTRUCTION = - "(系统指令:用户要求结构化输出/流程图——本轮不要写或修改任何 .md 报告文件," + - "在回复正文给出简短的文字说明/总结(用户未指定结构时,默认对各席位交付内容逐席位格式化输出:" + - "席位名→核心结论→关键交付要点),并在回复最末尾输出一个**完整**的 ```flow-json 代码块" + - "(按你职责设定的「结构化流程数据」契约;输出全量数据而非增量)。)"; + "(系统指令:用户要求结构化输出 / 流程图——这里的「结构化输出」指的是画一张**流程图**," + + "不是写结构化的文字报告。**有报告就直接基于报告提取层级条目**(报告已总结过各席位交付,不要重复总结);" + + "没有报告则基于各席位交付。\n" + + FLOW_ONLY_RULE + + "\n" + + FLOW_STRUCTURE_RULE + + "\n" + + FLOW_JSON_SHAPE + + ")"; /** * 上一轮主产物是流程图、但本条消息没显式提「结构化/流程图」时的意图引导指令。 @@ -299,6 +428,8 @@ export function useStep3Summary( consensusText, selectedModel, coordinatorPrompt, + chainLevels, + autoFlow, savedSummary, onSummarySaved, } = options; @@ -901,7 +1032,8 @@ export function useStep3Summary( try { // 编排提示里明确要求了结构化输出/流程图 → 首轮就走流程图模式(不写 md,直出 flow-json)。 - const flowMode = detectFlowIntent(coordinatorPrompt); + // 但 autoFlow(taskId/深链)模式要「先 md 再结构化输出」,故首轮强制走 md(忽略 detectFlowIntent)。 + const flowMode = !autoFlow && detectFlowIntent(coordinatorPrompt); flowActiveRef.current = flowMode; // 流式结束后会按真实产物校正 const prompt = buildSummaryPrompt({ intentReady, @@ -911,6 +1043,35 @@ export function useStep3Summary( flowMode, }); await streamOnce({ bubbleId, prompt, aborter, flowRound: flowMode }); + + // taskId/深链模式:报告生成完,**自动**续跑一轮结构化输出(流程图)。 + // 数据来源:有报告则内联报告全文、否则内联各席位交付(buildAutoFlowPrompt 处理)。 + // 故触发条件放宽为「有报告 **或** 有席位交付」——保证「无报告也能据席位交付出图」。 + // 复用同一真线程,与手动「结构化输出」走同一渲染链路。 + 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, + }); + } } catch (err) { if (aborter.signal.aborted) return; const msg = err instanceof Error ? err.message : String(err); @@ -926,6 +1087,8 @@ export function useStep3Summary( seatDeliveries, consensusText, coordinatorPrompt, + chainLevels, + autoFlow, appendStreamingDialogue, finalizeDialogue, streamOnce, @@ -1166,6 +1329,7 @@ export function useStep3Summary( return { phase, error, + autoFlow: !!autoFlow, summaryThreadId, dialogues, seatStubMessages, diff --git a/frontend-web/src/roundtable-planning/lib/flow-graph.test.ts b/frontend-web/src/roundtable-planning/lib/flow-graph.test.ts index 5604d2e5..553b4e18 100644 --- a/frontend-web/src/roundtable-planning/lib/flow-graph.test.ts +++ b/frontend-web/src/roundtable-planning/lib/flow-graph.test.ts @@ -77,6 +77,62 @@ test("extractFlowGraph:无 fence / 无完整节点返回 null", () => { assert.equal(extractFlowGraph('```flow-json\n{"nodes":[{"id":"a"'), null); }); +test("extractFlowGraph:group 层级名解析(兼容 group/level)", () => { + const text = [ + "```flow-json", + '{"nodes":[', + '{"id":"n1","label":"暑期文旅","type":"start","group":"任务"},', + '{"id":"n2","label":"情绪管理","type":"task","level":"目的","from":["n1"]}', + "]}", + "```", + ].join("\n"); + const g = extractFlowGraph(text); + assert.ok(g); + assert.equal(g.nodes[0].group, "任务"); + assert.equal(g.nodes[1].group, "目的"); // level 别名也认 +}); + +test("extractFlowGraph:容错——detail 含裸换行的非法 JSON 节点不被整个丢弃", () => { + // 模型常在 JSON 字符串里吐真实换行(非法 JSON):n2 的 detail 跨行, + // 旧逻辑会 JSON.parse 失败把 n2/n3 整个丢掉,只剩 n1(即「只出两个节点」bug)。 + const text = [ + "```flow-json", + '{"title":"T","nodes":[', + '{"id":"n1","label":"任务X","type":"start","group":"任务"},', + '{"id":"n2","label":"目的A","type":"task","group":"目的","detail":"名称:目的A', + '理由:跨了行的说明文字","from":["n1"]},', + '{"id":"n3","label":"行为体A","type":"role","group":"行为体","detail":"名称:行为体A;理由:单行说明","from":["n2"]}', + "]}", + "```", + ].join("\n"); + const g = extractFlowGraph(text); + assert.ok(g); + // 三个节点都救回来(含裸换行的 n2 不再丢失)。 + assert.equal(g.nodes.length, 3); + assert.equal(g.nodes[1].group, "目的"); + assert.ok((g.nodes[1].detail ?? "").includes("目的A")); + // 连线也按 from 串起来。 + assert.deepEqual(g.edges, [ + { from: "n1", to: "n2" }, + { from: "n2", to: "n3" }, + ]); +}); + +test("extractFlowGraph:容错——尾逗号的非法 JSON 节点仍解析", () => { + const text = [ + "```flow-json", + '{"nodes":[', + '{"id":"a","label":"A","type":"task","group":"目的",},', // 尾逗号 → JSON.parse 失败 + '{"id":"b","label":"B","type":"task","from":["a"]}', + "]}", + "```", + ].join("\n"); + const g = extractFlowGraph(text); + assert.ok(g); + assert.equal(g.nodes.length, 2); + assert.equal(g.nodes[0].group, "目的"); +}); + test("extractFlowGraph:兼容独立 edges 数组(source/target 别名)", () => { const text = [ "```flow-json", diff --git a/frontend-web/src/roundtable-planning/lib/flow-graph.ts b/frontend-web/src/roundtable-planning/lib/flow-graph.ts index b9e1f315..4bc14886 100644 --- a/frontend-web/src/roundtable-planning/lib/flow-graph.ts +++ b/frontend-web/src/roundtable-planning/lib/flow-graph.ts @@ -33,6 +33,12 @@ export interface FlowGraphNode { label: string; type: FlowNodeType; detail?: string; + /** + * 该节点所在的**层级名称**(如 任务 / 目的 / 行为体 / 预期行为 / 驱动因素 / 叙事)。 + * 由模型在 flow-json 里给出,画布的分类标签优先显示它(缺省回退到 type 的固定中文名)。 + * 让流程图按业务链层级标注,而非只显示通用的 开始/任务/角色 等 type 名。 + */ + group?: string; } export interface FlowGraphEdge { @@ -66,10 +72,66 @@ const VALID_TYPES = new Set(Object.keys(FLOW_NODE_TYPE_META)); export const FLOW_FENCE_OPEN = "```flow-json"; +/** + * 容错解析单个对象切片(JSON.parse 失败时回退)——模型输出不可控,常在 JSON 字符串里吐 + * **裸换行 / 尾逗号 / 中文标点**等非法内容,导致整对象 JSON.parse 失败、节点被整个丢弃 + * (表现为「流程图只剩前两个节点」)。这里用正则逐字段抽取,最大限度把节点 / 边救回来。 + * 字符串字段正则 `(?:\\.|[^"\\])*` 能跨裸换行匹配(换行既不是 `"` 也不是 `\`)。 + */ +function looseParseObject(slice: string): Record | null { + const pickStr = (...keys: string[]): string | undefined => { + for (const k of keys) { + const m = new RegExp(`"${k}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(slice); + if (m) { + const raw = m[1]; + try { + return JSON.parse(`"${raw.replace(/\r?\n/g, "\\n")}"`) as string; + } catch { + return raw.replace(/\\n/g, "\n").replace(/\\"/g, '"'); + } + } + } + return undefined; + }; + const obj: Record = {}; + const id = pickStr("id"); + if (id) obj.id = id; + const label = pickStr("label", "name"); + if (label) obj.label = label; + const type = pickStr("type"); + if (type) obj.type = type; + const group = pickStr("group", "level", "tier", "layer"); + if (group) obj.group = group; + const detail = pickStr("detail", "description"); + if (detail) obj.detail = detail; + // 边的简单形态(独立 edges 数组里):from/to 为字符串。 + const toStr = pickStr("to", "target"); + if (toStr) obj.to = toStr; + // 节点的 from 为数组(["n1"] 或 [{"id":"n1","label":"是"}]);先按数组抽,抽不到再当字符串。 + const fm = /"(?:from|prev)"\s*:\s*\[([\s\S]*?)\]/.exec(slice); + if (fm) { + const inner = fm[1]; + const ids: unknown[] = []; + const objRe = /\{[^{}]*?"id"\s*:\s*"([^"]+)"[^{}]*?\}/g; + let om: RegExpExecArray | null; + while ((om = objRe.exec(inner)) !== null) ids.push({ id: om[1] }); + if (ids.length === 0) { + const sRe = /"([^"]+)"/g; + let sm: RegExpExecArray | null; + while ((sm = sRe.exec(inner)) !== null) ids.push(sm[1]); + } + if (ids.length > 0) obj.from = ids; + } else { + const fromStr = pickStr("from", "source"); + if (fromStr) obj.from = fromStr; + } + return Object.keys(obj).length > 0 ? obj : null; +} + /** * 平衡括号扫描:从 `key`(如 `"nodes"`)后面的 `[` 开始,把数组里**已完整闭合**的 - * 顶层 `{…}` 逐个 JSON.parse 收集;跳过字符串内的括号与转义;数组未闭合(流式半截) - * 时收集到哪算哪,截断的尾对象忽略。 + * 顶层 `{…}` 逐个解析收集(JSON.parse 失败则用 looseParseObject 容错回退);跳过字符串内的 + * 括号与转义;数组未闭合(流式半截)时收集到哪算哪,截断的尾对象忽略。 */ function collectObjects(text: string, key: string): Record[] { const keyIdx = text.indexOf(key); @@ -101,14 +163,18 @@ function collectObjects(text: string, key: string): Record[] { if (ch === "}") { depth--; if (depth === 0 && objStart >= 0) { + const objSlice = text.slice(objStart, i + 1); + let parsed: Record | null = null; try { - const parsed = JSON.parse(text.slice(objStart, i + 1)) as unknown; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - out.push(parsed as Record); + const j = JSON.parse(objSlice) as unknown; + if (j && typeof j === "object" && !Array.isArray(j)) { + parsed = j as Record; } } catch { - /* 截断/非法对象:跳过 */ + // 非法 JSON(裸换行/尾逗号等)→ 容错正则抽取,别整节点丢掉。 + parsed = looseParseObject(objSlice); } + if (parsed) out.push(parsed); objStart = -1; } continue; @@ -130,7 +196,10 @@ function toNode(raw: Record): FlowGraphNode | null { const rawType = asString(raw.type).toLowerCase(); const type = (VALID_TYPES.has(rawType) ? rawType : "task") as FlowNodeType; const detail = asString(raw.detail) || asString(raw.description) || undefined; - return { id, label, type, detail }; + // 层级名(任务/目的/行为体/…):兼容 group / level / tier / layer 几种键名。 + const group = + asString(raw.group) || asString(raw.level) || asString(raw.tier) || asString(raw.layer) || undefined; + return { id, label, type, detail, group }; } /** 节点 `from` 字段(前驱列表,元素是 "n1" 或 {id:"n1",label:"是"})→ 入边列表。 */ diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index a1cfe23a..41796cbd 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -751,6 +751,12 @@ export default function RoundtablePlanningPage() { setStep2Runs([...syncedRuns, newRun]); setActiveStep2RunId(newRunId); setStep3Report(null); + // 开新一轮第二步 → 第三步必须基于这轮重新生成(含总结/流程图)。复位「已进入第三步」 + // 闸门 + 中断上一轮第三步在途三流,否则再进第三步会沿用上一轮旧结果(bug:第三步与第二步对不上)。 + step3EnteredRef.current = false; + summaryFlow.abort(); + reportFlow.abort(); + dashboardFlow.abort(); step2.resetStep2State(); step2.setSelectedAgents(normalized); step2.resetInitGate(); @@ -997,12 +1003,26 @@ export default function RoundtablePlanningPage() { setStep3SavedTick((t) => t + 1); }, []); + // 业务链条层级(取 stages 的「本阶段目标」goal,有序)——taskId 深链模式自动结构化输出时 + // 作流程图层级骨架(任务 → 层级1 → …)。memo 稳定数组身份,避免 run useCallback 频繁失效。 + const chainLevels = useMemo( + () => + (orchestrationPlan?.stages ?? []) + .map((s) => s.goal?.trim() ?? "") + .filter((g) => g.length > 0), + [orchestrationPlan], + ); + // taskId/深链模式:进第三步生成 md 后自动续跑结构化输出(流程图)。普通手动会商不自动。 + const autoSummaryFlow = !!prefillRef.current?.taskId; + const summaryFlow = useStep3Summary({ intentReady: step1.intentReady, seatDeliveries: step3SeatDeliveries, consensusText: step2.lastLeaderContent, selectedModel, coordinatorPrompt: orchestrationPlan?.coordinatorPrompt ?? null, + chainLevels, + autoFlow: autoSummaryFlow, savedSummary: step3Report?.summaryReport ?? null, onSummarySaved: handleSummarySaved, }); @@ -1596,6 +1616,7 @@ export default function RoundtablePlanningPage() { onSelectChain={handleSelectChainFromSidebar} onOpenDraft={drafts.handleLoadDraft} activeDraftId={drafts.currentDraftId} + entryRailTaskId={prefillRef.current?.taskId ?? null} /> {/* MIDDLE VIEWPORT */} @@ -2103,6 +2124,8 @@ interface LeftSidebarProps { onOpenDraft: (draftId: string) => void; /** 当前已加载草稿 id(分析记录选中高亮用),来自 drafts.currentDraftId。 */ activeDraftId: string | null; + /** 深链 taskId 模式:Step1 落地左栏「分析记录」只列该任务的草稿。 */ + entryRailTaskId?: string | null; } function Step2SandboxSidebarSync({ @@ -2148,6 +2171,7 @@ function LeftSidebar({ onSelectChain, onOpenDraft, activeDraftId, + entryRailTaskId, }: LeftSidebarProps) { // 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、 // 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。 @@ -2284,6 +2308,7 @@ function LeftSidebar({ onSelectChain={onSelectChain} onOpenDraft={onOpenDraft} activeDraftId={activeDraftId} + taskId={entryRailTaskId ?? null} /> )} @@ -2329,15 +2354,18 @@ function RoundtableEntryRail({ onSelectChain, onOpenDraft, activeDraftId, + taskId, }: { selectedChainId: string | null; onSelectChain: (chain: BusinessChain) => void; onOpenDraft: (draftId: string) => void; activeDraftId: string | null; + /** 深链 taskId 模式:分析记录只列该任务的草稿。 */ + taskId?: string | null; }) { const [chains, setChains] = useState([]); const [expandedChainId, setExpandedChainId] = useState(null); - const { drafts: records, refresh } = useRoundtableDrafts(); + const { drafts: records, refresh } = useRoundtableDrafts(taskId); useEffect(() => { let cancelled = false; diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index bd35b976..827f4398 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -245,7 +245,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA | **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id | | **Thread Shares** (`/api/thread-shares`) | Per-conversation sharing with two modes (`thread_shares.mode`). **`import`**: `POST /` `{thread_id, mode}` - create (or reuse) a revocable share code; `GET /{code}/preview` + `POST /{code}/import` - copy that conversation into the caller's account (fresh `thread_id` via `app/gateway/thread_copy.py` + new `threads_meta` row + `user-data` copy; dedup on `metadata.shared_origin`). **`view`**: a public, read-only static page ? see Public Shares below. Both modes: `GET /` - list my codes; `DELETE /{code}` - revoke. | | **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, ?) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. | -| **Roundtable Drafts** (`/api/roundtable-drafts`) | Per-user persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete. A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. | +| **Roundtable Drafts** (`/api/roundtable-drafts`) | Per-user persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete. **External-task binding (`task_id`)**: drafts carry an optional `task_id` (deep-link / 无界 embed); task-bound drafts are **shared** (the store ignores `user_id` for them, so a task's drafts span users). `GET /by-task/{task_id}/latest` - newest draft for a task (回显最新一条); `GET /by-task/{task_id}` - **all** drafts for a task, newest-first (taskId-mode history dropdown: 一个 taskId 多条记录), enriched with the latest background-job status per draft via `RoundtableJobRepository.status_map_by_drafts`. A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. Tests: `tests/test_roundtable_drafts_by_task.py`. | | **Roundtable Draft Shares** (`/api/roundtable-draft-shares`) | Per-draft sharing for the roundtable-planning page, mirroring Thread Shares but the shared resource is a `roundtable_drafts` row. Two modes (`roundtable_draft_shares.mode`). **`import`**: `POST /` `{draft_id, mode}` - create (or reuse) a revocable code; `GET /{code}/preview` + `POST /{code}/import` - copy that draft (step1/2/3) into the caller's account under a deterministic per-(code,recipient) id (`imp_` ? re-import is a no-op). **`view`**: a public, read-only result page ? see Public Roundtable Shares. Both: `GET /` - list my codes; `DELETE /{code}` - revoke. Backed by `deerflow.persistence.roundtable_draft_shares` (table `roundtable_draft_shares`, migration `20260606_01`); store wired in `deps.py` as `app.state.roundtable_draft_share_store`. Tests: `tests/test_roundtable_draft_shares.py`. | | **Public Roundtable Shares** (`/api/public/roundtable-shares`) | **Unauthenticated** (`/api/public/` whitelisted). `GET /{code}` - read-only snapshot for a `view`-mode draft share: returns the Step 3??????report HTML (`step3.html`, a self-contained document) + title/summary/`has_report`. The frontend renders it in a sandboxed `iframe` (no `allow-scripts`) at `#/roundtable-share/`. Missing/revoked/non-`view` codes all return 404. | | **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. | diff --git a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-summary/SOUL.md b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-summary/SOUL.md index 1f6d6680..fdaa5e19 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-summary/SOUL.md +++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-summary/SOUL.md @@ -74,6 +74,8 @@ - 每个节点字段:`id`(短唯一串 n1/n2…)、`label`(≤12 字)、`type`、`detail`(一两句说明,弹窗展示)、`from`(**前驱节点列表**,可空;分支边可写成 `{"id":"n3","label":"可行"}` 给边加标签); - `type` 只能取:`start`(开始)/ `end`(结束)/ `stage`(阶段)/ `task`(任务动作)/ `decision`(判断分支)/ `risk`(风险点)/ `deliverable`(交付物)/ `role`(角色席位)——前端按类型着不同颜色; - **`nodes` 必须按流程先后顺序排列**,且每个节点的 `from` 只引用**前面已出现**的节点 id——前端解析到一个节点就画一个节点和它的连线; +- **节点 label 命名约束(重要)**:label 必须是该层级 / 步骤对应的**具体概念或实体名**(如 目的 / 行为体 / 驱动因素,或它们下面的具体条目),**绝不能**写成「阶段1 / 阶段2 / 第N阶段 / 阶段几」这类阶段编号或泛称,也不要把「阶段」「环节」本身当成节点;不要在节点里塞阶段名称等与层级无关的内容; +- **按业务链条层级组织**:当本轮 prompt 给了「业务链层级」(如 任务→目的→行为体→驱动因素)时,以「任务」为根节点按该层级自上而下逐层展开(每层放该层级下提炼出的具体节点);总控编排提示里明确写了层级 / 结构时,以总控提示词为准(最高优先级); - 节点 6–20 个为宜;flow-json 块**只放对话回复末尾**,不要写进 .md 报告文件; - 用户没有要求结构化 / 流程图时,**不要**输出该块。 diff --git a/offline-backend-20260512/backend/app/gateway/routers/roundtable_drafts.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_drafts.py index 96cb09dc..14290b2b 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_drafts.py +++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_drafts.py @@ -194,6 +194,32 @@ async def get_latest_draft_by_task(request: Request, task_id: str) -> DraftRespo return DraftResponse(**row) +@router.get("/by-task/{task_id}", response_model=DraftListResponse) +async def list_drafts_by_task(request: Request, task_id: str) -> DraftListResponse: + """按外部任务 id 列出该任务全部草稿(轻量 meta,newest first,**不按 user 分权**)。 + + taskId 深链模式下历史下拉用:一个 taskId 可有多条会商记录;同 `list_drafts` + 一样用最近后台作业状态富化(job store 缺失/失败时静默退化为无状态)。 + """ + store = _get_store(request) + rows = await store.list_drafts_by_task(task_id) + job_store = getattr(request.app.state, "roundtable_job_store", None) + if job_store is not None and rows: + try: + status_map = await job_store.status_map_by_drafts([r.get("id") for r in rows]) + except AttributeError: + status_map = {} + except Exception: # noqa: BLE001 — 富化失败不能拖垮列表 + logger.warning("roundtable draft by-task list: job status enrichment failed", exc_info=True) + status_map = {} + for r in rows: + info = status_map.get(r.get("id")) + if info: + r["status"] = info.get("status") + r["job_id"] = info.get("jobId") + return DraftListResponse(drafts=[DraftMetaResponse(**r) for r in rows]) + + @router.get("/{draft_id}", response_model=DraftResponse) async def get_draft(request: Request, draft_id: str) -> DraftResponse: store = _get_store(request) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_drafts/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_drafts/sql.py index 8cc151df..7fec70b5 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_drafts/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_drafts/sql.py @@ -100,6 +100,21 @@ class RoundtableDraftRepository: row = result.scalars().first() return self._draft_to_dict(row) if row is not None else None + async def list_drafts_by_task(self, task_id: str, *, limit: int = 100) -> list[dict[str, Any]]: + """按外部任务 id 列出全部草稿(轻量 meta,newest first,**忽略 user_id** 不分权)。 + + taskId 深链模式下历史下拉用:一个 taskId 可有多条会商记录。 + """ + stmt = ( + select(RoundtableDraftRow) + .where(RoundtableDraftRow.task_id == task_id) + .order_by(RoundtableDraftRow.updated_at.desc()) + .limit(limit) + ) + async with self._sf() as session: + result = await session.execute(stmt) + return [self._draft_to_meta(row) for row in result.scalars()] + async def create_draft(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]: now = datetime.now(UTC) row = RoundtableDraftRow( diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/sql.py index e7e0995b..61c510ab 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/sql.py @@ -236,6 +236,36 @@ class RoundtableJobRepository: out[draft_id] = {"jobId": job_id, "status": status} return out + async def status_map_by_drafts( + self, + draft_ids: list[str], + *, + limit: int = 400, + ) -> dict[str, dict[str, str]]: + """``{draft_id: {"jobId": ..., "status": ...}}`` for the latest job per draft. + + Like :meth:`status_map_by_user` but filtered by an explicit draft-id list + instead of by owner — used by the taskId-mode history dropdown, where a + task's drafts may span multiple users (task-bound drafts are shared). + """ + ids = [d for d in draft_ids if d] + if not ids: + return {} + stmt = ( + select(RoundtableJobRow.id, RoundtableJobRow.draft_id, RoundtableJobRow.status) + .where(RoundtableJobRow.draft_id.in_(ids)) + .order_by(RoundtableJobRow.updated_at.desc()) + .limit(limit) + ) + out: dict[str, dict[str, str]] = {} + async with self._sf() as session: + result = await session.execute(stmt) + for job_id, draft_id, status in result.all(): + if draft_id is None or draft_id in out: + continue + out[draft_id] = {"jobId": job_id, "status": status} + return out + # ── delete / cleanup ───────────────────────────────────────────────────── async def delete(