diff --git a/backend/package/yuxi/services/viewer_filesystem_service.py b/backend/package/yuxi/services/viewer_filesystem_service.py
index 2a6d466c..a935b185 100644
--- a/backend/package/yuxi/services/viewer_filesystem_service.py
+++ b/backend/package/yuxi/services/viewer_filesystem_service.py
@@ -507,3 +507,42 @@ async def download_viewer_file(
"Content-Disposition": f"attachment; filename*=UTF-8''{quote(file_name)}",
}
return StreamingResponse(stream, media_type=media_type, headers=headers)
+
+
+async def delete_viewer_file(
+ *,
+ thread_id: str,
+ path: str,
+ agent_id: str | None,
+ agent_config_id: int | None,
+ current_user: User,
+ db: AsyncSession,
+) -> dict:
+ if not thread_id:
+ raise HTTPException(status_code=422, detail="thread_id 不能为空")
+
+ normalized_path = _normalize_path(path)
+ await _resolve_viewer_state(
+ thread_id=thread_id,
+ agent_id=agent_id,
+ agent_config_id=agent_config_id,
+ current_user=current_user,
+ db=db,
+ )
+
+ if not _is_user_data_path(normalized_path):
+ 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)
+ except PermissionError as e:
+ raise HTTPException(status_code=400, detail=str(e)) from e
+ except ValueError as e:
+ raise HTTPException(status_code=422, detail=str(e)) from e
+
+ return {"success": True, "path": normalized_path}
diff --git a/backend/server/routers/filesystem_router.py b/backend/server/routers/filesystem_router.py
index 72e28aee..f99458a9 100644
--- a/backend/server/routers/filesystem_router.py
+++ b/backend/server/routers/filesystem_router.py
@@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from server.utils.auth_middleware import get_db, get_required_user
from yuxi.services.viewer_filesystem_service import (
+ delete_viewer_file,
download_viewer_file,
list_viewer_filesystem_tree,
read_viewer_file_content,
@@ -58,6 +59,25 @@ async def get_viewer_file(
)
+@filesystem_router.delete("/file", response_model=dict)
+async def delete_viewer_file_route(
+ thread_id: str = Query(..., description="线程 ID"),
+ path: str = Query(..., description="文件路径"),
+ agent_id: str | None = Query(None, description="智能体 ID"),
+ agent_config_id: int | None = Query(None, description="智能体配置 ID"),
+ current_user: User = Depends(get_required_user),
+ db: AsyncSession = Depends(get_db),
+):
+ return await delete_viewer_file(
+ thread_id=thread_id,
+ path=path,
+ agent_id=agent_id,
+ agent_config_id=agent_config_id,
+ current_user=current_user,
+ db=db,
+ )
+
+
@filesystem_router.get("/download")
async def download_viewer(
thread_id: str = Query(..., description="线程 ID"),
diff --git a/backend/test/integration/api/test_viewer_filesystem_router.py b/backend/test/integration/api/test_viewer_filesystem_router.py
index 80775ef0..e3b79747 100644
--- a/backend/test/integration/api/test_viewer_filesystem_router.py
+++ b/backend/test/integration/api/test_viewer_filesystem_router.py
@@ -334,6 +334,34 @@ async def test_viewer_download_returns_full_file_for_large_user_data_content(tes
assert response.content == large_content.encode("utf-8")
+async def test_viewer_delete_removes_user_data_file(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) / "delete_me.txt"
+ actual_path.write_text("delete me", encoding="utf-8")
+ file_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": file_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 file_path not in paths
+
+
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 1403e334..e2443909 100644
--- a/docs/develop-guides/roadmap.md
+++ b/docs/develop-guides/roadmap.md
@@ -57,7 +57,8 @@
### 修复
-- 调整前端工作台文件预览交互:恢复默认侧边/弹窗预览,并新增显式“全屏预览”入口;全屏模式下由预览内容直接覆盖整页,仅保留右上角悬浮关闭按钮
+- 为前端工作台文件树补齐文件删除能力:`/api/viewer/filesystem/file` 新增删除接口,`AgentPanel` 文件节点新增删除按钮与确认交互,删除后会同步刷新树与预览状态
+- 调整前端工作台文件预览交互:恢复默认侧边/弹窗预览,并新增显式“全屏预览”入口;全屏模式下由预览内容直接覆盖整页,仅保留右上角悬浮关闭按钮;
- 统一 Agent Panel 文件预览与消息区交付物预览组件:两处改为复用同一套 `AgentFilePreview` 预览实现,并为交付物预览补齐与工作台一致的“全屏预览”入口
- 兼容旧版已安装的内置 `reporter` 技能记录:`update_builtin_skill` 现在会识别由 `system` 或 `builtin-system` 管理的历史记录,避免更新时误报“技能 `reporter` 不是内置 skill”
- 调整沙盒 user-data 目录隔离策略:`workspace` 改为共享目录 `saves/threads/shared/workspace`,`uploads/outputs` 继续保持 thread 级隔离;同时更新 thread artifact 权限校验、viewer 文件系统列举逻辑,以及对应的 router/E2E 测试
diff --git a/web/src/apis/viewer_filesystem.js b/web/src/apis/viewer_filesystem.js
index 1f7ec21f..c99f58af 100644
--- a/web/src/apis/viewer_filesystem.js
+++ b/web/src/apis/viewer_filesystem.js
@@ -1,4 +1,4 @@
-import { apiGet } from './base'
+import { apiDelete, apiGet } from './base'
const buildQuery = (params) => {
const query = new URLSearchParams()
@@ -38,3 +38,8 @@ export const downloadViewerFile = (threadId, path, agentId = null, agentConfigId
const query = buildViewerQuery(threadId, path, agentId, agentConfigId)
return apiGet(`/api/viewer/filesystem/download?${query}`, {}, true, 'blob')
}
+
+export const deleteViewerFile = (threadId, path, agentId = null, agentConfigId = null) => {
+ const query = buildViewerQuery(threadId, path, agentId, agentConfigId)
+ return apiDelete(`/api/viewer/filesystem/file?${query}`)
+}
diff --git a/web/src/components/AgentPanel.vue b/web/src/components/AgentPanel.vue
index 00d0ca0e..70aea378 100644
--- a/web/src/components/AgentPanel.vue
+++ b/web/src/components/AgentPanel.vue
@@ -98,6 +98,14 @@
>
+
@@ -153,7 +161,15 @@