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

View File

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

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

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 移到输入框内的轻量浮层,并让右侧“状态工作台”收敛为文件系统视图,输入框按钮文案同步由“状态”调整为“文件”
- 优化 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”

View File

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