feat: 优化 Skill 安装逻辑和展示逻辑

This commit is contained in:
Wenjie Zhang 2026-06-03 14:13:59 +08:00
parent eb55780d0d
commit 56cd1b500a
20 changed files with 1164 additions and 243 deletions

View File

@ -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):

View File

@ -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} 不存在")

View File

@ -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:

View File

@ -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

View File

@ -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] = {}

View File

@ -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)

View File

@ -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)

View File

@ -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 = {

View File

@ -30,7 +30,7 @@
<InfoCard
v-for="server in filteredEnabledServers"
:key="server.name"
:title="server.name"
:title="formatExtensionCardTitle(server.name)"
:subtitle="server.transport"
:description="server.description || '暂无描述'"
:tags="mcpTags(server)"
@ -48,7 +48,7 @@
<InfoCard
v-for="server in filteredDisabledServers"
:key="server.name"
:title="server.name"
:title="formatExtensionCardTitle(server.name)"
:subtitle="server.transport"
:description="server.description || '暂无描述'"
:tags="mcpTags(server)"
@ -81,6 +81,7 @@ import ExtensionCardGrid from './ExtensionCardGrid.vue'
import InfoCard from '@/components/shared/InfoCard.vue'
import PageShoulder from '@/components/shared/PageShoulder.vue'
import McpFormModal from './McpFormModal.vue'
import { formatExtensionCardTitle } from '@/utils/extensionDisplayName'
const router = useRouter()

View File

@ -76,7 +76,7 @@
</div>
<template v-else>
<ExtensionCardGrid>
<ExtensionCardGrid :min-width="360">
<div
v-for="skill in filteredInstalledSkills"
:key="skill.slug"
@ -87,25 +87,111 @@
}"
>
<a-checkbox
v-if="isBatchDeleteMode && skill.sourceType !== 'builtin'"
v-if="isBatchDeleteMode && canManageSkill(skill) && skill.sourceType !== 'builtin'"
:checked="selectedCardSlugs.includes(skill.slug)"
@change="handleToggleCardSelect(skill.slug)"
class="card-select-checkbox"
/>
<InfoCard
:title="skill.name"
variant="mini"
:title="formatExtensionCardTitle(skill.name)"
:description="skill.description || '暂无描述'"
:default-icon="BookMarkedIcon"
:tags="skillTags(skill)"
:status="skill.status"
@click="handleCardClick(skill)"
:class="{ 'card-clickable-select': isBatchDeleteMode }"
>
<template #action>
<button
type="button"
class="skill-enabled-action"
:class="{ enabled: skill.enabled !== false }"
:disabled="!canManageSkill(skill) || isSkillToggling(skill.slug)"
:aria-label="skill.enabled === false ? '启用 Skill' : '禁用 Skill'"
@click.stop="handleToggleSkillEnabled(skill)"
>
<Plus v-if="skill.enabled === false" :size="15" class="action-icon" />
<template v-else>
<Check :size="15" class="action-icon action-icon-check" />
<Minus :size="15" class="action-icon action-icon-minus" />
</template>
</button>
</template>
</InfoCard>
</div>
</ExtensionCardGrid>
</template>
<a-modal
v-model:open="skillPreviewVisible"
class="skill-preview-modal"
:footer="null"
width="680px"
:closable="false"
:destroy-on-close="true"
@cancel="closeSkillPreview"
>
<div v-if="previewSkill" class="skill-preview-panel">
<div class="skill-preview-header">
<div class="skill-preview-title-area">
<div class="skill-preview-icon">
<BookMarked :size="18" />
</div>
<div class="skill-preview-title-text">
<div class="skill-preview-title">
{{ formatExtensionCardTitle(previewSkill.name) }}
</div>
<div class="skill-preview-meta">
<span>{{ sourceTypeLabel(previewSkill.sourceType || previewSkill.source_type) }} Skill</span>
<span v-if="previewSkill.enabled === false" class="skill-preview-disabled-tag">
已禁用
</span>
</div>
</div>
</div>
<div class="skill-preview-actions">
<a-switch
:checked="previewSkill.enabled !== false"
:disabled="!canManageSkill(previewSkill) || isSkillToggling(previewSkill.slug)"
:loading="isSkillToggling(previewSkill.slug)"
size="small"
@change="handlePreviewToggle"
/>
</div>
</div>
<div class="skill-preview-body">
<div v-if="skillPreviewLoading" class="skill-preview-loading">
<a-spin />
</div>
<MarkdownPreview
v-else-if="skillPreviewMarkdown"
:content="skillPreviewMarkdown"
:compact="true"
/>
<a-empty v-else :description="skillPreviewError || '未读取到 SKILL.md'" />
</div>
<div class="skill-preview-footer">
<div class="skill-preview-footer-left">
<a-button
v-if="canDeletePreviewSkill"
danger
:loading="deletingPreviewSkill"
@click="confirmDeletePreviewSkill"
>
卸载
</a-button>
</div>
<div class="skill-preview-footer-right">
<a-button @click="closeSkillPreview">关闭</a-button>
<a-button type="primary" class="lucide-icon-btn" @click="goToPreviewSkillManagement">
<span>去管理</span>
</a-button>
</div>
</div>
</div>
</a-modal>
<a-modal
v-model:open="remoteInstallModalVisible"
title="远程安装 Skill"
@ -439,12 +525,14 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import { RefreshCw, Upload, Computer, BookMarked, History, Trash2 } from 'lucide-vue-next'
import { RefreshCw, Upload, Computer, BookMarked, History, Trash2, Check, Plus, Minus } from 'lucide-vue-next'
import { skillApi } from '@/apis/skill_api'
import ExtensionCardGrid from './ExtensionCardGrid.vue'
import InfoCard from '@/components/shared/InfoCard.vue'
import PageShoulder from '@/components/shared/PageShoulder.vue'
import ShareConfigForm from '@/components/ShareConfigForm.vue'
import MarkdownPreview from '@/components/common/MarkdownPreview.vue'
import { formatExtensionCardTitle } from '@/utils/extensionDisplayName'
const BookMarkedIcon = BookMarked
@ -458,8 +546,16 @@ const searchQuery = ref('')
const isBatchDeleteMode = ref(false)
const selectedCardSlugs = ref([])
const togglingSkillSlugs = ref([])
const skills = ref([])
const skillPreviewVisible = ref(false)
const previewSkill = ref(null)
const skillPreviewMarkdown = ref('')
const skillPreviewLoading = ref(false)
const skillPreviewError = ref('')
const deletingPreviewSkill = ref(false)
let previewRequestSeq = 0
const remoteInstallModalVisible = ref(false)
const activeTab = ref('repo') // 'repo' 'search'
@ -492,23 +588,20 @@ const matchesSearch = (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;

View File

@ -20,7 +20,7 @@
<div class="detail-actions">
<a-space :size="8">
<button
v-if="isInstalledSkill"
v-if="isInstalledSkill && canManageCurrentSkill"
type="button"
@click="handleExport"
class="lucide-icon-btn extension-panel-action extension-panel-action-secondary"
@ -29,7 +29,7 @@
<span>导出</span>
</button>
<button
v-if="isInstalledSkill && !isBuiltinInstalledSkill"
v-if="isInstalledSkill && canManageCurrentSkill && !isBuiltinInstalledSkill"
type="button"
@click="confirmDeleteSkill"
class="lucide-icon-btn extension-panel-action extension-panel-action-danger"
@ -43,6 +43,9 @@
<div class="detail-content-wrapper">
<div v-if="currentSkill" class="detail-content-inner">
<div v-if="isReadOnlySkill" class="readonly-scope-hint readonly-detail-hint">
你可以查看并使用此 Skill但没有管理权限
</div>
<a-tabs v-if="isInstalledSkill" v-model:activeKey="activeTab" class="minimal-tabs">
<a-tab-pane key="editor">
<template #tab>
@ -53,10 +56,10 @@
<div class="tree-header">
<span class="label">项目结构</span>
<div class="tree-actions">
<a-tooltip v-if="!isBuiltinInstalledSkill" title="新建文件"
<a-tooltip v-if="canEditSkillFiles" title="新建文件"
><button @click="openCreateModal(false)"><FilePlus :size="14" /></button
></a-tooltip>
<a-tooltip v-if="!isBuiltinInstalledSkill" title="新建目录"
<a-tooltip v-if="canEditSkillFiles" title="新建目录"
><button @click="openCreateModal(true)"><FolderPlus :size="14" /></button
></a-tooltip>
<a-tooltip title="刷新"
@ -86,7 +89,7 @@
:file-path="selectedPath"
:show-download="false"
:show-fullscreen="true"
:editable="!isBuiltinInstalledSkill"
:editable="canEditSkillFiles"
:edit-all-text="true"
:saving="savingFile"
:full-height="true"
@ -111,6 +114,7 @@
<p>控制此 Skill 是否可用以及哪些用户可以选择和运行它</p>
</div>
<a-button
v-if="canManageCurrentSkill"
type="primary"
:loading="savingShareConfig"
@click="saveShareConfig"
@ -132,7 +136,7 @@
<span class="status-pill" :class="enabledForm ? 'enabled' : 'disabled'">
{{ enabledForm ? '已启用' : '已禁用' }}
</span>
<a-switch v-model:checked="enabledForm" />
<a-switch v-model:checked="enabledForm" :disabled="!canManageCurrentSkill" />
</div>
</section>
@ -146,6 +150,9 @@
<div v-if="isBuiltinInstalledSkill" class="readonly-scope-hint">
内置 Skill 固定为全局生效范围可通过启用状态控制是否参与运行时
</div>
<div v-else-if="isReadOnlySkill" class="readonly-scope-hint">
当前 Skill 对你只读不能修改生效范围
</div>
<ShareConfigForm
v-else
ref="shareConfigFormRef"
@ -169,7 +176,7 @@
<p>配置此 Skill 所需的工具MCP 及其他 Skill 依赖</p>
</div>
<a-button
v-if="!isBuiltinInstalledSkill"
v-if="canEditSkillDependencies"
type="primary"
:loading="savingDependencies"
@click="saveDependencies"
@ -184,7 +191,7 @@
v-for="group in dependencyGroups"
:key="group.key"
class="dependency-card"
:class="{ readonly: isBuiltinInstalledSkill }"
:class="{ readonly: !canEditSkillDependencies }"
>
<div class="dependency-card-header">
<div class="dependency-title-block">
@ -197,7 +204,7 @@
<p>{{ group.description }}</p>
</div>
<a-dropdown
v-if="!isBuiltinInstalledSkill"
v-if="canEditSkillDependencies"
:trigger="['click']"
placement="bottomRight"
overlay-class-name="dependency-selection-popover"
@ -275,9 +282,9 @@
</div>
</template>
</a-dropdown>
<a-button v-else size="small" disabled class="dependency-action-btn"
>系统维护</a-button
>
<a-button v-else size="small" disabled class="dependency-action-btn">
{{ isBuiltinInstalledSkill ? '系统维护' : '只读' }}
</a-button>
</div>
<div v-if="getDependencyValues(group).length" class="dependency-chip-list">
@ -289,7 +296,7 @@
>
<span>{{ getDependencyOptionLabel(group, value) }}</span>
<button
v-if="!isBuiltinInstalledSkill"
v-if="canEditSkillDependencies"
type="button"
class="dependency-chip-remove"
:aria-label="`移除 ${getDependencyOptionLabel(group, value)}`"
@ -393,6 +400,12 @@ const isInstalledSkill = computed(() => !!currentSkill.value?.dir_path)
const isBuiltinInstalledSkill = computed(() => {
return !!(isInstalledSkill.value && currentSkill.value?.source_type === 'builtin')
})
const canManageCurrentSkill = computed(() => currentSkill.value?.can_manage !== false)
const isReadOnlySkill = computed(() => isInstalledSkill.value && !canManageCurrentSkill.value)
const canEditSkillFiles = computed(() => canManageCurrentSkill.value && !isBuiltinInstalledSkill.value)
const canEditSkillDependencies = computed(
() => canManageCurrentSkill.value && !isBuiltinInstalledSkill.value
)
const sourceTypeLabel = (sourceType) => {
if (sourceType === 'builtin') return '内置'
@ -484,7 +497,7 @@ const getFilteredDependencyOptions = (group) => {
const isDependencySelected = (group, value) => getDependencyValues(group).includes(value)
const toggleDependency = (group, value, checked) => {
if (isBuiltinInstalledSkill.value) return
if (!canEditSkillDependencies.value) return
const values = getDependencyValues(group)
if (checked) {
if (!values.includes(value)) dependencyForm[group.formKey] = [...values, value]
@ -627,12 +640,7 @@ const handleTreeSelect = async (keys, info) => {
}
const saveCurrentFile = async (content = fileContent.value) => {
if (
!currentSkill.value ||
!selectedPath.value ||
selectedIsDir.value ||
isBuiltinInstalledSkill.value
)
if (!currentSkill.value || !selectedPath.value || selectedIsDir.value || !canEditSkillFiles.value)
return
savingFile.value = true
try {
@ -652,7 +660,7 @@ const saveCurrentFile = async (content = fileContent.value) => {
const confirmDeleteSkill = () => {
const target = currentSkill.value
if (!target) return
if (!target || !canManageCurrentSkill.value || isBuiltinInstalledSkill.value) return
const actionText = '删除'
Modal.confirm({
title: `确认${actionText}技能「${target.slug}」?`,
@ -673,7 +681,7 @@ const confirmDeleteSkill = () => {
}
const handleExport = async () => {
if (!currentSkill.value || !isInstalledSkill.value) return
if (!currentSkill.value || !isInstalledSkill.value || !canManageCurrentSkill.value) return
try {
const response = await skillApi.exportSkill(currentSkill.value.slug)
const blob = await response.blob()
@ -689,7 +697,7 @@ const handleExport = async () => {
}
const openCreateModal = (isDir) => {
if (!currentSkill.value) return
if (!currentSkill.value || !canEditSkillFiles.value) return
createForm.path = ''
createForm.content = ''
createForm.isDir = isDir
@ -697,7 +705,7 @@ const openCreateModal = (isDir) => {
}
const handleCreateNode = async () => {
if (!currentSkill.value || !createForm.path.trim() || isBuiltinInstalledSkill.value) return
if (!currentSkill.value || !createForm.path.trim() || !canEditSkillFiles.value) return
creatingNode.value = true
try {
await skillApi.createSkillFile(currentSkill.value.slug, {
@ -716,7 +724,7 @@ const handleCreateNode = async () => {
}
const saveShareConfig = async () => {
if (!currentSkill.value || !isInstalledSkill.value) return
if (!currentSkill.value || !isInstalledSkill.value || !canManageCurrentSkill.value) return
if (!isBuiltinInstalledSkill.value) {
const validation = shareConfigFormRef.value?.validate?.()
if (validation && !validation.valid) {
@ -744,7 +752,7 @@ const saveShareConfig = async () => {
}
const saveDependencies = async () => {
if (!currentSkill.value || !isInstalledSkill.value || isBuiltinInstalledSkill.value) return
if (!currentSkill.value || !isInstalledSkill.value || !canEditSkillDependencies.value) return
savingDependencies.value = true
try {
const result = await skillApi.updateSkillDependencies(currentSkill.value.slug, {

View File

@ -31,7 +31,7 @@
<InfoCard
v-for="tool in filteredTools"
:key="getToolSlug(tool)"
:title="tool.name"
:title="formatExtensionCardTitle(tool.name)"
:subtitle="getToolSlug(tool)"
:description="tool.description || '无描述'"
:default-icon="getToolIcon(getToolSlug(tool)) || WrenchIcon"
@ -119,6 +119,7 @@ import { getToolIcon } from '@/components/ToolCallingResult/toolRegistry'
import ExtensionCardGrid from './ExtensionCardGrid.vue'
import InfoCard from '@/components/shared/InfoCard.vue'
import PageShoulder from '@/components/shared/PageShoulder.vue'
import { formatExtensionCardTitle } from '@/utils/extensionDisplayName'
const WrenchIcon = Wrench

View File

@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
Plus,
@ -52,6 +52,7 @@ const agentModalActiveTab = ref('basic')
const agentIconUploading = ref(false)
const agentShareConfigFormRef = ref(null)
const runtimeConfigFormRef = ref(null)
const agentNameInputRef = ref(null)
const agentShareConfig = ref({ access_level: 'user', department_ids: [], user_uids: [] })
const agentForm = reactive({
slug: '',
@ -142,6 +143,7 @@ const canManageAgent = (agent) => !!agent?.can_manage
const agentModalTitle = computed(() => (editingAgentId.value ? '编辑智能体' : '新增智能体'))
const getAgentIconSrc = (agent) => agent.icon || (agent.id ? generatePixelAvatar(agent.id) : '')
const getAgentTags = (agent) => [
...(!agent?.can_manage ? [{ name: '只读', color: 'default' }] : []),
...(agent?.is_subagent ? [{ name: '子智能体', color: 'purple' }] : []),
...(agent?.backend_id ? [{ name: agent.backend_id, color: 'blue' }] : [])
]
@ -197,6 +199,15 @@ const resetAgentForm = () => {
agentShareConfig.value = getInitialShareConfig()
}
const focusAgentNameInput = async () => {
await nextTick()
agentNameInputRef.value?.focus?.()
}
const handleAgentModalAfterOpenChange = (open) => {
if (open && !editingAgentId.value) focusAgentNameInput()
}
const openCreateAgentModal = () => {
editingAgentId.value = null
agentModalActiveTab.value = 'basic'
@ -444,10 +455,11 @@ defineExpose({
<a-modal
v-model:open="showAgentModal"
class="agent-edit-modal"
:width="920"
:width="editingAgentId ? 840 : 760"
:footer="null"
:closable="false"
@cancel="closeAgentModal"
@after-open-change="handleAgentModalAfterOpenChange"
>
<template #title>
<div class="agent-modal-titlebar">
@ -458,7 +470,13 @@ defineExpose({
</div>
</div>
</template>
<div class="agent-modal-content" :class="{ 'without-sidebar': !showAgentModalSidebar }">
<div
class="agent-modal-content"
:class="{
'without-sidebar': !showAgentModalSidebar,
'create-mode': !editingAgentId
}"
>
<aside v-if="showAgentModalSidebar" class="agent-modal-sidebar" aria-label="智能体配置分组">
<button
v-for="item in agentModalMenuItems"
@ -490,21 +508,28 @@ defineExpose({
:disabled="agentIconUploading"
accept="image/*"
>
<div class="agent-icon-upload" :class="{ uploading: agentIconUploading }">
<div
class="agent-icon-upload"
:class="{
uploading: agentIconUploading,
'is-empty': !agentPreviewIcon
}"
>
<img
v-if="agentPreviewIcon"
:src="agentPreviewIcon"
:alt="`${agentForm.name || '智能体'}图标`"
/>
<div class="agent-icon-mask">
<RefreshCw v-if="agentIconUploading" :size="14" class="spinning" />
<Upload v-else :size="14" />
<span>{{ agentForm.icon ? '更换' : '上传' }}</span>
<RefreshCw v-if="agentIconUploading" :size="16" class="spinning" />
<Upload v-else :size="16" />
<span>{{ agentForm.icon ? '更换图标' : '上传图标' }}</span>
</div>
</div>
</a-upload>
<div class="agent-icon-preview-text">
<input
ref="agentNameInputRef"
v-model="agentForm.name"
class="agent-inline-name-input"
type="text"
@ -675,6 +700,11 @@ defineExpose({
&.without-sidebar {
grid-template-columns: minmax(0, 1fr);
}
&.create-mode {
border-color: var(--gray-300);
box-shadow: 0 10px 28px var(--shadow-1);
}
}
.agent-modal-sidebar {
@ -807,10 +837,6 @@ defineExpose({
width: 100%;
min-width: 0;
gap: 16px;
padding: 10px 12px 10px 10px;
border: 1px solid var(--gray-150);
border-radius: 12px;
background: linear-gradient(135deg, var(--gray-0), var(--main-10));
:deep(.ant-upload) {
display: block;
@ -826,13 +852,19 @@ defineExpose({
.agent-icon-upload {
position: relative;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
overflow: hidden;
border: 1px solid var(--gray-150);
border-radius: 8px;
background: var(--gray-0);
border: 1px solid var(--gray-200);
border-radius: 12px;
background: var(--gray-25);
cursor: pointer;
transition:
border-color 0.16s ease,
box-shadow 0.16s ease;
img {
display: block;
@ -841,10 +873,24 @@ defineExpose({
object-fit: cover;
}
&:hover,
&:focus-within,
&.uploading {
border-color: var(--main-300);
box-shadow: 0 0 0 3px var(--main-50);
}
&:hover .agent-icon-mask,
&.uploading .agent-icon-mask {
&:focus-within .agent-icon-mask,
&.uploading .agent-icon-mask,
&.is-empty .agent-icon-mask {
opacity: 1;
}
&.is-empty {
border-style: dashed;
background: var(--gray-0);
}
}
.agent-icon-mask {
@ -854,14 +900,20 @@ defineExpose({
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
background: rgba(0, 0, 0, 0.48);
gap: 4px;
background: color-mix(in srgb, var(--gray-900) 62%, transparent);
color: var(--gray-0);
font-size: 11px;
font-weight: 600;
opacity: 0;
transition: opacity 0.16s ease;
}
.agent-icon-upload.is-empty .agent-icon-mask {
background: transparent;
color: var(--gray-600);
}
.agent-icon-preview-text {
display: flex;
flex-direction: column;
@ -878,18 +930,28 @@ defineExpose({
border-radius: 6px;
background: transparent;
color: var(--gray-900);
font-size: 14px;
font-weight: 600;
line-height: 1.4;
caret-color: var(--main-700);
font-size: 18px;
font-weight: 700;
line-height: 1.35;
transition:
border-color 0.16s ease,
background 0.16s ease,
box-shadow 0.16s ease;
&::placeholder {
color: var(--gray-400);
}
&:hover,
&:focus {
border-color: var(--main-200);
&:hover {
border-color: var(--gray-300);
background: var(--gray-0);
}
&:focus {
border-color: var(--main-300);
background: var(--gray-0);
box-shadow: 0 0 0 3px var(--main-50);
outline: none;
}
}
@ -917,7 +979,7 @@ defineExpose({
&:hover,
&:focus {
border-color: var(--main-200);
border-color: var(--gray-300);
background: var(--gray-0);
outline: none;
}
@ -928,13 +990,13 @@ defineExpose({
align-items: center;
flex-shrink: 0;
gap: 10px;
min-width: 220px;
width: 190px;
min-height: 56px;
padding: 10px 12px;
border: 1px solid var(--main-100);
border: 1px solid var(--gray-200);
border-radius: 12px;
background: linear-gradient(135deg, var(--gray-0), var(--main-10));
color: var(--main-700);
background: var(--gray-25);
color: var(--gray-700);
&.editable {
padding-right: 8px;
@ -949,8 +1011,8 @@ defineExpose({
width: 32px;
height: 32px;
border-radius: 10px;
background: var(--main-50);
color: var(--main-700);
background: var(--gray-100);
color: var(--gray-700);
}
.agent-backend-text {
@ -968,7 +1030,7 @@ defineExpose({
}
.agent-backend-name {
max-width: 180px;
max-width: 128px;
overflow: hidden;
color: var(--gray-900);
font-size: 13px;
@ -978,7 +1040,7 @@ defineExpose({
}
.agent-backend-select {
width: 170px;
width: 128px;
margin: -3px 0 -5px -11px;
:deep(.ant-select-selector) {
@ -1094,6 +1156,12 @@ defineExpose({
width: 100%;
}
.agent-backend-select,
.agent-backend-name {
max-width: none;
width: 100%;
}
.form-row {
grid-template-columns: 1fr;
}

View File

@ -1,69 +1,103 @@
<template>
<div class="info-card" :class="{ 'info-card-disabled': disabled }" @click="$emit('click')">
<div class="info-card-header">
<div class="info-card-icon">
<slot name="icon">
<component :is="defaultIcon" v-if="defaultIcon" :size="20" />
<div
class="info-card"
:class="{ 'info-card-disabled': disabled, 'info-card-mini': variant === 'mini' }"
@click="$emit('click')"
>
<template v-if="variant === 'mini'">
<div class="info-card-mini-row">
<div class="info-card-icon">
<slot name="icon">
<component :is="defaultIcon" v-if="defaultIcon" :size="20" />
</slot>
</div>
<div class="info-card-info">
<span class="info-card-name" :title="title">{{ title }}</span>
<span v-if="description" class="info-card-mini-desc" :title="description">
{{ description }}
</span>
</div>
<div v-if="$slots.action || actionLabel" class="info-card-mini-action">
<slot name="action">
<button
type="button"
class="card-action-btn"
:class="`card-action-btn--${actionVariant || 'primary'}`"
@click.stop="$emit('actionClick')"
>
{{ actionLabel }}
</button>
</slot>
</div>
</div>
</template>
<template v-else>
<div class="info-card-header">
<div class="info-card-icon">
<slot name="icon">
<component :is="defaultIcon" v-if="defaultIcon" :size="20" />
</slot>
</div>
<div class="info-card-info">
<span class="info-card-name" :title="title">{{ title }}</span>
<span v-if="subtitle" class="info-card-subtitle" :title="subtitle">{{ subtitle }}</span>
</div>
<div class="info-card-status">
<slot name="status" />
<template v-if="!$slots.status">
<button
v-if="actionLabel"
type="button"
class="card-action-btn"
:class="`card-action-btn--${actionVariant || 'primary'}`"
@click.stop="$emit('actionClick')"
>
{{ actionLabel }}
</button>
<template v-else-if="status">
<span
v-if="status.label"
class="card-status-tag"
:class="`card-status-tag--${status.level || 'info'}`"
>{{ status.label }}</span
>
<span class="card-status-dot" :class="`card-status-dot--${statusDotColor}`"></span>
</template>
</template>
</div>
</div>
<div v-if="$slots.info" class="info-card-body">
<slot name="info" />
</div>
<div v-else-if="description" class="info-card-desc" :title="description">
{{ description }}
</div>
<div v-else-if="info && info.length > 0" class="info-card-info-rows">
<div v-for="(row, idx) in info" :key="idx" class="info-row">
<span class="info-label">{{ row.label }}</span>
<span class="info-value">{{ row.value }}</span>
</div>
</div>
<div v-if="$slots.tags || (normalizedTags && normalizedTags.length > 0)" class="info-card-tags">
<slot name="tags">
<span
v-for="(tag, idx) in normalizedTags"
:key="idx"
class="card-tag"
:class="tag.color ? `tag-${tag.color}` : ''"
:style="tag.bgColor ? { backgroundColor: tag.bgColor } : {}"
>{{ tag.name }}</span
>
</slot>
</div>
<div class="info-card-info">
<span class="info-card-name" :title="title">{{ title }}</span>
<span v-if="subtitle" class="info-card-subtitle" :title="subtitle">{{ subtitle }}</span>
</div>
<div class="info-card-status">
<slot name="status" />
<template v-if="!$slots.status">
<button
v-if="actionLabel"
type="button"
class="card-action-btn"
:class="`card-action-btn--${actionVariant || 'primary'}`"
@click.stop="$emit('actionClick')"
>
{{ actionLabel }}
</button>
<template v-else-if="status">
<span
v-if="status.label"
class="card-status-tag"
:class="`card-status-tag--${status.level || 'info'}`"
>{{ status.label }}</span
>
<span class="card-status-dot" :class="`card-status-dot--${statusDotColor}`"></span>
</template>
</template>
</div>
</div>
<div v-if="$slots.info" class="info-card-body">
<slot name="info" />
</div>
<div v-else-if="description" class="info-card-desc" :title="description">
{{ description }}
</div>
<div v-else-if="info && info.length > 0" class="info-card-info-rows">
<div v-for="(row, idx) in info" :key="idx" class="info-row">
<span class="info-label">{{ row.label }}</span>
<span class="info-value">{{ row.value }}</span>
<div v-if="$slots.footer" class="info-card-footer">
<slot name="footer" />
</div>
</div>
<div v-if="$slots.tags || (normalizedTags && normalizedTags.length > 0)" class="info-card-tags">
<slot name="tags">
<span
v-for="(tag, idx) in normalizedTags"
:key="idx"
class="card-tag"
:class="tag.color ? `tag-${tag.color}` : ''"
:style="tag.bgColor ? { backgroundColor: tag.bgColor } : {}"
>{{ tag.name }}</span
>
</slot>
</div>
<div v-if="$slots.footer" class="info-card-footer">
<slot name="footer" />
</div>
</template>
</div>
</template>
@ -81,7 +115,8 @@ const props = defineProps({
tags: { type: Array, default: () => [] },
status: { type: Object, default: null },
actionLabel: { type: String, default: '' },
actionVariant: { type: String, default: 'primary' }
actionVariant: { type: String, default: 'primary' },
variant: { type: String, default: 'default' }
})
const statusDotColor = computed(() => {
@ -243,6 +278,34 @@ const normalizedTags = computed(() => {
border-top: 1px solid var(--gray-100);
background: var(--gray-10);
}
&-mini {
padding: 12px 14px;
gap: 0;
}
&-mini-row {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
&-mini-desc {
margin-top: 2px;
color: var(--gray-500);
font-size: 13px;
line-height: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&-mini-action {
display: flex;
align-items: center;
flex-shrink: 0;
}
}
.card-action-btn {

View File

@ -135,22 +135,22 @@ const mainList = computed(() => {
activeIcon: FolderKanban
})
items.push({
name: '智能体扩展',
path: '/extensions?tab=skills',
activePaths: ['/extensions'],
icon: LibraryBig,
activeIcon: LibraryBig
})
items.push({
name: '智能体管理',
path: '/model-manage',
icon: Box,
activeIcon: Box
})
if (userStore.isAdmin) {
items.push({
name: '智能体扩展',
path: '/extensions',
activePaths: ['/extensions'],
icon: LibraryBig,
activeIcon: LibraryBig
})
items.push({
name: '智能体管理',
path: '/model-manage',
icon: Box,
activeIcon: Box
})
items.push({
name: '数据总览',
path: '/dashboard',

View File

@ -101,8 +101,7 @@ const router = createRouter({
component: () => import('../views/ExtensionsView.vue'),
meta: {
keepAlive: false,
requiresAuth: true,
requiresAdmin: true
requiresAuth: true
},
children: [
{
@ -131,8 +130,7 @@ const router = createRouter({
component: () => import('../components/extensions/SkillDetailView.vue'),
meta: {
keepAlive: false,
requiresAuth: true,
requiresAdmin: true
requiresAuth: true
}
}
]
@ -142,7 +140,7 @@ const router = createRouter({
{
path: '/skills',
name: 'skills',
redirect: '/extensions'
redirect: '/extensions?tab=skills'
},
{
path: '/:pathMatch(.*)*',

View File

@ -87,7 +87,7 @@ export const useAgentStore = defineStore(
const [dbsRes, mcpsRes, skillsRes] = await Promise.all([
databaseApi.getAccessibleDatabases().catch(() => ({ databases: [] })),
mcpApi.getMcpServers().catch(() => ({ data: [] })),
skillApi.listSkills().catch(() => ({ data: [] }))
skillApi.listAccessibleSkills().catch(() => ({ data: [] }))
])
availableKnowledgeBases.value = dbsRes.databases || []
availableMcps.value = mcpsRes.data || []

View File

@ -0,0 +1,14 @@
export const formatExtensionCardTitle = (value) => {
const text = String(value || '').trim()
if (!text) return ''
return text
.replace(/[-_]+/g, ' ')
.split(/\s+/)
.filter(Boolean)
.map((word) => {
if (/^[A-Z0-9]{2,3}$/.test(word)) return word
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
})
.join(' ')
}

View File

@ -73,18 +73,16 @@
当前对话已绑定智能体新对话可切换
</div>
<template v-if="userStore.isAdmin">
<div class="config-dropdown-divider"></div>
<div class="config-dropdown-divider"></div>
<button
type="button"
class="config-dropdown-item action-item"
@click="openAgentManagement"
>
<Settings2 :size="15" class="config-dropdown-item-icon" />
<span class="config-dropdown-item-label">管理智能体</span>
</button>
</template>
<button
type="button"
class="config-dropdown-item action-item"
@click="openAgentManagement"
>
<Settings2 :size="15" class="config-dropdown-item-icon" />
<span class="config-dropdown-item-label">管理智能体</span>
</button>
</div>
</template>
</a-dropdown>

View File

@ -11,16 +11,16 @@
/>
<div v-if="!isDetailPage" class="extensions-content">
<div v-show="activeTab === 'knowledge'" class="tab-panel">
<div v-if="userStore.isAdmin && activeTab === 'knowledge'" class="tab-panel">
<DataBaseView ref="knowledgeRef" embedded />
</div>
<div v-show="activeTab === 'tools'" class="tab-panel">
<div v-if="userStore.isAdmin && activeTab === 'tools'" class="tab-panel">
<ToolsCardList ref="toolsRef" />
</div>
<div v-show="activeTab === 'skills'" class="tab-panel">
<div v-if="activeTab === 'skills'" class="tab-panel">
<SkillCardList ref="skillsRef" />
</div>
<div v-show="activeTab === 'mcp'" class="tab-panel">
<div v-if="userStore.isAdmin && activeTab === 'mcp'" class="tab-panel">
<McpCardList ref="mcpRef" />
</div>
</div>
@ -31,26 +31,37 @@
<script setup>
import { ref, watch, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import ToolsCardList from '@/components/extensions/ToolsCardList.vue'
import McpCardList from '@/components/extensions/McpCardList.vue'
import SkillCardList from '@/components/extensions/SkillCardList.vue'
import PageHeader from '@/components/shared/PageHeader.vue'
import DataBaseView from '@/views/DataBaseView.vue'
import { useUserStore } from '@/stores/user'
const route = useRoute()
const activeTab = ref('knowledge')
const router = useRouter()
const userStore = useUserStore()
const activeTab = ref('skills')
const knowledgeRef = ref(null)
const skillsRef = ref(null)
const mcpRef = ref(null)
const toolsRef = ref(null)
const extensionTabs = [
const adminExtensionTabs = [
{ key: 'knowledge', label: '知识库' },
{ key: 'tools', label: '工具' },
{ key: 'mcp', label: 'MCP' },
{ key: 'skills', label: 'Skills' }
]
const userExtensionTabs = [{ key: 'skills', label: 'Skills' }]
const extensionTabs = computed(() => (userStore.isAdmin ? adminExtensionTabs : userExtensionTabs))
const allowedTabKeys = computed(() => extensionTabs.value.map((tab) => tab.key))
const normalizeTab = (tab) => {
if (allowedTabKeys.value.includes(tab)) return tab
return userStore.isAdmin ? 'knowledge' : 'skills'
}
const isDetailPage = computed(() => {
return (
@ -72,14 +83,24 @@ const activeChildLoading = computed(() => {
})
watch(
() => route.query,
(query) => {
if (query.tab && ['knowledge', 'tools', 'skills', 'mcp'].includes(query.tab)) {
activeTab.value = query.tab
}
() => [route.query.tab, userStore.isAdmin],
([tab]) => {
const nextTab = normalizeTab(tab)
if (activeTab.value !== nextTab) activeTab.value = nextTab
if (route.query.tab !== nextTab) router.replace({ query: { ...route.query, tab: nextTab } })
},
{ immediate: true }
)
watch(activeTab, (tab) => {
const nextTab = normalizeTab(tab)
if (nextTab !== tab) {
activeTab.value = nextTab
return
}
if (route.query.tab === nextTab) return
router.replace({ query: { ...route.query, tab: nextTab } })
})
</script>
<style scoped lang="less">