Files
MoviePilot/app/chain/skills.py
2026-05-01 09:53:04 +08:00

1078 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import re
from typing import List, Optional, Tuple, Union
from app.chain import ChainBase
from app.helper.interaction import (
build_navigation_buttons,
page_items,
supports_interaction_buttons,
update_or_post_message, skills_interaction_manager, PendingSkillsInteraction,
)
from app.helper.skill import SkillHelper, SkillInfo
from app.schemas import Notification
from app.schemas.types import MessageChannel
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,
)
normalized_arg = (arg_str or "").strip()
force = normalized_arg.lower() in {"refresh", "刷新"}
search_query = self._extract_market_search_query(normalized_arg) or (
"" if force else normalized_arg
)
if search_query:
request.view = "market"
request.market_query = search_query
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"
request.awaiting_input = None
elif action == "installed":
request.view = "installed"
request.local_page = 0
request.awaiting_input = None
elif action == "market":
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":
request.awaiting_input = None
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":
request.awaiting_input = None
if request.view == "installed":
request.local_page += 1
elif request.view == "market":
request.market_page += 1
elif action == "page-prev":
request.awaiting_input = None
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:
request.awaiting_input = None
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:
request.awaiting_input = None
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
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,
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"
request.awaiting_input = None
self._render_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
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(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
force_market_refresh=True,
)
return True
if lowered in {"p", "prev", "上一页"}:
request.awaiting_input = None
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", "下一页"}:
request.awaiting_input = None
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"
elif lowered in {"3", "技能源", "", "sources", "source"}:
request.view = "sources"
elif self._extract_market_search_query(normalized):
self._apply_market_search(
request,
self._extract_market_search_query(normalized),
)
else:
self.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title="请输入 `1` 查看已安装技能,`2` 查看技能市场3 管理技能源,或回复 `刷新/退出`",
)
)
return True
self._render_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
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)
self._render_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
if request.awaiting_input == "market-search":
if lowered in {"取消", "cancel"}:
request.awaiting_input = None
else:
search_query = self._extract_market_search_query(normalized) or normalized
self._apply_market_search(request, search_query)
self._render_interaction(
request=request,
channel=channel,
source=source,
userid=userid,
username=username,
)
return True
search_query = self._extract_market_search_query(normalized)
if search_query:
self._apply_market_search(request, search_query)
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:
request.awaiting_input = None
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:
request.awaiting_input = None
_, 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 = self._get_market_skills(request=request)
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(items):
return False, "安装序号无效"
return self.skillhelper.install_market_skill(items[page_index - 1])
def _remove_local_skill(
self,
request: PendingSkillsInteraction,
page_index: int,
) -> Tuple[bool, str]:
"""
按当前已安装页的可见序号删除技能,并拦截内置技能。
"""
local_skills = self.skillhelper.list_local_skills()
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(items):
return False, "删除序号无效"
target = items[page_index - 1]
if not target.removable:
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,
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
)
elif request.view == "market":
title, text, buttons = self._build_market_view(
request=request,
force_market_refresh=force_market_refresh,
)
elif request.view == "sources":
title, text, buttons = self._build_sources_view(
request=request,
)
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
]
source_entries = self.skillhelper.list_market_source_entries()
source_lines = []
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(
[
"",
"1. 查看已安装技能",
"2. 浏览技能市场",
"3. 管理技能源",
"回复 `刷新` 重新获取市场数据,回复 `退出` 结束交互",
]
)
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}:sources"}],
[
{"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
) -> Tuple[str, str, Optional[List[List[dict]]]]:
"""
构建已安装技能视图,列出来源和可删除状态。
"""
local_skills = self.skillhelper.list_local_skills()
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 items:
text_lines.append("")
text_lines.append("当前没有已安装技能")
else:
for index, skill in enumerate(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 = self._get_market_skills(
request=request,
force_market_refresh=force_market_refresh,
)
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 request.market_query:
text_lines.append(f"当前搜索:{request.market_query}")
if request.awaiting_input == "market-search":
text_lines.extend(
[
"",
"搜索输入中:直接回复关键词即可筛选市场技能,回复 `取消` 结束输入。",
]
)
if not items:
text_lines.append("")
if request.market_query:
text_lines.append("当前搜索没有匹配的市场技能")
else:
text_lines.append("当前没有可安装的市场技能")
else:
for index, skill in enumerate(items, start=1):
text_lines.extend(
[
"",
f"{index}. {skill.id} ({skill.source_label})",
self._truncate(skill.description),
]
)
if skill.source_type == "registry":
text_lines.append("社区源,安装前请自行甄别安全性")
if any(skill.source_type == "registry" for skill in page_items):
text_lines.extend(
[
"",
"提示ClawHub 属于社区注册表,技能质量与安全性需要自行甄别。",
]
)
text_lines.extend(
[
"",
"回复 `搜索 <关键词>` 筛选技能,回复 `清除搜索` 恢复全量列表,回复 `安装 <序号>` 安装技能,回复 `刷新` 重新拉取市场,回复 `n/p` 翻页,回复 `返回` 回到菜单,回复 `退出` 结束交互",
]
)
buttons = None
if self._supports_interactive_buttons(request.channel):
buttons = []
search_row = []
if request.market_query:
search_row.append(
{
"text": "清除搜索",
"callback_data": f"skills:{request.request_id}:clear-search",
}
)
else:
search_row.append(
{
"text": "搜索",
"callback_data": f"skills:{request.request_id}:search",
}
)
if search_row:
buttons.append(search_row)
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
def _build_sources_view(
self,
request: PendingSkillsInteraction,
) -> 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:
"""
对技能描述做轻量截断,避免消息过长。
"""
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]:
"""
返回当前页的数据,并把页码钳制到有效范围内。
"""
return page_items(items=items, page=page, page_size=page_size)
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 supports_interaction_buttons(channel)
@staticmethod
def _navigation_buttons(
request: PendingSkillsInteraction,
page: int,
total_pages: int,
) -> List[List[dict]]:
"""
为分页视图生成上一页和下一页按钮。
"""
return build_navigation_buttons(
prefix="skills",
request=request,
page=page,
total_pages=total_pages,
)
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:
"""
优先编辑原消息,编辑失败时再回退为发送新消息。
"""
update_or_post_message(
chain=self,
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,
)
@staticmethod
def _build_usage_hint(view: str) -> str:
"""
根据当前视图返回可执行的文本命令提示。
"""
if view == "market":
return "请输入 搜索 <关键词>、清除搜索、安装 <序号>、刷新、n、p、返回 或 退出"
if view == "installed":
return "请输入 删除 <序号>、n、p、返回 或 退出"
if view == "sources":
return "请输入 添加源 <GitHub仓库地址>、删除源 <序号>、返回 或 退出"
return "请输入 1、2、3、搜索 <关键词>、刷新 或 退出"
def _get_market_skills(
self,
request: PendingSkillsInteraction,
force_market_refresh: bool = False,
) -> List[SkillInfo]:
"""
获取当前 /skills 会话可见的市场技能,并应用搜索词过滤。
"""
skills = [
skill
for skill in self.skillhelper.list_market_skills(force=force_market_refresh)
if not skill.installed
]
if not request.market_query:
return skills
return self.skillhelper.filter_market_skills(
skills=skills,
query=request.market_query,
)
@staticmethod
def _extract_market_search_query(text: str) -> str:
"""
从文本命令中提取市场搜索词,兼容“搜索/查找/查”前缀。
"""
normalized = (text or "").strip()
if not normalized:
return ""
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,
query: str,
) -> None:
"""
将会话切到市场搜索结果视图,并重置分页状态。
"""
request.view = "market"
request.market_query = (query or "").strip()
request.market_page = 0
request.awaiting_input = None
@staticmethod
def _clear_market_search(request: PendingSkillsInteraction) -> None:
"""
清除当前市场搜索状态,恢复全量市场列表。
"""
request.market_query = ""
request.market_page = 0
request.awaiting_input = None