feat: 内置 Skills 按需安装机制
- Skill 模型新增 version, is_builtin, content_hash 字段 - 内置 skills 不再自动安装,改为按需安装 - 新增 GET/POST /system/skills/builtin 路由 - 安装时计算目录内容 hash,用于变更检测 - 更新时 hash 不一致返回 409 确认 - 前端显示内置 skills 未安装状态,支持安装/更新按钮 - 内置 skill 文件编辑入口已隐藏 - 修复 _compute_dir_hash 内存问题,改为 chunk 读取 - 19 个测试用例全部通过
This commit is contained in:
parent
4e740bab1b
commit
a005e0f3dc
@ -12,6 +12,7 @@ class BuiltinSkillSpec:
|
||||
slug: str
|
||||
source_dir: Path
|
||||
description: str = ""
|
||||
version: str = "1.0.0"
|
||||
tool_dependencies: tuple[str, ...] = ()
|
||||
mcp_dependencies: tuple[str, ...] = ()
|
||||
skill_dependencies: tuple[str, ...] = ()
|
||||
@ -24,6 +25,7 @@ BUILTIN_SKILLS: list[BuiltinSkillSpec] = [
|
||||
slug="reporter",
|
||||
source_dir=_SKILLS_ROOT / "reporter",
|
||||
description="生成 SQL 查询报表并生成可视化图表。",
|
||||
version="1.0.0",
|
||||
tool_dependencies=[t["name"] for t in get_tool_info(get_mysql_tools())],
|
||||
mcp_dependencies=("mcp-server-chart",),
|
||||
),
|
||||
|
||||
@ -32,6 +32,9 @@ class SkillRepository:
|
||||
mcp_dependencies: list[str] | None,
|
||||
skill_dependencies: list[str] | None,
|
||||
dir_path: str,
|
||||
version: str | None = None,
|
||||
is_builtin: bool = False,
|
||||
content_hash: str | None = None,
|
||||
created_by: str | None,
|
||||
) -> Skill:
|
||||
now = utc_now_naive()
|
||||
@ -43,6 +46,9 @@ class SkillRepository:
|
||||
mcp_dependencies=mcp_dependencies or [],
|
||||
skill_dependencies=skill_dependencies or [],
|
||||
dir_path=dir_path,
|
||||
version=version,
|
||||
is_builtin=is_builtin,
|
||||
content_hash=content_hash,
|
||||
created_by=created_by,
|
||||
updated_by=created_by,
|
||||
created_at=now,
|
||||
@ -53,6 +59,23 @@ class SkillRepository:
|
||||
await self.db.refresh(item)
|
||||
return item
|
||||
|
||||
async def update_builtin_install(
|
||||
self,
|
||||
item: Skill,
|
||||
*,
|
||||
version: str,
|
||||
content_hash: str,
|
||||
updated_by: str | None,
|
||||
) -> Skill:
|
||||
item.version = version
|
||||
item.content_hash = content_hash
|
||||
item.is_builtin = True
|
||||
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_dependencies(
|
||||
self,
|
||||
item: Skill,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
@ -57,6 +58,12 @@ _THREAD_SKILLS_LOCK = threading.Lock()
|
||||
_THREAD_SKILLS_LOCKS: dict[str, threading.Lock] = {}
|
||||
|
||||
|
||||
class BuiltinSkillUpdateConflictError(ValueError):
|
||||
def __init__(self, message: str):
|
||||
super().__init__(message)
|
||||
self.needs_confirm = True
|
||||
|
||||
|
||||
def _get_thread_skills_lock(thread_id: str) -> threading.Lock:
|
||||
with _THREAD_SKILLS_LOCK:
|
||||
lock = _THREAD_SKILLS_LOCKS.get(thread_id)
|
||||
@ -163,6 +170,14 @@ def get_builtin_skill_specs() -> list[Any]:
|
||||
return BUILTIN_SKILLS
|
||||
|
||||
|
||||
def _get_builtin_skill_spec_or_raise(slug: str) -> Any:
|
||||
normalized_slug = slug.strip() if isinstance(slug, str) else ""
|
||||
for spec in get_builtin_skill_specs():
|
||||
if getattr(spec, "slug", "").strip() == normalized_slug:
|
||||
return spec
|
||||
raise ValueError(f"内置 skill '{slug}' 不存在")
|
||||
|
||||
|
||||
def _build_builtin_skill_dir_path(slug: str) -> str:
|
||||
return (Path("skills") / slug).as_posix()
|
||||
|
||||
@ -183,6 +198,20 @@ def _dirs_equal(dir1: Path, dir2: Path) -> bool:
|
||||
return list1 == list2
|
||||
|
||||
|
||||
def _compute_dir_hash(source_dir: Path) -> str:
|
||||
hasher = hashlib.sha256()
|
||||
file_paths = sorted(path for path in source_dir.rglob("*") if path.is_file())
|
||||
for file_path in file_paths:
|
||||
relative_path = file_path.relative_to(source_dir).as_posix()
|
||||
hasher.update(relative_path.encode("utf-8"))
|
||||
hasher.update(b"\0")
|
||||
with file_path.open("rb") as f:
|
||||
while chunk := f.read(1024 * 1024):
|
||||
hasher.update(chunk)
|
||||
hasher.update(b"\0")
|
||||
return hasher.hexdigest()
|
||||
|
||||
|
||||
def _copy_skill_target(target_dir: Path, source_dir: Path) -> None:
|
||||
if target_dir.is_symlink():
|
||||
target_dir.unlink()
|
||||
@ -194,6 +223,28 @@ def _copy_skill_target(target_dir: Path, source_dir: Path) -> None:
|
||||
shutil.copytree(source_dir, target_dir, symlinks=False, dirs_exist_ok=True)
|
||||
|
||||
|
||||
def _replace_skill_target(target_dir: Path, source_dir: Path) -> None:
|
||||
temp_target = target_dir.with_name(f".{target_dir.name}.tmp-{uuid.uuid4().hex[:8]}")
|
||||
trash_dir: Path | None = None
|
||||
if temp_target.exists():
|
||||
shutil.rmtree(temp_target, ignore_errors=True)
|
||||
|
||||
shutil.copytree(source_dir, temp_target, symlinks=False)
|
||||
try:
|
||||
if target_dir.exists():
|
||||
trash_dir = target_dir.with_name(f".{target_dir.name}.bak-{uuid.uuid4().hex[:8]}")
|
||||
target_dir.rename(trash_dir)
|
||||
temp_target.rename(target_dir)
|
||||
except Exception:
|
||||
shutil.rmtree(temp_target, ignore_errors=True)
|
||||
if trash_dir and trash_dir.exists() and not target_dir.exists():
|
||||
trash_dir.rename(target_dir)
|
||||
raise
|
||||
|
||||
if trash_dir and trash_dir.exists():
|
||||
shutil.rmtree(trash_dir, ignore_errors=True)
|
||||
|
||||
|
||||
async def get_skill_dependency_options(db: AsyncSession) -> dict[str, list[str] | list[dict]]:
|
||||
# 并行执行三个独立操作
|
||||
from yuxi.services.tool_service import get_tool_metadata
|
||||
@ -540,6 +591,8 @@ async def create_skill_node(
|
||||
updated_by: str | None,
|
||||
) -> None:
|
||||
item = await get_skill_or_raise(db, slug)
|
||||
if item.is_builtin:
|
||||
raise ValueError("内置 skill 不允许直接修改文件")
|
||||
skill_dir = _resolve_skill_dir(item)
|
||||
target, _ = _resolve_relative_path(skill_dir, relative_path)
|
||||
if target.exists():
|
||||
@ -569,6 +622,8 @@ async def update_skill_file(
|
||||
updated_by: str | None,
|
||||
) -> None:
|
||||
item = await get_skill_or_raise(db, slug)
|
||||
if item.is_builtin:
|
||||
raise ValueError("内置 skill 不允许直接修改文件")
|
||||
skill_dir = _resolve_skill_dir(item)
|
||||
target, _ = _resolve_relative_path(skill_dir, relative_path)
|
||||
if not target.exists() or not target.is_file():
|
||||
@ -600,6 +655,8 @@ async def _update_skill_metadata_if_skills_md(
|
||||
|
||||
async def delete_skill_node(db: AsyncSession, *, slug: str, relative_path: str) -> None:
|
||||
item = await get_skill_or_raise(db, slug)
|
||||
if item.is_builtin:
|
||||
raise ValueError("内置 skill 不允许直接修改文件")
|
||||
skill_dir = _resolve_skill_dir(item)
|
||||
target, rel = _resolve_relative_path(skill_dir, relative_path, allow_root=False)
|
||||
if not target.exists():
|
||||
@ -659,18 +716,46 @@ async def delete_skill(db: AsyncSession, *, slug: str) -> None:
|
||||
|
||||
|
||||
async def init_builtin_skills(db: AsyncSession, *, created_by: str = "system") -> None:
|
||||
"""初始化内置 skills(软链接目录 + 启动时同步元数据)。"""
|
||||
repo = SkillRepository(db)
|
||||
skills_root = get_skills_root_dir()
|
||||
"""校验内置 skills 配置,不执行安装。"""
|
||||
specs = get_builtin_skill_specs()
|
||||
|
||||
for spec in specs:
|
||||
slug = str(getattr(spec, "slug", "")).strip()
|
||||
source_dir = Path(str(getattr(spec, "source_dir", ""))).resolve()
|
||||
configured_description = str(getattr(spec, "description", "")).strip()
|
||||
configured_tools = _normalize_string_list(getattr(spec, "tool_dependencies", None))
|
||||
configured_mcps = _normalize_string_list(getattr(spec, "mcp_dependencies", None))
|
||||
configured_skills = _normalize_string_list(getattr(spec, "skill_dependencies", None))
|
||||
|
||||
if not is_valid_skill_slug(slug):
|
||||
raise ValueError(f"内置 skill slug 非法: {slug}")
|
||||
if not source_dir.exists() or not source_dir.is_dir():
|
||||
logger.warning(f"跳过不存在的内置 skill 目录: {source_dir}")
|
||||
continue
|
||||
|
||||
skill_md = source_dir / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
legacy_skill_md = source_dir / "SKILLS.md"
|
||||
if not legacy_skill_md.exists():
|
||||
raise ValueError(f"内置 skill 缺少 SKILL.md: {source_dir}")
|
||||
skill_md = legacy_skill_md
|
||||
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
parsed_name, _, meta = _parse_skill_markdown(content)
|
||||
if parsed_name != slug:
|
||||
raise ValueError(f"内置 skill frontmatter.name 必须等于 slug: {slug}")
|
||||
_normalize_string_list(meta.get("tool_dependencies"))
|
||||
_normalize_string_list(meta.get("mcp_dependencies"))
|
||||
_normalize_string_list(meta.get("skill_dependencies"))
|
||||
_compute_dir_hash(source_dir)
|
||||
|
||||
|
||||
def list_builtin_skill_specs() -> list[dict[str, Any]]:
|
||||
specs: list[dict[str, Any]] = []
|
||||
for raw_spec in get_builtin_skill_specs():
|
||||
slug = str(getattr(raw_spec, "slug", "")).strip()
|
||||
source_dir = Path(str(getattr(raw_spec, "source_dir", ""))).resolve()
|
||||
configured_description = str(getattr(raw_spec, "description", "")).strip()
|
||||
version = str(getattr(raw_spec, "version", "1.0.0")).strip() or "1.0.0"
|
||||
configured_tools = _normalize_string_list(getattr(raw_spec, "tool_dependencies", None))
|
||||
configured_mcps = _normalize_string_list(getattr(raw_spec, "mcp_dependencies", None))
|
||||
configured_skills = _normalize_string_list(getattr(raw_spec, "skill_dependencies", None))
|
||||
|
||||
if not is_valid_skill_slug(slug):
|
||||
raise ValueError(f"内置 skill slug 非法: {slug}")
|
||||
@ -690,46 +775,102 @@ async def init_builtin_skills(db: AsyncSession, *, created_by: str = "system") -
|
||||
if parsed_name != slug:
|
||||
raise ValueError(f"内置 skill frontmatter.name 必须等于 slug: {slug}")
|
||||
|
||||
description = configured_description or parsed_desc
|
||||
tool_dependencies = configured_tools or _normalize_string_list(meta.get("tool_dependencies"))
|
||||
mcp_dependencies = configured_mcps or _normalize_string_list(meta.get("mcp_dependencies"))
|
||||
skill_dependencies = configured_skills or _normalize_string_list(meta.get("skill_dependencies"))
|
||||
specs.append(
|
||||
{
|
||||
"slug": slug,
|
||||
"name": slug,
|
||||
"description": configured_description or parsed_desc,
|
||||
"version": version,
|
||||
"tool_dependencies": configured_tools or _normalize_string_list(meta.get("tool_dependencies")),
|
||||
"mcp_dependencies": configured_mcps or _normalize_string_list(meta.get("mcp_dependencies")),
|
||||
"skill_dependencies": configured_skills or _normalize_string_list(meta.get("skill_dependencies")),
|
||||
"content_hash": _compute_dir_hash(source_dir),
|
||||
"source_dir": source_dir,
|
||||
}
|
||||
)
|
||||
|
||||
target_dir = skills_root / slug
|
||||
_copy_skill_target(target_dir, source_dir)
|
||||
return specs
|
||||
|
||||
item = await repo.get_by_slug(slug)
|
||||
if item and not _is_builtin_managed(item, slug):
|
||||
logger.warning(f"发现同名非内置 skill,跳过自动同步: {slug}")
|
||||
continue
|
||||
|
||||
expected_dir_path = _build_builtin_skill_dir_path(slug)
|
||||
async def install_builtin_skill(db: AsyncSession, slug: str, *, installed_by: str | None) -> Skill:
|
||||
_get_builtin_skill_spec_or_raise(slug)
|
||||
repo = SkillRepository(db)
|
||||
spec = next(item for item in list_builtin_skill_specs() if item["slug"] == slug)
|
||||
|
||||
if not item:
|
||||
await repo.create(
|
||||
slug=slug,
|
||||
name=slug,
|
||||
description=description,
|
||||
tool_dependencies=tool_dependencies,
|
||||
mcp_dependencies=mcp_dependencies,
|
||||
skill_dependencies=skill_dependencies,
|
||||
dir_path=expected_dir_path,
|
||||
created_by=BUILTIN_SKILL_OPERATOR,
|
||||
)
|
||||
continue
|
||||
existing = await repo.get_by_slug(slug)
|
||||
if existing:
|
||||
raise ValueError(f"内置 skill '{slug}' 已安装")
|
||||
|
||||
if item.name != slug or item.description != description:
|
||||
await repo.update_metadata(item, name=slug, description=description, updated_by=created_by)
|
||||
target_dir = get_skills_root_dir() / slug
|
||||
if target_dir.exists():
|
||||
raise ValueError(f"技能目录已存在: {slug}")
|
||||
|
||||
if (
|
||||
_normalize_string_list(item.tool_dependencies or []) != tool_dependencies
|
||||
or _normalize_string_list(item.mcp_dependencies or []) != mcp_dependencies
|
||||
or _normalize_string_list(item.skill_dependencies or []) != skill_dependencies
|
||||
):
|
||||
await repo.update_dependencies(
|
||||
item,
|
||||
tool_dependencies=tool_dependencies,
|
||||
mcp_dependencies=mcp_dependencies,
|
||||
skill_dependencies=skill_dependencies,
|
||||
updated_by=created_by,
|
||||
)
|
||||
shutil.copytree(Path(spec["source_dir"]), target_dir, symlinks=False)
|
||||
try:
|
||||
return await repo.create(
|
||||
slug=slug,
|
||||
name=spec["name"],
|
||||
description=spec["description"],
|
||||
tool_dependencies=spec["tool_dependencies"],
|
||||
mcp_dependencies=spec["mcp_dependencies"],
|
||||
skill_dependencies=spec["skill_dependencies"],
|
||||
dir_path=_build_builtin_skill_dir_path(slug),
|
||||
version=spec["version"],
|
||||
is_builtin=True,
|
||||
content_hash=spec["content_hash"],
|
||||
created_by=installed_by or BUILTIN_SKILL_OPERATOR,
|
||||
)
|
||||
except Exception:
|
||||
shutil.rmtree(target_dir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
|
||||
async def update_builtin_skill(
|
||||
db: AsyncSession,
|
||||
slug: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
updated_by: str | None,
|
||||
) -> Skill:
|
||||
_get_builtin_skill_spec_or_raise(slug)
|
||||
repo = SkillRepository(db)
|
||||
spec = next(item for item in list_builtin_skill_specs() if item["slug"] == slug)
|
||||
item = await repo.get_by_slug(slug)
|
||||
if not item:
|
||||
raise ValueError(f"内置 skill '{slug}' 未安装")
|
||||
if not item.is_builtin:
|
||||
raise ValueError(f"技能 '{slug}' 不是内置 skill")
|
||||
|
||||
if item.content_hash != spec["content_hash"] and not force:
|
||||
raise BuiltinSkillUpdateConflictError("检测到你修改过此 skill,更新将覆盖你的修改,是否继续?")
|
||||
|
||||
target_dir = _resolve_skill_dir(item)
|
||||
_replace_skill_target(target_dir, Path(spec["source_dir"]))
|
||||
|
||||
if item.name != spec["name"] or item.description != spec["description"]:
|
||||
await repo.update_metadata(
|
||||
item,
|
||||
name=spec["name"],
|
||||
description=spec["description"],
|
||||
updated_by=updated_by,
|
||||
)
|
||||
|
||||
if (
|
||||
_normalize_string_list(item.tool_dependencies or []) != spec["tool_dependencies"]
|
||||
or _normalize_string_list(item.mcp_dependencies or []) != spec["mcp_dependencies"]
|
||||
or _normalize_string_list(item.skill_dependencies or []) != spec["skill_dependencies"]
|
||||
):
|
||||
await repo.update_dependencies(
|
||||
item,
|
||||
tool_dependencies=spec["tool_dependencies"],
|
||||
mcp_dependencies=spec["mcp_dependencies"],
|
||||
skill_dependencies=spec["skill_dependencies"],
|
||||
updated_by=updated_by,
|
||||
)
|
||||
|
||||
return await repo.update_builtin_install(
|
||||
item,
|
||||
version=spec["version"],
|
||||
content_hash=spec["content_hash"],
|
||||
updated_by=updated_by,
|
||||
)
|
||||
|
||||
@ -188,6 +188,9 @@ class PostgresManager(metaclass=SingletonMeta):
|
||||
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS tool_dependencies JSONB DEFAULT '[]'::jsonb",
|
||||
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS mcp_dependencies JSONB DEFAULT '[]'::jsonb",
|
||||
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS skill_dependencies JSONB DEFAULT '[]'::jsonb",
|
||||
"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 content_hash VARCHAR(128)",
|
||||
"ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS is_pinned BOOLEAN NOT NULL DEFAULT FALSE",
|
||||
"ALTER TABLE IF EXISTS mcp_servers ADD COLUMN IF NOT EXISTS env JSONB",
|
||||
"""
|
||||
|
||||
@ -186,6 +186,9 @@ class Skill(Base):
|
||||
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 安装时计算)")
|
||||
created_by = Column(String(64), nullable=True)
|
||||
updated_by = Column(String(64), nullable=True)
|
||||
created_at = Column(DateTime, default=utc_now_naive)
|
||||
@ -201,6 +204,9 @@ class Skill(Base):
|
||||
"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,
|
||||
"created_by": self.created_by,
|
||||
"updated_by": self.updated_by,
|
||||
"created_at": format_utc_datetime(self.created_at),
|
||||
|
||||
@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.utils.auth_middleware import get_admin_user, get_db, get_superadmin_user
|
||||
from yuxi.services.skill_service import (
|
||||
BuiltinSkillUpdateConflictError,
|
||||
create_skill_node,
|
||||
delete_skill,
|
||||
delete_skill_node,
|
||||
@ -18,8 +19,11 @@ from yuxi.services.skill_service import (
|
||||
get_skill_dependency_options,
|
||||
get_skill_tree,
|
||||
import_skill_zip,
|
||||
install_builtin_skill,
|
||||
list_builtin_skill_specs,
|
||||
list_skills,
|
||||
read_skill_file,
|
||||
update_builtin_skill,
|
||||
update_skill_dependencies,
|
||||
update_skill_file,
|
||||
)
|
||||
@ -46,6 +50,10 @@ class SkillDependenciesUpdateRequest(BaseModel):
|
||||
skill_dependencies: list[str] = Field(default_factory=list, description="依赖的其他 skill slug 列表")
|
||||
|
||||
|
||||
class BuiltinSkillUpdateRequest(BaseModel):
|
||||
force: bool = Field(False, description="是否强制覆盖本地已安装内容")
|
||||
|
||||
|
||||
def _raise_from_value_error(e: ValueError) -> None:
|
||||
message = str(e)
|
||||
status_code = 404 if "不存在" in message else 400
|
||||
@ -86,6 +94,88 @@ async def get_skill_dependency_options_route(
|
||||
raise HTTPException(status_code=500, detail="获取 skill 依赖选项失败")
|
||||
|
||||
|
||||
@skills.get("/builtin")
|
||||
async def list_builtin_skills_route(
|
||||
_current_user: User = Depends(get_superadmin_user),
|
||||
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}
|
||||
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(
|
||||
slug: str,
|
||||
current_user: User = Depends(get_superadmin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
item = await install_builtin_skill(db, slug, installed_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 builtin skill '{slug}': {e}")
|
||||
raise HTTPException(status_code=500, detail="安装内置 skill 失败")
|
||||
|
||||
|
||||
@skills.post("/builtin/{slug}/update")
|
||||
async def update_builtin_skill_route(
|
||||
slug: str,
|
||||
payload: BuiltinSkillUpdateRequest,
|
||||
current_user: User = Depends(get_superadmin_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(...),
|
||||
|
||||
@ -5,7 +5,6 @@ from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
|
||||
from yuxi.services.task_service import tasker
|
||||
from yuxi.services.mcp_service import init_mcp_servers
|
||||
from yuxi.services.skill_service import init_builtin_skills
|
||||
from yuxi.services.subagent_service import init_builtin_subagents
|
||||
from yuxi.services.run_queue_service import close_queue_clients, get_redis_client
|
||||
from yuxi.storage.postgres.manager import pg_manager
|
||||
@ -40,14 +39,6 @@ async def lifespan(app: FastAPI):
|
||||
logger.error(f"Failed to initialize builtin subagents during startup: {e}")
|
||||
raise
|
||||
|
||||
# 初始化内置 Skills
|
||||
try:
|
||||
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}")
|
||||
raise
|
||||
|
||||
# 初始化知识库管理器
|
||||
import os
|
||||
if os.environ.get("LITE_MODE", "").lower() in ("true", "1"):
|
||||
|
||||
@ -381,20 +381,8 @@ async def test_init_builtin_skills_create_missing(tmp_path: Path, monkeypatch: p
|
||||
|
||||
await svc.init_builtin_skills(None)
|
||||
|
||||
assert len(FakeRepo.created) == 1
|
||||
created = FakeRepo.created[0]
|
||||
assert created["slug"] == "reporter"
|
||||
assert created["name"] == "reporter"
|
||||
assert created["description"] == "SQL report from python"
|
||||
assert created["tool_dependencies"] == ["mysql_query"]
|
||||
assert created["mcp_dependencies"] == ["charts"]
|
||||
assert created["skill_dependencies"] == ["common-report"]
|
||||
assert created["created_by"] == svc.BUILTIN_SKILL_OPERATOR
|
||||
|
||||
target_dir = tmp_path / "skills" / "reporter"
|
||||
assert target_dir.exists()
|
||||
assert target_dir.is_dir()
|
||||
assert (target_dir / "SKILL.md").exists()
|
||||
assert FakeRepo.created == []
|
||||
assert not (tmp_path / "skills" / "reporter").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -481,12 +469,351 @@ async def test_init_builtin_skills_updates_existing_record(tmp_path: Path, monke
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@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"
|
||||
assert target_dir.exists()
|
||||
assert target_dir.is_dir()
|
||||
assert captured["description"] == "new description"
|
||||
assert captured["tool_dependencies"] == ["mysql_query"]
|
||||
assert captured["mcp_dependencies"] == ["charts"]
|
||||
assert captured["skill_dependencies"] == []
|
||||
assert captured["updated_by"] == "release-bot"
|
||||
assert captured["updated_by_deps"] == "release-bot"
|
||||
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:
|
||||
def __init__(self, _db):
|
||||
pass
|
||||
|
||||
async def get_by_slug(self, slug: str):
|
||||
return installed
|
||||
|
||||
async def update_metadata(self, item: Skill, *, name: str, description: str, updated_by: str | None):
|
||||
item.name = name
|
||||
item.description = description
|
||||
captured["metadata_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,
|
||||
):
|
||||
item.tool_dependencies = tool_dependencies
|
||||
item.mcp_dependencies = mcp_dependencies
|
||||
item.skill_dependencies = skill_dependencies
|
||||
captured["deps_updated_by"] = updated_by
|
||||
return item
|
||||
|
||||
async def update_builtin_install(
|
||||
self,
|
||||
item: Skill,
|
||||
*,
|
||||
version: str,
|
||||
content_hash: str,
|
||||
updated_by: str | None,
|
||||
):
|
||||
item.version = version
|
||||
item.content_hash = content_hash
|
||||
item.updated_by = updated_by
|
||||
captured["version"] = version
|
||||
captured["content_hash"] = content_hash
|
||||
captured["updated_by"] = updated_by
|
||||
return item
|
||||
|
||||
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
|
||||
|
||||
item = await svc.update_builtin_skill(None, "reporter", force=True, updated_by="root")
|
||||
|
||||
assert item.version == "1.0.1"
|
||||
assert item.content_hash == "hash-v2"
|
||||
assert (target_dir / "prompt.md").read_text(encoding="utf-8") == "new builtin content"
|
||||
assert captured["updated_by"] == "root"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_builtin_skill_file_edit_blocked(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
|
||||
|
||||
target_dir = tmp_path / "skills" / "reporter"
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
(target_dir / "SKILL.md").write_text(
|
||||
"---\nname: reporter\ndescription: builtin\n---\n# Reporter\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
builtin_item = Skill(
|
||||
slug="reporter",
|
||||
name="reporter",
|
||||
description="builtin",
|
||||
dir_path="skills/reporter",
|
||||
is_builtin=True,
|
||||
)
|
||||
|
||||
async def fake_get_skill_or_raise(_db, _slug: str):
|
||||
return builtin_item
|
||||
|
||||
monkeypatch.setattr(svc, "get_skill_or_raise", fake_get_skill_or_raise)
|
||||
|
||||
with pytest.raises(ValueError, match="内置 skill 不允许直接修改文件"):
|
||||
await svc.update_skill_file(
|
||||
None,
|
||||
slug="reporter",
|
||||
relative_path="SKILL.md",
|
||||
content="new content",
|
||||
updated_by="root",
|
||||
)
|
||||
|
||||
@ -43,6 +43,7 @@
|
||||
- 新增 API Key 认证功能,支持外部系统通过 API Key 调用系统服务
|
||||
- 新增 subagents 的支持,支持在 web 中添加 subagents,以及两个内置的子智能体
|
||||
- 新增内置Skills reporter,并移除内置 Agent reporter,数据库报表将由 Skills 完成
|
||||
- 重构内置 Skills 安装机制:内置 skill 改为在管理页以“未安装”状态展示,支持按需安装、基于 `version + content_hash` 的更新提示与覆盖确认,并对已安装内置 skill 禁止在线文件编辑
|
||||
- 新增知识库 PDF、图片的预览功能
|
||||
|
||||
<!-- 添加到这里 -->
|
||||
@ -122,6 +123,7 @@
|
||||
- 为工作台新增 viewer-oriented filesystem service 与 `/api/viewer/filesystem/*` 接口,解耦 agent backend 语义,支持真实目录浏览、原始文件读取与下载
|
||||
- 重写沙盒技术文档,明确 thread-local sandbox、viewer-oriented filesystem service、`/mnt` 命名空间、skills 可见性与当前实现边界,替换过时的 `/api/sandbox/*` 与 user-level 设计描述
|
||||
- 收紧沙盒遗留代码:修复未注册 `sandbox_router` 中残留的 user/thread 参数错位,改进宿主机挂载路径映射逻辑,并为 remote sandbox provisioner 增加基础 URL 校验与销毁失败日志
|
||||
- 修复 builtin skill 内容哈希计算对单文件使用 `read_bytes()` 的无上限内存读取问题,改为分块计算并补充回归测试
|
||||
|
||||
### 破坏性更新
|
||||
|
||||
|
||||
@ -73,6 +73,13 @@ export async function apiRequest(url, options = {}, requiresAuth = true, respons
|
||||
}
|
||||
|
||||
// 特殊处理401和403错误
|
||||
const error = new Error(errorMessage)
|
||||
error.response = {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data: errorData
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
// 如果是认证失败,可能需要重新登录
|
||||
const userStore = useUserStore()
|
||||
@ -97,14 +104,16 @@ export async function apiRequest(url, options = {}, requiresAuth = true, respons
|
||||
window.location.href = '/login'
|
||||
}, 1500)
|
||||
|
||||
throw new Error('未授权,请先登录')
|
||||
throw error
|
||||
} else if (response.status === 403) {
|
||||
throw new Error('没有权限执行此操作')
|
||||
error.message = '没有权限执行此操作'
|
||||
throw error
|
||||
} else if (response.status === 500) {
|
||||
throw new Error('服务器内部错误,请使用 docker logs api-dev 查看详细日志')
|
||||
error.message = '服务器内部错误,请使用 docker logs api-dev 查看详细日志'
|
||||
throw error
|
||||
}
|
||||
|
||||
throw new Error(errorMessage)
|
||||
throw error
|
||||
}
|
||||
|
||||
// 根据responseType处理响应
|
||||
|
||||
@ -22,6 +22,18 @@ export const getSkillDependencyOptions = async () => {
|
||||
return apiSuperAdminGet(`${BASE_URL}/dependency-options`)
|
||||
}
|
||||
|
||||
export const listBuiltinSkills = async () => {
|
||||
return apiSuperAdminGet(`${BASE_URL}/builtin`)
|
||||
}
|
||||
|
||||
export const installBuiltinSkill = async (slug) => {
|
||||
return apiSuperAdminPost(`${BASE_URL}/builtin/${encodeURIComponent(slug)}/install`)
|
||||
}
|
||||
|
||||
export const updateBuiltinSkill = async (slug, force = false) => {
|
||||
return apiSuperAdminPost(`${BASE_URL}/builtin/${encodeURIComponent(slug)}/update`, { force })
|
||||
}
|
||||
|
||||
export const getSkillTree = async (slug) => {
|
||||
return apiSuperAdminGet(`${BASE_URL}/${encodeURIComponent(slug)}/tree`)
|
||||
}
|
||||
@ -62,6 +74,9 @@ export const skillApi = {
|
||||
listSkills,
|
||||
importSkillZip,
|
||||
getSkillDependencyOptions,
|
||||
listBuiltinSkills,
|
||||
installBuiltinSkill,
|
||||
updateBuiltinSkill,
|
||||
getSkillTree,
|
||||
getSkillFile,
|
||||
createSkillFile,
|
||||
|
||||
@ -18,13 +18,68 @@
|
||||
</div>
|
||||
|
||||
<div class="list-container">
|
||||
<div v-if="filteredSkills.length === 0" class="empty-text">
|
||||
<div v-if="filteredBuiltinSkills.length === 0 && filteredSkills.length === 0" class="empty-text">
|
||||
<a-empty :image="false" description="无匹配技能" />
|
||||
</div>
|
||||
<div v-if="filteredBuiltinSkills.length" class="list-section-title">内置 Skills</div>
|
||||
<template v-for="(skill, index) in filteredBuiltinSkills" :key="`builtin-${skill.slug}`">
|
||||
<div
|
||||
class="list-item"
|
||||
:class="{ active: currentSkill?.slug === skill.slug && currentSkill?.is_builtin_spec }"
|
||||
@click="selectSkill(skill)"
|
||||
>
|
||||
<div class="item-header">
|
||||
<BookMarked :size="16" class="item-icon" />
|
||||
<span class="item-name">{{ skill.name }}</span>
|
||||
<span class="builtin-badge">内置</span>
|
||||
</div>
|
||||
<div class="item-details item-details-inline">
|
||||
<span class="item-slug item-meta mono-text">{{ skill.slug }}</span>
|
||||
<span v-if="skill.status === 'update_available'" class="status-text warning">可更新</span>
|
||||
<span v-else-if="skill.status === 'installed'" class="status-text">已安装</span>
|
||||
<span v-else class="status-text">未安装</span>
|
||||
<div class="item-badges">
|
||||
<span
|
||||
v-if="skill.installed_record?.tool_dependencies?.length"
|
||||
class="dot-badge blue"
|
||||
title="工具依赖"
|
||||
></span>
|
||||
<span
|
||||
v-if="skill.installed_record?.mcp_dependencies?.length"
|
||||
class="dot-badge green"
|
||||
title="MCP依赖"
|
||||
></span>
|
||||
</div>
|
||||
<div class="item-inline-actions">
|
||||
<a-button
|
||||
v-if="skill.status === 'not_installed'"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click.stop="handleInstallBuiltin(skill)"
|
||||
>
|
||||
安装
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else-if="skill.status === 'update_available'"
|
||||
size="small"
|
||||
@click.stop="handleUpdateBuiltin(skill)"
|
||||
>
|
||||
更新
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="index < filteredBuiltinSkills.length - 1 || filteredSkills.length > 0"
|
||||
class="list-separator"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<div v-if="filteredSkills.length" class="list-section-title">已安装 Skills</div>
|
||||
<template v-for="(skill, index) in filteredSkills" :key="skill.slug">
|
||||
<div
|
||||
class="list-item"
|
||||
:class="{ active: currentSkill?.slug === skill.slug }"
|
||||
:class="{ active: currentSkill?.slug === skill.slug && !currentSkill?.is_builtin_spec }"
|
||||
@click="selectSkill(skill)"
|
||||
>
|
||||
<div class="item-header">
|
||||
@ -69,11 +124,34 @@
|
||||
</div>
|
||||
<div class="panel-actions">
|
||||
<a-space :size="8">
|
||||
<a-button size="small" @click="handleExport" class="lucide-icon-btn">
|
||||
<a-button
|
||||
v-if="currentSkill.is_builtin_spec && currentSkill.status === 'not_installed'"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleInstallBuiltin(currentSkill)"
|
||||
class="lucide-icon-btn"
|
||||
>
|
||||
<span>安装</span>
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="currentSkill.is_builtin_spec && currentSkill.status === 'update_available'"
|
||||
size="small"
|
||||
@click="handleUpdateBuiltin(currentSkill)"
|
||||
class="lucide-icon-btn"
|
||||
>
|
||||
<span>更新</span>
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="isInstalledSkill"
|
||||
size="small"
|
||||
@click="handleExport"
|
||||
class="lucide-icon-btn"
|
||||
>
|
||||
<Download :size="14" />
|
||||
<span>导出</span>
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="isInstalledSkill && !isBuiltinInstalledSkill"
|
||||
size="small"
|
||||
danger
|
||||
ghost
|
||||
@ -87,7 +165,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-tabs v-model:activeKey="activeTab" class="minimal-tabs">
|
||||
<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-tab-pane key="editor">
|
||||
<template #tab>
|
||||
<span class="tab-title"><FileText :size="14" />代码管理</span>
|
||||
@ -97,10 +181,10 @@
|
||||
<div class="tree-header">
|
||||
<span class="label">项目结构</span>
|
||||
<div class="tree-actions">
|
||||
<a-tooltip title="新建文件"
|
||||
<a-tooltip v-if="!isBuiltinInstalledSkill" title="新建文件"
|
||||
><button @click="openCreateModal(false)"><FilePlus :size="14" /></button
|
||||
></a-tooltip>
|
||||
<a-tooltip title="新建目录"
|
||||
<a-tooltip v-if="!isBuiltinInstalledSkill" title="新建目录"
|
||||
><button @click="openCreateModal(true)"><FolderPlus :size="14" /></button
|
||||
></a-tooltip>
|
||||
<a-tooltip title="刷新"
|
||||
@ -138,6 +222,7 @@
|
||||
<span>{{ viewMode === 'edit' ? '预览' : '编辑' }}</span>
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="!isBuiltinInstalledSkill"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="saveCurrentFile"
|
||||
@ -168,6 +253,7 @@
|
||||
v-else
|
||||
v-model:value="fileContent"
|
||||
class="pure-editor"
|
||||
:readonly="isBuiltinInstalledSkill"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</template>
|
||||
@ -302,6 +388,7 @@ const searchQuery = ref('')
|
||||
const viewMode = ref('edit') // 'edit' | 'preview'
|
||||
|
||||
const skills = ref([])
|
||||
const builtinSkills = ref([])
|
||||
const currentSkill = ref(null)
|
||||
const treeData = ref([])
|
||||
const selectedTreeKeys = ref([])
|
||||
@ -321,13 +408,30 @@ const dependencyForm = reactive({
|
||||
})
|
||||
|
||||
const filteredSkills = computed(() => {
|
||||
if (!searchQuery.value) return skills.value
|
||||
const installedSkills = (skills.value || []).filter((skill) => !skill.is_builtin)
|
||||
if (!searchQuery.value) return installedSkills
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
return skills.value.filter(
|
||||
return installedSkills.filter(
|
||||
(s) => s.name.toLowerCase().includes(q) || s.slug.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
const filteredBuiltinSkills = computed(() => {
|
||||
if (!searchQuery.value) return builtinSkills.value
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
return builtinSkills.value.filter(
|
||||
(s) => s.name.toLowerCase().includes(q) || s.slug.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
const isInstalledSkill = computed(() => {
|
||||
return !!(currentSkill.value && (currentSkill.value.installed_record || currentSkill.value.dir_path))
|
||||
})
|
||||
|
||||
const isBuiltinInstalledSkill = computed(() => {
|
||||
return !!(isInstalledSkill.value && (currentSkill.value?.is_builtin || currentSkill.value?.installed_record))
|
||||
})
|
||||
|
||||
const canSave = computed(() => {
|
||||
if (!selectedPath.value || selectedIsDir.value) return false
|
||||
return fileContent.value !== originalFileContent.value
|
||||
@ -385,17 +489,28 @@ const expandAllKeys = (nodes) =>
|
||||
const fetchSkills = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await skillApi.listSkills()
|
||||
skills.value = result?.data || []
|
||||
const [skillResult, builtinResult] = await Promise.all([
|
||||
skillApi.listSkills(),
|
||||
skillApi.listBuiltinSkills()
|
||||
])
|
||||
skills.value = skillResult?.data || []
|
||||
builtinSkills.value = (builtinResult?.data || []).map((item) => ({
|
||||
...item,
|
||||
...(item.installed_record || {}),
|
||||
is_builtin_spec: true
|
||||
}))
|
||||
|
||||
// 默认选中第一个技能并加载 SKILL.md
|
||||
if (!currentSkill.value && skills.value.length > 0) {
|
||||
await selectSkill(skills.value[0])
|
||||
const preferredList = builtinSkills.value.length ? builtinSkills.value : filteredSkills.value
|
||||
if (!currentSkill.value && preferredList.length > 0) {
|
||||
await selectSkill(preferredList[0])
|
||||
} else if (currentSkill.value) {
|
||||
const latest = skills.value.find((i) => i.slug === currentSkill.value.slug)
|
||||
const latest =
|
||||
builtinSkills.value.find((i) => i.slug === currentSkill.value.slug) ||
|
||||
skills.value.find((i) => i.slug === currentSkill.value.slug)
|
||||
if (latest) {
|
||||
currentSkill.value = latest
|
||||
syncDependencyFormFromSkill(latest)
|
||||
syncDependencyFormFromSkill(latest.installed_record || latest)
|
||||
} else {
|
||||
currentSkill.value = null
|
||||
treeData.value = []
|
||||
@ -429,7 +544,7 @@ const syncDependencyFormFromSkill = (skillRecord) => {
|
||||
}
|
||||
|
||||
const reloadTree = async () => {
|
||||
if (!currentSkill.value) return
|
||||
if (!currentSkill.value || !isInstalledSkill.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await skillApi.getSkillTree(currentSkill.value.slug)
|
||||
@ -459,8 +574,13 @@ const loadSkillFile = async (slug, path = 'SKILL.md') => {
|
||||
|
||||
const selectSkill = async (record) => {
|
||||
currentSkill.value = record
|
||||
syncDependencyFormFromSkill(record)
|
||||
resetFileState()
|
||||
syncDependencyFormFromSkill(record.installed_record || record)
|
||||
|
||||
if (!record.installed_record && !record.dir_path) {
|
||||
treeData.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// 并行执行:加载树结构和获取 SKILL.md
|
||||
await Promise.all([reloadTree(), loadSkillFile(record.slug)])
|
||||
@ -493,7 +613,7 @@ const handleTreeSelect = async (keys, info) => {
|
||||
}
|
||||
|
||||
const saveCurrentFile = async () => {
|
||||
if (!currentSkill.value || !selectedPath.value || selectedIsDir.value) return
|
||||
if (!currentSkill.value || !selectedPath.value || selectedIsDir.value || isBuiltinInstalledSkill.value) return
|
||||
savingFile.value = true
|
||||
try {
|
||||
await skillApi.updateSkillFile(currentSkill.value.slug, {
|
||||
@ -510,6 +630,62 @@ const saveCurrentFile = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleInstallBuiltin = async (record) => {
|
||||
if (!record?.slug) return
|
||||
loading.value = true
|
||||
try {
|
||||
await skillApi.installBuiltinSkill(record.slug)
|
||||
await fetchSkills()
|
||||
const latest = builtinSkills.value.find((item) => item.slug === record.slug)
|
||||
if (latest) await selectSkill(latest)
|
||||
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 fetchSkills()
|
||||
const latest = builtinSkills.value.find((item) => item.slug === record.slug)
|
||||
if (latest) await selectSkill(latest)
|
||||
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 fetchSkills()
|
||||
const latest = builtinSkills.value.find((item) => item.slug === record.slug)
|
||||
if (latest) await selectSkill(latest)
|
||||
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 openCreateModal = (isDir) => {
|
||||
if (!currentSkill.value) return
|
||||
createForm.path = ''
|
||||
@ -519,7 +695,7 @@ const openCreateModal = (isDir) => {
|
||||
}
|
||||
|
||||
const handleCreateNode = async () => {
|
||||
if (!currentSkill.value || !createForm.path.trim()) return
|
||||
if (!currentSkill.value || !createForm.path.trim() || isBuiltinInstalledSkill.value) return
|
||||
creatingNode.value = true
|
||||
try {
|
||||
await skillApi.createSkillFile(currentSkill.value.slug, {
|
||||
@ -538,7 +714,7 @@ const handleCreateNode = async () => {
|
||||
}
|
||||
|
||||
const confirmDeleteSkill = () => {
|
||||
if (!currentSkill.value) return
|
||||
if (!currentSkill.value || !isInstalledSkill.value) return
|
||||
Modal.confirm({
|
||||
title: `彻底删除技能「${currentSkill.value.slug}」?`,
|
||||
content: '删除后无法恢复,所有文件和配置将永久消失。',
|
||||
@ -561,7 +737,7 @@ const confirmDeleteSkill = () => {
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!currentSkill.value) return
|
||||
if (!currentSkill.value || !isInstalledSkill.value) return
|
||||
try {
|
||||
const response = await skillApi.exportSkill(currentSkill.value.slug)
|
||||
const blob = await response.blob()
|
||||
@ -597,7 +773,7 @@ const handleImportUpload = async ({ file, onSuccess, onError }) => {
|
||||
}
|
||||
|
||||
const saveDependencies = async () => {
|
||||
if (!currentSkill.value) return
|
||||
if (!currentSkill.value || !isInstalledSkill.value) return
|
||||
savingDependencies.value = true
|
||||
try {
|
||||
const result = await skillApi.updateSkillDependencies(currentSkill.value.slug, {
|
||||
@ -631,8 +807,44 @@ defineExpose({
|
||||
<style scoped lang="less">
|
||||
@import '@/assets/css/extensions.less';
|
||||
|
||||
.list-section-title {
|
||||
padding: 10px 14px 6px;
|
||||
color: var(--gray-500);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
.item-header {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.builtin-badge {
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-primary-50);
|
||||
color: var(--color-primary-700);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.item-details {
|
||||
align-items: center;
|
||||
|
||||
.status-text {
|
||||
color: var(--gray-500);
|
||||
font-size: 12px;
|
||||
&.warning {
|
||||
color: #d97706;
|
||||
}
|
||||
}
|
||||
|
||||
.item-inline-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.item-badges {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
@ -651,6 +863,18 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user