This commit is contained in:
吉鑫升 committed 2026-06-05 20:07:03 +08:00
commit 24a5ca0c06
76 files changed
+5373 -1064

No files matched your search

+3 -3
View File
@@ -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 环境变量中配置:
@@ -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-<id>` 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>", jobId, jobStatus, step2RoundtableDialogues:[...], step3:{...}} ]`。
4. **前端验证**:后台挂起 → 等跑完 → 从历史加载该草稿 → Step2 应显示后台研讨完整对话(走现有 `normalizeStep2Runs`/`hydrateStep2Run`)。
---
## 三、遗留问题(下次重点,已按优先级排)
### P0 —— 报告 agent 不写 HTML 文件(结果绘制拿不到产物)
- **现象**:后台报告 run 跑完,日志 `report html: no .html found in ...threads/<id>/user-data/outputs`;
`run.step3.html` 回退成报告 agent 的**纯文字**(不含 `<html>`),第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。
@@ -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<string, unknown[]>;
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<string, unknown[]>;
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<string, unknown[]>;
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<Step2RunSnapshot[]>([]);
const [activeStep2RunId, setActiveStep2RunId] = useState<string | null>(null);
```
调整几个流程:
- `getStep2Snapshot` 输出 `runs` 和 `activeRunId`。
- `hydrateStep2` 兼容旧草稿并恢复 runs。
- `handleConfirmAgents` 在新建研讨前先归档当前 active run,再创建新 run。
- `onReportSaved` 把 Step3 报告写回当前 active run。
### 4. `Step2Panel.tsx`
新增左下角结果列表展示。建议作为一个独立组件,避免 Step2Panel 继续变大:
```tsx
<Step2RunSwitcher
runs={step2Runs}
activeRunId={activeStep2RunId}
onSelect={handleSelectStep2Run}
/>
```
组件职责只做展示和选择,不直接读写 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 报告,切换不同结果线时能看到各自对应的报告。
+1
View File
@@ -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",
+10
View File
@@ -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() {
<Route path="/landing" element={<LandingPage />} />
{/* Public read-only conversation viewer — no authentication. */}
<Route path="/share/:shareCode" element={<PublicSharePage />} />
{/* Public read-only roundtable share — landing + full result report. */}
<Route
path="/roundtable-share/:shareCode"
element={<PublicRoundtableSharePage />}
/>
<Route
path="/roundtable-share/:shareCode/report"
element={<PublicRoundtableSharePage report />}
/>
{/* Public read-only scheduled-task runs viewer — no authentication. */}
<Route
path="/public/scheduled-tasks/:taskId"
+1 -1
View File
@@ -1,5 +1,5 @@
.sidebar-contnet {
position: fixed;
position: absolute;
left: 0;
top: 4rem;
z-index: 40;
@@ -55,14 +55,6 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
}, 180);
};
const closeNavPanel = () => {
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);
}
};
@@ -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") && (
<ArtifactFilePreview
content={desensitize(displayContent ?? "")}
language={language ?? "text"}
@@ -782,6 +835,8 @@ export function ArtifactFilePreview({
activeCursor?: boolean;
}) {
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
const [mermaidSvg, setMermaidSvg] = useState<string | null>(null);
const [mermaidError, setMermaidError] = useState<string | null>(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 (
<div
@@ -873,20 +959,20 @@ export function ArtifactFilePreview({
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
)}
>
<Streamdown
className="size-full"
{...streamdownPlugins}
components={{
...markdownComponents,
img: (props) => (
<SandboxPreviewImage
{...props}
threadId={threadId}
publicShareCode={publicShareCode}
/>
),
}}
>
<Streamdown
className="size-full"
{...streamdownPlugins}
components={{
...markdownComponents,
img: (props) => (
<SandboxPreviewImage
{...props}
threadId={threadId}
publicShareCode={publicShareCode}
/>
),
}}
>
{content ?? ""}
</Streamdown>
{activeCursor && (
@@ -910,5 +996,38 @@ export function ArtifactFilePreview({
);
}
if (language === "mermaid") {
return (
<div
data-artifact-preview-scroll
className={cn(
"size-full overflow-auto bg-[#fcfdfc] px-5 py-4",
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
)}
>
{mermaidSvg ? (
<div
className="mx-auto flex min-h-full items-start justify-center rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"
dangerouslySetInnerHTML={{ __html: mermaidSvg }}
/>
) : mermaidError ? (
<div className="space-y-3">
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{mermaidError}
</div>
<pre className="whitespace-pre-wrap break-words rounded-xl bg-slate-100 p-4 text-xs text-slate-700">
{content ?? ""}
</pre>
</div>
) : (
<div className="text-muted-foreground flex size-full items-center justify-center gap-2 text-sm">
<LoaderIcon className="size-5 animate-spin" />
正在生成图表预览
</div>
)}
</div>
);
}
return null;
}
@@ -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<typeof useRouter>,
) {
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 (
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-primary">
<Loader2 className="w-2.5 h-2.5 animate-spin" />
进行中
</span>
);
}
if (draft.status === "awaiting_input") {
return <span className="text-[10px] font-medium text-amber-500">待澄清</span>;
}
if (draft.status === "error") {
return <span className="text-[10px] font-medium text-rose-500">出错</span>;
}
return (
<span className="text-[10px] font-medium text-muted-foreground">
第 {draft.furthestStep} 步
</span>
);
}
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<string | null>(null);
const [renameValue, setRenameValue] = useState("");
// 分享对话框 —— 正在分享的草稿 id。
const [shareId, setShareId] = useState<string | null>(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 (
<>
<SidebarGroup className="px-2 pt-3 mt-0 border-t border-sidebar-border/60">
<SidebarGroupLabel
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
onClick={toggleCollapsed}
>
<span>多智能体会商</span>
<ChevronDown
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
/>
</SidebarGroupLabel>
<SidebarGroupContent
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[600px] opacity-100"
}`}
>
<SidebarMenu>
<div className="flex w-full flex-col gap-0.5">
{loading && drafts.length === 0 ? (
<p className="px-3 py-1 text-[12px] text-muted-foreground">加载中…</p>
) : (
drafts.slice(0, 10).map((draft) => (
<SidebarMenuItem key={draft.id} className="group/side-menu-item">
<SidebarMenuButton
className={
"rounded-lg h-auto py-1.5 px-3 text-muted-foreground " +
"hover:text-foreground hover:bg-sidebar-accent/60"
}
onClick={() => openDraft(draft, isOnPage, router)}
>
<div className="flex flex-col items-start min-w-0 w-full">
<span className="text-[13px] truncate w-full leading-snug">
{draft.title || "未命名草稿"}
</span>
<div className="flex items-center gap-1 mt-0.5">
<StatusBadge draft={draft} />
<span className="text-[10px] text-muted-foreground">·</span>
<span className="text-[10px] text-muted-foreground">
{formatRelative(draft.updatedAt)}
</span>
</div>
</div>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="bg-background/50 hover:bg-background top-2"
>
<MoreHorizontal />
<span className="sr-only">更多</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-44 rounded-lg"
side="right"
align="start"
>
<DropdownMenuItem
onSelect={() => {
setRenameId(draft.id);
setRenameValue(draft.title || "");
}}
>
<Pencil className="text-muted-foreground" />
<span>重命名</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setShareId(draft.id)}>
<Share2 className="text-muted-foreground" />
<span>分享</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void handleDelete(draft)}>
<Trash2 className="text-muted-foreground" />
<span>删除</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))
)}
</div>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{/* 重命名对话框 */}
<Dialog open={renameId !== null} onOpenChange={(o) => !o && setRenameId(null)}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>重命名会商</DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
placeholder="输入新名称"
onKeyDown={(e) => {
if (e.key === "Enter" && !isIMEComposing(e)) {
e.preventDefault();
void handleRenameSubmit();
}
}}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRenameId(null)}>
取消
</Button>
<Button onClick={() => void handleRenameSubmit()}>保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 分享对话框 */}
<ShareRoundtableDraftDialog
draftId={shareId}
open={shareId !== null}
onOpenChange={(o) => {
if (!o) setShareId(null);
}}
/>
</>
);
}
@@ -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<DraftShareMode>("view");
const [share, setShare] = useState<DraftShare | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>分享会商</DialogTitle>
<DialogDescription>
{isView
? "生成一个只读链接,任何人无需登录都能查看这次会商生成的结果报告。"
: "生成一个分享码,对方在「导入会商」里输入它即可把这份会商复制到自己的账号继续研讨。"}
</DialogDescription>
</DialogHeader>
{/* Mode selector */}
<div className="bg-muted flex gap-1 rounded-lg p-1">
{(
[
{ value: "view" as const, label: "只读链接" },
{ value: "import" as const, label: "导入副本" },
]
).map((option) => (
<button
key={option.value}
type="button"
onClick={() => setMode(option.value)}
className={cn(
"flex-1 rounded-md px-3 py-1.5 text-sm transition-colors",
mode === option.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</button>
))}
</div>
{!share ? (
<div className="text-muted-foreground flex items-center gap-2 py-8 text-sm">
<Loader2 className="size-4 animate-spin" />
正在生成分享…
</div>
) : (
<div className="space-y-3">
<div>
<p className="text-muted-foreground mb-1 text-xs">
{isView ? "只读链接" : "分享码"}
</p>
<div className="flex items-center gap-2">
{isView ? (
<Input
readOnly
value={publicUrl}
onFocus={(e) => e.currentTarget.select()}
className="flex-1 font-mono text-xs"
/>
) : (
<code className="bg-muted flex-1 rounded-md px-3 py-2 text-center font-mono text-lg tracking-[0.3em]">
{share.share_code}
</code>
)}
<Button variant="outline" onClick={() => void handleCopy()}>
{copied ? (
<CheckIcon className="size-4" />
) : (
<CopyIcon className="size-4" />
)}
{isView ? "复制链接" : "复制码"}
</Button>
</div>
</div>
{isView ? (
<div className="space-y-3">
<p className="text-muted-foreground text-xs">
拿到链接的人都能查看这次会商的结果报告(只读)。若会商尚未生成结果报告,对方会看到提示。撤销后链接立即失效。
</p>
<Button
variant="outline"
size="sm"
onClick={() => window.open(publicUrl, "_blank", "noopener")}
>
<ExternalLinkIcon className="size-3.5" />
在新标签页打开
</Button>
</div>
) : (
<p className="text-muted-foreground text-xs">
把这个分享码发给对方,他们在「导入会商」里输入即可复制一份。撤销后分享码立即失效。
</p>
)}
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={revokeShare.isPending}
onClick={() => void handleRevoke()}
>
<Trash2Icon className="size-3.5" />
撤销分享
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}
/** 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<DraftSharePreview | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>导入会商</DialogTitle>
<DialogDescription>
输入对方分享的「导入副本」分享码,把这份会商复制到你自己的账号。
</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input
value={input}
onChange={(e) => {
setInput(e.target.value);
setPreview(null);
}}
placeholder="粘贴分享码或链接"
className="font-mono"
/>
<Button
variant="outline"
onClick={() => void handlePreview()}
disabled={previewShare.isPending || !input.trim()}
>
{previewShare.isPending && <Loader2 className="size-4 animate-spin" />}
预览
</Button>
</div>
{preview && (
<div className="space-y-2 rounded-lg border p-3 text-sm">
<p className="font-medium">{preview.title || "未命名会商"}</p>
<p className="text-muted-foreground text-xs">
来自用户 {preview.shared_by}
</p>
<Button
className="w-full"
onClick={() => void handleImport()}
disabled={importShare.isPending}
>
{importShare.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<DownloadIcon className="size-4" />
)}
{importShare.isPending ? "正在导入…" : "导入到我的账号"}
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -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({
<SidebarContent>
<WorkspaceNavChatList showCollapseTrigger={compactChrome} />
{isSidebarOpen && <RecentChatList />}
{isSidebarOpen && <RecentRoundtableList />}
{isSidebarOpen && <RecentWritingList />}
</SidebarContent>
{!compactChrome && (
@@ -184,6 +184,21 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ 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();
+1 -1
View File
@@ -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?: {
+1 -1
View File
@@ -1,4 +1,4 @@
import {
import {
CompassIcon,
GraduationCapIcon,
ImageIcon,
@@ -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" },
@@ -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<Error> {
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<DraftShare> {
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<DraftShare>;
}
/** List the current user's own roundtable-draft share links. */
export async function listDraftShares(): Promise<DraftShare[]> {
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<void> {
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<DraftSharePreview> {
const response = await apiFetch(
`${baseUrl()}/${encodeURIComponent(shareCode)}/preview`,
);
if (!response.ok) throw await parseError(response, "无法读取该分享");
return response.json() as Promise<DraftSharePreview>;
}
/** Import the roundtable draft behind a share link into the current account. */
export async function importDraftShare(shareCode: string): Promise<DraftImportResult> {
const response = await apiFetch(
`${baseUrl()}/${encodeURIComponent(shareCode)}/import`,
{ method: "POST" },
);
if (!response.ok) throw await parseError(response, "导入失败");
return response.json() as Promise<DraftImportResult>;
}
/**
* 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<PublicDraftShareSnapshot> {
const response = await fetch(
`${getBackendBaseURL()}/api/public/roundtable-shares/${encodeURIComponent(shareCode)}`,
);
if (!response.ok) throw await parseError(response, "无法打开该分享");
return response.json() as Promise<PublicDraftShareSnapshot>;
}
/** 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/<code>`.
return `${window.location.origin}${window.location.pathname}#/roundtable-share/${shareCode}`;
}
@@ -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"] });
},
});
}
+5 -4
View File
@@ -94,10 +94,11 @@ const extensionMap: Record<string, string> = {
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",
+75
View File
@@ -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<string> {
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<void> {
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<void> {
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);
}
+7 -3
View File
@@ -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";
+1 -1
View File
@@ -1,4 +1,4 @@
"use client";
"use client";
import {
ArrowLeftIcon,
@@ -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<Set<number>>(() => 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 (
<p className="text-[11px] text-slate-400 italic">本次会商暂无子智能体交付。</p>
);
}
return (
<div className="space-y-2">
{seats.map((seat, idx) => {
const open = expanded.has(idx);
return (
<div key={`${seat.sender}-${idx}`} className="border border-slate-200 rounded-xl overflow-hidden">
<button
type="button"
onClick={() => toggle(idx)}
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-slate-50 hover:bg-slate-100 transition text-left"
>
<div className="flex items-center gap-2 min-w-0">
{open ? (
<ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0" />
)}
<span className="text-[11.5px] font-bold text-slate-800 truncate">
{seat.sender || "未命名席位"}
</span>
</div>
<span className="text-[10px] text-slate-400 font-mono shrink-0">
{seat.content.length} 字
</span>
</button>
{open && (
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{seat.content || "(空)"}
</ReactMarkdown>
</div>
)}
</div>
);
})}
</div>
);
}
/**
* Public, read-only roundtable-result viewer.
*
* Reachable at `#/roundtable-share/<code>` with NO authentication — anyone with
* the link can open it. Two views, driven by the `report` prop / route:
*
* - landing (`#/roundtable-share/<code>`): 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/<code>/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 (
<div className="bg-background text-muted-foreground flex h-screen items-center justify-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
正在加载分享内容…
</div>
);
}
if (isError || !data) {
return (
<div className="bg-background flex h-screen flex-col items-center justify-center gap-2 px-6 text-center">
<p className="text-lg font-medium">分享不可用</p>
<p className="text-muted-foreground text-sm">
该分享链接不存在、已被撤销,或不是只读分享。
</p>
</div>
);
}
// ── 报告页:整页沙箱 iframe 展示第 3 步结果绘制 ──────────────────────────────
if (report) {
return (
<div className="bg-background flex h-screen flex-col">
<header className="flex shrink-0 items-center gap-2 border-b px-6 py-3">
<h1 className="truncate text-base font-semibold">
{data.title || "未命名会商"}
</h1>
<span className="bg-muted text-muted-foreground shrink-0 rounded-full px-2 py-0.5 text-xs">
结果绘制 · 只读
</span>
</header>
<main className="min-h-0 flex-1">
{data.has_report ? (
<iframe
// No `allow-scripts` — the report is self-contained (inline CSS +
// inline SVG charts) and must never run as a script in the app origin.
sandbox=""
title={data.title || "会商结果报告"}
srcDoc={data.html}
className="size-full border-0 bg-white"
/>
) : (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-2 px-6 text-center text-sm">
<p className="text-base font-medium text-foreground">暂无结果报告</p>
<p>这次会商还没有生成第三步「结果绘制」的方案报告。</p>
</div>
)}
</main>
</div>
);
}
// ── 落地页:总控最终结论 + 各席位最终交付 + 查看结果绘制(新标签打开报告页)─────
const reportUrl = `${window.location.origin}${window.location.pathname}#/roundtable-share/${shareCode}/report`;
return (
<div className="bg-[#fef6f6] flex h-screen flex-col text-slate-800">
<header className="flex shrink-0 flex-col gap-1 border-b border-slate-100 bg-white px-6 py-3">
<div className="flex items-center gap-2">
<h1 className="truncate text-base font-semibold">
{data.title || "未命名会商"}
</h1>
<span className="bg-slate-100 text-slate-500 shrink-0 rounded-full px-2 py-0.5 text-xs">
只读分享
</span>
</div>
<p className="text-slate-400 text-xs">
这是一份多智能体会商的结果分享(只读)。
</p>
</header>
<main className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-6xl space-y-6 p-6">
{/* 1. 结果绘制 —— 新标签打开报告页 */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-primary" />
结果绘制
</h4>
{data.has_report ? (
<a
href={reportUrl}
target="_blank"
rel="noopener noreferrer"
className="bg-primary text-white hover:bg-[var(--brand-900)] inline-flex items-center gap-2 rounded-xl px-5 py-2.5 text-sm font-semibold transition shadow-sm shadow-primary/10"
>
<BarChart3 className="size-4" />
查看结果绘制
<ExternalLink className="size-3.5 opacity-80" />
</a>
) : (
<p className="text-[11px] text-slate-400 italic">
这次会商还没有生成第三步「结果绘制」的方案报告。
</p>
)}
</section>
{/* 2. 总控最终结论 */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-emerald-500 shrink-0" />
总控最终结论
</h4>
{data.consensus_text ? (
<div className="text-xs text-slate-700 leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{data.consensus_text}
</ReactMarkdown>
</div>
) : (
<p className="text-[11px] text-slate-400 italic">暂无总控结论。</p>
)}
</section>
{/* 3. 各席位最终交付 */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<FileText className="w-4 h-4 text-primary" />
各席位最终交付
<span className="text-[10px] font-mono font-semibold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
{data.seat_deliveries.length}
</span>
</h4>
<SeatDeliveries seats={data.seat_deliveries} />
</section>
</div>
</main>
</div>
);
}
+52 -55
View File
@@ -1,46 +1,47 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { Toaster } from "sonner";
import { SidebarProvider } from "@/components/ui/sidebar";
import { getStoredAuth } from "@/core/auth";
import { RouteBaseProvider } from "@/core/route-base";
import { SidebarProvider } from "@/components/ui/sidebar";
import GeneralQA from "@/strategy-components/components/chat-qa/GeneralQA";
import { ChatRuntime } from "./WorkspaceRoutes";
import AgentsPage from "./AgentsPage";
import ChatsPage from "./ChatsPage";
import ChatPage from "./ChatPage";
import NewAgentPage from "./NewAgentPage";
import FanganTyPage from "@/strategy-components/components/task-components/FanganTy";
import SentimentAnalyzes from "@/strategy-components/components/task-components/SentimentAnalyzes";
import TaskManagement from "@/strategy-components/components/task-components/TaskManagement";
import TaskPgFrom from "@/strategy-components/components/task-components/TaskPgFrom";
import TaskXkFrom from "@/strategy-components/components/task-components/TaskXkFrom";
import TaskAnalyzes from "@/strategy-components/components/task-components/TaskAnalyzes";
import XingdongTyPage from "@/strategy-components/components/task-components/XingdongTy";
import XXTyPage from "@/strategy-components/components/task-components/XXTy";
import { ActionAnalyzes } from "@/strategy-components/components/task-components/ActionAnalyzes";
import KnowledgeBaseResourceManagement from "@/strategy-components/components/resource-management/KnowledgeBaseResourceManagement";
import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import "@/roundtable-planning/styles/roundtable-planning.css";
import AgentChatPage from "./AgentChatPage";
import SkillsPage from "./SkillsPage";
import SkillEvolutionPage from "./SkillEvolutionPage";
import MemoryPage from "./MemoryPage";
import ScheduledTasksPage from "./ScheduledTasksPage";
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
import LlmMetricsPage from "./LlmMetricsPage";
import UsersPage from "./UsersPage";
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminUserActivityPage from "./AdminUserActivityPage";
import AgentsPage from "./AgentsPage";
import ChatPage from "./ChatPage";
import ChatsPage from "./ChatsPage";
import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage";
import AdminUserLeaderboardSettingsPage from "./AdminUserLeaderboardSettingsPage";
import TagsPage from "./TagsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage";
import TaskXkFrom from "@/strategy-components/components/task-components/TaskXkFrom";
import TaskManagement from "@/strategy-components/components/task-components/TaskManagement";
import TaskPgFrom from "@/strategy-components/components/task-components/TaskPgFrom";
import FanganTyPage from "@/strategy-components/components/task-components/FanganTy";
import XingdongTyPage from "@/strategy-components/components/task-components/XingdongTy";
import XXTyPage from "@/strategy-components/components/task-components/XXTy";
import SentimentAnalyzes from "@/strategy-components/components/task-components/SentimentAnalyzes";
import TaskAnalyzes from "@/strategy-components/components/task-components/TaskAnalyzes";
import { ActionAnalyzes } from "@/strategy-components/components/task-components/ActionAnalyzes";
import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement";
import KnowledgeBaseResourceManagement from "@/strategy-components/components/resource-management/KnowledgeBaseResourceManagement";
import MemoryPage from "./MemoryPage";
import NewAgentPage from "./NewAgentPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import ScheduledTasksPage from "./ScheduledTasksPage";
import SkillEvolutionPage from "./SkillEvolutionPage";
import SkillsPage from "./SkillsPage";
import TagsPage from "./TagsPage";
import UsersPage from "./UsersPage";
import LlmMetricsPage from "./LlmMetricsPage";
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminUserActivityPage from "./AdminUserActivityPage";
import { ChatRuntime } from "./WorkspaceRoutes";
export default function StrategyRoutes() {
return (
@@ -50,21 +51,27 @@ export default function StrategyRoutes() {
<Routes>
<Route index element={<Navigate to="qa/general" replace />} />
{/* 问答中心 */}
<Route path="qa/general" element={<GeneralQA />} />
<Route
path="qa/expert-qa"
element={
<GeneralQA
chatHeadTitle="专家问答"
chatDesign="支持单轮RAG、多轮RAG、深度检索及结果对比"
chatHeadTitle="涓撳闂瓟"
chatDesign="鏀寔鍗曡疆RAG銆佸杞甊AG銆佹繁搴︽绱㈠強缁撴灉瀵规瘮"
/>
}
/>
{/* 任务管理 */}
<Route
path="qa/multi-agent-roundtable"
element={
<ChatRuntime>
<RoundtablePlanningPage />
</ChatRuntime>
}
/>
<Route path="task-management" element={<TaskManagement />}>
<Route index element={<Navigate to="xk-task-jc" replace />} />
{/* XK任务 */}
<Route
path="xk-task-jc"
element={<TaskXkFrom gopath="#/pageIndie/pageHome/pageSwrw" />}
@@ -73,7 +80,6 @@ export default function StrategyRoutes() {
path="xk-task-dispatch"
element={<TaskXkFrom gopath="#/pageIndie/pageJczl" />}
/>
{/* pg中任务 */}
<Route
path="effect-task-pg"
element={<TaskPgFrom gopath="task" />}
@@ -86,12 +92,11 @@ export default function StrategyRoutes() {
path="effect-info-pg"
element={<TaskPgFrom gopath="info" />}
/>
{/* COH中任务 */}
<Route
path="plan-task-sentiment"
element={
<SentimentAnalyzes
title="3QFX"
title="三情分析"
category="规划任务"
subCategory="规划任务"
/>
@@ -101,9 +106,9 @@ export default function StrategyRoutes() {
path="plan-task/plan-task-fx"
element={
<TaskAnalyzes
title="任务FX"
category="规划任务"
subCategory="规划任务"
title="浠诲姟FX"
category="瑙勫垝浠诲姟"
subCategory="瑙勫垝浠诲姟"
/>
}
/>
@@ -112,17 +117,16 @@ export default function StrategyRoutes() {
element={
<ActionAnalyzes
title="xdfx"
category="规划任务"
subCategory="规划任务"
category="瑙勫垝浠诲姟"
subCategory="瑙勫垝浠诲姟"
/>
}
/>
{/* MNTY任务 */}
<Route path="mnty-task-mechanism" element={<XingdongTyPage />} />
<Route path="mnty-task-plan" element={<FanganTyPage />} />
<Route path="mnty-task-zsty" element={<XXTyPage />} />
</Route>
{/* 资源管理 */}
<Route
path="resource-management/resource-network-actor"
element={<NetworkActorResourceManagement />}
@@ -133,16 +137,13 @@ export default function StrategyRoutes() {
/>
<Route
path="resource-management/resource-evaluation-index"
element={
<TaskXkFrom gopath="#/pageIndie/pageZyjc/pageZyzl" />
}
element={<TaskXkFrom gopath="#/pageIndie/pageZyjc/pageZyzl" />}
/>
{/* 轻应用 */}
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
{/* 对话 */}
<Route path="chats" element={<ChatsPage />} />
<Route
path="chats/:thread_id"
@@ -153,7 +154,6 @@ export default function StrategyRoutes() {
}
/>
{/* 智能体管理 */}
<Route path="agents" element={<AgentsPage />} />
<Route path="agents/new" element={<NewAgentPage />} />
<Route
@@ -169,12 +169,10 @@ export default function StrategyRoutes() {
}
/>
{/* 技能 / 记忆 */}
<Route path="skills" element={<SkillsPage />} />
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
<Route path="memory" element={<MemoryPage />} />
{/* 定时任务 */}
<Route
path="scheduled"
element={<Navigate to="/page/strategy/scheduled/tasks" replace />}
@@ -185,7 +183,6 @@ export default function StrategyRoutes() {
element={<ScheduledTaskRunDetailPage />}
/>
{/* 设置 / 管理 — 权限与 workspace 侧一致,非 admin 重定向到 chats/new */}
<Route
path="settings/recommended-questions"
element={
@@ -100,3 +100,37 @@ test("maps iframe model selection into stream context model_name", () => {
model_name: "OpenAI3",
});
});
test("maps externally uploaded files into stream additional_kwargs.files", () => {
const result = parseBrainyChatSubmitMessage({
source: "brainy-extension",
type: "brainy:chat-submit",
requestId: "iframe-789",
payload: {
text: "网摘",
prompt: "网摘",
uploadedFiles: [
{
filename: "LLM Wiki 接口说明.md",
size: "7002",
path: "/mnt/user-data/uploads/LLM Wiki 接口说明.md",
status: "uploaded",
artifact_url:
"/api/threads/445ca7a7-9737-44f0-8eea-6887776f4f9c/artifacts/mnt/user-data/uploads/LLM%20Wiki%20%E6%8E%A5%E5%8F%A3%E8%AF%B4%E6%98%8E.md",
},
],
},
});
assert.equal(result.submitText, "网摘");
assert.deepEqual(result.additionalKwargs?.files, [
{
filename: "LLM Wiki 接口说明.md",
size: "7002",
path: "/mnt/user-data/uploads/LLM Wiki 接口说明.md",
status: "uploaded",
artifact_url:
"/api/threads/445ca7a7-9737-44f0-8eea-6887776f4f9c/artifacts/mnt/user-data/uploads/LLM%20Wiki%20%E6%8E%A5%E5%8F%A3%E8%AF%B4%E6%98%8E.md",
},
]);
});
+48 -1
View File
@@ -26,6 +26,14 @@ type BrainyModelSelection = {
value?: string;
};
type BrainyUploadedFile = {
filename?: string;
size?: string | number;
path?: string;
status?: string;
artifact_url?: string;
};
export type BrainyChatSubmitMessage = {
source?: string;
type?: string;
@@ -35,6 +43,7 @@ export type BrainyChatSubmitMessage = {
pageContext?: BrainyPageContext;
screenshot?: BrainyScreenshot;
model?: BrainyModelSelection;
uploadedFiles?: BrainyUploadedFile[];
};
requestId?: string;
};
@@ -253,6 +262,36 @@ function buildScreenshotFileParts(
return [];
}
function normalizeUploadedFiles(
uploadedFiles: BrainyUploadedFile[] | undefined,
): Record<string, unknown>[] {
if (!Array.isArray(uploadedFiles)) {
return [];
}
return uploadedFiles
.map((file) => {
const filename = trimToUndefined(file.filename);
if (!filename) {
return null;
}
const size = String(file.size ?? "0");
const path = trimToUndefined(file.path);
const status = trimToUndefined(file.status);
const artifactUrl = trimToUndefined(file.artifact_url);
return {
filename,
size,
...(path ? { path } : {}),
...(status ? { status } : {}),
...(artifactUrl ? { artifact_url: artifactUrl } : {}),
};
})
.filter((file): file is Record<string, unknown> => file !== null);
}
export function parseBrainyChatSubmitMessage(
data: BrainyChatSubmitMessage,
): ParsedBrainyChatSubmitMessage {
@@ -268,6 +307,7 @@ export function parseBrainyChatSubmitMessage(
});
const requestId = trimToUndefined(data.requestId);
const modelName = trimToUndefined(data.payload?.model?.value);
const uploadedFiles = normalizeUploadedFiles(data.payload?.uploadedFiles);
const iframeContext: Record<string, unknown> = {};
if (requestId) {
@@ -281,6 +321,13 @@ export function parseBrainyChatSubmitMessage(
submitText,
files,
...(Object.keys(iframeContext).length > 0 ? { iframeContext } : {}),
...(promptPrefix ? { additionalKwargs: { prompt_prefix: promptPrefix } } : {}),
...(promptPrefix || uploadedFiles.length > 0
? {
additionalKwargs: {
...(promptPrefix ? { prompt_prefix: promptPrefix } : {}),
...(uploadedFiles.length > 0 ? { files: uploadedFiles } : {}),
},
}
: {}),
};
}
@@ -31,6 +31,8 @@ export interface DraftStep1Snapshot {
}
export interface DraftStep2Snapshot {
activeRunId?: string | null;
runs?: DraftStep2RunSnapshot[];
/**
* The seats actually selected for this session. Restored on load so the
* Step 2/3 「参与角色」panel shows the chosen agents instead of the default 6.
@@ -57,6 +59,27 @@ export interface DraftStep2Snapshot {
chain?: { id: string; title: string } | null;
}
export interface DraftStep2RunSnapshot {
id: string;
title: string;
createdAt: string;
source: "initial" | "recommend" | "chain" | "manual";
selectedAgents?: Array<{ agent_id: string; name: string }>;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: unknown[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
seatStubMessages?: Record<string, unknown[]>;
orchestrationMode?: "recommend" | "chain";
chain?: { id: string; title: string } | null;
step3?: DraftStep3Snapshot | null;
jobId?: string | null;
jobStatus?: JobStatus | null;
}
/**
* Step 3「结果绘制」snapshot — the generated visualization report, stored inline
* so a reloaded draft can show it without depending on the ephemeral report
@@ -18,6 +18,7 @@ import { getAvatarTypeFor } from "../lib/roundtable-constants";
import {
isTerminalJobStatus,
type DispatchChainNode,
type JobDialogue,
type RoundtableJob,
type StartJobPayload,
} from "../lib/job-types";
@@ -65,6 +66,8 @@ function mapJob(raw: Record<string, unknown>): RoundtableJob {
orchestrationMode: (raw.orchestrationMode as RoundtableJob["orchestrationMode"]) ?? "recommend",
chain: (raw.chain as RoundtableJob["chain"]) ?? null,
error: (raw.error as string | null) ?? null,
dialogues: Array.isArray(raw.dialogues) ? (raw.dialogues as JobDialogue[]) : [],
step3: (raw.step3 as RoundtableJob["step3"]) ?? null,
createdAt: (raw.createdAt as string | null) ?? null,
updatedAt: (raw.updatedAt as string | null) ?? null,
};
@@ -5,6 +5,11 @@ import remarkGfm from "remark-gfm";
import { Download, FileText, Loader2, X } from "lucide-react";
import { apiFetch } from "@/core/api/fetch-client";
import {
downloadMermaidSvgFile,
isMermaidFile,
renderMermaidToSvg,
} from "@/core/utils/mermaid";
import {
createMarkdownAnchorResolver,
isInPageAnchor,
@@ -111,6 +116,11 @@ function isMarkdown(mimeType: string | null, name: string): boolean {
return /\.(md|markdown)$/i.test(name);
}
function isMermaid(mimeType: string | null, name: string): boolean {
if (mimeType === "text/vnd.mermaid") return true;
return isMermaidFile(name);
}
export const ArtifactPreviewModal: React.FC<ArtifactPreviewModalProps> = ({
threadId,
path,
@@ -121,6 +131,7 @@ export const ArtifactPreviewModal: React.FC<ArtifactPreviewModalProps> = ({
const [content, setContent] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [mermaidSvg, setMermaidSvg] = useState<string | null>(null);
// Esc closes the modal. Attached once per mount.
useEffect(() => {
@@ -166,11 +177,38 @@ export const ArtifactPreviewModal: React.FC<ArtifactPreviewModalProps> = ({
const handleDownload = useCallback(() => {
if (typeof window === "undefined") return;
if (isMermaid(mimeType, name) && content) {
void downloadMermaidSvgFile(content, name).catch((err) => {
setError((err as Error).message);
});
return;
}
const url = buildArtifactDownloadUrl(threadId, path, { download: true });
window.open(url, "_blank", "noopener,noreferrer");
}, [threadId, path]);
}, [content, mimeType, name, path, threadId]);
const renderAsMarkdown = isMarkdown(mimeType, name);
const renderAsMermaid = isMermaid(mimeType, name);
useEffect(() => {
if (!renderAsMermaid || !content) {
setMermaidSvg(null);
return;
}
let cancelled = false;
void renderMermaidToSvg(content)
.then((svg) => {
if (!cancelled) setMermaidSvg(svg);
})
.catch((err) => {
if (!cancelled) setError((err as Error).message);
});
return () => {
cancelled = true;
};
}, [content, renderAsMermaid]);
const markdownComponents = useMemo(
() => createMarkdownComponents(content ?? ""),
[content],
@@ -227,8 +265,21 @@ export const ArtifactPreviewModal: React.FC<ArtifactPreviewModalProps> = ({
<div className="text-rose-600 text-xs py-6 text-center">
加载失败:{error}
</div>
) : content === null ? null : renderAsMarkdown ? (
) : content === null ? null : renderAsMermaid ? (
mermaidSvg ? (
<div
className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"
dangerouslySetInnerHTML={{ __html: mermaidSvg }}
/>
) : (
<div className="flex items-center justify-center gap-2 py-12 text-slate-400">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<span className="text-xs">正在生成图表预览</span>
</div>
)
) : renderAsMarkdown ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{content}
</ReactMarkdown>
) : (
@@ -67,6 +67,7 @@ export function DispatchChainJobModal({
consensusPercentage={job?.consensusPercentage ?? 0}
onView={job && onView ? () => onView(job) : undefined}
error={job?.error}
dialogues={job?.dialogues}
pendingClarification={job?.pendingClarification}
onResume={
jobId
@@ -14,7 +14,7 @@
* (business-chain.ts)。连线动画 CSS 在 `styles/roundtable-planning.css` 全局段
* (Radix Dialog portal 到 body,不在 .roundtable-planning 作用域内,故用全局类 + 语义 token)。
*/
import { Fragment, useState } from "react";
import { Fragment, useEffect, useRef, useState } from "react";
import { CheckIcon, ChevronRightIcon, EyeIcon, Loader2Icon, SendIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -29,10 +29,13 @@ import {
import { cn } from "@/lib/utils";
import { getAvatar } from "./Avatars";
import { MarkdownContent } from "./MessageBubble";
import { chainTagClasses } from "../lib/business-chain";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
import type {
ChainNodeState,
DispatchChainNode,
JobDialogue,
JobPhase,
JobStatus,
} from "../lib/job-types";
@@ -62,6 +65,8 @@ export interface DispatchChainModalProps {
onCancel?: () => void;
/** 出错详情(status==='error')。 */
error?: string | null;
/** 研讨对话记录(leader 说明 + 各席位交付)。 */
dialogues?: JobDialogue[];
}
const _NON_TERMINAL: ReadonlySet<JobStatus> = new Set<JobStatus>(["queued", "running", "awaiting_input"]);
@@ -99,8 +104,24 @@ export function DispatchChainModal({
onResume,
onCancel,
error,
dialogues,
}: DispatchChainModalProps) {
const [answer, setAnswer] = useState("");
const convoRef = useRef<HTMLDivElement>(null);
// 新对话进来时自动滚到底部。
const dialogueCount = dialogues?.length ?? 0;
useEffect(() => {
const el = convoRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [dialogueCount]);
// 对话气泡的头像配色:总控固定蓝;席位优先取流程图节点色,回退按 agentId 派生。
const avatarForDialogue = (d: JobDialogue): string => {
if (d.role === "leader") return "blue";
const node = nodes.find((n) => n.agentId === d.agentId);
return node?.avatarType || (d.agentId ? getAvatarTypeFor(d.agentId, node?.order ? node.order - 1 : 0) : "blue");
};
// 总控节点:初始化 / 决策派活阶段高亮
const coordinatorState: ChainNodeState =
phase === "initializing" || phase === "leader_thinking" ? "active" : "done";
@@ -120,7 +141,7 @@ export function DispatchChainModal({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[88vh] flex-col gap-0 sm:max-w-5xl">
<DialogContent className="flex max-h-[90vh] w-[94vw] flex-col gap-0 sm:max-w-[1240px]">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
{status === "running" && (
@@ -147,7 +168,7 @@ export function DispatchChainModal({
</DialogHeader>
{/* 横向流程图(横向滚动) */}
<div className="custom-scrollbar min-h-0 flex-1 overflow-x-auto overflow-y-hidden py-10">
<div className="custom-scrollbar shrink-0 overflow-x-auto overflow-y-hidden py-8">
<div className="flex min-w-max items-start gap-0 px-6">
{/* 总控节点 */}
<FlowNode
@@ -192,6 +213,28 @@ export function DispatchChainModal({
</div>
</div>
{/* 研讨对话(leader 说明 + 各席位交付,真网关下即真模型真实对话) */}
{dialogues && dialogues.length > 0 && (
<div
ref={convoRef}
className="custom-scrollbar min-h-0 flex-1 overflow-y-auto border-t px-1 py-3"
>
<div className="space-y-3.5">
{dialogues.map((d) => (
<div key={d.id} className="flex gap-2.5">
<div className="shrink-0">{getAvatar(avatarForDialogue(d), "h-7 w-7")}</div>
<div className="min-w-0 flex-1">
<div className="mb-0.5 text-xs font-semibold text-foreground/80">{d.name}</div>
<div className="prose prose-sm max-w-none break-words text-sm leading-relaxed text-foreground/80 dark:prose-invert">
<MarkdownContent>{d.content || "…"}</MarkdownContent>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* 出错详情 */}
{status === "error" && error && (
<div className="shrink-0 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
@@ -247,10 +290,15 @@ export function DispatchChainModal({
停止
</Button>
)}
<Button onClick={onView} disabled={!onView} className="gap-1.5">
<EyeIcon className="size-4" />
{status === "done" ? "查看报告" : "查看"}
</Button>
{/* 只对「运行中」任务隐藏入口——它正在后台真跑,进入会暂停前端编排、
与后台作业双跑或借此重跑。其它状态(排队 / 待澄清 / 已完成 / 已取消 /
出错)都不是正在跑的后台作业,可正常查看。 */}
{status !== "running" && (
<Button onClick={onView} disabled={!onView} className="gap-1.5">
<EyeIcon className="size-4" />
{status === "done" ? "查看报告" : "查看"}
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
@@ -9,7 +9,6 @@ import {
Eye,
FileText,
Loader2,
Printer,
RefreshCw,
Sparkles,
} from "lucide-react";
@@ -335,16 +334,6 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
<Sparkles className="w-3.5 h-3.5" />
结果绘制
</button>
<button
type="button"
onClick={() => {
if (typeof window !== "undefined") window.print();
}}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer"
>
<Printer className="w-3.5 h-3.5 text-slate-500" />
打印 / 另存 PDF
</button>
<button
type="button"
onClick={() => setReloadKey((k) => k + 1)}
@@ -52,16 +52,23 @@ export function MessageBubble({
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={cn("text-xs font-extrabold text-slate-900", senderNameClassName)}>
<span
className={cn(
"roundtable-chat-message font-extrabold text-slate-900",
senderNameClassName,
)}
>
{senderName}
</span>
{badge && (
<span className="text-[calc(9px*var(--ui-font-scale,1))] font-mono font-black uppercase text-[var(--brand-900)] bg-[var(--brand-200)] border border-red-200 px-1.5 py-0.2 rounded">
<span className="roundtable-chat-meta font-mono font-black uppercase text-[var(--brand-900)] bg-[var(--brand-200)] border border-red-200 px-1.5 py-0.2 rounded">
{badge}
</span>
)}
</div>
<span className="text-[calc(10px*var(--ui-font-scale,1))] text-slate-400 font-mono font-medium shrink-0">{time}</span>
<span className="roundtable-chat-meta text-slate-400 font-mono font-medium shrink-0">
{time}
</span>
</div>
{children}
</div>
@@ -75,7 +82,7 @@ export function MessageBubble({
*/
export function MessageLoadingPlaceholder({ label }: { label?: string }) {
return (
<div className="flex items-center gap-2 py-1.5 text-xs text-slate-600">
<div className="roundtable-chat-message flex items-center gap-2 py-1.5 text-slate-600">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<span className="font-medium">{label ?? "正在连接智能体..."}</span>
<span className="inline-flex items-center gap-0.5">
@@ -112,16 +119,16 @@ export const sharedMarkdownComponents = {
<li className="leading-snug">{children}</li>
),
h1: ({ children }: { children?: React.ReactNode }) => (
<h1 className="text-sm font-bold mt-2 mb-1">{children}</h1>
<h1 className="text-lg font-bold mt-2 mb-1">{children}</h1>
),
h2: ({ children }: { children?: React.ReactNode }) => (
<h2 className="text-sm font-bold mt-2 mb-1">{children}</h2>
<h2 className="text-base font-bold mt-2 mb-1">{children}</h2>
),
h3: ({ children }: { children?: React.ReactNode }) => (
<h3 className="text-xs font-bold mt-1.5 mb-0.5">{children}</h3>
<h3 className="roundtable-chat-message font-bold mt-1.5 mb-0.5">{children}</h3>
),
h4: ({ children }: { children?: React.ReactNode }) => (
<h4 className="text-xs font-bold mt-1 mb-0.5">{children}</h4>
<h4 className="roundtable-chat-message font-bold mt-1 mb-0.5">{children}</h4>
),
strong: ({ children }: { children?: React.ReactNode }) => (
<strong className="font-bold text-slate-900">{children}</strong>
@@ -139,13 +146,13 @@ export const sharedMarkdownComponents = {
const isBlock = /language-/.test(className ?? "");
if (isBlock) {
return (
<pre className="bg-slate-100 text-slate-800 rounded-md p-2 my-1 overflow-x-auto text-[calc(11px*var(--ui-font-scale,1))] font-mono">
<pre className="roundtable-chat-message bg-slate-100 text-slate-800 rounded-md p-2 my-1 overflow-x-auto font-mono">
<code>{children}</code>
</pre>
);
}
return (
<code className="bg-slate-100 text-slate-800 rounded px-1 py-0.5 text-[calc(11px*var(--ui-font-scale,1))] font-mono">
<code className="roundtable-chat-message bg-slate-100 text-slate-800 rounded px-1 py-0.5 font-mono">
{children}
</code>
);
@@ -161,7 +168,7 @@ export const sharedMarkdownComponents = {
</a>
),
table: ({ children }: { children?: React.ReactNode }) => (
<table className="my-1 border-collapse text-[calc(11px*var(--ui-font-scale,1))]">{children}</table>
<table className="roundtable-chat-message my-1 border-collapse">{children}</table>
),
th: ({ children }: { children?: React.ReactNode }) => (
<th className="border border-slate-300 px-1.5 py-0.5 bg-slate-50 font-bold text-left">
@@ -254,7 +261,7 @@ export function ReasoningBlock({
<button
type="button"
onClick={handleToggle}
className="inline-flex items-center gap-1.5 text-[calc(11.5px*var(--ui-font-scale,1))] text-slate-500 hover:text-slate-700 transition-colors cursor-pointer"
className="roundtable-chat-message inline-flex items-center gap-1.5 text-slate-500 hover:text-slate-700 transition-colors cursor-pointer"
>
<BrainIcon className={cn("size-3.5", streaming && "text-primary animate-pulse")} />
<span className={cn(streaming && "animate-pulse")}>{headerLabel}</span>
@@ -265,7 +272,7 @@ export function ReasoningBlock({
{open && (
<div
className={cn(
"rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2 text-[calc(11.5px*var(--ui-font-scale,1))] text-slate-600 leading-relaxed",
"roundtable-chat-message rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2 text-slate-600",
contentClassName,
)}
>
@@ -86,10 +86,15 @@ export function MessageStepsCard({
const display = getStepDisplay(lastReasoning.key, lastReasoning.label, lastReasoning.args);
const Icon = display.icon;
return (
<div className={cn("flex items-center gap-2 text-[calc(11.5px*var(--ui-font-scale,1))] text-slate-500 py-0.5", className)}>
<div
className={cn(
"roundtable-chat-message flex items-center gap-2 text-slate-500 py-0.5",
className,
)}
>
<Icon className="size-3.5 text-slate-400" />
<span>{display.label}</span>
<span className="text-[calc(10px*var(--ui-font-scale,1))] text-slate-400 font-mono">{lastReasoning.ts}</span>
<span className="roundtable-chat-meta text-slate-400 font-mono">{lastReasoning.ts}</span>
</div>
);
}
@@ -108,7 +113,7 @@ export function MessageStepsCard({
>
<ChainOfThoughtStep
label={
<span className="text-[calc(11.5px*var(--ui-font-scale,1))] opacity-60">
<span className="roundtable-chat-message opacity-60">
{showAbove ? "隐藏步骤" : `查看其他 ${priorToolSteps.length} 个步骤`}
</span>
}
@@ -189,9 +194,9 @@ function ToolStep({
: undefined
}
label={
<span className="text-[calc(12px*var(--ui-font-scale,1))] text-slate-700">
<span className="roundtable-chat-message text-slate-700">
<span className="font-medium">{display.label}</span>
<span className="ml-2 text-[calc(10px*var(--ui-font-scale,1))] text-slate-400 font-mono">{step.ts}</span>
<span className="roundtable-chat-meta ml-2 text-slate-400 font-mono">{step.ts}</span>
</span>
}
>
@@ -204,10 +209,10 @@ function ReasoningStep({ step, streaming }: { step: BubbleStep; streaming: boole
const display = getStepDisplay(step.key, step.label, step.args);
const Icon = display.icon;
return (
<div className="flex items-center gap-2 text-[calc(11.5px*var(--ui-font-scale,1))] text-slate-500">
<div className="roundtable-chat-message flex items-center gap-2 text-slate-500">
<Icon className={cn("size-3.5", streaming ? "text-primary animate-pulse" : "text-slate-400")} />
<span>{display.label}</span>
<span className="text-[calc(10px*var(--ui-font-scale,1))] text-slate-400 font-mono">{step.ts}</span>
<span className="roundtable-chat-meta text-slate-400 font-mono">{step.ts}</span>
</div>
);
}
@@ -351,7 +351,7 @@ function Step1Message({
{isStreaming && !hasContent ? (
<MessageLoadingPlaceholder label={msg.phase} />
) : isAgent ? (
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words space-y-2 roundtable-md">
<div className="roundtable-chat-message text-slate-700 font-medium break-words space-y-2">
{introText && <MarkdownContent>{introText}</MarkdownContent>}
{parts.summary && <IntentReadySummaryCard summary={parts.summary} />}
{/*
@@ -422,7 +422,7 @@ function Step1Message({
)}
</div>
) : (
<div className="text-xs text-slate-700 leading-relaxed font-medium whitespace-pre-wrap break-words roundtable-md">
<div className="roundtable-chat-message text-slate-700 font-medium whitespace-pre-wrap break-words">
{msg.content}
</div>
)}
@@ -289,9 +289,9 @@ export function Step2Panel(props: Step2PanelProps) {
)}
</div>
{pendingClarification.context && (
<p className="text-[calc(11px*var(--ui-font-scale,1))] text-amber-800 italic">{pendingClarification.context}</p>
<p className="roundtable-chat-message text-amber-800 italic">{pendingClarification.context}</p>
)}
<p className="text-xs text-slate-800 leading-relaxed font-medium whitespace-pre-wrap break-words">
<p className="roundtable-chat-message text-slate-800 font-medium whitespace-pre-wrap break-words">
{pendingClarification.question}
</p>
{Array.isArray(pendingClarification.options) &&
@@ -310,7 +310,7 @@ export function Step2Panel(props: Step2PanelProps) {
key={`${idx}-${label}`}
type="button"
onClick={() => onSubmitClarification(label)}
className="text-[calc(11px*var(--ui-font-scale,1))] font-bold px-3 py-1.5 rounded-full bg-white border border-amber-300 text-amber-900 hover:bg-amber-100 transition active:scale-95 cursor-pointer"
className="roundtable-chat-message font-bold px-3 py-1.5 rounded-full bg-white border border-amber-300 text-amber-900 hover:bg-amber-100 transition active:scale-95 cursor-pointer"
>
{label}
</button>
@@ -472,7 +472,7 @@ export function Step2Message({
<ReasoningBlock streaming={isStreaming}>{reasoning}</ReasoningBlock>
)}
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words roundtable-md">
<div className="roundtable-chat-message text-slate-700 font-medium break-words">
{isStreaming && !hasContent && <MessageLoadingPlaceholder label={msg.confidence} />}
<MarkdownContent>{content}</MarkdownContent>
{isStreaming && <StreamingCursor />}
@@ -594,17 +594,17 @@ function PresentFileCard({
<div className="flex min-w-0 items-start gap-2">
<FileIcon className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span
className="min-w-0 flex-1 truncate text-[calc(12px*var(--ui-font-scale,1))] font-medium text-slate-700"
className="roundtable-chat-message min-w-0 flex-1 truncate font-medium text-slate-700"
title={filename}
>
{filename}
</span>
</div>
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[calc(10px*var(--ui-font-scale,1))] font-mono font-semibold text-slate-500">
<span className="roundtable-chat-meta rounded bg-slate-100 px-1.5 py-0.5 font-mono font-semibold text-slate-500">
{typeLabel}
</span>
<span className="text-[calc(10px*var(--ui-font-scale,1))] text-slate-400">点击预览</span>
<span className="roundtable-chat-meta text-slate-400">点击预览</span>
</div>
</button>
);
@@ -648,7 +648,7 @@ function shouldPreviewInModal(file: { name: string; mimeType: string | null }):
const mime = file.mimeType ?? "";
if (mime.startsWith("text/")) return true;
if (["application/json"].includes(mime)) return true;
return /\.(md|markdown|txt|json|csv|log|yml|yaml|xml|html?)$/i.test(file.name);
return /\.(md|markdown|mmd|txt|json|csv|log|yml|yaml|xml|html?)$/i.test(file.name);
}
function formatBytes(n: number): string {
@@ -1,44 +1,75 @@
/**
* 圆桌 Step 3 报告里 `react-markdown` 的紧凑渲染组件集(小字号、卡片内排版)。
* 圆桌 Step 3 报告里 `react-markdown` 的渲染组件集(卡片内排版)。
* 抽到独立文件供 `HighFidelityReport` 与 `Step3ReportMode` 共用,避免两者互相 import
* 形成循环依赖。
*/
export const markdownComponents = {
p: ({ children }: any) => <p className="my-1.5 whitespace-pre-wrap leading-relaxed">{children}</p>,
ul: ({ children }: any) => <ul className="list-disc pl-5 my-1.5 space-y-1">{children}</ul>,
ol: ({ children }: any) => <ol className="list-decimal pl-5 my-1.5 space-y-1">{children}</ol>,
li: ({ children }: any) => <li className="leading-snug">{children}</li>,
h1: ({ children }: any) => <h1 className="text-sm font-bold mt-3 mb-1.5">{children}</h1>,
h2: ({ children }: any) => <h2 className="text-sm font-bold mt-3 mb-1.5">{children}</h2>,
h3: ({ children }: any) => <h3 className="text-xs font-bold mt-2 mb-1">{children}</h3>,
h4: ({ children }: any) => <h4 className="text-xs font-bold mt-1.5 mb-1">{children}</h4>,
strong: ({ children }: any) => <strong className="font-bold text-slate-900">{children}</strong>,
p: ({ children }: any) => (
<p className="roundtable-chat-message my-1.5 whitespace-pre-wrap">{children}</p>
),
ul: ({ children }: any) => (
<ul className="roundtable-chat-message list-disc pl-5 my-1.5 space-y-1">{children}</ul>
),
ol: ({ children }: any) => (
<ol className="roundtable-chat-message list-decimal pl-5 my-1.5 space-y-1">{children}</ol>
),
li: ({ children }: any) => <li className="leading-relaxed">{children}</li>,
h1: ({ children }: any) => (
<h1 className="text-lg font-bold mt-3 mb-1.5">{children}</h1>
),
h2: ({ children }: any) => (
<h2 className="text-base font-bold mt-3 mb-1.5">{children}</h2>
),
h3: ({ children }: any) => (
<h3 className="roundtable-chat-message font-bold mt-2 mb-1">{children}</h3>
),
h4: ({ children }: any) => (
<h4 className="roundtable-chat-message font-bold mt-1.5 mb-1">{children}</h4>
),
strong: ({ children }: any) => (
<strong className="font-bold text-slate-900">{children}</strong>
),
em: ({ children }: any) => <em className="italic">{children}</em>,
code: ({ children, className }: any) => {
const isBlock = /language-/.test(className ?? "");
if (isBlock) {
return (
<pre className="bg-slate-100 text-slate-800 rounded-md p-2 my-1 overflow-x-auto text-[11px] font-mono">
<pre className="roundtable-chat-message bg-slate-100 text-slate-800 rounded-md p-2 my-1 overflow-x-auto font-mono">
<code>{children}</code>
</pre>
);
}
return (
<code className="bg-slate-100 text-slate-800 rounded px-1 py-0.5 text-[11px] font-mono">{children}</code>
<code className="roundtable-chat-message bg-slate-100 text-slate-800 rounded px-1 py-0.5 font-mono">
{children}
</code>
);
},
blockquote: ({ children }: any) => (
<blockquote className="border-l-2 border-slate-300 pl-2 my-1 text-slate-600 italic">{children}</blockquote>
<blockquote className="roundtable-chat-message border-l-2 border-slate-300 pl-2 my-1 text-slate-600 italic">
{children}
</blockquote>
),
a: ({ children, href }: any) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline hover:opacity-80">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline hover:opacity-80"
>
{children}
</a>
),
table: ({ children }: any) => <table className="my-2 border-collapse text-[11px]">{children}</table>,
th: ({ children }: any) => (
<th className="border border-slate-300 px-1.5 py-0.5 bg-slate-50 font-bold text-left">{children}</th>
table: ({ children }: any) => (
<table className="roundtable-chat-message my-2 border-collapse">{children}</table>
),
th: ({ children }: any) => (
<th className="border border-slate-300 px-1.5 py-0.5 bg-slate-50 font-bold text-left">
{children}
</th>
),
td: ({ children }: any) => (
<td className="border border-slate-200 px-1.5 py-0.5">{children}</td>
),
td: ({ children }: any) => <td className="border border-slate-200 px-1.5 py-0.5">{children}</td>,
hr: () => <hr className="my-2 border-slate-200" />,
};
@@ -19,7 +19,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { Step1Snapshot } from "@/roundtable-planning/hooks/useStep1Intent";
import type { Step2Snapshot } from "@/roundtable-planning/hooks/useStep2Orchestration";
import {
createDraft,
deleteDraft,
@@ -27,6 +26,7 @@ import {
getDraft,
listDrafts,
updateDraft,
type DraftStep2Snapshot,
type DraftStep3Snapshot,
} from "@/roundtable-planning/api/drafts";
import type { JobStatus } from "@/roundtable-planning/lib/job-types";
@@ -51,13 +51,13 @@ export interface UseDraftPersistenceOptions {
/** 读 Step 1 当前快照(由 useStep1Intent 提供的 getter)。 */
getStep1Snapshot: () => Step1Snapshot;
/** 读 Step 2 当前快照(由 useStep2Orchestration 提供的 getter)。 */
getStep2Snapshot: () => Step2Snapshot;
getStep2Snapshot: () => DraftStep2Snapshot;
/** 读 Step 3「结果绘制」当前快照(无则 null)。 */
getStep3Snapshot?: () => DraftStep3Snapshot | null;
/** 加载草稿时把 Step 1 状态打回去。 */
hydrateStep1: (snapshot: Step1Snapshot) => void;
/** 加载草稿时把 Step 2 状态打回去。 */
hydrateStep2: (snapshot: Step2Snapshot) => void;
hydrateStep2: (snapshot: DraftStep2Snapshot, step3: DraftStep3Snapshot | null) => void;
/** 加载草稿时把 Step 3 结果打回去(无则传 null 清空)。 */
hydrateStep3?: (snapshot: DraftStep3Snapshot | null) => void;
/** 加载草稿前先中断所有 Step 1 / Step 2 in-flight。 */
@@ -187,7 +187,7 @@ export function useDraftPersistence(
}, [refreshList]);
/** 当前会话是否有值得保存的内容。 */
const hasSavableContent = useCallback((step1: Step1Snapshot, step2: Step2Snapshot): boolean => {
const hasSavableContent = useCallback((step1: Step1Snapshot, step2: DraftStep2Snapshot): boolean => {
const hasUserMessage = step1.interactiveMessages.some((m) => m.sender === "user");
return hasUserMessage || step2.step2RoundtableDialogues.length > 0;
}, []);
@@ -350,7 +350,12 @@ export function useDraftPersistence(
suppressStep2AutoSaveRef.current = true;
if (draft.step1) hydrateStep1(draft.step1 as Step1Snapshot);
if (draft.step2) hydrateStep2(draft.step2 as Step2Snapshot);
if (draft.step2) {
hydrateStep2(
draft.step2 as DraftStep2Snapshot,
(draft.step3 as DraftStep3Snapshot | null) ?? null,
);
}
// 恢复 Step 3 报告(无则传 null 清掉上一条草稿残留),进入「结果绘制」即可看回。
hydrateStep3?.((draft.step3 as DraftStep3Snapshot | null) ?? null);
setStep2InitGate(draft.furthestStep >= 2);
@@ -0,0 +1,50 @@
/**
* 侧边栏「多智能体会商」记录用的轻量草稿列表 hook。
*
* 与页面内的 {@link useDraftPersistence} 不同 —— 那个负责完整的「存 / 加载 / 重命名 /
* 删除」编排,依赖 Step1/Step2 快照。侧边栏只需要只读地展示最近草稿 + 重命名 / 删除,
* 所以这里单独包一层,结构对齐 `useAIWritingSessions`(`{ drafts, loading, refresh,
* rename, remove }`)。
*/
import { useCallback, useState } from "react";
import {
deleteDraft,
listDrafts,
updateDraft,
type DraftMeta,
} from "@/roundtable-planning/api/drafts";
export function useRoundtableDrafts() {
const [drafts, setDrafts] = useState<DraftMeta[]>([]);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const data = await listDrafts();
setDrafts(data);
} catch {
// 历史记录非关键,静默忽略
} finally {
setLoading(false);
}
}, []);
const rename = useCallback(async (id: string, title: string) => {
// 只发 title —— 与页面自动保存的「不带 title」互补,是改标题的唯一入口。
const updated = await updateDraft(id, { title });
if (updated) {
setDrafts((prev) =>
prev.map((d) => (d.id === id ? { ...d, title: updated.title } : d)),
);
}
}, []);
const remove = useCallback(async (id: string) => {
await deleteDraft(id);
setDrafts((prev) => prev.filter((d) => d.id !== id));
}, []);
return { drafts, loading, refresh, rename, remove };
}
@@ -336,6 +336,7 @@ export function useStep2Orchestration(
const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>(
DEFAULT_SELECTED_AGENTS,
);
const [initNonce, setInitNonce] = useState(0);
const [threadIds, setThreadIds] = useState<ThreadIdMap | null>(null);
const [coordinatorName, setCoordinatorName] = useState<string | null>(null);
const [isInitializing, setIsInitializing] = useState<boolean>(false);
@@ -1538,7 +1539,7 @@ export function useStep2Orchestration(
// 只关心 currentStep 切换 —— buildInitialIntent / runOrchestration / 等
// 都是 useCallback / ref 包装,变化不应触发重新 init。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentStep]);
}, [currentStep, initNonce]);
// 挂起 / 恢复:与上面的 init effect 独立。`isRoundTableRunning` toggle 时:
// - false → stopOrchestration
@@ -1738,6 +1739,7 @@ export function useStep2Orchestration(
const resetInitGate = useCallback(() => {
step2InitRef.current = false;
setInitNonce((n) => n + 1);
}, []);
const hydrateFromDraft = useCallback(
@@ -45,6 +45,16 @@ export interface DispatchChainNode {
lastSpokeCycle?: number;
}
/** 一条研讨对话记录(leader 说明 / 某席位交付)。 */
export interface JobDialogue {
id: number;
role: "leader" | "seat";
agentId: string | null;
name: string;
content: string;
cycle: number;
}
/** 一条后台作业的完整快照(§2.5)——对齐后端 `RoundtableJobResponse`(camelCase)。 */
export interface RoundtableJob {
id: string;
@@ -59,6 +69,10 @@ export interface RoundtableJob {
orchestrationMode: "recommend" | "chain";
chain: { id: string; title: string } | null;
error: string | null;
/** 研讨对话记录(leader 说明 + 各席位交付),跑完 / 进行中都可看真实内容。 */
dialogues: JobDialogue[];
/** Step3 报告快照(done 时有):{ html, generatedAt, model, summary }。 */
step3: { html: string; generatedAt?: string; model?: string; summary?: string } | null;
createdAt: string | null;
updatedAt: string | null;
}
@@ -55,7 +55,7 @@ export function renderStepArgs(
<ChainOfThoughtSearchResult>{displayName}</ChainOfThoughtSearchResult>
)}
{task && (
<div className="mt-1 max-h-64 overflow-y-auto rounded-md border border-slate-200 bg-white px-2.5 py-1 text-[11px] text-slate-600 leading-relaxed">
<div className="roundtable-chat-message mt-1 max-h-64 overflow-y-auto rounded-md border border-slate-200 bg-white px-2.5 py-1 text-slate-600">
<MarkdownContent>{task}</MarkdownContent>
</div>
)}
@@ -98,7 +98,7 @@ export function renderStepArgs(
case "ask_clarification": {
const question = typeof args.question === "string" ? args.question : "";
return question ? (
<span className="block text-[11px] text-slate-600 line-clamp-2 mt-0.5">
<span className="roundtable-chat-message block text-slate-600 line-clamp-2 mt-0.5">
{question}
</span>
) : null;
@@ -36,6 +36,7 @@ import {
Clipboard,
Clock,
Cpu,
Download,
History,
ListOrdered,
Loader2,
@@ -54,18 +55,26 @@ import { cn } from "@/lib/utils";
import { useModels } from "@/core/models/hooks";
import { getAvatar, Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
import { ImportRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs";
import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog";
import { BusinessChainListDialog } from "@/roundtable-planning/components/BusinessChainListDialog";
import { DispatchChainJobModal } from "@/roundtable-planning/components/DispatchChainJobModal";
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
import { Step2Panel } from "@/roundtable-planning/components/Step2Panel";
import { Step3Panel } from "@/roundtable-planning/components/Step3Panel";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import type {
DraftStep2RunSnapshot,
DraftStep2Snapshot,
DraftStep3Snapshot,
} from "@/roundtable-planning/api/drafts";
import {
useStep1Intent,
type InteractiveMessage,
} from "@/roundtable-planning/hooks/useStep1Intent";
import { useStep2Orchestration } from "@/roundtable-planning/hooks/useStep2Orchestration";
import {
useStep2Orchestration,
type Step2Snapshot,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import { useDraftPersistence } from "@/roundtable-planning/hooks/useDraftPersistence";
import {
BUILTIN_AGENT_META,
@@ -92,6 +101,8 @@ export default function RoundtablePlanningPage() {
const [isChainListOpen, setIsChainListOpen] = useState<boolean>(false);
// 历史下拉点击「进行中」任务 → 打开派活链路流程图弹窗(订阅该作业 SSE)。
const [chainModalJobId, setChainModalJobId] = useState<string | null>(null);
// 「导入会商」弹窗(输入分享码把别人分享的会商复制到本账号)。
const [isImportOpen, setIsImportOpen] = useState<boolean>(false);
const [selectedModel, setSelectedModel] = useState<string>("");
// Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活。
// 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。
@@ -101,11 +112,15 @@ export default function RoundtablePlanningPage() {
// chain 模式来源链条(仅展示/持久化用)。
const [chainMeta, setChainMeta] = useState<{ id: string; title: string } | null>(null);
const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true);
const [step2Runs, setStep2Runs] = useState<DraftStep2RunSnapshot[]>([]);
const [activeStep2RunId, setActiveStep2RunId] = useState<string | null>(null);
// Step 3「结果绘制」结果 —— 持久化进草稿 step3。绘制完成由 onReportSaved 写入;
// 加载草稿时 hydrateStep3 恢复;新建任务清空。step3SavedTick 递增触发一次草稿自动保存。
const [step3Report, setStep3Report] = useState<DraftStep3Snapshot | null>(null);
const [step3SavedTick, setStep3SavedTick] = useState<number>(0);
const step2RunsRef = useRef<DraftStep2RunSnapshot[]>([]);
const activeStep2RunIdRef = useRef<string | null>(null);
// 角色面板(LeftSidebar)。原页面把它当成两步共用的展示数据,这里继续放在
// 主页 —— Step 1 / Step 2 / Step 3 都会读 / 写它,任何 hook 单独持有都不合适。
@@ -207,6 +222,173 @@ export default function RoundtablePlanningPage() {
[step2.step2RoundtableDialogues],
);
useEffect(() => {
step2RunsRef.current = step2Runs;
}, [step2Runs]);
useEffect(() => {
activeStep2RunIdRef.current = activeStep2RunId;
}, [activeStep2RunId]);
const buildCurrentStep2Snapshot = useCallback(
(): DraftStep2Snapshot => ({
selectedAgents: step2.selectedAgents,
threadIds: step2.threadIds,
coordinatorName: step2.coordinatorName,
step2RoundtableDialogues: step2.step2RoundtableDialogues,
lastLeaderContent: step2.lastLeaderContent,
hasConsensus: step2.hasConsensus,
consensusPercentage: step2.consensusPercentage,
budgetLimit: step2.budgetLimit,
seatStubMessages: step2.seatStubMessages,
orchestrationMode,
chain: chainMeta,
}),
[
chainMeta,
orchestrationMode,
step2.budgetLimit,
step2.consensusPercentage,
step2.coordinatorName,
step2.hasConsensus,
step2.lastLeaderContent,
step2.seatStubMessages,
step2.selectedAgents,
step2.step2RoundtableDialogues,
step2.threadIds,
],
);
const snapshotToRun = useCallback(
(
snapshot: DraftStep2Snapshot,
fallback: Partial<Pick<DraftStep2RunSnapshot, "id" | "title" | "createdAt" | "source">> = {},
): DraftStep2RunSnapshot => ({
id: fallback.id ?? `step2-run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
title: fallback.title ?? "第一次生成结果",
createdAt: fallback.createdAt ?? new Date().toISOString(),
source: fallback.source ?? snapshot.orchestrationMode ?? "initial",
selectedAgents: snapshot.selectedAgents,
threadIds: snapshot.threadIds,
coordinatorName: snapshot.coordinatorName,
step2RoundtableDialogues: snapshot.step2RoundtableDialogues ?? [],
lastLeaderContent: snapshot.lastLeaderContent ?? "",
hasConsensus: Boolean(snapshot.hasConsensus),
consensusPercentage: snapshot.consensusPercentage ?? 0,
budgetLimit: snapshot.budgetLimit ?? 0,
seatStubMessages: snapshot.seatStubMessages ?? {},
orchestrationMode: snapshot.orchestrationMode,
chain: snapshot.chain ?? null,
step3: step3Report,
}),
[step3Report],
);
const mergeActiveRun = useCallback(
(
runs: DraftStep2RunSnapshot[],
activeRunId: string | null,
snapshot: DraftStep2Snapshot,
report: DraftStep3Snapshot | null,
): DraftStep2RunSnapshot[] => {
if (!activeRunId) return runs;
const idx = runs.findIndex((run) => run.id === activeRunId);
if (idx === -1) return runs;
const current = runs[idx];
const next = snapshotToRun(snapshot, {
id: current.id,
title: current.title,
createdAt: current.createdAt,
source: current.source,
});
next.step3 = report;
return runs.map((run, i) => (i === idx ? next : run));
},
[snapshotToRun],
);
const getStep2SnapshotWithRuns = useCallback((): DraftStep2Snapshot => {
const snapshot = buildCurrentStep2Snapshot();
const runs = mergeActiveRun(
step2RunsRef.current,
activeStep2RunIdRef.current,
snapshot,
step3Report,
);
return {
...snapshot,
runs,
activeRunId: activeStep2RunIdRef.current,
};
}, [buildCurrentStep2Snapshot, mergeActiveRun, step3Report]);
const normalizeStep2Runs = useCallback(
(
snapshot: DraftStep2Snapshot,
fallbackStep3: DraftStep3Snapshot | null,
): { runs: DraftStep2RunSnapshot[]; activeRunId: string | null } => {
if (snapshot.runs?.length) {
const activeRunId = snapshot.activeRunId ?? snapshot.runs[0]?.id ?? null;
const runs = snapshot.runs.map((run) =>
run.id === activeRunId && !run.step3 && fallbackStep3
? { ...run, step3: fallbackStep3 }
: run,
);
return {
runs,
activeRunId,
};
}
const hasLegacyContent =
(snapshot.step2RoundtableDialogues?.length ?? 0) > 0 ||
Boolean(snapshot.threadIds) ||
Boolean(snapshot.lastLeaderContent);
if (!hasLegacyContent) return { runs: [], activeRunId: null };
const legacy = snapshotToRun(snapshot, {
id: "legacy-run",
title: "第一次生成结果",
source: snapshot.orchestrationMode ?? "initial",
});
legacy.step3 = fallbackStep3;
return { runs: [legacy], activeRunId: legacy.id };
},
[snapshotToRun],
);
const hydrateStep2Run = useCallback(
(run: DraftStep2RunSnapshot) => {
setOrchestrationMode(run.orchestrationMode ?? "recommend");
setChainMeta(run.chain ?? null);
setStep3Report(run.step3 ?? null);
step2.hydrateFromDraft(run as unknown as Step2Snapshot);
},
[step2],
);
const handleSelectStep2Run = useCallback(
(runId: string) => {
if (runId === activeStep2RunIdRef.current) return;
step2.stopOrchestration();
const currentSnapshot = buildCurrentStep2Snapshot();
const syncedRuns = mergeActiveRun(
step2RunsRef.current,
activeStep2RunIdRef.current,
currentSnapshot,
step3Report,
);
const target = syncedRuns.find((run) => run.id === runId);
if (!target) return;
setStep2Runs(syncedRuns);
setActiveStep2RunId(runId);
hydrateStep2Run(target);
setCurrentStep(2);
},
[buildCurrentStep2Snapshot, hydrateStep2Run, mergeActiveRun, step2, step3Report],
);
// 草稿 hook —— 同时依赖 step1 + step2 的 snapshot / hydrate。
const drafts = useDraftPersistence({
currentStep,
@@ -223,26 +405,23 @@ export default function RoundtablePlanningPage() {
interactiveMessages: step1.interactiveMessages,
timelineEntries: step1.timelineEntries,
}),
getStep2Snapshot: () => ({
selectedAgents: step2.selectedAgents,
threadIds: step2.threadIds,
coordinatorName: step2.coordinatorName,
step2RoundtableDialogues: step2.step2RoundtableDialogues,
lastLeaderContent: step2.lastLeaderContent,
hasConsensus: step2.hasConsensus,
consensusPercentage: step2.consensusPercentage,
budgetLimit: step2.budgetLimit,
seatStubMessages: step2.seatStubMessages,
orchestrationMode,
chain: chainMeta,
}),
getStep2Snapshot: getStep2SnapshotWithRuns,
hydrateStep1: step1.hydrateFromDraft,
// 包一层:先把页面持有的编排模式 / 链条恢复出来(老草稿无字段 → recommend),
// 再交给 step2 hook 恢复其余 Step 2 状态。
hydrateStep2: (snapshot) => {
hydrateStep2: (snapshot, savedStep3) => {
const normalized = normalizeStep2Runs(snapshot, savedStep3);
setStep2Runs(normalized.runs);
setActiveStep2RunId(normalized.activeRunId);
const active =
normalized.runs.find((run) => run.id === normalized.activeRunId) ?? null;
if (active) {
hydrateStep2Run(active);
return;
}
setOrchestrationMode(snapshot.orchestrationMode ?? "recommend");
setChainMeta(snapshot.chain ?? null);
step2.hydrateFromDraft(snapshot);
step2.hydrateFromDraft(snapshot as unknown as Step2Snapshot);
},
cancelAllStreams: () => {
step2.abortAllInFlight();
@@ -267,6 +446,30 @@ export default function RoundtablePlanningPage() {
loadedFromDraftRefHolder.current = drafts.loadedFromDraftRef.current;
});
// ─── 从侧边栏「多智能体会商」记录进入 ───────────────────────
// 侧边栏点击某条草稿时:不在本页 → 把 id 存进 sessionStorage 再跳转,挂载时消费;
// 已在本页 → 直接派 `roundtable:open-draft` 事件。两条路径都走 handleLoadDraft。
// 用 ref 持有最新 handleLoadDraft,让下面的 effect 保持「挂载只跑一次」,避免
// handleLoadDraft 身份变化导致重复消费 / 重复加载。
const handleLoadDraftRef = useRef(drafts.handleLoadDraft);
useEffect(() => {
handleLoadDraftRef.current = drafts.handleLoadDraft;
}, [drafts.handleLoadDraft]);
useEffect(() => {
const pending = sessionStorage.getItem("roundtable:pending-draft");
if (pending) {
sessionStorage.removeItem("roundtable:pending-draft");
handleLoadDraftRef.current(pending);
}
const onOpenDraft = (e: Event) => {
const id = (e as CustomEvent<string>).detail;
if (id) handleLoadDraftRef.current(id);
};
window.addEventListener("roundtable:open-draft", onOpenDraft);
return () => window.removeEventListener("roundtable:open-draft", onOpenDraft);
}, []);
// ─── 角色面板转场 ────────────────────────────────────────
// Step 1: 仅指挥员 + 任务理解智能体两行(状态跟随 intent init)。
// Step 2: 完整席位(loadedFromDraft 时直接 "在线",否则 "正在上线")。
@@ -309,6 +512,38 @@ export default function RoundtablePlanningPage() {
// 内置 agent 走 BUILTIN_AGENT_META;非内置用候选池传过来的 name。
name: BUILTIN_AGENT_META[agent_id]?.displayName ?? name ?? agent_id,
}));
step2.stopOrchestration();
const currentSnapshot = buildCurrentStep2Snapshot();
const syncedRuns = mergeActiveRun(
step2RunsRef.current,
activeStep2RunIdRef.current,
currentSnapshot,
step3Report,
);
const nextRunNumber = syncedRuns.length + 1;
const newRunId = `step2-run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const newRun: DraftStep2RunSnapshot = {
id: newRunId,
title: `第 ${nextRunNumber} 次生成结果`,
createdAt: new Date().toISOString(),
source: result.mode,
selectedAgents: normalized,
threadIds: null,
coordinatorName: null,
step2RoundtableDialogues: [],
lastLeaderContent: "",
hasConsensus: false,
consensusPercentage: 0,
budgetLimit: step2.budgetLimit,
seatStubMessages: {},
orchestrationMode: result.mode,
chain: result.mode === "chain" ? result.chain ?? null : null,
step3: null,
};
setStep2Runs([...syncedRuns, newRun]);
setActiveStep2RunId(newRunId);
setStep3Report(null);
step2.resetStep2State();
step2.setSelectedAgents(normalized);
step2.resetInitGate();
// result.mode 决定 Step 2 编排:recommend = 总控自由派活;chain = 按链条顺序派活。
@@ -322,7 +557,7 @@ export default function RoundtablePlanningPage() {
: "成功确认!已切换至第二阶段圆桌研讨系统",
);
},
[step2, triggerToast],
[buildCurrentStep2Snapshot, mergeActiveRun, step2, step3Report, triggerToast],
);
// 把页面重置回「第一步全新任务」——新建任务 + 后台挂起后都复用。
@@ -331,6 +566,8 @@ export default function RoundtablePlanningPage() {
const resetToStep1 = useCallback(() => {
step2.stopOrchestration();
step2.resetStep2State();
setStep2Runs([]);
setActiveStep2RunId(null);
setOrchestrationMode("recommend");
setChainMeta(null);
setStep3Report(null);
@@ -374,8 +611,8 @@ export default function RoundtablePlanningPage() {
model: selectedModel || undefined, // 模型沿用第二步选择
orchestrationMode,
chain: chainMeta,
threadIds: step2.threadIds ?? undefined,
coordinatorName: step2.coordinatorName ?? undefined,
// 不传 threadIds:后台作业由真网关自建全新线程,绝不复用前端本地研讨的线程
// (那些线程可能有活跃 run,复用会撞 409)。
});
}
} catch (err) {
@@ -451,6 +688,16 @@ export default function RoundtablePlanningPage() {
}}
/>
{/* 导入会商弹窗:输入分享码 → 复制副本 → 加载到当前页面继续研讨 */}
<ImportRoundtableDraftDialog
open={isImportOpen}
onOpenChange={setIsImportOpen}
onImported={(draftId) => {
void drafts.refresh();
if (draftId) drafts.handleLoadDraft(draftId);
}}
/>
{/* 派活链路流程图弹窗(订阅进行中作业的真实 SSE 进度) */}
<DispatchChainJobModal
jobId={chainModalJobId}
@@ -509,42 +756,6 @@ export default function RoundtablePlanningPage() {
新建任务
</button>
{currentStep !== 2 && (
<>
<button
id="start-clear-btn"
onClick={() => {
setCurrentStep(1);
triggerToast("已切换至第一阶段:人机问答意图澄清");
}}
className="bg-primary text-white hover:bg-[var(--brand-900)] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition-all shadow-sm shadow-primary/10 active:scale-95"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
开始澄清
</button>
<button
id="human-intervention-btn"
onClick={() => {
setRoles((prev) =>
prev.map((r) =>
r.id === 1
? { ...r, status: r.status === "在线" ? "等待激活" : "在线" }
: r,
),
);
triggerToast("指挥专家状态变更!人工介入切换");
}}
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition-all dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<Users className="w-3.5 h-3.5 text-slate-500 dark:text-muted-foreground" />
人工介入
</button>
</>
)}
<button
id="save-draft-header-btn"
onClick={drafts.handleManualSave}
@@ -554,6 +765,19 @@ export default function RoundtablePlanningPage() {
保存草稿
</button>
{/* 「导入会商」按钮先隐藏(保留代码 + 弹窗逻辑,便于以后恢复)。 */}
{false && (
<button
id="import-roundtable-btn"
onClick={() => setIsImportOpen(true)}
title="输入分享码,把别人分享的会商复制到我的账号"
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<Download className="w-3.5 h-3.5 text-slate-500" />
导入会商
</button>
)}
<HistoryDropdown
innerRef={drafts.historyDropdownRef}
isOpen={drafts.isHistoryOpen}
@@ -603,6 +827,9 @@ export default function RoundtablePlanningPage() {
intentReady={step1.intentReady}
sidebarExpanded={step2SidebarExpanded}
setSidebarExpanded={setStep2SidebarExpanded}
step2Runs={step2Runs}
activeStep2RunId={activeStep2RunId}
onSelectStep2Run={handleSelectStep2Run}
/>
{/* MIDDLE VIEWPORT */}
@@ -700,6 +927,11 @@ export default function RoundtablePlanningPage() {
savedReport={step3Report}
onReportSaved={(snap) => {
setStep3Report(snap);
setStep2Runs((prev) =>
prev.map((run) =>
run.id === activeStep2RunIdRef.current ? { ...run, step3: snap } : run,
),
);
setStep3SavedTick((t) => t + 1);
}}
onReset={handleStartNewTask}
@@ -1027,6 +1259,9 @@ interface LeftSidebarProps {
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
sidebarExpanded: boolean;
setSidebarExpanded: React.Dispatch<React.SetStateAction<boolean>>;
step2Runs: DraftStep2RunSnapshot[];
activeStep2RunId: string | null;
onSelectStep2Run: (runId: string) => void;
}
function Step2SandboxSidebarSync({
@@ -1063,6 +1298,9 @@ function LeftSidebar({
intentReady,
sidebarExpanded,
setSidebarExpanded,
step2Runs,
activeStep2RunId,
onSelectStep2Run,
}: LeftSidebarProps) {
const isStep2 = currentStep === 2;
@@ -1116,6 +1354,11 @@ function LeftSidebar({
</div>
<HumanParticipationCard />
<Step2RunSwitcher
runs={step2Runs}
activeRunId={activeStep2RunId}
onSelect={onSelectStep2Run}
/>
<TaskOverviewCard currentStep={currentStep} />
</>
) : (
@@ -1170,6 +1413,94 @@ function LeftSidebar({
);
}
function Step2RunSwitcher({
runs,
activeRunId,
onSelect,
}: {
runs: DraftStep2RunSnapshot[];
activeRunId: string | null;
onSelect: (runId: string) => void;
}) {
if (runs.length === 0) return null;
const formatTime = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
};
const sourceLabel = (run: DraftStep2RunSnapshot) => {
if (run.source === "chain") return "业务链条";
if (run.source === "recommend") return "智能体推荐";
return "历史结果";
};
const statusLabel = (run: DraftStep2RunSnapshot) => {
if (run.step3) return "已生成报告";
if (run.hasConsensus) return "已达成共识";
if ((run.step2RoundtableDialogues?.length ?? 0) > 0) return "研讨记录";
return "待开始";
};
return (
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
<ListOrdered className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">生成结果</h2>
</div>
<div className="space-y-2">
{runs.map((run) => {
const active = run.id === activeRunId;
return (
<button
key={run.id}
type="button"
onClick={() => onSelect(run.id)}
className={cn(
"w-full rounded-xl border p-2.5 text-left transition-colors",
active
? "border-primary/40 bg-primary/5 text-slate-900"
: "border-slate-100 bg-slate-50/60 text-slate-600 hover:border-primary/20 hover:bg-white",
)}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-xs font-extrabold">{run.title}</span>
{active ? (
<CheckCircle className="h-3.5 w-3.5 shrink-0 text-primary" />
) : null}
</div>
<div className="mt-1.5 flex items-center justify-between gap-2 text-[10px] font-semibold">
<span className="truncate text-slate-400">{sourceLabel(run)}</span>
<span className="shrink-0 text-slate-400">{formatTime(run.createdAt)}</span>
</div>
<div className="mt-2 flex items-center gap-1.5">
<span
className={cn(
"h-1.5 w-1.5 rounded-full",
run.step3
? "bg-emerald-500"
: run.hasConsensus
? "bg-primary"
: "bg-slate-300",
)}
/>
<span className="text-[10px] font-bold text-slate-500">{statusLabel(run)}</span>
</div>
</button>
);
})}
</div>
</div>
);
}
function TaskOverviewCard({ currentStep }: { currentStep: number }) {
return (
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
@@ -1212,17 +1543,17 @@ function HumanParticipationCard() {
<Activity className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">人工参与</h2>
</div>
<div className="flex items-center justify-between p-2 rounded-xl bg-slate-50 border border-slate-100">
<div className="flex items-center gap-2.5">
<Man1Avatar size="h-7 w-7" />
<span className="text-xs font-bold text-slate-800">用户</span>
<div className="flex min-h-[56px] items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50 p-3">
<div className="flex min-w-0 items-center gap-3">
<Man1Avatar size="h-10 w-10" />
<span className="truncate text-sm font-bold leading-5 text-slate-800">用户</span>
</div>
<div className="flex items-center gap-1.5">
<div className="flex shrink-0 items-center gap-1.5 rounded-full border border-white/70 bg-white/70 px-2 py-1">
<span className="flex h-1.5 w-1.5 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500"></span>
</span>
<span className="text-[10px] font-bold text-emerald-600">在线</span>
<span className="whitespace-nowrap text-xs font-bold leading-none text-emerald-600">在线</span>
</div>
</div>
</div>
@@ -1326,32 +1657,39 @@ function IntentSummaryCard({
{intentReady ? (
<>
<ul className="text-xs text-slate-700 space-y-2.5 font-normal">
<li className="flex items-baseline gap-2 leading-relaxed">
<span className="text-primary text-base leading-none">•</span>
<span>
<li className="flex items-start gap-2 leading-relaxed">
<span className="text-primary text-base leading-none pt-0.5">•</span>
<span
className={cn(
"min-w-0 leading-relaxed",
collapsible && !expanded && "line-clamp-3",
)}
>
<b className="text-slate-900 font-bold">目标</b>: {intentReady.objective}
</span>
</li>
</ul>
{collapsible && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex items-center gap-0.5 text-[10px] font-bold text-slate-500 hover:text-primary transition-colors"
aria-expanded={expanded}
>
{expanded ? (
<>
收起
<ChevronUp className="w-3.5 h-3.5" />
</>
) : (
<>
展开
<ChevronDown className="w-3.5 h-3.5" />
</>
)}
</button>
<div className="flex justify-end">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="inline-flex items-center gap-0.5 text-xs font-bold text-slate-500 hover:text-primary transition-colors"
aria-expanded={expanded}
>
{expanded ? (
<>
收起
<ChevronUp className="w-3.5 h-3.5" />
</>
) : (
<>
展开
<ChevronDown className="w-3.5 h-3.5" />
</>
)}
</button>
</div>
)}
{showFullDetail && (
<ul className="text-xs text-slate-700 space-y-2.5 font-normal">
@@ -1492,7 +1830,7 @@ function RoleRow({
return (
<div
id={`role-item-${role.id}`}
className={`flex items-center justify-between p-2 rounded-xl transition ${
className={`flex min-h-[60px] items-center justify-between gap-3 rounded-xl p-3 transition ${
isActive
? "bg-slate-50 border border-slate-100"
: isBootingUp
@@ -1500,17 +1838,19 @@ function RoleRow({
: "opacity-60"
}`}
>
<div className="flex items-center gap-2.5">
<RoleAvatarBadge role={role} agentNameToRole={agentNameToRole} />
<div className="flex flex-col">
<span className="text-xs font-bold text-slate-800 tracking-tight">{role.name}</span>
<div className="flex min-w-0 items-center gap-3">
<RoleAvatarBadge role={role} agentNameToRole={agentNameToRole} size="h-10 w-10" />
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-bold leading-5 tracking-tight text-slate-800">
{role.name}
</span>
{role.type === "agent" && (
<span className="text-[9px] text-primary font-medium leading-none">AI 智能体</span>
<span className="text-[11px] text-primary font-medium leading-4">AI 智能体</span>
)}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<div className="flex shrink-0 items-center gap-1.5 rounded-full border border-white/70 bg-white/70 px-2 py-1">
{isOngoing ? (
<span className="flex h-1.5 w-1.5 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary/60 opacity-75"></span>
@@ -1529,7 +1869,7 @@ function RoleRow({
<span className="h-1.5 w-1.5 rounded-full bg-slate-300" />
)}
<span
className={`text-[10px] font-semibold ${
className={`whitespace-nowrap text-xs font-semibold leading-none ${
isOngoing
? "text-[var(--brand-900)]"
: role.status === "在线"
@@ -1592,23 +1932,26 @@ function RightSidebar({
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">澄清记录</h2>
</div>
<div className="flex-1 overflow-y-auto pr-1 space-y-4 font-normal custom-scrollbar">
<div className="relative pl-4 border-l border-red-50 space-y-4 ml-2 pt-1.5 pb-2">
<div className="flex-1 overflow-y-auto pr-1 font-normal custom-scrollbar">
<div className="relative ml-2 space-y-4 border-l border-red-50 pb-2 pl-4 pt-1.5">
{timelineEntries.map((entry) => (
<div key={entry.id} className="relative">
<div className="absolute -left-[20.5px] top-0.5 h-3 w-3 rounded-full bg-primary border-2 border-white ring-2 ring-[var(--brand-200)]" />
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<span className="text-[10px] text-slate-400 font-mono tracking-tight shrink-0">
{entry.time}
</span>
<h4 className="text-[11.5px] font-bold text-slate-800 tracking-tight leading-none">
<div
key={entry.id}
className="relative rounded-lg px-1.5 py-1 transition-colors hover:bg-slate-50/80"
>
<div className="absolute -left-[20.5px] top-2 h-3 w-3 rounded-full bg-primary border-2 border-white ring-2 ring-[var(--brand-200)]" />
<div className="grid grid-cols-[68px_minmax(0,1fr)] gap-x-2">
<span className="font-mono text-xs leading-5 tracking-tight text-slate-400">
{entry.time}
</span>
<div className="min-w-0">
<h4 className="break-words text-sm font-bold leading-5 tracking-tight text-slate-800">
{entry.title}
</h4>
<p className="mt-1 break-words text-xs leading-5 text-slate-500">
{entry.desc}
</p>
</div>
<p className="text-[11px] text-slate-500 leading-normal pl-0.5 mt-0.5 font-sans">
{entry.desc}
</p>
</div>
</div>
))}
@@ -10,6 +10,43 @@
--rt-primary-active: var(--brand-950, #a8071a);
--rt-primary-soft: var(--brand-200, #feeded);
--rt-page-bg: #fef6f6;
/* 圆桌全页最小可读字号(含 Step 3 方案展示 / 结果绘制) */
--roundtable-chat-font-size: 16px;
--roundtable-chat-meta-font-size: 16px;
font-size: 16px;
}
/* 会商对话区:气泡正文、步骤卡、Markdown 块 */
.roundtable-chat-message {
font-size: var(--roundtable-chat-font-size);
line-height: 1.625;
}
.roundtable-chat-meta {
font-size: var(--roundtable-chat-meta-font-size);
line-height: 1.5;
}
/*
* 抬升页内显式写死的小字号类(text-xs / text-sm / text-[10–14px] / calc),
* 避免 Step 3、侧栏、按钮条等遗漏逐文件改 class。text-base 及以上标题保持不变。
*/
.roundtable-planning :where(.text-xs, .text-sm) {
font-size: 16px;
}
.roundtable-planning :where(
[class*="text-[9px"],
[class*="text-[10px"],
[class*="text-[10.5px"],
[class*="text-[11px"],
[class*="text-[11.5px"],
[class*="text-[12px"],
[class*="text-[13px"],
[class*="text-[14px"],
[class*="text-[calc"]
):not(.w-5) {
font-size: 16px;
}
.roundtable-planning ::-webkit-scrollbar-thumb {
@@ -1,9 +1,9 @@
import React, { useState } from 'react';
import { ChevronRight, FileText } from 'lucide-react';
import { Pagination } from 'tdesign-react';
import { useTheme } from '@/contexts/ThemeContext';
import { TaskListItem } from './TaskManagement';
import { ChevronRight, FileText } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { TaskCard } from './TaskCard';
import { Pagination } from 'tdesign-react';
interface KanbanColumnProps {
title: string;
@@ -11,64 +11,73 @@ interface KanbanColumnProps {
color: string;
tasks: TaskListItem[];
taskType: string;
TaskDataType?: string; // 区分来源类型
TaskDataType?: string;
pageIndexSearch: (taskType: string, pageIndex: number) => void;
handleStartAnalysis: (task: TaskListItem) => void
handleStartAnalysis: (task: TaskListItem) => void;
}
export const KanbanColumn: React.FC<KanbanColumnProps> = ({ title, count, color, tasks,taskType, TaskDataType = 'task' , pageIndexSearch, handleStartAnalysis }) => {
export const KanbanColumn: React.FC<KanbanColumnProps> = ({
title,
count,
color,
tasks,
taskType,
TaskDataType = 'task',
pageIndexSearch,
handleStartAnalysis,
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
const textMuted = isDark ? 'text-slate-500' : 'text-gray-400';
// 当前页码,默认为第 1 页
const [pageNum, setPageNum] = useState<number>(1);
// 每页显示数量,默认为 10 条
const [pageSize, setPageSize] = useState<number>(10); // 每页显示 10 条
/**
* 页码变化时的回调函数
* 当用户切换页码时触发,更新页码并重新获取数据
* @param value - 新的页码值
*/
const [pageNum, setPageNum] = useState(1);
const pageIndexChange = async (value: number) => {
// 更新 ref 中存储的页码值
// 更新页码状态,触发组件重新渲染
console.log('taskType', taskType)
setPageNum(value);
await pageIndexSearch(taskType, value);
}
};
/**
* 每页显示数量变化时的回调函数
* 当用户切换每页显示条数时触发,更新每页数量并重新获取数据
* @param value - 新的每页显示数量
*/
const pageSizeChange = async (value: number) => {
// 更新 ref 中存储的每页显示数量
// 更新每页显示数量状态,触发组件重新渲染
setPageSize(value);
}
return (
<div className="flex flex-col min-h-0 flex-1">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${color}`} />
<h2 className={`font-semibold text-sm ${textPrimary}`}>{title}</h2>
<span className={`px-2 py-0.5 rounded-full text-xs ${isDark ? 'bg-slate-700/50 text-slate-400' : 'bg-red-50 text-red-500'
}`}>{count}</span>
<div
className={`theme-transition flex h-full min-h-0 flex-col rounded-[28px] border p-4 md:p-5 ${
isDark
? 'theme-card border-[rgba(110,136,176,0.24)] shadow-[0_18px_50px_rgba(2,8,20,0.24)]'
: 'border-slate-200 bg-white shadow-sm'
}`}
>
<div className="mb-4 flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className={`mt-1 h-2.5 w-2.5 rounded-full ${color}`} />
<div>
<div className="flex items-center gap-2">
<h2 className={`text-base font-semibold ${isDark ? 'text-slate-50' : 'text-slate-900'}`}>{title}</h2>
<span
className={`rounded-full px-2 py-0.5 text-xs ${
isDark ? 'bg-[rgba(20,47,86,0.92)] text-slate-300' : 'bg-slate-100 text-slate-600'
}`}
>
{count}
</span>
</div>
<p className={`mt-1 text-xs ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>
按时间倒序展示,支持分页切换。
</p>
</div>
</div>
{/* <button className={`p-1 rounded transition-colors ${isDark ? 'hover:bg-slate-700/50' : 'hover:bg-red-50'
}`}>
<ChevronRight className={`w-4 h-4 ${textMuted}`} />
</button> */}
<iframe></iframe>
<div>
<div className="flex items-center gap-2">
<div
className={`hidden rounded-xl p-2 lg:flex ${
isDark ? 'bg-[rgba(10,30,54,0.82)] text-slate-300' : 'bg-slate-50 text-slate-500'
}`}
>
<ChevronRight className="h-4 w-4" />
</div>
<Pagination
className="sentiment-pagination"
total={count}
size="small"
theme="simple"
current={pageNum}
pageSize={pageSize}
pageSize={10}
maxPageBtn={3}
showPageSize={false}
totalContent={false}
@@ -76,16 +85,39 @@ export const KanbanColumn: React.FC<KanbanColumnProps> = ({ title, count, color,
/>
</div>
</div>
<div className="flex-1 overflow-y-auto space-y-3 pr-1 scrollbar-thin">
<div className="scrollbar-thin flex-1 space-y-3 overflow-y-auto pr-1">
{tasks.length === 0 ? (
<div className={`flex flex-col items-center justify-center py-8 ${textMuted}`}>
<FileText className="w-8 h-8 mb-2 opacity-50" />
<p className="text-sm">暂无任务</p>
<div
className={`flex h-full min-h-[260px] flex-col items-center justify-center rounded-2xl border border-dashed ${
isDark
? 'border-[rgba(110,136,176,0.24)] bg-[rgba(9,25,46,0.38)]'
: 'border-slate-200 bg-slate-50'
}`}
>
<div
className={`mb-3 flex h-12 w-12 items-center justify-center rounded-2xl ${
isDark ? 'bg-[rgba(13,42,78,0.92)] text-slate-300' : 'bg-white text-slate-500'
}`}
>
<FileText className="h-5 w-5" />
</div>
<div className={`text-sm ${isDark ? 'text-slate-300' : 'text-slate-600'}`}>暂无任务</div>
<div className={`mt-1 text-xs ${isDark ? 'text-slate-500' : 'text-slate-400'}`}>
可以尝试调整筛选条件或切换数据模式。
</div>
</div>
) : (
tasks.map(task => <TaskCard key={task.id} task={task} TaskType={TaskDataType} handleStartAnalysis={handleStartAnalysis}/>)
tasks.map((task) => (
<TaskCard
key={task.id}
task={task}
TaskType={TaskDataType}
handleStartAnalysis={handleStartAnalysis}
/>
))
)}
</div>
</div>
);
}
};
@@ -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<TaskListPageProps> = ({ 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<ColumnType, TaskListItem[]> = {
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<TaskListPageProps> = ({ 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<string>('all');
const [showAnalysisModal, setShowAnalysisModal] = useState(false);
const [selectedTaskForAnalysis, setSelectedTaskForAnalysis] = useState<TaskListItem | null>(null);
const [unanalyzedTotal, setUnanalyzedTotal] = useState<number>(0); // 未分析
const [runningTotal, setRunningTotal] = useState<number>(0); // 分析中
const [completedTotal, setCompletedTotal] = useState<number>(0); // 已完成
const [unanalyzedList, setUnanalyzedList] = useState<TaskListItem[]>([]); // 未分析
const [runningList, setRunningList] = useState<TaskListItem[]>([]); // 分析中
const [completedList, setCompletedList] = useState<TaskListItem[]>([]); // 已完成
const [unanalyzedLoading, setUnanalyzedLoading] = useState<boolean>(false); // 未分析
const [runningLoading, setRunningLoading] = useState<boolean>(false); // 分析中
const [completedLoading, setCompletedLoading] = useState<boolean>(false); // 已完成
const [presets] = useState<DateRangePickerProps['presets']>({
最近7天: [dayjs().subtract(6, 'day').toDate(), dayjs().toDate()],
最近3天: [dayjs().subtract(2, 'day').toDate(), dayjs().toDate()],
今天: [dayjs().toDate(), dayjs().toDate()],
});
const [range, setRange] = useState<DateRangeValue>([]);
const state = useRef<stateItem>({
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<TaskListItem[]>([]);
const [runningList, setRunningList] = useState<TaskListItem[]>([]);
const [completedList, setCompletedList] = useState<TaskListItem[]>([]);
const [unanalyzedLoading, setUnanalyzedLoading] = useState(false);
const [runningLoading, setRunningLoading] = useState(false);
const [completedLoading, setCompletedLoading] = useState(false);
const [dateRange, setDateRange] = useState<DateRangeValue>([
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 (
<div className="h-full flex flex-col">
{/* 顶部工具栏 */}
<div className={`rounded-xl p-4 flex-shrink-0 mb-4 ${cardBg} border ${cardBorder}`}>
<div className="flex items-center justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-blue-500/20 text-blue-400' : 'bg-red-100 text-red-600'}`}>
{category}
</span>
<span className={`text-xs ${textMuted}`}>/</span>
<span className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-cyan-500/20 text-cyan-400' : 'bg-red-100 text-red-600'}`}>
{subCategory}
</span>
</div>
<h1 className={`text-xl font-bold ${textPrimary}`}>任务中心</h1>
</div>
{/* <button
onClick={() => onNavigate('task-create')}
className={`flex items-center gap-2 px-4 py-2 text-white rounded-lg text-sm font-medium transition-colors ${isDark
? 'bg-blue-600 hover:bg-blue-500'
: 'bg-red-600 hover:bg-red-500 shadow-md'
}`}
>
<Plus className="w-4 h-4" />
新建任务
</button> */}
</div>
{/* 搜索和筛选 */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${textMuted}`} />
<input
type="text"
placeholder="搜索任务名称"
value={searchQuery}
onChange={(e) => 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}`}
/>
</div>
{/* <select
value={modeFilter}
onChange={(e) => setModeFilter(e.target.value)}
className={`px-4 py-2 border rounded-lg text-sm focus:outline-none cursor-pointer ${inputBg} ${cardBorder} ${textSecondary}`}
>
<option value="all">全部模式</option>
<option value="single">单智能体</option>
<option value="multi-agent">多智能体协同</option>
<option value="consultation">多智能体会商</option>
<option value="scene">场景执行</option>
</select> */}
{/* <button className={`flex items-center gap-2 px-4 py-2 border rounded-lg text-sm transition-colors ${inputBg} ${cardBorder} ${textSecondary}`}>
<Clock className="w-4 h-4" />
时间范围
</button> */}
<DateRangePicker value={range} presets={presets} onChange={(val) => setRangeChange(val)} enableTimePicker={true} clearable />
</div>
</div>
{/* 统计概览栏 */}
<div className="grid grid-cols-4 gap-4 mb-4 flex-shrink-0">
<button
onClick={() => { setModeFilter('all'); }}
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
>
<p className={`text-2xl font-bold ${textPrimary}`}>{unanalyzedTotal + runningTotal + completedTotal}</p>
<p className={`text-sm ${textSecondary}`}>全部任务</p>
</button>
<button
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
>
<p className={`text-2xl font-bold ${textPrimary}`}>{unanalyzedTotal}</p>
<p className={`text-sm ${textSecondary}`}>未分析</p>
</button>
<button
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
>
<p className={`text-2xl font-bold ${isDark ? 'text-blue-400' : 'text-red-600'}`}>{runningTotal}</p>
<p className={`text-sm ${textSecondary}`}>分析中</p>
</button>
<button
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
>
<p className={`text-2xl font-bold ${isDark ? 'text-green-400' : 'text-green-600'}`}>{completedTotal}</p>
<p className={`text-sm ${textSecondary}`}>已完成</p>
</button>
</div>
{/* 看板区 */}
<div className="flex-1 grid grid-cols-3 gap-4 min-h-0 overflow-hidden">
<Loading loading={unanalyzedLoading}>
<KanbanColumn title="未分析" taskType={"unanalyzed"} TaskDataType={'sentiment'} count={unanalyzedTotal} color={isDark ? 'bg-yellow-400' : 'bg-yellow-400'} tasks={unanalyzedList} pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis} />
</Loading>
<Loading loading={runningLoading}>
<KanbanColumn title="分析中" taskType="running" TaskDataType={'sentiment'} count={runningTotal} color={isDark ? 'bg-blue-400' : 'bg-red-400'} tasks={runningList} pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis} />
</Loading>
<Loading loading={completedLoading}>
<KanbanColumn title="已完成" taskType="completed" TaskDataType={'sentiment'} count={completedTotal} color={isDark ? 'bg-green-400' : 'bg-green-400'} tasks={completedList} pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis} />
</Loading>
</div>
{/* 选择分析模式弹窗 */}
{showAnalysisModal && selectedTaskForAnalysis && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowAnalysisModal(false)}>
<div
className={`w-full max-w-lg rounded-2xl p-6 ${cardBg} border ${cardBorder} shadow-xl`}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-6">
<div className={shellClass}>
<div className={`relative mx-auto flex h-full max-w-[1680px] flex-col gap-5 px-6 py-6 lg:px-8 ${panelGlowClass}`}>
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl border border-[rgba(96,165,250,0.28)] bg-[linear-gradient(180deg,rgba(22,119,255,0.28),rgba(14,63,135,0.18))] shadow-[0_10px_30px_rgba(14,63,135,0.24)]">
<Bot className="h-5 w-5 text-[#7CC9FF]" />
</div>
<div>
<h2 className={`text-xl font-bold ${textPrimary}`}>选择分析模式</h2>
<p className={`text-sm ${textMuted} mt-1`}>为任务「{selectedTaskForAnalysis.taskName}」选择分析方式</p>
<div className="flex items-center gap-2">
<h1 className="text-[26px] font-semibold tracking-[0.02em] text-slate-50">{title}</h1>
{useDemoData ? (
<span className="rounded-full border border-[rgba(125,211,252,0.28)] bg-[rgba(56,189,248,0.14)] px-2.5 py-1 text-xs text-[#8EDCFF]">
演示数据
</span>
) : null}
</div>
<p className={`mt-1 text-sm ${isDark ? 'text-slate-300/80' : 'text-slate-500'}`}>
面向规划任务的情绪与倾向分析看板,支持快速检索、会商启动与结果回看。
</p>
</div>
</div>
<div className={`inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs ${isDark ? 'border-[rgba(110,136,176,0.28)] bg-[rgba(9,25,46,0.72)] text-slate-300' : 'border-slate-200 bg-white text-slate-600'}`}>
<span>{category}</span>
<ArrowRight className="h-3.5 w-3.5 opacity-70" />
<span>{subCategory}</span>
</div>
</div>
<div className="flex flex-col gap-3 xl:items-end">
<div className="flex flex-wrap items-center gap-3">
<label className={`flex h-11 min-w-[320px] items-center gap-3 rounded-2xl border px-4 ${inputClass}`}>
<Search className="h-4 w-4 text-[#7CC9FF]" />
<input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
className="w-full bg-transparent text-sm outline-none"
placeholder="搜索任务名称、摘要或负责人"
/>
</label>
<DateRangePicker
className="sentiment-date-range"
value={dateRange}
enableTimePicker={false}
clearable={false}
popupProps={{ overlayClassName: 'sentiment-date-range-popup' }}
rangeInputProps={{
inputProps: [
{ inputClass: 'sentiment-date-range-input' },
{ inputClass: 'sentiment-date-range-input' },
],
}}
onChange={(value) => setDateRange(value)}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setUseDemoData(true)}
className={`rounded-xl px-4 py-2 text-sm transition ${useDemoData ? 'bg-[#1677FF] text-white shadow-[0_8px_22px_rgba(22,119,255,0.28)]' : isDark ? 'bg-[rgba(10,30,54,0.9)] text-slate-300 hover:bg-[rgba(13,42,78,0.95)]' : 'bg-white text-slate-700 hover:bg-slate-50'}`}
>
演示数据
</button>
<button
type="button"
onClick={() => setUseDemoData(false)}
className={`rounded-xl px-4 py-2 text-sm transition ${!useDemoData ? 'bg-[#0EA5E9] text-white shadow-[0_8px_22px_rgba(14,165,233,0.24)]' : isDark ? 'bg-[rgba(10,30,54,0.9)] text-slate-300 hover:bg-[rgba(13,42,78,0.95)]' : 'bg-white text-slate-700 hover:bg-slate-50'}`}
>
真实数据
</button>
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
{metrics.map(({ label, value, icon: Icon, accent }) => (
<div key={label} className={`rounded-3xl p-5 ${metricCardClass}`}>
<div className="flex items-center justify-between">
<div>
<p className={`text-sm ${isDark ? 'text-slate-300/78' : 'text-slate-500'}`}>{label}</p>
<p className="mt-3 text-3xl font-semibold tracking-[0.03em] text-white">{value}</p>
</div>
<div className={`flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br ${accent} shadow-[inset_0_1px_0_rgba(255,255,255,0.18)]`}>
<Icon className="h-5 w-5 text-white" />
</div>
</div>
</div>
))}
</div>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 xl:grid-cols-3">
<div className="min-h-[420px]">
<Loading loading={unanalyzedLoading} showOverlay={false}>
<KanbanColumn
title="未分析"
count={unanalyzedTotal}
color="bg-[#38BDF8]"
tasks={unanalyzedList}
taskType="unanalyzed"
TaskDataType="sentiment"
pageIndexSearch={(taskType, pageIndex) => taskListSearch(taskType as ColumnType, pageIndex)}
handleStartAnalysis={handleStartAnalysis}
/>
</Loading>
</div>
<div className="min-h-[420px]">
<Loading loading={runningLoading} showOverlay={false}>
<KanbanColumn
title="分析中"
count={runningTotal}
color="bg-[#2DD4BF]"
tasks={runningList}
taskType="analyzing"
TaskDataType="sentiment"
pageIndexSearch={(taskType, pageIndex) => taskListSearch(taskType as ColumnType, pageIndex)}
handleStartAnalysis={handleStartAnalysis}
/>
</Loading>
</div>
<div className="min-h-[420px]">
<Loading loading={completedLoading} showOverlay={false}>
<KanbanColumn
title="已完成"
count={completedTotal}
color="bg-[#60A5FA]"
tasks={completedList}
taskType="completed"
TaskDataType="sentiment"
pageIndexSearch={(taskType, pageIndex) => taskListSearch(taskType as ColumnType, pageIndex)}
handleStartAnalysis={handleStartAnalysis}
/>
</Loading>
</div>
</div>
</div>
{showAnalysisModal && selectedTaskForAnalysis ? (
<div className="fixed inset-0 z-[2000] flex items-center justify-center bg-[rgba(3,10,22,0.62)] px-4 backdrop-blur-sm">
<div className={`w-full max-w-[560px] rounded-[28px] border p-6 ${isDark ? 'border-[rgba(110,136,176,0.26)] bg-[linear-gradient(180deg,rgba(10,29,54,0.98),rgba(8,20,37,0.98))] text-slate-100 shadow-[0_28px_80px_rgba(2,8,20,0.42)]' : 'border-slate-200 bg-white text-slate-900 shadow-xl'}`}>
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-xl font-semibold">选择分析模式</h3>
<p className={`mt-2 text-sm ${isDark ? 'text-slate-300/78' : 'text-slate-500'}`}>
当前任务:{selectedTaskForAnalysis.taskName}
</p>
</div>
<button
type="button"
onClick={() => setShowAnalysisModal(false)}
className={`p-2 rounded-lg hover:bg-slate-700/50 ${textSecondary}`}
className={`rounded-xl p-2 transition ${isDark ? 'hover:bg-[rgba(15,41,74,0.88)]' : 'hover:bg-slate-100'}`}
>
<X className="w-5 h-5" />
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-3">
<div className="mt-6 grid gap-3">
<button
onClick={() => handleSelectAnalysisMode('task-search')}
className={`w-full p-4 rounded-xl border transition-all duration-200 text-left hover:shadow-md ${isDark
? 'bg-slate-700/50 border-blue-800/30 hover:border-green-500/50'
: 'bg-white border-red-100 hover:border-red-300 hover:shadow-md'
}`}
type="button"
onClick={handleViewTaskDetail}
className={`group flex items-center justify-between rounded-2xl border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(110,136,176,0.24)] bg-[rgba(10,30,54,0.9)] hover:border-[rgba(96,165,250,0.48)] hover:bg-[rgba(12,38,69,0.96)]' : 'border-slate-200 bg-slate-50 hover:border-sky-200 hover:bg-sky-50'}`}
>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-green-500/20' : 'bg-green-100'
}`}>
<Layers className={`w-6 h-6 ${isDark ? 'text-green-400' : 'text-green-600'}`} />
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-[linear-gradient(180deg,#1677FF,#0EA5E9)]">
<Layers className="h-5 w-5 text-white" />
</div>
<div className="flex-1">
<h3 className={`font-semibold ${textPrimary}`}>查看任务详情</h3>
<p className={`text-sm ${textSecondary}`}>进入b1系统查看任务详情</p>
<div>
<div className="font-medium">查看任务详情</div>
<div className={`mt-1 text-sm ${isDark ? 'text-slate-300/72' : 'text-slate-500'}`}>进入单任务分析视图,查看步骤、结论和上下文信息。</div>
</div>
<ArrowRight className={`w-5 h-5 ${textMuted}`} />
</div>
<ArrowRight className="h-4 w-4 opacity-70 transition group-hover:translate-x-0.5" />
</button>
{/* 多智能体会商 */}
<button
onClick={() => handleSelectAnalysisMode('consultation')}
className={`w-full p-4 rounded-xl border transition-all duration-200 text-left hover:shadow-md ${isDark
? 'bg-slate-700/50 border-blue-800/30 hover:border-orange-500/50'
: 'bg-white border-red-100 hover:border-red-300 hover:shadow-md'
}`}
type="button"
onClick={handleMultiAgentDiscussion}
className={`group flex items-center justify-between rounded-2xl border px-5 py-4 text-left transition ${isDark ? 'border-[rgba(110,136,176,0.24)] bg-[rgba(10,30,54,0.9)] hover:border-[rgba(45,212,191,0.48)] hover:bg-[rgba(10,42,58,0.96)]' : 'border-slate-200 bg-slate-50 hover:border-emerald-200 hover:bg-emerald-50'}`}
>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-orange-500/20' : 'bg-red-100'
}`}>
<Users className={`w-6 h-6 ${isDark ? 'text-orange-400' : 'text-red-500'}`} />
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-[linear-gradient(180deg,#14B8A6,#22C55E)]">
<Users className="h-5 w-5 text-white" />
</div>
<div className="flex-1">
<h3 className={`font-semibold ${textPrimary}`}>多智能体会商</h3>
<p className={`text-sm ${textSecondary}`}>多个AI智能体从不同视角开展讨论并形成共识</p>
<div>
<div className="font-medium">多智能体会商</div>
<div className={`mt-1 text-sm ${isDark ? 'text-slate-300/72' : 'text-slate-500'}`}>启动会商模式,由多个智能体并行分析情绪走势与影响因素。</div>
</div>
<ArrowRight className={`w-5 h-5 ${textMuted}`} />
</div>
<ArrowRight className="h-4 w-4 opacity-70 transition group-hover:translate-x-0.5" />
</button>
</div>
</div>
</div>
)}
) : null}
</div>
);
};
@@ -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<TaskCardProps> = ({ task, TaskType = 'task', handleStartAnalysis }) => {
export const TaskCard: React.FC<TaskCardProps> = ({
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<TaskCardProps> = ({ 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 (
<div
className={`rounded-xl p-4 hover:shadow-md transition-all duration-200 cursor-pointer group theme-card border ${isDark
? 'bg-slate-800/40 backdrop-blur-xl border-blue-800/30 hover:border-blue-500/50'
: 'bg-white border-red-100 hover:shadow-md shadow-red-100'
}`}
onClick={() => 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'
}`}
>
{/* 卡片头部:模式标签 + 状态标签 */}
<div className="flex items-center justify-between mb-2">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-xs font-medium ${levelConfig.color}`}>
{LevelIcon && <LevelIcon className="w-3 h-3" />}
{levelConfig.label}
</span>
<span></span>
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${statusConfig.color}`}>
<div className="flex items-start justify-between gap-3">
<div className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs ${typeConfig.className}`}>
<TypeIcon className="h-3.5 w-3.5" />
<span>{typeConfig.label}</span>
</div>
<span className={`inline-flex items-center rounded-full px-2.5 py-1 text-xs ${statusConfig.className}`}>
{statusConfig.label}
</span>
</div>
{/* 任务名称 */}
<h3 className={`font-semibold text-sm mb-1 line-clamp-2 group-hover:text-red-500 transition-colors ${textPrimary}`}>
{task.taskName}
</h3>
{/* 任务编号 */}
<p className={`text-xs mb-2 ${textMuted}`}>TASK-{task.id}</p>
{/* 任务摘要 */}
<p className={`text-xs mb-3 line-clamp-2 ${textSecondary}`}>{task.overview}</p>
{/* 补充信息区 */}
{/* <div className="space-y-1 mb-3">
{task.type === 'single' && task.agent && (
<div className="flex items-center gap-2 text-xs">
<Bot className={`w-3 h-3 ${isDark ? 'text-cyan-400' : 'text-red-500'}`} />
<span className={textSecondary}>执行智能体:</span>
<span className={isDark ? 'text-cyan-400' : 'text-red-600'}>{task.agent}</span>
</div>
)}
{task.type === 'scene' && task.scene && (
<div className="flex items-center gap-2 text-xs">
<Workflow className={`w-3 h-3 ${isDark ? 'text-purple-400' : 'text-red-500'}`} />
<span className={textSecondary}>关联场景:</span>
<span className={isDark ? 'text-purple-400' : 'text-red-600'}>{task.scene}</span>
</div>
)}
{task.type === 'consultation' && (
<>
<div className="flex items-center gap-2 text-xs">
<Users className={`w-3 h-3 ${isDark ? 'text-orange-400' : 'text-red-500'}`} />
<span className={textSecondary}>参与智能体:</span>
<span className={isDark ? 'text-orange-400' : 'text-red-600'}>{task.participants?.length || 0}个</span>
</div>
{task.currentRound && (
<div className="flex items-center gap-2 text-xs">
<Clock className={`w-3 h-3 ${textMuted}`} />
<span className={textSecondary}>当前轮次:</span>
<span className={textPrimary}>第{task.currentRound}轮</span>
</div>
)}
</>
)}
{task.column === 'analyzing' && task.currentPhase && (
<div className="flex items-center gap-2 text-xs">
<Target className={`w-3 h-3 ${textMuted}`} />
<span className={textSecondary}>当前阶段:</span>
<span className={textPrimary}>{task.currentPhase}</span>
</div>
)}
</div> */}
{/* 时间信息 */}
<div className={`flex items-center justify-between text-xs pt-3 border-t ${isDark ? 'border-blue-800/30 text-slate-500' : 'border-red-100 text-gray-400'
}`}>
<div className="flex items-center gap-1.5">
<User className="w-3 h-3" />
<span>{task.taskDirection}</span>
<button
type="button"
onClick={() => handleTaskClick(task)}
className="mt-4 block w-full text-left"
>
<div className={`line-clamp-2 text-[15px] font-semibold leading-6 ${isDark ? 'text-slate-50' : 'text-slate-900'}`}>
{task.taskName}
</div>
<p className={`mt-2 line-clamp-3 text-sm leading-6 ${isDark ? 'text-slate-300/78' : 'text-slate-500'}`}>
{task.overview || '暂无任务摘要'}
</p>
</button>
<div className="mt-4 space-y-3">
<div className="flex items-center justify-between text-xs">
<div className={`flex items-center gap-1.5 ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>
<User className="h-3.5 w-3.5" />
<span>{task.owner || task.creator || '未分配'}</span>
</div>
<div className={`${isDark ? 'text-slate-500' : 'text-slate-400'}`}>优先级:{getLevel(task.priority)}</div>
</div>
<div>
<div className="mb-1.5 flex items-center justify-between text-xs">
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>分析进度</span>
<span className={isDark ? 'text-slate-300' : 'text-slate-700'}>{progressValue}%</span>
</div>
<div className={`h-2 overflow-hidden rounded-full ${isDark ? 'bg-[rgba(255,255,255,0.08)]' : 'bg-slate-100'}`}>
<div
className="h-full rounded-full bg-[linear-gradient(90deg,#1677FF,#5EEAD4)]"
style={{ width: `${progressValue}%` }}
/>
</div>
</div>
<div className="flex items-center justify-between text-xs">
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>
创建于 {task.createTime ? format(new Date(task.createTime), 'MM-dd HH:mm') : '--'}
</span>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>
更新于 {task.updateTime ? format(new Date(task.updateTime), 'MM-dd HH:mm') : '--'}
</span>
</div>
<span>{format(new Date(task.updateTime), 'yyyy-MM-dd HH:mm:ss')}</span>
</div>
{/* 操作按钮区 */}
<div className={`flex items-center gap-2 mt-3 pt-3 border-t ${isDark ? 'border-blue-800/30' : 'border-red-100'
}`}>
{task.column === 'unanalyzed' && (
<>
<button
onClick={(e) => { e.stopPropagation(); handleStartChange(task); }}
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-white rounded-lg text-xs font-medium transition-colors ${isDark
? 'bg-blue-600 hover:bg-blue-500'
: 'bg-red-600 hover:bg-red-500 shadow-md'
}`}
>
<Play className="w-3 h-3" />
<div className="mt-4 flex gap-2">
{task.column === 'completed' ? (
<button
type="button"
onClick={() => handleTaskClick(task)}
className={`flex-1 rounded-xl px-3 py-2 text-sm transition ${
isDark
? 'bg-[rgba(11,40,71,0.95)] text-slate-100 hover:bg-[rgba(15,49,87,0.98)]'
: 'bg-slate-900 text-white hover:bg-slate-800'
}`}
>
<span className="inline-flex items-center gap-2">
<Eye className="h-4 w-4" />
查看结果
</span>
</button>
) : (
<button
type="button"
onClick={() => handleStartChange(task)}
className="flex-1 rounded-xl bg-[linear-gradient(90deg,#1677FF,#0EA5E9)] px-3 py-2 text-sm text-white shadow-[0_10px_24px_rgba(22,119,255,0.28)] transition hover:brightness-110"
>
<span className="inline-flex items-center gap-2">
<Play className="h-4 w-4" />
开始分析
</button>
{/* <button
onClick={(e) => { e.stopPropagation(); onNavigate('task-create'); }}
className={`p-1.5 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-700/50' : 'hover:bg-red-50'
}`}
title="编辑"
>
<Edit className={`w-4 h-4 ${isDark ? 'text-slate-400' : 'text-red-400'}`} />
</button> */}
</>
</span>
</button>
)}
{task.column === 'running' && (
<>
<button
onClick={(e) => { e.stopPropagation(); handleStartChange(task); }}
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-white rounded-lg text-xs font-medium transition-colors ${isDark
? 'bg-blue-600 hover:bg-blue-500'
: 'bg-red-600 hover:bg-red-500 shadow-md'
}`}
>
<Play className="w-3 h-3" />
开始分析
</button>
{/* <button
onClick={(e) => { e.stopPropagation(); handleStartChange(task); }}
className={`px-2 py-1.5 rounded-lg text-xs transition-colors ${isDark
? 'hover:bg-slate-700/50 border border-blue-800/30 text-slate-300'
: 'hover:bg-red-50 border border-red-200 text-red-600'
}`}
>
查看进展
</button> */}
</>
)}
{task.column === 'completed' && (
<>
<button
onClick={(e) => { e.stopPropagation(); handleStartChange(task); }}
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-white rounded-lg text-xs font-medium transition-colors ${isDark
? 'bg-green-600 hover:bg-green-500'
: 'bg-green-600 hover:bg-green-500 shadow-md'
}`}
>
<Eye className="w-3 h-3" />
开始分析
</button>
{/* <button className={`px-2 py-1.5 rounded-lg text-xs transition-colors ${isDark
? 'hover:bg-slate-700/50 border border-blue-800/30 text-slate-300'
: 'hover:bg-red-50 border border-red-200 text-red-600'
}`}>
<Download className="w-4 h-4" />
</button> */}
</>
)}
{/* <button className={`p-1.5 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-700/50' : 'hover:bg-red-50'
}`} title="更多">
<MoreHorizontal className={`w-4 h-4 ${isDark ? 'text-slate-400' : 'text-red-400'}`} />
</button> */}
</div>
</div>
);
}
};
@@ -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);
+3 -2
View File
@@ -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 {
+3 -1
View File
@@ -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
DEER_FLOW_NOTEBOOK_API_BASE=http://127.0.0.1:8003
# 圆桌后台作业网关:inprocess=进程内驱动真模型;simulated=模拟(默认)
ROUNDTABLE_JOB_GATEWAY=inprocess
@@ -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_<sha1>` → 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/<code>`. 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). |
@@ -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)
@@ -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)
@@ -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)
@@ -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:<toolName>``——前端 ``getStepDisplay`` 据此推图标 + 文案;
- ``id`` = ``<dialogueId>-<i>``——对齐前端 ``${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-<tid>``、
工具调用 ``tc-<tid>-<i>``),使加载草稿后点开产物的虚拟 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-<jobId>`` 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)
@@ -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 [])]))
@@ -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(
@@ -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"),
)
@@ -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)
+12 -13
View File
@@ -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 工具会把
# 图片交给该模型识别,并把识别出的文字结果返回给主对话模型 —— 这样即使主对话模型不支持
@@ -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("</uploaded_files>")
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,
@@ -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 必须是完整文档,<head> 最前面声明 <meta charset="utf-8">。'
)
return "\n".join(lines)
@@ -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()
@@ -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
@@ -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")
@@ -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")
@@ -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")
@@ -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",
@@ -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)
@@ -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
@@ -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
@@ -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))
@@ -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
@@ -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 对齐)。
@@ -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)
@@ -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" 含 <html 标签 = {'<html' in html.lower()} (True=真 HTML / False=纯文字回退=P0未解决)")
lines.append(f" summary = {(step3.get('summary') or '')[:120]!r}")
lines.append("")
# ── 顶层 draft.step3(结果绘制聊天记录靠它重建报告气泡)──
lines.append("【顶层 draft.step3】(前端 hydrateStep3 用它重建报告聊天记录气泡)")
if not top_step3:
lines.append(" ❌ 空 —— 报告聊天记录加载后会丢失(本次修复点:应非空)")
else:
th = (top_step3.get("html") or "") if isinstance(top_step3, dict) else ""
lines.append(f" ✅ 已存 html_len={len(th)} summary={(top_step3.get('summary') or '')[:80]!r}" if isinstance(top_step3, dict) else f" {top_step3!r}")
OUT.write_text("\n".join(lines), encoding="utf-8")
print(f"written: {OUT.resolve()}")
if __name__ == "__main__":
main()