Skip to content

Commit 9bc86c0

Browse files
committed
feat(filesystem): 增强文件删除功能,支持递归删除目录并添加保护路径检查
1 parent 061067a commit 9bc86c0

File tree

5 files changed

+230
-37
lines changed

5 files changed

+230
-37
lines changed

backend/package/yuxi/services/viewer_filesystem_service.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
import io
55
import mimetypes
6+
import shutil
67
from pathlib import PurePosixPath
78
from urllib.parse import quote
89

@@ -24,6 +25,7 @@
2425
from yuxi.services.filesystem_service import _resolve_filesystem_state
2526
from yuxi.storage.postgres.models_business import User
2627
from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp
28+
from yuxi.utils.paths import VIRTUAL_PATH_OUTPUTS, VIRTUAL_PATH_UPLOADS, VIRTUAL_PATH_WORKSPACE
2729

2830
_MARKDOWN_EXTENSIONS = frozenset({".md", ".markdown", ".mdx"})
2931
_PDF_EXTENSIONS = frozenset({".pdf"})
@@ -79,6 +81,13 @@
7981
b"GIF89a",
8082
b"RIFF",
8183
)
84+
_PROTECTED_USER_DATA_ROOTS = frozenset(
85+
{
86+
VIRTUAL_PATH_WORKSPACE,
87+
VIRTUAL_PATH_UPLOADS,
88+
VIRTUAL_PATH_OUTPUTS,
89+
}
90+
)
8291

8392

