fix: avoid sandbox cold starts in viewer tree

This commit is contained in:
Wenjie Zhang 2026-03-28 14:59:36 +08:00
parent d20d65c735
commit 4e740bab1b
3 changed files with 104 additions and 41 deletions

View File

@ -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))

View File

@ -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)

View File

@ -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 在依赖缓存和安装阶段直接失败