Merge remote-tracking branch 'origin/master' into ji

# Conflicts:
#	frontend-web/src/core/page-layout/sidebar-menu.ts
#	frontend-web/src/core/system-settings/api.ts
#	frontend-web/src/core/system-settings/hooks.ts
#	frontend-web/src/strategy-components/Header.tsx
#	offline-backend-20260512/backend/CLAUDE.md
#	offline-backend-20260512/backend/app/gateway/routers/system_settings.py
#	offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py
#	offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql
This commit is contained in:
Administrator committed 2026-06-16 20:01:42 +08:00
commit b380575f16
103 files changed
+5915 -859

No files matched your search

+1
View File
@@ -84,6 +84,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA:
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE - **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
- **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/` - **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/`
- **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js) - **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js)
- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalysSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A (resolved via `/api/business-mapping`, opening message in `sessionStorage.pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management/<business-path>/{roundtable,agent/:agent_id/chats/:thread_id}` in `StrategyRoutes.tsx` (`<business-path>` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. The agent chat is rendered via `TaskEmbeddedAgentChat`, which passes `AgentChatPage`'s `chatBasePath` prop so the post-send URL rewrite + new-chat button stay in the task-management context (instead of the standalone `/agents/...` route). Any failure (no consumer URL / fetch error / unmapped goPath / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`.
### Configuration ### Configuration
+1 -1
View File
@@ -27,7 +27,7 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://localhost:8003 VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
# 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL) # 工作室/空间后端接口(侧边栏“工作室”列表、空间详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/空间前端地址(用于 auth/callback 等跳转) # 工作室/空间前端地址(用于 auth/callback 等跳转)
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn # VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
-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` 与默认落地页可能需要调整,需同步确认。
+11 -1
View File
@@ -86,12 +86,22 @@ function AppProviders({ children }: { children: React.ReactNode }) {
); );
} }
/**
* 根路由 `/` → `/login` 重定向,**保留 query string**。
* 外部系统的任务深链落在 `#/?taskId=...&goPath=...`,裸 `<Navigate to="/login">` 会丢掉
* query,导致 LoginPage 读不到参数;这里把 search 一并带过去。
*/
function RootRedirect() {
const { search } = useLocation();
return <Navigate to={`/login${search}`} replace />;
}
export function App() { export function App() {
return ( return (
<HashRouter> <HashRouter>
<AppProviders> <AppProviders>
<Routes> <Routes>
<Route path="/" element={<Navigate to="/login" replace />} /> <Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/login/:username" element={<LoginPage />} /> <Route path="/login/:username" element={<LoginPage />} />
<Route path="/landing" element={<LandingPage />} /> <Route path="/landing" element={<LandingPage />} />
@@ -27,6 +27,7 @@ import { Button } from "@/components/ui/button";
import { toast } from "sonner"; import { toast } from "sonner";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { queryClient } from "@/components/query-client-provider"; import { queryClient } from "@/components/query-client-provider";
import { Checkbox } from "@/components/ui/checkbox";
import Empty from "./empty"; import Empty from "./empty";
import { NetworkActor } from "./network-actor"; import { NetworkActor } from "./network-actor";
@@ -693,169 +694,402 @@ const DocumentUploadPopupContent: FC<{
{/* A系统基因库 */} {/* A系统基因库 */}
{activeTab === "A" && ( {activeTab === "A" && (
<div className="bg-white rounded-lg"> <div className="bg-white rounded-lg">
<div className="custom-radio-group pt-[10px] ml-[10px]"> {false && (
{searchTypes.map((option) => ( <div className="relative pt-[10px]">
<div <div className="relative rounded-lg border border-neutral-200 bg-white dark-theme-input ml-[10px] mr-[10px] mb-[10px] transition-colors hover:border-neutral-300">
key={option.value} <Textarea
className={`custom-radio-btn ${searchType === option.value ? "active" : ""}`} placeholder="搜索资料..."
onClick={() => searchTypeChange(option.value)} value={searchText}
> onChange={(e) => setSearchText(e.target.value)}
{option.label} onKeyDown={async (e) => {
</div> if (e.key === "Enter" && !e.shiftKey) {
))} e.preventDefault();
</div> await searchAChange(searchType);
}
}}
className=" max-h-[160px] resize-none border-0 dark-theme-input bg-transparent px-10 py-3 pr-20 text-sm text-neutral-700 placeholder:text-neutral-400 focus-visible:ring-0 focus-visible:outline-none"
rows={1}
/>
{/* 左侧搜索图标 */}
<div className="absolute left-3 top-3 text-neutral-400 pointer-events-none dark-theme-search">
<Search className="h-4 w-4" strokeWidth={1.5} />
</div>
{/* 右侧搜索按钮 */}
<Button
size="sm"
onClick={async () =>
await searchAChange(searchType)
}
disabled={isSearching || !searchText.trim()}
className="absolute right-2 top-2 h-7 rounded-md bg-neutral-900 px-3 text-xs font-medium text-white dark-theme-search transition-colors hover:bg-neutral-800 disabled:pointer-events-none disabled:opacity-40"
>
{isSearching ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<span>搜索</span>
)}
</Button>
{/* 左下角模式选择 */}
<div className="absolute left-2 bottom-1.5 flex items-center">
{/* <Select value={searchType} onValueChange={(value) => searchTypeChange(value)}>
<SelectTrigger className="h-6 w-auto min-w-[80px] gap-1.5 rounded border border-neutral-200 bg-white px-2 py-0 pr-1.5 text-[11px] font-medium text-neutral-600 hover:bg-neutral-50 data-[state=open]:bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent align="start" className="min-w-[120px] rounded-md border border-neutral-200 bg-white p-0.5 shadow-sm">
{searchTypes.map((mode) => {
return (
<SelectItem
key={mode.value}
value={mode.value}
className="rounded px-2 py-1.5 text-[11px] font-medium data-[highlighted]:bg-neutral-100"
>
<div className="flex items-center gap-1.5">
<span className="text-neutral-700">{mode.label}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select> */}
</div>
</div>
</div>
)}
<div className="custom-radio-group pt-[10px] ml-[10px]">
{" "}
{/* 外层容器,模拟 nz-radio-group */}
{searchTypes.map((option) => {
const isActive = searchType === option.value;
return (
<div
key={option.value}
className={`custom-radio-btn ${isActive ? "active" : ""
}`}
onClick={() => searchTypeChange(option.value)}
>
{option.label}
</div>
);
})}
</div>
{/* 批量操作栏 - 当有选中项时显示 */}
{selectedSearchSources.length > 0 && ( {selectedSearchSources.length > 0 && (
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100"> <div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100">
<span className="text-sm text-gray-600">已选中 {selectedSearchSources.length} 项</span> <span className="text-sm text-gray-600">
已选中 {selectedSearchSources.length} 项
</span>
<div className="flex gap-2"> <div className="flex gap-2">
<Button size="sm" variant="outline" onClick={handleBatchDelete} className="h-8 px-3 text-sm"> <Button
size="sm"
variant="outline"
onClick={handleBatchDelete}
className="h-8 px-3 text-sm"
>
<Trash2 className="h-3.5 w-3.5 mr-1" /> <Trash2 className="h-3.5 w-3.5 mr-1" />
删除选中 删除选中
</Button> </Button>
<Button <Button
size="sm" size="sm"
onClick={() => void handleImport()} onClick={() => handleImport()}
disabled={isImporting} disabled={isImporting}
className="h-8 px-3 text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 border border-blue-200" className="h-8 px-3 text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 border border-blue-200"
> >
{isImporting ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Plus className="h-3.5 w-3.5 mr-1" />} {isImporting ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Plus className="h-3.5 w-3.5 mr-1" />
)}
导入选中 导入选中
</Button> </Button>
</div> </div>
</div> </div>
)} )}
{/* 如果没有选中项,显示普通导入按钮 */}
{isImporting && (
<div className="px-4 py-2 border-b border-gray-100">
<Button
onClick={() => handleImport()}
disabled={isImporting}
className="w-full h-8 text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 border border-blue-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isImporting ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Plus className="h-4 w-4 mr-1" />
)}
{isImporting ? "导入中..." : "导入全部"}
</Button>
</div>
)}
{listData.length === 0 && <Empty />} {listData.length === 0 && <Empty />}
{searchType === "human" && ( {searchType === "human" && (
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]"> <div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
{listData.map((item, index) => ( {listData.map((item: listDataItem, index: number) => {
<div key={index} className="relative flex items-center shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"> return (
{/* 上传文件显示 loading 状态 */} <div
{item.is_uploaded_file && item.processing_status === 'processing' && ( key={index}
<div className="absolute inset-0 bg-white/80 flex items-center justify-center z-10"> className="relative flex items-center shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"
<Loader2 className="h-6 w-6 animate-spin text-primary" /> >
<div className="w-[50px] mr-[10px]">
<img
src={item.imagePath || ""}
className="w-[50px] h-[50px]"
alt=""
/>
</div> </div>
)} <div className="flex-1 min-w-0">
{/* 上传文件完成状态标记 */} <p
{item.is_uploaded_file && item.processing_status === 'error' && ( className="truncate hover:underline cursor-pointer"
<div className="absolute top-1 left-1"> onClick={() => linkToA(item.url)}
<span className="text-xs text-red-500">处理失败</span> >
{item.name}
</p>
<p className="truncate text-[#909090]">
职业:{item.occupationData}
</p>
<p className="truncate text-[#909090]">
属地:{item.country}
</p>
</div> </div>
)} <div className="absolute top-[5px] right-[10px]">
{item.is_uploaded_file && item.processing_status === 'success' && ( <Checkbox
<div className="absolute top-1 left-1"> checked={item.checked}
<span className="text-xs text-green-500">✓ 已完成</span> onCheckedChange={() =>
listDataCheckedChange(index, !item.checked)
}
/>
</div> </div>
)}
<div className="w-[50px] mr-[10px]">
<img src={item.imagePath || ""} className="w-[50px] h-[50px]" alt="" />
</div> </div>
<div className="flex-1 min-w-0"> );
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.name}</p> })}
<p className="truncate text-[#909090]">职业:{item.occupationData}</p>
<p className="truncate text-[#909090]">属地:{item.country}</p>
</div>
<div className="absolute top-[5px] right-[10px]">
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
</div>
</div>
))}
</div> </div>
)} )}
{searchType === "organization" && ( {searchType === "organization" && (
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]"> <div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
{listData.map((item, index) => ( {listData.map((item: listDataItem, index: number) => {
<div key={index} className="relative flex items-center shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"> return (
<div className="w-[50px] mr-[10px]"> <div
<img src={item.imagePath || ""} className="w-[50px] h-[50px]" alt="" /> key={index}
className="relative flex items-center shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"
>
<div className="w-[50px] mr-[10px]">
<img
src={item.imagePath || ""}
className="w-[50px] h-[50px]"
alt=""
/>
</div>
<div className="flex-1 min-w-0">
<p
className="truncate hover:underline cursor-pointer"
onClick={() => linkToA(item.url)}
>
{item.name}
</p>
<p className="truncate text-[#909090]">
组织类别:{item.subtype}
</p>
<p className="truncate text-[#909090]">
简介:{item.text}
</p>
</div>
<div className="absolute top-[5px] right-[10px]">
<Checkbox
checked={item.checked}
onCheckedChange={() =>
listDataCheckedChange(index, !item.checked)
}
/>
</div>
</div> </div>
<div className="flex-1 min-w-0"> );
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.name}</p> })}
<p className="truncate text-[#909090]">组织类别:{item.subtype}</p>
<p className="truncate text-[#909090]">简介:{item.text}</p>
</div>
<div className="absolute top-[5px] right-[10px]">
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
</div>
</div>
))}
</div> </div>
)} )}
{searchType === "community" && ( {searchType === "community" && (
<div className="bg-white p-[10px] grid grid-cols-4 gap-[10px]"> <div className="bg-white p-[10px] grid grid-cols-4 gap-[10px]">
{listData.map((item, index) => ( {listData.map((item: listDataItem, index: number) => {
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"> return (
<div> <div
<div className="truncate mb-[5px] hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.title}</div> key={index}
<div className="flex items-center text-[#909090] mb-[5px]"> className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"
{item.nations && <span className="truncate max-w-[200px] inline-block border border-[#cf1322] rounded px-1 text-[#cf1322]">{item.nations}</span>} >
{item.labels && <span className="inline-block border border-[#bbb] rounded px-1 ml-[10px]">{item.labels}</span>} <div>
<div
className="truncate mb-[5px] hover:underline cursor-pointer"
onClick={() => linkToA(item.url)}
>
{item.title}
</div>
<div className="flex items-center text-[#909090] mb-[5px]">
{item.nations && (
<span className="truncate max-width-[200px] inline-block border border-[#cf1322] rounded px-1 text-[#cf1322]">
{item.nations}
</span>
)}
{item.labels && (
<span className="inline-block border border-[#bbb] rounded px-1 ml-[10px]">
{item.labels}
</span>
)}
</div>
<div className="flex justify-between mb-[5px]">
<span className="text-[#909090]">
群体规模:
<span className="text-[#cf1322]">
{item.total}
</span>
</span>
<span className="text-[#909090]">
对应实体数:
<span className="text-[#cf1322]">
{item.real_total}
</span>
</span>
</div>
<div>
<span className="text-[#909090]">
涉及平台:
<span className="text-[#222222]">
{item.platforms}
</span>
</span>
</div>
</div> </div>
<div className="flex justify-between mb-[5px]"> <div className="absolute top-[5px] right-[10px]">
<span className="text-[#909090]">群体规模:<span className="text-[#cf1322]">{item.total}</span></span> <Checkbox
<span className="text-[#909090]">对应实体数:<span className="text-[#cf1322]">{item.real_total}</span></span> checked={item.checked}
onCheckedChange={() =>
listDataCheckedChange(index, !item.checked)
}
/>
</div> </div>
<div><span className="text-[#909090]">涉及平台:<span className="text-[#222222]">{item.platforms}</span></span></div>
</div> </div>
<div className="absolute top-[5px] right-[10px]"> );
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" /> })}
</div>
</div>
))}
</div> </div>
)} )}
{searchType === "issue" && ( {searchType === "issue" && (
<div className="bg-white p-[10px] grid grid-cols-4 gap-[10px]"> <div className="bg-white p-[10px] grid grid-cols-4 gap-[10px]">
{listData.map((item, index) => ( {listData.map((item: listDataItem, index: number) => {
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"> return (
<div> <div
<div className="truncate mb-[5px]">{item.title}</div> key={index}
<div className="flex justify-between mb-[5px]"> className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"
<span className="text-[#909090]">方向:<span className="text-[#222222]">{item.direction}</span></span> >
<span className="text-[#909090]">类别:<span className="text-[#222222]">{item.issue_type}</span></span> <div>
<div className="truncate mb-[5px]">
{item.title}
</div>
<div className="flex justify-between mb-[5px]">
<span className="text-[#909090]">
方向:
<span className="text-[#222222]">
{item.direction}
</span>
</span>
<span className="text-[#909090]">
类别:
<span className="text-[#222222]">
{item.issue_type}
</span>
</span>
</div>
</div>
<div className="absolute top-[5px] right-[10px]">
<Checkbox
checked={item.checked}
onCheckedChange={() =>
listDataCheckedChange(index, !item.checked)
}
/>
</div> </div>
</div> </div>
<div className="absolute top-[5px] right-[10px]"> );
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" /> })}
</div>
</div>
))}
</div> </div>
)} )}
{searchType === "event" && ( {searchType === "event" && (
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]"> <div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
{listData.map((item, index) => ( {listData.map((item: listDataItem, index: number) => {
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"> return (
<div className="flex-1 min-w-0"> <div
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.title}</p> key={index}
<p className="truncate text-[#909090]">首发新闻:{item.site_name}</p> className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"
<p className="truncate text-[#909090]">事件时间:{item.first_publish_time}</p> >
<p className="truncate text-[#909090]">关键词:{item.keywords?.join("、")}</p> <div className="flex-1 min-w-0">
<p
className="truncate hover:underline cursor-pointer"
onClick={() => linkToA(item.url)}
>
{item.title}
</p>
<p className="truncate text-[#909090]">
首发新闻:{item.site_name}
</p>
<p className="truncate text-[#909090]">
事件时间:{item.first_publish_time}
</p>
<p className="truncate text-[#909090]">
事件时间:{item.keywords?.join("、")}
</p>
</div>
<div className="absolute top-[5px] right-[10px]">
<Checkbox
checked={item.checked}
onCheckedChange={() =>
listDataCheckedChange(index, !item.checked)
}
/>
</div>
</div> </div>
<div className="absolute top-[5px] right-[10px]"> );
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" /> })}
</div>
</div>
))}
</div> </div>
)} )}
{searchType === "threat" && ( {searchType === "threat" && (
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]"> <div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
{listData.map((item, index) => ( {listData.map((item: listDataItem, index: number) => {
<div key={index} className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"> return (
<div className="flex-1 min-w-0 pr-[25px]"> <div
<p className="truncate hover:underline cursor-pointer" onClick={() => linkToA(item.url)}>{item.title}</p> key={index}
<p className="truncate text-[#909090]">发布平台:{item.site_name || "暂无数据"}</p> className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"
<p className="truncate text-[#909090]">事件时间:{item.first_publish_time || "暂无数据"}</p> >
<p className="truncate text-[#909090]">关键词:{item.keywords?.join("、") || "暂无数据"}</p> <div className="flex-1 min-w-0 pr-[25px]">
<p
className="truncate hover:underline cursor-pointer"
onClick={() => linkToA(item.url)}
>
{item.title}
</p>
<p className="truncate text-[#909090]">
发布平台:{item.site_name || "暂无数据"}
</p>
<p className="truncate text-[#909090]">
事件时间:
{item.first_publish_time || "暂无数据"}
</p>
<p className="truncate text-[#909090]">
关键词:
{item.keywords?.join("、") || "暂无数据"}
</p>
</div>
<div className="absolute top-[5px] right-[10px]">
<Checkbox
checked={item.checked}
onCheckedChange={() =>
listDataCheckedChange(index, !item.checked)
}
/>
</div>
</div> </div>
<div className="absolute top-[5px] right-[10px]"> );
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" /> })}
</div>
</div>
))}
</div> </div>
)} )}
</div> </div>
@@ -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,46 @@
"use client";
import * as React from "react";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
export interface CheckboxProps
extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"onChange" | "checked" | "type"
> {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
}
/**
* 轻量级 Checkbox(不依赖 @radix-ui/react-checkbox,适配离线部署)。
* API 与 Radix/shadcn 对齐:受控 `checked` + `onCheckedChange(next)`。
*/
export const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
({ className, checked = false, onCheckedChange, disabled, ...props }, ref) => {
return (
<button
ref={ref}
type="button"
role="checkbox"
aria-checked={checked}
disabled={disabled}
data-state={checked ? "checked" : "unchecked"}
onClick={() => onCheckedChange?.(!checked)}
className={cn(
"peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input shadow-sm outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
checked && "border-primary bg-primary text-primary-foreground",
className,
)}
{...props}
>
{checked && <Check className="size-3.5" strokeWidth={3} />}
</button>
);
},
);
Checkbox.displayName = "Checkbox";
@@ -0,0 +1,174 @@
"use client";
import { Bot, Loader2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Select } from "tdesign-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useAgents } from "@/core/agents";
import type { Agent } from "@/core/agents";
import {
useAgentModuleConfig,
useUpdateAgentModuleConfig,
} from "@/core/system-settings/hooks";
import { useTags } from "@/core/tags";
import { desensitize } from "@/lib/desensitize";
/**
* 模块(zzJC / zzXD)类型配置弹窗(需求 0616 #3/#4,admin)。
*
* 单选一个「类型」(agent 标签,TDesign 下拉框)→ 下方实时预览该类型下的智能体(名称+描述)→
* 确认后写入后端(``/api/system-settings/agent-modules/{key}``,单选即存单元素列表),
* 该模块左栏据此只展示选中类型的智能体。
*/
export function AgentModuleSettingsDialog({
open,
onOpenChange,
moduleKey,
moduleTitle,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
moduleKey: string;
moduleTitle: string;
}) {
const { data: tags = [] } = useTags("agent");
const { agents } = useAgents();
const { data: config } = useAgentModuleConfig(moduleKey);
const { mutate: save, isPending } = useUpdateAgentModuleConfig(moduleKey);
const [selectedTagId, setSelectedTagId] = useState<string | undefined>();
// Radix Dialog(modal) 把 body 设为 pointer-events:none,TDesign 浮层默认挂到 body 上
// 会点不动 —— 用 attach 把下拉浮层挂到弹窗内部容器(pointer-events 正常)。
const selectWrapRef = useRef<HTMLDivElement>(null);
// 每次打开同步当前已保存配置(取首个,单选语义)。
useEffect(() => {
if (open) setSelectedTagId(config?.tag_ids?.[0]);
}, [open, config?.tag_ids]);
const tagOptions = useMemo(
() => tags.map((t) => ({ label: t.name, value: t.id })),
[tags],
);
// 选中类型下的智能体(含名称 + 描述)。
const previewAgents = useMemo<Agent[]>(() => {
if (!selectedTagId) return [];
return agents.filter((agent) =>
(agent.tags ?? []).some((t) => t.id === selectedTagId),
);
}, [agents, selectedTagId]);
const handleConfirm = () => {
save(selectedTagId ? [selectedTagId] : [], {
onSuccess: () => {
toast.success(`已更新「${moduleTitle}」展示的类型`);
onOpenChange(false);
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : "保存失败,请重试"),
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex h-[78vh] max-h-[88vh] min-h-[620px] flex-col gap-0 p-0 sm:max-w-[760px]">
<DialogHeader className="border-b border-border/60 px-6 py-4">
<DialogTitle>{moduleTitle} · 类型配置</DialogTitle>
<DialogDescription>
选择该模块左栏展示的智能体类型(标签);下方预览该类型下的智能体。
</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-hidden px-6 py-5">
<div className="shrink-0">
<div className="text-muted-foreground/80 mb-2 text-sm font-semibold">
选择类型
</div>
{tags.length === 0 ? (
<p className="text-muted-foreground py-1 text-sm">
暂无智能体标签,请先到「标签管理」创建。
</p>
) : (
<div ref={selectWrapRef} className="relative">
<Select
className="w-full"
value={selectedTagId}
options={tagOptions}
filterable
clearable
placeholder="搜索并选择一个类型"
popupProps={{
attach: () => selectWrapRef.current ?? document.body,
}}
onChange={(v) => setSelectedTagId((v as string) || undefined)}
/>
</div>
)}
</div>
<div className="flex min-h-0 flex-1 flex-col">
<div className="text-muted-foreground/80 mb-2 shrink-0 text-sm font-semibold">
该类型下的智能体({previewAgents.length})
</div>
{!selectedTagId ? (
<p className="text-muted-foreground py-1 text-sm">
未选择类型时,左栏展示全部可见智能体。
</p>
) : previewAgents.length === 0 ? (
<p className="text-muted-foreground py-1 text-sm">
该类型下暂无智能体。
</p>
) : (
<ScrollArea className="min-h-0 flex-1 pr-3">
<div className="flex flex-col gap-2">
{previewAgents.map((agent) => (
<div
key={agent.id}
className="flex items-start gap-3 rounded-xl border border-border/60 bg-card p-3"
>
<div className="bg-primary/10 text-primary mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg">
<Bot className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-foreground">
{desensitize(agent.name)}
</div>
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs leading-relaxed">
{agent.description?.trim()
? desensitize(agent.description)
: "暂无描述"}
</p>
</div>
</div>
))}
</div>
</ScrollArea>
)}
</div>
</div>
<DialogFooter className="border-t border-border/60 px-6 py-4">
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={handleConfirm} disabled={isPending}>
{isPending && <Loader2 className="size-4 animate-spin" />}
确认
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,278 @@
"use client";
import { Bot, ChevronDown, MessageSquare, Settings } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo, useState } from "react";
import { useAgents } from "@/core/agents";
import type { Agent } from "@/core/agents";
import { useAgentModuleConfig } from "@/core/system-settings/hooks";
import { useRouteBase } from "@/core/route-base";
import { useThreads } from "@/core/threads/hooks";
import type { AgentThread } from "@/core/threads/types";
import {
pathOfThread,
sortThreadsPinnedFirst,
titleOfThread,
} from "@/core/threads/utils";
import type { Tag } from "@/core/tags";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/** 单个「按类型聚合」模块配置(需求 0616 #3 zzJC / #4 zzXD)。 */
export interface AgentModuleConfig {
/** 模块标识(路由 /page/strategy/qa/{key} 的 key)。 */
key: string;
/** 模块标题。 */
title: string;
/** 未配置类型时的默认落地智能体 id。 */
fallbackAgentId: string;
}
/**
* 已知「按类型聚合」模块 —— 按 **moduleKey** 索引(路由 /page/strategy/qa/{key})。
* zzJC / zzXD 布局完全一致,仅默认落地智能体不同。
*/
export const AGENT_MODULES: Record<string, AgentModuleConfig> = {
zzjc: { key: "zzjc", title: "zzJC支持", fallbackAgentId: "6bf" },
zzxd: {
key: "zzxd",
title: "zzXD应用",
fallbackAgentId: "10d69686718349688e58a8790cb61024",
},
};
const UNTAGGED_KEY = "__untagged__";
interface AgentGroup {
/** 分组键(tag id 或未分类常量)。 */
key: string;
/** 分组显示名。 */
label: string;
agents: Agent[];
}
/**
* 按 agent 标签把可见智能体分组(多标签智能体会出现在多个组)。
*
* - ``allowedTagIds`` 非空(admin 已配置该模块的类型):仅保留这些标签、按其配置顺序,
* 空组(无智能体)不展示;不含「未分类」。
* - ``allowedTagIds`` 为空(未配置):回退展示全部标签 + 「未分类」。
*/
function groupAgentsByTag(agents: Agent[], allowedTagIds: string[]): AgentGroup[] {
if (allowedTagIds.length > 0) {
const allowed = new Set(allowedTagIds);
const byTag = new Map<string, { tag: Tag; agents: Agent[] }>();
for (const agent of agents) {
for (const tag of agent.tags ?? []) {
if (!allowed.has(tag.id)) continue;
const entry = byTag.get(tag.id) ?? { tag, agents: [] };
entry.agents.push(agent);
byTag.set(tag.id, entry);
}
}
const groups: AgentGroup[] = [];
for (const tagId of allowedTagIds) {
const entry = byTag.get(tagId);
if (entry && entry.agents.length > 0) {
groups.push({ key: tagId, label: entry.tag.name, agents: entry.agents });
}
}
return groups;
}
const byTag = new Map<string, { tag: Tag; agents: Agent[] }>();
const untagged: Agent[] = [];
for (const agent of agents) {
const tags = agent.tags ?? [];
if (tags.length === 0) {
untagged.push(agent);
continue;
}
for (const tag of tags) {
const entry = byTag.get(tag.id) ?? { tag, agents: [] };
entry.agents.push(agent);
byTag.set(tag.id, entry);
}
}
const groups: AgentGroup[] = [...byTag.values()]
.map((e) => ({ key: e.tag.id, label: e.tag.name, agents: e.agents }))
.sort((a, b) => a.label.localeCompare(b.label, "zh"));
if (untagged.length > 0) {
groups.push({ key: UNTAGGED_KEY, label: "未分类", agents: untagged });
}
return groups;
}
/**
* 「按类型聚合智能体 + 聊天记录」左栏(需求 0616 #3 zzJC / #4 zzXD)。
*
* - 上半:可见智能体按标签(类型)分组、可折叠;点击进入该智能体对话(保持当前路由
* 命名空间,全量模式下为 /page/strategy)。
* - 下半:聊天记录 —— 仅与本模块智能体(即左栏列出的智能体)的会话,按 metadata.agent_id 过滤。
* - 宽度与通用问答左栏一致(``--sidebar-width``)。
*/
export function AgentModuleSidebar({
moduleKey,
activeAgentId,
onOpenSettings,
}: {
moduleKey: string;
activeAgentId?: string;
/** 传入则在左栏顶部显示设置齿轮(admin)。 */
onOpenSettings?: () => void;
}) {
const routeBase = useRouteBase();
const pathname = usePathname();
const { agents } = useAgents();
const { data: threads = [] } = useThreads();
const { data: moduleConfig } = useAgentModuleConfig(moduleKey);
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
const allowedTagIds = useMemo(
() => moduleConfig?.tag_ids ?? [],
[moduleConfig?.tag_ids],
);
const groups = useMemo(
() => groupAgentsByTag(agents, allowedTagIds),
[agents, allowedTagIds],
);
// 本模块智能体集合 = 左栏实际展示的智能体(聊天记录据此过滤,仅本模块相关会话)。
const moduleAgentIds = useMemo(
() => new Set(groups.flatMap((g) => g.agents.map((a) => a.id))),
[groups],
);
// 聊天记录:仅与本模块智能体的会话(按 metadata.agent_id 过滤),置顶优先。
const moduleThreads = useMemo(
() =>
sortThreadsPinnedFirst(
threads.filter((thread: AgentThread) => {
const agentId = thread.metadata?.agent_id;
return typeof agentId === "string" && moduleAgentIds.has(agentId);
}),
),
[threads, moduleAgentIds],
);
const toggleGroup = (key: string) =>
setCollapsed((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
return (
<aside className="flex h-full w-(--sidebar-width) shrink-0 flex-col overflow-hidden border-r border-border/70 bg-card/60 backdrop-blur-sm">
{onOpenSettings && (
<div className="flex shrink-0 items-center justify-between border-b border-border/60 px-3 py-2">
<span className="text-xs font-semibold text-foreground/80">智能体</span>
<button
type="button"
onClick={onOpenSettings}
title="模块设置"
aria-label="模块设置"
className="text-muted-foreground hover:text-foreground rounded-md p-1 transition-colors hover:bg-sidebar-accent/60"
>
<Settings className="size-4" />
</button>
</div>
)}
{/* 上半:按类型分组的智能体 */}
<div className="flex min-h-0 flex-[3] flex-col overflow-y-auto px-2 py-3">
<h2 className="text-muted-foreground/70 px-2 mb-1.5 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
智能体类型
</h2>
{groups.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">暂无智能体</p>
) : (
<div className="flex flex-col gap-0.5">
{groups.map((group) => {
const isCollapsed = collapsed.has(group.key);
return (
<div key={group.key}>
<button
type="button"
onClick={() => toggleGroup(group.key)}
className="flex w-full items-center justify-between gap-1 rounded-lg px-2 py-1.5 text-xs font-semibold text-foreground/80 hover:bg-sidebar-accent/60"
>
<span className="flex items-center gap-1.5 truncate">
<span className="truncate">{group.label}</span>
<span className="text-muted-foreground/60 text-[10px] font-normal tabular-nums">
{group.agents.length}
</span>
</span>
<ChevronDown
className={cn(
"size-3.5 shrink-0 transition-transform duration-200",
isCollapsed && "-rotate-90",
)}
/>
</button>
{!isCollapsed && (
<div className="mt-0.5 flex flex-col gap-0.5 pl-1">
{group.agents.map((agent) => {
const href = `${routeBase}/agents/${agent.id}/chats/new`;
const isActive = agent.id === activeAgentId;
return (
<Link
key={`${group.key}:${agent.id}`}
href={href}
className={cn(
"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60",
isActive &&
"bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary",
)}
>
<Bot className="size-3.5 shrink-0 text-muted-foreground/70" />
<span className="truncate">{desensitize(agent.name)}</span>
</Link>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* 下半:聊天记录(仅本模块智能体会话) */}
<div className="flex min-h-0 flex-[2] flex-col overflow-y-auto border-t border-border/60 px-2 py-3">
<h2 className="text-muted-foreground/70 px-2 mb-1.5 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
聊天记录
</h2>
{moduleThreads.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">暂无聊天记录</p>
) : (
<div className="flex flex-col gap-0.5">
{moduleThreads.map((thread) => {
const href = pathOfThread(thread, undefined, routeBase);
const isActive = href === pathname;
return (
<Link
key={thread.thread_id}
href={href}
className={cn(
"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-[13px] text-muted-foreground hover:text-foreground hover:bg-sidebar-accent/60",
isActive &&
"bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary",
)}
>
<MessageSquare className="size-3.5 shrink-0 text-muted-foreground/70" />
<span className="truncate">
{desensitize(titleOfThread(thread, "未命名对话"))}
</span>
</Link>
);
})}
</div>
)}
</div>
</aside>
);
}
@@ -20,9 +20,10 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { PromptInputButton } from "@/components/ai-elements/prompt-input"; import { PromptInputButton } from "@/components/ai-elements/prompt-input";
import { AgentManageDialog } from "@/components/workspace/agents/agent-manage-dialog"; import { AgentManageDialog } from "@/components/workspace/agents/agent-manage-dialog";
import { useAgentSelector } from "@/core/agents"; import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
import type { Agent } from "@/core/agents"; import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface AgentSelectorProps { interface AgentSelectorProps {
@@ -51,12 +52,21 @@ export function AgentSelector({
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [manageOpen, setManageOpen] = useState(false); const [manageOpen, setManageOpen] = useState(false);
const { agents, isLoading } = useAgentSelector(); const { agents, isLoading } = useAgentSelector();
// 弹窗列表只展示管理弹窗里配置的 agent(agents);但「已选中 agent」的名称解析要更宽——
// 侧边栏「常用智能体」可以选中未配置进管理弹窗的 agent(如用户自己的智能体),
// 这些 agent 也要能在胶囊上显示名称,故额外用「我的 / 广场」列表兜底解析(缓存与侧边栏共享)。
const { agents: mineAgents } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
const { agents: squareAgents } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
const isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []); const isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []);
const selectedAgent = useMemo<Agent | null>(() => { const selectedAgent = useMemo<Agent | null>(() => {
if (!selectedAgentId) return null; if (!selectedAgentId) return null;
return agents.find((a) => a.id === selectedAgentId) ?? null; return (
}, [agents, selectedAgentId]); [...agents, ...mineAgents, ...squareAgents].find(
(a) => a.id === selectedAgentId,
) ?? null
);
}, [agents, mineAgents, squareAgents, selectedAgentId]);
const handleSelect = (agent: Agent) => { const handleSelect = (agent: Agent) => {
onAgentChange(agent.id); onAgentChange(agent.id);
@@ -68,7 +78,7 @@ export function AgentSelector({
onAgentChange(null); onAgentChange(null);
}; };
const triggerLabel = selectedAgent?.name ?? "选择 Agent"; const triggerLabel = selectedAgent ? desensitize(selectedAgent.name) : "选择 Agent";
return ( return (
<> <>
@@ -125,7 +135,7 @@ export function AgentSelector({
{agents.map((agent) => ( {agents.map((agent) => (
<CommandItem <CommandItem
key={agent.id} key={agent.id}
value={`${agent.name} ${agent.description ?? ""}`} value={`${desensitize(agent.name)} ${desensitize(agent.description ?? "")}`}
onSelect={() => handleSelect(agent)} onSelect={() => handleSelect(agent)}
className={cn( className={cn(
"flex w-full min-w-0 items-start gap-2 overflow-hidden", "flex w-full min-w-0 items-start gap-2 overflow-hidden",
@@ -134,10 +144,10 @@ export function AgentSelector({
> >
<BotIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" /> <BotIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{agent.name}</span> <span className="truncate text-sm">{desensitize(agent.name)}</span>
{agent.description && ( {agent.description && (
<span className="line-clamp-2 whitespace-normal break-words text-[10px] leading-snug text-muted-foreground"> <span className="line-clamp-2 whitespace-normal break-words text-[10px] leading-snug text-muted-foreground">
{agent.description} {desensitize(agent.description)}
</span> </span>
)} )}
</div> </div>
@@ -122,6 +122,7 @@ export function ArtifactFileDetail({
threadId, threadId,
onClose, onClose,
allowVirtualMarkdownEdit = false, allowVirtualMarkdownEdit = false,
disableDesensitize = false,
}: { }: {
className?: string; className?: string;
filepath: string; filepath: string;
@@ -134,6 +135,12 @@ export function ArtifactFileDetail({
* 默认 false —— 主聊天页等其它调用方行为不变。 * 默认 false —— 主聊天页等其它调用方行为不变。
*/ */
allowVirtualMarkdownEdit?: boolean; allowVirtualMarkdownEdit?: boolean;
/**
* 关闭对预览内容的脱敏。多智能体会商页的「消息列表沙箱」按需求**不脱敏**沙箱内的
* 产物内容(用户要拿原始文件),故 Step2SandboxLayout 传 true;主聊天等其它调用方
* 默认 false,沿用全局脱敏。
*/
disableDesensitize?: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts(); const { artifacts, setOpen, select } = useArtifacts();
@@ -802,7 +809,7 @@ export function ArtifactFileDetail({
language === "html" || language === "html" ||
language === "mermaid") && ( language === "mermaid") && (
<ArtifactFilePreview <ArtifactFilePreview
content={desensitize(displayContent ?? "")} content={disableDesensitize ? (displayContent ?? "") : desensitize(displayContent ?? "")}
language={language ?? "text"} language={language ?? "text"}
threadId={threadId} threadId={threadId}
publicShareCode={publicShareCode} publicShareCode={publicShareCode}
@@ -815,7 +822,7 @@ export function ArtifactFileDetail({
<CodeEditor <CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent" className="size-full resize-none rounded-none border-none bg-transparent"
value={ value={
canEditArtifact canEditArtifact || disableDesensitize
? (displayContent ?? "") ? (displayContent ?? "")
: desensitize(displayContent ?? "") : desensitize(displayContent ?? "")
} }
@@ -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>
);
}
@@ -0,0 +1,586 @@
"use client";
/**
* 通用问答(全量版 / full layout)新建对话左侧栏 —— 按「样式修改.png」参考图重画。
*
* 这是**全量版专属**的独立组件:因为全量版是新功能、新需求,若继续往
* 共享的 WorkspaceNavChatList / FrequentAgentsList / RecentChatList 里塞会越来越难
* 维护,故单独成一个组件,只在 `/page/workspace/chats*` 的全量布局下挂载,互不影响。
*
* 三块结构(自上而下):
* 1. 「新对话」胶囊按钮 + 收起整列的圆形按钮;
* 2. 「常用智能体」—— 彩色图标卡(名称 + 两行简介,命中为选中态:主色描边 + 右侧主色条);
* 3. 「最近的对话」—— 计数徽标 + 折叠、历史搜索框、会话列表(悬浮「更多」菜单:
* 重命名/置顶/收藏/分享/导出/删除)、「更多最近对话」入口,底部品牌信息。
*/
import {
Bot,
ChevronDown,
ChevronRight,
Download,
FileJson,
FileText,
MessageSquare,
MoreHorizontal,
Pencil,
Pin,
PinOff,
Plus,
Search,
Share2,
Star,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import { useAgentsPaginated, type Agent } from "@/core/agents";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks";
import {
exportThreadAsJSON,
exportThreadAsMarkdown,
} from "@/core/threads/export";
import {
useDeleteThread,
useRenameThread,
useThreads,
useToggleThreadFavorite,
useToggleThreadPin,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import {
isThreadFavorite,
isThreadPinned,
pathOfThread,
sortThreadsPinnedFirst,
titleOfThread,
} from "@/core/threads/utils";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime";
import { cn } from "@/lib/utils";
import { SidebarCollapseButton } from "./workspace-header";
const NEW_CHAT_PATH = "/page/workspace/chats/new";
/** 「更多」常用智能体跳转目标:智能体管理页。 */
const MORE_AGENTS_HREF = "/page/workspace/agents";
/** 「更多最近对话」跳转目标。 */
const MORE_CHATS_HREF = "/page/workspace/chats";
/** 常用智能体最多展示个数。 */
const MAX_FREQUENT = 4;
// 智能体图标统一用 Bot 机器人图标;底色按 id 稳定散列到一组主题色,复刻参考图的彩色方块。
const AGENT_COLORS = [
{ from: "#fb7185", to: "#ef4444" },
{ from: "#60a5fa", to: "#3b82f6" },
{ from: "#fbbf24", to: "#f59e0b" },
{ from: "#34d399", to: "#10b981" },
{ from: "#a78bfa", to: "#8b5cf6" },
] as const;
function agentColor(id: string) {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
}
return AGENT_COLORS[hash % AGENT_COLORS.length]!;
}
/** 折叠区块标题(带 chevron 与可选计数徽标)。 */
function SectionLabel({
label,
count,
collapsed,
onToggle,
}: {
label: string;
count?: number;
collapsed: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
onClick={onToggle}
className="flex w-full items-center justify-between px-3 py-1 text-[11px] font-semibold tracking-[0.04em] text-muted-foreground/80 transition-colors hover:text-muted-foreground select-none"
>
<span className="flex items-center gap-1.5">
<span>{label}</span>
{count != null && (
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground tabular-nums">
{count}
</span>
)}
</span>
<ChevronDown
className={cn(
"size-3.5 shrink-0 transition-transform duration-200",
collapsed && "-rotate-90",
)}
/>
</button>
);
}
export function GeneralQaSidebar() {
const { t } = useI18n();
const pathname = usePathname();
const router = useRouter();
const navigate = useNavigate();
// ── 区块折叠状态(持久化,键独立于旧组件,避免互相影响)──
const [agentsCollapsed, setAgentsCollapsed] = useState(
() => localStorage.getItem("gqa-sidebar:agents-collapsed") === "true",
);
const [chatsCollapsed, setChatsCollapsed] = useState(
() => localStorage.getItem("gqa-sidebar:chats-collapsed") === "true",
);
const toggleAgents = () =>
setAgentsCollapsed((v) => {
localStorage.setItem("gqa-sidebar:agents-collapsed", String(!v));
return !v;
});
const toggleChats = () =>
setChatsCollapsed((v) => {
localStorage.setItem("gqa-sidebar:chats-collapsed", String(!v));
return !v;
});
// ── 常用智能体取数:置顶 → 我的 → 广场,去重补足到 MAX_FREQUENT(沿用旧逻辑)──
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);
};
[...mine, ...square].filter((a) => a.is_pinned).forEach(add);
mine.forEach(add);
square.forEach(add);
return out;
}, [mine, square]);
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
const searchParams = useSearchParams();
const activeAgentId = searchParams.get("agent");
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
// 仅给 URL 加 ?agent=<id>,由 NewChatContent 把右侧问答区换成 AgentChatPage。
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
const handleSelectAgent = useCallback(
(agentId: string) => {
navigate(`/page/workspace/chats/new?agent=${encodeURIComponent(agentId)}`);
},
[navigate],
);
// ── 最近的对话取数 + 搜索 ──
const { thread_id: threadIdFromPath, agent_id: agentIdFromPath } = useParams<{
thread_id: string;
agent_id?: string;
}>();
const { data: threads = [] } = useThreads();
const { mutate: deleteThread } = useDeleteThread();
const { mutate: renameThread } = useRenameThread();
const { toggle: togglePin } = useToggleThreadPin();
const { toggle: toggleFavorite } = useToggleThreadFavorite();
const [query, setQuery] = useState("");
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
const [renameThreadId, setRenameThreadId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [shareThreadId, setShareThreadId] = useState<string | null>(null);
const visibleThreads = useMemo(
() =>
sortThreadsPinnedFirst(
threads.filter(
(thread) =>
thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "bootstrap" &&
thread.metadata?.thread_type !== "roundtable",
),
),
[threads],
);
const filteredThreads = useMemo(() => {
const q = query.trim().toLowerCase();
const list = q
? visibleThreads.filter((thread) =>
titleOfThread(thread, t.pages.newChat).toLowerCase().includes(q),
)
: visibleThreads;
// 未搜索时与旧版一致:最多展示 10 条
return q ? list : list.slice(0, 10);
}, [visibleThreads, query, t.pages.newChat]);
const handleNewChatClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return;
event.preventDefault();
navigate(NEW_CHAT_PATH, { replace: true, state: { resetAt: Date.now() } });
};
const handleDelete = useCallback(
(threadId: string) => {
deleteThread({ threadId });
if (threadId === threadIdFromPath) {
const threadIndex = threads.findIndex((th) => th.thread_id === threadId);
let nextThreadPath = pathOfThread("new", { agent_id: agentIdFromPath });
if (threadIndex > -1) {
if (threads[threadIndex + 1]) {
nextThreadPath = pathOfThread(threads[threadIndex + 1]!);
} else if (threads[threadIndex - 1]) {
nextThreadPath = pathOfThread(threads[threadIndex - 1]!);
}
}
void router.push(nextThreadPath);
}
},
[agentIdFromPath, deleteThread, router, threadIdFromPath, threads],
);
const handleRenameClick = useCallback((threadId: string, currentTitle: string) => {
setRenameThreadId(threadId);
setRenameValue(currentTitle);
setRenameDialogOpen(true);
}, []);
const handleRenameSubmit = useCallback(() => {
if (renameThreadId && renameValue.trim()) {
renameThread({ threadId: renameThreadId, title: renameValue.trim() });
setRenameDialogOpen(false);
setRenameThreadId(null);
setRenameValue("");
}
}, [renameThread, renameThreadId, renameValue]);
const handleExport = useCallback(
async (thread: AgentThread, format: "markdown" | "json") => {
try {
const apiClient = getAPIClient();
const state = await apiClient.threads.getState<AgentThreadState>(
thread.thread_id,
);
const messages = state.values?.messages ?? [];
if (messages.length === 0) {
toast.error(t.conversation.noMessages);
return;
}
if (format === "markdown") exportThreadAsMarkdown(thread, messages);
else exportThreadAsJSON(thread, messages);
toast.success(t.common.exportSuccess);
} catch {
toast.error("Failed to export conversation");
}
},
[t],
);
const isStatic = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
return (
<div className="flex h-full min-h-0 flex-col bg-white">
{/* ── 顶部:新对话胶囊 + 收起按钮 ── */}
<div className="flex items-center gap-2 px-3 pt-3 pb-2">
{/* 新对话按钮:完全自定义样式(不复用 Button variant)。borderColor / color 走
inline style,避免被全局 border-color(--border)覆盖;无阴影。浅主色底 + 主色描边
+ 5px 圆角,全部走 --primary token,自动适配红/蓝/深蓝主题。 */}
<Link
href={NEW_CHAT_PATH}
onClick={handleNewChatClick}
style={{ borderColor: "var(--primary)", color: "var(--primary)" }}
className={cn(
"flex h-10 min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[5px] text-[14px] font-semibold transition-colors",
"border border-solid",
"bg-[color-mix(in_srgb,var(--primary)_12%,white)] hover:bg-[color-mix(in_srgb,var(--primary)_20%,white)]",
)}
>
<Plus className="size-4 stroke-[2.5]" />
<span>{t.sidebar.newChat}</span>
</Link>
<SidebarCollapseButton className="size-9 shrink-0 rounded-full border border-border bg-card text-muted-foreground shadow-sm hover:bg-accent" />
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
{/* ── 常用智能体 ── */}
{frequent.length > 0 && (
<div className="pt-2">
<SectionLabel
label="常用智能体"
collapsed={agentsCollapsed}
onToggle={toggleAgents}
/>
{!agentsCollapsed && (
<div className="mt-1 flex flex-col gap-1">
{frequent.map((agent) => {
const isActive = activeAgentId === agent.id;
const { from, to } = agentColor(agent.id);
return (
<button
key={agent.id}
type="button"
onClick={() => handleSelectAgent(agent.id)}
className={cn(
"group relative flex w-full items-start gap-2.5 rounded-xl border px-2.5 py-2 text-left transition-colors",
isActive
? "border-primary/40 bg-primary/5"
: "border-transparent hover:border-border hover:bg-accent/50",
)}
>
<span
className="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg text-white shadow-sm"
style={{ backgroundImage: `linear-gradient(135deg, ${from}, ${to})` }}
>
<Bot className="size-[18px]" />
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
"block truncate text-[13px] font-semibold",
isActive ? "text-primary" : "text-foreground",
)}
>
{desensitize(agent.name)}
</span>
<span className="mt-0.5 block truncate text-[11px] leading-snug text-muted-foreground">
{agent.description ? desensitize(agent.description) : "智能体助手"}
</span>
</span>
{isActive && (
<span className="absolute top-1/2 right-1 h-6 w-1 -translate-y-1/2 rounded-full bg-primary" />
)}
</button>
);
})}
<Link
href={MORE_AGENTS_HREF}
className="flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground"
>
<span>{t.common.more}</span>
<ChevronRight className="size-3.5 shrink-0" />
</Link>
</div>
)}
</div>
)}
{/* ── 最近的对话 ── */}
<div className="mt-3 border-t border-sidebar-border/60 pt-3">
<SectionLabel
label={t.sidebar.recentChats}
count={visibleThreads.length}
collapsed={chatsCollapsed}
onToggle={toggleChats}
/>
{!chatsCollapsed && (
<div className="mt-1.5">
{/* 历史会话搜索框 */}
<div className="relative mb-1.5 px-1">
<Search className="pointer-events-none absolute top-1/2 left-3.5 size-3.5 -translate-y-1/2 text-muted-foreground/60" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索历史会话…"
className="h-8 rounded-lg border-border bg-card pl-8 text-[12px]"
/>
</div>
<div className="flex flex-col gap-0.5">
{filteredThreads.length === 0 ? (
<div className="px-3 py-4 text-center text-[12px] text-muted-foreground/70">
{query ? "没有匹配的会话" : t.chats.noChats}
</div>
) : (
filteredThreads.map((thread) => {
const isActive = pathOfThread(thread) === pathname;
const pinned = isThreadPinned(thread);
const favorited = isThreadFavorite(thread);
return (
<div
key={thread.thread_id}
className="group/item relative flex items-center"
>
<Link
href={pathOfThread(thread)}
className={cn(
"relative flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg px-2.5 text-[13px] transition-colors",
isActive
? "border border-primary/40 bg-primary/5 font-medium text-primary"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
<MessageSquare
className={cn(
"size-3.5 shrink-0",
isActive ? "text-primary" : "text-muted-foreground/70",
)}
/>
{pinned && (
<Pin className="size-3 shrink-0 text-muted-foreground/70" />
)}
<span className="truncate">
{desensitize(titleOfThread(thread, t.pages.newChat))}
</span>
{isActive && (
<span className="absolute top-1/2 right-1 h-5 w-1 -translate-y-1/2 rounded-full bg-primary" />
)}
</Link>
{!isStatic && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="absolute right-1 flex size-6 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-background group-hover/item:opacity-100 data-[state=open]:opacity-100"
>
<MoreHorizontal className="size-4" />
<span className="sr-only">{t.common.more}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48 rounded-lg" side="right" align="start">
<DropdownMenuItem
onSelect={() =>
handleRenameClick(
thread.thread_id,
titleOfThread(thread, t.pages.newChat),
)
}
>
<Pencil className="text-muted-foreground" />
<span>{t.common.rename}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => togglePin(thread.thread_id, !pinned)}>
{pinned ? (
<PinOff className="text-muted-foreground" />
) : (
<Pin className="text-muted-foreground" />
)}
<span>{pinned ? t.common.unpin : t.common.pin}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => toggleFavorite(thread.thread_id, !favorited)}
>
<Star
className={
favorited
? "fill-amber-400 text-amber-400"
: "text-muted-foreground"
}
/>
<span>{favorited ? t.common.unfavorite : t.common.favorite}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setShareThreadId(thread.thread_id)}>
<Share2 className="text-muted-foreground" />
<span>{t.common.share}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Download className="text-muted-foreground" />
<span>{t.common.export}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onSelect={() => handleExport(thread, "markdown")}>
<FileText className="text-muted-foreground" />
<span>{t.common.exportAsMarkdown}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleExport(thread, "json")}>
<FileJson className="text-muted-foreground" />
<span>{t.common.exportAsJSON}</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => handleDelete(thread.thread_id)}>
<Trash2 className="text-muted-foreground" />
<span>{t.common.delete}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
})
)}
{!query && (
<Link
href={MORE_CHATS_HREF}
className="mt-0.5 flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground"
>
<span>更多最近对话</span>
<ChevronRight className="size-3.5 shrink-0" />
</Link>
)}
</div>
</div>
)}
</div>
</div>
{/* 重命名弹窗 */}
<Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t.common.rename}</DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
placeholder={t.common.rename}
onKeyDown={(e) => {
if (e.key === "Enter" && !isIMEComposing(e)) {
e.preventDefault();
handleRenameSubmit();
}
}}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRenameDialogOpen(false)}>
{t.common.cancel}
</Button>
<Button onClick={handleRenameSubmit}>{t.common.save}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 分享弹窗 */}
<ShareConversationDialog
threadId={shareThreadId}
open={shareThreadId !== null}
onOpenChange={(o) => {
if (!o) setShareThreadId(null);
}}
/>
</div>
);
}
@@ -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,
@@ -53,6 +54,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,
@@ -270,6 +303,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>
@@ -167,7 +167,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")
@@ -176,6 +176,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 { GeneralQaSidebar } from "./general-qa-sidebar";
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,12 @@ 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*)下启用三块式布局;点击常用智能体后是
// 「原地嵌入」(停留在 /chats/new?agent=...,仅右侧问答区换成智能体对话),左侧栏不变。
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 +51,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,
@@ -52,8 +59,16 @@ export function WorkspaceSidebar({
{...props} {...props}
> >
<SidebarContent> <SidebarContent>
<WorkspaceNavChatList showCollapseTrigger compact /> {isGeneralQa ? (
<RecentChatList compact /> // 全量版通用问答(/page/workspace/chats*):新建对话侧栏独立成
// GeneralQaSidebar(按 0616 样式参考图重画),与其余 workspace 页解耦。
<GeneralQaSidebar />
) : (
<>
<WorkspaceNavChatList showCollapseTrigger compact />
<RecentChatList compact />
</>
)}
</SidebarContent> </SidebarContent>
<SidebarRail /> <SidebarRail />
</Sidebar> </Sidebar>
+175
View File
@@ -0,0 +1,175 @@
/**
* 外部系统跳转登录时的「任务深链」:登录 URL 带 ``taskId`` + ``goPath`` 时,
* 拉取任务详情并据 goPath 把用户直接送到对应的智能体问答 / 多智能体会商页。
*
* 流程:
* 1. 调 consumer 接口 ``GET {VITE_AIPI_CONSUMER_URL}/taskAnalysSearch/cop-task-detail?taskId=``,
* 请求头 ``Authorization: Bearer admin``(由 service 拦截器统一注入)。
* 2. 把返回的 data(id/overview/taskDirection/taskName/taskContent)整包存到 sessionStorage。
* 3. 按 goPath 解析出业务码 + 模式:
* - ``3qfx`` / ``rwfx`` / ``xdfx`` → 单智能体问答(用「配置管理」里该业务配置的智能体)
* - ``3qfx-A`` / ``rwfx-A`` / ``xdfx-A`` → 多智能体会商(用该业务配置的业务链条)
* 携带 taskName / taskContent 作为开场信息。
*
* 业务码映射与「配置管理」(`/api/business-mapping`) 一致:
* 3Q=三情分析(3QFX)、6BF=任务分析(任务FX)、7BF=行动分析(XDFX)。
*/
import { requestCrudService } from "@/service/request-axios-service";
import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
/** consumer 接口返回的任务详情(data 字段)。 */
export interface CopTaskDetail {
id: string | number | null;
overview: string;
taskDirection: string;
taskName: string;
taskContent: string;
}
/** 任务详情整包暂存的 sessionStorage key(目标页/后续可读取)。 */
export const COP_TASK_DETAIL_STORAGE_KEY = "login.copTaskDetail";
/** goPath 基础码 → 业务码(配置管理 businessCode)。 */
const GOPATH_BUSINESS_CODE: Record<string, string> = {
"3qfx": "3Q", // 三情分析
rwfx: "6BF", // 任务分析(任务FX)
xdfx: "7BF", // 行动分析(XDFX)
};
/**
* 业务码 → 「任务管理」布局下的路由前缀(与 sidebar-menu.ts 的 COH 中任务菜单路径一致)。
* 会商 / 智能体问答都挂在此前缀下,保证侧边栏停留在 任务管理 → COH中任务 → 对应业务 高亮。
*/
const BUSINESS_TASK_BASE: Record<string, string> = {
"3Q": "/page/strategy/task-management/plan-task-sentiment",
"6BF": "/page/strategy/task-management/plan-task/plan-task-fx",
"7BF": "/page/strategy/task-management/plan-task/plan-task-action",
};
export interface ParsedGoPath {
businessCode: string;
/** true = 多智能体会商(``-A`` 后缀);false = 单智能体问答。 */
roundtable: boolean;
}
/** 解析 goPath(大小写不敏感);``-A`` 后缀表示多智能体会商。无法识别返回 null。 */
export function parseGoPath(goPath: string): ParsedGoPath | null {
const raw = goPath.trim().toLowerCase();
if (!raw) return null;
const roundtable = raw.endsWith("-a");
const base = roundtable ? raw.slice(0, -2) : raw;
const businessCode = GOPATH_BUSINESS_CODE[base];
if (!businessCode) return null;
return { businessCode, roundtable };
}
function consumerBaseUrl(): string {
const value = import.meta.env.VITE_AIPI_CONSUMER_URL;
return typeof value === "string" ? value.replace(/\/+$/, "") : "";
}
/** 拉取任务详情;缺少 consumer 地址 / 返回不合法时返回 null。 */
export async function fetchCopTaskDetail(taskId: string): Promise<CopTaskDetail | null> {
const base = consumerBaseUrl();
if (!base || !taskId) return null;
// service 拦截器已统一注入 `Authorization: Bearer admin` 并返回 response.data(整包 body)。
const body = await requestCrudService.get(`${base}/taskAnalysSearch/cop-task-detail`, { taskId });
const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null;
if (!data || typeof data !== "object") return null;
const d = data as Record<string, unknown>;
return {
id: (d.id as string | number | null) ?? null,
overview: typeof d.overview === "string" ? d.overview : "",
taskDirection: typeof d.taskDirection === "string" ? d.taskDirection : "",
taskName: typeof d.taskName === "string" ? d.taskName : "",
taskContent: typeof d.taskContent === "string" ? d.taskContent : "",
};
}
/** 把任务详情整包暂存到 sessionStorage(隐私模式/quota 异常时静默失败)。 */
export function persistCopTaskDetail(detail: CopTaskDetail): void {
try {
sessionStorage.setItem(COP_TASK_DETAIL_STORAGE_KEY, JSON.stringify(detail));
} catch {
/* noop */
}
}
/** 读取暂存的任务详情。 */
export function readCopTaskDetail(): CopTaskDetail | null {
try {
const raw = sessionStorage.getItem(COP_TASK_DETAIL_STORAGE_KEY);
return raw ? (JSON.parse(raw) as CopTaskDetail) : null;
} catch {
return null;
}
}
export interface TaskDeeplinkTarget {
path: string;
/** react-router navigate 的 state(多智能体会商用 roundtablePrefill)。 */
state?: Record<string, unknown>;
}
/** 用 taskName / taskContent 拼出开场信息文本。 */
function buildOpeningText(detail: CopTaskDetail): string {
return [
detail.taskName ? `任务名称:${detail.taskName}` : "",
detail.taskContent ? `任务内容:${detail.taskContent}` : "",
]
.filter(Boolean)
.join("\n");
}
/**
* 据 goPath + 任务详情解析跳转目标。
* - 多智能体会商:返回带 ``roundtablePrefill``(businessCode 让会商页自动选用配置的业务链条)的 state。
* - 单智能体问答:解析「配置管理」里该业务配置的智能体;把开场信息写入
* ``sessionStorage.pendingAgentMessage``(AgentChatPage 自动发出)。
* 无法识别 goPath / 未配置智能体时返回 null(调用方回退到默认首页)。
*/
export async function resolveTaskDeeplinkTarget(
goPath: string,
detail: CopTaskDetail,
): Promise<TaskDeeplinkTarget | null> {
const parsed = parseGoPath(goPath);
if (!parsed) return null;
const taskBase = BUSINESS_TASK_BASE[parsed.businessCode];
if (!taskBase) return null;
if (parsed.roundtable) {
// 多智能体会商:嵌进对应业务的「任务管理」布局;businessCode 让会商页自动选用配置的业务链条。
return {
path: `${taskBase}/roundtable`,
state: {
roundtablePrefill: {
taskTitle: detail.taskName,
taskGoal: detail.taskContent,
businessCode: parsed.businessCode,
autoStart: true,
},
},
};
}
let agentId: string | null = null;
try {
const mappings = await listBusinessMappings();
agentId = mappings.find((m) => m.businessCode === parsed.businessCode)?.agentId ?? null;
} catch {
agentId = null;
}
if (!agentId) return null;
try {
sessionStorage.setItem(
"pendingAgentMessage",
JSON.stringify({ agentId, text: buildOpeningText(detail) }),
);
} catch {
/* noop */
}
// 单智能体问答:同样嵌进对应业务的「任务管理」布局。
return { path: `${taskBase}/agent/${agentId}/chats/new` };
}
+109 -92
View File
@@ -44,6 +44,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[];
}; };
@@ -51,7 +53,7 @@ export type MenuItem = {
export const sidebarMenuItems: MenuItem[] = [ export const sidebarMenuItems: MenuItem[] = [
{ {
id: "qa-center", id: "qa-center",
label: "问答中心", label: "问答管理",
icon: HelpCircle, icon: HelpCircle,
children: [ children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" }, { id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
@@ -64,11 +66,11 @@ export const sidebarMenuItems: MenuItem[] = [
// { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" }, // { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
{ {
id: "qa-zzjc", id: "qa-zzjc",
label: "zzJC支持", label: "JC支持",
icon: Bot, icon: Bot,
path: "/page/strategy/agents/6bf/chats/new", path: "/page/strategy/qa/zzjc",
}, },
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout, path: "/page/strategy/agents/10d69686718349688e58a8790cb61024/chats/new", }, { id: "qa-zzxd", label: "XD应用", icon: Layout, path: "/page/strategy/qa/zzxd" },
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" }, { id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
], ],
}, },
@@ -82,18 +84,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",
}, // },
], ],
}, },
{ {
@@ -101,24 +103,24 @@ export const sidebarMenuItems: MenuItem[] = [
label: "COH中任务", label: "COH中任务",
icon: Activity, icon: Activity,
children: [ children: [
{ // {
id: "coh-task-3qfx", // id: "coh-task-3qfx",
label: "3QFX", // label: "3QFX",
icon: ListTodo, // icon: ListTodo,
path: "/page/strategy/task-management/plan-task-sentiment", // path: "/page/strategy/task-management/plan-task-sentiment",
}, // },
{ // {
id: "coh-task-fx", // id: "coh-task-fx",
label: "任务FX", // label: "任务FX",
icon: ListTodo, // icon: ListTodo,
path: "/page/strategy/task-management/plan-task/plan-task-fx", // path: "/page/strategy/task-management/plan-task/plan-task-fx",
}, // },
{ // {
id: "coh-task-xdfx", // id: "coh-task-xdfx",
label: "XDFX", // label: "XDFX",
icon: ListTodo, // icon: ListTodo,
path: "/page/strategy/task-management/plan-task/plan-task-action", // path: "/page/strategy/task-management/plan-task/plan-task-action",
}, // },
], ],
}, },
{ {
@@ -126,24 +128,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",
}, // },
], ],
}, },
{ {
@@ -151,24 +153,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",
}, // },
], ],
}, },
], ],
@@ -198,6 +200,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",
@@ -268,12 +277,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: "问答梳理",
@@ -286,12 +295,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",
}, // },
], ],
}, },
]; ];
@@ -306,9 +315,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;
@@ -316,7 +325,8 @@ export function menuItemContainsPath(item: MenuItem, pathname: string): boolean
/** /**
* 在「一级 → 二级 → 三级」菜单中,取当前路由所在的「激活二级项」的三级子项。 * 在「一级 → 二级 → 三级」菜单中,取当前路由所在的「激活二级项」的三级子项。
* 浅色侧边栏据此在二级右侧展开三级列;无三级则返回空数组(侧边栏保持窄栏)。 * 浅色侧边栏据此在二级右侧展开三级列;深色顶栏据此渲染三级横条。
* 全量模式:三级仅一项时不返回(无选择必要,侧边栏保持窄栏 / 不渲染横条)。
*/ */
export function getActiveThirdLevelItems( export function getActiveThirdLevelItems(
items: MenuItem[], items: MenuItem[],
@@ -328,16 +338,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;
});
} }
@@ -291,6 +291,45 @@ export async function updateSkillCompressionSettings(
return (await response.json()) as SkillCompressionSettings; return (await response.json()) as SkillCompressionSettings;
} }
/** 「按类型聚合」模块(zzJC / zzXD)选中的智能体标签配置(需求 0616 #3/#4)。 */
export interface AgentModuleConfig {
key: string;
tag_ids: string[];
}
export async function loadAgentModuleConfig(
key: string,
): Promise<AgentModuleConfig> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/agent-modules/${encodeURIComponent(key)}`,
);
if (!response.ok) {
throw new Error(`Failed to load agent module config: ${response.status}`);
}
return (await response.json()) as AgentModuleConfig;
}
export async function updateAgentModuleConfig(
key: string,
tagIds: string[],
): Promise<AgentModuleConfig> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/agent-modules/${encodeURIComponent(key)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tag_ids: tagIds }),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update agent module config" }));
throw new Error(detail.detail ?? "Failed to update agent module config");
}
return (await response.json()) as AgentModuleConfig;
}
export interface LogSystemSettings { export interface LogSystemSettings {
enabled: boolean; enabled: boolean;
url: string; url: string;
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { import {
loadAgentModuleConfig,
loadAppearanceSettings, loadAppearanceSettings,
loadCitationDisplaySettings, loadCitationDisplaySettings,
loadLeaderboardSettings, loadLeaderboardSettings,
@@ -10,6 +11,7 @@ import {
loadPromptPrefixSettings, loadPromptPrefixSettings,
loadPublishSquares, loadPublishSquares,
loadSkillCompressionSettings, loadSkillCompressionSettings,
updateAgentModuleConfig,
updateAppearanceSettings, updateAppearanceSettings,
updateCitationDisplaySettings, updateCitationDisplaySettings,
updateLeaderboardSettings, updateLeaderboardSettings,
@@ -169,6 +171,29 @@ export function useLayoutMode(): [
return [mode, setMode]; return [mode, setMode];
} }
/** 「按类型聚合」模块(zzJC / zzXD)选中标签配置(需求 0616 #3/#4)。 */
const agentModuleKey = (key: string) =>
["systemSettings", "agentModule", key] as const;
export function useAgentModuleConfig(key: string) {
return useQuery({
queryKey: agentModuleKey(key),
queryFn: () => loadAgentModuleConfig(key),
staleTime: 60_000,
refetchOnWindowFocus: false,
});
}
export function useUpdateAgentModuleConfig(key: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (tagIds: string[]) => updateAgentModuleConfig(key, tagIds),
onSuccess: (data) => {
queryClient.setQueryData(agentModuleKey(key), data);
},
});
}
export function useCitationDisplaySettings() { export function useCitationDisplaySettings() {
return useQuery({ return useQuery({
queryKey: CITATION_DISPLAY_KEY, queryKey: CITATION_DISPLAY_KEY,
+23
View File
@@ -4,6 +4,9 @@
* value: 脱敏后替换词 * value: 脱敏后替换词
*/ */
export const DESENSITIZE_MAP: Record<string, string> = { export const DESENSITIZE_MAP: Record<string, string> = {
方案: "FA",
设计: "SJ",
任务: "RW",
态势: "TS", 态势: "TS",
滨海作战体系: "第一张网", 滨海作战体系: "第一张网",
滨海作战部队: "第一张网", 滨海作战部队: "第一张网",
@@ -72,6 +75,26 @@ export function desensitize(text: string): string {
return text.replace(_regex, (matched) => DESENSITIZE_MAP[matched] ?? matched); return text.replace(_regex, (matched) => DESENSITIZE_MAP[matched] ?? matched);
} }
/**
* 深度脱敏:递归遍历对象 / 数组,对其中所有字符串值做 {@link desensitize},
* 其余类型原样保留。返回**新值**(不修改入参),用于对结构化数据(如大屏 report-json
* 解析出的对象)整体脱敏后再渲染。
*
* 注意:仅用于「展示」路径——不要用它改写要回传后端 / 落库的数据。
*/
export function desensitizeDeep<T>(value: T): T {
if (typeof value === "string") return desensitize(value) as unknown as T;
if (Array.isArray(value)) return value.map((v) => desensitizeDeep(v)) as unknown as T;
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = desensitizeDeep(v);
}
return out as T;
}
return value;
}
/** /**
* 运行时动态追加脱敏词,追加后自动重编译正则。 * 运行时动态追加脱敏词,追加后自动重编译正则。
* *
@@ -0,0 +1,75 @@
import { getBackendBaseURL } from '@/core/config';
import { apiFetch } from '@/core/api';
/** 课题研究模板:一组写作表单预设(需求 0616 #5)。 */
export interface ResearchTemplate {
id: string;
name: string;
description: string | null;
/** 引用的文章类型 key(对应 ArticleType.key);null = 不设。 */
article_type_key: string | null;
outline: string | null;
material_source: 'general' | 'notebook';
word_count: number;
audience: string | null;
strict_mode: boolean;
topic: string | null;
sort_order: number;
created_at: string;
updated_at: string;
}
export interface CreateResearchTemplateRequest {
name: string;
description?: string | null;
article_type_key?: string | null;
outline?: string | null;
material_source?: 'general' | 'notebook';
word_count?: number;
audience?: string | null;
strict_mode?: boolean;
topic?: string | null;
sort_order?: number;
}
export type UpdateResearchTemplateRequest = Partial<CreateResearchTemplateRequest>;
const BASE = () => `${getBackendBaseURL()}/api/ai-writing/research-templates`;
export async function listResearchTemplates(): Promise<ResearchTemplate[]> {
const resp = await apiFetch(BASE());
if (!resp.ok) throw new Error(`获取研究模板失败: ${resp.status}`);
return resp.json();
}
export async function createResearchTemplate(
data: CreateResearchTemplateRequest,
): Promise<ResearchTemplate> {
const resp = await apiFetch(BASE(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!resp.ok) throw new Error(`创建研究模板失败: ${resp.status}`);
return resp.json();
}
export async function updateResearchTemplate(
id: string,
data: UpdateResearchTemplateRequest,
): Promise<ResearchTemplate> {
const resp = await apiFetch(`${BASE()}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (resp.status === 404) throw new Error(`研究模板不存在`);
if (!resp.ok) throw new Error(`更新研究模板失败: ${resp.status}`);
return resp.json();
}
export async function deleteResearchTemplate(id: string): Promise<void> {
const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' });
if (resp.status === 404) throw new Error(`研究模板不存在`);
if (!resp.ok) throw new Error(`删除研究模板失败: ${resp.status}`);
}
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo } from 'react';
import { PenLine, SlidersHorizontal } from 'lucide-react'; import { PenLine, SlidersHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -55,8 +55,6 @@ export function AIWritingForm({
onSubmit, onSubmit,
disabled, disabled,
hideAdvancedFields = false, hideAdvancedFields = false,
initialNotebookId,
initialDocumentIds,
studioToken, studioToken,
}: AIWritingFormProps) { }: AIWritingFormProps) {
const { const {
@@ -68,13 +66,12 @@ export function AIWritingForm({
wordCountInput, setWordCountInput, wordCountInput, setWordCountInput,
strictMode, setStrictMode, strictMode, setStrictMode,
materialSource, setMaterialSource, materialSource, setMaterialSource,
// 笔记来源(search_space_id + document_id 列表)已上提到 WritingFormContext,
// 以便左栏「笔记本」点击可直接设为素材来源(需求 0616 #5)。
selectedNotebookId, setSelectedNotebookId,
selectedDocumentIds, setSelectedDocumentIds,
modelName, setModelName, modelName, setModelName,
} = useWritingForm(); } = useWritingForm();
// 笔记来源:选中的 search_space_id 和多个 document_id
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(
initialNotebookId ?? null,
);
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>(initialDocumentIds ?? []);
const { models, isLoading: modelsLoading } = useModels(); const { models, isLoading: modelsLoading } = useModels();
const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks(!studioToken); const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks(!studioToken);
@@ -146,8 +143,10 @@ export function AIWritingForm({
} }
function toggleDocument(docId: number) { function toggleDocument(docId: number) {
setSelectedDocumentIds((prev) => setSelectedDocumentIds(
prev.includes(docId) ? prev.filter((id) => id !== docId) : [...prev, docId], selectedDocumentIds.includes(docId)
? selectedDocumentIds.filter((id) => id !== docId)
: [...selectedDocumentIds, docId],
); );
} }
@@ -0,0 +1,283 @@
"use client";
import { BookOpen, FileText, LayoutGrid, Loader2, Plus, Settings2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { getStoredAuth } from "@/core/auth";
import { createSearchSpace } from "@/core/studio/api";
import { useStudioNotebooks } from "@/core/studio/hooks";
import { cn } from "@/lib/utils";
import { useResearchTemplates } from "../../hooks/useResearchTemplates";
import type { ResearchTemplate } from "../../api/ai-writing-research-templates";
import { useWritingForm } from "../../contexts/WritingFormContext";
import { useAIWriting } from "../../contexts/AIWritingContext";
/**
* 课题研究左栏(需求 0616 #5)。
* - 上半「研究模板」= 研究模板实体(``useResearchTemplates``);点击一键预填整张写作表单
* (文章类型 / 大纲 / 搜索来源 / 字数 / 受众 / 严格模式 / 主题)并展开配置。顶部「配置」
* 入口进入模板配置页维护这些模板。
* - 下半「笔记本」= 我的空间(``useStudioNotebooks``);点击在右侧内嵌打开该笔记本页。
*/
export function ResearchSidebar({
viewingNotebookId,
onOpenNotebook,
onOpenAllSpaces,
}: {
/** 当前在右侧查看的笔记本 id(高亮用)。 */
viewingNotebookId?: number | null;
/** 点击笔记本 → 在右侧内嵌打开该笔记本页(替换右侧内容)。 */
onOpenNotebook: (notebookId: number) => void;
/** 点击「所有空间」→ 在右侧内嵌打开空间列表页。 */
onOpenAllSpaces: () => void;
}) {
const navigate = useNavigate();
const { templates, refresh } = useResearchTemplates();
const { startWriting } = useAIWriting();
const {
notebooks,
isLoading: notebooksLoading,
isError: notebooksError,
} = useStudioNotebooks();
const {
setIntent,
setArticleType,
setOutlineText,
setShowOutline,
setMaterialSource,
setWordCountInput,
setAudience,
setStrictMode,
setFormExpanded,
} = useWritingForm();
const queryClient = useQueryClient();
// 新建空间弹窗(与 workspace-nav-studio 同款)。
const [createOpen, setCreateOpen] = useState(false);
const [name, setName] = useState("");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState("");
useEffect(() => {
void refresh();
}, [refresh]);
const handleCreate = async () => {
const trimmed = name.trim();
if (!trimmed) return;
setCreating(true);
setCreateError("");
try {
const email = getStoredAuth()?.email ?? "";
const token = queryClient.getQueryData<string>(["studio", "token", email]);
if (!token) throw new Error("未登录或 token 已过期,请刷新页面");
await createSearchSpace(trimmed, token);
setCreateOpen(false);
setName("");
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
} catch (e) {
setCreateError(e instanceof Error ? e.message : "创建失败");
} finally {
setCreating(false);
}
};
// 点击研究模板 → ①回填整张写作表单(保持写作态左栏显示与运行配置一致)
// ②直接以模板预设进入 AI 写作流程(startWriting)。
const handleTemplateClick = (tpl: ResearchTemplate) => {
const intent = tpl.topic?.trim() ?? "";
const articleType = tpl.article_type_key || "analysis";
const outline = tpl.outline?.trim() || "";
const audience = tpl.audience?.trim() || "普通读者";
const wordCount = tpl.word_count > 0 ? tpl.word_count : 800;
const writingMode = tpl.strict_mode ? "strict" : "loose";
// 回填表单上下文(写作态左栏据此展示)。
if (intent) setIntent(intent);
if (tpl.article_type_key) setArticleType(tpl.article_type_key);
if (outline) {
setOutlineText(outline);
setShowOutline(true);
}
setMaterialSource(tpl.material_source);
setWordCountInput(String(wordCount));
if (tpl.audience?.trim()) setAudience(audience);
setStrictMode(tpl.strict_mode);
setFormExpanded(true);
// 无主题/意图无法发起写作 → 仅回填表单,由用户补充后手动开始。
if (!intent) return;
void startWriting({
userIntent: intent,
userOutline: outline || undefined,
articleType,
targetAudience: audience,
wordCountTarget: wordCount,
keywordCount: 1,
writingMode,
materialSource: tpl.material_source,
});
};
return (
<aside className="flex h-full w-64 shrink-0 flex-col overflow-hidden border-r border-border/70 bg-card/60 backdrop-blur-sm">
{/* 上半:研究模板 */}
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-2 py-3">
<div className="px-2 mb-1.5 flex items-center justify-between">
<h2 className="text-muted-foreground/70 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
研究模板
</h2>
<button
type="button"
onClick={() => navigate("/page/canvas/research-templates")}
title="配置研究模板"
aria-label="配置研究模板"
className="text-muted-foreground/70 hover:text-foreground rounded p-0.5 transition-colors"
>
<Settings2 className="size-3.5" />
</button>
</div>
{templates.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">
暂无研究模板,点右上角配置
</p>
) : (
<div className="flex flex-col gap-1">
{templates.map((tpl) => (
<button
key={tpl.id}
type="button"
onClick={() => handleTemplateClick(tpl)}
title={tpl.description ?? tpl.name}
className="flex items-start gap-2 rounded-lg px-2.5 py-1.5 text-left text-muted-foreground transition-colors hover:bg-sidebar-accent/60 hover:text-foreground"
>
<FileText className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/70" />
<span className="min-w-0">
<span className="block truncate text-[13px] font-medium">{tpl.name}</span>
{tpl.description && (
<span className="text-muted-foreground/70 line-clamp-1 block text-[11px]">
{tpl.description}
</span>
)}
</span>
</button>
))}
</div>
)}
</div>
{/* 下半:笔记本(我的空间) */}
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto border-t border-border/60 px-2 py-3">
<h2 className="text-muted-foreground/70 px-2 mb-1.5 text-[10px] font-semibold tracking-[0.12em] uppercase select-none">
笔记本
</h2>
<div className="flex flex-col gap-0.5">
{/* 新建空间 */}
<button
type="button"
onClick={() => {
setName("");
setCreateError("");
setCreateOpen(true);
}}
className="flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-[13px] text-muted-foreground transition-colors hover:bg-sidebar-accent/60 hover:text-foreground"
>
<Plus className="size-3.5 shrink-0 text-muted-foreground/70" />
<span>新建空间</span>
</button>
{notebooksLoading ? (
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">加载中…</p>
) : notebooksError ? (
<p className="px-2 py-1.5 text-xs text-amber-600">
笔记本服务暂不可用(检查空间服务地址 / 账号)
</p>
) : notebooks.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-muted-foreground/70">暂无笔记本</p>
) : (
notebooks.map((nb) => {
const active = viewingNotebookId === nb.id;
return (
<button
key={nb.id}
type="button"
onClick={() => onOpenNotebook(nb.id)}
title={nb.description || nb.name}
className={cn(
"flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-[13px] transition-colors",
active
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground",
)}
>
<BookOpen className="size-3.5 shrink-0 text-muted-foreground/70" />
<span className="truncate">{nb.name}</span>
</button>
);
})
)}
{/* 所有空间 */}
<button
type="button"
onClick={onOpenAllSpaces}
className="flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-[13px] text-muted-foreground transition-colors hover:bg-sidebar-accent/60 hover:text-foreground"
>
<LayoutGrid className="size-3.5 shrink-0 text-muted-foreground/70" />
<span>所有空间</span>
</button>
</div>
</div>
{/* 新建空间弹窗 */}
<Dialog open={createOpen} onOpenChange={(v) => !creating && setCreateOpen(v)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>新建空间</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2">
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void handleCreate();
}}
placeholder="空间名称"
disabled={creating}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
/>
{createError && <p className="text-xs text-destructive">{createError}</p>}
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
disabled={creating}
onClick={() => setCreateOpen(false)}
>
取消
</Button>
<Button
size="sm"
disabled={!name.trim() || creating}
onClick={() => void handleCreate()}
>
{creating ? <Loader2 className="size-4 animate-spin" /> : "创建"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</aside>
);
}
@@ -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" />
@@ -36,6 +36,12 @@ export interface WritingFormValue {
setStrictMode: (v: boolean) => void; setStrictMode: (v: boolean) => void;
materialSource: 'general' | 'notebook'; materialSource: 'general' | 'notebook';
setMaterialSource: (v: 'general' | 'notebook') => void; setMaterialSource: (v: 'general' | 'notebook') => void;
/** 我的空间(笔记本)来源:选中的 search_space_id;上提到 Context 以便左栏「笔记本」点击直接设为素材来源。 */
selectedNotebookId: number | null;
setSelectedNotebookId: (v: number | null) => void;
/** 选中笔记本下的文档 id 列表(缩小检索范围)。 */
selectedDocumentIds: number[];
setSelectedDocumentIds: (v: number[]) => void;
modelName: string; modelName: string;
setModelName: (v: string) => void; setModelName: (v: string) => void;
/** 把模型建议(setup_writing 审批 payload)回填到表单各字段。 */ /** 把模型建议(setup_writing 审批 payload)回填到表单各字段。 */
@@ -56,7 +62,17 @@ export function useWritingFormOptional(): WritingFormValue | null {
return useContext(WritingFormContext); return useContext(WritingFormContext);
} }
export function WritingFormProvider({ children, initialMaterialSource }: { children: ReactNode; initialMaterialSource?: 'general' | 'notebook' }) { export function WritingFormProvider({
children,
initialMaterialSource,
initialNotebookId,
initialDocumentIds,
}: {
children: ReactNode;
initialMaterialSource?: 'general' | 'notebook';
initialNotebookId?: number | null;
initialDocumentIds?: number[];
}) {
const [intent, setIntent] = useState(''); const [intent, setIntent] = useState('');
const [formExpanded, setFormExpanded] = useState(false); const [formExpanded, setFormExpanded] = useState(false);
const [showOutline, setShowOutline] = useState(true); const [showOutline, setShowOutline] = useState(true);
@@ -66,6 +82,8 @@ export function WritingFormProvider({ children, initialMaterialSource }: { child
const [audience, setAudience] = useState('普通读者'); const [audience, setAudience] = useState('普通读者');
const [strictMode, setStrictMode] = useState(false); const [strictMode, setStrictMode] = useState(false);
const [materialSource, setMaterialSource] = useState<'general' | 'notebook'>(initialMaterialSource ?? 'general'); const [materialSource, setMaterialSource] = useState<'general' | 'notebook'>(initialMaterialSource ?? 'general');
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(initialNotebookId ?? null);
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>(initialDocumentIds ?? []);
const [modelName, setModelName] = useState(''); const [modelName, setModelName] = useState('');
const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => { const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => {
@@ -100,10 +118,12 @@ export function WritingFormProvider({ children, initialMaterialSource }: { child
audience, setAudience, audience, setAudience,
strictMode, setStrictMode, strictMode, setStrictMode,
materialSource, setMaterialSource, materialSource, setMaterialSource,
selectedNotebookId, setSelectedNotebookId,
selectedDocumentIds, setSelectedDocumentIds,
modelName, setModelName, modelName, setModelName,
applyProposal, applyProposal,
}), }),
[intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, materialSource, modelName, applyProposal], [intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, materialSource, selectedNotebookId, selectedDocumentIds, modelName, applyProposal],
); );
return <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>; return <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>;
@@ -0,0 +1,60 @@
import { useCallback, useState } from 'react';
import {
createResearchTemplate,
deleteResearchTemplate,
listResearchTemplates,
updateResearchTemplate,
} from '../api/ai-writing-research-templates';
import type {
CreateResearchTemplateRequest,
ResearchTemplate,
UpdateResearchTemplateRequest,
} from '../api/ai-writing-research-templates';
export function useResearchTemplates() {
const [templates, setTemplates] = useState<ResearchTemplate[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
setTemplates(await listResearchTemplates());
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
}, []);
const create = useCallback(
async (data: CreateResearchTemplateRequest): Promise<ResearchTemplate> => {
const created = await createResearchTemplate(data);
setTemplates((prev) =>
[...prev, created].sort((a, b) => a.sort_order - b.sort_order),
);
return created;
},
[],
);
const update = useCallback(
async (id: string, data: UpdateResearchTemplateRequest): Promise<ResearchTemplate> => {
const updated = await updateResearchTemplate(id, data);
setTemplates((prev) =>
prev.map((t) => (t.id === id ? updated : t)).sort((a, b) => a.sort_order - b.sort_order),
);
return updated;
},
[],
);
const remove = useCallback(async (id: string): Promise<void> => {
await deleteResearchTemplate(id);
setTemplates((prev) => prev.filter((t) => t.id !== id));
}, []);
return { templates, loading, error, refresh, create, update, remove };
}
@@ -81,8 +81,8 @@
* -d '{"retention_days": 0}' * -d '{"retention_days": 0}'
* ────────────────────────────────────────────────────────────────────────── * ──────────────────────────────────────────────────────────────────────────
*/ */
import { useRef } from 'react'; import { useRef, useState } from 'react';
import { PanelRightOpen } from 'lucide-react'; import { ArrowLeft, PanelRightOpen } from 'lucide-react';
import { TooltipProvider } from '@/components/ui/tooltip'; import { TooltipProvider } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { GroupImperativeHandle } from 'react-resizable-panels'; import type { GroupImperativeHandle } from 'react-resizable-panels';
@@ -93,6 +93,13 @@ import { CanvasUIProvider } from '../contexts/CanvasUIContext';
import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel'; import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel'; import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat'; import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat';
import { ResearchSidebar } from '../components/ai-writing/ResearchSidebar';
import StudioNotebookDetailPage from '@/pages/StudioNotebookDetailPage';
import StudioNotebooksPage from '@/pages/StudioNotebooksPage';
import { ChatRuntime } from '@/pages/WorkspaceRoutes';
/** 右侧内嵌视图:笔记本详情 / 所有空间列表 / null(写作) */
type RightView = { kind: 'notebook'; id: number } | { kind: 'allSpaces' } | null;
import { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext'; import { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext';
import { AIWritingHistoryProvider, useAIWritingHistory } from '../contexts/AIWritingHistoryContext'; import { AIWritingHistoryProvider, useAIWritingHistory } from '../contexts/AIWritingHistoryContext';
import { WritingFormProvider, useWritingForm } from '../contexts/WritingFormContext'; import { WritingFormProvider, useWritingForm } from '../contexts/WritingFormContext';
@@ -210,6 +217,81 @@ function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumen
); );
} }
/**
* 写作页主体(侧栏 + 内容区)。放在 Provider 内部,以便读 useAIWriting 状态:
* 进入写作(status !== idle 或查看历史)后自动收起左侧研究模板栏,让写作区更宽。
*/
function AIWritingBody({ hideAdvancedFields, initialNotebookId, initialDocumentIds, studioToken }: { hideAdvancedFields: boolean } & NotebookProps) {
const { state } = useAIWriting();
const { isHistoryMode } = useAIWritingHistory();
// 需求 0616 #5:左栏点「笔记本 / 所有空间」→ 在右侧内嵌打开对应页,替换右侧内容
// (写作布局不卸载,仅 display:none 隐藏,避免 WritingSetupChat 重挂丢线程)。
const [rightView, setRightView] = useState<RightView>(null);
const overlayActive = rightView != null;
const viewingNotebookId = rightView?.kind === 'notebook' ? rightView.id : null;
// 进入写作(开始生成 / 查看历史)后收起研究模板侧栏;嵌入态(studioToken)始终不展示。
const showSidebar = !studioToken && state.status === 'idle' && !isHistoryMode;
return (
<div className="flex-1 min-h-0 overflow-hidden flex">
{/* 需求 0616 #5:课题研究左栏(研究模板 + 笔记本)。进入写作后收起。 */}
{showSidebar && (
<ResearchSidebar
viewingNotebookId={viewingNotebookId}
onOpenNotebook={(id) => setRightView({ kind: 'notebook', id })}
onOpenAllSpaces={() => setRightView({ kind: 'allSpaces' })}
/>
)}
{/* 右侧:写作布局常驻(仅 display:none 隐藏,避免 WritingSetupChat 重挂丢线程),
查看笔记本 / 所有空间时在其上叠加内嵌页。 */}
<div className="relative flex-1 min-h-0 overflow-hidden">
<div className={cn('absolute inset-0', overlayActive && 'hidden')}>
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
</div>
{overlayActive && (
<div className="absolute inset-0 flex flex-col bg-background">
<div className="flex shrink-0 items-center gap-1 border-b border-border px-4 py-2">
<button
type="button"
onClick={() => setRightView(null)}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<ArrowLeft className="size-4" />
返回写作
</button>
{rightView.kind === 'notebook' && (
<button
type="button"
onClick={() => setRightView({ kind: 'allSpaces' })}
className="ml-1 rounded-md px-2 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
所有空间
</button>
)}
</div>
<div className="min-h-0 flex-1 overflow-hidden">
{rightView.kind === 'notebook' ? (
// 笔记本详情页内部用 ChatBox(useArtifacts) → 需 ChatRuntime 提供 Provider。
<ChatRuntime>
<StudioNotebookDetailPage key={rightView.id} notebookId={rightView.id} />
</ChatRuntime>
) : (
<div className="h-full overflow-y-auto">
<StudioNotebooksPage
onOpenNotebook={(id) => setRightView({ kind: 'notebook', id })}
/>
</div>
)}
</div>
</div>
)}
</div>
</div>
);
}
export default function AIWritingPage({ export default function AIWritingPage({
hideAdvancedFields = false, hideAdvancedFields = false,
initialNotebookId, initialNotebookId,
@@ -227,12 +309,19 @@ export default function AIWritingPage({
<CanvasUIProvider> <CanvasUIProvider>
<AIWritingHistoryProvider> <AIWritingHistoryProvider>
<AIWritingProvider> <AIWritingProvider>
<WritingFormProvider initialMaterialSource={studioToken ?? initialNotebookId != null ? 'notebook' : undefined}> <WritingFormProvider
initialMaterialSource={studioToken ?? initialNotebookId != null ? 'notebook' : undefined}
initialNotebookId={initialNotebookId}
initialDocumentIds={initialDocumentIds}
>
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background canvas-writing-bg"> <div className="h-full flex flex-col overflow-hidden bg-background canvas-writing-bg">
<div className="flex-1 min-h-0 overflow-hidden"> <AIWritingBody
<AIWritingLayout hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} /> hideAdvancedFields={hideAdvancedFields}
</div> initialNotebookId={initialNotebookId}
initialDocumentIds={initialDocumentIds}
studioToken={studioToken}
/>
</div> </div>
</TooltipProvider> </TooltipProvider>
</WritingFormProvider> </WritingFormProvider>
@@ -0,0 +1,374 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, FileText, Loader2, Pencil, Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useResearchTemplates } from '../hooks/useResearchTemplates';
import { useArticleTypes } from '../hooks/useArticleTypes';
import type {
CreateResearchTemplateRequest,
ResearchTemplate,
} from '../api/ai-writing-research-templates';
const INPUT_CLASS =
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm ' +
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50';
const TEXTAREA_CLASS =
'flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm ' +
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none';
const LABEL_CLASS = 'mb-1 block text-xs font-medium text-muted-foreground';
interface FormState {
name: string;
description: string;
article_type_key: string;
outline: string;
material_source: 'general' | 'notebook';
word_count: string;
audience: string;
strict_mode: boolean;
topic: string;
sort_order: string;
}
const EMPTY_FORM: FormState = {
name: '',
description: '',
article_type_key: '',
outline: '',
material_source: 'general',
word_count: '800',
audience: '',
strict_mode: false,
topic: '',
sort_order: '0',
};
function toForm(t: ResearchTemplate): FormState {
return {
name: t.name,
description: t.description ?? '',
article_type_key: t.article_type_key ?? '',
outline: t.outline ?? '',
material_source: t.material_source,
word_count: String(t.word_count),
audience: t.audience ?? '',
strict_mode: t.strict_mode,
topic: t.topic ?? '',
sort_order: String(t.sort_order),
};
}
function toPayload(form: FormState): CreateResearchTemplateRequest {
return {
name: form.name.trim(),
description: form.description.trim() || null,
article_type_key: form.article_type_key || null,
outline: form.outline.trim() || null,
material_source: form.material_source,
word_count: Math.max(100, parseInt(form.word_count, 10) || 800),
audience: form.audience.trim() || null,
strict_mode: form.strict_mode,
topic: form.topic.trim() || null,
sort_order: parseInt(form.sort_order, 10) || 0,
};
}
export default function ResearchTemplatesConfigPage() {
const navigate = useNavigate();
const { templates, loading, error, refresh, create, update, remove } = useResearchTemplates();
const { articleTypes, refresh: refreshTypes } = useArticleTypes();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
void refresh();
void refreshTypes();
}, [refresh, refreshTypes]);
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((f) => ({ ...f, [key]: value }));
const openCreate = () => {
setEditingId(null);
setForm(EMPTY_FORM);
setFormError(null);
setDialogOpen(true);
};
const openEdit = (t: ResearchTemplate) => {
setEditingId(t.id);
setForm(toForm(t));
setFormError(null);
setDialogOpen(true);
};
const handleSave = async () => {
if (!form.name.trim()) {
setFormError('请填写模板名称');
return;
}
setSaving(true);
setFormError(null);
try {
const payload = toPayload(form);
if (editingId) await update(editingId, payload);
else await create(payload);
setDialogOpen(false);
} catch (e) {
setFormError(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleteTargetId) return;
setDeleting(true);
try {
await remove(deleteTargetId);
setDeleteTargetId(null);
} finally {
setDeleting(false);
}
};
const articleTypeLabel = (key: string | null) =>
key ? articleTypes.find((t) => t.key === key)?.label ?? key : '—';
return (
<div className="mx-auto h-full w-full max-w-4xl overflow-y-auto p-6">
<div className="mb-5 flex items-center justify-between">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon-sm" onClick={() => navigate(-1)}>
<ArrowLeft className="size-4" />
</Button>
<div>
<h1 className="text-lg font-semibold">研究模板配置</h1>
<p className="text-xs text-muted-foreground">
配置课题研究左栏的研究模板;点击模板时一键预填写作表单。
</p>
</div>
</div>
<Button size="sm" onClick={openCreate}>
<Plus className="size-4" />
新建模板
</Button>
</div>
{error && <p className="mb-3 text-sm text-destructive">{error}</p>}
{loading ? (
<div className="flex justify-center py-16">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : templates.length === 0 ? (
<p className="py-16 text-center text-sm text-muted-foreground">
暂无研究模板,点击右上角「新建模板」创建。
</p>
) : (
<div className="flex flex-col gap-2">
{templates.map((t) => (
<div
key={t.id}
className="flex items-start gap-3 rounded-xl border border-border bg-card p-4"
>
<div className="bg-primary/10 text-primary mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-lg">
<FileText className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold">{t.name}</span>
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
{articleTypeLabel(t.article_type_key)}
</span>
</div>
{t.description && (
<p className="mt-0.5 line-clamp-1 text-xs text-muted-foreground">{t.description}</p>
)}
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-muted-foreground/80">
<span>来源:{t.material_source === 'notebook' ? '我的空间' : '通用检索'}</span>
<span>字数:{t.word_count}</span>
{t.audience && <span>受众:{t.audience}</span>}
<span>{t.strict_mode ? '严格模式' : '宽松模式'}</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(t)} title="编辑">
<Pencil className="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setDeleteTargetId(t.id)}
title="删除"
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
))}
</div>
)}
{/* 新建 / 编辑 弹窗 */}
<Dialog open={dialogOpen} onOpenChange={(v) => !saving && setDialogOpen(v)}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-[640px]">
<DialogHeader>
<DialogTitle>{editingId ? '编辑研究模板' : '新建研究模板'}</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-2">
<div className="col-span-2">
<label className={LABEL_CLASS}>模板名称 *</label>
<input
className={INPUT_CLASS}
value={form.name}
onChange={(e) => set('name', e.target.value)}
placeholder="如:深度舆情分析"
/>
</div>
<div className="col-span-2">
<label className={LABEL_CLASS}>描述</label>
<input
className={INPUT_CLASS}
value={form.description}
onChange={(e) => set('description', e.target.value)}
placeholder="模板用途简述"
/>
</div>
<div>
<label className={LABEL_CLASS}>文章类型</label>
<select
className={INPUT_CLASS}
value={form.article_type_key}
onChange={(e) => set('article_type_key', e.target.value)}
>
<option value="">不设</option>
{articleTypes.map((t) => (
<option key={t.key} value={t.key}>
{t.label}
</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLASS}>搜索来源</label>
<select
className={INPUT_CLASS}
value={form.material_source}
onChange={(e) => set('material_source', e.target.value as 'general' | 'notebook')}
>
<option value="general">通用检索</option>
<option value="notebook">我的空间(笔记本)</option>
</select>
</div>
<div>
<label className={LABEL_CLASS}>目标字数</label>
<input
type="number"
className={INPUT_CLASS}
value={form.word_count}
onChange={(e) => set('word_count', e.target.value)}
/>
</div>
<div>
<label className={LABEL_CLASS}>目标受众</label>
<input
className={INPUT_CLASS}
value={form.audience}
onChange={(e) => set('audience', e.target.value)}
placeholder="如:决策者"
/>
</div>
<div className="col-span-2">
<label className={LABEL_CLASS}>主题 / 意图</label>
<textarea
className={TEXTAREA_CLASS}
rows={2}
value={form.topic}
onChange={(e) => set('topic', e.target.value)}
placeholder="点击模板时预填到写作意图输入框"
/>
</div>
<div className="col-span-2">
<label className={LABEL_CLASS}>默认大纲</label>
<textarea
className={TEXTAREA_CLASS}
rows={4}
value={form.outline}
onChange={(e) => set('outline', e.target.value)}
placeholder={'一、…\n二、…\n三、…'}
/>
</div>
<div className="flex items-center gap-2">
<input
id="rt-strict"
type="checkbox"
checked={form.strict_mode}
onChange={(e) => set('strict_mode', e.target.checked)}
className="size-4"
/>
<label htmlFor="rt-strict" className="text-sm">
严格写作模式
</label>
</div>
<div>
<label className={LABEL_CLASS}>排序(小在前)</label>
<input
type="number"
className={INPUT_CLASS}
value={form.sort_order}
onChange={(e) => set('sort_order', e.target.value)}
/>
</div>
</div>
{formError && <p className="text-sm text-destructive">{formError}</p>}
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>
取消
</Button>
<Button onClick={() => void handleSave()} disabled={saving}>
{saving && <Loader2 className="size-4 animate-spin" />}
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认 */}
<Dialog open={!!deleteTargetId} onOpenChange={(v) => !deleting && !v && setDeleteTargetId(null)}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>删除研究模板</DialogTitle>
</DialogHeader>
<p className="py-2 text-sm text-muted-foreground">确定删除该研究模板吗?此操作不可恢复。</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTargetId(null)} disabled={deleting}>
取消
</Button>
<Button variant="destructive" onClick={() => void handleDelete()} disabled={deleting}>
{deleting && <Loader2 className="size-4 animate-spin" />}
删除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -4,6 +4,7 @@ import CanvasHomePage from "../pages/CanvasHomePage";
import CanvasThreadPage from "../pages/CanvasThreadPage"; import CanvasThreadPage from "../pages/CanvasThreadPage";
import AIWritingPage from "../pages/AIWritingPage"; import AIWritingPage from "../pages/AIWritingPage";
import ArticleTypesConfigPage from "../pages/ArticleTypesConfigPage"; import ArticleTypesConfigPage from "../pages/ArticleTypesConfigPage";
import ResearchTemplatesConfigPage from "../pages/ResearchTemplatesConfigPage";
export default function OpenCanvasRoutes() { export default function OpenCanvasRoutes() {
return ( return (
@@ -11,6 +12,7 @@ export default function OpenCanvasRoutes() {
<Route index element={<CanvasHomePage />} /> <Route index element={<CanvasHomePage />} />
<Route path="ai-writing" element={<AIWritingPage hideAdvancedFields />} /> <Route path="ai-writing" element={<AIWritingPage hideAdvancedFields />} />
<Route path="article-types" element={<ArticleTypesConfigPage />} /> <Route path="article-types" element={<ArticleTypesConfigPage />} />
<Route path="research-templates" element={<ResearchTemplatesConfigPage />} />
<Route path=":thread_id" element={<CanvasThreadPage />} /> <Route path=":thread_id" element={<CanvasThreadPage />} />
</Routes> </Routes>
); );
@@ -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[];
+24 -4
View File
@@ -31,15 +31,31 @@ import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env"; import { env } from "@/env";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
export default function AgentChatPage() { export default function AgentChatPage({
embeddedAgentId,
chatBasePath,
}: {
/** 当作为「通用问答右侧嵌入」渲染时由父组件传入的 agent id(替代路由参数)。
* 传入即视为嵌入态:路由仍停留在 /chats/new,仅右侧问答区换成该智能体的对话。 */
embeddedAgentId?: string;
/** 智能体对话所在的路由前缀(到 `/chats` 为止,不含 thread_id)。
* 默认 `${routeBase}/agents/${agent_id}/chats`;任务深链把对话嵌进「任务管理」布局时,
* 传入如 `/page/strategy/task-management/plan-task-sentiment/agent/${id}/chats`,
* 使开新会话 / 首条消息后的 URL 改写都停留在任务管理上下文(侧边栏保持对应业务高亮)。 */
chatBasePath?: string;
} = {}) {
const routeBase = useRouteBase(); const routeBase = useRouteBase();
const { t } = useI18n(); const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false); const [showFollowups, setShowFollowups] = useState(false);
const router = useRouter(); const router = useRouter();
const { agent_id } = useParams<{ const { agent_id: agentIdFromPath } = useParams<{
agent_id: string; agent_id: string;
}>(); }>();
const agent_id = embeddedAgentId ?? agentIdFromPath;
const isEmbedded = !!embeddedAgentId;
// 对话路由前缀:调用方可覆盖(任务深链嵌入态),否则用当前 routeBase 下的独立智能体对话路径。
const agentChatsBase = chatBasePath ?? `${routeBase}/agents/${agent_id}/chats`;
// If ChatPage stashed an opening message for this agent, skip the landing // If ChatPage stashed an opening message for this agent, skip the landing
// screen on first render so the user doesn't see a flash of the welcome UI // screen on first render so the user doesn't see a flash of the welcome UI
@@ -92,7 +108,7 @@ export default function AgentChatPage() {
history.replaceState( history.replaceState(
null, null,
"", "",
`${window.location.pathname}${window.location.search}#${routeBase}/agents/${agent_id}/chats/${createdThreadId}`, `${window.location.pathname}${window.location.search}#${agentChatsBase}/${createdThreadId}`,
); );
}, },
onFinish: (state) => { onFinish: (state) => {
@@ -208,8 +224,12 @@ export default function AgentChatPage() {
size="sm" size="sm"
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
// 嵌入态:保持停留在通用问答右侧,开同一智能体的新对话;
// 非嵌入态:走该智能体的独立新对话页(按当前路由 base)。
router.push( router.push(
`/page/workspace/agents/${agent_id}/chats/new`, isEmbedded
? `/page/workspace/chats/new?agent=${agent_id}`
: `${agentChatsBase}/new`,
); );
}} }}
> >
@@ -0,0 +1,99 @@
"use client";
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { Navigate, Route, Routes, useParams } from "react-router-dom";
import {
AgentModuleSidebar,
type AgentModuleConfig,
} from "@/components/workspace/agents/agent-module-sidebar";
import { AgentModuleSettingsDialog } from "@/components/workspace/agents/agent-module-settings-dialog";
import { useAgents } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { RouteBaseProvider } from "@/core/route-base";
import { useAgentModuleConfig } from "@/core/system-settings/hooks";
import AgentChatPage from "./AgentChatPage";
import { ChatRuntime } from "./WorkspaceRoutes";
/**
* 「按类型聚合」模块页(需求 0616 #3 zzJC / #4 zzXD,重做)。
*
* 一个**常驻外壳**(左栏:按类型分组智能体 + 聊天记录 + admin 设置)包裹右侧对话;
* 点击智能体只在模块内切换对话(左栏不卸载,无整页跳转)。模块自带独立路由
* ``/page/strategy/qa/{moduleKey}``,并以该路径作为 RouteBase,使内嵌的 AgentChatPage
* 所有跳转(新建对话 / onStart 改写 hash)都留在模块子树内。
*/
export function AgentModuleRoutes({ module }: { module: AgentModuleConfig }) {
const base = `/page/strategy/qa/${module.key}`;
return (
<RouteBaseProvider value={base}>
<Routes>
<Route index element={<ModuleDefaultRedirect module={module} />} />
<Route
path="agents/:agent_id/chats/:thread_id"
element={<ModuleChatShell module={module} />}
/>
</Routes>
</RouteBaseProvider>
);
}
/** 进入模块默认落地:配置类型下的第一个智能体;无配置回退到模块默认体。 */
function ModuleDefaultRedirect({ module }: { module: AgentModuleConfig }) {
const { agents, isLoading: agentsLoading } = useAgents();
const { data: config, isLoading: configLoading } = useAgentModuleConfig(
module.key,
);
if (agentsLoading || configLoading) {
return (
<div className="flex size-full items-center justify-center">
<Loader2 className="text-muted-foreground size-5 animate-spin" />
</div>
);
}
const tagIds = config?.tag_ids ?? [];
let targetId = module.fallbackAgentId;
if (tagIds.length > 0) {
const allowed = new Set(tagIds);
const first = agents.find((a) =>
(a.tags ?? []).some((t) => allowed.has(t.id)),
);
if (first) targetId = first.id;
}
return <Navigate to={`agents/${targetId}/chats/new`} replace />;
}
/** 模块外壳:常驻左栏 + 内嵌当前智能体对话(按 agent_id 重建对话区,左栏保持挂载)。 */
function ModuleChatShell({ module }: { module: AgentModuleConfig }) {
const { agent_id } = useParams<{ agent_id: string }>();
const isAdmin = getStoredAuth()?.system_role === "admin";
const [settingsOpen, setSettingsOpen] = useState(false);
return (
<div className="flex size-full min-h-0 overflow-hidden">
<AgentModuleSidebar
moduleKey={module.key}
activeAgentId={agent_id}
onOpenSettings={isAdmin ? () => setSettingsOpen(true) : undefined}
/>
<div className="min-h-0 min-w-0 flex-1">
<ChatRuntime>
<AgentChatPage key={agent_id} />
</ChatRuntime>
</div>
{isAdmin && (
<AgentModuleSettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
moduleKey={module.key}
moduleTitle={module.title}
/>
)}
</div>
);
}
@@ -31,10 +31,13 @@ import {
type BusinessMapping, type BusinessMapping,
} from "@/strategy-components/api/business-mapping"; } from "@/strategy-components/api/business-mapping";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
export default function BusinessChainMappingPage() { export default function BusinessChainMappingPage() {
const { theme } = useTheme(); const { theme } = useTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
// 整片脱敏:业务链条映射页展示文本(链条名/描述/席位名/标题)。输入框 Hook 自动跳过。
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
const [rows, setRows] = useState<BusinessMapping[]>([]); const [rows, setRows] = useState<BusinessMapping[]>([]);
const [agents, setAgents] = useState<Agent[]>([]); const [agents, setAgents] = useState<Agent[]>([]);
@@ -160,7 +163,7 @@ export default function BusinessChainMappingPage() {
]; ];
return ( return (
<div className={cn(shellClass, "[&_.t-input]:rounded-[5px] [&_.t-input__wrap]:rounded-[5px]")}> <div ref={dzRef} className={cn(shellClass, "[&_.t-input]:rounded-[5px] [&_.t-input__wrap]:rounded-[5px]")}>
<div className="mx-auto flex min-h-full max-w-7xl flex-col gap-6 px-6 py-7 lg:px-8"> <div className="mx-auto flex min-h-full max-w-7xl flex-col gap-6 px-6 py-7 lg:px-8">
{/* ── 页头 banner ── */} {/* ── 页头 banner ── */}
+12 -4
View File
@@ -34,7 +34,7 @@ import { isChatsIframeWorkspacePath } from "@/core/page-layout/chats-iframe-rout
import { useSidebar } from "@/components/ui/sidebar"; import { useSidebar } from "@/components/ui/sidebar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PanelLeftOpen } from "lucide-react"; import { PanelLeftOpen } from "lucide-react";
import { useAgentSelector } from "@/core/agents"; import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks"; import { useNotification } from "@/core/notification/hooks";
@@ -130,6 +130,10 @@ export default function ChatPage() {
const browserContextAutoSubmitRef = useRef<string | null>(null); const browserContextAutoSubmitRef = useRef<string | null>(null);
const { agents: selectorAgents, isLoading: selectorLoading } = const { agents: selectorAgents, isLoading: selectorLoading } =
useAgentSelector(); useAgentSelector();
// 已选 agent 的存在性校验需要更宽的来源:侧边栏可选未配置进管理弹窗的 agent
// (用户自己的智能体),这些也算有效,故把「我的 / 广场」一并纳入校验集(缓存共享)。
const { agents: mineAgents } = useAgentsPaginated({ scope: "mine", page: 1, pageSize: 20 });
const { agents: squareAgents } = useAgentsPaginated({ scope: "square", page: 1, pageSize: 20 });
const { selectedArtifact } = useArtifacts(); const { selectedArtifact } = useArtifacts();
useSpecificChatMode(); useSpecificChatMode();
@@ -142,7 +146,7 @@ export default function ChatPage() {
setHasStartedConversation(false); setHasStartedConversation(false);
setPromptPrefixOn(false); setPromptPrefixOn(false);
// Pre-select the agent the user last started a conversation with so a // Pre-select the agent the user last started a conversation with so a
// fresh chat defaults to it. Validated against the selector list below. // fresh chat defaults to it. Validated against the known agent list below.
setSelectedAgentId(getLastAgentId()); setSelectedAgentId(getLastAgentId());
} }
}, [isNewThread, threadId]); }, [isNewThread, threadId]);
@@ -152,10 +156,14 @@ export default function ChatPage() {
// dead agent. Wait for the list to load before deciding. // dead agent. Wait for the list to load before deciding.
useEffect(() => { useEffect(() => {
if (selectorLoading || !selectedAgentId) return; if (selectorLoading || !selectedAgentId) return;
if (!selectorAgents.some((a) => a.id === selectedAgentId)) { // 校验集 = 管理弹窗 ∪ 我的 ∪ 广场;任一存在即视为有效(含用户未配置进管理弹窗
// 的自有智能体)。三者都还没加载出来时不做清除,避免把恢复值误删。
const known = [...selectorAgents, ...mineAgents, ...squareAgents];
if (known.length === 0) return;
if (!known.some((a) => a.id === selectedAgentId)) {
setSelectedAgentId(null); setSelectedAgentId(null);
} }
}, [selectedAgentId, selectorAgents, selectorLoading]); }, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
+152 -28
View File
@@ -1,10 +1,18 @@
"use client"; "use client";
import {
BookMarkedIcon,
BrainCircuitIcon,
CheckIcon,
FileTextIcon,
GraduationCapIcon,
} from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { useArtifacts } from "@/components/workspace/artifacts"; import { useArtifacts } from "@/components/workspace/artifacts";
import { AgentSelector } from "@/components/workspace/agents/agent-selector";
import { import {
ChatBox, ChatBox,
useSpecificChatMode, useSpecificChatMode,
@@ -12,18 +20,23 @@ import {
} from "@/components/workspace/chats"; } from "@/components/workspace/chats";
import { import {
MessageList, MessageList,
MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM,
} from "@/components/workspace/messages"; } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
import { TodoList } from "@/components/workspace/todo-list"; import { TodoList } from "@/components/workspace/todo-list";
import { Button } from "@/components/ui/button";
import { useAgentSelector } from "@/core/agents";
import { getAccessToken } from "@/core/auth"; import { getAccessToken } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks"; import { useNotification } from "@/core/notification/hooks";
import { useRouteBase } from "@/core/route-base"; import { useRouteBase } from "@/core/route-base";
import { useThreadSettings } from "@/core/settings"; import {
import { usePromptPrefixSettings } from "@/core/system-settings/hooks"; getLastAgentId,
isThreadRagModeLocked,
saveLastAgentId,
useLocalSettings,
useThreadSettings,
} from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks"; import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env"; import { env } from "@/env";
@@ -81,16 +94,16 @@ export default function IframeChatPage() {
const routeBase = useRouteBase(); const routeBase = useRouteBase();
const router = useRouter(); const router = useRouter();
const { t } = useI18n(); const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false); const [hasStartedConversation, setHasStartedConversation] = useState(false);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } = const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
useThreadChat(); useThreadChat();
const [settings] = useThreadSettings(threadId); const [settings, setSettings] = useThreadSettings(threadId);
const [localSettings, setLocalSettings] = useLocalSettings();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const { tokenUsageEnabled } = useModels(); const { tokenUsageEnabled } = useModels();
const { data: promptPrefixSettings } = usePromptPrefixSettings();
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null); const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const { agents: selectorAgents, isLoading: selectorLoading } =
useAgentSelector();
const pendingRequestIdRef = useRef<string | null>(null); const pendingRequestIdRef = useRef<string | null>(null);
const { selectedArtifact } = useArtifacts(); const { selectedArtifact } = useArtifacts();
useSpecificChatMode(); useSpecificChatMode();
@@ -124,11 +137,19 @@ export default function IframeChatPage() {
useEffect(() => { useEffect(() => {
if (isNewThread) { if (isNewThread) {
setHasStartedConversation(false); setHasStartedConversation(false);
setPromptPrefixOn(false); setSelectedAgentId(getLastAgentId());
setSelectedAgentId(null);
} }
}, [isNewThread, threadId]); }, [isNewThread, threadId]);
useEffect(() => {
if (selectorLoading || !selectedAgentId) {
return;
}
if (!selectorAgents.some((agent) => agent.id === selectedAgentId)) {
setSelectedAgentId(null);
}
}, [selectedAgentId, selectorAgents, selectorLoading]);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const [thread, sendMessage, isUploading] = useThreadStream({ const [thread, sendMessage, isUploading] = useThreadStream({
@@ -207,20 +228,12 @@ export default function IframeChatPage() {
return; return;
} }
const shouldApplyPrefix =
isNewThread &&
!hasStartedConversation &&
promptPrefixOn &&
promptPrefixSettings?.enabled === true;
setHasStartedConversation(true); setHasStartedConversation(true);
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"), new CustomEvent("workspace:sandbox-resume-autoscroll"),
); );
const extraContext: Record<string, unknown> = {}; const extraContext: Record<string, unknown> = {};
if (shouldApplyPrefix) {
extraContext.prompt_prefix_enabled = true;
}
if (settings.context.writing_mode && writingArtifactPath) { if (settings.context.writing_mode && writingArtifactPath) {
extraContext.writing_artifact_path = writingArtifactPath; extraContext.writing_artifact_path = writingArtifactPath;
} }
@@ -238,8 +251,6 @@ export default function IframeChatPage() {
[ [
hasStartedConversation, hasStartedConversation,
isNewThread, isNewThread,
promptPrefixOn,
promptPrefixSettings,
router, router,
selectedAgentId, selectedAgentId,
sendMessage, sendMessage,
@@ -257,6 +268,45 @@ export default function IframeChatPage() {
[sendMessage, threadId], [sendMessage, threadId],
); );
const handleAgentChange = useCallback((agentId: string | null) => {
setSelectedAgentId(agentId);
saveLastAgentId(agentId);
}, []);
const ragModeLocked = isThreadRagModeLocked(threadId);
const ragModeEnabled =
localSettings.display.ragModeEnabled || ragModeLocked;
const proModeEnabled = settings.context.mode === "pro";
const toggleWritingMode = useCallback(() => {
setSettings("context", {
writing_mode: !settings.context.writing_mode,
});
}, [setSettings, settings.context.writing_mode]);
const toggleMemoryInjection = useCallback(() => {
setSettings("context", {
memory_injection_enabled:
!(settings.context.memory_injection_enabled ?? true),
});
}, [setSettings, settings.context.memory_injection_enabled]);
const toggleRagMode = useCallback(() => {
if (ragModeLocked) {
return;
}
setLocalSettings("display", {
ragModeEnabled: !ragModeEnabled,
});
}, [ragModeEnabled, ragModeLocked, setLocalSettings]);
const toggleProMode = useCallback(() => {
setSettings("context", {
mode: proModeEnabled ? "flash" : "pro",
reasoning_effort: proModeEnabled ? "minimal" : "medium",
});
}, [proModeEnabled, setSettings]);
const handleInterrupt = useCallback( const handleInterrupt = useCallback(
async (message: BrainyChatInterruptMessage) => { async (message: BrainyChatInterruptMessage) => {
const targetRequestId = message.payload?.requestId?.trim(); const targetRequestId = message.payload?.requestId?.trim();
@@ -318,11 +368,47 @@ export default function IframeChatPage() {
}; };
}, [handleInterrupt, handleSubmit]); }, [handleInterrupt, handleSubmit]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined;
const isLandingState = isNewThread && !hasStartedConversation; const isLandingState = isNewThread && !hasStartedConversation;
const bottomBarHeightClass = "pb-28";
const modeButtons = [
{
key: "writing",
label: "写作",
icon: FileTextIcon,
active: !!settings.context.writing_mode,
onClick: toggleWritingMode,
activeClassName:
"border-amber-300 bg-amber-50 text-amber-700 hover:bg-amber-100",
},
{
key: "memory",
label: "记忆",
icon: BrainCircuitIcon,
active: settings.context.memory_injection_enabled ?? true,
onClick: toggleMemoryInjection,
activeClassName:
"border-blue-300 bg-blue-50 text-blue-700 hover:bg-blue-100",
},
{
key: "references",
label: "参考文献",
icon: BookMarkedIcon,
active: ragModeEnabled,
onClick: toggleRagMode,
disabled: ragModeLocked,
activeClassName:
"border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100",
},
{
key: "pro",
label: "专业问答",
icon: GraduationCapIcon,
active: proModeEnabled,
onClick: toggleProMode,
activeClassName:
"border-violet-300 bg-violet-50 text-violet-700 hover:bg-violet-100",
},
] as const;
return ( return (
<ThreadContext.Provider value={{ thread, isMock }}> <ThreadContext.Provider value={{ thread, isMock }}>
@@ -350,24 +436,30 @@ export default function IframeChatPage() {
</header> </header>
*/} */}
<main className="flex min-h-0 max-w-full grow flex-col overflow-hidden"> <main className="flex min-h-0 max-w-full grow flex-col overflow-hidden">
<div className="flex size-full min-w-0"> <div className={cn("flex size-full min-w-0", !isLandingState && bottomBarHeightClass)}>
{!isLandingState && ( {!isLandingState && (
<MessageList <MessageList
className="size-full" className="size-full"
contentClassName="mx-0 max-w-full px-3 sm:px-4" contentClassName="mx-0 max-w-full px-3 sm:px-4"
threadId={threadId} threadId={threadId}
thread={thread} thread={thread}
paddingBottom={messageListPaddingBottom} paddingBottom={96}
tokenUsageEnabled={tokenUsageEnabled} tokenUsageEnabled={tokenUsageEnabled}
onClarificationSubmit={handleClarificationSubmit} onClarificationSubmit={handleClarificationSubmit}
/> />
)} )}
</div> </div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4"> <div
className={cn(
"absolute inset-x-0 z-30 flex justify-center px-4",
isLandingState
? "top-0 bottom-24 items-center"
: "bottom-24",
)}
>
<div <div
className={cn( className={cn(
"relative w-full", "relative w-full",
isLandingState && "-translate-y-[calc(50vh-96px)]",
isLandingState isLandingState
? "max-w-(--container-width-md)" ? "max-w-(--container-width-md)"
: "max-w-full", : "max-w-full",
@@ -387,6 +479,13 @@ export default function IframeChatPage() {
{isLandingState && mounted && ( {isLandingState && mounted && (
<div className="mb-6 flex min-h-40 flex-col items-center justify-center"> <div className="mb-6 flex min-h-40 flex-col items-center justify-center">
<div className="text-2xl font-medium">你好</div> <div className="text-2xl font-medium">你好</div>
<div className="mt-4">
<AgentSelector
selectedAgentId={selectedAgentId}
onAgentChange={handleAgentChange}
disabled={isUploading}
/>
</div>
</div> </div>
)} )}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && ( {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
@@ -396,6 +495,31 @@ export default function IframeChatPage() {
)} )}
</div> </div>
</div> </div>
<div className="absolute right-0 bottom-0 left-0 z-40 border-t bg-background/95 backdrop-blur">
<div className="mx-auto flex max-w-(--container-width-lg) flex-wrap items-center justify-center gap-2 px-4 py-4">
{modeButtons.map((button) => {
const Icon = button.icon;
return (
<Button
key={button.key}
type="button"
variant="outline"
disabled={button.disabled || isUploading}
onClick={button.onClick}
className={cn(
"gap-1.5 rounded-full border border-transparent bg-muted/40 text-muted-foreground shadow-none hover:bg-muted hover:text-foreground",
button.active && button.activeClassName,
button.disabled && "cursor-not-allowed opacity-80",
)}
>
{button.active && <CheckIcon className="size-3.5" />}
<Icon className="size-3.5" />
<span className="text-xs font-normal">{button.label}</span>
</Button>
);
})}
</div>
</div>
</main> </main>
</div> </div>
</ChatBox> </ChatBox>
@@ -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]">
+34
View File
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useNavigate } from 'react-router-dom';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useTheme } from '@/contexts/ThemeContext'; import { useTheme } from '@/contexts/ThemeContext';
@@ -11,6 +12,11 @@ import {
readExternalLoginParams, readExternalLoginParams,
resolveThemeFromQuery, resolveThemeFromQuery,
} from '@/core/auth/external-login'; } from '@/core/auth/external-login';
import {
fetchCopTaskDetail,
persistCopTaskDetail,
resolveTaskDeeplinkTarget,
} from '@/core/auth/task-deeplink';
import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login'; import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login';
import { isWujieEnv, useWujieProps } from '@/core/embed/wujie'; import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
import { import {
@@ -34,6 +40,7 @@ type LoginStatus = 'loading' | 'success' | 'error';
export default function LoginPage() { export default function LoginPage() {
const { username } = useParams<{ username?: string }>(); const { username } = useParams<{ username?: string }>();
const router = useRouter(); const router = useRouter();
const navigate = useNavigate();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { setTheme } = useTheme(); const { setTheme } = useTheme();
const sidebarEnabled = useMemo( const sidebarEnabled = useMemo(
@@ -60,6 +67,9 @@ export default function LoginPage() {
const gatePassword = searchParams.get('password') ?? ''; const gatePassword = searchParams.get('password') ?? '';
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开) // token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
const authToken = searchParams.get('authToken')?.trim() ?? ''; const authToken = searchParams.get('authToken')?.trim() ?? '';
// 任务深链:外部系统带 ?taskId=&goPath= 时,登录后拉任务详情并直送对应问答/会商页
const taskId = searchParams.get('taskId')?.trim() ?? '';
const goPath = searchParams.get('goPath')?.trim() ?? '';
const externalParams = useMemo( const externalParams = useMemo(
() => ({ () => ({
theme: searchParams.get('theme') ?? '', theme: searchParams.get('theme') ?? '',
@@ -145,6 +155,27 @@ export default function LoginPage() {
persistAccessToken(accessToken); persistAccessToken(accessToken);
persistSidebarEnabled(sidebarEnabled); persistSidebarEnabled(sidebarEnabled);
setStatus('success'); setStatus('success');
// 任务深链:带 taskId + goPath 时拉任务详情并直送对应问答/会商页;
// 任一环节失败(无地址/拉取失败/未配置智能体/goPath 无法识别)都回退默认首页。
if (taskId && goPath) {
try {
const detail = await fetchCopTaskDetail(taskId);
if (cancelled) return;
if (detail) {
persistCopTaskDetail(detail);
const target = await resolveTaskDeeplinkTarget(goPath, detail);
if (cancelled) return;
if (target) {
navigate(target.path, { replace: true, state: target.state });
return;
}
}
} catch {
/* 深链失败不阻断登录,落默认首页 */
}
}
router.replace(yUserId ? GENERAL_QA_ROUTE : redirectPath); router.replace(yUserId ? GENERAL_QA_ROUTE : redirectPath);
} catch (error) { } catch (error) {
if (cancelled) { if (cancelled) {
@@ -164,6 +195,7 @@ export default function LoginPage() {
}, [ }, [
resolvedUsername, resolvedUsername,
router, router,
navigate,
sidebarEnabled, sidebarEnabled,
yUserId, yUserId,
toolToken, toolToken,
@@ -171,6 +203,8 @@ export default function LoginPage() {
onlineTicket, onlineTicket,
authToken, authToken,
gatePassword, gatePassword,
taskId,
goPath,
]); ]);
return ( return (
+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>
); );
} }
+68 -1
View File
@@ -1,4 +1,4 @@
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes, useParams } from "react-router-dom";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { SidebarProvider } from "@/components/ui/sidebar"; import { SidebarProvider } from "@/components/ui/sidebar";
@@ -19,12 +19,15 @@ import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainList
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage"; import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
import "@/roundtable-planning/styles/roundtable-planning.css"; import "@/roundtable-planning/styles/roundtable-planning.css";
import AgentChatPage from "./AgentChatPage"; import AgentChatPage from "./AgentChatPage";
import { AgentModuleRoutes } from "./AgentModulePage";
import { AGENT_MODULES } from "@/components/workspace/agents/agent-module-sidebar";
import AgentsPage from "./AgentsPage"; import AgentsPage from "./AgentsPage";
import BusinessChainMappingPage from "./BusinessChainMappingPage"; import BusinessChainMappingPage from "./BusinessChainMappingPage";
import ChatPage from "./ChatPage"; 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";
@@ -48,6 +51,20 @@ import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminUserActivityPage from "./AdminUserActivityPage"; import AdminUserActivityPage from "./AdminUserActivityPage";
import { ChatRuntime } from "./WorkspaceRoutes"; import { ChatRuntime } from "./WorkspaceRoutes";
/**
* 任务深链:把智能体问答嵌进「任务管理」布局。读路由参数 agent_id,给 AgentChatPage
* 传入以 taskBase 为前缀的对话路由(开新会话 / URL 改写都停留在任务管理上下文,
* 侧边栏保持对应业务高亮)。
*/
function TaskEmbeddedAgentChat({ taskBase }: { taskBase: string }) {
const { agent_id } = useParams<{ agent_id: string }>();
return (
<ChatRuntime>
<AgentChatPage chatBasePath={`${taskBase}/agent/${agent_id}/chats`} />
</ChatRuntime>
);
}
export default function StrategyRoutes() { export default function StrategyRoutes() {
return ( return (
<RouteBaseProvider value="/page/strategy"> <RouteBaseProvider value="/page/strategy">
@@ -75,6 +92,10 @@ export default function StrategyRoutes() {
} }
/> />
{/* 需求 0616 #3/#4:zzJC / zzXD 「按类型聚合」模块页(重做,独立路由)。 */}
<Route path="qa/zzjc/*" element={<AgentModuleRoutes module={AGENT_MODULES.zzjc} />} />
<Route path="qa/zzxd/*" element={<AgentModuleRoutes module={AGENT_MODULES.zzxd} />} />
<Route path="task-management" element={<TaskManagement />}> <Route path="task-management" element={<TaskManagement />}>
<Route index element={<Navigate to="xk-task-jc" replace />} /> <Route index element={<Navigate to="xk-task-jc" replace />} />
<Route <Route
@@ -107,6 +128,21 @@ export default function StrategyRoutes() {
/> />
} }
/> />
{/* 任务深链(3qfx / 3qfx-A):把智能体问答 / 会商嵌进 3QFX 业务上下文。 */}
<Route
path="plan-task-sentiment/roundtable"
element={
<ChatRuntime>
<RoundtablePlanningPage />
</ChatRuntime>
}
/>
<Route
path="plan-task-sentiment/agent/:agent_id/chats/:thread_id"
element={
<TaskEmbeddedAgentChat taskBase="/page/strategy/task-management/plan-task-sentiment" />
}
/>
<Route <Route
path="plan-task/plan-task-fx" path="plan-task/plan-task-fx"
element={ element={
@@ -117,6 +153,21 @@ export default function StrategyRoutes() {
/> />
} }
/> />
{/* 任务深链(rwfx / rwfx-A):嵌进 任务FX 业务上下文。 */}
<Route
path="plan-task/plan-task-fx/roundtable"
element={
<ChatRuntime>
<RoundtablePlanningPage />
</ChatRuntime>
}
/>
<Route
path="plan-task/plan-task-fx/agent/:agent_id/chats/:thread_id"
element={
<TaskEmbeddedAgentChat taskBase="/page/strategy/task-management/plan-task/plan-task-fx" />
}
/>
<Route <Route
path="plan-task/plan-task-action" path="plan-task/plan-task-action"
element={ element={
@@ -127,6 +178,21 @@ export default function StrategyRoutes() {
/> />
} }
/> />
{/* 任务深链(xdfx / xdfx-A):嵌进 XDFX 业务上下文。 */}
<Route
path="plan-task/plan-task-action/roundtable"
element={
<ChatRuntime>
<RoundtablePlanningPage />
</ChatRuntime>
}
/>
<Route
path="plan-task/plan-task-action/agent/:agent_id/chats/:thread_id"
element={
<TaskEmbeddedAgentChat taskBase="/page/strategy/task-management/plan-task/plan-task-action" />
}
/>
<Route path="mnty-task-mechanism" element={<TaskTyFrom defaultPage="iframeXdty" title="XDTY任务" />} /> <Route path="mnty-task-mechanism" element={<TaskTyFrom defaultPage="iframeXdty" title="XDTY任务" />} />
<Route path="mnty-task-plan" element={<TaskTyFrom defaultPage="iframeFaty" title="方案TY任务" />} /> <Route path="mnty-task-plan" element={<TaskTyFrom defaultPage="iframeFaty" title="方案TY任务" />} />
<Route path="mnty-task-zsty" element={<TaskTyFrom defaultPage="iframeSysystem" title="XXTY任务" />} /> <Route path="mnty-task-zsty" element={<TaskTyFrom defaultPage="iframeSysystem" title="XXTY任务" />} />
@@ -254,6 +320,7 @@ export default function StrategyRoutes() {
) )
} }
/> />
<Route path="user-leaderboard" element={<PublicLeaderboardPage />} />
<Route <Route
path="admin/user-leaderboard" path="admin/user-leaderboard"
element={ element={
@@ -209,8 +209,15 @@ function SourcesDialog({
} }
// ── 主页面 ────────────────────────────────────────────── // ── 主页面 ──────────────────────────────────────────────
export default function StudioNotebookDetailPage() { export default function StudioNotebookDetailPage({
const { notebookId } = useParams<{ notebookId: string }>(); notebookId: notebookIdProp,
}: {
/** 内嵌使用(如课题研究右侧)时直接传入;缺省则从路由参数取。 */
notebookId?: number;
} = {}) {
const params = useParams<{ notebookId: string }>();
const notebookId =
notebookIdProp != null ? String(notebookIdProp) : params.notebookId;
const { notebooks, isLoading, token: studioToken } = useStudioNotebooks(); // token 用于跳转 callback const { notebooks, isLoading, token: studioToken } = useStudioNotebooks(); // token 用于跳转 callback
const { models } = useModels(); const { models } = useModels();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
+33 -8
View File
@@ -39,17 +39,16 @@ function NotebookCard({
nb, nb,
onRename, onRename,
onDelete, onDelete,
onOpen,
}: { }: {
nb: StudioNotebook; nb: StudioNotebook;
onRename: (nb: StudioNotebook) => void; onRename: (nb: StudioNotebook) => void;
onDelete: (nb: StudioNotebook) => void; onDelete: (nb: StudioNotebook) => void;
/** 传入则点击卡片回调(内嵌打开),不再路由跳转。 */
onOpen?: (id: number) => void;
}) { }) {
return ( const inner = (
<div className="group relative flex flex-col rounded-[20px] bg-[#f2f2f2] transition-colors hover:bg-[#e8e8e8]" style={{ minHeight: 180 }}> <>
<Link
to={`/page/workspace/studio/notebooks/${nb.id}`}
className="flex flex-1 flex-col p-5"
>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex size-11 items-center justify-center text-3xl leading-none select-none"> <div className="flex size-11 items-center justify-center text-3xl leading-none select-none">
{nb.avatar_url ? ( {nb.avatar_url ? (
@@ -67,7 +66,27 @@ function NotebookCard({
</span> </span>
<span className="text-[13px] text-[#666]">{nb.source_count} 个来源</span> <span className="text-[13px] text-[#666]">{nb.source_count} 个来源</span>
</div> </div>
</Link> </>
);
return (
<div className="group relative flex flex-col rounded-[20px] bg-[#f2f2f2] transition-colors hover:bg-[#e8e8e8]" style={{ minHeight: 180 }}>
{onOpen ? (
<button
type="button"
onClick={() => onOpen(nb.id)}
className="flex flex-1 flex-col p-5 text-left"
>
{inner}
</button>
) : (
<Link
to={`/page/workspace/studio/notebooks/${nb.id}`}
className="flex flex-1 flex-col p-5"
>
{inner}
</Link>
)}
{/* three-dot menu — absolute so it doesn't shift layout */} {/* three-dot menu — absolute so it doesn't shift layout */}
<div className="absolute right-4 top-4"> <div className="absolute right-4 top-4">
@@ -99,7 +118,12 @@ function NotebookCard({
); );
} }
export default function StudioNotebooksPage() { export default function StudioNotebooksPage({
onOpenNotebook,
}: {
/** 内嵌使用(课题研究右侧)时传入:点击空间卡片回调,不跳路由。 */
onOpenNotebook?: (id: number) => void;
} = {}) {
const { notebooks, isLoading, isError } = useStudioNotebooks(); const { notebooks, isLoading, isError } = useStudioNotebooks();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -204,6 +228,7 @@ export default function StudioNotebooksPage() {
nb={nb} nb={nb}
onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }} onRename={(nb) => { setRenameTarget(nb); setRenameName(nb.name); setRenameError(""); }}
onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }} onDelete={(nb) => { setDeleteTarget(nb); setDeleteError(""); }}
onOpen={onOpenNotebook}
/> />
))} ))}
</div> </div>
+29 -6
View File
@@ -1,5 +1,5 @@
import type { CSSProperties, ReactNode } from 'react'; import type { CSSProperties, ReactNode } from 'react';
import { Navigate, Route, Routes, useParams } from 'react-router-dom'; import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
import { getStoredAuth } from '@/core/auth'; import { getStoredAuth } from '@/core/auth';
@@ -72,6 +72,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,
@@ -82,10 +83,16 @@ 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;
}) { }) {
// 全量模式下通用问答(/page/workspace/chats*)的历史侧栏默认展开;其余紧凑页
// (如智能体对话,为聊天区更宽而默认收起)维持原默认。
const isGeneralQa = useLocation().pathname.startsWith("/page/workspace/chats");
const pageSidebarWidth = pageSidebarHidden const pageSidebarWidth = pageSidebarHidden
? '0rem' ? '0rem'
: pageSidebarDark : pageSidebarDark
@@ -112,8 +119,9 @@ export function WorkspaceLayout({
} }
key={String(compactWorkspaceSidebar)} key={String(compactWorkspaceSidebar)}
data-page-sidebar-collapsed={pageSidebarCollapsed} data-page-sidebar-collapsed={pageSidebarCollapsed}
// 紧凑模式下历史侧栏默认收起(聊天区更宽),由聊天页右上角图标钮展开 defaultOpen={
defaultOpen={!compactWorkspaceSidebar} defaultHistorySidebarOpen ?? (isGeneralQa || !compactWorkspaceSidebar)
}
> >
{!workspaceSidebarHidden && ( {!workspaceSidebarHidden && (
<WorkspaceSidebar compactChrome={compactWorkspaceSidebar} /> <WorkspaceSidebar compactChrome={compactWorkspaceSidebar} />
@@ -139,6 +147,21 @@ export function ChatRuntime({ children }: { children: ReactNode }) {
); );
} }
/**
* 通用问答新对话内容区:默认渲染 ChatPage;当 URL 带 ``?agent=<id>``(点击左栏
* 「常用智能体」时由侧边栏写入)时,**在右侧问答区原地嵌入**该智能体的对话页
* (AgentChatPage),路由仍停留在 /chats/new,不跳转出去、不更换左侧栏。
* key 绑定 location.key —— 每次点击(即便同一 agent)都重开一个干净的会话。
*/
function NewChatContent() {
const location = useLocation();
const agentId = new URLSearchParams(location.search).get('agent');
if (agentId) {
return <AgentChatPage key={`${agentId}:${location.key}`} embeddedAgentId={agentId} />;
}
return <ChatPage />;
}
function NewChatEntryRoute({ function NewChatEntryRoute({
layoutProps, layoutProps,
}: { }: {
@@ -155,9 +178,9 @@ function NewChatEntryRoute({
} }
return ( return (
<WorkspaceLayout {...layoutProps}> <WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
<ChatRuntime> <ChatRuntime>
<ChatPage /> <NewChatContent />
</ChatRuntime> </ChatRuntime>
</WorkspaceLayout> </WorkspaceLayout>
); );
@@ -203,7 +226,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,
}
: {}),
}; };
} }
@@ -29,6 +29,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { listAgents } from "@/core/agents"; import { listAgents } from "@/core/agents";
@@ -212,9 +213,11 @@ export function BusinessChainListDialog({
onOpenChange, onOpenChange,
onConfigure, onConfigure,
}: BusinessChainListDialogProps) { }: BusinessChainListDialogProps) {
// 整片脱敏:弹窗内链条列表(链条名/描述/席位名/标题)。
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[85vh] flex-col gap-0 sm:max-w-2xl"> <DialogContent ref={dzRef} className="flex max-h-[85vh] flex-col gap-0 sm:max-w-2xl">
<DialogHeader className="shrink-0"> <DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<LayersIcon className="size-5 text-primary" /> <LayersIcon className="size-5 text-primary" />
@@ -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,
@@ -26,6 +26,7 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DialogFooter } from "@/components/ui/dialog"; import { DialogFooter } from "@/components/ui/dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { listAgents } from "@/core/agents"; import { listAgents } from "@/core/agents";
import { filterRecommendCandidates } from "../api/recommend"; import { filterRecommendCandidates } from "../api/recommend";
@@ -44,12 +45,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 +106,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],
@@ -149,9 +162,12 @@ export function BusinessChainPicker({
onConfirm({ agents, mode: "chain", chain: chainMeta }); onConfirm({ agents, mode: "chain", chain: chainMeta });
}; };
// 整片脱敏:链条卡片区(链条名/描述/席位智能体名)。输入框不受影响(Hook 跳过)。
const dzRef = useDesensitizeSubtree<HTMLElement>();
return ( return (
<> <>
<section className="flex-1 min-h-0 flex flex-col overflow-hidden"> <section ref={dzRef} className="flex-1 min-h-0 flex flex-col overflow-hidden">
{/* 筛选:我的 / 公共 */} {/* 筛选:我的 / 公共 */}
<div className="mb-2 flex shrink-0 items-center gap-1 self-start rounded-lg border border-border bg-muted/40 p-0.5"> <div className="mb-2 flex shrink-0 items-center gap-1 self-start rounded-lg border border-border bg-muted/40 p-0.5">
{(["mine", "public"] as const).map((s) => ( {(["mine", "public"] as const).map((s) => (
@@ -21,6 +21,7 @@
*/ */
import { CheckIcon, ChevronRightIcon, Loader2Icon } from "lucide-react"; import { CheckIcon, ChevronRightIcon, Loader2Icon } from "lucide-react";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getAvatar } from "./Avatars"; import { getAvatar } from "./Avatars";
@@ -137,7 +138,7 @@ export function DagFlowChart({
return ( return (
<FlowNode <FlowNode
key={agentId} key={agentId}
label={meta.name} label={desensitize(meta.name)}
avatarType={meta.avatarType} avatarType={meta.avatarType}
state={state} state={state}
compact={compact} compact={compact}
@@ -20,6 +20,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { CheckIcon, Loader2Icon, Network, X } from "lucide-react"; import { CheckIcon, Loader2Icon, Network, X } from "lucide-react";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getAvatar } from "./Avatars"; import { getAvatar } from "./Avatars";
@@ -400,7 +401,7 @@ export function DagFlowMiniPanel({
return ( return (
<MiniNode <MiniNode
key={agentId} key={agentId}
label={meta.name} label={desensitize(meta.name)}
avatarType={meta.avatarType} avatarType={meta.avatarType}
state={state} state={state}
/> />
@@ -26,6 +26,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getAvatar } from "./Avatars"; import { getAvatar } from "./Avatars";
@@ -236,7 +237,7 @@ export function DispatchChainModal({
done={node.state === "done"} done={node.state === "done"}
/> />
<FlowNode <FlowNode
label={node.name} label={desensitize(node.name)}
avatarType={node.avatarType} avatarType={node.avatarType}
state={node.state} state={node.state}
order={node.order} order={node.order}
@@ -277,7 +278,7 @@ export function DispatchChainModal({
<div key={d.id} className="flex gap-2.5"> <div key={d.id} className="flex gap-2.5">
<div className="shrink-0">{getAvatar(avatarForDialogue(d), "h-7 w-7")}</div> <div className="shrink-0">{getAvatar(avatarForDialogue(d), "h-7 w-7")}</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="mb-0.5 text-xs font-semibold text-foreground/80">{d.name}</div> <div className="mb-0.5 text-xs font-semibold text-foreground/80">{desensitize(d.name)}</div>
<div className="prose prose-sm max-w-none break-words text-sm leading-relaxed text-foreground/80 dark:prose-invert"> <div className="prose prose-sm max-w-none break-words text-sm leading-relaxed text-foreground/80 dark:prose-invert">
<MarkdownContent>{d.content || "…"}</MarkdownContent> <MarkdownContent>{d.content || "…"}</MarkdownContent>
</div> </div>
@@ -32,6 +32,7 @@ import {
type ThreadIdMap, type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent"; } from "@/roundtable-planning/api/multi-agent";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton"; import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { desensitize } from "@/lib/desensitize";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components"; import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import { import {
Step3ReportMode, Step3ReportMode,
@@ -707,7 +708,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
lit ? "text-slate-800 dark:text-foreground" : "text-slate-400 dark:text-muted-foreground", lit ? "text-slate-800 dark:text-foreground" : "text-slate-400 dark:text-muted-foreground",
)} )}
> >
{seat.displayName} {desensitize(seat.displayName)}
</span> </span>
<span className="block truncate text-[9.5px] font-mono text-slate-400 dark:text-muted-foreground"> <span className="block truncate text-[9.5px] font-mono text-slate-400 dark:text-muted-foreground">
{lit ? `${seat.delivery!.length} 字` : "无交付"} {lit ? `${seat.delivery!.length} 字` : "无交付"}
@@ -771,7 +772,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
{consensusText ? ( {consensusText ? (
<div className="text-xs text-slate-700 leading-relaxed dark:text-foreground/80"> <div className="text-xs text-slate-700 leading-relaxed dark:text-foreground/80">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText} {desensitize(consensusText)}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
) : ( ) : (
@@ -830,7 +831,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
{getAvatar(seat.avatarType, "h-5.5 w-5.5")} {getAvatar(seat.avatarType, "h-5.5 w-5.5")}
</span> </span>
<span className="text-xs font-black text-slate-800 truncate dark:text-foreground"> <span className="text-xs font-black text-slate-800 truncate dark:text-foreground">
{seat.displayName} {desensitize(seat.displayName)}
</span> </span>
</div> </div>
<span className="text-[10px] text-slate-400 font-mono shrink-0 dark:text-muted-foreground"> <span className="text-[10px] text-slate-400 font-mono shrink-0 dark:text-muted-foreground">
@@ -858,7 +859,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={markdownComponents} components={markdownComponents}
> >
{seat.delivery!} {desensitize(seat.delivery!)}
</ReactMarkdown> </ReactMarkdown>
) : ( ) : (
<p className="text-[11px] text-slate-400 italic dark:text-muted-foreground">该席位暂无交付内容。</p> <p className="text-[11px] text-slate-400 italic dark:text-muted-foreground">该席位暂无交付内容。</p>
@@ -18,6 +18,7 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { BrainIcon, ChevronDownIcon, Loader2 } from "lucide-react"; import { BrainIcon, ChevronDownIcon, Loader2 } from "lucide-react";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface MessageBubbleProps { export interface MessageBubbleProps {
@@ -58,7 +59,8 @@ export function MessageBubble({
senderNameClassName, senderNameClassName,
)} )}
> >
{senderName} {/* 智能体名称需脱敏(消息列表里唯一要脱敏的就是名称)。 */}
{desensitize(senderName)}
</span> </span>
{badge && ( {badge && (
<span className="roundtable-chat-meta font-mono font-black uppercase text-[var(--brand-900)] bg-[var(--brand-200)] border border-red-200 px-1.5 py-0.2 rounded"> <span className="roundtable-chat-meta font-mono font-black uppercase text-[var(--brand-900)] bg-[var(--brand-200)] border border-red-200 px-1.5 py-0.2 rounded">
@@ -190,6 +192,7 @@ export const sharedMarkdownComponents = {
* 便捷封装:用 sharedMarkdownComponents + remarkGfm 渲染 markdown。 * 便捷封装:用 sharedMarkdownComponents + remarkGfm 渲染 markdown。
* 大多数 caller 都不需要再覆盖任何 components,直接用这个就好。 * 大多数 caller 都不需要再覆盖任何 components,直接用这个就好。
*/ */
// 消息列表正文**不脱敏**(仅名称脱敏);MarkdownContent 是会商对话气泡正文渲染器,故原文渲染。
export function MarkdownContent({ children }: { children: string }) { export function MarkdownContent({ children }: { children: string }) {
return ( return (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={sharedMarkdownComponents}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={sharedMarkdownComponents}>
@@ -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;
} }
@@ -11,6 +11,7 @@
*/ */
import { CheckIcon, Loader2Icon, XIcon } from "lucide-react"; import { CheckIcon, Loader2Icon, XIcon } from "lucide-react";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getAvatar } from "@/roundtable-planning/components/Avatars"; import { getAvatar } from "@/roundtable-planning/components/Avatars";
import type { ParallelSeatView } from "@/roundtable-planning/hooks/useStep2Orchestration"; import type { ParallelSeatView } from "@/roundtable-planning/hooks/useStep2Orchestration";
@@ -42,7 +43,7 @@ export function ParallelSeatStrip({ batch, selectedSeats, onToggleSeat }: Parall
key={seat.agentId} key={seat.agentId}
type="button" type="button"
onClick={() => onToggleSeat(seat.agentId)} onClick={() => onToggleSeat(seat.agentId)}
title={`${seat.displayName}${isSelected ? "(主区显示中)" : "(点击在主区显示)"}`} title={`${desensitize(seat.displayName)}${isSelected ? "(主区显示中)" : "(点击在主区显示)"}`}
className={cn( className={cn(
"relative shrink-0 rounded-full transition", "relative shrink-0 rounded-full transition",
isSelected ? "ring-2 ring-primary ring-offset-1" : "ring-1 ring-transparent hover:ring-slate-300", isSelected ? "ring-2 ring-primary ring-offset-1" : "ring-1 ring-transparent hover:ring-slate-300",
@@ -23,6 +23,7 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { listAgents, type Agent } from "@/core/agents"; import { listAgents, type Agent } from "@/core/agents";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
@@ -34,6 +35,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 +206,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 +524,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 +547,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 +612,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 +796,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>
@@ -1073,7 +1123,7 @@ function AgentCard({
</span> </span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<div className="font-medium text-sm truncate">{candidate.name || candidate.agent_id}</div> <div className="font-medium text-sm truncate">{desensitize(candidate.name || candidate.agent_id)}</div>
{recommended && ( {recommended && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/10 text-purple-700 dark:text-purple-300 font-medium"> <span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/10 text-purple-700 dark:text-purple-300 font-medium">
推荐 推荐
@@ -1082,12 +1132,12 @@ function AgentCard({
</div> </div>
{candidate.description && ( {candidate.description && (
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2"> <div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{candidate.description} {desensitize(candidate.description)}
</div> </div>
)} )}
{reason ? ( {reason ? (
<div className="text-xs text-primary/80 mt-1 italic"> <div className="text-xs text-primary/80 mt-1 italic">
<span className="font-medium not-italic">推荐理由:</span> {reason} <span className="font-medium not-italic">推荐理由:</span> {desensitize(reason)}
</div> </div>
) : streaming ? ( ) : streaming ? (
<div className="text-xs text-muted-foreground mt-1 italic">正在生成推荐理由...</div> <div className="text-xs text-muted-foreground mt-1 italic">正在生成推荐理由...</div>
@@ -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>
);
}
@@ -56,6 +56,7 @@ import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToB
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll"; import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics"; import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import type { InteractiveMessage } from "@/roundtable-planning/hooks/useStep1Intent"; import type { InteractiveMessage } from "@/roundtable-planning/hooks/useStep1Intent";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning"; import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
import { splitIntentReadyBlock } from "@/roundtable-planning/lib/roundtable-constants"; import { splitIntentReadyBlock } from "@/roundtable-planning/lib/roundtable-constants";
import type { import type {
@@ -149,10 +150,13 @@ export function Step1Panel(props: Step1PanelProps) {
setAttachedFiles([]); setAttachedFiles([]);
}; };
// 顶部标题栏整片脱敏(消息列表正文在下方,不在此容器内,保持原文)。
const headerDzRef = useDesensitizeSubtree<HTMLDivElement>();
return ( return (
<div className="flex-1 flex flex-col min-h-0"> <div className="flex-1 flex flex-col min-h-0">
{/* Dialogue Header */} {/* Dialogue Header */}
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-slate-50/50"> <div ref={headerDzRef} className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-slate-50/50">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MessageSquare className="w-4.5 h-4.5 text-primary" strokeWidth={2.5} /> <MessageSquare className="w-4.5 h-4.5 text-primary" strokeWidth={2.5} />
<h3 className="text-sm font-extrabold text-slate-900"> <h3 className="text-sm font-extrabold text-slate-900">
@@ -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";
@@ -79,6 +81,8 @@ import {
getAvatarTypeFor, getAvatarTypeFor,
type RoleEntry, type RoleEntry,
} from "@/roundtable-planning/lib/roundtable-constants"; } from "@/roundtable-planning/lib/roundtable-constants";
import { desensitize } from "@/lib/desensitize";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning"; import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent"; import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
@@ -403,10 +407,13 @@ export function Step2Panel(props: Step2PanelProps) {
isOrchestrating || isOrchestrating ||
step2RoundtableDialogues.some((dialogue) => dialogue.streaming === true); step2RoundtableDialogues.some((dialogue) => dialogue.streaming === true);
// 顶部标题栏整片脱敏(消息列表正文不在此容器,保持原文)。
const headerDzRef = useDesensitizeSubtree<HTMLDivElement>();
const chatBody = ( const chatBody = (
<> <>
{/* Headline */} {/* Headline */}
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm"> <div ref={headerDzRef} className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Cpu className="w-5 h-5 text-primary" strokeWidth={2.5} /> <Cpu className="w-5 h-5 text-primary" strokeWidth={2.5} />
<div> <div>
@@ -582,7 +589,7 @@ export function Step2Panel(props: Step2PanelProps) {
/> />
) : ( ) : (
<div className="py-6 text-center text-xs text-slate-400"> <div className="py-6 text-center text-xs text-slate-400">
{seat.displayName} · 等待输出… {desensitize(seat.displayName)} · 等待输出…
</div> </div>
)} )}
</div> </div>
@@ -597,7 +604,7 @@ export function Step2Panel(props: Step2PanelProps) {
<span className="roundtable-chat-meta font-medium text-slate-600"> <span className="roundtable-chat-meta font-medium text-slate-600">
仅显示{" "} 仅显示{" "}
<span className="font-bold text-primary"> <span className="font-bold text-primary">
{seatFilterLabel(seatFilter)} {desensitize(seatFilterLabel(seatFilter))}
</span>{" "} </span>{" "}
的发言 的发言
</span> </span>
@@ -613,7 +620,7 @@ export function Step2Panel(props: Step2PanelProps) {
)} )}
{seatFilter && visibleDialogues.length === 0 ? ( {seatFilter && visibleDialogues.length === 0 ? (
<div className="py-10 text-center text-xs text-slate-400"> <div className="py-10 text-center text-xs text-slate-400">
没有匹配「{seatFilterLabel(seatFilter)}」的消息 没有匹配「{desensitize(seatFilterLabel(seatFilter))}」的消息
</div> </div>
) : ( ) : (
visibleDialogues.map((msg) => ( visibleDialogues.map((msg) => (
@@ -876,7 +883,7 @@ export function Step2Message({
title={ title={
isAvatarSelected isAvatarSelected
? "取消筛选,显示全部发言" ? "取消筛选,显示全部发言"
: `只看 ${msg.sender} 的发言` : `只看 ${desensitize(msg.sender)} 的发言`
} }
className={cn( className={cn(
"shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60", "shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60",
@@ -909,6 +916,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 +948,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} />;
}
/** /**
* 收集气泡里所有"写了文件"的产出路径,按出现顺序去重,聚合到一行文件卡片。 * 收集气泡里所有"写了文件"的产出路径,按出现顺序去重,聚合到一行文件卡片。
* *
@@ -116,6 +116,8 @@ export function Step2SandboxLayout({
threadId={previewThreadId} threadId={previewThreadId}
onClose={artifactsFill && onArtifactsClose ? handleArtifactsClose : undefined} onClose={artifactsFill && onArtifactsClose ? handleArtifactsClose : undefined}
allowVirtualMarkdownEdit allowVirtualMarkdownEdit
// 会商「消息列表沙箱」按需求不脱敏产物内容(用户要拿原始文件)。
disableDesensitize
/> />
) : ( ) : (
<div className="relative flex size-full justify-center"> <div className="relative flex size-full justify-center">
@@ -35,6 +35,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { desensitizeDeep } from "@/lib/desensitize";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts"; import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp"; import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp";
import { adaptReportData } from "@/roundtable-planning/dashboard/data-context"; import { adaptReportData } from "@/roundtable-planning/dashboard/data-context";
@@ -101,10 +102,12 @@ export function Step3DashboardMode({
} = dashboard; } = dashboard;
// 真实数据(report-json):优先本轮实时生成,其次草稿快照。应用渲染/网页渲染共用。 // 真实数据(report-json):优先本轮实时生成,其次草稿快照。应用渲染/网页渲染共用。
const report = useMemo( // 整体深度脱敏后再渲染——大屏(DashboardApp)与自包含 HTML(iframe/新窗口/分享/下载)
() => dashboard.data ?? parseReportJson(savedReport?.dashboardJson ?? null), // 都从这份 report 派生,故在此一处脱敏即覆盖大屏的所有展示文本(会商页非沙箱内容需脱敏)。
[dashboard.data, savedReport?.dashboardJson], const report = useMemo(() => {
); const raw = dashboard.data ?? parseReportJson(savedReport?.dashboardJson ?? null);
return raw ? desensitizeDeep(raw) : raw;
}, [dashboard.data, savedReport?.dashboardJson]);
// 应用渲染用的大屏数据(不传则 DashboardApp 回落 DEMO)。 // 应用渲染用的大屏数据(不传则 DashboardApp 回落 DEMO)。
const dashboardData = useMemo( const dashboardData = useMemo(
@@ -44,6 +44,7 @@ import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/Co
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel"; import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import { desensitize } from "@/lib/desensitize";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components"; import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts"; import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import { Step3DashboardMode } from "@/roundtable-planning/components/Step3DashboardMode"; import { Step3DashboardMode } from "@/roundtable-planning/components/Step3DashboardMode";
@@ -407,7 +408,7 @@ export function Step3ReportMode({
> >
{consensusText ? ( {consensusText ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText} {desensitize(consensusText)}
</ReactMarkdown> </ReactMarkdown>
) : ( ) : (
<p className="text-xs text-slate-400 italic">暂无总控结论。</p> <p className="text-xs text-slate-400 italic">暂无总控结论。</p>
@@ -773,14 +774,14 @@ function SeatDeliveriesView({ seatDeliveries }: { seatDeliveries: SeatDelivery[]
> >
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<ListOrdered className="w-3.5 h-3.5 text-slate-400 shrink-0" /> <ListOrdered className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.sender}</span> <span className="text-[11.5px] font-bold text-slate-800 truncate">{desensitize(seat.sender)}</span>
</div> </div>
<span className="text-[10px] text-slate-400 font-mono shrink-0">{seat.content.length} 字</span> <span className="text-[10px] text-slate-400 font-mono shrink-0">{seat.content.length} 字</span>
</button> </button>
{open && ( {open && (
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white"> <div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{seat.content || "(空)"} {desensitize(seat.content || "(空)")}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
)} )}
@@ -51,6 +51,7 @@ import {
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel"; import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import { desensitize } from "@/lib/desensitize";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components"; import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode"; import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts"; import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
@@ -449,7 +450,7 @@ export function Step3SummaryMode({
> >
{consensusText ? ( {consensusText ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText} {desensitize(consensusText)}
</ReactMarkdown> </ReactMarkdown>
) : ( ) : (
<p className="text-xs text-slate-400 italic">暂无总控结论。</p> <p className="text-xs text-slate-400 italic">暂无总控结论。</p>
@@ -846,14 +847,14 @@ function SeatDeliveriesView({ seatDeliveries }: { seatDeliveries: SeatDelivery[]
> >
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" /> <FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.sender}</span> <span className="text-[11.5px] font-bold text-slate-800 truncate">{desensitize(seat.sender)}</span>
</div> </div>
<span className="text-[10px] text-slate-400 font-mono shrink-0">{seat.content.length} 字</span> <span className="text-[10px] text-slate-400 font-mono shrink-0">{seat.content.length} 字</span>
</button> </button>
{open && ( {open && (
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white"> <div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}> <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{seat.content || "(空)"} {desensitize(seat.content || "(空)")}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
)} )}
@@ -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: "在专业问答基础上,每个席位可调用子代理分工协作,能力最强、更慢更耗时",
},
];
@@ -24,6 +24,7 @@ import {
import { ChainOfThoughtSearchResult } from "@/components/ai-elements/chain-of-thought"; import { ChainOfThoughtSearchResult } from "@/components/ai-elements/chain-of-thought";
import { CodeBlock } from "@/components/ai-elements/code-block"; import { CodeBlock } from "@/components/ai-elements/code-block";
import { desensitize } from "@/lib/desensitize";
import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble"; import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble";
/** /**
@@ -52,7 +53,7 @@ export function renderStepArgs(
return ( return (
<> <>
{displayName && ( {displayName && (
<ChainOfThoughtSearchResult>{displayName}</ChainOfThoughtSearchResult> <ChainOfThoughtSearchResult>{desensitize(displayName)}</ChainOfThoughtSearchResult>
)} )}
{task && ( {task && (
<div className="roundtable-chat-message mt-1 max-h-64 overflow-y-auto rounded-md border border-slate-200 bg-white px-2.5 py-1 text-slate-600"> <div className="roundtable-chat-message mt-1 max-h-64 overflow-y-auto rounded-md border border-slate-200 bg-white px-2.5 py-1 text-slate-600">
@@ -0,0 +1,67 @@
/**
* 区域级脱敏 Hook —— 把某个容器(DOM 子树)里的**所有文本节点**做脱敏,
* 用于「左/右侧面板 + 顶部标题」这类需要整片脱敏的区域:静态标题、动态内容、
* 嵌套子组件文本一并覆盖,无需逐串包 `desensitize()`(那样易漏、难维护)。
*
* 用法:`const ref = useDesensitizeSubtree<HTMLDivElement>(); <section ref={ref}>…</section>`。
*
* 设计要点:
* - 只读改文本节点(display-only),不碰 `<input>/<textarea>` 的 value、不改 React 状态/回传值;
* - `desensitize` 幂等(替换词不再命中词表),MutationObserver 重入安全;改写前先 disconnect 防自触发循环;
* - 想在区域内**保留原文**(如内嵌沙箱/消息列表),给该子树根加 `data-no-desensitize` 属性即可跳过。
*
* 注意:**不要**用它包「消息列表正文」或「沙箱产物预览」——这两处按需求保持原文,
* 应作为独立容器、不挂本 Hook(智能体名称的脱敏单独在 MessageBubble 的 senderName 处理)。
*/
import { useEffect, useRef } from "react";
import { desensitize } from "@/lib/desensitize";
const _SKIP_TAGS = new Set(["SCRIPT", "STYLE", "TEXTAREA", "INPUT", "IFRAME", "CODE", "PRE"]);
export function useDesensitizeSubtree<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
useEffect(() => {
const root = ref.current;
if (!root || typeof MutationObserver === "undefined") return;
let observer: MutationObserver | null = null;
const apply = () => {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
if (_SKIP_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT;
// 显式标记 data-no-desensitize 的子树保留原文(如内嵌沙箱)。
if (parent.closest("[data-no-desensitize]")) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
},
});
const edits: Array<[Text, string]> = [];
let current = walker.nextNode();
while (current) {
const textNode = current as Text;
const original = textNode.nodeValue ?? "";
if (original.trim()) {
const masked = desensitize(original);
if (masked !== original) edits.push([textNode, masked]);
}
current = walker.nextNode();
}
if (edits.length === 0) return;
// 改写期间停掉观察,避免自身改动再次触发,改完恢复。
observer?.disconnect();
for (const [textNode, masked] of edits) textNode.nodeValue = masked;
observer?.observe(root, { subtree: true, childList: true, characterData: true });
};
apply();
observer = new MutationObserver(() => apply());
observer.observe(root, { subtree: true, childList: true, characterData: true });
return () => observer?.disconnect();
});
return ref;
}
@@ -63,6 +63,7 @@ import {
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { listAgents } from "@/core/agents"; import { listAgents } from "@/core/agents";
@@ -85,6 +86,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,
@@ -129,6 +135,8 @@ type ActiveDrag =
export default function BusinessChainEditorPage() { export default function BusinessChainEditorPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
// 整片脱敏:链条配置页展示文本(链条名/描述/席位名/标题)。可编辑的 input/textarea 不脱敏(Hook 跳过),保证编辑用原文。
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
// ─── 数据 ──────────────────────────────────────────────────────────── // ─── 数据 ────────────────────────────────────────────────────────────
const [pool, setPool] = useState<RecommendCandidate[]>([]); const [pool, setPool] = useState<RecommendCandidate[]>([]);
@@ -153,6 +161,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 +287,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 +299,7 @@ export default function BusinessChainEditorPage() {
setTitle(""); setTitle("");
setDescription(""); setDescription("");
setCoordinatorPrompt(""); setCoordinatorPrompt("");
setSeatMode(DEFAULT_SEAT_MODE);
setStageGroups([]); setStageGroups([]);
setStep(1); setStep(1);
}, []); }, []);
@@ -560,6 +572,7 @@ export default function BusinessChainEditorPage() {
stages, stages,
stageGoals, stageGoals,
coordinatorPrompt: coordPrompt, coordinatorPrompt: coordPrompt,
seatMode,
}); });
if (!updated) { if (!updated) {
toast.error("链条已不存在,可能被删除"); toast.error("链条已不存在,可能被删除");
@@ -578,6 +591,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 +606,7 @@ export default function BusinessChainEditorPage() {
title, title,
description, description,
coordinatorPrompt, coordinatorPrompt,
seatMode,
stageGroups, stageGroups,
seatCountInvalid, seatCountInvalid,
currentChainId, currentChainId,
@@ -699,6 +714,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">
@@ -761,7 +807,7 @@ export default function BusinessChainEditorPage() {
); );
return ( return (
<div className="flex h-full min-h-0 flex-col bg-muted/40 text-foreground"> <div ref={dzRef} className="flex h-full min-h-0 flex-col bg-muted/40 text-foreground">
{/* 顶栏 */} {/* 顶栏 */}
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-border bg-card px-6 py-3"> <header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-border bg-card px-6 py-3">
<button <button
@@ -62,6 +62,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { useRouteNavigate } from "@/core/route-base"; import { useRouteNavigate } from "@/core/route-base";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { deleteChain, listChains, setChainPublic, type ChainScope } from "../api/chains"; import { deleteChain, listChains, setChainPublic, type ChainScope } from "../api/chains";
import { chainTagClasses, type BusinessChain } from "../lib/business-chain"; import { chainTagClasses, type BusinessChain } from "../lib/business-chain";
@@ -79,6 +80,8 @@ const BRAND =
export default function BusinessChainListPage() { export default function BusinessChainListPage() {
const navigate = useRouteNavigate(); const navigate = useRouteNavigate();
// 整片脱敏:链条列表页展示文本(链条名/描述/席位名/标题)。
const dzRef = useDesensitizeSubtree<HTMLDivElement>();
// 进入编辑器:带 chainId = 打开并切到该链条;不带 = 空白新建。 // 进入编辑器:带 chainId = 打开并切到该链条;不带 = 空白新建。
// 路径写 workspace base,`useRouteNavigate` 会按当前路由 base 改写。 // 路径写 workspace base,`useRouteNavigate` 会按当前路由 base 改写。
@@ -233,7 +236,7 @@ export default function BusinessChainListPage() {
}, [chains, search, sortBy]); }, [chains, search, sortBy]);
return ( return (
<div className="flex h-full min-h-0 w-full flex-col bg-zinc-50/60 font-sans text-zinc-900 antialiased dark:bg-[#0C1836] dark:text-[#C5CFE0]"> <div ref={dzRef} className="flex h-full min-h-0 w-full flex-col bg-zinc-50/60 font-sans text-zinc-900 antialiased dark:bg-[#0C1836] dark:text-[#C5CFE0]">
{/* 顶栏:标题 + 描述 + 新建链条 */} {/* 顶栏:标题 + 描述 + 新建链条 */}
<header className="shrink-0 border-b border-zinc-200/80 bg-white/85 px-6 py-4 backdrop-blur-md dark:border-[#1A2F5A] dark:bg-[#122347]/80"> <header className="shrink-0 border-b border-zinc-200/80 bg-white/85 px-6 py-4 backdrop-blur-md dark:border-[#1A2F5A] dark:bg-[#122347]/80">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="mx-auto flex w-full max-w-7xl flex-col gap-4 md:flex-row md:items-center md:justify-between">
@@ -38,6 +38,7 @@ import {
Cpu, Cpu,
Download, Download,
History, History,
Layers,
ListOrdered, ListOrdered,
Loader2, Loader2,
Pencil, Pencil,
@@ -54,7 +55,9 @@ import {
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { useArtifacts } from "@/components/workspace/artifacts"; import { useArtifacts } from "@/components/workspace/artifacts";
import { useSidebarSafe } from "@/components/ui/sidebar"; import { useSidebarSafe } from "@/components/ui/sidebar";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import { getAvatar, Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars"; import { getAvatar, Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
@@ -98,8 +101,10 @@ import {
} from "@/roundtable-planning/lib/seat-filter"; } from "@/roundtable-planning/lib/seat-filter";
import { import {
buildChainSeatSelection, buildChainSeatSelection,
type BusinessChain,
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,7 +113,9 @@ import {
startJob, startJob,
startJobKeepalive, startJobKeepalive,
} from "@/roundtable-planning/api/roundtable-jobs"; } from "@/roundtable-planning/api/roundtable-jobs";
import { getChain } from "@/roundtable-planning/api/chains"; import { useRoundtableJobNotifier } from "@/roundtable-planning/components/RoundtableJobNotifier";
import { getChain, listChains } from "@/roundtable-planning/api/chains";
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend"; import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend";
import { listAgents } from "@/core/agents"; import { listAgents } from "@/core/agents";
import { listBusinessMappings } from "@/strategy-components/api/business-mapping"; import { listBusinessMappings } from "@/strategy-components/api/business-mapping";
@@ -169,6 +176,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 +231,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 +349,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 +763,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");
@@ -766,6 +781,34 @@ export default function RoundtablePlanningPage() {
[buildCurrentStep2Snapshot, drafts, mergeActiveRun, step2, step3Report, triggerToast], [buildCurrentStep2Snapshot, drafts, mergeActiveRun, step2, step3Report, triggerToast],
); );
// 需求 0616 #2:Step1 左栏点击某条业务链 → 仅**勾选**该链(不直接进 Step 2)。
// 复用 autoChainResult 机制:用户在第一步完成意图澄清后,点「进入多智能体研讨」时
// onEnterRoundtable 的 `if (autoChainResult)` 分支会据此跳过选链弹窗、按该链进研讨。
// 再次点击同一条链 = 取消勾选。
const handleSelectChainFromSidebar = useCallback(
async (chain: BusinessChain) => {
if (autoChainResult?.chain?.id === chain.id) {
setAutoChainResult(null);
triggerToast(`已取消业务链「${chain.title}」`);
return;
}
try {
const agents = await listAgents().catch(() => []);
const candidates = filterRecommendCandidates(agents);
const result = buildChainSeatSelection(chain, candidates);
if (!result) {
triggerToast("⚠️ 该业务链条暂无法选用(匹配到的席位不足)");
return;
}
setAutoChainResult(result);
triggerToast(`✅ 已选择业务链「${chain.title}」,完成第一步澄清后点「进入多智能体研讨」`);
} catch {
triggerToast("❌ 选择业务链失败,请重试");
}
},
[autoChainResult, triggerToast],
);
// ─── 会商自动化串联(模块 C) ────────────────────────────────── // ─── 会商自动化串联(模块 C) ──────────────────────────────────
// 消费 prefill 后清掉 history state,避免刷新 / 后退重放自动开跑与自动选链。 // 消费 prefill 后清掉 history state,避免刷新 / 后退重放自动开跑与自动选链。
useEffect(() => { useEffect(() => {
@@ -1133,12 +1176,36 @@ export default function RoundtablePlanningPage() {
}); });
}, [resetToStep1]); }, [resetToStep1]);
// 「挂起中」本地标记:点「后台挂起」后立刻跳转,建作业是 fire-and-forget——从点击到作业
// 真正建好的窗口里,用它让历史记录显示「挂起中」(作业建好后转 queued/running,由后端状态接管)。
// resetToStep1 不卸载本页,故该 state 跨「跳转」存活。
const [pendingSuspendIds, setPendingSuspendIds] = useState<Set<string>>(() => new Set());
// 整片脱敏:顶部页头(步骤导航 / 历史记录 / 标题等)的所有文本。
const headerDzRef = useDesensitizeSubtree<HTMLElement>();
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 +1215,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 +1356,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 +1481,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 ref={headerDzRef} 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 +1542,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}
@@ -1457,6 +1592,9 @@ export default function RoundtablePlanningPage() {
onSelectStep2Run={handleSelectStep2Run} onSelectStep2Run={handleSelectStep2Run}
seatFilter={seatFilter} seatFilter={seatFilter}
setSeatFilter={setSeatFilter} setSeatFilter={setSeatFilter}
selectedChainId={autoChainResult?.chain?.id ?? null}
onSelectChain={handleSelectChainFromSidebar}
onOpenDraft={drafts.handleLoadDraft}
/> />
{/* MIDDLE VIEWPORT */} {/* MIDDLE VIEWPORT */}
@@ -1733,6 +1871,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 +1895,7 @@ function HistoryDropdown(props: HistoryDropdownProps) {
setIsOpen, setIsOpen,
drafts, drafts,
currentDraftId, currentDraftId,
pendingSuspendIds,
renamingDraftId, renamingDraftId,
renameDraftValue, renameDraftValue,
setRenameDraftValue, setRenameDraftValue,
@@ -1825,6 +1966,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 +2005,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" />
进行中 进行中
@@ -1946,6 +2094,12 @@ interface LeftSidebarProps {
/** 第二步「按席位筛选会商记录」—— 点角色卡片筛选,与中间消息头像共用。 */ /** 第二步「按席位筛选会商记录」—— 点角色卡片筛选,与中间消息头像共用。 */
seatFilter: SeatFilter | null; seatFilter: SeatFilter | null;
setSeatFilter: React.Dispatch<React.SetStateAction<SeatFilter | null>>; setSeatFilter: React.Dispatch<React.SetStateAction<SeatFilter | null>>;
/** 需求 0616 #2:当前已勾选的业务链 id(高亮用),来自 autoChainResult.chain.id。 */
selectedChainId: string | null;
/** 需求 0616 #2:Step1 落地左栏「业务链」点击 → 勾选该链(不直接进研讨)。 */
onSelectChain: (chain: BusinessChain) => void;
/** 需求 0616 #2:Step1 落地左栏「分析记录」点击 → 打开历史会商分析(加载草稿)。 */
onOpenDraft: (draftId: string) => void;
} }
function Step2SandboxSidebarSync({ function Step2SandboxSidebarSync({
@@ -1987,7 +2141,13 @@ function LeftSidebar({
onSelectStep2Run, onSelectStep2Run,
seatFilter, seatFilter,
setSeatFilter, setSeatFilter,
selectedChainId,
onSelectChain,
onOpenDraft,
}: LeftSidebarProps) { }: LeftSidebarProps) {
// 整片脱敏:左侧面板(任务概览/参与角色/人工参与等)的所有文本——标题、动态内容、
// 嵌套子组件文本一并脱敏。消息列表与沙箱是另外的容器,不挂此 Hook,保持原文。
const dzRef = useDesensitizeSubtree<HTMLElement>();
const isStep2 = currentStep === 2; const isStep2 = currentStep === 2;
// 仅第二步可按席位筛选会商记录;点中已选则取消(回到显示全部)。 // 仅第二步可按席位筛选会商记录;点中已选则取消(回到显示全部)。
const toggleSeatFilter = (filter: SeatFilter) => const toggleSeatFilter = (filter: SeatFilter) =>
@@ -1999,6 +2159,7 @@ function LeftSidebar({
return ( return (
<section <section
ref={dzRef}
id="left-sidebar" id="left-sidebar"
className={cn( className={cn(
"shrink-0 flex flex-col gap-3 overflow-y-auto pr-1 transition-[width] duration-200 ease-out", "shrink-0 flex flex-col gap-3 overflow-y-auto pr-1 transition-[width] duration-200 ease-out",
@@ -2113,28 +2274,147 @@ function LeftSidebar({
</> </>
) )
) : ( ) : (
<> // Step 1(落地,未开始研讨):左栏改为业务链 + 分析记录入口(需求 0616 #2)。
<TaskOverviewCard currentStep={currentStep} /> <RoundtableEntryRail
selectedChainId={selectedChainId}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[300px]"> onSelectChain={onSelectChain}
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3"> onOpenDraft={onOpenDraft}
<Users className="w-4 h-4 text-primary" strokeWidth={2.5} /> />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">参与角色</h2>
</div>
<div className="space-y-2.5 overflow-y-auto flex-1 pr-1 custom-scrollbar">
{roles.map((role) => (
<RoleRow key={role.id} role={role} agentNameToRole={agentNameToRole} />
))}
</div>
</div>
<HumanParticipationCard />
</>
)} )}
</section> </section>
); );
} }
/**
* 需求 0616 #2:多智能体会商 Step1(落地)左栏入口栏。
* - 业务链:仅公共链条(``listChains("public")``)前 3 个;点击以该链席位启动会商。
* - 分析记录:圆桌历史草稿(``useRoundtableDrafts``);点击打开对应历史会商分析。
* 进入研讨(Step 2/3)后左栏恢复为「参与角色 / 任务概览」工作面板。
*/
function RoundtableEntryRail({
selectedChainId,
onSelectChain,
onOpenDraft,
}: {
selectedChainId: string | null;
onSelectChain: (chain: BusinessChain) => void;
onOpenDraft: (draftId: string) => void;
}) {
const [chains, setChains] = useState<BusinessChain[]>([]);
const { drafts: records, refresh } = useRoundtableDrafts();
useEffect(() => {
let cancelled = false;
void listChains("public")
.then((all) => {
if (!cancelled) setChains(all.slice(0, 3));
})
.catch(() => {
/* 业务链非关键,静默忽略 */
});
void refresh();
return () => {
cancelled = true;
};
}, [refresh]);
const formatTime = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
};
return (
<>
{/* 业务链(公共,前 3 个) */}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
<Layers className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">业务链</h2>
</div>
{chains.length === 0 ? (
<p className="text-xs text-slate-400 py-1.5">暂无公共业务链</p>
) : (
<div className="space-y-2">
{chains.map((chain) => {
const selected = selectedChainId === chain.id;
return (
<button
key={chain.id}
type="button"
onClick={() => onSelectChain(chain)}
title={selected ? "再次点击取消勾选" : `勾选业务链「${chain.title}」`}
aria-pressed={selected}
className={cn(
"w-full rounded-xl border p-2.5 text-left transition-colors",
selected
? "border-primary/40 bg-primary/5"
: "border-slate-100 bg-slate-50/60 hover:border-primary/30 hover:bg-white",
)}
>
<div className="flex items-center justify-between gap-2">
<span
className={cn(
"truncate text-xs font-extrabold",
selected ? "text-primary" : "text-slate-800",
)}
>
{chain.title}
</span>
{selected ? (
<Check className="h-3.5 w-3.5 shrink-0 text-primary" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-slate-400" />
)}
</div>
<div className="mt-1 text-[10px] font-semibold text-slate-400">
{chain.seats.length} 个席位
{selected ? " · 已选" : ""}
</div>
</button>
);
})}
</div>
)}
</div>
{/* 分析记录(圆桌历史草稿) */}
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[200px]">
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
<History className="w-4 h-4 text-primary" strokeWidth={2.5} />
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">分析记录</h2>
</div>
{records.length === 0 ? (
<p className="text-xs text-slate-400 py-1.5">暂无分析记录</p>
) : (
<div className="space-y-2 overflow-y-auto flex-1 pr-1 custom-scrollbar">
{records.map((rec) => (
<button
key={rec.id}
type="button"
onClick={() => onOpenDraft(rec.id)}
title={rec.title}
className="w-full rounded-xl border border-slate-100 bg-slate-50/60 p-2.5 text-left transition-colors hover:border-primary/20 hover:bg-white"
>
<div className="truncate text-xs font-bold text-slate-700">{rec.title}</div>
<div className="mt-1 text-[10px] font-semibold text-slate-400">
{formatTime(rec.updatedAt)}
</div>
</button>
))}
</div>
)}
</div>
</>
);
}
function Step2RunSwitcher({ function Step2RunSwitcher({
runs, runs,
activeRunId, activeRunId,
@@ -2759,14 +3039,14 @@ function RoleAvatarBadge({
); );
if (!clickable) { if (!clickable) {
return <div title={`${role.name} · ${role.status}`}>{inner}</div>; return <div title={`${desensitize(role.name)} · ${role.status}`}>{inner}</div>;
} }
return ( return (
<button <button
type="button" type="button"
onClick={onSelect} onClick={onSelect}
aria-pressed={active} aria-pressed={active}
title={active ? "取消筛选,显示全部发言" : `只看 ${role.name} 的发言`} title={active ? "取消筛选,显示全部发言" : `只看 ${desensitize(role.name)} 的发言`}
className="shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60" className="shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
> >
{inner} {inner}
@@ -2806,7 +3086,7 @@ function RoleRow({
clickable clickable
? active ? active
? "取消筛选,显示全部发言" ? "取消筛选,显示全部发言"
: `只看 ${role.name} 的发言` : `只看 ${desensitize(role.name)} 的发言`
: undefined : undefined
} }
onClick={onSelect} onClick={onSelect}
@@ -2836,7 +3116,7 @@ function RoleRow({
<RoleAvatarBadge role={role} agentNameToRole={agentNameToRole} size="h-10 w-10" /> <RoleAvatarBadge role={role} agentNameToRole={agentNameToRole} size="h-10 w-10" />
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-bold leading-5 tracking-tight text-slate-800"> <span className="truncate text-sm font-bold leading-5 tracking-tight text-slate-800">
{role.name} {desensitize(role.name)}
</span> </span>
{role.type === "agent" && ( {role.type === "agent" && (
<span className="text-[11px] text-primary font-medium leading-4">AI 智能体</span> <span className="text-[11px] text-primary font-medium leading-4">AI 智能体</span>
@@ -2906,10 +3186,13 @@ function RightSidebar({
triggerToast, triggerToast,
onSaveIntent, onSaveIntent,
}: RightSidebarProps) { }: RightSidebarProps) {
// 整片脱敏:右侧面板(推荐模型 / 意图摘要 / 澄清记录)所有文本。
const dzRef = useDesensitizeSubtree<HTMLElement>();
// Step 2 侧栏内容已迁至左侧;Step 3 沿用原逻辑不展示右侧栏。 // Step 2 侧栏内容已迁至左侧;Step 3 沿用原逻辑不展示右侧栏。
if (currentStep !== 1) return null; if (currentStep !== 1) return null;
return ( return (
<section <section
ref={dzRef}
id="right-sidebar" id="right-sidebar"
className="w-[300px] shrink-0 flex flex-col gap-4 overflow-y-auto pl-1" className="w-[300px] shrink-0 flex flex-col gap-4 overflow-y-auto pl-1"
> >
+13 -12
View File
@@ -9,7 +9,10 @@ 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 { useMyPosition } from "@/core/positions"; import { useMyPosition } from "@/core/positions";
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";
@@ -148,7 +151,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;
} }
@@ -173,9 +176,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));
@@ -188,7 +189,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}
@@ -202,7 +203,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}
@@ -302,7 +303,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):菜单靠右贴近中间基座 */}
@@ -318,7 +319,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">
@@ -344,7 +345,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="一级菜单">
@@ -371,7 +372,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>
@@ -387,7 +388,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
@@ -9,6 +9,7 @@ import type { LightApp, LightAppRouteParam } from "@/strategy-components/api/lig
/** iframe 可挂载的父菜单(取自 core/page-layout/sidebar-menu.ts 的现有菜单 id)。 */ /** iframe 可挂载的父菜单(取自 core/page-layout/sidebar-menu.ts 的现有菜单 id)。 */
export const MOUNT_OPTIONS: { label: string; value: string }[] = [ export const MOUNT_OPTIONS: { label: string; value: string }[] = [
{ label: "XK中任务", value: "xk-center-task" }, { label: "XK中任务", value: "xk-center-task" },
{ label: "COH中任务", value: "coh-center-task" },
{ label: "PG中任务", value: "pg-center-task" }, { label: "PG中任务", value: "pg-center-task" },
{ label: "TY中任务", value: "ty-center-task" }, { label: "TY中任务", value: "ty-center-task" },
{ label: "资源管理", value: "resource-management" }, { label: "资源管理", value: "resource-management" },
+52 -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",
@@ -1013,3 +1016,51 @@ html.lightblue .workspace-assistant-card::before {
html.lightblue .tiptap-writing-editor .ProseMirror ::selection { html.lightblue .tiptap-writing-editor .ProseMirror ::selection {
background: color-mix(in srgb, #55a6fe 35%, transparent); background: color-mix(in srgb, #55a6fe 35%, transparent);
} }
/* ── 来源上传弹窗:A系统基因库的分段单选条(custom-radio) ─────────────── */
/* 外层容器,模拟 nz-radio-group */
.custom-radio-group {
display: inline-flex;
align-items: center;
font-size: 12px;
}
/* 单个按钮样式 */
.custom-radio-btn {
position: relative;
display: inline-block;
padding: 5px 16px;
line-height: 12px;
background: #fff;
border: 1px solid #d9d9d9;
cursor: pointer;
/* 除第一个外向左移 1px,实现边框重叠,消除双层边框 */
margin-left: -1px;
transition: all 0.1s ease;
user-select: none;
}
/* 第一个按钮:左侧圆角 */
.custom-radio-btn:first-child {
margin-left: 0;
border-radius: 4px 0 0 4px;
}
/* 最后一个按钮:右侧圆角 */
.custom-radio-btn:last-child {
border-radius: 0 4px 4px 0;
}
/* 悬浮状态 */
.custom-radio-btn:hover {
color: #1890ff;
z-index: 1;
}
/* 选中状态 */
.custom-radio-btn.active {
background-color: #e6f7ff;
border-color: #1890ff;
color: #1890ff;
z-index: 2;
}
+5 -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*`)
@@ -338,6 +338,10 @@ Admin-configurable, **access-control-free** categories that published agents/ski
Each agent/skill carries a single `square_id` column (`AgentRow` / `SkillRow`, `String(64)` NOT NULL default `''`, migration `20260611_01`, idempotent SQL in `column_additions_mysql.sql`). One column = at most one square, so an item can never be "published twice". `''` = unassigned ? resolved to the default square at read/filter time. Writes normalize via `normalize_square_id`: agents on create/update (`square_id` field), skills on publish (`PUT /api/skills/custom/{name}/published` accepts optional `square_id`). Listing filters: `GET /api/agents?square=` and `GET /api/skills?square=` keep only that square's items; the **default** square additionally sweeps in legacy `''` rows (`square_is_default`). Tests: `tests/test_publish_squares.py`. Frontend: `core/system-settings` (`usePublishSquares`/`useUpdatePublishSquares`), admin `PublishSquaresSettingsPage` (embedded in the appearance/settings hub), square picker in the agent edit dialog + per-skill card, and a ??-tab square sub-filter in both galleries. Each agent/skill carries a single `square_id` column (`AgentRow` / `SkillRow`, `String(64)` NOT NULL default `''`, migration `20260611_01`, idempotent SQL in `column_additions_mysql.sql`). One column = at most one square, so an item can never be "published twice". `''` = unassigned ? resolved to the default square at read/filter time. Writes normalize via `normalize_square_id`: agents on create/update (`square_id` field), skills on publish (`PUT /api/skills/custom/{name}/published` accepts optional `square_id`). Listing filters: `GET /api/agents?square=` and `GET /api/skills?square=` keep only that square's items; the **default** square additionally sweeps in legacy `''` rows (`square_is_default`). Tests: `tests/test_publish_squares.py`. Frontend: `core/system-settings` (`usePublishSquares`/`useUpdatePublishSquares`), admin `PublishSquaresSettingsPage` (embedded in the appearance/settings hub), square picker in the agent edit dialog + per-skill card, and a ??-tab square sub-filter in both galleries.
### Agent Module Tag Config (zzJC / zzXD 按类型聚合)
Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊天记录」). An admin picks which agent **tags** belong to a module; the module's left sidebar then shows only agents under those tags (empty/unconfigured = show all visible agents). Stored in `system_settings.json` → `agent_modules` (`AgentModuleSettings`: `modules: {module_key → [tag_id, ...]}`); pure helpers `get_module_tag_ids` / `set_module_tag_ids` (de-dupe + strip). Endpoints (in `app/gateway/routers/system_settings.py`): `GET /api/system-settings/agent-modules/{key}` (readable by any user — the sidebar needs it to filter), `PUT /api/system-settings/agent-modules/{key}` (admin only, full replacement of the tag-id list). Module keys are frontend-defined (`AGENT_MODULES` in `components/workspace/agents/agent-module-sidebar.tsx`, keyed by the module's landing agent id: `6bf`→`zzjc`, `10d69…`→`zzxd`). Tests: `tests/test_agent_modules.py`. Frontend: `core/system-settings` (`useAgentModuleConfig`/`useUpdateAgentModuleConfig`), admin `AgentModuleSettingsDialog` (gear button in the agent-chat header).
### 岗位 (Position) Subscription System ### 岗位 (Position) Subscription System
An admin-managed aggregation layer that decides, per user, which agents / skills An admin-managed aggregation layer that decides, per user, which agents / skills
@@ -176,6 +176,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.article_type_store = make_article_type_store(sf) app.state.article_type_store = make_article_type_store(sf)
from deerflow.persistence.research_templates import make_research_template_store
app.state.research_template_store = make_research_template_store(sf)
from deerflow.persistence.memory_config import make_user_memory_config_store from deerflow.persistence.memory_config import make_user_memory_config_store
app.state.memory_config_store = make_user_memory_config_store(sf) app.state.memory_config_store = make_user_memory_config_store(sf)
@@ -275,7 +279,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)")
@@ -448,6 +459,11 @@ def get_article_type_store(request: Request):
return getattr(request.app.state, "article_type_store", None) return getattr(request.app.state, "article_type_store", None)
def get_research_template_store(request: Request):
"""Return the research template store, or None when not configured."""
return getattr(request.app.state, "research_template_store", None)
def get_memory_config_store(request: Request): def get_memory_config_store(request: Request):
"""Return the per-user memory config store.""" """Return the per-user memory config store."""
val = getattr(request.app.state, "memory_config_store", None) val = getattr(request.app.state, "memory_config_store", None)
@@ -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 实例。
@@ -90,6 +90,48 @@ class ArticleTypeResponse(BaseModel):
updated_at: str updated_at: str
class ResearchTemplateRequest(BaseModel):
name: str
description: Optional[str] = None
article_type_key: Optional[str] = None
outline: Optional[str] = None
material_source: str = "general"
word_count: int = 800
audience: Optional[str] = None
strict_mode: bool = False
topic: Optional[str] = None
sort_order: int = 0
class UpdateResearchTemplateRequest(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
article_type_key: Optional[str] = None
outline: Optional[str] = None
material_source: Optional[str] = None
word_count: Optional[int] = None
audience: Optional[str] = None
strict_mode: Optional[bool] = None
topic: Optional[str] = None
sort_order: Optional[int] = None
class ResearchTemplateResponse(BaseModel):
id: str
name: str
description: Optional[str] = None
article_type_key: Optional[str] = None
outline: Optional[str] = None
material_source: str
word_count: int
audience: Optional[str] = None
strict_mode: bool
topic: Optional[str] = None
sort_order: int
created_at: str
updated_at: str
class SessionResponse(BaseModel): class SessionResponse(BaseModel):
id: str id: str
user_id: Optional[str] = None user_id: Optional[str] = None
@@ -268,6 +310,94 @@ async def delete_article_type(article_type_id: str, request: Request):
return {"success": True} return {"success": True}
# ── 研究模板(课题研究左栏一键预填表单,需求 0616 #5)────────────────────────────
@router.get("/research-templates", response_model=list[ResearchTemplateResponse])
async def list_research_templates(request: Request):
"""列出所有研究模板,按 sort_order 升序。
新机器/空表时自动写入内置范例模板(网摘/周报/要讯),幂等仅在表为空时执行。
"""
from app.gateway.deps import get_research_template_store
store = get_research_template_store(request)
if store is None:
return []
try:
await store.seed_defaults()
except Exception:
logger.exception("ai_writing: failed to seed default research templates")
return await store.list()
@router.post("/research-templates", response_model=ResearchTemplateResponse, status_code=201)
async def create_research_template(body: ResearchTemplateRequest, request: Request):
"""新增研究模板。"""
from app.gateway.deps import get_research_template_store
store = get_research_template_store(request)
if store is None:
raise HTTPException(status_code=503, detail="Research template store not available")
return await store.create(
name=body.name,
description=body.description,
article_type_key=body.article_type_key,
outline=body.outline,
material_source=body.material_source,
word_count=body.word_count,
audience=body.audience,
strict_mode=body.strict_mode,
topic=body.topic,
sort_order=body.sort_order,
)
@router.patch("/research-templates/{template_id}", response_model=ResearchTemplateResponse)
async def update_research_template(template_id: str, body: UpdateResearchTemplateRequest, request: Request):
"""更新研究模板字段(只传需要修改的字段)。"""
from app.gateway.deps import get_research_template_store
store = get_research_template_store(request)
if store is None:
raise HTTPException(status_code=503, detail="Research template store not available")
kwargs = {}
for field in (
"name",
"description",
"article_type_key",
"outline",
"material_source",
"word_count",
"audience",
"strict_mode",
"topic",
"sort_order",
):
val = getattr(body, field)
if val is not None:
kwargs[field] = val
updated = await store.update(template_id, **kwargs)
if updated is None:
raise HTTPException(status_code=404, detail="Research template not found")
return updated
@router.delete("/research-templates/{template_id}")
async def delete_research_template(template_id: str, request: Request):
"""删除研究模板。"""
from app.gateway.deps import get_research_template_store
store = get_research_template_store(request)
if store is None:
raise HTTPException(status_code=503, detail="Research template store not available")
deleted = await store.delete(template_id)
if not deleted:
raise HTTPException(status_code=404, detail="Research template not found")
return {"success": True}
# ── 管理接口 ────────────────────────────────────────────────────────────────── # ── 管理接口 ──────────────────────────────────────────────────────────────────
@@ -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)
@@ -18,10 +18,12 @@ from deerflow.config.system_settings import (
PublishSquaresSettings, PublishSquaresSettings,
SkillCompressionSettings, SkillCompressionSettings,
SystemSettings, SystemSettings,
get_module_tag_ids,
list_publish_squares, list_publish_squares,
load_system_settings, load_system_settings,
resolve_default_square_id, resolve_default_square_id,
save_system_settings, save_system_settings,
set_module_tag_ids,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -436,3 +438,35 @@ async def get_log_system(request: Request) -> LogSystemResponse:
await _require_admin(request) await _require_admin(request)
cfg = get_app_config().log_system cfg = get_app_config().log_system
return LogSystemResponse(enabled=cfg.enabled, url=cfg.url, label=cfg.label) return LogSystemResponse(enabled=cfg.enabled, url=cfg.url, label=cfg.label)
class AgentModuleConfigResponse(BaseModel):
"""Selected agent-tag ids for a「按类型聚合」module (zzJC / zzXD)."""
key: str = Field(..., description="Module key, e.g. zzjc / zzxd")
tag_ids: list[str] = Field(default_factory=list, description="Selected agent tag ids")
class AgentModuleConfigUpdateRequest(BaseModel):
tag_ids: list[str] = Field(default_factory=list, description="Full replacement list of selected agent tag ids")
@router.get("/agent-modules/{key}", response_model=AgentModuleConfigResponse)
async def get_agent_module_config(key: str) -> AgentModuleConfigResponse:
"""Return the agent tag ids configured for ``key`` (readable by any user —
the module's left sidebar needs it to filter the agent list)."""
settings = load_system_settings().agent_modules
return AgentModuleConfigResponse(key=key, tag_ids=get_module_tag_ids(settings, key))
@router.put("/agent-modules/{key}", response_model=AgentModuleConfigResponse)
async def update_agent_module_config(
key: str, body: AgentModuleConfigUpdateRequest, request: Request
) -> AgentModuleConfigResponse:
"""Replace the agent tag ids configured for ``key``. Admin only."""
await _require_admin(request)
current = load_system_settings()
updated = set_module_tag_ids(current.agent_modules, key, body.tag_ids)
saved = save_system_settings(current.model_copy(update={"agent_modules": updated}))
return AgentModuleConfigResponse(key=key, tag_ids=get_module_tag_ids(saved.agent_modules, key))
+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:保留旧素材 + 追加新检索结果
@@ -190,6 +190,37 @@ class SkillCompressionSettings(BaseModel):
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
class AgentModuleSettings(BaseModel):
"""Admin-configured「按类型聚合」module → selected agent-tag ids.
Keyed by module key (e.g. ``"zzjc"`` / ``"zzxd"``). Each value is the list
of agent **tag ids** whose agents that module's left sidebar shows. A missing
key / empty list means "not configured" — the frontend falls back to showing
all visible agents grouped by tag.
"""
modules: dict[str, list[str]] = Field(
default_factory=dict,
description="module key → selected agent tag ids",
)
model_config = ConfigDict(extra="ignore")
def get_module_tag_ids(settings: AgentModuleSettings, key: str) -> list[str]:
"""Return the configured agent-tag ids for ``key`` (empty when unset)."""
return list(settings.modules.get(key, []))
def set_module_tag_ids(
settings: AgentModuleSettings, key: str, tag_ids: list[str]
) -> AgentModuleSettings:
"""Return a copy with ``key`` set to a de-duplicated, stripped tag-id list."""
cleaned = list(dict.fromkeys([str(t).strip() for t in tag_ids if str(t).strip()]))
modules = dict(settings.modules)
modules[key] = cleaned
return settings.model_copy(update={"modules": modules})
class SystemSettings(BaseModel): class SystemSettings(BaseModel):
"""Top-level system settings document.""" """Top-level system settings document."""
@@ -200,6 +231,7 @@ class SystemSettings(BaseModel):
citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings) citation_display: CitationDisplaySettings = Field(default_factory=CitationDisplaySettings)
leaderboard: LeaderboardSettings = Field(default_factory=LeaderboardSettings) leaderboard: LeaderboardSettings = Field(default_factory=LeaderboardSettings)
skill_compression: SkillCompressionSettings = Field(default_factory=SkillCompressionSettings) skill_compression: SkillCompressionSettings = Field(default_factory=SkillCompressionSettings)
agent_modules: AgentModuleSettings = Field(default_factory=AgentModuleSettings)
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
@@ -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")
@@ -45,6 +45,7 @@ from deerflow.persistence.notifications.model import NotificationRow
from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow from deerflow.persistence.parallel_agent_tasks.model import ParallelAgentTaskRow
from deerflow.persistence.positions.model import PositionRow, PositionTagBindingRow from deerflow.persistence.positions.model import PositionRow, PositionTagBindingRow
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
from deerflow.persistence.research_templates.model import ResearchTemplateRow
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
from deerflow.persistence.roundtable_draft_shares.model import RoundtableDraftShareRow from deerflow.persistence.roundtable_draft_shares.model import RoundtableDraftShareRow
from deerflow.persistence.roundtable_drafts.model import ( from deerflow.persistence.roundtable_drafts.model import (
@@ -96,6 +97,7 @@ __all__ = [
"PositionRow", "PositionRow",
"PositionTagBindingRow", "PositionTagBindingRow",
"RecommendedQuestionRow", "RecommendedQuestionRow",
"ResearchTemplateRow",
"RoundtableChainRow", "RoundtableChainRow",
"RoundtableDraftRow", "RoundtableDraftRow",
"RoundtableDraftShareRow", "RoundtableDraftShareRow",
@@ -0,0 +1,15 @@
from deerflow.persistence.research_templates.model import ResearchTemplateRow
from deerflow.persistence.research_templates.sql import ResearchTemplateRepository
def make_research_template_store(session_factory) -> ResearchTemplateRepository | None:
if session_factory is None:
return None
return ResearchTemplateRepository(session_factory)
__all__ = [
"ResearchTemplateRow",
"ResearchTemplateRepository",
"make_research_template_store",
]
@@ -0,0 +1,35 @@
"""ORM model for AI writing research templates (课题研究模板).
A research template bundles a set of writing-form presets — article type,
outline, material source, word count, audience, strict mode and a topic/intent —
so a user can one-click pre-fill the whole 课题研究 form. Maintained from the
research-template config page (需求 0616 #5).
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Boolean, DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
class ResearchTemplateRow(Base):
__tablename__ = "ai_writing_research_templates"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(128), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
# 预设字段(点击模板时回填表单)。
article_type_key: Mapped[str | None] = mapped_column(String(64), nullable=True)
outline: Mapped[str | None] = mapped_column(Text, nullable=True)
material_source: Mapped[str] = mapped_column(String(32), nullable=False, default="general")
word_count: Mapped[int] = mapped_column(Integer, nullable=False, default=800)
audience: Mapped[str | None] = mapped_column(String(128), nullable=True)
strict_mode: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
topic: Mapped[str | None] = mapped_column(Text, nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
@@ -0,0 +1,221 @@
"""SQLAlchemy-backed research template storage."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.research_templates.model import ResearchTemplateRow
_UNSET = object()
# 新机器内置范例模板(表为空时由 seed_defaults() 写入;作为给用户的范例,内容务求规范好用)。
# article_type_key 取自默认文章类型(news/analysis/feature/report,见 20260517_02 迁移)。
DEFAULT_RESEARCH_TEMPLATES: list[dict] = [
{
"name": "网摘",
"description": "汇编近期网络热点信息,分条摘录核心要点并标注来源,快速掌握全貌",
"article_type_key": "news",
"material_source": "general",
"word_count": 1000,
"audience": "关注热点动态的一般读者",
"strict_mode": False,
"topic": (
"围绕【主题】检索并汇编近一周网络上的重要信息与热点动态,"
"逐条摘录核心事实、关键数据与原文出处,帮助读者快速掌握全貌。"
),
"outline": (
"一、热点速览(用一句话清单列出本期要点)\n"
"二、重点摘录\n"
" 1.【事件/话题标题】——核心事实、关键数据、信息来源\n"
" 2.【事件/话题标题】——……\n"
" 3.(按重要性依次罗列)\n"
"三、关联动态与背景补充(串联相关信息、补足来龙去脉)\n"
"四、小结与观察(提炼趋势或值得关注的看点)"
),
"sort_order": 0,
},
{
"name": "周报",
"description": "按周梳理工作进展、数据指标与问题风险,输出结构化周度汇报",
"article_type_key": "report",
"material_source": "general",
"word_count": 1200,
"audience": "团队成员与上级主管",
"strict_mode": True,
"topic": (
"汇总本周【项目/团队】的工作进展,撰写周报:"
"覆盖关键成果、数据指标、问题风险与下周计划,简明、客观、可量化。"
),
"outline": (
"一、本周概览(一句话总结 + 关键指标对比上周)\n"
"二、重点进展\n"
" 1. 已完成事项与成果(结论先行,附支撑数据)\n"
" 2. 关键数据 / 里程碑达成情况\n"
"三、问题与风险(逐条列出问题、影响面及应对措施)\n"
"四、下周计划与资源需求(目标、负责人、所需支持)"
),
"sort_order": 1,
},
{
"name": "要讯",
"description": "提炼最重要的资讯,精炼成简报式要点,突出核心事实与潜在影响",
"article_type_key": "analysis",
"material_source": "general",
"word_count": 600,
"audience": "需快速掌握要点的决策者",
"strict_mode": True,
"topic": (
"围绕【主题/领域】提炼近期最重要的资讯,编写要讯:"
"每条聚焦核心事实、关键背景与潜在影响,文字精炼、判断审慎。"
),
"outline": (
"一、头条要讯(1–3 条,每条用一句话点明核心)\n"
"二、要点详述(逐条展开)\n"
" · 事实:发生了什么、何时何地、涉及谁\n"
" · 背景:为何重要、与既有态势的关系\n"
" · 影响:可能带来的后果与连锁反应\n"
"三、研判与建议(给出倾向性判断与可行动建议)"
),
"sort_order": 2,
},
]
class ResearchTemplateRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _row_to_dict(row: ResearchTemplateRow) -> dict:
d = row.to_dict()
for k in ("created_at", "updated_at"):
if isinstance(d.get(k), datetime):
d[k] = d[k].isoformat()
return d
async def create(
self,
*,
name: str,
description: str | None = None,
article_type_key: str | None = None,
outline: str | None = None,
material_source: str = "general",
word_count: int = 800,
audience: str | None = None,
strict_mode: bool = False,
topic: str | None = None,
sort_order: int = 0,
) -> dict:
now = datetime.now(UTC)
row = ResearchTemplateRow(
id=str(uuid.uuid4()),
name=name,
description=description,
article_type_key=article_type_key,
outline=outline,
material_source=material_source,
word_count=word_count,
audience=audience,
strict_mode=strict_mode,
topic=topic,
sort_order=sort_order,
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
async def update(
self,
template_id: str,
*,
name=_UNSET,
description=_UNSET,
article_type_key=_UNSET,
outline=_UNSET,
material_source=_UNSET,
word_count=_UNSET,
audience=_UNSET,
strict_mode=_UNSET,
topic=_UNSET,
sort_order=_UNSET,
) -> dict | None:
async with self._sf() as session:
row = await session.get(ResearchTemplateRow, template_id)
if row is None:
return None
if name is not _UNSET:
row.name = name
if description is not _UNSET:
row.description = description
if article_type_key is not _UNSET:
row.article_type_key = article_type_key
if outline is not _UNSET:
row.outline = outline
if material_source is not _UNSET:
row.material_source = material_source
if word_count is not _UNSET:
row.word_count = word_count
if audience is not _UNSET:
row.audience = audience
if strict_mode is not _UNSET:
row.strict_mode = strict_mode
if topic is not _UNSET:
row.topic = topic
if sort_order is not _UNSET:
row.sort_order = sort_order
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._row_to_dict(row)
async def get(self, template_id: str) -> dict | None:
async with self._sf() as session:
row = await session.get(ResearchTemplateRow, template_id)
if row is None:
return None
return self._row_to_dict(row)
async def list(self) -> list[dict]:
stmt = select(ResearchTemplateRow).order_by(
ResearchTemplateRow.sort_order.asc(), ResearchTemplateRow.created_at.asc()
)
async with self._sf() as session:
result = await session.execute(stmt)
return [self._row_to_dict(r) for r in result.scalars()]
async def delete(self, template_id: str) -> bool:
async with self._sf() as session:
row = await session.get(ResearchTemplateRow, template_id)
if row is None:
return False
await session.delete(row)
await session.commit()
return True
async def seed_defaults(self) -> int:
"""新机器/空表时写入内置范例模板(网摘/周报/要讯)。
幂等:仅当表为空时插入;已有任何模板则跳过(避免覆盖用户配置或重复播种)。
返回插入条数。
"""
async with self._sf() as session:
existing = await session.execute(select(ResearchTemplateRow.id).limit(1))
if existing.first() is not None:
return 0
now = datetime.now(UTC)
for t in DEFAULT_RESEARCH_TEMPLATES:
session.add(
ResearchTemplateRow(id=str(uuid.uuid4()), created_at=now, updated_at=now, **t)
)
await session.commit()
return len(DEFAULT_RESEARCH_TEMPLATES)
@@ -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)
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more