fix: avoid sandbox cold starts in viewer tree
This commit is contained in:
parent
d20d65c735
commit
4e740bab1b
@ -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))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 在依赖缓存和安装阶段直接失败
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user