feat(filesystem): 增强文件删除功能,支持递归删除目录并添加保护路径检查
This commit is contained in:
parent
061067aa89
commit
9bc86c070c
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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”
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user