Merge branch 'dhk-1' into 'master'

feat(roundtable): 第二步席位执行模式(快速/思考/专业问答/多智能体)+ 子智能体任务卡片 + 后台完成跨页通知 + 链条默认模式持久化

See merge request knowledge-base-bj/deerflow-server!124
This commit is contained in:
董宏凯 committed 2026-06-16 06:19:04 +00:00
commit fe0dc8d1a5
48 files changed
+1733 -598

No files matched your search

-265
View File
@@ -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。
+161
View File
@@ -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 => { const hasActiveChild = (item: MenuItem): boolean => {
if (item.path && pathname === item.path) { if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
return true; return true;
} }
@@ -153,7 +153,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
inWujieEnv inWujieEnv
? item.id === activeTopId ? item.id === activeTopId
: item.path : item.path
? pathname === item.path ? pathname === item.path || pathname.startsWith(item.path + "/")
: hasActiveChild(item); : hasActiveChild(item);
const handleSidebarItemClick = (item: MenuItem) => { const handleSidebarItemClick = (item: MenuItem) => {
if (inWujieEnv) { if (inWujieEnv) {
+5 -3
View File
@@ -42,7 +42,7 @@ type PageSidebarProps = {
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about"; type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
const hasActiveChild = (item: MenuItem, pathname: string): boolean => { 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 true;
} }
return item.children?.some((child) => hasActiveChild(child, pathname)) ?? false; return item.children?.some((child) => hasActiveChild(child, pathname)) ?? false;
@@ -116,7 +116,9 @@ function SecondaryNavSidebar() {
const secondaryItems = activeTopItem?.children ?? []; const secondaryItems = activeTopItem?.children ?? [];
const isItemActive = (item: MenuItem) => 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 handleItemClick = (item: MenuItem) => {
const path = item.path ?? findFirstRoutePath(item.children); const path = item.path ?? findFirstRoutePath(item.children);
@@ -210,7 +212,7 @@ function SecondaryNavSidebar() {
{thirdItems.map((child) => { {thirdItems.map((child) => {
const ChildIcon = child.icon; const ChildIcon = child.icon;
const childActive = child.path const childActive = child.path
? pathname === child.path ? pathname === child.path || pathname.startsWith(child.path + "/")
: hasActiveChild(child, pathname); : hasActiveChild(child, pathname);
return ( return (
<button <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 { Message } from "@langchain/langgraph-sdk";
import type { BaseStream } from "@langchain/langgraph-sdk/react"; import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { CheckIcon, SendIcon, XIcon } from "lucide-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 { import {
Conversation, Conversation,
@@ -52,6 +53,38 @@ import { SubtaskCard } from "./subtask-card";
export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160; export const MESSAGE_LIST_DEFAULT_PADDING_BOTTOM = 160;
export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80; 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({ export function MessageList({
className, className,
contentClassName, contentClassName,
@@ -266,6 +299,7 @@ export function MessageList({
<Conversation <Conversation
className={cn("flex size-full flex-col justify-center", className)} className={cn("flex size-full flex-col justify-center", className)}
> >
<ScrollToBottomOnUserMessage threadId={threadId} messages={messages} />
<ConversationContent <ConversationContent
className={cn( className={cn(
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12", "mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
@@ -2,6 +2,7 @@
import { import {
ChevronDown, ChevronDown,
ChevronRight,
Download, Download,
FileJson, FileJson,
FileText, FileText,
@@ -74,7 +75,17 @@ import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime"; import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils"; 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 { t } = useI18n();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
@@ -190,6 +201,8 @@ export function RecentChatList({ compact = false }: { compact?: boolean }) {
if (visibleThreads.length === 0) { if (visibleThreads.length === 0) {
return null; return null;
} }
const shownThreads =
limit != null ? visibleThreads.slice(0, limit) : visibleThreads;
return ( return (
<> <>
<SidebarGroup className="px-2 pt-3 mt-2 border-t border-sidebar-border/60"> <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> <SidebarMenu>
<div className="flex w-full flex-col gap-0.5"> <div className="flex w-full flex-col gap-0.5">
{visibleThreads.map((thread) => { {shownThreads.map((thread) => {
const isActive = pathOfThread(thread) === pathname; const isActive = pathOfThread(thread) === pathname;
const pinned = isThreadPinned(thread); const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread); const favorited = isThreadFavorite(thread);
@@ -368,6 +381,22 @@ export function RecentChatList({ compact = false }: { compact?: boolean }) {
</SidebarMenuItem> </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> </div>
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
@@ -165,7 +165,7 @@ export function WorkspaceNavMenu() {
Checkpoint 迁移 Checkpoint 迁移
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && ( {isAdmin ? (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
navigate("/page/strategy/admin/user-leaderboard") navigate("/page/strategy/admin/user-leaderboard")
@@ -174,6 +174,15 @@ export function WorkspaceNavMenu() {
<TrophyIcon /> <TrophyIcon />
用户排行榜 用户排行榜
</DropdownMenuItem> </DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() =>
navigate("/page/strategy/user-leaderboard")
}
>
<TrophyIcon />
用户排行榜
</DropdownMenuItem>
)} )}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
@@ -10,7 +10,9 @@ import {
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { usePathname } from "next/navigation";
import { FrequentAgentsList } from "./frequent-agents-list";
import { RecentChatList } from "./recent-chat-list"; import { RecentChatList } from "./recent-chat-list";
import { RecentRoundtableList } from "./recent-roundtable-list"; import { RecentRoundtableList } from "./recent-roundtable-list";
import { RecentWritingList } from "./recent-writing-list"; import { RecentWritingList } from "./recent-writing-list";
@@ -25,7 +27,11 @@ export function WorkspaceSidebar({
}: React.ComponentProps<typeof Sidebar> & { compactChrome?: boolean }) { }: React.ComponentProps<typeof Sidebar> & { compactChrome?: boolean }) {
const { open: isSidebarOpen } = useSidebar(); const { open: isSidebarOpen } = useSidebar();
const { theme } = useTheme(); const { theme } = useTheme();
const pathname = usePathname();
const useWhiteSidebar = compactChrome && theme === "light"; const useWhiteSidebar = compactChrome && theme === "light";
// 通用问答(全量模式)左栏:新建回答 → 常用智能体 → 聊天记录(需求 0616 #1)。
// 仅在通用问答(/page/workspace/chats*)下启用三块式布局;其余 workspace 页保持原样。
const isGeneralQa = pathname.startsWith("/page/workspace/chats");
// 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。 // 紧凑模式:用「外层定宽容器 + transition-[width]」做展开/收起过渡动画。
// collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对 // collapsible="none" 的 Sidebar 是普通 in-flow 定宽块(非 absolute,故不受本布局对
@@ -44,7 +50,7 @@ export function WorkspaceSidebar({
variant="sidebar" variant="sidebar"
collapsible="none" collapsible="none"
className={cn( className={cn(
"h-full w-(--sidebar-width)", "h-full w-(--sidebar-width) border-r border-border",
useWhiteSidebar && useWhiteSidebar &&
"[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white", "[&_[data-slot=sidebar-inner]]:bg-white [&_[data-slot=sidebar-container]]:bg-white",
className, className,
@@ -53,7 +59,12 @@ export function WorkspaceSidebar({
> >
<SidebarContent> <SidebarContent>
<WorkspaceNavChatList showCollapseTrigger compact /> <WorkspaceNavChatList showCollapseTrigger compact />
<RecentChatList compact /> {isGeneralQa && <FrequentAgentsList />}
<RecentChatList
compact
limit={isGeneralQa ? 10 : undefined}
moreHref={isGeneralQa ? "/page/workspace/chats" : undefined}
/>
</SidebarContent> </SidebarContent>
<SidebarRail /> <SidebarRail />
</Sidebar> </Sidebar>
@@ -43,6 +43,8 @@ export type MenuItem = {
label: string; label: string;
icon: ElementType; icon: ElementType;
path?: string; path?: string;
/** 非 admin 用户使用的路由(菜单对普通用户可见时生效,与简洁模式公开排行榜一致) */
userPath?: string;
adminOnly?: boolean; adminOnly?: boolean;
children?: MenuItem[]; children?: MenuItem[];
}; };
@@ -81,18 +83,18 @@ export const sidebarMenuItems: MenuItem[] = [
label: "XK中任务", label: "XK中任务",
icon: ClipboardList, icon: ClipboardList,
children: [ children: [
{ // {
id: "xk-task-jc", // id: "xk-task-jc",
label: "任务JC", // label: "任务JC",
icon: ListTodo, // icon: ListTodo,
path: "/page/strategy/task-management/xk-task-jc", // path: "/page/strategy/task-management/xk-task-jc",
}, // },
{ // {
id: "xk-task-dispatch", // id: "xk-task-dispatch",
label: "任务调度", // label: "任务调度",
icon: ListTodo, // icon: ListTodo,
path: "/page/strategy/task-management/xk-task-dispatch", // path: "/page/strategy/task-management/xk-task-dispatch",
}, // },
], ],
}, },
{ {
@@ -125,24 +127,24 @@ export const sidebarMenuItems: MenuItem[] = [
label: "PG中任务", label: "PG中任务",
icon: Gauge, icon: Gauge,
children: [ children: [
{ // {
id: "pg-task-pg", // id: "pg-task-pg",
label: "任务PG", // label: "任务PG",
icon: ListTodo, // icon: ListTodo,
path: "/page/strategy/task-management/effect-task-pg", // path: "/page/strategy/task-management/effect-task-pg",
}, // },
{ // {
id: "pg-task-xdpg", // id: "pg-task-xdpg",
label: "XDPG", // label: "XDPG",
icon: ListTodo, // icon: ListTodo,
path: "/page/strategy/task-management/effect-action-pg", // path: "/page/strategy/task-management/effect-action-pg",
}, // },
{ // {
id: "pg-task-info-pg", // id: "pg-task-info-pg",
label: "信息PG", // label: "信息PG",
icon: ListTodo, // icon: ListTodo,
path: "/page/strategy/task-management/effect-info-pg", // path: "/page/strategy/task-management/effect-info-pg",
}, // },
], ],
}, },
{ {
@@ -150,24 +152,24 @@ export const sidebarMenuItems: MenuItem[] = [
label: "TY中任务", label: "TY中任务",
icon: Radar, icon: Radar,
children: [ children: [
{ // {
id: "ty-task-xdty", // id: "ty-task-xdty",
label: "XDTY任务", // label: "XDTY任务",
icon: ListTodo, // icon: ListTodo,
path: "/page/strategy/task-management/mnty-task-mechanism", // path: "/page/strategy/task-management/mnty-task-mechanism",
}, // },
{ // {
id: "ty-task-plan", // id: "ty-task-plan",
label: "方案TY任务", // label: "方案TY任务",
icon: ListTodo, // icon: ListTodo,
path: "/page/strategy/task-management/mnty-task-plan", // path: "/page/strategy/task-management/mnty-task-plan",
}, // },
{ // {
id: "ty-task-xxty", // id: "ty-task-xxty",
label: "XXTY任务", // label: "XXTY任务",
icon: ListTodo, // icon: ListTodo,
path: "/page/strategy/task-management/mnty-task-zsty", // path: "/page/strategy/task-management/mnty-task-zsty",
}, // },
], ],
}, },
], ],
@@ -197,6 +199,13 @@ export const sidebarMenuItems: MenuItem[] = [
label: "综合管理", label: "综合管理",
icon: Settings, icon: Settings,
children: [ 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-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
{ {
id: "mgmt-business-chain-mapping", id: "mgmt-business-chain-mapping",
@@ -232,13 +241,6 @@ export const sidebarMenuItems: MenuItem[] = [
path: "/page/strategy/admin/users", path: "/page/strategy/admin/users",
adminOnly: true, adminOnly: true,
}, },
{
id: "mgmt-user-leaderboard",
label: "用户排行榜",
icon: Trophy,
path: "/page/strategy/admin/user-leaderboard",
adminOnly: true,
},
{ {
id: "mgmt-admin-scheduled-tasks", id: "mgmt-admin-scheduled-tasks",
label: "定时任务管理", label: "定时任务管理",
@@ -260,12 +262,12 @@ export const sidebarMenuItems: MenuItem[] = [
label: "资源管理", label: "资源管理",
icon: Database, icon: Database,
children: [ children: [
{ // {
id: "resource-network-xwt", // id: "resource-network-xwt",
label: "网络xwt资源管理", // label: "网络xwt资源管理",
icon: Network, // icon: Network,
path: "/page/strategy/resource-management/resource-network-actor", // path: "/page/strategy/resource-management/resource-network-actor",
}, // },
{ {
id: "resource-knowledge-native", id: "resource-knowledge-native",
label: "问答梳理", label: "问答梳理",
@@ -278,12 +280,12 @@ export const sidebarMenuItems: MenuItem[] = [
icon: Library, icon: Library,
path: "/page/strategy/resource-management/resource-knowledge-base", path: "/page/strategy/resource-management/resource-knowledge-base",
}, },
{ // {
id: "resource-evaluation-index", // id: "resource-evaluation-index",
label: "作战资源管理", // label: "作战资源管理",
icon: FileBox, // icon: FileBox,
path: "/page/strategy/resource-management/resource-evaluation-index", // path: "/page/strategy/resource-management/resource-evaluation-index",
}, // },
], ],
}, },
]; ];
@@ -298,9 +300,9 @@ export const defaultExpandedIds: ReadonlySet<string> = new Set([
"comprehensive-management", "comprehensive-management",
]); ]);
/** 该菜单项自身或任意后代的 path 是否等于当前路由 */ /** 该菜单项自身或任意后代的 path 是否匹配当前路由(支持子路径,如 /knowledge/:id) */
export function menuItemContainsPath(item: MenuItem, pathname: string): boolean { 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 true;
} }
return item.children?.some((child) => menuItemContainsPath(child, pathname)) ?? false; return item.children?.some((child) => menuItemContainsPath(child, pathname)) ?? false;
@@ -308,7 +310,8 @@ export function menuItemContainsPath(item: MenuItem, pathname: string): boolean
/** /**
* 在「一级 → 二级 → 三级」菜单中,取当前路由所在的「激活二级项」的三级子项。 * 在「一级 → 二级 → 三级」菜单中,取当前路由所在的「激活二级项」的三级子项。
* 浅色侧边栏据此在二级右侧展开三级列;无三级则返回空数组(侧边栏保持窄栏)。 * 浅色侧边栏据此在二级右侧展开三级列;深色顶栏据此渲染三级横条。
* 全量模式:三级仅一项时不返回(无选择必要,侧边栏保持窄栏 / 不渲染横条)。
*/ */
export function getActiveThirdLevelItems( export function getActiveThirdLevelItems(
items: MenuItem[], items: MenuItem[],
@@ -320,16 +323,23 @@ export function getActiveThirdLevelItems(
const activeSecond = secondary.find( const activeSecond = secondary.find(
(item) => (item.children?.length ?? 0) > 0 && menuItemContainsPath(item, pathname), (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[] { export function filterMenuItemsByRole(items: MenuItem[], isAdmin: boolean): MenuItem[] {
return items return items
.filter((item) => !item.adminOnly || isAdmin) .filter((item) => !item.adminOnly || isAdmin)
.map((item) => .map((item) => {
item.children const children = item.children
? { ...item, children: filterMenuItemsByRole(item.children, isAdmin) } ? filterMenuItemsByRole(item.children, isAdmin)
: item, : 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 { 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 { cn } from '@/lib/utils';
import type { SkillSearchStep } from '../../types/ai-writing'; import type { SkillSearchStep } from '../../types/ai-writing';
import { CollapseTransition } from './CollapseTransition'; import { CollapseTransition } from './CollapseTransition';
@@ -16,6 +16,8 @@ const STEP_ICON: Record<SkillSearchStep['type'], typeof Sparkles> = {
select_skills: Sparkles, select_skills: Sparkles,
invoke_skill: Zap, invoke_skill: Zap,
skill_result: CheckCircle2, skill_result: CheckCircle2,
skill_error: AlertTriangle,
fallback: Shuffle,
stop_early: CircleStop, stop_early: CircleStop,
}; };
@@ -25,6 +27,8 @@ const STEP_LABEL: Record<SkillSearchStep['type'], string> = {
select_skills: '筛选相关技能', select_skills: '筛选相关技能',
invoke_skill: '调用技能', invoke_skill: '调用技能',
skill_result: '检索结果', skill_result: '检索结果',
skill_error: '技能调用失败',
fallback: '改用其他技能',
stop_early: '提前停止', stop_early: '提前停止',
}; };
@@ -71,6 +75,9 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
const label = STEP_LABEL[step.type] ?? step.type; const label = STEP_LABEL[step.type] ?? step.type;
const isStopEarly = step.type === 'stop_early'; const isStopEarly = step.type === 'stop_early';
const isResult = step.type === 'skill_result'; const isResult = step.type === 'skill_result';
// 容错:技能调用失败标红,回退其它技能标琥珀,提示用户"出错了但已自动改用其他技能"
const isError = step.type === 'skill_error';
const isFallback = step.type === 'fallback';
const isActive = idx === activeIdx; const isActive = idx === activeIdx;
return ( return (
<li key={idx} className="flex items-start gap-2 text-[11px]"> <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', 'shrink-0 mt-0.5 inline-flex items-center justify-center size-4 rounded-full text-[10px] font-medium',
isActive isActive
? 'bg-violet-500 text-white' ? 'bg-violet-500 text-white'
: isStopEarly : isError
? 'bg-amber-100 dark:bg-amber-900/60 text-amber-600 dark:text-amber-300' ? 'bg-rose-100 dark:bg-rose-900/60 text-rose-600 dark:text-rose-300'
: isResult : isStopEarly || isFallback
? 'bg-emerald-100 dark:bg-emerald-900/60 text-emerald-600 dark:text-emerald-300' ? 'bg-amber-100 dark:bg-amber-900/60 text-amber-600 dark:text-amber-300'
: 'bg-violet-100 dark:bg-violet-900/60 text-violet-600 dark:text-violet-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} {isActive ? <Loader2 className="size-2.5 animate-spin" /> : idx + 1}
@@ -92,11 +101,13 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
<div <div
className={cn( className={cn(
'flex items-center gap-1 font-medium', 'flex items-center gap-1 font-medium',
isStopEarly isError
? 'text-amber-700 dark:text-amber-300' ? 'text-rose-700 dark:text-rose-300'
: isResult : isStopEarly || isFallback
? 'text-emerald-700 dark:text-emerald-300' ? 'text-amber-700 dark:text-amber-300'
: 'text-violet-700 dark:text-violet-300', : isResult
? 'text-emerald-700 dark:text-emerald-300'
: 'text-violet-700 dark:text-violet-300',
)} )}
> >
<Icon className="size-3 shrink-0" /> <Icon className="size-3 shrink-0" />
@@ -20,6 +20,8 @@ export interface AIMaterial {
// select_skills:LLM 按相关度排序的候选 // select_skills:LLM 按相关度排序的候选
// invoke_skill:开始调用某个技能去检索 // invoke_skill:开始调用某个技能去检索
// skill_result:某个技能检索返回了 N 条素材 // skill_result:某个技能检索返回了 N 条素材
// skill_error:某个技能调用失败(超时/报错),将改用其他技能(容错)
// fallback:配置技能全部失败,回退到其他技能继续搜集(容错)
// stop_early:累计素材达到上限,停止后续技能 // stop_early:累计素材达到上限,停止后续技能
export interface SkillSearchStep { export interface SkillSearchStep {
type: type:
@@ -28,6 +30,8 @@ export interface SkillSearchStep {
| "select_skills" | "select_skills"
| "invoke_skill" | "invoke_skill"
| "skill_result" | "skill_result"
| "skill_error"
| "fallback"
| "stop_early"; | "stop_early";
message: string; message: string;
skillNames?: string[]; skillNames?: string[];
@@ -133,6 +133,8 @@ export default function LightAppManagementPage() {
const [form, setForm] = useState<FormState>(emptyForm); const [form, setForm] = useState<FormState>(emptyForm);
const [activeParamPopover, setActiveParamPopover] = useState<string | null>(null); const [activeParamPopover, setActiveParamPopover] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"table" | "cards">("table"); 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; const saving = actions.create.isPending || actions.update.isPending;
@@ -220,7 +222,22 @@ export default function LightAppManagementPage() {
toast.success("已复制地址"); 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 ( return (
<div className="light-app-management-page h-full overflow-y-auto bg-neutral-50 dark:bg-[#0C1836] dark:text-[#C5CFE0]"> <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> </button>
</div> </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"> <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" /> <Search className="absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-neutral-400" />
<input <input
@@ -272,6 +289,41 @@ export default function LightAppManagementPage() {
)} )}
</div> </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"> <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 <button
onClick={() => setViewMode("table")} onClick={() => setViewMode("table")}
@@ -650,7 +702,7 @@ export default function LightAppManagementPage() {
onClick={() => setDialogOpen(false)} 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="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"> <div className="flex items-center space-x-2.5">
<span className="lam-dialog-icon rounded-[5px] bg-rose-50 p-1.5 text-[#A8071A]"> <span className="lam-dialog-icon rounded-[5px] bg-rose-50 p-1.5 text-[#A8071A]">
+3
View File
@@ -17,6 +17,7 @@ import Header from "@/strategy-components/Header";
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes"; import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
import RoundtableRoutes from "./RoundtableRoutes"; import RoundtableRoutes from "./RoundtableRoutes";
import NotebookRoutes from "./NotebookRoutes"; import NotebookRoutes from "./NotebookRoutes";
import { RoundtableJobNotifier } from "@/roundtable-planning/components/RoundtableJobNotifier";
function PageLayout() { function PageLayout() {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
@@ -43,6 +44,7 @@ function PageLayout() {
// 高度链,根布局跟随容器高度。全屏下 100% 解析到视口,与 h-screen 等价;无界等嵌套 // 高度链,根布局跟随容器高度。全屏下 100% 解析到视口,与 h-screen 等价;无界等嵌套
// 下容器不占满整屏时,高度才能正确收缩而非锁死整屏。 // 下容器不占满整屏时,高度才能正确收缩而非锁死整屏。
return ( return (
<RoundtableJobNotifier>
<div className="h-full flex flex-col bg-muted/20"> <div className="h-full flex flex-col bg-muted/20">
{pageSidebarAllowed && <Header collapsed={collapsed} />} {pageSidebarAllowed && <Header collapsed={collapsed} />}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
@@ -97,6 +99,7 @@ function PageLayout() {
</main> </main>
</div> </div>
</div> </div>
</RoundtableJobNotifier>
); );
} }
@@ -25,6 +25,7 @@ import ChatPage from "./ChatPage";
import ChatsPage from "./ChatsPage"; import ChatsPage from "./ChatsPage";
import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage"; import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage";
import AdminUserLeaderboardSettingsPage from "./AdminUserLeaderboardSettingsPage"; import AdminUserLeaderboardSettingsPage from "./AdminUserLeaderboardSettingsPage";
import PublicLeaderboardPage from "./PublicLeaderboardPage";
import LightAppIframeView from "./LightAppIframeView"; import LightAppIframeView from "./LightAppIframeView";
import LightAppListPage from "./LightAppListPage"; import LightAppListPage from "./LightAppListPage";
import LightAppManagementPage from "./LightAppManagementPage"; import LightAppManagementPage from "./LightAppManagementPage";
@@ -253,6 +254,7 @@ export default function StrategyRoutes() {
) )
} }
/> />
<Route path="user-leaderboard" element={<PublicLeaderboardPage />} />
<Route <Route
path="admin/user-leaderboard" path="admin/user-leaderboard"
element={ element={
+6 -4
View File
@@ -70,6 +70,7 @@ export function WorkspaceLayout({
workspaceSidebarHidden = false, workspaceSidebarHidden = false,
headerHidden = false, headerHidden = false,
compactWorkspaceSidebar = false, compactWorkspaceSidebar = false,
defaultHistorySidebarOpen,
wrapperOverflowHidden = false, wrapperOverflowHidden = false,
insetOverflowHidden = false, insetOverflowHidden = false,
pageSidebarDark = false, pageSidebarDark = false,
@@ -80,6 +81,8 @@ export function WorkspaceLayout({
workspaceSidebarHidden?: boolean; workspaceSidebarHidden?: boolean;
headerHidden?: boolean; headerHidden?: boolean;
compactWorkspaceSidebar?: boolean; compactWorkspaceSidebar?: boolean;
/** 全量模式下通用问答等页:聊天记录侧栏默认展开;未指定时紧凑模式仍默认收起。 */
defaultHistorySidebarOpen?: boolean;
wrapperOverflowHidden?: boolean; wrapperOverflowHidden?: boolean;
insetOverflowHidden?: boolean; insetOverflowHidden?: boolean;
pageSidebarDark?: boolean; pageSidebarDark?: boolean;
@@ -110,8 +113,7 @@ export function WorkspaceLayout({
} }
key={String(compactWorkspaceSidebar)} key={String(compactWorkspaceSidebar)}
data-page-sidebar-collapsed={pageSidebarCollapsed} data-page-sidebar-collapsed={pageSidebarCollapsed}
// 紧凑模式下历史侧栏默认收起(聊天区更宽),由聊天页右上角图标钮展开 defaultOpen={defaultHistorySidebarOpen ?? !compactWorkspaceSidebar}
defaultOpen={!compactWorkspaceSidebar}
> >
{!workspaceSidebarHidden && ( {!workspaceSidebarHidden && (
<WorkspaceSidebar compactChrome={compactWorkspaceSidebar} /> <WorkspaceSidebar compactChrome={compactWorkspaceSidebar} />
@@ -153,7 +155,7 @@ function NewChatEntryRoute({
} }
return ( return (
<WorkspaceLayout {...layoutProps}> <WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
<ChatRuntime> <ChatRuntime>
<ChatPage /> <ChatPage />
</ChatRuntime> </ChatRuntime>
@@ -201,7 +203,7 @@ export default function WorkspaceRoutes({
<Route <Route
path="chats/:thread_id" path="chats/:thread_id"
element={ element={
<WorkspaceLayout {...layoutProps}> <WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
<ChatRuntime> <ChatRuntime>
<ChatPage /> <ChatPage />
</ChatRuntime> </ChatRuntime>
@@ -65,22 +65,26 @@
outline: none; 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); background: var(--color-bg-page);
border-color: var(--color-border); 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); background: var(--color-bg-card);
color: var(--color-link); color: var(--color-link);
box-shadow: var(--shadow-1); 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); 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); 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; color: #0062D9;
} }
@@ -14,6 +14,7 @@ import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain"; 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. */ /** Fields accepted when creating a chain. */
export interface ChainCreatePayload { export interface ChainCreatePayload {
@@ -28,6 +29,8 @@ export interface ChainCreatePayload {
stageGoals?: (string | null)[] | null; stageGoals?: (string | null)[] | null;
/** Optional coordinator orchestration prompt (dag-only). null/"" = none. */ /** Optional coordinator orchestration prompt (dag-only). null/"" = none. */
coordinatorPrompt?: string | null; coordinatorPrompt?: string | null;
/** 默认席位执行模式(flash/thinking/pro/ultra);null = 未设。 */
seatMode?: SeatMode | null;
} }
/** Partial update — send only what changed. */ /** Partial update — send only what changed. */
@@ -41,6 +44,8 @@ export interface ChainUpdatePayload {
stageGoals?: (string | null)[] | null; stageGoals?: (string | null)[] | null;
/** 写给总控的编排提示;send `null`/"" to clear, omit to leave. */ /** 写给总控的编排提示;send `null`/"" to clear, omit to leave. */
coordinatorPrompt?: string | null; coordinatorPrompt?: string | null;
/** 默认席位执行模式;send `null` to clear, omit to leave. */
seatMode?: SeatMode | null;
/** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */ /** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */
isPublic?: boolean; isPublic?: boolean;
} }
@@ -70,6 +75,7 @@ interface ServerChain {
stages?: string[][] | null; stages?: string[][] | null;
stage_goals?: (string | null)[] | null; stage_goals?: (string | null)[] | null;
coordinator_prompt?: string | null; coordinator_prompt?: string | null;
seat_mode?: string | null;
is_public?: boolean; is_public?: boolean;
is_mine?: boolean; is_mine?: boolean;
owner_name?: string | null; owner_name?: string | null;
@@ -86,6 +92,7 @@ function toChain(s: ServerChain): BusinessChain {
stages: Array.isArray(s.stages) ? s.stages : null, stages: Array.isArray(s.stages) ? s.stages : null,
stageGoals: Array.isArray(s.stage_goals) ? s.stage_goals : null, stageGoals: Array.isArray(s.stage_goals) ? s.stage_goals : null,
coordinatorPrompt: s.coordinator_prompt ?? null, coordinatorPrompt: s.coordinator_prompt ?? null,
seatMode: (s.seat_mode as SeatMode | null) ?? undefined,
isPublic: s.is_public ?? false, isPublic: s.is_public ?? false,
// 后端缺省时按「我的」处理(向后兼容旧响应)。 // 后端缺省时按「我的」处理(向后兼容旧响应)。
isMine: s.is_mine ?? true, isMine: s.is_mine ?? true,
@@ -104,6 +111,7 @@ function fromCreate(p: ChainCreatePayload) {
...(p.stages !== undefined ? { stages: p.stages } : {}), ...(p.stages !== undefined ? { stages: p.stages } : {}),
...(p.stageGoals !== undefined ? { stage_goals: p.stageGoals } : {}), ...(p.stageGoals !== undefined ? { stage_goals: p.stageGoals } : {}),
...(p.coordinatorPrompt !== undefined ? { coordinator_prompt: p.coordinatorPrompt } : {}), ...(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). // Forward stage goals even when explicitly null (clears goals server-side).
if (p.stageGoals !== undefined) out.stage_goals = p.stageGoals; if (p.stageGoals !== undefined) out.stage_goals = p.stageGoals;
if (p.coordinatorPrompt !== undefined) out.coordinator_prompt = p.coordinatorPrompt; 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; if (p.isPublic !== undefined) out.is_public = p.isPublic;
return out; return out;
} }
@@ -202,6 +202,15 @@ export interface StreamMultiAgentRequest {
displayName?: string; displayName?: string;
newMessage: string; newMessage: string;
model?: 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[]; skillStopNames?: string[];
/** /**
* 仅 leader 用。置 true 表示这是「派活后让总控判断继续/综合」的轮次:后端会把各已交付 * 仅 leader 用。置 true 表示这是「派活后让总控判断继续/综合」的轮次:后端会把各已交付
@@ -444,6 +453,10 @@ export async function streamMultiAgent(
display_name: req.displayName, display_name: req.displayName,
new_message: req.newMessage, new_message: req.newMessage,
model: req.model, model: req.model,
// 席位模式派生参数(仅 special);undefined 时不带上 → 后端按 policy 默认执行。
thinking_enabled: req.thinkingEnabled,
reasoning_effort: req.reasoningEffort,
subagent_enabled: req.subagentEnabled,
skill_stop_names: req.skillStopNames ?? [], skill_stop_names: req.skillStopNames ?? [],
// 仅 leader 用;综合轮让后端注入各席位完整交付正文。省略 → 后端按 false 处理。 // 仅 leader 用;综合轮让后端注入各席位完整交付正文。省略 → 后端按 false 处理。
synthesis_mode: req.synthesisMode ?? false, synthesis_mode: req.synthesisMode ?? false,
@@ -15,6 +15,7 @@ import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import { getAvatarTypeFor } from "../lib/roundtable-constants"; import { getAvatarTypeFor } from "../lib/roundtable-constants";
import { seatModeToRunParams } from "../lib/seat-mode";
import { import {
isTerminalJobStatus, isTerminalJobStatus,
type DispatchChainNode, type DispatchChainNode,
@@ -75,6 +76,9 @@ function mapJob(raw: Record<string, unknown>): RoundtableJob {
} }
function toStartBody(p: StartJobPayload) { function toStartBody(p: StartJobPayload) {
// 席位模式 → thinking/reasoning/subagent,与前端实时路径(/run/stream)同一份映射;
// 缺省(未选模式)时不带这三字段 → 后端按 policy 默认(≈pro),向后兼容。
const seat = p.seatMode ? seatModeToRunParams(p.seatMode) : null;
return { return {
draftId: p.draftId ?? null, draftId: p.draftId ?? null,
intent: p.intent, intent: p.intent,
@@ -86,6 +90,13 @@ function toStartBody(p: StartJobPayload) {
seedMessage: p.seedMessage, seedMessage: p.seedMessage,
threadIds: p.threadIds ?? null, threadIds: p.threadIds ?? null,
coordinatorName: p.coordinatorName ?? "roundtable-coordinator", 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。 * 详见 `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 { import {
ArrowRightIcon, ArrowRightIcon,
CheckIcon, CheckIcon,
@@ -44,12 +44,15 @@ export interface BusinessChainPickerProps {
onCancel: () => void; onCancel: () => void;
/** 跳转到业务链条配置页(同时关闭弹窗)。 */ /** 跳转到业务链条配置页(同时关闭弹窗)。 */
onGoConfig: () => void; onGoConfig: () => void;
/** 选中链条变化时回调(含其 seatMode),父弹窗据此把执行模式默认设为链条的配置。 */
onSelectedChainChange?: (chain: BusinessChain | null) => void;
} }
export function BusinessChainPicker({ export function BusinessChainPicker({
onConfirm, onConfirm,
onCancel, onCancel,
onGoConfig, onGoConfig,
onSelectedChainChange,
}: BusinessChainPickerProps) { }: BusinessChainPickerProps) {
const [chains, setChains] = useState<BusinessChain[]>([]); const [chains, setChains] = useState<BusinessChain[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -102,6 +105,15 @@ export function BusinessChainPicker({
() => chains.find((c) => c.id === selectedId) ?? null, () => chains.find((c) => c.id === selectedId) ?? null,
[chains, selectedId], [chains, selectedId],
); );
// 选中链条变化 → 通知父弹窗(用 ref 持有回调,只在 selectedChain 真正变化时触发,
// 避免父级每次 render 重置用户已手动改过的执行模式)。
const onSelectedChainChangeRef = useRef(onSelectedChainChange);
useEffect(() => {
onSelectedChainChangeRef.current = onSelectedChainChange;
}, [onSelectedChainChange]);
useEffect(() => {
onSelectedChainChangeRef.current?.(selectedChain);
}, [selectedChain]);
const survivingSeats = useMemo( const survivingSeats = useMemo(
() => (selectedChain ? selectedChain.seats.filter((s) => isExisting(s.agent_id)) : []), () => (selectedChain ? selectedChain.seats.filter((s) => isExisting(s.agent_id)) : []),
[selectedChain, isExisting], [selectedChain, isExisting],
@@ -52,6 +52,8 @@ export interface MessageStepsCardProps {
/** 工具调用阶段的 step 才进入折叠卡;非工具步骤(connecting/streaming/tool_result)归到「思考」段。 */ /** 工具调用阶段的 step 才进入折叠卡;非工具步骤(connecting/streaming/tool_result)归到「思考」段。 */
function isToolStep(step: BubbleStep): boolean { 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; 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 { BusinessChainPicker } from "./BusinessChainPicker";
import type { IntentSummary } from "../api/intent"; import type { IntentSummary } from "../api/intent";
import type { SeatSelectionResult } from "../lib/business-chain"; import type { SeatSelectionResult } from "../lib/business-chain";
import {
DEFAULT_SEAT_MODE,
SEAT_MODE_OPTIONS,
type SeatMode,
} from "../lib/seat-mode";
import { import {
filterRecommendCandidates, filterRecommendCandidates,
listRecommendHistory, listRecommendHistory,
@@ -200,6 +205,9 @@ export function RecommendAgentsDialog({
const navigate = useNavigate(); const navigate = useNavigate();
// 席位来源模式:智能推荐 / 业务链条。默认**业务链条**。 // 席位来源模式:智能推荐 / 业务链条。默认**业务链条**。
const [mode, setMode] = useState<"recommend" | "chain">("chain"); const [mode, setMode] = useState<"recommend" | "chain">("chain");
// 席位执行模式(快速/思考/专业问答/多智能体问答)——推荐 / 业务链条两种来源通用,
// 决定第二步每个子智能体席位的推理深度。默认 pro(= 改造前席位固定行为)。
const [seatMode, setSeatMode] = useState<SeatMode>(DEFAULT_SEAT_MODE);
const [state, setState] = useState<InternalState>(INITIAL_STATE); const [state, setState] = useState<InternalState>(INITIAL_STATE);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
@@ -515,6 +523,14 @@ export function RecommendAgentsDialog({
return `已选 ${selectedCount} / ${MAX_SELECTED}`; return `已选 ${selectedCount} / ${MAX_SELECTED}`;
})(); })();
// 统一在回传前合并席位模式,推荐 / 业务链条两条确认路径共用,保证 seatMode 总被带上。
const handleConfirmWithMode = useCallback(
(result: SeatSelectionResult) => {
onConfirm({ ...result, seatMode });
},
[onConfirm, seatMode],
);
const handleConfirm = useCallback(() => { const handleConfirm = useCallback(() => {
if (!canConfirm) return; if (!canConfirm) return;
// Order: recommended-and-selected first (in recommender order), then // Order: recommended-and-selected first (in recommender order), then
@@ -530,8 +546,8 @@ export function RecommendAgentsDialog({
for (const c of otherCards) { for (const c of otherCards) {
if (selectedIds.has(c.agent_id)) ordered.push({ agent_id: c.agent_id, name: c.name }); if (selectedIds.has(c.agent_id)) ordered.push({ agent_id: c.agent_id, name: c.name });
} }
onConfirm({ agents: ordered, mode: "recommend" }); handleConfirmWithMode({ agents: ordered, mode: "recommend" });
}, [canConfirm, recommendedCards, otherCards, selectedIds, onConfirm]); }, [canConfirm, recommendedCards, otherCards, selectedIds, handleConfirmWithMode]);
const handleGoConfig = useCallback(() => { const handleGoConfig = useCallback(() => {
onCancel(); onCancel();
@@ -595,6 +611,35 @@ export function RecommendAgentsDialog({
</button> </button>
</div> </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" && ( {mode === "recommend" && (
<> <>
@@ -750,9 +795,13 @@ export function RecommendAgentsDialog({
{/* ===== 业务链条模式 ===== */} {/* ===== 业务链条模式 ===== */}
{mode === "chain" && ( {mode === "chain" && (
<BusinessChainPicker <BusinessChainPicker
onConfirm={onConfirm} onConfirm={handleConfirmWithMode}
onCancel={onCancel} onCancel={onCancel}
onGoConfig={handleGoConfig} onGoConfig={handleGoConfig}
onSelectedChainChange={(chain) => {
// 选中链条若配了默认执行模式 → 把顶部模式选择器默认设为它(用户仍可临时改)。
if (chain?.seatMode) setSeatMode(chain.seatMode);
}}
/> />
)} )}
</DialogContent> </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, StreamingCursor,
} from "@/roundtable-planning/components/MessageBubble"; } from "@/roundtable-planning/components/MessageBubble";
import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard"; 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 { ParallelSeatStrip } from "@/roundtable-planning/components/ParallelSeatStrip";
import { DagFlowMiniPanel } from "@/roundtable-planning/components/DagFlowMiniPanel"; import { DagFlowMiniPanel } from "@/roundtable-planning/components/DagFlowMiniPanel";
import { useArtifacts } from "@/components/workspace/artifacts"; import { useArtifacts } from "@/components/workspace/artifacts";
@@ -909,6 +911,15 @@ export function Step2Message({
resolveAgentName={resolveAgentName} 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 && ( {reasoning && (
<ReasoningBlock streaming={isStreaming}>{reasoning}</ReasoningBlock> <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。 * 发起前 / 点查看前各存一次,保住当时的聊天记录不丢。返回 draft id。
*/ */
saveDraftNow: (furthestStep?: 1 | 2 | 3) => Promise<string | null>; saveDraftNow: (furthestStep?: 1 | 2 | 3) => Promise<string | null>;
/**
* 用**显式传入的 step2 快照**强制存一次(不读 live 状态)。「后台挂起直接跳转」用:
* 跳转会清空 step2,调用方在跳转前冻结快照传进来,保证落库的是挂起那一刻的研讨记录。
*/
saveStep2SnapshotNow: (step2: DraftStep2Snapshot, furthestStep?: 1 | 2 | 3) => Promise<string | null>;
/** 强制刷新草稿列表。 */ /** 强制刷新草稿列表。 */
refresh: () => Promise<void>; refresh: () => Promise<void>;
} }
@@ -287,6 +292,51 @@ export function useDraftPersistence(
[currentStep, enqueueSave, refreshList], [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 handleManualSave = useCallback(() => {
const furthest = (currentStep as 1 | 2 | 3) || 1; const furthest = (currentStep as 1 | 2 | 3) || 1;
void (async () => { void (async () => {
@@ -535,6 +585,7 @@ export function useDraftPersistence(
handleCancelRename, handleCancelRename,
ensureDraft, ensureDraft,
saveDraftNow, saveDraftNow,
saveStep2SnapshotNow,
/** 强制刷新草稿列表(如后台挂起后让「进行中」状态立刻出现)。 */ /** 强制刷新草稿列表(如后台挂起后让「进行中」状态立刻出现)。 */
refresh: refreshList, refresh: refreshList,
}; };
@@ -27,6 +27,7 @@ import {
type ThreadIdMap, type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent"; } from "@/roundtable-planning/api/multi-agent";
import { uploadFiles } from "@/core/uploads/api"; import { uploadFiles } from "@/core/uploads/api";
import { useUpdateSubtask } from "@/core/tasks/context";
/** 干预消息携带的上传文件引用(与 streamMultiAgent 的 files 字段同形)。 */ /** 干预消息携带的上传文件引用(与 streamMultiAgent 的 files 字段同形)。 */
type RunFileRef = NonNullable<StreamMultiAgentRequest["files"]>[number]; type RunFileRef = NonNullable<StreamMultiAgentRequest["files"]>[number];
@@ -37,12 +38,56 @@ function takeOnce<T>(ref: { current: T | null }): T | undefined {
ref.current = null; ref.current = null;
return v; 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 { import {
buildDagStageLeaderTask, buildDagStageLeaderTask,
pickDagSeatTask, pickDagSeatTask,
type OrchestrationPlan, type OrchestrationPlan,
} from "@/roundtable-planning/lib/dag-types"; } from "@/roundtable-planning/lib/dag-types";
import type { ChainNodeState } from "@/roundtable-planning/lib/job-types"; import type { ChainNodeState } from "@/roundtable-planning/lib/job-types";
import { seatModeToRequestFields, type SeatMode } from "@/roundtable-planning/lib/seat-mode";
import { import {
DEFAULT_SELECTED_AGENTS, DEFAULT_SELECTED_AGENTS,
MAX_CYCLES, MAX_CYCLES,
@@ -65,6 +110,12 @@ export type Step2Dialogue = {
streaming?: boolean; streaming?: boolean;
/** special 席位气泡对应的 LangGraph thread,用于沙箱预览。 */ /** special 席位气泡对应的 LangGraph thread,用于沙箱预览。 */
agentThreadId?: string; agentThreadId?: string;
/**
* 「多智能体问答」模式下,本席位用 `task` 工具派生的子智能体任务 id 列表(=task 工具
* 调用的 tool_call_id)。渲染时据此从共享 SubtaskContext 取每个 Subtask 渲染任务卡片
* (复用主聊天 `SubtaskCard`,见 Step2Panel.Step2Message)。非 ultra 模式恒为空。
*/
subtaskIds?: string[];
steps?: Array<{ steps?: Array<{
id: string; id: string;
key: string; key: string;
@@ -222,6 +273,12 @@ export interface UseStep2OrchestrationOptions {
currentStep: number; currentStep: number;
/** 全局选中模型(顶栏「推理模型」下拉)。 */ /** 全局选中模型(顶栏「推理模型」下拉)。 */
selectedModel: string; selectedModel: string;
/**
* 席位执行模式(快速/思考/专业问答/多智能体问答)。决定第二步**每个子智能体席位**
* 的推理深度(thinking/reasoning_effort/subagent);总控派活不受影响。由「选择参会
* 智能体」弹窗确认时的 `SeatSelectionResult.seatMode` 决定。缺省 `pro`。
*/
selectedMode: SeatMode;
/** /**
* 编排模式: * 编排模式:
* - `recommend`(默认)—— 总控自由派活循环(`runOrchestration`); * - `recommend`(默认)—— 总控自由派活循环(`runOrchestration`);
@@ -376,6 +433,7 @@ export function useStep2Orchestration(
const { const {
currentStep, currentStep,
selectedModel, selectedModel,
selectedMode,
orchestrationMode, orchestrationMode,
orchestrationPlan, orchestrationPlan,
dagExecMode = "fast", dagExecMode = "fast",
@@ -507,6 +565,8 @@ export function useStep2Orchestration(
// runOrchestration 的 useCallback deps 大幅收窄,避免每次模型切换、agentConfigs // runOrchestration 的 useCallback deps 大幅收窄,避免每次模型切换、agentConfigs
// 改写都重建一遍循环函数 —— 之前没收窄时 useEffect 会因 deps 变化重复 init。 // 改写都重建一遍循环函数 —— 之前没收窄时 useEffect 会因 deps 变化重复 init。
const selectedModelRef = useRef<string>(selectedModel); const selectedModelRef = useRef<string>(selectedModel);
// 席位执行模式同样打到 ref,让席位调用在编排循环里读到最新值而不重建回调。
const selectedModeRef = useRef<SeatMode>(selectedMode);
const agentConfigsRef = useRef<Record<number, AgentConfig>>(agentConfigs); const agentConfigsRef = useRef<Record<number, AgentConfig>>(agentConfigs);
const agentNameToRoleRef = useRef(agentNameToRole); const agentNameToRoleRef = useRef(agentNameToRole);
const threadIdsRef = useRef<ThreadIdMap | null>(threadIds); const threadIdsRef = useRef<ThreadIdMap | null>(threadIds);
@@ -543,6 +603,9 @@ export function useStep2Orchestration(
useEffect(() => { useEffect(() => {
selectedModelRef.current = selectedModel; selectedModelRef.current = selectedModel;
}, [selectedModel]); }, [selectedModel]);
useEffect(() => {
selectedModeRef.current = selectedMode;
}, [selectedMode]);
useEffect(() => { useEffect(() => {
agentConfigsRef.current = agentConfigs; agentConfigsRef.current = agentConfigs;
}, [agentConfigs]); }, [agentConfigs]);
@@ -945,6 +1008,137 @@ export function useStep2Orchestration(
[upsertSeatToolCall], [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 ──────────────────────────────────── // ─── Dialogue / Speaker helpers ────────────────────────────────────
const appendDialogue = useCallback( const appendDialogue = useCallback(
(card: { (card: {
@@ -1406,6 +1600,8 @@ export function useStep2Orchestration(
newMessage: fullTask, newMessage: fullTask,
peerDeliveries: priorDeliveries, peerDeliveries: priorDeliveries,
model: perSeatModel || selectedModelRef.current || undefined, model: perSeatModel || selectedModelRef.current || undefined,
// 席位执行模式(快速/思考/专业问答/多智能体问答)→ thinking/reasoning/subagent。
...seatModeToRequestFields(selectedModeRef.current),
signal: subAborter.signal, signal: subAborter.signal,
onTextDelta: (text) => onTextDelta: (text) =>
a.isParallel ? ingestParallelDelta(a.bubbleId, text) : appendToDialogue(a.bubbleId, 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); appendStepIfNew(a.bubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, a.seatThreadId); 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); activeAbortersRef.current.delete(subAborter);
if (isStale()) { if (isStale()) {
@@ -1690,6 +1889,7 @@ export function useStep2Orchestration(
triggerToast, triggerToast,
handleArtifactToolPhase, handleArtifactToolPhase,
handleUpstreamFrame, handleUpstreamFrame,
ingestSubagentTaskFrame,
resolveSeatThreadId, resolveSeatThreadId,
], ],
); );
@@ -1829,6 +2029,8 @@ export function useStep2Orchestration(
displayName: seatName, displayName: seatName,
newMessage: task, newMessage: task,
model: perSeatModel || selectedModelRef.current || undefined, model: perSeatModel || selectedModelRef.current || undefined,
// 席位执行模式 → thinking/reasoning/subagent(与推荐/DAG 路径一致)。
...seatModeToRequestFields(selectedModeRef.current),
signal: subAborter.signal, signal: subAborter.signal,
onTextDelta: (text) => appendToDialogue(subBubbleId, text), onTextDelta: (text) => appendToDialogue(subBubbleId, text),
onPhaseChange: (phase, toolName, args) => { onPhaseChange: (phase, toolName, args) => {
@@ -1837,7 +2039,10 @@ export function useStep2Orchestration(
appendStepIfNew(subBubbleId, label, `${phase}:${toolName ?? ""}`, args); appendStepIfNew(subBubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, seatThreadId); handleArtifactToolPhase(phase, toolName, args, seatThreadId);
}, },
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, seatThreadId), onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, seatThreadId);
ingestSubagentTaskFrame(frame, subBubbleId);
},
}); });
if (isStale()) { if (isStale()) {
finalizeDialogue(subBubbleId, specRes.content || "(已停止)", "已停止"); finalizeDialogue(subBubbleId, specRes.content || "(已停止)", "已停止");
@@ -1936,6 +2141,7 @@ export function useStep2Orchestration(
triggerToast, triggerToast,
handleArtifactToolPhase, handleArtifactToolPhase,
handleUpstreamFrame, handleUpstreamFrame,
ingestSubagentTaskFrame,
resolveSeatThreadId, resolveSeatThreadId,
], ],
); );
@@ -2163,6 +2369,8 @@ export function useStep2Orchestration(
// 前序各阶段的完整交付 → 供本席位 read_peer_delivery 按需取全文(§12.9)。 // 前序各阶段的完整交付 → 供本席位 read_peer_delivery 按需取全文(§12.9)。
peerDeliveries: priorDeliveries, peerDeliveries: priorDeliveries,
model: perSeatModel || selectedModelRef.current || undefined, model: perSeatModel || selectedModelRef.current || undefined,
// 席位执行模式 → thinking/reasoning/subagent(与推荐/链条路径一致)。
...seatModeToRequestFields(selectedModeRef.current),
signal: subAborter.signal, signal: subAborter.signal,
// §6:并行席位走 ref 全量累积 + 节流 flush(只渲染选中的);单节点 // §6:并行席位走 ref 全量累积 + 节流 flush(只渲染选中的);单节点
// stage 实际串行,1 路流直接 setState 不卡,沿用 appendToDialogue。 // stage 实际串行,1 路流直接 setState 不卡,沿用 appendToDialogue。
@@ -2174,7 +2382,10 @@ export function useStep2Orchestration(
appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args); appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, seatThreadId); handleArtifactToolPhase(phase, toolName, args, seatThreadId);
}, },
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, seatThreadId), onUpstreamFrame: (frame) => {
handleUpstreamFrame(frame, seatThreadId);
ingestSubagentTaskFrame(frame, bubbleId);
},
}); });
if (isStale()) { if (isStale()) {
finalizeDialogue(bubbleId, specRes.content || "(已停止)", "已停止"); finalizeDialogue(bubbleId, specRes.content || "(已停止)", "已停止");
@@ -2276,6 +2487,7 @@ export function useStep2Orchestration(
triggerToast, triggerToast,
handleArtifactToolPhase, handleArtifactToolPhase,
handleUpstreamFrame, handleUpstreamFrame,
ingestSubagentTaskFrame,
resolveSeatThreadId, resolveSeatThreadId,
ingestParallelDelta, ingestParallelDelta,
resetParallelStreams, resetParallelStreams,
@@ -10,6 +10,7 @@
*/ */
import { buildPlanFromStages } from "./dag-types"; import { buildPlanFromStages } from "./dag-types";
import type { SeatMode } from "./seat-mode";
/** 链条里的一个有序席位。`seats[0]` 是第一个发言者。 */ /** 链条里的一个有序席位。`seats[0]` 是第一个发言者。 */
export interface ChainSeat { export interface ChainSeat {
@@ -48,6 +49,11 @@ export interface BusinessChain {
* 空 / null = 不注入。 * 空 / null = 不注入。
*/ */
coordinatorPrompt?: string | null; coordinatorPrompt?: string | null;
/**
* 默认席位执行模式(快速/思考/专业问答/多智能体)。选入研讨时弹窗默认用它(仍可临时改)。
* 缺省 = 未设,调用方回退 `DEFAULT_SEAT_MODE`(pro)。
*/
seatMode?: SeatMode;
/** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */ /** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */
isPublic?: boolean; isPublic?: boolean;
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */ /** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
@@ -85,6 +91,12 @@ export interface SeatSelectionResult {
* 缺省 `fast`。 * 缺省 `fast`。
*/ */
dagExecMode?: "fast" | "sandbox"; dagExecMode?: "fast" | "sandbox";
/**
* 席位执行模式(快速/思考/专业问答/多智能体问答)——决定第二步**每个子智能体席位**
* 的推理深度(thinking/reasoning_effort/subagent)。三种编排(recommend/chain/dag)通用。
* 缺省由调用方按 `DEFAULT_SEAT_MODE`(pro)处理。
*/
seatMode?: SeatMode;
} }
/** 链条席位数硬约束,与圆桌推荐弹窗的 `MIN_SELECTED`/`MAX_SELECTED` 对齐。 */ /** 链条席位数硬约束,与圆桌推荐弹窗的 `MIN_SELECTED`/`MAX_SELECTED` 对齐。 */
@@ -132,12 +144,12 @@ export function buildChainSeatSelection(
const agents = plan.stages const agents = plan.stages
.flatMap((st) => st.agentIds) .flatMap((st) => st.agentIds)
.map((id) => ({ agent_id: id, name: nameOf(id) })); .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) })); 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 { OrchestrationPlan } from "./dag-types";
import type { SeatMode } from "./seat-mode";
/** 作业状态机(§2.1)。 */ /** 作业状态机(§2.1)。 */
export type JobStatus = export type JobStatus =
@@ -120,6 +121,12 @@ export interface StartJobPayload {
/** 预建好的 thread 映射(Phase 3b 真网关用;模拟网关忽略)。 */ /** 预建好的 thread 映射(Phase 3b 真网关用;模拟网关忽略)。 */
threadIds?: Record<string, string> | null; threadIds?: Record<string, string> | null;
coordinatorName?: string; coordinatorName?: string;
/**
* 席位执行模式(快速/思考/专业问答/多智能体)。后台续跑时席位按此模式执行(与前端实时
* 路径一致);缺省由后端按 policy 默认(≈pro)。在 `toStartBody` 里派生成 thinking/
* reasoning/subagent 三字段发给后端。
*/
seatMode?: SeatMode;
} }
/** 终态集合——SSE/轮询判定作业是否结束。 */ /** 终态集合——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 BusinessChain,
type ChainSeat, type ChainSeat,
} from "@/roundtable-planning/lib/business-chain"; } from "@/roundtable-planning/lib/business-chain";
import {
DEFAULT_SEAT_MODE,
SEAT_MODE_OPTIONS,
type SeatMode,
} from "@/roundtable-planning/lib/seat-mode";
import { import {
addEmptyStage, addEmptyStage,
addSeatAsNewStage, addSeatAsNewStage,
@@ -153,6 +158,8 @@ export default function BusinessChainEditorPage() {
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
// 写给总控的「编排提示」:告诉总控该怎么派活、做什么(仅分层 DAG 模式注入)。 // 写给总控的「编排提示」:告诉总控该怎么派活、做什么(仅分层 DAG 模式注入)。
const [coordinatorPrompt, setCoordinatorPrompt] = useState(""); const [coordinatorPrompt, setCoordinatorPrompt] = useState("");
// 默认席位执行模式(快速/思考/专业问答/多智能体)—— 选入研讨时弹窗默认用它。
const [seatMode, setSeatMode] = useState<SeatMode>(DEFAULT_SEAT_MODE);
// 分层编排是唯一数据源;落库时展平为 seats + 折叠为 stages。 // 分层编排是唯一数据源;落库时展平为 seats + 折叠为 stages。
const [stageGroups, setStageGroups] = useState<StageGroups>([]); const [stageGroups, setStageGroups] = useState<StageGroups>([]);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -277,6 +284,7 @@ export default function BusinessChainEditorPage() {
setTitle(chain.title); setTitle(chain.title);
setDescription(chain.description ?? ""); setDescription(chain.description ?? "");
setCoordinatorPrompt(chain.coordinatorPrompt ?? ""); setCoordinatorPrompt(chain.coordinatorPrompt ?? "");
setSeatMode(chain.seatMode ?? DEFAULT_SEAT_MODE);
setStageGroups(chainToStageGroups(chain)); setStageGroups(chainToStageGroups(chain));
setStep(1); setStep(1);
}, []); }, []);
@@ -288,6 +296,7 @@ export default function BusinessChainEditorPage() {
setTitle(""); setTitle("");
setDescription(""); setDescription("");
setCoordinatorPrompt(""); setCoordinatorPrompt("");
setSeatMode(DEFAULT_SEAT_MODE);
setStageGroups([]); setStageGroups([]);
setStep(1); setStep(1);
}, []); }, []);
@@ -560,6 +569,7 @@ export default function BusinessChainEditorPage() {
stages, stages,
stageGoals, stageGoals,
coordinatorPrompt: coordPrompt, coordinatorPrompt: coordPrompt,
seatMode,
}); });
if (!updated) { if (!updated) {
toast.error("链条已不存在,可能被删除"); toast.error("链条已不存在,可能被删除");
@@ -578,6 +588,7 @@ export default function BusinessChainEditorPage() {
stages, stages,
stageGoals, stageGoals,
coordinatorPrompt: coordPrompt, coordinatorPrompt: coordPrompt,
seatMode,
}); });
setCurrentChainId(created.id); setCurrentChainId(created.id);
toast.success("已创建业务链条"); toast.success("已创建业务链条");
@@ -592,6 +603,7 @@ export default function BusinessChainEditorPage() {
title, title,
description, description,
coordinatorPrompt, coordinatorPrompt,
seatMode,
stageGroups, stageGroups,
seatCountInvalid, seatCountInvalid,
currentChainId, currentChainId,
@@ -699,6 +711,37 @@ export default function BusinessChainEditorPage() {
/> />
</div> </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)+ 大号等宽编辑区 */} {/* 总控提示词:label + AI 优化(配色沿用现有 primary)+ 大号等宽编辑区 */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@@ -100,6 +100,7 @@ import {
buildChainSeatSelection, buildChainSeatSelection,
type SeatSelectionResult, type SeatSelectionResult,
} from "@/roundtable-planning/lib/business-chain"; } 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 { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
import type { IntentSummary } from "@/roundtable-planning/api/intent"; import type { IntentSummary } from "@/roundtable-planning/api/intent";
import { import {
@@ -108,6 +109,7 @@ import {
startJob, startJob,
startJobKeepalive, startJobKeepalive,
} from "@/roundtable-planning/api/roundtable-jobs"; } from "@/roundtable-planning/api/roundtable-jobs";
import { useRoundtableJobNotifier } from "@/roundtable-planning/components/RoundtableJobNotifier";
import { getChain } from "@/roundtable-planning/api/chains"; import { getChain } from "@/roundtable-planning/api/chains";
import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend"; import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend";
import { listAgents } from "@/core/agents"; import { listAgents } from "@/core/agents";
@@ -169,6 +171,8 @@ function readEmbedHashQueryPrefill(): RoundtablePrefill | null {
export default function RoundtablePlanningPage() { export default function RoundtablePlanningPage() {
const { theme } = useTheme(); const { theme } = useTheme();
// 全工作区跨页的后台作业完成通知器(Provider 挂在 PageLayout 常驻层)。
const { watchJob } = useRoundtableJobNotifier();
const isDark = theme === "dark"; const isDark = theme === "dark";
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -222,6 +226,9 @@ export default function RoundtablePlanningPage() {
// 「导入会商」弹窗(输入分享码把别人分享的会商复制到本账号)。 // 「导入会商」弹窗(输入分享码把别人分享的会商复制到本账号)。
const [isImportOpen, setIsImportOpen] = useState<boolean>(false); const [isImportOpen, setIsImportOpen] = useState<boolean>(false);
const [selectedModel, setSelectedModel] = useState<string>(""); const [selectedModel, setSelectedModel] = useState<string>("");
// Step 2 席位执行模式(快速/思考/专业问答/多智能体问答)——决定每个子智能体席位的
// 推理深度。由「选择参会智能体」弹窗确认时的 SeatSelectionResult.seatMode 决定。
const [seatMode, setSeatMode] = useState<SeatMode>(DEFAULT_SEAT_MODE);
// Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活; // Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活;
// dag = 分层 DAG(stage 内并行、stage 间串行,配合 orchestrationPlan)。 // dag = 分层 DAG(stage 内并行、stage 间串行,配合 orchestrationPlan)。
// 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。 // 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。
@@ -337,6 +344,7 @@ export default function RoundtablePlanningPage() {
const step2 = useStep2Orchestration({ const step2 = useStep2Orchestration({
currentStep, currentStep,
selectedModel, selectedModel,
selectedMode: seatMode,
orchestrationMode, orchestrationMode,
orchestrationPlan, orchestrationPlan,
dagExecMode, dagExecMode,
@@ -750,6 +758,8 @@ export default function RoundtablePlanningPage() {
// result.mode 决定 Step 2 编排:recommend = 总控自由派活;chain = 按链条顺序派活; // result.mode 决定 Step 2 编排:recommend = 总控自由派活;chain = 按链条顺序派活;
// dag = 分层并行(来源链条带 stages,携带 result.plan)。 // dag = 分层并行(来源链条带 stages,携带 result.plan)。
setOrchestrationMode(result.mode); setOrchestrationMode(result.mode);
// 席位执行模式:弹窗未带(旧调用)时回退默认 pro,保证行为可预期。
setSeatMode(result.seatMode ?? DEFAULT_SEAT_MODE);
setChainMeta(result.mode === "recommend" ? null : result.chain ?? null); setChainMeta(result.mode === "recommend" ? null : result.chain ?? null);
setOrchestrationPlan(result.mode === "dag" ? result.plan ?? null : null); setOrchestrationPlan(result.mode === "dag" ? result.plan ?? null : null);
setDagExecMode(result.mode === "dag" ? result.dagExecMode ?? "fast" : "fast"); setDagExecMode(result.mode === "dag" ? result.dagExecMode ?? "fast" : "fast");
@@ -1133,12 +1143,34 @@ export default function RoundtablePlanningPage() {
}); });
}, [resetToStep1]); }, [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 与本函数并发触发),绝不重复建作业。 // 兜底(覆盖跨标签页 / 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 })); 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); const draftId = await drafts.saveDraftNow(2);
// 防重复:该草稿已有在跑作业就不再新建(避免重复作业 / 前后端双跑)。 // 防重复:该草稿已有在跑作业就不再新建(避免重复作业 / 前后端双跑)。
@@ -1148,52 +1180,118 @@ export default function RoundtablePlanningPage() {
(existing.status === "running" || (existing.status === "running" ||
existing.status === "awaiting_input" || existing.status === "awaiting_input" ||
existing.status === "queued"); existing.status === "queued");
if (!alreadyRunning) { if (alreadyRunning) {
await startJob({ return existing?.id ?? null;
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)。
});
} }
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, step2,
drafts, drafts,
selectedModel, selectedModel,
seatMode,
orchestrationMode, orchestrationMode,
orchestrationPlan, orchestrationPlan,
chainMeta, chainMeta,
buildInitialIntent, buildInitialIntent,
]); ]);
// 「后台挂起」按钮:把当前研讨交给后端自动跑完 Step2 研讨 + Step3(总结报告 + 结果绘制)。 // 「后台挂起」按钮(2026-06-16 改「直接跳转」):点击后**立刻**跳回第一步并提示,挂起动作
// 挂起后页面像「新建任务」一样重置回第一步(后台作业继续在历史记录里跑)。 // (存稿 → 去重 → 建后台作业)改为 fire-and-forget 在后台完成,不再等接口返回才跳转。
const handleBackgroundSuspend = useCallback(async () => { // 历史记录在作业真正建好前显示「挂起中」(pendingSuspendIds),建好后由后端状态(queued→
if (step2.selectedAgents.length === 0) { // running)接管。⚠️ resetToStep1 会清空 step2 等 live 状态,故挂起入参 + step2 快照必须在
// 跳转前同步冻结(getStep2SnapshotWithRuns 返回的是不可变快照对象,reset 替换 state 不影响它)。
const handleBackgroundSuspend = useCallback(() => {
const selected = step2.selectedAgents;
if (selected.length === 0) {
triggerToast("⚠️ 请先选择研讨席位再后台挂起"); triggerToast("⚠️ 请先选择研讨席位再后台挂起");
return; return;
} }
try { // —— 跳转前同步冻结所有需要的输入(reset 之后这些 live 状态都会被清空)——
await startBackgroundJob(); const step2Snapshot = getStep2SnapshotWithRuns();
} catch (err) { const agents = selected.map((a) => ({ agentId: a.agent_id, name: a.name }));
triggerToast(`❌ 后台挂起失败:${(err as Error).message}`); const intent = buildInitialIntent();
return; 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(); resetToStep1();
triggerToast("🚀 已转入后台,任务会自动跑完研讨、总结报告与结果绘制;可在「历史记录」查看进度"); 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 都是「拍快照时刻」的同步回调, // 最新状态进 ref:卸载 cleanup 与 beforeunload 都是「拍快照时刻」的同步回调,
@@ -1223,6 +1321,7 @@ export default function RoundtablePlanningPage() {
intent: buildInitialIntent(), intent: buildInitialIntent(),
agents, agents,
model: selectedModel || undefined, model: selectedModel || undefined,
seatMode,
orchestrationMode, orchestrationMode,
chain: chainMeta, chain: chainMeta,
orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null, orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null,
@@ -1347,7 +1446,7 @@ export default function RoundtablePlanningPage() {
)} )}
{/* HEADER */} {/* 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 <StepNavigation
currentStep={currentStep} currentStep={currentStep}
intentReady={step1.intentReady} intentReady={step1.intentReady}
@@ -1408,6 +1507,7 @@ export default function RoundtablePlanningPage() {
setIsOpen={drafts.setIsHistoryOpen} setIsOpen={drafts.setIsHistoryOpen}
drafts={drafts.drafts} drafts={drafts.drafts}
currentDraftId={drafts.currentDraftId} currentDraftId={drafts.currentDraftId}
pendingSuspendIds={pendingSuspendIds}
renamingDraftId={drafts.renamingDraftId} renamingDraftId={drafts.renamingDraftId}
renameDraftValue={drafts.renameDraftValue} renameDraftValue={drafts.renameDraftValue}
setRenameDraftValue={drafts.setRenameDraftValue} setRenameDraftValue={drafts.setRenameDraftValue}
@@ -1733,6 +1833,8 @@ interface HistoryDropdownProps {
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
drafts: ReturnType<typeof useDraftPersistence>["drafts"]; drafts: ReturnType<typeof useDraftPersistence>["drafts"];
currentDraftId: string | null; currentDraftId: string | null;
/** 正在「挂起中」(已点后台挂起、作业尚未建好)的草稿 id 集合 —— 历史记录显示「挂起中」。 */
pendingSuspendIds: Set<string>;
renamingDraftId: string | null; renamingDraftId: string | null;
renameDraftValue: string; renameDraftValue: string;
setRenameDraftValue: React.Dispatch<React.SetStateAction<string>>; setRenameDraftValue: React.Dispatch<React.SetStateAction<string>>;
@@ -1755,6 +1857,7 @@ function HistoryDropdown(props: HistoryDropdownProps) {
setIsOpen, setIsOpen,
drafts, drafts,
currentDraftId, currentDraftId,
pendingSuspendIds,
renamingDraftId, renamingDraftId,
renameDraftValue, renameDraftValue,
setRenameDraftValue, setRenameDraftValue,
@@ -1825,6 +1928,8 @@ function HistoryDropdown(props: HistoryDropdownProps) {
{drafts.map((d) => { {drafts.map((d) => {
const isCurrent = d.id === currentDraftId; const isCurrent = d.id === currentDraftId;
const isRenaming = d.id === renamingDraftId; const isRenaming = d.id === renamingDraftId;
// 「挂起中」:已点后台挂起、作业还没建好(本地标记),或作业刚建好仍排队(queued)。
const isSuspending = pendingSuspendIds.has(d.id) || d.status === "queued";
// 「进行中 / 待澄清」且有 jobId → 点击打开流程图弹窗,而非加载草稿。 // 「进行中 / 待澄清」且有 jobId → 点击打开流程图弹窗,而非加载草稿。
const isActiveJob = const isActiveJob =
(d.status === "running" || d.status === "awaiting_input") && !!d.jobId; (d.status === "running" || d.status === "awaiting_input") && !!d.jobId;
@@ -1862,7 +1967,12 @@ function HistoryDropdown(props: HistoryDropdownProps) {
{d.name} {d.name}
</span> </span>
<span className="text-[10px] text-slate-400 mt-0.5 flex items-center gap-1 font-mono"> <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"> <span className="inline-flex items-center gap-1 text-primary font-semibold">
<Loader2 className="w-3 h-3 animate-spin" /> <Loader2 className="w-3 h-3 animate-spin" />
进行中 进行中
+13 -12
View File
@@ -8,7 +8,10 @@ import { NotificationDialog } from "@/components/workspace/notification-dialog";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { isWujieEnv } from "@/core/embed/wujie"; import { isWujieEnv } from "@/core/embed/wujie";
import { useUnreadCount } from "@/core/notifications"; 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 { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -145,7 +148,7 @@ const Header = ({ collapsed }: HeaderProps) => {
const showLightTopNav = !isDark && !isWujieEnv(); const showLightTopNav = !isDark && !isWujieEnv();
const hasActiveChild = (item: MenuItem): boolean => { const hasActiveChild = (item: MenuItem): boolean => {
if (item.path && pathname === item.path) { if (item.path && (pathname === item.path || pathname.startsWith(item.path + "/"))) {
return true; return true;
} }
@@ -170,9 +173,7 @@ const Header = ({ collapsed }: HeaderProps) => {
return null; return null;
}; };
const activeTopItem = visibleMenuItems.find(hasActiveChild); const thirdSubNavItems = getActiveThirdLevelItems(visibleMenuItems, pathname);
const activeSecondItem = activeTopItem?.children?.find(hasActiveChild);
const thirdSubNavItems = activeSecondItem?.children ?? [];
const leftMenuItems = visibleMenuItems.filter((i) => LEFT_MENU_IDS.includes(i.id)); const leftMenuItems = visibleMenuItems.filter((i) => LEFT_MENU_IDS.includes(i.id));
const rightMenuItems = visibleMenuItems.filter((i) => RIGHT_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 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 ( return (
<button <button
key={item.id} key={item.id}
@@ -199,7 +200,7 @@ const Header = ({ collapsed }: HeaderProps) => {
}; };
const renderLightTopItem = (item: MenuItem) => { 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 ( return (
<button <button
key={item.id} key={item.id}
@@ -294,7 +295,7 @@ const Header = ({ collapsed }: HeaderProps) => {
open={notificationOpen} open={notificationOpen}
onOpenChange={setNotificationOpen} 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 ? ( {showTopNav ? (
<> <>
{/* 左分区(flex-1):菜单靠右贴近中间基座 */} {/* 左分区(flex-1):菜单靠右贴近中间基座 */}
@@ -310,7 +311,7 @@ const Header = ({ collapsed }: HeaderProps) => {
</div> </div>
{/* 中:CM助手(落在背景图发光基座上,纯文字无图标) */} {/* 中:CM助手(落在背景图发光基座上,纯文字无图标) */}
<div className="header-center-title">CM助手</div> <div className="header-center-title">CM业务中心</div>
{/* 右分区(flex-1):菜单靠左贴近中间基座,操作区靠最右 */} {/* 右分区(flex-1):菜单靠左贴近中间基座,操作区靠最右 */}
<div className="flex h-full min-w-0 flex-1 items-center justify-between"> <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" /> <FileText className="h-4 w-4 text-white" />
</div> </div>
<span className="whitespace-nowrap text-base font-semibold text-white"> <span className="whitespace-nowrap text-base font-semibold text-white">
CM助手 CM业务中心
</span> </span>
</div> </div>
<nav className="light-topnav" aria-label="一级菜单"> <nav className="light-topnav" aria-label="一级菜单">
@@ -363,7 +364,7 @@ const Header = ({ collapsed }: HeaderProps) => {
</div> </div>
{!collapsed && ( {!collapsed && (
<span className="text-sm font-semibold text-white"> <span className="text-sm font-semibold text-white">
CM助手 CM业务中心
</span> </span>
)} )}
</div> </div>
@@ -379,7 +380,7 @@ const Header = ({ collapsed }: HeaderProps) => {
<nav className="third-submenu-bar" aria-label="三级菜单"> <nav className="third-submenu-bar" aria-label="三级菜单">
<div className="third-submenu-list"> <div className="third-submenu-list">
{thirdSubNavItems.map((item) => { {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 ( return (
<button <button
+4 -1
View File
@@ -950,7 +950,10 @@ html.dark .strategy-app-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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); transform: translateY(-6px);
font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese", font-family: "Source Han Sans SC", "Noto Sans SC", "Noto Sans S Chinese",
+1 -1
View File
@@ -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`. 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*`) ### 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) _app = app # 闭包捕获 app,供网关访问 app.state(checkpointer/thread_store)
gateway_factory = lambda p: InProcessRoundtableGateway( # noqa: E731 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)") logger.info("[roundtable-jobs] gateway = inprocess (real models, default)")
@@ -14,8 +14,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
import os
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from types import SimpleNamespace from types import SimpleNamespace
@@ -51,6 +53,33 @@ _SUMMARY_FILE = "方案总结报告.md"
# 这里漏禁了 web_search,导致后台席位 / 报告能联网搜索而前端不能,现由 policy 统一。 # 这里漏禁了 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( async def _run_agent_turn(
app, app,
*, *,
user_id: str,
thread_id: str, thread_id: str,
agent_name: str, agent_name: str,
message: str, message: str,
@@ -164,11 +194,16 @@ async def _run_agent_turn(
skill_stop_names: list[str], skill_stop_names: list[str],
thinking_enabled: bool, thinking_enabled: bool,
reasoning_effort: str, reasoning_effort: str,
subagent_enabled: bool = False,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""进程内驱动 agent_name 在 thread_id 上跑一轮(发 message),阻塞到完成,返回最终 messages。 """进程内驱动 agent_name 在 thread_id 上跑一轮(发 message),阻塞到完成,返回最终 messages。
镜像 multi_agent ``_run_payload`` 的 body 形状:agent 通过 ``context.agent_name`` 选, 镜像 multi_agent ``_run_payload`` 的 body 形状:agent 通过 ``context.agent_name`` 选,
assistant_id 固定 lead_agent;关闭 memory recall / subagent / plan mode(临时研讨任务)。 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.routers.thread_runs import RunCreateRequest
from app.gateway.services import start_run from app.gateway.services import start_run
@@ -184,7 +219,8 @@ async def _run_agent_turn(
"reasoning_effort": reasoning_effort, "reasoning_effort": reasoning_effort,
"thinking_enabled": thinking_enabled, "thinking_enabled": thinking_enabled,
"is_plan_mode": False, "is_plan_mode": False,
"subagent_enabled": False, # ultra 席位才置真 → 解锁 task 子代理工具(席位 policy 未禁 task)。leader 恒 False。
"subagent_enabled": subagent_enabled,
"thread_id": thread_id, "thread_id": thread_id,
"memory_recall_disabled": True, "memory_recall_disabled": True,
# 与 multi_agent._run_payload 对齐:关 builtin 记忆注入,避免单例总控/席位 # 与 multi_agent._run_payload 对齐:关 builtin 记忆注入,避免单例总控/席位
@@ -199,9 +235,12 @@ async def _run_agent_turn(
# 虚拟 request:带上空 ``state``(auth 中间件在真请求上会写 state.user / state.auth; # 虚拟 request:带上空 ``state``(auth 中间件在真请求上会写 state.user / state.auth;
# 进程内没有这层,下游凡是读 request.state.X 的代码必须能拿到 None 而不是 AttributeError)。 # 进程内没有这层,下游凡是读 request.state.X 的代码必须能拿到 None 而不是 AttributeError)。
request = SimpleNamespace(app=app, state=SimpleNamespace(user=None, auth=None)) request = SimpleNamespace(app=app, state=SimpleNamespace(user=None, auth=None))
record = await start_run(body, thread_id, request) # type: ignore[arg-type] # 取该用户的后台额度后再开跑:start_run + await task 是最重的部分(建 agent、调模型、
if getattr(record, "task", None) is not None: # 落 checkpoint)。额度满时本轮在此 await 排队,等同用户某个在跑轮次释放额度后再开。
await record.task # 阻塞到这轮 agent 跑完,checkpoint 已落盘 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) 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 截断 → 工具调用从未执行、 # it all at once」后发出超长 write_file,单次输出被 max_tokens 截断 → 工具调用从未执行、
# run 以 success 收尾但 outputs 下没有 .html → step3.html 回退成开场白文字。 # run 以 success 收尾但 outputs 下没有 .html → step3.html 回退成开场白文字。
# 自愈:检测产物缺失/截断后,同线程追加纠偏消息(要求 write_file 分段 + append 续写)重试。 # 自愈:检测产物缺失/截断后,同线程追加纠偏消息(要求 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 = ( _REPORT_RETRY_MISSING = (
f"检测到 /mnt/user-data/outputs/{_REPORT_FILE} 还没有生成——你上一轮的 write_file 没有执行成功" f"检测到 /mnt/user-data/outputs/{_REPORT_FILE} 还没有生成——你上一轮的 write_file 没有执行成功"
@@ -332,11 +377,26 @@ def _build_seat_intro(agents: list[SeatRef]) -> str:
class InProcessRoundtableGateway: class InProcessRoundtableGateway:
"""进程内真网关。executor 先调 ``init_threads`` 建好线程,再跑编排引擎。""" """进程内真网关。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._app = app
self._user_id = user_id self._user_id = user_id
self._agents = list(agents) self._agents = list(agents)
self._model = model 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._seat_intro = ""
self._first_leader = True self._first_leader = True
@@ -362,6 +422,7 @@ class InProcessRoundtableGateway:
policy = roundtable_run_policy("leader") policy = roundtable_run_policy("leader")
messages = await _run_agent_turn( messages = await _run_agent_turn(
self._app, self._app,
user_id=self._user_id,
thread_id=thread_ids[coordinator_name], thread_id=thread_ids[coordinator_name],
agent_name=coordinator_name, agent_name=coordinator_name,
message=msg, message=msg,
@@ -400,16 +461,27 @@ class InProcessRoundtableGateway:
async def run_seat(self, *, thread_ids, agent_id, task, model) -> SeatTurn: async def run_seat(self, *, thread_ids, agent_id, task, model) -> SeatTurn:
policy = roundtable_run_policy("seat") policy = roundtable_run_policy("seat")
# 席位模式覆盖 policy 的 thinking/reasoning(None → policy 默认),与前端 _special_run 一致。
messages = await _run_agent_turn( messages = await _run_agent_turn(
self._app, self._app,
user_id=self._user_id,
thread_id=thread_ids[agent_id], thread_id=thread_ids[agent_id],
agent_name=agent_id, agent_name=agent_id,
message=task, message=task,
model=model or self._model, model=model or self._model,
excluded_tools=policy.excluded_tools, excluded_tools=policy.excluded_tools,
skill_stop_names=policy.skill_stop_names, skill_stop_names=policy.skill_stop_names,
thinking_enabled=policy.thinking_enabled, thinking_enabled=(
reasoning_effort=policy.reasoning_effort, 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)) 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): for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
messages = await _run_agent_turn( messages = await _run_agent_turn(
self._app, self._app,
user_id=self._user_id,
thread_id=report_thread_id, thread_id=report_thread_id,
agent_name=REPORT_AGENT_ID, agent_name=REPORT_AGENT_ID,
message=message, message=message,
@@ -470,6 +543,7 @@ class InProcessRoundtableGateway:
for attempt in range(1 + _ARTIFACT_RETRY_LIMIT): for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
messages = await _run_agent_turn( messages = await _run_agent_turn(
self._app, self._app,
user_id=self._user_id,
thread_id=summary_thread_id, thread_id=summary_thread_id,
agent_name=SUMMARY_AGENT_ID, agent_name=SUMMARY_AGENT_ID,
message=message, message=message,
@@ -505,6 +579,7 @@ class InProcessRoundtableGateway:
for attempt in range(1 + _ARTIFACT_RETRY_LIMIT): for attempt in range(1 + _ARTIFACT_RETRY_LIMIT):
messages = await _run_agent_turn( messages = await _run_agent_turn(
self._app, self._app,
user_id=self._user_id,
thread_id=thread_id, thread_id=thread_id,
agent_name=REPORT_AGENT_ID, agent_name=REPORT_AGENT_ID,
message=message, message=message,
@@ -179,6 +179,12 @@ class StartParams:
# 是否自动产出 Step3(方案总结报告 + 结果绘制)。默认沿用全局开关(现已恢复 True, # 是否自动产出 Step3(方案总结报告 + 结果绘制)。默认沿用全局开关(现已恢复 True,
# 见 ROUNDTABLE_BACKGROUND_REPORT_ENABLED)。测试可显式覆盖。 # 见 ROUNDTABLE_BACKGROUND_REPORT_ENABLED)。测试可显式覆盖。
enable_report: bool = 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 实例。 # 网关工厂:给定一次启动参数,返回一个 RoundtableGateway 实例。
@@ -637,6 +637,8 @@ def _run_payload(
skill_stop_names: list[str], skill_stop_names: list[str],
thinking_enabled: bool = True, thinking_enabled: bool = True,
reasoning_effort: str = "medium", reasoning_effort: str = "medium",
subagent_enabled: bool = False,
stream_custom: bool = False,
multitask_strategy: str = "reject", multitask_strategy: str = "reject",
extra_context: dict[str, Any] | None = None, extra_context: dict[str, Any] | None = None,
files: list[dict[str, Any]] | None = None, files: list[dict[str, Any]] | None = None,
@@ -676,7 +678,9 @@ def _run_payload(
"reasoning_effort": reasoning_effort, "reasoning_effort": reasoning_effort,
"thinking_enabled": thinking_enabled, "thinking_enabled": thinking_enabled,
"is_plan_mode": False, "is_plan_mode": False,
"subagent_enabled": False, # 默认 False;圆桌「多智能体问答(ultra)」档会让席位 _special_run 传 True,
# 给席位解锁 task 子代理工具(席位 policy 未禁 task,故可用)。
"subagent_enabled": subagent_enabled,
"thread_id": thread_id, "thread_id": thread_id,
# 圆桌四个 router(intent / recommend / leader / special)都是临时任务, # 圆桌四个 router(intent / recommend / leader / special)都是临时任务,
# 用户个人记忆与本次研讨无关。MemoryMiddleware 的 Hindsight recall 是 # 用户个人记忆与本次研讨无关。MemoryMiddleware 的 Hindsight recall 是
@@ -691,7 +695,12 @@ def _run_payload(
# 圆桌是一次性研讨,本就不该吃历史会话记忆,这里统一关注入根治该污染。 # 圆桌是一次性研讨,本就不该吃历史会话记忆,这里统一关注入根治该污染。
"memory_injection_enabled": False, "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_subgraphs": True,
"stream_resumable": True, "stream_resumable": True,
"assistant_id": "lead_agent", "assistant_id": "lead_agent",
@@ -1570,6 +1579,9 @@ async def _special_run(
no_file: bool = False, no_file: bool = False,
peer_deliveries: list[dict[str, Any]] | None = None, peer_deliveries: list[dict[str, Any]] | None = None,
files: 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]]: ) -> AsyncIterator[str | dict[str, Any]]:
"""子智能体(special)单轮:执行总控下发的 task 文本,交付 content。 """子智能体(special)单轮:执行总控下发的 task 文本,交付 content。
@@ -1584,6 +1596,12 @@ async def _special_run(
``peer_deliveries``:前序席位的**完整交付** ``[{name, content}, ...]``。注入到 ``peer_deliveries``:前序席位的**完整交付** ``[{name, content}, ...]``。注入到
run context 的 ``roundtable_peer_deliveries``,供席位用 ``read_peer_delivery`` 工具 run context 的 ``roundtable_peer_deliveries``,供席位用 ``read_peer_delivery`` 工具
**按需**取全文(平时只看广播摘要,§12.9 传摘要按需传全文)。不进 prompt,不耗 token。 **按需**取全文(平时只看广播摘要,§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() t0 = time.perf_counter()
thread_id = agent_threads[agent_name] thread_id = agent_threads[agent_name]
@@ -1613,8 +1631,13 @@ async def _special_run(
new_message=new_message, new_message=new_message,
excluded_tools=seat_excluded, excluded_tools=seat_excluded,
skill_stop_names=merge_skill_stop_names(policy.skill_stop_names, skill_stop_names), skill_stop_names=merge_skill_stop_names(policy.skill_stop_names, skill_stop_names),
thinking_enabled=policy.thinking_enabled, # 席位执行模式覆盖 policy 的 thinking/reasoning(None → 回退 policy 默认);
reasoning_effort=policy.reasoning_effort, # 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。 # 干预/重新派活会把同一席位 thread 再跑一次;抢占掉没结束的旧 run,避免 409。
multitask_strategy="rollback", multitask_strategy="rollback",
# 前序席位完整交付 → run context,供 read_peer_delivery 工具按需取全文(§12.9)。 # 前序席位完整交付 → 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() 回退到 # 不写死任何默认模型;空串会让 lead_agent._resolve_model_name() 回退到
# config.yaml 的 models[0],与 /api/ai-writing 等其它路由一致。 # config.yaml 的 models[0],与 /api/ai-writing 等其它路由一致。
model_name: str = str(payload.get("model") or "") 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: if not agent_name or agent_name not in agent_threads:
raise HTTPException( raise HTTPException(
@@ -1803,6 +1835,9 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) ->
no_file=no_file, no_file=no_file,
peer_deliveries=peer_deliveries, peer_deliveries=peer_deliveries,
files=files, files=files,
thinking_enabled=seat_thinking_enabled,
reasoning_effort=seat_reasoning_effort,
subagent_enabled=seat_subagent_enabled,
) )
async for chunk in gen: async for chunk in gen:
if isinstance(chunk, dict): if isinstance(chunk, dict):
@@ -57,6 +57,8 @@ class ChainResponse(BaseModel):
stage_goals: list[str | None] | None = None stage_goals: list[str | None] | None = None
# 写给总控的「编排提示」(dag-only):告诉总控怎么派活、做什么。None/空 = 不注入。 # 写给总控的「编排提示」(dag-only):告诉总控怎么派活、做什么。None/空 = 不注入。
coordinator_prompt: str | None = None coordinator_prompt: str | None = None
# 默认席位执行模式(flash/thinking/pro/ultra);None = 未设,选入研讨时回退默认(pro)。
seat_mode: str | None = None
# 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。 # 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。
is_public: bool = False is_public: bool = False
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。 # 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
@@ -83,6 +85,7 @@ class ChainCreateRequest(BaseModel):
stages: list[list[str]] | None = None stages: list[list[str]] | None = None
stage_goals: list[str | None] | None = None stage_goals: list[str | None] | None = None
coordinator_prompt: str | None = Field(default=None, max_length=4000) coordinator_prompt: str | None = Field(default=None, max_length=4000)
seat_mode: str | None = Field(default=None, max_length=16)
class ChainUpdateRequest(BaseModel): class ChainUpdateRequest(BaseModel):
@@ -93,6 +96,7 @@ class ChainUpdateRequest(BaseModel):
stages: list[list[str]] | None = None stages: list[list[str]] | None = None
stage_goals: list[str | None] | None = None stage_goals: list[str | None] | None = None
coordinator_prompt: str | None = Field(default=None, max_length=4000) 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 限定)。 # 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
is_public: bool | None = None 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 ─────────────────────────────────────────────────────────────── # ── routes ───────────────────────────────────────────────────────────────
@@ -249,6 +265,7 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo
_validate_seats(body.seats) _validate_seats(body.seats)
_validate_stages(body.seats, body.stages) _validate_stages(body.seats, body.stages)
_validate_stage_goals(body.stages, body.stage_goals) _validate_stage_goals(body.stages, body.stage_goals)
_validate_seat_mode(body.seat_mode)
store = _get_store(request) store = _get_store(request)
user_id = _current_user_id(request) user_id = _current_user_id(request)
row = await store.create_chain( row = await store.create_chain(
@@ -261,6 +278,7 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo
"stages": body.stages, "stages": body.stages,
"stage_goals": body.stage_goals, "stage_goals": body.stage_goals,
"coordinator_prompt": body.coordinator_prompt, "coordinator_prompt": body.coordinator_prompt,
"seat_mode": body.seat_mode,
}, },
) )
await _enrich_owner_names([row]) 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) _validate_stages(body.seats, body.stages)
# Goals align to stages; reject goals sent without a layering, enforce length/size. # Goals align to stages; reject goals sent without a layering, enforce length/size.
_validate_stage_goals(body.stages, body.stage_goals) _validate_stage_goals(body.stages, body.stage_goals)
_validate_seat_mode(body.seat_mode)
store = _get_store(request) store = _get_store(request)
user_id = _current_user_id(request) user_id = _current_user_id(request)
# Distinguish "omitted" from "explicitly sent"; only forward provided fields. # Distinguish "omitted" from "explicitly sent"; only forward provided fields.
@@ -68,6 +68,12 @@ class StartJobRequest(BaseModel):
# 预建好的 thread 映射(Phase 3b 真网关用;模拟网关忽略)。 # 预建好的 thread 映射(Phase 3b 真网关用;模拟网关忽略)。
threadIds: dict[str, str] | None = None threadIds: dict[str, str] | None = None
coordinatorName: str = "roundtable-coordinator" 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): class ResumeJobRequest(BaseModel):
@@ -219,6 +225,9 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes
draft_id=body.draftId, draft_id=body.draftId,
chain=body.chain, chain=body.chain,
orchestration_plan=body.orchestrationPlan, 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) return _to_response(row)
+12 -12
View File
@@ -10,18 +10,18 @@ gateway:
port: 8001 port: 8001
models: models:
- name: deepseek-chat # - name: deepseek-chat
display_name: DeepSeek Chat # display_name: DeepSeek Chat
use: langchain_openai:ChatOpenAI # use: langchain_openai:ChatOpenAI
model: deepseek-chat # model: deepseek-chat
api_key: sk-3fee562f834a4c08bb0d471ef44cb445 # api_key: sk-e1c22b7931dc446f8b67e57878edd105
base_url: https://api.deepseek.com # base_url: https://api.deepseek.com
request_timeout: 600.0 # request_timeout: 600.0
max_retries: 2 # max_retries: 2
max_tokens: 4096 # max_tokens: 4096
temperature: 1.0 # temperature: 1.0
supports_vision: false # supports_vision: false
supports_thinking: true # supports_thinking: true
# - name: zai-org/GLM-5-FP8 # - name: zai-org/GLM-5-FP8
# display_name: zai-org/GLM-5-FP8 # display_name: zai-org/GLM-5-FP8
@@ -141,6 +141,30 @@ def _load_configured_skills() -> list:
return resolved or [_fallback_knowledge_skill()] 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: def _resolve_keyword_count(raw: object) -> int:
"""把用户传入的检索关键词数量规整到 [1, _MAX_KEYWORD_COUNT],非法值回退默认 6。""" """把用户传入的检索关键词数量规整到 [1, _MAX_KEYWORD_COUNT],非法值回退默认 6。"""
try: try:
@@ -511,24 +535,35 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
added += 1 added += 1
return added 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 秒, # 加超时兜底:单个技能检索最多等 _SKILL_SEARCH_TIMEOUT_SECONDS 秒,
# 超时不再阻塞后续技能,避免整段过程看起来"冻结"。 # 超时不再阻塞后续技能,避免整段过程看起来"冻结"。
# 返回 (结果, 错误详情):错误详情非空 = 该技能调用失败(触发兜底)。
try: try:
return await asyncio.wait_for( results = await asyncio.wait_for(
provider.search(query, max_results=limit), provider.search(query, max_results=limit),
timeout=_SKILL_SEARCH_TIMEOUT_SECONDS, timeout=_SKILL_SEARCH_TIMEOUT_SECONDS,
) or [] )
return (results or []), ""
except asyncio.TimeoutError: 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) 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)") search_errors.append(f"skill[{skill_name}]: {msg}")
return [] return [], msg
except Exception as e: except Exception as e:
logger.warning("skill[%s] search failed: %s", skill_name, e) logger.warning("skill[%s] search failed: %s", skill_name, e)
search_errors.append(f"skill[{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 is_knowledge = sk.name == KNOWLEDGE_SEARCH_SKILL_NAME
if is_knowledge and not is_user_query_search: 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) gathered = await asyncio.gather(*[_search_one(kw) for kw in search_keywords], return_exceptions=True)
added = 0 added = 0
errs: list[str] = []
for results in gathered: for results in gathered:
if isinstance(results, Exception): if isinstance(results, Exception):
logger.warning("search failed: %s", results) logger.warning("search failed: %s", results)
search_errors.append(f"{type(results).__name__}: {results}") errs.append(f"{type(results).__name__}: {results}")
continue continue
added += _collect(results) added += _collect(results)
search_errors.extend(getattr(provider, "errors", [])) 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: else:
# 定向技能检索:user_query 模式优先用用户原话;非知识库技能再 # 定向技能检索:user_query 模式优先用用户原话;非知识库技能再
# 附上 description 让 query 贴合该技能的领域。 # 附上 description 让 query 贴合该技能的领域。
@@ -576,14 +618,35 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
"message": f"使用技能「{sk.name}」检索:{skill_query[:60]}", "message": f"使用技能「{sk.name}」检索:{skill_query[:60]}",
"skill_name": sk.name, "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", failed_skill_names: list[str] = []
"message": f"技能「{_KNOWLEDGE_SKILL_DISPLAY if is_knowledge else sk.name}」检索到 {added} 条新素材", tried_names: set[str] = set()
"skill_name": sk.name,
"matched_count": added, 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: if len(new_results) >= max_materials:
@@ -594,6 +657,27 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
}) })
break 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(无输入,用户只想重出):旧素材丢弃,覆盖式替换 # - 首次检索 / 普通 re_search(无输入,用户只想重出):旧素材丢弃,覆盖式替换
# - 素材不足求助 supplement_search:保留旧素材 + 追加新检索结果 # - 素材不足求助 supplement_search:保留旧素材 + 追加新检索结果
@@ -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")
@@ -49,6 +49,9 @@ class RoundtableChainRow(Base):
# 怎么派活、做什么(整体编排策略 / 风格 / 约束)。**仅分层 DAG 模式生效**:注入到总控 # 怎么派活、做什么(整体编排策略 / 风格 / 约束)。**仅分层 DAG 模式生效**:注入到总控
# 每阶段派活提示(``buildDagStageLeaderTask``)。NULL = 不注入。纯文本,存为 LONGTEXT。 # 每阶段派活提示(``buildDagStageLeaderTask``)。NULL = 不注入。纯文本,存为 LONGTEXT。
coordinator_prompt: Mapped[str | None] = mapped_column(PortableLongText, nullable=True) 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 = 仅本人可见。 # 发布开关:True = 公共(所有账号可在「公共」筛选里查看 / 选用);False = 仅本人可见。
# 编辑 / 删除始终只限 owner;发布只是放开「查看 + 选入研讨」。索引以支持「公共」列表筛选。 # 编辑 / 删除始终只限 owner;发布只是放开「查看 + 选入研讨」。索引以支持「公共」列表筛选。
is_public: Mapped[bool] = mapped_column( is_public: Mapped[bool] = mapped_column(
@@ -56,6 +56,8 @@ class RoundtableChainRepository:
"stage_goals": _loads(getattr(row, "stage_goals", None)), "stage_goals": _loads(getattr(row, "stage_goals", None)),
# 总控编排提示(写给总控的派活指引,dag-only);None/空 = 不注入。纯文本,不 json。 # 总控编排提示(写给总控的派活指引,dag-only);None/空 = 不注入。纯文本,不 json。
"coordinator_prompt": getattr(row, "coordinator_prompt", None), "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_public": bool(getattr(row, "is_public", False)),
"is_mine": (current_user_id is None) or (row.user_id == current_user_id), "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")), stages=_dumps(data.get("stages")),
stage_goals=_dumps(data.get("stage_goals")), stage_goals=_dumps(data.get("stage_goals")),
coordinator_prompt=(data.get("coordinator_prompt") or None), 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), is_public=bool(data.get("is_public") or False),
created_at=now, created_at=now,
updated_at=now, updated_at=now,
@@ -131,6 +134,7 @@ class RoundtableChainRepository:
stages=_UNSET, stages=_UNSET,
stage_goals=_UNSET, stage_goals=_UNSET,
coordinator_prompt=_UNSET, coordinator_prompt=_UNSET,
seat_mode=_UNSET,
is_public=_UNSET, is_public=_UNSET,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
async with self._sf() as session: async with self._sf() as session:
@@ -149,6 +153,8 @@ class RoundtableChainRepository:
row.stage_goals = _dumps(stage_goals) row.stage_goals = _dumps(stage_goals)
if coordinator_prompt is not _UNSET: if coordinator_prompt is not _UNSET:
row.coordinator_prompt = coordinator_prompt or None 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: if is_public is not _UNSET and is_public is not None:
row.is_public = bool(is_public) row.is_public = bool(is_public)
row.updated_at = datetime.now(UTC) 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。 -- ORM: RoundtableJobRow.orchestration_plan = PortableLongText(LONGTEXT), nullable。
CALL _add_column_if_missing('roundtable_jobs', 'orchestration_plan', 'LONGTEXT NULL DEFAULT NULL'); 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; DROP PROCEDURE IF EXISTS _add_column_if_missing;