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"
|
||||
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__ = [
|
||||
"DEFAULT_PROJECT_PATH",
|
||||
"PROMPTS_DIR",
|
||||
@ -20,4 +25,7 @@ __all__ = [
|
||||
"USERS_DB_FILE",
|
||||
"INVITE_CODES_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.status import status_bp
|
||||
from server.tasks import tasks_bp
|
||||
from server.api_v1 import api_v1_bp
|
||||
from server.socket_handlers import socketio
|
||||
from server.security import attach_security_hooks
|
||||
from werkzeug.utils import secure_filename
|
||||
@ -291,6 +292,7 @@ app.register_blueprint(chat_bp)
|
||||
app.register_blueprint(usage_bp)
|
||||
app.register_blueprint(status_bp)
|
||||
app.register_blueprint(tasks_bp)
|
||||
app.register_blueprint(api_v1_bp)
|
||||
|
||||
# 安全钩子(CSRF 校验 + 响应头)
|
||||
attach_security_hooks(app)
|
||||
|
||||
@ -508,8 +508,8 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
|
||||
last_tool_call_time = 0
|
||||
detected_tool_intent: Dict[str, str] = {}
|
||||
|
||||
# 设置最大迭代次数
|
||||
max_iterations = MAX_ITERATIONS_PER_TASK
|
||||
# 设置最大迭代次数(API 可覆盖)
|
||||
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, ...}
|
||||
append_probe_buffer = ""
|
||||
@ -1291,37 +1291,6 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
|
||||
content_chunks += 1
|
||||
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:
|
||||
in_thinking = False
|
||||
thinking_ended = True
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
from functools import wraps
|
||||
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 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())
|
||||
if not username:
|
||||
return None, None
|
||||
record = get_current_user_record()
|
||||
workspace = state.user_manager.ensure_user_workspace(username)
|
||||
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()
|
||||
workspace = state.user_manager.ensure_user_workspace(username)
|
||||
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)
|
||||
if not terminal:
|
||||
run_mode = session.get('run_mode')
|
||||
thinking_mode_flag = session.get('thinking_mode')
|
||||
run_mode = session.get('run_mode') if has_request_context() else None
|
||||
thinking_mode_flag = session.get('thinking_mode') if has_request_context() else None
|
||||
if run_mode not in {"fast", "thinking", "deep"}:
|
||||
preferred_run_mode = None
|
||||
try:
|
||||
@ -78,51 +84,53 @@ def get_user_resources(username: Optional[str] = None) -> Tuple[Optional[WebTerm
|
||||
terminal.terminal_manager.broadcast = terminal.message_callback
|
||||
state.user_terminals[username] = terminal
|
||||
terminal.username = username
|
||||
terminal.user_role = get_current_user_role(record)
|
||||
terminal.quota_update_callback = lambda metric=None: emit_user_quota_update(username)
|
||||
session['run_mode'] = terminal.run_mode
|
||||
session['thinking_mode'] = terminal.thinking_mode
|
||||
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)) if not is_api_user else None
|
||||
if has_request_context():
|
||||
session['run_mode'] = terminal.run_mode
|
||||
session['thinking_mode'] = terminal.thinking_mode
|
||||
else:
|
||||
terminal.update_container_session(container_handle)
|
||||
attach_user_broadcast(terminal, username)
|
||||
terminal.username = username
|
||||
terminal.user_role = get_current_user_role(record)
|
||||
terminal.quota_update_callback = lambda metric=None: emit_user_quota_update(username)
|
||||
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)) if not is_api_user else None
|
||||
|
||||
# 应用管理员策略
|
||||
try:
|
||||
from core.tool_config import ToolCategory
|
||||
from modules import admin_policy_manager
|
||||
policy = admin_policy_manager.get_effective_policy(
|
||||
record.username if record else None,
|
||||
get_current_user_role(record),
|
||||
getattr(record, "invite_code", None),
|
||||
)
|
||||
categories_map = {
|
||||
cid: ToolCategory(
|
||||
label=cat.get("label") or cid,
|
||||
tools=list(cat.get("tools") or []),
|
||||
default_enabled=bool(cat.get("default_enabled", True)),
|
||||
silent_when_disabled=bool(cat.get("silent_when_disabled", False)),
|
||||
if not is_api_user:
|
||||
try:
|
||||
from core.tool_config import ToolCategory
|
||||
from modules import admin_policy_manager
|
||||
policy = admin_policy_manager.get_effective_policy(
|
||||
record.username if record else None,
|
||||
get_current_user_role(record),
|
||||
getattr(record, "invite_code", None),
|
||||
)
|
||||
for cid, cat in policy.get("categories", {}).items()
|
||||
}
|
||||
forced_states = policy.get("forced_category_states") or {}
|
||||
disabled_models = policy.get("disabled_models") or []
|
||||
terminal.set_admin_policy(categories_map, forced_states, disabled_models)
|
||||
terminal.admin_policy_ui_blocks = policy.get("ui_blocks") or {}
|
||||
terminal.admin_policy_version = policy.get("updated_at")
|
||||
if terminal.model_key in disabled_models:
|
||||
for candidate in ["kimi", "deepseek", "qwen3-vl-plus", "qwen3-max"]:
|
||||
if candidate not in disabled_models:
|
||||
try:
|
||||
terminal.set_model(candidate)
|
||||
session["model_key"] = terminal.model_key
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as exc:
|
||||
debug_log(f"[admin_policy] 应用失败: {exc}")
|
||||
categories_map = {
|
||||
cid: ToolCategory(
|
||||
label=cat.get("label") or cid,
|
||||
tools=list(cat.get("tools") or []),
|
||||
default_enabled=bool(cat.get("default_enabled", True)),
|
||||
silent_when_disabled=bool(cat.get("silent_when_disabled", False)),
|
||||
)
|
||||
for cid, cat in policy.get("categories", {}).items()
|
||||
}
|
||||
forced_states = policy.get("forced_category_states") or {}
|
||||
disabled_models = policy.get("disabled_models") or []
|
||||
terminal.set_admin_policy(categories_map, forced_states, disabled_models)
|
||||
terminal.admin_policy_ui_blocks = policy.get("ui_blocks") or {}
|
||||
terminal.admin_policy_version = policy.get("updated_at")
|
||||
if terminal.model_key in disabled_models:
|
||||
for candidate in ["kimi", "deepseek", "qwen3-vl-plus", "qwen3-max"]:
|
||||
if candidate not in disabled_models:
|
||||
try:
|
||||
terminal.set_model(candidate)
|
||||
session["model_key"] = terminal.model_key
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
except Exception as exc:
|
||||
debug_log(f"[admin_policy] 应用失败: {exc}")
|
||||
return terminal, workspace
|
||||
|
||||
|
||||
@ -213,8 +221,9 @@ def ensure_conversation_loaded(terminal: WebTerminal, conversation_id: Optional[
|
||||
if not result.get("success"):
|
||||
raise RuntimeError(result.get("message", "创建对话失败"))
|
||||
conversation_id = result["conversation_id"]
|
||||
session['run_mode'] = terminal.run_mode
|
||||
session['thinking_mode'] = terminal.thinking_mode
|
||||
if has_request_context():
|
||||
session['run_mode'] = terminal.run_mode
|
||||
session['thinking_mode'] = terminal.thinking_mode
|
||||
created_new = True
|
||||
else:
|
||||
conversation_id = conversation_id if conversation_id.startswith('conv_') else f"conv_{conversation_id}"
|
||||
@ -237,8 +246,9 @@ def ensure_conversation_loaded(terminal: WebTerminal, conversation_id: Optional[
|
||||
terminal.api_client.start_new_task(force_deep=terminal.deep_thinking_mode)
|
||||
else:
|
||||
terminal.api_client.start_new_task()
|
||||
session['run_mode'] = terminal.run_mode
|
||||
session['thinking_mode'] = terminal.thinking_mode
|
||||
if has_request_context():
|
||||
session['run_mode'] = terminal.run_mode
|
||||
session['thinking_mode'] = terminal.thinking_mode
|
||||
except Exception:
|
||||
pass
|
||||
return conversation_id, created_new
|
||||
|
||||
@ -111,6 +111,13 @@ def get_csrf_token(force_new: bool = False) -> str:
|
||||
|
||||
|
||||
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:
|
||||
return False
|
||||
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.user_container_manager import UserContainerManager
|
||||
from modules.user_manager import UserManager
|
||||
from modules.api_user_manager import ApiUserManager
|
||||
|
||||
# 全局实例
|
||||
user_manager = UserManager()
|
||||
api_user_manager = ApiUserManager()
|
||||
custom_tool_registry = CustomToolRegistry()
|
||||
container_manager = UserContainerManager()
|
||||
user_terminals: Dict[str, WebTerminal] = {}
|
||||
@ -68,6 +70,7 @@ PROJECT_MAX_STORAGE_MB = PROJECT_MAX_STORAGE_MB
|
||||
|
||||
__all__ = [
|
||||
"user_manager",
|
||||
"api_user_manager",
|
||||
"custom_tool_registry",
|
||||
"container_manager",
|
||||
"user_terminals",
|
||||
|
||||
124
server/tasks.py
124
server/tasks.py
@ -7,6 +7,7 @@ from collections import deque
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask import current_app, session
|
||||
|
||||
from .auth_helpers import api_login_required, get_current_username
|
||||
from .context import get_user_resources, ensure_conversation_loaded
|
||||
@ -27,9 +28,25 @@ class TaskRecord:
|
||||
"events",
|
||||
"thread",
|
||||
"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.username = username
|
||||
self.status = "pending"
|
||||
@ -40,6 +57,12 @@ class TaskRecord:
|
||||
self.events: deque[Dict[str, Any]] = deque(maxlen=1000)
|
||||
self.thread: Optional[threading.Thread] = 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:
|
||||
@ -50,9 +73,40 @@ class TaskManager:
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# ---- 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())
|
||||
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:
|
||||
self._tasks[task_id] = record
|
||||
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)
|
||||
if not rec:
|
||||
return False
|
||||
rec.stop_requested = True
|
||||
# 标记停止标志;chat_flow 会检测 stop_flags
|
||||
entry = stop_flags.get(task_id)
|
||||
if not isinstance(entry, dict):
|
||||
@ -107,10 +162,45 @@ class TaskManager:
|
||||
|
||||
def _run_chat_task(self, rec: TaskRecord, images: List[Any]):
|
||||
username = rec.username
|
||||
terminal = None
|
||||
workspace = None
|
||||
stop_hint = False
|
||||
try:
|
||||
terminal, workspace = get_user_resources(username)
|
||||
# 为后台线程构造最小请求上下文,填充 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)
|
||||
if not terminal or not workspace:
|
||||
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
|
||||
@ -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:
|
||||
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()
|
||||
except Exception as exc:
|
||||
debug_log(f"[Task] 后台任务失败: {exc}")
|
||||
@ -155,6 +246,12 @@ class TaskManager:
|
||||
finally:
|
||||
# 清理 stop_flags
|
||||
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()
|
||||
@ -192,7 +289,21 @@ def create_task_api():
|
||||
conversation_id = payload.get("conversation_id")
|
||||
if not message and not images:
|
||||
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({
|
||||
"success": True,
|
||||
"data": {
|
||||
@ -241,4 +352,3 @@ def cancel_task_api(task_id: str):
|
||||
if not ok:
|
||||
return jsonify({"success": False, "error": "任务不存在"}), 404
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user