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" INVITE_CODES_FILE = f"{DATA_DIR}/invite_codes.json"
ADMIN_POLICY_FILE = f"{DATA_DIR}/admin_policy.json" ADMIN_POLICY_FILE = f"{DATA_DIR}/admin_policy.json"
# API 专用用户与工作区(与网页用户隔离)
API_USER_SPACE_DIR = "./api/users"
API_USERS_DB_FILE = f"{DATA_DIR}/api_users.json"
API_TOKENS_FILE = f"{DATA_DIR}/api_tokens.json"
__all__ = [ __all__ = [
"DEFAULT_PROJECT_PATH", "DEFAULT_PROJECT_PATH",
"PROMPTS_DIR", "PROMPTS_DIR",
@ -20,4 +25,7 @@ __all__ = [
"USERS_DB_FILE", "USERS_DB_FILE",
"INVITE_CODES_FILE", "INVITE_CODES_FILE",
"ADMIN_POLICY_FILE", "ADMIN_POLICY_FILE",
"API_USER_SPACE_DIR",
"API_USERS_DB_FILE",
"API_TOKENS_FILE",
] ]

160
modules/api_user_manager.py Normal file
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.usage import usage_bp
from server.status import status_bp from server.status import status_bp
from server.tasks import tasks_bp from server.tasks import tasks_bp
from server.api_v1 import api_v1_bp
from server.socket_handlers import socketio from server.socket_handlers import socketio
from server.security import attach_security_hooks from server.security import attach_security_hooks
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
@ -291,6 +292,7 @@ app.register_blueprint(chat_bp)
app.register_blueprint(usage_bp) app.register_blueprint(usage_bp)
app.register_blueprint(status_bp) app.register_blueprint(status_bp)
app.register_blueprint(tasks_bp) app.register_blueprint(tasks_bp)
app.register_blueprint(api_v1_bp)
# 安全钩子CSRF 校验 + 响应头) # 安全钩子CSRF 校验 + 响应头)
attach_security_hooks(app) attach_security_hooks(app)

View File

@ -508,8 +508,8 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
last_tool_call_time = 0 last_tool_call_time = 0
detected_tool_intent: Dict[str, str] = {} detected_tool_intent: Dict[str, str] = {}
# 设置最大迭代次数 # 设置最大迭代次数API 可覆盖)
max_iterations = MAX_ITERATIONS_PER_TASK max_iterations = getattr(web_terminal, "max_iterations_override", None) or MAX_ITERATIONS_PER_TASK
pending_append = None # {"path": str, "tool_call_id": str, "buffer": str, ...} pending_append = None # {"path": str, "tool_call_id": str, "buffer": str, ...}
append_probe_buffer = "" append_probe_buffer = ""
@ -1291,37 +1291,6 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
content_chunks += 1 content_chunks += 1
debug_log(f" 正式内容 #{content_chunks}: {repr(content[:100] if content else 'None')}") debug_log(f" 正式内容 #{content_chunks}: {repr(content[:100] if content else 'None')}")
# 通过文本内容提前检测工具调用意图
if not detected_tools:
# 检测常见的工具调用模式
tool_patterns = [
(r'(创建|新建|生成).*(文件|file)', 'create_file'),
(r'(读取|查看|打开).*(文件|file)', 'read_file'),
(r'(修改|编辑|更新).*(文件|file)', 'write_file_diff'),
(r'(删除|移除).*(文件|file)', 'delete_file'),
(r'(搜索|查找|search)', 'web_search'),
(r'(执行|运行).*(Python|python|代码)', 'run_python'),
(r'(执行|运行).*(命令|command)', 'run_command'),
(r'(等待|sleep|延迟)', 'sleep'),
(r'(聚焦|focus).*(文件|file)', 'focus_file'),
(r'(终端|terminal|会话|session)', 'terminal_session'),
]
for pattern, tool_name in tool_patterns:
if re.search(pattern, content, re.IGNORECASE):
early_tool_id = f"early_{tool_name}_{time.time()}"
if early_tool_id not in detected_tools:
sender('tool_hint', {
'id': early_tool_id,
'name': tool_name,
'message': f'检测到可能需要调用 {tool_name}...',
'confidence': 'low',
'conversation_id': conversation_id
})
detected_tools[early_tool_id] = tool_name
debug_log(f" ⚡ 提前检测到工具意图: {tool_name}")
break
if in_thinking and not thinking_ended: if in_thinking and not thinking_ended:
in_thinking = False in_thinking = False
thinking_ended = True thinking_ended = True

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from functools import wraps from functools import wraps
from typing import Optional, Tuple, Dict, Any from typing import Optional, Tuple, Dict, Any
from flask import session, jsonify from flask import session, jsonify, has_request_context
from core.web_terminal import WebTerminal from core.web_terminal import WebTerminal
from modules.gui_file_manager import GuiFileManager from modules.gui_file_manager import GuiFileManager
@ -39,14 +39,20 @@ def get_user_resources(username: Optional[str] = None) -> Tuple[Optional[WebTerm
username = (username or get_current_username()) username = (username or get_current_username())
if not username: if not username:
return None, None return None, None
record = get_current_user_record() is_api_user = bool(session.get("is_api_user")) if has_request_context() else False
workspace = state.user_manager.ensure_user_workspace(username) # 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)) container_handle = state.container_manager.ensure_container(username, str(workspace.project_path))
usage_tracker = get_or_create_usage_tracker(username, workspace) usage_tracker = None if is_api_user else get_or_create_usage_tracker(username, workspace)
terminal = state.user_terminals.get(username) terminal = state.user_terminals.get(username)
if not terminal: if not terminal:
run_mode = session.get('run_mode') run_mode = session.get('run_mode') if has_request_context() else None
thinking_mode_flag = session.get('thinking_mode') thinking_mode_flag = session.get('thinking_mode') if has_request_context() else None
if run_mode not in {"fast", "thinking", "deep"}: if run_mode not in {"fast", "thinking", "deep"}:
preferred_run_mode = None preferred_run_mode = None
try: try:
@ -78,51 +84,53 @@ def get_user_resources(username: Optional[str] = None) -> Tuple[Optional[WebTerm
terminal.terminal_manager.broadcast = terminal.message_callback terminal.terminal_manager.broadcast = terminal.message_callback
state.user_terminals[username] = terminal state.user_terminals[username] = terminal
terminal.username = username terminal.username = username
terminal.user_role = get_current_user_role(record) terminal.user_role = "api" if is_api_user else get_current_user_role(record)
terminal.quota_update_callback = lambda metric=None: emit_user_quota_update(username) terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None
session['run_mode'] = terminal.run_mode if has_request_context():
session['thinking_mode'] = terminal.thinking_mode session['run_mode'] = terminal.run_mode
session['thinking_mode'] = terminal.thinking_mode
else: else:
terminal.update_container_session(container_handle) terminal.update_container_session(container_handle)
attach_user_broadcast(terminal, username) attach_user_broadcast(terminal, username)
terminal.username = username terminal.username = username
terminal.user_role = get_current_user_role(record) terminal.user_role = "api" if is_api_user else get_current_user_role(record)
terminal.quota_update_callback = lambda metric=None: emit_user_quota_update(username) terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None
# 应用管理员策略 # 应用管理员策略
try: if not is_api_user:
from core.tool_config import ToolCategory try:
from modules import admin_policy_manager from core.tool_config import ToolCategory
policy = admin_policy_manager.get_effective_policy( from modules import admin_policy_manager
record.username if record else None, policy = admin_policy_manager.get_effective_policy(
get_current_user_role(record), record.username if record else None,
getattr(record, "invite_code", 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)),
) )
for cid, cat in policy.get("categories", {}).items() categories_map = {
} cid: ToolCategory(
forced_states = policy.get("forced_category_states") or {} label=cat.get("label") or cid,
disabled_models = policy.get("disabled_models") or [] tools=list(cat.get("tools") or []),
terminal.set_admin_policy(categories_map, forced_states, disabled_models) default_enabled=bool(cat.get("default_enabled", True)),
terminal.admin_policy_ui_blocks = policy.get("ui_blocks") or {} silent_when_disabled=bool(cat.get("silent_when_disabled", False)),
terminal.admin_policy_version = policy.get("updated_at") )
if terminal.model_key in disabled_models: for cid, cat in policy.get("categories", {}).items()
for candidate in ["kimi", "deepseek", "qwen3-vl-plus", "qwen3-max"]: }
if candidate not in disabled_models: forced_states = policy.get("forced_category_states") or {}
try: disabled_models = policy.get("disabled_models") or []
terminal.set_model(candidate) terminal.set_admin_policy(categories_map, forced_states, disabled_models)
session["model_key"] = terminal.model_key terminal.admin_policy_ui_blocks = policy.get("ui_blocks") or {}
break terminal.admin_policy_version = policy.get("updated_at")
except Exception: if terminal.model_key in disabled_models:
continue for candidate in ["kimi", "deepseek", "qwen3-vl-plus", "qwen3-max"]:
except Exception as exc: if candidate not in disabled_models:
debug_log(f"[admin_policy] 应用失败: {exc}") 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 return terminal, workspace
@ -213,8 +221,9 @@ def ensure_conversation_loaded(terminal: WebTerminal, conversation_id: Optional[
if not result.get("success"): if not result.get("success"):
raise RuntimeError(result.get("message", "创建对话失败")) raise RuntimeError(result.get("message", "创建对话失败"))
conversation_id = result["conversation_id"] conversation_id = result["conversation_id"]
session['run_mode'] = terminal.run_mode if has_request_context():
session['thinking_mode'] = terminal.thinking_mode session['run_mode'] = terminal.run_mode
session['thinking_mode'] = terminal.thinking_mode
created_new = True created_new = True
else: else:
conversation_id = conversation_id if conversation_id.startswith('conv_') else f"conv_{conversation_id}" 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) terminal.api_client.start_new_task(force_deep=terminal.deep_thinking_mode)
else: else:
terminal.api_client.start_new_task() terminal.api_client.start_new_task()
session['run_mode'] = terminal.run_mode if has_request_context():
session['thinking_mode'] = terminal.thinking_mode session['run_mode'] = terminal.run_mode
session['thinking_mode'] = terminal.thinking_mode
except Exception: except Exception:
pass pass
return conversation_id, created_new 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: def requires_csrf_protection(path: str) -> bool:
# Bearer Token 请求走无状态认证,跳过 CSRF
auth_header = (request.headers.get("Authorization") or "").lower()
if auth_header.startswith("bearer "):
return False
# API v1 统一跳过 CSRF若未携带 Authorization将由鉴权层返回 401
if path.startswith("/api/v1/"):
return False
if path in state.CSRF_EXEMPT_PATHS: if path in state.CSRF_EXEMPT_PATHS:
return False return False
if path in state.CSRF_PROTECTED_PATHS: if path in state.CSRF_PROTECTED_PATHS:

View File

@ -12,9 +12,11 @@ from modules.custom_tool_registry import CustomToolRegistry
from modules.usage_tracker import UsageTracker from modules.usage_tracker import UsageTracker
from modules.user_container_manager import UserContainerManager from modules.user_container_manager import UserContainerManager
from modules.user_manager import UserManager from modules.user_manager import UserManager
from modules.api_user_manager import ApiUserManager
# 全局实例 # 全局实例
user_manager = UserManager() user_manager = UserManager()
api_user_manager = ApiUserManager()
custom_tool_registry = CustomToolRegistry() custom_tool_registry = CustomToolRegistry()
container_manager = UserContainerManager() container_manager = UserContainerManager()
user_terminals: Dict[str, WebTerminal] = {} user_terminals: Dict[str, WebTerminal] = {}
@ -68,6 +70,7 @@ PROJECT_MAX_STORAGE_MB = PROJECT_MAX_STORAGE_MB
__all__ = [ __all__ = [
"user_manager", "user_manager",
"api_user_manager",
"custom_tool_registry", "custom_tool_registry",
"container_manager", "container_manager",
"user_terminals", "user_terminals",

View File

@ -7,6 +7,7 @@ from collections import deque
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from flask import current_app, session
from .auth_helpers import api_login_required, get_current_username from .auth_helpers import api_login_required, get_current_username
from .context import get_user_resources, ensure_conversation_loaded from .context import get_user_resources, ensure_conversation_loaded
@ -27,9 +28,25 @@ class TaskRecord:
"events", "events",
"thread", "thread",
"error", "error",
"model_key",
"thinking_mode",
"run_mode",
"max_iterations",
"session_data",
"stop_requested",
) )
def __init__(self, task_id: str, username: str, message: str, conversation_id: Optional[str]): def __init__(
self,
task_id: str,
username: str,
message: str,
conversation_id: Optional[str],
model_key: Optional[str],
thinking_mode: Optional[bool],
run_mode: Optional[str],
max_iterations: Optional[int],
):
self.task_id = task_id self.task_id = task_id
self.username = username self.username = username
self.status = "pending" self.status = "pending"
@ -40,6 +57,12 @@ class TaskRecord:
self.events: deque[Dict[str, Any]] = deque(maxlen=1000) self.events: deque[Dict[str, Any]] = deque(maxlen=1000)
self.thread: Optional[threading.Thread] = None self.thread: Optional[threading.Thread] = None
self.error: Optional[str] = None self.error: Optional[str] = None
self.model_key = model_key
self.thinking_mode = thinking_mode
self.run_mode = run_mode
self.max_iterations = max_iterations
self.session_data: Dict[str, Any] = {}
self.stop_requested: bool = False
class TaskManager: class TaskManager:
@ -50,9 +73,40 @@ class TaskManager:
self._lock = threading.Lock() self._lock = threading.Lock()
# ---- public APIs ---- # ---- public APIs ----
def create_chat_task(self, username: str, message: str, images: List[Any], conversation_id: Optional[str]) -> TaskRecord: def create_chat_task(
self,
username: str,
message: str,
images: List[Any],
conversation_id: Optional[str],
model_key: Optional[str] = None,
thinking_mode: Optional[bool] = None,
run_mode: Optional[str] = None,
max_iterations: Optional[int] = None,
) -> TaskRecord:
if run_mode:
normalized = str(run_mode).lower()
if normalized not in {"fast", "thinking", "deep"}:
raise ValueError("run_mode 只支持 fast/thinking/deep")
run_mode = normalized
# 单用户互斥:禁止并发任务
existing = [t for t in self.list_tasks(username) if t.status in {"pending", "running"}]
if existing:
raise RuntimeError("已有运行中的任务,请稍后再试。")
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
record = TaskRecord(task_id, username, message, conversation_id) record = TaskRecord(task_id, username, message, conversation_id, model_key, thinking_mode, run_mode, max_iterations)
# 记录当前 session 快照,便于后台线程内使用
try:
record.session_data = {
"username": session.get("username"),
"role": session.get("role"),
"is_api_user": session.get("is_api_user"),
"run_mode": session.get("run_mode"),
"thinking_mode": session.get("thinking_mode"),
"model_key": session.get("model_key"),
}
except Exception:
record.session_data = {}
with self._lock: with self._lock:
self._tasks[task_id] = record self._tasks[task_id] = record
thread = threading.Thread(target=self._run_chat_task, args=(record, images), daemon=True) thread = threading.Thread(target=self._run_chat_task, args=(record, images), daemon=True)
@ -77,6 +131,7 @@ class TaskManager:
rec = self.get_task(username, task_id) rec = self.get_task(username, task_id)
if not rec: if not rec:
return False return False
rec.stop_requested = True
# 标记停止标志chat_flow 会检测 stop_flags # 标记停止标志chat_flow 会检测 stop_flags
entry = stop_flags.get(task_id) entry = stop_flags.get(task_id)
if not isinstance(entry, dict): if not isinstance(entry, dict):
@ -107,10 +162,45 @@ class TaskManager:
def _run_chat_task(self, rec: TaskRecord, images: List[Any]): def _run_chat_task(self, rec: TaskRecord, images: List[Any]):
username = rec.username username = rec.username
terminal = None
workspace = None
stop_hint = False
try: try:
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: if not terminal or not workspace:
raise RuntimeError("系统未初始化") raise RuntimeError("系统未初始化")
stop_hint = bool(stop_flags.get(rec.task_id, {}).get("stop"))
# API 传入的模型/模式配置
if rec.model_key:
try:
terminal.set_model(rec.model_key)
except Exception as exc:
debug_log(f"[Task] 设置模型失败 {rec.model_key}: {exc}")
if rec.run_mode:
try:
terminal.set_run_mode(rec.run_mode)
except Exception as exc:
debug_log(f"[Task] 设置运行模式失败 {rec.run_mode}: {exc}")
elif rec.thinking_mode is not None:
try:
terminal.set_run_mode("thinking" if rec.thinking_mode else "fast")
except Exception as exc:
debug_log(f"[Task] 设置思考模式失败: {exc}")
if rec.max_iterations:
try:
terminal.max_iterations_override = int(rec.max_iterations)
except Exception:
terminal.max_iterations_override = None
# 确保会话加载 # 确保会话加载
conversation_id = rec.conversation_id conversation_id = rec.conversation_id
@ -142,8 +232,9 @@ class TaskManager:
) )
# 结束状态 # 结束状态
canceled_flag = rec.stop_requested or stop_hint or bool(stop_flags.get(rec.task_id, {}).get("stop"))
with self._lock: with self._lock:
rec.status = "canceled" if rec.task_id in stop_flags and stop_flags[rec.task_id].get('stop') else "succeeded" rec.status = "canceled" if canceled_flag else "succeeded"
rec.updated_at = time.time() rec.updated_at = time.time()
except Exception as exc: except Exception as exc:
debug_log(f"[Task] 后台任务失败: {exc}") debug_log(f"[Task] 后台任务失败: {exc}")
@ -155,6 +246,12 @@ class TaskManager:
finally: finally:
# 清理 stop_flags # 清理 stop_flags
stop_flags.pop(rec.task_id, None) stop_flags.pop(rec.task_id, None)
# 清理一次性配置
if terminal and hasattr(terminal, "max_iterations_override"):
try:
delattr(terminal, "max_iterations_override")
except Exception:
terminal.max_iterations_override = None
task_manager = TaskManager() task_manager = TaskManager()
@ -192,7 +289,21 @@ def create_task_api():
conversation_id = payload.get("conversation_id") conversation_id = payload.get("conversation_id")
if not message and not images: if not message and not images:
return jsonify({"success": False, "error": "消息不能为空"}), 400 return jsonify({"success": False, "error": "消息不能为空"}), 400
rec = task_manager.create_chat_task(username, message, images, conversation_id) model_key = payload.get("model_key")
thinking_mode = payload.get("thinking_mode")
max_iterations = payload.get("max_iterations")
try:
rec = task_manager.create_chat_task(
username,
message,
images,
conversation_id,
model_key=model_key,
thinking_mode=thinking_mode,
max_iterations=max_iterations,
)
except RuntimeError as exc:
return jsonify({"success": False, "error": str(exc)}), 409
return jsonify({ return jsonify({
"success": True, "success": True,
"data": { "data": {
@ -241,4 +352,3 @@ def cancel_task_api(task_id: str):
if not ok: if not ok:
return jsonify({"success": False, "error": "任务不存在"}), 404 return jsonify({"success": False, "error": "任务不存在"}), 404
return jsonify({"success": True}) return jsonify({"success": True})