Merge pull request #601 from xerrors/feat/skills-update

feat: Enhance skill management with remote installation and file upload
This commit is contained in:
Wenjie Zhang 2026-04-03 11:51:13 +08:00 committed by GitHub
commit 6b9b0a0637
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1020 additions and 70 deletions

View File

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

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

View File

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

View File

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

View File

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

View 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",
)

View File

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

View File

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

View File

@ -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 之间可以建立依赖关系,形成一个松耦合的技能网络。

View File

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

View File

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

View File

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

View File

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