From 35f09f0bc8681e43a8e7acd48043799baa131e7b Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Thu, 30 Apr 2026 22:07:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yuxi/services/workspace_service.py | 97 ++++- backend/server/routers/workspace_router.py | 46 +- docs/develop-guides/roadmap.md | 2 +- web/src/apis/workspace_api.js | 21 +- .../workspace/WorkspaceFileList.vue | 192 ++++++++- .../components/workspace/WorkspaceSidebar.vue | 2 +- web/src/views/WorkspaceView.vue | 400 ++++++++++++++++-- 7 files changed, 712 insertions(+), 48 deletions(-) diff --git a/backend/package/yuxi/services/workspace_service.py b/backend/package/yuxi/services/workspace_service.py index b4427b6b..081e218d 100644 --- a/backend/package/yuxi/services/workspace_service.py +++ b/backend/package/yuxi/services/workspace_service.py @@ -1,12 +1,15 @@ from __future__ import annotations import asyncio +import contextlib import io import mimetypes +import shutil from pathlib import Path, PurePosixPath from urllib.parse import quote -from fastapi import HTTPException +import aiofiles +from fastapi import HTTPException, UploadFile from fastapi.responses import FileResponse, StreamingResponse from yuxi.agents.backends.sandbox.paths import _global_user_data_dir @@ -67,6 +70,37 @@ def _sort_entries(entries: list[dict]) -> list[dict]: return sorted(entries, key=lambda item: (not bool(item.get("is_dir")), str(item.get("name") or "").lower())) +def _validate_child_name(name: str, *, field_name: str) -> str: + clean_name = str(name or "").strip() + if not clean_name: + raise HTTPException(status_code=422, detail=f"{field_name} 不能为空") + if clean_name in {".", ".."} or "/" in clean_name or "\\" in clean_name: + raise HTTPException(status_code=422, detail=f"{field_name} 不能包含路径分隔符") + if PurePosixPath(clean_name).name != clean_name: + raise HTTPException(status_code=422, detail=f"{field_name} 不能包含路径分隔符") + return clean_name + + +def _resolve_parent_directory(user: User, parent_path: str) -> Path: + parent = _resolve_workspace_path(user, parent_path) + if not parent.exists(): + raise HTTPException(status_code=404, detail="目标目录不存在") + if not parent.is_dir(): + raise HTTPException(status_code=400, detail="目标路径不是目录") + return parent + + +def _resolve_new_child(root: Path, parent: Path, name: str) -> Path: + target = parent / name + try: + target.resolve(strict=False).relative_to(root) + except ValueError as exc: + raise HTTPException(status_code=403, detail="Access denied") from exc + if target.exists(): + raise HTTPException(status_code=400, detail="同名文件或文件夹已存在") + return target + + def _list_directory(root: Path, target: Path) -> list[dict]: entries = [_entry_for_path(root, child) for child in target.iterdir()] return _sort_entries(entries) @@ -107,6 +141,67 @@ async def read_workspace_file_content(*, path: str, current_user: User) -> dict: } +async def delete_workspace_path(*, path: str, current_user: User) -> dict: + root = _workspace_root(current_user) + target = _resolve_workspace_path(current_user, path) + if target == root: + raise HTTPException(status_code=400, detail="工作区根目录不允许删除") + if not target.exists(): + raise HTTPException(status_code=404, detail="文件不存在") + + try: + if target.is_dir(): + await asyncio.to_thread(shutil.rmtree, target) + else: + await asyncio.to_thread(target.unlink) + except PermissionError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return {"success": True, "path": _normalize_workspace_path(path).as_posix()} + + +async def create_workspace_directory(*, parent_path: str, name: str, current_user: User) -> dict: + root = _workspace_root(current_user) + directory_name = _validate_child_name(name, field_name="文件夹名") + parent = _resolve_parent_directory(current_user, parent_path) + target = _resolve_new_child(root, parent, directory_name) + + try: + await asyncio.to_thread(target.mkdir) + except FileExistsError as exc: + raise HTTPException(status_code=400, detail="同名文件或文件夹已存在") from exc + except PermissionError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return {"success": True, "entry": _entry_for_path(root, target)} + + +async def upload_workspace_file(*, parent_path: str, file: UploadFile, current_user: User) -> dict: + root = _workspace_root(current_user) + file_name = _validate_child_name(Path(file.filename or "").name, field_name="文件名") + parent = _resolve_parent_directory(current_user, parent_path) + target = _resolve_new_child(root, parent, file_name) + created_file = False + upload_completed = False + + try: + async with aiofiles.open(target, "xb") as buffer: + created_file = True + while chunk := await file.read(1024 * 1024): + await buffer.write(chunk) + upload_completed = True + except FileExistsError as exc: + raise HTTPException(status_code=400, detail="同名文件或文件夹已存在") from exc + except PermissionError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + finally: + if created_file and not upload_completed and target.exists(): + with contextlib.suppress(OSError): + await asyncio.to_thread(target.unlink) + + return {"success": True, "entry": _entry_for_path(root, target)} + + async def download_workspace_file(*, path: str, current_user: User) -> StreamingResponse | FileResponse: target = _resolve_workspace_path(current_user, path) if not target.exists(): diff --git a/backend/server/routers/workspace_router.py b/backend/server/routers/workspace_router.py index 31c5472d..ba713266 100644 --- a/backend/server/routers/workspace_router.py +++ b/backend/server/routers/workspace_router.py @@ -1,14 +1,27 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, File, Form, Query, UploadFile +from pydantic import BaseModel from server.utils.auth_middleware import get_required_user -from yuxi.services.workspace_service import download_workspace_file, list_workspace_tree, read_workspace_file_content +from yuxi.services.workspace_service import ( + create_workspace_directory, + delete_workspace_path, + download_workspace_file, + list_workspace_tree, + read_workspace_file_content, + upload_workspace_file, +) from yuxi.storage.postgres.models_business import User workspace = APIRouter(prefix="/workspace", tags=["workspace"]) +class CreateWorkspaceDirectoryRequest(BaseModel): + parent_path: str + name: str + + @workspace.get("/tree", response_model=dict) async def get_workspace_tree( path: str = Query("/", description="工作区目录路径"), @@ -25,6 +38,35 @@ async def get_workspace_file( return await read_workspace_file_content(path=path, current_user=current_user) +@workspace.delete("/file", response_model=dict) +async def delete_workspace_file_route( + path: str = Query(..., description="工作区文件或目录路径"), + current_user: User = Depends(get_required_user), +): + return await delete_workspace_path(path=path, current_user=current_user) + + +@workspace.post("/directory", response_model=dict) +async def create_workspace_directory_route( + payload: CreateWorkspaceDirectoryRequest, + current_user: User = Depends(get_required_user), +): + return await create_workspace_directory( + parent_path=payload.parent_path, + name=payload.name, + current_user=current_user, + ) + + +@workspace.post("/upload", response_model=dict) +async def upload_workspace_file_route( + parent_path: str = Form(..., description="父目录路径"), + file: UploadFile = File(..., description="上传文件"), + current_user: User = Depends(get_required_user), +): + return await upload_workspace_file(parent_path=parent_path, file=file, current_user=current_user) + + @workspace.get("/download") async def download_workspace( path: str = Query(..., description="工作区文件路径"), diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 6641ac7e..10fd42d1 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -37,7 +37,7 @@ ### 0.6.2 开发记录 -- 新增个人工作区预览:提供独立于对话 thread 的用户级 workspace 只读 API,并增加“工作区”页面,用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF;知识库与团队空间入口先展示到占位层级。 +- 新增个人工作区预览与管理:提供独立于对话 thread 的用户级 workspace API,并增加“工作区”页面,用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF;支持新建文件夹、上传文件、下载文件、删除文件/文件夹和多选删除;知识库与团队空间入口先展示到占位层级。 - 扩展管理界面交互逻辑重构:将 MCP / Subagents / Skills 三个标签页从「左侧边栏 + 右侧详情面板」布局重构为「卡片式网格布局 + 路由跳转二级页面」布局,工具标签页改为卡片网格布局 + 弹窗详情(保持弹窗内容不变)。新增共享组件 `ExtensionCard`、`ExtensionCardGrid`、`ExtensionToolbar`、`ExtensionDetailLayout`,详情页(`McpDetailView`、`SubagentDetailView`、`SkillDetailView`)使用居中宽度限制,路由规划为 `/extensions/mcp/:name`、`/extensions/subagent/:name`、`/extensions/skill/:slug`。 - 统一卡片样式:`ExtensionCard` 新增 `tags` prop 支持传入 `[{label, color}]` 数组,内部使用 `` 渲染,与知识库卡片标签风格统一;知识库列表页 `DataBaseView` 改用 `ExtensionCard` + `ExtensionCardGrid` 替代原有自定义卡片,移除冗余 card 样式。 - 调整应用主导航:`AppLayout` 从默认窄栏升级为默认展开的侧边栏,保留折叠态图标导航;侧边栏样式收敛为 14px 文本 + 18px 图标的标准紧凑密度,并统一导航项、任务中心、GitHub、用户信息的图标与文字对齐。折叠态改为仅通过显式按钮展开,避免空白区域误触发。 diff --git a/web/src/apis/workspace_api.js b/web/src/apis/workspace_api.js index 37519b5e..83a56715 100644 --- a/web/src/apis/workspace_api.js +++ b/web/src/apis/workspace_api.js @@ -1,4 +1,4 @@ -import { apiGet } from './base' +import { apiDelete, apiGet, apiPost } from './base' const buildQuery = (params) => { const query = new URLSearchParams() @@ -20,6 +20,25 @@ export const getWorkspaceFileContent = (path) => { return apiGet(`/api/workspace/file?${query}`) } +export const deleteWorkspacePath = (path) => { + const query = buildQuery({ path }) + return apiDelete(`/api/workspace/file?${query}`) +} + +export const createWorkspaceDirectory = (parentPath, name) => { + return apiPost('/api/workspace/directory', { + parent_path: parentPath, + name + }) +} + +export const uploadWorkspaceFile = (parentPath, file) => { + const formData = new FormData() + formData.append('parent_path', parentPath) + formData.append('file', file) + return apiPost('/api/workspace/upload', formData) +} + export const downloadWorkspaceFile = (path) => { const query = buildQuery({ path }) return apiGet(`/api/workspace/download?${query}`, {}, true, 'blob') diff --git a/web/src/components/workspace/WorkspaceFileList.vue b/web/src/components/workspace/WorkspaceFileList.vue index 2f492001..0373c8c1 100644 --- a/web/src/components/workspace/WorkspaceFileList.vue +++ b/web/src/components/workspace/WorkspaceFileList.vue @@ -13,24 +13,62 @@ {{ currentPath }} - {{ entries.length }} 项 +
+ {{ entries.length }} 项 + + 多选 + + + 删除选中 + +
-
+
+ + + 名称 大小 修改时间 + 操作
- + + + + + + +
@@ -54,17 +121,65 @@