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)}",
|
"Content-Disposition": f"attachment; filename*=UTF-8''{quote(file_name)}",
|
||||||
}
|
}
|
||||||
return StreamingResponse(stream, media_type=media_type, headers=headers)
|
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 server.utils.auth_middleware import get_db, get_required_user
|
||||||
from yuxi.services.viewer_filesystem_service import (
|
from yuxi.services.viewer_filesystem_service import (
|
||||||
|
delete_viewer_file,
|
||||||
download_viewer_file,
|
download_viewer_file,
|
||||||
list_viewer_filesystem_tree,
|
list_viewer_filesystem_tree,
|
||||||
read_viewer_file_content,
|
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")
|
@filesystem_router.get("/download")
|
||||||
async def download_viewer(
|
async def download_viewer(
|
||||||
thread_id: str = Query(..., description="线程 ID"),
|
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")
|
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):
|
async def test_viewer_tree_root_hides_kbs_namespace_when_no_database_is_visible(test_client, standard_user):
|
||||||
headers = standard_user["headers"]
|
headers = standard_user["headers"]
|
||||||
thread_id = await _create_thread_for_user(test_client, headers)
|
thread_id = await _create_thread_for_user(test_client, headers)
|
||||||
|
|||||||
@ -57,7 +57,8 @@
|
|||||||
|
|
||||||
### 修复
|
### 修复
|
||||||
|
|
||||||
- 调整前端工作台文件预览交互:恢复默认侧边/弹窗预览,并新增显式“全屏预览”入口;全屏模式下由预览内容直接覆盖整页,仅保留右上角悬浮关闭按钮
|
- 为前端工作台文件树补齐文件删除能力:`/api/viewer/filesystem/file` 新增删除接口,`AgentPanel` 文件节点新增删除按钮与确认交互,删除后会同步刷新树与预览状态
|
||||||
|
- 调整前端工作台文件预览交互:恢复默认侧边/弹窗预览,并新增显式“全屏预览”入口;全屏模式下由预览内容直接覆盖整页,仅保留右上角悬浮关闭按钮;
|
||||||
- 统一 Agent Panel 文件预览与消息区交付物预览组件:两处改为复用同一套 `AgentFilePreview` 预览实现,并为交付物预览补齐与工作台一致的“全屏预览”入口
|
- 统一 Agent Panel 文件预览与消息区交付物预览组件:两处改为复用同一套 `AgentFilePreview` 预览实现,并为交付物预览补齐与工作台一致的“全屏预览”入口
|
||||||
- 兼容旧版已安装的内置 `reporter` 技能记录:`update_builtin_skill` 现在会识别由 `system` 或 `builtin-system` 管理的历史记录,避免更新时误报“技能 `reporter` 不是内置 skill”
|
- 兼容旧版已安装的内置 `reporter` 技能记录:`update_builtin_skill` 现在会识别由 `system` 或 `builtin-system` 管理的历史记录,避免更新时误报“技能 `reporter` 不是内置 skill”
|
||||||
- 调整沙盒 user-data 目录隔离策略:`workspace` 改为共享目录 `saves/threads/shared/workspace`,`uploads/outputs` 继续保持 thread 级隔离;同时更新 thread artifact 权限校验、viewer 文件系统列举逻辑,以及对应的 router/E2E 测试
|
- 调整沙盒 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 buildQuery = (params) => {
|
||||||
const query = new URLSearchParams()
|
const query = new URLSearchParams()
|
||||||
@ -38,3 +38,8 @@ export const downloadViewerFile = (threadId, path, agentId = null, agentConfigId
|
|||||||
const query = buildViewerQuery(threadId, path, agentId, agentConfigId)
|
const query = buildViewerQuery(threadId, path, agentId, agentConfigId)
|
||||||
return apiGet(`/api/viewer/filesystem/download?${query}`, {}, true, 'blob')
|
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" />
|
<Download :size="14" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="tree-action-btn tree-delete-btn"
|
||||||
|
:disabled="deletingPaths.has(node.key)"
|
||||||
|
@click.stop="confirmDeleteFile(node)"
|
||||||
|
title="删除文件"
|
||||||
|
>
|
||||||
|
<Trash2 :size="14" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</FileTreeComponent>
|
</FileTreeComponent>
|
||||||
@ -153,7 +161,15 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, onUpdated, nextTick, ref, watch } from 'vue'
|
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 {
|
import {
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
SyncOutlined,
|
SyncOutlined,
|
||||||
@ -161,9 +177,11 @@ import {
|
|||||||
CloseCircleOutlined,
|
CloseCircleOutlined,
|
||||||
QuestionCircleOutlined
|
QuestionCircleOutlined
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
|
import { Modal, message } from 'ant-design-vue'
|
||||||
import FileTreeComponent from '@/components/FileTreeComponent.vue'
|
import FileTreeComponent from '@/components/FileTreeComponent.vue'
|
||||||
import AgentFilePreview from '@/components/AgentFilePreview.vue'
|
import AgentFilePreview from '@/components/AgentFilePreview.vue'
|
||||||
import {
|
import {
|
||||||
|
deleteViewerFile,
|
||||||
downloadViewerFile,
|
downloadViewerFile,
|
||||||
getViewerFileContent,
|
getViewerFileContent,
|
||||||
getViewerFileSystemTree
|
getViewerFileSystemTree
|
||||||
@ -219,6 +237,7 @@ const panelWidth = ref(0)
|
|||||||
const dynamicTreeData = ref([])
|
const dynamicTreeData = ref([])
|
||||||
const selectedKeys = ref([])
|
const selectedKeys = ref([])
|
||||||
const expandedKeys = ref([])
|
const expandedKeys = ref([])
|
||||||
|
const deletingPaths = ref(new Set())
|
||||||
|
|
||||||
const useInlinePreview = computed(() => panelWidth.value >= INLINE_PREVIEW_MIN_WIDTH)
|
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) => {
|
const parseDownloadFilename = (contentDisposition) => {
|
||||||
if (!contentDisposition) return ''
|
if (!contentDisposition) return ''
|
||||||
|
|
||||||
@ -500,6 +537,39 @@ const closePreview = () => {
|
|||||||
selectedKeys.value = []
|
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) => {
|
const downloadFile = async (fileItem) => {
|
||||||
if (!props.threadId || !fileItem?.path) return
|
if (!props.threadId || !fileItem?.path) return
|
||||||
|
|
||||||
@ -1118,6 +1188,11 @@ watch(useInlinePreview, (isInline) => {
|
|||||||
color: var(--gray-500);
|
color: var(--gray-500);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-download-btn:hover {
|
.tree-download-btn:hover {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user