From 19399482e1b5e3a1248cf33cc91d0a155c46f001 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Thu, 4 Jun 2026 20:00:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E6=8C=82=E8=B5=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/multi-agent-step2-run-branches.md | 404 ++++++++++++++++++ .../api/roundtable-jobs.ts | 2 + .../components/DispatchChainJobModal.tsx | 1 + .../components/DispatchChainModal.tsx | 46 +- .../components/MessageBubble.tsx | 29 +- .../components/MessageStepsCard.tsx | 19 +- .../components/Step1Panel.tsx | 4 +- .../components/Step2Panel.tsx | 14 +- .../src/roundtable-planning/lib/job-types.ts | 12 + .../roundtable-planning/lib/step-display.tsx | 4 +- .../pages/RoundtablePlanningPage.tsx | 4 +- .../styles/roundtable-planning.css | 13 + offline-backend-20260512/.env | 4 +- .../app/gateway/roundtable_job_executor.py | 8 +- .../app/gateway/routers/roundtable_jobs.py | 3 + offline-backend-20260512/backend/config.yaml | 25 +- .../agents/roundtable_orchestrator/engine.py | 6 + .../roundtable_orchestrator/progress.py | 24 ++ .../20260605_01_roundtable_jobs_dialogues.py | 46 ++ .../persistence/roundtable_jobs/model.py | 3 + .../persistence/roundtable_jobs/sql.py | 5 +- 21 files changed, 626 insertions(+), 50 deletions(-) create mode 100644 frontend-web/docs/multi-agent-step2-run-branches.md create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260605_01_roundtable_jobs_dialogues.py diff --git a/frontend-web/docs/multi-agent-step2-run-branches.md b/frontend-web/docs/multi-agent-step2-run-branches.md new file mode 100644 index 00000000..4d7b0fa1 --- /dev/null +++ b/frontend-web/docs/multi-agent-step2-run-branches.md @@ -0,0 +1,404 @@ +# 多智能体会商 Step2 多结果线方案 + +## 背景 + +当前「多智能体会商 / 多智能体规划」里,一个历史草稿只保存一份 `step2` 快照。用户从历史记录加载草稿后,如果再次点击「智能体推荐」并在弹窗里点击「进入研讨」,新的 Step2 研讨会继续写入同一份 `draft.step2`,结果是第一次生成内容会被第二次生成内容覆盖,或者页面只能展示最新一次结果。 + +产品期望是: + +- 用户从历史记录进入已有草稿时,进入第二步后,左下角能看到第一次生成结果。 +- 用户再次点击「智能体推荐」并点击「进入研讨」时,要开启一条新的研讨结果线。 +- 第二次生成结果显示在左下角,但不能影响第一次生成结果。 +- 用户之后再次查看同一个历史草稿时,可以分别查看第一次生成结果和第二次生成结果。 + +因此,这个需求的核心不是简单增加一个展示区域,而是把 Step2 从「单结果」升级为「一个草稿下多条研讨 run」。 + +## 现状 + +相关位置: + +- `src/roundtable-planning/api/drafts.ts` +- `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` +- `src/roundtable-planning/hooks/useStep2Orchestration.ts` +- `src/roundtable-planning/components/Step2Panel.tsx` +- `src/roundtable-planning/components/RecommendAgentsDialog.tsx` + +当前 `DraftStep2Snapshot` 结构大致是: + +```ts +export interface DraftStep2Snapshot { + selectedAgents?: Array<{ agent_id: string; name: string }>; + threadIds: ThreadIdMap | null; + coordinatorName: string | null; + step2RoundtableDialogues: unknown[]; + lastLeaderContent: string; + hasConsensus: boolean; + consensusPercentage: number; + budgetLimit: number; + seatStubMessages?: Record; + orchestrationMode?: "recommend" | "chain"; + chain?: { id: string; title: string } | null; +} +``` + +这意味着一个草稿只有一份: + +- 参会智能体 +- 多智能体 thread 映射 +- 研讨气泡 +- 总控最终内容 +- 共识状态 +- 沙箱产物 stub +- 编排模式和业务链条 + +当前 `RoundtablePlanningPage.tsx` 里的 `handleConfirmAgents` 在用户点击「进入研讨」后会: + +1. 规范化选中的 agents。 +2. 写入 `step2.selectedAgents`。 +3. 调用 `step2.resetInitGate()`。 +4. 设置 `orchestrationMode` / `chainMeta`。 +5. 切到 `currentStep = 2`。 + +它没有先把当前 Step2 结果归档成「第一次生成结果」,也没有为新的研讨创建独立 run。因此第二次研讨天然会复用同一个 `draft.step2` 写入位置。 + +## 目标模型 + +建议引入 Step2 run 概念:一个草稿可以有多条 Step2 结果线,每条结果线保存一份完整的 Step2 快照。 + +```ts +interface Step2RunSnapshot { + id: string; + title: string; + createdAt: string; + source: "initial" | "recommend" | "chain" | "manual"; + selectedAgents?: Array<{ agent_id: string; name: string }>; + threadIds: ThreadIdMap | null; + coordinatorName: string | null; + step2RoundtableDialogues: unknown[]; + lastLeaderContent: string; + hasConsensus: boolean; + consensusPercentage: number; + budgetLimit: number; + seatStubMessages?: Record; + orchestrationMode?: "recommend" | "chain"; + chain?: { id: string; title: string } | null; + step3?: DraftStep3Snapshot | null; +} +``` + +`DraftStep2Snapshot` 保留现有字段作为当前激活 run 的镜像,同时新增 `runs` 和 `activeRunId`: + +```ts +export interface DraftStep2Snapshot { + activeRunId?: string; + runs?: Step2RunSnapshot[]; + + selectedAgents?: Array<{ agent_id: string; name: string }>; + threadIds: ThreadIdMap | null; + coordinatorName: string | null; + step2RoundtableDialogues: unknown[]; + lastLeaderContent: string; + hasConsensus: boolean; + consensusPercentage: number; + budgetLimit: number; + seatStubMessages?: Record; + orchestrationMode?: "recommend" | "chain"; + chain?: { id: string; title: string } | null; +} +``` + +这样做的好处: + +- 老代码仍然可以从 `draft.step2.step2RoundtableDialogues` 读取当前结果。 +- 新 UI 可以从 `draft.step2.runs` 展示第一次、第二次、第三次生成结果。 +- 老草稿没有 `runs` 时,可以在 hydrate 时把原来的 `step2` 包装成一条默认 run。 +- 暂时不需要改数据库表结构,因为 `step2` 本身就是 JSON 快照。 + +## 行为设计 + +### 1. 历史记录加载 + +用户点击历史记录加载草稿时: + +1. 拉取完整草稿。 +2. 如果 `draft.step2.runs` 存在,使用 `activeRunId` 找到当前结果线。 +3. 如果 `draft.step2.runs` 不存在,但旧字段里有 `step2RoundtableDialogues`,自动包装成一条 run: + +```ts +{ + id: "legacy-run", + title: "第一次生成结果", + createdAt: draft.updatedAt, + source: "initial", + ...draft.step2 +} +``` + +4. hydrate 当前 active run 到 Step2 runtime。 +5. 左下角展示 run 列表。 + +### 2. 再次点击智能体推荐进入研讨 + +用户在已有历史草稿中再次点击「智能体推荐」并确认进入研讨时: + +1. 先把当前页面里的 Step2 状态保存为当前 active run。 +2. 创建一条新的 run: + +```ts +{ + id: crypto.randomUUID(), + title: `第 ${runs.length + 1} 次生成结果`, + createdAt: new Date().toISOString(), + source: result.mode, + selectedAgents: normalizedAgents, + threadIds: null, + coordinatorName: null, + step2RoundtableDialogues: [], + lastLeaderContent: "", + hasConsensus: false, + consensusPercentage: 0, + budgetLimit: DEFAULT_BUDGET, + seatStubMessages: {}, + orchestrationMode: result.mode, + chain: result.mode === "chain" ? result.chain ?? null : null +} +``` + +3. 将 `activeRunId` 切到新 run。 +4. 清空 Step2 runtime。 +5. 设置新选中的 agents / mode / chain。 +6. `resetInitGate()`,进入 Step2 后重新创建一组独立 thread。 +7. 自动保存草稿。 + +关键点:第二次 run 必须使用新的 `threadIds`,不能沿用第一次 run 的 `threadIds`,否则后端 thread 历史会混在一起。 + +### 3. 左下角结果列表 + +左下角建议展示一个「生成结果」区域: + +- 第一次生成结果 +- 第二次生成结果 +- 第三次生成结果 + +每条展示: + +- 标题 +- 创建时间 +- 状态:研讨中 / 已达成共识 / 未完成 / 已生成报告 +- 使用模式:智能体推荐 / 业务链条 + +点击某条结果时: + +1. 保存当前 active run。 +2. 切换 `activeRunId`。 +3. hydrate 被点击的 run。 +4. 中间 Step2 对话区显示该 run 的研讨气泡。 +5. 如果该 run 已有 `step3`,进入 Step3 时展示对应报告。 + +### 4. Step3 结果归属 + +目前 `draft.step3` 是草稿级别的一份报告。如果只保留这一份,第二次结果生成报告后仍会覆盖第一次报告。 + +建议: + +- `draft.step3` 继续作为当前 active run 的镜像,兼容现有 Step3 页面。 +- 每条 `Step2RunSnapshot.step3` 保存自己的报告。 +- `onReportSaved` 时同时写入当前 active run 的 `step3`。 + +这样用户切换到第一次生成结果时,可以看到第一次的 Step3 报告;切换到第二次生成结果时,可以看到第二次的 Step3 报告。 + +## 建议改动点 + +### 1. `api/drafts.ts` + +扩展类型: + +- 新增 `Step2RunSnapshot`。 +- `DraftStep2Snapshot` 增加 `activeRunId?: string` 和 `runs?: Step2RunSnapshot[]`。 + +同时保留旧字段,避免影响现有保存、加载和 Step3。 + +### 2. `useStep2Orchestration.ts` + +新增或暴露两个能力: + +- `getCurrentRunSnapshot()`:从当前 Step2 runtime 生成一条 run 快照。 +- `hydrateRunSnapshot(run)`:把某条 run 恢复到 Step2 runtime。 + +`hydrateFromDraft` 继续存在,但内部可以优先选择 active run 来 hydrate。 + +### 3. `RoundtablePlanningPage.tsx` + +新增页面级状态: + +```ts +const [step2Runs, setStep2Runs] = useState([]); +const [activeStep2RunId, setActiveStep2RunId] = useState(null); +``` + +调整几个流程: + +- `getStep2Snapshot` 输出 `runs` 和 `activeRunId`。 +- `hydrateStep2` 兼容旧草稿并恢复 runs。 +- `handleConfirmAgents` 在新建研讨前先归档当前 active run,再创建新 run。 +- `onReportSaved` 把 Step3 报告写回当前 active run。 + +### 4. `Step2Panel.tsx` + +新增左下角结果列表展示。建议作为一个独立组件,避免 Step2Panel 继续变大: + +```tsx + +``` + +组件职责只做展示和选择,不直接读写 draft。 + +### 5. `Step3Panel.tsx` + +传入当前 active run 的报告: + +- 如果当前 run 有 `step3`,展示它。 +- 如果没有,按现有逻辑生成新报告。 +- 生成完成后通过 `onReportSaved` 写回 run。 + +## 兼容策略 + +老草稿没有 `runs` 字段时,不做数据迁移,前端加载时即时包装。 + +判断规则: + +```ts +function normalizeStep2Runs(step2: DraftStep2Snapshot): { + runs: Step2RunSnapshot[]; + activeRunId: string | null; +} { + if (step2.runs?.length) { + return { + runs: step2.runs, + activeRunId: step2.activeRunId ?? step2.runs[0].id, + }; + } + + const hasLegacyContent = + step2.step2RoundtableDialogues?.length > 0 || + Boolean(step2.threadIds) || + Boolean(step2.lastLeaderContent); + + if (!hasLegacyContent) { + return { runs: [], activeRunId: null }; + } + + const legacyRun: Step2RunSnapshot = { + id: "legacy-run", + title: "第一次生成结果", + createdAt: new Date().toISOString(), + source: step2.orchestrationMode ?? "initial", + selectedAgents: step2.selectedAgents, + threadIds: step2.threadIds, + coordinatorName: step2.coordinatorName, + step2RoundtableDialogues: step2.step2RoundtableDialogues, + lastLeaderContent: step2.lastLeaderContent, + hasConsensus: step2.hasConsensus, + consensusPercentage: step2.consensusPercentage, + budgetLimit: step2.budgetLimit, + seatStubMessages: step2.seatStubMessages, + orchestrationMode: step2.orchestrationMode, + chain: step2.chain, + }; + + return { runs: [legacyRun], activeRunId: legacyRun.id }; +} +``` + +## 风险点 + +### 1. 自动保存覆盖 + +Step2 现在会在每个气泡收尾时自动保存。如果引入多 run,自动保存必须先把当前 runtime 合并回 active run,再写入 `draft.step2.runs`。 + +否则会出现 UI 切到第二次结果,但保存时仍更新第一次 run 的情况。 + +### 2. threadIds 混用 + +每条 run 必须拥有自己的 `threadIds`。新建 run 时要清空 `threadIds` 并让 Step2 重新 init。 + +如果复用旧 thread,会导致两次研讨的后端上下文互相污染。 + +### 3. Step3 报告覆盖 + +如果只保存草稿级 `step3`,多 run 的报告无法分别查看。 + +建议把报告写入当前 run,同时保留草稿级 `step3` 作为 active run 镜像。 + +### 4. 正在运行中的 run + +如果配合后台任务能力,run 还需要带 job 信息: + +```ts +jobId?: string | null; +jobStatus?: JobStatus | null; +``` + +这样左下角可以显示「第二次生成结果正在运行」,并打开派活链路弹窗。 + +## 分阶段实现建议 + +### P0:纯前端多 run 快照 + +目标:先解决“第一次 / 第二次结果分别可查看”。 + +- 扩展 `DraftStep2Snapshot` 类型。 +- 页面维护 `step2Runs` / `activeStep2RunId`。 +- `handleConfirmAgents` 新建 run。 +- 左下角展示 run 列表。 +- 切换 run 能恢复不同 Step2 对话。 + +不改后端表结构。 + +### P1:Step3 绑定 run + +目标:每次研讨结果对应自己的报告。 + +- `Step2RunSnapshot.step3` 保存报告。 +- `onReportSaved` 写回 active run。 +- 切换 run 时同步 Step3 显示。 + +### P2:后台任务绑定 run + +目标:和后台挂起执行方案打通。 + +- run 增加 `jobId` / `jobStatus`。 +- 历史列表和左下角都能显示某条 run 正在执行。 +- 点击正在执行的 run 打开派活链路弹窗。 + +### P3:后端 run 表 + +如果后续一个草稿下 run 数量很多,或者需要服务端按 run 查询、删除、重命名,再考虑新增后端表: + +- `roundtable_runs` +- `draft_id` +- `run_id` +- `title` +- `status` +- `step2_snapshot` +- `step3_snapshot` +- `created_at` +- `updated_at` + +短期不建议一开始就上表,先用现有 `step2` JSON 快照落地更快。 + +## 验收清单 + +- 从历史记录加载旧草稿时,左下角出现「第一次生成结果」。 +- 点击「第一次生成结果」能看到原来的 Step2 研讨气泡。 +- 在历史草稿中重新点击「智能体推荐」并「进入研讨」后,左下角新增「第二次生成结果」。 +- 第二次研讨开始时,Step2 对话区为空或只显示新 run 初始化内容。 +- 第二次 run 使用新的 `threadIds`,不会复用第一次 run 的后端线程。 +- 切回「第一次生成结果」时,第一次的对话、共识状态、沙箱产物仍可查看。 +- 切回「第二次生成结果」时,第二次的对话继续显示。 +- 刷新页面后再次加载该历史草稿,两条结果线都还在。 +- 如果两次都生成了 Step3 报告,切换不同结果线时能看到各自对应的报告。 diff --git a/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts b/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts index ccbc09df..a75e581f 100644 --- a/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts +++ b/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts @@ -18,6 +18,7 @@ import { getAvatarTypeFor } from "../lib/roundtable-constants"; import { isTerminalJobStatus, type DispatchChainNode, + type JobDialogue, type RoundtableJob, type StartJobPayload, } from "../lib/job-types"; @@ -65,6 +66,7 @@ function mapJob(raw: Record): RoundtableJob { orchestrationMode: (raw.orchestrationMode as RoundtableJob["orchestrationMode"]) ?? "recommend", chain: (raw.chain as RoundtableJob["chain"]) ?? null, error: (raw.error as string | null) ?? null, + dialogues: Array.isArray(raw.dialogues) ? (raw.dialogues as JobDialogue[]) : [], createdAt: (raw.createdAt as string | null) ?? null, updatedAt: (raw.updatedAt as string | null) ?? null, }; diff --git a/frontend-web/src/roundtable-planning/components/DispatchChainJobModal.tsx b/frontend-web/src/roundtable-planning/components/DispatchChainJobModal.tsx index 8d0a538b..8da7ebbc 100644 --- a/frontend-web/src/roundtable-planning/components/DispatchChainJobModal.tsx +++ b/frontend-web/src/roundtable-planning/components/DispatchChainJobModal.tsx @@ -67,6 +67,7 @@ export function DispatchChainJobModal({ consensusPercentage={job?.consensusPercentage ?? 0} onView={job && onView ? () => onView(job) : undefined} error={job?.error} + dialogues={job?.dialogues} pendingClarification={job?.pendingClarification} onResume={ jobId diff --git a/frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx b/frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx index 349500a5..41a441c5 100644 --- a/frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx +++ b/frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx @@ -14,7 +14,7 @@ * (business-chain.ts)。连线动画 CSS 在 `styles/roundtable-planning.css` 全局段 * (Radix Dialog portal 到 body,不在 .roundtable-planning 作用域内,故用全局类 + 语义 token)。 */ -import { Fragment, useState } from "react"; +import { Fragment, useEffect, useRef, useState } from "react"; import { CheckIcon, ChevronRightIcon, EyeIcon, Loader2Icon, SendIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -30,9 +30,11 @@ import { cn } from "@/lib/utils"; import { getAvatar } from "./Avatars"; import { chainTagClasses } from "../lib/business-chain"; +import { getAvatarTypeFor } from "../lib/roundtable-constants"; import type { ChainNodeState, DispatchChainNode, + JobDialogue, JobPhase, JobStatus, } from "../lib/job-types"; @@ -62,6 +64,8 @@ export interface DispatchChainModalProps { onCancel?: () => void; /** 出错详情(status==='error')。 */ error?: string | null; + /** 研讨对话记录(leader 说明 + 各席位交付)。 */ + dialogues?: JobDialogue[]; } const _NON_TERMINAL: ReadonlySet = new Set(["queued", "running", "awaiting_input"]); @@ -99,8 +103,24 @@ export function DispatchChainModal({ onResume, onCancel, error, + dialogues, }: DispatchChainModalProps) { const [answer, setAnswer] = useState(""); + const convoRef = useRef(null); + + // 新对话进来时自动滚到底部。 + const dialogueCount = dialogues?.length ?? 0; + useEffect(() => { + const el = convoRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [dialogueCount]); + + // 对话气泡的头像配色:总控固定蓝;席位优先取流程图节点色,回退按 agentId 派生。 + const avatarForDialogue = (d: JobDialogue): string => { + if (d.role === "leader") return "blue"; + const node = nodes.find((n) => n.agentId === d.agentId); + return node?.avatarType || (d.agentId ? getAvatarTypeFor(d.agentId, node?.order ? node.order - 1 : 0) : "blue"); + }; // 总控节点:初始化 / 决策派活阶段高亮 const coordinatorState: ChainNodeState = phase === "initializing" || phase === "leader_thinking" ? "active" : "done"; @@ -147,7 +167,7 @@ export function DispatchChainModal({ {/* 横向流程图(横向滚动) */} -
+
{/* 总控节点 */}
+ {/* 研讨对话(leader 说明 + 各席位交付,真网关下即真模型真实对话) */} + {dialogues && dialogues.length > 0 && ( +
+
+ {dialogues.map((d) => ( +
+
{getAvatar(avatarForDialogue(d), "h-7 w-7")}
+
+
{d.name}
+
+ {d.content || "…"} +
+
+
+ ))} +
+
+ )} + {/* 出错详情 */} {status === "error" && error && (
diff --git a/frontend-web/src/roundtable-planning/components/MessageBubble.tsx b/frontend-web/src/roundtable-planning/components/MessageBubble.tsx index f7f205ae..fd5fef4b 100644 --- a/frontend-web/src/roundtable-planning/components/MessageBubble.tsx +++ b/frontend-web/src/roundtable-planning/components/MessageBubble.tsx @@ -52,16 +52,23 @@ export function MessageBubble({
- + {senderName} {badge && ( - + {badge} )}
- {time} + + {time} +
{children}
@@ -75,7 +82,7 @@ export function MessageBubble({ */ export function MessageLoadingPlaceholder({ label }: { label?: string }) { return ( -
+
{label ?? "正在连接智能体..."} @@ -118,10 +125,10 @@ export const sharedMarkdownComponents = {

{children}

), h3: ({ children }: { children?: React.ReactNode }) => ( -

{children}

+

{children}

), h4: ({ children }: { children?: React.ReactNode }) => ( -

{children}

+

{children}

), strong: ({ children }: { children?: React.ReactNode }) => ( {children} @@ -139,13 +146,13 @@ export const sharedMarkdownComponents = { const isBlock = /language-/.test(className ?? ""); if (isBlock) { return ( -
+        
           {children}
         
); } return ( - + {children} ); @@ -161,7 +168,7 @@ export const sharedMarkdownComponents = { ), table: ({ children }: { children?: React.ReactNode }) => ( - {children}
+ {children}
), th: ({ children }: { children?: React.ReactNode }) => ( @@ -254,7 +261,7 @@ export function ReasoningBlock({ @@ -472,7 +472,7 @@ export function Step2Message({ {reasoning} )} -
+
{isStreaming && !hasContent && } {content} {isStreaming && } @@ -594,17 +594,17 @@ function PresentFileCard({
{filename}
- + {typeLabel} - 点击预览 + 点击预览
); diff --git a/frontend-web/src/roundtable-planning/lib/job-types.ts b/frontend-web/src/roundtable-planning/lib/job-types.ts index 59bfb8ec..ae2595fc 100644 --- a/frontend-web/src/roundtable-planning/lib/job-types.ts +++ b/frontend-web/src/roundtable-planning/lib/job-types.ts @@ -45,6 +45,16 @@ export interface DispatchChainNode { lastSpokeCycle?: number; } +/** 一条研讨对话记录(leader 说明 / 某席位交付)。 */ +export interface JobDialogue { + id: number; + role: "leader" | "seat"; + agentId: string | null; + name: string; + content: string; + cycle: number; +} + /** 一条后台作业的完整快照(§2.5)——对齐后端 `RoundtableJobResponse`(camelCase)。 */ export interface RoundtableJob { id: string; @@ -59,6 +69,8 @@ export interface RoundtableJob { orchestrationMode: "recommend" | "chain"; chain: { id: string; title: string } | null; error: string | null; + /** 研讨对话记录(leader 说明 + 各席位交付),跑完 / 进行中都可看真实内容。 */ + dialogues: JobDialogue[]; createdAt: string | null; updatedAt: string | null; } diff --git a/frontend-web/src/roundtable-planning/lib/step-display.tsx b/frontend-web/src/roundtable-planning/lib/step-display.tsx index 6f197cd9..e7887c64 100644 --- a/frontend-web/src/roundtable-planning/lib/step-display.tsx +++ b/frontend-web/src/roundtable-planning/lib/step-display.tsx @@ -55,7 +55,7 @@ export function renderStepArgs( {displayName} )} {task && ( -
+
{task}
)} @@ -98,7 +98,7 @@ export function renderStepArgs( case "ask_clarification": { const question = typeof args.question === "string" ? args.question : ""; return question ? ( - + {question} ) : null; diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 7f4f1836..d66f5e3d 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -374,8 +374,8 @@ export default function RoundtablePlanningPage() { model: selectedModel || undefined, // 模型沿用第二步选择 orchestrationMode, chain: chainMeta, - threadIds: step2.threadIds ?? undefined, - coordinatorName: step2.coordinatorName ?? undefined, + // 不传 threadIds:后台作业由真网关自建全新线程,绝不复用前端本地研讨的线程 + // (那些线程可能有活跃 run,复用会撞 409)。 }); } } catch (err) { diff --git a/frontend-web/src/roundtable-planning/styles/roundtable-planning.css b/frontend-web/src/roundtable-planning/styles/roundtable-planning.css index 4223a4ce..b16247a0 100644 --- a/frontend-web/src/roundtable-planning/styles/roundtable-planning.css +++ b/frontend-web/src/roundtable-planning/styles/roundtable-planning.css @@ -10,6 +10,19 @@ --rt-primary-active: var(--brand-950, #a8071a); --rt-primary-soft: var(--brand-200, #feeded); --rt-page-bg: #fef6f6; + /* 消息列表正文字号 —— 与右侧沙箱 CodeEditor / Markdown 预览的 text-sm 对齐 */ + --roundtable-chat-font-size: var(--text-sm, 0.875rem); + --roundtable-chat-meta-font-size: var(--text-xs, 0.75rem); +} + +/* 会商对话区:气泡正文、步骤卡、Markdown 块统一继承 */ +.roundtable-chat-message { + font-size: var(--roundtable-chat-font-size); + line-height: 1.625; +} + +.roundtable-chat-meta { + font-size: var(--roundtable-chat-meta-font-size); } .roundtable-planning ::-webkit-scrollbar-thumb { diff --git a/offline-backend-20260512/.env b/offline-backend-20260512/.env index 50aa890e..715e331f 100644 --- a/offline-backend-20260512/.env +++ b/offline-backend-20260512/.env @@ -59,4 +59,6 @@ ELEMENT_STATE_FILE=/app/backend/.deer-flow/element-bot-token # PYTHON_EXTRA_INDEX_URL= # PYTHON_TRUSTED_HOST=your-python-mirror MYSQL_DATABASE_URL=mysql+asyncmy://deerflow:deerflow123@192.168.0.123:3306/deerflow?charset=utf8mb4 -DEER_FLOW_NOTEBOOK_API_BASE=http://127.0.0.1:8003 \ No newline at end of file +DEER_FLOW_NOTEBOOK_API_BASE=http://127.0.0.1:8003 +# 圆桌后台作业网关:inprocess=进程内驱动真模型;simulated=模拟(默认) +ROUNDTABLE_JOB_GATEWAY=inprocess diff --git a/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py b/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py index 717ddcb2..5732a1bb 100644 --- a/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py +++ b/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py @@ -49,6 +49,9 @@ class StartParams: coordinator_name: str = "roundtable-coordinator" # 关联草稿:作业完成后把 step3 报告写回该草稿,使「查看报告」可用。 draft_id: str | None = None + # 是否为「续跑」(resume 澄清)。首启=False → 真网关建全新线程(不复用前端线程, + # 避免与前端本地研讨的活跃 run 撞 409);续跑=True → 复用作业自己的线程。 + is_resume: bool = False # 网关工厂:给定一次启动参数,返回一个 RoundtableGateway 实例。 @@ -79,9 +82,10 @@ class RoundtableJobExecutor: thread_ids = params.thread_ids or {} coordinator_name = params.coordinator_name # 真网关需要先进程内建好 leader/seat 线程;模拟网关无 init_threads。 - # 仅首启建线程(resume 时 params.thread_ids 已带,复用、不重建)。 + # 首启(非 resume)始终建**全新线程** —— 绝不复用前端本地研讨的线程 + # (那些线程可能有活跃 run,复用会撞 409)。resume 时复用作业自己的线程。 init = getattr(gateway, "init_threads", None) - if init is not None and not thread_ids: + if init is not None and not params.is_resume: thread_ids, coordinator_name = await init() try: await self._store.update_progress( diff --git a/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py index 895c791c..fbad2d4b 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py +++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py @@ -97,6 +97,7 @@ class RoundtableJobResponse(BaseModel): orchestrationMode: str = "recommend" chain: dict[str, Any] | None = None error: str | None = None + dialogues: list[dict[str, Any]] = Field(default_factory=list) createdAt: datetime | str | None = None updatedAt: datetime | str | None = None @@ -147,6 +148,7 @@ def _to_response(row: dict[str, Any]) -> RoundtableJobResponse: orchestrationMode=row.get("orchestration_mode") or "recommend", chain=row.get("chain"), error=row.get("error"), + dialogues=row.get("dialogues") or [], createdAt=row.get("created_at"), updatedAt=row.get("updated_at"), ) @@ -282,6 +284,7 @@ async def resume_job(request: Request, job_id: str, body: ResumeJobRequest) -> R thread_ids=row.get("thread_ids"), coordinator_name=row.get("coordinator_name") or "roundtable-coordinator", draft_id=row.get("draft_id"), + is_resume=True, ) ) return _to_response(updated or row) diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 345a2f46..dc012dc3 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -10,19 +10,6 @@ gateway: port: 8001 models: - - name: zai-org/GLM-5-FP8 - display_name: zai-org/GLM-5-FP8 - use: langchain_openai:ChatOpenAI - model: zai-org/GLM-5-FP8 - api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA - base_url: https://api.us-west-2.modal.direct/v1 - request_timeout: 600.0 - max_retries: 2 - max_tokens: 4096 - temperature: 1.0 - supports_vision: true - supports_thinking: true - - name: deepseek-chat display_name: DeepSeek Chat use: langchain_openai:ChatOpenAI @@ -91,6 +78,18 @@ models: temperature: 1.0 supports_vision: true supports_thinking: true + - name: zai-org/GLM-5-FP8 + display_name: zai-org/GLM-5-FP8 + use: langchain_openai:ChatOpenAI + model: zai-org/GLM-5-FP8 + api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA + base_url: https://api.us-west-2.modal.direct/v1 + request_timeout: 600.0 + max_retries: 2 + max_tokens: 4096 + temperature: 1.0 + supports_vision: true + supports_thinking: true # 识图(图片识别)配置。 # - model_name:指定一个 models[] 中的模型名,作为“默认识图大模型”。view_image 工具会把 # 图片交给该模型识别,并把识别出的文字结果返回给主对话模型 —— 这样即使主对话模型不支持 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/engine.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/engine.py index 13f55592..9f21212e 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/engine.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/engine.py @@ -140,6 +140,7 @@ async def _run_recommend( deliveries: dict[str, str] = {} last_leader = "" message = seed_message + name_by_id = {a.agent_id: a.name for a in agents} for cycle in range(1, MAX_CYCLES + 1): await progress.leader_thinking(cycle) @@ -151,6 +152,7 @@ async def _run_recommend( ) if turn.final_text: last_leader = turn.final_text + await progress.add_leader_dialogue(turn.final_text, cycle) if turn.is_clarification: return _LoopOutcome("clarification", question=turn.clarification, last_leader_content=last_leader, deliveries=deliveries) @@ -163,6 +165,7 @@ async def _run_recommend( await progress.seat_active(agent_id) seat = await gateway.run_seat(thread_ids=thread_ids, agent_id=agent_id, task=task, model=model) deliveries[agent_id] = seat.final_text + await progress.add_seat_dialogue(agent_id, name_by_id.get(agent_id, agent_id), seat.final_text, cycle) await progress.seat_done(agent_id, cycle) await progress.bump_consensus() @@ -208,6 +211,7 @@ async def _run_chain( ) if turn.final_text: last_leader = turn.final_text + await progress.add_leader_dialogue(turn.final_text, i + 1) if turn.is_clarification: return _LoopOutcome("clarification", question=turn.clarification, last_leader_content=last_leader, deliveries=deliveries) @@ -217,6 +221,7 @@ async def _run_chain( await progress.seat_active(seat.agent_id) seat_turn = await gateway.run_seat(thread_ids=thread_ids, agent_id=seat.agent_id, task=task, model=model) deliveries[seat.agent_id] = seat_turn.final_text + await progress.add_seat_dialogue(seat.agent_id, seat.name, seat_turn.final_text, i + 1) await progress.seat_done(seat.agent_id, i + 1) if total > 0: @@ -233,6 +238,7 @@ async def _run_chain( ) if closeout.final_text: last_leader = closeout.final_text + await progress.add_leader_dialogue(closeout.final_text, total + 1) return _LoopOutcome("consensus", last_leader_content=last_leader, deliveries=deliveries) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/progress.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/progress.py index acf3494c..1ecf06c7 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/progress.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/progress.py @@ -50,6 +50,9 @@ class JobProgressWriter: self._state: dict[str, str] = {a.agent_id: _PENDING for a in agents} self._spoke_cycle: dict[str, int] = {} self._consensus = 0 + # 研讨对话记录(leader 说明 + 各席位交付),随每次落库一并写入。 + self._dialogues: list[dict[str, Any]] = [] + self._dlg_seq = 0 # ── 节点快照 ────────────────────────────────────────────────────────── @@ -76,6 +79,7 @@ class JobProgressWriter: """落库(始终带最新 dispatch_chain + consensus)+ 外发事件,均 best-effort。""" fields.setdefault("dispatch_chain", self._nodes()) fields.setdefault("consensus_percentage", self._consensus) + fields.setdefault("dialogues", self._dialogues) if self._store is not None: try: await self._store.update_progress(self._job_id, **fields) @@ -140,3 +144,23 @@ class JobProgressWriter: async def error(self, message: str) -> None: await self._persist(status="error", error=message) + + # ── 对话记录 ───────────────────────────────────────────────────────────── + + def _add(self, role: str, agent_id: str | None, name: str, content: str, cycle: int) -> None: + self._dlg_seq += 1 + self._dialogues.append( + {"id": self._dlg_seq, "role": role, "agentId": agent_id, "name": name, "content": content, "cycle": cycle} + ) + + async def add_leader_dialogue(self, content: str, cycle: int) -> None: + """总控一轮发言(派活说明 / 共识结论)。空内容跳过。""" + if not (content or "").strip(): + return + self._add("leader", None, "总控协调", content, cycle) + await self._persist() + + async def add_seat_dialogue(self, agent_id: str, name: str, content: str, cycle: int) -> None: + """某席位一轮交付。""" + self._add("seat", agent_id, name, content or "", cycle) + await self._persist() diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260605_01_roundtable_jobs_dialogues.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260605_01_roundtable_jobs_dialogues.py new file mode 100644 index 00000000..10c6e1b8 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260605_01_roundtable_jobs_dialogues.py @@ -0,0 +1,46 @@ +"""Add dialogues column to roundtable_jobs. + +存研讨对话记录(leader 说明 + 各席位交付),使后台作业跑完 / 进行中用户都能看到 +真实研讨内容(而非只有流程图进度)。LONGTEXT(对话可超 64 KB),匹配 +``PortableLongText`` on ``RoundtableJobRow.dialogues``。 + +Revision ID: 20260605_01 +Revises: 20260604_01 +Create Date: 2026-06-05 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect +from sqlalchemy.dialects.mysql import LONGTEXT + + +revision = "20260605_01" +down_revision = "20260604_01" +branch_labels = None +depends_on = None + + +def _has_table(table_name: str) -> bool: + inspector = inspect(op.get_bind()) + return table_name in inspector.get_table_names() + + +def _has_column(table_name: str, column_name: str) -> bool: + if not _has_table(table_name): + return False + inspector = inspect(op.get_bind()) + return any(col["name"] == column_name for col in inspector.get_columns(table_name)) + + +def upgrade() -> None: + if _has_table("roundtable_jobs") and not _has_column("roundtable_jobs", "dialogues"): + col_type = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text() + op.add_column("roundtable_jobs", sa.Column("dialogues", col_type, nullable=True)) + + +def downgrade() -> None: + if _has_column("roundtable_jobs", "dialogues"): + op.drop_column("roundtable_jobs", "dialogues") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/model.py index ae4130c0..1dc0ab8a 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/model.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_jobs/model.py @@ -55,6 +55,9 @@ class RoundtableJobRow(Base): orchestration_mode: Mapped[str] = mapped_column(String(16), nullable=False, default="recommend") # chain 模式来源链条快照:JSON object { id, title }(recommend 模式为 null). chain: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) + # 研讨对话记录:JSON array of { id, role('leader'|'seat'), agentId, name, content, cycle }。 + # 让用户跑完 / 进行中都能看到真实研讨内容(leader 说明 + 各席位交付),而非只有流程图。 + dialogues: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) # Terminal error detail when status=error. error: Mapped[str | None] = mapped_column(Text, nullable=True) # 时间列必须用 BeijingDateTime(项目规范,与 ai_writing_sessions / roundtable_drafts 对齐)。 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 ba431113..93ee52e9 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 @@ -55,7 +55,7 @@ class RoundtableJobRepository: for k in ("created_at", "updated_at"): if isinstance(d.get(k), datetime): d[k] = d[k].isoformat() - for k in ("dispatch_chain", "thread_ids", "chain"): + for k in ("dispatch_chain", "thread_ids", "chain", "dialogues"): d[k] = _loads(d.get(k)) return d @@ -118,6 +118,7 @@ class RoundtableJobRepository: pending_clarification=_UNSET, thread_ids=_UNSET, coordinator_name=_UNSET, + dialogues=_UNSET, error=_UNSET, ) -> dict[str, Any] | None: resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.update_progress") @@ -145,6 +146,8 @@ class RoundtableJobRepository: row.thread_ids = _dumps(thread_ids) if coordinator_name is not _UNSET: row.coordinator_name = coordinator_name + if dialogues is not _UNSET: + row.dialogues = _dumps(dialogues) if error is not _UNSET: row.error = error row.updated_at = datetime.now(UTC)