feat(skill): 新增 Skill 的权限设置

This commit is contained in:
Wenjie Zhang 2026-05-27 14:51:56 +08:00
parent 404fcd55a3
commit 4036530bb6
22 changed files with 2387 additions and 1791 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

View File

@ -35,4 +35,5 @@
- [x] 链接 Notion 和 feishu 目前来看,都是支持的
- [x] 知识库的权限调整,修改为三个等级,全局共享、部门共享、指定人可访问- [x] 当前的评估基准是最重要的是评估数据集和评估结果都是放在一个文件里面的,这个是绝对不可以的,应该是放在数据库里面,比如评估数据集是一个表,每一个评估的题目是一个表,评估的结果是一个表,每一个评估的 item 也是一个表,但是数据表太多要注意命名规范。现在第一步就是完成原本的评估的功能的重新梳理
- [ ] 考虑如何将知识库更好的挂载到沙盒,是不是可以使用一个别的后端,但是使用别的后端是否还能读取到数据?应该不能
- [ ] 智能体体系改进。
- [ ] 智能体体系改进。改进子智能体
- [ ] RAG 中的文件的 metadata 包含那些内容?然后 Find 和 Read 的时候要支持展示

View File

@ -155,8 +155,8 @@ class BaseContext:
summary_threshold: int = field(
default=100,
metadata={
"name": "上下文摘要触发阈值 (KB)",
"description": "当上下文大小超过该值时,启用摘要功能以优化上下文使用。单位为 KB,默认值为 100KB",
"name": "上下文摘要触发阈值 (K)",
"description": "当上下文大小超过该值时,启用摘要功能以优化上下文使用。单位为 K,默认值为 100K。",
"type": "number",
"auth": "admin",
},
@ -294,9 +294,9 @@ async def resolve_agent_resource_options(
if server.enabled and server.slug
]
if "skills" in fields_to_load:
from yuxi.services.skill_service import list_skills
from yuxi.services.skill_service import list_accessible_skills
skills = await list_skills(db)
skills = await list_accessible_skills(db, user)
options["skills"] = [
_resource_option(skill.slug, skill.name, skill.description) for skill in skills if skill.slug
]
@ -374,6 +374,8 @@ async def prepare_agent_runtime_context(
setattr(context, "_visible_knowledge_bases", [])
setattr(context, "_prompt_skills", [])
setattr(context, "_readable_skills", [])
setattr(context, "_runtime_skill_metadata", {})
setattr(context, "_runtime_skill_dependency_map", {})
return context
raw_resources = {
@ -392,9 +394,11 @@ async def prepare_agent_runtime_context(
setattr(context, field_name, normalized.get(field_name, []))
await resolve_visible_knowledge_bases_for_context(context)
skill_scope = await resolve_runtime_skills_for_context(context, db=db)
skill_scope = await resolve_runtime_skills_for_context(context, db=db, user=user)
context.skills = skill_scope["context_skills"]
setattr(context, "_prompt_skills", skill_scope["prompt_skills"])
setattr(context, "_readable_skills", skill_scope["readable_skills"])
setattr(context, "_runtime_skill_metadata", skill_scope["runtime_skill_metadata"])
setattr(context, "_runtime_skill_dependency_map", skill_scope["runtime_skill_dependency_map"])
return context

View File

@ -16,7 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.agents.toolkits import get_all_tool_instances
from yuxi.repositories.skill_repository import SkillRepository
from yuxi.services.mcp_service import get_enabled_mcp_tools
from yuxi.services.skill_service import is_valid_skill_slug, normalize_string_list
from yuxi.services.skill_service import is_valid_skill_slug, list_accessible_skills, normalize_string_list
from yuxi.storage.postgres.manager import pg_manager
from yuxi.utils.logging_config import logger
@ -42,20 +42,22 @@ class SkillDependencyNode(TypedDict):
# =============================================================================
async def _list_skills_from_db(db: AsyncSession | None = None) -> list:
async def _list_skills_from_db(db: AsyncSession | None = None, user=None) -> list:
"""从数据库加载 skills 列表"""
if db is not None:
if user is not None:
return await list_accessible_skills(db, user)
repo = SkillRepository(db)
return await repo.list_all()
return await repo.list_enabled()
async with pg_manager.get_async_session_context() as session:
if user is not None:
return await list_accessible_skills(session, user)
repo = SkillRepository(session)
return await repo.list_all()
return await repo.list_enabled()
async def get_prompt_metadata(db: AsyncSession | None = None) -> dict[str, SkillPromptMetadata]:
"""获取提示词元数据(直接从数据库加载)"""
skills = await _list_skills_from_db(db)
def build_prompt_metadata(skills: list) -> dict[str, SkillPromptMetadata]:
return {
item.slug: {
"name": item.name,
@ -63,14 +65,15 @@ async def get_prompt_metadata(db: AsyncSession | None = None) -> dict[str, Skill
"path": f"/home/gem/skills/{item.slug}/SKILL.md",
}
for item in skills
if item.slug
}
async def get_dependency_map(db: AsyncSession | None = None) -> dict[str, SkillDependencyNode]:
"""获取依赖关系映射(直接从数据库加载)"""
skills = await _list_skills_from_db(db)
def build_dependency_map(skills: list) -> dict[str, SkillDependencyNode]:
result: dict[str, SkillDependencyNode] = {}
for item in skills:
if not item.slug:
continue
result[item.slug] = {
"tools": normalize_string_list(item.tool_dependencies or []),
"mcps": normalize_string_list(item.mcp_dependencies or []),
@ -79,6 +82,16 @@ async def get_dependency_map(db: AsyncSession | None = None) -> dict[str, SkillD
return result
async def get_prompt_metadata(db: AsyncSession | None = None, user=None) -> dict[str, SkillPromptMetadata]:
"""获取提示词元数据(直接从数据库加载)"""
return build_prompt_metadata(await _list_skills_from_db(db, user))
async def get_dependency_map(db: AsyncSession | None = None, user=None) -> dict[str, SkillDependencyNode]:
"""获取依赖关系映射(直接从数据库加载)"""
return build_dependency_map(await _list_skills_from_db(db, user))
def expand_skill_closure(
slugs: list[str] | None,
dependency_map: dict[str, SkillDependencyNode],
@ -115,16 +128,20 @@ def expand_skill_closure(
return result
async def resolve_runtime_skills_for_context(context, *, db: AsyncSession | None = None) -> dict[str, list[str]]:
dependency_map = await get_dependency_map(db)
installed = set(dependency_map)
async def resolve_runtime_skills_for_context(context, *, db: AsyncSession | None = None, user=None) -> dict[str, Any]:
skill_items = await _list_skills_from_db(db, user)
dependency_map = build_dependency_map(skill_items)
prompt_metadata = build_prompt_metadata(skill_items)
available = set(dependency_map)
selected = normalize_string_list(getattr(context, "skills", None))
context_skills = [slug for slug in selected if slug in installed]
context_skills = [slug for slug in selected if slug in available]
prompt_skills = expand_skill_closure(context_skills, dependency_map)
return {
"context_skills": context_skills,
"prompt_skills": prompt_skills,
"readable_skills": prompt_skills,
"runtime_skill_metadata": prompt_metadata,
"runtime_skill_dependency_map": dependency_map,
}
@ -199,7 +216,7 @@ class SkillsMiddleware(AgentMiddleware):
return None
# 收集提示词元数据并构建提示段
skills_meta = await self._collect_prompt_metadata(prompt_skills)
skills_meta = self._collect_prompt_metadata(prompt_skills, runtime_context)
skills_section = self._build_skills_section(skills_meta)
# 注入提示词
@ -224,7 +241,7 @@ class SkillsMiddleware(AgentMiddleware):
readable_skills = self._get_readable_skills(runtime_context)
activated = [slug for slug in normalize_string_list(activated) if slug in readable_skills]
deps_bundle = await self._build_dependency_bundle(activated)
deps_bundle = self._build_dependency_bundle(activated, runtime_context)
enabled_tools = []
@ -251,9 +268,9 @@ class SkillsMiddleware(AgentMiddleware):
return await handler(request)
async def _build_dependency_bundle(self, activated_skills: list[str]) -> dict[str, list[str]]:
def _build_dependency_bundle(self, activated_skills: list[str], runtime_context) -> dict[str, list[str]]:
"""根据直接激活的 skills 构建依赖包(不包含闭包展开的依赖)"""
dependency_map = await get_dependency_map()
dependency_map = self._get_runtime_dependency_map(runtime_context)
tools: list[str] = []
mcps: list[str] = []
@ -275,9 +292,9 @@ class SkillsMiddleware(AgentMiddleware):
return {"tools": tools, "mcps": mcps, "skills": activated_skills}
async def _collect_prompt_metadata(self, slugs: list[str]) -> list[SkillPromptMetadata]:
def _collect_prompt_metadata(self, slugs: list[str], runtime_context) -> list[SkillPromptMetadata]:
"""收集指定 slugs 的提示词元数据"""
prompt_metadata = await get_prompt_metadata()
prompt_metadata = self._get_runtime_prompt_metadata(runtime_context)
result: list[SkillPromptMetadata] = []
seen: set[str] = set()
@ -403,6 +420,14 @@ class SkillsMiddleware(AgentMiddleware):
selected = getattr(runtime_context, "_readable_skills", [])
return set(normalize_string_list(selected if isinstance(selected, list) else []))
def _get_runtime_prompt_metadata(self, runtime_context) -> dict[str, SkillPromptMetadata]:
metadata = getattr(runtime_context, "_runtime_skill_metadata", {})
return metadata if isinstance(metadata, dict) else {}
def _get_runtime_dependency_map(self, runtime_context) -> dict[str, SkillDependencyNode]:
dependency_map = getattr(runtime_context, "_runtime_skill_dependency_map", {})
return dependency_map if isinstance(dependency_map, dict) else {}
def _is_visible_skill_slug(self, request: ToolCallRequest, slug: str) -> bool:
"""检查 slug 是否可见"""
return slug in self._get_readable_skills(request.runtime.context)

View File

@ -15,6 +15,21 @@ class SkillRepository:
result = await self.db.execute(select(Skill).order_by(Skill.updated_at.desc(), Skill.id.desc()))
return list(result.scalars().all())
async def list_enabled(self) -> list[Skill]:
result = await self.db.execute(
select(Skill).where(Skill.enabled.is_(True)).order_by(Skill.updated_at.desc(), Skill.id.desc())
)
return list(result.scalars().all())
async def list_by_slugs(self, slugs: list[str]) -> list[Skill]:
normalized = [slug for slug in dict.fromkeys(slugs) if isinstance(slug, str) and slug]
if not normalized:
return []
result = await self.db.execute(select(Skill).where(Skill.slug.in_(normalized)))
items = list(result.scalars().all())
item_map = {item.slug: item for item in items}
return [item_map[slug] for slug in normalized if slug in item_map]
async def get_by_slug(self, slug: str, *, for_update: bool = False) -> Skill | None:
stmt = select(Skill).where(Skill.slug == slug)
if for_update:
@ -31,12 +46,14 @@ class SkillRepository:
slug: str,
name: str,
description: str,
source_type: str,
tool_dependencies: list[str] | None,
mcp_dependencies: list[str] | None,
skill_dependencies: list[str] | None,
dir_path: str,
share_config: dict,
enabled: bool = True,
version: str | None = None,
is_builtin: bool = False,
content_hash: str | None = None,
created_by: str | None,
) -> Skill:
@ -45,13 +62,15 @@ class SkillRepository:
slug=slug,
name=name,
description=description,
source_type=source_type,
tool_dependencies=tool_dependencies or [],
mcp_dependencies=mcp_dependencies or [],
skill_dependencies=skill_dependencies or [],
dir_path=dir_path,
version=version,
is_builtin=is_builtin,
content_hash=content_hash,
share_config=share_config,
enabled=enabled,
created_by=created_by,
updated_by=created_by,
created_at=now,
@ -72,7 +91,8 @@ class SkillRepository:
) -> Skill:
item.version = version
item.content_hash = content_hash
item.is_builtin = True
item.source_type = "builtin"
item.share_config = {"access_level": "global", "department_ids": [], "user_uids": []}
item.updated_by = updated_by
item.updated_at = utc_now_naive()
await self.db.commit()
@ -113,6 +133,22 @@ class SkillRepository:
await self.db.refresh(item)
return item
async def update_share_config(self, item: Skill, *, share_config: dict, updated_by: str | None) -> Skill:
item.share_config = share_config
item.updated_by = updated_by
item.updated_at = utc_now_naive()
await self.db.commit()
await self.db.refresh(item)
return item
async def update_enabled(self, item: Skill, *, enabled: bool, updated_by: str | None) -> Skill:
item.enabled = enabled
item.updated_by = updated_by
item.updated_at = utc_now_naive()
await self.db.commit()
await self.db.refresh(item)
return item
async def delete(self, item: Skill) -> None:
await self.db.delete(item)
await self.db.commit()

View File

@ -13,6 +13,7 @@ from sqlalchemy.exc import OperationalError
from yuxi.repositories.agent_run_repository import TERMINAL_RUN_STATUSES, AgentRunRepository
from yuxi.services.chat_service import stream_agent_chat
from yuxi.services.mcp_service import ensure_builtin_mcp_servers_in_db
from yuxi.services.skill_service import init_builtin_skills
from yuxi.services.run_queue_service import (
append_run_stream_event,
clear_cancel_signal,
@ -369,6 +370,8 @@ async def _worker_startup(ctx):
await pg_manager.create_business_tables()
await pg_manager.ensure_business_schema()
await ensure_builtin_mcp_servers_in_db()
async with pg_manager.get_async_session_context() as session:
await init_builtin_skills(session)
async def _worker_shutdown(ctx):

File diff suppressed because it is too large Load Diff

View File

@ -372,7 +372,12 @@ class PostgresManager(metaclass=SingletonMeta):
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS mcp_dependencies JSONB DEFAULT '[]'::jsonb",
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS skill_dependencies JSONB DEFAULT '[]'::jsonb",
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS version VARCHAR(64)",
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS is_builtin BOOLEAN NOT NULL DEFAULT FALSE",
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS source_type VARCHAR(32) NOT NULL DEFAULT 'upload'",
(
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS share_config JSONB NOT NULL "
'DEFAULT \'{"access_level": "user", "department_ids": [], "user_uids": []}\'::jsonb'
),
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT TRUE",
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS content_hash VARCHAR(128)",
"ALTER TABLE IF EXISTS subagents ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT TRUE",
"ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS is_pinned BOOLEAN NOT NULL DEFAULT FALSE",

View File

@ -208,13 +208,17 @@ class Skill(Base):
slug = Column(String(128), nullable=False, unique=True, index=True, comment="技能唯一标识(目录名)")
name = Column(String(128), nullable=False, comment="技能名称(来自 SKILL.md frontmatter.name")
description = Column(Text, nullable=False, comment="技能描述(来自 SKILL.md frontmatter.description")
source_type = Column(
String(32), nullable=False, default="upload", index=True, comment="来源: builtin/upload/remote"
)
tool_dependencies = Column(JSON, nullable=False, default=list, comment="依赖的内置工具名列表")
mcp_dependencies = Column(JSON, nullable=False, default=list, comment="依赖的 MCP 服务名列表")
skill_dependencies = Column(JSON, nullable=False, default=list, comment="依赖的其他 skill slug 列表")
dir_path = Column(String(512), nullable=False, comment="技能目录路径(相对 save_dir")
version = Column(String(64), nullable=True, comment="技能版本(内置 skill 使用语义化版本)")
is_builtin = Column(Boolean, nullable=False, default=False, comment="是否为内置 skill")
content_hash = Column(String(128), nullable=True, comment="技能目录内容哈希(内置 skill 安装时计算)")
share_config = Column(JSON, nullable=False, default=dict, comment="共享权限配置")
enabled = Column(Boolean, nullable=False, default=True, comment="是否启用")
created_by = Column(String(64), nullable=True)
updated_by = Column(String(64), nullable=True)
created_at = Column(DateTime, default=utc_now_naive)
@ -226,13 +230,15 @@ class Skill(Base):
"slug": self.slug,
"name": self.name,
"description": self.description,
"source_type": self.source_type,
"tool_dependencies": self.tool_dependencies or [],
"mcp_dependencies": self.mcp_dependencies or [],
"skill_dependencies": self.skill_dependencies or [],
"dir_path": self.dir_path,
"version": self.version,
"is_builtin": self.is_builtin,
"content_hash": self.content_hash,
"share_config": self.share_config or {},
"enabled": bool(self.enabled),
"created_by": self.created_by,
"updated_by": self.updated_by,
"created_at": format_utc_datetime(self.created_at),

View File

@ -9,7 +9,7 @@ from server.routers.dashboard_router import dashboard
from server.routers.auth_dept_router import department
from server.routers.mcp_router import mcp
from server.routers.model_provider_router import model_providers
from server.routers.skill_router import skills
from server.routers.skill_router import skills, user_skills
from server.routers.subagent_router import subagents_router
from server.routers.system_router import system
from server.routers.system_task_router import tasks
@ -36,6 +36,7 @@ router.include_router(tasks) # /api/tasks/* 后台任务查询与管理
router.include_router(mcp) # /api/system/mcp-servers/* MCP 服务管理
router.include_router(model_providers) # /api/system/model-providers/* 独立模型配置
router.include_router(skills) # /api/system/skills/* Skills 管理
router.include_router(user_skills) # /api/skills/* 用户可用 Skills
router.include_router(subagents_router) # /api/system/subagents/* 子智能体管理
router.include_router(tools) # /api/system/tools/* 工具列表与配置
router.include_router(user_router) # /api/user/* 用户级配置与凭据

View File

@ -10,33 +10,44 @@ from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from server.utils.auth_middleware import get_admin_user, get_db, get_required_user
from yuxi.services.remote_skill_install_service import (
install_remote_skill,
install_remote_skills_batch,
list_remote_skills,
)
from yuxi.services.remote_skill_install_service import list_remote_skills, search_remote_skills
from yuxi.services.skill_service import (
BuiltinSkillUpdateConflictError,
confirm_skill_install_draft,
create_skill_node,
delete_skill,
delete_skill_node,
delete_skills_batch,
discard_skill_install_draft,
export_skill_zip,
get_allowed_skill_access_levels,
get_manageable_skill_or_raise,
get_skill_dependency_options,
get_skill_tree,
import_skill_zip,
install_builtin_skill,
list_builtin_skill_specs,
init_builtin_skills,
list_accessible_skills,
list_manageable_skills,
list_skills,
prepare_remote_skill_install,
prepare_skill_upload,
read_skill_file,
update_builtin_skill,
update_skill_dependencies,
update_skill_enabled,
update_skill_file,
update_skill_share_config,
)
from yuxi.storage.postgres.models_business import User
from yuxi.utils.logging_config import logger
skills = APIRouter(prefix="/system/skills", tags=["skills"])
user_skills = APIRouter(prefix="/skills", tags=["skills"])
class ShareConfigPayload(BaseModel):
share_config: dict | None = Field(None, description="共享权限配置")
class SkillEnabledUpdateRequest(BaseModel):
enabled: bool = Field(..., description="是否启用")
class SkillNodeCreateRequest(BaseModel):
@ -56,20 +67,12 @@ class SkillDependenciesUpdateRequest(BaseModel):
skill_dependencies: list[str] = Field(default_factory=list, description="依赖的其他 skill slug 列表")
class BuiltinSkillUpdateRequest(BaseModel):
force: bool = Field(False, description="是否强制覆盖本地已安装内容")
class RemoteSkillSourceRequest(BaseModel):
source: str = Field(..., description="skills 仓库来源,如 owner/repo 或 GitHub URL")
class RemoteSkillInstallRequest(RemoteSkillSourceRequest):
skill: str = Field(..., description="需要安装的 skill 名称")
class RemoteSkillBatchInstallRequest(RemoteSkillSourceRequest):
skills: list[str] = Field(..., description="需要安装的 skill 名称列表(批量,共享一次克隆)")
class RemoteSkillPrepareRequest(RemoteSkillSourceRequest):
skills: list[str] = Field(..., description="需要安装的 skill 名称列表")
class RemoteSkillSearchRequest(BaseModel):
@ -80,9 +83,13 @@ class SkillBatchDeleteRequest(BaseModel):
slugs: list[str] = Field(..., max_length=50, description="需要批量删除的 skill slug 列表,最多支持 50 个")
class SkillDraftConfirmRequest(BaseModel):
share_config: dict | None = Field(None, description="共享权限配置")
def _raise_from_value_error(e: ValueError) -> None:
message = str(e)
status_code = 404 if "不存在" in message else 400
status_code = 404 if "不存在" in message or "无权" in message else 400
raise HTTPException(status_code=status_code, detail=message)
@ -93,44 +100,156 @@ def _cleanup_export_file(path: str) -> None:
logger.warning(f"Failed to cleanup exported skill archive '{path}': {e}")
def _summarize_results(results: list[dict]) -> dict[str, int]:
return {
"total": len(results),
"success": sum(1 for item in results if item.get("success")),
"failed": sum(1 for item in results if not item.get("success")),
}
@user_skills.get("/accessible")
async def list_accessible_skills_route(
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
try:
items = await list_accessible_skills(db, current_user)
return {"success": True, "data": [item.to_dict() for item in items]}
except Exception as e:
logger.error(f"Failed to list accessible skills: {e}")
raise HTTPException(status_code=500, detail="获取可访问 Skills 失败")
@user_skills.post("/import/prepare")
async def prepare_skill_upload_route(
file: UploadFile = File(...),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
try:
data = await prepare_skill_upload(
db,
filename=file.filename or "",
file_bytes=await file.read(),
operator=current_user,
)
return {"success": True, "data": data}
except ValueError as e:
_raise_from_value_error(e)
except Exception as e:
logger.error(f"Failed to prepare skill upload: {e}")
raise HTTPException(status_code=500, detail="解析上传 Skill 失败")
@user_skills.post("/remote/list")
async def list_remote_skills_route(payload: RemoteSkillSourceRequest, _current_user: User = Depends(get_required_user)):
try:
return {"success": True, "data": await list_remote_skills(payload.source)}
except ValueError as e:
_raise_from_value_error(e)
except Exception as e:
logger.error(f"Failed to list remote skills from '{payload.source}': {e}")
raise HTTPException(status_code=500, detail="获取远程 skills 列表失败")
@user_skills.post("/remote/search")
async def search_remote_skills_route(
payload: RemoteSkillSearchRequest, _current_user: User = Depends(get_required_user)
):
try:
return {"success": True, "data": await search_remote_skills(payload.query)}
except ValueError as e:
_raise_from_value_error(e)
except Exception as e:
logger.error(f"Failed to search remote skills with query '{payload.query}': {e}")
raise HTTPException(status_code=500, detail="搜索远程 skills 失败")
@user_skills.post("/remote/prepare")
async def prepare_remote_skills_route(
payload: RemoteSkillPrepareRequest,
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
try:
data = await prepare_remote_skill_install(
db,
source=payload.source,
skills=payload.skills,
operator=current_user,
)
return {"success": True, "data": data}
except ValueError as e:
_raise_from_value_error(e)
except Exception as e:
logger.error(f"Failed to prepare remote skills from '{payload.source}': {e}")
raise HTTPException(status_code=500, detail="解析远程 Skills 失败")
@user_skills.post("/install-drafts/{draft_id}/confirm")
async def confirm_skill_install_draft_route(
draft_id: str,
payload: SkillDraftConfirmRequest,
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
try:
results = await confirm_skill_install_draft(
db,
draft_id=draft_id,
share_config=payload.share_config,
operator=current_user,
)
return {"success": True, "data": results, "summary": _summarize_results(results)}
except ValueError as e:
_raise_from_value_error(e)
except Exception as e:
logger.error(f"Failed to confirm skill install draft '{draft_id}': {e}")
raise HTTPException(status_code=500, detail="确认安装 Skill 失败")
@user_skills.delete("/install-drafts/{draft_id}")
async def discard_skill_install_draft_route(draft_id: str, current_user: User = Depends(get_required_user)):
try:
await discard_skill_install_draft(draft_id=draft_id, operator=current_user)
return {"success": True}
except ValueError as e:
_raise_from_value_error(e)
except Exception as e:
logger.error(f"Failed to discard skill install draft '{draft_id}': {e}")
raise HTTPException(status_code=500, detail="取消安装 Skill 失败")
@skills.get("")
async def list_skills_route(
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
"""获取技能列表(普通用户仅获取白名单脱敏数据,管理员可读完整元数据)。"""
try:
items = await list_skills(db)
# NOTE: 针对管理员与常规登录用户分流返回防止物理目录结构dir_path与系统审计信息越权暴露给常规用户
if current_user.role in ["admin", "superadmin"]:
return {"success": True, "data": [item.to_dict() for item in items]}
safe_data = []
for item in items:
safe_data.append(
{
"slug": item.slug,
"name": item.name,
"description": item.description,
"version": item.version,
"is_builtin": item.is_builtin,
}
)
return {"success": True, "data": safe_data}
items = await list_manageable_skills(db, current_user)
return {
"success": True,
"data": [item.to_dict() for item in items],
"allowed_access_levels": get_allowed_skill_access_levels(current_user),
}
except Exception as e:
logger.error(f"Failed to list skills: {e}")
logger.error(f"Failed to list manageable skills: {e}")
raise HTTPException(status_code=500, detail="获取技能列表失败")
@skills.get("/dependency-options")
async def get_skill_dependency_options_route(
_current_user: User = Depends(get_admin_user),
slug: str | None = Query(None, description="当前 Skill slug"),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
"""获取 skill 依赖项可选列表(管理员)。"""
try:
return {"success": True, "data": await get_skill_dependency_options(db)}
if slug:
await get_manageable_skill_or_raise(db, current_user, slug)
return {"success": True, "data": await get_skill_dependency_options(db, current_user, slug)}
except ValueError as e:
_raise_from_value_error(e)
except Exception as e:
logger.error(f"Failed to get skill dependency options: {e}")
raise HTTPException(status_code=500, detail="获取 skill 依赖选项失败")
@ -142,208 +261,75 @@ async def list_builtin_skills_route(
db: AsyncSession = Depends(get_db),
):
try:
installed_map = {item.slug: item for item in await list_skills(db)}
data = []
for spec in list_builtin_skill_specs():
installed = installed_map.get(spec["slug"])
status = "not_installed"
if installed:
status = "installed"
if installed.version != spec["version"] or installed.content_hash != spec["content_hash"]:
status = "update_available"
data.append(
{
"slug": spec["slug"],
"name": spec["name"],
"description": spec["description"],
"version": spec["version"],
"status": status,
"installed_record": installed.to_dict() if installed else None,
}
)
return {"success": True, "data": data}
items = [item for item in await list_skills(db) if item.source_type == "builtin"]
return {"success": True, "data": [item.to_dict() for item in items]}
except ValueError as e:
_raise_from_value_error(e)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to list builtin skills: {e}")
raise HTTPException(status_code=500, detail="获取内置 skill 列表失败")
@skills.post("/builtin/{slug}/install")
async def install_builtin_skill_route(
@skills.post("/builtin/sync")
async def sync_builtin_skills_route(
current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
try:
items = await init_builtin_skills(db, created_by=current_user.uid)
return {"success": True, "data": [item.to_dict() for item in items]}
except ValueError as e:
_raise_from_value_error(e)
except Exception as e:
logger.error(f"Failed to sync builtin skills: {e}")
raise HTTPException(status_code=500, detail="同步内置 skill 失败")
@skills.put("/{slug}/share-config")
async def update_skill_share_config_route(
slug: str,
current_user: User = Depends(get_admin_user),
payload: ShareConfigPayload,
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
try:
item = await install_builtin_skill(db, slug, installed_by=current_user.username)
item = await update_skill_share_config(db, slug=slug, share_config=payload.share_config, operator=current_user)
return {"success": True, "data": item.to_dict()}
except ValueError as e:
_raise_from_value_error(e)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to install builtin skill '{slug}': {e}")
raise HTTPException(status_code=500, detail="安装内置 skill 失败")
logger.error(f"Failed to update skill share config '{slug}': {e}")
raise HTTPException(status_code=500, detail="更新 Skill 共享范围失败")
@skills.post("/builtin/{slug}/update")
async def update_builtin_skill_route(
@skills.put("/{slug}/enabled")
async def update_skill_enabled_route(
slug: str,
payload: BuiltinSkillUpdateRequest,
current_user: User = Depends(get_admin_user),
payload: SkillEnabledUpdateRequest,
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
try:
item = await update_builtin_skill(
db,
slug,
force=payload.force,
updated_by=current_user.username,
)
return {"success": True, "data": item.to_dict()}
except BuiltinSkillUpdateConflictError as e:
raise HTTPException(
status_code=409,
detail={"needs_confirm": True, "message": str(e)},
)
except ValueError as e:
_raise_from_value_error(e)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update builtin skill '{slug}': {e}")
raise HTTPException(status_code=500, detail="更新内置 skill 失败")
@skills.post("/import")
async def import_skill_route(
file: UploadFile = File(...),
current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""导入技能包(支持 ZIP 或单个 SKILL.md管理员"""
try:
file_bytes = await file.read()
item = await import_skill_zip(
db,
filename=file.filename or "",
file_bytes=file_bytes,
created_by=current_user.username,
)
item = await update_skill_enabled(db, slug=slug, enabled=payload.enabled, operator=current_user)
return {"success": True, "data": item.to_dict()}
except ValueError as e:
_raise_from_value_error(e)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to import skill package: {e}")
raise HTTPException(status_code=500, detail="导入技能失败")
@skills.post("/remote/list")
async def list_remote_skills_route(
payload: RemoteSkillSourceRequest,
_current_user: User = Depends(get_admin_user),
):
try:
return {"success": True, "data": await list_remote_skills(payload.source)}
except ValueError as e:
_raise_from_value_error(e)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to list remote skills from '{payload.source}': {e}")
raise HTTPException(status_code=500, detail="获取远程 skills 列表失败")
@skills.post("/remote/install")
async def install_remote_skill_route(
payload: RemoteSkillInstallRequest,
current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
try:
item = await install_remote_skill(
db,
source=payload.source,
skill=payload.skill,
created_by=current_user.username,
)
return {"success": True, "data": item.to_dict()}
except ValueError as e:
_raise_from_value_error(e)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to install remote skill '{payload.skill}' from '{payload.source}': {e}")
raise HTTPException(status_code=500, detail="安装远程 skill 失败")
@skills.post("/remote/install-batch")
async def install_remote_skills_batch_route(
payload: RemoteSkillBatchInstallRequest,
current_user: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""批量从同一远程仓库安装多个 skills仅一次克隆不存在的 skill 静默跳过)。"""
try:
results = await install_remote_skills_batch(
db,
source=payload.source,
skills=payload.skills,
created_by=current_user.username,
)
success_count = sum(1 for r in results if r["success"])
failed_count = sum(1 for r in results if not r["success"])
return {
"success": True,
"data": results,
"summary": {"total": len(results), "success": success_count, "failed": failed_count},
}
except ValueError as e:
_raise_from_value_error(e)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to install remote skills batch from '{payload.source}': {e}")
raise HTTPException(status_code=500, detail="批量安装远程 skills 失败")
@skills.post("/remote/search")
async def search_remote_skills_route(
payload: RemoteSkillSearchRequest,
_current_user: User = Depends(get_admin_user),
):
"""搜索远程公开的 skills管理员"""
try:
data = await search_remote_skills(payload.query)
return {"success": True, "data": data}
except ValueError as e:
_raise_from_value_error(e)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to search remote skills with query '{payload.query}': {e}")
raise HTTPException(status_code=500, detail="搜索远程 skills 失败")
logger.error(f"Failed to update skill enabled '{slug}': {e}")
raise HTTPException(status_code=500, detail="更新 Skill 启用状态失败")
@skills.get("/{slug}/tree")
async def get_skill_tree_route(
slug: str,
_current_user: User = Depends(get_admin_user),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
"""获取技能目录树(管理员)。"""
try:
tree = await get_skill_tree(db, slug)
return {"success": True, "data": tree}
await get_manageable_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)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get skill tree '{slug}': {e}")
raise HTTPException(status_code=500, detail="获取技能目录树失败")
@ -353,17 +339,14 @@ async def get_skill_tree_route(
async def get_skill_file_route(
slug: str,
path: str = Query(..., description="相对 skill 根目录路径"),
_current_user: User = Depends(get_admin_user),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
"""读取技能文本文件(管理员)。"""
try:
data = await read_skill_file(db, slug, path)
return {"success": True, "data": data}
await get_manageable_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)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to read skill file '{slug}/{path}': {e}")
raise HTTPException(status_code=500, detail="读取技能文件失败")
@ -373,24 +356,22 @@ async def get_skill_file_route(
async def create_skill_file_route(
slug: str,
payload: SkillNodeCreateRequest,
current_user: User = Depends(get_admin_user),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
"""创建技能文件或目录(管理员)。"""
try:
await get_manageable_skill_or_raise(db, current_user, slug)
await create_skill_node(
db,
slug=slug,
relative_path=payload.path,
is_dir=payload.is_dir,
content=payload.content,
updated_by=current_user.username,
updated_by=current_user.uid,
)
return {"success": True}
except ValueError as e:
_raise_from_value_error(e)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to create skill node '{slug}/{payload.path}': {e}")
raise HTTPException(status_code=500, detail="创建技能文件失败")
@ -400,23 +381,21 @@ async def create_skill_file_route(
async def update_skill_file_route(
slug: str,
payload: SkillFileUpdateRequest,
current_user: User = Depends(get_admin_user),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
"""更新技能文本文件(管理员)。"""
try:
await get_manageable_skill_or_raise(db, current_user, slug)
await update_skill_file(
db,
slug=slug,
relative_path=payload.path,
content=payload.content,
updated_by=current_user.username,
updated_by=current_user.uid,
)
return {"success": True}
except ValueError as e:
_raise_from_value_error(e)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update skill file '{slug}/{payload.path}': {e}")
raise HTTPException(status_code=500, detail="更新技能文件失败")
@ -426,10 +405,9 @@ async def update_skill_file_route(
async def update_skill_dependencies_route(
slug: str,
payload: SkillDependenciesUpdateRequest,
current_user: User = Depends(get_admin_user),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
"""更新 skill 依赖(管理员)。"""
try:
item = await update_skill_dependencies(
db,
@ -437,13 +415,11 @@ async def update_skill_dependencies_route(
tool_dependencies=payload.tool_dependencies,
mcp_dependencies=payload.mcp_dependencies,
skill_dependencies=payload.skill_dependencies,
updated_by=current_user.username,
operator=current_user,
)
return {"success": True, "data": item.to_dict()}
except ValueError as e:
_raise_from_value_error(e)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update skill dependencies '{slug}': {e}")
raise HTTPException(status_code=500, detail="更新 skill 依赖失败")
@ -453,17 +429,15 @@ async def update_skill_dependencies_route(
async def delete_skill_file_route(
slug: str,
path: str = Query(..., description="相对 skill 根目录路径"),
_current_user: User = Depends(get_admin_user),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
"""删除技能文件或目录(管理员)。"""
try:
await get_manageable_skill_or_raise(db, current_user, slug)
await delete_skill_node(db, slug=slug, relative_path=path)
return {"success": True}
except ValueError as e:
_raise_from_value_error(e)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete skill file '{slug}/{path}': {e}")
raise HTTPException(status_code=500, detail="删除技能文件失败")
@ -473,22 +447,16 @@ async def delete_skill_file_route(
async def export_skill_route(
slug: str,
background_tasks: BackgroundTasks,
_current_user: User = Depends(get_admin_user),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
"""导出技能压缩包(管理员)。"""
try:
await get_manageable_skill_or_raise(db, current_user, slug)
export_path, download_name = await export_skill_zip(db, slug)
background_tasks.add_task(_cleanup_export_file, export_path)
return FileResponse(
path=export_path,
media_type="application/zip",
filename=download_name,
)
return FileResponse(path=export_path, media_type="application/zip", filename=download_name)
except ValueError as e:
_raise_from_value_error(e)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to export skill '{slug}': {e}")
raise HTTPException(status_code=500, detail="导出技能失败")
@ -497,17 +465,15 @@ async def export_skill_route(
@skills.delete("/{slug}")
async def delete_skill_route(
slug: str,
_current_user: User = Depends(get_admin_user),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
"""删除技能(目录 + 数据库记录,管理员)。"""
try:
await get_manageable_skill_or_raise(db, current_user, slug)
await delete_skill(db, slug=slug)
return {"success": True}
except ValueError as e:
_raise_from_value_error(e)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete skill '{slug}': {e}")
raise HTTPException(status_code=500, detail="删除技能失败")
@ -516,23 +482,16 @@ async def delete_skill_route(
@skills.post("/delete-batch")
async def delete_skills_batch_route(
payload: SkillBatchDeleteRequest,
_current_user: User = Depends(get_admin_user),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
):
"""批量删除技能(目录 + 数据库记录,管理员)。"""
try:
for slug in payload.slugs:
await get_manageable_skill_or_raise(db, current_user, slug)
results = await delete_skills_batch(db, slugs=payload.slugs)
success_count = sum(1 for r in results if r["success"])
failed_count = sum(1 for r in results if not r["success"])
return {
"success": True,
"data": results,
"summary": {"total": len(results), "success": success_count, "failed": failed_count},
}
return {"success": True, "data": results, "summary": _summarize_results(results)}
except ValueError as e:
_raise_from_value_error(e)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete skills batch: {e}")
raise HTTPException(status_code=500, detail="批量删除技能失败")

View File

@ -34,6 +34,14 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.error(f"Failed to ensure builtin MCP servers during startup: {e}")
try:
from yuxi.services.skill_service import init_builtin_skills
async with pg_manager.get_async_session_context() as session:
await init_builtin_skills(session)
except Exception as e:
logger.error(f"Failed to initialize builtin skills during startup: {e}")
try:
from yuxi.repositories.agent_repository import AgentRepository

View File

@ -104,7 +104,7 @@ async def test_normalize_agent_context_config_expands_null_and_filters_explicit_
types.SimpleNamespace(slug="mcp-b", name="MCP B", description="", enabled=True),
]
async def fake_list_skills(_db):
async def fake_list_skills(_db, _user):
return [
types.SimpleNamespace(slug="skill-a", name="Skill A", description=""),
types.SimpleNamespace(slug="skill-b", name="Skill B", description=""),
@ -120,7 +120,7 @@ async def test_normalize_agent_context_config_expands_null_and_filters_explicit_
sys.modules,
"yuxi.services.tool_service",
types.SimpleNamespace(
get_tool_metadata=lambda: [
get_tool_metadata=lambda category=None: [
{"slug": "ask_user_question", "name": "Ask User", "description": ""},
{"slug": "tavily_search", "name": "Tavily", "description": ""},
]
@ -139,7 +139,7 @@ async def test_normalize_agent_context_config_expands_null_and_filters_explicit_
monkeypatch.setitem(
sys.modules,
"yuxi.services.skill_service",
types.SimpleNamespace(list_skills=fake_list_skills),
types.SimpleNamespace(list_accessible_skills=fake_list_skills),
)
monkeypatch.setitem(
sys.modules,
@ -176,7 +176,7 @@ async def test_prepare_agent_runtime_context_filters_resources_and_derives_runti
async def fake_get_all_mcp_servers(_db):
return [types.SimpleNamespace(slug="mcp-a", name="MCP A", description="", enabled=True)]
async def fake_list_skills(_db):
async def fake_list_skills(_db, _user):
return [
types.SimpleNamespace(slug="skill-a", name="Skill A", description=""),
types.SimpleNamespace(slug="skill-b", name="Skill B", description=""),
@ -190,13 +190,16 @@ async def test_prepare_agent_runtime_context_filters_resources_and_derives_runti
context._visible_knowledge_bases = [{"slug": "kb-a", "name": "Docs A"}]
return context._visible_knowledge_bases
async def fake_resolve_runtime_skills_for_context(context, *, db=None):
async def fake_resolve_runtime_skills_for_context(context, *, db=None, user=None):
del db
assert user.uid == "u1"
assert context.skills == ["skill-a"]
return {
"context_skills": ["skill-a"],
"prompt_skills": ["skill-a", "skill-b"],
"readable_skills": ["skill-a", "skill-b"],
"runtime_skill_metadata": {"skill-a": {"name": "Skill A"}},
"runtime_skill_dependency_map": {"skill-a": {"skills": ["skill-b"]}},
}
class FakeSessionContext:
@ -209,7 +212,7 @@ async def test_prepare_agent_runtime_context_filters_resources_and_derives_runti
class FakeUserRepository:
async def get_by_uid_with_db(self, _db, uid):
assert uid == "u1"
return types.SimpleNamespace(role="user", uid="u1")
return types.SimpleNamespace(role="user", uid="u1", department_id=None)
monkeypatch.setitem(
sys.modules,
@ -235,7 +238,9 @@ async def test_prepare_agent_runtime_context_filters_resources_and_derives_runti
sys.modules,
"yuxi.services.tool_service",
types.SimpleNamespace(
get_tool_metadata=lambda: [{"slug": "ask_user_question", "name": "Ask User", "description": ""}]
get_tool_metadata=lambda category=None: [
{"slug": "ask_user_question", "name": "Ask User", "description": ""}
]
),
)
monkeypatch.setitem(
@ -251,7 +256,7 @@ async def test_prepare_agent_runtime_context_filters_resources_and_derives_runti
monkeypatch.setitem(
sys.modules,
"yuxi.services.skill_service",
types.SimpleNamespace(list_skills=fake_list_skills),
types.SimpleNamespace(list_accessible_skills=fake_list_skills),
)
monkeypatch.setitem(
sys.modules,
@ -278,6 +283,8 @@ async def test_prepare_agent_runtime_context_filters_resources_and_derives_runti
assert prepared._visible_knowledge_bases == [{"slug": "kb-a", "name": "Docs A"}]
assert prepared._prompt_skills == ["skill-a", "skill-b"]
assert prepared._readable_skills == ["skill-a", "skill-b"]
assert prepared._runtime_skill_metadata == {"skill-a": {"name": "Skill A"}}
assert prepared._runtime_skill_dependency_map == {"skill-a": {"skills": ["skill-b"]}}
@pytest.mark.asyncio
@ -301,7 +308,7 @@ async def test_prepare_agent_runtime_context_clears_resources_for_missing_user(m
monkeypatch.setitem(
sys.modules,
"yuxi.agents.middlewares.skills_middleware",
types.SimpleNamespace(resolve_runtime_skills_for_context=lambda _context, db=None: None),
types.SimpleNamespace(resolve_runtime_skills_for_context=lambda _context, db=None, user=None: None),
)
monkeypatch.setitem(
sys.modules,
@ -333,3 +340,5 @@ async def test_prepare_agent_runtime_context_clears_resources_for_missing_user(m
assert prepared._visible_knowledge_bases == []
assert prepared._prompt_skills == []
assert prepared._readable_skills == []
assert prepared._runtime_skill_metadata == {}
assert prepared._runtime_skill_dependency_map == {}

View File

@ -12,31 +12,47 @@ from yuxi.agents.middlewares.skills_middleware import SkillsMiddleware, resolve_
@pytest.mark.asyncio
async def test_resolve_runtime_skills_derives_prompt_and_readable_closure(monkeypatch):
async def fake_get_dependency_map(db=None):
del db
return {
"alpha": {"tools": [], "mcps": [], "skills": ["beta"]},
"beta": {"tools": [], "mcps": [], "skills": []},
}
async def fake_list_skills_from_db(db=None, user=None):
del db, user
return [
SimpleNamespace(
slug="alpha",
name="Alpha",
description="alpha desc",
tool_dependencies=[],
mcp_dependencies=[],
skill_dependencies=["beta"],
),
SimpleNamespace(
slug="beta",
name="Beta",
description="beta desc",
tool_dependencies=[],
mcp_dependencies=[],
skill_dependencies=[],
),
]
monkeypatch.setattr(skills_middleware, "get_dependency_map", fake_get_dependency_map)
monkeypatch.setattr(skills_middleware, "_list_skills_from_db", fake_list_skills_from_db)
context = SimpleNamespace(skills=["alpha", "missing"])
scope = await resolve_runtime_skills_for_context(context)
assert scope == {
"context_skills": ["alpha"],
"prompt_skills": ["alpha", "beta"],
"readable_skills": ["alpha", "beta"],
}
assert scope["context_skills"] == ["alpha"]
assert scope["prompt_skills"] == ["alpha", "beta"]
assert scope["readable_skills"] == ["alpha", "beta"]
assert set(scope["runtime_skill_metadata"]) == {"alpha", "beta"}
assert scope["runtime_skill_dependency_map"]["alpha"]["skills"] == ["beta"]
@pytest.mark.asyncio
async def test_skills_prompt_uses_prepared_prompt_skills(monkeypatch):
async def fake_get_prompt_metadata(db=None):
del db
return {
async def test_skills_prompt_uses_prepared_prompt_skills():
context = SimpleNamespace(
system_prompt="base",
skills=["configured-only"],
_prompt_skills=["alpha"],
_runtime_skill_metadata={
"alpha": {
"name": "Alpha",
"description": "alpha desc",
@ -47,14 +63,7 @@ async def test_skills_prompt_uses_prepared_prompt_skills(monkeypatch):
"description": "should not appear",
"path": "/home/gem/skills/configured-only/SKILL.md",
},
}
monkeypatch.setattr(skills_middleware, "get_prompt_metadata", fake_get_prompt_metadata)
context = SimpleNamespace(
system_prompt="base",
skills=["configured-only"],
_prompt_skills=["alpha"],
},
)
await SkillsMiddleware().abefore_agent({}, SimpleNamespace(context=context))
@ -68,14 +77,6 @@ async def test_skills_prompt_uses_prepared_prompt_skills(monkeypatch):
@pytest.mark.asyncio
async def test_awrap_model_call_mounts_dependencies_only_for_readable_activated_skills(monkeypatch):
async def fake_get_dependency_map(db=None):
del db
return {
"alpha": {"tools": ["tool-a"], "mcps": [], "skills": []},
"beta": {"tools": ["tool-b"], "mcps": [], "skills": []},
}
monkeypatch.setattr(skills_middleware, "get_dependency_map", fake_get_dependency_map)
monkeypatch.setattr(
skills_middleware,
"get_all_tool_instances",
@ -84,7 +85,16 @@ async def test_awrap_model_call_mounts_dependencies_only_for_readable_activated_
class FakeRequest:
def __init__(self, tools=None):
self.runtime = SimpleNamespace(context=SimpleNamespace(_readable_skills=["alpha"], mcps=[]))
self.runtime = SimpleNamespace(
context=SimpleNamespace(
_readable_skills=["alpha"],
_runtime_skill_dependency_map={
"alpha": {"tools": ["tool-a"], "mcps": [], "skills": []},
"beta": {"tools": ["tool-b"], "mcps": [], "skills": []},
},
mcps=[],
)
)
self.state = {"activated_skills": ["alpha", "beta"]}
self.tools = tools or []

View File

@ -3,159 +3,187 @@ from __future__ import annotations
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from server.routers.skill_router import skills
from server.routers.skill_router import skills, user_skills
from server.utils.auth_middleware import get_admin_user, get_db, get_required_user
from yuxi.storage.postgres.models_business import Skill, User
def _build_app(*, allow_admin: bool = True) -> FastAPI:
def _build_app(*, role: str = "admin") -> FastAPI:
app = FastAPI()
app.include_router(skills, prefix="/api")
app.include_router(user_skills, prefix="/api")
async def fake_db():
return None
async def fake_admin_user():
if not allow_admin:
raise HTTPException(status_code=403, detail="需要管理员权限")
return User(
username="admin",
uid="admin",
password_hash="x",
role="admin",
)
async def fake_required_user():
return User(
username="admin" if allow_admin else "user",
user_id="admin" if allow_admin else "user",
username=role,
uid=role,
password_hash="x",
role="admin" if allow_admin else "user",
role=role,
department_id=1,
)
async def fake_admin_user():
if role not in {"admin", "superadmin"}:
raise HTTPException(status_code=403, detail="需要管理员权限")
return await fake_required_user()
app.dependency_overrides[get_db] = fake_db
app.dependency_overrides[get_admin_user] = fake_admin_user
app.dependency_overrides[get_required_user] = fake_required_user
app.dependency_overrides[get_admin_user] = fake_admin_user
return app
def test_list_skills_route_returns_data(monkeypatch):
async def fake_list_skills(_db):
return [
Skill(
slug="demo",
name="demo",
description="demo skill",
dir_path="skills/demo",
)
]
def _skill(slug: str = "demo", *, source_type: str = "upload", created_by: str = "admin") -> 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,
created_by=created_by,
updated_by=created_by,
)
monkeypatch.setattr("server.routers.skill_router.list_skills", fake_list_skills)
app = _build_app()
client = TestClient(app)
def test_list_manageable_skills_route_returns_allowed_levels(monkeypatch):
async def fake_list_manageable_skills(_db, user):
assert user.uid == "admin"
return [_skill()]
monkeypatch.setattr("server.routers.skill_router.list_manageable_skills", fake_list_manageable_skills)
client = TestClient(_build_app())
resp = client.get("/api/system/skills")
assert resp.status_code == 200, resp.text
payload = resp.json()
assert payload["success"] is True
assert payload["data"][0]["slug"] == "demo"
assert payload["allowed_access_levels"] == ["global", "department", "user"]
def test_list_accessible_skills_route(monkeypatch):
async def fake_list_accessible_skills(_db, user):
assert user.uid == "user"
return [_skill(created_by="user")]
monkeypatch.setattr("server.routers.skill_router.list_accessible_skills", fake_list_accessible_skills)
client = TestClient(_build_app(role="user"))
resp = client.get("/api/skills/accessible")
assert resp.status_code == 200, resp.text
payload = resp.json()
assert payload["success"] is True
assert payload["data"][0]["slug"] == "demo"
def test_skill_management_requires_admin():
app = _build_app(allow_admin=False)
client = TestClient(app)
def test_prepare_skill_upload_route(monkeypatch):
captured: dict[str, object] = {}
dependency_resp = client.get("/api/system/skills/dependency-options")
assert dependency_resp.status_code == 403
import_resp = client.post(
"/api/system/skills/import",
files={"file": ("demo.zip", b"not zip", "application/zip")},
)
assert import_resp.status_code == 403
delete_resp = client.delete("/api/system/skills/demo")
assert delete_resp.status_code == 403
def test_import_skill_route_accepts_skill_md(monkeypatch):
captured: dict[str, str] = {}
async def fake_import_skill_zip(_db, *, filename, file_bytes, created_by):
async def fake_prepare_skill_upload(_db, *, filename, file_bytes, operator):
captured["filename"] = filename
captured["file_bytes"] = file_bytes.decode("utf-8")
captured["created_by"] = created_by
return Skill(
slug="demo",
name="demo",
description="demo skill",
dir_path="skills/demo",
created_by=created_by,
updated_by=created_by,
)
captured["operator_uid"] = operator.uid
return {"draft_id": "draft-1", "items": [{"slug": "demo", "success": True}]}
monkeypatch.setattr("server.routers.skill_router.import_skill_zip", fake_import_skill_zip)
app = _build_app()
client = TestClient(app)
monkeypatch.setattr("server.routers.skill_router.prepare_skill_upload", fake_prepare_skill_upload)
client = TestClient(_build_app(role="user"))
resp = client.post(
"/api/system/skills/import",
"/api/skills/import/prepare",
files={"file": ("SKILL.md", b"---\nname: demo\ndescription: demo skill\n---\n", "text/markdown")},
)
assert resp.status_code == 200, resp.text
assert captured["filename"] == "SKILL.md"
assert "name: demo" in captured["file_bytes"]
assert captured["created_by"] == "admin"
assert resp.json()["data"]["draft_id"] == "draft-1"
assert captured == {
"filename": "SKILL.md",
"file_bytes": "---\nname: demo\ndescription: demo skill\n---\n",
"operator_uid": "user",
}
def test_update_skill_file_passes_operator(monkeypatch):
def test_remote_skill_prepare_and_confirm_routes(monkeypatch):
captured: dict[str, object] = {}
async def fake_prepare_remote_skill_install(_db, *, source, skills, operator):
captured["prepare"] = {"source": source, "skills": skills, "operator_uid": operator.uid}
return {"draft_id": "draft-remote", "items": [{"slug": "frontend-design", "success": True}]}
async def fake_confirm_skill_install_draft(_db, *, draft_id, share_config, operator):
captured["confirm"] = {"draft_id": draft_id, "share_config": share_config, "operator_uid": operator.uid}
return [{"slug": "frontend-design", "success": True}]
monkeypatch.setattr("server.routers.skill_router.prepare_remote_skill_install", fake_prepare_remote_skill_install)
monkeypatch.setattr("server.routers.skill_router.confirm_skill_install_draft", fake_confirm_skill_install_draft)
client = TestClient(_build_app(role="user"))
prepare_resp = client.post(
"/api/skills/remote/prepare",
json={"source": "anthropics/skills", "skills": ["frontend-design"]},
)
confirm_resp = client.post(
"/api/skills/install-drafts/draft-remote/confirm",
json={"share_config": {"access_level": "user", "department_ids": [], "user_uids": ["user"]}},
)
assert prepare_resp.status_code == 200, prepare_resp.text
assert confirm_resp.status_code == 200, confirm_resp.text
assert captured["prepare"] == {
"source": "anthropics/skills",
"skills": ["frontend-design"],
"operator_uid": "user",
}
assert captured["confirm"]["draft_id"] == "draft-remote"
assert captured["confirm"]["operator_uid"] == "user"
def test_discard_skill_draft_route(monkeypatch):
captured: dict[str, str] = {}
async def fake_update_skill_file(_db, *, slug, relative_path, content, updated_by):
captured["slug"] = slug
captured["relative_path"] = relative_path
captured["content"] = content
captured["updated_by"] = updated_by
async def fake_discard_skill_install_draft(*, draft_id, operator):
captured["draft_id"] = draft_id
captured["operator_uid"] = operator.uid
monkeypatch.setattr("server.routers.skill_router.update_skill_file", fake_update_skill_file)
monkeypatch.setattr("server.routers.skill_router.discard_skill_install_draft", fake_discard_skill_install_draft)
app = _build_app()
client = TestClient(app)
client = TestClient(_build_app(role="user"))
resp = client.delete("/api/skills/install-drafts/draft-1")
resp = client.put(
"/api/system/skills/demo/file",
json={
"path": "SKILL.md",
"content": "---\nname: demo\ndescription: demo\n---\n# Demo\n",
},
)
assert resp.status_code == 200, resp.text
assert captured["slug"] == "demo"
assert captured["relative_path"] == "SKILL.md"
assert captured["updated_by"] == "admin"
assert captured == {"draft_id": "draft-1", "operator_uid": "user"}
def test_dependency_options_route(monkeypatch):
async def fake_get_skill_dependency_options(_db):
return {
"tools": ["calculator"],
"mcps": ["mcp-a"],
"skills": ["demo"],
}
def test_dependency_options_route_checks_manage_permission(monkeypatch):
captured: dict[str, object] = {}
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_get_skill_dependency_options(_db, user, slug=None):
captured["options"] = {"slug": slug, "operator_uid": user.uid}
return {"tools": [{"slug": "calculator", "name": "Calculator"}], "mcps": ["mcp-a"], "skills": ["other"]}
monkeypatch.setattr("server.routers.skill_router.get_manageable_skill_or_raise", fake_get_manageable_skill_or_raise)
monkeypatch.setattr("server.routers.skill_router.get_skill_dependency_options", fake_get_skill_dependency_options)
app = _build_app()
client = TestClient(app)
resp = client.get("/api/system/skills/dependency-options")
client = TestClient(_build_app())
resp = client.get("/api/system/skills/dependency-options?slug=demo")
assert resp.status_code == 200, resp.text
payload = resp.json()
assert payload["success"] is True
assert payload["data"]["tools"] == ["calculator"]
assert resp.json()["data"]["skills"] == ["other"]
assert captured["manageable"] == {"slug": "demo", "operator_uid": "admin"}
assert captured["options"] == {"slug": "demo", "operator_uid": "admin"}
def test_update_skill_dependencies_route(monkeypatch):
def test_update_skill_dependencies_route_passes_operator(monkeypatch):
captured: dict[str, object] = {}
async def fake_update_skill_dependencies(
@ -165,27 +193,18 @@ def test_update_skill_dependencies_route(monkeypatch):
tool_dependencies,
mcp_dependencies,
skill_dependencies,
updated_by,
operator,
):
captured["slug"] = slug
captured["tool_dependencies"] = tool_dependencies
captured["mcp_dependencies"] = mcp_dependencies
captured["skill_dependencies"] = skill_dependencies
captured["updated_by"] = updated_by
return Skill(
slug=slug,
name=slug,
description="demo",
dir_path=f"skills/{slug}",
tool_dependencies=tool_dependencies,
mcp_dependencies=mcp_dependencies,
skill_dependencies=skill_dependencies,
)
captured["operator_uid"] = operator.uid
return _skill(slug=slug)
monkeypatch.setattr("server.routers.skill_router.update_skill_dependencies", fake_update_skill_dependencies)
app = _build_app()
client = TestClient(app)
client = TestClient(_build_app())
resp = client.put(
"/api/system/skills/demo/dependencies",
json={
@ -194,88 +213,37 @@ def test_update_skill_dependencies_route(monkeypatch):
"skill_dependencies": ["other-skill"],
},
)
assert resp.status_code == 200, resp.text
assert captured["slug"] == "demo"
assert captured["tool_dependencies"] == ["calculator"]
assert captured["mcp_dependencies"] == ["mcp-a"]
assert captured["skill_dependencies"] == ["other-skill"]
assert captured["updated_by"] == "admin"
assert captured == {
"slug": "demo",
"tool_dependencies": ["calculator"],
"mcp_dependencies": ["mcp-a"],
"skill_dependencies": ["other-skill"],
"operator_uid": "admin",
}
def test_list_remote_skills_route(monkeypatch):
async def fake_list_remote_skills(source: str):
assert source == "anthropics/skills"
return [{"name": "frontend-design", "description": "demo"}]
def test_builtin_routes_require_admin():
client = TestClient(_build_app(role="user"))
monkeypatch.setattr("server.routers.skill_router.list_remote_skills", fake_list_remote_skills)
resp = client.get("/api/system/skills/builtin")
app = _build_app()
client = TestClient(app)
resp = client.post("/api/system/skills/remote/list", json={"source": "anthropics/skills"})
assert resp.status_code == 200, resp.text
payload = resp.json()
assert payload["success"] is True
assert payload["data"] == [{"name": "frontend-design", "description": "demo"}]
assert resp.status_code == 403
def test_install_remote_skill_route(monkeypatch):
def test_sync_builtin_skills_route(monkeypatch):
captured: dict[str, str] = {}
async def fake_install_remote_skill(_db, *, source, skill, created_by):
captured["source"] = source
captured["skill"] = skill
async def fake_init_builtin_skills(_db, *, created_by):
captured["created_by"] = created_by
return Skill(
slug="frontend-design",
name="frontend-design",
description="demo skill",
dir_path="skills/frontend-design",
created_by=created_by,
updated_by=created_by,
)
return [_skill(slug="builtin-demo", source_type="builtin")]
monkeypatch.setattr("server.routers.skill_router.install_remote_skill", fake_install_remote_skill)
monkeypatch.setattr("server.routers.skill_router.init_builtin_skills", fake_init_builtin_skills)
client = TestClient(_build_app())
resp = client.post("/api/system/skills/builtin/sync")
app = _build_app()
client = TestClient(app)
resp = client.post(
"/api/system/skills/remote/install",
json={"source": "anthropics/skills", "skill": "frontend-design"},
)
assert resp.status_code == 200, resp.text
payload = resp.json()
assert payload["success"] is True
assert payload["data"]["slug"] == "frontend-design"
assert captured["source"] == "anthropics/skills"
assert captured["skill"] == "frontend-design"
assert captured["created_by"] == "admin"
def test_list_skills_route_normal_user_success(monkeypatch):
async def fake_list_skills(_db):
return [
Skill(
slug="test-skill",
name="test-skill-name",
description="test skill description",
dir_path="skills/test-skill",
)
]
monkeypatch.setattr("server.routers.skill_router.list_skills", fake_list_skills)
# 普通用户应该也能成功获取列表,但返回的字段应被安全白名单投影过滤
app = _build_app(allow_admin=False)
client = TestClient(app)
resp = client.get("/api/system/skills")
assert resp.status_code == 200, resp.text
payload = resp.json()
assert payload["success"] is True
skill_data = payload["data"][0]
assert skill_data["slug"] == "test-skill"
assert skill_data["name"] == "test-skill-name"
# NOTE: 验证敏感字段如 dir_path、created_by 以及其它元数据已全部被白名单机制过滤,不发生越权泄露
assert "dir_path" not in skill_data
assert "created_by" not in skill_data
assert "updated_by" not in skill_data
assert "content_hash" not in skill_data
assert resp.json()["data"][0]["slug"] == "builtin-demo"
assert captured == {"created_by": "admin"}

View File

@ -10,7 +10,7 @@ import pytest
from yuxi.services import skill_service as svc
from yuxi.services import tool_service
from yuxi.storage.postgres.models_business import Skill
from yuxi.storage.postgres.models_business import Skill, User
def _build_zip(files: dict[str, str]) -> bytes:
@ -21,6 +21,10 @@ def _build_zip(files: dict[str, str]) -> bytes:
return buf.getvalue()
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_parse_skill_markdown_ok():
content = "---\nname: demo-skill\ndescription: demo description\n---\n# Demo\n"
name, desc, meta = svc._parse_skill_markdown(content)
@ -93,12 +97,15 @@ async def test_get_skill_dependency_options(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(svc, "get_enabled_mcp_server_slugs", fake_get_enabled_mcp_server_slugs)
async def fake_list_skill_slugs(_db):
user = SimpleNamespace(uid="user")
async def fake_list_skill_slugs(_db, *, user):
assert user.uid == "user"
return ["alpha", "beta"]
monkeypatch.setattr(svc, "list_skill_slugs", fake_list_skill_slugs)
result = await svc.get_skill_dependency_options(None)
result = await svc.get_skill_dependency_options(None, user)
assert result["tools"] == [{"slug": "calculator", "name": "Calculator"}, {"slug": "search", "name": "Search"}]
assert result["mcps"] == ["mcp-a", "mcp-b"]
assert result["skills"] == ["alpha", "beta"]
@ -113,7 +120,7 @@ def test_resolve_relative_path_blocks_traversal(tmp_path: Path):
@pytest.mark.asyncio
async def test_import_skill_zip_conflict_rewrite_name(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
async def test_skill_upload_prepare_confirm_rewrites_conflicting_name(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
class FakeRepo:
@ -126,30 +133,9 @@ async def test_import_skill_zip_conflict_rewrite_name(tmp_path: Path, monkeypatc
async def exists_slug(self, slug: str) -> bool:
return slug in self.__class__.existing_slugs
async def create(
self,
*,
slug: str,
name: str,
description: str,
tool_dependencies: list[str] | None,
mcp_dependencies: list[str] | None,
skill_dependencies: list[str] | None,
dir_path: str,
created_by: str | None,
) -> Skill:
item = Skill(
slug=slug,
name=name,
description=description,
tool_dependencies=tool_dependencies or [],
mcp_dependencies=mcp_dependencies or [],
skill_dependencies=skill_dependencies or [],
dir_path=dir_path,
created_by=created_by,
updated_by=created_by,
)
self.__class__.existing_slugs.add(slug)
async def create(self, **kwargs) -> Skill:
item = Skill(**kwargs, updated_by=kwargs["created_by"])
self.__class__.existing_slugs.add(item.slug)
self.__class__.created_item = item
return item
@ -161,22 +147,30 @@ async def test_import_skill_zip_conflict_rewrite_name(tmp_path: Path, monkeypatc
"demo/prompts/system.md": "You are demo skill",
}
)
operator = _user("root")
item = await svc.import_skill_zip(
draft = await svc.prepare_skill_upload(
None,
filename="demo.zip",
file_bytes=zip_bytes,
created_by="root",
operator=operator,
)
results = await svc.confirm_skill_install_draft(
None,
draft_id=draft["draft_id"],
share_config=draft["default_share_config"],
operator=operator,
)
assert item.slug == "demo-v2"
assert item.name == "demo-v2"
assert results[0]["slug"] == "demo-v2"
assert results[0]["success"] is True
assert FakeRepo.created_item.slug == "demo-v2"
skill_md = (tmp_path / "skills" / "demo-v2" / "SKILL.md").read_text(encoding="utf-8")
assert "name: demo-v2" in skill_md
@pytest.mark.asyncio
async def test_import_skill_md_creates_single_file_skill(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
async def test_skill_md_prepare_confirm_creates_single_file_skill(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
class FakeRepo:
@ -188,44 +182,31 @@ async def test_import_skill_md_creates_single_file_skill(tmp_path: Path, monkeyp
async def exists_slug(self, slug: str) -> bool:
return False
async def create(
self,
*,
slug: str,
name: str,
description: str,
tool_dependencies: list[str] | None,
mcp_dependencies: list[str] | None,
skill_dependencies: list[str] | None,
dir_path: str,
created_by: str | None,
) -> Skill:
item = Skill(
slug=slug,
name=name,
description=description,
tool_dependencies=tool_dependencies or [],
mcp_dependencies=mcp_dependencies or [],
skill_dependencies=skill_dependencies or [],
dir_path=dir_path,
created_by=created_by,
updated_by=created_by,
)
async def create(self, **kwargs) -> Skill:
item = Skill(**kwargs, updated_by=kwargs["created_by"])
self.__class__.created_item = item
return item
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
skill_md = "---\nname: demo\ndescription: this is demo\n---\n# Demo\n"
item = await svc.import_skill_zip(
operator = _user("root")
draft = await svc.prepare_skill_upload(
None,
filename="SKILL.md",
file_bytes=skill_md.encode("utf-8"),
created_by="root",
operator=operator,
)
results = await svc.confirm_skill_install_draft(
None,
draft_id=draft["draft_id"],
share_config=draft["default_share_config"],
operator=operator,
)
assert item.slug == "demo"
assert item.name == "demo"
assert results[0]["slug"] == "demo"
assert results[0]["success"] is True
assert FakeRepo.created_item.name == "demo"
assert (tmp_path / "skills" / "demo" / "SKILL.md").read_text(encoding="utf-8") == skill_md
@ -309,7 +290,22 @@ async def test_update_skill_dependencies(monkeypatch: pytest.MonkeyPatch):
slug="alpha",
name="alpha",
description="alpha",
source_type="upload",
dir_path="skills/alpha",
share_config={"access_level": "user", "department_ids": [], "user_uids": ["root"]},
enabled=True,
tool_dependencies=[],
mcp_dependencies=[],
skill_dependencies=[],
)
dependency = Skill(
slug="beta",
name="beta",
description="beta",
source_type="upload",
dir_path="skills/beta",
share_config={"access_level": "user", "department_ids": [], "user_uids": ["root"]},
enabled=True,
tool_dependencies=[],
mcp_dependencies=[],
skill_dependencies=[],
@ -338,18 +334,7 @@ async def test_update_skill_dependencies(monkeypatch: pytest.MonkeyPatch):
pass
async def list_all(self):
return [
item,
Skill(
slug="beta",
name="beta",
description="beta",
dir_path="skills/beta",
tool_dependencies=[],
mcp_dependencies=[],
skill_dependencies=[],
),
]
return [item, dependency]
async def update_dependencies(
self,
@ -369,7 +354,11 @@ async def test_update_skill_dependencies(monkeypatch: pytest.MonkeyPatch):
_item.skill_dependencies = skill_dependencies
return _item
async def fake_list_accessible_skills(_db, _operator):
return [item, dependency]
monkeypatch.setattr(svc, "get_skill_or_raise", fake_get_skill_or_raise)
monkeypatch.setattr(svc, "list_accessible_skills", fake_list_accessible_skills)
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
updated = await svc.update_skill_dependencies(
@ -378,7 +367,7 @@ async def test_update_skill_dependencies(monkeypatch: pytest.MonkeyPatch):
tool_dependencies=["calculator", "calculator"],
mcp_dependencies=["mcp-a", "mcp-a"],
skill_dependencies=["beta", "beta"],
updated_by="root",
operator=_user("root"),
)
assert captured["tool_dependencies"] == ["calculator"]
assert captured["mcp_dependencies"] == ["mcp-a"]
@ -416,68 +405,53 @@ async def test_init_builtin_skills_create_missing(tmp_path: Path, monkeypatch: p
)
class FakeRepo:
created: list[dict] = []
created_payload: dict | None = None
def __init__(self, _db):
pass
async def get_by_slug(self, slug: str):
assert slug == "reporter"
return None
async def create(
self,
*,
slug: str,
name: str,
description: str,
tool_dependencies: list[str] | None,
mcp_dependencies: list[str] | None,
skill_dependencies: list[str] | None,
dir_path: str,
created_by: str | None,
) -> Skill:
self.__class__.created.append(
{
"slug": slug,
"name": name,
"description": description,
"tool_dependencies": tool_dependencies,
"mcp_dependencies": mcp_dependencies,
"skill_dependencies": skill_dependencies,
"dir_path": dir_path,
"created_by": created_by,
}
)
return Skill(
slug=slug,
name=name,
description=description,
dir_path=dir_path,
tool_dependencies=tool_dependencies or [],
mcp_dependencies=mcp_dependencies or [],
skill_dependencies=skill_dependencies or [],
created_by=created_by,
updated_by=created_by,
)
async def create(self, **kwargs) -> Skill:
self.__class__.created_payload = kwargs
return Skill(**kwargs, updated_by=kwargs["created_by"])
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
await svc.init_builtin_skills(None)
items = await svc.init_builtin_skills(None)
assert FakeRepo.created == []
assert not (tmp_path / "skills" / "reporter").exists()
assert len(items) == 1
assert items[0].slug == "reporter"
assert FakeRepo.created_payload["source_type"] == "builtin"
assert FakeRepo.created_payload["share_config"] == svc.BUILTIN_SKILL_SHARE_CONFIG
assert FakeRepo.created_payload["enabled"] is True
assert FakeRepo.created_payload["created_by"] == "system"
assert FakeRepo.created_payload["tool_dependencies"] == ["mysql_query"]
assert FakeRepo.created_payload["mcp_dependencies"] == ["charts"]
assert FakeRepo.created_payload["skill_dependencies"] == ["common-report"]
assert (tmp_path / "skills" / "reporter" / "SKILL.md").exists()
assert (tmp_path / "skills" / "reporter" / "prompts" / "system.md").read_text(encoding="utf-8") == "prompt"
@pytest.mark.asyncio
async def test_init_builtin_skills_updates_existing_record(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
async def test_init_builtin_skills_updates_existing_record_and_preserves_disabled(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
source_dir = tmp_path / "builtin-skills" / "reporter"
source_dir.mkdir(parents=True, exist_ok=True)
(source_dir / "SKILL.md").write_text(
"---\nname: reporter\ndescription: old\n---\n# SQL Reporter\n",
"---\nname: reporter\ndescription: new markdown description\n---\n# SQL Reporter\n",
encoding="utf-8",
)
(source_dir / "prompt.md").write_text("new builtin content", encoding="utf-8")
target_dir = tmp_path / "skills" / "reporter"
target_dir.mkdir(parents=True, exist_ok=True)
(target_dir / "prompt.md").write_text("old content", encoding="utf-8")
monkeypatch.setattr(
svc,
@ -487,6 +461,7 @@ async def test_init_builtin_skills_updates_existing_record(tmp_path: Path, monke
slug="reporter",
source_dir=source_dir,
description="new description",
version="1.0.1",
tool_dependencies=("mysql_query",),
mcp_dependencies=("charts",),
skill_dependencies=(),
@ -499,327 +474,18 @@ async def test_init_builtin_skills_updates_existing_record(tmp_path: Path, monke
name="reporter",
description="old description",
dir_path="skills/reporter",
source_type="builtin",
tool_dependencies=[],
mcp_dependencies=[],
skill_dependencies=[],
share_config={"access_level": "global", "department_ids": [], "user_uids": []},
enabled=False,
version="1.0.0",
content_hash="old-hash",
created_by="system",
updated_by="system",
)
captured: dict[str, list[str] | str | None] = {}
class FakeRepo:
def __init__(self, _db):
pass
async def get_by_slug(self, slug: str):
return existing_item
async def update_metadata(
self,
item: Skill,
*,
name: str,
description: str,
updated_by: str | None,
) -> Skill:
item.name = name
item.description = description
captured["name"] = name
captured["description"] = description
captured["updated_by"] = updated_by
return item
async def update_dependencies(
self,
item: Skill,
*,
tool_dependencies: list[str],
mcp_dependencies: list[str],
skill_dependencies: list[str],
updated_by: str | None,
) -> Skill:
item.tool_dependencies = tool_dependencies
item.mcp_dependencies = mcp_dependencies
item.skill_dependencies = skill_dependencies
captured["tool_dependencies"] = tool_dependencies
captured["mcp_dependencies"] = mcp_dependencies
captured["skill_dependencies"] = skill_dependencies
captured["updated_by_deps"] = updated_by
return item
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
await svc.init_builtin_skills(None, created_by="release-bot")
assert not (tmp_path / "skills" / "reporter").exists()
assert captured == {}
def test_compute_dir_hash_stable(tmp_path: Path):
source_dir = tmp_path / "skill"
(source_dir / "nested").mkdir(parents=True, exist_ok=True)
(source_dir / "SKILL.md").write_text("hello", encoding="utf-8")
(source_dir / "nested" / "prompt.md").write_text("world", encoding="utf-8")
assert svc._compute_dir_hash(source_dir) == svc._compute_dir_hash(source_dir)
def test_compute_dir_hash_changes_on_content_change(tmp_path: Path):
source_dir = tmp_path / "skill"
source_dir.mkdir(parents=True, exist_ok=True)
target_file = source_dir / "SKILL.md"
target_file.write_text("hello", encoding="utf-8")
first_hash = svc._compute_dir_hash(source_dir)
target_file.write_text("updated", encoding="utf-8")
second_hash = svc._compute_dir_hash(source_dir)
assert first_hash != second_hash
def test_compute_dir_hash_does_not_use_read_bytes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
source_dir = tmp_path / "skill"
source_dir.mkdir(parents=True, exist_ok=True)
(source_dir / "SKILL.md").write_text("hello", encoding="utf-8")
def fail_read_bytes(self: Path) -> bytes:
raise AssertionError("read_bytes should not be used")
monkeypatch.setattr(Path, "read_bytes", fail_read_bytes)
assert svc._compute_dir_hash(source_dir)
def test_builtin_skill_specs_include_deep_reporter():
specs = svc.list_builtin_skill_specs()
deep_reporter = next(item for item in specs if item["slug"] == "deep-reporter")
assert deep_reporter["name"] == "deep-reporter"
assert "深度" in deep_reporter["description"]
assert deep_reporter["source_dir"].is_dir()
assert (deep_reporter["source_dir"] / "SKILL.md").is_file()
@pytest.mark.asyncio
async def test_install_builtin_skill_ok(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
source_dir = tmp_path / "builtin" / "reporter"
source_dir.mkdir(parents=True, exist_ok=True)
(source_dir / "SKILL.md").write_text(
"---\nname: reporter\ndescription: SQL report\n---\n# SQL Reporter\n",
encoding="utf-8",
)
(source_dir / "prompt.md").write_text("prompt", encoding="utf-8")
monkeypatch.setattr(
svc,
"list_builtin_skill_specs",
lambda: [
{
"slug": "reporter",
"name": "reporter",
"description": "SQL report",
"version": "1.0.0",
"tool_dependencies": ["mysql_query"],
"mcp_dependencies": ["charts"],
"skill_dependencies": [],
"content_hash": "hash-v1",
"source_dir": source_dir,
}
],
)
monkeypatch.setattr(
svc,
"get_builtin_skill_specs",
lambda: [SimpleNamespace(slug="reporter", source_dir=source_dir)],
)
class FakeRepo:
created_payload: dict | None = None
def __init__(self, _db):
pass
async def get_by_slug(self, slug: str):
assert slug == "reporter"
return None
async def create(self, **kwargs):
self.__class__.created_payload = kwargs
return Skill(**kwargs, updated_by=kwargs["created_by"])
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
item = await svc.install_builtin_skill(None, "reporter", installed_by="root")
assert item.slug == "reporter"
assert item.is_builtin is True
assert item.version == "1.0.0"
assert item.content_hash == "hash-v1"
assert (tmp_path / "skills" / "reporter" / "SKILL.md").exists()
assert FakeRepo.created_payload["created_by"] == "root"
@pytest.mark.asyncio
async def test_install_builtin_skill_already_installed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
source_dir = tmp_path / "builtin" / "reporter"
source_dir.mkdir(parents=True, exist_ok=True)
(source_dir / "SKILL.md").write_text(
"---\nname: reporter\ndescription: SQL report\n---\n# SQL Reporter\n",
encoding="utf-8",
)
monkeypatch.setattr(
svc,
"list_builtin_skill_specs",
lambda: [
{
"slug": "reporter",
"name": "reporter",
"description": "SQL report",
"version": "1.0.0",
"tool_dependencies": [],
"mcp_dependencies": [],
"skill_dependencies": [],
"content_hash": "hash-v1",
"source_dir": source_dir,
}
],
)
monkeypatch.setattr(
svc,
"get_builtin_skill_specs",
lambda: [SimpleNamespace(slug="reporter", source_dir=source_dir)],
)
class FakeRepo:
def __init__(self, _db):
pass
async def get_by_slug(self, slug: str):
return Skill(slug=slug, name=slug, description="installed", dir_path=f"skills/{slug}")
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
with pytest.raises(ValueError, match="已安装"):
await svc.install_builtin_skill(None, "reporter", installed_by="root")
@pytest.mark.asyncio
async def test_update_builtin_skill_needs_confirm_when_hash_mismatch(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
source_dir = tmp_path / "builtin" / "reporter"
source_dir.mkdir(parents=True, exist_ok=True)
(source_dir / "SKILL.md").write_text(
"---\nname: reporter\ndescription: SQL report\n---\n# SQL Reporter\n",
encoding="utf-8",
)
monkeypatch.setattr(
svc,
"list_builtin_skill_specs",
lambda: [
{
"slug": "reporter",
"name": "reporter",
"description": "SQL report",
"version": "1.0.1",
"tool_dependencies": [],
"mcp_dependencies": [],
"skill_dependencies": [],
"content_hash": "hash-v2",
"source_dir": source_dir,
}
],
)
monkeypatch.setattr(
svc,
"get_builtin_skill_specs",
lambda: [SimpleNamespace(slug="reporter", source_dir=source_dir)],
)
installed = Skill(
slug="reporter",
name="reporter",
description="installed",
dir_path="skills/reporter",
is_builtin=True,
version="1.0.0",
content_hash="hash-v1",
)
class FakeRepo:
def __init__(self, _db):
pass
async def get_by_slug(self, slug: str):
return installed
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
with pytest.raises(svc.BuiltinSkillUpdateConflictError) as exc_info:
await svc.update_builtin_skill(None, "reporter", updated_by="root")
assert exc_info.value.needs_confirm is True
@pytest.mark.asyncio
async def test_update_builtin_skill_force_overwrites(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
source_dir = tmp_path / "builtin" / "reporter"
source_dir.mkdir(parents=True, exist_ok=True)
(source_dir / "SKILL.md").write_text(
"---\nname: reporter\ndescription: builtin new\n---\n# SQL Reporter\n",
encoding="utf-8",
)
(source_dir / "prompt.md").write_text("new builtin content", encoding="utf-8")
target_dir = tmp_path / "skills" / "reporter"
target_dir.mkdir(parents=True, exist_ok=True)
(target_dir / "prompt.md").write_text("old content", encoding="utf-8")
monkeypatch.setattr(
svc,
"list_builtin_skill_specs",
lambda: [
{
"slug": "reporter",
"name": "reporter",
"description": "builtin new",
"version": "1.0.1",
"tool_dependencies": ["mysql_query"],
"mcp_dependencies": ["charts"],
"skill_dependencies": [],
"content_hash": "hash-v2",
"source_dir": source_dir,
}
],
)
monkeypatch.setattr(
svc,
"get_builtin_skill_specs",
lambda: [SimpleNamespace(slug="reporter", source_dir=source_dir)],
)
installed = Skill(
slug="reporter",
name="reporter",
description="old",
dir_path="skills/reporter",
is_builtin=True,
version="1.0.0",
content_hash="hash-v1",
tool_dependencies=[],
mcp_dependencies=[],
skill_dependencies=[],
)
captured: dict[str, object] = {}
class FakeRepo:
@ -827,12 +493,13 @@ async def test_update_builtin_skill_force_overwrites(tmp_path: Path, monkeypatch
pass
async def get_by_slug(self, slug: str):
return installed
assert slug == "reporter"
return existing_item
async def update_metadata(self, item: Skill, *, name: str, description: str, updated_by: str | None):
async def update_metadata(self, item: Skill, *, name: str, description: str, updated_by: str | None) -> Skill:
item.name = name
item.description = description
captured["metadata_updated_by"] = updated_by
captured["metadata"] = {"name": name, "description": description, "updated_by": updated_by}
return item
async def update_dependencies(
@ -843,11 +510,16 @@ async def test_update_builtin_skill_force_overwrites(tmp_path: Path, monkeypatch
mcp_dependencies: list[str],
skill_dependencies: list[str],
updated_by: str | None,
):
) -> Skill:
item.tool_dependencies = tool_dependencies
item.mcp_dependencies = mcp_dependencies
item.skill_dependencies = skill_dependencies
captured["deps_updated_by"] = updated_by
captured["dependencies"] = {
"tool_dependencies": tool_dependencies,
"mcp_dependencies": mcp_dependencies,
"skill_dependencies": skill_dependencies,
"updated_by": updated_by,
}
return item
async def update_builtin_install(
@ -857,23 +529,111 @@ async def test_update_builtin_skill_force_overwrites(tmp_path: Path, monkeypatch
version: str,
content_hash: str,
updated_by: str | None,
):
) -> Skill:
item.version = version
item.content_hash = content_hash
item.source_type = "builtin"
item.share_config = svc.BUILTIN_SKILL_SHARE_CONFIG.copy()
item.updated_by = updated_by
captured["version"] = version
captured["content_hash"] = content_hash
captured["updated_by"] = updated_by
captured["install"] = {"version": version, "content_hash": content_hash, "updated_by": updated_by}
return item
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
item = await svc.update_builtin_skill(None, "reporter", force=True, updated_by="root")
items = await svc.init_builtin_skills(None, created_by="release-bot")
assert item.version == "1.0.1"
assert item.content_hash == "hash-v2"
assert len(items) == 1
assert items[0].enabled is False
assert items[0].version == "1.0.1"
assert (target_dir / "prompt.md").read_text(encoding="utf-8") == "new builtin content"
assert captured["updated_by"] == "root"
assert captured["metadata"] == {
"name": "reporter",
"description": "new description",
"updated_by": "release-bot",
}
assert captured["dependencies"] == {
"tool_dependencies": ["mysql_query"],
"mcp_dependencies": ["charts"],
"skill_dependencies": [],
"updated_by": "release-bot",
}
assert captured["install"]["updated_by"] == "release-bot"
@pytest.mark.asyncio
async def test_init_builtin_skills_rejects_non_builtin_conflict(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
source_dir = tmp_path / "builtin" / "reporter"
source_dir.mkdir(parents=True, exist_ok=True)
(source_dir / "SKILL.md").write_text(
"---\nname: reporter\ndescription: SQL report\n---\n# SQL Reporter\n",
encoding="utf-8",
)
monkeypatch.setattr(
svc,
"list_builtin_skill_specs",
lambda: [
{
"slug": "reporter",
"name": "reporter",
"description": "SQL report",
"version": "1.0.0",
"tool_dependencies": [],
"mcp_dependencies": [],
"skill_dependencies": [],
"content_hash": "hash-v1",
"source_dir": source_dir,
}
],
)
class FakeRepo:
def __init__(self, _db):
pass
async def get_by_slug(self, slug: str):
return Skill(slug=slug, name=slug, description="uploaded", dir_path=f"skills/{slug}", source_type="upload")
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
with pytest.raises(ValueError, match="非内置 skill 冲突"):
await svc.init_builtin_skills(None)
@pytest.mark.asyncio
async def test_update_skill_enabled_allows_builtin(monkeypatch: pytest.MonkeyPatch):
builtin_item = Skill(
slug="reporter",
name="reporter",
description="builtin",
dir_path="skills/reporter",
source_type="builtin",
enabled=True,
)
async def fake_get_manageable_skill_or_raise(_db, user, slug: str):
assert user.uid == "root"
assert slug == "reporter"
return builtin_item
class FakeRepo:
def __init__(self, _db):
pass
async def update_enabled(self, item: Skill, *, enabled: bool, updated_by: str | None):
item.enabled = enabled
item.updated_by = updated_by
return item
monkeypatch.setattr(svc, "get_manageable_skill_or_raise", fake_get_manageable_skill_or_raise)
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
updated = await svc.update_skill_enabled(None, slug="reporter", enabled=False, operator=_user("root"))
assert updated.enabled is False
assert updated.updated_by == "root"
@pytest.mark.asyncio
@ -892,7 +652,7 @@ async def test_builtin_skill_file_edit_blocked(tmp_path: Path, monkeypatch: pyte
name="reporter",
description="builtin",
dir_path="skills/reporter",
is_builtin=True,
source_type="builtin",
)
async def fake_get_skill_or_raise(_db, _slug: str):
@ -962,7 +722,9 @@ async def test_delete_skill_concurrent_lock(tmp_path: Path, monkeypatch: pytest.
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
(tmp_path / "skills" / "concurrent-skill").mkdir(parents=True, exist_ok=True)
item = Skill(slug="concurrent-skill", name="concurrent-skill", description="desc", dir_path="skills/concurrent-skill")
item = Skill(
slug="concurrent-skill", name="concurrent-skill", description="desc", dir_path="skills/concurrent-skill"
)
db_items = {"concurrent-skill": item}
lock_active = asyncio.Lock()
@ -1006,4 +768,3 @@ async def test_delete_skill_concurrent_lock(tmp_path: Path, monkeypatch: pytest.
assert success_count == 1
assert error_count == 1
assert not (tmp_path / "skills" / "concurrent-skill").exists()

View File

@ -29,6 +29,7 @@
- 智能体运行时语义收敛:用户可见的 `AgentConfig` 收敛为数据库持久化的一级 `Agent`,内置 Python Agent 改为智能体后端;聊天、运行任务、恢复审批和文件预览均从线程绑定的 Agent 解析运行时上下文,前端只提交 `agent_id`
- 知识库能力边界收敛:移除 Upload 与 LightRAG 知识库/图谱能力,知识库类型收敛为 Milvus 与只读连接器;知识库 API 统一使用 `/{db_id}/xxx` 形式,并整合 mindmap / eval 等子接口。
- Agent 资源默认选择与权限过滤未显式配置工具、知识库、MCP、Skills、SubAgent 时默认启用当前用户可访问/可用的全部资源显式保存空列表仍表示不启用对应资源Agent 创建前统一完成最终资源权限过滤、知识库 `db_id` 可见范围派生和 Skill prompt/readable 依赖闭包派生。
- Skill 安装与权限模型收敛Skill 元数据使用 `source_type/share_config/enabled` 表达来源、生效范围与启用状态;内置 Skill 启动或同步时自动写入数据库并默认全局启用,上传和远程添加统一改为解析草稿后确认安装,不保留旧直接安装兼容路径。
- 历史兼容层精简:移除 sandbox provisioner `local` 后端别名、ask_user_question 单问题旧协议、JWT 历史默认密钥特殊判断、内置 Skill `SKILLS.md` 文件名回退、运行事件数字 seq 兼容和前端旧字段回退。
- 用户身份命名收敛:原业务登录标识统一改为 `uid`Agent/LangGraph runtime、conversation、agent_run、sandbox 路径和前端用户态均使用字符串 `uid``user_id` 仅保留给外部响应中的数值 `users.id` 或真实外键场景。
@ -46,6 +47,7 @@
- 新增 Milvus 图谱检索链路Query 可召回图谱实体和三元组,结合 Chunk 命中实体构造 seed entity读取 Neo4j 2-hop 子图后用 igraph 执行 PPR最终以 Chunk 为产物并通过 RRF 与原 Chunk 召回融合;检索配置改为 dataclass 元数据生成,支持 `depend_on` 控制重排序和图检索参数展示。
- 收紧用户管理部门隔离:普通管理员创建用户时固定归属本部门,用户列表、访问选项、详情、更新和删除接口均限制在本部门范围内。
- 调整 Agent 资源默认选择与运行时上下文未显式配置工具、知识库、MCP、Skills、SubAgent 时默认启用当前用户可访问/可用的全部资源显式保存空列表仍表示不启用对应资源Agent 创建前统一完成最终资源权限过滤、知识库 `db_id` 可见范围派生和 Skill prompt/readable 依赖闭包派生,聊天运行时与文件系统预览复用同一结果。
- 重构 Skills 权限与安装流程Skill 增加 `source_type/share_config/enabled`,内置 Skill 作为启动同步入库的全局资源,不再保留前端安装/更新状态支持启停但不允许删除上传和远程添加统一为解析草稿后确认生效范围管理端支持编辑生效范围与启停Agent 运行时按当前用户可访问 Skills 派生 prompt/readable 依赖闭包并限制挂载/激活。
- 精简历史兼容层:移除 sandbox provisioner `local` 后端别名、ask_user_question 单问题旧协议、JWT 历史默认密钥特殊判断、内置 Skill `SKILLS.md` 文件名回退、运行事件数字 seq 兼容和前端若干旧字段回退。
- 重构知识库共享权限:`share_config` 改为全局共享、部门共享、指定人可访问三档,部门共享必须包含当前用户部门,指定人可访问必须包含当前用户,并补充权限过滤测试。
- 移除知识库沙盒文件系统映射:不再通过 `/home/gem/kbs` 暴露知识库文件树Agent 继续使用 `query_kb``open_kb_document` 访问知识库内容。

View File

@ -1,47 +1,55 @@
import { apiGet, apiAdminGet, apiAdminPost, apiAdminPut, apiAdminDelete } from './base'
import { apiGet, apiPost, apiDelete, apiAdminGet, apiAdminPost, apiAdminPut, apiAdminDelete } from './base'
const BASE_URL = '/api/system/skills'
const USER_BASE_URL = '/api/skills'
export const listSkills = async () => {
return apiGet(BASE_URL)
}
export const importSkillZip = async (file) => {
export const listAccessibleSkills = async () => {
return apiGet(`${USER_BASE_URL}/accessible`)
}
export const prepareSkillUpload = async (file) => {
const formData = new FormData()
formData.append('file', file)
return apiAdminPost(`${BASE_URL}/import`, formData)
return apiPost(`${USER_BASE_URL}/import/prepare`, formData)
}
export const listRemoteSkills = async (source) => {
return apiAdminPost(`${BASE_URL}/remote/list`, { source })
return apiPost(`${USER_BASE_URL}/remote/list`, { source })
}
export const installRemoteSkill = async (payload) => {
return apiAdminPost(`${BASE_URL}/remote/install`, payload)
}
export const installRemoteSkillsBatch = async (payload) => {
return apiAdminPost(`${BASE_URL}/remote/install-batch`, payload)
export const prepareRemoteSkills = async (payload) => {
return apiPost(`${USER_BASE_URL}/remote/prepare`, payload)
}
export const searchRemoteSkills = async (query) => {
return apiAdminPost(`${BASE_URL}/remote/search`, { query })
return apiPost(`${USER_BASE_URL}/remote/search`, { query })
}
export const getSkillDependencyOptions = async () => {
return apiAdminGet(`${BASE_URL}/dependency-options`)
export const confirmSkillInstallDraft = async (draftId, shareConfig) => {
return apiPost(`${USER_BASE_URL}/install-drafts/${encodeURIComponent(draftId)}/confirm`, {
share_config: shareConfig
})
}
export const discardSkillInstallDraft = async (draftId) => {
return apiDelete(`${USER_BASE_URL}/install-drafts/${encodeURIComponent(draftId)}`)
}
export const getSkillDependencyOptions = async (slug) => {
const query = slug ? `?slug=${encodeURIComponent(slug)}` : ''
return apiAdminGet(`${BASE_URL}/dependency-options${query}`)
}
export const listBuiltinSkills = async () => {
return apiAdminGet(`${BASE_URL}/builtin`)
}
export const installBuiltinSkill = async (slug) => {
return apiAdminPost(`${BASE_URL}/builtin/${encodeURIComponent(slug)}/install`)
}
export const updateBuiltinSkill = async (slug, force = false) => {
return apiAdminPost(`${BASE_URL}/builtin/${encodeURIComponent(slug)}/update`, { force })
export const syncBuiltinSkills = async () => {
return apiAdminPost(`${BASE_URL}/builtin/sync`)
}
export const getSkillTree = async (slug) => {
@ -66,6 +74,16 @@ export const updateSkillDependencies = async (slug, payload) => {
return apiAdminPut(`${BASE_URL}/${encodeURIComponent(slug)}/dependencies`, payload)
}
export const updateSkillShareConfig = async (slug, shareConfig) => {
return apiAdminPut(`${BASE_URL}/${encodeURIComponent(slug)}/share-config`, {
share_config: shareConfig
})
}
export const updateSkillEnabled = async (slug, enabled) => {
return apiAdminPut(`${BASE_URL}/${encodeURIComponent(slug)}/enabled`, { enabled })
}
export const deleteSkillFile = async (slug, path) => {
return apiAdminDelete(
`${BASE_URL}/${encodeURIComponent(slug)}/file?path=${encodeURIComponent(path)}`
@ -86,20 +104,23 @@ export const deleteSkillsBatch = async (slugs) => {
export const skillApi = {
listSkills,
importSkillZip,
listAccessibleSkills,
prepareSkillUpload,
listRemoteSkills,
installRemoteSkill,
installRemoteSkillsBatch,
prepareRemoteSkills,
searchRemoteSkills,
confirmSkillInstallDraft,
discardSkillInstallDraft,
getSkillDependencyOptions,
listBuiltinSkills,
installBuiltinSkill,
updateBuiltinSkill,
syncBuiltinSkills,
getSkillTree,
getSkillFile,
createSkillFile,
updateSkillFile,
updateSkillDependencies,
updateSkillShareConfig,
updateSkillEnabled,
deleteSkillFile,
exportSkill,
deleteSkill,

View File

@ -90,7 +90,13 @@
</div>
</div>
<div class="file-content" :class="{contentClass, 'is-iframe-preview': file?.previewType === 'pdf' || (isHtmlFile && htmlPreviewMode === 'render') }">
<div
class="file-content"
:class="[
contentClass,
{ 'is-iframe-preview': file?.previewType === 'pdf' || (isHtmlFile && htmlPreviewMode === 'render') }
]"
>
<div v-if="canEdit && editMode === 'edit'" class="edit-floating-actions">
<span v-if="draftChanged" class="edit-status-badge">修改未保存</span>
<button

View File

@ -45,7 +45,7 @@
:aria-label="treePaneVisible ? '隐藏文件列表' : '查看文件列表'"
@click="toggleFileTree"
>
<FolderKanban :size="15" />
<Folders :size="15" />
</button>
<button class="header-action-btn" title="刷新" aria-label="刷新" @click="emitRefresh">
<RefreshCw :size="15" />
@ -130,7 +130,7 @@
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { Download, FolderKanban, RefreshCw, Trash2, X } from 'lucide-vue-next'
import { Download, Folders, RefreshCw, Trash2, X } from 'lucide-vue-next'
import { Modal, message } from 'ant-design-vue'
import FileTreeComponent from '@/components/FileTreeComponent.vue'
import AgentFilePreview from '@/components/AgentFilePreview.vue'

View File

@ -5,7 +5,7 @@
<template v-if="!isBatchDeleteMode">
<a-button
@click="isBatchDeleteMode = true"
:disabled="loading || importing || filteredInstalledSkills.length === 0"
:disabled="loading || importing || filteredDeletableSkills.length === 0"
class="lucide-icon-btn"
>
<span>批量管理</span>
@ -54,17 +54,19 @@
</template>
</PageShoulder>
<div
v-if="filteredInstalledSkills.length === 0 && filteredUninstalledBuiltinSkills.length === 0"
class="extension-card-grid-empty-state"
>
<a-empty :image="false" description="无匹配技能" />
<div v-if="filteredInstalledSkills.length === 0" class="extension-card-grid-empty-state skill-empty-state">
<div class="skill-empty-card">
<div class="skill-empty-icon">
<BookMarked :size="22" />
</div>
<div class="skill-empty-title">{{ searchQuery ? '没有匹配的 Skill' : '还没有添加 Skill' }}</div>
<div class="skill-empty-desc">
{{ searchQuery ? '换个关键词试试,或清空搜索条件。' : '可以从远程仓库安装,或上传本地 Skill 文件。' }}
</div>
</div>
</div>
<template v-else>
<div v-if="filteredInstalledSkills.length" class="extension-section-header">
已添加 Skills
</div>
<ExtensionCardGrid>
<div
v-for="skill in filteredInstalledSkills"
@ -76,7 +78,7 @@
}"
>
<a-checkbox
v-if="isBatchDeleteMode"
v-if="isBatchDeleteMode && skill.sourceType !== 'builtin'"
:checked="selectedCardSlugs.includes(skill.slug)"
@change="handleToggleCardSelect(skill.slug)"
class="card-select-checkbox"
@ -86,30 +88,13 @@
:description="skill.description || '暂无描述'"
:default-icon="BookMarkedIcon"
:tags="skillTags(skill)"
:status="{ label: '已安装', level: 'success' }"
:status="skill.status"
@click="handleCardClick(skill)"
:class="{ 'card-clickable-select': isBatchDeleteMode }"
>
</InfoCard>
</div>
</ExtensionCardGrid>
<div v-if="filteredUninstalledBuiltinSkills.length" class="extension-section-header">
可添加 Skills
</div>
<ExtensionCardGrid v-if="filteredUninstalledBuiltinSkills.length">
<InfoCard
v-for="skill in filteredUninstalledBuiltinSkills"
:key="skill.slug"
:title="skill.name"
:description="skill.description || '暂无描述'"
:default-icon="BookMarkedIcon"
:tags="[{ name: '内置' }]"
action-label="安装"
@action-click="handleInstallBuiltin(skill)"
>
</InfoCard>
</ExtensionCardGrid>
</template>
<a-modal
@ -122,8 +107,7 @@
:keyboard="!installingRemoteSkill"
>
<div class="remote-install-panel modal-mode">
<!-- 阶段一选择配置阶段 -->
<div v-if="!remoteInstallProgress.visible" class="install-setup-stage">
<div class="install-setup-stage">
<a-tabs
v-model:activeKey="activeTab"
:disabled="installingRemoteSkill"
@ -383,68 +367,58 @@
"
@click="startInstallRemoteSkills"
>
开始安装 (已选
解析并确认 (已选
{{ activeTab === 'repo' ? selectedRepoSkills.length : selectedSearchSkills.length }}
)
</a-button>
</div>
</div>
</div>
</a-modal>
<!-- 阶段二安装进度展示阶段 -->
<div v-else class="install-run-stage">
<div class="install-progress-section">
<div class="progress-info-row">
<span class="progress-title">批量安装进度</span>
<span class="progress-text"
>{{ remoteInstallProgress.completed }} / {{ remoteInstallProgress.total }}</span
>
</div>
<a-progress
:percent="
Math.round(
(remoteInstallProgress.completed / Math.max(1, remoteInstallProgress.total)) * 100
)
"
size="small"
:status="remoteInstallProgress.failed > 0 ? 'exception' : 'active'"
/>
<div v-if="remoteInstallProgress.currentSkill" class="progress-detail">
{{ remoteInstallProgress.currentSkill }}
</div>
<!-- 详细报告列表 -->
<div v-if="installReportList.length" class="install-results-report">
<div class="report-title">安装报告</div>
<div class="report-items-container">
<div
v-for="res in installReportList"
:key="res.slug"
class="report-item"
:class="{ success: res.success, fail: !res.success }"
>
<span class="skill-slug">{{ res.slug }}</span>
<span class="install-status">{{
res.success ? '安装成功' : `安装失败: ${res.error}`
}}</span>
</div>
<a-modal
v-model:open="draftConfirmVisible"
title="确认添加 Skill"
width="720px"
:confirm-loading="draftConfirmLoading"
:closable="!draftConfirmLoading"
:mask-closable="!draftConfirmLoading"
:keyboard="!draftConfirmLoading"
ok-text="确认添加"
cancel-text="取消"
@ok="confirmSkillDraft"
@cancel="cancelSkillDraft"
>
<div v-if="pendingDraft" class="skill-draft-confirm-panel">
<div class="draft-source-row">
<span class="draft-source-label">来源</span>
<span>{{ pendingDraft.source || sourceTypeLabel(pendingDraft.source_type) }}</span>
</div>
<div class="draft-items-list">
<div
v-for="item in pendingDraft.items"
:key="`${item.source || pendingDraft.source || 'local'}:${item.slug || item.name}`"
class="draft-item"
:class="{ failed: item.success === false }"
>
<div class="draft-item-main">
<div class="draft-item-title">{{ item.name || item.slug }}</div>
<div class="draft-item-desc">{{ item.description || item.error || '暂无描述' }}</div>
<div v-if="item.warnings?.length" class="draft-item-warning">
{{ item.warnings.join('') }}
</div>
</div>
</div>
<!-- 进度阶段的底部操作区 -->
<div class="modal-footer-actions">
<a-button
v-if="!installingRemoteSkill && remoteInstallProgress.failed > 0"
@click="handleBackToSetup"
>
返回修改
</a-button>
<a-button v-if="!installingRemoteSkill" type="primary" @click="handleCloseAfterInstall">
完成
</a-button>
<span v-else class="installing-hint-text">正在进行后台拉取安装请勿关闭窗口...</span>
<a-tag v-if="item.success === false" color="red">解析失败</a-tag>
<a-tag v-else color="blue">{{ sourceTypeLabel(item.source_type || pendingDraft.source_type) }}</a-tag>
</div>
</div>
<div class="draft-share-title">生效范围</div>
<ShareConfigForm
ref="shareConfigFormRef"
v-model="draftShareConfig"
:auto-select-user-dept="true"
:allowed-access-levels="pendingDraft.allowed_access_levels || ['user']"
/>
</div>
</a-modal>
</div>
@ -459,6 +433,7 @@ 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'
const BookMarkedIcon = BookMarked
@ -474,7 +449,6 @@ const isBatchDeleteMode = ref(false)
const selectedCardSlugs = ref([])
const skills = ref([])
const builtinSkills = ref([])
const remoteInstallModalVisible = ref(false)
const activeTab = ref('repo') // 'repo' 'search'
@ -493,16 +467,12 @@ const searchedSkills = ref([])
const selectedSearchSkills = ref([])
const repoHistory = ref([])
const remoteInstallProgress = reactive({
visible: false,
total: 0,
completed: 0,
success: 0,
failed: 0,
currentSkill: ''
})
const installReportList = ref([])
const allowedSkillAccessLevels = ref(['user'])
const draftConfirmVisible = ref(false)
const draftConfirmLoading = ref(false)
const pendingDraft = ref(null)
const draftShareConfig = ref({ access_level: 'user', department_ids: [], user_uids: [] })
const shareConfigFormRef = ref(null)
const matchesSearch = (skill) => {
if (!searchQuery.value) return true
@ -510,41 +480,23 @@ const matchesSearch = (skill) => {
return skill.name.toLowerCase().includes(q) || skill.slug.toLowerCase().includes(q)
}
const installedSkillCards = computed(() => {
const builtinInstalledMap = new Map(
(builtinSkills.value || [])
.filter((skill) => skill.status !== 'not_installed')
.map((skill) => [
skill.slug,
{
...skill,
sourceType: 'builtin',
sourceLabel: '内置',
status:
skill.status === 'update_available'
? { label: '更新可用', level: 'warning' }
: { label: '已安装', level: 'success' }
}
])
)
const importedInstalled = (skills.value || [])
.filter((skill) => !builtinInstalledMap.has(skill.slug))
.map((skill) => ({
const installedSkillCards = computed(() =>
(skills.value || []).map((skill) => {
const sourceType = skill.source_type || 'upload'
return {
...skill,
sourceType: 'imported',
sourceLabel: '导入',
status: { label: '已上传', level: 'success' }
}))
return [...builtinInstalledMap.values(), ...importedInstalled]
})
sourceType,
sourceLabel: sourceTypeLabel(sourceType),
status:
skill.enabled === false ? { label: '已禁用', level: 'default' } : { label: '已启用', level: 'success' }
}
})
)
const filteredInstalledSkills = computed(() => installedSkillCards.value.filter(matchesSearch))
const filteredUninstalledBuiltinSkills = computed(() => {
return (builtinSkills.value || []).filter(
(skill) => skill.status === 'not_installed' && matchesSearch(skill)
)
})
const filteredDeletableSkills = computed(() =>
filteredInstalledSkills.value.filter((skill) => skill.sourceType !== 'builtin')
)
//
const filteredRepoSkills = computed(() => {
@ -557,16 +509,6 @@ const filteredRepoSkills = computed(() => {
)
})
const resetRemoteInstallState = () => {
remoteInstallProgress.visible = false
remoteInstallProgress.total = 0
remoteInstallProgress.completed = 0
remoteInstallProgress.success = 0
remoteInstallProgress.failed = 0
remoteInstallProgress.currentSkill = ''
installReportList.value = []
}
// //
const handleRepoSelectAll = () => {
selectedRepoSkills.value = filteredRepoSkills.value.map((item) => item.name)
@ -629,6 +571,12 @@ const handleToggleSearchSkill = (item, checked) => {
}
}
const sourceTypeLabel = (sourceType) => {
if (sourceType === 'builtin') return '内置'
if (sourceType === 'remote') return '远程'
return '上传'
}
const skillTags = (skill) => {
if (skill.sourceType === 'builtin') return [{ name: skill.sourceLabel || '内置' }]
return [{ name: skill.sourceLabel || '外部', color: 'blue' }]
@ -647,6 +595,8 @@ const handleCardClick = (skill) => {
}
const handleToggleCardSelect = (slug) => {
const target = installedSkillCards.value.find((skill) => skill.slug === slug)
if (target?.sourceType === 'builtin') return
const idx = selectedCardSlugs.value.indexOf(slug)
if (idx > -1) {
selectedCardSlugs.value.splice(idx, 1)
@ -656,7 +606,9 @@ const handleToggleCardSelect = (slug) => {
}
const handleBatchSelectAll = () => {
selectedCardSlugs.value = filteredInstalledSkills.value.map((skill) => skill.slug)
selectedCardSlugs.value = filteredInstalledSkills.value
.filter((skill) => skill.sourceType !== 'builtin')
.map((skill) => skill.slug)
}
const handleBatchSelectNone = () => {
@ -667,7 +619,7 @@ const handleBatchSelectInvert = () => {
const currentSet = new Set(selectedCardSlugs.value)
const nextSelected = []
filteredInstalledSkills.value.forEach((skill) => {
if (!currentSet.has(skill.slug)) {
if (skill.sourceType !== 'builtin' && !currentSet.has(skill.slug)) {
nextSelected.push(skill.slug)
}
})
@ -680,18 +632,22 @@ const exitBatchDeleteMode = () => {
}
const handleBatchDelete = () => {
if (selectedCardSlugs.value.length === 0) return
const deletableSlugs = selectedCardSlugs.value.filter((slug) => {
const target = installedSkillCards.value.find((skill) => skill.slug === slug)
return target?.sourceType !== 'builtin'
})
if (deletableSlugs.length === 0) return
Modal.confirm({
title: '确定要批量删除选中的技能吗?',
content: `您已选中了 ${selectedCardSlugs.value.length} 个技能。该操作将从数据库和物理磁盘中彻底删除这些技能包,且不可恢复!`,
content: `您已选中了 ${deletableSlugs.length} 个技能。该操作将从数据库和物理磁盘中彻底删除这些技能包,且不可恢复!`,
okText: '确定删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
loading.value = true
try {
const res = await skillApi.deleteSkillsBatch(selectedCardSlugs.value)
const res = await skillApi.deleteSkillsBatch(deletableSlugs)
const results = res?.data || []
const successList = results.filter((r) => r.success)
const failList = results.filter((r) => !r.success)
@ -716,16 +672,9 @@ const handleBatchDelete = () => {
const fetchSkills = async () => {
loading.value = true
try {
const [skillResult, builtinResult] = await Promise.all([
skillApi.listSkills(),
skillApi.listBuiltinSkills()
])
const skillResult = await skillApi.listSkills()
skills.value = skillResult?.data || []
builtinSkills.value = (builtinResult?.data || []).map((item) => ({
...item,
...(item.installed_record || {}),
is_builtin_spec: true
}))
allowedSkillAccessLevels.value = skillResult?.allowed_access_levels || ['user']
} catch {
message.error('加载失败')
} finally {
@ -733,20 +682,6 @@ const fetchSkills = async () => {
}
}
const handleInstallBuiltin = async (record) => {
if (!record?.slug) return
loading.value = true
try {
await skillApi.installBuiltinSkill(record.slug)
await fetchSkills()
message.success('安装成功')
} catch (error) {
message.error(error?.response?.data?.detail || error.message || '安装失败')
} finally {
loading.value = false
}
}
const beforeSkillUpload = (file) => {
const lower = file.name.toLowerCase()
if (!lower.endsWith('.zip') && lower !== 'skill.md') {
@ -756,15 +691,103 @@ const beforeSkillUpload = (file) => {
return true
}
const cloneShareConfig = (config) => ({
access_level: config?.access_level || 'user',
department_ids: [...(config?.department_ids || [])],
user_uids: [...(config?.user_uids || [])]
})
const resetDraftConfirmation = () => {
draftConfirmVisible.value = false
draftConfirmLoading.value = false
pendingDraft.value = null
draftShareConfig.value = { access_level: 'user', department_ids: [], user_uids: [] }
}
const normalizePendingDraft = (draftPayload) => {
const drafts = Array.isArray(draftPayload) ? draftPayload : [draftPayload]
const validDrafts = drafts.filter((item) => item?.draft_id)
const first = validDrafts[0] || {}
return {
...first,
draft_ids: validDrafts.map((item) => item.draft_id),
source: validDrafts.length === 1 ? first.source : `${validDrafts.length} 个来源`,
items: validDrafts.flatMap((draft) =>
(draft.items || []).map((item) => ({
...item,
source: draft.source,
source_type: draft.source_type
}))
),
default_share_config: first.default_share_config || cloneShareConfig(null),
allowed_access_levels: first.allowed_access_levels || allowedSkillAccessLevels.value
}
}
const openDraftConfirmation = async (draftPayload) => {
const draft = normalizePendingDraft(draftPayload)
if (!draft.draft_ids.length || !draft.items.some((item) => item.success !== false)) {
await Promise.allSettled(draft.draft_ids.map((draftId) => skillApi.discardSkillInstallDraft(draftId)))
message.error('没有可添加的 Skill')
return false
}
pendingDraft.value = draft
draftShareConfig.value = cloneShareConfig(draft.default_share_config)
draftConfirmVisible.value = true
return true
}
const cancelSkillDraft = async () => {
if (draftConfirmLoading.value) return
const draftIds = pendingDraft.value?.draft_ids || []
resetDraftConfirmation()
await Promise.allSettled(draftIds.map((draftId) => skillApi.discardSkillInstallDraft(draftId)))
}
const confirmSkillDraft = async () => {
const validation = shareConfigFormRef.value?.validate?.()
if (validation && !validation.valid) {
message.warning(validation.message || '请完善 Skill 生效范围')
return
}
const draftIds = pendingDraft.value?.draft_ids || []
if (!draftIds.length) return
draftConfirmLoading.value = true
try {
const results = []
for (const draftId of draftIds) {
const res = await skillApi.confirmSkillInstallDraft(draftId, draftShareConfig.value)
results.push(...(res?.data || []))
}
const successCount = results.filter((item) => item.success).length
const failedCount = results.length - successCount
if (failedCount === 0) {
message.success(`已添加 ${successCount} 个 Skill`)
} else {
message.warning(`添加完成:成功 ${successCount} 个,失败 ${failedCount}`)
}
remoteInstallModalVisible.value = false
resetDraftConfirmation()
await fetchSkills()
} catch (error) {
message.error(error?.response?.data?.detail || error.message || '确认添加 Skill 失败')
} finally {
draftConfirmLoading.value = false
}
}
const handleImportUpload = async ({ file, onSuccess, onError }) => {
importing.value = true
try {
const result = await skillApi.importSkillZip(file)
message.success('导入完成')
await fetchSkills()
const result = await skillApi.prepareSkillUpload(file)
if (await openDraftConfirmation(result?.data)) {
message.success('解析完成,请确认 Skill 生效范围')
}
onSuccess?.(result)
} catch (e) {
message.error('导入失败')
message.error(e?.response?.data?.detail || e.message || '解析 Skill 失败')
onError?.(e)
} finally {
importing.value = false
@ -773,7 +796,6 @@ const handleImportUpload = async ({ file, onSuccess, onError }) => {
const handleOpenRemoteInstall = () => {
if (!remoteInstallModalVisible.value) {
resetRemoteInstallState()
selectedRepoSkills.value = []
selectedSearchSkills.value = []
remoteSkillOptions.value = []
@ -875,152 +897,48 @@ const handleSearchRemoteSkills = async () => {
}
const startInstallRemoteSkills = async () => {
resetRemoteInstallState()
installingRemoteSkill.value = true
remoteInstallProgress.visible = true
if (activeTab.value === 'repo') {
const source = remoteInstallForm.source.trim()
const skillsToInstall = [...selectedRepoSkills.value]
remoteInstallProgress.total = skillsToInstall.length
remoteInstallProgress.currentSkill = '正在开始下载远程 Skills 并写入系统...'
try {
const result = await skillApi.installRemoteSkillsBatch({
source,
skills: skillsToInstall
})
const results = result?.data || []
results.forEach((r) => {
installReportList.value.push({
slug: r.slug,
success: r.success,
error: r.error || ''
})
if (r.success) {
remoteInstallProgress.success++
} else {
remoteInstallProgress.failed++
}
remoteInstallProgress.completed++
try {
const drafts = []
if (activeTab.value === 'repo') {
const source = remoteInstallForm.source.trim()
const skillsToInstall = [...selectedRepoSkills.value]
const result = await skillApi.prepareRemoteSkills({ source, skills: skillsToInstall })
drafts.push(result?.data)
} else {
const groups = {}
selectedSearchSkills.value.forEach((item) => {
if (!groups[item.source]) groups[item.source] = []
groups[item.source].push(item.name)
})
await fetchSkills()
if (remoteInstallProgress.failed === 0) {
message.success('所有选中的 Skills 安装成功')
setTimeout(() => {
remoteInstallModalVisible.value = false
}, 1500)
} else {
message.warning(
`安装完成,成功 ${remoteInstallProgress.success} 个,失败 ${remoteInstallProgress.failed}`
)
for (const [source, sourceSkills] of Object.entries(groups)) {
const result = await skillApi.prepareRemoteSkills({ source, skills: sourceSkills })
drafts.push(result?.data)
}
} catch (error) {
message.error(error?.response?.data?.detail || error.message || '远程 Skill 安装失败')
skillsToInstall.forEach((slug) => {
installReportList.value.push({
slug,
success: false,
error: error.message || '安装失败'
})
remoteInstallProgress.failed++
remoteInstallProgress.completed++
})
} finally {
remoteInstallProgress.currentSkill = ''
installingRemoteSkill.value = false
}
} else {
const skillsToInstall = [...selectedSearchSkills.value]
remoteInstallProgress.total = skillsToInstall.length
// source
const groups = {}
skillsToInstall.forEach((item) => {
if (!groups[item.source]) groups[item.source] = []
groups[item.source].push(item.name)
})
const sources = Object.keys(groups)
try {
for (const source of sources) {
const sourceSkills = groups[source]
remoteInstallProgress.currentSkill = `正在下载并安装来自 ${source} 的技能...`
try {
const result = await skillApi.installRemoteSkillsBatch({
source,
skills: sourceSkills
})
const results = result?.data || []
results.forEach((r) => {
installReportList.value.push({
slug: `${source}@${r.slug}`,
success: r.success,
error: r.error || ''
})
if (r.success) {
remoteInstallProgress.success++
} else {
remoteInstallProgress.failed++
}
remoteInstallProgress.completed++
})
} catch (error) {
sourceSkills.forEach((name) => {
installReportList.value.push({
slug: `${source}@${name}`,
success: false,
error: error.message || '安装请求失败'
})
remoteInstallProgress.failed++
remoteInstallProgress.completed++
})
}
}
await fetchSkills()
if (remoteInstallProgress.failed === 0) {
message.success('所有选中的 Skills 安装成功')
setTimeout(() => {
remoteInstallModalVisible.value = false
}, 1500)
} else {
message.warning(
`安装完成,成功 ${remoteInstallProgress.success} 个,失败 ${remoteInstallProgress.failed}`
)
}
} finally {
remoteInstallProgress.currentSkill = ''
installingRemoteSkill.value = false
if (await openDraftConfirmation(drafts)) {
remoteInstallModalVisible.value = false
message.success('解析完成,请确认 Skill 生效范围')
}
} catch (error) {
message.error(error?.response?.data?.detail || error.message || '解析远程 Skill 失败')
} finally {
installingRemoteSkill.value = false
}
}
const handleBackToSetup = () => {
remoteInstallProgress.visible = false
}
const handleCloseAfterInstall = () => {
remoteInstallModalVisible.value = false
resetRemoteInstallState()
selectedRepoSkills.value = []
selectedSearchSkills.value = []
}
watch(activeTab, () => {
selectedRepoSkills.value = []
selectedSearchSkills.value = []
resetRemoteInstallState()
})
watch(remoteInstallModalVisible, (visible) => {
if (!visible && !installingRemoteSkill.value) {
selectedRepoSkills.value = []
selectedSearchSkills.value = []
resetRemoteInstallState()
}
})
@ -1042,20 +960,53 @@ defineExpose({
</style>
<style lang="less" scoped>
.extension-card-grid-empty-state {
background: linear-gradient(180deg, var(--gray-0) 0%, var(--gray-50) 100%);
border: 1px solid var(--gray-150);
border-radius: 12px;
padding: 16px;
&.modal-mode {
border: none;
border-radius: 0;
padding: 0;
background: transparent;
}
.skill-empty-state {
width: 100%;
min-height: 280px;
padding: 40px var(--page-padding);
}
.skill-empty-card {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 220px;
flex-direction: column;
border: 1px dashed var(--gray-150);
border-radius: 16px;
background: linear-gradient(180deg, var(--gray-0) 0%, var(--gray-25) 100%);
color: var(--gray-500);
text-align: center;
}
.skill-empty-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
margin-bottom: 12px;
border-radius: 14px;
background: var(--main-10);
color: var(--main-color);
}
.skill-empty-title {
color: var(--gray-800);
font-size: 15px;
font-weight: 700;
line-height: 22px;
}
.skill-empty-desc {
margin-top: 4px;
color: var(--gray-500);
font-size: 13px;
line-height: 20px;
}
.card-wrapper {
position: relative;
@ -1091,23 +1042,70 @@ defineExpose({
}
}
.remote-install-panel {
.panel-header-text {
.skill-draft-confirm-panel {
display: flex;
flex-direction: column;
gap: 14px;
.draft-source-row {
display: flex;
gap: 8px;
color: var(--gray-700);
font-size: 13px;
}
.draft-source-label,
.draft-share-title {
color: var(--gray-500);
font-weight: 600;
}
.draft-items-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
gap: 8px;
max-height: 260px;
overflow: auto;
}
.desc {
font-size: 12px;
color: var(--gray-500);
a {
color: var(--main-color);
text-decoration: underline;
}
.draft-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 12px;
border: 1px solid var(--gray-150);
border-radius: 10px;
background: var(--gray-0);
&.failed {
border-color: var(--error-200, #ffccc7);
background: var(--error-50, #fff2f0);
}
}
.draft-item-main {
min-width: 0;
}
.draft-item-title {
font-weight: 600;
color: var(--gray-900);
}
.draft-item-desc,
.draft-item-warning {
margin-top: 4px;
font-size: 12px;
color: var(--gray-500);
}
.draft-item-warning {
color: var(--warning-600, #d48806);
}
}
.remote-install-panel {
.repo-input-row {
display: flex;
gap: 8px;
@ -1176,9 +1174,6 @@ defineExpose({
padding: 4px 0 8px 0;
}
.search-form-item {
margin-bottom: 8px !important;
}
.skills-list-section {
margin-top: 12px;
@ -1315,88 +1310,6 @@ defineExpose({
}
}
.install-progress-section {
margin-top: 16px;
padding: 12px;
background: var(--gray-25);
border: 1px solid var(--gray-150);
border-radius: 8px;
.progress-info-row {
display: flex;
justify-content: space-between;
font-size: 13px;
margin-bottom: 8px;
.progress-title {
font-weight: 600;
color: var(--gray-900);
}
.progress-text {
color: var(--gray-600);
}
}
.progress-detail {
margin-top: 8px;
font-size: 12px;
color: var(--main-color);
}
}
.install-results-report {
margin-top: 12px;
.report-title {
font-size: 12px;
font-weight: 600;
color: var(--gray-800);
margin-bottom: 6px;
}
.report-items-container {
max-height: 120px;
overflow-y: auto;
border: 1px solid var(--gray-150);
border-radius: 6px;
background: var(--gray-0);
padding: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.report-item {
display: flex;
justify-content: space-between;
padding: 4px 6px;
border-radius: 4px;
font-size: 12px;
&.success {
background: var(--color-success-10);
.skill-slug {
color: var(--color-success-700);
font-weight: 500;
}
.install-status {
color: var(--color-success-700);
}
}
&.fail {
background: var(--color-error-10);
.skill-slug {
color: var(--color-error-700);
font-weight: 500;
}
.install-status {
color: var(--color-error-700);
}
}
}
}
.modal-footer-actions {
display: flex;

View File

@ -19,22 +19,6 @@
</div>
<div class="detail-actions">
<a-space :size="8">
<button
v-if="currentSkill?.is_builtin_spec && currentSkill?.status === 'not_installed'"
type="button"
@click="handleInstallBuiltin(currentSkill)"
class="lucide-icon-btn extension-panel-action extension-panel-action-primary"
>
<span>安装</span>
</button>
<button
v-if="currentSkill?.is_builtin_spec && currentSkill?.status === 'update_available'"
type="button"
@click="handleUpdateBuiltin(currentSkill)"
class="lucide-icon-btn extension-panel-action extension-panel-action-secondary"
>
<span>更新</span>
</button>
<button
v-if="isInstalledSkill"
type="button"
@ -45,13 +29,13 @@
<span>导出</span>
</button>
<button
v-if="isInstalledSkill"
v-if="isInstalledSkill && !isBuiltinInstalledSkill"
type="button"
@click="confirmDeleteSkill"
class="lucide-icon-btn extension-panel-action extension-panel-action-danger"
>
<Trash2 :size="14" />
<span>{{ isBuiltinInstalledSkill ? '卸载' : '删除' }}</span>
<span>删除</span>
</button>
</a-space>
</div>
@ -59,15 +43,7 @@
<div class="detail-content-wrapper">
<div v-if="currentSkill" class="detail-content-inner">
<div v-if="!isInstalledSkill" class="builtin-uninstalled-state">
<h3>{{ currentSkill.description }}</h3>
<p>版本 {{ currentSkill.version }}</p>
<a-button type="primary" @click="handleInstallBuiltin(currentSkill)"
>安装内置 Skill</a-button
>
</div>
<a-tabs v-else v-model:activeKey="activeTab" class="minimal-tabs">
<a-tabs v-if="isInstalledSkill" v-model:activeKey="activeTab" class="minimal-tabs">
<a-tab-pane key="editor">
<template #tab>
<span class="tab-title"><FileText :size="14" />代码管理</span>
@ -124,6 +100,64 @@
</div>
</a-tab-pane>
<a-tab-pane key="settings">
<template #tab>
<span class="tab-title"><Settings :size="14" />生效范围</span>
</template>
<div class="config-view">
<div class="config-header">
<div class="text">
<h3>共享与启用状态</h3>
<p>控制此 Skill 是否可用以及哪些用户可以选择和运行它</p>
</div>
<a-button
type="primary"
:loading="savingShareConfig"
@click="saveShareConfig"
class="lucide-icon-btn"
>
<Save :size="14" />
<span>保存设置</span>
</a-button>
</div>
<div class="settings-stack">
<section class="settings-card">
<div class="settings-card-main">
<div class="settings-card-title">启用状态</div>
<div class="settings-card-desc">
禁用后此 Skill 不会出现在可选资源中也不会参与 Agent 运行时加载
</div>
</div>
<div class="settings-card-action">
<span class="status-pill" :class="enabledForm ? 'enabled' : 'disabled'">
{{ enabledForm ? '已启用' : '已禁用' }}
</span>
<a-switch v-model:checked="enabledForm" />
</div>
</section>
<section class="settings-card scope-card">
<div class="settings-card-main">
<div class="settings-card-title">生效范围</div>
<div class="settings-card-desc">
控制哪些用户可以选择并在运行时使用此 Skill
</div>
</div>
<div v-if="isBuiltinInstalledSkill" class="readonly-scope-hint">
内置 Skill 固定为全局生效范围可通过启用状态控制是否参与运行时
</div>
<ShareConfigForm
v-else
ref="shareConfigFormRef"
v-model="shareConfigForm"
:auto-select-user-dept="true"
:allowed-access-levels="allowedSkillAccessLevels"
/>
</section>
</div>
</div>
</a-tab-pane>
<a-tab-pane key="dependencies">
<template #tab>
<span class="tab-title"><Layers :size="14" />依赖管理</span>
@ -135,6 +169,7 @@
<p>配置此 Skill 所需的工具MCP 及其他 Skill 依赖</p>
</div>
<a-button
v-if="!isBuiltinInstalledSkill"
type="primary"
:loading="savingDependencies"
@click="saveDependencies"
@ -144,39 +179,101 @@
<span>更新依赖</span>
</a-button>
</div>
<div class="config-form">
<a-form layout="vertical">
<a-form-item label="工具依赖 (Tools)">
<a-select
v-model:value="dependencyForm.tool_dependencies"
mode="multiple"
:options="toolDependencyOptions"
placeholder="选择工具..."
allow-clear
show-search
/>
</a-form-item>
<a-form-item label="MCP 依赖 (Model Context Protocol)">
<a-select
v-model:value="dependencyForm.mcp_dependencies"
mode="multiple"
:options="mcpDependencyOptions"
placeholder="选择 MCP 服务..."
allow-clear
show-search
/>
</a-form-item>
<a-form-item label="Skill 依赖">
<a-select
v-model:value="dependencyForm.skill_dependencies"
mode="multiple"
:options="skillDependencyOptions"
placeholder="选择 Skill..."
allow-clear
show-search
/>
</a-form-item>
</a-form>
<div class="dependency-groups">
<section
v-for="group in dependencyGroups"
:key="group.key"
class="dependency-card"
:class="{ readonly: isBuiltinInstalledSkill }"
>
<div class="dependency-card-header">
<div class="dependency-title-block">
<div class="dependency-title-row">
<h4>{{ group.title }}</h4>
<span class="dependency-count">已选择 {{ getDependencyValues(group).length }} </span>
</div>
<p>{{ group.description }}</p>
</div>
<a-dropdown
v-if="!isBuiltinInstalledSkill"
:trigger="['click']"
placement="bottomRight"
overlay-class-name="dependency-selection-popover"
>
<a-button size="small" class="dependency-action-btn dependency-select-btn">
<Plus :size="13" />
<span>选择依赖</span>
<ChevronDown :size="12" class="dependency-select-chevron" />
</a-button>
<template #overlay>
<div class="selection-dropdown" @mousedown.stop @click.stop>
<div class="selection-dropdown-header">
<div class="selection-dropdown-title">{{ group.title }}</div>
<div class="selection-dropdown-subtitle">{{ group.dropdownHint }}</div>
</div>
<a-input
v-model:value="dependencySearch[group.key]"
size="small"
allow-clear
class="selection-search"
:placeholder="`搜索${group.shortTitle}`"
@mousedown.stop
@click.stop
/>
<div v-if="getFilteredDependencyOptions(group).length" class="selection-list">
<div
v-for="option in getFilteredDependencyOptions(group)"
:key="option.value"
role="checkbox"
:aria-checked="isDependencySelected(group, option.value)"
tabindex="0"
class="selection-item"
:class="{ selected: isDependencySelected(group, option.value) }"
@mousedown.stop
@click.stop="toggleDependency(group, option.value, !isDependencySelected(group, option.value))"
@keydown.enter.prevent="toggleDependency(group, option.value, !isDependencySelected(group, option.value))"
@keydown.space.prevent="toggleDependency(group, option.value, !isDependencySelected(group, option.value))"
>
<span class="selection-item-content">
<a-checkbox
:checked="isDependencySelected(group, option.value)"
@click.stop
@change="toggleDependency(group, option.value, $event.target.checked)"
/>
<span class="selection-label">{{ option.label }}</span>
</span>
</div>
</div>
<div v-else class="selection-empty">
{{ group.options.length ? '没有匹配的依赖' : '暂无可选依赖' }}
</div>
</div>
</template>
</a-dropdown>
<a-button v-else size="small" disabled class="dependency-action-btn">系统维护</a-button>
</div>
<div v-if="getDependencyValues(group).length" class="dependency-chip-list">
<span
v-for="value in getDependencyValues(group)"
:key="value"
class="dependency-chip"
:title="getDependencyOptionLabel(group, value)"
>
<span>{{ getDependencyOptionLabel(group, value) }}</span>
<button
v-if="!isBuiltinInstalledSkill"
type="button"
class="dependency-chip-remove"
:aria-label="`移除 ${getDependencyOptionLabel(group, value)}`"
@click="removeDependency(group, value)"
>
<X :size="12" />
</button>
</span>
</div>
<div v-else class="dependency-empty-hint">{{ group.emptyText }}</div>
</section>
</div>
</div>
</a-tab-pane>
@ -220,11 +317,16 @@ import {
Layers,
FilePlus,
FolderPlus,
RotateCw
RotateCw,
Settings,
X,
Plus,
ChevronDown
} from 'lucide-vue-next'
import { skillApi } from '@/apis/skill_api'
import AgentFilePreview from '@/components/AgentFilePreview.vue'
import FileTreeComponent from '@/components/FileTreeComponent.vue'
import ShareConfigForm from '@/components/ShareConfigForm.vue'
const route = useRoute()
const router = useRouter()
@ -241,43 +343,41 @@ const fileContent = ref('')
const savingFile = ref(false)
const creatingNode = ref(false)
const savingDependencies = ref(false)
const savingShareConfig = ref(false)
const activeTab = ref('editor')
const skills = ref([])
const builtinSkills = ref([])
const createModalVisible = ref(false)
const createForm = reactive({ path: '', isDir: false, content: '' })
const allowedSkillAccessLevels = ref(['user'])
const enabledForm = ref(true)
const shareConfigFormRef = ref(null)
const shareConfigForm = ref({ access_level: 'user', department_ids: [], user_uids: [] })
const dependencyOptions = reactive({ tools: [], mcps: [], skills: [] })
const dependencyForm = reactive({
tool_dependencies: [],
mcp_dependencies: [],
skill_dependencies: []
})
const dependencySearch = reactive({ tools: '', mcps: '', skills: '' })
const isInstalledSkill = computed(() => {
return !!(
currentSkill.value &&
(currentSkill.value.installed_record || currentSkill.value.dir_path)
)
})
const isInstalledSkill = computed(() => !!currentSkill.value?.dir_path)
const isBuiltinInstalledSkill = computed(() => {
return !!(
isInstalledSkill.value &&
(currentSkill.value?.is_builtin || currentSkill.value?.installed_record)
)
return !!(isInstalledSkill.value && currentSkill.value?.source_type === 'builtin')
})
const sourceTypeLabel = (sourceType) => {
if (sourceType === 'builtin') return '内置'
if (sourceType === 'remote') return '远程添加'
return '上传'
}
const currentSkillStatusLabel = computed(() => {
const skill = currentSkill.value
if (!skill) return ''
if (skill.is_builtin_spec) {
if (skill.status === 'not_installed') return '未安装'
if (skill.status === 'update_available') return '更新可用'
return '已安装'
}
if (skill.is_builtin) return '已安装'
return '已上传'
if (skill.enabled === false) return `${sourceTypeLabel(skill.source_type)} · 已禁用`
return sourceTypeLabel(skill.source_type)
})
const selectedFilePreview = computed(() => ({
@ -288,7 +388,7 @@ const selectedFilePreview = computed(() => ({
const toolDependencyOptions = computed(() =>
(dependencyOptions.tools || []).map((i) =>
typeof i === 'object' ? { label: i.name, value: i.id } : { label: i, value: i }
typeof i === 'object' ? { label: i.name || i.slug, value: i.slug || i.id } : { label: i, value: i }
)
)
const mcpDependencyOptions = computed(() =>
@ -300,38 +400,103 @@ const skillDependencyOptions = computed(() =>
.map((i) => ({ label: i, value: i }))
)
const dependencyGroups = computed(() => [
{
key: 'tools',
formKey: 'tool_dependencies',
title: '工具依赖',
shortTitle: '工具',
description: '声明此 Skill 运行时需要调用的工具能力。',
dropdownHint: '选择后 Agent 运行时会同时加载这些工具。',
emptyText: '未声明工具依赖',
options: toolDependencyOptions.value
},
{
key: 'mcps',
formKey: 'mcp_dependencies',
title: 'MCP 依赖',
shortTitle: 'MCP',
description: '声明此 Skill 依赖的 MCP 服务。',
dropdownHint: '选择此 Skill 运行时需要的 MCP 服务。',
emptyText: '未声明 MCP 依赖',
options: mcpDependencyOptions.value
},
{
key: 'skills',
formKey: 'skill_dependencies',
title: 'Skill 依赖',
shortTitle: 'Skill',
description: '声明需要一起加载的其他 Skill。',
dropdownHint: '依赖 Skill 会随当前 Skill 一起进入运行时可读范围。',
emptyText: '未声明 Skill 依赖',
options: skillDependencyOptions.value
}
])
const getDependencyValues = (group) => dependencyForm[group.formKey] || []
const getDependencyOptionLabel = (group, value) => {
const option = group.options.find((item) => item.value === value)
return option?.label || value
}
const getFilteredDependencyOptions = (group) => {
const keyword = String(dependencySearch[group.key] || '').trim().toLowerCase()
if (!keyword) return group.options
return group.options.filter((option) => {
const label = String(option.label || '').toLowerCase()
const value = String(option.value || '').toLowerCase()
return label.includes(keyword) || value.includes(keyword)
})
}
const isDependencySelected = (group, value) => getDependencyValues(group).includes(value)
const toggleDependency = (group, value, checked) => {
if (isBuiltinInstalledSkill.value) return
const values = getDependencyValues(group)
if (checked) {
if (!values.includes(value)) dependencyForm[group.formKey] = [...values, value]
return
}
dependencyForm[group.formKey] = values.filter((item) => item !== value)
}
const removeDependency = (group, value) => {
toggleDependency(group, value, false)
}
const goBack = () => {
router.push({ path: '/extensions', query: { tab: 'skills' } })
}
const cloneShareConfig = (config) => ({
access_level: config?.access_level || 'user',
department_ids: [...(config?.department_ids || [])],
user_uids: [...(config?.user_uids || [])]
})
const syncShareConfigFromSkill = (skillRecord) => {
enabledForm.value = skillRecord?.enabled !== false
shareConfigForm.value = cloneShareConfig(skillRecord?.share_config)
}
const fetchSkillDetail = async () => {
loading.value = true
try {
const [skillResult, builtinResult] = await Promise.all([
skillApi.listSkills(),
skillApi.listBuiltinSkills()
])
const skillResult = await skillApi.listSkills()
skills.value = skillResult?.data || []
builtinSkills.value = (builtinResult?.data || []).map((item) => ({
...item,
...(item.installed_record || {}),
is_builtin_spec: true
}))
allowedSkillAccessLevels.value = skillResult?.allowed_access_levels || ['user']
const allSkills = [
...builtinSkills.value,
...skills.value.filter((s) => !builtinSkills.value.find((b) => b.slug === s.slug))
]
const found = allSkills.find((s) => s.slug === slug.value)
const found = skills.value.find((s) => s.slug === slug.value)
if (found) {
currentSkill.value = found
syncDependencyFormFromSkill(found.installed_record || found)
if (found.installed_record || found.dir_path) {
await reloadTree()
await loadSkillFile(found.slug)
}
syncDependencyFormFromSkill(found)
syncShareConfigFromSkill(found)
await reloadTree()
await loadSkillFile(found.slug)
}
await fetchDependencyOptions()
await fetchDependencyOptions(currentSkill.value?.slug)
} catch {
message.error('加载失败')
} finally {
@ -339,9 +504,9 @@ const fetchSkillDetail = async () => {
}
}
const fetchDependencyOptions = async () => {
const fetchDependencyOptions = async (currentSlug) => {
try {
const result = await skillApi.getSkillDependencyOptions()
const result = await skillApi.getSkillDependencyOptions(currentSlug)
const data = result?.data || {}
dependencyOptions.tools = data.tools || []
dependencyOptions.mcps = data.mcps || []
@ -454,70 +619,13 @@ const saveCurrentFile = async (content = fileContent.value) => {
}
}
const handleInstallBuiltin = async (record) => {
if (!record?.slug) return
loading.value = true
try {
await skillApi.installBuiltinSkill(record.slug)
await fetchSkillDetail()
message.success('安装成功')
} catch (error) {
message.error(error?.response?.data?.detail || error.message || '安装失败')
} finally {
loading.value = false
}
}
const handleUpdateBuiltin = async (record) => {
if (!record?.slug) return
loading.value = true
try {
await skillApi.updateBuiltinSkill(record.slug, false)
await fetchSkillDetail()
message.success('更新成功')
} catch (error) {
if (error.response?.data?.detail?.needs_confirm) {
loading.value = false
Modal.confirm({
title: '确认覆盖更新?',
content: '检测到你修改过此 skill更新将覆盖你的修改是否继续',
okText: '继续更新',
cancelText: '取消',
onOk: async () => {
loading.value = true
try {
await skillApi.updateBuiltinSkill(record.slug, true)
await fetchSkillDetail()
message.success('更新成功')
} catch (forceError) {
message.error(forceError?.response?.data?.detail || forceError.message || '更新失败')
} finally {
loading.value = false
}
}
})
return
}
message.error(error?.response?.data?.detail || error.message || '更新失败')
} finally {
loading.value = false
}
}
const confirmDeleteSkill = () => {
const target = currentSkill.value
if (!target) return
const isBuiltinTarget = !!(
target?.is_builtin ||
target?.installed_record ||
target?.sourceType === 'builtin'
)
const actionText = isBuiltinTarget ? '卸载' : '删除'
const actionText = '删除'
Modal.confirm({
title: `确认${actionText}技能「${target.slug}」?`,
content: isBuiltinTarget
? '卸载后会移除已安装文件和数据库记录,但仍可从"未安装 Skills"中重新安装。'
: '删除后无法恢复,所有文件和配置将永久消失。',
content: '删除后无法恢复,所有文件和配置将永久消失。',
okText: `确认${actionText}`,
okType: 'danger',
cancelText: '取消',
@ -576,8 +684,36 @@ const handleCreateNode = async () => {
}
}
const saveDependencies = async () => {
const saveShareConfig = async () => {
if (!currentSkill.value || !isInstalledSkill.value) return
if (!isBuiltinInstalledSkill.value) {
const validation = shareConfigFormRef.value?.validate?.()
if (validation && !validation.valid) {
message.warning(validation.message || '请完善 Skill 生效范围')
return
}
}
savingShareConfig.value = true
try {
if (!isBuiltinInstalledSkill.value) {
await skillApi.updateSkillShareConfig(currentSkill.value.slug, shareConfigForm.value)
}
const result = await skillApi.updateSkillEnabled(currentSkill.value.slug, enabledForm.value)
if (result?.data) {
currentSkill.value = result.data
syncShareConfigFromSkill(result.data)
}
message.success('设置已保存')
} catch (error) {
message.error(error?.response?.data?.detail || error.message || '保存设置失败')
} finally {
savingShareConfig.value = false
}
}
const saveDependencies = async () => {
if (!currentSkill.value || !isInstalledSkill.value || isBuiltinInstalledSkill.value) return
savingDependencies.value = true
try {
const result = await skillApi.updateSkillDependencies(currentSkill.value.slug, {
@ -627,18 +763,6 @@ onMounted(() => {
}
}
.builtin-uninstalled-state {
padding: 24px;
h3 {
margin: 0 0 8px;
font-size: 16px;
}
p {
margin: 0 0 16px;
color: var(--gray-500);
}
}
.workspace {
display: flex;
flex: 1;
@ -736,22 +860,27 @@ onMounted(() => {
}
.config-view {
padding: 16px;
padding: 20px;
flex: 1;
overflow-y: auto;
max-width: 720px;
max-width: 860px;
.config-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 32px;
gap: 16px;
margin-bottom: 18px;
flex-shrink: 0;
.text {
h3 {
margin: 0 0 4px 0;
font-size: 16px;
font-weight: 600;
color: var(--gray-900);
}
p {
margin: 0;
color: var(--gray-500);
@ -759,12 +888,241 @@ onMounted(() => {
}
}
}
.config-form {
max-width: 600px;
:deep(.ant-form-item-label label) {
font-weight: 500;
font-size: 13px;
}
}
.settings-stack,
.dependency-groups {
display: flex;
flex-direction: column;
gap: 12px;
}
.settings-card,
.dependency-card {
border: 1px solid var(--gray-150);
border-radius: 10px;
background: var(--gray-0);
}
.settings-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 12px;
&.scope-card {
display: block;
}
}
.settings-card-main {
min-width: 0;
}
.settings-card-title {
margin-bottom: 4px;
color: var(--gray-900);
font-size: 14px;
font-weight: 700;
}
.settings-card-desc {
color: var(--gray-500);
font-size: 13px;
line-height: 1.55;
}
.settings-card-action {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 10px;
}
.scope-card .settings-card-main {
margin-bottom: 14px;
}
.status-pill {
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
line-height: 18px;
&.enabled {
background: var(--main-10);
color: var(--main-color);
}
&.disabled {
background: var(--gray-100);
color: var(--gray-500);
}
}
.readonly-scope-hint {
color: var(--gray-500);
background: var(--gray-50);
border: 1px solid var(--gray-150);
border-radius: 10px;
padding: 11px 12px;
font-size: 13px;
line-height: 1.55;
}
.dependency-card {
padding: 14px;
&.readonly {
background: linear-gradient(180deg, var(--gray-0) 0%, var(--gray-25) 100%);
}
}
.dependency-card-header {
display: flex;
align-items: flex-start;
gap: 12px;
}
.dependency-title-block {
min-width: 0;
flex: 1;
}
.dependency-title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
h4 {
margin: 0;
color: var(--gray-900);
font-size: 14px;
font-weight: 700;
}
}
.dependency-title-block p {
margin: 4px 0 0;
color: var(--gray-500);
font-size: 12px;
line-height: 1.45;
}
.dependency-count {
padding: 1px 7px;
border-radius: 999px;
background: var(--gray-50);
color: var(--gray-500);
font-size: 12px;
line-height: 18px;
}
.dependency-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
height: 30px;
flex-shrink: 0;
gap: 5px;
padding: 0 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
.dependency-select-btn {
border-color: var(--gray-100);
background: var(--gray-50);
box-shadow: 0 1px 3px rgb(0 0 0 / 3%);
&:hover,
&:focus {
border-color: var(--main-color);
background: var(--main-20);
color: var(--main-color);
}
}
.dependency-select-chevron {
opacity: 0.72;
}
.dependency-chip-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.dependency-chip {
display: inline-flex;
align-items: center;
max-width: 220px;
gap: 6px;
padding: 4px 8px;
border: 1px solid var(--gray-150);
border-radius: 6px;
background: var(--gray-50);
color: var(--gray-700);
font-size: 12px;
line-height: 18px;
span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.dependency-chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex-shrink: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--gray-500);
cursor: pointer;
&:hover {
background: var(--gray-150);
color: var(--gray-800);
}
}
.dependency-empty-hint {
margin-top: 14px;
padding: 10px 12px;
border: 1px dashed var(--gray-150);
border-radius: 6px;
background: var(--gray-25);
color: var(--gray-500);
font-size: 12px;
}
@media (max-width: 768px) {
.config-view {
padding: 14px;
}
.config-header,
.settings-card,
.dependency-card-header {
flex-direction: column;
align-items: stretch;
}
.dependency-chip-list,
.dependency-empty-hint {
margin-left: 0;
padding-left: 0;
}
}
@ -775,3 +1133,97 @@ onMounted(() => {
padding-top: 12px;
}
</style>
<style lang="less">
.dependency-selection-popover {
.selection-dropdown {
width: 300px;
max-height: 360px;
padding: 8px;
overflow: hidden auto;
border: 1px solid var(--gray-200);
border-radius: 14px;
background: var(--gray-0);
box-shadow: 0 8px 22px rgb(0 0 0 / 8%);
}
.selection-dropdown-header {
padding: 8px 10px 10px;
margin-bottom: 4px;
border-bottom: 1px solid var(--gray-100);
}
.selection-dropdown-title {
color: var(--gray-900);
font-size: 13px;
font-weight: 700;
line-height: 1.4;
}
.selection-dropdown-subtitle {
margin-top: 2px;
color: var(--gray-500);
font-size: 12px;
line-height: 1.4;
}
.selection-search {
width: calc(100% - 16px);
height: 30px;
margin: 8px;
}
.selection-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.selection-item {
display: flex;
align-items: center;
min-height: 38px;
gap: 8px;
padding: 8px 10px;
border-radius: 9px;
color: var(--gray-800);
cursor: pointer;
transition:
background-color 160ms ease,
color 160ms ease;
&:hover {
background: var(--gray-50);
}
&.selected {
background: var(--main-10);
color: var(--gray-900);
}
}
.selection-item-content {
display: flex;
align-items: center;
min-width: 0;
gap: 8px;
}
.selection-label {
min-width: 0;
overflow: hidden;
font-size: 13px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.selection-empty {
display: block;
padding: 16px 0;
color: var(--gray-600);
font-size: 13px;
text-align: center;
}
}
</style>