Merge branch 'dhk-1' into 'master'
feat(roundtable): 第二步席位执行模式(快速/思考/专业问答/多智能体)+ 子智能体任务卡片 + 后台完成跨页通知 + 链条默认模式持久化 See merge request knowledge-base-bj/deerflow-server!124
This commit is contained in:
commit
fe0dc8d1a5
48 files changed
+1733
-598
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` 与默认落地页可能需要调整,需同步确认。
|
||||
@@ -54,7 +54,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
|
||||
};
|
||||
|
||||
const hasActiveChild = (item: MenuItem): boolean => {
|
||||
if (item.path && pathname === item.path) {
|
||||
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
|
||||
inWujieEnv
|
||||
? item.id === activeTopId
|
||||
: item.path
|
||||
? pathname === item.path
|
||||
? pathname === item.path || pathname.startsWith(item.path + "/")
|
||||
: hasActiveChild(item);
|
||||
const handleSidebarItemClick = (item: MenuItem) => {
|
||||
if (inWujieEnv) {
|
||||
|
||||
@@ -42,7 +42,7 @@ type PageSidebarProps = {
|
||||
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
|
||||
|
||||
const hasActiveChild = (item: MenuItem, pathname: string): boolean => {
|
||||
if (item.path && pathname === item.path) {
|
||||
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
|
||||
return true;
|
||||
}
|
||||
return item.children?.some((child) => hasActiveChild(child, pathname)) ?? false;
|
||||
@@ -116,7 +116,9 @@ function SecondaryNavSidebar() {
|
||||
const secondaryItems = activeTopItem?.children ?? [];
|
||||
|
||||
const isItemActive = (item: MenuItem) =>
|
||||
item.path ? pathname === item.path : hasActiveChild(item, pathname);
|
||||
item.path
|
||||
? pathname === item.path || pathname.startsWith(item.path + "/")
|
||||
: hasActiveChild(item, pathname);
|
||||
|
||||
const handleItemClick = (item: MenuItem) => {
|
||||
const path = item.path ?? findFirstRoutePath(item.children);
|
||||
@@ -210,7 +212,7 @@ function SecondaryNavSidebar() {
|
||||
{thirdItems.map((child) => {
|
||||
const ChildIcon = child.icon;
|
||||
const childActive = child.path
|
||||
? pathname === child.path
|
||||
? pathname === child.path || pathname.startsWith(child.path + "/")
|
||||
: hasActiveChild(child, pathname);
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useStickToBottomContext } from "use-stick-to-bottom";
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
@@ -52,6 +53,38 @@ import { SubtaskCard } from "./subtask-card";
|
||||
export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160;
|
||||
export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
|
||||
|
||||
/** 用户发送新问题时强制滚到底部(即使用户此前上滑看过历史)。 */
|
||||
function ScrollToBottomOnUserMessage({
|
||||
threadId,
|
||||
messages,
|
||||
}: {
|
||||
threadId: string;
|
||||
messages: Message[];
|
||||
}) {
|
||||
const { scrollToBottom } = useStickToBottomContext();
|
||||
const prevHumanCountRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
prevHumanCountRef.current = null;
|
||||
}, [threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
const humanCount = messages.reduce(
|
||||
(count, message) => count + (message.type === "human" ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
const prev = prevHumanCountRef.current;
|
||||
prevHumanCountRef.current = humanCount;
|
||||
if (prev !== null && humanCount > prev) {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => scrollToBottom());
|
||||
});
|
||||
}
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
className,
|
||||
contentClassName,
|
||||
@@ -266,6 +299,7 @@ export function MessageList({
|
||||
<Conversation
|
||||
className={cn("flex size-full flex-col justify-center", className)}
|
||||
>
|
||||
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
|
||||
<ConversationContent
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -165,7 +165,7 @@ export function WorkspaceNavMenu() {
|
||||
Checkpoint 迁移
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
{isAdmin ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/strategy/admin/user-leaderboard")
|
||||
@@ -174,6 +174,15 @@ export function WorkspaceNavMenu() {
|
||||
<TrophyIcon />
|
||||
用户排行榜
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/strategy/user-leaderboard")
|
||||
}
|
||||
>
|
||||
<TrophyIcon />
|
||||
用户排行榜
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -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,故不受本布局对
|
||||
@@ -44,7 +50,7 @@ export function WorkspaceSidebar({
|
||||
variant="sidebar"
|
||||
collapsible="none"
|
||||
className={cn(
|
||||
"h-full w-(--sidebar-width)",
|
||||
"h-full w-(--sidebar-width) border-r border-border",
|
||||
useWhiteSidebar &&
|
||||
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
|
||||
className,
|
||||
@@ -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>
|
||||
|
||||
@@ -43,6 +43,8 @@ export type MenuItem = {
|
||||
label: string;
|
||||
icon: ElementType;
|
||||
path?: string;
|
||||
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
|
||||
userPath?: string;
|
||||
adminOnly?: boolean;
|
||||
children?: MenuItem[];
|
||||
};
|
||||
@@ -81,18 +83,18 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
label: "XK中任务",
|
||||
icon: ClipboardList,
|
||||
children: [
|
||||
{
|
||||
id: "xk-task-jc",
|
||||
label: "任务JC",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/xk-task-jc",
|
||||
},
|
||||
{
|
||||
id: "xk-task-dispatch",
|
||||
label: "任务调度",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/xk-task-dispatch",
|
||||
},
|
||||
// {
|
||||
// id: "xk-task-jc",
|
||||
// label: "任务JC",
|
||||
// icon: ListTodo,
|
||||
// path: "/page/strategy/task-management/xk-task-jc",
|
||||
// },
|
||||
// {
|
||||
// id: "xk-task-dispatch",
|
||||
// label: "任务调度",
|
||||
// icon: ListTodo,
|
||||
// path: "/page/strategy/task-management/xk-task-dispatch",
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -125,24 +127,24 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
label: "PG中任务",
|
||||
icon: Gauge,
|
||||
children: [
|
||||
{
|
||||
id: "pg-task-pg",
|
||||
label: "任务PG",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/effect-task-pg",
|
||||
},
|
||||
{
|
||||
id: "pg-task-xdpg",
|
||||
label: "XDPG",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/effect-action-pg",
|
||||
},
|
||||
{
|
||||
id: "pg-task-info-pg",
|
||||
label: "信息PG",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/effect-info-pg",
|
||||
},
|
||||
// {
|
||||
// id: "pg-task-pg",
|
||||
// label: "任务PG",
|
||||
// icon: ListTodo,
|
||||
// path: "/page/strategy/task-management/effect-task-pg",
|
||||
// },
|
||||
// {
|
||||
// id: "pg-task-xdpg",
|
||||
// label: "XDPG",
|
||||
// icon: ListTodo,
|
||||
// path: "/page/strategy/task-management/effect-action-pg",
|
||||
// },
|
||||
// {
|
||||
// id: "pg-task-info-pg",
|
||||
// label: "信息PG",
|
||||
// icon: ListTodo,
|
||||
// path: "/page/strategy/task-management/effect-info-pg",
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -150,24 +152,24 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
label: "TY中任务",
|
||||
icon: Radar,
|
||||
children: [
|
||||
{
|
||||
id: "ty-task-xdty",
|
||||
label: "XDTY任务",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/mnty-task-mechanism",
|
||||
},
|
||||
{
|
||||
id: "ty-task-plan",
|
||||
label: "方案TY任务",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/mnty-task-plan",
|
||||
},
|
||||
{
|
||||
id: "ty-task-xxty",
|
||||
label: "XXTY任务",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/mnty-task-zsty",
|
||||
},
|
||||
// {
|
||||
// id: "ty-task-xdty",
|
||||
// label: "XDTY任务",
|
||||
// icon: ListTodo,
|
||||
// path: "/page/strategy/task-management/mnty-task-mechanism",
|
||||
// },
|
||||
// {
|
||||
// id: "ty-task-plan",
|
||||
// label: "方案TY任务",
|
||||
// icon: ListTodo,
|
||||
// path: "/page/strategy/task-management/mnty-task-plan",
|
||||
// },
|
||||
// {
|
||||
// id: "ty-task-xxty",
|
||||
// label: "XXTY任务",
|
||||
// icon: ListTodo,
|
||||
// path: "/page/strategy/task-management/mnty-task-zsty",
|
||||
// },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -197,6 +199,13 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
label: "综合管理",
|
||||
icon: Settings,
|
||||
children: [
|
||||
{
|
||||
id: "mgmt-user-leaderboard",
|
||||
label: "用户排行榜",
|
||||
icon: Trophy,
|
||||
path: "/page/strategy/admin/user-leaderboard",
|
||||
userPath: "/page/strategy/user-leaderboard",
|
||||
},
|
||||
{ id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
|
||||
{
|
||||
id: "mgmt-business-chain-mapping",
|
||||
@@ -232,13 +241,6 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
path: "/page/strategy/admin/users",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-user-leaderboard",
|
||||
label: "用户排行榜",
|
||||
icon: Trophy,
|
||||
path: "/page/strategy/admin/user-leaderboard",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-admin-scheduled-tasks",
|
||||
label: "定时任务管理",
|
||||
@@ -260,12 +262,12 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
label: "资源管理",
|
||||
icon: Database,
|
||||
children: [
|
||||
{
|
||||
id: "resource-network-xwt",
|
||||
label: "网络xwt资源管理",
|
||||
icon: Network,
|
||||
path: "/page/strategy/resource-management/resource-network-actor",
|
||||
},
|
||||
// {
|
||||
// id: "resource-network-xwt",
|
||||
// label: "网络xwt资源管理",
|
||||
// icon: Network,
|
||||
// path: "/page/strategy/resource-management/resource-network-actor",
|
||||
// },
|
||||
{
|
||||
id: "resource-knowledge-native",
|
||||
label: "问答梳理",
|
||||
@@ -278,12 +280,12 @@ export const sidebarMenuItems: MenuItem[] = [
|
||||
icon: Library,
|
||||
path: "/page/strategy/resource-management/resource-knowledge-base",
|
||||
},
|
||||
{
|
||||
id: "resource-evaluation-index",
|
||||
label: "作战资源管理",
|
||||
icon: FileBox,
|
||||
path: "/page/strategy/resource-management/resource-evaluation-index",
|
||||
},
|
||||
// {
|
||||
// id: "resource-evaluation-index",
|
||||
// label: "作战资源管理",
|
||||
// icon: FileBox,
|
||||
// path: "/page/strategy/resource-management/resource-evaluation-index",
|
||||
// },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -298,9 +300,9 @@ export const defaultExpandedIds: ReadonlySet<string> = new Set([
|
||||
"comprehensive-management",
|
||||
]);
|
||||
|
||||
/** 该菜单项自身或任意后代的 path 是否等于当前路由 */
|
||||
/** 该菜单项自身或任意后代的 path 是否匹配当前路由(支持子路径,如 /knowledge/:id) */
|
||||
export function menuItemContainsPath(item: MenuItem, pathname: string): boolean {
|
||||
if (item.path && item.path === pathname) {
|
||||
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
|
||||
return true;
|
||||
}
|
||||
return item.children?.some((child) => menuItemContainsPath(child, pathname)) ?? false;
|
||||
@@ -308,7 +310,8 @@ export function menuItemContainsPath(item: MenuItem, pathname: string): boolean
|
||||
|
||||
/**
|
||||
* 在「一级 → 二级 → 三级」菜单中,取当前路由所在的「激活二级项」的三级子项。
|
||||
* 浅色侧边栏据此在二级右侧展开三级列;无三级则返回空数组(侧边栏保持窄栏)。
|
||||
* 浅色侧边栏据此在二级右侧展开三级列;深色顶栏据此渲染三级横条。
|
||||
* 全量模式:三级仅一项时不返回(无选择必要,侧边栏保持窄栏 / 不渲染横条)。
|
||||
*/
|
||||
export function getActiveThirdLevelItems(
|
||||
items: MenuItem[],
|
||||
@@ -320,16 +323,23 @@ export function getActiveThirdLevelItems(
|
||||
const activeSecond = secondary.find(
|
||||
(item) => (item.children?.length ?? 0) > 0 && menuItemContainsPath(item, pathname),
|
||||
);
|
||||
return activeSecond?.children ?? [];
|
||||
const third = activeSecond?.children ?? [];
|
||||
return third.length <= 1 ? [] : third;
|
||||
}
|
||||
|
||||
/** 按 admin 角色过滤菜单:非 admin 不展示 ``adminOnly: true`` 的项及其子项 */
|
||||
/** 按 admin 角色过滤菜单:非 admin 不展示 ``adminOnly: true`` 的项;``userPath`` 替换 ``path``。 */
|
||||
export function filterMenuItemsByRole(items: MenuItem[], isAdmin: boolean): MenuItem[] {
|
||||
return items
|
||||
.filter((item) => !item.adminOnly || isAdmin)
|
||||
.map((item) =>
|
||||
item.children
|
||||
? { ...item, children: filterMenuItemsByRole(item.children, isAdmin) }
|
||||
: item,
|
||||
);
|
||||
.map((item) => {
|
||||
const children = item.children
|
||||
? filterMenuItemsByRole(item.children, isAdmin)
|
||||
: undefined;
|
||||
const base = children ? { ...item, children } : item;
|
||||
if (!isAdmin && base.userPath) {
|
||||
const { userPath: _userPath, ...rest } = base;
|
||||
return { ...rest, path: base.userPath };
|
||||
}
|
||||
return base;
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronRight, Sparkles, ListChecks, Zap, Loader2, CheckCircle2, CircleStop, Search } from 'lucide-react';
|
||||
import { ChevronRight, Sparkles, ListChecks, Zap, Loader2, CheckCircle2, CircleStop, Search, AlertTriangle, Shuffle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SkillSearchStep } from '../../types/ai-writing';
|
||||
import { CollapseTransition } from './CollapseTransition';
|
||||
@@ -16,6 +16,8 @@ const STEP_ICON: Record<SkillSearchStep['type'], typeof Sparkles> = {
|
||||
select_skills: Sparkles,
|
||||
invoke_skill: Zap,
|
||||
skill_result: CheckCircle2,
|
||||
skill_error: AlertTriangle,
|
||||
fallback: Shuffle,
|
||||
stop_early: CircleStop,
|
||||
};
|
||||
|
||||
@@ -25,6 +27,8 @@ const STEP_LABEL: Record<SkillSearchStep['type'], string> = {
|
||||
select_skills: '筛选相关技能',
|
||||
invoke_skill: '调用技能',
|
||||
skill_result: '检索结果',
|
||||
skill_error: '技能调用失败',
|
||||
fallback: '改用其他技能',
|
||||
stop_early: '提前停止',
|
||||
};
|
||||
|
||||
@@ -71,6 +75,9 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
|
||||
const label = STEP_LABEL[step.type] ?? step.type;
|
||||
const isStopEarly = step.type === 'stop_early';
|
||||
const isResult = step.type === 'skill_result';
|
||||
// 容错:技能调用失败标红,回退其它技能标琥珀,提示用户"出错了但已自动改用其他技能"
|
||||
const isError = step.type === 'skill_error';
|
||||
const isFallback = step.type === 'fallback';
|
||||
const isActive = idx === activeIdx;
|
||||
return (
|
||||
<li key={idx} className="flex items-start gap-2 text-[11px]">
|
||||
@@ -79,11 +86,13 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
|
||||
'shrink-0 mt-0.5 inline-flex items-center justify-center size-4 rounded-full text-[10px] font-medium',
|
||||
isActive
|
||||
? 'bg-violet-500 text-white'
|
||||
: isStopEarly
|
||||
? 'bg-amber-100 dark:bg-amber-900/60 text-amber-600 dark:text-amber-300'
|
||||
: isResult
|
||||
? 'bg-emerald-100 dark:bg-emerald-900/60 text-emerald-600 dark:text-emerald-300'
|
||||
: 'bg-violet-100 dark:bg-violet-900/60 text-violet-600 dark:text-violet-300',
|
||||
: isError
|
||||
? 'bg-rose-100 dark:bg-rose-900/60 text-rose-600 dark:text-rose-300'
|
||||
: isStopEarly || isFallback
|
||||
? 'bg-amber-100 dark:bg-amber-900/60 text-amber-600 dark:text-amber-300'
|
||||
: isResult
|
||||
? 'bg-emerald-100 dark:bg-emerald-900/60 text-emerald-600 dark:text-emerald-300'
|
||||
: 'bg-violet-100 dark:bg-violet-900/60 text-violet-600 dark:text-violet-300',
|
||||
)}
|
||||
>
|
||||
{isActive ? <Loader2 className="size-2.5 animate-spin" /> : idx + 1}
|
||||
@@ -92,11 +101,13 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 font-medium',
|
||||
isStopEarly
|
||||
? 'text-amber-700 dark:text-amber-300'
|
||||
: isResult
|
||||
? 'text-emerald-700 dark:text-emerald-300'
|
||||
: 'text-violet-700 dark:text-violet-300',
|
||||
isError
|
||||
? 'text-rose-700 dark:text-rose-300'
|
||||
: isStopEarly || isFallback
|
||||
? 'text-amber-700 dark:text-amber-300'
|
||||
: isResult
|
||||
? 'text-emerald-700 dark:text-emerald-300'
|
||||
: 'text-violet-700 dark:text-violet-300',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3 shrink-0" />
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface AIMaterial {
|
||||
// select_skills:LLM 按相关度排序的候选
|
||||
// invoke_skill:开始调用某个技能去检索
|
||||
// skill_result:某个技能检索返回了 N 条素材
|
||||
// skill_error:某个技能调用失败(超时/报错),将改用其他技能(容错)
|
||||
// fallback:配置技能全部失败,回退到其他技能继续搜集(容错)
|
||||
// stop_early:累计素材达到上限,停止后续技能
|
||||
export interface SkillSearchStep {
|
||||
type:
|
||||
@@ -28,6 +30,8 @@ export interface SkillSearchStep {
|
||||
| "select_skills"
|
||||
| "invoke_skill"
|
||||
| "skill_result"
|
||||
| "skill_error"
|
||||
| "fallback"
|
||||
| "stop_early";
|
||||
message: string;
|
||||
skillNames?: string[];
|
||||
|
||||
@@ -133,6 +133,8 @@ export default function LightAppManagementPage() {
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [activeParamPopover, setActiveParamPopover] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<"table" | "cards">("table");
|
||||
const [categoryFilter, setCategoryFilter] = useState<"all" | LightAppType>("all");
|
||||
const [mountFilter, setMountFilter] = useState<string>("");
|
||||
|
||||
const saving = actions.create.isPending || actions.update.isPending;
|
||||
|
||||
@@ -220,7 +222,22 @@ export default function LightAppManagementPage() {
|
||||
toast.success("已复制地址");
|
||||
};
|
||||
|
||||
const tableApps = useMemo(() => apps, [apps]);
|
||||
const selectCategory = (cat: "all" | LightAppType) => {
|
||||
setCategoryFilter(cat);
|
||||
if (cat !== "iframe") setMountFilter("");
|
||||
};
|
||||
|
||||
const tableApps = useMemo(
|
||||
() =>
|
||||
apps.filter((app) => {
|
||||
if (categoryFilter !== "all" && app.appType !== categoryFilter) return false;
|
||||
if (categoryFilter === "iframe" && mountFilter && app.mountLocation !== mountFilter) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
[apps, categoryFilter, mountFilter],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="light-app-management-page h-full overflow-y-auto bg-neutral-50 dark:bg-[#0C1836] dark:text-[#C5CFE0]">
|
||||
@@ -252,7 +269,7 @@ export default function LightAppManagementPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="lam-toolbar flex flex-col items-center gap-3.5 rounded-[5px] bg-white p-3 shadow-[0_1px_3px_rgba(0,0,0,0.01)] md:flex-row">
|
||||
<div className="lam-toolbar flex flex-col items-center gap-3.5 rounded-[5px] bg-white p-3 shadow-[0_1px_3px_rgba(0,0,0,0.01)] md:flex-row md:flex-wrap">
|
||||
<div className="relative w-full flex-1">
|
||||
<Search className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-400" />
|
||||
<input
|
||||
@@ -272,6 +289,41 @@ export default function LightAppManagementPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="lam-cat-toggle flex shrink-0 items-center space-x-0.5 self-end rounded-[5px] border border-neutral-200/50 bg-neutral-100/60 p-0.5 md:self-auto">
|
||||
{(
|
||||
[
|
||||
{ value: "all", label: "全部" },
|
||||
{ value: "window_open", label: "新页跳转" },
|
||||
{ value: "iframe", label: "iframe 嵌套" },
|
||||
] as const
|
||||
).map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
onClick={() => selectCategory(c.value)}
|
||||
className={`lam-cat-toggle-btn cursor-pointer rounded-[5px] px-4 py-1.5 text-xs font-bold transition-all ${
|
||||
categoryFilter === c.value
|
||||
? "is-active bg-white text-[#F5222D] shadow-sm"
|
||||
: "text-neutral-500 hover:text-neutral-700"
|
||||
}`}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{categoryFilter === "iframe" && (
|
||||
<div className="lam-mount-filter w-full shrink-0 animate-in fade-in self-end md:w-[180px] md:self-auto">
|
||||
<Select
|
||||
className="w-full"
|
||||
value={mountFilter || undefined}
|
||||
placeholder="按挂载菜单筛选"
|
||||
clearable
|
||||
options={MOUNT_OPTIONS}
|
||||
onChange={(v) => setMountFilter((v as string) ?? "")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="lam-view-toggle flex shrink-0 items-center space-x-0.5 self-end rounded-[5px] border border-neutral-200/50 bg-neutral-100/60 p-0.5 md:self-auto">
|
||||
<button
|
||||
onClick={() => setViewMode("table")}
|
||||
@@ -650,7 +702,7 @@ export default function LightAppManagementPage() {
|
||||
onClick={() => setDialogOpen(false)}
|
||||
/>
|
||||
|
||||
<div className="lam-dialog relative z-[110] m-4 flex max-h-[90vh] w-full max-w-[580px] flex-col rounded-[5px] border border-[#E8E8E8] bg-white p-6 shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
|
||||
<div className="lam-dialog relative z-[110] m-4 flex max-h-[90vh] w-full max-w-[900px] flex-col rounded-[5px] border border-[#E8E8E8] bg-white p-6 shadow-[0_8px_32px_rgba(34,34,34,0.16)]">
|
||||
<div className="lam-dialog-header flex flex-shrink-0 items-center justify-between border-b border-[#E8E8E8] pb-4">
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<span className="lam-dialog-icon rounded-[5px] bg-rose-50 p-1.5 text-[#A8071A]">
|
||||
|
||||
@@ -17,6 +17,7 @@ import Header from "@/strategy-components/Header";
|
||||
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
|
||||
import RoundtableRoutes from "./RoundtableRoutes";
|
||||
import NotebookRoutes from "./NotebookRoutes";
|
||||
import { RoundtableJobNotifier } from "@/roundtable-planning/components/RoundtableJobNotifier";
|
||||
|
||||
function PageLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
@@ -43,6 +44,7 @@ function PageLayout() {
|
||||
// 高度链,根布局跟随容器高度。全屏下 100% 解析到视口,与 h-screen 等价;无界等嵌套
|
||||
// 下容器不占满整屏时,高度才能正确收缩而非锁死整屏。
|
||||
return (
|
||||
<RoundtableJobNotifier>
|
||||
<div className="h-full flex flex-col bg-muted/20">
|
||||
{pageSidebarAllowed && <Header collapsed={collapsed} />}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
@@ -97,6 +99,7 @@ function PageLayout() {
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</RoundtableJobNotifier>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import ChatPage from "./ChatPage";
|
||||
import ChatsPage from "./ChatsPage";
|
||||
import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage";
|
||||
import AdminUserLeaderboardSettingsPage from "./AdminUserLeaderboardSettingsPage";
|
||||
import PublicLeaderboardPage from "./PublicLeaderboardPage";
|
||||
import LightAppIframeView from "./LightAppIframeView";
|
||||
import LightAppListPage from "./LightAppListPage";
|
||||
import LightAppManagementPage from "./LightAppManagementPage";
|
||||
@@ -253,6 +254,7 @@ export default function StrategyRoutes() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route path="user-leaderboard" element={<PublicLeaderboardPage />} />
|
||||
<Route
|
||||
path="admin/user-leaderboard"
|
||||
element={
|
||||
|
||||
@@ -70,6 +70,7 @@ export function WorkspaceLayout({
|
||||
workspaceSidebarHidden = false,
|
||||
headerHidden = false,
|
||||
compactWorkspaceSidebar = false,
|
||||
defaultHistorySidebarOpen,
|
||||
wrapperOverflowHidden = false,
|
||||
insetOverflowHidden = false,
|
||||
pageSidebarDark = false,
|
||||
@@ -80,6 +81,8 @@ export function WorkspaceLayout({
|
||||
workspaceSidebarHidden?: boolean;
|
||||
headerHidden?: boolean;
|
||||
compactWorkspaceSidebar?: boolean;
|
||||
/** 全量模式下通用问答等页:聊天记录侧栏默认展开;未指定时紧凑模式仍默认收起。 */
|
||||
defaultHistorySidebarOpen?: boolean;
|
||||
wrapperOverflowHidden?: boolean;
|
||||
insetOverflowHidden?: boolean;
|
||||
pageSidebarDark?: boolean;
|
||||
@@ -110,8 +113,7 @@ export function WorkspaceLayout({
|
||||
}
|
||||
key={String(compactWorkspaceSidebar)}
|
||||
data-page-sidebar-collapsed={pageSidebarCollapsed}
|
||||
// 紧凑模式下历史侧栏默认收起(聊天区更宽),由聊天页右上角图标钮展开
|
||||
defaultOpen={!compactWorkspaceSidebar}
|
||||
defaultOpen={defaultHistorySidebarOpen ?? !compactWorkspaceSidebar}
|
||||
>
|
||||
{!workspaceSidebarHidden && (
|
||||
<WorkspaceSidebar compactChrome={compactWorkspaceSidebar} />
|
||||
@@ -153,7 +155,7 @@ function NewChatEntryRoute({
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
</ChatRuntime>
|
||||
@@ -201,7 +203,7 @@ export default function WorkspaceRoutes({
|
||||
<Route
|
||||
path="chats/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
</ChatRuntime>
|
||||
|
||||
@@ -65,22 +65,26 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dark .light-app-management-page .lam-view-toggle {
|
||||
.dark .light-app-management-page .lam-view-toggle,
|
||||
.dark .light-app-management-page .lam-cat-toggle {
|
||||
background: var(--color-bg-page);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.dark .light-app-management-page .lam-view-toggle-btn.is-active {
|
||||
.dark .light-app-management-page .lam-view-toggle-btn.is-active,
|
||||
.dark .light-app-management-page .lam-cat-toggle-btn.is-active {
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-link);
|
||||
box-shadow: var(--shadow-1);
|
||||
}
|
||||
|
||||
.dark .light-app-management-page .lam-view-toggle-btn:not(.is-active) {
|
||||
.dark .light-app-management-page .lam-view-toggle-btn:not(.is-active),
|
||||
.dark .light-app-management-page .lam-cat-toggle-btn:not(.is-active) {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.dark .light-app-management-page .lam-view-toggle-btn:not(.is-active):hover {
|
||||
.dark .light-app-management-page .lam-view-toggle-btn:not(.is-active):hover,
|
||||
.dark .light-app-management-page .lam-cat-toggle-btn:not(.is-active):hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@@ -592,7 +596,8 @@
|
||||
}
|
||||
|
||||
/* ── 视图切换激活态 ──────────────────────────────────────────────────── */
|
||||
[data-theme="lightblue"] .light-app-management-page .lam-view-toggle-btn.is-active {
|
||||
[data-theme="lightblue"] .light-app-management-page .lam-view-toggle-btn.is-active,
|
||||
[data-theme="lightblue"] .light-app-management-page .lam-cat-toggle-btn.is-active {
|
||||
color: #0062D9;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
|
||||
import type { SeatMode } from "@/roundtable-planning/lib/seat-mode";
|
||||
|
||||
/** Fields accepted when creating a chain. */
|
||||
export interface ChainCreatePayload {
|
||||
@@ -28,6 +29,8 @@ export interface ChainCreatePayload {
|
||||
stageGoals?: (string | null)[] | null;
|
||||
/** Optional coordinator orchestration prompt (dag-only). null/"" = none. */
|
||||
coordinatorPrompt?: string | null;
|
||||
/** 默认席位执行模式(flash/thinking/pro/ultra);null = 未设。 */
|
||||
seatMode?: SeatMode | null;
|
||||
}
|
||||
|
||||
/** Partial update — send only what changed. */
|
||||
@@ -41,6 +44,8 @@ export interface ChainUpdatePayload {
|
||||
stageGoals?: (string | null)[] | null;
|
||||
/** 写给总控的编排提示;send `null`/"" to clear, omit to leave. */
|
||||
coordinatorPrompt?: string | null;
|
||||
/** 默认席位执行模式;send `null` to clear, omit to leave. */
|
||||
seatMode?: SeatMode | null;
|
||||
/** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */
|
||||
isPublic?: boolean;
|
||||
}
|
||||
@@ -70,6 +75,7 @@ interface ServerChain {
|
||||
stages?: string[][] | null;
|
||||
stage_goals?: (string | null)[] | null;
|
||||
coordinator_prompt?: string | null;
|
||||
seat_mode?: string | null;
|
||||
is_public?: boolean;
|
||||
is_mine?: boolean;
|
||||
owner_name?: string | null;
|
||||
@@ -86,6 +92,7 @@ function toChain(s: ServerChain): BusinessChain {
|
||||
stages: Array.isArray(s.stages) ? s.stages : null,
|
||||
stageGoals: Array.isArray(s.stage_goals) ? s.stage_goals : null,
|
||||
coordinatorPrompt: s.coordinator_prompt ?? null,
|
||||
seatMode: (s.seat_mode as SeatMode | null) ?? undefined,
|
||||
isPublic: s.is_public ?? false,
|
||||
// 后端缺省时按「我的」处理(向后兼容旧响应)。
|
||||
isMine: s.is_mine ?? true,
|
||||
@@ -104,6 +111,7 @@ function fromCreate(p: ChainCreatePayload) {
|
||||
...(p.stages !== undefined ? { stages: p.stages } : {}),
|
||||
...(p.stageGoals !== undefined ? { stage_goals: p.stageGoals } : {}),
|
||||
...(p.coordinatorPrompt !== undefined ? { coordinator_prompt: p.coordinatorPrompt } : {}),
|
||||
...(p.seatMode !== undefined ? { seat_mode: p.seatMode } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,6 +125,7 @@ function fromUpdate(p: ChainUpdatePayload) {
|
||||
// Forward stage goals even when explicitly null (clears goals server-side).
|
||||
if (p.stageGoals !== undefined) out.stage_goals = p.stageGoals;
|
||||
if (p.coordinatorPrompt !== undefined) out.coordinator_prompt = p.coordinatorPrompt;
|
||||
if (p.seatMode !== undefined) out.seat_mode = p.seatMode;
|
||||
if (p.isPublic !== undefined) out.is_public = p.isPublic;
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -202,6 +202,15 @@ export interface StreamMultiAgentRequest {
|
||||
displayName?: string;
|
||||
newMessage: string;
|
||||
model?: string;
|
||||
/**
|
||||
* 仅 special 用。席位执行模式(快速/思考/专业问答/多智能体问答)派生的运行参数,
|
||||
* 覆盖后端 `roundtable_run_policy` 的 thinking/reasoning 默认值。省略时后端按 policy
|
||||
* 默认执行(向后兼容,旧前端不传不破坏)。三者通常一起传(见 `lib/seat-mode.ts`)。
|
||||
*/
|
||||
thinkingEnabled?: boolean;
|
||||
reasoningEffort?: "minimal" | "low" | "medium" | "high";
|
||||
/** 仅 special 用;ultra 档置真,给席位解锁 `task` 子代理工具。 */
|
||||
subagentEnabled?: boolean;
|
||||
skillStopNames?: string[];
|
||||
/**
|
||||
* 仅 leader 用。置 true 表示这是「派活后让总控判断继续/综合」的轮次:后端会把各已交付
|
||||
@@ -444,6 +453,10 @@ export async function streamMultiAgent(
|
||||
display_name: req.displayName,
|
||||
new_message: req.newMessage,
|
||||
model: req.model,
|
||||
// 席位模式派生参数(仅 special);undefined 时不带上 → 后端按 policy 默认执行。
|
||||
thinking_enabled: req.thinkingEnabled,
|
||||
reasoning_effort: req.reasoningEffort,
|
||||
subagent_enabled: req.subagentEnabled,
|
||||
skill_stop_names: req.skillStopNames ?? [],
|
||||
// 仅 leader 用;综合轮让后端注入各席位完整交付正文。省略 → 后端按 false 处理。
|
||||
synthesis_mode: req.synthesisMode ?? false,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import { getAvatarTypeFor } from "../lib/roundtable-constants";
|
||||
import { seatModeToRunParams } from "../lib/seat-mode";
|
||||
import {
|
||||
isTerminalJobStatus,
|
||||
type DispatchChainNode,
|
||||
@@ -75,6 +76,9 @@ function mapJob(raw: Record<string, unknown>): RoundtableJob {
|
||||
}
|
||||
|
||||
function toStartBody(p: StartJobPayload) {
|
||||
// 席位模式 → thinking/reasoning/subagent,与前端实时路径(/run/stream)同一份映射;
|
||||
// 缺省(未选模式)时不带这三字段 → 后端按 policy 默认(≈pro),向后兼容。
|
||||
const seat = p.seatMode ? seatModeToRunParams(p.seatMode) : null;
|
||||
return {
|
||||
draftId: p.draftId ?? null,
|
||||
intent: p.intent,
|
||||
@@ -86,6 +90,13 @@ function toStartBody(p: StartJobPayload) {
|
||||
seedMessage: p.seedMessage,
|
||||
threadIds: p.threadIds ?? null,
|
||||
coordinatorName: p.coordinatorName ?? "roundtable-coordinator",
|
||||
...(seat
|
||||
? {
|
||||
thinking_enabled: seat.thinking_enabled,
|
||||
reasoning_effort: seat.reasoning_effort,
|
||||
subagent_enabled: seat.subagent_enabled,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*
|
||||
* 详见 `docs/multi-agent-business-chain-dev.md` §6.2 / §7 阶段 3。
|
||||
*/
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CheckIcon,
|
||||
@@ -44,12 +44,15 @@ export interface BusinessChainPickerProps {
|
||||
onCancel: () => void;
|
||||
/** 跳转到业务链条配置页(同时关闭弹窗)。 */
|
||||
onGoConfig: () => void;
|
||||
/** 选中链条变化时回调(含其 seatMode),父弹窗据此把执行模式默认设为链条的配置。 */
|
||||
onSelectedChainChange?: (chain: BusinessChain | null) => void;
|
||||
}
|
||||
|
||||
export function BusinessChainPicker({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
onGoConfig,
|
||||
onSelectedChainChange,
|
||||
}: BusinessChainPickerProps) {
|
||||
const [chains, setChains] = useState<BusinessChain[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -102,6 +105,15 @@ export function BusinessChainPicker({
|
||||
() => chains.find((c) => c.id === selectedId) ?? null,
|
||||
[chains, selectedId],
|
||||
);
|
||||
// 选中链条变化 → 通知父弹窗(用 ref 持有回调,只在 selectedChain 真正变化时触发,
|
||||
// 避免父级每次 render 重置用户已手动改过的执行模式)。
|
||||
const onSelectedChainChangeRef = useRef(onSelectedChainChange);
|
||||
useEffect(() => {
|
||||
onSelectedChainChangeRef.current = onSelectedChainChange;
|
||||
}, [onSelectedChainChange]);
|
||||
useEffect(() => {
|
||||
onSelectedChainChangeRef.current?.(selectedChain);
|
||||
}, [selectedChain]);
|
||||
const survivingSeats = useMemo(
|
||||
() => (selectedChain ? selectedChain.seats.filter((s) => isExisting(s.agent_id)) : []),
|
||||
[selectedChain, isExisting],
|
||||
|
||||
@@ -52,6 +52,8 @@ export interface MessageStepsCardProps {
|
||||
|
||||
/** 工具调用阶段的 step 才进入折叠卡;非工具步骤(connecting/streaming/tool_result)归到「思考」段。 */
|
||||
function isToolStep(step: BubbleStep): boolean {
|
||||
// `task`(派生子智能体)单独渲染成任务卡片(SubtaskCard),不在步骤折叠卡里重复展示。
|
||||
if (step.key === "tool_calling:task") return false;
|
||||
return step.key.startsWith("tool_calling:") && step.key.length > "tool_calling:".length;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,11 @@ import { MessageStepsCard } from "./MessageStepsCard";
|
||||
import { BusinessChainPicker } from "./BusinessChainPicker";
|
||||
import type { IntentSummary } from "../api/intent";
|
||||
import type { SeatSelectionResult } from "../lib/business-chain";
|
||||
import {
|
||||
DEFAULT_SEAT_MODE,
|
||||
SEAT_MODE_OPTIONS,
|
||||
type SeatMode,
|
||||
} from "../lib/seat-mode";
|
||||
import {
|
||||
filterRecommendCandidates,
|
||||
listRecommendHistory,
|
||||
@@ -200,6 +205,9 @@ export function RecommendAgentsDialog({
|
||||
const navigate = useNavigate();
|
||||
// 席位来源模式:智能推荐 / 业务链条。默认**业务链条**。
|
||||
const [mode, setMode] = useState<"recommend" | "chain">("chain");
|
||||
// 席位执行模式(快速/思考/专业问答/多智能体问答)——推荐 / 业务链条两种来源通用,
|
||||
// 决定第二步每个子智能体席位的推理深度。默认 pro(= 改造前席位固定行为)。
|
||||
const [seatMode, setSeatMode] = useState<SeatMode>(DEFAULT_SEAT_MODE);
|
||||
const [state, setState] = useState<InternalState>(INITIAL_STATE);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -515,6 +523,14 @@ export function RecommendAgentsDialog({
|
||||
return `已选 ${selectedCount} / ${MAX_SELECTED}`;
|
||||
})();
|
||||
|
||||
// 统一在回传前合并席位模式,推荐 / 业务链条两条确认路径共用,保证 seatMode 总被带上。
|
||||
const handleConfirmWithMode = useCallback(
|
||||
(result: SeatSelectionResult) => {
|
||||
onConfirm({ ...result, seatMode });
|
||||
},
|
||||
[onConfirm, seatMode],
|
||||
);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (!canConfirm) return;
|
||||
// Order: recommended-and-selected first (in recommender order), then
|
||||
@@ -530,8 +546,8 @@ export function RecommendAgentsDialog({
|
||||
for (const c of otherCards) {
|
||||
if (selectedIds.has(c.agent_id)) ordered.push({ agent_id: c.agent_id, name: c.name });
|
||||
}
|
||||
onConfirm({ agents: ordered, mode: "recommend" });
|
||||
}, [canConfirm, recommendedCards, otherCards, selectedIds, onConfirm]);
|
||||
handleConfirmWithMode({ agents: ordered, mode: "recommend" });
|
||||
}, [canConfirm, recommendedCards, otherCards, selectedIds, handleConfirmWithMode]);
|
||||
|
||||
const handleGoConfig = useCallback(() => {
|
||||
onCancel();
|
||||
@@ -595,6 +611,35 @@ export function RecommendAgentsDialog({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 席位执行模式:快速 / 思考 / 专业问答 / 多智能体问答。推荐 & 业务链条通用,
|
||||
决定第二步每个子智能体席位的推理深度(thinking/reasoning/subagent)。 */}
|
||||
<div className="shrink-0 space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">执行模式</span>
|
||||
<div className="inline-flex rounded-lg border border-border bg-muted/40 p-0.5 text-xs font-medium">
|
||||
{SEAT_MODE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setSeatMode(opt.value)}
|
||||
title={opt.description}
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 transition-colors",
|
||||
seatMode === opt.value
|
||||
? "bg-card text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
{SEAT_MODE_OPTIONS.find((o) => o.value === seatMode)?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ===== 智能推荐模式(现状,零行为变更)===== */}
|
||||
{mode === "recommend" && (
|
||||
<>
|
||||
@@ -750,9 +795,13 @@ export function RecommendAgentsDialog({
|
||||
{/* ===== 业务链条模式 ===== */}
|
||||
{mode === "chain" && (
|
||||
<BusinessChainPicker
|
||||
onConfirm={onConfirm}
|
||||
onConfirm={handleConfirmWithMode}
|
||||
onCancel={onCancel}
|
||||
onGoConfig={handleGoConfig}
|
||||
onSelectedChainChange={(chain) => {
|
||||
// 选中链条若配了默认执行模式 → 把顶部模式选择器默认设为它(用户仍可临时改)。
|
||||
if (chain?.seatMode) setSeatMode(chain.seatMode);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 圆桌「后台挂起作业」全工作区跨页完成通知器。
|
||||
*
|
||||
* 挂在 `PageLayout`(`/page/*` 的常驻层,导航切页不卸载)里:圆桌页点「后台挂起」后
|
||||
* 调 `watchJob(jobId)` 注册监听,本 Provider 用 `streamJob` 订阅该作业 SSE;作业跑到
|
||||
* 终态时弹一个**与「达成共识」同款**的顶部浮窗(bg-primary + Sparkles + bounce),
|
||||
* 因此用户切到聊天/任务/其它工作区页面也能收到完成提示。
|
||||
*
|
||||
* 自带浮窗(不走 sonner)—— 各工作区页各自挂了一个 sonner `<Toaster>`,若这里也用
|
||||
* sonner 会出现多 Toaster 重复弹;自渲染浮窗由本常驻 Provider 独占,天然不重复。
|
||||
*
|
||||
* 不含「刷新恢复」:watchedJobIds 是内存态,整页刷新后丢失(本期范围)。Provider 缺失
|
||||
* 时(如 embed 路由在 PageRoutes 之外)`watchJob` 为 no-op,调用方不报错。
|
||||
*/
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
import { streamJob } from "@/roundtable-planning/api/roundtable-jobs";
|
||||
import type { RoundtableJob } from "@/roundtable-planning/lib/job-types";
|
||||
|
||||
interface RoundtableJobNotifierValue {
|
||||
/** 注册一个后台作业 id,作业完成(done/error)时弹全局提示。重复 id 自动去重。 */
|
||||
watchJob: (jobId: string) => void;
|
||||
}
|
||||
|
||||
const RoundtableJobNotifierContext = createContext<RoundtableJobNotifierValue>({
|
||||
watchJob: () => {
|
||||
/* 无 Provider(如 embed 路由)时 no-op,不报错 */
|
||||
},
|
||||
});
|
||||
|
||||
export function useRoundtableJobNotifier(): RoundtableJobNotifierValue {
|
||||
return useContext(RoundtableJobNotifierContext);
|
||||
}
|
||||
|
||||
/** 单个作业的 SSE 监听器(无 UI):到终态回调 onTerminal;传输中断回调 onStreamError。 */
|
||||
function JobWatcher({
|
||||
jobId,
|
||||
onTerminal,
|
||||
onStreamError,
|
||||
}: {
|
||||
jobId: string;
|
||||
onTerminal: (job: RoundtableJob) => void;
|
||||
onStreamError: (jobId: string) => void;
|
||||
}) {
|
||||
const onTerminalRef = useRef(onTerminal);
|
||||
const onStreamErrorRef = useRef(onStreamError);
|
||||
useEffect(() => {
|
||||
onTerminalRef.current = onTerminal;
|
||||
onStreamErrorRef.current = onStreamError;
|
||||
}, [onTerminal, onStreamError]);
|
||||
useEffect(() => {
|
||||
const stop = streamJob(jobId, {
|
||||
onProgress: () => {},
|
||||
onDone: (job) => onTerminalRef.current(job),
|
||||
onError: () => onStreamErrorRef.current(jobId),
|
||||
});
|
||||
return () => stop();
|
||||
}, [jobId]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 提示浮窗存活时长(ms)—— 比页内 toast(3.5s) 略长,给跨页用户更多时间看到。 */
|
||||
const NOTICE_DURATION_MS = 6000;
|
||||
|
||||
export function RoundtableJobNotifier({ children }: { children: ReactNode }) {
|
||||
const [watchedJobIds, setWatchedJobIds] = useState<string[]>([]);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const watchJob = useCallback((jobId: string) => {
|
||||
setWatchedJobIds((prev) => (prev.includes(jobId) ? prev : [...prev, jobId]));
|
||||
}, []);
|
||||
|
||||
const handleTerminal = useCallback((job: RoundtableJob) => {
|
||||
setWatchedJobIds((prev) => prev.filter((id) => id !== job.id));
|
||||
if (job.status === "done") {
|
||||
setNotice("🎉 后台研讨已完成!可在「历史记录」查看结论与报告");
|
||||
} else if (job.status === "error") {
|
||||
setNotice("❌ 后台任务执行出错,请在「历史记录」中查看");
|
||||
}
|
||||
// cancelled:用户主动取消,不提示。
|
||||
}, []);
|
||||
|
||||
const handleStreamError = useCallback((jobId: string) => {
|
||||
// SSE 传输中断(非作业终态):静默停止监听,不误报完成。
|
||||
setWatchedJobIds((prev) => prev.filter((id) => id !== jobId));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!notice) return;
|
||||
const timer = setTimeout(() => setNotice(null), NOTICE_DURATION_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [notice]);
|
||||
|
||||
return (
|
||||
<RoundtableJobNotifierContext.Provider value={{ watchJob }}>
|
||||
{children}
|
||||
{watchedJobIds.map((id) => (
|
||||
<JobWatcher
|
||||
key={id}
|
||||
jobId={id}
|
||||
onTerminal={handleTerminal}
|
||||
onStreamError={handleStreamError}
|
||||
/>
|
||||
))}
|
||||
{notice && (
|
||||
<div
|
||||
className="fixed top-4 left-1/2 z-[100] flex -translate-x-1/2 transform items-center gap-2 rounded-xl border border-primary/40 bg-primary px-4 py-2.5 text-xs font-medium text-white shadow-lg animate-bounce"
|
||||
role="status"
|
||||
>
|
||||
<Sparkles className="h-4 w-4 shrink-0" />
|
||||
<span>{notice}</span>
|
||||
</div>
|
||||
)}
|
||||
</RoundtableJobNotifierContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -57,6 +57,8 @@ import {
|
||||
StreamingCursor,
|
||||
} from "@/roundtable-planning/components/MessageBubble";
|
||||
import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard";
|
||||
import { SubtaskCard } from "@/components/workspace/messages/subtask-card";
|
||||
import { useSubtask } from "@/core/tasks/context";
|
||||
import { ParallelSeatStrip } from "@/roundtable-planning/components/ParallelSeatStrip";
|
||||
import { DagFlowMiniPanel } from "@/roundtable-planning/components/DagFlowMiniPanel";
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
@@ -909,6 +911,15 @@ export function Step2Message({
|
||||
resolveAgentName={resolveAgentName}
|
||||
/>
|
||||
)}
|
||||
{/* 「多智能体问答」模式:本席位派生的子智能体任务卡片(复用主聊天 SubtaskCard,
|
||||
从共享 SubtaskContext 取状态)。同时发起多个 task → 多张卡片纵向堆叠。 */}
|
||||
{Array.isArray(msg.subtaskIds) && msg.subtaskIds.length > 0 && (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{msg.subtaskIds.map((taskId) => (
|
||||
<RoundtableSubtaskCard key={taskId} taskId={taskId} isLoading={isStreaming} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{reasoning && (
|
||||
<ReasoningBlock streaming={isStreaming}>{reasoning}</ReasoningBlock>
|
||||
)}
|
||||
@@ -932,6 +943,23 @@ export function Step2Message({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 子智能体任务卡片包裹层:子任务内容是**实时态**,存放在共享 SubtaskContext(主聊天同一份);
|
||||
* 草稿回显 / 历史记录等场景气泡上虽留有 subtaskId,但 context 里已无对应 Subtask —— 此时
|
||||
* 返回 null 不渲染(而非让 SubtaskCard 的 `useSubtask(id)!` 取到 undefined 崩溃)。
|
||||
*/
|
||||
function RoundtableSubtaskCard({
|
||||
taskId,
|
||||
isLoading,
|
||||
}: {
|
||||
taskId: string;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const task = useSubtask(taskId);
|
||||
if (!task) return null;
|
||||
return <SubtaskCard taskId={taskId} isLoading={isLoading} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集气泡里所有"写了文件"的产出路径,按出现顺序去重,聚合到一行文件卡片。
|
||||
*
|
||||
|
||||
@@ -120,6 +120,11 @@ export interface UseDraftPersistenceReturn {
|
||||
* 发起前 / 点查看前各存一次,保住当时的聊天记录不丢。返回 draft id。
|
||||
*/
|
||||
saveDraftNow: (furthestStep?: 1 | 2 | 3) => Promise<string | null>;
|
||||
/**
|
||||
* 用**显式传入的 step2 快照**强制存一次(不读 live 状态)。「后台挂起直接跳转」用:
|
||||
* 跳转会清空 step2,调用方在跳转前冻结快照传进来,保证落库的是挂起那一刻的研讨记录。
|
||||
*/
|
||||
saveStep2SnapshotNow: (step2: DraftStep2Snapshot, furthestStep?: 1 | 2 | 3) => Promise<string | null>;
|
||||
/** 强制刷新草稿列表。 */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
@@ -287,6 +292,51 @@ export function useDraftPersistence(
|
||||
[currentStep, enqueueSave, refreshList],
|
||||
);
|
||||
|
||||
/**
|
||||
* 用**显式传入的 step2 快照**强制存一次(不读 live 状态)。
|
||||
*
|
||||
* 「后台挂起改直接跳转」用:点挂起后页面立刻 resetToStep1 会清空 step2,而存稿是
|
||||
* fire-and-forget 在跳转之后才真正落库——若仍走 `saveDraftNow`(其 `getStep2Snapshot`
|
||||
* 是延迟读取)就会存到被重置后的空 step2。调用方在跳转前同步 `getStep2Snapshot()` 冻结
|
||||
* 一份快照传进来,这里只认这份,确保存的是挂起那一刻的研讨记录。
|
||||
*
|
||||
* 有草稿 id → update(保护重命名,不带 title);无 id → 用当前 step1 + 传入 step2 建档。
|
||||
*/
|
||||
const saveStep2SnapshotNow = useCallback(
|
||||
(step2: DraftStep2Snapshot, furthestStep: 1 | 2 | 3 = 2): Promise<string | null> => {
|
||||
const run = async (): Promise<string | null> => {
|
||||
const id = currentDraftIdRef.current;
|
||||
if (id) {
|
||||
const updated = await updateDraft(id, { furthestStep, step2 });
|
||||
if (updated) {
|
||||
currentDraftIdRef.current = updated.id;
|
||||
return updated.id;
|
||||
}
|
||||
// 服务端已不存在 → 落回新建。
|
||||
}
|
||||
const step1 = getStep1Snapshot();
|
||||
if (!hasSavableContent(step1, step2)) return null;
|
||||
const created = await createDraft({
|
||||
...(taskIdRef.current ? { taskId: taskIdRef.current } : {}),
|
||||
title: deriveDraftName({
|
||||
intentReady: step1.intentReady,
|
||||
interactiveMessages: step1.interactiveMessages,
|
||||
}),
|
||||
furthestStep,
|
||||
step1,
|
||||
step2,
|
||||
});
|
||||
currentDraftIdRef.current = created.id;
|
||||
setCurrentDraftId(created.id);
|
||||
return created.id;
|
||||
};
|
||||
const next = saveQueueRef.current.then(run, run);
|
||||
saveQueueRef.current = next.catch(() => undefined);
|
||||
return next as Promise<string | null>;
|
||||
},
|
||||
[getStep1Snapshot, hasSavableContent, setCurrentDraftId],
|
||||
);
|
||||
|
||||
const handleManualSave = useCallback(() => {
|
||||
const furthest = (currentStep as 1 | 2 | 3) || 1;
|
||||
void (async () => {
|
||||
@@ -535,6 +585,7 @@ export function useDraftPersistence(
|
||||
handleCancelRename,
|
||||
ensureDraft,
|
||||
saveDraftNow,
|
||||
saveStep2SnapshotNow,
|
||||
/** 强制刷新草稿列表(如后台挂起后让「进行中」状态立刻出现)。 */
|
||||
refresh: refreshList,
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type ThreadIdMap,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import { uploadFiles } from "@/core/uploads/api";
|
||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||
|
||||
/** 干预消息携带的上传文件引用(与 streamMultiAgent 的 files 字段同形)。 */
|
||||
type RunFileRef = NonNullable<StreamMultiAgentRequest["files"]>[number];
|
||||
@@ -37,12 +38,56 @@ function takeOnce<T>(ref: { current: T | null }): T | undefined {
|
||||
ref.current = null;
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从一帧里识别子智能体 custom 事件(task_started/running/completed/failed/cancelled/
|
||||
* timed_out)。custom 流帧通常是裸 payload 对象 `{type:"task_*", task_id, ...}`;
|
||||
* 开启 stream_subgraphs 时也可能被包成 `[namespace, payload]`,这里两种都兼容。
|
||||
*/
|
||||
function asSubagentTaskEvent(frame: unknown): {
|
||||
type: string;
|
||||
task_id: string;
|
||||
message?: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
description?: unknown;
|
||||
} | null {
|
||||
const cand =
|
||||
frame && typeof frame === "object" && !Array.isArray(frame)
|
||||
? (frame as Record<string, unknown>)
|
||||
: Array.isArray(frame) && frame.length >= 2 && frame[1] && typeof frame[1] === "object"
|
||||
? (frame[1] as Record<string, unknown>)
|
||||
: null;
|
||||
if (!cand) return null;
|
||||
const type = typeof cand.type === "string" ? cand.type : "";
|
||||
const taskId = typeof cand.task_id === "string" ? cand.task_id : "";
|
||||
if (!type.startsWith("task_") || !taskId) return null;
|
||||
return { type, task_id: taskId, message: cand.message, result: cand.result, error: cand.error, description: cand.description };
|
||||
}
|
||||
|
||||
/** 从 LangGraph 消息 chunk 的 content(string 或结构化 block 数组)里抽纯文本。 */
|
||||
function extractFrameText(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
let out = "";
|
||||
for (const item of content) {
|
||||
if (typeof item === "string") out += item;
|
||||
else if (item && typeof item === "object") {
|
||||
const block = item as Record<string, unknown>;
|
||||
if (typeof block.text === "string") out += block.text;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
import {
|
||||
buildDagStageLeaderTask,
|
||||
pickDagSeatTask,
|
||||
type OrchestrationPlan,
|
||||
} from "@/roundtable-planning/lib/dag-types";
|
||||
import type { ChainNodeState } from "@/roundtable-planning/lib/job-types";
|
||||
import { seatModeToRequestFields, type SeatMode } from "@/roundtable-planning/lib/seat-mode";
|
||||
import {
|
||||
DEFAULT_SELECTED_AGENTS,
|
||||
MAX_CYCLES,
|
||||
@@ -65,6 +110,12 @@ export type Step2Dialogue = {
|
||||
streaming?: boolean;
|
||||
/** special 席位气泡对应的 LangGraph thread,用于沙箱预览。 */
|
||||
agentThreadId?: string;
|
||||
/**
|
||||
* 「多智能体问答」模式下,本席位用 `task` 工具派生的子智能体任务 id 列表(=task 工具
|
||||
* 调用的 tool_call_id)。渲染时据此从共享 SubtaskContext 取每个 Subtask 渲染任务卡片
|
||||
* (复用主聊天 `SubtaskCard`,见 Step2Panel.Step2Message)。非 ultra 模式恒为空。
|
||||
*/
|
||||
subtaskIds?: string[];
|
||||
steps?: Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
@@ -222,6 +273,12 @@ export interface UseStep2OrchestrationOptions {
|
||||
currentStep: number;
|
||||
/** 全局选中模型(顶栏「推理模型」下拉)。 */
|
||||
selectedModel: string;
|
||||
/**
|
||||
* 席位执行模式(快速/思考/专业问答/多智能体问答)。决定第二步**每个子智能体席位**
|
||||
* 的推理深度(thinking/reasoning_effort/subagent);总控派活不受影响。由「选择参会
|
||||
* 智能体」弹窗确认时的 `SeatSelectionResult.seatMode` 决定。缺省 `pro`。
|
||||
*/
|
||||
selectedMode: SeatMode;
|
||||
/**
|
||||
* 编排模式:
|
||||
* - `recommend`(默认)—— 总控自由派活循环(`runOrchestration`);
|
||||
@@ -376,6 +433,7 @@ export function useStep2Orchestration(
|
||||
const {
|
||||
currentStep,
|
||||
selectedModel,
|
||||
selectedMode,
|
||||
orchestrationMode,
|
||||
orchestrationPlan,
|
||||
dagExecMode = "fast",
|
||||
@@ -507,6 +565,8 @@ export function useStep2Orchestration(
|
||||
// runOrchestration 的 useCallback deps 大幅收窄,避免每次模型切换、agentConfigs
|
||||
// 改写都重建一遍循环函数 —— 之前没收窄时 useEffect 会因 deps 变化重复 init。
|
||||
const selectedModelRef = useRef<string>(selectedModel);
|
||||
// 席位执行模式同样打到 ref,让席位调用在编排循环里读到最新值而不重建回调。
|
||||
const selectedModeRef = useRef<SeatMode>(selectedMode);
|
||||
const agentConfigsRef = useRef<Record<number, AgentConfig>>(agentConfigs);
|
||||
const agentNameToRoleRef = useRef(agentNameToRole);
|
||||
const threadIdsRef = useRef<ThreadIdMap | null>(threadIds);
|
||||
@@ -543,6 +603,9 @@ export function useStep2Orchestration(
|
||||
useEffect(() => {
|
||||
selectedModelRef.current = selectedModel;
|
||||
}, [selectedModel]);
|
||||
useEffect(() => {
|
||||
selectedModeRef.current = selectedMode;
|
||||
}, [selectedMode]);
|
||||
useEffect(() => {
|
||||
agentConfigsRef.current = agentConfigs;
|
||||
}, [agentConfigs]);
|
||||
@@ -945,6 +1008,137 @@ export function useStep2Orchestration(
|
||||
[upsertSeatToolCall],
|
||||
);
|
||||
|
||||
// ── 「多智能体问答」子智能体任务卡片(参照主聊天 message-list 的 task 识别)──────────
|
||||
// ultra 模式下席位用 `task` 工具派生子智能体。从席位流的 messages-tuple 帧里:
|
||||
// (1) AI chunk 的 tool_calls 里 name==="task" → 建/更新一条 Subtask(in_progress);
|
||||
// (2) type==="tool" 且 tool_call_id 命中已知 task id → 据「Task Succeeded./failed./
|
||||
// timed out」前缀更新为 completed/failed(与主聊天 message-list.tsx 同源解析)。
|
||||
// Subtask 存进共享 SubtaskContext(主聊天同一份),气泡只记 id 列表并渲染 <SubtaskCard>。
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
// 已识别的 task tool_call_id —— 用于在收到 tool 结果帧时只认领「子智能体任务」结果,
|
||||
// 不误把 write_file 等其它工具的结果当成子任务处理。
|
||||
const knownTaskIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
/** 把某个子任务 id 记到对应席位气泡上(新引用 → 触发气泡重渲染,卡片随之刷新状态)。 */
|
||||
const touchBubbleSubtaskId = useCallback((bubbleId: number, taskId: string) => {
|
||||
setStep2RoundtableDialogues((prev) =>
|
||||
prev.map((d) => {
|
||||
if (d.id !== bubbleId) return d;
|
||||
const ids = d.subtaskIds ?? [];
|
||||
// 每次都换新数组引用,确保状态(in_progress→completed/failed)变化时卡片重渲染。
|
||||
return { ...d, subtaskIds: ids.includes(taskId) ? [...ids] : [...ids, taskId] };
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
/** 处理一条消息(values 快照里的整条 message,或 messages-tuple 的 chunk)的子任务语义。 */
|
||||
const ingestSubagentMessage = useCallback(
|
||||
(msg: Record<string, unknown>, bubbleId: number) => {
|
||||
// (1) AI 消息:抓 task 工具调用 → 建/更新 in_progress 子任务。
|
||||
const toolCalls = msg.tool_calls;
|
||||
if (Array.isArray(toolCalls)) {
|
||||
for (const tc of toolCalls) {
|
||||
if (!tc || typeof tc !== "object") continue;
|
||||
const t = tc as Record<string, unknown>;
|
||||
if (t.name !== "task") continue;
|
||||
const id = typeof t.id === "string" ? t.id : "";
|
||||
if (!id) continue;
|
||||
const a = (t.args && typeof t.args === "object" ? t.args : {}) as Record<string, unknown>;
|
||||
updateSubtask({
|
||||
id,
|
||||
status: "in_progress",
|
||||
subagent_type: typeof a.subagent_type === "string" ? a.subagent_type : "",
|
||||
description: typeof a.description === "string" ? a.description : "",
|
||||
prompt: typeof a.prompt === "string" ? a.prompt : "",
|
||||
});
|
||||
knownTaskIdsRef.current.add(id);
|
||||
touchBubbleSubtaskId(bubbleId, id);
|
||||
}
|
||||
}
|
||||
// (2) tool 结果消息:命中已知 task id 时按前缀判完成/失败(与主聊天解析一致)。
|
||||
const type = typeof msg.type === "string" ? msg.type.toLowerCase() : "";
|
||||
if (type === "tool") {
|
||||
const taskId = typeof msg.tool_call_id === "string" ? msg.tool_call_id : "";
|
||||
if (taskId && knownTaskIdsRef.current.has(taskId)) {
|
||||
const result = extractFrameText(msg.content);
|
||||
if (result.startsWith("Task Succeeded. Result:")) {
|
||||
updateSubtask({
|
||||
id: taskId,
|
||||
status: "completed",
|
||||
result: result.split("Task Succeeded. Result:")[1]?.trim(),
|
||||
});
|
||||
} else if (result.startsWith("Task failed.")) {
|
||||
updateSubtask({
|
||||
id: taskId,
|
||||
status: "failed",
|
||||
error: result.split("Task failed.")[1]?.trim(),
|
||||
});
|
||||
} else if (result.startsWith("Task timed out")) {
|
||||
updateSubtask({ id: taskId, status: "failed", error: result });
|
||||
}
|
||||
touchBubbleSubtaskId(bubbleId, taskId);
|
||||
}
|
||||
}
|
||||
},
|
||||
[updateSubtask, touchBubbleSubtaskId],
|
||||
);
|
||||
|
||||
const ingestSubagentTaskFrame = useCallback(
|
||||
(frame: unknown, bubbleId: number) => {
|
||||
// custom 事件帧(ultra 席位子智能体进度):task_running 带子智能体最新消息 → 回填
|
||||
// latestMessage(卡片实时显示「正在调用 X 工具」);完成/失败即时定状态(比 values 更快)。
|
||||
const ev = asSubagentTaskEvent(frame);
|
||||
if (ev) {
|
||||
knownTaskIdsRef.current.add(ev.task_id);
|
||||
if (ev.type === "task_running") {
|
||||
// latestMessage 非空才会触发 SubtaskContext setTasks 重渲染(见 useUpdateSubtask)。
|
||||
updateSubtask({ id: ev.task_id, status: "in_progress", latestMessage: ev.message as never });
|
||||
} else if (ev.type === "task_started") {
|
||||
updateSubtask({
|
||||
id: ev.task_id,
|
||||
status: "in_progress",
|
||||
description: typeof ev.description === "string" ? ev.description : undefined,
|
||||
});
|
||||
} else if (ev.type === "task_completed") {
|
||||
updateSubtask({
|
||||
id: ev.task_id,
|
||||
status: "completed",
|
||||
result: typeof ev.result === "string" ? ev.result : extractFrameText(ev.result),
|
||||
});
|
||||
} else if (
|
||||
ev.type === "task_failed" ||
|
||||
ev.type === "task_cancelled" ||
|
||||
ev.type === "task_timed_out"
|
||||
) {
|
||||
updateSubtask({
|
||||
id: ev.task_id,
|
||||
status: "failed",
|
||||
error: typeof ev.error === "string" ? ev.error : "子智能体任务失败",
|
||||
});
|
||||
}
|
||||
touchBubbleSubtaskId(bubbleId, ev.task_id);
|
||||
return;
|
||||
}
|
||||
|
||||
// values 快照帧:{messages:[...]} —— 权威来源,按整条消息列表对账(子任务完成/失败)。
|
||||
if (frame && typeof frame === "object" && !Array.isArray(frame)) {
|
||||
const msgs = (frame as Record<string, unknown>).messages;
|
||||
if (Array.isArray(msgs)) {
|
||||
for (const m of msgs) {
|
||||
if (m && typeof m === "object") ingestSubagentMessage(m as Record<string, unknown>, bubbleId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// messages-tuple 帧:[chunk, metadata] —— 增量,派活时即时建卡。
|
||||
if (!Array.isArray(frame) || frame.length === 0) return;
|
||||
const chunk = frame[0];
|
||||
if (!chunk || typeof chunk !== "object") return;
|
||||
ingestSubagentMessage(chunk as Record<string, unknown>, bubbleId);
|
||||
},
|
||||
[ingestSubagentMessage, updateSubtask, touchBubbleSubtaskId],
|
||||
);
|
||||
|
||||
// ─── Dialogue / Speaker helpers ────────────────────────────────────
|
||||
const appendDialogue = useCallback(
|
||||
(card: {
|
||||
@@ -1406,6 +1600,8 @@ export function useStep2Orchestration(
|
||||
newMessage: fullTask,
|
||||
peerDeliveries: priorDeliveries,
|
||||
model: perSeatModel || selectedModelRef.current || undefined,
|
||||
// 席位执行模式(快速/思考/专业问答/多智能体问答)→ thinking/reasoning/subagent。
|
||||
...seatModeToRequestFields(selectedModeRef.current),
|
||||
signal: subAborter.signal,
|
||||
onTextDelta: (text) =>
|
||||
a.isParallel ? ingestParallelDelta(a.bubbleId, text) : appendToDialogue(a.bubbleId, text),
|
||||
@@ -1415,7 +1611,10 @@ export function useStep2Orchestration(
|
||||
appendStepIfNew(a.bubbleId, label, `${phase}:${toolName ?? ""}`, args);
|
||||
handleArtifactToolPhase(phase, toolName, args, a.seatThreadId);
|
||||
},
|
||||
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, a.seatThreadId),
|
||||
onUpstreamFrame: (frame) => {
|
||||
handleUpstreamFrame(frame, a.seatThreadId);
|
||||
ingestSubagentTaskFrame(frame, a.bubbleId);
|
||||
},
|
||||
});
|
||||
activeAbortersRef.current.delete(subAborter);
|
||||
if (isStale()) {
|
||||
@@ -1690,6 +1889,7 @@ export function useStep2Orchestration(
|
||||
triggerToast,
|
||||
handleArtifactToolPhase,
|
||||
handleUpstreamFrame,
|
||||
ingestSubagentTaskFrame,
|
||||
resolveSeatThreadId,
|
||||
],
|
||||
);
|
||||
@@ -1829,6 +2029,8 @@ export function useStep2Orchestration(
|
||||
displayName: seatName,
|
||||
newMessage: task,
|
||||
model: perSeatModel || selectedModelRef.current || undefined,
|
||||
// 席位执行模式 → thinking/reasoning/subagent(与推荐/DAG 路径一致)。
|
||||
...seatModeToRequestFields(selectedModeRef.current),
|
||||
signal: subAborter.signal,
|
||||
onTextDelta: (text) => appendToDialogue(subBubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
@@ -1837,7 +2039,10 @@ export function useStep2Orchestration(
|
||||
appendStepIfNew(subBubbleId, label, `${phase}:${toolName ?? ""}`, args);
|
||||
handleArtifactToolPhase(phase, toolName, args, seatThreadId);
|
||||
},
|
||||
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, seatThreadId),
|
||||
onUpstreamFrame: (frame) => {
|
||||
handleUpstreamFrame(frame, seatThreadId);
|
||||
ingestSubagentTaskFrame(frame, subBubbleId);
|
||||
},
|
||||
});
|
||||
if (isStale()) {
|
||||
finalizeDialogue(subBubbleId, specRes.content || "(已停止)", "已停止");
|
||||
@@ -1936,6 +2141,7 @@ export function useStep2Orchestration(
|
||||
triggerToast,
|
||||
handleArtifactToolPhase,
|
||||
handleUpstreamFrame,
|
||||
ingestSubagentTaskFrame,
|
||||
resolveSeatThreadId,
|
||||
],
|
||||
);
|
||||
@@ -2163,6 +2369,8 @@ export function useStep2Orchestration(
|
||||
// 前序各阶段的完整交付 → 供本席位 read_peer_delivery 按需取全文(§12.9)。
|
||||
peerDeliveries: priorDeliveries,
|
||||
model: perSeatModel || selectedModelRef.current || undefined,
|
||||
// 席位执行模式 → thinking/reasoning/subagent(与推荐/链条路径一致)。
|
||||
...seatModeToRequestFields(selectedModeRef.current),
|
||||
signal: subAborter.signal,
|
||||
// §6:并行席位走 ref 全量累积 + 节流 flush(只渲染选中的);单节点
|
||||
// stage 实际串行,1 路流直接 setState 不卡,沿用 appendToDialogue。
|
||||
@@ -2174,7 +2382,10 @@ export function useStep2Orchestration(
|
||||
appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args);
|
||||
handleArtifactToolPhase(phase, toolName, args, seatThreadId);
|
||||
},
|
||||
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, seatThreadId),
|
||||
onUpstreamFrame: (frame) => {
|
||||
handleUpstreamFrame(frame, seatThreadId);
|
||||
ingestSubagentTaskFrame(frame, bubbleId);
|
||||
},
|
||||
});
|
||||
if (isStale()) {
|
||||
finalizeDialogue(bubbleId, specRes.content || "(已停止)", "已停止");
|
||||
@@ -2276,6 +2487,7 @@ export function useStep2Orchestration(
|
||||
triggerToast,
|
||||
handleArtifactToolPhase,
|
||||
handleUpstreamFrame,
|
||||
ingestSubagentTaskFrame,
|
||||
resolveSeatThreadId,
|
||||
ingestParallelDelta,
|
||||
resetParallelStreams,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import { buildPlanFromStages } from "./dag-types";
|
||||
import type { SeatMode } from "./seat-mode";
|
||||
|
||||
/** 链条里的一个有序席位。`seats[0]` 是第一个发言者。 */
|
||||
export interface ChainSeat {
|
||||
@@ -48,6 +49,11 @@ export interface BusinessChain {
|
||||
* 空 / null = 不注入。
|
||||
*/
|
||||
coordinatorPrompt?: string | null;
|
||||
/**
|
||||
* 默认席位执行模式(快速/思考/专业问答/多智能体)。选入研讨时弹窗默认用它(仍可临时改)。
|
||||
* 缺省 = 未设,调用方回退 `DEFAULT_SEAT_MODE`(pro)。
|
||||
*/
|
||||
seatMode?: SeatMode;
|
||||
/** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */
|
||||
isPublic?: boolean;
|
||||
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
|
||||
@@ -85,6 +91,12 @@ export interface SeatSelectionResult {
|
||||
* 缺省 `fast`。
|
||||
*/
|
||||
dagExecMode?: "fast" | "sandbox";
|
||||
/**
|
||||
* 席位执行模式(快速/思考/专业问答/多智能体问答)——决定第二步**每个子智能体席位**
|
||||
* 的推理深度(thinking/reasoning_effort/subagent)。三种编排(recommend/chain/dag)通用。
|
||||
* 缺省由调用方按 `DEFAULT_SEAT_MODE`(pro)处理。
|
||||
*/
|
||||
seatMode?: SeatMode;
|
||||
}
|
||||
|
||||
/** 链条席位数硬约束,与圆桌推荐弹窗的 `MIN_SELECTED`/`MAX_SELECTED` 对齐。 */
|
||||
@@ -132,12 +144,12 @@ export function buildChainSeatSelection(
|
||||
const agents = plan.stages
|
||||
.flatMap((st) => st.agentIds)
|
||||
.map((id) => ({ agent_id: id, name: nameOf(id) }));
|
||||
return { agents, mode: "dag", chain: chainMeta, plan, dagExecMode: "fast" };
|
||||
return { agents, mode: "dag", chain: chainMeta, plan, dagExecMode: "fast", seatMode: chain.seatMode };
|
||||
}
|
||||
}
|
||||
|
||||
const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id) }));
|
||||
return { agents, mode: "chain", chain: chainMeta };
|
||||
return { agents, mode: "chain", chain: chainMeta, seatMode: chain.seatMode };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import type { OrchestrationPlan } from "./dag-types";
|
||||
import type { SeatMode } from "./seat-mode";
|
||||
|
||||
/** 作业状态机(§2.1)。 */
|
||||
export type JobStatus =
|
||||
@@ -120,6 +121,12 @@ export interface StartJobPayload {
|
||||
/** 预建好的 thread 映射(Phase 3b 真网关用;模拟网关忽略)。 */
|
||||
threadIds?: Record<string, string> | null;
|
||||
coordinatorName?: string;
|
||||
/**
|
||||
* 席位执行模式(快速/思考/专业问答/多智能体)。后台续跑时席位按此模式执行(与前端实时
|
||||
* 路径一致);缺省由后端按 policy 默认(≈pro)。在 `toStartBody` 里派生成 thinking/
|
||||
* reasoning/subagent 三字段发给后端。
|
||||
*/
|
||||
seatMode?: SeatMode;
|
||||
}
|
||||
|
||||
/** 终态集合——SSE/轮询判定作业是否结束。 */
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 圆桌第二步「席位执行模式」共享定义。
|
||||
*
|
||||
* 与主聊天输入框(`components/workspace/input-box.tsx`)的四档模式同源:
|
||||
* 用户在「选择参会智能体」弹窗里选一档,第二步派活的**每个子智能体(special 席位)**
|
||||
* 都按这一档的推理深度执行。模式只决定 `thinking_enabled / reasoning_effort /
|
||||
* subagent_enabled` 三个运行参数;席位的工具裁剪仍由后端 `roundtable_run_policy`
|
||||
* 决定(见 `app/gateway/roundtable_run_policy.py`)。
|
||||
*
|
||||
* 总控(leader)派活是短决策任务,**不**受本模式影响(沿用其快速派活策略)。
|
||||
*
|
||||
* 映射逻辑刻意对齐主聊天 `core/threads/hooks.ts` 的 mode → context 转换,保持一致体验:
|
||||
* - flash 快速 —— 不思考、最快
|
||||
* - thinking 思考 —— 思考 + low
|
||||
* - pro 专业问答 —— 思考 + medium(= 现有席位默认行为,向后兼容)
|
||||
* - ultra 多智能体 —— 思考 + high + 席位可派子代理(task 工具)
|
||||
*/
|
||||
|
||||
export type SeatMode = "flash" | "thinking" | "pro" | "ultra";
|
||||
|
||||
/** 默认档位:专业问答(pro)—— 与改造前席位的固定行为(thinking + medium)一致。 */
|
||||
export const DEFAULT_SEAT_MODE: SeatMode = "pro";
|
||||
|
||||
/** 席位单轮 run 的派生参数(透传给 `/api/multi-agent/run/stream`,覆盖后端 policy 默认)。 */
|
||||
export interface SeatRunParams {
|
||||
thinking_enabled: boolean;
|
||||
reasoning_effort: "minimal" | "low" | "medium" | "high";
|
||||
subagent_enabled: boolean;
|
||||
}
|
||||
|
||||
/** 把一个席位模式映射成具体的运行参数。 */
|
||||
export function seatModeToRunParams(mode: SeatMode): SeatRunParams {
|
||||
switch (mode) {
|
||||
case "flash":
|
||||
return { thinking_enabled: false, reasoning_effort: "minimal", subagent_enabled: false };
|
||||
case "thinking":
|
||||
return { thinking_enabled: true, reasoning_effort: "low", subagent_enabled: false };
|
||||
case "ultra":
|
||||
return { thinking_enabled: true, reasoning_effort: "high", subagent_enabled: true };
|
||||
case "pro":
|
||||
default:
|
||||
return { thinking_enabled: true, reasoning_effort: "medium", subagent_enabled: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把席位模式映射成 `streamMultiAgent` 请求字段(camelCase),方便在席位调用处直接 spread。
|
||||
* 与 `seatModeToRunParams` 同一份逻辑,只是改成 API 客户端期望的字段名。
|
||||
*/
|
||||
export function seatModeToRequestFields(mode: SeatMode): {
|
||||
thinkingEnabled: boolean;
|
||||
reasoningEffort: SeatRunParams["reasoning_effort"];
|
||||
subagentEnabled: boolean;
|
||||
} {
|
||||
const p = seatModeToRunParams(mode);
|
||||
return {
|
||||
thinkingEnabled: p.thinking_enabled,
|
||||
reasoningEffort: p.reasoning_effort,
|
||||
subagentEnabled: p.subagent_enabled,
|
||||
};
|
||||
}
|
||||
|
||||
/** 弹窗模式选择器用的展示元数据(标签 + 一句话说明),顺序即展示顺序。 */
|
||||
export const SEAT_MODE_OPTIONS: ReadonlyArray<{
|
||||
value: SeatMode;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{ value: "flash", label: "快速", description: "快速且高效地完成任务,但可能不够精准" },
|
||||
{ value: "thinking", label: "思考", description: "思考后再行动,在时间与准确性之间取得平衡" },
|
||||
{ value: "pro", label: "专业问答", description: "思考、计划再执行,获得更精准的结果,可能需要更多时间" },
|
||||
{
|
||||
value: "ultra",
|
||||
label: "多智能体问答",
|
||||
description: "在专业问答基础上,每个席位可调用子代理分工协作,能力最强、更慢更耗时",
|
||||
},
|
||||
];
|
||||
@@ -85,6 +85,11 @@ import {
|
||||
type BusinessChain,
|
||||
type ChainSeat,
|
||||
} from "@/roundtable-planning/lib/business-chain";
|
||||
import {
|
||||
DEFAULT_SEAT_MODE,
|
||||
SEAT_MODE_OPTIONS,
|
||||
type SeatMode,
|
||||
} from "@/roundtable-planning/lib/seat-mode";
|
||||
import {
|
||||
addEmptyStage,
|
||||
addSeatAsNewStage,
|
||||
@@ -153,6 +158,8 @@ export default function BusinessChainEditorPage() {
|
||||
const [description, setDescription] = useState("");
|
||||
// 写给总控的「编排提示」:告诉总控该怎么派活、做什么(仅分层 DAG 模式注入)。
|
||||
const [coordinatorPrompt, setCoordinatorPrompt] = useState("");
|
||||
// 默认席位执行模式(快速/思考/专业问答/多智能体)—— 选入研讨时弹窗默认用它。
|
||||
const [seatMode, setSeatMode] = useState<SeatMode>(DEFAULT_SEAT_MODE);
|
||||
// 分层编排是唯一数据源;落库时展平为 seats + 折叠为 stages。
|
||||
const [stageGroups, setStageGroups] = useState<StageGroups>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -277,6 +284,7 @@ export default function BusinessChainEditorPage() {
|
||||
setTitle(chain.title);
|
||||
setDescription(chain.description ?? "");
|
||||
setCoordinatorPrompt(chain.coordinatorPrompt ?? "");
|
||||
setSeatMode(chain.seatMode ?? DEFAULT_SEAT_MODE);
|
||||
setStageGroups(chainToStageGroups(chain));
|
||||
setStep(1);
|
||||
}, []);
|
||||
@@ -288,6 +296,7 @@ export default function BusinessChainEditorPage() {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setCoordinatorPrompt("");
|
||||
setSeatMode(DEFAULT_SEAT_MODE);
|
||||
setStageGroups([]);
|
||||
setStep(1);
|
||||
}, []);
|
||||
@@ -560,6 +569,7 @@ export default function BusinessChainEditorPage() {
|
||||
stages,
|
||||
stageGoals,
|
||||
coordinatorPrompt: coordPrompt,
|
||||
seatMode,
|
||||
});
|
||||
if (!updated) {
|
||||
toast.error("链条已不存在,可能被删除");
|
||||
@@ -578,6 +588,7 @@ export default function BusinessChainEditorPage() {
|
||||
stages,
|
||||
stageGoals,
|
||||
coordinatorPrompt: coordPrompt,
|
||||
seatMode,
|
||||
});
|
||||
setCurrentChainId(created.id);
|
||||
toast.success("已创建业务链条");
|
||||
@@ -592,6 +603,7 @@ export default function BusinessChainEditorPage() {
|
||||
title,
|
||||
description,
|
||||
coordinatorPrompt,
|
||||
seatMode,
|
||||
stageGroups,
|
||||
seatCountInvalid,
|
||||
currentChainId,
|
||||
@@ -699,6 +711,37 @@ export default function BusinessChainEditorPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 默认执行模式:选入研讨时弹窗默认用它(仍可临时改)。 */}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-1 text-xs font-semibold text-foreground">
|
||||
默认执行模式
|
||||
<span className="text-[10px] font-normal text-muted-foreground">
|
||||
(选入研讨时默认用它,进研讨前仍可临时切换)
|
||||
</span>
|
||||
</label>
|
||||
<div className="inline-flex flex-wrap gap-0.5 rounded-lg border border-border bg-muted/40 p-0.5 text-xs font-medium">
|
||||
{SEAT_MODE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setSeatMode(opt.value)}
|
||||
title={opt.description}
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 transition-colors",
|
||||
seatMode === opt.value
|
||||
? "bg-card text-primary shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
{SEAT_MODE_OPTIONS.find((o) => o.value === seatMode)?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 总控提示词:label + AI 优化(配色沿用现有 primary)+ 大号等宽编辑区 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
|
||||
@@ -100,6 +100,7 @@ import {
|
||||
buildChainSeatSelection,
|
||||
type SeatSelectionResult,
|
||||
} from "@/roundtable-planning/lib/business-chain";
|
||||
import { DEFAULT_SEAT_MODE, type SeatMode } from "@/roundtable-planning/lib/seat-mode";
|
||||
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
|
||||
import type { IntentSummary } from "@/roundtable-planning/api/intent";
|
||||
import {
|
||||
@@ -108,6 +109,7 @@ import {
|
||||
startJob,
|
||||
startJobKeepalive,
|
||||
} from "@/roundtable-planning/api/roundtable-jobs";
|
||||
import { useRoundtableJobNotifier } from "@/roundtable-planning/components/RoundtableJobNotifier";
|
||||
import { getChain } from "@/roundtable-planning/api/chains";
|
||||
import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend";
|
||||
import { listAgents } from "@/core/agents";
|
||||
@@ -169,6 +171,8 @@ function readEmbedHashQueryPrefill(): RoundtablePrefill | null {
|
||||
|
||||
export default function RoundtablePlanningPage() {
|
||||
const { theme } = useTheme();
|
||||
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
|
||||
const { watchJob } = useRoundtableJobNotifier();
|
||||
const isDark = theme === "dark";
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -222,6 +226,9 @@ export default function RoundtablePlanningPage() {
|
||||
// 「导入会商」弹窗(输入分享码把别人分享的会商复制到本账号)。
|
||||
const [isImportOpen, setIsImportOpen] = useState<boolean>(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
// Step 2 席位执行模式(快速/思考/专业问答/多智能体问答)——决定每个子智能体席位的
|
||||
// 推理深度。由「选择参会智能体」弹窗确认时的 SeatSelectionResult.seatMode 决定。
|
||||
const [seatMode, setSeatMode] = useState<SeatMode>(DEFAULT_SEAT_MODE);
|
||||
// Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活;
|
||||
// dag = 分层 DAG(stage 内并行、stage 间串行,配合 orchestrationPlan)。
|
||||
// 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。
|
||||
@@ -337,6 +344,7 @@ export default function RoundtablePlanningPage() {
|
||||
const step2 = useStep2Orchestration({
|
||||
currentStep,
|
||||
selectedModel,
|
||||
selectedMode: seatMode,
|
||||
orchestrationMode,
|
||||
orchestrationPlan,
|
||||
dagExecMode,
|
||||
@@ -750,6 +758,8 @@ export default function RoundtablePlanningPage() {
|
||||
// result.mode 决定 Step 2 编排:recommend = 总控自由派活;chain = 按链条顺序派活;
|
||||
// dag = 分层并行(来源链条带 stages,携带 result.plan)。
|
||||
setOrchestrationMode(result.mode);
|
||||
// 席位执行模式:弹窗未带(旧调用)时回退默认 pro,保证行为可预期。
|
||||
setSeatMode(result.seatMode ?? DEFAULT_SEAT_MODE);
|
||||
setChainMeta(result.mode === "recommend" ? null : result.chain ?? null);
|
||||
setOrchestrationPlan(result.mode === "dag" ? result.plan ?? null : null);
|
||||
setDagExecMode(result.mode === "dag" ? result.dagExecMode ?? "fast" : "fast");
|
||||
@@ -1133,12 +1143,34 @@ export default function RoundtablePlanningPage() {
|
||||
});
|
||||
}, [resetToStep1]);
|
||||
|
||||
// 「挂起中」本地标记:点「后台挂起」后立刻跳转,建作业是 fire-and-forget——从点击到作业
|
||||
// 真正建好的窗口里,用它让历史记录显示「挂起中」(作业建好后转 queued/running,由后端状态接管)。
|
||||
// resetToStep1 不卸载本页,故该 state 跨「跳转」存活。
|
||||
const [pendingSuspendIds, setPendingSuspendIds] = useState<Set<string>>(() => new Set());
|
||||
const markPendingSuspend = useCallback((draftId: string) => {
|
||||
setPendingSuspendIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(draftId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const clearPendingSuspend = useCallback((draftId: string) => {
|
||||
setPendingSuspendIds((prev) => {
|
||||
if (!prev.has(draftId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(draftId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 「后台挂起」核心(手动按钮 / 离开页面自动挂起共用):幂等保证草稿存在 →
|
||||
// 同草稿已有在跑作业则跳过 → 启动后台作业。后端另有「同草稿未终态作业幂等返回」
|
||||
// 兜底(覆盖跨标签页 / beforeunload 与本函数并发触发),绝不重复建作业。
|
||||
const startBackgroundJob = useCallback(async (): Promise<boolean> => {
|
||||
// 返回本次后台作业的 jobId(已在跑则返回既有作业 id),供「完成时提示」监听;
|
||||
// 未发起(无席位)返回 null。
|
||||
const startBackgroundJob = useCallback(async (): Promise<string | null> => {
|
||||
const agents = step2.selectedAgents.map((a) => ({ agentId: a.agent_id, name: a.name }));
|
||||
if (agents.length === 0) return false;
|
||||
if (agents.length === 0) return null;
|
||||
// 后台挂起发起前**立刻强制存一次**当前聊天(保住发起时刻的本地研讨记录,防丢)。
|
||||
const draftId = await drafts.saveDraftNow(2);
|
||||
// 防重复:该草稿已有在跑作业就不再新建(避免重复作业 / 前后端双跑)。
|
||||
@@ -1148,52 +1180,118 @@ export default function RoundtablePlanningPage() {
|
||||
(existing.status === "running" ||
|
||||
existing.status === "awaiting_input" ||
|
||||
existing.status === "queued");
|
||||
if (!alreadyRunning) {
|
||||
await startJob({
|
||||
draftId: draftId ?? undefined,
|
||||
intent: buildInitialIntent(),
|
||||
agents,
|
||||
model: selectedModel || undefined, // 模型沿用第二步选择
|
||||
// 后台作业引擎支持 dag(后端 _run_dag:stage 串行 / stage 内并行)——
|
||||
// 直接把当前编排模式透传,业务链的分层 / 并行 / 阶段目标 / 总控提示词全部保留。
|
||||
orchestrationMode,
|
||||
chain: chainMeta,
|
||||
// dag 模式:把整份分层计划带给后端(含 stages / 阶段目标 / 总控提示词 / finalSynthesis)。
|
||||
// 非 dag 时为 null(后端按 mode 走 chain/recommend)。
|
||||
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
|
||||
// 不传 threadIds:后台作业由真网关自建全新线程,绝不复用前端本地研讨的线程
|
||||
// (那些线程可能有活跃 run,复用会撞 409)。
|
||||
});
|
||||
if (alreadyRunning) {
|
||||
return existing?.id ?? null;
|
||||
}
|
||||
return true;
|
||||
const job = await startJob({
|
||||
draftId: draftId ?? undefined,
|
||||
intent: buildInitialIntent(),
|
||||
agents,
|
||||
model: selectedModel || undefined, // 模型沿用第二步选择
|
||||
seatMode, // 席位执行模式沿用第二步选择(后台续跑同样按此模式跑席位)
|
||||
// 后台作业引擎支持 dag(后端 _run_dag:stage 串行 / stage 内并行)——
|
||||
// 直接把当前编排模式透传,业务链的分层 / 并行 / 阶段目标 / 总控提示词全部保留。
|
||||
orchestrationMode,
|
||||
chain: chainMeta,
|
||||
// dag 模式:把整份分层计划带给后端(含 stages / 阶段目标 / 总控提示词 / finalSynthesis)。
|
||||
// 非 dag 时为 null(后端按 mode 走 chain/recommend)。
|
||||
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
|
||||
// 不传 threadIds:后台作业由真网关自建全新线程,绝不复用前端本地研讨的线程
|
||||
// (那些线程可能有活跃 run,复用会撞 409)。
|
||||
});
|
||||
return job.id;
|
||||
}, [
|
||||
step2,
|
||||
drafts,
|
||||
selectedModel,
|
||||
seatMode,
|
||||
orchestrationMode,
|
||||
orchestrationPlan,
|
||||
chainMeta,
|
||||
buildInitialIntent,
|
||||
]);
|
||||
|
||||
// 「后台挂起」按钮:把当前研讨交给后端自动跑完 Step2 研讨 + Step3(总结报告 + 结果绘制)。
|
||||
// 挂起后页面像「新建任务」一样重置回第一步(后台作业继续在历史记录里跑)。
|
||||
const handleBackgroundSuspend = useCallback(async () => {
|
||||
if (step2.selectedAgents.length === 0) {
|
||||
// 「后台挂起」按钮(2026-06-16 改「直接跳转」):点击后**立刻**跳回第一步并提示,挂起动作
|
||||
// (存稿 → 去重 → 建后台作业)改为 fire-and-forget 在后台完成,不再等接口返回才跳转。
|
||||
// 历史记录在作业真正建好前显示「挂起中」(pendingSuspendIds),建好后由后端状态(queued→
|
||||
// running)接管。⚠️ resetToStep1 会清空 step2 等 live 状态,故挂起入参 + step2 快照必须在
|
||||
// 跳转前同步冻结(getStep2SnapshotWithRuns 返回的是不可变快照对象,reset 替换 state 不影响它)。
|
||||
const handleBackgroundSuspend = useCallback(() => {
|
||||
const selected = step2.selectedAgents;
|
||||
if (selected.length === 0) {
|
||||
triggerToast("⚠️ 请先选择研讨席位再后台挂起");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await startBackgroundJob();
|
||||
} catch (err) {
|
||||
triggerToast(`❌ 后台挂起失败:${(err as Error).message}`);
|
||||
return;
|
||||
}
|
||||
// 重置回第一步(同新建任务);后台作业在历史记录里继续跑、可点开看进度。
|
||||
// —— 跳转前同步冻结所有需要的输入(reset 之后这些 live 状态都会被清空)——
|
||||
const step2Snapshot = getStep2SnapshotWithRuns();
|
||||
const agents = selected.map((a) => ({ agentId: a.agent_id, name: a.name }));
|
||||
const intent = buildInitialIntent();
|
||||
const model = selectedModel || undefined;
|
||||
const seatModeSnap = seatMode;
|
||||
const mode = orchestrationMode;
|
||||
const chainSnap = chainMeta;
|
||||
const planSnap = mode === "dag" ? orchestrationPlan : null;
|
||||
const draftId = drafts.currentDraftId;
|
||||
|
||||
// 立刻:标记「挂起中」+ 跳回第一步 + 提示(不等接口)。
|
||||
if (draftId) markPendingSuspend(draftId);
|
||||
resetToStep1();
|
||||
triggerToast("🚀 已转入后台,任务会自动跑完研讨、总结报告与结果绘制;可在「历史记录」查看进度");
|
||||
void drafts.refresh();
|
||||
}, [step2, drafts, startBackgroundJob, resetToStep1, triggerToast]);
|
||||
|
||||
// 后台 fire-and-forget:用冻结快照存稿 → 去重 → 建作业 → 注册完成通知。失败只提示、不挡跳转。
|
||||
void (async () => {
|
||||
let watchId: string | null = null;
|
||||
try {
|
||||
const savedId = (await drafts.saveStep2SnapshotNow(step2Snapshot, 2)) ?? draftId ?? null;
|
||||
// 防重复:该草稿已有未终态作业就不再新建(同 startBackgroundJob 的客户端去重,
|
||||
// 后端另有「同草稿未终态作业幂等返回」兜底)。
|
||||
const existing = savedId ? await getJobByDraft(savedId).catch(() => null) : null;
|
||||
const alreadyRunning =
|
||||
existing &&
|
||||
(existing.status === "running" ||
|
||||
existing.status === "awaiting_input" ||
|
||||
existing.status === "queued");
|
||||
if (alreadyRunning) {
|
||||
watchId = existing?.id ?? null;
|
||||
} else {
|
||||
const job = await startJob({
|
||||
draftId: savedId ?? undefined,
|
||||
intent,
|
||||
agents,
|
||||
model,
|
||||
seatMode: seatModeSnap,
|
||||
orchestrationMode: mode,
|
||||
chain: chainSnap,
|
||||
orchestrationPlan: planSnap,
|
||||
});
|
||||
watchId = job.id;
|
||||
}
|
||||
} catch (err) {
|
||||
triggerToast(`❌ 后台挂起失败:${(err as Error).message}`);
|
||||
} finally {
|
||||
// 注册到全工作区通知器,作业完成时**跨页**弹「达成共识同款」提示。
|
||||
if (watchId) watchJob(watchId);
|
||||
// 清「挂起中」本地标记:此后历史记录的状态由后端作业(queued→running)权威接管。
|
||||
if (draftId) clearPendingSuspend(draftId);
|
||||
void drafts.refresh();
|
||||
}
|
||||
})();
|
||||
}, [
|
||||
step2,
|
||||
drafts,
|
||||
getStep2SnapshotWithRuns,
|
||||
buildInitialIntent,
|
||||
selectedModel,
|
||||
seatMode,
|
||||
orchestrationMode,
|
||||
orchestrationPlan,
|
||||
chainMeta,
|
||||
resetToStep1,
|
||||
triggerToast,
|
||||
watchJob,
|
||||
markPendingSuspend,
|
||||
clearPendingSuspend,
|
||||
]);
|
||||
|
||||
// ── 自动后台挂起:研讨进行中离开本页面(路由卸载)或刷新 / 关标签页时,任务自动转后台 ──
|
||||
// 最新状态进 ref:卸载 cleanup 与 beforeunload 都是「拍快照时刻」的同步回调,
|
||||
@@ -1223,6 +1321,7 @@ export default function RoundtablePlanningPage() {
|
||||
intent: buildInitialIntent(),
|
||||
agents,
|
||||
model: selectedModel || undefined,
|
||||
seatMode,
|
||||
orchestrationMode,
|
||||
chain: chainMeta,
|
||||
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
|
||||
@@ -1347,7 +1446,7 @@ export default function RoundtablePlanningPage() {
|
||||
)}
|
||||
|
||||
{/* HEADER */}
|
||||
<header className="relative z-[2000] flex shrink-0 items-center justify-between gap-4 border-b border-slate-100 bg-white px-6 py-3 shadow-[0_1px_3px_rgba(0,0,0,0.02)] dark:border-border dark:bg-card dark:shadow-none">
|
||||
<header className="relative z-[40] flex shrink-0 items-center justify-between gap-4 border-b border-slate-100 bg-white px-6 py-3 shadow-[0_1px_3px_rgba(0,0,0,0.02)] dark:border-border dark:bg-card dark:shadow-none">
|
||||
<StepNavigation
|
||||
currentStep={currentStep}
|
||||
intentReady={step1.intentReady}
|
||||
@@ -1408,6 +1507,7 @@ export default function RoundtablePlanningPage() {
|
||||
setIsOpen={drafts.setIsHistoryOpen}
|
||||
drafts={drafts.drafts}
|
||||
currentDraftId={drafts.currentDraftId}
|
||||
pendingSuspendIds={pendingSuspendIds}
|
||||
renamingDraftId={drafts.renamingDraftId}
|
||||
renameDraftValue={drafts.renameDraftValue}
|
||||
setRenameDraftValue={drafts.setRenameDraftValue}
|
||||
@@ -1733,6 +1833,8 @@ interface HistoryDropdownProps {
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
drafts: ReturnType<typeof useDraftPersistence>["drafts"];
|
||||
currentDraftId: string | null;
|
||||
/** 正在「挂起中」(已点后台挂起、作业尚未建好)的草稿 id 集合 —— 历史记录显示「挂起中」。 */
|
||||
pendingSuspendIds: Set<string>;
|
||||
renamingDraftId: string | null;
|
||||
renameDraftValue: string;
|
||||
setRenameDraftValue: React.Dispatch<React.SetStateAction<string>>;
|
||||
@@ -1755,6 +1857,7 @@ function HistoryDropdown(props: HistoryDropdownProps) {
|
||||
setIsOpen,
|
||||
drafts,
|
||||
currentDraftId,
|
||||
pendingSuspendIds,
|
||||
renamingDraftId,
|
||||
renameDraftValue,
|
||||
setRenameDraftValue,
|
||||
@@ -1825,6 +1928,8 @@ function HistoryDropdown(props: HistoryDropdownProps) {
|
||||
{drafts.map((d) => {
|
||||
const isCurrent = d.id === currentDraftId;
|
||||
const isRenaming = d.id === renamingDraftId;
|
||||
// 「挂起中」:已点后台挂起、作业还没建好(本地标记),或作业刚建好仍排队(queued)。
|
||||
const isSuspending = pendingSuspendIds.has(d.id) || d.status === "queued";
|
||||
// 「进行中 / 待澄清」且有 jobId → 点击打开流程图弹窗,而非加载草稿。
|
||||
const isActiveJob =
|
||||
(d.status === "running" || d.status === "awaiting_input") && !!d.jobId;
|
||||
@@ -1862,7 +1967,12 @@ function HistoryDropdown(props: HistoryDropdownProps) {
|
||||
{d.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 mt-0.5 flex items-center gap-1 font-mono">
|
||||
{d.status === "running" ? (
|
||||
{isSuspending ? (
|
||||
<span className="inline-flex items-center gap-1 text-sky-500 font-semibold">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
挂起中
|
||||
</span>
|
||||
) : d.status === "running" ? (
|
||||
<span className="inline-flex items-center gap-1 text-primary font-semibold">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
进行中
|
||||
|
||||
@@ -8,7 +8,10 @@ import { NotificationDialog } from "@/components/workspace/notification-dialog";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { isWujieEnv } from "@/core/embed/wujie";
|
||||
import { useUnreadCount } from "@/core/notifications";
|
||||
import type { MenuItem } from "@/core/page-layout/sidebar-menu";
|
||||
import {
|
||||
getActiveThirdLevelItems,
|
||||
type MenuItem,
|
||||
} from "@/core/page-layout/sidebar-menu";
|
||||
import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -145,7 +148,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
const showLightTopNav = !isDark && !isWujieEnv();
|
||||
|
||||
const hasActiveChild = (item: MenuItem): boolean => {
|
||||
if (item.path && pathname === item.path) {
|
||||
if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -170,9 +173,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const activeTopItem = visibleMenuItems.find(hasActiveChild);
|
||||
const activeSecondItem = activeTopItem?.children?.find(hasActiveChild);
|
||||
const thirdSubNavItems = activeSecondItem?.children ?? [];
|
||||
const thirdSubNavItems = getActiveThirdLevelItems(visibleMenuItems, pathname);
|
||||
|
||||
const leftMenuItems = visibleMenuItems.filter((i) => LEFT_MENU_IDS.includes(i.id));
|
||||
const rightMenuItems = visibleMenuItems.filter((i) => RIGHT_MENU_IDS.includes(i.id));
|
||||
@@ -185,7 +186,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
};
|
||||
|
||||
const renderTopItem = (item: MenuItem) => {
|
||||
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
|
||||
const isSelected = item.path ? (pathname === item.path || pathname.startsWith(item.path + "/")) : hasActiveChild(item);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
@@ -199,7 +200,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
};
|
||||
|
||||
const renderLightTopItem = (item: MenuItem) => {
|
||||
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
|
||||
const isSelected = item.path ? (pathname === item.path || pathname.startsWith(item.path + "/")) : hasActiveChild(item);
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
@@ -294,7 +295,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
open={notificationOpen}
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
<header className="strategy-app-header theme-transition relative z-[2101] flex h-16 items-center justify-between px-4">
|
||||
<header className="strategy-app-header theme-transition relative z-[45] flex h-16 items-center justify-between px-4">
|
||||
{showTopNav ? (
|
||||
<>
|
||||
{/* 左分区(flex-1):菜单靠右贴近中间基座 */}
|
||||
@@ -310,7 +311,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
</div>
|
||||
|
||||
{/* 中:CM助手(落在背景图发光基座上,纯文字无图标) */}
|
||||
<div className="header-center-title">CM助手</div>
|
||||
<div className="header-center-title">CM业务中心</div>
|
||||
|
||||
{/* 右分区(flex-1):菜单靠左贴近中间基座,操作区靠最右 */}
|
||||
<div className="flex h-full min-w-0 flex-1 items-center justify-between">
|
||||
@@ -336,7 +337,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
<FileText className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<span className="whitespace-nowrap text-base font-semibold text-white">
|
||||
CM助手
|
||||
CM业务中心
|
||||
</span>
|
||||
</div>
|
||||
<nav className="light-topnav" aria-label="一级菜单">
|
||||
@@ -363,7 +364,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="text-sm font-semibold text-white">
|
||||
CM助手
|
||||
CM业务中心
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -379,7 +380,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
<nav className="third-submenu-bar" aria-label="三级菜单">
|
||||
<div className="third-submenu-list">
|
||||
{thirdSubNavItems.map((item) => {
|
||||
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
|
||||
const isSelected = item.path ? (pathname === item.path || pathname.startsWith(item.path + "/")) : hasActiveChild(item);
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -950,7 +950,10 @@ html.dark .strategy-app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 155px;
|
||||
/* 左右内边距随视口宽度等比缩放,与背景图基座的 background-size:cover 同步,
|
||||
使两侧菜单在 1080p / 2K / 4K 下都贴齐发光基座边缘。
|
||||
基准:1920 宽时 155px → 155/1920×100 ≈ 8.07vw。 */
|
||||
padding: 0 8.07vw;
|
||||
/* 文字略微上移,贴合基座视觉中心 */
|
||||
transform: translateY(-6px);
|
||||
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese",
|
||||
|
||||
@@ -265,7 +265,7 @@ The AI-writing graph (`packages/harness/deerflow/agents/ai_writing/`) has four s
|
||||
|
||||
Seeding mirrors the roundtable pattern: verified `config.yaml` + `SOUL.md` ship in `app/gateway/routers/_ai_writing_seed_assets/<id>/`; `_ai_writing_seed.py::ensure_ai_writing_functional_agents()` copies them to `.deer-flow/agents/` when missing (never overwrites local edits), called from the startup lifespan (before `_sync_legacy_agents`, so the agents appear in `/api/agents` on first boot) and self-heals from `GET /api/ai-writing/sessions` + `GET /api/ai-writing/article-types`. Tests: `tests/test_ai_writing_seed.py`, `tests/test_ai_writing_agent_runner.py`.
|
||||
|
||||
**Skill-driven retrieval (检索类型).** `material_source` has exactly two values: `general` (通用检索, default) and `notebook` (我的空间); legacy `knowledge_base`/`skill` are normalized to `general` by the researcher node. General retrieval is orchestrated from the **skills configured on the `ai-writing-researcher` agent** (`config.yaml → skills`, editable from the agent management page): the built-in `knowledge-base-search` skill (知识库检索) runs the native keyword fan-out web/intranet search (the old default path), while any other configured skill searches with a `description`-targeted query; skills run in configured order with early-stop at `max_materials`, and an empty/unresolvable skills config falls back to the built-in knowledge search so retrieval always works. The skill itself is seeded from `_ai_writing_seed_assets/skills/knowledge-base-search/SKILL.md` into `skills/public/` (never overwrites), and a pre-existing researcher `config.yaml` lacking the `skills` key is patched in place with the default (explicit `skills: []` is respected). **PAUSE-1 input append flow**: when the user types natural language into the material-confirm input (`re_search`/legacy `enable_skill_search` + `user_query`), the node matches the most relevant configured skill via `SkillPicker(candidates=…)` (skipped when only one skill is configured), searches with the user's words, and **appends** the new materials to the existing package (URL-dedup, original order preserved, no LLM re-rank/truncation, 100-item hard cap). Tests: `tests/test_ai_writing_material_source.py`.
|
||||
**Skill-driven retrieval (检索类型).** `material_source` has exactly two values: `general` (通用检索, default) and `notebook` (我的空间); legacy `knowledge_base`/`skill` are normalized to `general` by the researcher node. General retrieval is orchestrated from the **skills configured on the `ai-writing-researcher` agent** (`config.yaml → skills`, editable from the agent management page): the built-in `knowledge-base-search` skill (知识库检索) runs the native keyword fan-out web/intranet search (the old default path), while any other configured skill searches with a `description`-targeted query; skills run in configured order with early-stop at `max_materials`, and an empty/unresolvable skills config falls back to the built-in knowledge search so retrieval always works. The skill itself is seeded from `_ai_writing_seed_assets/skills/knowledge-base-search/SKILL.md` into `skills/public/` (never overwrites), and a pre-existing researcher `config.yaml` lacking the `skills` key is patched in place with the default (explicit `skills: []` is respected). **PAUSE-1 input append flow**: when the user types natural language into the material-confirm input (`re_search`/legacy `enable_skill_search` + `user_query`), the node matches the most relevant configured skill via `SkillPicker(candidates=…)` (skipped when only one skill is configured), searches with the user's words, and **appends** the new materials to the existing package (URL-dedup, original order preserved, no LLM re-rank/truncation, 100-item hard cap). **Skill-failure fault tolerance**: a configured skill that times out / raises / has all its keyword searches fail (vs. legitimately returning 0 results) no longer just silently moves on — the researcher node emits a `skill_search_step` of type `skill_error` (carrying the error `reason`), and if the configured skills collected **no** new materials at all it runs a `fallback` pass over other available skills (`_fallback_candidate_skills`: the built-in `knowledge-base-search` native path first, then other enabled catalog skills not yet tried) instead of erroring out. The frontend `SkillStepsCard` renders `skill_error` (rose) and `fallback` (amber) so the timeline shows "技能报错 → 改用其他技能搜集". Tests: `tests/test_ai_writing_material_source.py`.
|
||||
|
||||
### Admin Leaderboard Snapshots (`/api/admin/users/leaderboard*`)
|
||||
|
||||
|
||||
@@ -259,7 +259,14 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
_app = app # 闭包捕获 app,供网关访问 app.state(checkpointer/thread_store)
|
||||
gateway_factory = lambda p: InProcessRoundtableGateway( # noqa: E731
|
||||
_app, user_id=p.user_id, agents=p.agents, model=p.model
|
||||
_app,
|
||||
user_id=p.user_id,
|
||||
agents=p.agents,
|
||||
model=p.model,
|
||||
# 席位执行模式沿用 Step2 选择,后台席位 run 据此覆盖 policy 的 thinking/reasoning + 开子代理。
|
||||
seat_thinking_enabled=p.seat_thinking_enabled,
|
||||
seat_reasoning_effort=p.seat_reasoning_effort,
|
||||
seat_subagent_enabled=p.seat_subagent_enabled,
|
||||
)
|
||||
logger.info("[roundtable-jobs] gateway = inprocess (real models, default)")
|
||||
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
@@ -51,6 +53,33 @@ _SUMMARY_FILE = "方案总结报告.md"
|
||||
# 这里漏禁了 web_search,导致后台席位 / 报告能联网搜索而前端不能,现由 policy 统一。
|
||||
|
||||
|
||||
# ── 后台作业「按用户限流」(2026-06-16)─────────────────────────────────────────
|
||||
# 痛点:进 2+ 个后台挂起后,整个网关变慢——建立智能体连接、等待模型响应都慢。
|
||||
# 根因:① checkpointer 用单连接 aiosqlite(AsyncSqliteSaver,内部一把 asyncio.Lock 串
|
||||
# 行全进程的 checkpoint 读写);② 后台作业无并发上限——每个作业进程内驱动真实 leader/
|
||||
# seat/report run,DAG/recommend 的 stage 还用 asyncio.gather 把席位真并行铺开。多个后台
|
||||
# 作业同时把几十个 agent run 压到同一把 SQLite 锁 + 同一个离线模型端点上,前台实时会商
|
||||
# 的 checkpoint 读写 / 模型调用只能排队 → 「干啥都慢」。
|
||||
#
|
||||
# 解法:给**每个用户**一个并发额度(默认 2),后台每个 agent 轮次(leader/seat/report/
|
||||
# summary/dashboard)真正开跑前先取一个额度,跑完释放。同一用户的所有后台作业共享这 2 个
|
||||
# 额度——随便挂多少个作业都能立刻建好,但同一瞬间真正在打模型/写 checkpoint 的后台轮次
|
||||
# 至多 2 个,不会把共享资源打满。**前台实时路径(multi_agent._leader_run/_special_run、
|
||||
# 普通聊天)不经过本文件的 `_run_agent_turn`,永不取这个额度,故前台不受限。**
|
||||
_BACKGROUND_SLOTS_PER_USER = max(1, int(os.getenv("ROUNDTABLE_BACKGROUND_SLOTS_PER_USER", "2") or "2"))
|
||||
# 按 user_id 缓存 Semaphore。网关全程跑在同一个事件循环上,普通 dict 取/建即可(无跨线程)。
|
||||
_user_background_sems: dict[str, asyncio.Semaphore] = {}
|
||||
|
||||
|
||||
def _user_background_semaphore(user_id: str) -> asyncio.Semaphore:
|
||||
"""取该用户的后台并发额度信号量(懒建,每用户一把)。"""
|
||||
sem = _user_background_sems.get(user_id)
|
||||
if sem is None:
|
||||
sem = asyncio.Semaphore(_BACKGROUND_SLOTS_PER_USER)
|
||||
_user_background_sems[user_id] = sem
|
||||
return sem
|
||||
|
||||
|
||||
# ── 进程内底层操作 ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -156,6 +185,7 @@ def _tool_calls_since_last_human(messages: list[dict[str, Any]]) -> list[dict[st
|
||||
async def _run_agent_turn(
|
||||
app,
|
||||
*,
|
||||
user_id: str,
|
||||
thread_id: str,
|
||||
agent_name: str,
|
||||
message: str,
|
||||
@@ -164,11 +194,16 @@ async def _run_agent_turn(
|
||||
skill_stop_names: list[str],
|
||||
thinking_enabled: bool,
|
||||
reasoning_effort: str,
|
||||
subagent_enabled: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""进程内驱动 agent_name 在 thread_id 上跑一轮(发 message),阻塞到完成,返回最终 messages。
|
||||
|
||||
镜像 multi_agent ``_run_payload`` 的 body 形状:agent 通过 ``context.agent_name`` 选,
|
||||
assistant_id 固定 lead_agent;关闭 memory recall / subagent / plan mode(临时研讨任务)。
|
||||
|
||||
并发受**该用户的后台额度**约束(``_user_background_semaphore``):真正开跑(start_run +
|
||||
await run task)前先取额度,跑完释放。同一用户的所有后台作业共享这几个额度,避免 2+
|
||||
后台挂起把单连接 SQLite checkpointer / 离线模型打满、拖垮前台实时会商。
|
||||
"""
|
||||
from app.gateway.routers.thread_runs import RunCreateRequest
|
||||
from app.gateway.services import start_run
|
||||
@@ -184,7 +219,8 @@ async def _run_agent_turn(
|
||||
"reasoning_effort": reasoning_effort,
|
||||
"thinking_enabled": thinking_enabled,
|
||||
"is_plan_mode": False,
|
||||
"subagent_enabled": False,
|
||||
# ultra 席位才置真 → 解锁 task 子代理工具(席位 policy 未禁 task)。leader 恒 False。
|
||||
"subagent_enabled": subagent_enabled,
|
||||
"thread_id": thread_id,
|
||||
"memory_recall_disabled": True,
|
||||
# 与 multi_agent._run_payload 对齐:关 builtin 记忆注入,避免单例总控/席位
|
||||
@@ -199,9 +235,12 @@ async def _run_agent_turn(
|
||||
# 虚拟 request:带上空 ``state``(auth 中间件在真请求上会写 state.user / state.auth;
|
||||
# 进程内没有这层,下游凡是读 request.state.X 的代码必须能拿到 None 而不是 AttributeError)。
|
||||
request = SimpleNamespace(app=app, state=SimpleNamespace(user=None, auth=None))
|
||||
record = await start_run(body, thread_id, request) # type: ignore[arg-type]
|
||||
if getattr(record, "task", None) is not None:
|
||||
await record.task # 阻塞到这轮 agent 跑完,checkpoint 已落盘
|
||||
# 取该用户的后台额度后再开跑:start_run + await task 是最重的部分(建 agent、调模型、
|
||||
# 落 checkpoint)。额度满时本轮在此 await 排队,等同用户某个在跑轮次释放额度后再开。
|
||||
async with _user_background_semaphore(user_id):
|
||||
record = await start_run(body, thread_id, request) # type: ignore[arg-type]
|
||||
if getattr(record, "task", None) is not None:
|
||||
await record.task # 阻塞到这轮 agent 跑完,checkpoint 已落盘
|
||||
return await _read_thread_messages(app, thread_id)
|
||||
|
||||
|
||||
@@ -262,7 +301,13 @@ def _read_summary_md(app, thread_id: str, user_id: str) -> str | None:
|
||||
# it all at once」后发出超长 write_file,单次输出被 max_tokens 截断 → 工具调用从未执行、
|
||||
# run 以 success 收尾但 outputs 下没有 .html → step3.html 回退成开场白文字。
|
||||
# 自愈:检测产物缺失/截断后,同线程追加纠偏消息(要求 write_file 分段 + append 续写)重试。
|
||||
_ARTIFACT_RETRY_LIMIT = 2
|
||||
#
|
||||
# 取值依据(2026-06-15 修复「后台 HTML 看板只生成一半、概率渲染失败」):模型单轮输出受
|
||||
# ``config.yaml`` 的 ``max_tokens``(deepseek-chat = 4096)硬截。一份完整看板 HTML 几十 KB
|
||||
# ≈ 上万 token,单轮写不完;旧值 2(最多 3 轮 ≈ 12k token)对长报告不够 → 落库半截 HTML →
|
||||
# 渲染失败。调到 5(最多 6 轮 append ≈ 24k token,足够绝大多数看板)显著降低截断概率。
|
||||
# 循环命中完整产物(``</html>`` / 非空 md / 合法 json)即 break,短报告不会白跑额外轮次。
|
||||
_ARTIFACT_RETRY_LIMIT = 5
|
||||
|
||||
_REPORT_RETRY_MISSING = (
|
||||
f"检测到 /mnt/user-data/outputs/{_REPORT_FILE} 还没有生成——你上一轮的 write_file 没有执行成功"
|
||||
@@ -332,11 +377,26 @@ def _build_seat_intro(agents: list[SeatRef]) -> str:
|
||||
class InProcessRoundtableGateway:
|
||||
"""进程内真网关。executor 先调 ``init_threads`` 建好线程,再跑编排引擎。"""
|
||||
|
||||
def __init__(self, app, *, user_id: str, agents: list[SeatRef], model: str | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
app,
|
||||
*,
|
||||
user_id: str,
|
||||
agents: list[SeatRef],
|
||||
model: str | None = None,
|
||||
seat_thinking_enabled: bool | None = None,
|
||||
seat_reasoning_effort: str | None = None,
|
||||
seat_subagent_enabled: bool = False,
|
||||
) -> None:
|
||||
self._app = app
|
||||
self._user_id = user_id
|
||||
self._agents = list(agents)
|
||||
self._model = model
|
||||
# 席位执行模式(沿用 Step2 选择):覆盖 seat policy 的 thinking/reasoning(None → policy 默认),
|
||||
# subagent 仅 ultra 档为真。只作用 special 席位;leader 派活不受影响。
|
||||
self._seat_thinking_enabled = seat_thinking_enabled
|
||||
self._seat_reasoning_effort = seat_reasoning_effort
|
||||
self._seat_subagent_enabled = seat_subagent_enabled
|
||||
self._seat_intro = ""
|
||||
self._first_leader = True
|
||||
|
||||
@@ -362,6 +422,7 @@ class InProcessRoundtableGateway:
|
||||
policy = roundtable_run_policy("leader")
|
||||
messages = await _run_agent_turn(
|
||||
self._app,
|
||||
user_id=self._user_id,
|
||||
thread_id=thread_ids[coordinator_name],
|
||||
agent_name=coordinator_name,
|
||||
message=msg,
|
||||
@@ -400,16 +461,27 @@ class InProcessRoundtableGateway:
|
||||
|
||||
async def run_seat(self, *, thread_ids, agent_id, task, model) -> SeatTurn:
|
||||
policy = roundtable_run_policy("seat")
|
||||
# 席位模式覆盖 policy 的 thinking/reasoning(None → policy 默认),与前端 _special_run 一致。
|
||||
messages = await _run_agent_turn(
|
||||
self._app,
|
||||
user_id=self._user_id,
|
||||
thread_id=thread_ids[agent_id],
|
||||
agent_name=agent_id,
|
||||
message=task,
|
||||
model=model or self._model,
|
||||
excluded_tools=policy.excluded_tools,
|
||||
skill_stop_names=policy.skill_stop_names,
|
||||
thinking_enabled=policy.thinking_enabled,
|
||||
reasoning_effort=policy.reasoning_effort,
|
||||
thinking_enabled=(
|
||||
policy.thinking_enabled
|
||||
if self._seat_thinking_enabled is None
|
||||
else self._seat_thinking_enabled
|
||||
),
|
||||
reasoning_effort=(
|
||||
policy.reasoning_effort
|
||||
if self._seat_reasoning_effort is None
|
||||
else self._seat_reasoning_effort
|
||||
),
|
||||
subagent_enabled=self._seat_subagent_enabled,
|
||||
)
|
||||
return SeatTurn(final_text=_latest_ai_text(messages), tool_calls=_tool_calls_since_last_human(messages))
|
||||
|
||||
@@ -427,6 +499,7 @@ class InProcessRoundtableGateway:
|
||||
for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
|
||||
messages = await _run_agent_turn(
|
||||
self._app,
|
||||
user_id=self._user_id,
|
||||
thread_id=report_thread_id,
|
||||
agent_name=REPORT_AGENT_ID,
|
||||
message=message,
|
||||
@@ -470,6 +543,7 @@ class InProcessRoundtableGateway:
|
||||
for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
|
||||
messages = await _run_agent_turn(
|
||||
self._app,
|
||||
user_id=self._user_id,
|
||||
thread_id=summary_thread_id,
|
||||
agent_name=SUMMARY_AGENT_ID,
|
||||
message=message,
|
||||
@@ -505,6 +579,7 @@ class InProcessRoundtableGateway:
|
||||
for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
|
||||
messages = await _run_agent_turn(
|
||||
self._app,
|
||||
user_id=self._user_id,
|
||||
thread_id=thread_id,
|
||||
agent_name=REPORT_AGENT_ID,
|
||||
message=message,
|
||||
|
||||
@@ -179,6 +179,12 @@ class StartParams:
|
||||
# 是否自动产出 Step3(方案总结报告 + 结果绘制)。默认沿用全局开关(现已恢复 True,
|
||||
# 见 ROUNDTABLE_BACKGROUND_REPORT_ENABLED)。测试可显式覆盖。
|
||||
enable_report: bool = ROUNDTABLE_BACKGROUND_REPORT_ENABLED
|
||||
# 席位执行模式(快速/思考/专业问答/多智能体)派生参数,沿用 Step2 选择。仅 special 席位用:
|
||||
# 覆盖 roundtable_run_policy("seat") 的 thinking/reasoning(None → policy 默认);
|
||||
# subagent 仅 ultra 档为真。透传给进程内网关(InProcessRoundtableGateway)的席位 run。
|
||||
seat_thinking_enabled: bool | None = None
|
||||
seat_reasoning_effort: str | None = None
|
||||
seat_subagent_enabled: bool = False
|
||||
|
||||
|
||||
# 网关工厂:给定一次启动参数,返回一个 RoundtableGateway 实例。
|
||||
|
||||
@@ -637,6 +637,8 @@ def _run_payload(
|
||||
skill_stop_names: list[str],
|
||||
thinking_enabled: bool = True,
|
||||
reasoning_effort: str = "medium",
|
||||
subagent_enabled: bool = False,
|
||||
stream_custom: bool = False,
|
||||
multitask_strategy: str = "reject",
|
||||
extra_context: dict[str, Any] | None = None,
|
||||
files: list[dict[str, Any]] | None = None,
|
||||
@@ -676,7 +678,9 @@ def _run_payload(
|
||||
"reasoning_effort": reasoning_effort,
|
||||
"thinking_enabled": thinking_enabled,
|
||||
"is_plan_mode": False,
|
||||
"subagent_enabled": False,
|
||||
# 默认 False;圆桌「多智能体问答(ultra)」档会让席位 _special_run 传 True,
|
||||
# 给席位解锁 task 子代理工具(席位 policy 未禁 task,故可用)。
|
||||
"subagent_enabled": subagent_enabled,
|
||||
"thread_id": thread_id,
|
||||
# 圆桌四个 router(intent / recommend / leader / special)都是临时任务,
|
||||
# 用户个人记忆与本次研讨无关。MemoryMiddleware 的 Hindsight recall 是
|
||||
@@ -691,7 +695,12 @@ def _run_payload(
|
||||
# 圆桌是一次性研讨,本就不该吃历史会话记忆,这里统一关注入根治该污染。
|
||||
"memory_injection_enabled": False,
|
||||
},
|
||||
"stream_mode": ["messages-tuple", "values"],
|
||||
# ultra 席位会派子智能体(task 工具),其 task_started/running/completed 进度走
|
||||
# **custom** 流(StreamWriter)。仅这种场景加 "custom",让前端任务卡片能显示子智能体
|
||||
# 实时进度;其余角色不加,避免无谓帧。
|
||||
"stream_mode": (
|
||||
["messages-tuple", "values", "custom"] if stream_custom else ["messages-tuple", "values"]
|
||||
),
|
||||
"stream_subgraphs": True,
|
||||
"stream_resumable": True,
|
||||
"assistant_id": "lead_agent",
|
||||
@@ -1570,6 +1579,9 @@ async def _special_run(
|
||||
no_file: bool = False,
|
||||
peer_deliveries: list[dict[str, Any]] | None = None,
|
||||
files: list[dict[str, Any]] | None = None,
|
||||
thinking_enabled: bool | None = None,
|
||||
reasoning_effort: str | None = None,
|
||||
subagent_enabled: bool = False,
|
||||
) -> AsyncIterator[str | dict[str, Any]]:
|
||||
"""子智能体(special)单轮:执行总控下发的 task 文本,交付 content。
|
||||
|
||||
@@ -1584,6 +1596,12 @@ async def _special_run(
|
||||
``peer_deliveries``:前序席位的**完整交付** ``[{name, content}, ...]``。注入到
|
||||
run context 的 ``roundtable_peer_deliveries``,供席位用 ``read_peer_delivery`` 工具
|
||||
**按需**取全文(平时只看广播摘要,§12.9 传摘要按需传全文)。不进 prompt,不耗 token。
|
||||
|
||||
``thinking_enabled`` / ``reasoning_effort`` / ``subagent_enabled``:圆桌第二步「席位
|
||||
执行模式」(快速/思考/专业问答/多智能体问答)派生的覆盖值。**仅 special 席位**接收:
|
||||
前两者为 None 时回退到 policy 默认(向后兼容,旧前端不传 → 维持 thinking + medium);
|
||||
``subagent_enabled`` 仅 ultra 档为 True,给席位解锁 ``task`` 子代理工具。**总控
|
||||
(leader)不受影响**,始终用其快速派活策略。
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
thread_id = agent_threads[agent_name]
|
||||
@@ -1613,8 +1631,13 @@ async def _special_run(
|
||||
new_message=new_message,
|
||||
excluded_tools=seat_excluded,
|
||||
skill_stop_names=merge_skill_stop_names(policy.skill_stop_names, skill_stop_names),
|
||||
thinking_enabled=policy.thinking_enabled,
|
||||
reasoning_effort=policy.reasoning_effort,
|
||||
# 席位执行模式覆盖 policy 的 thinking/reasoning(None → 回退 policy 默认);
|
||||
# subagent 仅 ultra 档置真。工具裁剪(excluded_tools)仍完全由 policy 决定。
|
||||
thinking_enabled=(policy.thinking_enabled if thinking_enabled is None else thinking_enabled),
|
||||
reasoning_effort=(policy.reasoning_effort if reasoning_effort is None else reasoning_effort),
|
||||
subagent_enabled=subagent_enabled,
|
||||
# ultra 席位才会派子智能体 → 才需要 custom 流回传子任务实时进度(task_running 等)。
|
||||
stream_custom=subagent_enabled,
|
||||
# 干预/重新派活会把同一席位 thread 再跑一次;抢占掉没结束的旧 run,避免 409。
|
||||
multitask_strategy="rollback",
|
||||
# 前序席位完整交付 → run context,供 read_peer_delivery 工具按需取全文(§12.9)。
|
||||
@@ -1756,6 +1779,15 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
|
||||
# 不写死任何默认模型;空串会让 lead_agent._resolve_model_name() 回退到
|
||||
# config.yaml 的 models[0],与 /api/ai-writing 等其它路由一致。
|
||||
model_name: str = str(payload.get("model") or "")
|
||||
# 圆桌第二步「席位执行模式」(快速/思考/专业问答/多智能体问答)派生参数。**仅 special
|
||||
# 席位消费**:覆盖 seat policy 的 thinking/reasoning。thinking_enabled/reasoning_effort
|
||||
# 缺省(None/空)→ 后端按 policy 默认执行(向后兼容,旧前端不传维持 thinking + medium);
|
||||
# subagent_enabled 仅 ultra 档为真,给席位解锁 task 子代理工具。leader 不消费这些字段。
|
||||
_te_raw = payload.get("thinking_enabled")
|
||||
seat_thinking_enabled: bool | None = bool(_te_raw) if _te_raw is not None else None
|
||||
_re_raw = payload.get("reasoning_effort")
|
||||
seat_reasoning_effort: str | None = _re_raw if isinstance(_re_raw, str) and _re_raw else None
|
||||
seat_subagent_enabled: bool = bool(payload.get("subagent_enabled") or False)
|
||||
|
||||
if not agent_name or agent_name not in agent_threads:
|
||||
raise HTTPException(
|
||||
@@ -1803,6 +1835,9 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
|
||||
no_file=no_file,
|
||||
peer_deliveries=peer_deliveries,
|
||||
files=files,
|
||||
thinking_enabled=seat_thinking_enabled,
|
||||
reasoning_effort=seat_reasoning_effort,
|
||||
subagent_enabled=seat_subagent_enabled,
|
||||
)
|
||||
async for chunk in gen:
|
||||
if isinstance(chunk, dict):
|
||||
|
||||
@@ -57,6 +57,8 @@ class ChainResponse(BaseModel):
|
||||
stage_goals: list[str | None] | None = None
|
||||
# 写给总控的「编排提示」(dag-only):告诉总控怎么派活、做什么。None/空 = 不注入。
|
||||
coordinator_prompt: str | None = None
|
||||
# 默认席位执行模式(flash/thinking/pro/ultra);None = 未设,选入研讨时回退默认(pro)。
|
||||
seat_mode: str | None = None
|
||||
# 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。
|
||||
is_public: bool = False
|
||||
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
|
||||
@@ -83,6 +85,7 @@ class ChainCreateRequest(BaseModel):
|
||||
stages: list[list[str]] | None = None
|
||||
stage_goals: list[str | None] | None = None
|
||||
coordinator_prompt: str | None = Field(default=None, max_length=4000)
|
||||
seat_mode: str | None = Field(default=None, max_length=16)
|
||||
|
||||
|
||||
class ChainUpdateRequest(BaseModel):
|
||||
@@ -93,6 +96,7 @@ class ChainUpdateRequest(BaseModel):
|
||||
stages: list[list[str]] | None = None
|
||||
stage_goals: list[str | None] | None = None
|
||||
coordinator_prompt: str | None = Field(default=None, max_length=4000)
|
||||
seat_mode: str | None = Field(default=None, max_length=16)
|
||||
# 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
|
||||
is_public: bool | None = None
|
||||
|
||||
@@ -224,6 +228,18 @@ def _validate_stage_goals(
|
||||
)
|
||||
|
||||
|
||||
_VALID_SEAT_MODES = {"flash", "thinking", "pro", "ultra"}
|
||||
|
||||
|
||||
def _validate_seat_mode(seat_mode: str | None) -> None:
|
||||
"""席位执行模式只能是 flash/thinking/pro/ultra 或 None(未设)。"""
|
||||
if seat_mode is not None and seat_mode not in _VALID_SEAT_MODES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"非法席位执行模式: {seat_mode}(应为 {'/'.join(sorted(_VALID_SEAT_MODES))} 之一)",
|
||||
)
|
||||
|
||||
|
||||
# ── routes ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -249,6 +265,7 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo
|
||||
_validate_seats(body.seats)
|
||||
_validate_stages(body.seats, body.stages)
|
||||
_validate_stage_goals(body.stages, body.stage_goals)
|
||||
_validate_seat_mode(body.seat_mode)
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
row = await store.create_chain(
|
||||
@@ -261,6 +278,7 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo
|
||||
"stages": body.stages,
|
||||
"stage_goals": body.stage_goals,
|
||||
"coordinator_prompt": body.coordinator_prompt,
|
||||
"seat_mode": body.seat_mode,
|
||||
},
|
||||
)
|
||||
await _enrich_owner_names([row])
|
||||
@@ -290,6 +308,7 @@ async def update_chain(request: Request, chain_id: str, body: ChainUpdateRequest
|
||||
_validate_stages(body.seats, body.stages)
|
||||
# Goals align to stages; reject goals sent without a layering, enforce length/size.
|
||||
_validate_stage_goals(body.stages, body.stage_goals)
|
||||
_validate_seat_mode(body.seat_mode)
|
||||
store = _get_store(request)
|
||||
user_id = _current_user_id(request)
|
||||
# Distinguish "omitted" from "explicitly sent"; only forward provided fields.
|
||||
|
||||
@@ -68,6 +68,12 @@ class StartJobRequest(BaseModel):
|
||||
# 预建好的 thread 映射(Phase 3b 真网关用;模拟网关忽略)。
|
||||
threadIds: dict[str, str] | None = None
|
||||
coordinatorName: str = "roundtable-coordinator"
|
||||
# 席位执行模式(快速/思考/专业问答/多智能体)派生参数,由前端 toStartBody 下发。
|
||||
# 仅 special 席位用:覆盖 roundtable_run_policy("seat") 的 thinking/reasoning;
|
||||
# None/缺省 → 后端按 policy 默认(≈pro),向后兼容旧前端。subagent 仅 ultra 档为真。
|
||||
thinking_enabled: bool | None = None
|
||||
reasoning_effort: str | None = None
|
||||
subagent_enabled: bool = False
|
||||
|
||||
|
||||
class ResumeJobRequest(BaseModel):
|
||||
@@ -219,6 +225,9 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes
|
||||
draft_id=body.draftId,
|
||||
chain=body.chain,
|
||||
orchestration_plan=body.orchestrationPlan,
|
||||
seat_thinking_enabled=body.thinking_enabled,
|
||||
seat_reasoning_effort=body.reasoning_effort,
|
||||
seat_subagent_enabled=body.subagent_enabled,
|
||||
)
|
||||
)
|
||||
return _to_response(row)
|
||||
|
||||
@@ -10,18 +10,18 @@ gateway:
|
||||
port: 8001
|
||||
|
||||
models:
|
||||
- name: deepseek-chat
|
||||
display_name: DeepSeek Chat
|
||||
use: langchain_openai:ChatOpenAI
|
||||
model: deepseek-chat
|
||||
api_key: sk-3fee562f834a4c08bb0d471ef44cb445
|
||||
base_url: https://api.deepseek.com
|
||||
request_timeout: 600.0
|
||||
max_retries: 2
|
||||
max_tokens: 4096
|
||||
temperature: 1.0
|
||||
supports_vision: false
|
||||
supports_thinking: true
|
||||
# - name: deepseek-chat
|
||||
# display_name: DeepSeek Chat
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: deepseek-chat
|
||||
# api_key: sk-e1c22b7931dc446f8b67e57878edd105
|
||||
# base_url: https://api.deepseek.com
|
||||
# request_timeout: 600.0
|
||||
# max_retries: 2
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: false
|
||||
# supports_thinking: true
|
||||
|
||||
# - name: zai-org/GLM-5-FP8
|
||||
# display_name: zai-org/GLM-5-FP8
|
||||
|
||||
+99
-15
@@ -141,6 +141,30 @@ def _load_configured_skills() -> list:
|
||||
return resolved or [_fallback_knowledge_skill()]
|
||||
|
||||
|
||||
def _fallback_candidate_skills(tried: set[str]) -> list:
|
||||
"""容错兜底:从技能目录里挑出**尚未尝试过**的可用技能,内置「知识库检索」优先。
|
||||
|
||||
当配置技能调用全部失败、且没收集到任何新素材时,researcher 用这些技能再试一轮,
|
||||
而不是直接报错。内置知识库检索(原生多关键词并发,最稳)排在最前,其后是技能目录里
|
||||
其它已启用的技能。技能目录加载失败时只回退内置知识库检索,保证兜底链永不断。
|
||||
"""
|
||||
candidates: list = []
|
||||
seen: set[str] = set(tried)
|
||||
if KNOWLEDGE_SEARCH_SKILL_NAME not in seen:
|
||||
candidates.append(_fallback_knowledge_skill())
|
||||
seen.add(KNOWLEDGE_SEARCH_SKILL_NAME)
|
||||
try:
|
||||
from deerflow.skills.storage import get_or_new_skill_storage
|
||||
|
||||
for s in get_or_new_skill_storage().load_skills(enabled_only=True):
|
||||
if getattr(s, "name", None) and s.name not in seen:
|
||||
candidates.append(s)
|
||||
seen.add(s.name)
|
||||
except Exception:
|
||||
logger.warning("researcher: 兜底加载技能目录失败", exc_info=True)
|
||||
return candidates
|
||||
|
||||
|
||||
def _resolve_keyword_count(raw: object) -> int:
|
||||
"""把用户传入的检索关键词数量规整到 [1, _MAX_KEYWORD_COUNT],非法值回退默认 6。"""
|
||||
try:
|
||||
@@ -511,24 +535,35 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
||||
added += 1
|
||||
return added
|
||||
|
||||
async def _timed_search(skill_name: str, query: str, limit: int) -> list[SearchResult]:
|
||||
async def _timed_search(skill_name: str, query: str, limit: int) -> tuple[list[SearchResult], str]:
|
||||
# 加超时兜底:单个技能检索最多等 _SKILL_SEARCH_TIMEOUT_SECONDS 秒,
|
||||
# 超时不再阻塞后续技能,避免整段过程看起来"冻结"。
|
||||
# 返回 (结果, 错误详情):错误详情非空 = 该技能调用失败(触发兜底)。
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
results = await asyncio.wait_for(
|
||||
provider.search(query, max_results=limit),
|
||||
timeout=_SKILL_SEARCH_TIMEOUT_SECONDS,
|
||||
) or []
|
||||
)
|
||||
return (results or []), ""
|
||||
except asyncio.TimeoutError:
|
||||
msg = f"检索超时(>{_SKILL_SEARCH_TIMEOUT_SECONDS}s)"
|
||||
logger.warning("skill[%s] search timed out after %ss", skill_name, _SKILL_SEARCH_TIMEOUT_SECONDS)
|
||||
search_errors.append(f"skill[{skill_name}]: 检索超时(>{_SKILL_SEARCH_TIMEOUT_SECONDS}s)")
|
||||
return []
|
||||
search_errors.append(f"skill[{skill_name}]: {msg}")
|
||||
return [], msg
|
||||
except Exception as e:
|
||||
logger.warning("skill[%s] search failed: %s", skill_name, e)
|
||||
search_errors.append(f"skill[{skill_name}]: {e}")
|
||||
return []
|
||||
return [], str(e)
|
||||
|
||||
for sk in ordered:
|
||||
def _skill_display(sk) -> str:
|
||||
return _KNOWLEDGE_SKILL_DISPLAY if sk.name == KNOWLEDGE_SEARCH_SKILL_NAME else sk.name
|
||||
|
||||
async def _execute_skill(sk) -> tuple[int, str]:
|
||||
"""运行单个技能检索,返回 (新增素材数, 错误详情)。
|
||||
|
||||
错误详情非空 = 该技能整体调用失败(超时 / 抛异常 / 全部检索词失败),
|
||||
调用方据此 emit ``skill_error`` 步骤并触发"改用其它技能"的兜底逻辑。
|
||||
"""
|
||||
is_knowledge = sk.name == KNOWLEDGE_SEARCH_SKILL_NAME
|
||||
if is_knowledge and not is_user_query_search:
|
||||
# 原生知识库检索:对全部检索词并发检索(旧版默认路径)。
|
||||
@@ -557,13 +592,20 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
||||
|
||||
gathered = await asyncio.gather(*[_search_one(kw) for kw in search_keywords], return_exceptions=True)
|
||||
added = 0
|
||||
errs: list[str] = []
|
||||
for results in gathered:
|
||||
if isinstance(results, Exception):
|
||||
logger.warning("search failed: %s", results)
|
||||
search_errors.append(f"{type(results).__name__}: {results}")
|
||||
errs.append(f"{type(results).__name__}: {results}")
|
||||
continue
|
||||
added += _collect(results)
|
||||
search_errors.extend(getattr(provider, "errors", []))
|
||||
search_errors.extend(errs)
|
||||
# 全部检索词都抛异常且 0 新增 → 视为该技能整体调用失败,触发兜底
|
||||
error_detail = ""
|
||||
if added == 0 and errs and len(errs) == total:
|
||||
error_detail = ";".join(dict.fromkeys(errs))[:200]
|
||||
return added, error_detail
|
||||
else:
|
||||
# 定向技能检索:user_query 模式优先用用户原话;非知识库技能再
|
||||
# 附上 description 让 query 贴合该技能的领域。
|
||||
@@ -576,14 +618,35 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
||||
"message": f"使用技能「{sk.name}」检索:{skill_query[:60]}",
|
||||
"skill_name": sk.name,
|
||||
})
|
||||
added = _collect(await _timed_search(sk.name, skill_query, per_skill))
|
||||
results, error_detail = await _timed_search(sk.name, skill_query, per_skill)
|
||||
return _collect(results), error_detail
|
||||
|
||||
_emit_skill_step({
|
||||
"type": "skill_result",
|
||||
"message": f"技能「{_KNOWLEDGE_SKILL_DISPLAY if is_knowledge else sk.name}」检索到 {added} 条新素材",
|
||||
"skill_name": sk.name,
|
||||
"matched_count": added,
|
||||
})
|
||||
# 失败技能名(去重后用于兜底提示):记录配置技能里调用失败的那些
|
||||
failed_skill_names: list[str] = []
|
||||
tried_names: set[str] = set()
|
||||
|
||||
def _emit_skill_outcome(sk, added: int, error_detail: str) -> None:
|
||||
"""单个技能跑完后,按成功 / 失败 emit 对应步骤事件。"""
|
||||
if error_detail:
|
||||
failed_skill_names.append(_skill_display(sk))
|
||||
_emit_skill_step({
|
||||
"type": "skill_error",
|
||||
"message": f"技能「{_skill_display(sk)}」调用失败,尝试使用其他技能检索",
|
||||
"skill_name": sk.name,
|
||||
"reason": error_detail,
|
||||
})
|
||||
else:
|
||||
_emit_skill_step({
|
||||
"type": "skill_result",
|
||||
"message": f"技能「{_skill_display(sk)}」检索到 {added} 条新素材",
|
||||
"skill_name": sk.name,
|
||||
"matched_count": added,
|
||||
})
|
||||
|
||||
for sk in ordered:
|
||||
added, error_detail = await _execute_skill(sk)
|
||||
tried_names.add(sk.name)
|
||||
_emit_skill_outcome(sk, added, error_detail)
|
||||
|
||||
# 早停:累积素材已达上限
|
||||
if len(new_results) >= max_materials:
|
||||
@@ -594,6 +657,27 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
|
||||
})
|
||||
break
|
||||
|
||||
# ── 容错兜底:配置技能调用失败且没收集到任何新素材 → 改用其它可用技能继续检索 ──
|
||||
# (不直接报错;前端时间线靠 skill_error / fallback 步骤展示"技能报错,改用其它技能搜集")
|
||||
if not new_results and failed_skill_names:
|
||||
fallback_skills = _fallback_candidate_skills(tried_names)
|
||||
if fallback_skills:
|
||||
_emit_skill_step({
|
||||
"type": "fallback",
|
||||
"message": (
|
||||
f"已配置技能({'、'.join(dict.fromkeys(failed_skill_names))})检索失败,"
|
||||
"改用其他技能继续搜集素材"
|
||||
),
|
||||
"skill_names": [_skill_display(s) for s in fallback_skills],
|
||||
})
|
||||
for sk in fallback_skills:
|
||||
added, error_detail = await _execute_skill(sk)
|
||||
tried_names.add(sk.name)
|
||||
_emit_skill_outcome(sk, added, error_detail)
|
||||
if new_results:
|
||||
# 兜底已拿到素材即止,不再无谓尝试后续技能
|
||||
break
|
||||
|
||||
# 合并去重
|
||||
# - 首次检索 / 普通 re_search(无输入,用户只想重出):旧素材丢弃,覆盖式替换
|
||||
# - 素材不足求助 supplement_search:保留旧素材 + 追加新检索结果
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
"""Add seat_mode column to roundtable_chains.
|
||||
|
||||
Per-chain「默认席位执行模式」: an optional short string (flash/thinking/pro/ultra, see
|
||||
frontend ``lib/seat-mode.ts``) the user picks in the chain editor. When the chain is
|
||||
selected into a roundtable discussion the picker defaults to it (still overridable per
|
||||
run). NULL = unset → callers fall back to the default (pro). VARCHAR(16) on MySQL, plain
|
||||
String elsewhere — matching ``String(16)`` on ``RoundtableChainRow.seat_mode``.
|
||||
|
||||
``create_all`` adds the column for brand-new DBs; the startup ``_ensure_orm_columns_sync``
|
||||
sweep adds it to already-existing tables (sqlite/mysql/postgres). This revision keeps the
|
||||
alembic graph consistent with the rest of the chain columns and documents the change.
|
||||
|
||||
Revision ID: 20260615_01
|
||||
Revises: 20260612_01
|
||||
Create Date: 2026-06-15
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
|
||||
revision = "20260615_01"
|
||||
down_revision = "20260612_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_chains") and not _has_column("roundtable_chains", "seat_mode"):
|
||||
op.add_column("roundtable_chains", sa.Column("seat_mode", sa.String(length=16), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_column("roundtable_chains", "seat_mode"):
|
||||
op.drop_column("roundtable_chains", "seat_mode")
|
||||
+3
@@ -49,6 +49,9 @@ class RoundtableChainRow(Base):
|
||||
# 怎么派活、做什么(整体编排策略 / 风格 / 约束)。**仅分层 DAG 模式生效**:注入到总控
|
||||
# 每阶段派活提示(``buildDagStageLeaderTask``)。NULL = 不注入。纯文本,存为 LONGTEXT。
|
||||
coordinator_prompt: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
# 可选「默认席位执行模式」:flash/thinking/pro/ultra(见前端 lib/seat-mode.ts)。选入研讨时
|
||||
# 弹窗默认用它(仍可临时改)。NULL = 未设,调用方回退默认(pro)。短串,VARCHAR(16)。
|
||||
seat_mode: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
# 发布开关:True = 公共(所有账号可在「公共」筛选里查看 / 选用);False = 仅本人可见。
|
||||
# 编辑 / 删除始终只限 owner;发布只是放开「查看 + 选入研讨」。索引以支持「公共」列表筛选。
|
||||
is_public: Mapped[bool] = mapped_column(
|
||||
|
||||
+6
@@ -56,6 +56,8 @@ class RoundtableChainRepository:
|
||||
"stage_goals": _loads(getattr(row, "stage_goals", None)),
|
||||
# 总控编排提示(写给总控的派活指引,dag-only);None/空 = 不注入。纯文本,不 json。
|
||||
"coordinator_prompt": getattr(row, "coordinator_prompt", None),
|
||||
# 默认席位执行模式(flash/thinking/pro/ultra);None = 未设。
|
||||
"seat_mode": getattr(row, "seat_mode", None),
|
||||
# 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。
|
||||
"is_public": bool(getattr(row, "is_public", False)),
|
||||
"is_mine": (current_user_id is None) or (row.user_id == current_user_id),
|
||||
@@ -110,6 +112,7 @@ class RoundtableChainRepository:
|
||||
stages=_dumps(data.get("stages")),
|
||||
stage_goals=_dumps(data.get("stage_goals")),
|
||||
coordinator_prompt=(data.get("coordinator_prompt") or None),
|
||||
seat_mode=(data.get("seat_mode") or None),
|
||||
is_public=bool(data.get("is_public") or False),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
@@ -131,6 +134,7 @@ class RoundtableChainRepository:
|
||||
stages=_UNSET,
|
||||
stage_goals=_UNSET,
|
||||
coordinator_prompt=_UNSET,
|
||||
seat_mode=_UNSET,
|
||||
is_public=_UNSET,
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
@@ -149,6 +153,8 @@ class RoundtableChainRepository:
|
||||
row.stage_goals = _dumps(stage_goals)
|
||||
if coordinator_prompt is not _UNSET:
|
||||
row.coordinator_prompt = coordinator_prompt or None
|
||||
if seat_mode is not _UNSET:
|
||||
row.seat_mode = seat_mode or None
|
||||
if is_public is not _UNSET and is_public is not None:
|
||||
row.is_public = bool(is_public)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
|
||||
@@ -82,6 +82,10 @@ CALL _add_column_if_missing('roundtable_drafts', 'task_id', 'VARCHAR(128) NULL D
|
||||
-- ORM: RoundtableJobRow.orchestration_plan = PortableLongText(LONGTEXT), nullable。
|
||||
CALL _add_column_if_missing('roundtable_jobs', 'orchestration_plan', 'LONGTEXT NULL DEFAULT NULL');
|
||||
|
||||
-- seat_mode: 业务链条「默认席位执行模式」(flash/thinking/pro/ultra),对应 migration 20260615_01。
|
||||
-- 选入研讨时弹窗默认用它(仍可临时改)。ORM: RoundtableChainRow.seat_mode = String(16), nullable。
|
||||
CALL _add_column_if_missing('roundtable_chains', 'seat_mode', 'VARCHAR(16) NULL DEFAULT NULL');
|
||||
|
||||
-- ---- 清理临时存储过程 --------------------------------------------------------
|
||||
DROP PROCEDURE IF EXISTS _add_column_if_missing;
|
||||
|
||||
|
||||
Reference in new issue
Block a user