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 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))
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 在依赖缓存和安装阶段直接失败
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user