refactor: reorganize interaction chain

This commit is contained in:
jxxghp
2026-05-01 09:53:04 +08:00
parent db6dc926cf
commit 4d0a722b09
12 changed files with 1975 additions and 1988 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import List, Optional, Type
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
from app.agent.tools.base import MoviePilotTool, ToolChain from app.agent.tools.base import MoviePilotTool, ToolChain
from app.chain.interaction import ( from app.helper.interaction import (
AgentInteractionOption, AgentInteractionOption,
agent_interaction_manager, agent_interaction_manager,
) )
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -24,9 +24,9 @@ from app.schemas.types import (
ScrapingPolicy, ScrapingPolicy,
SystemConfigKey, SystemConfigKey,
) )
from app.utils.http import RequestUtils
from app.utils.mixins import ConfigReloadMixin from app.utils.mixins import ConfigReloadMixin
from app.utils.singleton import Singleton from app.utils.singleton import Singleton
from app.utils.http import RequestUtils
from app.utils.string import StringUtils from app.utils.string import StringUtils
recognize_lock = Lock() recognize_lock = Lock()
@@ -173,8 +173,8 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
def on_config_changed(self): def on_config_changed(self):
self.scraping_policies = ScrapingConfig.from_system_config() self.scraping_policies = ScrapingConfig.from_system_config()
@staticmethod
def _should_scrape( def _should_scrape(
self,
scraping_option: ScrapingOption, scraping_option: ScrapingOption,
file_exists: bool, file_exists: bool,
global_overwrite: bool = False, global_overwrite: bool = False,
@@ -402,8 +402,9 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
episode=episode, episode=episode,
) )
@staticmethod
def select_recognize_source( def select_recognize_source(
self, log_name: str, log_context: str, native_fn, plugin_fn log_name: str, log_context: str, native_fn, plugin_fn
) -> Optional[MediaInfo]: ) -> Optional[MediaInfo]:
""" """
选择识别模式,插件优先或原生优先 选择识别模式,插件优先或原生优先
@@ -1051,7 +1052,6 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
meta=meta, meta=meta,
mediainfo=mediainfo, mediainfo=mediainfo,
init_folder=init_folder, init_folder=init_folder,
parent=parent,
overwrite=overwrite, overwrite=overwrite,
recursive=recursive, recursive=recursive,
) )
@@ -1062,7 +1062,6 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
meta: MetaBase, meta: MetaBase,
mediainfo: MediaInfo, mediainfo: MediaInfo,
init_folder: bool, init_folder: bool,
parent: schemas.FileItem,
overwrite: bool, overwrite: bool,
recursive: bool, recursive: bool,
): ):
@@ -1296,8 +1295,9 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
else: else:
logger.warn("无法识别元数据,跳过") logger.warn("无法识别元数据,跳过")
@staticmethod
async def async_select_recognize_source( async def async_select_recognize_source(
self, log_name: str, log_context: str, native_fn, plugin_fn log_name: str, log_context: str, native_fn, plugin_fn
) -> Optional[MediaInfo]: ) -> Optional[MediaInfo]:
""" """
选择识别模式,插件优先或原生优先(异步版本) 选择识别模式,插件优先或原生优先(异步版本)
+1103 -9
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -7,7 +7,7 @@ from urllib.parse import urljoin
from lxml import etree from lxml import etree
from app.chain import ChainBase from app.chain import ChainBase
from app.helper.slash import ( from app.helper.interaction import (
SlashInteractionManager, SlashInteractionManager,
build_navigation_buttons, build_navigation_buttons,
format_markdown_table, format_markdown_table,
@@ -1060,8 +1060,9 @@ class SiteChain(ChainBase):
original_chat_id=original_chat_id, original_chat_id=original_chat_id,
) )
@staticmethod
def _format_site_list( def _format_site_list(
self, site_list: List[Site], channel: Optional[MessageChannel] site_list: List[Site], channel: Optional[MessageChannel]
) -> str: ) -> str:
""" """
根据渠道能力格式化站点列表。 根据渠道能力格式化站点列表。
+15 -142
View File
@@ -1,145 +1,18 @@
import re import re
from dataclasses import dataclass, field from typing import List, Optional, Tuple, Union
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.chain import ChainBase
from app.helper.slash import ( from app.helper.interaction import (
build_navigation_buttons, build_navigation_buttons,
page_items, page_items,
supports_interaction_buttons, supports_interaction_buttons,
update_or_post_message, update_or_post_message, skills_interaction_manager, PendingSkillsInteraction,
) )
from app.helper.skill import SkillHelper, SkillInfo from app.helper.skill import SkillHelper, SkillInfo
from app.schemas import Notification from app.schemas import Notification
from app.schemas.types import MessageChannel 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
market_query: str = ""
awaiting_input: Optional[str] = None
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): class SkillsChain(ChainBase):
""" """
处理 /skills 指令、按钮回调和文本式技能管理交互。 处理 /skills 指令、按钮回调和文本式技能管理交互。
@@ -668,15 +541,15 @@ class SkillsChain(ChainBase):
按当前市场页的可见序号安装技能,避免跨页序号歧义。 按当前市场页的可见序号安装技能,避免跨页序号歧义。
""" """
market_skills = self._get_market_skills(request=request) market_skills = self._get_market_skills(request=request)
page_items, page, _ = self._page_items( items, page, _ = self._page_items(
items=market_skills, items=market_skills,
page=request.market_page, page=request.market_page,
page_size=self._page_size(request.channel), page_size=self._page_size(request.channel),
) )
request.market_page = page request.market_page = page
if page_index < 1 or page_index > len(page_items): if page_index < 1 or page_index > len(items):
return False, "安装序号无效" return False, "安装序号无效"
return self.skillhelper.install_market_skill(page_items[page_index - 1]) return self.skillhelper.install_market_skill(items[page_index - 1])
def _remove_local_skill( def _remove_local_skill(
self, self,
@@ -687,15 +560,15 @@ class SkillsChain(ChainBase):
按当前已安装页的可见序号删除技能,并拦截内置技能。 按当前已安装页的可见序号删除技能,并拦截内置技能。
""" """
local_skills = self.skillhelper.list_local_skills() local_skills = self.skillhelper.list_local_skills()
page_items, page, _ = self._page_items( items, page, _ = self._page_items(
items=local_skills, items=local_skills,
page=request.local_page, page=request.local_page,
page_size=self._page_size(request.channel), page_size=self._page_size(request.channel),
) )
request.local_page = page request.local_page = page
if page_index < 1 or page_index > len(page_items): if page_index < 1 or page_index > len(items):
return False, "删除序号无效" return False, "删除序号无效"
target = page_items[page_index - 1] target = items[page_index - 1]
if not target.removable: if not target.removable:
return False, f"技能 {target.id} 是内置技能,不能删除" return False, f"技能 {target.id} 是内置技能,不能删除"
return self.skillhelper.remove_local_skill(target.id) return self.skillhelper.remove_local_skill(target.id)
@@ -816,7 +689,7 @@ class SkillsChain(ChainBase):
构建已安装技能视图,列出来源和可删除状态。 构建已安装技能视图,列出来源和可删除状态。
""" """
local_skills = self.skillhelper.list_local_skills() local_skills = self.skillhelper.list_local_skills()
page_items, page, total_pages = self._page_items( items, page, total_pages = self._page_items(
items=local_skills, items=local_skills,
page=request.local_page, page=request.local_page,
page_size=self._page_size(request.channel), page_size=self._page_size(request.channel),
@@ -824,11 +697,11 @@ class SkillsChain(ChainBase):
request.local_page = page request.local_page = page
text_lines = [f"{page + 1}/{total_pages} 页,共 {len(local_skills)} 个技能"] text_lines = [f"{page + 1}/{total_pages} 页,共 {len(local_skills)} 个技能"]
if not page_items: if not items:
text_lines.append("") text_lines.append("")
text_lines.append("当前没有已安装技能") text_lines.append("当前没有已安装技能")
else: else:
for index, skill in enumerate(page_items, start=1): for index, skill in enumerate(items, start=1):
action = "可删除" if skill.removable else "内置不可删" action = "可删除" if skill.removable else "内置不可删"
text_lines.extend( text_lines.extend(
[ [
@@ -880,7 +753,7 @@ class SkillsChain(ChainBase):
request=request, request=request,
force_market_refresh=force_market_refresh, force_market_refresh=force_market_refresh,
) )
page_items, page, total_pages = self._page_items( items, page, total_pages = self._page_items(
items=market_skills, items=market_skills,
page=request.market_page, page=request.market_page,
page_size=self._page_size(request.channel), page_size=self._page_size(request.channel),
@@ -897,14 +770,14 @@ class SkillsChain(ChainBase):
"搜索输入中:直接回复关键词即可筛选市场技能,回复 `取消` 结束输入。", "搜索输入中:直接回复关键词即可筛选市场技能,回复 `取消` 结束输入。",
] ]
) )
if not page_items: if not items:
text_lines.append("") text_lines.append("")
if request.market_query: if request.market_query:
text_lines.append("当前搜索没有匹配的市场技能") text_lines.append("当前搜索没有匹配的市场技能")
else: else:
text_lines.append("当前没有可安装的市场技能") text_lines.append("当前没有可安装的市场技能")
else: else:
for index, skill in enumerate(page_items, start=1): for index, skill in enumerate(items, start=1):
text_lines.extend( text_lines.extend(
[ [
"", "",
+2 -1
View File
@@ -1,6 +1,7 @@
import copy import copy
import json import json
import random import random
import re
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
@@ -11,7 +12,7 @@ from app.chain import ChainBase
from app.chain.download import DownloadChain from app.chain.download import DownloadChain
from app.chain.media import MediaChain from app.chain.media import MediaChain
from app.chain.search import SearchChain from app.chain.search import SearchChain
from app.helper.slash import ( from app.helper.interaction import (
SlashInteractionManager, SlashInteractionManager,
build_navigation_buttons, build_navigation_buttons,
format_markdown_table, format_markdown_table,
-1
View File
@@ -1 +0,0 @@
from .cloudflare import under_challenge
+626
View File
@@ -0,0 +1,626 @@
import math
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from threading import Lock
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.schemas import Notification
from app.schemas.message import ChannelCapabilityManager
from app.schemas.types import MessageChannel
@dataclass
class PendingSlashInteraction:
"""
通用 slash 命令交互上下文
"""
request_id: str
user_id: str
channel: Optional[MessageChannel]
source: Optional[str]
username: Optional[str]
command: str
page: int = 0
awaiting_input: Optional[str] = None
created_at: datetime = field(default_factory=datetime.now)
class SlashInteractionManager:
"""
管理单个 slash 命令的交互会话
"""
_ttl = timedelta(hours=24)
def __init__(self):
self._by_id: Dict[str, PendingSlashInteraction] = {}
self._by_user: Dict[str, str] = {}
self._lock = Lock()
def _cleanup_locked(self) -> None:
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],
command: str,
channel: Optional[MessageChannel],
source: Optional[str],
username: Optional[str],
) -> PendingSlashInteraction:
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 = PendingSlashInteraction(
request_id=uuid.uuid4().hex[:12],
user_id=user_key,
command=command,
channel=channel,
source=source,
username=username,
)
self._by_id[request.request_id] = request
self._by_user[user_key] = request.request_id
return request
def get_by_user(
self, user_id: Union[str, int]
) -> Optional[PendingSlashInteraction]:
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[PendingSlashInteraction]:
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:
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) -> None:
with self._lock:
self._by_id.clear()
self._by_user.clear()
def supports_interaction_buttons(channel: Optional[MessageChannel]) -> bool:
"""
渠道同时支持按钮和回调时优先使用按钮交互
"""
return bool(
channel
and ChannelCapabilityManager.supports_buttons(channel)
and ChannelCapabilityManager.supports_callbacks(channel)
)
def supports_markdown(channel: Optional[MessageChannel]) -> bool:
"""
仅在支持 Markdown 的渠道上输出 Markdown 内容
"""
return bool(channel and ChannelCapabilityManager.supports_markdown(channel))
def page_items(
items: Sequence[Any],
page: int,
page_size: int,
) -> Tuple[List[Any], int, int]:
"""
对列表做分页并规范化页码
"""
total = len(items)
if total == 0:
return [], 0, 1
total_pages = max(1, math.ceil(total / max(1, page_size)))
page = min(max(0, page), total_pages - 1)
start = page * page_size
end = start + page_size
return list(items[start:end]), page, total_pages
def build_navigation_buttons(
prefix: str,
request: Any,
page: int,
total_pages: int,
) -> List[List[dict]]:
"""
构造标准上一页/下一页按钮
"""
buttons = []
nav_row = []
if page > 0:
nav_row.append(
{
"text": "⬅️ 上一页",
"callback_data": f"{prefix}:{request.request_id}:page-prev",
}
)
if page < total_pages - 1:
nav_row.append(
{
"text": "下一页 ➡️",
"callback_data": f"{prefix}:{request.request_id}:page-next",
}
)
if nav_row:
buttons.append(nav_row)
return buttons
def update_or_post_message(
chain,
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 = chain.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
chain.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=title,
text=text,
buttons=buttons,
)
)
def escape_markdown_table_cell(value: object) -> str:
"""
最小化转义 Markdown 表格中的特殊字符
"""
text = str(value or "").replace("\n", "<br>")
return text.replace("|", "\\|")
def format_markdown_table(
headers: Sequence[str],
rows: Sequence[Sequence[object]],
) -> str:
"""
生成 Markdown 表格文本
"""
header_line = (
"| "
+ " | ".join(escape_markdown_table_cell(item) for item in headers)
+ " |"
)
separator_line = "| " + " | ".join("---" for _ in headers) + " |"
data_lines = [
"| "
+ " | ".join(escape_markdown_table_cell(item) for item in row)
+ " |"
for row in rows
]
return "\n".join([header_line, separator_line, *data_lines])
@dataclass
class PendingMediaInteraction:
"""
记录一次搜索/下载/订阅交互的当前上下文
"""
request_id: str
user_id: str
channel: Optional[MessageChannel]
source: Optional[str]
username: Optional[str]
action: str
keyword: str
phase: str = "media"
page: int = 0
title: str = ""
meta: Optional[MetaBase] = None
current_media: Optional[MediaInfo] = None
items: List[Any] = field(default_factory=list)
created_at: datetime = field(default_factory=datetime.now)
class MediaInteractionManager:
"""
管理用户当前激活的媒体交互状态
每个用户只保留一个有效会话避免旧按钮与新一轮搜索混用
"""
_ttl = timedelta(hours=24)
def __init__(self):
self._by_id: Dict[str, PendingMediaInteraction] = {}
self._by_user: Dict[str, str] = {}
self._lock = Lock()
def _cleanup_locked(self) -> None:
"""
清理超时会话避免内存中残留旧交互状态
"""
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],
action: str,
keyword: str,
title: str = "",
meta: Optional[MetaBase] = None,
items: Optional[List[Any]] = None,
) -> PendingMediaInteraction:
"""
为用户创建新的交互状态并替换旧会话
"""
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 = PendingMediaInteraction(
request_id=uuid.uuid4().hex[:12],
user_id=user_key,
channel=channel,
source=source,
username=username,
action=action,
keyword=keyword,
title=title,
meta=meta,
items=list(items or []),
)
self._by_id[request.request_id] = request
self._by_user[user_key] = request.request_id
return request
def get_by_user(
self, user_id: Union[str, int]
) -> Optional[PendingMediaInteraction]:
"""
按用户读取当前会话供文本回复和旧按钮兼容使用
"""
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[PendingMediaInteraction]:
"""
按请求 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:
"""
主动结束一条会话
"""
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) -> None:
"""
清空所有交互状态主要用于测试
"""
with self._lock:
self._by_id.clear()
self._by_user.clear()
media_interaction_manager = MediaInteractionManager()
@dataclass(frozen=True)
class AgentInteractionOption:
"""
Agent 交互选项
"""
label: str
value: str
@dataclass
class PendingAgentInteraction:
"""
待处理的 Agent 客户端交互请求
"""
request_id: str
session_id: str
user_id: str
channel: Optional[str]
source: Optional[str]
username: Optional[str]
title: Optional[str]
prompt: str
options: List[AgentInteractionOption]
created_at: datetime = field(default_factory=datetime.now)
class AgentInteractionManager:
"""
管理 Agent 发起的客户端交互请求
"""
_ttl = timedelta(hours=24)
def __init__(self):
self._pending_interactions: Dict[str, PendingAgentInteraction] = {}
self._lock = Lock()
def _cleanup_locked(self) -> None:
expire_before = datetime.now() - self._ttl
expired_ids = [
request_id
for request_id, request in self._pending_interactions.items()
if request.created_at < expire_before
]
for request_id in expired_ids:
self._pending_interactions.pop(request_id, None)
def create_request(
self,
session_id: str,
user_id: str,
channel: Optional[str],
source: Optional[str],
username: Optional[str],
title: Optional[str],
prompt: str,
options: List[AgentInteractionOption],
) -> PendingAgentInteraction:
"""
创建一条待用户确认的 Agent 交互请求
"""
with self._lock:
self._cleanup_locked()
request_id = uuid.uuid4().hex[:12]
while request_id in self._pending_interactions:
request_id = uuid.uuid4().hex[:12]
request = PendingAgentInteraction(
request_id=request_id,
session_id=session_id,
user_id=str(user_id),
channel=channel,
source=source,
username=username,
title=title,
prompt=prompt,
options=options,
)
self._pending_interactions[request_id] = request
return request
def resolve(
self,
request_id: str,
option_index: int,
user_id: Optional[str] = None,
) -> Optional[tuple[PendingAgentInteraction, AgentInteractionOption]]:
"""
消费一条 Agent 交互请求并返回选中的选项
"""
with self._lock:
self._cleanup_locked()
request = self._pending_interactions.get(request_id)
if not request:
return None
if user_id is not None and str(request.user_id) != str(user_id):
return None
if option_index < 1 or option_index > len(request.options):
return None
option = request.options[option_index - 1]
self._pending_interactions.pop(request_id, None)
return request, option
def clear(self) -> None:
"""
清空所有 Agent 交互请求
"""
with self._lock:
self._pending_interactions.clear()
agent_interaction_manager = AgentInteractionManager()
@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
market_query: str = ""
awaiting_input: Optional[str] = None
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()
-244
View File
@@ -1,244 +0,0 @@
import math
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from threading import Lock
from typing import Dict, List, Optional, Sequence, Tuple, Union
from app.schemas import Notification
from app.schemas.message import ChannelCapabilityManager
from app.schemas.types import MessageChannel
@dataclass
class PendingSlashInteraction:
"""
通用 slash 命令交互上下文
"""
request_id: str
user_id: str
channel: Optional[MessageChannel]
source: Optional[str]
username: Optional[str]
command: str
page: int = 0
awaiting_input: Optional[str] = None
created_at: datetime = field(default_factory=datetime.now)
class SlashInteractionManager:
"""
管理单个 slash 命令的交互会话
"""
_ttl = timedelta(hours=24)
def __init__(self):
self._by_id: Dict[str, PendingSlashInteraction] = {}
self._by_user: Dict[str, str] = {}
self._lock = Lock()
def _cleanup_locked(self) -> None:
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],
command: str,
channel: Optional[MessageChannel],
source: Optional[str],
username: Optional[str],
) -> PendingSlashInteraction:
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 = PendingSlashInteraction(
request_id=uuid.uuid4().hex[:12],
user_id=user_key,
command=command,
channel=channel,
source=source,
username=username,
)
self._by_id[request.request_id] = request
self._by_user[user_key] = request.request_id
return request
def get_by_user(
self, user_id: Union[str, int]
) -> Optional[PendingSlashInteraction]:
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[PendingSlashInteraction]:
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:
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) -> None:
with self._lock:
self._by_id.clear()
self._by_user.clear()
def supports_interaction_buttons(channel: Optional[MessageChannel]) -> bool:
"""
渠道同时支持按钮和回调时优先使用按钮交互
"""
return bool(
channel
and ChannelCapabilityManager.supports_buttons(channel)
and ChannelCapabilityManager.supports_callbacks(channel)
)
def supports_markdown(channel: Optional[MessageChannel]) -> bool:
"""
仅在支持 Markdown 的渠道上输出 Markdown 内容
"""
return bool(channel and ChannelCapabilityManager.supports_markdown(channel))
def page_items(
items: Sequence,
page: int,
page_size: int,
) -> Tuple[List, int, int]:
"""
对列表做分页并规范化页码
"""
total = len(items)
if total == 0:
return [], 0, 1
total_pages = max(1, math.ceil(total / max(1, page_size)))
page = min(max(0, page), total_pages - 1)
start = page * page_size
end = start + page_size
return list(items[start:end]), page, total_pages
def build_navigation_buttons(
prefix: str,
request: PendingSlashInteraction,
page: int,
total_pages: int,
) -> List[List[dict]]:
"""
构造标准上一页/下一页按钮
"""
buttons = []
nav_row = []
if page > 0:
nav_row.append(
{
"text": "⬅️ 上一页",
"callback_data": f"{prefix}:{request.request_id}:page-prev",
}
)
if page < total_pages - 1:
nav_row.append(
{
"text": "下一页 ➡️",
"callback_data": f"{prefix}:{request.request_id}:page-next",
}
)
if nav_row:
buttons.append(nav_row)
return buttons
def update_or_post_message(
chain,
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 = chain.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
chain.post_message(
Notification(
channel=channel,
source=source,
userid=userid,
username=username,
title=title,
text=text,
buttons=buttons,
)
)
def escape_markdown_table_cell(value: object) -> str:
"""
最小化转义 Markdown 表格中的特殊字符
"""
text = str(value or "").replace("\n", "<br>")
text = text.replace("|", "\\|")
return text
def format_markdown_table(headers: Sequence[str], rows: Sequence[Sequence[object]]) -> str:
"""
生成 Markdown 表格文本
"""
header_line = "| " + " | ".join(escape_markdown_table_cell(item) for item in headers) + " |"
separator_line = "| " + " | ".join("---" for _ in headers) + " |"
data_lines = [
"| "
+ " | ".join(escape_markdown_table_cell(item) for item in row)
+ " |"
for row in rows
]
return "\n".join([header_line, separator_line, *data_lines])
+1 -1
View File
@@ -8,7 +8,7 @@ from app.agent.tools.impl.ask_user_choice import (
AskUserChoiceTool, AskUserChoiceTool,
UserChoiceOptionInput, UserChoiceOptionInput,
) )
from app.chain.interaction import ( from app.helper.interaction import (
AgentInteractionOption, AgentInteractionOption,
agent_interaction_manager, agent_interaction_manager,
) )
+6 -6
View File
@@ -9,7 +9,7 @@ sys.modules.setdefault("transmission_rpc", ModuleType("transmission_rpc"))
setattr(sys.modules["transmission_rpc"], "File", object) setattr(sys.modules["transmission_rpc"], "File", object)
sys.modules.setdefault("psutil", ModuleType("psutil")) sys.modules.setdefault("psutil", ModuleType("psutil"))
from app.chain.interaction import MediaInteractionChain, media_interaction_manager from app.chain.media import MediaChain, media_interaction_manager
from app.chain.message import MessageChain from app.chain.message import MessageChain
from app.core.context import MediaInfo from app.core.context import MediaInfo
from app.core.meta import MetaBase from app.core.meta import MetaBase
@@ -43,7 +43,7 @@ class TestMediaInteraction(unittest.TestCase):
self.assertIsNotNone(request) self.assertIsNotNone(request)
with patch.object(chain, "_record_user_message"), patch( with patch.object(chain, "_record_user_message"), patch(
"app.chain.message.MediaInteractionChain.handle_text_interaction", "app.chain.message.MediaChain.handle_text_interaction",
return_value=True, return_value=True,
) as handle_text, patch.object(chain, "_handle_ai_message") as handle_ai: ) as handle_text, patch.object(chain, "_handle_ai_message") as handle_ai:
chain.handle_message( chain.handle_message(
@@ -72,7 +72,7 @@ class TestMediaInteraction(unittest.TestCase):
) )
with patch( with patch(
"app.chain.message.MediaInteractionChain.handle_callback_interaction", "app.chain.message.MediaChain.handle_callback_interaction",
return_value=True, return_value=True,
) as handle_callback: ) as handle_callback:
chain._handle_callback( chain._handle_callback(
@@ -86,7 +86,7 @@ class TestMediaInteraction(unittest.TestCase):
handle_callback.assert_called_once() handle_callback.assert_called_once()
def test_media_interaction_starts_search_and_posts_media_list(self): def test_media_interaction_starts_search_and_posts_media_list(self):
chain = MediaInteractionChain() chain = MediaChain()
meta = self._build_meta("星际穿越") meta = self._build_meta("星际穿越")
medias = [ medias = [
MediaInfo(title="星际穿越", year="2014"), MediaInfo(title="星际穿越", year="2014"),
@@ -94,7 +94,7 @@ class TestMediaInteraction(unittest.TestCase):
] ]
with patch( with patch(
"app.chain.interaction.MediaChain.search", "app.chain.media.MediaChain.search",
return_value=(meta, medias), return_value=(meta, medias),
), patch.object(chain, "post_medias_message") as post_medias_message: ), patch.object(chain, "post_medias_message") as post_medias_message:
handled = chain.handle_text_interaction( handled = chain.handle_text_interaction(
@@ -119,7 +119,7 @@ class TestMediaInteraction(unittest.TestCase):
self.assertEqual(len(request.items), 2) self.assertEqual(len(request.items), 2)
def test_media_interaction_legacy_page_callback_updates_existing_request(self): def test_media_interaction_legacy_page_callback_updates_existing_request(self):
chain = MediaInteractionChain() chain = MediaChain()
request = media_interaction_manager.create_or_replace( request = media_interaction_manager.create_or_replace(
user_id="10001", user_id="10001",
channel=MessageChannel.Telegram, channel=MessageChannel.Telegram,