2026-05-14 20:02:16 +08:00
|
|
|
|
import shutil
|
|
|
|
|
|
import tempfile
|
|
|
|
|
|
from pathlib import Path, PurePosixPath
|
2026-05-14 23:25:11 +08:00
|
|
|
|
from typing import Annotated, Any
|
|
|
|
|
|
|
|
|
|
|
|
from langchain_core.tools import InjectedToolArg
|
2026-05-14 20:02:16 +08:00
|
|
|
|
|
|
|
|
|
|
from langchain.tools import InjectedToolCallId
|
|
|
|
|
|
from langchain_core.messages import ToolMessage
|
|
|
|
|
|
from langgraph.prebuilt.tool_node import ToolRuntime
|
|
|
|
|
|
from langgraph.types import Command
|
|
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
|
|
|
|
|
|
from yuxi.agents.toolkits.registry import tool
|
|
|
|
|
|
from yuxi.repositories.agent_config_repository import AgentConfigRepository
|
|
|
|
|
|
from yuxi.repositories.conversation_repository import ConversationRepository
|
|
|
|
|
|
from yuxi.repositories.user_repository import UserRepository
|
|
|
|
|
|
from yuxi.storage.postgres.manager import pg_manager
|
|
|
|
|
|
from yuxi.utils.logging_config import logger
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ADMIN_ROLES = {"admin", "superadmin"}
|
|
|
|
|
|
|
|
|
|
|
|
class InstallSkillInput(BaseModel):
|
|
|
|
|
|
source: str = Field(
|
|
|
|
|
|
description="Skill 来源,支持两种格式:\n"
|
|
|
|
|
|
"1. Sandbox 路径: /home/gem/user-data/workspace/my-skill (/ 开头)\n"
|
|
|
|
|
|
"2. Git 仓库: owner/repo 或完整 GitHub URL"
|
|
|
|
|
|
)
|
|
|
|
|
|
skill_names: list[str] | None = Field(
|
|
|
|
|
|
default=None,
|
|
|
|
|
|
description="Git 安装时指定要安装的 skill slug 列表(至少一个)。Sandbox 路径安装时忽略此参数。"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-14 23:25:11 +08:00
|
|
|
|
async def _assert_admin(db, user_id: str) -> None:
|
2026-05-14 20:02:16 +08:00
|
|
|
|
"""验证用户是管理员,否则抛出 ValueError。"""
|
2026-05-14 23:25:11 +08:00
|
|
|
|
repo = UserRepository()
|
|
|
|
|
|
user = await repo.get_by_id_with_db(db, int(user_id))
|
|
|
|
|
|
if user is None:
|
|
|
|
|
|
raise ValueError("用户不存在")
|
|
|
|
|
|
if user.role not in ADMIN_ROLES:
|
|
|
|
|
|
raise ValueError("仅管理员可以安装 skill")
|
2026-05-14 20:02:16 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _download_skill_dir(backend, remote_dir: str, local_dir: Path) -> None:
|
|
|
|
|
|
"""递归通过沙盒 API 下载 skill 目录到本地。"""
|
|
|
|
|
|
entries = backend.ls_info(remote_dir)
|
|
|
|
|
|
for entry in entries:
|
|
|
|
|
|
path = entry["path"]
|
|
|
|
|
|
if entry.get("is_dir"):
|
|
|
|
|
|
sub = local_dir / PurePosixPath(path).name
|
|
|
|
|
|
sub.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
_download_skill_dir(backend, path, sub)
|
|
|
|
|
|
else:
|
|
|
|
|
|
resp = backend.download_files([path])
|
|
|
|
|
|
if resp and not resp[0].error:
|
|
|
|
|
|
(local_dir / PurePosixPath(path).name).write_bytes(resp[0].content)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-14 23:25:11 +08:00
|
|
|
|
async def _install_skill_from_sandbox(db, sandbox_path: str, thread_id: str, user_id: str) -> tuple[str, bool]:
|
|
|
|
|
|
"""从 Sandbox 路径安装 skill。返回 (slug, 是否因冲突被重命名)。"""
|
2026-05-14 20:02:16 +08:00
|
|
|
|
from yuxi.agents.backends.sandbox import ProvisionerSandboxBackend, resolve_virtual_path
|
2026-05-14 23:25:11 +08:00
|
|
|
|
from yuxi.services.skill_service import (
|
|
|
|
|
|
_parse_skill_markdown,
|
|
|
|
|
|
import_skill_dir,
|
|
|
|
|
|
is_valid_skill_slug,
|
|
|
|
|
|
)
|
2026-05-14 20:02:16 +08:00
|
|
|
|
|
|
|
|
|
|
slug = PurePosixPath(sandbox_path.rstrip("/")).name
|
|
|
|
|
|
if not is_valid_skill_slug(slug):
|
|
|
|
|
|
raise ValueError(f"slug '{slug}' 不合法(仅允许小写字母、数字和连字符)")
|
|
|
|
|
|
|
|
|
|
|
|
if not sandbox_path.startswith("/home/gem/user-data/"):
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"不支持的沙盒路径: {sandbox_path}。"
|
|
|
|
|
|
"请使用 /home/gem/user-data/workspace/...、/home/gem/user-data/uploads/... "
|
|
|
|
|
|
"或 /home/gem/user-data/outputs/..."
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
with tempfile.TemporaryDirectory(prefix=".skill-install-") as tmp:
|
|
|
|
|
|
staging = Path(tmp) / slug
|
|
|
|
|
|
|
|
|
|
|
|
# 优先尝试共享卷路径(性能更好,无需走沙盒 API)
|
|
|
|
|
|
try:
|
|
|
|
|
|
local_path = resolve_virtual_path(thread_id, sandbox_path, user_id=user_id)
|
|
|
|
|
|
if (local_path / "SKILL.md").exists():
|
|
|
|
|
|
shutil.copytree(local_path, staging)
|
|
|
|
|
|
else:
|
|
|
|
|
|
raise FileNotFoundError(f"{local_path} 中未找到 SKILL.md")
|
|
|
|
|
|
except (ValueError, FileNotFoundError):
|
|
|
|
|
|
staging.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
backend = ProvisionerSandboxBackend(thread_id=thread_id, user_id=user_id)
|
|
|
|
|
|
_download_skill_dir(backend, sandbox_path, staging)
|
|
|
|
|
|
if not (staging / "SKILL.md").exists():
|
|
|
|
|
|
raise ValueError(f"沙盒路径 {sandbox_path} 中未找到 SKILL.md")
|
|
|
|
|
|
|
2026-05-14 23:25:11 +08:00
|
|
|
|
content = (staging / "SKILL.md").read_text(encoding="utf-8")
|
|
|
|
|
|
parsed_name, _, _ = _parse_skill_markdown(content)
|
|
|
|
|
|
result = await import_skill_dir(db, source_dir=staging, created_by=user_id)
|
|
|
|
|
|
return result.slug, result.slug != parsed_name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _enable_skill_in_current_config(db, user_id: str, thread_id: str, skill_slug: str) -> bool:
|
|
|
|
|
|
"""在当前会话的配置中启用新安装的 skill"""
|
|
|
|
|
|
conv_repo = ConversationRepository(db)
|
|
|
|
|
|
conv = await conv_repo.get_conversation_by_thread_id(thread_id)
|
|
|
|
|
|
if not conv:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
agent_config_id = (conv.extra_metadata or {}).get("agent_config_id")
|
|
|
|
|
|
if not agent_config_id:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
config_repo = AgentConfigRepository(db)
|
|
|
|
|
|
result = await config_repo.add_skills_to_config_json(
|
|
|
|
|
|
agent_config_id=agent_config_id, new_slugs=[skill_slug]
|
|
|
|
|
|
)
|
|
|
|
|
|
return result
|
2026-05-14 20:02:16 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-14 23:25:11 +08:00
|
|
|
|
async def _run_install_task(
|
2026-05-14 20:02:16 +08:00
|
|
|
|
source: str,
|
|
|
|
|
|
runtime: ToolRuntime,
|
2026-05-14 23:25:11 +08:00
|
|
|
|
tool_call_id: str,
|
2026-05-14 20:02:16 +08:00
|
|
|
|
skill_names: list[str] | None = None,
|
|
|
|
|
|
) -> Command:
|
2026-05-14 23:25:11 +08:00
|
|
|
|
"""执行异步安装任务的核心逻辑"""
|
2026-05-14 20:02:16 +08:00
|
|
|
|
from yuxi.agents.middlewares.skills_middleware import normalize_selected_skills
|
|
|
|
|
|
from yuxi.services.skill_service import sync_thread_visible_skills
|
2026-05-14 23:25:11 +08:00
|
|
|
|
from yuxi.services.remote_skill_install_service import install_remote_skills_batch
|
2026-05-14 20:02:16 +08:00
|
|
|
|
|
|
|
|
|
|
user_id = getattr(runtime.context, "user_id", None)
|
2026-05-14 23:25:11 +08:00
|
|
|
|
thread_id = getattr(runtime.context, "thread_id", None)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"DEBUG: install_skill called with user_id={user_id}, thread_id={thread_id}, source={source}")
|
|
|
|
|
|
|
|
|
|
|
|
if not user_id or not thread_id:
|
2026-05-14 20:02:16 +08:00
|
|
|
|
return Command(update={
|
|
|
|
|
|
"messages": [ToolMessage(content="错误:无法获取当前会话信息", tool_call_id=tool_call_id)]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2026-05-14 23:25:11 +08:00
|
|
|
|
async with pg_manager.get_async_session_context() as db:
|
|
|
|
|
|
await _assert_admin(db, user_id)
|
|
|
|
|
|
|
|
|
|
|
|
installed_slugs: list[str] = []
|
|
|
|
|
|
failed_items: list[dict] = []
|
|
|
|
|
|
slug_warnings: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
if source.startswith("/"):
|
|
|
|
|
|
# Sandbox 路径安装
|
|
|
|
|
|
actual_slug, was_renamed = await _install_skill_from_sandbox(db, source, thread_id, user_id)
|
|
|
|
|
|
installed_slugs = [actual_slug]
|
|
|
|
|
|
if was_renamed:
|
|
|
|
|
|
slug_warnings.append(f"⚠️ 技能 slug '{actual_slug}' 已存在,已自动重命名安装")
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Git 安装
|
|
|
|
|
|
_skill_names = skill_names or []
|
|
|
|
|
|
if not _skill_names:
|
|
|
|
|
|
return Command(update={
|
|
|
|
|
|
"messages": [ToolMessage(
|
|
|
|
|
|
content="❌ 错误: 从 Git 安装时必须通过 skill_names 指定技能名称",
|
|
|
|
|
|
tool_call_id=tool_call_id,
|
|
|
|
|
|
)]
|
|
|
|
|
|
})
|
|
|
|
|
|
results = await install_remote_skills_batch(db, source=source, skills=_skill_names, created_by=user_id)
|
|
|
|
|
|
installed_slugs = [r["slug"] for r in results if r.get("success")]
|
|
|
|
|
|
failed_items = [r for r in results if not r.get("success")]
|
|
|
|
|
|
|
|
|
|
|
|
# 持久化
|
|
|
|
|
|
config_success = True
|
|
|
|
|
|
if installed_slugs:
|
|
|
|
|
|
for slug in installed_slugs:
|
|
|
|
|
|
ok = await _enable_skill_in_current_config(db, user_id, thread_id, slug)
|
|
|
|
|
|
if not ok:
|
|
|
|
|
|
config_success = False
|
|
|
|
|
|
|
|
|
|
|
|
# 文件同步
|
2026-05-14 20:02:16 +08:00
|
|
|
|
current_skills = normalize_selected_skills(
|
|
|
|
|
|
getattr(runtime.context, "skills", None)
|
|
|
|
|
|
)
|
|
|
|
|
|
sync_thread_visible_skills(thread_id, current_skills + installed_slugs)
|
|
|
|
|
|
|
2026-05-14 23:25:11 +08:00
|
|
|
|
# 响应
|
|
|
|
|
|
lines = []
|
|
|
|
|
|
if installed_slugs:
|
|
|
|
|
|
lines.append(f"✅ 成功安装并激活技能: {', '.join(installed_slugs)}")
|
|
|
|
|
|
for w in slug_warnings:
|
|
|
|
|
|
lines.append(w)
|
|
|
|
|
|
if failed_items:
|
|
|
|
|
|
for item in failed_items:
|
|
|
|
|
|
lines.append(f"❌ 安装失败 ({item['slug']}): {item.get('error', '未知错误')}")
|
|
|
|
|
|
if not config_success:
|
|
|
|
|
|
lines.append("⚠️ 技能已安装到系统,但在当前会话配置中激活失败")
|
|
|
|
|
|
if not installed_slugs and not failed_items:
|
|
|
|
|
|
lines.append("ℹ️ 未发现需要安装的技能")
|
|
|
|
|
|
|
|
|
|
|
|
return Command(update={
|
|
|
|
|
|
"activated_skills": installed_slugs,
|
|
|
|
|
|
"messages": [ToolMessage(content="\n".join(lines), tool_call_id=tool_call_id)],
|
|
|
|
|
|
})
|
2026-05-14 20:02:16 +08:00
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.exception("install_skill 异常")
|
|
|
|
|
|
return Command(update={
|
2026-05-14 23:25:11 +08:00
|
|
|
|
"messages": [ToolMessage(
|
|
|
|
|
|
content=f"❌ 安装异常: {str(e)}",
|
|
|
|
|
|
tool_call_id=tool_call_id,
|
|
|
|
|
|
)]
|
2026-05-14 20:02:16 +08:00
|
|
|
|
})
|
2026-05-14 23:25:11 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@tool(
|
|
|
|
|
|
category="buildin",
|
|
|
|
|
|
tags=["skill", "安装"],
|
|
|
|
|
|
display_name="安装技能",
|
|
|
|
|
|
args_schema=InstallSkillInput,
|
|
|
|
|
|
)
|
|
|
|
|
|
async def install_skill(
|
|
|
|
|
|
source: str,
|
|
|
|
|
|
skill_names: list[str] | None = None,
|
|
|
|
|
|
runtime: ToolRuntime = None,
|
|
|
|
|
|
tool_call_id: Annotated[str, InjectedToolCallId] = "",
|
|
|
|
|
|
) -> Command:
|
|
|
|
|
|
"""安装新的技能 (Skill) 到系统中。
|
|
|
|
|
|
|
|
|
|
|
|
参数说明:
|
|
|
|
|
|
- source: 必填。支持两种格式:
|
|
|
|
|
|
1. Sandbox 路径: 例如 "/tmp/my-skill"
|
|
|
|
|
|
2. Git 仓库: 例如 "owner/repo" 或 "https://github.com/owner/repo"
|
|
|
|
|
|
- skill_names: 从 Git 仓库安装时必填,指定要安装的技能列表。
|
|
|
|
|
|
|
|
|
|
|
|
注意:
|
|
|
|
|
|
- 仅超级管理员 (superadmin) 有权执行此操作。
|
|
|
|
|
|
- 安装成功后,该技能会自动在当前会话 (thread) 中激活。
|
|
|
|
|
|
"""
|
|
|
|
|
|
return await _run_install_task(source, runtime, tool_call_id, skill_names)
|