feat: 优化 Skill 安装逻辑和展示逻辑
This commit is contained in:
parent
eb55780d0d
commit
56cd1b500a
@ -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):
|
||||
|
||||
@ -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} 不存在")
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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] = {}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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(.*)*',
|
||||
|
||||
@ -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 || []
|
||||
|
||||
14
web/src/utils/extensionDisplayName.js
Normal file
14
web/src/utils/extensionDisplayName.js
Normal 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(' ')
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user