feat(skill): 添加远程技能安装服务及相关功能更新

- 新增 remote_skill_install_service.py 支持远程技能安装
- 更新 skill_service.py 增强技能管理功能
- 更新 skill_router.py 添加相关路由
- 更新前端组件 SkillsManagerComponent.vue
- 更新 ExtensionsView.vue
- 添加对应单元测试

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wenjie Zhang 2026-04-01 14:44:33 +08:00
parent 8ba4f7050d
commit a828d7154b
11 changed files with 757 additions and 44 deletions

View File

@ -0,0 +1,190 @@
from __future__ import annotations
import asyncio
import os
import re
import tempfile
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.services.skill_service import import_skill_dir, is_valid_skill_slug
ANSI_ESCAPE_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]")
CONTROL_SEQUENCE_RE = re.compile(r"\x1B\][^\x07]*(?:\x07|\x1B\\)|\x1B[\(\)][A-Za-z0-9]")
SKILL_LINE_RE = re.compile(r"^[a-z0-9]+(?:-[a-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 SKILL_LINE_RE.fullmatch(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 SKILL_LINE_RE.fullmatch(next_line):
break
if next_line and next_line[0].isalpha():
description = next_line
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:
raise ValueError(combined or "skills CLI 执行失败")
return combined
async def list_remote_skills(source: str) -> list[dict[str, str]]:
normalized_source = _normalize_source(source)
with tempfile.TemporaryDirectory(prefix=".remote-skills-") as temp_home:
env = os.environ.copy()
env["HOME"] = temp_home
workdir = str(Path(temp_home) / "workspace")
Path(workdir).mkdir(parents=True, exist_ok=True)
output = await _run_skills_cli(
["npx", "-y", "skills", "add", normalized_source, "--list"],
env=env,
cwd=workdir,
)
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,
) -> object:
normalized_source = _normalize_source(source)
normalized_skill = _normalize_skill_name(skill)
with tempfile.TemporaryDirectory(prefix=".remote-skills-") as temp_home:
env = os.environ.copy()
env["HOME"] = temp_home
workdir = str(Path(temp_home) / "workspace")
Path(workdir).mkdir(parents=True, exist_ok=True)
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,
)
installed_dir = Path(temp_home) / ".agents" / "skills" / normalized_skill
if not installed_dir.exists() or not installed_dir.is_dir():
raise ValueError("skills CLI 未生成预期的技能目录")
return await import_skill_dir(
db,
source_dir=installed_dir,
created_by=created_by,
)

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():
@ -498,13 +555,11 @@ async def import_skill_zip(
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)
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"
@ -525,45 +580,27 @@ async def import_skill_zip(
skill_md_path = source_skill_dir / "SKILL.md"
skill_md_path.write_bytes(file_bytes)
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()
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
@ -201,6 +210,47 @@ async def import_skill_route(
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

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

@ -221,6 +221,20 @@ async def test_import_skill_md_creates_single_file_skill(
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

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

@ -33,6 +33,7 @@
- 调整输入框 `@` 提及中的文件搜索交互:无查询内容时不再直接展示文件列表,改为提示“输入相关内容以搜索文件”,避免未过滤结果干扰选择。
- 收紧文件系统安全边界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` 直接作为系统主存储
---

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,62 @@
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:open="remoteInstallModalVisible"
title="远程安装 Skill"
:footer="null"
width="560px"
>
<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"
/>
</a-form-item>
<a-form-item label="Skill 名称">
<a-auto-complete
v-model:value="remoteInstallForm.skill"
:options="filteredRemoteSkillOptions"
placeholder="frontend-design"
allow-clear
>
<a-input />
</a-auto-complete>
</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>
</div>
<div v-if="remoteSkillOptions.length" class="remote-skill-summary">
共发现 {{ remoteSkillOptions.length }} skills可按输入内容筛选候选项
</div>
</a-form>
</div>
</a-modal>
</div>
</template>
@ -391,6 +447,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 +468,13 @@ 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',
skill: ''
})
const remoteSkillOptions = ref([])
const dependencyOptions = reactive({ tools: [], mcps: [], skills: [] })
const dependencyForm = reactive({
tool_dependencies: [],
@ -521,6 +585,15 @@ const skillDependencyOptions = computed(() =>
.filter((s) => s !== currentSkill.value?.slug)
.map((i) => ({ label: i, value: i }))
)
const filteredRemoteSkillOptions = computed(() => {
const keyword = remoteInstallForm.skill.trim().toLowerCase()
return remoteSkillOptions.value
.filter((item) => !keyword || item.name.toLowerCase().includes(keyword))
.map((item) => ({
value: item.name,
label: item.description ? `${item.name} - ${item.description}` : item.name
}))
})
const normalizeTree = (nodes) =>
(nodes || []).map((node) => ({
@ -858,6 +931,63 @@ 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 || []
if (!remoteSkillOptions.value.length) {
message.warning('未发现可安装的 Skills')
return
}
if (!remoteInstallForm.skill) {
remoteInstallForm.skill = remoteSkillOptions.value[0]?.name || ''
}
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 skill = remoteInstallForm.skill.trim()
if (!source || !skill) {
message.warning('请填写来源仓库和 Skill 名称')
return
}
installingRemoteSkill.value = true
try {
const result = await skillApi.installRemoteSkill({ source, skill })
const installed = result?.data
remoteInstallForm.skill = installed?.slug || skill
await fetchSkills()
if (installed?.slug) {
const record =
skills.value.find((item) => item.slug === installed.slug) ||
builtinSkills.value.find((item) => item.slug === installed.slug)
if (record) await selectSkill(record)
}
remoteInstallModalVisible.value = false
message.success('远程 Skill 安装成功')
} catch (error) {
message.error(error?.response?.data?.detail || error.message || '远程 Skill 安装失败')
} finally {
installingRemoteSkill.value = false
}
}
const openRemoteInstallModal = () => {
remoteInstallModalVisible.value = true
}
const saveDependencies = async () => {
if (!currentSkill.value || !isInstalledSkill.value) return
savingDependencies.value = true
@ -886,7 +1016,8 @@ onMounted(fetchSkills)
//
defineExpose({
fetchSkills,
handleImportUpload
handleImportUpload,
openRemoteInstallModal
})
</script>
@ -913,6 +1044,62 @@ 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;
gap: 8px;
}
.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,6 +10,14 @@
<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,.md"
:show-upload-list="false"
@ -90,7 +98,7 @@
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Upload, RotateCw, Plus } from 'lucide-vue-next'
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 +162,12 @@ const handleSkillsRefresh = () => {
}
}
const handleOpenRemoteInstall = () => {
if (skillsRef.value?.openRemoteInstallModal) {
skillsRef.value.openRemoteInstallModal()
}
}
// Tools
const handleToolsRefresh = () => {
if (toolsRef.value?.fetchTools) {