mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-07-21 04:31:59 +08:00
feat: support custom skill sources in /skills
This commit is contained in:
@@ -260,9 +260,15 @@ class SkillsChain(ChainBase):
|
||||
request.view = "market"
|
||||
request.market_page = 0
|
||||
request.awaiting_input = None
|
||||
elif action == "sources":
|
||||
request.view = "sources"
|
||||
request.awaiting_input = None
|
||||
elif action == "search":
|
||||
request.view = "market"
|
||||
request.awaiting_input = "market-search"
|
||||
elif action == "source-add":
|
||||
request.view = "sources"
|
||||
request.awaiting_input = "source-add"
|
||||
elif action == "clear-search":
|
||||
self._clear_market_search(request)
|
||||
elif action == "refresh":
|
||||
@@ -328,6 +334,19 @@ class SkillsChain(ChainBase):
|
||||
if not success:
|
||||
# 保持当前页
|
||||
pass
|
||||
elif action == "source-remove" and index:
|
||||
request.view = "sources"
|
||||
request.awaiting_input = None
|
||||
success, message = self._remove_market_source(index)
|
||||
self.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
title=message,
|
||||
)
|
||||
)
|
||||
|
||||
self._render_interaction(
|
||||
request=request,
|
||||
@@ -386,6 +405,59 @@ class SkillsChain(ChainBase):
|
||||
)
|
||||
return True
|
||||
|
||||
add_source = self._extract_market_source_input(normalized)
|
||||
remove_source_match = re.match(
|
||||
r"^(?:删除源|移除源|删除仓库|移除仓库|remove source)\s*(\d+)$",
|
||||
normalized,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
if add_source:
|
||||
request.view = "sources"
|
||||
request.awaiting_input = None
|
||||
_, message = self.skillhelper.add_custom_market_source(add_source)
|
||||
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 remove_source_match:
|
||||
request.view = "sources"
|
||||
request.awaiting_input = None
|
||||
_, message = self._remove_market_source(
|
||||
page_index=int(remove_source_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 lowered in {"刷新", "refresh"}:
|
||||
request.awaiting_input = None
|
||||
self._render_interaction(
|
||||
@@ -433,6 +505,8 @@ class SkillsChain(ChainBase):
|
||||
request.view = "installed"
|
||||
elif lowered in {"2", "市场", "market"}:
|
||||
request.view = "market"
|
||||
elif lowered in {"3", "技能源", "源", "sources", "source"}:
|
||||
request.view = "sources"
|
||||
elif self._extract_market_search_query(normalized):
|
||||
self._apply_market_search(
|
||||
request,
|
||||
@@ -445,7 +519,7 @@ class SkillsChain(ChainBase):
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username,
|
||||
title="请输入 1 查看已安装技能,2 查看技能市场,或回复 刷新/退出",
|
||||
title="请输入 1 查看已安装技能,2 查看技能市场,3 管理技能源,或回复 刷新/退出",
|
||||
)
|
||||
)
|
||||
return True
|
||||
@@ -458,6 +532,30 @@ class SkillsChain(ChainBase):
|
||||
)
|
||||
return True
|
||||
|
||||
if request.awaiting_input == "source-add":
|
||||
if lowered in {"取消", "cancel"}:
|
||||
request.awaiting_input = None
|
||||
else:
|
||||
_, message = self.skillhelper.add_custom_market_source(normalized)
|
||||
request.awaiting_input = None
|
||||
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 lowered in {"清除搜索", "取消搜索", "clear", "clear search"}:
|
||||
if request.view == "market" or request.market_query:
|
||||
self._clear_market_search(request)
|
||||
@@ -524,7 +622,7 @@ class SkillsChain(ChainBase):
|
||||
return True
|
||||
if request.view == "installed" and remove_match:
|
||||
request.awaiting_input = None
|
||||
success, message = self._remove_local_skill(
|
||||
_, message = self._remove_local_skill(
|
||||
request=request,
|
||||
page_index=int(remove_match.group(1)),
|
||||
)
|
||||
@@ -598,6 +696,18 @@ class SkillsChain(ChainBase):
|
||||
return False, f"技能 {target.id} 是内置技能,不能删除"
|
||||
return self.skillhelper.remove_local_skill(target.id)
|
||||
|
||||
def _remove_market_source(self, page_index: int) -> Tuple[bool, str]:
|
||||
"""
|
||||
按当前源列表序号删除自定义技能源,避免误删内置默认源。
|
||||
"""
|
||||
sources = self.skillhelper.list_market_source_entries()
|
||||
if page_index < 1 or page_index > len(sources):
|
||||
return False, "删除源序号无效"
|
||||
target = sources[page_index - 1]
|
||||
if not target.removable:
|
||||
return False, f"技能源 {target.label} 是内置默认源,不能删除"
|
||||
return self.skillhelper.remove_custom_market_source(target.source)
|
||||
|
||||
def _render_interaction(
|
||||
self,
|
||||
request: PendingSkillsInteraction,
|
||||
@@ -622,6 +732,11 @@ class SkillsChain(ChainBase):
|
||||
request=request,
|
||||
force_market_refresh=force_market_refresh,
|
||||
)
|
||||
elif request.view == "sources":
|
||||
title, text, buttons = self._build_sources_view(
|
||||
request=request,
|
||||
force_market_refresh=force_market_refresh,
|
||||
)
|
||||
else:
|
||||
title, text, buttons = self._build_root_view(
|
||||
request=request,
|
||||
@@ -654,22 +769,26 @@ class SkillsChain(ChainBase):
|
||||
for skill in self.skillhelper.list_market_skills(force=force_market_refresh)
|
||||
if not skill.installed
|
||||
]
|
||||
sources = self.skillhelper.get_market_sources()
|
||||
source_entries = self.skillhelper.list_market_source_entries()
|
||||
source_lines = []
|
||||
for index, source in enumerate(sources, start=1):
|
||||
source_lines.append(f"{index}. {self.skillhelper.describe_market_source(source)}")
|
||||
for index, source_entry in enumerate(source_entries, start=1):
|
||||
state = "内置" if source_entry.builtin else "自定义"
|
||||
source_lines.append(
|
||||
f"{index}. {source_entry.label}({state})"
|
||||
)
|
||||
|
||||
text_lines = [
|
||||
f"已安装技能:{len(local_skills)}",
|
||||
f"市场可安装技能:{len(market_skills)}",
|
||||
]
|
||||
if source_lines:
|
||||
text_lines.extend(["", "公开技能源:", *source_lines])
|
||||
text_lines.extend(["", "当前技能源:", *source_lines])
|
||||
text_lines.extend(
|
||||
[
|
||||
"",
|
||||
"1. 查看已安装技能",
|
||||
"2. 浏览技能市场",
|
||||
"3. 管理技能源",
|
||||
"回复 刷新 重新获取市场数据,回复 退出 结束交互",
|
||||
]
|
||||
)
|
||||
@@ -679,6 +798,7 @@ class SkillsChain(ChainBase):
|
||||
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}:sources"}],
|
||||
[
|
||||
{"text": "刷新市场", "callback_data": f"skills:{request.request_id}:refresh"},
|
||||
{"text": "关闭", "callback_data": f"skills:{request.request_id}:close"},
|
||||
@@ -848,6 +968,79 @@ class SkillsChain(ChainBase):
|
||||
)
|
||||
return "技能市场", "\n".join(text_lines), buttons
|
||||
|
||||
def _build_sources_view(
|
||||
self,
|
||||
request: PendingSkillsInteraction,
|
||||
force_market_refresh: bool = False, # noqa: ARG002
|
||||
) -> Tuple[str, str, Optional[List[List[dict]]]]:
|
||||
"""
|
||||
构建技能源管理视图,提供自定义 GitHub 源的增删入口。
|
||||
"""
|
||||
sources = self.skillhelper.list_market_source_entries()
|
||||
custom_count = len([source for source in sources if not source.builtin])
|
||||
text_lines = [
|
||||
f"当前技能源:{len(sources)}",
|
||||
f"自定义技能源:{custom_count}",
|
||||
]
|
||||
if request.awaiting_input == "source-add":
|
||||
text_lines.extend(
|
||||
[
|
||||
"",
|
||||
"添加输入中:直接回复 GitHub 仓库地址即可。",
|
||||
"支持 owner/repo、https://github.com/owner/repo,或 /tree/<branch>/<skills_path> 形式。",
|
||||
"回复 取消 结束输入。",
|
||||
]
|
||||
)
|
||||
|
||||
if not sources:
|
||||
text_lines.extend(["", "当前没有可用技能源"])
|
||||
else:
|
||||
for index, market_source in enumerate(sources, start=1):
|
||||
state = "自定义可删" if market_source.removable else "内置默认"
|
||||
text_lines.extend(
|
||||
[
|
||||
"",
|
||||
f"{index}. {market_source.label}({state})",
|
||||
self._truncate(market_source.source, limit=200),
|
||||
]
|
||||
)
|
||||
|
||||
text_lines.extend(
|
||||
[
|
||||
"",
|
||||
"回复 添加源 <GitHub仓库地址> 添加自定义源,回复 删除源 <序号> 删除自定义源,回复 返回 回到菜单,回复 退出 结束交互",
|
||||
]
|
||||
)
|
||||
|
||||
buttons = None
|
||||
if self._supports_interactive_buttons(request.channel):
|
||||
buttons = [
|
||||
[
|
||||
{
|
||||
"text": "添加自定义源",
|
||||
"callback_data": f"skills:{request.request_id}:source-add",
|
||||
}
|
||||
]
|
||||
]
|
||||
for index, market_source in enumerate(sources, start=1):
|
||||
if not market_source.removable:
|
||||
continue
|
||||
buttons.append(
|
||||
[
|
||||
{
|
||||
"text": f"删除 {index}",
|
||||
"callback_data": f"skills:{request.request_id}:source-remove:{index}",
|
||||
}
|
||||
]
|
||||
)
|
||||
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
|
||||
|
||||
@staticmethod
|
||||
def _truncate(text: str, limit: int = 140) -> str:
|
||||
"""
|
||||
@@ -975,7 +1168,9 @@ class SkillsChain(ChainBase):
|
||||
return "请输入 搜索 <关键词>、清除搜索、安装 <序号>、刷新、n、p、返回 或 退出"
|
||||
if view == "installed":
|
||||
return "请输入 删除 <序号>、n、p、返回 或 退出"
|
||||
return "请输入 1、2、搜索 <关键词>、刷新 或 退出"
|
||||
if view == "sources":
|
||||
return "请输入 添加源 <GitHub仓库地址>、删除源 <序号>、返回 或 退出"
|
||||
return "请输入 1、2、3、搜索 <关键词>、刷新 或 退出"
|
||||
|
||||
def _get_market_skills(
|
||||
self,
|
||||
@@ -1008,6 +1203,21 @@ class SkillsChain(ChainBase):
|
||||
match = re.match(r"^(?:搜索|查找|查)\s+(.+)$", normalized)
|
||||
return match.group(1).strip() if match else ""
|
||||
|
||||
@staticmethod
|
||||
def _extract_market_source_input(text: str) -> str:
|
||||
"""
|
||||
从文本命令中提取自定义技能源地址。
|
||||
"""
|
||||
normalized = (text or "").strip()
|
||||
if not normalized:
|
||||
return ""
|
||||
match = re.match(
|
||||
r"^(?:添加源|新增源|添加仓库|新增仓库|add source)\s+(.+)$",
|
||||
normalized,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
return match.group(1).strip() if match else ""
|
||||
|
||||
@staticmethod
|
||||
def _apply_market_search(
|
||||
request: PendingSkillsInteraction,
|
||||
|
||||
@@ -53,6 +53,14 @@ class SkillInfo:
|
||||
removable: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillMarketSource:
|
||||
source: str
|
||||
label: str
|
||||
builtin: bool = True
|
||||
removable: bool = False
|
||||
|
||||
|
||||
class SkillHelper(metaclass=WeakSingleton):
|
||||
"""
|
||||
技能市场与本地技能管理
|
||||
@@ -81,6 +89,16 @@ class SkillHelper(metaclass=WeakSingleton):
|
||||
return []
|
||||
return [item.strip() for item in settings.SKILL_MARKET.split(",") if item.strip()]
|
||||
|
||||
@staticmethod
|
||||
def get_default_market_sources() -> List[str]:
|
||||
"""
|
||||
返回系统默认的技能市场列表,用于区分内置源和用户追加源。
|
||||
"""
|
||||
default_value = type(settings).model_fields["SKILL_MARKET"].default
|
||||
if not default_value:
|
||||
return []
|
||||
return [item.strip() for item in str(default_value).split(",") if item.strip()]
|
||||
|
||||
@staticmethod
|
||||
def _ensure_user_skills_dir() -> Path:
|
||||
"""
|
||||
@@ -90,6 +108,31 @@ class SkillHelper(metaclass=WeakSingleton):
|
||||
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
return skill_dir
|
||||
|
||||
@staticmethod
|
||||
def _canonical_market_source(source: str) -> Optional[str]:
|
||||
"""
|
||||
生成市场源的规范化值,用于去重、默认源比对和持久化。
|
||||
"""
|
||||
normalized = (source or "").strip()
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
registry = SkillHelper._parse_market_registry(normalized)
|
||||
if registry:
|
||||
return registry["registry_url"].rstrip("/")
|
||||
|
||||
repo = SkillHelper._parse_market_repo(normalized)
|
||||
if repo:
|
||||
# 对 GitHub 仓库保留分支和技能根目录,避免不同路径的技能仓库混淆。
|
||||
if repo["branch"]:
|
||||
return (
|
||||
f"{repo['repo_url']}/tree/"
|
||||
f"{repo['branch']}/{repo['root_path'].strip('/')}"
|
||||
).rstrip("/")
|
||||
return repo["repo_url"].rstrip("/")
|
||||
|
||||
return normalized.rstrip("/")
|
||||
|
||||
@staticmethod
|
||||
def _build_repo_source_label(repo_name: Optional[str]) -> str:
|
||||
"""
|
||||
@@ -125,6 +168,98 @@ class SkillHelper(metaclass=WeakSingleton):
|
||||
return self._build_repo_source_label(repo.get("repo_name"))
|
||||
return source
|
||||
|
||||
def list_market_source_entries(self) -> List[SkillMarketSource]:
|
||||
"""
|
||||
返回当前技能源及其是否属于内置默认源的展示信息。
|
||||
"""
|
||||
default_keys = {
|
||||
self._canonical_market_source(item) for item in self.get_default_market_sources()
|
||||
}
|
||||
results: List[SkillMarketSource] = []
|
||||
for source in self.get_market_sources():
|
||||
source_key = self._canonical_market_source(source)
|
||||
builtin = source_key in default_keys
|
||||
results.append(
|
||||
SkillMarketSource(
|
||||
source=source,
|
||||
label=self.describe_market_source(source),
|
||||
builtin=builtin,
|
||||
removable=not builtin,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _persist_market_sources(sources: List[str]) -> Tuple[bool, str]:
|
||||
"""
|
||||
将技能源列表写回配置文件,并同步更新内存中的 settings。
|
||||
"""
|
||||
filtered_sources = [item.strip() for item in sources if item and item.strip()]
|
||||
success, message = settings.update_setting(
|
||||
key="SKILL_MARKET",
|
||||
value=",".join(filtered_sources),
|
||||
)
|
||||
if success is False:
|
||||
return False, message
|
||||
return True, message
|
||||
|
||||
def add_custom_market_source(self, source: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
添加自定义 GitHub 技能源,支持 owner/repo 与 GitHub URL 两种写法。
|
||||
"""
|
||||
repo = self._parse_market_repo(source)
|
||||
if not repo:
|
||||
return (
|
||||
False,
|
||||
"仅支持 GitHub skills 仓库,示例:openai/skills 或 https://github.com/openai/skills",
|
||||
)
|
||||
|
||||
canonical_source = self._canonical_market_source(source)
|
||||
if not canonical_source:
|
||||
return False, "技能源地址不能为空"
|
||||
|
||||
existing_keys = {
|
||||
self._canonical_market_source(item) for item in self.get_market_sources()
|
||||
}
|
||||
if canonical_source in existing_keys:
|
||||
return False, "该技能源已存在"
|
||||
|
||||
current_sources = self.get_market_sources()
|
||||
success, message = self._persist_market_sources(
|
||||
current_sources + [canonical_source]
|
||||
)
|
||||
if not success:
|
||||
return False, message
|
||||
return True, f"已添加技能源:{self.describe_market_source(canonical_source)}"
|
||||
|
||||
def remove_custom_market_source(self, source: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
删除一个自定义技能源,内置默认源不允许移除。
|
||||
"""
|
||||
canonical_source = self._canonical_market_source(source)
|
||||
if not canonical_source:
|
||||
return False, "技能源地址无效"
|
||||
|
||||
default_keys = {
|
||||
self._canonical_market_source(item) for item in self.get_default_market_sources()
|
||||
}
|
||||
if canonical_source in default_keys:
|
||||
return False, f"技能源 {self.describe_market_source(source)} 是内置默认源,不能删除"
|
||||
|
||||
current_sources = self.get_market_sources()
|
||||
remaining_sources = [
|
||||
item
|
||||
for item in current_sources
|
||||
if self._canonical_market_source(item) != canonical_source
|
||||
]
|
||||
if len(remaining_sources) == len(current_sources):
|
||||
return False, "技能源不存在"
|
||||
|
||||
success, message = self._persist_market_sources(remaining_sources)
|
||||
if not success:
|
||||
return False, message
|
||||
return True, f"已删除技能源:{self.describe_market_source(source)}"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_repo_url(repo_url: str) -> Optional[str]:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user