feat: 新增对于 workspace 的删除接口的实现
- 为前端工作台文件树补齐文件删除能力:`/api/viewer/filesystem/file` - 新增删除接口,`AgentPanel` 文件节点新增删除按钮与确认交互,删除后会同步刷新树与预览状态
This commit is contained in:
parent
ea4cecc354
commit
3f1ec778a4
@ -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}
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 测试
|
||||
|
||||
@ -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}`)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user