refactor: 用户数据路径从 '/home/yuxi' 重构为 '/home/gem'

This commit is contained in:
Wenjie Zhang 2026-03-26 03:29:34 +08:00
parent 2be5aedaf2
commit 623f6f31ab
25 changed files with 251 additions and 80 deletions

View File

@ -4,7 +4,7 @@ SAVE_DIR=./saves
# Sandbox (deerFlow-style provisioner)
SANDBOX_PROVIDER=provisioner
SANDBOX_PROVISIONER_URL=http://sandbox-provisioner:8002
SANDBOX_VIRTUAL_PATH_PREFIX=/home/yuxi/user-data
SANDBOX_VIRTUAL_PATH_PREFIX=/home/gem/user-data
SANDBOX_EXEC_TIMEOUT_SECONDS=180
SANDBOX_MAX_OUTPUT_BYTES=262144
SANDBOX_KEEPALIVE_INTERVAL_SECONDS=30

View File

@ -110,6 +110,6 @@ def create_agent_composite_backend(runtime) -> CompositeBackend:
default=ProvisionerSandboxBackend(thread_id=thread_id, visible_skills=visible_skills),
routes={
"/skills/": SelectedSkillsReadonlyBackend(selected_slugs=visible_skills),
"/home/yuxi/kbs/": KnowledgeBaseReadonlyBackend(visible_kbs=visible_kbs),
"/home/gem/kbs/": KnowledgeBaseReadonlyBackend(visible_kbs=visible_kbs),
},
)

View File

@ -15,7 +15,7 @@ from yuxi import knowledge_base
from yuxi.knowledge.utils.kb_utils import is_minio_url, parse_minio_url
from yuxi.storage.minio import get_minio_client
KBS_PATH = "/home/yuxi/kbs"
KBS_PATH = "/home/gem/kbs"
_INVALID_SEGMENT_RE = re.compile(r"[\\/\x00-\x1f\x7f]+")
_WHITESPACE_RE = re.compile(r"\s+")

View File

@ -28,7 +28,7 @@ SandboxInfo = SandboxConnection
# Sandbox-visible paths for viewer/filesystem services.
USER_DATA_PATH = VIRTUAL_PATH_PREFIX
SKILLS_PATH = "/home/yuxi/skills"
SKILLS_PATH = "/home/gem/skills"
# Relative host-side directory names under thread user-data.
WORKSPACE_DIR = "workspace"

View File

@ -5,7 +5,7 @@ from pathlib import Path
from yuxi import config as conf
DEFAULT_VIRTUAL_PATH_PREFIX = "/home/yuxi/user-data"
DEFAULT_VIRTUAL_PATH_PREFIX = "/home/gem/user-data"
VIRTUAL_PATH_PREFIX = DEFAULT_VIRTUAL_PATH_PREFIX
_SAFE_THREAD_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")

View File

@ -2,10 +2,10 @@ PROMPT = """
你是一个人工智能助手 语析专门用来回答用户的问题请根据用户提供的信息尽可能详细地回答问题
如果你不确定答案可以说你不知道但请尽量提供相关的信息或建议请保持礼貌和专业
允许的写入路径为 /home/yuxi/user-data/请将需要保存的文件写入该目录下
允许的写入路径为 /home/gem/user-data/请将需要保存的文件写入该目录下
推荐使用的路径
- /home/yuxi/user-data/workspace/用于存放工作文件和中间结果
- /home/yuxi/user-data/outputs/用于存放最终输出结果
- /home/yuxi/user-data/uploads/用于存放用户上传的文件
- /home/gem/user-data/workspace/用于存放工作文件和中间结果
- /home/gem/user-data/outputs/用于存放最终输出结果
- /home/gem/user-data/uploads/用于存放用户上传的文件
"""

View File

