diff --git a/backend/package/yuxi/services/workspace_service.py b/backend/package/yuxi/services/workspace_service.py new file mode 100644 index 00000000..b4427b6b --- /dev/null +++ b/backend/package/yuxi/services/workspace_service.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import asyncio +import io +import mimetypes +from pathlib import Path, PurePosixPath +from urllib.parse import quote + +from fastapi import HTTPException +from fastapi.responses import FileResponse, StreamingResponse + +from yuxi.agents.backends.sandbox.paths import _global_user_data_dir +from yuxi.services.viewer_filesystem_service import _detect_preview_type +from yuxi.storage.postgres.models_business import User +from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp +from yuxi.utils.paths import WORKSPACE_DIR_NAME + + +def _workspace_root(user: User) -> Path: + try: + root = _global_user_data_dir(str(user.id)) / WORKSPACE_DIR_NAME + except ValueError as exc: + raise HTTPException(status_code=403, detail="Access denied") from exc + root.mkdir(parents=True, exist_ok=True) + return root.resolve() + + +def _normalize_workspace_path(path: str | None) -> PurePosixPath: + raw_path = (path or "/").strip() or "/" + if not raw_path.startswith("/"): + raw_path = f"/{raw_path}" + normalized = PurePosixPath(raw_path) + if ".." in normalized.parts: + raise HTTPException(status_code=403, detail="Access denied") + return normalized + + +def _resolve_workspace_path(user: User, path: str | None) -> Path: + root = _workspace_root(user) + normalized = _normalize_workspace_path(path) + relative_parts = [part for part in normalized.parts if part not in {"/", ""}] + target = (root.joinpath(*relative_parts) if relative_parts else root).resolve() + try: + target.relative_to(root) + except ValueError as exc: + raise HTTPException(status_code=403, detail="Access denied") from exc + return target + + +def _entry_for_path(root: Path, path: Path) -> dict: + stat = path.stat() + is_dir = path.is_dir() + relative = path.relative_to(root).as_posix() + display_path = f"/{relative}" if relative else "/" + if is_dir and display_path != "/" and not display_path.endswith("/"): + display_path = f"{display_path}/" + return { + "path": display_path, + "name": path.name or "工作区", + "is_dir": is_dir, + "size": 0 if is_dir else stat.st_size, + "modified_at": utc_isoformat_from_timestamp(stat.st_mtime) or "", + } + + +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 _list_directory(root: Path, target: Path) -> list[dict]: + entries = [_entry_for_path(root, child) for child in target.iterdir()] + return _sort_entries(entries) + + +async def list_workspace_tree(*, path: str, current_user: User) -> dict: + root = _workspace_root(current_user) + target = _resolve_workspace_path(current_user, path) + if not target.exists(): + return {"entries": []} + if not target.is_dir(): + raise HTTPException(status_code=400, detail="当前路径不是目录") + entries = await asyncio.to_thread(_list_directory, root, target) + return {"entries": entries} + + +async def read_workspace_file_content(*, path: str, current_user: User) -> dict: + target = _resolve_workspace_path(current_user, path) + if not target.exists(): + raise HTTPException(status_code=404, detail="文件不存在") + if not target.is_file(): + raise HTTPException(status_code=400, detail="当前路径是目录") + + raw_content = await asyncio.to_thread(target.read_bytes) + preview_type, supported, message = _detect_preview_type(path, raw_content) + if preview_type in {"image", "pdf"} or not supported: + return { + "content": None, + "preview_type": preview_type, + "supported": supported, + "message": message, + } + return { + "content": raw_content.decode("utf-8"), + "preview_type": preview_type, + "supported": supported, + "message": message, + } + + +async def download_workspace_file(*, path: str, current_user: User) -> StreamingResponse | FileResponse: + target = _resolve_workspace_path(current_user, path) + if not target.exists(): + raise HTTPException(status_code=404, detail="文件不存在") + if not target.is_file(): + raise HTTPException(status_code=400, detail="当前路径是目录") + + file_name = target.name or "download" + media_type = mimetypes.guess_type(file_name)[0] or "application/octet-stream" + headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{quote(file_name)}"} + if target.stat().st_size > 1024 * 1024 * 16: + return FileResponse(path=target, media_type=media_type, headers=headers) + + content = await asyncio.to_thread(target.read_bytes) + return StreamingResponse(io.BytesIO(content), media_type=media_type, headers=headers) diff --git a/backend/server/routers/__init__.py b/backend/server/routers/__init__.py index 86112ec4..0413eea3 100644 --- a/backend/server/routers/__init__.py +++ b/backend/server/routers/__init__.py @@ -15,6 +15,7 @@ from server.routers.task_router import tasks from server.routers.tool_router import tools from server.routers.apikey_router import apikey_router from server.routers.filesystem_router import filesystem_router +from server.routers.workspace_router import workspace _LITE_MODE = os.environ.get("LITE_MODE", "").lower() in ("true", "1") @@ -36,6 +37,7 @@ router.include_router(subagents_router) # /api/system/subagents/* 子智能体 router.include_router(tools) # /api/system/tools/* 工具列表与配置 router.include_router(apikey_router) # /api/apikey/* API Key 管理 router.include_router(filesystem_router) # /api/viewer/filesystem/* 工作台文件系统视图 +router.include_router(workspace) # /api/workspace/* 用户个人工作区 if not _LITE_MODE: from server.routers.graph_router import graph diff --git a/backend/server/routers/workspace_router.py b/backend/server/routers/workspace_router.py new file mode 100644 index 00000000..31c5472d --- /dev/null +++ b/backend/server/routers/workspace_router.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query + +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.storage.postgres.models_business import User + +workspace = APIRouter(prefix="/workspace", tags=["workspace"]) + + +@workspace.get("/tree", response_model=dict) +async def get_workspace_tree( + path: str = Query("/", description="工作区目录路径"), + current_user: User = Depends(get_required_user), +): + return await list_workspace_tree(path=path, current_user=current_user) + + +@workspace.get("/file", response_model=dict) +async def get_workspace_file( + path: str = Query(..., description="工作区文件路径"), + current_user: User = Depends(get_required_user), +): + return await read_workspace_file_content(path=path, current_user=current_user) + + +@workspace.get("/download") +async def download_workspace( + path: str = Query(..., description="工作区文件路径"), + current_user: User = Depends(get_required_user), +): + return await download_workspace_file(path=path, current_user=current_user) diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index a969a546..6641ac7e 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -18,6 +18,8 @@ - 完善 Skills 的环境变量注入 - 拓宽检索的知识源,统一多知识源(channel),目前已知知识库/知识图谱/网页,可拓展:个人知识库、数据库、历史对话等 - 前置任务,多知识库并行检索(扩展 query_kb) + - 新增 query_keywords 工具,专门用于基于关键词命中的排序,也结合词频(和 BM25 的区别?) +- 评估 ### Bugs - 目前的知识库的图片存在公开访问风险 @@ -35,6 +37,7 @@ ### 0.6.2 开发记录 +- 新增个人工作区预览:提供独立于对话 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 new file mode 100644 index 00000000..37519b5e --- /dev/null +++ b/web/src/apis/workspace_api.js @@ -0,0 +1,26 @@ +import { apiGet } from './base' + +const buildQuery = (params) => { + const query = new URLSearchParams() + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + query.set(key, String(value)) + } + }) + return query.toString() +} + +export const getWorkspaceTree = (path = '/') => { + const query = buildQuery({ path }) + return apiGet(`/api/workspace/tree?${query}`) +} + +export const getWorkspaceFileContent = (path) => { + const query = buildQuery({ path }) + return apiGet(`/api/workspace/file?${query}`) +} + +export const downloadWorkspaceFile = (path) => { + const query = buildQuery({ path }) + return apiGet(`/api/workspace/download?${query}`, {}, true, 'blob') +} diff --git a/web/src/assets/css/main.css b/web/src/assets/css/main.css index df6d4255..16b51757 100644 --- a/web/src/assets/css/main.css +++ b/web/src/assets/css/main.css @@ -39,7 +39,6 @@ body { .layout-container { width: 100%; - padding: 0 var(--page-padding); h2 { margin: 20px 0 10px 0; diff --git a/web/src/components/workspace/WorkspaceFileList.vue b/web/src/components/workspace/WorkspaceFileList.vue new file mode 100644 index 00000000..2f492001 --- /dev/null +++ b/web/src/components/workspace/WorkspaceFileList.vue @@ -0,0 +1,213 @@ + + + + + diff --git a/web/src/components/workspace/WorkspacePreviewPane.vue b/web/src/components/workspace/WorkspacePreviewPane.vue new file mode 100644 index 00000000..3c0b172f --- /dev/null +++ b/web/src/components/workspace/WorkspacePreviewPane.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/web/src/components/workspace/WorkspaceSidebar.vue b/web/src/components/workspace/WorkspaceSidebar.vue new file mode 100644 index 00000000..5d90bfe5 --- /dev/null +++ b/web/src/components/workspace/WorkspaceSidebar.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/web/src/layouts/AppLayout.vue b/web/src/layouts/AppLayout.vue index e6d17ced..f5520446 100644 --- a/web/src/layouts/AppLayout.vue +++ b/web/src/layouts/AppLayout.vue @@ -8,6 +8,7 @@ import { ClipboardList, Blocks, Box, + FolderKanban, PanelLeftClose, PanelLeftOpen, MessageCirclePlus @@ -131,6 +132,13 @@ const mainList = computed(() => { } ] + items.push({ + name: '工作区', + path: '/workspace', + icon: FolderKanban, + activeIcon: FolderKanban + }) + if (userStore.isAdmin) { if (!isLiteMode) { items.push({ diff --git a/web/src/router/index.js b/web/src/router/index.js index 6de4557f..8ab901c6 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -51,6 +51,19 @@ const router = createRouter({ } ] }, + { + path: '/workspace', + name: 'workspace', + component: AppLayout, + children: [ + { + path: '', + name: 'WorkspaceComp', + component: () => import('../views/WorkspaceView.vue'), + meta: { keepAlive: true, requiresAuth: true } + } + ] + }, { path: '/graph', name: 'graph', diff --git a/web/src/views/WorkspaceView.vue b/web/src/views/WorkspaceView.vue new file mode 100644 index 00000000..045d0bf7 --- /dev/null +++ b/web/src/views/WorkspaceView.vue @@ -0,0 +1,343 @@ + + + + + + +