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 asyncio
import io import io
import mimetypes import mimetypes
from datetime import UTC, datetime
from pathlib import PurePosixPath from pathlib import PurePosixPath
from urllib.parse import quote from urllib.parse import quote
@ -10,31 +11,12 @@ from fastapi import HTTPException
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.agents.backends import KBS_PATH, KnowledgeBaseReadonlyBackend, resolve_visible_knowledge_bases_for_context 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.backends.skills_backend import SelectedSkillsReadonlyBackend
from yuxi.agents.middlewares.skills_middleware import normalize_selected_skills from yuxi.agents.middlewares.skills_middleware import normalize_selected_skills
from yuxi.services.filesystem_service import _resolve_filesystem_state from yuxi.services.filesystem_service import _resolve_filesystem_state
from yuxi.storage.postgres.models_business import User 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"}) _MARKDOWN_EXTENSIONS = frozenset({".md", ".markdown", ".mdx"})
_PDF_EXTENSIONS = frozenset({".pdf"}) _PDF_EXTENSIONS = frozenset({".pdf"})
_TEXT_EXTENSIONS = frozenset( _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( async def _resolve_viewer_state(
*, *,
thread_id: str, thread_id: str,
@ -271,41 +279,28 @@ async def list_viewer_filesystem_tree(
) )
if normalized_path == "/": if normalized_path == "/":
# 根目录显示 /home/gem/ 下的所有内容 # 根目录只显示 viewer 暴露的虚拟命名空间,避免为只读树视图触发 sandbox 冷启动。
entries = [] entries = []
# 收集已添加的虚拟挂载点名称,用于去重
added_names = set()
# 添加虚拟挂载点
entries.append( entries.append(
{"path": f"{USER_DATA_PATH}/", "name": "user-data", "is_dir": True, "size": 0, "modified_at": ""} {"path": f"{USER_DATA_PATH}/", "name": "user-data", "is_dir": True, "size": 0, "modified_at": ""}
) )
added_names.add("user-data")
if selected_skills: if selected_skills:
entries.append({"path": f"{SKILLS_PATH}/", "name": "skills", "is_dir": True, "size": 0, "modified_at": ""}) entries.append({"path": f"{SKILLS_PATH}/", "name": "skills", "is_dir": True, "size": 0, "modified_at": ""})
added_names.add("skills")
if kb_backend.has_entries(): if kb_backend.has_entries():
entries.append({"path": f"{KBS_PATH}/", "name": "kbs", "is_dir": True, "size": 0, "modified_at": ""}) 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)} return {"entries": _sort_entries(entries)}
try: try:
if _is_user_data_path(normalized_path): if _is_user_data_path(normalized_path):
entries = await asyncio.to_thread(sandbox_backend.ls_info, normalized_path) actual_path = resolve_virtual_path(thread_id, normalized_path)
return {"entries": _sort_entries(_normalize_entries(entries))} 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): if _is_skills_path(normalized_path):
entries = await asyncio.to_thread(skills_backend.ls_info, _strip_skills_prefix(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 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): async def test_viewer_file_returns_raw_content_without_line_numbers(test_client, standard_user):
headers = standard_user["headers"] headers = standard_user["headers"]
thread_id = await _create_thread_for_user(test_client, 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` 一次性执行,并补充对应测试 - 重构聊天接口请求模型:流式与非流式聊天统一使用 `query + agent_config_id` 请求体,并移除路径中的 `agent_id`;同时修复非流式接口实际误走流式执行链路的问题,改为调用 `invoke_messages` 一次性执行,并补充对应测试
- 修复对话线程与 Agent 配置错位的问题:发送消息时将当前 `agent_config_id` 绑定到 thread 的 `extra_metadata`,线程列表接口返回该绑定值,前端切换历史 thread 时会自动恢复对应配置 - 修复对话线程与 Agent 配置错位的问题:发送消息时将当前 `agent_config_id` 绑定到 thread 的 `extra_metadata`,线程列表接口返回该绑定值,前端切换历史 thread 时会自动恢复对应配置
- 为沙盒与 viewer 文件系统补齐知识库只读映射:新增 `/home/gem/kbs` 命名空间,按“用户可访问知识库 ∩ 当前 Agent 已启用知识库”暴露原始文件与解析后的 Markdown并补充对应后端与 viewer 路由测试 - 为沙盒与 viewer 文件系统补齐知识库只读映射:新增 `/home/gem/kbs` 命名空间,按“用户可访问知识库 ∩ 当前 Agent 已启用知识库”暴露原始文件与解析后的 Markdown并补充对应后端与 viewer 路由测试
- 优化 viewer 文件系统目录树加载:根目录与 `/home/gem/user-data` 改为直接读取本地线程挂载目录,不再为只读树视图触发 sandbox 冷启动,并补充对应后端测试
- 修复前端工具图标与渲染匹配不准确的问题:工具管理列表与工具调用结果统一改为基于工具 `id` 的精确映射,避免模糊匹配导致的误渲染,未命中的工具不再显示默认扳手图标 - 修复前端工具图标与渲染匹配不准确的问题:工具管理列表与工具调用结果统一改为基于工具 `id` 的精确映射,避免模糊匹配导致的误渲染,未命中的工具不再显示默认扳手图标
- 修复 GitHub Pages 文档部署工作流失败:移除 `actions/setup-node@v4` 对不存在 `docs/package-lock.json` 的缓存依赖,并将 `docs` 目录安装命令从 `npm ci` 调整为 `npm install`,避免因未提交锁文件导致 CI 在依赖缓存和安装阶段直接失败 - 修复 GitHub Pages 文档部署工作流失败:移除 `actions/setup-node@v4` 对不存在 `docs/package-lock.json` 的缓存依赖,并将 `docs` 目录安装命令从 `npm ci` 调整为 `npm install`,避免因未提交锁文件导致 CI 在依赖缓存和安装阶段直接失败