feat(api): multi-workspace endpoints and container cleanup

This commit is contained in:
JOJO 2026-01-24 18:29:30 +08:00
parent 9f7b443268
commit 6e0df78fef
22 changed files with 1898 additions and 611 deletions

View File

@ -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**:任务特别长或轮询太慢会导致早期事件被丢弃;请按推荐频率轮询并在客户端持久化你需要的内容。
- **进程重启不可恢复**:重启后任务/事件会消失,但对话/文件已落盘的不受影响。

View File

@ -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
View 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`
成功响应200full=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 正在运行任务,建议客户端禁用删除操作(避免对话文件与运行状态不一致)。

View File

@ -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`,这些往往包含更具体原因

View File

@ -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` 与关键工具事件持久化到本地,避免错过

View File

@ -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) Pythonrequests推荐写法
```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) JSfetch浏览器/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) FlutterDart轮询示例伪代码
依赖:`http` 包或 `dio` 包均可,这里用 `http` 表达逻辑。
## 4) Flutterhttp示意
```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 中显示“正在搜索/正在执行工具”等状态。

View File

@ -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`:文件 mtimeUNIX 秒)
- `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/` 前缀)。

View File

@ -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=thinkingfalse=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=thinkingfalse=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`:任务不存在

View File

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

View File

@ -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
View 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
View 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 不存在

View File

@ -2555,6 +2555,12 @@ class MainTerminal:
personalization_text = personalization_block
messages.append({"role": "system", "content": personalization_text})
# 支持按对话覆盖的自定义 system promptAPI 用途)。
# 放在最后一个 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):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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