feat(filesystem): 增强文件删除功能,支持递归删除目录并添加保护路径检查

This commit is contained in:
Wenjie Zhang 2026-04-01 04:04:55 +08:00
parent 061067aa89
commit 9bc86c070c
5 changed files with 230 additions and 37 deletions

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import io import io
import mimetypes import mimetypes
import shutil
from pathlib import PurePosixPath from pathlib import PurePosixPath
from urllib.parse import quote 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.services.filesystem_service import _resolve_filesystem_state
from yuxi.storage.postgres.models_business import User from yuxi.storage.postgres.models_business import User
from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp 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"}) _MARKDOWN_EXTENSIONS = frozenset({".md", ".markdown", ".mdx"})
_PDF_EXTENSIONS = frozenset({".pdf"}) _PDF_EXTENSIONS = frozenset({".pdf"})
@ -79,6 +81,13 @@ _BINARY_SIGNATURES = (
b"GIF89a", b"GIF89a",
b"RIFF", 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]: 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): if not _is_user_data_path(normalized_path):
raise HTTPException(status_code=400, detail="当前路径不支持删除") raise HTTPException(status_code=400, detail="当前路径不支持删除")
if normalized_path in _PROTECTED_USER_DATA_ROOTS:
raise HTTPException(status_code=400, detail="当前目录不允许删除")
try: try:
actual_path = resolve_virtual_path(thread_id, normalized_path) actual_path = resolve_virtual_path(thread_id, normalized_path)
if not actual_path.exists(): if not actual_path.exists():
raise HTTPException(status_code=404, detail="文件不存在") raise HTTPException(status_code=404, detail="文件不存在")
if actual_path.is_dir(): if actual_path.is_dir():
raise HTTPException(status_code=400, detail="当前路径是目录") await asyncio.to_thread(shutil.rmtree, actual_path)
await asyncio.to_thread(actual_path.unlink) else:
await asyncio.to_thread(actual_path.unlink)
except PermissionError as e: except PermissionError as e:
raise HTTPException(status_code=400, detail=str(e)) from e raise HTTPException(status_code=400, detail=str(e)) from e
except ValueError as e: except ValueError as e:

View File

