diff --git a/frontend-web/docs/embed-iframe-chat.md b/frontend-web/docs/embed-iframe-chat.md index 45ed5de2..5c80a46d 100644 --- a/frontend-web/docs/embed-iframe-chat.md +++ b/frontend-web/docs/embed-iframe-chat.md @@ -107,7 +107,7 @@ http://localhost:5173/#/embed/chats/new?sessionId=...&message=...&username=guest ## 4. 公开 HTTP API(无需鉴权) -前缀:`/api/public/embed/`(走 Gateway,与 `VITE_BACKEND_BASE_URL` 一致,默认 `http://127.0.0.1:8001`)。 +前缀:`/api/public/embed/`(走 Gateway,与 `VITE_BACKEND_BASE_URL` 一致,默认 `http://47.88.25.99:7001/`)。 CSRF / Auth 中间件对 `/api/public/` 放行;请求 **不需要** Cookie。 @@ -315,7 +315,7 @@ document.getElementById("deerflow-embed").src = ### 5.2 轮询直到结束 ```javascript -const API = "http://127.0.0.1:8001"; // VITE_BACKEND_BASE_URL +const API = "http://47.88.25.99:7001/"; // VITE_BACKEND_BASE_URL async function pollUntilFinished(sessionId) { const res = await fetch( @@ -348,7 +348,7 @@ const timer = setInterval(async () => { ### 5.3 跨域注意 -父页面从浏览器 `fetch` Gateway(如 `http://127.0.0.1:8001`)时,**Origin 必须是后端 CORS 白名单里的完整地址**(含协议、主机、端口),与 API 地址是否写 `localhost` / `127.0.0.1` 无关。 +父页面从浏览器 `fetch` Gateway(如 `http://47.88.25.99:7001/`)时,**Origin 必须是后端 CORS 白名单里的完整地址**(含协议、主机、端口),与 API 地址是否写 `localhost` / `127.0.0.1` 无关。 **本地开发**(前端默认 `5174`)请在 Gateway 环境变量中配置: 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/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/package.json b/frontend-web/package.json index bc5cbd2e..e54c5f66 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/App.tsx b/frontend-web/src/App.tsx index 4503b615..a24f9b53 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,15 @@ export function App() { } /> {/* Public read-only conversation viewer — no authentication. */} } /> + {/* Public read-only roundtable share — landing + full result report. */} + } + /> + } + /> {/* Public read-only scheduled-task runs viewer — no authentication. */} { - if (hoverCloseTimerRef.current !== null) { - window.clearTimeout(hoverCloseTimerRef.current); - hoverCloseTimerRef.current = null; - } - setHoveredTopId(null); - }; - const hasActiveChild = (item: MenuItem): boolean => { if (item.path && pathname === item.path) { return true; @@ -83,7 +75,9 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar if (matchedTopItem) { setActiveTopId(matchedTopItem.id); } - closeNavPanel(); + // 不在导航时强制关闭 flyout:flyout 绝对定位在 group 盒子之外,强制关闭后鼠标 + // 仍停在 flyout 区域却收不到 group 的 mouseenter,导致连续点击第二个任务时面板 + // 消失且不回来。开合只交给 hover(mouseleave 有 180ms 宽限),即可连续点选。 }, [pathname, visibleMenuItems]); const toggleExpanded = (id: string) => { @@ -122,14 +116,12 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar if (item.children?.length) { const firstPath = findFirstRoutePath(item.children); if (firstPath) { - closeNavPanel(); navigate(firstPath); } return; } if (item.path) { - closeNavPanel(); navigate(item.path); } }; @@ -141,7 +133,6 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar } if (item.path) { - closeNavPanel(); navigate(item.path); } }; diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index 726c5196..a9d8d156 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -44,23 +44,29 @@ import { useArtifactContent, useUpdateArtifactContent, } from "@/core/artifacts/hooks"; -import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils"; +import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils"; import { useI18n } from "@/core/i18n/hooks"; import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; import { installSkill } from "@/core/skills/api"; import { streamdownPlugins } from "@/core/streamdown"; -import { - checkCodeFile, - getFileName, - isImageFile, -} from "@/core/utils/files"; -import { - createMarkdownAnchorResolver, - injectInPageAnchorHandler, - isInPageAnchor, - scrollToInPageAnchor, -} from "@/core/utils/markdown"; -import { getBackendBaseURL } from "@/core/config"; +import { + checkCodeFile, + getFileName, + isImageFile, +} from "@/core/utils/files"; +import { + downloadMermaidSvgFile, + isMermaidFile, + openMermaidSvgInNewTab, + renderMermaidToSvg, +} from "@/core/utils/mermaid"; +import { + createMarkdownAnchorResolver, + injectInPageAnchorHandler, + isInPageAnchor, + scrollToInPageAnchor, +} from "@/core/utils/markdown"; +import { getBackendBaseURL } from "@/core/config"; import { env } from "@/env"; import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; @@ -143,7 +149,11 @@ export function ArtifactFileDetail({ return checkCodeFile(filepath); }, [filepath, useWriteFileStreaming, isSkillFile]); const isSupportPreview = useMemo(() => { - return language === "html" || language === "markdown"; + return ( + language === "html" || + language === "markdown" || + language === "mermaid" + ); }, [language]); const { content, isApplyingEdit } = useArtifactContent({ threadId, @@ -164,6 +174,7 @@ export function ArtifactFileDetail({ const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1; const fileTypeLabel = useMemo(() => { if (isImageFile(filepath)) return "IMG"; + if (isMermaidFile(filepath) || language === "mermaid") return "MMD"; if (language === "html") return "HTML"; if (language === "markdown") return "MD"; if (isSkillFile) return "SKILL"; @@ -435,6 +446,13 @@ export function ArtifactFileDetail({ toast.error("文件内容为空,暂时无法打开"); return; } + if (language === "mermaid") { + void openMermaidSvgInNewTab(source).catch((error) => { + console.error("Failed to open mermaid artifact:", error); + toast.error(error instanceof Error ? error.message : "Failed to open artifact"); + }); + return; + } const blobType = language === "html" ? "text/html;charset=utf-8" @@ -453,6 +471,13 @@ export function ArtifactFileDetail({ toast.error("文件内容为空,暂时无法下载"); return; } + if (language === "mermaid") { + void downloadMermaidSvgFile(source, filepath).catch((error) => { + console.error("Failed to download mermaid artifact:", error); + toast.error(error instanceof Error ? error.message : "Download failed"); + }); + return; + } const blobType = language === "html" ? "text/html;charset=utf-8" @@ -579,6 +604,19 @@ export function ArtifactFileDetail({ handleOpenWriteFileInNewTab(); return; } + if (language === "mermaid") { + try { + await openMermaidSvgInNewTab(displayContent ?? ""); + } catch (error) { + console.error(error); + toast.error( + error instanceof Error + ? error.message + : "Failed to open artifact", + ); + } + return; + } try { await openArtifactInNewTab({ filepath, @@ -631,6 +669,19 @@ export function ArtifactFileDetail({ handleDownloadWriteFile(); return; } + if (language === "mermaid") { + try { + await downloadMermaidSvgFile(displayContent ?? "", filepath); + } catch (error) { + console.error(error); + toast.error( + error instanceof Error + ? error.message + : "Download failed", + ); + } + return; + } try { await downloadArtifactFile({ filepath, @@ -681,7 +732,9 @@ export function ArtifactFileDetail({ )} {isSupportPreview && viewMode === "preview" && - (language === "markdown" || language === "html") && ( + (language === "markdown" || + language === "html" || + language === "mermaid") && ( (); + const [mermaidSvg, setMermaidSvg] = useState(null); + const [mermaidError, setMermaidError] = useState(null); const markdownComponents = useMemo(() => { const anchorResolver = createMarkdownAnchorResolver(content ?? ""); const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => { @@ -863,6 +918,37 @@ export function ArtifactFilePreview({ }; }, [content, language]); + useEffect(() => { + if (language !== "mermaid") { + setMermaidSvg(null); + setMermaidError(null); + return; + } + + let cancelled = false; + setMermaidSvg(null); + setMermaidError(null); + + void renderMermaidToSvg(content ?? "") + .then((svg) => { + if (!cancelled) { + setMermaidSvg(svg); + } + }) + .catch((error) => { + console.error("Failed to render mermaid preview:", error); + if (!cancelled) { + setMermaidError( + error instanceof Error ? error.message : "Mermaid 渲染失败", + ); + } + }); + + return () => { + cancelled = true; + }; + }, [content, language]); + if (language === "markdown") { return (
- ( - - ), - }} - > + ( + + ), + }} + > {content ?? ""} {activeCursor && ( @@ -910,5 +996,38 @@ export function ArtifactFilePreview({ ); } + if (language === "mermaid") { + return ( +
+ {mermaidSvg ? ( +
+ ) : mermaidError ? ( +
+
+ {mermaidError} +
+
+              {content ?? ""}
+            
+
+ ) : ( +
+ + 正在生成图表预览 +
+ )} +
+ ); + } + return null; } diff --git a/frontend-web/src/components/workspace/recent-roundtable-list.tsx b/frontend-web/src/components/workspace/recent-roundtable-list.tsx new file mode 100644 index 00000000..c1af707f --- /dev/null +++ b/frontend-web/src/components/workspace/recent-roundtable-list.tsx @@ -0,0 +1,266 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { ChevronDown, Loader2, MoreHorizontal, Pencil, Share2, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuItem, + SidebarMenuButton, +} from "@/components/ui/sidebar"; +import { ShareRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs"; +import { formatRelative } from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown"; +import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts"; +import type { DraftMeta } from "@/roundtable-planning/api/drafts"; +import { isIMEComposing } from "@/lib/ime"; + +const ROUNDTABLE_PATH = "/page/workspace/roundtable/planning"; + +function openDraft( + draft: DraftMeta, + isOnPage: boolean, + router: ReturnType, +) { + 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/contexts/ThemeContext.tsx b/frontend-web/src/contexts/ThemeContext.tsx index 7a5dcc9c..5d372e9b 100644 --- a/frontend-web/src/contexts/ThemeContext.tsx +++ b/frontend-web/src/contexts/ThemeContext.tsx @@ -184,6 +184,21 @@ export const ThemeProvider: React.FC = ({ children }) => { : 'light' : readPersistedTheme(); + useEffect(() => { + if (typeof document === 'undefined') return; + + const root = document.documentElement; + + if (theme === 'dark') { + root.setAttribute('theme-mode', 'dark'); + root.classList.add('dark'); + return; + } + + root.removeAttribute('theme-mode'); + root.classList.remove('dark'); + }, [theme]); + const toggleTheme = () => { setNextTheme(theme === 'dark' ? 'light' : 'dark'); markExplicitChoice(); diff --git a/frontend-web/src/core/auth/yuser-login.ts b/frontend-web/src/core/auth/yuser-login.ts index a163db71..27e2419c 100644 --- a/frontend-web/src/core/auth/yuser-login.ts +++ b/frontend-web/src/core/auth/yuser-login.ts @@ -5,7 +5,7 @@ const CONSUMER_URL = import.meta.env.VITE_AIPI_CONSUMER_URL || ""; export const TOOLS_TOKEN_STORAGE_KEY = "tools_token"; export const EXTERNAL_USER_ID_STORAGE_KEY = "userId"; export const EXTERNAL_USER_INFO_STORAGE_KEY = "userInfo"; -export const GENERAL_QA_ROUTE = "/page/strategy/qa/general"; +export const GENERAL_QA_ROUTE = "/page/workspace/chats/new"; type UserDetailResponse = { data?: { diff --git a/frontend-web/src/core/i18n/locales/zh-CN.ts b/frontend-web/src/core/i18n/locales/zh-CN.ts index 692e1f61..50ecf6fe 100644 --- a/frontend-web/src/core/i18n/locales/zh-CN.ts +++ b/frontend-web/src/core/i18n/locales/zh-CN.ts @@ -1,4 +1,4 @@ -import { +import { CompassIcon, GraduationCapIcon, ImageIcon, diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index 48cd5afc..1a52e92f 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -55,15 +55,13 @@ export const sidebarMenuItems: MenuItem[] = [ icon: HelpCircle, children: [ { id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" }, - { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" }, - // 「圆桌规划」入口暂时隐藏 —— 功能仍在 /page/roundtable/planning,只是不在侧边栏出现。 - // 需要重新开放时取消注释即可。 - // { - // id: "qa-roundtable", - // label: "圆桌规划", - // icon: Bot, - // path: "/page/roundtable/planning", - // }, + { + id: "multi-agent-roundtable", + label: "多智能体会商", + icon: Users, + path: "/page/strategy/qa/multi-agent-roundtable", + }, + // { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" }, { id: "qa-zzjc", label: "zzjc支持", icon: Bot }, { id: "qa-zzxd", label: "zzXD应用", icon: Layout }, { id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" }, 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..544ef588 --- /dev/null +++ b/frontend-web/src/core/roundtable-draft-shares/api.ts @@ -0,0 +1,141 @@ +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; +} + +/** One sub-agent's final delivery from the Step 2 roundtable. */ +export interface PublicDraftSeatDelivery { + sender: string; + content: string; +} + +/** Read-only roundtable-result snapshot returned by the public view-share endpoint. */ +export interface PublicDraftShareSnapshot { + share_code: string; + title: string; + mode: string; + /** 总控最终结论 (markdown). */ + consensus_text: string; + /** 各席位最终交付. */ + seat_deliveries: PublicDraftSeatDelivery[]; + /** 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/core/utils/files.tsx b/frontend-web/src/core/utils/files.tsx index 76939b09..784d87c8 100644 --- a/frontend-web/src/core/utils/files.tsx +++ b/frontend-web/src/core/utils/files.tsx @@ -94,10 +94,11 @@ const extensionMap: Record = { ini: "ini", env: "dotenv", - // Markdown & Docs - md: "markdown", - mdx: "mdx", - rst: "rst", + // Markdown & Docs + md: "markdown", + mdx: "mdx", + mmd: "mermaid", + rst: "rst", // SQL sql: "sql", diff --git a/frontend-web/src/core/utils/mermaid.ts b/frontend-web/src/core/utils/mermaid.ts new file mode 100644 index 00000000..01fa3e5f --- /dev/null +++ b/frontend-web/src/core/utils/mermaid.ts @@ -0,0 +1,75 @@ +import { getFileName } from "./files"; + +let mermaidInitialized = false; + +export function isMermaidFile(filepath: string): boolean { + return /\.mmd$/i.test(filepath); +} + +export function getMermaidImageFileName(filepath: string, extension = "svg"): string { + return getFileName(filepath).replace(/\.mmd$/i, `.${extension}`); +} + +async function getMermaidModule() { + const mod = await import("mermaid"); + const mermaid = mod.default; + if (!mermaidInitialized) { + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + }); + mermaidInitialized = true; + } + return mermaid; +} + +export async function renderMermaidToSvg(source: string): Promise { + const text = source.trim(); + if (!text) { + throw new Error("Mermaid 内容为空,无法生成图片"); + } + + const mermaid = await getMermaidModule(); + const renderId = `mermaid-preview-${Math.random().toString(36).slice(2, 10)}`; + const host = document.createElement("div"); + host.style.position = "fixed"; + host.style.left = "-10000px"; + host.style.top = "-10000px"; + host.style.pointerEvents = "none"; + host.style.opacity = "0"; + document.body.appendChild(host); + + try { + const { svg, bindFunctions } = await mermaid.render(renderId, text); + host.innerHTML = svg; + bindFunctions?.(host); + return host.innerHTML; + } finally { + host.remove(); + } +} + +export async function openMermaidSvgInNewTab(source: string): Promise { + const svg = await renderMermaidToSvg(source); + const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" }); + const objectUrl = URL.createObjectURL(blob); + const win = window.open(objectUrl, "_blank", "noopener,noreferrer"); + if (win) { + win.opener = null; + } + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 120_000); +} + +export async function downloadMermaidSvgFile(source: string, filepath: string): Promise { + const svg = await renderMermaidToSvg(source); + const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" }); + const objectUrl = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = objectUrl; + a.download = getMermaidImageFileName(filepath); + a.rel = "noopener"; + document.body.appendChild(a); + a.click(); + a.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000); +} 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/pages/NewAgentPage.tsx b/frontend-web/src/pages/NewAgentPage.tsx index 0cf9fe3c..a99e8407 100644 --- a/frontend-web/src/pages/NewAgentPage.tsx +++ b/frontend-web/src/pages/NewAgentPage.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import { ArrowLeftIcon, diff --git a/frontend-web/src/pages/PublicRoundtableSharePage.tsx b/frontend-web/src/pages/PublicRoundtableSharePage.tsx new file mode 100644 index 00000000..9c280c62 --- /dev/null +++ b/frontend-web/src/pages/PublicRoundtableSharePage.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { + BarChart3, + CheckCircle, + ChevronDown, + ChevronRight, + ExternalLink, + FileText, + Loader2, +} from "lucide-react"; +import { useState } from "react"; +import ReactMarkdown from "react-markdown"; +import { useParams } from "react-router-dom"; +import remarkGfm from "remark-gfm"; + +import { + getPublicDraftShareSnapshot, + type PublicDraftSeatDelivery, +} from "@/core/roundtable-draft-shares/api"; +import { markdownComponents } from "@/roundtable-planning/components/markdown-components"; + +/** 各席位最终交付 —— 折叠手风琴(仿 HighFidelityReport 第二区块)。 */ +function SeatDeliveries({ seats }: { seats: PublicDraftSeatDelivery[] }) { + const [expanded, setExpanded] = useState>(() => new Set()); + const toggle = (i: number) => + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(i)) next.delete(i); + else next.add(i); + return next; + }); + + if (seats.length === 0) { + return ( +

本次会商暂无子智能体交付。

+ ); + } + return ( +
+ {seats.map((seat, idx) => { + const open = expanded.has(idx); + return ( +
+ + {open && ( +
+ + {seat.content || "(空)"} + +
+ )} +
+ ); + })} +
+ ); +} + +/** + * Public, read-only roundtable-result viewer. + * + * Reachable at `#/roundtable-share/` with NO authentication — anyone with + * the link can open it. Two views, driven by the `report` prop / route: + * + * - landing (`#/roundtable-share/`): a read-only result page mirroring the + * in-app Step 3「最终方案展示」— 总控最终结论 + 各席位最终交付 + a 「查看结果绘制」 + * button that opens the full report in a NEW TAB (when one was generated). + * - report (`#/roundtable-share//report`): the Step 3 report (a + * self-contained HTML document) rendered full-screen inside a sandboxed + * `iframe` (no `allow-scripts`), so the agent-generated HTML can never execute + * code in the app origin. + */ +export default function PublicRoundtableSharePage({ + report = false, +}: { + report?: boolean; +}) { + 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 ( +
+

分享不可用

+

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

+
+ ); + } + + // ── 报告页:整页沙箱 iframe 展示第 3 步结果绘制 ────────────────────────────── + if (report) { + 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); 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 { 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/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index ff5eca68..f6bfc4c9 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -245,6 +245,8 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA | **Thread Shares** (`/api/thread-shares`) | Per-conversation sharing with two modes (`thread_shares.mode`). **`import`**: `POST /` `{thread_id, mode}` - create (or reuse) a revocable share code; `GET /{code}/preview` + `POST /{code}/import` - copy that conversation into the caller's account (fresh `thread_id` via `app/gateway/thread_copy.py` + new `threads_meta` row + `user-data` copy; dedup on `metadata.shared_origin`). **`view`**: a public, read-only static page — see Public Shares below. Both modes: `GET /` - list my codes; `DELETE /{code}` - revoke. | | **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves a file referenced by that conversation (images, Markdown, text, PDF, …) so the shared page's links are openable; path resolution is confined to the owner's thread directory. Active content (`text/html`, `application/xhtml+xml`, `image/svg+xml`) is forced to download (`_public_share_disposition`); everything else is served inline. Missing/revoked/non-`view` codes all return 404. | | **Roundtable Drafts** (`/api/roundtable-drafts`) | Per-user persistence for the roundtable-planning page (replaces the old browser `localStorage`). `GET /` - list drafts (lightweight meta); `POST /` - create; `GET/PUT/DELETE /{id}` - full read / partial update / delete. A draft = one full session (Step 1 intent + Step 2 roundtable), step snapshots stored as JSON in `PortableLongText`. Recommendation history bound to a draft: `GET /{id}/recommendations` - list newest-first; `POST /{id}/recommendations` - append one run (objective/status/rationale/picks/candidates). Backed by `deerflow.persistence.roundtable_drafts` (tables `roundtable_drafts`, `roundtable_recommend_history`); store wired in `deps.py` as `app.state.roundtable_draft_store`. | +| **Roundtable Draft Shares** (`/api/roundtable-draft-shares`) | Per-draft sharing for the roundtable-planning page, mirroring Thread Shares but the shared resource is a `roundtable_drafts` row. Two modes (`roundtable_draft_shares.mode`). **`import`**: `POST /` `{draft_id, mode}` - create (or reuse) a revocable code; `GET /{code}/preview` + `POST /{code}/import` - copy that draft (step1/2/3) into the caller's account under a deterministic per-(code,recipient) id (`imp_` → re-import is a no-op). **`view`**: a public, read-only result page — see Public Roundtable Shares. Both: `GET /` - list my codes; `DELETE /{code}` - revoke. Backed by `deerflow.persistence.roundtable_draft_shares` (table `roundtable_draft_shares`, migration `20260606_01`); store wired in `deps.py` as `app.state.roundtable_draft_share_store`. Tests: `tests/test_roundtable_draft_shares.py`. | +| **Public Roundtable Shares** (`/api/public/roundtable-shares`) | **Unauthenticated** (`/api/public/` whitelisted). `GET /{code}` - read-only snapshot for a `view`-mode draft share: returns the Step 3「结果绘制」report HTML (`step3.html`, a self-contained document) + title/summary/`has_report`. The frontend renders it in a sandboxed `iframe` (no `allow-scripts`) at `#/roundtable-share/`. Missing/revoked/non-`view` codes all return 404. | | **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. | | **HTML Page Favorites** (`/api/html-page-favorites`) | Bookmarked generated HTML pages, owner-scoped. `GET /` - list; `POST /` `{page_id, title?, description?, tags?}` - bookmark a `scheduled_html_pages` row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (`DuplicateFavoriteError` → HTTP 409). `GET/DELETE /{id}` - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new `html_page` task. | | **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). | diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index 47484a91..84353025 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -46,6 +46,7 @@ from app.gateway.routers import ( recommend, recommended_questions, roundtable_chains, + roundtable_draft_shares, roundtable_drafts, roundtable_jobs, runs, @@ -933,6 +934,11 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an # Public, unauthenticated read-only share snapshots (/api/public/shares/*). app.include_router(thread_shares.public_router) + # 圆桌会商草稿分享(import 复制副本 / view 只读结果报告页) + app.include_router(roundtable_draft_shares.router) + # Public, unauthenticated read-only roundtable result (/api/public/roundtable-shares/*). + app.include_router(roundtable_draft_shares.public_router) + # Stateless Runs API (stream/wait without a pre-existing thread) app.include_router(runs.router) diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py index 8ed95b28..e440e95d 100644 --- a/offline-backend-20260512/backend/app/gateway/deps.py +++ b/offline-backend-20260512/backend/app/gateway/deps.py @@ -171,6 +171,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: app.state.roundtable_draft_store = make_roundtable_draft_store(sf) + from deerflow.persistence.roundtable_draft_shares import make_roundtable_draft_share_store + + app.state.roundtable_draft_share_store = make_roundtable_draft_share_store(sf) + from deerflow.persistence.roundtable_chains import make_roundtable_chain_store app.state.roundtable_chain_store = make_roundtable_chain_store(sf) @@ -334,6 +338,14 @@ def get_thread_share_store(request: Request): return val +def get_roundtable_draft_share_store(request: Request): + """Return the roundtable-draft share-link store.""" + val = getattr(request.app.state, "roundtable_draft_share_store", None) + if val is None: + raise HTTPException(status_code=503, detail="Roundtable draft share store not available") + return val + + def get_embed_session_store(request: Request): """Return the iframe embed session → thread mapping store.""" val = getattr(request.app.state, "embed_session_store", None) 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..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, *, @@ -149,12 +197,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 @@ -202,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 @@ -235,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 717ddcb2..c097d897 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 @@ -49,6 +159,11 @@ class StartParams: coordinator_name: str = "roundtable-coordinator" # 关联草稿:作业完成后把 step3 报告写回该草稿,使「查看报告」可用。 draft_id: str | None = None + # 是否为「续跑」(resume 澄清)。首启=False → 真网关建全新线程(不复用前端线程, + # 避免与前端本地研讨的活跃 run 撞 409);续跑=True → 复用作业自己的线程。 + is_resume: bool = False + # chain 模式来源链条(写进草稿 run 用,展示)。 + chain: dict[str, Any] | None = None # 网关工厂:给定一次启动参数,返回一个 RoundtableGateway 实例。 @@ -79,9 +194,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( @@ -90,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, @@ -102,23 +230,21 @@ 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 - ): + # 终态:把最终对话 + 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._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) except asyncio.CancelledError: # 干净停止即可,**不在被取消的任务里写 DB**:取消态下二次 await 会再抛 # CancelledError、并可能打断进行中的 sqlite 连接。``cancelled`` 终态由 @@ -130,6 +256,78 @@ 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 + # 本次有效 step3:新报告优先,否则保留上次(running 中间态 step3=None 不抹掉已存的)。 + effective_step3 = step3 if step3 is not None else prev_step3 + 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": effective_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 + # 顶层 draft.step3 也写一份(同 Step2 把对话写进 run 的同一次落库一并带上)。 + # 前端 hydrateStep3 用**顶层** draft.step3 重建报告气泡(结果绘制的聊天记录 + HTML 预览); + # 只存 run 内 step3 时,加载会被 hydrateStep3(draft.step3=None) 覆盖成空 → 报告聊天记录丢失。 + update_kwargs: dict[str, Any] = {"step2": step2, "furthest_step": 3 if status == "done" else 2} + if effective_step3 is not None: + update_kwargs["step3"] = effective_step3 + try: + await self._draft_store.update_draft(params.draft_id, params.user_id, **update_kwargs) + 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_draft_shares.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_draft_shares.py new file mode 100644 index 00000000..e2a7b38b --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_draft_shares.py @@ -0,0 +1,330 @@ +"""Roundtable-draft sharing API. + +A user creates a share code for *one* of their roundtable-planning drafts (a full +会商 session). Two modes: + +- ``import`` — redeeming the code copies the draft into the redeemer's account as + an independent copy they fully own (fresh draft id, deduped per recipient). +- ``view`` — a public, read-only page that renders the draft's Step 3 result + report (the self-contained 方案总览 HTML). Anyone with the link can open it + without logging in. + +Mirrors ``app/gateway/routers/thread_shares.py`` but the shared resource is a +``roundtable_drafts`` row (read via ``app.state.roundtable_draft_store``) rather +than a conversation thread. +""" + +from __future__ import annotations + +import hashlib +import logging + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, Field + +from app.gateway.deps import get_roundtable_draft_share_store +from deerflow.persistence.roundtable_draft_shares.base import RoundtableDraftShareStore +from deerflow.runtime.user_context import get_effective_user_id + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/roundtable-draft-shares", tags=["roundtable-draft-shares"]) + +# Public, unauthenticated router. Its prefix is whitelisted in +# ``auth_middleware._PUBLIC_PATH_PREFIXES`` (``/api/public/``) — only read-only, +# intentionally world-readable endpoints belong here. +public_router = APIRouter(prefix="/api/public/roundtable-shares", tags=["roundtable-draft-shares-public"]) + +_UNTITLED = "未命名会商" +_SHARE_MODES = ("import", "view") + + +def _current_user_id(request: Request) -> str: + user = getattr(request.state, "user", None) + if user is not None: + return str(user.id) + return get_effective_user_id() + + +def _get_draft_store(request: Request): + store = getattr(request.app.state, "roundtable_draft_store", None) + if store is None: + raise HTTPException(status_code=503, detail="Roundtable draft store not available") + return store + + +# --------------------------------------------------------------------------- +# Request / response models +# --------------------------------------------------------------------------- + + +class CreateShareRequest(BaseModel): + draft_id: str = Field(..., description="The roundtable draft to share") + mode: str = Field( + default="import", + description='"import" — recipient copies the draft; "view" — public read-only result page', + ) + + +class ShareResponse(BaseModel): + share_code: str = Field(..., description="Short code that recipients enter to import") + owner_user_id: str = Field(..., description="User who created the share") + draft_id: str = Field(..., description="The shared draft") + revoked: bool = Field(default=False, description="Whether the code has been revoked") + mode: str = Field(default="import", description='"import" or "view"') + created_at: str | None = Field(default=None, description="Creation timestamp (ISO-8601)") + + +class SharesListResponse(BaseModel): + shares: list[ShareResponse] + + +class SharePreviewResponse(BaseModel): + share_code: str + shared_by: str = Field(..., description="Owner user id of the shared draft") + title: str = Field(..., description="Title of the shared draft") + + +class ImportResultResponse(BaseModel): + imported: int = Field(..., description="1 when the draft was copied, else 0") + skipped: int = Field(..., description="1 when it was already imported, else 0") + draft_id: str | None = Field(default=None, description="The new local draft id (when imported)") + + +class SeatDeliveryItem(BaseModel): + """One sub-agent's final delivery from the Step 2 roundtable.""" + + sender: str = "" + content: str = "" + + +class PublicDraftShareSnapshotResponse(BaseModel): + """Read-only roundtable-result snapshot served to anonymous viewers. + + Mirrors what the in-app Step 3「最终方案展示」(`HighFidelityReport`) shows, minus + the live per-seat output files (those need authenticated thread access): + 总控最终结论 + 各席位最终交付 + the generated 结果绘制 report. + """ + + share_code: str + title: str = Field(..., description="Draft title") + mode: str = Field(default="view") + # 总控最终结论 —— the coordinator's final consensus text (Step 2 lastLeaderContent). + consensus_text: str = Field(default="", description="Coordinator's final consensus (markdown)") + # 各席位最终交付 —— each sub-agent's final delivery, filtered from Step 2 dialogues. + seat_deliveries: list[SeatDeliveryItem] = Field(default_factory=list) + # The Step 3「结果绘制」report — a self-contained HTML document. Empty when the + # shared draft never reached / generated a result report. + html: str = Field(default="", description="Self-contained Step 3 result report HTML") + summary: str | None = Field(default=None, description="The report's closing one-liner") + generated_at: str | None = Field(default=None, description="When the report was generated (ISO-8601)") + has_report: bool = Field(default=False, description="Whether a Step 3 report exists") + created_at: str | None = Field(default=None, description="When the share was created (ISO-8601)") + + +# --------------------------------------------------------------------------- +# Owner side — create / list / revoke +# --------------------------------------------------------------------------- + + +@router.post("", response_model=ShareResponse, summary="Create a share code for one roundtable draft") +async def create_share( + body: CreateShareRequest, + request: Request, + share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store), +) -> ShareResponse: + me = _current_user_id(request) + mode = body.mode if body.mode in _SHARE_MODES else "import" + # Only the draft's owner may share it. + draft = await _get_draft_store(request).get_draft(body.draft_id, me) + if draft is None: + raise HTTPException(status_code=404, detail="Draft not found") + # Reuse an existing active code (of the same mode) so re-sharing is idempotent. + existing = await share_store.get_active_for_draft(me, body.draft_id, mode=mode) + if existing is not None: + return ShareResponse(**existing) + record = await share_store.create(me, body.draft_id, mode=mode) + return ShareResponse(**record) + + +@router.get("", response_model=SharesListResponse, summary="List my roundtable-draft share codes") +async def list_shares( + request: Request, + share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store), +) -> SharesListResponse: + records = await share_store.list_for_owner(_current_user_id(request)) + return SharesListResponse(shares=[ShareResponse(**r) for r in records]) + + +@router.delete("/{share_code}", summary="Revoke a roundtable-draft share code") +async def revoke_share( + share_code: str, + request: Request, + share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store), +) -> dict[str, bool]: + ok = await share_store.revoke(share_code, _current_user_id(request)) + if not ok: + raise HTTPException(status_code=404, detail="Share code not found") + return {"success": True} + + +# --------------------------------------------------------------------------- +# Recipient side — preview / import +# --------------------------------------------------------------------------- + + +async def _resolve_active_share(share_code: str, share_store: RoundtableDraftShareStore) -> dict: + share = await share_store.get(share_code) + if share is None: + raise HTTPException(status_code=404, detail="Share code not found") + if share.get("revoked"): + raise HTTPException(status_code=410, detail="This share code has been revoked") + return share + + +@router.get("/{share_code}/preview", response_model=SharePreviewResponse, summary="Preview a shared roundtable draft") +async def preview_share( + share_code: str, + request: Request, + share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store), +) -> SharePreviewResponse: + share = await _resolve_active_share(share_code, share_store) + owner = share["owner_user_id"] + draft = await _get_draft_store(request).get_draft(share["draft_id"], owner) + if draft is None: + raise HTTPException(status_code=404, detail="The shared draft no longer exists") + return SharePreviewResponse( + share_code=share_code, + shared_by=owner, + title=draft.get("title") or _UNTITLED, + ) + + +def _imported_draft_id(share_code: str, recipient: str) -> str: + """Deterministic per-(code, recipient) draft id so re-import is idempotent. + + ``roundtable_drafts.id`` is a global PK, so the id must be unique across + users — hence the recipient is folded into the hash rather than reusing a + code-only id (which would collide when two people import the same code). + """ + digest = hashlib.sha1(f"{share_code}:{recipient}".encode()).hexdigest() + return f"imp_{digest[:40]}" + + +@router.post("/{share_code}/import", response_model=ImportResultResponse, summary="Import a shared roundtable draft") +async def import_share( + share_code: str, + request: Request, + share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store), +) -> ImportResultResponse: + share = await _resolve_active_share(share_code, share_store) + if share.get("mode") == "view": + raise HTTPException(status_code=400, detail="This is a view-only share — open its link to read it instead of importing") + owner = share["owner_user_id"] + origin_id = share["draft_id"] + recipient = _current_user_id(request) + if owner == recipient: + raise HTTPException(status_code=400, detail="You cannot import your own share code") + + draft_store = _get_draft_store(request) + src = await draft_store.get_draft(origin_id, owner) + if src is None: + raise HTTPException(status_code=404, detail="The shared draft no longer exists") + + # Dedup: a deterministic id per (code, recipient) makes re-import a no-op. + new_id = _imported_draft_id(share_code, recipient) + if await draft_store.get_draft(new_id, recipient) is not None: + return ImportResultResponse(imported=0, skipped=1, draft_id=new_id) + + await draft_store.create_draft( + recipient, + { + "id": new_id, + "title": src.get("title") or _UNTITLED, + "furthest_step": src.get("furthest_step") or 1, + "step1": src.get("step1"), + "step2": src.get("step2"), + "step3": src.get("step3"), + }, + ) + logger.info("Roundtable share %s imported by %s as draft %s", share_code, recipient, new_id) + return ImportResultResponse(imported=1, skipped=0, draft_id=new_id) + + +# --------------------------------------------------------------------------- +# Public read-only view — no authentication required +# --------------------------------------------------------------------------- + + +def _result_sections(draft: dict) -> tuple[str, list[dict[str, str]]]: + """Derive (总控最终结论, 各席位最终交付) from a draft's Step 2 snapshot. + + Mirrors the frontend `Step3Panel`: the consensus is `step2.lastLeaderContent` + and each seat delivery is a Step 2 dialogue bubble tagged + ``confidence == "子智能体交付"``. Falls back to the active run's snapshot when + the top-level Step 2 fields are empty (multi-run drafts). + """ + step2 = draft.get("step2") if isinstance(draft.get("step2"), dict) else {} + step2 = step2 or {} + consensus = step2.get("lastLeaderContent") or "" + dialogues = step2.get("step2RoundtableDialogues") + if not isinstance(dialogues, list): + dialogues = [] + + # Multi-run drafts keep the freshest content on the active run; use it when + # the top-level snapshot carries nothing. + if not consensus and not dialogues and isinstance(step2.get("runs"), list): + runs = [r for r in step2["runs"] if isinstance(r, dict)] + active_id = step2.get("activeRunId") + active = next((r for r in runs if r.get("id") == active_id), None) or (runs[0] if runs else None) + if active: + consensus = active.get("lastLeaderContent") or "" + d = active.get("step2RoundtableDialogues") + dialogues = d if isinstance(d, list) else [] + + seat_deliveries = [ + {"sender": str(d.get("sender") or ""), "content": str(d.get("content") or "")} + for d in dialogues + if isinstance(d, dict) and d.get("confidence") == "子智能体交付" + ] + return consensus, seat_deliveries + + +@public_router.get( + "/{share_code}", + response_model=PublicDraftShareSnapshotResponse, + summary="Public read-only snapshot of a view-shared roundtable draft", +) +async def get_public_draft_share_snapshot( + share_code: str, + request: Request, + share_store: RoundtableDraftShareStore = Depends(get_roundtable_draft_share_store), +) -> PublicDraftShareSnapshotResponse: + """Serve a frozen, read-only roundtable result. Anyone with the link can read it. + + Only ``mode="view"`` shares are exposed; missing, revoked, or import-mode + codes all return 404 so the endpoint never reveals which case applies. + """ + share = await share_store.get(share_code) + if share is None or share.get("revoked") or share.get("mode") != "view": + raise HTTPException(status_code=404, detail="Share not found") + + draft = await _get_draft_store(request).get_draft(share["draft_id"], share["owner_user_id"]) + if draft is None: + raise HTTPException(status_code=404, detail="The shared draft no longer exists") + + step3 = draft.get("step3") if isinstance(draft.get("step3"), dict) else None + html = (step3 or {}).get("html") or "" + consensus_text, seat_deliveries = _result_sections(draft) + return PublicDraftShareSnapshotResponse( + share_code=share_code, + title=draft.get("title") or _UNTITLED, + mode="view", + consensus_text=consensus_text, + seat_deliveries=[SeatDeliveryItem(**s) for s in seat_deliveries], + html=html, + summary=(step3 or {}).get("summary"), + generated_at=(step3 or {}).get("generatedAt"), + has_report=bool(html), + created_at=share.get("created_at"), + ) 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..6a6e0af4 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,8 @@ 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) + step3: dict[str, Any] | None = None createdAt: datetime | str | None = None updatedAt: datetime | str | None = None @@ -147,6 +149,8 @@ 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 [], + step3=row.get("step3"), createdAt=row.get("created_at"), updatedAt=row.get("updated_at"), ) @@ -199,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) @@ -282,6 +287,8 @@ 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, + chain=row.get("chain"), ) ) return _to_response(updated or row) diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 4a91badf..621f301f 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/middlewares/uploads_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py index 3358984c..e0be6761 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/uploads_middleware.py @@ -1,8 +1,10 @@ """Middleware to inject uploaded files information into agent context.""" -import logging -from pathlib import Path -from typing import NotRequired, override +import logging +import shutil +from pathlib import Path +from typing import NotRequired, override +from urllib.parse import unquote from langchain.agents import AgentState from langchain.agents.middleware import AgentMiddleware @@ -13,7 +15,7 @@ from deerflow.config.paths import Paths, get_paths from deerflow.runtime.user_context import get_effective_user_id from deerflow.utils.file_conversion import extract_outline -logger = logging.getLogger(__name__) +logger = logging.getLogger(__name__) _OUTLINE_PREVIEW_LINES = 5 @@ -22,7 +24,8 @@ _OUTLINE_PREVIEW_LINES = 5 # configured vision model), never by reading their bytes. Listing them with the # generic read_file/grep guidance made the agent try to parse image binaries # with bash/python, which fails and wastes turns. -_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"} +_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"} +_ARTIFACT_URL_MARKER = "/artifacts/" def _extract_outline_for_file(file_path: Path) -> tuple[list[dict], list[str]]: @@ -69,7 +72,7 @@ class UploadsMiddlewareState(AgentState): uploaded_files: NotRequired[list[dict] | None] -class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]): +class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]): """Middleware to inject uploaded files information into the agent context. Reads file metadata from the current message's additional_kwargs.files @@ -122,7 +125,7 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]): lines.append(" Use `grep` to search for keywords (e.g. `grep(pattern='keyword', path='/mnt/user-data/uploads/')`).") lines.append("") - def _create_files_message(self, new_files: list[dict], historical_files: list[dict]) -> str: + def _create_files_message(self, new_files: list[dict], historical_files: list[dict]) -> str: """Create a formatted message listing uploaded files. Args: @@ -160,9 +163,61 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]): lines.append("- Only fall back to web search if the file content is clearly insufficient to answer the question.") lines.append("") - return "\n".join(lines) - - def _files_from_kwargs(self, message: HumanMessage, uploads_dir: Path | None = None) -> list[dict] | None: + return "\n".join(lines) + + def _import_file_from_artifact_url( + self, + file_info: dict, + uploads_dir: Path | None, + ) -> bool: + """Copy a previously uploaded file into the current thread when possible. + + iframe-style callers may pass file metadata that points at an upload + from another DeerFlow thread. The current run thread still needs a real + file under its own ``/mnt/user-data/uploads`` directory, otherwise this + middleware will filter the entry out during existence checks. + """ + filename = file_info.get("filename") or "" + artifact_url = str(file_info.get("artifact_url") or "").strip() + if not filename or uploads_dir is None or not artifact_url: + return False + + if not artifact_url.startswith("/api/threads/") or _ARTIFACT_URL_MARKER not in artifact_url: + return False + + prefix, encoded_virtual_path = artifact_url.split(_ARTIFACT_URL_MARKER, 1) + source_thread_id = prefix.removeprefix("/api/threads/").strip("/") + if not source_thread_id or not encoded_virtual_path: + return False + + virtual_path = "/" + unquote(encoded_virtual_path.lstrip("/")) + if Path(virtual_path).name != filename: + return False + + try: + source_path = self._paths.resolve_virtual_path( + source_thread_id, + virtual_path, + user_id=get_effective_user_id(), + ) + except ValueError: + logger.debug("Failed to resolve uploaded artifact path: %s", artifact_url, exc_info=True) + return False + + if not source_path.is_file(): + return False + + uploads_dir.mkdir(parents=True, exist_ok=True) + target_path = uploads_dir / filename + shutil.copy2(source_path, target_path) + + source_markdown = source_path.with_suffix(".md") + if source_markdown.is_file() and source_markdown != source_path: + shutil.copy2(source_markdown, target_path.with_suffix(".md")) + + return True + + def _files_from_kwargs(self, message: HumanMessage, uploads_dir: Path | None = None) -> list[dict] | None: """Extract file info from message additional_kwargs.files. The frontend sends uploaded file metadata in additional_kwargs.files @@ -183,16 +238,17 @@ class UploadsMiddleware(AgentMiddleware[UploadsMiddlewareState]): files = [] for f in kwargs_files: - if not isinstance(f, dict): - continue - filename = f.get("filename") or "" - if not filename or Path(filename).name != filename: - continue - if uploads_dir is not None and not (uploads_dir / filename).is_file(): - continue - files.append( - { - "filename": filename, + if not isinstance(f, dict): + continue + filename = f.get("filename") or "" + if not filename or Path(filename).name != filename: + continue + if uploads_dir is not None and not (uploads_dir / filename).is_file(): + if not self._import_file_from_artifact_url(f, uploads_dir): + continue + files.append( + { + "filename": filename, "size": int(f.get("size") or 0), "path": f"/mnt/user-data/uploads/{filename}", "extension": Path(filename).suffix, 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..1dd314ac 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,52 @@ 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 = ( + "注意:这是自动化圆桌研讨,没有用户在线应答——**请勿向用户提问或等待确认**," + "直接基于已知信息做出合理假设,产出完整的结构化交付。\n" + "**交付方式(与前端手动研讨一致)**:请用 write_file 工具把你这一轮的完整交付整理成一份 " + "Markdown 文件,写入 /mnt/user-data/outputs/ 下(文件名体现你的职责,如 `情报收集.md`、" + "`风险评估.md`);同时在回复正文里给出这份交付的内容本身(供总控汇总)," + "不要只回复『已写入文件』这类空话。" +) + + +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,10 +177,12 @@ async def _run_recommend( coordinator_name: str, model: str | None, seed_message: str, + intent_text: str, ) -> _LoopOutcome: 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,22 +194,32 @@ async def _run_recommend( ) if turn.final_text: last_leader = turn.final_text + 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, + 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) @@ -184,13 +237,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, @@ -200,6 +255,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, @@ -208,31 +266,44 @@ async def _run_chain( ) if turn.final_text: last_leader = turn.final_text + 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, + 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, tool_calls=closeout.tool_calls, thread_id=thread_ids.get(coordinator_name) + ) return _LoopOutcome("consensus", last_leader_content=last_leader, deliveries=deliveries) @@ -273,27 +344,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 acf3494c..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,14 +51,21 @@ 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 + # 研讨对话记录(leader 说明 + 各席位交付),随每次落库一并写入。 + self._dialogues: list[dict[str, Any]] = [] + self._dlg_seq = 0 # ── 节点快照 ────────────────────────────────────────────────────────── @@ -76,6 +92,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 +157,78 @@ 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, + *, + 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, + # 工具步骤卡数据源(每项 {name, args, id});映射成前端 Step2Dialogue.steps。 + "toolCalls": list(tool_calls or []), + # 该气泡对应的 leader/seat 线程——映射成 agentThreadId,供点开产物 / 反查 stub。 + "threadId": thread_id, + # 气泡时间戳,**创建时一次性盖章**(不随后续 flush 抖动),对齐前端 nowTime()。 + "time": _now_hms(), + } + ) + + 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, 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, + *, + 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 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/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/migrations/versions/20260606_01_roundtable_draft_shares.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260606_01_roundtable_draft_shares.py new file mode 100644 index 00000000..ab5b568e --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260606_01_roundtable_draft_shares.py @@ -0,0 +1,57 @@ +"""Create roundtable_draft_shares table. + +Per-draft share links for the roundtable-planning page, mirroring +``thread_shares`` but pointing at a ``roundtable_drafts`` row. ``import`` codes +copy the draft into the recipient's account; ``view`` codes back a public +read-only result-report page. ``create_all`` makes the table for brand-new DBs; +this revision patches DBs that already exist. + +Revision ID: 20260606_01 +Revises: 20260605_02 +Create Date: 2026-06-06 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +revision = "20260606_01" +down_revision = "20260605_02" +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 upgrade() -> None: + if _has_table("roundtable_draft_shares"): + return + op.create_table( + "roundtable_draft_shares", + sa.Column("share_code", sa.String(length=32), primary_key=True), + sa.Column("owner_user_id", sa.String(length=64), nullable=False), + sa.Column("draft_id", sa.String(length=64), nullable=False), + sa.Column("revoked", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("mode", sa.String(length=16), nullable=False, server_default="import"), + sa.Column("created_at", sa.DateTime(), nullable=False), + ) + op.create_index( + "ix_roundtable_draft_shares_owner_user_id", + "roundtable_draft_shares", + ["owner_user_id"], + ) + op.create_index( + "ix_roundtable_draft_shares_draft_id", + "roundtable_draft_shares", + ["draft_id"], + ) + + +def downgrade() -> None: + if _has_table("roundtable_draft_shares"): + op.drop_table("roundtable_draft_shares") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py index 983dbfcb..4ac8baa7 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py @@ -28,6 +28,7 @@ from deerflow.persistence.models.run_event import RunEventRow from deerflow.persistence.notifications.model import NotificationRow from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow from deerflow.persistence.roundtable_chains.model import RoundtableChainRow +from deerflow.persistence.roundtable_draft_shares.model import RoundtableDraftShareRow from deerflow.persistence.roundtable_drafts.model import ( RoundtableDraftRow, RoundtableRecommendHistoryRow, @@ -62,6 +63,7 @@ __all__ = [ "RecommendedQuestionRow", "RoundtableChainRow", "RoundtableDraftRow", + "RoundtableDraftShareRow", "RoundtableJobRow", "RoundtableRecommendHistoryRow", "RunEventRow", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/__init__.py new file mode 100644 index 00000000..16089569 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/__init__.py @@ -0,0 +1,22 @@ +"""Persistence layer for roundtable-draft share links.""" + +from deerflow.persistence.roundtable_draft_shares.base import ( + RoundtableDraftShareStore, + generate_share_code, +) +from deerflow.persistence.roundtable_draft_shares.memory import MemoryRoundtableDraftShareStore +from deerflow.persistence.roundtable_draft_shares.sql import RoundtableDraftShareRepository + +__all__ = [ + "RoundtableDraftShareStore", + "MemoryRoundtableDraftShareStore", + "RoundtableDraftShareRepository", + "generate_share_code", + "make_roundtable_draft_share_store", +] + + +def make_roundtable_draft_share_store(session_factory): + if session_factory is None: + return MemoryRoundtableDraftShareStore() + return RoundtableDraftShareRepository(session_factory) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/base.py new file mode 100644 index 00000000..4830a813 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/base.py @@ -0,0 +1,56 @@ +"""Abstract persistence API for roundtable-draft share links. + +Mirrors ``thread_shares`` but shares one *roundtable-planning draft* (a full +会商 session: Step 1 intent + Step 2 roundtable + optional Step 3 report) +instead of a conversation thread. Redeeming an ``import`` code copies the draft +into the recipient's account; a ``view`` code is a public read-only page that +renders the draft's Step 3 result report. + +The share-code alphabet / generator is reused from ``thread_shares.base`` so +codes are interchangeable in shape (unambiguous, human-readable). +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +# Reuse the same unambiguous-alphabet code generator as conversation shares. +from deerflow.persistence.thread_shares.base import generate_share_code + +__all__ = ["RoundtableDraftShareStore", "generate_share_code"] + + +class RoundtableDraftShareStore(ABC): + @abstractmethod + async def create(self, owner_user_id: str, draft_id: str, *, mode: str = "import") -> dict[str, Any]: + """Create a new share link for one draft and return its record. + + ``mode`` is ``"import"`` (recipient copies the draft) or ``"view"`` + (a public read-only result-report page). + """ + raise NotImplementedError + + @abstractmethod + async def get(self, share_code: str) -> dict[str, Any] | None: + """Look up a share by code regardless of owner (None if missing).""" + raise NotImplementedError + + @abstractmethod + async def get_active_for_draft(self, owner_user_id: str, draft_id: str, *, mode: str = "import") -> dict[str, Any] | None: + """Return the owner's non-revoked share of ``mode`` for ``draft_id``, if any. + + Used so re-sharing the same draft in the same mode reuses its existing + code instead of minting a new one each time. + """ + raise NotImplementedError + + @abstractmethod + async def list_for_owner(self, owner_user_id: str) -> list[dict[str, Any]]: + """List all share links created by ``owner_user_id`` (newest first).""" + raise NotImplementedError + + @abstractmethod + async def revoke(self, share_code: str, owner_user_id: str) -> bool: + """Revoke a share link. Returns False if missing or not owned.""" + raise NotImplementedError diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/memory.py new file mode 100644 index 00000000..a89942a4 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/memory.py @@ -0,0 +1,59 @@ +"""In-memory ``RoundtableDraftShareStore`` for tests / memory backend.""" + +from __future__ import annotations + +import copy +from datetime import UTC, datetime +from typing import Any + +from deerflow.persistence.roundtable_draft_shares.base import RoundtableDraftShareStore, generate_share_code + + +class MemoryRoundtableDraftShareStore(RoundtableDraftShareStore): + def __init__(self) -> None: + self._shares: dict[str, dict[str, Any]] = {} + + async def create(self, owner_user_id: str, draft_id: str, *, mode: str = "import") -> dict[str, Any]: + code = generate_share_code() + while code in self._shares: + code = generate_share_code() + record = { + "share_code": code, + "owner_user_id": owner_user_id, + "draft_id": draft_id, + "revoked": False, + "mode": mode, + "created_at": datetime.now(UTC).isoformat(), + } + self._shares[code] = record + return copy.deepcopy(record) + + async def get(self, share_code: str) -> dict[str, Any] | None: + record = self._shares.get(share_code) + return copy.deepcopy(record) if record is not None else None + + async def get_active_for_draft(self, owner_user_id: str, draft_id: str, *, mode: str = "import") -> dict[str, Any] | None: + matches = [ + r + for r in self._shares.values() + if r["owner_user_id"] == owner_user_id + and r["draft_id"] == draft_id + and r.get("mode", "import") == mode + and not r["revoked"] + ] + if not matches: + return None + matches.sort(key=lambda r: r["created_at"], reverse=True) + return copy.deepcopy(matches[0]) + + async def list_for_owner(self, owner_user_id: str) -> list[dict[str, Any]]: + rows = [r for r in self._shares.values() if r["owner_user_id"] == owner_user_id] + rows.sort(key=lambda r: r["created_at"], reverse=True) + return [copy.deepcopy(r) for r in rows] + + async def revoke(self, share_code: str, owner_user_id: str) -> bool: + record = self._shares.get(share_code) + if record is None or record["owner_user_id"] != owner_user_id: + return False + record["revoked"] = True + return True diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/model.py new file mode 100644 index 00000000..35bf0ed3 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/model.py @@ -0,0 +1,38 @@ +"""ORM model for roundtable-draft share links. + +A share link is a short, revocable code created by a user for *one* of their +roundtable-planning drafts. Redeeming it copies that draft into the redeemer's +account (``import``) or exposes its Step 3 result report on a public read-only +page (``view``). The table only stores the link — the draft snapshot is read +lazily at import / view time, so a revoked link instantly stops working. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import Boolean, String +from sqlalchemy.orm import Mapped, mapped_column + +from deerflow.persistence.base import Base +from deerflow.persistence.types import BeijingDateTime + + +class RoundtableDraftShareRow(Base): + __tablename__ = "roundtable_draft_shares" + + # Short, URL-safe, unguessable code embedded in the share link. + share_code: Mapped[str] = mapped_column(String(32), primary_key=True) + # The user who created the share. + owner_user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + # The single roundtable draft this link shares. + draft_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + # Soft-disable: a revoked link can no longer be previewed, imported, or viewed. + revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + # Share mode: + # "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). + mode: Mapped[str] = mapped_column(String(16), nullable=False, default="import", server_default="import") + created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/sql.py new file mode 100644 index 00000000..8834ed0d --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_draft_shares/sql.py @@ -0,0 +1,84 @@ +"""SQLAlchemy-backed roundtable-draft share-link repository.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from deerflow.persistence.roundtable_draft_shares.base import RoundtableDraftShareStore, generate_share_code +from deerflow.persistence.roundtable_draft_shares.model import RoundtableDraftShareRow + + +def _row_to_dict(row: RoundtableDraftShareRow) -> dict[str, Any]: + data = row.to_dict() + created = data.get("created_at") + if isinstance(created, datetime): + data["created_at"] = created.isoformat() + return data + + +class RoundtableDraftShareRepository(RoundtableDraftShareStore): + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._sf = session_factory + + async def create(self, owner_user_id: str, draft_id: str, *, mode: str = "import") -> dict[str, Any]: + # Retry on the (astronomically unlikely) code collision. + async with self._sf() as session: + for _ in range(8): + code = generate_share_code() + if await session.get(RoundtableDraftShareRow, code) is not None: + continue + row = RoundtableDraftShareRow( + share_code=code, + owner_user_id=owner_user_id, + draft_id=draft_id, + revoked=False, + mode=mode, + ) + session.add(row) + await session.commit() + await session.refresh(row) + return _row_to_dict(row) + raise RuntimeError("Failed to allocate a unique share code") + + async def get(self, share_code: str) -> dict[str, Any] | None: + async with self._sf() as session: + row = await session.get(RoundtableDraftShareRow, share_code) + return _row_to_dict(row) if row is not None else None + + async def get_active_for_draft(self, owner_user_id: str, draft_id: str, *, mode: str = "import") -> dict[str, Any] | None: + stmt = ( + select(RoundtableDraftShareRow) + .where( + RoundtableDraftShareRow.owner_user_id == owner_user_id, + RoundtableDraftShareRow.draft_id == draft_id, + RoundtableDraftShareRow.mode == mode, + RoundtableDraftShareRow.revoked.is_(False), + ) + .order_by(RoundtableDraftShareRow.created_at.desc()) + ) + async with self._sf() as session: + row = (await session.execute(stmt)).scalars().first() + return _row_to_dict(row) if row is not None else None + + async def list_for_owner(self, owner_user_id: str) -> list[dict[str, Any]]: + stmt = ( + select(RoundtableDraftShareRow) + .where(RoundtableDraftShareRow.owner_user_id == owner_user_id) + .order_by(RoundtableDraftShareRow.created_at.desc()) + ) + async with self._sf() as session: + result = await session.execute(stmt) + return [_row_to_dict(row) for row in result.scalars()] + + async def revoke(self, share_code: str, owner_user_id: str) -> bool: + async with self._sf() as session: + row = await session.get(RoundtableDraftShareRow, share_code) + if row is None or row.owner_user_id != owner_user_id: + return False + row.revoked = True + await session.commit() + return True 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..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 @@ -55,6 +55,12 @@ 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) + # 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 ba431113..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"): + for k in ("dispatch_chain", "thread_ids", "chain", "dialogues", "step3"): d[k] = _loads(d.get(k)) return d @@ -118,6 +118,8 @@ class RoundtableJobRepository: pending_clarification=_UNSET, 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") @@ -145,6 +147,10 @@ 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 step3 is not _UNSET: + row.step3 = _dumps(step3) if error is not _UNSET: row.error = error row.updated_at = datetime.now(UTC) diff --git a/offline-backend-20260512/backend/scripts/inspect_latest_roundtable_run.py b/offline-backend-20260512/backend/scripts/inspect_latest_roundtable_run.py new file mode 100644 index 00000000..a7392196 --- /dev/null +++ b/offline-backend-20260512/backend/scripts/inspect_latest_roundtable_run.py @@ -0,0 +1,114 @@ +"""一次性巡检:把最新圆桌草稿里「后台作业 run」的关键字段 dump 到文件, +用于验证维度3 存储 + 报告 HTML + 席位 md 文件是否都对齐了。 + +用法(在 backend 目录): + PYTHONPATH=. uv run python scripts/inspect_latest_roundtable_run.py + +输出写到 scripts/_roundtable_run_report.txt(UTF-8),直接打开看,避免 PowerShell GBK 乱码。 +""" + +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path + +DB = Path(".deer-flow/data/deerflow.db") +OUT = Path("scripts/_roundtable_run_report.txt") + + +def _loads(v): + if isinstance(v, (bytes, bytearray)): + v = v.decode("utf-8", "replace") + if isinstance(v, str): + try: + return json.loads(v) + except Exception: + return v + return v + + +def main() -> None: + con = sqlite3.connect(str(DB)) + con.row_factory = sqlite3.Row + lines: list[str] = [] + + # 最新一条有 step2.runs 的草稿 + rows = con.execute( + "SELECT id, step2, step3 FROM roundtable_drafts ORDER BY updated_at DESC LIMIT 5" + ).fetchall() + + draft = None + top_step3 = None + for r in rows: + s2 = _loads(r["step2"]) or {} + if isinstance(s2, dict) and s2.get("runs"): + draft = (r["id"], s2) + top_step3 = _loads(r["step3"]) + break + + if not draft: + lines.append("没找到带 step2.runs 的草稿。") + OUT.write_text("\n".join(lines), encoding="utf-8") + print(f"written: {OUT}") + return + + draft_id, s2 = draft + runs = s2.get("runs") or [] + job_run = next((x for x in runs if str(x.get("id", "")).startswith("job-")), runs[-1]) + + lines.append(f"draft_id = {draft_id}") + lines.append(f"run.id = {job_run.get('id')} jobStatus = {job_run.get('jobStatus')} hasConsensus = {job_run.get('hasConsensus')}") + lines.append("") + + # ── 维度3:threadIds ── + lines.append("【threadIds】(应为真实 map,不是 null)") + lines.append(json.dumps(job_run.get("threadIds"), ensure_ascii=False, indent=2)) + lines.append("") + + # ── 维度3:各对话的 steps / agentThreadId / time ── + lines.append("【step2RoundtableDialogues 摘要】") + for d in job_run.get("step2RoundtableDialogues") or []: + steps = d.get("steps") or [] + step_keys = [s.get("key") for s in steps] + lines.append( + f"- [{d.get('sender')}] time={d.get('time')!r} thread={d.get('agentThreadId')!r} " + f"steps={step_keys} content_len={len(d.get('content') or '')}" + ) + lines.append("") + + # ── 席位 md 文件:seatStubMessages 里有没有 write_file ── + lines.append("【seatStubMessages】(席位写的文件 → 文件卡片数据源)") + stubs = job_run.get("seatStubMessages") or {} + if not stubs: + lines.append(" (空 —— 席位没有写文件 / 没采集到)") + for tid, msgs in stubs.items(): + for m in msgs: + for tc in m.get("tool_calls") or []: + args = tc.get("args") or {} + lines.append(f" thread={tid} {tc.get('name')} path={args.get('path')!r} content_len={len(str(args.get('content') or ''))}") + lines.append("") + + # ── 报告 HTML(P0)── + lines.append("【step3 报告】") + step3 = job_run.get("step3") or {} + html = step3.get("html") or "" + lines.append(f" html_len = {len(html)}") + lines.append(f" 含