Merge pull request #601 from xerrors/feat/skills-update
feat: Enhance skill management with remote installation and file upload
This commit is contained in:
commit
6b9b0a0637
@ -7,10 +7,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
from collections.abc import Callable, Iterable, Mapping
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, cast, override
|
||||
|
||||
from langchain.agents import AgentState
|
||||
|
||||
211
backend/package/yuxi/services/remote_skill_install_service.py
Normal file
211
backend/package/yuxi/services/remote_skill_install_service.py
Normal file
@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from yuxi.services.skill_service import import_skill_dir, is_valid_skill_slug
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from yuxi.storage.postgres.models_business import Skill
|
||||
|
||||
ANSI_ESCAPE_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
|
||||
CONTROL_SEQUENCE_RE = re.compile(r"\x1B\][^\x07]*(?:\x07|\x1B\\)|\x1B[\(\)][A-Za-z0-9]")
|
||||
CLI_TIMEOUT_SECONDS = 300
|
||||
|
||||
|
||||
def _normalize_source(source: str) -> str:
|
||||
value = str(source or "").strip()
|
||||
if not value:
|
||||
raise ValueError("source 不能为空")
|
||||
if any(ch in value for ch in ("\n", "\r", "\x00")):
|
||||
raise ValueError("source 包含非法字符")
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_skill_name(skill: str) -> str:
|
||||
value = str(skill or "").strip()
|
||||
if not is_valid_skill_slug(value):
|
||||
raise ValueError("skill 名称不合法")
|
||||
return value
|
||||
|
||||
|
||||
def _clean_cli_output(output: str) -> list[str]:
|
||||
cleaned = ANSI_ESCAPE_RE.sub("", output or "")
|
||||
cleaned = CONTROL_SEQUENCE_RE.sub("", cleaned)
|
||||
cleaned = cleaned.replace("\r", "\n")
|
||||
normalized_lines: list[str] = []
|
||||
for line in cleaned.splitlines():
|
||||
stripped = line.strip()
|
||||
stripped = re.sub(r"^[│┌└◇◒◐◓◑■●]+\s*", "", stripped)
|
||||
normalized_lines.append(stripped.strip())
|
||||
return normalized_lines
|
||||
|
||||
|
||||
def _parse_available_skills(output: str) -> list[dict[str, str]]:
|
||||
lines = _clean_cli_output(output)
|
||||
items: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
collecting = False
|
||||
|
||||
for idx, line in enumerate(lines):
|
||||
if not collecting:
|
||||
if "Available Skills" in line:
|
||||
collecting = True
|
||||
continue
|
||||
|
||||
if not line:
|
||||
continue
|
||||
if "Use --skill " in line:
|
||||
break
|
||||
if not is_valid_skill_slug(line):
|
||||
continue
|
||||
if line in seen:
|
||||
continue
|
||||
|
||||
description = ""
|
||||
next_index = idx + 1
|
||||
while next_index < len(lines):
|
||||
next_line = lines[next_index]
|
||||
next_index += 1
|
||||
if not next_line:
|
||||
continue
|
||||
if "Use --skill " in next_line:
|
||||
break
|
||||
if is_valid_skill_slug(next_line):
|
||||
break
|
||||
if next_line and next_line[0].isalpha():
|
||||
description = next_line
|
||||
else:
|
||||
continue
|
||||
break
|
||||
|
||||
seen.add(line)
|
||||
items.append({"name": line, "description": description})
|
||||
|
||||
return items
|
||||
|
||||
|
||||
async def _run_skills_cli(
|
||||
args: list[str],
|
||||
*,
|
||||
env: dict[str, str],
|
||||
cwd: str,
|
||||
) -> str:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=CLI_TIMEOUT_SECONDS)
|
||||
except TimeoutError:
|
||||
process.kill()
|
||||
await process.communicate()
|
||||
raise ValueError("skills CLI 执行超时") from None
|
||||
|
||||
output = (stdout or b"").decode("utf-8", errors="replace")
|
||||
error_output = (stderr or b"").decode("utf-8", errors="replace")
|
||||
combined = "\n".join(part for part in [output.strip(), error_output.strip()] if part)
|
||||
if process.returncode != 0:
|
||||
cleaned_lines = _clean_cli_output(combined)
|
||||
error_msg = "\n".join(line for line in cleaned_lines if line)[:500]
|
||||
raise ValueError(error_msg or "skills CLI 执行失败")
|
||||
return combined
|
||||
|
||||
|
||||
def _create_isolated_workdir() -> tuple[str, dict[str, str], str]:
|
||||
temp_home = tempfile.mkdtemp(prefix=".remote-skills-")
|
||||
env = os.environ.copy()
|
||||
env["HOME"] = temp_home
|
||||
workdir = str(Path(temp_home) / "workspace")
|
||||
Path(workdir).mkdir(parents=True, exist_ok=True)
|
||||
return temp_home, env, workdir
|
||||
|
||||
|
||||
async def list_remote_skills(source: str) -> list[dict[str, str]]:
|
||||
normalized_source = _normalize_source(source)
|
||||
|
||||
temp_home, env, workdir = _create_isolated_workdir()
|
||||
try:
|
||||
output = await _run_skills_cli(
|
||||
["npx", "-y", "skills", "add", normalized_source, "--list"],
|
||||
env=env,
|
||||
cwd=workdir,
|
||||
)
|
||||
finally:
|
||||
shutil.rmtree(temp_home, ignore_errors=True)
|
||||
|
||||
skills = _parse_available_skills(output)
|
||||
if not skills:
|
||||
raise ValueError("未发现可安装的 skills")
|
||||
return skills
|
||||
|
||||
|
||||
async def install_remote_skill(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
source: str,
|
||||
skill: str,
|
||||
created_by: str | None,
|
||||
) -> Skill:
|
||||
normalized_source = _normalize_source(source)
|
||||
normalized_skill = _normalize_skill_name(skill)
|
||||
|
||||
temp_home, env, workdir = _create_isolated_workdir()
|
||||
try:
|
||||
available_skills = _parse_available_skills(
|
||||
await _run_skills_cli(
|
||||
["npx", "-y", "skills", "add", normalized_source, "--list"],
|
||||
env=env,
|
||||
cwd=workdir,
|
||||
)
|
||||
)
|
||||
available_names = {item["name"] for item in available_skills}
|
||||
if normalized_skill not in available_names:
|
||||
raise ValueError(f"远程仓库中不存在 skill: {normalized_skill}")
|
||||
|
||||
await _run_skills_cli(
|
||||
[
|
||||
"npx",
|
||||
"-y",
|
||||
"skills",
|
||||
"add",
|
||||
normalized_source,
|
||||
"--skill",
|
||||
normalized_skill,
|
||||
"-g",
|
||||
"-y",
|
||||
"--copy",
|
||||
],
|
||||
env=env,
|
||||
cwd=workdir,
|
||||
)
|
||||
|
||||
base_dir = Path(temp_home).resolve()
|
||||
skills_dir = base_dir / ".agents" / "skills"
|
||||
# Scan for the installed skill directory rather than constructing the path
|
||||
# from user input, to avoid path traversal concerns
|
||||
installed_dir = None
|
||||
if skills_dir.is_dir():
|
||||
for candidate in skills_dir.iterdir():
|
||||
if candidate.name == normalized_skill and candidate.is_dir():
|
||||
installed_dir = candidate
|
||||
break
|
||||
if installed_dir is None:
|
||||
raise ValueError("skills CLI 未生成预期的技能目录")
|
||||
|
||||
return await import_skill_dir(
|
||||
db,
|
||||
source_dir=installed_dir,
|
||||
created_by=created_by,
|
||||
)
|
||||
finally:
|
||||
shutil.rmtree(temp_home, ignore_errors=True)
|
||||
@ -429,6 +429,63 @@ async def _generate_available_slug(repo: SkillRepository, base_slug: str) -> str
|
||||
idx += 1
|
||||
|
||||
|
||||
async def _import_skill_dir_impl(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
source_skill_dir: Path,
|
||||
created_by: str | None,
|
||||
) -> Skill:
|
||||
repo = SkillRepository(db)
|
||||
skills_root = get_skills_root_dir()
|
||||
|
||||
skill_md_path = source_skill_dir / "SKILL.md"
|
||||
if not skill_md_path.exists() or not skill_md_path.is_file():
|
||||
raise ValueError("技能目录缺少根级 SKILL.md")
|
||||
|
||||
content = skill_md_path.read_text(encoding="utf-8")
|
||||
parsed_name, parsed_desc, _ = _parse_skill_markdown(content)
|
||||
|
||||
final_slug = await _generate_available_slug(repo, parsed_name)
|
||||
final_name = parsed_name
|
||||
with tempfile.TemporaryDirectory(prefix=".skill-import-", dir=str(skills_root.parent)) as temp_root:
|
||||
temp_root_path = Path(temp_root)
|
||||
stage_dir = temp_root_path / "stage"
|
||||
shutil.copytree(source_skill_dir, stage_dir)
|
||||
|
||||
if final_slug != parsed_name:
|
||||
final_name = final_slug
|
||||
content = _rewrite_frontmatter_name(content, final_name)
|
||||
(stage_dir / "SKILL.md").write_text(content, encoding="utf-8")
|
||||
|
||||
temp_target = skills_root / f".{final_slug}.tmp-{uuid.uuid4().hex[:8]}"
|
||||
if temp_target.exists():
|
||||
shutil.rmtree(temp_target)
|
||||
shutil.move(str(stage_dir), str(temp_target))
|
||||
|
||||
final_dir = skills_root / final_slug
|
||||
if final_dir.exists():
|
||||
shutil.rmtree(temp_target, ignore_errors=True)
|
||||
raise ValueError(f"技能目录冲突,请重试: {final_slug}")
|
||||
temp_target.rename(final_dir)
|
||||
|
||||
try:
|
||||
item = await repo.create(
|
||||
slug=final_slug,
|
||||
name=final_name,
|
||||
description=parsed_desc,
|
||||
tool_dependencies=[],
|
||||
mcp_dependencies=[],
|
||||
skill_dependencies=[],
|
||||
dir_path=(Path("skills") / final_slug).as_posix(),
|
||||
created_by=created_by,
|
||||
)
|
||||
except Exception:
|
||||
shutil.rmtree(final_dir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
return item
|
||||
|
||||
|
||||
def _resolve_skill_dir(item: Skill) -> Path:
|
||||
dir_path = Path(item.dir_path)
|
||||
if dir_path.is_absolute():
|
||||
@ -492,70 +549,62 @@ async def import_skill_zip(
|
||||
file_bytes: bytes,
|
||||
created_by: str | None,
|
||||
) -> Skill:
|
||||
if not filename.lower().endswith(".zip"):
|
||||
raise ValueError("仅支持上传 .zip 文件")
|
||||
normalized_filename = filename.lower()
|
||||
is_zip_upload = normalized_filename.endswith(".zip")
|
||||
is_skill_md_upload = normalized_filename.endswith("skill.md")
|
||||
if not is_zip_upload and not is_skill_md_upload:
|
||||
raise ValueError("仅支持上传 .zip 或 SKILL.md 文件")
|
||||
|
||||
repo = SkillRepository(db)
|
||||
skills_root = get_skills_root_dir()
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix=".skill-import-", dir=str(skills_root.parent)) as temp_root:
|
||||
temp_root_path = Path(temp_root)
|
||||
zip_path = temp_root_path / "upload.zip"
|
||||
extract_dir = temp_root_path / "extract"
|
||||
stage_dir = temp_root_path / "stage"
|
||||
extract_dir.mkdir(parents=True, exist_ok=True)
|
||||
if is_zip_upload:
|
||||
zip_path = temp_root_path / "upload.zip"
|
||||
zip_path.write_bytes(file_bytes)
|
||||
|
||||
zip_path.write_bytes(file_bytes)
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
_validate_zip_paths(zf)
|
||||
zf.extractall(extract_dir)
|
||||
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
_validate_zip_paths(zf)
|
||||
zf.extractall(extract_dir)
|
||||
skill_md_files = list(extract_dir.rglob("SKILL.md"))
|
||||
if len(skill_md_files) != 1:
|
||||
raise ValueError("ZIP 必须且只能包含一个技能(检测到一个 SKILL.md)")
|
||||
|
||||
skill_md_files = list(extract_dir.rglob("SKILL.md"))
|
||||
if len(skill_md_files) != 1:
|
||||
raise ValueError("ZIP 必须且只能包含一个技能(检测到一个 SKILL.md)")
|
||||
skill_md_path = skill_md_files[0]
|
||||
source_skill_dir = skill_md_path.parent
|
||||
else:
|
||||
source_skill_dir = extract_dir
|
||||
skill_md_path = source_skill_dir / "SKILL.md"
|
||||
skill_md_path.write_bytes(file_bytes)
|
||||
|
||||
skill_md_path = skill_md_files[0]
|
||||
source_skill_dir = skill_md_path.parent
|
||||
content = skill_md_path.read_text(encoding="utf-8")
|
||||
parsed_name, parsed_desc, _ = _parse_skill_markdown(content)
|
||||
return await _import_skill_dir_impl(
|
||||
db,
|
||||
source_skill_dir=source_skill_dir,
|
||||
created_by=created_by,
|
||||
)
|
||||
|
||||
final_slug = await _generate_available_slug(repo, parsed_name)
|
||||
final_name = parsed_name
|
||||
if final_slug != parsed_name:
|
||||
final_name = final_slug
|
||||
content = _rewrite_frontmatter_name(content, final_name)
|
||||
skill_md_path.write_text(content, encoding="utf-8")
|
||||
|
||||
shutil.copytree(source_skill_dir, stage_dir)
|
||||
|
||||
temp_target = skills_root / f".{final_slug}.tmp-{uuid.uuid4().hex[:8]}"
|
||||
if temp_target.exists():
|
||||
shutil.rmtree(temp_target)
|
||||
shutil.move(str(stage_dir), str(temp_target))
|
||||
|
||||
final_dir = skills_root / final_slug
|
||||
if final_dir.exists():
|
||||
shutil.rmtree(temp_target, ignore_errors=True)
|
||||
raise ValueError(f"技能目录冲突,请重试: {final_slug}")
|
||||
temp_target.rename(final_dir)
|
||||
|
||||
try:
|
||||
item = await repo.create(
|
||||
slug=final_slug,
|
||||
name=final_name,
|
||||
description=parsed_desc,
|
||||
tool_dependencies=[],
|
||||
mcp_dependencies=[],
|
||||
skill_dependencies=[],
|
||||
dir_path=(Path("skills") / final_slug).as_posix(),
|
||||
created_by=created_by,
|
||||
)
|
||||
except Exception:
|
||||
shutil.rmtree(final_dir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
return item
|
||||
async def import_skill_dir(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
source_dir: Path | str,
|
||||
created_by: str | None,
|
||||
) -> Skill:
|
||||
source_skill_dir = Path(source_dir).resolve()
|
||||
# Confine to the system temp directory to prevent path traversal
|
||||
tmp_root = Path(tempfile.gettempdir()).resolve()
|
||||
if not source_skill_dir.is_relative_to(tmp_root):
|
||||
raise ValueError("技能目录路径不合法")
|
||||
if not source_skill_dir.exists() or not source_skill_dir.is_dir():
|
||||
raise ValueError("技能目录不存在")
|
||||
return await _import_skill_dir_impl(
|
||||
db,
|
||||
source_skill_dir=source_skill_dir,
|
||||
created_by=created_by,
|
||||
)
|
||||
|
||||
|
||||
async def get_skill_or_raise(db: AsyncSession, slug: str) -> Skill:
|
||||
|
||||
@ -10,6 +10,7 @@ from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from server.utils.auth_middleware import get_admin_user, get_db, get_superadmin_user
|
||||
from yuxi.services.remote_skill_install_service import install_remote_skill, list_remote_skills
|
||||
from yuxi.services.skill_service import (
|
||||
BuiltinSkillUpdateConflictError,
|
||||
create_skill_node,
|
||||
@ -54,6 +55,14 @@ 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 名称")
|
||||
|
||||
|
||||
def _raise_from_value_error(e: ValueError) -> None:
|
||||
message = str(e)
|
||||
status_code = 404 if "不存在" in message else 400
|
||||
@ -182,7 +191,7 @@ async def import_skill_route(
|
||||
current_user: User = Depends(get_superadmin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""导入技能压缩包(仅超级管理员)。"""
|
||||
"""导入技能包(支持 ZIP 或单个 SKILL.md,仅超级管理员)。"""
|
||||
try:
|
||||
file_bytes = await file.read()
|
||||
item = await import_skill_zip(
|
||||
@ -197,10 +206,51 @@ async def import_skill_route(
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import skill zip: {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_superadmin_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_superadmin_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.get("/{slug}/tree")
|
||||
async def get_skill_tree_route(
|
||||
slug: str,
|
||||
|
||||
@ -72,6 +72,37 @@ def test_import_skill_requires_superadmin():
|
||||
assert 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):
|
||||
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,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("server.routers.skill_router.import_skill_zip", fake_import_skill_zip)
|
||||
|
||||
app = _build_app(allow_superadmin=True)
|
||||
client = TestClient(app)
|
||||
|
||||
resp = client.post(
|
||||
"/api/system/skills/import",
|
||||
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"] == "root"
|
||||
|
||||
|
||||
def test_update_skill_file_passes_operator(monkeypatch):
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
@ -163,3 +194,52 @@ def test_update_skill_dependencies_route(monkeypatch):
|
||||
assert captured["mcp_dependencies"] == ["mcp-a"]
|
||||
assert captured["skill_dependencies"] == ["other-skill"]
|
||||
assert captured["updated_by"] == "root"
|
||||
|
||||
|
||||
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"}]
|
||||
|
||||
monkeypatch.setattr("server.routers.skill_router.list_remote_skills", fake_list_remote_skills)
|
||||
|
||||
app = _build_app(allow_superadmin=True)
|
||||
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"}]
|
||||
|
||||
|
||||
def test_install_remote_skill_route(monkeypatch):
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
async def fake_install_remote_skill(_db, *, source, skill, created_by):
|
||||
captured["source"] = source
|
||||
captured["skill"] = skill
|
||||
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,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("server.routers.skill_router.install_remote_skill", fake_install_remote_skill)
|
||||
|
||||
app = _build_app(allow_superadmin=True)
|
||||
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"] == "root"
|
||||
|
||||
145
backend/test/unit/services/test_remote_skill_install_service.py
Normal file
145
backend/test/unit/services/test_remote_skill_install_service.py
Normal file
@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.services import remote_skill_install_service as svc
|
||||
|
||||
|
||||
def test_parse_available_skills_from_cli_output() -> None:
|
||||
output = """
|
||||
\x1b[38;5;250m███████╗\x1b[0m
|
||||
◇ Available Skills
|
||||
Claude Api
|
||||
|
||||
claude-api
|
||||
|
||||
Build apps with the Claude API.
|
||||
|
||||
Example Skills
|
||||
|
||||
frontend-design
|
||||
|
||||
Create distinctive frontend interfaces.
|
||||
|
||||
└ Use --skill <name> to install specific skills
|
||||
"""
|
||||
|
||||
skills = svc._parse_available_skills(output)
|
||||
|
||||
assert skills == [
|
||||
{"name": "claude-api", "description": "Build apps with the Claude API."},
|
||||
{"name": "frontend-design", "description": "Create distinctive frontend interfaces."},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_remote_skills_uses_isolated_home(monkeypatch: pytest.MonkeyPatch):
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_run_skills_cli(args: list[str], *, env: dict[str, str], cwd: str) -> str:
|
||||
captured["args"] = args
|
||||
captured["home"] = env["HOME"]
|
||||
captured["cwd"] = cwd
|
||||
return """
|
||||
◇ Available Skills
|
||||
|
||||
frontend-design
|
||||
|
||||
Create distinctive frontend interfaces.
|
||||
|
||||
└ Use --skill <name> to install specific skills
|
||||
"""
|
||||
|
||||
monkeypatch.setattr(svc, "_run_skills_cli", fake_run_skills_cli)
|
||||
|
||||
items = await svc.list_remote_skills("anthropics/skills")
|
||||
|
||||
assert items == [{"name": "frontend-design", "description": "Create distinctive frontend interfaces."}]
|
||||
assert captured["args"] == ["npx", "-y", "skills", "add", "anthropics/skills", "--list"]
|
||||
assert str(captured["cwd"]).startswith(str(captured["home"]))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_remote_skill_imports_from_cli_output_dir(monkeypatch: pytest.MonkeyPatch):
|
||||
calls: list[tuple[list[str], str]] = []
|
||||
|
||||
async def fake_run_skills_cli(args: list[str], *, env: dict[str, str], cwd: str) -> str:
|
||||
calls.append((args, env["HOME"]))
|
||||
home = Path(env["HOME"])
|
||||
if "--list" in args:
|
||||
return """
|
||||
◇ Available Skills
|
||||
|
||||
frontend-design
|
||||
|
||||
Create distinctive frontend interfaces.
|
||||
|
||||
└ Use --skill <name> to install specific skills
|
||||
"""
|
||||
skill_dir = home / ".agents" / "skills" / "frontend-design"
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: frontend-design\ndescription: demo\n---\n# Demo\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return "installed"
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
async def fake_import_skill_dir(_db, *, source_dir, created_by):
|
||||
captured["source_dir"] = Path(source_dir)
|
||||
captured["created_by"] = created_by
|
||||
return {"slug": "frontend-design"}
|
||||
|
||||
monkeypatch.setattr(svc, "_run_skills_cli", fake_run_skills_cli)
|
||||
monkeypatch.setattr(svc, "import_skill_dir", fake_import_skill_dir)
|
||||
|
||||
item = await svc.install_remote_skill(
|
||||
None,
|
||||
source="anthropics/skills",
|
||||
skill="frontend-design",
|
||||
created_by="root",
|
||||
)
|
||||
|
||||
assert item == {"slug": "frontend-design"}
|
||||
assert calls[0][0] == ["npx", "-y", "skills", "add", "anthropics/skills", "--list"]
|
||||
assert calls[1][0] == [
|
||||
"npx",
|
||||
"-y",
|
||||
"skills",
|
||||
"add",
|
||||
"anthropics/skills",
|
||||
"--skill",
|
||||
"frontend-design",
|
||||
"-g",
|
||||
"-y",
|
||||
"--copy",
|
||||
]
|
||||
assert captured["source_dir"] == Path(calls[1][1]) / ".agents" / "skills" / "frontend-design"
|
||||
assert captured["created_by"] == "root"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_remote_skill_rejects_missing_remote_skill(monkeypatch: pytest.MonkeyPatch):
|
||||
async def fake_run_skills_cli(args: list[str], *, env: dict[str, str], cwd: str) -> str:
|
||||
return """
|
||||
◇ Available Skills
|
||||
|
||||
other-skill
|
||||
|
||||
Description
|
||||
|
||||
└ Use --skill <name> to install specific skills
|
||||
"""
|
||||
|
||||
monkeypatch.setattr(svc, "_run_skills_cli", fake_run_skills_cli)
|
||||
|
||||
with pytest.raises(ValueError, match="不存在 skill"):
|
||||
await svc.install_remote_skill(
|
||||
None,
|
||||
source="anthropics/skills",
|
||||
skill="frontend-design",
|
||||
created_by="root",
|
||||
)
|
||||
@ -165,6 +165,76 @@ async def test_import_skill_zip_conflict_rewrite_name(tmp_path: Path, monkeypatc
|
||||
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
|
||||
):
|
||||
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
|
||||
|
||||
class FakeRepo:
|
||||
created_item: Skill | None = None
|
||||
|
||||
def __init__(self, _db):
|
||||
pass
|
||||
|
||||
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,
|
||||
)
|
||||
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(
|
||||
None,
|
||||
filename="SKILL.md",
|
||||
file_bytes=skill_md.encode("utf-8"),
|
||||
created_by="root",
|
||||
)
|
||||
|
||||
assert item.slug == "demo"
|
||||
assert item.name == "demo"
|
||||
assert (tmp_path / "skills" / "demo" / "SKILL.md").read_text(encoding="utf-8") == skill_md
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_skill_dir_requires_root_skill_md(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
|
||||
source_dir = tmp_path / "source-skill"
|
||||
source_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with pytest.raises(ValueError, match="根级 SKILL.md"):
|
||||
await svc.import_skill_dir(
|
||||
None,
|
||||
source_dir=source_dir,
|
||||
created_by="root",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_skill_md_syncs_metadata(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(svc.sys_config, "save_dir", str(tmp_path))
|
||||
|
||||
@ -15,7 +15,9 @@ ENV TZ=Asia/Shanghai \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN npm install -g npm@latest && npm cache clean --force
|
||||
# 设置 npm 镜像源,为 MCP 和 Skills 安装依赖
|
||||
RUN npm config set registry https://registry.npmmirror.com --global \
|
||||
&& npm cache clean --force
|
||||
|
||||
# 设置代理和时区,更换镜像源,安装系统依赖 - 合并为一个RUN减少层数
|
||||
RUN set -ex \
|
||||
@ -29,6 +31,7 @@ RUN set -ex \
|
||||
&& apt-get install -y --no-install-recommends --fix-missing \
|
||||
curl \
|
||||
ffmpeg \
|
||||
git \
|
||||
libpq5 \
|
||||
libsm6 \
|
||||
libxext6 \
|
||||
@ -36,7 +39,6 @@ RUN set -ex \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
# 复制项目配置文件
|
||||
COPY ../backend/pyproject.toml /app/pyproject.toml
|
||||
COPY ../backend/.python-version /app/.python-version
|
||||
|
||||
@ -42,11 +42,12 @@ Skills 系统采用「文件系统存内容,数据库存索引」的分离架
|
||||
|
||||
## 创建方式
|
||||
|
||||
系统提供三种方式创建 Skills:
|
||||
系统提供四种方式创建 Skills:
|
||||
|
||||
1. **ZIP 导入(推荐)**:将 Skill 目录打包成 ZIP,通过管理界面上传导入
|
||||
2. **在线创建**:通过 Skills 管理页面在线创建目录和文件
|
||||
3. **手动导入**:直接操作数据库(不推荐,需要手动同步文件系统和数据库)
|
||||
3. **远程仓库安装**:在 Skills 管理页面填写 skills 仓库地址和 skill 名称,由后端调用 `npx skills` 下载后再导入系统
|
||||
4. **手动导入**:直接操作数据库(不推荐,需要手动同步文件系统和数据库)
|
||||
|
||||
## Skills 来源
|
||||
|
||||
@ -102,7 +103,7 @@ description: 这是一个用于处理特定任务的技能
|
||||
|
||||
### 导入 Skill
|
||||
|
||||
有两种方式可以导入 Skill:
|
||||
有三种方式可以导入 Skill:
|
||||
|
||||
**方式一:通过 ZIP 包导入(推荐)**
|
||||
|
||||
@ -122,6 +123,21 @@ description: 这是一个用于处理特定任务的技能
|
||||
- 在线编辑文本文件(支持 .md、.py、.js、.json 等格式)
|
||||
- 直接在网页上编写 SKILL.md 内容
|
||||
|
||||
**方式三:从远程 skills 仓库安装**
|
||||
|
||||
1. 在 Skills 管理页面的“远程安装”面板中填写仓库来源,例如 `anthropics/skills` 或完整 GitHub URL
|
||||
2. 点击“查看可安装 Skills”获取该仓库中可发现的 skills 列表
|
||||
3. 选择或输入目标 skill 名称后点击“安装”
|
||||
|
||||
系统会在后端:
|
||||
- 调用 `npx skills add <source> --list` 校验来源并发现可安装的 skills
|
||||
- 使用隔离的临时 `HOME` 执行 `npx skills add <source> --skill <name> -g -y --copy`
|
||||
- 从临时目录中提取对应 skill,再按现有导入流程写入 `/app/saves/skills` 与数据库
|
||||
|
||||
::: tip 远程安装不会把 ~/.agents/skills 作为系统主存储
|
||||
远程安装只把 `skills.sh` CLI 作为“下载器”使用。Yuxi 仍然以 `/app/saves/skills + skills 表` 作为正式来源,这样才能与现有的权限、线程可见性和沙盒挂载机制保持一致。
|
||||
:::
|
||||
|
||||
## 依赖系统
|
||||
|
||||
Skills 之间可以建立依赖关系,形成一个松耦合的技能网络。
|
||||
|
||||
@ -9,17 +9,11 @@
|
||||
|
||||
### 看板
|
||||
|
||||
- 集成 LangFuse (观望) 添加用户日志与用户反馈模块,可以在 AgentView 中查看信息
|
||||
- Langfuse 增加 self-host 模式支持,补齐私有化部署与配置说明
|
||||
- 部分场景应该使用默认模型作为默认值而不是空值
|
||||
- 检索测试中,添加问答
|
||||
- 集成 Memory,基于 deepagents 的文件后端实现
|
||||
- 添加自定义向量模型和 rerank 模型的配置,在网页上面
|
||||
- 调研轻便的文件展示与编辑器
|
||||
- 移除 TODO 的模块与设计,移除这个中间件。
|
||||
- Yuxi-cli 相关的功能,放在后续版本中实现(不是类似于编程助手,而是工具)
|
||||
- 工作区实现长期记忆保存,要求模型只能将结果写入到 outputs 文件夹
|
||||
- 新增 present file 工具,提供预览以及保存到工作区的选项
|
||||
|
||||
### Bugs
|
||||
- 部分异常状态下,智能体的模型名称出现重叠[#279](https://github.com/xerrors/Yuxi/issues/279)
|
||||
@ -38,7 +32,10 @@
|
||||
- 修复沙盒 `workspace` 隔离粒度:宿主机目录从共享 `saves/threads/shared/workspace` 收敛为用户级 `saves/threads/shared/<user_id>/workspace`,并同步传递 `user_id` 到 sandbox 路径解析、provisioner 挂载与 viewer/chat 测试,保证同用户跨线程共享、不同用户隔离。
|
||||
- 调整输入框 `@` 提及中的文件搜索交互:无查询内容时不再直接展示文件列表,改为提示“输入相关内容以搜索文件”,避免未过滤结果干扰选择。
|
||||
- 收紧文件系统安全边界:viewer/chat 下载与删除路径统一基于解析后的真实路径做允许目录校验,阻止通过软链接逃逸工作区/线程目录;同时将密码哈希默认实现升级为 Argon2,并移除 skill frontmatter 解析中的正则回溯风险。
|
||||
- 调整 Skills 导入能力:`/api/system/skills/import` 现在除 ZIP 外也支持直接上传单个 `SKILL.md`,前端上传入口与后端导入服务同步兼容,便于快速导入单文件技能
|
||||
- 新增 Skills 远程安装能力:Skills 管理页支持填写 `owner/repo` 或 GitHub URL,后端通过隔离的临时 `HOME` 调用 `npx skills add` 下载指定 skill,再复用现有导入链路写入 `saves/skills` 和数据库,避免将 `~/.agents/skills` 直接作为系统主存储;前端远程安装弹窗补充多选串行安装与批量进度展示,复用现有单 skill 安装接口逐个提交请求
|
||||
|
||||
---
|
||||
|
||||
历史版本发布记录已迁移到 [版本变更记录](./changelog.md)。
|
||||
|
||||
|
||||
@ -18,6 +18,14 @@ export const importSkillZip = async (file) => {
|
||||
return apiSuperAdminPost(`${BASE_URL}/import`, formData)
|
||||
}
|
||||
|
||||
export const listRemoteSkills = async (source) => {
|
||||
return apiSuperAdminPost(`${BASE_URL}/remote/list`, { source })
|
||||
}
|
||||
|
||||
export const installRemoteSkill = async (payload) => {
|
||||
return apiSuperAdminPost(`${BASE_URL}/remote/install`, payload)
|
||||
}
|
||||
|
||||
export const getSkillDependencyOptions = async () => {
|
||||
return apiSuperAdminGet(`${BASE_URL}/dependency-options`)
|
||||
}
|
||||
@ -73,6 +81,8 @@ export const deleteSkill = async (slug) => {
|
||||
export const skillApi = {
|
||||
listSkills,
|
||||
importSkillZip,
|
||||
listRemoteSkills,
|
||||
installRemoteSkill,
|
||||
getSkillDependencyOptions,
|
||||
listBuiltinSkills,
|
||||
installBuiltinSkill,
|
||||
|
||||
@ -352,6 +352,72 @@
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="remoteInstallModalVisible"
|
||||
title="远程安装 Skill"
|
||||
:footer="null"
|
||||
width="560px"
|
||||
:closable="!installingRemoteSkill"
|
||||
:mask-closable="!installingRemoteSkill"
|
||||
:keyboard="!installingRemoteSkill"
|
||||
>
|
||||
<div class="remote-install-panel modal-mode">
|
||||
<div class="panel-header-text">
|
||||
<span class="title">基于 skills.sh 的能力拉取并导入到当前系统</span>
|
||||
<span class="desc">
|
||||
支持 `owner/repo` 或完整 GitHub URL,可前往
|
||||
<a href="https://skills.sh/" target="_blank" rel="noopener noreferrer">skills.sh</a>
|
||||
查询可用 skills
|
||||
</span>
|
||||
</div>
|
||||
<a-form layout="vertical" class="remote-install-form">
|
||||
<a-form-item label="来源仓库">
|
||||
<a-input
|
||||
v-model:value="remoteInstallForm.source"
|
||||
placeholder="anthropics/skills 或 GitHub URL"
|
||||
:disabled="installingRemoteSkill"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="Skill 名称">
|
||||
<a-select
|
||||
v-model:value="remoteInstallForm.skills"
|
||||
mode="tags"
|
||||
:options="filteredRemoteSkillOptions"
|
||||
placeholder="frontend-design"
|
||||
allow-clear
|
||||
show-search
|
||||
:disabled="installingRemoteSkill"
|
||||
:filter-option="filterRemoteSkillOption"
|
||||
:max-tag-count="6"
|
||||
/>
|
||||
</a-form-item>
|
||||
<div class="remote-install-actions">
|
||||
<a-button
|
||||
:loading="listingRemoteSkills"
|
||||
:disabled="installingRemoteSkill"
|
||||
@click="handleListRemoteSkills"
|
||||
>
|
||||
查看可安装 Skills
|
||||
</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
:loading="installingRemoteSkill"
|
||||
:disabled="listingRemoteSkills"
|
||||
@click="handleInstallRemoteSkill"
|
||||
>
|
||||
安装
|
||||
</a-button>
|
||||
<span v-if="remoteInstallStatusText" class="remote-install-status">
|
||||
{{ remoteInstallStatusText }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="remoteSkillOptions.length" class="remote-skill-summary">
|
||||
共发现 {{ remoteSkillOptions.length }} 个 skills,可按输入内容筛选候选项。
|
||||
</div>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -391,6 +457,8 @@ const theme = computed(() => (themeStore.isDark ? 'dark' : 'light'))
|
||||
|
||||
const loading = ref(false)
|
||||
const importing = ref(false)
|
||||
const listingRemoteSkills = ref(false)
|
||||
const installingRemoteSkill = ref(false)
|
||||
const savingFile = ref(false)
|
||||
const creatingNode = ref(false)
|
||||
const savingDependencies = ref(false)
|
||||
@ -410,7 +478,25 @@ const fileContent = ref('')
|
||||
const originalFileContent = ref('')
|
||||
|
||||
const createModalVisible = ref(false)
|
||||
const remoteInstallModalVisible = ref(false)
|
||||
const createForm = reactive({ path: '', isDir: false, content: '' })
|
||||
const remoteInstallForm = reactive({
|
||||
source: 'https://github.com/anthropics/skills',
|
||||
skills: []
|
||||
})
|
||||
const remoteSkillOptions = ref([])
|
||||
const remoteInstallProgress = reactive({
|
||||
visible: false,
|
||||
total: 0,
|
||||
completed: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
currentSkill: ''
|
||||
})
|
||||
const remoteInstallResults = reactive({
|
||||
success: [],
|
||||
failed: []
|
||||
})
|
||||
const dependencyOptions = reactive({ tools: [], mcps: [], skills: [] })
|
||||
const dependencyForm = reactive({
|
||||
tool_dependencies: [],
|
||||
@ -521,6 +607,49 @@ const skillDependencyOptions = computed(() =>
|
||||
.filter((s) => s !== currentSkill.value?.slug)
|
||||
.map((i) => ({ label: i, value: i }))
|
||||
)
|
||||
const filteredRemoteSkillOptions = computed(() =>
|
||||
remoteSkillOptions.value.map((item) => ({
|
||||
value: item.name,
|
||||
label: item.description ? `${item.name} - ${item.description}` : item.name
|
||||
}))
|
||||
)
|
||||
const remoteInstallStatusText = computed(() => {
|
||||
if (!remoteInstallProgress.visible || !remoteInstallProgress.total) return ''
|
||||
const progressText = `[${remoteInstallProgress.completed}/${remoteInstallProgress.total}]`
|
||||
const currentSkill = remoteInstallProgress.currentSkill || ''
|
||||
const failedText =
|
||||
remoteInstallProgress.failed > 0 ? `, ${remoteInstallProgress.failed} failed` : ''
|
||||
return `${progressText} ${currentSkill}${failedText}`.trim()
|
||||
})
|
||||
const filterRemoteSkillOption = (input, option) => {
|
||||
const keyword = input.trim().toLowerCase()
|
||||
if (!keyword) return true
|
||||
const value = String(option?.value || '').toLowerCase()
|
||||
const label = String(option?.label || '').toLowerCase()
|
||||
return value.includes(keyword) || label.includes(keyword)
|
||||
}
|
||||
|
||||
const normalizeRemoteSkillNames = (skills) => {
|
||||
const seen = new Set()
|
||||
return (skills || []).reduce((acc, skill) => {
|
||||
const normalized = String(skill || '').trim()
|
||||
if (!normalized || seen.has(normalized)) return acc
|
||||
seen.add(normalized)
|
||||
acc.push(normalized)
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
|
||||
const resetRemoteInstallState = () => {
|
||||
remoteInstallProgress.visible = false
|
||||
remoteInstallProgress.total = 0
|
||||
remoteInstallProgress.completed = 0
|
||||
remoteInstallProgress.success = 0
|
||||
remoteInstallProgress.failed = 0
|
||||
remoteInstallProgress.currentSkill = ''
|
||||
remoteInstallResults.success = []
|
||||
remoteInstallResults.failed = []
|
||||
}
|
||||
|
||||
const normalizeTree = (nodes) =>
|
||||
(nodes || []).map((node) => ({
|
||||
@ -539,7 +668,7 @@ const resetFileState = () => {
|
||||
expandedKeys.value = []
|
||||
fileContent.value = ''
|
||||
originalFileContent.value = ''
|
||||
viewMode.value = 'edit'
|
||||
viewMode.value = 'preview'
|
||||
}
|
||||
|
||||
const expandAllKeys = (nodes) =>
|
||||
@ -858,6 +987,103 @@ const handleImportUpload = async ({ file, onSuccess, onError }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleListRemoteSkills = async () => {
|
||||
const source = remoteInstallForm.source.trim()
|
||||
if (!source) {
|
||||
message.warning('请输入来源仓库')
|
||||
return
|
||||
}
|
||||
listingRemoteSkills.value = true
|
||||
try {
|
||||
const result = await skillApi.listRemoteSkills(source)
|
||||
remoteSkillOptions.value = result?.data || []
|
||||
remoteInstallForm.skills = normalizeRemoteSkillNames(remoteInstallForm.skills)
|
||||
if (!remoteSkillOptions.value.length) {
|
||||
message.warning('未发现可安装的 Skills')
|
||||
return
|
||||
}
|
||||
message.success(`已发现 ${remoteSkillOptions.value.length} 个 Skills`)
|
||||
} catch (error) {
|
||||
message.error(error?.response?.data?.detail || error.message || '获取远程 Skills 失败')
|
||||
} finally {
|
||||
listingRemoteSkills.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleInstallRemoteSkill = async () => {
|
||||
const source = remoteInstallForm.source.trim()
|
||||
const skillsToInstall = normalizeRemoteSkillNames(remoteInstallForm.skills)
|
||||
if (!source || !skillsToInstall.length) {
|
||||
message.warning('请填写来源仓库和 Skill 名称')
|
||||
return
|
||||
}
|
||||
remoteInstallForm.skills = skillsToInstall
|
||||
resetRemoteInstallState()
|
||||
installingRemoteSkill.value = true
|
||||
remoteInstallProgress.visible = true
|
||||
remoteInstallProgress.total = skillsToInstall.length
|
||||
let lastInstalledSlug = ''
|
||||
try {
|
||||
for (const skill of skillsToInstall) {
|
||||
remoteInstallProgress.currentSkill = skill
|
||||
try {
|
||||
const result = await skillApi.installRemoteSkill({ source, skill })
|
||||
const installed = result?.data
|
||||
const installedSlug = installed?.slug || skill
|
||||
remoteInstallResults.success.push(installedSlug)
|
||||
remoteInstallProgress.success += 1
|
||||
lastInstalledSlug = installedSlug
|
||||
} catch (error) {
|
||||
remoteInstallResults.failed.push({
|
||||
skill,
|
||||
error: error?.response?.data?.detail || error.message || '远程 Skill 安装失败'
|
||||
})
|
||||
remoteInstallProgress.failed += 1
|
||||
} finally {
|
||||
remoteInstallProgress.completed += 1
|
||||
}
|
||||
}
|
||||
remoteInstallProgress.currentSkill = ''
|
||||
await fetchSkills()
|
||||
if (lastInstalledSlug) {
|
||||
const record =
|
||||
skills.value.find((item) => item.slug === lastInstalledSlug) ||
|
||||
builtinSkills.value.find((item) => item.slug === lastInstalledSlug)
|
||||
if (record) await selectSkill(record)
|
||||
}
|
||||
if (remoteInstallResults.failed.length === 0) {
|
||||
remoteInstallModalVisible.value = false
|
||||
message.success(`远程 Skills 安装成功,共 ${remoteInstallResults.success.length} 个`)
|
||||
resetRemoteInstallState()
|
||||
remoteInstallForm.skills = []
|
||||
return
|
||||
}
|
||||
message.warning(
|
||||
`远程 Skills 安装完成,成功 ${remoteInstallResults.success.length} 个,失败 ${remoteInstallResults.failed.length} 个`
|
||||
)
|
||||
} catch (error) {
|
||||
message.error(error?.response?.data?.detail || error.message || '远程 Skill 安装失败')
|
||||
} finally {
|
||||
remoteInstallProgress.currentSkill = ''
|
||||
installingRemoteSkill.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openRemoteInstallModal = () => {
|
||||
if (!remoteInstallModalVisible.value) {
|
||||
remoteInstallForm.skills = []
|
||||
resetRemoteInstallState()
|
||||
}
|
||||
remoteInstallModalVisible.value = true
|
||||
}
|
||||
|
||||
watch(remoteInstallModalVisible, (visible) => {
|
||||
if (!visible && !installingRemoteSkill.value) {
|
||||
remoteInstallForm.skills = []
|
||||
resetRemoteInstallState()
|
||||
}
|
||||
})
|
||||
|
||||
const saveDependencies = async () => {
|
||||
if (!currentSkill.value || !isInstalledSkill.value) return
|
||||
savingDependencies.value = true
|
||||
@ -886,7 +1112,8 @@ onMounted(fetchSkills)
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
fetchSkills,
|
||||
handleImportUpload
|
||||
handleImportUpload,
|
||||
openRemoteInstallModal
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -913,6 +1140,73 @@ defineExpose({
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.remote-install-panel {
|
||||
background: linear-gradient(180deg, var(--gray-0) 0%, var(--gray-50) 100%);
|
||||
border: 1px solid @border-color;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
|
||||
&.modal-mode {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.panel-header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
}
|
||||
|
||||
.remote-install-form {
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.remote-install-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.remote-install-status {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--gray-600);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.remote-skill-hints {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.remote-skill-summary {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
}
|
||||
|
||||
/* 文件 tree */
|
||||
.tree-container {
|
||||
width: 240px;
|
||||
|
||||
@ -10,15 +10,24 @@
|
||||
<div class="header-actions">
|
||||
<!-- Skills Tab 的按钮 -->
|
||||
<template v-if="activeTab === 'skills'">
|
||||
<a-button
|
||||
@click="handleOpenRemoteInstall"
|
||||
:disabled="skillsLoading || skillsImporting"
|
||||
class="lucide-icon-btn"
|
||||
>
|
||||
<Computer :size="14" />
|
||||
<span>远程安装</span>
|
||||
</a-button>
|
||||
<a-upload
|
||||
accept=".zip"
|
||||
accept=".zip,.md"
|
||||
:show-upload-list="false"
|
||||
:custom-request="handleImportUpload"
|
||||
:before-upload="beforeSkillUpload"
|
||||
:disabled="skillsLoading || skillsImporting"
|
||||
>
|
||||
<a-button type="primary" :loading="skillsImporting" class="lucide-icon-btn">
|
||||
<Upload :size="14" />
|
||||
<span>上传 ZIP</span>
|
||||
<span>上传 Skill</span>
|
||||
</a-button>
|
||||
</a-upload>
|
||||
<a-button @click="handleSkillsRefresh" :disabled="skillsLoading" class="lucide-icon-btn">
|
||||
@ -90,7 +99,8 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Upload, RotateCw, Plus } from 'lucide-vue-next'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { Upload, RotateCw, Plus, Computer } from 'lucide-vue-next'
|
||||
import SkillsManagerComponent from '@/components/SkillsManagerComponent.vue'
|
||||
import ToolsManagerComponent from '@/components/ToolsManagerComponent.vue'
|
||||
import McpServersComponent from '@/components/McpServersComponent.vue'
|
||||
@ -154,6 +164,12 @@ const handleSkillsRefresh = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenRemoteInstall = () => {
|
||||
if (skillsRef.value?.openRemoteInstallModal) {
|
||||
skillsRef.value.openRemoteInstallModal()
|
||||
}
|
||||
}
|
||||
|
||||
// Tools 事件处理
|
||||
const handleToolsRefresh = () => {
|
||||
if (toolsRef.value?.fetchTools) {
|
||||
@ -196,6 +212,16 @@ const handleSubagentRefresh = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 上传前校验文件名:仅允许 .zip 或 SKILL.md
|
||||
const beforeSkillUpload = (file) => {
|
||||
const lower = file.name.toLowerCase()
|
||||
if (!lower.endsWith('.zip') && lower !== 'skill.md') {
|
||||
message.error('仅支持上传 .zip 文件或 SKILL.md 文件')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 处理导入上传
|
||||
const handleImportUpload = async ({ file, onSuccess, onError }) => {
|
||||
if (skillsRef.value?.handleImportUpload) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user