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

# Conflicts:
#	frontend-web/src/components/workspace/messages/message-list-item.tsx
This commit is contained in:
吉鑫升 committed 2026-05-25 19:50:17 +08:00
commit c4277f8566
24 files changed
+1255 -567

No files matched your search

+456 -134
View File
@@ -1,37 +1,53 @@
# 多智能体圆桌研讨 · 后端开发文档
本文档面向需要维护或扩展「多智能体圆桌研讨」功能后端的开发者。前端 API 速查见 `multi-agent-api.md`、`multi-agent-step1-api.md`、`multi-agent-step3-api.md`;推荐弹窗见 `recommend-agents-progress.md`;前端实现见 `multi-agent-frontend-dev.md`。本文关注**实现细节、协议帧、设计取舍**。
本文档面向需要维护或扩展「多智能体圆桌研讨」功能后端的开发者。前端 API 速查见 `multi-agent-api.md`;前端实现见 `multi-agent-frontend-dev.md`;推荐弹窗专项进度见 `recommend-agents-progress.md`(若未提供则当作待补文件)。
源码内另有中文模块级注释(`app/gateway/routers/intent.py`、`recommend.py`、`multi_agent.py` 及 `clarification_utils.py`),与本文互为补充。
> ⚠️ 历史版本曾引用 `multi-agent-step1-api.md`、`multi-agent-step3-api.md`,**当前仓库未提供**这两份文件;Step 1(intent)与 Step 3(artifacts)的接口签名直接参考本页第 4 / 7 节即可。
源码内已有中文模块级注释(`app/gateway/routers/intent.py`、`recommend.py`、`multi_agent.py`、`artifacts.py` 及 `clarification_utils.py`),与本文互为补充。
---
## 0. 速读
- 三个圆桌 Router(`intent / recommend / multi_agent`)**全部走 HTTP loopback**:在同进程内通过 `httpx.AsyncClient` 调本机 `127.0.0.1` 的 `/api/threads`、`/api/agents`、`/api/threads/{id}/runs/stream`。
- **所有 loopback `httpx.AsyncClient` 必须 `trust_env=False`**。开发机若设了 `HTTP_PROXY` / `HTTPS_PROXY`,httpx 默认会把 loopback 也走代理 → 不可达 → 单次请求多出 40 秒超时(典型症状日志:`headers_in=42.160s`)。
- **席位 agent 持久注册**(磁盘 `.deer-flow/agents/` + `_sync_legacy_agents`),**协调智能体每次新建**(命名 `roundtable-coordinator-{timestamp36}`,DB 累积,需后续 GC)。
- **`init` 是 SSE 而非一次性 JSON**:用 `asyncio.as_completed` 按真实完成顺序推送 `seat_ready`,前端因此能逐个点亮席位。
- **`init_done.thread_ids` 包含协调智能体自身**(`agent_threads[main_agent_name] = coord_thread`),前端 Step 3 列文件时需自行过滤。
- **澄清协议统一**:Step 1 与 Step 2 leader 都借 `ask_clarification` + 终态 `clarification` 帧驱动同一套选项卡片组件;`allow_multiple` 默认 false(单选、点击即发送)。
- **leader 共识 = `status: []` 空数组**;澄清 = `status: "clarification"`(字符串);派活 = `status: [[name, task], ...]`。这三者**互斥不混用**。
- **后端 picks clamp 2–6,前端用户可手动加到 8**。
---
## 1. 功能定位与三步数据流
对应前端页面:`/page/roundtable/planning`(`RoundtablePlanningPage.tsx`)的三个步骤。
对应前端页面:`/page/workspace/roundtable/planning`(`RoundtablePlanningPage.tsx`)的三个步骤。
```
Step 1 任务意图理解
POST /api/intent/init → 建 thread,绑定内置 roundtable-intent
POST /api/intent/stream (多轮) → 澄清对话,终态 [INTENT_READY] + summary
POST /api/intent/init → 建 thread,绑定内置 roundtable-intent
POST /api/intent/stream (多轮) → 澄清对话,终态 [INTENT_READY] + summary
↓ intentReady { objective, constraints, assumptions }
推荐弹窗(Step 1 → Step 2 之间,可选)
POST /api/recommend/stream → 从候选池选 2–6 个席位,[RECOMMEND_READY] + picks
推荐弹窗(Step 1 → Step 2 之间,可选)
POST /api/recommend/stream → 从候选池选 2–6 个席位,[RECOMMEND_READY] + picks
↓ selectedAgents[]
↓ selectedAgents[] // 前端可再手动调整到 2-8
Step 2 多智能体圆桌研讨
POST /api/multi-agent/init (SSE) → 协调智能体 + 各席位 thread(席位列表由前端传入)
POST /api/multi-agent/init (SSE) → 协调智能体 + 各席位 thread(asyncio.as_completed 流式推 seat_ready)
POST /api/multi-agent/run/stream → leader 派活 / special 执行 / 澄清
↓ lastLeaderContent、各席位交付、hasConsensus
Step 3 最终方案展示
GET /api/threads/{tid}/artifacts → 列出 outputs/ 产出文件
GET /api/threads/{tid}/artifacts/{path} → 预览或下载
(总控结论、席位文本主要来自 Step 2 前端内存,一般不再调 run/stream)
GET /api/threads/{tid}/artifacts → 列出 outputs/ 产出文件
GET /api/threads/{tid}/artifacts/{path} → 预览或下载
PATCH /api/threads/{tid}/artifacts/{path} → 文本类产物原地编辑(可选)
(总控结论、席位文本主要来自 Step 2 前端内存缓存,一般不再调 run/stream)
```
| 步骤 | 前端 `currentStep` | 专属 Router | 内置 Agent |
@@ -41,7 +57,7 @@ Step 3 最终方案展示
| 2 | 2 | `multi_agent.py` | 协调智能体(每次新建)+ 用户选定的 `roundtable-*` 席位 |
| 3 | 3 | `artifacts.py`(通用) | 无新 agent |
所有接口需 `Authorization: Bearer <JWT>`。Loopback 调用统一 `trust_env=False`,避免本机 `HTTP_PROXY` 把 `127.0.0.1` 指到不可达代理(症状:单次请求卡住 40s+)。
所有接口需 `Authorization: Bearer <JWT>`。Loopback 调用统一 `trust_env=False`,避免本机 `HTTP_PROXY` 把 `127.0.0.1` 指到不可达代理(症状:`headers_in=40s+`,前端表现为「Step 1 init 卡顿 / Step 2 init 永远不发 seat_ready」)。
---
@@ -50,26 +66,33 @@ Step 3 最终方案展示
```
offline-backend-20260512/backend/
├── app/gateway/
│ ├── app.py # include_router: multi_agent, intent, recommend
│ ├── app.py # include_router: multi_agent, intent, recommend (artifacts 已存在)
│ └── routers/
│ ├── multi_agent.py # Step 2:init(SSE) + run/stream
│ ├── intent.py # Step 1:init + stream
│ ├── recommend.py # Step 1→2:推荐 stream
│ └── artifacts.py # Step 3:列出/下载 thread 产出
│ ├── multi_agent.py # Step 2:init(SSE) + run/stream + 共享的 loopback 工具
│ ├── intent.py # Step 1:init + stream(import multi_agent 工具)
│ ├── recommend.py # Step 1→2:推荐 stream(import multi_agent 工具)
│ └── artifacts.py # Step 3:列出/下载/编辑 thread 产出(通用,非圆桌专用)
├── packages/harness/deerflow/tools/builtins/
│ └── clarification_utils.py # resolve_allow_multiple + coerce_bool(Step1/Step2 共享)
└── .deer-flow/agents/
├── roundtable-intent/ # Step 1 任务理解(SOUL 含完整工作流,可不挂 Skill)
├── roundtable-recommender/ # 推荐智能体
├── roundtable-intelligence/ # 默认可选席位(6 个,前端可替换)
├── roundtable-intent/ # Step 1 任务理解(SOUL 含完整工作流,可不挂 Skill)
├── roundtable-recommender/ # 推荐智能体
├── roundtable-intelligence/ # 默认可选席位(6 个,前端可替换)
├── roundtable-environment/
├── roundtable-solution-design/
├── roundtable-risk-review/
├── roundtable-execution-plan/
└── roundtable-summary/
├── roundtable-summary/
└── roundtable-coordinator-<id>/ # 协调智能体壳,每次会话新建,长期累积
```
**内置 agent 注册**:各 `roundtable-*` 目录通过 `_sync_legacy_agents()`(`app/gateway/routers/agents.py`)在下次 `/api/agents` 请求时 upsert 进 `agents` 表,`user_id IS NULL` 表示内置。
**内置 agent 注册**:各 `roundtable-*` 目录通过 `_sync_legacy_agents()`(`app/gateway/routers/agents.py`)在下次 `/api/agents` 请求时 upsert 进 `agents` 表,`user_id IS NULL` 表示内置。`recommend.py` 不依赖此表,前端会过滤 `roundtable-coordinator-*` 出候选池。
**共享工具逻辑**:`packages/harness/deerflow/tools/builtins/clarification_utils.py` 中的 `resolve_allow_multiple()` 被 `intent.py`、`multi_agent.py` 与 `ClarificationMiddleware` 共用,决定选项卡片默认单选还是多选。
**Router 间共享导出**:`intent.py` 与 `recommend.py` 从 `multi_agent.py` import `_auth_headers / _create_thread / _flatten_content / _loopback_base / _parse_last_messages / _run_payload / _stream_upstream` 等底层工具,**不要**在 intent / recommend 重复实现 loopback 逻辑。
**共享工具逻辑**:`clarification_utils.py` 的 `resolve_allow_multiple(args)` 与 `coerce_bool(value, default)` 被 `intent.py` / `multi_agent.py` / `ClarificationMiddleware` 共用:
- `coerce_bool` 把 LLM tool args 里可能出现的 bool / 数字 / 字符串(`"true"`、`"1"`、`"yes"`、`"y"`、`"on"` / `"false"`、`"0"`、`"no"`、`"n"`、`"off"`)统一成 bool;
- `resolve_allow_multiple` 仅 `args["allow_multiple"]` 显式为真时返回 `True`,缺省 / `null` 都视为 `False`(单选)。
---
@@ -80,10 +103,11 @@ offline-backend-20260512/backend/
| POST | `/api/intent/init` | 1 | 创建意图澄清 thread |
| POST | `/api/intent/stream` | 1 | SSE,一轮用户对话 |
| POST | `/api/recommend/stream` | 1→2 | SSE,一次性推荐席位 |
| POST | `/api/multi-agent/init` | 2 | SSE,并行拉起席位 + 协调智能体 |
| POST | `/api/multi-agent/init` | 2 | SSE,**按完成顺序**推 `seat_ready`,最后 `init_done` |
| POST | `/api/multi-agent/run/stream` | 2 | SSE,leader 或 special 一轮 |
| GET | `/api/threads/{thread_id}/artifacts` | 3 | 列出该 thread 的 outputs 文件 |
| GET | `/api/threads/{thread_id}/artifacts/{path}` | 3 | 预览/下载单个产出 |
| GET | `/api/threads/{thread_id}/artifacts/{path}` | 3 | 预览/下载单个产出(含 `.skill/` 内文件提取) |
| PATCH | `/api/threads/{thread_id}/artifacts/{path}` | 3 | 编辑文本类产物(白名单后缀) |
字段级请求/响应见各 step 的 API 文档;下文只写**后端实现语义**。
@@ -93,47 +117,64 @@ offline-backend-20260512/backend/
### 4.1 定位
轻量包装 Lead Agent 运行时,固定跑内置 `roundtable-intent`。不做 broadcast、不派活;每轮用户消息对应一次 `/stream`。
轻量包装 Lead Agent 运行时,固定跑内置 `roundtable-intent`(`INTENT_AGENT_ID` 常量)。不做 broadcast、不派活;每轮用户消息对应一次 `/stream`。
### 4.2 `POST /api/intent/init`
1. `_create_thread()` → LangGraph thread
2. 返回固定 `agent_id: "roundtable-intent"`(**不**每次新建 agent)
请求体:`{"model": "deepseek-chat"}` — `model` 可选,**此处不写入 thread**,每轮 `/stream` 自己带 model。
与 Step 2 `/init` 的区别:无协调智能体、无 `agent_names` 列表。
### 4.3 `POST /api/intent/stream`
**上游**:`_run_payload(agent_name=roundtable-intent, excluded_tools=[...])`,硬排除 `agent_orchestration`、`write_file`、`web_search` 等,**仅保留** `ask_clarification` 等澄清能力,防止模型写方案文档。
**入参**:`{thread_id, message, model?}`,缺 `thread_id` 或 `message` → 400。
**流式**:透传 LangGraph SSE → 解析 `_parse_last_messages(captured)`。
**上游 run_payload**:见 §6.6;`excluded_tools` 屏蔽**除 `ask_clarification` 外的几乎全部工具**,硬清单:
**终态帧判定顺序(重要)**:
1. **先**扫描全部 AI 消息中的 `[INTENT_READY]\n\`\`\`json {...}\`\`\``(正则 `_INTENT_READY_PATTERN`)
- 命中 → `status: "done"`,`summary` 为解析后的 JSON,`content` 为含标记的整段文本
- **优先于** `ask_clarification`,避免「已汇总仍被当成待澄清」
2. 否则查最后一则带 `tool_calls` 的 AIMessage 是否含 `ask_clarification`
- 命中 → `status: "clarification"`,附带结构化字段供前端选项卡片:
```json
{
"status": "clarification",
"content": "<引导语/前言>",
"question": "...",
"clarification_type": "missing_info",
"clarification_context": "...",
"options": ["..."],
"allow_custom": true,
"allow_multiple": false,
"summary": null
}
```python
[
"web_search",
"present_files",
"view_image",
"agent_orchestration",
"bash", "ls", "read_file", "write_file", "str_replace",
"memory", "hindsight_recall", "hindsight_reflect", "hindsight_retain",
"task", "write_todos",
]
```
`allow_multiple` 由 `resolve_allow_multiple(cargs)` 决定:**默认单选**(点击即发送);仅模型显式传 `allow_multiple: true` 时为多选。
写死黑名单而不是「只允许 ask_clarification」的原因:SOUL 文字约束不可靠,硬排除更稳;防止模型偏离去写方案文档或派活。
3. 否则 → `status: "asking"`(继续对话,无结构化澄清)
**流式**:透传 LangGraph SSE → `_parse_last_messages(captured)` 从倒数 `values` 快照拿 `messages`。
**终态帧判定顺序(重要,勿调整)**:
1. **`[INTENT_READY]` 优先**:从最新消息向前扫描每条 AIMessage,正则 `_INTENT_READY_PATTERN = /\[INTENT_READY\]\s*```json\s*(\{.*?\})\s*```/s` 命中后 `json.loads` 解析
- 命中 → `status: "done"`,`summary` 为 JSON、`content` 为含标记的整段
- **优先于** `ask_clarification`:模型可能在较早的 AIMessage 里已 emit `[INTENT_READY]`,但本轮残留 `ask_clarification` 的 tool_call;不先扫 READY 前端会卡在澄清 UI
2. 否则用 `_find_dispatch_message(messages)` 找最后一条带 `tool_calls` 的 AIMessage,检查是否含 `ask_clarification`
- 命中 → `status: "clarification"`,附结构化字段:
```json
{
"status": "clarification",
"content": "<follow-up 或 preamble 或 question>",
"question": "...",
"clarification_type": "missing_info",
"clarification_context": "...",
"options": ["..."],
"allow_custom": true,
"allow_multiple": false,
"summary": null
}
```
- `content` 取 `_find_trailing_ai_text(messages, exclude_text=preamble)`:ClarificationMiddleware 常在 tool 后还有一条纯文本 AIMessage,比 tool_call 旁的短 preamble 更适合作为气泡正文
- `allow_multiple` 由 `resolve_allow_multiple(cargs)` 决定(缺省 false)
3. 否则 → `status: "asking"`,`content` 为 `_flatten_content(dispatch_msg.content)`,前端继续 chat
**异常**:`HTTPException` / 其它异常都返回 `{"status": "error", "content": "...", "summary": null}`,前端展示红色错误气泡。
**`summary` 形状**(`done` 时):
@@ -157,25 +198,45 @@ offline-backend-20260512/backend/
### 5.1 定位
无状态单次调用:每次请求内部 `_create_thread()`,不暴露 `/init`。前端传入 Step 1 的 `intent` + 过滤后的 `candidates`(排除 `roundtable-intent`、`roundtable-recommender`、历史 `roundtable-coordinator-*`)。
无状态单次调用:每次请求内部 `_create_thread()`,**不暴露 `/init`**。前端传入 Step 1 的 `intent` + 过滤后的 `candidates`(剔除 `roundtable-intent`、`roundtable-recommender`、历史 `roundtable-coordinator-*`)。
### 5.2 `POST /api/recommend/stream`
**入参校验**:
- `intent.objective` 必填
- `candidates` 非空,且至少一个有效 `agent_id`
- `intent.objective` 必填(trim 后非空)
- `candidates` 必须是非空数组
- 至少一个有效 `agent_id`(trim 后非空)
- 三者缺一 → 400
**Prompt 拼装**(`_build_prompt`):把 objective/constraints/assumptions 与候选池格式化为一条 user message。
**Prompt 拼装**(`_build_prompt`):
**上游**:`roundtable-recommender`,`excluded_tools` 含 `ask_clarification`、`agent_orchestration` 等(纯判断,不调工具)。
```
任务目标:<objective>
约束:<constraint1>; <constraint2>; ...
关键假设:<assumption1>; ...
**终态**:
候选智能体池:
- <agent_id> (<name>): <description>
- ...
- 解析 `[RECOMMEND_READY]\n\`\`\`json {"picks":[{"agent_id","reason"},...]}\`\`\``
- `_validate_picks()`:只保留候选池内的 id、去重、数量 clamp 到 2–6
请按 SOUL 中规定的格式输出推荐结果(自然语言思路 + [RECOMMEND_READY] + json 块)。
```
格式刻意贴近 SOUL 示例,让模型有清晰的复刻模板。
**上游 run_payload**:`agent_name=RECOMMENDER_AGENT_ID`、`excluded_tools=["web_search", "present_files", "view_image", "agent_orchestration", "ask_clarification"]` — 纯判断,不调任何工具。
**终态分支**:
- 取**最后一条非空 AIMessage** 的文本(`last_ai_content`)
- 正则 `_RECOMMEND_READY_PATTERN = /\[RECOMMEND_READY\]\s*```json\s*(\{.*?\})\s*```/s` 匹配 picks JSON
- `_validate_picks(parsed.picks, candidate_ids)`:
- 跳过非 dict / 缺 `agent_id` / 不在候选池 / 已 seen 的项
- 至多保留 `_MAX_PICKS = 6` 个
- 不足 `_MIN_PICKS = 2` → 返回 `[]`,触发 fallback
- 有效 picks ≥ 2 → `status: "done"`,`picks` 有值
- 否则 → `status: "asking"`(前端回退默认席位列表)
- 否则 → `status: "asking"`(**不是模型在追问用户**,而是「网关未能产出合法 picks」,前端走 FALLBACK 默认 6 席)
```json
{
@@ -185,74 +246,197 @@ offline-backend-20260512/backend/
}
```
**前端流式展示**:自然语言理由与 JSON 由 `[RECOMMEND_READY]` 分界;`TitleMiddleware` 产生的标题增量会在客户端过滤,避免污染推荐理由区。
**picks 数量上下限的不一致**(需要心里有数):
| 边界 | 后端 | 前端弹窗 |
|------|------|----------|
| 下限 | `_MIN_PICKS = 2`(不足返 asking) | `MIN_SELECTED = 2`(确认按钮校验) |
| 上限 | `_MAX_PICKS = 6`(截断模型输出) | `MAX_SELECTED = 8`(允许用户手加 2 个) |
后端只对**模型输出**做 clamp,用户可手动追加候选池里其它 agent 到 8 个。
**前端流式展示**:自然语言理由与 JSON 由 `[RECOMMEND_READY]` 分界;`TitleMiddleware` 产生的标题增量会在客户端过滤(`langgraph_node` 含 `TitleMiddleware` / `tags` 含 `middleware:title`),避免污染推荐理由区。
### 5.3 与 SOUL 的契约
模型须在自然语言思路后输出:
```
[RECOMMEND_READY]
```json
{"picks": [{"agent_id": "...", "reason": "..."}, ...]}
```
```
`agent_id` 必须是候选池内已有的 id,`_validate_picks` 会做白名单校验。
---
## 6. Step 2:多智能体研讨(`multi_agent.py`)
### 6.1 `/init` 内部流程(SSE,非一次性 JSON)
### 6.1 `POST /api/multi-agent/init` — SSE,非一次性 JSON
当前实现为 **流式 init**,便于前端按席位点亮 UI:
```
请求体:
{
"agent_names": ["roundtable-intelligence", ...], // 用户选的席位 id
"main_agent_name": "roundtable-coordinator-xxx", // 当次总控 id,前端生成
"model": "deepseek-chat" // 可选,写入 coordinator agent
}
```
**早期校验**(同步抛 HTTP 4xx,**不**进 SSE):
- `agent_names` 必须非空
- 每个 `agent_names` 元素 + `main_agent_name` 都需通过 `_AGENT_ID_PATTERN = ^[A-Za-z0-9_-]+$`(用 `_validate_agent_id` 提前 400;上游若收到不合规 id 会抛 422,提前拦更友好)
- 所有 id 都 `.lower()` 后比较 / 存储
**SSE 内部流程**:
```
asyncio.as_completed(各席位 _prepare_sub_agent)
每完成一个 → data: {"event":"seat_ready","agent_name","thread_id","display_name","description"}
(按真实完成顺序,先完成先发,前端因此能逐个点亮)
协调智能体:
异常时:取消所有未完成的 task,避免泄露到 AsyncClient close 之后
协调智能体:
POST /api/threads → coord_thread
POST /api/agents(skills=[agent_orchestration],SOUL 动态拼入可调度列表)
POST /api/agents
skills=["agent_orchestration"]
soul=动态拼接(本次 init 的席位列表 + 派活规则)
id=main_agent_name(前端生成的 roundtable-coordinator-<ts36>)
agent_threads[main_agent_name] = coord_thread // ⚠️ thread_ids 含 coordinator 自身
→ data: {"event":"coordinator_ready","main_agent_name","thread_id"}
→ data: {"event":"init_done","main_agent_name","thread_ids":{...}}
失败 → data: {"event":"error","detail":"..."}
```
**席位列表**:由前端 `agent_names` 传入(推荐弹窗确认后的 2–6 个 id),不再写死 6 个。
**协调智能体创建幂等**:若 `_create_agent` 返回 (False, "exists ...")(同名 agent 已存在)则继续往下走,不报错。注意这里的「已存在」会复用历史 agent 的 SOUL(与本次动态拼接的不同),通常只在前端用同一个时间戳跑过两遍时触发。
**协调智能体 SOUL**(动态拼接)包含:只负责协调汇总、通过 `agent_orchestration` 派活、`agent_name` 必须用英文 id(不要用中文显示名)。
### 6.2 `/run/stream` — `agent_type = "leader"`
**协调智能体 SOUL**(动态拼接,与席位列表一起写入):
```
1. (可选)pre_broadcast:总控收到用户消息 → 广播到非自己的 thread
2. POST /api/threads/{coordinator}/runs/stream
- excluded_tools: ["web_search"]
3. 透传 LangGraph 事件
4. 解析 messages → tool_calls
#核心定位:
用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,
当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果
分支 A — ask_clarification:
status: "clarification"
+ question / options / allow_multiple / clarification_type / clarification_context
(空 dispatched 不等于共识,前端展示澄清卡片并暂停自动派活循环)
#调度方法:
通过使用 agent_orchestration 技能调用其他智能体干活,使用技能时传入
agent_name 和需要做的活。
⚠️ agent_name 必须严格使用下方列出的英文 id(例如 roundtable-intelligence),
不要把括号里的中文显示名拼进 agent_name 里。
分支 B — 有 agent_orchestration 派活:
并发 _broadcast 派活消息到相关 thread
status: [["sub_agent_id", "task_text"], ...]
分支 C — 无 tool_calls:
status: [] // 共识达成,content 为总控最终文本
#可调度智能体:
- agent_name: roundtable-intelligence(情报收集):<description>
- agent_name: roundtable-environment(环境评估):<description>
...
```
### 6.3 `/run/stream` — `agent_type = "special"`
中文显示名通过 `GET /api/agents/{id}` 取 `name`、描述取 `description`。**强调英文 id** 是因为部分模型会把整段 `roundtable-x(中文)` 当 `agent_name` 塞回来,靠后端 `_strip_display_suffix` 兜底(详见 §6.4)。
### 6.2 `/run/stream` — `agent_type = "leader"`(`_leader_run`)
```
1. POST /api/threads/{sub}/runs/stream
- excluded_tools: web_search, ask_clarification, present_files, view_image, agent_orchestration
2. 透传 + 解析最后 AI 文本
3. post_broadcast:子智能体完成交付 → 广播到非自己的 thread
4. status: "<完成提示字符串>" 或业务自定义;content 为交付正文;agent_name 为子 agent id
1. 并行启动 pre_broadcast_task(不 await):向除自己外所有 thread 追加
"总控智能体收到用户消息:<msg>" human 消息
2. _run_payload + _stream_upstream(POST /api/threads/{coordinator}/runs/stream)
- excluded_tools: ["web_search"] // 只禁联网,其余工具(含 agent_orchestration)保留
- skill_stop_names: {"agent_orchestration", *callers_extra}
// 网关自动并入 agent_orchestration,让 SkillStopMiddleware 在工具调用后停图,
// 便于本路由从 AIMessage.tool_calls 解析派活列表
3. 透传上游 SSE,_parse_last_messages 拿 messages
4. await pre_broadcast_task(LLM 通常已跑完,这里 await 接近 0 成本)
5. _find_dispatch_message(messages) 找带 tool_calls 的 AIMessage
6. 分三支:
分支 A — ask_clarification:
{"status": "clarification", "content": follow_up 或 question 或 leader_text,
"question", "clarification_type", "clarification_context",
"options", "allow_custom", "allow_multiple", "agent_name"}
⚠️ 空 dispatched 不等于共识,前端展示澄清卡片并暂停自动派活循环
分支 B — 有 tool_calls(派活):
_extract_dispatch(tool_call)解析每个 tool_call → (sub_name, task_text)
并行 _broadcast("总控智能体给 X 派活,内容为:Y", skip=[leader, sub_name])
等所有派活广播完成(asyncio.gather)
{"status": [[sub_name, task_text], ...], "content": leader_text, "agent_name": agent_name}
分支 C — 无 tool_calls:
{"status": [], "content": leader_text, "agent_name": agent_name}
// 共识达成,前端 hasConsensus = true
```
### 6.4 `_broadcast` 机制
**`_extract_dispatch` 双格式支持**:
1. **结构化**:`{"name": "agent_orchestration", "args": {"agent_name": "...", "task": "..."}}`
— 大多数 LLM 在 SOUL 引导下走这条
2. **Legacy bash argv**:`{"name": "bash", "args": {"command": '... .py "X" "Y" ...'}}`
— 正则 `_TOOL_CALL_PATTERN = /\.py\s+"([^"]*)"\s+"([^"]*)"/`,兼容旧 skill;不可移除
两条路径都会过 `_strip_display_suffix`:用 `re.split(r"[^A-Za-z0-9_-]", agent_name, maxsplit=1)[0]` 截断第一个非合法 id 字符之后的内容,把 `roundtable-x(中文)` 还原为 `roundtable-x`;剥离后若仍违规,原样返回让上游 422。
**时序日志**(grep `[leader-timing]`):
```
payload_built — 构造请求体耗时(broadcast 已经在后台跑)
first_upstream_line — 上游首行(LangGraph 启动 + LLM 首字节)
upstream_stream_complete — 整条 SSE 透传完成
pre_broadcast_await — 必须 await 才能 yield 终态;>0 表示广播比 LLM 还慢
dispatch_broadcasts_await — 派活广播并行 gather 耗时
```
### 6.3 `/run/stream` — `agent_type = "special"`(`_special_run`)
```
1. _run_payload + _stream_upstream(POST /api/threads/{sub}/runs/stream)
- excluded_tools: ["web_search", "ask_clarification", "present_files",
"view_image", "agent_orchestration"]
// 子智能体禁派活/澄清/文件呈现/图片;只做交付
2. 透传 SSE,_parse_last_messages 拿最后一条 AIMessage 的 content
3. 启动 post_broadcast_task("子智能体 X 完成 Y 工作,交付内容为:..."),不 await
4. yield {"status": "子智能体X完成X工作", "content": "<交付正文>", "agent_name": "..."}
// ⚠️ 在 yield 之后再 await broadcast,让前端先收到终态、关 loading
5. await post_broadcast_task(SSE 连接稍微多保持一会;前端早就拿到结果了)
```
**时序日志**(grep `[special-timing]`):
```
payload_built
first_upstream_line
upstream_stream_complete
post_broadcast_total — yield 之后 await broadcast 的总耗时
```
### 6.4 `_broadcast` 与 `_append_thread_message` 机制
```python
async def _append_thread_message(client, base, headers, thread_id, content):
# GET /api/threads/{tid}/state → state
# state["values"]["messages"].append({"type": "human", "content": content})
# POST /api/threads/{tid}/state
# 失败仅打 warning,不阻断主流程
async def _broadcast(client, base, headers, message, agent_threads, skip):
targets = [tid for name, tid in agent_threads.items() if name not in skip]
# 对每个 target: GET state → append human message → POST state
await asyncio.gather(*(_append_thread_message(...) for tid in targets),
return_exceptions=True)
```
目的:各 thread 对话历史里保留「谁派活、谁交付」的群聊上下文。`skip` 通常含发起方与接收方,避免回声。
**目的**:各 thread 对话历史里保留「谁派活、谁交付」的群聊上下文;**`skip` 通常含发起方与接收方**,避免回声。
**三种触发**:
| 触发点 | 内容 | skip |
|--------|------|------|
| leader pre_broadcast | `总控智能体收到用户消息:<msg>` | `[leader]` |
| leader 派活 | `总控智能体给 X 派活,内容为:<task>` | `[leader, sub_name]` |
| special 后广播 | `子智能体 X 完成 Y 工作,交付内容为:<content>` | `[sub]` |
**注意**:广播是 best-effort,对方 LLM 真正干活仍需前端显式 `/run/stream` 调到对应席位。
### 6.5 终态帧协议(leader / special / clarification)
@@ -260,24 +444,87 @@ async def _broadcast(client, base, headers, message, agent_threads, skip):
| `status` 类型 | 含义 |
|---------------|------|
| `[["agent","task"],...]` | 派活列表;`[]` = 共识 |
| `[["agent","task"],...]` | 派活列表(数组);`[]` = 共识 |
| `"clarification"` | 总控追问,需用户回复后再 leader |
| `"子智能体 X 完成..."` | special 完成(字符串) |
| `"error"` | 异常 |
与 Step 1 的 `clarification` 帧字段对齐(`question`、`options`、`allow_multiple` 等),便于复用选项卡片组件。
### 6.6 `_run_payload` 完整字段
```python
{
"input": {"messages": [{"type": "human",
"content": [{"type": "text", "text": new_message}],
"additional_kwargs": {}}]},
"config": {"recursion_limit": 1000},
"context": {
"agent_name": <coordinator 或 sub agent id>,
"model_name": "deepseek-chat",
"mode": "pro",
"reasoning_effort": "medium",
"thinking_enabled": True,
"is_plan_mode": False,
"subagent_enabled": False,
"thread_id": <thread_id>,
},
"stream_mode": ["messages-tuple", "values"],
"stream_subgraphs": True,
"stream_resumable": True,
"assistant_id": "lead_agent",
"on_disconnect": "continue",
"excluded_tools": <按 router 不同>,
"skill_stop_names": <leader 强制并入 agent_orchestration>,
}
```
字段含义:
- `recursion_limit: 1000` — 防止 LangGraph 循环深度兜底(圆桌实际很难达到)
- `thinking_enabled: True` — 开启模型的扩展思考(CoT),需 model 支持
- `is_plan_mode: False`、`subagent_enabled: False` — 圆桌内禁用 TodoList / subagent 中间件,避免与多智能体协调冲突
- `stream_mode: ["messages-tuple", "values"]` — 同时需要逐 token 增量与最后一次完整 values 快照
- `assistant_id: "lead_agent"` — 走 Lead Agent 工厂
- `on_disconnect: "continue"` — 客户端断连后上游继续跑完(不丢 token 但客户端拿不到)
### 6.7 上游 SSE 透传与 `_parse_last_messages`
`_stream_upstream` 逐行 yield 上游 `/api/threads/{tid}/runs/stream` 的 SSE。返回元组:
```
(line, None) — 原样转发给浏览器(含 event: / data: 行)
(None, captured) — 流结束,captured 是所有 data: 行的字符串列表
```
日志 `[upstream-timing] <agent> headers_in=<>` / `first_line=<>`:排查首 token 慢、代理导致 loopback 超时(见 `trust_env=False`)。
`_parse_last_messages(captured)` 从倒数往前扫,**跳过 trailing `data: null` end 帧**,找第一条解析为 dict 且含 `messages` key 的 frame;取 `messages` 列表。无可解析帧抛 `502`。
### 6.8 三个 AIMessage 搜索辅助
| 函数 | 用途 |
|------|------|
| `_find_dispatch_message(messages)` | 从后向前找带 `tool_calls` 的 AIMessage(跳过 SkillStop / Clarification 注入的 ToolMessage);都没有则返回最后一条 AIMessage |
| `_find_trailing_ai_text(messages, exclude_text)` | 从后向前找**无 tool_calls** 的 AIMessage 的纯文本(用于澄清后的 follow-up,排除等于 preamble 的项) |
| `_flatten_content(content)` | 把 `str` / 结构化 content blocks `[{"text": ...}]` 拍平成单串 |
---
## 7. Step 3:产出文件(`artifacts.py`,通用 Thread API)
Step 3 **没有** 单独的 `multi_agent` 路由;使用既有 artifacts 能力。
Step 3 **没有** 单独的 `multi_agent` 路由;使用既有 artifacts 能力,圆桌只是消费者之一。
### 7.1 `GET /api/threads/{thread_id}/artifacts`
- 鉴权:`threads:read`,owner 校验
- 扫描:`users/{user_id}/threads/{thread_id}/user-data/outputs/`(`collect_outputs_listing`)
- 目录不存在 → `files: []`(非 404)
- 鉴权:`@require_permission("threads", "read", owner_check=True)`
- 扫描:`get_paths().sandbox_outputs_dir(thread_id, user_id=get_effective_user_id())`
- 解析为 `.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/`
- `collect_outputs_listing(outputs_dir)`:
- 递归 `rglob("*")`,过滤非文件
- 软链接安全检查(`resolved.relative_to(outputs_root)`,越界丢弃)
- 按 `modified_at` 倒序
- 目录不存在 → `files: []`(**非 404**)
```json
{
@@ -294,15 +541,34 @@ Step 3 **没有** 单独的 `multi_agent` 路由;使用既有 artifacts 能力
}
```
前端 `listSessionArtifacts(threadId)` 封装此接口;Step 3 可对协调 thread 与各席位 thread 分别拉取,合并为下载清单。
前端 `listSessionArtifacts(threadId)` 封装此接口;Step 3 对协调 thread 与各席位 thread 分别拉取,合并为下载清单(实际只列子席位)。
### 7.2 `GET /api/threads/{thread_id}/artifacts/{path}`
- 虚拟路径前缀 `mnt/user-data/outputs/...`
- 路径穿越防护;`download=true` 强制附件下载
- HTML/XHTML/SVG 等 active content 始终按下载处理
- 鉴权同上
- 虚拟路径前缀 `mnt/user-data/outputs/...`,经 `resolve_thread_virtual_path` 转物理路径并防穿越
- **特殊:`.skill/` 压缩包透视**:若 `path` 含 `.skill/`,从 ZIP 提取内部文件(如 `xxx.skill/SKILL.md`)。`_extract_file_from_skill_archive` 同时支持「直接路径命中」与「带顶层目录前缀」两种压缩包结构。结果带 `Cache-Control: private, max-age=300` 头避免重复解压
- `ACTIVE_CONTENT_MIME_TYPES = {"text/html", "application/xhtml+xml", "image/svg+xml"}` 始终强制 `Content-Disposition: attachment`(防止 XSS 与脚本执行)
- 文本类(mime 以 `text/` 开头)→ `PlainTextResponse`
- 后缀判定不到、但 8KB 内无 null 字节 → 当文本返回(`is_text_file_by_content`)
- 其它 → `FileResponse` 或 `Response` 内嵌
- `?download=true` 对所有类型强制附件下载
**总控结论、各席位最终段落**:主要来自 Step 2 已缓存的 `lastLeaderContent`、`step2RoundtableDialogues`;无需为 Step 3 新增聚合接口(可选优化见 `multi-agent-step3-api.md` 中的 `sessions/summary` 草案)。
### 7.3 `PATCH /api/threads/{thread_id}/artifacts/{path}`
- 鉴权:`@require_permission("threads", "write", owner_check=True)`
- 请求体:`{"content": "<新内容>"}`
- 限制:
- `.skill/` ZIP 内文件只读(400)
- `ACTIVE_CONTENT_MIME_TYPES` 拒绝(400 active artifacts are read-only)
- 文件后缀需在 `EDITABLE_TEXT_SUFFIXES = {.md, .markdown, .txt, .json, .yaml, .yml, .csv, .xml, .log}` 之内,**或** mime 以 `text/` 开头
- 用 UTF-8 覆写原文件,返回 `PlainTextResponse(body.content)`
圆桌 Step 3 当前主要消费 GET,PATCH 用于「在线修订交付稿」类场景,前端默认未启用。
### 7.4 与 Step 2 内存的关系
**总控结论、各席位最终段落**:主要来自 Step 2 前端缓存的 `lastLeaderContent`、`step2RoundtableDialogues`(filter `confidence === "子智能体交付"`),**不**为 Step 3 新增聚合接口;如后续需要后端聚合,可加 `sessions/summary` 草案路由。
---
@@ -325,13 +591,22 @@ Step 3 **没有** 单独的 `multi_agent` 路由;使用既有 artifacts 能力
## 9. 关键设计决策
### 9.1 HTTP loopback
### 9.1 HTTP loopback + `trust_env=False`
三个 router 均通过 `httpx` 调本机 Gateway(`127.0.0.1`),复用鉴权与 LangGraph 路由,代价是额外 HTTP 开销;init 已用 `asyncio.as_completed` 并行化席位准备。
**每个 `httpx.AsyncClient(timeout=None, trust_env=False)` 都是必须的**:
- `timeout=None` — SSE 长连接需要
- `trust_env=False` — 否则 httpx 会读取 `HTTP_PROXY` / `HTTPS_PROXY` 环境变量,把 loopback `127.0.0.1` 也走代理。开发机常配的「校园网代理 / 公司代理」会把回环地址丢弃,导致单次请求 40+ 秒超时(日志典型症状:`headers_in=42.160s`)
### 9.2 协调智能体每次新建
`main_agent_name` 形如 `roundtable-coordinator-{timestamp36}`,会话隔离;长期会累积 agent 目录,需后续 GC 策略。
`main_agent_name` 形如 `roundtable-coordinator-{timestamp36}`,会话隔离;长期会累积 agent 目录(实际仓库已有数十个 `roundtable-coordinator-mp*`),需后续 GC 策略:
- 选项 A:定时任务清理 N 天前无活跃 thread 的 coordinator agent
- 选项 B:每次创建前 LRU 清理(注意:跨服务实例难协调)
- 选项 C:把 coordinator 实例化为「内置 + 动态 SOUL」复用同一个 id
### 9.3 澄清协议统一
@@ -339,11 +614,19 @@ Step 1 与 Step 2 leader 均通过 `ask_clarification` + 终态 `clarification`
### 9.4 选项单选默认
`resolve_allow_multiple()`:**未传则 false**(单选、点击发送);仅显式 `allow_multiple: true` 时多选。降低模型把 `missing_info` 全标成多选导致 UX 混乱的概率。
`resolve_allow_multiple()`:**未传则 false**(单选、点击发送);仅显式 `allow_multiple: true` 时多选。降低模型把 `missing_info` 全标成多选导致 UX 混乱的概率。`coerce_bool` 容忍 LLM 返回的字符串/数字 bool。
### 9.5 SSE 末尾状态帧
LangGraph 原生流不含「派活结果 / 意图 summary / 推荐 picks」;各 router 在流末追加 JSON `data:` 帧,前端与 LangGraph 帧区分解析。
LangGraph 原生流不含「派活结果 / 意图 summary / 推荐 picks」;各 router 在流末追加 JSON `data:` 帧,前端用 `isFinalStatusFrame()` / `isFinalIntentFrame()` / `isFinalRecommendFrame()` 与 LangGraph 帧区分解析。
### 9.6 SkillStopMiddleware 配合 leader 派活
leader run 自动并入 `skill_stop_names = {"agent_orchestration", ...}`,让 `SkillStopMiddleware` 在 `agent_orchestration` 工具调用后立刻停图。这样网关能从 `AIMessage.tool_calls` 直接读出派活列表,不必等 LLM 真把任务执行完。
### 9.7 init `agent_threads` 含 coordinator 自身
`init_done.thread_ids` 是 `{agent_name: thread_id}` 形式,其中 `main_agent_name` 自己的 thread 也在里面。前端 Step 3 列文件时需自行过滤掉 coordinator thread,否则会多调一次 `listSessionArtifacts`(虽然结果会是空,但有冗余 HTTP)。
---
@@ -351,11 +634,23 @@ LangGraph 原生流不含「派活结果 / 意图 summary / 推荐 picks」;
| 阶段 | 已做优化 | 典型耗时 |
|------|----------|----------|
| Step 2 `/init` | 席位 `as_completed` + 去掉 intro 广播 | 约 0.5–1.2s(视席位数) |
| LLM `/run/stream` | — | 数百 ms~数十秒(主因) |
| Step 2 `/init` | 席位 `asyncio.as_completed` + 流式 `seat_ready` + 去掉 intro 广播 | 约 0.5–1.2s(视席位数) |
| LLM `/run/stream` | — | 数百 ms~数十秒(主因) |
| leader pre_broadcast | 与上游 LLM run 并发启动 | 通常已在 LLM 完成前结束 |
| leader 派活广播 | `asyncio.gather` 并行 | < 200ms(同 thread state R/W) |
| special post_broadcast | yield 之后才 await,前端先关 loading | 不阻塞 UX |
| Step 1/推荐 | 单 thread,无 broadcast | 与模型相关 |
仍存在的开销:`GET /api/agents/{id}` 触发 legacy sync 扫目录、`GET/POST /state` 广播等。未来可考虑 loopback 改内部函数调用(见原 B/D 优化项)。
**时序日志关键字**(开发自带):
```bash
grep "init-timing" .runtime/logs/*.log # 仅前端打,作参考
grep "upstream-timing" .runtime/logs/*.log # _stream_upstream 内 headers_in / first_line
grep "leader-timing" .runtime/logs/*.log # payload_built / first_upstream_line / dispatch_broadcasts_await ...
grep "special-timing" .runtime/logs/*.log # payload_built / first_upstream_line / post_broadcast_total
```
仍存在的开销:`GET /api/agents/{id}` 触发 legacy sync 扫目录、`GET/POST /state` 广播等。未来可考虑 loopback 改内部函数调用。
---
@@ -367,7 +662,7 @@ LangGraph 原生流不含「派活结果 / 意图 summary / 推荐 picks」;
### 11.2 模型
`model` 写入 run 的 `context.model_name`;前端可按席位覆盖 special 调用的模型。
`model` 写入 run 的 `context.model_name`;前端可按席位覆盖 special 调用的模型(见前端文档 §5.5 Persona 抽屉)。
### 11.3 新增席位
@@ -377,7 +672,15 @@ LangGraph 原生流不含「派活结果 / 意图 summary / 推荐 picks」;
### 11.4 新增 Step 1 工具
若需新工具,须同步修改 `intent.py` 的 `excluded_tools` 白名单逻辑(当前为黑名单,仅 `ask_clarification` 实际可用)。
若需新工具,须同步修改 `intent.py` 的 `excluded_tools` 黑名单(当前默认禁 15 个工具,仅 `ask_clarification` 实际可用)。
### 11.5 改回旧 bash 派活协议
后端已**双格式兼容**,前端无需变化;只要 SOUL 改成生成 `bash` + `.py "X" "Y"` argv,`_extract_dispatch` 仍会解析正确。但建议保持新的结构化 `agent_orchestration`,prompt 更稳定。
### 11.6 把广播改为推送
`_append_thread_message` 通过 `GET state → mutate → POST state` 实现,是为了与 LangGraph checkpoint 一致;如果将来 Gateway 提供 `POST /api/threads/{id}/messages/append` 这类原子接口,可换上,降低并发竞争(虽然圆桌当前不会并发广播同一 thread)。
---
@@ -385,20 +688,27 @@ LangGraph 原生流不含「派活结果 / 意图 summary / 推荐 picks」;
| 现象 | 可能原因 | 排查 |
|------|----------|------|
| Step 1 一直有澄清、无法进入 Step 2 | 未产出 `[INTENT_READY]` 或终态被 `clarification` 覆盖 | 看 SOUL、最后一帧 `status`;确认 intent 先扫 READY |
| 选项全是多选 | 旧前端默认 `allow_multiple !== false` | 确认 `allow_multiple` 字段与 `resolve_allow_multiple` |
| Step 2 init SSE `error` | 协调 agent 创建失败 / agent_orchestration 缺失 | Network + 后端 `multi_agent` 日志 |
| leader 立即 `status: []` | 未调用 orchestration 技能 | 换模型或加强 SOUL |
| 推荐 picks 为空 | 未输出 `[RECOMMEND_READY]` 或 picks 不在候选池 | 看 recommend 终态帧 |
| Step 3 无文件 | 子 agent 未写 outputs | `GET .../artifacts` 对子 thread_id 逐个查 |
| Step 1 一直有澄清、无法进入 Step 2 | 未产出 `[INTENT_READY]` 或终态被 `clarification` 覆盖 | 看 SOUL、最后一帧 `status`;确认 intent 先扫 READY(§4.3 顺序) |
| 选项全是多选 | 旧前端默认 `allow_multiple !== false` | 确认 `allow_multiple` 字段与 `resolve_allow_multiple`(缺省 false) |
| Step 2 init SSE `error` | 协调 agent 创建失败 / agent_orchestration 缺失 / `_validate_agent_id` 命中 | Network + 后端 `multi_agent` 日志 |
| Step 2 init 完全卡住 ~40 秒 | `httpx` 走了 `HTTP_PROXY` | 确认 `trust_env=False` 仍在;grep `headers_in=` 看时长 |
| leader 立即 `status: []` | 未调用 orchestration 技能 / SkillStopMiddleware 没拦到 / SOUL 没声明派活 | 换模型或加强 SOUL;检查 `skill_stop_names` 是否包含 `agent_orchestration` |
| dispatch 失败但 leader 文本看着正常 | 模型把 `roundtable-x(中文)` 整段塞进 `agent_name` | `_strip_display_suffix` 会兜底;若仍报 400,强化 SOUL 的「严格英文 id」 |
| 推荐 picks 为空 | 未输出 `[RECOMMEND_READY]` 或 picks 不在候选池 | 看 recommend 终态帧;检查 `candidate_ids` 集合 |
| Step 3 无文件 | 子 agent 未写 outputs / 错路径 | `GET .../artifacts` 对子 thread_id 逐个查;确认 sandbox outputs 路径 |
| `seat_ready` 顺序不固定 | `asyncio.as_completed` 按真实完成顺序推送 | 这是设计行为,不是 bug;前端不应按顺序依赖 agent_name |
### 日志关键字
```bash
grep "intent stream" .runtime/logs/*.log
grep "multi_agent\|multi-agent" .runtime/logs/*.log
grep "recommend stream" .runtime/logs/*.log
grep "dispatch failed" .runtime/logs/*.log
grep "intent stream" .runtime/logs/*.log
grep "multi_agent\|multi-agent" .runtime/logs/*.log
grep "recommend stream" .runtime/logs/*.log
grep "dispatch failed" .runtime/logs/*.log
grep "upstream-timing" .runtime/logs/*.log
grep "leader-timing" .runtime/logs/*.log
grep "special-timing" .runtime/logs/*.log
grep "headers_in=" .runtime/logs/*.log # 走代理症状
```
---
@@ -406,12 +716,24 @@ grep "dispatch failed" .runtime/logs/*.log
## 13. 测试建议
```
backend/tests/test_intent.py # init、INTENT_READY、clarification 优先级
backend/tests/test_recommend.py # picks 校验、候选池过滤
backend/tests/test_multi_agent.py # init SSE 事件、leader/special 终态、clarification
backend/tests/test_intent.py # init、INTENT_READY 优先级、clarification 字段
backend/tests/test_recommend.py # picks 校验、候选池过滤、clamp 2-6
backend/tests/test_multi_agent.py # init SSE 事件顺序、leader/special 终态、clarification
```
可用 `httpx.MockTransport` 模拟 loopback,不依赖真实 Gateway。
可用 `httpx.MockTransport` 模拟 loopback,不依赖真实 Gateway:
```python
def mock_handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/api/threads":
return httpx.Response(200, json={"thread_id": "t-1"})
...
transport = httpx.MockTransport(mock_handler)
client = httpx.AsyncClient(transport=transport, base_url=...)
```
针对 SSE 上游可用 `respx` 或直接 mock `_stream_upstream` 的字符串列表。
---
@@ -422,16 +744,16 @@ backend/tests/test_multi_agent.py # init SSE 事件、leader/special 终态
| Step 1 路由 | `offline-backend-20260512/backend/app/gateway/routers/intent.py` |
| 推荐路由 | `offline-backend-20260512/backend/app/gateway/routers/recommend.py` |
| Step 2 路由 | `offline-backend-20260512/backend/app/gateway/routers/multi_agent.py` |
| Step 3 列出/下载 | `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` |
| Step 3 列出/下载/编辑 | `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` |
| 路由注册 | `offline-backend-20260512/backend/app/gateway/app.py` |
| 澄清单选/多选 | `packages/harness/deerflow/tools/builtins/clarification_utils.py` |
| 任务理解 Agent | `.deer-flow/agents/roundtable-intent/` |
| 推荐 Agent | `.deer-flow/agents/roundtable-recommender/` |
| 圆桌席位 | `.deer-flow/agents/roundtable-*/` |
| 澄清单选/多选 | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/clarification_utils.py` |
| 任务理解 Agent | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/` |
| 推荐 Agent | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-recommender/` |
| 圆桌席位 | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-*/` |
| 协调智能体壳(累积) | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-coordinator-*/` |
| 前端 Step 1 API | `frontend-web/src/roundtable-planning/api/intent.ts` |
| 前端推荐 API | `frontend-web/src/roundtable-planning/api/recommend.ts` |
| 前端 Step 2 API | `frontend-web/src/roundtable-planning/api/multi-agent.ts` |
| 前端页面 | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
| 前端开发文档 | `frontend-web/docs/multi-agent-frontend-dev.md` |
| API 速查 | `frontend-web/docs/multi-agent-api.md` |
| Step 1 API 说明 | `frontend-web/docs/multi-agent-step1-api.md` |
| Step 3 API 说明 | `frontend-web/docs/multi-agent-step3-api.md` |
+235
View File
@@ -0,0 +1,235 @@
# 深蓝色 UI 规范
---
## 1. 色彩
### 1.1 标准色
色彩体系由四个色系构成,每个色系分主色与辅色两组,每组按明度从浅到深编号(01 最浅,数字越大越深)。
---
#### 1.1.1 色系总览
| 色系 | 用途定位 | 代码前缀 |
|------|----------|----------|
| 渐红系 | 警示、错误、强调操作 | ZS-001 |
| 渐蓝系 | 信息、链接、次级操作 | ZS-002 |
| 渐青系 | 成功、在线状态、数据高亮 | ZS-003 |
| 深蓝系 | 页面背景、容器、导航底色 | ZS-004 |
---
#### 1.1.2 渐红系(ZS-001)
用于错误提示、危险操作、重要告警。
| 编号 | 色值 | 说明 |
|------|------|------|
| ZS-001-01 | `#FFF1F0` | 极浅红,错误背景底色 |
| ZS-001-02 | `#FFCCC7` | 浅红,输入框错误边框 |
| ZS-001-03 | `#FFA39E` | — |
| ZS-001-04 | `#FF7875` | — |
| ZS-001-05 | `#FF4D4F` | 标准红,Tag / Badge |
| ZS-001-06 | `#F5222D` | 主色红,按钮 / 图标 |
| ZS-001-07 | `#CF1322` | 深红,Hover 状态 |
| ZS-001-08 | `#A8071A` | 深红,Active / Press |
| ZS-001-09 | `#820014` | 极深红 |
| ZS-001-10 | `#5C0011` | 最深红 |
---
#### 1.1.3 渐蓝系(ZS-002)
用于信息提示、链接、次级按钮。
| 编号 | 色值 | 说明 |
|------|------|------|
| ZS-002-01 | `#E6F4FF` | 极浅蓝,信息背景底色 |
| ZS-002-02 | `#BAE0FF` | 浅蓝 |
| ZS-002-03 | `#91CAFF` | — |
| ZS-002-04 | `#69B1FF` | — |
| ZS-002-05 | `#4096FF` | 标准蓝,链接 / 信息 Tag |
| ZS-002-06 | `#1677FF` | 主色蓝,主操作按钮 |
| ZS-002-07 | `#0958D9` | 深蓝,Hover |
| ZS-002-08 | `#003EB3` | 深蓝,Active |
| ZS-002-09 | `#002C8C` | 极深蓝 |
| ZS-002-10 | `#001D6C` | 最深蓝 |
---
#### 1.1.4 渐青系(ZS-003)
用于成功状态、在线标识、数据图表高亮。
| 编号 | 色值 | 说明 |
|------|------|------|
| ZS-003-01 | `#E6FFFB` | 极浅青,成功背景 |
| ZS-003-02 | `#B5F5EC` | 浅青 |
| ZS-003-03 | `#87E8DE` | — |
| ZS-003-04 | `#5CDBD3` | — |
| ZS-003-05 | `#36CFC9` | 标准青,成功 Tag |
| ZS-003-06 | `#13C2C2` | 主色青,在线点 / 图标 |
| ZS-003-07 | `#08979C` | 深青,Hover |
| ZS-003-08 | `#006D75` | 深青,Active |
| ZS-003-09 | `#00474F` | 极深青 |
| ZS-003-10 | `#002329` | 最深青 |
---
#### 1.1.5 深蓝系(ZS-004)
用于页面背景、导航栏、侧边栏、卡片容器。这是深蓝主题的核心色系。
| 编号 | 色值 | 说明 |
|------|------|------|
| ZS-004-01 | `#E8EDF5` | 极浅,分割线 / 禁用底色 |
| ZS-004-02 | `#C5CFE0` | 浅,边框 |
| ZS-004-03 | `#9BADC8` | — |
| ZS-004-04 | `#6E88B0` | — |
| ZS-004-05 | `#4A6491` | 中深蓝,次级文字 |
| ZS-004-06 | `#2D4473` | 深蓝,卡片容器背景 |
| ZS-004-07 | `#1A2F5A` | 深蓝,侧边栏背景 |
| ZS-004-08 | `#122347` | 深蓝,导航栏背景 |
| ZS-004-09 | `#0C1836` | 极深蓝,页面背景 |
| ZS-004-10 | `#060E24` | 最深,全局底色 |
---
#### 1.1.6 功能色(主色 / 字体色 / 辅色 / 背景色)
从标准色系中提取,用于具体 UI 角色指定。
| 角色 | 色值 | 来源 |
|------|------|------|
| 主色(Primary) | `#1677FF` | ZS-002-06 |
| 主色 Hover | `#0958D9` | ZS-002-07 |
| 主色 Active | `#003EB3` | ZS-002-08 |
| 正常字体色 | `#C5CFE0` | ZS-004-02 |
| 次级字体色 | `#6E88B0` | ZS-004-04 |
| 禁用字体色 | `#4A6491` | ZS-004-05 |
| 辅色(Accent) | `#13C2C2` | ZS-003-06 |
| 页面背景色 | `#0C1836` | ZS-004-09 |
| 容器背景色 | `#1A2F5A` | ZS-004-07 |
| 白色 | `#FFFFFF` | — |
---
#### 1.1.7 小面积装饰色
用于图标点缀、徽标、少量强调,不用于大面积背景。
| 色值 | 说明 |
|------|------|
| `#6388FF` | 紫蓝,图标装饰 |
| `#F5222D` | 红色,危险徽标 |
| `#12306C` | 深蓝,品牌点缀 |
| `#478880` | 青绿,辅助点缀 |
| `#950404` | 深红,重要告警点 |
---
#### 1.1.8 图表配色
**通用图表色序**(面积较大,如柱状图、折线图、饼图):
| 序号 | 色值 |
|------|------|
| 1 | `#1677FF` |
| 2 | `#13C2C2` |
| 3 | `#4096FF` |
| 4 | `#36CFC9` |
| 5 | `#69B1FF` |
| 6 | `#5CDBD3` |
| 7 | `#91CAFF` |
| 8 | `#87E8DE` |
**小面积图表色序**(面积较小,如散点、小徽章、迷你图):
| 序号 | 色值 |
|------|------|
| 1 | `#4096FF` |
| 2 | `#36CFC9` |
| 3 | `#FF4D4F` |
| 4 | `#FFA39E` |
| 5 | `#6388FF` |
| 6 | `#478880` |
---
## 2. 字体
### 2.1 全局样式 - 字体
---
#### 2.1.1 大屏页面字体规范
##### 中文字体:Noto Sans S Chinese
| 样式 | 使用场景 |
|------|----------|
| 24px Bold | 一级标题 |
| 24px Medium | 次要一级标题 |
| 22px Medium | 二级标题 |
| 22px Regular | 正文 |
| 20px Regular | 排版空间有限的正文 |
##### 数字字体:D-DIN
| 样式 | 使用场景 |
|------|----------|
| 32px Bold | 重要数据展示 |
| 32px Regular | 普通数据展示 |
| 26px Bold | 常规数据展示 |
| 26px Regular | 常规数据展示 |
##### 特殊数字:DS-Digital
| 样式 | 使用场景 |
|------|----------|
| 50px Bold | 大屏主数据展示 |
##### 表格字体:Arial
| 样式 | 使用场景 |
|------|----------|
| 20px Regular | 表格横纵轴数字 |
---
#### 2.1.2 后台系统字体规范
##### 中文字体:Noto Sans S Chinese
| 样式 | 使用场景 |
|------|----------|
| 18px Bold | 导航、一级标题 |
| 18px Medium | 次要一级标题 |
| 16px Bold | 二级标题 |
| 16px Medium | 次要二级标题 |
| 16px Regular | 正文、按钮内部文字等 |
| 16px Light | 次要正文 |
##### 数字字体:D-DIN
| 样式 | 使用场景 |
|------|----------|
| 26px Bold | 重要数据展示 |
| 26px Regular | 普通数据展示 |
| 24px Bold | 常规数据展示(小) |
| 24px Regular | 常规数据展示(小) |
##### LOGO 字体:DOUYUFont
| 样式 | 使用场景 |
|------|----------|
| 20px 文字样式 | 页面顶部系统名称 |
##### 表格数字:Arial
| 样式 | 使用场景 |
|------|----------|
| 16px Regular | 表格横纵轴数字 |
| 14px Regular | 表格横轴数字(空间有限) |
+12 -49
View File
@@ -400,63 +400,26 @@ export function PageSidebar({
onOpenChange={setNotificationOpen}
/>
<aside
className={`fixed left-0 top-0 z-40 flex h-screen flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
className={`fixed left-0 top-16 z-40 flex h-[calc(100vh-4rem)] flex-col overflow-hidden shadow-sm theme-transition transition-all duration-300 ${
isDark
? "border-r border-blue-800/30 bg-slate-900"
: "border-r border-red-100 bg-white"
} ${collapsed ? "w-16" : "w-64"}`}
>
<div
className={`flex h-16 items-center theme-transition ${
isDark ? "border-b border-blue-800/30" : "border-b border-red-100"
} ${collapsed ? "justify-center px-2" : "justify-between px-6"}`}
<button
type="button"
onClick={onToggleCollapsed}
title={collapsed ? "展开侧边栏" : "收起侧边栏"}
className={`absolute -right-3 top-6 z-50 flex h-6 w-6 items-center justify-center rounded-full shadow-md theme-transition ${
isDark
? "bg-slate-700 text-slate-300 hover:bg-slate-600"
: "bg-white text-gray-500 hover:bg-red-50 hover:text-red-600 border border-red-100"
}`}
>
<div className="flex min-w-0 items-center gap-3">
<div
className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg transition-transform duration-300 ease-out ${
isDark
? "bg-gradient-to-br from-blue-500 to-cyan-400"
: "bg-gradient-to-br from-red-500 to-red-400"
}`}
>
<FileText className="h-4 w-4 text-white" />
</div>
{!collapsed && (
<div className="overflow-hidden whitespace-nowrap animate-in fade-in-0 slide-in-from-left-2 duration-300">
<h1
className={`text-sm font-semibold theme-transition ${
isDark ? "text-white" : "text-gray-900"
}`}
>
CM助手
</h1>
</div>
)}
</div>
{!collapsed && (
<button
type="button"
onClick={onToggleCollapsed}
className={iconButtonClass}
title="Collapse sidebar"
>
<ChevronLeft className="h-4 w-4" />
</button>
)}
</div>
{collapsed ? <ChevronRight className="h-3 w-3" /> : <ChevronLeft className="h-3 w-3" />}
</button>
<nav className={`flex-1 overflow-y-auto ${collapsed ? "p-2" : "p-4"} space-y-2 transition-all duration-300 ease-out`}>
{collapsed && (
<button
type="button"
onClick={onToggleCollapsed}
className={`flex w-full items-center justify-center ${iconButtonClass}`}
title="Expand sidebar"
>
<ChevronRight className="h-5 w-5" />
</button>
)}
{visibleMenuItems.map((item) =>
item.children ? renderBranch(item) : renderLeaf(item),
)}
@@ -3,6 +3,7 @@
import { BotIcon } from "lucide-react";
import { type Agent } from "@/core/agents";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
export function AgentWelcome({
@@ -14,8 +15,10 @@ export function AgentWelcome({
agent: Agent | null | undefined;
agentName: string;
}) {
const displayName = agent?.name ?? agentName;
const description = agent?.description;
const displayName = desensitize(agent?.name ?? agentName);
const description = agent?.description
? desensitize(agent.description)
: undefined;
return (
<div
@@ -48,6 +48,7 @@ import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useSkills } from "@/core/skills/hooks";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
interface AgentCardProps {
@@ -195,12 +196,12 @@ export function AgentCard({ agent }: AgentCardProps) {
<BotIcon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<CardTitle className="truncate text-base" title={agent.name}>
{agent.name}
<CardTitle className="truncate text-base" title={desensitize(agent.name)}>
{desensitize(agent.name)}
</CardTitle>
{agent.model && (
<Badge variant="secondary" className="mt-0.5 text-xs">
{agent.model}
{desensitize(agent.model)}
</Badge>
)}
</div>
@@ -252,7 +253,7 @@ export function AgentCard({ agent }: AgentCardProps) {
</div>
{agent.description && (
<CardDescription className="mt-2 line-clamp-2 text-sm">
{agent.description}
{desensitize(agent.description)}
</CardDescription>
)}
</CardHeader>
@@ -272,7 +273,7 @@ export function AgentCard({ agent }: AgentCardProps) {
variant="outline"
className="text-xs"
>
{group}
{desensitize(group)}
</Badge>
))}
{agent.skills?.map((skill) => (
@@ -286,7 +287,7 @@ export function AgentCard({ agent }: AgentCardProps) {
setDetailSkillName(skill);
}}
>
{skill}
{desensitize(skill)}
</Badge>
))}
</div>
@@ -377,7 +378,7 @@ export function AgentCard({ agent }: AgentCardProps) {
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>编辑智能体:{agent.name}</DialogTitle>
<DialogTitle>编辑智能体:{desensitize(agent.name)}</DialogTitle>
<DialogDescription>
可以修改名称、描述、技能白名单、SOUL 内容与发布状态。
</DialogDescription>
@@ -428,10 +429,10 @@ export function AgentCard({ agent }: AgentCardProps) {
size="sm"
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill.name)}
title={skill.description}
title={desensitize(skill.description ?? "")}
className="h-8 max-w-[200px] rounded-r-none"
>
<span className="truncate">{skill.name}</span>
<span className="truncate">{desensitize(skill.name)}</span>
</Button>
<Button
type="button"
@@ -50,6 +50,7 @@ import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import { ArtifactLink } from "../citations/artifact-link";
@@ -608,7 +609,7 @@ export function ArtifactFileDetail({
viewMode === "preview" &&
(language === "markdown" || language === "html") && (
<ArtifactFilePreview
content={displayContent}
content={desensitize(displayContent ?? "")}
language={language ?? "text"}
animated={showPreviewSwitchPulse}
activeCursor={isApplyingEdit}
@@ -618,7 +619,11 @@ export function ArtifactFileDetail({
<div className="size-full">
<CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent"
value={displayContent ?? ""}
value={
canEditArtifact
? (displayContent ?? "")
: desensitize(displayContent ?? "")
}
readonly={!canEditArtifact}
onChange={
canEditArtifact
@@ -8,6 +8,7 @@ import {
type MessageResponseProps,
} from "@/components/ai-elements/message";
import { streamdownPlugins } from "@/core/streamdown";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link";
@@ -63,6 +64,8 @@ export function MarkdownContent({
if (!content) return null;
const displayContent = desensitize(content);
return (
<MessageResponse
className={className}
@@ -70,7 +73,7 @@ export function MarkdownContent({
rehypePlugins={rehypePlugins}
components={components}
>
{content}
{displayContent}
</MessageResponse>
);
}
@@ -24,31 +24,32 @@ import {
} from "@/components/ai-elements/reasoning";
import { Task, TaskTrigger } from "@/components/ai-elements/task";
import { Badge } from "@/components/ui/badge";
import { resolveArtifactURL } from "@/core/artifacts/utils";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
import { resolveArtifactURL } from "@/core/artifacts/utils";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
extractReasoningContentFromMessage,
parseUploadedFiles,
stripArtifactTag,
stripUploadedFilesTag,
type FileInMessage,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import {
applyInlineCitations,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
import { humanMessagePlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link";
import { CopyButton } from "../copy-button";
import { SkillDisplayBlock } from "../skill-display-block";
import { ThreadContext } from "./context";
type FileInMessage,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import {
applyInlineCitations,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
import { humanMessagePlugins } from "@/core/streamdown";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link";
import { CopyButton } from "../copy-button";
import { SkillDisplayBlock } from "../skill-display-block";
import { ThreadContext } from "./context";
import { DownloadWordButton } from "./download-word-button";
import { MarkdownContent, type MarkdownContentProps } from "./markdown-content";
import { MarkdownContent, type MarkdownContentProps } from "./markdown-content";
import { MessageTokenUsage } from "./message-token-usage";
/**
@@ -79,16 +80,16 @@ export function MessageListItem({
message,
isLoading,
threadId,
tokenUsageEnabled = false,
displaySources = [],
}: {
className?: string;
message: Message;
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
}) {
tokenUsageEnabled = false,
displaySources = [],
}: {
className?: string;
message: Message;
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
}) {
const isHuman = message.type === "human";
const messageMarkdown =
extractContentFromMessage(message) ??
@@ -101,12 +102,12 @@ export function MessageListItem({
>
<MessageContent
className={isHuman ? "w-fit" : "w-full"}
message={message}
isLoading={isLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
/>
message={message}
isLoading={isLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
/>
{!isLoading && (
<MessageToolbar
className={cn(
@@ -165,16 +166,16 @@ function MessageContent_({
message,
isLoading = false,
threadId,
tokenUsageEnabled = false,
displaySources = [],
}: {
className?: string;
message: Message;
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
}) {
tokenUsageEnabled = false,
displaySources = [],
}: {
className?: string;
message: Message;
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
}) {
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const isHuman = message.type === "human";
const resolveArtifact = useArtifactResolver(threadId);
@@ -183,12 +184,12 @@ function MessageContent_({
img: (props: ImgHTMLAttributes<HTMLImageElement>) => (
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
),
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (typeof props.children === "string" && /^citation:/i.test(props.children)) {
return <CitationLink href={href} {...props} />;
}
if (href?.startsWith("/mnt/")) {
const url = resolveArtifact(href);
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (typeof props.children === "string" && /^citation:/i.test(props.children)) {
return <CitationLink href={href} {...props} />;
}
if (href?.startsWith("/mnt/")) {
const url = resolveArtifact(href);
return (
<a
{...props}
@@ -219,12 +220,12 @@ function MessageContent_({
return files as FileInMessage[];
}, [message.additional_kwargs?.files, rawContent]);
const contentToDisplay = useMemo(() => {
if (isHuman) {
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
}
return applyInlineCitations(rawContent ?? "", displaySources);
}, [rawContent, isHuman, displaySources]);
const contentToDisplay = useMemo(() => {
if (isHuman) {
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
}
return applyInlineCitations(rawContent ?? "", displaySources);
}, [rawContent, isHuman, displaySources]);
const filesList =
files && files.length > 0 ? (
@@ -239,7 +240,7 @@ function MessageContent_({
<TaskTrigger title="">
<div className="text-muted-foreground flex w-full cursor-default items-center gap-2 text-sm select-none">
<Loader className="size-4" />
<span>{contentToDisplay}</span>
<span>{desensitize(contentToDisplay)}</span>
</div>
</TaskTrigger>
</Task>
@@ -253,7 +254,7 @@ function MessageContent_({
<AIElementMessageContent className={className}>
<Reasoning isStreaming={isLoading}>
<ReasoningTrigger />
<ReasoningContent>{reasoningContent}</ReasoningContent>
<ReasoningContent>{desensitize(reasoningContent)}</ReasoningContent>
</Reasoning>
<MessageTokenUsage
enabled={tokenUsageEnabled}
@@ -272,7 +273,7 @@ function MessageContent_({
components={components}
parseIncompleteMarkdown={false}
>
{contentToDisplay}
{desensitize(contentToDisplay)}
</AIElementMessageResponse>
) : null;
return (
@@ -287,17 +288,17 @@ function MessageContent_({
);
}
return (
<AIElementMessageContent className={className}>
{filesList}
<AssistantDisplayContent
content={contentToDisplay}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
components={components}
/>
<MessageTokenUsage
return (
<AIElementMessageContent className={className}>
{filesList}
<AssistantDisplayContent
content={contentToDisplay}
displaySources={displaySources}
isLoading={isLoading}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
components={components}
/>
<MessageTokenUsage
enabled={tokenUsageEnabled}
isLoading={isLoading}
message={message}
@@ -472,160 +473,160 @@ function RichFileCard({
);
}
const MessageContent = memo(MessageContent_);
function AssistantDisplayContent({
content,
displaySources,
isLoading,
rehypePlugins,
components,
}: {
content: string;
displaySources: RuntimeDisplaySource[];
isLoading: boolean;
rehypePlugins: MarkdownContentProps["rehypePlugins"];
components: MarkdownContentProps["components"];
}) {
const { parts, placedKeys } = useMemo(
() => splitSkillDisplayPlaceholders(content, displaySources),
[content, displaySources],
);
const fallbackBlocks = useMemo(() => {
return groupDisplaySources(displaySources).filter(
(group) => group.mode !== "citation" && !placedKeys.has(group.key),
);
}, [displaySources, placedKeys]);
return (
<>
{parts.map((part, index) =>
part.type === "markdown" ? (
<MarkdownContent
key={`markdown-${index}`}
content={part.content}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
className="my-3"
components={components}
/>
) : (
<SkillDisplayBlock
key={`display-${index}`}
mode={part.mode}
sources={part.sources}
/>
),
)}
{fallbackBlocks.map((group) => (
<SkillDisplayBlock
key={`fallback-${group.key}`}
mode={group.mode}
sources={group.sources}
/>
))}
</>
);
}
type DisplayPart =
| { type: "markdown"; content: string }
| {
type: "display";
key: string;
mode: RuntimeDisplaySource["mode"];
sources: RuntimeDisplaySource[];
};
function splitSkillDisplayPlaceholders(
content: string,
displaySources: RuntimeDisplaySource[],
): { parts: DisplayPart[]; placedKeys: Set<string> } {
const pattern = /<skill-display\s+([^>]*?)\/>/g;
const parts: DisplayPart[] = [];
const placedKeys = new Set<string>();
let cursor = 0;
for (const match of content.matchAll(pattern)) {
const start = match.index ?? 0;
const before = content.slice(cursor, start);
if (before.trim()) {
parts.push({ type: "markdown", content: before });
}
const attrs = parseDisplayAttrs(match[1] ?? "");
const group = findDisplayGroup(displaySources, attrs.tool, attrs.mode);
if (group) {
parts.push({
type: "display",
key: group.key,
mode: group.mode,
sources: group.sources,
});
placedKeys.add(group.key);
}
cursor = start + match[0].length;
}
const rest = content.slice(cursor);
if (rest.trim() || parts.length === 0) {
parts.push({ type: "markdown", content: rest });
}
return { parts, placedKeys };
}
function parseDisplayAttrs(raw: string): { tool?: string; mode?: RuntimeDisplaySource["mode"] } {
const attrs: Record<string, string> = {};
for (const match of raw.matchAll(/([a-zA-Z][\w-]*)="([^"]*)"/g)) {
attrs[match[1]!] = match[2]!;
}
return {
tool: attrs.tool || attrs.skill || attrs.name,
mode: attrs.mode as RuntimeDisplaySource["mode"] | undefined,
};
}
function findDisplayGroup(
sources: RuntimeDisplaySource[],
tool?: string,
mode?: RuntimeDisplaySource["mode"],
) {
return groupDisplaySources(sources).find((group) => {
if (mode && group.mode !== mode) {
return false;
}
if (!tool) {
return true;
}
const left = normalizeName(group.skillName);
const right = normalizeName(tool);
return left === right || left.includes(right) || right.includes(left);
});
}
function groupDisplaySources(sources: RuntimeDisplaySource[]) {
const groups = new Map<
string,
{
key: string;
skillName: string;
mode: RuntimeDisplaySource["mode"];
sources: RuntimeDisplaySource[];
}
>();
for (const source of sources) {
const key = `${source.skillName}:${source.mode}`;
const group =
groups.get(key) ??
{
key,
skillName: source.skillName,
mode: source.mode,
sources: [],
};
group.sources.push(source);
groups.set(key, group);
}
return [...groups.values()];
}
function normalizeName(name: string) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
const MessageContent = memo(MessageContent_);
function AssistantDisplayContent({
content,
displaySources,
isLoading,
rehypePlugins,
components,
}: {
content: string;
displaySources: RuntimeDisplaySource[];
isLoading: boolean;
rehypePlugins: MarkdownContentProps["rehypePlugins"];
components: MarkdownContentProps["components"];
}) {
const { parts, placedKeys } = useMemo(
() => splitSkillDisplayPlaceholders(content, displaySources),
[content, displaySources],
);
const fallbackBlocks = useMemo(() => {
return groupDisplaySources(displaySources).filter(
(group) => group.mode !== "citation" && !placedKeys.has(group.key),
);
}, [displaySources, placedKeys]);
return (
<>
{parts.map((part, index) =>
part.type === "markdown" ? (
<MarkdownContent
key={`markdown-${index}`}
content={part.content}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
className="my-3"
components={components}
/>
) : (
<SkillDisplayBlock
key={`display-${index}`}
mode={part.mode}
sources={part.sources}
/>
),
)}
{fallbackBlocks.map((group) => (
<SkillDisplayBlock
key={`fallback-${group.key}`}
mode={group.mode}
sources={group.sources}
/>
))}
</>
);
}
type DisplayPart =
| { type: "markdown"; content: string }
| {
type: "display";
key: string;
mode: RuntimeDisplaySource["mode"];
sources: RuntimeDisplaySource[];
};
function splitSkillDisplayPlaceholders(
content: string,
displaySources: RuntimeDisplaySource[],
): { parts: DisplayPart[]; placedKeys: Set<string> } {
const pattern = /<skill-display\s+([^>]*?)\/>/g;
const parts: DisplayPart[] = [];
const placedKeys = new Set<string>();
let cursor = 0;
for (const match of content.matchAll(pattern)) {
const start = match.index ?? 0;
const before = content.slice(cursor, start);
if (before.trim()) {
parts.push({ type: "markdown", content: before });
}
const attrs = parseDisplayAttrs(match[1] ?? "");
const group = findDisplayGroup(displaySources, attrs.tool, attrs.mode);
if (group) {
parts.push({
type: "display",
key: group.key,
mode: group.mode,
sources: group.sources,
});
placedKeys.add(group.key);
}
cursor = start + match[0].length;
}
const rest = content.slice(cursor);
if (rest.trim() || parts.length === 0) {
parts.push({ type: "markdown", content: rest });
}
return { parts, placedKeys };
}
function parseDisplayAttrs(raw: string): { tool?: string; mode?: RuntimeDisplaySource["mode"] } {
const attrs: Record<string, string> = {};
for (const match of raw.matchAll(/([a-zA-Z][\w-]*)="([^"]*)"/g)) {
attrs[match[1]!] = match[2]!;
}
return {
tool: attrs.tool || attrs.skill || attrs.name,
mode: attrs.mode as RuntimeDisplaySource["mode"] | undefined,
};
}
function findDisplayGroup(
sources: RuntimeDisplaySource[],
tool?: string,
mode?: RuntimeDisplaySource["mode"],
) {
return groupDisplaySources(sources).find((group) => {
if (mode && group.mode !== mode) {
return false;
}
if (!tool) {
return true;
}
const left = normalizeName(group.skillName);
const right = normalizeName(tool);
return left === right || left.includes(right) || right.includes(left);
});
}
function groupDisplaySources(sources: RuntimeDisplaySource[]) {
const groups = new Map<
string,
{
key: string;
skillName: string;
mode: RuntimeDisplaySource["mode"];
sources: RuntimeDisplaySource[];
}
>();
for (const source of sources) {
const key = `${source.skillName}:${source.mode}`;
const group =
groups.get(key) ??
{
key,
skillName: source.skillName,
mode: source.mode,
sources: [],
};
group.sources.push(source);
groups.set(key, group);
}
return [...groups.values()];
}
function normalizeName(name: string) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
@@ -58,6 +58,7 @@ import {
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime";
export function RecentChatList() {
@@ -211,7 +212,7 @@ export function RecentChatList() {
className="block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)}
>
{titleOfThread(thread)}
{desensitize(titleOfThread(thread))}
</Link>
</SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
@@ -42,6 +42,7 @@ import type {
MemoryTarget,
MemoryV2Config,
} from "@/core/memory/types";
import { desensitize } from "@/lib/desensitize";
import { SettingsSection } from "./settings-section";
@@ -173,7 +174,7 @@ export function MemorySettingsPage() {
className="flex items-start justify-between gap-3 rounded-md border p-3"
>
<p className="min-w-0 flex-1 whitespace-pre-wrap text-sm [overflow-wrap:anywhere]">
{entry}
{desensitize(entry)}
</p>
<div className="flex shrink-0 items-center gap-1">
<Button
@@ -234,7 +235,7 @@ export function MemorySettingsPage() {
</SelectItem>
{experts.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
{desensitize(a.name)}
</SelectItem>
))}
</SelectContent>
@@ -272,7 +273,7 @@ export function MemorySettingsPage() {
</SelectItem>
{experts.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
{desensitize(a.name)}
</SelectItem>
))}
</SelectContent>
@@ -356,7 +357,7 @@ export function MemorySettingsPage() {
<div className="text-muted-foreground mb-1 font-medium">
{m.entryToDeleteLabel}
</div>
<p className="break-words">{entryToDelete.content}</p>
<p className="break-words">{desensitize(entryToDelete.content)}</p>
</div>
) : null}
<DialogFooter>
@@ -539,10 +540,10 @@ function MemoryV1View({
{Math.round(fact.confidence * 100)}%
</span>
</div>
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{fact.content}</p>
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(fact.content)}</p>
{fact.sourceError && (
<p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">
避免:{fact.sourceError}
避免:{desensitize(fact.sourceError)}
</p>
)}
</div>
@@ -566,7 +567,7 @@ function MemoryV1View({
<dt className="font-medium text-muted-foreground mb-0.5">
{CTX_LABELS[key] ?? key}
</dt>
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(value)}</dd>
</div>
))}
</dl>
@@ -585,7 +586,7 @@ function MemoryV1View({
<dt className="font-medium text-muted-foreground mb-0.5">
{HISTORY_LABELS[key] ?? key}
</dt>
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{value}</dd>
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(value)}</dd>
</div>
))}
</dl>
@@ -686,7 +687,7 @@ function MemorySearchPanel() {
</SelectItem>
{experts.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
{desensitize(a.name)}
</SelectItem>
))}
</SelectContent>
@@ -719,7 +720,7 @@ function MemorySearchPanel() {
) : results !== null ? (
mode === "reflect" ? (
<div className="rounded-md border p-3 text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">
{reflectText.trim() || m.searchNoResults}
{reflectText.trim() ? desensitize(reflectText.trim()) : m.searchNoResults}
</div>
) : results.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-3 text-sm">
@@ -735,7 +736,7 @@ function MemorySearchPanel() {
key={`${index}-${item.slice(0, 16)}`}
className="rounded-md border p-3 text-sm whitespace-pre-wrap [overflow-wrap:anywhere]"
>
{item}
{desensitize(item)}
</div>
))}
</div>
@@ -68,6 +68,7 @@ import {
} from "@/core/skills/hooks";
import type { Skill } from "@/core/skills/type";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section";
@@ -404,7 +405,7 @@ function SkillRow({
>
<ItemTitle>
<div className="flex flex-wrap items-center gap-2">
<span>{skill.name}</span>
<span>{desensitize(skill.name)}</span>
{isBuiltin && (
<Badge variant="secondary" className="text-xs">
{t.settings.skills.badgeBuiltin}
@@ -474,7 +475,7 @@ function SkillRow({
</div>
</ItemTitle>
<ItemDescription className="line-clamp-4">
{skill.description}
{desensitize(skill.description ?? "")}
</ItemDescription>
{skill.use_count !== undefined && (
<div className="text-muted-foreground mt-1 text-xs">
@@ -560,7 +561,9 @@ function SkillRow({
<DialogHeader>
<DialogTitle>{t.settings.skills.deleteAction}</DialogTitle>
<DialogDescription>
{t.settings.skills.deleteConfirm.replace("{name}", skill.name)}
{desensitize(
t.settings.skills.deleteConfirm.replace("{name}", skill.name),
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -809,22 +812,22 @@ function InstallSkillDialog({
<div className="flex flex-col gap-2 rounded-md border p-3 text-sm">
<div className="flex items-center gap-2 text-emerald-600">
<CheckCircle2 className="size-4" />
<span className="font-medium">{validation.skill_name}</span>
<span className="font-medium">{desensitize(validation.skill_name)}</span>
</div>
<Field label={dialog.descriptionLabel}>
<span className="line-clamp-3 whitespace-pre-wrap">
{validation.description || "—"}
{validation.description ? desensitize(validation.description) : "—"}
</span>
</Field>
{validation.license && (
<Field label={dialog.licenseLabel}>{validation.license}</Field>
<Field label={dialog.licenseLabel}>{desensitize(validation.license)}</Field>
)}
{validation.allowed_tools.length > 0 && (
<Field label={dialog.toolsLabel}>
<div className="flex flex-wrap gap-1">
{validation.allowed_tools.map((tool) => (
<Badge key={tool} variant="secondary" className="text-xs">
{tool}
{desensitize(tool)}
</Badge>
))}
</div>
@@ -834,7 +837,7 @@ function InstallSkillDialog({
<Field label={dialog.warningsLabel}>
<ul className="list-disc pl-4 text-xs">
{validation.warnings.map((w, idx) => (
<li key={idx}>{w}</li>
<li key={idx}>{desensitize(w)}</li>
))}
</ul>
</Field>
@@ -843,7 +846,9 @@ function InstallSkillDialog({
<div className="text-amber-600 mt-1 flex items-start gap-2 text-xs">
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span>
{dialog.conflictCustom.replace("{name}", validation.skill_name)}
{desensitize(
dialog.conflictCustom.replace("{name}", validation.skill_name),
)}
</span>
</div>
)}
@@ -851,7 +856,9 @@ function InstallSkillDialog({
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
<span>
{(adminCanOverwritePublic ? dialog.conflictPublicAdminHint : dialog.conflictPublic).replace("{name}", validation.skill_name)}
{desensitize(
(adminCanOverwritePublic ? dialog.conflictPublicAdminHint : dialog.conflictPublic).replace("{name}", validation.skill_name),
)}
</span>
</div>
)}
@@ -30,6 +30,7 @@ import {
import { useI18n } from "@/core/i18n/hooks";
import { useDeleteCustomSkill, useSetSkillState } from "@/core/skills/hooks";
import type { Skill } from "@/core/skills/type";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
type Kind = "stale" | "archived";
@@ -146,7 +147,7 @@ function SkillStateRow({ skill, kind }: { skill: Skill; kind: Kind }) {
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{skill.name}</span>
<span className="font-medium">{desensitize(skill.name)}</span>
{isArchived ? (
<Badge variant="secondary" className="text-xs" title={tt.badgeArchivedTitle}>
{tt.badgeArchived}
@@ -180,7 +181,7 @@ function SkillStateRow({ skill, kind }: { skill: Skill; kind: Kind }) {
)}
</div>
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">
{skill.description}
{desensitize(skill.description ?? "")}
</p>
<p className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 text-xs">
<span>{timeLabel}</span>
@@ -254,7 +255,7 @@ function SkillStateRow({ skill, kind }: { skill: Skill; kind: Kind }) {
<DialogHeader>
<DialogTitle>{tt.deleteAction}</DialogTitle>
<DialogDescription>
{tt.deleteConfirm.replace("{name}", skill.name)}
{desensitize(tt.deleteConfirm.replace("{name}", skill.name))}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -295,12 +296,12 @@ function SkillViewDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{skill.name}</DialogTitle>
<DialogTitle>{desensitize(skill.name)}</DialogTitle>
<DialogDescription>{tv.title}</DialogDescription>
</DialogHeader>
<div className="grid gap-3 text-sm">
<Row label={tv.descriptionLabel}>
<span className="whitespace-pre-wrap">{skill.description}</span>
<span className="whitespace-pre-wrap">{desensitize(skill.description ?? "")}</span>
</Row>
<Row label={tv.stateLabel}>
<span className={cn("font-medium", stateColor(skill.state))}>
@@ -40,6 +40,7 @@ import {
} from "@/core/skills/hooks";
import type { Skill } from "@/core/skills/type";
import { reasoningPlugins } from "@/core/streamdown";
import { desensitize } from "@/lib/desensitize";
const markdownClassName =
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
@@ -163,7 +164,7 @@ export function SkillDetailDialog({
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<DialogTitle className="truncate text-base">
{skill?.name ?? skillName ?? ""}
{desensitize(skill?.name ?? skillName ?? "")}
</DialogTitle>
{skill?.builtin && (
<Badge variant="secondary" className="text-xs">
@@ -181,7 +182,7 @@ export function SkillDetailDialog({
)}
</div>
<DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed">
{skill?.description || "-"}
{skill?.description ? desensitize(skill.description) : "-"}
</DialogDescription>
</div>
</div>
@@ -394,7 +395,7 @@ export function SkillDetailDialog({
</div>
) : showRawSource ? (
<pre className="text-foreground/80 max-w-full overflow-x-auto rounded-lg border bg-muted p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
{contentData?.content}
{desensitize(contentData?.content ?? "")}
</pre>
) : (
<MarkdownContent
@@ -1,5 +1,6 @@
import { XIcon } from "lucide-react";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
import type { Tag } from "@/core/tags";
@@ -24,7 +25,7 @@ export function TagChip({
className,
)}
>
{tag.name}
{desensitize(tag.name)}
{onRemove && (
<button
type="button"
@@ -3,6 +3,7 @@ import { useEffect } from "react";
import { useI18n } from "@/core/i18n/hooks";
import type { AgentThreadState } from "@/core/threads";
import { desensitize } from "@/lib/desensitize";
import { useThreadChat } from "./chats";
import { FlipDisplay } from "./flip-display";
@@ -44,7 +45,7 @@ export function ThreadTitle({
}
return (
<FlipDisplay uniqueKey={threadId}>
{thread.values.title ?? "Untitled"}
{desensitize(thread.values.title ?? "Untitled")}
</FlipDisplay>
);
}
+28 -2
View File
@@ -1,6 +1,8 @@
"use client";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { desensitize } from "@/lib/desensitize";
import { useI18nContext } from "./context";
import { getLocaleFromCookie, setLocaleInCookie } from "./cookies";
@@ -13,10 +15,34 @@ import {
type Locale,
} from "./index";
function wrapWithDesensitize<T>(value: T): T {
if (typeof value === "string") {
return desensitize(value) as T;
}
if (typeof value === "function") {
return ((...args: unknown[]) => {
const result = (value as (...a: unknown[]) => unknown)(...args);
return wrapWithDesensitize(result);
}) as T;
}
if (Array.isArray(value)) {
return value.map((item) => wrapWithDesensitize(item)) as T;
}
if (value !== null && typeof value === "object") {
return new Proxy(value as object, {
get(target, prop, receiver) {
return wrapWithDesensitize(Reflect.get(target, prop, receiver));
},
}) as T;
}
return value;
}
export function useI18n() {
const { locale, setLocale } = useI18nContext();
const t = translations[locale] ?? translations[DEFAULT_LOCALE];
const rawT = translations[locale] ?? translations[DEFAULT_LOCALE];
const t = useMemo(() => wrapWithDesensitize(rawT), [rawT]);
const changeLocale = (newLocale: Locale) => {
setLocale(newLocale);
+71
View File
@@ -0,0 +1,71 @@
/**
* 脱敏词映射表
* key: 原始词(支持正则特殊字符,会被转义)
* value: 脱敏后替换词
*/
export const DESENSITIZE_MAP: Record<string, string> = {
态势: "TS",
情报: "QB",
指挥: "ZH",
信息准备: "XX准备",
行为体: "XWT",
驱动因素: "QDYS",
叙事: "XS",
前三定: "QSD",
受众目标: "SZMB",
作战: "ZZ",
目的: "MD",
关键传播节点: "GJCBJD",
战法: "ZF",
行动: "XD",
打击: "DJ",
目标: "MB",
战力: "ZL",
筹划: "COH",
策划: "CEH",
模拟: "MN",
推演: "TY",
认知: "RZ",
触及: "CJ"
};
/**
* 将 DESENSITIZE_MAP 编译为单个正则,避免多次遍历字符串。
* 仅在模块首次加载时执行一次。
*/
function buildRegex(map: Record<string, string>): RegExp | null {
const keys = Object.keys(map);
if (keys.length === 0) return null;
const pattern = keys.map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
return new RegExp(pattern, "g");
}
let _regex: RegExp | null = buildRegex(DESENSITIZE_MAP);
/**
* 对文本进行脱敏处理,将 DESENSITIZE_MAP 中的词替换为对应脱敏词。
*
* @param text 原始文本
* @returns 脱敏后的文本
*
* @example
* desensitize("当前态势分析") // => "当前TS分析"
*/
export function desensitize(text: string): string {
if (!_regex) return text;
// 每次 exec 需要重置 lastIndex,使用 replace 更安全
return text.replace(_regex, (matched) => DESENSITIZE_MAP[matched] ?? matched);
}
/**
* 运行时动态追加脱敏词,追加后自动重编译正则。
*
* @param entries 要追加的词条,格式同 DESENSITIZE_MAP
*
* @example
* addDesensitizeEntries({ 情报: "QB", 指挥: "ZH" });
*/
export function addDesensitizeEntries(entries: Record<string, string>): void {
Object.assign(DESENSITIZE_MAP, entries);
_regex = buildRegex(DESENSITIZE_MAP);
}
@@ -29,12 +29,17 @@ interface ModelOption {
}
const INPUT_CLASS =
'flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-xs shadow-sm ' +
'flex h-12 w-full rounded-xl border border-[#e8dedd] bg-white px-4 py-2 text-sm shadow-sm ' +
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
'focus-visible:ring-ring disabled:opacity-50';
const DEFAULT_KEYWORD_COUNT = 6;
const DEFAULT_AUDIENCE = '普通读者';
const MAX_INTENT_LENGTH = 500;
const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222]';
const FIELD_TRIGGER_CLASS =
'h-12 w-full rounded-xl border-[#e8dedd] bg-white text-sm shadow-sm';
export function AIWritingForm({
onSubmit,
@@ -136,60 +141,102 @@ export function AIWritingForm({
}
return (
<form onSubmit={handleSubmit} className="space-y-3 px-4 py-4">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">写作意图</label>
<Textarea
placeholder="描述你想写的文章,例如:写一篇分析今年 AI 大模型发展趋势的文章,重点关注国内企业的布局…"
value={intent}
onChange={(e) => setIntent(e.target.value)}
disabled={disabled}
rows={4}
className="resize-none text-sm"
/>
</div>
<form onSubmit={handleSubmit} className="space-y-4 px-4 py-4">
<div className="rounded-2xl border border-[#ebe3e2] bg-[#fcf8f8] p-4">
<div className="flex items-center gap-2 border-b border-[#ece5e4] pb-3">
<PenLine className="size-4 text-[#8b6f6f]" />
<h3 className="text-[18px] font-semibold leading-none text-[#2f2f2f]">写作配置选项</h3>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">文章类型</label>
<div className="mt-4 space-y-2">
<div className="flex items-center justify-between">
<label className={FIELD_LABEL_CLASS}>写作意图</label>
<span className="text-sm text-muted-foreground">
{intent.length} / {MAX_INTENT_LENGTH} 字
</span>
</div>
<Textarea
placeholder="描述你想写的文章,例如:写一篇分析今年 AI 大模型发展趋势的文章,重点关注国内企业的布局..."
value={intent}
onChange={(e) => setIntent(e.target.value)}
disabled={disabled}
rows={5}
maxLength={MAX_INTENT_LENGTH}
className="resize-none rounded-2xl border-[#e8dedd] bg-white px-4 py-3 text-sm leading-6 shadow-sm"
/>
</div>
<div className="mt-4 grid grid-cols-2 gap-4">
<div className="min-w-0 space-y-2">
<label className={FIELD_LABEL_CLASS}>文章类型</label>
<Select
value={articleType}
onValueChange={handleArticleTypeChange}
disabled={disabled || typesLoading}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder={typesLoading ? '加载中…' : '选择类型'} />
</SelectTrigger>
<SelectContent>
{articleTypes.map((t) => (
<SelectItem key={t.key} value={t.key} className="text-sm">
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="min-w-0 space-y-2">
<label className={FIELD_LABEL_CLASS}>目标字数</label>
<input
type="number"
min={100}
step={100}
className={INPUT_CLASS}
placeholder="800"
value={wordCountInput}
onChange={(e) => setWordCountInput(e.target.value)}
onBlur={handleWordCountBlur}
disabled={disabled}
/>
</div>
</div>
<div className="mt-4 space-y-2">
<label className={FIELD_LABEL_CLASS}>写作模型</label>
<Select
value={articleType}
onValueChange={handleArticleTypeChange}
disabled={disabled || typesLoading}
value={modelName}
onValueChange={setModelName}
disabled={disabled || models.length === 0}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={typesLoading ? '加载中…' : '选择类型'} />
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder={models.length > 0 ? '选择模型…' : '模型加载中…'} />
</SelectTrigger>
<SelectContent>
{articleTypes.map((t) => (
<SelectItem key={t.key} value={t.key} className="text-xs">
{t.label}
{models.map((m) => (
<SelectItem key={m.name} value={m.name} className="text-sm">
{m.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">目标字数(字)</label>
<input
type="number"
min={100}
step={100}
className={INPUT_CLASS}
placeholder="800"
value={wordCountInput}
onChange={(e) => setWordCountInput(e.target.value)}
onBlur={handleWordCountBlur}
disabled={disabled}
/>
</div>
<Button
type="submit"
disabled={disabled || !intent.trim()}
className="mt-4 h-12 w-full gap-2 rounded-2xl text-base font-semibold"
>
<PenLine className="size-4" />
开始 AI 写作
</Button>
</div>
{!hideAdvancedFields && (
<>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">检索关键词数量(1~10)</label>
<div className="space-y-2">
<label className="text-sm font-semibold text-foreground">检索关键词数量(1~10)</label>
<input
type="number"
min={1}
@@ -202,30 +249,30 @@ export function AIWritingForm({
onBlur={handleKeywordCountBlur}
disabled={disabled}
/>
<p className="text-[11px] text-muted-foreground/70">
<p className="text-xs text-muted-foreground/80">
素材收集专家用几个关键词检索,越多素材越全面但耗时更长。
</p>
</div>
<div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2">
<div className="flex items-start gap-2 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] px-3 py-3">
<input
type="checkbox"
id="strict-mode"
checked={strictMode}
onChange={(e) => setStrictMode(e.target.checked)}
disabled={disabled}
className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer"
className="mt-0.5 size-3.5 shrink-0 cursor-pointer accent-primary"
/>
<label htmlFor="strict-mode" className="cursor-pointer select-none">
<span className="text-xs font-medium text-foreground/80">严格依据素材写作</span>
<p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed">
<span className="text-sm font-medium text-foreground/90">严格依据素材写作</span>
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground/80">
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
</p>
</label>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
<div className="space-y-2">
<label className="text-sm font-semibold text-foreground">目标读者</label>
<input
type="text"
className={INPUT_CLASS}
@@ -238,32 +285,6 @@ export function AIWritingForm({
</>
)}
{models.length > 0 && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">写作模型</label>
<Select
value={modelName}
onValueChange={setModelName}
disabled={disabled}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="选择模型…" />
</SelectTrigger>
<SelectContent>
{models.map((m) => (
<SelectItem key={m.name} value={m.name} className="text-xs">
{m.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<Button type="submit" disabled={disabled || !intent.trim()} className="w-full h-8 text-xs gap-1.5">
<PenLine className="size-3.5" />
开始 AI 写作
</Button>
</form>
);
}
@@ -9,7 +9,7 @@ export default function OpenCanvasRoutes() {
return (
<Routes>
<Route index element={<CanvasHomePage />} />
<Route path="ai-writing" element={<AIWritingPage />} />
<Route path="ai-writing" element={<AIWritingPage hideAdvancedFields />} />
<Route path="article-types" element={<ArticleTypesConfigPage />} />
<Route path=":thread_id" element={<CanvasThreadPage />} />
</Routes>
+4 -2
View File
@@ -30,6 +30,7 @@ import { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
export default function AgentChatPage() {
@@ -61,8 +62,9 @@ export default function AgentChatPage() {
);
const { agent, isLoading: isAgentLoading } = useAgent(agent_id);
const agentDisplayName =
agent?.name ?? (isAgentLoading ? t.common.loading : "");
const agentDisplayName = desensitize(
agent?.name ?? (isAgentLoading ? t.common.loading : ""),
);
const { threadId, setThreadId, isNewThread, setIsNewThread } =
useThreadChat();
+2 -1
View File
@@ -17,6 +17,7 @@ import { useI18n } from "@/core/i18n/hooks";
import { useThreads } from "@/core/threads/hooks";
import { usePathOfThread, titleOfThread } from "@/core/threads/utils";
import { formatTimeAgo } from "@/core/utils/datetime";
import { desensitize } from "@/lib/desensitize";
export default function ChatsPage() {
const pathOfThread = usePathOfThread();
@@ -69,7 +70,7 @@ export default function ChatsPage() {
<Link key={thread.thread_id} href={pathOfThread(thread)}>
<div className="flex flex-col gap-2 border-b p-4">
<div>
<div>{titleOfThread(thread)}</div>
<div>{desensitize(titleOfThread(thread))}</div>
</div>
{thread.updated_at && (
<div className="text-muted-foreground text-sm">
+14 -14
View File
@@ -15,20 +15,20 @@ function PageLayout() {
const sidebarEnabled = useMemo(() => readSidebarEnabled(), []);
return (
<div className="h-screen flex bg-muted/20">
{sidebarEnabled && (
<PageSidebar
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
/>
)}
<div
className={`flex flex-col flex-1 overflow-hidden transition-[padding] duration-300 ${
!sidebarEnabled ? "" : collapsed ? "pl-16" : "pl-64"
}`}
>
{sidebarEnabled && <Header />}
<main className="flex-1 overflow-hidden">
<div className="h-screen flex flex-col bg-muted/20">
{sidebarEnabled && <Header collapsed={collapsed} />}
<div className="flex flex-1 overflow-hidden">
{sidebarEnabled && (
<PageSidebar
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
/>
)}
<main
className={`flex-1 overflow-hidden transition-[padding] duration-300 ${
!sidebarEnabled ? "" : collapsed ? "pl-16" : "pl-64"
}`}
>
<Routes>
<Route index element={<Navigate to="workspace" replace />} />
<Route
+14 -13
View File
@@ -62,6 +62,7 @@ import {
statusLabel,
statusVariant,
} from "@/components/workspace/scheduled-tasks/schedule-builder";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
function formatDate(value?: string | null) {
@@ -334,7 +335,7 @@ export default function ScheduledTasksPage() {
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="font-medium leading-snug break-all">
{task.name}
{desensitize(task.name)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-1">
{isRunningNow && (
@@ -358,7 +359,7 @@ export default function ScheduledTasksPage() {
)}
</div>
<div className="text-muted-foreground mt-1 truncate text-xs">
{task.schedule_text} · {task.timezone}
{desensitize(task.schedule_text ?? "")} · {task.timezone}
</div>
</div>
<Badge variant={task.enabled ? "default" : "secondary"} className="shrink-0">
@@ -531,9 +532,9 @@ export default function ScheduledTasksPage() {
<div key={item.task_id} className="bg-card rounded-md border p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium">{item.name}</div>
<div className="truncate font-medium">{desensitize(item.name)}</div>
<div className="text-muted-foreground mt-1 truncate text-xs">
{item.schedule_text} · {item.timezone}
{desensitize(item.schedule_text ?? "")} · {item.timezone}
</div>
</div>
{subscribed && (
@@ -543,7 +544,7 @@ export default function ScheduledTasksPage() {
)}
</div>
<div className="text-muted-foreground mt-3 line-clamp-3 text-xs whitespace-pre-wrap">
{item.prompt_summary || "(无摘要)"}
{item.prompt_summary ? desensitize(item.prompt_summary) : "(无摘要)"}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button
@@ -585,7 +586,7 @@ export default function ScheduledTasksPage() {
</Select>
</div>
<p className="text-muted-foreground text-sm">
{selectedTask ? selectedTask.name : "请选择左侧任务"}
{selectedTask ? desensitize(selectedTask.name) : "请选择左侧任务"}
</p>
{selectedTask && (selectedTask.creator_email || selectedTask.creator_user_id) && (
<p className="text-muted-foreground text-xs">
@@ -625,7 +626,7 @@ export default function ScheduledTasksPage() {
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium">
{run.result?.title || formatDate(run.scheduled_for)}
{run.result?.title ? desensitize(run.result.title) : formatDate(run.scheduled_for)}
</div>
<div className="text-muted-foreground mt-1 text-xs">
计划:{formatDate(run.scheduled_for)} · 开始:
@@ -642,7 +643,7 @@ export default function ScheduledTasksPage() {
</div>
{run.error && (
<div className="text-destructive mt-3 line-clamp-2 text-sm">
{run.error}
{desensitize(run.error)}
</div>
)}
<div className="mt-4 flex flex-wrap gap-2">
@@ -703,13 +704,13 @@ export default function ScheduledTasksPage() {
</DialogHeader>
<div className="flex flex-col gap-3">
<div>
<div className="font-medium">{subscribeTarget?.name}</div>
<div className="font-medium">{desensitize(subscribeTarget?.name ?? "")}</div>
<div className="text-muted-foreground mt-1 text-xs">
{subscribeTarget?.schedule_text} · {subscribeTarget?.timezone}
{desensitize(subscribeTarget?.schedule_text ?? "")} · {subscribeTarget?.timezone}
</div>
</div>
<div className="text-muted-foreground line-clamp-4 text-sm whitespace-pre-wrap">
{subscribeTarget?.prompt_summary || "(无摘要)"}
{subscribeTarget?.prompt_summary ? desensitize(subscribeTarget.prompt_summary) : "(无摘要)"}
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div>
@@ -809,8 +810,8 @@ export default function ScheduledTasksPage() {
{agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
<span className="block truncate">
{agent.name}
{agent.description ? ` — ${agent.description}` : ""}
{desensitize(agent.name)}
{agent.description ? ` — ${desensitize(agent.description)}` : ""}
</span>
</SelectItem>
))}
@@ -1,4 +1,4 @@
import { Bell, ChevronDown, Moon, Palette, User } from "lucide-react";
import { Bell, ChevronDown, FileText, Moon, Palette, User } from "lucide-react";
import { useState } from "react";
import { NotificationDialog } from "@/components/workspace/notification-dialog";
@@ -16,7 +16,11 @@ const iconButtonClass = (isDark: boolean) =>
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
);
const Header = () => {
type HeaderProps = {
collapsed?: boolean;
};
const Header = ({ collapsed }: HeaderProps) => {
const { theme, toggleTheme } = useTheme();
const isDark = theme === "dark";
const [notificationOpen, setNotificationOpen] = useState(false);
@@ -36,7 +40,23 @@ const Header = () => {
: "border-[var(--brand-950)] bg-[var(--brand-900)]",
)}
>
<div className="flex min-w-0 flex-1 items-center gap-4" />
<div className="flex min-w-0 flex-1 items-center gap-3">
<div
className={cn(
"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg",
isDark
? "bg-gradient-to-br from-blue-500 to-cyan-400"
: "bg-white/20",
)}
>
<FileText className="h-4 w-4 text-white" />
</div>
{!collapsed && (
<span className={cn("text-sm font-semibold", isDark ? "text-white" : "text-white")}>
CM助手
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<Button