mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
refactor(application): 拆分 Agent 选择交互
This commit is contained in:
@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.agent.tools.base import MoviePilotTool
|
||||
from app.agent.tools.tags import ToolTag
|
||||
from app.application.messaging.agent import (
|
||||
from app.application.messaging.agent_interaction import (
|
||||
AgentInteractionOption,
|
||||
agent_interaction_manager,
|
||||
build_agent_choice_callback,
|
||||
|
||||
@@ -7,7 +7,7 @@ import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from queue import Empty, Queue
|
||||
from threading import Lock
|
||||
@@ -16,12 +16,9 @@ from typing import (
|
||||
AsyncIterator,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Dict,
|
||||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
Protocol,
|
||||
Tuple,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
@@ -40,6 +37,17 @@ from app.application.messaging.chat import (
|
||||
get_configured_agent_chat_persistence,
|
||||
get_configured_agent_chat_service,
|
||||
)
|
||||
from app.application.messaging.agent_interaction import (
|
||||
AGENT_CHOICE_PREFIX,
|
||||
LEGACY_AGENT_CHOICE_PREFIX,
|
||||
AgentInteractionManager,
|
||||
AgentInteractionOption,
|
||||
PendingAgentInteraction,
|
||||
agent_interaction_manager,
|
||||
build_agent_choice_button_rows,
|
||||
build_agent_choice_callback,
|
||||
parse_agent_choice_callback,
|
||||
)
|
||||
from app.application.messaging.router import has_pending_interaction
|
||||
from app.runtime.execution import run_in_threadpool
|
||||
from app.runtime.log import logger
|
||||
@@ -48,170 +56,6 @@ from app.schemas.message import Message
|
||||
from app.schemas.types import NotificationChannel
|
||||
|
||||
__all__ = ["dispatch_command"]
|
||||
# Agent 选择按钮回调前缀(新旧两种格式都必须继续兼容)
|
||||
AGENT_CHOICE_PREFIX = "agent_interaction:choice:"
|
||||
LEGACY_AGENT_CHOICE_PREFIX = "agent_choice:"
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
class AgentInteractionManager:
|
||||
"""
|
||||
管理 Agent 发起的客户端交互请求。
|
||||
"""
|
||||
|
||||
_ttl = timedelta(hours=24)
|
||||
|
||||
def __init__(self):
|
||||
"""初始化待处理的 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()
|
||||
|
||||
|
||||
_WEB_AGENT_EDIT_QUEUES: dict[str, list[Queue[dict[str, Any]]]] = {}
|
||||
|
||||
160
app/application/messaging/agent_interaction.py
Normal file
160
app/application/messaging/agent_interaction.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Agent 按钮选择交互契约与进程内待处理请求管理。"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from threading import Lock
|
||||
from typing import Any, Optional
|
||||
import uuid
|
||||
|
||||
# Agent 选择按钮回调前缀,新旧两种格式都必须继续兼容。
|
||||
AGENT_CHOICE_PREFIX = "agent_interaction:choice:"
|
||||
LEGACY_AGENT_CHOICE_PREFIX = "agent_choice:"
|
||||
|
||||
|
||||
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()
|
||||
@@ -19,7 +19,10 @@ from app.application.agent import (
|
||||
transcribe_audio,
|
||||
)
|
||||
from app.application.messaging import router as interaction_router
|
||||
from app.application.messaging.agent import agent_interaction_manager, parse_agent_choice_callback
|
||||
from app.application.messaging.agent_interaction import (
|
||||
agent_interaction_manager,
|
||||
parse_agent_choice_callback,
|
||||
)
|
||||
from app.application.messaging.interaction import InteractionContext, InteractionDispatch
|
||||
from app.application.messaging.media import media_interaction_manager
|
||||
from app.application.messaging.plugin import PluginInputInteractionHandler
|
||||
|
||||
41
tests/test_application_messaging_agent_architecture.py
Normal file
41
tests/test_application_messaging_agent_architecture.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Agent 消息应用层职责拆分门禁。"""
|
||||
|
||||
import ast
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parents[1]
|
||||
MESSAGING_ROOT = PROJECT_ROOT / "app" / "application" / "messaging"
|
||||
AGENT_FACADE_PATH = MESSAGING_ROOT / "agent.py"
|
||||
AGENT_INTERACTION_PATH = MESSAGING_ROOT / "agent_interaction.py"
|
||||
|
||||
|
||||
def _top_level_owners(path: Path) -> set[str]:
|
||||
"""返回模块顶层定义的类和函数名称。"""
|
||||
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
|
||||
return {
|
||||
node.name
|
||||
for node in tree.body
|
||||
if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
}
|
||||
|
||||
|
||||
def test_agent_interaction_has_one_canonical_owner() -> None:
|
||||
"""Agent 选择交互只能在独立模块实现,旧入口保留对象级兼容导出。"""
|
||||
names = {
|
||||
"AgentInteractionManager",
|
||||
"AgentInteractionOption",
|
||||
"PendingAgentInteraction",
|
||||
"build_agent_choice_button_rows",
|
||||
"build_agent_choice_callback",
|
||||
"parse_agent_choice_callback",
|
||||
}
|
||||
facade = importlib.import_module("app.application.messaging.agent")
|
||||
interaction = importlib.import_module(
|
||||
"app.application.messaging.agent_interaction"
|
||||
)
|
||||
|
||||
assert names <= _top_level_owners(AGENT_INTERACTION_PATH)
|
||||
assert names.isdisjoint(_top_level_owners(AGENT_FACADE_PATH))
|
||||
for name in names | {"agent_interaction_manager"}:
|
||||
assert getattr(facade, name) is getattr(interaction, name)
|
||||
Reference in New Issue
Block a user