8493
def _detect_preview_type(path: str, raw_content: bytes) -> tuple[str, bool, str | None]:
@@ -532,14 +541,17 @@ async def delete_viewer_file(
532541

533542
if not _is_user_data_path(normalized_path):
534543
raise HTTPException(status_code=400, detail="当前路径不支持删除")
544+
if normalized_path in _PROTECTED_USER_DATA_ROOTS:
545+
raise HTTPException(status_code=400, detail="当前目录不允许删除")
535546

536547
try:
537548
actual_path = resolve_virtual_path(thread_id, normalized_path)
538549
if not actual_path.exists():
539550
raise HTTPException(status_code=404, detail="文件不存在")
540551
if actual_path.is_dir():
541-
raise HTTPException(status_code=400, detail="当前路径是目录")
542-
await asyncio.to_thread(actual_path.unlink)
552+
await asyncio.to_thread(shutil.rmtree, actual_path)
553+
else:
554+
await asyncio.to_thread(actual_path.unlink)
543555
except PermissionError as e:
544556
raise HTTPException(status_code=400, detail=str(e)) from e
545557
except ValueError as e:

backend/test/e2e/test_viewer_filesystem_e2e.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,23 @@ async def _download(
8080
return response.headers.get("content-disposition", ""), response.content
8181

8282

83+
async def _delete(
84+
client: httpx.AsyncClient,
85+
headers: dict[str, str],
86+
*,
87+
agent_id: str,
88+
thread_id: str,
89+
path: str,
90+
) -> dict:
91+
response = await client.delete(
92+
"/api/viewer/filesystem/file",
93+
params={"thread_id": thread_id, "path": path, "agent_id": agent_id},
94+
headers=headers,
95+
)
96+
assert response.status_code == 200, response.text
97+
return dict(response.json())
98+
99+
83100
async def test_viewer_filesystem_e2e_respects_workspace_sharing_and_thread_local_uploads(
84101
e2e_client: httpx.AsyncClient,
85102
e2e_headers: dict[str, str],
@@ -193,3 +210,40 @@ async def test_viewer_filesystem_e2e_respects_workspace_sharing_and_thread_local
193210
)
194211
assert "result.txt" in content_disposition, content_disposition
195212
assert payload == b"viewer-output\n", payload
213+
214+
215+
async def test_viewer_filesystem_e2e_deletes_workspace_directory_recursively(
216+
e2e_client: httpx.AsyncClient,
217+
e2e_headers: dict[str, str],
218+
e2e_agent_context: dict[str, str | int],
219+
):
220+
agent_id = str(e2e_agent_context["agent_id"])
221+
thread_id = await _create_thread(e2e_client, e2e_headers, agent_id)
222+
223+
ensure_thread_dirs(thread_id)
224+
target_dir = sandbox_workspace_dir(thread_id) / "delete-dir"
225+
nested_dir = target_dir / "deep"
226+
nested_dir.mkdir(parents=True)
227+
(nested_dir / "artifact.txt").write_text("delete me\n", encoding="utf-8")
228+
229+
delete_payload = await _delete(
230+
e2e_client,
231+
e2e_headers,
232+
agent_id=agent_id,
233+
thread_id=thread_id,
234+
path="/home/gem/user-data/workspace/delete-dir",
235+
)
236+
assert delete_payload.get("success") is True, delete_payload
237+
assert not target_dir.exists()
238+
239+
workspace_paths = {
240+
str(entry.get("path", ""))
241+
for entry in await _tree(
242+
e2e_client,
243+
e2e_headers,
244+
agent_id=agent_id,
245+
thread_id=thread_id,
246+
path="/home/gem/user-data/workspace",
247+
)
248+
}
249+
assert "/home/gem/user-data/workspace/delete-dir/" not in workspace_paths, sorted(workspace_paths)

backend/test/integration/api/test_viewer_filesystem_router.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,104 @@ async def test_viewer_delete_removes_user_data_file(test_client, standard_user):
362362
assert file_path not in paths
363363

364364

365+
async def test_viewer_delete_removes_empty_user_data_directory(test_client, standard_user):
366+
headers = standard_user["headers"]
367+
thread_id = await _create_thread_for_user(test_client, headers)
368+
369+
ensure_thread_dirs(thread_id)
370+
actual_path = sandbox_workspace_dir(thread_id) / "empty-folder"
371+
actual_path.mkdir()
372+
dir_path = virtual_path_for_thread_file(thread_id, actual_path)
373+
374+
delete_response = await test_client.delete(
375+
"/api/viewer/filesystem/file",
376+
params={"thread_id": thread_id, "path": dir_path},
377+
headers=headers,
378+
)
379+
assert delete_response.status_code == 200, delete_response.text
380+
assert delete_response.json()["success"] is True
381+
assert not actual_path.exists()
382+
383+
tree_response = await test_client.get(
384+
"/api/viewer/filesystem/tree",
385+
params={"thread_id": thread_id, "path": "/home/gem/user-data/workspace"},
386+
headers=headers,
387+
)
388+
assert tree_response.status_code == 200, tree_response.text
389+
paths = {entry.get("path") for entry in tree_response.json().get("entries", [])}
390+
assert f"{dir_path}/" not in paths
391+
392+
393+
async def test_viewer_delete_recursively_removes_user_data_directory(test_client, standard_user):
394+
headers = standard_user["headers"]
395+
thread_id = await _create_thread_for_user(test_client, headers)
396+
397+
ensure_thread_dirs(thread_id)
398+
actual_path = sandbox_workspace_dir(thread_id) / "nested-folder"
399+
nested_dir = actual_path / "child"
400+
nested_dir.mkdir(parents=True)
401+
nested_file = nested_dir / "notes.txt"
402+
nested_file.write_text("remove recursively", encoding="utf-8")
403+
dir_path = virtual_path_for_thread_file(thread_id, actual_path)
404+
405+
delete_response = await test_client.delete(
406+
"/api/viewer/filesystem/file",
407+
params={"thread_id": thread_id, "path": dir_path},
408+
headers=headers,
409+
)
410+
assert delete_response.status_code == 200, delete_response.text
411+
assert delete_response.json()["success"] is True
412+
assert not actual_path.exists()
413+
assert not nested_file.exists()
414+
415+
tree_response = await test_client.get(
416+
"/api/viewer/filesystem/tree",
417+
params={"thread_id": thread_id, "path": "/home/gem/user-data/workspace"},
418+
headers=headers,
419+
)
420+
assert tree_response.status_code == 200, tree_response.text
421+
paths = {entry.get("path") for entry in tree_response.json().get("entries", [])}
422+
assert f"{dir_path}/" not in paths
423+
424+
425+
async def test_viewer_delete_rejects_readonly_namespace_directory(test_client, standard_user):
426+
headers = standard_user["headers"]
427+
thread_id = await _create_thread_for_user(test_client, headers)
428+
429+
response = await test_client.delete(
430+
"/api/viewer/filesystem/file",
431+
params={"thread_id": thread_id, "path": "/home/gem/skills"},
432+
headers=headers,
433+
)
434+
assert response.status_code == 400, response.text
435+
assert response.json()["detail"] == "当前路径不支持删除"
436+
437+
438+
@pytest.mark.parametrize(
439+
"protected_path",
440+
[
441+
"/home/gem/user-data/workspace",
442+
"/home/gem/user-data/uploads",
443+
"/home/gem/user-data/outputs",
444+
],
445+
)
446+
async def test_viewer_delete_rejects_protected_user_data_root_directories(
447+
test_client, standard_user, protected_path: str
448+
):
449+
headers = standard_user["headers"]
450+
thread_id = await _create_thread_for_user(test_client, headers)
451+
452+
ensure_thread_dirs(thread_id)
453+
454+
response = await test_client.delete(
455+
"/api/viewer/filesystem/file",
456+
params={"thread_id": thread_id, "path": protected_path},
457+
headers=headers,
458+
)
459+
assert response.status_code == 400, response.text
460+
assert response.json()["detail"] == "当前目录不允许删除"
461+
462+
365463
async def test_viewer_tree_root_hides_kbs_namespace_when_no_database_is_visible(test_client, standard_user):
366464
headers = standard_user["headers"]
367465
thread_id = await _create_thread_for_user(test_client, headers)

docs/develop-guides/roadmap.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,11 @@
5858

5959
### 修复
6060

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

web/src/components/AgentPanel.vue

Lines changed: 62 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,9 @@
5555
</div>
5656
</template>
5757
<template #actions="{ node }">
58-
<div v-if="node.isLeaf" class="node-actions-container">
58+
<div class="node-actions-container">
5959
<button
60+
v-if="node.isLeaf"
6061
class="tree-action-btn tree-download-btn"
6162
@click.stop="downloadFile(node.fileData)"
6263
title="下载文件"
@@ -66,8 +67,8 @@
6667
<button
6768
class="tree-action-btn tree-delete-btn"
6869
:disabled="deletingPaths.has(node.key)"
69-
@click.stop="confirmDeleteFile(node)"
70-
title="删除文件"
70+
@click.stop="confirmDeleteNode(node)"
71+
:title="node.isLeaf ? '删除文件' : '删除文件夹'"
7172
>
7273
<Trash2 :size="14" />
7374
</button>
@@ -127,7 +128,15 @@
127128

128129
<script setup>
129130
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
130-
import { ChevronsDownUp, ChevronsUpDown, Download, FolderCode, RefreshCw, Trash2, X } from 'lucide-vue-next'
131+
import {
132+
ChevronsDownUp,
133+
ChevronsUpDown,
134+
Download,
135+
FolderCode,
136+
RefreshCw,
137+
Trash2,
138+
X
139+
} from 'lucide-vue-next'
131140
import { Modal, message } from 'ant-design-vue'
132141
import FileTreeComponent from '@/components/FileTreeComponent.vue'
133142
import AgentFilePreview from '@/components/AgentFilePreview.vue'
@@ -271,6 +280,17 @@ const removeTreeNode = (nodes, targetKey) => {
271280
}, [])
272281
}
273282
283+
const normalizePathKey = (path) => String(path || '').replace(/\/+$/, '')
284+
285+
const isSameOrChildPath = (path, targetPath) => {
286+
const normalizedPath = normalizePathKey(path)
287+
const normalizedTargetPath = normalizePathKey(targetPath)
288+
if (!normalizedPath || !normalizedTargetPath) return false
289+
return (
290+
normalizedPath === normalizedTargetPath || normalizedPath.startsWith(`${normalizedTargetPath}/`)
291+
)
292+
}
293+
274294
const parseDownloadFilename = (contentDisposition) => {
275295
if (!contentDisposition) return ''
276296
@@ -429,11 +449,21 @@ const closePreview = () => {
429449
selectedKeys.value = []
430450
}
431451
432-
const confirmDeleteFile = (node) => {
452+
const pruneTreeStateAfterDelete = (targetPath) => {
453+
selectedKeys.value = selectedKeys.value.filter((key) => !isSameOrChildPath(key, targetPath))
454+
expandedKeys.value = expandedKeys.value.filter((key) => !isSameOrChildPath(key, targetPath))
455+
456+
if (isSameOrChildPath(currentFilePath.value, targetPath)) {
457+
closePreview()
458+
}
459+
}
460+
461+
const confirmDeleteNode = (node) => {
433462
const fileName = node?.title || getFileName(node?.fileData)
463+
const isDirectory = !node?.isLeaf
434464
Modal.confirm({
435-
title: `确认删除文件「${fileName}」?`,
436-
content: '删除后不可恢复。',
465+
title: isDirectory ? `确认删除文件夹「${fileName}」?` : `确认删除文件「${fileName}」?`,
466+
content: isDirectory ? '将删除该文件夹及其所有内容,删除后不可恢复。' : '删除后不可恢复。',
437467
okText: '删除',
438468
okType: 'danger',
439469
cancelText: '取消',
@@ -445,14 +475,11 @@ const confirmDeleteFile = (node) => {
445475
try {
446476
await deleteViewerFile(props.threadId, node.key, props.agentId, props.agentConfigId)
447477
dynamicTreeData.value = removeTreeNode(dynamicTreeData.value, node.key)
448-
selectedKeys.value = selectedKeys.value.filter((key) => key !== node.key)
449-
if (currentFilePath.value === node.key) {
450-
closePreview()
451-
}
452-
message.success('文件删除成功')
478+
pruneTreeStateAfterDelete(node.key)
479+
message.success(isDirectory ? '文件夹删除成功' : '文件删除成功')
453480
} catch (error) {
454-
console.error('删除文件失败:', error)
455-
message.error(error?.message || '删除文件失败')
481+
console.error(isDirectory ? '删除文件夹失败:' : '删除文件失败:', error)
482+
message.error(error?.message || (isDirectory ? '删除文件夹失败' : '删除文件失败'))
456483
} finally {
457484
const latestDeletingPaths = new Set(deletingPaths.value)
458485
latestDeletingPaths.delete(node.key)
@@ -1087,30 +1114,30 @@ watch(useInlinePreview, (isInline) => {
10871114
<style lang="less">
10881115
.agent-file-preview-modal {
10891116
.ant-modal {
1090-
z-index: 1050;
1091-
.ant-modal-content {
1092-
border-radius: 8px;
1093-
padding: 0;
1094-
overflow: hidden;
1095-
border: 1px solid var(--gray-200);
1096-
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
1097-
}
1117+
z-index: 1050;
1118+
.ant-modal-content {
1119+
border-radius: 8px;
1120+
padding: 0;
1121+
overflow: hidden;
1122+
border: 1px solid var(--gray-200);
1123+
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
1124+
}
10981125
1099-
:deep(.ant-modal-header) {
1100-
background: var(--main-5);
1101-
border-bottom: 1px solid var(--gray-200);
1102-
padding: 16px 20px;
1103-
}
1126+
:deep(.ant-modal-header) {
1127+
background: var(--main-5);
1128+
border-bottom: 1px solid var(--gray-200);
1129+
padding: 16px 20px;
1130+
}
11041131
1105-
:deep(.ant-modal-title) {
1106-
font-weight: 600;
1107-
color: var(--gray-1000);
1108-
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
1109-
}
1132+
:deep(.ant-modal-title) {
1133+
font-weight: 600;
1134+
color: var(--gray-1000);
1135+
font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
1136+
}
11101137
1111-
:deep(.ant-modal-body) {
1112-
padding: 0;
1138+
:deep(.ant-modal-body) {
1139+
padding: 0;
1140+
}
11131141
}
11141142
}
1115-
}
11161143
</style>

0 commit comments

Comments
 (0)