diff --git a/backend/package/yuxi/agents/backends/composite.py b/backend/package/yuxi/agents/backends/composite.py index f1289be8..13ecc3be 100644 --- a/backend/package/yuxi/agents/backends/composite.py +++ b/backend/package/yuxi/agents/backends/composite.py @@ -69,7 +69,9 @@ class CustomCompositeBackend(CompositeBackend): def _get_visible_skills_from_runtime(runtime) -> list[str]: """获取运行时可见的 skills 列表""" context = getattr(runtime, "context", None) - selected = getattr(context, "skills", None) or [] + selected = getattr(context, "_visible_skills", None) + if not isinstance(selected, list): + selected = getattr(context, "skills", None) or [] return normalize_selected_skills(selected) @@ -94,7 +96,7 @@ def create_agent_composite_backend(runtime) -> CompositeBackend: visible_skills = _get_visible_skills_from_runtime(runtime) thread_id = _extract_thread_id(runtime) return CustomCompositeBackend( - default=ProvisionerSandboxBackend(thread_id=thread_id), + default=ProvisionerSandboxBackend(thread_id=thread_id, visible_skills=visible_skills), routes={ "/skills/": SelectedSkillsReadonlyBackend(selected_slugs=visible_skills), }, diff --git a/backend/package/yuxi/agents/backends/sandbox/backend.py b/backend/package/yuxi/agents/backends/sandbox/backend.py index e1247dc5..a238eb50 100644 --- a/backend/package/yuxi/agents/backends/sandbox/backend.py +++ b/backend/package/yuxi/agents/backends/sandbox/backend.py @@ -17,6 +17,7 @@ from deepagents.backends.protocol import ( from deepagents.backends.sandbox import BaseSandbox from yuxi import config as conf +from yuxi.services.skill_service import sync_thread_visible_skills from yuxi.utils.logging_config import logger from .provider import get_sandbox_provider, sandbox_id_for_thread @@ -61,11 +62,12 @@ def _looks_like_binary(content: bytes) -> bool: class ProvisionerSandboxBackend(BaseSandbox): - def __init__(self, thread_id: str): + def __init__(self, thread_id: str, *, visible_skills: list[str] | None = None): self._thread_id = str(thread_id or "").strip() if not self._thread_id: raise ValueError("thread_id is required for ProvisionerSandboxBackend") + self._visible_skills = list(visible_skills or []) self._provider = get_sandbox_provider() self._id = sandbox_id_for_thread(self._thread_id) self._client: Any | None = None @@ -88,6 +90,7 @@ class ProvisionerSandboxBackend(BaseSandbox): return AgentSandboxClient(base_url=sandbox_url, timeout=self._command_timeout_seconds) def _get_client(self) -> Any: + sync_thread_visible_skills(self._thread_id, self._visible_skills) connection = self._provider.get(self._thread_id, create_if_missing=True) if connection is None: raise RuntimeError(f"sandbox is unavailable for thread {self._thread_id}") diff --git a/backend/package/yuxi/services/skill_service.py b/backend/package/yuxi/services/skill_service.py index 1021aa0a..c7235426 100644 --- a/backend/package/yuxi/services/skill_service.py +++ b/backend/package/yuxi/services/skill_service.py @@ -4,6 +4,7 @@ import asyncio import re import shutil import tempfile +import threading import uuid import zipfile from pathlib import Path, PurePosixPath @@ -52,6 +53,17 @@ TEXT_FILE_EXTENSIONS = { } BUILTIN_SKILL_OPERATOR = "builtin-system" +_THREAD_SKILLS_LOCK = threading.Lock() +_THREAD_SKILLS_LOCKS: dict[str, threading.Lock] = {} + + +def _get_thread_skills_lock(thread_id: str) -> threading.Lock: + with _THREAD_SKILLS_LOCK: + lock = _THREAD_SKILLS_LOCKS.get(thread_id) + if lock is None: + lock = threading.Lock() + _THREAD_SKILLS_LOCKS[thread_id] = lock + return lock def _normalize_string_list(values: list[str] | None) -> list[str]: @@ -82,6 +94,69 @@ def get_skills_root_dir() -> Path: return root +def get_thread_skills_root_dir(thread_id: str) -> Path: + safe_thread_id = str(thread_id or "").strip() + if not safe_thread_id: + raise ValueError("thread_id is required") + if not re.fullmatch(r"[A-Za-z0-9_-]+", safe_thread_id): + raise ValueError("thread_id contains invalid characters") + + root = Path(sys_config.save_dir) / "threads" / safe_thread_id / "skills" + root.mkdir(parents=True, exist_ok=True) + return root + + +def sync_thread_visible_skills(thread_id: str, selected_slugs: list[str] | None) -> Path: + skills_root = get_skills_root_dir().resolve() + thread_skills_root = get_thread_skills_root_dir(thread_id) + normalized_slugs = [slug for slug in _normalize_string_list(selected_slugs) if is_valid_skill_slug(slug)] + visible_slugs = set(normalized_slugs) + with _get_thread_skills_lock(thread_id): + for entry in thread_skills_root.iterdir(): + if entry.name in visible_slugs: + continue + if entry.is_dir() and not entry.is_symlink(): + shutil.rmtree(entry) + else: + entry.unlink() + + for slug in normalized_slugs: + source_dir = (skills_root / slug).resolve() + target_dir = thread_skills_root / slug + + try: + source_dir.relative_to(skills_root) + except ValueError: + continue + if not source_dir.is_dir(): + if target_dir.exists() or target_dir.is_symlink(): + if target_dir.is_dir() and not target_dir.is_symlink(): + shutil.rmtree(target_dir) + else: + target_dir.unlink() + continue + + if target_dir.exists(): + if target_dir.is_symlink(): + target_dir.unlink() + elif target_dir.is_dir(): + if _dirs_equal(target_dir, source_dir): + continue + shutil.rmtree(target_dir) + else: + target_dir.unlink() + + temp_target = thread_skills_root / f".{slug}.tmp-{uuid.uuid4().hex[:8]}" + try: + shutil.copytree(source_dir, temp_target, symlinks=False) + temp_target.rename(target_dir) + finally: + if temp_target.exists(): + shutil.rmtree(temp_target, ignore_errors=True) + + return thread_skills_root + + def get_builtin_skill_specs() -> list[Any]: from yuxi.agents.skills.buildin import BUILTIN_SKILLS diff --git a/backend/test/test_sandbox_backends.py b/backend/test/test_sandbox_backends.py index 4573a05f..b19c89d7 100644 --- a/backend/test/test_sandbox_backends.py +++ b/backend/test/test_sandbox_backends.py @@ -26,6 +26,7 @@ def test_create_agent_composite_backend_uses_provisioner_default(monkeypatch): backend = create_agent_composite_backend(_runtime(skills=["reporter"])) assert isinstance(backend.default, ProvisionerSandboxBackend) + assert backend.default._visible_skills == ["reporter"] assert "/skills/" in backend.routes diff --git a/backend/test/test_skill_service.py b/backend/test/test_skill_service.py index deb478fd..f6a9c1ef 100644 --- a/backend/test/test_skill_service.py +++ b/backend/test/test_skill_service.py @@ -43,6 +43,27 @@ def test_is_valid_skill_slug(): assert svc.is_valid_skill_slug("") is False +def test_sync_thread_visible_skills_only_keeps_selected(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) + skills_root = tmp_path / "skills" + (skills_root / "alpha").mkdir(parents=True, exist_ok=True) + (skills_root / "alpha" / "SKILL.md").write_text("alpha", encoding="utf-8") + (skills_root / "beta").mkdir(parents=True, exist_ok=True) + (skills_root / "beta" / "SKILL.md").write_text("beta", encoding="utf-8") + + thread_root = svc.sync_thread_visible_skills("thread_1", ["alpha", "missing", "alpha"]) + + assert thread_root == tmp_path / "threads" / "thread_1" / "skills" + assert sorted(path.name for path in thread_root.iterdir()) == ["alpha"] + assert (thread_root / "alpha").is_dir() + assert not (thread_root / "alpha").is_symlink() + assert (thread_root / "alpha" / "SKILL.md").read_text(encoding="utf-8") == "alpha" + + svc.sync_thread_visible_skills("thread_1", ["beta"]) + assert sorted(path.name for path in thread_root.iterdir()) == ["beta"] + assert (thread_root / "beta" / "SKILL.md").read_text(encoding="utf-8") == "beta" + + @pytest.mark.asyncio async def test_get_skill_dependency_options(monkeypatch: pytest.MonkeyPatch): # Mock get_tool_metadata to return tool list @@ -290,11 +311,7 @@ async def test_init_builtin_skills_create_missing(tmp_path: Path, monkeypatch: p source_dir = tmp_path / "builtin-skills" / "reporter" source_dir.mkdir(parents=True, exist_ok=True) (source_dir / "SKILL.md").write_text( - "---\n" - "name: reporter\n" - "description: SQL report\n" - "---\n" - "# SQL Reporter\n", + "---\nname: reporter\ndescription: SQL report\n---\n# SQL Reporter\n", encoding="utf-8", ) (source_dir / "prompts").mkdir(parents=True, exist_ok=True) @@ -387,11 +404,7 @@ async def test_init_builtin_skills_updates_existing_record(tmp_path: Path, monke source_dir = tmp_path / "builtin-skills" / "reporter" source_dir.mkdir(parents=True, exist_ok=True) (source_dir / "SKILL.md").write_text( - "---\n" - "name: reporter\n" - "description: old\n" - "---\n" - "# SQL Reporter\n", + "---\nname: reporter\ndescription: old\n---\n# SQL Reporter\n", encoding="utf-8", ) diff --git a/docker/sandbox_provisioner/app.py b/docker/sandbox_provisioner/app.py index e4fdd64e..405a438f 100644 --- a/docker/sandbox_provisioner/app.py +++ b/docker/sandbox_provisioner/app.py @@ -118,7 +118,6 @@ class LocalContainerProvisionerBackend: ) self._network = os.getenv("DOCKER_NETWORK") self._threads_host_path = os.getenv("DOCKER_THREADS_HOST_PATH") - self._skills_host_path = os.getenv("DOCKER_SKILLS_HOST_PATH") self._container_prefix = os.getenv("DOCKER_SANDBOX_PREFIX", "yuxi-sandbox") self._sandbox_host = os.getenv("DOCKER_SANDBOX_HOST", "host.docker.internal") self._health_timeout_seconds = int(os.getenv("SANDBOX_HEALTH_TIMEOUT_SECONDS", "300")) @@ -131,7 +130,6 @@ class LocalContainerProvisionerBackend: self._resolve_host_paths() self._threads_host_path = self._normalize_host_bind_path(self._threads_host_path) - self._skills_host_path = self._normalize_host_bind_path(self._skills_host_path) @staticmethod def _normalize_host_bind_path(path_value: str | None) -> str: @@ -170,8 +168,27 @@ class LocalContainerProvisionerBackend: def _container_name(self, sandbox_id: str) -> str: return f"{self._container_prefix}-{self._sanitize_id(sandbox_id)}" + def _thread_skills_host_path(self, thread_id: str) -> Path: + threads_root = Path(self._threads_host_path).resolve() + thread_skills = (threads_root / thread_id / "skills").resolve() + try: + thread_skills.relative_to(threads_root) + except ValueError as exc: + raise ValueError("thread skills path resolved outside threads host root") from exc + return thread_skills + + def _is_expected_skills_mount(self, container, thread_id: str) -> bool: + 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": + continue + source = str(mount.get("Source") or "").rstrip("/") + return source == expected_source + return False + def _resolve_host_paths(self) -> None: - if self._threads_host_path and self._skills_host_path: + if self._threads_host_path: return container_id = os.getenv("HOSTNAME", "").strip() @@ -194,9 +211,6 @@ class LocalContainerProvisionerBackend: base = Path(self._normalize_host_bind_path(saves_source)) if not self._threads_host_path: self._threads_host_path = str(base / "threads") - if not self._skills_host_path: - self._skills_host_path = str(base / "skills") - def _host_port_for(self, container) -> int | None: ports = (container.attrs.get("NetworkSettings") or {}).get("Ports") or {} bindings = ports.get(f"{self._container_port}/tcp") @@ -252,6 +266,11 @@ class LocalContainerProvisionerBackend: existing = self._get_container(sandbox_id) if existing is not None: existing.reload() + if not self._is_expected_skills_mount(existing, safe_thread_id): + logger.info("Recreating sandbox %s because skills mount is stale", sandbox_id) + self.delete(sandbox_id) + existing = None + if existing is not None: if existing.status == "running": try: self._ensure_user_data_writable(existing) @@ -277,8 +296,8 @@ class LocalContainerProvisionerBackend: raise ValueError("thread_id resolved outside threads host root") from exc thread_user_data.mkdir(parents=True, exist_ok=True) - skills_path = Path(self._skills_host_path) - skills_path.mkdir(parents=True, exist_ok=True) + thread_skills = self._thread_skills_host_path(safe_thread_id) + thread_skills.mkdir(parents=True, exist_ok=True) container_name = self._container_name(sandbox_id) run_kwargs = { @@ -292,7 +311,7 @@ class LocalContainerProvisionerBackend: }, "volumes": { str(thread_user_data): {"bind": "/home/yuxi/user-data", "mode": "rw"}, - str(skills_path): {"bind": "/home/yuxi/skills", "mode": "ro"}, + str(thread_skills): {"bind": "/home/yuxi/skills", "mode": "ro"}, }, "ports": {f"{self._container_port}/tcp": None}, "security_opt": ["seccomp=unconfined"], @@ -318,6 +337,17 @@ class LocalContainerProvisionerBackend: if container is None: return None container.reload() + thread_id = str((container.labels or {}).get("thread-id") or "").strip() + if not thread_id: + return None + safe_thread_id = self._validate_thread_id(thread_id) + if not self._is_expected_skills_mount(container, safe_thread_id): + logger.info("Discarding stale sandbox %s with legacy skills mount", sandbox_id) + try: + self.delete(sandbox_id) + except Exception as exc: + logger.warning("Failed to delete stale sandbox %s during discover: %s", sandbox_id, exc) + return None record = self._to_record(container, sandbox_id) if not record.sandbox_url: return None @@ -403,16 +433,15 @@ class KubernetesProvisionerBackend: command=["sh", "-c"], args=[ "chmod 777 /home/yuxi " - "&& mkdir -p /home/yuxi/user-data/workspace /home/yuxi/user-data/uploads /home/yuxi/user-data/outputs " - "&& chmod -R 777 /home/yuxi/user-data ", + 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 " + f"/mnt/shared-data/threads/{thread_id}/skills " + 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="shared-data", - mount_path="/home/yuxi/user-data", - sub_path=f"threads/{thread_id}/user-data", - ), + self._client.V1VolumeMount(name="shared-data", mount_path="/mnt/shared-data"), ], ), ], @@ -431,8 +460,8 @@ class KubernetesProvisionerBackend: self._client.V1VolumeMount( name="shared-data", mount_path="/home/yuxi/skills", - sub_path="skills", - read_only=False, + sub_path=f"threads/{thread_id}/skills", + read_only=True, ), ], ) diff --git a/web/src/views/DataBaseView.vue b/web/src/views/DataBaseView.vue index 8ffab89b..6953081f 100644 --- a/web/src/views/DataBaseView.vue +++ b/web/src/views/DataBaseView.vue @@ -212,17 +212,19 @@ -->
{{ database.description || '暂无描述' }}
@@ -675,11 +677,16 @@ onMounted(() => { .database, .graphbase { - background: linear-gradient(145deg, var(--gray-0) 0%, var(--gray-10) 100%); + background: linear-gradient(45deg, var(--gray-0) 0%, var(--gray-25) 100%); box-shadow: 0px 1px 2px 0px var(--shadow-2); - border: 1px solid var(--gray-100); - transition: none; + border: 1px solid var(--gray-50); + transition: all 0.3s; position: relative; + + &:hover { + background: linear-gradient(45deg, var(--gray-0) 0%, var(--main-30) 100%); + box-shadow: 0px 1px 5px var(--shadow-3); + } } .dbcard, @@ -784,6 +791,10 @@ onMounted(() => { font-weight: 400; flex: 1; } + + .tags { + opacity: 0.8; + } } .database-empty {