Files
MoviePilot/app/application/messaging/interaction/agent.py

167 lines
5.5 KiB
Python

"""Agent 按钮选择交互契约与进程内待处理请求管理。"""
import re
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from threading import Lock
from typing import Any, Optional
# Agent 选择按钮回调前缀,新旧两种格式都必须继续兼容。
AGENT_CHOICE_PREFIX = "agent_interaction:choice:"
LEGACY_AGENT_CHOICE_PREFIX = "agent_choice:"
def is_agent_learning_command(text: str | None) -> bool:
"""仅识别专用学习命令前缀;把它们交给 Agent 用户控制入口而非传统管理员命令。"""
return bool(re.match(r'^/(?:memory(?:\s|$)|skills\s+(?:adopt|pin|unpin|restore)(?:\s|$))', str(text or '').strip()))
def build_agent_choice_callback(request_id: str, option_index: int) -> str:
"""构造 Agent 选择按钮回调数据。"""
return f"{AGENT_CHOICE_PREFIX}{request_id}:{option_index}"
def parse_agent_choice_callback(
callback_data: str,
) -> Optional[tuple[str, int]]:
"""解析新旧两种 Agent 选择回调,格式无效时返回 None。"""
if callback_data.startswith(AGENT_CHOICE_PREFIX):
try:
_, _, request_id, option_index = callback_data.split(":", 3)
except ValueError:
return None
elif callback_data.startswith(LEGACY_AGENT_CHOICE_PREFIX):
# 兼容旧格式,避免已发送的按钮失效。
try:
_, request_id, option_index = callback_data.split(":", 2)
except ValueError:
return None
else:
return None
if not request_id or not option_index.isdigit():
return None
return request_id, int(option_index)
@dataclass(frozen=True)
class AgentInteractionOption:
"""Agent 交互选项。"""
label: str
value: str
description: Optional[str] = None
@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)
def build_agent_choice_button_rows(
request: PendingAgentInteraction,
) -> tuple[list[dict[str, Any]], list[list[dict[str, Any]]]]:
"""根据待选择请求构造 WebAgent 和消息渠道共用的按钮。"""
buttons = [
{
"label": option.label,
"callback_data": build_agent_choice_callback(request.request_id, index),
"description": option.description or option.label,
}
for index, option in enumerate(request.options, start=1)
]
button_rows = [[button] for button in buttons]
return buttons, button_rows
class AgentInteractionManager:
"""管理 Agent 发起的客户端交互请求。"""
_ttl = timedelta(hours=24)
def __init__(self) -> None:
"""初始化待处理的 Agent 交互请求表。"""
self._pending_interactions: dict[str, PendingAgentInteraction] = {}
self._lock = Lock()
def _cleanup_locked(self) -> None:
"""在持锁状态下移除过期 Agent 交互。"""
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()