From 4e740bab1b4567e0ab67ec7cf25f69600297152f Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Sat, 28 Mar 2026 14:59:36 +0800 Subject: [PATCH] fix: avoid sandbox cold starts in viewer tree --- .../services/viewer_filesystem_service.py | 77 +++++++++---------- .../test/api/test_viewer_filesystem_router.py | 67 ++++++++++++++++ docs/develop-guides/roadmap.md | 1 + 3 files changed, 104 insertions(+), 41 deletions(-) diff --git a/backend/package/yuxi/services/viewer_filesystem_service.py b/backend/package/yuxi/services/viewer_filesystem_service.py index 3b030421..59aa86f7 100644 --- a/backend/package/yuxi/services/viewer_filesystem_service.py +++ b/backend/package/yuxi/services/viewer_filesystem_service.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import io import mimetypes +from datetime import UTC, datetime from pathlib import PurePosixPath from urllib.parse import quote @@ -10,31 +11,12 @@ from fastapi import HTTPException from fastapi.responses import FileResponse, StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from yuxi.agents.backends import KBS_PATH, KnowledgeBaseReadonlyBackend, resolve_visible_knowledge_bases_for_context -from yuxi.agents.backends.sandbox import SKILLS_PATH, USER_DATA_PATH, resolve_virtual_path +from yuxi.agents.backends.sandbox import SKILLS_PATH, USER_DATA_PATH, resolve_virtual_path, virtual_path_for_thread_file from yuxi.agents.backends.skills_backend import SelectedSkillsReadonlyBackend from yuxi.agents.middlewares.skills_middleware import normalize_selected_skills from yuxi.services.filesystem_service import _resolve_filesystem_state from yuxi.storage.postgres.models_business import User -# Sandbox 的 /home/gem 路径(虚拟根) -SANDBOX_HOME = "/home/gem" - -# 根目录排除的文件/目录名 -_HIDDEN_EXCLUDE = frozenset( - { - ".cache", - ".config", - ".jupyter", - ".local", - ".npm-global", - ".pki", - ".ipython", - ".npm", - ".bashrc", - ".Xauthority", - } -) - _MARKDOWN_EXTENSIONS = frozenset({".md", ".markdown", ".mdx"}) _PDF_EXTENSIONS = frozenset({".pdf"}) _TEXT_EXTENSIONS = frozenset( @@ -227,6 +209,32 @@ def _sort_entries(entries: list[dict]) -> list[dict]: ) +def _to_iso8601(timestamp: float | None) -> str: + if timestamp is None: + return "" + return datetime.fromtimestamp(timestamp, tz=UTC).isoformat() + + +def _list_local_entries(thread_id: str, actual_path) -> list[dict]: + entries: list[dict] = [] + for child in sorted(actual_path.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())): + stat = child.stat() + is_dir = child.is_dir() + display_path = virtual_path_for_thread_file(thread_id, child) + if is_dir and not display_path.endswith("/"): + display_path = f"{display_path}/" + entries.append( + { + "path": display_path, + "name": child.name, + "is_dir": is_dir, + "size": 0 if is_dir else stat.st_size, + "modified_at": _to_iso8601(stat.st_mtime), + } + ) + return entries + + async def _resolve_viewer_state( *, thread_id: str, @@ -271,41 +279,28 @@ async def list_viewer_filesystem_tree( ) if normalized_path == "/": - # 根目录显示 /home/gem/ 下的所有内容 + # 根目录只显示 viewer 暴露的虚拟命名空间,避免为只读树视图触发 sandbox 冷启动。 entries = [] - # 收集已添加的虚拟挂载点名称,用于去重 - added_names = set() - # 添加虚拟挂载点 entries.append( {"path": f"{USER_DATA_PATH}/", "name": "user-data", "is_dir": True, "size": 0, "modified_at": ""} ) - added_names.add("user-data") if selected_skills: entries.append({"path": f"{SKILLS_PATH}/", "name": "skills", "is_dir": True, "size": 0, "modified_at": ""}) - added_names.add("skills") if kb_backend.has_entries(): entries.append({"path": f"{KBS_PATH}/", "name": "kbs", "is_dir": True, "size": 0, "modified_at": ""}) - added_names.add("kbs") - - # 添加 sandbox 根目录 /home/gem/ 的实际文件(排除已添加的虚拟挂载点和隐藏文件) - sandbox_root_entries = await asyncio.to_thread(sandbox_backend.ls_info, SANDBOX_HOME) - if sandbox_root_entries: - sandbox_entries = _normalize_entries(sandbox_root_entries) - for entry in sandbox_entries: - name = PurePosixPath(entry["path"].rstrip("/")).name - if name in _HIDDEN_EXCLUDE: - continue - if name not in added_names: - entries.append(entry) - added_names.add(name) # 防止其他同名文件/文件夹重复 return {"entries": _sort_entries(entries)} try: if _is_user_data_path(normalized_path): - entries = await asyncio.to_thread(sandbox_backend.ls_info, normalized_path) - return {"entries": _sort_entries(_normalize_entries(entries))} + actual_path = resolve_virtual_path(thread_id, normalized_path) + if not actual_path.exists(): + return {"entries": []} + if not actual_path.is_dir(): + raise HTTPException(status_code=400, detail="当前路径不是目录") + entries = await asyncio.to_thread(_list_local_entries, thread_id, actual_path) + return {"entries": _sort_entries(entries)} if _is_skills_path(normalized_path): entries = await asyncio.to_thread(skills_backend.ls_info, _strip_skills_prefix(normalized_path)) diff --git a/backend/test/api/test_viewer_filesystem_router.py b/backend/test/api/test_viewer_filesystem_router.py index 80315e5a..95f072e3 100644 --- a/backend/test/api/test_viewer_filesystem_router.py +++ b/backend/test/api/test_viewer_filesystem_router.py @@ -85,6 +85,73 @@ async def test_viewer_tree_root_lists_user_data_namespace(test_client, standard_ assert "/home/gem/user-data/" in paths +async def test_viewer_tree_root_does_not_require_sandbox_listing(test_client, standard_user, monkeypatch): + headers = standard_user["headers"] + thread_id = await _create_thread_for_user(test_client, headers) + + class _FailingSandbox: + def ls_info(self, path): + raise AssertionError(f"sandbox ls_info should not be used for root path: {path}") + + class _EmptyBackend: + def has_entries(self): + return False + + service_module = importlib.import_module("yuxi.services.viewer_filesystem_service") + + async def _fake_resolve_viewer_state(**kwargs): + return _FailingSandbox(), _EmptyBackend(), _EmptyBackend(), [] + + monkeypatch.setattr(service_module, "_resolve_viewer_state", _fake_resolve_viewer_state) + + response = await test_client.get( + "/api/viewer/filesystem/tree", + params={"thread_id": thread_id, "path": "/"}, + headers=headers, + ) + assert response.status_code == 200, response.text + + entries = response.json().get("entries", []) + paths = {entry.get("path") for entry in entries} + assert "/home/gem/user-data/" in paths + + +async def test_viewer_tree_user_data_uses_local_thread_directory(test_client, standard_user, monkeypatch): + 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) / "viewer_tree_demo.txt" + actual_path.write_text("viewer tree", encoding="utf-8") + + class _FailingSandbox: + def ls_info(self, path): + raise AssertionError(f"sandbox ls_info should not be used for user-data path: {path}") + + class _EmptyBackend: + def has_entries(self): + return False + + service_module = importlib.import_module("yuxi.services.viewer_filesystem_service") + + async def _fake_resolve_viewer_state(**kwargs): + return _FailingSandbox(), _EmptyBackend(), _EmptyBackend(), [] + + monkeypatch.setattr(service_module, "_resolve_viewer_state", _fake_resolve_viewer_state) + + response = await test_client.get( + "/api/viewer/filesystem/tree", + params={"thread_id": thread_id, "path": "/home/gem/user-data"}, + headers=headers, + ) + assert response.status_code == 200, response.text + + entries = response.json().get("entries", []) + paths = {entry.get("path") for entry in entries} + assert "/home/gem/user-data/workspace/" in paths + assert all(str(path).startswith("/home/gem/user-data") for path in paths) + + async def test_viewer_file_returns_raw_content_without_line_numbers(test_client, standard_user): headers = standard_user["headers"] thread_id = await _create_thread_for_user(test_client, headers) diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index aaa113c6..952fc2a4 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -52,6 +52,7 @@ - 重构聊天接口请求模型:流式与非流式聊天统一使用 `query + agent_config_id` 请求体,并移除路径中的 `agent_id`;同时修复非流式接口实际误走流式执行链路的问题,改为调用 `invoke_messages` 一次性执行,并补充对应测试 - 修复对话线程与 Agent 配置错位的问题:发送消息时将当前 `agent_config_id` 绑定到 thread 的 `extra_metadata`,线程列表接口返回该绑定值,前端切换历史 thread 时会自动恢复对应配置 - 为沙盒与 viewer 文件系统补齐知识库只读映射:新增 `/home/gem/kbs` 命名空间,按“用户可访问知识库 ∩ 当前 Agent 已启用知识库”暴露原始文件与解析后的 Markdown,并补充对应后端与 viewer 路由测试 +- 优化 viewer 文件系统目录树加载:根目录与 `/home/gem/user-data` 改为直接读取本地线程挂载目录,不再为只读树视图触发 sandbox 冷启动,并补充对应后端测试 - 修复前端工具图标与渲染匹配不准确的问题:工具管理列表与工具调用结果统一改为基于工具 `id` 的精确映射,避免模糊匹配导致的误渲染,未命中的工具不再显示默认扳手图标 - 修复 GitHub Pages 文档部署工作流失败:移除 `actions/setup-node@v4` 对不存在 `docs/package-lock.json` 的缓存依赖,并将 `docs` 目录安装命令从 `npm ci` 调整为 `npm install`,避免因未提交锁文件导致 CI 在依赖缓存和安装阶段直接失败