From 6e0df78fef19def24ad549ec6e9571d6b72cd24f Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sat, 24 Jan 2026 18:29:30 +0800 Subject: [PATCH] feat(api): multi-workspace endpoints and container cleanup --- api_doc/README.md | 55 ++- api_doc/auth.md | 139 +++--- api_doc/conversations.md | 106 +++++ api_doc/errors.md | 7 +- api_doc/events.md | 32 +- api_doc/examples.md | 295 +++++------- api_doc/files.md | 180 +++----- api_doc/messages_tasks.md | 100 +++-- api_doc/openapi.yaml | 692 ++++++++++++++++++++++++++++- api_doc/prompts_personalization.md | 72 ++- api_doc/tools.md | 41 ++ api_doc/workspaces.md | 115 +++++ core/main_terminal.py | 6 + modules/api_user_manager.py | 109 ++++- modules/user_container_manager.py | 221 ++++++++- server/_conversation_segment.py | 31 -- server/_socket_segment.py | 2 +- server/api_v1.py | 221 +++++++-- server/auth.py | 11 +- server/context.py | 46 +- server/socket_handlers.py | 2 +- server/tasks.py | 26 +- 22 files changed, 1898 insertions(+), 611 deletions(-) create mode 100644 api_doc/conversations.md create mode 100644 api_doc/tools.md create mode 100644 api_doc/workspaces.md diff --git a/api_doc/README.md b/api_doc/README.md index 6c7b3b9..bcdf5ab 100644 --- a/api_doc/README.md +++ b/api_doc/README.md @@ -1,10 +1,11 @@ -# API v1 接入文档(后台任务 + 轮询进度 + 文件) +# API v1 接入文档(多工作区 + 后台任务 + 轮询进度) 本目录面向**第三方/多端**(Web、Flutter、脚本、其它服务)对接,目标是: - API 调用不依赖 WebSocket:网页刷新/断网/关闭不会影响后台任务运行; - 客户端使用 **HTTP 轮询**获取与网页端一致的细粒度进度(token 输出、工具块、工具结果等); - API 账号与网页账号隔离:避免互相抢占任务/文件/对话状态。 +- **多工作区并行**:同一个 API 用户可以创建多个 `workspace`,每个 workspace 有独立容器/项目目录/对话数据;并发限制以 workspace 为粒度(同一 workspace 禁止并发)。 ## 基本信息 @@ -14,27 +15,30 @@ - API 前缀:`/api/v1` - 数据落盘:对话与文件落盘到 API 用户独立目录(见 `auth.md`) - 任务与事件:**仅内存**保存(进程重启后任务/事件不可恢复) -- 默认最大迭代次数 `max_iterations`:**100**(可在调用 `/api/v1/messages` 时覆盖) +- 默认最大迭代次数 `max_iterations`:**100**(可在调用 `/api/v1/workspaces/{workspace_id}/messages` 时覆盖) -## 只保留的接口(共 6 组能力) +## 接口一览(建议按 workspace 使用) -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` -7. 对话查询/删除:`GET /api/v1/conversations`、`GET/DELETE /api/v1/conversations/{id}` -8. Prompt 管理:`GET/POST /api/v1/prompts`、`GET /api/v1/prompts/{name}` -9. 个性化管理:`GET/POST /api/v1/personalizations`、`GET /api/v1/personalizations/{name}` +1. 工具列表:`GET /api/v1/tools` +2. 工作区管理:`GET/POST /api/v1/workspaces`、`GET/DELETE /api/v1/workspaces/{workspace_id}` +3. 对话(工作区内):`POST/GET /api/v1/workspaces/{workspace_id}/conversations`、`GET/DELETE /api/v1/workspaces/{workspace_id}/conversations/{conversation_id}` +4. 发送消息/启动后台任务(工作区内):`POST /api/v1/workspaces/{workspace_id}/messages` +5. 轮询任务事件:`GET /api/v1/tasks/{task_id}?from=` +6. 停止任务:`POST /api/v1/tasks/{task_id}/cancel` +7. 文件(工作区内,仅 user_upload):`POST /api/v1/workspaces/{workspace_id}/files/upload`、`GET /api/v1/workspaces/{workspace_id}/files`、`GET /api/v1/workspaces/{workspace_id}/files/download` +8. Prompt 管理(用户级共享):`GET/POST /api/v1/prompts`、`GET /api/v1/prompts/{name}` +9. 个性化管理(用户级共享):`GET/POST /api/v1/personalizations`、`GET /api/v1/personalizations/{name}` 10. 模型列表与健康检查:`GET /api/v1/models`、`GET /api/v1/health` 详细参数与返回请看: - `auth.md`:API 用户与 Token、目录结构、安全注意事项 -- `messages_tasks.md`:发送消息/轮询/停止 +- `workspaces.md`:工作区概念、创建/删除/查询 +- `messages_tasks.md`:发送消息/轮询/停止(工作区内发消息) +- `conversations.md`:对话列表/详情/删除(工作区内) - `events.md`:事件流格式与事件类型说明(与 WebSocket 同源) -- `files.md`:上传/列目录/下载 +- `files.md`:上传/列目录/下载(工作区内) +- `tools.md`:工具分类/工具名列表返回格式 - `prompts_personalization.md`:Prompt 与个性化管理 - `errors.md`:HTTP 错误码与常见排查 - `examples.md`:curl/Python/JS/Flutter 示例 @@ -45,21 +49,31 @@ > 将 `` 替换为你的 Bearer Token。 > 默认后端:`http://localhost:8091`,如不同请修改。 -### 0)创建对话 +### 0)创建工作区 ```bash curl -sS -X POST \ -H "Authorization: Bearer " \ - http://localhost:8091/api/v1/conversations + -H "Content-Type: application/json" \ + -d '{ "workspace_id": "ws1" }' \ + http://localhost:8091/api/v1/workspaces +``` + +### 1)创建对话(指定工作区) + +```bash +curl -sS -X POST \ + -H "Authorization: Bearer " \ + http://localhost:8091/api/v1/workspaces/ws1/conversations ``` 返回示例: ```json -{ "success": true, "conversation_id": "conv_20260123_234245_036" } +{ "success": true, "workspace_id": "ws1", "conversation_id": "conv_20260123_234245_036" } ``` -### 1)发送消息(创建后台任务) +### 2)发送消息(创建后台任务,指定工作区) ```bash curl -sS -X POST \ @@ -72,7 +86,7 @@ curl -sS -X POST \ "model_key": null, "max_iterations": 100 # 默认 100,示例显式填写便于对齐 }' \ - http://localhost:8091/api/v1/messages + http://localhost:8091/api/v1/workspaces/ws1/messages ``` 返回示例(202): @@ -82,6 +96,7 @@ curl -sS -X POST \ "success": true, "task_id": "60322db3-f884-4a1e-a9b3-6eeb07fbab47", "conversation_id": "conv_20260123_234245_036", + "workspace_id": "ws1", "status": "running", "created_at": 1769182965.30 } @@ -127,6 +142,6 @@ curl -sS -X POST \ ## 重要限制(务必阅读) -- **单用户禁止并发任务**:同一个 API 用户同一时间只允许一个 `running/pending` 任务。重复发送消息会返回 `409`。 +- **单工作区禁止并发任务**:同一个 API 用户的同一个 `workspace_id` 同一时间只允许一个 `running/pending` 任务。重复调用该 workspace 的 `/messages` 会返回 `409`;不同 workspace 之间可并行。 - **事件缓冲为内存队列(maxlen=1000)**:任务特别长或轮询太慢会导致早期事件被丢弃;请按推荐频率轮询并在客户端持久化你需要的内容。 - **进程重启不可恢复**:重启后任务/事件会消失,但对话/文件已落盘的不受影响。 diff --git a/api_doc/auth.md b/api_doc/auth.md index 255280f..368e031 100644 --- a/api_doc/auth.md +++ b/api_doc/auth.md @@ -1,119 +1,96 @@ -# 认证与 API 用户(JSON 手动维护) +# 鉴权与目录结构(API v1) -本项目的 API v1 使用 **Bearer Token** 鉴权,且 **API 用户与网页用户完全隔离**。 +本项目的 API v1 使用 **Bearer Token** 鉴权,并且与网页端账号体系隔离。 -## 1. 鉴权方式(每个请求都要带) +--- -请求头: +## 1) 鉴权方式 + +所有需要鉴权的 API 请求都必须带 Header: ``` Authorization: Bearer ``` -服务端会对 `` 做 `SHA256`,与 `data/api_users.json` 中保存的 `token_sha256` 进行匹配。 +- `` 是你分配给 API 调用方的明文 token +- 服务端不会保存明文 token,只保存其 SHA256 -## 2. API 用户配置文件 +--- -路径: +## 2) API 用户与 token 的配置文件 -- `data/api_users.json` +文件:`data/api_users.json` -格式(示例): +结构示例: ```json { "users": { - "api_jojo": { - "token_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "api_demo": { + "token_sha256": "9d2f30ce92cee1301dbbdc990aed245a781c34717a1bf51a7a368b6f4ae28f50", "created_at": "2026-01-23", - "note": "for flutter app" + "note": "dev generated" } } } ``` -字段说明: +说明: -- `users`:对象,key 是 API 用户名(建议全小写、只包含字母数字下划线/连字符)。 -- `token_sha256`:必填,token 的 SHA256 十六进制字符串。 -- `created_at` / `note`:可选,仅用于记录与审计。 +- `users` 的 key 是 API 用户名(仅用于区分与落盘路径) +- 调用时只需要 token(用户名不会作为参数传入) -注意: +> 修改 `data/api_users.json` 后通常需要重启服务以生效(取决于当前是否支持热加载)。 -- 当前实现为**启动时加载**该 JSON;如果你修改了 `data/api_users.json`,请**重启服务**使其生效。 +--- -## 3. 生成 token_sha256 的方法 +## 3) 目录结构(多工作区) -### 3.1 用 Python 生成 +API 用户根目录(默认):`api/users//` -```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//` - -典型结构: +### 3.1 用户级共享目录(prompts / personalization) ``` -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/users//shared/ + prompts/ + .txt + personalization/ + personalization.json # 默认个性化(主文件) + .json # 命名个性化 ``` 说明: -- API v1 **没有提供“读取历史对话”的 HTTP 接口**;如确实需要历史,请直接读落盘文件或后续再加只读接口。 +- `prompts` 与 `personalization` 是**用户级共享**:不同 workspace 共用同一份配置; +- `/api/v1/prompts` 与 `/api/v1/personalizations` 操作的就是这里的文件。 -## 5. 安全注意事项 +### 3.2 工作区目录(每个 workspace 独立) -- Bearer Token 等同密码:不要放到 Git、不要发到日志、不要在网页端暴露。 -- 如果怀疑泄露:替换 token(更新 JSON),重启服务;旧 token 会立即失效。 -- 生产环境建议: - - 通过反向代理限制来源 IP; - - 记录 API 调用审计日志(至少记录 user、endpoint、时间、返回码); - - 加入速率限制(避免被刷)。 +``` +api/users//workspaces// + project/ # 工具读写根目录(容器挂载目录) + user_upload/ # 上传目录(API 只允许在这里上传/下载) + data/ # 对话/备份等数据落盘(工作区独立) + conversations/ + conv_*.json + backups/ + prompts -> ../../shared/prompts # 软链(可能不存在) + personalization -> ../../shared/personalization # 软链(可能不存在) + logs/ +``` + +说明: + +- 每个 workspace 都会启动**独立容器**(隔离执行环境); +- `project/` 与 `data/` 都是 workspace 级:对话文件不会跨 workspace 混用; +- `user_upload/` 也是 workspace 级:不同 workspace 互相不可见。 + +--- + +## 4) 并发规则 + +- 并发限制以 workspace 为粒度:同一 API 用户的同一 `workspace_id` 同时只允许一个任务运行。 +- 不同 workspace 可以同时运行多个任务。 diff --git a/api_doc/conversations.md b/api_doc/conversations.md new file mode 100644 index 0000000..3814af1 --- /dev/null +++ b/api_doc/conversations.md @@ -0,0 +1,106 @@ +# 对话查询与删除(工作区内,API v1) + +对话数据是**工作区级**的:同一个 `conversation_id` 只属于一个 workspace。 + +鉴权:所有接口都需要 `Authorization: Bearer ` + +--- + +## 1) 获取对话列表 + +### GET `/api/v1/workspaces/{workspace_id}/conversations?limit=&offset=` + +返回该 workspace 下的对话列表(按更新时间/文件 mtime 倒序)。 + +Query 参数: + +- `limit`:可选,默认 20,范围 1~100 +- `offset`:可选,默认 0 + +成功响应(200): + +```json +{ + "success": true, + "data": [ + { + "id": "conv_20260124_023218_677", + "title": "新对话", + "created_at": "2026-01-24T02:32:18.677Z", + "updated_at": "2026-01-24T02:33:10.123Z", + "run_mode": "fast", + "model_key": "kimi", + "custom_prompt_name": "custom_a", + "personalization_name": "biz_mobile", + "workspace_id": "ws1", + "messages_count": 6 + } + ], + "total": 42 +} +``` + +常见错误: + +- `400`:workspace_id 不合法 +- `401`:缺少或无效 token +- `503`:系统未初始化 + +--- + +## 2) 获取单个对话(元数据或完整内容) + +### GET `/api/v1/workspaces/{workspace_id}/conversations/{conversation_id}?full=0|1` + +Query 参数: + +- `full`:可选,默认 `0` + - `0`:只返回对话文件内容,但 `messages` 字段会被置为 `null`(避免数据量太大) + - `1`:返回完整对话内容(包含 `messages`) + +成功响应(200,full=0): + +```json +{ + "success": true, + "workspace_id": "ws1", + "data": { + "id": "conv_20260124_023218_677", + "title": "新对话", + "created_at": "2026-01-24T02:32:18.677Z", + "updated_at": "2026-01-24T02:33:10.123Z", + "messages": null, + "metadata": { }, + "token_statistics": { } + } +} +``` + +常见错误: + +- `404`:对话不存在(该 workspace 下找不到文件) +- `500`:对话 JSON 解析失败(损坏/写入中断等) + +--- + +## 3) 删除对话 + +### DELETE `/api/v1/workspaces/{workspace_id}/conversations/{conversation_id}` + +删除指定对话(仅影响当前 workspace 的对话文件)。 + +成功响应(200): + +```json +{ + "success": true, + "workspace_id": "ws1", + "message": "对话已删除: conv_20260124_023218_677" +} +``` + +注意事项: + +- 删除是“硬删除”(删除对话文件),当前实现不提供回收站。 +- 若该 workspace 正在运行任务,建议客户端禁用删除操作(避免对话文件与运行状态不一致)。 + diff --git a/api_doc/errors.md b/api_doc/errors.md index 609718d..23c920f 100644 --- a/api_doc/errors.md +++ b/api_doc/errors.md @@ -36,12 +36,12 @@ 原因: -- 同一个 API 用户在任务未结束时再次调用 `/api/v1/messages` +- 同一个 API 用户在**同一个 workspace** 的任务未结束时再次调用 `/api/v1/workspaces/{workspace_id}/messages` 处理建议: - 客户端 UI:禁用“发送”按钮,直到轮询显示 `status != running` -- 或先调用 `/api/v1/tasks//cancel` 再发送新任务 +- 或先调用 `/api/v1/tasks/{task_id}/cancel` 再发送新任务 ### 2.3 轮询一直 running,但没有 events @@ -65,6 +65,5 @@ 建议: -- 读取 `GET /api/v1/tasks/` 返回的 `data.error` +- 读取 `GET /api/v1/tasks/{task_id}` 返回的 `data.error` - 同时遍历 `events` 中的 `type=error` 与 `system_message`,这些往往包含更具体原因 - diff --git a/api_doc/events.md b/api_doc/events.md index 163ac80..3c58cac 100644 --- a/api_doc/events.md +++ b/api_doc/events.md @@ -1,6 +1,6 @@ # 事件流说明(轮询返回 events) -`GET /api/v1/tasks/` 返回的 `events` 字段,是一个按时间顺序(`idx` 递增)的**事件流**。 +`GET /api/v1/tasks/{task_id}` 返回的 `events` 字段,是一个按时间顺序(`idx` 递增)的**事件流**。 本项目的目标是:**与网页端 WebSocket 事件保持同一粒度**。因此你会看到: @@ -34,8 +34,9 @@ 轮询接口支持 `from` 参数: -- `GET /api/v1/tasks/?from=0`:从头拉取 -- `GET /api/v1/tasks/?from=next_offset`:增量拉取 +- `GET /api/v1/tasks/{task_id}?from=0`:从头拉取 +- `GET /api/v1/tasks/{task_id}?from=next_offset`:增量拉取 +(路径里的 `{task_id}` 为任务 id) 正确做法: @@ -129,26 +130,10 @@ AI 新一轮回复开始(一次用户消息可能触发多次迭代,但 UI 工具链路通常会出现如下事件: -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_..." - } -} -``` +1) `tool_preparing`(模型开始输出 tool_calls 时) +2) `tool_intent`(从增量 arguments 中抽取 intent 字段) +3) `tool_start`(真正执行工具时) +4) `update_action`(执行进度与结果状态更新) #### `tool_preparing` @@ -252,4 +237,3 @@ AI 新一轮回复开始(一次用户消息可能触发多次迭代,但 UI - 服务端每个任务保存最近 **1000** 条事件(队列会丢弃更早的数据) - 建议轮询间隔 `0.5s ~ 2s` - 长任务建议客户端把 `text_chunk` 与关键工具事件持久化到本地,避免错过 - diff --git a/api_doc/examples.md b/api_doc/examples.md index 663acdc..9ebedad 100644 --- a/api_doc/examples.md +++ b/api_doc/examples.md @@ -1,71 +1,77 @@ # 示例(curl / Python / JS / Flutter) -本文提供“最小可用”的端到端示例:创建对话 → 发送消息 → 轮询输出 →(可选)停止任务 → 文件上传/下载。 +默认服务端:`http://localhost:8091` -请先阅读 `auth.md` 并准备好 token。 +通用 Header: -## 0. 统一变量 +- `Authorization: Bearer ` -- `BASE_URL`:例如 `http://localhost:8091` -- `TOKEN`:你的 Bearer Token(明文) +下文示例使用: + +- `TOKEN=` +- `WS=ws1` --- -## 1) curl:完整对话流程 - -### 1.1 创建对话 +## 1) curl:完整流程(工作区 + 对话 + 发送 + 轮询) ```bash BASE_URL="http://localhost:8091" TOKEN="" +WS="ws1" -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 +# 1) 创建/确保工作区存在 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" -``` + -d "{\"workspace_id\":\"$WS\"}" \ + "$BASE_URL/api/v1/workspaces" -假设返回: +# 2) 创建对话 +CONV=$(curl -sS -X POST \ + -H "Authorization: Bearer $TOKEN" \ + "$BASE_URL/api/v1/workspaces/$WS/conversations" \ + | python3 -c 'import sys,json; print(json.load(sys.stdin)["conversation_id"])') +echo "CONV=$CONV" -```json -{ "success": true, "task_id": "60322db3-...", "status": "running", "conversation_id": "conv_...", "created_at": 1769182965.30 } -``` +# 3) 发送消息 -> task_id +TASK=$(curl -sS -X POST \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"conversation_id\":\"$CONV\",\"message\":\"介绍明日方舟:终末地\",\"run_mode\":\"fast\"}" \ + "$BASE_URL/api/v1/workspaces/$WS/messages" \ + | python3 -c 'import sys,json; print(json.load(sys.stdin)["task_id"])') +echo "TASK=$TASK" -### 1.3 轮询(建议脚本处理 next_offset) +# 4) 轮询增量事件 +FROM=0 +while true; do + RESP=$(curl -sS -H "Authorization: Bearer $TOKEN" "$BASE_URL/api/v1/tasks/$TASK?from=$FROM") + STATUS=$(echo "$RESP" | python3 -c 'import sys,json; print(json.load(sys.stdin)["data"]["status"])') + NEXT=$(echo "$RESP" | python3 -c 'import sys,json; print(json.load(sys.stdin)["data"]["next_offset"])') -```bash -TASK_ID="60322db3-..." -OFFSET=0 + # 把本轮新增 text_chunk 直接输出(按 token 拼接) + echo "$RESP" | python3 - <<'PY' +import sys,json +obj=json.load(sys.stdin) +for ev in obj.get("data",{}).get("events",[]): + if ev.get("type")=="text_chunk": + print(ev.get("data",{}).get("content",""), end="") +PY -curl -sS -H "Authorization: Bearer $TOKEN" \ - "$BASE_URL/api/v1/tasks/$TASK_ID?from=$OFFSET" + if [ "$STATUS" != "running" ]; then + echo + echo "status=$STATUS" + break + fi + FROM=$NEXT + sleep 0.5 +done ``` --- -## 2) Python:轮询并实时拼接文本 - -依赖:`pip install requests` +## 2) Python:requests(推荐写法) ```python import time @@ -73,192 +79,137 @@ import requests BASE_URL = "http://localhost:8091" TOKEN = "" +WS = "ws1" + 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 = requests.post(BASE_URL + path, headers={**H, "Content-Type": "application/json"}, json=payload, 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) +def get_json(path): + r = requests.get(BASE_URL + path, 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"] +# workspace +post_json("/api/v1/workspaces", {"workspace_id": WS}) -# 2) send message -task = post_json("/api/v1/messages", { - "conversation_id": conversation_id, - "message": "请用中文简要介绍《明日方舟:终末地》", +# conversation +conv = requests.post(BASE_URL + f"/api/v1/workspaces/{WS}/conversations", headers=H, timeout=30).json() +conv_id = conv["conversation_id"] + +# message -> task +task = post_json(f"/api/v1/workspaces/{WS}/messages", { + "conversation_id": conv_id, + "message": "介绍明日方舟:终末地", "run_mode": "fast", - "max_iterations": 100 + "max_iterations": 100, }) task_id = task["task_id"] -# 3) poll events +# poll 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": + poll = get_json(f"/api/v1/tasks/{task_id}?from={offset}")["data"] + offset = poll["next_offset"] + for ev in poll["events"]: + if ev["type"] == "text_chunk": + print(ev["data"]["content"], end="", flush=True) + if poll["status"] != "running": + print() + print("status:", poll["status"]) 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)) + time.sleep(0.5) ``` --- -## 3) JavaScript(浏览器/Node)要点 - -浏览器端直接跨域请求时,请确保服务端允许 CORS(当前服务端已启用 CORS)。请求示例: +## 3) JS:fetch(浏览器/Node) ```js const BASE_URL = "http://localhost:8091"; const TOKEN = ""; +const WS = "ws1"; -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; +async function api(path, { method = "GET", body } = {}) { + const headers = { Authorization: `Bearer ${TOKEN}` }; + if (body !== undefined) headers["Content-Type"] = "application/json"; + const res = await fetch(BASE_URL + path, { method, headers, body: body ? JSON.stringify(body) : undefined }); + if (!res.ok) throw new Error(await res.text()); + return await res.json(); } -const conv = await api("/api/v1/conversations", { method: "POST" }); -const msg = await api("/api/v1/messages", { +await api("/api/v1/workspaces", { method: "POST", body: { workspace_id: WS } }); +const conv = await api(`/api/v1/workspaces/${WS}/conversations`, { method: "POST" }); +const msg = await api(`/api/v1/workspaces/${WS}/messages`, { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - conversation_id: conv.conversation_id, - message: "请用中文简要介绍《明日方舟:终末地》", - run_mode: "fast", - max_iterations: 100 - }) + body: { conversation_id: conv.conversation_id, message: "你好", run_mode: "fast" }, }); -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); +let from = 0; +for (;;) { + const poll = await api(`/api/v1/tasks/${msg.task_id}?from=${from}`); + const data = poll.data; + from = data.next_offset; + for (const ev of data.events) { + if (ev.type === "text_chunk") process.stdout?.write?.(ev.data.content) ?? console.log(ev.data.content); } - offset = info.next_offset; - if (info.status !== "running") break; - await new Promise(r => setTimeout(r, 1000)); + if (data.status !== "running") break; + await new Promise((r) => setTimeout(r, 500)); } -console.log("done:", text); ``` --- -## 4) Flutter(Dart)轮询示例(伪代码) - -依赖:`http` 包或 `dio` 包均可,这里用 `http` 表达逻辑。 +## 4) Flutter:http(示意) ```dart import 'dart:convert'; -import 'dart:async'; import 'package:http/http.dart' as http; const baseUrl = "http://localhost:8091"; const token = ""; +const ws = "ws1"; -Map headersJson() => { +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 main() async { + await http.post(Uri.parse("$baseUrl/api/v1/workspaces"), + headers: headersJson(), body: jsonEncode({"workspace_id": ws})); -Future sendMessage(String convId, String message) async { - final resp = await http.post(Uri.parse("$baseUrl/api/v1/messages"), + final convResp = await http.post(Uri.parse("$baseUrl/api/v1/workspaces/$ws/conversations"), + headers: {"Authorization": "Bearer $token"}); + final conv = jsonDecode(convResp.body); + final convId = conv["conversation_id"]; + + final msgResp = await http.post(Uri.parse("$baseUrl/api/v1/workspaces/$ws/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"]; -} + body: jsonEncode({"conversation_id": convId, "message": "你好", "run_mode": "fast"})); + final msg = jsonDecode(msgResp.body); + final taskId = msg["task_id"]; -Stream pollText(String taskId) async* { - int offset = 0; + var from = 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) { + final pollResp = await http.get( + Uri.parse("$baseUrl/api/v1/tasks/$taskId?from=$from"), + headers: {"Authorization": "Bearer $token"}, + ); + final poll = jsonDecode(pollResp.body)["data"]; + from = poll["next_offset"]; + for (final ev in poll["events"]) { if (ev["type"] == "text_chunk") { - yield (ev["data"]["content"] ?? ""); + // append to UI + print(ev["data"]["content"]); } } - offset = info["next_offset"]; - if (info["status"] != "running") break; - await Future.delayed(Duration(milliseconds: 800)); + if (poll["status"] != "running") break; + await Future.delayed(const Duration(milliseconds: 500)); } } ``` -提示: - -- Flutter UI 展示建议:把 `pollText()` 的输出 append 到一个 `StringBuffer`,并用 `setState()`/状态管理更新。 -- 同时可以订阅 tool_* 事件在 UI 中显示“正在搜索/正在执行工具”等状态。 diff --git a/api_doc/files.md b/api_doc/files.md index 8a1be39..27e56fb 100644 --- a/api_doc/files.md +++ b/api_doc/files.md @@ -1,166 +1,114 @@ -# 文件接口(上传 / 列目录 / 下载) +# 文件 API(工作区内,仅 user_upload) -API v1 只提供最小化的文件能力,用于把外部输入文件放入工作区,并下载产物。 +文件读写是**工作区级**的:上传/列目录/下载都发生在指定 workspace 的 `project/user_upload/` 下。 -核心限制: +鉴权:所有接口均需要 `Authorization: Bearer ` -- **上传只能落在 `project/user_upload/` 目录及其子目录**; -- 列目录/下载也只允许访问 `user_upload` 内部路径(路径穿越会被拒绝)。 - -> 目录结构见 `auth.md`。 +--- ## 1) 上传文件 -### POST `/api/v1/files/upload` +### POST `/api/v1/workspaces/{workspace_id}/files/upload` -#### 请求 +说明: -- Headers:`Authorization: Bearer ` -- Content-Type:`multipart/form-data` -- Form 字段: - - `file`:必填,上传的文件对象 - - `filename`:可选,覆盖原文件名(会做安全清洗) - - `dir`:可选,`user_upload` 下的子目录(例如 `docs/`);不传则落在 `user_upload/` 根目录 +- 不指定“任意路径上传”,只能上传到该 workspace 的 `user_upload` 目录(以及其子目录)。 -> 如果你希望“完全不允许指定目录”,客户端请不要传 `dir`,统一上传到根目录即可。 +请求(multipart/form-data): -#### 响应 +- Headers: + - `Authorization: Bearer ` +- Form: + - `file`:文件本体(必填) + - `filename`:可选,自定义文件名(服务端会清洗) + - `dir`:可选,user_upload 下子目录(如 `inputs` / `a/b`) -成功(200): +成功响应(200): ```json { "success": true, - "path": "docs/spec.pdf", - "filename": "spec.pdf", - "size": 12345, - "sha256": "..." + "workspace_id": "ws1", + "path": "inputs/hello.txt", + "filename": "hello.txt", + "size": 16, + "sha256": "0f5bd6..." } ``` -字段说明: - -- `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=` +### GET `/api/v1/workspaces/{workspace_id}/files?path=` -#### 请求 +说明: -- Headers:`Authorization: Bearer ` -- Query: - - `path`:可选,相对 `user_upload` 的目录路径;不传表示根目录 +- `path` 是 `user_upload` 下的相对路径;不传或传空则表示根目录。 -#### 响应 - -成功(200): +成功响应(200): ```json { "success": true, + "workspace_id": "ws1", "base": "inputs", "items": [ - { - "name": "hello.txt", - "is_dir": false, - "size": 16, - "modified_at": 1769182550.594278, - "path": "inputs/hello.txt" - } + { "name": "hello.txt", "is_dir": false, "size": 16, "modified_at": 1769182550.59, "path": "inputs/hello.txt" }, + { "name": "images", "is_dir": true, "size": 64, "modified_at": 1769182552.01, "path": "inputs/images" } ] } ``` -字段说明: +常见错误: -- `base`:你请求的目录(相对 `user_upload`),根目录时为 `.` -- `items[].path`:相对 `user_upload` 的路径(用于下载或继续列目录) -- `modified_at`:文件 mtime(UNIX 秒) +- `404`:路径不存在 +- `400`:path 指向文件(不是目录)或非法路径 -可能错误: +--- -- `400`:path 不是目录或 path 非法 -- `401`:缺少或无效 token -- `404`:目录不存在 -- `503`:系统未初始化 +## 3) 下载文件或目录 -#### curl 示例 +### GET `/api/v1/workspaces/{workspace_id}/files/download?path=` + +说明: + +- `path` 是 `user_upload` 下相对路径 +- 若 `path` 是文件:直接下载该文件 +- 若 `path` 是目录:服务端打包为 zip 返回 + +成功响应: + +- 文件:`Content-Type: application/octet-stream` +- 目录:`Content-Type: application/zip` + +--- + +## curl 示例 + +上传到 `ws1/user_upload/inputs/hello.txt`: + +```bash +curl -sS -X POST \ + -H "Authorization: Bearer " \ + -F "file=@hello.txt" \ + -F "dir=inputs" \ + http://localhost:8091/api/v1/workspaces/ws1/files/upload +``` + +列出 `ws1/user_upload/inputs`: ```bash curl -sS \ -H "Authorization: Bearer " \ - "http://localhost:8091/api/v1/files?path=inputs" + "http://localhost:8091/api/v1/workspaces/ws1/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): +下载目录 `ws1/user_upload/inputs`: ```bash curl -L -o inputs.zip \ -H "Authorization: Bearer " \ - "http://localhost:8091/api/v1/files/download?path=inputs" + "http://localhost:8091/api/v1/workspaces/ws1/files/download?path=inputs" ``` -> zip 内的路径目前是相对 `project/` 的(因此通常会包含 `user_upload/` 前缀)。 - diff --git a/api_doc/messages_tasks.md b/api_doc/messages_tasks.md index 90de2d4..3a9758c 100644 --- a/api_doc/messages_tasks.md +++ b/api_doc/messages_tasks.md @@ -2,40 +2,61 @@ 本节覆盖: -- 创建对话(可选):`POST /api/v1/conversations` -- 发送消息(创建后台任务):`POST /api/v1/messages` -- 轮询事件:`GET /api/v1/tasks/` -- 停止任务:`POST /api/v1/tasks//cancel` +- 创建对话(工作区内,可选):`POST /api/v1/workspaces/{workspace_id}/conversations` +- 发送消息(工作区内,创建后台任务):`POST /api/v1/workspaces/{workspace_id}/messages` +- 轮询事件:`GET /api/v1/tasks/{task_id}?from=` +- 停止任务:`POST /api/v1/tasks/{task_id}/cancel` -## 1) 创建对话 +重要限制: -### POST `/api/v1/conversations` +- **单工作区禁止并发任务**:同一个 API 用户的同一个 workspace 同一时间只允许 1 个 `running/pending` 任务;否则返回 `409`。 +- 不同 workspace 之间可以并行。 + +--- + +## 1) 创建对话(工作区内) + +### POST `/api/v1/workspaces/{workspace_id}/conversations` 创建一个新的对话,并返回 `conversation_id`。 请求: - Headers:`Authorization: Bearer ` -- Body:无 +- Path:`workspace_id` +- Body:可选(JSON),用于在创建对话时绑定自定义 prompt / personalization(不传则使用默认) + +Body(JSON,可选): + +```json +{ + "prompt_name": "prompt_strict", + "personalization_name": "biz_mobile" +} +``` 响应(200): ```json { "success": true, + "workspace_id": "ws1", "conversation_id": "conv_20260123_234245_036" } ``` -可能错误: +常见错误: +- `400`:workspace_id 不合法 - `401`:缺少或无效 token - `503`:系统未初始化(资源繁忙/容器不可用等) - `500`:创建失败 -## 2) 发送消息(创建后台任务) +--- -### POST `/api/v1/messages` +## 2) 发送消息(工作区内,创建后台任务) + +### POST `/api/v1/workspaces/{workspace_id}/messages` 创建一个后台任务执行智能体流程,并立即返回 `task_id`。 @@ -46,21 +67,25 @@ Headers: - `Authorization: Bearer ` - `Content-Type: application/json` +Path: + +- `workspace_id`:工作区 id(必填) + Body(JSON): | 字段 | 类型 | 必填 | 说明 | |---|---:|---:|---| | `message` | string | 是(或 images) | 用户消息文本;`message` 与 `images` 至少其一不为空 | -| `conversation_id` | string | 否 | 不传则自动新建对话;建议显式传入以便客户端管理会话 | +| `conversation_id` | string | 否 | 不传则自动新建对话(在该 workspace 内) | | `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`);传入可覆盖 | -| `prompt_name` | string/null | 否 | 选择自定义主 prompt(存放于 `data/prompts/.txt`);若不存在返回 404 | -| `personalization_name` | string/null | 否 | 选择个性化配置(`data/personalization/.json`);若不存在返回 404 | +| `run_mode` | string/null | 否 | 运行模式:`fast` \| `thinking` \| `deep` | +| `thinking_mode` | boolean/null | 否 | 兼容字段:true=thinking,false=fast;仅当 `run_mode` 为空时生效 | +| `max_iterations` | integer/null | 否 | 最大迭代次数,默认 **100**;传入可覆盖 | +| `prompt_name` | string/null | 否 | 自定义主 prompt 名称(用户级共享,见 `prompts_personalization.md`);若不存在返回 404 | +| `personalization_name` | string/null | 否 | 个性化配置名称(用户级共享,见 `prompts_personalization.md`);若不存在返回 404 | | `images` | string[] | 否 | 图片路径列表(服务端可访问的路径);一般配合特定模型使用 | -优先级:`run_mode` > `thinking_mode` > 终端当前配置。`run_mode="deep"` 将启用深度思考模式(若模型与配置允许)。 +优先级:`run_mode` > `thinking_mode` > 终端当前配置。 #### 响应 @@ -71,36 +96,27 @@ Body(JSON): "success": true, "task_id": "60322db3-f884-4a1e-a9b3-6eeb07fbab47", "conversation_id": "conv_20260123_234245_036", + "workspace_id": "ws1", "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 用户已有运行中的任务(禁止并发) +- `404`:prompt/personalization 不存在 +- `409`:该 workspace 已有运行中的任务(禁止并发) - `503`:系统未初始化/资源繁忙 -并发冲突示例(409): - -```json -{ "success": false, "error": "已有运行中的任务,请稍后再试。" } -``` +--- ## 3) 轮询任务事件(增量) -### GET `/api/v1/tasks/?from=` +### GET `/api/v1/tasks/{task_id}?from=` -用于以 **HTTP 轮询**方式获取任务执行过程中的流式事件(与 WebSocket 同粒度)。 +用于以 **HTTP 轮询**方式获取任务执行过程中的流式事件(与网页端 WebSocket 同粒度)。 #### 请求 @@ -115,6 +131,7 @@ Body(JSON): "success": true, "data": { "task_id": "60322db3-f884-4a1e-a9b3-6eeb07fbab47", + "workspace_id": "ws1", "status": "running", "created_at": 1769182965.30, "updated_at": 1769182968.15, @@ -140,24 +157,21 @@ Body(JSON): - `events`:从 `idx>=from` 的事件列表(按 idx 升序) - `next_offset`:建议下一次轮询的 `from` 值 -可能错误: - -- `401`:缺少或无效 token -- `404`:任务不存在(或不属于该 API 用户) - #### 推荐轮询策略 - 轮询间隔:`0.5s ~ 2s`(任务越密集越推荐更快) - 客户端必须: 1) 保存 `next_offset` - 2) 追加处理 events + 2) 追加处理 `events` 3) 当 `status != running` 时停止轮询 > 重要:服务端事件缓冲是 `deque(maxlen=1000)`,轮询过慢会丢失早期事件;客户端应自行落盘你需要的内容。 +--- + ## 4) 停止任务 -### POST `/api/v1/tasks//cancel` +### POST `/api/v1/tasks/{task_id}/cancel` 请求停止某个任务。 @@ -175,9 +189,5 @@ Body(JSON): 说明: - 该接口是“请求停止”,任务可能不会立刻停下; -- 停止后的最终状态在轮询里体现:`status` 变为 `canceled` 或 `failed/succeeded`(极少数情况下已接近结束)。 +- 停止后的最终状态以轮询结果为准(`status` 变为 `canceled` / `failed` / `succeeded`)。 -可能错误: - -- `401`:缺少或无效 token -- `404`:任务不存在 diff --git a/api_doc/openapi.yaml b/api_doc/openapi.yaml index 8b83926..4e961f1 100644 --- a/api_doc/openapi.yaml +++ b/api_doc/openapi.yaml @@ -3,10 +3,11 @@ info: title: Agents API v1 version: "1.0" description: | - 后台任务 + HTTP 轮询进度 + 文件上传/下载。 + 多工作区 + 后台任务 + HTTP 轮询进度 + 文件上传/下载。 - 鉴权:Authorization: Bearer (token 为自定义字符串,服务端存 SHA256) - 事件流:与网页端 WebSocket 同粒度(text_chunk、tool_start 等) + - 多工作区:所有对话/消息/文件都在指定 workspace 下进行 servers: - url: http://localhost:8091 @@ -24,12 +25,27 @@ components: error: { type: string, example: "无效的 Token" } required: [success, error] + CreateConversationRequest: + type: object + description: 可选参数;用于在创建对话时绑定 prompt/personalization + properties: + prompt_name: + type: string + nullable: true + description: 自定义主 prompt 名称(用户级共享,shared/prompts/.txt) + personalization_name: + type: string + nullable: true + description: 个性化配置名称(用户级共享,shared/personalization/.json) + additionalProperties: false + CreateConversationResponse: type: object properties: success: { type: boolean, example: true } + workspace_id: { type: string, example: "ws1" } conversation_id: { type: string, example: "conv_20260123_234245_036" } - required: [success, conversation_id] + required: [success, workspace_id, conversation_id] SendMessageRequest: type: object @@ -60,17 +76,197 @@ components: type: integer nullable: true description: 最大迭代次数(最多允许多少轮模型调用/工具循环) + prompt_name: + type: string + nullable: true + description: 自定义主 prompt 名称(用户级共享,shared/prompts/.txt) + personalization_name: + type: string + nullable: true + description: 个性化配置名称(用户级共享,shared/personalization/.json) additionalProperties: false + PersonalizationConfig: + type: object + description: 个性化配置结构(content),服务端会规范化与回落默认值 + properties: + enabled: { type: boolean, default: false } + self_identify: { type: string, default: "" } + user_name: { type: string, default: "" } + profession: { type: string, default: "" } + tone: { type: string, default: "" } + considerations: + type: array + items: { type: string } + default: [] + description: 最多 10 条,每条最多 50 字符 + thinking_interval: + type: integer + nullable: true + description: 1~50;非法值会被置空 + disabled_tool_categories: + type: array + items: { type: string } + default: [] + default_run_mode: + type: string + nullable: true + description: fast/thinking/deep 或 null + auto_generate_title: { type: boolean, default: true } + tool_intent_enabled: { type: boolean, default: true } + default_model: + type: string + default: kimi + description: kimi/deepseek/qwen3-max/qwen3-vl-plus + additionalProperties: true + example: + enabled: true + user_name: "Jojo" + tone: "企业商务" + considerations: ["优先给出结论,再补充依据"] + + PromptUpsertRequest: + type: object + properties: + name: { type: string, example: "prompt_strict" } + content: { type: string, example: "严格模式:..." } + required: [name, content] + additionalProperties: false + + PromptGetResponse: + type: object + properties: + success: { type: boolean, example: true } + name: { type: string, example: "prompt_strict" } + content: { type: string, example: "严格模式:..." } + required: [success, name, content] + + PromptListItem: + type: object + properties: + name: { type: string, example: "prompt_strict" } + size: { type: integer, example: 123 } + updated_at: { type: number, format: double, example: 1769182550.59 } + required: [name, size, updated_at] + + PromptListResponse: + type: object + properties: + success: { type: boolean, example: true } + items: + type: array + items: { $ref: "#/components/schemas/PromptListItem" } + required: [success, items] + + PersonalizationUpsertRequest: + type: object + properties: + name: { type: string, example: "biz_mobile" } + content: { $ref: "#/components/schemas/PersonalizationConfig" } + required: [name, content] + additionalProperties: false + + PersonalizationGetResponse: + type: object + properties: + success: { type: boolean, example: true } + name: { type: string, example: "biz_mobile" } + content: { $ref: "#/components/schemas/PersonalizationConfig" } + required: [success, name, content] + + PersonalizationListItem: + type: object + properties: + name: { type: string, example: "biz_mobile" } + size: { type: integer, example: 345 } + updated_at: { type: number, format: double, example: 1769182550.59 } + required: [name, size, updated_at] + + PersonalizationListResponse: + type: object + properties: + success: { type: boolean, example: true } + items: + type: array + items: { $ref: "#/components/schemas/PersonalizationListItem" } + required: [success, items] + + ConversationListItem: + type: object + properties: + workspace_id: { type: string, example: "ws1" } + id: { type: string, example: "conv_20260124_023218_677" } + title: { type: string, example: "新对话" } + created_at: { type: string, example: "2026-01-24T02:32:18.677Z" } + updated_at: { type: string, example: "2026-01-24T02:33:10.123Z" } + run_mode: { type: string, nullable: true, example: "fast" } + model_key: { type: string, nullable: true, example: "kimi" } + custom_prompt_name: { type: string, nullable: true, example: "custom_a" } + personalization_name: { type: string, nullable: true, example: "biz_mobile" } + messages_count: { type: integer, example: 6 } + required: [workspace_id, id, title, created_at, updated_at, messages_count] + + ConversationListResponse: + type: object + properties: + success: { type: boolean, example: true } + data: + type: array + items: { $ref: "#/components/schemas/ConversationListItem" } + total: { type: integer, example: 42 } + required: [success, data, total] + + ConversationGetResponse: + type: object + properties: + success: { type: boolean, example: true } + workspace_id: { type: string, example: "ws1" } + data: + type: object + additionalProperties: true + required: [success, workspace_id, data] + + DeleteConversationResponse: + type: object + properties: + success: { type: boolean, example: true } + workspace_id: { type: string, example: "ws1" } + message: { type: string, example: "对话已删除: conv_..." } + required: [success, workspace_id, message] + + ModelsResponse: + type: object + properties: + success: { type: boolean, example: true } + items: + type: array + items: + type: object + properties: + model_key: { type: string, example: "kimi" } + name: { type: string, example: "Kimi" } + supports_thinking: { type: boolean, example: true } + fast_only: { type: boolean, example: false } + required: [model_key, name, supports_thinking, fast_only] + required: [success, items] + + HealthResponse: + type: object + properties: + success: { type: boolean, example: true } + status: { type: string, example: "ok" } + required: [success, status] + 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" } + workspace_id: { type: string, example: "ws1" } status: { type: string, example: "running" } created_at: { type: number, format: double, example: 1769182965.3038778 } - required: [success, task_id, conversation_id, status, created_at] + required: [success, task_id, conversation_id, workspace_id, status, created_at] TaskEvent: type: object @@ -91,6 +287,7 @@ components: type: object properties: task_id: { type: string } + workspace_id: { type: string, nullable: true } status: { type: string, example: "running" } created_at: { type: number, format: double } updated_at: { type: number, format: double } @@ -112,11 +309,12 @@ components: type: object properties: success: { type: boolean, example: true } + workspace_id: { type: string, example: "ws1" } 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] + required: [success, workspace_id, path, filename] ListFilesItem: type: object @@ -132,20 +330,209 @@ components: type: object properties: success: { type: boolean, example: true } + workspace_id: { type: string, example: "ws1" } base: { type: string, example: "." } items: type: array items: { $ref: "#/components/schemas/ListFilesItem" } - required: [success, base, items] + required: [success, workspace_id, base, items] + + WorkspaceCreateRequest: + type: object + properties: + workspace_id: + type: string + description: 仅允许字母/数字/._-,长度1-40 + required: [workspace_id] + additionalProperties: false + + WorkspaceInfo: + type: object + properties: + workspace_id: { type: string, example: "ws1" } + project_path: { type: string } + data_dir: { type: string } + has_conversations: { type: boolean } + required: [workspace_id, project_path, data_dir, has_conversations] + + WorkspacesListResponse: + type: object + properties: + success: { type: boolean, example: true } + items: + type: array + items: { $ref: "#/components/schemas/WorkspaceInfo" } + required: [success, items] + + WorkspaceCreateResponse: + type: object + properties: + success: { type: boolean, example: true } + workspace_id: { type: string, example: "ws1" } + project_path: { type: string } + data_dir: { type: string } + required: [success, workspace_id, project_path, data_dir] + + ToolsCategory: + type: object + properties: + id: { type: string, example: "network" } + label: { type: string, example: "网络检索" } + default_enabled: { type: boolean, example: true } + silent_when_disabled: { type: boolean, example: false } + tools: + type: array + items: { type: string } + required: [id, label, default_enabled, silent_when_disabled, tools] + + ToolsResponse: + type: object + properties: + success: { type: boolean, example: true } + categories: + type: array + items: { $ref: "#/components/schemas/ToolsCategory" } + required: [success, categories] security: - bearerAuth: [] paths: - /api/v1/conversations: - post: - summary: 创建对话 + /api/v1/tools: + get: + summary: 获取工具分类与工具名称列表 security: [{ bearerAuth: [] }] + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/ToolsResponse" } + "401": + description: Unauthorized + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + + /api/v1/workspaces: + get: + summary: 列出工作区 + security: [{ bearerAuth: [] }] + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/WorkspacesListResponse" } + "401": + description: Unauthorized + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + post: + summary: 创建/确保工作区存在 + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/WorkspaceCreateRequest" } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/WorkspaceCreateResponse" } + "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/workspaces/{workspace_id}: + get: + summary: 查询单个工作区 + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: workspace_id + required: true + schema: { type: string } + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + success: { type: boolean, example: true } + workspace: { $ref: "#/components/schemas/WorkspaceInfo" } + required: [success, workspace] + "401": + description: Unauthorized + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + "404": + description: Not Found + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + delete: + summary: 删除工作区(若有运行中任务则 409) + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: workspace_id + required: true + schema: { type: string } + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + success: { type: boolean, example: true } + workspace_id: { type: string, example: "ws1" } + required: [success, workspace_id] + "401": + description: Unauthorized + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + "404": + description: Not Found + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + "409": + description: Conflict (该工作区有运行中任务) + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + + /api/v1/workspaces/{workspace_id}/conversations: + post: + summary: 创建对话(工作区内) + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: workspace_id + required: true + schema: { type: string } + requestBody: + required: false + content: + application/json: + schema: { $ref: "#/components/schemas/CreateConversationRequest" } responses: "200": description: OK @@ -157,11 +544,105 @@ paths: content: application/json: schema: { $ref: "#/components/schemas/ErrorResponse" } - - /api/v1/messages: - post: - summary: 发送消息并创建后台任务 + get: + summary: 获取对话列表(工作区内) security: [{ bearerAuth: [] }] + parameters: + - in: path + name: workspace_id + required: true + schema: { type: string } + - in: query + name: limit + required: false + schema: { type: integer, default: 20, minimum: 1, maximum: 100 } + - in: query + name: offset + required: false + schema: { type: integer, default: 0, minimum: 0 } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/ConversationListResponse" } + "401": + description: Unauthorized + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + + /api/v1/workspaces/{workspace_id}/conversations/{conversation_id}: + get: + summary: 获取单个对话(工作区内,可选 full=1 返回 messages) + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: workspace_id + required: true + schema: { type: string } + - in: path + name: conversation_id + required: true + schema: { type: string } + - in: query + name: full + required: false + schema: { type: string, default: "0", enum: ["0", "1"] } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/ConversationGetResponse" } + "401": + description: Unauthorized + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + "404": + description: Not Found + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + delete: + summary: 删除对话(工作区内) + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: workspace_id + required: true + schema: { type: string } + - in: path + name: conversation_id + required: true + schema: { type: string } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/DeleteConversationResponse" } + "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/workspaces/{workspace_id}/messages: + post: + summary: 发送消息并创建后台任务(工作区内) + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: workspace_id + required: true + schema: { type: string } requestBody: required: true content: @@ -183,11 +664,175 @@ paths: content: application/json: schema: { $ref: "#/components/schemas/ErrorResponse" } - "409": - description: Conflict (已有运行任务) + "404": + description: Not Found (prompt/personalization 不存在) content: application/json: schema: { $ref: "#/components/schemas/ErrorResponse" } + "409": + description: Conflict (该工作区已有运行任务) + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + + /api/v1/prompts: + get: + summary: 列出 Prompt + security: [{ bearerAuth: [] }] + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/PromptListResponse" } + "401": + description: Unauthorized + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + post: + summary: 创建/覆盖 Prompt + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PromptUpsertRequest" } + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + success: { type: boolean, example: true } + name: { type: string, example: "prompt_strict" } + required: [success, name] + "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/prompts/{name}: + get: + summary: 获取 Prompt 内容 + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: name + required: true + schema: { type: string } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/PromptGetResponse" } + "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/personalizations: + get: + summary: 列出个性化配置 + security: [{ bearerAuth: [] }] + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/PersonalizationListResponse" } + "401": + description: Unauthorized + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + post: + summary: 创建/覆盖个性化配置(服务端会 sanitize 并返回清洗后的 content) + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PersonalizationUpsertRequest" } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/PersonalizationGetResponse" } + "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/personalizations/{name}: + get: + summary: 获取个性化配置内容(服务端会 sanitize 并可能回写) + security: [{ bearerAuth: [] }] + parameters: + - in: path + name: name + required: true + schema: { type: string } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/PersonalizationGetResponse" } + "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/models: + get: + summary: 列出服务端模型(公开) + security: [] + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/ModelsResponse" } + + /api/v1/health: + get: + summary: 健康检查(公开) + security: [] + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/HealthResponse" } /api/v1/tasks/{task_id}: get: @@ -245,10 +890,15 @@ paths: application/json: schema: { $ref: "#/components/schemas/ErrorResponse" } - /api/v1/files/upload: + /api/v1/workspaces/{workspace_id}/files/upload: post: summary: 上传文件到 user_upload security: [{ bearerAuth: [] }] + parameters: + - in: path + name: workspace_id + required: true + schema: { type: string } requestBody: required: true content: @@ -282,11 +932,15 @@ paths: application/json: schema: { $ref: "#/components/schemas/ErrorResponse" } - /api/v1/files: + /api/v1/workspaces/{workspace_id}/files: get: summary: 列出 user_upload 目录内容 security: [{ bearerAuth: [] }] parameters: + - in: path + name: workspace_id + required: true + schema: { type: string } - in: query name: path required: false @@ -313,11 +967,15 @@ paths: application/json: schema: { $ref: "#/components/schemas/ErrorResponse" } - /api/v1/files/download: + /api/v1/workspaces/{workspace_id}/files/download: get: summary: 下载文件或目录(目录会打包成 zip) security: [{ bearerAuth: [] }] parameters: + - in: path + name: workspace_id + required: true + schema: { type: string } - in: query name: path required: true diff --git a/api_doc/prompts_personalization.md b/api_doc/prompts_personalization.md index 8195673..3aa8496 100644 --- a/api_doc/prompts_personalization.md +++ b/api_doc/prompts_personalization.md @@ -1,12 +1,67 @@ # Prompt 与个性化(Personalization)管理 API ## Prompt(主提示词)存储位置 -- 路径:`api/users//data/prompts/.txt` +- 路径:`api/users//shared/prompts/.txt`(用户级共享) - 内容格式:纯文本 ## 个性化存储位置 -- 路径:`api/users//data/personalization/.json` -- 内容格式:JSON,对应原有 personalization 配置结构 +- 路径:`api/users//shared/personalization/.json`(用户级共享) +- 内容格式:JSON(下面给出完整结构与字段约束) + +## 个性化 JSON 结构(content) + +> 该结构与服务端 `modules/personalization_manager.py` 的 `DEFAULT_PERSONALIZATION_CONFIG` / `sanitize_personalization_payload()` 完全一致。 +> 服务端会对你上传的 `content` 做规范化(截断长度、限制条数、过滤非法值/未知分类、回落默认值)。 + +### 完整示例(可直接用) + +```json +{ + "enabled": false, + "self_identify": "", + "user_name": "", + "profession": "", + "tone": "", + "considerations": [], + "thinking_interval": null, + "disabled_tool_categories": [], + "default_run_mode": null, + "auto_generate_title": true, + "tool_intent_enabled": true, + "default_model": "kimi" +} +``` + +### 字段说明与约束 + +| 字段 | 类型 | 默认 | 说明/约束 | +|---|---|---|---| +| `enabled` | bool | false | 是否启用个性化注入;为 false 时个性化不会写入 system prompt | +| `self_identify` | string | "" | 助手自称;会被截断到 20 字符 | +| `user_name` | string | "" | 对用户称呼;会被截断到 20 字符 | +| `profession` | string | "" | 用户职业;会被截断到 20 字符 | +| `tone` | string | "" | 语气风格;会被截断到 20 字符(服务端有推荐列表但不强校验) | +| `considerations` | string[] | [] | 重要注意事项列表:最多 10 条,每条最多 50 字符,非字符串/空字符串会被丢弃 | +| `thinking_interval` | int / null | null | 思考内容展示节奏相关:会被钳制到 1~50;非法值会变成 null | +| `disabled_tool_categories` | string[] | [] | 禁用的工具分类 id 列表:未知分类会被过滤(分类集合来自服务端 TOOL_CATEGORIES) | +| `default_run_mode` | "fast"/"thinking"/"deep"/null | null | 默认运行模式:非法值会变成 null | +| `auto_generate_title` | bool | true | 是否自动生成对话标题 | +| `tool_intent_enabled` | bool | true | 工具意图提示开关(属于配置结构的一部分) | +| `default_model` | string | "kimi" | 默认模型:仅允许 `"kimi"|"deepseek"|"qwen3-max"|"qwen3-vl-plus"`,非法值回落到 `"kimi"` | + +### 最小示例(启用 + 2 条注意事项) + +```json +{ + "enabled": true, + "user_name": "Jojo", + "tone": "企业商务", + "considerations": [ + "优先给出结论,再补充依据", + "涉及风险时给出可执行的规避方案" + ] +} +``` ## 接口 @@ -44,15 +99,20 @@ ### 创建/覆盖个性化 `POST /api/v1/personalizations` ```json -{ "name": "biz_mobile", "content": { ... personalization json ... } } +{ "name": "biz_mobile", "content": { "enabled": true, "tone": "企业商务" } } ``` +说明: + +- 服务端会对 `content` 做 **规范化/清洗**(截断长度、限制条数、过滤非法值/未知分类、回落默认值),并把清洗后的结果落盘; +- 返回体里的 `content` 也是 **清洗后的** 版本(建议客户端以返回值为准)。 + --- ## 在对话/消息中使用 -- `POST /api/v1/conversations` 可选参数:`prompt_name`、`personalization_name`,会写入对话元数据并在后续消息中应用。 -- `POST /api/v1/messages` 可选参数:`prompt_name`、`personalization_name`,立即应用并写入元数据。 +- `POST /api/v1/workspaces/{workspace_id}/conversations` 可选参数:`prompt_name`、`personalization_name`,会写入对话元数据并在后续消息中应用。 +- `POST /api/v1/workspaces/{workspace_id}/messages` 可选参数:`prompt_name`、`personalization_name`,立即应用并写入元数据。 - 元数据字段:`custom_prompt_name`、`personalization_name`;对话加载时会自动套用对应文件(若不存在则忽略)。 优先级:调用时传入 > 对话元数据 > 默认主 prompt / 默认个性化配置。 diff --git a/api_doc/tools.md b/api_doc/tools.md new file mode 100644 index 0000000..4234e3d --- /dev/null +++ b/api_doc/tools.md @@ -0,0 +1,41 @@ +# 工具列表 API + +该接口用于让 API 调用方获知服务端“工具分类”与“每个分类下的工具名称”。 + +鉴权:需要 `Authorization: Bearer ` + +--- + +## GET `/api/v1/tools` + +成功响应(200): + +```json +{ + "success": true, + "categories": [ + { + "id": "network", + "label": "网络检索", + "default_enabled": true, + "silent_when_disabled": false, + "tools": ["web_search", "extract_webpage", "save_webpage"] + }, + { + "id": "terminal_command", + "label": "终端指令", + "default_enabled": true, + "silent_when_disabled": false, + "tools": ["run_command", "run_python"] + } + ] +} +``` + +字段说明: + +- `categories[].id`:工具分类 id(服务端内部标识) +- `categories[].label`:分类展示名 +- `categories[].tools`:该分类下具体工具名称列表(用于前端渲染/筛选/白名单提示) +- `default_enabled/silent_when_disabled`:用于 UI/策略展示(并不等于“你一定能调用到该工具”,真实可用性仍取决于服务端策略与运行时环境) + diff --git a/api_doc/workspaces.md b/api_doc/workspaces.md new file mode 100644 index 0000000..590c0bc --- /dev/null +++ b/api_doc/workspaces.md @@ -0,0 +1,115 @@ +# 工作区(Workspace)管理 API + +本项目的 API v1 支持**多工作区并行**: + +- 一个 API 用户可以创建多个 `workspace_id` +- 每个 workspace 有独立的: + - 容器(运行隔离) + - `project/`(工具读写的根目录) + - `data/`(对话等数据落盘) + - `user_upload/`(上传目录) +- 并发限制以 workspace 为粒度:**同一 workspace 禁止并发任务**,不同 workspace 可同时运行。 + +鉴权:所有接口均需要 `Authorization: Bearer ` + +`workspace_id` 规则: + +- 仅允许字符:字母/数字/`.`/`_`/`-` +- 长度:1~40 + +--- + +## 1) 列出工作区 + +### GET `/api/v1/workspaces` + +成功响应(200): + +```json +{ + "success": true, + "items": [ + { + "workspace_id": "ws1", + "project_path": ".../api/users/api_demo/workspaces/ws1/project", + "data_dir": ".../api/users/api_demo/workspaces/ws1/data", + "has_conversations": true + } + ] +} +``` + +--- + +## 2) 创建/确保工作区存在 + +### POST `/api/v1/workspaces` + +Body(JSON): + +```json +{ "workspace_id": "ws1" } +``` + +成功响应(200): + +```json +{ + "success": true, + "workspace_id": "ws1", + "project_path": ".../api/users//workspaces/ws1/project", + "data_dir": ".../api/users//workspaces/ws1/data" +} +``` + +错误: + +- `400`:workspace_id 不合法 + +--- + +## 3) 查询单个工作区 + +### GET `/api/v1/workspaces/{workspace_id}` + +成功响应(200): + +```json +{ + "success": true, + "workspace": { + "workspace_id": "ws1", + "project_path": "...", + "data_dir": "...", + "has_conversations": true + } +} +``` + +错误: + +- `404`:workspace 不存在 + +--- + +## 4) 删除工作区 + +### DELETE `/api/v1/workspaces/{workspace_id}` + +说明: + +- 会删除该 workspace 的 `project/`、`data/`、`logs/` 等目录; +- **不会删除用户级共享**的 `prompts/personalization`(它们不属于 workspace); +- 如果该 workspace 有运行中的任务,会返回 `409`(禁止删除)。 + +成功响应(200): + +```json +{ "success": true, "workspace_id": "ws1" } +``` + +错误: + +- `409`:该工作区有运行中的任务 +- `404`:workspace 不存在 + diff --git a/core/main_terminal.py b/core/main_terminal.py index bf2387b..4361282 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -2554,6 +2554,12 @@ class MainTerminal: else: personalization_text = personalization_block messages.append({"role": "system", "content": personalization_text}) + + # 支持按对话覆盖的自定义 system prompt(API 用途)。 + # 放在最后一个 system 消息位置,确保优先级最高,便于业务场景强约束。 + custom_system_prompt = getattr(self.context_manager, "custom_system_prompt", None) + if isinstance(custom_system_prompt, str) and custom_system_prompt.strip(): + messages.append({"role": "system", "content": custom_system_prompt.strip()}) # 添加对话历史(保留完整结构,包括tool_calls和tool消息) conversation = context["conversation"] diff --git a/modules/api_user_manager.py b/modules/api_user_manager.py index 9f74f22..5477527 100644 --- a/modules/api_user_manager.py +++ b/modules/api_user_manager.py @@ -40,13 +40,18 @@ class ApiUserRecord: @dataclass class ApiUserWorkspace: + """API 用户的单个工作区描述。""" username: str + workspace_id: str root: Path project_path: Path - data_dir: Path + data_dir: Path # 会话/备份等落盘到这里(每个工作区独立) logs_dir: Path - uploads_dir: Path - quarantine_dir: Path + uploads_dir: Path # project/user_upload + quarantine_dir: Path # 上传隔离区(按用户/工作区划分) + shared_dir: Path # 用户级共享目录(prompts/personalization) + prompts_dir: Path # 实际使用的 prompts 目录(指向 shared_dir/prompt) + personalization_dir: Path # 实际使用的 personalization 目录(指向 shared_dir/personalization) class ApiUserManager: @@ -79,40 +84,114 @@ class ApiUserManager: 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" + def ensure_workspace(self, username: str, workspace_id: str = "default") -> ApiUserWorkspace: + """为 API 用户创建/获取指定工作区。 + + 目录布局(每个用户): + // + shared/ # 用户级共享(prompts/personalization) + prompts/ + personalization/ + workspaces// # 单个工作区 + project/ + user_upload/ + data/ + conversations/ + backups/ + logs/ + """ + username = username.strip().lower() + ws_id = (workspace_id or "default").strip() + if not ws_id: + ws_id = "default" + + user_root = (self.workspace_root / username).resolve() + shared_dir = user_root / "shared" + prompts_dir = shared_dir / "prompts" + personalization_dir = shared_dir / "personalization" + work_root = user_root / "workspaces" / ws_id + + project_path = work_root / "project" + data_dir = work_root / "data" + logs_dir = work_root / "logs" uploads_dir = project_path / "user_upload" - for path in (project_path, data_dir, logs_dir, uploads_dir): + for path in (project_path, data_dir, logs_dir, uploads_dir, shared_dir, prompts_dir, personalization_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 配置) + # 用户级 personalization 主文件(共享) + ensure_personalization_config(personalization_dir) + + # 为 prompts/personalization 创建便捷访问(保持向后兼容:data_dir 下可作为符号链接) + for name, target in (("prompts", prompts_dir), ("personalization", personalization_dir)): + link = data_dir / name + if not link.exists(): + try: + link.symlink_to(target, target_is_directory=True) + except Exception: + # 某些环境禁用 symlink,则忽略,使用共享目录路径显式传递 + pass + + # 上传隔离区(按用户/工作区划分) 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 = (quarantine_root / username / ws_id).resolve() quarantine_dir.mkdir(parents=True, exist_ok=True) return ApiUserWorkspace( username=username, - root=root, + workspace_id=ws_id, + root=work_root, project_path=project_path, data_dir=data_dir, logs_dir=logs_dir, uploads_dir=uploads_dir, quarantine_dir=quarantine_dir, + shared_dir=shared_dir, + prompts_dir=prompts_dir, + personalization_dir=personalization_dir, ) + def list_workspaces(self, username: str) -> Dict[str, Dict]: + """列出用户的所有工作区信息。""" + username = username.strip().lower() + user_root = (self.workspace_root / username / "workspaces").resolve() + if not user_root.exists(): + return {} + result = {} + for p in sorted(user_root.iterdir()): + if not p.is_dir(): + continue + ws_id = p.name + data_dir = p / "data" + project_path = p / "project" + result[ws_id] = { + "workspace_id": ws_id, + "project_path": str(project_path), + "data_dir": str(data_dir), + "has_conversations": (data_dir / "conversations").exists(), + } + return result + + def delete_workspace(self, username: str, workspace_id: str) -> bool: + """删除指定工作区(仅工作区目录,不删除共享 prompts/personalization)。""" + username = username.strip().lower() + ws_id = (workspace_id or "").strip() + if not ws_id: + return False + work_root = (self.workspace_root / username / "workspaces" / ws_id).resolve() + if not work_root.exists(): + return False + import shutil + shutil.rmtree(work_root, ignore_errors=True) + return True + # ----------------------- internal helpers ----------------------- def _sha256(self, token: str) -> str: return hashlib.sha256((token or "").encode("utf-8")).hexdigest() diff --git a/modules/user_container_manager.py b/modules/user_container_manager.py index a2b3556..611e7de 100644 --- a/modules/user_container_manager.py +++ b/modules/user_container_manager.py @@ -3,11 +3,13 @@ from __future__ import annotations import json +import os import re import shutil import subprocess import threading import time +import hashlib from dataclasses import dataclass, field from pathlib import Path from typing import Dict, Optional @@ -81,6 +83,12 @@ class UserContainerManager: self.name_prefix = TERMINAL_SANDBOX_NAME_PREFIX or "agent-user" self.require = bool(TERMINAL_SANDBOX_REQUIRE) self.extra_env = dict(TERMINAL_SANDBOX_ENV) + # 用 label 标记“本项目创建的容器”,用于启动时安全清理。 + self._project_root = str(Path(__file__).resolve().parents[1]) + self._project_label_key = "agents.project_root" + self._project_label_value = self._project_root + self._kind_label_key = "agents.kind" + self._kind_label_value = "terminal_sandbox" self._containers: Dict[str, ContainerHandle] = {} self._lock = threading.Lock() self._stats_log_path = Path(LOGS_DIR).expanduser().resolve() / "container_stats.log" @@ -88,19 +96,37 @@ class UserContainerManager: if not self._stats_log_path.exists(): self._stats_log_path.touch() + # 用户要求:每次启动程序时自动关闭本项目相关容器,避免“程序退出后容器残留”。 + # 默认开启;如确实需要保留容器,可设置 CLEANUP_PROJECT_CONTAINERS_ON_START=0。 + cleanup_flag = os.environ.get("CLEANUP_PROJECT_CONTAINERS_ON_START", "1").strip().lower() + cleanup_enabled = cleanup_flag not in {"0", "false", "no", "off"} + if cleanup_enabled: + try: + self.cleanup_project_containers() + except Exception: + # 清理失败不应影响主流程启动 + pass + # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ - def ensure_container(self, username: str, workspace_path: str) -> ContainerHandle: - username = self._normalize_username(username) + def ensure_container(self, username: str, workspace_path: str, container_key: Optional[str] = None) -> ContainerHandle: + """为指定“容器键”确保一个容器。 + + - username:业务用户名(用于日志/权限) + - container_key:容器缓存的 key,默认等于 username; + 对于多工作区 API,可传入 f\"{username}::{workspace_id}\" 以实现“每工作区一容器”。 + """ + username_norm = self._normalize_username(username) + key = self._normalize_username(container_key or username_norm) workspace = str(Path(workspace_path).expanduser().resolve()) Path(workspace).mkdir(parents=True, exist_ok=True) with self._lock: - handle = self._containers.get(username) + handle = self._containers.get(key) if handle: if handle.mode == "docker" and not self._is_container_running(handle): - self._containers.pop(username, None) + self._containers.pop(key, None) self._kill_container(handle.container_name, handle.sandbox_bin) handle = None else: @@ -108,17 +134,18 @@ class UserContainerManager: handle.touch() return handle - if not self._has_capacity(username): + if not self._has_capacity(key): raise RuntimeError("资源繁忙:容器配额已用尽,请稍候再试。") - handle = self._create_handle(username, workspace) - self._containers[username] = handle + # Important: create container using the cache key so each workspace gets its own container name. + handle = self._create_handle(key, workspace) + self._containers[key] = handle return handle - def release_container(self, username: str, reason: str = "logout"): - username = self._normalize_username(username) + def release_container(self, container_key: str, reason: str = "logout"): + key = self._normalize_username(container_key) with self._lock: - handle = self._containers.pop(username, None) + handle = self._containers.pop(key, None) if not handle: return if handle.mode == "docker" and handle.container_name: @@ -134,10 +161,10 @@ class UserContainerManager: return True return len(self._containers) < self.max_containers - def get_handle(self, username: str) -> Optional[ContainerHandle]: - username = self._normalize_username(username) + def get_handle(self, container_key: str) -> Optional[ContainerHandle]: + key = self._normalize_username(container_key) with self._lock: - handle = self._containers.get(username) + handle = self._containers.get(key) if handle: handle.touch() return handle @@ -146,16 +173,16 @@ class UserContainerManager: with self._lock: return {user: handle.to_dict() for user, handle in self._containers.items()} - def get_container_status(self, username: str, include_stats: bool = True) -> Dict: - username = self._normalize_username(username) + def get_container_status(self, container_key: str, include_stats: bool = True) -> Dict: + key = self._normalize_username(container_key) with self._lock: - handle = self._containers.get(username) + handle = self._containers.get(key) if not handle: # 未找到句柄,视为未运行 - return {"username": username, "mode": "host", "running": False} + return {"username": key, "mode": "host", "running": False} info = { - "username": username, + "username": handle.username, "mode": handle.mode, "workspace_path": handle.workspace_path, "mount_path": handle.mount_path, @@ -182,6 +209,50 @@ class UserContainerManager: return info + def cleanup_project_containers(self) -> Dict[str, int]: + """关闭并移除“本项目创建的终端容器”。 + + 只在 docker 沙箱模式下生效,并尽量避免误杀其他项目容器: + 1) 优先通过 label 精确匹配;2) 兼容旧容器:在 name_prefix 匹配后,再用 Mounts 路径校验。 + """ + if self.sandbox_mode != "docker": + return {"candidates": 0, "removed": 0} + + docker_path = shutil.which(self.sandbox_bin or "docker") + if not docker_path: + return {"candidates": 0, "removed": 0} + + removed = 0 + + # 1) label 精确匹配(最安全) + labeled = self._list_containers_by_label( + docker_path, + label_key=self._project_label_key, + label_value=self._project_label_value, + ) + # 2) 兼容旧容器:按 name_prefix 粗筛,再按 Mounts 是否落在本 repo 下精筛 + legacy = self._list_containers_by_name_prefix(docker_path, self.name_prefix) + candidates = [] + seen = set() + for name in labeled + legacy: + if not name or name in seen: + continue + seen.add(name) + candidates.append(name) + + for name in candidates: + if name in labeled: + self._kill_container(name, docker_path) + removed += 1 + continue + + # legacy:必须满足 mounts 落在 repo root 下,才允许清理 + if self._is_container_from_this_project(docker_path, name): + self._kill_container(name, docker_path) + removed += 1 + + return {"candidates": len(candidates), "removed": removed} + # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ @@ -215,6 +286,10 @@ class UserContainerManager: "-d", "--name", container_name, + "--label", + f"{self._project_label_key}={self._project_label_value}", + "--label", + f"{self._kind_label_key}={self._kind_label_value}", "-w", self.mount_path, "-v", @@ -287,6 +362,98 @@ class UserContainerManager: check=False, ) + def _list_containers_by_label(self, docker_bin: str, label_key: str, label_value: str) -> list[str]: + try: + result = subprocess.run( + [ + docker_bin, + "ps", + "-a", + "--filter", + f"label={label_key}={label_value}", + "--format", + "{{.Names}}", + ], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=5, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return [] + if result.returncode != 0: + return [] + return [line.strip() for line in (result.stdout or "").splitlines() if line.strip()] + + def _list_containers_by_name_prefix(self, docker_bin: str, prefix: str) -> list[str]: + safe_prefix = (prefix or "").strip() + if not safe_prefix: + return [] + try: + result = subprocess.run( + [ + docker_bin, + "ps", + "-a", + "--filter", + f"name=^{safe_prefix}-", + "--format", + "{{.Names}}", + ], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=5, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return [] + if result.returncode != 0: + return [] + return [line.strip() for line in (result.stdout or "").splitlines() if line.strip()] + + def _is_container_from_this_project(self, docker_bin: str, container_name: str) -> bool: + """通过 docker inspect 的 Mounts/Labels 判断容器是否属于本 repo。""" + try: + result = subprocess.run( + [docker_bin, "inspect", container_name], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=6, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return False + if result.returncode != 0 or not result.stdout.strip(): + return False + + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError: + return False + if not payload or not isinstance(payload, list): + return False + info = payload[0] if payload else {} + + labels = (info.get("Config") or {}).get("Labels") or {} + if labels.get(self._project_label_key) == self._project_label_value: + return True + + # 旧容器:检查 mount source 是否落在当前 repo 下(避免误删其他项目容器) + mounts = info.get("Mounts") or [] + root = str(Path(self._project_root).resolve()) + root_norm = root.rstrip("/") + "/" + for m in mounts: + src = (m or {}).get("Source") or "" + if not src: + continue + src_norm = str(Path(src).resolve()) + if src_norm == root or src_norm.startswith(root_norm): + return True + return False + def _is_container_running(self, handle: ContainerHandle) -> bool: if handle.mode != "docker" or not handle.container_name or not handle.sandbox_bin: return True @@ -309,10 +476,24 @@ class UserContainerManager: return False return result.returncode == 0 and result.stdout.strip().lower() == "true" - def _build_container_name(self, username: str) -> str: - slug = re.sub(r"[^a-z0-9\-]", "-", username.lower()).strip("-") + def _build_container_name(self, key: str) -> str: + """ + Build a docker container name for the given cache key. + + Notes: + - Multi-workspace API uses key like `api_demo::ws1`, we must encode workspace into the name, + otherwise all workspaces would share one container (breaking isolation). + - Docker names should be reasonably short and only contain [a-z0-9-]. + """ + raw = (key or "").strip().lower() + slug = re.sub(r"[^a-z0-9]+", "-", raw).strip("-") if not slug: slug = "user" + # Keep name short; append a stable hash suffix when truncated to avoid collisions. + max_slug = 48 + if len(slug) > max_slug: + suffix = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:8] + slug = f"{slug[:max_slug].strip('-')}-{suffix}" return f"{self.name_prefix}-{slug}" def _log_stats(self, username: str, stats: Dict): diff --git a/server/_conversation_segment.py b/server/_conversation_segment.py index 0ae339d..517b112 100644 --- a/server/_conversation_segment.py +++ b/server/_conversation_segment.py @@ -1478,37 +1478,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/_socket_segment.py b/server/_socket_segment.py index 075d872..390efad 100644 --- a/server/_socket_segment.py +++ b/server/_socket_segment.py @@ -241,7 +241,7 @@ def handle_message(data): activity_events = { 'ai_message_start', 'thinking_start', 'thinking_chunk', 'thinking_end', 'text_start', 'text_chunk', 'text_end', - 'tool_hint', 'tool_preparing', 'tool_start', 'update_action', + 'tool_preparing', 'tool_start', 'update_action', 'append_payload', 'modify_payload', 'system_message', 'task_complete' } diff --git a/server/api_v1.py b/server/api_v1.py index 76986df..8434a2e 100644 --- a/server/api_v1.py +++ b/server/api_v1.py @@ -15,9 +15,104 @@ from .context import get_user_resources, ensure_conversation_loaded, get_upload_ from .files import sanitize_filename_preserve_unicode from .utils_common import debug_log from config.model_profiles import MODEL_PROFILES +from core.tool_config import TOOL_CATEGORIES +from . import state +from modules.personalization_manager import sanitize_personalization_payload api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1") +@api_v1_bp.route("/tools", methods=["GET"]) +@api_token_required +def list_tools_api(): + """返回工具分类与工具名称列表。""" + categories = [] + for cid, cat in TOOL_CATEGORIES.items(): + categories.append({ + "id": cid, + "label": cat.label, + "default_enabled": bool(cat.default_enabled), + "silent_when_disabled": bool(cat.silent_when_disabled), + "tools": list(cat.tools), + }) + return jsonify({"success": True, "categories": categories}) + + +# -------------------- 工作区管理 -------------------- +def _sanitize_workspace_id(ws_id: str) -> str: + import re + ws = (ws_id or "").strip() + if not ws: + return "" + if not re.fullmatch(r"[A-Za-z0-9._-]{1,40}", ws): + return "" + return ws + + +@api_v1_bp.route("/workspaces", methods=["GET"]) +@api_token_required +def list_workspaces_api(): + username = session.get("username") + items = state.api_user_manager.list_workspaces(username) + return jsonify({"success": True, "items": list(items.values())}) + + +@api_v1_bp.route("/workspaces", methods=["POST"]) +@api_token_required +def create_workspace_api(): + username = session.get("username") + payload = request.get_json(silent=True) or {} + ws_id = _sanitize_workspace_id(payload.get("workspace_id") or payload.get("name") or "") + if not ws_id: + return jsonify({"success": False, "error": "workspace_id 只能包含字母/数字/._-,长度1-40"}), 400 + try: + ws = state.api_user_manager.ensure_workspace(username, ws_id) + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + return jsonify({ + "success": True, + "workspace_id": ws.workspace_id, + "project_path": str(ws.project_path), + "data_dir": str(ws.data_dir), + }) + + +@api_v1_bp.route("/workspaces/", methods=["GET"]) +@api_token_required +def get_workspace_api(workspace_id: str): + username = session.get("username") + ws_id = _sanitize_workspace_id(workspace_id) + if not ws_id: + return jsonify({"success": False, "error": "workspace_id 不合法"}), 400 + workspaces = state.api_user_manager.list_workspaces(username) + ws_info = workspaces.get(ws_id) + if not ws_info: + return jsonify({"success": False, "error": "workspace 不存在"}), 404 + return jsonify({"success": True, "workspace": ws_info}) + + +@api_v1_bp.route("/workspaces/", methods=["DELETE"]) +@api_token_required +def delete_workspace_api(workspace_id: str): + username = session.get("username") + ws_id = _sanitize_workspace_id(workspace_id) + if not ws_id: + return jsonify({"success": False, "error": "workspace_id 不合法"}), 400 + # 阻止删除有运行中任务的工作区 + running = [t for t in task_manager.list_tasks(username, ws_id) if t.status in {"pending", "running"}] + if running: + return jsonify({"success": False, "error": "该工作区有运行中的任务,无法删除"}), 409 + removed = state.api_user_manager.delete_workspace(username, ws_id) + # 清理终端/容器缓存 + term_key = f"{username}::{ws_id}" + state.user_terminals.pop(term_key, None) + try: + state.container_manager.release_container(term_key, reason="workspace_deleted") + except Exception: + pass + if not removed: + return jsonify({"success": False, "error": "workspace 不存在或删除失败"}), 404 + return jsonify({"success": True, "workspace_id": ws_id}) + def _within_uploads(workspace, rel_path: str) -> Path: base = Path(workspace.uploads_dir).resolve() @@ -55,22 +150,38 @@ def _update_conversation_metadata(workspace, conv_id: str, updates: Dict[str, An def _prompt_dir(workspace): + # 优先使用用户级共享目录(ApiUserWorkspace.prompts_dir),否则退回 data_dir/prompts + target = getattr(workspace, "prompts_dir", None) + if target: + Path(target).mkdir(parents=True, exist_ok=True) + return Path(target) p = Path(workspace.data_dir) / "prompts" p.mkdir(parents=True, exist_ok=True) return p def _personalization_dir(workspace): + target = getattr(workspace, "personalization_dir", None) + if target: + Path(target).mkdir(parents=True, exist_ok=True) + return Path(target) p = Path(workspace.data_dir) / "personalization" p.mkdir(parents=True, exist_ok=True) return p -@api_v1_bp.route("/conversations", methods=["POST"]) +def _resolve_workspace(username: str, workspace_id: str): + if not workspace_id: + raise RuntimeError("workspace_id 不能为空") + return state.api_user_manager.ensure_workspace(username, workspace_id) + + +@api_v1_bp.route("/workspaces//conversations", methods=["POST"]) @api_token_required -def create_conversation_api(): +def create_conversation_api(workspace_id: str): username = session.get("username") - terminal, workspace = get_user_resources(username) + ws = _resolve_workspace(username, workspace_id) + terminal, workspace = get_user_resources(username, workspace_id=ws.workspace_id) if not terminal: return jsonify({"success": False, "error": "系统未初始化"}), 503 payload = request.get_json(silent=True) or {} @@ -84,16 +195,18 @@ def create_conversation_api(): _update_conversation_metadata(workspace, conv_id, { "custom_prompt_name": prompt_name, "personalization_name": personalization_name, + "workspace_id": ws.workspace_id, }) # 立即应用覆盖 apply_conversation_overrides(terminal, workspace, conv_id) - return jsonify({"success": True, "conversation_id": conv_id}) + return jsonify({"success": True, "conversation_id": conv_id, "workspace_id": ws.workspace_id}) -@api_v1_bp.route("/messages", methods=["POST"]) +@api_v1_bp.route("/workspaces//messages", methods=["POST"]) @api_token_required -def send_message_api(): +def send_message_api(workspace_id: str): username = session.get("username") + ws = _resolve_workspace(username, workspace_id) payload = request.get_json() or {} message = (payload.get("message") or "").strip() images = payload.get("images") or [] @@ -107,11 +220,11 @@ def send_message_api(): if not message and not images: return jsonify({"success": False, "error": "消息不能为空"}), 400 - terminal, workspace = get_user_resources(username) + terminal, workspace = get_user_resources(username, workspace_id=ws.workspace_id) if not terminal or not workspace: return jsonify({"success": False, "error": "系统未初始化"}), 503 try: - conversation_id, _ = ensure_conversation_loaded(terminal, conversation_id) + conversation_id, _ = ensure_conversation_loaded(terminal, conversation_id, workspace=workspace) except Exception as exc: return jsonify({"success": False, "error": f"对话加载失败: {exc}"}), 400 @@ -134,6 +247,7 @@ def send_message_api(): _update_conversation_metadata(workspace, conversation_id, { "custom_prompt_name": prompt_name, "personalization_name": personalization_name, + "workspace_id": ws.workspace_id, }) except Exception as exc: return jsonify({"success": False, "error": f"自定义参数错误: {exc}"}), 400 @@ -141,6 +255,7 @@ def send_message_api(): try: rec = task_manager.create_chat_task( username=username, + workspace_id=ws.workspace_id, message=message, images=images, conversation_id=conversation_id, @@ -158,16 +273,18 @@ def send_message_api(): "success": True, "task_id": rec.task_id, "conversation_id": rec.conversation_id, + "workspace_id": ws.workspace_id, "status": rec.status, "created_at": rec.created_at, }), 202 -@api_v1_bp.route("/conversations", methods=["GET"]) +@api_v1_bp.route("/workspaces//conversations", methods=["GET"]) @api_token_required -def list_conversations_api(): +def list_conversations_api(workspace_id: str): username = session.get("username") - terminal, workspace = get_user_resources(username) + ws = _resolve_workspace(username, workspace_id) + terminal, workspace = get_user_resources(username, workspace_id=ws.workspace_id) if not terminal or not workspace: return jsonify({"success": False, "error": "系统未初始化"}), 503 limit = max(1, min(int(request.args.get("limit", 20)), 100)) @@ -187,6 +304,7 @@ def list_conversations_api(): "model_key": meta.get("model_key"), "custom_prompt_name": meta.get("custom_prompt_name"), "personalization_name": meta.get("personalization_name"), + "workspace_id": ws.workspace_id, "messages_count": len(data.get("messages", [])), }) except Exception: @@ -195,11 +313,12 @@ def list_conversations_api(): return jsonify({"success": True, "data": sliced, "total": len(items)}) -@api_v1_bp.route("/conversations/", methods=["GET"]) +@api_v1_bp.route("/workspaces//conversations/", methods=["GET"]) @api_token_required -def get_conversation_api(conv_id: str): +def get_conversation_api(workspace_id: str, conv_id: str): username = session.get("username") - _, workspace = get_user_resources(username) + ws = _resolve_workspace(username, workspace_id) + _, workspace = get_user_resources(username, workspace_id=ws.workspace_id) if not workspace: return jsonify({"success": False, "error": "系统未初始化"}), 503 path = _conversation_path(workspace, conv_id) @@ -210,20 +329,22 @@ def get_conversation_api(conv_id: str): include_messages = request.args.get("full", "0") == "1" if not include_messages: data["messages"] = None - return jsonify({"success": True, "data": data}) + return jsonify({"success": True, "data": data, "workspace_id": ws.workspace_id}) except Exception as exc: return jsonify({"success": False, "error": str(exc)}), 500 -@api_v1_bp.route("/conversations/", methods=["DELETE"]) +@api_v1_bp.route("/workspaces//conversations/", methods=["DELETE"]) @api_token_required -def delete_conversation_api(conv_id: str): +def delete_conversation_api(workspace_id: str, conv_id: str): username = session.get("username") - terminal, workspace = get_user_resources(username) + ws = _resolve_workspace(username, workspace_id) + terminal, workspace = get_user_resources(username, workspace_id=ws.workspace_id) if not terminal or not workspace: return jsonify({"success": False, "error": "系统未初始化"}), 503 result = terminal.delete_conversation(conv_id) status = 200 if result.get("success") else 404 + result["workspace_id"] = ws.workspace_id return jsonify(result), status @@ -244,6 +365,7 @@ def get_task_events(task_id: str): "success": True, "data": { "task_id": rec.task_id, + "workspace_id": getattr(rec, "workspace_id", None), "status": rec.status, "created_at": rec.created_at, "updated_at": rec.updated_at, @@ -264,11 +386,12 @@ def cancel_task_api_v1(task_id: str): return jsonify({"success": True}) -@api_v1_bp.route("/files/upload", methods=["POST"]) +@api_v1_bp.route("/workspaces//files/upload", methods=["POST"]) @api_token_required -def upload_file_api(): +def upload_file_api(workspace_id: str): username = session.get("username") - terminal, workspace = get_user_resources(username) + ws = _resolve_workspace(username, workspace_id) + terminal, workspace = get_user_resources(username, workspace_id=ws.workspace_id) if not terminal or not workspace: return jsonify({"success": False, "error": "系统未初始化"}), 503 if 'file' not in request.files: @@ -303,6 +426,7 @@ def upload_file_api(): metadata = result.get("metadata", {}) return jsonify({ "success": True, + "workspace_id": ws.workspace_id, "path": rel_path, "filename": target_path.name, "size": metadata.get("size"), @@ -310,11 +434,12 @@ def upload_file_api(): }) -@api_v1_bp.route("/files", methods=["GET"]) +@api_v1_bp.route("/workspaces//files", methods=["GET"]) @api_token_required -def list_files_api(): +def list_files_api(workspace_id: str): username = session.get("username") - _, workspace = get_user_resources(username) + ws = _resolve_workspace(username, workspace_id) + _, workspace = get_user_resources(username, workspace_id=ws.workspace_id) if not workspace: return jsonify({"success": False, "error": "系统未初始化"}), 503 rel = request.args.get("path") or "" @@ -335,16 +460,17 @@ def list_files_api(): "modified_at": stat.st_mtime, "path": str(rel_entry), }) - return jsonify({"success": True, "items": items, "base": str(target.relative_to(workspace.uploads_dir))}) + return jsonify({"success": True, "workspace_id": ws.workspace_id, "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_v1_bp.route("/workspaces//files/download", methods=["GET"]) @api_token_required -def download_file_api(): +def download_file_api(workspace_id: str): username = session.get("username") - _, workspace = get_user_resources(username) + ws = _resolve_workspace(username, workspace_id) + _, workspace = get_user_resources(username, workspace_id=ws.workspace_id) if not workspace: return jsonify({"success": False, "error": "系统未初始化"}), 503 rel = request.args.get("path") @@ -375,7 +501,9 @@ def download_file_api(): @api_token_required def list_prompts_api(): username = session.get("username") - _, workspace = get_user_resources(username) + # prompts 按用户共享,使用默认工作区上下文 + ws = _resolve_workspace(username, "default") + _, workspace = get_user_resources(username, workspace_id=ws.workspace_id) if not workspace: return jsonify({"success": False, "error": "系统未初始化"}), 503 base = _prompt_dir(workspace) @@ -394,7 +522,8 @@ def list_prompts_api(): @api_token_required def get_prompt_api(name: str): username = session.get("username") - _, workspace = get_user_resources(username) + ws = _resolve_workspace(username, "default") + _, workspace = get_user_resources(username, workspace_id=ws.workspace_id) if not workspace: return jsonify({"success": False, "error": "系统未初始化"}), 503 p = _prompt_dir(workspace) / f"{name}.txt" @@ -407,7 +536,8 @@ def get_prompt_api(name: str): @api_token_required def create_prompt_api(): username = session.get("username") - _, workspace = get_user_resources(username) + ws = _resolve_workspace(username, "default") + _, workspace = get_user_resources(username, workspace_id=ws.workspace_id) if not workspace: return jsonify({"success": False, "error": "系统未初始化"}), 503 payload = request.get_json() or {} @@ -424,7 +554,8 @@ def create_prompt_api(): @api_token_required def list_personalizations_api(): username = session.get("username") - _, workspace = get_user_resources(username) + ws = _resolve_workspace(username, "default") + _, workspace = get_user_resources(username, workspace_id=ws.workspace_id) if not workspace: return jsonify({"success": False, "error": "系统未初始化"}), 503 base = _personalization_dir(workspace) @@ -443,14 +574,19 @@ def list_personalizations_api(): @api_token_required def get_personalization_api(name: str): username = session.get("username") - _, workspace = get_user_resources(username) + ws = _resolve_workspace(username, "default") + _, workspace = get_user_resources(username, workspace_id=ws.workspace_id) if not workspace: return jsonify({"success": False, "error": "系统未初始化"}), 503 p = _personalization_dir(workspace) / f"{name}.json" if not p.exists(): return jsonify({"success": False, "error": "personalization 不存在"}), 404 try: - content = json.loads(p.read_text(encoding="utf-8")) + raw = json.loads(p.read_text(encoding="utf-8")) + # 读取时也做一次规范化,避免历史脏数据一直向外暴露 + content = sanitize_personalization_payload(raw) + if content != raw: + p.write_text(json.dumps(content, ensure_ascii=False, indent=2), encoding="utf-8") except Exception as exc: return jsonify({"success": False, "error": f"解析失败: {exc}"}), 500 return jsonify({"success": True, "name": name, "content": content}) @@ -460,7 +596,8 @@ def get_personalization_api(name: str): @api_token_required def create_personalization_api(): username = session.get("username") - _, workspace = get_user_resources(username) + ws = _resolve_workspace(username, "default") + _, workspace = get_user_resources(username, workspace_id=ws.workspace_id) if not workspace: return jsonify({"success": False, "error": "系统未初始化"}), 503 payload = request.get_json() or {} @@ -472,10 +609,20 @@ def create_personalization_api(): return jsonify({"success": False, "error": "content 不能为空"}), 400 p = _personalization_dir(workspace) / f"{name}.json" try: - p.write_text(json.dumps(content, ensure_ascii=False, indent=2), encoding="utf-8") + if not isinstance(content, dict): + return jsonify({"success": False, "error": "content 必须是 JSON object"}), 400 + existing = None + if p.exists(): + try: + existing = json.loads(p.read_text(encoding="utf-8")) + except Exception: + existing = None + # 规范化/清洗:截断长度、过滤非法值、回落默认 + sanitized = sanitize_personalization_payload(content, fallback=existing) + p.write_text(json.dumps(sanitized, ensure_ascii=False, indent=2), encoding="utf-8") except Exception as exc: return jsonify({"success": False, "error": f"保存失败: {exc}"}), 500 - return jsonify({"success": True, "name": name}) + return jsonify({"success": True, "name": name, "content": sanitized}) @api_v1_bp.route("/models", methods=["GET"]) diff --git a/server/auth.py b/server/auth.py index d1e368b..4df383d 100644 --- a/server/auth.py +++ b/server/auth.py @@ -125,10 +125,15 @@ def register(): def logout(): username = session.get('username') session.clear() - if username and username in state.user_terminals: - state.user_terminals.pop(username, None) if username: - state.container_manager.release_container(username, reason="logout") + # 清理该用户相关的所有终端/容器(包含 API 多工作区) + term_keys = [k for k in list(state.user_terminals.keys()) if k == username or k.startswith(f"{username}::")] + for key in term_keys: + state.user_terminals.pop(key, None) + try: + state.container_manager.release_container(key, reason="logout") + except Exception: + pass for token_value, meta in list(state.pending_socket_tokens.items()): if meta.get("username") == username: state.pending_socket_tokens.pop(token_value, None) diff --git a/server/context.py b/server/context.py index 2a1c502..54f84d3 100644 --- a/server/context.py +++ b/server/context.py @@ -36,7 +36,11 @@ def attach_user_broadcast(terminal: WebTerminal, username: str): terminal.terminal_manager.broadcast = callback -def get_user_resources(username: Optional[str] = None) -> Tuple[Optional[WebTerminal], Optional['modules.user_manager.UserWorkspace']]: +def _make_terminal_key(username: str, workspace_id: Optional[str] = None) -> str: + return f"{username}::{workspace_id}" if workspace_id else username + + +def get_user_resources(username: Optional[str] = None, workspace_id: Optional[str] = None) -> Tuple[Optional[WebTerminal], Optional['modules.user_manager.UserWorkspace']]: from modules.user_manager import UserWorkspace username = (username or get_current_username()) if not username: @@ -45,13 +49,22 @@ def get_user_resources(username: Optional[str] = None) -> Tuple[Optional[WebTerm # API 用户与网页用户使用不同的 manager if is_api_user: record = None - workspace = state.api_user_manager.ensure_workspace(username) + if workspace_id is None: + raise RuntimeError("API 调用缺少 workspace_id") + workspace = state.api_user_manager.ensure_workspace(username, workspace_id) 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)) + # 为兼容后续逻辑,补充 workspace_id 属性 + if not hasattr(workspace, "workspace_id"): + try: + workspace.workspace_id = "default" + except Exception: + pass + term_key = _make_terminal_key(username, getattr(workspace, "workspace_id", None) if is_api_user else None) + container_handle = state.container_manager.ensure_container(username, str(workspace.project_path), container_key=term_key) 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(term_key) if not terminal: 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 @@ -84,19 +97,23 @@ def get_user_resources(username: Optional[str] = None) -> Tuple[Optional[WebTerm ) if terminal.terminal_manager: terminal.terminal_manager.broadcast = terminal.message_callback - state.user_terminals[username] = terminal + state.user_terminals[term_key] = terminal terminal.username = 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 if has_request_context(): session['run_mode'] = terminal.run_mode session['thinking_mode'] = terminal.thinking_mode + if is_api_user: + session['workspace_id'] = getattr(workspace, "workspace_id", None) else: terminal.update_container_session(container_handle) attach_user_broadcast(terminal, username) terminal.username = 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 + if has_request_context() and is_api_user: + session['workspace_id'] = getattr(workspace, "workspace_id", None) # 应用管理员策略 if not is_api_user: @@ -253,7 +270,11 @@ def build_upload_error_response(exc: UploadSecurityError): }), status -def ensure_conversation_loaded(terminal: WebTerminal, conversation_id: Optional[str]): +def ensure_conversation_loaded( + terminal: WebTerminal, + conversation_id: Optional[str], + workspace=None, +): created_new = False if not conversation_id: result = terminal.create_new_conversation() @@ -290,11 +311,14 @@ def ensure_conversation_loaded(terminal: WebTerminal, conversation_id: Optional[ session['thinking_mode'] = terminal.thinking_mode except Exception: pass - # 应用对话级自定义 prompt / personalization(仅 API) - try: - apply_conversation_overrides(terminal, workspace, conversation_id) - except Exception as exc: - debug_log(f"[apply_overrides] 失败: {exc}") + # 应用对话级自定义 prompt / personalization(仅 API)。 + # 注意:ensure_conversation_loaded 在 WebSocket/后台任务等多处复用,有些调用点拿不到 workspace; + # 因此这里允许 workspace 为空(仅跳过 override,不影响正常对话加载)。 + if workspace is not None: + try: + apply_conversation_overrides(terminal, workspace, conversation_id) + except Exception as exc: + debug_log(f"[apply_overrides] 失败: {exc}") return conversation_id, created_new diff --git a/server/socket_handlers.py b/server/socket_handlers.py index 3bd9987..7362f45 100644 --- a/server/socket_handlers.py +++ b/server/socket_handlers.py @@ -262,7 +262,7 @@ def handle_message(data): activity_events = { 'ai_message_start', 'thinking_start', 'thinking_chunk', 'thinking_end', 'text_start', 'text_chunk', 'text_end', - 'tool_hint', 'tool_preparing', 'tool_start', 'update_action', + 'tool_preparing', 'tool_start', 'update_action', 'append_payload', 'modify_payload', 'system_message', 'task_complete' } diff --git a/server/tasks.py b/server/tasks.py index 47aea53..983c801 100644 --- a/server/tasks.py +++ b/server/tasks.py @@ -20,6 +20,7 @@ class TaskRecord: __slots__ = ( "task_id", "username", + "workspace_id", "status", "created_at", "updated_at", @@ -40,6 +41,7 @@ class TaskRecord: self, task_id: str, username: str, + workspace_id: str, message: str, conversation_id: Optional[str], model_key: Optional[str], @@ -49,6 +51,7 @@ class TaskRecord: ): self.task_id = task_id self.username = username + self.workspace_id = workspace_id self.status = "pending" self.created_at = time.time() self.updated_at = self.created_at @@ -76,6 +79,7 @@ class TaskManager: def create_chat_task( self, username: str, + workspace_id: str, message: str, images: List[Any], conversation_id: Optional[str], @@ -89,18 +93,19 @@ class TaskManager: 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"}] + # 单工作区互斥:禁止同一用户同一工作区并发任务 + existing = [t for t in self.list_tasks(username, workspace_id) if t.status in {"pending", "running"}] if existing: raise RuntimeError("已有运行中的任务,请稍后再试。") task_id = str(uuid.uuid4()) - record = TaskRecord(task_id, username, message, conversation_id, model_key, thinking_mode, run_mode, max_iterations) + record = TaskRecord(task_id, username, workspace_id, 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"), + "workspace_id": workspace_id, "run_mode": session.get("run_mode"), "thinking_mode": session.get("thinking_mode"), "model_key": session.get("model_key"), @@ -123,9 +128,13 @@ class TaskManager: return None return rec - def list_tasks(self, username: str) -> List[TaskRecord]: + def list_tasks(self, username: str, workspace_id: Optional[str] = None) -> List[TaskRecord]: with self._lock: - return [rec for rec in self._tasks.values() if rec.username == username] + return [ + rec + for rec in self._tasks.values() + if rec.username == username and (workspace_id is None or rec.workspace_id == workspace_id) + ] def cancel_task(self, username: str, task_id: str) -> bool: rec = self.get_task(username, task_id) @@ -162,6 +171,7 @@ class TaskManager: def _run_chat_task(self, rec: TaskRecord, images: List[Any]): username = rec.username + workspace_id = rec.workspace_id terminal = None workspace = None stop_hint = False @@ -175,7 +185,7 @@ class TaskManager: session[k] = v except Exception: pass - terminal, workspace = get_user_resources(username) + terminal, workspace = get_user_resources(username, workspace_id=workspace_id) if not terminal or not workspace: raise RuntimeError("系统未初始化") stop_hint = bool(stop_flags.get(rec.task_id, {}).get("stop")) @@ -205,7 +215,7 @@ class TaskManager: # 确保会话加载 conversation_id = rec.conversation_id try: - conversation_id, _ = ensure_conversation_loaded(terminal, conversation_id) + conversation_id, _ = ensure_conversation_loaded(terminal, conversation_id, workspace=workspace) rec.conversation_id = conversation_id except Exception as exc: raise RuntimeError(f"对话加载失败: {exc}") from exc @@ -283,6 +293,7 @@ def list_tasks_api(): @api_login_required def create_task_api(): username = get_current_username() + workspace_id = session.get("workspace_id") or "default" payload = request.get_json() or {} message = (payload.get("message") or "").strip() images = payload.get("images") or [] @@ -295,6 +306,7 @@ def create_task_api(): try: rec = task_manager.create_chat_task( username, + workspace_id, message, images, conversation_id,