feat: add api v1 bearer auth and polling docs
This commit is contained in:
parent
d6fb59e1d8
commit
82e7d0680a
127
api_doc/README.md
Normal file
127
api_doc/README.md
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
# API v1 接入文档(后台任务 + 轮询进度 + 文件)
|
||||||
|
|
||||||
|
本目录面向**第三方/多端**(Web、Flutter、脚本、其它服务)对接,目标是:
|
||||||
|
|
||||||
|
- API 调用不依赖 WebSocket:网页刷新/断网/关闭不会影响后台任务运行;
|
||||||
|
- 客户端使用 **HTTP 轮询**获取与网页端一致的细粒度进度(token 输出、工具块、工具结果等);
|
||||||
|
- API 账号与网页账号隔离:避免互相抢占任务/文件/对话状态。
|
||||||
|
|
||||||
|
## 基本信息
|
||||||
|
|
||||||
|
- API 版本:v1
|
||||||
|
- 默认后端地址(本地):`http://localhost:8091`
|
||||||
|
- 鉴权方式:`Authorization: Bearer <token>`
|
||||||
|
- API 前缀:`/api/v1`
|
||||||
|
- 数据落盘:对话与文件落盘到 API 用户独立目录(见 `auth.md`)
|
||||||
|
- 任务与事件:**仅内存**保存(进程重启后任务/事件不可恢复)
|
||||||
|
- 默认最大迭代次数 `max_iterations`:**100**(可在调用 `/api/v1/messages` 时覆盖)
|
||||||
|
|
||||||
|
## 只保留的接口(共 6 组能力)
|
||||||
|
|
||||||
|
1. 创建对话:`POST /api/v1/conversations`
|
||||||
|
2. 发送消息/启动后台任务:`POST /api/v1/messages`
|
||||||
|
3. 轮询任务事件:`GET /api/v1/tasks/<task_id>?from=<offset>`
|
||||||
|
4. 停止任务:`POST /api/v1/tasks/<task_id>/cancel`
|
||||||
|
5. 文件上传(仅 user_upload):`POST /api/v1/files/upload`
|
||||||
|
6. 文件浏览/下载:`GET /api/v1/files`、`GET /api/v1/files/download`
|
||||||
|
|
||||||
|
详细参数与返回请看:
|
||||||
|
|
||||||
|
- `auth.md`:API 用户与 Token、目录结构、安全注意事项
|
||||||
|
- `messages_tasks.md`:发送消息/轮询/停止
|
||||||
|
- `events.md`:事件流格式与事件类型说明(与 WebSocket 同源)
|
||||||
|
- `files.md`:上传/列目录/下载
|
||||||
|
- `errors.md`:HTTP 错误码与常见排查
|
||||||
|
- `examples.md`:curl/Python/JS/Flutter 示例
|
||||||
|
- `openapi.yaml`:OpenAPI 3.0 规范(可导入 Postman/Swagger)
|
||||||
|
|
||||||
|
## 快速开始(curl)
|
||||||
|
|
||||||
|
> 将 `<TOKEN>` 替换为你的 Bearer Token。
|
||||||
|
> 默认后端:`http://localhost:8091`,如不同请修改。
|
||||||
|
|
||||||
|
### 0)创建对话
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST \
|
||||||
|
-H "Authorization: Bearer <TOKEN>" \
|
||||||
|
http://localhost:8091/api/v1/conversations
|
||||||
|
```
|
||||||
|
|
||||||
|
返回示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "success": true, "conversation_id": "conv_20260123_234245_036" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1)发送消息(创建后台任务)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST \
|
||||||
|
-H "Authorization: Bearer <TOKEN>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"conversation_id": "conv_20260123_234245_036",
|
||||||
|
"message": "请用中文简要介绍《明日方舟:终末地》",
|
||||||
|
"run_mode": "fast", # 可选:fast / thinking / deep
|
||||||
|
"model_key": null,
|
||||||
|
"max_iterations": 100 # 默认 100,示例显式填写便于对齐
|
||||||
|
}' \
|
||||||
|
http://localhost:8091/api/v1/messages
|
||||||
|
```
|
||||||
|
|
||||||
|
返回示例(202):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"task_id": "60322db3-f884-4a1e-a9b3-6eeb07fbab47",
|
||||||
|
"conversation_id": "conv_20260123_234245_036",
|
||||||
|
"status": "running",
|
||||||
|
"created_at": 1769182965.30
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2)轮询进度(按 offset 增量拉取)
|
||||||
|
|
||||||
|
首次从 `from=0` 开始,之后使用返回中的 `next_offset`。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS \
|
||||||
|
-H "Authorization: Bearer <TOKEN>" \
|
||||||
|
"http://localhost:8091/api/v1/tasks/60322db3-f884-4a1e-a9b3-6eeb07fbab47?from=0"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3)停止任务(可选)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST \
|
||||||
|
-H "Authorization: Bearer <TOKEN>" \
|
||||||
|
http://localhost:8091/api/v1/tasks/60322db3-f884-4a1e-a9b3-6eeb07fbab47/cancel
|
||||||
|
```
|
||||||
|
|
||||||
|
## 统一约定:响应结构与错误处理
|
||||||
|
|
||||||
|
### 成功响应
|
||||||
|
|
||||||
|
大部分接口返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "success": true, ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 失败响应
|
||||||
|
|
||||||
|
大部分失败返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "success": false, "error": "原因说明" }
|
||||||
|
```
|
||||||
|
|
||||||
|
并使用合适的 HTTP 状态码(如 400/401/404/409/503)。
|
||||||
|
|
||||||
|
## 重要限制(务必阅读)
|
||||||
|
|
||||||
|
- **单用户禁止并发任务**:同一个 API 用户同一时间只允许一个 `running/pending` 任务。重复发送消息会返回 `409`。
|
||||||
|
- **事件缓冲为内存队列(maxlen=1000)**:任务特别长或轮询太慢会导致早期事件被丢弃;请按推荐频率轮询并在客户端持久化你需要的内容。
|
||||||
|
- **进程重启不可恢复**:重启后任务/事件会消失,但对话/文件已落盘的不受影响。
|
||||||
119
api_doc/auth.md
Normal file
119
api_doc/auth.md
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
# 认证与 API 用户(JSON 手动维护)
|
||||||
|
|
||||||
|
本项目的 API v1 使用 **Bearer Token** 鉴权,且 **API 用户与网页用户完全隔离**。
|
||||||
|
|
||||||
|
## 1. 鉴权方式(每个请求都要带)
|
||||||
|
|
||||||
|
请求头:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <TOKEN>
|
||||||
|
```
|
||||||
|
|
||||||
|
服务端会对 `<TOKEN>` 做 `SHA256`,与 `data/api_users.json` 中保存的 `token_sha256` 进行匹配。
|
||||||
|
|
||||||
|
## 2. API 用户配置文件
|
||||||
|
|
||||||
|
路径:
|
||||||
|
|
||||||
|
- `data/api_users.json`
|
||||||
|
|
||||||
|
格式(示例):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"users": {
|
||||||
|
"api_jojo": {
|
||||||
|
"token_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
"created_at": "2026-01-23",
|
||||||
|
"note": "for flutter app"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
- `users`:对象,key 是 API 用户名(建议全小写、只包含字母数字下划线/连字符)。
|
||||||
|
- `token_sha256`:必填,token 的 SHA256 十六进制字符串。
|
||||||
|
- `created_at` / `note`:可选,仅用于记录与审计。
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
- 当前实现为**启动时加载**该 JSON;如果你修改了 `data/api_users.json`,请**重启服务**使其生效。
|
||||||
|
|
||||||
|
## 3. 生成 token_sha256 的方法
|
||||||
|
|
||||||
|
### 3.1 用 Python 生成
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 - <<'PY'
|
||||||
|
import hashlib
|
||||||
|
token = "YOUR_TOKEN"
|
||||||
|
print(hashlib.sha256(token.encode("utf-8")).hexdigest())
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
把输出粘到 `token_sha256`。
|
||||||
|
|
||||||
|
### 3.2 token 建议
|
||||||
|
|
||||||
|
- 长度建议:>= 32 字符(越长越好)
|
||||||
|
- 生成建议:使用密码管理器或安全随机源生成
|
||||||
|
- 存储建议:客户端侧使用系统安全存储(Keychain/Keystore),不要写死在前端代码里
|
||||||
|
|
||||||
|
## 4. API 用户的独立落盘目录
|
||||||
|
|
||||||
|
每个 API 用户会创建独立工作区,默认根目录:
|
||||||
|
|
||||||
|
- `api/users/<api_username>/`
|
||||||
|
|
||||||
|
典型结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
api/users/<api_username>/
|
||||||
|
project/
|
||||||
|
user_upload/ # API 上传文件只能落在这里(以及其子目录)
|
||||||
|
... # 智能体运行产生的项目文件也在 project 下
|
||||||
|
data/
|
||||||
|
conversations/ # 对话 JSON 落盘目录
|
||||||
|
memory.md
|
||||||
|
task_memory.md
|
||||||
|
...
|
||||||
|
logs/
|
||||||
|
```
|
||||||
|
|
||||||
|
对话文件示例路径:
|
||||||
|
|
||||||
|
- `api/users/api_jojo/data/conversations/conv_20260123_234245_036.json`
|
||||||
|
|
||||||
|
对话 JSON 结构(简化示例):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "conv_20260123_234245_036",
|
||||||
|
"title": "新对话",
|
||||||
|
"created_at": "2026-01-23T23:42:45.036Z",
|
||||||
|
"updated_at": "...",
|
||||||
|
"messages": [
|
||||||
|
{ "role": "user", "content": "你好", "timestamp": "..." },
|
||||||
|
{ "role": "assistant", "content": "…", "reasoning_content": "", "timestamp": "..." },
|
||||||
|
{ "role": "tool", "name": "web_search", "content": "{...}", "timestamp": "..." }
|
||||||
|
],
|
||||||
|
"metadata": { "run_mode": "fast", "model_key": "kimi", "...": "..." }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- API v1 **没有提供“读取历史对话”的 HTTP 接口**;如确实需要历史,请直接读落盘文件或后续再加只读接口。
|
||||||
|
|
||||||
|
## 5. 安全注意事项
|
||||||
|
|
||||||
|
- Bearer Token 等同密码:不要放到 Git、不要发到日志、不要在网页端暴露。
|
||||||
|
- 如果怀疑泄露:替换 token(更新 JSON),重启服务;旧 token 会立即失效。
|
||||||
|
- 生产环境建议:
|
||||||
|
- 通过反向代理限制来源 IP;
|
||||||
|
- 记录 API 调用审计日志(至少记录 user、endpoint、时间、返回码);
|
||||||
|
- 加入速率限制(避免被刷)。
|
||||||
|
|
||||||
70
api_doc/errors.md
Normal file
70
api_doc/errors.md
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
# 错误码与常见问题(HTTP 层)
|
||||||
|
|
||||||
|
本 API 的错误分两类:
|
||||||
|
|
||||||
|
1) **HTTP 层错误**:接口直接返回 `success=false`,并用 HTTP 状态码表达错误类型。
|
||||||
|
2) **任务内错误**:接口本身 200,但轮询的 `events` 中出现 `type=error`,且任务状态变为 `failed`。
|
||||||
|
|
||||||
|
## 1. HTTP 状态码约定
|
||||||
|
|
||||||
|
| 状态码 | 何时出现 | 返回体示例 |
|
||||||
|
|---:|---|---|
|
||||||
|
| 200 | 成功(大多数 GET/POST) | `{ "success": true, ... }` |
|
||||||
|
| 202 | 成功但已进入后台执行(发送消息) | `{ "success": true, "task_id": "...", "status": "running", ... }` |
|
||||||
|
| 400 | 参数错误(缺字段/格式不对/路径非法) | `{ "success": false, "error": "..." }` |
|
||||||
|
| 401 | 未授权(缺 token 或 token 无效) | `{ "success": false, "error": "..." }` |
|
||||||
|
| 404 | 资源不存在(task/file/path 不存在) | `{ "success": false, "error": "..." }` |
|
||||||
|
| 409 | 并发冲突(已有 running/pending 任务) | `{ "success": false, "error": "已有运行中的任务,请稍后再试。" }` |
|
||||||
|
| 500 | 服务端内部错误(少见) | `{ "success": false, "error": "..." }` |
|
||||||
|
| 503 | 系统未初始化/资源繁忙(容器不可用等) | `{ "success": false, "error": "..." }` |
|
||||||
|
|
||||||
|
## 2. 常见错误与排查
|
||||||
|
|
||||||
|
### 2.1 401 Unauthorized
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 忘记带 `Authorization: Bearer ...`
|
||||||
|
- token 不存在或已被替换
|
||||||
|
|
||||||
|
排查:
|
||||||
|
|
||||||
|
- 检查 `data/api_users.json` 是否包含该 token 的 SHA256
|
||||||
|
- 修改 JSON 后是否重启服务(当前为启动时加载)
|
||||||
|
|
||||||
|
### 2.2 409 已有运行中的任务
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 同一个 API 用户在任务未结束时再次调用 `/api/v1/messages`
|
||||||
|
|
||||||
|
处理建议:
|
||||||
|
|
||||||
|
- 客户端 UI:禁用“发送”按钮,直到轮询显示 `status != running`
|
||||||
|
- 或先调用 `/api/v1/tasks/<task_id>/cancel` 再发送新任务
|
||||||
|
|
||||||
|
### 2.3 轮询一直 running,但没有 events
|
||||||
|
|
||||||
|
可能原因:
|
||||||
|
|
||||||
|
- 模型调用还没开始(排队/网络慢)
|
||||||
|
- 事件被缓冲上限丢弃(极少见,通常是轮询太慢)
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- 将轮询间隔调小(0.5s~1s)
|
||||||
|
- 确保客户端从 `next_offset` 增量拉取,不要重复拉取导致 UI“看似卡住”
|
||||||
|
|
||||||
|
### 2.4 任务失败(轮询 events 里出现 type=error)
|
||||||
|
|
||||||
|
原因可能来自:
|
||||||
|
|
||||||
|
- 模型调用异常(上游 API/Key/网络)
|
||||||
|
- 工具执行异常(例如 web_search/extract_webpage 失败)
|
||||||
|
- 运行时资源问题
|
||||||
|
|
||||||
|
建议:
|
||||||
|
|
||||||
|
- 读取 `GET /api/v1/tasks/<task_id>` 返回的 `data.error`
|
||||||
|
- 同时遍历 `events` 中的 `type=error` 与 `system_message`,这些往往包含更具体原因
|
||||||
|
|
||||||
255
api_doc/events.md
Normal file
255
api_doc/events.md
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
# 事件流说明(轮询返回 events)
|
||||||
|
|
||||||
|
`GET /api/v1/tasks/<task_id>` 返回的 `events` 字段,是一个按时间顺序(`idx` 递增)的**事件流**。
|
||||||
|
|
||||||
|
本项目的目标是:**与网页端 WebSocket 事件保持同一粒度**。因此你会看到:
|
||||||
|
|
||||||
|
- token 级别的 `text_chunk`/`thinking_chunk`
|
||||||
|
- 工具调用生命周期事件(准备/意图/开始/状态更新)
|
||||||
|
- 工具执行结果的状态更新与系统消息
|
||||||
|
|
||||||
|
> 提示:客户端应只依赖 `type` 字符串与 `data` 字段,并对未知事件类型保持兼容(忽略或记录)。
|
||||||
|
|
||||||
|
## 1. 事件统一包裹格式(envelope)
|
||||||
|
|
||||||
|
每个事件的结构:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"type": "text_chunk",
|
||||||
|
"data": { "content": "你好", "index": 5, "elapsed": 0.003 },
|
||||||
|
"ts": 1769182968.154797
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段含义:
|
||||||
|
|
||||||
|
- `idx`:事件序号(从 0 开始递增),用于轮询 offset 与去重
|
||||||
|
- `type`:事件类型(字符串)
|
||||||
|
- `data`:事件载荷(不同 type 不同结构)
|
||||||
|
- `ts`:服务端记录的 UNIX 时间戳(秒,float)
|
||||||
|
|
||||||
|
## 2. offset 与去重
|
||||||
|
|
||||||
|
轮询接口支持 `from` 参数:
|
||||||
|
|
||||||
|
- `GET /api/v1/tasks/<task_id>?from=0`:从头拉取
|
||||||
|
- `GET /api/v1/tasks/<task_id>?from=next_offset`:增量拉取
|
||||||
|
|
||||||
|
正确做法:
|
||||||
|
|
||||||
|
1. 客户端本地保存 `offset`(初始 0)
|
||||||
|
2. 轮询请求带上 `from=offset`
|
||||||
|
3. 处理返回 `events`
|
||||||
|
4. 将 `offset` 更新为返回的 `next_offset`
|
||||||
|
|
||||||
|
## 3. 常见事件类型与载荷
|
||||||
|
|
||||||
|
以下为**常见**事件类型(实际可能随版本增加更多 type)。
|
||||||
|
|
||||||
|
### 3.1 AI 消息生命周期
|
||||||
|
|
||||||
|
#### `ai_message_start`
|
||||||
|
|
||||||
|
AI 新一轮回复开始(一次用户消息可能触发多次迭代,但 UI 通常以此作为“助手消息开始”的信号)。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "ai_message_start", "data": {} }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 思考(reasoning)
|
||||||
|
|
||||||
|
#### `thinking_start`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "thinking_start", "data": {} }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `thinking_chunk`
|
||||||
|
|
||||||
|
逐段思考内容(细粒度流式)。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "thinking_chunk", "data": { "content": "..." } }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `thinking_end`
|
||||||
|
|
||||||
|
思考结束(带完整思考文本)。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "thinking_end", "data": { "full_content": "..." } }
|
||||||
|
```
|
||||||
|
|
||||||
|
建议客户端处理方式:
|
||||||
|
|
||||||
|
- 若你想还原“实时思考流”,就持续 append `thinking_chunk.data.content`;
|
||||||
|
- 若你只想最终值,可忽略 chunk,只在 `thinking_end` 读取 `full_content`。
|
||||||
|
|
||||||
|
### 3.3 正文输出(token 级)
|
||||||
|
|
||||||
|
#### `text_start`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "text_start", "data": {} }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `text_chunk`
|
||||||
|
|
||||||
|
逐 token/子串输出。字段:
|
||||||
|
|
||||||
|
- `content`:本次增量内容
|
||||||
|
- `index`:从 1 开始的 chunk 序号(仅用于调试/对齐)
|
||||||
|
- `elapsed`:与上一 chunk 的时间间隔(秒)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "text_chunk",
|
||||||
|
"data": { "content": "你好", "index": 5, "elapsed": 0.003 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `text_end`
|
||||||
|
|
||||||
|
正文结束,包含 `full_content`(完整文本)。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "text_end", "data": { "full_content": "..." } }
|
||||||
|
```
|
||||||
|
|
||||||
|
建议客户端处理方式:
|
||||||
|
|
||||||
|
- 实时显示:append `text_chunk.data.content`
|
||||||
|
- 最终落盘:以 `text_end.data.full_content` 为准(如果你做了实时 append,最终可用 full_content 校验/纠偏)
|
||||||
|
|
||||||
|
> 注意:若模型在工具调用后没有再次输出总结文本,则可能出现“最后一轮事件是工具相关,没有 text_end”的情况;这属于模型行为/迭代次数限制导致,并不代表任务异常。
|
||||||
|
|
||||||
|
### 3.4 工具调用(tool)相关
|
||||||
|
|
||||||
|
工具链路通常会出现如下事件:
|
||||||
|
|
||||||
|
1) `tool_hint`(可选,提前猜测意图)
|
||||||
|
2) `tool_preparing`(模型开始输出 tool_calls 时)
|
||||||
|
3) `tool_intent`(从增量 arguments 中抽取 intent 字段)
|
||||||
|
4) `tool_start`(真正执行工具时)
|
||||||
|
5) `update_action`(执行进度与结果状态更新)
|
||||||
|
|
||||||
|
#### `tool_hint`(可选)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "tool_hint",
|
||||||
|
"data": {
|
||||||
|
"id": "early_web_search_...",
|
||||||
|
"name": "web_search",
|
||||||
|
"message": "检测到可能需要调用 web_search...",
|
||||||
|
"confidence": "low",
|
||||||
|
"conversation_id": "conv_..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `tool_preparing`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "tool_preparing",
|
||||||
|
"data": {
|
||||||
|
"id": "web_search:0",
|
||||||
|
"name": "web_search",
|
||||||
|
"message": "准备调用 web_search...",
|
||||||
|
"intent": "搜索明日方舟终末地游戏信息",
|
||||||
|
"conversation_id": "conv_..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `tool_intent`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "tool_intent",
|
||||||
|
"data": {
|
||||||
|
"id": "web_search:0",
|
||||||
|
"name": "web_search",
|
||||||
|
"intent": "搜索明日方舟终末地游戏信息",
|
||||||
|
"conversation_id": "conv_..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `tool_start`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "tool_start",
|
||||||
|
"data": {
|
||||||
|
"id": "tool_0_web_search_...",
|
||||||
|
"name": "web_search",
|
||||||
|
"arguments": { "...": "..." },
|
||||||
|
"preparing_id": "web_search:0",
|
||||||
|
"monitor_snapshot": null,
|
||||||
|
"conversation_id": "conv_..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- `preparing_id` 用于把“模型输出的 tool_call”与“真实执行的工具卡片”关联起来
|
||||||
|
- `arguments` 为工具参数(通常是 JSON 对象)
|
||||||
|
|
||||||
|
#### `update_action`
|
||||||
|
|
||||||
|
该事件用于更新工具卡片(或其他 action)的状态。字段随工具不同可能略有差异,但通常包含:
|
||||||
|
|
||||||
|
- `id`:与 `tool_start.data.id` 对应
|
||||||
|
- `status`:`running/completed/failed/...`
|
||||||
|
- `message`:可读描述
|
||||||
|
- 可能还带 `result/summary/monitor_snapshot/...`
|
||||||
|
|
||||||
|
客户端建议:
|
||||||
|
|
||||||
|
- 把 `update_action` 当作通用的“状态更新”事件处理;
|
||||||
|
- 对未知字段保持兼容(直接展示或忽略)。
|
||||||
|
|
||||||
|
### 3.5 系统消息与错误
|
||||||
|
|
||||||
|
#### `system_message`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "system_message", "data": { "content": "..." } }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `error`
|
||||||
|
|
||||||
|
任务内错误(不同于 HTTP 层错误)。出现后通常任务会进入 `failed`。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "error", "data": { "message": "..." } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6 任务结束
|
||||||
|
|
||||||
|
#### `task_complete`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "task_complete",
|
||||||
|
"data": {
|
||||||
|
"total_iterations": 2,
|
||||||
|
"total_tool_calls": 1,
|
||||||
|
"auto_fix_attempts": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 结束条件以轮询返回的 `status != running` 为准;`task_complete` 是一个“完成事件”,但客户端仍应读取 `status`。
|
||||||
|
|
||||||
|
## 4. 事件缓冲与性能建议
|
||||||
|
|
||||||
|
- 服务端每个任务保存最近 **1000** 条事件(队列会丢弃更早的数据)
|
||||||
|
- 建议轮询间隔 `0.5s ~ 2s`
|
||||||
|
- 长任务建议客户端把 `text_chunk` 与关键工具事件持久化到本地,避免错过
|
||||||
|
|
||||||
264
api_doc/examples.md
Normal file
264
api_doc/examples.md
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
# 示例(curl / Python / JS / Flutter)
|
||||||
|
|
||||||
|
本文提供“最小可用”的端到端示例:创建对话 → 发送消息 → 轮询输出 →(可选)停止任务 → 文件上传/下载。
|
||||||
|
|
||||||
|
请先阅读 `auth.md` 并准备好 token。
|
||||||
|
|
||||||
|
## 0. 统一变量
|
||||||
|
|
||||||
|
- `BASE_URL`:例如 `http://localhost:8091`
|
||||||
|
- `TOKEN`:你的 Bearer Token(明文)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) curl:完整对话流程
|
||||||
|
|
||||||
|
### 1.1 创建对话
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BASE_URL="http://localhost:8091"
|
||||||
|
TOKEN="<TOKEN>"
|
||||||
|
|
||||||
|
curl -sS -X POST \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
"$BASE_URL/api/v1/conversations"
|
||||||
|
```
|
||||||
|
|
||||||
|
假设返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "success": true, "conversation_id": "conv_20260123_234245_036" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 发送消息(创建任务)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"conversation_id": "conv_20260123_234245_036",
|
||||||
|
"message": "请用中文简要介绍《明日方舟:终末地》",
|
||||||
|
"run_mode": "fast",
|
||||||
|
"max_iterations": 100
|
||||||
|
}' \
|
||||||
|
"$BASE_URL/api/v1/messages"
|
||||||
|
```
|
||||||
|
|
||||||
|
假设返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "success": true, "task_id": "60322db3-...", "status": "running", "conversation_id": "conv_...", "created_at": 1769182965.30 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 轮询(建议脚本处理 next_offset)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TASK_ID="60322db3-..."
|
||||||
|
OFFSET=0
|
||||||
|
|
||||||
|
curl -sS -H "Authorization: Bearer $TOKEN" \
|
||||||
|
"$BASE_URL/api/v1/tasks/$TASK_ID?from=$OFFSET"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Python:轮询并实时拼接文本
|
||||||
|
|
||||||
|
依赖:`pip install requests`
|
||||||
|
|
||||||
|
```python
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
|
||||||
|
BASE_URL = "http://localhost:8091"
|
||||||
|
TOKEN = "<TOKEN>"
|
||||||
|
H = {"Authorization": f"Bearer {TOKEN}"}
|
||||||
|
|
||||||
|
def post_json(path, payload):
|
||||||
|
r = requests.post(BASE_URL + path, json=payload, headers={**H, "Content-Type":"application/json"}, timeout=30)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def get_json(path, params=None):
|
||||||
|
r = requests.get(BASE_URL + path, params=params or {}, headers=H, timeout=30)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
# 1) create conversation
|
||||||
|
conv = requests.post(BASE_URL + "/api/v1/conversations", headers=H, timeout=30).json()
|
||||||
|
assert conv["success"]
|
||||||
|
conversation_id = conv["conversation_id"]
|
||||||
|
|
||||||
|
# 2) send message
|
||||||
|
task = post_json("/api/v1/messages", {
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"message": "请用中文简要介绍《明日方舟:终末地》",
|
||||||
|
"run_mode": "fast",
|
||||||
|
"max_iterations": 100
|
||||||
|
})
|
||||||
|
task_id = task["task_id"]
|
||||||
|
|
||||||
|
# 3) poll events
|
||||||
|
offset = 0
|
||||||
|
text_buf = []
|
||||||
|
think_buf = []
|
||||||
|
|
||||||
|
while True:
|
||||||
|
data = get_json(f"/api/v1/tasks/{task_id}", params={"from": offset})
|
||||||
|
if not data["success"]:
|
||||||
|
raise RuntimeError(data.get("error"))
|
||||||
|
info = data["data"]
|
||||||
|
events = info["events"]
|
||||||
|
offset = info["next_offset"]
|
||||||
|
|
||||||
|
for ev in events:
|
||||||
|
t = ev["type"]
|
||||||
|
d = ev["data"] or {}
|
||||||
|
if t == "text_chunk":
|
||||||
|
text_buf.append(d.get("content",""))
|
||||||
|
print(d.get("content",""), end="", flush=True)
|
||||||
|
elif t == "text_end":
|
||||||
|
print("\\n--- text_end ---\\n")
|
||||||
|
elif t == "thinking_chunk":
|
||||||
|
think_buf.append(d.get("content",""))
|
||||||
|
elif t == "tool_start":
|
||||||
|
print(f\"\\n[tool_start] {d.get('name')}\\n\")
|
||||||
|
elif t == "update_action":
|
||||||
|
# 工具状态更新
|
||||||
|
st = d.get("status") or ""
|
||||||
|
msg = d.get("message") or ""
|
||||||
|
if st or msg:
|
||||||
|
print(f\"\\n[update_action] {st} {msg}\\n\")
|
||||||
|
elif t == "error":
|
||||||
|
print(f\"\\n[error] {d.get('message')}\\n\")
|
||||||
|
|
||||||
|
if info["status"] != "running":
|
||||||
|
break
|
||||||
|
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
final_text = "".join(text_buf)
|
||||||
|
final_thinking = "".join(think_buf)
|
||||||
|
print("final status:", info["status"])
|
||||||
|
print("final text length:", len(final_text))
|
||||||
|
print("final thinking length:", len(final_thinking))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) JavaScript(浏览器/Node)要点
|
||||||
|
|
||||||
|
浏览器端直接跨域请求时,请确保服务端允许 CORS(当前服务端已启用 CORS)。请求示例:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const BASE_URL = "http://localhost:8091";
|
||||||
|
const TOKEN = "<TOKEN>";
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const resp = await fetch(BASE_URL + path, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${TOKEN}`,
|
||||||
|
...(options.headers || {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok || !data.success) throw new Error(data.error || resp.statusText);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conv = await api("/api/v1/conversations", { method: "POST" });
|
||||||
|
const msg = await api("/api/v1/messages", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
conversation_id: conv.conversation_id,
|
||||||
|
message: "请用中文简要介绍《明日方舟:终末地》",
|
||||||
|
run_mode: "fast",
|
||||||
|
max_iterations: 100
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
let text = "";
|
||||||
|
while (true) {
|
||||||
|
const poll = await api(`/api/v1/tasks/${msg.task_id}?from=${offset}`);
|
||||||
|
const info = poll.data;
|
||||||
|
for (const ev of info.events) {
|
||||||
|
if (ev.type === "text_chunk") text += ev.data.content || "";
|
||||||
|
if (ev.type === "tool_start") console.log("tool:", ev.data.name);
|
||||||
|
}
|
||||||
|
offset = info.next_offset;
|
||||||
|
if (info.status !== "running") break;
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
console.log("done:", text);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Flutter(Dart)轮询示例(伪代码)
|
||||||
|
|
||||||
|
依赖:`http` 包或 `dio` 包均可,这里用 `http` 表达逻辑。
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
const baseUrl = "http://localhost:8091";
|
||||||
|
const token = "<TOKEN>";
|
||||||
|
|
||||||
|
Map<String,String> headersJson() => {
|
||||||
|
"Authorization": "Bearer $token",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<String> createConversation() async {
|
||||||
|
final resp = await http.post(Uri.parse("$baseUrl/api/v1/conversations"),
|
||||||
|
headers: {"Authorization":"Bearer $token"});
|
||||||
|
final data = jsonDecode(resp.body);
|
||||||
|
if (resp.statusCode != 200 || data["success"] != true) throw Exception(data["error"]);
|
||||||
|
return data["conversation_id"];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> sendMessage(String convId, String message) async {
|
||||||
|
final resp = await http.post(Uri.parse("$baseUrl/api/v1/messages"),
|
||||||
|
headers: headersJson(),
|
||||||
|
body: jsonEncode({
|
||||||
|
"conversation_id": convId,
|
||||||
|
"message": message,
|
||||||
|
"run_mode": "fast",
|
||||||
|
"max_iterations": 100,
|
||||||
|
}));
|
||||||
|
final data = jsonDecode(resp.body);
|
||||||
|
if (resp.statusCode != 202 || data["success"] != true) throw Exception(data["error"]);
|
||||||
|
return data["task_id"];
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<String> pollText(String taskId) async* {
|
||||||
|
int offset = 0;
|
||||||
|
while (true) {
|
||||||
|
final resp = await http.get(Uri.parse("$baseUrl/api/v1/tasks/$taskId?from=$offset"),
|
||||||
|
headers: {"Authorization":"Bearer $token"});
|
||||||
|
final data = jsonDecode(resp.body);
|
||||||
|
if (resp.statusCode != 200 || data["success"] != true) throw Exception(data["error"]);
|
||||||
|
final info = data["data"];
|
||||||
|
final events = (info["events"] as List);
|
||||||
|
for (final ev in events) {
|
||||||
|
if (ev["type"] == "text_chunk") {
|
||||||
|
yield (ev["data"]["content"] ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset = info["next_offset"];
|
||||||
|
if (info["status"] != "running") break;
|
||||||
|
await Future.delayed(Duration(milliseconds: 800));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
提示:
|
||||||
|
|
||||||
|
- Flutter UI 展示建议:把 `pollText()` 的输出 append 到一个 `StringBuffer`,并用 `setState()`/状态管理更新。
|
||||||
|
- 同时可以订阅 tool_* 事件在 UI 中显示“正在搜索/正在执行工具”等状态。
|
||||||
166
api_doc/files.md
Normal file
166
api_doc/files.md
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
# 文件接口(上传 / 列目录 / 下载)
|
||||||
|
|
||||||
|
API v1 只提供最小化的文件能力,用于把外部输入文件放入工作区,并下载产物。
|
||||||
|
|
||||||
|
核心限制:
|
||||||
|
|
||||||
|
- **上传只能落在 `project/user_upload/` 目录及其子目录**;
|
||||||
|
- 列目录/下载也只允许访问 `user_upload` 内部路径(路径穿越会被拒绝)。
|
||||||
|
|
||||||
|
> 目录结构见 `auth.md`。
|
||||||
|
|
||||||
|
## 1) 上传文件
|
||||||
|
|
||||||
|
### POST `/api/v1/files/upload`
|
||||||
|
|
||||||
|
#### 请求
|
||||||
|
|
||||||
|
- Headers:`Authorization: Bearer <TOKEN>`
|
||||||
|
- Content-Type:`multipart/form-data`
|
||||||
|
- Form 字段:
|
||||||
|
- `file`:必填,上传的文件对象
|
||||||
|
- `filename`:可选,覆盖原文件名(会做安全清洗)
|
||||||
|
- `dir`:可选,`user_upload` 下的子目录(例如 `docs/`);不传则落在 `user_upload/` 根目录
|
||||||
|
|
||||||
|
> 如果你希望“完全不允许指定目录”,客户端请不要传 `dir`,统一上传到根目录即可。
|
||||||
|
|
||||||
|
#### 响应
|
||||||
|
|
||||||
|
成功(200):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"path": "docs/spec.pdf",
|
||||||
|
"filename": "spec.pdf",
|
||||||
|
"size": 12345,
|
||||||
|
"sha256": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
- `path`:相对 `user_upload` 的路径(用于 list/download)
|
||||||
|
- `sha256`:服务端计算的文件哈希(便于客户端校验/去重)
|
||||||
|
|
||||||
|
可能错误:
|
||||||
|
|
||||||
|
- `400`:缺少 file / 文件名非法 / 目录非法
|
||||||
|
- `401`:缺少或无效 token
|
||||||
|
- `503`:系统未初始化
|
||||||
|
- `500`:保存失败(例如写入异常、扫描/隔离异常等)
|
||||||
|
|
||||||
|
#### curl 示例
|
||||||
|
|
||||||
|
上传到根目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST \
|
||||||
|
-H "Authorization: Bearer <TOKEN>" \
|
||||||
|
-F "file=@./hello.txt" \
|
||||||
|
http://localhost:8091/api/v1/files/upload
|
||||||
|
```
|
||||||
|
|
||||||
|
上传到子目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS -X POST \
|
||||||
|
-H "Authorization: Bearer <TOKEN>" \
|
||||||
|
-F "dir=inputs" \
|
||||||
|
-F "file=@./hello.txt" \
|
||||||
|
http://localhost:8091/api/v1/files/upload
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2) 列出目录内容
|
||||||
|
|
||||||
|
### GET `/api/v1/files?path=<dir>`
|
||||||
|
|
||||||
|
#### 请求
|
||||||
|
|
||||||
|
- Headers:`Authorization: Bearer <TOKEN>`
|
||||||
|
- Query:
|
||||||
|
- `path`:可选,相对 `user_upload` 的目录路径;不传表示根目录
|
||||||
|
|
||||||
|
#### 响应
|
||||||
|
|
||||||
|
成功(200):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"base": "inputs",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "hello.txt",
|
||||||
|
"is_dir": false,
|
||||||
|
"size": 16,
|
||||||
|
"modified_at": 1769182550.594278,
|
||||||
|
"path": "inputs/hello.txt"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
- `base`:你请求的目录(相对 `user_upload`),根目录时为 `.`
|
||||||
|
- `items[].path`:相对 `user_upload` 的路径(用于下载或继续列目录)
|
||||||
|
- `modified_at`:文件 mtime(UNIX 秒)
|
||||||
|
|
||||||
|
可能错误:
|
||||||
|
|
||||||
|
- `400`:path 不是目录或 path 非法
|
||||||
|
- `401`:缺少或无效 token
|
||||||
|
- `404`:目录不存在
|
||||||
|
- `503`:系统未初始化
|
||||||
|
|
||||||
|
#### curl 示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS \
|
||||||
|
-H "Authorization: Bearer <TOKEN>" \
|
||||||
|
"http://localhost:8091/api/v1/files?path=inputs"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3) 下载文件/目录
|
||||||
|
|
||||||
|
### GET `/api/v1/files/download?path=<file_or_dir>`
|
||||||
|
|
||||||
|
#### 请求
|
||||||
|
|
||||||
|
- Headers:`Authorization: Bearer <TOKEN>`
|
||||||
|
- Query:
|
||||||
|
- `path`:必填,相对 `user_upload` 的文件或目录路径
|
||||||
|
|
||||||
|
#### 响应
|
||||||
|
|
||||||
|
1) 若 `path` 指向文件:返回文件二进制流(`Content-Disposition: attachment`)。
|
||||||
|
2) 若 `path` 指向目录:服务端会把目录打包成 zip 后返回(`application/zip`)。
|
||||||
|
|
||||||
|
可能错误:
|
||||||
|
|
||||||
|
- `400`:缺少 path 或 path 非法
|
||||||
|
- `401`:缺少或无效 token
|
||||||
|
- `404`:文件/目录不存在
|
||||||
|
- `503`:系统未初始化
|
||||||
|
|
||||||
|
#### curl 示例
|
||||||
|
|
||||||
|
下载文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -L -o out.txt \
|
||||||
|
-H "Authorization: Bearer <TOKEN>" \
|
||||||
|
"http://localhost:8091/api/v1/files/download?path=hello.txt"
|
||||||
|
```
|
||||||
|
|
||||||
|
下载目录(zip):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -L -o inputs.zip \
|
||||||
|
-H "Authorization: Bearer <TOKEN>" \
|
||||||
|
"http://localhost:8091/api/v1/files/download?path=inputs"
|
||||||
|
```
|
||||||
|
|
||||||
|
> zip 内的路径目前是相对 `project/` 的(因此通常会包含 `user_upload/` 前缀)。
|
||||||
|
|
||||||
181
api_doc/messages_tasks.md
Normal file
181
api_doc/messages_tasks.md
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
# 发送消息与任务系统(后台运行 + 轮询)
|
||||||
|
|
||||||
|
本节覆盖:
|
||||||
|
|
||||||
|
- 创建对话(可选):`POST /api/v1/conversations`
|
||||||
|
- 发送消息(创建后台任务):`POST /api/v1/messages`
|
||||||
|
- 轮询事件:`GET /api/v1/tasks/<task_id>`
|
||||||
|
- 停止任务:`POST /api/v1/tasks/<task_id>/cancel`
|
||||||
|
|
||||||
|
## 1) 创建对话
|
||||||
|
|
||||||
|
### POST `/api/v1/conversations`
|
||||||
|
|
||||||
|
创建一个新的对话,并返回 `conversation_id`。
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
- Headers:`Authorization: Bearer <TOKEN>`
|
||||||
|
- Body:无
|
||||||
|
|
||||||
|
响应(200):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"conversation_id": "conv_20260123_234245_036"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
可能错误:
|
||||||
|
|
||||||
|
- `401`:缺少或无效 token
|
||||||
|
- `503`:系统未初始化(资源繁忙/容器不可用等)
|
||||||
|
- `500`:创建失败
|
||||||
|
|
||||||
|
## 2) 发送消息(创建后台任务)
|
||||||
|
|
||||||
|
### POST `/api/v1/messages`
|
||||||
|
|
||||||
|
创建一个后台任务执行智能体流程,并立即返回 `task_id`。
|
||||||
|
|
||||||
|
#### 请求
|
||||||
|
|
||||||
|
Headers:
|
||||||
|
|
||||||
|
- `Authorization: Bearer <TOKEN>`
|
||||||
|
- `Content-Type: application/json`
|
||||||
|
|
||||||
|
Body(JSON):
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| `message` | string | 是(或 images) | 用户消息文本;`message` 与 `images` 至少其一不为空 |
|
||||||
|
| `conversation_id` | string | 否 | 不传则自动新建对话;建议显式传入以便客户端管理会话 |
|
||||||
|
| `model_key` | string/null | 否 | 指定模型 key(可选);具体可用值取决于服务端配置 |
|
||||||
|
| `run_mode` | string/null | 否 | 运行模式:`"fast"` \| `"thinking"` \| `"deep"`;若传入则优先使用 |
|
||||||
|
| `thinking_mode` | boolean/null | 否 | 兼容字段:true=thinking,false=fast;当 `run_mode` 为空时才使用 |
|
||||||
|
| `max_iterations` | integer/null | 否 | 最大迭代次数,默认服务端配置为 **100**(`config.MAX_ITERATIONS_PER_TASK`);传入可覆盖 |
|
||||||
|
| `images` | string[] | 否 | 图片路径列表(服务端可访问的路径);一般配合特定模型使用 |
|
||||||
|
|
||||||
|
优先级:`run_mode` > `thinking_mode` > 终端当前配置。`run_mode="deep"` 将启用深度思考模式(若模型与配置允许)。
|
||||||
|
|
||||||
|
#### 响应
|
||||||
|
|
||||||
|
成功(202):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"task_id": "60322db3-f884-4a1e-a9b3-6eeb07fbab47",
|
||||||
|
"conversation_id": "conv_20260123_234245_036",
|
||||||
|
"status": "running",
|
||||||
|
"created_at": 1769182965.3038778
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
- `task_id`:任务唯一 ID(用于轮询/停止)
|
||||||
|
- `conversation_id`:本次任务归属对话
|
||||||
|
- `status`:初始为 `running`
|
||||||
|
- `created_at`:UNIX 时间戳(秒,float)
|
||||||
|
|
||||||
|
可能错误:
|
||||||
|
|
||||||
|
- `400`:`message/images` 都为空;或 `conversation_id` 无法加载
|
||||||
|
- `401`:缺少或无效 token
|
||||||
|
- `409`:该 API 用户已有运行中的任务(禁止并发)
|
||||||
|
- `503`:系统未初始化/资源繁忙
|
||||||
|
|
||||||
|
并发冲突示例(409):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "success": false, "error": "已有运行中的任务,请稍后再试。" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3) 轮询任务事件(增量)
|
||||||
|
|
||||||
|
### GET `/api/v1/tasks/<task_id>?from=<offset>`
|
||||||
|
|
||||||
|
用于以 **HTTP 轮询**方式获取任务执行过程中的流式事件(与 WebSocket 同粒度)。
|
||||||
|
|
||||||
|
#### 请求
|
||||||
|
|
||||||
|
- Headers:`Authorization: Bearer <TOKEN>`
|
||||||
|
- Query:
|
||||||
|
- `from`:起始 offset(默认 0)
|
||||||
|
|
||||||
|
#### 响应(200)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"task_id": "60322db3-f884-4a1e-a9b3-6eeb07fbab47",
|
||||||
|
"status": "running",
|
||||||
|
"created_at": 1769182965.30,
|
||||||
|
"updated_at": 1769182968.15,
|
||||||
|
"error": null,
|
||||||
|
"events": [
|
||||||
|
{ "idx": 0, "type": "ai_message_start", "data": {}, "ts": 1769182965.30 },
|
||||||
|
{ "idx": 1, "type": "text_start", "data": {}, "ts": 1769182968.09 },
|
||||||
|
{ "idx": 2, "type": "text_chunk", "data": { "content": "我来", "index": 1, "elapsed": 0.0 }, "ts": 1769182968.15 }
|
||||||
|
],
|
||||||
|
"next_offset": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
|
||||||
|
- `status`:任务状态,常见值:
|
||||||
|
- `running`:执行中
|
||||||
|
- `cancel_requested`:已请求停止,等待实际停下
|
||||||
|
- `canceled`:已停止
|
||||||
|
- `succeeded`:成功结束
|
||||||
|
- `failed`:失败结束(`error` 会给出原因)
|
||||||
|
- `events`:从 `idx>=from` 的事件列表(按 idx 升序)
|
||||||
|
- `next_offset`:建议下一次轮询的 `from` 值
|
||||||
|
|
||||||
|
可能错误:
|
||||||
|
|
||||||
|
- `401`:缺少或无效 token
|
||||||
|
- `404`:任务不存在(或不属于该 API 用户)
|
||||||
|
|
||||||
|
#### 推荐轮询策略
|
||||||
|
|
||||||
|
- 轮询间隔:`0.5s ~ 2s`(任务越密集越推荐更快)
|
||||||
|
- 客户端必须:
|
||||||
|
1) 保存 `next_offset`
|
||||||
|
2) 追加处理 events
|
||||||
|
3) 当 `status != running` 时停止轮询
|
||||||
|
|
||||||
|
> 重要:服务端事件缓冲是 `deque(maxlen=1000)`,轮询过慢会丢失早期事件;客户端应自行落盘你需要的内容。
|
||||||
|
|
||||||
|
## 4) 停止任务
|
||||||
|
|
||||||
|
### POST `/api/v1/tasks/<task_id>/cancel`
|
||||||
|
|
||||||
|
请求停止某个任务。
|
||||||
|
|
||||||
|
请求:
|
||||||
|
|
||||||
|
- Headers:`Authorization: Bearer <TOKEN>`
|
||||||
|
- Body:无
|
||||||
|
|
||||||
|
成功(200):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "success": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 该接口是“请求停止”,任务可能不会立刻停下;
|
||||||
|
- 停止后的最终状态在轮询里体现:`status` 变为 `canceled` 或 `failed/succeeded`(极少数情况下已接近结束)。
|
||||||
|
|
||||||
|
可能错误:
|
||||||
|
|
||||||
|
- `401`:缺少或无效 token
|
||||||
|
- `404`:任务不存在
|
||||||
351
api_doc/openapi.yaml
Normal file
351
api_doc/openapi.yaml
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Agents API v1
|
||||||
|
version: "1.0"
|
||||||
|
description: |
|
||||||
|
后台任务 + HTTP 轮询进度 + 文件上传/下载。
|
||||||
|
|
||||||
|
- 鉴权:Authorization: Bearer <token>(token 为自定义字符串,服务端存 SHA256)
|
||||||
|
- 事件流:与网页端 WebSocket 同粒度(text_chunk、tool_start 等)
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:8091
|
||||||
|
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: token
|
||||||
|
schemas:
|
||||||
|
ErrorResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success: { type: boolean, example: false }
|
||||||
|
error: { type: string, example: "无效的 Token" }
|
||||||
|
required: [success, error]
|
||||||
|
|
||||||
|
CreateConversationResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success: { type: boolean, example: true }
|
||||||
|
conversation_id: { type: string, example: "conv_20260123_234245_036" }
|
||||||
|
required: [success, conversation_id]
|
||||||
|
|
||||||
|
SendMessageRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: 用户消息文本(message 与 images 至少其一不为空)
|
||||||
|
images:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
description: 图片路径列表(相对 project 根目录)
|
||||||
|
conversation_id:
|
||||||
|
type: string
|
||||||
|
description: 对话 ID;不传则自动创建
|
||||||
|
model_key:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: 模型 key(可选,取决于服务端配置)
|
||||||
|
run_mode:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: 运行模式:fast | thinking | deep;若传入则优先使用
|
||||||
|
thinking_mode:
|
||||||
|
type: boolean
|
||||||
|
nullable: true
|
||||||
|
description: 是否开启思考模式(true/false)
|
||||||
|
max_iterations:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
description: 最大迭代次数(最多允许多少轮模型调用/工具循环)
|
||||||
|
additionalProperties: false
|
||||||
|
|
||||||
|
SendMessageResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success: { type: boolean, example: true }
|
||||||
|
task_id: { type: string, example: "60322db3-f884-4a1e-a9b3-6eeb07fbab47" }
|
||||||
|
conversation_id: { type: string, example: "conv_20260123_234245_036" }
|
||||||
|
status: { type: string, example: "running" }
|
||||||
|
created_at: { type: number, format: double, example: 1769182965.3038778 }
|
||||||
|
required: [success, task_id, conversation_id, status, created_at]
|
||||||
|
|
||||||
|
TaskEvent:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
idx: { type: integer, example: 12 }
|
||||||
|
type: { type: string, example: "text_chunk" }
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
ts: { type: number, format: double, example: 1769182968.154797 }
|
||||||
|
required: [idx, type, data, ts]
|
||||||
|
|
||||||
|
TaskPollResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success: { type: boolean, example: true }
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
task_id: { type: string }
|
||||||
|
status: { type: string, example: "running" }
|
||||||
|
created_at: { type: number, format: double }
|
||||||
|
updated_at: { type: number, format: double }
|
||||||
|
error: { type: string, nullable: true }
|
||||||
|
events:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/TaskEvent" }
|
||||||
|
next_offset: { type: integer, example: 3 }
|
||||||
|
required: [task_id, status, created_at, updated_at, error, events, next_offset]
|
||||||
|
required: [success, data]
|
||||||
|
|
||||||
|
CancelResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success: { type: boolean, example: true }
|
||||||
|
required: [success]
|
||||||
|
|
||||||
|
UploadResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success: { type: boolean, example: true }
|
||||||
|
path: { type: string, example: "inputs/hello.txt" }
|
||||||
|
filename: { type: string, example: "hello.txt" }
|
||||||
|
size: { type: integer, example: 16 }
|
||||||
|
sha256: { type: string, example: "0f5bd6..." }
|
||||||
|
required: [success, path, filename]
|
||||||
|
|
||||||
|
ListFilesItem:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name: { type: string, example: "hello.txt" }
|
||||||
|
is_dir: { type: boolean, example: false }
|
||||||
|
size: { type: integer, example: 16 }
|
||||||
|
modified_at: { type: number, format: double, example: 1769182550.594278 }
|
||||||
|
path: { type: string, example: "inputs/hello.txt" }
|
||||||
|
required: [name, is_dir, size, modified_at, path]
|
||||||
|
|
||||||
|
ListFilesResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success: { type: boolean, example: true }
|
||||||
|
base: { type: string, example: "." }
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items: { $ref: "#/components/schemas/ListFilesItem" }
|
||||||
|
required: [success, base, items]
|
||||||
|
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/api/v1/conversations:
|
||||||
|
post:
|
||||||
|
summary: 创建对话
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/CreateConversationResponse" }
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ErrorResponse" }
|
||||||
|
|
||||||
|
/api/v1/messages:
|
||||||
|
post:
|
||||||
|
summary: 发送消息并创建后台任务
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/SendMessageRequest" }
|
||||||
|
responses:
|
||||||
|
"202":
|
||||||
|
description: Accepted
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/SendMessageResponse" }
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ErrorResponse" }
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ErrorResponse" }
|
||||||
|
"409":
|
||||||
|
description: Conflict (已有运行任务)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ErrorResponse" }
|
||||||
|
|
||||||
|
/api/v1/tasks/{task_id}:
|
||||||
|
get:
|
||||||
|
summary: 轮询任务事件
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: task_id
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
- in: query
|
||||||
|
name: from
|
||||||
|
required: false
|
||||||
|
schema: { type: integer, default: 0 }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/TaskPollResponse" }
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ErrorResponse" }
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ErrorResponse" }
|
||||||
|
|
||||||
|
/api/v1/tasks/{task_id}/cancel:
|
||||||
|
post:
|
||||||
|
summary: 请求停止任务
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: task_id
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/CancelResponse" }
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ErrorResponse" }
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ErrorResponse" }
|
||||||
|
|
||||||
|
/api/v1/files/upload:
|
||||||
|
post:
|
||||||
|
summary: 上传文件到 user_upload
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
multipart/form-data:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
file:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
filename:
|
||||||
|
type: string
|
||||||
|
dir:
|
||||||
|
type: string
|
||||||
|
description: user_upload 下子目录
|
||||||
|
required: [file]
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/UploadResponse" }
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ErrorResponse" }
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ErrorResponse" }
|
||||||
|
|
||||||
|
/api/v1/files:
|
||||||
|
get:
|
||||||
|
summary: 列出 user_upload 目录内容
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: path
|
||||||
|
required: false
|
||||||
|
schema: { type: string, default: "" }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ListFilesResponse" }
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ErrorResponse" }
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ErrorResponse" }
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ErrorResponse" }
|
||||||
|
|
||||||
|
/api/v1/files/download:
|
||||||
|
get:
|
||||||
|
summary: 下载文件或目录(目录会打包成 zip)
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
parameters:
|
||||||
|
- in: query
|
||||||
|
name: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: File content
|
||||||
|
content:
|
||||||
|
application/octet-stream:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
application/zip:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ErrorResponse" }
|
||||||
|
"401":
|
||||||
|
description: Unauthorized
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ErrorResponse" }
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: "#/components/schemas/ErrorResponse" }
|
||||||
@ -11,6 +11,11 @@ USERS_DB_FILE = f"{DATA_DIR}/users.json"
|
|||||||
INVITE_CODES_FILE = f"{DATA_DIR}/invite_codes.json"
|
INVITE_CODES_FILE = f"{DATA_DIR}/invite_codes.json"
|
||||||
ADMIN_POLICY_FILE = f"{DATA_DIR}/admin_policy.json"
|
ADMIN_POLICY_FILE = f"{DATA_DIR}/admin_policy.json"
|
||||||
|
|
||||||
|
# API 专用用户与工作区(与网页用户隔离)
|
||||||
|
API_USER_SPACE_DIR = "./api/users"
|
||||||
|
API_USERS_DB_FILE = f"{DATA_DIR}/api_users.json"
|
||||||
|
API_TOKENS_FILE = f"{DATA_DIR}/api_tokens.json"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DEFAULT_PROJECT_PATH",
|
"DEFAULT_PROJECT_PATH",
|
||||||
"PROMPTS_DIR",
|
"PROMPTS_DIR",
|
||||||
@ -20,4 +25,7 @@ __all__ = [
|
|||||||
"USERS_DB_FILE",
|
"USERS_DB_FILE",
|
||||||
"INVITE_CODES_FILE",
|
"INVITE_CODES_FILE",
|
||||||
"ADMIN_POLICY_FILE",
|
"ADMIN_POLICY_FILE",
|
||||||
|
"API_USER_SPACE_DIR",
|
||||||
|
"API_USERS_DB_FILE",
|
||||||
|
"API_TOKENS_FILE",
|
||||||
]
|
]
|
||||||
|
|||||||
160
modules/api_user_manager.py
Normal file
160
modules/api_user_manager.py
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
"""API 专用用户与工作区管理(JSON + Bearer Token 哈希)。
|
||||||
|
|
||||||
|
仅支持手动维护:在 `API_USERS_DB_FILE` 中添加用户与 SHA256(token)。
|
||||||
|
结构示例:
|
||||||
|
{
|
||||||
|
"users": {
|
||||||
|
"api_jojo": {
|
||||||
|
"token_sha256": "abc123...",
|
||||||
|
"created_at": "2026-01-23",
|
||||||
|
"note": "for mobile app"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional, Tuple
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
API_USER_SPACE_DIR,
|
||||||
|
API_USERS_DB_FILE,
|
||||||
|
API_TOKENS_FILE,
|
||||||
|
)
|
||||||
|
from modules.personalization_manager import ensure_personalization_config
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ApiUserRecord:
|
||||||
|
username: str
|
||||||
|
token_sha256: str
|
||||||
|
created_at: str
|
||||||
|
note: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ApiUserWorkspace:
|
||||||
|
username: str
|
||||||
|
root: Path
|
||||||
|
project_path: Path
|
||||||
|
data_dir: Path
|
||||||
|
logs_dir: Path
|
||||||
|
uploads_dir: Path
|
||||||
|
quarantine_dir: Path
|
||||||
|
|
||||||
|
|
||||||
|
class ApiUserManager:
|
||||||
|
"""最小化的 API 用户管理:只校验 token 哈希并准备隔离工作区。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
users_file: str = API_USERS_DB_FILE,
|
||||||
|
tokens_file: str = API_TOKENS_FILE,
|
||||||
|
workspace_root: str = API_USER_SPACE_DIR,
|
||||||
|
):
|
||||||
|
self.users_file = Path(users_file)
|
||||||
|
self.tokens_file = Path(tokens_file)
|
||||||
|
self.workspace_root = Path(workspace_root).expanduser().resolve()
|
||||||
|
self.workspace_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self._users: Dict[str, ApiUserRecord] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
self._load_users()
|
||||||
|
|
||||||
|
# ----------------------- public APIs -----------------------
|
||||||
|
def get_user_by_token(self, bearer_token: str) -> Optional[ApiUserRecord]:
|
||||||
|
if not bearer_token:
|
||||||
|
return None
|
||||||
|
token_sha = self._sha256(bearer_token)
|
||||||
|
with self._lock:
|
||||||
|
for user in self._users.values():
|
||||||
|
if user.token_sha256 == token_sha:
|
||||||
|
return user
|
||||||
|
return None
|
||||||
|
|
||||||
|
def ensure_workspace(self, username: str) -> ApiUserWorkspace:
|
||||||
|
"""为 API 用户创建隔离工作区。"""
|
||||||
|
root = (self.workspace_root / username).resolve()
|
||||||
|
project_path = root / "project"
|
||||||
|
data_dir = root / "data"
|
||||||
|
logs_dir = root / "logs"
|
||||||
|
uploads_dir = project_path / "user_upload"
|
||||||
|
|
||||||
|
for path in (project_path, data_dir, logs_dir, uploads_dir):
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 数据子目录
|
||||||
|
(data_dir / "conversations").mkdir(parents=True, exist_ok=True)
|
||||||
|
(data_dir / "backups").mkdir(parents=True, exist_ok=True)
|
||||||
|
ensure_personalization_config(data_dir)
|
||||||
|
|
||||||
|
# 上传隔离区(沿用 uploads 配置)
|
||||||
|
from config import UPLOAD_QUARANTINE_SUBDIR
|
||||||
|
quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser()
|
||||||
|
if not quarantine_root.is_absolute():
|
||||||
|
quarantine_root = (self.workspace_root.parent / UPLOAD_QUARANTINE_SUBDIR).resolve()
|
||||||
|
quarantine_dir = (quarantine_root / username).resolve()
|
||||||
|
quarantine_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
return ApiUserWorkspace(
|
||||||
|
username=username,
|
||||||
|
root=root,
|
||||||
|
project_path=project_path,
|
||||||
|
data_dir=data_dir,
|
||||||
|
logs_dir=logs_dir,
|
||||||
|
uploads_dir=uploads_dir,
|
||||||
|
quarantine_dir=quarantine_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------- internal helpers -----------------------
|
||||||
|
def _sha256(self, token: str) -> str:
|
||||||
|
return hashlib.sha256((token or "").encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def _load_users(self):
|
||||||
|
"""加载用户列表,读取 token_sha256;不支持明文存储。"""
|
||||||
|
if not self.users_file.exists():
|
||||||
|
self._save_users()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
raw = json.loads(self.users_file.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise RuntimeError(f"无法解析 API 用户文件: {self.users_file} ({exc})")
|
||||||
|
|
||||||
|
users = raw.get("users", {}) if isinstance(raw, dict) else {}
|
||||||
|
for username, payload in users.items():
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
continue
|
||||||
|
token_sha = (payload.get("token_sha256") or "").strip()
|
||||||
|
if not token_sha:
|
||||||
|
continue
|
||||||
|
record = ApiUserRecord(
|
||||||
|
username=username.strip().lower(),
|
||||||
|
token_sha256=token_sha,
|
||||||
|
created_at=payload.get("created_at") or "",
|
||||||
|
note=payload.get("note") or "",
|
||||||
|
)
|
||||||
|
self._users[record.username] = record
|
||||||
|
|
||||||
|
def _save_users(self):
|
||||||
|
payload = {
|
||||||
|
"users": {
|
||||||
|
username: {
|
||||||
|
"token_sha256": record.token_sha256,
|
||||||
|
"created_at": record.created_at or datetime.utcnow().isoformat(),
|
||||||
|
"note": record.note,
|
||||||
|
}
|
||||||
|
for username, record in self._users.items()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.users_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.users_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["ApiUserManager", "ApiUserRecord", "ApiUserWorkspace"]
|
||||||
44
server/api_auth.py
Normal file
44
server/api_auth.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
"""Bearer Token 认证:用于 API v1。
|
||||||
|
|
||||||
|
策略:
|
||||||
|
- 从 Authorization: Bearer <token> 读取明文 token。
|
||||||
|
- 计算 SHA256,与 `api_user_manager` 里的 token_sha256 匹配。
|
||||||
|
- 验证通过后,把 username 写入 Flask session 与 `g.api_username`,并标记 `is_api_user=True`。
|
||||||
|
- 该装饰器跳过 CSRF(在 server/security.requires_csrf_protection 中已放行 Bearer)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import functools
|
||||||
|
from flask import request, jsonify, session, g
|
||||||
|
|
||||||
|
from . import state
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_bearer_token() -> str:
|
||||||
|
auth_header = request.headers.get("Authorization") or ""
|
||||||
|
if not auth_header.lower().startswith("bearer "):
|
||||||
|
return ""
|
||||||
|
return auth_header.split(" ", 1)[1].strip()
|
||||||
|
|
||||||
|
|
||||||
|
def api_token_required(view_func):
|
||||||
|
@functools.wraps(view_func)
|
||||||
|
def wrapped(*args, **kwargs):
|
||||||
|
token = _extract_bearer_token()
|
||||||
|
if not token:
|
||||||
|
return jsonify({"success": False, "error": "缺少 Bearer Token"}), 401
|
||||||
|
|
||||||
|
record = state.api_user_manager.get_user_by_token(token)
|
||||||
|
if not record:
|
||||||
|
return jsonify({"success": False, "error": "无效的 Token"}), 401
|
||||||
|
|
||||||
|
# 写入 session 以复用现有上下文/工作区逻辑
|
||||||
|
session["username"] = record.username
|
||||||
|
session["role"] = "api"
|
||||||
|
session["is_api_user"] = True
|
||||||
|
g.api_username = record.username
|
||||||
|
return view_func(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["api_token_required"]
|
||||||
238
server/api_v1.py
Normal file
238
server/api_v1.py
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
"""API v1:Bearer Token 版轻量接口(后台任务 + 轮询 + 文件)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from flask import Blueprint, request, jsonify, send_file, session
|
||||||
|
|
||||||
|
from .api_auth import api_token_required
|
||||||
|
from .tasks import task_manager
|
||||||
|
from .context import get_user_resources, ensure_conversation_loaded, get_upload_guard
|
||||||
|
from .files import sanitize_filename_preserve_unicode
|
||||||
|
from .utils_common import debug_log
|
||||||
|
|
||||||
|
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
|
def _within_uploads(workspace, rel_path: str) -> Path:
|
||||||
|
base = Path(workspace.uploads_dir).resolve()
|
||||||
|
rel = rel_path or ""
|
||||||
|
# 兼容传入包含 user_upload/ 前缀的路径
|
||||||
|
if rel.startswith("user_upload/"):
|
||||||
|
rel = rel.split("user_upload/", 1)[1]
|
||||||
|
rel = rel.lstrip("/").strip()
|
||||||
|
target = (base / rel).resolve()
|
||||||
|
if not str(target).startswith(str(base)):
|
||||||
|
raise ValueError("非法路径")
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/conversations", methods=["POST"])
|
||||||
|
@api_token_required
|
||||||
|
def create_conversation_api():
|
||||||
|
username = session.get("username")
|
||||||
|
terminal, workspace = get_user_resources(username)
|
||||||
|
if not terminal:
|
||||||
|
return jsonify({"success": False, "error": "系统未初始化"}), 503
|
||||||
|
result = terminal.create_new_conversation()
|
||||||
|
if not result.get("success"):
|
||||||
|
return jsonify({"success": False, "error": result.get("error") or "创建对话失败"}), 500
|
||||||
|
return jsonify({"success": True, "conversation_id": result.get("conversation_id")})
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/messages", methods=["POST"])
|
||||||
|
@api_token_required
|
||||||
|
def send_message_api():
|
||||||
|
username = session.get("username")
|
||||||
|
payload = request.get_json() or {}
|
||||||
|
message = (payload.get("message") or "").strip()
|
||||||
|
images = payload.get("images") or []
|
||||||
|
conversation_id = payload.get("conversation_id")
|
||||||
|
model_key = payload.get("model_key")
|
||||||
|
thinking_mode = payload.get("thinking_mode")
|
||||||
|
run_mode = payload.get("run_mode")
|
||||||
|
max_iterations = payload.get("max_iterations")
|
||||||
|
if not message and not images:
|
||||||
|
return jsonify({"success": False, "error": "消息不能为空"}), 400
|
||||||
|
|
||||||
|
terminal, workspace = get_user_resources(username)
|
||||||
|
if not terminal or not workspace:
|
||||||
|
return jsonify({"success": False, "error": "系统未初始化"}), 503
|
||||||
|
try:
|
||||||
|
conversation_id, _ = ensure_conversation_loaded(terminal, conversation_id)
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"success": False, "error": f"对话加载失败: {exc}"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
rec = task_manager.create_chat_task(
|
||||||
|
username=username,
|
||||||
|
message=message,
|
||||||
|
images=images,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
model_key=model_key,
|
||||||
|
thinking_mode=thinking_mode,
|
||||||
|
run_mode=run_mode,
|
||||||
|
max_iterations=max_iterations,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 400
|
||||||
|
except RuntimeError as exc:
|
||||||
|
# 并发等业务冲突仍用 409
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 409
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"task_id": rec.task_id,
|
||||||
|
"conversation_id": rec.conversation_id,
|
||||||
|
"status": rec.status,
|
||||||
|
"created_at": rec.created_at,
|
||||||
|
}), 202
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/tasks/<task_id>", methods=["GET"])
|
||||||
|
@api_token_required
|
||||||
|
def get_task_events(task_id: str):
|
||||||
|
username = session.get("username")
|
||||||
|
rec = task_manager.get_task(username, task_id)
|
||||||
|
if not rec:
|
||||||
|
return jsonify({"success": False, "error": "任务不存在"}), 404
|
||||||
|
try:
|
||||||
|
offset = int(request.args.get("from", 0))
|
||||||
|
except Exception:
|
||||||
|
offset = 0
|
||||||
|
events = [e for e in rec.events if e["idx"] >= offset]
|
||||||
|
next_offset = events[-1]["idx"] + 1 if events else offset
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"task_id": rec.task_id,
|
||||||
|
"status": rec.status,
|
||||||
|
"created_at": rec.created_at,
|
||||||
|
"updated_at": rec.updated_at,
|
||||||
|
"error": rec.error,
|
||||||
|
"events": events,
|
||||||
|
"next_offset": next_offset,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/tasks/<task_id>/cancel", methods=["POST"])
|
||||||
|
@api_token_required
|
||||||
|
def cancel_task_api_v1(task_id: str):
|
||||||
|
username = session.get("username")
|
||||||
|
ok = task_manager.cancel_task(username, task_id)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"success": False, "error": "任务不存在"}), 404
|
||||||
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/files/upload", methods=["POST"])
|
||||||
|
@api_token_required
|
||||||
|
def upload_file_api():
|
||||||
|
username = session.get("username")
|
||||||
|
terminal, workspace = get_user_resources(username)
|
||||||
|
if not terminal or not workspace:
|
||||||
|
return jsonify({"success": False, "error": "系统未初始化"}), 503
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return jsonify({"success": False, "error": "未找到文件"}), 400
|
||||||
|
file_obj = request.files['file']
|
||||||
|
raw_name = request.form.get('filename') or file_obj.filename
|
||||||
|
filename = sanitize_filename_preserve_unicode(raw_name)
|
||||||
|
if not filename:
|
||||||
|
return jsonify({"success": False, "error": "非法文件名"}), 400
|
||||||
|
subdir = request.form.get("dir") or ""
|
||||||
|
try:
|
||||||
|
target_dir = _within_uploads(workspace, subdir)
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
target_path = (target_dir / filename).resolve()
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 400
|
||||||
|
|
||||||
|
guard = get_upload_guard(workspace)
|
||||||
|
rel_path = str(target_path.relative_to(workspace.uploads_dir))
|
||||||
|
try:
|
||||||
|
result = guard.process_upload(
|
||||||
|
file_obj,
|
||||||
|
target_path,
|
||||||
|
username=username,
|
||||||
|
source="api_v1",
|
||||||
|
original_name=raw_name,
|
||||||
|
relative_path=rel_path,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"success": False, "error": f"保存文件失败: {exc}"}), 500
|
||||||
|
|
||||||
|
metadata = result.get("metadata", {})
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"path": rel_path,
|
||||||
|
"filename": target_path.name,
|
||||||
|
"size": metadata.get("size"),
|
||||||
|
"sha256": metadata.get("sha256"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/files", methods=["GET"])
|
||||||
|
@api_token_required
|
||||||
|
def list_files_api():
|
||||||
|
username = session.get("username")
|
||||||
|
_, workspace = get_user_resources(username)
|
||||||
|
if not workspace:
|
||||||
|
return jsonify({"success": False, "error": "系统未初始化"}), 503
|
||||||
|
rel = request.args.get("path") or ""
|
||||||
|
try:
|
||||||
|
target = _within_uploads(workspace, rel)
|
||||||
|
if not target.exists():
|
||||||
|
return jsonify({"success": False, "error": "路径不存在"}), 404
|
||||||
|
if not target.is_dir():
|
||||||
|
return jsonify({"success": False, "error": "路径不是文件夹"}), 400
|
||||||
|
items = []
|
||||||
|
for entry in sorted(target.iterdir(), key=lambda p: p.name):
|
||||||
|
stat = entry.stat()
|
||||||
|
rel_entry = entry.relative_to(workspace.uploads_dir)
|
||||||
|
items.append({
|
||||||
|
"name": entry.name,
|
||||||
|
"is_dir": entry.is_dir(),
|
||||||
|
"size": stat.st_size,
|
||||||
|
"modified_at": stat.st_mtime,
|
||||||
|
"path": str(rel_entry),
|
||||||
|
})
|
||||||
|
return jsonify({"success": True, "items": items, "base": str(target.relative_to(workspace.uploads_dir))})
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/files/download", methods=["GET"])
|
||||||
|
@api_token_required
|
||||||
|
def download_file_api():
|
||||||
|
username = session.get("username")
|
||||||
|
_, workspace = get_user_resources(username)
|
||||||
|
if not workspace:
|
||||||
|
return jsonify({"success": False, "error": "系统未初始化"}), 503
|
||||||
|
rel = request.args.get("path")
|
||||||
|
if not rel:
|
||||||
|
return jsonify({"success": False, "error": "缺少 path"}), 400
|
||||||
|
try:
|
||||||
|
target = _within_uploads(workspace, rel)
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 400
|
||||||
|
if not target.exists():
|
||||||
|
return jsonify({"success": False, "error": "文件不存在"}), 404
|
||||||
|
|
||||||
|
if target.is_dir():
|
||||||
|
memory_file = BytesIO()
|
||||||
|
with zipfile.ZipFile(memory_file, mode='w', compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for root, _, files in os.walk(target):
|
||||||
|
for file in files:
|
||||||
|
full_path = Path(root) / file
|
||||||
|
arcname = full_path.relative_to(workspace.project_path)
|
||||||
|
zf.write(full_path, arcname=str(arcname))
|
||||||
|
memory_file.seek(0)
|
||||||
|
download_name = f"{target.name}.zip"
|
||||||
|
return send_file(memory_file, as_attachment=True, download_name=download_name, mimetype='application/zip')
|
||||||
|
return send_file(target, as_attachment=True, download_name=target.name)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["api_v1_bp"]
|
||||||
@ -32,6 +32,7 @@ from server.chat import chat_bp
|
|||||||
from server.usage import usage_bp
|
from server.usage import usage_bp
|
||||||
from server.status import status_bp
|
from server.status import status_bp
|
||||||
from server.tasks import tasks_bp
|
from server.tasks import tasks_bp
|
||||||
|
from server.api_v1 import api_v1_bp
|
||||||
from server.socket_handlers import socketio
|
from server.socket_handlers import socketio
|
||||||
from server.security import attach_security_hooks
|
from server.security import attach_security_hooks
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
@ -291,6 +292,7 @@ app.register_blueprint(chat_bp)
|
|||||||
app.register_blueprint(usage_bp)
|
app.register_blueprint(usage_bp)
|
||||||
app.register_blueprint(status_bp)
|
app.register_blueprint(status_bp)
|
||||||
app.register_blueprint(tasks_bp)
|
app.register_blueprint(tasks_bp)
|
||||||
|
app.register_blueprint(api_v1_bp)
|
||||||
|
|
||||||
# 安全钩子(CSRF 校验 + 响应头)
|
# 安全钩子(CSRF 校验 + 响应头)
|
||||||
attach_security_hooks(app)
|
attach_security_hooks(app)
|
||||||
|
|||||||
@ -508,8 +508,8 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
|
|||||||
last_tool_call_time = 0
|
last_tool_call_time = 0
|
||||||
detected_tool_intent: Dict[str, str] = {}
|
detected_tool_intent: Dict[str, str] = {}
|
||||||
|
|
||||||
# 设置最大迭代次数
|
# 设置最大迭代次数(API 可覆盖)
|
||||||
max_iterations = MAX_ITERATIONS_PER_TASK
|
max_iterations = getattr(web_terminal, "max_iterations_override", None) or MAX_ITERATIONS_PER_TASK
|
||||||
|
|
||||||
pending_append = None # {"path": str, "tool_call_id": str, "buffer": str, ...}
|
pending_append = None # {"path": str, "tool_call_id": str, "buffer": str, ...}
|
||||||
append_probe_buffer = ""
|
append_probe_buffer = ""
|
||||||
@ -1291,37 +1291,6 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
|
|||||||
content_chunks += 1
|
content_chunks += 1
|
||||||
debug_log(f" 正式内容 #{content_chunks}: {repr(content[:100] if content else 'None')}")
|
debug_log(f" 正式内容 #{content_chunks}: {repr(content[:100] if content else 'None')}")
|
||||||
|
|
||||||
# 通过文本内容提前检测工具调用意图
|
|
||||||
if not detected_tools:
|
|
||||||
# 检测常见的工具调用模式
|
|
||||||
tool_patterns = [
|
|
||||||
(r'(创建|新建|生成).*(文件|file)', 'create_file'),
|
|
||||||
(r'(读取|查看|打开).*(文件|file)', 'read_file'),
|
|
||||||
(r'(修改|编辑|更新).*(文件|file)', 'write_file_diff'),
|
|
||||||
(r'(删除|移除).*(文件|file)', 'delete_file'),
|
|
||||||
(r'(搜索|查找|search)', 'web_search'),
|
|
||||||
(r'(执行|运行).*(Python|python|代码)', 'run_python'),
|
|
||||||
(r'(执行|运行).*(命令|command)', 'run_command'),
|
|
||||||
(r'(等待|sleep|延迟)', 'sleep'),
|
|
||||||
(r'(聚焦|focus).*(文件|file)', 'focus_file'),
|
|
||||||
(r'(终端|terminal|会话|session)', 'terminal_session'),
|
|
||||||
]
|
|
||||||
|
|
||||||
for pattern, tool_name in tool_patterns:
|
|
||||||
if re.search(pattern, content, re.IGNORECASE):
|
|
||||||
early_tool_id = f"early_{tool_name}_{time.time()}"
|
|
||||||
if early_tool_id not in detected_tools:
|
|
||||||
sender('tool_hint', {
|
|
||||||
'id': early_tool_id,
|
|
||||||
'name': tool_name,
|
|
||||||
'message': f'检测到可能需要调用 {tool_name}...',
|
|
||||||
'confidence': 'low',
|
|
||||||
'conversation_id': conversation_id
|
|
||||||
})
|
|
||||||
detected_tools[early_tool_id] = tool_name
|
|
||||||
debug_log(f" ⚡ 提前检测到工具意图: {tool_name}")
|
|
||||||
break
|
|
||||||
|
|
||||||
if in_thinking and not thinking_ended:
|
if in_thinking and not thinking_ended:
|
||||||
in_thinking = False
|
in_thinking = False
|
||||||
thinking_ended = True
|
thinking_ended = True
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Optional, Tuple, Dict, Any
|
from typing import Optional, Tuple, Dict, Any
|
||||||
from flask import session, jsonify
|
from flask import session, jsonify, has_request_context
|
||||||
|
|
||||||
from core.web_terminal import WebTerminal
|
from core.web_terminal import WebTerminal
|
||||||
from modules.gui_file_manager import GuiFileManager
|
from modules.gui_file_manager import GuiFileManager
|
||||||
@ -39,14 +39,20 @@ def get_user_resources(username: Optional[str] = None) -> Tuple[Optional[WebTerm
|
|||||||
username = (username or get_current_username())
|
username = (username or get_current_username())
|
||||||
if not username:
|
if not username:
|
||||||
return None, None
|
return None, None
|
||||||
|
is_api_user = bool(session.get("is_api_user")) if has_request_context() else False
|
||||||
|
# API 用户与网页用户使用不同的 manager
|
||||||
|
if is_api_user:
|
||||||
|
record = None
|
||||||
|
workspace = state.api_user_manager.ensure_workspace(username)
|
||||||
|
else:
|
||||||
record = get_current_user_record()
|
record = get_current_user_record()
|
||||||
workspace = state.user_manager.ensure_user_workspace(username)
|
workspace = state.user_manager.ensure_user_workspace(username)
|
||||||
container_handle = state.container_manager.ensure_container(username, str(workspace.project_path))
|
container_handle = state.container_manager.ensure_container(username, str(workspace.project_path))
|
||||||
usage_tracker = get_or_create_usage_tracker(username, workspace)
|
usage_tracker = None if is_api_user else get_or_create_usage_tracker(username, workspace)
|
||||||
terminal = state.user_terminals.get(username)
|
terminal = state.user_terminals.get(username)
|
||||||
if not terminal:
|
if not terminal:
|
||||||
run_mode = session.get('run_mode')
|
run_mode = session.get('run_mode') if has_request_context() else None
|
||||||
thinking_mode_flag = session.get('thinking_mode')
|
thinking_mode_flag = session.get('thinking_mode') if has_request_context() else None
|
||||||
if run_mode not in {"fast", "thinking", "deep"}:
|
if run_mode not in {"fast", "thinking", "deep"}:
|
||||||
preferred_run_mode = None
|
preferred_run_mode = None
|
||||||
try:
|
try:
|
||||||
@ -78,18 +84,20 @@ def get_user_resources(username: Optional[str] = None) -> Tuple[Optional[WebTerm
|
|||||||
terminal.terminal_manager.broadcast = terminal.message_callback
|
terminal.terminal_manager.broadcast = terminal.message_callback
|
||||||
state.user_terminals[username] = terminal
|
state.user_terminals[username] = terminal
|
||||||
terminal.username = username
|
terminal.username = username
|
||||||
terminal.user_role = get_current_user_role(record)
|
terminal.user_role = "api" if is_api_user else get_current_user_role(record)
|
||||||
terminal.quota_update_callback = lambda metric=None: emit_user_quota_update(username)
|
terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None
|
||||||
|
if has_request_context():
|
||||||
session['run_mode'] = terminal.run_mode
|
session['run_mode'] = terminal.run_mode
|
||||||
session['thinking_mode'] = terminal.thinking_mode
|
session['thinking_mode'] = terminal.thinking_mode
|
||||||
else:
|
else:
|
||||||
terminal.update_container_session(container_handle)
|
terminal.update_container_session(container_handle)
|
||||||
attach_user_broadcast(terminal, username)
|
attach_user_broadcast(terminal, username)
|
||||||
terminal.username = username
|
terminal.username = username
|
||||||
terminal.user_role = get_current_user_role(record)
|
terminal.user_role = "api" if is_api_user else get_current_user_role(record)
|
||||||
terminal.quota_update_callback = lambda metric=None: emit_user_quota_update(username)
|
terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None
|
||||||
|
|
||||||
# 应用管理员策略
|
# 应用管理员策略
|
||||||
|
if not is_api_user:
|
||||||
try:
|
try:
|
||||||
from core.tool_config import ToolCategory
|
from core.tool_config import ToolCategory
|
||||||
from modules import admin_policy_manager
|
from modules import admin_policy_manager
|
||||||
@ -213,6 +221,7 @@ def ensure_conversation_loaded(terminal: WebTerminal, conversation_id: Optional[
|
|||||||
if not result.get("success"):
|
if not result.get("success"):
|
||||||
raise RuntimeError(result.get("message", "创建对话失败"))
|
raise RuntimeError(result.get("message", "创建对话失败"))
|
||||||
conversation_id = result["conversation_id"]
|
conversation_id = result["conversation_id"]
|
||||||
|
if has_request_context():
|
||||||
session['run_mode'] = terminal.run_mode
|
session['run_mode'] = terminal.run_mode
|
||||||
session['thinking_mode'] = terminal.thinking_mode
|
session['thinking_mode'] = terminal.thinking_mode
|
||||||
created_new = True
|
created_new = True
|
||||||
@ -237,6 +246,7 @@ def ensure_conversation_loaded(terminal: WebTerminal, conversation_id: Optional[
|
|||||||
terminal.api_client.start_new_task(force_deep=terminal.deep_thinking_mode)
|
terminal.api_client.start_new_task(force_deep=terminal.deep_thinking_mode)
|
||||||
else:
|
else:
|
||||||
terminal.api_client.start_new_task()
|
terminal.api_client.start_new_task()
|
||||||
|
if has_request_context():
|
||||||
session['run_mode'] = terminal.run_mode
|
session['run_mode'] = terminal.run_mode
|
||||||
session['thinking_mode'] = terminal.thinking_mode
|
session['thinking_mode'] = terminal.thinking_mode
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@ -111,6 +111,13 @@ def get_csrf_token(force_new: bool = False) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def requires_csrf_protection(path: str) -> bool:
|
def requires_csrf_protection(path: str) -> bool:
|
||||||
|
# Bearer Token 请求走无状态认证,跳过 CSRF
|
||||||
|
auth_header = (request.headers.get("Authorization") or "").lower()
|
||||||
|
if auth_header.startswith("bearer "):
|
||||||
|
return False
|
||||||
|
# API v1 统一跳过 CSRF;若未携带 Authorization,将由鉴权层返回 401
|
||||||
|
if path.startswith("/api/v1/"):
|
||||||
|
return False
|
||||||
if path in state.CSRF_EXEMPT_PATHS:
|
if path in state.CSRF_EXEMPT_PATHS:
|
||||||
return False
|
return False
|
||||||
if path in state.CSRF_PROTECTED_PATHS:
|
if path in state.CSRF_PROTECTED_PATHS:
|
||||||
|
|||||||
@ -12,9 +12,11 @@ from modules.custom_tool_registry import CustomToolRegistry
|
|||||||
from modules.usage_tracker import UsageTracker
|
from modules.usage_tracker import UsageTracker
|
||||||
from modules.user_container_manager import UserContainerManager
|
from modules.user_container_manager import UserContainerManager
|
||||||
from modules.user_manager import UserManager
|
from modules.user_manager import UserManager
|
||||||
|
from modules.api_user_manager import ApiUserManager
|
||||||
|
|
||||||
# 全局实例
|
# 全局实例
|
||||||
user_manager = UserManager()
|
user_manager = UserManager()
|
||||||
|
api_user_manager = ApiUserManager()
|
||||||
custom_tool_registry = CustomToolRegistry()
|
custom_tool_registry = CustomToolRegistry()
|
||||||
container_manager = UserContainerManager()
|
container_manager = UserContainerManager()
|
||||||
user_terminals: Dict[str, WebTerminal] = {}
|
user_terminals: Dict[str, WebTerminal] = {}
|
||||||
@ -68,6 +70,7 @@ PROJECT_MAX_STORAGE_MB = PROJECT_MAX_STORAGE_MB
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"user_manager",
|
"user_manager",
|
||||||
|
"api_user_manager",
|
||||||
"custom_tool_registry",
|
"custom_tool_registry",
|
||||||
"container_manager",
|
"container_manager",
|
||||||
"user_terminals",
|
"user_terminals",
|
||||||
|
|||||||
122
server/tasks.py
122
server/tasks.py
@ -7,6 +7,7 @@ from collections import deque
|
|||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
|
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
|
from flask import current_app, session
|
||||||
|
|
||||||
from .auth_helpers import api_login_required, get_current_username
|
from .auth_helpers import api_login_required, get_current_username
|
||||||
from .context import get_user_resources, ensure_conversation_loaded
|
from .context import get_user_resources, ensure_conversation_loaded
|
||||||
@ -27,9 +28,25 @@ class TaskRecord:
|
|||||||
"events",
|
"events",
|
||||||
"thread",
|
"thread",
|
||||||
"error",
|
"error",
|
||||||
|
"model_key",
|
||||||
|
"thinking_mode",
|
||||||
|
"run_mode",
|
||||||
|
"max_iterations",
|
||||||
|
"session_data",
|
||||||
|
"stop_requested",
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, task_id: str, username: str, message: str, conversation_id: Optional[str]):
|
def __init__(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
username: str,
|
||||||
|
message: str,
|
||||||
|
conversation_id: Optional[str],
|
||||||
|
model_key: Optional[str],
|
||||||
|
thinking_mode: Optional[bool],
|
||||||
|
run_mode: Optional[str],
|
||||||
|
max_iterations: Optional[int],
|
||||||
|
):
|
||||||
self.task_id = task_id
|
self.task_id = task_id
|
||||||
self.username = username
|
self.username = username
|
||||||
self.status = "pending"
|
self.status = "pending"
|
||||||
@ -40,6 +57,12 @@ class TaskRecord:
|
|||||||
self.events: deque[Dict[str, Any]] = deque(maxlen=1000)
|
self.events: deque[Dict[str, Any]] = deque(maxlen=1000)
|
||||||
self.thread: Optional[threading.Thread] = None
|
self.thread: Optional[threading.Thread] = None
|
||||||
self.error: Optional[str] = None
|
self.error: Optional[str] = None
|
||||||
|
self.model_key = model_key
|
||||||
|
self.thinking_mode = thinking_mode
|
||||||
|
self.run_mode = run_mode
|
||||||
|
self.max_iterations = max_iterations
|
||||||
|
self.session_data: Dict[str, Any] = {}
|
||||||
|
self.stop_requested: bool = False
|
||||||
|
|
||||||
|
|
||||||
class TaskManager:
|
class TaskManager:
|
||||||
@ -50,9 +73,40 @@ class TaskManager:
|
|||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
# ---- public APIs ----
|
# ---- public APIs ----
|
||||||
def create_chat_task(self, username: str, message: str, images: List[Any], conversation_id: Optional[str]) -> TaskRecord:
|
def create_chat_task(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
message: str,
|
||||||
|
images: List[Any],
|
||||||
|
conversation_id: Optional[str],
|
||||||
|
model_key: Optional[str] = None,
|
||||||
|
thinking_mode: Optional[bool] = None,
|
||||||
|
run_mode: Optional[str] = None,
|
||||||
|
max_iterations: Optional[int] = None,
|
||||||
|
) -> TaskRecord:
|
||||||
|
if run_mode:
|
||||||
|
normalized = str(run_mode).lower()
|
||||||
|
if normalized not in {"fast", "thinking", "deep"}:
|
||||||
|
raise ValueError("run_mode 只支持 fast/thinking/deep")
|
||||||
|
run_mode = normalized
|
||||||
|
# 单用户互斥:禁止并发任务
|
||||||
|
existing = [t for t in self.list_tasks(username) if t.status in {"pending", "running"}]
|
||||||
|
if existing:
|
||||||
|
raise RuntimeError("已有运行中的任务,请稍后再试。")
|
||||||
task_id = str(uuid.uuid4())
|
task_id = str(uuid.uuid4())
|
||||||
record = TaskRecord(task_id, username, message, conversation_id)
|
record = TaskRecord(task_id, username, message, conversation_id, model_key, thinking_mode, run_mode, max_iterations)
|
||||||
|
# 记录当前 session 快照,便于后台线程内使用
|
||||||
|
try:
|
||||||
|
record.session_data = {
|
||||||
|
"username": session.get("username"),
|
||||||
|
"role": session.get("role"),
|
||||||
|
"is_api_user": session.get("is_api_user"),
|
||||||
|
"run_mode": session.get("run_mode"),
|
||||||
|
"thinking_mode": session.get("thinking_mode"),
|
||||||
|
"model_key": session.get("model_key"),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
record.session_data = {}
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._tasks[task_id] = record
|
self._tasks[task_id] = record
|
||||||
thread = threading.Thread(target=self._run_chat_task, args=(record, images), daemon=True)
|
thread = threading.Thread(target=self._run_chat_task, args=(record, images), daemon=True)
|
||||||
@ -77,6 +131,7 @@ class TaskManager:
|
|||||||
rec = self.get_task(username, task_id)
|
rec = self.get_task(username, task_id)
|
||||||
if not rec:
|
if not rec:
|
||||||
return False
|
return False
|
||||||
|
rec.stop_requested = True
|
||||||
# 标记停止标志;chat_flow 会检测 stop_flags
|
# 标记停止标志;chat_flow 会检测 stop_flags
|
||||||
entry = stop_flags.get(task_id)
|
entry = stop_flags.get(task_id)
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
@ -107,10 +162,45 @@ class TaskManager:
|
|||||||
|
|
||||||
def _run_chat_task(self, rec: TaskRecord, images: List[Any]):
|
def _run_chat_task(self, rec: TaskRecord, images: List[Any]):
|
||||||
username = rec.username
|
username = rec.username
|
||||||
|
terminal = None
|
||||||
|
workspace = None
|
||||||
|
stop_hint = False
|
||||||
try:
|
try:
|
||||||
|
# 为后台线程构造最小请求上下文,填充 session
|
||||||
|
from server.app import app as flask_app
|
||||||
|
with flask_app.test_request_context():
|
||||||
|
try:
|
||||||
|
for k, v in (rec.session_data or {}).items():
|
||||||
|
if v is not None:
|
||||||
|
session[k] = v
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
terminal, workspace = get_user_resources(username)
|
terminal, workspace = get_user_resources(username)
|
||||||
if not terminal or not workspace:
|
if not terminal or not workspace:
|
||||||
raise RuntimeError("系统未初始化")
|
raise RuntimeError("系统未初始化")
|
||||||
|
stop_hint = bool(stop_flags.get(rec.task_id, {}).get("stop"))
|
||||||
|
|
||||||
|
# API 传入的模型/模式配置
|
||||||
|
if rec.model_key:
|
||||||
|
try:
|
||||||
|
terminal.set_model(rec.model_key)
|
||||||
|
except Exception as exc:
|
||||||
|
debug_log(f"[Task] 设置模型失败 {rec.model_key}: {exc}")
|
||||||
|
if rec.run_mode:
|
||||||
|
try:
|
||||||
|
terminal.set_run_mode(rec.run_mode)
|
||||||
|
except Exception as exc:
|
||||||
|
debug_log(f"[Task] 设置运行模式失败 {rec.run_mode}: {exc}")
|
||||||
|
elif rec.thinking_mode is not None:
|
||||||
|
try:
|
||||||
|
terminal.set_run_mode("thinking" if rec.thinking_mode else "fast")
|
||||||
|
except Exception as exc:
|
||||||
|
debug_log(f"[Task] 设置思考模式失败: {exc}")
|
||||||
|
if rec.max_iterations:
|
||||||
|
try:
|
||||||
|
terminal.max_iterations_override = int(rec.max_iterations)
|
||||||
|
except Exception:
|
||||||
|
terminal.max_iterations_override = None
|
||||||
|
|
||||||
# 确保会话加载
|
# 确保会话加载
|
||||||
conversation_id = rec.conversation_id
|
conversation_id = rec.conversation_id
|
||||||
@ -142,8 +232,9 @@ class TaskManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 结束状态
|
# 结束状态
|
||||||
|
canceled_flag = rec.stop_requested or stop_hint or bool(stop_flags.get(rec.task_id, {}).get("stop"))
|
||||||
with self._lock:
|
with self._lock:
|
||||||
rec.status = "canceled" if rec.task_id in stop_flags and stop_flags[rec.task_id].get('stop') else "succeeded"
|
rec.status = "canceled" if canceled_flag else "succeeded"
|
||||||
rec.updated_at = time.time()
|
rec.updated_at = time.time()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
debug_log(f"[Task] 后台任务失败: {exc}")
|
debug_log(f"[Task] 后台任务失败: {exc}")
|
||||||
@ -155,6 +246,12 @@ class TaskManager:
|
|||||||
finally:
|
finally:
|
||||||
# 清理 stop_flags
|
# 清理 stop_flags
|
||||||
stop_flags.pop(rec.task_id, None)
|
stop_flags.pop(rec.task_id, None)
|
||||||
|
# 清理一次性配置
|
||||||
|
if terminal and hasattr(terminal, "max_iterations_override"):
|
||||||
|
try:
|
||||||
|
delattr(terminal, "max_iterations_override")
|
||||||
|
except Exception:
|
||||||
|
terminal.max_iterations_override = None
|
||||||
|
|
||||||
|
|
||||||
task_manager = TaskManager()
|
task_manager = TaskManager()
|
||||||
@ -192,7 +289,21 @@ def create_task_api():
|
|||||||
conversation_id = payload.get("conversation_id")
|
conversation_id = payload.get("conversation_id")
|
||||||
if not message and not images:
|
if not message and not images:
|
||||||
return jsonify({"success": False, "error": "消息不能为空"}), 400
|
return jsonify({"success": False, "error": "消息不能为空"}), 400
|
||||||
rec = task_manager.create_chat_task(username, message, images, conversation_id)
|
model_key = payload.get("model_key")
|
||||||
|
thinking_mode = payload.get("thinking_mode")
|
||||||
|
max_iterations = payload.get("max_iterations")
|
||||||
|
try:
|
||||||
|
rec = task_manager.create_chat_task(
|
||||||
|
username,
|
||||||
|
message,
|
||||||
|
images,
|
||||||
|
conversation_id,
|
||||||
|
model_key=model_key,
|
||||||
|
thinking_mode=thinking_mode,
|
||||||
|
max_iterations=max_iterations,
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 409
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"success": True,
|
"success": True,
|
||||||
"data": {
|
"data": {
|
||||||
@ -241,4 +352,3 @@ def cancel_task_api(task_id: str):
|
|||||||
if not ok:
|
if not ok:
|
||||||
return jsonify({"success": False, "error": "任务不存在"}), 404
|
return jsonify({"success": False, "error": "任务不存在"}), 404
|
||||||
return jsonify({"success": True})
|
return jsonify({"success": True})
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user