fix: expand workspace file access and paginate convo index

This commit is contained in:
JOJO 2026-01-25 16:13:32 +08:00
parent f7034a3047
commit d0197c38c3
10 changed files with 170 additions and 63 deletions

View File

@ -26,7 +26,7 @@
4. 发送消息/启动后台任务(工作区内):`POST /api/v1/workspaces/{workspace_id}/messages` 4. 发送消息/启动后台任务(工作区内):`POST /api/v1/workspaces/{workspace_id}/messages`
5. 轮询任务事件:`GET /api/v1/tasks/{task_id}?from=<offset>` 5. 轮询任务事件:`GET /api/v1/tasks/{task_id}?from=<offset>`
6. 停止任务:`POST /api/v1/tasks/{task_id}/cancel` 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` 7. 文件:上传仅限 `user_upload/`,但列目录/下载可遍历整个 `project/``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}` 8. Prompt 管理(用户级共享):`GET/POST /api/v1/prompts`、`GET /api/v1/prompts/{name}`
9. 个性化管理(用户级共享):`GET/POST /api/v1/personalizations`、`GET /api/v1/personalizations/{name}` 9. 个性化管理(用户级共享):`GET/POST /api/v1/personalizations`、`GET /api/v1/personalizations/{name}`
10. 模型列表与健康检查:`GET /api/v1/models`、`GET /api/v1/health` 10. 模型列表与健康检查:`GET /api/v1/models`、`GET /api/v1/health`

View File

@ -1,6 +1,6 @@
# 文件 API工作区内,仅 user_upload # 文件 API工作区内:上传仅限 user_upload读取可遍历 project
文件读写是**工作区级**的:上传/列目录/下载都发生在指定 workspace 的 `project/user_upload/` 文件读写是**工作区级**的:上传仅允许写入该 workspace 的 `project/user_upload/`;列目录/下载则可访问整个 `project/`(对应容器内 `/workspace`),兼容传入 `user_upload/` 路径
鉴权:所有接口均需要 `Authorization: Bearer <TOKEN>` 鉴权:所有接口均需要 `Authorization: Bearer <TOKEN>`
@ -12,7 +12,7 @@
说明: 说明:
- 不指定“任意路径上传”,只能上传到该 workspace 的 `user_upload` 目录(以及其子目录) - 仅可上传到 workspace 的 `user_upload/` 目录(及其子目录);路径越界将被拒绝
请求multipart/form-data 请求multipart/form-data
@ -21,7 +21,7 @@
- Form - Form
- `file`:文件本体(必填) - `file`:文件本体(必填)
- `filename`:可选,自定义文件名(服务端会清洗) - `filename`:可选,自定义文件名(服务端会清洗)
- `dir`可选user_upload 下子目录(如 `inputs` / `a/b` - `dir`:可选,`user_upload` 下子目录(如 `inputs` / `a/b`
成功响应200 成功响应200
@ -44,7 +44,7 @@
说明: 说明:
- `path``user_upload` 下的相对路径;不传或传空则表示根目录。 - `path``project/` 下的相对路径;不传或传空则表示 `project` 根目录。兼容传入 `user_upload/...`
成功响应200 成功响应200
@ -73,7 +73,7 @@
说明: 说明:
- `path``user_upload` 下相对路径 - `path``project/` 下相对路径(兼容传入 `user_upload/...`
- 若 `path` 是文件:直接下载该文件 - 若 `path` 是文件:直接下载该文件
- 若 `path` 是目录:服务端打包为 zip 返回 - 若 `path` 是目录:服务端打包为 zip 返回
@ -111,4 +111,3 @@ curl -L -o inputs.zip \
-H "Authorization: Bearer <TOKEN>" \ -H "Authorization: Bearer <TOKEN>" \
"https://agent.cyjai.com/api/v1/workspaces/ws1/files/download?path=inputs" "https://agent.cyjai.com/api/v1/workspaces/ws1/files/download?path=inputs"
``` ```

View File

@ -934,7 +934,7 @@ paths:
/api/v1/workspaces/{workspace_id}/files: /api/v1/workspaces/{workspace_id}/files:
get: get:
summary: 列出 user_upload 目录内容 summary: 列出 project 目录内容
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
parameters: parameters:
- in: path - in: path

View File

@ -1496,7 +1496,7 @@ class MainTerminal:
"type": "function", "type": "function",
"function": { "function": {
"name": "terminal_input", "name": "terminal_input",
"description": "向活动终端发送命令或输入。禁止启动会占用终端界面的程序python/node/nano/vim 等);如遇卡死请结合 terminal_snapshot 并使用 terminal_reset 恢复。必须提供 timeout一旦超时当前命令**一定会被打断**且无法继续执行(需要重新运行),终端会话本身保持可用。若不确定上一条命令是否结束,先用 terminal_snapshot 确认后再继续输入。", "description": "向活动终端发送命令或输入。禁止启动会占用终端界面的程序python/node/nano/vim 等);如遇卡死请结合 terminal_snapshot 并使用 terminal_reset 恢复。timeout 可填秒数最大300超时会强制打断命令或填 never不封装超时、不杀进程可能无输出无法仅靠快照判断是否成功需要用 curl/ps 等主动检查)。若不确定上一条命令是否结束,先用 terminal_snapshot 确认后再继续输入。",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": self._inject_intent({ "properties": self._inject_intent({
@ -1509,8 +1509,8 @@ class MainTerminal:
"description": "目标终端会话名称(可选,默认使用活动终端)" "description": "目标终端会话名称(可选,默认使用活动终端)"
}, },
"timeout": { "timeout": {
"type": "number", "type": ["number", "string"],
"description": "等待输出的最长秒数必填最大300" "description": "等待输出的最长秒数必填最大300,或填 never 表示不封装超时且不中断进程"
} }
}), }),
"required": ["command", "timeout"] "required": ["command", "timeout"]

View File

@ -268,8 +268,9 @@ class ApiUserManager:
project_path = p / "project" project_path = p / "project"
result[ws_id] = { result[ws_id] = {
"workspace_id": ws_id, "workspace_id": ws_id,
"project_path": str(project_path), # 不暴露宿主机绝对路径,只返回相对工作区的信息
"data_dir": str(data_dir), "project_path": "project",
"data_dir": "data",
"has_conversations": (data_dir / "conversations").exists(), "has_conversations": (data_dir / "conversations").exists(),
} }
return result return result

View File

@ -523,29 +523,57 @@ class TerminalManager:
# 发送命令 # 发送命令
terminal = self.terminals[target_session] terminal = self.terminals[target_session]
if timeout is None or timeout <= 0: never_timeout = False
return { if isinstance(timeout, str):
"success": False, if timeout.lower() == "never":
"error": "timeout 参数必填且需大于0", never_timeout = True
"status": "error", else:
"output": "timeout 参数缺失" try:
} timeout = float(timeout)
timeout = min(timeout, 300) except (TypeError, ValueError):
return {
"success": False,
"error": "timeout 参数必须是数字或 'never'",
"status": "error",
"output": "timeout 参数无效"
}
base_timeout = timeout if not never_timeout:
marker = f"__CMD_DONE__{int(time.time()*1000)}__" if timeout is None or timeout <= 0:
return {
"success": False,
"error": "timeout 参数必填且需大于0或设置为 'never'",
"status": "error",
"output": "timeout 参数缺失"
}
timeout = min(timeout, 300)
wrapped_command, wait_timeout = self._build_wrapped_command(command, marker, timeout) base_timeout = timeout
marker = f"__CMD_DONE__{int(time.time()*1000)}__"
wrapped_command, wait_timeout = self._build_wrapped_command(command, marker, timeout)
result = terminal.send_command(
wrapped_command,
timeout=wait_timeout,
timeout_cutoff=base_timeout,
enforce_full_timeout=True,
sentinel=marker,
)
result["timeout"] = base_timeout
result["never_timeout"] = False
return result
# never_timeout 分支:不包装命令,不发送结束标记,不强杀进程
result = terminal.send_command( result = terminal.send_command(
wrapped_command, command,
timeout=wait_timeout, timeout=None,
timeout_cutoff=base_timeout, timeout_cutoff=None,
enforce_full_timeout=True, enforce_full_timeout=False,
sentinel=marker, sentinel=None,
) )
result["timeout"] = base_timeout result["timeout"] = "never"
result["never_timeout"] = True
return result return result
def _build_wrapped_command(self, command: str, marker: str, timeout: int) -> (str, int): def _build_wrapped_command(self, command: str, marker: str, timeout: int) -> (str, int):

View File

@ -122,7 +122,7 @@
- 如果终端卡住了,用 terminal_reset 重启 - 如果终端卡住了,用 terminal_reset 重启
**⏱️ 时间/超时/状态确认(硬性规则)** **⏱️ 时间/超时/状态确认(硬性规则)**
- 需要控制“命令最多跑多久”,请使用 `run_command` / `terminal_input` 的 `timeout` 参数;一旦超时,命令**一定会被打断**、无法继续执行(需要重新运行)。 - 需要控制“命令最多跑多久”,请使用 `run_command` / `terminal_input` 的 `timeout` 参数;一旦超时,命令**一定会被打断**、无法继续执行(需要重新运行)。如需在持久终端保持后台运行且不被强制杀掉,可将 `terminal_input` 的 `timeout` 设为 `never`(不添加超时封装,也不会追加结束标记;可能无输出,快照无法判断成败,需用 curl/ps/log 等主动验证);`run_command` 仍需设定具体秒数。
- 禁止凭感觉判断“我觉得下载/编译应该已经完成了/还没完成”;必须使用 `terminal_snapshot` 获取终端快照来确认真实情况。 - 禁止凭感觉判断“我觉得下载/编译应该已经完成了/还没完成”;必须使用 `terminal_snapshot` 获取终端快照来确认真实情况。
- 若不确定某终端里**上一条命令是否已结束**,禁止在**同一终端**继续输入任何内容(可能导致终端彻底卡死);应先用 `terminal_snapshot` 检查,或在**其他终端会话**里用 `ps/pgrep/ls` 等命令验证后再继续操作。 - 若不确定某终端里**上一条命令是否已结束**,禁止在**同一终端**继续输入任何内容(可能导致终端彻底卡死);应先用 `terminal_snapshot` 检查,或在**其他终端会话**里用 `ps/pgrep/ls` 等命令验证后再继续操作。

View File

@ -142,7 +142,7 @@
- 如果终端卡住了,用 terminal_reset 重启 - 如果终端卡住了,用 terminal_reset 重启
**⏱️ 时间/超时/状态确认(硬性规则)** **⏱️ 时间/超时/状态确认(硬性规则)**
- 需要控制“命令最多跑多久”,请使用 `run_command` / `terminal_input` 的 `timeout` 参数;一旦超时,命令**一定会被打断**、无法继续执行(需要重新运行)。 - 需要控制“命令最多跑多久”,请使用 `run_command` / `terminal_input` 的 `timeout` 参数;一旦超时,命令**一定会被打断**、无法继续执行(需要重新运行)。如需在持久终端保持后台运行且不被强制杀掉,可将 `terminal_input` 的 `timeout` 设为 `never`(不添加超时封装,也不会追加结束标记;可能无输出,快照无法判断成败,需用 curl/ps/log 等主动验证);`run_command` 仍需设定具体秒数。
- 禁止凭感觉判断“我觉得下载/编译应该已经完成了/还没完成”;必须使用 `terminal_snapshot` 获取终端快照来确认真实情况。 - 禁止凭感觉判断“我觉得下载/编译应该已经完成了/还没完成”;必须使用 `terminal_snapshot` 获取终端快照来确认真实情况。
- 若不确定某终端里**上一条命令是否已结束**,禁止在**同一终端**继续输入任何内容(可能导致终端彻底卡死);应先用 `terminal_snapshot` 检查,或在**其他终端会话**里用 `ps/pgrep/ls` 等命令验证后再继续操作。 - 若不确定某终端里**上一条命令是否已结束**,禁止在**同一终端**继续输入任何内容(可能导致终端彻底卡死);应先用 `terminal_snapshot` 检查,或在**其他终端会话**里用 `ps/pgrep/ls` 等命令验证后再继续操作。

View File

@ -48,6 +48,16 @@ def _sanitize_workspace_id(ws_id: str) -> str:
return ws return ws
def _public_workspace_info(workspace) -> Dict[str, Any]:
"""对外返回的工作区信息,隐藏宿主机绝对路径。"""
return {
"success": True,
"workspace_id": workspace.workspace_id,
"project_path": "project", # 相对工作区目录,避免暴露宿主机路径
"data_dir": "data",
}
@api_v1_bp.route("/workspaces", methods=["GET"]) @api_v1_bp.route("/workspaces", methods=["GET"])
@api_token_required @api_token_required
def list_workspaces_api(): def list_workspaces_api():
@ -68,12 +78,7 @@ def create_workspace_api():
ws = state.api_user_manager.ensure_workspace(username, ws_id) ws = state.api_user_manager.ensure_workspace(username, ws_id)
except Exception as exc: except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500 return jsonify({"success": False, "error": str(exc)}), 500
return jsonify({ return jsonify(_public_workspace_info(ws))
"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_v1_bp.route("/workspaces/<workspace_id>", methods=["GET"])
@ -127,6 +132,29 @@ def _within_uploads(workspace, rel_path: str) -> Path:
return target return target
def _within_project(workspace, rel_path: str, default_to_project_root: bool = True) -> Path:
"""
将相对路径解析到工作区 project 目录内防止越界
Args:
workspace: ApiUserWorkspace
rel_path: 相对路径允许以 user_upload/ 开头向后兼容
default_to_project_root: rel_path 为空时是否默认指向 project
"""
base = Path(workspace.project_path).resolve()
rel = (rel_path or "").strip()
if not rel and default_to_project_root:
rel = ""
# 兼容 /workspace/<...>/ 前缀(容器路径)
if rel.startswith("/workspace/"):
rel = rel.split("/workspace/", 1)[1]
rel = rel.lstrip("/")
target = (base / rel).resolve()
if not str(target).startswith(str(base)):
raise ValueError("非法路径")
return target
def _conversation_path(workspace, conv_id: str) -> Path: def _conversation_path(workspace, conv_id: str) -> Path:
return Path(workspace.data_dir) / "conversations" / f"{conv_id}.json" return Path(workspace.data_dir) / "conversations" / f"{conv_id}.json"
@ -449,15 +477,28 @@ def list_files_api(workspace_id: str):
return jsonify({"success": False, "error": "系统未初始化"}), 503 return jsonify({"success": False, "error": "系统未初始化"}), 503
rel = request.args.get("path") or "" rel = request.args.get("path") or ""
try: try:
target = _within_uploads(workspace, rel) target = _within_project(workspace, rel)
if not target.exists(): if not target.exists():
return jsonify({"success": False, "error": "路径不存在"}), 404 return jsonify({"success": False, "error": "路径不存在"}), 404
if not target.is_dir(): if not target.is_dir():
return jsonify({"success": False, "error": "路径不是文件夹"}), 400 stat = target.stat()
rel_entry = target.relative_to(workspace.project_path)
return jsonify({
"success": True,
"workspace_id": ws.workspace_id,
"items": [{
"name": target.name,
"is_dir": False,
"size": stat.st_size,
"modified_at": stat.st_mtime,
"path": str(rel_entry),
}],
"base": str(rel_entry.parent) if rel_entry.parent != Path(".") else "",
})
items = [] items = []
for entry in sorted(target.iterdir(), key=lambda p: p.name): for entry in sorted(target.iterdir(), key=lambda p: p.name):
stat = entry.stat() stat = entry.stat()
rel_entry = entry.relative_to(workspace.uploads_dir) rel_entry = entry.relative_to(workspace.project_path)
items.append({ items.append({
"name": entry.name, "name": entry.name,
"is_dir": entry.is_dir(), "is_dir": entry.is_dir(),
@ -465,7 +506,9 @@ def list_files_api(workspace_id: str):
"modified_at": stat.st_mtime, "modified_at": stat.st_mtime,
"path": str(rel_entry), "path": str(rel_entry),
}) })
return jsonify({"success": True, "workspace_id": ws.workspace_id, "items": items, "base": str(target.relative_to(workspace.uploads_dir))}) base_rel = target.relative_to(workspace.project_path)
base_str = "" if str(base_rel) == "." else str(base_rel)
return jsonify({"success": True, "workspace_id": ws.workspace_id, "items": items, "base": base_str})
except Exception as exc: except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400 return jsonify({"success": False, "error": str(exc)}), 400
@ -482,7 +525,7 @@ def download_file_api(workspace_id: str):
if not rel: if not rel:
return jsonify({"success": False, "error": "缺少 path"}), 400 return jsonify({"success": False, "error": "缺少 path"}), 400
try: try:
target = _within_uploads(workspace, rel) target = _within_project(workspace, rel)
except Exception as exc: except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 400 return jsonify({"success": False, "error": str(exc)}), 400
if not target.exists(): if not target.exists():

View File

@ -45,7 +45,8 @@ class ConversationManager:
self.workspace_root = Path(__file__).resolve().parents[1] self.workspace_root = Path(__file__).resolve().parents[1]
self._ensure_directories() self._ensure_directories()
self._index_verified = False self._index_verified = False
self._load_index(ensure_integrity=True) # 首次加载索引仅重建最近 20 条,降低启动开销;后续按需扩展
self._load_index(ensure_integrity=True, max_rebuild=20)
def _ensure_directories(self): def _ensure_directories(self):
"""确保必要的目录存在""" """确保必要的目录存在"""
@ -56,17 +57,25 @@ class ConversationManager:
if not self.index_file.exists(): if not self.index_file.exists():
self._save_index({}) self._save_index({})
def _iter_conversation_files(self): def _iter_conversation_files(self, sort_by_mtime: bool = True):
"""遍历对话文件(排除索引文件)""" """遍历对话文件(排除索引文件),可按修改时间降序排序。"""
for path in self.conversations_dir.glob("*.json"): files = [p for p in self.conversations_dir.glob("*.json") if p != self.index_file]
if path == self.index_file: if sort_by_mtime:
continue files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
yield path return files
def _rebuild_index_from_files(self) -> Dict: def _rebuild_index_from_files(self, max_count: Optional[int] = None) -> Dict:
"""从现有对话文件重建索引""" """
从现有对话文件重建索引
Args:
max_count: 限制重建的条目数按文件修改时间倒序None 表示全量重建
"""
rebuilt_index: Dict[str, Dict] = {} rebuilt_index: Dict[str, Dict] = {}
for file_path in self._iter_conversation_files(): files = self._iter_conversation_files(sort_by_mtime=True)
if max_count is not None:
files = files[:max(0, int(max_count))]
for file_path in files:
try: try:
with open(file_path, "r", encoding="utf-8") as f: with open(file_path, "r", encoding="utf-8") as f:
raw = f.read().strip() raw = f.read().strip()
@ -108,8 +117,8 @@ class ConversationManager:
return True return True
return False return False
def _load_index(self, ensure_integrity: bool = False) -> Dict: def _load_index(self, ensure_integrity: bool = False, max_rebuild: Optional[int] = None) -> Dict:
"""加载对话索引,可选地在缺失时自动重建""" """加载对话索引,可选地在缺失时自动重建(可限制重建条数)"""
try: try:
index: Dict = {} index: Dict = {}
if self.index_file.exists(): if self.index_file.exists():
@ -120,14 +129,14 @@ class ConversationManager:
if index: if index:
if ensure_integrity and not self._index_verified: if ensure_integrity and not self._index_verified:
if self._index_missing_conversations(index): if self._index_missing_conversations(index):
rebuilt = self._rebuild_index_from_files() rebuilt = self._rebuild_index_from_files(max_count=max_rebuild)
if rebuilt: if rebuilt:
self._save_index(rebuilt) self._save_index(rebuilt)
index = rebuilt index = rebuilt
self._index_verified = True self._index_verified = True
return index return index
# 索引为空但对话文件仍然存在时尝试重建 # 索引为空但对话文件仍然存在时尝试重建
rebuilt = self._rebuild_index_from_files() rebuilt = self._rebuild_index_from_files(max_count=max_rebuild)
if rebuilt: if rebuilt:
self._save_index(rebuilt) self._save_index(rebuilt)
if ensure_integrity: if ensure_integrity:
@ -135,7 +144,7 @@ class ConversationManager:
return rebuilt return rebuilt
return {} return {}
# 索引缺失但存在对话文件时重建 # 索引缺失但存在对话文件时重建
rebuilt = self._rebuild_index_from_files() rebuilt = self._rebuild_index_from_files(max_count=max_rebuild)
if rebuilt: if rebuilt:
self._save_index(rebuilt) self._save_index(rebuilt)
if ensure_integrity: if ensure_integrity:
@ -153,7 +162,7 @@ class ConversationManager:
print(f"🗄️ 已备份损坏的索引文件到: {backup_path.name}") print(f"🗄️ 已备份损坏的索引文件到: {backup_path.name}")
except Exception as backup_exc: except Exception as backup_exc:
print(f"⚠️ 备份损坏索引文件失败: {backup_exc}") print(f"⚠️ 备份损坏索引文件失败: {backup_exc}")
rebuilt = self._rebuild_index_from_files() rebuilt = self._rebuild_index_from_files(max_count=max_rebuild)
if rebuilt: if rebuilt:
self._save_index(rebuilt) self._save_index(rebuilt)
if ensure_integrity: if ensure_integrity:
@ -176,6 +185,30 @@ class ConversationManager:
pass pass
print(f"⌘ 保存对话索引失败: {e}") print(f"⌘ 保存对话索引失败: {e}")
def _ensure_index_covering(self, limit: int, offset: int) -> Dict:
"""
确保索引涵盖到 offset+limit 条记录不足时按需扩展重建仍按 mtime 倒序增量加载批量
"""
needed = max(0, int(offset) + int(limit))
index = self._load_index()
if len(index) >= needed:
return index
# 第一次尝试:扩展到需要的数量(按更新时间倒序)
rebuilt = self._rebuild_index_from_files(max_count=needed)
if rebuilt:
self._save_index(rebuilt)
index = rebuilt
# 如果仍不足且存在更多文件可能未被纳入(例如首批限定过小),进行一次全量重建兜底
if len(index) < needed:
rebuilt_full = self._rebuild_index_from_files(max_count=None)
if rebuilt_full:
self._save_index(rebuilt_full)
index = rebuilt_full
return index
def _generate_conversation_id(self) -> str: def _generate_conversation_id(self) -> str:
"""生成唯一的对话ID""" """生成唯一的对话ID"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
@ -675,7 +708,10 @@ class ConversationManager:
Dict: 包含对话列表和统计信息 Dict: 包含对话列表和统计信息
""" """
try: try:
index = self._load_index() # 总对话数按文件数统计,防止初始索引截断导致“没有更多”按钮消失
total_files = len(self._iter_conversation_files(sort_by_mtime=False))
index = self._ensure_index_covering(limit=limit, offset=offset)
# 按更新时间倒序排列 # 按更新时间倒序排列
sorted_conversations = sorted( sorted_conversations = sorted(
@ -685,7 +721,7 @@ class ConversationManager:
) )
# 分页 # 分页
total = len(sorted_conversations) total = max(len(sorted_conversations), total_files)
conversations = sorted_conversations[offset:offset+limit] conversations = sorted_conversations[offset:offset+limit]
# 格式化结果 # 格式化结果