diff --git a/backend/package/yuxi/services/viewer_filesystem_service.py b/backend/package/yuxi/services/viewer_filesystem_service.py index a935b185..780f9b25 100644 --- a/backend/package/yuxi/services/viewer_filesystem_service.py +++ b/backend/package/yuxi/services/viewer_filesystem_service.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import io import mimetypes +import shutil from pathlib import PurePosixPath from urllib.parse import quote @@ -24,6 +25,7 @@ from yuxi.agents.middlewares.skills_middleware import normalize_selected_skills from yuxi.services.filesystem_service import _resolve_filesystem_state from yuxi.storage.postgres.models_business import User from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp +from yuxi.utils.paths import VIRTUAL_PATH_OUTPUTS, VIRTUAL_PATH_UPLOADS, VIRTUAL_PATH_WORKSPACE _MARKDOWN_EXTENSIONS = frozenset({".md", ".markdown", ".mdx"}) _PDF_EXTENSIONS = frozenset({".pdf"}) @@ -79,6 +81,13 @@ _BINARY_SIGNATURES = ( b"GIF89a", b"RIFF", ) +_PROTECTED_USER_DATA_ROOTS = frozenset( + { + VIRTUAL_PATH_WORKSPACE, + VIRTUAL_PATH_UPLOADS, + VIRTUAL_PATH_OUTPUTS, + } +) def _detect_preview_type(path: str, raw_content: bytes) -> tuple[str, bool, str | None]: @@ -532,14 +541,17 @@ async def delete_viewer_file( if not _is_user_data_path(normalized_path): raise HTTPException(status_code=400, detail="当前路径不支持删除") + if normalized_path in _PROTECTED_USER_DATA_ROOTS: + raise HTTPException(status_code=400, detail="当前目录不允许删除") try: actual_path = resolve_virtual_path(thread_id, normalized_path) if not actual_path.exists(): raise HTTPException(status_code=404, detail="文件不存在") if actual_path.is_dir(): - raise HTTPException(status_code=400, detail="当前路径是目录") - await asyncio.to_thread(actual_path.unlink) + await asyncio.to_thread(shutil.rmtree, actual_path) + else: + await asyncio.to_thread(actual_path.unlink) except PermissionError as e: raise HTTPException(status_code=400, detail=str(e)) from e except ValueError as e: diff --git a/backend/test/e2e/test_viewer_filesystem_e2e.py b/backend/test/e2e/test_viewer_filesystem_e2e.py index 5fb23f7c..64151357 100644 --- a/backend/test/e2e/test_viewer_filesystem_e2e.py +++ b/backend/test/e2e/test_viewer_filesystem_e2e.py @@ -80,6 +80,23 @@ async def _download( return response.headers.get("content-disposition", ""), response.content +async def _delete( + client: httpx.AsyncClient, + headers: dict[str, str], + *, + agent_id: str, + thread_id: str, + path: str, +) -> dict: + response = await client.delete( + "/api/viewer/filesystem/file", + params={"thread_id": thread_id, "path": path, "agent_id": agent_id}, + headers=headers, + ) + assert response.status_code == 200, response.text + return dict(response.json()) + + async def test_viewer_filesystem_e2e_respects_workspace_sharing_and_thread_local_uploads( e2e_client: httpx.AsyncClient, e2e_headers: dict[str, str], @@ -193,3 +210,40 @@ async def test_viewer_filesystem_e2e_respects_workspace_sharing_and_thread_local ) assert "result.txt" in content_disposition, content_disposition assert payload == b"viewer-output\n", payload + + +async def test_viewer_filesystem_e2e_deletes_workspace_directory_recursively( + e2e_client: httpx.AsyncClient, + e2e_headers: dict[str, str], + e2e_agent_context: dict[str, str | int], +): + agent_id = str(e2e_agent_context["agent_id"]) + thread_id = await _create_thread(e2e_client, e2e_headers, agent_id) + + ensure_thread_dirs(thread_id) + target_dir = sandbox_workspace_dir(thread_id) / "delete-dir" + nested_dir = target_dir / "deep" + nested_dir.mkdir(parents=True) + (nested_dir / "artifact.txt").write_text("delete me\n", encoding="utf-8") + + delete_payload = await _delete( + e2e_client, + e2e_headers, + agent_id=agent_id, + thread_id=thread_id, + path="/home/gem/user-data/workspace/delete-dir", + ) + assert delete_payload.get("success") is True, delete_payload + assert not target_dir.exists() + + workspace_paths = { + str(entry.get("path", "")) + for entry in await _tree( + e2e_client, + e2e_headers, + agent_id=agent_id, + thread_id=thread_id, + path="/home/gem/user-data/workspace", + ) + } + assert "/home/gem/user-data/workspace/delete-dir/" not in workspace_paths, sorted(workspace_paths) diff --git a/backend/test/integration/api/test_viewer_filesystem_router.py b/backend/test/integration/api/test_viewer_filesystem_router.py index e3b79747..6f3ea5d3 100644 --- a/backend/test/integration/api/test_viewer_filesystem_router.py +++ b/backend/test/integration/api/test_viewer_filesystem_router.py @@ -362,6 +362,104 @@ async def test_viewer_delete_removes_user_data_file(test_client, standard_user): assert file_path not in paths +async def test_viewer_delete_removes_empty_user_data_directory(test_client, standard_user): + headers = standard_user["headers"] + thread_id = await _create_thread_for_user(test_client, headers) + + ensure_thread_dirs(thread_id) + actual_path = sandbox_workspace_dir(thread_id) / "empty-folder" + actual_path.mkdir() + dir_path = virtual_path_for_thread_file(thread_id, actual_path) + + delete_response = await test_client.delete( + "/api/viewer/filesystem/file", + params={"thread_id": thread_id, "path": dir_path}, + headers=headers, + ) + assert delete_response.status_code == 200, delete_response.text + assert delete_response.json()["success"] is True + assert not actual_path.exists() + + tree_response = await test_client.get( + "/api/viewer/filesystem/tree", + params={"thread_id": thread_id, "path": "/home/gem/user-data/workspace"}, + headers=headers, + ) + assert tree_response.status_code == 200, tree_response.text + paths = {entry.get("path") for entry in tree_response.json().get("entries", [])} + assert f"{dir_path}/" not in paths + + +async def test_viewer_delete_recursively_removes_user_data_directory(test_client, standard_user): + headers = standard_user["headers"] + thread_id = await _create_thread_for_user(test_client, headers) + + ensure_thread_dirs(thread_id) + actual_path = sandbox_workspace_dir(thread_id) / "nested-folder" + nested_dir = actual_path / "child" + nested_dir.mkdir(parents=True) + nested_file = nested_dir / "notes.txt" + nested_file.write_text("remove recursively", encoding="utf-8") + dir_path = virtual_path_for_thread_file(thread_id, actual_path) + + delete_response = await test_client.delete( + "/api/viewer/filesystem/file", + params={"thread_id": thread_id, "path": dir_path}, + headers=headers, + ) + assert delete_response.status_code == 200, delete_response.text + assert delete_response.json()["success"] is True + assert not actual_path.exists() + assert not nested_file.exists() + + tree_response = await test_client.get( + "/api/viewer/filesystem/tree", + params={"thread_id": thread_id, "path": "/home/gem/user-data/workspace"}, + headers=headers, + ) + assert tree_response.status_code == 200, tree_response.text + paths = {entry.get("path") for entry in tree_response.json().get("entries", [])} + assert f"{dir_path}/" not in paths + + +async def test_viewer_delete_rejects_readonly_namespace_directory(test_client, standard_user): + headers = standard_user["headers"] + thread_id = await _create_thread_for_user(test_client, headers) + + response = await test_client.delete( + "/api/viewer/filesystem/file", + params={"thread_id": thread_id, "path": "/home/gem/skills"}, + headers=headers, + ) + assert response.status_code == 400, response.text + assert response.json()["detail"] == "当前路径不支持删除" + + +@pytest.mark.parametrize( + "protected_path", + [ + "/home/gem/user-data/workspace", + "/home/gem/user-data/uploads", + "/home/gem/user-data/outputs", + ], +) +async def test_viewer_delete_rejects_protected_user_data_root_directories( + test_client, standard_user, protected_path: str +): + headers = standard_user["headers"] + thread_id = await _create_thread_for_user(test_client, headers) + + ensure_thread_dirs(thread_id) + + response = await test_client.delete( + "/api/viewer/filesystem/file", + params={"thread_id": thread_id, "path": protected_path}, + headers=headers, + ) + assert response.status_code == 400, response.text + assert response.json()["detail"] == "当前目录不允许删除" + + async def test_viewer_tree_root_hides_kbs_namespace_when_no_database_is_visible(test_client, standard_user): headers = standard_user["headers"] thread_id = await _create_thread_for_user(test_client, headers) diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index ff7a13bb..8ccd9e98 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -58,9 +58,11 @@ ### 修复 +- 收敛“状态工作台”自动弹出规则:前端不再因为共享 `workspace` 或文件系统天然存在内容而默认展开,改为仅在 `/home/gem/user-data/uploads` 或 `/home/gem/user-data/outputs` 下检测到实际文件时自动弹出;手动打开、关闭、刷新和伸缩交互保持不变 - 调整智能体 todo 展示语义:待办状态不再作为 `capabilities` 前端开关,而是直接根据运行态 `agent_state.todos` 渲染;同时将 todo 入口从 Agent Panel 移到输入框内的轻量浮层,并让右侧“状态工作台”收敛为文件系统视图,输入框按钮文案同步由“状态”调整为“文件” - 优化 Agent 输入框 mention 行为:在保留附件 mention 的同时,将共享 `workspace` 文件纳入候选范围;并将 `@` 空查询时的候选列表改为空,仅在继续输入后再执行筛选,避免工作区文件过多时直接铺满下拉面板 - 为前端工作台文件树补齐文件删除能力:`/api/viewer/filesystem/file` 新增删除接口,`AgentPanel` 文件节点新增删除按钮与确认交互,删除后会同步刷新树与预览状态 +- 扩展 Agent Panel 状态工作台删除能力:继续复用 `DELETE /api/viewer/filesystem/file`,在保持接口不变的前提下支持删除文件夹;空目录与非空目录现在都会递归删除,`workspace` 下目录也可直接清理,前端目录节点同步新增删除入口与对应确认文案 - 调整前端工作台文件预览交互:恢复默认侧边/弹窗预览,并新增显式“全屏预览”入口;全屏模式下由预览内容直接覆盖整页,仅保留右上角悬浮关闭按钮;同时修复 HTML 文件首次在弹窗中预览偶现白屏的问题,改为在内容更新后强制重建 `iframe` - 统一 Agent Panel 文件预览与消息区交付物预览组件:两处改为复用同一套 `AgentFilePreview` 预览实现,并为交付物预览补齐与工作台一致的“全屏预览”入口 - 兼容旧版已安装的内置 `reporter` 技能记录:`update_builtin_skill` 现在会识别由 `system` 或 `builtin-system` 管理的历史记录,避免更新时误报“技能 `reporter` 不是内置 skill” diff --git a/web/src/components/AgentPanel.vue b/web/src/components/AgentPanel.vue index 02ae45a8..4fbedc12 100644 --- a/web/src/components/AgentPanel.vue +++ b/web/src/components/AgentPanel.vue @@ -55,8 +55,9 @@