feat: add api v1 bearer auth and polling docs

This commit is contained in:
JOJO 2026-01-24 02:35:07 +08:00
parent d6fb59e1d8
commit 82e7d0680a
18 changed files with 2172 additions and 88 deletions

127
api_doc/README.md Normal file
View 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
View 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
View 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
View 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
View 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) FlutterDart轮询示例伪代码
依赖:`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
View 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`:文件 mtimeUNIX 秒)
可能错误:
- `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
View 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=thinkingfalse=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
View 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" }

View File

@ -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
View 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
View 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
View File

@ -0,0 +1,238 @@
"""API v1Bearer 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"]

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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",

View File

@ -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})