diff --git a/.env.template b/.env.template index 08178422..a1219321 100644 --- a/.env.template +++ b/.env.template @@ -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 diff --git a/backend/package/yuxi/agents/backends/composite.py b/backend/package/yuxi/agents/backends/composite.py index 013563f8..4507bd65 100644 --- a/backend/package/yuxi/agents/backends/composite.py +++ b/backend/package/yuxi/agents/backends/composite.py @@ -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), }, ) diff --git a/backend/package/yuxi/agents/backends/knowledge_base_backend.py b/backend/package/yuxi/agents/backends/knowledge_base_backend.py index d761cc72..72f8eacf 100644 --- a/backend/package/yuxi/agents/backends/knowledge_base_backend.py +++ b/backend/package/yuxi/agents/backends/knowledge_base_backend.py @@ -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+") diff --git a/backend/package/yuxi/agents/backends/sandbox/__init__.py b/backend/package/yuxi/agents/backends/sandbox/__init__.py index 1291df9d..e6ca900d 100644 --- a/backend/package/yuxi/agents/backends/sandbox/__init__.py +++ b/backend/package/yuxi/agents/backends/sandbox/__init__.py @@ -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" diff --git a/backend/package/yuxi/agents/backends/sandbox/paths.py b/backend/package/yuxi/agents/backends/sandbox/paths.py index 64f8d621..12f3cb01 100644 --- a/backend/package/yuxi/agents/backends/sandbox/paths.py +++ b/backend/package/yuxi/agents/backends/sandbox/paths.py @@ -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_-]+$") diff --git a/backend/package/yuxi/agents/buildin/chatbot/prompt.py b/backend/package/yuxi/agents/buildin/chatbot/prompt.py index 05c0432e..3a05051a 100644 --- a/backend/package/yuxi/agents/buildin/chatbot/prompt.py +++ b/backend/package/yuxi/agents/buildin/chatbot/prompt.py @@ -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/:用于存放用户上传的文件 """ diff --git a/backend/package/yuxi/agents/middlewares/skills_middleware.py b/backend/package/yuxi/agents/middlewares/skills_middleware.py index fb5097eb..a308ab38 100644 --- a/backend/package/yuxi/agents/middlewares/skills_middleware.py +++ b/backend/package/yuxi/agents/middlewares/skills_middleware.py @@ -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): diff --git a/backend/package/yuxi/config/app.py b/backend/package/yuxi/config/app.py index 2ac562d6..85412c63 100644 --- a/backend/package/yuxi/config/app.py +++ b/backend/package/yuxi/config/app.py @@ -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 diff --git a/backend/package/yuxi/services/conversation_service.py b/backend/package/yuxi/services/conversation_service.py index 8415c378..bf066505 100644 --- a/backend/package/yuxi/services/conversation_service.py +++ b/backend/package/yuxi/services/conversation_service.py @@ -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) diff --git a/backend/package/yuxi/services/knowledge_fs_service.py b/backend/package/yuxi/services/knowledge_fs_service.py new file mode 100644 index 00000000..ca8a3a98 --- /dev/null +++ b/backend/package/yuxi/services/knowledge_fs_service.py @@ -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 diff --git a/backend/package/yuxi/services/thread_files_service.py b/backend/package/yuxi/services/thread_files_service.py index 7c0c9fd8..4be3d01a 100644 --- a/backend/package/yuxi/services/thread_files_service.py +++ b/backend/package/yuxi/services/thread_files_service.py @@ -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("/") diff --git a/backend/server/routers/chat_router.py b/backend/server/routers/chat_router.py index 19a50956..213a9fe6 100644 --- a/backend/server/routers/chat_router.py +++ b/backend/server/routers/chat_router.py @@ -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), diff --git a/backend/test/api/test_agent_bubble_sort_e2e.py b/backend/test/api/test_agent_bubble_sort_e2e.py index d325ab99..37b150ed 100644 --- a/backend/test/api/test_agent_bubble_sort_e2e.py +++ b/backend/test/api/test_agent_bubble_sort_e2e.py @@ -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: diff --git a/backend/test/api/test_viewer_filesystem_e2e.py b/backend/test/api/test_viewer_filesystem_e2e.py index 55b07265..2e2c3a7c 100644 --- a/backend/test/api/test_viewer_filesystem_e2e.py +++ b/backend/test/api/test_viewer_filesystem_e2e.py @@ -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": diff --git a/backend/test/api/test_viewer_filesystem_router.py b/backend/test/api/test_viewer_filesystem_router.py index ff713808..10a62fce 100644 --- a/backend/test/api/test_viewer_filesystem_router.py +++ b/backend/test/api/test_viewer_filesystem_router.py @@ -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) diff --git a/backend/test/test_chat_stream_attachment_materialize.py b/backend/test/test_chat_stream_attachment_materialize.py index f021852f..d8191192 100644 --- a/backend/test/test_chat_stream_attachment_materialize.py +++ b/backend/test/test_chat_stream_attachment_materialize.py @@ -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 ( diff --git a/backend/test/test_conversation_service_attachment_state.py b/backend/test/test_conversation_service_attachment_state.py index c032ef9e..7de31e9b 100644 --- a/backend/test/test_conversation_service_attachment_state.py +++ b/backend/test/test_conversation_service_attachment_state.py @@ -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", } diff --git a/backend/test/test_sandbox_backends.py b/backend/test/test_sandbox_backends.py index 225733c9..a757881f 100644 --- a/backend/test/test_sandbox_backends.py +++ b/backend/test/test_sandbox_backends.py @@ -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: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 8671f161..c6c7116c 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index e65e7e54..34d468ce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docker/sandbox_provisioner/app.py b/docker/sandbox_provisioner/app.py index 405a438f..cdb79f69 100644 --- a/docker/sandbox_provisioner/app.py +++ b/docker/sandbox_provisioner/app.py @@ -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, ), diff --git a/docs/agents/agents-config.md b/docs/agents/agents-config.md index ec72b9a2..571e3b5b 100644 --- a/docs/agents/agents-config.md +++ b/docs/agents/agents-config.md @@ -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 既是输入配置,也是中间件共享的运行时状态载体。 diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 5391ca21..933c6f6c 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -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//user-data/uploads`,解析成功后额外生成 `uploads/attachments/*.md`,不再依赖 MinIO 或显式上传到 sandbox - 修复知识库文件列表包体异常膨胀:上传阶段不再把批次级 `content_hashes` 写入每个文件的 `processing_params`,并从数据库详情列表接口中移除该字段,改为按需读取单文件详情 diff --git a/web/src/apis/agent_api.js b/web/src/apis/agent_api.js index 80cd6d32..655cd77b 100644 --- a/web/src/apis/agent_api.js +++ b/web/src/apis/agent_api.js @@ -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}` ), diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue index b9246a09..d3cdd83e 100644 --- a/web/src/components/AgentChatComponent.vue +++ b/web/src/components/AgentChatComponent.vue @@ -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) {