@ -60,7 +60,7 @@ async def get_prompt_metadata(db: AsyncSession | None = None) -> dict[str, Skill
item.slug: {
"name": item.name,
"description": item.description,
"path": f"/home/yuxi/skills/{item.slug}/SKILL.md",
"path": f"/home/gem/skills/{item.slug}/SKILL.md",
}
for item in skills
}
@ -165,12 +165,12 @@ class SkillsMiddleware(AgentMiddleware):
Args:
skills_context_name: 上下文中的 skills 列表字段名称默认 "skills"
enable_skills_prompt: 是否启用 skills 提示段注入默认 True
skills_sources_for_prompt: skills 来源路径用于提示词展示默认 ["/home/yuxi/skills/"]
skills_sources_for_prompt: skills 来源路径用于提示词展示默认 ["/home/gem/skills/"]
"""
super().__init__()
self.skills_context_name = skills_context_name
self.enable_skills_prompt = enable_skills_prompt
self.skills_sources_for_prompt = skills_sources_for_prompt or ["/home/yuxi/skills/"]
self.skills_sources_for_prompt = skills_sources_for_prompt or ["/home/gem/skills/"]
async def abefore_agent(self, state: SkillsState, runtime) -> dict[str, Any] | None:
"""在 agent 执行前注入 skills 提示词"""
@ -405,7 +405,7 @@ class SkillsMiddleware(AgentMiddleware):
pure = PurePosixPath(raw if raw.startswith("/") else f"/{raw}")
parts = [p for p in pure.parts if p not in ("/", "")]
slug: str | None = None
if len(parts) == 5 and parts[0] == "home" and parts[1] == "yuxi" and parts[2] == "skills" and parts[4] == "SKILL.md":
if len(parts) == 5 and parts[0] == "home" and parts[1] == "gem" and parts[2] == "skills" and parts[4] == "SKILL.md":
slug = parts[3]
if not is_valid_skill_slug(slug):

View File

@ -79,7 +79,7 @@ class Config(BaseModel):
# ============================================================
sandbox_provider: str = Field(default="provisioner", description="沙箱提供者")
sandbox_provisioner_url: str = Field(default="http://sandbox-provisioner:8002", description="沙箱服务地址")
sandbox_virtual_path_prefix: str = Field(default="/home/yuxi/user-data", description="沙箱用户目录前缀")
sandbox_virtual_path_prefix: str = Field(default="/home/gem/user-data", description="沙箱用户目录前缀")
sandbox_exec_timeout_seconds: int = Field(default=180, description="沙箱执行超时时间(秒)")
sandbox_max_output_bytes: int = Field(default=262144, description="沙箱最大输出字节数")
sandbox_keepalive_interval_seconds: int = Field(default=30, description="沙箱保活间隔(秒)")
@ -250,7 +250,7 @@ class Config(BaseModel):
os.getenv("SANDBOX_PROVISIONER_URL") or self.sandbox_provisioner_url or "http://sandbox-provisioner:8002"
).strip()
self.sandbox_virtual_path_prefix = (
os.getenv("SANDBOX_VIRTUAL_PATH_PREFIX") or self.sandbox_virtual_path_prefix or "/home/yuxi/user-data"
os.getenv("SANDBOX_VIRTUAL_PATH_PREFIX") or self.sandbox_virtual_path_prefix or "/home/gem/user-data"
).strip()
self.sandbox_exec_timeout_seconds = int(
os.getenv("SANDBOX_EXEC_TIMEOUT_SECONDS") or self.sandbox_exec_timeout_seconds or 180

View File

@ -13,7 +13,7 @@ from yuxi.agents.backends.sandbox import (
)
from yuxi.services.doc_converter import ATTACHMENT_ALLOWED_EXTENSIONS, MAX_ATTACHMENT_SIZE_BYTES
UPLOADS_VIRTUAL_PREFIX = "/home/yuxi/user-data/uploads"
UPLOADS_VIRTUAL_PREFIX = "/home/gem/user-data/uploads"
from yuxi.agents.buildin import agent_manager
from yuxi.config import config as app_config
from yuxi.plugins.parser import Parser
@ -132,7 +132,7 @@ def _make_attachment_path(file_name: str) -> str:
def _build_attachment_storage_path(*, user_id: str, thread_id: str, file_name: str) -> tuple[str, Path]:
"""返回附件虚拟路径和宿主机落盘路径。"""
relative_name = _make_attachment_path(file_name)
virtual_path = f"/home/yuxi/user-data/uploads/attachments/{relative_name}"
virtual_path = f"/home/gem/user-data/uploads/attachments/{relative_name}"
host_dir = Path(app_config.save_dir) / "threads" / thread_id / "user-data" / "uploads" / "attachments"
host_dir.mkdir(parents=True, exist_ok=True)

View File

@ -0,0 +1,171 @@
from __future__ import annotations
import hashlib
import json
import re
import tempfile
from pathlib import Path
from typing import Any
from yuxi.config import config
from yuxi.knowledge import knowledge_base
from yuxi.knowledge.utils.kb_utils import parse_minio_url
from yuxi.repositories.knowledge_file_repository import KnowledgeFileRepository
from yuxi.storage.minio import get_minio_client
KBS_PATH = "/home/gem/kbs"
PARSED_DIR_NAME = "parsed"
_INVALID_MOUNT_NAME_CHARS = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
_MULTISPACE = re.compile(r"\s+")
def get_kb_cache_root() -> Path:
return Path(config.save_dir) / "knowledge_base_data" / "kb-cache"
def normalize_knowledge_mount_name(name: str) -> str:
normalized = _MULTISPACE.sub(" ", str(name or "").strip())
if _INVALID_MOUNT_NAME_CHARS.search(normalized):
raise ValueError("知识库名称包含不能映射为目录名的非法字符")
normalized = normalized.strip(" .")
if not normalized or normalized in {".", ".."}:
raise ValueError("知识库名称不能映射为有效目录名")
if "/" in normalized or "\\" in normalized:
raise ValueError("知识库名称不能包含路径分隔符")
return normalized
def validate_knowledge_mount_name(name: str) -> str:
return normalize_knowledge_mount_name(name)
def _normalize_selected_knowledges(selected: list[str] | None) -> list[str]:
normalized: list[str] = []
seen: set[str] = set()
for item in selected or []:
if not isinstance(item, str):
continue
value = item.strip()
if not value:
continue
key = value.casefold()
if key in seen:
continue
seen.add(key)
normalized.append(value)
return normalized
def _derive_parsed_filename(filename: str, file_id: str, used_names: set[str]) -> str:
raw_name = (filename or file_id or "file").strip() or file_id or "file"
suffix = Path(raw_name).suffix
stem = Path(raw_name).name[: -len(suffix)] if suffix else Path(raw_name).name
candidate = f"{stem or file_id}.md"
lowered = candidate.casefold()
if lowered in used_names:
candidate = f"{stem or file_id}__{file_id}.md"
lowered = candidate.casefold()
used_names.add(lowered)
return candidate
def _serialize_file_record(record) -> dict[str, Any]:
return {
"file_id": record.file_id,
"db_id": record.db_id,
"parent_id": record.parent_id,
"filename": record.filename,
"original_filename": record.original_filename,
"file_type": record.file_type,
"path": record.path,
"minio_url": record.minio_url,
"markdown_file": record.markdown_file,
"status": record.status,
"content_hash": record.content_hash,
"file_size": record.file_size,
"content_type": record.content_type,
"processing_params": record.processing_params,
"is_folder": bool(record.is_folder),
}
async def build_visible_knowledge_mounts(
*,
user_id: str,
selected_knowledges: list[str] | None,
) -> list[dict[str, Any]]:
accessible = (await knowledge_base.get_databases_by_user_id(user_id)).get("databases", [])
selected = _normalize_selected_knowledges(selected_knowledges)
selected_keys = {item.casefold() for item in selected}
file_repo = KnowledgeFileRepository()
mounts: list[dict[str, Any]] = []
used_mount_names: dict[str, str] = {}
for db in accessible:
db_id = str(db.get("db_id") or "").strip()
db_name = str(db.get("name") or db_id).strip()
if not db_id or not db_name:
continue
if selected_keys and db_name.casefold() not in selected_keys and db_id.casefold() not in selected_keys:
continue
mount_name = normalize_knowledge_mount_name(db_name)
conflict_db_id = used_mount_names.get(mount_name.casefold())
if conflict_db_id and conflict_db_id != db_id:
raise ValueError(f"知识库名称映射冲突: '{db_name}' -> '{mount_name}'")
used_mount_names[mount_name.casefold()] = db_id
records = await file_repo.list_by_db_id(db_id)
mounts.append(
{
"db_id": db_id,
"db_name": db_name,
"mount_name": mount_name,
"files": [_serialize_file_record(record) for record in records],
}
)
mounts.sort(key=lambda item: item["mount_name"].casefold())
return mounts
def cache_minio_object(*, source_url: str, metadata: dict[str, Any] | None = None) -> Path:
bucket_name, object_name = parse_minio_url(source_url)
minio_client = get_minio_client()
stat = minio_client.client.stat_object(bucket_name=bucket_name, object_name=object_name)
etag = str(getattr(stat, "etag", "") or "")
last_modified = getattr(stat, "last_modified", None)
version_key = etag or (last_modified.isoformat() if last_modified else "")
cache_key = hashlib.sha256(f"{bucket_name}:{object_name}:{version_key}".encode("utf-8")).hexdigest()
suffix = Path(object_name).suffix
objects_root = get_kb_cache_root() / "objects"
manifests_root = get_kb_cache_root() / "manifests"
objects_root.mkdir(parents=True, exist_ok=True)
manifests_root.mkdir(parents=True, exist_ok=True)
cached_path = objects_root / f"{cache_key}{suffix}"
if not cached_path.exists():
payload = minio_client.download_file(bucket_name=bucket_name, object_name=object_name)
with tempfile.NamedTemporaryFile(dir=objects_root, delete=False) as tmp:
tmp.write(payload)
tmp_path = Path(tmp.name)
tmp_path.replace(cached_path)
manifest_path = manifests_root / f"{cache_key}.json"
if not manifest_path.exists():
manifest = {
"bucket_name": bucket_name,
"object_name": object_name,
"etag": etag,
"last_modified": last_modified.isoformat() if last_modified else None,
"source_url": source_url,
"cached_path": str(cached_path),
"metadata": metadata or {},
}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
return cached_path

View File

@ -18,7 +18,7 @@ from yuxi.services.conversation_service import require_user_conversation
def _get_virtual_root() -> str:
prefix = str(getattr(conf, "sandbox_virtual_path_prefix", "/home/yuxi/user-data") or "/home/yuxi/user-data")
prefix = str(getattr(conf, "sandbox_virtual_path_prefix", "/home/gem/user-data") or "/home/gem/user-data")
return "/" + prefix.strip("/")

View File

@ -862,7 +862,7 @@ async def delete_thread_attachment(
@chat.get("/thread/{thread_id}/files", response_model=ThreadFileListResponse)
async def list_thread_files(
thread_id: str,
path: str = Query("/home/yuxi/user-data"),
path: str = Query("/home/gem/user-data"),
recursive: bool = Query(False),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_required_user),

View File

@ -36,8 +36,8 @@ TIMEOUT = httpx.Timeout(300.0, connect=10.0)
POLL_INTERVAL_SECONDS = float(os.getenv("E2E_RUN_POLL_INTERVAL_SECONDS", "2"))
RUN_TIMEOUT_SECONDS = int(os.getenv("E2E_RUN_TIMEOUT_SECONDS", "240"))
SCRIPT_PATH = "/home/yuxi/user-data/workspace/bubble_sort.py"
RESULT_PATH = "/home/yuxi/user-data/outputs/bubble_sort_result.txt"
SCRIPT_PATH = "/home/gem/user-data/workspace/bubble_sort.py"
RESULT_PATH = "/home/gem/user-data/outputs/bubble_sort_result.txt"
EXPECTED_OUTPUT = "[1, 2, 4, 5, 8]"
@ -182,7 +182,7 @@ class AgentBubbleSortE2ETester:
if run.get("status") != "completed":
raise RuntimeError("run did not complete successfully: " + json.dumps(run, ensure_ascii=False))
file_entries = await self.list_thread_files("/home/yuxi/user-data")
file_entries = await self.list_thread_files("/home/gem/user-data")
file_paths = {str(entry.get("path") or "") for entry in file_entries}
if SCRIPT_PATH not in file_paths:

View File

@ -110,10 +110,10 @@ class ViewerFilesystemE2ETester:
assert self.thread_id
sandbox = _make_sandbox_backend(self.thread_id)
commands = [
"mkdir -p /home/yuxi/user-data/workspace /home/yuxi/user-data/outputs",
"printf 'print(42)\\n' > /home/yuxi/user-data/workspace/demo.py",
"printf 'root-file\\n' > /home/yuxi/user-data/root_file.txt",
"printf 'viewer-output\\n' > /home/yuxi/user-data/outputs/result.txt",
"mkdir -p /home/gem/user-data/workspace /home/gem/user-data/outputs",
"printf 'print(42)\\n' > /home/gem/user-data/workspace/demo.py",
"printf 'root-file\\n' > /home/gem/user-data/root_file.txt",
"printf 'viewer-output\\n' > /home/gem/user-data/outputs/result.txt",
]
for command in commands:
result = await asyncio.to_thread(sandbox.execute, command)
@ -121,22 +121,22 @@ class ViewerFilesystemE2ETester:
raise RuntimeError(f"command failed: {command}\n{result.output}")
root_paths = {str(e.get("path", "")) for e in await self.tree("/")}
if "/home/yuxi/user-data/" not in root_paths:
if "/home/gem/user-data/" not in root_paths:
raise RuntimeError(f"viewer root missing user-data: {sorted(root_paths)}")
user_data_paths = {str(e.get("path", "")) for e in await self.tree("/home/yuxi/user-data")}
if "/home/yuxi/user-data/root_file.txt" not in user_data_paths:
user_data_paths = {str(e.get("path", "")) for e in await self.tree("/home/gem/user-data")}
if "/home/gem/user-data/root_file.txt" not in user_data_paths:
raise RuntimeError(f"viewer user-data missing root_file.txt: {sorted(user_data_paths)}")
workspace_paths = {str(e.get("path", "")) for e in await self.tree("/home/yuxi/user-data/workspace")}
if "/home/yuxi/user-data/workspace/demo.py" not in workspace_paths:
workspace_paths = {str(e.get("path", "")) for e in await self.tree("/home/gem/user-data/workspace")}
if "/home/gem/user-data/workspace/demo.py" not in workspace_paths:
raise RuntimeError(f"viewer workspace missing demo.py: {sorted(workspace_paths)}")
content = await self.file("/home/yuxi/user-data/workspace/demo.py")
content = await self.file("/home/gem/user-data/workspace/demo.py")
if content != "print(42)\n":
raise RuntimeError(f"unexpected viewer file content: {content!r}")
content_disposition, payload = await self.download("/home/yuxi/user-data/outputs/result.txt")
content_disposition, payload = await self.download("/home/gem/user-data/outputs/result.txt")
if "result.txt" not in content_disposition:
raise RuntimeError(f"unexpected content-disposition: {content_disposition}")
if payload != b"viewer-output\n":

View File

@ -82,7 +82,7 @@ async def test_viewer_tree_root_lists_user_data_namespace(test_client, standard_
entries = response.json().get("entries", [])
paths = {entry.get("path") for entry in entries}
assert "/home/yuxi/user-data/" in paths
assert "/home/gem/user-data/" in paths
async def test_viewer_file_returns_raw_content_without_line_numbers(test_client, standard_user):
@ -143,8 +143,8 @@ async def test_viewer_tree_root_lists_kbs_namespace_when_visible(test_client, st
async def _fake_list_viewer_filesystem_tree(**kwargs):
return {
"entries": [
{"path": "/home/yuxi/user-data/", "name": "user-data", "is_dir": True, "size": 0, "modified_at": ""},
{"path": "/home/yuxi/kbs/", "name": "kbs", "is_dir": True, "size": 0, "modified_at": ""},
{"path": "/home/gem/user-data/", "name": "user-data", "is_dir": True, "size": 0, "modified_at": ""},
{"path": "/home/gem/kbs/", "name": "kbs", "is_dir": True, "size": 0, "modified_at": ""},
]
}
@ -164,7 +164,7 @@ async def test_viewer_tree_root_lists_kbs_namespace_when_visible(test_client, st
entries = response.json().get("entries", [])
paths = {entry.get("path") for entry in entries}
assert "/home/yuxi/kbs/" in paths
assert "/home/gem/kbs/" in paths
async def test_viewer_can_list_and_read_kbs_namespace(test_client, standard_user):
@ -173,9 +173,9 @@ async def test_viewer_can_list_and_read_kbs_namespace(test_client, standard_user
tree_response = await test_client.get(
"/api/viewer/filesystem/tree",
params={"thread_id": thread_id, "path": "/home/yuxi/kbs"},
params={"thread_id": thread_id, "path": "/home/gem/kbs"},
headers=headers,
)
assert tree_response.status_code == 200, tree_response.text
entries = tree_response.json().get("entries", [])
assert any(str(entry.get("path", "")).startswith("/home/yuxi/kbs/") for entry in entries)
assert any(str(entry.get("path", "")).startswith("/home/gem/kbs/") for entry in entries)

View File

@ -30,7 +30,7 @@ def test_build_attachment_storage_path_uses_thread_local_uploads_dir(tmp_path: P
file_name="demo.txt",
)
assert virtual_path == "/home/yuxi/user-data/uploads/attachments/demo.md"
assert virtual_path == "/home/gem/user-data/uploads/attachments/demo.md"
assert host_path == tmp_path / "threads" / "t-1" / "user-data" / "uploads" / "attachments" / "demo.md"
@ -43,17 +43,17 @@ def test_serialize_attachment_includes_original_file_fields() -> None:
"file_size": 5,
"status": "parsed",
"uploaded_at": "2026-03-25T00:00:00+00:00",
"path": "/home/yuxi/user-data/uploads/attachments/demo.md",
"artifact_url": "/api/chat/thread/t-1/artifacts/home/yuxi/user-data/uploads/attachments/demo.md",
"original_path": "/home/yuxi/user-data/uploads/demo.txt",
"original_artifact_url": "/api/chat/thread/t-1/artifacts/home/yuxi/user-data/uploads/demo.txt",
"path": "/home/gem/user-data/uploads/attachments/demo.md",
"artifact_url": "/api/chat/thread/t-1/artifacts/home/gem/user-data/uploads/attachments/demo.md",
"original_path": "/home/gem/user-data/uploads/demo.txt",
"original_artifact_url": "/api/chat/thread/t-1/artifacts/home/gem/user-data/uploads/demo.txt",
"minio_url": None,
}
)
assert serialized["path"] == "/home/yuxi/user-data/uploads/attachments/demo.md"
assert serialized["original_path"] == "/home/yuxi/user-data/uploads/demo.txt"
assert serialized["original_artifact_url"] == "/api/chat/thread/t-1/artifacts/home/yuxi/user-data/uploads/demo.txt"
assert serialized["path"] == "/home/gem/user-data/uploads/attachments/demo.md"
assert serialized["original_path"] == "/home/gem/user-data/uploads/demo.txt"
assert serialized["original_artifact_url"] == "/api/chat/thread/t-1/artifacts/home/gem/user-data/uploads/demo.txt"
@pytest.mark.asyncio
@ -73,8 +73,8 @@ async def test_materialize_attachment_files_keeps_original_file_when_markdown_co
)
assert result["status"] == "uploaded"
assert result["path"] == "/home/yuxi/user-data/uploads/demo.pdf"
assert result["original_path"] == "/home/yuxi/user-data/uploads/demo.pdf"
assert result["path"] == "/home/gem/user-data/uploads/demo.pdf"
assert result["original_path"] == "/home/gem/user-data/uploads/demo.pdf"
assert (
tmp_path / "threads" / "t-1" / "user-data" / "uploads" / "demo.pdf"
).read_bytes() == b"%PDF-test"
@ -109,9 +109,9 @@ async def test_materialize_attachment_files_writes_markdown_copy_when_conversion
)
assert result["status"] == "parsed"
assert result["path"] == "/home/yuxi/user-data/uploads/attachments/demo.md"
assert result["original_path"] == "/home/yuxi/user-data/uploads/demo.txt"
assert result["file_path"] == "/home/yuxi/user-data/uploads/attachments/demo.md"
assert result["path"] == "/home/gem/user-data/uploads/attachments/demo.md"
assert result["original_path"] == "/home/gem/user-data/uploads/demo.txt"
assert result["file_path"] == "/home/gem/user-data/uploads/attachments/demo.md"
assert result["markdown"] == "hello\nworld"
assert (tmp_path / "threads" / "t-1" / "user-data" / "uploads" / "demo.txt").read_bytes() == b"hello"
assert (

View File

@ -67,7 +67,7 @@ async def test_sync_thread_attachment_state_updates_graph(monkeypatch: pytest.Mo
attachments = [
{
"status": "parsed",
"path": "/home/yuxi/user-data/uploads/attachments/resume.md",
"path": "/home/gem/user-data/uploads/attachments/resume.md",
"file_name": "resume.md",
"uploaded_at": "2026-02-20T00:00:00+00:00",
}

View File

@ -34,7 +34,7 @@ def test_create_agent_composite_backend_uses_provisioner_default(monkeypatch):
assert isinstance(backend.default, ProvisionerSandboxBackend)
assert backend.default._visible_skills == ["reporter"]
assert "/skills/" in backend.routes
assert "/home/yuxi/kbs/" in backend.routes
assert "/home/gem/kbs/" in backend.routes
def test_create_agent_composite_backend_requires_thread_id():
@ -44,8 +44,8 @@ def test_create_agent_composite_backend_requires_thread_id():
def test_skills_middleware_extracts_slug_for_new_paths() -> None:
middleware = SkillsMiddleware()
assert middleware.skills_sources_for_prompt == ["/home/yuxi/skills/"]
assert middleware._extract_skill_slug_from_skill_md_path("/home/yuxi/skills/demo-skill/SKILL.md") == "demo-skill"
assert middleware.skills_sources_for_prompt == ["/home/gem/skills/"]
assert middleware._extract_skill_slug_from_skill_md_path("/home/gem/skills/demo-skill/SKILL.md") == "demo-skill"
def test_resolve_virtual_path_rejects_outside_prefix():
@ -55,7 +55,7 @@ def test_resolve_virtual_path_rejects_outside_prefix():
def test_resolve_virtual_path_rejects_path_traversal():
with pytest.raises(ValueError, match="path traversal"):
resolve_virtual_path("thread-1", "/home/yuxi/user-data/../secrets")
resolve_virtual_path("thread-1", "/home/gem/user-data/../secrets")
def test_sandbox_id_for_thread_is_stable():
@ -72,9 +72,9 @@ def test_provisioner_read_reports_binary_files(monkeypatch) -> None:
backend = ProvisionerSandboxBackend(thread_id="thread-1")
monkeypatch.setattr(backend, "_read_binary", lambda path, offset=0, limit=None: b"\x89PNG\r\n\x1a\n")
result = backend.read("/home/yuxi/user-data/image.png")
result = backend.read("/home/gem/user-data/image.png")
assert result == "Error: File '/home/yuxi/user-data/image.png' is binary and cannot be rendered as text"
assert result == "Error: File '/home/gem/user-data/image.png' is binary and cannot be rendered as text"
def test_provisioner_read_reports_invalid_path(monkeypatch) -> None:

View File

@ -20,7 +20,7 @@ x-api-worker-env: &api-worker-env
MINIO_URI: ${MINIO_URI:-http://minio:9000}
SANDBOX_PROVIDER: ${SANDBOX_PROVIDER:-provisioner}
SANDBOX_PROVISIONER_URL: ${SANDBOX_PROVISIONER_URL:-http://sandbox-provisioner:8002}
SANDBOX_VIRTUAL_PATH_PREFIX: ${SANDBOX_VIRTUAL_PATH_PREFIX:-/home/yuxi/user-data}
SANDBOX_VIRTUAL_PATH_PREFIX: ${SANDBOX_VIRTUAL_PATH_PREFIX:-/home/gem/user-data}
SANDBOX_EXEC_TIMEOUT_SECONDS: ${SANDBOX_EXEC_TIMEOUT_SECONDS:-180}
SANDBOX_MAX_OUTPUT_BYTES: ${SANDBOX_MAX_OUTPUT_BYTES:-262144}
NO_PROXY: localhost,127.0.0.1,milvus,graph,minio,milvus-etcd,etcd,minio,mineru,paddlex,sandbox-provisioner,api.siliconflow.cn

View File

@ -22,7 +22,7 @@ x-api-worker-env: &api-worker-env
YUXI_DOCKER_API_BASE: ${YUXI_DOCKER_API_BASE:-http://localhost}
SANDBOX_PROVIDER: ${SANDBOX_PROVIDER:-provisioner}
SANDBOX_PROVISIONER_URL: ${SANDBOX_PROVISIONER_URL:-http://sandbox-provisioner:8002}
SANDBOX_VIRTUAL_PATH_PREFIX: ${SANDBOX_VIRTUAL_PATH_PREFIX:-/home/yuxi/user-data}
SANDBOX_VIRTUAL_PATH_PREFIX: ${SANDBOX_VIRTUAL_PATH_PREFIX:-/home/gem/user-data}
SANDBOX_EXEC_TIMEOUT_SECONDS: ${SANDBOX_EXEC_TIMEOUT_SECONDS:-180}
SANDBOX_MAX_OUTPUT_BYTES: ${SANDBOX_MAX_OUTPUT_BYTES:-262144}
# LightRAG

View File

@ -181,7 +181,7 @@ class LocalContainerProvisionerBackend:
expected_source = str(self._thread_skills_host_path(thread_id))
for mount in container.attrs.get("Mounts") or []:
destination = (mount.get("Destination") or "").rstrip("/")
if destination != "/home/yuxi/skills":
if destination != "/home/gem/skills":
continue
source = str(mount.get("Source") or "").rstrip("/")
return source == expected_source
@ -238,9 +238,9 @@ class LocalContainerProvisionerBackend:
def _ensure_user_data_writable(container) -> None:
cmd = (
"sh -lc "
'"mkdir -p /home/yuxi/user-data/workspace /home/yuxi/user-data/uploads /home/yuxi/user-data/outputs '
'&& chmod a+rwx /home/yuxi/user-data /home/yuxi/user-data/workspace '
'/home/yuxi/user-data/uploads /home/yuxi/user-data/outputs"'
'"mkdir -p /home/gem/user-data/workspace /home/gem/user-data/uploads /home/gem/user-data/outputs '
'&& chmod a+rwx /home/gem/user-data /home/gem/user-data/workspace '
'/home/gem/user-data/uploads /home/gem/user-data/outputs"'
)
result = container.exec_run(cmd, user="0:0")
if result.exit_code != 0:
@ -310,14 +310,14 @@ class LocalContainerProvisionerBackend:
"managed-by": "yuxi-sandbox-provisioner",
},
"volumes": {
str(thread_user_data): {"bind": "/home/yuxi/user-data", "mode": "rw"},
str(thread_skills): {"bind": "/home/yuxi/skills", "mode": "ro"},
str(thread_user_data): {"bind": "/home/gem/user-data", "mode": "rw"},
str(thread_skills): {"bind": "/home/gem/skills", "mode": "ro"},
},
"ports": {f"{self._container_port}/tcp": None},
"security_opt": ["seccomp=unconfined"],
# The sandbox image expects /home/yuxi to be writable during boot.
# The sandbox image expects /home/gem to be writable during boot.
# Keep it ephemeral and mount persistent user-data underneath it.
"tmpfs": {"/home/yuxi": "rw,exec,mode=777"},
"tmpfs": {"/home/gem": "rw,exec,mode=777"},
}
if self._network:
run_kwargs["network"] = self._network
@ -432,7 +432,7 @@ class KubernetesProvisionerBackend:
image=self._sandbox_image,
command=["sh", "-c"],
args=[
"chmod 777 /home/yuxi "
"chmod 777 /home/gem "
f"&& mkdir -p /mnt/shared-data/threads/{thread_id}/user-data/workspace "
f"/mnt/shared-data/threads/{thread_id}/user-data/uploads "
f"/mnt/shared-data/threads/{thread_id}/user-data/outputs "
@ -440,7 +440,7 @@ class KubernetesProvisionerBackend:
f"&& chmod -R 777 /mnt/shared-data/threads/{thread_id}/user-data ",
],
volume_mounts=[
self._client.V1VolumeMount(name="home-dir", mount_path="/home/yuxi"),
self._client.V1VolumeMount(name="home-dir", mount_path="/home/gem"),
self._client.V1VolumeMount(name="shared-data", mount_path="/mnt/shared-data"),
],
),
@ -451,15 +451,15 @@ class KubernetesProvisionerBackend:
image=self._sandbox_image,
ports=[self._client.V1ContainerPort(container_port=self._container_port)],
volume_mounts=[
self._client.V1VolumeMount(name="home-dir", mount_path="/home/yuxi"),
self._client.V1VolumeMount(name="home-dir", mount_path="/home/gem"),
self._client.V1VolumeMount(
name="shared-data",
mount_path="/home/yuxi/user-data",
mount_path="/home/gem/user-data",
sub_path=f"threads/{thread_id}/user-data",
),
self._client.V1VolumeMount(
name="shared-data",
mount_path="/home/yuxi/skills",
mount_path="/home/gem/skills",
sub_path=f"threads/{thread_id}/skills",
read_only=True,
),

View File

@ -225,7 +225,7 @@ config_json.context + runtime ids -> context_schema instance
- 在运行期回写 `_visible_skills`
- 文件系统与沙盒接入
- 通过 `thread_id` 获取对应沙盒
- 通过 `skills` 决定 `/home/yuxi/skills` 的可见范围
- 通过 `skills` 决定 `/home/gem/skills` 的可见范围
所以 Context 既是输入配置,也是中间件共享的运行时状态载体。

View File

@ -31,7 +31,7 @@
- 重构后端代码 src -> backend/package/yuxi
- 重构文档解析,统一文档解析体验,并新增 Parser 类
- 新增 LITE 模式启动,启动时不加载知识库、知识图谱相关模块,可以使用 make up-lite 快捷启动
- 新增沙盒环境,详见后续文档更新,统一沙盒虚拟路径前缀默认值为 `/home/yuxi/user-data`
- 新增沙盒环境,详见后续文档更新,统一沙盒虚拟路径前缀默认值为 `/home/gem/user-data`
- 新增基于沙盒的文件系统,前端工作台可以查看文件系统,支持预览、下载文件
- 重构附件系统,直接集成在了沙盒文件系统中,附件上传后直接落盘到沙盒挂载目录
- 优化前端流式消息体验:新增通用 `useStreamSmoother` 调度层,统一平滑 Agent runs SSE、普通聊天流与审批恢复流中的 `loading` chunk
@ -45,7 +45,7 @@
### 修复
- 为沙盒与 viewer 文件系统补齐知识库只读映射:新增 `/home/yuxi/kbs` 命名空间,按“用户可访问知识库 ∩ 当前 Agent 已启用知识库”暴露原始文件与解析后的 Markdown并补充对应后端与 viewer 路由测试
- 为沙盒与 viewer 文件系统补齐知识库只读映射:新增 `/home/gem/kbs` 命名空间,按“用户可访问知识库 ∩ 当前 Agent 已启用知识库”暴露原始文件与解析后的 Markdown并补充对应后端与 viewer 路由测试
<!-- 添加到这里 -->
@ -80,7 +80,7 @@
- 修改方法备注信息 [#478](https://github.com/xerrors/Yuxi-Know/pull/478)
- 修复多次 human-in-the-loop 的渲染解析问题 [#453](https://github.com/xerrors/Yuxi-Know/issues/453) [#475](https://github.com/xerrors/Yuxi-Know/pull/475)
- 修复沙盒后端接入回归:补齐 composite backend 的 `sandbox_backend` 参数、限制 `/api/sandbox/prepare` 仅允许访问当前用户线程、确保 `release()` 之后的 `destroy()` 会真正停止热池容器,并恢复 docker-compose 的完整模式默认值
- 重构沙盒为 deer-flow 风格的 AIO provider切换为 thread-local sandbox、统一 `/home/yuxi/user-data/{workspace,uploads,outputs}` 固定路径、移除公开 `/api/sandbox/*` 生命周期接口,并补充 lite 模式下的 provider 生命周期、filesystem API 与 sandbox 复用/隔离 E2E 验证
- 重构沙盒为 deer-flow 风格的 AIO provider切换为 thread-local sandbox、统一 `/home/gem/user-data/{workspace,uploads,outputs}` 固定路径、移除公开 `/api/sandbox/*` 生命周期接口,并补充 lite 模式下的 provider 生命周期、filesystem API 与 sandbox 复用/隔离 E2E 验证
- 调整聊天附件存储链路:线程附件改为直接落盘到 `saves/threads/<thread_id>/user-data/uploads`,解析成功后额外生成 `uploads/attachments/*.md`,不再依赖 MinIO 或显式上传到 sandbox
- 修复知识库文件列表包体异常膨胀:上传阶段不再把批次级 `content_hashes` 写入每个文件的 `processing_params`,并从数据库详情列表接口中移除该字段,改为按需读取单文件详情

View File

@ -341,7 +341,7 @@ export const threadApi = {
* @param {boolean} recursive
* @returns {Promise}
*/
listThreadFiles: (threadId, path = '/home/yuxi/user-data', recursive = false) =>
listThreadFiles: (threadId, path = '/home/gem/user-data', recursive = false) =>
apiGet(
`/api/chat/thread/${threadId}/files?path=${encodeURIComponent(path)}&recursive=${recursive}`
),

View File

@ -775,7 +775,7 @@ const fetchThreadMessages = async ({ agentId, threadId, delay = 0 }) => {
const fetchThreadFiles = async (threadId) => {
if (!threadId) return
try {
const response = await threadApi.listThreadFiles(threadId, '/home/yuxi/user-data', true)
const response = await threadApi.listThreadFiles(threadId, '/home/gem/user-data', true)
const entries = Array.isArray(response?.files) ? response.files : []
threadFilesMap.value[threadId] = entries
} catch (error) {