feat(skills): 支持依赖管理与运行时渐进注入

This commit is contained in:
肖泽涛 2026-02-22 17:42:37 +08:00
parent 85b28f3753
commit 2666b27fcb
13 changed files with 720 additions and 5 deletions

View File

@ -15,10 +15,12 @@ from src.services.skill_service import (
delete_skill,
delete_skill_node,
export_skill_zip,
get_skill_dependency_options,
get_skill_tree,
import_skill_zip,
list_skills,
read_skill_file,
update_skill_dependencies,
update_skill_file,
)
from src.storage.postgres.models_business import User
@ -38,6 +40,12 @@ class SkillFileUpdateRequest(BaseModel):
content: str = Field(..., description="文件内容")
class SkillDependenciesUpdateRequest(BaseModel):
tool_dependencies: list[str] = Field(default_factory=list, description="依赖的内置工具列表")
mcp_dependencies: list[str] = Field(default_factory=list, description="依赖的 MCP 服务列表")
skill_dependencies: list[str] = Field(default_factory=list, description="依赖的其他 skill slug 列表")
def _raise_from_value_error(e: ValueError) -> None:
message = str(e)
status_code = 404 if "不存在" in message else 400
@ -58,6 +66,18 @@ async def list_skills_route(
raise HTTPException(status_code=500, detail="获取技能列表失败")
@skills.get("/dependency-options")
async def get_skill_dependency_options_route(
_current_user: User = Depends(get_superadmin_user),
):
"""获取 skill 依赖项可选列表(仅超级管理员)。"""
try:
return {"success": True, "data": get_skill_dependency_options()}
except Exception as e:
logger.error(f"Failed to get skill dependency options: {e}")
raise HTTPException(status_code=500, detail="获取 skill 依赖选项失败")
@skills.post("/import")
async def import_skill_route(
file: UploadFile = File(...),
@ -175,6 +195,33 @@ async def update_skill_file_route(
raise HTTPException(status_code=500, detail="更新技能文件失败")
@skills.put("/{slug}/dependencies")
async def update_skill_dependencies_route(
slug: str,
payload: SkillDependenciesUpdateRequest,
current_user: User = Depends(get_superadmin_user),
db: AsyncSession = Depends(get_db),
):
"""更新 skill 依赖(仅超级管理员)。"""
try:
item = await update_skill_dependencies(
db,
slug=slug,
tool_dependencies=payload.tool_dependencies,
mcp_dependencies=payload.mcp_dependencies,
skill_dependencies=payload.skill_dependencies,
updated_by=current_user.username,
)
return {"success": True, "data": item.to_dict()}
except ValueError as e:
_raise_from_value_error(e)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update skill dependencies '{slug}': {e}")
raise HTTPException(status_code=500, detail="更新 skill 依赖失败")
@skills.delete("/{slug}/file")
async def delete_skill_file_route(
slug: str,

View File

@ -17,6 +17,7 @@ async def lifespan(app: FastAPI):
try:
pg_manager.initialize()
await pg_manager.create_business_tables()
await pg_manager.ensure_business_schema()
await pg_manager.ensure_knowledge_schema()
except Exception as e:
logger.error(f"Failed to initialize database during startup: {e}")

View File

@ -6,7 +6,7 @@ from typing import Any
from deepagents.backends import CompositeBackend, FilesystemBackend, StateBackend
from deepagents.backends.protocol import EditResult, FileDownloadResponse, FileUploadResponse, WriteResult
from src.services.skill_service import get_skills_root_dir
from src.services.skill_service import get_expanded_visible_skill_slugs, get_skills_root_dir
class SelectedSkillsReadonlyBackend(FilesystemBackend):
@ -120,9 +120,10 @@ class SelectedSkillsReadonlyBackend(FilesystemBackend):
def create_agent_composite_backend(runtime) -> CompositeBackend:
"""为 agent 构建 backend默认 StateBackend + /skills 路由只读 backend。"""
selected_skills = getattr(runtime.context, "skills", None)
visible_skills = get_expanded_visible_skill_slugs(selected_skills or [])
return CompositeBackend(
default=StateBackend(runtime),
routes={
"/skills/": SelectedSkillsReadonlyBackend(selected_slugs=selected_skills),
"/skills/": SelectedSkillsReadonlyBackend(selected_slugs=visible_skills),
},
)

View File

@ -28,6 +28,9 @@ class SkillRepository:
slug: str,
name: str,
description: str,
tool_dependencies: list[str] | None,
mcp_dependencies: list[str] | None,
skill_dependencies: list[str] | None,
dir_path: str,
created_by: str | None,
) -> Skill:
@ -36,6 +39,9 @@ class SkillRepository:
slug=slug,
name=name,
description=description,
tool_dependencies=tool_dependencies or [],
mcp_dependencies=mcp_dependencies or [],
skill_dependencies=skill_dependencies or [],
dir_path=dir_path,
created_by=created_by,
updated_by=created_by,
@ -47,6 +53,24 @@ class SkillRepository:
await self.db.refresh(item)
return item
async def update_dependencies(
self,
item: Skill,
*,
tool_dependencies: list[str],
mcp_dependencies: list[str],
skill_dependencies: list[str],
updated_by: str | None,
) -> Skill:
item.tool_dependencies = tool_dependencies
item.mcp_dependencies = mcp_dependencies
item.skill_dependencies = skill_dependencies
item.updated_by = updated_by
item.updated_at = utc_now_naive()
await self.db.commit()
await self.db.refresh(item)
return item
async def update_metadata(
self,
item: Skill,

View File

@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from src import config as sys_config
from src.repositories.skill_repository import SkillRepository
from src.services.mcp_service import get_mcp_server_names
from src.storage.postgres.manager import pg_manager
from src.storage.postgres.models_business import Skill
from src.utils.logging_config import logger
@ -52,6 +53,29 @@ TEXT_FILE_EXTENSIONS = {
_skill_options_cache: list[dict[str, str]] = []
_skill_prompt_metadata_cache: dict[str, dict[str, str]] = {}
_skill_dependency_cache: dict[str, dict[str, list[str]]] = {}
def _normalize_string_list(values: list[str] | None) -> list[str]:
if not values:
return []
normalized: list[str] = []
seen: set[str] = set()
for value in values:
if not isinstance(value, str):
continue
item = value.strip()
if not item or item in seen:
continue
seen.add(item)
normalized.append(item)
return normalized
def _get_buildin_tool_names() -> list[str]:
from src.agents.common.tools import get_buildin_tools
return [tool.name for tool in get_buildin_tools()]
def get_skills_root_dir() -> Path:
@ -65,6 +89,14 @@ def get_skill_options() -> list[dict[str, str]]:
return list(_skill_options_cache)
def get_skill_dependency_options() -> dict[str, list[str]]:
return {
"tools": _get_buildin_tool_names(),
"mcps": get_mcp_server_names(),
"skills": [item["id"] for item in _skill_options_cache],
}
def get_skill_prompt_metadata_by_slugs(slugs: list[str]) -> list[dict[str, str]]:
"""按 slug 顺序返回 skills prompt 元数据(仅缓存,无 IO"""
if not slugs:
@ -87,8 +119,68 @@ def get_skill_prompt_metadata_by_slugs(slugs: list[str]) -> list[dict[str, str]]
return result
def expand_skill_closure(slugs: list[str]) -> list[str]:
"""递归展开 skill 依赖(仅缓存,无 IO去重保序并去环。"""
ordered_roots = _normalize_string_list(slugs)
if not ordered_roots:
return []
result: list[str] = []
seen: set[str] = set()
def dfs(slug: str, stack: set[str]) -> None:
if slug in stack:
logger.warning(f"Cycle detected in skill dependencies, skip: {' -> '.join([*stack, slug])}")
return
if slug in seen:
return
node = _skill_dependency_cache.get(slug)
if not node:
logger.warning(f"Skill dependency target not found in cache, skip: {slug}")
return
seen.add(slug)
result.append(slug)
next_stack = set(stack)
next_stack.add(slug)
for dep in node.get("skills", []):
dfs(dep, next_stack)
for root in ordered_roots:
dfs(root, set())
return result
def get_dependency_bundle_for_activated_skills(activated_slugs: list[str]) -> dict[str, list[str]]:
closure = expand_skill_closure(activated_slugs)
tools: list[str] = []
mcps: list[str] = []
seen_tools: set[str] = set()
seen_mcps: set[str] = set()
for slug in closure:
dep = _skill_dependency_cache.get(slug, {})
for tool_name in dep.get("tools", []):
if tool_name in seen_tools:
continue
seen_tools.add(tool_name)
tools.append(tool_name)
for server_name in dep.get("mcps", []):
if server_name in seen_mcps:
continue
seen_mcps.add(server_name)
mcps.append(server_name)
return {"tools": tools, "mcps": mcps, "skills": closure}
def get_expanded_visible_skill_slugs(selected_slugs: list[str]) -> list[str]:
"""展开运行时可见 skills根 skills + 递归依赖)。"""
return expand_skill_closure(selected_slugs)
def _set_skill_options_cache(items: list[Skill]) -> None:
global _skill_options_cache, _skill_prompt_metadata_cache
global _skill_options_cache, _skill_prompt_metadata_cache, _skill_dependency_cache
_skill_options_cache = [
{
"id": item.slug,
@ -105,6 +197,14 @@ def _set_skill_options_cache(items: list[Skill]) -> None:
}
for item in items
}
_skill_dependency_cache = {
item.slug: {
"tools": _normalize_string_list(item.tool_dependencies or []),
"mcps": _normalize_string_list(item.mcp_dependencies or []),
"skills": _normalize_string_list(item.skill_dependencies or []),
}
for item in items
}
async def init_skills_cache() -> None:
@ -126,6 +226,69 @@ async def list_skills(db: AsyncSession) -> list[Skill]:
return items
def _validate_dependencies(
*,
slug: str,
tool_dependencies: list[str],
mcp_dependencies: list[str],
skill_dependencies: list[str],
) -> tuple[list[str], list[str], list[str]]:
tools = _normalize_string_list(tool_dependencies)
mcps = _normalize_string_list(mcp_dependencies)
skills = _normalize_string_list(skill_dependencies)
available_tools = set(_get_buildin_tool_names())
invalid_tools = [name for name in tools if name not in available_tools]
if invalid_tools:
raise ValueError(f"存在无效工具依赖: {', '.join(invalid_tools)}")
available_mcps = set(get_mcp_server_names())
invalid_mcps = [name for name in mcps if name not in available_mcps]
if invalid_mcps:
raise ValueError(f"存在无效 MCP 依赖: {', '.join(invalid_mcps)}")
available_skills = {item["id"] for item in _skill_options_cache}
invalid_skills = [name for name in skills if name not in available_skills]
if invalid_skills:
raise ValueError(f"存在无效 skill 依赖: {', '.join(invalid_skills)}")
if slug in skills:
raise ValueError("skill_dependencies 不允许包含自身")
return tools, mcps, skills
async def update_skill_dependencies(
db: AsyncSession,
*,
slug: str,
tool_dependencies: list[str],
mcp_dependencies: list[str],
skill_dependencies: list[str],
updated_by: str | None,
) -> Skill:
item = await get_skill_or_raise(db, slug)
repo = SkillRepository(db)
# 写操作前先同步一次缓存,确保依赖校验基于最新技能集合。
_set_skill_options_cache(await repo.list_all())
tools, mcps, skills = _validate_dependencies(
slug=slug,
tool_dependencies=tool_dependencies,
mcp_dependencies=mcp_dependencies,
skill_dependencies=skill_dependencies,
)
updated = await repo.update_dependencies(
item,
tool_dependencies=tools,
mcp_dependencies=mcps,
skill_dependencies=skills,
updated_by=updated_by,
)
_set_skill_options_cache(await repo.list_all())
return updated
def _validate_skill_name(name: str) -> str:
name = name.strip()
if not name:
@ -312,6 +475,9 @@ async def import_skill_zip(
slug=final_slug,
name=final_name,
description=parsed_desc,
tool_dependencies=[],
mcp_dependencies=[],
skill_dependencies=[],
dir_path=(Path("skills") / final_slug).as_posix(),
created_by=created_by,
)

View File

@ -164,6 +164,18 @@ class PostgresManager(metaclass=SingletonMeta):
for stmt in stmts:
await conn.execute(text(stmt))
async def ensure_business_schema(self):
"""确保业务 schema 包含后续新增字段(兼容已存在表)。"""
self._check_initialized()
stmts = [
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS tool_dependencies JSONB DEFAULT '[]'::jsonb",
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS mcp_dependencies JSONB DEFAULT '[]'::jsonb",
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS skill_dependencies JSONB DEFAULT '[]'::jsonb",
]
async with self.async_engine.begin() as conn:
for stmt in stmts:
await conn.execute(text(stmt))
@property
def is_postgresql(self) -> bool:
"""检查是否是 PostgreSQL 数据库"""

View File

@ -180,6 +180,9 @@ class Skill(Base):
slug = Column(String(128), nullable=False, unique=True, index=True, comment="技能唯一标识(目录名)")
name = Column(String(128), nullable=False, comment="技能名称(来自 SKILL.md frontmatter.name")
description = Column(Text, nullable=False, comment="技能描述(来自 SKILL.md frontmatter.description")
tool_dependencies = Column(JSON, nullable=False, default=list, comment="依赖的内置工具名列表")
mcp_dependencies = Column(JSON, nullable=False, default=list, comment="依赖的 MCP 服务名列表")
skill_dependencies = Column(JSON, nullable=False, default=list, comment="依赖的其他 skill slug 列表")
dir_path = Column(String(512), nullable=False, comment="技能目录路径(相对 save_dir")
created_by = Column(String(64), nullable=True)
updated_by = Column(String(64), nullable=True)
@ -192,6 +195,9 @@ class Skill(Base):
"slug": self.slug,
"name": self.name,
"description": self.description,
"tool_dependencies": self.tool_dependencies or [],
"mcp_dependencies": self.mcp_dependencies or [],
"skill_dependencies": self.skill_dependencies or [],
"dir_path": self.dir_path,
"created_by": self.created_by,
"updated_by": self.updated_by,

View File

@ -5,7 +5,8 @@ from types import SimpleNamespace
from typing import Any
import pytest
from langchain_core.messages import SystemMessage
from langchain_core.messages import SystemMessage, ToolMessage
from langgraph.types import Command
import src.agents.common.middlewares.runtime_config_middleware as runtime_middleware
from src.agents.common.middlewares.runtime_config_middleware import RuntimeConfigMiddleware
@ -22,12 +23,14 @@ class _FakeRequest:
runtime: Any
tools: list[Any]
system_message: SystemMessage
state: dict[str, Any]
def override(self, **kwargs):
return _FakeRequest(
runtime=kwargs.get("runtime", self.runtime),
tools=kwargs.get("tools", self.tools),
system_message=kwargs.get("system_message", self.system_message),
state=kwargs.get("state", self.state),
)
@ -42,6 +45,7 @@ def _build_request(*, skills: list[str], tools: list[str], system_prompt: str =
runtime=runtime,
tools=[_FakeTool(name=name) for name in tools],
system_message=SystemMessage(content=[{"type": "text", "text": "base"}]),
state={},
)
@ -58,6 +62,11 @@ def _build_middleware() -> RuntimeConfigMiddleware:
)
@dataclass
class _FakeToolCallRequest:
tool_call: dict[str, Any]
@pytest.mark.asyncio
async def test_injects_skills_section_when_skills_configured_and_read_file_available(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
@ -154,3 +163,89 @@ async def test_injects_skills_in_input_order_with_dedup_and_invalid_slug_skipped
assert prompt.find(beta_line) < prompt.find(alpha_line)
assert prompt.count(beta_line) == 1
assert "missing" not in prompt
@pytest.mark.asyncio
async def test_awrap_tool_call_activates_skill_when_read_skill_md():
middleware = _build_middleware()
request = _FakeToolCallRequest(
tool_call={
"name": "read_file",
"args": {"file_path": "/skills/research-report/SKILL.md"},
}
)
async def _handler(_request):
return ToolMessage(content="ok", tool_call_id="tc-1")
result = await middleware.awrap_tool_call(request, _handler)
assert isinstance(result, Command)
assert result.update["activated_skills"] == ["research-report"]
assert len(result.update["messages"]) == 1
@pytest.mark.asyncio
async def test_awrap_tool_call_merges_with_existing_command_update():
middleware = _build_middleware()
request = _FakeToolCallRequest(
tool_call={
"name": "read_file",
"args": {"file_path": "/skills/research-report/SKILL.md"},
}
)
async def _handler(_request):
return Command(update={"messages": [ToolMessage(content="ok", tool_call_id="tc-1")], "activated_skills": ["a"]})
result = await middleware.awrap_tool_call(request, _handler)
assert isinstance(result, Command)
assert result.update["activated_skills"] == ["a", "research-report"]
@pytest.mark.asyncio
async def test_model_call_injects_dependency_tools_and_mcps_after_activation(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
runtime_middleware,
"get_buildin_tools",
lambda: [_FakeTool(name="calculator"), _FakeTool(name="dep-tool")],
)
monkeypatch.setattr(runtime_middleware, "get_kb_based_tools", lambda db_names=None: [])
monkeypatch.setattr(
runtime_middleware,
"get_dependency_bundle_for_activated_skills",
lambda activated: {"tools": ["dep-tool"], "mcps": ["mcp-a"], "skills": activated},
)
async def fake_get_enabled_mcp_tools(server_name: str):
if server_name == "mcp-a":
return [_FakeTool(name="mcp_tool")]
return []
monkeypatch.setattr(runtime_middleware, "get_enabled_mcp_tools", fake_get_enabled_mcp_tools)
middleware = RuntimeConfigMiddleware(
extra_tools=[_FakeTool(name="mcp_tool")],
enable_model_override=False,
enable_tools_override=True,
enable_system_prompt_override=False,
enable_skills_prompt_override=False,
)
context = SimpleNamespace(system_prompt="x", skills=[], tools=[], knowledges=[], mcps=[])
request = _FakeRequest(
runtime=SimpleNamespace(context=context),
tools=[
_FakeTool(name="calculator"),
_FakeTool(name="dep-tool"),
_FakeTool(name="mcp_tool"),
_FakeTool(name="read_file"),
],
system_message=SystemMessage(content=[{"type": "text", "text": "base"}]),
state={"activated_skills": ["alpha"]},
)
result = await middleware.awrap_model_call(request, _echo_handler)
tool_names = [t.name for t in result.tools]
assert "dep-tool" in tool_names
assert "mcp_tool" in tool_names
assert "calculator" not in tool_names

View File

@ -97,3 +97,69 @@ def test_update_skill_file_passes_operator(monkeypatch):
assert captured["slug"] == "demo"
assert captured["relative_path"] == "SKILL.md"
assert captured["updated_by"] == "root"
def test_dependency_options_route(monkeypatch):
monkeypatch.setattr(
"server.routers.skill_router.get_skill_dependency_options",
lambda: {
"tools": ["calculator"],
"mcps": ["mcp-a"],
"skills": ["demo"],
},
)
app = _build_app(allow_superadmin=True)
client = TestClient(app)
resp = client.get("/api/system/skills/dependency-options")
assert resp.status_code == 200, resp.text
payload = resp.json()
assert payload["success"] is True
assert payload["data"]["tools"] == ["calculator"]
def test_update_skill_dependencies_route(monkeypatch):
captured: dict[str, object] = {}
async def fake_update_skill_dependencies(
_db,
*,
slug,
tool_dependencies,
mcp_dependencies,
skill_dependencies,
updated_by,
):
captured["slug"] = slug
captured["tool_dependencies"] = tool_dependencies
captured["mcp_dependencies"] = mcp_dependencies
captured["skill_dependencies"] = skill_dependencies
captured["updated_by"] = updated_by
return Skill(
slug=slug,
name=slug,
description="demo",
dir_path=f"skills/{slug}",
tool_dependencies=tool_dependencies,
mcp_dependencies=mcp_dependencies,
skill_dependencies=skill_dependencies,
)
monkeypatch.setattr("server.routers.skill_router.update_skill_dependencies", fake_update_skill_dependencies)
app = _build_app(allow_superadmin=True)
client = TestClient(app)
resp = client.put(
"/api/system/skills/demo/dependencies",
json={
"tool_dependencies": ["calculator"],
"mcp_dependencies": ["mcp-a"],
"skill_dependencies": ["other-skill"],
},
)
assert resp.status_code == 200, resp.text
assert captured["slug"] == "demo"
assert captured["tool_dependencies"] == ["calculator"]
assert captured["mcp_dependencies"] == ["mcp-a"]
assert captured["skill_dependencies"] == ["other-skill"]
assert captured["updated_by"] == "root"

View File

@ -52,6 +52,57 @@ def test_get_skill_prompt_metadata_by_slugs_dedup_and_skip_missing(monkeypatch:
assert [item["path"] for item in result] == ["/skills/beta/SKILL.md", "/skills/alpha/SKILL.md"]
def test_get_skill_dependency_options(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(svc, "_get_buildin_tool_names", lambda: ["calculator", "search"])
monkeypatch.setattr(svc, "get_mcp_server_names", lambda: ["mcp-a", "mcp-b"])
monkeypatch.setattr(
svc,
"_skill_options_cache",
[
{"id": "alpha", "name": "alpha", "description": "a"},
{"id": "beta", "name": "beta", "description": "b"},
],
)
result = svc.get_skill_dependency_options()
assert result["tools"] == ["calculator", "search"]
assert result["mcps"] == ["mcp-a", "mcp-b"]
assert result["skills"] == ["alpha", "beta"]
def test_expand_skill_closure_and_dependency_bundle(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
svc,
"_skill_dependency_cache",
{
"alpha": {"tools": ["t1"], "mcps": ["m1"], "skills": ["beta"]},
"beta": {"tools": ["t2"], "mcps": ["m2"], "skills": ["gamma"]},
"gamma": {"tools": ["t3"], "mcps": [], "skills": []},
},
)
closure = svc.expand_skill_closure(["alpha"])
assert closure == ["alpha", "beta", "gamma"]
bundle = svc.get_dependency_bundle_for_activated_skills(["alpha"])
assert bundle["skills"] == ["alpha", "beta", "gamma"]
assert bundle["tools"] == ["t1", "t2", "t3"]
assert bundle["mcps"] == ["m1", "m2"]
def test_expand_skill_closure_cycle(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
svc,
"_skill_dependency_cache",
{
"alpha": {"tools": [], "mcps": [], "skills": ["beta"]},
"beta": {"tools": [], "mcps": [], "skills": ["alpha"]},
},
)
# 不应抛异常,并且去重保序
assert svc.expand_skill_closure(["alpha"]) == ["alpha", "beta"]
def test_resolve_relative_path_blocks_traversal(tmp_path: Path):
skill_dir = tmp_path / "skill"
skill_dir.mkdir(parents=True, exist_ok=True)
@ -80,6 +131,9 @@ async def test_import_skill_zip_conflict_rewrite_name(tmp_path: Path, monkeypatc
slug: str,
name: str,
description: str,
tool_dependencies: list[str] | None,
mcp_dependencies: list[str] | None,
skill_dependencies: list[str] | None,
dir_path: str,
created_by: str | None,
) -> Skill:
@ -87,6 +141,9 @@ async def test_import_skill_zip_conflict_rewrite_name(tmp_path: Path, monkeypatc
slug=slug,
name=name,
description=description,
tool_dependencies=tool_dependencies or [],
mcp_dependencies=mcp_dependencies or [],
skill_dependencies=skill_dependencies or [],
dir_path=dir_path,
created_by=created_by,
updated_by=created_by,
@ -193,3 +250,85 @@ async def test_update_skill_md_syncs_metadata(tmp_path: Path, monkeypatch: pytes
assert updates["updated_by"] == "admin"
saved_content = (skill_dir / "SKILL.md").read_text(encoding="utf-8")
assert "description: updated desc" in saved_content
@pytest.mark.asyncio
async def test_update_skill_dependencies(monkeypatch: pytest.MonkeyPatch):
item = Skill(
slug="alpha",
name="alpha",
description="alpha",
dir_path="skills/alpha",
tool_dependencies=[],
mcp_dependencies=[],
skill_dependencies=[],
)
monkeypatch.setattr(svc, "_get_buildin_tool_names", lambda: ["calculator"])
monkeypatch.setattr(svc, "get_mcp_server_names", lambda: ["mcp-a"])
monkeypatch.setattr(
svc,
"_skill_options_cache",
[
{"id": "alpha", "name": "alpha", "description": "a"},
{"id": "beta", "name": "beta", "description": "b"},
],
)
async def fake_get_skill_or_raise(_db, slug: str):
assert slug == "alpha"
return item
captured: dict[str, list[str] | str | None] = {}
class FakeRepo:
def __init__(self, _db):
pass
async def list_all(self):
return [
item,
Skill(
slug="beta",
name="beta",
description="beta",
dir_path="skills/beta",
tool_dependencies=[],
mcp_dependencies=[],
skill_dependencies=[],
),
]
async def update_dependencies(
self,
_item: Skill,
*,
tool_dependencies: list[str],
mcp_dependencies: list[str],
skill_dependencies: list[str],
updated_by: str | None,
):
captured["tool_dependencies"] = tool_dependencies
captured["mcp_dependencies"] = mcp_dependencies
captured["skill_dependencies"] = skill_dependencies
captured["updated_by"] = updated_by
_item.tool_dependencies = tool_dependencies
_item.mcp_dependencies = mcp_dependencies
_item.skill_dependencies = skill_dependencies
return _item
monkeypatch.setattr(svc, "get_skill_or_raise", fake_get_skill_or_raise)
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
updated = await svc.update_skill_dependencies(
None,
slug="alpha",
tool_dependencies=["calculator", "calculator"],
mcp_dependencies=["mcp-a", "mcp-a"],
skill_dependencies=["beta", "beta"],
updated_by="root",
)
assert captured["tool_dependencies"] == ["calculator"]
assert captured["mcp_dependencies"] == ["mcp-a"]
assert captured["skill_dependencies"] == ["beta"]
assert captured["updated_by"] == "root"
assert updated.skill_dependencies == ["beta"]

View File

@ -49,6 +49,7 @@ def test_selected_skills_backend_readonly_and_visible_only_selected(tmp_path, mo
def test_composite_backend_mounts_skills_under_prefix(tmp_path, monkeypatch):
_prepare_skills_dir(tmp_path)
monkeypatch.setattr(skills_backend, "get_skills_root_dir", lambda: tmp_path)
monkeypatch.setattr(skills_backend, "get_expanded_visible_skill_slugs", lambda slugs: ["alpha", "beta"])
runtime = SimpleNamespace(
context=SimpleNamespace(skills=["alpha"]),
@ -62,7 +63,7 @@ def test_composite_backend_mounts_skills_under_prefix(tmp_path, monkeypatch):
skills_root = composite.ls_info("/skills/")
skill_paths = sorted(entry.get("path") for entry in skills_root)
assert skill_paths == ["/skills/alpha/"]
assert skill_paths == ["/skills/alpha/", "/skills/beta/"]
denied = composite.write("/skills/alpha/new.md", "x")
assert denied.error and "read-only" in denied.error

View File

@ -18,6 +18,10 @@ export const importSkillZip = async (file) => {
return apiSuperAdminPost(`${BASE_URL}/import`, formData)
}
export const getSkillDependencyOptions = async () => {
return apiSuperAdminGet(`${BASE_URL}/dependency-options`)
}
export const getSkillTree = async (slug) => {
return apiSuperAdminGet(`${BASE_URL}/${encodeURIComponent(slug)}/tree`)
}
@ -34,6 +38,10 @@ export const updateSkillFile = async (slug, payload) => {
return apiSuperAdminPut(`${BASE_URL}/${encodeURIComponent(slug)}/file`, payload)
}
export const updateSkillDependencies = async (slug, payload) => {
return apiSuperAdminPut(`${BASE_URL}/${encodeURIComponent(slug)}/dependencies`, payload)
}
export const deleteSkillFile = async (slug, path) => {
return apiSuperAdminDelete(`${BASE_URL}/${encodeURIComponent(slug)}/file?path=${encodeURIComponent(path)}`)
}
@ -49,10 +57,12 @@ export const deleteSkill = async (slug) => {
export const skillApi = {
listSkills,
importSkillZip,
getSkillDependencyOptions,
getSkillTree,
getSkillFile,
createSkillFile,
updateSkillFile,
updateSkillDependencies,
deleteSkillFile,
exportSkill,
deleteSkill

View File

@ -41,6 +41,11 @@
<div class="detail-title">
<strong>{{ currentSkill.name }}</strong>
<span class="slug">({{ currentSkill.slug }})</span>
<span class="dependency-summary">
工具 {{ (currentSkill.tool_dependencies || []).length }} · MCP
{{ (currentSkill.mcp_dependencies || []).length }} · Skills
{{ (currentSkill.skill_dependencies || []).length }}
</span>
</div>
<div class="detail-actions">
<a-button size="small" @click="reloadTree">刷新目录</a-button>
@ -51,6 +56,44 @@
</div>
</div>
<div class="dependency-panel">
<div class="dependency-header">
<span class="dependency-title">依赖管理</span>
<a-button type="primary" size="small" :loading="savingDependencies" @click="saveDependencies">
保存依赖
</a-button>
</div>
<a-form layout="vertical" class="dependency-form">
<a-form-item label="工具依赖">
<a-select
v-model:value="dependencyForm.tool_dependencies"
mode="multiple"
:options="toolDependencyOptions"
placeholder="选择工具依赖"
allow-clear
/>
</a-form-item>
<a-form-item label="MCP 依赖">
<a-select
v-model:value="dependencyForm.mcp_dependencies"
mode="multiple"
:options="mcpDependencyOptions"
placeholder="选择 MCP 服务依赖"
allow-clear
/>
</a-form-item>
<a-form-item label="Skill 依赖">
<a-select
v-model:value="dependencyForm.skill_dependencies"
mode="multiple"
:options="skillDependencyOptions"
placeholder="选择 Skill 依赖"
allow-clear
/>
</a-form-item>
</a-form>
</div>
<div class="detail-body">
<div class="tree-panel">
<div class="tree-toolbar">
@ -129,6 +172,7 @@ const loading = ref(false)
const importing = ref(false)
const savingFile = ref(false)
const creatingNode = ref(false)
const savingDependencies = ref(false)
const skills = ref([])
const currentSkill = ref(null)
@ -145,10 +189,27 @@ const createForm = reactive({
isDir: false,
content: ''
})
const dependencyOptions = reactive({
tools: [],
mcps: [],
skills: []
})
const dependencyForm = reactive({
tool_dependencies: [],
mcp_dependencies: [],
skill_dependencies: []
})
const columns = [
{ title: '名称', dataIndex: 'name', key: 'name', width: 180, ellipsis: true },
{ title: 'Slug', dataIndex: 'slug', key: 'slug', width: 180, ellipsis: true },
{
title: '依赖',
key: 'dependencies',
width: 150,
customRender: ({ record }) =>
`T${(record.tool_dependencies || []).length} / M${(record.mcp_dependencies || []).length} / S${(record.skill_dependencies || []).length}`
},
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
{ title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 180, ellipsis: true }
]
@ -162,6 +223,20 @@ const rowClassName = (record) => {
return currentSkill.value?.slug === record.slug ? 'selected-row' : ''
}
const toolDependencyOptions = computed(() =>
(dependencyOptions.tools || []).map((item) => ({ label: item, value: item }))
)
const mcpDependencyOptions = computed(() =>
(dependencyOptions.mcps || []).map((item) => ({ label: item, value: item }))
)
const skillDependencyOptions = computed(() =>
(dependencyOptions.skills || [])
.filter((slug) => slug !== currentSkill.value?.slug)
.map((item) => ({ label: item, value: item }))
)
const bindSkillRow = (record) => ({
onClick: () => selectSkill(record)
})
@ -199,8 +274,10 @@ const fetchSkills = async () => {
resetFileState()
} else {
currentSkill.value = latest
syncDependencyFormFromSkill(latest)
}
}
await fetchDependencyOptions()
} catch (error) {
message.error(error.message || '获取 Skills 列表失败')
} finally {
@ -208,6 +285,24 @@ const fetchSkills = async () => {
}
}
const fetchDependencyOptions = async () => {
try {
const result = await skillApi.getSkillDependencyOptions()
const data = result?.data || {}
dependencyOptions.tools = data.tools || []
dependencyOptions.mcps = data.mcps || []
dependencyOptions.skills = data.skills || []
} catch (error) {
message.error(error.message || '获取依赖选项失败')
}
}
const syncDependencyFormFromSkill = (skillRecord) => {
dependencyForm.tool_dependencies = [...(skillRecord?.tool_dependencies || [])]
dependencyForm.mcp_dependencies = [...(skillRecord?.mcp_dependencies || [])]
dependencyForm.skill_dependencies = [...(skillRecord?.skill_dependencies || [])]
}
const reloadTree = async () => {
if (!currentSkill.value) return
loading.value = true
@ -223,6 +318,7 @@ const reloadTree = async () => {
const selectSkill = async (record) => {
currentSkill.value = record
syncDependencyFormFromSkill(record)
resetFileState()
await reloadTree()
}
@ -400,6 +496,29 @@ const handleImportUpload = async ({ file, onSuccess, onError }) => {
}
}
const saveDependencies = async () => {
if (!currentSkill.value) return
savingDependencies.value = true
try {
const result = await skillApi.updateSkillDependencies(currentSkill.value.slug, {
tool_dependencies: dependencyForm.tool_dependencies,
mcp_dependencies: dependencyForm.mcp_dependencies,
skill_dependencies: dependencyForm.skill_dependencies
})
const updated = result?.data || null
if (updated) {
currentSkill.value = updated
syncDependencyFormFromSkill(updated)
}
await fetchSkills()
message.success('依赖保存成功')
} catch (error) {
message.error(error.message || '依赖保存失败')
} finally {
savingDependencies.value = false
}
}
onMounted(() => {
fetchSkills()
})
@ -463,18 +582,46 @@ onMounted(() => {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.slug {
color: var(--gray-600);
}
.dependency-summary {
color: var(--gray-600);
font-size: 12px;
}
.detail-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.dependency-panel {
border: 1px solid var(--gray-150);
border-radius: 8px;
padding: 8px;
margin-bottom: 10px;
}
.dependency-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.dependency-title {
font-weight: 600;
}
.dependency-form :deep(.ant-form-item) {
margin-bottom: 8px;
}
.detail-body {
display: grid;
grid-template-columns: minmax(240px, 35%) 1fr;