From 56cd1b500a483993397686da2737f7cbf69d5bbe Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Wed, 3 Jun 2026 14:13:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20Skill=20=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E9=80=BB=E8=BE=91=E5=92=8C=E5=B1=95=E7=A4=BA=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package/yuxi/agents/skills/service.py | 20 + backend/server/routers/agent_router.py | 2 +- backend/server/routers/skill_router.py | 28 +- .../repositories/test_agent_repository.py | 47 +- .../test/unit/routers/test_skill_router.py | 110 ++++- backend/test/unit/routers/test_subagent.py | 31 +- .../test/unit/services/test_skill_service.py | 206 +++++++++ web/src/apis/skill_api.js | 38 +- web/src/components/extensions/McpCardList.vue | 5 +- .../components/extensions/SkillCardList.vue | 415 ++++++++++++++++-- .../components/extensions/SkillDetailView.vue | 60 +-- .../components/extensions/ToolsCardList.vue | 3 +- .../model-management/AgentManagePanel.vue | 136 ++++-- web/src/components/shared/InfoCard.vue | 187 +++++--- web/src/layouts/AppLayout.vue | 30 +- web/src/router/index.js | 8 +- web/src/stores/agent.js | 2 +- web/src/utils/extensionDisplayName.js | 14 + web/src/views/AgentView.vue | 20 +- web/src/views/ExtensionsView.vue | 45 +- 20 files changed, 1164 insertions(+), 243 deletions(-) create mode 100644 web/src/utils/extensionDisplayName.js diff --git a/backend/package/yuxi/agents/skills/service.py b/backend/package/yuxi/agents/skills/service.py index bfe9cf36..827f2f50 100644 --- a/backend/package/yuxi/agents/skills/service.py +++ b/backend/package/yuxi/agents/skills/service.py @@ -404,6 +404,19 @@ async def list_manageable_skills(db: AsyncSession, user: User) -> list[Skill]: return [item for item in await repo.list_all() if user_can_manage_skill(user, item)] +async def list_visible_skills_for_management(db: AsyncSession, user: User) -> list[Skill]: + repo = SkillRepository(db) + visible: list[Skill] = [] + seen: set[str] = set() + for item in await repo.list_all(): + if item.slug in seen: + continue + if user_can_manage_skill(user, item) or (item.enabled and user_can_access_skill(user, item)): + visible.append(item) + seen.add(item.slug) + return visible + + async def list_skills(db: AsyncSession) -> list[Skill]: repo = SkillRepository(db) return await repo.list_all() @@ -1019,6 +1032,13 @@ async def get_accessible_skill_or_raise(db: AsyncSession, user: User, slug: str) return item +async def get_management_readable_skill_or_raise(db: AsyncSession, user: User, slug: str) -> Skill: + item = await get_skill_or_raise(db, slug) + if not user_can_manage_skill(user, item) and not user_can_access_skill(user, item): + raise ValueError(f"技能 '{slug}' 不存在或无权访问") + return item + + async def get_manageable_skill_or_raise(db: AsyncSession, user: User, slug: str) -> Skill: item = await get_skill_or_raise(db, slug) if not user_can_manage_skill(user, item): diff --git a/backend/server/routers/agent_router.py b/backend/server/routers/agent_router.py index af24a6b4..8e501eba 100644 --- a/backend/server/routers/agent_router.py +++ b/backend/server/routers/agent_router.py @@ -136,7 +136,7 @@ async def get_default_agent(current_user: User = Depends(get_required_user), db: @agent_router.post("") async def create_agent( - payload: AgentCreate, current_user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db) + payload: AgentCreate, current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db) ): if not agent_manager.get_agent(payload.backend_id): raise HTTPException(status_code=404, detail=f"智能体后端 {payload.backend_id} 不存在") diff --git a/backend/server/routers/skill_router.py b/backend/server/routers/skill_router.py index 2eea4670..57e9d751 100644 --- a/backend/server/routers/skill_router.py +++ b/backend/server/routers/skill_router.py @@ -20,12 +20,14 @@ from yuxi.agents.skills.service import ( export_skill_zip, get_allowed_skill_access_levels, get_manageable_skill_or_raise, + get_management_readable_skill_or_raise, get_skill_dependency_options, get_skill_tree, init_builtin_skills, + is_builtin_skill, list_accessible_skills, - list_manageable_skills, list_skills, + list_visible_skills_for_management, prepare_remote_skill_install, prepare_skill_upload, read_skill_file, @@ -33,6 +35,7 @@ from yuxi.agents.skills.service import ( update_skill_enabled, update_skill_file, update_skill_share_config, + user_can_manage_skill, ) from yuxi.agents.skills.remote_install import list_remote_skills, search_remote_skills from yuxi.storage.postgres.models_business import User @@ -108,6 +111,13 @@ def _summarize_results(results: list[dict]) -> dict[str, int]: } +def _serialize_skill_for_user(item, user: User) -> dict: + data = item.to_dict() + data["can_manage"] = user_can_manage_skill(user, item) + data["is_builtin"] = is_builtin_skill(item) + return data + + @user_skills.get("/accessible") async def list_accessible_skills_route( current_user: User = Depends(get_required_user), @@ -115,7 +125,7 @@ async def list_accessible_skills_route( ): try: items = await list_accessible_skills(db, current_user) - return {"success": True, "data": [item.to_dict() for item in items]} + return {"success": True, "data": [_serialize_skill_for_user(item, current_user) for item in items]} except Exception as e: logger.error(f"Failed to list accessible skills: {e}") raise HTTPException(status_code=500, detail="获取可访问 Skills 失败") @@ -227,10 +237,10 @@ async def list_skills_route( db: AsyncSession = Depends(get_db), ): try: - items = await list_manageable_skills(db, current_user) + items = await list_visible_skills_for_management(db, current_user) return { "success": True, - "data": [item.to_dict() for item in items], + "data": [_serialize_skill_for_user(item, current_user) for item in items], "allowed_access_levels": get_allowed_skill_access_levels(current_user), } except Exception as e: @@ -294,7 +304,7 @@ async def update_skill_share_config_route( ): try: item = await update_skill_share_config(db, slug=slug, share_config=payload.share_config, operator=current_user) - return {"success": True, "data": item.to_dict()} + return {"success": True, "data": _serialize_skill_for_user(item, current_user)} except ValueError as e: _raise_from_value_error(e) except Exception as e: @@ -311,7 +321,7 @@ async def update_skill_enabled_route( ): try: item = await update_skill_enabled(db, slug=slug, enabled=payload.enabled, operator=current_user) - return {"success": True, "data": item.to_dict()} + return {"success": True, "data": _serialize_skill_for_user(item, current_user)} except ValueError as e: _raise_from_value_error(e) except Exception as e: @@ -326,7 +336,7 @@ async def get_skill_tree_route( db: AsyncSession = Depends(get_db), ): try: - await get_manageable_skill_or_raise(db, current_user, slug) + await get_management_readable_skill_or_raise(db, current_user, slug) return {"success": True, "data": await get_skill_tree(db, slug)} except ValueError as e: _raise_from_value_error(e) @@ -343,7 +353,7 @@ async def get_skill_file_route( db: AsyncSession = Depends(get_db), ): try: - await get_manageable_skill_or_raise(db, current_user, slug) + await get_management_readable_skill_or_raise(db, current_user, slug) return {"success": True, "data": await read_skill_file(db, slug, path)} except ValueError as e: _raise_from_value_error(e) @@ -417,7 +427,7 @@ async def update_skill_dependencies_route( skill_dependencies=payload.skill_dependencies, operator=current_user, ) - return {"success": True, "data": item.to_dict()} + return {"success": True, "data": _serialize_skill_for_user(item, current_user)} except ValueError as e: _raise_from_value_error(e) except Exception as e: diff --git a/backend/test/unit/repositories/test_agent_repository.py b/backend/test/unit/repositories/test_agent_repository.py index 8fe4ffff..a9357d47 100644 --- a/backend/test/unit/repositories/test_agent_repository.py +++ b/backend/test/unit/repositories/test_agent_repository.py @@ -5,7 +5,14 @@ from unittest.mock import AsyncMock import pytest -from yuxi.repositories.agent_repository import AgentRepository, DEFAULT_AGENT_DESCRIPTION, DEFAULT_SHARE_CONFIG +from yuxi.repositories.agent_repository import ( + AgentRepository, + DEFAULT_AGENT_DESCRIPTION, + DEFAULT_SHARE_CONFIG, + user_can_access_agent, + user_can_manage_agent, +) +from yuxi.storage.postgres.models_business import Agent, User class FakeDb: @@ -60,3 +67,41 @@ async def test_ensure_default_agent_backfills_missing_description(monkeypatch): assert agent.updated_by == "admin" db.commit.assert_awaited_once() db.refresh.assert_awaited_once_with(agent) + + +@pytest.mark.asyncio +async def test_create_agent_for_normal_user_forces_private_share(monkeypatch): + db = FakeDb() + repo = AgentRepository(db) + + async def fake_unique_slug(_slug, _name): + return "personal-bot" + + monkeypatch.setattr(repo, "_unique_slug", fake_unique_slug) + + creator = User(username="user", uid="user", password_hash="x", role="user", department_id=1) + agent = await repo.create( + name="Personal Bot", + backend_id="ChatbotAgent", + slug="personal-bot", + share_config={"access_level": "global", "department_ids": [], "user_uids": []}, + created_by="user", + creator=creator, + ) + + assert agent.share_config == {"access_level": "user", "department_ids": [], "user_uids": ["user"]} + assert db.added is agent + + +def test_shared_agent_is_accessible_but_not_manageable_for_normal_user(): + user = User(username="user", uid="user", password_hash="x", role="user", department_id=1) + agent = Agent( + slug="shared-bot", + name="Shared Bot", + backend_id="ChatbotAgent", + created_by="other", + share_config={"access_level": "user", "department_ids": [], "user_uids": ["user"]}, + ) + + assert user_can_access_agent(user, agent) is True + assert user_can_manage_agent(user, agent) is False diff --git a/backend/test/unit/routers/test_skill_router.py b/backend/test/unit/routers/test_skill_router.py index 83abf5fb..d39ef69e 100644 --- a/backend/test/unit/routers/test_skill_router.py +++ b/backend/test/unit/routers/test_skill_router.py @@ -36,26 +36,36 @@ def _build_app(*, role: str = "admin") -> FastAPI: return app -def _skill(slug: str = "demo", *, source_type: str = "upload", created_by: str = "admin") -> Skill: +def _skill( + slug: str = "demo", + *, + source_type: str = "upload", + created_by: str = "admin", + enabled: bool = True, + user_uids: list[str] | None = None, +) -> Skill: return Skill( slug=slug, name=slug, description="demo skill", source_type=source_type, dir_path=f"skills/{slug}", - share_config={"access_level": "user", "department_ids": [], "user_uids": [created_by]}, - enabled=True, + share_config={"access_level": "user", "department_ids": [], "user_uids": user_uids or [created_by]}, + enabled=enabled, created_by=created_by, updated_by=created_by, ) -def test_list_manageable_skills_route_returns_allowed_levels(monkeypatch): - async def fake_list_manageable_skills(_db, user): +def test_list_visible_skills_route_returns_allowed_levels_and_can_manage(monkeypatch): + async def fake_list_visible_skills_for_management(_db, user): assert user.uid == "admin" return [_skill()] - monkeypatch.setattr("server.routers.skill_router.list_manageable_skills", fake_list_manageable_skills) + monkeypatch.setattr( + "server.routers.skill_router.list_visible_skills_for_management", + fake_list_visible_skills_for_management, + ) client = TestClient(_build_app()) resp = client.get("/api/system/skills") @@ -64,9 +74,36 @@ def test_list_manageable_skills_route_returns_allowed_levels(monkeypatch): payload = resp.json() assert payload["success"] is True assert payload["data"][0]["slug"] == "demo" + assert payload["data"][0]["can_manage"] is True assert payload["allowed_access_levels"] == ["global", "department", "user"] +def test_list_visible_skills_route_allows_normal_user_readonly_items(monkeypatch): + async def fake_list_visible_skills_for_management(_db, user): + assert user.uid == "user" + return [ + _skill(slug="owned-disabled", created_by="user", enabled=False), + _skill(slug="shared", created_by="other", user_uids=["user"]), + ] + + monkeypatch.setattr( + "server.routers.skill_router.list_visible_skills_for_management", + fake_list_visible_skills_for_management, + ) + + client = TestClient(_build_app(role="user")) + resp = client.get("/api/system/skills") + + assert resp.status_code == 200, resp.text + payload = resp.json() + assert payload["success"] is True + assert [(item["slug"], item["can_manage"]) for item in payload["data"]] == [ + ("owned-disabled", True), + ("shared", False), + ] + assert payload["allowed_access_levels"] == ["user"] + + def test_list_accessible_skills_route(monkeypatch): async def fake_list_accessible_skills(_db, user): assert user.uid == "user" @@ -81,6 +118,7 @@ def test_list_accessible_skills_route(monkeypatch): payload = resp.json() assert payload["success"] is True assert payload["data"][0]["slug"] == "demo" + assert payload["data"][0]["can_manage"] is True def test_prepare_skill_upload_route(monkeypatch): @@ -183,6 +221,66 @@ def test_dependency_options_route_checks_manage_permission(monkeypatch): assert captured["options"] == {"slug": "demo", "operator_uid": "admin"} +def test_skill_tree_and_file_routes_check_management_read_permission(monkeypatch): + captured: dict[str, object] = {} + + async def fake_get_management_readable_skill_or_raise(_db, user, slug): + captured.setdefault("read", []).append({"slug": slug, "operator_uid": user.uid}) + return _skill(slug=slug, created_by="user", enabled=False) + + async def fake_get_skill_tree(_db, slug): + captured["tree_slug"] = slug + return [{"name": "SKILL.md", "path": "SKILL.md", "is_dir": False}] + + async def fake_read_skill_file(_db, slug, path): + captured["file"] = {"slug": slug, "path": path} + return {"path": path, "content": "---\nname: demo\n---\n"} + + monkeypatch.setattr( + "server.routers.skill_router.get_management_readable_skill_or_raise", + fake_get_management_readable_skill_or_raise, + ) + monkeypatch.setattr("server.routers.skill_router.get_skill_tree", fake_get_skill_tree) + monkeypatch.setattr("server.routers.skill_router.read_skill_file", fake_read_skill_file) + + client = TestClient(_build_app(role="user")) + tree_resp = client.get("/api/system/skills/demo/tree") + file_resp = client.get("/api/system/skills/demo/file?path=SKILL.md") + + assert tree_resp.status_code == 200, tree_resp.text + assert file_resp.status_code == 200, file_resp.text + assert captured["read"] == [ + {"slug": "demo", "operator_uid": "user"}, + {"slug": "demo", "operator_uid": "user"}, + ] + assert captured["tree_slug"] == "demo" + assert captured["file"] == {"slug": "demo", "path": "SKILL.md"} + + +def test_skill_export_route_still_checks_manage_permission(monkeypatch, tmp_path): + captured: dict[str, object] = {} + export_path = tmp_path / "demo.zip" + export_path.write_bytes(b"zip") + + async def fake_get_manageable_skill_or_raise(_db, user, slug): + captured["manageable"] = {"slug": slug, "operator_uid": user.uid} + return _skill(slug=slug) + + async def fake_export_skill_zip(_db, slug): + captured["export_slug"] = slug + return str(export_path), "demo.zip" + + monkeypatch.setattr("server.routers.skill_router.get_manageable_skill_or_raise", fake_get_manageable_skill_or_raise) + monkeypatch.setattr("server.routers.skill_router.export_skill_zip", fake_export_skill_zip) + + client = TestClient(_build_app()) + resp = client.get("/api/system/skills/demo/export") + + assert resp.status_code == 200, resp.text + assert captured["manageable"] == {"slug": "demo", "operator_uid": "admin"} + assert captured["export_slug"] == "demo" + + def test_update_skill_dependencies_route_passes_operator(monkeypatch): captured: dict[str, object] = {} diff --git a/backend/test/unit/routers/test_subagent.py b/backend/test/unit/routers/test_subagent.py index f611b8fc..3960f781 100644 --- a/backend/test/unit/routers/test_subagent.py +++ b/backend/test/unit/routers/test_subagent.py @@ -12,7 +12,8 @@ agent_router_module = importlib.import_module("server.routers.agent_router") def _user(role: str = "admin"): - return SimpleNamespace(uid="admin", role=role, department_id=None) + uid = "admin" if role in {"admin", "superadmin"} else "user" + return SimpleNamespace(uid=uid, role=role, department_id=1) def _agent(slug: str, *, backend_id: str = "ChatbotAgent", is_subagent: bool = False): @@ -76,7 +77,7 @@ class _RejectingCreateRepo(_ListRepo): raise ValueError("SubAgentBackend 与 is_subagent 必须保持一致") -def _build_app(monkeypatch, repo_cls) -> TestClient: +def _build_app(monkeypatch, repo_cls, *, role: str = "admin") -> TestClient: monkeypatch.setattr(agent_router_module, "agent_manager", _FakeAgentManager()) monkeypatch.setattr(agent_router_module, "AgentRepository", repo_cls) @@ -87,7 +88,7 @@ def _build_app(monkeypatch, repo_cls) -> TestClient: return None async def fake_user(): - return _user() + return _user(role) app.dependency_overrides[get_db] = fake_db app.dependency_overrides[get_required_user] = fake_user @@ -120,6 +121,30 @@ def test_agent_management_list_can_include_subagents(monkeypatch): assert _ListRepo.include_subagents_calls == [True] +def test_normal_user_can_create_agent(monkeypatch): + _CreateRepo.created_payload = None + client = _build_app(monkeypatch, _CreateRepo, role="user") + + response = client.post( + "/api/agent", + json={ + "name": "Personal Bot", + "slug": "personal-bot", + "backend_id": "ChatbotAgent", + "share_config": {"access_level": "global", "department_ids": [], "user_uids": []}, + }, + ) + + assert response.status_code == 200, response.text + assert _CreateRepo.created_payload["creator"].uid == "user" + assert _CreateRepo.created_payload["creator"].role == "user" + assert _CreateRepo.created_payload["share_config"] == { + "access_level": "global", + "department_ids": [], + "user_uids": [], + } + + def test_create_subagent_backend_agent_sets_subagent_flag(monkeypatch): _CreateRepo.created_payload = None client = _build_app(monkeypatch, _CreateRepo) diff --git a/backend/test/unit/services/test_skill_service.py b/backend/test/unit/services/test_skill_service.py index 65944be3..58f961c3 100644 --- a/backend/test/unit/services/test_skill_service.py +++ b/backend/test/unit/services/test_skill_service.py @@ -25,6 +25,212 @@ def _user(uid: str = "root", role: str = "admin") -> User: return User(username=uid, uid=uid, password_hash="x", role=role, department_id=1) +def test_allowed_skill_access_levels_by_role(): + assert svc.get_allowed_skill_access_levels(_user(role="user")) == ["user"] + assert svc.get_allowed_skill_access_levels(_user(role="admin")) == ["global", "department", "user"] + assert svc.get_allowed_skill_access_levels(_user(role="superadmin")) == ["global", "department", "user"] + + +@pytest.mark.asyncio +async def test_list_visible_skills_for_management_includes_owned_disabled_and_enabled_shared( + monkeypatch: pytest.MonkeyPatch, +): + items = [ + Skill(slug="owned-disabled", name="owned-disabled", description="", created_by="root", enabled=False), + Skill( + slug="shared-enabled", + name="shared-enabled", + description="", + created_by="other", + enabled=True, + share_config={"access_level": "user", "department_ids": [], "user_uids": ["root"]}, + ), + Skill( + slug="shared-disabled", + name="shared-disabled", + description="", + created_by="other", + enabled=False, + share_config={"access_level": "user", "department_ids": [], "user_uids": ["root"]}, + ), + Skill(slug="unrelated", name="unrelated", description="", created_by="other", enabled=True), + ] + + class FakeRepo: + def __init__(self, _db): + pass + + async def list_all(self): + return items + + monkeypatch.setattr(svc, "SkillRepository", FakeRepo) + + visible = await svc.list_visible_skills_for_management(None, _user("root", role="user")) + + assert [item.slug for item in visible] == ["owned-disabled", "shared-enabled"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "skill,operator", + [ + (Skill(slug="owned-disabled", name="owned-disabled", description="", created_by="root", enabled=False), _user("root", role="user")), + (Skill(slug="admin-disabled", name="admin-disabled", description="", created_by="other", enabled=False), _user("root", role="admin")), + ( + Skill( + slug="shared-enabled", + name="shared-enabled", + description="", + created_by="other", + enabled=True, + share_config={"access_level": "user", "department_ids": [], "user_uids": ["root"]}, + ), + _user("root", role="user"), + ), + ], +) +async def test_management_readable_skill_allows_manageable_disabled_and_enabled_shared( + monkeypatch: pytest.MonkeyPatch, + skill: Skill, + operator: User, +): + class FakeRepo: + def __init__(self, _db): + pass + + async def get_by_slug(self, slug: str): + assert slug == skill.slug + return skill + + monkeypatch.setattr(svc, "SkillRepository", FakeRepo) + + result = await svc.get_management_readable_skill_or_raise(None, operator, skill.slug) + + assert result is skill + + +@pytest.mark.asyncio +async def test_management_readable_skill_rejects_disabled_shared_readonly(monkeypatch: pytest.MonkeyPatch): + skill = Skill( + slug="shared-disabled", + name="shared-disabled", + description="", + created_by="other", + enabled=False, + share_config={"access_level": "user", "department_ids": [], "user_uids": ["root"]}, + ) + + class FakeRepo: + def __init__(self, _db): + pass + + async def get_by_slug(self, slug: str): + assert slug == skill.slug + return skill + + monkeypatch.setattr(svc, "SkillRepository", FakeRepo) + + with pytest.raises(ValueError, match="不存在或无权访问"): + await svc.get_management_readable_skill_or_raise(None, _user("root", role="user"), skill.slug) + + +@pytest.mark.asyncio +async def test_runtime_access_still_excludes_disabled_shared_skill(monkeypatch: pytest.MonkeyPatch): + skill = Skill( + slug="shared-disabled", + name="shared-disabled", + description="", + created_by="other", + enabled=False, + share_config={"access_level": "user", "department_ids": [], "user_uids": ["root"]}, + ) + + class FakeRepo: + def __init__(self, _db): + pass + + async def list_enabled(self): + return [] + + monkeypatch.setattr(svc, "SkillRepository", FakeRepo) + + assert svc.user_can_access_skill(_user("root", role="user"), skill) is False + assert await svc.list_accessible_skills(None, _user("root", role="user")) == [] + + +@pytest.mark.asyncio +async def test_normal_user_skill_upload_draft_defaults_to_user_share( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) + + class FakeRepo: + def __init__(self, _db): + pass + + async def exists_slug(self, _slug: str) -> bool: + return False + + monkeypatch.setattr(svc, "SkillRepository", FakeRepo) + + draft = await svc.prepare_skill_upload( + None, + filename="SKILL.md", + file_bytes=b"---\nname: demo\ndescription: demo skill\n---\n# Demo\n", + operator=_user("normal-user", role="user"), + ) + + assert draft["default_share_config"] == { + "access_level": "user", + "department_ids": [], + "user_uids": ["normal-user"], + } + assert draft["allowed_access_levels"] == ["user"] + + +@pytest.mark.parametrize( + "share_config", + [ + {"access_level": "global", "department_ids": [], "user_uids": []}, + {"access_level": "department", "department_ids": [1], "user_uids": []}, + ], +) +@pytest.mark.asyncio +async def test_normal_user_confirm_skill_draft_rejects_wider_share_scope( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + share_config: dict, +): + monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path)) + + class FakeRepo: + def __init__(self, _db): + pass + + async def exists_slug(self, _slug: str) -> bool: + return False + + async def create(self, **_kwargs) -> Skill: + raise AssertionError("普通用户的越权共享范围应在创建前被拒绝") + + monkeypatch.setattr(svc, "SkillRepository", FakeRepo) + operator = _user("normal-user", role="user") + draft = await svc.prepare_skill_upload( + None, + filename="SKILL.md", + file_bytes=b"---\nname: demo\ndescription: demo skill\n---\n# Demo\n", + operator=operator, + ) + + with pytest.raises(ValueError, match="无权使用该 Skill 共享范围"): + await svc.confirm_skill_install_draft( + None, + draft_id=draft["draft_id"], + share_config=share_config, + operator=operator, + ) + + def test_parse_skill_markdown_ok(): content = "---\nname: demo-skill\ndescription: demo description\n---\n# Demo\n" name, desc, meta = svc._parse_skill_markdown(content) diff --git a/web/src/apis/skill_api.js b/web/src/apis/skill_api.js index 618889d7..4a30d795 100644 --- a/web/src/apis/skill_api.js +++ b/web/src/apis/skill_api.js @@ -1,12 +1,4 @@ -import { - apiGet, - apiPost, - apiDelete, - apiAdminGet, - apiAdminPost, - apiAdminPut, - apiAdminDelete -} from './base' +import { apiGet, apiPost, apiPut, apiDelete, apiAdminGet, apiAdminPost } from './base' const BASE_URL = '/api/system/skills' const USER_BASE_URL = '/api/skills' @@ -49,7 +41,7 @@ export const discardSkillInstallDraft = async (draftId) => { export const getSkillDependencyOptions = async (slug) => { const query = slug ? `?slug=${encodeURIComponent(slug)}` : '' - return apiAdminGet(`${BASE_URL}/dependency-options${query}`) + return apiGet(`${BASE_URL}/dependency-options${query}`) } export const listBuiltinSkills = async () => { @@ -61,53 +53,49 @@ export const syncBuiltinSkills = async () => { } export const getSkillTree = async (slug) => { - return apiAdminGet(`${BASE_URL}/${encodeURIComponent(slug)}/tree`) + return apiGet(`${BASE_URL}/${encodeURIComponent(slug)}/tree`) } export const getSkillFile = async (slug, path) => { - return apiAdminGet( - `${BASE_URL}/${encodeURIComponent(slug)}/file?path=${encodeURIComponent(path)}` - ) + return apiGet(`${BASE_URL}/${encodeURIComponent(slug)}/file?path=${encodeURIComponent(path)}`) } export const createSkillFile = async (slug, payload) => { - return apiAdminPost(`${BASE_URL}/${encodeURIComponent(slug)}/file`, payload) + return apiPost(`${BASE_URL}/${encodeURIComponent(slug)}/file`, payload) } export const updateSkillFile = async (slug, payload) => { - return apiAdminPut(`${BASE_URL}/${encodeURIComponent(slug)}/file`, payload) + return apiPut(`${BASE_URL}/${encodeURIComponent(slug)}/file`, payload) } export const updateSkillDependencies = async (slug, payload) => { - return apiAdminPut(`${BASE_URL}/${encodeURIComponent(slug)}/dependencies`, payload) + return apiPut(`${BASE_URL}/${encodeURIComponent(slug)}/dependencies`, payload) } export const updateSkillShareConfig = async (slug, shareConfig) => { - return apiAdminPut(`${BASE_URL}/${encodeURIComponent(slug)}/share-config`, { + return apiPut(`${BASE_URL}/${encodeURIComponent(slug)}/share-config`, { share_config: shareConfig }) } export const updateSkillEnabled = async (slug, enabled) => { - return apiAdminPut(`${BASE_URL}/${encodeURIComponent(slug)}/enabled`, { enabled }) + return apiPut(`${BASE_URL}/${encodeURIComponent(slug)}/enabled`, { enabled }) } export const deleteSkillFile = async (slug, path) => { - return apiAdminDelete( - `${BASE_URL}/${encodeURIComponent(slug)}/file?path=${encodeURIComponent(path)}` - ) + return apiDelete(`${BASE_URL}/${encodeURIComponent(slug)}/file?path=${encodeURIComponent(path)}`) } export const exportSkill = async (slug) => { - return apiAdminGet(`${BASE_URL}/${encodeURIComponent(slug)}/export`, {}, 'blob') + return apiGet(`${BASE_URL}/${encodeURIComponent(slug)}/export`, {}, true, 'blob') } export const deleteSkill = async (slug) => { - return apiAdminDelete(`${BASE_URL}/${encodeURIComponent(slug)}`) + return apiDelete(`${BASE_URL}/${encodeURIComponent(slug)}`) } export const deleteSkillsBatch = async (slugs) => { - return apiAdminPost(`${BASE_URL}/delete-batch`, { slugs }) + return apiPost(`${BASE_URL}/delete-batch`, { slugs }) } export const skillApi = { diff --git a/web/src/components/extensions/McpCardList.vue b/web/src/components/extensions/McpCardList.vue index 06b8f085..c3d10da1 100644 --- a/web/src/components/extensions/McpCardList.vue +++ b/web/src/components/extensions/McpCardList.vue @@ -30,7 +30,7 @@ + +
+
+
+
+ +
+
+
+ {{ formatExtensionCardTitle(previewSkill.name) }} +
+
+ {{ sourceTypeLabel(previewSkill.sourceType || previewSkill.source_type) }} Skill + + 已禁用 + +
+
+
+
+ +
+
+ +
+
+ +
+ + +
+ + +
+
+ { } const installedSkillCards = computed(() => - (skills.value || []).map((skill) => { - const sourceType = skill.source_type || 'upload' - return { - ...skill, - sourceType, - sourceLabel: sourceTypeLabel(sourceType), - status: - skill.enabled === false - ? { label: '已禁用', level: 'default' } - : { label: '已启用', level: 'success' } - } - }) + (skills.value || []).map((skill) => ({ + ...skill, + sourceType: skill.source_type || 'upload' + })) ) const filteredInstalledSkills = computed(() => installedSkillCards.value.filter(matchesSearch)) const filteredDeletableSkills = computed(() => - filteredInstalledSkills.value.filter((skill) => skill.sourceType !== 'builtin') + filteredInstalledSkills.value.filter( + (skill) => canManageSkill(skill) && skill.sourceType !== 'builtin' + ) +) +const canDeletePreviewSkill = computed( + () => !!previewSkill.value && canManageSkill(previewSkill.value) && previewSkill.value.sourceType !== 'builtin' ) // 仓库拉取的技能列表过滤 @@ -590,26 +683,54 @@ const sourceTypeLabel = (sourceType) => { return '上传' } -const skillTags = (skill) => { - if (skill.sourceType === 'builtin') return [{ name: skill.sourceLabel || '内置' }] - return [{ name: skill.sourceLabel || '外部', color: 'blue' }] -} +const canManageSkill = (skill) => skill?.can_manage !== false +const isSkillToggling = (slug) => togglingSkillSlugs.value.includes(slug) const navigateToDetail = (skill) => { router.push({ path: `/extensions/skill/${encodeURIComponent(skill.slug)}` }) } +const closeSkillPreview = () => { + skillPreviewVisible.value = false +} + +const openSkillPreview = async (skill) => { + if (!skill?.slug) return + const requestSeq = ++previewRequestSeq + previewSkill.value = skill + skillPreviewMarkdown.value = '' + skillPreviewError.value = '' + skillPreviewLoading.value = true + skillPreviewVisible.value = true + try { + const result = await skillApi.getSkillFile(skill.slug, 'SKILL.md') + if (requestSeq !== previewRequestSeq || previewSkill.value?.slug !== skill.slug) return + skillPreviewMarkdown.value = result?.data?.content || '' + } catch (error) { + if (requestSeq !== previewRequestSeq || previewSkill.value?.slug !== skill.slug) return + skillPreviewError.value = error?.response?.data?.detail || error.message || '读取 SKILL.md 失败' + } finally { + if (requestSeq === previewRequestSeq) skillPreviewLoading.value = false + } +} + +const goToPreviewSkillManagement = () => { + if (!previewSkill.value) return + navigateToDetail(previewSkill.value) + closeSkillPreview() +} + const handleCardClick = (skill) => { if (isBatchDeleteMode.value) { handleToggleCardSelect(skill.slug) } else { - navigateToDetail(skill) + openSkillPreview(skill) } } const handleToggleCardSelect = (slug) => { const target = installedSkillCards.value.find((skill) => skill.slug === slug) - if (target?.sourceType === 'builtin') return + if (!canManageSkill(target) || target?.sourceType === 'builtin') return const idx = selectedCardSlugs.value.indexOf(slug) if (idx > -1) { selectedCardSlugs.value.splice(idx, 1) @@ -618,9 +739,67 @@ const handleToggleCardSelect = (slug) => { } } +const handleToggleSkillEnabled = async (skill) => { + if (!skill || !canManageSkill(skill) || isSkillToggling(skill.slug)) return + const enabled = skill.enabled === false + togglingSkillSlugs.value.push(skill.slug) + try { + const result = await skillApi.updateSkillEnabled(skill.slug, enabled) + const updatedSkill = result?.data + const index = skills.value.findIndex((item) => item.slug === skill.slug) + if (updatedSkill && index > -1) { + skills.value[index] = updatedSkill + } else { + await fetchSkills() + } + if (previewSkill.value?.slug === skill.slug) { + previewSkill.value = updatedSkill + ? { ...updatedSkill, sourceType: updatedSkill.source_type || 'upload' } + : { ...previewSkill.value, enabled } + } + message.success(`Skill 已${enabled ? '启用' : '禁用'}`) + } catch (error) { + message.error(error?.response?.data?.detail || error.message || '更新 Skill 启用状态失败') + } finally { + togglingSkillSlugs.value = togglingSkillSlugs.value.filter((slug) => slug !== skill.slug) + } +} + +const handlePreviewToggle = () => { + if (!previewSkill.value) return + handleToggleSkillEnabled(previewSkill.value) +} + +const confirmDeletePreviewSkill = () => { + const target = previewSkill.value + if (!target || !canDeletePreviewSkill.value || deletingPreviewSkill.value) return + + Modal.confirm({ + title: `卸载 ${target.name || target.slug}`, + content: '卸载后会删除该 Skill 的数据库记录和本地文件,操作不可恢复。', + okText: '卸载', + okType: 'danger', + cancelText: '取消', + async onOk() { + deletingPreviewSkill.value = true + try { + await skillApi.deleteSkill(target.slug) + message.success('Skill 已卸载') + closeSkillPreview() + previewSkill.value = null + await fetchSkills() + } catch (error) { + message.error(error?.response?.data?.detail || error.message || '卸载 Skill 失败') + } finally { + deletingPreviewSkill.value = false + } + } + }) +} + const handleBatchSelectAll = () => { selectedCardSlugs.value = filteredInstalledSkills.value - .filter((skill) => skill.sourceType !== 'builtin') + .filter((skill) => canManageSkill(skill) && skill.sourceType !== 'builtin') .map((skill) => skill.slug) } @@ -632,7 +811,7 @@ const handleBatchSelectInvert = () => { const currentSet = new Set(selectedCardSlugs.value) const nextSelected = [] filteredInstalledSkills.value.forEach((skill) => { - if (skill.sourceType !== 'builtin' && !currentSet.has(skill.slug)) { + if (canManageSkill(skill) && skill.sourceType !== 'builtin' && !currentSet.has(skill.slug)) { nextSelected.push(skill.slug) } }) @@ -647,7 +826,7 @@ const exitBatchDeleteMode = () => { const handleBatchDelete = () => { const deletableSlugs = selectedCardSlugs.value.filter((slug) => { const target = installedSkillCards.value.find((skill) => skill.slug === slug) - return target?.sourceType !== 'builtin' + return canManageSkill(target) && target?.sourceType !== 'builtin' }) if (deletableSlugs.length === 0) return @@ -1034,11 +1213,16 @@ defineExpose({ } } - :deep(.info-card-status) { + :deep(.info-card-status), + :deep(.info-card-mini-action) { opacity: 0; pointer-events: none; transition: opacity 0.2s ease; } + + :deep(.info-card-mini .info-card-info) { + padding-right: 28px; + } } &.selected { @@ -1056,6 +1240,179 @@ defineExpose({ } } +.skill-enabled-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 1px solid var(--gray-150); + border-radius: 8px; + background: var(--gray-0); + color: var(--main-color); + font-size: 18px; + font-weight: 600; + line-height: 1; + cursor: pointer; + transition: + border-color 0.18s ease, + background-color 0.18s ease, + color 0.18s ease; + + &:hover, + &:focus { + outline: none; + border-color: var(--main-200); + background: var(--main-50); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.45; + } + + &.enabled { + color: var(--color-success-700); + + .action-icon-minus { + display: none; + } + + &:hover, + &:focus { + border-color: var(--color-error-200, #ffccc7); + background: var(--color-error-50, #fff2f0); + color: var(--color-error-700, #cf1322); + + .action-icon-check { + display: none; + } + + .action-icon-minus { + display: block; + } + } + } +} + +.action-icon { + flex-shrink: 0; +} + +.skill-preview-panel { + display: flex; + flex-direction: column; + min-height: 0; +} + +.skill-preview-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; +} + +.skill-preview-title-area { + display: flex; + align-items: center; + min-width: 0; + gap: 10px; +} + +.skill-preview-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 32px; + height: 32px; + border-radius: 9px; + background: var(--main-50); + color: var(--main-color); +} + +.skill-preview-title-text { + min-width: 0; +} + +.skill-preview-title { + overflow: hidden; + color: var(--gray-900); + font-size: 16px; + font-weight: 700; + line-height: 22px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.skill-preview-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + margin-top: 2px; + color: var(--gray-500); + font-size: 12px; + line-height: 18px; +} + +.skill-preview-disabled-tag { + display: inline-flex; + align-items: center; + height: 18px; + padding: 0 6px; + border-radius: 999px; + background: var(--gray-100); + color: var(--gray-600); + font-size: 11px; + font-weight: 600; +} + +.skill-preview-actions { + display: inline-flex; + align-items: center; + flex-shrink: 0; + gap: 8px; + padding-top: 2px; +} + +.skill-preview-body { + min-height: 260px; + max-height: min(56vh, 520px); + padding: 14px 16px; + overflow-y: auto; + border: 1px solid var(--gray-150); + border-radius: 12px; + background: var(--gray-25); + + :deep(.yk-markdown-preview) { + background: transparent; + } +} + +.skill-preview-loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 220px; +} + +.skill-preview-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 12px; +} + +.skill-preview-footer-left, +.skill-preview-footer-right { + display: inline-flex; + align-items: center; + gap: 8px; +} + .skill-draft-confirm-panel { display: flex; flex-direction: column; diff --git a/web/src/components/extensions/SkillDetailView.vue b/web/src/components/extensions/SkillDetailView.vue index f36d39ac..02e3b0a0 100644 --- a/web/src/components/extensions/SkillDetailView.vue +++ b/web/src/components/extensions/SkillDetailView.vue @@ -20,7 +20,7 @@
diff --git a/web/src/views/ExtensionsView.vue b/web/src/views/ExtensionsView.vue index 38c793ff..304764f4 100644 --- a/web/src/views/ExtensionsView.vue +++ b/web/src/views/ExtensionsView.vue @@ -11,16 +11,16 @@ />
-
+
-
+
-
+
-
+
@@ -31,26 +31,37 @@