mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-09 14:52:45 +08:00
feat: add skills marketplace management
This commit is contained in:
@@ -1407,6 +1407,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
chat_id: Union[str, int],
|
||||
text: str,
|
||||
title: Optional[str] = None,
|
||||
buttons: Optional[List[List[dict]]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
编辑已发送的消息
|
||||
@@ -1416,6 +1417,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
:param chat_id: 聊天ID
|
||||
:param text: 新的消息内容
|
||||
:param title: 消息标题
|
||||
:param buttons: 更新后的按钮列表
|
||||
:return: 编辑是否成功
|
||||
"""
|
||||
return self.run_module(
|
||||
@@ -1426,6 +1428,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
title=title,
|
||||
buttons=buttons,
|
||||
)
|
||||
|
||||
def send_direct_message(self, message: Notification) -> Optional[MessageResponse]:
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.chain import ChainBase
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.search import SearchChain
|
||||
from app.chain.skills import SkillsChain, skills_interaction_manager
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.config import settings, global_vars
|
||||
@@ -246,6 +247,15 @@ class MessageChain(ChainBase):
|
||||
EventType.CommandExcute,
|
||||
{"cmd": text, "user": userid, "channel": channel, "source": source},
|
||||
)
|
||||
elif skills_interaction_manager.get_by_user(userid):
|
||||
if SkillsChain().handle_text_interaction(
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
text=text,
|
||||
):
|
||||
return
|
||||
elif text.lower().startswith("/ai"):
|
||||
self._handle_ai_message(
|
||||
text=text,
|
||||
@@ -785,6 +795,17 @@ class MessageChain(ChainBase):
|
||||
):
|
||||
return
|
||||
|
||||
if SkillsChain().handle_callback_interaction(
|
||||
callback_data=callback_data,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
original_message_id=original_message_id,
|
||||
original_chat_id=original_chat_id,
|
||||
):
|
||||
return
|
||||
|
||||
if self._handle_agent_choice_callback(
|
||||
callback_data=callback_data,
|
||||
channel=channel,
|
||||
|
||||
868
app/chain/skills.py
Normal file
868
app/chain/skills.py
Normal file
@@ -0,0 +1,868 @@
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from threading import Lock
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
import uuid
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.helper.skill import SkillHelper, SkillInfo
|
||||
from app.schemas import Notification
|
||||
from app.schemas.message import ChannelCapabilityManager
|
||||
from app.schemas.types import MessageChannel
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingSkillsInteraction:
|
||||
"""
|
||||
记录一次 /skills 会话的上下文,便于按钮和文本回复共用同一状态。
|
||||
"""
|
||||
|
||||
request_id: str
|
||||
user_id: str
|
||||
channel: Optional[MessageChannel]
|
||||
source: Optional[str]
|
||||
username: Optional[str]
|
||||
view: str = "root"
|
||||
local_page: int = 0
|
||||
market_page: int = 0
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
|
||||
class SkillsInteractionManager:
|
||||
"""
|
||||
管理用户当前的技能交互状态。
|
||||
|
||||
每个用户同一时间只保留一个有效会话,避免旧按钮继续生效。
|
||||
"""
|
||||
|
||||
_ttl = timedelta(hours=24)
|
||||
|
||||
def __init__(self):
|
||||
self._by_id: Dict[str, PendingSkillsInteraction] = {}
|
||||
self._by_user: Dict[str, str] = {}
|
||||
self._lock = Lock()
|
||||
|
||||
def _cleanup_locked(self):
|
||||
"""
|
||||
清理超时会话,避免按钮回调无限积累。
|
||||
"""
|
||||
expire_before = datetime.now() - self._ttl
|
||||
expired = [
|
||||
request_id
|
||||
for request_id, request in self._by_id.items()
|
||||
if request.created_at < expire_before
|
||||
]
|
||||
for request_id in expired:
|
||||
request = self._by_id.pop(request_id, None)
|
||||
if request:
|
||||
self._by_user.pop(str(request.user_id), None)
|
||||
|
||||
def create_or_replace(
|
||||
self,
|
||||
user_id: Union[str, int],
|
||||
channel: Optional[MessageChannel],
|
||||
source: Optional[str],
|
||||
username: Optional[str],
|
||||
) -> PendingSkillsInteraction:
|
||||
"""
|
||||
为用户创建新会话,并替换掉旧的技能交互状态。
|
||||
"""
|
||||
with self._lock:
|
||||
self._cleanup_locked()
|
||||
user_key = str(user_id)
|
||||
old_request_id = self._by_user.get(user_key)
|
||||
if old_request_id:
|
||||
self._by_id.pop(old_request_id, None)
|
||||
request_id = uuid.uuid4().hex[:12]
|
||||
request = PendingSkillsInteraction(
|
||||
request_id=request_id,
|
||||
user_id=user_key,
|
||||
channel=channel,
|
||||
source=source,
|
||||
username=username,
|
||||
)
|
||||
self._by_id[request_id] = request
|
||||
self._by_user[user_key] = request_id
|
||||
return request
|
||||
|
||||
def get_by_user(
|
||||
self, user_id: Union[str, int]
|
||||
) -> Optional[PendingSkillsInteraction]:
|
||||
"""
|
||||
按用户获取当前有效会话,供纯文本回复路由使用。
|
||||
"""
|
||||
with self._lock:
|
||||
self._cleanup_locked()
|
||||
request_id = self._by_user.get(str(user_id))
|
||||
if not request_id:
|
||||
return None
|
||||
return self._by_id.get(request_id)
|
||||
|
||||
def get_by_id(
|
||||
self, request_id: str, user_id: Union[str, int]
|
||||
) -> Optional[PendingSkillsInteraction]:
|
||||
"""
|
||||
按请求 ID 获取会话,并校验会话归属用户。
|
||||
"""
|
||||
with self._lock:
|
||||
self._cleanup_locked()
|
||||
request = self._by_id.get(request_id)
|
||||
if not request or str(request.user_id) != str(user_id):
|
||||
return None
|
||||
return request
|
||||
|
||||
def remove(self, request_id: str) -> None:
|
||||
"""
|
||||
主动结束会话,释放用户和请求 ID 的双向索引。
|
||||
"""
|
||||
with self._lock:
|
||||
request = self._by_id.pop(request_id, None)
|
||||
if request:
|
||||
self._by_user.pop(str(request.user_id), None)
|
||||
|
||||
def clear(self):
|
||||
"""
|
||||
清空所有会话,主要用于测试场景。
|
||||
"""
|
||||
with self._lock:
|
||||
self._by_id.clear()
|
||||
self._by_user.clear()
|
||||
|
||||
|
||||
skills_interaction_manager = SkillsInteractionManager()
|
||||
|
||||
|
||||
class SkillsChain(ChainBase):
|
||||
"""
|
||||
处理 /skills 指令、按钮回调和文本式技能管理交互。
|
||||
"""
|
||||
|
||||
_button_page_size = 6
|
||||
_text_page_size = 8
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.skillhelper = SkillHelper()
|
||||
|
||||
def remote_manage(
|
||||
self,
|
||||
arg_str: str,
|
||||
channel: MessageChannel,
|
||||
userid: Union[str, int],
|
||||
source: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
/skills 入口。创建新会话并渲染首屏菜单。
|
||||
"""
|
||||
request = skills_interaction_manager.create_or_replace(
|
||||
user_id=userid,
|
||||
channel=channel,
|
||||
source=source,
|
||||
username=None,
|
||||
)
|
||||
force = (arg_str or "").strip().lower() in {"refresh", "刷新"}
|
||||
self._render_interaction(
|
||||
request=request,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=None,
|
||||
force_market_refresh=force,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse_callback(callback_data: str) -> Optional[Tuple[str, str, Optional[int]]]:
|
||||
"""
|
||||
解析 /skills 按钮回调。
|
||||
|
||||
回调格式:skills:{request_id}:{action}[:index]
|
||||
"""
|
||||
if not callback_data.startswith("skills:"):
|
||||
return None
|
||||
parts = callback_data.split(":")
|
||||
if len(parts) < 3:
|
||||
return None
|
||||
request_id = parts[1]
|
||||
action = parts[2]
|
||||
index = None
|
||||
if len(parts) >= 4 and parts[3].isdigit():
|
||||
index = int(parts[3])
|
||||
return request_id, action, index
|
||||
|
||||
def handle_callback_interaction(
|
||||
self,
|
||||
callback_data: str,
|
||||
channel: MessageChannel,
|
||||
source: str,
|
||||
userid: Union[str, int],
|
||||
username: str,
|
||||
original_message_id: Optional[Union[str, int]] = None,
|
||||
original_chat_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
处理按钮交互,并在同一条消息上刷新当前视图。
|
||||
"""
|
||||
parsed = self.parse_callback(callback_data)
|
||||
if not parsed:
|
||||
return False
|
||||
|
||||
request_id, action, index = parsed
|
||||
request = skills_interaction_manager.get_by_id(request_id, userid)
|
||||
if not request:
|
||||
self.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
title="技能交互已失效,请重新发送 /skills",
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
request.channel = channel
|
||||
request.source = source
|
||||
request.username = username
|
||||
|
||||
if action == "close":
|
||||
skills_interaction_manager.remove(request.request_id)
|
||||
self._update_or_post_message(
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
title="技能管理",
|
||||
text="技能交互已结束",
|
||||
original_message_id=original_message_id,
|
||||
original_chat_id=original_chat_id,
|
||||
)
|
||||
return True
|
||||
|
||||
if action == "root":
|
||||
request.view = "root"
|
||||
elif action == "installed":
|
||||
request.view = "installed"
|
||||
request.local_page = 0
|
||||
elif action == "market":
|
||||
request.view = "market"
|
||||
request.market_page = 0
|
||||
elif action == "refresh":
|
||||
self._render_interaction(
|
||||
request=request,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
original_message_id=original_message_id,
|
||||
original_chat_id=original_chat_id,
|
||||
force_market_refresh=True,
|
||||
)
|
||||
return True
|
||||
elif action == "page-next":
|
||||
if request.view == "installed":
|
||||
request.local_page += 1
|
||||
elif request.view == "market":
|
||||
request.market_page += 1
|
||||
elif action == "page-prev":
|
||||
if request.view == "installed":
|
||||
request.local_page = max(0, request.local_page - 1)
|
||||
elif request.view == "market":
|
||||
request.market_page = max(0, request.market_page - 1)
|
||||
elif action == "install" and index:
|
||||
success, message = self._install_market_skill(request, index)
|
||||
if success:
|
||||
self.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
title=message,
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
title=message,
|
||||
)
|
||||
)
|
||||
elif action == "remove" and index:
|
||||
success, message = self._remove_local_skill(request, index)
|
||||
self.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
title=message,
|
||||
)
|
||||
)
|
||||
if not success:
|
||||
# 保持当前页
|
||||
pass
|
||||
|
||||
self._render_interaction(
|
||||
request=request,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
original_message_id=original_message_id,
|
||||
original_chat_id=original_chat_id,
|
||||
)
|
||||
return True
|
||||
|
||||
def handle_text_interaction(
|
||||
self,
|
||||
channel: MessageChannel,
|
||||
source: str,
|
||||
userid: Union[str, int],
|
||||
username: str,
|
||||
text: str,
|
||||
) -> bool:
|
||||
"""
|
||||
处理不支持按钮渠道上的文本指令,也兼容用户直接回复文字操作。
|
||||
"""
|
||||
request = skills_interaction_manager.get_by_user(userid)
|
||||
if not request:
|
||||
return False
|
||||
|
||||
request.channel = channel
|
||||
request.source = source
|
||||
request.username = username
|
||||
|
||||
normalized = (text or "").strip()
|
||||
lowered = normalized.lower()
|
||||
if lowered in {"退出", "关闭", "q", "quit", "exit"}:
|
||||
skills_interaction_manager.remove(request.request_id)
|
||||
self.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
title="技能交互已结束",
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
if lowered in {"返回", "back"}:
|
||||
request.view = "root"
|
||||
self._render_interaction(
|
||||
request=request,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
)
|
||||
return True
|
||||
|
||||
if lowered in {"刷新", "refresh"}:
|
||||
self._render_interaction(
|
||||
request=request,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
force_market_refresh=True,
|
||||
)
|
||||
return True
|
||||
|
||||
if lowered in {"p", "prev", "上一页"}:
|
||||
if request.view == "installed":
|
||||
request.local_page = max(0, request.local_page - 1)
|
||||
elif request.view == "market":
|
||||
request.market_page = max(0, request.market_page - 1)
|
||||
self._render_interaction(
|
||||
request=request,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
)
|
||||
return True
|
||||
|
||||
if lowered in {"n", "next", "下一页"}:
|
||||
if request.view == "installed":
|
||||
request.local_page += 1
|
||||
elif request.view == "market":
|
||||
request.market_page += 1
|
||||
self._render_interaction(
|
||||
request=request,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
)
|
||||
return True
|
||||
|
||||
if request.view == "root":
|
||||
if lowered in {"1", "已安装", "本地", "local"}:
|
||||
request.view = "installed"
|
||||
elif lowered in {"2", "市场", "market"}:
|
||||
request.view = "market"
|
||||
else:
|
||||
self.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
title="请输入 1 查看已安装技能,2 查看技能市场,或回复 刷新/退出",
|
||||
)
|
||||
)
|
||||
return True
|
||||
self._render_interaction(
|
||||
request=request,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
)
|
||||
return True
|
||||
|
||||
install_match = re.match(r"^(?:安装|装)\s*(\d+)$", normalized)
|
||||
remove_match = re.match(r"^(?:删除|删)\s*(\d+)$", normalized)
|
||||
if request.view == "market" and install_match:
|
||||
success, message = self._install_market_skill(
|
||||
request=request,
|
||||
page_index=int(install_match.group(1)),
|
||||
)
|
||||
self.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
title=message,
|
||||
)
|
||||
)
|
||||
self._render_interaction(
|
||||
request=request,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
)
|
||||
return True
|
||||
if request.view == "installed" and remove_match:
|
||||
success, message = self._remove_local_skill(
|
||||
request=request,
|
||||
page_index=int(remove_match.group(1)),
|
||||
)
|
||||
self.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
title=message,
|
||||
)
|
||||
)
|
||||
self._render_interaction(
|
||||
request=request,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
)
|
||||
return True
|
||||
|
||||
self.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
title=self._build_usage_hint(request.view),
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
def _install_market_skill(
|
||||
self,
|
||||
request: PendingSkillsInteraction,
|
||||
page_index: int,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
按当前市场页的可见序号安装技能,避免跨页序号歧义。
|
||||
"""
|
||||
market_skills = [skill for skill in self.skillhelper.list_market_skills() if not skill.installed]
|
||||
page_items, page, _ = self._page_items(
|
||||
items=market_skills,
|
||||
page=request.market_page,
|
||||
page_size=self._page_size(request.channel),
|
||||
)
|
||||
request.market_page = page
|
||||
if page_index < 1 or page_index > len(page_items):
|
||||
return False, "安装序号无效"
|
||||
return self.skillhelper.install_market_skill(page_items[page_index - 1])
|
||||
|
||||
def _remove_local_skill(
|
||||
self,
|
||||
request: PendingSkillsInteraction,
|
||||
page_index: int,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
按当前已安装页的可见序号删除技能,并拦截内置技能。
|
||||
"""
|
||||
local_skills = self.skillhelper.list_local_skills()
|
||||
page_items, page, _ = self._page_items(
|
||||
items=local_skills,
|
||||
page=request.local_page,
|
||||
page_size=self._page_size(request.channel),
|
||||
)
|
||||
request.local_page = page
|
||||
if page_index < 1 or page_index > len(page_items):
|
||||
return False, "删除序号无效"
|
||||
target = page_items[page_index - 1]
|
||||
if not target.removable:
|
||||
return False, f"技能 {target.id} 是内置技能,不能删除"
|
||||
return self.skillhelper.remove_local_skill(target.id)
|
||||
|
||||
def _render_interaction(
|
||||
self,
|
||||
request: PendingSkillsInteraction,
|
||||
channel: MessageChannel,
|
||||
source: Optional[str],
|
||||
userid: Union[str, int],
|
||||
username: Optional[str],
|
||||
original_message_id: Optional[Union[str, int]] = None,
|
||||
original_chat_id: Optional[str] = None,
|
||||
force_market_refresh: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
根据当前视图生成内容,并选择编辑原消息或发送新消息。
|
||||
"""
|
||||
if request.view == "installed":
|
||||
title, text, buttons = self._build_installed_view(
|
||||
request=request,
|
||||
force_market_refresh=force_market_refresh,
|
||||
)
|
||||
elif request.view == "market":
|
||||
title, text, buttons = self._build_market_view(
|
||||
request=request,
|
||||
force_market_refresh=force_market_refresh,
|
||||
)
|
||||
else:
|
||||
title, text, buttons = self._build_root_view(
|
||||
request=request,
|
||||
force_market_refresh=force_market_refresh,
|
||||
)
|
||||
|
||||
self._update_or_post_message(
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
title=title,
|
||||
text=text,
|
||||
buttons=buttons,
|
||||
original_message_id=original_message_id,
|
||||
original_chat_id=original_chat_id,
|
||||
)
|
||||
|
||||
def _build_root_view(
|
||||
self,
|
||||
request: PendingSkillsInteraction,
|
||||
force_market_refresh: bool = False,
|
||||
) -> Tuple[str, str, Optional[List[List[dict]]]]:
|
||||
"""
|
||||
构建根菜单视图,汇总本地技能和市场概览。
|
||||
"""
|
||||
local_skills = self.skillhelper.list_local_skills()
|
||||
market_skills = [
|
||||
skill
|
||||
for skill in self.skillhelper.list_market_skills(force=force_market_refresh)
|
||||
if not skill.installed
|
||||
]
|
||||
sources = self.skillhelper.get_market_sources()
|
||||
source_lines = []
|
||||
for index, source in enumerate(sources, start=1):
|
||||
source_lines.append(f"{index}. {source}")
|
||||
|
||||
text_lines = [
|
||||
f"已安装技能:{len(local_skills)}",
|
||||
f"市场可安装技能:{len(market_skills)}",
|
||||
]
|
||||
if source_lines:
|
||||
text_lines.extend(["", "公开技能源:", *source_lines])
|
||||
text_lines.extend(
|
||||
[
|
||||
"",
|
||||
"1. 查看已安装技能",
|
||||
"2. 浏览技能市场",
|
||||
"回复 刷新 重新获取市场数据,回复 退出 结束交互",
|
||||
]
|
||||
)
|
||||
|
||||
buttons = None
|
||||
if self._supports_interactive_buttons(request.channel):
|
||||
buttons = [
|
||||
[{"text": "已安装技能", "callback_data": f"skills:{request.request_id}:installed"}],
|
||||
[{"text": "技能市场", "callback_data": f"skills:{request.request_id}:market"}],
|
||||
[
|
||||
{"text": "刷新市场", "callback_data": f"skills:{request.request_id}:refresh"},
|
||||
{"text": "关闭", "callback_data": f"skills:{request.request_id}:close"},
|
||||
],
|
||||
]
|
||||
return "技能管理", "\n".join(text_lines), buttons
|
||||
|
||||
def _build_installed_view(
|
||||
self,
|
||||
request: PendingSkillsInteraction,
|
||||
force_market_refresh: bool = False, # noqa: ARG002
|
||||
) -> Tuple[str, str, Optional[List[List[dict]]]]:
|
||||
"""
|
||||
构建已安装技能视图,列出来源和可删除状态。
|
||||
"""
|
||||
local_skills = self.skillhelper.list_local_skills()
|
||||
page_items, page, total_pages = self._page_items(
|
||||
items=local_skills,
|
||||
page=request.local_page,
|
||||
page_size=self._page_size(request.channel),
|
||||
)
|
||||
request.local_page = page
|
||||
|
||||
text_lines = [f"第 {page + 1}/{total_pages} 页,共 {len(local_skills)} 个技能"]
|
||||
if not page_items:
|
||||
text_lines.append("")
|
||||
text_lines.append("当前没有已安装技能")
|
||||
else:
|
||||
for index, skill in enumerate(page_items, start=1):
|
||||
action = "可删除" if skill.removable else "内置不可删"
|
||||
text_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"{index}. {skill.id} ({skill.source_label},{action})",
|
||||
self._truncate(skill.description),
|
||||
]
|
||||
)
|
||||
|
||||
text_lines.extend(
|
||||
[
|
||||
"",
|
||||
"回复 删除 <序号> 删除技能,回复 n/p 翻页,回复 返回 回到菜单,回复 退出 结束交互",
|
||||
]
|
||||
)
|
||||
|
||||
buttons = None
|
||||
if self._supports_interactive_buttons(request.channel):
|
||||
buttons = []
|
||||
for index, skill in enumerate(page_items, start=1):
|
||||
if not skill.removable:
|
||||
continue
|
||||
buttons.append(
|
||||
[
|
||||
{
|
||||
"text": f"删除 {index}",
|
||||
"callback_data": f"skills:{request.request_id}:remove:{index}",
|
||||
}
|
||||
]
|
||||
)
|
||||
buttons.extend(self._navigation_buttons(request, page, total_pages))
|
||||
buttons.append(
|
||||
[
|
||||
{"text": "返回", "callback_data": f"skills:{request.request_id}:root"},
|
||||
{"text": "关闭", "callback_data": f"skills:{request.request_id}:close"},
|
||||
]
|
||||
)
|
||||
return "已安装技能", "\n".join(text_lines), buttons
|
||||
|
||||
def _build_market_view(
|
||||
self,
|
||||
request: PendingSkillsInteraction,
|
||||
force_market_refresh: bool = False,
|
||||
) -> Tuple[str, str, Optional[List[List[dict]]]]:
|
||||
"""
|
||||
构建技能市场视图,仅展示尚未安装的技能。
|
||||
"""
|
||||
market_skills = [
|
||||
skill
|
||||
for skill in self.skillhelper.list_market_skills(force=force_market_refresh)
|
||||
if not skill.installed
|
||||
]
|
||||
page_items, page, total_pages = self._page_items(
|
||||
items=market_skills,
|
||||
page=request.market_page,
|
||||
page_size=self._page_size(request.channel),
|
||||
)
|
||||
request.market_page = page
|
||||
|
||||
text_lines = [f"第 {page + 1}/{total_pages} 页,共 {len(market_skills)} 个可安装技能"]
|
||||
if not page_items:
|
||||
text_lines.append("")
|
||||
text_lines.append("当前没有可安装的市场技能")
|
||||
else:
|
||||
for index, skill in enumerate(page_items, start=1):
|
||||
text_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"{index}. {skill.id} ({skill.source_label})",
|
||||
self._truncate(skill.description),
|
||||
]
|
||||
)
|
||||
|
||||
text_lines.extend(
|
||||
[
|
||||
"",
|
||||
"回复 安装 <序号> 安装技能,回复 刷新 重新拉取市场,回复 n/p 翻页,回复 返回 回到菜单,回复 退出 结束交互",
|
||||
]
|
||||
)
|
||||
|
||||
buttons = None
|
||||
if self._supports_interactive_buttons(request.channel):
|
||||
buttons = []
|
||||
for index, _skill in enumerate(page_items, start=1):
|
||||
buttons.append(
|
||||
[
|
||||
{
|
||||
"text": f"安装 {index}",
|
||||
"callback_data": f"skills:{request.request_id}:install:{index}",
|
||||
}
|
||||
]
|
||||
)
|
||||
buttons.extend(self._navigation_buttons(request, page, total_pages))
|
||||
buttons.append(
|
||||
[
|
||||
{"text": "刷新", "callback_data": f"skills:{request.request_id}:refresh"},
|
||||
{"text": "返回", "callback_data": f"skills:{request.request_id}:root"},
|
||||
{"text": "关闭", "callback_data": f"skills:{request.request_id}:close"},
|
||||
]
|
||||
)
|
||||
return "技能市场", "\n".join(text_lines), buttons
|
||||
|
||||
@staticmethod
|
||||
def _truncate(text: str, limit: int = 140) -> str:
|
||||
"""
|
||||
对技能描述做轻量截断,避免消息过长。
|
||||
"""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 3] + "..."
|
||||
|
||||
@staticmethod
|
||||
def _page_items(
|
||||
items: List[SkillInfo],
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> Tuple[List[SkillInfo], int, int]:
|
||||
"""
|
||||
返回当前页的数据,并把页码钳制到有效范围内。
|
||||
"""
|
||||
total_pages = max(1, math.ceil(len(items) / page_size)) if page_size else 1
|
||||
page = min(max(0, page), total_pages - 1)
|
||||
start = page * page_size
|
||||
end = start + page_size
|
||||
return items[start:end], page, total_pages
|
||||
|
||||
def _page_size(self, channel: Optional[MessageChannel]) -> int:
|
||||
"""
|
||||
按渠道能力选择分页大小,按钮渠道单页更短,便于直接操作。
|
||||
"""
|
||||
return (
|
||||
self._button_page_size
|
||||
if self._supports_interactive_buttons(channel)
|
||||
else self._text_page_size
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _supports_interactive_buttons(channel: Optional[MessageChannel]) -> bool:
|
||||
"""
|
||||
判断当前渠道是否同时支持按钮展示和回调。
|
||||
"""
|
||||
return bool(
|
||||
channel
|
||||
and ChannelCapabilityManager.supports_buttons(channel)
|
||||
and ChannelCapabilityManager.supports_callbacks(channel)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _navigation_buttons(
|
||||
request: PendingSkillsInteraction,
|
||||
page: int,
|
||||
total_pages: int,
|
||||
) -> List[List[dict]]:
|
||||
"""
|
||||
为分页视图生成上一页和下一页按钮。
|
||||
"""
|
||||
buttons = []
|
||||
nav_row = []
|
||||
if page > 0:
|
||||
nav_row.append(
|
||||
{
|
||||
"text": "⬅️ 上一页",
|
||||
"callback_data": f"skills:{request.request_id}:page-prev",
|
||||
}
|
||||
)
|
||||
if page < total_pages - 1:
|
||||
nav_row.append(
|
||||
{
|
||||
"text": "下一页 ➡️",
|
||||
"callback_data": f"skills:{request.request_id}:page-next",
|
||||
}
|
||||
)
|
||||
if nav_row:
|
||||
buttons.append(nav_row)
|
||||
return buttons
|
||||
|
||||
def _update_or_post_message(
|
||||
self,
|
||||
channel: MessageChannel,
|
||||
source: Optional[str],
|
||||
userid: Union[str, int],
|
||||
username: Optional[str],
|
||||
title: str,
|
||||
text: str,
|
||||
buttons: Optional[List[List[dict]]] = None,
|
||||
original_message_id: Optional[Union[str, int]] = None,
|
||||
original_chat_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
优先编辑原消息,编辑失败时再回退为发送新消息。
|
||||
"""
|
||||
if (
|
||||
original_message_id
|
||||
and original_chat_id
|
||||
and ChannelCapabilityManager.supports_editing(channel)
|
||||
):
|
||||
edited = self.edit_message(
|
||||
channel=channel,
|
||||
source=source,
|
||||
message_id=original_message_id,
|
||||
chat_id=original_chat_id,
|
||||
title=title,
|
||||
text=text,
|
||||
buttons=buttons,
|
||||
)
|
||||
if edited:
|
||||
return
|
||||
|
||||
self.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
title=title,
|
||||
text=text,
|
||||
buttons=buttons,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_usage_hint(view: str) -> str:
|
||||
"""
|
||||
根据当前视图返回可执行的文本命令提示。
|
||||
"""
|
||||
if view == "market":
|
||||
return "请输入 安装 <序号>、刷新、n、p、返回 或 退出"
|
||||
if view == "installed":
|
||||
return "请输入 删除 <序号>、n、p、返回 或 退出"
|
||||
return "请输入 1、2、刷新 或 退出"
|
||||
@@ -7,6 +7,7 @@ from app.chain import ChainBase
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.message import MessageChain
|
||||
from app.chain.site import SiteChain
|
||||
from app.chain.skills import SkillsChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.chain.system import SystemChain
|
||||
from app.chain.transfer import TransferChain
|
||||
@@ -154,6 +155,12 @@ class Command(metaclass=Singleton):
|
||||
"category": "管理",
|
||||
"data": {},
|
||||
},
|
||||
"/skills": {
|
||||
"func": SkillsChain().remote_manage,
|
||||
"description": "管理技能",
|
||||
"category": "智能体",
|
||||
"data": {},
|
||||
},
|
||||
}
|
||||
# 插件命令集合
|
||||
self._plugin_commands = {}
|
||||
|
||||
@@ -420,6 +420,14 @@ class ConfigModel(BaseModel):
|
||||
# 本地插件仓库目录,多个地址使用,分隔
|
||||
PLUGIN_LOCAL_REPO_PATHS: Optional[str] = None
|
||||
|
||||
# ==================== 技能配置 ====================
|
||||
# 技能市场仓库地址,多个地址使用,分隔
|
||||
SKILL_MARKET: str = (
|
||||
"https://github.com/openai/skills,"
|
||||
"https://github.com/anthropics/skills,"
|
||||
"https://github.com/vercel-labs/agent-skills"
|
||||
)
|
||||
|
||||
# ==================== Github & PIP ====================
|
||||
# Github token,提高请求api限流阈值 ghp_****
|
||||
GITHUB_TOKEN: Optional[str] = None
|
||||
|
||||
466
app/helper/skill.py
Normal file
466
app/helper/skill.py
Normal file
@@ -0,0 +1,466 @@
|
||||
import io
|
||||
import json
|
||||
import shutil
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.agent.middleware.skills import _parse_skill_metadata
|
||||
from app.core.cache import cached, fresh
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.singleton import WeakSingleton
|
||||
from app.utils.url import UrlUtils
|
||||
|
||||
_SOURCE_META_FILENAME = ".moviepilot-skill-source.json"
|
||||
_DEFAULT_BRANCHES = ("main", "master")
|
||||
_MARKET_CACHE_TTL = 60 * 30
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillInfo:
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
version: int = 0
|
||||
path: str = ""
|
||||
source_type: str = "local"
|
||||
source_label: str = "本地"
|
||||
repo_url: Optional[str] = None
|
||||
repo_name: Optional[str] = None
|
||||
skill_path: Optional[str] = None
|
||||
installed: bool = False
|
||||
removable: bool = False
|
||||
|
||||
|
||||
class SkillHelper(metaclass=WeakSingleton):
|
||||
"""
|
||||
技能市场与本地技能管理
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_user_skills_dir() -> Path:
|
||||
"""
|
||||
返回用户技能目录,所有市场安装的技能都落在这里。
|
||||
"""
|
||||
return settings.CONFIG_PATH / "agent" / "skills"
|
||||
|
||||
@staticmethod
|
||||
def get_bundled_skills_dir() -> Path:
|
||||
"""
|
||||
返回仓库内置技能目录。
|
||||
"""
|
||||
return settings.ROOT_PATH / "skills"
|
||||
|
||||
@staticmethod
|
||||
def get_market_sources() -> List[str]:
|
||||
"""
|
||||
解析配置中的技能市场列表。
|
||||
"""
|
||||
if not settings.SKILL_MARKET:
|
||||
return []
|
||||
return [item.strip() for item in settings.SKILL_MARKET.split(",") if item.strip()]
|
||||
|
||||
@staticmethod
|
||||
def _ensure_user_skills_dir() -> Path:
|
||||
"""
|
||||
确保用户技能目录存在,供安装和扫描复用。
|
||||
"""
|
||||
skill_dir = SkillHelper.get_user_skills_dir()
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
return skill_dir
|
||||
|
||||
@staticmethod
|
||||
def _normalize_repo_url(repo_url: str) -> Optional[str]:
|
||||
"""
|
||||
将技能市场配置统一归一为 GitHub HTTPS 地址。
|
||||
"""
|
||||
repo_url = (repo_url or "").strip()
|
||||
if not repo_url:
|
||||
return None
|
||||
if repo_url.startswith(("http://", "https://")):
|
||||
return repo_url.rstrip("/")
|
||||
return f"https://github.com/{repo_url.strip('/')}"
|
||||
|
||||
@staticmethod
|
||||
def _parse_market_repo(repo_url: str) -> Optional[dict]:
|
||||
"""
|
||||
解析市场仓库地址,提取仓库、分支和技能根目录信息。
|
||||
"""
|
||||
normalized = SkillHelper._normalize_repo_url(repo_url)
|
||||
if not normalized:
|
||||
return None
|
||||
parsed = urlparse(normalized)
|
||||
if parsed.netloc.lower() not in {"github.com", "www.github.com"}:
|
||||
logger.warning("暂不支持的技能市场地址:%s", repo_url)
|
||||
return None
|
||||
|
||||
parts = [part for part in parsed.path.strip("/").split("/") if part]
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
|
||||
owner = parts[0]
|
||||
repo = parts[1].removesuffix(".git")
|
||||
branch = None
|
||||
root_path = "skills"
|
||||
if len(parts) >= 4 and parts[2] == "tree":
|
||||
branch = parts[3]
|
||||
if len(parts) > 4:
|
||||
root_path = "/".join(parts[4:])
|
||||
|
||||
return {
|
||||
"repo_url": f"https://github.com/{owner}/{repo}",
|
||||
"repo_name": f"{owner}/{repo}",
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"branch": branch,
|
||||
"root_path": root_path.strip("/") or "skills",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _read_source_meta(skill_dir: Path) -> dict:
|
||||
"""
|
||||
读取技能来源元数据,用于区分本地、市场和内置技能。
|
||||
"""
|
||||
meta_path = skill_dir / _SOURCE_META_FILENAME
|
||||
if not meta_path.exists():
|
||||
return {}
|
||||
try:
|
||||
payload = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
except Exception as e:
|
||||
logger.warning("读取技能来源元数据失败:%s - %s", meta_path, e)
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _write_source_meta(skill_dir: Path, payload: dict) -> None:
|
||||
"""
|
||||
写入技能来源元数据,便于后续展示来源和追踪安装来源。
|
||||
"""
|
||||
meta_path = skill_dir / _SOURCE_META_FILENAME
|
||||
meta_path.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_bundled_skill(skill_id: str) -> bool:
|
||||
"""
|
||||
判断技能是否来自仓库内置目录。
|
||||
"""
|
||||
return (SkillHelper.get_bundled_skills_dir() / skill_id / "SKILL.md").exists()
|
||||
|
||||
def list_local_skills(self) -> List[SkillInfo]:
|
||||
"""
|
||||
扫描本地已安装技能,并补充来源和是否可删除等展示信息。
|
||||
"""
|
||||
skill_root = self._ensure_user_skills_dir()
|
||||
results: List[SkillInfo] = []
|
||||
|
||||
for path in sorted(skill_root.iterdir(), key=lambda item: item.name.lower()):
|
||||
if not path.is_dir():
|
||||
continue
|
||||
skill_md = path / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
continue
|
||||
try:
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.warning("读取技能文件失败:%s - %s", skill_md, e)
|
||||
continue
|
||||
|
||||
metadata = _parse_skill_metadata(content, str(skill_md), path.name)
|
||||
if not metadata:
|
||||
continue
|
||||
|
||||
bundled = self._is_bundled_skill(path.name)
|
||||
source_meta = self._read_source_meta(path)
|
||||
source_type = "bundled" if bundled else source_meta.get("source", "local")
|
||||
if source_type == "market":
|
||||
repo_name = source_meta.get("repo_name") or source_meta.get("repo_url")
|
||||
source_label = f"市场 · {repo_name}" if repo_name else "市场"
|
||||
elif source_type == "bundled":
|
||||
source_label = "内置"
|
||||
else:
|
||||
source_label = "本地"
|
||||
|
||||
results.append(
|
||||
SkillInfo(
|
||||
id=path.name,
|
||||
name=metadata["name"],
|
||||
description=metadata["description"],
|
||||
version=metadata["version"],
|
||||
path=str(skill_md),
|
||||
source_type=source_type,
|
||||
source_label=source_label,
|
||||
repo_url=source_meta.get("repo_url"),
|
||||
repo_name=source_meta.get("repo_name"),
|
||||
skill_path=source_meta.get("skill_path"),
|
||||
installed=True,
|
||||
removable=not bundled,
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def list_market_skills(self, force: bool = False) -> List[SkillInfo]:
|
||||
"""
|
||||
聚合所有市场源的技能,并用本地技能状态标记已安装项。
|
||||
"""
|
||||
local_skills = self.list_local_skills()
|
||||
local_ids = {skill.id for skill in local_skills}
|
||||
local_names = {skill.name for skill in local_skills}
|
||||
|
||||
deduped: Dict[str, SkillInfo] = {}
|
||||
for source in self.get_market_sources():
|
||||
with fresh(force):
|
||||
market_skills = self._list_market_repo_skills(source)
|
||||
for skill in market_skills:
|
||||
key = skill.name or skill.id
|
||||
if key in deduped:
|
||||
continue
|
||||
skill.installed = skill.id in local_ids or skill.name in local_names
|
||||
deduped[key] = skill
|
||||
|
||||
return sorted(
|
||||
deduped.values(),
|
||||
key=lambda item: (
|
||||
item.installed,
|
||||
(item.repo_name or "").lower(),
|
||||
item.name.lower(),
|
||||
),
|
||||
)
|
||||
|
||||
@cached(maxsize=16, ttl=_MARKET_CACHE_TTL, skip_empty=True)
|
||||
def _list_market_repo_skills(self, repo_url: str) -> List[SkillInfo]:
|
||||
"""
|
||||
读取单个市场仓库中的技能列表。
|
||||
|
||||
仓库按 zip 方式拉取后直接在压缩包内解析,避免落地整个仓库。
|
||||
"""
|
||||
repo = self._parse_market_repo(repo_url)
|
||||
if not repo:
|
||||
return []
|
||||
|
||||
repo_bytes = self._download_repo_archive(repo)
|
||||
if not repo_bytes:
|
||||
return []
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(repo_bytes)) as zf:
|
||||
names = zf.namelist()
|
||||
if not names:
|
||||
return []
|
||||
root_prefix = names[0].split("/", 1)[0] + "/"
|
||||
results: List[SkillInfo] = []
|
||||
seen_paths = set()
|
||||
for archive_name in names:
|
||||
if not archive_name.endswith("/SKILL.md"):
|
||||
continue
|
||||
if not archive_name.startswith(root_prefix):
|
||||
continue
|
||||
|
||||
rel_path = archive_name[len(root_prefix):].strip("/")
|
||||
if not rel_path.startswith(f"{repo['root_path'].strip('/')}/"):
|
||||
continue
|
||||
if "/.system/" in f"/{rel_path}/":
|
||||
continue
|
||||
if rel_path in seen_paths:
|
||||
continue
|
||||
seen_paths.add(rel_path)
|
||||
|
||||
skill_dir = rel_path[: -len("/SKILL.md")]
|
||||
skill_id = Path(skill_dir).name
|
||||
try:
|
||||
content = zf.read(archive_name).decode("utf-8")
|
||||
except Exception as e:
|
||||
logger.warning("读取市场技能失败:%s - %s", archive_name, e)
|
||||
continue
|
||||
|
||||
metadata = _parse_skill_metadata(
|
||||
content,
|
||||
f"{repo['repo_url']}:{rel_path}",
|
||||
skill_id,
|
||||
)
|
||||
if not metadata:
|
||||
continue
|
||||
|
||||
results.append(
|
||||
SkillInfo(
|
||||
id=skill_id,
|
||||
name=metadata["name"],
|
||||
description=metadata["description"],
|
||||
version=metadata["version"],
|
||||
path=f"{repo['repo_url']}/tree/{repo['branch']}/{skill_dir}",
|
||||
source_type="market",
|
||||
source_label=f"市场 · {repo['repo_name']}",
|
||||
repo_url=repo["repo_url"],
|
||||
repo_name=repo["repo_name"],
|
||||
skill_path=skill_dir,
|
||||
installed=False,
|
||||
removable=False,
|
||||
)
|
||||
)
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error("解析技能市场压缩包失败:%s", e)
|
||||
return []
|
||||
|
||||
def install_market_skill(self, skill: SkillInfo) -> Tuple[bool, str]:
|
||||
"""
|
||||
将市场技能安装到用户技能目录,并记录来源元数据。
|
||||
"""
|
||||
if not skill.repo_url or not skill.skill_path:
|
||||
return False, "技能来源信息不完整,无法安装"
|
||||
|
||||
target_root = self._ensure_user_skills_dir()
|
||||
target_dir = target_root / skill.id
|
||||
if target_dir.exists():
|
||||
return False, f"技能 {skill.id} 已存在"
|
||||
if self._is_bundled_skill(skill.id):
|
||||
return False, f"技能 {skill.id} 是 MoviePilot 内置技能,不能覆盖安装"
|
||||
|
||||
repo = self._parse_market_repo(skill.repo_url)
|
||||
if not repo:
|
||||
return False, "技能市场地址无效"
|
||||
|
||||
repo_bytes = self._download_repo_archive(repo)
|
||||
if not repo_bytes:
|
||||
return False, "下载技能仓库失败,请检查网络连接或 GitHub 配置"
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(repo_bytes)) as zf:
|
||||
names = zf.namelist()
|
||||
if not names:
|
||||
return False, "技能仓库为空"
|
||||
root_prefix = names[0].split("/", 1)[0] + "/"
|
||||
skill_prefix = f"{root_prefix}{skill.skill_path.strip('/')}/"
|
||||
matched = [name for name in names if name.startswith(skill_prefix)]
|
||||
if not matched:
|
||||
return False, f"未找到技能目录:{skill.skill_path}"
|
||||
|
||||
target_dir.mkdir(parents=True, exist_ok=False)
|
||||
try:
|
||||
wrote = False
|
||||
for archive_name in matched:
|
||||
rel_name = archive_name[len(skill_prefix):]
|
||||
if not rel_name:
|
||||
continue
|
||||
output_path = target_dir / rel_name
|
||||
if archive_name.endswith("/"):
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zf.open(archive_name, "r") as src, open(output_path, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
wrote = True
|
||||
|
||||
if not wrote or not (target_dir / "SKILL.md").exists():
|
||||
shutil.rmtree(target_dir, ignore_errors=True)
|
||||
return False, "技能目录内容不完整,安装失败"
|
||||
|
||||
self._write_source_meta(
|
||||
target_dir,
|
||||
{
|
||||
"source": "market",
|
||||
"repo_url": repo["repo_url"],
|
||||
"repo_name": repo["repo_name"],
|
||||
"branch": repo["branch"],
|
||||
"skill_path": skill.skill_path,
|
||||
"installed_at": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
return True, f"技能 {skill.id} 已安装到 {target_dir}"
|
||||
except Exception:
|
||||
shutil.rmtree(target_dir, ignore_errors=True)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("安装市场技能失败:%s", e)
|
||||
return False, f"安装技能失败:{e}"
|
||||
|
||||
def remove_local_skill(self, skill_id: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
删除一个本地技能目录,内置技能会被显式拦截。
|
||||
"""
|
||||
if not skill_id:
|
||||
return False, "技能ID不能为空"
|
||||
if self._is_bundled_skill(skill_id):
|
||||
return False, f"技能 {skill_id} 是 MoviePilot 内置技能,不能删除"
|
||||
|
||||
skill_dir = self._ensure_user_skills_dir() / skill_id
|
||||
if not skill_dir.exists():
|
||||
return False, f"技能 {skill_id} 不存在"
|
||||
if not (skill_dir / "SKILL.md").exists():
|
||||
return False, f"{skill_id} 不是有效的技能目录"
|
||||
|
||||
try:
|
||||
shutil.rmtree(skill_dir)
|
||||
return True, f"技能 {skill_id} 已删除"
|
||||
except Exception as e:
|
||||
logger.error("删除技能失败:%s", e)
|
||||
return False, f"删除技能失败:{e}"
|
||||
|
||||
def _download_repo_archive(self, repo: dict) -> Optional[bytes]:
|
||||
"""
|
||||
下载市场仓库压缩包,并在缺省分支之间做回退尝试。
|
||||
"""
|
||||
branches = [repo.get("branch")] if repo.get("branch") else []
|
||||
branches.extend([branch for branch in _DEFAULT_BRANCHES if branch not in branches])
|
||||
for branch in branches:
|
||||
archive_url = (
|
||||
f"https://codeload.github.com/{repo['owner']}/{repo['repo']}/zip/refs/heads/{branch}"
|
||||
)
|
||||
response = self._request_github(
|
||||
url=archive_url,
|
||||
repo_name=repo["repo_name"],
|
||||
is_api=False,
|
||||
)
|
||||
if response is not None and response.status_code == 200:
|
||||
repo["branch"] = branch
|
||||
return response.content
|
||||
logger.warning("下载技能市场仓库失败:%s", repo["repo_url"])
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _request_github(
|
||||
url: str,
|
||||
repo_name: str,
|
||||
is_api: bool = False,
|
||||
timeout: int = 30,
|
||||
):
|
||||
"""
|
||||
按代理优先级顺序请求 GitHub 资源,兼容代理和直连场景。
|
||||
"""
|
||||
strategies = []
|
||||
headers = settings.REPO_GITHUB_HEADERS(repo=repo_name)
|
||||
if not is_api and settings.GITHUB_PROXY:
|
||||
proxy_url = f"{UrlUtils.standardize_base_url(settings.GITHUB_PROXY)}{url}"
|
||||
strategies.append((proxy_url, {"headers": headers, "timeout": timeout}))
|
||||
if settings.PROXY_HOST:
|
||||
strategies.append(
|
||||
(
|
||||
url,
|
||||
{
|
||||
"headers": headers,
|
||||
"proxies": settings.PROXY,
|
||||
"timeout": timeout,
|
||||
},
|
||||
)
|
||||
)
|
||||
strategies.append((url, {"headers": headers, "timeout": timeout}))
|
||||
|
||||
for target_url, kwargs in strategies:
|
||||
try:
|
||||
response = RequestUtils(**kwargs).get_res(
|
||||
url=target_url,
|
||||
raise_exception=True,
|
||||
)
|
||||
if response is not None and response.status_code == 200:
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.warning("请求技能市场失败:%s - %s", target_url, e)
|
||||
return None
|
||||
@@ -439,6 +439,7 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
||||
chat_id: Union[str, int],
|
||||
text: str,
|
||||
title: Optional[str] = None,
|
||||
buttons: Optional[List[List[dict]]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
编辑消息
|
||||
@@ -448,6 +449,7 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
||||
:param chat_id: 聊天ID
|
||||
:param text: 新的消息内容
|
||||
:param title: 消息标题
|
||||
:param buttons: 新的按钮列表
|
||||
:return: 编辑是否成功
|
||||
"""
|
||||
if channel != self._channel:
|
||||
@@ -460,6 +462,7 @@ class DiscordModule(_ModuleBase, _MessageBase[Discord]):
|
||||
result = client.send_msg(
|
||||
title=title or "",
|
||||
text=text,
|
||||
buttons=buttons,
|
||||
original_message_id=message_id,
|
||||
original_chat_id=str(chat_id),
|
||||
)
|
||||
|
||||
@@ -557,6 +557,7 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
|
||||
chat_id: Union[str, int],
|
||||
text: str,
|
||||
title: Optional[str] = None,
|
||||
buttons: Optional[List[List[dict]]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
编辑消息
|
||||
@@ -566,6 +567,7 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
|
||||
:param chat_id: 聊天ID
|
||||
:param text: 新的消息内容
|
||||
:param title: 消息标题
|
||||
:param buttons: 新的按钮列表
|
||||
:return: 编辑是否成功
|
||||
"""
|
||||
if channel != self._channel:
|
||||
@@ -578,6 +580,7 @@ class SlackModule(_ModuleBase, _MessageBase[Slack]):
|
||||
result = client.send_msg(
|
||||
title=title or "",
|
||||
text=text,
|
||||
buttons=buttons,
|
||||
original_message_id=str(message_id),
|
||||
original_chat_id=str(chat_id),
|
||||
)
|
||||
|
||||
@@ -564,6 +564,7 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
||||
chat_id: Union[str, int],
|
||||
text: str,
|
||||
title: Optional[str] = None,
|
||||
buttons: Optional[List[List[dict]]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
编辑消息
|
||||
@@ -573,6 +574,7 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
||||
:param chat_id: 聊天ID
|
||||
:param text: 新的消息内容
|
||||
:param title: 消息标题
|
||||
:param buttons: 新的按钮列表
|
||||
:return: 编辑是否成功
|
||||
"""
|
||||
if channel != self._channel:
|
||||
@@ -587,6 +589,7 @@ class TelegramModule(_ModuleBase, _MessageBase[Telegram]):
|
||||
message_id=message_id,
|
||||
text=text,
|
||||
title=title,
|
||||
buttons=buttons,
|
||||
)
|
||||
if result:
|
||||
return True
|
||||
|
||||
@@ -835,6 +835,7 @@ class Telegram:
|
||||
message_id: Union[str, int],
|
||||
text: str,
|
||||
title: Optional[str] = None,
|
||||
buttons: Optional[List[List[dict]]] = None,
|
||||
) -> Optional[bool]:
|
||||
"""
|
||||
编辑Telegram消息(公开方法)
|
||||
@@ -842,6 +843,7 @@ class Telegram:
|
||||
:param message_id: 消息ID
|
||||
:param text: 新的消息内容
|
||||
:param title: 消息标题
|
||||
:param buttons: 新的按钮列表
|
||||
:return: 编辑是否成功
|
||||
"""
|
||||
if not self._bot:
|
||||
@@ -861,6 +863,7 @@ class Telegram:
|
||||
chat_id=str(chat_id),
|
||||
message_id=int(message_id),
|
||||
text=caption,
|
||||
buttons=buttons,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"编辑Telegram消息异常: {str(e)}")
|
||||
|
||||
164
skills/create-moviepilot-skill/SKILL.md
Normal file
164
skills/create-moviepilot-skill/SKILL.md
Normal file
@@ -0,0 +1,164 @@
|
||||
---
|
||||
name: create-moviepilot-skill
|
||||
version: 1
|
||||
description: >-
|
||||
Use this skill when the user asks to create, scaffold, update, or review a
|
||||
MoviePilot agent skill. This includes adding a new built-in skill under the
|
||||
repository `skills/` directory, editing an existing built-in skill, writing
|
||||
`SKILL.md` frontmatter and workflow instructions, choosing `allowed-tools`,
|
||||
adding helper scripts when needed, and bumping the built-in skill `version`
|
||||
so changes can sync into `config/agent/skills`.
|
||||
allowed-tools: list_directory read_file write_file edit_file execute_command
|
||||
---
|
||||
|
||||
# Create MoviePilot Skill
|
||||
|
||||
This skill guides you through creating or updating a built-in MoviePilot agent
|
||||
skill in this repository.
|
||||
|
||||
## Scope
|
||||
|
||||
Use this workflow for repository built-in skills:
|
||||
|
||||
- Create or update files under `skills/<skill-id>/`
|
||||
- Commit the skill as part of the MoviePilot repository
|
||||
- Do not place the implementation only in `config/agent/skills` unless the user
|
||||
explicitly asks for a local override instead of a built-in skill
|
||||
|
||||
## MoviePilot-Specific Rules
|
||||
|
||||
- The repository root `skills/` directory is the bundled source of truth for
|
||||
built-in skills.
|
||||
- On agent startup, bundled skills are synced into `config/agent/skills`.
|
||||
- Sync overwrite depends on the `version` field in `SKILL.md`. If you update an
|
||||
existing built-in skill, increment `version`, or users may continue using an
|
||||
older copied version.
|
||||
- Keep the folder name and frontmatter `name` identical. Use lowercase letters,
|
||||
digits, and hyphens only.
|
||||
- Prefer extending an existing skill instead of creating an overlapping
|
||||
duplicate.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Understand the Request
|
||||
|
||||
- Determine whether the user wants a new skill or a change to an existing one.
|
||||
- Extract the target task, likely trigger phrases, needed tools, and whether
|
||||
helper scripts are necessary.
|
||||
- If the goal is still ambiguous after reading the request and local context,
|
||||
ask one focused clarification question. Otherwise proceed with a reasonable
|
||||
default.
|
||||
|
||||
### Step 2: Check Existing Skills First
|
||||
|
||||
- Inspect the repository `skills/` directory before creating anything new.
|
||||
- If an existing skill already covers most of the workflow, update it instead of
|
||||
adding a near-duplicate.
|
||||
- Reuse the repository style: concise YAML frontmatter, trigger-rich
|
||||
description, and procedural body sections.
|
||||
|
||||
### Step 3: Choose the Skill ID and Path
|
||||
|
||||
- New built-in skill path: `skills/<skill-id>/SKILL.md`
|
||||
- Keep `<skill-id>` short, hyphen-case, and under 64 characters.
|
||||
- Use a verb-led or domain-led name that makes the trigger obvious, such as
|
||||
`transfer-failed-retry`, `moviepilot-api`, or `create-moviepilot-skill`.
|
||||
|
||||
### Step 4: Write Frontmatter Correctly
|
||||
|
||||
Use this shape:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: create-moviepilot-skill
|
||||
version: 1
|
||||
description: >-
|
||||
Explain what the skill does and exactly when to use it.
|
||||
allowed-tools: list_directory read_file write_file edit_file execute_command
|
||||
---
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `description` is the primary trigger surface. Put concrete "when to use"
|
||||
scenarios there.
|
||||
- Include `version` for built-in skills. Increment it whenever you ship a new
|
||||
built-in revision.
|
||||
- Add `allowed-tools` when the workflow depends on a small, well-defined tool
|
||||
set.
|
||||
- Add `compatibility` only when environment constraints actually matter.
|
||||
|
||||
### Step 5: Write the Body
|
||||
|
||||
The body should contain:
|
||||
|
||||
- A short purpose statement
|
||||
- MoviePilot-specific rules or guardrails
|
||||
- A step-by-step workflow
|
||||
- Concrete examples of matching user requests
|
||||
- References to supporting files when they exist
|
||||
|
||||
Prefer:
|
||||
|
||||
- Imperative instructions
|
||||
- Concrete file paths
|
||||
- Examples aligned with actual MoviePilot conventions
|
||||
|
||||
Avoid:
|
||||
|
||||
- Generic theory that does not change execution
|
||||
- Large duplicated documentation
|
||||
- Extra files like `README.md` or `CHANGELOG.md` inside the skill directory
|
||||
|
||||
### Step 6: Add Supporting Files Only When They Help
|
||||
|
||||
- Add `scripts/` only when the same deterministic work would otherwise be
|
||||
rewritten repeatedly.
|
||||
- Keep helper files inside the same skill directory.
|
||||
- Reference helper paths explicitly from `SKILL.md`.
|
||||
- If the skill is instructions-only, keep it to a single `SKILL.md`.
|
||||
|
||||
### Step 7: Implement the Skill
|
||||
|
||||
For a new built-in skill:
|
||||
|
||||
1. Create `skills/<skill-id>/`
|
||||
2. Create `SKILL.md`
|
||||
3. Add helper scripts only if they are justified
|
||||
|
||||
For an existing built-in skill:
|
||||
|
||||
1. Edit `skills/<skill-id>/SKILL.md`
|
||||
2. Increment `version`
|
||||
3. Update helper files in the same directory if needed
|
||||
|
||||
### Step 8: Validate Before Finishing
|
||||
|
||||
- Re-read the frontmatter and confirm `name` matches the directory name.
|
||||
- Confirm `description` mentions real trigger scenarios.
|
||||
- If you changed an existing built-in skill, confirm `version` increased.
|
||||
- If possible, validate the file can be parsed by the MoviePilot skills loader.
|
||||
- Report the final path and note whether the agent needs a restart to sync the
|
||||
latest built-in skill into `config/agent/skills`.
|
||||
|
||||
## Minimal Example
|
||||
|
||||
User request:
|
||||
|
||||
`给 MoviePilot agent 加一个处理站点 Cookie 更新的内置技能`
|
||||
|
||||
Expected outcome:
|
||||
|
||||
- Create or update a directory such as `skills/update-site-cookie/`
|
||||
- Write `SKILL.md` with a trigger-rich `description`
|
||||
- Include only the tools needed for that workflow
|
||||
- Increment `version` when revising an existing built-in skill
|
||||
|
||||
## Final Checklist
|
||||
|
||||
- Is the skill under the repository `skills/` directory?
|
||||
- Does the folder name equal frontmatter `name`?
|
||||
- Does `description` clearly say when the skill should trigger?
|
||||
- Did you avoid duplicating an existing skill unnecessarily?
|
||||
- Did you increment `version` for built-in skill updates?
|
||||
- Did you keep the skill lean and procedural?
|
||||
183
tests/test_skills_command.py
Normal file
183
tests/test_skills_command.py
Normal file
@@ -0,0 +1,183 @@
|
||||
import io
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.modules.setdefault("qbittorrentapi", ModuleType("qbittorrentapi"))
|
||||
setattr(sys.modules["qbittorrentapi"], "TorrentFilesList", list)
|
||||
sys.modules.setdefault("transmission_rpc", ModuleType("transmission_rpc"))
|
||||
setattr(sys.modules["transmission_rpc"], "File", object)
|
||||
sys.modules.setdefault("psutil", ModuleType("psutil"))
|
||||
|
||||
from app.chain.message import MessageChain
|
||||
from app.chain.skills import SkillsChain, skills_interaction_manager
|
||||
from app.helper.skill import SkillHelper, SkillInfo
|
||||
from app.schemas.types import MessageChannel
|
||||
|
||||
|
||||
def _build_skill_zip(skill_dir: str, skill_name: str) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr(
|
||||
f"demo-main/{skill_dir}/SKILL.md",
|
||||
(
|
||||
f"---\n"
|
||||
f"name: {skill_name}\n"
|
||||
f"version: 1\n"
|
||||
f"description: demo skill\n"
|
||||
f"---\n\n"
|
||||
f"# {skill_name}\n"
|
||||
),
|
||||
)
|
||||
zf.writestr(f"demo-main/{skill_dir}/scripts/example.py", "print('ok')\n")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
class TestSkillsCommand(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
skills_interaction_manager.clear()
|
||||
|
||||
def test_message_routes_text_reply_to_skills_interaction_before_ai(self):
|
||||
chain = MessageChain()
|
||||
skills_interaction_manager.create_or_replace(
|
||||
user_id="10001",
|
||||
channel=MessageChannel.Wechat,
|
||||
source="wechat-test",
|
||||
username="tester",
|
||||
)
|
||||
|
||||
with patch.object(chain, "_record_user_message"), patch(
|
||||
"app.chain.message.SkillsChain.handle_text_interaction",
|
||||
return_value=True,
|
||||
) as handle_text, patch.object(chain, "_handle_ai_message") as handle_ai:
|
||||
chain.handle_message(
|
||||
channel=MessageChannel.Wechat,
|
||||
source="wechat-test",
|
||||
userid="10001",
|
||||
username="tester",
|
||||
text="2",
|
||||
)
|
||||
|
||||
handle_text.assert_called_once()
|
||||
handle_ai.assert_not_called()
|
||||
|
||||
def test_callback_routes_to_skills_chain(self):
|
||||
chain = MessageChain()
|
||||
request = skills_interaction_manager.create_or_replace(
|
||||
user_id="10001",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
username="tester",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.chain.message.SkillsChain.handle_callback_interaction",
|
||||
return_value=True,
|
||||
) as handle_callback:
|
||||
chain._handle_callback(
|
||||
text=f"CALLBACK:skills:{request.request_id}:market",
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
username="tester",
|
||||
)
|
||||
|
||||
handle_callback.assert_called_once()
|
||||
|
||||
def test_skillhelper_install_and_remove_market_skill(self):
|
||||
helper = SkillHelper()
|
||||
skill = SkillInfo(
|
||||
id="demo-skill",
|
||||
name="demo-skill",
|
||||
description="demo",
|
||||
source_type="market",
|
||||
source_label="市场 · acme/demo",
|
||||
repo_url="https://github.com/acme/demo",
|
||||
repo_name="acme/demo",
|
||||
skill_path="skills/demo-skill",
|
||||
)
|
||||
zip_bytes = _build_skill_zip("skills/demo-skill", "demo-skill")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
user_root = Path(tempdir) / "user-skills"
|
||||
bundled_root = Path(tempdir) / "bundled-skills"
|
||||
user_root.mkdir(parents=True, exist_ok=True)
|
||||
bundled_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with patch.object(
|
||||
SkillHelper, "get_user_skills_dir", return_value=user_root
|
||||
), patch.object(
|
||||
SkillHelper, "get_bundled_skills_dir", return_value=bundled_root
|
||||
), patch.object(
|
||||
helper, "_download_repo_archive", return_value=zip_bytes
|
||||
):
|
||||
success, message = helper.install_market_skill(skill)
|
||||
self.assertTrue(success, message)
|
||||
self.assertTrue((user_root / "demo-skill" / "SKILL.md").exists())
|
||||
self.assertTrue(
|
||||
(user_root / "demo-skill" / ".moviepilot-skill-source.json").exists()
|
||||
)
|
||||
|
||||
local_skills = helper.list_local_skills()
|
||||
self.assertEqual(len(local_skills), 1)
|
||||
self.assertEqual(local_skills[0].source_type, "market")
|
||||
self.assertTrue(local_skills[0].removable)
|
||||
|
||||
removed, remove_message = helper.remove_local_skill("demo-skill")
|
||||
self.assertTrue(removed, remove_message)
|
||||
self.assertFalse((user_root / "demo-skill").exists())
|
||||
|
||||
bundled_skill_dir = bundled_root / "builtin-skill"
|
||||
bundled_skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(bundled_skill_dir / "SKILL.md").write_text(
|
||||
"---\nname: builtin-skill\ndescription: builtin\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
installed_builtin = user_root / "builtin-skill"
|
||||
installed_builtin.mkdir(parents=True, exist_ok=True)
|
||||
(installed_builtin / "SKILL.md").write_text(
|
||||
"---\nname: builtin-skill\ndescription: builtin\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
removed, remove_message = helper.remove_local_skill("builtin-skill")
|
||||
self.assertFalse(removed)
|
||||
self.assertIn("内置技能", remove_message)
|
||||
|
||||
def test_skills_chain_updates_buttons_via_edit_message(self):
|
||||
chain = SkillsChain()
|
||||
buttons = [[{"text": "安装 1", "callback_data": "skills:req:install:1"}]]
|
||||
|
||||
with patch.object(chain, "edit_message", return_value=True) as edit_message, patch.object(
|
||||
chain, "post_message"
|
||||
) as post_message:
|
||||
chain._update_or_post_message(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
userid="10001",
|
||||
username="tester",
|
||||
title="技能市场",
|
||||
text="请选择技能",
|
||||
buttons=buttons,
|
||||
original_message_id=123,
|
||||
original_chat_id="456",
|
||||
)
|
||||
|
||||
edit_message.assert_called_once_with(
|
||||
channel=MessageChannel.Telegram,
|
||||
source="telegram-test",
|
||||
message_id=123,
|
||||
chat_id="456",
|
||||
title="技能市场",
|
||||
text="请选择技能",
|
||||
buttons=buttons,
|
||||
)
|
||||
post_message.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user