代码修改
This commit is contained in:
1 parent
88b1a330a6
commit
6b49741e69
7 files changed
+332
-394
No files matched your search
@@ -1,265 +0,0 @@
|
||||
# 多智能体协调 API 接口文档
|
||||
|
||||
后端路由:`offline-backend-20260512/backend/app/gateway/routers/multi_agent.py`
|
||||
路由前缀:`/api/multi-agent`
|
||||
|
||||
## 概览
|
||||
|
||||
提供一个"总控 + 多个子智能体"的协调编排接口。
|
||||
|
||||
- **总控(leader)** 智能体:通过 `agent_orchestration` 技能给子智能体派活
|
||||
- **子智能体(special)**:执行被派到的具体任务,完成后把交付内容广播给其他线程
|
||||
|
||||
调用方按下面顺序使用:
|
||||
|
||||
1. 调 `POST /api/multi-agent/init` 一次性创建协调智能体 + 给每个子智能体建 `thread`,拿到 `thread_ids` 映射
|
||||
2. 客户端把 `thread_ids` 保管好,后续每次调 `POST /api/multi-agent/run/stream` 时回传
|
||||
3. 先调用 `agent_type="leader"` 拿派活清单,再针对清单里每个子智能体调用 `agent_type="special"`
|
||||
|
||||
|
||||
## 1. 初始化多智能体集群
|
||||
|
||||
### `POST /api/multi-agent/init`
|
||||
|
||||
创建协调智能体并为列表中的每个子智能体建立独立的 `thread`,同时把"职责说明"广播到所有子智能体线程作为初始上下文。
|
||||
|
||||
#### 请求体
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_names": ["taiwan-intelligence-gathering-agent", "us-intelligence-gathering-agent"],
|
||||
"main_agent_name": "main-controller",
|
||||
"model": "deepseek-chat"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `agent_names` | `string[]` | 是 | 已有子智能体的 id 列表(kebab-case,区分大小写不敏感,自动转小写) |
|
||||
| `main_agent_name` | `string` | 否 | 要创建的协调智能体名,默认 `main-agent`。重名时会复用已存在的同名智能体 |
|
||||
| `model` | `string` | 否 | 协调智能体使用的模型名。不传则用后端默认模型 |
|
||||
|
||||
#### 响应(200)
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"main_agent_name": "main-controller",
|
||||
"thread_ids": {
|
||||
"main-controller": "thread-uuid-xxx",
|
||||
"taiwan-intelligence-gathering-agent": "thread-uuid-yyy",
|
||||
"us-intelligence-gathering-agent": "thread-uuid-zzz"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`thread_ids` 是一个 `{agent_name: thread_id}` 字典。**前端必须把这个对象完整保管下来**,后续调用 stream 接口需要原样回传。
|
||||
|
||||
#### 错误
|
||||
|
||||
| 状态码 | 触发条件 |
|
||||
|---|---|
|
||||
| `400` | `agent_names` 为空 |
|
||||
| `401` | 未携带 `Authorization` 头 |
|
||||
| `500` | 协调智能体创建失败且不是"已存在"错误 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 流式调度
|
||||
|
||||
### `POST /api/multi-agent/run/stream`
|
||||
|
||||
通过 SSE(Server-Sent Events)流式驱动一个 leader 或 special 智能体跑一轮对话。
|
||||
|
||||
#### 请求体
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_type": "leader",
|
||||
"d_agent_thread_id": {
|
||||
"main-controller": "thread-uuid-xxx",
|
||||
"taiwan-intelligence-gathering-agent": "thread-uuid-yyy"
|
||||
},
|
||||
"agent_name": "main-controller",
|
||||
"new_message": "请分析特朗普的支持率",
|
||||
"skill_stop_names": [],
|
||||
"model": "deepseek-chat"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `agent_type` | `"leader"` \| `"special"` | 否 | 默认 `leader`。`leader` 调用协调智能体,`special` 调用子智能体 |
|
||||
| `d_agent_thread_id` | `object` | 是 | 由 `/init` 接口返回的 `thread_ids` 映射,原样回传 |
|
||||
| `agent_name` | `string` | 是 | 本轮要驱动的智能体名(必须存在于 `d_agent_thread_id` 中) |
|
||||
| `new_message` | `string` | 是 | 这轮发给智能体的用户消息 |
|
||||
| `skill_stop_names` | `string[]` | 否 | 触发后中断执行的技能名列表(默认空) |
|
||||
| `model` | `string` | 否 | 本轮 LLM 模型名,默认 `deepseek-chat` |
|
||||
|
||||
#### 响应
|
||||
|
||||
`Content-Type: text/event-stream` 的 SSE 长连接,按到达顺序产出多种数据:
|
||||
|
||||
##### a. 透传上游事件
|
||||
|
||||
原样转发底层 `POST /api/threads/{thread_id}/runs/stream` 的事件帧(LangGraph 协议),包含 `event:`、`data:` 等行。前端按 LangGraph SDK 既有方式渲染即可。
|
||||
|
||||
##### b. 最后一帧:调度状态
|
||||
|
||||
流结束前会额外产出一帧 `data:` 行,结构因 `agent_type` 而异:
|
||||
|
||||
**`agent_type == "leader"`:**
|
||||
|
||||
```
|
||||
data: {"status": [["us-intelligence-gathering-agent", "收集美国民众对特朗普的看法"], ["taiwan-intelligence-gathering-agent", "..."]]}
|
||||
```
|
||||
|
||||
`status` 是一个 `[agent_name, task_text]` 二元组数组,代表协调智能体本轮派出去的任务清单。**前端拿到后应当对每个元素再调用一次本接口(`agent_type="special"`)来执行子智能体**。如果数组为空,说明 leader 觉得已有信息足够,不再派活,可以直接展示 leader 的最终文本回答。
|
||||
|
||||
**`agent_type == "special"`:**
|
||||
|
||||
```
|
||||
data: {"status": "子智能体xxx完成yyy工作"}
|
||||
```
|
||||
|
||||
代表子智能体完成本轮工作并已把交付内容广播给其他线程。
|
||||
|
||||
#### 副作用(重要)
|
||||
|
||||
- **leader 调用**:自动把"总控智能体收到用户消息: …"广播到所有非自己的线程;派活后再把"总控智能体给 X 派活, 内容为: …"广播到所有非派活双方的线程
|
||||
- **special 调用**:完成后把"子智能体 X 完成 Y 工作, 交付内容为: …"广播到所有非自己的线程
|
||||
|
||||
这意味着每个线程的对话历史里会逐步累积出全局上下文,无需前端再做合并。
|
||||
|
||||
#### 错误
|
||||
|
||||
| 状态码 | 触发条件 |
|
||||
|---|---|
|
||||
| `400` | `agent_name` 缺失或未在 `d_agent_thread_id` 中 |
|
||||
| `401` | 未携带 `Authorization` 头 |
|
||||
| `502` | 上游 `runs/stream` 返回非 200 / SSE 解析失败 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 前端调用流程示例
|
||||
|
||||
```ts
|
||||
// 1. 拿 token
|
||||
const loginResp = await fetch("/api/v1/auth/login/username", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "yourname" }),
|
||||
});
|
||||
const { access_token } = await loginResp.json();
|
||||
const authHeaders = {
|
||||
Authorization: `Bearer ${access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
// 2. 初始化
|
||||
const initResp = await fetch("/api/multi-agent/init", {
|
||||
method: "POST",
|
||||
headers: authHeaders,
|
||||
body: JSON.stringify({
|
||||
agent_names: [
|
||||
"taiwan-intelligence-gathering-agent",
|
||||
"us-intelligence-gathering-agent",
|
||||
],
|
||||
main_agent_name: "main-controller",
|
||||
model: "deepseek-chat",
|
||||
}),
|
||||
});
|
||||
const { thread_ids } = await initResp.json();
|
||||
|
||||
// 3. 流式调用 leader
|
||||
async function streamLeader(message: string, model = "deepseek-chat") {
|
||||
const resp = await fetch("/api/multi-agent/run/stream", {
|
||||
method: "POST",
|
||||
headers: authHeaders,
|
||||
body: JSON.stringify({
|
||||
agent_type: "leader",
|
||||
d_agent_thread_id: thread_ids,
|
||||
agent_name: "main-controller",
|
||||
new_message: message,
|
||||
skill_stop_names: [],
|
||||
model,
|
||||
}),
|
||||
});
|
||||
const reader = resp.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
let dispatched: [string, string][] = [];
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
try {
|
||||
const payload = JSON.parse(line.slice(5).trim());
|
||||
if (Array.isArray(payload.status)) {
|
||||
dispatched = payload.status;
|
||||
}
|
||||
// 其余事件按 LangGraph SDK 协议自行渲染
|
||||
} catch {
|
||||
/* 非 JSON 帧,可忽略或交给现有渲染器 */
|
||||
}
|
||||
}
|
||||
}
|
||||
return dispatched;
|
||||
}
|
||||
|
||||
// 4. 对派活清单逐个调用 special
|
||||
async function streamSpecial(agentName: string, task: string, model = "deepseek-chat") {
|
||||
return fetch("/api/multi-agent/run/stream", {
|
||||
method: "POST",
|
||||
headers: authHeaders,
|
||||
body: JSON.stringify({
|
||||
agent_type: "special",
|
||||
d_agent_thread_id: thread_ids,
|
||||
agent_name: agentName,
|
||||
new_message: task,
|
||||
skill_stop_names: [],
|
||||
model,
|
||||
}),
|
||||
});
|
||||
// 同样按 SSE 解析
|
||||
}
|
||||
|
||||
// 5. 编排:先 leader 派活 → 依次执行 special → 再让 leader 汇总
|
||||
const dispatched = await streamLeader("请分析特朗普的支持率");
|
||||
for (const [agentName, task] of dispatched) {
|
||||
await streamSpecial(agentName, task);
|
||||
}
|
||||
const next = await streamLeader("根据上面收集到的信息出最终结论");
|
||||
// 当 leader 返回 status: [] 时,意味着汇报完毕,可结束循环
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 注意事项
|
||||
|
||||
- **`model` 字段的取值**:必须是后端 `config.yaml` 中 `models[]` 已配置的模型名(如 `deepseek-chat`、`glm-4`、`gpt-4o` 等)。传错会导致上游 502
|
||||
- **`thread_ids` 必须每次回传**:后端不存储这个映射,是无状态接口
|
||||
- **协调智能体必须装有 `agent_orchestration` 技能**:`/init` 创建时会自动指定 `skills=["agent_orchestration"]`。后端 `skills/` 目录里必须存在该技能定义,否则 leader 无法派活
|
||||
- **同名协调智能体冲突**:`main_agent_name` 重名时后端不会报错("already exists" 走兼容分支),会复用既有智能体——所以同一个用户多次 init 用同一个名字是安全的,但 SOUL 不会被覆盖
|
||||
- **SSE 断连**:本接口底层 `on_disconnect: "continue"`,断连后上游 run 会继续跑到结束(不会取消)
|
||||
- **并发广播**:广播消息使用 `asyncio.gather` 并发更新所有线程 state,性能上不会随子智能体数线性变慢
|
||||
|
||||
---
|
||||
|
||||
## 5. 相关接口(底层依赖)
|
||||
|
||||
本接口内部转发以下 Gateway 原生接口,了解细节可查:
|
||||
|
||||
| 接口 | 路由 | 用途 |
|
||||
|---|---|---|
|
||||
| 创建 thread | `POST /api/threads` | 为每个智能体建立独立会话 |
|
||||
| 读 thread state | `GET /api/threads/{id}/state` | 广播前取当前消息列表 |
|
||||
| 写 thread state | `POST /api/threads/{id}/state` | 把广播消息追加到 messages |
|
||||
| 取 agent 详情 | `GET /api/agents/{id}` | init 时读取子智能体的 name/description |
|
||||
| 建 agent | `POST /api/agents` | init 时创建协调智能体 |
|
||||
| 流式 run | `POST /api/threads/{id}/runs/stream` | leader/special 的实际执行 |
|
||||
@@ -1,125 +0,0 @@
|
||||
# 多智能体「后台挂起」—— 当前状态、遗留问题与下次继续指南
|
||||
|
||||
> 配套设计文档:`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 文件(结果绘制拿不到产物)✅ 已于 2026-06-12 修复并恢复
|
||||
- **根因(2026-06-12 定位)**:`dc8fca6`(2026-06-11 智能体可见性对齐)给 `services._enforce_agent_access`
|
||||
加 admin 判断时**裸访问 `request.state`** —— 后台进程内虚拟 request(`SimpleNamespace(app=app)`)
|
||||
没有 `state` 属性 → `AttributeError` 杀死整个编排(前端真 HTTP 请求有 auth 中间件写的 `state.user`
|
||||
所以手动路径一直正常)。修复:getattr 全链防御 + 虚拟 request 补空 `state`(含 scheduler 同款),
|
||||
回归测试 `tests/test_roundtable_inprocess_request.py`。
|
||||
- **已恢复**:`ROUNDTABLE_BACKGROUND_REPORT_ENABLED = True`。后台收口后自动跑 Step3 双产物:
|
||||
① `roundtable-summary` 写「方案总结报告.md」(新增 `gateway.run_summary` + `engine._run_step3`,
|
||||
prompt 对齐 `useStep3Summary.buildSummaryPrompt`)→ ② `roundtable-report` 画「方案总览.html」。
|
||||
两段故障隔离(一段失败保留另一段产物);step3 形状对齐前端 `DraftStep3Snapshot`
|
||||
(`summaryReport` 嵌套),写草稿 `draft.step3` 与手动调用同款。测试
|
||||
`tests/test_roundtable_step3_pipeline.py`。
|
||||
- **待真机验证**:早期(06-05,回归引入前)曾观察到 deepseek-chat 在后台不调 `write_file`,
|
||||
当时根因可能就是更早版本的同类访问崩溃,也可能另有 prompt 层问题——恢复后跑一单真模型确认
|
||||
outputs 下两份产物都落盘;若报告仍不写 HTML,按旧排查清单对比前端 run 参数(run policy 现已
|
||||
由 `roundtable_run_policy.py` 单源统一,差异面只剩 prompt/上下文)。
|
||||
|
||||
### 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,161 @@
|
||||
# 需求文档 · 问答中心左侧栏改造(0616)
|
||||
|
||||
> 整理自 2026-06-16 需求草稿。本批需求统一围绕「问答中心」菜单下 5 个页面入口的**左侧侧边栏**进行改造,目标是让每个业务页面的左栏从「通用导航」变为「贴合该业务场景的快捷入口 + 历史记录」。
|
||||
>
|
||||
> 菜单结构参考:`frontend-web/src/core/page-layout/sidebar-menu.ts` 中 `qa-center`(问答中心)分组,含 5 个子项,正好对应下文 5 条需求。
|
||||
|
||||
## 0. 背景与共性约定
|
||||
|
||||
| 需求 | 菜单项 | id | 现有路由 | 对应页面/组件 |
|
||||
|------|--------|----|----------|--------------|
|
||||
| 1 | 通用问答 | `general-qa` | `/page/workspace/chats/new` | Workspace Chat + 左栏(`recent-chat-list.tsx` 等) |
|
||||
| 2 | 多智能体会商 | `multi-agent-roundtable` | `/page/strategy/qa/multi-agent-roundtable` | `roundtable-planning/pages/RoundtablePlanningPage.tsx` |
|
||||
| 3 | zzJC支持 | `qa-zzjc` | `/page/strategy/agents/6bf/chats/new` | `pages/AgentChatPage.tsx` |
|
||||
| 4 | zzXD应用 | `qa-zzxd` | `/page/strategy/agents/10d69686718349688e58a8790cb61024/chats/new` | `pages/AgentChatPage.tsx` |
|
||||
| 5 | 课题研究 | `qa-research` | `/page/canvas/ai-writing` | `open-canvas/pages/AIWritingPage.tsx` |
|
||||
|
||||
**共性约定(适用于全部 5 条,除非单条另有说明):**
|
||||
|
||||
- 「更多」一律为可点击入口;点击后跳转到对应的完整列表/管理页(具体目标见各条「待确认」)。
|
||||
- 列表为空时的兜底:参考各条「现状」中的级联/空态规则;无内容时该区块整体隐藏或显示占位文案(待 UI 设计确认,默认隐藏,与现有 `recent-chat-list.tsx` 一致)。
|
||||
- 左栏宽度、折叠交互沿用现有侧边栏体系(`SidebarGroup` 系列组件)。
|
||||
- 所有「智能体」相关数据复用 `core/agents`(`Agent` 类型已含 `is_pinned` / `is_favorite` / `featured_order`,scope 支持 `mine` / `square` / `builtin`)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 通用问答 — 左侧栏改造
|
||||
|
||||
**页面**:`/page/workspace/chats/new`
|
||||
|
||||
### 1.1 现状
|
||||
当前左栏主要展示「最近会话」列表(`recent-chat-list.tsx`,已过滤 `scheduler` / `bootstrap` / `roundtable` 类型线程,置顶会话优先)。
|
||||
|
||||
### 1.2 目标布局(自上而下)
|
||||
左栏从上到下依次为三块:**新建回答按钮 → 常用智能体 → 聊天记录**。
|
||||
|
||||
#### ① 顶部:新建回答
|
||||
- 左栏最顶部放置「**新建回答**」按钮(主操作)。
|
||||
- 点击 = 开启一次全新的通用问答会话(等价于跳转 `/page/workspace/chats/new`)。
|
||||
|
||||
#### ② 常用智能体(最多 3 个 + 更多)
|
||||
- 展示「常用智能体」卡片/列表项,**最多 3 个**。
|
||||
- 数据来源采用**级联兜底**逻辑(取到即用,不足再降级):
|
||||
1. 若用户有**置顶智能体**(`is_pinned = true`)→ 展示置顶的;
|
||||
2. 否则展示**用户自己的智能体**(`scope = "mine"`);
|
||||
3. 仍没有 → 展示**默认广场智能体**(`scope = "square"` / `builtin`,即广场默认推荐)。
|
||||
- 三者按上述优先级取,最终最多取 3 个。
|
||||
- 下方显示「**更多**」入口。
|
||||
- 点击某个常用智能体 → 进入该智能体的对话(待确认:是否为该智能体新建会话)。
|
||||
|
||||
#### ③ 聊天记录(最多 10 条 + 更多)
|
||||
- 展示用户的问答聊天记录,**最多 10 条**(沿用现有 `useThreads` + 过滤规则)。
|
||||
- 下方显示「**更多**」入口。
|
||||
- 点击「更多」→ **跳转到问答记录页面**(完整聊天记录列表页)。
|
||||
|
||||
### 1.3
|
||||
- 「常用智能体」点击后「打开该智能体主页」
|
||||
- 「常用智能体」的「更多」跳转目标页 智能体管理 `/page/strategy/agents`。
|
||||
- 「聊天记录」的「问答记录页面」具体路由http://localhost:5173/#/page/workspace/chats。
|
||||
- 级联兜底是(置顶组够 3 个就完全不展示我的/广场)如果不够「补足到 3 个
|
||||
|
||||
---
|
||||
|
||||
## 2. 多智能体会商 — 左侧栏重做
|
||||
|
||||
**页面**:`/page/strategy/qa/multi-agent-roundtable`(`RoundtablePlanningPage`)
|
||||
|
||||
### 2.1 现状
|
||||
圆桌会商页已有业务链选择、分析草稿等能力(`roundtable-planning/api/chains.ts`、`hooks/useRoundtableDrafts.ts`)。需求要求**左栏整体重做**。
|
||||
|
||||
### 2.2 目标布局(自上而下)
|
||||
|
||||
#### ① 顶部:业务链(公共,前 3 个)
|
||||
- 顶部展示**业务链**,仅取**公共**链条(`listChains("public")`,即所有已发布 `is_public = true` 的链条)。
|
||||
- 仅展示**前 3 个**。
|
||||
- 点击某条业务链 → 以该业务链启动一次会商(沿用现有"会商自动串联圆桌"流程,见记忆 `business-chain-mapping`)。
|
||||
|
||||
#### ② 下方:分析记录
|
||||
- 业务链下方展示「**分析记录**」列表。
|
||||
- 数据来源:圆桌分析草稿/历史(`useRoundtableDrafts` / `listDrafts`)。
|
||||
- 点击某条分析记录 → 打开对应的历史会商分析。
|
||||
|
||||
### 2.3 待确认
|
||||
- 「业务链」是否需要「更多」入口跳转到业务链条配置页(`/page/strategy/business-chains`)?需求未提及,默认仅展示前 3 个、无更多。 需要更多入口
|
||||
- 「分析记录」是否需要分页/「更多」?需求未提及,默认展示全部或合理上限 分析记录默认全部吧。
|
||||
- 公共业务链「前 3 个」的排序口径(创建时间倒序?已有 `listChains` 默认 newest first) 按照现在默认的显示来吧,然后要支持用户单选,如果用户选择后,点击进入多智能体研讨按钮时,直接按照选择的业务链进去执行,如果用户取消勾选,或没有勾选就还是打开弹窗。
|
||||
|
||||
---
|
||||
|
||||
## 3. zzJC支持 — 左侧栏改造(按类型聚合智能体)
|
||||
|
||||
**页面**:`/page/strategy/agents/6bf/chats/new`(当前为单一智能体 `6bf` 的对话页)
|
||||
|
||||
### 3.1 现状
|
||||
当前 zzJC支持直接进入某个固定智能体(id=`6bf`)的对话页(`AgentChatPage`)。
|
||||
|
||||
### 3.2 目标布局
|
||||
- **左栏按「类型」分组**,展示该类型下的智能体列表("类型"待确认,推测为智能体 `tags` / 分类)。
|
||||
- 智能体分组**下方**展示**聊天记录**。
|
||||
- 左栏**宽度与通用问答(需求 1)保持一致**("左侧是问答宽")。
|
||||
|
||||
### 3.3 Admin 设置入口
|
||||
- 在 **admin 账号权限下**,页面**右侧顶部显示「设置」图标按钮**(齿轮图标)。
|
||||
- 点击设置按钮 → 打开设置面板,可对该模块进行配置。
|
||||
- 非 admin 账号不显示设置按钮(沿用 `sidebar-menu.ts` 中 `adminOnly` 同款角色判断)。
|
||||
|
||||
### 3.4 待确认
|
||||
- 「类型」的确切含义:是智能体的 `tags`,还是 zzJC 业务自定义的一组分类?
|
||||
- 现 zzJC 入口是单个智能体 `6bf`;改为「按类型展示多个智能体」后,**进入该菜单时默认落地页是什么**(智能体列表?还是仍默认进 `6bf` 对话)?是否需要新建一个 zzJC 聚合页。
|
||||
- Admin「设置」具体可配置项:配置「该模块纳入哪些类型/哪些智能体」?还是其他(如默认智能体、欢迎语)?需明确设置项清单。
|
||||
- 「聊天记录」过滤范围:仅 zzJC 相关会话,还是全部会话?
|
||||
|
||||
---
|
||||
|
||||
## 4. zzXD应用 — 左侧栏改造
|
||||
|
||||
**页面**:`/page/strategy/agents/10d69686718349688e58a8790cb61024/chats/new`
|
||||
|
||||
### 4.1 需求
|
||||
**与需求 3(zzJC支持)完全一致**:
|
||||
- 左栏按类型展示该类型下的智能体 + 下方聊天记录;
|
||||
- 左栏宽度同通用问答;
|
||||
- admin 账号下右侧顶部显示设置图标按钮,可设置。
|
||||
|
||||
### 4.2 待确认
|
||||
- 同需求 3 全部待确认项。
|
||||
- zzXD 与 zzJC 是否共用同一套「按类型 + 设置」组件(仅数据源/默认智能体不同),还是各自独立配置?建议**抽象为同一可复用组件**,通过参数区分。
|
||||
|
||||
---
|
||||
|
||||
## 5. 课题研究 — 左侧栏改造(研究模板 + 笔记本)
|
||||
|
||||
**页面**:`/page/canvas/ai-writing`(`AIWritingPage`)
|
||||
|
||||
### 5.1 目标布局(自上而下)
|
||||
|
||||
#### ① 上半部分:研究模板
|
||||
- 左栏上半部分展示「**研究模板**」。
|
||||
- 研究模板 = **原来的「文章类型」**(`open-canvas/hooks/useArticleTypes.ts` 的 `articleTypes`,即 AI 写作表单里的「文章类型」选择项)。
|
||||
- 点击某个研究模板 → 以该模板/文章类型发起课题研究(沿用现有 `articleType` 设置逻辑)。
|
||||
|
||||
#### ② 下半部分:笔记本
|
||||
- 左栏下半部分展示「**笔记本**」。
|
||||
- 笔记本内容 = **「简洁模式」左侧菜单下方展示的笔记本内容**(`useStudioNotebooks`,即 `workspace-nav-studio.tsx` 中渲染的 studio notebooks 列表)。
|
||||
- 点击某个笔记本 → 打开该笔记本(沿用现有 `/page/workspace/studio/notebooks/{id}` 行为,或作为课题研究的素材来源 `materialSource = 'notebook'`,待确认)。
|
||||
|
||||
> 备注:草稿原文写作「简介模式」,结合代码应为「**简洁模式**」(`appearance-settings-page.tsx` 中的全局界面模式:全量 / 简洁;简洁模式下笔记本在左栏菜单展示)。
|
||||
|
||||
### 5.2 待确认
|
||||
- 「研究模板」点击行为:仅预填表单 `articleType`,还是直接开始生成?
|
||||
- 「笔记本」点击行为:跳转笔记本详情页,还是将笔记本设为当前课题研究的素材来源(`materialSource='notebook'`)?
|
||||
- 上/下两部分是否各自可折叠、是否需要「更多」入口与数量上限?需求未提及。
|
||||
|
||||
---
|
||||
|
||||
## 6. 整体待确认与建议
|
||||
|
||||
1. **复用性**:需求 3 / 4(zzJC / zzXD)布局完全相同,建议实现为同一套「按类型分组 + 聊天记录 + admin 设置」可复用组件,通过配置区分模块。
|
||||
2. **左栏宽度统一**:需求 1 / 3 / 4 都要求"问答宽",建议统一左栏宽度常量,避免各页面各写一套。
|
||||
3. **「更多」目标页**:需求 1 涉及「智能体更多」「聊天记录更多→问答记录页」两个跳转目标需先确定路由是否已存在。
|
||||
4. **Admin 设置项清单**:需求 3 / 4 的设置面板具体能配置什么,需要产品给出字段清单后才能落地。
|
||||
5. **入口语义变更**:需求 3 / 4 把原「单一智能体对话」入口改为「按类型聚合」,菜单 `path` 与默认落地页可能需要调整,需同步确认。
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { Bot, ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useAgentsPaginated } from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
/** 常用智能体最多展示个数(需求 1.2 ②)。 */
|
||||
const MAX_FREQUENT = 3;
|
||||
|
||||
/** 「更多」跳转目标:智能体管理页(需求 1.3)。 */
|
||||
const MORE_HREF = "/page/strategy/agents";
|
||||
|
||||
/** 打开某个智能体主页(其对话落地页,thread_id=new = 新会话)。 */
|
||||
function agentHomeHref(agentId: string): string {
|
||||
return `/page/strategy/agents/${agentId}/chats/new`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「常用智能体」侧栏区块(通用问答左栏,全量模式)。
|
||||
*
|
||||
* 级联兜底取数(需求 1.2 ② / 1.3)——按优先级补足到最多 3 个、去重:
|
||||
* 1. 置顶智能体(``is_pinned``,可来自「我的」或「广场」);
|
||||
* 2. 不足 3 个 → 用「我的」智能体(``scope="mine"``)补足;
|
||||
* 3. 仍不足 → 用「广场」默认推荐(``scope="square"``)补足。
|
||||
*
|
||||
* 取不到任何智能体时整块隐藏(与 ``RecentChatList`` 的空态约定一致)。
|
||||
*/
|
||||
export function FrequentAgentsList() {
|
||||
const pathname = usePathname();
|
||||
const { agents: mine } = useAgentsPaginated({
|
||||
scope: "mine",
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
const { agents: square } = useAgentsPaginated({
|
||||
scope: "square",
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
const frequent = useMemo<Agent[]>(() => {
|
||||
const out: Agent[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (agent: Agent | undefined) => {
|
||||
if (!agent || out.length >= MAX_FREQUENT || seen.has(agent.id)) return;
|
||||
seen.add(agent.id);
|
||||
out.push(agent);
|
||||
};
|
||||
// 1. 置顶组优先(够 3 个则下面我的/广场都不再补)。
|
||||
[...mine, ...square].filter((a) => a.is_pinned).forEach(add);
|
||||
// 2. 我的智能体补足。
|
||||
mine.forEach(add);
|
||||
// 3. 广场默认推荐补足。
|
||||
square.forEach(add);
|
||||
return out;
|
||||
}, [mine, square]);
|
||||
|
||||
if (frequent.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
|
||||
<SidebarGroupLabel className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
|
||||
常用智能体
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
{frequent.map((agent) => {
|
||||
const href = agentHomeHref(agent.id);
|
||||
const isActive = pathname === href;
|
||||
return (
|
||||
<SidebarMenuItem key={agent.id}>
|
||||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
asChild
|
||||
className={
|
||||
"rounded-lg h-7 px-3 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60 " +
|
||||
"data-[active=true]:bg-white data-[active=true]:text-foreground data-[active=true]:shadow-[0_1px_2px_rgba(15,23,42,0.05)] " +
|
||||
"dark:data-[active=true]:bg-slate-700/80 dark:data-[active=true]:text-slate-100 dark:data-[active=true]:shadow-none"
|
||||
}
|
||||
>
|
||||
<Link
|
||||
className="flex w-full items-center gap-2 whitespace-nowrap"
|
||||
href={href}
|
||||
>
|
||||
<Bot className="size-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span className="truncate">{desensitize(agent.name)}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className="rounded-lg h-7 px-3 text-[12px] text-muted-foreground/70 hover:text-foreground hover:bg-sidebar-accent/60"
|
||||
>
|
||||
<Link
|
||||
className="flex w-full items-center justify-between gap-1"
|
||||
href={MORE_HREF}
|
||||
>
|
||||
<span>更多</span>
|
||||
<ChevronRight className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</div>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
FileJson,
|
||||
FileText,
|
||||
@@ -74,7 +75,17 @@ import { desensitize } from "@/lib/desensitize";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function RecentChatList({ compact = false }: { compact?: boolean }) {
|
||||
export function RecentChatList({
|
||||
compact = false,
|
||||
limit,
|
||||
moreHref,
|
||||
}: {
|
||||
compact?: boolean;
|
||||
/** 最多展示的会话条数;省略则不限制(需求 1.3:通用问答最多 10 条)。 */
|
||||
limit?: number;
|
||||
/** 「更多」入口跳转目标;省略则不展示「更多」行。 */
|
||||
moreHref?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -190,6 +201,8 @@ export function RecentChatList({ compact = false }: { compact?: boolean }) {
|
||||
if (visibleThreads.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const shownThreads =
|
||||
limit != null ? visibleThreads.slice(0, limit) : visibleThreads;
|
||||
return (
|
||||
<>
|
||||
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60">
|
||||
@@ -225,7 +238,7 @@ export function RecentChatList({ compact = false }: { compact?: boolean }) {
|
||||
>
|
||||
<SidebarMenu>
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
{visibleThreads.map((thread) => {
|
||||
{shownThreads.map((thread) => {
|
||||
const isActive = pathOfThread(thread) === pathname;
|
||||
const pinned = isThreadPinned(thread);
|
||||
const favorited = isThreadFavorite(thread);
|
||||
@@ -368,6 +381,22 @@ export function RecentChatList({ compact = false }: { compact?: boolean }) {
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
{moreHref && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
className="rounded-lg h-7 px-3 text-[12px] text-muted-foreground/70 hover:text-foreground hover:bg-sidebar-accent/60"
|
||||
>
|
||||
<Link
|
||||
className="flex w-full items-center justify-between gap-1"
|
||||
href={moreHref}
|
||||
>
|
||||
<span>{t.common.more}</span>
|
||||
<ChevronRight className="size-3.5 shrink-0" />
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { FrequentAgentsList } from "./frequent-agents-list";
|
||||
import { RecentChatList } from "./recent-chat-list";
|
||||
import { RecentRoundtableList } from "./recent-roundtable-list";
|
||||
import { RecentWritingList } from "./recent-writing-list";
|
||||
@@ -25,7 +27,11 @@ export function WorkspaceSidebar({
|
||||
}: React.ComponentProps<typeof Sidebar> & { compactChrome?: boolean }) {
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { theme } = useTheme();
|
||||
const pathname = usePathname();
|
||||
const useWhiteSidebar = compactChrome && theme === "light";
|
||||
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
|
||||
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;其余 workspace 页保持原样。
|
||||
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
|
||||
|
||||
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
|
||||
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
|
||||
@@ -53,7 +59,12 @@ export function WorkspaceSidebar({
|
||||
>
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList showCollapseTrigger compact />
|
||||
<RecentChatList compact />
|
||||
{isGeneralQa && <FrequentAgentsList />}
|
||||
<RecentChatList
|
||||
compact
|
||||
limit={isGeneralQa ? 10 : undefined}
|
||||
moreHref={isGeneralQa ? "/page/workspace/chats" : undefined}
|
||||
/>
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
||||
@@ -14,7 +14,7 @@ models:
|
||||
display_name: DeepSeek Chat
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-chat
|
||||
api_key: sk-3fee562f834a4c08bb0d471ef44cb445
|
||||
api_key: sk-35345154983047aba9e12faa86c5f62c
|
||||
base_url: https://api.deepseek.com
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
|
||||
Reference in new issue
Block a user