diff --git a/api_doc/README.md b/api_doc/README.md new file mode 100644 index 0000000..a0ef1d6 --- /dev/null +++ b/api_doc/README.md @@ -0,0 +1,127 @@ +# API v1 接入文档(后台任务 + 轮询进度 + 文件) + +本目录面向**第三方/多端**(Web、Flutter、脚本、其它服务)对接,目标是: + +- API 调用不依赖 WebSocket:网页刷新/断网/关闭不会影响后台任务运行; +- 客户端使用 **HTTP 轮询**获取与网页端一致的细粒度进度(token 输出、工具块、工具结果等); +- API 账号与网页账号隔离:避免互相抢占任务/文件/对话状态。 + +## 基本信息 + +- API 版本:v1 +- 默认后端地址(本地):`http://localhost:8091` +- 鉴权方式:`Authorization: Bearer ` +- 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/?from=` +4. 停止任务:`POST /api/v1/tasks//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) + +> 将 `` 替换为你的 Bearer Token。 +> 默认后端:`http://localhost:8091`,如不同请修改。 + +### 0)创建对话 + +```bash +curl -sS -X POST \ + -H "Authorization: Bearer " \ + http://localhost:8091/api/v1/conversations +``` + +返回示例: + +```json +{ "success": true, "conversation_id": "conv_20260123_234245_036" } +``` + +### 1)发送消息(创建后台任务) + +```bash +curl -sS -X POST \ + -H "Authorization: Bearer " \ + -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 " \ + "http://localhost:8091/api/v1/tasks/60322db3-f884-4a1e-a9b3-6eeb07fbab47?from=0" +``` + +### 3)停止任务(可选) + +```bash +curl -sS -X POST \ + -H "Authorization: Bearer " \ + 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)**:任务特别长或轮询太慢会导致早期事件被丢弃;请按推荐频率轮询并在客户端持久化你需要的内容。 +- **进程重启不可恢复**:重启后任务/事件会消失,但对话/文件已落盘的不受影响。 diff --git a/api_doc/auth.md b/api_doc/auth.md new file mode 100644 index 0000000..255280f --- /dev/null +++ b/api_doc/auth.md @@ -0,0 +1,119 @@ +# 认证与 API 用户(JSON 手动维护) + +本项目的 API v1 使用 **Bearer Token** 鉴权,且 **API 用户与网页用户完全隔离**。 + +## 1. 鉴权方式(每个请求都要带) + +请求头: + +``` +Authorization: Bearer +``` + +服务端会对 `` 做 `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/users// + 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、时间、返回码); + - 加入速率限制(避免被刷)。 + diff --git a/api_doc/errors.md b/api_doc/errors.md new file mode 100644 index 0000000..609718d --- /dev/null +++ b/api_doc/errors.md @@ -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//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/` 返回的 `data.error` +- 同时遍历 `events` 中的 `type=error` 与 `system_message`,这些往往包含更具体原因 + diff --git a/api_doc/events.md b/api_doc/events.md new file mode 100644 index 0000000..163ac80 --- /dev/null +++ b/api_doc/events.md @@ -0,0 +1,255 @@ +# 事件流说明(轮询返回 events) + +`GET /api/v1/tasks/` 返回的 `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/?from=0`:从头拉取 +- `GET /api/v1/tasks/?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` 与关键工具事件持久化到本地,避免错过 + diff --git a/api_doc/examples.md b/api_doc/examples.md new file mode 100644 index 0000000..663acdc --- /dev/null +++ b/api_doc/examples.md @@ -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="" + +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 = "" +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 = ""; + +async function api(path, options = {}) { + const resp = await fetch(BASE_URL + path, { + ...options, + headers: { + "Authorization": `Bearer ${TOKEN}`, + ...(options.headers || {}) + } + }); + const data = await resp.json(); + if (!resp.ok || !data.success) throw new Error(data.error || resp.statusText); + return data; +} + +const conv = await api("/api/v1/conversations", { method: "POST" }); +const msg = await api("/api/v1/messages", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + conversation_id: conv.conversation_id, + message: "请用中文简要介绍《明日方舟:终末地》", + run_mode: "fast", + max_iterations: 100 + }) +}); + +let offset = 0; +let text = ""; +while (true) { + const poll = await api(`/api/v1/tasks/${msg.task_id}?from=${offset}`); + const info = poll.data; + for (const ev of info.events) { + if (ev.type === "text_chunk") text += ev.data.content || ""; + if (ev.type === "tool_start") console.log("tool:", ev.data.name); + } + offset = info.next_offset; + if (info.status !== "running") break; + await new Promise(r => setTimeout(r, 1000)); +} +console.log("done:", text); +``` + +--- + +## 4) Flutter(Dart)轮询示例(伪代码) + +依赖:`http` 包或 `dio` 包均可,这里用 `http` 表达逻辑。 + +```dart +import 'dart:convert'; +import 'dart:async'; +import 'package:http/http.dart' as http; + +const baseUrl = "http://localhost:8091"; +const token = ""; + +Map headersJson() => { + "Authorization": "Bearer $token", + "Content-Type": "application/json", +}; + +Future 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 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 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 中显示“正在搜索/正在执行工具”等状态。 diff --git a/api_doc/files.md b/api_doc/files.md new file mode 100644 index 0000000..8a1be39 --- /dev/null +++ b/api_doc/files.md @@ -0,0 +1,166 @@ +# 文件接口(上传 / 列目录 / 下载) + +API v1 只提供最小化的文件能力,用于把外部输入文件放入工作区,并下载产物。 + +核心限制: + +- **上传只能落在 `project/user_upload/` 目录及其子目录**; +- 列目录/下载也只允许访问 `user_upload` 内部路径(路径穿越会被拒绝)。 + +> 目录结构见 `auth.md`。 + +## 1) 上传文件 + +### POST `/api/v1/files/upload` + +#### 请求 + +- Headers:`Authorization: Bearer ` +- 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 " \ + -F "file=@./hello.txt" \ + http://localhost:8091/api/v1/files/upload +``` + +上传到子目录: + +```bash +curl -sS -X POST \ + -H "Authorization: Bearer " \ + -F "dir=inputs" \ + -F "file=@./hello.txt" \ + http://localhost:8091/api/v1/files/upload +``` + +## 2) 列出目录内容 + +### GET `/api/v1/files?path=` + +#### 请求 + +- Headers:`Authorization: Bearer ` +- Query: + - `path`:可选,相对 `user_upload` 的目录路径;不传表示根目录 + +#### 响应 + +成功(200): + +```json +{ + "success": true, + "base": "inputs", + "items": [ + { + "name": "hello.txt", + "is_dir": false, + "size": 16, + "modified_at": 1769182550.594278, + "path": "inputs/hello.txt" + } + ] +} +``` + +字段说明: + +- `base`:你请求的目录(相对 `user_upload`),根目录时为 `.` +- `items[].path`:相对 `user_upload` 的路径(用于下载或继续列目录) +- `modified_at`:文件 mtime(UNIX 秒) + +可能错误: + +- `400`:path 不是目录或 path 非法 +- `401`:缺少或无效 token +- `404`:目录不存在 +- `503`:系统未初始化 + +#### curl 示例 + +```bash +curl -sS \ + -H "Authorization: Bearer " \ + "http://localhost:8091/api/v1/files?path=inputs" +``` + +## 3) 下载文件/目录 + +### GET `/api/v1/files/download?path=` + +#### 请求 + +- Headers:`Authorization: Bearer ` +- 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 " \ + "http://localhost:8091/api/v1/files/download?path=hello.txt" +``` + +下载目录(zip): + +```bash +curl -L -o inputs.zip \ + -H "Authorization: Bearer " \ + "http://localhost:8091/api/v1/files/download?path=inputs" +``` + +> zip 内的路径目前是相对 `project/` 的(因此通常会包含 `user_upload/` 前缀)。 + diff --git a/api_doc/messages_tasks.md b/api_doc/messages_tasks.md new file mode 100644 index 0000000..a4cfabb --- /dev/null +++ b/api_doc/messages_tasks.md @@ -0,0 +1,181 @@ +# 发送消息与任务系统(后台运行 + 轮询) + +本节覆盖: + +- 创建对话(可选):`POST /api/v1/conversations` +- 发送消息(创建后台任务):`POST /api/v1/messages` +- 轮询事件:`GET /api/v1/tasks/` +- 停止任务:`POST /api/v1/tasks//cancel` + +## 1) 创建对话 + +### POST `/api/v1/conversations` + +创建一个新的对话,并返回 `conversation_id`。 + +请求: + +- Headers:`Authorization: Bearer ` +- 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 ` +- `Content-Type: application/json` + +Body(JSON): + +| 字段 | 类型 | 必填 | 说明 | +|---|---:|---:|---| +| `message` | string | 是(或 images) | 用户消息文本;`message` 与 `images` 至少其一不为空 | +| `conversation_id` | string | 否 | 不传则自动新建对话;建议显式传入以便客户端管理会话 | +| `model_key` | string/null | 否 | 指定模型 key(可选);具体可用值取决于服务端配置 | +| `run_mode` | string/null | 否 | 运行模式:`"fast"` \| `"thinking"` \| `"deep"`;若传入则优先使用 | +| `thinking_mode` | boolean/null | 否 | 兼容字段:true=thinking,false=fast;当 `run_mode` 为空时才使用 | +| `max_iterations` | integer/null | 否 | 最大迭代次数,默认服务端配置为 **100**(`config.MAX_ITERATIONS_PER_TASK`);传入可覆盖 | +| `images` | string[] | 否 | 图片路径列表(服务端可访问的路径);一般配合特定模型使用 | + +优先级:`run_mode` > `thinking_mode` > 终端当前配置。`run_mode="deep"` 将启用深度思考模式(若模型与配置允许)。 + +#### 响应 + +成功(202): + +```json +{ + "success": true, + "task_id": "60322db3-f884-4a1e-a9b3-6eeb07fbab47", + "conversation_id": "conv_20260123_234245_036", + "status": "running", + "created_at": 1769182965.3038778 +} +``` + +字段说明: + +- `task_id`:任务唯一 ID(用于轮询/停止) +- `conversation_id`:本次任务归属对话 +- `status`:初始为 `running` +- `created_at`:UNIX 时间戳(秒,float) + +可能错误: + +- `400`:`message/images` 都为空;或 `conversation_id` 无法加载 +- `401`:缺少或无效 token +- `409`:该 API 用户已有运行中的任务(禁止并发) +- `503`:系统未初始化/资源繁忙 + +并发冲突示例(409): + +```json +{ "success": false, "error": "已有运行中的任务,请稍后再试。" } +``` + +## 3) 轮询任务事件(增量) + +### GET `/api/v1/tasks/?from=` + +用于以 **HTTP 轮询**方式获取任务执行过程中的流式事件(与 WebSocket 同粒度)。 + +#### 请求 + +- Headers:`Authorization: Bearer ` +- 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//cancel` + +请求停止某个任务。 + +请求: + +- Headers:`Authorization: Bearer ` +- Body:无 + +成功(200): + +```json +{ "success": true } +``` + +说明: + +- 该接口是“请求停止”,任务可能不会立刻停下; +- 停止后的最终状态在轮询里体现:`status` 变为 `canceled` 或 `failed/succeeded`(极少数情况下已接近结束)。 + +可能错误: + +- `401`:缺少或无效 token +- `404`:任务不存在 diff --git a/api_doc/openapi.yaml b/api_doc/openapi.yaml new file mode 100644 index 0000000..8b83926 --- /dev/null +++ b/api_doc/openapi.yaml @@ -0,0 +1,351 @@ +openapi: 3.0.3 +info: + title: Agents API v1 + version: "1.0" + description: | + 后台任务 + HTTP 轮询进度 + 文件上传/下载。 + + - 鉴权:Authorization: Bearer (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" } diff --git a/config/paths.py b/config/paths.py index c19de5b..1281650 100644 --- a/config/paths.py +++ b/config/paths.py @@ -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", ] diff --git a/modules/api_user_manager.py b/modules/api_user_manager.py new file mode 100644 index 0000000..9f74f22 --- /dev/null +++ b/modules/api_user_manager.py @@ -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"] diff --git a/server/api_auth.py b/server/api_auth.py new file mode 100644 index 0000000..f26d761 --- /dev/null +++ b/server/api_auth.py @@ -0,0 +1,44 @@ +"""Bearer Token 认证:用于 API v1。 + +策略: +- 从 Authorization: Bearer 读取明文 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"] diff --git a/server/api_v1.py b/server/api_v1.py new file mode 100644 index 0000000..1643864 --- /dev/null +++ b/server/api_v1.py @@ -0,0 +1,238 @@ +"""API v1:Bearer Token 版轻量接口(后台任务 + 轮询 + 文件)。""" +from __future__ import annotations +import os +import zipfile +from io import BytesIO +from pathlib import Path +from typing import Dict, Any + +from flask import Blueprint, request, jsonify, send_file, session + +from .api_auth import api_token_required +from .tasks import task_manager +from .context import get_user_resources, ensure_conversation_loaded, get_upload_guard +from .files import sanitize_filename_preserve_unicode +from .utils_common import debug_log + +api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1") + + +def _within_uploads(workspace, rel_path: str) -> Path: + base = Path(workspace.uploads_dir).resolve() + rel = rel_path or "" + # 兼容传入包含 user_upload/ 前缀的路径 + if rel.startswith("user_upload/"): + rel = rel.split("user_upload/", 1)[1] + rel = rel.lstrip("/").strip() + target = (base / rel).resolve() + if not str(target).startswith(str(base)): + raise ValueError("非法路径") + return target + + +@api_v1_bp.route("/conversations", methods=["POST"]) +@api_token_required +def create_conversation_api(): + username = session.get("username") + terminal, workspace = get_user_resources(username) + if not terminal: + return jsonify({"success": False, "error": "系统未初始化"}), 503 + result = terminal.create_new_conversation() + if not result.get("success"): + return jsonify({"success": False, "error": result.get("error") or "创建对话失败"}), 500 + return jsonify({"success": True, "conversation_id": result.get("conversation_id")}) + + +@api_v1_bp.route("/messages", methods=["POST"]) +@api_token_required +def send_message_api(): + username = session.get("username") + payload = request.get_json() or {} + message = (payload.get("message") or "").strip() + images = payload.get("images") or [] + conversation_id = payload.get("conversation_id") + model_key = payload.get("model_key") + thinking_mode = payload.get("thinking_mode") + run_mode = payload.get("run_mode") + max_iterations = payload.get("max_iterations") + if not message and not images: + return jsonify({"success": False, "error": "消息不能为空"}), 400 + + terminal, workspace = get_user_resources(username) + if not terminal or not workspace: + return jsonify({"success": False, "error": "系统未初始化"}), 503 + try: + conversation_id, _ = ensure_conversation_loaded(terminal, conversation_id) + except Exception as exc: + return jsonify({"success": False, "error": f"对话加载失败: {exc}"}), 400 + + try: + rec = task_manager.create_chat_task( + username=username, + message=message, + images=images, + conversation_id=conversation_id, + model_key=model_key, + thinking_mode=thinking_mode, + run_mode=run_mode, + max_iterations=max_iterations, + ) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except RuntimeError as exc: + # 并发等业务冲突仍用 409 + return jsonify({"success": False, "error": str(exc)}), 409 + return jsonify({ + "success": True, + "task_id": rec.task_id, + "conversation_id": rec.conversation_id, + "status": rec.status, + "created_at": rec.created_at, + }), 202 + + +@api_v1_bp.route("/tasks/", 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//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"] diff --git a/server/app_legacy.py b/server/app_legacy.py index 5dba7a4..558e559 100644 --- a/server/app_legacy.py +++ b/server/app_legacy.py @@ -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) diff --git a/server/chat_flow.py b/server/chat_flow.py index 7f6a6c4..f9bb276 100644 --- a/server/chat_flow.py +++ b/server/chat_flow.py @@ -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 diff --git a/server/context.py b/server/context.py index 5fd78e6..083966b 100644 --- a/server/context.py +++ b/server/context.py @@ -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 diff --git a/server/security.py b/server/security.py index 1373c6f..00d6439 100644 --- a/server/security.py +++ b/server/security.py @@ -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: diff --git a/server/state.py b/server/state.py index 082c14c..85c0037 100644 --- a/server/state.py +++ b/server/state.py @@ -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", diff --git a/server/tasks.py b/server/tasks.py index 700d085..47aea53 100644 --- a/server/tasks.py +++ b/server/tasks.py @@ -7,6 +7,7 @@ from collections import deque from typing import Dict, Any, Optional, List from flask import Blueprint, request, jsonify +from flask import current_app, session from .auth_helpers import api_login_required, get_current_username from .context import get_user_resources, ensure_conversation_loaded @@ -27,9 +28,25 @@ class TaskRecord: "events", "thread", "error", + "model_key", + "thinking_mode", + "run_mode", + "max_iterations", + "session_data", + "stop_requested", ) - def __init__(self, task_id: str, username: str, message: str, conversation_id: Optional[str]): + def __init__( + self, + task_id: str, + username: str, + message: str, + conversation_id: Optional[str], + model_key: Optional[str], + thinking_mode: Optional[bool], + run_mode: Optional[str], + max_iterations: Optional[int], + ): self.task_id = task_id self.username = username self.status = "pending" @@ -40,6 +57,12 @@ class TaskRecord: self.events: deque[Dict[str, Any]] = deque(maxlen=1000) self.thread: Optional[threading.Thread] = None self.error: Optional[str] = None + self.model_key = model_key + self.thinking_mode = thinking_mode + self.run_mode = run_mode + self.max_iterations = max_iterations + self.session_data: Dict[str, Any] = {} + self.stop_requested: bool = False class TaskManager: @@ -50,9 +73,40 @@ class TaskManager: self._lock = threading.Lock() # ---- public APIs ---- - def create_chat_task(self, username: str, message: str, images: List[Any], conversation_id: Optional[str]) -> TaskRecord: + def create_chat_task( + self, + username: str, + message: str, + images: List[Any], + conversation_id: Optional[str], + model_key: Optional[str] = None, + thinking_mode: Optional[bool] = None, + run_mode: Optional[str] = None, + max_iterations: Optional[int] = None, + ) -> TaskRecord: + if run_mode: + normalized = str(run_mode).lower() + if normalized not in {"fast", "thinking", "deep"}: + raise ValueError("run_mode 只支持 fast/thinking/deep") + run_mode = normalized + # 单用户互斥:禁止并发任务 + existing = [t for t in self.list_tasks(username) if t.status in {"pending", "running"}] + if existing: + raise RuntimeError("已有运行中的任务,请稍后再试。") task_id = str(uuid.uuid4()) - record = TaskRecord(task_id, username, message, conversation_id) + record = TaskRecord(task_id, username, message, conversation_id, model_key, thinking_mode, run_mode, max_iterations) + # 记录当前 session 快照,便于后台线程内使用 + try: + record.session_data = { + "username": session.get("username"), + "role": session.get("role"), + "is_api_user": session.get("is_api_user"), + "run_mode": session.get("run_mode"), + "thinking_mode": session.get("thinking_mode"), + "model_key": session.get("model_key"), + } + except Exception: + record.session_data = {} with self._lock: self._tasks[task_id] = record thread = threading.Thread(target=self._run_chat_task, args=(record, images), daemon=True) @@ -77,6 +131,7 @@ class TaskManager: rec = self.get_task(username, task_id) if not rec: return False + rec.stop_requested = True # 标记停止标志;chat_flow 会检测 stop_flags entry = stop_flags.get(task_id) if not isinstance(entry, dict): @@ -107,10 +162,45 @@ class TaskManager: def _run_chat_task(self, rec: TaskRecord, images: List[Any]): username = rec.username + terminal = None + workspace = None + stop_hint = False try: - terminal, workspace = get_user_resources(username) + # 为后台线程构造最小请求上下文,填充 session + from server.app import app as flask_app + with flask_app.test_request_context(): + try: + for k, v in (rec.session_data or {}).items(): + if v is not None: + session[k] = v + except Exception: + pass + terminal, workspace = get_user_resources(username) if not terminal or not workspace: raise RuntimeError("系统未初始化") + stop_hint = bool(stop_flags.get(rec.task_id, {}).get("stop")) + + # API 传入的模型/模式配置 + if rec.model_key: + try: + terminal.set_model(rec.model_key) + except Exception as exc: + debug_log(f"[Task] 设置模型失败 {rec.model_key}: {exc}") + if rec.run_mode: + try: + terminal.set_run_mode(rec.run_mode) + except Exception as exc: + debug_log(f"[Task] 设置运行模式失败 {rec.run_mode}: {exc}") + elif rec.thinking_mode is not None: + try: + terminal.set_run_mode("thinking" if rec.thinking_mode else "fast") + except Exception as exc: + debug_log(f"[Task] 设置思考模式失败: {exc}") + if rec.max_iterations: + try: + terminal.max_iterations_override = int(rec.max_iterations) + except Exception: + terminal.max_iterations_override = None # 确保会话加载 conversation_id = rec.conversation_id @@ -142,8 +232,9 @@ class TaskManager: ) # 结束状态 + canceled_flag = rec.stop_requested or stop_hint or bool(stop_flags.get(rec.task_id, {}).get("stop")) with self._lock: - rec.status = "canceled" if rec.task_id in stop_flags and stop_flags[rec.task_id].get('stop') else "succeeded" + rec.status = "canceled" if canceled_flag else "succeeded" rec.updated_at = time.time() except Exception as exc: debug_log(f"[Task] 后台任务失败: {exc}") @@ -155,6 +246,12 @@ class TaskManager: finally: # 清理 stop_flags stop_flags.pop(rec.task_id, None) + # 清理一次性配置 + if terminal and hasattr(terminal, "max_iterations_override"): + try: + delattr(terminal, "max_iterations_override") + except Exception: + terminal.max_iterations_override = None task_manager = TaskManager() @@ -192,7 +289,21 @@ def create_task_api(): conversation_id = payload.get("conversation_id") if not message and not images: return jsonify({"success": False, "error": "消息不能为空"}), 400 - rec = task_manager.create_chat_task(username, message, images, conversation_id) + model_key = payload.get("model_key") + thinking_mode = payload.get("thinking_mode") + max_iterations = payload.get("max_iterations") + try: + rec = task_manager.create_chat_task( + username, + message, + images, + conversation_id, + model_key=model_key, + thinking_mode=thinking_mode, + max_iterations=max_iterations, + ) + except RuntimeError as exc: + return jsonify({"success": False, "error": str(exc)}), 409 return jsonify({ "success": True, "data": { @@ -241,4 +352,3 @@ def cancel_task_api(task_id: str): if not ok: return jsonify({"success": False, "error": "任务不存在"}), 404 return jsonify({"success": True}) -