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 01/10] =?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) From 90bced3ed2d09d77199ec5b223c8ccdcc88a639b Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Thu, 4 Jun 2026 20:18:34 +0800 Subject: [PATCH 02/10] =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E6=AC=A1=E7=94=9F?= =?UTF-8?q?=E6=88=90=E7=BB=93=E6=9E=9C=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 --- .../src/roundtable-planning/api/drafts.ts | 23 ++ .../api/roundtable-jobs.ts | 1 + .../hooks/useDraftPersistence.ts | 15 +- .../hooks/useStep2Orchestration.ts | 4 +- .../src/roundtable-planning/lib/job-types.ts | 2 + .../pages/RoundtablePlanningPage.tsx | 352 +++++++++++++++++- .../gateway/roundtable_inprocess_gateway.py | 22 +- .../app/gateway/roundtable_job_executor.py | 35 +- .../app/gateway/routers/roundtable_jobs.py | 2 + .../20260605_02_roundtable_jobs_step3.py | 46 +++ .../persistence/roundtable_jobs/model.py | 3 + .../persistence/roundtable_jobs/sql.py | 5 +- 12 files changed, 465 insertions(+), 45 deletions(-) create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260605_02_roundtable_jobs_step3.py diff --git a/frontend-web/src/roundtable-planning/api/drafts.ts b/frontend-web/src/roundtable-planning/api/drafts.ts index ed15557a..bcca76bc 100644 --- a/frontend-web/src/roundtable-planning/api/drafts.ts +++ b/frontend-web/src/roundtable-planning/api/drafts.ts @@ -31,6 +31,8 @@ export interface DraftStep1Snapshot { } export interface DraftStep2Snapshot { + activeRunId?: string | null; + runs?: DraftStep2RunSnapshot[]; /** * The seats actually selected for this session. Restored on load so the * Step 2/3 「参与角色」panel shows the chosen agents instead of the default 6. @@ -57,6 +59,27 @@ export interface DraftStep2Snapshot { chain?: { id: string; title: string } | null; } +export interface DraftStep2RunSnapshot { + 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; + jobId?: string | null; + jobStatus?: JobStatus | null; +} + /** * Step 3「结果绘制」snapshot — the generated visualization report, stored inline * so a reloaded draft can show it without depending on the ephemeral report diff --git a/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts b/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts index a75e581f..1a4a664a 100644 --- a/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts +++ b/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts @@ -67,6 +67,7 @@ function mapJob(raw: Record): RoundtableJob { chain: (raw.chain as RoundtableJob["chain"]) ?? null, error: (raw.error as string | null) ?? null, dialogues: Array.isArray(raw.dialogues) ? (raw.dialogues as JobDialogue[]) : [], + step3: (raw.step3 as RoundtableJob["step3"]) ?? null, createdAt: (raw.createdAt as string | null) ?? null, updatedAt: (raw.updatedAt as string | null) ?? null, }; diff --git a/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts b/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts index 758ef135..a0cf8b8a 100644 --- a/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts +++ b/frontend-web/src/roundtable-planning/hooks/useDraftPersistence.ts @@ -19,7 +19,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { Step1Snapshot } from "@/roundtable-planning/hooks/useStep1Intent"; -import type { Step2Snapshot } from "@/roundtable-planning/hooks/useStep2Orchestration"; import { createDraft, deleteDraft, @@ -27,6 +26,7 @@ import { getDraft, listDrafts, updateDraft, + type DraftStep2Snapshot, type DraftStep3Snapshot, } from "@/roundtable-planning/api/drafts"; import type { JobStatus } from "@/roundtable-planning/lib/job-types"; @@ -51,13 +51,13 @@ export interface UseDraftPersistenceOptions { /** 读 Step 1 当前快照(由 useStep1Intent 提供的 getter)。 */ getStep1Snapshot: () => Step1Snapshot; /** 读 Step 2 当前快照(由 useStep2Orchestration 提供的 getter)。 */ - getStep2Snapshot: () => Step2Snapshot; + getStep2Snapshot: () => DraftStep2Snapshot; /** 读 Step 3「结果绘制」当前快照(无则 null)。 */ getStep3Snapshot?: () => DraftStep3Snapshot | null; /** 加载草稿时把 Step 1 状态打回去。 */ hydrateStep1: (snapshot: Step1Snapshot) => void; /** 加载草稿时把 Step 2 状态打回去。 */ - hydrateStep2: (snapshot: Step2Snapshot) => void; + hydrateStep2: (snapshot: DraftStep2Snapshot, step3: DraftStep3Snapshot | null) => void; /** 加载草稿时把 Step 3 结果打回去(无则传 null 清空)。 */ hydrateStep3?: (snapshot: DraftStep3Snapshot | null) => void; /** 加载草稿前先中断所有 Step 1 / Step 2 in-flight。 */ @@ -187,7 +187,7 @@ export function useDraftPersistence( }, [refreshList]); /** 当前会话是否有值得保存的内容。 */ - const hasSavableContent = useCallback((step1: Step1Snapshot, step2: Step2Snapshot): boolean => { + const hasSavableContent = useCallback((step1: Step1Snapshot, step2: DraftStep2Snapshot): boolean => { const hasUserMessage = step1.interactiveMessages.some((m) => m.sender === "user"); return hasUserMessage || step2.step2RoundtableDialogues.length > 0; }, []); @@ -350,7 +350,12 @@ export function useDraftPersistence( suppressStep2AutoSaveRef.current = true; if (draft.step1) hydrateStep1(draft.step1 as Step1Snapshot); - if (draft.step2) hydrateStep2(draft.step2 as Step2Snapshot); + if (draft.step2) { + hydrateStep2( + draft.step2 as DraftStep2Snapshot, + (draft.step3 as DraftStep3Snapshot | null) ?? null, + ); + } // 恢复 Step 3 报告(无则传 null 清掉上一条草稿残留),进入「结果绘制」即可看回。 hydrateStep3?.((draft.step3 as DraftStep3Snapshot | null) ?? null); setStep2InitGate(draft.furthestStep >= 2); diff --git a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts index 910beeed..f7dbf50b 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts @@ -336,6 +336,7 @@ export function useStep2Orchestration( const [selectedAgents, setSelectedAgents] = useState( DEFAULT_SELECTED_AGENTS, ); + const [initNonce, setInitNonce] = useState(0); const [threadIds, setThreadIds] = useState(null); const [coordinatorName, setCoordinatorName] = useState(null); const [isInitializing, setIsInitializing] = useState(false); @@ -1538,7 +1539,7 @@ export function useStep2Orchestration( // 只关心 currentStep 切换 —— buildInitialIntent / runOrchestration / 等 // 都是 useCallback / ref 包装,变化不应触发重新 init。 // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentStep]); + }, [currentStep, initNonce]); // 挂起 / 恢复:与上面的 init effect 独立。`isRoundTableRunning` toggle 时: // - false → stopOrchestration @@ -1738,6 +1739,7 @@ export function useStep2Orchestration( const resetInitGate = useCallback(() => { step2InitRef.current = false; + setInitNonce((n) => n + 1); }, []); const hydrateFromDraft = useCallback( diff --git a/frontend-web/src/roundtable-planning/lib/job-types.ts b/frontend-web/src/roundtable-planning/lib/job-types.ts index ae2595fc..fc957fa1 100644 --- a/frontend-web/src/roundtable-planning/lib/job-types.ts +++ b/frontend-web/src/roundtable-planning/lib/job-types.ts @@ -71,6 +71,8 @@ export interface RoundtableJob { error: string | null; /** 研讨对话记录(leader 说明 + 各席位交付),跑完 / 进行中都可看真实内容。 */ dialogues: JobDialogue[]; + /** Step3 报告快照(done 时有):{ html, generatedAt, model, summary }。 */ + step3: { html: string; generatedAt?: string; model?: string; summary?: string } | null; createdAt: string | null; updatedAt: string | null; } diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index d66f5e3d..ab993096 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -60,12 +60,19 @@ import { DispatchChainJobModal } from "@/roundtable-planning/components/Dispatch import { Step1Panel } from "@/roundtable-planning/components/Step1Panel"; import { Step2Panel } from "@/roundtable-planning/components/Step2Panel"; import { Step3Panel } from "@/roundtable-planning/components/Step3Panel"; -import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts"; +import type { + DraftStep2RunSnapshot, + DraftStep2Snapshot, + DraftStep3Snapshot, +} from "@/roundtable-planning/api/drafts"; import { useStep1Intent, type InteractiveMessage, } from "@/roundtable-planning/hooks/useStep1Intent"; -import { useStep2Orchestration } from "@/roundtable-planning/hooks/useStep2Orchestration"; +import { + useStep2Orchestration, + type Step2Snapshot, +} from "@/roundtable-planning/hooks/useStep2Orchestration"; import { useDraftPersistence } from "@/roundtable-planning/hooks/useDraftPersistence"; import { BUILTIN_AGENT_META, @@ -101,11 +108,15 @@ export default function RoundtablePlanningPage() { // chain 模式来源链条(仅展示/持久化用)。 const [chainMeta, setChainMeta] = useState<{ id: string; title: string } | null>(null); const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true); + const [step2Runs, setStep2Runs] = useState([]); + const [activeStep2RunId, setActiveStep2RunId] = useState(null); // Step 3「结果绘制」结果 —— 持久化进草稿 step3。绘制完成由 onReportSaved 写入; // 加载草稿时 hydrateStep3 恢复;新建任务清空。step3SavedTick 递增触发一次草稿自动保存。 const [step3Report, setStep3Report] = useState(null); const [step3SavedTick, setStep3SavedTick] = useState(0); + const step2RunsRef = useRef([]); + const activeStep2RunIdRef = useRef(null); // 角色面板(LeftSidebar)。原页面把它当成两步共用的展示数据,这里继续放在 // 主页 —— Step 1 / Step 2 / Step 3 都会读 / 写它,任何 hook 单独持有都不合适。 @@ -207,6 +218,173 @@ export default function RoundtablePlanningPage() { [step2.step2RoundtableDialogues], ); + useEffect(() => { + step2RunsRef.current = step2Runs; + }, [step2Runs]); + + useEffect(() => { + activeStep2RunIdRef.current = activeStep2RunId; + }, [activeStep2RunId]); + + const buildCurrentStep2Snapshot = useCallback( + (): DraftStep2Snapshot => ({ + 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, + chain: chainMeta, + }), + [ + chainMeta, + orchestrationMode, + step2.budgetLimit, + step2.consensusPercentage, + step2.coordinatorName, + step2.hasConsensus, + step2.lastLeaderContent, + step2.seatStubMessages, + step2.selectedAgents, + step2.step2RoundtableDialogues, + step2.threadIds, + ], + ); + + const snapshotToRun = useCallback( + ( + snapshot: DraftStep2Snapshot, + fallback: Partial> = {}, + ): DraftStep2RunSnapshot => ({ + id: fallback.id ?? `step2-run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + title: fallback.title ?? "第一次生成结果", + createdAt: fallback.createdAt ?? new Date().toISOString(), + source: fallback.source ?? snapshot.orchestrationMode ?? "initial", + selectedAgents: snapshot.selectedAgents, + threadIds: snapshot.threadIds, + coordinatorName: snapshot.coordinatorName, + step2RoundtableDialogues: snapshot.step2RoundtableDialogues ?? [], + lastLeaderContent: snapshot.lastLeaderContent ?? "", + hasConsensus: Boolean(snapshot.hasConsensus), + consensusPercentage: snapshot.consensusPercentage ?? 0, + budgetLimit: snapshot.budgetLimit ?? 0, + seatStubMessages: snapshot.seatStubMessages ?? {}, + orchestrationMode: snapshot.orchestrationMode, + chain: snapshot.chain ?? null, + step3: step3Report, + }), + [step3Report], + ); + + const mergeActiveRun = useCallback( + ( + runs: DraftStep2RunSnapshot[], + activeRunId: string | null, + snapshot: DraftStep2Snapshot, + report: DraftStep3Snapshot | null, + ): DraftStep2RunSnapshot[] => { + if (!activeRunId) return runs; + const idx = runs.findIndex((run) => run.id === activeRunId); + if (idx === -1) return runs; + const current = runs[idx]; + const next = snapshotToRun(snapshot, { + id: current.id, + title: current.title, + createdAt: current.createdAt, + source: current.source, + }); + next.step3 = report; + return runs.map((run, i) => (i === idx ? next : run)); + }, + [snapshotToRun], + ); + + const getStep2SnapshotWithRuns = useCallback((): DraftStep2Snapshot => { + const snapshot = buildCurrentStep2Snapshot(); + const runs = mergeActiveRun( + step2RunsRef.current, + activeStep2RunIdRef.current, + snapshot, + step3Report, + ); + return { + ...snapshot, + runs, + activeRunId: activeStep2RunIdRef.current, + }; + }, [buildCurrentStep2Snapshot, mergeActiveRun, step3Report]); + + const normalizeStep2Runs = useCallback( + ( + snapshot: DraftStep2Snapshot, + fallbackStep3: DraftStep3Snapshot | null, + ): { runs: DraftStep2RunSnapshot[]; activeRunId: string | null } => { + if (snapshot.runs?.length) { + const activeRunId = snapshot.activeRunId ?? snapshot.runs[0]?.id ?? null; + const runs = snapshot.runs.map((run) => + run.id === activeRunId && !run.step3 && fallbackStep3 + ? { ...run, step3: fallbackStep3 } + : run, + ); + return { + runs, + activeRunId, + }; + } + + const hasLegacyContent = + (snapshot.step2RoundtableDialogues?.length ?? 0) > 0 || + Boolean(snapshot.threadIds) || + Boolean(snapshot.lastLeaderContent); + + if (!hasLegacyContent) return { runs: [], activeRunId: null }; + + const legacy = snapshotToRun(snapshot, { + id: "legacy-run", + title: "第一次生成结果", + source: snapshot.orchestrationMode ?? "initial", + }); + legacy.step3 = fallbackStep3; + return { runs: [legacy], activeRunId: legacy.id }; + }, + [snapshotToRun], + ); + + const hydrateStep2Run = useCallback( + (run: DraftStep2RunSnapshot) => { + setOrchestrationMode(run.orchestrationMode ?? "recommend"); + setChainMeta(run.chain ?? null); + setStep3Report(run.step3 ?? null); + step2.hydrateFromDraft(run as unknown as Step2Snapshot); + }, + [step2], + ); + + const handleSelectStep2Run = useCallback( + (runId: string) => { + if (runId === activeStep2RunIdRef.current) return; + step2.stopOrchestration(); + const currentSnapshot = buildCurrentStep2Snapshot(); + const syncedRuns = mergeActiveRun( + step2RunsRef.current, + activeStep2RunIdRef.current, + currentSnapshot, + step3Report, + ); + const target = syncedRuns.find((run) => run.id === runId); + if (!target) return; + setStep2Runs(syncedRuns); + setActiveStep2RunId(runId); + hydrateStep2Run(target); + setCurrentStep(2); + }, + [buildCurrentStep2Snapshot, hydrateStep2Run, mergeActiveRun, step2, step3Report], + ); + // 草稿 hook —— 同时依赖 step1 + step2 的 snapshot / hydrate。 const drafts = useDraftPersistence({ currentStep, @@ -223,26 +401,23 @@ export default function RoundtablePlanningPage() { interactiveMessages: step1.interactiveMessages, timelineEntries: step1.timelineEntries, }), - getStep2Snapshot: () => ({ - 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, - chain: chainMeta, - }), + getStep2Snapshot: getStep2SnapshotWithRuns, hydrateStep1: step1.hydrateFromDraft, // 包一层:先把页面持有的编排模式 / 链条恢复出来(老草稿无字段 → recommend), // 再交给 step2 hook 恢复其余 Step 2 状态。 - hydrateStep2: (snapshot) => { + hydrateStep2: (snapshot, savedStep3) => { + const normalized = normalizeStep2Runs(snapshot, savedStep3); + setStep2Runs(normalized.runs); + setActiveStep2RunId(normalized.activeRunId); + const active = + normalized.runs.find((run) => run.id === normalized.activeRunId) ?? null; + if (active) { + hydrateStep2Run(active); + return; + } setOrchestrationMode(snapshot.orchestrationMode ?? "recommend"); setChainMeta(snapshot.chain ?? null); - step2.hydrateFromDraft(snapshot); + step2.hydrateFromDraft(snapshot as unknown as Step2Snapshot); }, cancelAllStreams: () => { step2.abortAllInFlight(); @@ -309,6 +484,38 @@ export default function RoundtablePlanningPage() { // 内置 agent 走 BUILTIN_AGENT_META;非内置用候选池传过来的 name。 name: BUILTIN_AGENT_META[agent_id]?.displayName ?? name ?? agent_id, })); + step2.stopOrchestration(); + const currentSnapshot = buildCurrentStep2Snapshot(); + const syncedRuns = mergeActiveRun( + step2RunsRef.current, + activeStep2RunIdRef.current, + currentSnapshot, + step3Report, + ); + const nextRunNumber = syncedRuns.length + 1; + const newRunId = `step2-run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const newRun: DraftStep2RunSnapshot = { + id: newRunId, + title: `第 ${nextRunNumber} 次生成结果`, + createdAt: new Date().toISOString(), + source: result.mode, + selectedAgents: normalized, + threadIds: null, + coordinatorName: null, + step2RoundtableDialogues: [], + lastLeaderContent: "", + hasConsensus: false, + consensusPercentage: 0, + budgetLimit: step2.budgetLimit, + seatStubMessages: {}, + orchestrationMode: result.mode, + chain: result.mode === "chain" ? result.chain ?? null : null, + step3: null, + }; + setStep2Runs([...syncedRuns, newRun]); + setActiveStep2RunId(newRunId); + setStep3Report(null); + step2.resetStep2State(); step2.setSelectedAgents(normalized); step2.resetInitGate(); // result.mode 决定 Step 2 编排:recommend = 总控自由派活;chain = 按链条顺序派活。 @@ -322,7 +529,7 @@ export default function RoundtablePlanningPage() { : "成功确认!已切换至第二阶段圆桌研讨系统", ); }, - [step2, triggerToast], + [buildCurrentStep2Snapshot, mergeActiveRun, step2, step3Report, triggerToast], ); // 把页面重置回「第一步全新任务」——新建任务 + 后台挂起后都复用。 @@ -331,6 +538,8 @@ export default function RoundtablePlanningPage() { const resetToStep1 = useCallback(() => { step2.stopOrchestration(); step2.resetStep2State(); + setStep2Runs([]); + setActiveStep2RunId(null); setOrchestrationMode("recommend"); setChainMeta(null); setStep3Report(null); @@ -603,6 +812,9 @@ export default function RoundtablePlanningPage() { intentReady={step1.intentReady} sidebarExpanded={step2SidebarExpanded} setSidebarExpanded={setStep2SidebarExpanded} + step2Runs={step2Runs} + activeStep2RunId={activeStep2RunId} + onSelectStep2Run={handleSelectStep2Run} /> {/* MIDDLE VIEWPORT */} @@ -700,6 +912,11 @@ export default function RoundtablePlanningPage() { savedReport={step3Report} onReportSaved={(snap) => { setStep3Report(snap); + setStep2Runs((prev) => + prev.map((run) => + run.id === activeStep2RunIdRef.current ? { ...run, step3: snap } : run, + ), + ); setStep3SavedTick((t) => t + 1); }} onReset={handleStartNewTask} @@ -1027,6 +1244,9 @@ interface LeftSidebarProps { intentReady: ReturnType["intentReady"]; sidebarExpanded: boolean; setSidebarExpanded: React.Dispatch>; + step2Runs: DraftStep2RunSnapshot[]; + activeStep2RunId: string | null; + onSelectStep2Run: (runId: string) => void; } function Step2SandboxSidebarSync({ @@ -1063,6 +1283,9 @@ function LeftSidebar({ intentReady, sidebarExpanded, setSidebarExpanded, + step2Runs, + activeStep2RunId, + onSelectStep2Run, }: LeftSidebarProps) { const isStep2 = currentStep === 2; @@ -1116,6 +1339,11 @@ function LeftSidebar({
+ ) : ( @@ -1170,6 +1398,94 @@ function LeftSidebar({ ); } +function Step2RunSwitcher({ + runs, + activeRunId, + onSelect, +}: { + runs: DraftStep2RunSnapshot[]; + activeRunId: string | null; + onSelect: (runId: string) => void; +}) { + if (runs.length === 0) return null; + + const formatTime = (value: string) => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + }; + + const sourceLabel = (run: DraftStep2RunSnapshot) => { + if (run.source === "chain") return "业务链条"; + if (run.source === "recommend") return "智能体推荐"; + return "历史结果"; + }; + + const statusLabel = (run: DraftStep2RunSnapshot) => { + if (run.step3) return "已生成报告"; + if (run.hasConsensus) return "已达成共识"; + if ((run.step2RoundtableDialogues?.length ?? 0) > 0) return "研讨记录"; + return "待开始"; + }; + + return ( +
+
+ +

生成结果

+
+
+ {runs.map((run) => { + const active = run.id === activeRunId; + return ( + + ); + })} +
+
+ ); +} + function TaskOverviewCard({ currentStep }: { currentStep: number }) { return (
diff --git a/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py b/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py index 203ccb08..4353da31 100644 --- a/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py +++ b/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py @@ -149,12 +149,26 @@ async def _run_agent_turn( def _read_report_html(app, thread_id: str, user_id: str) -> str | None: - """从 report thread 的 outputs/方案总览.html 读出报告 HTML。""" + """从 report thread 的 outputs 目录读出报告 HTML。 + + 优先 ``方案总览.html``;找不到就扫目录里**任意 .html**(取体积最大的那个), + 容忍报告 agent 用了别的文件名。 + """ try: outputs_dir = get_paths().sandbox_outputs_dir(thread_id, user_id=user_id) - path = outputs_dir / _REPORT_FILE - if path.exists(): - return path.read_text(encoding="utf-8") + preferred = outputs_dir / _REPORT_FILE + if preferred.exists(): + return preferred.read_text(encoding="utf-8") + if outputs_dir.exists(): + htmls = sorted( + outputs_dir.glob("*.html"), + key=lambda p: p.stat().st_size, + reverse=True, + ) + if htmls: + logger.info("report html: 方案总览.html not found, using %s", htmls[0].name) + return htmls[0].read_text(encoding="utf-8") + logger.warning("report html: no .html found in %s", outputs_dir) except Exception: # noqa: BLE001 logger.warning("read report html failed for thread %s", thread_id, exc_info=True) return None 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 5732a1bb..b08c9ab4 100644 --- a/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py +++ b/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py @@ -106,23 +106,26 @@ class RoundtableJobExecutor: mode=params.mode, intent_text=params.intent_text, ) - # 完成且有报告:写回关联草稿的 step3 + 推进 furthest_step=3, - # 这样用户从历史「查看报告」加载草稿即可看到最终方案(沿用既有 hydrateStep3 路径)。 - if ( - result.status == "done" - and result.step3 - and params.draft_id - and self._draft_store is not None - ): + # 完成且有报告: + # 1) 把 step3 挂到 job 上 —— 前端「多运行」模型从单个 job 即可拿到完整结果 + # (对话 dialogues + 报告 step3)组装一个 run; + # 2) 同时写回关联草稿的顶层 step3 + 推进 furthest_step=3,兼容既有 + # 「查看报告 → 加载草稿 → Step3」路径。 + if result.status == "done" and result.step3: try: - await self._draft_store.update_draft( - params.draft_id, - params.user_id, - step3=result.step3, - furthest_step=3, - ) - except Exception: # noqa: BLE001 — 写报告失败不影响作业本身已 done - logger.warning("persist step3 to draft %s failed", params.draft_id, exc_info=True) + await self._store.update_progress(params.job_id, step3=result.step3) + except Exception: # noqa: BLE001 + logger.warning("persist step3 to job %s failed", params.job_id, exc_info=True) + if params.draft_id and self._draft_store is not None: + try: + await self._draft_store.update_draft( + params.draft_id, + params.user_id, + step3=result.step3, + furthest_step=3, + ) + except Exception: # noqa: BLE001 — 写报告失败不影响作业本身已 done + logger.warning("persist step3 to draft %s failed", params.draft_id, exc_info=True) except asyncio.CancelledError: # 干净停止即可,**不在被取消的任务里写 DB**:取消态下二次 await 会再抛 # CancelledError、并可能打断进行中的 sqlite 连接。``cancelled`` 终态由 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 fbad2d4b..4e3d4b9e 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py +++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py @@ -98,6 +98,7 @@ class RoundtableJobResponse(BaseModel): chain: dict[str, Any] | None = None error: str | None = None dialogues: list[dict[str, Any]] = Field(default_factory=list) + step3: dict[str, Any] | None = None createdAt: datetime | str | None = None updatedAt: datetime | str | None = None @@ -149,6 +150,7 @@ def _to_response(row: dict[str, Any]) -> RoundtableJobResponse: chain=row.get("chain"), error=row.get("error"), dialogues=row.get("dialogues") or [], + step3=row.get("step3"), createdAt=row.get("created_at"), updatedAt=row.get("updated_at"), ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260605_02_roundtable_jobs_step3.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260605_02_roundtable_jobs_step3.py new file mode 100644 index 00000000..35548cf0 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260605_02_roundtable_jobs_step3.py @@ -0,0 +1,46 @@ +"""Add step3 column to roundtable_jobs. + +把作业的 Step3 报告({html, generatedAt, model, summary})直接挂在 job 上,使前端 +「多运行」模型(draft.step2.runs[])能从单个 job 读到完整结果(对话 + 报告)组装一个 run, +而不必依赖草稿顶层的单一 step3。LONGTEXT(HTML 可超 64 KB)。 + +Revision ID: 20260605_02 +Revises: 20260605_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_02" +down_revision = "20260605_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", "step3"): + col_type = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text() + op.add_column("roundtable_jobs", sa.Column("step3", col_type, nullable=True)) + + +def downgrade() -> None: + if _has_column("roundtable_jobs", "step3"): + op.drop_column("roundtable_jobs", "step3") 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 1dc0ab8a..1c4c2034 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 @@ -58,6 +58,9 @@ class RoundtableJobRow(Base): # 研讨对话记录:JSON array of { id, role('leader'|'seat'), agentId, name, content, cycle }。 # 让用户跑完 / 进行中都能看到真实研讨内容(leader 说明 + 各席位交付),而非只有流程图。 dialogues: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) + # Step3 报告快照:JSON object { html, generatedAt, model, summary }。done 时写入, + # 使前端从单个 job 即可拿到完整结果(对话 + 报告)。 + step3: 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 93ee52e9..e9e9a92a 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", "dialogues"): + for k in ("dispatch_chain", "thread_ids", "chain", "dialogues", "step3"): d[k] = _loads(d.get(k)) return d @@ -119,6 +119,7 @@ class RoundtableJobRepository: thread_ids=_UNSET, coordinator_name=_UNSET, dialogues=_UNSET, + step3=_UNSET, error=_UNSET, ) -> dict[str, Any] | None: resolved_user_id = resolve_user_id(user_id, method_name="RoundtableJobRepository.update_progress") @@ -148,6 +149,8 @@ class RoundtableJobRepository: row.coordinator_name = coordinator_name if dialogues is not _UNSET: row.dialogues = _dumps(dialogues) + if step3 is not _UNSET: + row.step3 = _dumps(step3) if error is not _UNSET: row.error = error row.updated_at = datetime.now(UTC) From cca3d8eb41ac2be356d921d24bdf181b892df1ea Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Thu, 4 Jun 2026 20:49:32 +0800 Subject: [PATCH 03/10] =?UTF-8?q?=E5=AD=97=E4=BD=93=E5=A4=A7=E5=B0=8F?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/MessageBubble.tsx | 8 +-- .../components/markdown-components.tsx | 67 ++++++++++++++----- .../pages/RoundtablePlanningPage.tsx | 59 ++++++++-------- .../styles/roundtable-planning.css | 32 +++++++-- frontend-web/src/styles/globals.css | 5 +- 5 files changed, 116 insertions(+), 55 deletions(-) diff --git a/frontend-web/src/roundtable-planning/components/MessageBubble.tsx b/frontend-web/src/roundtable-planning/components/MessageBubble.tsx index fd5fef4b..00470de6 100644 --- a/frontend-web/src/roundtable-planning/components/MessageBubble.tsx +++ b/frontend-web/src/roundtable-planning/components/MessageBubble.tsx @@ -119,16 +119,16 @@ export const sharedMarkdownComponents = {
  • {children}
  • ), h1: ({ children }: { children?: React.ReactNode }) => ( -

    {children}

    +

    {children}

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

    {children}

    +

    {children}

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

    {children}

    +

    {children}

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

    {children}

    +

    {children}

    ), strong: ({ children }: { children?: React.ReactNode }) => ( {children} diff --git a/frontend-web/src/roundtable-planning/components/markdown-components.tsx b/frontend-web/src/roundtable-planning/components/markdown-components.tsx index ca534ba6..81f6adab 100644 --- a/frontend-web/src/roundtable-planning/components/markdown-components.tsx +++ b/frontend-web/src/roundtable-planning/components/markdown-components.tsx @@ -1,44 +1,75 @@ /** - * 圆桌 Step 3 报告里 `react-markdown` 的紧凑渲染组件集(小字号、卡片内排版)。 + * 圆桌 Step 3 报告里 `react-markdown` 的渲染组件集(卡片内排版)。 * 抽到独立文件供 `HighFidelityReport` 与 `Step3ReportMode` 共用,避免两者互相 import * 形成循环依赖。 */ export const markdownComponents = { - p: ({ children }: any) =>

    {children}

    , - ul: ({ children }: any) =>
      {children}
    , - ol: ({ children }: any) =>
      {children}
    , - li: ({ children }: any) =>
  • {children}
  • , - h1: ({ children }: any) =>

    {children}

    , - h2: ({ children }: any) =>

    {children}

    , - h3: ({ children }: any) =>

    {children}

    , - h4: ({ children }: any) =>

    {children}

    , - strong: ({ children }: any) => {children}, + p: ({ children }: any) => ( +

    {children}

    + ), + ul: ({ children }: any) => ( +
      {children}
    + ), + ol: ({ children }: any) => ( +
      {children}
    + ), + li: ({ children }: any) =>
  • {children}
  • , + h1: ({ children }: any) => ( +

    {children}

    + ), + h2: ({ children }: any) => ( +

    {children}

    + ), + h3: ({ children }: any) => ( +

    {children}

    + ), + h4: ({ children }: any) => ( +

    {children}

    + ), + strong: ({ children }: any) => ( + {children} + ), em: ({ children }: any) => {children}, code: ({ children, className }: any) => { const isBlock = /language-/.test(className ?? ""); if (isBlock) { return ( -
    +        
               {children}
             
    ); } return ( - {children} + + {children} + ); }, blockquote: ({ children }: any) => ( -
    {children}
    +
    + {children} +
    ), a: ({ children, href }: any) => ( - + {children} ), - table: ({ children }: any) => {children}
    , - th: ({ children }: any) => ( - {children} + table: ({ children }: any) => ( + {children}
    + ), + th: ({ children }: any) => ( + + {children} + + ), + td: ({ children }: any) => ( + {children} ), - td: ({ children }: any) => {children}, hr: () =>
    , }; diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index ab993096..e2582397 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -1528,17 +1528,17 @@ function HumanParticipationCard() {

    人工参与

    -
    -
    - - 用户 +
    +
    + + 用户
    -
    +
    - 在线 + 在线
    @@ -1808,7 +1808,7 @@ function RoleRow({ return (
    -
    - -
    - {role.name} +
    + +
    + + {role.name} + {role.type === "agent" && ( - AI 智能体 + AI 智能体 )}
    -
    +
    {isOngoing ? ( @@ -1845,7 +1847,7 @@ function RoleRow({ )} 澄清记录
    -
    -
    +
    +
    {timelineEntries.map((entry) => ( -
    -
    -
    -
    - - {entry.time} - -

    +
    +
    +
    + + {entry.time} + +
    +

    {entry.title}

    +

    + {entry.desc} +

    -

    - {entry.desc} -

    ))} diff --git a/frontend-web/src/roundtable-planning/styles/roundtable-planning.css b/frontend-web/src/roundtable-planning/styles/roundtable-planning.css index b16247a0..a248d43a 100644 --- a/frontend-web/src/roundtable-planning/styles/roundtable-planning.css +++ b/frontend-web/src/roundtable-planning/styles/roundtable-planning.css @@ -10,12 +10,13 @@ --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); + /* 圆桌全页最小可读字号(含 Step 3 方案展示 / 结果绘制) */ + --roundtable-chat-font-size: 16px; + --roundtable-chat-meta-font-size: 16px; + font-size: 16px; } -/* 会商对话区:气泡正文、步骤卡、Markdown 块统一继承 */ +/* 会商对话区:气泡正文、步骤卡、Markdown 块 */ .roundtable-chat-message { font-size: var(--roundtable-chat-font-size); line-height: 1.625; @@ -23,6 +24,29 @@ .roundtable-chat-meta { font-size: var(--roundtable-chat-meta-font-size); + line-height: 1.5; +} + +/* + * 抬升页内显式写死的小字号类(text-xs / text-sm / text-[10–14px] / calc), + * 避免 Step 3、侧栏、按钮条等遗漏逐文件改 class。text-base 及以上标题保持不变。 + */ +.roundtable-planning :where(.text-xs, .text-sm) { + font-size: 16px; +} + +.roundtable-planning :where( + [class*="text-[9px"], + [class*="text-[10px"], + [class*="text-[10.5px"], + [class*="text-[11px"], + [class*="text-[11.5px"], + [class*="text-[12px"], + [class*="text-[13px"], + [class*="text-[14px"], + [class*="text-[calc"] +):not(.w-5) { + font-size: 16px; } .roundtable-planning ::-webkit-scrollbar-thumb { diff --git a/frontend-web/src/styles/globals.css b/frontend-web/src/styles/globals.css index cb36f5ea..947e290a 100644 --- a/frontend-web/src/styles/globals.css +++ b/frontend-web/src/styles/globals.css @@ -72,8 +72,9 @@ @theme { --font-sans: - ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", - "Segoe UI Symbol", "Noto Color Emoji"; + "Noto Sans S Chinese", "Noto Sans SC", "Source Han Sans SC", "思源黑体", + "Microsoft YaHei", "PingFang SC", ui-sans-serif, system-ui, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; --animate-fade-in: fade-in 1.1s; @keyframes fade-in { From 9bbd04d6cf2ba1ec9bc7aa6be78e598d2327218b Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Fri, 5 Jun 2026 10:59:56 +0800 Subject: [PATCH 04/10] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E6=8C=82=E8=B5=B7?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...agent-background-run-遗留问题与下次继续.md | 124 ++++++++++ frontend-web/package-lock.json | 88 ++++++- frontend-web/package.json | 1 + frontend-web/src/main.tsx | 10 +- .../components/DispatchChainModal.tsx | 22 +- .../pages/RoundtablePlanningPage.tsx | 49 ++-- .../gateway/roundtable_inprocess_gateway.py | 110 ++++++--- .../app/gateway/roundtable_job_executor.py | 219 ++++++++++++++++-- .../app/gateway/roundtable_run_policy.py | 94 ++++++++ .../app/gateway/routers/multi_agent.py | 46 ++-- .../app/gateway/routers/roundtable_jobs.py | 2 + .../agents/roundtable_orchestrator/engine.py | 127 ++++++++-- .../roundtable_orchestrator/progress.py | 84 ++++++- .../agents/roundtable_orchestrator/types.py | 6 + 14 files changed, 846 insertions(+), 136 deletions(-) create mode 100644 frontend-web/docs/multi-agent-background-run-遗留问题与下次继续.md create mode 100644 offline-backend-20260512/backend/app/gateway/roundtable_run_policy.py diff --git a/frontend-web/docs/multi-agent-background-run-遗留问题与下次继续.md b/frontend-web/docs/multi-agent-background-run-遗留问题与下次继续.md new file mode 100644 index 00000000..ba2eefde --- /dev/null +++ b/frontend-web/docs/multi-agent-background-run-遗留问题与下次继续.md @@ -0,0 +1,124 @@ +# 多智能体「后台挂起」—— 当前状态、遗留问题与下次继续指南 + +> 配套设计文档:`multi-agent-background-run-dev.md`(分 Phase 0-9 的总方案)。 +> 本文记录**截至本次会话的实际进展 + 还没解决的遗留问题**,方便下次直接续接。 + +--- + +## 一、整体状态:跑通到哪了 + +「后台挂起」= Step2 把研讨交给后端**进程内驱动真模型**(leader→各席位→共识→报告), +survive 刷新/关页,对话与报告作为一个 **run 写进草稿**(和手动跑同一存取),可在历史回看。 + +**真网关已在本地验证可跑通**(2026-06-04 日志确认:roundtable-coordinator + 各席位 + +roundtable-report 真调 deepseek、真搜索、跑到 report run success)。但还有几个**内容/产物层面的遗留问题**(见第三节)。 + +### 已完成(可用) +- ✅ 后端持久化:`roundtable_jobs` 表(迁移 `20260604_01` / `20260605_01` dialogues / `20260605_02` step3)。 +- ✅ 编排引擎 `packages/harness/deerflow/agents/roundtable_orchestrator/`(recommend + chain + 报告,纯逻辑可单测)。 +- ✅ 控制面 `app/gateway/routers/roundtable_jobs.py`:start/get/by-draft/stream(SSE)/resume/cancel/admin-cleanup。 +- ✅ 执行器 `app/gateway/roundtable_job_executor.py`:后台任务 + `set_current_user` + 生命周期 + **每轮把对话作为 `job-` run 写进 `draft.step2.runs[]`(同手动存取)** + done 写 step3。 +- ✅ 真网关 `app/gateway/roundtable_inprocess_gateway.py`:`SimpleNamespace(app)` + `start_run` 进程内驱动 agent;复用 `multi_agent._extract_dispatch` 解析派活;checkpointer 读最终 messages。 +- ✅ 模拟网关 `roundtable_orchestrator/simulated_gateway.py`(不调真模型,前端联调用)。 +- ✅ 前端:`api/roundtable-jobs.ts`、`lib/job-types.ts`、Step2「后台挂起」按钮、历史下拉 loading、`DispatchChainModal`/`DispatchChainJobModal`(流程图 + 对话面板 markdown + 加宽 + 澄清作答 + 取消 + 查看)。 +- ✅ 上下文「广播」(inline):席位/总控 prompt 带上「任务背景 + 前序各席位交付 + 别提问」约束(修「席位拿不到关键信息、反问用户、总控瞎编」)。 +- ✅ 后端测试:`tests/test_roundtable_jobs.py` / `test_roundtable_orchestrator.py` / `test_roundtable_job_executor.py` / `test_roundtable_jobs_api.py` 全过。 + +--- + +## 二、怎么启动 / 验证(每次开工先做) + +1. **开真网关**(默认是模拟网关):已写进 `offline-backend-20260512/.env`: + ``` + ROUNDTABLE_JOB_GATEWAY=inprocess + ``` + 启动日志应出现 `[roundtable-jobs] gateway = inprocess (real models)`。 + +2. **必须用 `make gateway`(不带 --reload)跑后端!** + `make dev` 的 `--reload` 监视 `backend/` 目录,agent 运行时往 `.deer-flow/` 写文件 + (memory.json / checkpoint / 沙箱输出)会触发热重载,**把长时后台任务杀掉**。 + (改进项:给 uvicorn 加 `--reload-exclude '.deer-flow/*'`,或始终用 `make gateway`。) + +3. **直接查库验证**(最有效,PowerShell 控制台是 GBK,导出到文件看中文): + ```bash + cd offline-backend-20260512/backend + # 查最新作业的 dialogues / step3 + PYTHONPATH=. uv run python -c "import sqlite3,json; con=sqlite3.connect('.deer-flow/data/deerflow.db'); ..." + ``` + - 作业表:`.deer-flow/data/deerflow.db` → 表 `roundtable_jobs`(`dialogues`/`step3` 列)。 + - 草稿表:`roundtable_drafts` → `step2` 列里应有 `runs[ {id:"job-", jobId, jobStatus, step2RoundtableDialogues:[...], step3:{...}} ]`。 + +4. **前端验证**:后台挂起 → 等跑完 → 从历史加载该草稿 → Step2 应显示后台研讨完整对话(走现有 `normalizeStep2Runs`/`hydrateStep2Run`)。 + +--- + +## 三、遗留问题(下次重点,已按优先级排) + +### P0 —— 报告 agent 不写 HTML 文件(结果绘制拿不到产物) +- **现象**:后台报告 run 跑完,日志 `report html: no .html found in ...threads//user-data/outputs`; + `run.step3.html` 回退成报告 agent 的**纯文字**(不含 ``),第3步画不出东西。 +- **已做**:`build_report_prompt`(`roundtable_orchestrator/engine.py`)已对齐前端 + `useStep3Report.buildReportPrompt`,明确「用 write_file 写入 /mnt/user-data/outputs/方案总览.html」; + `_read_report_html`(`roundtable_inprocess_gateway.py`)已改为扫 outputs 目录任意 `.html`。 +- **仍没解决**:报告 agent(deepseek-chat)还是没调 `write_file`。 +- **下次怎么查**: + 1. 对比**前端那条能写出 HTML 的报告 run** 的确切调用(`useStep3Report.ts` 里 `streamMultiAgent` + 调 `/api/multi-agent/run/stream` 的 body:`agent_type`/`excluded_tools`/`context` 等), + 和后台 `_run_agent_turn`(`roundtable_inprocess_gateway.py`,agent_name=roundtable-report, + `excluded_tools=_REPORT_EXCLUDED`)逐项比对,找出差异(怀疑 reasoning_effort / thinking / + 某个 context 字段 / recursion 步数导致它没走到 write_file)。 + 2. 看报告 run 的最终 messages(checkpoint)里有没有 write_file 的 tool_call(被 dangling 截断?)。 + 3. 报告 agent 的 SOUL:`app/gateway/routers/_roundtable_seed/roundtable-report/SOUL.md`——确认它默认行为。 + +### P1 —— 内容质量(本次刚加 inline 广播,待真机验证) +- **已做**:席位/总控 prompt 注入「任务背景 + 前序交付 + 别提问」。 +- **待验证**:真机跑一次,看席位是否基于真实上下文产出(不再反问/凭记忆瞎编)。 +- **若 inline 不够**:考虑实现**真·thread 广播**(像 `multi_agent._append_thread_message` 那样把 + 「用户意图 / 派活 / 各席位交付」追加进各 thread 历史)。进程内要避开 HTTP loopback 鉴权, + 得用 checkpointer 直接 append human 消息(参考 `threads` 路由 POST `/state` 的内部逻辑)。这块较重,先看 inline 够不够。 + +### P2 —— 对话「没有步骤(steps)」 +- **现象**:手动跑的气泡带工具调用步骤卡(`onPhaseChange` → `appendStepIfNew` → `Step2Dialogue.steps`); + 后台对话只存了最终正文,没有 steps。 +- **下次怎么做**:后台 `_run_agent_turn` 跑完后,从 checkpoint 的 messages 里抽出 tool_calls, + 映射成 `Step2Dialogue.steps`(`{id,key,label,ts,args}`),在执行器 `_to_step2_dialogue` 里带上。 + 纯展示增强,不影响内容/报告。 + +### P3 —— 双跑浪费 + --reload 坑(工程健壮性) +- 进 Step2 时前端会自动跑一遍本地研讨(真模型、真线程);点后台挂起后前端那条 run 在后端还会跑完 + (`stopOrchestration` 只断客户端、停不了后端 run)→ 和后台作业短暂双跑、双倍 token。 + - 优化方向:进 Step2 改成「显式开始」而非自动跑;或后台挂起时真正 cancel 前端那条 run。 +- `make dev` 的 `--reload` 会杀后台任务(见第二节);改进项:`--reload-exclude '.deer-flow/*'`。 + +--- + +## 四、关键文件清单(下次定位用) + +**后端** +| 文件 | 作用 | +|------|------| +| `app/gateway/roundtable_inprocess_gateway.py` | 真网关:进程内驱动 leader/seat/re 编排循环 + `build_report_prompt` + inline 广播(`_seat_message`/`_deliveries_block`) | +| `.../roundtable_orchestrator/progress.py` | 进度落库 + `on_dialogue` 每轮回调 | +| `.../persistence/roundtable_jobs/` | job 表 model/sql | +| `app/gateway/deps.py` | `ROUNDTABLE_JOB_GATEWAY` 选网关 + 注入 executor(draft_store) | + +**前端** +| 文件 | 作用 | +|------|------| +| `src/roundtable-planning/api/roundtable-jobs.ts` | 作业 API + SSE | +| `src/roundtable-planning/lib/job-types.ts` | 类型(RoundtableJob/JobDialogue/...) | +| `src/roundtable-planning/components/DispatchChainModal.tsx` / `DispatchChainJobModal.tsx` | 流程图弹窗 + 对话面板 | +| `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | 后台挂起按钮、历史下拉、**runs[] 多运行模型**(`hydrateStep2Run`/`getStep2SnapshotWithRuns`/`normalizeStep2Runs`) | +| `src/roundtable-planning/api/drafts.ts` | `DraftStep2RunSnapshot`(带 jobId/jobStatus/step3) | + +--- + +## 五、关键架构事实(避免重复踩坑) + +1. **真网关进程内驱动**:`start_run(body, thread_id, SimpleNamespace(app=app))` + `set_current_user`, + 不走 HTTP(loopback 调 `/api/multi-agent/*` 不可行:`_auth_headers` 只转发用户 token + owner check)。 +2. **后台作业首启自建全新线程**(`StartParams.is_resume`),绝不复用前端本地研讨的线程(会撞 409)。 +3. **存取统一**:后台作业 = `draft.step2.runs[]` 里一个带 jobId 的 run,和手动跑同一存取/hydrate 路径, + **不要再开 observe/回显分支**(之前试过,被否)。 +4. **聊天记录确实存上了**(job.dialogues + draft 的 run);之前"没存上"是测了旧代码 / 看的是前端本地残缺数据。 +5. 多 worker / SSE:状态进 DB、SSE 走 DB 轮询;真网关进程内无 loopback。 diff --git a/frontend-web/package-lock.json b/frontend-web/package-lock.json index 0437d4af..8dc58311 100644 --- a/frontend-web/package-lock.json +++ b/frontend-web/package-lock.json @@ -15,6 +15,10 @@ "@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-python": "^6.2.1", "@codemirror/language-data": "^6.5.2", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@fontsource/noto-sans-sc": "^5.2.9", "@langchain/core": "^1.1.15", "@langchain/langgraph-sdk": "^1.5.3", "@radix-ui/react-avatar": "^1.1.11", @@ -55,9 +59,12 @@ "cmdk": "^1.1.1", "codemirror": "^6.0.2", "date-fns": "^4.1.0", + "dayjs": "^1.11.21", + "dnd-kit": "^0.0.2", "docx": "^9.5.1", "dotenv": "^17.2.3", "embla-carousel-react": "^8.6.0", + "file-saver": "^2.0.5", "gsap": "^3.13.0", "hast": "^1.0.0", "katex": "^0.16.28", @@ -90,6 +97,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.0.15", + "@types/file-saver": "^2.0.7", "@types/gsap": "^3.0.0", "@types/node": "^20.14.10", "@types/react": "^19.0.0", @@ -858,6 +866,55 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1334,6 +1391,14 @@ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==" }, + "node_modules/@fontsource/noto-sans-sc": { + "version": "5.2.9", + "resolved": "https://registry.npmjs.org/@fontsource/noto-sans-sc/-/noto-sans-sc-5.2.9.tgz", + "integrity": "sha512-bTUIWGBgJDpwi5qAr+x0/lcgv80IHTB9vl6s2f6EymZEa7qYV99yNRBZuKFT+SYDKVunZrjCEhWtpxqmbXWl5Q==", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@iconify/types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", @@ -4995,6 +5060,12 @@ "@types/estree": "*" } }, + "node_modules/@types/file-saver": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", + "dev": true + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -6146,9 +6217,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.20", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", - "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==" + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==" }, "node_modules/debug": { "version": "4.4.3", @@ -6228,6 +6299,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dnd-kit": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/dnd-kit/-/dnd-kit-0.0.2.tgz", + "integrity": "sha512-d8AYd6I7D2b5u882+QNVGw0slBAt851/LWZ2j/pU+onf5/TGEKXeb47sCyhPYKEAUXp4oLfvWfNCqfkU03R1lw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info." + }, "node_modules/docx": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz", @@ -6470,6 +6547,11 @@ } } }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" + }, "node_modules/format": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", diff --git a/frontend-web/package.json b/frontend-web/package.json index f7eed944..c977bb51 100644 --- a/frontend-web/package.json +++ b/frontend-web/package.json @@ -22,6 +22,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@fontsource/noto-sans-sc": "^5.2.9", "@langchain/core": "^1.1.15", "@langchain/langgraph-sdk": "^1.5.3", "@radix-ui/react-avatar": "^1.1.11", diff --git a/frontend-web/src/main.tsx b/frontend-web/src/main.tsx index 924e3e76..b7b08da6 100644 --- a/frontend-web/src/main.tsx +++ b/frontend-web/src/main.tsx @@ -1,6 +1,10 @@ -import "./styles/globals.css"; -import "tdesign-react/es/style/index.css"; -import "katex/dist/katex.min.css"; +import "@fontsource/noto-sans-sc/chinese-simplified-400.css"; +import "@fontsource/noto-sans-sc/chinese-simplified-500.css"; +import "@fontsource/noto-sans-sc/chinese-simplified-600.css"; +import "@fontsource/noto-sans-sc/chinese-simplified-700.css"; +import "./styles/globals.css"; +import "tdesign-react/es/style/index.css"; +import "katex/dist/katex.min.css"; import React from "react"; import ReactDOM from "react-dom/client"; diff --git a/frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx b/frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx index 41a441c5..400bc7e9 100644 --- a/frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx +++ b/frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx @@ -29,6 +29,7 @@ import { import { cn } from "@/lib/utils"; import { getAvatar } from "./Avatars"; +import { MarkdownContent } from "./MessageBubble"; import { chainTagClasses } from "../lib/business-chain"; import { getAvatarTypeFor } from "../lib/roundtable-constants"; import type { @@ -140,7 +141,7 @@ export function DispatchChainModal({ return ( - + {status === "running" && ( @@ -223,9 +224,9 @@ export function DispatchChainModal({
    {getAvatar(avatarForDialogue(d), "h-7 w-7")}
    -
    {d.name}
    -
    - {d.content || "…"} +
    {d.name}
    +
    + {d.content || "…"}
    @@ -289,10 +290,15 @@ export function DispatchChainModal({ 停止 )} - + {/* 只对「运行中」任务隐藏入口——它正在后台真跑,进入会暂停前端编排、 + 与后台作业双跑或借此重跑。其它状态(排队 / 待澄清 / 已完成 / 已取消 / + 出错)都不是正在跑的后台作业,可正常查看。 */} + {status !== "running" && ( + + )}
    diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index e2582397..726380f7 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -1642,32 +1642,39 @@ function IntentSummaryCard({ {intentReady ? ( <>
      -
    • - • - +
    • + • + 目标: {intentReady.objective}
    {collapsible && ( - +
    + +
    )} {showFullDetail && (
      diff --git a/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py b/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py index 4353da31..af624952 100644 --- a/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py +++ b/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py @@ -22,6 +22,12 @@ from typing import Any 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, + REPORT_AGENT_ID, + ensure_roundtable_functional_agents, +) from deerflow.agents.roundtable_orchestrator.types import ( LeaderTurn, ReportResult, @@ -31,22 +37,14 @@ from deerflow.agents.roundtable_orchestrator.types import ( from deerflow.config.paths import get_paths from deerflow.runtime.serialization import serialize_channel_values -from app.gateway.routers._roundtable_seed import ( - COORDINATOR_AGENT_ID, - REPORT_AGENT_ID, - ensure_roundtable_functional_agents, -) - logger = logging.getLogger(__name__) _ROUNDTABLE_THREAD_METADATA: dict[str, Any] = {"thread_type": "roundtable", "system": True} _REPORT_FILE = "方案总览.html" -# 各角色的工具裁剪(镜像 multi_agent 的 _leader_run / _special_run)。 -_LEADER_EXCLUDED = ["web_search"] -_LEADER_SKILL_STOP = ["agent_orchestration"] -_SEAT_EXCLUDED = ["ask_clarification", "present_files", "view_image", "agent_orchestration"] -_REPORT_EXCLUDED = [*_SEAT_EXCLUDED, "bash", "str_replace"] +# 各角色的工具裁剪 / thinking / reasoning 全部取自单一数据源 roundtable_run_policy +# (与 multi_agent 的 _leader_run / _special_run 同源),不再在本文件硬编码——历史上 +# 这里漏禁了 web_search,导致后台席位 / 报告能联网搜索而前端不能,现由 policy 统一。 # ── 进程内底层操作 ─────────────────────────────────────────────────────────── @@ -101,6 +99,56 @@ def _message_text(message: dict[str, Any] | None) -> str: return "" +def _latest_ai_text(messages: list[dict[str, Any]]) -> str: + """取**最后一条有正文**的 AI 消息文本。 + + 席位常以「调一堆工具」的 AIMessage 收尾(正文为空、只有 tool_calls,甚至 dangling), + 直接取最后一条 AI 消息会拿到空串 → 报告就拿不到席位产物。这里向前找到真正有文字的那条。 + """ + for m in reversed(messages): + if not isinstance(m, dict): + continue + if (m.get("type") or m.get("role")) not in {"ai", "assistant"}: + continue + text = _message_text(m) + if text.strip(): + return text + return "" + + +def _normalize_tool_calls(tool_calls: Any) -> list[dict[str, Any]]: + """把 checkpoint 里的 tool_calls 归一化成 {name, args, id}(丢掉无名项)。""" + out: list[dict[str, Any]] = [] + for tc in tool_calls or []: + if not isinstance(tc, dict): + continue + name = tc.get("name") or "" + if not name: + continue + out.append({"name": name, "args": tc.get("args") or {}, "id": tc.get("id") or ""}) + return out + + +def _tool_calls_since_last_human(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """收集「最后一条 human 消息之后」各 AI 消息的 tool_calls(即**本轮**调用),保序。 + + 席位 thread 跨轮累积历史,直接扫全量会把往轮的工具调用也算进来;以最后一条 human + (本轮 run 的输入)为界,只取其后的 AI 工具调用,对应前端「这一条气泡的步骤卡」。 + """ + last_human = -1 + for i, m in enumerate(messages): + if isinstance(m, dict) and (m.get("type") or m.get("role")) in {"human", "user"}: + last_human = i + out: list[dict[str, Any]] = [] + for m in messages[last_human + 1 :]: + if not isinstance(m, dict): + continue + if (m.get("type") or m.get("role")) not in {"ai", "assistant"}: + continue + out.extend(_normalize_tool_calls(m.get("tool_calls"))) + return out + + async def _run_agent_turn( app, *, @@ -216,27 +264,31 @@ class InProcessRoundtableGateway: if self._first_leader and self._seat_intro: msg = self._seat_intro + "\n\n" + message self._first_leader = False + policy = roundtable_run_policy("leader") messages = await _run_agent_turn( self._app, thread_id=thread_ids[coordinator_name], agent_name=coordinator_name, message=msg, model=model or self._model, - excluded_tools=_LEADER_EXCLUDED, - skill_stop_names=_LEADER_SKILL_STOP, - thinking_enabled=False, - reasoning_effort="low", + excluded_tools=policy.excluded_tools, + skill_stop_names=policy.skill_stop_names, + thinking_enabled=policy.thinking_enabled, + reasoning_effort=policy.reasoning_effort, ) ai = _latest_ai_message(messages) - final_text = _message_text(ai) + # tool_calls 取最后一条 AI 消息(派活就在这条);正文取最后一条有文字的 AI 消息。 + final_text = _latest_ai_text(messages) tool_calls = (ai.get("tool_calls") if ai else None) or [] + # 本轮工具调用(派活 / 澄清),带进 LeaderTurn 供草稿 run 重建步骤卡。 + turn_tool_calls = _normalize_tool_calls(tool_calls) # 澄清优先 clar = next((tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"), None) if clar is not None: cargs = clar.get("args") or {} question = str(cargs.get("question") or "").strip() or "总控需要补充信息,请补充后继续。" - return LeaderTurn(final_text=final_text, clarification=question) + return LeaderTurn(final_text=final_text, clarification=question, tool_calls=turn_tool_calls) # 派活解析(复用 multi_agent 纯函数) from app.gateway.routers.multi_agent import _extract_dispatch @@ -249,38 +301,40 @@ class InProcessRoundtableGateway: dispatched.append((name, task)) except Exception: # noqa: BLE001 logger.warning("dispatch parse failed for tool_call", exc_info=True) - return LeaderTurn(final_text=final_text, dispatched=dispatched) + return LeaderTurn(final_text=final_text, dispatched=dispatched, tool_calls=turn_tool_calls) async def run_seat(self, *, thread_ids, agent_id, task, model) -> SeatTurn: + policy = roundtable_run_policy("seat") messages = await _run_agent_turn( self._app, thread_id=thread_ids[agent_id], agent_name=agent_id, message=task, model=model or self._model, - excluded_tools=_SEAT_EXCLUDED, - skill_stop_names=[], - thinking_enabled=True, - reasoning_effort="medium", + excluded_tools=policy.excluded_tools, + skill_stop_names=policy.skill_stop_names, + thinking_enabled=policy.thinking_enabled, + reasoning_effort=policy.reasoning_effort, ) - return SeatTurn(final_text=_message_text(_latest_ai_message(messages))) + return SeatTurn(final_text=_latest_ai_text(messages), tool_calls=_tool_calls_since_last_human(messages)) async def create_report_thread(self) -> str: ensure_roundtable_functional_agents() return await _create_thread(self._app) async def run_report(self, *, report_thread_id, prompt, model) -> ReportResult: + policy = roundtable_run_policy("report") messages = await _run_agent_turn( self._app, thread_id=report_thread_id, agent_name=REPORT_AGENT_ID, message=prompt, model=model or self._model, - excluded_tools=_REPORT_EXCLUDED, - skill_stop_names=[], - thinking_enabled=True, - reasoning_effort="medium", + excluded_tools=policy.excluded_tools, + skill_stop_names=policy.skill_stop_names, + thinking_enabled=policy.thinking_enabled, + reasoning_effort=policy.reasoning_effort, ) - summary = _message_text(_latest_ai_message(messages)) + summary = _latest_ai_text(messages) html = _read_report_html(self._app, report_thread_id, self._user_id) or summary return ReportResult(html=html, summary=summary, model=model or self._model) 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 b08c9ab4..e7530b64 100644 --- a/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py +++ b/offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py @@ -17,7 +17,8 @@ from __future__ import annotations import asyncio import logging from dataclasses import dataclass, field -from typing import Callable +from datetime import UTC, datetime +from typing import Any, Callable from deerflow.agents.roundtable_orchestrator import ( JobProgressWriter, @@ -36,6 +37,115 @@ class _JobUser: self.id = user_id +# 头像配色:与前端 getAvatarTypeFor 对齐(内置圆桌席位固定色,其余按序轮换)。 +_BUILTIN_AVATAR = { + "roundtable-intelligence": "purple", + "roundtable-environment": "emerald", + "roundtable-solution-design": "human", + "roundtable-risk-review": "amber", + "roundtable-execution-plan": "pink", + "roundtable-summary": "blue", +} +_FALLBACK_AVATAR = ["blue", "purple", "emerald", "amber", "pink"] + + +def _avatar_for(agent_id: str, index: int) -> str: + return _BUILTIN_AVATAR.get(agent_id) or _FALLBACK_AVATAR[index % len(_FALLBACK_AVATAR)] + + +# 工具步骤卡的回退文案。前端 getStepDisplay 对 tool 步骤是从 key+args 推图标/文案的, +# 存的 label 基本不展示——这里给个合理回退即可(与前端默认文案对齐)。 +_STEP_LABEL = { + "agent_orchestration": "派活给子智能体", + "write_file": "写入文件", + "str_replace": "写入文件", + "read_file": "读取文件", + "ls": "列出文件夹", + "bash": "执行命令", + "web_search": "搜索相关信息", + "ask_clarification": "需要你的协助", + "present_files": "展示文件", +} + + +def _steps_from_tool_calls(tool_calls: list[dict], dialogue_id: object, ts: str) -> list[dict]: + """把一轮 tool_calls 映射成前端 ``Step2Dialogue.steps``(工具步骤卡)。 + + - ``key`` = ``tool_calling:``——前端 ``getStepDisplay`` 据此推图标 + 文案; + - ``id`` = ``-``——对齐前端 ``${bubbleId}-${stepIndex}``; + - ``args`` 原样带上(write_file 的 path/content、派活的 agent_name/task 等)。 + """ + steps: list[dict] = [] + for i, tc in enumerate(tool_calls or []): + name = tc.get("name") or "" + if not name: + continue + steps.append( + { + "id": f"{dialogue_id}-{i}", + "key": f"tool_calling:{name}", + "label": _STEP_LABEL.get(name, f"使用 “{name}” 工具"), + "ts": ts, + "args": tc.get("args") or {}, + } + ) + return steps + + +def _seat_stub_messages(dialogues: list[dict]) -> dict[str, list[dict]]: + """从各对话的 write_file 调用重建 ``seatStubMessages``(threadId → [stub ai message])。 + + 对齐前端 ``upsertSeatToolCall`` 的 stub 形状与 id 约定(消息 ``roundtable-stub-``、 + 工具调用 ``tc--``),使加载草稿后点开产物的虚拟 URL 能正确解析到内容。 + 同一 thread 跨轮写多次按 path 去重(后写覆盖),与前端一致。 + """ + by_tid: dict[str, dict[str, dict]] = {} + for d in dialogues: + tid = d.get("threadId") + if not tid: + continue + for tc in d.get("toolCalls") or []: + if tc.get("name") != "write_file": + continue + path = str((tc.get("args") or {}).get("path") or "") + by_tid.setdefault(tid, {})[path] = tc # 后写覆盖 + out: dict[str, list[dict]] = {} + for tid, by_path in by_tid.items(): + tool_calls = [ + {"id": f"tc-{tid}-{i}", "name": "write_file", "args": tc.get("args") or {}} + for i, tc in enumerate(by_path.values()) + ] + out[tid] = [{"id": f"roundtable-stub-{tid}", "type": "ai", "content": "", "tool_calls": tool_calls}] + return out + + +def _to_step2_dialogue(d: dict, idx_by_agent: dict[str, int]) -> dict: + """把作业的中性对话记录映射成前端 Step2Dialogue 形状(含 avatarType / steps / + agentThreadId / time),使写进草稿 step2.runs 后,前端 hydrateStep2Run 能像手动跑 + 一样直接渲染——步骤卡、时间戳、点开产物全部可用。""" + dialogue_id = d.get("id") + time_str = d.get("time") or "" + base: dict = { + "id": dialogue_id, + "content": d.get("content") or "", + "time": time_str, + "streaming": False, + "steps": _steps_from_tool_calls(d.get("toolCalls") or [], dialogue_id, time_str), + } + thread_id = d.get("threadId") + if thread_id: + base["agentThreadId"] = thread_id + if d.get("role") == "leader": + return {**base, "sender": "总控协调", "avatarType": "blue", "confidence": "派活说明"} + aid = d.get("agentId") or "" + return { + **base, + "sender": d.get("name") or aid, + "avatarType": _avatar_for(aid, idx_by_agent.get(aid, 0)), + "confidence": "子智能体交付", + } + + @dataclass class StartParams: job_id: str @@ -52,6 +162,8 @@ class StartParams: # 是否为「续跑」(resume 澄清)。首启=False → 真网关建全新线程(不复用前端线程, # 避免与前端本地研讨的活跃 run 撞 409);续跑=True → 复用作业自己的线程。 is_resume: bool = False + # chain 模式来源链条(写进草稿 run 用,展示)。 + chain: dict[str, Any] | None = None # 网关工厂:给定一次启动参数,返回一个 RoundtableGateway 实例。 @@ -94,7 +206,19 @@ class RoundtableJobExecutor: except Exception: # noqa: BLE001 logger.warning("persist thread_ids for job %s failed", params.job_id, exc_info=True) - progress = JobProgressWriter(self._store, job_id=params.job_id, agents=params.agents) + # 每轮模型返回后,把对话作为一个带 jobId 的 run 写进草稿 step2.runs[] + # —— 和手动跑同一存取路径(draft_store.update_draft),加载草稿即可显示。 + async def _on_dialogue(dialogues: list[dict], consensus: int) -> None: + await self._write_run_to_draft( + params, dialogues=dialogues, status="running", consensus=consensus, thread_ids=thread_ids + ) + + progress = JobProgressWriter( + self._store, + job_id=params.job_id, + agents=params.agents, + on_dialogue=_on_dialogue if (self._draft_store and params.draft_id) else None, + ) result = await run_orchestration( gateway=gateway, progress=progress, @@ -106,26 +230,21 @@ class RoundtableJobExecutor: mode=params.mode, intent_text=params.intent_text, ) - # 完成且有报告: - # 1) 把 step3 挂到 job 上 —— 前端「多运行」模型从单个 job 即可拿到完整结果 - # (对话 dialogues + 报告 step3)组装一个 run; - # 2) 同时写回关联草稿的顶层 step3 + 推进 furthest_step=3,兼容既有 - # 「查看报告 → 加载草稿 → Step3」路径。 + # 终态:把最终对话 + step3 + jobStatus 落进同一个 run(含 furthest_step=3)。 + await self._write_run_to_draft( + params, + dialogues=result.dialogues, + status=result.status, + consensus=100 if result.status == "done" else 95, + step3=result.step3, + thread_ids=thread_ids, + ) + # step3 也挂 job(API / 弹窗用)。 if result.status == "done" and result.step3: try: await self._store.update_progress(params.job_id, step3=result.step3) except Exception: # noqa: BLE001 logger.warning("persist step3 to job %s failed", params.job_id, exc_info=True) - if params.draft_id and self._draft_store is not None: - try: - await self._draft_store.update_draft( - params.draft_id, - params.user_id, - step3=result.step3, - furthest_step=3, - ) - except Exception: # noqa: BLE001 — 写报告失败不影响作业本身已 done - logger.warning("persist step3 to draft %s failed", params.draft_id, exc_info=True) except asyncio.CancelledError: # 干净停止即可,**不在被取消的任务里写 DB**:取消态下二次 await 会再抛 # CancelledError、并可能打断进行中的 sqlite 连接。``cancelled`` 终态由 @@ -137,6 +256,72 @@ class RoundtableJobExecutor: finally: reset_current_user(token) + async def _write_run_to_draft( + self, + params: StartParams, + *, + dialogues: list[dict], + status: str, + consensus: int, + step3: dict | None = None, + thread_ids: dict[str, str] | None = None, + ) -> None: + """把本次后台作业作为一个带 jobId 的 run 写进草稿 step2.runs[](同手动跑存取)。 + + 读现有 step2 → 按 run_id=``job-`` upsert(保留其它手动 run)→ 设为 activeRun + → update_draft。前端加载草稿即可像手动跑一样 hydrate 出对话。 + """ + if self._draft_store is None or not params.draft_id: + return + idx_by_agent = {a.agent_id: i for i, a in enumerate(params.agents)} + s2_dialogues = [_to_step2_dialogue(d, idx_by_agent) for d in dialogues] + last_leader = next( + (d.get("content") for d in reversed(dialogues) if d.get("role") == "leader"), "" + ) + run_id = f"job-{params.job_id}" + try: + draft = await self._draft_store.get_draft(params.draft_id, params.user_id) + except Exception: # noqa: BLE001 + draft = None + step2 = dict((draft or {}).get("step2") or {}) + runs = list(step2.get("runs") or []) + idx = next((i for i, r in enumerate(runs) if r.get("id") == run_id), -1) + created = runs[idx].get("createdAt") if idx >= 0 else datetime.now(UTC).isoformat() + prev_step3 = runs[idx].get("step3") if idx >= 0 else None + run = { + "id": run_id, + "title": "后台研讨", + "createdAt": created, + "source": params.mode, + "selectedAgents": [{"agent_id": a.agent_id, "name": a.name} for a in params.agents], + # 后台真建的 leader/seat 线程是真实持久的——存进去,前端加载后能像手动跑一样 + # 按 thread 打开各席位产物(HTTP outputs 兜底)。hydrate 是纯展示、绝不 resume, + # 不会触发重跑(init 闸门 + isRoundTableRunning=false 双重拦截)。空则存 None。 + "threadIds": (thread_ids or None), + "coordinatorName": params.coordinator_name, + "step2RoundtableDialogues": s2_dialogues, + "lastLeaderContent": last_leader or "", + "hasConsensus": status == "done", + "consensusPercentage": int(consensus), + "budgetLimit": 0, + # 从各席位 write_file 调用重建 stub,使加载草稿后点开产物的虚拟预览可用。 + "seatStubMessages": _seat_stub_messages(dialogues), + "orchestrationMode": params.mode, + "chain": params.chain, + "step3": step3 if step3 is not None else prev_step3, + "jobId": params.job_id, + "jobStatus": status, + } + runs = [run if i == idx else r for i, r in enumerate(runs)] if idx >= 0 else [*runs, run] + step2["runs"] = runs + step2["activeRunId"] = run_id + try: + await self._draft_store.update_draft( + params.draft_id, params.user_id, step2=step2, furthest_step=3 if status == "done" else 2 + ) + except Exception: # noqa: BLE001 — 写草稿失败不影响作业本身 + logger.warning("write run to draft %s failed", params.draft_id, exc_info=True) + def cancel(self, job_id: str) -> bool: """请求取消一个在跑的作业。返回是否命中在跑任务。""" task = self._tasks.get(job_id) diff --git a/offline-backend-20260512/backend/app/gateway/roundtable_run_policy.py b/offline-backend-20260512/backend/app/gateway/roundtable_run_policy.py new file mode 100644 index 00000000..8843a618 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/roundtable_run_policy.py @@ -0,0 +1,94 @@ +"""圆桌各角色「单轮 run 参数」的**单一数据源**。 + +历史问题:前端手动路径(``multi_agent.py`` 的 ``_leader_run`` / ``_special_run``) +和后台进程内网关(``roundtable_inprocess_gateway.py``)各自硬编码了一份「每角色 +工具裁剪 + thinking + reasoning」,结果**漂移**出差异(后台席位 / 报告少禁了 +``web_search``,能联网搜索而前端不能)。 + +本模块把这套策略收敛成唯一定义,两条路径都从这里取,保证「后台挂起」与「前端 +手动调用」传参**逐字一致**,且以后改一处即可,不再漂移。 + +角色(``role``): + - ``leader`` —— 总控派活:短决策,关 thinking + low reasoning(首字节快); + 只禁 ``web_search``;并配合 ``SkillStopMiddleware`` 在 ``agent_orchestration`` + 调用后停图,便于网关从 AIMessage.tool_calls 解析派活列表。 + - ``seat`` —— 子智能体交付:开 thinking + medium reasoning;禁派活 / + 澄清 / 展示文件 / 看图 / 联网搜索(与前端 ``_special_run`` 一致)。 + - ``report`` —— Step3 报告绘制:在 ``seat`` 基础上**额外禁 ``bash`` / + ``str_replace``**: + * 禁 ``bash`` → 杜绝它写 Python 脚本再跑出 HTML(脚本产物落在 outputs, + 但前端虚拟预览只抓 ``write_file`` 的 ``args.content``,抓不到脚本产物 → + 预览停在半截 ``write_file``,正是「没写完就去生成 py 文件」的现象); + * 禁 ``str_replace`` → 强制返工用整文件 ``write_file`` 重写 + (``str_replace`` 没有 ``content`` 字段,会让前端虚拟预览读到 undefined + 而清空沙箱)。 +""" + +from __future__ import annotations + +from dataclasses import dataclass + +# ── 每角色基础工具裁剪(唯一定义处)───────────────────────────────────────────── +# +# 注意:``seat`` / ``report`` 都包含 ``web_search`` —— 圆桌研讨里联网搜索一律禁用 +# (与前端 ``_special_run`` 的 excluded_tools 对齐)。leader 也禁 ``web_search``。 +_LEADER_EXCLUDED: list[str] = ["web_search"] +_SEAT_EXCLUDED: list[str] = ["web_search", "ask_clarification", "present_files", "view_image", "agent_orchestration"] +_REPORT_EXCLUDED: list[str] = [*_SEAT_EXCLUDED, "bash", "str_replace"] + +# leader 内禀的 skill_stop:``agent_orchestration`` 调用后停图。调用方可在此基础上 +# 再叠加自己的 skill_stop(见 ``merge_skill_stop_names``)。 +_LEADER_SKILL_STOP: list[str] = ["agent_orchestration"] + + +@dataclass(frozen=True) +class RoundtableRunPolicy: + """一个角色单轮 run 的归一化参数。 + + 与 ``multi_agent._run_payload`` 的入参一一对应:``excluded_tools`` / + ``skill_stop_names`` / ``thinking_enabled`` / ``reasoning_effort``。 + """ + + excluded_tools: list[str] + skill_stop_names: list[str] + thinking_enabled: bool + reasoning_effort: str + + +def roundtable_run_policy(role: str) -> RoundtableRunPolicy: + """返回某角色(``leader`` / ``seat`` / ``report``)的单轮 run 参数。 + + 返回值里的列表是**新副本**,调用方可安全地再 append/merge 而不污染本模块常量。 + 未知角色抛 ``ValueError``(早失败,避免悄悄退化到错误的工具面)。 + """ + if role == "leader": + return RoundtableRunPolicy( + excluded_tools=list(_LEADER_EXCLUDED), + skill_stop_names=list(_LEADER_SKILL_STOP), + thinking_enabled=False, + reasoning_effort="low", + ) + if role == "seat": + return RoundtableRunPolicy( + excluded_tools=list(_SEAT_EXCLUDED), + skill_stop_names=[], + thinking_enabled=True, + reasoning_effort="medium", + ) + if role == "report": + return RoundtableRunPolicy( + excluded_tools=list(_REPORT_EXCLUDED), + skill_stop_names=[], + thinking_enabled=True, + reasoning_effort="medium", + ) + raise ValueError(f"unknown roundtable role: {role!r} (expected leader/seat/report)") + + +def merge_skill_stop_names(policy_names: list[str], caller_names: list[str] | None) -> list[str]: + """把角色内禀 skill_stop 与调用方追加的 skill_stop 合并去重(保序)。 + + 取代历史上 ``list({"agent_orchestration", *skill_stop_names})`` 的 set 写法 + (set 顺序不确定);这里用 dict.fromkeys 保序去重,结果确定、便于断言。 + """ + return list(dict.fromkeys([*policy_names, *(caller_names or [])])) diff --git a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py index 199c366e..3f5faf4c 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py @@ -57,18 +57,20 @@ import uuid from typing import Any, AsyncIterator import httpx +from fastapi import APIRouter, Body, HTTPException, Request +from fastapi.responses import StreamingResponse +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, REPORT_AGENT_ID, ensure_roundtable_functional_agents, ) from deerflow.config import get_app_config -from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple from deerflow.runtime.user_context import get_effective_user_id +from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple from deerflow.utils.time import now_iso -from fastapi import APIRouter, Body, HTTPException, Request -from fastapi.responses import StreamingResponse -from langgraph.checkpoint.base import empty_checkpoint logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/multi-agent", tags=["multi-agent"]) @@ -1008,19 +1010,20 @@ async def _leader_run( _broadcast(client, base, headers, "总控智能体收到用户消息:" + new_message, agent_threads, [agent_name]) ) thread_id = agent_threads[agent_name] - effective_stop_names = list({"agent_orchestration", *skill_stop_names}) + # 角色参数取自单一数据源 roundtable_run_policy(leader:禁 web_search、停 + # agent_orchestration 图、关 thinking + low reasoning——短决策、首字节快)。 + # 调用方可再叠加自己的 skill_stop。后台进程内网关用同一份 policy,保证传参一致。 + policy = roundtable_run_policy("leader") + effective_stop_names = merge_skill_stop_names(policy.skill_stop_names, skill_stop_names) body = _run_payload( agent_name=agent_name, model_name=model_name, thread_id=thread_id, new_message=new_message, - excluded_tools=["web_search"], + excluded_tools=policy.excluded_tools, skill_stop_names=effective_stop_names, - # 总控派活是"下一步派谁、什么任务"的短决策,不需要 CoT。 - # 关掉 thinking + low reasoning 让"调度中"几乎瞬间转入"正在派活", - # 子智能体仍保留 thinking(在 _special_run 里默认 True),保证交付质量。 - thinking_enabled=False, - reasoning_effort="low", + thinking_enabled=policy.thinking_enabled, + reasoning_effort=policy.reasoning_effort, ) t_payload_built = time.perf_counter() logger.info( @@ -1163,23 +1166,20 @@ async def _special_run( """ t0 = time.perf_counter() thread_id = agent_threads[agent_name] - excluded_tools = ["web_search", "ask_clarification", "present_files", "view_image", "agent_orchestration"] - if agent_name == REPORT_AGENT_ID: - # Step 3「结果绘制」的报告智能体只需**直接 write_file 写出完整 HTML**。 - # 额外禁用 bash / str_replace: - # - 禁 bash → 杜绝它写 Python/脚本再跑出 HTML(脚本产物落在 outputs 里, - # 但前端虚拟预览只抓 write_file 的 args.content,抓不到脚本产物 → 预览停在 - # 那个半截 write_file 上,正是用户看到的「没写完就去生成 py 文件」); - # - 禁 str_replace → 强制返工用整文件 write_file 重写(str_replace 没有 content - # 字段,会让前端虚拟预览读到 undefined 而清空沙箱)。 - excluded_tools = [*excluded_tools, "bash", "str_replace"] + # 角色参数取自单一数据源 roundtable_run_policy(seat / report)。report 在 seat + # 基础上额外禁 bash / str_replace(理由见 roundtable_run_policy 模块注释)。 + # 后台进程内网关用同一份 policy,保证「后台挂起」与「前端手动调用」传参一致。 + role = "report" if agent_name == REPORT_AGENT_ID else "seat" + policy = roundtable_run_policy(role) body = _run_payload( agent_name=agent_name, model_name=model_name, thread_id=thread_id, new_message=new_message, - excluded_tools=excluded_tools, - skill_stop_names=skill_stop_names, + excluded_tools=policy.excluded_tools, + skill_stop_names=merge_skill_stop_names(policy.skill_stop_names, skill_stop_names), + thinking_enabled=policy.thinking_enabled, + reasoning_effort=policy.reasoning_effort, ) t_payload_built = time.perf_counter() logger.info( 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 4e3d4b9e..6a6e0af4 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py +++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py @@ -203,6 +203,7 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes thread_ids=body.threadIds, coordinator_name=body.coordinatorName, draft_id=body.draftId, + chain=body.chain, ) ) return _to_response(row) @@ -287,6 +288,7 @@ async def resume_job(request: Request, job_id: str, body: ResumeJobRequest) -> R coordinator_name=row.get("coordinator_name") or "roundtable-coordinator", draft_id=row.get("draft_id"), is_resume=True, + chain=row.get("chain"), ) ) return _to_response(updated or row) 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 9f21212e..17aaa51f 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 @@ -47,6 +47,7 @@ class OrchestrationResult: pending_clarification: str | None = None error: str | None = None last_leader_content: str = "" + dialogues: list = field(default_factory=list) @dataclass @@ -85,12 +86,13 @@ async def run_orchestration( mode: "recommend" | "chain"。 intent_text: 任务意图,用于报告 prompt(缺省退回 seed_message)。 """ + intent = (intent_text or seed_message or "").strip() try: await progress.begin() if mode == "chain": - outcome = await _run_chain(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message) + outcome = await _run_chain(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message, intent) else: - outcome = await _run_recommend(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message) + outcome = await _run_recommend(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message, intent) if outcome.kind == "clarification": question = outcome.question or "总控需要补充信息,请补充后继续。" @@ -99,6 +101,7 @@ async def run_orchestration( status="awaiting_input", pending_clarification=question, last_leader_content=outcome.last_leader_content, + dialogues=progress.dialogues(), ) # consensus 或 max_cycles:均收口 → 报告绘制 @@ -118,14 +121,48 @@ async def run_orchestration( consensus=True, step3=step3, last_leader_content=outcome.last_leader_content, + dialogues=progress.dialogues(), ) except Exception as exc: # noqa: BLE001 — 顶层兜底:标记 error 并返回 logger.exception("roundtable orchestration failed") await progress.error(str(exc)) - return OrchestrationResult(status="error", error=str(exc)) + return OrchestrationResult(status="error", error=str(exc), dialogues=progress.dialogues()) -# ── recommend 模式 ─────────────────────────────────────────────────────────── +# ── 上下文「广播」(inline 实现)──────────────────────────────────────────────── +# +# 进程内网关没有 multi_agent 的 thread 广播,席位/总控会看不到「原始任务背景 + 各席位 +# 前序交付」→ 席位反问用户、总控靠记忆瞎编。这里把这些信息**直接拼进 prompt**: +# - 每个席位的任务带上:任务背景 + 前序各席位交付 + 「直接产出、别提问」硬约束; +# - 总控的续轮 prompt 带上:各席位本轮交付,让它据实派活/收口。 + +_SEAT_NO_ASK = ( + "注意:这是自动化圆桌研讨,没有用户在线应答——**请勿向用户提问或等待确认**," + "直接基于已知信息做出合理假设并产出完整的结构化交付(Markdown)。" +) + + +def _deliveries_block(deliveries: dict[str, str], name_by_id: dict[str, str]) -> str: + parts: list[str] = [] + for aid, text in deliveries.items(): + t = (text or "").strip() + if not t: + continue + parts.append(f"### {name_by_id.get(aid, aid)}\n{t}") + return "\n\n".join(parts) + + +def _seat_message(intent_text: str, deliveries: dict[str, str], name_by_id: dict[str, str], task: str) -> str: + """把任务背景 + 前序交付 + 防提问约束拼进席位任务(替代 thread 广播)。""" + parts: list[str] = [] + if intent_text.strip(): + parts.append(f"【本次研讨任务背景】\n{intent_text.strip()}") + block = _deliveries_block(deliveries, name_by_id) + if block: + parts.append("【前序各席位已完成的交付,请在此基础上承接、补充、不要重复或推翻】\n" + block) + parts.append(f"【你的本轮具体任务】\n{task}") + parts.append(_SEAT_NO_ASK) + return "\n\n---\n\n".join(parts) async def _run_recommend( @@ -136,6 +173,7 @@ async def _run_recommend( coordinator_name: str, model: str | None, seed_message: str, + intent_text: str, ) -> _LoopOutcome: deliveries: dict[str, str] = {} last_leader = "" @@ -152,24 +190,32 @@ async def _run_recommend( ) if turn.final_text: last_leader = turn.final_text - await progress.add_leader_dialogue(turn.final_text, cycle) + await progress.add_leader_dialogue( + turn.final_text, cycle, tool_calls=turn.tool_calls, thread_id=thread_ids.get(coordinator_name) + ) if turn.is_clarification: return _LoopOutcome("clarification", question=turn.clarification, last_leader_content=last_leader, deliveries=deliveries) if turn.is_consensus: return _LoopOutcome("consensus", last_leader_content=last_leader, deliveries=deliveries) - # **顺序**派活 —— 不要并行 + # **顺序**派活 —— 不要并行;每个席位带上「背景 + 前序交付」上下文 await progress.dispatching(cycle) for agent_id, task in turn.dispatched: await progress.seat_active(agent_id) - seat = await gateway.run_seat(thread_ids=thread_ids, agent_id=agent_id, task=task, model=model) + seat_msg = _seat_message(intent_text, deliveries, name_by_id, task) + seat = await gateway.run_seat(thread_ids=thread_ids, agent_id=agent_id, task=seat_msg, 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.add_seat_dialogue( + agent_id, name_by_id.get(agent_id, agent_id), seat.final_text, cycle, + tool_calls=seat.tool_calls, thread_id=thread_ids.get(agent_id), + ) await progress.seat_done(agent_id, cycle) await progress.bump_consensus() - message = CONTINUE_PROMPT + # 续轮:把各席位本轮交付喂给总控,让它据实判断(总控 SOUL 依赖看到交付) + block = _deliveries_block(deliveries, name_by_id) + message = CONTINUE_PROMPT + (f"\n\n各席位最新交付如下:\n{block}" if block else "") # 达到最大轮次:强制收口(对齐前端 toast「已达到最大研讨轮次」) logger.info("roundtable recommend loop hit MAX_CYCLES=%d, forcing consensus", MAX_CYCLES) @@ -187,13 +233,15 @@ async def _run_chain( coordinator_name: str, model: str | None, seed_message: str, + intent_text: str, ) -> _LoopOutcome: deliveries: dict[str, str] = {} last_leader = "" total = len(agents) + name_by_id = {a.agent_id: a.name for a in agents} for i, seat in enumerate(agents): - # (a) 总控按链条顺序只给当前席位派活 + # (a) 总控按链条顺序只给当前席位派活;带上前序各席位交付,让它据实派活 await progress.leader_thinking(i + 1) leader_task = build_chain_leader_task( seed_message=seed_message, @@ -203,6 +251,9 @@ async def _run_chain( seat_name=seat.name, is_first=(i == 0), ) + block = _deliveries_block(deliveries, name_by_id) + if block: + leader_task += f"\n\n前序各席位已完成的交付如下,请据此为本席位分配承接性任务:\n{block}" turn = await gateway.run_leader( thread_ids=thread_ids, coordinator_name=coordinator_name, @@ -211,34 +262,44 @@ async def _run_chain( ) if turn.final_text: last_leader = turn.final_text - await progress.add_leader_dialogue(turn.final_text, i + 1) + await progress.add_leader_dialogue( + turn.final_text, i + 1, tool_calls=turn.tool_calls, thread_id=thread_ids.get(coordinator_name) + ) if turn.is_clarification: return _LoopOutcome("clarification", question=turn.clarification, last_leader_content=last_leader, deliveries=deliveries) task = pick_chain_task(turn.dispatched, turn.final_text, seat.agent_id, seat.name) - # (b) 当前席位交付(顺序,不并行) + # (b) 当前席位交付(顺序,不并行);带上「背景 + 前序交付 + 别提问」上下文 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) + seat_msg = _seat_message(intent_text, deliveries, name_by_id, task) + seat_turn = await gateway.run_seat(thread_ids=thread_ids, agent_id=seat.agent_id, task=seat_msg, 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.add_seat_dialogue( + seat.agent_id, seat.name, seat_turn.final_text, i + 1, + tool_calls=seat_turn.tool_calls, thread_id=thread_ids.get(seat.agent_id), + ) await progress.seat_done(seat.agent_id, i + 1) if total > 0: await progress.set_consensus_pct(round((i + 1) / total * 90)) - # (c) 总控收口:综合全链交付,产出最终结论 + # (c) 总控收口:综合全链交付,产出最终结论(带上全部席位交付) if total > 0: await progress.leader_thinking(total + 1) + block = _deliveries_block(deliveries, name_by_id) + closeout_msg = CHAIN_CLOSEOUT_PROMPT + (f"\n\n各席位完整交付如下:\n{block}" if block else "") closeout = await gateway.run_leader( thread_ids=thread_ids, coordinator_name=coordinator_name, - message=CHAIN_CLOSEOUT_PROMPT, + message=closeout_msg, model=model, ) if closeout.final_text: last_leader = closeout.final_text - await progress.add_leader_dialogue(closeout.final_text, total + 1) + await progress.add_leader_dialogue( + closeout.final_text, total + 1, tool_calls=closeout.tool_calls, thread_id=thread_ids.get(coordinator_name) + ) return _LoopOutcome("consensus", last_leader_content=last_leader, deliveries=deliveries) @@ -279,27 +340,43 @@ def build_report_prompt( 席位交付按 ``agents`` 顺序列出(缺交付的席位跳过)。报告智能体据此用 write_file 写出自包含的 ``outputs/方案总览.html``(具体绘制规则在 roundtable-report 的 SOUL)。 """ + # 与前端 useStep3Report.buildReportPrompt 对齐 —— 关键是**明确叫报告 agent 用 + # write_file 写到 /mnt/user-data/outputs/方案总览.html**,否则它只会在对话里回文字、 + # 不落 HTML 文件(_read_report_html 就读不到)。 + name_by_id = {a.agent_id: a.name for a in agents} lines: list[str] = [ - "【圆桌最终成果汇总 —— 请据此绘制方案总览 HTML 看板】", + "请基于以下圆桌多智能体研讨的成果,撰写一份图文并茂、以文字叙述为主线的 HTML「方案研讨汇报」," + "用 write_file 工具写入 /mnt/user-data/outputs/方案总览.html。", + "目标:让用户能轻松读懂这群智能体讨论出了什么方案、为什么是它。请逐个席位提炼其核心观点与交付写成文字," + "再综合成最终方案;图表用来点睛(风险矩阵、时间线、资源占比、贡献雷达等),详略得当——" + "缺明确数字时也要据研讨内容合理估算出图并标注「定性/估算」,不要通篇只有文字。", "", - "## 任务意图", + "# 一、任务意图", (intent_text or "").strip() or "(未提供)", "", - "## 各席位交付", + "# 二、各席位最终交付", ] - name_by_id = {a.agent_id: a.name for a in agents} has_delivery = False - for a in agents: + for i, a in enumerate(agents): text = (deliveries.get(a.agent_id) or "").strip() if not text: continue has_delivery = True - lines.append(f"### {name_by_id.get(a.agent_id, a.agent_id)}") + lines.append(f"## {i + 1}. {name_by_id.get(a.agent_id, a.agent_id)}") lines.append(text) lines.append("") if not has_delivery: - lines.append("(各席位暂无可用交付,请基于任务意图与总控结论绘制。)") + lines.append("(各席位暂无文字交付,请基于任务意图与总控结论合理展开成方案。)") lines.append("") - lines.append("## 总控最终结论") + lines.append("# 三、总控最终共识结论") lines.append((consensus_text or "").strip() or "(未提供)") + lines.append("") + lines.append( + "请严格遵循你的职责设定:自包含离线(纯 inline SVG 图表、禁止任何外链 CDN)、研讨叙事型结构" + "(结论+KPI → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线)、" + "文字为主图表点睛、浅红主题(主色 #c25555);写完文件后只用一两句话总结,不要把 HTML 源码贴进对话。" + ) + lines.append( + '硬性要求:① 全程使用简体中文;② HTML 必须是完整文档, 最前面声明 。' + ) return "\n".join(lines) 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 1ecf06c7..ad6ba229 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 @@ -10,12 +10,21 @@ from __future__ import annotations import logging +from datetime import datetime, timedelta, timezone from typing import Any, Awaitable, Callable from .types import SeatRef logger = logging.getLogger(__name__) +# 北京时区(UTC+8,固定无 DST)——对话气泡时间戳用,对齐前端 nowTime() +# = new Date().toLocaleTimeString("zh-CN", {hour12:false}) 的 "HH:MM:SS" 形状。 +_BEIJING_TZ = timezone(timedelta(hours=8)) + + +def _now_hms() -> str: + return datetime.now(_BEIJING_TZ).strftime("%H:%M:%S") + # 节点状态机:pending → active → done。 _PENDING = "pending" _ACTIVE = "active" @@ -42,11 +51,15 @@ class JobProgressWriter: job_id: str, agents: list[SeatRef], event_sink: EventSink = None, + on_dialogue: Any | None = None, ) -> None: self._store = store self._job_id = job_id self._agents = list(agents) self._event_sink = event_sink + # 每轮 leader/seat 模型返回后回调(dialogues, consensus)—— 执行器据此把对话 + # 写进草稿(和手动跑同一存取路径)。 + self._on_dialogue = on_dialogue self._state: dict[str, str] = {a.agent_id: _PENDING for a in agents} self._spoke_cycle: dict[str, int] = {} self._consensus = 0 @@ -147,20 +160,75 @@ class JobProgressWriter: # ── 对话记录 ───────────────────────────────────────────────────────────── - def _add(self, role: str, agent_id: str | None, name: str, content: str, cycle: int) -> None: + def _add( + self, + role: str, + agent_id: str | None, + name: str, + content: str, + cycle: int, + *, + tool_calls: list[dict] | None = None, + thread_id: str | None = None, + ) -> None: self._dlg_seq += 1 self._dialogues.append( - {"id": self._dlg_seq, "role": role, "agentId": agent_id, "name": name, "content": content, "cycle": cycle} + { + "id": self._dlg_seq, + "role": role, + "agentId": agent_id, + "name": name, + "content": content, + "cycle": cycle, + # 工具步骤卡数据源(每项 {name, args, id});映射成前端 Step2Dialogue.steps。 + "toolCalls": list(tool_calls or []), + # 该气泡对应的 leader/seat 线程——映射成 agentThreadId,供点开产物 / 反查 stub。 + "threadId": thread_id, + # 气泡时间戳,**创建时一次性盖章**(不随后续 flush 抖动),对齐前端 nowTime()。 + "time": _now_hms(), + } ) - async def add_leader_dialogue(self, content: str, cycle: int) -> None: - """总控一轮发言(派活说明 / 共识结论)。空内容跳过。""" + def dialogues(self) -> list[dict[str, Any]]: + return list(self._dialogues) + + async def _flush_dialogue(self) -> None: + if self._on_dialogue is None: + return + try: + await self._on_dialogue(self.dialogues(), self._consensus) + except Exception: # noqa: BLE001 — 写草稿失败不打断编排 + logger.warning("roundtable job dialogue → draft flush failed", exc_info=True) + + async def add_leader_dialogue( + self, + content: str, + cycle: int, + *, + tool_calls: list[dict] | None = None, + thread_id: str | None = None, + ) -> None: + """总控一轮发言(派活说明 / 共识结论)。空内容跳过。 + + ``tool_calls`` / ``thread_id`` 用于重建工具步骤卡(派活 chip)+ agentThreadId。 + """ if not (content or "").strip(): return - self._add("leader", None, "总控协调", content, cycle) + self._add("leader", None, "总控协调", content, cycle, tool_calls=tool_calls, thread_id=thread_id) await self._persist() + await self._flush_dialogue() - 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) + async def add_seat_dialogue( + self, + agent_id: str, + name: str, + content: str, + cycle: int, + *, + tool_calls: list[dict] | None = None, + thread_id: str | None = None, + ) -> None: + """某席位一轮交付。``tool_calls`` / ``thread_id`` 用于步骤卡 + 产物预览。""" + self._add("seat", agent_id, name, content or "", cycle, tool_calls=tool_calls, thread_id=thread_id) await self._persist() + await self._flush_dialogue() diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/types.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/types.py index 6c011f7a..3525493f 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/types.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/types.py @@ -52,6 +52,9 @@ class LeaderTurn: dispatched: list[tuple[str, str]] = field(default_factory=list) # 命中澄清时的问题文本;非 None 即 clarification 分支。 clarification: str | None = None + # 本轮总控调用的工具(按出现顺序,每项 {name, args, id})——通常是 agent_orchestration + # 派活 / ask_clarification。用于在草稿 run 里重建「工具步骤卡」,与前端手动跑一致。 + tool_calls: list[dict] = field(default_factory=list) @property def is_clarification(self) -> bool: @@ -67,6 +70,9 @@ class SeatTurn: """某席位一轮交付的归一化结果(由 gateway.run_seat 产出)。""" final_text: str = "" + # 本轮席位调用的工具(按出现顺序,每项 {name, args, id})——用于在草稿 run 里 + # 重建「工具步骤卡」+ write_file stub(点开产物),与前端手动跑一致。 + tool_calls: list[dict] = field(default_factory=list) @dataclass From 0fd314632ac095f97f835951304561da82aae297 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 5 Jun 2026 11:19:09 +0800 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20=E5=A4=9A=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93=E4=BC=9A=E5=95=86=E8=8D=89=E7=A8=BF=E5=88=86=E4=BA=AB?= =?UTF-8?q?=EF=BC=88import=20=E5=A4=8D=E5=88=B6=20/=20view=20=E5=85=AC?= =?UTF-8?q?=E5=BC=80=E7=BB=93=E6=9E=9C=E9=A1=B5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持会商草稿生成分享码、导入副本与公开只读结果页,并在侧边栏展示最近会商记录。 Co-authored-by: Cursor --- frontend-web/src/App.tsx | 6 + .../workspace/recent-roundtable-list.tsx | 266 +++++++++++++++ .../roundtable-draft-share-dialogs.tsx | 322 ++++++++++++++++++ .../workspace/workspace-sidebar.tsx | 2 + .../src/core/roundtable-draft-shares/api.ts | 131 +++++++ .../src/core/roundtable-draft-shares/hooks.ts | 61 ++++ .../src/pages/PublicRoundtableSharePage.tsx | 83 +++++ .../hooks/useRoundtableDrafts.ts | 50 +++ .../pages/RoundtablePlanningPage.tsx | 48 +++ offline-backend-20260512/backend/CLAUDE.md | 2 + .../backend/app/gateway/app.py | 6 + .../backend/app/gateway/deps.py | 12 + .../routers/roundtable_draft_shares.py | 277 +++++++++++++++ .../20260606_01_roundtable_draft_shares.py | 57 ++++ .../deerflow/persistence/models/__init__.py | 2 + .../roundtable_draft_shares/__init__.py | 22 ++ .../roundtable_draft_shares/base.py | 56 +++ .../roundtable_draft_shares/memory.py | 59 ++++ .../roundtable_draft_shares/model.py | 38 +++ .../roundtable_draft_shares/sql.py | 84 +++++ 20 files changed, 1584 insertions(+) create mode 100644 frontend-web/src/components/workspace/recent-roundtable-list.tsx create mode 100644 frontend-web/src/components/workspace/roundtable-draft-share-dialogs.tsx create mode 100644 frontend-web/src/core/roundtable-draft-shares/api.ts create mode 100644 frontend-web/src/core/roundtable-draft-shares/hooks.ts create mode 100644 frontend-web/src/pages/PublicRoundtableSharePage.tsx create mode 100644 frontend-web/src/roundtable-planning/hooks/useRoundtableDrafts.ts create mode 100644 offline-backend-20260512/backend/app/gateway/routers/roundtable_draft_shares.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260606_01_roundtable_draft_shares.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/base.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/memory.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/sql.py diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 4503b615..b231ad97 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -17,6 +17,7 @@ import LoginPage from "./pages/LoginPage"; import PageRoutes from "./pages/PageRoutes"; import PublicScheduledTaskPage from "./pages/PublicScheduledTaskPage"; import PublicSharePage from "./pages/PublicSharePage"; +import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage"; import EmbedChatPage from "./pages/EmbedChatPage"; import { ChatRuntime } from "./pages/WorkspaceRoutes"; import { NotFoundPage } from "./pages/NotFoundPage"; @@ -63,6 +64,11 @@ export function App() { } /> {/* Public read-only conversation viewer — no authentication. */} } /> + {/* Public read-only roundtable result viewer — no authentication. */} + } + /> {/* Public read-only scheduled-task runs viewer — no authentication. */} , +) { + if (isOnPage) { + // 已在会商页面:直接派事件让页面加载该草稿(仿 AI 写作的 open-session)。 + window.dispatchEvent( + new CustomEvent("roundtable:open-draft", { detail: draft.id }), + ); + } else { + // 不在该页面:把草稿 id 暂存到 sessionStorage,跳转后由页面消费。 + sessionStorage.setItem("roundtable:pending-draft", draft.id); + router.push(ROUNDTABLE_PATH); + } +} + +/** 把草稿的后台作业状态 / 步骤映射成侧边栏小标签(仿页面内 HistoryDropdown)。 */ +function StatusBadge({ draft }: { draft: DraftMeta }) { + if (draft.status === "running") { + return ( + + + 进行中 + + ); + } + if (draft.status === "awaiting_input") { + return 待澄清; + } + if (draft.status === "error") { + return 出错; + } + return ( + + 第 {draft.furthestStep} 步 + + ); +} + +export function RecentRoundtableList() { + const router = useRouter(); + const pathname = usePathname(); + const { drafts, loading, refresh, rename, remove } = useRoundtableDrafts(); + const [collapsed, setCollapsed] = useState( + () => localStorage.getItem("sidebar:recent-roundtable-collapsed") === "true", + ); + + // 重命名对话框状态。 + const [renameId, setRenameId] = useState(null); + const [renameValue, setRenameValue] = useState(""); + // 分享对话框 —— 正在分享的草稿 id。 + const [shareId, setShareId] = useState(null); + + const toggleCollapsed = () => { + const next = !collapsed; + setCollapsed(next); + localStorage.setItem("sidebar:recent-roundtable-collapsed", String(next)); + }; + + useEffect(() => { + void refresh(); + }, [refresh]); + + const handleRenameSubmit = useCallback(async () => { + const id = renameId; + const title = renameValue.trim(); + if (!id || !title) { + setRenameId(null); + return; + } + try { + await rename(id, title); + toast.success("✏️ 已重命名"); + } catch { + toast.error("重命名失败,请重试"); + } finally { + setRenameId(null); + setRenameValue(""); + } + }, [renameId, renameValue, rename]); + + const handleDelete = useCallback( + async (draft: DraftMeta) => { + if (!window.confirm("确定删除这条会商记录吗?此操作不可恢复。")) return; + try { + await remove(draft.id); + toast.success("🗑 已删除会商记录"); + } catch { + toast.error("删除失败,请重试"); + } + }, + [remove], + ); + + if (!loading && drafts.length === 0) return null; + + const isOnPage = pathname === ROUNDTABLE_PATH; + + return ( + <> + + + 多智能体会商 + + + + +
      + {loading && drafts.length === 0 ? ( +

      加载中…

      + ) : ( + drafts.slice(0, 10).map((draft) => ( + + openDraft(draft, isOnPage, router)} + > +
      + + {draft.title || "未命名草稿"} + +
      + + · + + {formatRelative(draft.updatedAt)} + +
      +
      +
      + + + + + 更多 + + + + { + setRenameId(draft.id); + setRenameValue(draft.title || ""); + }} + > + + 重命名 + + setShareId(draft.id)}> + + 分享 + + + void handleDelete(draft)}> + + 删除 + + + +
      + )) + )} +
      +
      +
      +
      + + {/* 重命名对话框 */} + !o && setRenameId(null)}> + + + 重命名会商 + +
      + setRenameValue(e.target.value)} + placeholder="输入新名称" + onKeyDown={(e) => { + if (e.key === "Enter" && !isIMEComposing(e)) { + e.preventDefault(); + void handleRenameSubmit(); + } + }} + /> +
      + + + + +
      +
      + + {/* 分享对话框 */} + { + if (!o) setShareId(null); + }} + /> + + ); +} diff --git a/frontend-web/src/components/workspace/roundtable-draft-share-dialogs.tsx b/frontend-web/src/components/workspace/roundtable-draft-share-dialogs.tsx new file mode 100644 index 00000000..00b4235e --- /dev/null +++ b/frontend-web/src/components/workspace/roundtable-draft-share-dialogs.tsx @@ -0,0 +1,322 @@ +"use client"; + +import { + CheckIcon, + CopyIcon, + DownloadIcon, + ExternalLinkIcon, + Loader2, + Trash2Icon, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import type { DraftShare, DraftShareMode, DraftSharePreview } from "@/core/roundtable-draft-shares/api"; +import { buildPublicDraftShareUrl, extractShareCode } from "@/core/roundtable-draft-shares/api"; +import { + useCreateDraftShare, + useImportDraftShare, + usePreviewDraftShare, + useRevokeDraftShare, +} from "@/core/roundtable-draft-shares/hooks"; +import { cn } from "@/lib/utils"; + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** + * Dialog that produces a share link for a single roundtable draft. Two modes: + * + * - `view` — mints a public, read-only link that anyone can open without logging + * in; it renders the draft's Step 3 result report (方案总览). + * - `import` — mints a short code; a recipient enters it in the import dialog to + * copy the whole 会商 draft into their own account. + */ +export function ShareRoundtableDraftDialog({ + draftId, + open, + onOpenChange, +}: { + draftId: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const createShare = useCreateDraftShare(); + const revokeShare = useRevokeDraftShare(); + const [mode, setMode] = useState("view"); + const [share, setShare] = useState(null); + const [copied, setCopied] = useState(false); + + // Reset to the default mode (read-only link) every time the dialog opens. + useEffect(() => { + if (open) setMode("view"); + }, [open, draftId]); + + // Mint / reuse the link whenever the dialog opens or the mode changes. + useEffect(() => { + if (!open || !draftId) return; + setShare(null); + setCopied(false); + createShare + .mutateAsync({ draftId, mode }) + .then(setShare) + .catch((err) => toast.error(errorMessage(err))); + // createShare is stable enough; re-running only on open/draftId/mode is intended. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, draftId, mode]); + + const isView = mode === "view"; + const publicUrl = isView && share ? buildPublicDraftShareUrl(share.share_code) : ""; + + async function handleCopy() { + if (!share) return; + const text = isView ? publicUrl : share.share_code; + try { + await navigator.clipboard.writeText(text); + setCopied(true); + toast.success(isView ? "链接已复制" : "分享码已复制"); + setTimeout(() => setCopied(false), 1500); + } catch { + toast.error("复制到剪贴板失败"); + } + } + + async function handleRevoke() { + if (!share || !window.confirm("撤销后此分享立即失效,确定吗?")) return; + try { + await revokeShare.mutateAsync(share.share_code); + toast.success("已撤销分享"); + onOpenChange(false); + } catch (err) { + toast.error(errorMessage(err)); + } + } + + return ( + + + + 分享会商 + + {isView + ? "生成一个只读链接,任何人无需登录都能查看这次会商生成的结果报告。" + : "生成一个分享码,对方在「导入会商」里输入它即可把这份会商复制到自己的账号继续研讨。"} + + + + {/* Mode selector */} +
      + {( + [ + { value: "view" as const, label: "只读链接" }, + { value: "import" as const, label: "导入副本" }, + ] + ).map((option) => ( + + ))} +
      + + {!share ? ( +
      + + 正在生成分享… +
      + ) : ( +
      +
      +

      + {isView ? "只读链接" : "分享码"} +

      +
      + {isView ? ( + e.currentTarget.select()} + className="flex-1 font-mono text-xs" + /> + ) : ( + + {share.share_code} + + )} + +
      +
      + {isView ? ( +
      +

      + 拿到链接的人都能查看这次会商的结果报告(只读)。若会商尚未生成结果报告,对方会看到提示。撤销后链接立即失效。 +

      + +
      + ) : ( +

      + 把这个分享码发给对方,他们在「导入会商」里输入即可复制一份。撤销后分享码立即失效。 +

      + )} + +
      + )} +
      +
      + ); +} + +/** Dialog where a user pastes a share code to import someone's roundtable draft. */ +export function ImportRoundtableDraftDialog({ + open, + onOpenChange, + onImported, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Called with the new local draft id after a successful import. */ + onImported?: (draftId: string | null) => void; +}) { + const [input, setInput] = useState(""); + const [preview, setPreview] = useState(null); + const previewShare = usePreviewDraftShare(); + const importShare = useImportDraftShare(); + + // Reset state whenever the dialog closes. + useEffect(() => { + if (!open) { + setInput(""); + setPreview(null); + } + }, [open]); + + async function handlePreview() { + const code = extractShareCode(input); + if (!code) { + toast.error("请输入分享码"); + return; + } + try { + setPreview(await previewShare.mutateAsync(code)); + } catch (err) { + setPreview(null); + toast.error(errorMessage(err)); + } + } + + async function handleImport() { + const code = preview?.share_code ?? extractShareCode(input); + if (!code) { + toast.error("请输入分享码"); + return; + } + try { + const result = await importShare.mutateAsync(code); + if (result.imported > 0) { + toast.success("导入成功,已加入「多智能体会商」历史"); + } else { + toast.info("这份会商之前已导入过"); + } + onImported?.(result.draft_id); + onOpenChange(false); + } catch (err) { + toast.error(errorMessage(err)); + } + } + + return ( + + + + 导入会商 + + 输入对方分享的「导入副本」分享码,把这份会商复制到你自己的账号。 + + + +
      + { + setInput(e.target.value); + setPreview(null); + }} + placeholder="粘贴分享码或链接" + className="font-mono" + /> + +
      + + {preview && ( +
      +

      {preview.title || "未命名会商"}

      +

      + 来自用户 {preview.shared_by} +

      + +
      + )} +
      +
      + ); +} diff --git a/frontend-web/src/components/workspace/workspace-sidebar.tsx b/frontend-web/src/components/workspace/workspace-sidebar.tsx index eeacf4f7..dc1e097b 100644 --- a/frontend-web/src/components/workspace/workspace-sidebar.tsx +++ b/frontend-web/src/components/workspace/workspace-sidebar.tsx @@ -12,6 +12,7 @@ import { useTheme } from "@/contexts/ThemeContext"; import { cn } from "@/lib/utils"; import { RecentChatList } from "./recent-chat-list"; +import { RecentRoundtableList } from "./recent-roundtable-list"; import { RecentWritingList } from "./recent-writing-list"; import { WorkspaceHeader } from "./workspace-header"; import { WorkspaceNavChatList } from "./workspace-nav-chat-list"; @@ -46,6 +47,7 @@ export function WorkspaceSidebar({ {isSidebarOpen && } + {isSidebarOpen && } {isSidebarOpen && } {!compactChrome && ( diff --git a/frontend-web/src/core/roundtable-draft-shares/api.ts b/frontend-web/src/core/roundtable-draft-shares/api.ts new file mode 100644 index 00000000..47a6f9d2 --- /dev/null +++ b/frontend-web/src/core/roundtable-draft-shares/api.ts @@ -0,0 +1,131 @@ +import { apiFetch } from "@/core/api"; +import { getBackendBaseURL } from "@/core/config"; +// Share codes share the same alphabet/shape as conversation shares, so reuse the +// extractor instead of duplicating the regex. +export { extractShareCode } from "@/core/thread-shares/api"; + +/** + * Roundtable-draft share modes: + * - `import` — recipient (a logged-in user) copies the 会商 draft into their own + * account and can keep working on the copy. + * - `view` — a public, read-only page anyone can open without logging in; it + * renders the draft's Step 3 result report (方案总览 HTML). + */ +export type DraftShareMode = "import" | "view"; + +export interface DraftShare { + share_code: string; + owner_user_id: string; + draft_id: string; + revoked: boolean; + mode: DraftShareMode; + created_at: string | null; +} + +/** Read-only roundtable-result snapshot returned by the public view-share endpoint. */ +export interface PublicDraftShareSnapshot { + share_code: string; + title: string; + mode: string; + /** Self-contained Step 3 result report HTML (empty when no report exists). */ + html: string; + summary: string | null; + generated_at: string | null; + has_report: boolean; + created_at: string | null; +} + +export interface DraftSharePreview { + share_code: string; + shared_by: string; + title: string; +} + +export interface DraftImportResult { + imported: number; + skipped: number; + draft_id: string | null; +} + +const baseUrl = () => `${getBackendBaseURL()}/api/roundtable-draft-shares`; + +async function parseError(response: Response, fallback: string): Promise { + const data = (await response.json().catch(() => ({}))) as { detail?: unknown }; + const detail = data.detail; + const message = + typeof detail === "string" && detail + ? detail + : `${fallback} (HTTP ${response.status})`; + return new Error(message); +} + +/** Create (or reuse) a share code for one roundtable draft in the given mode. */ +export async function createDraftShare( + draftId: string, + mode: DraftShareMode = "import", +): Promise { + const response = await apiFetch(baseUrl(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ draft_id: draftId, mode }), + }); + if (!response.ok) throw await parseError(response, "创建分享失败"); + return response.json() as Promise; +} + +/** List the current user's own roundtable-draft share links. */ +export async function listDraftShares(): Promise { + const response = await apiFetch(baseUrl()); + if (!response.ok) throw await parseError(response, "加载分享列表失败"); + const json = (await response.json()) as { shares: DraftShare[] }; + return json.shares; +} + +/** Revoke a share link so it can no longer be previewed, imported, or viewed. */ +export async function revokeDraftShare(shareCode: string): Promise { + const response = await apiFetch(`${baseUrl()}/${encodeURIComponent(shareCode)}`, { + method: "DELETE", + }); + if (!response.ok) throw await parseError(response, "撤销分享失败"); +} + +/** Preview a share link before importing it. */ +export async function previewDraftShare(shareCode: string): Promise { + const response = await apiFetch( + `${baseUrl()}/${encodeURIComponent(shareCode)}/preview`, + ); + if (!response.ok) throw await parseError(response, "无法读取该分享"); + return response.json() as Promise; +} + +/** Import the roundtable draft behind a share link into the current account. */ +export async function importDraftShare(shareCode: string): Promise { + const response = await apiFetch( + `${baseUrl()}/${encodeURIComponent(shareCode)}/import`, + { method: "POST" }, + ); + if (!response.ok) throw await parseError(response, "导入失败"); + return response.json() as Promise; +} + +/** + * Fetch the public read-only snapshot of a `view`-mode roundtable share. + * + * Uses a plain `fetch` (no auth header) — the `/api/public/` endpoint is + * intentionally world-readable so anyone with the link can open it. + */ +export async function getPublicDraftShareSnapshot( + shareCode: string, +): Promise { + const response = await fetch( + `${getBackendBaseURL()}/api/public/roundtable-shares/${encodeURIComponent(shareCode)}`, + ); + if (!response.ok) throw await parseError(response, "无法打开该分享"); + return response.json() as Promise; +} + +/** Build the shareable public URL for a `view`-mode roundtable share code. */ +export function buildPublicDraftShareUrl(shareCode: string): string { + // The app uses HashRouter, so the public viewer lives at `#/roundtable-share/`. + return `${window.location.origin}${window.location.pathname}#/roundtable-share/${shareCode}`; +} diff --git a/frontend-web/src/core/roundtable-draft-shares/hooks.ts b/frontend-web/src/core/roundtable-draft-shares/hooks.ts new file mode 100644 index 00000000..98a10f5f --- /dev/null +++ b/frontend-web/src/core/roundtable-draft-shares/hooks.ts @@ -0,0 +1,61 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + createDraftShare, + importDraftShare, + listDraftShares, + previewDraftShare, + revokeDraftShare, + type DraftShareMode, +} from "./api"; + +export function useDraftShares(enabled = true) { + return useQuery({ + queryKey: ["roundtable-draft-shares"], + queryFn: listDraftShares, + enabled, + }); +} + +export function useCreateDraftShare() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + draftId, + mode = "import", + }: { + draftId: string; + mode?: DraftShareMode; + }) => createDraftShare(draftId, mode), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["roundtable-draft-shares"] }); + }, + }); +} + +export function useRevokeDraftShare() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (shareCode: string) => revokeDraftShare(shareCode), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["roundtable-draft-shares"] }); + }, + }); +} + +export function usePreviewDraftShare() { + return useMutation({ + mutationFn: (shareCode: string) => previewDraftShare(shareCode), + }); +} + +export function useImportDraftShare() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (shareCode: string) => importDraftShare(shareCode), + onSuccess: () => { + // Imported drafts show up in the user's roundtable history sidebar. + void queryClient.invalidateQueries({ queryKey: ["roundtable-drafts"] }); + }, + }); +} diff --git a/frontend-web/src/pages/PublicRoundtableSharePage.tsx b/frontend-web/src/pages/PublicRoundtableSharePage.tsx new file mode 100644 index 00000000..42c1392e --- /dev/null +++ b/frontend-web/src/pages/PublicRoundtableSharePage.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { useParams } from "react-router-dom"; + +import { getPublicDraftShareSnapshot } from "@/core/roundtable-draft-shares/api"; + +/** + * Public, read-only roundtable-result viewer. + * + * Reachable at `#/roundtable-share/` with NO authentication — anyone with + * the link can open it. It renders the Step 3「结果绘制」report (a self-contained + * HTML document) inside a sandboxed iframe with no `allow-scripts`, mirroring the + * scheduled-task HTML-page preview, so a shared report can never execute code in + * the app origin. + */ +export default function PublicRoundtableSharePage() { + const { shareCode } = useParams<{ shareCode: string }>(); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["public-roundtable-share", shareCode], + queryFn: () => getPublicDraftShareSnapshot(shareCode!), + enabled: !!shareCode, + retry: false, + refetchOnWindowFocus: false, + }); + + if (isLoading) { + return ( +
      + + 正在加载分享内容… +
      + ); + } + + if (isError || !data) { + return ( +
      +

      分享不可用

      +

      + 该分享链接不存在、已被撤销,或不是只读分享。 +

      +
      + ); + } + + return ( +
      +
      +
      +

      + {data.title || "未命名会商"} +

      + + 只读分享 + +
      +

      + 这是一份多智能体会商的结果报告快照(只读),你可以查看全部内容,但无法继续研讨。 +

      +
      +
      + {data.has_report ? ( + -
      + +
      +
      + +
      = ({ title, count, color, />
      -
      + +
      {tasks.length === 0 ? ( -
      - -

      暂无任务

      +
      +
      + +
      +
      暂无任务
      +
      + 可以尝试调整筛选条件或切换数据模式。 +
      ) : ( - tasks.map(task => ) + tasks.map((task) => ( + + )) )}
      ); -} +}; diff --git a/frontend-web/src/strategy-components/components/task-components/SentimentAnalyzes.tsx b/frontend-web/src/strategy-components/components/task-components/SentimentAnalyzes.tsx index 7ced621f..b3f3fb56 100644 --- a/frontend-web/src/strategy-components/components/task-components/SentimentAnalyzes.tsx +++ b/frontend-web/src/strategy-components/components/task-components/SentimentAnalyzes.tsx @@ -1,38 +1,13 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { - Plus, - Search, - Eye, - Play, - Pause, - Clock, - User, - ChevronRight, - Filter, - FileText, - AlertTriangle, - CheckCircle, - XCircle, - MoreHorizontal, - Edit, - Download, - Bot, - Users, - Workflow, - X, - ArrowRight, - Target, - Layers, -} from 'lucide-react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { ArrowRight, Bot, Clock3, Layers, Search, Sparkles, Users, X } from 'lucide-react'; +import { DateRangePicker, DateRangeValue, Loading } from 'tdesign-react'; +import dayjs from 'dayjs'; +import { useNavigate } from 'react-router-dom'; import { useTheme } from '@/contexts/ThemeContext'; -// 导入请求服务,用于与后端 API 交互 +import { useChatContext } from '@/contexts/ChatContext'; import { requestCrudService } from '@/service/request-axios-service'; import { KanbanColumn } from './KanbanColumn'; import { TaskListItem } from './TaskManagement'; -import { DateRangePicker, DateRangePickerProps, DateRangeValue, Loading } from 'tdesign-react'; -import dayjs from 'dayjs'; -import { useChatContext } from '@/contexts/ChatContext'; -import { useNavigate } from 'react-router-dom'; interface TaskListPageProps { title: string; @@ -40,350 +15,575 @@ interface TaskListPageProps { subCategory: string; } -interface stateItem { - startTime: string, - endTime: string, - task: TaskListItem, -} - type TaskType = 'single' | 'consultation' | 'scene' | 'multi-agent' | 'task-search'; -type TaskStatus = 'draft' | 'pending' | 'running' | 'paused' | 'blocked' | 'completed'; type ColumnType = 'unanalyzed' | 'analyzing' | 'completed'; const service = requestCrudService; const zncmUrl = import.meta.env.VITE_API_BASE_URL || ''; +const ENABLE_SENTIMENT_DEMO_BY_DEFAULT = import.meta.env.DEV; -const SentimentAnalyzes: React.FC = ({ title, category, subCategory}) => { +const buildMockTask = ( + id: string, + name: string, + overview: string, + column: ColumnType, + type: TaskType, + priority: 1 | 2 | 3, + progress: number, + createTime: string, + updateTime: string, + owner: string, +): TaskListItem => ({ + id, + taskName: name, + overview, + column, + type, + priority, + progress, + createTime, + updateTime, + owner, + creator: owner, + executor: owner, + scene: JSON.stringify({ + taskJson: JSON.stringify({ + task_title: name, + task_goal: overview, + }), + }), +}); + +const MOCK_TASKS: Record = { + unanalyzed: [ + buildMockTask( + 'demo-u-1', + '新能源价格舆情波动分析', + '聚焦近 7 日新能源整车与动力电池价格讨论热度,识别社媒与媒体端异常扩散信号。', + 'unanalyzed', + 'single', + 2, + 0, + '2026-06-05 09:10:00', + '2026-06-05 09:10:00', + '王晓楠', + ), + buildMockTask( + 'demo-u-2', + '重点项目负面反馈聚类', + '从任务评论、审批意见和业务群消息中抽取高频情绪主题,辅助判断风险聚焦点。', + 'unanalyzed', + 'consultation', + 3, + 0, + '2026-06-05 08:40:00', + '2026-06-05 08:40:00', + '刘志诚', + ), + buildMockTask( + 'demo-u-3', + '竞品发布会用户情绪追踪', + '围绕发布会直播、短视频评论与论坛帖子,建立情绪转折时间线并识别主要触发事件。', + 'unanalyzed', + 'scene', + 1, + 0, + '2026-06-04 18:20:00', + '2026-06-04 18:20:00', + '陈思雨', + ), + buildMockTask( + 'demo-u-4', + '区域渠道满意度扫描', + '基于周报、工单和售后回访文本,发现华东区域渠道服务满意度下行趋势。', + 'unanalyzed', + 'task-search', + 2, + 0, + '2026-06-04 15:35:00', + '2026-06-04 15:35:00', + '赵明宇', + ), + ], + analyzing: [ + buildMockTask( + 'demo-a-1', + '新车上市口碑监测会商', + '正在联合多个智能体汇总平台评价、媒体测评与用户问答,生成阶段性情绪画像。', + 'analyzing', + 'multi-agent', + 3, + 56, + '2026-06-05 07:50:00', + '2026-06-05 10:05:00', + '张雨桐', + ), + buildMockTask( + 'demo-a-2', + '投诉热点归因拆解', + '拆分售后投诉中关于续航、交付和门店服务的负面占比,分析主要归因与蔓延路径。', + 'analyzing', + 'consultation', + 2, + 78, + '2026-06-04 20:10:00', + '2026-06-05 09:48:00', + '何嘉成', + ), + ], + completed: [ + buildMockTask( + 'demo-c-1', + '车展传播效果情绪复盘', + '已输出传播节点热度变化、核心评论主题以及 KOL 内容对情绪曲线的影响分析。', + 'completed', + 'single', + 1, + 100, + '2026-06-03 14:20:00', + '2026-06-04 17:30:00', + '孙怡宁', + ), + buildMockTask( + 'demo-c-2', + '智能驾驶争议议题扫描', + '已完成争议议题聚类、情绪倾向分层和外部平台扩散链路分析,可直接查看结论。', + 'completed', + 'scene', + 3, + 100, + '2026-06-03 09:00:00', + '2026-06-04 11:45:00', + '李承泽', + ), + buildMockTask( + 'demo-c-3', + '品牌联名活动舆情总结', + '已完成用户态度分布统计与正负面声音对比,附带评论样本摘要和建议动作。', + 'completed', + 'task-search', + 2, + 100, + '2026-06-02 16:50:00', + '2026-06-03 19:12:00', + '周清越', + ), + ], +}; + +const paginateTasks = (tasks: TaskListItem[], pageIndex: number, pageSize: number) => { + const start = (pageIndex - 1) * pageSize; + return tasks.slice(start, start + pageSize); +}; + +const SentimentAnalyzes: React.FC = ({ title, category, subCategory }) => { const { theme } = useTheme(); - const isDark = theme === 'dark'; - const router = useNavigate(); - // 主题颜色变量 - const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white'; - const cardBorder = isDark ? 'border-blue-800/30' : 'border-red-100'; - const textPrimary = isDark ? 'text-white' : 'text-gray-900'; - const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600'; - const textMuted = isDark ? 'text-slate-500' : 'text-gray-400'; - const inputBg = isDark ? 'bg-slate-900/50' : 'bg-white'; const { setStartConfig } = useChatContext(); + const navigate = useNavigate(); + const isDark = theme === 'dark'; + const shellClass = isDark + ? 'strategy-data min-h-full bg-[radial-gradient(circle_at_top_left,rgba(27,94,168,0.28),transparent_32%),linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)] text-slate-100' + : 'strategy-data min-h-full bg-[#f8fafc] text-slate-900'; + const metricCardClass = isDark + ? 'border border-[rgba(110,136,176,0.24)] bg-[linear-gradient(180deg,rgba(12,32,58,0.96),rgba(9,22,39,0.96))] shadow-[0_14px_42px_rgba(2,8,20,0.30)]' + : 'border border-slate-200 bg-white shadow-sm'; + const inputClass = isDark + ? 'border-[rgba(110,136,176,0.3)] bg-[rgba(8,23,42,0.92)] text-slate-100 placeholder:text-slate-500' + : 'border-slate-200 bg-white text-slate-900 placeholder:text-slate-400'; + const panelGlowClass = isDark + ? 'before:pointer-events-none before:absolute before:inset-x-8 before:top-0 before:h-px before:bg-[linear-gradient(90deg,transparent,rgba(96,165,250,0.45),transparent)]' + : ''; + const [searchQuery, setSearchQuery] = useState(''); - const [modeFilter, setModeFilter] = useState('all'); const [showAnalysisModal, setShowAnalysisModal] = useState(false); const [selectedTaskForAnalysis, setSelectedTaskForAnalysis] = useState(null); - const [unanalyzedTotal, setUnanalyzedTotal] = useState(0); // 未分析 - const [runningTotal, setRunningTotal] = useState(0); // 分析中 - const [completedTotal, setCompletedTotal] = useState(0); // 已完成 - const [unanalyzedList, setUnanalyzedList] = useState([]); // 未分析 - const [runningList, setRunningList] = useState([]); // 分析中 - const [completedList, setCompletedList] = useState([]); // 已完成 - const [unanalyzedLoading, setUnanalyzedLoading] = useState(false); // 未分析 - const [runningLoading, setRunningLoading] = useState(false); // 分析中 - const [completedLoading, setCompletedLoading] = useState(false); // 已完成 - const [presets] = useState({ - 最近7天: [dayjs().subtract(6, 'day').toDate(), dayjs().toDate()], - 最近3天: [dayjs().subtract(2, 'day').toDate(), dayjs().toDate()], - 今天: [dayjs().toDate(), dayjs().toDate()], - }); - const [range, setRange] = useState([]); - const state = useRef({ - startTime: '', - endTime: '', - task: {} as TaskListItem, - }) + const [useDemoData, setUseDemoData] = useState(ENABLE_SENTIMENT_DEMO_BY_DEFAULT); + const [unanalyzedTotal, setUnanalyzedTotal] = useState(0); + const [runningTotal, setRunningTotal] = useState(0); + const [completedTotal, setCompletedTotal] = useState(0); + const [unanalyzedList, setUnanalyzedList] = useState([]); + const [runningList, setRunningList] = useState([]); + const [completedList, setCompletedList] = useState([]); + const [unanalyzedLoading, setUnanalyzedLoading] = useState(false); + const [runningLoading, setRunningLoading] = useState(false); + const [completedLoading, setCompletedLoading] = useState(false); + const [dateRange, setDateRange] = useState([ + dayjs().startOf('month').format('YYYY-MM-DD'), + dayjs().endOf('day').format('YYYY-MM-DD'), + ]); - // 时间日期选择器查询 - const setRangeChange = async (val) => { - setRange(val); - console.log('时间日期', val); - state.current.startTime = ''; - state.current.endTime = ''; - if (val.length > 0) { - state.current.startTime = val[0]; - state.current.endTime = val[1]; - } + const pageSizeRef = useRef(10); - await taskDataChange(); - } + const filteredMock = useMemo(() => { + const keyword = searchQuery.trim().toLowerCase(); + const filterItems = (items: TaskListItem[]) => { + if (!keyword) return items; + return items.filter((item) => { + const text = `${item.taskName} ${item.overview} ${item.owner || ''}`.toLowerCase(); + return text.includes(keyword); + }); + }; - // 处理开始分析按钮点击 - const handleStartAnalysis = (task: TaskListItem) => { - setSelectedTaskForAnalysis(task); - state.current.task = task; - setShowAnalysisModal(true); - }; + return { + unanalyzed: filterItems(MOCK_TASKS.unanalyzed), + analyzing: filterItems(MOCK_TASKS.analyzing), + completed: filterItems(MOCK_TASKS.completed), + }; + }, [searchQuery]); - // 处理分析模式选择 - const handleSelectAnalysisMode = (mode: TaskType) => { - setShowAnalysisModal(false); - console.log('mode', mode); - if (mode === 'consultation') { - console.log('state.current.task', state.current.task) - const sceneItem = JSON.parse(state.current.task.scene); - if (sceneItem.taskJson) { - sceneItem.taskJson = JSON.parse(sceneItem.taskJson); - setStartConfig({ - ...sceneItem.taskJson, - llm_preset_id: 'BAILING', - consistency_mode: 'soft', - phase_config: { diverge_rounds: 3 }, - task_context: state.current.task.overview - }); - router('/qa-center/mope-chat') - } - console.log('sceneItem', sceneItem); - } else if (mode === 'task-search') { - window.open(`https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-info-search?id=${state.current.task.id}`) - } - }; + const applyMockColumn = (column: ColumnType, pageIndex = 1) => { + const source = filteredMock[column]; + const pageItems = paginateTasks(source, pageIndex, pageSizeRef.current); - // 处理卡片点击 - const taskListSearch = async (taskType: string, pageNum: number = 1, pageSize: number = 10) => { - let taskStatus: number[] = [] - if (taskType === 'unanalyzed') { // 未分析 - taskStatus = [0, 10]; - setUnanalyzedLoading(true); - } else if (taskType === 'running') { // 分析中 - taskStatus = [20]; - setRunningLoading(true); - } else if (taskType === 'completed') { // 已完成 - taskStatus = [25, 30, 40, 60, 70, 80, 90, 100, 110]; - setCompletedLoading(true); - } - const param = { - content: searchQuery, - pageNum: pageNum, - pageSize: pageSize, - taskStatus: taskStatus, - startTime: state.current.startTime, - endTime: state.current.endTime, - taskDirection: '' - } - const res = await service.post(`${zncmUrl}/three/cop-three-tasks`, param); - console.log('任务列表', res); - const result = res.data; - result.records.forEach((item: TaskListItem) => { - item.column = taskType; - if (!item.taskLevel) item.taskLevel = 1; - }) - if (taskType === 'unanalyzed') { + if (column === 'unanalyzed') { + setUnanalyzedTotal(source.length); + setUnanalyzedList(pageItems); setUnanalyzedLoading(false); - setUnanalyzedTotal(result.total); - setUnanalyzedList(result.records); - } else if (taskType === 'running') { + } else if (column === 'analyzing') { + setRunningTotal(source.length); + setRunningList(pageItems); setRunningLoading(false); - setRunningTotal(result.total); - setRunningList(result.records); - } else if (taskType === 'completed') { + } else { + setCompletedTotal(source.length); + setCompletedList(pageItems); setCompletedLoading(false); - setCompletedTotal(result.total); - setCompletedList(result.records); } - } + }; - /** - * 页码变化时的回调函数 - * 当用户切换页码时触发,更新页码并重新获取数据 - * @param value - 新的页码值 - */ - const pageIndexChange = async (type: string, pageIndex: number) => { - // 更新 ref 中存储的页码值 - // 更新页码状态,触发组件重新渲染 - await taskListSearch(type, pageIndex); - } + const taskListSearch = async ( + taskStatus: ColumnType, + pageIndex = 1, + pageSize = pageSizeRef.current, + ) => { + pageSizeRef.current = pageSize; - const taskDataChange = async (pageNum: number = 1, pageSize: number = 10) => { - taskListSearch('unanalyzed', pageNum, pageSize); - taskListSearch('running', pageNum, pageSize); - taskListSearch('completed', pageNum, pageSize); - } + const setLoadingByType = (loading: boolean) => { + if (taskStatus === 'unanalyzed') setUnanalyzedLoading(loading); + if (taskStatus === 'analyzing') setRunningLoading(loading); + if (taskStatus === 'completed') setCompletedLoading(loading); + }; - // 回车处理 - const handleKeyDown = async (e) => { - if ((e.metaKey || e.ctrlKey || e.shiftKey) && e.key === "Enter") { - e.preventDefault(); - } else if (e.keyCode === 13) { - e.preventDefault(); - await taskDataChange(); - } else if (e.key === "/") { - e.preventDefault(); + setLoadingByType(true); + + if (useDemoData) { + applyMockColumn(taskStatus, pageIndex); + return; } + + try { + const [startTime, endTime] = Array.isArray(dateRange) ? dateRange : ['', '']; + const payload = { + pageNum: pageIndex, + pageSize, + taskStatus, + taskCategoryName: category, + taskTypeName: subCategory, + searchKey: searchQuery.trim() || undefined, + startTime, + endTime, + }; + + const response = await service.post(`${zncmUrl}/three/cop-three-tasks`, payload); + const rows = response?.rows || response?.data?.rows || []; + const total = response?.total || response?.data?.total || 0; + const mappedList = rows.map((item: TaskListItem) => ({ + ...item, + column: taskStatus === 'analyzing' ? 'running' : taskStatus, + })); + + if (taskStatus === 'unanalyzed') { + setUnanalyzedList(mappedList); + setUnanalyzedTotal(total); + } else if (taskStatus === 'analyzing') { + setRunningList(mappedList); + setRunningTotal(total); + } else { + setCompletedList(mappedList); + setCompletedTotal(total); + } + } catch (error) { + console.error('sentiment task list fetch failed', error); + applyMockColumn(taskStatus, pageIndex); + setUseDemoData(true); + } finally { + setLoadingByType(false); + } + }; + + const refreshAllColumns = async () => { + await Promise.all([ + taskListSearch('unanalyzed', 1), + taskListSearch('analyzing', 1), + taskListSearch('completed', 1), + ]); }; useEffect(() => { - taskDataChange(); - }, []); + refreshAllColumns(); + }, [useDemoData]); - // 渲染看板列 + useEffect(() => { + const timer = window.setTimeout(() => { + refreshAllColumns(); + }, useDemoData ? 120 : 260); + + return () => window.clearTimeout(timer); + }, [searchQuery, dateRange, useDemoData]); + + const metrics = [ + { + label: '全部任务', + value: unanalyzedTotal + runningTotal + completedTotal, + icon: Layers, + accent: 'from-[#4D8DFF] to-[#76C2FF]', + }, + { + label: '未分析', + value: unanalyzedTotal, + icon: Clock3, + accent: 'from-[#38BDF8] to-[#7DD3FC]', + }, + { + label: '分析中', + value: runningTotal, + icon: Sparkles, + accent: 'from-[#2DD4BF] to-[#67E8F9]', + }, + { + label: '已完成', + value: completedTotal, + icon: Bot, + accent: 'from-[#60A5FA] to-[#A78BFA]', + }, + ]; + + const handleStartAnalysis = (task: TaskListItem) => { + setSelectedTaskForAnalysis(task); + setShowAnalysisModal(true); + }; + + const handleViewTaskDetail = () => { + if (!selectedTaskForAnalysis) return; + setStartConfig({ + task_title: selectedTaskForAnalysis.taskName, + task_goal: selectedTaskForAnalysis.overview, + task_context: selectedTaskForAnalysis.overview, + llm_preset_id: 'BAILING', + consistency_mode: 'soft', + phase_config: { diverge_rounds: 3 }, + }); + setShowAnalysisModal(false); + navigate('/qa-center/mope-chat'); + }; + + const handleMultiAgentDiscussion = () => { + if (!selectedTaskForAnalysis) return; + setStartConfig({ + task_title: selectedTaskForAnalysis.taskName, + task_goal: selectedTaskForAnalysis.overview, + task_context: selectedTaskForAnalysis.overview, + llm_preset_id: 'BAILING', + consistency_mode: 'soft', + phase_config: { diverge_rounds: 3 }, + }); + setShowAnalysisModal(false); + navigate('/page/workspace/roundtable/planning'); + }; return ( -
      - {/* 顶部工具栏 */} -
      -
      -
      -
      - - {category} - - / - - {subCategory} - -
      -

      任务中心

      -
      - {/* */} -
      - - {/* 搜索和筛选 */} -
      -
      - - setSearchQuery(e.target.value)} - onKeyDown={(e) => handleKeyDown(e)} - className={`w-full pl-10 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-red-500/20 transition-colors ${inputBg} ${cardBorder} ${textPrimary} placeholder:${textMuted}`} - /> -
      - {/* */} - {/* */} - setRangeChange(val)} enableTimePicker={true} clearable /> -
      -
      - - {/* 统计概览栏 */} -
      - - - - -
      - - {/* 看板区 */} -
      - - - - - - - - - -
      - - {/* 选择分析模式弹窗 */} - {showAnalysisModal && selectedTaskForAnalysis && ( -
      setShowAnalysisModal(false)}> -
      e.stopPropagation()} - > -
      +
      +
      +
      +
      +
      +
      + +
      -

      选择分析模式

      -

      为任务「{selectedTaskForAnalysis.taskName}」选择分析方式

      +
      +

      {title}

      + {useDemoData ? ( + + 演示数据 + + ) : null} +
      +

      + 面向规划任务的情绪与倾向分析看板,支持快速检索、会商启动与结果回看。 +

      +
      +
      +
      + {category} + + {subCategory} +
      +
      + +
      +
      + + setDateRange(value)} + /> +
      +
      + + +
      +
      +
      + +
      + {metrics.map(({ label, value, icon: Icon, accent }) => ( +
      +
      +
      +

      {label}

      +

      {value}

      +
      +
      + +
      +
      +
      + ))} +
      + +
      +
      + + taskListSearch(taskType as ColumnType, pageIndex)} + handleStartAnalysis={handleStartAnalysis} + /> + +
      +
      + + taskListSearch(taskType as ColumnType, pageIndex)} + handleStartAnalysis={handleStartAnalysis} + /> + +
      +
      + + taskListSearch(taskType as ColumnType, pageIndex)} + handleStartAnalysis={handleStartAnalysis} + /> + +
      +
      +
      + + {showAnalysisModal && selectedTaskForAnalysis ? ( +
      +
      +
      +
      +

      选择分析模式

      +

      + 当前任务:{selectedTaskForAnalysis.taskName} +

      -
      - +
      - - {/* 多智能体会商 */}
      - )} + ) : null}
      ); }; diff --git a/frontend-web/src/strategy-components/components/task-components/TaskCard.tsx b/frontend-web/src/strategy-components/components/task-components/TaskCard.tsx index 32f75e0a..62edd86b 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskCard.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskCard.tsx @@ -1,10 +1,10 @@ -import { useTheme } from '@/contexts/ThemeContext'; -import { TaskListItem } from './TaskManagement'; -import { Bot, ChevronRight, Download, Edit, Eye, FileText, Layers, MoreHorizontal, Play, User, Users, Workflow } from 'lucide-react'; import React from 'react'; import { format } from 'date-fns'; import { useNavigate } from 'react-router-dom'; +import { Bot, Eye, Layers, Play, User, Users, Workflow } from 'lucide-react'; +import { useTheme } from '@/contexts/ThemeContext'; import { useChatContext } from '@/contexts/ChatContext'; +import { TaskListItem } from './TaskManagement'; interface TaskCardProps { TaskType?: string; @@ -12,27 +12,20 @@ interface TaskCardProps { handleStartAnalysis: (task: TaskListItem) => void; } -export const TaskCard: React.FC = ({ task, TaskType = 'task', handleStartAnalysis }) => { +export const TaskCard: React.FC = ({ + task, + TaskType = 'task', + handleStartAnalysis, +}) => { const { theme } = useTheme(); const { setStartConfig } = useChatContext(); - const isDark = theme === 'dark'; - const textPrimary = isDark ? 'text-white' : 'text-gray-900'; - const textMuted = isDark ? 'text-slate-500' : 'text-gray-400'; - const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600'; const navigate = useNavigate(); + const isDark = theme === 'dark'; - const handleStartChange = (task: TaskListItem) => { - // if (TaskType === 'task') { - // handleStartAnalysis(task); - // } else { - // sceneDataChange(task); - // } - handleStartAnalysis(task); - } + const sceneDataChange = (currentTask: TaskListItem) => { + if (!currentTask.scene) return; - const sceneDataChange = (task: TaskListItem) => { - if (!task.scene) return; - const sceneItem = JSON.parse(task.scene); + const sceneItem = JSON.parse(currentTask.scene); if (sceneItem.taskJson) { sceneItem.taskJson = JSON.parse(sceneItem.taskJson); setStartConfig({ @@ -40,264 +33,198 @@ export const TaskCard: React.FC = ({ task, TaskType = 'task', han llm_preset_id: 'BAILING', consistency_mode: 'soft', phase_config: { diverge_rounds: 3 }, - task_context: task.overview + task_context: currentTask.overview, }); - navigate('/qa-center/mope-chat') + navigate('/qa-center/mope-chat'); } - console.log('sceneItem', sceneItem); - } - // 处理卡片点击 - const handleTaskClick = (task: TaskListItem) => { + }; + + const handleTaskClick = (currentTask: TaskListItem) => { if (TaskType === 'sentiment') { - console.log('点击了', task) - sceneDataChange(task); - // window.open(`http://ch1.b.uat.4.cn/taskcop/#/situation-overview/action/sentiment?taskId=${task.id}`) - } else { - if (task.column === 'completed') { - // if (task.type === 'consultation') { - // onNavigate('task-consult-result'); - // } else { - // onNavigate('strategy-result'); - // } - navigate('/strategy-result') - } else if (task.column === 'running') { - // navigate('/task-management/task-consult') - sceneDataChange(task); - // window.open(`http://ch1.b.uat.4.cn/mopeweb/#/mope?taskOverview=${task.overview}`) - // if (task.type === 'scene') { - // onNavigate('task-scene'); - // } else if (task.type === 'consultation') { - // onNavigate('task-consult'); - // } else { - // onNavigate('task-single'); - // } - } + sceneDataChange(currentTask); + return; + } + + if (currentTask.column === 'completed') { + navigate('/strategy-result'); + return; + } + + if (currentTask.column === 'running' || currentTask.column === 'analyzing') { + sceneDataChange(currentTask); } }; - // 获取任务类型配置 + const handleStartChange = (currentTask: TaskListItem) => { + handleStartAnalysis(currentTask); + }; + const getTypeConfig = (type: string) => { - if (isDark) { - switch (type) { - case 'single': - return { label: '单智能体', color: 'bg-cyan-500/20 text-cyan-400', icon: Bot }; - case 'scene': - return { label: '场景执行', color: 'bg-purple-500/20 text-purple-400', icon: Workflow }; - case 'consultation': - return { label: '多智能体会商', color: 'bg-orange-500/20 text-orange-400', icon: Users }; - case 'multi-agent': - return { label: '多智能体协同', color: 'bg-green-500/20 text-green-400', icon: Layers }; - } - } else { - switch (type) { - case 'single': - return { label: '单智能体', color: 'bg-blue-100 text-blue-600', icon: Bot }; - case 'scene': - return { label: '场景执行', color: 'bg-purple-100 text-purple-600', icon: Workflow }; - case 'consultation': - return { label: '多智能体会商', color: 'bg-orange-100 text-orange-600', icon: Users }; - case 'multi-agent': - return { label: '多智能体协同', color: 'bg-green-100 text-green-600', icon: Layers }; - } - } - }; - const typeConfig = getTypeConfig('single'); - // 获取任务等级 - const getLevel = (type: number) => { - const fallback = { label: '一般', color: isDark ? 'bg-cyan-500/20 text-cyan-400' : 'bg-blue-100 text-blue-600', icon: Bot }; - if (isDark) { - switch (type) { - case 1: - return { label: '一般', color: 'bg-cyan-500/20 text-cyan-400', icon: Bot }; - case 2: - return { label: '紧急', color: 'bg-purple-500/20 text-purple-400', icon: Workflow }; - case 3: - return { label: '加急', color: 'bg-orange-500/20 text-orange-400', icon: Users }; - default: - return fallback; - } - } else { - switch (type) { - case 1: - return { label: '一般', color: 'bg-blue-100 text-blue-600', icon: Bot }; - case 2: - return { label: '紧急', color: 'bg-purple-100 text-purple-600', icon: Workflow }; - case 3: - return { label: '加急', color: 'bg-orange-100 text-orange-600', icon: Users }; - default: - return fallback; - } + const common = isDark ? 'border border-white/10 text-slate-200' : 'border border-slate-200 text-slate-700'; + switch (type) { + case 'single': + return { + label: '单任务', + icon: Layers, + className: `${common} ${isDark ? 'bg-[rgba(22,119,255,0.14)]' : 'bg-sky-50'}`, + }; + case 'consultation': + return { + label: '会商', + icon: Users, + className: `${common} ${isDark ? 'bg-[rgba(20,184,166,0.14)]' : 'bg-emerald-50'}`, + }; + case 'scene': + return { + label: '场景', + icon: Workflow, + className: `${common} ${isDark ? 'bg-[rgba(168,85,247,0.14)]' : 'bg-violet-50'}`, + }; + case 'multi-agent': + return { + label: '多智能体', + icon: Bot, + className: `${common} ${isDark ? 'bg-[rgba(99,102,241,0.16)]' : 'bg-indigo-50'}`, + }; + default: + return { + label: '检索', + icon: Layers, + className: `${common} ${isDark ? 'bg-[rgba(59,130,246,0.12)]' : 'bg-slate-100'}`, + }; } }; - const levelConfig = getLevel(task.taskLevel); - const LevelIcon = levelConfig.icon; - // 获取状态配置 + const getLevel = (priority: number = 1) => { + if (priority >= 3) return '加急'; + if (priority === 2) return '紧急'; + return '一般'; + }; + const getStatusConfig = (column: string) => { - if (column === 'unanalyzed') { - return { label: '待分析', color: isDark ? 'bg-yellow-500/20 text-yellow-400' : 'bg-yellow-100 text-yellow-600' }; - } else if (column === 'running') { - return { label: '运行中', color: isDark ? 'bg-blue-500/20 text-blue-400' : 'bg-red-100 text-red-600' }; - } else { - return { label: '已完成', color: isDark ? 'bg-green-500/20 text-green-400' : 'bg-green-100 text-green-600' }; + switch (column) { + case 'completed': + return { + label: '已完成', + className: isDark + ? 'bg-[rgba(34,197,94,0.16)] text-emerald-300 border border-emerald-400/20' + : 'bg-emerald-50 text-emerald-600 border border-emerald-100', + }; + case 'running': + case 'analyzing': + return { + label: '分析中', + className: isDark + ? 'bg-[rgba(14,165,233,0.16)] text-sky-300 border border-sky-400/20' + : 'bg-sky-50 text-sky-600 border border-sky-100', + }; + default: + return { + label: '待分析', + className: isDark + ? 'bg-[rgba(96,165,250,0.14)] text-blue-200 border border-blue-300/15' + : 'bg-blue-50 text-blue-600 border border-blue-100', + }; } }; - const statusConfig = getStatusConfig(task.column); + + const typeConfig = getTypeConfig(task.type || 'single'); + const statusConfig = getStatusConfig(task.column || 'unanalyzed'); + const TypeIcon = typeConfig.icon; + const progressValue = Math.max(0, Math.min(task.progress || 0, 100)); + return (
      handleTaskClick(task)} + className={`theme-card theme-card-hover theme-transition rounded-[24px] border p-4 ${ + isDark + ? 'border-[rgba(110,136,176,0.2)] shadow-[0_12px_30px_rgba(2,8,20,0.20)]' + : 'border-slate-200 bg-white shadow-sm' + }`} > - {/* 卡片头部:模式标签 + 状态标签 */} -
      - - {LevelIcon && } - {levelConfig.label} - - - +
      +
      + + {typeConfig.label} +
      + {statusConfig.label}
      - {/* 任务名称 */} -

      - {task.taskName} -

      - - {/* 任务编号 */} -

      TASK-{task.id}

      - - {/* 任务摘要 */} -

      {task.overview}

      - - {/* 补充信息区 */} - {/*
      - {task.type === 'single' && task.agent && ( -
      - - 执行智能体: - {task.agent} -
      - )} - {task.type === 'scene' && task.scene && ( -
      - - 关联场景: - {task.scene} -
      - )} - {task.type === 'consultation' && ( - <> -
      - - 参与智能体: - {task.participants?.length || 0}个 -
      - {task.currentRound && ( -
      - - 当前轮次: - 第{task.currentRound}轮 -
      - )} - - )} - {task.column === 'analyzing' && task.currentPhase && ( -
      - - 当前阶段: - {task.currentPhase} -
      - )} -
      */} - - {/* 时间信息 */} -
      -
      - - {task.taskDirection} + + +
      +
      +
      + + {task.owner || task.creator || '未分配'} +
      +
      优先级:{getLevel(task.priority)}
      +
      + +
      +
      + 分析进度 + {progressValue}% +
      +
      +
      +
      +
      + +
      + + 创建于 {task.createTime ? format(new Date(task.createTime), 'MM-dd HH:mm') : '--'} + + + 更新于 {task.updateTime ? format(new Date(task.updateTime), 'MM-dd HH:mm') : '--'} +
      - {format(new Date(task.updateTime), 'yyyy-MM-dd HH:mm:ss')}
      - {/* 操作按钮区 */} -
      - {task.column === 'unanalyzed' && ( - <> - + ) : ( + - {/* */} - + + )} - {task.column === 'running' && ( - <> - - {/* */} - - )} - {task.column === 'completed' && ( - <> - - {/* */} - - )} - {/* */}
      ); -} +}; diff --git a/frontend-web/src/strategy-components/components/task-components/css/strategy-data.css b/frontend-web/src/strategy-components/components/task-components/css/strategy-data.css index 85bca3d6..7ef260bc 100644 --- a/frontend-web/src/strategy-components/components/task-components/css/strategy-data.css +++ b/frontend-web/src/strategy-components/components/task-components/css/strategy-data.css @@ -1,3 +1,5 @@ +@import "../../../../../docs/ui规范-深蓝/tokens.css"; + /** * Strategy task components styles migrated from web-code/index.css * Used by TaskAnalyzes, SentimentAnalyzes, ActionAnalyzes and related children. @@ -20,17 +22,17 @@ /* ── CSS variables (dark) ────────────────────────────────────────────── */ .dark .strategy-data { - --color-main: #0062d9; - --td-brand-color: #ce2d39; - --bg-color: #0062d966; - --card-border: #fa9196; - --text-color: #ffffff; + --color-main: var(--color-primary); + --td-brand-color: var(--color-primary); + --bg-color: rgba(22, 119, 255, 0.12); + --card-border: rgba(110, 136, 176, 0.32); + --text-color: var(--color-text-strong); - --card-bg: 217.2 32.6% 17.5%; - --card-hover: 215 20.2% 25%; - --border-color: 217.2 32.6% 17.5%; - --input-bg: 217.2 32.6% 17.5%; - --input-border: 217.2 32.6% 17.5%; + --card-bg: 220 59% 16%; + --card-hover: 219 55% 22%; + --border-color: 220 54% 29%; + --input-bg: 220 71% 10%; + --input-border: 220 35% 38%; } /* ── Theme card (TaskCard, KanbanColumn) ─────────────────────────────── */ @@ -75,10 +77,51 @@ border-radius: 3px; } +.strategy-data::-webkit-scrollbar, +.strategy-data *::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.strategy-data::-webkit-scrollbar-thumb, +.strategy-data *::-webkit-scrollbar-thumb { + background: rgba(203, 213, 225, 0.75); + border-radius: 999px; +} + +.strategy-data::-webkit-scrollbar-track, +.strategy-data *::-webkit-scrollbar-track { + background: rgba(241, 245, 249, 0.9); + border-radius: 999px; +} + .dark .strategy-data .scrollbar-thin { scrollbar-color: rgba(148, 163, 184, 0.45) transparent; } +.dark .strategy-data, +.dark .strategy-data * { + scrollbar-width: thin; + scrollbar-color: rgba(84, 145, 229, 0.58) rgba(8, 23, 42, 0.24); +} + +.dark .strategy-data::-webkit-scrollbar-thumb, +.dark .strategy-data *::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, rgba(96, 165, 250, 0.78), rgba(45, 212, 191, 0.72)); + border: 1px solid rgba(8, 23, 42, 0.3); +} + +.dark .strategy-data::-webkit-scrollbar-thumb:hover, +.dark .strategy-data *::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, rgba(125, 211, 252, 0.9), rgba(94, 234, 212, 0.82)); +} + +.dark .strategy-data::-webkit-scrollbar-track, +.dark .strategy-data *::-webkit-scrollbar-track { + background: rgba(8, 23, 42, 0.68); + border: 1px solid rgba(110, 136, 176, 0.12); +} + .dark .strategy-data .scrollbar-thin::-webkit-scrollbar-thumb { background: rgba(148, 163, 184, 0.45); } @@ -91,6 +134,158 @@ background: transparent; } +.dark .strategy-data .t-input__wrap, +.dark .strategy-data .t-input-adornment, +.dark .strategy-data .t-date-range-picker__cell { + background: rgba(8, 23, 42, 0.92); + border-color: rgba(110, 136, 176, 0.3); + color: var(--color-text-strong); +} + +.dark .strategy-data .sentiment-date-range, +.dark .strategy-data .sentiment-date-range .t-range-input, +.dark .strategy-data .sentiment-date-range .t-input__wrap, +.dark .strategy-data .sentiment-date-range .t-range-input__inner, +.dark .strategy-data .sentiment-date-range .sentiment-date-range-input { + background: rgba(8, 23, 42, 0.92) !important; + color: #e6f4ff !important; + border-color: rgba(110, 136, 176, 0.3) !important; +} + +.dark .strategy-data .sentiment-date-range .t-range-input { + border-radius: 14px; + box-shadow: inset 0 0 0 1px rgba(110, 136, 176, 0.22); +} + +.dark .strategy-data .sentiment-date-range .t-range-input__inner::placeholder, +.dark .strategy-data .sentiment-date-range .t-input__inner::placeholder { + color: rgba(148, 163, 184, 0.82) !important; +} + +.dark .strategy-data .sentiment-date-range .t-input__suffix, +.dark .strategy-data .sentiment-date-range .t-input-adornment, +.dark .strategy-data .sentiment-date-range .t-range-input__suffix { + color: #8edcff !important; +} + +.dark .strategy-data .t-input__wrap:hover, +.dark .strategy-data .t-date-range-picker__cell:hover { + border-color: rgba(96, 165, 250, 0.45); +} + +.dark .strategy-data .t-input__inner, +.dark .strategy-data .t-input-adornment__text, +.dark .strategy-data .t-date-range-picker__cell-text { + color: var(--color-text-strong); +} + +.dark .strategy-data .t-input__inner::placeholder { + color: rgba(148, 163, 184, 0.82); +} + +.dark .strategy-data .t-pagination .t-button, +.dark .strategy-data .t-pagination .t-pagination__btn { + color: var(--color-text-strong); + background: rgba(10, 30, 54, 0.86); + border-color: rgba(110, 136, 176, 0.24); +} + +.dark .strategy-data .sentiment-pagination, +.dark .strategy-data .sentiment-pagination .t-pagination-mini, +.dark .strategy-data .sentiment-pagination .t-pagination-mini__current, +.dark .strategy-data .sentiment-pagination .t-pagination-mini__current-input, +.dark .strategy-data .sentiment-pagination .t-input, +.dark .strategy-data .sentiment-pagination .t-input__wrap, +.dark .strategy-data .sentiment-pagination .t-input__inner { + background: transparent !important; + color: #e6f4ff !important; +} + +.dark .strategy-data .t-pagination .t-input, +.dark .strategy-data .t-pagination .t-input__wrap, +.dark .strategy-data .t-pagination .t-input-adornment, +.dark .strategy-data .t-pagination .t-pagination__jump-number-input, +.dark .strategy-data .t-pagination .t-pagination__jump-number-input .t-input__wrap, +.dark .strategy-data .t-pagination .t-pagination__jump-number-input .t-input__inner { + background: rgba(10, 30, 54, 0.92) !important; + color: var(--color-text-strong) !important; + border-color: rgba(110, 136, 176, 0.3) !important; + box-shadow: none !important; +} + +.dark .strategy-data .t-pagination .t-pagination__jump-number-input { + border-radius: 10px; +} + +.dark .strategy-data .sentiment-pagination .t-pagination-mini__current-input { + border-bottom: 1px solid rgba(148, 163, 184, 0.75); +} + +.dark .strategy-data .sentiment-pagination .t-pagination-mini__current-input:hover, +.dark .strategy-data .sentiment-pagination .t-pagination-mini__current-input:focus-within { + border-bottom-color: #8edcff; +} + +.dark .strategy-data .sentiment-pagination .t-pagination-mini__current, +.dark .strategy-data .sentiment-pagination .t-pagination-mini__current-input, +.dark .strategy-data .sentiment-pagination .t-pagination__btn-prev, +.dark .strategy-data .sentiment-pagination .t-pagination__btn-next { + color: #e6f4ff !important; +} + +.dark .strategy-data .sentiment-pagination .t-button, +.dark .strategy-data .sentiment-pagination .t-pagination__btn { + background: rgba(10, 30, 54, 0.86) !important; +} + +.dark .strategy-data .t-pagination .t-pagination__jump-number-input .t-input__inner, +.dark .strategy-data .t-pagination .t-input__inner { + color: #e6f4ff !important; + caret-color: #8edcff; +} + +.dark .strategy-data .t-pagination .t-pagination__jump-number-input .t-input__inner::placeholder, +.dark .strategy-data .t-pagination .t-input__inner::placeholder { + color: rgba(148, 163, 184, 0.82) !important; +} + +.dark .strategy-data .t-pagination .t-button:hover, +.dark .strategy-data .t-pagination .t-pagination__btn:hover { + background: rgba(13, 42, 78, 0.95); +} + +.dark .strategy-data .t-pagination .t-pagination__current { + color: #8edcff; +} + +.dark .strategy-data .t-date-range-picker { + min-width: 320px; +} + +.sentiment-date-range-popup .t-date-picker__panel, +.sentiment-date-range-popup .t-date-range-picker__panel, +.sentiment-date-range-popup .t-date-picker__panel-body, +.sentiment-date-range-popup .t-date-picker__panel-content, +.sentiment-date-range-popup .t-date-picker__table, +.sentiment-date-range-popup .t-time-picker__panel { + background: #0d1d33 !important; + color: #e6f4ff !important; +} + +.sentiment-date-range-popup .t-popup__content { + background: #0d1d33 !important; + border: 1px solid rgba(110, 136, 176, 0.24); + box-shadow: 0 24px 60px rgba(2, 8, 20, 0.42); +} + +.sentiment-date-range-popup .t-date-picker__cell, +.sentiment-date-range-popup .t-date-picker__cell-inner, +.sentiment-date-range-popup .t-date-picker__header, +.sentiment-date-range-popup .t-date-picker__text, +.sentiment-date-range-popup .t-button { + color: #e6f4ff !important; +} + /* ── ActionAnalyzes task sidebar ─────────────────────────────────────── */ .strategy-data .task-item-active:hover { border: 1px solid var(--color-main);