feat: 新增对于 workspace 的删除接口的实现

- 为前端工作台文件树补齐文件删除能力:`/api/viewer/filesystem/file`
- 新增删除接口,`AgentPanel` 文件节点新增删除按钮与确认交互,删除后会同步刷新树与预览状态
This commit is contained in:
Wenjie Zhang 2026-03-31 19:41:08 +08:00
parent ea4cecc354
commit 3f1ec778a4
6 changed files with 171 additions and 3 deletions

View File

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

View File

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

View File

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

View File

@ -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 测试

View File

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

View File

@ -98,6 +98,14 @@
>
<Download :size="14" />
</button>
<button
class="tree-action-btn tree-delete-btn"
:disabled="deletingPaths.has(node.key)"
@click.stop="confirmDeleteFile(node)"
title="删除文件"
>
<Trash2 :size="14" />
</button>
</div>
</template>
</FileTreeComponent>
@ -153,7 +161,15 @@
<script setup>
import { computed, onMounted, onUnmounted, onUpdated, nextTick, ref, watch } from 'vue'
import { ChevronsDownUp, ChevronsUpDown, Download, FolderCode, RefreshCw, X } from 'lucide-vue-next'
import {
ChevronsDownUp,
ChevronsUpDown,
Download,
FolderCode,
RefreshCw,
Trash2,
X
} from 'lucide-vue-next'
import {
CheckCircleOutlined,
SyncOutlined,
@ -161,9 +177,11 @@ import {
CloseCircleOutlined,
QuestionCircleOutlined
} from '@ant-design/icons-vue'
import { Modal, message } from 'ant-design-vue'
import FileTreeComponent from '@/components/FileTreeComponent.vue'
import AgentFilePreview from '@/components/AgentFilePreview.vue'
import {
deleteViewerFile,
downloadViewerFile,
getViewerFileContent,
getViewerFileSystemTree
@ -219,6 +237,7 @@ const panelWidth = ref(0)
const dynamicTreeData = ref([])
const selectedKeys = ref([])
const expandedKeys = ref([])
const deletingPaths = ref(new Set())
const useInlinePreview = computed(() => panelWidth.value >= INLINE_PREVIEW_MIN_WIDTH)
@ -342,6 +361,24 @@ const updateTreeChildren = (nodes, targetKey, children) => {
})
}
const removeTreeNode = (nodes, targetKey) => {
return nodes.reduce((result, node) => {
if (node.key === targetKey) {
return result
}
const nextNode = node.children?.length
? {
...node,
children: removeTreeNode(node.children, targetKey)
}
: node
result.push(nextNode)
return result
}, [])
}
const parseDownloadFilename = (contentDisposition) => {
if (!contentDisposition) return ''
@ -500,6 +537,39 @@ const closePreview = () => {
selectedKeys.value = []
}
const confirmDeleteFile = (node) => {
const fileName = node?.title || getFileName(node?.fileData)
Modal.confirm({
title: `确认删除文件「${fileName}」?`,
content: '删除后不可恢复。',
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
const nextDeletingPaths = new Set(deletingPaths.value)
nextDeletingPaths.add(node.key)
deletingPaths.value = nextDeletingPaths
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('文件删除成功')
} catch (error) {
console.error('删除文件失败:', error)
message.error(error?.message || '删除文件失败')
} finally {
const latestDeletingPaths = new Set(deletingPaths.value)
latestDeletingPaths.delete(node.key)
deletingPaths.value = latestDeletingPaths
}
}
})
}
const downloadFile = async (fileItem) => {
if (!props.threadId || !fileItem?.path) return
@ -1118,6 +1188,11 @@ watch(useInlinePreview, (isInline) => {
color: var(--gray-500);
cursor: pointer;
padding: 0;
&:disabled {
cursor: not-allowed;
opacity: 0.45;
}
}
.tree-download-btn:hover {