@ -80,6 +80,23 @@ async def _download(
return response.headers.get("content-disposition", ""), response.content 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( async def test_viewer_filesystem_e2e_respects_workspace_sharing_and_thread_local_uploads(
e2e_client: httpx.AsyncClient, e2e_client: httpx.AsyncClient,
e2e_headers: dict[str, str], 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 "result.txt" in content_disposition, content_disposition
assert payload == b"viewer-output\n", payload 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)

View File

@ -362,6 +362,104 @@ async def test_viewer_delete_removes_user_data_file(test_client, standard_user):
assert file_path not in paths 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): async def test_viewer_tree_root_hides_kbs_namespace_when_no_database_is_visible(test_client, standard_user):
headers = standard_user["headers"] headers = standard_user["headers"]
thread_id = await _create_thread_for_user(test_client, headers) thread_id = await _create_thread_for_user(test_client, headers)

View File

@ -58,9 +58,11 @@
### 修复 ### 修复
- 收敛“状态工作台”自动弹出规则:前端不再因为共享 `workspace` 或文件系统天然存在内容而默认展开,改为仅在 `/home/gem/user-data/uploads``/home/gem/user-data/outputs` 下检测到实际文件时自动弹出;手动打开、关闭、刷新和伸缩交互保持不变
- 调整智能体 todo 展示语义:待办状态不再作为 `capabilities` 前端开关,而是直接根据运行态 `agent_state.todos` 渲染;同时将 todo 入口从 Agent Panel 移到输入框内的轻量浮层,并让右侧“状态工作台”收敛为文件系统视图,输入框按钮文案同步由“状态”调整为“文件” - 调整智能体 todo 展示语义:待办状态不再作为 `capabilities` 前端开关,而是直接根据运行态 `agent_state.todos` 渲染;同时将 todo 入口从 Agent Panel 移到输入框内的轻量浮层,并让右侧“状态工作台”收敛为文件系统视图,输入框按钮文案同步由“状态”调整为“文件”
- 优化 Agent 输入框 mention 行为:在保留附件 mention 的同时,将共享 `workspace` 文件纳入候选范围;并将 `@` 空查询时的候选列表改为空,仅在继续输入后再执行筛选,避免工作区文件过多时直接铺满下拉面板 - 优化 Agent 输入框 mention 行为:在保留附件 mention 的同时,将共享 `workspace` 文件纳入候选范围;并将 `@` 空查询时的候选列表改为空,仅在继续输入后再执行筛选,避免工作区文件过多时直接铺满下拉面板
- 为前端工作台文件树补齐文件删除能力:`/api/viewer/filesystem/file` 新增删除接口,`AgentPanel` 文件节点新增删除按钮与确认交互,删除后会同步刷新树与预览状态 - 为前端工作台文件树补齐文件删除能力:`/api/viewer/filesystem/file` 新增删除接口,`AgentPanel` 文件节点新增删除按钮与确认交互,删除后会同步刷新树与预览状态
- 扩展 Agent Panel 状态工作台删除能力:继续复用 `DELETE /api/viewer/filesystem/file`,在保持接口不变的前提下支持删除文件夹;空目录与非空目录现在都会递归删除,`workspace` 下目录也可直接清理,前端目录节点同步新增删除入口与对应确认文案
- 调整前端工作台文件预览交互:恢复默认侧边/弹窗预览,并新增显式“全屏预览”入口;全屏模式下由预览内容直接覆盖整页,仅保留右上角悬浮关闭按钮;同时修复 HTML 文件首次在弹窗中预览偶现白屏的问题,改为在内容更新后强制重建 `iframe` - 调整前端工作台文件预览交互:恢复默认侧边/弹窗预览,并新增显式“全屏预览”入口;全屏模式下由预览内容直接覆盖整页,仅保留右上角悬浮关闭按钮;同时修复 HTML 文件首次在弹窗中预览偶现白屏的问题,改为在内容更新后强制重建 `iframe`
- 统一 Agent Panel 文件预览与消息区交付物预览组件:两处改为复用同一套 `AgentFilePreview` 预览实现,并为交付物预览补齐与工作台一致的“全屏预览”入口 - 统一 Agent Panel 文件预览与消息区交付物预览组件:两处改为复用同一套 `AgentFilePreview` 预览实现,并为交付物预览补齐与工作台一致的“全屏预览”入口
- 兼容旧版已安装的内置 `reporter` 技能记录:`update_builtin_skill` 现在会识别由 `system``builtin-system` 管理的历史记录,避免更新时误报“技能 `reporter` 不是内置 skill” - 兼容旧版已安装的内置 `reporter` 技能记录:`update_builtin_skill` 现在会识别由 `system``builtin-system` 管理的历史记录,避免更新时误报“技能 `reporter` 不是内置 skill”

View File

@ -55,8 +55,9 @@
</div> </div>
</template> </template>
<template #actions="{ node }"> <template #actions="{ node }">
<div v-if="node.isLeaf" class="node-actions-container"> <div class="node-actions-container">
<button <button
v-if="node.isLeaf"
class="tree-action-btn tree-download-btn" class="tree-action-btn tree-download-btn"
@click.stop="downloadFile(node.fileData)" @click.stop="downloadFile(node.fileData)"
title="下载文件" title="下载文件"
@ -66,8 +67,8 @@
<button <button
class="tree-action-btn tree-delete-btn" class="tree-action-btn tree-delete-btn"
:disabled="deletingPaths.has(node.key)" :disabled="deletingPaths.has(node.key)"
@click.stop="confirmDeleteFile(node)" @click.stop="confirmDeleteNode(node)"
title="删除文件" :title="node.isLeaf ? '删除文件' : '删除文件夹'"
> >
<Trash2 :size="14" /> <Trash2 :size="14" />
</button> </button>
@ -127,7 +128,15 @@
<script setup> <script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { ChevronsDownUp, ChevronsUpDown, Download, FolderCode, RefreshCw, Trash2, X } from 'lucide-vue-next' import {
ChevronsDownUp,
ChevronsUpDown,
Download,
FolderCode,
RefreshCw,
Trash2,
X
} from 'lucide-vue-next'
import { Modal, message } from 'ant-design-vue' import { Modal, message } from 'ant-design-vue'
import FileTreeComponent from '@/components/FileTreeComponent.vue' import FileTreeComponent from '@/components/FileTreeComponent.vue'
import AgentFilePreview from '@/components/AgentFilePreview.vue' import AgentFilePreview from '@/components/AgentFilePreview.vue'
@ -271,6 +280,17 @@ const removeTreeNode = (nodes, targetKey) => {
}, []) }, [])
} }
const normalizePathKey = (path) => String(path || '').replace(/\/+$/, '')
const isSameOrChildPath = (path, targetPath) => {
const normalizedPath = normalizePathKey(path)
const normalizedTargetPath = normalizePathKey(targetPath)
if (!normalizedPath || !normalizedTargetPath) return false
return (
normalizedPath === normalizedTargetPath || normalizedPath.startsWith(`${normalizedTargetPath}/`)
)
}
const parseDownloadFilename = (contentDisposition) => { const parseDownloadFilename = (contentDisposition) => {
if (!contentDisposition) return '' if (!contentDisposition) return ''
@ -429,11 +449,21 @@ const closePreview = () => {
selectedKeys.value = [] selectedKeys.value = []
} }
const confirmDeleteFile = (node) => { const pruneTreeStateAfterDelete = (targetPath) => {
selectedKeys.value = selectedKeys.value.filter((key) => !isSameOrChildPath(key, targetPath))
expandedKeys.value = expandedKeys.value.filter((key) => !isSameOrChildPath(key, targetPath))
if (isSameOrChildPath(currentFilePath.value, targetPath)) {
closePreview()
}
}
const confirmDeleteNode = (node) => {
const fileName = node?.title || getFileName(node?.fileData) const fileName = node?.title || getFileName(node?.fileData)
const isDirectory = !node?.isLeaf
Modal.confirm({ Modal.confirm({
title: `确认删除文件「${fileName}」?`, title: isDirectory ? `确认删除文件夹「${fileName}」?` : `确认删除文件「${fileName}」?`,
content: '删除后不可恢复。', content: isDirectory ? '将删除该文件夹及其所有内容,删除后不可恢复。' : '删除后不可恢复。',
okText: '删除', okText: '删除',
okType: 'danger', okType: 'danger',
cancelText: '取消', cancelText: '取消',
@ -445,14 +475,11 @@ const confirmDeleteFile = (node) => {
try { try {
await deleteViewerFile(props.threadId, node.key, props.agentId, props.agentConfigId) await deleteViewerFile(props.threadId, node.key, props.agentId, props.agentConfigId)
dynamicTreeData.value = removeTreeNode(dynamicTreeData.value, node.key) dynamicTreeData.value = removeTreeNode(dynamicTreeData.value, node.key)
selectedKeys.value = selectedKeys.value.filter((key) => key !== node.key) pruneTreeStateAfterDelete(node.key)
if (currentFilePath.value === node.key) { message.success(isDirectory ? '文件夹删除成功' : '文件删除成功')
closePreview()
}
message.success('文件删除成功')
} catch (error) { } catch (error) {
console.error('删除文件失败:', error) console.error(isDirectory ? '删除文件夹失败:' : '删除文件失败:', error)
message.error(error?.message || '删除文件失败') message.error(error?.message || (isDirectory ? '删除文件夹失败' : '删除文件失败'))
} finally { } finally {
const latestDeletingPaths = new Set(deletingPaths.value) const latestDeletingPaths = new Set(deletingPaths.value)
latestDeletingPaths.delete(node.key) latestDeletingPaths.delete(node.key)
@ -1087,30 +1114,30 @@ watch(useInlinePreview, (isInline) => {
<style lang="less"> <style lang="less">
.agent-file-preview-modal { .agent-file-preview-modal {
.ant-modal { .ant-modal {
z-index: 1050; z-index: 1050;
.ant-modal-content { .ant-modal-content {
border-radius: 8px; border-radius: 8px;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
border: 1px solid var(--gray-200); border: 1px solid var(--gray-200);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
} }
:deep(.ant-modal-header) { :deep(.ant-modal-header) {
background: var(--main-5); background: var(--main-5);
border-bottom: 1px solid var(--gray-200); border-bottom: 1px solid var(--gray-200);
padding: 16px 20px; padding: 16px 20px;
} }
:deep(.ant-modal-title) { :deep(.ant-modal-title) {
font-weight: 600; font-weight: 600;
color: var(--gray-1000); color: var(--gray-1000);
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
} }
:deep(.ant-modal-body) { :deep(.ant-modal-body) {
padding: 0; padding: 0;
}
} }
} }
}
</style> </style>