feat(api): multi-workspace endpoints and container cleanup
This commit is contained in:
parent
9f7b443268
commit
6e0df78fef
@ -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/<task_id>?from=<offset>`
|
||||
4. 停止任务:`POST /api/v1/tasks/<task_id>/cancel`
|
||||
5. 文件上传(仅 user_upload):`POST /api/v1/files/upload`
|
||||
6. 文件浏览/下载:`GET /api/v1/files`、`GET /api/v1/files/download`
|
||||
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=<offset>`
|
||||
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 @@
|
||||
> 将 `<TOKEN>` 替换为你的 Bearer Token。
|
||||
> 默认后端:`http://localhost:8091`,如不同请修改。
|
||||
|
||||
### 0)创建对话
|
||||
### 0)创建工作区
|
||||
|
||||
```bash
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: Bearer <TOKEN>" \
|
||||
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 <TOKEN>" \
|
||||
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)**:任务特别长或轮询太慢会导致早期事件被丢弃;请按推荐频率轮询并在客户端持久化你需要的内容。
|
||||
- **进程重启不可恢复**:重启后任务/事件会消失,但对话/文件已落盘的不受影响。
|
||||
|
||||
161
api_doc/auth.md
161
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 <TOKEN>
|
||||
```
|
||||
|
||||
服务端会对 `<TOKEN>` 做 `SHA256`,与 `data/api_users.json` 中保存的 `token_sha256` 进行匹配。
|
||||
- `<TOKEN>` 是你分配给 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`:可选,仅用于记录与审计。
|
||||
|
||||
注意:
|
||||
|
||||
- 当前实现为**启动时加载**该 JSON;如果你修改了 `data/api_users.json`,请**重启服务**使其生效。
|
||||
|
||||
## 3. 生成 token_sha256 的方法
|
||||
|
||||
### 3.1 用 Python 生成
|
||||
|
||||
```bash
|
||||
python3 - <<'PY'
|
||||
import hashlib
|
||||
token = "YOUR_TOKEN"
|
||||
print(hashlib.sha256(token.encode("utf-8")).hexdigest())
|
||||
PY
|
||||
```
|
||||
|
||||
把输出粘到 `token_sha256`。
|
||||
|
||||
### 3.2 token 建议
|
||||
|
||||
- 长度建议:>= 32 字符(越长越好)
|
||||
- 生成建议:使用密码管理器或安全随机源生成
|
||||
- 存储建议:客户端侧使用系统安全存储(Keychain/Keystore),不要写死在前端代码里
|
||||
|
||||
## 4. API 用户的独立落盘目录
|
||||
|
||||
每个 API 用户会创建独立工作区,默认根目录:
|
||||
|
||||
- `api/users/<api_username>/`
|
||||
|
||||
典型结构:
|
||||
|
||||
```
|
||||
api/users/<api_username>/
|
||||
project/
|
||||
user_upload/ # API 上传文件只能落在这里(以及其子目录)
|
||||
... # 智能体运行产生的项目文件也在 project 下
|
||||
data/
|
||||
conversations/ # 对话 JSON 落盘目录
|
||||
memory.md
|
||||
task_memory.md
|
||||
...
|
||||
logs/
|
||||
```
|
||||
|
||||
对话文件示例路径:
|
||||
|
||||
- `api/users/api_jojo/data/conversations/conv_20260123_234245_036.json`
|
||||
|
||||
对话 JSON 结构(简化示例):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "conv_20260123_234245_036",
|
||||
"title": "新对话",
|
||||
"created_at": "2026-01-23T23:42:45.036Z",
|
||||
"updated_at": "...",
|
||||
"messages": [
|
||||
{ "role": "user", "content": "你好", "timestamp": "..." },
|
||||
{ "role": "assistant", "content": "…", "reasoning_content": "", "timestamp": "..." },
|
||||
{ "role": "tool", "name": "web_search", "content": "{...}", "timestamp": "..." }
|
||||
],
|
||||
"metadata": { "run_mode": "fast", "model_key": "kimi", "...": "..." }
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- API v1 **没有提供“读取历史对话”的 HTTP 接口**;如确实需要历史,请直接读落盘文件或后续再加只读接口。
|
||||
- `users` 的 key 是 API 用户名(仅用于区分与落盘路径)
|
||||
- 调用时只需要 token(用户名不会作为参数传入)
|
||||
|
||||
## 5. 安全注意事项
|
||||
> 修改 `data/api_users.json` 后通常需要重启服务以生效(取决于当前是否支持热加载)。
|
||||
|
||||
- Bearer Token 等同密码:不要放到 Git、不要发到日志、不要在网页端暴露。
|
||||
- 如果怀疑泄露:替换 token(更新 JSON),重启服务;旧 token 会立即失效。
|
||||
- 生产环境建议:
|
||||
- 通过反向代理限制来源 IP;
|
||||
- 记录 API 调用审计日志(至少记录 user、endpoint、时间、返回码);
|
||||
- 加入速率限制(避免被刷)。
|
||||
---
|
||||
|
||||
## 3) 目录结构(多工作区)
|
||||
|
||||
API 用户根目录(默认):`api/users/<api_user>/`
|
||||
|
||||
在该目录下包含:
|
||||
|
||||
### 3.1 用户级共享目录(prompts / personalization)
|
||||
|
||||
```
|
||||
api/users/<api_user>/shared/
|
||||
prompts/
|
||||
<name>.txt
|
||||
personalization/
|
||||
personalization.json # 默认个性化(主文件)
|
||||
<name>.json # 命名个性化
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `prompts` 与 `personalization` 是**用户级共享**:不同 workspace 共用同一份配置;
|
||||
- `/api/v1/prompts` 与 `/api/v1/personalizations` 操作的就是这里的文件。
|
||||
|
||||
### 3.2 工作区目录(每个 workspace 独立)
|
||||
|
||||
```
|
||||
api/users/<api_user>/workspaces/<workspace_id>/
|
||||
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 可以同时运行多个任务。
|
||||
|
||||
|
||||
106
api_doc/conversations.md
Normal file
106
api_doc/conversations.md
Normal file
@ -0,0 +1,106 @@
|
||||
# 对话查询与删除(工作区内,API v1)
|
||||
|
||||
对话数据是**工作区级**的:同一个 `conversation_id` 只属于一个 workspace。
|
||||
|
||||
鉴权:所有接口都需要 `Authorization: Bearer <TOKEN>`
|
||||
|
||||
---
|
||||
|
||||
## 1) 获取对话列表
|
||||
|
||||
### GET `/api/v1/workspaces/{workspace_id}/conversations?limit=<n>&offset=<n>`
|
||||
|
||||
返回该 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 正在运行任务,建议客户端禁用删除操作(避免对话文件与运行状态不一致)。
|
||||
|
||||
@ -36,12 +36,12 @@
|
||||
|
||||
原因:
|
||||
|
||||
- 同一个 API 用户在任务未结束时再次调用 `/api/v1/messages`
|
||||
- 同一个 API 用户在**同一个 workspace** 的任务未结束时再次调用 `/api/v1/workspaces/{workspace_id}/messages`
|
||||
|
||||
处理建议:
|
||||
|
||||
- 客户端 UI:禁用“发送”按钮,直到轮询显示 `status != running`
|
||||
- 或先调用 `/api/v1/tasks/<task_id>/cancel` 再发送新任务
|
||||
- 或先调用 `/api/v1/tasks/{task_id}/cancel` 再发送新任务
|
||||
|
||||
### 2.3 轮询一直 running,但没有 events
|
||||
|
||||
@ -65,6 +65,5 @@
|
||||
|
||||
建议:
|
||||
|
||||
- 读取 `GET /api/v1/tasks/<task_id>` 返回的 `data.error`
|
||||
- 读取 `GET /api/v1/tasks/{task_id}` 返回的 `data.error`
|
||||
- 同时遍历 `events` 中的 `type=error` 与 `system_message`,这些往往包含更具体原因
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# 事件流说明(轮询返回 events)
|
||||
|
||||
`GET /api/v1/tasks/<task_id>` 返回的 `events` 字段,是一个按时间顺序(`idx` 递增)的**事件流**。
|
||||
`GET /api/v1/tasks/{task_id}` 返回的 `events` 字段,是一个按时间顺序(`idx` 递增)的**事件流**。
|
||||
|
||||
本项目的目标是:**与网页端 WebSocket 事件保持同一粒度**。因此你会看到:
|
||||
|
||||
@ -34,8 +34,9 @@
|
||||
|
||||
轮询接口支持 `from` 参数:
|
||||
|
||||
- `GET /api/v1/tasks/<task_id>?from=0`:从头拉取
|
||||
- `GET /api/v1/tasks/<task_id>?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` 与关键工具事件持久化到本地,避免错过
|
||||
|
||||
|
||||
@ -1,71 +1,77 @@
|
||||
# 示例(curl / Python / JS / Flutter)
|
||||
|
||||
本文提供“最小可用”的端到端示例:创建对话 → 发送消息 → 轮询输出 →(可选)停止任务 → 文件上传/下载。
|
||||
默认服务端:`http://localhost:8091`
|
||||
|
||||
请先阅读 `auth.md` 并准备好 token。
|
||||
通用 Header:
|
||||
|
||||
## 0. 统一变量
|
||||
- `Authorization: Bearer <TOKEN>`
|
||||
|
||||
- `BASE_URL`:例如 `http://localhost:8091`
|
||||
- `TOKEN`:你的 Bearer Token(明文)
|
||||
下文示例使用:
|
||||
|
||||
- `TOKEN=<TOKEN>`
|
||||
- `WS=ws1`
|
||||
|
||||
---
|
||||
|
||||
## 1) curl:完整对话流程
|
||||
|
||||
### 1.1 创建对话
|
||||
## 1) curl:完整流程(工作区 + 对话 + 发送 + 轮询)
|
||||
|
||||
```bash
|
||||
BASE_URL="http://localhost:8091"
|
||||
TOKEN="<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 = "<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 = "<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 = "<TOKEN>";
|
||||
const ws = "ws1";
|
||||
|
||||
Map<String, String> headersJson() => {
|
||||
"Authorization": "Bearer $token",
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
Future<String> createConversation() async {
|
||||
final resp = await http.post(Uri.parse("$baseUrl/api/v1/conversations"),
|
||||
headers: {"Authorization":"Bearer $token"});
|
||||
final data = jsonDecode(resp.body);
|
||||
if (resp.statusCode != 200 || data["success"] != true) throw Exception(data["error"]);
|
||||
return data["conversation_id"];
|
||||
}
|
||||
Future<void> main() async {
|
||||
await http.post(Uri.parse("$baseUrl/api/v1/workspaces"),
|
||||
headers: headersJson(), body: jsonEncode({"workspace_id": ws}));
|
||||
|
||||
Future<String> 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<String> 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 中显示“正在搜索/正在执行工具”等状态。
|
||||
|
||||
180
api_doc/files.md
180
api_doc/files.md
@ -1,166 +1,114 @@
|
||||
# 文件接口(上传 / 列目录 / 下载)
|
||||
# 文件 API(工作区内,仅 user_upload)
|
||||
|
||||
API v1 只提供最小化的文件能力,用于把外部输入文件放入工作区,并下载产物。
|
||||
文件读写是**工作区级**的:上传/列目录/下载都发生在指定 workspace 的 `project/user_upload/` 下。
|
||||
|
||||
核心限制:
|
||||
鉴权:所有接口均需要 `Authorization: Bearer <TOKEN>`
|
||||
|
||||
- **上传只能落在 `project/user_upload/` 目录及其子目录**;
|
||||
- 列目录/下载也只允许访问 `user_upload` 内部路径(路径穿越会被拒绝)。
|
||||
|
||||
> 目录结构见 `auth.md`。
|
||||
---
|
||||
|
||||
## 1) 上传文件
|
||||
|
||||
### POST `/api/v1/files/upload`
|
||||
### POST `/api/v1/workspaces/{workspace_id}/files/upload`
|
||||
|
||||
#### 请求
|
||||
说明:
|
||||
|
||||
- Headers:`Authorization: Bearer <TOKEN>`
|
||||
- Content-Type:`multipart/form-data`
|
||||
- Form 字段:
|
||||
- `file`:必填,上传的文件对象
|
||||
- `filename`:可选,覆盖原文件名(会做安全清洗)
|
||||
- `dir`:可选,`user_upload` 下的子目录(例如 `docs/`);不传则落在 `user_upload/` 根目录
|
||||
- 不指定“任意路径上传”,只能上传到该 workspace 的 `user_upload` 目录(以及其子目录)。
|
||||
|
||||
> 如果你希望“完全不允许指定目录”,客户端请不要传 `dir`,统一上传到根目录即可。
|
||||
请求(multipart/form-data):
|
||||
|
||||
#### 响应
|
||||
- Headers:
|
||||
- `Authorization: Bearer <TOKEN>`
|
||||
- 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 <TOKEN>" \
|
||||
-F "file=@./hello.txt" \
|
||||
http://localhost:8091/api/v1/files/upload
|
||||
```
|
||||
|
||||
上传到子目录:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST \
|
||||
-H "Authorization: Bearer <TOKEN>" \
|
||||
-F "dir=inputs" \
|
||||
-F "file=@./hello.txt" \
|
||||
http://localhost:8091/api/v1/files/upload
|
||||
```
|
||||
---
|
||||
|
||||
## 2) 列出目录内容
|
||||
|
||||
### GET `/api/v1/files?path=<dir>`
|
||||
### GET `/api/v1/workspaces/{workspace_id}/files?path=<dir>`
|
||||
|
||||
#### 请求
|
||||
说明:
|
||||
|
||||
- Headers:`Authorization: Bearer <TOKEN>`
|
||||
- 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=<file_or_dir>`
|
||||
|
||||
说明:
|
||||
|
||||
- `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 <TOKEN>" \
|
||||
-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 <TOKEN>" \
|
||||
"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=<file_or_dir>`
|
||||
|
||||
#### 请求
|
||||
|
||||
- Headers:`Authorization: Bearer <TOKEN>`
|
||||
- Query:
|
||||
- `path`:必填,相对 `user_upload` 的文件或目录路径
|
||||
|
||||
#### 响应
|
||||
|
||||
1) 若 `path` 指向文件:返回文件二进制流(`Content-Disposition: attachment`)。
|
||||
2) 若 `path` 指向目录:服务端会把目录打包成 zip 后返回(`application/zip`)。
|
||||
|
||||
可能错误:
|
||||
|
||||
- `400`:缺少 path 或 path 非法
|
||||
- `401`:缺少或无效 token
|
||||
- `404`:文件/目录不存在
|
||||
- `503`:系统未初始化
|
||||
|
||||
#### curl 示例
|
||||
|
||||
下载文件:
|
||||
|
||||
```bash
|
||||
curl -L -o out.txt \
|
||||
-H "Authorization: Bearer <TOKEN>" \
|
||||
"http://localhost:8091/api/v1/files/download?path=hello.txt"
|
||||
```
|
||||
|
||||
下载目录(zip):
|
||||
下载目录 `ws1/user_upload/inputs`:
|
||||
|
||||
```bash
|
||||
curl -L -o inputs.zip \
|
||||
-H "Authorization: Bearer <TOKEN>" \
|
||||
"http://localhost:8091/api/v1/files/download?path=inputs"
|
||||
"http://localhost:8091/api/v1/workspaces/ws1/files/download?path=inputs"
|
||||
```
|
||||
|
||||
> zip 内的路径目前是相对 `project/` 的(因此通常会包含 `user_upload/` 前缀)。
|
||||
|
||||
|
||||
@ -2,40 +2,61 @@
|
||||
|
||||
本节覆盖:
|
||||
|
||||
- 创建对话(可选):`POST /api/v1/conversations`
|
||||
- 发送消息(创建后台任务):`POST /api/v1/messages`
|
||||
- 轮询事件:`GET /api/v1/tasks/<task_id>`
|
||||
- 停止任务:`POST /api/v1/tasks/<task_id>/cancel`
|
||||
- 创建对话(工作区内,可选):`POST /api/v1/workspaces/{workspace_id}/conversations`
|
||||
- 发送消息(工作区内,创建后台任务):`POST /api/v1/workspaces/{workspace_id}/messages`
|
||||
- 轮询事件:`GET /api/v1/tasks/{task_id}?from=<offset>`
|
||||
- 停止任务:`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 <TOKEN>`
|
||||
- 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 <TOKEN>`
|
||||
- `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/<name>.txt`);若不存在返回 404 |
|
||||
| `personalization_name` | string/null | 否 | 选择个性化配置(`data/personalization/<name>.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/<task_id>?from=<offset>`
|
||||
### GET `/api/v1/tasks/{task_id}?from=<offset>`
|
||||
|
||||
用于以 **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/<task_id>/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`:任务不存在
|
||||
|
||||
@ -3,10 +3,11 @@ info:
|
||||
title: Agents API v1
|
||||
version: "1.0"
|
||||
description: |
|
||||
后台任务 + HTTP 轮询进度 + 文件上传/下载。
|
||||
多工作区 + 后台任务 + HTTP 轮询进度 + 文件上传/下载。
|
||||
|
||||
- 鉴权:Authorization: Bearer <token>(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/<name>.txt)
|
||||
personalization_name:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 个性化配置名称(用户级共享,shared/personalization/<name>.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/<name>.txt)
|
||||
personalization_name:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 个性化配置名称(用户级共享,shared/personalization/<name>.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
|
||||
|
||||
@ -1,12 +1,67 @@
|
||||
# Prompt 与个性化(Personalization)管理 API
|
||||
|
||||
## Prompt(主提示词)存储位置
|
||||
- 路径:`api/users/<user>/data/prompts/<name>.txt`
|
||||
- 路径:`api/users/<user>/shared/prompts/<name>.txt`(用户级共享)
|
||||
- 内容格式:纯文本
|
||||
|
||||
## 个性化存储位置
|
||||
- 路径:`api/users/<user>/data/personalization/<name>.json`
|
||||
- 内容格式:JSON,对应原有 personalization 配置结构
|
||||
- 路径:`api/users/<user>/shared/personalization/<name>.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 / 默认个性化配置。
|
||||
|
||||
41
api_doc/tools.md
Normal file
41
api_doc/tools.md
Normal file
@ -0,0 +1,41 @@
|
||||
# 工具列表 API
|
||||
|
||||
该接口用于让 API 调用方获知服务端“工具分类”与“每个分类下的工具名称”。
|
||||
|
||||
鉴权:需要 `Authorization: Bearer <TOKEN>`
|
||||
|
||||
---
|
||||
|
||||
## 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/策略展示(并不等于“你一定能调用到该工具”,真实可用性仍取决于服务端策略与运行时环境)
|
||||
|
||||
115
api_doc/workspaces.md
Normal file
115
api_doc/workspaces.md
Normal file
@ -0,0 +1,115 @@
|
||||
# 工作区(Workspace)管理 API
|
||||
|
||||
本项目的 API v1 支持**多工作区并行**:
|
||||
|
||||
- 一个 API 用户可以创建多个 `workspace_id`
|
||||
- 每个 workspace 有独立的:
|
||||
- 容器(运行隔离)
|
||||
- `project/`(工具读写的根目录)
|
||||
- `data/`(对话等数据落盘)
|
||||
- `user_upload/`(上传目录)
|
||||
- 并发限制以 workspace 为粒度:**同一 workspace 禁止并发任务**,不同 workspace 可同时运行。
|
||||
|
||||
鉴权:所有接口均需要 `Authorization: Bearer <TOKEN>`
|
||||
|
||||
`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/<user>/workspaces/ws1/project",
|
||||
"data_dir": ".../api/users/<user>/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 不存在
|
||||
|
||||
@ -2555,6 +2555,12 @@ class MainTerminal:
|
||||
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"]
|
||||
for idx, conv in enumerate(conversation):
|
||||
|
||||
@ -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 用户创建/获取指定工作区。
|
||||
|
||||
目录布局(每个用户):
|
||||
<root>/<username>/
|
||||
shared/ # 用户级共享(prompts/personalization)
|
||||
prompts/
|
||||
personalization/
|
||||
workspaces/<ws>/ # 单个工作区
|
||||
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()
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
221
server/api_v1.py
221
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/<workspace_id>", 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/<workspace_id>", 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/<workspace_id>/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/<workspace_id>/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/<workspace_id>/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/<conv_id>", methods=["GET"])
|
||||
@api_v1_bp.route("/workspaces/<workspace_id>/conversations/<conv_id>", 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/<conv_id>", methods=["DELETE"])
|
||||
@api_v1_bp.route("/workspaces/<workspace_id>/conversations/<conv_id>", 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/<workspace_id>/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/<workspace_id>/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/<workspace_id>/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"])
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,7 +311,10 @@ def ensure_conversation_loaded(terminal: WebTerminal, conversation_id: Optional[
|
||||
session['thinking_mode'] = terminal.thinking_mode
|
||||
except Exception:
|
||||
pass
|
||||
# 应用对话级自定义 prompt / personalization(仅 API)
|
||||
# 应用对话级自定义 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:
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user