diff --git a/backend/package/yuxi/services/workspace_service.py b/backend/package/yuxi/services/workspace_service.py index 14fe0e74..970c2d18 100644 --- a/backend/package/yuxi/services/workspace_service.py +++ b/backend/package/yuxi/services/workspace_service.py @@ -19,6 +19,9 @@ from yuxi.utils.datetime_utils import utc_isoformat_from_timestamp from yuxi.utils.paths import WORKSPACE_DIR_NAME +EDITABLE_WORKSPACE_SUFFIXES = {".md", ".markdown", ".mdx", ".txt"} + + def _workspace_root(user: User) -> Path: try: root = _global_user_data_dir(str(user.id)) / WORKSPACE_DIR_NAME @@ -143,6 +146,37 @@ async def read_workspace_file_content(*, path: str, current_user: User) -> dict: } +async def write_workspace_file_content(*, path: str, content: str, current_user: User) -> dict: + root = _workspace_root(current_user) + 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="当前路径是目录") + if target.suffix.lower() not in EDITABLE_WORKSPACE_SUFFIXES: + 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 not in {"markdown", "text"} or not supported: + raise HTTPException(status_code=400, detail="当前文件类型不支持编辑") + try: + raw_content.decode("utf-8") + except UnicodeDecodeError as exc: + raise HTTPException(status_code=400, detail="当前文件不是 UTF-8 文本") from exc + + try: + await asyncio.to_thread(target.write_text, content, encoding="utf-8") + except PermissionError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return { + "success": True, + "path": _normalize_workspace_path(path).as_posix(), + "entry": _entry_for_path(root, target), + } + + async def delete_workspace_path(*, path: str, current_user: User) -> dict: root = _workspace_root(current_user) target = _resolve_workspace_path(current_user, path) diff --git a/backend/server/routers/workspace_router.py b/backend/server/routers/workspace_router.py index ba713266..671c08a4 100644 --- a/backend/server/routers/workspace_router.py +++ b/backend/server/routers/workspace_router.py @@ -11,6 +11,7 @@ from yuxi.services.workspace_service import ( list_workspace_tree, read_workspace_file_content, upload_workspace_file, + write_workspace_file_content, ) from yuxi.storage.postgres.models_business import User @@ -22,6 +23,11 @@ class CreateWorkspaceDirectoryRequest(BaseModel): name: str +class UpdateWorkspaceFileContentRequest(BaseModel): + path: str + content: str + + @workspace.get("/tree", response_model=dict) async def get_workspace_tree( path: str = Query("/", description="工作区目录路径"), @@ -38,6 +44,18 @@ async def get_workspace_file( return await read_workspace_file_content(path=path, current_user=current_user) +@workspace.put("/file", response_model=dict) +async def update_workspace_file( + payload: UpdateWorkspaceFileContentRequest, + current_user: User = Depends(get_required_user), +): + return await write_workspace_file_content( + path=payload.path, + content=payload.content, + current_user=current_user, + ) + + @workspace.delete("/file", response_model=dict) async def delete_workspace_file_route( path: str = Query(..., description="工作区文件或目录路径"), diff --git a/backend/test/unit/services/test_workspace_service.py b/backend/test/unit/services/test_workspace_service.py index 38d3b0ee..798fe47d 100644 --- a/backend/test/unit/services/test_workspace_service.py +++ b/backend/test/unit/services/test_workspace_service.py @@ -3,6 +3,9 @@ from __future__ import annotations from pathlib import Path from types import SimpleNamespace +import pytest +from fastapi import HTTPException + from yuxi.agents.backends.sandbox import paths as workspace_paths from yuxi.services import workspace_service as svc @@ -38,3 +41,76 @@ def test_workspace_root_keeps_existing_agents_prompt_file(tmp_path: Path, monkey assert root == tmp_path / "threads" / "shared" / "user-1" / "workspace" assert agents_file.read_text(encoding="utf-8") == "保留已有内容" + + +@pytest.mark.asyncio +async def test_write_workspace_file_content_updates_markdown_file(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path)) + user = SimpleNamespace(id="user-1") + root = svc._workspace_root(user) + target = root / "note.md" + target.write_text("旧内容", encoding="utf-8") + + result = await svc.write_workspace_file_content(path="/note.md", content="# 新内容", current_user=user) + + assert result["success"] is True + assert result["path"] == "/note.md" + assert result["entry"]["path"] == "/note.md" + assert target.read_text(encoding="utf-8") == "# 新内容" + + +@pytest.mark.asyncio +async def test_write_workspace_file_content_updates_txt_file(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path)) + user = SimpleNamespace(id="user-1") + root = svc._workspace_root(user) + target = root / "note.txt" + target.write_text("old", encoding="utf-8") + + await svc.write_workspace_file_content(path="/note.txt", content="new", current_user=user) + + assert target.read_text(encoding="utf-8") == "new" + + +@pytest.mark.asyncio +async def test_write_workspace_file_content_rejects_unsupported_suffix(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path)) + user = SimpleNamespace(id="user-1") + root = svc._workspace_root(user) + target = root / "script.py" + target.write_text("print('hello')", encoding="utf-8") + + with pytest.raises(HTTPException) as exc_info: + await svc.write_workspace_file_content(path="/script.py", content="print('bye')", current_user=user) + + assert exc_info.value.status_code == 400 + assert target.read_text(encoding="utf-8") == "print('hello')" + + +@pytest.mark.asyncio +async def test_write_workspace_file_content_rejects_directory_and_missing_file(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path)) + user = SimpleNamespace(id="user-1") + svc._workspace_root(user) + + with pytest.raises(HTTPException) as directory_error: + await svc.write_workspace_file_content(path="/agents/", content="x", current_user=user) + with pytest.raises(HTTPException) as missing_error: + await svc.write_workspace_file_content(path="/missing.md", content="x", current_user=user) + + assert directory_error.value.status_code == 400 + assert missing_error.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_write_workspace_file_content_blocks_path_traversal(tmp_path: Path, monkeypatch) -> None: + monkeypatch.setattr(workspace_paths.conf, "save_dir", str(tmp_path)) + + with pytest.raises(HTTPException) as exc_info: + await svc.write_workspace_file_content( + path="/../outside.md", + content="x", + current_user=SimpleNamespace(id="user-1"), + ) + + assert exc_info.value.status_code == 403 diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index f7bdb033..9b59a963 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;支持新建文件夹、上传文件、下载文件、删除文件/文件夹和多选删除;知识库与团队空间入口先展示到占位层级;默认创建 `agents/AGENTS.md`,并在 Agent 执行时将其内容追加到系统提示词。 +- 新增个人工作区预览与管理:提供独立于对话 thread 的用户级 workspace API,并增加“工作区”页面,用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF;支持新建文件夹、上传文件、下载文件、删除文件/文件夹和多选删除;工作区预览支持 Markdown/TXT 在右侧预览框内切换编辑并保存,其他格式和非工作区预览默认只读;知识库与团队空间入口先展示到占位层级;默认创建 `agents/AGENTS.md`,并在 Agent 执行时将其内容追加到系统提示词。 - 扩展管理界面交互逻辑重构:将 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 83a56715..a4cdbf03 100644 --- a/web/src/apis/workspace_api.js +++ b/web/src/apis/workspace_api.js @@ -1,4 +1,4 @@ -import { apiDelete, apiGet, apiPost } from './base' +import { apiDelete, apiGet, apiPost, apiPut } from './base' const buildQuery = (params) => { const query = new URLSearchParams() @@ -20,6 +20,10 @@ export const getWorkspaceFileContent = (path) => { return apiGet(`/api/workspace/file?${query}`) } +export const saveWorkspaceFileContent = (path, content) => { + return apiPut('/api/workspace/file', { path, content }) +} + export const deleteWorkspacePath = (path) => { const query = buildQuery({ path }) return apiDelete(`/api/workspace/file?${query}`) diff --git a/web/src/components/AgentFilePreview.vue b/web/src/components/AgentFilePreview.vue index 8eab4a69..9fd0956d 100644 --- a/web/src/components/AgentFilePreview.vue +++ b/web/src/components/AgentFilePreview.vue @@ -9,6 +9,42 @@ {{ filePath }}