deerflow-code/frontend-web/docs/angular-智能体对话对接文档.md
2026-09-07 18:24:55 +08:00

1545 lines
62 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Angular 对接 DeerFlow 智能体对话页
本文说明如何在 **其它 Angular 项目** 中对接 DeerFlow 智能体对话能力,对标页面:
```
http://localhost:5173/#/page/workspace/agents/roundtable-structure/chats/new
```
对应前端路由:`#/page/workspace/agents/{agent_id}/chats/{thread_id}`
其中 `agent_id = roundtable-structure`(展示名「结构化抽取」),`thread_id = new` 表示新建会话。
本文覆盖:**登录拿 Token → 列模型/切模型 → 建会话/存聊天记录 → 流式对话 → 思考内容 → 步骤条(含创建脚本/执行脚本/写入文件及全部工具)→ 用户协助卡片 → 消息列表渲染 → 停止/重连/上传/产物**。
**不包含**智能体配置(创建/编辑智能体、技能绑定等)。
路径约定(均相对本仓库根目录 `deerflow-server/`):
- 前端:`frontend-web/src/…`
- 后端:`offline-backend-20260512/backend/…`(下文若写 `app/`、`deerflow/`,都在这个 backend 目录下)
- 每节末尾有 **参考代码**;文末第 20 节是总索引。
---
## 0. 先看这一页
对接本质不是「调一个聊天 HTTP」,而是复用本系统的 **LangGraph 兼容协议**:
| 能力 | 走哪条线 | Base URL |
|------|----------|----------|
| 登录、模型列表、上传、产物、追问建议 | Gateway REST | `{GATEWAY}`,例如 `http://host:8001` |
| 会话 / 流式对话 / 取消 | LangGraph Platform 兼容 API | `{GATEWAY}/api` |
本仓库前端环境变量:
- `VITE_BACKEND_BASE_URL` → `{GATEWAY}`
- `VITE_LANGGRAPH_BASE_URL` → `{GATEWAY}/api`
开发机前端常见端口是 **5173 或 5174**(Vite 被占用会换端口),Gateway 默认 **8001**。
下文用:
```text
GATEWAY = http://localhost:8001
LG = http://localhost:8001/api
```
所有对话接口都要带:
```http
Authorization: Bearer <access_token>
Content-Type: application/json
```
用 Bearer **不必**再带 CSRF。不要用 cookie 登录(`/login/local` 只把 JWT 写进 HttpOnly cookie,不适合 Angular 跨域 API 客户端)。
**参考代码**
| 说明 | 路径 |
|------|------|
| 前端两个 Base URL | `frontend-web/src/core/config/index.ts`(`getBackendBaseURL` / `getLangGraphBaseURL`) |
| 环境变量注入 | `frontend-web/src/env.ts` |
| LangGraph SDK 客户端(自动带 Bearer) | `frontend-web/src/core/api/api-client.ts` |
| Gateway fetch 自动带 Authorization | `frontend-web/src/core/api/fetch-client.ts` |
| CSRF:有 Bearer 则跳过 | `offline-backend-20260512/backend/app/gateway/csrf_middleware.py` |
| 登录接口白名单 | `offline-backend-20260512/backend/app/gateway/auth_middleware.py` |
---
## 1. 架构与端到端流程
```mermaid
sequenceDiagram
participant A as Angular
participant G as Gateway :8001
participant R as RunManager / Lead Agent
A->>G: POST /api/v1/auth/login/username
G-->>A: access_token (JWT, ~7 天)
A->>G: GET /api/models
G-->>A: models[]
A->>G: POST /api/threads metadata.agent_id=roundtable-structure
G-->>A: thread_id
A->>G: POST /api/threads/{id}/runs/stream (SSE)
G->>R: start_run(assistant_id, context.model_name, ...)
loop 流式
R-->>A: event: metadata / messages / values / custom / end
end
Note over A: 按消息分组渲染气泡 + 步骤条 + 协助卡片
A->>G: GET /api/threads/{id}/state (刷新后还原聊天记录)
```
`roundtable-structure` 与其它自定义智能体共用 **同一套 Lead Agent 图**。
真正选中哪个智能体靠三层同时带上(缺一不可,建议都传):
1. 请求体 `assistant_id = "roundtable-structure"`(文件系统/库表里的 **id**,不是中文名)
2. `context.agent_id = "roundtable-structure"`
3. 会话 `metadata.agent_id = "roundtable-structure"`(历史列表按此过滤)
**参考代码**
| 说明 | 路径 |
|------|------|
| 路由挂载 AgentChatPage | `frontend-web/src/pages/WorkspaceRoutes.tsx`(`agents/:agent_id/chats/:thread_id`) |
| 对话页:`assistantId` + `context.agent_id` | `frontend-web/src/pages/AgentChatPage.tsx` |
| 聊天气泡壳 | `frontend-web/src/components/workspace/chats/chat-box.tsx` |
| 发消息时写入 metadata.agent_id | `frontend-web/src/core/threads/hooks.ts`(`onCreated` / `thread.submit`) |
| 非内置 assistant_id → agent_id | `offline-backend-20260512/backend/app/gateway/services.py`(`build_run_config`) |
| Lead Agent 工厂 | `offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py` |
| 智能体种子(id / 中文名 / 技能) | `offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/config.yaml` |
| 智能体 SOUL | `offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/SOUL.md` |
---
## 2. CORS / 跨域(Angular 必读)
Gateway 默认放行的浏览器 Origin 只有:
- `http://localhost:5173` / `5174` / `3000` / `8080`(及对应 `127.0.0.1`)
**Angular CLI 默认是 `http://localhost:4200`,不在白名单里。**
部署或本地对接时必须把 Angular 源加进环境变量,例如:
```bash
GATEWAY_CORS_ORIGINS=http://localhost:4200,http://127.0.0.1:4200,https://your-angular-host
```
或开发期临时 `GATEWAY_CORS_ALLOW_ALL=1`(勿用于生产)。
**参考代码**:`offline-backend-20260512/backend/app/gateway/app.py`(搜 `GATEWAY_CORS_ORIGINS` / `local_vite_origins` / `cors_expose_headers`)。回归:`offline-backend-20260512/backend/tests/test_taskcop_cors.py`。
浏览器跨域读 SSE 还需要暴露这些响应头(Gateway 已配):
- `Content-Location`(从中解析 `run_id`,取消/重连要用)
- `Location`、`Content-Disposition`
请求请带 `credentials: include` 也可以,但 **Bearer 才是主认证**。
---
## 3. 登录:如何拿到 Token
登录接口 **不需要** JWT。前缀:`/api/v1/auth`。
推荐 Angular 用 **用户名登录** 或 **上游 Token 换票**。不要用 `/login/local`。
### 3.1 用户名登录(推荐)
```http
POST {GATEWAY}/api/v1/auth/login/username?password=OPTIONAL
Content-Type: application/json
{ "username": "alice" }
```
- 若该账号在后台配了「登录口令」,**必须**带匹配的 `?password=`
- 若开了全局共享口令门(`config.yaml → auth_login.username_login.require_password`),未配个人口令的账号也要带共享口令
- 未开密码门且无个人口令时,可省略 `password`
成功响应:
```json
{
"access_token": "<jwt>",
"token_type": "bearer",
"expires_in": 604800,
"user_id": "<uuid>",
"email": "alice@deerflow.local",
"system_role": "user",
"needs_setup": false,
"created": false,
"username": "alice"
}
```
`expires_in` 默认 **7 天(604800 秒)**。系统 **没有 refresh 接口**,过期后重新登录。
JWT payload:`{ sub: user_id, exp, iat, ver }`,HS256。改密码会 bump `token_version`,旧票立刻失效。
后续所有请求:
```http
Authorization: Bearer <access_token>
```
即 ``${token_type} ${access_token}``。
校验当前用户:
```http
GET {GATEWAY}/api/v1/auth/me
Authorization: Bearer <jwt>
```
```json
{ "id": "...", "email": "...", "system_role": "user", "needs_setup": false }
```
401 → 清本地 token,回到登录。
### 3.2 上游 Token 换票
适用于已有统一认证、URL 里带 `authToken` 的场景:
```http
POST {GATEWAY}/api/v1/auth/login/token?token=<upstreamToken>
Content-Type: application/json
```
响应形状与用户名登录相同。上游时钟偏差可能导致短暂 401,本系统前端会重试最多 3 次、间隔 3 秒;Angular 建议同样处理。
开关:`config.yaml → auth_login.token_login.enabled`。
### 3.3 登录失败
`detail` 可能是字符串、`{ code, message }`、或校验数组。请按字符串抽取,不要直接 `String(object)`。
常见:
| HTTP | 含义 |
|------|------|
| 401 | 口令错误 / 上游 token 无效 |
| 403 `{ code: "PENDING_APPROVAL" }` | 管理员取消了该账号放行 |
| 429 | 限流 |
### 3.4 Angular 存储建议
```ts
const AUTH_KEY = 'deerflow.auth';
interface LoginResponse {
access_token: string;
token_type: string;
expires_in: number;
user_id: string;
email: string;
system_role: string;
username?: string;
}
function authHeader(): HttpHeaders {
const auth = JSON.parse(localStorage.getItem(AUTH_KEY) || 'null') as LoginResponse | null;
return new HttpHeaders({
Authorization: `${auth?.token_type || 'Bearer'} ${auth?.access_token || ''}`,
});
}
```
**参考代码**
| 说明 | 路径 |
|------|------|
| 前端登录 API | `frontend-web/src/core/auth/api.ts`(`loginByUsername` / `loginByToken`) |
| Token 存取、拼 Bearer | `frontend-web/src/core/auth/index.ts`(`getAuthorizationHeaderValue` / `setStoredAuth`) |
| 类型 `LoginByUsernameResponse` | `frontend-web/src/core/auth/index.ts` |
| 登录页(含 token 换票) | `frontend-web/src/pages/LoginPage.tsx` |
| 后端登录路由 | `offline-backend-20260512/backend/app/gateway/routers/auth.py`(`/login/username`、`/login/token`、`/me`) |
| JWT 签发 | `offline-backend-20260512/backend/app/gateway/auth/jwt.py` |
| 请求里解析 cookie / Bearer | `offline-backend-20260512/backend/app/gateway/deps.py`(`get_current_user`) |
| 登录配置(口令门 / token_login) | `offline-backend-20260512/backend/config.yaml` → `auth_login`;schema:`deerflow/config/login_config.py` |
| 登录契约说明 | `offline-backend-20260512/backend/docs/AUTH_LOGIN.md` |
---
## 4. 模型列表与切换
智能体配置不用做;**对话时选哪个大模型**要做。
### 4.1 拉模型
```http
GET {GATEWAY}/api/models
Authorization: Bearer <jwt>
```
```json
{
"models": [
{
"name": "deepseek-chat",
"model": "deepseek-chat",
"display_name": "DeepSeek Chat",
"supports_thinking": true,
"supports_reasoning_effort": false,
"supports_vision": false,
"supports_tool_calling": true,
"is_local": false,
"provider": "openai"
}
],
"token_usage": { "enabled": false }
}
```
下拉框展示 `display_name`,提交时传 **`name`(不是 `model`)** 到 `context.model_name`。
### 4.2 思考档位(本系统输入框的 flash / thinking / pro / ultra)
每轮 `runs/stream` 的 `context` 里带这些开关(后端白名单字段,未列入的会被丢掉):
| 档位 | `thinking_enabled` | `is_plan_mode` | `subagent_enabled` | `reasoning_effort` | 效果 |
|------|--------------------|----------------|--------------------|--------------------|------|
| flash | false | false | false | — | 快答,尽量不思考 |
| thinking | true | false | false | `low` | 出思考块 |
| pro | true | true | false | `medium` | 思考 + 计划 Todo |
| ultra | true | true | true | `high` | 再加子任务委派 |
若所选模型 `supports_thinking === false`,请强制 flash。
内网部分 vLLM/Qwen 只声明了 `supports_thinking`,关思考时建议额外传 `thinking_force_disabled: true`,否则可能关不干净。
模型解析顺序(后端):
1. 本轮 `context.model_name`
2. 智能体自己的 `config.yaml → model`
3. 系统配置的第一个模型
每轮都可以换模型;换了只影响 **下一轮**,不会改写历史。
**参考代码**
| 说明 | 路径 |
|------|------|
| 前端拉模型 | `frontend-web/src/core/models/api.ts`、`frontend-web/src/core/models/types.ts` |
| 输入框模型下拉 + 档位 | `frontend-web/src/components/workspace/input-box.tsx`(`InputMode`、`ModelSelector`) |
| 档位写入 context | `frontend-web/src/core/threads/hooks.ts`(`thread.submit` 的 `context`:`thinking_enabled` / `is_plan_mode` / `subagent_enabled`) |
| 本地记住所选模型 | `frontend-web/src/core/settings/local.ts`(`deerflow.thread-model.{threadId}`) |
| 后端模型列表 | `offline-backend-20260512/backend/app/gateway/routers/models.py` |
| context 白名单 | `offline-backend-20260512/backend/app/gateway/services.py`(`_CONTEXT_CONFIGURABLE_KEYS`) |
| 本轮实际用哪个模型 | `offline-backend-20260512/backend/app/gateway/services.py`(`_resolve_effective_run_model_name`) |
| 创建 ChatModel、关思考 | `offline-backend-20260512/backend/packages/harness/deerflow/models/factory.py` |
| 模型配置源 | `offline-backend-20260512/backend/config.yaml` → `models[]` |
---
## 5. 会话(Thread)= 聊天记录的存储单位
聊天记录 **不用 Angular 自己建库**。Gateway 用 LangGraph checkpointer 存消息,用 `threads_meta` 存索引(标题、owner、metadata)。
物理目录(了解即可):`.deer-flow/users/{user_id}/threads/{thread_id}/user-data/{workspace,uploads,outputs}`。
### 5.1 新建会话
```http
POST {LG}/threads
Authorization: Bearer <jwt>
Content-Type: application/json
{
"assistant_id": "roundtable-structure",
"metadata": { "agent_id": "roundtable-structure" }
}
```
```json
{
"thread_id": "3f2a…",
"status": "idle",
"created_at": "2026-08-18T09:00:00+08:00",
"updated_at": "…",
"metadata": { "agent_id": "roundtable-structure" },
"values": {},
"interrupts": {}
}
```
也可以不先 create:`runs/stream` 带 `if_not_exists: "create"` 时会自动建。本系统前端就是 **第一次发消息时** 由 SDK 建 thread,再 `PATCH` 写入 `metadata.agent_id`。建议 Angular **显式 create**,方便先上传文件、再发问。
### 5.2 补 metadata(必须,否则历史列表滤不出来)
```http
PATCH {LG}/threads/{thread_id}
{ "metadata": { "agent_id": "roundtable-structure" } }
```
为 merge,不会整表覆盖。
### 5.3 历史列表(按智能体过滤)
```http
POST {LG}/threads/search
{
"metadata": { "agent_id": "roundtable-structure" },
"limit": 50,
"offset": 0,
"query": null,
"status": null
}
```
返回 `ThreadResponse[]`。标题在 `values.title`(来自库里的 `display_name`)。
`query` 按标题模糊搜。系统会话(调度、会商工人线程)默认排除。
条数:
```http
POST {LG}/threads/count
(body 同上,limit/offset 忽略)
→ { "total": 12 }
```
列表项建议展示:
1. `values.title`;没有则用该会话第一条用户消息截断 50 字
2. `updated_at`
打开某条:跳到自己的路由并 `GET …/state` 拉消息。
### 5.4 打开已有会话(还原聊天记录)
优先:
```http
GET {LG}/threads/{thread_id}/state
```
`values` 里至少有:
```json
{
"title": "抽取方案结构",
"title_provisional": false,
"messages": [ /* 见第 8 节 */ ],
"artifacts": ["/mnt/user-data/outputs/flow.json"],
"todos": [{ "content": "…", "status": "in_progress" }]
}
```
也可:
```http
GET {LG}/threads/{thread_id}
```
带 metadata + 当前 `values`(含 messages)。
checkpoint 历史(一般不需要):
```http
POST {LG}/threads/{thread_id}/history
{ "limit": 10, "before": null }
```
只在最新一条 history 上带 `messages`,避免重复。
### 5.5 删除会话
两步都做才干净:
```http
DELETE {LG}/threads/{thread_id}
```
这会删 checkpoint / meta / 磁盘目录。本系统前端 SDK 删除后再打一次 Gateway 同路径,确保本地文件清掉。
### 5.6 会话状态
`status`:`idle` | `busy` | `interrupted` | `error`
正在跑时为 `busy`。同一会话再发会被 `multitask_strategy: "reject"` 打回 **HTTP 409**。
**参考代码**
| 说明 | 路径 |
|------|------|
| 线程类型 / context 类型 | `frontend-web/src/core/threads/types.ts` |
| 列表、标题、路由 | `frontend-web/src/core/threads/utils.ts`(`titleOfThread` / `pathOfThread`) |
| 分页搜索 | `frontend-web/src/core/threads/hooks.ts`(`useThreadsPage`) |
| 智能体最近对话 | `frontend-web/src/components/workspace/agents/agent-recent-chats.tsx` |
| 工作区会话列表 UI | `frontend-web/src/components/workspace/workspace-nav-chat-list.tsx` |
| 建/搜/改/删/state/history | `offline-backend-20260512/backend/app/gateway/routers/threads.py` |
| ThreadResponse / Search 模型 | 同上文件内 `ThreadResponse`、`ThreadCreateRequest`、`ThreadSearchRequest` |
| 删除时清本地目录 | `frontend-web/src/core/threads/hooks.ts`(delete 后再调 Gateway DELETE) |
| 409 中文文案 | `offline-backend-20260512/backend/app/gateway/services.py`(`create_or_reject` / RunManager) |
---
## 6. 发起智能体对话(核心)
```http
POST {LG}/threads/{thread_id}/runs/stream
Authorization: Bearer <jwt>
Content-Type: application/json
Accept: text/event-stream
```
### 6.1 请求体(与本系统 AgentChatPage 对齐)
```json
{
"assistant_id": "roundtable-structure",
"input": {
"messages": [
{
"type": "human",
"content": [{ "type": "text", "text": "请根据这份方案抽取流程图" }],
"additional_kwargs": {
"client_ts": "2026-08-18T09:00:00.000Z"
}
}
]
},
"context": {
"agent_id": "roundtable-structure",
"model_name": "deepseek-chat",
"thinking_enabled": true,
"thinking_force_disabled": false,
"is_plan_mode": false,
"subagent_enabled": false,
"reasoning_effort": "low",
"memory_injection_enabled": true,
"rag_mode_enabled": false,
"summarization_enabled": false,
"thread_id": "<thread_id>"
},
"config": { "recursion_limit": 1000 },
"stream_mode": ["values", "messages-tuple", "updates", "custom"],
"stream_subgraphs": true,
"stream_resumable": true,
"on_disconnect": "cancel",
"multitask_strategy": "reject",
"if_not_exists": "create"
}
```
要点:
- `input.messages` **只传本轮新的 human**,不要把历史整包再塞一遍(服务端 checkpointer 会拼)
- `content` 可以是纯字符串 `"你好"`,也可以是上面的 text block 数组
- `client_ts` 可选,前端用来显示提问时间
- `stream_mode` 里写 `messages-tuple`(LangGraph Platform 名);服务端会转成内部 `messages` 再以 SSE `event: messages` 吐出
- `on_disconnect: "cancel"`:浏览器关 SSE 就停服务端;若希望关页后台继续跑,改 `"continue"`
- `multitask_strategy: "reject"`:会话已有进行中的 run → **409**,文案类似「当前对话还在生成回答…」
响应头:
```http
Content-Type: text/event-stream
Content-Location: /api/threads/{thread_id}/runs/{run_id}
```
**立刻把 `run_id` 存下来**(停止、重连都靠它)。SSE 第一帧 `event: metadata` 里也会再给一次。
### 6.2 纯文本以外的用户消息
图片(模型 `supports_vision` 时):
```json
{
"type": "human",
"content": [
{ "type": "text", "text": "看看这张图" },
{ "type": "image_url", "image_url": { "url": "https://…/a.png" } }
]
}
```
附件:先上传(第 15 节),再在 `additional_kwargs.files` 里带虚拟路径。
协助卡片的回复:**不要**调 LangGraph `Command(resume)`,当成 **普通新一轮 human 消息** 再 `runs/stream`(见第 12 节)。
### 6.3 停止生成
只关 EventSource **不够**,服务端可能还在跑。
```http
POST {LG}/threads/{thread_id}/runs/{run_id}/cancel?action=interrupt&wait=false
```
- 202:已受理;`wait=true` 等到停完返回 204
- `action=interrupt`:停下,保留当前 checkpoint(可继续聊)
- `action=rollback`:回滚到本轮开始前
列出该会话的 run:
```http
GET {LG}/threads/{thread_id}/runs
GET {LG}/threads/{thread_id}/runs/{run_id}
```
### 6.4 断线重连
SSE 帧带 `id:`。重连时:
```http
GET {LG}/threads/{thread_id}/runs/{run_id}/join
Last-Event-ID: <上一帧 id>
```
或 POST stream 时带 `stream_resumable: true` 后由 SDK 自动 join。Gateway 重启后 join 会立刻 `event: end`,此时改拉 `/state` 渲染历史即可。
心跳是注释行:`: heartbeat`,忽略即可。
**参考代码**
| 说明 | 路径 |
|------|------|
| 前端 `useStream` + `thread.submit` | `frontend-web/src/core/threads/hooks.ts`(`useThreadStream`、构造 `messages` / `context`) |
| SDK 包装 | `frontend-web/src/core/api/api-client.ts` |
| stream_mode 规范化 | `frontend-web/src/core/api/stream-mode.ts` |
| 停止:`thread.stop` / `runs.cancel` | `frontend-web/src/core/threads/hooks.ts`(`stopThreadRun`) |
| 输入框停止按钮 | `frontend-web/src/components/workspace/input-box.tsx` |
| 后端 stream / cancel / join | `offline-backend-20260512/backend/app/gateway/routers/thread_runs.py`(`RunCreateRequest`、`stream_run`、`cancel_run`、`join_run`) |
| 真正起跑 | `offline-backend-20260512/backend/app/gateway/services.py`(`start_run`) |
| 后台跑图 + 发 SSE | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/worker.py`(`run_agent`) |
| Run 生命周期 | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py` |
| 流式设计说明 | `offline-backend-20260512/backend/docs/STREAMING.md` |
---
## 7. SSE 事件协议(Angular 必须自己解析 POST 流)
**`EventSource` 只支持 GET,不能用来 POST `/runs/stream`。**
Angular 请用 `fetch` + `ReadableStream`(或封装好的 SSE parser)。不要用 `HttpClient` 的默认 JSON 解析。
### 7.1 线格式
```
event: metadata
data: {"run_id":"…","thread_id":"…"}
id: 1
event: messages
data: [{...chunk...}, {...meta...}]
id: 2
event: values
data: {"title":"…","messages":[...],"artifacts":[],"todos":[]}
id: 3
event: custom
data: {"type":"task_running","task_id":"…","message":"…"}
id: 4
event: end
data: null
id: 5
```
字段顺序:`event:` → `data:` → `id:` → 空行。`data` 是一行 JSON(`ensure_ascii=false`,中文直接出)。
### 7.2 各事件怎么用
| `event` | `data` | UI 该做什么 |
|---------|--------|-------------|
| `metadata` | `{ run_id, thread_id }` | 保存 run_id |
| `messages` | `[chunk, meta]` | **token 级增量**:拼 AI 正文、思考、tool_call 参数 |
| `values` | 完整 state | **权威快照**:用 `messages[]` 替换本地列表(标题、todos、artifacts 也在这) |
| `updates` | `{ nodeName: writes }` | 可忽略;或用来知道刚跑完哪个节点 |
| `custom` | 见下表 | toast / 子任务状态 |
| `end` | `null` | 结束本轮,关 loading |
| `error` | 错误对象 | 展示错误 |
`custom` 常见:
```json
{ "type": "task_running", "task_id": "…", "message": "正在检索…" }
{ "type": "llm_retry", "message": "模型调用失败,正在重试…" }
{ "type": "context_compacting", "message": "正在压缩上下文…" }
```
后两个弹 toast 即可,不要当消息气泡。
### 7.3 `event: messages` 的 chunk(最重要)
`data` 是二元组:
```json
[
{
"type": "AIMessageChunk",
"id": "run-abc-0",
"content": "正在",
"additional_kwargs": {
"reasoning_content": "用户想抽取结构,我先读文件…"
},
"tool_call_chunks": [
{
"index": 0,
"id": "call_xxx",
"name": "bash",
"args": "{\"description\": \"创建脚本\""
}
]
},
{
"langgraph_node": "model",
"ls_provider": "…",
"ls_model_name": "…"
}
]
```
合并规则(按 `chunk.id` 分桶):
1. **正文**:把每次 `content`(字符串)往后拼;如果是 block 数组,只拼 `type==="text"` 的 `text`
2. **思考**:`additional_kwargs.reasoning_content` 一般是 **累计全文**(以最新一帧为准);若是 delta 则拼接。同时要从正文里剥 `<think>…</think>`(见第 10 节)
3. **工具调用**:按 `tool_call_chunks[].index` 拼 `name` / `args`(args 是 JSON **字符串碎片**,拼完再 `JSON.parse`)
4. **工具结果**:会出现 `type: "tool"` / `ToolMessage`,`tool_call_id` 对上上面的 `id`,`name` 为工具名,`content` 为结果
`values.messages` 到来后,用快照覆盖增量状态,避免拼错。
### 7.4 Angular 解析骨架
```ts
async function streamRun(
lgBase: string,
threadId: string,
body: unknown,
token: string,
onEvent: (event: string, data: unknown) => void,
): Promise<string | undefined> {
const res = await fetch(`${lgBase}/threads/${threadId}/runs/stream`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Accept: 'text/event-stream',
},
body: JSON.stringify(body),
});
if (res.status === 409) throw new Error('当前对话还在生成回答,请稍候再发送');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const location = res.headers.get('Content-Location') || '';
const runId = location.split('/runs/')[1];
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split('\n\n');
buf = parts.pop() || '';
for (const block of parts) {
if (block.startsWith(':')) continue; // heartbeat
let event = 'message';
let data = '';
for (const line of block.split('\n')) {
if (line.startsWith('event:')) event = line.slice(6).trim();
else if (line.startsWith('data:')) data += line.slice(5).trim();
}
if (!data) continue;
onEvent(event, data === 'null' ? null : JSON.parse(data));
}
}
return runId;
}
```
**参考代码**
| 说明 | 路径 |
|------|------|
| SSE 组帧(`event:` / `data:` / `id:`) | `offline-backend-20260512/backend/app/gateway/services.py`(`format_sse`、`sse_consumer`) |
| 心跳 / 订阅 | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/stream_bridge.py` |
| 首帧 `event: metadata` | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/worker.py`(`bridge.publish(..., "metadata", {run_id, thread_id})`) |
| `messages-tuple` → 内部 `messages` | 同上 `run_agent`;SSE 事件名 `_lg_mode_to_sse_event` |
| chunk 序列化成 `[chunk, meta]` | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/serialization.py`(`serialize_messages_tuple` / `serialize_channel_values`) |
| 前端消费:onCustomEvent / onUpdateEvent | `frontend-web/src/core/threads/hooks.ts` |
| 协议说明 | `offline-backend-20260512/backend/docs/STREAMING.md` |
---
## 8. 消息数据结构(历史 + 实时共用)
`values.messages[]` 每条大致如下(LangChain 序列化后):
```json
{
"type": "human | ai | tool",
"id": "uuid-or-run-id",
"name": "ask_clarification | bash | …",
"content": "字符串 或 content block 数组",
"tool_calls": [
{ "id": "call_xxx", "name": "bash", "args": { "description": "执行脚本", "command": "python3 a.py" } }
],
"tool_call_id": "call_xxx",
"additional_kwargs": {
"reasoning_content": "…",
"clarification": { },
"files": [ ],
"client_ts": "…",
"hide_from_ui": false
}
}
```
### 8.1 `content` 形态
| 形态 | 含义 |
|------|------|
| `"一段文本"` | 最常见 |
| `[{ "type": "text", "text": "…" }]` | 多段文本 |
| `[{ "type": "image_url", "image_url": { "url": "…" } }]` | 用户图 / 模型回图 |
| `[{ "type": "thinking", "thinking": "…" }]` | Anthropic 风格思考 |
| `[{ "type": "json", "json": { } }]` | 工具结构化结果 |
### 8.2 必须隐藏、不要渲染的消息
- `additional_kwargs.hide_from_ui === true`(摘要压缩等)
- `name === "summary"`
- `name === "todo_reminder"`
- `type === "system"`(一般不会出现在 UI 列表)
### 8.3 角色怎么画
| `type` | 默认 UI |
|--------|---------|
| `human` | 右侧用户气泡,Markdown 可关,展示附件 chips |
| `ai` 且无 `tool_calls`、有正文 | 左侧助手最终回答(Markdown) |
| `ai` 有 `tool_calls` 或仅有思考 | **不单独当最终气泡**,进入步骤条 |
| `tool` | **几乎不单独成气泡**,并进步骤条 / 协助卡片 |
**参考代码**
| 说明 | 路径 |
|------|------|
| 分组、抽文本、抽思考、隐藏消息 | `frontend-web/src/core/messages/utils.ts`(`groupMessages` / `extractContentFromMessage` / `extractReasoningContentFromMessage` / `isHiddenFromUIMessage`) |
| 检索结果识别 | `frontend-web/src/core/messages/search-results.ts` |
| 序列化后的 message dict | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/serialization.py` |
---
## 9. 消息列表如何分组渲染
本系统 `groupMessages()` 的规则,Angular 请原样移植,否则步骤条和协助卡片会对不齐。
从前往后扫 `messages`(已过滤隐藏消息):
1. `human` → 一组 `human`
2. `ai` 且 `tool_calls` 含 `present_files` → `assistant:present-files`(正文 + 文件卡片)
3. `ai` 且 `tool_calls` 含 `task` → `assistant:subagent`(子任务卡)
4. `ai` 有思考或有其它 tool_calls → 并入(或新建)`assistant:processing`(**步骤条**)
5. 同一条 `ai` **既有思考又有最终正文、且没有 tool_calls** → processing 一组 **再** 出一组 `assistant` 气泡
6. `tool` 且 `name === ask_clarification` → 先塞进当前 processing,再单独开 `assistant:clarification`
7. 其它 `tool` → 塞进当前 processing(用 `tool_call_id` 填步骤的 result)
渲染顺序示例(一轮典型技能执行):
```text
[用户] 请抽取结构
[步骤条]
思考
读取文件 SKILL.md
创建脚本
执行脚本
写入文件 /mnt/user-data/outputs/flow.json
展示文件
[助手] 已生成 flow-json,可在右侧预览。
```
若中途问用户:
```text
[步骤条] … 需要你的协助
[协助卡片] 需要你的确认
(等待用户下一轮 human)
```
### 9.1 用户气泡
- 文本:`extractContentFromMessage`,去掉 `<uploaded_files>…</uploaded_files>` 这类内部标记
- 附件:`additional_kwargs.files[]` → `{ filename, size, path, status }`
- 图片 block → `<img>`
- 可选:编辑/删除(删除要改 checkpoint,对接初期可不做)
### 9.2 助手最终回答
- Markdown(GFM 表格/代码块)
- 流式时按 token 追加;`values` 快照到达后用完整 `content` 替换
- 正文里的 `<think>` 必须剥掉(见第 10 节)
- 引用:技能检索结果会在步骤里展开,正文里可能有 `[n]` 引用角标
- 复制 / 导出 Word 可选
### 9.3 流式指示
`busy` 且还没有最终 `assistant` 气泡时,在列表底部显示跳动的「正在生成」。
上传文件未完成时可插一条 mock AI:`additional_kwargs.element === "task"`,文案「文件上传中,请稍候...」。
**参考代码**
| 说明 | 路径 |
|------|------|
| 列表入口、分组渲染 | `frontend-web/src/components/workspace/messages/message-list.tsx` |
| 单条用户/助手气泡 | `frontend-web/src/components/workspace/messages/message-list-item.tsx` |
| Markdown | `frontend-web/src/components/workspace/messages/markdown-content.tsx` |
| 气泡/会话滚动容器 | `frontend-web/src/components/ai-elements/message.tsx`、`conversation.tsx` |
| 跳动「正在生成」 | `frontend-web/src/components/workspace/streaming-indicator.tsx` |
| 技能来源展示 | `frontend-web/src/core/skill-display/runtime.ts` |
| 导出 | `frontend-web/src/core/threads/export.ts`;Word:`frontend-web/src/components/workspace/messages/download-word-button.tsx` |
| 删除消息 | `frontend-web/src/components/workspace/messages/delete-message-button.tsx` |
| 问题跳转 | `frontend-web/src/components/workspace/question-navigator.tsx` |
---
## 10. 思考内容如何对接
思考 **没有** 独立 SSE 事件名,混在 AI 消息里。按优先级取:
1. `additional_kwargs.reasoning_content`(字符串,多数内网模型走这条)
2. content block `{ type: "thinking", thinking: "…" }`
3. 正文里的 `<think>…</think>`(含流式 **未闭合** 的 `<think>`)
### 10.1 剥离算法(请直接用)
```ts
const THINK_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
const THINK_OPEN = '<think>';
function splitThink(content: string): { answer: string; thinking: string | null } {
const parts: string[] = [];
let working = content.replace(THINK_RE, (_, t: string) => {
const s = t.trim();
if (s) parts.push(s);
return '';
});
const i = working.indexOf(THINK_OPEN);
if (i !== -1 && !working.slice(i).includes('</think>')) {
const partial = working.slice(i + THINK_OPEN.length).trim();
if (partial) parts.push(partial);
working = working.slice(0, i);
}
return { answer: working.trim(), thinking: parts.length ? parts.join('\n\n') : null };
}
```
展示用 `answer`,思考区用 `reasoning_content || thinking`。
### 10.2 UI 约定(与现网一致)
- 步骤条里:思考是一种 step,标题 **「思考」**,可折叠,灯泡图标
- 只有思考、还没有工具、还没有最终正文时:单独一块可折叠「思考」,流式时默认展开,结束后可自动收起
- **历史刷新后思考仍在**(写在 checkpoint 的 AI 消息上),不要丢
- 不要把思考当作用户可见的最终答案
`thinking_enabled: false` 时模型不应再吐思考;若仍出现 `<think>`,继续剥掉以免露标签。
**参考代码**
| 说明 | 路径 |
|------|------|
| 抽思考 / 剥 `<think>`(含未闭合) | `frontend-web/src/core/messages/utils.ts`(`extractReasoningContentFromMessage`、`splitInlineReasoning`) |
| 步骤条里的思考步 | `frontend-web/src/components/workspace/messages/message-group.tsx`(`convertToSteps` 的 `reasoning`) |
| 独立思考折叠块 | `frontend-web/src/components/ai-elements/reasoning.tsx` |
| 文案「思考」 | `frontend-web/src/core/i18n/locales/zh-CN.ts`(`t.common.thinking`) |
| 模型思考流字段 | `offline-backend-20260512/backend/packages/harness/deerflow/models/vllm_provider.py` |
| 关思考注入 extra_body | `offline-backend-20260512/backend/packages/harness/deerflow/models/factory.py` |
---
## 11. 步骤条(Chain of Thought)— 请做全
步骤条只渲染 `assistant:processing` 组。内部只有两类 step:
- `reasoning`:思考文本
- `toolCall`:一次工具调用(`task` 子任务除外,走独立卡片)
### 11.1 从消息抽出 steps
对组内每条 `ai`:
1. 若有思考 → push `{ type: "reasoning", reasoning }`
2. 遍历 `tool_calls`,跳过 `name === "task"`
3. 用 `tool_call.id` 在组内找对应 `type==="tool"` 的消息,作为 `result`
4. `result.content` 若是 JSON 字符串就 parse
UI:
- 默认只展示 **最后一个工具步骤** + 其后的思考
- 上方折叠:「查看其他 N 个步骤」/「隐藏步骤」
- 进行中:该步 status=`active`(转圈);有 result 后 `complete`(对勾)
- 工具参数还在流式拼接时,label 用已有 `args.description` 或占位「正在调用工具…」
### 11.2 步骤标题怎么来(「创建脚本 / 执行脚本 / 写入文件」在这里)
**系统没有名为 `create_script` / `execute_script` 的工具。**
模型调用沙箱工具时 **必须先填 `args.description`**(短中文说明),步骤条 **优先展示 description**。所以你会看到:
| 用户看到的步骤 | 实际 `tool_calls[].name` | `args` |
|----------------|--------------------------|--------|
| 创建脚本 | `bash` | `{ description: "创建脚本", command: "cat > /tmp/a.py <<'EOF' …" }` |
| 执行脚本 | `bash` | `{ description: "执行脚本", command: "python3 /tmp/a.py" }` |
| 写入文件 | `write_file` 或 `str_replace` | `{ description: "写入文件", path: "/mnt/user-data/outputs/flow.json", content: "…" }` |
| 读取技能说明 | `read_file` | `{ description: "读取技能说明", path: "/mnt/skills/public/…/SKILL.md" }` |
`description` 是模型写的,文案不固定。没有 description 时再用下表中文底稿。
### 11.3 全量工具 → 步骤条映射
与现网 `message-group.tsx` + `zh-CN.ts toolCalls` 对齐。未知 MCP/技能脚本一律走最后一行。
| `name` | 默认中文标题 | 图标建议 | 展开区 |
|--------|--------------|----------|--------|
| (reasoning step) | 思考 | 灯泡 | Markdown/纯文本思考 |
| `web_search` | `知识库检索 "{query}"`;无 query 则为「知识库检索」 | 搜索 | 结果列表(title/snippet/url),可折叠「N 条检索结果」 |
| 任意工具但 result 长得像检索 `{ results: [...] }` | `搜索 “{query}”` 或「搜索相关信息」 | 搜索 | 同上 |
| 技能展示/RAG 引用模式 | 「检索参考来源」或「获取 N 条结果」 | 搜索 | 来源卡片(title/snippet/原文) |
| `image_search` | `搜索相关图片 “{query}”` | 搜索 | 缩略图网格,点开 `source_url` |
| `web_fetch` | 查看网页 | 地球 | 链接,标题从返回 Markdown 的 `# title` 抽 |
| `ls` | `args.description` 或 **列出文件夹** | 文件夹 | path chip |
| `read_file` | `args.description` 或 **读取文件** | 书本 | path chip |
| `write_file` | `args.description` 或 **写入文件** | 笔记本 | path;点击打开产物预览 |
| `str_replace` | 同上(默认也叫写入文件) | 笔记本 | path |
| `bash` | **必须用 `args.description`**;没有则 **执行命令** | 终端 | 展示 `command` 的 bash 代码块(不一定展示 stdout,stdout 在 tool result 里) |
| `glob` | `args.description` 或 `使用 “glob” 工具` | 扳手 | 可选 path/pattern |
| `grep` | `args.description` 或 `使用 “grep” 工具` | 扳手 | pattern / path |
| `browser_fetch_page` | `args.description` 或 `使用 “browser_fetch_page” 工具` | 扳手 | url |
| `browser_act` | `args.description` 或 `使用 “browser_act” 工具` | 扳手 | 动作说明 |
| `ask_clarification` | **需要你的协助** | 问号 | 空(真正交互在卡片) |
| `write_todos` | **更新 To-do 列表** | 清单 | 空;完整列表见输入框上方 Todo 面板 |
| `present_files` | 一般不进步骤条,进「展示文件」组 | 文件 | 见 13.1 |
| `update_artifact` | `args.title` 或「生成代码」/「生成文档」 | 笔 | 「代码文件」或「Markdown 文档」 |
| `view_image` | `args.description` 或 `使用 “view_image” 工具` | 扳手 | 图片路径 |
| `skill_list` | `args.description` 或 `使用 “skill_list” 工具` | 扳手 | — |
| `skill_view` | 读取某个技能 | 扳手 | 技能名 |
| `search_skills` | 检索可用技能 | 扳手 | 关键词 |
| `memory` / `hindsight_*` | 记忆读写 | 扳手 | — |
| `deep_research_progress` | `args.label` 或「正在撰写研究报告」 | 转圈/对勾 | detail + 可折叠「模型思考」;`status!=="complete"` 为进行中 |
| `task` | **不要**画在步骤条 | — | 见 13.2 子任务卡 |
| **其它 MCP / 自定义工具** | `args.description` **或** `使用 “{name}” 工具` | 扳手 | 有 path/url 可展示 |
`roundtable-structure` 常走技能 `knowledge-base-ingest`,因此一轮里会密集出现:`read_file`(SKILL.md)→ `bash`(创建/执行脚本)→ `write_file`(产物)→ `present_files`。把 `bash.description` 原样显示,就能覆盖「创建脚本」「执行脚本」。
### 11.4 工具参数字段(沙箱类)
所有沙箱工具的 **第一个参数都是 `description`**:
```ts
interface BashArgs { description: string; command: string }
interface LsArgs { description: string; path: string }
interface ReadFileArgs { description: string; path: string }
interface WriteFileArgs { description: string; path: string; content: string }
interface StrReplaceArgs { description: string; path: string; old_str: string; new_str: string }
interface GlobArgs { description: string; pattern: string; path?: string }
interface GrepArgs { description: string; pattern: string; path?: string }
interface WebSearchArgs { query: string }
interface WebFetchArgs { url: string }
```
`write_file` 流式时 `content` 会变长,可做实时预览(现网点击步骤打开右侧产物面板)。
### 11.5 检索结果 result 形状(web_search / 技能)
常见:
```json
{
"success": true,
"query": "台湾滨海防卫",
"count": 10,
"results": [
{
"title": "…",
"content_preview": "…",
"url": "https://…",
"recUuid": "…",
"score": 0.8
}
]
}
```
字段名不统一(`content` / `page_content` / `snippet` / `m_title` / `name`),展示时做并集兜底。
**参考代码**
| 说明 | 路径 |
|------|------|
| **步骤条主实现(必看)** | `frontend-web/src/components/workspace/messages/message-group.tsx`(`convertToSteps`、按 `name` 分支、`ArtifactWriteToolCall`) |
| ChainOfThought 组件 | `frontend-web/src/components/ai-elements/chain-of-thought.tsx` |
| 中文底稿 | `frontend-web/src/core/i18n/locales/zh-CN.ts` → `toolCalls`;类型 `frontend-web/src/core/i18n/locales/types.ts` |
| 工具一句话说明 | `frontend-web/src/core/tools/utils.ts`(`explainToolCall`) |
| 检索结果卡片 | `frontend-web/src/core/messages/search-results.ts` |
| 写文件实时预览 | `frontend-web/src/core/artifacts/streaming-write-preview.ts` |
| bash / ls / read / write / glob / grep | `offline-backend-20260512/backend/packages/harness/deerflow/sandbox/tools.py`(参数都要求先填 `description`) |
| 工具装配(含 MCP、内置) | `offline-backend-20260512/backend/packages/harness/deerflow/tools/tools.py` |
| present_files | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/present_file_tool.py` |
| skill_list / skill_view / search_skills | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/skill_tools.py` |
| 启用哪些工具 | `offline-backend-20260512/backend/config.yaml` → `tools[]` |
| 会商页步骤条(对照用,对话页以 message-group 为准) | `frontend-web/src/roundtable-planning/lib/step-display.tsx` |
---
## 12. 用户协助卡片(ask_clarification)
当模型信息不够、有多种做法、或要确认风险时,会调用工具 `ask_clarification`。
**ClarificationMiddleware 会截住该工具,写入 ToolMessage,并 `goto=END` 结束本轮。**
这 **不是** LangGraph `interrupt()` / `Command(resume)`。用户点选项后,发 **新的 human 消息** 即可,智能体会带着选择继续。
### 12.1 如何识别
`message.type === "tool" && message.name === "ask_clarification"`
结构化数据在:
```json
{
"additional_kwargs": {
"clarification": {
"question": "流程图层级按哪套业务?",
"clarification_type": "approach_choice",
"context": "报告里同时出现了任务链和目的链。",
"options": [
{ "id": "option-0", "label": "任务→目的→行为体", "description": "可选" },
{ "id": "option-1", "label": "按报告原标题层级" }
],
"allow_custom": true,
"allow_multiple": false
}
},
"content": "(Markdown 兜底文案,无结构化数据时用这个渲染)"
}
```
`clarification_type`:
- `missing_info` 缺信息
- `ambiguous_requirement` 需求有歧义
- `approach_choice` 方案选择
- `risk_confirmation` 风险确认
- `suggestion` 建议确认
同一轮可以有 **多张** 独立卡片(多个 tool call)。只统计 **上一条 human 之后** 的 clarification 为「待答」。
### 12.2 UI(与现网文案)
- 标题:**需要你的确认**;多张时显示 `1/N`
- 展示 `context`、`question`
- 选项做成按钮
- `allow_multiple !== true`:单选。**仅一张待答卡时,点击即提交**;多张时只记录选择,点最后一张上的「全部发送」
- `allow_multiple === true`:多选 + 必须点提交
- `allow_custom !== false`(后端恒为 true):额外文本框
- 按钮:「发送回答」/ 多张时「全部发送」
- 历史里已答过的卡:禁用,只读回显
步骤条里对应一步标题仍是「需要你的协助」。
### 12.3 提交内容格式(必须按这个拼,模型才认)
单张:
```text
我选择:任务→目的→行为体
我选择:A、B;补充:用户打的字
(只有自定义)用户打的字
```
多张:
```text
问题1「……」:我选择:A
问题2「……」:补充:……
```
然后:
```http
POST …/runs/stream
input.messages = [{ type: "human", content: "<上面拼好的文本>" }]
```
不要调 `/state` 的 resume,也不要自己造 ToolMessage。
**参考代码**
| 说明 | 路径 |
|------|------|
| 卡片 UI、拼「我选择:」、多卡合并提交 | `frontend-web/src/components/workspace/messages/message-list.tsx`(`ClarificationCard`、`handleSubmitAllClarifications`) |
| 识别 tool 消息 | `frontend-web/src/core/messages/utils.ts`(`isClarificationToolMessage`) |
| 工具定义 | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/clarification_tool.py` |
| 截住工具、写 `additional_kwargs.clarification`、goto END | `offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py`(`_build_payload`) |
| `allow_multiple` 解析 | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/clarification_utils.py` |
| 提交后又走同一套 sendMessage | `frontend-web/src/pages/AgentChatPage.tsx`、`frontend-web/src/core/threads/hooks.ts`(**不是** Command resume) |
---
## 13. 其它卡片与面板
### 13.1 展示文件 `present_files`
AI `tool_calls` 含 `name: "present_files"`,`args.filepaths: string[]`(必须是 `/mnt/user-data/outputs/…`)。
UI:Markdown 摘要 + 文件卡片。点击后:
```http
GET {GATEWAY}/api/threads/{thread_id}/artifacts/{path}
```
`path` 例:`mnt/user-data/outputs/flow.json`(虚拟前缀,不要用 Windows 盘符)。
`?download=true` 强制下载。HTML/SVG 会当附件,降低 XSS。
列表:
```http
GET {GATEWAY}/api/threads/{thread_id}/artifacts
→ { "thread_id", "files": [{ path, name, size_bytes, mime_type, modified_at }] }
```
state 里的 `artifacts: string[]` 是虚拟路径列表,可与卡片同步。
### 13.2 子任务 `task`(ultra 档)
`tool_calls[].name === "task"`,`args` 含 `description` / `prompt` / `subagent_type`。
组类型 `assistant:subagent`,标题「执行 N 个子任务」(N>1 时「并行执行」)。
`custom` 事件 `task_running` 更新进行中文案。终态看对应 tool 结果。
`roundtable-structure` 默认不必开 ultra;对接时可先不做,遇到 `task` 当普通扳手步骤也行。
### 13.3 Todo 面板(pro / ultra)
`write_todos` 出现在步骤条;完整列表在 `values.todos`:
```ts
interface Todo { content?: string; status?: 'pending' | 'in_progress' | 'completed' }
```
现网浮在输入框上方。一轮只应有一个 `in_progress`。
### 13.4 创建智能体 / 写作审批卡
`setup_agent` / `setup_writing` 带 approval kwargs。普通 `roundtable-structure` 对话 **不会出现**,可忽略。
Deep Research 的 `setup_research_approval` 同理。
**参考代码**
| 说明 | 路径 |
|------|------|
| present_files 分组渲染 | `frontend-web/src/components/workspace/messages/message-list.tsx`(`assistant:present-files`) |
| 产物 API | `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` |
| 产物前端 | `frontend-web/src/components/workspace/artifacts/`(如 `artifact-file-detail.tsx`) |
| 子任务卡 | `frontend-web/src/components/workspace/messages/subtask-card.tsx` |
| `task` 工具 / 子代理 | `offline-backend-20260512/backend/packages/harness/deerflow/subagents/` |
| Todo 面板 | `frontend-web/src/components/workspace/todo-list.tsx`;类型 `frontend-web/src/core/todos/types.ts` |
| write_todos 中间件装配 | `offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py`(`_build_middlewares`) |
---
## 14. 标题、追问、错误态
### 14.1 自动标题
第一轮用户消息到达后,TitleMiddleware 先用问题字面做 **临时标题**(`title_provisional: true`),再异步 LLM 生成最终标题。
听 `event: values` 的 `title` 即可,刷新列表。
### 14.2 追问建议(一轮结束后)
```http
POST {GATEWAY}/api/threads/{thread_id}/suggestions
{
"messages": [
{ "role": "user", "content": "…" },
{ "role": "assistant", "content": "…" }
],
"n": 3,
"model_name": "deepseek-chat"
}
```
```json
{ "suggestions": ["是否导出 JSON?", "按另一套层级再抽一次"] }
```
点建议 = 再发一条 human。失败时返回空数组,不要报错挡 UI。
### 14.3 错误与限流
| 情况 | 表现 | UI |
|------|------|----|
| 会话已在生成 | HTTP 409 | toast:「当前对话还在生成回答…」,不要红框 |
| 全局并发打满 | HTTP 429 | toast 提示稍后再试 |
| 模型失败重试 | `custom.llm_retry` | 轻提示 |
| 上下文压缩 | `custom.context_compacting` | 「正在压缩上下文…」 |
| 会话不属于当前用户 | 403/404 | 回到新建 |
**参考代码**
| 说明 | 路径 |
|------|------|
| 标题展示 | `frontend-web/src/components/workspace/thread-title.tsx` |
| 列表用 title | `frontend-web/src/core/threads/utils.ts`(`titleOfThread`) |
| 临时标题 + LLM 标题 | `offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py` |
| 追问 API 调用 | `frontend-web/src/components/workspace/input-box.tsx`(`/api/threads/${threadId}/suggestions`) |
| 追问后端 | `offline-backend-20260512/backend/app/gateway/routers/suggestions.py` |
| 409 toast | `frontend-web/src/core/threads/hooks.ts`(`isActiveRunConflictError` / `onError`) |
| 压缩 toast | `offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/summarization_middleware.py`(`custom.context_compacting`) |
---
## 15. 文件上传(用户侧)
先有 `thread_id`(可先 create 空会话)。
```http
POST {GATEWAY}/api/threads/{thread_id}/uploads
Authorization: Bearer <jwt>
Content-Type: multipart/form-data
```
字段名 `files`,可多文件。默认上限约 10 个、单文件 50MB、合计 100MB。
PDF/PPT/Excel/Word 会转成 Markdown 一并放入 uploads。
```json
{
"success": true,
"message": "Successfully uploaded 1 file(s)",
"files": [
{
"filename": "report.docx",
"virtual_path": "/mnt/user-data/uploads/report.docx",
"markdown_virtual_path": "/mnt/user-data/uploads/report.md"
}
],
"skipped_files": []
}
```
发消息时:
```json
"additional_kwargs": {
"files": [
{ "filename": "report.md", "path": "/mnt/user-data/uploads/report.md", "status": "uploaded" }
]
}
```
限额:`GET /api/threads/{thread_id}/uploads/limits`。
列表/删除:`GET …/uploads/list`,`DELETE …/uploads/{filename}`。
**参考代码**
| 说明 | 路径 |
|------|------|
| 前端上传 | `frontend-web/src/components/workspace/input-box.tsx`;预览 `frontend-web/src/components/workspace/messages/uploaded-file-preview-dialog.tsx` |
| 后端上传路由 | `offline-backend-20260512/backend/app/gateway/routers/uploads.py` |
| 目录与虚拟路径 | `offline-backend-20260512/backend/packages/harness/deerflow/uploads/manager.py` |
| 文档转 Markdown | `offline-backend-20260512/backend/packages/harness/deerflow/utils/file_conversion.py` |
| 说明文档 | `offline-backend-20260512/backend/docs/FILE_UPLOAD.md` |
---
## 16. 推荐的页面结构(对标 AgentChatPage)
不必做智能体配置侧栏。最小工作台:
```
┌─ 左:会话列表(threads/search by agent_id)─┬─ 中:消息列表 ─┬─ 右:产物预览 ─┐
│ 新建对话 │ 标题(values.title)│ artifacts │
│ 每条 title + 时间 │ 用户/步骤条/卡片/回答│ │
│ │ Todo(可选) │ │
│ │ 输入框:模型+档位+停止│ │
└────────────────────────────────────────────────┴─────────────────────┴────────────────┘
```
输入框建议:
- 模型 Select(`GET /api/models`)
- 档位:快速 / 思考 / 规划(对应 flash / thinking / pro);ultra 可隐藏
- 发送、停止(streaming 时)
- 可选:上传、追问 chips
`roundtable-structure` 是「结构化抽取」智能体:给它方案报告或席位交付,产出 flow-json 流程图数据,常伴随写文件 + 展示文件。不要当成多智能体会商编排页(`/roundtable` 是另一套 API)。
**参考代码**
| 说明 | 路径 |
|------|------|
| 对话页整页 | `frontend-web/src/pages/AgentChatPage.tsx` |
| 路由 | `frontend-web/src/pages/WorkspaceRoutes.tsx` |
| ChatBox(标题栏 + 列表 + 输入) | `frontend-web/src/components/workspace/chats/chat-box.tsx` |
| 输入框 | `frontend-web/src/components/workspace/input-box.tsx` |
| 左栏最近会话 | `frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx`、`agent-recent-chats.tsx` |
| 欢迎/推荐问题 | `frontend-web/src/components/workspace/agent-welcome.tsx`、`agents/agent-question-chips.tsx` |
| 智能体种子 | `offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/` |
| 会商编排(不要和本页搞混) | `frontend-web/src/roundtable-planning/`、`offline-backend-20260512/backend/app/gateway/routers/multi_agent.py` |
| iframe 嵌现网 UI(备选) | `frontend-web/docs/embed-iframe-chat.md` |
---
## 17. 最小对接顺序(验收清单)
1. `POST /api/v1/auth/login/username` → 存 JWT
2. CORS 已包含 Angular Origin
3. `GET /api/models` → 下拉,选中项的 `name` 写入 `context.model_name`
4. `POST /api/threads` + `metadata.agent_id=roundtable-structure`
5. `POST /api/threads/{id}/runs/stream`,`assistant_id` 与 `context.agent_id` 都等于 `roundtable-structure`
6. 解析 SSE:`metadata` → `messages` 增量 → `values` 覆盖 → `end`
7. 消息分组:用户气泡 / 步骤条 / 协助卡片 / 最终 Markdown
8. 步骤条至少覆盖:`bash`(description=创建脚本/执行脚本)、`write_file`(写入文件)、`read_file`、`ls`、`web_search`、未知工具
9. 思考:`reasoning_content` + `<think>` 剥离
10. 协助卡片:选项 → 拼「我选择:」→ 新一轮 human
11. `POST /threads/search` 还原列表;`GET /state` 还原消息(含思考与步骤)
12. 停止:`POST …/runs/{run_id}/cancel`
13. 409 toast
14. `present_files` → `GET …/artifacts/{path}`
---
## 18. 接口速查
Base:`{GATEWAY} = http://host:8001`,LangGraph `{LG} = {GATEWAY}/api`。
除登录外均需 `Authorization: Bearer`。
| 方法 | 路径 | 用途 |
|------|------|------|
| POST | `/api/v1/auth/login/username` | 用户名登录 |
| POST | `/api/v1/auth/login/token?token=` | 上游 token 换票 |
| GET | `/api/v1/auth/me` | 当前用户 |
| GET | `/api/models` | 模型列表 |
| POST | `/api/threads` | 建会话 |
| POST | `/api/threads/search` | 历史列表 |
| POST | `/api/threads/count` | 历史条数 |
| GET | `/api/threads/{id}` | 会话摘要 + values |
| GET | `/api/threads/{id}/state` | 最新 checkpoint |
| PATCH | `/api/threads/{id}` | 改 metadata |
| DELETE | `/api/threads/{id}` | 删除会话 |
| POST | `/api/threads/{id}/history` | checkpoint 历史 |
| POST | `/api/threads/{id}/runs/stream` | **流式对话** |
| POST | `/api/threads/{id}/runs/{rid}/cancel` | 停止 |
| GET | `/api/threads/{id}/runs` | run 列表 |
| GET | `/api/threads/{id}/runs/{rid}/join` | 重连 SSE |
| POST | `/api/threads/{id}/uploads` | 上传 |
| GET | `/api/threads/{id}/artifacts` | 产物列表 |
| GET | `/api/threads/{id}/artifacts/{path}` | 读产物 |
| POST | `/api/threads/{id}/suggestions` | 追问 |
可选简化入口(**无登录、不落聊天记录、没有步骤条协议**,不建议用来复刻本页):
- `POST /api/open/chat` / `/api/open/chat/stream`(`agent_id` 或中文 `agent_name`)
可选 iframe 嵌现网 UI(不自己画步骤条):见 `frontend-web/docs/embed-iframe-chat.md`。那是通用工作台 `/embed/chats`,不是 `agents/roundtable-structure` 路由;若必须嵌本页,可用:
```
{FRONTEND}/#/page/workspace/agents/roundtable-structure/chats/new?embed=1
```
仍要先登录;iframe 跨域时走 `?embed=1` 可隐藏外壳。要完全自绘 UI,请走本文 REST + SSE。
---
## 19. Angular 工程注意
1. **POST SSE 用 fetch**,不要 `HttpClient.get` + EventSource
2. `run_id` 从 `Content-Location` 或 `event: metadata` 取;取消必须打 cancel
3. `assistant_id` 用 **`roundtable-structure`**,不要传「结构化抽取」
4. 历史过滤靠 `metadata.agent_id`,第一轮后务必 PATCH
5. 同一 thread 未 `end` 不要再发,否则 409
6. JWT 约 7 天,无 refresh
7. 把 `localhost:4200` 加入 `GATEWAY_CORS_ORIGINS`
8. 可用 npm 包 `@langchain/langgraph-sdk` 的 `Client`(`apiUrl: LG, defaultHeaders: { Authorization }`)少写 SSE 细节;UI 仍要自己做分组/步骤条/卡片
9. 不要把思考、tool 结果、协助卡当成三条普通聊天记录平铺,观感会和现网差很多
10. 步骤条 label **永远优先 `args.description`**,这样才能显示「创建脚本」「执行脚本」「写入文件」
---
## 20. 参考实现总索引
路径均相对仓库根 `deerflow-server/`。后端根目录 = `offline-backend-20260512/backend/`。
各节正文末尾也有对应表;这里按主题汇总,加粗的是对接时优先打开的文件。
### 20.1 前端:页面与 UI
| 主题 | 路径 |
|------|------|
| **智能体对话页** | `frontend-web/src/pages/AgentChatPage.tsx` |
| 路由 `agents/:agent_id/chats/:thread_id` | `frontend-web/src/pages/WorkspaceRoutes.tsx` |
| 对话壳(标题 / 列表 / 输入 / 产物入口) | `frontend-web/src/components/workspace/chats/chat-box.tsx` |
| **消息列表 + 协助卡片** | `frontend-web/src/components/workspace/messages/message-list.tsx` |
| 用户/助手气泡 | `frontend-web/src/components/workspace/messages/message-list-item.tsx` |
| **步骤条(创建脚本/执行脚本/写入文件等)** | `frontend-web/src/components/workspace/messages/message-group.tsx` |
| Markdown | `frontend-web/src/components/workspace/messages/markdown-content.tsx` |
| 子任务卡 | `frontend-web/src/components/workspace/messages/subtask-card.tsx` |
| 思考折叠块 | `frontend-web/src/components/ai-elements/reasoning.tsx` |
| ChainOfThought 控件 | `frontend-web/src/components/ai-elements/chain-of-thought.tsx` |
| 输入框(模型、档位、发送、停止、追问、上传) | `frontend-web/src/components/workspace/input-box.tsx` |
| Todo 条 | `frontend-web/src/components/workspace/todo-list.tsx` |
| 会话标题 | `frontend-web/src/components/workspace/thread-title.tsx` |
| 正在生成 | `frontend-web/src/components/workspace/streaming-indicator.tsx` |
| 会话列表 UI | `frontend-web/src/components/workspace/workspace-nav-chat-list.tsx` |
| 该智能体最近对话 | `frontend-web/src/components/workspace/agents/agent-recent-chats.tsx` |
| 左栏 | `frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx` |
| 产物预览 | `frontend-web/src/components/workspace/artifacts/` |
| 中文步骤文案 `toolCalls.*` | `frontend-web/src/core/i18n/locales/zh-CN.ts` |
### 20.2 前端:协议与状态
| 主题 | 路径 |
|------|------|
| **发消息 / useStream / cancel / 409** | `frontend-web/src/core/threads/hooks.ts` |
| 线程类型 | `frontend-web/src/core/threads/types.ts` |
| 标题与路由 | `frontend-web/src/core/threads/utils.ts` |
| **消息分组、思考、隐藏** | `frontend-web/src/core/messages/utils.ts` |
| 检索结果归一化 | `frontend-web/src/core/messages/search-results.ts` |
| **登录 API** | `frontend-web/src/core/auth/api.ts` |
| Bearer 存取 | `frontend-web/src/core/auth/index.ts` |
| 模型 API / 类型 | `frontend-web/src/core/models/api.ts`、`frontend-web/src/core/models/types.ts` |
| LangGraph Client | `frontend-web/src/core/api/api-client.ts` |
| Gateway fetch + 401 | `frontend-web/src/core/api/fetch-client.ts` |
| Base URL | `frontend-web/src/core/config/index.ts`、`frontend-web/src/env.ts` |
| 写文件流式预览 | `frontend-web/src/core/artifacts/streaming-write-preview.ts` |
| 工具一句话 | `frontend-web/src/core/tools/utils.ts` |
### 20.3 后端:HTTP
| 主题 | 路径 |
|------|------|
| **登录 /me** | `offline-backend-20260512/backend/app/gateway/routers/auth.py` |
| JWT | `offline-backend-20260512/backend/app/gateway/auth/jwt.py` |
| **CORS** | `offline-backend-20260512/backend/app/gateway/app.py` |
| 鉴权中间件 | `offline-backend-20260512/backend/app/gateway/auth_middleware.py` |
| **模型列表** | `offline-backend-20260512/backend/app/gateway/routers/models.py` |
| **会话 CRUD / search / state** | `offline-backend-20260512/backend/app/gateway/routers/threads.py` |
| **runs/stream / cancel / join** | `offline-backend-20260512/backend/app/gateway/routers/thread_runs.py` |
| start_run、SSE、context 白名单 | `offline-backend-20260512/backend/app/gateway/services.py` |
| 上传 | `offline-backend-20260512/backend/app/gateway/routers/uploads.py` |
| 产物 | `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` |
| 追问 | `offline-backend-20260512/backend/app/gateway/routers/suggestions.py` |
| 简化开放聊天(不要用来复刻本页) | `offline-backend-20260512/backend/app/gateway/routers/open_chat.py` |
### 20.4 后端:运行时与工具
| 主题 | 路径 |
|------|------|
| **跑图 + 发 SSE 事件** | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/worker.py` |
| RunManager | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py` |
| StreamBridge | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/stream_bridge.py` |
| 消息序列化 | `offline-backend-20260512/backend/packages/harness/deerflow/runtime/serialization.py` |
| Lead Agent | `offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py` |
| **协助卡中间件** | `offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/clarification_middleware.py` |
| 协助工具 | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/clarification_tool.py` |
| **bash / write_file 等(description)** | `offline-backend-20260512/backend/packages/harness/deerflow/sandbox/tools.py` |
| 工具总装 | `offline-backend-20260512/backend/packages/harness/deerflow/tools/tools.py` |
| present_files | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/present_file_tool.py` |
| 标题 | `offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/title_middleware.py` |
| 模型工厂 | `offline-backend-20260512/backend/packages/harness/deerflow/models/factory.py` |
### 20.5 配置、种子、文档
| 主题 | 路径 |
|------|------|
| **roundtable-structure 种子** | `offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/config.yaml` |
| 同上 SOUL | `offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/SOUL.md` |
| 模型 / 工具开关 | `offline-backend-20260512/backend/config.yaml` |
| 流式协议 | `offline-backend-20260512/backend/docs/STREAMING.md` |
| 登录契约 | `offline-backend-20260512/backend/docs/AUTH_LOGIN.md` |
| 上传 | `offline-backend-20260512/backend/docs/FILE_UPLOAD.md` |
| iframe 嵌入备选 | `frontend-web/docs/embed-iframe-chat.md` |