Merge branch 'dhk-1' into 'master'
feat: 多智能体会商草稿分享(import 复制 / view 公开结果页) See merge request knowledge-base-bj/deerflow-server!94
This commit is contained in:
commit
1341767692
73 files changed
+5215
-1043
No files matched your search
@@ -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 报告,切换不同结果线时能看到各自对应的报告。
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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,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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
+519
-319
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
+205
-10
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -45,6 +45,7 @@ from app.gateway.routers import (
|
||||
recommend,
|
||||
recommended_questions,
|
||||
roundtable_chains,
|
||||
roundtable_draft_shares,
|
||||
roundtable_drafts,
|
||||
roundtable_jobs,
|
||||
runs,
|
||||
@@ -929,6 +930,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)
|
||||
|
||||
@@ -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 工具会把
|
||||
# 图片交给该模型识别,并把识别出的文字结果返回给主对话模型 —— 这样即使主对话模型不支持
|
||||
|
||||
+107
-20
@@ -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)
|
||||
+92
@@ -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()
|
||||
+6
@@ -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
|
||||
|
||||
+46
@@ -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")
|
||||
+46
@@ -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")
|
||||
+57
@@ -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")
|
||||
+2
@@ -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",
|
||||
|
||||
+22
@@ -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)
|
||||
+56
@@ -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
|
||||
+59
@@ -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
|
||||
+38
@@ -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))
|
||||
+84
@@ -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
|
||||
+6
@@ -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 对齐)。
|
||||
|
||||
+7
-1
@@ -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()
|
||||
Reference in new issue
Block a user