import asyncio import concurrent.futures import copy import hmac import inspect import json import os import re import threading import time import uuid from hashlib import md5 from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from urllib.parse import urlparse, urlencode from urllib.request import Request as UrlRequest, urlopen from fastapi import Request try: from apscheduler.triggers.cron import CronTrigger except Exception: CronTrigger = None try: from app.core.config import settings except Exception: settings = None try: from app.log import logger except Exception: class _FallbackLogger: @staticmethod def info(message: str) -> None: print(message) @staticmethod def warning(message: str) -> None: print(message) @staticmethod def error(message: str) -> None: print(message) logger = _FallbackLogger() try: from app.utils.crypto import CryptoJsUtils except Exception: CryptoJsUtils = None try: from app.agent.tools.manager import moviepilot_tool_manager except Exception: moviepilot_tool_manager = None try: from app.core.plugin import PluginManager except Exception: PluginManager = None try: from app import schemas as app_schemas except Exception: app_schemas = None from app.plugins import _PluginBase from .services.hdhive_openapi import HDHiveOpenApiService from .services.p115_transfer import P115TransferService from .services.quark_transfer import QuarkTransferService from .feishu_channel import FeishuChannel from .agenttool import ( AssistantCapabilitiesTool, AssistantExecuteActionTool, AssistantExecuteActionsTool, AssistantExecutePlanTool, AssistantHistoryTool, AssistantHelpTool, AssistantMaintainTool, AssistantPickTool, AssistantPlansClearTool, AssistantPlansTool, AssistantPulseTool, AssistantReadinessTool, AssistantRecoverTool, AssistantRequestTemplatesTool, AssistantRouteTool, AssistantSessionClearTool, AssistantSessionsClearTool, AssistantSessionsTool, AssistantSessionStateTool, AssistantSelfcheckTool, AssistantStartupTool, AssistantToolboxTool, AssistantWorkflowTool, FeishuChannelHealthTool, HDHiveSearchSessionTool, HDHiveSessionPickTool, P115CancelPendingTool, P115PendingTool, P115QRCodeCheckTool, P115QRCodeStartTool, P115ResumePendingTool, P115StatusTool, ShareRouteTool, ) class _JsonRequestShim: def __init__(self, request: Request, body: Dict[str, Any], method: str = "POST") -> None: self.method = str(method or "POST").upper() self.headers = request.headers self.query_params = request.query_params self._body = body async def json(self) -> Dict[str, Any]: return self._body class _RequestContextShim: def __init__(self, headers: Optional[Dict[str, Any]] = None, query_params: Optional[Dict[str, Any]] = None) -> None: default_headers = dict(headers or {}) if settings is not None and not default_headers.get("Authorization"): token = str(getattr(settings, "API_TOKEN", "") or "").strip() if token: default_headers["Authorization"] = f"Bearer {token}" self.headers = default_headers self.query_params = query_params or {} class AgentResourceOfficer(_PluginBase): plugin_name = "Agent影视助手" plugin_desc = "龙虾agent稳定控制 MP:飞书入口、盘搜/影巢搜索、115/夸克转存、智能评分推荐。" plugin_icon = "https://raw.githubusercontent.com/liuyuexi1987/MoviePilot-Plugins/main/icons/agentresourceofficer.png" plugin_version = "0.2.71" request_templates_schema_version = "request_templates.v1" plugin_author = "liuyuexi1987" plugin_level = 1 author_url = "https://github.com/liuyuexi1987" plugin_config_prefix = "agentresourceofficer_" plugin_order = 40 auth_level = 1 _enabled = False _notify = True _debug = False _quark_cookie = "" _quark_default_path = "/飞书" _quark_timeout = 30 _quark_auto_import_cookiecloud = True _pansou_enabled = True _pansou_base_url = "http://127.0.0.1:805" _pansou_timeout = 20 _hdhive_api_key = "" _hdhive_base_url = "https://hdhive.com" _hdhive_timeout = 30 _hdhive_default_path = "/待整理" _assistant_result_page_size = 10 _hdhive_candidate_page_size = 10 _hdhive_resource_enabled = True _hdhive_max_unlock_points = 20 _hdhive_checkin_enabled = False _hdhive_checkin_gambler_mode = False _hdhive_checkin_once = False _hdhive_checkin_cron = "0 8 * * *" _hdhive_checkin_cookie = "" _hdhive_checkin_auto_login = True _hdhive_checkin_username = "" _hdhive_checkin_password = "" _p115_default_path = "/待整理" _p115_client_type = "alipaymini" _p115_cookie = "" _p115_prefer_direct = True _mp_pt_enabled = True _assistant_default_pt_min_seeders = 3 _assistant_default_auto_ingest_enabled = False _assistant_default_auto_ingest_score_threshold = 90 _assistant_default_confirm_score_threshold = 70 _feishu_enabled = False _feishu_allow_all = False _feishu_reply_enabled = True _feishu_reply_receive_id_type = "chat_id" _feishu_app_id = "" _feishu_app_secret = "" _feishu_verification_token = "" _feishu_allowed_chat_ids: List[str] = [] _feishu_allowed_user_ids: List[str] = [] _feishu_command_whitelist: List[str] = [] _feishu_command_aliases = "" _feishu_command_mode = "resource_officer" _quark_service: Optional[QuarkTransferService] = None _hdhive_service: Optional[HDHiveOpenApiService] = None _p115_service: Optional[P115TransferService] = None _feishu_channel: Optional[FeishuChannel] = None _session_cache: Dict[str, Dict[str, Any]] = {} _session_lock = threading.RLock() _agent_tools_reloaded = False _candidate_actor_cache: Dict[str, List[str]] = {} _candidate_actor_cache_lock = threading.Lock() _session_store_key = "assistant_session_cache" _session_retention_seconds = 7 * 24 * 60 * 60 _execution_history_store_key = "assistant_execution_history" _execution_history_limit = 100 _execution_history: List[Dict[str, Any]] = [] _workflow_plan_store_key = "assistant_workflow_plans" _workflow_plan_limit = 50 _workflow_plans: Dict[str, Dict[str, Any]] = {} _assistant_preferences_store_key = "assistant_preferences" _assistant_preferences_limit = 100 _assistant_preferences: Dict[str, Dict[str, Any]] = {} _hdhive_checkin_history_store_key = "hdhive_checkin_history" _hdhive_checkin_history_limit = 60 _hdhive_checkin_history: List[Dict[str, Any]] = [] _agent_tools_reload_lock = threading.Lock() _agent_tools_reload_version = "" _agent_tools_reload_at = 0.0 @staticmethod def _extract_first_url(text: str) -> str: match = re.search(r"https?://[^\s<>\"']+", str(text or "")) return match.group(0).rstrip(".,);]") if match else "" @staticmethod def _format_pansou_datetime(value: Any) -> str: text = str(value or "").strip() if not text or text.startswith("0001-01-01"): return "" text = text.replace("T", " ").replace("Z", "") if len(text) >= 10: text = text[:10].replace("-", "/") return text.strip() @staticmethod def _format_pansou_display_datetime(value: Any) -> str: text = str(value or "").strip() if not text: return "" if text.startswith("🕒"): return text match = re.match(r"^\d{4}[/-](\d{2}[/-]\d{2})(?:\b|$)", text) if match: text = match.group(1).replace("-", "/") return f"🕒{text}" @staticmethod def _keyword_prefers_mp_pt_search(text: str) -> bool: raw = str(text or "").strip() if not raw: return False compact = re.sub(r"\s+", "", raw).lower() if re.search(r"\b(?:s\d{1,2}e\d{1,3}|e\d{1,3}|ep\d{1,3})\b", raw, flags=re.IGNORECASE): return True if re.search(r"第\s*\d+\s*(?:季|集|话|回|期)", raw): return True if re.search(r"更新(?:至)?\s*(?:ep|e)?\s*0*\d{1,3}", raw, flags=re.IGNORECASE): return True if re.search(r"更(?:至)?\s*0*\d{1,3}\s*集", raw, flags=re.IGNORECASE): return True return any(token in compact for token in ["全集", "全季", "完结", "短剧", "剧集", "season"]) @staticmethod def _normalize_search_prefix(text: str) -> Tuple[str, str]: raw = str(text or "").strip() raw = re.sub(r"[\u00a0\u1680\u180e\u2000-\u200b\u202f\u205f\u3000]+", " ", raw) raw = re.sub(r"\s+", " ", raw).strip() for pattern in ( r"^(?:mp|pt)\s*搜索\s*(.*)$", r"^原生\s*搜索\s*(.*)$", ): match = re.match(pattern, raw, flags=re.IGNORECASE) if match: return "mp", match.group(1).strip() mappings = [ ("盘搜更新检查", "update_pansou"), ("盘搜检查", "update_pansou"), ("影巢更新检查", "update_hdhive"), ("影巢检查", "update_hdhive"), ("更新检查", "update"), ("更新搜索", "update"), ("查更新", "update"), ("更新", "update"), ("资源决策", "smart_decision"), ("智能决策", "smart_decision"), ("智能执行", "smart_execute"), ("智能搜执行", "smart_execute"), ("智能计划", "smart_plan"), ("智能搜计划", "smart_plan"), ("智能搜索", "smart"), ("智能搜", "smart"), ("MP搜索", "mp"), ("MP 搜索", "mp"), ("PT搜索", "mp"), ("PT 搜索", "mp"), ("pt搜索", "mp"), ("pt 搜索", "mp"), ("原生搜索", "mp"), ("原生 搜索", "mp"), ("搜索资源", "pansou"), ("找资源", "pansou"), ("搜索", "search"), ("找", "search"), ("1搜索", "pansou"), ("2搜索", "hdhive"), ("影巢搜索", "hdhive"), ("yc", "hdhive"), ("2", "hdhive"), ("盘搜搜索", "pansou"), ("盘搜", "pansou"), ("ps", "pansou"), ("1", "pansou"), ] for prefix, mode in mappings: if raw == prefix: return mode, "" if raw.startswith(prefix + " "): return mode, raw[len(prefix):].strip() if raw.startswith(prefix): remain = raw[len(prefix):].strip() if remain: return mode, remain if raw.startswith("检查 "): remain = raw[len("检查"):].strip() if remain: return "update", remain return "", raw @staticmethod def _is_generic_search_command(text: str) -> bool: raw = str(text or "").strip() raw = re.sub(r"[\u00a0\u1680\u180e\u2000-\u200b\u202f\u205f\u3000]+", " ", raw) raw = re.sub(r"\s+", " ", raw).strip().lower() return any( raw == prefix or raw.startswith(prefix + " ") for prefix in ["搜索资源", "找资源", "搜索", "找"] ) @staticmethod def _is_explicit_pansou_command(text: str) -> bool: raw = str(text or "").strip() raw = re.sub(r"[\u00a0\u1680\u180e\u2000-\u200b\u202f\u205f\u3000]+", " ", raw) raw = re.sub(r"\s+", " ", raw).strip().lower() return any( raw == prefix or raw.startswith(prefix + " ") for prefix in ["盘搜搜索", "盘搜", "ps", "1搜索", "1"] ) @staticmethod def _extract_smart_decision_intent(text: str) -> Tuple[str, str]: raw = str(text or "").strip() if not raw: return "", "" patterns = [ ("execute_now", [ r"(?:直接|立即|马上|立刻)?确认执行$", r"(?:直接|立即|马上|立刻)?确认$", r"(?:直接|立即|马上|立刻)?执行$", r"(?:直接|立即|马上|立刻)?下载$", r"(?:直接|立即|马上|立刻)?转存$", r"(?:直接|立即|马上|立刻)?解锁$", r"(?:直接|立即|马上|立刻)?处理$", ]), ("make_plan", [ r"(?:先)?生成计划$", r"(?:先)?做计划$", r"(?:先)?出计划$", r"(?:先)?计划$", r"(?:先)?待确认计划$", ]), ("show_detail", [ r"详情$", r"(?:先)?看详情$", r"(?:先)?看一下$", r"(?:先)?看看$", r"(?:只)?看推荐$", r"(?:只)?看结果$", r"(?:先)?推荐一下$", ]), ] compact = re.sub(r"\s+", "", raw) for intent, tokens in patterns: for token in tokens: if re.search(token, compact, flags=re.IGNORECASE): cleaned = re.sub(token, "", compact, flags=re.IGNORECASE).strip() return cleaned or raw, intent return raw, "" @classmethod def _extract_mp_result_filter_intent(cls, text: str) -> Tuple[str, str]: raw = str(text or "").strip() if not raw: return "", "" episode_patterns = [ r"(?:给我|帮我|只看|看看|看一下|要|下)?\s*s\d{1,2}\s*e0*(\d{1,3})\s*(?:集|资源|结果)?", r"(?:给我|帮我|只看|看看|看一下|要|下)?\s*e0*(\d{1,3})\s*(?:集|资源|结果)?", r"(?:给我|帮我|只看|看看|看一下|要|下)?\s*第\s*(\d{1,3})\s*集\s*(?:资源|结果)?", r"(?:给我|帮我|只看|看看|看一下|要|下)?\s*第\s*([零〇一二两三四五六七八九十]{1,4})\s*集\s*(?:资源|结果)?", ] for pattern in episode_patterns: match = re.search(pattern, raw, flags=re.IGNORECASE) if not match: continue raw_episode = match.group(1) if str(raw_episode).isdigit(): episode = int(raw_episode) else: episode = cls._parse_simple_cjk_number(str(raw_episode)) or 0 if episode <= 0: continue cleaned = (raw[:match.start()] + " " + raw[match.end():]).strip(" ::,,。") cleaned = re.sub(r"\s+", " ", cleaned).strip() cleaned = re.sub(r"(?:的|第)$", "", cleaned).strip() return cleaned or raw, f"episode:{episode}" patterns = [ r"(?:给我|帮我|只看|看看|看一下)?最新(?:一)?集(?:资源|结果)?", r"(?:给我|帮我|只看|看看|看一下)?最新(?:两|2)集(?:资源|结果)?", r"只(?:要|看)(?:当前)?最新", ] cleaned = raw matched = False for pattern in patterns: new_value = re.sub(pattern, " ", cleaned, flags=re.IGNORECASE) if new_value != cleaned: matched = True cleaned = new_value cleaned = re.sub(r"\s+", " ", cleaned).strip(" ::,,。") cleaned = re.sub(r"(?:的|第)$", "", cleaned).strip() return (cleaned or raw, "latest_episode") if matched else (raw, "") async def _assistant_smart_decision_followup_detail( self, request, *, session: str, cache_key: str, keyword: str, compact: bool = False, apikey: str = "", media_type: str = "", year: str = "", source_order: Optional[List[str]] = None, target_path: str = "", decision_profile: str = "", ): decision_result = await self._assistant_smart_resource_decision( request, keyword=keyword, session=session, cache_key=cache_key, media_type=media_type, year=year, source_order=source_order, target_path=target_path, decision_profile=decision_profile, ) state = self._load_session(cache_key) if not isinstance(state, dict) or self._clean_text(state.get("kind")) != "assistant_smart_search": return decision_result return await self.api_assistant_pick( _JsonRequestShim(request, { "session": session, "session_id": cache_key, "choice": 0, "action": "best", "path": target_path, "compact": compact, "apikey": apikey, }) ) @staticmethod def _match_command_prefix(raw: str, prefixes: List[str]) -> Optional[Tuple[str, str]]: text = str(raw or "").strip() for prefix in prefixes: if not text.startswith(prefix): continue remain = text[len(prefix):] if not remain: return prefix, "" return prefix, remain.lstrip(" ::").strip() return None @staticmethod def _normalize_mp_recommend_request(value: Any, default_source: str = "tmdb_trending") -> Tuple[str, str]: raw = str(value or "").strip() compact = re.sub(r"[\s,。?!!?,、::]+", "", raw).lower() allowed_sources = { "tmdb_trending", "tmdb_movies", "tmdb_tvs", "douban_hot", "douban_movie_hot", "douban_tv_hot", "douban_showing", "douban_movie_showing", "douban_movie_top250", "douban_tv_animation", "bangumi_calendar", } if compact in allowed_sources: if compact == "douban_showing": return "douban_movie_showing", "movie" return compact, "all" source_aliases = { "trending": ("tmdb_trending", "all"), "tmdb": ("tmdb_trending", "all"), "tmdb热门": ("tmdb_trending", "all"), "tmdb电影": ("tmdb_movies", "movie"), "tmdb剧集": ("tmdb_tvs", "tv"), "tmdb电视剧": ("tmdb_tvs", "tv"), "豆瓣": ("douban_hot", "all"), "豆瓣热门": ("douban_hot", "all"), "豆瓣电影": ("douban_movie_hot", "movie"), "豆瓣热门电影": ("douban_movie_hot", "movie"), "豆瓣热映": ("douban_movie_showing", "movie"), "豆瓣电视剧": ("douban_tv_hot", "tv"), "豆瓣剧集": ("douban_tv_hot", "tv"), "豆瓣top250": ("douban_movie_top250", "movie"), "top250": ("douban_movie_top250", "movie"), "douban_showing": ("douban_movie_showing", "movie"), "正在热映": ("douban_movie_showing", "movie"), "热映": ("douban_movie_showing", "movie"), "院线": ("douban_movie_showing", "movie"), "bangumi": ("bangumi_calendar", "tv"), "番剧": ("bangumi_calendar", "tv"), "今日番剧": ("bangumi_calendar", "tv"), "每日放送": ("bangumi_calendar", "tv"), "动画番剧": ("bangumi_calendar", "tv"), } if compact in source_aliases: return source_aliases[compact] if "top250" in compact: return "douban_movie_top250", "movie" if any(token in compact for token in ["bangumi", "番剧", "每日放送", "今日放送", "今日动画"]): return "bangumi_calendar", "tv" if any(token in compact for token in ["正在热映", "热映", "院线"]): return "douban_movie_showing", "movie" if "豆瓣" in compact: if "动画" in compact: return "douban_tv_animation", "tv" if any(token in compact for token in ["电视剧", "剧集", "剧", "tv"]): return "douban_tv_hot", "tv" if any(token in compact for token in ["电影", "movie"]): return "douban_movie_hot", "movie" return "douban_hot", "all" if "tmdb" in compact: if any(token in compact for token in ["电视剧", "剧集", "剧", "tv"]): return "tmdb_tvs", "tv" if any(token in compact for token in ["电影", "movie"]): return "tmdb_movies", "movie" return "tmdb_trending", "all" if any(token in compact for token in ["电视剧", "剧集", "剧集推荐", "热门剧", "热门电视剧"]): return "tmdb_tvs", "tv" if any(token in compact for token in ["电影", "影视电影", "热门电影"]): return "tmdb_movies", "movie" return default_source or "tmdb_trending", "all" @classmethod def _resolve_pan_path_value(cls, value: str) -> str: text = str(value or "").strip() if not text: return "" alias_map = { "分享": "/飞书", "飞书": "/飞书", "待整理": "/待整理", "最新动画": "/最新动画", } mapped = alias_map.get(text, text) return cls._normalize_path(mapped) @staticmethod def _normalize_pick_action(value: Any) -> str: text = str(value or "").strip().lower() if text in {"继续决策", "继续资源决策", "decision_continue"}: return "decision_continue" if text in {"换影巢", "切换影巢", "走影巢", "用影巢", "decision_hdhive"}: return "decision_hdhive" if text in {"换盘搜", "切换盘搜", "走盘搜", "用盘搜", "decision_pansou"}: return "decision_pansou" if text in {"换pt", "换原生", "换mp", "切换pt", "切换原生", "切换mp", "走pt", "走原生", "走mp", "decision_mp_pt"}: return "decision_mp_pt" if text in {"保守一点", "更保守", "保守模式", "decision_conservative"}: return "decision_conservative" if text in {"激进一点", "更激进", "激进模式", "decision_aggressive"}: return "decision_aggressive" if text in {"只用夸克", "只有夸克", "仅夸克", "只要夸克", "decision_only_quark"}: return "decision_only_quark" if text in {"只用115", "只有115", "仅115", "只要115", "decision_only_115"}: return "decision_only_115" if text in {"两者都可", "云盘都可", "115和夸克都可", "115夸克都可", "decision_cloud_both"}: return "decision_cloud_both" if text in {"只走pt", "只走原生", "只用pt", "只用原生", "只走mp", "只用mp", "decision_only_mp_pt"}: return "decision_only_mp_pt" if text in {"只走盘搜", "只用盘搜", "decision_only_pansou"}: return "decision_only_pansou" if text in {"只走影巢", "只用影巢", "decision_only_hdhive"}: return "decision_only_hdhive" if text in {"不用盘搜", "关闭盘搜", "禁用盘搜", "decision_disable_pansou"}: return "decision_disable_pansou" if text in {"不用影巢", "关闭影巢", "禁用影巢", "decision_disable_hdhive"}: return "decision_disable_hdhive" if text in {"不用pt", "不用原生", "关闭pt", "关闭原生", "关闭mp", "decision_disable_mp_pt"}: return "decision_disable_mp_pt" if text in {"按保存偏好", "恢复偏好", "恢复默认偏好", "清除会话偏好", "decision_reset_preferences"}: return "decision_reset_preferences" if text in {"确认执行", "确认执行吧", "就执行吧", "直接来", "执行它", "确认处理", "确认转存", "确认解锁"}: return "best_execute" if text in {"先计划", "先做计划", "先生成计划", "先出计划", "还是先计划"}: return "best_plan" if text in {"先看详情", "先看推荐", "先看结果", "看最佳", "看看最佳", "先看一下"}: return "best" if text in {"best_execute", "execute_best", "执行最佳", "最佳执行", "立即执行最佳", "直接执行最佳"}: return "best_execute" if text in {"best_plan", "plan_best", "计划最佳", "最佳计划", "计划推荐", "推荐计划", "最优计划"}: return "best_plan" if text in {"detail", "details", "review", "详情", "审查"}: return "detail" if text in {"best", "best_result", "recommend_best", "最佳", "最佳片源", "推荐片源", "推荐下载", "最优"}: return "best" if text in {"plan", "dry_run", "make_plan", "计划", "生成计划", "计划选择", "计划处理", "转存计划", "解锁计划"}: return "plan" if text in {"n", "next", "next_page", "下一页", "下页"} or text.startswith("n "): return "next_page" return "" @staticmethod def _normalize_smart_search_short_action(value: Any, *, state_kind: str = "") -> str: if str(state_kind or "").strip() != "assistant_smart_search": return "" compact = re.sub(r"\s+", "", str(value or "").strip().lower()) if compact in {"详情", "看详情", "看看", "看一下", "detail", "details"}: return "best" if compact in {"计划", "做计划", "plan"}: return "best_plan" if compact in {"确认", "执行", "确认吧", "执行吧", "确定执行", "execute", "run"}: return "best_execute" return "" @staticmethod def _is_pending_plan_confirmation_text(value: Any) -> bool: raw = str(value or "").strip() if not raw: return False compact = re.sub(r"\s+", "", raw).lower() if re.search(r"\d", compact): return False return compact in { "确认", "确认吧", "确认执行", "确认执行吧", "执行", "执行吧", "execute", "run", "确定", "确定执行", "执行下载", "确认下载", "开始下载", "下载吧", } @classmethod def _parse_pending_plan_numeric_confirmation(cls, value: Any) -> int: raw = cls._normalize_fullwidth_digits(cls._clean_text(value)) if not raw: return 0 compact = re.sub(r"[\s,。?!!?,、::;;“”\"'()()【】\[\]]+", "", raw) # Only a bare number or explicit execution wording may confirm a saved plan. # "下载1" is treated as the current PT-list download command, not a saved-plan # confirmation, so it must not match here. match = re.fullmatch(r"(?:执行|确认|执行计划|确认计划)?(\d+)", compact, flags=re.IGNORECASE) if not match: return 0 return cls._safe_int(match.group(1), 0) def _is_pending_plan_numeric_confirmation(self, value: Any, plan: Optional[Dict[str, Any]]) -> bool: index = self._parse_pending_plan_numeric_confirmation(value) if index <= 0 or not isinstance(plan, dict): return False expected_choices: List[int] = [] execute_body = plan.get("execute_body") if isinstance(plan.get("execute_body"), dict) else {} expected_choices.append(self._safe_int(execute_body.get("plan_rank"), 0)) expected_choices.append(self._safe_int(execute_body.get("choice") or execute_body.get("index"), 0)) for action in plan.get("actions") or []: if isinstance(action, dict): expected_choices.append(self._safe_int(action.get("plan_rank"), 0)) expected_choices.append(self._safe_int(action.get("choice") or action.get("index"), 0)) return index in {choice for choice in expected_choices if choice > 0} @classmethod def _normalize_ai_reingest_short_action( cls, value: Any, *, state: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: current_state = dict(state or {}) kind = cls._clean_text(current_state.get("kind")) compact = re.sub(r"\s+", "", str(value or "").strip().lower()) result: Dict[str, Any] = {} ai_session_kinds = { "assistant_ai_failed_samples", "assistant_ai_sample_worklist", "assistant_ai_sample_insights", "assistant_ai_replay", } saved_plan = dict((current_state.get("saved_plan") or {}).get("latest") or {}) saved_workflow = cls._clean_text(saved_plan.get("workflow")) has_pending_ai_replay_plan = ( bool((current_state.get("saved_plan") or {}).get("has_pending")) and saved_workflow == "ai_replay_failed_sample" ) if has_pending_ai_replay_plan and compact in {"确认", "确认吧", "执行", "执行吧", "确定", "确定执行", "run", "execute"}: return {"action": "execute_plan"} if kind not in ai_session_kinds: return {} keyword = cls._clean_text(current_state.get("keyword")) if keyword and compact in {"诊断", "本地诊断", "重新诊断", "看诊断"}: return {"action": "mp_local_diagnose", "keyword": keyword} if keyword and compact in {"入库状态", "看入库状态", "状态"}: return {"action": "mp_ingest_status", "keyword": keyword} if compact in {"工作清单", "返回工作清单", "回工作清单"}: return {"action": "ai_sample_worklist", "keyword": keyword} if compact in {"失败样本", "返回失败样本", "回失败样本"}: return {"action": "ai_failed_samples", "keyword": keyword} if compact in {"样本洞察", "洞察", "返回样本洞察", "回样本洞察"}: return {"action": "ai_sample_insights", "keyword": keyword} raw = cls._clean_text(value) replay_match = re.match(r"^\s*(重放|重识别|重跑)\s*(\d+)(?:\s+(.*))?$", raw) if replay_match: result = { "action": "ai_replay_failed_sample", "sample_index": replay_match.group(2), "keyword": "", "mode": "", "remove_if_resolved": "true", } remain_text = cls._clean_text(replay_match.group(3)) if "保留样本" in remain_text or "不移除" in remain_text: result["remove_if_resolved"] = "false" return result return {} @classmethod def _normalize_mp_recommend_short_action( cls, value: Any, *, state: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: current_state = dict(state or {}) if cls._clean_text(current_state.get("kind")) != "assistant_mp_recommend": return {} raw = cls._clean_text(value) patterns = [ (r"^\s*(?:决策|资源决策|智能决策)\s*(\d+)\s*$", {"mode": "smart_decision"}), (r"^\s*(?:详情|查看详情|看详情)\s*(\d+)\s*$", {"action": "detail"}), (r"^\s*(?:计划|生成计划|先计划)\s*(\d+)\s*$", {"action": "plan"}), (r"^\s*(?:确认|执行|直接执行)\s*(\d+)\s*$", {"mode": "smart_execute"}), (r"^\s*(?:盘搜|ps)\s*(\d+)\s*$", {"mode": "pansou"}), (r"^\s*(?:影巢|yc)\s*(\d+)\s*$", {"mode": "hdhive"}), (r"^\s*(?:原生|mp|pt)\s*(\d+)\s*$", {"mode": "mp"}), ] for pattern, base in patterns: match = re.match(pattern, raw, flags=re.IGNORECASE) if match: return {"index": match.group(1), **base} selected_index = cls._safe_int(current_state.get("selected_index"), 0) if selected_index <= 0: items = current_state.get("items") if isinstance(current_state.get("items"), list) else [] if items: selected_index = 1 if selected_index > 0: no_index_aliases = { "详情": {"action": "detail"}, "查看详情": {"action": "detail"}, "看详情": {"action": "detail"}, "决策": {"mode": "smart_decision"}, "资源决策": {"mode": "smart_decision"}, "智能决策": {"mode": "smart_decision"}, "计划": {"action": "plan"}, "生成计划": {"action": "plan"}, "先计划": {"action": "plan"}, "确认": {"mode": "smart_execute"}, "执行": {"mode": "smart_execute"}, "直接执行": {"mode": "smart_execute"}, "盘搜": {"mode": "pansou"}, "ps": {"mode": "pansou"}, "影巢": {"mode": "hdhive"}, "yc": {"mode": "hdhive"}, "原生": {"mode": "mp"}, "mp": {"mode": "mp"}, "pt": {"mode": "mp"}, } shortcut = no_index_aliases.get(raw) if shortcut: return {"index": selected_index, **shortcut} return {} @classmethod def _normalize_mp_recommend_followup( cls, value: Any, *, state: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: current_state = dict(state or {}) if cls._clean_text(current_state.get("kind")) != "assistant_mp_recommend": return {} compact = re.sub(r"[\s,。?!!?,、::]+", "", cls._clean_text(value)).lower() followup_aliases = { "电影": ("tmdb_movies", "movie"), "热门电影": ("tmdb_movies", "movie"), "电视剧": ("tmdb_tvs", "tv"), "热门电视剧": ("tmdb_tvs", "tv"), "豆瓣": ("douban_hot", "all"), "豆瓣热门": ("douban_hot", "all"), "热映": ("douban_movie_showing", "movie"), "正在热映": ("douban_movie_showing", "movie"), "番剧": ("bangumi_calendar", "tv"), "今日番剧": ("bangumi_calendar", "tv"), "tmdb": ("tmdb_trending", "all"), "热门": ("tmdb_trending", "all"), } source_pair = followup_aliases.get(compact) if not source_pair: return {} source_name, media_type = source_pair return {"action": "mp_recommendations", "keyword": source_name, "type": media_type} @classmethod def _normalize_recommend_handoff_action( cls, value: Any, *, state: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: current_state = dict(state or {}) kind = cls._clean_text(current_state.get("kind")) if kind not in {"assistant_pansou", "assistant_mp", "assistant_hdhive"}: return {} handoff = current_state.get("recommend_handoff") if not isinstance(handoff, dict) or not handoff: return {} compact = re.sub(r"[\s,。?!!?,、::]+", "", cls._clean_text(value)).lower() if compact not in {"回推荐", "回榜单", "返回推荐", "返回榜单", "推荐", "榜单"}: switch_mode_aliases = { "盘搜": "pansou", "ps": "pansou", "影巢": "hdhive", "yc": "hdhive", "原生": "mp", "mp": "mp", } switch_mode = switch_mode_aliases.get(compact) if not switch_mode: return {} return {"action": "switch_recommend_handoff_source", "mode": switch_mode} return {"action": "return_to_recommend"} @classmethod def _normalize_recommend_handoff_short_action( cls, value: Any, *, state: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: current_state = dict(state or {}) kind = cls._clean_text(current_state.get("kind")) if kind not in {"assistant_pansou", "assistant_mp", "assistant_hdhive"}: return {} handoff = current_state.get("recommend_handoff") if not isinstance(handoff, dict) or not handoff: return {} compact = re.sub(r"[\s,。?!!?,、::]+", "", cls._clean_text(value)).lower() if compact in {"决策", "资源决策", "智能决策"}: return {"action": "return_to_smart_decision"} if kind in {"assistant_pansou", "assistant_mp"}: if compact in {"详情", "看详情", "看看", "看一下", "detail", "details"}: return {"route_action": "best"} if compact in {"计划", "做计划", "plan"}: return {"route_action": "best_plan"} if compact in {"确认", "确认吧", "确认执行", "执行", "执行吧", "确定执行", "run", "execute"}: return {"action": "confirm_recommend_handoff"} return {} @classmethod def _normalize_recommend_source_compound_action( cls, value: Any, *, state: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: current_state = dict(state or {}) kind = cls._clean_text(current_state.get("kind")) if kind not in {"assistant_mp_recommend", "assistant_pansou", "assistant_mp", "assistant_hdhive"}: return {} compact = re.sub(r"[\s,。?!!?,、::]+", "", cls._clean_text(value)).lower() source_aliases = { "盘搜": "pansou", "ps": "pansou", "原生": "mp", "mp": "mp", } action_aliases = { "详情": "best", "看详情": "best", "计划": "best_plan", "确认": "best_execute", "执行": "best_execute", } for alias, mode in source_aliases.items(): if not compact.startswith(alias): continue remain = compact[len(alias):] followup_action = action_aliases.get(remain) if not followup_action: continue return { "action": "recommend_source_compound", "mode": mode, "followup_action": followup_action, } return {} @classmethod def _normalize_mp_recommend_direct_intent(cls, value: Any) -> Dict[str, Any]: raw = cls._clean_text(value) if not raw: return {} prefix_match = cls._match_command_prefix(raw, ["智能发现", "热门发现", "热门推荐", "推荐"]) if not prefix_match: return {} _, remain = prefix_match remain = cls._clean_text(remain) if not remain: return {} suffix_aliases: List[Tuple[str, Dict[str, str]]] = [ ("资源决策", {"mode": "smart_decision"}), ("智能决策", {"mode": "smart_decision"}), ("查看详情", {"action": "detail"}), ("看详情", {"action": "detail"}), ("确认执行", {"mode": "smart_execute"}), ("直接执行", {"mode": "smart_execute"}), ("生成计划", {"action": "plan"}), ("先计划", {"action": "plan"}), ("详情", {"action": "detail"}), ("决策", {"mode": "smart_decision"}), ("计划", {"action": "plan"}), ("确认", {"mode": "smart_execute"}), ("执行", {"mode": "smart_execute"}), ("盘搜", {"mode": "pansou"}), ("影巢", {"mode": "hdhive"}), ("原生", {"mode": "mp"}), ] for suffix, action_payload in suffix_aliases: separator = f" {suffix}" if remain.endswith(separator): keyword = cls._clean_text(remain[: -len(separator)]) elif remain == suffix: keyword = "" else: continue if not keyword: return {} return {"keyword": keyword, "index": 1, **action_payload} return {} @staticmethod def _normalize_pick_mode(value: Any) -> str: text = str(value or "").strip().lower() compact = re.sub(r"\s+", "", text) if not compact: return "" if any(token in compact for token in ["smart_execute", "smartexecute", "直接执行", "确认执行", "立即执行", "确认", "执行"]): return "smart_execute" if any(token in compact for token in ["smart_plan", "smartplan", "生成计划", "先计划", "计划"]): return "smart_plan" if any(token in compact for token in ["smart_decision", "smartdecision", "智能决策", "资源决策", "决策"]): return "smart_decision" if re.search(r"(^|[^a-z])smart($|[^a-z])", text): return "smart_decision" if any(token in compact for token in ["hdhive", "影巢", "影潮", "走影巢", "用影巢"]): return "hdhive" if re.search(r"(^|[^a-z])yc($|[^a-z])", text): return "hdhive" if any(token in compact for token in ["pansou", "盘搜", "走盘搜", "用盘搜"]): return "pansou" if re.search(r"(^|[^a-z])ps($|[^a-z])", text): return "pansou" if any(token in compact for token in ["mp", "原生", "moviepilot", "站点", "pt"]): return "mp" return "" @staticmethod def _normalize_fullwidth_digits(value: Any) -> str: return str(value or "").translate(str.maketrans("0123456789", "0123456789")) @staticmethod def _parse_chinese_pick_number(value: Any) -> int: text = str(value or "").strip() if not text: return 0 text = text.replace("两", "二").replace("〇", "零") digits = { "零": 0, "一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9, } if text.isdigit(): return int(text) if any(ch not in digits and ch not in {"十", "百"} for ch in text): return 0 if len(text) == 1: return digits.get(text, 0) total = 0 remainder = text if "百" in remainder: left, _, remainder = remainder.partition("百") hundreds = 1 if not left else digits.get(left, 0) if hundreds <= 0: return 0 total += hundreds * 100 if "十" in remainder: left, _, right = remainder.partition("十") tens = 1 if not left else digits.get(left, 0) ones = 0 if not right else digits.get(right, 0) if tens <= 0 or (right and ones <= 0): return 0 total += tens * 10 + ones return total if total and len(remainder) == 1: return total + digits.get(remainder, 0) return total @classmethod def _normalize_pick_action_fragment(cls, value: Any) -> str: compact = re.sub(r"[\s的地得一下]+", "", str(value or "").strip().lower()) if not compact: return "" action = cls._normalize_pick_action(compact) if action: return action if any(token in compact for token in ["详情", "明细", "查看", "看看", "看下", "看一下"]): return "detail" if "审查" in compact: return "detail" if any(token in compact for token in ["计划", "dryrun", "makeplan"]): return "plan" return "" @classmethod def _parse_compact_pick_text(cls, value: Any) -> Tuple[int, str]: raw = cls._normalize_fullwidth_digits(value) compact = re.sub(r"[\s,。?!!?,、::;;“”\"'()()【】\[\]]+", "", raw) if not compact: return 0, "" chinese_number = r"[零〇一二两三四五六七八九十百]+" number_token = rf"(?:\d+|{chinese_number})" def number_value(token: str) -> int: return cls._safe_int(token, 0) if token.isdigit() else cls._parse_chinese_pick_number(token) # Short forms: "16详情", "十六详情", "16", "十六". match = re.fullmatch(rf"({number_token})(.*)", compact) if match: index = number_value(match.group(1)) suffix = match.group(2) action = cls._normalize_pick_action_fragment(suffix) if index > 0 and (not suffix or action): return index, action # Reversed short forms: "详情16", "详情十六", "计划16". match = re.fullmatch(rf"(.+?)({number_token})", compact) if match: action = cls._normalize_pick_action_fragment(match.group(1)) index = number_value(match.group(2)) if index > 0 and action: return index, action # Natural phrases: "我要看看 16 的详情", "帮我看第十六个详情". natural_action = cls._normalize_pick_action_fragment(compact) if natural_action: digit_match = re.search(r"\d+", compact) if digit_match: return cls._safe_int(digit_match.group(0), 0), natural_action for cn_match in re.finditer(chinese_number, compact): token = cn_match.group(0) # Avoid treating "看一下详情" as "pick 1 detail". if len(token) == 1 and token in {"一", "二", "三"}: continue index = cls._parse_chinese_pick_number(token) if index > 0: return index, natural_action return 0, "" @classmethod def _parse_pick_text(cls, value: Any) -> Tuple[int, str, str, str]: raw = cls._normalize_fullwidth_digits(cls._clean_text(value)) action = cls._normalize_pick_action(raw) if action: return 0, "", action, "" alias_pattern = r"^(?:/smart_pick|smart_pick|计划选择|计划处理|生成计划|转存计划|解锁计划|计划(?=\s*\d)|选择|选|继续|pick|plan|dry_run|make_plan)\s*" alias_match = re.match(alias_pattern, raw, flags=re.IGNORECASE) digit_match = re.match(r"^(\d+)(.*)$", raw) compact_index, compact_action = cls._parse_compact_pick_text(raw) if compact_index > 0 and not alias_match: return compact_index, "", compact_action, "" if not alias_match and not digit_match: return 0, "", "", "" if digit_match and not alias_match: suffix = cls._clean_text(digit_match.group(2)) if suffix and not ( suffix.startswith("/") or "=" in suffix or cls._normalize_pick_mode(suffix) or cls._normalize_pick_action(suffix) or suffix.lower() in {"n", "next"} ): return 0, "", "", "" text = re.sub(alias_pattern, "", raw, flags=re.IGNORECASE).strip() if alias_match and cls._normalize_pick_action(alias_match.group(0).strip()) == "plan": action = "plan" index = 0 path = "" mode = "" pick_action = action or "" match = re.search(r"\d+", text) if match: index = cls._safe_int(match.group(0), 0) elif text: compact_index, compact_action = cls._parse_compact_pick_text(text) if compact_index > 0: index = compact_index if compact_action: pick_action = compact_action for token in text.split(): if "=" not in token: if not pick_action: pick_action = cls._normalize_pick_action(token) continue key, token_value = token.split("=", 1) key = key.strip().lower() token_value = token_value.strip() if key in {"path", "dir", "目录", "位置"} and token_value: path = cls._resolve_pan_path_value(token_value) elif key in {"mode", "search_mode", "target", "方式", "来源", "渠道"} and token_value: mode = cls._normalize_pick_mode(token_value) if not mode: mode = cls._normalize_pick_mode(text) if not pick_action: suffix = re.sub(r"\d+", " ", text, count=1).strip() pick_action = cls._normalize_pick_action(suffix) return index, path, pick_action, mode def init_plugin(self, config: Optional[Dict[str, Any]] = None): config = config or {} self._enabled = bool(config.get("enabled", False)) self._notify = bool(config.get("notify", True)) self._debug = bool(config.get("debug", False)) self._quark_cookie = self._clean_text(config.get("quark_cookie")) self._quark_default_path = self._normalize_path(config.get("quark_default_path") or "/飞书") self._quark_timeout = self._safe_int(config.get("quark_timeout"), 30) self._quark_auto_import_cookiecloud = bool(config.get("quark_auto_import_cookiecloud", True)) self._pansou_enabled = bool(config.get("pansou_enabled", True)) self._pansou_base_url = self._clean_text(config.get("pansou_base_url") or "http://127.0.0.1:805").rstrip("/") self._pansou_timeout = max(3, min(120, self._safe_int(config.get("pansou_timeout"), 20))) self._hdhive_api_key = self._clean_text(config.get("hdhive_api_key")) self._hdhive_base_url = self._clean_text(config.get("hdhive_base_url") or "https://hdhive.com").rstrip("/") self._hdhive_timeout = self._safe_int(config.get("hdhive_timeout"), 30) self._hdhive_default_path = self._normalize_path(config.get("hdhive_default_path") or "/待整理") self._hdhive_candidate_page_size = max(5, min(10, self._safe_int(config.get("hdhive_candidate_page_size"), self._assistant_result_page_size))) self._hdhive_resource_enabled = bool(config.get("hdhive_resource_enabled", True)) self._hdhive_max_unlock_points = max(0, self._safe_int(config.get("hdhive_max_unlock_points"), 20)) self._hdhive_checkin_enabled = bool(config.get("hdhive_checkin_enabled", False)) self._hdhive_checkin_gambler_mode = bool(config.get("hdhive_checkin_gambler_mode", False)) self._hdhive_checkin_once = bool(config.get("hdhive_checkin_once", False)) self._hdhive_checkin_cron = self._clean_text(config.get("hdhive_checkin_cron") or "0 8 * * *") self._hdhive_checkin_cookie = self._clean_text(config.get("hdhive_checkin_cookie")) self._hdhive_checkin_auto_login = bool(config.get("hdhive_checkin_auto_login", True)) self._hdhive_checkin_username = self._clean_text(config.get("hdhive_checkin_username")) self._hdhive_checkin_password = self._clean_text(config.get("hdhive_checkin_password")) self._p115_default_path = self._normalize_path(config.get("p115_default_path") or "/待整理") self._p115_client_type = P115TransferService.normalize_qrcode_client_type(config.get("p115_client_type")) self._p115_cookie = self._clean_text(config.get("p115_cookie")) self._p115_prefer_direct = bool(config.get("p115_prefer_direct", True)) self._mp_pt_enabled = bool(config.get("mp_pt_enabled", True)) self._mp_download_save_path = self._clean_text(config.get("mp_download_save_path")) self._assistant_default_pt_min_seeders = max(0, self._safe_int(config.get("assistant_default_pt_min_seeders"), 3)) self._assistant_default_auto_ingest_enabled = bool(config.get("assistant_default_auto_ingest_enabled", False)) self._assistant_default_auto_ingest_score_threshold = max(1, min(100, self._safe_int(config.get("assistant_default_auto_ingest_score_threshold"), 90))) self._assistant_default_confirm_score_threshold = max(1, min(100, self._safe_int(config.get("assistant_default_confirm_score_threshold"), 70))) self._feishu_enabled = bool(config.get("feishu_enabled", False)) self._feishu_allow_all = bool(config.get("feishu_allow_all", False)) self._feishu_reply_enabled = bool(config.get("feishu_reply_enabled", True)) self._feishu_reply_receive_id_type = self._clean_text(config.get("feishu_reply_receive_id_type") or "chat_id") self._feishu_app_id = self._clean_text(config.get("feishu_app_id")) self._feishu_app_secret = self._clean_text(config.get("feishu_app_secret")) self._feishu_verification_token = self._clean_text(config.get("feishu_verification_token")) self._feishu_allowed_chat_ids = FeishuChannel.split_lines(config.get("feishu_allowed_chat_ids")) self._feishu_allowed_user_ids = FeishuChannel.split_lines(config.get("feishu_allowed_user_ids")) self._feishu_command_whitelist = FeishuChannel.merge_command_whitelist( FeishuChannel.split_commands(config.get("feishu_command_whitelist")) ) self._feishu_command_aliases = FeishuChannel.merge_command_aliases( self._clean_text(config.get("feishu_command_aliases")) ) self._feishu_command_mode = self._clean_text(config.get("feishu_command_mode") or "resource_officer") self._quark_service = QuarkTransferService( cookie=self._quark_cookie, timeout=self._quark_timeout, default_target_path=self._quark_default_path, auto_import_cookiecloud=self._quark_auto_import_cookiecloud, cookie_refresh_callback=self._refresh_quark_cookie_from_cookiecloud, ) self._hdhive_service = HDHiveOpenApiService( api_key=self._hdhive_api_key, base_url=self._hdhive_base_url, timeout=self._hdhive_timeout, ) self._p115_service = P115TransferService( default_target_path=self._p115_default_path, cookie=self._p115_cookie, prefer_direct=self._p115_prefer_direct, ) self._restore_persisted_sessions() self._restore_execution_history() self._restore_hdhive_checkin_history() self._restore_workflow_plans() self._restore_assistant_preferences() self._agent_tools_reloaded = False self._ensure_feishu_channel().configure(self._build_config()) if self._enabled and self._feishu_enabled: self._feishu_channel.start() elif self._feishu_channel is not None: self._feishu_channel.stop() self._maybe_run_hdhive_checkin_once() def get_state(self) -> bool: if self._enabled: self._maybe_reload_agent_tools_once() return self._enabled def get_agent_tools(self) -> List[type]: return [ AssistantCapabilitiesTool, AssistantExecuteActionTool, AssistantExecuteActionsTool, AssistantExecutePlanTool, AssistantPlansTool, AssistantPlansClearTool, AssistantRecoverTool, AssistantPulseTool, AssistantStartupTool, AssistantMaintainTool, AssistantToolboxTool, AssistantRequestTemplatesTool, AssistantSelfcheckTool, AssistantReadinessTool, FeishuChannelHealthTool, AssistantHistoryTool, AssistantHelpTool, AssistantRouteTool, AssistantPickTool, AssistantWorkflowTool, AssistantSessionsTool, AssistantSessionStateTool, AssistantSessionClearTool, AssistantSessionsClearTool, HDHiveSearchSessionTool, HDHiveSessionPickTool, ShareRouteTool, P115QRCodeStartTool, P115QRCodeCheckTool, P115StatusTool, P115PendingTool, P115ResumePendingTool, P115CancelPendingTool, ] @staticmethod def _reload_agent_tools() -> None: if moviepilot_tool_manager is None: return try: moviepilot_tool_manager._load_tools() except Exception: return def _maybe_reload_agent_tools_once(self) -> None: if moviepilot_tool_manager is None: return now = time.time() with self.__class__._agent_tools_reload_lock: if ( self.__class__._agent_tools_reload_version == self.plugin_version and now - self.__class__._agent_tools_reload_at < 600 ): return # Mark before reloading, because tool loading can query plugin states recursively. self.__class__._agent_tools_reload_version = self.plugin_version self.__class__._agent_tools_reload_at = now self._reload_agent_tools() @staticmethod def _clean_text(value: Any) -> str: if value is None: return "" return str(value).strip() @staticmethod def _safe_int(value: Any, default: int) -> int: try: return int(value) except Exception: return default @staticmethod def _parse_optional_bool(value: Any) -> Optional[bool]: if value is None: return None if isinstance(value, bool): return value text = str(value).strip().lower() if text in {"1", "true", "yes", "y", "on"}: return True if text in {"0", "false", "no", "n", "off"}: return False return None @classmethod def _parse_bool_value(cls, value: Any, default: bool = False) -> bool: parsed = cls._parse_optional_bool(value) return bool(default) if parsed is None else bool(parsed) @staticmethod def _normalize_path(value: Any) -> str: return QuarkTransferService.normalize_path(value) @staticmethod def _friendly_hdhive_error(message: str, capability: str) -> str: text = str(message or "").strip() lowered = text.lower() if "premium" in lowered or "仅对 premium 用户开放" in text: if capability == "checkin": return "影巢 OpenAPI 签到当前需要 Premium 用户;普通用户可配置网页 Cookie 或账号密码启用网页签到兜底。" return f"影巢 OpenAPI 的{capability}接口当前需要 Premium 用户。" return text or f"影巢 {capability} 接口调用失败" @staticmethod def _is_hdhive_premium_limited(message: str) -> bool: text = str(message or "").strip() lowered = text.lower() return "premium" in lowered or "仅对 premium 用户开放" in text @staticmethod def _read_json_file(path: Path) -> Optional[Dict[str, Any]]: try: data = json.loads(path.read_text(encoding="utf-8")) except Exception: return None return data if isinstance(data, dict) else None @classmethod def _hdhive_daily_sign_config_paths(cls) -> List[Path]: return [ Path("/config/plugins/hdhivedailysign.json"), Path("/Applications/Dockge/moviepilotv2/config/plugins/hdhivedailysign.json"), ] @classmethod def _hdhive_daily_sign_user_info_paths(cls) -> List[Path]: return [ Path("/config/logs/plugins/hdhivedailysign_user_info.json"), Path("/Applications/Dockge/moviepilotv2/config/logs/plugins/hdhivedailysign_user_info.json"), ] @classmethod def _load_hdhive_daily_sign_config(cls) -> Dict[str, Any]: for path in cls._hdhive_daily_sign_config_paths(): if not path.exists(): continue data = cls._read_json_file(path) if data: return data return {} @classmethod def _load_hdhive_daily_sign_user_info(cls) -> Dict[str, Any]: for path in cls._hdhive_daily_sign_user_info_paths(): if not path.exists(): continue data = cls._read_json_file(path) if data: return data return {} @classmethod def _build_hdhive_account_snapshot(cls, snapshot: Dict[str, Any]) -> Dict[str, Any]: if not isinstance(snapshot, dict) or not snapshot: return {} return { "id": snapshot.get("id"), "nickname": snapshot.get("nickname"), "username": snapshot.get("nickname"), "avatar_url": snapshot.get("avatar_url"), "created_at": snapshot.get("created_at"), "is_vip": False, "source": "hdhivedailysign_snapshot", "user_meta": { "points": snapshot.get("points"), "signin_days_total": snapshot.get("signin_days_total"), }, "warnings_nums": snapshot.get("warnings_nums"), } @classmethod def _extract_hdhive_account_fields(cls, payload: Dict[str, Any]) -> Dict[str, Any]: data = payload if isinstance(payload, dict) else {} meta = data.get("user_meta") if isinstance(data.get("user_meta"), dict) else {} return { "nickname": data.get("nickname") or data.get("username") or "—", "points": meta.get("points", data.get("points", "—")), "signin_days_total": meta.get("signin_days_total", data.get("signin_days_total", "—")), "is_vip": bool(data.get("is_vip")), } def _get_hdhive_fallback_cookie(self) -> str: own_cookie = self._clean_text(self._hdhive_checkin_cookie) if own_cookie: return own_cookie config = self._load_hdhive_daily_sign_config() return self._clean_text(config.get("cookie")) def _refresh_hdhive_checkin_cookie(self) -> Tuple[bool, str, str]: if not self._hdhive_checkin_auto_login: return False, "", "未启用影巢自动登录刷新 Cookie" service = self._ensure_hdhive_service() # Playwright sync API cannot run inside MoviePilot's asyncio loop; keep login isolated. with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: future = executor.submit( service.login_for_cookie, username=self._hdhive_checkin_username, password=self._hdhive_checkin_password, ) try: login_ok, cookie_string, login_message = future.result(timeout=max(60, self._hdhive_timeout * 4)) except Exception as exc: return False, "", f"影巢自动登录超时或异常: {exc}" if not login_ok or not cookie_string: return False, "", login_message or "影巢自动登录失败" self._hdhive_checkin_cookie = cookie_string try: self.update_config(self._build_config()) except Exception as exc: logger.warning(f"[Agent影视助手] 影巢自动登录已获取 Cookie,但保存配置失败:{exc}") return True, cookie_string, login_message or "影巢自动登录成功" def _run_hdhive_checkin(self, *, is_gambler: Optional[bool] = None, trigger: str = "Agent影视助手") -> Dict[str, Any]: if not self._hdhive_checkin_enabled: return self._hdhive_checkin_disabled_response() service = self._ensure_hdhive_service() final_gambler_mode = self._hdhive_checkin_gambler_mode if is_gambler is None else bool(is_gambler) checkin_ok, result, checkin_message = service.perform_checkin( is_gambler=final_gambler_mode, trigger=trigger, ) if checkin_ok: final_result = {"success": True, "message": result.get("message") or "success", "data": result} self._record_hdhive_checkin_history(trigger=trigger, is_gambler=final_gambler_mode, result=final_result) return final_result raw_message = result.get("message") or checkin_message checkin_status_code = self._safe_int(result.get("status_code"), 0) if isinstance(result, dict) else 0 should_try_web_fallback = ( self._is_hdhive_premium_limited(raw_message) or checkin_status_code in (404, 405) or "405 not allowed" in self._clean_text(raw_message).lower() or " None: if not self._enabled or not self._hdhive_checkin_once: return self._hdhive_checkin_once = False try: self.update_config(self._build_config({"hdhive_checkin_once": False})) except Exception as exc: logger.warning(f"[Agent影视助手] 重置“立即影巢签到”开关失败:{exc}") def _run_once() -> None: try: result = self._run_hdhive_checkin(trigger="Agent影视助手 插件页立即签到") status = "成功" if result.get("success") else "失败" logger.info(f"[Agent影视助手] 插件页立即影巢签到{status}: {result.get('message')}") except Exception as exc: logger.error(f"[Agent影视助手] 插件页立即影巢签到异常: {exc}") threading.Thread(target=_run_once, name="aro-hdhive-checkin-once", daemon=True).start() def get_service(self) -> List[Dict[str, Any]]: if not self._enabled or not self._hdhive_checkin_enabled or not self._hdhive_checkin_cron: return [] if CronTrigger is None: logger.warning("[Agent影视助手] apscheduler 不可用,无法注册影巢定时签到") return [] try: trigger = CronTrigger.from_crontab(self._hdhive_checkin_cron) except Exception as exc: logger.warning(f"[Agent影视助手] 影巢签到 Cron 配置无效:{self._hdhive_checkin_cron} {exc}") return [] return [{ "id": "agentresourceofficer_hdhive_checkin", "name": "Agent影视助手影巢签到", "trigger": trigger, "func": self._scheduled_hdhive_checkin, "kwargs": {}, }] def _build_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: config = { "enabled": self._enabled, "notify": self._notify, "debug": self._debug, "quark_cookie": self._quark_cookie, "quark_default_path": self._quark_default_path, "quark_timeout": self._quark_timeout, "quark_auto_import_cookiecloud": self._quark_auto_import_cookiecloud, "pansou_enabled": self._pansou_enabled, "pansou_base_url": self._pansou_base_url, "pansou_timeout": self._pansou_timeout, "hdhive_api_key": self._hdhive_api_key, "hdhive_base_url": self._hdhive_base_url, "hdhive_timeout": self._hdhive_timeout, "hdhive_default_path": self._hdhive_default_path, "hdhive_candidate_page_size": self._hdhive_candidate_page_size, "hdhive_resource_enabled": self._hdhive_resource_enabled, "hdhive_max_unlock_points": self._hdhive_max_unlock_points, "hdhive_checkin_enabled": self._hdhive_checkin_enabled, "hdhive_checkin_gambler_mode": self._hdhive_checkin_gambler_mode, "hdhive_checkin_once": self._hdhive_checkin_once, "hdhive_checkin_cron": self._hdhive_checkin_cron, "hdhive_checkin_cookie": self._hdhive_checkin_cookie, "hdhive_checkin_auto_login": self._hdhive_checkin_auto_login, "hdhive_checkin_username": self._hdhive_checkin_username, "hdhive_checkin_password": self._hdhive_checkin_password, "p115_default_path": self._p115_default_path, "p115_client_type": self._p115_client_type, "p115_cookie": self._p115_cookie, "p115_prefer_direct": self._p115_prefer_direct, "mp_pt_enabled": self._mp_pt_enabled, "mp_download_save_path": self._mp_download_save_path, "assistant_default_pt_min_seeders": self._assistant_default_pt_min_seeders, "assistant_default_auto_ingest_enabled": self._assistant_default_auto_ingest_enabled, "assistant_default_auto_ingest_score_threshold": self._assistant_default_auto_ingest_score_threshold, "assistant_default_confirm_score_threshold": self._assistant_default_confirm_score_threshold, "feishu_enabled": self._feishu_enabled, "feishu_allow_all": self._feishu_allow_all, "feishu_reply_enabled": self._feishu_reply_enabled, "feishu_reply_receive_id_type": self._feishu_reply_receive_id_type, "feishu_app_id": self._feishu_app_id, "feishu_app_secret": self._feishu_app_secret, "feishu_verification_token": self._feishu_verification_token, "feishu_allowed_chat_ids": "\n".join(self._feishu_allowed_chat_ids), "feishu_allowed_user_ids": "\n".join(self._feishu_allowed_user_ids), "feishu_command_whitelist": ",".join(self._feishu_command_whitelist), "feishu_command_aliases": self._feishu_command_aliases, "feishu_command_mode": self._feishu_command_mode, } if overrides: config.update(overrides) return config @staticmethod def _extract_apikey(request: Request, body: Optional[Dict[str, Any]] = None) -> str: header = str(request.headers.get("Authorization") or "").strip() if header.lower().startswith("bearer "): return header.split(" ", 1)[1].strip() if body: token = str(body.get("apikey") or body.get("api_key") or "").strip() if token: return token return str(request.query_params.get("apikey") or "").strip() def _check_api_access(self, request: Request, body: Optional[Dict[str, Any]] = None) -> Tuple[bool, str]: expected = self._clean_text(getattr(settings, "API_TOKEN", "") if settings is not None else "") if not expected: return False, "服务端未配置 API Token" actual = self._extract_apikey(request, body) if not hmac.compare_digest(actual, expected): return False, "API Token 无效" return True, "" async def _request_payload(self, request: Request) -> Dict[str, Any]: if str(getattr(request, "method", "") or "").upper() == "GET": return {} try: data = await request.json() return data if isinstance(data, dict) else {} except Exception: return {} def _ensure_quark_service(self) -> QuarkTransferService: if self._quark_service is None: self._quark_service = QuarkTransferService( cookie=self._quark_cookie, timeout=self._quark_timeout, default_target_path=self._quark_default_path, auto_import_cookiecloud=self._quark_auto_import_cookiecloud, cookie_refresh_callback=self._refresh_quark_cookie_from_cookiecloud, ) else: self._quark_service.set_cookie(self._quark_cookie) self._quark_service.timeout = max(10, self._safe_int(self._quark_timeout, 30)) self._quark_service.default_target_path = self._quark_default_path self._quark_service.auto_import_cookiecloud = self._quark_auto_import_cookiecloud self._quark_service.cookie_refresh_callback = self._refresh_quark_cookie_from_cookiecloud return self._quark_service def _load_cookiecloud_quark_cookie(self) -> Tuple[str, str]: if settings is None: return "", "未获取到系统设置" if CryptoJsUtils is None: return "", "运行环境缺少 CookieCloud 解密依赖" key = self._clean_text(getattr(settings, "COOKIECLOUD_KEY", "")) password = self._clean_text(getattr(settings, "COOKIECLOUD_PASSWORD", "")) cookie_path = getattr(settings, "COOKIE_PATH", None) if not bool(getattr(settings, "COOKIECLOUD_ENABLE_LOCAL", False)): return "", "未启用本地 CookieCloud" if not key or not password or not cookie_path: return "", "CookieCloud 参数不完整" file_path = Path(cookie_path) / f"{key}.json" if not file_path.exists(): return "", f"未找到 CookieCloud 文件: {file_path.name}" try: encrypted_data = json.loads(file_path.read_text(encoding="utf-8")) encrypted = encrypted_data.get("encrypted") if not encrypted: return "", "CookieCloud 文件缺少 encrypted 字段" crypt_key = md5(f"{key}-{password}".encode("utf-8")).hexdigest()[:16].encode("utf-8") decrypted = CryptoJsUtils.decrypt(encrypted, crypt_key).decode("utf-8") payload = json.loads(decrypted) except Exception as exc: return "", f"CookieCloud 解密失败: {exc}" contents = payload.get("cookie_data") if isinstance(payload, dict) else None if not isinstance(contents, dict): contents = payload if isinstance(payload, dict) else {} merged: Dict[str, str] = {} for cookie_items in contents.values(): if not isinstance(cookie_items, list): continue for item in cookie_items: if not isinstance(item, dict): continue domain = self._clean_text(item.get("domain")).lower() name = self._clean_text(item.get("name")) value = self._clean_text(item.get("value")) if "quark.cn" not in domain or not name: continue merged[name] = value if not merged: return "", "CookieCloud 中没有 quark.cn 的 Cookie" return "; ".join(f"{name}={value}" for name, value in merged.items() if value), "" def _refresh_quark_cookie_from_cookiecloud(self) -> str: cookie, _message = self._load_cookiecloud_quark_cookie() if cookie: self._quark_cookie = cookie return cookie def _ensure_hdhive_service(self) -> HDHiveOpenApiService: if self._hdhive_service is None: self._hdhive_service = HDHiveOpenApiService( api_key=self._hdhive_api_key, base_url=self._hdhive_base_url, timeout=self._hdhive_timeout, ) else: self._hdhive_service.api_key = self._hdhive_api_key self._hdhive_service.base_url = self._hdhive_base_url self._hdhive_service.timeout = self._hdhive_timeout return self._hdhive_service def _ensure_p115_service(self) -> P115TransferService: if self._p115_service is None: self._p115_service = P115TransferService( default_target_path=self._p115_default_path, cookie=self._p115_cookie, prefer_direct=self._p115_prefer_direct, ) else: self._p115_service.default_target_path = self._p115_default_path self._p115_service.set_cookie(self._p115_cookie) self._p115_service.prefer_direct = self._p115_prefer_direct return self._p115_service def _apply_runtime_config(self, overrides: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: config = self._build_config(overrides) self.update_config(config) self.init_plugin(config) return config @staticmethod def _p115_client_type_items() -> List[Dict[str, str]]: return [ {"title": "支付宝小程序(推荐)", "value": "alipaymini"}, {"title": "115 Android", "value": "115android"}, {"title": "115 iOS", "value": "115ios"}, {"title": "115 iPad", "value": "115ipad"}, {"title": "115 TV", "value": "tv"}, {"title": "微信小程序", "value": "wechatmini"}, {"title": "Web", "value": "web"}, ] @classmethod def _p115_client_type_title(cls, value: str) -> str: final_value = P115TransferService.normalize_qrcode_client_type(value) for item in cls._p115_client_type_items(): if item.get("value") == final_value: return str(item.get("title") or final_value) return final_value @staticmethod def get_command() -> List[Dict[str, Any]]: return [] def stop_service(self): if self._feishu_channel is not None: self._feishu_channel.stop() return def _ensure_feishu_channel(self) -> FeishuChannel: if self._feishu_channel is None: self._feishu_channel = FeishuChannel(self) return self._feishu_channel def get_api(self) -> List[Dict[str, Any]]: return [ { "path": "/quark/health", "endpoint": self.api_quark_health, "methods": ["GET"], "summary": "检查 Agent影视助手 的夸克配置", }, { "path": "/quark/transfer", "endpoint": self.api_quark_transfer, "methods": ["POST"], "summary": "通过 Agent影视助手 执行夸克分享转存", }, { "path": "/hdhive/health", "endpoint": self.api_hdhive_health, "methods": ["GET"], "summary": "检查 Agent影视助手 的影巢配置", }, { "path": "/hdhive/account", "endpoint": self.api_hdhive_account, "methods": ["GET"], "summary": "获取影巢当前账号信息", }, { "path": "/hdhive/checkin", "endpoint": self.api_hdhive_checkin, "methods": ["POST"], "summary": "执行影巢普通签到或赌狗签到", }, { "path": "/hdhive/checkin/history", "endpoint": self.api_hdhive_checkin_history, "methods": ["GET"], "summary": "查看插件保存的影巢签到日志", }, { "path": "/hdhive/quota", "endpoint": self.api_hdhive_quota, "methods": ["GET"], "summary": "获取影巢当前配额信息", }, { "path": "/hdhive/usage_today", "endpoint": self.api_hdhive_usage_today, "methods": ["GET"], "summary": "获取影巢今日用量统计", }, { "path": "/hdhive/weekly_free_quota", "endpoint": self.api_hdhive_weekly_free_quota, "methods": ["GET"], "summary": "获取影巢每周免费解锁额度", }, { "path": "/hdhive/search", "endpoint": self.api_hdhive_search, "methods": ["POST"], "summary": "通过 Agent影视助手 执行影巢资源搜索", }, { "path": "/hdhive/search_by_keyword", "endpoint": self.api_hdhive_search_by_keyword, "methods": ["POST"], "summary": "通过 Agent影视助手 执行影巢关键词候选搜索", }, { "path": "/hdhive/unlock", "endpoint": self.api_hdhive_unlock, "methods": ["POST"], "summary": "通过 Agent影视助手 执行影巢资源解锁", }, { "path": "/hdhive/unlock_and_route", "endpoint": self.api_hdhive_unlock_and_route, "methods": ["POST"], "summary": "通过 Agent影视助手 解锁影巢资源并尝试自动路由到对应网盘执行层", }, { "path": "/p115/health", "endpoint": self.api_p115_health, "methods": ["GET"], "summary": "检查 Agent影视助手 的 115 转存依赖状态", }, { "path": "/p115/qrcode", "endpoint": self.api_p115_qrcode, "methods": ["GET"], "summary": "获取 Agent影视助手 的 115 扫码登录二维码", }, { "path": "/p115/qrcode/check", "endpoint": self.api_p115_qrcode_check, "methods": ["GET"], "summary": "检查 Agent影视助手 的 115 扫码登录状态", }, { "path": "/p115/transfer", "endpoint": self.api_p115_transfer, "methods": ["POST"], "summary": "通过 Agent影视助手 执行 115 分享转存", }, { "path": "/p115/pending", "endpoint": self.api_p115_pending, "methods": ["GET", "POST"], "summary": "查看指定会话中待继续的 115 任务", }, { "path": "/p115/pending/resume", "endpoint": self.api_p115_pending_resume, "methods": ["POST"], "summary": "继续执行指定会话中待处理的 115 任务", }, { "path": "/p115/pending/cancel", "endpoint": self.api_p115_pending_cancel, "methods": ["POST"], "summary": "取消指定会话中待处理的 115 任务", }, { "path": "/share/route", "endpoint": self.api_share_route, "methods": ["POST"], "summary": "通过 Agent影视助手 自动识别 115 / 夸克分享链接并执行对应转存", }, { "path": "/feishu/health", "endpoint": self.api_feishu_health, "methods": ["GET"], "summary": "检查 Agent影视助手 内置飞书入口状态", }, { "path": "/assistant/route", "endpoint": self.api_assistant_route, "methods": ["POST"], "summary": "统一智能入口:盘搜 / 影巢 / 直链分享", }, { "path": "/assistant/pick", "endpoint": self.api_assistant_pick, "methods": ["POST"], "summary": "统一智能入口的按编号继续执行", }, { "path": "/assistant/capabilities", "endpoint": self.api_assistant_capabilities, "methods": ["GET"], "summary": "查看统一智能入口支持的结构化参数、默认值与推荐调用方式", }, { "path": "/assistant/readiness", "endpoint": self.api_assistant_readiness, "methods": ["GET"], "summary": "检查 Agent影视助手 是否已准备好给外部智能体调用", }, { "path": "/assistant/cookie/update", "endpoint": self.api_assistant_cookie_update, "methods": ["POST"], "summary": "外部智能体安全写回影巢 / 夸克 Cookie", }, { "path": "/assistant/pulse", "endpoint": self.api_assistant_pulse, "methods": ["GET"], "summary": "轻量启动探针:返回版本、关键服务状态、警告和最佳恢复建议", }, { "path": "/assistant/startup", "endpoint": self.api_assistant_startup, "methods": ["GET"], "summary": "启动聚合包:一次返回 pulse、自检、核心工具、端点、默认目录和恢复建议", }, { "path": "/assistant/maintain", "endpoint": self.api_assistant_maintain, "methods": ["GET", "POST"], "summary": "低风险维护:查看或执行过期会话、已执行计划清理", }, { "path": "/assistant/toolbox", "endpoint": self.api_assistant_toolbox, "methods": ["GET"], "summary": "轻量工具清单:返回推荐工具、端点、工作流、动作名、默认目录和命令示例", }, { "path": "/assistant/request_templates", "endpoint": self.api_assistant_request_templates, "methods": ["GET", "POST"], "summary": "轻量请求模板:返回外部智能体常用 assistant 请求模板", }, { "path": "/assistant/selfcheck", "endpoint": self.api_assistant_selfcheck, "methods": ["GET"], "summary": "轻量自检:确认 compact 协议、模板默认 compact 和布尔字符串解析是否正常", }, { "path": "/assistant/history", "endpoint": self.api_assistant_history, "methods": ["GET"], "summary": "查看最近执行历史,便于外部智能体判断上一步是否完成", }, { "path": "/assistant/action", "endpoint": self.api_assistant_action, "methods": ["POST"], "summary": "直接执行统一智能入口返回的动作模板名,适合外部智能体无映射继续执行", }, { "path": "/assistant/actions", "endpoint": self.api_assistant_actions, "methods": ["POST"], "summary": "批量执行多个动作模板,适合外部智能体一次请求串起多步工作流,减少往返", }, { "path": "/assistant/workflow", "endpoint": self.api_assistant_workflow, "methods": ["GET", "POST"], "summary": "运行预设工作流,适合外部智能体用更短参数完成常见资源任务", }, { "path": "/assistant/preferences", "endpoint": self.api_assistant_preferences, "methods": ["GET", "POST", "DELETE"], "summary": "读取、保存或重置智能体片源偏好画像,用于云盘和 PT 分源评分", }, { "path": "/assistant/plan/execute", "endpoint": self.api_assistant_plan_execute, "methods": ["POST"], "summary": "执行 dry_run 保存的工作流计划,避免外部智能体重复携带大 JSON", }, { "path": "/assistant/plans", "endpoint": self.api_assistant_plans, "methods": ["GET"], "summary": "查看 dry_run 保存的工作流计划,便于断线恢复和选择 plan_id", }, { "path": "/assistant/plans/clear", "endpoint": self.api_assistant_plans_clear, "methods": ["POST"], "summary": "清理 dry_run 保存的工作流计划", }, { "path": "/assistant/recover", "endpoint": self.api_assistant_recover, "methods": ["GET", "POST"], "summary": "查看或直接执行当前最推荐的恢复动作,给外部智能体提供单入口续跑能力", }, { "path": "/assistant/session", "endpoint": self.api_assistant_session_state, "methods": ["GET", "POST"], "summary": "查看统一智能入口当前会话状态与建议动作", }, { "path": "/assistant/session/clear", "endpoint": self.api_assistant_session_clear, "methods": ["POST"], "summary": "清理统一智能入口当前会话缓存", }, { "path": "/assistant/sessions", "endpoint": self.api_assistant_sessions, "methods": ["GET"], "summary": "列出当前活跃的统一智能入口会话,便于外部智能体恢复和接续", }, { "path": "/assistant/sessions/clear", "endpoint": self.api_assistant_sessions_clear, "methods": ["POST"], "summary": "按 session_id、类型或过滤条件批量清理统一智能入口会话", }, { "path": "/session/hdhive/search", "endpoint": self.api_session_hdhive_search, "methods": ["POST"], "summary": "创建影巢搜索会话并返回候选影片列表", }, { "path": "/session/hdhive/pick", "endpoint": self.api_session_hdhive_pick, "methods": ["POST"], "summary": "按编号继续影巢会话:候选选片或资源解锁落盘", }, ] def _build_hdhive_page_summary(self) -> str: if not self._enabled: return "插件未启用" if not self._hdhive_api_key: return "影巢 API Key 未配置" service = self._ensure_hdhive_service() account_ok, account_result, account_message = service.fetch_me() quota_ok, quota_result, _quota_message = service.fetch_quota() usage_ok, usage_result, _usage_message = service.fetch_usage_today() account = account_result.get("data") or {} account_source = "hdhive_openapi" if not account_ok and self._is_hdhive_premium_limited(account_message): fallback_account = self._build_hdhive_account_snapshot(self._load_hdhive_daily_sign_user_info()) if fallback_account: account = fallback_account account_ok = True account_source = "hdhivedailysign_snapshot" account_fields = self._extract_hdhive_account_fields(account) quota = quota_result.get("data") or {} usage = usage_result.get("data") or {} return ( f"影巢账号:{'可用' if account_ok else '异常'}" f"\n资源入口:{'开启' if self._hdhive_resource_enabled else '关闭'}" f"\n单资源积分上限:{self._hdhive_max_unlock_points if self._hdhive_max_unlock_points > 0 else '不限制'}" f"\n签到入口:{'开启' if self._hdhive_checkin_enabled else '关闭'}" f"\n昵称:{account_fields.get('nickname', '—')}" f"\n积分:{account_fields.get('points', '—')}" f"\nVIP:{'是' if account_fields.get('is_vip') else '否'}" f"\n累计签到:{account_fields.get('signin_days_total', '—')}" f"\n今日剩余配额:{quota.get('endpoint_remaining', '—')}" f"\n今日总调用:{usage.get('total_calls', '—')}" f"\n账号来源:{'网页快照' if account_source == 'hdhivedailysign_snapshot' else 'OpenAPI'}" ) def get_page(self) -> List[dict]: quark_ready = "已配置" if self._quark_cookie else "未配置" hdhive_ready = "已配置" if self._hdhive_api_key else "未配置" p115_health_ok, p115_health, _p115_health_message = self._ensure_p115_service().health() cookie_state = p115_health.get("cookie_state") or {} if cookie_state.get("valid"): p115_ready = "已配置扫码会话" elif cookie_state.get("configured"): p115_ready = "已配置但不是扫码会话" else: p115_ready = "复用 115 助手客户端" hdhive_summary = self._build_hdhive_page_summary() feishu_health = self._ensure_feishu_channel().health() feishu_state = "已启用" if feishu_health.get("enabled") else "未启用" feishu_running = "运行中" if feishu_health.get("running") else "未运行" hdhive_lines = [line.strip() for line in str(hdhive_summary or "").splitlines() if line.strip()] hdhive_compact_lines = hdhive_lines[:4] if len(hdhive_lines) >= 6: hdhive_compact_lines.append(f"{hdhive_lines[4]} / {hdhive_lines[5]}") p115_cookie_message = cookie_state.get("message") or "当前会话可直接用于 115 直转" def text_line(text: str, css_class: str = "text-body-2 py-1") -> Dict[str, Any]: return { "component": "div", "props": {"class": css_class}, "text": text, } def status_card(title: str, subtitle: str, lines: List[str], color: str = "primary") -> Dict[str, Any]: return { "component": "VCard", "props": {"variant": "tonal", "color": color, "class": "h-100"}, "content": [ { "component": "VCardTitle", "props": {"class": "text-subtitle-1 font-weight-bold pb-1"}, "text": title, }, { "component": "VCardSubtitle", "props": {"class": "text-body-2"}, "text": subtitle, }, { "component": "VCardText", "props": {"class": "py-2"}, "content": [text_line(line, "text-body-2 py-0") for line in lines], }, ], } def section_card(title: str, lines: List[str], compact: bool = False) -> Dict[str, Any]: return { "component": "VCard", "props": {"flat": True, "border": True, "class": "h-100"}, "content": [ { "component": "VCardTitle", "props": {"class": "text-subtitle-1 font-weight-bold pb-1" if compact else "text-subtitle-1 font-weight-bold"}, "text": title, }, { "component": "VCardText", "props": {"class": "py-2"} if compact else {}, "content": [text_line(line, "text-body-2 py-0") for line in lines] if compact else [text_line(line) for line in lines], }, ], } return [ { "component": "VContainer", "props": {"fluid": True, "class": "pa-0"}, "content": [ { "component": "VRow", "props": {"dense": True}, "content": [ { "component": "VCol", "props": {"cols": 12, "md": 3}, "content": [ status_card( "影巢", hdhive_ready, [ f"默认目录:{self._hdhive_default_path}", "能力:搜索 / 解锁 / 签到", "API:/hdhive/account /checkin /quota", ], "success" if self._hdhive_api_key else "warning", ) ], }, { "component": "VCol", "props": {"cols": 12, "md": 3}, "content": [ status_card( "115", "可用" if p115_health_ok else "待修复", [ f"默认目录:{self._p115_default_path}", f"登录方式:{p115_ready}", f"扫码客户端:{self._p115_client_type_title(self._p115_client_type)}", ], "success" if p115_health_ok else "error", ) ], }, { "component": "VCol", "props": {"cols": 12, "md": 3}, "content": [ status_card( "夸克", quark_ready, [ f"默认目录:{self._quark_default_path}", "能力:分享链接转存", "入口:通用分享路由", ], "success" if self._quark_cookie else "warning", ) ], }, { "component": "VCol", "props": {"cols": 12, "md": 3}, "content": [ status_card( "飞书", f"{feishu_state},长连接:{feishu_running}", [ "模式:内置 Channel", "健康检查:/feishu/health", "建议:只保留一个飞书入口监听", ], "success" if feishu_health.get("running") else "secondary", ) ], }, ], }, { "component": "VRow", "props": {"dense": True, "class": "mt-3"}, "content": [ { "component": "VCol", "props": {"cols": 12, "md": 4}, "content": [ section_card( "智能体入口", [ "统一路由:/assistant/route", "继续选择:/assistant/pick", "工作流:/assistant/workflow", "计划执行:/assistant/plan/execute", "Agent Tool:搜索/选择、115 扫码、待任务查看/继续/取消、通用分享路由", ], compact=True, ) ], }, { "component": "VCol", "props": {"cols": 12, "md": 4}, "content": [ section_card( "账号与签到", hdhive_compact_lines + [ f"115 Cookie:{p115_cookie_message}", ], compact=True, ) ], }, { "component": "VCol", "props": {"cols": 12, "md": 4}, "content": [ section_card( "盘搜服务", [ f"API 地址:{self._pansou_base_url}", f"请求超时:{self._pansou_timeout} 秒", "用法:发送“盘搜搜索 片名”“ps片名”或“1片名”。", "说明:插件只负责调用 PanSou API,本机需要先运行 PanSou 服务。", ], compact=True, ) ], } ], }, { "component": "VAlert", "props": { "type": "info", "variant": "tonal", "class": "mt-4 mb-1", "title": "统一资源入口", }, "content": [ text_line( "Agent影视助手支持四种接入模式:飞书直接发命令、外部智能体直连官方 MCP、外部智能体调用 skill/helper、MP 内置智能体调用 Agent Tool。", "text-body-2 mb-3", ), text_line( "不接外部智能体", "text-subtitle-2 font-weight-bold mb-2", ), { "component": "div", "props": { "class": "pa-3 rounded text-body-2 mb-3", "style": "white-space: pre-line; line-height: 1.7; background: rgba(255,255,255,.55);", }, "text": ( "如果你只想直接用插件或飞书入口,不需要额外安装 skill。\n" "直接使用这些命令即可:搜索 片名 / 盘搜搜索 片名 / 影巢搜索 片名 / 转存 片名 / 下载 片名 / 更新检查 片名 / 115登录。\n" "如果你同时装了 P115StrmHelper,它更适合 115 整理、STRM 和旧登录态复用;Agent影视助手负责资源搜索、转存编排和 115 直转。" ), }, text_line( "接外部智能体", "text-subtitle-2 font-weight-bold mb-2", ), { "component": "div", "props": { "class": "pa-3 rounded text-body-2 mb-3", "style": "white-space: pre-line; line-height: 1.7; background: rgba(255,255,255,.55);", }, "text": ( "快速开始主页:\n" "https://github.com/liuyuexi1987/MoviePilot-Plugins\n\n" "外部智能体接入文档:\n" "https://github.com/liuyuexi1987/MoviePilot-Plugins/blob/main/docs/AGENT_RESOURCE_OFFICER_EXTERNAL_AGENTS.md\n\n" "跨机器部署:\n" "https://github.com/liuyuexi1987/MoviePilot-Plugins/blob/main/docs/AGENT_RESOURCE_OFFICER_REMOTE_DEPLOY.md\n\n" "Skill 说明:\n" "https://github.com/liuyuexi1987/MoviePilot-Plugins/blob/main/skills/agent-resource-officer/SKILL.md" ), }, ], }, ], } ] @staticmethod def get_render_mode() -> Tuple[str, Optional[str]]: return "vuetify", None def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: form = [ { "component": "VForm", "content": [ { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12, "md": 4}, "content": [ { "component": "VSwitch", "props": { "model": "enabled", "label": "启用插件", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 4}, "content": [ { "component": "VSwitch", "props": { "model": "notify", "label": "发送通知", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 4}, "content": [ { "component": "VSwitch", "props": { "model": "debug", "label": "调试模式", }, } ], }, ], }, { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VAlert", "props": { "type": "info", "variant": "tonal", "text": "搜索源总开关:可以分别控制 MP/PT、盘搜、影巢。普通“搜索”默认优先走 MP/PT;盘搜和影巢会在对方无结果时互相补查;两边都没有时只提示是否改搜 PT,不会自动切过去。", }, } ], }, ], }, { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12, "md": 4}, "content": [ { "component": "VSwitch", "props": { "model": "mp_pt_enabled", "label": "启用 MP/PT 原生搜索/下载", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 4}, "content": [ { "component": "VSwitch", "props": { "model": "pansou_enabled", "label": "启用盘搜", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 4}, "content": [ { "component": "VSwitch", "props": { "model": "hdhive_resource_enabled", "label": "启用影巢资源搜索/解锁", }, } ], }, { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VAlert", "props": { "type": "info", "variant": "tonal", "text": "下面这组是智能体默认评分策略,只影响还没有保存个人偏好的新会话。高分不代表一定执行;遇到影巢高积分、PT 低做种这类硬风险时,插件仍会拦截。", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 2}, "content": [ { "component": "VTextField", "props": { "model": "assistant_default_pt_min_seeders", "label": "PT 最低做种数", "type": "number", "placeholder": "3", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 2}, "content": [ { "component": "VTextField", "props": { "model": "assistant_default_confirm_score_threshold", "label": "建议确认分数线", "type": "number", "placeholder": "70", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 2}, "content": [ { "component": "VTextField", "props": { "model": "assistant_default_auto_ingest_score_threshold", "label": "自动入库分数线", "type": "number", "placeholder": "90", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 3}, "content": [ { "component": "VSwitch", "props": { "model": "assistant_default_auto_ingest_enabled", "label": "默认允许高分自动入库", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 3}, "content": [ { "component": "VTextField", "props": { "model": "mp_download_save_path", "label": "PT 下载保存路径(可选)", "placeholder": "默认留空", "hint": "默认不用填。MoviePilot 和 qB 在同一台机器时留空;如果不在同一台机器,填目标 NAS / qB 的真实下载目录,例如 local:/downloads。", }, } ], }, ], }, { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VAlert", "props": { "type": "info", "variant": "tonal", "text": "影巢负责搜索、解锁和签到。关闭资源入口后,不再执行影巢搜索、解锁或转存;单资源积分上限默认 20 分,填 0 表示不限制。", }, } ], }, { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VTextarea", "props": { "model": "hdhive_api_key", "label": "影巢 API Key", "rows": 2, "placeholder": "填写影巢 OpenAPI 的 API Key", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 2}, "content": [ { "component": "VTextField", "props": { "model": "hdhive_max_unlock_points", "label": "单资源积分上限", "type": "number", "placeholder": "20;填 0 不限制", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 2}, "content": [ { "component": "VTextField", "props": { "model": "hdhive_base_url", "label": "影巢 Base URL", "placeholder": "https://hdhive.com", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 3}, "content": [ { "component": "VTextField", "props": { "model": "hdhive_timeout", "label": "影巢超时(秒)", "type": "number", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 3}, "content": [ { "component": "VTextField", "props": { "model": "hdhive_default_path", "label": "影巢默认目录", "placeholder": "/待整理", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 2}, "content": [ { "component": "VTextField", "props": { "model": "hdhive_candidate_page_size", "label": "候选页大小", "type": "number", }, } ], }, ], }, { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VAlert", "props": { "type": "info", "variant": "tonal", "text": "影巢签到支持 OpenAPI 和网页 Cookie 两种方式。OpenAPI 需要 Premium;普通用户建议优先用本机导出工具自动写回完整 Cookie,不建议手工复制。", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 3}, "content": [ { "component": "VSwitch", "props": { "model": "hdhive_checkin_enabled", "label": "启用影巢签到", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 3}, "content": [ { "component": "VSwitch", "props": { "model": "hdhive_checkin_gambler_mode", "label": "默认赌狗签到", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 3}, "content": [ { "component": "VSwitch", "props": { "model": "hdhive_checkin_once", "label": "立即运行一次", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 3}, "content": [ { "component": "VTextField", "props": { "model": "hdhive_checkin_cron", "label": "影巢签到 Cron", "placeholder": "0 8 * * *", }, } ], }, ], }, { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VTextarea", "props": { "model": "hdhive_checkin_cookie", "label": "影巢网页 Cookie(非 Premium 兜底)", "rows": 3, "placeholder": "不建议手工填写;优先在 Edge 登录 hdhive.com 后运行“影巢Cookie导出.command”自动写回", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 3}, "content": [ { "component": "VSwitch", "props": { "model": "hdhive_checkin_auto_login", "label": "自动刷新 Cookie", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 4}, "content": [ { "component": "VTextField", "props": { "model": "hdhive_checkin_username", "label": "影巢用户名/邮箱", "placeholder": "用于 Cookie 失效时自动登录", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 5}, "content": [ { "component": "VTextField", "props": { "model": "hdhive_checkin_password", "label": "影巢密码", "type": "password", "placeholder": "仅保存在 MoviePilot 本机配置中", }, } ], }, ], }, { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VAlert", "props": { "type": "info", "variant": "tonal", "text": "盘搜用于聚合公开网盘分享结果。请填写 MoviePilot 容器视角下可访问的 API 地址,不要按外部智能体机器的视角填写。要查盘搜请直接发“盘搜搜索 片名”;盘搜无结果时会按开关补查影巢。", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 8}, "content": [ { "component": "VTextField", "props": { "model": "pansou_base_url", "label": "盘搜 API 地址", "placeholder": "http://host.docker.internal:805", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 4}, "content": [ { "component": "VTextField", "props": { "model": "pansou_timeout", "label": "盘搜超时(秒)", "type": "number", }, } ], }, ], }, { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VAlert", "props": { "type": "info", "variant": "tonal", "text": "夸克用于转存 pan.quark.cn 分享链接。优先使用 CookieCloud 或有效网页登录 Cookie;只有明确出现“require login [guest]”“夸克登录态已过期”“当前夸克登录态不足”时,才建议走 Cookie 修复。分享受限、41031、分享者封禁不属于 Cookie 失效。", }, } ], }, { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VTextarea", "props": { "model": "quark_cookie", "label": "夸克 Cookie", "rows": 4, "placeholder": "可手工填写,但更推荐 CookieCloud 或本机夸克Cookie导出工具自动写回", }, } ], } ], }, { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12, "md": 6}, "content": [ { "component": "VTextField", "props": { "model": "quark_default_path", "label": "夸克默认目录", "placeholder": "/飞书", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 3}, "content": [ { "component": "VTextField", "props": { "model": "quark_timeout", "label": "夸克超时(秒)", "type": "number", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 3}, "content": [ { "component": "VSwitch", "props": { "model": "quark_auto_import_cookiecloud", "label": "允许自动刷新 Cookie", }, } ], }, ], }, { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VAlert", "props": { "type": "info", "variant": "tonal", "text": "115 建议和 P115StrmHelper 搭配使用:它更适合 115 整理、STRM 和旧登录态复用;Agent影视助手负责资源搜索、转存编排和 115 直转。新环境即使不装 P115StrmHelper,也可以直接发“115登录”扫码完成 115 转存。不建议填网页版 Cookie;手填 Cookie 仅作为高级兜底。", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 3}, "content": [ { "component": "VTextField", "props": { "model": "p115_default_path", "label": "115 默认目录", "placeholder": "/待整理", "hint": "Agent影视助手的 115 转存默认会落到这里;如果你同时用 P115StrmHelper,通常也填成你常用的 115 整理目录。", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 5}, "content": [ { "component": "VSelect", "props": { "model": "p115_client_type", "label": "115 扫码客户端类型", "items": self._p115_client_type_items(), }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 3}, "content": [ { "component": "VSwitch", "props": { "model": "p115_prefer_direct", "label": "115 优先直转", }, } ], }, ], }, { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VTextarea", "props": { "model": "p115_cookie", "label": "115 扫码会话 Cookie(高级,可选)", "rows": 3, "placeholder": "推荐直接发“115登录”扫码;这里只接受 UID/CID/SEID/KID 这类扫码客户端 Cookie,普通网页版 Cookie 不建议粘贴到这里", }, } ], }, ], }, { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VAlert", "props": { "type": "info", "variant": "tonal", "text": "新手最简配置:只填飞书 App ID、App Secret,并开启飞书入口即可;其他项一般先保持默认。详细说明请看仓库主页快速开始。保存后,直接到飞书里发送“版本”“菜单”或“帮助”验证是否接通。", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 4}, "content": [ { "component": "VSwitch", "props": { "model": "feishu_enabled", "label": "启用内置飞书入口", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 4}, "content": [ { "component": "VSwitch", "props": { "model": "feishu_allow_all", "label": "允许所有飞书会话", "hint": "开启后,下面的群聊 Chat ID / 用户 Open ID 白名单都会被忽略,适合刚接通时先快速测试。", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 4}, "content": [ { "component": "VSwitch", "props": { "model": "feishu_reply_enabled", "label": "发送飞书回复", "hint": "关闭后仍可接收和处理命令,但插件不会回消息。一般保持开启。", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 4}, "content": [ { "component": "VTextField", "props": { "model": "feishu_app_id", "label": "飞书 App ID", "placeholder": "cli_xxxxxxxxx", "hint": "飞书应用机器人的 App ID,通常在飞书开放平台应用凭证页里查看。", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 4}, "content": [ { "component": "VTextField", "props": { "model": "feishu_app_secret", "label": "飞书 App Secret", "type": "password", "hint": "飞书应用机器人的 App Secret。和 App ID 配套,填错时飞书长连接无法启动。", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 4}, "content": [ { "component": "VTextField", "props": { "model": "feishu_verification_token", "label": "Verification Token(可选)", "type": "password", "hint": "当前内置飞书长连接通常不依赖它;一般留空即可。只有你自己额外用了飞书事件订阅/Webhook 校验链路时再填写。", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 3}, "content": [ { "component": "VSelect", "props": { "model": "feishu_reply_receive_id_type", "label": "回复 ID 类型", "items": [ {"title": "群聊 chat_id", "value": "chat_id"}, {"title": "用户 open_id", "value": "open_id"}, ], "hint": "一般保持 chat_id,群聊和多数场景最稳;只有明确要按单个用户回复时再改成 open_id。", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 4}, "content": [ { "component": "VTextarea", "props": { "model": "feishu_allowed_chat_ids", "label": "允许的群聊 Chat ID", "rows": 1, "placeholder": "一个一行;allow_all 关闭时生效", "hint": "只有这些群聊里的消息才会被插件处理。适合把机器人限制在几个指定群里使用。", }, } ], }, { "component": "VCol", "props": {"cols": 12, "md": 5}, "content": [ { "component": "VTextarea", "props": { "model": "feishu_allowed_user_ids", "label": "允许的用户 Open ID", "rows": 1, "placeholder": "一个一行", "hint": "只有这些用户发来的消息才会被处理。通常用于私聊白名单;这里填的是 open_id,不是昵称。", }, } ], }, { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VTextarea", "props": { "model": "feishu_command_whitelist", "label": "飞书命令白名单", "rows": 3, "placeholder": "逗号或换行分隔;留空时会自动合并当前主线命令。旧 STRM/刮削命令不再默认暴露,如需兼容旧环境可手动加入。", "hint": "控制飞书里允许执行哪些斜杠命令。一般不用自己改,保持默认即可;只有你想故意收窄命令范围时再填。", }, } ], }, { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VTextarea", "props": { "model": "feishu_command_aliases", "label": "飞书命令别名", "rows": 5, "placeholder": FeishuChannel.default_command_aliases(), "hint": "别名就是把你在飞书里说的话,自动改写成插件真正执行的固定命令。比如把简短说法映射成“盘搜搜索 片名”“影巢搜索 片名”或“115转存 片名”。一般保持默认即可;只有你想自定义口令,或兼容旧习惯时再改。", }, } ], }, ], }, ], } ] return form, self._build_config() async def api_feishu_health(self, request: Request): ok, message = self._check_api_access(request) if not ok: return {"success": False, "message": message} channel = self._ensure_feishu_channel() return { "success": True, "message": "Agent影视助手 内置飞书入口状态", "data": { "plugin_version": self.plugin_version, "plugin_enabled": self._enabled, **channel.health(), }, } async def api_quark_health(self, request: Request): ok, message = self._check_api_access(request) if not ok: return {"success": False, "message": message} service = self._ensure_quark_service() cookie_ok, cookie_message = service.check_cookie() return { "success": True, "data": { "plugin_version": self.plugin_version, "enabled": self._enabled, "quark_cookie_configured": bool(self._quark_cookie), "quark_cookie_valid": cookie_ok, "default_target_path": self._quark_default_path, "message": "" if cookie_ok else cookie_message, }, } async def api_quark_transfer(self, request: Request): body = await request.json() ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} share_text = self._clean_text(body.get("url") or body.get("share_url") or body.get("share_text")) access_code = self._clean_text(body.get("access_code") or body.get("pwd") or body.get("code")) target_path = self._clean_text(body.get("path") or body.get("target_path")) trigger = self._clean_text(body.get("trigger") or "Agent影视助手 API") service = self._ensure_quark_service() transfer_ok, result, transfer_message = service.transfer_share( share_text, access_code=access_code, target_path=target_path, trigger=trigger, ) if not transfer_ok: return { "success": False, "message": self._format_quark_transfer_failure( detail=transfer_message, target_path=target_path or self._quark_default_path, ), "data": result, } return {"success": True, "message": transfer_message, "data": result} async def api_hdhive_health(self, request: Request): ok, message = self._check_api_access(request) if not ok: return {"success": False, "message": message} service = self._ensure_hdhive_service() ping_ok, result, ping_message, _status_code = service.request("GET", "/api/open/ping") return { "success": True, "data": { "plugin_version": self.plugin_version, "enabled": self._enabled, "hdhive_api_key_configured": bool(self._hdhive_api_key), "hdhive_ping_ok": ping_ok, "base_url": self._hdhive_base_url, "default_target_path": self._hdhive_default_path, "resource_enabled": self._hdhive_resource_enabled, "max_unlock_points": self._hdhive_max_unlock_points, "checkin_enabled": self._hdhive_checkin_enabled, "checkin_gambler_mode": self._hdhive_checkin_gambler_mode, "checkin_cron": self._hdhive_checkin_cron, "checkin_web_cookie_configured": bool(self._hdhive_checkin_cookie), "checkin_auto_login_enabled": self._hdhive_checkin_auto_login, "checkin_username_configured": bool(self._hdhive_checkin_username), "message": "" if ping_ok else ping_message, "raw": result, }, } async def api_hdhive_account(self, request: Request): ok, message = self._check_api_access(request) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} service = self._ensure_hdhive_service() account_ok, result, account_message = service.fetch_me() if not account_ok: if self._is_hdhive_premium_limited(account_message): fallback_account = self._build_hdhive_account_snapshot(self._load_hdhive_daily_sign_user_info()) if fallback_account: return { "success": True, "message": "当前返回的是网页用户快照", "data": fallback_account, } return {"success": False, "message": self._friendly_hdhive_error(account_message, "账号"), "data": result} return {"success": True, "message": result.get("message") or "success", "data": result.get("data") or {}} async def api_hdhive_checkin(self, request: Request): body = await request.json() ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} is_gambler = self._parse_bool_value(body.get("is_gambler"), self._hdhive_checkin_gambler_mode) return self._run_hdhive_checkin(is_gambler=is_gambler, trigger="Agent影视助手 API") async def api_hdhive_checkin_history(self, request: Request): ok, message = self._check_api_access(request) if not ok: return {"success": False, "message": message} limit = self._safe_int(request.query_params.get("limit"), 20) data = self._hdhive_checkin_history_public_data(limit=limit) return { "success": True, "message": self._format_hdhive_checkin_history_text(limit=limit), "data": data, } async def api_hdhive_quota(self, request: Request): ok, message = self._check_api_access(request) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} service = self._ensure_hdhive_service() quota_ok, result, quota_message = service.fetch_quota() if not quota_ok: return {"success": False, "message": self._friendly_hdhive_error(quota_message, "配额"), "data": result} return {"success": True, "message": result.get("message") or "success", "data": result.get("data") or {}} async def api_hdhive_usage_today(self, request: Request): ok, message = self._check_api_access(request) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} service = self._ensure_hdhive_service() usage_ok, result, usage_message = service.fetch_usage_today() if not usage_ok: return {"success": False, "message": self._friendly_hdhive_error(usage_message, "今日用量"), "data": result} return {"success": True, "message": result.get("message") or "success", "data": result.get("data") or {}} async def api_hdhive_weekly_free_quota(self, request: Request): ok, message = self._check_api_access(request) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} service = self._ensure_hdhive_service() weekly_ok, result, weekly_message = service.fetch_weekly_free_quota() if not weekly_ok: return {"success": False, "message": self._friendly_hdhive_error(weekly_message, "每周免费额度"), "data": result} return {"success": True, "message": result.get("message") or "success", "data": result.get("data") or {}} async def api_hdhive_search(self, request: Request): body = await request.json() ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} allowed, disabled = self._ensure_hdhive_resource_enabled() if not allowed: return disabled media_type = self._clean_text(body.get("media_type") or body.get("type") or "movie").lower() tmdb_id = self._clean_text(body.get("tmdb_id")) service = self._ensure_hdhive_service() search_ok, result, search_message = service.search_resources(media_type=media_type, tmdb_id=tmdb_id) if not search_ok: return {"success": False, "message": search_message, "data": result} return {"success": True, "message": "success", "data": result} async def api_hdhive_search_by_keyword(self, request: Request): body = await request.json() ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} allowed, disabled = self._ensure_hdhive_resource_enabled() if not allowed: return disabled keyword = self._clean_text(body.get("keyword") or body.get("title")) media_type = self._clean_text(body.get("media_type") or body.get("type") or "auto").lower() year = self._clean_text(body.get("year")) candidate_limit = self._safe_int(body.get("candidate_limit"), self._hdhive_candidate_page_size) result_limit = self._safe_int(body.get("limit"), 12) service = self._ensure_hdhive_service() search_ok, result, search_message = await service.search_resources_by_keyword( keyword=keyword, media_type=media_type, year=year, candidate_limit=candidate_limit, result_limit=result_limit, ) if not search_ok: return {"success": False, "message": search_message, "data": result} return {"success": True, "message": "success", "data": result} async def api_hdhive_unlock(self, request: Request): body = await request.json() ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} allowed, disabled = self._ensure_hdhive_resource_enabled() if not allowed: return disabled slug = self._clean_text(body.get("slug")) points_ok, points_message, points_data = self._check_hdhive_unlock_points_limit(body) if not points_ok: return {"success": False, "message": points_message, "data": {"resource_guard": points_data}} service = self._ensure_hdhive_service() unlock_ok, result, unlock_message = service.unlock_resource(slug) if not unlock_ok: return {"success": False, "message": unlock_message, "data": result} return {"success": True, "message": "success", "data": result} @staticmethod def _new_session_id(prefix: str = "aro") -> str: return f"{prefix}-{uuid.uuid4().hex[:12]}" def _save_session(self, session_id: str, payload: Dict[str, Any]) -> None: payload = dict(payload) payload["updated_at"] = int(time.time()) with self._session_lock: self._session_cache[session_id] = payload self._persist_relevant_sessions() def _load_session(self, session_id: str) -> Optional[Dict[str, Any]]: with self._session_lock: session = self._session_cache.get(session_id) if not session: return None return dict(session) def _persist_relevant_sessions(self) -> None: try: data: Dict[str, Dict[str, Any]] = {} with self._session_lock: for session_id, payload in (self._session_cache or {}).items(): session = dict(payload or {}) if self._is_session_expired(session): continue if str(session_id).startswith("assistant::") or session.get("pending_p115") or str(session.get("kind") or "").strip() == "assistant_p115_login": data[session_id] = session self.save_data(key=self._session_store_key, value=data) except Exception: pass def _restore_persisted_sessions(self) -> None: try: restored = self.get_data(self._session_store_key) or {} if isinstance(restored, dict): with self._session_lock: for session_id, payload in restored.items(): if isinstance(payload, dict) and not self._is_session_expired(payload): self._session_cache[str(session_id)] = dict(payload) except Exception: pass def _persist_execution_history(self) -> None: try: history = list(self._execution_history or [])[-self._execution_history_limit:] self._execution_history = history self.save_data(key=self._execution_history_store_key, value=history) except Exception: pass def _restore_execution_history(self) -> None: try: restored = self.get_data(self._execution_history_store_key) or [] if isinstance(restored, list): self._execution_history = [ dict(item) for item in restored[-self._execution_history_limit:] if isinstance(item, dict) ] except Exception: self._execution_history = [] def _persist_hdhive_checkin_history(self) -> None: try: history = list(self._hdhive_checkin_history or [])[-self._hdhive_checkin_history_limit:] self._hdhive_checkin_history = history self.save_data(key=self._hdhive_checkin_history_store_key, value=history) except Exception: pass def _restore_hdhive_checkin_history(self) -> None: try: restored = self.get_data(self._hdhive_checkin_history_store_key) or [] if isinstance(restored, list): self._hdhive_checkin_history = [ dict(item) for item in restored[-self._hdhive_checkin_history_limit:] if isinstance(item, dict) ] except Exception: self._hdhive_checkin_history = [] def _record_hdhive_checkin_history( self, *, trigger: str, is_gambler: bool, result: Dict[str, Any], ) -> None: timestamp = int(time.time()) payload = dict(result or {}) data = payload.get("data") if isinstance(payload.get("data"), dict) else {} entry = { "id": self._new_session_id("hdhive-sign"), "time": timestamp, "time_text": self._format_unix_time(timestamp), "trigger": self._clean_text(trigger), "mode": "赌狗签到" if is_gambler else "普通签到", "is_gambler": bool(is_gambler), "success": bool(payload.get("success")), "message_head": self._assistant_result_message_head(payload.get("message")), "status": self._clean_text(data.get("status")) or ("成功" if payload.get("success") else "失败"), "source": self._clean_text(data.get("source")), "status_code": data.get("status_code"), "login_retry": bool(((data.get("login") or {}) if isinstance(data.get("login"), dict) else {}).get("ok")), "web_fallback": bool(data.get("web_fallback")) if isinstance(data, dict) else False, } self._hdhive_checkin_history.append(entry) self._hdhive_checkin_history = self._hdhive_checkin_history[-self._hdhive_checkin_history_limit:] self._persist_hdhive_checkin_history() def _hdhive_checkin_history_public_data(self, *, limit: int = 20) -> Dict[str, Any]: max_limit = min(max(1, self._safe_int(limit, 20)), self._hdhive_checkin_history_limit) items = list(reversed(self._hdhive_checkin_history or []))[:max_limit] return { "total": len(self._hdhive_checkin_history or []), "limit": max_limit, "items": items, } def _format_hdhive_checkin_history_text(self, *, limit: int = 10) -> str: data = self._hdhive_checkin_history_public_data(limit=limit) items = data.get("items") or [] if not items: return "暂无影巢签到日志。" lines = [f"影巢签到日志:最近 {len(items)} 条"] for idx, item in enumerate(items, start=1): ok_text = "成功" if item.get("success") else "失败" parts = [ f"{idx}. {item.get('time_text') or ''}", f"{item.get('mode') or ''}", ok_text, ] if item.get("trigger"): parts.append(f"来源:{item.get('trigger')}") if item.get("login_retry"): parts.append("已自动刷新Cookie") message = self._clean_text(item.get("message_head")) if message: parts.append(message) lines.append(" | ".join(part for part in parts if part)) return "\n".join(lines) def _hdhive_resource_disabled_response(self) -> Dict[str, Any]: return { "success": False, "message": "影巢资源入口已关闭:当前不会执行影巢搜索、解锁或转存。可在插件设置中开启“影巢资源搜索/解锁”。", "data": { "provider": "hdhive", "resource_enabled": False, "error_code": "hdhive_resource_disabled", }, } def _ensure_hdhive_resource_enabled(self) -> Tuple[bool, Dict[str, Any]]: if self._hdhive_resource_enabled: return True, {} return False, self._hdhive_resource_disabled_response() def _pansou_disabled_response(self) -> Dict[str, Any]: return { "success": False, "message": "盘搜入口已关闭:当前不会执行盘搜搜索或盘搜补查。可在插件设置中开启“启用盘搜”。", "data": { "provider": "pansou", "resource_enabled": False, "error_code": "pansou_disabled", }, } def _ensure_pansou_enabled(self) -> Tuple[bool, Dict[str, Any]]: if self._pansou_enabled: return True, {} return False, self._pansou_disabled_response() def _mp_pt_disabled_response(self) -> Dict[str, Any]: return { "success": False, "message": "MP/PT 原生搜索入口已关闭:当前不会执行 MP搜索、PT搜索、下载或原生补查。可在插件设置中开启“启用 MP/PT 原生搜索/下载”。", "data": { "provider": "mp_pt", "resource_enabled": False, "error_code": "mp_pt_disabled", }, } def _ensure_mp_pt_enabled(self) -> Tuple[bool, Dict[str, Any]]: if self._mp_pt_enabled: return True, {} return False, self._mp_pt_disabled_response() def _cloud_sources_disabled_response(self) -> Dict[str, Any]: return { "success": False, "message": "盘搜和影巢都已关闭:当前无法执行云盘资源搜索或云盘转存。可在插件设置中重新开启盘搜/影巢,或直接使用 MP搜索/PT搜索。", "data": { "error_code": "cloud_sources_disabled", "pansou_enabled": self._pansou_enabled, "hdhive_resource_enabled": self._hdhive_resource_enabled, }, } def _cloud_no_result_suggestion_response( self, *, keyword: str, session: str, primary_source: str, checked_fallback: bool, fallback_source_enabled: bool, mp_pt_enabled: bool, detail: str = "", ) -> Dict[str, Any]: clean_keyword = self._clean_text(keyword) lines = [f"{primary_source}暂无结果:{clean_keyword}"] if checked_fallback: other_source = "影巢" if primary_source == "盘搜" else "盘搜" lines.append(f"已自动补查{other_source},也没有可用结果。") elif not fallback_source_enabled: other_source = "影巢" if primary_source == "盘搜" else "盘搜" lines.append(f"{other_source}已关闭,未继续补查。") if detail: lines.append(f"原因:{detail}") next_command = f"PT搜索 {clean_keyword}" if clean_keyword else "PT搜索 片名" if mp_pt_enabled: lines.append(f"如果想继续扩搜,可以回复:{next_command}") else: lines.append("MP/PT 原生搜索也已关闭;如需继续扩搜,请先在插件设置里开启。") return { "success": False, "message": "\n".join(line for line in lines if line).strip(), "data": self._assistant_response_data(session=session, data={ "action": "cloud_source_no_result", "ok": False, "keyword": clean_keyword, "primary_source": primary_source, "checked_fallback": checked_fallback, "fallback_source_enabled": fallback_source_enabled, "suggested_command": next_command if mp_pt_enabled else "", "decision_summary": { "stage": "cloud_no_result", "label": "盘搜/影巢暂无结果", "preferred_command": next_command if mp_pt_enabled else "", "fallback_command": "", "compact_commands": [next_command] if mp_pt_enabled else [], "recommended_agent_behavior": "ask_user", "can_auto_run_preferred": False, }, }), } def _all_search_sources_disabled_response(self) -> Dict[str, Any]: return { "success": False, "message": "盘搜、影巢、MP/PT 原生搜索都已关闭:当前无法继续搜索。请先在插件设置里重新开启至少一个搜索源。", "data": { "error_code": "all_search_sources_disabled", "pansou_enabled": self._pansou_enabled, "hdhive_resource_enabled": self._hdhive_resource_enabled, "mp_pt_enabled": self._mp_pt_enabled, }, } def _hdhive_checkin_disabled_response(self) -> Dict[str, Any]: return { "success": False, "message": "影巢签到入口已关闭:如需执行签到,请先在插件设置中开启“影巢签到”。", "data": { "provider": "hdhive", "checkin_enabled": False, "error_code": "hdhive_checkin_disabled", }, } @staticmethod def _resource_has_free_marker(item: Optional[Dict[str, Any]]) -> bool: if not isinstance(item, dict) or not item: return False for key in ["unlock_points", "cost", "points", "price", "point_text"]: text = str(item.get(key) or "").strip().lower() if text in {"0", "free", "免费", "0分"}: return True blob = " ".join( str(item.get(key) or "") for key in [ "title", "remark", "description", "desc", "detail", "details", "summary", "note", "source", "source_name", ] ).lower() return "免费" in blob or " free " in f" {blob} " @staticmethod def _resource_points_value(item: Optional[Dict[str, Any]]) -> Optional[int]: if not item: return None raw = item.get("unlock_points") if raw is None: raw = item.get("cost") if raw is None: raw = item.get("points") text = str(raw or "").strip() if not text: return 0 if AgentResourceOfficer._resource_has_free_marker(item) else None if text.lower() == "free" or text == "免费": return 0 match = re.search(r"-?\d+", text) if not match: return 0 if AgentResourceOfficer._resource_has_free_marker(item) else None try: return int(match.group(0)) except Exception: return None def _check_hdhive_unlock_points_limit(self, resource: Optional[Dict[str, Any]]) -> Tuple[bool, str, Dict[str, Any]]: limit = max(0, self._safe_int(self._hdhive_max_unlock_points, 20)) if limit <= 0: return True, "", {"limit": limit, "points": None, "limited": False} points = self._resource_points_value(resource) title = self._clean_text((resource or {}).get("title") or (resource or {}).get("matched_title") or "该资源") if points is None: return True, "", {"limit": limit, "points": None, "limited": False, "reason": "unknown_points", "title": title} if points > limit: return False, ( f"已阻止影巢解锁:{title} 需要 {points} 分,超过当前单资源积分上限 {limit} 分。" "如确认要解锁,请提高上限或临时设为 0。" ), {"limit": limit, "points": points, "limited": True, "reason": "points_over_limit"} return True, "", {"limit": limit, "points": points, "limited": False} def _default_assistant_preferences(self) -> Dict[str, Any]: return { "schema_version": "preferences.v1", "initialized": False, "prefer_resolution": "4K", "prefer_dolby_vision": True, "prefer_hdr": True, "prefer_chinese_subtitle": True, "prefer_complete_series": True, "prefer_cloud_provider": "", "enable_pansou": True, "enable_hdhive": True, "enable_mp_pt": True, "has_quark": True, "has_115": True, "cloud_default_path": self._hdhive_default_path, "quark_default_path": self._quark_default_path, "p115_default_path": self._p115_default_path, "pt_require_free": False, "pt_min_seeders": self._assistant_default_pt_min_seeders, "pt_prefer_free": True, "hdhive_max_unlock_points": self._hdhive_max_unlock_points, "auto_ingest_enabled": self._assistant_default_auto_ingest_enabled, "auto_ingest_score_threshold": self._assistant_default_auto_ingest_score_threshold, "confirm_score_threshold": self._assistant_default_confirm_score_threshold, "updated_at": 0, } def _normalize_preference_key(self, session: Any = None, user_key: Any = None) -> str: key = self._clean_text(user_key) if key: return key session_name = self._clean_text(session) or "default" return f"session:{session_name}" def _normalize_assistant_preferences(self, value: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: defaults = self._default_assistant_preferences() payload = dict(value or {}) if "resolution_priority" in payload and "prefer_resolution" not in payload: choices = payload.get("resolution_priority") if isinstance(choices, (list, tuple)) and choices: payload["prefer_resolution"] = choices[0] else: payload["prefer_resolution"] = choices if "subtitle_priority" in payload and "prefer_chinese_subtitle" not in payload: subtitle_text = " ".join(str(item) for item in payload.get("subtitle_priority") or []) if isinstance(payload.get("subtitle_priority"), (list, tuple)) else str(payload.get("subtitle_priority") or "") payload["prefer_chinese_subtitle"] = bool(re.search(r"中文|中字|简中|繁中|双语|chinese", subtitle_text, flags=re.IGNORECASE)) if "pt_free_only" in payload and "pt_require_free" not in payload: payload["pt_require_free"] = payload.get("pt_free_only") if "preferred_cloud_drive" in payload and "prefer_cloud_provider" not in payload: providers = payload.get("preferred_cloud_drive") payload["prefer_cloud_provider"] = providers[0] if isinstance(providers, (list, tuple)) and providers else providers if "available_sources" in payload: sources = payload.get("available_sources") if isinstance(sources, (list, tuple, set)): source_list = [self._clean_text(item).lower() for item in sources] if "enable_pansou" not in payload: payload["enable_pansou"] = "pansou" in source_list or "盘搜" in source_list if "enable_hdhive" not in payload: payload["enable_hdhive"] = any(item in source_list for item in ["hdhive", "影巢", "影潮"]) if "enable_mp_pt" not in payload: payload["enable_mp_pt"] = any(item in source_list for item in ["mp_pt", "mp", "pt", "原生", "moviepilot"]) if "available_providers" in payload: providers = payload.get("available_providers") if isinstance(providers, (list, tuple, set)): provider_list = [self._clean_text(item).lower() for item in providers] if "has_115" not in payload: payload["has_115"] = "115" in provider_list if "has_quark" not in payload: payload["has_quark"] = "quark" in provider_list or "夸克" in provider_list if "auto_ingest" in payload and "auto_ingest_enabled" not in payload: payload["auto_ingest_enabled"] = payload.get("auto_ingest") if "auto_execute_score_threshold" in payload and "auto_ingest_score_threshold" not in payload: payload["auto_ingest_score_threshold"] = payload.get("auto_execute_score_threshold") normalized = {**defaults, **payload} normalized["schema_version"] = "preferences.v1" normalized["initialized"] = bool(normalized.get("initialized")) normalized["prefer_resolution"] = self._clean_text(normalized.get("prefer_resolution") or defaults["prefer_resolution"]).upper() normalized["prefer_dolby_vision"] = self._parse_bool_value(normalized.get("prefer_dolby_vision"), True) normalized["prefer_hdr"] = self._parse_bool_value(normalized.get("prefer_hdr"), True) normalized["prefer_chinese_subtitle"] = self._parse_bool_value(normalized.get("prefer_chinese_subtitle"), True) normalized["prefer_complete_series"] = self._parse_bool_value(normalized.get("prefer_complete_series"), True) normalized["prefer_cloud_provider"] = self._clean_text(normalized.get("prefer_cloud_provider")).lower() normalized["enable_pansou"] = self._parse_bool_value(normalized.get("enable_pansou"), True) normalized["enable_hdhive"] = self._parse_bool_value(normalized.get("enable_hdhive"), True) normalized["enable_mp_pt"] = self._parse_bool_value(normalized.get("enable_mp_pt"), True) if not self._pansou_enabled: normalized["enable_pansou"] = False if not self._hdhive_resource_enabled: normalized["enable_hdhive"] = False if not self._mp_pt_enabled: normalized["enable_mp_pt"] = False normalized["has_quark"] = self._parse_bool_value(normalized.get("has_quark"), True) normalized["has_115"] = self._parse_bool_value(normalized.get("has_115"), True) normalized["cloud_default_path"] = self._normalize_path(normalized.get("cloud_default_path") or self._hdhive_default_path) normalized["quark_default_path"] = self._normalize_path(normalized.get("quark_default_path") or self._quark_default_path) normalized["p115_default_path"] = self._normalize_path(normalized.get("p115_default_path") or self._p115_default_path) normalized["pt_require_free"] = self._parse_bool_value(normalized.get("pt_require_free"), False) normalized["pt_min_seeders"] = max(0, self._safe_int(normalized.get("pt_min_seeders"), 3)) normalized["pt_prefer_free"] = self._parse_bool_value(normalized.get("pt_prefer_free"), True) normalized["hdhive_max_unlock_points"] = max(0, self._safe_int(normalized.get("hdhive_max_unlock_points"), self._hdhive_max_unlock_points)) normalized["auto_ingest_enabled"] = self._parse_bool_value(normalized.get("auto_ingest_enabled"), False) normalized["auto_ingest_score_threshold"] = max(1, min(100, self._safe_int(normalized.get("auto_ingest_score_threshold"), 90))) normalized["confirm_score_threshold"] = max(1, min(100, self._safe_int(normalized.get("confirm_score_threshold"), 70))) normalized["updated_at"] = self._safe_int(normalized.get("updated_at"), 0) return normalized def _restore_assistant_preferences(self) -> None: try: restored = self.get_data(self._assistant_preferences_store_key) or {} if isinstance(restored, dict): self._assistant_preferences = { self._clean_text(key): self._normalize_assistant_preferences(value) for key, value in restored.items() if self._clean_text(key) and isinstance(value, dict) } except Exception: self._assistant_preferences = {} def _persist_assistant_preferences(self) -> None: try: items = list((self._assistant_preferences or {}).items())[-self._assistant_preferences_limit:] self._assistant_preferences = {key: value for key, value in items if key} self.save_data(key=self._assistant_preferences_store_key, value=self._assistant_preferences) except Exception: pass def _assistant_preferences_public_data(self, *, session: str = "", user_key: str = "") -> Dict[str, Any]: key = self._normalize_preference_key(session=session, user_key=user_key) preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(key)) questions = [ "你更偏好 4K 还是 1080P?", "是否优先杜比视界 / HDR?", "是否必须中文字幕?", "电视剧是否优先全集或完整季?", "你可用哪些源?例如:只用盘搜、只用影巢、只用 MP/PT,或者关闭其中某些源。", "你可用哪些云盘?例如:只有夸克、只有 115、两者都可。", "PT 资源最低做种数是多少?默认 3。", "影巢单资源最多愿意消耗多少积分?默认 20。", "是否允许 90 分以上资源自动入库?默认关闭。", ] return { "schema_version": "preferences.v1", "key": key, "initialized": bool(preferences.get("initialized")), "preferences": preferences, "needs_onboarding": not bool(preferences.get("initialized")), "onboarding_questions": questions if not bool(preferences.get("initialized")) else [], "defaults": self._default_assistant_preferences(), } def _save_assistant_preferences( self, *, session: str = "", user_key: str = "", preferences: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: key = self._normalize_preference_key(session=session, user_key=user_key) current = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(key)) incoming = dict(preferences or {}) merged = {**current, **incoming} merged["initialized"] = self._parse_bool_value(incoming.get("initialized"), True) if "initialized" in incoming else True merged["updated_at"] = int(time.time()) normalized = self._normalize_assistant_preferences(merged) self._assistant_preferences[key] = normalized self._persist_assistant_preferences() return self._assistant_preferences_public_data(session=session, user_key=key) def _reset_assistant_preferences(self, *, session: str = "", user_key: str = "") -> Dict[str, Any]: key = self._normalize_preference_key(session=session, user_key=user_key) self._assistant_preferences.pop(key, None) self._persist_assistant_preferences() return self._assistant_preferences_public_data(session=session, user_key=key) def _assistant_preferences_status_brief(self, *, session: str = "", user_key: str = "") -> Dict[str, Any]: data = self._assistant_preferences_public_data(session=session, user_key=user_key) prefs = dict(data.get("preferences") or {}) brief = { "key": data.get("key"), "initialized": bool(data.get("initialized")), "needs_onboarding": bool(data.get("needs_onboarding")), "summary": { "prefer_resolution": self._clean_text(prefs.get("prefer_resolution")), "prefer_dolby_vision": bool(prefs.get("prefer_dolby_vision")), "prefer_hdr": bool(prefs.get("prefer_hdr")), "prefer_chinese_subtitle": bool(prefs.get("prefer_chinese_subtitle")), "prefer_complete_series": bool(prefs.get("prefer_complete_series")), "prefer_cloud_provider": self._clean_text(prefs.get("prefer_cloud_provider")), "enable_pansou": bool(prefs.get("enable_pansou")), "enable_hdhive": bool(prefs.get("enable_hdhive")), "enable_mp_pt": bool(prefs.get("enable_mp_pt")), "has_quark": bool(prefs.get("has_quark")), "has_115": bool(prefs.get("has_115")), "pt_min_seeders": self._safe_int(prefs.get("pt_min_seeders"), 3), "pt_require_free": bool(prefs.get("pt_require_free")), "hdhive_max_unlock_points": self._safe_int(prefs.get("hdhive_max_unlock_points"), self._hdhive_max_unlock_points), "auto_ingest_enabled": bool(prefs.get("auto_ingest_enabled")), "auto_ingest_score_threshold": self._safe_int(prefs.get("auto_ingest_score_threshold"), 90), }, } if brief["needs_onboarding"]: brief["onboarding_questions"] = data.get("onboarding_questions") or [] brief["recommended_action"] = "ask_user_preferences_then_save" return brief def _assistant_default_preferences_template(self) -> Dict[str, Any]: prefs = self._default_assistant_preferences() return { "prefer_resolution": prefs.get("prefer_resolution"), "prefer_dolby_vision": prefs.get("prefer_dolby_vision"), "prefer_hdr": prefs.get("prefer_hdr"), "prefer_chinese_subtitle": prefs.get("prefer_chinese_subtitle"), "prefer_complete_series": prefs.get("prefer_complete_series"), "prefer_cloud_provider": prefs.get("prefer_cloud_provider"), "enable_pansou": prefs.get("enable_pansou"), "enable_hdhive": prefs.get("enable_hdhive"), "enable_mp_pt": prefs.get("enable_mp_pt"), "has_quark": prefs.get("has_quark"), "has_115": prefs.get("has_115"), "pt_require_free": prefs.get("pt_require_free"), "pt_min_seeders": prefs.get("pt_min_seeders"), "hdhive_max_unlock_points": prefs.get("hdhive_max_unlock_points"), "p115_default_path": prefs.get("p115_default_path"), "quark_default_path": prefs.get("quark_default_path"), "auto_ingest_enabled": prefs.get("auto_ingest_enabled"), "auto_ingest_score_threshold": prefs.get("auto_ingest_score_threshold"), } def _assistant_preferences_for_session(self, session: str = "", user_key: str = "") -> Dict[str, Any]: key = self._normalize_preference_key(session=session, user_key=user_key) return self._normalize_assistant_preferences((self._assistant_preferences or {}).get(key)) def _assistant_source_enabled(self, preferences: Optional[Dict[str, Any]], source_type: str) -> bool: prefs = self._normalize_assistant_preferences(preferences) source = self._clean_text(source_type).lower() if source == "pansou": return bool(prefs.get("enable_pansou")) if source == "hdhive": return bool(prefs.get("enable_hdhive")) if source in {"mp", "mp_pt", "pt"}: return bool(prefs.get("enable_mp_pt")) return True def _assistant_available_cloud_providers(self, preferences: Optional[Dict[str, Any]]) -> List[str]: prefs = self._normalize_assistant_preferences(preferences) providers: List[str] = [] if self._parse_bool_value(prefs.get("has_115"), True): providers.append("115") if self._parse_bool_value(prefs.get("has_quark"), True): providers.append("quark") return providers def _assistant_enabled_cloud_source_order( self, *, session: str = "", source_order: Optional[List[str]] = None, session_overrides: Optional[Dict[str, Any]] = None, ) -> List[str]: preferences = self._assistant_smart_merge_session_preferences( self._assistant_preferences_for_session(session=session), session_overrides=session_overrides, ) return self._assistant_smart_search_source_order( preferences, source_order=source_order or ["pansou", "hdhive"], ) def _assistant_transfer_source_order( self, *, preferences: Optional[Dict[str, Any]], prefer_provider: str = "", requested_source_order: Optional[List[str]] = None, ) -> List[str]: prefs = self._normalize_assistant_preferences(preferences) pansou_enabled = self._assistant_source_enabled(prefs, "pansou") hdhive_enabled = self._assistant_source_enabled(prefs, "hdhive") provider = self._clean_text(prefer_provider).lower() if provider == "quark": return ["pansou"] if pansou_enabled else [] if provider == "115": return ["pansou"] if pansou_enabled else [] return self._assistant_smart_search_source_order( prefs, source_order=requested_source_order or ["pansou", "hdhive"], ) def _assistant_filter_cloud_items_by_preferences( self, items: Optional[List[Dict[str, Any]]], preferences: Optional[Dict[str, Any]], ) -> List[Dict[str, Any]]: available = set(self._assistant_available_cloud_providers(preferences)) source_items = [dict(item or {}) for item in (items or []) if isinstance(item, dict)] if not available: return [] if available == {"115", "quark"}: return source_items filtered: List[Dict[str, Any]] = [] for item in source_items: provider = self._clean_text( item.get("pan_type") or item.get("provider") or item.get("channel") or item.get("network") or item.get("netdisk") or item.get("cloud") ).lower() if provider in {"quark", "夸克", "quarkcloud"}: provider = "quark" elif provider in {"115", "115cloud", "115网盘"}: provider = "115" if provider in available: filtered.append(item) return filtered def _assistant_smart_search_source_order( self, preferences: Optional[Dict[str, Any]], source_order: Optional[List[str]] = None, ) -> List[str]: prefs = self._normalize_assistant_preferences(preferences) requested = [self._clean_text(item).lower() for item in (source_order or []) if self._clean_text(item)] order = requested or ["pansou", "hdhive", "mp_pt"] normalized: List[str] = [] for item in order: if item in {"mp", "pt"}: item = "mp_pt" if item not in {"pansou", "hdhive", "mp_pt"} or item in normalized: continue if self._assistant_source_enabled(prefs, item): normalized.append(item) return normalized def _assistant_smart_source_availability( self, preferences: Optional[Dict[str, Any]], *, source_order: Optional[List[str]] = None, ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: prefs = self._normalize_assistant_preferences(preferences) available_cloud = self._assistant_available_cloud_providers(prefs) order = self._assistant_smart_search_source_order(prefs, source_order=source_order) source_labels = { "pansou": "盘搜", "hdhive": "影巢", "mp_pt": "MP/PT", } available: List[Dict[str, Any]] = [] blocked: List[Dict[str, Any]] = [] for source in ["pansou", "hdhive", "mp_pt"]: entry = { "source_type": source, "label": source_labels[source], } if source not in order: if not self._assistant_source_enabled(prefs, source): blocked.append({**entry, "reason": f"当前偏好已关闭{source_labels[source]}"}) else: blocked.append({**entry, "reason": "当前决策顺序未包含该源"}) continue if source in {"pansou", "hdhive"} and not available_cloud: blocked.append({**entry, "reason": "当前偏好未启用任何云盘"}) continue available.append({ **entry, "provider_scope": list(available_cloud) if source in {"pansou", "hdhive"} else ["pt"], }) if "115" not in available_cloud: blocked.append({"source_type": "115", "label": "115", "reason": "当前偏好未启用 115"}) if "quark" not in available_cloud: blocked.append({"source_type": "quark", "label": "夸克", "reason": "当前偏好未启用夸克"}) return available, blocked def _assistant_smart_decision_preferences( self, preferences: Optional[Dict[str, Any]], *, decision_profile: str = "", ) -> Dict[str, Any]: prefs = self._normalize_assistant_preferences(preferences) profile = self._clean_text(decision_profile).lower() if not profile: return prefs updated = dict(prefs) confirm_threshold = self._safe_int( updated.get("confirm_score_threshold"), self._assistant_default_confirm_score_threshold, ) if profile == "conservative": updated["confirm_score_threshold"] = max(confirm_threshold, min(95, confirm_threshold + 10)) elif profile == "aggressive": updated["confirm_score_threshold"] = max(40, min(100, confirm_threshold - 10)) return self._normalize_assistant_preferences(updated) def _assistant_smart_session_overrides_summary(self, overrides: Optional[Dict[str, Any]]) -> str: raw = dict(overrides or {}) if not raw: return "" payload = self._normalize_assistant_preferences(raw) parts: List[str] = [] if "has_quark" in raw or "has_115" in raw: if payload.get("has_quark") and not payload.get("has_115"): parts.append("只用夸克") elif payload.get("has_115") and not payload.get("has_quark"): parts.append("只用115") elif payload.get("has_115") and payload.get("has_quark"): parts.append("115 / 夸克都可") if "enable_pansou" in raw or "enable_hdhive" in raw or "enable_mp_pt" in raw: enabled_sources: List[str] = [] if payload.get("enable_pansou"): enabled_sources.append("盘搜") if payload.get("enable_hdhive"): enabled_sources.append("影巢") if payload.get("enable_mp_pt"): enabled_sources.append("MP/PT") if enabled_sources: parts.append("仅会话源:" + " / ".join(enabled_sources)) return ";".join(parts[:2]) def _assistant_smart_merge_session_preferences( self, preferences: Optional[Dict[str, Any]], *, session_overrides: Optional[Dict[str, Any]] = None, decision_profile: str = "", ) -> Dict[str, Any]: merged = { **self._normalize_assistant_preferences(preferences), **dict(session_overrides or {}), } return self._assistant_smart_decision_preferences( merged, decision_profile=decision_profile, ) def _assistant_smart_apply_session_override( self, session_overrides: Optional[Dict[str, Any]], *, adjust_action: str, source_order: Optional[List[str]] = None, ) -> Tuple[Dict[str, Any], List[str]]: overrides = dict(session_overrides or {}) order = [self._clean_text(item).lower() for item in (source_order or []) if self._clean_text(item)] normalized_action = self._clean_text(adjust_action).lower() if normalized_action == "decision_only_quark": overrides["has_quark"] = True overrides["has_115"] = False overrides["prefer_cloud_provider"] = "quark" elif normalized_action == "decision_only_115": overrides["has_quark"] = False overrides["has_115"] = True overrides["prefer_cloud_provider"] = "115" elif normalized_action == "decision_cloud_both": overrides["has_quark"] = True overrides["has_115"] = True overrides["prefer_cloud_provider"] = "" elif normalized_action == "decision_only_mp_pt": overrides["enable_pansou"] = False overrides["enable_hdhive"] = False overrides["enable_mp_pt"] = True order = ["mp_pt"] elif normalized_action == "decision_only_pansou": overrides["enable_pansou"] = True overrides["enable_hdhive"] = False overrides["enable_mp_pt"] = False order = ["pansou"] elif normalized_action == "decision_only_hdhive": overrides["enable_pansou"] = False overrides["enable_hdhive"] = True overrides["enable_mp_pt"] = False order = ["hdhive"] elif normalized_action == "decision_disable_pansou": overrides["enable_pansou"] = False elif normalized_action == "decision_disable_hdhive": overrides["enable_hdhive"] = False elif normalized_action == "decision_disable_mp_pt": overrides["enable_mp_pt"] = False elif normalized_action == "decision_reset_preferences": overrides = {} order = [] return overrides, order def _parse_assistant_preferences_text(self, text: str) -> Dict[str, Any]: raw = self._clean_text(text) compact = re.sub(r"\s+", "", raw).lower() payload: Dict[str, Any] = {} if re.search(r"1080|fhd", raw, flags=re.IGNORECASE): payload["prefer_resolution"] = "1080P" elif re.search(r"4k|2160|uhd", raw, flags=re.IGNORECASE): payload["prefer_resolution"] = "4K" if re.search(r"(不要|不需要|关闭|禁用).{0,4}(杜比|dv)", raw, flags=re.IGNORECASE): payload["prefer_dolby_vision"] = False elif re.search(r"杜比|dv|dolby", raw, flags=re.IGNORECASE): payload["prefer_dolby_vision"] = True if re.search(r"(不要|不需要|关闭|禁用).{0,4}hdr", raw, flags=re.IGNORECASE): payload["prefer_hdr"] = False elif re.search(r"hdr", raw, flags=re.IGNORECASE): payload["prefer_hdr"] = True if re.search(r"(不要|不需要|关闭|禁用).{0,6}(中字|中文|字幕)", raw, flags=re.IGNORECASE) or re.search(r"无字幕也可|字幕无所谓", raw): payload["prefer_chinese_subtitle"] = False elif re.search(r"中字|中文|简中|繁中|双语字幕|字幕", raw, flags=re.IGNORECASE): payload["prefer_chinese_subtitle"] = True if re.search(r"不强求全集|不要全集|单集也可|更新也可", raw): payload["prefer_complete_series"] = False elif re.search(r"全集|完整季|整季|完结", raw): payload["prefer_complete_series"] = True if re.search(r"夸克优先|优先夸克|quark", raw, flags=re.IGNORECASE): payload["prefer_cloud_provider"] = "quark" elif re.search(r"115优先|优先115", raw): payload["prefer_cloud_provider"] = "115" if re.search(r"只用盘搜|仅用盘搜|只搜盘搜", raw): payload["enable_pansou"] = True payload["enable_hdhive"] = False payload["enable_mp_pt"] = False elif re.search(r"只用影巢|仅用影巢|只搜影巢|只用影潮", raw): payload["enable_pansou"] = False payload["enable_hdhive"] = True payload["enable_mp_pt"] = False elif re.search(r"只用原生|只用pt|仅用pt|只搜pt|只搜原生|只要原生", raw, flags=re.IGNORECASE): payload["enable_pansou"] = False payload["enable_hdhive"] = False payload["enable_mp_pt"] = True if re.search(r"(不要|不用|关闭|禁用).{0,4}盘搜", raw): payload["enable_pansou"] = False elif re.search(r"(启用|使用|打开).{0,4}盘搜", raw): payload["enable_pansou"] = True if re.search(r"(不要|不用|关闭|禁用).{0,4}(影巢|影潮)", raw): payload["enable_hdhive"] = False elif re.search(r"(启用|使用|打开).{0,4}(影巢|影潮)", raw): payload["enable_hdhive"] = True if re.search(r"(不要|不用|关闭|禁用).{0,4}(pt|原生)", raw, flags=re.IGNORECASE): payload["enable_mp_pt"] = False elif re.search(r"(启用|使用|打开).{0,4}(pt|原生)", raw, flags=re.IGNORECASE): payload["enable_mp_pt"] = True if re.search(r"只有夸克|仅有夸克|只用夸克|没有115|不用115", raw, flags=re.IGNORECASE): payload["has_quark"] = True payload["has_115"] = False elif re.search(r"只有115|仅有115|只用115|没有夸克|不用夸克", raw, flags=re.IGNORECASE): payload["has_115"] = True payload["has_quark"] = False if re.search(r"(不要|不用|关闭|禁用).{0,4}夸克", raw, flags=re.IGNORECASE): payload["has_quark"] = False elif re.search(r"(启用|使用|打开).{0,4}夸克", raw, flags=re.IGNORECASE): payload["has_quark"] = True if re.search(r"(不要|不用|关闭|禁用).{0,4}115", raw): payload["has_115"] = False elif re.search(r"(启用|使用|打开).{0,4}115", raw): payload["has_115"] = True if re.search(r"pt.{0,4}(只要|必须).{0,4}免费|只下免费|只要免费", raw, flags=re.IGNORECASE): payload["pt_require_free"] = True elif re.search(r"pt.{0,4}(不限|不强求).{0,4}免费|不只要免费|免费不强求", raw, flags=re.IGNORECASE): payload["pt_require_free"] = False seed_match = re.search(r"(?:做种|种子|seeders?|seeder)[^\d]{0,8}(\d+)", raw, flags=re.IGNORECASE) if seed_match: payload["pt_min_seeders"] = self._safe_int(seed_match.group(1), 3) points_match = re.search(r"(?:影巢|积分|解锁)[^\d]{0,10}(\d+)", raw) if points_match: payload["hdhive_max_unlock_points"] = self._safe_int(points_match.group(1), self._hdhive_max_unlock_points) if re.search(r"(不|不要|关闭|禁用).{0,4}自动入库", raw): payload["auto_ingest_enabled"] = False elif re.search(r"自动入库|自动下载|自动转存", raw): payload["auto_ingest_enabled"] = True threshold_match = re.search(r"(?:自动入库(?:评分|分数|阈值)|评分阈值|分数阈值|阈值)[^\d]{0,10}(\d{2,3})", raw) if threshold_match: payload["auto_ingest_score_threshold"] = self._safe_int(threshold_match.group(1), 90) for key, target in [ ("115目录", "p115_default_path"), ("p115目录", "p115_default_path"), ("夸克目录", "quark_default_path"), ("quark目录", "quark_default_path"), ("云盘目录", "cloud_default_path"), ]: match = re.search(rf"{re.escape(key)}\s*=\s*([^\s,,]+)", raw, flags=re.IGNORECASE) if match: payload[target] = self._normalize_path(match.group(1)) if "initialized" not in payload and payload: payload["initialized"] = True if "auto_ingest_score_threshold" in payload: payload["auto_ingest_score_threshold"] = max(1, min(100, self._safe_int(payload["auto_ingest_score_threshold"], 90))) if "pt_min_seeders" in payload: payload["pt_min_seeders"] = max(0, self._safe_int(payload["pt_min_seeders"], 3)) if "hdhive_max_unlock_points" in payload: payload["hdhive_max_unlock_points"] = max(0, self._safe_int(payload["hdhive_max_unlock_points"], self._hdhive_max_unlock_points)) return payload @staticmethod def _score_text_blob(item: Any) -> str: if isinstance(item, dict): parts: List[str] = [] for value in item.values(): if isinstance(value, (dict, list, tuple)): parts.append(AgentResourceOfficer._score_text_blob(value)) elif value is not None: parts.append(str(value)) return " ".join(parts).lower() if isinstance(item, (list, tuple)): return " ".join(AgentResourceOfficer._score_text_blob(value) for value in item).lower() return str(item or "").lower() @staticmethod def _score_has_any(text: str, keywords: List[str]) -> bool: return any(keyword.lower() in text for keyword in keywords) @classmethod def _score_quality_rank(cls, item: Any) -> int: text = cls._score_text_blob(item) rank = 0 if cls._score_has_any(text, ["dolby vision", "dovi", "dv", "杜比视界"]): rank += 60 if cls._score_has_any(text, ["hdr10", "hdr", "hlg", "杜比", "臻彩"]): rank += 45 if cls._score_has_any(text, ["60fps", "60帧", "50fps", "50帧", "高帧率"]): rank += 35 if cls._score_has_any(text, ["4k", "2160", "uhd"]): rank += 25 if cls._score_has_any(text, ["高码率", "remux", "原盘", "web-dl", "webrip"]): rank += 15 if cls._score_has_any(text, ["e01", "s01", "全集", "全季", "更新至", "更至"]): rank += 10 if cls._score_has_any(text, ["中字", "简中", "繁中", "内封", "内嵌", "字幕"]): rank += 8 return rank @staticmethod def _extract_series_progress(text: str) -> Dict[str, int]: blob = str(text or "").lower() result = {"max_episode": 0, "episode_count": 0} episodes: List[int] = [] for pattern in ( r"\be(\d{1,3})\b", r"第\s*(\d{1,3})\s*集", r"更新(?:至)?\s*(?:ep|e)?\s*0*(\d{1,3})", r"更(?:至)?\s*0*(\d{1,3})\s*集", ): for match in re.finditer(pattern, blob, flags=re.IGNORECASE): try: episodes.append(int(match.group(1))) except Exception: continue for pattern in ( r"e0*(\d{1,3})\s*[-~—]\s*e?0*(\d{1,3})", r"第?\s*0*(\d{1,3})\s*[-~—]\s*0*(\d{1,3})\s*集", ): match = re.search(pattern, blob, flags=re.IGNORECASE) if not match: continue try: start_ep = int(match.group(1)) end_ep = int(match.group(2)) except Exception: continue if end_ep >= start_ep > 0: result["episode_count"] = max(result["episode_count"], end_ep - start_ep + 1) episodes.extend([start_ep, end_ep]) if episodes: result["max_episode"] = max(episodes) if result["episode_count"] <= 0 and len(set(episodes)) >= 2: sorted_eps = sorted(set(episodes)) if sorted_eps == list(range(sorted_eps[0], sorted_eps[-1] + 1)): result["episode_count"] = len(sorted_eps) return result @staticmethod def _score_level(score: int) -> str: if score >= 90: return "excellent" if score >= 70: return "confirm" return "low" def _score_decision( self, *, score: int, risk_reasons: List[str], hard_risk_reasons: Optional[List[str]] = None, preferences: Dict[str, Any], default_action: str, ) -> Dict[str, Any]: threshold = max(1, min(100, self._safe_int(preferences.get("auto_ingest_score_threshold"), 90))) confirm_threshold = max(1, min(100, self._safe_int(preferences.get("confirm_score_threshold"), 70))) auto_enabled = self._parse_bool_value(preferences.get("auto_ingest_enabled"), False) hard_risks = [self._clean_text(item) for item in (hard_risk_reasons or []) if self._clean_text(item)] hard_risk = bool(hard_risks) can_auto = bool(auto_enabled and not hard_risk and score >= threshold) if can_auto: recommended = default_action elif score >= confirm_threshold and not hard_risk: recommended = "ask_confirm" else: recommended = "do_not_auto" return { "score": score, "score_level": self._score_level(score), "risk_reasons": risk_reasons, "hard_risk_reasons": hard_risks, "can_auto_execute": can_auto, "recommended_action": recommended, "auto_ingest_enabled": auto_enabled, "auto_ingest_score_threshold": threshold, "confirm_score_threshold": confirm_threshold, } def _score_cloud_resource( self, item: Dict[str, Any], *, preferences: Optional[Dict[str, Any]] = None, source_type: str = "cloud", target_path: str = "", ) -> Dict[str, Any]: prefs = self._normalize_assistant_preferences(preferences) text = self._score_text_blob(item) score = 20 reasons: List[str] = [] risks: List[str] = [] hard_risks: List[str] = [] resolution_pref = self._clean_text(prefs.get("prefer_resolution")).lower() provider = self._clean_text(item.get("pan_type") or item.get("channel")).lower() media_type = self._clean_text(item.get("media_type") or item.get("type")).lower() series_like = ( media_type in {"tv", "series", "电视剧", "剧集", "番剧"} or bool(re.search(r"\bs\d{1,2}\b|\be\d{1,3}\b|season|第\s*\d+\s*集|[全整]季|全集|完结|更至|更新至|短剧|剧集", text, flags=re.IGNORECASE)) ) series_progress = self._extract_series_progress(text) if "2160" in text or "4k" in text or "uhd" in text: score += 25 reasons.append("4K/UHD +25") elif "1080" in text: score += 16 reasons.append("1080P +16") elif "720" in text: score += 6 reasons.append("720P +6") if resolution_pref and resolution_pref in text: score += 5 reasons.append(f"匹配偏好分辨率 {resolution_pref.upper()} +5") if self._score_has_any(text, ["dolby vision", "dovi", "dv", "杜比视界"]): score += 18 reasons.append("杜比视界 +18") elif self._score_has_any(text, ["hdr10", "hdr", "hlg", "杜比"]): score += 12 reasons.append("HDR +12") if self._score_has_any(text, ["60fps", "60帧", "50fps", "50帧", "高帧率"]): score += 4 reasons.append("高帧率 +4") if self._score_has_any(text, ["中字", "中文字幕", "简中", "繁中", "内封简繁", "双语", "官中"]): score += 14 reasons.append("中文字幕 +14") elif self._parse_bool_value(prefs.get("prefer_chinese_subtitle"), True): score -= 5 risks.append("未识别到中文字幕") completeness_detected = self._score_has_any(text, ["全集", "全季", "完结", "complete", "全 ", "更至", "更0", "更新至"]) if not completeness_detected and series_like and series_progress.get("episode_count", 0) >= 3: completeness_detected = True if completeness_detected: score += 12 reasons.append("完整度信息 +12") elif series_like and self._parse_bool_value(prefs.get("prefer_complete_series"), True): score -= 6 risks.append("未识别到全集/更新完整度") if series_like and series_progress.get("max_episode", 0) > 0: progress_bonus = min(10, series_progress["max_episode"]) if progress_bonus > 0: score += progress_bonus reasons.append(f"更新到 E{series_progress['max_episode']:02d} +{progress_bonus}") if series_progress["max_episode"] >= 7: latest_bonus = min(4, series_progress["max_episode"] - 6) if latest_bonus > 0: score += latest_bonus reasons.append(f"更新进度领先 +{latest_bonus}") if series_like and series_progress.get("episode_count", 0) >= 3: pack_bonus = min(12, series_progress["episode_count"] + 2) score += pack_bonus reasons.append(f"连续剧集包 +{pack_bonus}") if series_progress["episode_count"] >= series_progress.get("max_episode", 0) >= 3: score += 4 reasons.append("从 E01 连续覆盖到当前 +4") if self._score_has_any(text, ["remux", "原盘", "blu-ray", "bluray", "web-dl", "高码率"]): score += 8 reasons.append("片源质量标识 +8") prefer_provider = self._clean_text(prefs.get("prefer_cloud_provider")).lower() if prefer_provider and provider and prefer_provider == provider: score += 5 reasons.append(f"匹配网盘偏好 {provider} +5") if provider == "115" and not self._parse_bool_value(prefs.get("has_115"), True): message = "当前偏好未启用 115" risks.append(message) hard_risks.append(message) score -= 30 if provider == "quark" and not self._parse_bool_value(prefs.get("has_quark"), True): message = "当前偏好未启用夸克" risks.append(message) hard_risks.append(message) score -= 30 if target_path and target_path in { self._clean_text(prefs.get("cloud_default_path")), self._clean_text(prefs.get("p115_default_path")), self._clean_text(prefs.get("quark_default_path")), }: score += 3 reasons.append("匹配默认目录 +3") if source_type == "hdhive": limit = max(0, self._safe_int(prefs.get("hdhive_max_unlock_points"), self._hdhive_max_unlock_points)) points = self._resource_points_value(item) if points == 0: score += 10 reasons.append("影巢免费 +10") elif points is None and limit > 0: reasons.append("影巢未标积分") elif limit > 0 and points is not None and points > limit: score -= 30 message = f"影巢积分 {points} 超过上限 {limit},禁止自动解锁" risks.append(message) hard_risks.append(message) elif points is not None: score += max(0, 10 - points) reasons.append(f"影巢积分 {points}") final_score = max(0, min(100, score)) decision = self._score_decision( score=final_score, risk_reasons=risks, hard_risk_reasons=hard_risks, preferences=prefs, default_action="auto_ingest_cloud", ) return { **decision, "source_type": source_type, "score_reasons": reasons, } def _score_pt_resource( self, item: Dict[str, Any], *, preferences: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: prefs = self._normalize_assistant_preferences(preferences) text = self._score_text_blob(item) torrent = item.get("torrent_info") if isinstance(item.get("torrent_info"), dict) else item meta = item.get("meta_info") if isinstance(item.get("meta_info"), dict) else {} media = item.get("media_info") if isinstance(item.get("media_info"), dict) else {} score = 20 reasons: List[str] = [] risks: List[str] = [] hard_risks: List[str] = [] min_seeders = max(0, self._safe_int(prefs.get("pt_min_seeders"), 3)) seeders = self._safe_int(torrent.get("seeders"), 0) peers = self._safe_int(torrent.get("peers"), 0) volume = self._clean_text(torrent.get("volume_factor") or item.get("volume_factor")).lower() title = self._clean_text(torrent.get("title")) site_name = self._clean_text(torrent.get("site_name")) group_name = self._clean_text(meta.get("resource_team")) media_title = self._clean_text(media.get("title")).lower() media_year = self._clean_text(media.get("year")) resolution_pref = self._clean_text(prefs.get("prefer_resolution")).lower() prefer_dovi = self._parse_bool_value(prefs.get("prefer_dolby_vision"), True) prefer_hdr = self._parse_bool_value(prefs.get("prefer_hdr"), True) prefer_subtitle = self._parse_bool_value(prefs.get("prefer_chinese_subtitle"), True) prefer_complete = self._parse_bool_value(prefs.get("prefer_complete_series"), True) if seeders <= 0: message = "做种数 0,禁止自动下载" risks.append(message) hard_risks.append(message) score -= 35 elif seeders < min_seeders: message = f"做种数 {seeders},低于阈值 {min_seeders},禁止自动下载" risks.append(message) hard_risks.append(message) score -= 25 elif seeders >= 20: score += 22 reasons.append(f"做种数 {seeders} +22") elif seeders >= 10: score += 16 reasons.append(f"做种数 {seeders} +16") else: score += 10 reasons.append(f"做种数 {seeders} +10") if peers >= seeders * 5 and seeders < 5: score -= 8 risks.append("下载需求高但做种偏低") elif peers >= max(8, seeders): score += 6 reasons.append(f"下载热度 {peers} +6") elif peers >= 3: score += 3 reasons.append(f"下载热度 {peers} +3") if self._score_has_any(volume, ["free", "免费", "2xfree", "2x free", "freeleech"]): score += 18 reasons.append("免费/促销 +18") elif self._parse_bool_value(prefs.get("pt_require_free"), False): score -= 20 message = "用户要求 PT 免费资源" risks.append(message) hard_risks.append(message) elif self._parse_bool_value(prefs.get("pt_prefer_free"), True): reasons.append("普通 PT 资源,未额外扣分") if "2160" in text or "4k" in text or "uhd" in text: score += 20 reasons.append("4K/UHD +20") elif "1080" in text: score += 12 reasons.append("1080P +12") elif "720" in text: score += 4 reasons.append("720P +4") if resolution_pref and resolution_pref in text: score += 6 reasons.append(f"匹配偏好分辨率 {resolution_pref.upper()} +6") has_dovi = self._score_has_any(text, ["dolby vision", "dovi", "dv", "杜比视界"]) has_hdr = self._score_has_any(text, ["hdr10", "hdr", "hlg"]) if has_dovi: score += 14 reasons.append("杜比视界 +14") elif prefer_dovi: score -= 4 risks.append("未识别到杜比视界") if has_hdr: score += 9 reasons.append("HDR +9") elif prefer_hdr: score -= 2 risks.append("未识别到 HDR") if self._score_has_any(text, ["中字", "简中", "繁中", "双语", "内封", "字幕"]): score += 10 reasons.append("字幕信息 +10") elif prefer_subtitle: score -= 5 risks.append("未识别到中文字幕") if self._score_has_any(text, ["remux", "原盘", "blu-ray", "bluray", "web-dl", "高码率"]): score += 8 reasons.append("片源质量标识 +8") if self._score_has_any(text, ["s01", "season", "全集", "全季", "complete", "完结", "更新至", "更至"]): score += 6 reasons.append("季/全集标识 +6") elif prefer_complete and self._score_has_any(text, ["s01", "s02", "e01", "第1集", "season"]): score -= 4 risks.append("未识别到全集/完整季") if media_title: if media_title in title.lower(): score += 8 reasons.append("标题匹配媒体名 +8") else: score -= 4 risks.append("标题与识别媒体名匹配一般") if media_year and media_year in title: score += 4 reasons.append(f"年份匹配 {media_year} +4") if site_name: score += 2 reasons.append(f"站点标识 {site_name} +2") if group_name: score += 2 reasons.append(f"发布组 {group_name} +2") size_text = self._clean_text(torrent.get("size") or item.get("size")) size_value = 0.0 size_match = re.search(r"(\d+(?:\.\d+)?)\s*(tb|gb|mb)", size_text, flags=re.IGNORECASE) if size_match: size_value = float(size_match.group(1)) unit = size_match.group(2).lower() if unit == "tb": size_value *= 1024 elif unit == "mb": size_value /= 1024 if size_value > 0: if self._score_has_any(text, ["2160", "4k", "uhd", "remux", "原盘"]) and size_value < 8: score -= 6 risks.append(f"体积 {size_text} 偏小,需留意是否压制过度") elif self._score_has_any(text, ["1080", "web-dl", "bluray"]) and size_value >= 4: score += 3 reasons.append(f"体积 {size_text} 较充足 +3") if peers >= 30 and seeders >= min_seeders: score += 4 reasons.append("热度稳定 +4") final_score = max(0, min(100, score)) decision = self._score_decision( score=final_score, risk_reasons=risks, hard_risk_reasons=hard_risks, preferences=prefs, default_action="auto_download_pt", ) return { **decision, "source_type": "pt", "score_reasons": reasons, "seeders": seeders, "peers": peers, "min_seeders": min_seeders, "volume_factor": volume, "site_name": site_name, "resource_team": group_name, } def _attach_cloud_scores( self, items: List[Dict[str, Any]], *, preferences: Optional[Dict[str, Any]] = None, source_type: str = "cloud", target_path: str = "", ) -> List[Dict[str, Any]]: return [ { **dict(item or {}), "score": self._score_cloud_resource( dict(item or {}), preferences=preferences, source_type=source_type, target_path=target_path, ), } for item in items if isinstance(item, dict) ] def _rank_pansou_items( self, items: List[Dict[str, Any]], *, channel_order: Optional[List[str]] = None, limit_per_channel: int = 20, ) -> List[Dict[str, Any]]: grouped: Dict[str, List[Dict[str, Any]]] = {} for item in items: if not isinstance(item, dict): continue channel = self._normalize_pansou_channel_name(item.get("channel")) grouped.setdefault(channel, []).append(dict(item)) def sort_key(entry: Dict[str, Any]) -> Tuple[Any, ...]: score = entry.get("score") if isinstance(entry.get("score"), dict) else {} score_value = self._safe_int(score.get("score"), 0) hard_risk_count = len(score.get("hard_risk_reasons") or []) risk_count = len(score.get("risk_reasons") or []) dt = self._clean_text(entry.get("datetime")) note = self._clean_text(entry.get("note")) return ( score_value, self._score_quality_rank(entry), -hard_risk_count, -risk_count, dt, len(note), ) ordered_channels = channel_order or ["115", "quark"] ranked: List[Dict[str, Any]] = [] for channel in ordered_channels: channel_items = sorted(grouped.get(channel) or [], key=sort_key, reverse=True) ranked.extend(channel_items[:max(1, limit_per_channel)]) for index, item in enumerate(ranked, start=1): item["index"] = index return ranked def _public_pansou_items(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: public_items: List[Dict[str, Any]] = [] for item in items or []: if not isinstance(item, dict): continue public_item = { "index": self._safe_int(item.get("index"), 0), "channel": self._normalize_pansou_channel_name(item.get("channel")), "note": self._clean_text(item.get("note")), "source": self._clean_text(item.get("source")), "datetime": self._clean_text(item.get("datetime")), "display_datetime": self._format_pansou_display_datetime(item.get("datetime")), "password": self._clean_text(item.get("password")), "url": self._clean_text(item.get("url")), "brief_summary": self._pansou_item_brief_summary(item), } for extra_key in [ "title", "remark", "description", "desc", "detail", "details", "summary", "share_size", "size", "subtitle", "subtitles", "subtitle_language", "subtitle_languages", "video_resolution", "videoFormat", "media_type", "type", "episode", "episodes", "episode_range", "update_status", "update_info", ]: value = item.get(extra_key) if value not in (None, "", []): public_item[extra_key] = value public_items.append(public_item) return public_items @staticmethod def _public_hdhive_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: public_items: List[Dict[str, Any]] = [] for item in items or []: if not isinstance(item, dict): continue public_items.append({ "index": AgentResourceOfficer._safe_int(item.get("index"), 0), "cloud_index": AgentResourceOfficer._safe_int(item.get("cloud_index"), 0), "title": AgentResourceOfficer._clean_text(item.get("title")), "pan_type": AgentResourceOfficer._clean_text(item.get("pan_type")), "share_size": AgentResourceOfficer._list_text(item.get("share_size") or item.get("size")), "source": AgentResourceOfficer._list_text(item.get("source")), "subtitle_text": AgentResourceOfficer._resource_subtitle_text(item), "episode_text": AgentResourceOfficer._resource_episode_text(item), "remark": AgentResourceOfficer._resource_remark_text(item), "points_text": AgentResourceOfficer._resource_points_text(item), "video_resolution": item.get("video_resolution") or [], "slug": AgentResourceOfficer._clean_text(item.get("slug")), }) return public_items @staticmethod def _prepend_search_note(message: str, note: str) -> str: clean_message = str(message or "").strip() clean_note = str(note or "").strip() if not clean_note: return clean_message if not clean_message: return clean_note return f"{clean_note}\n\n{clean_message}" def _assistant_finalize_pansou_result( self, *, session: str, cache_key: str, keyword: str, items: List[Dict[str, Any]], total: int, target_path: str, action_name: str, search_scope: str, recommend_handoff: Optional[Dict[str, Any]] = None, lead_note: str = "", ) -> Dict[str, Any]: page_size = self._assistant_result_page_size self._save_session( cache_key, { "kind": "assistant_pansou", "stage": "result", "keyword": keyword, "target_path": target_path or self._hdhive_default_path, "items": items, "total": self._safe_int(total, len(items)), "page": 1, "page_size": page_size, **({"recommend_handoff": dict(recommend_handoff)} if recommend_handoff else {}), }, ) text_message = self._format_pansou_text(keyword, items, total, page=1, page_size=page_size) if lead_note: text_message = self._prepend_search_note(text_message, lead_note) public_items = self._public_pansou_items(items) result_data = { "action": action_name, "ok": True, "items": public_items, "total": self._safe_int(total, len(items)), "page": 1, "page_size": page_size, "total_pages": max(1, (len(items) + page_size - 1) // page_size) if items else 1, "decision_summary": self._assistant_pansou_entry_summary(items), "search_scope": search_scope, } if recommend_handoff: saved_state = self._load_session(cache_key) or {} result_data.update(self._assistant_recommend_handoff_short_metadata(saved_state)) result_data["decision_summary"] = self._assistant_recommend_handoff_entry_summary(saved_state) return { "success": True, "message": text_message, "data": self._assistant_response_data(session=session, data=result_data), } @staticmethod def _pick_cloud_hdhive_candidate( keyword: str, candidates: List[Dict[str, Any]], year: str = "", ) -> Dict[str, Any]: clean_year = AgentResourceOfficer._clean_text(year)[:4] if not candidates: return {} if len(candidates) == 1: return dict(candidates[0] or {}) if not clean_year: return {} matched = [ dict(item or {}) for item in candidates if AgentResourceOfficer._clean_text((item or {}).get("year"))[:4] == clean_year ] if len(matched) == 1: return matched[0] return {} def _format_pansou_body_lines(self, items: List[Dict[str, Any]], total: int) -> List[str]: count_115 = len([x for x in items if x.get("channel") == "115"]) count_quark = len([x for x in items if x.get("channel") == "quark"]) lines = [f"共找到 {total} 条结果,当前展示 115 {count_115} 条、夸克 {count_quark} 条:"] seen_115 = False seen_quark = False for cached in items: idx = cached["index"] channel = cached["channel"] if channel == "115" and not seen_115: if lines and lines[-1] != "": lines.append("") lines.append("🟦 115 结果") lines.append("") seen_115 = True elif channel == "quark" and not seen_quark: if lines and lines[-1] != "": lines.append("") lines.append("🟨 夸克结果") lines.append("") seen_quark = True display_datetime = self._format_pansou_display_datetime(cached.get("datetime")) date_suffix = f" — {display_datetime}" if display_datetime else "" lines.append(f"{idx}. {self._format_pansou_list_title(cached)}{date_suffix}") if cached.get("password"): lines.append(f" 提取码:{cached['password']}") brief = self._pansou_item_brief_summary(cached) if brief: lines.append(f" 摘要:{brief}") return lines @classmethod def _format_pansou_list_title(cls, item: Dict[str, Any]) -> str: channel = cls._normalize_pansou_channel_name(item.get("channel")) emoji = "📺" if channel == "115" else "🗄" if channel == "quark" else "🔗" title = cls._clean_text(item.get("note") or item.get("title") or "未命名资源") title = re.sub(r"^\s*\[(?:115|quark|QUARK)\]\s*", "", title).strip() title = re.sub(r"^\s*#(?:剧集|电影|动漫|资源)\s*", "", title).strip() title = cls._truncate_text(title, 110) if title.startswith(("📺", "🗄", "🗃", "📁", "🔗")): return title return f"{emoji} {title}".strip() def _format_pansou_text( self, keyword: str, items: List[Dict[str, Any]], total: int, *, page: int = 1, page_size: int = 10, ) -> str: safe_page, safe_page_size, total_pages, start, end = self._page_bounds(len(items), page=page, page_size=page_size) page_items = items[start:end] count_115 = len([x for x in page_items if x.get("channel") == "115"]) count_quark = len([x for x in page_items if x.get("channel") == "quark"]) first_visible_index = self._safe_int((page_items[0] or {}).get("index"), 1) if page_items else 1 last_visible_index = self._safe_int((page_items[-1] or {}).get("index"), first_visible_index) if page_items else first_visible_index lines = [ f"盘搜搜索:{keyword}", f"共找到 {total} 条结果,当前第 {safe_page}/{total_pages} 页,展示全局编号 {first_visible_index}-{last_visible_index}(本次缓存 {len(items)} 条候选),本页 115 {count_115} 条、夸克 {count_quark} 条:", ] seen_115 = False seen_quark = False for cached in page_items: idx = cached["index"] channel = cached["channel"] if channel == "115" and not seen_115: if lines and lines[-1] != "": lines.append("") lines.append("🟦 115 结果") lines.append("") seen_115 = True elif channel == "quark" and not seen_quark: if lines and lines[-1] != "": lines.append("") lines.append("🟨 夸克结果") lines.append("") seen_quark = True display_datetime = self._format_pansou_display_datetime(cached.get("datetime")) date_suffix = f" — {display_datetime}" if display_datetime else "" lines.append(f"{idx}. {self._format_pansou_list_title(cached)}{date_suffix}") if cached.get("password"): lines.append(f" 提取码:{cached['password']}") brief = self._pansou_item_brief_summary(cached) if brief: lines.append(f" 摘要:{brief}") next_quark_hint = next((self._safe_int(item.get("index"), 0) for item in page_items if item.get("channel") == "quark"), 0) suggestion_lines = self._format_pansou_selection_suggestion(items) if suggestion_lines: lines.extend(["", *suggestion_lines]) lines.append("下一步:直接回全局编号即可转存;想先确认可发“选择 编号 详情”。") if next_quark_hint > 0 and next_quark_hint != first_visible_index: lines.append(f"注意:本页夸克结果从全局编号 {next_quark_hint} 开始;例如直接回复“{next_quark_hint}”即可处理本页第 1 条夸克结果,不要重新从 1 开始。") if safe_page < total_pages: lines.append("如需继续翻页,可回复:n 下一页") return "\n".join(lines) @classmethod def _human_resource_signal_text(cls, title: str, reasons: List[str]) -> str: blob = f"{title} {' '.join(reasons or [])}".lower() signals: List[str] = [] if any(token in blob for token in ["4k", "2160", "uhd", "臻彩"]): signals.append("画质规格更高") if any(token in blob for token in ["hdr", "dv", "杜比", "dolby", "高码率", "60fps", "60帧"]): signals.append("视频规格更完整") if any(token in blob for token in ["e01", "s01", "全集", "全季", "连续", "更新至", "更至", "覆盖到当前"]): signals.append("集数覆盖更明确") if any(token in blob for token in ["中字", "简中", "繁中", "字幕", "内封", "内嵌"]): signals.append("字幕信息更清楚") if any(token in blob for token in ["免费", "积分 0", "影巢免费"]): signals.append("成本更低") if not signals: return "综合画质、完整度和可转存性更均衡" return "、".join(signals[:3]) @classmethod def _human_resource_provider_text(cls, provider: str) -> str: normalized = cls._clean_text(provider).lower() if normalized == "115" or "115" in normalized: return "115 资源更适合做主收藏和后续入库" if normalized == "quark" or "夸克" in normalized: return "夸克资源更适合走夸克转存链路" return "这个资源整体更适合优先处理" @classmethod def _human_resource_suggestion_line(cls, best_index: int, best_title: str, provider: str, reasons: List[str]) -> str: title_part = f":{best_title}" if best_title else "" provider_text = cls._human_resource_provider_text(provider) signal_text = cls._human_resource_signal_text(best_title, reasons) return f"优先选 {best_index}{title_part}。{provider_text},主要优势是{signal_text}。" @classmethod def _human_quark_fallback_line(cls, quark_index: int, quark_title: str, reasons: List[str]) -> str: title_part = f":{quark_title}" if quark_title else "" signal_text = cls._human_resource_signal_text(quark_title, reasons) return f"如果这次明确只走夸克,备选 {quark_index}{title_part}。它在夸克结果里相对更稳,优势是{signal_text}。" def _format_pansou_selection_suggestion(self, items: List[Dict[str, Any]]) -> List[str]: summary = self._score_summary(items, limit=5) best = summary.get("best") if isinstance(summary.get("best"), dict) else {} best_index = self._safe_int(best.get("index"), 0) if best_index <= 0: return [] best_title = self._clean_text(best.get("title")) best_reasons = [self._clean_text(value) for value in (best.get("score_reasons") or [])[:4] if self._clean_text(value)] lines = ["智能建议"] lines.append(self._human_resource_suggestion_line(best_index, best_title, self._clean_text(best.get("provider")), best_reasons)) quark_items = [ dict(item or {}) for item in (items or []) if isinstance(item, dict) and self._normalize_pansou_channel_name(item.get("channel")) == "quark" ] best_quark = self._best_scored_source_item(quark_items) quark_index = self._safe_int(best_quark.get("index") or best_quark.get("pick_index"), 0) if quark_index > 0 and quark_index != best_index: quark_title = self._clean_text(best_quark.get("note") or best_quark.get("title")) quark_score = best_quark.get("score") if isinstance(best_quark.get("score"), dict) else {} quark_reasons = [self._clean_text(value) for value in (quark_score.get("score_reasons") or [])[:3] if self._clean_text(value)] lines.append(self._human_quark_fallback_line(quark_index, quark_title, quark_reasons)) return lines def _format_hdhive_resource_body_lines( self, resources: List[Dict[str, Any]], *, candidate: Optional[Dict[str, Any]] = None, start_index: int = 1, ) -> List[str]: lines: List[str] = [] if candidate: candidate_title = str(candidate.get("title") or "未命名") candidate_year = str(candidate.get("year") or "?") lines.append(f"已选影片:{candidate_title} ({candidate_year})") lines.append(f"资源结果:共 {len(resources)} 条") current_provider = "" for offset, item in enumerate(resources, start=start_index): provider = str(item.get("pan_type") or "?").lower() if provider != current_provider: current_provider = provider if lines and lines[-1] != "": lines.append("") if provider == "115": lines.append("🟦 115 结果") elif provider == "quark": lines.append("🟨 夸克结果") else: lines.append(f"{provider} 结果") lines.append(self._format_hdhive_resource_summary_line(item, offset)) lines.append("") return [line for line in lines if line is not None] @classmethod def _format_hdhive_resource_summary_line(cls, item: Dict[str, Any], index: int) -> str: provider = str(item.get("pan_type") or "?").lower() emoji = "📺" if provider == "115" else "🗄" if provider == "quark" else "🔗" title = cls._clean_text(item.get("title") or "未命名资源") points_text = cls._resource_points_text(item) share_size = cls._list_text(item.get("share_size") or item.get("size")) episode = cls._resource_episode_text(item) resolution = "/".join(item.get("video_resolution") or []) or "" source = cls._list_text(item.get("source")) subtitle = cls._resource_subtitle_text(item) remark = cls._resource_remark_text(item) detail_parts = [ points_text, share_size, episode, resolution, source, subtitle, remark, ] details = " · ".join(part for part in detail_parts if part) return f"{index}. {emoji} {title}" + (f" · {details}" if details else "") def _format_hdhive_selection_suggestion(self, resources: List[Dict[str, Any]]) -> List[str]: summary = self._score_summary(resources, limit=5) best = summary.get("best") if isinstance(summary.get("best"), dict) else {} best_index = self._safe_int(best.get("index"), 0) if best_index <= 0: return [] quark_items = [ dict(item or {}) for item in (resources or []) if isinstance(item, dict) and str(item.get("pan_type") or "").lower() == "quark" ] best_quark = self._best_scored_source_item(quark_items) quark_index = self._safe_int(best_quark.get("pick_index") or best_quark.get("index"), 0) best_title = self._clean_text(best.get("title")) best_reasons = [self._clean_text(value) for value in (best.get("score_reasons") or [])[:3] if self._clean_text(value)] lines = ["智能建议"] lines.append(self._human_resource_suggestion_line(best_index, best_title, self._clean_text(best.get("provider")), best_reasons)) if quark_index > 0 and quark_index != best_index: quark_title = self._clean_text(best_quark.get("title")) quark_score = best_quark.get("score") if isinstance(best_quark.get("score"), dict) else {} quark_reasons = [self._clean_text(value) for value in (quark_score.get("score_reasons") or [])[:3] if self._clean_text(value)] lines.append(self._human_quark_fallback_line(quark_index, quark_title, quark_reasons)) return lines def _format_cloud_selection_suggestion( self, pansou_items: List[Dict[str, Any]], hdhive_resources: List[Dict[str, Any]], ) -> List[str]: combined: List[Dict[str, Any]] = [] for item in pansou_items or []: if isinstance(item, dict): combined.append(dict(item)) for item in hdhive_resources or []: if not isinstance(item, dict): continue current = dict(item) cloud_index = self._safe_int( current.get("cloud_index") or current.get("pick_index") or current.get("index"), 0, ) current["index"] = cloud_index current["pick_index"] = cloud_index combined.append(current) summary = self._score_summary(combined, limit=5) best = summary.get("best") if isinstance(summary.get("best"), dict) else {} best_index = self._safe_int(best.get("index"), 0) if best_index <= 0: return [] best_title = self._clean_text(best.get("title")) best_reasons = [self._clean_text(value) for value in (best.get("score_reasons") or [])[:3] if self._clean_text(value)] lines = ["智能建议"] lines.append(self._human_resource_suggestion_line(best_index, best_title, self._clean_text(best.get("provider")), best_reasons)) quark_candidates = [ dict(item or {}) for item in combined if isinstance(item, dict) and self._normalize_pansou_channel_name(item.get("channel") or item.get("pan_type")) == "quark" ] best_quark = self._best_scored_source_item(quark_candidates) quark_index = self._safe_int(best_quark.get("index") or best_quark.get("pick_index") or best_quark.get("cloud_index"), 0) if quark_index > 0 and quark_index != best_index: quark_title = self._clean_text(best_quark.get("note") or best_quark.get("title")) quark_score = best_quark.get("score") if isinstance(best_quark.get("score"), dict) else {} quark_reasons = [self._clean_text(value) for value in (quark_score.get("score_reasons") or [])[:3] if self._clean_text(value)] lines.append(self._human_quark_fallback_line(quark_index, quark_title, quark_reasons)) return lines def _assistant_cloud_entry_summary( self, pansou_items: List[Dict[str, Any]], hdhive_resources: List[Dict[str, Any]], ) -> Dict[str, Any]: preferred_index = 0 if pansou_items: preferred_index = self._safe_int((pansou_items[0] or {}).get("index"), 0) elif hdhive_resources: preferred_index = self._safe_int((hdhive_resources[0] or {}).get("cloud_index") or (hdhive_resources[0] or {}).get("index"), 0) if preferred_index <= 0: return { "stage": "cloud_search", "label": "盘搜+影巢结果已返回", "preferred_command": "", "fallback_command": "", "recommended_agent_behavior": "show_only", } return { "stage": "cloud_search", "label": "盘搜+影巢结果已返回", "decision_hint": "默认按编号直接选择;想先看详情可回复“选择 编号 详情”。", "preferred_command": str(preferred_index), "fallback_command": f"选择 {preferred_index} 详情", "compact_commands": [str(preferred_index), f"选择 {preferred_index} 详情"], "preferred_requires_confirmation": True, "fallback_requires_confirmation": False, "can_auto_run_preferred": False, "recommended_agent_behavior": "show_only", } def _assistant_finalize_cloud_result( self, *, session: str, cache_key: str, keyword: str, pansou_items: List[Dict[str, Any]], pansou_total: int, hdhive_resources: List[Dict[str, Any]], hdhive_candidate: Optional[Dict[str, Any]], hdhive_candidates: List[Dict[str, Any]], target_path: str, lead_note: str = "", ) -> Dict[str, Any]: page_size = self._assistant_result_page_size self._save_session( cache_key, { "kind": "assistant_cloud", "stage": "result", "keyword": keyword, "target_path": target_path or self._hdhive_default_path, "pansou_items": pansou_items, "pansou_total": pansou_total, "hdhive_resources": hdhive_resources, "hdhive_candidate": dict(hdhive_candidate or {}), "hdhive_candidates": hdhive_candidates, "page": 1, "page_size": page_size, }, ) text_message = self._format_cloud_text( keyword=keyword, pansou_items=pansou_items, pansou_total=pansou_total, hdhive_resources=hdhive_resources, hdhive_candidate=hdhive_candidate, hdhive_candidates=hdhive_candidates, page=1, page_size=page_size, ) if lead_note: text_message = self._prepend_search_note(text_message, lead_note) return { "success": True, "message": text_message, "data": self._assistant_response_data(session=session, data={ "action": "cloud_search", "ok": True, "items": self._public_pansou_items(pansou_items), "hdhive_resources": self._public_hdhive_items(hdhive_resources), "hdhive_candidate": dict(hdhive_candidate or {}), "hdhive_candidate_count": len(hdhive_candidates or []), "page": 1, "page_size": page_size, "total_pages": max(1, ((len(pansou_items) + len(hdhive_resources)) + page_size - 1) // page_size) if (pansou_items or hdhive_resources) else 1, "decision_summary": self._assistant_cloud_entry_summary(pansou_items, hdhive_resources), "search_scope": "pansou_and_hdhive", }), } def _format_cloud_text( self, *, keyword: str, pansou_items: List[Dict[str, Any]], pansou_total: int, hdhive_resources: List[Dict[str, Any]], hdhive_candidate: Optional[Dict[str, Any]], hdhive_candidates: List[Dict[str, Any]], page: int = 1, page_size: int = 10, ) -> str: total_items = len(pansou_items) + len(hdhive_resources) safe_page, safe_page_size, total_pages, start, end = self._page_bounds(total_items, page=page, page_size=page_size) start_index = start + 1 end_index = min(total_items, end) lines = [f"盘搜 + 影巢结果:{keyword}"] if total_items > 0: lines.append(f"当前第 {safe_page}/{total_pages} 页,展示编号 {start_index}-{end_index} / 共 {total_items} 条已展开结果:") pansou_page_items = [item for item in pansou_items if start < self._safe_int(item.get('index'), 0) <= end] hdhive_page_items = [item for item in hdhive_resources if start < self._safe_int(item.get('cloud_index') or item.get('index'), 0) <= end] lines.extend(["", "盘搜结果"]) if pansou_page_items: lines.extend(self._format_pansou_body_lines(pansou_page_items, pansou_total)) elif pansou_items: lines.append("本页无盘搜结果") else: lines.append("暂无结果") lines.extend(["", "影巢结果"]) if hdhive_page_items: lines.extend(self._format_hdhive_resource_body_lines( hdhive_page_items, candidate=hdhive_candidate, start_index=self._safe_int((hdhive_page_items[0] or {}).get("cloud_index") or (hdhive_page_items[0] or {}).get("index"), 1), )) elif hdhive_resources: lines.append("本页无影巢结果") elif hdhive_candidates: lines.append(f"候选影片 {len(hdhive_candidates)} 个,当前未自动展开。") lines.append(f"如需细看影巢,请发送:影巢搜索 {keyword}") else: lines.append("暂无结果") suggestion_lines = self._format_cloud_selection_suggestion(pansou_items, hdhive_resources) if suggestion_lines: lines.extend(["", *suggestion_lines]) lines.append("下一步:直接回编号即可转存;想先确认可发“选择 编号 详情”。") lines.append("如需只看单一来源,可继续发:盘搜搜索 片名 / 影巢搜索 片名。") if safe_page < total_pages: lines.append("如需继续翻页,可回复:n 下一页") return "\n".join(lines) def _assistant_finalize_hdhive_candidates( self, *, session: str, cache_key: str, keyword: str, candidates: List[Dict[str, Any]], media_type: str, year: str, target_path: str, recommend_handoff: Optional[Dict[str, Any]] = None, lead_note: str = "", ) -> Dict[str, Any]: self._save_session( cache_key, { "kind": "assistant_hdhive", "stage": "candidate", "keyword": keyword, "media_type": media_type, "year": year, "target_path": target_path or self._hdhive_default_path, "candidates": candidates, "page": 1, "page_size": self._hdhive_candidate_page_size, **({"recommend_handoff": dict(recommend_handoff)} if recommend_handoff else {}), }, ) text_message = self._format_candidate_lines(candidates, page=1, page_size=self._hdhive_candidate_page_size) if lead_note: text_message = self._prepend_search_note(text_message, lead_note) return { "success": True, "message": text_message, "data": self._assistant_response_data(session=session, data={ "action": "hdhive_candidates", "ok": True, "candidates": candidates, "search_scope": "hdhive", }), } @staticmethod def _format_score_label(item: Dict[str, Any]) -> str: score = item.get("score") if isinstance(item.get("score"), dict) else {} if not score: return "" try: value = int(float(score.get("score") or 0)) except Exception: value = 0 action = AgentResourceOfficer._clean_text(score.get("recommended_action")) if score.get("can_auto_execute"): suffix = "可自动入库" elif action == "ask_confirm": suffix = "建议确认" else: suffix = "不建议自动" return f"{value}分 {suffix}" @staticmethod def _score_decision_label(value: Any) -> str: action = AgentResourceOfficer._clean_text(value) if action == "ask_confirm": return "建议确认" if action == "do_not_auto": return "不建议自动" if action == "auto_ingest_cloud": return "可自动入库" if action == "auto_download_pt": return "可自动下载" return action or "待判断" def _score_brief_item(self, item: Dict[str, Any], fallback_index: int = 0) -> Dict[str, Any]: current = dict(item or {}) score = current.get("score") if isinstance(current.get("score"), dict) else {} if not score: return {} torrent = current.get("torrent_info") if isinstance(current.get("torrent_info"), dict) else {} index = self._safe_int( current.get("pick_index") or current.get("index") or fallback_index, fallback_index, ) title = ( self._clean_text(torrent.get("title")) or self._clean_text(current.get("note")) or self._clean_text(current.get("title") or current.get("matched_title")) or "未命名资源" ) provider = ( self._clean_text(current.get("pan_type") or current.get("channel")) or self._clean_text(torrent.get("site_name")) or self._clean_text(current.get("site")) ) reasons = [self._clean_text(value) for value in (score.get("score_reasons") or []) if self._clean_text(value)] risks = [self._clean_text(value) for value in (score.get("risk_reasons") or []) if self._clean_text(value)] hard_risks = [self._clean_text(value) for value in (score.get("hard_risk_reasons") or []) if self._clean_text(value)] brief = { "index": index, "title": title[:160], "provider": provider, "source_type": self._clean_text(score.get("source_type")), "score": self._safe_int(score.get("score"), 0), "quality_rank": self._score_quality_rank(current), "score_level": self._clean_text(score.get("score_level")), "recommended_action": self._clean_text(score.get("recommended_action")), "can_auto_execute": bool(score.get("can_auto_execute")), "score_reasons": reasons[:3], "risk_reasons": risks[:2], "hard_risk_reasons": hard_risks[:2], } points_text = self._resource_points_text(current) if current and brief.get("source_type") != "pt" else "" if points_text and points_text != "积分未知": brief["points_text"] = points_text seeders = torrent.get("seeders") if torrent else score.get("seeders") if seeders is not None: brief["seeders"] = seeders volume = self._clean_text(torrent.get("volume_factor") if torrent else score.get("volume_factor")) if volume: brief["volume_factor"] = volume size = self._clean_text(current.get("share_size") or current.get("size") or torrent.get("size")) if size: brief["size"] = size return brief def _score_summary(self, items: List[Dict[str, Any]], *, limit: int = 5) -> Dict[str, Any]: scored: List[Dict[str, Any]] = [] for index, item in enumerate(items or [], 1): if not isinstance(item, dict): continue brief = self._score_brief_item(item, fallback_index=index) if brief: scored.append(brief) scored.sort(key=lambda value: ( 1 if value.get("can_auto_execute") else 0, self._safe_int(value.get("score"), 0), self._safe_int(value.get("quality_rank"), 0), ), reverse=True) auto_count = len([item for item in scored if item.get("can_auto_execute")]) confirm_count = len([item for item in scored if item.get("recommended_action") == "ask_confirm"]) blocked_count = len([item for item in scored if item.get("hard_risk_reasons")]) warning_count = len([item for item in scored if item.get("risk_reasons")]) summary = { "total_scored": len(scored), "auto_count": auto_count, "confirm_count": confirm_count, "blocked_count": blocked_count, "warning_count": warning_count, "best": scored[0] if scored else None, "top_recommendations": scored[:max(1, min(10, self._safe_int(limit, 5)))], } summary["decision"] = self._score_summary_decision(summary) return summary def _score_summary_decision(self, summary: Optional[Dict[str, Any]]) -> Dict[str, Any]: data = dict(summary or {}) best = data.get("best") if isinstance(data.get("best"), dict) else {} if not best: return { "stage": "no_scored_items", "label": "暂无评分结果", "requires_confirmation": False, "prefer_plan_first": True, "decision_hint": "当前没有可评分条目,先完成搜索或选择后再判断。", "command_policy": "none", "preferred_requires_confirmation": False, "fallback_requires_confirmation": False, "can_auto_run_preferred": False, "preferred_command": "", "fallback_command": "", "compact_commands": [], "recommended_commands": [], } choice = self._safe_int(best.get("index"), 0) source_type = self._clean_text(best.get("source_type")).lower() recommended_action = self._clean_text(best.get("recommended_action")) title = self._clean_text(best.get("title")) hard_risks = [self._clean_text(value) for value in (best.get("hard_risk_reasons") or []) if self._clean_text(value)] risks = [self._clean_text(value) for value in (best.get("risk_reasons") or []) if self._clean_text(value)] risks = [risk for risk in risks if risk not in hard_risks] is_pt = source_type == "pt" if is_pt: detail_command = "" plan_command = "" commands = [command for command in [str(choice) if choice else ""] if command] if best.get("can_auto_execute"): hint = f"当前最高分候选是 #{choice}{':' + title if title else ''},可以直接回编号下载。" stage = "auto_candidate" elif hard_risks: hint = f"当前最高分候选是 #{choice}{':' + title if title else ''},但存在硬风险;仍可直接回编号下载。" stage = "blocked" elif recommended_action == "ask_confirm": hint = f"当前最高分候选是 #{choice}{':' + title if title else ''},建议直接回编号下载。" stage = "confirm" else: hint = f"当前最高分候选是 #{choice}{':' + title if title else ''},但综合评分偏低;建议直接回编号下载。" stage = "low_score" else: detail_command = f"选择 {choice} 详情" plan_command = f"计划选择 {choice}" if choice > 0 else "" commands = [command for command in [detail_command if choice else "", plan_command if choice else "", "执行计划"] if command] if best.get("can_auto_execute"): hint = f"当前最高分候选是 #{choice}{':' + title if title else ''},已达到自动化阈值,但默认仍建议先生成计划再执行。" stage = "auto_candidate" elif hard_risks: hint = f"当前最高分候选是 #{choice}{':' + title if title else ''},但存在硬风险,不能自动执行。" stage = "blocked" elif recommended_action == "ask_confirm": hint = f"当前最高分候选是 #{choice}{':' + title if title else ''},风险可控,建议先看详情再决定。" stage = "confirm" else: hint = f"当前最高分候选是 #{choice}{':' + title if title else ''},但综合评分偏低,不建议直接处理。" stage = "low_score" return { "stage": stage, "label": self._score_decision_label("auto_download_pt" if best.get("can_auto_execute") and is_pt else "auto_ingest_cloud" if best.get("can_auto_execute") else recommended_action), "source_type": source_type, "choice": choice, "title": title[:160], "score": self._safe_int(best.get("score"), 0), "requires_confirmation": False if is_pt else not bool(best.get("can_auto_execute")), "prefer_plan_first": not is_pt, "command_policy": "auto_continue" if is_pt else "read_then_confirm_write" if len(commands) > 1 else "safe_read_only", "preferred_requires_confirmation": False, "fallback_requires_confirmation": False if is_pt else bool(len(commands) > 1), "can_auto_run_preferred": bool(commands), "preferred_command": commands[0] if commands else "", "fallback_command": commands[1] if len(commands) > 1 else "", "compact_commands": commands[:2], "recommended_commands": commands, "decision_hint": hint, "top_hard_risk": hard_risks[0] if hard_risks else "", "top_warning": risks[0] if risks else "", } @staticmethod def _format_score_summary_decision_lines(summary: Optional[Dict[str, Any]]) -> List[str]: data = dict(summary or {}) decision = data.get("decision") if isinstance(data.get("decision"), dict) else {} if not decision: return [] lines: List[str] = [] label = AgentResourceOfficer._clean_text(decision.get("label")) hint = AgentResourceOfficer._clean_text(decision.get("decision_hint")) commands = [AgentResourceOfficer._clean_text(item) for item in (decision.get("compact_commands") or decision.get("recommended_commands") or []) if AgentResourceOfficer._clean_text(item)] if label: lines.append(f"决策建议:{label}") if hint: lines.append(f"建议:{hint}") if commands: lines.append("下一步:" + " / ".join(commands)) return lines def _best_scored_source_item(self, items: List[Dict[str, Any]]) -> Dict[str, Any]: candidates = [ dict(item or {}) for item in (items or []) if isinstance(item, dict) and isinstance(item.get("score"), dict) ] if not candidates: return {} return max( candidates, key=lambda item: ( 1 if (item.get("score") or {}).get("can_auto_execute") else 0, self._safe_int((item.get("score") or {}).get("score"), 0), self._score_quality_rank(item), -self._safe_int(item.get("index") or item.get("pick_index"), 0), ), ) def _format_cloud_item_detail_text(self, item: Dict[str, Any], *, title: str = "云盘资源详情") -> str: current = dict(item or {}) score = current.get("score") if isinstance(current.get("score"), dict) else {} score_summary = self._score_summary([current], limit=1) if score else {} index = self._safe_int(current.get("index") or current.get("pick_index"), 0) name = ( self._clean_text(current.get("note")) or self._clean_text(current.get("title")) or self._clean_text(current.get("matched_title")) or "未命名资源" ) provider = self._clean_text(current.get("channel") or current.get("pan_type") or current.get("provider")) size = self._clean_text(current.get("share_size") or current.get("size")) resolution = self._clean_text(current.get("resolution")) source = self._clean_text(current.get("source") or current.get("source_name")) datetime_text = self._format_pansou_display_datetime(current.get("datetime")) password = self._clean_text(current.get("password")) url = self._clean_text(current.get("url") or current.get("share_url") or current.get("link")) brief = self._clean_text(current.get("brief_summary") or self._pansou_item_brief_summary(current)) points = self._resource_points_text(current) lines = [title] if index: lines.append(f"编号:{index}") lines.append(f"资源:{name}") if provider: lines.append(f"网盘:{provider}") if points: lines.append(f"积分:{points}") if resolution: lines.append(f"分辨率:{resolution}") if size: lines.append(f"大小:{size}") if source: lines.append(f"来源:{source}") if datetime_text: lines.append(f"日期:{datetime_text}") if brief: lines.append(f"摘要:{brief}") if password: lines.append(f"提取码:{password}") if url: lines.append(f"链接:{url}") if score: lines.append("") lines.append("智能建议") lines.append(f"评分:{self._safe_int(score.get('score'), 0)} / {self._clean_text(score.get('score_level')) or '-'}") reasons = [self._clean_text(value) for value in (score.get("score_reasons") or []) if self._clean_text(value)] hard_risks = [self._clean_text(value) for value in (score.get("hard_risk_reasons") or []) if self._clean_text(value)] risks = [self._clean_text(value) for value in (score.get("risk_reasons") or []) if self._clean_text(value)] risks = [risk for risk in risks if risk not in hard_risks] if reasons: lines.append("加分理由:" + ";".join(reasons[:6])) if hard_risks: lines.append("硬风险:" + ";".join(hard_risks[:6])) if risks: lines.append("提醒:" + ";".join(risks[:6])) lines.extend(self._format_score_summary_decision_lines(score_summary)) return "\n".join(lines) def _assistant_scoring_policy_public_data(self) -> Dict[str, Any]: prefs = self._default_assistant_preferences() return { "schema_version": "scoring_policy.v1", "owner": "plugin_rules", "agent_role": "explain_and_confirm_only", "summary": "评分由插件内置规则执行;外部智能体只读取 score_summary、解释原因、请求确认,不能绕过硬风险。", "global_decision": { "auto_execute_requires": [ "auto_ingest_enabled=true", "score >= auto_ingest_score_threshold", "hard_risk_reasons 为空", ], "confirm_range": "confirm_score_threshold <= score < auto_ingest_score_threshold 且无硬风险", "default_source": "plugin_config_then_session_preferences", "default_confirm_score_threshold": prefs.get("confirm_score_threshold"), "default_auto_ingest_score_threshold": prefs.get("auto_ingest_score_threshold"), "auto_ingest_default": prefs.get("auto_ingest_enabled"), }, "cloud": { "source_types": ["hdhive", "pansou", "115", "quark"], "positive_signals": [ "4K/UHD", "杜比视界/HDR", "中文字幕", "全集/完整季/更新完整度", "REMUX/原盘/Web-DL/高码率", "匹配网盘偏好", "匹配默认目录", ], "hard_gates": [ "影巢积分超过 hdhive_max_unlock_points 时禁止自动解锁", "影巢积分未知时禁止自动解锁", ], "default_hdhive_max_unlock_points": prefs.get("hdhive_max_unlock_points"), "pansou_cost": "无积分成本,主要按质量、完整度、字幕和网盘类型评分", }, "pt": { "source_types": ["moviepilot_native_search", "torrent_search", "subscribe_search"], "positive_signals": [ "做种数高", "免费/促销/FreeLeech", "4K/UHD", "杜比视界/HDR", "字幕信息", "REMUX/原盘/Web-DL/高码率", "季/全集标识", ], "hard_gates": [ "做种数 0 禁止自动下载", "做种数低于 pt_min_seeders 禁止自动下载", "用户要求免费时,非免费资源禁止自动下载", ], "default_pt_min_seeders": prefs.get("pt_min_seeders"), "volume_factor_note": "免费/促销明显加分;普通资源默认中性,不强行判废", }, "score_summary_contract": { "read_fields": [ "best", "top_recommendations", "decision", "score", "score_level", "recommended_action", "can_auto_execute", "score_reasons", "risk_reasons", "hard_risk_reasons", ], "decision_fields": [ "stage", "label", "choice", "decision_hint", "preferred_command", "fallback_command", "compact_commands", "recommended_commands", ], "blocked_count": "只统计 hard_risk_reasons,不统计缺字幕等软提醒", "warning_count": "统计 risk_reasons,用于解释需要用户确认的原因", "do_not_parse": "不要解析自然语言 message 来判断自动化,优先读取 score_summary", }, } @staticmethod def _format_bytes_size(value: Any) -> str: try: size = float(value or 0) except Exception: return "" if size <= 0: return "" units = ["B", "KB", "MB", "GB", "TB"] index = 0 while size >= 1024 and index < len(units) - 1: size /= 1024 index += 1 return f"{size:.2f}{units[index]}" if index else f"{int(size)}B" def _mp_context_preview_item(self, context: Any, index: int, preferences: Dict[str, Any]) -> Dict[str, Any]: torrent = getattr(context, "torrent_info", None) meta = getattr(context, "meta_info", None) media = getattr(context, "media_info", None) item = { "index": index, "cache_index": index, "torrent_info": { "title": self._clean_text(getattr(torrent, "title", "")), "size": self._format_bytes_size(getattr(torrent, "size", None)), "seeders": getattr(torrent, "seeders", None), "peers": getattr(torrent, "peers", None), "site_name": self._clean_text(getattr(torrent, "site_name", "")), "volume_factor": self._clean_text(getattr(torrent, "volume_factor", "")), "page_url": self._clean_text(getattr(torrent, "page_url", "")), }, "meta_info": { "season_episode": self._clean_text(getattr(meta, "season_episode", "")), "resource_team": self._clean_text(getattr(meta, "resource_team", "")), "video_encode": self._clean_text(getattr(meta, "video_encode", "")), "edition": self._clean_text(getattr(meta, "edition", "")), "resource_pix": self._clean_text(getattr(meta, "resource_pix", "")), }, "media_info": { "title": self._clean_text(getattr(media, "title", "")), "year": self._clean_text(getattr(media, "year", "")), "tmdb_id": getattr(media, "tmdb_id", None), "douban_id": getattr(media, "douban_id", None), }, } item["score"] = self._score_pt_resource(item, preferences=preferences) return item @classmethod def _display_pt_title(cls, title: Any) -> str: text = cls._clean_text(title or "未命名资源") # Some chat frontends auto-link dotted release names such as # Spider-Man.2002.1080p. Insert zero-width breaks for display only. return re.sub(r"(?<=[A-Za-z0-9])\.(?=[A-Za-z0-9])", ".\u200b", text) @classmethod def _pt_title_badges(cls, title: Any) -> str: text = cls._clean_text(title).lower() badges: List[str] = [] if cls._score_has_any(text, ["2160", "4k", "uhd"]): badges.append("✨4K") elif cls._score_has_any(text, ["1080"]): badges.append("🎞️1080p") if cls._score_has_any(text, ["dolby vision", "dovi", " dv ", ".dv.", "杜比视界"]): badges.append("⚡DV") if cls._score_has_any(text, ["hdr10", "hdr", "hlg"]): badges.append("🔶HDR") if cls._score_has_any(text, ["remux", "原盘"]): badges.append("💿REMUX") return " ".join(badges) @staticmethod def _pt_promotion_text(value: Any) -> str: text = str(value or "").strip() or "普通" lowered = text.lower() if "免费" in text or "free" in lowered: return f"🆓 {text}" if "%" in text or "x" in lowered: return f"🎁 {text}" return f"🎫 {text}" def _sort_mp_preview_items(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: ranked = [ dict(item or {}) for item in (items or []) if isinstance(item, dict) ] ranked.sort( key=lambda item: ( self._safe_int(((item.get("torrent_info") or {}).get("seeders")), 0), self._score_quality_rank(item), self._safe_int((item.get("score") or {}).get("score"), 0), -self._safe_int(item.get("index"), 0), ), reverse=True, ) for index, item in enumerate(ranked, start=1): item["index"] = index return ranked @staticmethod def _page_bounds(total_items: int, page: int = 1, page_size: int = 10) -> Tuple[int, int, int, int]: safe_page_size = max(1, int(page_size or 10)) total_pages = max(1, (max(0, total_items) + safe_page_size - 1) // safe_page_size) if total_items else 1 safe_page = min(max(1, int(page or 1)), total_pages) start = (safe_page - 1) * safe_page_size end = start + safe_page_size return safe_page, safe_page_size, total_pages, start, end def _mp_search_cache_preview( self, cache_key: str, preferences: Dict[str, Any], *, page: int = 1, page_size: int = 10, limit: Optional[int] = None, ) -> List[Dict[str, Any]]: try: cache = self._ensure_feishu_channel()._get_search_cache(cache_key) except Exception: cache = None results = (cache or {}).get("results") or [] all_items: List[Dict[str, Any]] = [] for index, context in enumerate(results, 1): all_items.append(self._mp_context_preview_item(context, index, preferences)) all_items = self._sort_mp_preview_items(all_items) effective_page_size = max(1, self._safe_int(limit, page_size)) return self._slice_mp_preview_items(all_items, page=page, page_size=effective_page_size) def _mp_search_all_preview_items(self, cache_key: str, preferences: Dict[str, Any]) -> List[Dict[str, Any]]: try: cache = self._ensure_feishu_channel()._get_search_cache(cache_key) except Exception: cache = None results = (cache or {}).get("results") or [] return self._sort_mp_preview_items([ self._mp_context_preview_item(context, index, preferences) for index, context in enumerate(results, 1) ]) def _slice_mp_preview_items( self, items: List[Dict[str, Any]], *, page: int = 1, page_size: int = 10, ) -> List[Dict[str, Any]]: _safe_page, _safe_page_size, _total_pages, start, end = self._page_bounds( len(items), page=page, page_size=page_size, ) return [ dict(item or {}) for item in items[start:end] if isinstance(item, dict) ] def _reindex_mp_preview_items(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: reindexed: List[Dict[str, Any]] = [] for index, item in enumerate(items or [], start=1): if not isinstance(item, dict): continue current = dict(item) current["index"] = index reindexed.append(current) return reindexed def _assistant_mp_current_items( self, *, cache_key: str, preferences: Dict[str, Any], ) -> List[Dict[str, Any]]: state = self._load_session(cache_key) or {} all_items = state.get("all_items") if isinstance(state.get("all_items"), list) else [] if all_items: return [dict(item or {}) for item in all_items if isinstance(item, dict)] items = state.get("items") if isinstance(state.get("items"), list) else [] if items: return [dict(item or {}) for item in items if isinstance(item, dict)] return self._mp_search_cache_preview( cache_key, preferences=preferences, limit=self._assistant_result_page_size, ) def _assistant_mp_item_by_display_index( self, *, choice: int, cache_key: str, preferences: Dict[str, Any], ) -> Tuple[Dict[str, Any], List[int]]: items = self._assistant_mp_current_items(cache_key=cache_key, preferences=preferences) available_indexes = [ self._safe_int(item.get("index"), 0) for item in items if self._safe_int(item.get("index"), 0) > 0 ] selected = next( (dict(item or {}) for item in items if self._safe_int(item.get("index"), 0) == self._safe_int(choice, 0)), {}, ) return selected, available_indexes def _format_mp_search_text( self, keyword: str, message_text: str, preview: List[Dict[str, Any]], *, total: int = 0, page: int = 1, page_size: int = 10, result_filter: str = "", latest_episode: str = "", episode_filter: str = "", ) -> str: header = message_text.strip().splitlines()[0] if message_text else f"MP 原生搜索:{keyword}" if preview and ( header.startswith("搜索资源失败") or header.startswith("MP 原生搜索失败") or header.startswith("未识别到媒体信息") ): header = f"{keyword} — MP搜索" lines = [header] if preview: total_results = max(self._safe_int(total, 0), len(preview)) safe_page_size = max(1, self._safe_int(page_size, self._assistant_result_page_size)) total_pages = max(1, (total_results + safe_page_size - 1) // safe_page_size) score_summary = self._score_summary(preview, limit=5) lines.append("") header_suffix = "按做种数优先排序" target_episode = self._safe_int(episode_filter, 0) if result_filter == "latest_episode" and self._safe_int(latest_episode, 0) > 0: header_suffix = f"已按最新集 E{self._safe_int(latest_episode, 0):02d} 优先排序" elif result_filter.startswith("episode:") and target_episode > 0: header_suffix = f"已按第 {target_episode} 集优先排序" lines.append(f"当前第 {max(1, page)}/{total_pages} 页,共 {total_results} 条结果({header_suffix}):") lines.append("PT 资源:") for item in preview: torrent = item.get("torrent_info") or {} score = item.get("score") or {} title = self._display_pt_title(torrent.get("title")) badges = self._pt_title_badges(torrent.get("title")) badge_suffix = f" {badges}" if badges else "" lines.append(f"{item.get('index')}. 【{torrent.get('site_name') or '未知站点'}】 {title}{badge_suffix}") details = [ f"🌱 做种:{torrent.get('seeders') if torrent.get('seeders') is not None else '?'}", self._pt_promotion_text(torrent.get("volume_factor")), f"💾 {torrent.get('size') or '未知'}", f"⭐ {self._format_score_label(item)}", ] hard_risks = score.get("hard_risk_reasons") or [] risks = score.get("risk_reasons") or [] risks = [risk for risk in risks if risk not in hard_risks] if hard_risks: details.append("硬风险:" + ";".join(str(item) for item in hard_risks[:2])) elif risks: details.append("提醒:" + ";".join(str(item) for item in risks[:2])) lines.append(" " + " | ".join(details)) decision_lines = self._format_score_summary_decision_lines(score_summary) if result_filter == "latest_episode" or result_filter.startswith("episode:"): first_item = preview[0] if preview else {} first_index = self._safe_int(first_item.get("index"), 0) first_title = self._display_pt_title(((first_item.get("torrent_info") or {}).get("title"))) decision_lines = [] if first_index > 0: decision_lines.append("决策建议:建议确认") if result_filter == "latest_episode": decision_lines.append( f"建议:当前已按最新集优先排序,首选 #{first_index}:{first_title}。" ) elif target_episode > 0: decision_lines.append( f"建议:当前已按第 {target_episode} 集优先排序,首选 #{first_index}:{first_title}。" ) decision_lines.append(f"下一步:{first_index}") lines.extend(decision_lines) if page < total_pages: lines.append("如需继续翻页,可回复:n 下一页") lines.append("回复编号直接下载;n 下一页。") return "\n".join(line for line in lines if line) async def _assistant_mp_media_detail( self, *, keyword: str, session: str, cache_key: str, media_type: str = "", year: str = "", ) -> Dict[str, Any]: result = self._ensure_feishu_channel()._query_media_detail( keyword=keyword, media_type=media_type, year=year, ) item = result.get("item") if isinstance(result.get("item"), dict) else {} self._save_session(cache_key, { "kind": "assistant_mp_media_detail", "stage": "media_detail", "keyword": keyword, "items": [item] if item else [], "target_path": "", }) return { "success": bool(result.get("success")), "message": self._clean_text(result.get("message")) or "媒体识别完成", "data": self._assistant_response_data(session=session, data={ "action": "mp_media_detail", "ok": bool(result.get("success")), "keyword": keyword, "media_type": media_type, "year": year, "item": item, }), } async def _assistant_mp_media_search( self, *, keyword: str, session: str, cache_key: str, preferences: Dict[str, Any], page: int = 1, page_size: int = 10, result_filter: str = "", ) -> Dict[str, Any]: try: effective_filter = self._clean_text(result_filter).lower() latest_episode = 0 episode_filter = 0 message_text = self._ensure_feishu_channel()._execute_media_search(keyword, cache_key) failed_prefixes = ("MP 原生搜索失败", "未识别到媒体信息", "搜索资源失败") route_ok = not any(message_text.startswith(prefix) for prefix in failed_prefixes) try: cache = self._ensure_feishu_channel()._get_search_cache(cache_key) except Exception: cache = None all_items = self._mp_search_all_preview_items(cache_key, preferences=preferences) filtered_items = [dict(item or {}) for item in all_items if isinstance(item, dict)] if effective_filter == "latest_episode": filtered_items, latest_episode = self._latest_episode_mp_items(filtered_items) filtered_items = self._sort_mp_episode_filtered_items(filtered_items, latest_episode) elif effective_filter.startswith("episode:"): episode_filter = self._safe_int(effective_filter.split(":", 1)[1], 0) filtered_items = self._episode_filter_mp_items(filtered_items, episode_filter) filtered_items = self._sort_mp_episode_filtered_items(filtered_items, episode_filter) filtered_items = self._reindex_mp_preview_items(filtered_items) total = len(filtered_items) preview = ( self._slice_mp_preview_items(filtered_items, page=page, page_size=page_size) if filtered_items else [] ) self._save_session(cache_key, { "kind": "assistant_mp", "stage": "search_result", "keyword": keyword, "items": preview, "all_items": filtered_items, "total": total, "page": max(1, self._safe_int(page, 1)), "page_size": max(1, self._safe_int(page_size, self._assistant_result_page_size)), "result_filter": effective_filter, "latest_episode": latest_episode, "episode_filter": episode_filter, "target_path": "", }) return { "success": route_ok, "message": self._format_mp_search_text( keyword, message_text, preview, total=total, page=page, page_size=page_size, result_filter=effective_filter, latest_episode=latest_episode, episode_filter=episode_filter, ), "data": self._assistant_response_data(session=session, data={ "action": "mp_media_search", "ok": route_ok, "keyword": keyword, "source_type": "pt", "items": preview, "total": total, "page": max(1, self._safe_int(page, 1)), "page_size": max(1, self._safe_int(page_size, self._assistant_result_page_size)), "total_pages": max(1, (max(0, total) + max(1, self._safe_int(page_size, self._assistant_result_page_size)) - 1) // max(1, self._safe_int(page_size, self._assistant_result_page_size))) if total else 1, "score_summary": self._score_summary(preview, limit=5), "preferences": preferences, }), } except Exception as exc: logger.error( f"[AgentResourceOfficer] MP 搜索处理失败:{keyword} {exc}\n{traceback.format_exc()}" ) return { "success": False, "message": f"MP 搜索处理失败:{exc}", "data": self._assistant_response_data(session=session, data={ "action": "mp_media_search", "ok": False, "keyword": keyword, "error_code": "mp_media_search_exception", "error": str(exc), }), } async def _assistant_mp_candidate_search( self, *, keyword: str, session: str, cache_key: str, media_type: str = "auto", year: str = "", page: int = 1, pending_action: Optional[Dict[str, Any]] = None, target_path: str = "", ) -> Dict[str, Any]: clean_keyword = self._clean_text(keyword) try: service = self._ensure_hdhive_service() search_ok, result, search_message = await service.resolve_candidates_by_keyword( keyword=clean_keyword, media_type=self._clean_text(media_type or "auto").lower() or "auto", year=self._clean_text(year), candidate_limit=max(30, self._hdhive_candidate_page_size), ) if not search_ok: return { "success": False, "message": f"MP 候选解析失败:{search_message}", "data": self._assistant_response_data(session=session, data={ "action": "mp_media_candidates", "ok": False, "keyword": clean_keyword, "error_code": "candidate_search_failed", "result": result, }), } candidates = result.get("candidates") or [] page_size = self._assistant_result_page_size self._save_session(cache_key, { "kind": "assistant_mp_candidate", "stage": "candidate", "keyword": clean_keyword, "media_type": self._clean_text(media_type or "auto").lower() or "auto", "year": self._clean_text(year), "candidates": candidates, "page": max(1, self._safe_int(page, 1)), "page_size": page_size, "target_path": target_path or "", **({"pending_action": dict(pending_action)} if isinstance(pending_action, dict) and pending_action else {}), }) message = self._format_mp_candidate_lines(candidates, page=page, page_size=page_size) if isinstance(pending_action, dict) and pending_action: pending_mode = self._clean_text(pending_action.get("mode")) if pending_mode == "mp_download_title": message = message.replace( "选定后再搜索 PT 资源。", "选定后将用正确片名继续搜索 PT 资源并下载。", ) elif pending_mode == "cloud_transfer_execute": message = message.replace( "选定后再搜索 PT 资源。", "选定后将用正确片名继续搜索云盘资源并转存。", ) pending_label = self._clean_text(pending_action.get("label")) if pending_label: message = f"{message}\n选定影片后将继续:{pending_label}" return { "success": True, "message": message, "data": self._assistant_response_data(session=session, data={ "action": "mp_media_candidates", "ok": True, "keyword": clean_keyword, "candidates": candidates, "page": max(1, self._safe_int(page, 1)), "page_size": page_size, **({"pending_action": dict(pending_action)} if isinstance(pending_action, dict) and pending_action else {}), }), } except Exception as exc: logger.error( f"[AgentResourceOfficer] MP 候选解析异常:{clean_keyword} {exc}\n{traceback.format_exc()}" ) return { "success": False, "message": f"MP 候选解析异常:{exc}", "data": self._assistant_response_data(session=session, data={ "action": "mp_media_candidates", "ok": False, "keyword": clean_keyword, "error_code": "candidate_search_exception", "error": str(exc), }), } async def _assistant_mp_result_detail( self, *, choice: int, session: str, cache_key: str, preferences: Dict[str, Any], ) -> Dict[str, Any]: try: cache = self._ensure_feishu_channel()._get_search_cache(cache_key) except Exception: cache = None results = (cache or {}).get("results") or [] item, available_indexes = self._assistant_mp_item_by_display_index( choice=choice, cache_key=cache_key, preferences=preferences, ) if not item: available_text = "、".join(str(index) for index in available_indexes[:20]) return { "success": False, "message": ( f"当前列表没有 #{choice}。可选编号:{available_text}。" if available_text else "没有可继续的 MP 搜索结果,请先发送“MP搜索 片名”。" ), "data": self._assistant_response_data(session=session, data={ "action": "mp_search_result_detail", "ok": False, "error_code": "mp_result_not_found", "choice": choice, "available_indexes": available_indexes, }), } torrent = item.get("torrent_info") or {} meta = item.get("meta_info") or {} media = item.get("media_info") or {} score = item.get("score") or {} lines = [ f"MP 搜索结果详情 #{choice}", f"标题:{self._display_pt_title(torrent.get('title'))} {self._pt_title_badges(torrent.get('title'))}".strip(), f"站点:【{torrent.get('site_name') or '未知站点'}】", f"大小:💾 {torrent.get('size') or '未知'}", f"热度:🌱 做种 {torrent.get('seeders') if torrent.get('seeders') is not None else '?'} | 下载 {torrent.get('peers') if torrent.get('peers') is not None else '?'} | {self._pt_promotion_text(torrent.get('volume_factor'))}", ] media_text = " / ".join(str(part) for part in [ media.get("title"), media.get("year"), f"TMDB:{media.get('tmdb_id')}" if media.get("tmdb_id") else "", f"豆瓣:{media.get('douban_id')}" if media.get("douban_id") else "", ] if part) if media_text: lines.append(f"媒体:{media_text}") meta_text = " / ".join(str(part) for part in [ meta.get("season_episode"), meta.get("resource_pix"), meta.get("video_encode"), meta.get("edition"), meta.get("resource_team"), ] if part) if meta_text: lines.append(f"识别标签:{meta_text}") score_label = self._format_score_label(item) if score_label: lines.append(f"评分:{score_label}") reasons = [str(value) for value in (score.get("score_reasons") or []) if value] hard_risks = [str(value) for value in (score.get("hard_risk_reasons") or []) if value] risks = [str(value) for value in (score.get("risk_reasons") or []) if value] risks = [risk for risk in risks if risk not in hard_risks] score_summary = self._score_summary([item], limit=1) if reasons: lines.append("加分理由:" + ";".join(reasons[:6])) if hard_risks: lines.append("硬风险:" + ";".join(hard_risks[:6])) if risks: lines.append("提醒:" + ";".join(risks[:6])) if torrent.get("page_url"): lines.append(f"详情页:{torrent.get('page_url')}") lines.extend(self._format_score_summary_decision_lines(score_summary)) current_state = self._load_session(cache_key) or {} self._save_session(cache_key, { "kind": "assistant_mp", "stage": "search_result", "keyword": (cache or {}).get("keyword") or current_state.get("keyword") or "", "items": current_state.get("items") or self._mp_search_cache_preview( cache_key, preferences=preferences, page=max(1, self._safe_int(current_state.get("page"), 1)), page_size=max(1, self._safe_int(current_state.get("page_size"), self._assistant_result_page_size)), ), "all_items": current_state.get("all_items") or [], "selected_index": choice, "total": len(results), "page": max(1, self._safe_int(current_state.get("page"), 1)), "page_size": max(1, self._safe_int(current_state.get("page_size"), self._assistant_result_page_size)), "target_path": current_state.get("target_path") or "", **({"recommend_handoff": dict(current_state.get("recommend_handoff") or {})} if current_state.get("recommend_handoff") else {}), }) return { "success": True, "message": "\n".join(lines), "data": self._assistant_response_data(session=session, data={ "action": "mp_search_result_detail", "ok": True, "choice": choice, "item": item, "score_summary": score_summary, }), } async def _assistant_mp_best_result_detail( self, *, session: str, cache_key: str, preferences: Dict[str, Any], ) -> Dict[str, Any]: preview = self._mp_search_cache_preview(cache_key, preferences=preferences, limit=self._assistant_result_page_size) scored = [ item for item in preview if isinstance(item, dict) and isinstance(item.get("score"), dict) ] if not scored: return { "success": False, "message": "没有可评分的 MP 搜索结果,请先发送“MP搜索 片名”。", "data": self._assistant_response_data(session=session, data={ "action": "mp_search_best_detail", "ok": False, "error_code": "mp_best_result_not_found", }), } best = max( scored, key=lambda item: ( self._safe_int((item.get("score") or {}).get("score"), 0), self._safe_int(((item.get("torrent_info") or {}).get("seeders")), 0), ), ) choice = self._safe_int(best.get("index"), 0) result = await self._assistant_mp_result_detail( choice=choice, session=session, cache_key=cache_key, preferences=preferences, ) if result.get("success"): result["message"] = f"当前评分最高的 PT 候选是 #{choice}\n{result.get('message') or ''}".strip() data = dict(result.get("data") or {}) data["action"] = "mp_search_best_detail" data["best_choice"] = choice result["data"] = data return result async def _assistant_mp_best_download_plan( self, *, session: str, cache_key: str, preferences: Dict[str, Any], ) -> Dict[str, Any]: preview = self._mp_search_cache_preview(cache_key, preferences=preferences, limit=self._assistant_result_page_size) scored = [ item for item in preview if isinstance(item, dict) and isinstance(item.get("score"), dict) ] if not scored: return { "success": False, "message": "没有可评分的 MP 搜索结果,请先发送“MP搜索 片名”。", "data": self._assistant_response_data(session=session, data={ "action": "mp_best_download_plan", "ok": False, "error_code": "mp_best_result_not_found", }), } best = max( scored, key=lambda item: ( self._safe_int((item.get("score") or {}).get("score"), 0), self._safe_int(((item.get("torrent_info") or {}).get("seeders")), 0), ), ) choice = self._safe_int(best.get("index"), 0) result = self._assistant_mp_download_plan_response( choice=choice, session=session, cache_key=cache_key, preferences=preferences, workflow="mp_best_download", message="最佳片源下载计划已生成", ) if result.get("success"): result["message"] = "\n".join(line for line in [ f"已选择当前评分最高的 PT 候选:#{choice}", result.get("message") or "", ] if line) data = dict(result.get("data") or {}) data["choice"] = choice data["item"] = best result["data"] = data return result def _assistant_format_download_plan_choice(self, *, rank: int, plan_id: str, item: Dict[str, Any]) -> str: torrent = item.get("torrent_info") if isinstance(item.get("torrent_info"), dict) else {} score = item.get("score") if isinstance(item.get("score"), dict) else {} title = self._display_pt_title(torrent.get("title")) badges = self._pt_title_badges(torrent.get("title")) site = self._clean_text(torrent.get("site_name")) or "未知站点" seeders = torrent.get("seeders") if torrent.get("seeders") is not None else "?" size = self._clean_text(torrent.get("size")) or "未知" score_label = self._format_score_label(item) risk_text = self._assistant_score_warning_text(score, limit=1).replace("风险提示:", "") suffix = f" | 提醒:{risk_text}" if risk_text else "" badge_text = f" {badges}" if badges else "" return ( f"{rank}. {plan_id} | 资源 #{self._safe_int(item.get('index'), 0)} | " f"【{site}】 {title}{badge_text}\n" f" 🌱 做种:{seeders} | {self._pt_promotion_text(torrent.get('volume_factor'))} | 💾 {size} | ⭐ {score_label}{suffix}" ) async def _assistant_attach_download_plan_choices( self, result: Dict[str, Any], *, session: str, cache_key: str, preferences: Dict[str, Any], limit: int = 3, ) -> Dict[str, Any]: if not isinstance(result, dict) or not result.get("success"): return result result_data = result.get("data") if isinstance(result.get("data"), dict) else {} current_state = self._load_session(cache_key) or {} result_filter = self._clean_text(current_state.get("result_filter")).lower() target_episode = 0 filter_notice = "" if result_filter == "latest_episode": target_episode = self._safe_int(current_state.get("latest_episode"), 0) if target_episode > 0: filter_notice = f"已按最新集 E{target_episode:02d} 优先筛选,并生成 3 个待确认下载方案,均未实际下载:" elif result_filter.startswith("episode:"): target_episode = self._safe_int(current_state.get("episode_filter"), 0) if target_episode > 0: filter_notice = f"已优先按第 {target_episode} 集单集匹配,并生成 3 个待确认下载方案,均未实际下载:" preview = self._assistant_mp_current_items(cache_key=cache_key, preferences=preferences) if not preview: preview = self._mp_search_cache_preview(cache_key, preferences=preferences, limit=self._assistant_result_page_size) scored = [ item for item in preview if isinstance(item, dict) and isinstance(item.get("score"), dict) ] if target_episode > 0: scored.sort( key=lambda item: ( self._mp_episode_plan_priority(item, target_episode), -self._safe_int((item.get("score") or {}).get("score"), 0), -self._safe_int(((item.get("torrent_info") or {}).get("seeders")), 0), -self._score_quality_rank(item), self._safe_int(item.get("index"), 0), ), ) else: scored.sort( key=lambda item: ( self._safe_int((item.get("score") or {}).get("score"), 0), self._safe_int(((item.get("torrent_info") or {}).get("seeders")), 0), ), reverse=True, ) choices = scored[:max(1, min(3, self._safe_int(limit, 3)))] if not choices: return result group_id = self._new_session_id("mpdl") plan_rows: List[str] = [] plan_items: List[Dict[str, Any]] = [] for rank, item in enumerate(choices, start=1): plan_result = self._assistant_mp_download_plan_response( choice=self._safe_int(item.get("index"), 0), session=session, cache_key=cache_key, preferences=preferences, workflow="mp_download_choice", message=f"下载方案 {rank} 已生成", ) if not plan_result.get("success"): continue plan_data = plan_result.get("data") or {} plan_id = self._clean_text(plan_data.get("plan_id")) if plan_id and isinstance(self._workflow_plans.get(plan_id), dict): execute_body = self._workflow_plans[plan_id].get("execute_body") if not isinstance(execute_body, dict): execute_body = {} execute_body.update({ "plan_rank": rank, "multi_plan_group": group_id, }) self._workflow_plans[plan_id]["execute_body"] = execute_body self._workflow_plans[plan_id]["plan_rank"] = rank self._workflow_plans[plan_id]["plan_choice"] = self._safe_int(item.get("index"), 0) self._workflow_plans[plan_id]["multi_plan_group"] = group_id self._persist_workflow_plans() plan_rows.append(self._assistant_format_download_plan_choice(rank=rank, plan_id=plan_id, item=item)) plan_items.append({ "rank": rank, "plan_id": plan_id, "choice": self._safe_int(item.get("index"), 0), "item": item, }) if not plan_items: return result self._save_session(cache_key, { **current_state, "kind": "assistant_mp_download_plans", "stage": "download_plan_choices", "pending_plan_group": group_id, "plan_choices": plan_items, "source_search": result.get("data") or {}, }) merged = { "success": True, "message": "\n".join([ filter_notice or f"已按当前偏好生成 {len(plan_items)} 个待确认下载方案,均未实际下载:", *plan_rows, "回复方案编号 1/2/3 执行对应方案;回复“选择 资源编号”可先看详情。", ]), } data = { "action": "mp_download_plan_choices", "ok": True, "pending_plan_group": group_id, "plan_choices": plan_items, "ready_to_execute": True, "write_effect": "state", } data["source_search"] = result.get("data") or {} data["source_search_message"] = self._clean_text(result.get("message")) merged["data"] = self._assistant_response_data(session=session, data=data) return merged def _assistant_smart_search_stop_after_source( self, candidate: Optional[Dict[str, Any]], preferences: Optional[Dict[str, Any]], ) -> bool: current = dict(candidate or {}) if not current: return False score_value = self._safe_int(current.get("score"), 0) prefs = self._normalize_assistant_preferences(preferences) threshold = self._safe_int( prefs.get("confirm_score_threshold"), self._assistant_default_confirm_score_threshold, ) hard_risks = current.get("hard_risk_reasons") or [] recommended_action = self._clean_text(current.get("recommended_action")) return score_value >= threshold and not hard_risks and recommended_action != "not_recommended" def _assistant_smart_search_candidate_from_cloud_item( self, item: Dict[str, Any], *, source_type: str, ) -> Dict[str, Any]: score = item.get("score") if isinstance(item.get("score"), dict) else {} provider = self._clean_text(item.get("pan_type") or item.get("channel") or item.get("provider")).lower() index = self._safe_int(item.get("index") or item.get("pick_index"), 0) hard_risks = [self._clean_text(value) for value in (score.get("hard_risk_reasons") or []) if self._clean_text(value)] risks = [self._clean_text(value) for value in (score.get("risk_reasons") or []) if self._clean_text(value)] risks = [value for value in risks if value not in hard_risks] raw_action = self._clean_text(score.get("recommended_action")) if hard_risks: recommended_action = "not_recommended" elif raw_action == "ask_confirm": recommended_action = "ask_confirm_plan" else: recommended_action = "show_only" plan_command = f"计划选择 {index}" if index > 0 else "" detail_command = f"选择 {index} 详情" if index > 0 else "" return { "source_type": source_type, "title": self._clean_text(item.get("note") or item.get("title") or item.get("matched_title") or "未命名资源"), "year": self._clean_text(item.get("year") or item.get("matched_year")), "media_type": self._clean_text(item.get("media_type") or item.get("type")), "provider": provider or self._clean_text(item.get("source") or source_type), "choice": index, "score": self._safe_int(score.get("score"), 0), "score_level": self._clean_text(score.get("score_level")), "score_reasons": [self._clean_text(value) for value in (score.get("score_reasons") or []) if self._clean_text(value)][:4], "risk_reasons": risks[:3], "hard_risk_reasons": hard_risks[:2], "cost_summary": { "points_text": self._resource_points_text(item), "provider": provider, "size": self._clean_text(item.get("share_size") or item.get("size")), }, "recommended_action": recommended_action, "next_command": plan_command or detail_command, "detail_command": detail_command, "plan_command": plan_command, "raw_item": item, } def _assistant_smart_search_candidate_from_pt_item(self, item: Dict[str, Any]) -> Dict[str, Any]: score = item.get("score") if isinstance(item.get("score"), dict) else {} torrent = item.get("torrent_info") if isinstance(item.get("torrent_info"), dict) else {} media = item.get("media_info") if isinstance(item.get("media_info"), dict) else {} index = self._safe_int(item.get("index") or item.get("pick_index"), 0) hard_risks = [self._clean_text(value) for value in (score.get("hard_risk_reasons") or []) if self._clean_text(value)] risks = [self._clean_text(value) for value in (score.get("risk_reasons") or []) if self._clean_text(value)] risks = [value for value in risks if value not in hard_risks] raw_action = self._clean_text(score.get("recommended_action")) if hard_risks: recommended_action = "not_recommended" elif raw_action in {"ask_confirm", "auto_download_pt"}: recommended_action = "ask_confirm_plan" else: recommended_action = "show_only" plan_command = f"下载{index}" if index > 0 else "" detail_command = "" next_command = str(index) if index > 0 else plan_command return { "source_type": "mp_pt", "title": self._clean_text(torrent.get("title") or media.get("title") or "未命名资源"), "year": self._clean_text(media.get("year")), "media_type": self._clean_text(item.get("media_type") or media.get("type") or "tv"), "provider": self._clean_text(torrent.get("site_name") or "pt"), "choice": index, "score": self._safe_int(score.get("score"), 0), "score_level": self._clean_text(score.get("score_level")), "score_reasons": [self._clean_text(value) for value in (score.get("score_reasons") or []) if self._clean_text(value)][:4], "risk_reasons": risks[:3], "hard_risk_reasons": hard_risks[:2], "cost_summary": { "seeders": self._safe_int(score.get("seeders"), self._safe_int(torrent.get("seeders"), 0)), "volume_factor": self._clean_text(score.get("volume_factor") or torrent.get("volume_factor")), "site_name": self._clean_text(score.get("site_name") or torrent.get("site_name")), }, "recommended_action": recommended_action, "next_command": next_command, "detail_command": detail_command, "plan_command": plan_command, "raw_item": item, } async def _assistant_smart_search_hdhive_resources( self, *, keyword: str, session: str, cache_key: str, media_type: str, year: str, target_path: str, preferences: Dict[str, Any], ) -> Dict[str, Any]: allowed, disabled = self._ensure_hdhive_resource_enabled() if not allowed: return { "source_type": "hdhive", "ok": False, "skipped": True, "reason": disabled.get("message") or "影巢资源入口已关闭", "state": {}, "candidates": [], "items": [], "score_summary": {}, } service = self._ensure_hdhive_service() preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) pansou_enabled = self._assistant_source_enabled(preferences, "pansou") search_ok, result, search_message = await service.resolve_candidates_by_keyword( keyword=keyword, media_type=media_type, year=year, candidate_limit=max(30, self._hdhive_candidate_page_size), ) if not search_ok: return { "source_type": "hdhive", "ok": False, "reason": search_message, "state": {}, "candidates": [], "items": [], "score_summary": {}, } candidates = result.get("candidates") or [] if not candidates: return { "source_type": "hdhive", "ok": True, "reason": "未找到影巢候选", "state": { "kind": "assistant_hdhive", "stage": "candidate", "keyword": keyword, "media_type": media_type, "year": year, "target_path": target_path, "candidates": [], "page": 1, "page_size": self._hdhive_candidate_page_size, }, "candidates": [], "items": [], "score_summary": {}, } selected_candidate: Dict[str, Any] = {} selected_preview: List[Dict[str, Any]] = [] selected_summary: Dict[str, Any] = {} for candidate in candidates[:3]: resource_ok, resource_result, resource_message = service.search_resources( media_type=candidate.get("media_type") or media_type or "movie", tmdb_id=str(candidate.get("tmdb_id") or ""), ) if not resource_ok: continue preview = self._attach_cloud_scores( self._group_resource_preview(resource_result.get("data") or [], per_group=None), preferences=preferences, source_type="hdhive", target_path=target_path, ) if not preview: continue summary = self._score_summary(preview, limit=5) best = summary.get("best") or {} current_best = selected_summary.get("best") if isinstance(selected_summary.get("best"), dict) else {} if not selected_preview or self._safe_int(best.get("score"), 0) > self._safe_int(current_best.get("score"), 0): selected_candidate = dict(candidate or {}) selected_preview = preview selected_summary = summary if self._assistant_smart_search_stop_after_source( self._assistant_smart_search_candidate_from_cloud_item(self._best_scored_source_item(preview), source_type="hdhive"), preferences, ): break state: Dict[str, Any] if selected_preview: state = { "kind": "assistant_hdhive", "stage": "resource", "keyword": keyword, "media_type": media_type, "year": year, "target_path": target_path, "selected_candidate": selected_candidate, "resources": selected_preview, "page": 1, "page_size": self._assistant_result_page_size, } else: state = { "kind": "assistant_hdhive", "stage": "candidate", "keyword": keyword, "media_type": media_type, "year": year, "target_path": target_path, "candidates": candidates, "page": 1, "page_size": self._hdhive_candidate_page_size, } return { "source_type": "hdhive", "ok": True, "reason": search_message, "state": state, "candidates": candidates, "items": selected_preview, "score_summary": selected_summary, } def _assistant_smart_search_decision_summary( self, *, keyword: str, preferences: Dict[str, Any], checked: List[Dict[str, Any]], best_candidate: Dict[str, Any], available_sources: Optional[List[Dict[str, Any]]] = None, blocked_sources: Optional[List[Dict[str, Any]]] = None, decision_profile: str = "", ) -> Dict[str, Any]: prefs = self._normalize_assistant_preferences(preferences) threshold = self._safe_int( prefs.get("confirm_score_threshold"), self._assistant_default_confirm_score_threshold, ) auto_threshold = self._safe_int( prefs.get("auto_ingest_score_threshold"), self._assistant_default_auto_ingest_score_threshold, ) source_names = { "pansou": "盘搜", "hdhive": "影巢", "mp_pt": "MP/PT", } preferred_command = self._clean_text(best_candidate.get("next_command")) title = self._clean_text(best_candidate.get("title")) source_type = self._clean_text(best_candidate.get("source_type")).lower() is_pt_source = source_type == "mp_pt" fallback_command = self._clean_text(best_candidate.get("plan_command" if is_pt_source else "detail_command")) detail_command = "" if is_pt_source else "先看详情" if best_candidate.get("choice") else "" detail_short_command = "" if is_pt_source else "详情" if best_candidate.get("choice") else "" score_value = self._safe_int(best_candidate.get("score"), 0) hard_risks = [self._clean_text(value) for value in (best_candidate.get("hard_risk_reasons") or []) if self._clean_text(value)] write_intent_commands = {"计划最佳", "执行最佳", "确认执行", "先计划"} decision_mode = "show_detail" confirm_required = False decision_reason = "" confirmation_prompt = "" if not best_candidate: return { "checked_sources": [self._clean_text(item.get("source_type")) for item in checked if self._clean_text(item.get("source_type"))], "decision_hint": "已按当前偏好尝试可用源,但没有找到符合条件的资源。", "decision_mode": "not_recommended", "decision_reason": "所有可用源都没有给出符合当前偏好的候选。", "preferred_command": "", "fallback_command": "", "detail_command": "", "detail_short_command": "", "confirmation_prompt": "", "plan_short_command": "", "confirm_short_command": "", "compact_commands": [], "available_sources": available_sources or [], "blocked_sources": blocked_sources or [], "confirm_required": False, "decision_profile": self._clean_text(decision_profile), "recommended_agent_behavior": "show_only", } if hard_risks: hint = f"已检查 {' -> '.join(source_names.get(self._clean_text(item.get('source_type')).lower(), self._clean_text(item.get('source_type'))) for item in checked if self._clean_text(item.get('source_type')))};当前最高分是{source_names.get(source_type, source_type)} #{best_candidate.get('choice')},但存在硬风险。" decision_mode = "not_recommended" decision_reason = "当前最高分候选存在硬风险,不能作为直接推荐执行项。" confirmation_prompt = "直接回编号下载,或换源后再试。" if is_pt_source else "先看详情,或换源后再试。" elif score_value >= threshold: hint = f"已检查 {' -> '.join(source_names.get(self._clean_text(item.get('source_type')).lower(), self._clean_text(item.get('source_type'))) for item in checked if self._clean_text(item.get('source_type')))};当前首选是{source_names.get(source_type, source_type)} #{best_candidate.get('choice')}({score_value}分)。" if is_pt_source: decision_mode = "execute_now" decision_reason = "PT 搜索结果默认回编号直接下载。" fallback_command = self._clean_text(best_candidate.get("plan_command")) or fallback_command confirm_required = False confirmation_prompt = "直接回编号下载。" elif score_value >= auto_threshold: decision_mode = "execute_now" decision_reason = "当前首选分数已达到高可信区间,可以作为立即执行首选,但写入仍需明确意图。" preferred_command = "执行最佳" fallback_command = "计划最佳" confirm_required = True confirmation_prompt = "确认执行;如果想保守一点,回复:先计划 或 先看详情。" else: decision_mode = "make_plan" decision_reason = "当前首选已达到建议确认阈值,优先生成待确认计划。" preferred_command = "计划最佳" fallback_command = "执行最佳" confirm_required = True confirmation_prompt = "先计划;如果想直接落地,回复:确认执行;如果想先检查资源,回复:先看详情。" else: hint = f"已检查 {' -> '.join(source_names.get(self._clean_text(item.get('source_type')).lower(), self._clean_text(item.get('source_type'))) for item in checked if self._clean_text(item.get('source_type')))};当前最佳候选是{source_names.get(source_type, source_type)} #{best_candidate.get('choice')}({score_value}分),但还没达到优先阈值。" if is_pt_source: decision_mode = "execute_now" decision_reason = "当前最佳 PT 候选分数偏低,但仍可直接回编号下载。" fallback_command = self._clean_text(best_candidate.get("plan_command")) or fallback_command confirmation_prompt = "直接回编号下载。" else: decision_mode = "show_detail" decision_reason = "当前最佳候选分数偏低,优先查看详情或尝试切换搜索源。" preferred_command = fallback_command or preferred_command fallback_command = "计划最佳" if best_candidate.get("choice") and not hard_risks else "" confirmation_prompt = "先看详情;如果仍要继续,回复:先计划 或 换影巢 / 换盘搜 / 换PT。" if title: hint = f"{hint} {title}" auto_run_command = "" confirm_command = "" display_command = preferred_command or detail_command preferred_requires_confirmation = preferred_command in write_intent_commands fallback_requires_confirmation = fallback_command in write_intent_commands if preferred_requires_confirmation: confirm_command = preferred_command if detail_command and detail_command != preferred_command: command_policy = "read_then_confirm_write" auto_run_command = detail_command display_command = detail_command recommended_agent_behavior = "auto_continue_then_wait_confirmation" else: command_policy = "wait_user_confirmation" recommended_agent_behavior = "wait_user_confirmation" can_auto_run_preferred = False else: command_policy = "safe_read_only" can_auto_run_preferred = bool(preferred_command) recommended_agent_behavior = "show_only" if decision_mode == "not_recommended" else "auto_continue" display_command = preferred_command or detail_command return { "checked_sources": [self._clean_text(item.get("source_type")) for item in checked if self._clean_text(item.get("source_type"))], "threshold": threshold, "auto_ingest_score_threshold": auto_threshold, "preferred_source": source_type, "preferred_score": score_value, "preferred_title": title[:160], "decision_hint": hint.strip(), "decision_mode": decision_mode, "decision_reason": decision_reason or hint.strip(), "preferred_command": preferred_command, "fallback_command": fallback_command, "detail_command": detail_command if best_candidate.get("choice") and not hard_risks else "", "detail_short_command": detail_short_command if best_candidate.get("choice") and not hard_risks else "", "confirmation_prompt": confirmation_prompt, "plan_command": (self._clean_text(best_candidate.get("plan_command")) if is_pt_source else "计划最佳") if best_candidate.get("choice") and not hard_risks else "", "plan_short_command": "" if is_pt_source else "计划" if best_candidate.get("choice") and not hard_risks else "", "execute_command": "" if is_pt_source else "执行最佳" if best_candidate.get("choice") and not hard_risks else "", "confirm_short_command": "" if is_pt_source else "确认" if best_candidate.get("choice") and not hard_risks else "", "compact_commands": [ command for command in [preferred_command, fallback_command, detail_short_command or detail_command] if command ][:2], "command_policy": command_policy, "preferred_requires_confirmation": preferred_requires_confirmation, "fallback_requires_confirmation": fallback_requires_confirmation, "can_auto_run_preferred": can_auto_run_preferred, "auto_run_command": auto_run_command, "confirm_command": confirm_command, "display_command": display_command, "available_sources": available_sources or [], "blocked_sources": blocked_sources or [], "confirm_required": confirm_required, "decision_profile": self._clean_text(decision_profile), "recommended_agent_behavior": recommended_agent_behavior, } def _assistant_smart_decision_entry_message( self, *, keyword: str, decision_summary: Dict[str, Any], available_sources: List[Dict[str, Any]], blocked_sources: List[Dict[str, Any]], ) -> str: checked = decision_summary.get("checked_sources") or [] checked_text = " -> ".join( { "pansou": "盘搜", "hdhive": "影巢", "mp_pt": "MP/PT", }.get(self._clean_text(item).lower(), self._clean_text(item)) for item in checked if self._clean_text(item) ) lines = [f"资源决策:{keyword}"] if checked_text: lines.append(f"已检查:{checked_text}") if available_sources: lines.append("可用源:" + " / ".join(self._clean_text(item.get("label")) for item in available_sources if self._clean_text(item.get("label")))) if blocked_sources: blocked_text = ";".join( f"{self._clean_text(item.get('label'))}:{self._clean_text(item.get('reason'))}" for item in blocked_sources[:3] if self._clean_text(item.get("label")) and self._clean_text(item.get("reason")) ) if blocked_text: lines.append("已跳过:" + blocked_text) if self._clean_text(decision_summary.get("decision_hint")): lines.append(self._clean_text(decision_summary.get("decision_hint"))) if self._clean_text(decision_summary.get("decision_reason")): lines.append("结论:" + self._clean_text(decision_summary.get("decision_reason"))) preferred_command = self._clean_text(decision_summary.get("preferred_command")) fallback_command = self._clean_text(decision_summary.get("fallback_command")) confirmation_prompt = self._clean_text(decision_summary.get("confirmation_prompt")) if preferred_command: lines.append(f"首选:{preferred_command}") if fallback_command and fallback_command != preferred_command: lines.append(f"备选:{fallback_command}") if confirmation_prompt: lines.append("确认链:" + confirmation_prompt) return "\n".join(line for line in lines if line).strip() def _assistant_smart_source_item_by_choice( self, source_states: Dict[str, Any], *, source_type: str, choice: int, ) -> Dict[str, Any]: current_state = source_states.get(source_type) if isinstance(source_states, dict) else {} current_state = current_state if isinstance(current_state, dict) else {} if source_type == "pansou": items = current_state.get("items") or [] elif source_type == "hdhive": items = current_state.get("resources") or current_state.get("items") or [] else: items = current_state.get("items") or [] for item in items: if self._safe_int((item or {}).get("index") or (item or {}).get("pick_index"), 0) == choice: return dict(item or {}) return {} def _assistant_smart_best_plan_response( self, *, session: str, cache_key: str, state: Optional[Dict[str, Any]] = None, target_path: str = "", ) -> Dict[str, Any]: current_state = dict(state or self._load_session(cache_key) or {}) if self._clean_text(current_state.get("kind")) != "assistant_smart_search": return { "success": False, "message": "当前没有可继续的智能搜索结果,请先发送:智能搜索 片名 或 智能计划 片名。", "data": self._assistant_response_data(session=session, data={ "action": "smart_resource_plan", "ok": False, "error_code": "smart_search_session_not_found", }), } best_candidate = current_state.get("best_candidate") if isinstance(current_state.get("best_candidate"), dict) else {} decision_summary = current_state.get("decision_summary") if isinstance(current_state.get("decision_summary"), dict) else {} checked = current_state.get("sources_checked") if isinstance(current_state.get("sources_checked"), list) else [] source_states = current_state.get("source_states") if isinstance(current_state.get("source_states"), dict) else {} if not best_candidate: return { "success": False, "message": "智能搜索当前没有可用于生成计划的候选,请先换片名,或调整偏好后再试。", "data": self._assistant_response_data(session=session, data={ "action": "smart_resource_plan", "ok": False, "error_code": "smart_best_candidate_not_found", "decision_summary": decision_summary, "sources_checked": checked, }), } source_type = self._clean_text(best_candidate.get("source_type")).lower() choice = self._safe_int(best_candidate.get("choice"), 0) score_value = self._safe_int(best_candidate.get("score"), 0) title = self._clean_text(best_candidate.get("title")) hard_risks = [self._clean_text(value) for value in (best_candidate.get("hard_risk_reasons") or []) if self._clean_text(value)] risks = [self._clean_text(value) for value in (best_candidate.get("risk_reasons") or []) if self._clean_text(value)] origin = self._clean_text(current_state.get("origin")).lower() final_path = ( target_path or self._clean_text(current_state.get("target_path")) or self._clean_text(((source_states.get(source_type) or {}) if isinstance(source_states, dict) else {}).get("target_path")) ) if hard_risks: head = self._clean_text(decision_summary.get("decision_hint")) or "当前首选存在硬风险。" tail = ";".join(hard_risks[:2]) return { "success": False, "message": f"{head}\n未生成计划:{tail}", "data": self._assistant_response_data(session=session, data={ "action": "smart_resource_plan", "ok": False, "error_code": "smart_best_candidate_blocked", "best_candidate": best_candidate, "decision_summary": decision_summary, "sources_checked": checked, }), } result: Dict[str, Any] if source_type == "pansou": selected = self._assistant_smart_source_item_by_choice(source_states, source_type="pansou", choice=choice) or dict(best_candidate.get("raw_item") or {}) share_url = self._clean_text(selected.get("url")) if not share_url: return { "success": False, "message": "当前盘搜首选缺少可转存链接,无法生成计划。", "data": self._assistant_response_data(session=session, data={ "action": "smart_resource_plan", "ok": False, "error_code": "smart_plan_missing_share_url", "best_candidate": best_candidate, }), } provider_path = self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path result = self._save_assistant_pick_plan_response( workflow="smart_resource_plan", session=session, session_id=cache_key, actions=[{ "name": "route_share", "session": session, "session_id": cache_key, "url": share_url, "access_code": self._clean_text(selected.get("password")), "path": final_path or provider_path, }], execute_body={ "workflow": "smart_resource_plan", "session": session, "session_id": cache_key, "choice": choice, "mode": "pansou", "path": final_path or provider_path, }, message="智能搜索待确认计划已生成", score_items=[selected], extra_data={ "choice": choice, "source_type": "pansou", "target_path": final_path or provider_path, "selected_item": selected, }, ) elif source_type == "hdhive": resource = self._assistant_smart_source_item_by_choice(source_states, source_type="hdhive", choice=choice) or dict(best_candidate.get("raw_item") or {}) slug = self._clean_text(resource.get("slug")) if not slug: return { "success": False, "message": "当前影巢首选缺少 slug,无法生成计划。", "data": self._assistant_response_data(session=session, data={ "action": "smart_resource_plan", "ok": False, "error_code": "smart_plan_missing_hdhive_slug", "best_candidate": best_candidate, }), } result = self._save_assistant_pick_plan_response( workflow="smart_resource_plan", session=session, session_id=cache_key, actions=[{ "name": "unlock_hdhive_resource", "session": session, "session_id": cache_key, "slug": slug, "path": final_path or self._hdhive_default_path, "resource": resource, }], execute_body={ "workflow": "smart_resource_plan", "session": session, "session_id": cache_key, "choice": choice, "mode": "hdhive", "path": final_path or self._hdhive_default_path, }, message="智能搜索待确认计划已生成", score_items=[resource], extra_data={ "choice": choice, "source_type": "hdhive", "target_path": final_path or self._hdhive_default_path, "selected_resource": resource, }, ) elif source_type == "mp_pt": preferences = self._assistant_preferences_for_session(session=session) result = self._assistant_mp_download_plan_response( choice=choice, session=session, cache_key=cache_key, preferences=preferences, workflow="smart_resource_plan", message="智能搜索待确认计划已生成", ) else: return { "success": False, "message": "当前首选来源暂不支持统一计划生成,请先查看详情后手动选择。", "data": self._assistant_response_data(session=session, data={ "action": "smart_resource_plan", "ok": False, "error_code": "smart_plan_unsupported_source", "best_candidate": best_candidate, }), } data = dict(result.get("data") or {}) adjusted_decision_summary = dict(decision_summary) if origin == "mp_recommend" and choice and not hard_risks: adjusted_decision_summary.update({ "decision_mode": "make_plan", "decision_reason": "推荐会话已为当前首项生成待确认计划;建议先看详情,再决定是否确认执行。", "preferred_command": "确认", "fallback_command": "详情", "detail_command": "详情", "detail_short_command": "详情", "confirmation_prompt": "先看详情;如果确认无误,回复:确认。", "plan_command": "计划", "plan_short_command": "计划", "execute_command": "确认", "confirm_short_command": "确认", "compact_commands": ["确认", "详情"], "command_policy": "read_then_confirm_write", "preferred_requires_confirmation": True, "fallback_requires_confirmation": False, "can_auto_run_preferred": False, "auto_run_command": "详情", "confirm_command": "确认", "display_command": "详情", "recommended_agent_behavior": "auto_continue_then_wait_confirmation", }) data.update({ "source_type": source_type, "best_candidate": best_candidate, "decision_summary": adjusted_decision_summary, "sources_checked": checked, "smart_plan_auto_selected": True, }) for key in ["detail_short_command", "plan_short_command", "confirm_short_command", "auto_run_command", "confirm_command", "display_command"]: if key in adjusted_decision_summary: data[key] = adjusted_decision_summary.get(key) command_summary = self._assistant_compact_command_summary(data) if command_summary: data.update(command_summary) if choice and not data.get("choice"): data["choice"] = choice result["data"] = data message_lines = [ "已根据智能搜索结果自动选择当前首选并生成待确认计划", f"首选:{source_type} #{choice} | {score_value}分" + (f" | {title}" if title else ""), result.get("message") or "", ] if risks: message_lines.append("风险提示:" + ";".join(risks[:2])) result["message"] = "\n".join(line for line in message_lines if line) return result async def _assistant_execute_prepared_plan_result( self, request: Request, *, session: str, cache_key: str, plan_result: Dict[str, Any], message_prefix: str = "", extra_data: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: if not bool(plan_result.get("success")): return plan_result plan_data = dict(plan_result.get("data") or {}) plan_id = self._clean_text(plan_data.get("plan_id")) if not plan_id: return { "success": False, "message": "当前没有可执行的待确认计划,请先生成计划后再执行。", "data": self._assistant_response_data(session=session, data={ "action": "execute_plan", "ok": False, "error_code": "plan_not_found", }), } execute_result = await self.api_assistant_plan_execute( _JsonRequestShim(request, { "session": session, "session_id": cache_key, "plan_id": plan_id, "prefer_unexecuted": True, "stop_on_error": True, "include_raw_results": False, "compact": False, "apikey": self._extract_apikey(request, {}), }) ) execute_data = dict(execute_result.get("data") or {}) for key in [ "source_type", "best_candidate", "decision_summary", "sources_checked", "score_summary", "choice", "smart_plan_auto_selected", ]: if key in plan_data: execute_data[key] = plan_data.get(key) if extra_data: execute_data.update(dict(extra_data)) execute_result["data"] = execute_data if message_prefix: execute_result["message"] = "\n".join( line for line in [message_prefix, self._clean_text(execute_result.get("message"))] if line ).strip() return execute_result async def _assistant_smart_best_execute_response( self, request: Request, *, session: str, cache_key: str, state: Optional[Dict[str, Any]] = None, target_path: str = "", ) -> Dict[str, Any]: plan_result = self._assistant_smart_best_plan_response( session=session, cache_key=cache_key, state=state, target_path=target_path, ) plan_data = dict(plan_result.get("data") or {}) best_candidate = plan_data.get("best_candidate") if isinstance(plan_data.get("best_candidate"), dict) else {} source_type = self._clean_text(best_candidate.get("source_type")).lower() choice = self._safe_int(best_candidate.get("choice"), 0) score_value = self._safe_int(best_candidate.get("score"), 0) title = self._clean_text(best_candidate.get("title")) source_label = {"pansou": "盘搜", "hdhive": "影巢", "mp_pt": "MP/PT"}.get(source_type, source_type or "当前源") transfer_source_text = { "pansou": "盘搜搜索转存", "hdhive": "影巢搜索转存", "mp_pt": "MP/PT 下载", }.get(source_type, source_label) prefix = "已根据智能搜索结果自动生成并执行当前首选计划" prefix += f"\n执行方式:{transfer_source_text}" if choice: prefix += f"\n首选:{source_label} #{choice} | {score_value}分" + (f" | {title}" if title else "") result = await self._assistant_execute_prepared_plan_result( request, session=session, cache_key=cache_key, plan_result=plan_result, message_prefix=prefix, extra_data={ "smart_execute_auto_selected": True, "source_type": source_type, }, ) if result.get("success"): overrides = dict((state or {}).get("session_preference_overrides") or {}) prefer_provider = self._clean_text(overrides.get("prefer_cloud_provider")).lower() provider_label = "115 转存" if prefer_provider == "115" else "夸克转存" if prefer_provider == "quark" else "资源转存" compact_lines = [f"已自动完成 {provider_label} ✅"] if choice: resource_line = f"• 资源:{source_label} #{choice}" if title: resource_line += f" — {title}" if score_value > 0: resource_line += f",{score_value}分" compact_lines.append(resource_line) compact_lines.append(f"• 执行方式:{transfer_source_text}") compact_lines.append("• 状态:执行成功") result["message"] = "\n".join(compact_lines) return result async def _assistant_cloud_transfer_execute( self, request: Request, *, keyword: str, session: str, cache_key: str, media_type: str, year: str, source_order: Optional[List[str]] = None, target_path: str = "", session_preference_overrides: Optional[Dict[str, Any]] = None, origin: str = "", ) -> Dict[str, Any]: effective_target_path = self._clean_text(target_path) prefer_provider = self._clean_text((session_preference_overrides or {}).get("prefer_cloud_provider")).lower() if not effective_target_path: if prefer_provider == "quark": effective_target_path = self._quark_default_path elif prefer_provider == "115": effective_target_path = self._p115_default_path merged_preferences = self._assistant_smart_merge_session_preferences( self._assistant_preferences_for_session(session=session), session_overrides=session_preference_overrides, ) enabled_cloud_source_order = self._assistant_transfer_source_order( preferences=merged_preferences, prefer_provider=prefer_provider, requested_source_order=source_order, ) if not enabled_cloud_source_order: if prefer_provider == "quark": return { "success": False, "message": "夸克转存只走盘搜;当前盘搜入口已关闭,可在插件设置中开启“启用盘搜”。", "data": self._assistant_response_data(session=session, data={ "action": "cloud_transfer_execute", "ok": False, "error_code": "pansou_required_for_quark_transfer", }), } if prefer_provider == "115": hdhive_enabled = self._assistant_source_enabled(merged_preferences, "hdhive") message = ( f"盘搜入口已关闭;转存/115转存当前不会自动降级执行影巢转存。可继续试:影巢搜索 {keyword}" if hdhive_enabled else "115转存当前只走盘搜;当前盘搜入口已关闭,可在插件设置中开启“启用盘搜”。" ) return { "success": False, "message": message, "data": self._assistant_response_data(session=session, data={ "action": "cloud_transfer_execute", "ok": False, "error_code": "pansou_required_for_115_transfer", }), } return self._cloud_sources_disabled_response() search_result = await self._assistant_smart_resource_search( request, keyword=keyword, session=session, cache_key=cache_key, media_type=media_type, year=year, source_order=enabled_cloud_source_order, target_path=effective_target_path, session_preference_overrides=session_preference_overrides, origin=origin, ) smart_state = self._assistant_smart_state_after_search( cache_key=cache_key, keyword=keyword, media_type=media_type, year=year, source_order=enabled_cloud_source_order, target_path=effective_target_path, search_result=search_result, origin=origin, ) best_candidate = dict((smart_state.get("best_candidate") or {})) if not search_result.get("success") and not best_candidate.get("source_type"): if prefer_provider == "115" and enabled_cloud_source_order == ["pansou"]: return { **search_result, "message": "\n".join( line for line in [ self._clean_text(search_result.get("message")), f"盘搜无结果,可继续试:影巢搜索 {keyword}", ] if line ), } if prefer_provider == "quark" and enabled_cloud_source_order == ["pansou"]: return { **search_result, "message": "\n".join( line for line in [ self._clean_text(search_result.get("message")), f"盘搜无结果。夸克转存当前只走盘搜;如果想继续找资源,可改发:影巢搜索 {keyword}", ] if line ), } return search_result preferences = merged_preferences threshold = self._safe_int( preferences.get("confirm_score_threshold"), self._assistant_default_confirm_score_threshold, ) score_value = self._safe_int(best_candidate.get("score"), 0) hard_risks = [self._clean_text(value) for value in (best_candidate.get("hard_risk_reasons") or []) if self._clean_text(value)] if best_candidate.get("source_type") and score_value >= threshold and not hard_risks: execute_result = await self._assistant_smart_best_execute_response( request, session=session, cache_key=cache_key, state=smart_state, target_path=effective_target_path, ) source_type = self._clean_text(best_candidate.get("source_type")).lower() if source_type == "hdhive": raw_item = dict(best_candidate.get("raw_item") or {}) points_text = self._resource_points_text(raw_item) if points_text and points_text not in {"免费", "未标积分"}: execute_result["message"] = "\n".join( line for line in [ f"影巢积分:{points_text},已直接解锁并转存。", self._clean_text(execute_result.get("message")), ] if line ).strip() return execute_result source_states = smart_state.get("source_states") if isinstance(smart_state.get("source_states"), dict) else {} pansou_state = dict(source_states.get("pansou") or {}) hdhive_state = dict(source_states.get("hdhive") or {}) cloud_result = self._assistant_finalize_cloud_result( session=session, cache_key=cache_key, keyword=keyword, pansou_items=pansou_state.get("items") or [], pansou_total=len(pansou_state.get("items") or []), hdhive_resources=hdhive_state.get("resources") or [], hdhive_candidate=hdhive_state.get("selected_candidate") or {}, hdhive_candidates=hdhive_state.get("candidates") or [], target_path=effective_target_path or self._hdhive_default_path, lead_note="当前云盘资源质量或风险未达到直接转存阈值,已列出盘搜 + 影巢结果供你手动选择。", ) top_choices: List[str] = [] if best_candidate.get("choice") and best_candidate.get("source_type"): source_label = {"pansou": "盘搜", "hdhive": "影巢"}.get( self._clean_text(best_candidate.get("source_type")).lower(), self._clean_text(best_candidate.get("source_type")), ) top_choices.append(f"{source_label} #{self._safe_int(best_candidate.get('choice'), 0)}") for item in (smart_state.get("alternatives") or []): if not isinstance(item, dict): continue source_type = self._clean_text(item.get("source_type")).lower() if source_type not in {"pansou", "hdhive"}: continue source_label = {"pansou": "盘搜", "hdhive": "影巢"}.get(source_type, source_type) label = f"{source_label} #{self._safe_int(item.get('choice'), 0)}" if label not in top_choices and self._safe_int(item.get("choice"), 0) > 0: top_choices.append(label) if len(top_choices) >= 3: break if top_choices: cloud_result["message"] = "\n".join( [ self._clean_text(cloud_result.get("message")), "推荐优先看:" + " / ".join(top_choices[:3]), ] ).strip() if isinstance(cloud_result.get("data"), dict): cloud_result["data"]["action"] = "cloud_transfer_execute" cloud_result["data"]["best_candidate"] = best_candidate return cloud_result async def _assistant_smart_resource_search( self, request: Request, *, keyword: str, session: str, cache_key: str, media_type: str, year: str, source_order: Optional[List[str]] = None, target_path: str = "", decision_profile: str = "", session_preference_overrides: Optional[Dict[str, Any]] = None, action_name: str = "smart_resource_search", origin: str = "", ) -> Dict[str, Any]: base_preferences = self._assistant_preferences_for_session(session=session) preferences = self._assistant_smart_merge_session_preferences( base_preferences, session_overrides=session_preference_overrides, decision_profile=decision_profile, ) search_order = self._assistant_smart_search_source_order(preferences, source_order=source_order) available_sources, blocked_sources = self._assistant_smart_source_availability( preferences, source_order=source_order, ) if not search_order: return { "success": False, "message": "当前偏好把盘搜、影巢、MP/PT 都关闭了,请先保存偏好后再试。", "data": self._assistant_response_data(session=session, data={ "action": action_name, "ok": False, "error_code": "no_enabled_sources", "preference_status": self._assistant_preferences_status_brief(session=session), "available_sources": available_sources, "blocked_sources": blocked_sources, }), } checked: List[Dict[str, Any]] = [] alternatives: List[Dict[str, Any]] = [] active_state: Dict[str, Any] = {} best_candidate: Dict[str, Any] = {} best_source_score = -1 source_states: Dict[str, Any] = {} threshold = self._safe_int(preferences.get("confirm_score_threshold"), self._assistant_default_confirm_score_threshold) for current_source in search_order: if current_source == "pansou": search_ok, payload, search_message = self._call_pansou_search(keyword) data = payload.get("data") or {} merged = data.get("merged_by_type") or {} channel_115 = self._collect_pansou_channel_items(merged, "115", 20) channel_quark = self._collect_pansou_channel_items(merged, "quark", 20) items: List[Dict[str, Any]] = [] for item in channel_115 + channel_quark: items.append({**item, "index": len(items) + 1}) items = self._assistant_filter_cloud_items_by_preferences(items, preferences) for idx, item in enumerate(items, start=1): item["index"] = idx items = self._attach_cloud_scores( items, preferences=preferences, source_type="pansou", target_path=target_path or self._hdhive_default_path, ) items = self._rank_pansou_items(items, limit_per_channel=6) source_state = { "kind": "assistant_pansou", "stage": "result", "keyword": keyword, "target_path": target_path or self._hdhive_default_path, "items": items, } source_states[current_source] = copy.deepcopy(source_state) summary = self._score_summary(items, limit=5) best_item = self._best_scored_source_item(items) source_best = self._assistant_smart_search_candidate_from_cloud_item(best_item, source_type="pansou") if best_item else {} checked.append({ "source_type": current_source, "ok": bool(search_ok), "result_count": len(items), "best_score": self._safe_int((source_best or {}).get("score"), 0), "continued": not self._assistant_smart_search_stop_after_source(source_best, preferences), "reason": search_message or ("盘搜无结果" if not items else ""), }) alternatives.extend( self._assistant_smart_search_candidate_from_cloud_item(item, source_type="pansou") for item in items[:4] ) if source_best and self._safe_int(source_best.get("score"), 0) > best_source_score: best_source_score = self._safe_int(source_best.get("score"), 0) best_candidate = source_best active_state = source_state if self._assistant_smart_search_stop_after_source(source_best, preferences): break continue if current_source == "hdhive": hdhive_result = await self._assistant_smart_search_hdhive_resources( keyword=keyword, session=session, cache_key=cache_key, media_type=media_type, year=year, target_path=target_path or self._hdhive_default_path, preferences=preferences, ) source_state = dict(hdhive_result.get("state") or {}) items = hdhive_result.get("items") or [] items = self._assistant_filter_cloud_items_by_preferences(items, preferences) source_state["items"] = items source_state["resources"] = items source_states[current_source] = copy.deepcopy(source_state) summary = hdhive_result.get("score_summary") if isinstance(hdhive_result.get("score_summary"), dict) else {} best_item = self._best_scored_source_item(items) source_best = self._assistant_smart_search_candidate_from_cloud_item(best_item, source_type="hdhive") if best_item else {} checked.append({ "source_type": current_source, "ok": bool(hdhive_result.get("ok")), "result_count": len(items), "candidate_count": len(hdhive_result.get("candidates") or []), "best_score": self._safe_int((source_best or {}).get("score"), 0), "continued": not self._assistant_smart_search_stop_after_source(source_best, preferences), "reason": self._clean_text(hdhive_result.get("reason")), }) alternatives.extend( self._assistant_smart_search_candidate_from_cloud_item(item, source_type="hdhive") for item in items[:4] ) if source_best and self._safe_int(source_best.get("score"), 0) > best_source_score: best_source_score = self._safe_int(source_best.get("score"), 0) best_candidate = source_best active_state = source_state if self._assistant_smart_search_stop_after_source(source_best, preferences): break continue mp_result = await self._assistant_mp_media_search( keyword=keyword, session=session, cache_key=cache_key, preferences=preferences, ) items = ((mp_result.get("data") or {}).get("items") or []) if isinstance(mp_result.get("data"), dict) else [] summary = ((mp_result.get("data") or {}).get("score_summary") or {}) if isinstance(mp_result.get("data"), dict) else {} source_state = copy.deepcopy(self._load_session(cache_key) or {}) source_states[current_source] = source_state best_item = self._best_scored_source_item(items) source_best = self._assistant_smart_search_candidate_from_pt_item(best_item) if best_item else {} checked.append({ "source_type": current_source, "ok": bool(mp_result.get("success")), "result_count": len(items), "best_score": self._safe_int((source_best or {}).get("score"), 0), "continued": not self._assistant_smart_search_stop_after_source(source_best, preferences), "reason": self._assistant_result_message_head(mp_result.get("message")), }) alternatives.extend( self._assistant_smart_search_candidate_from_pt_item(item) for item in items[:4] ) if source_best and self._safe_int(source_best.get("score"), 0) > best_source_score: best_source_score = self._safe_int(source_best.get("score"), 0) best_candidate = source_best active_state = source_state if self._assistant_smart_search_stop_after_source(source_best, preferences): break alternatives = [item for item in alternatives if isinstance(item, dict) and item.get("choice")] alternatives.sort( key=lambda item: ( self._safe_int(item.get("score"), 0), 0 if item.get("hard_risk_reasons") else 1, ), reverse=True, ) unique_alternatives: List[Dict[str, Any]] = [] seen_keys: set = set() for item in alternatives: dedupe_key = ( self._clean_text(item.get("source_type")).lower(), self._safe_int(item.get("choice"), 0), ) if dedupe_key in seen_keys: continue seen_keys.add(dedupe_key) unique_alternatives.append(item) decision_summary = self._assistant_smart_search_decision_summary( keyword=keyword, preferences=preferences, checked=checked, best_candidate=best_candidate, available_sources=available_sources, blocked_sources=blocked_sources, decision_profile=decision_profile, ) if active_state: smart_state = { "kind": "assistant_smart_search", "stage": "result", "keyword": keyword, "media_type": media_type, "year": year, "target_path": target_path or active_state.get("target_path") or "", "active_source": self._clean_text(best_candidate.get("source_type")), "active_state": copy.deepcopy(active_state), "source_states": source_states, "sources_checked": checked, "best_candidate": best_candidate, "alternatives": unique_alternatives[:6], "decision_summary": decision_summary, "available_sources": available_sources, "blocked_sources": blocked_sources, "source_order": search_order, "decision_profile": self._clean_text(decision_profile), "decision_entry": action_name, "origin": self._clean_text(origin), "session_preference_overrides": dict(session_preference_overrides or {}), } self._save_session(cache_key, smart_state) if action_name == "smart_resource_decision": message_lines = [ self._assistant_smart_decision_entry_message( keyword=keyword, decision_summary=decision_summary, available_sources=available_sources, blocked_sources=blocked_sources, ) ] else: message_lines = [ f"智能搜索:{keyword}", "已检查:" + " -> ".join( {"pansou": "盘搜", "hdhive": "影巢", "mp_pt": "MP/PT"}.get( self._clean_text(item.get("source_type")).lower(), self._clean_text(item.get("source_type")), ) for item in checked if self._clean_text(item.get("source_type")) ), ] if best_candidate: message_lines.append(decision_summary.get("decision_hint") or "") if best_candidate.get("hard_risk_reasons"): message_lines.append("降级原因:" + ";".join(best_candidate.get("hard_risk_reasons")[:2])) elif best_candidate.get("risk_reasons") and self._safe_int(best_candidate.get("score"), 0) < threshold: message_lines.append("继续原因:" + ";".join(best_candidate.get("risk_reasons")[:2])) else: message_lines.append("当前按偏好筛过可用源后,没有找到可推荐结果。") preferred_command = self._clean_text(decision_summary.get("preferred_command")) fallback_command = self._clean_text(decision_summary.get("fallback_command")) plan_command = self._clean_text(decision_summary.get("plan_command")) execute_command = self._clean_text(decision_summary.get("execute_command")) detail_short_command = self._clean_text(decision_summary.get("detail_short_command")) plan_short_command = self._clean_text(decision_summary.get("plan_short_command")) confirm_short_command = self._clean_text(decision_summary.get("confirm_short_command")) if action_name != "smart_resource_decision" and preferred_command: message_lines.append(f"建议先发:{preferred_command}") if action_name != "smart_resource_decision" and fallback_command and fallback_command != preferred_command: message_lines.append(f"备选:{fallback_command}") if action_name != "smart_resource_decision" and plan_command and plan_command not in {preferred_command, fallback_command}: message_lines.append(f"如需直接生成待确认计划:{plan_command}") if action_name != "smart_resource_decision" and execute_command and execute_command not in {preferred_command, fallback_command, plan_command}: message_lines.append(f"如需直接执行:{execute_command}") override_summary = self._assistant_smart_session_overrides_summary(session_preference_overrides) if override_summary: message_lines.append("会话偏好:" + override_summary) score_summary = {} active_source_type = self._clean_text(best_candidate.get("source_type")).lower() if active_source_type == "pansou": active_items = (source_states.get("pansou") or {}).get("items") or [] score_summary = self._score_summary(active_items, limit=5) elif active_source_type == "hdhive": active_items = (source_states.get("hdhive") or {}).get("resources") or [] score_summary = self._score_summary(active_items, limit=5) elif active_source_type == "mp_pt": active_items = (source_states.get("mp_pt") or {}).get("items") or [] score_summary = self._score_summary(active_items, limit=5) return { "success": True, "message": "\n".join(line for line in message_lines if line).strip(), "data": self._assistant_response_data(session=session, data={ "action": action_name, "ok": True, "sources_checked": checked, "best_candidate": best_candidate, "alternatives": unique_alternatives[:6], "decision_summary": decision_summary, "score_summary": score_summary, "preference_status": self._assistant_preferences_status_brief(session=session), "effective_preferences": preferences, "session_preference_overrides": dict(session_preference_overrides or {}), "available_sources": available_sources, "blocked_sources": blocked_sources, "decision_mode": self._clean_text(decision_summary.get("decision_mode")), "decision_reason": self._clean_text(decision_summary.get("decision_reason")), "next_actions": [ command for command in [ preferred_command, fallback_command, detail_short_command, plan_command, plan_short_command, execute_command, confirm_short_command, "继续决策", "换影巢", "换盘搜", "换PT", "保守一点", "激进一点", "只用夸克", "只用115", "只走PT", "不用影巢", "按保存偏好", "跟进", ] if self._clean_text(command) ], }), } async def _assistant_smart_resource_decision( self, request: Request, *, keyword: str, session: str, cache_key: str, media_type: str, year: str, source_order: Optional[List[str]] = None, target_path: str = "", decision_profile: str = "", session_preference_overrides: Optional[Dict[str, Any]] = None, origin: str = "", ) -> Dict[str, Any]: return await self._assistant_smart_resource_search( request, keyword=keyword, session=session, cache_key=cache_key, media_type=media_type, year=year, source_order=source_order, target_path=target_path, decision_profile=decision_profile, session_preference_overrides=session_preference_overrides, action_name="smart_resource_decision", origin=origin, ) async def _assistant_smart_resource_decision_adjust( self, request: Request, *, session: str, cache_key: str, state: Optional[Dict[str, Any]], adjust_action: str, ) -> Dict[str, Any]: current_state = dict(state or self._load_session(cache_key) or {}) if self._clean_text(current_state.get("kind")) != "assistant_smart_search": return { "success": False, "message": "当前没有可继续的资源决策会话,请先发送:资源决策 片名。", "data": self._assistant_response_data(session=session, data={ "action": "smart_resource_decision", "ok": False, "error_code": "smart_decision_session_not_found", }), } keyword = self._clean_text(current_state.get("keyword")) if not keyword: return { "success": False, "message": "当前资源决策会话缺少片名,请重新发送:资源决策 片名。", "data": self._assistant_response_data(session=session, data={ "action": "smart_resource_decision", "ok": False, "error_code": "smart_decision_missing_keyword", }), } media_type = self._clean_text(current_state.get("media_type") or "auto") or "auto" year = self._clean_text(current_state.get("year")) target_path = self._clean_text(current_state.get("target_path")) source_order = list(current_state.get("source_order") or []) session_preference_overrides = dict(current_state.get("session_preference_overrides") or {}) if not source_order: base_preferences = self._assistant_preferences_for_session(session=session) source_order = self._assistant_smart_search_source_order( self._assistant_smart_merge_session_preferences( base_preferences, session_overrides=session_preference_overrides, ) ) decision_profile = self._clean_text(current_state.get("decision_profile")) if adjust_action == "decision_hdhive": source_order = ["hdhive", "pansou", "mp_pt"] elif adjust_action == "decision_pansou": source_order = ["pansou", "hdhive", "mp_pt"] elif adjust_action == "decision_mp_pt": source_order = ["mp_pt", "pansou", "hdhive"] elif adjust_action in { "decision_only_quark", "decision_only_115", "decision_cloud_both", "decision_only_mp_pt", "decision_only_pansou", "decision_only_hdhive", "decision_disable_pansou", "decision_disable_hdhive", "decision_disable_mp_pt", "decision_reset_preferences", }: session_preference_overrides, source_order = self._assistant_smart_apply_session_override( session_preference_overrides, adjust_action=adjust_action, source_order=source_order, ) elif adjust_action == "decision_conservative": decision_profile = "conservative" elif adjust_action == "decision_aggressive": decision_profile = "aggressive" elif adjust_action == "decision_continue": pass return await self._assistant_smart_resource_decision( request, keyword=keyword, session=session, cache_key=cache_key, media_type=media_type, year=year, source_order=source_order, target_path=target_path, decision_profile=decision_profile, session_preference_overrides=session_preference_overrides, ) async def _assistant_smart_resource_plan( self, request: Request, *, keyword: str, session: str, cache_key: str, media_type: str, year: str, source_order: Optional[List[str]] = None, target_path: str = "", origin: str = "", ) -> Dict[str, Any]: if keyword: search_result = await self._assistant_smart_resource_search( request, keyword=keyword, session=session, cache_key=cache_key, media_type=media_type, year=year, source_order=source_order, target_path=target_path, origin=origin, ) smart_state = self._assistant_smart_state_after_search( cache_key=cache_key, keyword=keyword, media_type=media_type, year=year, source_order=source_order, target_path=target_path, search_result=search_result, origin=origin, ) if not search_result.get("success") and not bool(((search_result.get("data") or {}).get("best_candidate") or {}).get("source_type")): return search_result return self._assistant_smart_best_plan_response( session=session, cache_key=cache_key, state=smart_state, target_path=target_path, ) return self._assistant_smart_best_plan_response( session=session, cache_key=cache_key, target_path=target_path, ) async def _assistant_smart_resource_execute( self, request: Request, *, keyword: str, session: str, cache_key: str, media_type: str, year: str, source_order: Optional[List[str]] = None, target_path: str = "", origin: str = "", ) -> Dict[str, Any]: if keyword: search_result = await self._assistant_smart_resource_search( request, keyword=keyword, session=session, cache_key=cache_key, media_type=media_type, year=year, source_order=source_order, target_path=target_path, origin=origin, ) smart_state = self._assistant_smart_state_after_search( cache_key=cache_key, keyword=keyword, media_type=media_type, year=year, source_order=source_order, target_path=target_path, search_result=search_result, origin=origin, ) if not search_result.get("success") and not bool(((search_result.get("data") or {}).get("best_candidate") or {}).get("source_type")): return search_result return await self._assistant_smart_best_execute_response( request, session=session, cache_key=cache_key, state=smart_state, target_path=target_path, ) return await self._assistant_smart_best_execute_response( request, session=session, cache_key=cache_key, target_path=target_path, ) def _assistant_smart_state_after_search( self, *, cache_key: str, keyword: str, media_type: str, year: str, source_order: Optional[List[str]] = None, target_path: str = "", search_result: Optional[Dict[str, Any]] = None, origin: str = "", ) -> Dict[str, Any]: current_state = dict(self._load_session(cache_key) or {}) if self._clean_text(current_state.get("kind")) == "assistant_smart_search": return current_state data = (search_result or {}).get("data") if isinstance((search_result or {}).get("data"), dict) else {} best_candidate = data.get("best_candidate") if isinstance(data.get("best_candidate"), dict) else {} source_type = self._clean_text(best_candidate.get("source_type")).lower() if not source_type: return current_state source_states = copy.deepcopy(current_state.get("source_states") or {}) if isinstance(current_state.get("source_states"), dict) else {} raw_item = dict(best_candidate.get("raw_item") or {}) if isinstance(best_candidate.get("raw_item"), dict) else {} choice = self._safe_int(best_candidate.get("choice") or raw_item.get("index"), 0) final_path = self._clean_text(target_path or current_state.get("target_path") or data.get("target_path")) if source_type == "pansou" and raw_item: item = dict(raw_item) if choice and not item.get("index"): item["index"] = choice if not final_path: share_url = self._clean_text(item.get("url")) final_path = self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path source_states[source_type] = { "kind": "assistant_pansou", "stage": "result", "keyword": keyword, "target_path": final_path, "items": [item], } elif source_type == "hdhive" and raw_item: resource = dict(raw_item) source_states[source_type] = { "kind": "assistant_hdhive", "stage": "resource_list", "keyword": keyword, "target_path": final_path or self._hdhive_default_path, "resources": [resource], "items": [resource], } active_state = copy.deepcopy(source_states.get(source_type) or current_state.get("active_state") or {}) fallback_state = { "kind": "assistant_smart_search", "stage": "result", "keyword": keyword, "media_type": media_type, "year": year, "target_path": final_path, "active_source": source_type, "active_state": active_state, "source_states": source_states, "sources_checked": data.get("sources_checked") if isinstance(data.get("sources_checked"), list) else [], "best_candidate": best_candidate, "alternatives": data.get("alternatives") if isinstance(data.get("alternatives"), list) else [], "decision_summary": data.get("decision_summary") if isinstance(data.get("decision_summary"), dict) else {}, "available_sources": data.get("available_sources") if isinstance(data.get("available_sources"), list) else [], "blocked_sources": data.get("blocked_sources") if isinstance(data.get("blocked_sources"), list) else [], "source_order": source_order or current_state.get("source_order") or [], "decision_profile": self._clean_text(current_state.get("decision_profile") or ((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("decision_profile")), "decision_entry": self._clean_text(data.get("action") or current_state.get("decision_entry") or "smart_resource_search"), "origin": self._clean_text(origin or current_state.get("origin")), "session_preference_overrides": data.get("session_preference_overrides") if isinstance(data.get("session_preference_overrides"), dict) else dict(current_state.get("session_preference_overrides") or {}), } self._save_session(cache_key, fallback_state) return fallback_state @staticmethod def _assistant_score_warning_text(score: Dict[str, Any], *, limit: int = 3) -> str: risks = [str(value) for value in (score.get("risk_reasons") or []) if value] if not risks: return "" return "风险提示:" + ";".join(risks[:max(1, limit)]) def _assistant_mp_download_plan_response( self, *, choice: int, session: str, cache_key: str, preferences: Dict[str, Any], workflow: str = "mp_download", message: str = "PT 下载计划已生成", ) -> Dict[str, Any]: selected, available_indexes = self._assistant_mp_item_by_display_index( choice=choice, cache_key=cache_key, preferences=preferences, ) if not selected: available_text = "、".join(str(index) for index in available_indexes[:20]) return { "success": False, "message": ( f"当前列表没有 #{choice},不会生成下载计划。可选编号:{available_text}。" if available_text else "没有可继续的 MP 搜索结果,请先发送“MP搜索 片名”后再选择编号。" ), "data": self._assistant_response_data(session=session, data={ "action": "mp_download", "ok": False, "error_code": "mp_result_not_found", "choice": choice, "available_indexes": available_indexes, }), } result = self._save_assistant_pick_plan_response( workflow=workflow, session=session, session_id=cache_key, actions=[{ "name": "pick_mp_download", "session": session, "session_id": cache_key, "choice": choice, }], execute_body={ "workflow": workflow, "session": session, "session_id": cache_key, "choice": choice, "dry_run": False, }, message=message, score_items=[selected], extra_data={ "choice": choice, "selected": selected, }, ) score = selected.get("score") if isinstance(selected.get("score"), dict) else {} warning = self._assistant_score_warning_text(score) if warning: result["message"] = f"{result.get('message')}\n{warning}" return result def _assistant_mp_subscribe_plan_response( self, *, keyword: str, session: str, cache_key: str, immediate_search: bool = False, ) -> Dict[str, Any]: keyword = self._clean_text(keyword) if not keyword: return { "success": False, "message": "订阅失败:缺少片名或关键词", "data": self._assistant_response_data(session=session, data={ "action": "mp_subscribe", "ok": False, "error_code": "missing_keyword", }), } return self._save_assistant_pick_plan_response( workflow="mp_subscribe", session=session, session_id=cache_key, actions=[{ "name": "start_mp_subscribe", "session": session, "session_id": cache_key, "keyword": keyword, }], execute_body={ "workflow": "mp_subscribe", "session": session, "session_id": cache_key, "keyword": keyword, "dry_run": False, }, message="订阅计划已生成", extra_data={ "keyword": keyword, "immediate_search": bool(immediate_search), }, ) def _assistant_mp_download_control_plan_response( self, *, control: str, target: str, session: str, cache_key: str, downloader: str = "", delete_files: bool = False, ) -> Dict[str, Any]: control = self._clean_text(control) target = self._clean_text(target) if not control or not target: return { "success": False, "message": "下载任务操作缺少 control 或 target。", "data": self._assistant_response_data(session=session, data={ "action": "mp_download_control", "ok": False, "error_code": "invalid_download_control_args", }), } if not self._resolve_mp_download_task_target(target=target, cache_key=cache_key): return { "success": False, "message": "未找到可操作的下载任务。请先发送“下载任务”获取列表,再按编号操作;也可以直接传 40 位任务 hash。", "data": self._assistant_response_data(session=session, data={ "action": "mp_download_control", "ok": False, "error_code": "download_target_not_found", "target": target, }), } downloader = self._clean_text(downloader) return self._save_assistant_pick_plan_response( workflow="mp_download_control", session=session, session_id=cache_key, actions=[{ "name": "mp_download_control", "session": session, "session_id": cache_key, "control": control, "target": target, "downloader": downloader, "delete_files": delete_files, }], execute_body={ "workflow": "mp_download_control", "session": session, "session_id": cache_key, "control": control, "target": target, "downloader": downloader, "delete_files": delete_files, "dry_run": False, }, message="下载任务操作计划已生成", extra_data={ "control": control, "target": target, }, ) def _assistant_mp_subscribe_control_plan_response( self, *, control: str, target: str, session: str, cache_key: str, allow_raw_id: bool = False, ) -> Dict[str, Any]: control = self._clean_text(control) target = self._clean_text(target) if not control or not target: return { "success": False, "message": "订阅操作缺少 control 或 target。", "data": self._assistant_response_data(session=session, data={ "action": "mp_subscribe_control", "ok": False, "error_code": "invalid_subscribe_control_args", }), } if not self._resolve_mp_subscribe_target(target=target, cache_key=cache_key, allow_raw_id=allow_raw_id): return { "success": False, "message": "未找到可操作的订阅。请先发送“订阅列表”获取列表,再按编号操作;也可以直接传订阅 ID。", "data": self._assistant_response_data(session=session, data={ "action": "mp_subscribe_control", "ok": False, "error_code": "subscribe_target_not_found", "target": target, }), } return self._save_assistant_pick_plan_response( workflow="mp_subscribe_control", session=session, session_id=cache_key, actions=[{ "name": "mp_subscribe_control", "session": session, "session_id": cache_key, "control": control, "target": target, }], execute_body={ "workflow": "mp_subscribe_control", "session": session, "session_id": cache_key, "control": control, "target": target, "dry_run": False, }, message="订阅操作计划已生成", extra_data={ "control": control, "target": target, }, ) async def _assistant_mp_download(self, *, choice: int, session: str, cache_key: str, preferences: Dict[str, Any]) -> Dict[str, Any]: selected, available_indexes = self._assistant_mp_item_by_display_index( choice=choice, cache_key=cache_key, preferences=preferences, ) if not selected: available_text = "、".join(str(index) for index in available_indexes[:20]) return { "success": False, "message": ( f"当前列表没有 #{choice}。可选编号:{available_text}。" if available_text else "没有可继续的 MP/PT 搜索结果,请先发送“搜索 片名”或“MP搜索 片名”。" ), "data": self._assistant_response_data(session=session, data={ "action": "mp_download", "ok": False, "error_code": "mp_result_not_found", "choice": choice, "available_indexes": available_indexes, }), } score = selected.get("score") if isinstance(selected.get("score"), dict) else {} cache_choice = self._safe_int(selected.get("cache_index"), choice) message_text = self._ensure_feishu_channel()._execute_media_download(cache_choice, cache_key) ok = not message_text.startswith(( "下载资源失败", "下载提交失败", "没有可用", "序号超出范围", )) and "无法连接" not in message_text and "添加下载任务失败" not in message_text warning = self._assistant_score_warning_text(score) if warning: message_text = f"{message_text}\n{warning}".strip() return { "success": ok, "message": message_text, "data": self._assistant_response_data(session=session, data={ "action": "mp_download", "ok": ok, "choice": choice, "cache_choice": cache_choice, "selected": selected, "score": score, "write_effect": "write", }), } async def _assistant_mp_subscribe( self, *, keyword: str, session: str, immediate_search: bool = False, ) -> Dict[str, Any]: keyword = self._clean_text(keyword) if not keyword: return { "success": False, "message": "订阅失败:缺少片名或关键词", "data": self._assistant_response_data(session=session, data={ "action": "mp_subscribe_search" if immediate_search else "mp_subscribe", "ok": False, "error_code": "missing_keyword", }), } message_text = self._ensure_feishu_channel()._execute_media_subscribe(keyword, immediate_search) ok = not message_text.startswith("订阅失败") return { "success": ok, "message": message_text, "data": self._assistant_response_data(session=session, data={ "action": "mp_subscribe_search" if immediate_search else "mp_subscribe", "ok": ok, "keyword": keyword, "immediate_search": bool(immediate_search), "write_effect": "write", }), } def _resolve_mp_download_task_target(self, *, target: str, cache_key: str) -> Dict[str, Any]: target_text = self._clean_text(target) state = self._load_session(cache_key) or {} items = state.get("items") if isinstance(state.get("items"), list) else [] index = self._safe_int(target_text, 0) if index > 0: for item in items: if self._safe_int(item.get("index"), 0) == index: return dict(item) hash_match = re.search(r"\b[0-9a-fA-F]{40}\b", target_text) if hash_match: task_hash = hash_match.group(0) return {"hash": task_hash, "hash_short": task_hash[:8]} short_hash_match = re.search(r"\b[0-9a-fA-F]{6,12}\b", target_text) if short_hash_match: short_hash = short_hash_match.group(0).lower() for item in items: task_hash = self._clean_text(item.get("hash")).lower() if task_hash.startswith(short_hash): return dict(item) return {} async def _assistant_mp_download_tasks( self, *, session: str, cache_key: str, status: str = "downloading", title: str = "", hash_value: str = "", downloader: str = "", limit: int = 10, ) -> Dict[str, Any]: result = self._ensure_feishu_channel()._query_download_tasks( downloader=downloader, status=status or "downloading", title=title, hash_value=hash_value, limit=max(1, min(30, self._safe_int(limit, 10))), ) items = result.get("items") if isinstance(result.get("items"), list) else [] self._save_session(cache_key, { "kind": "assistant_mp_download_tasks", "stage": "download_tasks", "keyword": title or hash_value or status or "downloading", "items": items, "target_path": "", }) return { "success": bool(result.get("success")), "message": self._clean_text(result.get("message")) or "下载任务查询完成", "data": self._assistant_response_data(session=session, data={ "action": "mp_download_tasks", "ok": bool(result.get("success")), "status": result.get("status") or status, "items": items, "total": result.get("total", len(items)), }), } async def _assistant_mp_download_control( self, *, session: str, cache_key: str, control: str, target: str, downloader: str = "", delete_files: bool = False, ) -> Dict[str, Any]: selected = self._resolve_mp_download_task_target(target=target, cache_key=cache_key) task_hash = self._clean_text(selected.get("hash")) if not task_hash: return { "success": False, "message": "操作下载任务失败:请先发送“下载任务”获取列表,再按编号操作,例如“暂停下载 1”。", "data": self._assistant_response_data(session=session, data={ "action": "mp_download_control", "ok": False, "error_code": "missing_download_task_hash", "target": target, }), } result = self._ensure_feishu_channel()._control_download_task( action=control, hash_value=task_hash, downloader=downloader or self._clean_text(selected.get("downloader")), delete_files=delete_files, ) return { "success": bool(result.get("success")), "message": self._clean_text(result.get("message")) or "下载任务操作完成", "data": self._assistant_response_data(session=session, data={ "action": "mp_download_control", "ok": bool(result.get("success")), "control": control, "target": target, "selected": selected, "result": result, "write_effect": "write", }), } async def _assistant_mp_download_history( self, *, session: str, cache_key: str, title: str = "", hash_value: str = "", limit: int = 10, page: int = 1, ) -> Dict[str, Any]: result = self._ensure_feishu_channel()._query_download_history( title=title, hash_value=hash_value, limit=max(1, min(50, self._safe_int(limit, 10))), page=max(1, self._safe_int(page, 1)), ) items = result.get("items") if isinstance(result.get("items"), list) else [] self._save_session(cache_key, { "kind": "assistant_mp_download_history", "stage": "download_history", "keyword": title or hash_value or "all", "items": items, "target_path": "", }) return { "success": bool(result.get("success")), "message": self._clean_text(result.get("message")) or "下载历史查询完成", "data": self._assistant_response_data(session=session, data={ "action": "mp_download_history", "ok": bool(result.get("success")), "source_type": "moviepilot_download_history", "title": title, "hash": hash_value, "items": items, "total": self._safe_int(result.get("total"), len(items)), "page": self._safe_int(result.get("page"), page), "limit": self._safe_int(result.get("limit"), limit), }), } async def _assistant_mp_downloaders(self, *, session: str, cache_key: str) -> Dict[str, Any]: result = self._ensure_feishu_channel()._query_downloaders() items = result.get("items") if isinstance(result.get("items"), list) else [] self._save_session(cache_key, { "kind": "assistant_mp_downloaders", "stage": "downloaders", "keyword": "downloaders", "items": items, "enabled_count": self._safe_int(result.get("enabled_count"), 0), "target_path": "", }) return { "success": bool(result.get("success")), "message": self._clean_text(result.get("message")) or "下载器查询完成", "data": self._assistant_response_data(session=session, data={ "action": "mp_downloaders", "ok": bool(result.get("success")), "items": items, "enabled_count": self._safe_int(result.get("enabled_count"), 0), }), } async def _assistant_mp_sites( self, *, session: str, cache_key: str, status: str = "active", name: str = "", limit: int = 30, ) -> Dict[str, Any]: result = self._ensure_feishu_channel()._query_sites( status=status or "active", name=name, limit=max(1, min(100, self._safe_int(limit, 30))), ) items = result.get("items") if isinstance(result.get("items"), list) else [] self._save_session(cache_key, { "kind": "assistant_mp_sites", "stage": "sites", "keyword": name or status or "active", "items": items, "status": result.get("status") or status, "target_path": "", }) return { "success": bool(result.get("success")), "message": self._clean_text(result.get("message")) or "站点查询完成", "data": self._assistant_response_data(session=session, data={ "action": "mp_sites", "ok": bool(result.get("success")), "status": result.get("status") or status, "items": items, "total": self._safe_int(result.get("total"), 0), }), } def _resolve_mp_subscribe_target(self, *, target: str, cache_key: str, allow_raw_id: bool = False) -> Dict[str, Any]: target_text = self._clean_text(target) state = self._load_session(cache_key) or {} items = state.get("items") if isinstance(state.get("items"), list) else [] index = self._safe_int(target_text, 0) if index > 0: for item in items: if self._safe_int(item.get("index"), 0) == index or self._safe_int(item.get("id"), 0) == index: return dict(item) if allow_raw_id: return {"id": index} return {} async def _assistant_mp_subscribes( self, *, session: str, cache_key: str, status: str = "all", media_type: str = "all", name: str = "", limit: int = 20, ) -> Dict[str, Any]: result = self._ensure_feishu_channel()._query_subscribes( status=status or "all", media_type=media_type or "all", name=name, limit=max(1, min(100, self._safe_int(limit, 20))), ) items = result.get("items") if isinstance(result.get("items"), list) else [] self._save_session(cache_key, { "kind": "assistant_mp_subscribes", "stage": "subscribe_list", "keyword": name or status or "all", "items": items, "target_path": "", }) return { "success": bool(result.get("success")), "message": self._clean_text(result.get("message")) or "订阅查询完成", "data": self._assistant_response_data(session=session, data={ "action": "mp_subscribes", "ok": bool(result.get("success")), "status": result.get("status") or status, "items": items, "total": self._safe_int(result.get("total"), len(items)), }), } async def _assistant_mp_subscribe_control( self, *, session: str, cache_key: str, control: str, target: str, allow_raw_id: bool = False, ) -> Dict[str, Any]: selected = self._resolve_mp_subscribe_target(target=target, cache_key=cache_key, allow_raw_id=allow_raw_id) subscribe_id = self._safe_int(selected.get("id") or target, 0) if subscribe_id <= 0: return { "success": False, "message": "操作订阅失败:请先发送“订阅列表”获取列表,再按编号操作,例如“刷新订阅 1”。", "data": self._assistant_response_data(session=session, data={ "action": "mp_subscribe_control", "ok": False, "error_code": "missing_subscribe_id", "target": target, }), } result = self._ensure_feishu_channel()._control_subscribe(action=control, subscribe_id=subscribe_id) return { "success": bool(result.get("success")), "message": self._clean_text(result.get("message")) or "订阅操作完成", "data": self._assistant_response_data(session=session, data={ "action": "mp_subscribe_control", "ok": bool(result.get("success")), "control": control, "target": target, "selected": selected, "result": result, "write_effect": "write", }), } async def _assistant_mp_transfer_history( self, *, session: str, cache_key: str, title: str = "", status: str = "all", limit: int = 10, page: int = 1, ) -> Dict[str, Any]: result = self._ensure_feishu_channel()._query_transfer_history( title=title, status=status or "all", limit=max(1, min(50, self._safe_int(limit, 10))), page=max(1, self._safe_int(page, 1)), ) items = result.get("items") if isinstance(result.get("items"), list) else [] self._save_session(cache_key, { "kind": "assistant_mp_transfer_history", "stage": "transfer_history", "keyword": title or status or "all", "items": items, "target_path": "", }) return { "success": bool(result.get("success")), "message": self._clean_text(result.get("message")) or "整理历史查询完成", "data": self._assistant_response_data(session=session, data={ "action": "mp_transfer_history", "ok": bool(result.get("success")), "source_type": "moviepilot_transfer_history", "status": result.get("status") or status, "title": title, "items": items, "total": self._safe_int(result.get("total"), len(items)), "page": self._safe_int(result.get("page"), page), "limit": self._safe_int(result.get("limit"), limit), }), } def _assistant_mp_detect_stage( self, *, task_items: List[Dict[str, Any]], download_items: List[Dict[str, Any]], transfer_items: List[Dict[str, Any]], ) -> str: def has_term(value: Any, terms: List[str]) -> bool: text = self._clean_text(value).lower() return bool(text) and any(term in text for term in terms) failed_terms = ["失败", "错误", "error", "fail", "未识别", "识别失败", "入库失败", "整理失败"] success_terms = ["成功", "完成", "已入库", "已整理", "已转移", "imported", "success", "completed"] running_terms = ["处理中", "进行中", "转移中", "整理中", "入库中", "等待", "队列", "pending", "running"] for item in transfer_items: if has_term(item.get("status_text") or item.get("status"), failed_terms): return "failed" for item in download_items: if has_term(item.get("transfer_status_text") or item.get("transfer_status"), failed_terms): return "failed" if task_items: return "downloading" for item in transfer_items: status_value = item.get("status_text") or item.get("status") if has_term(status_value, success_terms): return "imported" if has_term(status_value, running_terms): return "transferring" for item in download_items: transfer_value = item.get("transfer_status_text") or item.get("transfer_status") if has_term(transfer_value, success_terms): return "imported" if has_term(transfer_value, running_terms): return "transferring" if transfer_items: return "transferring" if download_items: return "downloaded" return "not_found" def _assistant_mp_diagnosis_summary( self, *, keyword: str, hash_value: str, task_items: List[Dict[str, Any]], download_items: List[Dict[str, Any]], transfer_items: List[Dict[str, Any]], force_failed: bool = False, ) -> Dict[str, Any]: stage = "failed" if force_failed else self._assistant_mp_detect_stage( task_items=task_items, download_items=download_items, transfer_items=transfer_items, ) matched_count = len(task_items) + len(download_items) + len(transfer_items) matched = matched_count > 0 evidence: List[str] = [] risk_reasons: List[str] = [] recommended_action = "" follow_up_hint = "" confidence = 0.0 if task_items: first_task = task_items[0] evidence.append( f"下载任务 {self._clean_text(first_task.get('title')) or '-'} | " f"{self._clean_text(first_task.get('progress')) or '-'} | " f"{self._clean_text(first_task.get('state')) or '-'}" ) if download_items: first_download = download_items[0] evidence.append( f"下载历史 {self._clean_text(first_download.get('title')) or '-'} | " f"{self._clean_text(first_download.get('date')) or '-'} | " f"{self._clean_text(first_download.get('transfer_status_text')) or '-'}" ) transfer_status_text = self._clean_text(first_download.get("transfer_status_text")) if transfer_status_text and any(word in transfer_status_text for word in ["失败", "错误"]): risk_reasons.append(f"下载历史显示整理状态异常:{transfer_status_text}") if transfer_items: first_transfer = transfer_items[0] evidence.append( f"整理历史 {self._clean_text(first_transfer.get('title')) or '-'} | " f"{self._clean_text(first_transfer.get('status_text')) or '-'} | " f"{self._clean_text(first_transfer.get('date')) or '-'}" ) status_text = self._clean_text(first_transfer.get("status_text")) if status_text and any(word in status_text for word in ["失败", "错误"]): risk_reasons.append(f"整理历史存在失败记录:{status_text}") if stage == "downloading": confidence = 0.95 recommended_action = "query_mp_download_tasks" follow_up_hint = "资源仍在下载阶段,优先查看下载任务进度。" elif stage == "downloaded": confidence = 0.85 recommended_action = "query_mp_transfer_history" follow_up_hint = "已找到下载历史,但还没有明确的入库记录,下一步查看整理/入库历史。" elif stage == "transferring": confidence = 0.9 recommended_action = "query_mp_transfer_history" follow_up_hint = "资源已经进入整理/入库链路,优先查看最近整理历史。" elif stage == "imported": confidence = 0.98 recommended_action = "query_mp_transfer_history" follow_up_hint = "已经找到成功入库线索,如需确认最终落地可查看最近整理历史。" elif stage == "failed": confidence = 0.92 if matched else 0.65 recommended_action = "query_mp_local_diagnose" follow_up_hint = "已发现失败线索,建议进入本地诊断汇总失败原因和下一步处理建议。" elif stage == "not_found": confidence = 0.95 recommended_action = "query_mp_download_history" follow_up_hint = "当前没有找到相关下载任务、下载历史或整理记录。先检查是否真的提交过下载/订阅。" else: confidence = 0.5 recommended_action = "query_mp_lifecycle_status" follow_up_hint = "状态不足以判断,建议继续查看生命周期聚合结果。" if hash_value: evidence.append(f"Hash 检索:{hash_value}") elif keyword: evidence.append(f"关键词检索:{keyword}") return { "status": "ok" if matched else "not_found", "matched": matched, "stage": stage, "confidence": confidence, "evidence": evidence[:6], "risk_reasons": risk_reasons[:6], "recommended_action": recommended_action, "follow_up_hint": follow_up_hint, } def _assistant_mp_diagnosis_followups( self, *, session: str, session_id: str, keyword: str, hash_value: str, preferred: str, ) -> Tuple[List[str], List[Dict[str, Any]]]: base_body = { "session": session, "session_id": session_id, } keyword_body = dict(base_body) if keyword: keyword_body["keyword"] = keyword if hash_value: keyword_body["hash"] = hash_value action_order = [preferred] for name in ["query_mp_lifecycle_status", "query_mp_download_history", "query_mp_transfer_history", "query_mp_local_diagnose"]: if name not in action_order: action_order.append(name) templates = [ self._assistant_action_template( name=name, description={ "query_mp_lifecycle_status": "聚合查看下载任务、下载历史和整理/入库历史", "query_mp_download_history": "查看下载历史,并确认是否已经提交过下载", "query_mp_transfer_history": "查看整理/入库历史,确认是否已经落库或失败", "query_mp_local_diagnose": "汇总本地/PT 链路的失败线索与处理建议", }.get(name, name), endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**keyword_body, "name": name, "compact": True}, ) for name in action_order[:4] ] return action_order[:4], templates def _assistant_followup_command(self, action_name: str, *, keyword: str = "", hash_value: str = "") -> str: target = self._clean_text(keyword or hash_value) mapping = { "query_execution_followup": "后续", "query_mp_download_tasks": "下载任务", "query_mp_subscribes": "订阅列表", } if action_name in mapping: return mapping[action_name] if action_name == "query_mp_ingest_status": return f"入库 {target}".strip() if action_name == "query_mp_lifecycle_status": return f"状态 {target}".strip() if action_name == "query_mp_download_history": return f"记录 {target}".strip() if action_name == "query_mp_transfer_history": return f"入库记录 {target}".strip() if action_name == "query_mp_local_diagnose": return f"诊断 {target}".strip() if action_name == "query_ai_failed_samples": return f"失败样本 {target}".strip() if target else "失败样本" if action_name == "query_ai_sample_worklist": return f"工作清单 {target}".strip() if target else "工作清单" if action_name == "query_ai_sample_insights": return f"样本洞察 {target}".strip() if target else "样本洞察" if action_name == "start_mp_media_search": return f"MP搜索 {target}".strip() return "" def _assistant_template_command( self, template: Dict[str, Any], *, keyword: str = "", hash_value: str = "", target: str = "", ) -> str: if not isinstance(template, dict): return "" body = dict(template.get("action_body") or template.get("body") or {}) action_name = self._clean_text(body.get("name")) or self._clean_text(template.get("name")) target_text = self._clean_text(target or body.get("keyword") or body.get("hash") or body.get("target")) command = self._assistant_followup_command( action_name, keyword=self._clean_text(keyword or body.get("keyword") or target_text), hash_value=self._clean_text(hash_value or body.get("hash")), ) if command: return command if action_name == "query_mp_recent_activity": return "最近" if action_name == "execute_session_latest_plan": return "执行计划" if action_name == "mp_download_control": control = self._clean_text(body.get("control")) mapping = { "pause": "暂停下载", "resume": "恢复下载", "delete": "删除下载", } prefix = mapping.get(control) if prefix and target_text: return f"{prefix} {target_text}" return prefix or "下载任务" if action_name == "mp_subscribe_control": control = self._clean_text(body.get("control")) mapping = { "pause": "暂停订阅", "resume": "恢复订阅", "delete": "删除订阅", "search": "刷新订阅", } prefix = mapping.get(control) if prefix and target_text: return f"{prefix} {target_text}" return prefix or "订阅列表" return "" def _assistant_error_summary( self, *, error_code: str, recommended_action: str = "", message_head: str = "", next_actions: Optional[List[Any]] = None, action_templates: Optional[List[Dict[str, Any]]] = None, keyword: str = "", hash_value: str = "", target: str = "", ) -> Dict[str, Any]: code = self._clean_text(error_code) if not code: return {} label_map = { "latest_plan_not_executed": "当前会话还有待执行计划", "plan_not_executed": "指定计划还没有执行", "executed_plan_not_found": "当前没有可追踪的已执行计划", "followup_not_available": "当前计划没有自动追踪动作", "invalid_download_control_args": "下载任务参数不完整", "download_target_not_found": "没有匹配到可操作的下载任务", "invalid_subscribe_control_args": "订阅操作参数不完整", "subscribe_target_not_found": "没有匹配到可操作的订阅", } hint_map = { "latest_plan_not_executed": "先执行计划,再继续查看后续状态。", "plan_not_executed": "先执行这条计划,再继续查看执行后追踪。", "executed_plan_not_found": "先执行下载、订阅或转存计划,再查看后续。", "followup_not_available": "当前计划只能改查状态、记录或最近活动。", "invalid_download_control_args": "先发“下载任务”获取列表,再按编号暂停、恢复或删除。", "download_target_not_found": "先发“下载任务”刷新列表,再按编号操作。", "invalid_subscribe_control_args": "先发“订阅列表”获取列表,再按编号暂停、恢复或删除。", "subscribe_target_not_found": "先发“订阅列表”刷新列表,再按编号操作。", } command_candidates: List[str] = [] fallback_map = { "latest_plan_not_executed": ["执行计划", "后续"], "plan_not_executed": ["执行计划", "后续"], "executed_plan_not_found": ["最近"], "followup_not_available": [f"状态 {target}".strip(), f"记录 {target}".strip(), "最近"], "invalid_download_control_args": ["下载任务"], "download_target_not_found": ["下载任务"], "invalid_subscribe_control_args": ["订阅列表"], "subscribe_target_not_found": ["订阅列表"], } for command in fallback_map.get(code, []): command_text = self._clean_text(command) if command_text and command_text not in command_candidates: command_candidates.append(command_text) synthetic_templates: List[Dict[str, Any]] = [] action_name = self._clean_text(recommended_action) if action_name: synthetic_templates.append({ "name": action_name, "action_body": { "name": action_name, "keyword": keyword, "hash": hash_value, "target": target, }, }) synthetic_templates.extend( item for item in (action_templates or []) if isinstance(item, dict) ) for item in synthetic_templates: command = self._assistant_template_command( item, keyword=keyword, hash_value=hash_value, target=target, ) if command and command not in command_candidates: command_candidates.append(command) for name in [self._clean_text(item) for item in (next_actions or []) if self._clean_text(item)]: command = self._assistant_followup_command(name, keyword=keyword or target, hash_value=hash_value) if command and command not in command_candidates: command_candidates.append(command) preferred_requires_confirmation = code in {"latest_plan_not_executed", "plan_not_executed"} return { "error_code": code, "label": label_map.get(code, message_head or code), "decision_hint": hint_map.get(code, message_head or "当前请求未完成,请先按建议补充上下文。"), "command_policy": "confirm_then_resume" if preferred_requires_confirmation else "safe_read_recovery", "preferred_requires_confirmation": preferred_requires_confirmation, "fallback_requires_confirmation": False, "can_auto_run_preferred": not preferred_requires_confirmation and bool(command_candidates), "preferred_command": command_candidates[0] if command_candidates else "", "fallback_command": command_candidates[1] if len(command_candidates) > 1 else "", "compact_commands": command_candidates[:2], "recommended_commands": command_candidates[:3], } def _format_quark_transfer_failure(self, detail: str = "", target_path: str = "") -> str: text = self._clean_text(detail) target = self._clean_text(target_path) or self._quark_default_path or "/飞书" if "41031" in text or "分享者用户封禁链接查看受限" in text: return ( "夸克转存失败:当前分享链接已受限或分享者账号受限," "建议改选同一列表里的其他夸克资源。" ) if "未获取到 stoken" in text: try: cookie_ok, _cookie_message = self._ensure_quark_service().check_cookie() except Exception: cookie_ok = False if cookie_ok: return ( "夸克转存失败:当前夸克登录态正常,但这条分享无法获取 stoken," "更可能是分享链接失效、提取码错误或分享已受限。建议改选同一列表里的其他夸克资源。" ) return ( f"夸克转存失败:当前夸克登录态不足,无法转存到 {target}。" "建议先刷新夸克登录后再试。" ) if "分享链接为空" in text or "无权查看内容" in text: return ( "夸克转存失败:当前分享内容为空或账号无权查看," "更可能是分享链接失效、内容被取消分享或分享受限。建议改选其他资源。" ) if "require login [guest]" in text or "未配置夸克 Cookie" in text: return ( f"夸克转存失败:当前夸克登录态不足,无法转存到 {target}。" "建议先刷新夸克登录后再试。" ) if not text or text == f"无法转存到 {target}": try: cookie_ok, cookie_message = self._ensure_quark_service().check_cookie() except Exception: cookie_ok, cookie_message = True, "" cookie_text = self._clean_text(cookie_message) if (not cookie_ok) and ( "require login [guest]" in cookie_text or "未配置夸克 Cookie" in cookie_text or "cookie" in cookie_text.lower() or "login" in cookie_text.lower() ): return ( f"夸克转存失败:当前夸克登录态不足,无法转存到 {target}。" "建议先刷新夸克登录后再试。" ) if not text: return ( f"夸克转存失败:无法转存到 {target}。" "当前原因未明,请先不要自行推断为路径问题,可稍后重试或先检查夸克登录态。" ) return f"夸克转存失败:{text}" @staticmethod def _is_quark_share_restricted_message(detail: str) -> bool: text = str(detail or "").strip() if not text: return False return "41031" in text or "分享者用户封禁链接查看受限" in text async def _assistant_retry_pansou_quark_candidates( self, request: Request, *, items: List[Dict[str, Any]], selected_index: int, selected_url: str, session: str, target_path: str, apikey: str, ) -> Optional[Dict[str, Any]]: candidates: List[Tuple[int, Dict[str, Any]]] = [] for offset, raw_item in enumerate(items[selected_index:], start=selected_index + 1): item = dict(raw_item or {}) share_url = self._clean_text(item.get("url")) if not share_url or not self._is_quark_url(share_url): continue if share_url == selected_url: continue candidates.append((offset, item)) if len(candidates) >= 3: break if not candidates: return None for alt_index, alt_item in candidates: alt_url = self._clean_text(alt_item.get("url")) alt_code = self._clean_text(alt_item.get("password")) alt_result = await self.api_share_route( _JsonRequestShim(request, { "url": alt_url, "access_code": alt_code, "path": target_path, "trigger": "Agent影视助手 盘搜夸克候选自动切换", "apikey": apikey, }) ) if alt_result.get("success"): message = ( f"原夸克链接已受限,已自动切换到同列表夸克候选 #{alt_index}。\n" f"{str(alt_result.get('message') or '夸克转存成功')}" ) data = dict(alt_result.get("data") or {}) data.update( { "provider": "quark", "selected_choice": alt_index, "auto_switched_from_choice": selected_index, "auto_switched": True, "selected_item": alt_item, } ) return { "success": True, "message": message, "data": self._assistant_response_data(session=session, data=data), } hint_choices = "、".join(f"#{idx}" for idx, _ in candidates) return { "success": False, "message": ( "夸克转存失败:当前分享链接已受限或分享者账号受限," f"可改试同列表夸克候选 {hint_choices}。" ), "data": self._assistant_response_data( session=session, data={ "provider": "quark", "auto_switched": False, "fallback_choices": [idx for idx, _ in candidates], }, ), } def _assistant_followup_summary( self, *, category: str, stage: str, recommended_action: str, follow_up_hint: str, next_actions: Optional[List[str]] = None, action_templates: Optional[List[Dict[str, Any]]] = None, keyword: str = "", hash_value: str = "", ) -> Dict[str, Any]: action_names = [self._clean_text(item) for item in (next_actions or []) if self._clean_text(item)] command_candidates: List[str] = [] for name in action_names: command = self._assistant_followup_command(name, keyword=keyword, hash_value=hash_value) if command and command not in command_candidates: command_candidates.append(command) label_map = { "mp_download": "下载后追踪", "mp_subscribe": "订阅后追踪", "cloud_write": "云盘落库追踪", "mp_diagnosis": "本地/PT 状态追踪", "ai_reingest": "AI 二次识别重放追踪", } summary = { "category": category, "stage": self._clean_text(stage), "label": label_map.get(category, category or "后续追踪"), "preferred_action": self._clean_text(recommended_action), "decision_hint": self._clean_text(follow_up_hint), "command_policy": "safe_read_only", "preferred_requires_confirmation": False, "fallback_requires_confirmation": False, "can_auto_run_preferred": bool(command_candidates), "preferred_command": command_candidates[0] if command_candidates else "", "fallback_command": command_candidates[1] if len(command_candidates) > 1 else "", "compact_commands": command_candidates[:2], "recommended_commands": command_candidates[:3], "next_actions": action_names[:4], "action_templates_count": len([item for item in (action_templates or []) if isinstance(item, dict)]), } if category == "ai_reingest" and keyword: summary.update({ "preferred_command": "诊断", "fallback_command": "入库状态", "compact_commands": ["诊断", "入库状态"], "recommended_commands": ["诊断", "入库状态", "工作清单"], "can_auto_run_preferred": True, "recommended_agent_behavior": "auto_continue", "auto_run_command": "诊断", "confirm_command": "", "display_command": "诊断", }) return summary @staticmethod def _format_followup_summary_lines(summary: Optional[Dict[str, Any]]) -> List[str]: data = dict(summary or {}) if not data: return [] lines: List[str] = [] label = AgentResourceOfficer._clean_text(data.get("label")) stage = AgentResourceOfficer._clean_text(data.get("stage")) hint = AgentResourceOfficer._clean_text(data.get("decision_hint")) commands = [AgentResourceOfficer._clean_text(item) for item in (data.get("compact_commands") or data.get("recommended_commands") or []) if AgentResourceOfficer._clean_text(item)] if label: lines.append(f"后续追踪:{label}{f' | {stage}' if stage else ''}") if hint: lines.append(f"建议:{hint}") if commands: lines.append("可直接继续:" + " / ".join(commands)) return lines @staticmethod def _assistant_local_api_base_urls() -> List[str]: return [ "http://127.0.0.1:3001", "http://127.0.0.1:3000", "http://host.docker.internal:3000", ] def _assistant_plugin_api_request( self, *, plugin_name: str, path: str, query: Optional[Dict[str, Any]] = None, body: Optional[Dict[str, Any]] = None, method: str = "GET", timeout: int = 15, ) -> Dict[str, Any]: token = self._clean_text(getattr(settings, "API_TOKEN", "") if settings is not None else "") if not token: return {"success": False, "message": "服务端未配置 API Token"} safe_path = "/" + self._clean_text(path).lstrip("/") query_payload = dict(query or {}) query_payload["apikey"] = token last_error = "" payload_bytes: Optional[bytes] = None headers = {"Accept": "application/json"} if body is not None: headers["Content-Type"] = "application/json" payload_bytes = json.dumps(dict(body or {}), ensure_ascii=False).encode("utf-8") for base_url in self._assistant_local_api_base_urls(): try: url = f"{base_url}/api/v1/plugin/{plugin_name}{safe_path}?{urlencode(query_payload)}" request = UrlRequest( url=url, data=payload_bytes, headers=headers, method=(method or "GET").upper(), ) with urlopen(request, timeout=max(5, self._safe_int(timeout, 15))) as response: raw = response.read().decode("utf-8", errors="ignore") payload = json.loads(raw or "{}") if isinstance(payload, dict): return payload last_error = f"插件 {plugin_name} 返回了非字典 JSON" except Exception as exc: last_error = str(exc) return { "success": False, "message": f"调用 {plugin_name}{safe_path} 失败:{last_error or '未知错误'}", } @staticmethod def _assistant_ai_recognizer_plugin() -> Any: if PluginManager is None: return None try: running_plugins = PluginManager().running_plugins or {} except Exception: return None return running_plugins.get("AIRecognizerEnhancer") def _assistant_ai_failed_samples_payload(self, *, limit: int) -> Dict[str, Any]: plugin = self._assistant_ai_recognizer_plugin() if plugin is not None and hasattr(plugin, "_read_failed_samples") and hasattr(plugin, "_inject_sample_indices"): try: samples = plugin._inject_sample_indices(plugin._read_failed_samples(limit=max(1, min(limit, 100)))) return { "success": True, "data": { "count": len(samples), "samples": samples, }, } except Exception as exc: logger.warning(f"[AgentResourceOfficer] 读取 AI 失败样本失败,改走 HTTP 兜底:{exc}") return self._assistant_plugin_api_request( plugin_name="AIRecognizerEnhancer", path="/failed_samples", query={"limit": max(1, min(limit, 100))}, method="GET", ) def _assistant_ai_sample_worklist_payload(self, *, limit: int) -> Dict[str, Any]: plugin = self._assistant_ai_recognizer_plugin() if plugin is not None and hasattr(plugin, "_read_failed_samples") and hasattr(plugin, "_inject_sample_indices") and hasattr(plugin, "_summarize_sample"): try: samples = plugin._inject_sample_indices(plugin._read_failed_samples(limit=max(1, min(limit, 100)))) worklist = [plugin._summarize_sample(sample) for sample in samples] return { "success": True, "data": { "count": len(worklist), "samples": worklist, }, } except Exception as exc: logger.warning(f"[AgentResourceOfficer] 读取 AI 工作清单失败,改走 HTTP 兜底:{exc}") return self._assistant_plugin_api_request( plugin_name="AIRecognizerEnhancer", path="/sample_worklist", query={"limit": max(1, min(limit, 100))}, method="GET", ) def _assistant_ai_sample_insights_payload(self, *, limit: int, top: int) -> Dict[str, Any]: plugin = self._assistant_ai_recognizer_plugin() if plugin is not None and hasattr(plugin, "_read_failed_samples") and hasattr(plugin, "_inject_sample_indices") and hasattr(plugin, "_build_sample_insights"): try: samples = plugin._inject_sample_indices(plugin._read_failed_samples(limit=max(1, min(limit, 200)))) insights = plugin._build_sample_insights(samples, top=max(1, min(top, 20))) return { "success": True, "data": insights, } except Exception as exc: logger.warning(f"[AgentResourceOfficer] 读取 AI 样本洞察失败,改走 HTTP 兜底:{exc}") return self._assistant_plugin_api_request( plugin_name="AIRecognizerEnhancer", path="/sample_insights", query={"limit": max(1, min(limit, 200)), "top": max(1, min(top, 20))}, method="GET", ) def _assistant_ai_replay_failed_sample_payload( self, *, sample_index: int, remove_if_resolved: bool, ) -> Dict[str, Any]: body = { "sample_index": max(0, self._safe_int(sample_index, 0)), "remove_if_resolved": bool(remove_if_resolved), } plugin = self._assistant_ai_recognizer_plugin() if plugin is not None and hasattr(plugin, "_replay_failed_sample"): try: result = plugin._replay_failed_sample(body) if isinstance(result, dict): return result except Exception as exc: logger.warning(f"[AgentResourceOfficer] 重放 AI 失败样本失败,改走 HTTP 兜底:{exc}") return self._assistant_plugin_api_request( plugin_name="AIRecognizerEnhancer", path="/replay_failed_sample", method="POST", body=body, ) def _assistant_ai_sample_by_index(self, *, cache_key: str, sample_index: int) -> Dict[str, Any]: desired = self._safe_int(sample_index, 0) if desired <= 0: return {} state = self._load_session(cache_key) or {} items = state.get("items") if isinstance(state.get("items"), list) else [] for item in items: current = item if isinstance(item, dict) else {} if self._safe_int(current.get("sample_index"), 0) == desired: return dict(current) if self._safe_int(current.get("index"), 0) == desired: return dict(current) return {} @staticmethod def _assistant_quarkdisk_plugin() -> Any: if PluginManager is None: return None try: running_plugins = PluginManager().running_plugins or {} except Exception: return None plugin = running_plugins.get("QuarkDisk") or running_plugins.get("quarkdisk") if plugin is not None: return plugin for candidate in running_plugins.values(): if candidate is None: continue if candidate.__class__.__name__ == "QuarkDisk": return candidate if getattr(candidate, "plugin_name", "") == "夸克网盘存储": return candidate return None def _assistant_quarkdisk_clear_directory(self, path: str) -> Optional[Tuple[bool, Dict[str, Any], str]]: plugin = self._assistant_quarkdisk_plugin() if plugin is None or app_schemas is None: return None try: storage_name = self._clean_text(getattr(plugin, "_disk_name", "")) or "夸克网盘" clear_cache = getattr(plugin, "clear_cache", None) def _safe_clear_cache() -> None: if callable(clear_cache): try: clear_cache() except Exception: pass def _resolve_folder() -> Any: resolved = None if hasattr(plugin, "get_folder"): resolved = plugin.get_folder(storage=storage_name, path=Path(path)) if resolved is None and hasattr(plugin, "get_file_item"): resolved = plugin.get_file_item(storage=storage_name, path=Path(path)) return resolved _safe_clear_cache() folder = _resolve_folder() if folder is None: _safe_clear_cache() folder = _resolve_folder() if folder is None: return False, { "target_path": path, "removed_count": 0, "file_count": 0, "folder_count": 0, "items": [], "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), "bridge": "quarkdisk", "fallback_to_direct": True, }, "QuarkDisk 未定位到目录,改走直连夸克接口确认" items = [] if hasattr(plugin, "list_files"): items = plugin.list_files(folder, recursion=False) or [] if not items: _safe_clear_cache() folder = _resolve_folder() or folder items = plugin.list_files(folder, recursion=False) or [] files = [item for item in items if getattr(item, "type", "") != "dir"] folders = [item for item in items if getattr(item, "type", "") == "dir"] if not items: return False, { "target_path": path, "removed_count": 0, "file_count": 0, "folder_count": 0, "items": [], "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), "bridge": "quarkdisk", "fallback_to_direct": True, }, "QuarkDisk 返回空列表,改走直连夸克接口确认" removed_count = 0 failed_items: List[str] = [] for item in items: ok = plugin.delete_file(item) if hasattr(plugin, "delete_file") else None if ok: removed_count += 1 continue item_path = self._clean_text(getattr(item, "path", "")) or self._clean_text(getattr(item, "name", "")) if item_path: failed_items.append(item_path) result = { "target_path": path, "removed_count": removed_count, "file_count": len(files), "folder_count": len(folders), "items": [self._clean_text(getattr(item, "name", "")) for item in items[:20] if self._clean_text(getattr(item, "name", ""))], "failed_items": failed_items[:20], "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), "bridge": "quarkdisk", } if failed_items: return False, result, f"夸克网盘存储删除失败:{len(failed_items)} 项未删除" return True, result, "success" except Exception as exc: logger.warning(f"[AgentResourceOfficer] 调用 quarkdisk 清空目录失败,改走直连夸克接口:{exc}") return None def _assistant_ai_sample_reference(self, *, cache_key: str, sample_index: int) -> Dict[str, Any]: desired = self._safe_int(sample_index, 0) if desired <= 0: return {} cached = self._assistant_ai_sample_by_index(cache_key=cache_key, sample_index=desired) if cached: return cached for payload in ( self._assistant_ai_failed_samples_payload(limit=1000), self._assistant_ai_sample_worklist_payload(limit=1000), ): rows = [] if isinstance((payload or {}).get("data"), dict): rows = (payload.get("data") or {}).get("samples") or [] if not isinstance(rows, list): continue for item in rows: current = dict(item or {}) if self._safe_int(current.get("sample_index") or current.get("index"), 0) == desired: return current return {} def _assistant_ai_replay_target_title(self, *, sample: Dict[str, Any], target: Dict[str, Any]) -> str: current_sample = dict(sample or {}) current_target = dict(target or {}) return ( self._clean_text(current_target.get("name")) or self._clean_text(current_sample.get("verified_title")) or self._clean_text(current_sample.get("guess_name")) or self._clean_text(current_sample.get("title")) or self._clean_text(current_sample.get("path")) ) def _assistant_ai_sample_matches(self, sample: Dict[str, Any], keyword: str) -> bool: needle = self._clean_text(keyword).lower() if not needle: return True target = sample.get("inferred_target") if isinstance(sample.get("inferred_target"), dict) else {} fields = [ sample.get("title"), sample.get("path"), sample.get("reason"), sample.get("guess_name"), sample.get("verified_title"), target.get("name"), ] for value in fields: text = self._clean_text(value).lower() if text and needle in text: return True return False def _assistant_ai_filtered_rows(self, rows: List[Dict[str, Any]], keyword: str) -> List[Dict[str, Any]]: if not keyword: return [dict(item or {}) for item in rows] return [ dict(item or {}) for item in rows if self._assistant_ai_sample_matches(dict(item or {}), keyword) ] def _assistant_ai_followups( self, *, session: str, session_id: str, keyword: str, ) -> Tuple[List[str], List[Dict[str, Any]]]: base_body = { "session": session, "session_id": session_id, "keyword": keyword, "compact": True, } action_order = [ "query_ai_sample_worklist", "query_ai_sample_insights", "query_ai_failed_samples", "query_mp_local_diagnose", ] templates = [ self._assistant_action_template( name=name, description={ "query_ai_sample_worklist": "查看 AI 识别失败样本工作清单,适合先挑目标", "query_ai_sample_insights": "查看 AI 失败样本洞察,适合看重复样本和优先处理项", "query_ai_failed_samples": "查看 AI 原始失败样本列表,适合核对标题、路径和失败原因", "query_mp_local_diagnose": "回到本地/PT 诊断链,继续看入库失败阶段和证据", }.get(name, name), endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_body, "name": name}, ) for name in action_order ] return action_order, templates def _assistant_ai_sample_brief_lines( self, rows: List[Dict[str, Any]], *, limit: int = 5, ) -> List[str]: lines: List[str] = [] for item in rows[: max(1, min(limit, 10))]: label = ( self._clean_text( ((item.get("inferred_target") or {}).get("name")) if isinstance(item.get("inferred_target"), dict) else "" ) or self._clean_text(item.get("verified_title")) or self._clean_text(item.get("guess_name")) or self._clean_text(item.get("title")) or "未命名样本" ) confidence = round(self._safe_float(item.get("guess_confidence"), 0.0), 2) reason = self._clean_text(item.get("reason")) or "-" lines.append( f"{self._safe_int(item.get('sample_index'), 0)}. {label} | 置信度 {confidence} | {reason}" ) return lines def _assistant_mp_recent_activity_summary( self, *, download_items: List[Dict[str, Any]], transfer_items: List[Dict[str, Any]], ) -> Dict[str, Any]: evidence: List[str] = [] for item in download_items[:3]: evidence.append( f"下载 {self._clean_text(item.get('title')) or '-'} | " f"{self._clean_text(item.get('date')) or '-'} | " f"{self._clean_text(item.get('transfer_status_text')) or '-'}" ) for item in transfer_items[:3]: evidence.append( f"入库 {self._clean_text(item.get('title')) or '-'} | " f"{self._clean_text(item.get('date')) or '-'} | " f"{self._clean_text(item.get('status_text')) or '-'}" ) return { "status": "ok" if (download_items or transfer_items) else "not_found", "matched": bool(download_items or transfer_items), "stage": "unknown", "confidence": 0.8 if (download_items or transfer_items) else 0.95, "evidence": evidence[:6], "risk_reasons": [], "recommended_action": "query_mp_lifecycle_status", "follow_up_hint": "先从最近活动里挑目标,再继续看单个资源的生命周期或本地诊断。", } async def _assistant_mp_lifecycle_status( self, *, session: str, cache_key: str, title: str = "", hash_value: str = "", limit: int = 5, ) -> Dict[str, Any]: safe_limit = max(1, min(10, self._safe_int(limit, 5))) channel = self._ensure_feishu_channel() task_result = channel._query_download_tasks( status="all", title=title, hash_value=hash_value, limit=safe_limit, ) download_result = channel._query_download_history( title=title, hash_value=hash_value, limit=safe_limit, ) transfer_result = channel._query_transfer_history( title=title, status="all", limit=safe_limit, ) task_items = task_result.get("items") if isinstance(task_result.get("items"), list) else [] download_items = download_result.get("items") if isinstance(download_result.get("items"), list) else [] transfer_items = transfer_result.get("items") if isinstance(transfer_result.get("items"), list) else [] keyword = title or hash_value or "全部" diagnosis_summary = self._assistant_mp_diagnosis_summary( keyword=title, hash_value=hash_value, task_items=task_items, download_items=download_items, transfer_items=transfer_items, ) next_actions, action_templates = self._assistant_mp_diagnosis_followups( session=session, session_id=cache_key, keyword=title, hash_value=hash_value, preferred=self._clean_text(diagnosis_summary.get("recommended_action")) or "query_mp_download_history", ) followup_summary = self._assistant_followup_summary( category="mp_diagnosis", stage=self._clean_text(diagnosis_summary.get("stage")), recommended_action=self._clean_text(diagnosis_summary.get("recommended_action")), follow_up_hint=self._clean_text(diagnosis_summary.get("follow_up_hint")), next_actions=next_actions, action_templates=action_templates, keyword=title, hash_value=hash_value, ) diagnosis_summary["followup_summary"] = followup_summary lines = [f"MP 生命周期追踪:{keyword}"] lines.append( f"结论:{diagnosis_summary.get('stage') or 'unknown'} | " f"置信度 {int(float(diagnosis_summary.get('confidence') or 0) * 100)}%" ) lines.append(f"活动下载任务:{len(task_items)} 条;下载历史:{download_result.get('total', len(download_items))} 条;整理历史:{transfer_result.get('total', len(transfer_items))} 条") if task_items: lines.append("下载任务:") for item in task_items[:safe_limit]: lines.append(f"{item.get('index')}. {item.get('title')} | {item.get('progress') or '-'} | {item.get('state') or '-'} | Hash:{item.get('hash_short') or '-'}") if download_items: lines.append("下载历史:") for item in download_items[:safe_limit]: lines.append(f"{item.get('index')}. {item.get('title')} ({item.get('year') or '-'}) | {item.get('date') or '-'} | {item.get('transfer_status_text') or '-'} | Hash:{item.get('download_hash_short') or '-'}") if transfer_items: lines.append("整理/入库历史:") for item in transfer_items[:safe_limit]: lines.append(f"{item.get('index')}. {item.get('title')} ({item.get('year') or '-'}) | {item.get('status_text') or '-'} | {item.get('date') or '-'}") if not task_items and not download_items and not transfer_items: lines.append("未找到相关任务、下载历史或整理历史。") lines.extend(self._format_followup_summary_lines(followup_summary)) lines.append("说明:这是只读聚合查询,用于判断资源处于搜索后、下载中、已下载还是已落库阶段。") self._save_session(cache_key, { "kind": "assistant_mp_lifecycle_status", "stage": "lifecycle_status", "keyword": keyword, "items": { "download_tasks": task_items, "download_history": download_items, "transfer_history": transfer_items, }, "target_path": "", }) ok = bool(task_result.get("success")) and bool(download_result.get("success")) and bool(transfer_result.get("success")) return { "success": ok, "message": "\n".join(lines), "data": self._assistant_response_data(session=session, data={ "action": "mp_lifecycle_status", "ok": ok, "source_type": "moviepilot_lifecycle_status", "title": title, "hash": hash_value, "download_tasks": { "ok": bool(task_result.get("success")), "total": self._safe_int(task_result.get("total"), len(task_items)), "items": task_items, }, "download_history": { "ok": bool(download_result.get("success")), "total": self._safe_int(download_result.get("total"), len(download_items)), "items": download_items, }, "transfer_history": { "ok": bool(transfer_result.get("success")), "total": self._safe_int(transfer_result.get("total"), len(transfer_items)), "items": transfer_items, }, "diagnosis_summary": diagnosis_summary, "followup_summary": followup_summary, "recommended_action": diagnosis_summary.get("recommended_action"), "follow_up_hint": diagnosis_summary.get("follow_up_hint"), "next_actions": next_actions, "action_templates": action_templates, }), } async def _assistant_mp_ingest_status( self, *, session: str, cache_key: str, title: str = "", hash_value: str = "", limit: int = 5, ) -> Dict[str, Any]: lifecycle = await self._assistant_mp_lifecycle_status( session=session, cache_key=cache_key, title=title, hash_value=hash_value, limit=limit, ) lifecycle_data = dict((lifecycle or {}).get("data") or {}) diagnosis_summary = dict(lifecycle_data.get("diagnosis_summary") or {}) stage = self._clean_text(diagnosis_summary.get("stage")) or "unknown" lines = [ f"本地/PT 入库状态:{self._clean_text(title or hash_value) or '全部'}", f"当前阶段:{stage}", ] if diagnosis_summary.get("evidence"): lines.append("证据:") for item in (diagnosis_summary.get("evidence") or [])[:5]: lines.append(f"- {item}") if diagnosis_summary.get("risk_reasons"): lines.append("风险:") for item in (diagnosis_summary.get("risk_reasons") or [])[:5]: lines.append(f"- {item}") lines.extend(self._format_followup_summary_lines(diagnosis_summary.get("followup_summary"))) lifecycle_data["action"] = "mp_ingest_status" return { "success": bool(lifecycle.get("success")), "message": "\n".join(lines), "data": lifecycle_data, } async def _assistant_mp_ingest_failures( self, *, session: str, cache_key: str, title: str = "", limit: int = 10, page: int = 1, ) -> Dict[str, Any]: result = self._ensure_feishu_channel()._query_transfer_history( title=title, status="failed", limit=max(1, min(50, self._safe_int(limit, 10))), page=max(1, self._safe_int(page, 1)), ) items = result.get("items") if isinstance(result.get("items"), list) else [] diagnosis_summary = self._assistant_mp_diagnosis_summary( keyword=title, hash_value="", task_items=[], download_items=[], transfer_items=items, force_failed=bool(items), ) next_actions, action_templates = self._assistant_mp_diagnosis_followups( session=session, session_id=cache_key, keyword=title, hash_value="", preferred="query_mp_local_diagnose", ) lines = [f"本地/PT 失败线索:{self._clean_text(title) or '最近失败'}"] if items: for item in items[: min(len(items), 6)]: lines.append( f"{item.get('index')}. {item.get('title')} ({item.get('year') or '-'}) | " f"{item.get('status_text') or '-'} | {item.get('date') or '-'}" ) else: lines.append("未找到匹配的整理/入库失败记录。") self._save_session(cache_key, { "kind": "assistant_mp_ingest_failures", "stage": "ingest_failures", "keyword": title or "failed", "items": items, "target_path": "", }) return { "success": bool(result.get("success")), "message": "\n".join(lines), "data": self._assistant_response_data(session=session, data={ "action": "mp_ingest_failures", "ok": bool(result.get("success")), "source_type": "moviepilot_transfer_failures", "title": title, "status": "failed", "items": items, "total": self._safe_int(result.get("total"), len(items)), "page": self._safe_int(result.get("page"), page), "limit": self._safe_int(result.get("limit"), limit), "diagnosis_summary": diagnosis_summary, "recommended_action": diagnosis_summary.get("recommended_action"), "follow_up_hint": diagnosis_summary.get("follow_up_hint"), "next_actions": next_actions, "action_templates": action_templates, }), } async def _assistant_ai_failed_samples( self, *, session: str, cache_key: str, keyword: str = "", limit: int = 10, ) -> Dict[str, Any]: safe_limit = max(1, min(50, self._safe_int(limit, 10))) result = self._assistant_ai_failed_samples_payload(limit=safe_limit) rows = [] if isinstance((result or {}).get("data"), dict): rows = (result.get("data") or {}).get("samples") or [] items = self._assistant_ai_filtered_rows(rows if isinstance(rows, list) else [], keyword) next_actions, action_templates = self._assistant_ai_followups( session=session, session_id=cache_key, keyword=keyword, ) decision_summary = self._assistant_ai_reingest_decision_summary( items=items, fallback_command="工作清单", ) lines = [f"AI 失败样本:{self._clean_text(keyword) or '全部'}"] if items: lines.append(f"命中 {len(items)} 条,展示前 {min(len(items), 6)} 条:") lines.extend(self._assistant_ai_sample_brief_lines(items, limit=6)) else: lines.append("当前没有命中的 AI 失败样本。") self._save_session(cache_key, { "kind": "assistant_ai_failed_samples", "stage": "ai_failed_samples", "keyword": keyword or "all", "items": items, "target_path": "", }) return { "success": bool(result.get("success")), "message": "\n".join(lines), "data": self._assistant_response_data(session=session, data={ "action": "ai_failed_samples", "ok": bool(result.get("success")), "keyword": keyword, "items": items, "count": len(items), "total": self._safe_int(((result.get("data") or {}).get("count")), len(items)), "next_actions": next_actions, "action_templates": action_templates, "decision_summary": decision_summary, "recommended_action": "query_ai_sample_worklist", "follow_up_hint": "先看工作清单或样本洞察,再决定是否进入二次识别重放。", }), } async def _assistant_ai_sample_worklist( self, *, session: str, cache_key: str, keyword: str = "", limit: int = 10, ) -> Dict[str, Any]: safe_limit = max(1, min(50, self._safe_int(limit, 10))) result = self._assistant_ai_sample_worklist_payload(limit=safe_limit) rows = [] if isinstance((result or {}).get("data"), dict): rows = (result.get("data") or {}).get("samples") or [] items = self._assistant_ai_filtered_rows(rows if isinstance(rows, list) else [], keyword) next_actions, action_templates = self._assistant_ai_followups( session=session, session_id=cache_key, keyword=keyword, ) decision_summary = self._assistant_ai_reingest_decision_summary( items=items, fallback_command="样本洞察", ) lines = [f"AI 工作清单:{self._clean_text(keyword) or '全部'}"] if items: lines.append(f"命中 {len(items)} 条,展示前 {min(len(items), 6)} 条:") lines.extend(self._assistant_ai_sample_brief_lines(items, limit=6)) else: lines.append("当前没有命中的 AI 工作清单样本。") self._save_session(cache_key, { "kind": "assistant_ai_sample_worklist", "stage": "ai_sample_worklist", "keyword": keyword or "all", "items": items, "target_path": "", }) return { "success": bool(result.get("success")), "message": "\n".join(lines), "data": self._assistant_response_data(session=session, data={ "action": "ai_sample_worklist", "ok": bool(result.get("success")), "keyword": keyword, "items": items, "count": len(items), "total": self._safe_int(((result.get("data") or {}).get("count")), len(items)), "next_actions": next_actions, "action_templates": action_templates, "decision_summary": decision_summary, "recommended_action": "query_ai_sample_insights", "follow_up_hint": "先看样本洞察确认哪些失败样本最值得重放或生成识别词。", }), } async def _assistant_ai_sample_insights( self, *, session: str, cache_key: str, keyword: str = "", limit: int = 20, top: int = 5, ) -> Dict[str, Any]: safe_limit = max(1, min(100, self._safe_int(limit, 20))) safe_top = max(1, min(10, self._safe_int(top, 5))) result = self._assistant_ai_sample_insights_payload(limit=safe_limit, top=safe_top) insights = dict((result.get("data") or {})) if isinstance(result.get("data"), dict) else {} next_actions, action_templates = self._assistant_ai_followups( session=session, session_id=cache_key, keyword=keyword, ) priority_samples = insights.get("priority_samples") if isinstance(insights.get("priority_samples"), list) else [] decision_summary = self._assistant_ai_reingest_decision_summary( items=priority_samples, fallback_command="工作清单", ) lines = [f"AI 样本洞察:{self._clean_text(keyword) or '全局'}"] total_count = self._safe_int(insights.get("total_count"), 0) if total_count > 0: lines.append(f"样本总数:{total_count}") reason_counts = insights.get("reason_counts") if isinstance(insights.get("reason_counts"), list) else [] if reason_counts: lines.append("主要失败原因:") for item in reason_counts[:safe_top]: lines.append(f"- {self._clean_text(item.get('reason')) or '-'}:{self._safe_int(item.get('count'), 0)}") repeated_groups = insights.get("repeated_groups") if isinstance(insights.get("repeated_groups"), list) else [] if repeated_groups: lines.append("重复出现的样本组:") for item in repeated_groups[:safe_top]: lines.append(f"- {self._clean_text(item.get('title')) or '-'}:{self._safe_int(item.get('count'), 0)}") if priority_samples: lines.append("优先处理样本:") lines.extend(self._assistant_ai_sample_brief_lines(priority_samples, limit=safe_top)) else: lines.append("当前没有可分析的 AI 失败样本洞察。") self._save_session(cache_key, { "kind": "assistant_ai_sample_insights", "stage": "ai_sample_insights", "keyword": keyword or "all", "items": insights, "target_path": "", }) return { "success": bool(result.get("success")), "message": "\n".join(lines), "data": self._assistant_response_data(session=session, data={ "action": "ai_sample_insights", "ok": bool(result.get("success")), "keyword": keyword, "insights": insights, "next_actions": next_actions, "action_templates": action_templates, "decision_summary": decision_summary, "recommended_action": "query_ai_sample_worklist", "follow_up_hint": "先挑优先样本,再确认是否进入 AI 二次识别重放。", }), } def _assistant_ai_replay_followups( self, *, session: str, session_id: str, keyword: str, ) -> Tuple[List[str], List[Dict[str, Any]]]: next_actions: List[str] = [ "query_ai_sample_worklist", "query_ai_failed_samples", "query_ai_sample_insights", ] if keyword: next_actions = ["query_mp_local_diagnose", "query_mp_ingest_status", *next_actions] base_body = { "session": session, "session_id": session_id, "compact": True, } templates: List[Dict[str, Any]] = [] if keyword: templates.extend([ self._assistant_action_template( name="query_mp_local_diagnose", description="继续查看这次二次识别相关的本地/PT 入库诊断线索", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_body, "name": "query_mp_local_diagnose", "keyword": keyword, "limit": 5}, ), self._assistant_action_template( name="query_mp_ingest_status", description="按目标标题查看当前是否已重新识别、整理或入库", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_body, "name": "query_mp_ingest_status", "keyword": keyword, "limit": 5}, ), ]) templates.extend([ self._assistant_action_template( name="query_ai_sample_worklist", description="回看 AI 工作清单,继续挑需要重放的失败样本", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_body, "name": "query_ai_sample_worklist", "keyword": keyword}, ), self._assistant_action_template( name="query_ai_failed_samples", description="回看 AI 原始失败样本,核对标题、路径和失败原因", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_body, "name": "query_ai_failed_samples", "keyword": keyword}, ), self._assistant_action_template( name="query_ai_sample_insights", description="查看 AI 样本洞察,判断失败原因是否仍然集中", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_body, "name": "query_ai_sample_insights", "keyword": keyword}, ), ]) return next_actions, templates def _assistant_ai_reingest_decision_summary( self, *, items: List[Dict[str, Any]], fallback_command: str, ) -> Dict[str, Any]: fallback = self._clean_text(fallback_command) first_index = 0 for item in items or []: first_index = self._safe_int((item or {}).get("index"), 0) if first_index > 0: break if first_index <= 0: return { "decision_mode": "show_detail", "decision_reason": "当前没有可直接重放的 AI 失败样本。", "preferred_command": fallback, "fallback_command": "", "compact_commands": [fallback] if fallback else [], "recommended_agent_behavior": "show_only" if fallback else "stop", "auto_run_command": "", "confirm_command": "", "display_command": fallback, } replay_command = f"重放 {first_index}" return { "decision_mode": "make_plan", "decision_reason": f"先为样本 #{first_index} 生成二次识别重放计划,再确认是否实际重放。", "preferred_command": replay_command, "fallback_command": fallback, "compact_commands": [item for item in [replay_command, fallback] if item], "command_policy": "read_then_confirm_write", "preferred_requires_confirmation": False, "fallback_requires_confirmation": False, "can_auto_run_preferred": True, "recommended_agent_behavior": "auto_continue_then_wait_confirmation", "auto_run_command": replay_command, "confirm_command": "确认", "display_command": replay_command, } def _assistant_ai_replay_sample_plan_response( self, *, sample_index: int, session: str, cache_key: str, remove_if_resolved: bool = True, ) -> Dict[str, Any]: desired = self._safe_int(sample_index, 0) if desired <= 0: return { "success": False, "message": "重放样本需要有效编号,例如:重放样本 3。", "data": self._assistant_response_data(session=session, data={ "action": "ai_replay_failed_sample", "ok": False, "error_code": "missing_sample_index", }), } reference = self._assistant_ai_sample_reference(cache_key=cache_key, sample_index=desired) if not reference: return { "success": False, "message": "未找到对应的 AI 失败样本。请先发送“工作清单”或“失败样本”查看当前样本编号。", "data": self._assistant_response_data(session=session, data={ "action": "ai_replay_failed_sample", "ok": False, "error_code": "sample_not_found", "sample_index": desired, }), } title = self._assistant_ai_replay_target_title( sample=reference, target=(reference.get("inferred_target") if isinstance(reference.get("inferred_target"), dict) else {}), ) return self._save_assistant_pick_plan_response( workflow="ai_replay_failed_sample", session=session, session_id=cache_key, actions=[{ "name": "replay_ai_failed_sample", "session": session, "session_id": cache_key, "sample_index": desired, "remove_if_resolved": bool(remove_if_resolved), }], execute_body={ "workflow": "ai_replay_failed_sample", "session": session, "session_id": cache_key, "sample_index": desired, "remove_if_resolved": bool(remove_if_resolved), "dry_run": False, }, message="AI 二次识别重放计划已生成", extra_data={ "sample_index": desired, "remove_if_resolved": bool(remove_if_resolved), "source_sample": reference, "target": reference.get("inferred_target") if isinstance(reference.get("inferred_target"), dict) else {}, "keyword": title, "decision_summary": { "decision_mode": "execute_now", "decision_reason": ( "AI 二次识别重放计划已生成,确认后才会实际重放并尝试重新识别。" if bool(remove_if_resolved) else "AI 二次识别重放计划已生成,确认后会实际重放,但保留原失败样本。" ), "preferred_command": "确认", "fallback_command": "工作清单", "compact_commands": ["确认", "工作清单"], "command_policy": "confirm_then_resume", "preferred_requires_confirmation": True, "fallback_requires_confirmation": False, "can_auto_run_preferred": False, "recommended_agent_behavior": "wait_user_confirmation", "auto_run_command": "", "confirm_command": "确认", "display_command": "确认", }, }, ) def _assistant_ai_replay_execution_decision_summary( self, *, ok: bool, resolved: bool, has_title: bool, ) -> Dict[str, Any]: if ok and resolved and has_title: return { "decision_mode": "show_detail", "decision_reason": "这次二次识别已命中目标,下一步先看本地诊断确认是否已消除失败,再决定是否继续整理。", "preferred_command": "诊断", "fallback_command": "入库状态", "compact_commands": ["诊断", "入库状态"], "command_policy": "safe_read_only", "preferred_requires_confirmation": False, "fallback_requires_confirmation": False, "can_auto_run_preferred": True, "recommended_agent_behavior": "auto_continue", "auto_run_command": "诊断", "confirm_command": "", "display_command": "诊断", } if ok: return { "decision_mode": "show_detail", "decision_reason": "这次重放已完成,但暂未命中目标。先回看工作清单和样本洞察,再决定是否继续重放或补识别词。", "preferred_command": "工作清单", "fallback_command": "样本洞察", "compact_commands": ["工作清单", "样本洞察"], "command_policy": "safe_read_only", "preferred_requires_confirmation": False, "fallback_requires_confirmation": False, "can_auto_run_preferred": True, "recommended_agent_behavior": "auto_continue", "auto_run_command": "工作清单", "confirm_command": "", "display_command": "工作清单", } return { "decision_mode": "show_detail", "decision_reason": "这次重放没有成功执行。先回看工作清单或失败样本,确认当前还能处理哪些样本。", "preferred_command": "工作清单", "fallback_command": "失败样本", "compact_commands": ["工作清单", "失败样本"], "command_policy": "show_only", "preferred_requires_confirmation": False, "fallback_requires_confirmation": False, "can_auto_run_preferred": False, "recommended_agent_behavior": "show_only", "auto_run_command": "", "confirm_command": "", "display_command": "工作清单", } async def _assistant_ai_replay_failed_sample( self, *, sample_index: int, session: str, cache_key: str, remove_if_resolved: bool = True, ) -> Dict[str, Any]: desired = self._safe_int(sample_index, 0) if desired <= 0: return { "success": False, "message": "重放样本需要有效编号,例如:重放样本 3。", "data": self._assistant_response_data(session=session, data={ "action": "ai_replay_failed_sample", "ok": False, "error_code": "missing_sample_index", }), } result = self._assistant_ai_replay_failed_sample_payload( sample_index=desired, remove_if_resolved=bool(remove_if_resolved), ) ok = bool(result.get("success")) payload = dict(result.get("data") or {}) if isinstance(result.get("data"), dict) else {} source_sample = dict(payload.get("source_sample") or {}) if isinstance(payload.get("source_sample"), dict) else {} target = dict(payload.get("target") or {}) if isinstance(payload.get("target"), dict) else {} title = self._assistant_ai_replay_target_title(sample=source_sample, target=target) next_actions, action_templates = self._assistant_ai_replay_followups( session=session, session_id=cache_key, keyword=title, ) if ok: self._save_session(cache_key, { "kind": "assistant_ai_replay", "stage": "ai_replay_failed_sample", "keyword": title or f"sample-{desired}", "items": [source_sample] if source_sample else [], "target_path": "", "sample_index": desired, "replay_result": { "resolved": bool(payload.get("resolved")), "resolved_by_identifiers": bool(payload.get("resolved_by_identifiers")), "resolved_by_recognizer": bool(payload.get("resolved_by_recognizer")), "sample_removed": bool(payload.get("sample_removed")), }, }) lines = [f"AI 二次识别重放:样本 #{desired}"] if title: lines.append(f"目标:{title}") if ok: lines.append("结果:" + ("已命中目标并完成重放" if bool(payload.get("resolved")) else "已完成重放,但暂未命中目标")) if payload.get("resolved_by_identifiers"): lines.append("来源:当前识别词已直接命中目标。") elif payload.get("resolved_by_recognizer"): lines.append("来源:识别器重跑后命中了目标。") if "sample_removed" in payload: lines.append("样本移除:" + ("已移除" if payload.get("sample_removed") else "未移除")) else: lines.append(self._clean_text(result.get("message")) or "重放失败") resolved = bool(payload.get("resolved")) recommended_action = "query_mp_local_diagnose" if ok and resolved and title else "query_ai_sample_worklist" follow_up_hint = ( "先回到本地诊断或入库状态,确认这次重放是否已经消除失败。" if ok and resolved and title else "先看工作清单或失败样本,确认是否还有可重放样本。" ) decision_summary = self._assistant_ai_replay_execution_decision_summary( ok=ok, resolved=resolved, has_title=bool(title), ) return { "success": ok, "message": "\n".join(line for line in lines if line).strip(), "data": self._assistant_response_data(session=session, data={ "action": "ai_replay_failed_sample", "ok": ok, "sample_index": desired, "source_sample": source_sample, "target": target, "identifier_preview": payload.get("identifier_preview") if isinstance(payload.get("identifier_preview"), dict) else {}, "recognize_result": payload.get("recognize_result") if isinstance(payload.get("recognize_result"), dict) else {}, "resolved": bool(payload.get("resolved")), "resolved_by_identifiers": bool(payload.get("resolved_by_identifiers")), "resolved_by_recognizer": bool(payload.get("resolved_by_recognizer")), "sample_removed": bool(payload.get("sample_removed")), "sample_removal_result": payload.get("sample_removal_result") if isinstance(payload.get("sample_removal_result"), dict) else {}, "recommended_action": recommended_action, "follow_up_hint": follow_up_hint, "decision_summary": decision_summary, "next_actions": next_actions, "action_templates": action_templates, }), } async def _assistant_mp_recent_activity( self, *, session: str, cache_key: str, limit: int = 10, download_only: bool = False, transfer_only: bool = False, ) -> Dict[str, Any]: safe_limit = max(1, min(20, self._safe_int(limit, 10))) channel = self._ensure_feishu_channel() download_result = {"success": True, "items": [], "total": 0} transfer_result = {"success": True, "items": [], "total": 0} if not transfer_only: download_result = channel._query_download_history(title="", hash_value="", limit=safe_limit, page=1) if not download_only: transfer_result = channel._query_transfer_history(title="", status="all", limit=safe_limit, page=1) download_items = download_result.get("items") if isinstance(download_result.get("items"), list) else [] transfer_items = transfer_result.get("items") if isinstance(transfer_result.get("items"), list) else [] diagnosis_summary = self._assistant_mp_recent_activity_summary( download_items=download_items, transfer_items=transfer_items, ) next_actions, action_templates = self._assistant_mp_diagnosis_followups( session=session, session_id=cache_key, keyword="", hash_value="", preferred="query_mp_lifecycle_status", ) followup_summary = self._assistant_followup_summary( category="mp_diagnosis", stage=self._clean_text(diagnosis_summary.get("stage")), recommended_action=self._clean_text(diagnosis_summary.get("recommended_action")), follow_up_hint=self._clean_text(diagnosis_summary.get("follow_up_hint")), next_actions=next_actions, action_templates=action_templates, keyword="", hash_value="", ) diagnosis_summary["followup_summary"] = followup_summary lines = ["最近本地/PT 活动:"] if download_items: lines.append(f"最近下载:{len(download_items)} 条") if transfer_items: lines.append(f"最近入库:{len(transfer_items)} 条") if not download_items and not transfer_items: lines.append("当前没有可展示的最近下载或入库活动。") lines.extend(self._format_followup_summary_lines(followup_summary)) self._save_session(cache_key, { "kind": "assistant_mp_recent_activity", "stage": "recent_activity", "keyword": "recent_activity", "items": { "download_history": download_items, "transfer_history": transfer_items, }, "target_path": "", }) return { "success": bool(download_result.get("success")) and bool(transfer_result.get("success")), "message": "\n".join(lines), "data": self._assistant_response_data(session=session, data={ "action": "mp_recent_activity", "ok": bool(download_result.get("success")) and bool(transfer_result.get("success")), "download_history": { "ok": bool(download_result.get("success")), "total": self._safe_int(download_result.get("total"), len(download_items)), "items": download_items, }, "transfer_history": { "ok": bool(transfer_result.get("success")), "total": self._safe_int(transfer_result.get("total"), len(transfer_items)), "items": transfer_items, }, "diagnosis_summary": diagnosis_summary, "followup_summary": followup_summary, "recommended_action": diagnosis_summary.get("recommended_action"), "follow_up_hint": diagnosis_summary.get("follow_up_hint"), "next_actions": next_actions, "action_templates": action_templates, }), } async def _assistant_mp_local_diagnose( self, *, session: str, cache_key: str, title: str = "", hash_value: str = "", limit: int = 5, ) -> Dict[str, Any]: lifecycle = await self._assistant_mp_lifecycle_status( session=session, cache_key=cache_key, title=title, hash_value=hash_value, limit=limit, ) lifecycle_data = dict((lifecycle or {}).get("data") or {}) diagnosis_summary = dict(lifecycle_data.get("diagnosis_summary") or {}) stage = self._clean_text(diagnosis_summary.get("stage")) or "unknown" risk_reasons = diagnosis_summary.get("risk_reasons") or [] evidence = diagnosis_summary.get("evidence") or [] ai_worklist = await self._assistant_ai_sample_worklist( session=session, cache_key=cache_key, keyword=title, limit=max(5, limit), ) ai_data = dict((ai_worklist or {}).get("data") or {}) ai_items = ai_data.get("items") if isinstance(ai_data.get("items"), list) else [] if ai_items: evidence.append(f"AI 失败样本命中 {len(ai_items)} 条") risk_reasons.append("存在可用于二次识别重放的 AI 失败样本") message_lines = [ f"本地诊断:{self._clean_text(title or hash_value) or '全部'}", f"判断阶段:{stage}", f"是否命中记录:{'是' if diagnosis_summary.get('matched') else '否'}", ] if evidence: message_lines.append("诊断证据:") for item in evidence[:6]: message_lines.append(f"- {item}") if risk_reasons: message_lines.append("风险与失败线索:") for item in risk_reasons[:6]: message_lines.append(f"- {item}") if ai_items: message_lines.append("AI 失败样本:") for item in self._assistant_ai_sample_brief_lines(ai_items, limit=3): message_lines.append(f"- {item}") if diagnosis_summary.get("follow_up_hint"): message_lines.append(f"建议动作:{diagnosis_summary.get('follow_up_hint')}") next_actions = [] for name in lifecycle_data.get("next_actions") or []: text = self._clean_text(name) if text and text not in next_actions: next_actions.append(text) for name in ai_data.get("next_actions") or []: text = self._clean_text(name) if text and text not in next_actions: next_actions.append(text) action_templates = [] for item in (lifecycle_data.get("action_templates") or []) + (ai_data.get("action_templates") or []): if isinstance(item, dict): action_templates.append(item) diagnosis_summary["risk_reasons"] = risk_reasons[:6] diagnosis_summary["evidence"] = evidence[:6] lifecycle_data["diagnosis_summary"] = diagnosis_summary lifecycle_data["action"] = "mp_local_diagnose" lifecycle_data["ai_sample_worklist"] = { "ok": bool(ai_data.get("ok")), "count": self._safe_int(ai_data.get("count"), len(ai_items)), "items": ai_items[:10], } lifecycle_data["next_actions"] = next_actions[:6] lifecycle_data["action_templates"] = action_templates[:6] return { "success": bool(lifecycle.get("success")), "message": "\n".join(message_lines), "data": lifecycle_data, } async def _assistant_mp_recommendations( self, *, source: str = "tmdb_trending", media_type: str = "all", limit: int = 20, session: str = "default", cache_key: str = "", ) -> Dict[str, Any]: try: from app.chain.recommend import RecommendChain from app.schemas.types import MediaType, media_type_to_agent except Exception as exc: return { "success": False, "message": f"MP 推荐失败:当前环境缺少推荐依赖 {exc}", "data": self._assistant_response_data(session=session, data={"action": "mp_recommendations", "ok": False}), } max_limit = max(1, min(50, self._safe_int(limit, 20))) source_name = self._clean_text(source) or "tmdb_trending" media_type_name = self._clean_text(media_type) or "all" chain = RecommendChain() try: def collect_items(raw_results: List[Dict[str, Any]], media_type_filter: str = "") -> List[Dict[str, Any]]: current_media_type = media_type_filter or media_type_name collected = [] for raw_item in (raw_results or [])[:max_limit]: if not isinstance(raw_item, dict): continue item_type = raw_item.get("type") if current_media_type != "all": enum_type = MediaType.from_agent(current_media_type) agent_type = media_type_to_agent(item_type) expected_types = {current_media_type} if current_media_type == "movie": expected_types.add("电影") elif current_media_type == "tv": expected_types.update({"电视剧", "剧集"}) if enum_type and item_type != enum_type and agent_type not in expected_types: continue collected.append({ "index": len(collected) + 1, "title": raw_item.get("title"), "year": raw_item.get("year"), "type": media_type_to_agent(item_type), "tmdb_id": raw_item.get("tmdb_id"), "douban_id": raw_item.get("douban_id"), "vote_average": raw_item.get("vote_average"), "poster_path": raw_item.get("poster_path"), "detail_link": raw_item.get("detail_link"), }) return collected results: List[Dict[str, Any]] = [] if source_name == "tmdb_trending": results = await chain.async_tmdb_trending(page=1) elif source_name == "tmdb_movies": results = await chain.async_tmdb_movies(page=1) elif source_name == "tmdb_tvs": results = await chain.async_tmdb_tvs(page=1) elif source_name in {"douban_hot", "douban_movie_hot"}: results = await chain.async_douban_movie_hot(page=1, count=max_limit) if source_name == "douban_hot" and media_type_name in {"all", "tv"}: results.extend(await chain.async_douban_tv_hot(page=1, count=max_limit)) elif source_name == "douban_tv_hot": results = await chain.async_douban_tv_hot(page=1, count=max_limit) elif source_name == "douban_movie_showing": results = await chain.async_douban_movie_showing(page=1, count=max_limit) elif source_name == "douban_movie_top250": results = await chain.async_douban_movie_top250(page=1, count=max_limit) elif source_name == "douban_tv_animation": results = await chain.async_douban_tv_animation(page=1, count=max_limit) elif source_name == "bangumi_calendar": results = await chain.async_bangumi_calendar(page=1, count=max_limit) else: return { "success": False, "message": f"不支持的推荐来源:{source_name}", "data": self._assistant_response_data(session=session, data={"action": "mp_recommendations", "ok": False}), } items = collect_items(results) fallback_source = "" fallback_notice = "" async def fetch_source_items(next_source: str, next_media_type: str) -> List[Dict[str, Any]]: if next_source == "tmdb_trending": return collect_items(await chain.async_tmdb_trending(page=1), next_media_type) if next_source == "tmdb_movies": return collect_items(await chain.async_tmdb_movies(page=1), next_media_type) if next_source == "tmdb_tvs": return collect_items(await chain.async_tmdb_tvs(page=1), next_media_type) if next_source == "douban_movie_hot": return collect_items(await chain.async_douban_movie_hot(page=1, count=max_limit), next_media_type) if next_source == "douban_tv_hot": return collect_items(await chain.async_douban_tv_hot(page=1, count=max_limit), next_media_type) if next_source == "douban_movie_showing": return collect_items(await chain.async_douban_movie_showing(page=1, count=max_limit), next_media_type) if next_source == "douban_movie_top250": return collect_items(await chain.async_douban_movie_top250(page=1, count=max_limit), next_media_type) if next_source == "douban_tv_animation": return collect_items(await chain.async_douban_tv_animation(page=1, count=max_limit), next_media_type) if next_source == "bangumi_calendar": return collect_items(await chain.async_bangumi_calendar(page=1, count=max_limit), next_media_type) if next_source == "douban_hot": mixed = collect_items(await chain.async_douban_movie_hot(page=1, count=max_limit), "movie") if media_type_name in {"all", "tv"}: mixed.extend(collect_items(await chain.async_douban_tv_hot(page=1, count=max_limit), "tv")) for idx, item in enumerate(mixed, start=1): item["index"] = idx return mixed[:max_limit] return [] fallback_candidates: List[Tuple[str, str]] = [] if media_type_name == "tv": fallback_candidates = [ ("douban_tv_hot", "tv"), ("douban_tv_animation", "tv"), ("tmdb_tvs", "tv"), ("tmdb_trending", "tv"), ] elif media_type_name == "movie": fallback_candidates = [ ("douban_movie_showing", "movie"), ("douban_movie_hot", "movie"), ("tmdb_movies", "movie"), ("tmdb_trending", "movie"), ] else: fallback_candidates = [ ("douban_hot", "all"), ("douban_movie_showing", "movie"), ("tmdb_trending", "all"), ] if not items: for candidate_source, candidate_media_type in fallback_candidates: if candidate_source == source_name: continue candidate_items = await fetch_source_items(candidate_source, candidate_media_type) if candidate_items: fallback_source = candidate_source fallback_notice = f"{source_name} 当前暂无结果,已自动回退 {candidate_source}。" items = candidate_items break if not items: return { "success": False, "message": ( f"MP 推荐暂无结果:{source_name}。已尝试备用推荐源,仍没有可展示条目。" " 请换一个推荐来源,例如:豆瓣热门 / 豆瓣热映 / 热门电影 / 热门剧集。" ), "data": self._assistant_response_data(session=session, data={ "action": "mp_recommendations", "ok": False, "source_type": "moviepilot_recommendation", "source": source_name, "requested_source": source_name, "media_type": media_type_name, "items": [], "fallback_tried": [item[0] for item in fallback_candidates if item[0] != source_name], }), } display_source = fallback_source or source_name lines = [f"MP 热门推荐:{display_source},共 {len(items)} 条"] if fallback_notice: lines.append(f"注:{fallback_notice}") for item in items[:10]: lines.append(f"{item.get('index')}. {item.get('title') or '-'} ({item.get('year') or '-'}) | {item.get('type') or '-'} | 评分 {item.get('vote_average') or '-'}") lines.append("下一步:回复“选择 1 决策”进入统一资源决策。") lines.append("如果已经明确意图,也可以直接发“选择 1 计划”或“选择 1 确认”;也支持直接回复“详情”“计划”“确认”,默认作用于当前榜单首项。") lines.append("如果想走单源,也可以回复“选择 1”进入 MP 原生搜索,或“选择 1 影巢”“选择 1 盘搜”。") decision_summary = self._assistant_mp_recommendation_decision_summary() if cache_key: self._save_session(cache_key, { "kind": "assistant_mp_recommend", "stage": "result", "source": fallback_source or source_name, "requested_source": source_name, "media_type": media_type_name, "keyword": "", "items": items, "target_path": "", "decision_summary": decision_summary, "detail_short_command": "详情", "plan_short_command": "计划", "confirm_short_command": "确认", "decision_short_command": "决策", "pansou_short_command": "盘搜", "hdhive_short_command": "影巢", "mp_short_command": "原生", }) return { "success": True, "message": "\n".join(lines), "data": self._assistant_response_data(session=session, data={ "action": "mp_recommendations", "ok": True, "source_type": "moviepilot_recommendation", "source": fallback_source or source_name, "requested_source": source_name, "fallback_source": fallback_source, "media_type": media_type_name, "items": items, "decision_summary": decision_summary, "detail_short_command": "详情", "plan_short_command": "计划", "confirm_short_command": "确认", "decision_short_command": "决策", "pansou_short_command": "盘搜", "hdhive_short_command": "影巢", "mp_short_command": "原生", }), } except Exception as exc: logger.error(f"MP 推荐失败:{source_name} {exc}", exc_info=True) return { "success": False, "message": f"MP 推荐失败:{exc}", "data": self._assistant_response_data(session=session, data={"action": "mp_recommendations", "ok": False}), } async def _assistant_streaming_recommend( self, *, media_type: str = "all", intent: str = "hot", month: str = "", window_days: int = 90, session: str = "default", cache_key: str = "", compact: bool = False, ) -> Dict[str, Any]: """流媒体推荐:TMDB discover 直连,返回按热度+评分综合排序的推荐列表""" try: from datetime import date as _date, timedelta as _td from .services.streaming_recommend import StreamingRecommendService except Exception as exc: return { "success": False, "message": f"流媒体推荐加载失败:{exc}", "data": self._assistant_response_data(session=session, data={"action": "streaming_recommend", "ok": False}), } tmdb_api_key = self._read_tmdb_api_key() if not tmdb_api_key: return { "success": False, "message": "TMDB API Key 未配置,无法查询流媒体推荐。请在 MoviePilot 系统设置中配置 TMDB API Key。", "data": self._assistant_response_data(session=session, data={"action": "streaming_recommend", "ok": False}), } today = _date.today() # ── 时间范围 ── start_date = "" end_date = "" if month: # month 格式:2026-05 → 严格按自然月 try: y, m = (int(x) for x in month.split("-")) month_start = _date(y, m, 1) if m == 12: month_end = _date(y + 1, 1, 1) - _td(days=1) else: month_end = _date(y, m + 1, 1) - _td(days=1) start_date = month_start.isoformat() end_date = month_end.isoformat() except Exception: start_date = today.isoformat() end_date = today.isoformat() elif window_days > 0: start_date = (today - _td(days=window_days)).isoformat() end_date = today.isoformat() else: start_date = today.isoformat() end_date = today.isoformat() service = StreamingRecommendService(tmdb_api_key=tmdb_api_key) result = await service.query( media_type=media_type, intent=intent, start_date=start_date, end_date=end_date, window_days=window_days, limit=10, ) if not result.get("success"): return { "success": False, "message": result.get("message") or "流媒体推荐查询失败。", "data": self._assistant_response_data(session=session, data={"action": "streaming_recommend", "ok": False}), } items = result.get("items") or [] query_params = result.get("query_params") or {} if month: try: _y, _m = (int(x) for x in month.split("-")) time_desc = f"{_y}年{_m:02d}月" except Exception: time_desc = month else: time_desc = f"近{window_days}天" if not items: type_desc = {"movie": "电影", "tv": "剧集"}.get(media_type, "影视") return { "success": True, "message": f"流媒体推荐({time_desc} · {type_desc})暂无结果。请换个条件试试。", "data": self._assistant_response_data(session=session, data={ "action": "streaming_recommend", "ok": True, "items": [], }), } # ── 格式化输出 ── intent_labels = {"hot": "热门", "new": "上新", "big_titles": "大作"} type_labels = {"movie": "电影", "tv": "剧集", "all": "影视"} header = f"流媒体推荐({time_desc} · {intent_labels.get(intent, '热门')} · {type_labels.get(media_type, '影视')})共 {len(items)} 条" lines = [header] for item in items: idx = item.get("index", "") title = item.get("title", "-") year = item.get("year", "-") media_t = item.get("media_type", "-") avg = item.get("vote_average", "-") pop = item.get("popularity", "-") release = item.get("release_date", "") reason = item.get("reason", "") display_release = release[:10] if release else "-" emoji = "🎬" if media_t == "电影" else "📺" lines.append( f"{idx}. {emoji} {title} ({year}) | 评分 {avg} | 热度 {pop}" ) lines.append( f" 上线:{display_release} | {reason}" ) lines.append("当前结果为只读推荐列表;如需继续处理,请改发 MP搜索 / PT搜索 / 盘搜搜索 / 影巢搜索 片名。") # ── 存 session ── if cache_key: self._save_session(cache_key, { "kind": "assistant_streaming_recommend", "stage": "result", "media_type": media_type, "intent": intent, "month": month, "window_days": window_days, "items": items, "query_params": query_params, }) return { "success": True, "message": "\n".join(lines), "data": self._assistant_response_data(session=session, data={ "action": "streaming_recommend", "ok": True, "media_type": media_type, "intent": intent, "month": month, "window_days": window_days, "items": items, "query_params": query_params, }), } def _format_mp_recommend_item_detail_text(self, item: Dict[str, Any]) -> str: title = self._clean_text(item.get("title")) or "-" year = self._clean_text(item.get("year")) or "-" media_type = self._clean_text(item.get("type")) or "-" tmdb_id = self._clean_text(item.get("tmdb_id")) or "-" douban_id = self._clean_text(item.get("douban_id")) or "-" vote = self._clean_text(item.get("vote_average")) or "-" lines = [ "MP 推荐条目详情", f"标题:{title}", f"年份:{year}", f"类型:{media_type}", f"评分:{vote}", f"TMDB:{tmdb_id}", f"豆瓣:{douban_id}", "下一步:回复“决策”“计划”“确认”,或“盘搜”“影巢”“原生”。", ] return "\n".join(lines) def _assistant_mp_recommendation_decision_summary(self) -> Dict[str, Any]: return { "decision_mode": "show_detail", "decision_reason": "推荐列表默认先看当前榜单首项详情,再决定是否生成计划或直接确认执行。", "decision_hint": "当前推荐会话支持直接对首项继续:详情 / 决策 / 计划 / 确认,也支持切到盘搜 / 影巢 / 原生。", "preferred_command": "详情", "fallback_command": "计划", "compact_commands": ["详情", "计划"], "command_policy": "safe_read_only", "preferred_requires_confirmation": False, "fallback_requires_confirmation": False, "can_auto_run_preferred": True, "recommended_agent_behavior": "show_only", "detail_short_command": "详情", "decision_short_command": "决策", "plan_short_command": "计划", "confirm_short_command": "确认", "pansou_short_command": "盘搜", "hdhive_short_command": "影巢", "mp_short_command": "原生", } def _assistant_recommend_handoff_public_data(self, state: Optional[Dict[str, Any]]) -> Dict[str, Any]: current_state = dict(state or {}) handoff = current_state.get("recommend_handoff") if not isinstance(handoff, dict) or not handoff: return {} selected_index = self._safe_int(handoff.get("selected_index"), 0) selected_item = dict(handoff.get("selected_item") or {}) if isinstance(handoff.get("selected_item"), dict) else {} return { "source": self._clean_text(handoff.get("source")), "requested_source": self._clean_text(handoff.get("requested_source")), "media_type": self._clean_text(handoff.get("media_type")), "selected_index": selected_index if selected_index > 0 else None, "selected_title": self._clean_text(selected_item.get("title")), "return_short_command": "回推荐", "source_short_commands": { "pansou": "盘搜", "hdhive": "影巢", "mp": "原生", }, } def _assistant_recommend_handoff_state( self, *, source: str, requested_source: str, media_type: str, selected_index: int, selected_item: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: payload: Dict[str, Any] = { "source": self._clean_text(source), "requested_source": self._clean_text(requested_source or source), "media_type": self._clean_text(media_type or "all"), "selected_index": max(0, self._safe_int(selected_index, 0)), "return_short_command": "回推荐", } if isinstance(selected_item, dict) and selected_item: payload["selected_item"] = dict(selected_item) return payload async def _assistant_restore_mp_recommendation_handoff( self, *, session: str, cache_key: str, state: Dict[str, Any], limit: int = 20, ) -> Dict[str, Any]: handoff = state.get("recommend_handoff") if not isinstance(handoff, dict) or not handoff: return { "success": False, "message": "当前会话没有可恢复的推荐上下文,请重新发送:智能发现 热门电影。", "data": self._assistant_response_data(session=session, data={ "action": "mp_recommendations", "ok": False, "error_code": "recommend_handoff_missing", }), } result = await self._assistant_mp_recommendations( source=self._clean_text(handoff.get("requested_source") or handoff.get("source") or "tmdb_trending"), media_type=self._clean_text(handoff.get("media_type") or "all"), limit=limit, session=session, cache_key=cache_key, ) if not result.get("success"): return result selected_index = max(0, self._safe_int(handoff.get("selected_index"), 0)) if selected_index > 0: refreshed_state = self._load_session(cache_key) or {} items = refreshed_state.get("items") if isinstance(refreshed_state.get("items"), list) else [] if selected_index <= len(items): self._save_session(cache_key, { **refreshed_state, "selected_index": selected_index, "selected_item": dict(items[selected_index - 1] or {}), }) message = str(result.get("message") or "").strip() if selected_index > 0: message = f"{message}\n已返回推荐列表,默认仍指向第 {selected_index} 项。".strip() result["message"] = message payload = dict(result.get("data") or {}) if selected_index > 0: payload["selected_index"] = selected_index payload["return_short_command"] = "" result["data"] = self._assistant_response_data(session=session, data=payload) return result async def _assistant_switch_recommend_handoff_source( self, request, *, session: str, cache_key: str, state: Dict[str, Any], mode: str, ) -> Dict[str, Any]: handoff = state.get("recommend_handoff") if not isinstance(handoff, dict) or not handoff: return { "success": False, "message": "当前会话没有可切换的推荐上下文,请重新发送:智能发现 热门电影。", "data": self._assistant_response_data(session=session, data={ "action": "switch_recommend_handoff_source", "ok": False, "error_code": "recommend_handoff_missing", }), } selected_item = dict(handoff.get("selected_item") or {}) if isinstance(handoff.get("selected_item"), dict) else {} keyword = self._clean_text(selected_item.get("title")) if not keyword: return { "success": False, "message": "当前推荐上下文缺少标题,无法切换源。请先发送:回推荐。", "data": self._assistant_response_data(session=session, data={ "action": "switch_recommend_handoff_source", "ok": False, "error_code": "recommend_handoff_title_missing", }), } media_type = self._clean_text(handoff.get("media_type") or "auto") return await self.api_assistant_route(_JsonRequestShim(request, { "session": session, "session_id": cache_key, "mode": self._clean_text(mode), "keyword": keyword, "media_type": media_type, "recommend_handoff": dict(handoff), "apikey": self._extract_apikey(request, {}), })) async def _assistant_run_recommend_source_compound( self, request, *, session: str, cache_key: str, state: Dict[str, Any], mode: str, followup_action: str, compact: bool, target_path: str, ) -> Dict[str, Any]: mode = self._clean_text(mode).lower() followup_action = self._clean_text(followup_action) kind = self._clean_text(state.get("kind")) if mode not in {"pansou", "mp"}: return { "success": False, "message": "当前只支持 盘搜/原生 的单条详情、计划、确认命令。", "data": self._assistant_response_data(session=session, data={ "action": "recommend_source_compound", "ok": False, "error_code": "unsupported_recommend_source_compound_mode", }), } if kind == "assistant_mp_recommend": selected_index = max(1, self._safe_int(state.get("selected_index"), 0) or 1) source_result = await self.api_assistant_pick(_JsonRequestShim(request, { "session": session, "session_id": cache_key, "index": selected_index, "mode": mode, "path": target_path, "compact": compact, "apikey": self._extract_apikey(request, {}), })) if not source_result.get("success"): return source_result else: kind_mode = { "assistant_pansou": "pansou", "assistant_mp": "mp", "assistant_hdhive": "hdhive", }.get(kind, "") if kind_mode != mode: source_result = await self._assistant_switch_recommend_handoff_source( request, session=session, cache_key=cache_key, state=state, mode=mode, ) if not source_result.get("success"): return source_result return await self.api_assistant_pick(_JsonRequestShim(request, { "session": session, "session_id": cache_key, "index": 0, "action": followup_action, "path": target_path, "compact": compact, "apikey": self._extract_apikey(request, {}), })) async def _assistant_route_recommend_handoff_to_smart_decision( self, request, *, session: str, cache_key: str, state: Dict[str, Any], ) -> Dict[str, Any]: handoff = state.get("recommend_handoff") if not isinstance(handoff, dict) or not handoff: return { "success": False, "message": "当前会话没有可恢复的推荐上下文,请重新发送:智能发现 热门电影。", "data": self._assistant_response_data(session=session, data={ "action": "smart_resource_decision", "ok": False, "error_code": "recommend_handoff_missing", }), } selected_item = dict(handoff.get("selected_item") or {}) if isinstance(handoff.get("selected_item"), dict) else {} keyword = self._clean_text(selected_item.get("title")) if not keyword: return { "success": False, "message": "当前推荐上下文缺少标题,无法回到统一资源决策。", "data": self._assistant_response_data(session=session, data={ "action": "smart_resource_decision", "ok": False, "error_code": "recommend_handoff_title_missing", }), } media_type = self._clean_text(handoff.get("media_type") or "auto") return await self.api_assistant_route(_JsonRequestShim(request, { "session": session, "session_id": cache_key, "mode": "smart_decision", "keyword": keyword, "media_type": media_type, "origin": "mp_recommend", "apikey": self._extract_apikey(request, {}), })) async def _assistant_confirm_recommend_handoff( self, request, *, session: str, cache_key: str, state: Dict[str, Any], compact: bool, target_path: str, ) -> Dict[str, Any]: pending_plan = self._find_workflow_plan( session=session, session_id=cache_key, executed=False, ) handoff_state = dict(state or {}) if pending_plan and not isinstance(handoff_state.get("recommend_handoff"), dict): pending_handoff = pending_plan.get("recommend_handoff") if isinstance(pending_plan.get("recommend_handoff"), dict) else {} if pending_handoff: handoff_state["recommend_handoff"] = dict(pending_handoff) if pending_plan: result = await self.api_assistant_plan_execute(_JsonRequestShim(request, { "session": session, "session_id": cache_key, "prefer_unexecuted": True, "compact": compact, "apikey": self._extract_apikey(request, {}), })) if handoff_state.get("recommend_handoff") and not bool(result.get("success")): result_data = dict(result.get("data") or {}) result_data.update(self._assistant_recommend_handoff_short_metadata(handoff_state)) result_data["followup_summary"] = self._assistant_recommend_handoff_execute_failure_followup(handoff_state) command_summary = self._assistant_compact_command_summary(result_data) if command_summary: result_data.update(command_summary) result["data"] = result_data return result kind = self._clean_text(state.get("kind")) if kind in {"assistant_pansou", "assistant_mp"}: result = await self.api_assistant_pick(_JsonRequestShim(request, { "session": session, "session_id": cache_key, "choice": 0, "action": "best_execute", "path": target_path, "compact": compact, "apikey": self._extract_apikey(request, {}), })) if handoff_state.get("recommend_handoff") and not bool(result.get("success")): result_data = dict(result.get("data") or {}) result_data.update(self._assistant_recommend_handoff_short_metadata(handoff_state)) result_data["followup_summary"] = self._assistant_recommend_handoff_execute_failure_followup(handoff_state) command_summary = self._assistant_compact_command_summary(result_data) if command_summary: result_data.update(command_summary) result["data"] = result_data return result return { "success": False, "message": "当前会话没有待确认计划。影巢候选阶段请先选择编号;如果想回统一搜索决策,请回复:决策。", "data": self._assistant_response_data(session=session, data={ "action": "confirm_recommend_handoff", "ok": False, "error_code": "recommend_handoff_pending_plan_missing", "preferred_command": "决策", "fallback_command": "回推荐", }), } def _assistant_recommend_handoff_short_metadata( self, state: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: handoff = (state or {}).get("recommend_handoff") if isinstance((state or {}).get("recommend_handoff"), dict) else {} source_short_commands = handoff.get("source_short_commands") if isinstance(handoff.get("source_short_commands"), dict) else {} return { "recommend_handoff": dict(handoff) if handoff else {}, "return_short_command": self._clean_text(handoff.get("return_short_command") or "回推荐") if handoff else "", "detail_short_command": "详情", "decision_short_command": "决策", "plan_short_command": "计划", "confirm_short_command": "确认", "pansou_short_command": self._clean_text(source_short_commands.get("pansou") or "盘搜") if handoff else "", "hdhive_short_command": self._clean_text(source_short_commands.get("hdhive") or "影巢") if handoff else "", "mp_short_command": self._clean_text(source_short_commands.get("mp") or "原生") if handoff else "", } def _assistant_recommend_handoff_plan_summary( self, state: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: meta = self._assistant_recommend_handoff_short_metadata(state) return { "stage": "confirm", "label": "待确认计划", "decision_hint": "当前推荐条目已生成计划;先看详情,再决定是否确认执行。", "command_policy": "read_then_confirm_write", "preferred_requires_confirmation": True, "fallback_requires_confirmation": False, "can_auto_run_preferred": False, "preferred_command": "确认", "fallback_command": "详情", "compact_commands": ["确认", "详情"], "recommended_agent_behavior": "auto_continue_then_wait_confirmation", "auto_run_command": "详情", "confirm_command": "确认", "display_command": "详情", **meta, } def _assistant_recommend_handoff_entry_summary( self, state: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: meta = self._assistant_recommend_handoff_short_metadata(state) return { "stage": "source_entry", "label": "已切入单源", "decision_hint": "当前推荐条目已切入单源结果;先看详情,再决定是否生成计划。", "command_policy": "read_then_confirm_write", "preferred_requires_confirmation": False, "fallback_requires_confirmation": False, "can_auto_run_preferred": True, "preferred_command": "详情", "fallback_command": "计划", "compact_commands": ["详情", "计划"], "recommended_agent_behavior": "auto_continue_then_wait_confirmation", "auto_run_command": "详情", "confirm_command": "确认", "display_command": "详情", **meta, } def _assistant_recommend_handoff_detail_summary( self, state: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: meta = self._assistant_recommend_handoff_short_metadata(state) return { "stage": "detail", "label": "已查看详情", "decision_hint": "当前推荐条目详情已展开;可以先生成计划,确认无误后再执行。", "command_policy": "read_then_confirm_write", "preferred_requires_confirmation": False, "fallback_requires_confirmation": True, "can_auto_run_preferred": True, "preferred_command": "计划", "fallback_command": "确认", "compact_commands": ["计划", "确认"], "recommended_agent_behavior": "auto_continue_then_wait_confirmation", "auto_run_command": "计划", "confirm_command": "确认", "display_command": "详情", **meta, } def _assistant_recommend_handoff_execute_failure_followup( self, state: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: meta = self._assistant_recommend_handoff_short_metadata(state) return { "category": "recommend_handoff", "stage": "write_failed", "label": "执行失败,建议换路", "preferred_action": "return_to_smart_decision", "decision_hint": "当前推荐条目的执行失败;优先回到统一资源决策,或回推荐后切换其它源。", "command_policy": "safe_read_recovery", "preferred_requires_confirmation": False, "fallback_requires_confirmation": False, "can_auto_run_preferred": False, "preferred_command": "决策", "fallback_command": meta.get("return_short_command") or "回推荐", "compact_commands": ["决策", meta.get("return_short_command") or "回推荐"], "recommended_commands": [ "决策", meta.get("return_short_command") or "回推荐", meta.get("hdhive_short_command") or "影巢", meta.get("mp_short_command") or "原生", ], "recommended_agent_behavior": "show_only", **meta, } def _persist_workflow_plans(self) -> None: try: items = sorted( (dict(item) for item in (self._workflow_plans or {}).values() if isinstance(item, dict)), key=lambda item: self._safe_int(item.get("created_at"), 0), reverse=True, )[:self._workflow_plan_limit] self._workflow_plans = { self._clean_text(item.get("plan_id")): item for item in items if self._clean_text(item.get("plan_id")) } self.save_data(key=self._workflow_plan_store_key, value=self._workflow_plans) except Exception: pass def _restore_workflow_plans(self) -> None: try: restored = self.get_data(self._workflow_plan_store_key) or {} if isinstance(restored, dict): self._workflow_plans = { self._clean_text(plan_id): dict(payload) for plan_id, payload in restored.items() if self._clean_text(plan_id) and isinstance(payload, dict) } except Exception: self._workflow_plans = {} def _save_workflow_plan( self, *, workflow: str, session: str, session_id: str = "", actions: List[Dict[str, Any]], execute_body: Dict[str, Any], ) -> Dict[str, Any]: plan_id = self._new_session_id("plan") created_at = int(time.time()) session_name, normalized_session_id = self._normalize_assistant_session_ref( session=session, session_id=session_id, ) plan = { "plan_id": plan_id, "workflow": self._clean_text(workflow), "session": session_name, "session_id": normalized_session_id, "actions": [dict(item or {}) for item in (actions or [])], "execute_body": dict(execute_body or {}), "created_at": created_at, "created_at_text": self._format_unix_time(created_at), "executed_at": 0, "executed_at_text": "", "executed": False, } self._workflow_plans[plan_id] = plan self._persist_workflow_plans() return dict(plan) def _save_assistant_pick_plan_response( self, *, workflow: str, session: str, session_id: str, actions: List[Dict[str, Any]], execute_body: Dict[str, Any], message: str, score_items: Optional[List[Dict[str, Any]]] = None, extra_data: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: plan = self._save_workflow_plan( workflow=workflow, session=session, session_id=session_id, actions=actions, execute_body=execute_body, ) plan_id = self._clean_text(plan.get("plan_id")) recommend_handoff = extra_data.get("recommend_handoff") if isinstance((extra_data or {}).get("recommend_handoff"), dict) else {} if plan_id and recommend_handoff and isinstance(self._workflow_plans.get(plan_id), dict): self._workflow_plans[plan_id]["recommend_handoff"] = dict(recommend_handoff) self._persist_workflow_plans() template = self._assistant_action_template( name="execute_plan", description="执行刚生成的计划", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", tool="agent_resource_officer_execute_plan", body={ "plan_id": plan_id, "session": session, "session_id": session_id, "prefer_unexecuted": True, }, ) data = { "action": "workflow_plan", "ok": True, "plan_id": plan_id, "workflow": workflow, "dry_run": True, "workflow_actions": [dict(item or {}) for item in actions], "estimated_steps": len(actions), "ready_to_execute": True, "execute_plan_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", "execute_plan_body": {"plan_id": plan_id}, "plan_created_at": plan.get("created_at"), "plan_created_at_text": plan.get("created_at_text"), "next_actions": ["execute_plan"], "action_templates": [template], "write_effect": "state", } if score_items: data["score_summary"] = self._score_summary(score_items, limit=1) if extra_data: data.update(extra_data) decision_summary = data.get("decision_summary") if isinstance(data.get("decision_summary"), dict) else {} for key in ["detail_short_command", "plan_short_command", "confirm_short_command", "auto_run_command", "confirm_command", "display_command"]: if key in decision_summary and key not in data: data[key] = decision_summary.get(key) command_summary = self._assistant_compact_command_summary(data) if command_summary: data.update(command_summary) choice = self._safe_int(data.get("choice"), 0) confirm_hint = f"回复“执行计划”或“{choice}”确认执行。" if choice > 0 else "回复“执行计划”确认执行。" return { "success": True, "message": f"{message}:{plan_id}\n未实际执行。{confirm_hint}", "data": self._assistant_response_data(session=session, data=data), } def _load_workflow_plan(self, plan_id: str) -> Optional[Dict[str, Any]]: plan = (self._workflow_plans or {}).get(self._clean_text(plan_id)) return dict(plan) if isinstance(plan, dict) else None def _find_workflow_plan( self, *, plan_id: str = "", session: str = "", session_id: str = "", executed: Optional[bool] = None, ) -> Optional[Dict[str, Any]]: clean_plan_id = self._clean_text(plan_id) if clean_plan_id: return self._load_workflow_plan(clean_plan_id) session_filter = "" session_id_filter = "" if self._clean_text(session) or self._clean_text(session_id): session_filter, session_id_filter = self._normalize_assistant_session_ref( session=session, session_id=session_id, ) if not session_id_filter: return None plans = sorted( (dict(item) for item in (self._workflow_plans or {}).values() if isinstance(item, dict)), key=lambda item: self._safe_int(item.get("created_at"), 0), reverse=True, ) for plan in plans: if self._clean_text(plan.get("session_id")) != session_id_filter: continue if executed is not None and bool(plan.get("executed")) != bool(executed): continue return dict(plan) return None def _find_pending_multi_plan( self, *, session: str, session_id: str, rank: int = 0, choice: int = 0, group_id: str = "", ) -> Optional[Dict[str, Any]]: session_filter, session_id_filter = self._normalize_assistant_session_ref( session=session, session_id=session_id, ) if not session_id_filter: return None candidates: List[Dict[str, Any]] = [] for plan in (self._workflow_plans or {}).values(): if not isinstance(plan, dict) or bool(plan.get("executed")): continue if self._clean_text(plan.get("session_id")) != session_id_filter: continue execute_body = plan.get("execute_body") if isinstance(plan.get("execute_body"), dict) else {} plan_group = self._clean_text(plan.get("multi_plan_group") or execute_body.get("multi_plan_group")) if group_id and plan_group != group_id: continue plan_rank = self._safe_int(plan.get("plan_rank") or execute_body.get("plan_rank"), 0) plan_choice = self._safe_int(plan.get("plan_choice") or execute_body.get("choice") or execute_body.get("index"), 0) if rank > 0 and plan_rank != rank: continue if choice > 0 and plan_choice != choice and plan_rank != choice: continue candidates.append(dict(plan)) candidates.sort( key=lambda item: ( -self._safe_int(item.get("created_at"), 0), self._safe_int(item.get("plan_rank") or (item.get("execute_body") or {}).get("plan_rank"), 999), ) ) return candidates[0] if candidates else None def _workflow_plan_public_item(self, plan: Dict[str, Any], *, include_actions: bool = False) -> Dict[str, Any]: current = dict(plan or {}) actions = current.get("actions") or [] item = { "plan_id": self._clean_text(current.get("plan_id")), "workflow": self._clean_text(current.get("workflow")), "session": self._clean_text(current.get("session")), "session_id": self._clean_text(current.get("session_id")), "created_at": self._safe_int(current.get("created_at"), 0), "created_at_text": self._clean_text(current.get("created_at_text")), "executed": bool(current.get("executed")), "executed_at": self._safe_int(current.get("executed_at"), 0), "executed_at_text": self._clean_text(current.get("executed_at_text")), "last_success": current.get("last_success"), "last_message": self._clean_text(current.get("last_message")), "action_count": len(actions) if isinstance(actions, list) else 0, } if include_actions: item["actions"] = [dict(action or {}) for action in actions] if isinstance(actions, list) else [] item["execute_body"] = dict(current.get("execute_body") or {}) return item def _session_workflow_plan_public_data(self, *, session: str = "", session_id: str = "") -> Dict[str, Any]: pending = self._find_workflow_plan(session=session, session_id=session_id, executed=False) latest = pending or self._find_workflow_plan(session=session, session_id=session_id, executed=None) if not latest: return { "has_plan": False, "has_pending": False, "latest": None, } return { "has_plan": True, "has_pending": bool(pending), "latest": self._workflow_plan_public_item(latest, include_actions=False), } def _assistant_plans_public_data( self, *, session: str = "", session_id: str = "", executed: Optional[bool] = None, include_actions: bool = False, limit: int = 20, ) -> Dict[str, Any]: max_limit = min(max(1, self._safe_int(limit, 20)), 100) session_filter = "" session_id_filter = "" if self._clean_text(session) or self._clean_text(session_id): session_filter, session_id_filter = self._normalize_assistant_session_ref( session=session, session_id=session_id, ) plans = sorted( (dict(item) for item in (self._workflow_plans or {}).values() if isinstance(item, dict)), key=lambda item: self._safe_int(item.get("created_at"), 0), reverse=True, ) items: List[Dict[str, Any]] = [] matching_total = 0 for plan in plans: if session_id_filter and self._clean_text(plan.get("session_id")) != session_id_filter: continue if executed is not None and bool(plan.get("executed")) != bool(executed): continue matching_total += 1 if len(items) < max_limit: items.append(self._workflow_plan_public_item(plan, include_actions=include_actions)) return { "total": matching_total, "total_matching": matching_total, "total_all": len(self._workflow_plans or {}), "limit": max_limit, "session": session_filter, "session_id": session_id_filter, "executed": executed, "include_actions": bool(include_actions), "items": items, } def _format_assistant_plans_text( self, *, session: str = "", session_id: str = "", executed: Optional[bool] = None, include_actions: bool = False, limit: int = 20, ) -> str: data = self._assistant_plans_public_data( session=session, session_id=session_id, executed=executed, include_actions=include_actions, limit=limit, ) items = data.get("items") or [] if not items: return "当前没有 Agent影视助手 保存计划。" lines = [f"已保存计划:{len(items)} 条"] for index, item in enumerate(items, 1): status = "已执行" if item.get("executed") else "待执行" line = ( f"{index}. {item.get('plan_id') or '-'} | {status} | " f"{item.get('workflow') or '-'} | {item.get('session') or '-'} | " f"{item.get('action_count') or 0}步 | {item.get('created_at_text') or '-'}" ) if item.get("last_message"): line = f"{line} | {item.get('last_message')}" lines.append(line) lines.append("下一步:可用 agent_resource_officer_execute_plan 执行 plan_id,或用 agent_resource_officer_plans_clear 清理。") return "\n".join(lines) def _clear_workflow_plans( self, *, plan_id: str = "", session: str = "", session_id: str = "", executed: Optional[bool] = None, all_plans: bool = False, limit: int = 100, ) -> Dict[str, Any]: clean_plan_id = self._clean_text(plan_id) max_limit = min(max(1, self._safe_int(limit, 100)), 500) session_filter = "" session_id_filter = "" if self._clean_text(session) or self._clean_text(session_id): session_filter, session_id_filter = self._normalize_assistant_session_ref( session=session, session_id=session_id, ) if not any([clean_plan_id, session_id_filter, executed is not None, all_plans]): return { "ok": False, "message": "请指定 plan_id、session/session_id、executed 过滤条件,或显式 all_plans=true", "removed": 0, "removed_ids": [], } removed_ids: List[str] = [] for current_id, plan in list((self._workflow_plans or {}).items()): if len(removed_ids) >= max_limit: break current = dict(plan or {}) if clean_plan_id and self._clean_text(current_id) != clean_plan_id: continue if session_id_filter and self._clean_text(current.get("session_id")) != session_id_filter: continue if executed is not None and bool(current.get("executed")) != bool(executed): continue removed_ids.append(self._clean_text(current_id)) for current_id in removed_ids: self._workflow_plans.pop(current_id, None) if removed_ids: self._persist_workflow_plans() return { "ok": True, "message": f"已清理 {len(removed_ids)} 条计划", "removed": len(removed_ids), "removed_ids": removed_ids, "session": session_filter, "session_id": session_id_filter, "executed": executed, "all_plans": bool(all_plans), } def _record_assistant_execution( self, *, action: str, session: str = "default", session_id: str = "", success: bool = False, message: str = "", summary: Optional[Dict[str, Any]] = None, ) -> None: session_name, normalized_session_id = self._normalize_assistant_session_ref( session=session, session_id=session_id, ) entry = { "id": self._new_session_id("exec"), "time": int(time.time()), "time_text": self._format_unix_time(int(time.time())), "action": self._clean_text(action), "session": session_name, "session_id": normalized_session_id, "success": bool(success), "message_head": self._assistant_result_message_head(message), "summary": dict(summary or {}), } self._execution_history.append(entry) self._execution_history = self._execution_history[-self._execution_history_limit:] self._persist_execution_history() def _assistant_history_public_data( self, *, session: str = "", session_id: str = "", limit: int = 20, ) -> Dict[str, Any]: max_limit = min(max(1, self._safe_int(limit, 20)), 100) session_filter = "" session_id_filter = "" if self._clean_text(session) or self._clean_text(session_id): session_filter, session_id_filter = self._normalize_assistant_session_ref( session=session, session_id=session_id, ) items: List[Dict[str, Any]] = [] for entry in reversed(self._execution_history or []): current = dict(entry or {}) if session_id_filter and self._clean_text(current.get("session_id")) != session_id_filter: continue items.append(current) if len(items) >= max_limit: break return { "total": len(self._execution_history or []), "limit": max_limit, "session": session_filter, "session_id": session_id_filter, "items": items, } def _format_assistant_history_text( self, *, session: str = "", session_id: str = "", limit: int = 20, ) -> str: data = self._assistant_history_public_data(session=session, session_id=session_id, limit=limit) items = data.get("items") or [] if not items: return "当前没有 Agent影视助手 执行历史。" lines = [f"最近执行历史:{len(items)} 条"] for index, item in enumerate(items, 1): status = "成功" if item.get("success") else "失败" line = f"{index}. {item.get('time_text') or '-'} | {status} | {item.get('action') or '-'} | {item.get('session') or '-'}" if item.get("message_head"): line = f"{line} | {item.get('message_head')}" lines.append(line) return "\n".join(lines) def _is_session_expired(self, payload: Optional[Dict[str, Any]]) -> bool: session = dict(payload or {}) updated_at = self._safe_int(session.get("updated_at"), 0) if updated_at <= 0: return False return (int(time.time()) - updated_at) > self._session_retention_seconds @staticmethod def _format_unix_time(value: Any) -> str: try: timestamp = int(value) except Exception: return "" if timestamp <= 0: return "" try: return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(timestamp)) except Exception: return "" @staticmethod def _group_resource_preview(items: List[Dict[str, Any]], per_group: Optional[int] = 6) -> List[Dict[str, Any]]: groups: Dict[str, List[Dict[str, Any]]] = {"115": [], "quark": [], "other": []} for item in items: pan = str(item.get("pan_type") or "").lower() if pan == "115": key = "115" elif pan == "quark": key = "quark" else: key = "other" if per_group is None or len(groups[key]) < per_group: groups[key].append(item) ordered = groups["115"] + groups["quark"] if not ordered: ordered = groups["other"] preview: List[Dict[str, Any]] = [] for index, item in enumerate(ordered, 1): row = dict(item) row["pick_index"] = index preview.append(row) return preview def _assistant_session_id(self, session: str) -> str: session = self._clean_text(session) or "default" return f"assistant::{session}" def _assistant_session_name_from_id(self, session_id: str) -> str: clean_session_id = self._clean_text(session_id) if clean_session_id.startswith("assistant::"): return clean_session_id.split("assistant::", 1)[1] or "default" return clean_session_id or "default" def _normalize_assistant_session_ref( self, *, session: Any = None, session_id: Any = None, fallback: str = "default", ) -> Tuple[str, str]: clean_session_id = self._clean_text(session_id) if clean_session_id: session_name = self._assistant_session_name_from_id(clean_session_id) return session_name, self._assistant_session_id(session_name) session_name = self._clean_text(session) or fallback return session_name, self._assistant_session_id(session_name) def _p115_status_snapshot(self) -> Dict[str, Any]: health_ok, result, health_message = self._ensure_p115_service().health() return { "ready": health_ok, "message": health_message or result.get("message") or "", "direct_source": self._clean_text(result.get("direct_source")), "helper_ready": bool(result.get("helper_ready")), "client_type": self._p115_client_type, "default_target_path": self._p115_default_path, "cookie_mode": self._clean_text((result.get("cookie_state") or {}).get("mode")), } def _format_p115_next_actions(self, status: Optional[Dict[str, Any]] = None) -> str: current = dict(status or self._p115_status_snapshot()) final_path = current.get("default_target_path") or self._p115_default_path if current.get("ready"): lines = [ "下一步建议:", f"1. 直接发:链接 https://115cdn.com/s/xxxx path={final_path}", "2. 也可以直接贴 115 链接,不写前缀也能识别", "3. 搜资源可发:影巢搜索 片名", "4. 外部搜资源可发:盘搜搜索 片名", "5. 想复查登录状态可发:115状态", ] else: lines = [ "下一步建议:", "1. 回复:115登录", "2. 扫码确认后回复:检查115登录", "3. 登录完成后可回复:115状态", f"4. 然后可直接发 115 链接转存到 {final_path}", "5. 也可以继续发:影巢搜索 片名", ] return "\n".join(lines) def _format_p115_transfer_failure( self, *, detail: str = "", target_path: str = "", title: str = "115 转存失败", ) -> str: status = self._p115_status_snapshot() final_path = target_path or status.get("default_target_path") or self._p115_default_path clean_detail = self._clean_text(detail) status_message = self._clean_text(status.get("message")) lines = [title] if clean_detail: lines.append(f"原因:{clean_detail}") if final_path: lines.append(f"目标目录:{final_path}") lines.append(f"当前状态:{'可用' if status.get('ready') else '待登录/待修复'}") if status_message and status_message.lower() not in {"success", "ok"} and status_message != clean_detail: lines.append(f"状态详情:{status_message}") if status.get("ready"): lines.append("建议:先回复 115状态 检查当前会话;如果还是失败,再回复 115登录 重新扫码。") else: lines.append("建议:先回复 115登录,扫码成功后再重试当前操作。") return "\n".join(lines) @staticmethod def _format_p115_resume_hint(title: str = "") -> str: clean_title = str(title or "").strip() prefix = f"已记住这次 115 任务({clean_title})。" if clean_title else "已记住这次 115 任务。" return f"{prefix}\n登录成功后回复:检查115登录,我会自动继续处理。" def _save_pending_p115_share( self, session_id: str, *, share_url: str, access_code: str = "", target_path: str = "", source: str = "", title: str = "", last_error: str = "", ) -> None: clean_url = self._clean_text(share_url) if not clean_url: return state = self._load_session(session_id) or {} previous = dict(state.get("pending_p115") or {}) now = int(time.time()) state["pending_p115"] = { "kind": "share_route", "share_url": clean_url, "access_code": self._clean_text(access_code), "target_path": target_path or self._p115_default_path, "source": self._clean_text(source), "title": self._clean_text(title), "created_at": self._safe_int(previous.get("created_at"), now) or now, "last_attempt_at": now, "retry_count": max(0, self._safe_int(previous.get("retry_count"), 0)), "last_error": self._clean_text(last_error) or self._clean_text(previous.get("last_error")), } if not state.get("kind"): state["kind"] = "assistant_p115_pending" state["stage"] = "pending_login" self._save_session(session_id, state) def _clear_pending_p115_share(self, session_id: str) -> None: state = self._load_session(session_id) if not state or "pending_p115" not in state: return state.pop("pending_p115", None) self._save_session(session_id, state) def _pending_p115_summary(self, state: Optional[Dict[str, Any]]) -> str: pending = dict((state or {}).get("pending_p115") or {}) share_url = self._clean_text(pending.get("share_url")) if not share_url: return "" title = self._clean_text(pending.get("title")) or "未命名任务" target_path = self._clean_text(pending.get("target_path")) or self._p115_default_path source = self._clean_text(pending.get("source")) or "unknown" created_at = self._format_unix_time(pending.get("created_at")) last_attempt_at = self._format_unix_time(pending.get("last_attempt_at")) retry_count = max(0, self._safe_int(pending.get("retry_count"), 0)) last_error = self._clean_text(pending.get("last_error")) lines = [ "待继续的 115 任务:", f"资源:{title}", f"目录:{target_path}", f"来源:{source}", ] if created_at: lines.append(f"首次记录:{created_at}") if last_attempt_at: lines.append(f"最近尝试:{last_attempt_at}") if retry_count: lines.append(f"重试次数:{retry_count}") if last_error: lines.append(f"最近错误:{last_error}") lines.append("可用命令:继续115任务 / 取消115任务") return "\n".join(lines) def _pending_p115_public_data(self, state: Optional[Dict[str, Any]]) -> Dict[str, Any]: pending = dict((state or {}).get("pending_p115") or {}) if not self._clean_text(pending.get("share_url")): return {"has_pending": False} return { "has_pending": True, "title": self._clean_text(pending.get("title")) or "未命名任务", "target_path": self._clean_text(pending.get("target_path")) or self._p115_default_path, "source": self._clean_text(pending.get("source")) or "unknown", "created_at": self._safe_int(pending.get("created_at"), 0), "created_at_text": self._format_unix_time(pending.get("created_at")), "last_attempt_at": self._safe_int(pending.get("last_attempt_at"), 0), "last_attempt_at_text": self._format_unix_time(pending.get("last_attempt_at")), "retry_count": max(0, self._safe_int(pending.get("retry_count"), 0)), "last_error": self._clean_text(pending.get("last_error")), } @staticmethod def _assistant_find_action_template( templates: Optional[List[Dict[str, Any]]], names: List[str], ) -> Optional[Dict[str, Any]]: rows = [dict(item or {}) for item in (templates or []) if isinstance(item, dict)] for current_name in names: for item in rows: if str(item.get("name") or "").strip() == current_name: return item return None def _assistant_recovery_public_data( self, *, session_state: Optional[Dict[str, Any]] = None, action_templates: Optional[List[Dict[str, Any]]] = None, ) -> Dict[str, Any]: state = dict(session_state or {}) templates = [dict(item or {}) for item in (action_templates or state.get("action_templates") or []) if isinstance(item, dict)] saved_plan = dict(state.get("saved_plan") or {}) latest_plan = dict(saved_plan.get("latest") or {}) latest_plan_id = self._clean_text(latest_plan.get("plan_id") or saved_plan.get("plan_id")) latest_plan_executed = bool(latest_plan.get("executed")) pending_p115 = dict(state.get("pending_p115") or {}) has_session = bool(state.get("has_session")) kind = self._clean_text(state.get("kind")) stage = self._clean_text(state.get("stage")) session_name = self._clean_text(state.get("session")) or self._assistant_session_name_from_id(self._clean_text(state.get("session_id"))) session_id = self._clean_text(state.get("session_id")) mode = "" reason = "" template: Optional[Dict[str, Any]] = None if saved_plan.get("has_pending"): mode = "resume_saved_plan" reason = "当前会话存在待执行计划" template = self._assistant_find_action_template(templates, [ "execute_latest_plan", "execute_plan", "execute_session_latest_plan", ]) elif latest_plan_executed and latest_plan_id: mode = "followup_executed_plan" reason = "当前会话最近一条计划已执行,建议先做统一后续追踪" template = self._assistant_action_template( name="query_execution_followup", description="按最近已执行计划自动追踪下载、订阅或入库后续状态", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={ **({"session": session_name} if session_name else {}), **({"session_id": session_id} if session_id else {}), "plan_id": latest_plan_id, }, ) elif pending_p115.get("has_pending"): mode = "resume_pending_115" reason = "当前会话存在待继续的 115 任务" template = self._assistant_find_action_template(templates, [ "resume_pending_115", "check_115_login", ]) elif has_session and kind == "assistant_pansou": mode = "continue_pansou" reason = "当前会话停留在盘搜结果列表" template = self._assistant_find_action_template(templates, ["pick_pansou_result"]) elif has_session and kind == "assistant_mp_candidate": mode = "continue_mp_candidate" reason = "当前会话停留在 MP 媒体候选列表" template = self._assistant_find_action_template(templates, [ "query_mp_search_result_detail", "pick_mp_download", ]) elif has_session and kind == "assistant_mp": mode = "continue_mp_search" reason = "当前会话停留在 MP 原生搜索结果列表" template = self._assistant_find_action_template(templates, [ "query_mp_best_result_detail", "query_mp_search_result_detail", "pick_mp_download", ]) elif has_session and kind == "assistant_mp_recommend": mode = "continue_mp_recommend" reason = "当前会话停留在 MP 热门推荐列表" template = self._assistant_find_action_template(templates, [ "pick_recommend_smart_decision", "pick_recommend_smart_plan", "pick_recommend_mp_search", ]) elif has_session and kind == "assistant_mp_download_tasks": mode = "continue_mp_download_tasks" reason = "当前会话停留在 MP 下载任务列表" template = self._assistant_find_action_template(templates, [ "query_mp_download_history", "pause_mp_download", "resume_mp_download", "delete_mp_download", ]) elif has_session and kind == "assistant_mp_download_history": mode = "continue_mp_download_history" reason = "当前会话停留在 MP 下载历史" template = self._assistant_find_action_template(templates, [ "query_mp_lifecycle_status", "start_mp_media_search", ]) elif has_session and kind == "assistant_mp_downloaders": mode = "continue_mp_downloaders" reason = "当前会话停留在 MP 下载器状态" template = self._assistant_find_action_template(templates, [ "query_mp_sites", "start_mp_media_search", ]) elif has_session and kind == "assistant_mp_sites": mode = "continue_mp_sites" reason = "当前会话停留在 MP 站点状态" template = self._assistant_find_action_template(templates, [ "query_mp_downloaders", "start_mp_media_search", ]) elif has_session and kind == "assistant_mp_subscribes": mode = "continue_mp_subscribes" reason = "当前会话停留在 MP 订阅列表" template = self._assistant_find_action_template(templates, [ "start_mp_subscribe", "search_mp_subscribe", "start_mp_media_search", ]) elif has_session and kind == "assistant_mp_lifecycle_status": mode = "continue_mp_lifecycle_status" reason = "当前会话停留在 MP 生命周期追踪" template = self._assistant_find_action_template(templates, [ "query_mp_download_history", "start_mp_media_search", ]) elif has_session and kind == "assistant_hdhive" and stage == "candidate": mode = "continue_hdhive_candidate" reason = "当前会话停留在影巢候选列表" template = self._assistant_find_action_template(templates, ["pick_hdhive_candidate"]) elif has_session and kind == "assistant_hdhive" and stage == "resource": mode = "continue_hdhive_resource" reason = "当前会话停留在影巢资源列表" template = self._assistant_find_action_template(templates, ["pick_hdhive_resource"]) elif has_session and kind == "assistant_p115_login": mode = "continue_115_login" reason = "当前会话停留在 115 登录检查阶段" template = self._assistant_find_action_template(templates, ["check_115_login", "show_115_status"]) elif self._assistant_find_action_template(templates, ["preferences_save"]): mode = "onboard_preferences" reason = "智能体片源偏好未初始化,建议先询问并保存用户偏好" template = self._assistant_find_action_template(templates, ["preferences_save"]) else: mode = "start_new" reason = "当前没有待恢复的执行状态,可直接开始新任务" template = self._assistant_find_action_template(templates, [ "start_pansou_search", "start_hdhive_search", "start_115_login", ]) can_resume = mode != "start_new" and bool(template) return { "mode": mode, "reason": reason, "can_resume": can_resume, "recommended_action": self._clean_text((template or {}).get("name")), "recommended_tool": self._clean_text((template or {}).get("tool")), "action_template": template or None, "alternatives": [ self._clean_text(item.get("name")) for item in templates[:6] if self._clean_text(item.get("name")) ], } def _assistant_session_public_data(self, session: str = "default") -> Dict[str, Any]: session_name = self._clean_text(session) or "default" session_id = self._assistant_session_id(session_name) saved_plan = self._session_workflow_plan_public_data(session=session_name, session_id=session_id) state = self._load_session(session_id) or {} if not state: payload = { "has_session": False, "session": session_name, "session_id": session_id, "saved_plan": saved_plan, "suggested_actions": ["execute_plan.session", "smart_entry"] if saved_plan.get("has_pending") else ["smart_entry"], } payload["action_templates"] = self._assistant_action_templates(payload) payload["recovery"] = self._assistant_recovery_public_data(session_state=payload) return payload kind = self._clean_text(state.get("kind")) stage = self._clean_text(state.get("stage")) target_path = self._clean_text(state.get("target_path")) payload: Dict[str, Any] = { "has_session": True, "session": session_name, "session_id": session_id, "kind": kind, "stage": stage, "updated_at": self._safe_int(state.get("updated_at"), 0), "updated_at_text": self._format_unix_time(state.get("updated_at")), "target_path": target_path, "keyword": self._clean_text(state.get("keyword")), "media_type": self._clean_text(state.get("media_type")), "year": self._clean_text(state.get("year")), "pending_p115": self._pending_p115_public_data(state), "saved_plan": saved_plan, "suggested_actions": [], } if kind == "assistant_pansou": items = state.get("items") or [] payload.update({ "result_count": len(items), "recommend_handoff": self._assistant_recommend_handoff_public_data(state), "items_preview": [ { "index": self._safe_int(item.get("index"), idx + 1), "channel": self._clean_text(item.get("channel")), "title": self._clean_text(item.get("note")), "source": self._clean_text(item.get("source")), } for idx, item in enumerate(items[:6]) if isinstance(item, dict) ], "score_summary": self._score_summary(items, limit=5), "suggested_actions": ["smart_pick.choice", "session_clear"], }) if payload.get("recommend_handoff"): payload["suggested_actions"] = [ "smart_entry.text=回推荐", "smart_entry.text=决策", "smart_entry.text=详情", "smart_entry.text=计划", "smart_entry.text=确认", "smart_entry.text=影巢", "smart_entry.text=原生", *list(payload.get("suggested_actions") or []), ] elif kind == "assistant_mp": items = state.get("items") or [] payload.update({ "result_count": len(items), "recommend_handoff": self._assistant_recommend_handoff_public_data(state), "items_preview": [ { "index": self._safe_int(item.get("index"), idx + 1), "title": self._clean_text(((item.get("torrent_info") or {}).get("title")) or item.get("title")), "site": self._clean_text((item.get("torrent_info") or {}).get("site_name")), "seeders": (item.get("torrent_info") or {}).get("seeders"), "volume_factor": self._clean_text((item.get("torrent_info") or {}).get("volume_factor")), "score": (item.get("score") or {}).get("score") if isinstance(item.get("score"), dict) else None, "score_level": (item.get("score") or {}).get("score_level") if isinstance(item.get("score"), dict) else "", "recommended_action": (item.get("score") or {}).get("recommended_action") if isinstance(item.get("score"), dict) else "", "risk_reasons": (item.get("score") or {}).get("risk_reasons", [])[:2] if isinstance(item.get("score"), dict) else [], } for idx, item in enumerate(items[:8]) if isinstance(item, dict) ], "score_summary": self._score_summary(items, limit=5), "suggested_actions": ["mp_download.choice", "mp_subscribe.keyword", "session_clear"], }) if payload.get("recommend_handoff"): payload["suggested_actions"] = [ "smart_entry.text=回推荐", "smart_entry.text=决策", "smart_entry.text=详情", "smart_entry.text=计划", "smart_entry.text=确认", "smart_entry.text=盘搜", "smart_entry.text=影巢", *list(payload.get("suggested_actions") or []), ] elif kind == "assistant_mp_candidate": candidates = state.get("candidates") or [] payload.update({ "stage": "candidate", "candidate_count": len(candidates), "page": self._safe_int(state.get("page"), 1), "page_size": self._safe_int(state.get("page_size"), self._hdhive_candidate_page_size), "candidates_preview": [ { "index": idx + 1, "title": self._clean_text(item.get("title")), "year": self._clean_text(item.get("year")), "media_type": self._clean_text(item.get("media_type") or item.get("type")), "tmdb_id": item.get("tmdb_id"), } for idx, item in enumerate(candidates[:10]) if isinstance(item, dict) ], "suggested_actions": ["smart_pick.choice", "smart_pick.action=详情", "smart_pick.action=下一页", "session_clear"], }) elif kind == "assistant_mp_download_tasks": items = state.get("items") or [] payload.update({ "result_count": len(items), "items_preview": [ { "index": self._safe_int(item.get("index"), idx + 1), "title": self._clean_text(item.get("title")), "hash_short": self._clean_text(item.get("hash_short")), "downloader": self._clean_text(item.get("downloader")), "progress": self._clean_text(item.get("progress")), "state": self._clean_text(item.get("state")), } for idx, item in enumerate(items[:8]) if isinstance(item, dict) ], "suggested_actions": ( ["mp_download_control.pause", "mp_download_control.resume", "mp_download_control.delete", "session_clear"] if items else ["mp_media_search", "mp_download_history", "session_clear"] ), }) elif kind == "assistant_mp_download_history": items = state.get("items") or [] payload.update({ "result_count": len(items), "items_preview": [ { "index": self._safe_int(item.get("index"), idx + 1), "title": self._clean_text(item.get("title")), "year": self._clean_text(item.get("year")), "date": self._clean_text(item.get("date")), "transfer_status_text": self._clean_text(item.get("transfer_status_text")), "download_hash_short": self._clean_text(item.get("download_hash_short")), } for idx, item in enumerate(items[:8]) if isinstance(item, dict) ], "suggested_actions": ["mp_lifecycle_status", "mp_media_search", "session_clear"], }) elif kind == "assistant_mp_downloaders": items = state.get("items") or [] payload.update({ "enabled_count": self._safe_int(state.get("enabled_count"), 0), "result_count": len(items), "items_preview": [ { "name": self._clean_text(item.get("name")), "type": self._clean_text(item.get("type")), "enabled": bool(item.get("enabled")), "default": bool(item.get("default")), } for item in items[:8] if isinstance(item, dict) ], "suggested_actions": ["mp_sites", "mp_media_search", "session_clear"], }) elif kind == "assistant_mp_sites": items = state.get("items") or [] payload.update({ "status": self._clean_text(state.get("status")), "result_count": len(items), "items_preview": [ { "index": self._safe_int(item.get("index"), idx + 1), "name": self._clean_text(item.get("name")), "domain": self._clean_text(item.get("domain")), "enabled": bool(item.get("enabled")), "has_cookie": bool(item.get("has_cookie")), "priority": item.get("priority"), } for idx, item in enumerate(items[:8]) if isinstance(item, dict) ], "suggested_actions": ["mp_downloaders", "mp_media_search", "session_clear"], }) elif kind == "assistant_mp_subscribes": items = state.get("items") or [] payload.update({ "result_count": len(items), "items_preview": [ { "index": self._safe_int(item.get("index"), idx + 1), "id": self._safe_int(item.get("id"), 0), "title": self._clean_text(item.get("name")), "year": self._clean_text(item.get("year")), "state": self._clean_text(item.get("state")), "lack_episode": item.get("lack_episode"), } for idx, item in enumerate(items[:8]) if isinstance(item, dict) ], "suggested_actions": ( ["mp_subscribe_control.search", "mp_subscribe_control.pause", "mp_subscribe_control.resume", "mp_subscribe_control.delete", "session_clear"] if items else ["mp_subscribe.keyword", "mp_media_search", "session_clear"] ), }) elif kind == "assistant_mp_lifecycle_status": result_groups = state.get("items") if isinstance(state.get("items"), dict) else {} task_items = result_groups.get("download_tasks") if isinstance(result_groups.get("download_tasks"), list) else [] download_items = result_groups.get("download_history") if isinstance(result_groups.get("download_history"), list) else [] transfer_items = result_groups.get("transfer_history") if isinstance(result_groups.get("transfer_history"), list) else [] payload.update({ "download_task_count": len(task_items), "download_history_count": len(download_items), "transfer_history_count": len(transfer_items), "items_preview": [ { "kind": "download_task", "title": self._clean_text(item.get("title")), "progress": self._clean_text(item.get("progress")), "state": self._clean_text(item.get("state")), } for item in task_items[:3] if isinstance(item, dict) ] + [ { "kind": "download_history", "title": self._clean_text(item.get("title")), "date": self._clean_text(item.get("date")), "transfer_status_text": self._clean_text(item.get("transfer_status_text")), } for item in download_items[:3] if isinstance(item, dict) ] + [ { "kind": "transfer_history", "title": self._clean_text(item.get("title")), "date": self._clean_text(item.get("date")), "status_text": self._clean_text(item.get("status_text")), } for item in transfer_items[:3] if isinstance(item, dict) ], "suggested_actions": ["mp_media_search", "mp_download_history", "session_clear"], }) elif kind == "assistant_mp_recommend": items = state.get("items") or [] selected_index = self._safe_int(state.get("selected_index"), 0) payload.update({ "source": self._clean_text(state.get("source")), "result_count": len(items), "selected_index": selected_index if selected_index > 0 else None, "items_preview": [ { "index": self._safe_int(item.get("index"), idx + 1), "title": self._clean_text(item.get("title")), "year": self._clean_text(item.get("year")), "type": self._clean_text(item.get("type")), "tmdb_id": self._clean_text(item.get("tmdb_id")), "douban_id": self._clean_text(item.get("douban_id")), "vote_average": item.get("vote_average"), } for idx, item in enumerate(items[:10]) if isinstance(item, dict) ], "suggested_actions": [ "smart_pick.choice", "smart_entry.text=电影", "smart_entry.text=电视剧", "smart_entry.text=豆瓣", "smart_entry.text=热映", "smart_entry.text=番剧", "smart_pick.choice mode=hdhive", "smart_pick.choice mode=pansou", "session_clear", ], }) if selected_index > 0: payload["selected_item"] = dict(state.get("selected_item") or {}) payload["suggested_actions"] = [ *list(payload.get("suggested_actions") or []), "smart_entry.text=详情", "smart_entry.text=决策", "smart_entry.text=计划", "smart_entry.text=确认", "smart_entry.text=原生", "smart_entry.text=影巢", "smart_entry.text=盘搜", ] elif kind == "assistant_update_check": items = state.get("items") or [] payload.update({ "result_count": len(items), "items_preview": [ { "index": self._safe_int(item.get("_index") or item.get("index"), idx + 1), "source_type": self._clean_text(item.get("_update_source")), "title": self._clean_text(item.get("note") or item.get("title") or item.get("remark")), "provider": self._clean_text(item.get("channel") or item.get("pan_type")), } for idx, item in enumerate(items[:8]) if isinstance(item, dict) ], "suggested_actions": ["smart_pick.choice", "smart_pick.action=详情", "smart_pick.action=计划", "session_clear"], }) elif kind == "assistant_hdhive": payload["recommend_handoff"] = self._assistant_recommend_handoff_public_data(state) if stage == "candidate": candidates = state.get("candidates") or [] current_page = max(1, self._safe_int(state.get("page"), 1)) page_size = max(1, self._safe_int(state.get("page_size"), self._hdhive_candidate_page_size)) total_pages = max(1, (len(candidates) + page_size - 1) // page_size) if candidates else 1 start = (current_page - 1) * page_size end = start + page_size payload.update({ "page": current_page, "page_size": page_size, "total_candidates": len(candidates), "total_pages": total_pages, "candidates_preview": [ { "index": start + idx + 1, "tmdb_id": self._clean_text(item.get("tmdb_id")), "title": self._clean_text(item.get("title")), "year": self._clean_text(item.get("year")), "media_type": self._clean_text(item.get("media_type")), "actors": item.get("actors") or [], } for idx, item in enumerate(candidates[start:end]) if isinstance(item, dict) ], "suggested_actions": ["smart_pick.choice", "smart_pick.action=详情", "smart_pick.action=下一页", "session_clear"], }) if payload.get("recommend_handoff"): payload["suggested_actions"] = [ "smart_entry.text=回推荐", "smart_entry.text=决策", "smart_entry.text=盘搜", "smart_entry.text=原生", *list(payload.get("suggested_actions") or []), ] elif stage == "resource": resources = state.get("resources") or [] selected_candidate = dict(state.get("selected_candidate") or {}) current_page = max(1, self._safe_int(state.get("page"), 1)) page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 start = (current_page - 1) * page_size end = start + page_size payload.update({ "selected_candidate": { "tmdb_id": self._clean_text(selected_candidate.get("tmdb_id")), "title": self._clean_text(selected_candidate.get("title")), "year": self._clean_text(selected_candidate.get("year")), "media_type": self._clean_text(selected_candidate.get("media_type")), "actors": selected_candidate.get("actors") or [], }, "page": current_page, "page_size": page_size, "total_resources": len(resources), "total_pages": total_pages, "resource_count_115": len([x for x in resources if str((x or {}).get("pan_type") or "").lower() == "115"]), "resource_count_quark": len([x for x in resources if str((x or {}).get("pan_type") or "").lower() == "quark"]), "resources_preview": [ { "index": self._safe_int(item.get("pick_index"), idx + 1), "provider": self._clean_text(item.get("pan_type")), "title": self._clean_text(item.get("title") or item.get("matched_title")), "points": item.get("unlock_points"), "points_text": self._resource_points_text(item), "quality": self._clean_text(self._list_text(item.get("video_resolution")) or item.get("quality")), "source": self._clean_text(self._list_text(item.get("source"))), "size": self._clean_text(item.get("share_size") or item.get("size")), "episodes": self._resource_episode_text(item), "subtitle": self._resource_subtitle_text(item), "remark": self._resource_remark_text(item), "score": (item.get("score") or {}).get("score") if isinstance(item.get("score"), dict) else None, "score_level": (item.get("score") or {}).get("score_level") if isinstance(item.get("score"), dict) else "", "recommended_action": (item.get("score") or {}).get("recommended_action") if isinstance(item.get("score"), dict) else "", "risk_reasons": (item.get("score") or {}).get("risk_reasons", [])[:2] if isinstance(item.get("score"), dict) else [], } for idx, item in enumerate(resources[start:end], start=start + 1) if isinstance(item, dict) ], "score_summary": self._score_summary(resources, limit=5), "suggested_actions": ["smart_pick.choice", "smart_pick.action=下一页", "session_clear"], }) if payload.get("recommend_handoff"): payload["suggested_actions"] = [ "smart_entry.text=回推荐", "smart_entry.text=决策", "smart_entry.text=盘搜", "smart_entry.text=原生", *list(payload.get("suggested_actions") or []), ] elif kind == "assistant_p115_login": payload.update({ "client_type": self._clean_text(state.get("client_type")) or self._p115_client_type, "has_qrcode_session": bool(self._clean_text(state.get("uid")) and self._clean_text(state.get("time")) and self._clean_text(state.get("sign"))), "suggested_actions": ["smart_entry.text=检查115登录", "p115_status", "session_clear"], }) else: payload["suggested_actions"] = ["smart_entry", "session_clear"] if saved_plan.get("has_pending"): payload["suggested_actions"] = ["execute_plan.session", *list(payload.get("suggested_actions") or [])] payload["action_templates"] = self._assistant_action_templates(payload) payload["recovery"] = self._assistant_recovery_public_data(session_state=payload) return payload def _assistant_session_brief_public_data(self, session_id: str, state: Optional[Dict[str, Any]]) -> Dict[str, Any]: payload = dict(state or {}) name = str(session_id or "") session_name = name.split("assistant::", 1)[1] if name.startswith("assistant::") else name or "default" saved_plan = self._session_workflow_plan_public_data(session=session_name, session_id=name) result: Dict[str, Any] = { "session": session_name, "session_id": name or self._assistant_session_id(session_name), "kind": self._clean_text(payload.get("kind")), "stage": self._clean_text(payload.get("stage")), "updated_at": self._safe_int(payload.get("updated_at"), 0), "updated_at_text": self._format_unix_time(payload.get("updated_at")), "keyword": self._clean_text(payload.get("keyword")), "target_path": self._clean_text(payload.get("target_path")), "has_pending_p115": bool(self._clean_text(((payload.get("pending_p115") or {}).get("share_url")))), "has_saved_plan": bool(saved_plan.get("has_plan")), "has_pending_plan": bool(saved_plan.get("has_pending")), "saved_plan": saved_plan.get("latest"), } if result["kind"] == "assistant_pansou": result["result_count"] = len(payload.get("items") or []) elif result["kind"] == "assistant_mp_recommend": result["result_count"] = len(payload.get("items") or []) result["source"] = self._clean_text(payload.get("source")) elif result["kind"] == "assistant_hdhive": if result["stage"] == "candidate": candidates = payload.get("candidates") or [] page_size = max(1, self._safe_int(payload.get("page_size"), self._hdhive_candidate_page_size)) current_page = max(1, self._safe_int(payload.get("page"), 1)) total_pages = max(1, (len(candidates) + page_size - 1) // page_size) if candidates else 1 result["total_candidates"] = len(candidates) result["page"] = current_page result["total_pages"] = total_pages elif result["stage"] == "resource": selected = dict(payload.get("selected_candidate") or {}) resources = payload.get("resources") or [] page_size = max(1, self._safe_int(payload.get("page_size"), 10)) current_page = max(1, self._safe_int(payload.get("page"), 1)) total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 result["selected_title"] = self._clean_text(selected.get("title")) result["selected_year"] = self._clean_text(selected.get("year")) result["total_resources"] = len(resources) result["page"] = current_page result["total_pages"] = total_pages elif result["kind"] == "assistant_p115_login": result["client_type"] = self._clean_text(payload.get("client_type")) or self._p115_client_type result["recovery"] = self._assistant_recovery_public_data( session_state={ **result, "pending_p115": self._pending_p115_public_data(payload), "saved_plan": saved_plan, }, action_templates=[ self._assistant_action_template( name="execute_session_latest_plan", description="按 session_id 执行该会话最近一条待执行计划", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", tool="agent_resource_officer_execute_plan", body={"session_id": result.get("session_id"), "prefer_unexecuted": True}, ), self._assistant_action_template( name="inspect_session", description="查看某个会话的详细状态", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/session", tool="agent_resource_officer_session_state", body={"session_id": result.get("session_id")}, ), ], ) return result def _assistant_plan_only_session_brief_public_data(self, session_id: str) -> Dict[str, Any]: session_name = self._assistant_session_name_from_id(session_id) saved_plan = self._session_workflow_plan_public_data(session=session_name, session_id=session_id) latest = dict(saved_plan.get("latest") or {}) latest_plan_id = self._clean_text(latest.get("plan_id")) latest_executed = bool(latest.get("executed")) return { "session": session_name, "session_id": self._assistant_session_id(session_name), "kind": "assistant_workflow_plan", "stage": "planned", "updated_at": self._safe_int(latest.get("created_at"), 0), "updated_at_text": self._clean_text(latest.get("created_at_text")), "keyword": "", "target_path": "", "has_pending_p115": False, "has_saved_plan": bool(saved_plan.get("has_plan")), "has_pending_plan": bool(saved_plan.get("has_pending")), "saved_plan": latest or None, "recovery": { "mode": "resume_saved_plan" if saved_plan.get("has_pending") else "followup_executed_plan" if latest_executed and latest_plan_id else "inspect_plan_only_session", "reason": "当前会话只有 dry_run 计划,尚未生成交互会话缓存", "can_resume": bool(saved_plan.get("has_pending") or (latest_executed and latest_plan_id)), "recommended_action": "execute_session_latest_plan" if saved_plan.get("has_pending") else "query_execution_followup" if latest_executed and latest_plan_id else "inspect_session", "recommended_tool": "agent_resource_officer_execute_plan" if saved_plan.get("has_pending") else "agent_resource_officer_execute_action" if latest_executed and latest_plan_id else "agent_resource_officer_session_state", "action_template": self._assistant_action_template( name="execute_session_latest_plan" if saved_plan.get("has_pending") else "query_execution_followup" if latest_executed and latest_plan_id else "inspect_session", description="按 session_id 执行该会话最近一条待执行计划" if saved_plan.get("has_pending") else "按最近已执行计划自动追踪下载、订阅或入库后续状态" if latest_executed and latest_plan_id else "查看某个会话的详细状态", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute" if saved_plan.get("has_pending") else "/api/v1/plugin/AgentResourceOfficer/assistant/action" if latest_executed and latest_plan_id else "/api/v1/plugin/AgentResourceOfficer/assistant/session", tool="agent_resource_officer_execute_plan" if saved_plan.get("has_pending") else "agent_resource_officer_execute_action" if latest_executed and latest_plan_id else "agent_resource_officer_session_state", body={"session_id": self._assistant_session_id(session_name), "prefer_unexecuted": True} if saved_plan.get("has_pending") else {"session_id": self._assistant_session_id(session_name), "name": "query_execution_followup", "plan_id": latest_plan_id} if latest_executed and latest_plan_id else {"session_id": self._assistant_session_id(session_name)}, ), "alternatives": ["execute_session_latest_plan", "inspect_session"] if saved_plan.get("has_pending") else ["query_execution_followup", "inspect_session"] if latest_executed and latest_plan_id else ["inspect_session"], }, } @staticmethod def _assistant_action_template( *, name: str, description: str, endpoint: str, body: Dict[str, Any], method: str = "POST", tool: str = "", ) -> Dict[str, Any]: body_payload = dict(body or {}) compact_paths = [ "/assistant/action", "/assistant/actions", "/assistant/workflow", "/assistant/plan/execute", "/assistant/recover", "/assistant/route", "/assistant/pick", "/assistant/session", "/assistant/sessions", "/assistant/history", "/assistant/plans", "/assistant/readiness", "/assistant/capabilities", ] if "compact" not in body_payload and any(path in endpoint for path in compact_paths): body_payload["compact"] = True action_body: Dict[str, Any] = {"name": name} for key in [ "session", "session_id", "choice", "path", "keyword", "media_type", "year", "url", "access_code", "client_type", "status", "hash", "target", "control", "downloader", "delete_files", "kind", "has_pending_p115", "stale_only", "all_sessions", "limit", "top", "page", "plan_id", "prefer_unexecuted", "preferences", "compact", "mode", "source", ]: if key in body_payload: action_body[key] = body_payload.get(key) return { "name": name, "description": description, "endpoint": endpoint, "method": method, "tool": tool, "body": body_payload, "action_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", "action_tool": "agent_resource_officer_execute_action", "action_body": action_body, } @staticmethod def _assistant_compact_action_templates( primary: Optional[Dict[str, Any]] = None, templates: Optional[List[Dict[str, Any]]] = None, *, limit: int = 6, ) -> List[Dict[str, Any]]: result: List[Dict[str, Any]] = [] seen: set[str] = set() for item in [primary, *(templates or [])]: if not isinstance(item, dict): continue name = str(item.get("name") or "").strip() if not name or name in seen: continue seen.add(name) result.append(dict(item)) if len(result) >= max(1, limit): break return result @staticmethod def _assistant_compact_next_actions( primary: Optional[List[Any]] = None, secondary: Optional[List[Any]] = None, *, limit: int = 6, ) -> List[str]: result: List[str] = [] seen: set[str] = set() for item in [*(primary or []), *(secondary or [])]: name = str(item or "").strip() if not name or name in seen: continue seen.add(name) result.append(name) if len(result) >= max(1, limit): break return result def _assistant_action_templates(self, data: Dict[str, Any]) -> List[Dict[str, Any]]: session_name = self._clean_text(data.get("session")) or "default" session_id = self._clean_text(data.get("session_id")) or self._assistant_session_id(session_name) base_route = { "session": session_name, "session_id": session_id, } base_pick = { "session": session_name, "session_id": session_id, } base_state = { "session": session_name, "session_id": session_id, } templates: List[Dict[str, Any]] = [] preference_status = self._assistant_preferences_status_brief(session=session_name) preference_template = self._assistant_action_template( name="preferences_save", description="保存智能体片源偏好;首次接入建议先询问用户后再保存", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/preferences", tool="agent_resource_officer_preferences", body={**base_state, "preferences": self._assistant_default_preferences_template()}, ) if not data.get("has_session"): templates = [] if preference_status.get("needs_onboarding"): templates.append(preference_template) saved_plan = dict(data.get("saved_plan") or {}) if saved_plan.get("has_pending"): templates.append( self._assistant_action_template( name="execute_latest_plan", description="执行当前会话最近一条待执行计划", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", tool="agent_resource_officer_execute_plan", body={**base_state, "prefer_unexecuted": True}, ) ) templates.extend([ self._assistant_action_template( name="start_pansou_search", description="发起新的盘搜搜索", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "mode": "pansou", "keyword": "<关键词>"}, ), self._assistant_action_template( name="start_hdhive_search", description="发起新的影巢候选搜索", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "mode": "hdhive", "keyword": "<关键词>", "media_type": "auto"}, ), self._assistant_action_template( name="start_mp_media_search", description="发起新的 MP 原生搜索,返回 PT 候选和评分摘要", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "mode": "mp", "keyword": "<关键词>"}, ), self._assistant_action_template( name="query_mp_media_detail", description="使用 MoviePilot 原生识别确认媒体信息和 TMDB/Douban/IMDB ID", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_route, "name": "query_mp_media_detail", "keyword": "<关键词>", "media_type": "auto"}, ), self._assistant_action_template( name="start_mp_recommendations", description="查看 MP 原生热门推荐,例如 TMDB、豆瓣或 Bangumi", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_route, "name": "start_mp_recommendations", "source": "tmdb_trending", "media_type": "all"}, ), self._assistant_action_template( name="query_mp_download_tasks", description="查看 MP 下载任务状态", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_route, "name": "query_mp_download_tasks", "status": "downloading"}, ), self._assistant_action_template( name="query_mp_download_history", description="查看 MP 下载历史,并关联整理/入库状态", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_route, "name": "query_mp_download_history", "limit": 10}, ), self._assistant_action_template( name="query_mp_lifecycle_status", description="聚合查看 MP 下载任务、下载历史和整理/入库历史", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_route, "name": "query_mp_lifecycle_status", "keyword": "<关键词>", "limit": 5}, ), self._assistant_action_template( name="query_mp_downloaders", description="查看 MP 下载器配置摘要", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_route, "name": "query_mp_downloaders"}, ), self._assistant_action_template( name="query_mp_sites", description="查看 MP 站点启用状态和 Cookie 是否存在,不返回 Cookie 明文", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_route, "name": "query_mp_sites", "status": "active", "limit": 30}, ), self._assistant_action_template( name="query_mp_subscribes", description="查看 MP 订阅列表", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_route, "name": "query_mp_subscribes", "status": "all", "limit": 20}, ), self._assistant_action_template( name="query_mp_transfer_history", description="查看 MP 最近整理/入库历史,用于判断下载后是否已落库", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_route, "name": "query_mp_transfer_history", "status": "all", "limit": 10}, ), self._assistant_action_template( name="start_115_login", description="发起新的 115 扫码登录", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "action": "p115_qrcode_start"}, ), ]) return templates kind = self._clean_text(data.get("kind")) stage = self._clean_text(data.get("stage")) pending = dict(data.get("pending_p115") or {}) saved_plan = dict(data.get("saved_plan") or {}) target_path = self._clean_text(data.get("target_path")) if saved_plan.get("has_pending"): templates.append( self._assistant_action_template( name="execute_latest_plan", description="执行当前会话最近一条待执行计划", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", tool="agent_resource_officer_execute_plan", body={**base_state, "prefer_unexecuted": True}, ) ) if preference_status.get("needs_onboarding"): templates.append(preference_template) templates.append( self._assistant_action_template( name="inspect_session_state", description="重新获取当前会话详细状态", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/session", tool="agent_resource_officer_session_state", body=base_state, ) ) if kind == "assistant_pansou": templates.extend([ self._assistant_action_template( name="pick_pansou_result", description="按编号选择盘搜结果继续转存", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", tool="agent_resource_officer_smart_pick", body={**base_pick, "choice": "<1-N>", "path": target_path or self._p115_default_path}, ), self._assistant_action_template( name="plan_pansou_result", description="按编号生成盘搜转存计划,不立即写入", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", tool="agent_resource_officer_smart_pick", body={**base_pick, "choice": "<1-N>", "action": "plan", "path": target_path or self._p115_default_path}, ), ]) if isinstance(data.get("recommend_handoff"), dict) and data.get("recommend_handoff"): templates.extend([ self._assistant_action_template( name="return_to_recommendations", description="返回当前盘搜结果对应的推荐榜单会话", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "text": "回推荐"}, ), self._assistant_action_template( name="switch_pansou_to_hdhive", description="基于当前推荐条目切到影巢结果", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "text": "影巢"}, ), self._assistant_action_template( name="handoff_pansou_decision", description="基于当前推荐条目回到统一资源决策", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "text": "决策"}, ), self._assistant_action_template( name="handoff_pansou_best_detail", description="查看当前盘搜首选详情", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "text": "详情"}, ), self._assistant_action_template( name="handoff_pansou_best_plan", description="为当前盘搜首选生成待确认计划", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "text": "计划"}, ), self._assistant_action_template( name="handoff_pansou_confirm", description="优先执行当前待确认计划;如无待计划则直接执行当前盘搜首选", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "text": "确认"}, ), self._assistant_action_template( name="switch_pansou_to_mp", description="基于当前推荐条目切到 MP 原生搜索结果", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "text": "原生"}, ), ]) elif kind == "assistant_mp": templates.extend([ self._assistant_action_template( name="pick_mp_best_download", description="按当前评分最高的 MP 搜索结果生成下载计划;不会静默下载", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "pick_mp_best_download"}, ), self._assistant_action_template( name="pick_mp_download", description="按编号为 MP 原生搜索结果生成下载计划;聊天里直接回编号会立即下载", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "pick_mp_download", "choice": "<1-N>"}, ), self._assistant_action_template( name="start_mp_subscribe", description="按当前关键词生成 MP 订阅计划", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "start_mp_subscribe", "keyword": data.get("keyword") or "<关键词>"}, ), ]) if isinstance(data.get("recommend_handoff"), dict) and data.get("recommend_handoff"): templates.extend([ self._assistant_action_template( name="return_to_recommendations", description="返回当前原生搜索结果对应的推荐榜单会话", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "text": "回推荐"}, ), self._assistant_action_template( name="switch_mp_to_pansou", description="基于当前推荐条目切到盘搜结果", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "text": "盘搜"}, ), self._assistant_action_template( name="handoff_mp_decision", description="基于当前推荐条目回到统一资源决策", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "text": "决策"}, ), self._assistant_action_template( name="handoff_mp_best_detail", description="查看当前 MP 搜索里评分最高的候选详情", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "text": "详情"}, ), self._assistant_action_template( name="handoff_mp_best_plan", description="为当前 MP 搜索里评分最高的候选生成待确认计划", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "text": "计划"}, ), self._assistant_action_template( name="handoff_mp_confirm", description="优先执行当前待确认计划;如无待计划则直接执行当前 MP 首选", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "text": "确认"}, ), self._assistant_action_template( name="switch_mp_to_hdhive", description="基于当前推荐条目切到影巢结果", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "text": "影巢"}, ), ]) elif kind == "assistant_mp_download_tasks": has_items = bool(data.get("items")) or self._safe_int(data.get("result_count"), 0) > 0 if has_items: templates.extend([ self._assistant_action_template( name="pause_mp_download", description="按编号暂停下载任务;写入动作建议先 dry_run 生成计划", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "mp_download_control", "control": "pause", "target": "<1-N>"}, ), self._assistant_action_template( name="resume_mp_download", description="按编号恢复下载任务;写入动作建议先 dry_run 生成计划", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "mp_download_control", "control": "resume", "target": "<1-N>"}, ), self._assistant_action_template( name="delete_mp_download", description="按编号删除下载任务;默认不删除文件", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "mp_download_control", "control": "delete", "target": "<1-N>", "delete_files": False}, ), ]) else: templates.extend([ self._assistant_action_template( name="query_mp_download_history", description="当前没有下载中任务,改查下载历史和整理状态", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "query_mp_download_history", "limit": 10}, ), self._assistant_action_template( name="start_mp_media_search", description="重新发起 MP 原生搜索", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "mode": "mp", "keyword": "<关键词>"}, ), ]) elif kind == "assistant_mp_download_history": templates.extend([ self._assistant_action_template( name="query_mp_lifecycle_status", description="按关键词聚合查看下载任务、下载历史和整理/入库状态", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "query_mp_lifecycle_status", "keyword": data.get("keyword") or "<关键词>", "limit": 5}, ), self._assistant_action_template( name="start_mp_media_search", description="按关键词重新发起 MP 原生搜索", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "mode": "mp", "keyword": data.get("keyword") or "<关键词>"}, ), ]) elif kind == "assistant_mp_downloaders": templates.extend([ self._assistant_action_template( name="query_mp_sites", description="查看 PT 站点启用状态和 Cookie 是否存在", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "query_mp_sites", "status": "active", "limit": 30}, ), self._assistant_action_template( name="start_mp_media_search", description="发起新的 MP 原生搜索,返回 PT 候选和评分摘要", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "mode": "mp", "keyword": "<关键词>"}, ), ]) elif kind == "assistant_mp_sites": templates.extend([ self._assistant_action_template( name="query_mp_downloaders", description="查看 MP 下载器配置摘要", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "query_mp_downloaders"}, ), self._assistant_action_template( name="start_mp_media_search", description="发起新的 MP 原生搜索,返回 PT 候选和评分摘要", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "mode": "mp", "keyword": "<关键词>"}, ), ]) elif kind == "assistant_mp_subscribes": has_items = bool(data.get("items")) or self._safe_int(data.get("result_count"), 0) > 0 if has_items: templates.extend([ self._assistant_action_template( name="search_mp_subscribe", description="按编号触发订阅搜索;写入动作建议先 dry_run 生成计划", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "mp_subscribe_control", "control": "search", "target": "<1-N>"}, ), self._assistant_action_template( name="pause_mp_subscribe", description="按编号暂停订阅;写入动作建议先 dry_run 生成计划", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "mp_subscribe_control", "control": "pause", "target": "<1-N>"}, ), self._assistant_action_template( name="resume_mp_subscribe", description="按编号恢复订阅;写入动作建议先 dry_run 生成计划", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "mp_subscribe_control", "control": "resume", "target": "<1-N>"}, ), self._assistant_action_template( name="delete_mp_subscribe", description="按编号删除订阅", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "mp_subscribe_control", "control": "delete", "target": "<1-N>"}, ), ]) else: templates.extend([ self._assistant_action_template( name="start_mp_subscribe", description="按关键词生成新的 MP 订阅计划", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "start_mp_subscribe", "keyword": data.get("keyword") or "<关键词>"}, ), self._assistant_action_template( name="start_mp_media_search", description="按关键词重新发起 MP 原生搜索", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "mode": "mp", "keyword": data.get("keyword") or "<关键词>"}, ), ]) elif kind == "assistant_mp_lifecycle_status": templates.extend([ self._assistant_action_template( name="query_mp_download_history", description="继续查看 MP 下载历史", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "query_mp_download_history", "title": data.get("keyword") or "", "limit": 10}, ), self._assistant_action_template( name="start_mp_media_search", description="按当前关键词重新发起 MP 原生搜索", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "mode": "mp", "keyword": data.get("keyword") or "<关键词>"}, ), ]) elif kind == "assistant_mp_recommend": templates.extend([ self._assistant_action_template( name="pick_recommend_smart_decision", description="按编号选择推荐条目并进入统一资源决策", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", tool="agent_resource_officer_smart_pick", body={**base_pick, "choice": "<1-N>", "mode": "smart_decision"}, ), self._assistant_action_template( name="pick_recommend_smart_plan", description="按编号选择推荐条目并直接生成统一资源计划", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", tool="agent_resource_officer_smart_pick", body={**base_pick, "choice": "<1-N>", "mode": "smart_plan"}, ), self._assistant_action_template( name="pick_recommend_smart_execute", description="按编号选择推荐条目并直接进入统一资源执行链", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", tool="agent_resource_officer_smart_pick", body={**base_pick, "choice": "<1-N>", "mode": "smart_execute"}, ), self._assistant_action_template( name="pick_recommend_mp_search", description="按编号选择推荐条目并进入 MP 原生搜索", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", tool="agent_resource_officer_smart_pick", body={**base_pick, "choice": "<1-N>", "mode": "mp"}, ), self._assistant_action_template( name="pick_recommend_hdhive_search", description="按编号选择推荐条目并进入影巢候选搜索", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", tool="agent_resource_officer_smart_pick", body={**base_pick, "choice": "<1-N>", "mode": "hdhive"}, ), self._assistant_action_template( name="pick_recommend_pansou_search", description="按编号选择推荐条目并进入盘搜搜索", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", tool="agent_resource_officer_smart_pick", body={**base_pick, "choice": "<1-N>", "mode": "pansou"}, ), ]) elif kind == "assistant_hdhive" and stage == "candidate": templates.extend([ self._assistant_action_template( name="pick_hdhive_candidate", description="按编号选择影巢候选影片进入资源列表", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", tool="agent_resource_officer_smart_pick", body={**base_pick, "choice": "<1-N>", "path": target_path or self._hdhive_default_path}, ), self._assistant_action_template( name="candidate_detail", description="补充当前候选页详情,例如主演", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", tool="agent_resource_officer_smart_pick", body={**base_pick, "action": "detail"}, ), self._assistant_action_template( name="candidate_next_page", description="翻到候选下一页", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", tool="agent_resource_officer_smart_pick", body={**base_pick, "action": "next_page"}, ), ]) if isinstance(data.get("recommend_handoff"), dict) and data.get("recommend_handoff"): templates.extend([ self._assistant_action_template( name="return_to_recommendations", description="返回当前影巢候选对应的推荐榜单会话", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "text": "回推荐"}, ), self._assistant_action_template( name="switch_hdhive_to_pansou", description="基于当前推荐条目切到盘搜结果", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "text": "盘搜"}, ), self._assistant_action_template( name="handoff_hdhive_decision", description="基于当前推荐条目回到统一资源决策", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "text": "决策"}, ), self._assistant_action_template( name="switch_hdhive_to_mp", description="基于当前推荐条目切到 MP 原生搜索结果", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "text": "原生"}, ), ]) elif kind == "assistant_hdhive" and stage == "resource": templates.extend([ self._assistant_action_template( name="pick_hdhive_resource", description="按编号选择影巢资源,解锁并路由到对应网盘", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", tool="agent_resource_officer_smart_pick", body={**base_pick, "choice": "<1-N>", "path": target_path or self._hdhive_default_path}, ), self._assistant_action_template( name="plan_hdhive_resource", description="按编号生成影巢解锁/转存计划,不立即扣分或写入", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", tool="agent_resource_officer_smart_pick", body={**base_pick, "choice": "<1-N>", "action": "plan", "path": target_path or self._hdhive_default_path}, ), self._assistant_action_template( name="resource_next_page", description="翻到影巢资源下一页", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/pick", tool="agent_resource_officer_smart_pick", body={**base_pick, "action": "next_page"}, ), ]) if isinstance(data.get("recommend_handoff"), dict) and data.get("recommend_handoff"): templates.extend([ self._assistant_action_template( name="return_to_recommendations", description="返回当前影巢资源列表对应的推荐榜单会话", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "text": "回推荐"}, ), self._assistant_action_template( name="switch_hdhive_to_pansou", description="基于当前推荐条目切到盘搜结果", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "text": "盘搜"}, ), self._assistant_action_template( name="handoff_hdhive_decision", description="基于当前推荐条目回到统一资源决策", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "text": "决策"}, ), self._assistant_action_template( name="switch_hdhive_to_mp", description="基于当前推荐条目切到 MP 原生搜索结果", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "text": "原生"}, ), ]) elif kind == "assistant_p115_login": templates.extend([ self._assistant_action_template( name="check_115_login", description="检查 115 扫码是否已确认,并在成功后自动继续待任务", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "action": "p115_qrcode_check"}, ), self._assistant_action_template( name="show_115_status", description="查看当前 115 状态", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "action": "p115_status"}, ), ]) if pending.get("has_pending"): templates.extend([ self._assistant_action_template( name="resume_pending_115", description="继续当前会话里待处理的 115 任务", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "action": "p115_resume"}, ), self._assistant_action_template( name="cancel_pending_115", description="取消当前会话里待处理的 115 任务", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "action": "p115_cancel"}, ), ]) templates.append( self._assistant_action_template( name="clear_current_session", description="清理当前会话缓存", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/session/clear", tool="agent_resource_officer_session_clear", body=base_state, ) ) return templates def _assistant_sessions_public_data( self, *, kind: str = "", has_pending_p115: Optional[bool] = None, limit: int = 20, ) -> Dict[str, Any]: kind_filter = self._clean_text(kind) max_limit = min(max(1, self._safe_int(limit, 20)), 100) items_by_session: Dict[str, Dict[str, Any]] = {} for session_id, payload in (self._session_cache or {}).items(): if not str(session_id).startswith("assistant::"): continue session = dict(payload or {}) if self._is_session_expired(session): continue brief = self._assistant_session_brief_public_data(str(session_id), session) items_by_session[brief.get("session_id") or str(session_id)] = brief for plan in (self._workflow_plans or {}).values(): current = dict(plan or {}) plan_session_id = self._clean_text(current.get("session_id")) if not plan_session_id: continue brief = items_by_session.get(plan_session_id) if brief: if not brief.get("has_saved_plan"): refreshed = self._assistant_session_brief_public_data(plan_session_id, self._load_session(plan_session_id) or {}) items_by_session[plan_session_id] = refreshed continue items_by_session[plan_session_id] = self._assistant_plan_only_session_brief_public_data(plan_session_id) items: List[Dict[str, Any]] = [] for brief in items_by_session.values(): if kind_filter and brief.get("kind") != kind_filter: continue if has_pending_p115 is not None and bool(brief.get("has_pending_p115")) != bool(has_pending_p115): continue items.append(brief) items.sort(key=lambda item: self._safe_int(item.get("updated_at"), 0), reverse=True) return { "total": len(items), "limit": max_limit, "items": items[:max_limit], "filters": { "kind": kind_filter, "has_pending_p115": has_pending_p115, }, "action_templates": [ self._assistant_action_template( name="execute_session_latest_plan", description="按 session_id 执行该会话最近一条待执行计划", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", tool="agent_resource_officer_execute_plan", body={"session_id": "", "prefer_unexecuted": True}, ), self._assistant_action_template( name="inspect_session", description="查看某个会话的详细状态", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/session", tool="agent_resource_officer_session_state", body={"session_id": ""}, ), self._assistant_action_template( name="clear_session_by_id", description="按 session_id 清理单个会话", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/sessions/clear", tool="agent_resource_officer_sessions_clear", body={"session_id": ""}, ), ], "recovery": ( dict((items[:max_limit][0] or {}).get("recovery") or {}) if (items[:max_limit] and isinstance(items[:max_limit][0], dict)) else { "mode": "start_new", "reason": "当前没有活跃会话,可直接开始新任务", "can_resume": False, "recommended_action": "", "recommended_tool": "", "action_template": None, "alternatives": [], } ), } def _assistant_recover_public_data( self, *, session: str = "", session_id: str = "", limit: int = 20, ) -> Dict[str, Any]: requested_session = self._clean_text(session) requested_session_id = self._clean_text(session_id) max_limit = min(max(1, self._safe_int(limit, 20)), 100) if requested_session or requested_session_id: session_name, normalized_session_id = self._normalize_assistant_session_ref( session=requested_session or "default", session_id=requested_session_id, ) state = self._assistant_session_public_data(session=session_name) return { "scope": "session", "session": session_name, "session_id": normalized_session_id or state.get("session_id") or self._assistant_session_id(session_name), "selected_session": { "session": session_name, "session_id": normalized_session_id or state.get("session_id") or self._assistant_session_id(session_name), "kind": state.get("kind"), "stage": state.get("stage"), "keyword": state.get("keyword"), "has_pending_plan": bool((state.get("saved_plan") or {}).get("has_pending")), "has_pending_p115": bool((state.get("pending_p115") or {}).get("has_pending")), }, "session_state": state, "sessions": None, "recovery": dict(state.get("recovery") or self._assistant_recovery_public_data(session_state=state)), } sessions = self._assistant_sessions_public_data(limit=max_limit) items = [dict(item or {}) for item in (sessions.get("items") or []) if isinstance(item, dict)] selected: Optional[Dict[str, Any]] = None current_recovery = dict(sessions.get("recovery") or {}) template_body = dict(((current_recovery.get("action_template") or {}).get("body") or {})) preferred_session_id = self._clean_text(template_body.get("session_id")) if preferred_session_id: selected = next((item for item in items if self._clean_text(item.get("session_id")) == preferred_session_id), None) if not selected: selected = next((item for item in items if bool((item.get("recovery") or {}).get("can_resume"))), None) if not selected: selected = next((item for item in items if item.get("has_pending_plan") or item.get("has_pending_p115")), None) if not selected and items: selected = items[0] if selected: session_name = self._clean_text(selected.get("session")) or "default" selected_session_id = self._clean_text(selected.get("session_id")) or self._assistant_session_id(session_name) state = self._assistant_session_public_data(session=session_name) recovery = dict(state.get("recovery") or selected.get("recovery") or current_recovery) selected = {**selected, "recovery": recovery} return { "scope": "global", "session": session_name, "session_id": selected_session_id, "selected_session": selected, "session_state": state, "sessions": sessions, "recovery": recovery, } state = self._assistant_session_public_data(session="default") return { "scope": "global", "session": "default", "session_id": state.get("session_id") or self._assistant_session_id("default"), "selected_session": None, "session_state": state, "sessions": sessions, "recovery": dict(state.get("recovery") or current_recovery), } def _format_assistant_recover_text(self, data: Dict[str, Any]) -> str: recovery = dict((data or {}).get("recovery") or {}) selected = dict((data or {}).get("selected_session") or {}) lines = [ "Agent影视助手 恢复入口", f"范围:{(data or {}).get('scope') or 'session'}", f"会话:{(data or {}).get('session') or 'default'}", f"模式:{recovery.get('mode') or 'unknown'}", f"原因:{recovery.get('reason') or '-'}", f"可恢复:{'是' if recovery.get('can_resume') else '否'}", ] if recovery.get("recommended_action"): lines.append(f"推荐动作:{recovery.get('recommended_action')}") if recovery.get("recommended_tool"): lines.append(f"推荐 Tool:{recovery.get('recommended_tool')}") if selected.get("kind") or selected.get("keyword"): detail = " / ".join( str(item) for item in [ selected.get("kind"), selected.get("stage"), selected.get("keyword"), ] if item ) lines.append(f"当前状态:{detail}") if recovery.get("can_resume"): lines.append("如需直接恢复,可调用 assistant/recover 并传 execute=true。") return "\n".join(lines) def _assistant_recover_response_data(self, data: Dict[str, Any], compact: bool = False) -> Dict[str, Any]: if not compact: return self._assistant_response_data(session=(data or {}).get("session") or "default", data=data) payload = dict(data or {}) session_state = dict(payload.pop("session_state", {}) or {}) payload.pop("sessions", None) recovery = dict(payload.get("recovery") or {}) selected = payload.get("selected_session") if isinstance(selected, dict) and selected.get("recovery"): selected = dict(selected) selected.pop("recovery", None) payload["selected_session"] = selected session_name = self._clean_text(payload.get("session") or session_state.get("session")) or "default" session_id = self._clean_text(payload.get("session_id") or session_state.get("session_id")) or self._assistant_session_id(session_name) action_template = recovery.get("action_template") if isinstance(recovery.get("action_template"), dict) else None session_templates = session_state.get("action_templates") if isinstance(session_state.get("action_templates"), list) else [] payload.update({ "protocol_version": "assistant.v1", "compact": True, "session": session_name, "session_id": session_id, "next_actions": [ item for item in [ recovery.get("recommended_action"), *(session_state.get("suggested_actions") or []), ] if item ][:6], "action_templates": self._assistant_compact_action_templates(action_template, session_templates), }) return payload def _assistant_session_compact_data(self, session_state: Dict[str, Any]) -> Dict[str, Any]: state = dict(session_state or {}) recovery = dict(state.get("recovery") or {}) saved_plan = dict(state.get("saved_plan") or {}) pending_p115 = dict(state.get("pending_p115") or {}) payload: Dict[str, Any] = { "protocol_version": "assistant.v1", "action": "session_state", "ok": True, "compact": True, "has_session": bool(state.get("has_session")), "session": self._clean_text(state.get("session")) or "default", "session_id": self._clean_text(state.get("session_id")), "kind": self._clean_text(state.get("kind")), "stage": self._clean_text(state.get("stage")), "keyword": self._clean_text(state.get("keyword")), "target_path": self._clean_text(state.get("target_path")), "updated_at": state.get("updated_at"), "updated_at_text": state.get("updated_at_text"), "saved_plan": { "has_pending": bool(saved_plan.get("has_pending")), "plan_id": self._clean_text((saved_plan.get("latest") or {}).get("plan_id") or saved_plan.get("plan_id")), }, "pending_p115": { "has_pending": bool(pending_p115.get("has_pending")), "target_path": self._clean_text(pending_p115.get("target_path")), "retry_count": pending_p115.get("retry_count"), }, "recovery": recovery, "next_actions": state.get("suggested_actions") or [], "action_templates": self._assistant_compact_action_templates( recovery.get("action_template") if isinstance(recovery.get("action_template"), dict) else None, state.get("action_templates") if isinstance(state.get("action_templates"), list) else [], ), } for key in [ "result_count", "total_candidates", "page", "total_pages", "selected_candidate", "total_resources", "resource_count_115", "resource_count_quark", "score_summary", "client_type", ]: if key in state: payload[key] = state.get(key) return payload def _assistant_sessions_compact_data(self, sessions_data: Dict[str, Any]) -> Dict[str, Any]: data = dict(sessions_data or {}) items: List[Dict[str, Any]] = [] for item in data.get("items") or []: if not isinstance(item, dict): continue recovery = dict(item.get("recovery") or {}) items.append({ "session": self._clean_text(item.get("session")), "session_id": self._clean_text(item.get("session_id")), "kind": self._clean_text(item.get("kind")), "stage": self._clean_text(item.get("stage")), "keyword": self._clean_text(item.get("keyword")), "target_path": self._clean_text(item.get("target_path")), "updated_at": item.get("updated_at"), "updated_at_text": item.get("updated_at_text"), "has_pending_plan": bool(item.get("has_pending_plan")), "has_pending_p115": bool(item.get("has_pending_p115")), "recovery_mode": self._clean_text(recovery.get("mode")), "recommended_action": self._clean_text(recovery.get("recommended_action")), }) recovery = dict(data.get("recovery") or {}) return { "protocol_version": "assistant.v1", "action": "sessions", "ok": True, "compact": True, "total": data.get("total") or 0, "limit": data.get("limit") or len(items), "filters": data.get("filters") or {}, "items": items, "recovery": recovery, "next_actions": [recovery.get("recommended_action")] if recovery.get("recommended_action") else [], "action_templates": [recovery.get("action_template")] if isinstance(recovery.get("action_template"), dict) else [], } def _assistant_history_compact_data(self, history_data: Dict[str, Any]) -> Dict[str, Any]: data = dict(history_data or {}) items: List[Dict[str, Any]] = [] for item in data.get("items") or []: if not isinstance(item, dict): continue summary = dict(item.get("summary") or {}) steps = summary.get("steps") items.append({ "time_text": self._clean_text(item.get("time_text")), "success": bool(item.get("success")), "action": self._clean_text(item.get("action")), "workflow": self._clean_text(summary.get("workflow")), "session": self._clean_text(item.get("session")), "session_id": self._clean_text(item.get("session_id")), "message_head": self._clean_text(item.get("message_head")), "steps": steps if isinstance(steps, int) else None, }) return { "protocol_version": "assistant.v1", "action": "history", "ok": True, "compact": True, "total": data.get("total") or 0, "limit": data.get("limit") or len(items), "session": self._clean_text(data.get("session")), "session_id": self._clean_text(data.get("session_id")), "items": items, } def _assistant_plans_compact_data(self, plans_data: Dict[str, Any]) -> Dict[str, Any]: data = dict(plans_data or {}) items: List[Dict[str, Any]] = [] first_pending: Optional[Dict[str, Any]] = None for item in data.get("items") or []: if not isinstance(item, dict): continue plan_id = self._clean_text(item.get("plan_id")) executed = bool(item.get("executed")) compact_item = { "plan_id": plan_id, "workflow": self._clean_text(item.get("workflow")), "session": self._clean_text(item.get("session")), "session_id": self._clean_text(item.get("session_id")), "executed": executed, "action_count": self._safe_int(item.get("action_count"), 0), "created_at_text": self._clean_text(item.get("created_at_text")), "last_success": item.get("last_success"), "last_message": self._clean_text(item.get("last_message")), } items.append(compact_item) if not executed and first_pending is None and plan_id: first_pending = compact_item templates: List[Dict[str, Any]] = [] if first_pending: templates.append(self._assistant_action_template( name="execute_plan", description="执行待处理计划", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", tool="agent_resource_officer_execute_plan", body={ "plan_id": first_pending.get("plan_id"), "session": first_pending.get("session"), "session_id": first_pending.get("session_id"), "prefer_unexecuted": True, }, )) return { "protocol_version": "assistant.v1", "action": "plans", "ok": True, "compact": True, "total": data.get("total_matching") if data.get("total_matching") is not None else (data.get("total") or 0), "total_matching": data.get("total_matching") if data.get("total_matching") is not None else (data.get("total") or 0), "total_all": data.get("total_all") if data.get("total_all") is not None else (data.get("total") or 0), "limit": data.get("limit") or len(items), "session": self._clean_text(data.get("session")), "session_id": self._clean_text(data.get("session_id")), "executed": data.get("executed"), "include_actions": False, "items": items, "next_actions": ["execute_plan"] if first_pending else [], "action_templates": templates, } def _assistant_compact_action_results(self, rows: Any) -> List[Dict[str, Any]]: results: List[Dict[str, Any]] = [] for item in rows or []: if not isinstance(item, dict): continue results.append({ "index": item.get("index"), "name": self._clean_text(item.get("name")), "success": bool(item.get("success")), "action": self._clean_text(item.get("action")), "ok": bool(item.get("ok")) if "ok" in item else bool(item.get("success")), "message_head": self._clean_text(item.get("message_head")), "session": self._clean_text(item.get("session")), "session_id": self._clean_text(item.get("session_id")), "kind": self._clean_text(item.get("kind")), "stage": self._clean_text(item.get("stage")), "has_pending_p115": bool(item.get("has_pending_p115")), "next_actions": item.get("next_actions") or [], }) if isinstance(item.get("score_summary"), dict): results[-1]["score_summary"] = item.get("score_summary") if isinstance(item.get("diagnosis_summary"), dict): results[-1]["diagnosis_summary"] = item.get("diagnosis_summary") return results def _assistant_plan_execute_followup( self, *, workflow: str, session: str, session_id: str, session_state: Optional[Dict[str, Any]] = None, ok: bool, plan_id: str = "", ) -> Dict[str, Any]: workflow_name = self._clean_text(workflow) if not ok and workflow_name != "smart_resource_plan": return {"next_actions": [], "action_templates": [], "recommended_action": "", "follow_up_hint": ""} workflow_stage = workflow_name state = dict(session_state or {}) session_name = self._clean_text(session or state.get("session")) or "default" session_cache = self._clean_text(session_id or state.get("session_id")) or self._assistant_session_id(session_name) keyword = self._clean_text(state.get("keyword")) plan_execute_body = dict(state.get("plan_execute_body") or {}) if workflow_name == "smart_resource_plan": inferred_source = self._clean_text( state.get("source_type") or ((state.get("best_candidate") or {}).get("source_type")) or plan_execute_body.get("mode") ).lower() if inferred_source == "mp": inferred_source = "mp_pt" if inferred_source == "mp_pt": workflow_name = "mp_best_download" elif inferred_source in {"pansou", "hdhive"}: workflow_name = inferred_source base_route = { "session": session_name, "session_id": session_cache, } base_state = { "session": session_name, "session_id": session_cache, } keyword_value = keyword or "<关键词>" next_actions: List[str] = [] templates: List[Dict[str, Any]] = [] follow_up_hint = "" clean_plan_id = self._clean_text(plan_id) if workflow_name in {"mp_best_download", "mp_download", "mp_search_download", "mp_download_control"}: next_actions = ["query_execution_followup", "query_mp_ingest_status", "query_mp_lifecycle_status", "query_mp_local_diagnose"] follow_up_hint = "可以先执行统一后续追踪;它会自动去查下载历史,再继续判断是否已整理、已入库或失败。" templates = [ self._assistant_action_template( name="query_execution_followup", description="按最近已执行计划自动查询下载后的统一后续状态", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={ **base_state, "name": "query_execution_followup", **({"plan_id": clean_plan_id} if clean_plan_id else {}), }, ), self._assistant_action_template( name="query_mp_ingest_status", description="按关键词判断当前处于下载、整理、入库还是失败阶段", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "query_mp_ingest_status", "keyword": keyword_value, "limit": 5}, ), self._assistant_action_template( name="query_mp_download_history", description="查看下载历史,并继续追踪整理/入库状态", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "query_mp_download_history", "keyword": keyword_value, "limit": 10}, ), self._assistant_action_template( name="query_mp_lifecycle_status", description="按关键词聚合查看下载任务、下载历史和整理/入库状态", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "query_mp_lifecycle_status", "keyword": keyword_value, "limit": 5}, ), self._assistant_action_template( name="query_mp_local_diagnose", description="汇总失败线索并给出本地/PT 入库诊断建议", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "query_mp_local_diagnose", "keyword": keyword_value, "limit": 5}, ), ] elif workflow_name in {"mp_subscribe", "mp_subscribe_control"}: next_actions = ["query_execution_followup", "query_mp_subscribes", "query_mp_ingest_status", "start_mp_media_search"] follow_up_hint = "可以先执行统一后续追踪;它会自动查订阅列表,再决定是否继续搜索或进入入库追踪。" templates = [ self._assistant_action_template( name="query_execution_followup", description="按最近已执行计划自动查询订阅后的统一后续状态", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={ **base_state, "name": "query_execution_followup", **({"plan_id": clean_plan_id} if clean_plan_id else {}), }, ), self._assistant_action_template( name="query_mp_subscribes", description="查看订阅列表,确认当前订阅是否已生效", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "query_mp_subscribes", "status": "all", "keyword": keyword, "limit": 20}, ), self._assistant_action_template( name="query_mp_ingest_status", description="按关键词判断当前处于下载、整理、入库还是失败阶段", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "query_mp_ingest_status", "keyword": keyword_value, "limit": 5}, ), self._assistant_action_template( name="start_mp_media_search", description="按当前关键词重新发起 MP 原生搜索", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={**base_route, "mode": "mp", "keyword": keyword_value}, ), ] elif workflow_name in {"share_transfer", "pansou_transfer_selected", "hdhive_unlock", "hdhive_unlock_selected", "pansou", "hdhive"}: next_actions = ["query_execution_followup", "query_mp_transfer_history", "query_mp_local_diagnose"] follow_up_hint = "可以先执行统一后续追踪;它会自动查整理/入库历史,失败时再切到本地诊断。" templates = [ self._assistant_action_template( name="query_execution_followup", description="按最近已执行计划自动查询云盘转存后的统一后续状态", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={ **base_state, "name": "query_execution_followup", **({"plan_id": clean_plan_id} if clean_plan_id else {}), }, ), self._assistant_action_template( name="query_mp_transfer_history", description="查看最近整理/入库历史,确认转存资源是否已落库", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "query_mp_transfer_history", "keyword": keyword, "status": "all", "limit": 10}, ), self._assistant_action_template( name="query_mp_local_diagnose", description="汇总下载后未入库或整理失败的诊断线索", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "query_mp_local_diagnose", "keyword": keyword_value, "limit": 5}, ), ] if workflow_stage == "smart_resource_plan" and keyword: next_actions = ["query_execution_followup", "query_mp_ingest_status", "query_mp_local_diagnose"] follow_up_hint = "推荐先跟进当前发现链的落库状态;它会先看统一后续,再判断是否已入库或需要本地诊断。" templates = [ templates[0], self._assistant_action_template( name="query_mp_ingest_status", description="按推荐标题查看当前是否已下载、整理或入库", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "query_mp_ingest_status", "keyword": keyword_value, "limit": 5}, ), templates[-1], ] elif workflow_name == "ai_replay_failed_sample": next_actions = ["query_ai_sample_worklist", "query_ai_failed_samples", "query_ai_sample_insights"] follow_up_hint = "先回看 AI 工作清单和失败样本,确认这次二次识别是否减少了失败样本。" if keyword: next_actions = ["query_mp_local_diagnose", "query_mp_ingest_status", *next_actions] follow_up_hint = "先看本地诊断或入库状态,再回看 AI 工作清单确认失败样本是否已减少。" templates = [] if keyword: templates.extend([ self._assistant_action_template( name="query_mp_local_diagnose", description="查看这次二次识别关联的本地/PT 入库诊断", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "query_mp_local_diagnose", "keyword": keyword_value, "limit": 5}, ), self._assistant_action_template( name="query_mp_ingest_status", description="按目标标题查看当前是否已重新识别、整理或入库", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "query_mp_ingest_status", "keyword": keyword_value, "limit": 5}, ), ]) templates.extend([ self._assistant_action_template( name="query_ai_sample_worklist", description="回看 AI 工作清单,继续处理剩余失败样本", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "query_ai_sample_worklist", "keyword": keyword}, ), self._assistant_action_template( name="query_ai_failed_samples", description="回看 AI 原始失败样本,确认当前仍有哪些标题或路径失败", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "query_ai_failed_samples", "keyword": keyword}, ), self._assistant_action_template( name="query_ai_sample_insights", description="查看 AI 样本洞察,确认失败原因是否仍然集中", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={**base_state, "name": "query_ai_sample_insights", "keyword": keyword}, ), ]) category = "cloud_write" if workflow_name in {"mp_best_download", "mp_download", "mp_search_download", "mp_download_control"}: category = "mp_download" elif workflow_name in {"mp_subscribe", "mp_subscribe_control"}: category = "mp_subscribe" elif workflow_name == "ai_replay_failed_sample": category = "ai_reingest" followup_summary = self._assistant_followup_summary( category=category, stage=self._clean_text(workflow_stage), recommended_action=next_actions[0] if next_actions else "", follow_up_hint=follow_up_hint, next_actions=next_actions, action_templates=templates, keyword=keyword, ) return { "next_actions": next_actions, "action_templates": templates, "recommended_action": next_actions[0] if next_actions else "", "follow_up_hint": follow_up_hint, "followup_summary": followup_summary, } async def _assistant_execution_followup( self, request: Request, *, session: str, session_id: str, plan_id: str = "", ) -> Dict[str, Any]: session_name, cache_key = self._normalize_assistant_session_ref( session=session or "default", session_id=session_id, ) target_plan_id = self._clean_text(plan_id) plan = self._find_workflow_plan( plan_id=target_plan_id, session=session_name, session_id=cache_key, executed=True if not target_plan_id else None, ) if target_plan_id and plan and not bool(plan.get("executed")): return { "success": False, "message": f"计划 {target_plan_id} 还没有执行,暂时无法做执行后追踪。", "data": self._assistant_response_data(session=session_name, data={ "action": "execution_followup", "ok": False, "error_code": "plan_not_executed", "plan_id": target_plan_id, }), } if not plan: latest_any = self._find_workflow_plan(session=session_name, session_id=cache_key, executed=None) error_code = "executed_plan_not_found" if latest_any and not bool(latest_any.get("executed")): error_code = "latest_plan_not_executed" return { "success": False, "message": "当前会话没有可追踪的已执行计划,请先执行下载、订阅或转存计划。", "data": self._assistant_response_data(session=session_name, data={ "action": "execution_followup", "ok": False, "error_code": error_code, "plan_id": target_plan_id, }), } source_plan = self._workflow_plan_public_item(plan, include_actions=False) state = self._load_session(self._clean_text(plan.get("session_id"))) or {} followup = self._assistant_plan_execute_followup( workflow=self._clean_text(plan.get("workflow")), session=self._clean_text(plan.get("session")) or session_name, session_id=self._clean_text(plan.get("session_id")) or cache_key, session_state=state, ok=bool(plan.get("last_success")) if plan.get("last_success") is not None else True, plan_id=self._clean_text(plan.get("plan_id")), ) action_templates = [item for item in (followup.get("action_templates") or []) if isinstance(item, dict)] resolved_template = next( (item for item in action_templates if self._clean_text(item.get("name")) != "query_execution_followup"), {}, ) resolved_name = self._clean_text(resolved_template.get("name")) if not resolved_name: return { "success": False, "message": "当前执行计划没有可自动继续的只读追踪动作。", "data": self._assistant_response_data(session=session_name, data={ "action": "execution_followup", "ok": False, "error_code": "followup_not_available", "plan_id": self._clean_text(plan.get("plan_id")), "source_plan": source_plan, }), } action_body = dict(resolved_template.get("action_body") or resolved_template.get("body") or {}) action_body["compact"] = False action_body["apikey"] = self._extract_apikey(request, {}) result = await self.api_assistant_action(_JsonRequestShim(request, action_body)) payload = dict(result.get("data") or {}) payload.update({ "action": "execution_followup", "ok": bool(result.get("success")), "plan_id": self._clean_text(plan.get("plan_id")), "workflow": self._clean_text(plan.get("workflow")), "source_plan": source_plan, "recommended_action": self._clean_text(followup.get("recommended_action")), "follow_up_hint": self._clean_text(followup.get("follow_up_hint")), "resolved_followup_action": resolved_name, "followup_summary": followup.get("followup_summary") or {}, }) message_lines = [ f"已执行后续追踪:{resolved_name}", self._clean_text(result.get("message")), ] message_lines.extend(self._format_followup_summary_lines(followup.get("followup_summary"))) return { "success": bool(result.get("success")), "message": "\n".join(line for line in message_lines if line).strip(), "data": self._assistant_response_data(session=session_name, data=payload), } async def _assistant_smart_followup( self, request: Request, *, session: str, session_id: str, keyword: str = "", hash_value: str = "", limit: int = 5, ) -> Dict[str, Any]: session_name, cache_key = self._normalize_assistant_session_ref( session=session or "default", session_id=session_id, ) title = self._clean_text(keyword) hash_text = self._clean_text(hash_value) saved_plan = self._session_workflow_plan_public_data(session=session_name, session_id=cache_key) latest_plan = dict(saved_plan.get("latest") or {}) latest_plan_id = self._clean_text(latest_plan.get("plan_id")) latest_executed = bool(latest_plan.get("executed")) if title or hash_text: result = await self._assistant_mp_lifecycle_status( session=session_name, cache_key=cache_key, title=title, hash_value=hash_text, limit=limit, ) payload = dict(result.get("data") or {}) followup_summary = {} if isinstance(payload.get("followup_summary"), dict): followup_summary = dict(payload.get("followup_summary") or {}) elif isinstance(payload.get("diagnosis_summary"), dict): followup_summary = dict((payload.get("diagnosis_summary") or {}).get("followup_summary") or {}) payload.update({ "action": "smart_followup", "ok": bool(result.get("success")), "resolved_followup_action": "mp_lifecycle_status", "smart_mode": "keyword_lifecycle", "followup_summary": followup_summary, }) return { "success": bool(result.get("success")), "message": "\n".join( line for line in [ "智能跟进:按关键词查看本地/PT 状态", self._clean_text(result.get("message")), ] if line ).strip(), "data": self._assistant_response_data(session=session_name, data=payload), } if latest_executed and latest_plan_id: result = await self._assistant_execution_followup( request, session=session_name, session_id=cache_key, plan_id=latest_plan_id, ) payload = dict(result.get("data") or {}) payload.update({ "action": "smart_followup", "ok": bool(result.get("success")), "resolved_followup_action": self._clean_text(payload.get("resolved_followup_action")) or "execution_followup", "smart_mode": "executed_plan_followup", }) return { "success": bool(result.get("success")), "message": "\n".join( line for line in [ "智能跟进:按最近已执行计划继续追踪", self._clean_text(result.get("message")), ] if line ).strip(), "data": self._assistant_response_data(session=session_name, data=payload), } if saved_plan.get("has_pending"): template = self._assistant_action_template( name="execute_session_latest_plan", description="按 session_id 执行该会话最近一条待执行计划", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", tool="agent_resource_officer_execute_plan", body={"session_id": self._assistant_session_id(session_name), "prefer_unexecuted": True}, ) return { "success": False, "message": "当前会话还有待执行计划,先执行计划,再继续跟进。", "data": self._assistant_response_data(session=session_name, data={ "action": "smart_followup", "ok": False, "error_code": "latest_plan_not_executed", "recommended_action": "execute_session_latest_plan", "follow_up_hint": "当前会话还有待执行计划,先执行计划,再继续跟进。", "action_templates": [template], "smart_mode": "pending_plan_blocked", }), } result = await self._assistant_mp_recent_activity( session=session_name, cache_key=cache_key, limit=max(5, limit), ) payload = dict(result.get("data") or {}) followup_summary = {} if isinstance(payload.get("followup_summary"), dict): followup_summary = dict(payload.get("followup_summary") or {}) elif isinstance(payload.get("diagnosis_summary"), dict): followup_summary = dict((payload.get("diagnosis_summary") or {}).get("followup_summary") or {}) payload.update({ "action": "smart_followup", "ok": bool(result.get("success")), "resolved_followup_action": "mp_recent_activity", "smart_mode": "recent_activity_fallback", "followup_summary": followup_summary, }) return { "success": bool(result.get("success")), "message": "\n".join( line for line in [ "智能跟进:当前没有指定片名,也没有已执行计划,先看最近活动", self._clean_text(result.get("message")), ] if line ).strip(), "data": self._assistant_response_data(session=session_name, data=payload), } def _assistant_actions_compact_data(self, actions_data: Dict[str, Any]) -> Dict[str, Any]: data = dict(actions_data or {}) session_state = dict(data.get("session_state") or {}) results = self._assistant_compact_action_results(data.get("results")) payload = { "protocol_version": "assistant.v1", "action": self._clean_text(data.get("action")) or "execute_actions", "ok": bool(data.get("ok")), "compact": True, "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), "executed_count": data.get("executed_count") or len(results), "requested_count": data.get("requested_count") or len(results), "stopped_on_error": bool(data.get("stopped_on_error")), "halted_at": data.get("halted_at") or 0, "results": results, "next_actions": data.get("next_actions") or session_state.get("suggested_actions") or [], "action_templates": data.get("action_templates") or [], } error_summary = self._assistant_error_summary( error_code=self._clean_text(data.get("error_code")), recommended_action=self._clean_text(data.get("recommended_action")), message_head=self._assistant_result_message_head(data.get("message") or data.get("message_head")), next_actions=payload.get("next_actions"), action_templates=payload.get("action_templates"), keyword=self._clean_text(session_state.get("keyword")), hash_value=self._clean_text(session_state.get("hash")), target=self._clean_text(data.get("target")), ) if error_summary: payload["error_summary"] = error_summary if isinstance(data.get("preference_status"), dict): payload["preference_status"] = data.get("preference_status") payload["needs_onboarding"] = bool(data["preference_status"].get("needs_onboarding")) if isinstance(data.get("scoring_policy"), dict): payload["scoring_policy"] = data.get("scoring_policy") if isinstance(data.get("score_summary"), dict): payload["score_summary"] = data.get("score_summary") if isinstance(data.get("decision_summary"), dict): payload["decision_summary"] = data.get("decision_summary") if isinstance(data.get("diagnosis_summary"), dict): payload["diagnosis_summary"] = data.get("diagnosis_summary") if isinstance(data.get("followup_summary"), dict): payload["followup_summary"] = data.get("followup_summary") for key in ["best_candidate", "source_sample", "target", "identifier_preview", "recognize_result", "sample_removal_result"]: if isinstance(data.get(key), dict): payload[key] = data.get(key) for key in ["sources_checked", "alternatives", "available_sources", "blocked_sources"]: if isinstance(data.get(key), list): payload[key] = data.get(key) for key in ["decision_mode", "decision_reason"]: if key in data: payload[key] = data.get(key) command_summary = self._assistant_compact_command_summary(payload) if command_summary: payload.update(command_summary) return payload @staticmethod def _assistant_compact_command_summary(payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: data = dict(payload or {}) score_summary = data.get("score_summary") if isinstance(data.get("score_summary"), dict) else {} candidates = [ ("error_summary", data.get("error_summary") if isinstance(data.get("error_summary"), dict) else {}), ("followup_summary", data.get("followup_summary") if isinstance(data.get("followup_summary"), dict) else {}), ("decision_summary", data.get("decision_summary") if isinstance(data.get("decision_summary"), dict) else {}), ("score_summary", score_summary.get("decision") if isinstance(score_summary.get("decision"), dict) else {}), ] for source, summary in candidates: if not isinstance(summary, dict) or not summary: continue compact_commands = [ AgentResourceOfficer._clean_text(item) for item in (summary.get("compact_commands") or summary.get("recommended_commands") or []) if AgentResourceOfficer._clean_text(item) ] preferred_command = AgentResourceOfficer._clean_text(summary.get("preferred_command")) fallback_command = AgentResourceOfficer._clean_text(summary.get("fallback_command")) if preferred_command or compact_commands: return { "command_source": source, "command_policy": AgentResourceOfficer._clean_text(summary.get("command_policy")) or ("confirm_then_resume" if bool(summary.get("preferred_requires_confirmation")) else "safe_read_only"), "preferred_requires_confirmation": bool(summary.get("preferred_requires_confirmation")), "fallback_requires_confirmation": bool(summary.get("fallback_requires_confirmation")), "can_auto_run_preferred": bool(summary.get("can_auto_run_preferred")) if "can_auto_run_preferred" in summary else not bool(summary.get("preferred_requires_confirmation")), "preferred_command": preferred_command or (compact_commands[0] if compact_commands else ""), "fallback_command": fallback_command or (compact_commands[1] if len(compact_commands) > 1 else ""), "compact_commands": compact_commands[:2], "recommended_agent_behavior": AgentResourceOfficer._clean_text(summary.get("recommended_agent_behavior")), "auto_run_command": AgentResourceOfficer._clean_text(summary.get("auto_run_command")), "confirm_command": AgentResourceOfficer._clean_text(summary.get("confirm_command")), "display_command": AgentResourceOfficer._clean_text(summary.get("display_command")), "detail_short_command": AgentResourceOfficer._clean_text(summary.get("detail_short_command")), "decision_short_command": AgentResourceOfficer._clean_text(summary.get("decision_short_command")), "plan_short_command": AgentResourceOfficer._clean_text(summary.get("plan_short_command")), "confirm_short_command": AgentResourceOfficer._clean_text(summary.get("confirm_short_command")), "pansou_short_command": AgentResourceOfficer._clean_text(summary.get("pansou_short_command")), "hdhive_short_command": AgentResourceOfficer._clean_text(summary.get("hdhive_short_command")), "mp_short_command": AgentResourceOfficer._clean_text(summary.get("mp_short_command")), } return {} def _assistant_plan_execute_compact_response(self, result: Dict[str, Any]) -> Dict[str, Any]: response = dict(result or {}) data = dict(response.get("data") or {}) session_state = dict(data.get("session_state") or {}) results = self._assistant_compact_action_results(data.get("results")) success_count = len([item for item in results if item.get("success")]) last_result = results[-1] if results else {} followup = self._assistant_plan_execute_followup( workflow=self._clean_text(data.get("workflow")), session=self._clean_text(data.get("session") or session_state.get("session")) or "default", session_id=self._clean_text(data.get("session_id") or session_state.get("session_id")), session_state=session_state, ok=bool(data.get("ok")) if "ok" in data else bool(response.get("success")), plan_id=self._clean_text(data.get("plan_id")), ) payload = { "protocol_version": "assistant.v1", "action": "execute_plan", "ok": bool(data.get("ok")) if "ok" in data else bool(response.get("success")), "compact": True, "write_effect": data.get("write_effect") or "write", "error_code": self._clean_text(data.get("error_code")) or ("" if response.get("success") else "assistant_error"), "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), "message_head": self._assistant_result_message_head(response.get("message")), "plan_id": self._clean_text(data.get("plan_id")), "workflow": self._clean_text(data.get("workflow")), "plan_auto_selected": bool(data.get("plan_auto_selected")), "plan_created_at": data.get("plan_created_at"), "plan_created_at_text": data.get("plan_created_at_text"), "plan_executed_at": data.get("plan_executed_at"), "plan_executed_at_text": data.get("plan_executed_at_text"), "executed_count": data.get("executed_count") or len(results), "requested_count": data.get("requested_count") or len(results), "stopped_on_error": bool(data.get("stopped_on_error")), "halted_at": data.get("halted_at") or 0, "results": results, "result_summary": { "success_count": success_count, "failure_count": max(len(results) - success_count, 0), "last_action": self._clean_text(last_result.get("action")), "last_message_head": self._clean_text(last_result.get("message_head")), }, "recommended_action": self._clean_text(data.get("recommended_action")) or self._clean_text(followup.get("recommended_action")), "follow_up_hint": self._clean_text(data.get("follow_up_hint")) or self._clean_text(followup.get("follow_up_hint")), "next_actions": self._assistant_compact_next_actions( followup.get("next_actions"), data.get("next_actions") or session_state.get("suggested_actions") or [], ), "action_templates": self._assistant_compact_action_templates( templates=[ *(followup.get("action_templates") or []), *(data.get("action_templates") or []), ], limit=6, ), } error_summary = self._assistant_error_summary( error_code=payload.get("error_code"), recommended_action=payload.get("recommended_action"), message_head=payload.get("message_head"), next_actions=payload.get("next_actions"), action_templates=payload.get("action_templates"), keyword=self._clean_text(session_state.get("keyword")), hash_value=self._clean_text(session_state.get("hash")), target=self._clean_text(data.get("target")), ) if error_summary: payload["error_summary"] = error_summary if isinstance(data.get("preference_status"), dict): payload["preference_status"] = data.get("preference_status") payload["needs_onboarding"] = bool(data["preference_status"].get("needs_onboarding")) if isinstance(data.get("scoring_policy"), dict): payload["scoring_policy"] = data.get("scoring_policy") if isinstance(data.get("score_summary"), dict): payload["score_summary"] = data.get("score_summary") if isinstance(data.get("decision_summary"), dict): payload["decision_summary"] = data.get("decision_summary") if isinstance(data.get("diagnosis_summary"), dict): payload["diagnosis_summary"] = data.get("diagnosis_summary") if isinstance(data.get("followup_summary"), dict): payload["followup_summary"] = data.get("followup_summary") if isinstance(data.get("effective_preferences"), dict): payload["effective_preferences"] = data.get("effective_preferences") if isinstance(data.get("session_preference_overrides"), dict): payload["session_preference_overrides"] = data.get("session_preference_overrides") if isinstance(data.get("recovery"), dict): payload["recovery"] = data.get("recovery") command_summary = self._assistant_compact_command_summary(payload) if command_summary: payload.update(command_summary) return { "success": bool(response.get("success")), "message": response.get("message") or "", "data": payload, } def _assistant_single_action_compact_response(self, name: str, result: Dict[str, Any]) -> Dict[str, Any]: response = dict(result or {}) data = dict(response.get("data") or {}) session_state = dict(data.get("session_state") or {}) payload = { "protocol_version": "assistant.v1", "action": self._clean_text(data.get("action")) or self._clean_text(name) or "execute_action", "ok": bool(data.get("ok")) if "ok" in data else bool(response.get("success")), "compact": True, "write_effect": data.get("write_effect") or self._assistant_write_effect_for_action(self._clean_text(data.get("action")) or self._clean_text(name)), "error_code": self._clean_text(data.get("error_code")) or ("" if response.get("success") else "assistant_error"), "name": self._clean_text(name), "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), "message_head": self._assistant_result_message_head(response.get("message")), "kind": self._clean_text(session_state.get("kind")), "stage": self._clean_text(session_state.get("stage")), "next_actions": data.get("next_actions") or session_state.get("suggested_actions") or [], "action_templates": data.get("action_templates") or [], } error_summary = self._assistant_error_summary( error_code=payload.get("error_code"), recommended_action=self._clean_text(data.get("recommended_action")), message_head=payload.get("message_head"), next_actions=payload.get("next_actions"), action_templates=payload.get("action_templates"), keyword=self._clean_text(session_state.get("keyword")), hash_value=self._clean_text(session_state.get("hash")), target=self._clean_text(data.get("target")), ) if error_summary: payload["error_summary"] = error_summary for key in ["plan_id", "workflow", "plan_auto_selected", "has_session", "has_pending"]: if key in data: payload[key] = data.get(key) if isinstance(data.get("preference_status"), dict): payload["preference_status"] = data.get("preference_status") payload["needs_onboarding"] = bool(data["preference_status"].get("needs_onboarding")) if isinstance(data.get("scoring_policy"), dict): payload["scoring_policy"] = data.get("scoring_policy") if isinstance(data.get("score_summary"), dict): payload["score_summary"] = data.get("score_summary") if isinstance(data.get("decision_summary"), dict): payload["decision_summary"] = data.get("decision_summary") if isinstance(data.get("diagnosis_summary"), dict): payload["diagnosis_summary"] = data.get("diagnosis_summary") if isinstance(data.get("followup_summary"), dict): payload["followup_summary"] = data.get("followup_summary") if isinstance(data.get("effective_preferences"), dict): payload["effective_preferences"] = data.get("effective_preferences") if isinstance(data.get("session_preference_overrides"), dict): payload["session_preference_overrides"] = data.get("session_preference_overrides") if isinstance(data.get("decision_summary"), dict): payload["decision_summary"] = data.get("decision_summary") for key in ["download_tasks", "download_history", "transfer_history", "ai_sample_worklist"]: if isinstance(data.get(key), dict): payload[key] = data.get(key) for key in ["best_candidate", "source_sample", "target", "identifier_preview", "recognize_result", "sample_removal_result"]: if isinstance(data.get(key), dict): payload[key] = data.get(key) for key in ["sources_checked", "alternatives", "available_sources", "blocked_sources"]: if isinstance(data.get(key), list): payload[key] = data.get(key) for key in [ "recommended_action", "follow_up_hint", "resolved_followup_action", "smart_mode", "decision_mode", "decision_reason", "detail_short_command", "decision_short_command", "plan_short_command", "confirm_short_command", "pansou_short_command", "hdhive_short_command", "mp_short_command", "auto_run_command", "confirm_command", "display_command", "sample_index", "resolved", "resolved_by_identifiers", "resolved_by_recognizer", "sample_removed", ]: if key in data: payload[key] = data.get(key) pending_p115 = session_state.get("pending_p115") if isinstance(session_state.get("pending_p115"), dict) else {} if pending_p115: payload["has_pending_p115"] = bool(pending_p115.get("has_pending")) command_summary = self._assistant_compact_command_summary(payload) if command_summary: payload.update(command_summary) return { "success": bool(response.get("success")), "message": response.get("message") or "", "data": payload, } def _assistant_interaction_compact_response(self, result: Dict[str, Any]) -> Dict[str, Any]: response = dict(result or {}) data = dict(response.get("data") or {}) session_state = dict(data.get("session_state") or {}) payload = { "protocol_version": "assistant.v1", "action": self._clean_text(data.get("action")) or "assistant_interaction", "ok": bool(data.get("ok")) if "ok" in data else bool(response.get("success")), "compact": True, "write_effect": data.get("write_effect") or self._assistant_write_effect_for_action(self._clean_text(data.get("action"))), "error_code": self._clean_text(data.get("error_code")) or ("" if response.get("success") else "assistant_error"), "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), "message_head": self._assistant_result_message_head(response.get("message")), "kind": self._clean_text(session_state.get("kind")), "stage": self._clean_text(session_state.get("stage")), "keyword": self._clean_text(session_state.get("keyword")), "target_path": self._clean_text(session_state.get("target_path")), "next_actions": data.get("next_actions") or session_state.get("suggested_actions") or [], "action_templates": data.get("action_templates") or [], } error_summary = self._assistant_error_summary( error_code=payload.get("error_code"), recommended_action=self._clean_text(data.get("recommended_action")), message_head=payload.get("message_head"), next_actions=payload.get("next_actions"), action_templates=payload.get("action_templates"), keyword=self._clean_text(session_state.get("keyword")), hash_value=self._clean_text(session_state.get("hash")), target=self._clean_text(data.get("target") or session_state.get("keyword")), ) if error_summary: payload["error_summary"] = error_summary if isinstance(data.get("score_summary"), dict): payload["score_summary"] = data.get("score_summary") if isinstance(data.get("decision_summary"), dict): payload["decision_summary"] = data.get("decision_summary") if isinstance(data.get("diagnosis_summary"), dict): payload["diagnosis_summary"] = data.get("diagnosis_summary") if isinstance(data.get("followup_summary"), dict): payload["followup_summary"] = data.get("followup_summary") if isinstance(data.get("scoring_policy"), dict): payload["scoring_policy"] = data.get("scoring_policy") if isinstance(data.get("effective_preferences"), dict): payload["effective_preferences"] = data.get("effective_preferences") if isinstance(data.get("session_preference_overrides"), dict): payload["session_preference_overrides"] = data.get("session_preference_overrides") if isinstance(data.get("insights"), dict): payload["insights"] = data.get("insights") if isinstance(data.get("recommend_handoff"), dict): payload["recommend_handoff"] = data.get("recommend_handoff") for key in ["download_tasks", "download_history", "transfer_history", "ai_sample_worklist"]: if isinstance(data.get(key), dict): payload[key] = data.get(key) for key in [ "provider", "source", "requested_source", "fallback_source", "media_type", "page", "total_pages", "selected_candidate", "selected_resource", "plan_id", "workflow", "recommended_action", "follow_up_hint", "resolved_followup_action", "smart_mode", "smart_plan_auto_selected", "smart_execute_auto_selected", "decision_mode", "decision_reason", "sample_index", "resolved", "resolved_by_identifiers", "resolved_by_recognizer", "sample_removed", "selected_index", "return_short_command", ]: if key in data: payload[key] = data.get(key) for key in ["best_candidate"]: if isinstance(data.get(key), dict): payload[key] = data.get(key) for key in ["sources_checked", "alternatives", "available_sources", "blocked_sources"]: if isinstance(data.get(key), list): payload[key] = data.get(key) if isinstance(data.get("preference_status"), dict): payload["preference_status"] = data.get("preference_status") payload["needs_onboarding"] = bool(data["preference_status"].get("needs_onboarding")) if isinstance(data.get("items"), list): payload["items"] = data.get("items") for key in ["items", "candidates", "resources"]: if isinstance(data.get(key), list): payload[f"{key}_count"] = len(data.get(key) or []) pending_p115 = session_state.get("pending_p115") if isinstance(session_state.get("pending_p115"), dict) else {} if pending_p115: payload["has_pending_p115"] = bool(pending_p115.get("has_pending")) command_summary = self._assistant_compact_command_summary(payload) if command_summary: payload.update(command_summary) return { "success": bool(response.get("success")), "message": response.get("message") or "", "data": payload, } def _assistant_workflow_plan_compact_data(self, plan_data: Dict[str, Any]) -> Dict[str, Any]: data = dict(plan_data or {}) session_state = dict(data.get("session_state") or {}) plan_id = self._clean_text(data.get("plan_id")) template = self._assistant_action_template( name="execute_plan", description="执行刚生成的 dry_run 计划", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", tool="agent_resource_officer_execute_plan", body={"plan_id": plan_id}, ) if plan_id else None return { "protocol_version": "assistant.v1", "action": "workflow_plan", "ok": bool(data.get("ok")), "compact": True, "session": self._clean_text(data.get("session") or session_state.get("session")) or "default", "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), "plan_id": plan_id, "workflow": self._clean_text(data.get("workflow")), "dry_run": True, "estimated_steps": data.get("estimated_steps") or 0, "ready_to_execute": bool(data.get("ready_to_execute")), "execute_plan_endpoint": data.get("execute_plan_endpoint"), "execute_plan_body": data.get("execute_plan_body") or {"plan_id": plan_id}, "plan_created_at": data.get("plan_created_at"), "plan_created_at_text": data.get("plan_created_at_text"), "preference_status": data.get("preference_status") or {}, "needs_onboarding": bool((data.get("preference_status") or {}).get("needs_onboarding")), "score_summary": data.get("score_summary") or {}, "decision_summary": data.get("decision_summary") or {}, "best_candidate": data.get("best_candidate") or {}, "sources_checked": data.get("sources_checked") or [], "available_sources": data.get("available_sources") or [], "blocked_sources": data.get("blocked_sources") or [], "effective_preferences": data.get("effective_preferences") or {}, "session_preference_overrides": data.get("session_preference_overrides") or {}, "smart_plan_auto_selected": bool(data.get("smart_plan_auto_selected")), "detail_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("detail_short_command")), "decision_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("decision_short_command")), "plan_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("plan_short_command")), "confirm_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("confirm_short_command")), "pansou_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("pansou_short_command")), "hdhive_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("hdhive_short_command")), "mp_short_command": self._clean_text(((data.get("decision_summary") or {}) if isinstance(data.get("decision_summary"), dict) else {}).get("mp_short_command")), "next_actions": ["execute_plan"] if plan_id else [], "action_templates": [template] if template else [], } def _format_assistant_sessions_text( self, *, kind: str = "", has_pending_p115: Optional[bool] = None, limit: int = 20, ) -> str: data = self._assistant_sessions_public_data( kind=kind, has_pending_p115=has_pending_p115, limit=limit, ) items = data.get("items") or [] if not items: return "当前没有活跃的 Agent影视助手 会话。" lines = [ f"当前活跃会话:{data.get('total') or 0} 个", "可直接用 assistant/session 查看单个会话详情,也可按 session_id 直接恢复最近计划。", ] for idx, item in enumerate(items, 1): line = f"{idx}. {item.get('session')} | {item.get('kind') or '-'} | {item.get('stage') or '-'}" if item.get("keyword"): line = f"{line} | {item.get('keyword')}" lines.append(line) detail_parts: List[str] = [] if item.get("target_path"): detail_parts.append(f"目录:{item.get('target_path')}") if item.get("updated_at_text"): detail_parts.append(f"更新:{item.get('updated_at_text')}") if item.get("has_pending_p115"): detail_parts.append("含待继续115任务") if item.get("has_pending_plan"): detail_parts.append("含待执行计划") if item.get("selected_title"): detail_parts.append(f"已选:{item.get('selected_title')}") if item.get("result_count"): detail_parts.append(f"结果:{item.get('result_count')}") if item.get("total_candidates"): detail_parts.append(f"候选:{item.get('total_candidates')}") if item.get("total_resources"): detail_parts.append(f"资源:{item.get('total_resources')}") if detail_parts: lines.append(" " + " | ".join(detail_parts)) return "\n".join(lines) def _clear_assistant_sessions( self, *, session: str = "", session_id: str = "", kind: str = "", has_pending_p115: Optional[bool] = None, stale_only: bool = False, all_sessions: bool = False, limit: int = 100, ) -> Dict[str, Any]: max_limit = min(max(1, self._safe_int(limit, 100)), 500) cleared_ids: List[str] = [] if self._clean_text(session_id) or self._clean_text(session): _, cache_key = self._normalize_assistant_session_ref(session=session, session_id=session_id) if cache_key in self._session_cache: self._session_cache.pop(cache_key, None) cleared_ids.append(cache_key) self._persist_relevant_sessions() return { "cleared_count": len(cleared_ids), "cleared_session_ids": cleared_ids, "limit": max_limit, } kind_filter = self._clean_text(kind) for current_session_id, payload in list((self._session_cache or {}).items()): if len(cleared_ids) >= max_limit: break if not str(current_session_id).startswith("assistant::"): continue current = dict(payload or {}) expired = self._is_session_expired(current) if stale_only and not expired: continue if not stale_only and expired: continue if not all_sessions: if kind_filter and self._clean_text(current.get("kind")) != kind_filter: continue if has_pending_p115 is not None: has_pending = bool(self._clean_text(((current.get("pending_p115") or {}).get("share_url")))) if has_pending != bool(has_pending_p115): continue if not kind_filter and has_pending_p115 is None and not stale_only: continue self._session_cache.pop(current_session_id, None) cleared_ids.append(str(current_session_id)) self._persist_relevant_sessions() return { "cleared_count": len(cleared_ids), "cleared_session_ids": cleared_ids, "limit": max_limit, } def _format_assistant_session_summary(self, session: str = "default") -> str: data = self._assistant_session_public_data(session=session) if not data.get("has_session"): return "\n".join([ "当前没有活跃会话。", "可直接调用 smart_entry 发起新操作,例如:", "1. text=盘搜搜索 大君夫人", "2. text=影巢搜索 蜘蛛侠", "3. text=链接 https://115cdn.com/s/xxxx path=/待整理", ]) lines = [ "当前会话状态", f"会话:{data.get('session')}", f"类型:{data.get('kind') or '-'}", f"阶段:{data.get('stage') or '-'}", ] if data.get("keyword"): lines.append(f"关键词:{data.get('keyword')}") if data.get("target_path"): lines.append(f"目录:{data.get('target_path')}") if data.get("updated_at_text"): lines.append(f"最近更新:{data.get('updated_at_text')}") if data.get("kind") == "assistant_pansou": lines.append(f"结果数:{data.get('result_count') or 0}") lines.append("下一步:调用 smart_pick,传入 choice=编号") elif data.get("kind") == "assistant_hdhive" and data.get("stage") == "candidate": lines.append(f"候选数:{data.get('total_candidates') or 0}") lines.append(f"页码:{data.get('page')}/{data.get('total_pages')}") lines.append("下一步:smart_pick 可传 choice=编号,或 action=详情 / 下一页") elif data.get("kind") == "assistant_hdhive" and data.get("stage") == "resource": selected = data.get("selected_candidate") or {} if selected.get("title"): lines.append(f"已选影片:{selected.get('title')} ({selected.get('year') or '-'})") lines.append(f"资源数:{data.get('total_resources') or 0}") if data.get("page") and data.get("total_pages"): lines.append(f"页码:{data.get('page')}/{data.get('total_pages')}") lines.append("下一步:smart_pick 可传 choice=资源编号,或 action=下一页") elif data.get("kind") == "assistant_p115_login": lines.append(f"扫码客户端:{data.get('client_type') or self._p115_client_type}") lines.append("下一步:调用 smart_entry,传入 text=检查115登录") pending = data.get("pending_p115") or {} if pending.get("has_pending"): lines.append("存在待继续的 115 任务") lines.append(f"任务:{pending.get('title')}") lines.append(f"待转目录:{pending.get('target_path')}") actions = data.get("suggested_actions") or [] if actions: lines.append("建议动作:" + " / ".join(str(item) for item in actions if item)) return "\n".join(lines) def _session_key_for_tool(self, session: str = "default") -> str: clean_session = self._clean_text(session) or "default" if clean_session.startswith("assistant::"): return clean_session return self._assistant_session_id(clean_session) def _execute_pending_p115_share( self, *, session_id: str, state: Dict[str, Any], trigger: str, ) -> Tuple[bool, str, Dict[str, Any]]: pending = dict((state or {}).get("pending_p115") or {}) share_url = self._clean_text(pending.get("share_url")) if not share_url: return False, "", {} target_path = self._clean_text(pending.get("target_path")) or self._p115_default_path transfer_ok, result, transfer_message = self._ensure_p115_service().transfer_share( url=share_url, access_code=self._clean_text(pending.get("access_code")), path=target_path, trigger=trigger, ) if transfer_ok: self._clear_pending_p115_share(session_id) message = "\n".join( [ "115 转存已完成", f"目录:{result.get('path') or target_path}", f"结果:{transfer_message or result.get('message') or 'success'}", ] ) return True, message, {"provider": "115", "result": result} failure_message = self._format_p115_transfer_failure( detail=transfer_message, target_path=target_path, ) current_state = self._load_session(session_id) or dict(state or {}) pending["retry_count"] = max(0, self._safe_int(pending.get("retry_count"), 0)) + 1 pending["last_attempt_at"] = int(time.time()) pending["last_error"] = failure_message current_state["pending_p115"] = pending if not current_state.get("kind"): current_state["kind"] = "assistant_p115_pending" current_state["stage"] = "pending_login" self._save_session(session_id, current_state) return False, failure_message, {"provider": "115", "result": result} async def _resume_pending_p115_share( self, request: Request, body: Dict[str, Any], *, session_id: str, state: Dict[str, Any], ) -> Tuple[bool, str, Dict[str, Any]]: return self._execute_pending_p115_share( session_id=session_id, state=state, trigger="Agent影视助手 115 登录后自动继续", ) def _format_p115_status_summary(self, *, title: str = "115 当前状态") -> str: status = self._p115_status_snapshot() lines = [ title, f"可用状态:{'可用' if status.get('ready') else '待修复'}", f"默认目录:{status.get('default_target_path') or self._p115_default_path}", f"扫码客户端:{status.get('client_type') or self._p115_client_type}", ] if status.get("direct_source"): lines.append(f"直转来源:{status.get('direct_source')}") elif status.get("helper_ready"): lines.append("直转来源:P115StrmHelper") if status.get("cookie_mode") == "client_cookie": lines.append("当前会话:已保存扫码会话") elif status.get("cookie_mode") == "invalid_cookie": lines.append("当前会话:已配置但看起来不是扫码会话") else: lines.append("当前会话:复用 115 助手客户端") if status.get("message") and not status.get("ready"): lines.append(f"详情:{status.get('message')}") lines.append(self._format_p115_next_actions(status)) return "\n".join(lines) def _format_p115_help_text(self) -> str: status = self._p115_status_snapshot() final_path = status.get("default_target_path") or self._p115_default_path lines = [ "115 使用帮助", f"当前状态:{'可用' if status.get('ready') else '待登录/待修复'}", f"默认目录:{final_path}", "如果 115 转存因登录问题失败,我会记住这次任务;扫码成功后回复 检查115登录,会自动继续执行。", "常用示例:", f"1. 链接 https://115cdn.com/s/xxxx path={final_path}", "2. 影巢搜索 蜘蛛侠", "3. 盘搜搜索 大君夫人", "4. 115登录", "5. 检查115登录", "6. 115状态", "7. 继续115任务 / 取消115任务", self._format_p115_next_actions(status), ] return "\n".join(lines) def _format_assistant_help_text(self, session: str = "default") -> str: session_name = self._clean_text(session) or "default" lines = [ "Agent影视助手 使用帮助", f"当前会话:{session_name}", "推荐优先使用原生 Tool:agent_resource_officer_smart_entry 与 agent_resource_officer_smart_pick。", "smart_entry 常用示例:", "1. text=盘搜搜索 大君夫人", "2. text=搜索 大君夫人 默认走当前启用源顺序", "3. text=影巢搜索 蜘蛛侠", "4. text=MP搜索 蜘蛛侠 或 PT搜索 蜘蛛侠", "5. text=115登录", "6. text=检查115登录", "7. text=链接 https://115cdn.com/s/xxxx path=/待整理", "8. text=链接 https://pan.quark.cn/s/xxxx 位置=分享", "9. text=盘搜搜索 蜘蛛侠 / 影巢搜索 蜘蛛侠 先拿云盘结果;text=下载 蜘蛛侠 会先生成 3 个 MP/PT 下载方案", "10. text=下载任务;暂停下载 1 / 恢复下载 1 / 删除下载 1 会先生成计划", "11. text=站点状态;下载器状态 用于排查 PT 搜索/下载环境", "12. text=记录 片名 用于判断资源是否提交过下载并进入整理流程", "13. text=状态 片名 一次查看下载任务、下载历史和入库历史", "14. text=流媒体推荐 5月上新的大作 / 本月热门电影 / 近期热门剧", "15. text=识别 片名 使用 MoviePilot 原生识别确认 TMDB/Douban/IMDB 信息", "16. text=订阅列表;刷新订阅 1 / 暂停订阅 1 / 恢复订阅 1 / 删除订阅 1 会先生成计划", "17. text=入库记录;入库失败 片名 用于判断下载后是否已经整理落库", "18. text=执行计划 执行当前会话最近待执行计划;text=执行 plan-xxxx 精确执行指定计划", "19. text=偏好 / 保存偏好 4K 杜比 HDR 中字 全集 做种>=3 影巢积分20 不自动入库 / 重置偏好", "20. text=后续 / 最近 / 入库 片名 / 诊断 片名 是更省 token 的本地/PT 跟踪短命令", "21. text=跟进 / 跟进 片名 是统一跟进入口:有已执行计划时自动跟执行后状态,有片名时直接看生命周期", "smart_pick 常用示例:", "1. choice=1", "2. action=详情 仅用于云盘/影巢资源详情", "3. action=下一页", "MP 搜索结果里,choice=1 / text=下载1 / text=计划1 都会直接下载;如需三方案下载请直接发 text=下载 片名。", "MP 搜索结果里,action=最佳 仅用于评分推荐;普通聊天不要把 PT 编号改写成详情。", "MP 搜索结果里,text=下载最佳 会按当前最高分候选生成下载计划,不会静默下载。", "说明:同一个 session 会自动串起候选列表、资源列表、115 待任务与扫码续跑。", self._format_p115_next_actions(self._p115_status_snapshot()), ] pending_summary = self._pending_p115_summary(self._load_session(self._assistant_session_id(session_name)) or {}) if pending_summary: lines.extend(["", pending_summary]) return "\n".join(line for line in lines if line) def _assistant_capabilities_public_data(self) -> Dict[str, Any]: return { "version": self.plugin_version, "defaults": { "pansou_enabled": self._pansou_enabled, "hdhive_path": self._hdhive_default_path, "p115_path": self._p115_default_path, "quark_path": self._quark_default_path, "p115_client_type": self._p115_client_type, "mp_pt_enabled": self._mp_pt_enabled, "hdhive_candidate_page_size": self._hdhive_candidate_page_size, "hdhive_resource_enabled": self._hdhive_resource_enabled, "hdhive_max_unlock_points": self._hdhive_max_unlock_points, "hdhive_checkin_enabled": self._hdhive_checkin_enabled, "hdhive_checkin_gambler_mode": self._hdhive_checkin_gambler_mode, "pt_min_seeders": self._default_assistant_preferences().get("pt_min_seeders"), "auto_ingest": self._default_assistant_preferences().get("auto_ingest_enabled"), "auto_ingest_enabled": self._default_assistant_preferences().get("auto_ingest_enabled"), "auto_ingest_score_threshold": self._default_assistant_preferences().get("auto_ingest_score_threshold"), "confirm_score_threshold": self._default_assistant_preferences().get("confirm_score_threshold"), }, "smart_entry": { "supports_text": True, "supports_structured_fields": True, "modes": ["mp", "pansou", "hdhive"], "actions": [ "assistant_help", "p115_qrcode_start", "p115_qrcode_check", "p115_status", "p115_help", "p115_pending", "p115_resume", "p115_cancel", "hdhive_checkin", "hdhive_checkin_history", "mp_media_detail", "mp_download_tasks", "mp_download_history", "mp_lifecycle_status", "mp_download_control", "mp_downloaders", "mp_sites", "mp_subscribes", "mp_subscribe_control", "mp_transfer_history", "mp_download", "mp_download_best", "mp_subscribe", "mp_subscribe_search", "mp_recommendations", "streaming_recommend", "execute_plan", "plans_list", "plans_clear", "scoring_policy", "preferences_get", "preferences_save", "preferences_reset", ], "structured_fields": [ "session", "session_id", "path", "mode", "keyword", "url", "access_code", "media_type", "year", "client_type", "is_gambler", "action", "plan_id", "status", "hash", "name", "site_name", "subscribe_id", "subscribe_name", "downloader", "download_control", "subscribe_control", "delete_files", "page", "compact", ], }, "assistant_preferences": { "fields": ["session", "session_id", "user_key", "preferences", "reset", "compact"], "description": "智能体片源偏好画像:云盘与 PT 分源评分都会读取这里;无偏好时建议先完成一次偏好询问。", }, "scoring_policy": self._assistant_scoring_policy_public_data(), "smart_pick": { "fields": ["session", "session_id", "choice", "action", "path", "compact"], "actions": ["detail", "next_page"], }, "assistant_session": { "fields": ["session", "session_id", "compact"], "description": "compact=true 时返回低 token 会话快照,不嵌套完整 session_state。", }, "assistant_capabilities": { "fields": ["compact"], "description": "compact=true 时返回低 token 能力清单,不嵌套完整 session_state。", }, "assistant_readiness": { "fields": ["compact"], "description": "compact=true 时返回低 token 就绪状态,不嵌套完整 session_state。", }, "assistant_sessions": { "fields": ["kind", "has_pending_p115", "compact", "limit"], "description": "compact=true 时返回低 token 会话列表,不嵌套 default session_state。", }, "assistant_history": { "fields": ["session", "session_id", "compact", "limit"], "description": "compact=true 时返回低 token 执行历史,不嵌套 default session_state。", }, "assistant_action": { "fields": [ "name", "session", "session_id", "choice", "path", "keyword", "media_type", "year", "url", "access_code", "client_type", "source", "status", "hash", "target", "name", "site_name", "subscribe_id", "subscribe_name", "control", "subscribe_control", "downloader", "delete_files", "kind", "has_pending_p115", "stale_only", "all_sessions", "limit", "page", "plan_id", "prefer_unexecuted", "compact", ], "description": "compact=true 时返回低 token 单动作摘要,不嵌套完整 session_state。", }, "assistant_actions": { "fields": [ "actions", "session", "session_id", "stop_on_error", "include_raw_results", "compact", ], "description": "compact=true 时返回低 token 批量执行摘要,不嵌套完整 session_state。", }, "assistant_workflow": self._assistant_workflow_catalog(), "assistant_plan_execute": { "fields": [ "plan_id", "session", "session_id", "prefer_unexecuted", "stop_on_error", "include_raw_results", "compact", ], "description": "compact=true 时返回低 token 计划执行摘要,不嵌套完整 session_state。", }, "assistant_plans": { "fields": [ "session", "session_id", "executed", "include_actions", "compact", "limit", ], "description": "compact=true 时返回低 token 计划列表,不嵌套 default session_state。", }, "assistant_plans_clear": { "fields": [ "plan_id", "session", "session_id", "executed", "all_plans", "limit", ], }, "assistant_recover": { "fields": [ "session", "session_id", "execute", "prefer_unexecuted", "stop_on_error", "include_raw_results", "compact", "limit", ], "description": "单入口恢复协议:不传 session 时自动挑选最值得恢复的会话或计划;execute=true 时直接执行推荐动作;compact=true 可返回低 token 回执。", }, "assistant_maintain": { "fields": [ "execute", "limit", ], "description": "低风险维护入口:execute=false 只返回建议;execute=true 清理过期会话和已执行计划,不清理待执行计划。", }, "assistant_request_templates": { "fields": [ "limit", ], "description": "轻量请求模板入口:返回外部智能体常用 assistant 请求模板,适合缓存为调用说明。", }, "session_tools": [ "assistant/pulse", "assistant/startup", "assistant/maintain", "assistant/toolbox", "assistant/request_templates", "assistant/selfcheck", "assistant/readiness", "assistant/history", "assistant/action", "assistant/actions", "assistant/workflow", "assistant/preferences", "assistant/plan/execute", "assistant/plans", "assistant/plans/clear", "assistant/recover", "assistant/sessions", "assistant/sessions/clear", "assistant/session", "assistant/session/clear", ], "response_envelope": { "fields": [ "protocol_version", "action", "ok", "session", "session_id", "session_state", "next_actions", "action_templates", ], "description": "assistant/route 与 assistant/pick 返回的 data 中会统一附带当前会话状态、建议下一步动作与可直接调用的动作模板,上层智能体可直接按结构化字段继续编排。", }, "agent_tools": [ "agent_resource_officer_capabilities", "agent_resource_officer_startup", "agent_resource_officer_maintain", "agent_resource_officer_pulse", "agent_resource_officer_toolbox", "agent_resource_officer_request_templates", "agent_resource_officer_selfcheck", "agent_resource_officer_readiness", "agent_resource_officer_feishu_health", "agent_resource_officer_history", "agent_resource_officer_execute_action", "agent_resource_officer_execute_actions", "agent_resource_officer_execute_plan", "agent_resource_officer_plans", "agent_resource_officer_plans_clear", "agent_resource_officer_recover", "agent_resource_officer_run_workflow", "agent_resource_officer_preferences", "agent_resource_officer_help", "agent_resource_officer_smart_entry", "agent_resource_officer_smart_pick", "agent_resource_officer_sessions", "agent_resource_officer_sessions_clear", "agent_resource_officer_session_state", "agent_resource_officer_session_clear", ], } def _assistant_capabilities_compact_data(self, capabilities_data: Dict[str, Any]) -> Dict[str, Any]: data = dict(capabilities_data or {}) workflow_catalog = dict(data.get("assistant_workflow") or {}) workflows = [ self._clean_text(item.get("name")) for item in workflow_catalog.get("workflows") or [] if isinstance(item, dict) and self._clean_text(item.get("name")) ] compact_endpoints = [ "assistant/capabilities", "assistant/startup", "assistant/maintain", "assistant/request_templates", "assistant/readiness", "assistant/recover", "assistant/session", "assistant/sessions", "assistant/history", "assistant/actions", "assistant/workflow", "assistant/preferences", "assistant/plan/execute", "assistant/plans", ] return { "protocol_version": "assistant.v1", "action": "capabilities", "ok": True, "compact": True, "version": data.get("version"), "defaults": data.get("defaults") or {}, "smart_entry_modes": (data.get("smart_entry") or {}).get("modes") or [], "smart_entry_actions": (data.get("smart_entry") or {}).get("actions") or [], "smart_pick_actions": (data.get("smart_pick") or {}).get("actions") or [], "workflows": workflows, "request_templates": bool(data.get("assistant_request_templates")), "scoring_policy": data.get("scoring_policy") or {}, "recommended_start": [ "assistant/pulse", "assistant/startup", "assistant/maintain", "assistant/selfcheck", "assistant/toolbox", "assistant/request_templates", "assistant/readiness?compact=true", ], "compact_endpoints": compact_endpoints, "agent_tools": data.get("agent_tools") or [], "next_actions": ["assistant_startup", "assistant_maintain", "assistant_readiness", "smart_entry", "assistant_workflow"], } def _format_assistant_capabilities_text(self) -> str: data = self._assistant_capabilities_public_data() defaults = data.get("defaults") or {} lines = [ "Agent影视助手 能力说明", f"版本:{data.get('version')}", "推荐上层调用顺序:", "1. 先看 capabilities 或 assistant/startup", "2. 如需恢复会话,可先看 assistant/sessions", "3. 再调用 smart_entry", "4. 之后用 assistant/session 或 session_state 判断下一步", "5. 最后再调用 smart_pick 或 session_clear", "默认目录:", f"- 影巢:{defaults.get('hdhive_path')}", f"- 115:{defaults.get('p115_path')}", f"- 夸克:{defaults.get('quark_path')}", f"- 115 客户端:{defaults.get('p115_client_type')}", "搜索源总开关:", f"- 盘搜:{'开启' if defaults.get('pansou_enabled') else '关闭'}", f"- 影巢:{'开启' if defaults.get('hdhive_resource_enabled') else '关闭'}", f"- MP/PT:{'开启' if defaults.get('mp_pt_enabled') else '关闭'}", f"影巢资源入口:{'开启' if defaults.get('hdhive_resource_enabled') else '关闭'};单资源积分上限:{defaults.get('hdhive_max_unlock_points')} 分(0 表示不限制)", "默认评分策略:", f"- PT 最低做种数:{defaults.get('pt_min_seeders')}", f"- 建议确认分数线:{defaults.get('confirm_score_threshold')}", f"- 自动入库:{'开启' if defaults.get('auto_ingest_enabled') else '关闭'};自动入库分数线:{defaults.get('auto_ingest_score_threshold')}", "启动聚合包:assistant/startup,一次返回 pulse、自检、核心工具、端点和恢复建议,适合外部智能体开场调用", "轻量启动探针:assistant/pulse,返回版本、关键服务状态与最佳恢复建议,适合外部智能体每次开场调用", "轻量工具清单:assistant/toolbox,返回推荐工具、端点、工作流和命令示例,适合外部智能体初始化系统提示", "轻量协议自检:assistant/selfcheck,返回 compact 模板、布尔解析和低 token 入口健康状态", "启动探针:assistant/readiness,可直接判断外部智能体是否可以开始调用;compact=true 可减少嵌套回执", "执行历史:assistant/history,可查看最近 action/workflow 的成功状态和摘要;compact=true 可减少嵌套回执", "smart_entry 结构化字段:session / session_id / path / mode / keyword / url / access_code / media_type / year / client_type / action / plan_id", "smart_entry 结构化模式:mp / pansou / hdhive", "smart_entry 动作:assistant_help / p115_qrcode_start / p115_qrcode_check / p115_status / p115_help / p115_pending / p115_resume / p115_cancel", "smart_entry 与 smart_pick 支持 compact=true,可减少搜索与选择链路嵌套回执", "smart_pick 字段:session / session_id / choice / action / path / compact", "smart_pick 动作:detail / next_page", "动作执行入口:assistant/action,可直接执行 action_templates 里的 name + body;compact=true 可减少嵌套回执", "批量动作入口:assistant/actions,可一次执行多步 action_body;compact=true 可减少嵌套回执", "预设工作流入口:assistant/workflow,可用 pansou_search / pansou_transfer / hdhive_candidates / hdhive_unlock / share_transfer / p115_status 等短参数场景;compact=true 可减少嵌套回执", "计划执行入口:assistant/plan/execute,可执行 dry_run 返回的 plan_id;compact=true 可减少嵌套回执", "自然语言计划确认:smart_entry 支持“执行计划”和“执行 plan-xxxx”,用于确认已生成的下载、订阅或控制计划", "计划管理入口:assistant/plans 与 assistant/plans/clear,可查询或清理 dry_run 保存的计划;compact=true 可减少嵌套回执", "单入口恢复:assistant/recover,可自动选择最值得恢复的会话或计划;execute=true 时直接执行推荐动作;compact=true 可减少回执字段", "统一回执字段:protocol_version / action / ok / session / session_id / session_state / next_actions / action_templates", ] return "\n".join(lines) def _assistant_readiness_public_data(self) -> Dict[str, Any]: p115_status = self._p115_status_snapshot() sessions = self._assistant_sessions_public_data(limit=10) warnings: List[str] = [] if not self._enabled: warnings.append("插件未启用") if not self._pansou_enabled: warnings.append("盘搜已关闭,普通搜索和盘搜补查不会再走盘搜") if not self._hdhive_resource_enabled: warnings.append("影巢资源搜索/解锁已关闭,外部智能体应改用 MP 搜索或盘搜") if not self._mp_pt_enabled: warnings.append("MP/PT 原生搜索/下载已关闭,外部智能体不应再调用 MP搜索、PT搜索或下载链") if not self._hdhive_api_key: warnings.append("影巢 API Key 未配置,影巢相关工作流不可用") if not p115_status.get("ready"): warnings.append("115 当前不可用,需要先扫码或修复执行层") if not self._quark_cookie: warnings.append("夸克 Cookie 未配置,夸克转存可能需要先刷新") pansou_host = "" try: pansou_host = self._clean_text(urlparse(self._pansou_base_url).hostname or "") except Exception: pansou_host = "" if pansou_host in {"127.0.0.1", "localhost", "::1"}: warnings.append("盘搜地址为本机回环地址;跨机器部署时请确认 PanSou 服务运行在 MoviePilot 所在机器,或把地址改成 MoviePilot 可访问的实际地址") ready_for_external_agent = bool(self._enabled) pending_plans = self._assistant_plans_public_data(executed=False, limit=5) pending_plan_templates = [ self._assistant_action_template( name="execute_plan", description="执行待处理计划", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", tool="agent_resource_officer_execute_plan", body={ "plan_id": item.get("plan_id"), "session": item.get("session"), "session_id": item.get("session_id"), "prefer_unexecuted": True, }, ) for item in (pending_plans.get("items") or []) if isinstance(item, dict) and self._clean_text(item.get("plan_id")) ] return { "version": self.plugin_version, "enabled": self._enabled, "ready_for_external_agent": ready_for_external_agent, "can_start": ready_for_external_agent, "services": { "p115": p115_status, "hdhive": { "configured": bool(self._hdhive_api_key), "base_url": self._hdhive_base_url, "default_path": self._hdhive_default_path, }, "quark": { "configured": bool(self._quark_cookie), "default_path": self._quark_default_path, "auto_import_cookiecloud": self._quark_auto_import_cookiecloud, }, "pansou": { "base_url": self._pansou_base_url, "localhost_warning": pansou_host in {"127.0.0.1", "localhost", "::1"}, }, }, "active_sessions": { "total": sessions.get("total") or 0, "preview": sessions.get("items") or [], }, "saved_plans": { "total": len(self._workflow_plans or {}), "pending": len(pending_plans.get("items") or []), "pending_preview": pending_plans.get("items") or [], "action_templates": pending_plan_templates, }, "recovery": ( { "mode": "resume_saved_plan", "reason": "当前存在待执行计划,可直接恢复", "can_resume": True, "recommended_action": self._clean_text((pending_plan_templates[0] or {}).get("name")) if pending_plan_templates else "", "recommended_tool": self._clean_text((pending_plan_templates[0] or {}).get("tool")) if pending_plan_templates else "", "action_template": pending_plan_templates[0] if pending_plan_templates else None, "alternatives": [ self._clean_text(item.get("name")) for item in pending_plan_templates[:5] if self._clean_text(item.get("name")) ], } if pending_plan_templates else { "mode": "start_new", "reason": "当前没有待恢复计划,可直接开始新任务", "can_resume": False, "recommended_action": "", "recommended_tool": "", "action_template": None, "alternatives": [], } ), "recommended_entrypoints": [ "GET /api/v1/plugin/AgentResourceOfficer/assistant/startup", "GET /api/v1/plugin/AgentResourceOfficer/assistant/readiness", "GET /api/v1/plugin/AgentResourceOfficer/assistant/capabilities", "POST /api/v1/plugin/AgentResourceOfficer/assistant/workflow", "POST /api/v1/plugin/AgentResourceOfficer/assistant/actions", "POST /api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", "POST /api/v1/plugin/AgentResourceOfficer/assistant/route", ], "recommended_tools": [ "agent_resource_officer_startup", "agent_resource_officer_readiness", "agent_resource_officer_feishu_health", "agent_resource_officer_run_workflow", "agent_resource_officer_execute_actions", "agent_resource_officer_execute_plan", "agent_resource_officer_smart_entry", ], "warnings": warnings, "suggested_first_call": { "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "body": { "name": "pansou_search", "session": "external-agent-demo", "keyword": "片名", }, }, } def _assistant_readiness_compact_data(self, readiness_data: Dict[str, Any]) -> Dict[str, Any]: data = dict(readiness_data or {}) services = dict(data.get("services") or {}) p115 = dict(services.get("p115") or {}) hdhive = dict(services.get("hdhive") or {}) quark = dict(services.get("quark") or {}) recovery = dict(data.get("recovery") or {}) saved_plans = dict(data.get("saved_plans") or {}) template = recovery.get("action_template") if isinstance(recovery.get("action_template"), dict) else None return { "protocol_version": "assistant.v1", "action": "readiness", "ok": bool(data.get("can_start")), "compact": True, "version": data.get("version"), "enabled": bool(data.get("enabled")), "can_start": bool(data.get("can_start")), "services": { "p115_ready": bool(p115.get("ready")), "hdhive_configured": bool(hdhive.get("configured")), "quark_configured": bool(quark.get("configured")), }, "active_sessions_total": (data.get("active_sessions") or {}).get("total") or 0, "saved_plans_total": saved_plans.get("total") or 0, "saved_plans_pending": saved_plans.get("pending") or 0, "recovery": { "mode": self._clean_text(recovery.get("mode")), "can_resume": bool(recovery.get("can_resume")), "recommended_action": self._clean_text(recovery.get("recommended_action")), "recommended_tool": self._clean_text(recovery.get("recommended_tool")), "reason": self._clean_text(recovery.get("reason")), }, "warnings": data.get("warnings") or [], "next_actions": [ item for item in [ recovery.get("recommended_action") if recovery.get("can_resume") else "", "assistant_workflow", "smart_entry", ] if item ], "action_templates": [template] if template else [], } def _format_assistant_readiness_text(self) -> str: data = self._assistant_readiness_public_data() services = data.get("services") or {} p115 = services.get("p115") or {} hdhive = services.get("hdhive") or {} quark = services.get("quark") or {} lines = [ "Agent影视助手 启动就绪", f"版本:{data.get('version')}", f"插件:{'已启用' if data.get('enabled') else '未启用'}", f"外部智能体:{'可以启动' if data.get('can_start') else '暂不可启动'}", f"115:{'可用' if p115.get('ready') else '不可用'}", f"影巢:{'已配置' if hdhive.get('configured') else '未配置'}", f"夸克:{'已配置' if quark.get('configured') else '未配置'}", f"活跃会话:{(data.get('active_sessions') or {}).get('total') or 0}", f"待执行计划:{(data.get('saved_plans') or {}).get('pending') or 0}", "推荐入口:assistant/workflow 或 assistant/actions", ] warnings = data.get("warnings") or [] if warnings: lines.append("提示:" + ";".join(str(item) for item in warnings if item)) return "\n".join(lines) def _assistant_pulse_public_data(self) -> Dict[str, Any]: p115_status = self._p115_status_snapshot() recovery_data = self._assistant_recover_public_data(limit=10) recovery_data.update({ "action": "recover", "ok": True, "execute_requested": False, "executed": False, }) recovery_compact = self._assistant_recover_response_data(recovery_data, compact=True) warnings: List[str] = [] if not self._enabled: warnings.append("插件未启用") if not self._hdhive_api_key: warnings.append("影巢 API Key 未配置") if not p115_status.get("ready"): warnings.append("115 当前不可用") if not self._quark_cookie: warnings.append("夸克 Cookie 未配置") return { "protocol_version": "assistant.v1", "action": "pulse", "ok": bool(self._enabled), "version": self.plugin_version, "enabled": self._enabled, "can_start": bool(self._enabled), "services": { "p115_ready": bool(p115_status.get("ready")), "p115_direct_ready": bool(p115_status.get("direct_ready")), "hdhive_configured": bool(self._hdhive_api_key), "quark_configured": bool(self._quark_cookie), }, "warnings": warnings, "session": recovery_compact.get("session"), "session_id": recovery_compact.get("session_id"), "recovery": recovery_compact.get("recovery") or {}, "selected_session": recovery_compact.get("selected_session"), "next_actions": recovery_compact.get("next_actions") or [], "action_templates": recovery_compact.get("action_templates") or [], "recommended_endpoints": { "startup": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", "selfcheck": "/api/v1/plugin/AgentResourceOfficer/assistant/selfcheck", "toolbox": "/api/v1/plugin/AgentResourceOfficer/assistant/toolbox", "recover": "/api/v1/plugin/AgentResourceOfficer/assistant/recover?compact=true", "workflow": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "actions": "/api/v1/plugin/AgentResourceOfficer/assistant/actions", }, } def _format_assistant_pulse_text(self) -> str: data = self._assistant_pulse_public_data() services = data.get("services") or {} recovery = data.get("recovery") or {} lines = [ "Agent影视助手 轻量启动状态", f"版本:{data.get('version')}", f"插件:{'已启用' if data.get('enabled') else '未启用'}", f"115:{'可用' if services.get('p115_ready') else '不可用'}", f"影巢:{'已配置' if services.get('hdhive_configured') else '未配置'}", f"夸克:{'已配置' if services.get('quark_configured') else '未配置'}", f"恢复模式:{recovery.get('mode') or 'unknown'}", ] if recovery.get("recommended_action"): lines.append(f"推荐动作:{recovery.get('recommended_action')}") warnings = data.get("warnings") or [] if warnings: lines.append("提示:" + ";".join(str(item) for item in warnings if item)) return "\n".join(lines) def _assistant_maintenance_snapshot(self, limit: int = 100) -> Dict[str, Any]: max_limit = min(max(1, self._safe_int(limit, 100)), 500) pending_plan_count = sum( 1 for item in (self._workflow_plans or {}).values() if isinstance(item, dict) and not bool(item.get("executed")) ) executed_plan_count = sum( 1 for item in (self._workflow_plans or {}).values() if isinstance(item, dict) and bool(item.get("executed")) ) stale_session_count = sum( 1 for item in (self._session_cache or {}).values() if isinstance(item, dict) and self._is_session_expired(item) ) action_templates = [ self._assistant_action_template( name="clear_stale_sessions", description="清理过期 assistant 会话,不影响仍有效的当前会话", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/sessions/clear", tool="agent_resource_officer_sessions_clear", body={"stale_only": True, "limit": max_limit}, ), self._assistant_action_template( name="clear_executed_plans", description="清理已执行的保存计划,不影响待执行计划", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/plans/clear", tool="agent_resource_officer_plans_clear", body={"executed": True, "limit": max_limit}, ), ] recommended_actions = [ item.get("name") for item in action_templates if ( (item.get("name") == "clear_stale_sessions" and stale_session_count > 0) or (item.get("name") == "clear_executed_plans" and executed_plan_count > 0) ) ] return { "active_sessions": len(self._session_cache or {}), "stale_sessions": stale_session_count, "saved_plans_total": len(self._workflow_plans or {}), "saved_plans_pending": pending_plan_count, "saved_plans_executed": executed_plan_count, "recommended_actions": recommended_actions, "action_templates": action_templates, "safe_to_execute": bool(recommended_actions), "dry_run_method": "GET", "execute_method": "POST", "execute_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", "execute_body": {"execute": True, "limit": max_limit}, "execution_note": "GET 只返回 dry-run;只有 POST execute=true 会实际清理,并写入 assistant/history。", "limit": max_limit, } def _assistant_maintain_public_data(self, execute: bool = False, limit: int = 100) -> Dict[str, Any]: before = self._assistant_maintenance_snapshot(limit=limit) executed_actions: List[Dict[str, Any]] = [] if execute: if before.get("stale_sessions", 0) > 0: result = self._clear_assistant_sessions(stale_only=True, limit=before.get("limit") or limit) executed_actions.append({ "name": "clear_stale_sessions", "success": True, "removed": result.get("cleared_count") or 0, }) if before.get("saved_plans_executed", 0) > 0: result = self._clear_workflow_plans(executed=True, limit=before.get("limit") or limit) executed_actions.append({ "name": "clear_executed_plans", "success": bool(result.get("ok")), "removed": result.get("removed") or 0, }) after = self._assistant_maintenance_snapshot(limit=limit) return { "protocol_version": "assistant.v1", "action": "maintain", "ok": True, "compact": True, "version": self.plugin_version, "execute_requested": bool(execute), "executed": bool(executed_actions), "executed_actions": executed_actions, "before": before, "after": after, "next_actions": after.get("recommended_actions") or [], "action_templates": after.get("action_templates") or [], } def _format_assistant_maintain_text(self, data: Optional[Dict[str, Any]] = None) -> str: payload = data or self._assistant_maintain_public_data(execute=False) before = payload.get("before") or {} after = payload.get("after") or before lines = [ "Agent影视助手 低风险维护", f"版本:{payload.get('version')}", f"执行:{'是' if payload.get('execute_requested') else '否'}", "维护前:过期会话 {stale_sessions};已执行计划 {saved_plans_executed};待执行计划 {saved_plans_pending}".format(**before), "维护后:过期会话 {stale_sessions};已执行计划 {saved_plans_executed};待执行计划 {saved_plans_pending}".format(**after), ] executed_actions = payload.get("executed_actions") or [] if executed_actions: lines.append("已执行:" + " / ".join(f"{item.get('name')}({item.get('removed')})" for item in executed_actions)) recommended = after.get("recommended_actions") or [] if recommended: lines.append("仍建议:" + " / ".join(str(item) for item in recommended if item)) return "\n".join(lines) def _assistant_startup_public_data(self) -> Dict[str, Any]: pulse = self._assistant_pulse_public_data() selfcheck = self._assistant_selfcheck_public_data() toolbox = self._assistant_toolbox_public_data() maintenance = self._assistant_maintenance_snapshot(limit=100) request_templates = self._assistant_request_templates_public_data(limit=100) tools = toolbox.get("tools") or {} endpoints = toolbox.get("endpoints") or {} key_names = [ "startup", "maintain", "pulse", "selfcheck", "request_templates", "recover", "workflow", "route", "pick", "execute_action", "execute_actions", ] key_tools = {name: tools.get(name) for name in key_names if tools.get(name)} key_endpoints = { name: endpoints.get(name) for name in ["startup", "maintain", "pulse", "selfcheck", "request_templates", "recover", "workflow", "action", "actions", "route", "pick"] if endpoints.get(name) } recovery = pulse.get("recovery") or {} recommended_templates_recipe = "continue" if bool(recovery.get("can_resume")) else "bootstrap" recommended_templates_reason = ( "检测到可恢复会话,优先读取继续会话流程。" if recommended_templates_recipe == "continue" else "未检测到必须恢复的会话,优先读取安全启动流程。" ) return { "protocol_version": "assistant.v1", "action": "startup", "ok": bool(pulse.get("can_start")) and bool(selfcheck.get("ok")), "compact": True, "version": self.plugin_version, "services": pulse.get("services") or {}, "warnings": pulse.get("warnings") or [], "session": pulse.get("session"), "session_id": pulse.get("session_id"), "recovery": pulse.get("recovery") or {}, "selected_session": pulse.get("selected_session"), "action_templates": pulse.get("action_templates") or [], "maintenance": maintenance, "selfcheck": { "ok": bool(selfcheck.get("ok")), "checks": selfcheck.get("checks") or {}, }, "defaults": toolbox.get("defaults") or {}, "startup_order": toolbox.get("startup_order") or [], "tools": key_tools, "endpoints": key_endpoints, "workflows": [item.get("name") for item in (toolbox.get("workflows") or []) if item.get("name")], "actions": toolbox.get("actions") or [], "command_examples": (toolbox.get("command_examples") or [])[:6], "request_templates": request_templates, "request_templates_schema_version": self.request_templates_schema_version, "recommended_request_templates": self._assistant_recommended_request_templates_data( recipe=recommended_templates_recipe, reason=recommended_templates_reason, ), "next_actions": pulse.get("next_actions") or ["assistant_recover", "assistant_workflow", "smart_entry"], "recommended_endpoints": key_endpoints, } def _assistant_recommended_request_templates_data(self, recipe: str = "bootstrap", reason: str = "") -> Dict[str, Any]: recipe_name = self._clean_text(recipe) or "bootstrap" return { "recipe": recipe_name, "reason": self._clean_text(reason), "include_templates": False, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/request_templates", "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/request_templates?apikey={MP_API_TOKEN}", "tool": "agent_resource_officer_request_templates", "tool_args": { "recipe": recipe_name, "include_templates": False, }, } def _format_assistant_startup_text(self) -> str: data = self._assistant_startup_public_data() services = data.get("services") or {} recovery = data.get("recovery") or {} checks = (data.get("selfcheck") or {}).get("checks") or {} failed = [key for key, value in checks.items() if not value] lines = [ "Agent影视助手 启动聚合包", f"版本:{data.get('version')}", f"可启动:{'是' if data.get('ok') else '否'}", f"115:{'可用' if services.get('p115_ready') else '不可用'};影巢:{'已配' if services.get('hdhive_configured') else '未配'};夸克:{'已配' if services.get('quark_configured') else '未配'}", f"自检:{'通过' if not failed else '失败 ' + ', '.join(failed)}", f"恢复模式:{recovery.get('mode') or 'unknown'}", f"可执行模板:{len(data.get('action_templates') or [])} 个", "状态:活跃会话 {active_sessions};过期会话 {stale_sessions};保存计划 {saved_plans_total};待执行计划 {saved_plans_pending};已执行计划 {saved_plans_executed}".format(**(data.get("maintenance") or {})), "下一步:优先按 recovery 建议执行;没有待恢复任务时使用 workflow 或 smart_entry", ] warnings = data.get("warnings") or [] if warnings: lines.append("提示:" + ";".join(str(item) for item in warnings if item)) maintenance = data.get("maintenance") or {} recommended_actions = maintenance.get("recommended_actions") or [] if recommended_actions: lines.append("维护建议:" + " / ".join(str(item) for item in recommended_actions if item)) return "\n".join(lines) def _assistant_toolbox_public_data(self) -> Dict[str, Any]: workflows = [dict(item or {}) for item in (self._assistant_workflow_catalog().get("workflows") or []) if isinstance(item, dict)] return { "protocol_version": "assistant.v1", "action": "toolbox", "ok": True, "version": self.plugin_version, "defaults": { "p115_path": self._p115_default_path, "quark_path": self._quark_default_path, "hdhive_path": self._hdhive_default_path, "p115_client_type": self._p115_client_type, }, "startup_order": [ "agent_resource_officer_startup", "agent_resource_officer_maintain", "agent_resource_officer_pulse", "agent_resource_officer_selfcheck", "agent_resource_officer_request_templates", "agent_resource_officer_recover", "agent_resource_officer_run_workflow", "agent_resource_officer_smart_entry", "agent_resource_officer_smart_pick", ], "endpoints": { "pulse": "/api/v1/plugin/AgentResourceOfficer/assistant/pulse", "startup": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", "maintain": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", "toolbox": "/api/v1/plugin/AgentResourceOfficer/assistant/toolbox", "request_templates": "/api/v1/plugin/AgentResourceOfficer/assistant/request_templates", "selfcheck": "/api/v1/plugin/AgentResourceOfficer/assistant/selfcheck", "recover": "/api/v1/plugin/AgentResourceOfficer/assistant/recover?compact=true", "workflow": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "action": "/api/v1/plugin/AgentResourceOfficer/assistant/action", "actions": "/api/v1/plugin/AgentResourceOfficer/assistant/actions", "route": "/api/v1/plugin/AgentResourceOfficer/assistant/route", "pick": "/api/v1/plugin/AgentResourceOfficer/assistant/pick", }, "tools": { "startup": "agent_resource_officer_startup", "maintain": "agent_resource_officer_maintain", "pulse": "agent_resource_officer_pulse", "toolbox": "agent_resource_officer_toolbox", "request_templates": "agent_resource_officer_request_templates", "selfcheck": "agent_resource_officer_selfcheck", "recover": "agent_resource_officer_recover", "workflow": "agent_resource_officer_run_workflow", "route": "agent_resource_officer_smart_entry", "pick": "agent_resource_officer_smart_pick", "execute_action": "agent_resource_officer_execute_action", "execute_actions": "agent_resource_officer_execute_actions", }, "workflows": [ { "name": item.get("name"), "fields": item.get("fields") or [], } for item in workflows ], "actions": [ "start_pansou_search", "start_smart_resource_search", "start_smart_resource_decision", "start_smart_resource_plan", "start_smart_resource_execute", "pick_pansou_result", "start_hdhive_search", "pick_hdhive_candidate", "candidate_detail", "candidate_next_page", "pick_hdhive_resource", "route_share", "start_115_login", "check_115_login", "show_115_status", "resume_pending_115", "execute_latest_plan", "execute_session_latest_plan", "query_mp_download_tasks", "query_mp_download_history", "query_mp_lifecycle_status", "query_mp_ingest_status", "query_execution_followup", "query_mp_media_detail", "query_mp_search_result_detail", "query_mp_best_result_detail", "pick_mp_best_download", "query_mp_downloaders", "query_mp_sites", "query_mp_subscribes", "query_mp_transfer_history", "query_mp_ingest_failures", "query_mp_recent_activity", "query_mp_local_diagnose", "clear_stale_sessions", "clear_executed_plans", ], "command_examples": [ "盘搜搜索 大君夫人", "智能搜索 蜘蛛侠", "影巢搜索 蜘蛛侠", "1大君夫人", "2蜘蛛侠", "链接 https://pan.quark.cn/s/xxxx path=/飞书", "选择 1", "详情", "下一页", "识别 蜘蛛侠", "115登录", ], "request_templates": self._assistant_request_templates_public_data(limit=100), "request_templates_schema_version": self.request_templates_schema_version, } def _assistant_request_templates_public_data(self, limit: int = 100) -> Dict[str, Any]: max_limit = min(max(1, self._safe_int(limit, 100)), 500) return { "startup_probe": { "description": "读取启动聚合包,适合外部智能体开场获取状态、端点、工具和恢复建议。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "short_lived", "cache_ttl_seconds": 30, "method": "GET", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", "tool": "agent_resource_officer_startup", "tool_args": {}, "query": {}, }, "selfcheck_probe": { "description": "执行协议自检,确认模板、compact、布尔解析和核心入口是否健康。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "short_lived", "cache_ttl_seconds": 30, "method": "GET", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/selfcheck", "tool": "agent_resource_officer_selfcheck", "tool_args": {}, "query": {}, }, "maintain_preview": { "description": "预览低风险维护建议,不执行清理;适合高频探测。", "side_effect": "dry_run", "requires_confirmation": False, "cache_scope": "short_lived", "cache_ttl_seconds": 30, "method": "GET", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", "tool": "agent_resource_officer_maintain", "tool_args": {"execute": False, "limit": max_limit}, "query": {"execute": True, "limit": max_limit}, }, "maintain_execute": { "description": "执行低风险维护,清理过期会话和已执行计划;会写入 assistant/history。", "side_effect": "write", "requires_confirmation": True, "cache_scope": "no_cache", "cache_ttl_seconds": 0, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", "tool": "agent_resource_officer_maintain", "tool_args": {"execute": True, "limit": max_limit}, "body": {"execute": True, "limit": max_limit}, }, "preferences_get": { "description": "读取智能体片源偏好画像;未初始化时应先询问用户偏好。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "short_lived", "cache_ttl_seconds": 60, "method": "GET", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/preferences", "tool": "agent_resource_officer_preferences", "tool_args": {"session": "assistant", "compact": True}, "query": {"session": "assistant", "compact": True}, }, "preferences_save": { "description": "保存智能体片源偏好画像;影响云盘与 PT 评分、自动化建议和安全阈值。", "side_effect": "state", "requires_confirmation": True, "cache_scope": "no_cache", "cache_ttl_seconds": 0, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/preferences", "tool": "agent_resource_officer_preferences", "tool_args": { "session": "assistant", "preferences": self._default_assistant_preferences(), "compact": True, }, "body": { "session": "assistant", "preferences": self._default_assistant_preferences(), "compact": True, }, }, "scoring_policy": { "description": "读取插件内置云盘/PT 评分策略、硬门槛和 score_summary 使用约定;只读,可缓存。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "medium_lived", "cache_ttl_seconds": 3600, "method": "GET", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/capabilities", "tool": "agent_resource_officer_capabilities", "tool_args": {"compact": True}, "query": {"compact": True}, "response_field": "scoring_policy", }, "workflow_dry_run": { "description": "生成并保存工作流计划,不实际执行;适合先让用户确认。", "side_effect": "plan_write", "requires_confirmation": False, "cache_scope": "static_template", "cache_ttl_seconds": 3600, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": { "name": "hdhive_candidates", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "dry_run": True, "compact": True, }, "body": { "workflow": "hdhive_candidates", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "dry_run": True, "compact": True, }, }, "mp_search": { "description": "执行 MP 原生搜索,返回 PT 候选与 PT 评分摘要。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "session_cache", "cache_ttl_seconds": 600, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "mp_search", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, "body": {"workflow": "mp_search", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, }, "smart_search": { "description": "按用户偏好自动执行盘搜 -> 影巢 -> MP/PT 搜索决策,只返回推荐意见,不直接下载或转存。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "session_cache", "cache_ttl_seconds": 600, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "smart_resource_search", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, "body": {"workflow": "smart_resource_search", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, }, "smart_decision": { "description": "按用户偏好统一执行盘搜 -> 影巢 -> MP/PT 决策,并给出查看详情、生成计划或直接执行的首选建议。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "session_cache", "cache_ttl_seconds": 600, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "smart_resource_decision", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, "body": {"workflow": "smart_resource_decision", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, }, "smart_search_plan": { "description": "按用户偏好自动搜索并为当前首选生成待确认 plan_id,不直接执行下载、解锁或转存。", "side_effect": "plan_write", "requires_confirmation": False, "cache_scope": "session_cache", "cache_ttl_seconds": 600, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "smart_resource_plan", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, "body": {"workflow": "smart_resource_plan", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, }, "smart_search_execute": { "description": "按用户偏好自动搜索当前首选并立即执行写入动作;仅在用户已明确要求直接执行时使用。", "side_effect": "write", "requires_confirmation": True, "cache_scope": "session_cache", "cache_ttl_seconds": 600, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "smart_resource_execute", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, "body": {"workflow": "smart_resource_execute", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, }, "mp_media_detail": { "description": "使用 MoviePilot 原生识别确认片名、年份、类型和 TMDB/Douban/IMDB ID;适合搜索前消歧。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "short_lived", "cache_ttl_seconds": 300, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "mp_media_detail", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, "body": {"workflow": "mp_media_detail", "keyword": "蜘蛛侠", "media_type": "auto", "session": "assistant", "compact": True}, }, "mp_search_detail": { "description": "低层诊断:执行 MP 原生搜索并查看指定编号的 PT 评分明细;普通聊天 PT 编号默认直接下载。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "session_cache", "cache_ttl_seconds": 600, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "mp_search_detail", "keyword": "蜘蛛侠", "choice": 1, "session": "assistant", "compact": True}, "body": {"workflow": "mp_search_detail", "keyword": "蜘蛛侠", "choice": 1, "session": "assistant", "compact": True}, }, "mp_search_best": { "description": "执行 MP 原生搜索并返回当前评分最高的 PT 候选摘要;只读,不下载。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "session_cache", "cache_ttl_seconds": 600, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "mp_search_best", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, "body": {"workflow": "mp_search_best", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, }, "mp_best_download_plan": { "description": "在已有 MP 搜索会话中按当前最高分候选生成下载计划;不会直接下载。", "side_effect": "plan_write", "requires_confirmation": False, "cache_scope": "no_cache", "cache_ttl_seconds": 0, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", "tool": "agent_resource_officer_execute_action", "tool_args": {"name": "pick_mp_best_download", "session": "assistant", "compact": True}, "body": {"name": "pick_mp_best_download", "session": "assistant", "compact": True}, }, "mp_search_download_plan": { "description": "MP 原生搜索并选择编号下载;写入动作默认只生成 plan_id,确认后执行。", "side_effect": "plan_write", "requires_confirmation": False, "cache_scope": "no_cache", "cache_ttl_seconds": 0, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "mp_search_download", "keyword": "蜘蛛侠", "choice": 1, "session": "assistant", "dry_run": True, "compact": True}, "body": {"workflow": "mp_search_download", "keyword": "蜘蛛侠", "choice": 1, "session": "assistant", "dry_run": True, "compact": True}, }, "mp_download_tasks": { "description": "查询 MP 下载任务状态,可按下载中、等待、已暂停等状态过滤;只返回摘要。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "short_lived", "cache_ttl_seconds": 60, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "mp_download_tasks", "status": "downloading", "limit": 10, "session": "assistant", "compact": True}, "body": {"workflow": "mp_download_tasks", "status": "downloading", "limit": 10, "session": "assistant", "compact": True}, }, "mp_download_control_plan": { "description": "暂停、恢复或删除 MP 下载任务;默认只生成 plan_id,确认后执行。", "side_effect": "plan_write", "requires_confirmation": False, "cache_scope": "no_cache", "cache_ttl_seconds": 0, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "mp_download_control", "control": "pause", "target": "1", "session": "assistant", "dry_run": True, "compact": True}, "body": {"workflow": "mp_download_control", "control": "pause", "target": "1", "session": "assistant", "dry_run": True, "compact": True}, }, "mp_download_history": { "description": "查询 MP 下载历史,并按 hash 关联整理/入库状态;只返回摘要。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "short_lived", "cache_ttl_seconds": 120, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "mp_download_history", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, "body": {"workflow": "mp_download_history", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, }, "mp_lifecycle_status": { "description": "一次查询 MP 下载任务、下载历史和整理/入库历史,适合追踪资源当前卡在哪一步。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "short_lived", "cache_ttl_seconds": 60, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "mp_lifecycle_status", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, "body": {"workflow": "mp_lifecycle_status", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, }, "mp_ingest_status": { "description": "按片名或 hash 输出下载到入库的当前阶段,并附带结构化 diagnosis_summary。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "short_lived", "cache_ttl_seconds": 60, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "mp_ingest_status", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, "body": {"workflow": "mp_ingest_status", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, }, "mp_downloaders": { "description": "查询 MP 下载器配置摘要,不返回密码、Cookie 或 Token。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "short_lived", "cache_ttl_seconds": 120, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "mp_downloaders", "session": "assistant", "compact": True}, "body": {"workflow": "mp_downloaders", "session": "assistant", "compact": True}, }, "mp_sites": { "description": "查询 MP PT 站点启用状态、优先级和 Cookie 是否存在,不返回 Cookie 明文。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "short_lived", "cache_ttl_seconds": 120, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "mp_sites", "status": "active", "limit": 30, "session": "assistant", "compact": True}, "body": {"workflow": "mp_sites", "status": "active", "limit": 30, "session": "assistant", "compact": True}, }, "mp_subscribe_plan": { "description": "按关键词创建 MP 订阅;默认只生成 plan_id,确认后执行。", "side_effect": "plan_write", "requires_confirmation": False, "cache_scope": "no_cache", "cache_ttl_seconds": 0, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "mp_subscribe", "keyword": "蜘蛛侠", "session": "assistant", "dry_run": True, "compact": True}, "body": {"workflow": "mp_subscribe", "keyword": "蜘蛛侠", "session": "assistant", "dry_run": True, "compact": True}, }, "mp_subscribes": { "description": "查询 MP 订阅列表,可按状态、类型和关键词过滤;只返回摘要。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "short_lived", "cache_ttl_seconds": 120, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "mp_subscribes", "status": "all", "limit": 20, "session": "assistant", "compact": True}, "body": {"workflow": "mp_subscribes", "status": "all", "limit": 20, "session": "assistant", "compact": True}, }, "mp_subscribe_control_plan": { "description": "搜索、暂停、恢复或删除 MP 订阅;默认只生成 plan_id,确认后执行。", "side_effect": "plan_write", "requires_confirmation": False, "cache_scope": "no_cache", "cache_ttl_seconds": 0, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "mp_subscribe_control", "control": "search", "target": "1", "session": "assistant", "dry_run": True, "compact": True}, "body": {"workflow": "mp_subscribe_control", "control": "search", "target": "1", "session": "assistant", "dry_run": True, "compact": True}, }, "mp_transfer_history": { "description": "查询 MP 最近整理/入库历史,判断下载后是否已经落库;只返回摘要。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "short_lived", "cache_ttl_seconds": 120, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "mp_transfer_history", "keyword": "蜘蛛侠", "status": "all", "limit": 10, "session": "assistant", "compact": True}, "body": {"workflow": "mp_transfer_history", "keyword": "蜘蛛侠", "status": "all", "limit": 10, "session": "assistant", "compact": True}, }, "mp_ingest_failures": { "description": "聚合最近整理/入库失败记录,并返回失败态 diagnosis_summary。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "short_lived", "cache_ttl_seconds": 120, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "mp_ingest_failures", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, "body": {"workflow": "mp_ingest_failures", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, }, "ai_failed_samples": { "description": "读取 AI 识别增强插件保存的失败样本,可按关键词过滤;只返回摘要。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "short_lived", "cache_ttl_seconds": 120, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "ai_failed_samples", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, "body": {"workflow": "ai_failed_samples", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, }, "ai_sample_worklist": { "description": "读取 AI 失败样本工作清单,适合先挑需要二次识别重放的样本;只返回摘要。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "short_lived", "cache_ttl_seconds": 120, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "ai_sample_worklist", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, "body": {"workflow": "ai_sample_worklist", "keyword": "蜘蛛侠", "limit": 10, "session": "assistant", "compact": True}, }, "ai_sample_insights": { "description": "读取 AI 失败样本洞察,查看主要失败原因、重复样本组和优先处理项。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "short_lived", "cache_ttl_seconds": 120, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "ai_sample_insights", "keyword": "蜘蛛侠", "limit": 20, "top": 5, "session": "assistant", "compact": True}, "body": {"workflow": "ai_sample_insights", "keyword": "蜘蛛侠", "limit": 20, "top": 5, "session": "assistant", "compact": True}, }, "ai_replay_failed_sample_plan": { "description": "对指定 AI 失败样本生成二次识别重放计划;确认后才会真正执行。", "side_effect": "plan_write", "requires_confirmation": False, "cache_scope": "no_cache", "cache_ttl_seconds": 0, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "ai_replay_failed_sample", "sample_index": 3, "remove_if_resolved": True, "session": "assistant", "dry_run": True, "compact": True}, "body": {"workflow": "ai_replay_failed_sample", "sample_index": 3, "remove_if_resolved": True, "session": "assistant", "dry_run": True, "compact": True}, }, "mp_recent_activity": { "description": "查看最近下载和最近入库活动,适合先发现目标再进入单资源追踪。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "short_lived", "cache_ttl_seconds": 120, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "mp_recent_activity", "limit": 10, "session": "assistant", "compact": True}, "body": {"workflow": "mp_recent_activity", "limit": 10, "session": "assistant", "compact": True}, }, "mp_local_diagnose": { "description": "面向“为什么没入库/卡在哪”的一站式只读诊断入口。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "short_lived", "cache_ttl_seconds": 60, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "mp_local_diagnose", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, "body": {"workflow": "mp_local_diagnose", "keyword": "蜘蛛侠", "limit": 5, "session": "assistant", "compact": True}, }, "mp_recommend": { "description": "读取 MP 原生热门推荐,例如 TMDB、豆瓣或 Bangumi。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "short_lived", "cache_ttl_seconds": 300, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "mp_recommend", "source": "tmdb_trending", "media_type": "all", "limit": 20, "session": "assistant", "compact": True}, "body": {"workflow": "mp_recommend", "source": "tmdb_trending", "media_type": "all", "limit": 20, "session": "assistant", "compact": True}, }, "mp_recommend_search": { "description": "读取 MP 原生推荐并按编号继续搜索;mode 可选 smart_decision、smart_plan、smart_execute、mp、hdhive、pansou。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "session_cache", "cache_ttl_seconds": 300, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "mp_recommend_search", "source": "tmdb_trending", "choice": 1, "mode": "mp", "limit": 20, "session": "assistant", "compact": True}, "body": {"workflow": "mp_recommend_search", "source": "tmdb_trending", "choice": 1, "mode": "mp", "limit": 20, "session": "assistant", "compact": True}, }, "smart_discovery": { "description": "读取 MP 原生热门推荐,并优先引导到统一资源决策链。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "short_lived", "cache_ttl_seconds": 300, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "smart_discovery", "source": "tmdb_trending", "media_type": "all", "limit": 20, "session": "assistant", "compact": True}, "body": {"workflow": "smart_discovery", "source": "tmdb_trending", "media_type": "all", "limit": 20, "session": "assistant", "compact": True}, }, "saved_plan_execute": { "description": "执行已保存的 dry_run 工作流计划,可按 session 自动选择未执行计划。", "side_effect": "write", "requires_confirmation": True, "cache_scope": "no_cache", "cache_ttl_seconds": 0, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", "tool": "agent_resource_officer_execute_plan", "tool_args": { "session": "assistant", "prefer_unexecuted": True, "compact": True, }, "body": { "session": "assistant", "prefer_unexecuted": True, "compact": True, }, }, "execution_followup": { "description": "按最近已执行计划自动追踪下载、订阅或入库后续状态,由插件决定先查哪个只读动作。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "short_lived", "cache_ttl_seconds": 60, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", "tool": "agent_resource_officer_execute_action", "tool_args": { "name": "query_execution_followup", "session": "assistant", "compact": True, }, "body": { "name": "query_execution_followup", "session": "assistant", "compact": True, }, }, "smart_followup": { "description": "统一跟进入口:有片名时查生命周期;有已执行计划时查执行后状态;否则查最近活动。", "side_effect": "read_only", "requires_confirmation": False, "cache_scope": "short_lived", "cache_ttl_seconds": 60, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "tool": "agent_resource_officer_run_workflow", "tool_args": {"name": "smart_followup", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, "body": {"workflow": "smart_followup", "keyword": "蜘蛛侠", "session": "assistant", "compact": True}, }, "action_execute": { "description": "按动作名执行单个 action template,适合无映射继续执行。", "side_effect": "depends_on_action", "requires_confirmation": True, "cache_scope": "no_cache", "cache_ttl_seconds": 0, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", "tool": "agent_resource_officer_execute_action", "tool_args": { "name": "show_115_status", "session": "assistant", "compact": True, }, "body": { "name": "show_115_status", "session": "assistant", "compact": True, }, }, "route_text": { "description": "统一自然语言入口,适合 WorkBuddy、Hermes、OpenClaw(小龙虾)、微信侧智能体或其他外部智能体直接转发用户文本。", "side_effect": "depends_on_text", "requires_confirmation": False, "cache_scope": "no_cache", "cache_ttl_seconds": 0, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/route", "tool": "agent_resource_officer_smart_entry", "tool_args": { "text": "盘搜搜索 大君夫人", "session": "agent:demo", "compact": True, }, "body": { "text": "盘搜搜索 大君夫人", "session": "agent:demo", "compact": True, }, }, "pick_continue": { "description": "按编号继续当前会话,适合盘搜、影巢候选或资源列表选择。", "side_effect": "depends_on_session", "requires_confirmation": True, "cache_scope": "no_cache", "cache_ttl_seconds": 0, "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/pick", "tool": "agent_resource_officer_smart_pick", "tool_args": { "session": "agent:demo", "choice": 1, "compact": True, }, "body": { "session": "agent:demo", "choice": 1, "compact": True, }, }, } def _assistant_request_template_names(self, value: Any) -> List[str]: if isinstance(value, (list, tuple, set)): rows = value else: rows = re.split(r"[,,\s]+", self._clean_text(value)) names: List[str] = [] for item in rows: name = self._clean_text(item) if name and name not in names: names.append(name) return names def _assistant_request_templates_response_data( self, limit: int = 100, names: Any = None, recipe: Any = None, include_templates: bool = True, ) -> Dict[str, Any]: all_templates = self._assistant_request_templates_public_data(limit=limit) recipe_templates_map = { "safe_bootstrap": ["startup_probe", "selfcheck_probe", "maintain_preview"], "plan_then_confirm": ["workflow_dry_run", "saved_plan_execute"], "post_execute_followup": ["smart_followup", "execution_followup", "mp_download_history", "mp_lifecycle_status", "mp_subscribes", "mp_transfer_history"], "continue_existing_session": ["pick_continue"], "maintenance_cycle": ["maintain_preview", "maintain_execute"], "external_agent_quickstart": ["startup_probe", "route_text", "pick_continue"], "preferences_onboarding": ["preferences_get", "scoring_policy", "preferences_save"], "smart_search": ["smart_search", "preferences_get", "scoring_policy"], "smart_decision": ["smart_decision", "preferences_get", "scoring_policy"], "smart_search_plan": ["smart_search_plan", "preferences_get", "scoring_policy", "saved_plan_execute"], "smart_search_execute": ["smart_search_execute", "preferences_get", "scoring_policy", "post_execute_followup"], "mp_pt_mainline": [ "mp_media_detail", "mp_search", "mp_search_detail", "mp_search_best", "mp_search_download_plan", "mp_best_download_plan", "mp_download_tasks", "mp_download_control_plan", "mp_download_history", "mp_lifecycle_status", "mp_ingest_status", "mp_downloaders", "mp_sites", "mp_subscribe_plan", "mp_subscribe_search_plan", "mp_subscribes", "mp_subscribe_control_plan", "mp_transfer_history", "mp_ingest_failures", "mp_recent_activity", "mp_local_diagnose", "saved_plan_execute", ], "mp_recommendation": [ "smart_discovery", "mp_recommend", "mp_recommend_search", "mp_search", "mp_search_best", "mp_search_download_plan", "saved_plan_execute", ], "ai_reingest_readonly": [ "mp_ingest_failures", "mp_local_diagnose", "ai_failed_samples", "ai_sample_worklist", "ai_sample_insights", ], "ai_reingest": [ "mp_ingest_failures", "mp_local_diagnose", "ai_failed_samples", "ai_sample_worklist", "ai_sample_insights", "ai_replay_failed_sample_plan", "saved_plan_execute", "execution_followup", ], "local_ingest": [ "smart_followup", "mp_lifecycle_status", "mp_ingest_status", "mp_download_history", "mp_transfer_history", "mp_ingest_failures", "ai_failed_samples", "ai_sample_worklist", "ai_sample_insights", "mp_recent_activity", "mp_local_diagnose", ], } recipe_aliases = { "bootstrap": "safe_bootstrap", "safe": "safe_bootstrap", "start": "safe_bootstrap", "启动": "safe_bootstrap", "plan": "plan_then_confirm", "dry_run": "plan_then_confirm", "confirm": "plan_then_confirm", "计划": "plan_then_confirm", "followup": "post_execute_followup", "follow": "post_execute_followup", "post_execute": "post_execute_followup", "post-execute": "post_execute_followup", "after_execute": "post_execute_followup", "after-execute": "post_execute_followup", "执行后": "post_execute_followup", "执行后追踪": "post_execute_followup", "后续追踪": "post_execute_followup", "跟进": "post_execute_followup", "进展": "post_execute_followup", "continue": "continue_existing_session", "pick": "continue_existing_session", "resume": "continue_existing_session", "继续": "continue_existing_session", "选择": "continue_existing_session", "maintain": "maintenance_cycle", "maintenance": "maintenance_cycle", "cleanup": "maintenance_cycle", "维护": "maintenance_cycle", "external_agent": "external_agent_quickstart", "external-agent": "external_agent_quickstart", "agent": "external_agent_quickstart", "外部智能体": "external_agent_quickstart", "微信智能体": "external_agent_quickstart", "workbuddy": "external_agent_quickstart", "work_buddy": "external_agent_quickstart", "workbody": "external_agent_quickstart", "work_body": "external_agent_quickstart", "preferences": "preferences_onboarding", "preference": "preferences_onboarding", "prefs": "preferences_onboarding", "pref": "preferences_onboarding", "片源偏好": "preferences_onboarding", "偏好画像": "preferences_onboarding", "评分偏好": "preferences_onboarding", "mp": "mp_pt_mainline", "pt": "mp_pt_mainline", "mp_native": "mp_pt_mainline", "mp-native": "mp_pt_mainline", "mp_pt": "mp_pt_mainline", "mp-pt": "mp_pt_mainline", "pt_mainline": "mp_pt_mainline", "pt-mainline": "mp_pt_mainline", "原生mp": "mp_pt_mainline", "mp原生": "mp_pt_mainline", "原生搜索": "mp_pt_mainline", "pt下载": "mp_pt_mainline", "下载订阅": "mp_pt_mainline", "local_ingest": "local_ingest", "local-ingest": "local_ingest", "ingest": "local_ingest", "local": "local_ingest", "本地入库": "local_ingest", "入库诊断": "local_ingest", "ai_reingest": "ai_reingest", "ai-reingest": "ai_reingest", "失败样本": "ai_reingest_readonly", "失败样本诊断": "ai_reingest_readonly", "识别重放": "ai_reingest", "recommend": "mp_recommendation", "recommendation": "mp_recommendation", "discover": "mp_recommendation", "discovery": "mp_recommendation", "智能发现": "mp_recommendation", "热门发现": "mp_recommendation", "mp_recommend": "mp_recommendation", "mp-recommend": "mp_recommendation", "推荐": "mp_recommendation", "热门": "mp_recommendation", "smart_search": "smart_search", "smart-search": "smart_search", "smart": "smart_search", "智能搜索": "smart_search", "smart_decision": "smart_decision", "smart-decision": "smart_decision", "decision": "smart_decision", "资源决策": "smart_decision", "智能决策": "smart_decision", "smart_search_plan": "smart_search_plan", "smart-search-plan": "smart_search_plan", "smartplan": "smart_search_plan", "智能计划": "smart_search_plan", "smart_search_execute": "smart_search_execute", "smart-search-execute": "smart_search_execute", "smartexecute": "smart_search_execute", "智能执行": "smart_search_execute", } requested_recipe = self._clean_text(recipe) selected_recipe = recipe_aliases.get(requested_recipe, requested_recipe) invalid_recipe = requested_recipe if requested_recipe and selected_recipe not in recipe_templates_map else "" selected_names = self._assistant_request_template_names(names) if not selected_names and selected_recipe in recipe_templates_map: selected_names = list(recipe_templates_map[selected_recipe]) invalid_names = [name for name in selected_names if name not in all_templates] templates = { name: all_templates[name] for name in selected_names if name in all_templates } if selected_names else all_templates confirmation_required = [ name for name, item in templates.items() if bool((item or {}).get("requires_confirmation")) ] safe_without_confirmation = [ name for name, item in templates.items() if not bool((item or {}).get("requires_confirmation")) ] write_side_effects = [ name for name, item in templates.items() if self._clean_text((item or {}).get("side_effect")) in {"write", "depends_on_action", "depends_on_session", "depends_on_text"} ] cacheable_templates = [ name for name, item in templates.items() if self._safe_int((item or {}).get("cache_ttl_seconds"), 0) > 0 ] non_cacheable_templates = [ name for name, item in templates.items() if self._safe_int((item or {}).get("cache_ttl_seconds"), 0) <= 0 ] auth = { "mode": "query_apikey", "query_param": "apikey", "url_template": "{base_url}{endpoint}?apikey={MP_API_TOKEN}", "description": "调用插件 HTTP 接口时推荐使用 ?apikey=你的MP_API_TOKEN;MP Tool 调用不需要此参数。", } external_agent_execution_policy_contract = { "auto_continue": "直接执行 auto_run_command。", "auto_continue_then_wait_confirmation": "先执行 auto_run_command,再停止并向用户展示 confirm_command。", "wait_user_confirmation": "不要自动执行;先向用户展示 confirm_command 或 display_command。", "show_only": "只展示 display_command,不要自动继续。", "stop": "当前没有适合自动继续的命令,不要继续执行。", } external_agent_execution_loop_contract = [ { "step": "startup", "template": "startup_probe", "when": "新会话开始时先读取启动聚合包。", }, { "step": "decide", "template": "startup_probe", "when": "结合恢复信息和推荐 recipe,确定先继续会话还是开始新流程。", }, { "step": "route", "template": "route_text", "when": "处理用户的自然语言资源指令。", }, { "step": "policy", "template": "", "when": "读取 recommended_agent_behavior、auto_run_command、confirm_command 决定自动继续、确认或停止。", }, { "step": "followup", "template": "execution_followup", "when": "执行计划后继续追踪下载、入库或失败诊断。", }, ] entry_patterns = { "external_agent": { "label": "外部智能体", "client_role": "客户端调度层", "start_with": "startup", "decide_with": "decide --summary-only", "route_with": "route --summary-only", "followup_with": "followup --summary-only", "read_fields": [ "recommended_agent_behavior", "auto_run_command", "confirm_command", "display_command", "preferred_command", "compact_commands", ], "notes": "适用于 WorkBuddy、Hermes、OpenClaw(小龙虾)等外部智能体;优先使用 Skill/helper。", }, "mp_builtin_agent": { "label": "MP 内置智能体", "client_role": "客户端调度层", "start_with": "assistant/request_templates", "decide_with": "agent_resource_officer_request_templates", "route_with": "agent_resource_officer_smart_entry", "followup_with": "agent_resource_officer_execution_followup", "read_fields": [ "recommended_recipe_detail", "recommended_agent_behavior", "preferred_command", "compact_commands", ], "notes": "优先走 Agent Tool / request_templates,不在模型侧直拼底层资源 API。", }, "feishu_channel": { "label": "飞书入口", "client_role": "客户端消息入口", "start_with": "feishu message -> route", "decide_with": "插件内置命令解析", "route_with": "route/pick/followup", "followup_with": "followup / smart_followup", "read_fields": [ "recommended_agent_behavior", "preferred_command", "fallback_command", "compact_commands", ], "notes": "飞书只负责把消息送进同一套 assistant 协议;确认策略与外部智能体保持一致。", }, } orchestration_contract = { "service_role": "Agent影视助手 / AgentResourceOfficer 负责服务端能力执行。", "client_role": "外部智能体、MP 内置智能体、飞书入口负责客户端调度与展示。", "recommended_first_call": "startup", "recommended_decision_call": "decide --summary-only", "recommended_route_call": "route --summary-only", "recommended_followup_call": "followup --summary-only", "recommended_read_fields": [ "recommended_agent_behavior", "auto_run_command", "confirm_command", "display_command", "preferred_command", "compact_commands", ], "confirmation_rule": "写入动作默认确认制;只有明确标记可自动继续的只读步骤才自动续跑。", } entry_playbooks = { "external_agent": { "label": "外部智能体最小执行流", "transport": "skill_helper_or_http", "steps": [ { "step": "startup", "helper_command": "python3 scripts/aro_request.py startup", "http_call": { "method": "GET", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/startup?apikey={MP_API_TOKEN}", }, "purpose": "读取启动状态、恢复建议和推荐 recipe。", "read_fields": ["recommended_request_templates", "recovery", "services"], }, { "step": "decide", "helper_command": "python3 scripts/aro_request.py decide --summary-only", "http_call": { "method": "GET", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/startup?apikey={MP_API_TOKEN}", }, "purpose": "决定继续会话、初始化偏好还是直接进入 route。", "read_fields": ["recommended_agent_behavior", "auto_run_command", "confirm_command"], }, { "step": "route", "helper_command": "python3 scripts/aro_request.py route '<用户原始指令>' --session 'agent:<会话ID>' --summary-only", "http_call": { "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/route", "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/route?apikey={MP_API_TOKEN}", }, "purpose": "执行自然语言主入口。", "read_fields": [ "recommended_agent_behavior", "auto_run_command", "confirm_command", "preferred_command", "compact_commands", ], }, { "step": "followup", "helper_command": "python3 scripts/aro_request.py followup --session 'agent:<会话ID>' --summary-only", "http_call": { "method": "POST", "endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/action", "url_template": "{base_url}/api/v1/plugin/AgentResourceOfficer/assistant/action?apikey={MP_API_TOKEN}", }, "purpose": "执行计划后继续追踪下载、入库或失败诊断。", "read_fields": ["recommended_agent_behavior", "auto_run_command", "followup_summary"], }, ], "auto_rule": "优先读取 recommended_agent_behavior;只读步骤可自动续跑,写入步骤默认确认。", }, "mp_builtin_agent": { "label": "MP 内置智能体最小执行流", "transport": "moviepilot_agent_tool", "steps": [ { "step": "request_templates", "tool": "agent_resource_officer_request_templates", "tool_args": {"recipe": "external_agent", "compact": True}, "purpose": "读取最小流程、确认策略和推荐入口。", "read_fields": ["orchestration_contract", "entry_patterns", "recommended_recipe_detail"], }, { "step": "route", "tool": "agent_resource_officer_smart_entry", "tool_args": {"text": "<用户原始指令>"}, "purpose": "处理搜索、链接、登录状态等主入口。", "read_fields": ["recommended_agent_behavior", "preferred_command", "compact_commands"], }, { "step": "followup", "tool": "agent_resource_officer_execution_followup", "tool_args": {}, "purpose": "执行计划后继续查看下载、入库和失败状态。", "read_fields": ["followup_summary", "preferred_command", "compact_commands"], }, ], "auto_rule": "依然遵守服务端 compact 协议;不要在模型侧拼底层资源站点 API。", }, "feishu_channel": { "label": "飞书入口最小执行流", "transport": "feishu_channel", "steps": [ { "step": "message_in", "channel": "feishu", "purpose": "用户消息进入内置 Channel。", "read_fields": ["session", "text", "reply_target"], }, { "step": "route", "internal": "route / pick / followup", "purpose": "复用同一套 assistant 协议,不维护单独状态机。", "read_fields": ["recommended_agent_behavior", "preferred_command", "compact_commands"], }, { "step": "reply", "channel": "feishu", "purpose": "按确认策略回消息、展示编号或提示下一步。", "read_fields": ["display_command", "confirm_command", "message"], }, ], "auto_rule": "飞书只负责消息承载;写入动作依然由插件服务端统一确认。", }, } recommended_sequence = [ { "step": "bootstrap", "template": "startup_probe", "when": "每次新会话开始时先读取启动聚合包。", }, { "step": "healthcheck", "template": "selfcheck_probe", "when": "当外部智能体需要确认协议健康或怀疑环境变化时执行。", }, { "step": "maintenance_preview", "template": "maintain_preview", "when": "长会话或多轮执行前先看是否有低风险维护建议。", }, { "step": "plan", "template": "workflow_dry_run", "when": "需要先生成计划、等待用户确认或减少重复大 JSON 时使用。", }, { "step": "execute_saved_plan", "template": "saved_plan_execute", "when": "已确认 dry_run 计划后执行。", }, { "step": "post_execute_followup", "template": "execution_followup", "when": "计划执行成功后,自动决定先查下载历史、生命周期、订阅或入库历史。", }, { "step": "continue_session", "template": "pick_continue", "when": "盘搜、影巢候选或资源列表需要按编号继续时使用。", }, { "step": "preferences_onboarding", "template": "preferences_get", "when": "外部智能体首次接入时,先读取偏好和评分策略,再保存用户片源偏好。", }, { "step": "mp_pt_mainline", "template": "mp_search", "when": "需要 MP 原生 PT 搜索、评分、下载计划、订阅或任务追踪时使用。", }, { "step": "mp_recommendation", "template": "mp_recommend", "when": "需要 TMDB、豆瓣、Bangumi 等 MP 原生推荐,并继续搜索时使用。", }, ] recipes = [ { "name": "safe_bootstrap", "description": "新会话安全启动:先拿启动聚合包,再自检,再看维护建议。", "templates": recipe_templates_map["safe_bootstrap"], }, { "name": "plan_then_confirm", "description": "先生成计划,等待用户确认后再执行保存计划。", "templates": recipe_templates_map["plan_then_confirm"], }, { "name": "post_execute_followup", "description": "执行计划后的统一只读跟踪:由插件自动选择最合适的后续查询动作。", "templates": recipe_templates_map["post_execute_followup"], }, { "name": "continue_existing_session", "description": "已有盘搜、影巢或资源会话时,直接按编号继续。", "templates": recipe_templates_map["continue_existing_session"], }, { "name": "maintenance_cycle", "description": "先预览维护建议,再在确认后执行低风险维护。", "templates": recipe_templates_map["maintenance_cycle"], }, { "name": "external_agent_quickstart", "description": "外部智能体接入:启动探测后,把用户文本交给统一入口,再按编号继续。", "templates": recipe_templates_map["external_agent_quickstart"], }, { "name": "preferences_onboarding", "description": "首次接入时先读取偏好、评分策略,再保存用户的片源偏好画像。", "templates": recipe_templates_map["preferences_onboarding"], }, { "name": "smart_decision", "description": "统一搜索并返回明确的下一步决策:查看详情、生成计划或直接执行。", "templates": recipe_templates_map["smart_decision"], }, { "name": "smart_search_plan", "description": "统一搜索决策后,直接为当前首选生成待确认计划;仍需后续执行计划才会真正写入。", "templates": recipe_templates_map["smart_search_plan"], }, { "name": "smart_search_execute", "description": "统一搜索决策后,直接执行当前首选写入动作;只适用于用户明确要求立即执行的场景。", "templates": recipe_templates_map["smart_search_execute"], }, { "name": "mp_pt_mainline", "description": "MP 原生 PT 主线:识别、搜索、评分、下载计划、任务、订阅、站点和入库追踪。", "templates": recipe_templates_map["mp_pt_mainline"], }, { "name": "mp_recommendation", "description": "MP 原生推荐主线:读取热门推荐,再按编号进入 MP、影巢或盘搜搜索。", "templates": recipe_templates_map["mp_recommendation"], }, ] recipe_summaries: List[Dict[str, Any]] = [] for recipe in recipes: template_names = [ self._clean_text(item) for item in (recipe.get("templates") or []) if self._clean_text(item) ] current_templates = [ templates[name] for name in template_names if isinstance(templates.get(name), dict) ] recipe_summaries.append({ **recipe, "requires_confirmation": any(bool(item.get("requires_confirmation")) for item in current_templates), "has_write_effect": any( self._clean_text(item.get("side_effect")) in {"write", "depends_on_action", "depends_on_session", "depends_on_text"} for item in current_templates ), "cache_ttl_seconds": min( [self._safe_int(item.get("cache_ttl_seconds"), 0) for item in current_templates] or [0] ), }) recommended_recipe = "safe_bootstrap" recommended_recipe_reason = "默认优先安全启动,先读取启动聚合包、自检并查看维护建议。" selected_set = set(selected_names or []) if selected_recipe in recipe_templates_map: recommended_recipe = selected_recipe recommended_recipe_reason = f"当前请求显式指定 recipe={selected_recipe}。" elif {"workflow_dry_run", "saved_plan_execute"} & selected_set: recommended_recipe = "plan_then_confirm" recommended_recipe_reason = "当前模板集合包含 dry_run 或执行保存计划,更适合先计划后确认。" elif "pick_continue" in selected_set: recommended_recipe = "continue_existing_session" recommended_recipe_reason = "当前模板集合包含 pick_continue,说明更像继续既有会话。" elif {"preferences_get", "preferences_save", "scoring_policy"} & selected_set: recommended_recipe = "preferences_onboarding" recommended_recipe_reason = "当前模板集合包含偏好或评分策略模板,优先推荐偏好初始化流程。" elif {"maintain_preview", "maintain_execute"} & selected_set: recommended_recipe = "maintenance_cycle" recommended_recipe_reason = "当前模板集合包含维护模板,优先推荐维护流程。" recommended_recipe_detail = next( (item for item in recipe_summaries if item.get("name") == recommended_recipe), {}, ) recommended_recipe_templates = [ name for name in (recommended_recipe_detail.get("templates") or []) if name in all_templates ] first_template = recommended_recipe_templates[0] if recommended_recipe_templates else "" first_template_data = all_templates.get(first_template) or {} recommended_recipe_calls = [] for template_name in recommended_recipe_templates: template_data = all_templates.get(template_name) or {} if not template_data: continue recommended_recipe_calls.append({ "template": template_name, "auth": auth, "method": template_data.get("method"), "endpoint": template_data.get("endpoint"), "url_template": "{base_url}{endpoint}?apikey={MP_API_TOKEN}".replace( "{endpoint}", self._clean_text(template_data.get("endpoint")), ), "query": template_data.get("query") or {}, "body": template_data.get("body") or {}, "tool": template_data.get("tool"), "tool_args": template_data.get("tool_args") or {}, "requires_confirmation": bool(template_data.get("requires_confirmation")), "side_effect": template_data.get("side_effect"), }) recommended_recipe_detail = { **recommended_recipe_detail, "templates": recommended_recipe_templates, "first_template": first_template, "confirmation_required_templates": [ name for name in recommended_recipe_templates if bool((all_templates.get(name) or {}).get("requires_confirmation")) ], "write_templates": [ name for name in recommended_recipe_templates if self._clean_text((all_templates.get(name) or {}).get("side_effect")) in {"write", "depends_on_action", "depends_on_session", "depends_on_text"} ], "first_call": { "template": first_template, "auth": auth, "method": first_template_data.get("method"), "endpoint": first_template_data.get("endpoint"), "url_template": "{base_url}{endpoint}?apikey={MP_API_TOKEN}".replace( "{endpoint}", self._clean_text(first_template_data.get("endpoint")), ), "query": first_template_data.get("query") or {}, "body": first_template_data.get("body") or {}, "tool": first_template_data.get("tool"), "tool_args": first_template_data.get("tool_args") or {}, "requires_confirmation": bool(first_template_data.get("requires_confirmation")), "side_effect": first_template_data.get("side_effect"), } if first_template_data else {}, "calls": recommended_recipe_calls, } if recommended_recipe == "external_agent_quickstart": recommended_recipe_detail["execution_policy_contract"] = external_agent_execution_policy_contract recommended_recipe_detail["execution_loop_contract"] = external_agent_execution_loop_contract recommended_recipe_detail["entry_patterns"] = entry_patterns recommended_recipe_detail["orchestration_contract"] = orchestration_contract recommended_recipe_detail["entry_playbooks"] = entry_playbooks confirmation_templates = recommended_recipe_detail.get("confirmation_required_templates") or [] recommended_recipe_detail["first_confirmation_template"] = confirmation_templates[0] if confirmation_templates else "" recommended_recipe_detail["confirmation_message"] = ( f"执行 {recommended_recipe} 的 {recommended_recipe_detail['first_confirmation_template']} 前需要用户确认。" if confirmation_templates else f"{recommended_recipe} 当前推荐流程无需用户确认。" ) return { "protocol_version": "assistant.v1", "action": "request_templates", "ok": True, "compact": True, "version": self.plugin_version, "schema_version": self.request_templates_schema_version, "auth": auth, "templates_included": bool(include_templates), "request_templates": templates if include_templates else {}, "available_names": list(all_templates.keys()), "available_recipes": list(recipe_templates_map.keys()), "recipe_aliases": recipe_aliases, "selected_names": selected_names, "invalid_names": invalid_names, "requested_recipe": requested_recipe, "selected_recipe": selected_recipe if selected_recipe in recipe_templates_map else "", "invalid_recipe": invalid_recipe, "execution_policy": { "safe_without_confirmation": safe_without_confirmation, "confirmation_required": confirmation_required, "write_side_effects": write_side_effects, "cacheable_templates": cacheable_templates, "non_cacheable_templates": non_cacheable_templates, }, "recommended_sequence": recommended_sequence, "recipes": recipe_summaries, "recommended_recipe": recommended_recipe, "recommended_recipe_reason": recommended_recipe_reason, "recommended_recipe_detail": recommended_recipe_detail, "external_agent_execution_policy_contract": external_agent_execution_policy_contract, "external_agent_execution_loop_contract": external_agent_execution_loop_contract, "entry_patterns": entry_patterns, "orchestration_contract": orchestration_contract, "entry_playbooks": entry_playbooks, } def _format_assistant_request_templates_text(self, data: Optional[Dict[str, Any]] = None) -> str: payload = data or self._assistant_request_templates_response_data() templates = payload.get("request_templates") or {} lines = [ "Agent影视助手 请求模板", f"版本:{payload.get('version')}", ] detail = payload.get("recommended_recipe_detail") or {} first_call = detail.get("first_call") or {} if payload.get("recommended_recipe"): lines.append(f"推荐流程:{payload.get('recommended_recipe')}") if detail.get("first_template"): lines.append( "首步:{template} -> {method} {endpoint}".format( template=detail.get("first_template"), method=first_call.get("method") or "", endpoint=first_call.get("endpoint") or "", ).strip() ) if detail.get("confirmation_message"): lines.append(f"确认提示:{detail.get('confirmation_message')}") if detail.get("execution_policy_contract"): lines.append("外部智能体执行分支:" + " / ".join(str(key) for key in (detail.get("execution_policy_contract") or {}).keys())) if detail.get("execution_loop_contract"): lines.append( "外部智能体最小循环:" + " -> ".join(self._clean_text(item.get("step")) for item in (detail.get("execution_loop_contract") or []) if self._clean_text(item.get("step"))) ) orchestration_contract = payload.get("orchestration_contract") or detail.get("orchestration_contract") or {} if orchestration_contract: lines.append( "最小执行流:{startup} -> {decide} -> {route} -> policy -> {followup}".format( startup=self._clean_text(orchestration_contract.get("recommended_first_call")) or "startup", decide=self._clean_text(orchestration_contract.get("recommended_decision_call")) or "decide --summary-only", route=self._clean_text(orchestration_contract.get("recommended_route_call")) or "route --summary-only", followup=self._clean_text(orchestration_contract.get("recommended_followup_call")) or "followup --summary-only", ) ) read_fields = [ self._clean_text(item) for item in (orchestration_contract.get("recommended_read_fields") or []) if self._clean_text(item) ] if read_fields: lines.append("优先读取字段:" + " / ".join(read_fields)) entry_patterns = payload.get("entry_patterns") or detail.get("entry_patterns") or {} for key in ["external_agent", "mp_builtin_agent", "feishu_channel"]: item = entry_patterns.get(key) or {} if not item: continue lines.append( "{label}:{start} -> {route}".format( label=self._clean_text(item.get("label")) or key, start=self._clean_text(item.get("start_with")) or "", route=self._clean_text(item.get("route_with")) or "", ) ) entry_playbooks = payload.get("entry_playbooks") or detail.get("entry_playbooks") or {} external_playbook = (entry_playbooks.get("external_agent") or {}).get("steps") or [] if external_playbook: lines.append( "外部智能体脚手架:" + " -> ".join( self._clean_text(item.get("step")) for item in external_playbook if self._clean_text(item.get("step")) ) ) for name in [ "startup_probe", "selfcheck_probe", "maintain_preview", "maintain_execute", "workflow_dry_run", "saved_plan_execute", "action_execute", "pick_continue", ]: item = templates.get(name) or {} if item: tool = self._clean_text(item.get("tool")) suffix = f" -> {tool}" if tool else "" lines.append(f"{name}: {item.get('method')} {item.get('endpoint')}{suffix}") return "\n".join(lines) def _format_assistant_toolbox_text(self) -> str: data = self._assistant_toolbox_public_data() workflows = data.get("workflows") or [] lines = [ "Agent影视助手 轻量工具清单", f"版本:{data.get('version')}", "推荐启动顺序:" + " -> ".join(str(item) for item in (data.get("startup_order") or [])[:5]), "常用工作流:" + " / ".join(str(item.get("name")) for item in workflows if item.get("name")), "默认目录:115={p115_path};夸克={quark_path};影巢={hdhive_path}".format(**(data.get("defaults") or {})), ] return "\n".join(lines) def _assistant_selfcheck_public_data(self) -> Dict[str, Any]: mp_media_search_params = inspect.signature(self._assistant_mp_media_search).parameters format_mp_search_params = inspect.signature(self._format_mp_search_text).parameters mp_search_signature_ok = "result_filter" in mp_media_search_params mp_search_formatter_signature_ok = { "result_filter", "latest_episode", "episode_filter", }.issubset(format_mp_search_params) action_template = self._assistant_action_template( name="show_115_status", description="自检模板:查看 115 状态", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/action", tool="agent_resource_officer_execute_action", body={"session": "selfcheck"}, ) route_template = self._assistant_action_template( name="start_hdhive_search", description="自检模板:发起影巢搜索", endpoint="/api/v1/plugin/AgentResourceOfficer/assistant/route", tool="agent_resource_officer_smart_entry", body={"session": "selfcheck", "keyword": "蜘蛛侠", "media_type": "movie"}, ) bool_cases = { "true": self._parse_bool_value("true", False), "false": self._parse_bool_value("false", True), "one": self._parse_bool_value("1", False), "zero": self._parse_bool_value("0", True), "off": self._parse_bool_value("off", True), "default_true": self._parse_bool_value(None, True), "default_false": self._parse_bool_value(None, False), } compact_templates_ok = all([ (action_template.get("body") or {}).get("compact") is True, (action_template.get("action_body") or {}).get("compact") is True, (route_template.get("body") or {}).get("compact") is True, (route_template.get("action_body") or {}).get("compact") is True, ]) bool_parse_ok = ( bool_cases["true"] is True and bool_cases["false"] is False and bool_cases["one"] is True and bool_cases["zero"] is False and bool_cases["off"] is False and bool_cases["default_true"] is True and bool_cases["default_false"] is False ) pulse = self._assistant_pulse_public_data() toolbox = self._assistant_toolbox_public_data() startup_request_templates = self._assistant_recommended_request_templates_data() startup_continue_request_templates = self._assistant_recommended_request_templates_data( recipe="continue", reason="selfcheck", ) maintain = self._assistant_maintain_public_data(execute=False) request_templates = self._assistant_request_templates_public_data(limit=100) filtered_request_templates = self._assistant_request_templates_response_data( limit=5, names="maintain_execute,missing_template", ) recipe_request_templates = self._assistant_request_templates_response_data( limit=5, names="startup_probe,maintain_preview,workflow_dry_run,saved_plan_execute,pick_continue,maintain_execute", include_templates=False, ) recipe_filtered_request_templates = self._assistant_request_templates_response_data( limit=5, recipe="plan", include_templates=False, ) external_recipe_request_templates = self._assistant_request_templates_response_data( limit=5, recipe="external_agent", include_templates=False, ) policy_only_request_templates = self._assistant_request_templates_response_data( limit=5, names="maintain_execute", include_templates=False, ) maintenance_templates = maintain.get("action_templates") or [] maintenance_template_names = { self._clean_text(item.get("name")) for item in maintenance_templates if isinstance(item, dict) } maintenance_templates_compact_ok = all( (item.get("body") or {}).get("compact") is True and (item.get("action_body") or {}).get("compact") is True for item in maintenance_templates if isinstance(item, dict) ) maintain_dry_run_ok = ( maintain.get("action") == "maintain" and maintain.get("execute_requested") is False and maintain.get("executed") is False and {"clear_stale_sessions", "clear_executed_plans"}.issubset(maintenance_template_names) ) protocol_ok = ( pulse.get("protocol_version") == "assistant.v1" and toolbox.get("protocol_version") == "assistant.v1" and pulse.get("action") == "pulse" and toolbox.get("action") == "toolbox" and maintain.get("protocol_version") == "assistant.v1" ) startup_request_templates_ok = ( startup_request_templates.get("recipe") == "bootstrap" and startup_request_templates.get("include_templates") is False and startup_request_templates.get("tool") == "agent_resource_officer_request_templates" and "{MP_API_TOKEN}" in self._clean_text(startup_request_templates.get("url_template")) and startup_continue_request_templates.get("recipe") == "continue" and ((startup_continue_request_templates.get("tool_args") or {}).get("recipe")) == "continue" and bool(self._clean_text(startup_continue_request_templates.get("reason"))) ) request_templates_ok = all( isinstance(request_templates.get(name), dict) and self._clean_text((request_templates.get(name) or {}).get("endpoint")) and self._clean_text((request_templates.get(name) or {}).get("method")) and self._clean_text((request_templates.get(name) or {}).get("tool")) and self._clean_text((request_templates.get(name) or {}).get("description")) and self._clean_text((request_templates.get(name) or {}).get("side_effect")) and isinstance((request_templates.get(name) or {}).get("requires_confirmation"), bool) and self._clean_text((request_templates.get(name) or {}).get("cache_scope")) and isinstance((request_templates.get(name) or {}).get("cache_ttl_seconds"), int) and isinstance((request_templates.get(name) or {}).get("tool_args"), dict) for name in [ "startup_probe", "selfcheck_probe", "maintain_preview", "maintain_execute", "workflow_dry_run", "saved_plan_execute", "action_execute", "route_text", "pick_continue", ] ) request_templates_filter_ok = ( list((filtered_request_templates.get("request_templates") or {}).keys()) == ["maintain_execute"] and filtered_request_templates.get("selected_names") == ["maintain_execute", "missing_template"] and filtered_request_templates.get("invalid_names") == ["missing_template"] and (((filtered_request_templates.get("request_templates") or {}).get("maintain_execute") or {}).get("body") or {}).get("limit") == 5 ) request_templates_policy_ok = ( "maintain_execute" in ((filtered_request_templates.get("execution_policy") or {}).get("confirmation_required") or []) and "maintain_execute" in ((filtered_request_templates.get("execution_policy") or {}).get("write_side_effects") or []) ) request_templates_schema_ok = filtered_request_templates.get("schema_version") == self.request_templates_schema_version request_templates_cache_ok = ( ((filtered_request_templates.get("request_templates") or {}).get("maintain_execute") or {}).get("cache_scope") == "no_cache" and (((filtered_request_templates.get("request_templates") or {}).get("maintain_execute") or {}).get("cache_ttl_seconds")) == 0 ) request_templates_sequence_ok = ( isinstance(filtered_request_templates.get("recommended_sequence"), list) and any( isinstance(item, dict) and self._clean_text(item.get("template")) == "startup_probe" for item in (filtered_request_templates.get("recommended_sequence") or []) ) and any( isinstance(item, dict) and self._clean_text(item.get("template")) == "saved_plan_execute" for item in (filtered_request_templates.get("recommended_sequence") or []) ) ) request_templates_recipes_ok = ( isinstance(recipe_request_templates.get("recipes"), list) and any( isinstance(item, dict) and self._clean_text(item.get("name")) == "safe_bootstrap" for item in (recipe_request_templates.get("recipes") or []) ) and any( isinstance(item, dict) and self._clean_text(item.get("name")) == "plan_then_confirm" for item in (recipe_request_templates.get("recipes") or []) ) ) request_templates_recipe_summary_ok = ( any( isinstance(item, dict) and self._clean_text(item.get("name")) == "safe_bootstrap" and item.get("requires_confirmation") is False and item.get("has_write_effect") is False for item in (recipe_request_templates.get("recipes") or []) ) and any( isinstance(item, dict) and self._clean_text(item.get("name")) == "plan_then_confirm" and item.get("requires_confirmation") is True and item.get("has_write_effect") is True for item in (recipe_request_templates.get("recipes") or []) ) ) request_templates_recommended_recipe_ok = ( filtered_request_templates.get("recommended_recipe") == "maintenance_cycle" and bool(self._clean_text(filtered_request_templates.get("recommended_recipe_reason"))) and recipe_request_templates.get("recommended_recipe") == "plan_then_confirm" ) request_templates_recipe_filter_ok = ( recipe_filtered_request_templates.get("requested_recipe") == "plan" and recipe_filtered_request_templates.get("selected_recipe") == "plan_then_confirm" and recipe_filtered_request_templates.get("selected_names") == ["workflow_dry_run", "saved_plan_execute"] and recipe_filtered_request_templates.get("recommended_recipe") == "plan_then_confirm" and recipe_filtered_request_templates.get("templates_included") is False and "plan_then_confirm" in (recipe_filtered_request_templates.get("available_recipes") or []) and ((recipe_filtered_request_templates.get("recipe_aliases") or {}).get("plan")) == "plan_then_confirm" ) recommended_recipe_detail = filtered_request_templates.get("recommended_recipe_detail") or {} request_templates_recommended_recipe_detail_ok = ( recommended_recipe_detail.get("name") == "maintenance_cycle" and recommended_recipe_detail.get("first_template") == "maintain_preview" and "maintain_execute" in (recommended_recipe_detail.get("confirmation_required_templates") or []) and "maintain_execute" in (recommended_recipe_detail.get("write_templates") or []) and recommended_recipe_detail.get("first_confirmation_template") == "maintain_execute" and "maintain_execute" in self._clean_text(recommended_recipe_detail.get("confirmation_message")) and ((recommended_recipe_detail.get("first_call") or {}).get("template")) == "maintain_preview" and (((recommended_recipe_detail.get("first_call") or {}).get("auth") or {}).get("mode")) == "query_apikey" and self._clean_text((recommended_recipe_detail.get("first_call") or {}).get("url_template")).endswith( "/assistant/maintain?apikey={MP_API_TOKEN}" ) and ((recommended_recipe_detail.get("first_call") or {}).get("method")) == "GET" and ((recommended_recipe_detail.get("first_call") or {}).get("tool")) == "agent_resource_officer_maintain" and [ (item or {}).get("template") for item in (recommended_recipe_detail.get("calls") or []) ] == ["maintain_preview", "maintain_execute"] and all( (((item or {}).get("auth") or {}).get("query_param")) == "apikey" for item in (recommended_recipe_detail.get("calls") or []) ) and all( "{MP_API_TOKEN}" in self._clean_text((item or {}).get("url_template")) for item in (recommended_recipe_detail.get("calls") or []) ) ) request_templates_policy_only_ok = ( policy_only_request_templates.get("templates_included") is False and (policy_only_request_templates.get("request_templates") or {}) == {} and policy_only_request_templates.get("selected_names") == ["maintain_execute"] and "maintain_execute" in ((policy_only_request_templates.get("execution_policy") or {}).get("confirmation_required") or []) and "maintain_execute" in ((policy_only_request_templates.get("execution_policy") or {}).get("non_cacheable_templates") or []) ) external_recipe_detail = external_recipe_request_templates.get("recommended_recipe_detail") or {} request_templates_external_agent_contract_ok = ( external_recipe_request_templates.get("selected_recipe") == "external_agent_quickstart" and external_recipe_request_templates.get("recommended_recipe") == "external_agent_quickstart" and bool((external_recipe_request_templates.get("external_agent_execution_policy_contract") or {}).get("auto_continue")) and len(external_recipe_request_templates.get("external_agent_execution_loop_contract") or []) >= 5 and ((external_recipe_request_templates.get("orchestration_contract") or {}).get("recommended_first_call")) == "startup" and bool(((external_recipe_request_templates.get("entry_patterns") or {}).get("mp_builtin_agent") or {}).get("route_with")) and len((((external_recipe_request_templates.get("entry_playbooks") or {}).get("external_agent") or {}).get("steps") or [])) >= 4 and bool((external_recipe_detail.get("execution_policy_contract") or {}).get("auto_continue")) and len(external_recipe_detail.get("execution_loop_contract") or []) >= 5 and ((external_recipe_detail.get("orchestration_contract") or {}).get("recommended_route_call")) == "route --summary-only" and bool(((external_recipe_detail.get("entry_patterns") or {}).get("feishu_channel") or {}).get("route_with")) and bool(((((external_recipe_detail.get("entry_playbooks") or {}).get("mp_builtin_agent") or {}).get("steps") or [{}])[0]).get("tool")) and any( self._clean_text(item.get("step")) == "policy" for item in (external_recipe_detail.get("execution_loop_contract") or []) if isinstance(item, dict) ) ) start_new_recovery = self._assistant_recovery_public_data( session_state={"has_session": False}, action_templates=[{"name": "start_pansou_search", "tool": "agent_resource_officer_smart_entry"}], ) start_new_recovery_ok = ( start_new_recovery.get("mode") == "start_new" and start_new_recovery.get("can_resume") is False and start_new_recovery.get("recommended_action") == "start_pansou_search" ) executed_plan_recovery = self._assistant_recovery_public_data( session_state={ "has_session": True, "session": "selfcheck", "session_id": "assistant::selfcheck", "saved_plan": { "has_pending": False, "has_plan": True, "latest": { "plan_id": "plan-selfcheck-executed", "executed": True, }, }, }, ) executed_plan_recovery_ok = ( executed_plan_recovery.get("mode") == "followup_executed_plan" and executed_plan_recovery.get("can_resume") is True and executed_plan_recovery.get("recommended_action") == "query_execution_followup" ) execute_plan_followup_samples = { workflow: self._assistant_plan_execute_followup( workflow=workflow, session="selfcheck", session_id="selfcheck-session", session_state={ "keyword": keyword, "session": "selfcheck", "session_id": "selfcheck-session", }, ok=True, plan_id=f"plan-selfcheck-{workflow}", ) for workflow, keyword in [ ("mp_best_download", "蜘蛛侠"), ("mp_subscribe", "钢铁侠"), ("hdhive_unlock_selected", "复仇者联盟"), ("ai_replay_failed_sample", "地狱乐"), ] } execute_plan_followups_ok = ( [item.get("name") for item in (execute_plan_followup_samples.get("mp_best_download") or {}).get("action_templates") or []] == ["query_execution_followup", "query_mp_ingest_status", "query_mp_download_history", "query_mp_lifecycle_status", "query_mp_local_diagnose"] and (execute_plan_followup_samples.get("mp_best_download") or {}).get("recommended_action") == "query_execution_followup" and bool(self._clean_text((execute_plan_followup_samples.get("mp_best_download") or {}).get("follow_up_hint"))) and bool(self._clean_text(((execute_plan_followup_samples.get("mp_best_download") or {}).get("followup_summary") or {}).get("label"))) and [item.get("name") for item in (execute_plan_followup_samples.get("mp_subscribe") or {}).get("action_templates") or []] == ["query_execution_followup", "query_mp_subscribes", "query_mp_ingest_status", "start_mp_media_search"] and (execute_plan_followup_samples.get("mp_subscribe") or {}).get("recommended_action") == "query_execution_followup" and bool(self._clean_text((execute_plan_followup_samples.get("mp_subscribe") or {}).get("follow_up_hint"))) and bool(self._clean_text(((execute_plan_followup_samples.get("mp_subscribe") or {}).get("followup_summary") or {}).get("label"))) and [item.get("name") for item in (execute_plan_followup_samples.get("hdhive_unlock_selected") or {}).get("action_templates") or []] == ["query_execution_followup", "query_mp_transfer_history", "query_mp_local_diagnose"] and (execute_plan_followup_samples.get("hdhive_unlock_selected") or {}).get("recommended_action") == "query_execution_followup" and bool(self._clean_text((execute_plan_followup_samples.get("hdhive_unlock_selected") or {}).get("follow_up_hint"))) and bool(self._clean_text(((execute_plan_followup_samples.get("hdhive_unlock_selected") or {}).get("followup_summary") or {}).get("label"))) and [item.get("name") for item in (execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("action_templates") or []] == ["query_mp_local_diagnose", "query_mp_ingest_status", "query_ai_sample_worklist", "query_ai_failed_samples", "query_ai_sample_insights"] and (execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("recommended_action") == "query_mp_local_diagnose" and ((execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("followup_summary") or {}).get("preferred_command") == "诊断" and ((execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("followup_summary") or {}).get("fallback_command") == "入库状态" and ((execute_plan_followup_samples.get("ai_replay_failed_sample") or {}).get("followup_summary") or {}).get("recommended_agent_behavior") == "auto_continue" ) ai_replay_execution_summary_resolved = self._assistant_ai_replay_execution_decision_summary( ok=True, resolved=True, has_title=True, ) ai_replay_execution_summary_unresolved = self._assistant_ai_replay_execution_decision_summary( ok=True, resolved=False, has_title=True, ) ai_replay_execution_summary_failed = self._assistant_ai_replay_execution_decision_summary( ok=False, resolved=False, has_title=True, ) ai_replay_execution_summary_ok = ( ai_replay_execution_summary_resolved.get("preferred_command") == "诊断" and ai_replay_execution_summary_resolved.get("recommended_agent_behavior") == "auto_continue" and ai_replay_execution_summary_unresolved.get("preferred_command") == "工作清单" and ai_replay_execution_summary_unresolved.get("fallback_command") == "样本洞察" and ai_replay_execution_summary_failed.get("preferred_command") == "工作清单" and ai_replay_execution_summary_failed.get("recommended_agent_behavior") == "show_only" ) checks = { "compact_templates": compact_templates_ok, "bool_parser": bool_parse_ok, "protocol": protocol_ok, "maintain_dry_run": maintain_dry_run_ok, "maintenance_templates_compact": maintenance_templates_compact_ok, "request_templates": request_templates_ok, "request_templates_filter": request_templates_filter_ok, "request_templates_policy": request_templates_policy_ok, "request_templates_schema": request_templates_schema_ok, "request_templates_cache": request_templates_cache_ok, "request_templates_sequence": request_templates_sequence_ok, "request_templates_recipes": request_templates_recipes_ok, "request_templates_recipe_summary": request_templates_recipe_summary_ok, "request_templates_recommended_recipe": request_templates_recommended_recipe_ok, "request_templates_recipe_filter": request_templates_recipe_filter_ok, "request_templates_recommended_recipe_detail": request_templates_recommended_recipe_detail_ok, "request_templates_policy_only": request_templates_policy_only_ok, "request_templates_external_agent_contract": request_templates_external_agent_contract_ok, "startup_request_templates": startup_request_templates_ok, "start_new_recovery_not_resumable": start_new_recovery_ok, "executed_plan_recovery": executed_plan_recovery_ok, "execute_plan_followups": execute_plan_followups_ok, "ai_replay_execution_summary": ai_replay_execution_summary_ok, "toolbox_startup_endpoint": bool((toolbox.get("endpoints") or {}).get("startup")), "toolbox_maintain_endpoint": bool((toolbox.get("endpoints") or {}).get("maintain")), "toolbox_request_templates_endpoint": bool((toolbox.get("endpoints") or {}).get("request_templates")), "toolbox_maintain_tool": bool((toolbox.get("tools") or {}).get("maintain")), "toolbox_request_templates_tool": bool((toolbox.get("tools") or {}).get("request_templates")), "toolbox_selfcheck_endpoint": bool((toolbox.get("endpoints") or {}).get("selfcheck")), "mp_search_signature": mp_search_signature_ok, "mp_search_formatter_signature": mp_search_formatter_signature_ok, } ok = all(bool(value) for value in checks.values()) return { "protocol_version": "assistant.v1", "action": "selfcheck", "ok": ok, "compact": True, "version": self.plugin_version, "checks": checks, "bool_cases": bool_cases, "template_samples": { "action": { "name": action_template.get("name"), "body_compact": (action_template.get("body") or {}).get("compact"), "action_body_compact": (action_template.get("action_body") or {}).get("compact"), }, "route": { "name": route_template.get("name"), "body_compact": (route_template.get("body") or {}).get("compact"), "action_body_compact": (route_template.get("action_body") or {}).get("compact"), }, "request_templates": { name: { "method": (request_templates.get(name) or {}).get("method"), "endpoint": (request_templates.get(name) or {}).get("endpoint"), } for name in ["maintain_execute", "workflow_dry_run", "saved_plan_execute"] }, "execute_plan_followups": { workflow: { "next_actions": (sample or {}).get("next_actions") or [], "recommended_action": self._clean_text((sample or {}).get("recommended_action")), "follow_up_hint": self._clean_text((sample or {}).get("follow_up_hint")), "template_names": [ self._clean_text(item.get("name")) for item in ((sample or {}).get("action_templates") or []) if isinstance(item, dict) and self._clean_text(item.get("name")) ], } for workflow, sample in execute_plan_followup_samples.items() }, "mp_search_signatures": { "mp_media_search_has_result_filter": mp_search_signature_ok, "format_mp_search_text_has_filter_fields": mp_search_formatter_signature_ok, }, }, "next_actions": ["assistant_startup", "assistant_maintain", "assistant_pulse", "assistant_toolbox", "assistant_readiness"], "recommended_endpoints": { "startup": "/api/v1/plugin/AgentResourceOfficer/assistant/startup", "maintain": "/api/v1/plugin/AgentResourceOfficer/assistant/maintain", "pulse": "/api/v1/plugin/AgentResourceOfficer/assistant/pulse", "toolbox": "/api/v1/plugin/AgentResourceOfficer/assistant/toolbox", "readiness": "/api/v1/plugin/AgentResourceOfficer/assistant/readiness?compact=true", }, } def _format_assistant_selfcheck_text(self) -> str: data = self._assistant_selfcheck_public_data() checks = data.get("checks") or {} failed = [key for key, value in checks.items() if not value] lines = [ "Agent影视助手 协议自检", f"版本:{data.get('version')}", f"结果:{'通过' if data.get('ok') else '失败'}", ] if failed: lines.append("失败项:" + " / ".join(failed)) return "\n".join(lines) def _assistant_response_data( self, *, session: str, data: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: payload = dict(data or {}) session_name = self._clean_text(session) or "default" session_state = self._assistant_session_public_data(session=session_name) payload["action"] = self._clean_text(payload.get("action")) or "assistant_response" payload["ok"] = bool(payload.get("ok", True)) payload["write_effect"] = payload.get("write_effect") or self._assistant_write_effect_for_action(payload["action"]) payload["error_code"] = self._clean_text(payload.get("error_code")) or ("" if payload["ok"] else "assistant_error") payload["protocol_version"] = "assistant.v1" payload["session"] = session_name payload["session_id"] = session_state.get("session_id") or self._assistant_session_id(session_name) payload["session_state"] = session_state payload["preference_status"] = payload.get("preference_status") or self._assistant_preferences_status_brief(session=session_name) payload["next_actions"] = payload.get("next_actions") or session_state.get("suggested_actions") or [] if payload["preference_status"].get("needs_onboarding") and "preferences.init" not in payload["next_actions"]: payload["next_actions"] = ["preferences.init", *list(payload["next_actions"] or [])] payload["action_templates"] = payload.get("action_templates") or session_state.get("action_templates") or [] payload["recovery"] = payload.get("recovery") or session_state.get("recovery") or self._assistant_recovery_public_data(session_state=session_state) if payload.get("action") == "mp_recommendations" or self._clean_text(session_state.get("kind")) == "assistant_mp_recommend": payload["source"] = self._clean_text(payload.get("source") or session_state.get("source")) payload["requested_source"] = self._clean_text(payload.get("requested_source") or session_state.get("requested_source")) payload["fallback_source"] = self._clean_text(payload.get("fallback_source") or session_state.get("fallback_source")) payload["media_type"] = self._clean_text(payload.get("media_type") or session_state.get("media_type")) recommend_handoff = session_state.get("recommend_handoff") if isinstance(session_state.get("recommend_handoff"), dict) else {} if recommend_handoff: payload["recommend_handoff"] = recommend_handoff payload["return_short_command"] = self._clean_text(payload.get("return_short_command") or recommend_handoff.get("return_short_command") or "回推荐") return payload @staticmethod def _assistant_write_effect_for_action(action: str) -> str: action_name = str(action or "").strip() if action_name in { "share_route", "hdhive_unlock", "transfer_115", "quark_transfer", "quark_clear_default_dir", "p115_clear_default_dir", "p115_resume", "p115_cancel", "p115_qrcode_start", "p115_qrcode_check", "hdhive_checkin", "mp_download", "mp_download_control", "mp_subscribe", "mp_subscribe_control", "pick_mp_download", "start_mp_subscribe", "ai_replay_failed_sample", "preferences_save", "preferences_reset", "execute_actions", "execute_plan", "maintain", }: return "write" if action_name in {"workflow_plan", "plans_clear", "session_clear", "sessions_clear"}: return "state" return "read" def _merge_assistant_structured_input(self, body: Dict[str, Any], parsed: Dict[str, str]) -> Dict[str, str]: merged = dict(parsed or {}) body = dict(body or {}) mode = self._clean_text(body.get("mode")) if mode in {"mp", "mp_download_title", "pansou", "hdhive", "smart", "smart_decision", "smart_plan", "smart_execute", "cloud_transfer_execute"}: merged["mode"] = mode keyword = self._clean_text(body.get("keyword") or body.get("title")) if keyword: merged["keyword"] = keyword share_url = self._clean_text(body.get("url") or body.get("share_url")) if share_url: merged["url"] = share_url access_code = self._clean_text(body.get("access_code") or body.get("pwd") or body.get("code")) if access_code: merged["access_code"] = access_code path = self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))) if path: merged["path"] = path media_type = self._clean_text(body.get("media_type") or body.get("type")).lower() if media_type: merged["type"] = media_type year = self._clean_text(body.get("year")) if year: merged["year"] = year cloud_provider = self._clean_text(body.get("cloud_provider") or body.get("provider")).lower() if cloud_provider in {"115", "quark"}: merged["cloud_provider"] = cloud_provider client_type = self._clean_text(body.get("client_type") or body.get("client")) if client_type: merged["client_type"] = P115TransferService.normalize_qrcode_client_type(client_type) if "is_gambler" in body: merged["is_gambler"] = "true" if self._parse_bool_value(body.get("is_gambler"), False) else "false" action = self._clean_text(body.get("action")) if action: merged["action"] = action result_filter = self._clean_text(body.get("result_filter") or body.get("filter")).lower() if result_filter in {"latest_episode", "latest", "latest_episodes"}: merged["result_filter"] = "latest_episode" elif result_filter.startswith("episode:"): episode_value = self._safe_int(result_filter.split(":", 1)[1], 0) if episode_value > 0: merged["result_filter"] = f"episode:{episode_value}" plan_id = self._clean_text(body.get("plan_id") or body.get("plan")) if plan_id: merged["plan_id"] = plan_id download_control = self._clean_text(body.get("download_control") or body.get("control") or body.get("operation")) if download_control: merged["download_control"] = download_control subscribe_control = self._clean_text(body.get("subscribe_control") or body.get("control") or body.get("operation")) if subscribe_control: merged["subscribe_control"] = subscribe_control sample_index = self._safe_int(body.get("sample_index") or body.get("index"), 0) if sample_index > 0: merged["sample_index"] = str(sample_index) if "remove_if_resolved" in body: merged["remove_if_resolved"] = "true" if self._parse_bool_value(body.get("remove_if_resolved"), True) else "false" return merged @staticmethod def _parse_assistant_text(text: str) -> Dict[str, str]: raw = str(text or "").strip() compact = re.sub(r"\s+", "", raw).lower() share_url = AgentResourceOfficer._extract_first_url(raw) remain = raw.replace(share_url, " ").strip() if share_url else raw mode, query = AgentResourceOfficer._normalize_search_prefix(remain) plan_match = re.search(r"\bplan-[a-zA-Z0-9]+\b", raw) options: Dict[str, str] = { "text": raw, "url": share_url, "access_code": "", "path": "", "mode": mode, "keyword": query or remain, "result_filter": "", "source_order_text": "", "cloud_provider": "", "type": "", "year": "", "action": "", "client_type": "", "status": "", "hash": "", "plan_id": plan_match.group(0) if plan_match else "", "decision_intent": "", "sample_index": "", "remove_if_resolved": "true", "streaming_intent": "", "streaming_month": "", "streaming_window": "", "streaming_media_type": "", } if options.get("mode") in {"smart", "smart_decision"} and options.get("keyword"): cleaned_keyword, decision_intent = AgentResourceOfficer._extract_smart_decision_intent(options.get("keyword") or "") options["keyword"] = cleaned_keyword.strip() if decision_intent: options["decision_intent"] = decision_intent cloud_search_match = re.match(r"^\s*(?:云盘搜索|云盘搜)\s*(.*)$", raw) if cloud_search_match: options["action"] = "cloud_search_removed" options["mode"] = "" options["keyword"] = AgentResourceOfficer._clean_text(cloud_search_match.group(1)) transfer_provider_prefixes = [ ("夸克转存资源", "quark"), ("夸克转存", "quark"), ("115转存资源", "115"), ("115转存", "115"), ] for prefix, provider in transfer_provider_prefixes: if raw == prefix: options["action"] = "cloud_transfer_removed" options["mode"] = "" options["keyword"] = "" options["cloud_provider"] = provider break if raw.startswith(prefix + " "): remain_text = raw[len(prefix):].strip() options["action"] = "cloud_transfer_removed" options["mode"] = "" options["keyword"] = remain_text options["cloud_provider"] = provider break replay_match = re.match(r"^\s*(重放样本|重识别样本|重跑样本)\s*(\d+)?(?:\s+(.*))?$", raw) if replay_match: options["action"] = "ai_replay_failed_sample" options["mode"] = "" options["keyword"] = "" if replay_match.group(2): options["sample_index"] = replay_match.group(2) remain_text = AgentResourceOfficer._clean_text(replay_match.group(3)) if "保留样本" in remain_text or "不移除" in remain_text: options["remove_if_resolved"] = "false" # ── 流媒体推荐 ── # 只支持显式前缀命令,避免自然语言兜底和历史推荐命令互相干扰。 from datetime import date as _date _today = _date.today() streaming_match = re.match(r"^\s*流媒体推荐\s*(.*?)$", raw, re.IGNORECASE) if streaming_match: _streaming_text = AgentResourceOfficer._clean_text(streaming_match.group(1)) _nl_compact = re.sub(r"\s+", "", _streaming_text) options["action"] = "streaming_recommend" options["mode"] = "" options["keyword"] = _streaming_text # 媒体类型 if any(t in _nl_compact for t in ["电影", "影片"]): options["streaming_media_type"] = "movie" elif any(t in _nl_compact for t in ["剧", "剧集", "电视剧", "美剧", "英剧", "日剧", "韩剧"]): options["streaming_media_type"] = "tv" else: options["streaming_media_type"] = "all" # 意图 if any(t in _nl_compact for t in ["大作", "大片", "佳作", "高分", "口碑", "好看", "精彩"]): options["streaming_intent"] = "big_titles" elif any(t in _nl_compact for t in ["新", "上新", "最新", "刚上", "新片", "新剧"]): options["streaming_intent"] = "new" else: options["streaming_intent"] = "hot" # 时间:匹配"N月" → specific_month;"本月" → this_month;"近期" → recent month_match = re.search(r"(\d{1,2})\s*月", _streaming_text) if month_match: raw_month = int(month_match.group(1)) if 1 <= raw_month <= 12: target_year = _today.year if raw_month > _today.month: target_year -= 1 options["streaming_month"] = f"{target_year}-{raw_month:02d}" options["streaming_window"] = "" else: options["streaming_month"] = "" options["streaming_window"] = "90" elif re.search(r"本月|这个月", _streaming_text): options["streaming_month"] = f"{_today.year}-{_today.month:02d}" options["streaming_window"] = "" elif re.search(r"上月|上个月", _streaming_text): last_month = _today.month - 1 if _today.month > 1 else 12 last_year = _today.year if _today.month > 1 else _today.year - 1 options["streaming_month"] = f"{last_year}-{last_month:02d}" options["streaming_window"] = "" elif re.search(r"近期|最近|近来", _streaming_text): options["streaming_month"] = "" options["streaming_window"] = "90" else: options["streaming_month"] = f"{_today.year}-{_today.month:02d}" options["streaming_window"] = "" if options.get("plan_id") and compact.startswith(("执行plan-", "确认plan-", "executeplan-")): options["action"] = "execute_plan" options["mode"] = "" options["keyword"] = "" elif options.get("plan_id") and compact.startswith(("取消plan-", "清理plan-", "删除plan-", "clearplan-", "cancelplan-")): options["action"] = "plans_clear" options["mode"] = "" options["keyword"] = "" elif compact in { "帮助", "使用帮助", "命令帮助", "help", "agenthelp", "arohelp", "插件帮助", }: options["action"] = "assistant_help" options["mode"] = "" options["keyword"] = "" elif compact in { "执行计划", "执行最新计划", "确认计划", "确认执行计划", "执行plan", "执行最新plan", "executeplan", "executelatestplan", }: options["action"] = "execute_plan" options["mode"] = "" options["keyword"] = "" elif compact in { "计划列表", "查看计划", "待执行计划", "保存计划", "plans", "listplans", }: options["action"] = "plans_list" options["mode"] = "" options["keyword"] = "" elif compact in { "偏好", "片源偏好", "查看偏好", "偏好设置", "智能体偏好", "preferences", "getpreferences", }: options["action"] = "preferences_get" options["mode"] = "" options["keyword"] = "" elif compact in { "重置偏好", "清除偏好", "重设偏好", "恢复默认偏好", "resetpreferences", }: options["action"] = "preferences_reset" options["mode"] = "" options["keyword"] = "" elif compact in { "评分策略", "评分规则", "自动化规则", "scoringpolicy", "scoringrules", }: options["action"] = "scoring_policy" options["mode"] = "" options["keyword"] = "" elif compact in { "取消计划", "清理计划", "删除计划", "cancelplan", "clearplan", "deleteplan", }: options["action"] = "plans_clear" options["mode"] = "" options["keyword"] = "" elif compact in { "115登录", "115扫码", "扫码115", "登录115", "115login", "115qrcode", "p115login", "p115qrcode", }: options["action"] = "p115_qrcode_start" options["mode"] = "" options["keyword"] = "" elif compact in { "检查115登录", "检查115扫码", "检查扫码", "115check", "check115login", "p115check", }: options["action"] = "p115_qrcode_check" options["mode"] = "" options["keyword"] = "" elif compact in { "115登录状态", "115状态", "查看115状态", "115健康", "115status", "p115status", }: options["action"] = "p115_status" options["mode"] = "" options["keyword"] = "" elif compact in { "115帮助", "115命令", "115使用", "115help", "p115help", }: options["action"] = "p115_help" options["mode"] = "" options["keyword"] = "" elif compact in { "115任务", "待处理115", "待继续115", "115pending", "p115pending", }: options["action"] = "p115_pending" options["mode"] = "" options["keyword"] = "" elif compact in { "清空夸克默认目录", "清空夸克默认转存目录", "清空夸克转存目录", "清空夸克目录", }: options["action"] = "quark_clear_default_dir" options["mode"] = "" options["keyword"] = "" elif compact in { "清空115默认目录", "清空115默认转存目录", "清空115转存目录", "清空115目录", "清空115网盘转存目录", "清空115网盘目录", }: options["action"] = "p115_clear_default_dir" options["mode"] = "" options["keyword"] = "" elif compact in { "继续115任务", "重试115任务", "继续115转存", "重试115转存", "continue115", "resume115", }: options["action"] = "p115_resume" options["mode"] = "" options["keyword"] = "" elif compact in { "取消115任务", "取消115转存", "清除115任务", "cancel115", "clear115", }: options["action"] = "p115_cancel" options["mode"] = "" options["keyword"] = "" elif compact in { "影巢签到", "签到", "hdhivecheckin", "hdhivesign", }: options["action"] = "hdhive_checkin" options["mode"] = "" options["keyword"] = "" elif compact in { "影巢签到日志", "签到日志", "影巢日志", "hdhivecheckinhistory", "hdhivesignhistory", }: options["action"] = "hdhive_checkin_history" options["mode"] = "" options["keyword"] = "" elif compact in { "影巢普通签到", "普通签到", "普通", "hdhivenormalcheckin", }: options["action"] = "hdhive_checkin" options["mode"] = "" options["keyword"] = "" options["is_gambler"] = "false" elif compact in { "影巢赌狗签到", "赌狗签到", "赌狗", "hdhivegamblercheckin", "gamblercheckin", }: options["action"] = "hdhive_checkin" options["mode"] = "" options["keyword"] = "" options["is_gambler"] = "true" elif compact in { "资源决策", "智能决策", "decision", "smartdecision", }: options["mode"] = "smart_decision" options["keyword"] = "" elif compact in { "后续", "执行后追踪", "继续追踪", "followup", "postexecute", }: options["action"] = "execution_followup" options["mode"] = "" options["keyword"] = "" elif compact in { "跟进", "继续跟进", "查看进展", "进展", "smartfollowup", }: options["action"] = "smart_followup" options["mode"] = "" options["keyword"] = "" elif compact in { "继续决策", "继续资源决策", "decisioncontinue", }: options["action"] = "smart_decision_adjust" options["mode"] = "" options["keyword"] = "" options["decision_adjust"] = "decision_continue" elif compact in { "换影巢", "切换影巢", "走影巢", "用影巢", "decisionhdhive", }: options["action"] = "smart_decision_adjust" options["mode"] = "" options["keyword"] = "" options["decision_adjust"] = "decision_hdhive" elif compact in { "换盘搜", "切换盘搜", "走盘搜", "用盘搜", "decisionpansou", }: options["action"] = "smart_decision_adjust" options["mode"] = "" options["keyword"] = "" options["decision_adjust"] = "decision_pansou" elif compact in { "换pt", "换原生", "换mp", "切换pt", "切换原生", "切换mp", "decisionmppt", }: options["action"] = "smart_decision_adjust" options["mode"] = "" options["keyword"] = "" options["decision_adjust"] = "decision_mp_pt" elif compact in { "保守一点", "更保守", "保守模式", "decisionconservative", }: options["action"] = "smart_decision_adjust" options["mode"] = "" options["keyword"] = "" options["decision_adjust"] = "decision_conservative" elif compact in { "激进一点", "更激进", "激进模式", "decisionaggressive", }: options["action"] = "smart_decision_adjust" options["mode"] = "" options["keyword"] = "" options["decision_adjust"] = "decision_aggressive" elif compact in { "下载任务", "downloadtasks", }: options["action"] = "mp_download_tasks" options["mode"] = "" options["keyword"] = "" elif compact in { "下载最佳", "下载推荐", "下载最好", "下载最佳片源", "下载推荐片源", "downloadbest", }: options["action"] = "mp_download_best" options["mode"] = "" options["keyword"] = "" elif compact in { "下载历史", "下载记录", "记录", "历史下载", "downloadhistory", }: options["action"] = "mp_download_history" options["mode"] = "" options["keyword"] = "" elif compact in { "最近下载", "recentdownloads", }: options["action"] = "mp_recent_activity" options["mode"] = "" options["keyword"] = "" options["download_only"] = "true" elif compact in { "最近", "最近动态", "动态", "recentactivity", }: options["action"] = "mp_recent_activity" options["mode"] = "" options["keyword"] = "" elif compact in { "追踪", "资源追踪", "下载追踪", "媒体状态", "落库状态", "状态", "进度", "lifecyclestatus", }: options["action"] = "mp_lifecycle_status" options["mode"] = "" options["keyword"] = "" elif compact in { "入库状态", "本地入库", "入库", "ingeststatus", }: options["action"] = "mp_ingest_status" options["mode"] = "" options["keyword"] = "" elif compact in { "识别", "媒体识别", "媒体详情", "mp识别", "mp媒体识别", "mpmediadetail", }: options["action"] = "mp_media_detail" options["mode"] = "" options["keyword"] = "" elif compact in { "下载器状态", "downloaders", }: options["action"] = "mp_downloaders" options["mode"] = "" options["keyword"] = "" elif compact in { "站点状态", "sites", }: options["action"] = "mp_sites" options["mode"] = "" options["keyword"] = "" elif compact in { "订阅列表", "subscribes", }: options["action"] = "mp_subscribes" options["mode"] = "" options["keyword"] = "" elif compact in { "入库历史", "整理历史", "转移历史", "入库记录", "最近整理", "transferhistory", }: options["action"] = "mp_transfer_history" options["mode"] = "" options["keyword"] = "" elif compact in { "最近入库", "recentingest", }: options["action"] = "mp_recent_activity" options["mode"] = "" options["keyword"] = "" options["transfer_only"] = "true" elif compact in { "入库失败", "整理失败", "失败入库", "失败整理", "transferfailed", }: options["action"] = "mp_ingest_failures" options["mode"] = "" options["keyword"] = "" options["status"] = "failed" elif compact in { "入库成功", "整理成功", "成功入库", "成功整理", "transfersuccess", }: options["action"] = "mp_transfer_history" options["mode"] = "" options["keyword"] = "" options["status"] = "success" elif compact in { "本地诊断", "诊断", "失败原因", "localdiagnose", }: options["action"] = "mp_local_diagnose" options["mode"] = "" options["keyword"] = "" elif compact in { "失败样本", "识别样本", "ai失败样本", "aifailedsamples", }: options["action"] = "ai_failed_samples" options["mode"] = "" options["keyword"] = "" elif compact in { "工作清单", "识别工作清单", "样本清单", "sampleworklist", }: options["action"] = "ai_sample_worklist" options["mode"] = "" options["keyword"] = "" elif compact in { "样本洞察", "识别洞察", "失败洞察", "sampleinsights", }: options["action"] = "ai_sample_insights" options["mode"] = "" options["keyword"] = "" elif compact in { "重放样本", "重识别样本", "重跑样本", "aireplay", "replaysample", }: options["action"] = "ai_replay_failed_sample" options["mode"] = "" options["keyword"] = "" else: for prefix, action in [ ("执行计划", "execute_plan"), ("执行", "execute_plan"), ("确认计划", "execute_plan"), ("确认", "execute_plan"), ("查看计划", "plans_list"), ("计划列表", "plans_list"), ("取消计划", "plans_clear"), ("清理计划", "plans_clear"), ("删除计划", "plans_clear"), ("保存偏好", "preferences_save"), ("设置偏好", "preferences_save"), ("更新偏好", "preferences_save"), ("偏好设置", "preferences_save"), ("偏好", "preferences_save"), ("查看偏好", "preferences_get"), ("片源偏好", "preferences_get"), ("重置偏好", "preferences_reset"), ("清除偏好", "preferences_reset"), ("评分策略", "scoring_policy"), ("评分规则", "scoring_policy"), ("自动化规则", "scoring_policy"), ]: if raw.startswith(prefix + " ") or raw.startswith(prefix + ":") or raw.startswith(prefix + ":"): remain_text = raw[len(prefix):].lstrip(" ::").strip() if action in {"plans_list", "preferences_get", "preferences_reset"} or options.get("plan_id") or remain_text: options["action"] = action options["mode"] = "" options["keyword"] = "" if remain_text and not options.get("plan_id"): match = re.search(r"\bplan-[a-zA-Z0-9]+\b", remain_text) if match: options["plan_id"] = match.group(0) break for prefix, control in [ ("暂停下载", "pause"), ("停止下载", "pause"), ("恢复下载", "resume"), ("继续下载", "resume"), ("开始下载", "resume"), ("删除下载", "delete"), ("移除下载", "delete"), ]: prefix_match = AgentResourceOfficer._match_command_prefix(raw, [prefix]) if prefix_match: target_text = prefix_match[1] if target_text: options["action"] = "mp_download_control" options["mode"] = "" options["keyword"] = target_text options["download_control"] = control break if not options.get("action"): for prefix in ["下载状态", "正在下载", "下载列表", "查看下载", "下载进度", "站点", "站点列表", "PT站点", "pt站点", "pt站点状态", "下载器", "下载器列表", "查看下载器"]: prefix_match = AgentResourceOfficer._match_command_prefix(raw, [prefix]) if prefix_match: options["action"] = "command_alias_removed" options["mode"] = "" options["keyword"] = prefix_match[1] options["type"] = prefix break if not options.get("action"): prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["下载任务"]) if prefix_match: options["action"] = "mp_download_tasks" options["mode"] = "" options["keyword"] = prefix_match[1] if not options.get("action") and not options.get("mode"): prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["资源决策", "智能决策", "热门推荐", "推荐", "智能发现", "热门发现"]) if prefix_match: options["action"] = "advanced_command_removed" options["mode"] = "" options["keyword"] = prefix_match[1] if not options.get("action") and not options.get("mode"): prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["继续跟进", "查看进展", "跟进", "进展"]) if prefix_match: options["action"] = "smart_followup" options["mode"] = "" options["keyword"] = prefix_match[1] if not options.get("action"): prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["下载历史", "下载记录", "记录", "最近下载", "历史下载"]) if prefix_match: options["action"] = "mp_download_history" options["mode"] = "" options["keyword"] = prefix_match[1] if not options.get("action"): prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["资源追踪", "下载追踪", "媒体状态", "落库状态", "状态", "进度", "追踪"]) if prefix_match: options["action"] = "mp_lifecycle_status" options["mode"] = "" options["keyword"] = prefix_match[1] if not options.get("action"): prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["入库状态", "本地入库"]) if prefix_match: options["action"] = "mp_ingest_status" options["mode"] = "" options["keyword"] = prefix_match[1] if not options.get("action"): prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["MP识别", "mp识别", "媒体识别", "媒体详情", "详情媒体", "识别"]) if prefix_match: options["action"] = "mp_media_detail" options["mode"] = "" options["keyword"] = prefix_match[1] if not options.get("action"): prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["站点状态"]) if prefix_match: options["action"] = "mp_sites" options["mode"] = "" options["keyword"] = prefix_match[1] if not options.get("action"): for prefix in ["搜索订阅", "移除订阅", "订阅状态", "查看订阅", "MP订阅", "mp订阅"]: prefix_match = AgentResourceOfficer._match_command_prefix(raw, [prefix]) if prefix_match: options["action"] = "command_alias_removed" options["mode"] = "" options["keyword"] = prefix_match[1] options["type"] = prefix break if not options.get("action"): for prefix, control in [ ("刷新订阅", "search"), ("暂停订阅", "pause"), ("恢复订阅", "resume"), ("删除订阅", "delete"), ("移除订阅", "delete"), ]: prefix_match = AgentResourceOfficer._match_command_prefix(raw, [prefix]) if prefix_match: target_text = prefix_match[1] if target_text: options["action"] = "mp_subscribe_control" options["mode"] = "" options["keyword"] = target_text options["subscribe_control"] = control break if not options.get("action"): prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["订阅列表"]) if prefix_match: options["action"] = "mp_subscribes" options["mode"] = "" options["keyword"] = prefix_match[1] if not options.get("action"): for prefix, status_name in [ ("入库失败", "failed"), ("整理失败", "failed"), ("失败入库", "failed"), ("失败整理", "failed"), ("入库成功", "success"), ("整理成功", "success"), ("成功入库", "success"), ("成功整理", "success"), ("入库历史", "all"), ("入库记录", "all"), ("整理历史", "all"), ("转移历史", "all"), ("最近入库", "all"), ("最近整理", "all"), ]: prefix_match = AgentResourceOfficer._match_command_prefix(raw, [prefix]) if prefix_match: options["action"] = "mp_ingest_failures" if status_name == "failed" else "mp_transfer_history" options["mode"] = "" options["keyword"] = prefix_match[1] options["status"] = status_name break if not options.get("action") and raw.startswith("入库"): blocked_prefixes = ("入库失败", "入库成功", "入库历史", "入库记录", "入库状态") if not any(raw.startswith(prefix) for prefix in blocked_prefixes): remain_text = raw[len("入库"):].lstrip(" ::").strip() options["action"] = "mp_ingest_status" options["mode"] = "" options["keyword"] = remain_text if not options.get("action"): prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["为什么没入库", "本地诊断", "诊断", "失败原因"]) if prefix_match: options["action"] = "mp_local_diagnose" options["mode"] = "" options["keyword"] = prefix_match[1] if not options.get("action"): prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["失败样本", "识别样本", "AI失败样本"]) if prefix_match: options["action"] = "ai_failed_samples" options["mode"] = "" options["keyword"] = prefix_match[1] if not options.get("action"): prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["工作清单", "识别工作清单", "样本清单"]) if prefix_match: options["action"] = "ai_sample_worklist" options["mode"] = "" options["keyword"] = prefix_match[1] if not options.get("action"): prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["样本洞察", "识别洞察", "失败洞察"]) if prefix_match: options["action"] = "ai_sample_insights" options["mode"] = "" options["keyword"] = prefix_match[1] if not options.get("action"): prefix_match = AgentResourceOfficer._match_command_prefix(raw, ["重放样本", "重识别样本", "重跑样本"]) if prefix_match: remain_text = prefix_match[1] match = re.search(r"\d+", remain_text) options["action"] = "ai_replay_failed_sample" options["mode"] = "" options["keyword"] = "" if match: options["sample_index"] = match.group(0) if "保留样本" in remain_text or "不移除" in remain_text: options["remove_if_resolved"] = "false" if not options.get("action"): for prefix in ["转存资源", "下载资源", "订阅并搜索", "订阅搜索", "订阅媒体"]: prefix_match = AgentResourceOfficer._match_command_prefix(raw, [prefix]) if prefix_match: options["action"] = "command_alias_removed" options["mode"] = "" options["keyword"] = prefix_match[1] options["type"] = prefix break if not options.get("action"): for prefix, action in [ ("转存", "cloud_transfer"), ("下载", "mp_download"), ("订阅", "mp_subscribe"), ]: if raw == prefix: options["action"] = action options["mode"] = "" options["keyword"] = "" break if raw.startswith(prefix + " "): remain_text = raw[len(prefix):].strip() if action == "cloud_transfer": if remain_text: options["action"] = "cloud_transfer_removed" options["mode"] = "" options["keyword"] = remain_text options["cloud_provider"] = "115" break if action == "mp_download": download_match = re.fullmatch(r"[##]?\s*(\d+)", remain_text) if download_match: options["action"] = action options["mode"] = "" options["keyword"] = download_match.group(1) else: options["action"] = "" options["mode"] = "mp_download_title" options["keyword"] = remain_text options["source_order_text"] = "mp_pt" else: options["action"] = action options["mode"] = "" options["keyword"] = remain_text break if raw.startswith(prefix): remain_text = raw[len(prefix):].strip() if not remain_text: continue if action == "cloud_transfer": options["action"] = "cloud_transfer_removed" options["mode"] = "" options["keyword"] = remain_text options["cloud_provider"] = "115" break if action == "mp_download": download_match = re.fullmatch(r"[##]?\s*(\d+)", remain_text) if not download_match: options["action"] = "" options["mode"] = "mp_download_title" options["keyword"] = remain_text options["source_order_text"] = "mp_pt" break options["action"] = action options["mode"] = "" options["keyword"] = download_match.group(1) break options["action"] = action options["mode"] = "" options["keyword"] = remain_text break if options.get("mode") in {"search", "mp", "mp_download_title"} and options.get("keyword"): filtered_keyword, extracted_filter = AgentResourceOfficer._extract_mp_result_filter_intent( options.get("keyword") or "" ) if extracted_filter: options["keyword"] = filtered_keyword.strip() options["result_filter"] = extracted_filter for token in remain.split(): item = token.strip() if not item: continue if "=" in item: key, value = item.split("=", 1) key = key.strip().lower() value = value.strip() if key in {"pwd", "passcode", "code", "提取码"} and value: options["access_code"] = value continue if key in {"path", "dir", "目录", "位置"} and value: options["path"] = AgentResourceOfficer._resolve_pan_path_value(value) continue if key in {"type", "媒体类型"} and value: options["type"] = value.strip().lower() continue if key in {"year", "年份"} and value: options["year"] = value.strip() continue if key in {"client_type", "client", "客户端"} and value: options["client_type"] = P115TransferService.normalize_qrcode_client_type(value) continue if key in {"hash", "download_hash", "任务hash"} and value: options["hash"] = value.strip() continue if item.startswith("/") and not options["path"]: options["path"] = AgentResourceOfficer._resolve_pan_path_value(item) if share_url and ( AgentResourceOfficer._is_quark_url(share_url) or AgentResourceOfficer._is_115_url(share_url) ): if options.get("action") in {"cloud_transfer_removed", "command_alias_removed"}: options["action"] = "" options["mode"] = "" options["keyword"] = AgentResourceOfficer._clean_text(remain) options["cloud_provider"] = "" options["type"] = "" if not options.get("action") and not options.get("mode"): check_match = re.match(r"^\s*检查\s*(.+)$", raw) if check_match: remain = AgentResourceOfficer._clean_text(check_match.group(1)) if remain and not re.match(r"^[0-9a-zA-Z]", remain): options["mode"] = "update" options["keyword"] = remain return options def _call_pansou_search(self, keyword: str) -> Tuple[bool, Dict[str, Any], str]: last_error = "" queries = [ {"kw": keyword, "res": "merge", "src": "all"}, {"kw": keyword}, {"keyword": keyword}, ] urls = [] base_urls = [] configured_base = self._clean_text(self._pansou_base_url).rstrip("/") if configured_base: base_urls.append(configured_base) for fallback_base in ("http://host.docker.internal:805", "http://127.0.0.1:805"): if fallback_base not in base_urls: base_urls.append(fallback_base) for query in queries: for base_url in base_urls: urls.append(f"{base_url}/api/search?{urlencode(query)}") data: Dict[str, Any] = {} for url in urls: try: request = UrlRequest(url=url, headers={"Accept": "application/json"}) with urlopen(request, timeout=self._pansou_timeout) as response: data = json.loads(response.read().decode("utf-8", "ignore")) break except Exception as exc: last_error = str(exc) data = {} if not data: return False, {}, f"盘搜请求失败:{last_error or '未知错误'}" ok = str(data.get("code")) == "0" if not ok: return False, data, str(data.get("message") or "盘搜搜索失败") return True, data, str(data.get("message") or "success") @staticmethod def _normalize_pansou_channel_name(channel: str) -> str: text = str(channel or "").strip().lower() if text == "115" or "115" in text: return "115" if "quark" in text: return "quark" return str(channel or "").strip() or "未知" def _collect_pansou_channel_items( self, merged: Dict[str, Any], channel_name: str, limit: int = 6, ) -> List[Dict[str, Any]]: raw_items = merged.get(channel_name) or [] if not isinstance(raw_items, list): return [] results: List[Dict[str, Any]] = [] seen = set() for item in raw_items: if not isinstance(item, dict): continue url = str(item.get("url") or "").strip() if not url: continue note = str(item.get("note") or "未命名资源").strip() password = str(item.get("password") or "").strip() source = str(item.get("source") or "").strip() dt = self._format_pansou_datetime(item.get("datetime")) key = (url, note) if key in seen: continue seen.add(key) normalized = { "channel": self._normalize_pansou_channel_name(channel_name), "url": url, "password": password, "note": note, "source": source, "datetime": dt, } for extra_key in [ "title", "remark", "description", "desc", "detail", "details", "summary", "share_size", "size", "subtitle", "subtitles", "subtitle_language", "subtitle_languages", "video_resolution", "videoFormat", "media_type", "type", "episode", "episodes", "episode_range", "update_status", "update_info", ]: value = item.get(extra_key) if value not in (None, "", []): normalized[extra_key] = value results.append(normalized) if len(results) >= limit: break return results @staticmethod def _extract_size_text(text: str) -> str: match = re.search(r"(\d+(?:\.\d+)?)\s*(gb|g|mb|m)\b", str(text or ""), flags=re.IGNORECASE) if not match: return "" value = match.group(1) unit = match.group(2).upper() if unit == "G": unit = "GB" if unit == "M": unit = "MB" return f"{value}{unit}" def _pansou_item_brief_summary(self, item: Dict[str, Any]) -> str: text = self._score_text_blob(item) parts: List[str] = [] if "2160" in text or "4k" in text or "uhd" in text: parts.append("4K") elif "1080" in text: parts.append("1080P") elif "720" in text: parts.append("720P") if self._score_has_any(text, ["dolby vision", "dovi", "dv", "杜比视界"]): parts.append("杜比视界") elif self._score_has_any(text, ["hdr10", "hdr", "hlg", "杜比"]): parts.append("HDR") if self._score_has_any(text, ["中字", "中文字幕", "简中", "繁中", "双语", "官中", "内封简繁"]): parts.append("中字") elif self._score_has_any(text, ["无中字", "无字幕"]): parts.append("无中字") progress = self._extract_series_progress(text) if progress.get("episode_count", 0) >= max(3, progress.get("max_episode", 0)): parts.append(f"E01-E{progress['max_episode']:02d}") elif progress.get("max_episode", 0) > 0: parts.append(f"更新到E{progress['max_episode']:02d}") size_text = self._extract_size_text(text) or self._extract_size_text(self._list_text(item.get("share_size") or item.get("size"))) if size_text: parts.append(size_text) return " | ".join(parts[:5]) @staticmethod def _format_markdown_link(title: Any, url: Any) -> str: clean_title = str(title or "").strip() clean_url = str(url or "").strip() if not clean_title: return clean_url if not clean_url: return clean_title safe_title = clean_title.replace("[", "[").replace("]", "]") return f"[{safe_title}]({clean_url})" @staticmethod def _parse_simple_cjk_number(text: str) -> Optional[int]: raw = str(text or "").strip() if not raw: return None if raw.isdigit(): return int(raw) mapping = {"零": 0, "〇": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9} if raw == "十": return 10 if "十" in raw: left, right = raw.split("十", 1) left_value = 1 if left == "" else mapping.get(left) if left_value is None: return None right_value = 0 if right == "" else mapping.get(right) if right_value is None: return None return left_value * 10 + right_value total = 0 for ch in raw: if ch not in mapping: return None total = total * 10 + mapping[ch] return total if total >= 0 else None @classmethod def _normalize_simple_cjk_numbers(cls, text: str) -> str: raw = str(text or "") if not raw: return "" pattern = re.compile(r"[零〇一二两三四五六七八九十]{1,3}") def repl(match: re.Match[str]) -> str: value = cls._parse_simple_cjk_number(match.group(0)) return str(value) if value is not None else match.group(0) return pattern.sub(repl, raw) @classmethod def _build_keyword_variants(cls, keyword: str) -> List[str]: raw = cls._clean_text(keyword) if not raw: return [] variants: List[str] = [] def add(value: Any) -> None: text = cls._clean_text(value) if not text: return if text not in variants: variants.append(text) add(raw) trimmed = re.split(r"[::((【\[\-_/|]", raw, 1)[0].strip() add(trimmed) if "的" in raw: add(raw.split("的", 1)[0].strip()) base_candidates = list(variants) for base in base_candidates: numeric = cls._normalize_simple_cjk_numbers(base) add(numeric) add(numeric.replace("个月", "ヶ月").replace("個月", "ヶ月")) add(numeric.replace("个月", "个月").replace("個月", "个月")) add(base.replace("三个月", "3个月").replace("三個月", "3个月")) add(base.replace("三个月", "3ヶ月").replace("三個月", "3ヶ月")) add(re.sub(r"\s+", "", base)) return variants def _pansou_payload_item_count(self, payload: Dict[str, Any]) -> int: data = payload.get("data") or {} merged = data.get("merged_by_type") or {} total = 0 for channel in ("115", "quark"): items = merged.get(channel) or [] if isinstance(items, list): total += len(items) return total def _call_pansou_search_with_variants(self, keyword: str) -> Tuple[bool, Dict[str, Any], str, str]: variants = self._build_keyword_variants(keyword) last_error = "" last_payload: Dict[str, Any] = {} for variant in variants: ok, payload, message = self._call_pansou_search(variant) if ok and self._pansou_payload_item_count(payload) > 0: return True, payload, message, variant last_error = message last_payload = payload if isinstance(payload, dict) else {} return False, last_payload, last_error or "盘搜暂无结果", self._clean_text(keyword) def _assistant_pansou_entry_summary(self, items: List[Dict[str, Any]]) -> Dict[str, Any]: best = self._best_scored_source_item(items) index = self._safe_int((best or {}).get("index") or (best or {}).get("pick_index"), 0) if index <= 0: return { "stage": "pansou_result", "label": "先看条目再选编号", "preferred_command": "", "fallback_command": "", "recommended_agent_behavior": "show_only", } return { "stage": "pansou_result", "label": "盘搜列表已返回", "decision_hint": "默认直接回编号即可转存;想先确认可回复“选择 编号 详情”。只有明确要生成计划时才发“计划选择 编号”。", "preferred_command": str(index), "fallback_command": f"选择 {index} 详情", "compact_commands": [str(index), f"选择 {index} 详情"], "preferred_requires_confirmation": True, "fallback_requires_confirmation": False, "can_auto_run_preferred": False, "recommended_agent_behavior": "show_only", } def _best_series_progress_item(self, items: List[Dict[str, Any]]) -> Dict[str, Any]: candidates: List[Dict[str, Any]] = [] for index, item in enumerate(items or [], 1): if not isinstance(item, dict): continue progress = self._extract_series_progress(self._score_text_blob(item)) enriched = dict(item) enriched["_series_progress"] = progress enriched["_index"] = self._safe_int(enriched.get("index"), index) candidates.append(enriched) if not candidates: return {} candidates.sort( key=lambda value: ( self._safe_int(((value.get("_series_progress") or {}).get("max_episode")), 0), self._safe_int(((value.get("_series_progress") or {}).get("episode_count")), 0), self._safe_int((((value.get("score") or {}) if isinstance(value.get("score"), dict) else {}).get("score")), 0), ), reverse=True, ) return candidates[0] def _latest_series_progress_items(self, items: List[Dict[str, Any]], limit: int = 5) -> List[Dict[str, Any]]: candidates: List[Dict[str, Any]] = [] for index, item in enumerate(items or [], 1): if not isinstance(item, dict): continue progress = self._extract_series_progress(self._score_text_blob(item)) enriched = dict(item) enriched["_series_progress"] = progress enriched["_index"] = self._safe_int(enriched.get("index"), index) candidates.append(enriched) if not candidates: return [] best_episode = max(self._safe_int(((item.get("_series_progress") or {}).get("max_episode")), 0) for item in candidates) if best_episode <= 0: return [] matches = [ item for item in candidates if self._safe_int(((item.get("_series_progress") or {}).get("max_episode")), 0) == best_episode ] matches.sort( key=lambda value: ( self._safe_int(((value.get("_series_progress") or {}).get("episode_count")), 0), self._safe_int((((value.get("score") or {}) if isinstance(value.get("score"), dict) else {}).get("score")), 0), self._safe_int(value.get("updated_at"), 0), self._clean_text(value.get("datetime")), ), reverse=True, ) return matches[: max(1, limit)] def _latest_episode_mp_items(self, items: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], int]: candidates: List[Dict[str, Any]] = [] for item in items or []: if not isinstance(item, dict): continue progress = self._extract_series_progress(self._score_text_blob(item)) max_episode = self._safe_int(progress.get("max_episode"), 0) if max_episode <= 0: continue enriched = dict(item) enriched["_series_progress"] = progress candidates.append(enriched) if not candidates: return [], 0 latest_episode = max(self._safe_int((item.get("_series_progress") or {}).get("max_episode"), 0) for item in candidates) if latest_episode <= 0: return [], 0 latest_items = [ item for item in candidates if self._safe_int((item.get("_series_progress") or {}).get("max_episode"), 0) == latest_episode ] return latest_items, latest_episode def _episode_filter_mp_items(self, items: List[Dict[str, Any]], episode: int) -> List[Dict[str, Any]]: target = self._safe_int(episode, 0) if target <= 0: return [] matches: List[Dict[str, Any]] = [] for item in items or []: if not isinstance(item, dict): continue progress = self._extract_series_progress(self._score_text_blob(item)) max_episode = self._safe_int(progress.get("max_episode"), 0) episode_count = self._safe_int(progress.get("episode_count"), 0) if max_episode == target: enriched = dict(item) enriched["_series_progress"] = progress matches.append(enriched) continue if episode_count > 0 and max_episode >= target: start_episode = max(1, max_episode - episode_count + 1) if start_episode <= target <= max_episode: enriched = dict(item) enriched["_series_progress"] = progress matches.append(enriched) return matches def _mp_episode_plan_priority(self, item: Dict[str, Any], target_episode: int) -> Tuple[int, int, int]: target = self._safe_int(target_episode, 0) if target <= 0 or not isinstance(item, dict): return (9, 9, 9) progress = item.get("_series_progress") if isinstance(item.get("_series_progress"), dict) else self._extract_series_progress(self._score_text_blob(item)) max_episode = self._safe_int(progress.get("max_episode"), 0) episode_count = self._safe_int(progress.get("episode_count"), 0) if max_episode <= 0: return (9, 9, 9) count = max(1, episode_count or 1) start_episode = max(1, max_episode - count + 1) if not (start_episode <= target <= max_episode): return (9, 9, 9) exact_single = count == 1 and max_episode == target at_edge = target == start_episode or target == max_episode if exact_single: return (0, 0, count) if count <= 2 and at_edge: return (1, 0, count) if count <= 3: return (2 if at_edge else 3, 0 if at_edge else 1, count) if count <= 6: return (4 if at_edge else 5, 0 if at_edge else 1, count) return (6 if at_edge else 7, 0 if at_edge else 1, count) def _sort_mp_episode_filtered_items(self, items: List[Dict[str, Any]], target_episode: int) -> List[Dict[str, Any]]: ranked = [ dict(item or {}) for item in (items or []) if isinstance(item, dict) ] target = self._safe_int(target_episode, 0) ranked.sort( key=lambda item: ( self._mp_episode_plan_priority(item, target), -self._safe_int((item.get("score") or {}).get("score"), 0), -self._score_quality_rank(item), -self._safe_int(((item.get("torrent_info") or {}).get("seeders")), 0), self._safe_int(item.get("index"), 0), ), ) for index, item in enumerate(ranked, start=1): item["index"] = index return ranked def _latest_resource_date_text(self, items: List[Dict[str, Any]]) -> str: latest_text = "" latest_unix = 0 for item in items or []: if not isinstance(item, dict): continue text = self._clean_text(item.get("datetime") or item.get("updated_at_text")) if text and text > latest_text: latest_text = text unix_value = self._safe_int(item.get("updated_at"), 0) if unix_value > latest_unix: latest_unix = unix_value if latest_text: return latest_text if latest_unix > 0: return self._format_unix_time(latest_unix) return "" def _format_update_resource_choice(self, item: Dict[str, Any], source_type: str) -> str: current = dict(item or {}) index = self._safe_int(current.get("_index") or current.get("index"), 0) provider = self._clean_text(current.get("channel") or current.get("pan_type")).upper() if provider == "QUARK": provider = "夸克" elif provider == "115": provider = "115" provider_emoji = "🗄" if provider == "夸克" else "📺" if provider == "115" else "🔗" label = f"{index}. {provider_emoji}" if index > 0 else f"?. {provider_emoji}" if provider: label = f"{label} {provider}" if source_type == "pansou": brief = self._pansou_item_brief_summary(current) date_text = self._format_pansou_display_datetime(current.get("datetime")) title_text = self._clean_text(current.get("note") or current.get("title") or "盘搜资源") parts = [label] if date_text: parts.append(date_text) if brief: parts.append(brief) if title_text and title_text != brief: parts.append(self._truncate_text(title_text, 80)) return " · ".join(parts) progress = self._format_update_progress_label(current.get("_series_progress")) subtitle = self._resource_subtitle_text(current) resolution = "/".join(current.get("video_resolution") or []) or "" date_text = self._clean_text(current.get("updated_at_text")) parts = [label] if date_text: parts.append(self._format_pansou_display_datetime(date_text)) if resolution: parts.append(f"✨{resolution}" if "4" in resolution or "2160" in resolution else resolution) if subtitle: parts.append(f"字幕:{subtitle}") if progress and progress != "未识别到集数": parts.append(f"📌 {progress}") detail = self._clean_text(current.get("title") or current.get("remark") or current.get("description")) if detail: parts.append(self._truncate_text(detail, 70)) return " · ".join(parts) def _format_update_progress_label(self, progress: Optional[Dict[str, Any]] = None) -> str: current = dict(progress or {}) max_episode = self._safe_int(current.get("max_episode"), 0) episode_count = self._safe_int(current.get("episode_count"), 0) if max_episode <= 0: return "未识别到集数" if episode_count >= max_episode >= 2: return f"E01-E{max_episode:02d}" return f"更新到 E{max_episode:02d}" async def _assistant_update_check( self, *, keyword: str, session: str, cache_key: str, year: str = "", source_filter: str = "", ) -> Dict[str, Any]: clean_keyword = self._clean_text(keyword) source_filter = self._clean_text(source_filter).lower() if not clean_keyword: return { "success": False, "message": "用法:更新检查 片名;云盘侧可用:盘搜更新检查 片名 / 影巢更新检查 片名", "data": self._assistant_response_data(session=session, data={"action": "update_check", "ok": False, "error_code": "missing_keyword"}), } official = self._tmdb_latest_episode_progress(clean_keyword, year) official_episode = self._safe_int(official.get("episode"), 0) preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) pansou_best: Dict[str, Any] = {} pansou_latest_items: List[Dict[str, Any]] = [] pansou_recent_date = "" pansou_total = 0 allow_pansou_update = source_filter in {"", "pansou"} allow_hdhive_update = source_filter in {"", "hdhive"} if allow_pansou_update and self._assistant_source_enabled(preferences, "pansou"): search_ok, payload, _search_message = self._call_pansou_search(clean_keyword) else: search_ok, payload = False, {} if search_ok: data = payload.get("data") or {} merged = data.get("merged_by_type") or {} pansou_total = self._safe_int(data.get("total"), 0) raw_items = self._collect_pansou_channel_items(merged, "115", 20) + self._collect_pansou_channel_items(merged, "quark", 20) scored_items = self._attach_cloud_scores(raw_items, preferences=preferences, source_type="pansou", target_path=self._hdhive_default_path) pansou_best = self._best_series_progress_item(scored_items) pansou_latest_items = self._latest_series_progress_items(scored_items, limit=5) pansou_recent_date = self._latest_resource_date_text(scored_items) hdhive_best: Dict[str, Any] = {} hdhive_latest_items: List[Dict[str, Any]] = [] hdhive_recent_date = "" allowed = allow_hdhive_update and self._assistant_source_enabled(preferences, "hdhive") if allowed: service = self._ensure_hdhive_service() candidate_ok, candidate_result, _candidate_message = await service.resolve_candidates_by_keyword( keyword=clean_keyword, media_type="tv", year=self._clean_text(year), candidate_limit=max(10, self._hdhive_candidate_page_size), ) if candidate_ok: candidates = candidate_result.get("candidates") or [] chosen = next((item for item in candidates if self._clean_text(item.get("media_type")).lower() in {"tv", "series"}), None) if chosen is None and candidates: chosen = candidates[0] if isinstance(chosen, dict): resource_ok, resource_result, _resource_message = service.search_resources( media_type=chosen.get("media_type") or "tv", tmdb_id=str(chosen.get("tmdb_id") or ""), ) if resource_ok: preview = self._attach_cloud_scores( self._group_resource_preview(resource_result.get("data") or [], per_group=None), preferences=preferences, source_type="hdhive", target_path=self._hdhive_default_path, ) hdhive_best = self._best_series_progress_item(preview) hdhive_latest_items = self._latest_series_progress_items(preview, limit=5) hdhive_recent_date = self._latest_resource_date_text(preview) pansou_progress = dict(pansou_best.get("_series_progress") or {}) hdhive_progress = dict(hdhive_best.get("_series_progress") or {}) title_prefix = "盘搜更新检查" if source_filter == "pansou" else "影巢更新检查" if source_filter == "hdhive" else "更新检查" lines = [f"{title_prefix}:{clean_keyword}"] if official_episode > 0: official_title = self._clean_text(official.get("title")) or clean_keyword lines.append(f"📺 TMDB 进度:{official_title} S{self._safe_int(official.get('season'), 1):02d}E{official_episode:02d}") else: lines.append("📺 TMDB 进度:未稳定识别到最新集数") if pansou_best: lines.append( f"\n🟨 盘搜结果:{self._format_update_progress_label(pansou_progress)}" f" · 最佳 #{self._safe_int(pansou_best.get('_index'), 0)}" ) if pansou_latest_items: for item in pansou_latest_items: lines.append(self._format_update_resource_choice(item, "pansou")) elif pansou_recent_date: lines.append(f"🕒 最近资源日期:{pansou_recent_date}(未稳定识别到明确集数)") else: lines.append("\n🟨 盘搜结果:" + ("未检查" if not allow_pansou_update else "已关闭" if not self._assistant_source_enabled(preferences, "pansou") else "暂无可识别更新结果")) if pansou_recent_date: lines.append(f"🕒 最近资源日期:{pansou_recent_date}(未稳定识别到明确集数)") if hdhive_best: lines.append( f"\n🟦 影巢结果:{self._format_update_progress_label(hdhive_progress)}" f" · 最佳 #{self._safe_int(hdhive_best.get('_index'), 0)}" ) if hdhive_latest_items: for item in hdhive_latest_items: lines.append(self._format_update_resource_choice(item, "hdhive")) elif hdhive_recent_date: lines.append(f"🕒 最近资源时间:{hdhive_recent_date}(未稳定识别到明确集数)") else: lines.append("\n🟦 影巢结果:" + ("未检查" if not allow_hdhive_update else "已关闭" if not self._assistant_source_enabled(preferences, "hdhive") else "暂无可识别更新结果")) if hdhive_recent_date: lines.append(f"🕒 最近资源时间:{hdhive_recent_date}(未稳定识别到明确集数)") latest_seen = max( official_episode, self._safe_int(pansou_progress.get("max_episode"), 0), self._safe_int(hdhive_progress.get("max_episode"), 0), ) downloadable_latest = max( self._safe_int(pansou_progress.get("max_episode"), 0), self._safe_int(hdhive_progress.get("max_episode"), 0), ) if latest_seen > 0: lines.append(f"\n🔎 当前观察到最高更新:E{latest_seen:02d}") if downloadable_latest > 0: lines.append(f"✅ 已可下载最新集:E{downloadable_latest:02d}") else: lines.append("⚠️ 已可下载最新集:暂未稳定识别") if official_episode > 0: pansou_ok = self._safe_int(pansou_progress.get("max_episode"), 0) >= official_episode hdhive_ok = self._safe_int(hdhive_progress.get("max_episode"), 0) >= official_episode if pansou_ok and hdhive_ok: lines.append("✅ 盘搜和影巢都已跟上 TMDB 进度。") else: lines.append(f"{'✅' if pansou_ok else '⏳'} 盘搜:{'已跟上' if pansou_ok else '还没稳定跟上'}TMDB 进度。") lines.append(f"{'✅' if hdhive_ok else '⏳'} 影巢:{'已跟上' if hdhive_ok else '还没稳定跟上'}TMDB 进度。") pt_search_needed = latest_seen <= 0 and official_episode <= 0 cloud_sources_enabled = self._assistant_source_enabled(preferences, "pansou") or self._assistant_source_enabled(preferences, "hdhive") if not cloud_sources_enabled: lines.append("\n下一步:盘搜和影巢都已关闭;如需继续扩搜,请直接使用 MP搜索 或 PT搜索。") elif pt_search_needed: lines.append(f"\n下一步:官方和云盘侧都还没看到明确新集;如果要继续扩搜,可以回复:PT搜索 {clean_keyword}。") else: lines.append("\n下一步:直接回编号可继续处理;想先确认可发“选择 编号 详情”。") lines.append(f"也可以发“盘搜搜索 {clean_keyword}”或“影巢搜索 {clean_keyword}”只看单一来源。") update_items: List[Dict[str, Any]] = [] for item in pansou_latest_items: if isinstance(item, dict): update_items.append({**item, "_update_source": "pansou"}) for item in hdhive_latest_items: if isinstance(item, dict): update_items.append({**item, "_update_source": "hdhive"}) self._save_session(cache_key, { "kind": "assistant_update_check", "stage": "result", "keyword": clean_keyword, "source_filter": source_filter, "year": self._clean_text(year), "items": update_items, "pansou_items": pansou_latest_items, "hdhive_items": hdhive_latest_items, "target_path": self._hdhive_default_path, }) return { "success": True, "message": "\n".join(lines), "data": self._assistant_response_data(session=session, data={ "action": "update_check", "ok": True, "keyword": clean_keyword, "source_filter": source_filter, "official_progress": official, "pansou_best": pansou_best, "pansou_latest_items": pansou_latest_items, "pansou_recent_date": pansou_recent_date, "hdhive_best": hdhive_best, "hdhive_latest_items": hdhive_latest_items, "hdhive_recent_date": hdhive_recent_date, "latest_seen_episode": latest_seen, "downloadable_latest_episode": downloadable_latest, "decision_summary": { "stage": "update_check", "label": "更新检查已完成", "preferred_command": f"PT搜索 {clean_keyword}" if pt_search_needed else f"盘搜搜索 {clean_keyword}", "fallback_command": f"盘搜搜索 {clean_keyword}" if pt_search_needed else f"影巢搜索 {clean_keyword}", "compact_commands": [f"PT搜索 {clean_keyword}", f"盘搜搜索 {clean_keyword}"] if pt_search_needed else [f"盘搜搜索 {clean_keyword}", f"影巢搜索 {clean_keyword}"], "recommended_agent_behavior": "show_only", "preferred_requires_confirmation": False, "fallback_requires_confirmation": False, "can_auto_run_preferred": False, }, }), } @classmethod def _read_tmdb_api_key(cls) -> str: for value in [ os.environ.get("TMDB_API_KEY", ""), os.environ.get("TMDB_KEY", ""), cls._clean_text(getattr(settings, "TMDB_API_KEY", "") if settings is not None else ""), ]: if cls._clean_text(value): return cls._clean_text(value) compose_path = Path("/Applications/Dockge/moviepilot-ai-recognizer-gateway/docker-compose.yml") if compose_path.exists(): for line in compose_path.read_text(encoding="utf-8", errors="ignore").splitlines(): if "TMDB_API_KEY" not in line: continue _, _, value = line.partition(":") key = cls._clean_text(value.strip().strip("'\"")) if key: return key return "" @classmethod def _fetch_candidate_actors(cls, tmdb_id: Any, media_type: str) -> List[str]: clean_tmdb_id = cls._clean_text(tmdb_id) clean_media_type = cls._clean_text(media_type).lower() if not clean_tmdb_id or clean_media_type not in {"movie", "tv"}: return [] cache_key = f"{clean_media_type}:{clean_tmdb_id}" with cls._candidate_actor_cache_lock: cached = cls._candidate_actor_cache.get(cache_key) if cached is not None: return list(cached) tmdb_api_key = cls._read_tmdb_api_key() if not tmdb_api_key: return [] endpoint = "movie" if clean_media_type == "movie" else "tv" url = ( f"https://api.themoviedb.org/3/{endpoint}/{clean_tmdb_id}?" f"{urlencode({'api_key': tmdb_api_key, 'language': 'zh-CN', 'append_to_response': 'credits'})}" ) actors: List[str] = [] try: request = UrlRequest(url=url, headers={"Accept": "application/json"}) with urlopen(request, timeout=20) as response: payload = json.loads(response.read().decode("utf-8", "ignore")) cast = ((payload.get("credits") or {}).get("cast") or []) if isinstance(payload, dict) else [] for member in cast[:10]: name = cls._clean_text((member or {}).get("name")) department = cls._clean_text((member or {}).get("known_for_department")) if not name: continue if department and department != "Acting": continue if name not in actors: actors.append(name) if len(actors) >= 2: break except Exception: actors = [] with cls._candidate_actor_cache_lock: cls._candidate_actor_cache[cache_key] = list(actors) return actors @classmethod def _tmdb_latest_episode_progress(cls, keyword: str, year: str = "") -> Dict[str, Any]: title = cls._clean_text(keyword) if not title: return {} tmdb_api_key = cls._read_tmdb_api_key() if not tmdb_api_key: return {} params = { "api_key": tmdb_api_key, "language": "zh-CN", "query": title, } try: search_url = "https://api.themoviedb.org/3/search/tv?" + urlencode(params) request = UrlRequest(url=search_url, headers={"Accept": "application/json"}) with urlopen(request, timeout=20) as response: payload = json.loads(response.read().decode("utf-8", "ignore")) results = payload.get("results") or [] if not isinstance(results, list) or not results: return {} target_year = cls._clean_text(year)[:4] picked = None for item in results: first_air = cls._clean_text((item or {}).get("first_air_date"))[:4] if target_year and first_air and first_air == target_year: picked = item break if picked is None: picked = results[0] tv_id = cls._clean_text((picked or {}).get("id")) if not tv_id: return {} detail_url = ( f"https://api.themoviedb.org/3/tv/{tv_id}?" + urlencode({"api_key": tmdb_api_key, "language": "zh-CN"}) ) detail_request = UrlRequest(url=detail_url, headers={"Accept": "application/json"}) with urlopen(detail_request, timeout=20) as response: detail = json.loads(response.read().decode("utf-8", "ignore")) last_episode = (detail.get("last_episode_to_air") or {}) if isinstance(detail, dict) else {} season_number = cls._safe_int(last_episode.get("season_number"), 0) episode_number = cls._safe_int(last_episode.get("episode_number"), 0) return { "tmdb_id": tv_id, "title": cls._clean_text(detail.get("name") or picked.get("name") or title), "year": cls._clean_text(detail.get("first_air_date") or picked.get("first_air_date"))[:4], "season": season_number, "episode": episode_number, "status": cls._clean_text(detail.get("status")), } except Exception: return {} def _maybe_enrich_hdhive_candidate_with_actors(self, candidate: Dict[str, Any]) -> Dict[str, Any]: enriched = dict(candidate or {}) if enriched.get("actors"): return enriched enriched["actors"] = self._fetch_candidate_actors( enriched.get("tmdb_id"), str(enriched.get("media_type") or enriched.get("type") or ""), ) return enriched def _enrich_hdhive_candidates_with_actors(self, candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]: indexed = [(idx, dict(item or {})) for idx, item in enumerate(candidates)] pending = [(idx, item) for idx, item in indexed if not (item.get("actors") or [])] enriched_map: Dict[int, Dict[str, Any]] = {idx: item for idx, item in indexed} if pending: with concurrent.futures.ThreadPoolExecutor(max_workers=min(4, len(pending))) as executor: future_map = { executor.submit(self._maybe_enrich_hdhive_candidate_with_actors, item): idx for idx, item in pending } for future in concurrent.futures.as_completed(future_map): idx = future_map[future] try: enriched_map[idx] = future.result() except Exception: enriched_map[idx] = dict(indexed[idx][1]) return [enriched_map[idx] for idx, _ in indexed] @staticmethod def _format_candidate_label(item: Dict[str, Any]) -> str: title = str(item.get("title") or "未命名") year = str(item.get("year") or "?") raw_media_type = str(item.get("media_type") or item.get("type") or "?") media_type = { "movie": "电影", "tv": "剧集", "series": "剧集", }.get(raw_media_type.lower(), raw_media_type) actors = item.get("actors") or [] parts = [year, media_type] actor_text = " / ".join(str(name).strip() for name in actors[:2] if str(name).strip()) if actor_text: parts.append(f"主演:{actor_text}") return f"{title} ({' | '.join([part for part in parts if part])})" @staticmethod def _format_candidate_lines(candidates: List[Dict[str, Any]], page: int = 1, page_size: int = 10) -> str: if not candidates: return "候选影片:0 个" safe_page_size = max(1, int(page_size or 10)) total_pages = max(1, (len(candidates) + safe_page_size - 1) // safe_page_size) safe_page = min(max(1, int(page or 1)), total_pages) start = (safe_page - 1) * safe_page_size page_items = candidates[start:start + safe_page_size] lines = [f"候选影片:{len(candidates)} 个,请先选择影片:"] if total_pages > 1: lines.append(f"当前第 {safe_page}/{total_pages} 页,每页 {safe_page_size} 条:") for idx, item in enumerate(page_items, start=start + 1): lines.append(f"{idx}. {AgentResourceOfficer._format_candidate_label(item)}") lines.append("下一步:回复“选择 编号”查看该影片的影巢资源。") lines.append("如需补充当前候选页全部主演,可回复:详情 或 审查。") if safe_page < total_pages: lines.append("如需继续翻页,可回复:n 下一页") return "\n".join(lines) @staticmethod def _format_mp_candidate_lines(candidates: List[Dict[str, Any]], page: int = 1, page_size: int = 10) -> str: if not candidates: return "MP 搜索候选:0 个" safe_page_size = max(1, int(page_size or 10)) total_pages = max(1, (len(candidates) + safe_page_size - 1) // safe_page_size) safe_page = min(max(1, int(page or 1)), total_pages) start = (safe_page - 1) * safe_page_size page_items = candidates[start:start + safe_page_size] lines = [f"MP 搜索候选:{len(candidates)} 个,请先选择正确的电影/剧集:"] if total_pages > 1: lines.append(f"当前第 {safe_page}/{total_pages} 页,每页 {safe_page_size} 条:") for idx, item in enumerate(page_items, start=start + 1): lines.append(f"{idx}. {AgentResourceOfficer._format_candidate_label(item)}") lines.append("下一步:回复编号或“选择 编号”,选定后再搜索 PT 资源。") lines.append("如需补充当前候选页主演,可回复:详情 或 审查。") lines.append("如果已经知道年份,也可以直接发:MP搜索 片名 年份。") if safe_page < total_pages: lines.append("如需继续翻页,可回复:n 下一页") return "\n".join(lines) @classmethod def _keyword_has_explicit_year(cls, keyword: Any, year: Any = "") -> bool: return bool(cls._clean_text(year) or re.search(r"(?:19|20)\d{2}", cls._clean_text(keyword))) @staticmethod def _list_text(value: Any, separator: str = "/") -> str: if value is None: return "" if isinstance(value, (list, tuple, set)): parts = [str(item).strip() for item in value if str(item).strip()] return separator.join(parts) return str(value).strip() @staticmethod def _truncate_text(value: Any, limit: int = 140) -> str: text = re.sub(r"\s+", " ", str(value or "")).strip() if not text: return "" if len(text) <= limit: return text return f"{text[: max(0, limit - 1)]}…" @staticmethod def _resource_points_text(item: Dict[str, Any]) -> str: points = item.get("unlock_points") if points is None: points = item.get("cost") if points is None: points = item.get("points") if points in (0, "0"): return "免费" if points in (None, "", "未知"): return "免费" if AgentResourceOfficer._resource_has_free_marker(item) else "未标积分" return f"{points}分" @staticmethod def _resource_subtitle_text(item: Dict[str, Any]) -> str: language = AgentResourceOfficer._list_text( item.get("subtitle_language") or item.get("subtitle_languages") or item.get("subtitles") or item.get("subtitle") ) subtitle_type = AgentResourceOfficer._list_text(item.get("subtitle_type") or item.get("subtitle_types")) if language and subtitle_type: return f"{language} · {subtitle_type}" return language or subtitle_type @staticmethod def _resource_episode_text(item: Dict[str, Any]) -> str: explicit_keys = [ "episode_range", "episodes_range", "episode_info", "episodes", "episode", "update_status", "update_info", "season_episode", ] for key in explicit_keys: text = AgentResourceOfficer._list_text(item.get(key)) if text: return AgentResourceOfficer._truncate_text(text, 40) source_text = " ".join( str(item.get(key) or "") for key in ["title", "remark", "description", "desc", "detail", "note"] ) patterns = [ r"(全\s*\d+\s*集)", r"(全集)", r"(更新至\s*第?\s*\d+\s*集)", r"(更\s*\d+\s*集)", r"(第?\s*\d+\s*[-~到至]\s*\d+\s*集)", r"(\d+\s*-\s*\d+\s*集)", r"(S\d{1,2}E\d{1,3}(?:\s*[-~]\s*E?\d{1,3})?)", r"(EP?\s*\d{1,3}(?:\s*[-~]\s*EP?\s*\d{1,3})?)", ] for pattern in patterns: match = re.search(pattern, source_text, flags=re.IGNORECASE) if match: return re.sub(r"\s+", "", match.group(1)) return "" @staticmethod def _resource_remark_text(item: Dict[str, Any]) -> str: for key in ["remark", "description", "desc", "detail", "details", "summary", "note"]: text = AgentResourceOfficer._truncate_text(item.get(key), 160) if text: return text return "" def _format_resource_lines( self, resources: List[Dict[str, Any]], candidate: Optional[Dict[str, Any]] = None, *, page: int = 1, page_size: int = 10, total_resources: Optional[int] = None, ) -> str: safe_page, safe_page_size, total_pages, start, end = self._page_bounds(len(resources), page=page, page_size=page_size) page_items = resources[start:end] total_count = max(0, self._safe_int(total_resources if total_resources is not None else len(resources), len(resources))) lines = [] if candidate: candidate_title = str(candidate.get("title") or "未命名") candidate_year = str(candidate.get("year") or "?") lines.append(f"已选影片:{candidate_title} ({candidate_year})") lines.append(f"资源结果:共 {total_count} 条") if total_pages > 1 and page_items: first_visible = self._safe_int((page_items[0] or {}).get("pick_index"), start + 1) last_visible = self._safe_int((page_items[-1] or {}).get("pick_index"), min(len(resources), end)) lines.append(f"当前第 {safe_page}/{total_pages} 页,展示编号 {first_visible}-{last_visible} / 共 {total_count} 条:") current_provider = "" for local_idx, item in enumerate(page_items, start=1): provider = str(item.get("pan_type") or "?").lower() if provider != current_provider: current_provider = provider if lines and lines[-1] != "": lines.append("") if provider == "115": lines.append("🟦 115 结果") elif provider == "quark": lines.append("🟨 夸克结果") else: lines.append(f"{provider} 结果") global_index = self._safe_int(item.get("pick_index"), start + local_idx) lines.append(self._format_hdhive_resource_summary_line(item, global_index)) lines.append("") summary = self._score_summary(page_items, limit=5) best_index = self._safe_int((((summary or {}).get("best") or {}).get("index")), 0) detail_hint = f"选择 {best_index} 详情" if best_index > 0 else "选择 1 详情" suggestion_lines = self._format_hdhive_selection_suggestion(resources) if suggestion_lines: lines.append("") lines.extend(suggestion_lines) lines.append(f"下一步:直接回编号即可转存;想先确认可发“{detail_hint}”。") lines.append("如需保留计划确认链,可再发“计划选择 编号”。") if safe_page < total_pages: lines.append("如需继续翻页,可回复:n 下一页") return "\n".join(line for line in lines if line is not None) def _assistant_hdhive_resource_entry_summary(self, resources: List[Dict[str, Any]]) -> Dict[str, Any]: best = self._best_scored_source_item(resources) index = self._safe_int((best or {}).get("index") or (best or {}).get("pick_index"), 0) if index <= 0: return { "stage": "hdhive_resource", "label": "先查看条目再选择编号", "preferred_command": "", "fallback_command": "", "recommended_agent_behavior": "show_only", } return { "stage": "hdhive_resource", "label": "影巢资源列表已返回", "decision_hint": "默认按编号直接选择;想先看详情可回复“选择 编号 详情”。", "preferred_command": str(index), "fallback_command": f"选择 {index} 详情", "compact_commands": [str(index), f"选择 {index} 详情"], "preferred_requires_confirmation": True, "fallback_requires_confirmation": False, "can_auto_run_preferred": False, "recommended_agent_behavior": "show_only", } @classmethod def _normalize_hdhive_resource_short_action( cls, value: Any, *, state: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: current_state = dict(state or {}) if cls._clean_text(current_state.get("kind")) != "assistant_hdhive" or cls._clean_text(current_state.get("stage")) != "resource": return {} raw = cls._clean_text(value) patterns = [ (r"^\s*(?:详情|查看详情|看详情)\s*(\d+)\s*$", {"action": "detail"}), (r"^\s*(?:计划|生成计划|先计划)\s*(\d+)\s*$", {"action": "plan"}), ] for pattern, base in patterns: match = re.match(pattern, raw, flags=re.IGNORECASE) if match: return {"index": match.group(1), **base} return {} @staticmethod def _format_route_result(result: Dict[str, Any]) -> str: lines = ["已执行资源路由"] route = result.get("route") or {} provider = str(route.get("provider") or route.get("pan_type") or "") if provider: lines.append(f"网盘:{provider}") if route.get("message"): lines.append(f"结果:{route.get('message')}") if route.get("target_path"): lines.append(f"目录:{route.get('target_path')}") return "\n".join(lines) async def _unlock_and_route( self, slug: str, target_path: str = "", resource: Optional[Dict[str, Any]] = None, ) -> Tuple[bool, Dict[str, Any], str]: allowed, disabled = self._ensure_hdhive_resource_enabled() if not allowed: return False, disabled.get("data") or {}, disabled.get("message") or "影巢资源入口已关闭" points_ok, points_message, points_data = self._check_hdhive_unlock_points_limit(resource) if not points_ok: return False, {"resource_guard": points_data, "resource": resource or {}}, points_message service = self._ensure_hdhive_service() unlock_ok, result, unlock_message = service.unlock_resource(slug) if not unlock_ok: return False, result, unlock_message unlock_data = result.get("data") or {} share_url = self._clean_text(unlock_data.get("full_url") or unlock_data.get("url")) access_code = self._clean_text(unlock_data.get("access_code")) pan_type = self._clean_text(unlock_data.get("pan_type")).lower() route_result: Dict[str, Any] = { "unlock": result, "route": { "pan_type": pan_type or "unknown", "share_url": share_url, "access_code": access_code, "executed": False, "message": "", }, } if share_url and (pan_type == "quark" or self._is_quark_url(share_url)): quark_service = self._ensure_quark_service() transfer_ok, transfer_result, transfer_message = quark_service.transfer_share( share_url, access_code=access_code, target_path=target_path or self._quark_default_path, trigger="Agent影视助手 影巢解锁后自动路由", ) route_result["route"].update( { "executed": True, "provider": "quark", "target_path": target_path or self._quark_default_path, "message": transfer_message, "result": transfer_result, } ) if not transfer_ok: return False, route_result, ( "影巢解锁成功,但" + self._format_quark_transfer_failure( detail=transfer_message, target_path=target_path or self._quark_default_path, ) ) return True, route_result, "success" if share_url and (pan_type == "115" or self._is_115_url(share_url)): p115_service = self._ensure_p115_service() transfer_ok, transfer_result, transfer_message = p115_service.transfer_share( url=share_url, access_code=access_code, path=target_path or self._p115_default_path, trigger="Agent影视助手 影巢解锁后自动路由", ) route_result["route"].update( { "executed": True, "provider": "115", "target_path": target_path or self._p115_default_path, "message": transfer_message, "result": transfer_result, } ) if not transfer_ok: return False, route_result, self._format_p115_transfer_failure( detail=transfer_message, target_path=target_path or self._p115_default_path, title="影巢解锁成功,但 115 转存失败", ) return True, route_result, "success" route_result["route"]["message"] = "当前解锁结果未识别到可自动路由的 115 / 夸克链接" return True, route_result, "success" @staticmethod def _is_quark_url(value: str) -> bool: return QuarkTransferService.is_quark_share_url(value) @staticmethod def _is_115_url(value: str) -> bool: host = urlparse(value or "").netloc.lower() return host == "115.com" or host.endswith(".115.com") or "115cdn.com" in host @staticmethod def _run_coroutine_sync(coro): try: return asyncio.run(coro) except RuntimeError: loop = asyncio.new_event_loop() try: return loop.run_until_complete(coro) finally: loop.close() def feishu_assistant_route(self, text: str, session: str) -> Dict[str, Any]: return self._run_coroutine_sync( self.api_assistant_route( _JsonRequestShim( _RequestContextShim(), { "text": self._clean_text(text), "session": self._clean_text(session) or "feishu", "compact": False, }, ) ) ) def feishu_assistant_pick(self, arg: str, session: str) -> Dict[str, Any]: index, target_path, action, mode = self._parse_feishu_pick_arg(arg) return self._run_coroutine_sync( self.api_assistant_pick( _JsonRequestShim( _RequestContextShim(), { "session": self._clean_text(session) or "feishu", "index": index, "action": action, "mode": mode, "path": target_path, "compact": False, }, ) ) ) @classmethod def _parse_feishu_pick_arg(cls, arg: str) -> Tuple[int, str, str, str]: return cls._parse_pick_text(arg) async def tool_hdhive_search_session( self, keyword: str, media_type: str = "auto", year: str = "", target_path: str = "", ) -> str: if not self._enabled: return "Agent影视助手 插件未启用" allowed, disabled = self._ensure_hdhive_resource_enabled() if not allowed: return str(disabled.get("message") or "影巢资源入口已关闭") service = self._ensure_hdhive_service() search_ok, result, search_message = await service.resolve_candidates_by_keyword( keyword=self._clean_text(keyword), media_type=self._clean_text(media_type or "auto").lower(), year=self._clean_text(year), candidate_limit=max(30, self._hdhive_candidate_page_size), ) if not search_ok: return f"影巢搜索失败:{search_message}" candidates = result.get("candidates") or [] session_id = self._new_session_id("hdhive") self._save_session( session_id, { "kind": "hdhive", "stage": "candidate", "keyword": self._clean_text(keyword), "media_type": self._clean_text(media_type or "auto").lower(), "year": self._clean_text(year), "target_path": self._clean_text(target_path), "candidates": candidates, "page": 1, "page_size": self._hdhive_candidate_page_size, }, ) return ( f"{self._format_candidate_lines(candidates, page=1, page_size=self._hdhive_candidate_page_size)}\n" f"session_id: {session_id}\n" "下一步:调用 agent_resource_officer_hdhive_pick,并传入 session_id 与 choice" ) async def tool_hdhive_pick_session(self, session_id: str, index: int, target_path: str = "", action: str = "") -> str: if not self._enabled: return "Agent影视助手 插件未启用" allowed, disabled = self._ensure_hdhive_resource_enabled() if not allowed: return str(disabled.get("message") or "影巢资源入口已关闭") session = self._load_session(self._clean_text(session_id)) if not session: return "会话不存在或已过期" stage = session.get("stage") service = self._ensure_hdhive_service() action = self._normalize_pick_action(action) if stage == "candidate": candidates = session.get("candidates") or [] page_size = max(1, self._safe_int(session.get("page_size"), self._hdhive_candidate_page_size)) current_page = max(1, self._safe_int(session.get("page"), 1)) if action == "detail": start = (current_page - 1) * page_size end = start + page_size enriched = [dict(item or {}) for item in candidates] enriched[start:end] = self._enrich_hdhive_candidates_with_actors(enriched[start:end]) self._save_session(self._clean_text(session_id), {**session, "candidates": enriched}) return self._format_candidate_lines(enriched, page=current_page, page_size=page_size) if action == "next_page": total_pages = max(1, (len(candidates) + page_size - 1) // page_size) if current_page >= total_pages: return "已经是最后一页了,可以直接回复编号继续选择。" next_page = current_page + 1 self._save_session(self._clean_text(session_id), {**session, "page": next_page}) return self._format_candidate_lines(candidates, page=next_page, page_size=page_size) if index <= 0 or index > len(candidates): return "候选编号超出范围" candidate = dict(candidates[index - 1]) resource_ok, resource_result, resource_message = service.search_resources( media_type=candidate.get("media_type") or session.get("media_type") or "movie", tmdb_id=str(candidate.get("tmdb_id") or ""), ) if not resource_ok: return f"影巢资源查询失败:{resource_message}" preferences = self._normalize_assistant_preferences( (self._assistant_preferences or {}).get(self._normalize_preference_key(session=self._clean_text(session.get("keyword")) or "default")) ) preview = self._attach_cloud_scores( self._group_resource_preview(resource_result.get("data") or [], per_group=None), preferences=preferences, source_type="hdhive", target_path=target_path or session.get("target_path") or "", ) self._save_session( self._clean_text(session_id), { **session, "stage": "resource", "selected_candidate": candidate, "resources": preview, "page": 1, "page_size": self._assistant_result_page_size, "target_path": self._clean_text(target_path) or session.get("target_path") or "", }, ) return self._format_resource_lines(preview, candidate, page=1, page_size=self._assistant_result_page_size, total_resources=len(preview)) if stage == "resource": resources = session.get("resources") or [] page_size = max(1, self._safe_int(session.get("page_size"), self._assistant_result_page_size)) current_page = max(1, self._safe_int(session.get("page"), 1)) if action == "next_page": total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 if current_page >= total_pages: return "已经是最后一页了,可以直接回复编号继续选择。" next_page = current_page + 1 self._save_session(self._clean_text(session_id), {**session, "page": next_page}) return self._format_resource_lines(resources, dict(session.get("selected_candidate") or {}), page=next_page, page_size=page_size, total_resources=len(resources)) if index <= 0 or index > len(resources): return "资源编号超出范围" resource = dict(resources[index - 1]) route_ok, route_result, route_message = await self._unlock_and_route( self._clean_text(resource.get("slug")), target_path=self._clean_text(target_path) or session.get("target_path") or "", resource=resource, ) if not route_ok: return f"资源处理失败:{route_message}" return self._format_route_result(route_result) return f"当前会话阶段不支持继续选择:{stage}" async def tool_route_share(self, share_url: str, access_code: str = "", target_path: str = "") -> str: share_url = self._clean_text(share_url) if not share_url: return "缺少分享链接" if self._is_quark_url(share_url): service = self._ensure_quark_service() ok, result, message = service.transfer_share( share_url, access_code=self._clean_text(access_code), target_path=self._clean_text(target_path) or self._quark_default_path, trigger="Agent影视助手 Agent Tool", ) if not ok: return self._format_quark_transfer_failure( detail=message, target_path=self._clean_text(target_path) or self._quark_default_path, ) return f"夸克转存成功\n目录:{result.get('target_path') or self._quark_default_path}" if self._is_115_url(share_url): ok, result, message = self._ensure_p115_service().transfer_share( url=share_url, access_code=self._clean_text(access_code), path=self._clean_text(target_path) or self._p115_default_path, trigger="Agent影视助手 Agent Tool", ) if not ok: return self._format_p115_transfer_failure( detail=message, target_path=self._clean_text(target_path) or self._p115_default_path, ) return f"115 转存成功\n目录:{result.get('path') or self._p115_default_path}" return "当前链接不是可识别的 115 / 夸克分享链接" async def tool_assistant_route( self, text: str = "", session: str = "default", session_id: str = "", target_path: str = "", mode: str = "", keyword: str = "", share_url: str = "", access_code: str = "", media_type: str = "", year: str = "", client_type: str = "", action: str = "", compact: bool = True, ) -> str: if not self._enabled: return "Agent影视助手 插件未启用" result = await self.api_assistant_route( _JsonRequestShim( _RequestContextShim(), { "text": self._clean_text(text), "session": self._clean_text(session) or "default", "session_id": self._clean_text(session_id), "path": self._clean_text(target_path), "mode": self._clean_text(mode), "keyword": self._clean_text(keyword), "url": self._clean_text(share_url), "access_code": self._clean_text(access_code), "media_type": self._clean_text(media_type), "year": self._clean_text(year), "client_type": self._clean_text(client_type), "action": self._clean_text(action), "compact": bool(compact), }, ) ) return str(result.get("message") or "处理完成") async def tool_assistant_pick( self, session: str = "default", session_id: str = "", index: int = 0, action: str = "", mode: str = "", target_path: str = "", compact: bool = True, ) -> str: if not self._enabled: return "Agent影视助手 插件未启用" result = await self.api_assistant_pick( _JsonRequestShim( _RequestContextShim(), { "session": self._clean_text(session) or "default", "session_id": self._clean_text(session_id), "choice": index, "action": self._clean_text(action), "mode": self._clean_text(mode), "path": self._clean_text(target_path), "compact": bool(compact), }, ) ) return str(result.get("message") or "继续处理完成") async def tool_assistant_help(self, session: str = "default", session_id: str = "") -> str: if not self._enabled: return "Agent影视助手 插件未启用" session_name, _ = self._normalize_assistant_session_ref(session=session, session_id=session_id) return self._format_assistant_help_text(session=session_name) async def tool_assistant_capabilities(self, compact: bool = True) -> str: if not self._enabled: return "Agent影视助手 插件未启用" if compact: data = self._assistant_capabilities_compact_data(self._assistant_capabilities_public_data()) return ( f"Agent影视助手:{data.get('version')};" f"工作流 {len(data.get('workflows') or [])} 个;" f"Tool {len(data.get('agent_tools') or [])} 个" ) return self._format_assistant_capabilities_text() async def tool_assistant_readiness(self, compact: bool = True) -> str: if not self._enabled: return "Agent影视助手 插件未启用" if compact: data = self._assistant_readiness_compact_data(self._assistant_readiness_public_data()) services = data.get("services") or {} return ( f"就绪:{'是' if data.get('can_start') else '否'};" f"115:{'可用' if services.get('p115_ready') else '不可用'};" f"影巢:{'已配' if services.get('hdhive_configured') else '未配'};" f"夸克:{'已配' if services.get('quark_configured') else '未配'};" f"待计划:{data.get('saved_plans_pending') or 0}" ) return self._format_assistant_readiness_text() async def tool_feishu_health(self, compact: bool = True) -> str: if not self._enabled: return "Agent影视助手 插件未启用" channel = self._ensure_feishu_channel() data = { "plugin_version": self.plugin_version, "plugin_enabled": self._enabled, **channel.health(), } if not compact: return json.dumps(data, ensure_ascii=False, indent=2) return ( f"飞书入口:{'已开启' if data.get('enabled') else '未开启'};" f"长连接:{'运行中' if data.get('running') else '未运行'};" f"SDK:{'可用' if data.get('sdk_available') else '缺失'};" f"AppID:{'已填' if data.get('app_id_configured') else '未填'};" f"AppSecret:{'已填' if data.get('app_secret_configured') else '未填'};" f"白名单:chat {data.get('allowed_chat_count') or 0} / user {data.get('allowed_user_count') or 0};" f"其他飞书入口:{'检测到运行中' if data.get('legacy_bridge_running') else '未检测到'}" ) async def tool_assistant_pulse(self) -> str: if not self._enabled: return "Agent影视助手 插件未启用" return self._format_assistant_pulse_text() async def tool_assistant_startup(self) -> str: if not self._enabled: return "Agent影视助手 插件未启用" return self._format_assistant_startup_text() async def tool_assistant_maintain(self, execute: bool = False, limit: int = 100) -> str: if not self._enabled: return "Agent影视助手 插件未启用" data = self._assistant_maintain_public_data(execute=execute, limit=limit) return self._format_assistant_maintain_text(data) async def tool_assistant_toolbox(self) -> str: if not self._enabled: return "Agent影视助手 插件未启用" return self._format_assistant_toolbox_text() async def tool_assistant_request_templates( self, limit: int = 100, names: Any = None, recipe: Any = None, include_templates: bool = True, ) -> str: if not self._enabled: return "Agent影视助手 插件未启用" data = self._assistant_request_templates_response_data( limit=limit, names=names, recipe=recipe, include_templates=include_templates, ) return self._format_assistant_request_templates_text(data) async def tool_assistant_selfcheck(self) -> str: if not self._enabled: return "Agent影视助手 插件未启用" return self._format_assistant_selfcheck_text() async def tool_assistant_history( self, session: str = "", session_id: str = "", compact: bool = True, limit: int = 20, ) -> str: if not self._enabled: return "Agent影视助手 插件未启用" if compact: data = self._assistant_history_compact_data( self._assistant_history_public_data(session=session, session_id=session_id, limit=limit) ) failed = sum(1 for item in (data.get("items") or []) if not item.get("success")) return f"最近执行历史:{len(data.get('items') or [])} 条;失败 {failed} 条" return self._format_assistant_history_text(session=session, session_id=session_id, limit=limit) async def tool_assistant_execute_action( self, name: str, session: str = "default", session_id: str = "", choice: Optional[int] = None, target_path: str = "", keyword: str = "", media_type: str = "", year: str = "", share_url: str = "", access_code: str = "", client_type: str = "", source: str = "", kind: str = "", has_pending_p115: Optional[bool] = None, stale_only: bool = False, all_sessions: bool = False, limit: int = 100, plan_id: str = "", prefer_unexecuted: bool = True, compact: bool = True, ) -> str: if not self._enabled: return "Agent影视助手 插件未启用" result = await self.api_assistant_action( _JsonRequestShim( _RequestContextShim(), { "name": self._clean_text(name), "session": self._clean_text(session) or "default", "session_id": self._clean_text(session_id), "choice": choice, "path": self._clean_text(target_path), "keyword": self._clean_text(keyword), "media_type": self._clean_text(media_type), "year": self._clean_text(year), "url": self._clean_text(share_url), "access_code": self._clean_text(access_code), "client_type": self._clean_text(client_type), "source": self._clean_text(source), "kind": self._clean_text(kind), "has_pending_p115": has_pending_p115, "stale_only": bool(stale_only), "all_sessions": bool(all_sessions), "limit": self._safe_int(limit, 100), "plan_id": self._clean_text(plan_id), "prefer_unexecuted": bool(prefer_unexecuted), "compact": bool(compact), }, ) ) return str(result.get("message") or "动作执行完成") async def tool_assistant_execute_actions( self, actions: List[Dict[str, Any]], session: str = "default", session_id: str = "", stop_on_error: bool = True, include_raw_results: bool = False, compact: bool = True, ) -> str: if not self._enabled: return "Agent影视助手 插件未启用" result = await self.api_assistant_actions( _JsonRequestShim( _RequestContextShim(), { "actions": actions or [], "session": self._clean_text(session) or "default", "session_id": self._clean_text(session_id), "stop_on_error": bool(stop_on_error), "include_raw_results": bool(include_raw_results), "compact": bool(compact), }, ) ) return str(result.get("message") or "批量动作执行完成") async def tool_assistant_workflow( self, name: str, session: str = "default", session_id: str = "", keyword: str = "", choice: Optional[int] = None, candidate_choice: Optional[int] = None, resource_choice: Optional[int] = None, target_path: str = "", share_url: str = "", access_code: str = "", media_type: str = "", year: str = "", client_type: str = "", source: str = "", limit: int = 20, dry_run: Optional[bool] = None, stop_on_error: bool = True, include_raw_results: bool = False, compact: bool = True, ) -> str: if not self._enabled: return "Agent影视助手 插件未启用" payload = { "name": self._clean_text(name), "session": self._clean_text(session) or "default", "session_id": self._clean_text(session_id), "keyword": self._clean_text(keyword), "choice": choice, "candidate_choice": candidate_choice, "resource_choice": resource_choice, "path": self._clean_text(target_path), "url": self._clean_text(share_url), "access_code": self._clean_text(access_code), "media_type": self._clean_text(media_type), "year": self._clean_text(year), "client_type": self._clean_text(client_type), "source": self._clean_text(source), "limit": self._safe_int(limit, 20), "stop_on_error": bool(stop_on_error), "include_raw_results": bool(include_raw_results), "compact": bool(compact), } if dry_run is not None: payload["dry_run"] = bool(dry_run) result = await self.api_assistant_workflow( _JsonRequestShim(_RequestContextShim(), payload) ) return str(result.get("message") or "工作流执行完成") async def tool_assistant_preferences( self, session: str = "default", session_id: str = "", user_key: str = "", preferences: Optional[Dict[str, Any]] = None, reset: bool = False, compact: bool = True, ) -> str: if not self._enabled: return "Agent影视助手 插件未启用" method = "DELETE" if reset else "POST" if preferences else "GET" result = await self.api_assistant_preferences( _JsonRequestShim( _RequestContextShim(), { "session": self._clean_text(session) or "default", "session_id": self._clean_text(session_id), "user_key": self._clean_text(user_key), "preferences": preferences or {}, "compact": bool(compact), }, method=method, ) ) return str(result.get("message") or "偏好画像处理完成") async def tool_assistant_execute_plan( self, plan_id: str = "", session: str = "", session_id: str = "", prefer_unexecuted: bool = True, stop_on_error: bool = True, include_raw_results: bool = False, compact: bool = True, ) -> str: if not self._enabled: return "Agent影视助手 插件未启用" result = await self.api_assistant_plan_execute( _JsonRequestShim( _RequestContextShim(), { "plan_id": self._clean_text(plan_id), "session": self._clean_text(session), "session_id": self._clean_text(session_id), "prefer_unexecuted": bool(prefer_unexecuted), "stop_on_error": bool(stop_on_error), "include_raw_results": bool(include_raw_results), "compact": bool(compact), }, ) ) return str(result.get("message") or "计划执行完成") async def tool_assistant_plans( self, session: str = "", session_id: str = "", executed: Optional[bool] = None, include_actions: bool = False, compact: bool = True, limit: int = 20, ) -> str: if not self._enabled: return "Agent影视助手 插件未启用" if compact: data = self._assistant_plans_compact_data( self._assistant_plans_public_data( session=session, session_id=session_id, executed=executed, include_actions=False, limit=limit, ) ) pending = sum(1 for item in (data.get("items") or []) if not item.get("executed")) return f"已保存计划:{len(data.get('items') or [])} 条;待执行 {pending} 条" return self._format_assistant_plans_text( session=session, session_id=session_id, executed=executed, include_actions=include_actions, limit=limit, ) async def tool_assistant_plans_clear( self, plan_id: str = "", session: str = "", session_id: str = "", executed: Optional[bool] = None, all_plans: bool = False, limit: int = 100, ) -> str: if not self._enabled: return "Agent影视助手 插件未启用" result = self._clear_workflow_plans( plan_id=plan_id, session=session, session_id=session_id, executed=executed, all_plans=all_plans, limit=limit, ) return str(result.get("message") or "计划清理完成") async def tool_assistant_recover( self, session: str = "", session_id: str = "", execute: bool = False, prefer_unexecuted: bool = True, stop_on_error: bool = True, include_raw_results: bool = False, compact: bool = True, limit: int = 20, ) -> str: if not self._enabled: return "Agent影视助手 插件未启用" result = await self.api_assistant_recover( _JsonRequestShim( _RequestContextShim(), { "session": self._clean_text(session), "session_id": self._clean_text(session_id), "execute": bool(execute), "prefer_unexecuted": bool(prefer_unexecuted), "stop_on_error": bool(stop_on_error), "include_raw_results": bool(include_raw_results), "compact": bool(compact), "limit": self._safe_int(limit, 20), }, ) ) return str(result.get("message") or "恢复检查完成") async def tool_assistant_session_state(self, session: str = "default", session_id: str = "", compact: bool = True) -> str: if not self._enabled: return "Agent影视助手 插件未启用" session_name, _ = self._normalize_assistant_session_ref(session=session, session_id=session_id) if compact: state = self._assistant_session_compact_data(self._assistant_session_public_data(session=session_name)) recovery = state.get("recovery") or {} parts = [ f"会话:{state.get('session')}", f"阶段:{state.get('kind') or '-'} / {state.get('stage') or '-'}", f"恢复:{recovery.get('mode') or '-'}", ] if recovery.get("recommended_action"): parts.append(f"推荐动作:{recovery.get('recommended_action')}") return "\n".join(parts) return self._format_assistant_session_summary(session=session_name) async def tool_assistant_sessions( self, kind: str = "", has_pending_p115: Optional[bool] = None, compact: bool = True, limit: int = 20, ) -> str: if not self._enabled: return "Agent影视助手 插件未启用" if compact: data = self._assistant_sessions_compact_data( self._assistant_sessions_public_data(kind=kind, has_pending_p115=has_pending_p115, limit=limit) ) return f"活跃会话:{data.get('total') or 0} 个;展示 {len(data.get('items') or [])} 个" return self._format_assistant_sessions_text( kind=kind, has_pending_p115=has_pending_p115, limit=limit, ) async def tool_assistant_session_clear(self, session: str = "default", session_id: str = "") -> str: if not self._enabled: return "Agent影视助手 插件未启用" session_name, cache_key = self._normalize_assistant_session_ref(session=session, session_id=session_id) existing = self._load_session(cache_key) if not existing: return "当前没有需要清理的会话。" self._session_cache.pop(cache_key, None) self._persist_relevant_sessions() return f"已清理会话:{session_name}" async def tool_assistant_sessions_clear( self, session: str = "", session_id: str = "", kind: str = "", has_pending_p115: Optional[bool] = None, stale_only: bool = False, all_sessions: bool = False, limit: int = 100, ) -> str: if not self._enabled: return "Agent影视助手 插件未启用" result = await self.api_assistant_sessions_clear( _JsonRequestShim( _RequestContextShim(), { "session": self._clean_text(session), "session_id": self._clean_text(session_id), "kind": self._clean_text(kind), "has_pending_p115": has_pending_p115, "stale_only": bool(stale_only), "all_sessions": bool(all_sessions), "limit": self._safe_int(limit, 100), }, ) ) return str(result.get("message") or "会话清理完成") async def tool_p115_qrcode_start(self, client_type: str = "alipaymini") -> str: if not self._enabled: return "Agent影视助手 插件未启用" final_client_type = P115TransferService.normalize_qrcode_client_type(client_type or self._p115_client_type) qr_ok, data, qr_message = self._ensure_p115_service().create_qrcode_login(client_type=final_client_type) if not qr_ok: return f"115 扫码二维码生成失败:{qr_message}" return ( "115 扫码二维码已生成\n" f"client_type: {data.get('client_type')}\n" f"uid: {data.get('uid')}\n" f"time: {data.get('time')}\n" f"sign: {data.get('sign')}\n" f"qrcode: {data.get('qrcode')}\n" "下一步:调用 agent_resource_officer_p115_qrcode_check,并传入 uid、time、sign 和 client_type" ) async def tool_p115_qrcode_check( self, uid: str, time_value: str, sign: str, client_type: str = "alipaymini", ) -> str: if not self._enabled: return "Agent影视助手 插件未启用" qr_ok, data, qr_message = self._ensure_p115_service().check_qrcode_login( uid=self._clean_text(uid), time_value=self._clean_text(time_value), sign=self._clean_text(sign), client_type=P115TransferService.normalize_qrcode_client_type(client_type or self._p115_client_type), ) if qr_ok and data.get("status") == "success": cookie = self._clean_text(data.pop("cookie")) if cookie: self._p115_cookie = cookie self._p115_client_type = P115TransferService.normalize_qrcode_client_type(client_type or self._p115_client_type) self._apply_runtime_config({ "p115_cookie": cookie, "p115_client_type": self._p115_client_type, }) data["cookie_saved"] = True status = self._clean_text(data.get("status")) lines = [ "115 扫码状态", f"status: {status or 'unknown'}", f"message: {qr_message}", ] if data.get("cookie_saved"): lines.append("cookie_saved: true") lines.append(self._format_p115_status_summary(title="115 登录完成")) if data.get("cookie_keys"): lines.append(f"cookie_keys: {', '.join(data.get('cookie_keys') or [])}") return "\n".join(lines) async def tool_p115_status(self) -> str: if not self._enabled: return "Agent影视助手 插件未启用" return self._format_p115_status_summary() async def tool_p115_pending(self, session: str = "default") -> str: if not self._enabled: return "Agent影视助手 插件未启用" session_id = self._session_key_for_tool(session) summary = self._pending_p115_summary(self._load_session(session_id)) return summary or "当前没有待继续的 115 任务。" async def tool_p115_resume(self, session: str = "default") -> str: if not self._enabled: return "Agent影视助手 插件未启用" session_id = self._session_key_for_tool(session) state = self._load_session(session_id) or {} if not self._pending_p115_summary(state): return "当前没有待继续的 115 任务。" if not self._p115_status_snapshot().get("ready"): return f"{self._pending_p115_summary(state)}\n当前 115 还不可用,请先完成 115 登录。" resume_ok, resume_message, _ = self._execute_pending_p115_share( session_id=session_id, state=state, trigger="Agent影视助手 Agent Tool 手动继续 115 任务", ) lines = ["已手动继续 115 任务", resume_message] if not resume_ok: lines.append("任务仍未成功,保留待继续状态。") return "\n".join(line for line in lines if line) async def tool_p115_cancel(self, session: str = "default") -> str: if not self._enabled: return "Agent影视助手 插件未启用" session_id = self._session_key_for_tool(session) summary = self._pending_p115_summary(self._load_session(session_id)) if not summary: return "当前没有待取消的 115 任务。" self._clear_pending_p115_share(session_id) return f"{summary}\n已取消并清除这次待继续的 115 任务。" async def api_p115_health(self, request: Request): ok, message = self._check_api_access(request) if not ok: return {"success": False, "message": message} service = self._ensure_p115_service() health_ok, result, health_message = service.health() cookie_state = result.get("cookie_state") or {} return { "success": True, "data": { "plugin_version": self.plugin_version, "enabled": self._enabled, "p115_ready": health_ok, "p115_direct_ready": bool(result.get("direct_ready")), "p115_direct_source": result.get("direct_source") or "", "p115_helper_ready": bool(result.get("helper_ready")), "default_target_path": self._p115_default_path, "p115_client_type": self._p115_client_type, "p115_cookie_configured": bool(cookie_state.get("configured")), "p115_cookie_valid": bool(cookie_state.get("valid")), "p115_cookie_mode": cookie_state.get("mode") or "none", "p115_cookie_keys": cookie_state.get("cookie_keys") or [], "message": "" if health_ok else health_message, "raw": result, }, } async def api_p115_qrcode(self, request: Request): ok, message = self._check_api_access(request) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} client_type = P115TransferService.normalize_qrcode_client_type( request.query_params.get("client_type") or self._p115_client_type ) qr_ok, data, qr_message = self._ensure_p115_service().create_qrcode_login(client_type=client_type) if not qr_ok: return {"success": False, "message": qr_message} return {"success": True, "message": qr_message, "data": data} async def api_p115_qrcode_check(self, request: Request): ok, message = self._check_api_access(request) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} uid = self._clean_text(request.query_params.get("uid")) time_value = self._clean_text(request.query_params.get("time")) sign = self._clean_text(request.query_params.get("sign")) if not uid or not time_value or not sign: return {"success": False, "message": "缺少 uid/time/sign,无法检查扫码状态"} client_type = P115TransferService.normalize_qrcode_client_type( request.query_params.get("client_type") or self._p115_client_type ) qr_ok, data, qr_message = self._ensure_p115_service().check_qrcode_login( uid=uid, time_value=time_value, sign=sign, client_type=client_type, ) if qr_ok and (data.get("status") == "success"): cookie = self._clean_text(data.pop("cookie")) if cookie: self._p115_cookie = cookie self._p115_client_type = client_type self._apply_runtime_config({ "p115_cookie": cookie, "p115_client_type": client_type, }) data["cookie_saved"] = True data["cookie_mode"] = "client_cookie" data["status_summary"] = self._format_p115_status_summary(title="115 登录完成") if not qr_ok: return {"success": False, "message": qr_message, "data": data} return {"success": True, "message": qr_message, "data": data} async def api_p115_transfer(self, request: Request): body = await request.json() ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} url = self._clean_text(body.get("url") or body.get("share_url")) access_code = self._clean_text(body.get("access_code") or body.get("pwd") or body.get("code")) target_path = self._clean_text(body.get("path") or body.get("target_path")) trigger = self._clean_text(body.get("trigger") or "Agent影视助手 API") service = self._ensure_p115_service() transfer_ok, result, transfer_message = service.transfer_share( url=url, access_code=access_code, path=target_path or self._p115_default_path, trigger=trigger, ) if not transfer_ok: return { "success": False, "message": self._format_p115_transfer_failure( detail=transfer_message, target_path=target_path or self._p115_default_path, ), "data": result, } return {"success": True, "message": transfer_message, "data": result} async def api_p115_pending(self, request: Request): body = await self._request_payload(request) ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} session = self._clean_text( body.get("session") or body.get("session_id") or request.query_params.get("session") or request.query_params.get("session_id") or "default" ) session_id = self._session_key_for_tool(session) state = self._load_session(session_id) or {} summary = self._pending_p115_summary(state) data = self._pending_p115_public_data(state) data["session_id"] = session_id return { "success": True, "message": summary or "当前没有待继续的 115 任务。", "data": data, } async def api_p115_pending_resume(self, request: Request): body = await self._request_payload(request) ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} session = self._clean_text( body.get("session") or body.get("session_id") or request.query_params.get("session") or request.query_params.get("session_id") or "default" ) session_id = self._session_key_for_tool(session) state = self._load_session(session_id) or {} if not self._pending_p115_summary(state): return { "success": False, "message": "当前没有待继续的 115 任务。", "data": {"session_id": session_id, "has_pending": False}, } if not self._p115_status_snapshot().get("ready"): return { "success": False, "message": f"{self._pending_p115_summary(state)}\n当前 115 还不可用,请先完成 115 登录。", "data": {"session_id": session_id, **self._pending_p115_public_data(state)}, } resume_ok, resume_message, resume_data = self._execute_pending_p115_share( session_id=session_id, state=state, trigger="Agent影视助手 API 手动继续 115 任务", ) message_text = "已手动继续 115 任务" if resume_message: message_text = f"{message_text}\n{resume_message}" if not resume_ok: message_text = f"{message_text}\n任务仍未成功,保留待继续状态。" return { "success": resume_ok, "message": message_text, "data": { "session_id": session_id, "result": resume_data, "pending": self._pending_p115_public_data(self._load_session(session_id) or {}), }, } async def api_p115_pending_cancel(self, request: Request): body = await self._request_payload(request) ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} session = self._clean_text( body.get("session") or body.get("session_id") or request.query_params.get("session") or request.query_params.get("session_id") or "default" ) session_id = self._session_key_for_tool(session) state = self._load_session(session_id) or {} summary = self._pending_p115_summary(state) if not summary: return { "success": True, "message": "当前没有待取消的 115 任务。", "data": {"session_id": session_id, "has_pending": False}, } pending_data = self._pending_p115_public_data(state) self._clear_pending_p115_share(session_id) return { "success": True, "message": f"{summary}\n已取消并清除这次待继续的 115 任务。", "data": {"session_id": session_id, "cancelled": True, "pending": pending_data}, } async def api_hdhive_unlock_and_route(self, request: Request): body = await request.json() ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} allowed, disabled = self._ensure_hdhive_resource_enabled() if not allowed: return disabled slug = self._clean_text(body.get("slug")) target_path = self._clean_text(body.get("path") or body.get("target_path")) route_ok, route_result, route_message = await self._unlock_and_route(slug, target_path=target_path, resource=body) if not route_ok: return {"success": False, "message": route_message, "data": route_result} return {"success": True, "message": route_message, "data": route_result} async def api_share_route(self, request: Request): body = await request.json() ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} share_url = self._clean_text(body.get("url") or body.get("share_url") or body.get("share_text")) access_code = self._clean_text(body.get("access_code") or body.get("pwd") or body.get("code")) target_path = self._clean_text(body.get("path") or body.get("target_path")) trigger = self._clean_text(body.get("trigger") or "Agent影视助手 自动路由") if self._is_quark_url(share_url): quark_service = self._ensure_quark_service() transfer_ok, result, transfer_message = quark_service.transfer_share( share_url, access_code=access_code, target_path=target_path or self._quark_default_path, trigger=trigger, ) if not transfer_ok: return { "success": False, "message": self._format_quark_transfer_failure( detail=transfer_message, target_path=target_path or self._quark_default_path, ), "data": { "provider": "quark", "result": result, }, } return { "success": True, "message": transfer_message, "data": { "provider": "quark", "result": result, }, } if self._is_115_url(share_url): p115_service = self._ensure_p115_service() transfer_ok, result, transfer_message = p115_service.transfer_share( url=share_url, access_code=access_code, path=target_path or self._p115_default_path, trigger=trigger, ) if not transfer_ok: return { "success": False, "message": self._format_p115_transfer_failure( detail=transfer_message, target_path=target_path or self._p115_default_path, ), "data": { "provider": "115", "result": result, }, } return { "success": True, "message": transfer_message, "data": { "provider": "115", "result": result, }, } return { "success": False, "message": "当前链接不是可识别的 115 / 夸克分享链接", "data": {"provider": "unknown", "url": share_url}, } async def api_assistant_cookie_update(self, request: Request): body: Dict[str, Any] = {} try: body = await request.json() except Exception: body = {} ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} kind = self._clean_text( body.get("kind") or body.get("provider") or body.get("target") or body.get("name") ).lower() cookie = self._clean_text(body.get("cookie") or body.get("cookie_header")) if not cookie: return {"success": False, "message": "Cookie 不能为空"} aliases = { "yc": "hdhive", "yingchao": "hdhive", "影巢": "hdhive", "影潮": "hdhive", "hdhive": "hdhive", "quark": "quark", "夸克": "quark", } target = aliases.get(kind, "") if target == "hdhive": self._hdhive_checkin_cookie = cookie config = self._build_config({"hdhive_checkin_cookie": cookie}) self.update_config(config) self.init_plugin(config) return { "success": True, "message": "影巢 Cookie 已写回 Agent影视助手。", "data": { "kind": "hdhive", "field": "hdhive_checkin_cookie", "length": len(cookie), "write_effect": "config", }, } if target == "quark": self._quark_cookie = cookie config = self._build_config({"quark_cookie": cookie}) self.update_config(config) self.init_plugin(config) return { "success": True, "message": "夸克 Cookie 已写回 Agent影视助手。", "data": { "kind": "quark", "field": "quark_cookie", "length": len(cookie), "write_effect": "config", }, } return { "success": False, "message": "不支持的 Cookie 类型,请使用 hdhive 或 quark。", } async def api_assistant_preferences(self, request: Request): body: Dict[str, Any] = {} try: body = await request.json() except Exception: body = {} ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} session = self._clean_text(body.get("session") or request.query_params.get("session") or "default") user_key = self._clean_text(body.get("user_key") or request.query_params.get("user_key")) if request.method.upper() == "DELETE" or self._parse_bool_value(body.get("reset"), False): preferences_data = self._reset_assistant_preferences(session=session, user_key=user_key) return { "success": True, "message": "智能体片源偏好已重置,下一次启动会重新进入偏好询问。", "data": self._assistant_response_data(session=session, data={ "action": "preferences_reset", "ok": True, "preferences": preferences_data, "write_effect": "state", }), } if request.method.upper() == "POST": preferences = body.get("preferences") if isinstance(body.get("preferences"), dict) else { key: value for key, value in body.items() if key not in {"apikey", "session", "session_id", "user_key", "compact", "reset"} } preferences_data = self._save_assistant_preferences(session=session, user_key=user_key, preferences=preferences) return { "success": True, "message": "智能体片源偏好已保存。", "data": self._assistant_response_data(session=session, data={ "action": "preferences_save", "ok": True, "preferences": preferences_data, "write_effect": "state", }), } preferences_data = self._assistant_preferences_public_data(session=session, user_key=user_key) message_text = "智能体片源偏好未初始化,请先询问用户偏好。" if preferences_data.get("needs_onboarding") else "智能体片源偏好已初始化。" return { "success": True, "message": message_text, "data": self._assistant_response_data(session=session, data={ "action": "preferences", "ok": True, "preferences": preferences_data, }), } async def api_assistant_route(self, request: Request): body = await request.json() ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} session, cache_key = self._normalize_assistant_session_ref( session=( body.get("session") or body.get("chat_id") or body.get("user_id") or body.get("conversation_id") or "default" ), session_id=body.get("session_id"), ) text = self._clean_text(body.get("text") or body.get("query") or body.get("message") or "") state = self._load_session(cache_key) or {} compact = self._parse_bool_value(body.get("compact"), False) saved_plan = self._session_workflow_plan_public_data(session=session, session_id=cache_key) def finish(result: Dict[str, Any]) -> Dict[str, Any]: return self._assistant_interaction_compact_response(result) if compact else result def immediate(result: Dict[str, Any]) -> Dict[str, Any]: return result pending_plan_group = self._clean_text(state.get("pending_plan_group")) numeric_confirmation = self._parse_pending_plan_numeric_confirmation(text) pending_multi_plan = None if pending_plan_group and (numeric_confirmation > 0 or self._is_pending_plan_confirmation_text(text)): pending_multi_plan = self._find_pending_multi_plan( session=session, session_id=cache_key, rank=numeric_confirmation if numeric_confirmation > 0 else 1, group_id=pending_plan_group, ) pending_plan = pending_multi_plan or self._find_workflow_plan(session=session, session_id=cache_key, executed=False) if ( pending_plan and ( self._is_pending_plan_confirmation_text(text) or self._is_pending_plan_numeric_confirmation(text, pending_plan) ) and not self._clean_text(body.get("plan_id")) ): if isinstance(state.get("recommend_handoff"), dict) and state.get("recommend_handoff"): return finish(await self._assistant_confirm_recommend_handoff( request, session=session, cache_key=cache_key, state=state, compact=compact, target_path=self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), )) return finish(await self.api_assistant_plan_execute( _JsonRequestShim(request, { "session": session, "session_id": cache_key, "plan_id": self._clean_text(pending_plan.get("plan_id")) if pending_multi_plan else "", "prefer_unexecuted": True, "stop_on_error": self._parse_bool_value(body.get("stop_on_error"), True), "include_raw_results": self._parse_bool_value(body.get("include_raw_results"), False), "compact": compact, "apikey": self._extract_apikey(request, body), }) )) recommend_direct_intent = self._normalize_mp_recommend_direct_intent(text) if recommend_direct_intent: source_name, inferred_media_type = self._normalize_mp_recommend_request( recommend_direct_intent.get("keyword") or "tmdb_trending" ) recommend_result = await self._assistant_mp_recommendations( source=source_name, media_type=self._clean_text(body.get("media_type") or body.get("type") or inferred_media_type or "all"), limit=self._safe_int(body.get("limit"), 20), session=session, cache_key=cache_key, ) if not recommend_result.get("success"): return finish(recommend_result) return finish(await self.api_assistant_pick( _JsonRequestShim(request, { "session": session, "session_id": cache_key, "index": recommend_direct_intent.get("index") or 1, "action": recommend_direct_intent.get("action"), "mode": recommend_direct_intent.get("mode"), "path": self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), "compact": compact, "apikey": self._extract_apikey(request, body), }) )) recommend_short_direct = self._normalize_mp_recommend_short_action(text, state=state) if recommend_short_direct: return finish(await self.api_assistant_pick( _JsonRequestShim(request, { "session": session, "session_id": cache_key, "index": recommend_short_direct.get("index"), "action": recommend_short_direct.get("action"), "mode": recommend_short_direct.get("mode"), "path": self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), "compact": compact, "apikey": self._extract_apikey(request, body), }) )) recommend_source_compound = self._normalize_recommend_source_compound_action(text, state=state) if recommend_source_compound: return finish(await self._assistant_run_recommend_source_compound( request, session=session, cache_key=cache_key, state=state, mode=self._clean_text(recommend_source_compound.get("mode")), followup_action=self._clean_text(recommend_source_compound.get("followup_action")), compact=compact, target_path=self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), )) hdhive_resource_short = self._normalize_hdhive_resource_short_action(text, state=state) if hdhive_resource_short: return finish(await self.api_assistant_pick( _JsonRequestShim(request, { "session": session, "session_id": cache_key, "index": hdhive_resource_short.get("index"), "action": hdhive_resource_short.get("action"), "path": self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))), "compact": compact, "apikey": self._extract_apikey(request, body), }) )) parsed = self._merge_assistant_structured_input(body, self._parse_assistant_text(text)) target_path = parsed.get("path") or "" recommend_followup = self._normalize_mp_recommend_followup(text, state=state) has_recommend_followup = bool(recommend_followup) if self._clean_text((recommend_followup or {}).get("action")) == "mp_recommendations": return finish(await self._assistant_mp_recommendations( source=self._clean_text(recommend_followup.get("keyword") or state.get("requested_source") or state.get("source") or "tmdb_trending"), media_type=self._clean_text(recommend_followup.get("type") or state.get("media_type") or "all"), limit=self._safe_int(body.get("limit"), 20), session=session, cache_key=cache_key, )) if recommend_followup: for key, value in recommend_followup.items(): if value not in {None, ""}: parsed[key] = str(value) preparsed_action = self._clean_text(parsed.get("action")) recommend_handoff_short = self._normalize_recommend_handoff_short_action(text, state=state) recommend_short = self._normalize_mp_recommend_short_action(text, state=state) if preparsed_action not in {"ai_replay_failed_sample"} and recommend_short: pick_result = await self.api_assistant_pick( _JsonRequestShim(request, { "session": session, "index": recommend_short.get("index"), "action": recommend_short.get("action"), "mode": recommend_short.get("mode"), "path": target_path, "compact": compact, "apikey": self._extract_apikey(request, body), }) ) return pick_result pick_index, pick_path, pick_action, pick_mode = self._parse_pick_text(text) handoff_route_action = self._clean_text(recommend_handoff_short.get("route_action")) if not pick_index and handoff_route_action: pick_action = handoff_route_action if not has_recommend_followup and preparsed_action not in {"ai_replay_failed_sample"} and (pick_index > 0 or pick_action): pick_result = await self.api_assistant_pick( _JsonRequestShim(request, { "session": session, "index": pick_index, "action": pick_action, "mode": pick_mode, "path": target_path or pick_path, "compact": compact, "apikey": self._extract_apikey(request, body), }) ) return pick_result if not has_recommend_followup and preparsed_action not in {"ai_replay_failed_sample"}: route_action = self._normalize_pick_action(text) smart_route_action = self._normalize_smart_search_short_action(text, state_kind=state.get("kind")) if smart_route_action: route_action = smart_route_action if handoff_route_action: route_action = handoff_route_action if route_action: pick_result = await self.api_assistant_pick( _JsonRequestShim(request, { "session": session, "index": 0, "action": route_action, "path": target_path, "compact": compact, "apikey": self._extract_apikey(request, body), }) ) return pick_result if not text and not any(parsed.get(key) for key in ["mode", "keyword", "url", "action"]): summary = self._format_assistant_help_text(session=session) return finish({ "success": True, "message": summary, "data": self._assistant_response_data(session=session, data={ "action": "assistant_help", "ok": True, "status_summary": summary, }), }) assistant_action = preparsed_action ai_short_action = self._normalize_ai_reingest_short_action(text, state=state) if not assistant_action and ai_short_action: for key, value in ai_short_action.items(): if value not in {None, ""}: parsed[key] = value assistant_action = self._clean_text(parsed.get("action")) recommend_handoff_action = self._normalize_recommend_handoff_action(text, state=state) if not assistant_action and recommend_handoff_action: for key, value in recommend_handoff_action.items(): if value not in {None, ""}: parsed[key] = value assistant_action = self._clean_text(parsed.get("action")) if not assistant_action and self._clean_text(recommend_handoff_short.get("action")): for key, value in recommend_handoff_short.items(): if value not in {None, ""}: parsed[key] = value assistant_action = self._clean_text(parsed.get("action")) keyword = self._clean_text(parsed.get("keyword")) if assistant_action == "assistant_help": summary = self._format_assistant_help_text(session=session) return finish({ "success": True, "message": summary, "data": self._assistant_response_data(session=session, data={ "action": "assistant_help", "ok": True, "status_summary": summary, }), }) if assistant_action == "cloud_search_removed": lines = [ "“云盘搜索”命令已取消。", "现在请按来源明确搜索,结果更可控:", ] if keyword: lines.append(f"- 盘搜搜索 {keyword}") lines.append(f"- 影巢搜索 {keyword}") lines.append(f"- PT搜索 {keyword}") else: lines.append("- 盘搜搜索 片名") lines.append("- 影巢搜索 片名") lines.append("- PT搜索 片名") lines.append("盘搜和影巢会在对方无结果时互相补查;两边都没有时,只提示是否改搜 PT,不会自动切过去。") return finish({ "success": False, "message": "\n".join(lines), "data": self._assistant_response_data(session=session, data={ "action": "cloud_search_removed", "ok": False, "keyword": keyword, "recommended_commands": [ f"盘搜搜索 {keyword}" if keyword else "盘搜搜索 片名", f"影巢搜索 {keyword}" if keyword else "影巢搜索 片名", f"PT搜索 {keyword}" if keyword else "PT搜索 片名", ], }), }) if assistant_action == "command_alias_removed": removed_command = self._clean_text(parsed.get("type")) lines = [ f"“{removed_command}”这个旧别名已取消。", "请改用更直接的主线命令:", ] if removed_command == "下载资源": lines.append(f"- 下载 {keyword}" if keyword else "- 下载 片名") lines.append("- 或者先 MP搜索 / PT搜索,再回编号") elif removed_command in {"转存资源"}: lines.append(f"- 盘搜搜索 {keyword}" if keyword else "- 盘搜搜索 片名") lines.append(f"- 影巢搜索 {keyword}" if keyword else "- 影巢搜索 片名") lines.append("- 然后从结果里直接回编号继续") elif removed_command in {"订阅并搜索", "订阅搜索"}: lines.append(f"- 订阅 {keyword}" if keyword else "- 订阅 片名") lines.append("- 需要立刻看资源时,再手动发搜索命令") elif removed_command == "订阅媒体": lines.append(f"- 订阅 {keyword}" if keyword else "- 订阅 片名") elif removed_command == "搜索订阅": lines.append(f"- 刷新订阅 {keyword}" if keyword else "- 刷新订阅 1") elif removed_command == "移除订阅": lines.append(f"- 删除订阅 {keyword}" if keyword else "- 删除订阅 1") elif removed_command in {"订阅状态", "查看订阅", "MP订阅", "mp订阅"}: lines.append("- 订阅列表") elif removed_command in {"下载状态", "正在下载", "下载列表", "查看下载", "下载进度"}: lines.append("- 下载任务") elif removed_command in {"站点", "站点列表", "PT站点", "pt站点", "pt站点状态"}: lines.append("- 站点状态") elif removed_command in {"下载器", "下载器列表", "查看下载器"}: lines.append("- 下载器状态") return finish({ "success": False, "message": "\n".join(lines), "data": self._assistant_response_data(session=session, data={ "action": "command_alias_removed", "ok": False, "keyword": keyword, "removed_command": removed_command, }), }) if assistant_action == "cloud_transfer_removed": provider = self._clean_text(parsed.get("cloud_provider")).lower() command_name = "转存" if provider == "quark": command_name = "夸克转存" elif provider == "115": command_name = "115转存" lines = [ f"“{command_name} 片名”命令已取消。", "现在请先按来源搜索,再从结果里直接回编号继续处理:", ] if keyword: lines.append(f"- 盘搜搜索 {keyword}") lines.append(f"- 影巢搜索 {keyword}") lines.append(f"- PT搜索 {keyword}") else: lines.append("- 盘搜搜索 片名") lines.append("- 影巢搜索 片名") lines.append("- PT搜索 片名") lines.append("盘搜和影巢会在对方无结果时互相补查;两边都没有时,只提示是否改搜 PT,不会自动切过去。") lines.append("看到资源列表后,直接回编号即可继续转存或下载。") return finish({ "success": False, "message": "\n".join(lines), "data": self._assistant_response_data(session=session, data={ "action": "cloud_transfer_removed", "ok": False, "keyword": keyword, "cloud_provider": provider, }), }) if assistant_action == "advanced_command_removed": lines = [ "这组高级命令已下线。", "现在请直接用主线命令,结果更稳,也更不容易被外部智能体二次改写:", ] if keyword: lines.append(f"- 搜索 {keyword}") lines.append(f"- MP搜索 {keyword}") lines.append(f"- PT搜索 {keyword}") lines.append(f"- 盘搜搜索 {keyword}") lines.append(f"- 影巢搜索 {keyword}") lines.append(f"- 下载 {keyword}") lines.append(f"- 更新检查 {keyword}") else: lines.append("- 搜索 片名") lines.append("- MP搜索 片名") lines.append("- PT搜索 片名") lines.append("- 盘搜搜索 片名") lines.append("- 影巢搜索 片名") lines.append("- 下载 片名") lines.append("- 更新检查 片名") return finish({ "success": False, "message": "\n".join(lines), "data": self._assistant_response_data(session=session, data={ "action": "advanced_command_removed", "ok": False, "keyword": keyword, }), }) if assistant_action == "execute_plan": return finish(await self.api_assistant_plan_execute( _JsonRequestShim(request, { "session": session, "session_id": cache_key, "plan_id": self._clean_text(parsed.get("plan_id")), "prefer_unexecuted": True, "stop_on_error": self._parse_bool_value(body.get("stop_on_error"), True), "include_raw_results": self._parse_bool_value(body.get("include_raw_results"), False), "compact": compact, "apikey": self._extract_apikey(request, body), }) )) if assistant_action == "plans_list": include_actions = self._parse_bool_value(body.get("include_actions"), False) executed = self._parse_optional_bool(body.get("executed")) limit = self._safe_int(body.get("limit"), 10) plans_data = self._assistant_plans_public_data( session=session, session_id=cache_key, executed=executed, include_actions=include_actions, limit=limit, ) return finish({ "success": True, "message": self._format_assistant_plans_text( session=session, session_id=cache_key, executed=executed, include_actions=include_actions, limit=limit, ), "data": self._assistant_response_data(session=session, data={ "action": "plans_list", "ok": True, **plans_data, }), }) if assistant_action == "plans_clear": plan_id = self._clean_text(parsed.get("plan_id")) if not plan_id: return finish({ "success": False, "message": "取消或清理计划需要指定 plan_id,例如:取消计划 plan-xxxx。", "data": self._assistant_response_data(session=session, data={ "action": "plans_clear", "ok": False, "error_code": "missing_plan_id", }), }) clear_result = self._clear_workflow_plans(plan_id=plan_id, limit=1) return finish({ "success": bool(clear_result.get("ok")), "message": str(clear_result.get("message") or "计划清理完成"), "data": self._assistant_response_data(session=session, data={ "action": "plans_clear", "ok": bool(clear_result.get("ok")), **clear_result, }), }) if assistant_action in {"preferences_get", "preferences_save", "preferences_reset"}: if assistant_action == "preferences_save": preferences = body.get("preferences") if isinstance(body.get("preferences"), dict) else self._parse_assistant_preferences_text(text) if not preferences: return finish({ "success": False, "message": ( "保存偏好缺少可识别内容。示例:保存偏好 4K 杜比 HDR 中字 全集 " "做种>=3 影巢积分20 不自动入库" ), "data": self._assistant_response_data(session=session, data={ "action": "preferences_save", "ok": False, "error_code": "missing_preferences", }), }) payload = { "session": session, "user_key": self._clean_text(body.get("user_key")), "preferences": preferences, "compact": compact, "apikey": self._extract_apikey(request, body), } return finish(await self.api_assistant_preferences(_JsonRequestShim(request, payload, method="POST"))) if assistant_action == "preferences_reset": return finish(await self.api_assistant_preferences(_JsonRequestShim(request, { "session": session, "user_key": self._clean_text(body.get("user_key")), "compact": compact, "apikey": self._extract_apikey(request, body), }, method="DELETE"))) return finish(await self.api_assistant_preferences(_JsonRequestShim(request, { "session": session, "user_key": self._clean_text(body.get("user_key")), "compact": compact, "apikey": self._extract_apikey(request, body), }, method="GET"))) if assistant_action == "scoring_policy": return finish({ "success": True, "message": "评分策略已返回。云盘与 PT 使用不同规则;自动化决策以硬风险和 score_summary 为准。", "data": self._assistant_response_data(session=session, data={ "action": "scoring_policy", "ok": True, "scoring_policy": self._assistant_scoring_policy_public_data(), }), }) if assistant_action == "hdhive_checkin": is_gambler = self._parse_bool_value(parsed.get("is_gambler"), self._hdhive_checkin_gambler_mode) result = self._run_hdhive_checkin(is_gambler=is_gambler, trigger="Agent影视助手 智能入口") data = result.get("data") if isinstance(result.get("data"), dict) else {} status = data.get("status") or ("签到成功" if result.get("success") else "签到失败") mode_text = "赌狗签到" if is_gambler else "普通签到" summary = f"影巢{mode_text}:{status}\n{result.get('message') or ''}".strip() return finish({ "success": bool(result.get("success")), "message": summary, "data": self._assistant_response_data(session=session, data={ "action": "hdhive_checkin", "ok": bool(result.get("success")), "is_gambler": is_gambler, "status_summary": summary, "result": data, }), }) if assistant_action == "hdhive_checkin_history": summary = self._format_hdhive_checkin_history_text(limit=10) return finish({ "success": True, "message": summary, "data": self._assistant_response_data(session=session, data={ "action": "hdhive_checkin_history", "ok": True, "status_summary": summary, "history": self._hdhive_checkin_history_public_data(limit=10), }), }) if assistant_action == "mp_media_detail": if not keyword: return finish({ "success": False, "message": "媒体识别失败:缺少片名。用法:识别 蜘蛛侠", "data": self._assistant_response_data(session=session, data={ "action": "mp_media_detail", "ok": False, "error_code": "missing_keyword", }), }) return finish(await self._assistant_mp_media_detail( keyword=keyword, session=session, cache_key=cache_key, media_type=self._clean_text(body.get("media_type") or body.get("type") or parsed.get("type") or "auto"), year=self._clean_text(body.get("year") or parsed.get("year")), )) if assistant_action == "mp_downloaders": return finish(await self._assistant_mp_downloaders(session=session, cache_key=cache_key)) if assistant_action == "mp_sites": return finish(await self._assistant_mp_sites( session=session, cache_key=cache_key, status=self._clean_text(body.get("status") or parsed.get("status") or "active"), name=keyword, limit=self._safe_int(body.get("limit"), 30), )) if assistant_action == "mp_subscribes": return finish(await self._assistant_mp_subscribes( session=session, cache_key=cache_key, status=self._clean_text(body.get("status") or parsed.get("status") or "all"), media_type=self._clean_text(body.get("media_type") or body.get("type") or parsed.get("type") or "all"), name=keyword, limit=self._safe_int(body.get("limit"), 20), )) if assistant_action == "mp_transfer_history": return finish(await self._assistant_mp_transfer_history( session=session, cache_key=cache_key, title=keyword, status=self._clean_text(body.get("status") or parsed.get("status") or "all"), limit=self._safe_int(body.get("limit"), 10), page=self._safe_int(body.get("page"), 1), )) if assistant_action == "mp_ingest_failures": return finish(await self._assistant_mp_ingest_failures( session=session, cache_key=cache_key, title=keyword, limit=self._safe_int(body.get("limit"), 10), page=self._safe_int(body.get("page"), 1), )) if assistant_action == "ai_failed_samples": return finish(await self._assistant_ai_failed_samples( session=session, cache_key=cache_key, keyword=keyword, limit=self._safe_int(body.get("limit"), 10), )) if assistant_action == "ai_sample_worklist": return finish(await self._assistant_ai_sample_worklist( session=session, cache_key=cache_key, keyword=keyword, limit=self._safe_int(body.get("limit"), 10), )) if assistant_action == "ai_sample_insights": return finish(await self._assistant_ai_sample_insights( session=session, cache_key=cache_key, keyword=keyword, limit=self._safe_int(body.get("limit"), 20), top=self._safe_int(body.get("top"), 5), )) if assistant_action == "ai_replay_failed_sample": sample_index = self._safe_int(parsed.get("sample_index") or body.get("sample_index") or body.get("index"), 0) remove_if_resolved = self._parse_bool_value(parsed.get("remove_if_resolved") or body.get("remove_if_resolved"), True) return finish(self._assistant_ai_replay_sample_plan_response( sample_index=sample_index, session=session, cache_key=cache_key, remove_if_resolved=remove_if_resolved, )) if assistant_action == "streaming_recommend": return finish(await self._assistant_streaming_recommend( media_type=parsed.get("streaming_media_type") or "all", intent=parsed.get("streaming_intent") or "hot", month=parsed.get("streaming_month") or "", window_days=self._safe_int(parsed.get("streaming_window") or body.get("window_days"), 90), session=session, cache_key=cache_key, compact=compact, )) if assistant_action == "mp_subscribe_control": control = self._clean_text(parsed.get("subscribe_control") or body.get("subscribe_control") or body.get("control") or body.get("operation")).lower() control_aliases = { "搜索": "search", "刷新": "search", "search": "search", "run": "search", "暂停": "pause", "停止": "pause", "pause": "pause", "stop": "pause", "恢复": "resume", "继续": "resume", "resume": "resume", "start": "resume", "删除": "delete", "移除": "delete", "delete": "delete", "remove": "delete", } control = control_aliases.get(control, control) allow_raw_subscribe_id = body.get("subscribe_id") is not None target = self._clean_text(keyword or body.get("target") or body.get("subscribe_id") or body.get("index") or body.get("choice")) if control not in {"search", "pause", "resume", "delete"} or not target: return finish({ "success": False, "message": "用法:先发“订阅列表”,再发“刷新订阅 1”“暂停订阅 1”“恢复订阅 1”或“删除订阅 1”。", "data": self._assistant_response_data(session=session, data={ "action": "mp_subscribe_control", "ok": False, "error_code": "invalid_subscribe_control_args", }), }) if not self._resolve_mp_subscribe_target(target=target, cache_key=cache_key, allow_raw_id=allow_raw_subscribe_id): return finish({ "success": False, "message": "未找到可操作的订阅。请先发送“订阅列表”获取列表,再按编号操作;也可以直接传订阅 ID。", "data": self._assistant_response_data(session=session, data={ "action": "mp_subscribe_control", "ok": False, "error_code": "subscribe_target_not_found", "target": target, }), }) if not self._parse_bool_value(body.get("confirmed") or body.get("execute"), False): return finish(immediate(self._assistant_mp_subscribe_control_plan_response( control=control, target=target, session=session, cache_key=cache_key, allow_raw_id=allow_raw_subscribe_id, ))) return finish(await self._assistant_mp_subscribe_control( session=session, cache_key=cache_key, control=control, target=target, allow_raw_id=allow_raw_subscribe_id, )) if assistant_action == "mp_download_tasks": return finish(await self._assistant_mp_download_tasks( session=session, cache_key=cache_key, status=self._clean_text(body.get("status") or parsed.get("status") or "downloading"), title=keyword, hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), downloader=self._clean_text(body.get("downloader")), limit=self._safe_int(body.get("limit"), 10), )) if assistant_action == "mp_download_history": return finish(await self._assistant_mp_download_history( session=session, cache_key=cache_key, title=keyword, hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), limit=self._safe_int(body.get("limit"), 10), page=self._safe_int(body.get("page"), 1), )) if assistant_action == "mp_lifecycle_status": return finish(await self._assistant_mp_lifecycle_status( session=session, cache_key=cache_key, title=keyword, hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), limit=self._safe_int(body.get("limit"), 5), )) if assistant_action == "mp_ingest_status": return finish(await self._assistant_mp_ingest_status( session=session, cache_key=cache_key, title=keyword, hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), limit=self._safe_int(body.get("limit"), 5), )) if assistant_action == "mp_recent_activity": return finish(await self._assistant_mp_recent_activity( session=session, cache_key=cache_key, limit=self._safe_int(body.get("limit"), 10), download_only=self._parse_bool_value(body.get("download_only") or parsed.get("download_only"), False), transfer_only=self._parse_bool_value(body.get("transfer_only") or parsed.get("transfer_only"), False), )) if assistant_action == "smart_followup": return finish(await self._assistant_smart_followup( request, session=session, session_id=cache_key, keyword=keyword, hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), limit=self._safe_int(body.get("limit"), 5), )) if assistant_action == "smart_decision_adjust": return finish(await self._assistant_smart_resource_decision_adjust( request, session=session, cache_key=cache_key, state=state, adjust_action=self._clean_text(parsed.get("decision_adjust") or body.get("decision_adjust") or "decision_continue"), )) if assistant_action == "execution_followup": return finish(await self._assistant_execution_followup( request, session=session, session_id=cache_key, plan_id=self._clean_text(body.get("plan_id") or parsed.get("plan_id")), )) if assistant_action == "mp_local_diagnose": return finish(await self._assistant_mp_local_diagnose( session=session, cache_key=cache_key, title=keyword, hash_value=self._clean_text(body.get("hash") or body.get("hash_value") or parsed.get("hash")), limit=self._safe_int(body.get("limit"), 5), )) if assistant_action == "mp_download_control": control = self._clean_text(parsed.get("download_control") or body.get("download_control") or body.get("control") or body.get("operation")).lower() control_aliases = { "暂停": "pause", "停止": "pause", "pause": "pause", "stop": "pause", "恢复": "resume", "继续": "resume", "开始": "resume", "resume": "resume", "start": "resume", "删除": "delete", "移除": "delete", "delete": "delete", "remove": "delete", } control = control_aliases.get(control, control) target = self._clean_text(keyword or body.get("target") or body.get("hash") or body.get("index") or body.get("choice")) if control not in {"pause", "resume", "delete"} or not target: return finish({ "success": False, "message": "用法:先发“下载任务”,再发“暂停下载 1”“恢复下载 1”或“删除下载 1”。", "data": self._assistant_response_data(session=session, data={ "action": "mp_download_control", "ok": False, "error_code": "invalid_download_control_args", }), }) if not self._resolve_mp_download_task_target(target=target, cache_key=cache_key): return finish({ "success": False, "message": "未找到可操作的下载任务。请先发送“下载任务”获取列表,再按编号操作;也可以直接传 40 位任务 hash。", "data": self._assistant_response_data(session=session, data={ "action": "mp_download_control", "ok": False, "error_code": "download_task_target_not_found", "target": target, }), }) if not self._parse_bool_value(body.get("confirmed") or body.get("execute"), False): return finish(immediate(self._assistant_mp_download_control_plan_response( control=control, target=target, session=session, cache_key=cache_key, downloader=self._clean_text(body.get("downloader")), delete_files=self._parse_bool_value(body.get("delete_files"), False), ))) return finish(await self._assistant_mp_download_control( session=session, cache_key=cache_key, control=control, target=target, downloader=self._clean_text(body.get("downloader")), delete_files=self._parse_bool_value(body.get("delete_files"), False), )) if assistant_action == "mp_download_best": preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) return finish(await self._assistant_mp_best_download_plan( session=session, cache_key=cache_key, preferences=preferences, )) if assistant_action == "mp_download": choice = self._safe_int(parsed.get("keyword") or body.get("choice") or body.get("index"), 0) if choice <= 0: return finish({ "success": False, "message": "用法:下载 1", "data": self._assistant_response_data(session=session, data={"action": "mp_download", "ok": False}), }) preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) current_state = self._load_session(cache_key) current_kind = self._clean_text(current_state.get("kind")) if isinstance(current_state, dict) else "" direct_from_pt_list = current_kind == "assistant_mp" if not direct_from_pt_list and not self._parse_bool_value(body.get("confirmed") or body.get("execute"), False): return finish(immediate(self._assistant_mp_download_plan_response( choice=choice, session=session, cache_key=cache_key, preferences=preferences, ))) return finish(await self._assistant_mp_download( choice=choice, session=session, cache_key=cache_key, preferences=preferences, )) if assistant_action == "mp_subscribe_search": return finish({ "success": False, "message": "“订阅并搜索 片名”旧命令已取消。\n请改用:订阅 片名\n如需立即看资源,再手动发 MP搜索 / 盘搜搜索 / 影巢搜索。", "data": self._assistant_response_data(session=session, data={"action": "mp_subscribe_search_removed", "ok": False}), }) if assistant_action == "mp_subscribe": if not keyword: return finish({ "success": False, "message": "用法:订阅 片名", "data": self._assistant_response_data(session=session, data={"action": assistant_action, "ok": False}), }) if not self._parse_bool_value(body.get("confirmed") or body.get("execute"), False): return finish(immediate(self._assistant_mp_subscribe_plan_response( keyword=keyword, session=session, cache_key=cache_key, immediate_search=False, ))) return finish(await self._assistant_mp_subscribe( keyword=keyword, session=session, immediate_search=False, )) if assistant_action == "mp_recommendations": source, inferred_media_type = self._normalize_mp_recommend_request( body.get("source") or parsed.get("keyword") or text or "tmdb_trending" ) return finish(await self._assistant_mp_recommendations( source=source, media_type=self._clean_text(body.get("media_type") or body.get("type") or parsed.get("type") or inferred_media_type or "all"), limit=self._safe_int(body.get("limit"), 20), session=session, cache_key=cache_key, )) if assistant_action == "return_to_recommend": return finish(await self._assistant_restore_mp_recommendation_handoff( session=session, cache_key=cache_key, state=state, limit=self._safe_int(body.get("limit"), 20), )) if assistant_action == "switch_recommend_handoff_source": return finish(await self._assistant_switch_recommend_handoff_source( request, session=session, cache_key=cache_key, state=state, mode=self._clean_text(parsed.get("mode")), )) if assistant_action == "return_to_smart_decision": return finish(await self._assistant_route_recommend_handoff_to_smart_decision( request, session=session, cache_key=cache_key, state=state, )) if assistant_action == "confirm_recommend_handoff": return finish(await self._assistant_confirm_recommend_handoff( request, session=session, cache_key=cache_key, state=state, compact=compact, target_path=target_path, )) if assistant_action == "p115_help": summary = self._format_p115_help_text() pending_summary = self._pending_p115_summary(state) if pending_summary: summary = f"{summary}\n{pending_summary}" return { "success": True, "message": summary, "data": self._assistant_response_data(session=session, data={ "action": "p115_help", "ok": True, "status_summary": summary, "status": self._p115_status_snapshot(), }), } if assistant_action == "p115_status": summary = self._format_p115_status_summary() pending_summary = self._pending_p115_summary(state) if pending_summary: summary = f"{summary}\n{pending_summary}" return finish({ "success": True, "message": summary, "data": self._assistant_response_data(session=session, data={ "action": "p115_status", "ok": True, "status_summary": summary, "status": self._p115_status_snapshot(), }), }) if assistant_action == "p115_pending": pending_summary = self._pending_p115_summary(state) if not pending_summary: return { "success": True, "message": "当前没有待继续的 115 任务。", "data": self._assistant_response_data(session=session, data={"action": "p115_pending", "ok": True}), } return { "success": True, "message": pending_summary, "data": self._assistant_response_data(session=session, data={"action": "p115_pending", "ok": True}), } if assistant_action == "quark_clear_default_dir": bridge_result = self._assistant_quarkdisk_clear_directory(self._quark_default_path) if bridge_result is None: quark_service = self._ensure_quark_service() clear_ok, clear_result, clear_message = quark_service.clear_directory(self._quark_default_path) else: clear_ok, clear_result, clear_message = bridge_result if not clear_ok and isinstance(clear_result, dict) and clear_result.get("fallback_to_direct"): quark_service = self._ensure_quark_service() clear_ok, clear_result, clear_message = quark_service.clear_directory(self._quark_default_path) target_path = self._clean_text((clear_result or {}).get("target_path")) or self._quark_default_path if not clear_ok: file_count = self._safe_int((clear_result or {}).get("file_count"), 0) folder_count = self._safe_int((clear_result or {}).get("folder_count"), 0) removed_count = self._safe_int((clear_result or {}).get("removed_count"), 0) failed_items = (clear_result or {}).get("failed_items") or [] lines = [ f"清空夸克默认目录失败:{clear_message or '未知错误'}", f"目录:{target_path}", ] if removed_count: lines.append(f"已删除:{removed_count} 项") if file_count or folder_count: lines.append(f"当前层项目:文件 {file_count} / 文件夹 {folder_count}") if isinstance(failed_items, list) and failed_items: names = [ self._clean_text(item.get("name") if isinstance(item, dict) else item) for item in failed_items[:5] ] names = [name for name in names if name] if names: lines.append("失败项:" + "、".join(names)) return finish({ "success": False, "message": "\n".join(lines), "data": self._assistant_response_data(session=session, data={ "action": "quark_clear_default_dir", "ok": False, "target_path": target_path, "result": clear_result, "write_effect": "cloud_drive", }), }) removed_count = self._safe_int((clear_result or {}).get("removed_count"), 0) file_count = self._safe_int((clear_result or {}).get("file_count"), 0) folder_count = self._safe_int((clear_result or {}).get("folder_count"), 0) if removed_count <= 0: message_text = f"夸克默认目录当前层已是空目录\n目录:{target_path}" else: message_text = ( f"夸克默认目录已清空当前层\n" f"目录:{target_path}\n" f"已删除:{removed_count} 项(文件 {file_count} / 文件夹 {folder_count})" ) return finish({ "success": True, "message": message_text, "data": self._assistant_response_data(session=session, data={ "action": "quark_clear_default_dir", "ok": True, "target_path": target_path, "removed_count": removed_count, "file_count": file_count, "folder_count": folder_count, "result": clear_result, "write_effect": "cloud_drive", }), }) if assistant_action == "p115_clear_default_dir": p115_service = self._ensure_p115_service() clear_ok, clear_result, clear_message = p115_service.clear_directory(self._p115_default_path) target_path = self._clean_text((clear_result or {}).get("path")) or self._p115_default_path if not clear_ok: file_count = self._safe_int((clear_result or {}).get("file_count"), 0) folder_count = self._safe_int((clear_result or {}).get("folder_count"), 0) lines = [ f"清空115默认目录失败:{clear_message or '未知错误'}", f"目录:{target_path}", ] if file_count or folder_count: lines.append(f"当前层项目:文件 {file_count} / 文件夹 {folder_count}") return finish({ "success": False, "message": "\n".join(lines), "data": self._assistant_response_data(session=session, data={ "action": "p115_clear_default_dir", "ok": False, "target_path": target_path, "result": clear_result, "write_effect": "cloud_drive", }), }) removed_count = self._safe_int((clear_result or {}).get("removed_count"), 0) file_count = self._safe_int((clear_result or {}).get("file_count"), 0) folder_count = self._safe_int((clear_result or {}).get("folder_count"), 0) if removed_count <= 0: message_text = f"115 默认目录当前层已是空目录\n目录:{target_path}" else: message_text = ( f"115 默认目录已清空当前层\n" f"目录:{target_path}\n" f"已删除:{removed_count} 项(文件 {file_count} / 文件夹 {folder_count})" ) return finish({ "success": True, "message": message_text, "data": self._assistant_response_data(session=session, data={ "action": "p115_clear_default_dir", "ok": True, "target_path": target_path, "removed_count": removed_count, "file_count": file_count, "folder_count": folder_count, "result": clear_result, "write_effect": "cloud_drive", }), }) if assistant_action == "p115_resume": pending_summary = self._pending_p115_summary(state) if not pending_summary: summary = self._format_p115_status_summary() return { "success": False, "message": f"当前没有待继续的 115 任务。\n{summary}", "data": self._assistant_response_data(session=session, data={"action": "p115_resume", "ok": False}), } if not self._p115_status_snapshot().get("ready"): return { "success": False, "message": f"{pending_summary}\n当前 115 还不可用,请先回复:115登录", "data": self._assistant_response_data(session=session, data={"action": "p115_resume", "ok": False}), } resume_ok, resume_message, resume_data = await self._resume_pending_p115_share( request, body, session_id=cache_key, state=state, ) message_text = "已手动继续 115 任务" if resume_message: message_text = f"{message_text}\n{resume_message}" if not resume_ok: message_text = f"{message_text}\n任务仍未成功,保留待继续状态。" return { "success": resume_ok, "message": message_text, "data": self._assistant_response_data(session=session, data={"action": "p115_resume", "ok": resume_ok, "result": resume_data}), } if assistant_action == "p115_cancel": pending_summary = self._pending_p115_summary(state) if not pending_summary: return { "success": True, "message": "当前没有待取消的 115 任务。", "data": self._assistant_response_data(session=session, data={"action": "p115_cancel", "ok": True}), } self._clear_pending_p115_share(cache_key) return { "success": True, "message": f"{pending_summary}\n已取消并清除这次待继续的 115 任务。", "data": self._assistant_response_data(session=session, data={"action": "p115_cancel", "ok": True}), } if assistant_action == "p115_qrcode_start": previous_state = state client_type = P115TransferService.normalize_qrcode_client_type( parsed.get("client_type") or self._p115_client_type ) qr_ok, data, qr_message = self._ensure_p115_service().create_qrcode_login(client_type=client_type) if not qr_ok: return {"success": False, "message": f"115 扫码二维码生成失败:{qr_message}"} self._save_session( cache_key, { **previous_state, "kind": "assistant_p115_login", "stage": "qrcode", "client_type": client_type, "uid": self._clean_text(data.get("uid")), "time": self._clean_text(data.get("time")), "sign": self._clean_text(data.get("sign")), }, ) pending_text = "" if (previous_state.get("pending_p115") or {}).get("share_url"): pending_text = "\n检测到有待继续的 115 任务,登录成功后我会自动继续执行。" return { "success": True, "message": ( "115 扫码二维码已生成\n" f"客户端:{client_type}\n" "请使用 115 App 扫码确认后,再回复:检查115登录" f"{pending_text}" ), "data": self._assistant_response_data(session=session, data={ "action": "p115_qrcode_start", "ok": True, "qrcode": data.get("qrcode"), "uid": data.get("uid"), "time": data.get("time"), "sign": data.get("sign"), "client_type": client_type, }), } if assistant_action == "p115_qrcode_check": if not state or str(state.get("kind") or "").strip() != "assistant_p115_login": pending_summary = self._pending_p115_summary(state) if pending_summary and self._p115_status_snapshot().get("ready"): resume_ok, resume_message, resume_data = await self._resume_pending_p115_share( request, body, session_id=cache_key, state=state, ) message_text = "没有待检查的扫码会话,但检测到待继续的 115 任务。" if resume_message: message_text = f"{message_text}\n{resume_message}" if not resume_ok: message_text = f"{message_text}\n任务仍未成功,继续保留待处理状态。" return { "success": resume_ok, "message": message_text, "data": self._assistant_response_data(session=session, data={"action": "p115_qrcode_check", "ok": resume_ok, "result": resume_data}), } summary = self._format_p115_status_summary() if pending_summary: summary = f"{summary}\n{pending_summary}" return { "success": True, "message": ( "没有待检查的 115 登录会话。\n" f"{summary}\n" "如需重新扫码登录,请回复:115登录" ), "data": { **self._assistant_response_data(session=session, data={ "action": "p115_qrcode_check", "ok": True, "status_summary": summary, "status": self._p115_status_snapshot(), }), }, } client_type = P115TransferService.normalize_qrcode_client_type( state.get("client_type") or parsed.get("client_type") or self._p115_client_type ) qr_ok, data, qr_message = self._ensure_p115_service().check_qrcode_login( uid=self._clean_text(state.get("uid")), time_value=self._clean_text(state.get("time")), sign=self._clean_text(state.get("sign")), client_type=client_type, ) if qr_ok and data.get("status") == "success": cookie = self._clean_text(data.pop("cookie")) if cookie: self._p115_cookie = cookie self._p115_client_type = client_type self._apply_runtime_config({ "p115_cookie": cookie, "p115_client_type": client_type, }) data["cookie_saved"] = True data["cookie_mode"] = "client_cookie" self._save_session(cache_key, {**state, "stage": "success", "client_type": client_type}) if not qr_ok: return { "success": False, "message": f"115 扫码状态:{qr_message}", "data": self._assistant_response_data(session=session, data={"action": "p115_qrcode_check", "ok": False, **data}), } status = self._clean_text(data.get("status")) lines = [ "115 扫码状态", f"状态:{status or 'unknown'}", f"结果:{qr_message}", ] if data.get("cookie_saved"): lines.append(self._format_p115_status_summary(title="115 登录完成")) resume_ok, resume_message, resume_data = await self._resume_pending_p115_share( request, body, session_id=cache_key, state=state, ) if resume_message: lines.append("已自动继续刚才未完成的 115 任务") lines.append(resume_message) data["resume_ok"] = resume_ok data["resume_result"] = resume_data if not resume_ok: lines.append("任务仍未成功,已继续保留待处理状态。") elif status in {"waiting", "scanned"}: lines.append("如果还没确认登录,请在 115 App 里点确认后再次回复:检查115登录") message_text = "\n".join(line for line in lines if line).strip() return { "success": True, "message": message_text, "data": self._assistant_response_data(session=session, data={"action": "p115_qrcode_check", "ok": True, **data}), } if parsed.get("url"): provider = "quark" if self._is_quark_url(parsed["url"]) else "115" if self._is_115_url(parsed["url"]) else "unknown" result = await self.api_share_route( _JsonRequestShim(request, { "url": parsed["url"], "access_code": parsed.get("access_code") or "", "path": target_path, "trigger": "Agent影视助手 智能入口", "apikey": self._extract_apikey(request, body), }) ) if provider == "115": if result.get("success"): self._clear_pending_p115_share(cache_key) else: self._save_pending_p115_share( cache_key, share_url=parsed["url"], access_code=parsed.get("access_code") or "", target_path=target_path or self._p115_default_path, source="assistant_link", last_error=str(result.get("message") or ""), ) return finish({ "success": bool(result.get("success")), "message": ( f"{'夸克' if provider == 'quark' else '115' if provider == '115' else '分享'}转存已完成\n目录:" f"{((result.get('data') or {}).get('result') or {}).get('target_path') or ((result.get('data') or {}).get('result') or {}).get('path') or target_path or '-'}" if result.get("success") else ( f"{str(result.get('message') or '处理失败')}\n{self._format_p115_resume_hint()}" if provider == "115" else str(result.get("message") or "处理失败") ) ), "data": self._assistant_response_data(session=session, data={ "action": "share_route", "ok": bool(result.get("success")), "provider": provider, "result": result.get("data") or {}, }), }) mode = parsed.get("mode") or "hdhive" # ── 流媒体推荐:不走搜索链路,直接进入 action 分发 ── if self._clean_text(parsed.get("action")) == "streaming_recommend": return finish(await self._assistant_streaming_recommend( media_type=parsed.get("streaming_media_type") or "all", intent=parsed.get("streaming_intent") or "hot", month=parsed.get("streaming_month") or "", window_days=self._safe_int(parsed.get("streaming_window") or body.get("window_days"), 90), session=session, cache_key=cache_key, compact=compact, )) media_type = self._clean_text(parsed.get("type") or "auto").lower() or "auto" year = self._clean_text(parsed.get("year")) result_filter = self._clean_text(parsed.get("result_filter")).lower() decision_intent = self._clean_text(parsed.get("decision_intent")).lower() source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else None if not source_order: parsed_source_order_text = self._clean_text(parsed.get("source_order_text")) if parsed_source_order_text: source_order = [ self._clean_text(item).lower() for item in parsed_source_order_text.split(",") if self._clean_text(item) ] origin = self._clean_text(body.get("origin")) recommend_handoff = body.get("recommend_handoff") if isinstance(body.get("recommend_handoff"), dict) else {} apikey = self._extract_apikey(request, body) session_preference_overrides: Dict[str, Any] = {} cloud_provider = self._clean_text(parsed.get("cloud_provider") or body.get("cloud_provider") or body.get("provider")).lower() if cloud_provider == "quark": session_preference_overrides.update({ "has_quark": True, "has_115": False, "prefer_cloud_provider": "quark", }) elif cloud_provider == "115": session_preference_overrides.update({ "has_quark": False, "has_115": True, "prefer_cloud_provider": "115", }) preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) pansou_enabled = self._assistant_source_enabled(preferences, "pansou") hdhive_enabled = self._assistant_source_enabled(preferences, "hdhive") mp_pt_enabled = self._assistant_source_enabled(preferences, "mp_pt") if mode in {"update", "update_pansou", "update_hdhive"}: source_filter = "pansou" if mode == "update_pansou" else "hdhive" if mode == "update_hdhive" else "" update_keyword_prefers_pt = ( not source_filter and ( self._keyword_prefers_mp_pt_search(keyword) or bool(re.search(r"(?:^|\s)(?:剧|电视剧|短剧|剧集)\s*$", keyword)) ) ) if update_keyword_prefers_pt: return finish(await self.api_assistant_route( _JsonRequestShim(request, { "session": session, "session_id": cache_key, "mode": "mp", "keyword": keyword, "media_type": media_type, "year": year, "path": target_path, "compact": compact, "origin": "update_episode_search", "apikey": self._extract_apikey(request, body), }) )) return finish(await self._assistant_update_check( keyword=keyword, session=session, cache_key=cache_key, year=year, source_filter=source_filter, )) if mode == "mp_download_title": if not keyword: return finish({ "success": False, "message": "用法:下载 片名", "data": self._assistant_response_data(session=session, data={ "action": "mp_download_title", "ok": False, "error_code": "missing_keyword", }), }) if not self._keyword_has_explicit_year(keyword, year): candidate_result = await self._assistant_mp_candidate_search( keyword=keyword, session=session, cache_key=cache_key, media_type=media_type, year=year, pending_action={ "mode": "mp_download_title", "label": "生成待确认下载计划", "result_filter": result_filter, }, target_path=target_path, ) candidates = (candidate_result.get("data") or {}).get("candidates") or [] if len(candidates) > 1: return finish(candidate_result) if len(candidates) == 1: candidate = dict(candidates[0] or {}) candidate_title = self._clean_text(candidate.get("title")) or keyword candidate_year = self._clean_text(candidate.get("year")) keyword = f"{candidate_title} {candidate_year}".strip() if candidate_year and candidate_year not in candidate_title else candidate_title media_type = self._clean_text(candidate.get("media_type") or media_type or "auto").lower() or "auto" year = candidate_year or year preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) result = await self._assistant_mp_media_search( keyword=keyword, session=session, cache_key=cache_key, preferences=preferences, result_filter=result_filter, ) return finish(await self._assistant_attach_download_plan_choices( result, session=session, cache_key=cache_key, preferences=preferences, )) if mode == "smart": if not keyword: return finish({ "success": False, "message": "用法:智能搜索 片名", "data": self._assistant_response_data(session=session, data={ "action": "smart_resource_search", "ok": False, "error_code": "missing_keyword", }), }) if decision_intent == "make_plan": return finish(await self._assistant_smart_resource_plan( request, keyword=keyword, session=session, cache_key=cache_key, media_type=media_type, year=year, source_order=source_order, target_path=target_path, origin=origin, )) if decision_intent == "show_detail": return finish(await self._assistant_smart_decision_followup_detail( request, keyword=keyword, session=session, cache_key=cache_key, compact=compact, apikey=apikey, media_type=media_type, year=year, source_order=source_order, target_path=target_path, )) if decision_intent == "execute_now": return finish(await self._assistant_smart_resource_execute( request, keyword=keyword, session=session, cache_key=cache_key, media_type=media_type, year=year, source_order=source_order, target_path=target_path, origin=origin, )) return finish(await self._assistant_smart_resource_search( request, keyword=keyword, session=session, cache_key=cache_key, media_type=media_type, year=year, source_order=source_order, target_path=target_path, session_preference_overrides=session_preference_overrides, origin=origin, )) if mode == "cloud_transfer_execute": if not keyword: return finish({ "success": False, "message": "用法:转存 片名;也支持:夸克转存 片名 / 115转存 片名。", "data": self._assistant_response_data(session=session, data={ "action": "cloud_transfer_execute", "ok": False, "error_code": "missing_keyword", }), }) if not self._keyword_has_explicit_year(keyword, year): pending_label = "云盘转存" if cloud_provider == "quark": pending_label = "夸克转存" elif cloud_provider == "115": pending_label = "115转存" candidate_result = await self._assistant_mp_candidate_search( keyword=keyword, session=session, cache_key=cache_key, media_type=media_type, year=year, pending_action={ "mode": "cloud_transfer_execute", "cloud_provider": cloud_provider, "label": pending_label, }, target_path=target_path, ) candidates = (candidate_result.get("data") or {}).get("candidates") or [] if len(candidates) > 1: return finish(candidate_result) if len(candidates) == 1: candidate = dict(candidates[0] or {}) candidate_title = self._clean_text(candidate.get("title")) or keyword candidate_year = self._clean_text(candidate.get("year")) keyword = f"{candidate_title} {candidate_year}".strip() if candidate_year and candidate_year not in candidate_title else candidate_title media_type = self._clean_text(candidate.get("media_type") or media_type or "auto").lower() or "auto" year = candidate_year or year return finish(await self._assistant_cloud_transfer_execute( request, keyword=keyword, session=session, cache_key=cache_key, media_type=media_type, year=year, source_order=source_order or ["pansou", "hdhive"], target_path=target_path, session_preference_overrides=session_preference_overrides, origin=origin or cloud_provider or "cloud_transfer", )) if mode == "smart_decision": if not keyword: return finish({ "success": False, "message": "用法:资源决策 片名;也支持:智能决策 片名。", "data": self._assistant_response_data(session=session, data={ "action": "smart_resource_decision", "ok": False, "error_code": "missing_keyword", }), }) if decision_intent == "make_plan": return finish(await self._assistant_smart_resource_plan( request, keyword=keyword, session=session, cache_key=cache_key, media_type=media_type, year=year, source_order=source_order, target_path=target_path, origin=origin, )) if decision_intent == "show_detail": return finish(await self._assistant_smart_decision_followup_detail( request, keyword=keyword, session=session, cache_key=cache_key, compact=compact, apikey=apikey, media_type=media_type, year=year, source_order=source_order, target_path=target_path, decision_profile=self._clean_text(body.get("decision_profile") or parsed.get("decision_profile")), )) if decision_intent == "execute_now": return finish(await self._assistant_smart_resource_execute( request, keyword=keyword, session=session, cache_key=cache_key, media_type=media_type, year=year, source_order=source_order, target_path=target_path, origin=origin, )) return finish(await self._assistant_smart_resource_decision( request, keyword=keyword, session=session, cache_key=cache_key, media_type=media_type, year=year, source_order=source_order, target_path=target_path, decision_profile=self._clean_text(body.get("decision_profile") or parsed.get("decision_profile")), origin=origin, )) if mode == "smart_plan": if not keyword and self._clean_text((state or {}).get("kind")) != "assistant_smart_search": return finish({ "success": False, "message": "用法:智能计划 片名;也可以先做“智能搜索 片名”,再回复“计划最佳”。", "data": self._assistant_response_data(session=session, data={ "action": "smart_resource_plan", "ok": False, "error_code": "missing_keyword", }), }) return finish(await self._assistant_smart_resource_plan( request, keyword=keyword, session=session, cache_key=cache_key, media_type=media_type, year=year, source_order=source_order, target_path=target_path, origin=origin, )) if mode == "smart_execute": if not keyword and self._clean_text((state or {}).get("kind")) != "assistant_smart_search": return finish({ "success": False, "message": "用法:智能执行 片名;也可以先做“智能搜索 片名”,再回复“执行最佳”。", "data": self._assistant_response_data(session=session, data={ "action": "smart_resource_execute", "ok": False, "error_code": "missing_keyword", }), }) return finish(await self._assistant_smart_resource_execute( request, keyword=keyword, session=session, cache_key=cache_key, media_type=media_type, year=year, source_order=source_order, target_path=target_path, origin=origin, )) if mode == "search": preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) pansou_enabled = self._assistant_source_enabled(preferences, "pansou") hdhive_enabled = self._assistant_source_enabled(preferences, "hdhive") mp_pt_enabled = self._assistant_source_enabled(preferences, "mp_pt") if not keyword: return finish({ "success": False, "message": "用法:搜索 片名", "data": self._assistant_response_data(session=session, data={ "action": "media_search", "ok": False, }), }) next_mode = "" episode_query = self._keyword_prefers_mp_pt_search(keyword) if episode_query: allowed, disabled = self._ensure_mp_pt_enabled() if not allowed: return finish(disabled) next_mode = "mp" elif mp_pt_enabled: next_mode = "mp" elif pansou_enabled: next_mode = "pansou" elif hdhive_enabled: next_mode = "hdhive" if not next_mode: return finish(self._all_search_sources_disabled_response()) return finish(await self.api_assistant_route( _JsonRequestShim(request, { "session": session, "session_id": cache_key, "mode": next_mode, "keyword": keyword, "result_filter": result_filter, "media_type": media_type, "year": year, "path": target_path, "compact": compact, **({"origin": "episode_search"} if episode_query else {}), "apikey": self._extract_apikey(request, body), }) )) if mode == "mp": allowed, disabled = self._ensure_mp_pt_enabled() if not allowed: return finish(disabled) if not keyword: return finish({ "success": False, "message": "用法:MP搜索 片名", "data": self._assistant_response_data(session=session, data={ "action": "media_search", "ok": False, }), }) preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) if not self._keyword_has_explicit_year(keyword, year): candidate_result = await self._assistant_mp_candidate_search( keyword=keyword, session=session, cache_key=cache_key, media_type=media_type, year=year, ) candidates = (candidate_result.get("data") or {}).get("candidates") or [] if len(candidates) > 1: return finish(candidate_result) if len(candidates) == 1: candidate = dict(candidates[0] or {}) candidate_title = self._clean_text(candidate.get("title")) or keyword candidate_year = self._clean_text(candidate.get("year")) if candidate_year and candidate_year not in candidate_title: keyword = f"{candidate_title} {candidate_year}" else: keyword = candidate_title result = await self._assistant_mp_media_search( keyword=keyword, session=session, cache_key=cache_key, preferences=preferences, result_filter=result_filter, ) mp_items = (result.get("data") or {}).get("items") or [] if (not result.get("success") or not mp_items) and keyword: episode_origin = origin in {"episode_search", "update_episode_search"} or self._keyword_prefers_mp_pt_search(keyword) next_commands = [] if episode_origin: next_commands.append(f"PT搜索 {keyword}") else: if self._assistant_source_enabled(preferences, "pansou"): next_commands.append(f"盘搜搜索 {keyword}") if self._assistant_source_enabled(preferences, "hdhive"): next_commands.append(f"影巢搜索 {keyword}") lines = [self._clean_text(result.get("message")) or f"MP/PT 暂无可用结果:{keyword}"] if episode_origin: lines.append("这条带集数/剧集线索,已按 MP/PT 搜索处理;不会自动回退到盘搜或影巢。") lines.append(f"如需继续 PT 侧尝试,可以调整片名或年份后回复:PT搜索 {keyword}") elif next_commands: lines.append("如果想继续找云盘资源,可以回复:" + " / ".join(next_commands)) else: lines.append("盘搜和影巢当前都已关闭;如需继续找云盘资源,请先在插件设置里开启对应来源。") result["success"] = False result["message"] = "\n".join(line for line in lines if line).strip() result_data = dict(result.get("data") or {}) result_data.update({ "suggested_commands": next_commands, "decision_summary": { "stage": "mp_no_result", "label": "MP/PT 暂无结果", "preferred_command": next_commands[0] if next_commands else "", "fallback_command": next_commands[1] if len(next_commands) > 1 else "", "compact_commands": next_commands, "recommended_agent_behavior": "ask_user", "can_auto_run_preferred": False, }, }) result["data"] = self._assistant_response_data(session=session, data=result_data) if result.get("success") and recommend_handoff: current_state = self._load_session(cache_key) or {} self._save_session(cache_key, {**current_state, "recommend_handoff": dict(recommend_handoff)}) result_data = dict(result.get("data") or {}) result_data.update(self._assistant_recommend_handoff_short_metadata(self._load_session(cache_key) or {})) result_data["decision_summary"] = self._assistant_recommend_handoff_entry_summary(self._load_session(cache_key) or {}) result["data"] = self._assistant_response_data(session=session, data=result_data) return finish(result) if mode == "pansou" or mode == "cloud": preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) pansou_enabled = self._assistant_source_enabled(preferences, "pansou") hdhive_enabled = self._assistant_source_enabled(preferences, "hdhive") mp_pt_enabled = self._assistant_source_enabled(preferences, "mp_pt") if mode == "cloud" and not pansou_enabled and not hdhive_enabled: return finish(self._cloud_sources_disabled_response()) if mode == "cloud" and not pansou_enabled and hdhive_enabled: return finish(await self.api_assistant_route( _JsonRequestShim(request, { "session": session, "session_id": cache_key, "mode": "hdhive", "keyword": keyword, "media_type": media_type, "year": year, "path": target_path, "compact": compact, "apikey": self._extract_apikey(request, body), }) )) if mode == "pansou" and not pansou_enabled: if self._is_explicit_pansou_command(text): return finish(self._pansou_disabled_response()) if hdhive_enabled: return finish(await self.api_assistant_route( _JsonRequestShim(request, { "session": session, "session_id": cache_key, "mode": "hdhive", "keyword": keyword, "media_type": media_type, "year": year, "path": target_path, "compact": compact, "apikey": self._extract_apikey(request, body), }) )) if mp_pt_enabled: return finish(await self.api_assistant_route( _JsonRequestShim(request, { "session": session, "session_id": cache_key, "mode": "mp", "keyword": keyword, "media_type": media_type, "year": year, "path": target_path, "compact": compact, "apikey": self._extract_apikey(request, body), }) )) return finish(self._all_search_sources_disabled_response() if self._is_generic_search_command(text) else self._pansou_disabled_response()) search_ok, payload, search_message, used_keyword = self._call_pansou_search_with_variants(keyword) if not search_ok: if mode == "pansou": if hdhive_enabled: service = self._ensure_hdhive_service() hdhive_ok, hdhive_result, _hdhive_message = await service.resolve_candidates_by_keyword( keyword=keyword, media_type=media_type, year=year, candidate_limit=max(30, self._hdhive_candidate_page_size), ) candidates = (hdhive_result or {}).get("candidates") or [] if hdhive_ok and candidates: return finish(self._assistant_finalize_hdhive_candidates( session=session, cache_key=cache_key, keyword=keyword, candidates=candidates, media_type=media_type, year=year, target_path=target_path or self._hdhive_default_path, recommend_handoff=recommend_handoff, lead_note="盘搜当前暂无结果,已自动补查影巢。", )) return finish(self._cloud_no_result_suggestion_response( keyword=keyword, session=session, primary_source="盘搜", checked_fallback=hdhive_enabled, fallback_source_enabled=hdhive_enabled, mp_pt_enabled=mp_pt_enabled, detail=search_message, )) search_ok = False payload = {} search_message = search_message data = payload.get("data") or {} merged = data.get("merged_by_type") or {} channel_115 = self._collect_pansou_channel_items(merged, "115", 20) channel_quark = self._collect_pansou_channel_items(merged, "quark", 20) items: List[Dict[str, Any]] = [] for item in channel_115 + channel_quark: items.append({**item, "index": len(items) + 1}) if mode == "cloud" and not items: hdhive_resources: List[Dict[str, Any]] = [] hdhive_candidate: Dict[str, Any] = {} hdhive_candidates: List[Dict[str, Any]] = [] if hdhive_enabled: service = self._ensure_hdhive_service() hdhive_ok, hdhive_result, _hdhive_message = await service.resolve_candidates_by_keyword( keyword=keyword, media_type=media_type, year=year, candidate_limit=max(30, self._hdhive_candidate_page_size), ) hdhive_candidates = (hdhive_result or {}).get("candidates") or [] chosen_candidate = self._pick_cloud_hdhive_candidate(keyword, hdhive_candidates, year=year) if hdhive_ok and chosen_candidate: resource_ok, resource_result, _resource_message = service.search_resources( media_type=chosen_candidate.get("media_type") or media_type or "auto", tmdb_id=str(chosen_candidate.get("tmdb_id") or ""), ) if resource_ok: preview = self._attach_cloud_scores( self._group_resource_preview(resource_result.get("data") or [], per_group=6), preferences=preferences, source_type="hdhive", target_path=target_path or self._hdhive_default_path, ) hdhive_resources = [dict(item or {}) for item in preview] for local_index, resource in enumerate(hdhive_resources, start=1): resource["index"] = local_index resource["cloud_index"] = local_index hdhive_candidate = dict(chosen_candidate) return finish(self._assistant_finalize_cloud_result( session=session, cache_key=cache_key, keyword=keyword, pansou_items=[], pansou_total=int(data.get("total") or 0), hdhive_resources=hdhive_resources, hdhive_candidate=hdhive_candidate, hdhive_candidates=hdhive_candidates, target_path=target_path or self._hdhive_default_path, lead_note=(f"已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else ""), )) if not items and mode == "pansou": if hdhive_enabled: service = self._ensure_hdhive_service() hdhive_ok, hdhive_result, _hdhive_message = await service.resolve_candidates_by_keyword( keyword=keyword, media_type=media_type, year=year, candidate_limit=max(30, self._hdhive_candidate_page_size), ) candidates = (hdhive_result or {}).get("candidates") or [] if hdhive_ok and candidates: return finish(self._assistant_finalize_hdhive_candidates( session=session, cache_key=cache_key, keyword=keyword, candidates=candidates, media_type=media_type, year=year, target_path=target_path or self._hdhive_default_path, recommend_handoff=recommend_handoff, lead_note="盘搜当前暂无结果,已自动补查影巢。", )) return finish(self._cloud_no_result_suggestion_response( keyword=keyword, session=session, primary_source="盘搜", checked_fallback=hdhive_enabled, fallback_source_enabled=hdhive_enabled, mp_pt_enabled=mp_pt_enabled, )) if items and mode == "cloud": items = self._attach_cloud_scores( items, preferences=preferences, source_type="pansou", target_path=target_path or self._hdhive_default_path, ) items = self._rank_pansou_items(items, limit_per_channel=self._assistant_result_page_size) hdhive_resources: List[Dict[str, Any]] = [] hdhive_candidate: Dict[str, Any] = {} hdhive_candidates: List[Dict[str, Any]] = [] if hdhive_enabled: service = self._ensure_hdhive_service() hdhive_ok, hdhive_result, _hdhive_message = await service.resolve_candidates_by_keyword( keyword=keyword, media_type=media_type, year=year, candidate_limit=max(30, self._hdhive_candidate_page_size), ) hdhive_candidates = (hdhive_result or {}).get("candidates") or [] chosen_candidate = self._pick_cloud_hdhive_candidate(keyword, hdhive_candidates, year=year) if hdhive_ok and chosen_candidate: resource_ok, resource_result, _resource_message = service.search_resources( media_type=chosen_candidate.get("media_type") or media_type or "auto", tmdb_id=str(chosen_candidate.get("tmdb_id") or ""), ) if resource_ok: preview = self._attach_cloud_scores( self._group_resource_preview(resource_result.get("data") or [], per_group=6), preferences=preferences, source_type="hdhive", target_path=target_path or self._hdhive_default_path, ) hdhive_resources = [dict(item or {}) for item in preview] for local_index, resource in enumerate(hdhive_resources, start=1): resource["index"] = local_index resource["cloud_index"] = len(items) + local_index hdhive_candidate = dict(chosen_candidate) return finish(self._assistant_finalize_cloud_result( session=session, cache_key=cache_key, keyword=keyword, pansou_items=items, pansou_total=int(data.get("total") or len(items)), hdhive_resources=hdhive_resources, hdhive_candidate=hdhive_candidate, hdhive_candidates=hdhive_candidates, target_path=target_path or self._hdhive_default_path, lead_note=(f"已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else ""), )) if items: preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) items = self._attach_cloud_scores( items, preferences=preferences, source_type="pansou", target_path=target_path or self._hdhive_default_path, ) items = self._rank_pansou_items(items, limit_per_channel=self._assistant_result_page_size) return finish(self._assistant_finalize_pansou_result( session=session, cache_key=cache_key, keyword=keyword, items=items, total=int(data.get("total") or len(items)), target_path=target_path or self._hdhive_default_path, action_name="pansou_search" if mode == "pansou" else "cloud_search", search_scope="pansou" if mode == "pansou" else "pansou_then_hdhive", recommend_handoff=recommend_handoff, lead_note=(f"已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else ""), )) allowed, disabled = self._ensure_hdhive_resource_enabled() if not allowed: return finish({ "success": False, "message": disabled.get("message") or "影巢资源入口已关闭", "data": self._assistant_response_data(session=session, data={ "action": "hdhive_candidates", "ok": False, "error_code": "hdhive_resource_disabled", "resource_enabled": False, }), }) service = self._ensure_hdhive_service() search_ok, result, search_message = await service.resolve_candidates_by_keyword( keyword=keyword, media_type=media_type, year=year, candidate_limit=max(30, self._hdhive_candidate_page_size), ) if not search_ok: if mode == "hdhive" and pansou_enabled: search_ok, payload, _pansou_message, used_keyword = self._call_pansou_search_with_variants(keyword) if search_ok: data = payload.get("data") or {} merged = data.get("merged_by_type") or {} channel_115 = self._collect_pansou_channel_items(merged, "115", 20) channel_quark = self._collect_pansou_channel_items(merged, "quark", 20) fallback_items: List[Dict[str, Any]] = [] for item in channel_115 + channel_quark: fallback_items.append({**item, "index": len(fallback_items) + 1}) if fallback_items: fallback_items = self._attach_cloud_scores( fallback_items, preferences=preferences, source_type="pansou", target_path=target_path or self._hdhive_default_path, ) fallback_items = self._rank_pansou_items(fallback_items, limit_per_channel=self._assistant_result_page_size) return finish(self._assistant_finalize_pansou_result( session=session, cache_key=cache_key, keyword=keyword, items=fallback_items, total=int(data.get("total") or len(fallback_items)), target_path=target_path or self._hdhive_default_path, action_name="pansou_search", search_scope="hdhive_then_pansou", recommend_handoff=recommend_handoff, lead_note=("影巢当前暂无结果,已自动补查盘搜。" + (f"\n已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else "")), )) return finish(self._cloud_no_result_suggestion_response( keyword=keyword, session=session, primary_source="影巢", checked_fallback=pansou_enabled, fallback_source_enabled=pansou_enabled, mp_pt_enabled=mp_pt_enabled, detail=search_message, )) candidates = result.get("candidates") or [] if not candidates and mode == "hdhive" and pansou_enabled: search_ok, payload, _pansou_message, used_keyword = self._call_pansou_search_with_variants(keyword) if search_ok: data = payload.get("data") or {} merged = data.get("merged_by_type") or {} channel_115 = self._collect_pansou_channel_items(merged, "115", 20) channel_quark = self._collect_pansou_channel_items(merged, "quark", 20) fallback_items: List[Dict[str, Any]] = [] for item in channel_115 + channel_quark: fallback_items.append({**item, "index": len(fallback_items) + 1}) if fallback_items: fallback_items = self._attach_cloud_scores( fallback_items, preferences=preferences, source_type="pansou", target_path=target_path or self._hdhive_default_path, ) fallback_items = self._rank_pansou_items(fallback_items, limit_per_channel=self._assistant_result_page_size) return finish(self._assistant_finalize_pansou_result( session=session, cache_key=cache_key, keyword=keyword, items=fallback_items, total=int(data.get("total") or len(fallback_items)), target_path=target_path or self._hdhive_default_path, action_name="pansou_search", search_scope="hdhive_then_pansou", recommend_handoff=recommend_handoff, lead_note=("影巢当前暂无结果,已自动补查盘搜。" + (f"\n已自动改用关键词“{used_keyword}”补查。" if used_keyword and used_keyword != self._clean_text(keyword) else "")), )) if not candidates and mode == "hdhive": return finish(self._cloud_no_result_suggestion_response( keyword=keyword, session=session, primary_source="影巢", checked_fallback=pansou_enabled, fallback_source_enabled=pansou_enabled, mp_pt_enabled=mp_pt_enabled, )) return finish(self._assistant_finalize_hdhive_candidates( session=session, cache_key=cache_key, keyword=keyword, candidates=candidates, media_type=media_type, year=year, target_path=target_path or self._hdhive_default_path, recommend_handoff=recommend_handoff, )) async def api_assistant_action(self, request: Request): body = await request.json() ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} name = self._clean_text(body.get("name") or body.get("action_name")) if not name: return {"success": False, "message": "缺少动作名 name"} compact = self._parse_bool_value(body.get("compact"), False) async def finish(awaitable): result = await awaitable return self._assistant_single_action_compact_response(name, result) if compact else result async def immediate(result: Dict[str, Any]) -> Dict[str, Any]: return result route_payload = { "session": body.get("session"), "session_id": body.get("session_id"), "path": body.get("path") or body.get("target_path"), "apikey": self._extract_apikey(request, body), } pick_payload = { "session": body.get("session"), "session_id": body.get("session_id"), "path": body.get("path") or body.get("target_path"), "apikey": self._extract_apikey(request, body), } if name == "start_pansou_search": route_payload.update({ "mode": "pansou", "keyword": body.get("keyword"), }) return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) if name == "start_smart_resource_search": route_payload.update({ "mode": "smart", "keyword": body.get("keyword"), "media_type": body.get("media_type") or "auto", "year": body.get("year"), "source_order": body.get("source_order") if isinstance(body.get("source_order"), list) else None, }) return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) if name == "start_smart_resource_decision": route_payload.update({ "mode": "smart_decision", "keyword": body.get("keyword"), "media_type": body.get("media_type") or "auto", "year": body.get("year"), "source_order": body.get("source_order") if isinstance(body.get("source_order"), list) else None, "decision_profile": body.get("decision_profile"), }) return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) if name == "start_smart_resource_plan": route_payload.update({ "mode": "smart_plan", "keyword": body.get("keyword"), "media_type": body.get("media_type") or "auto", "year": body.get("year"), "source_order": body.get("source_order") if isinstance(body.get("source_order"), list) else None, }) return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) if name == "start_smart_resource_execute": route_payload.update({ "mode": "smart_execute", "keyword": body.get("keyword"), "media_type": body.get("media_type") or "auto", "year": body.get("year"), "source_order": body.get("source_order") if isinstance(body.get("source_order"), list) else None, }) return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) if name == "start_hdhive_search": route_payload.update({ "mode": "hdhive", "keyword": body.get("keyword"), "media_type": body.get("media_type") or "auto", "year": body.get("year"), }) return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) if name == "start_mp_media_search": route_payload.update({ "mode": "mp", "keyword": body.get("keyword"), }) return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) if name == "query_mp_media_detail": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) return await finish(self._assistant_mp_media_detail( keyword=self._clean_text(body.get("keyword") or body.get("title")), session=session_name, cache_key=cache_key, media_type=self._clean_text(body.get("media_type") or body.get("type") or "auto"), year=self._clean_text(body.get("year")), )) if name == "query_mp_search_result_detail": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) prefs = self._assistant_preferences_public_data(session=session_name).get("preferences") or self._default_assistant_preferences() return await finish(self._assistant_mp_result_detail( choice=self._safe_int(body.get("choice") or body.get("index"), 0), session=session_name, cache_key=cache_key, preferences=prefs, )) if name == "query_mp_best_result_detail": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) prefs = self._assistant_preferences_public_data(session=session_name).get("preferences") or self._default_assistant_preferences() return await finish(self._assistant_mp_best_result_detail( session=session_name, cache_key=cache_key, preferences=prefs, )) if name == "pick_mp_best_download": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) prefs = self._assistant_preferences_public_data(session=session_name).get("preferences") or self._default_assistant_preferences() return await finish(self._assistant_mp_best_download_plan( session=session_name, cache_key=cache_key, preferences=prefs, )) if name == "pick_mp_download": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) prefs = self._assistant_preferences_public_data(session=session_name).get("preferences") or self._default_assistant_preferences() execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) choice = self._safe_int(body.get("choice") or body.get("index"), 0) if not execute_requested: return await finish(immediate(self._assistant_mp_download_plan_response( choice=choice, session=session_name, cache_key=cache_key, preferences=prefs, ))) return await finish(self._assistant_mp_download( choice=choice, session=session_name, cache_key=cache_key, preferences=prefs, )) if name == "query_mp_download_tasks": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) return await finish(self._assistant_mp_download_tasks( session=session_name, cache_key=cache_key, status=self._clean_text(body.get("status") or "downloading"), title=self._clean_text(body.get("title") or body.get("keyword")), hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), downloader=self._clean_text(body.get("downloader")), limit=self._safe_int(body.get("limit"), 10), )) if name == "query_mp_download_history": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) return await finish(self._assistant_mp_download_history( session=session_name, cache_key=cache_key, title=self._clean_text(body.get("title") or body.get("keyword")), hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), limit=self._safe_int(body.get("limit"), 10), page=self._safe_int(body.get("page"), 1), )) if name == "query_mp_lifecycle_status": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) return await finish(self._assistant_mp_lifecycle_status( session=session_name, cache_key=cache_key, title=self._clean_text(body.get("title") or body.get("keyword")), hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), limit=self._safe_int(body.get("limit"), 5), )) if name == "query_mp_ingest_status": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) return await finish(self._assistant_mp_ingest_status( session=session_name, cache_key=cache_key, title=self._clean_text(body.get("title") or body.get("keyword")), hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), limit=self._safe_int(body.get("limit"), 5), )) if name == "query_mp_downloaders": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) return await finish(self._assistant_mp_downloaders(session=session_name, cache_key=cache_key)) if name == "query_mp_sites": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) return await finish(self._assistant_mp_sites( session=session_name, cache_key=cache_key, status=self._clean_text(body.get("status") or "active"), name=self._clean_text(body.get("site_name") or body.get("keyword") or body.get("title")), limit=self._safe_int(body.get("limit"), 30), )) if name == "query_mp_subscribes": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) return await finish(self._assistant_mp_subscribes( session=session_name, cache_key=cache_key, status=self._clean_text(body.get("status") or "all"), media_type=self._clean_text(body.get("media_type") or body.get("type") or "all"), name=self._clean_text(body.get("subscribe_name") or body.get("keyword") or body.get("title")), limit=self._safe_int(body.get("limit"), 20), )) if name == "query_mp_ingest_failures": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) return await finish(self._assistant_mp_ingest_failures( session=session_name, cache_key=cache_key, title=self._clean_text(body.get("title") or body.get("keyword")), limit=self._safe_int(body.get("limit"), 10), page=self._safe_int(body.get("page"), 1), )) if name == "query_ai_failed_samples": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) return await finish(self._assistant_ai_failed_samples( session=session_name, cache_key=cache_key, keyword=self._clean_text(body.get("title") or body.get("keyword")), limit=self._safe_int(body.get("limit"), 10), )) if name == "query_ai_sample_worklist": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) return await finish(self._assistant_ai_sample_worklist( session=session_name, cache_key=cache_key, keyword=self._clean_text(body.get("title") or body.get("keyword")), limit=self._safe_int(body.get("limit"), 10), )) if name == "query_ai_sample_insights": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) return await finish(self._assistant_ai_sample_insights( session=session_name, cache_key=cache_key, keyword=self._clean_text(body.get("title") or body.get("keyword")), limit=self._safe_int(body.get("limit"), 20), top=self._safe_int(body.get("top"), 5), )) if name == "replay_ai_failed_sample": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) sample_index = self._safe_int(body.get("sample_index") or body.get("index"), 0) remove_if_resolved = self._parse_bool_value(body.get("remove_if_resolved"), True) execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) if not execute_requested: return await finish(immediate(self._assistant_ai_replay_sample_plan_response( sample_index=sample_index, session=session_name, cache_key=cache_key, remove_if_resolved=remove_if_resolved, ))) return await finish(self._assistant_ai_replay_failed_sample( sample_index=sample_index, session=session_name, cache_key=cache_key, remove_if_resolved=remove_if_resolved, )) if name == "query_mp_transfer_history": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) return await finish(self._assistant_mp_transfer_history( session=session_name, cache_key=cache_key, title=self._clean_text(body.get("title") or body.get("keyword")), status=self._clean_text(body.get("status") or "all"), limit=self._safe_int(body.get("limit"), 10), page=self._safe_int(body.get("page"), 1), )) if name == "query_mp_recent_activity": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) return await finish(self._assistant_mp_recent_activity( session=session_name, cache_key=cache_key, limit=self._safe_int(body.get("limit"), 10), download_only=self._parse_bool_value(body.get("download_only"), False), transfer_only=self._parse_bool_value(body.get("transfer_only"), False), )) if name == "query_mp_local_diagnose": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) return await finish(self._assistant_mp_local_diagnose( session=session_name, cache_key=cache_key, title=self._clean_text(body.get("title") or body.get("keyword")), hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), limit=self._safe_int(body.get("limit"), 5), )) if name == "query_execution_followup": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) return await finish(self._assistant_execution_followup( request, session=session_name, session_id=cache_key, plan_id=self._clean_text(body.get("plan_id")), )) if name == "query_smart_followup": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) return await finish(self._assistant_smart_followup( request, session=session_name, session_id=cache_key, keyword=self._clean_text(body.get("title") or body.get("keyword")), hash_value=self._clean_text(body.get("hash") or body.get("hash_value")), limit=self._safe_int(body.get("limit"), 5), )) if name == "mp_subscribe_control": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) control = self._clean_text(body.get("control") or body.get("subscribe_control") or body.get("operation")) target = self._clean_text(body.get("target") or body.get("subscribe_id") or body.get("index") or body.get("choice")) allow_raw_subscribe_id = body.get("subscribe_id") is not None execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) if not execute_requested: return await finish(immediate(self._assistant_mp_subscribe_control_plan_response( control=control, target=target, session=session_name, cache_key=cache_key, allow_raw_id=allow_raw_subscribe_id, ))) return await finish(self._assistant_mp_subscribe_control( session=session_name, cache_key=cache_key, control=control, target=target, allow_raw_id=allow_raw_subscribe_id, )) if name == "mp_download_control": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) control = self._clean_text(body.get("control") or body.get("download_control") or body.get("operation")) target = self._clean_text(body.get("target") or body.get("hash") or body.get("index") or body.get("choice")) downloader = self._clean_text(body.get("downloader")) delete_files = self._parse_bool_value(body.get("delete_files"), False) execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) if not execute_requested: return await finish(immediate(self._assistant_mp_download_control_plan_response( control=control, target=target, session=session_name, cache_key=cache_key, downloader=downloader, delete_files=delete_files, ))) return await finish(self._assistant_mp_download_control( session=session_name, cache_key=cache_key, control=control, target=target, downloader=downloader, delete_files=delete_files, )) if name == "start_mp_subscribe": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) keyword = self._clean_text(body.get("keyword") or body.get("title")) if not keyword: state = self._load_session(cache_key) or {} keyword = self._clean_text(state.get("keyword")) execute_requested = self._parse_bool_value(body.get("execute") or body.get("confirmed"), False) if not execute_requested: return await finish(immediate(self._assistant_mp_subscribe_plan_response( keyword=keyword, session=session_name, cache_key=cache_key, immediate_search=False, ))) return await finish(self._assistant_mp_subscribe( keyword=keyword, session=session_name, immediate_search=False, )) if name == "start_mp_recommendations": recommend_session, recommend_cache_key = self._normalize_assistant_session_ref( session=self._clean_text(body.get("session")) or "default", session_id=body.get("session_id"), ) source_name, inferred_media_type = self._normalize_mp_recommend_request( body.get("source") or "tmdb_trending" ) return await finish(self._assistant_mp_recommendations( source=source_name, media_type=self._clean_text(body.get("media_type") or body.get("type") or inferred_media_type) or "all", limit=self._safe_int(body.get("limit"), 20), session=recommend_session, cache_key=recommend_cache_key, )) if name == "pick_recommend_search": pick_payload.update({ "choice": body.get("choice") or body.get("index"), "mode": body.get("mode") or body.get("search_mode") or "mp", }) return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) if name in {"preferences_get", "preferences_save", "preferences_reset"}: method = "GET" if name == "preferences_get" else "DELETE" if name == "preferences_reset" else "POST" payload = { "session": body.get("session"), "session_id": body.get("session_id"), "user_key": body.get("user_key"), "preferences": body.get("preferences") or {}, "compact": body.get("compact", True), "apikey": self._extract_apikey(request, body), } return await finish(self.api_assistant_preferences(_JsonRequestShim(request, payload, method=method))) if name in {"scoring_policy", "query_scoring_policy"}: session_name = self._clean_text(body.get("session")) or "default" return await finish({ "success": True, "message": "评分策略已返回。云盘与 PT 使用不同规则;自动化决策以硬风险和 score_summary 为准。", "data": self._assistant_response_data(session=session_name, data={ "action": "scoring_policy", "ok": True, "scoring_policy": self._assistant_scoring_policy_public_data(), }), }) if name == "start_115_login": route_payload.update({ "action": "p115_qrcode_start", "client_type": body.get("client_type"), }) return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) if name == "route_share": route_payload.update({ "url": body.get("url") or body.get("share_url"), "access_code": body.get("access_code"), }) return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) if name == "unlock_hdhive_resource": session_name, cache_key = self._normalize_assistant_session_ref( session=body.get("session") or "default", session_id=body.get("session_id"), ) resource = body.get("resource") if isinstance(body.get("resource"), dict) else {} slug = self._clean_text(body.get("slug") or resource.get("slug")) final_path = self._resolve_pan_path_value( self._clean_text(body.get("path") or body.get("target_path")) ) or self._hdhive_default_path if not slug: return await finish(immediate({ "success": False, "message": "影巢解锁动作缺少 slug", "data": self._assistant_response_data(session=session_name, data={ "action": "hdhive_unlock", "ok": False, "error_code": "missing_slug", }), })) route_ok, route_result, route_message = await self._unlock_and_route( slug, target_path=final_path, resource=resource, ) if not route_ok: route = dict((route_result or {}).get("route") or {}) share_url = self._clean_text(route.get("share_url")) if self._is_115_url(share_url) or self._clean_text(route.get("provider")) == "115": self._save_pending_p115_share( cache_key, share_url=share_url, access_code=route.get("access_code") or "", target_path=route.get("target_path") or final_path, source="assistant_hdhive_plan", title=resource.get("title") or resource.get("matched_title") or "", last_error=route_message, ) return await finish(immediate({ "success": False, "message": route_message, "data": self._assistant_response_data(session=session_name, data={ "action": "hdhive_unlock", "ok": False, "selected_resource": resource, "result": route_result, }), })) return await finish(immediate({ "success": True, "message": self._format_route_result(route_result), "data": self._assistant_response_data(session=session_name, data={ "action": "hdhive_unlock", "ok": True, "selected_resource": resource, "result": route_result, }), })) if name == "inspect_session_state": return await finish(self.api_assistant_session_state(_JsonRequestShim(request, { "session": body.get("session"), "session_id": body.get("session_id"), "apikey": self._extract_apikey(request, body), }))) if name in {"execute_latest_plan", "execute_plan", "execute_session_latest_plan"}: return await finish(self.api_assistant_plan_execute(_JsonRequestShim(request, { "plan_id": body.get("plan_id"), "session": body.get("session"), "session_id": body.get("session_id"), "prefer_unexecuted": body.get("prefer_unexecuted", True), "stop_on_error": body.get("stop_on_error", True), "include_raw_results": body.get("include_raw_results", False), "apikey": self._extract_apikey(request, body), }))) if name in {"pick_pansou_result", "pick_hdhive_candidate", "pick_hdhive_resource"}: pick_payload.update({"choice": body.get("choice") or body.get("index")}) return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) if name in {"plan_pansou_result", "plan_hdhive_resource", "plan_pick_result"}: pick_payload.update({ "choice": body.get("choice") or body.get("index"), "action": "plan", }) return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) if name == "candidate_detail": pick_payload.update({"action": "detail"}) return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) if name == "candidate_next_page": pick_payload.update({"action": "next_page"}) return await finish(self.api_assistant_pick(_JsonRequestShim(request, pick_payload))) if name == "check_115_login": route_payload.update({"action": "p115_qrcode_check"}) return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) if name == "show_115_status": route_payload.update({"action": "p115_status"}) return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) if name == "resume_pending_115": route_payload.update({"action": "p115_resume"}) return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) if name == "cancel_pending_115": route_payload.update({"action": "p115_cancel"}) return await finish(self.api_assistant_route(_JsonRequestShim(request, route_payload))) if name == "clear_current_session": return await finish(self.api_assistant_session_clear(_JsonRequestShim(request, { "session": body.get("session"), "session_id": body.get("session_id"), "apikey": self._extract_apikey(request, body), }))) if name == "inspect_session": return await finish(self.api_assistant_session_state(_JsonRequestShim(request, { "session": body.get("session"), "session_id": body.get("session_id"), "apikey": self._extract_apikey(request, body), }))) if name == "clear_session_by_id": return await finish(self.api_assistant_sessions_clear(_JsonRequestShim(request, { "session_id": body.get("session_id"), "apikey": self._extract_apikey(request, body), }))) if name == "clear_stale_sessions": return await finish(self.api_assistant_sessions_clear(_JsonRequestShim(request, { "stale_only": True, "limit": body.get("limit") or 100, "apikey": self._extract_apikey(request, body), }))) if name == "clear_executed_plans": return await finish(self.api_assistant_plans_clear(_JsonRequestShim(request, { "executed": True, "limit": body.get("limit") or 100, "apikey": self._extract_apikey(request, body), }))) return {"success": False, "message": f"不支持的动作模板:{name}"} @staticmethod def _assistant_result_message_head(value: Any) -> str: text = str(value or "").strip() if not text: return "" return text.splitlines()[0][:200] def _assistant_action_result_summary( self, *, index: int, name: str, result: Dict[str, Any], ) -> Dict[str, Any]: data = dict((result or {}).get("data") or {}) session_state = dict(data.get("session_state") or {}) if not session_state and ( "has_session" in data or "kind" in data or "stage" in data or "suggested_actions" in data ): session_state = dict(data) summary = { "index": index, "name": self._clean_text(name), "success": bool((result or {}).get("success")), "action": self._clean_text(data.get("action")) or self._clean_text(name), "ok": bool(data.get("ok")) if "ok" in data else bool((result or {}).get("success")), "message_head": self._assistant_result_message_head((result or {}).get("message")), "session": self._clean_text(data.get("session") or session_state.get("session")), "session_id": self._clean_text(data.get("session_id") or session_state.get("session_id")), "kind": self._clean_text(session_state.get("kind")), "stage": self._clean_text(session_state.get("stage")), "next_actions": data.get("next_actions") or session_state.get("suggested_actions") or [], "has_pending_p115": bool(((session_state.get("pending_p115") or {}).get("has_pending"))), } if isinstance(data.get("score_summary"), dict): summary["score_summary"] = data.get("score_summary") if isinstance(data.get("diagnosis_summary"), dict): summary["diagnosis_summary"] = data.get("diagnosis_summary") return summary async def api_assistant_actions(self, request: Request): body = await request.json() ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} actions = body.get("actions") or [] if not isinstance(actions, list) or not actions: return {"success": False, "message": "缺少 actions 数组"} apikey = self._extract_apikey(request, body) requested_count = min(len(actions), 20) stop_on_error = self._parse_bool_value(body.get("stop_on_error"), True) include_raw_results = self._parse_bool_value(body.get("include_raw_results"), False) compact = self._parse_bool_value(body.get("compact"), False) batch_session = self._clean_text(body.get("session")) or "default" batch_session_id = self._clean_text(body.get("session_id")) summaries: List[Dict[str, Any]] = [] raw_results: List[Dict[str, Any]] = [] halted = False halted_at = 0 for idx, item in enumerate(actions[:requested_count], 1): payload = dict(item or {}) if isinstance(item, dict) else {"name": self._clean_text(item)} if not payload.get("session") and batch_session: payload["session"] = batch_session if not payload.get("session_id") and batch_session_id: payload["session_id"] = batch_session_id if "execute" not in payload and "execute" in body: payload["execute"] = body.get("execute") if apikey and not payload.get("apikey") and not payload.get("api_key"): payload["apikey"] = apikey action_name = self._clean_text(payload.get("name") or payload.get("action_name")) result = await self.api_assistant_action(_JsonRequestShim(request, payload)) summaries.append(self._assistant_action_result_summary(index=idx, name=action_name, result=result)) if include_raw_results: raw_results.append(result) if not result.get("success") and stop_on_error: halted = True halted_at = idx break final_session = batch_session final_session_id = batch_session_id if summaries: final_session = self._clean_text(summaries[-1].get("session")) or final_session final_session_id = self._clean_text(summaries[-1].get("session_id")) or final_session_id session_name, _ = self._normalize_assistant_session_ref(session=final_session, session_id=final_session_id) success = bool(summaries) and all(item.get("success") for item in summaries) if halted: success = False message_lines = [ f"批量动作执行完成:{len(summaries)}/{requested_count} 步", f"成功:{len([item for item in summaries if item.get('success')])} 步", ] if halted: message_lines.append(f"已在第 {halted_at} 步停止") else: message_lines.append("已按顺序执行完毕") if summaries: last_head = self._clean_text(summaries[-1].get("message_head")) if last_head: message_lines.append(f"最后结果:{last_head}") data = { "action": "execute_actions", "ok": success, "executed_count": len(summaries), "requested_count": requested_count, "stopped_on_error": halted, "halted_at": halted_at, "results": summaries, } if include_raw_results: data["raw_results"] = raw_results self._record_assistant_execution( action=self._clean_text(body.get("workflow")) or "execute_actions", session=session_name, success=success, message="\n".join(message_lines), summary={ "executed_count": len(summaries), "requested_count": requested_count, "stopped_on_error": halted, "halted_at": halted_at, "results": summaries, }, ) full_data = self._assistant_response_data(session=session_name, data=data) return { "success": success, "message": "\n".join(message_lines), "data": self._assistant_actions_compact_data(full_data) if compact else full_data, } @staticmethod def _assistant_workflow_catalog() -> Dict[str, Any]: return { "workflows": [ { "name": "pansou_search", "description": "按关键词执行盘搜,只返回候选结果并保留会话", "fields": ["session", "keyword", "compact"], }, { "name": "smart_resource_search", "description": "按偏好自动执行盘搜 -> 影巢 -> MP/PT 搜索决策,只读返回推荐结果", "fields": ["session", "keyword", "media_type", "year", "source_order", "path", "compact"], }, { "name": "smart_resource_decision", "description": "按偏好自动执行盘搜 -> 影巢 -> MP/PT 搜索决策,并返回查看详情、生成计划或直接执行的统一建议。", "fields": ["session", "keyword", "media_type", "year", "source_order", "decision_profile", "path", "compact"], }, { "name": "smart_resource_plan", "description": "按偏好自动执行盘搜 -> 影巢 -> MP/PT 搜索决策,并为当前首选生成待确认 plan_id", "fields": ["session", "keyword", "media_type", "year", "source_order", "path", "compact"], }, { "name": "smart_resource_execute", "description": "按偏好自动搜索当前首选,并立即执行对应写入动作;仅适用于用户已明确要求直接执行的场景。", "fields": ["session", "keyword", "media_type", "year", "source_order", "path", "compact"], }, { "name": "pansou_transfer", "description": "按关键词盘搜并直接选择指定编号转存,choice 默认 1", "fields": ["session", "keyword", "choice", "path", "compact"], }, { "name": "hdhive_candidates", "description": "按关键词搜索影巢候选影片,等待下一步选片", "fields": ["session", "keyword", "media_type", "year", "path", "compact"], }, { "name": "hdhive_unlock", "description": "按关键词搜索影巢,选择候选影片,再选择资源解锁落盘", "fields": ["session", "keyword", "candidate_choice", "resource_choice", "media_type", "year", "path", "compact"], }, { "name": "mp_search", "description": "执行 MP 原生搜索,返回 PT 候选结果和评分摘要", "fields": ["session", "keyword", "compact"], }, { "name": "mp_media_detail", "description": "使用 MoviePilot 原生识别确认媒体详情、年份、类型和 TMDB/Douban/IMDB ID", "fields": ["session", "keyword", "media_type", "year", "compact"], }, { "name": "mp_search_detail", "description": "低层诊断:执行 MP 原生搜索并按编号查看 PT 评分明细,只读", "fields": ["session", "keyword", "choice", "compact"], }, { "name": "mp_search_best", "description": "执行 MP 原生搜索并查看当前评分最高的 PT 候选摘要,只读", "fields": ["session", "keyword", "compact"], }, { "name": "mp_search_download", "description": "执行 MP 原生搜索并按编号下载,默认先生成 plan_id", "fields": ["session", "keyword", "choice", "compact", "dry_run"], }, { "name": "mp_download_tasks", "description": "查询 MP 下载任务状态,可按 status/title/hash/downloader 过滤", "fields": ["session", "status", "title", "hash", "downloader", "limit", "compact"], }, { "name": "mp_download_history", "description": "查询 MP 下载历史,并按 hash 关联整理/入库状态,只读", "fields": ["session", "keyword", "hash", "limit", "page", "compact"], }, { "name": "mp_lifecycle_status", "description": "聚合查询 MP 下载任务、下载历史和整理/入库历史,只读", "fields": ["session", "keyword", "hash", "limit", "compact"], }, { "name": "mp_ingest_status", "description": "按片名或 hash 判断当前处于下载中、已下载、整理中、已入库还是失败阶段,只读", "fields": ["session", "keyword", "hash", "limit", "compact"], }, { "name": "mp_downloaders", "description": "查询 MP 下载器配置摘要,不返回敏感字段", "fields": ["session", "compact"], }, { "name": "mp_sites", "description": "查询 MP 站点启用状态、优先级和 Cookie 是否存在,不返回 Cookie 明文", "fields": ["session", "status", "keyword", "limit", "compact"], }, { "name": "mp_download_control", "description": "暂停、恢复或删除 MP 下载任务,默认先生成 plan_id", "fields": ["session", "control", "target", "downloader", "delete_files", "compact", "dry_run"], }, { "name": "mp_subscribe", "description": "按关键词创建 MP 订阅,默认先生成 plan_id", "fields": ["session", "keyword", "compact", "dry_run"], }, { "name": "mp_subscribes", "description": "查询 MP 订阅列表,可按 status/media_type/keyword 过滤", "fields": ["session", "status", "media_type", "keyword", "limit", "compact"], }, { "name": "mp_subscribe_control", "description": "触发订阅刷新、暂停、恢复或删除订阅,默认先生成 plan_id", "fields": ["session", "control", "target", "compact", "dry_run"], }, { "name": "mp_transfer_history", "description": "查询 MP 最近整理/入库历史,可按标题和成功/失败状态过滤,只读", "fields": ["session", "keyword", "status", "limit", "page", "compact"], }, { "name": "mp_ingest_failures", "description": "聚合查看最近整理/入库失败记录,只读", "fields": ["session", "keyword", "limit", "page", "compact"], }, { "name": "ai_failed_samples", "description": "读取 AI 识别增强插件保存的失败样本,只读", "fields": ["session", "keyword", "limit", "compact"], }, { "name": "ai_sample_worklist", "description": "读取 AI 失败样本工作清单,适合先挑需要复查的样本,只读", "fields": ["session", "keyword", "limit", "compact"], }, { "name": "ai_sample_insights", "description": "读取 AI 失败样本洞察,查看主要失败原因与优先处理项,只读", "fields": ["session", "keyword", "limit", "top", "compact"], }, { "name": "ai_replay_failed_sample", "description": "对指定 AI 失败样本执行二次识别重放;默认只生成 plan_id,确认后执行", "fields": ["session", "sample_index", "remove_if_resolved", "compact", "confirmed"], }, { "name": "mp_recent_activity", "description": "查看最近下载和最近整理/入库活动,只读", "fields": ["session", "limit", "download_only", "transfer_only", "compact"], }, { "name": "mp_local_diagnose", "description": "一站式诊断为什么还没入库或当前卡在什么阶段,只读", "fields": ["session", "keyword", "hash", "limit", "compact"], }, { "name": "execution_followup", "description": "按最近已执行计划自动查询对应的下载、订阅或入库后续状态,只读", "fields": ["session", "session_id", "plan_id", "compact"], }, { "name": "smart_followup", "description": "统一跟进入口:有片名时查生命周期;有已执行计划时查执行后状态;否则查最近活动,只读", "fields": ["session", "session_id", "keyword", "hash", "limit", "compact"], }, { "name": "mp_recommend", "description": "读取 MP 原生推荐,例如 TMDB、豆瓣、Bangumi", "fields": ["session", "source", "media_type", "limit", "compact"], }, { "name": "mp_recommend_search", "description": "读取 MP 推荐并按编号继续搜索;mode 可选 mp / hdhive / pansou,传 keyword 时直接 MP 搜索", "fields": ["session", "source", "keyword", "choice", "mode", "media_type", "limit", "compact"], }, { "name": "share_transfer", "description": "识别 115 或夸克分享链接并直接转存", "fields": ["session", "url", "access_code", "path", "compact"], }, { "name": "p115_login_start", "description": "发起 115 扫码登录", "fields": ["session", "client_type", "compact"], }, { "name": "p115_status", "description": "查看 115 当前可用状态", "fields": ["session", "compact"], }, ] } def _assistant_workflow_actions(self, name: str, body: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], str]: workflow_name = self._clean_text(name).lower() session = self._clean_text(body.get("session")) or "default" session_id = self._clean_text(body.get("session_id")) path = self._clean_text(body.get("path") or body.get("target_path")) keyword = self._clean_text(body.get("keyword") or body.get("title")) media_type = self._clean_text(body.get("media_type") or "auto") year = self._clean_text(body.get("year")) source = self._clean_text(body.get("source")) or "tmdb_trending" limit = self._safe_int(body.get("limit"), 20) def base(payload: Dict[str, Any]) -> Dict[str, Any]: current = dict(payload) current.setdefault("session", session) if session_id: current.setdefault("session_id", session_id) if path and "path" not in current: current["path"] = path return current if workflow_name == "pansou_search": if not keyword: return [], "pansou_search 缺少 keyword" return [base({"name": "start_pansou_search", "keyword": keyword})], "" if workflow_name == "smart_resource_search": if not keyword: return [], "smart_resource_search 缺少 keyword" source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else [] return [base({ "name": "start_smart_resource_search", "keyword": keyword, "media_type": media_type, "year": year, "source_order": source_order, })], "" if workflow_name == "smart_resource_decision": if not keyword: return [], "smart_resource_decision 缺少 keyword" source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else [] return [base({ "name": "start_smart_resource_decision", "keyword": keyword, "media_type": media_type, "year": year, "source_order": source_order, "decision_profile": body.get("decision_profile"), })], "" if workflow_name == "smart_resource_plan": source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else [] return [base({ "name": "start_smart_resource_plan", "keyword": keyword, "media_type": media_type, "year": year, "source_order": source_order, })], "" if workflow_name == "smart_resource_execute": source_order = body.get("source_order") if isinstance(body.get("source_order"), list) else [] return [base({ "name": "start_smart_resource_execute", "keyword": keyword, "media_type": media_type, "year": year, "source_order": source_order, })], "" if workflow_name == "pansou_transfer": if not keyword: return [], "pansou_transfer 缺少 keyword" choice = self._safe_int(body.get("choice"), 1) return [ base({"name": "start_pansou_search", "keyword": keyword}), base({"name": "pick_pansou_result", "choice": max(1, choice)}), ], "" if workflow_name == "hdhive_candidates": if not keyword: return [], "hdhive_candidates 缺少 keyword" return [ base({ "name": "start_hdhive_search", "keyword": keyword, "media_type": media_type, "year": year, }) ], "" if workflow_name == "hdhive_unlock": if not keyword: return [], "hdhive_unlock 缺少 keyword" candidate_choice = self._safe_int(body.get("candidate_choice") or body.get("choice"), 0) resource_choice = self._safe_int(body.get("resource_choice"), 0) if candidate_choice <= 0 or resource_choice <= 0: return [], "hdhive_unlock 需要 candidate_choice 和 resource_choice" return [ base({ "name": "start_hdhive_search", "keyword": keyword, "media_type": media_type, "year": year, }), base({"name": "pick_hdhive_candidate", "choice": candidate_choice}), base({"name": "pick_hdhive_resource", "choice": resource_choice}), ], "" if workflow_name == "mp_search": if not keyword: return [], "mp_search 缺少 keyword" return [base({"name": "start_mp_media_search", "keyword": keyword})], "" if workflow_name == "mp_media_detail": if not keyword: return [], "mp_media_detail 缺少 keyword" return [base({ "name": "query_mp_media_detail", "keyword": keyword, "media_type": media_type, "year": year, })], "" if workflow_name == "mp_search_detail": if not keyword: return [], "mp_search_detail 缺少 keyword" choice = self._safe_int(body.get("choice"), 1) return [ base({"name": "start_mp_media_search", "keyword": keyword}), base({"name": "query_mp_search_result_detail", "choice": max(1, choice)}), ], "" if workflow_name == "mp_search_best": if not keyword: return [], "mp_search_best 缺少 keyword" return [ base({"name": "start_mp_media_search", "keyword": keyword}), base({"name": "query_mp_best_result_detail"}), ], "" if workflow_name == "mp_search_download": if not keyword: return [], "mp_search_download 缺少 keyword" choice = self._safe_int(body.get("choice"), 1) return [ base({"name": "start_mp_media_search", "keyword": keyword}), base({"name": "pick_mp_download", "choice": max(1, choice)}), ], "" if workflow_name == "mp_download_tasks": return [base({ "name": "query_mp_download_tasks", "status": self._clean_text(body.get("status")) or "downloading", "title": self._clean_text(body.get("title") or body.get("keyword")), "hash": self._clean_text(body.get("hash") or body.get("hash_value")), "downloader": self._clean_text(body.get("downloader")), "limit": self._safe_int(body.get("limit"), 10), })], "" if workflow_name == "mp_download_history": return [base({ "name": "query_mp_download_history", "keyword": keyword, "hash": self._clean_text(body.get("hash") or body.get("hash_value")), "limit": self._safe_int(body.get("limit"), 10), "page": self._safe_int(body.get("page"), 1), })], "" if workflow_name == "mp_lifecycle_status": return [base({ "name": "query_mp_lifecycle_status", "keyword": keyword, "hash": self._clean_text(body.get("hash") or body.get("hash_value")), "limit": self._safe_int(body.get("limit"), 5), })], "" if workflow_name == "mp_ingest_status": return [base({ "name": "query_mp_ingest_status", "keyword": keyword, "hash": self._clean_text(body.get("hash") or body.get("hash_value")), "limit": self._safe_int(body.get("limit"), 5), })], "" if workflow_name == "mp_downloaders": return [base({"name": "query_mp_downloaders"})], "" if workflow_name == "mp_sites": return [base({ "name": "query_mp_sites", "status": self._clean_text(body.get("status")) or "active", "keyword": keyword, "limit": self._safe_int(body.get("limit"), 30), })], "" if workflow_name == "mp_download_control": control = self._clean_text(body.get("control") or body.get("download_control") or body.get("operation")) target = self._clean_text(body.get("target") or body.get("hash") or body.get("index") or body.get("choice")) if not control or not target: return [], "mp_download_control 需要 control 和 target" return [base({ "name": "mp_download_control", "control": control, "target": target, "downloader": self._clean_text(body.get("downloader")), "delete_files": self._parse_bool_value(body.get("delete_files"), False), })], "" if workflow_name == "mp_subscribe": if not keyword: return [], "mp_subscribe 缺少 keyword" return [base({"name": "start_mp_subscribe", "keyword": keyword})], "" if workflow_name == "mp_subscribes": return [base({ "name": "query_mp_subscribes", "status": self._clean_text(body.get("status")) or "all", "media_type": self._clean_text(body.get("media_type") or body.get("type")) or "all", "keyword": keyword, "limit": self._safe_int(body.get("limit"), 20), })], "" if workflow_name == "mp_subscribe_control": control = self._clean_text(body.get("control") or body.get("subscribe_control") or body.get("operation")) target = self._clean_text(body.get("target") or body.get("subscribe_id") or body.get("index") or body.get("choice")) if not control or not target: return [], "mp_subscribe_control 需要 control 和 target" return [base({ "name": "mp_subscribe_control", "control": control, "target": target, })], "" if workflow_name == "mp_transfer_history": return [base({ "name": "query_mp_transfer_history", "keyword": keyword, "status": self._clean_text(body.get("status")) or "all", "limit": self._safe_int(body.get("limit"), 10), "page": self._safe_int(body.get("page"), 1), })], "" if workflow_name == "mp_ingest_failures": return [base({ "name": "query_mp_ingest_failures", "keyword": keyword, "limit": self._safe_int(body.get("limit"), 10), "page": self._safe_int(body.get("page"), 1), })], "" if workflow_name == "ai_failed_samples": return [base({ "name": "query_ai_failed_samples", "keyword": keyword, "limit": self._safe_int(body.get("limit"), 10), })], "" if workflow_name == "ai_sample_worklist": return [base({ "name": "query_ai_sample_worklist", "keyword": keyword, "limit": self._safe_int(body.get("limit"), 10), })], "" if workflow_name == "ai_sample_insights": return [base({ "name": "query_ai_sample_insights", "keyword": keyword, "limit": self._safe_int(body.get("limit"), 20), "top": self._safe_int(body.get("top"), 5), })], "" if workflow_name == "ai_replay_failed_sample": sample_index = self._safe_int(body.get("sample_index") or body.get("index"), 0) if sample_index <= 0: return [], "ai_replay_failed_sample 需要 sample_index" return [base({ "name": "replay_ai_failed_sample", "sample_index": sample_index, "remove_if_resolved": self._parse_bool_value(body.get("remove_if_resolved"), True), })], "" if workflow_name == "mp_recent_activity": return [base({ "name": "query_mp_recent_activity", "limit": self._safe_int(body.get("limit"), 10), "download_only": self._parse_bool_value(body.get("download_only"), False), "transfer_only": self._parse_bool_value(body.get("transfer_only"), False), })], "" if workflow_name == "mp_local_diagnose": return [base({ "name": "query_mp_local_diagnose", "keyword": keyword, "hash": self._clean_text(body.get("hash") or body.get("hash_value")), "limit": self._safe_int(body.get("limit"), 5), })], "" if workflow_name == "execution_followup": return [base({ "name": "query_execution_followup", "plan_id": self._clean_text(body.get("plan_id")), })], "" if workflow_name == "smart_followup": return [base({ "name": "query_smart_followup", "keyword": keyword, "hash": self._clean_text(body.get("hash") or body.get("hash_value")), "limit": self._safe_int(body.get("limit"), 5), })], "" if workflow_name == "mp_recommend": source_name, inferred_media_type = self._normalize_mp_recommend_request(source) return [ base({ "name": "start_mp_recommendations", "source": source_name, "media_type": self._clean_text(body.get("media_type")) or inferred_media_type or "all", "limit": max(1, min(50, limit)), }) ], "" if workflow_name == "smart_discovery": source_name, inferred_media_type = self._normalize_mp_recommend_request(source) return [ base({ "name": "start_mp_recommendations", "source": source_name, "media_type": self._clean_text(body.get("media_type")) or inferred_media_type or "all", "limit": max(1, min(50, limit)), }) ], "" if workflow_name == "mp_recommend_search": if keyword: return [base({"name": "start_mp_media_search", "keyword": keyword})], "" source_name, inferred_media_type = self._normalize_mp_recommend_request(source) actions = [ base({ "name": "start_mp_recommendations", "source": source_name, "media_type": self._clean_text(body.get("media_type")) or inferred_media_type or "all", "limit": max(1, min(50, limit)), }) ] choice = self._safe_int(body.get("choice"), 0) if choice > 0: actions.append(base({ "name": "pick_recommend_search", "choice": choice, "mode": self._clean_text(body.get("mode")) or "mp", })) return actions, "" if workflow_name == "share_transfer": share_url = self._clean_text(body.get("url") or body.get("share_url")) if not share_url: return [], "share_transfer 缺少 url" return [ base({ "name": "route_share", "url": share_url, "access_code": self._clean_text(body.get("access_code")), }) ], "" if workflow_name == "p115_login_start": return [ base({ "name": "start_115_login", "client_type": self._clean_text(body.get("client_type")), }) ], "" if workflow_name == "p115_status": return [base({"name": "show_115_status"})], "" return [], f"不支持的工作流:{name}" async def api_assistant_workflow(self, request: Request): if request.method.upper() == "GET": ok, message = self._check_api_access(request) if not ok: return {"success": False, "message": message} return { "success": True, "message": "Agent影视助手 预设工作流目录", "data": self._assistant_response_data(session="default", data={ "action": "workflow_catalog", "ok": True, **self._assistant_workflow_catalog(), }), } body = await request.json() ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} workflow_name = self._clean_text(body.get("name") or body.get("workflow")) compact = self._parse_bool_value(body.get("compact"), False) if not workflow_name: return {"success": False, "message": "缺少工作流名 name"} actions, build_error = self._assistant_workflow_actions(workflow_name, body) if build_error: return {"success": False, "message": build_error} session = self._clean_text(body.get("session")) or "default" write_workflows = { "pansou_transfer", "hdhive_unlock", "share_transfer", "ai_replay_failed_sample", "mp_search_download", "mp_download_control", "mp_subscribe", "mp_subscribe_control", } dry_run = self._parse_bool_value( body.get("dry_run"), self._clean_text(workflow_name).lower() in write_workflows, ) if dry_run: if workflow_name == "mp_download_control": control = self._clean_text(body.get("control") or body.get("download_control") or body.get("operation")) target = self._clean_text(body.get("target") or body.get("hash") or body.get("index") or body.get("choice")) result = self._assistant_mp_download_control_plan_response( control=control, target=target, session=session, cache_key=self._clean_text(body.get("session_id")), downloader=self._clean_text(body.get("downloader")), delete_files=self._parse_bool_value(body.get("delete_files"), False), ) if not result.get("success"): return result if workflow_name == "mp_subscribe_control": control = self._clean_text(body.get("control") or body.get("subscribe_control") or body.get("operation")) target = self._clean_text(body.get("target") or body.get("subscribe_id") or body.get("index") or body.get("choice")) allow_raw_id = self._parse_bool_value(body.get("allow_raw_id"), False) result = self._assistant_mp_subscribe_control_plan_response( control=control, target=target, session=session, cache_key=self._clean_text(body.get("session_id")), allow_raw_id=allow_raw_id, ) if not result.get("success"): return result if workflow_name == "ai_replay_failed_sample": result = self._assistant_ai_replay_sample_plan_response( sample_index=self._safe_int(body.get("sample_index") or body.get("index"), 0), session=session, cache_key=self._clean_text(body.get("session_id")), remove_if_resolved=self._parse_bool_value(body.get("remove_if_resolved"), True), ) if not result.get("success"): return result return self._assistant_workflow_plan_response_compact(result) if compact else result execute_body = { **{key: value for key, value in body.items() if key not in {"apikey", "dry_run"}}, "dry_run": False, } plan = self._save_workflow_plan( workflow=workflow_name, session=session, session_id=self._clean_text(body.get("session_id")), actions=actions, execute_body=execute_body, ) full_data = self._assistant_response_data(session=session, data={ "action": "workflow_plan", "ok": True, "plan_id": plan.get("plan_id"), "workflow": workflow_name, "dry_run": True, "workflow_actions": actions, "estimated_steps": len(actions), "ready_to_execute": True, "execute_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/workflow", "execute_plan_endpoint": "/api/v1/plugin/AgentResourceOfficer/assistant/plan/execute", "execute_plan_body": {"plan_id": plan.get("plan_id")}, "execute_body": execute_body, "plan_created_at": plan.get("created_at"), "plan_created_at_text": plan.get("created_at_text"), }) return { "success": True, "message": f"工作流 {workflow_name} 计划已生成:{plan.get('plan_id')},共 {len(actions)} 步,未实际执行。", "data": self._assistant_workflow_plan_compact_data(full_data) if compact else full_data, } result = await self.api_assistant_actions( _JsonRequestShim( request, { "actions": actions, "workflow": workflow_name, "session": session, "session_id": self._clean_text(body.get("session_id")), "execute": True, "stop_on_error": self._parse_bool_value(body.get("stop_on_error"), True), "include_raw_results": self._parse_bool_value(body.get("include_raw_results"), False), "compact": compact, "apikey": self._extract_apikey(request, body), }, ) ) data = dict(result.get("data") or {}) data["workflow"] = workflow_name if not compact: data["workflow_actions"] = actions return { "success": bool(result.get("success")), "message": f"工作流 {workflow_name} 执行完成\n{result.get('message') or ''}".strip(), "data": data, } async def api_assistant_plan_execute(self, request: Request): body = await request.json() ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} plan_id = self._clean_text(body.get("plan_id")) session = self._clean_text(body.get("session")) session_id = self._clean_text(body.get("session_id")) prefer_unexecuted = self._parse_bool_value(body.get("prefer_unexecuted"), True) compact = self._parse_bool_value(body.get("compact"), False) plan = self._find_workflow_plan( plan_id=plan_id, session=session, session_id=session_id, executed=False if prefer_unexecuted and not plan_id else None, ) if not plan and not plan_id and (session or session_id) and prefer_unexecuted: plan = self._find_workflow_plan( session=session, session_id=session_id, executed=None, ) if not plan: result = { "success": False, "message": f"计划不存在或已过期:{plan_id}" if plan_id else "没有匹配到可执行计划,请先生成 dry_run 计划或改传 plan_id。", "data": self._assistant_response_data(session=session or "default", data={ "action": "execute_plan", "ok": False, "plan_id": plan_id, "error_code": "plan_not_found", }), } return self._assistant_plan_execute_compact_response(result) if compact else result plan_id = self._clean_text(plan.get("plan_id")) actions = plan.get("actions") or [] if not isinstance(actions, list) or not actions: result = { "success": False, "message": f"计划没有可执行动作:{plan_id}", "data": self._assistant_response_data(session=session or "default", data={ "action": "execute_plan", "ok": False, "plan_id": plan_id, "error_code": "plan_has_no_actions", }), } return self._assistant_plan_execute_compact_response(result) if compact else result workflow_name = self._clean_text(plan.get("workflow")) or "saved_plan" session = self._clean_text(plan.get("session")) or "default" session_id = self._clean_text(plan.get("session_id")) action_result = await self.api_assistant_actions( _JsonRequestShim( request, { "actions": actions, "workflow": workflow_name, "session": session, "session_id": session_id, "execute": True, "stop_on_error": self._parse_bool_value(body.get("stop_on_error"), True), "include_raw_results": self._parse_bool_value(body.get("include_raw_results"), False), "compact": False, "apikey": self._extract_apikey(request, body), }, ) ) executed_at = int(time.time()) plan.update({ "executed": True, "executed_at": executed_at, "executed_at_text": self._format_unix_time(executed_at), "last_success": bool(action_result.get("success")), "last_message": self._assistant_result_message_head(action_result.get("message")), }) self._workflow_plans[plan_id] = plan self._persist_workflow_plans() data = dict(action_result.get("data") or {}) data.update({ "action": "execute_plan", "plan_id": plan_id, "workflow": workflow_name, "plan_auto_selected": not bool(self._clean_text(body.get("plan_id"))), "plan_created_at": plan.get("created_at"), "plan_created_at_text": plan.get("created_at_text"), "plan_executed_at": executed_at, "plan_executed_at_text": plan.get("executed_at_text"), }) followup_state = dict(data.get("session_state") or {}) if isinstance(data.get("session_state"), dict) else {} if isinstance(plan.get("execute_body"), dict): followup_state["plan_execute_body"] = dict(plan.get("execute_body") or {}) followup = self._assistant_plan_execute_followup( workflow=workflow_name, session=session, session_id=session_id, session_state=followup_state, ok=bool(action_result.get("success")), plan_id=plan_id, ) data["recommended_action"] = self._clean_text(data.get("recommended_action")) or self._clean_text(followup.get("recommended_action")) data["follow_up_hint"] = self._clean_text(data.get("follow_up_hint")) or self._clean_text(followup.get("follow_up_hint")) data["followup_summary"] = followup.get("followup_summary") or {} data["next_actions"] = self._assistant_compact_next_actions( followup.get("next_actions"), data.get("next_actions") or [], ) data["action_templates"] = self._assistant_compact_action_templates( templates=[ *(followup.get("action_templates") or []), *(data.get("action_templates") or []), ], limit=6, ) if not compact: data["workflow_actions"] = actions message_lines = [ f"计划 {plan_id} 执行完成", self._clean_text(action_result.get("message")), ] if data.get("recommended_action"): message_lines.append(f"推荐动作:{data.get('recommended_action')}") if data.get("follow_up_hint"): message_lines.append(f"下一步:{data.get('follow_up_hint')}") message_lines.extend(self._format_followup_summary_lines(data.get("followup_summary"))) result = { "success": bool(action_result.get("success")), "message": "\n".join(line for line in message_lines if line).strip(), "data": data, } return self._assistant_plan_execute_compact_response(result) if compact else result async def api_assistant_pick(self, request: Request): body = await request.json() ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} session, cache_key = self._normalize_assistant_session_ref( session=( body.get("session") or body.get("chat_id") or body.get("user_id") or body.get("conversation_id") or "default" ), session_id=body.get("session_id"), ) index = self._safe_int( body.get("index") or body.get("choice") or body.get("selection") or body.get("number"), 0, ) raw_action_text = self._clean_text(body.get("action") or body.get("pick_action")) action = self._normalize_pick_action(raw_action_text) target_path = self._resolve_pan_path_value(self._clean_text(body.get("path") or body.get("target_path"))) compact = self._parse_bool_value(body.get("compact"), False) def finish(result: Dict[str, Any]) -> Dict[str, Any]: return self._assistant_interaction_compact_response(result) if compact else result state = self._load_session(cache_key) if not state: return {"success": False, "message": "没有可继续的缓存,请先发起搜索或发送分享链接。"} if index <= 0 and not action: return {"success": False, "message": "请选择有效序号,例如:选择 1"} kind = str(state.get("kind") or "").strip() smart_short_action = self._normalize_smart_search_short_action(raw_action_text, state_kind=kind) if smart_short_action: action = smart_short_action if kind == "assistant_cloud": pansou_items = state.get("pansou_items") or [] hdhive_resources = state.get("hdhive_resources") or [] hdhive_candidate = dict(state.get("hdhive_candidate") or {}) hdhive_candidates = state.get("hdhive_candidates") or [] page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) current_page = max(1, self._safe_int(state.get("page"), 1)) pansou_count = len(pansou_items) total_count = pansou_count + len(hdhive_resources) if action == "next_page": total_pages = max(1, (total_count + page_size - 1) // page_size) if total_count else 1 if current_page >= total_pages: return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} next_page = current_page + 1 updated_state = {**state, "page": next_page, "page_size": page_size} self._save_session(cache_key, updated_state) return finish({ "success": True, "message": self._format_cloud_text( keyword=self._clean_text(state.get("keyword")), pansou_items=pansou_items, pansou_total=self._safe_int(state.get("pansou_total"), pansou_count), hdhive_resources=hdhive_resources, hdhive_candidate=hdhive_candidate, hdhive_candidates=hdhive_candidates, page=next_page, page_size=page_size, ), "data": self._assistant_response_data(session=session, data={ "action": "cloud_search_next_page", "ok": True, "page": next_page, "page_size": page_size, "total_pages": total_pages, }), }) if action == "best" and pansou_items: delegate_state = { "kind": "assistant_pansou", "stage": "result", "keyword": state.get("keyword"), "target_path": target_path or state.get("target_path") or self._hdhive_default_path, "items": pansou_items, "total": self._safe_int(state.get("pansou_total"), len(pansou_items)), "page": max(1, self._safe_int(state.get("page"), 1)), "page_size": page_size, } self._save_session(cache_key, delegate_state) try: return finish(await self.api_assistant_pick( _JsonRequestShim(request, { "session": session, "session_id": cache_key, "choice": 0, "action": "best", "path": target_path, "compact": compact, "apikey": self._extract_apikey(request, body), }) )) finally: self._save_session(cache_key, state) if index <= 0: if action: return {"success": False, "message": "当前盘搜+影巢结果需要编号,例如:选择 1 或 选择 12 详情。"} return {"success": False, "message": "请选择有效序号,例如:选择 1"} if index > total_count: return {"success": False, "message": f"序号超出范围,请输入 1 到 {total_count} 之间的数字。"} if index <= pansou_count: delegate_state = { "kind": "assistant_pansou", "stage": "result", "keyword": state.get("keyword"), "target_path": target_path or state.get("target_path") or self._hdhive_default_path, "items": pansou_items, } self._save_session(cache_key, delegate_state) try: return finish(await self.api_assistant_pick( _JsonRequestShim(request, { "session": session, "session_id": cache_key, "choice": index, "action": action, "path": target_path, "compact": compact, "apikey": self._extract_apikey(request, body), }) )) finally: self._save_session(cache_key, state) local_index = index - pansou_count delegate_state = { "kind": "assistant_hdhive", "stage": "resource", "keyword": state.get("keyword"), "target_path": target_path or state.get("target_path") or self._hdhive_default_path, "selected_candidate": hdhive_candidate, "resources": [ {**dict(item or {}), "index": idx} for idx, item in enumerate(hdhive_resources, start=1) ], } self._save_session(cache_key, delegate_state) try: return finish(await self.api_assistant_pick( _JsonRequestShim(request, { "session": session, "session_id": cache_key, "choice": local_index, "action": action, "path": target_path, "compact": compact, "apikey": self._extract_apikey(request, body), }) )) finally: self._save_session(cache_key, state) if kind == "assistant_update_check": items = [dict(item or {}) for item in (state.get("items") or []) if isinstance(item, dict)] if action == "next_page": return {"success": False, "message": "更新检查结果当前不分页,可以直接回复编号或“选择 编号 详情”。"} if action in {"best", "best_execute", "best_plan"}: best = self._best_scored_source_item(items) if not best: return {"success": False, "message": "当前更新检查结果没有可评分条目,请直接回复编号。"} index = self._safe_int(best.get("_index") or best.get("index"), 0) action = "plan" if action == "best_plan" else "" if action == "best_execute" else "detail" if index <= 0: return {"success": False, "message": "更新检查结果需要编号,例如:选择 25 详情。"} matched_items = [ item for item in items if self._safe_int(item.get("_index") or item.get("index"), 0) == index ] if not matched_items and 1 <= index <= len(items): matched_items = [items[index - 1]] if not matched_items: available = "、".join( f"#{self._safe_int(item.get('_index') or item.get('index'), 0)}" for item in items if self._safe_int(item.get("_index") or item.get("index"), 0) > 0 ) return { "success": False, "message": f"序号不在当前更新检查结果里。可选编号:{available or '暂无'}", } selected = dict(matched_items[0]) source_type = self._clean_text(selected.get("_update_source")).lower() choice_index = self._safe_int(selected.get("_index") or selected.get("index"), index) selected["index"] = choice_index selected["_index"] = choice_index final_path = target_path or state.get("target_path") or self._hdhive_default_path if action == "detail": title = "盘搜资源详情" if source_type == "pansou" else "影巢资源详情" return finish({ "success": True, "message": self._format_cloud_item_detail_text(selected, title=title), "data": self._assistant_response_data(session=session, data={ "action": "update_check_resource_detail", "ok": True, "choice": choice_index, "source_type": source_type, "item": selected, "score_summary": self._score_summary([selected], limit=1), }), }) if source_type == "pansou": share_url = self._clean_text(selected.get("url")) if not share_url: return {"success": False, "message": "选中的盘搜结果缺少分享链接,无法继续处理;可先发“选择 编号 详情”查看。"} access_code = self._clean_text(selected.get("password")) route_path = target_path or ( self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path ) if action == "plan": return finish(self._save_assistant_pick_plan_response( workflow="update_check_pansou_transfer", session=session, session_id=cache_key, actions=[{ "name": "route_share", "session": session, "session_id": cache_key, "url": share_url, "access_code": access_code, "path": route_path, }], execute_body={ "workflow": "update_check_pansou_transfer", "session": session, "session_id": cache_key, "choice": choice_index, "path": route_path, }, message="更新检查盘搜资源转存计划已生成", score_items=[selected], extra_data={ "choice": choice_index, "source_type": source_type, "target_path": route_path, "selected_item": selected, }, )) if action: return {"success": False, "message": "更新检查结果支持:直接回编号、选择 编号 详情、计划选择 编号。"} route_result = await self.api_share_route( _JsonRequestShim(request, { "url": share_url, "access_code": access_code, "target_path": route_path, "apikey": self._extract_apikey(request, body), }) ) return finish(route_result) if source_type == "hdhive": slug = self._clean_text(selected.get("slug")) if not slug: return {"success": False, "message": "选中的影巢资源缺少 slug,无法继续处理;可先发“选择 编号 详情”查看。"} if action == "plan": return finish(self._save_assistant_pick_plan_response( workflow="update_check_hdhive_unlock", session=session, session_id=cache_key, actions=[{ "name": "unlock_hdhive_resource", "session": session, "session_id": cache_key, "slug": slug, "path": final_path, "resource": selected, }], execute_body={ "workflow": "update_check_hdhive_unlock", "session": session, "session_id": cache_key, "choice": choice_index, "path": final_path, }, message="更新检查影巢资源解锁/转存计划已生成", score_items=[selected], extra_data={ "choice": choice_index, "source_type": source_type, "target_path": final_path, "selected_resource": selected, }, )) if action: return {"success": False, "message": "更新检查结果支持:直接回编号、选择 编号 详情、计划选择 编号。"} route_ok, route_result, route_message = await self._unlock_and_route( slug, target_path=final_path, resource=selected, ) if not route_ok: route = dict((route_result or {}).get("route") or {}) share_url = self._clean_text(route.get("share_url")) if self._is_115_url(share_url) or self._clean_text(route.get("provider")) == "115": self._save_pending_p115_share( cache_key, share_url=share_url, access_code=route.get("access_code") or "", target_path=route.get("target_path") or final_path, source="assistant_update_check_hdhive", title=selected.get("title") or selected.get("matched_title") or "", last_error=route_message, ) return finish({ "success": False, "message": f"{route_message}\n{self._format_p115_resume_hint(selected.get('title') or selected.get('matched_title') or '')}", "data": self._assistant_response_data(session=session, data=route_result), }) return finish({ "success": False, "message": route_message, "data": self._assistant_response_data(session=session, data=route_result), }) return finish({ "success": True, "message": self._format_route_result(route_result), "data": self._assistant_response_data(session=session, data={ "action": "update_check_hdhive_unlock", "ok": True, "selected_resource": selected, "result": route_result, }), }) return {"success": False, "message": "当前更新检查条目缺少来源信息,请重新执行更新检查。"} if kind == "assistant_pansou": items = state.get("items") or [] page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) current_page = max(1, self._safe_int(state.get("page"), 1)) total_items = len(items) if action == "next_page": total_pages = max(1, (total_items + page_size - 1) // page_size) if total_items else 1 if current_page >= total_pages: return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} next_page = current_page + 1 updated_state = {**state, "page": next_page, "page_size": page_size} self._save_session(cache_key, updated_state) return finish({ "success": True, "message": self._format_pansou_text( self._clean_text(state.get("keyword")), items, self._safe_int(state.get("total"), total_items), page=next_page, page_size=page_size, ), "data": self._assistant_response_data(session=session, data={ "action": "pansou_search_next_page", "ok": True, "page": next_page, "page_size": page_size, "total_pages": total_pages, }), }) if action == "best": best = self._best_scored_source_item(items) if not best: return finish({ "success": False, "message": "当前盘搜结果没有可评分条目,请直接回复编号选择。", "data": self._assistant_response_data(session=session, data={ "action": "pansou_best_detail", "ok": False, "error_code": "best_item_not_found", }), }) return finish({ "success": True, "message": self._format_cloud_item_detail_text(best, title="盘搜当前最佳候选"), "data": self._assistant_response_data(session=session, data={ "action": "pansou_best_detail", "ok": True, "item": best, "score_summary": self._score_summary([best], limit=1), }), } if not state.get("recommend_handoff") else { "success": True, "message": self._format_cloud_item_detail_text(best, title="盘搜当前最佳候选"), "data": self._assistant_response_data(session=session, data={ "action": "pansou_best_detail", "ok": True, "item": best, "score_summary": self._score_summary([best], limit=1), **self._assistant_recommend_handoff_short_metadata(state), "decision_summary": self._assistant_recommend_handoff_detail_summary(state), }), }) if action == "best_execute": best = self._best_scored_source_item(items) if not best: return finish({ "success": False, "message": "当前盘搜结果没有可评分条目,请先查看列表后再执行最佳。", "data": self._assistant_response_data(session=session, data={ "action": "pansou_best_execute", "ok": False, "error_code": "best_item_not_found", }), }) index = self._safe_int(best.get("index"), 0) selected = dict(best) share_url = self._clean_text(selected.get("url")) if not share_url: return {"success": False, "message": "当前盘搜首选缺少分享链接,无法执行最佳。"} access_code = self._clean_text(selected.get("password")) final_path = target_path or ( self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path ) plan_result = self._save_assistant_pick_plan_response( workflow="pansou_best_execute", session=session, session_id=cache_key, actions=[{ "name": "route_share", "session": session, "session_id": cache_key, "url": share_url, "access_code": access_code, "path": final_path, }], execute_body={ "workflow": "pansou_best_execute", "session": session, "session_id": cache_key, "choice": index, "path": final_path, }, message="盘搜最佳候选计划已生成", score_items=[selected], extra_data={ "choice": index, "provider": "115" if self._is_115_url(share_url) else "quark" if self._is_quark_url(share_url) else selected.get("channel"), "target_path": final_path, "selected_item": selected, }, ) return finish(await self._assistant_execute_prepared_plan_result( request, session=session, cache_key=cache_key, plan_result=plan_result, message_prefix=f"已自动选择盘搜最佳候选并执行:#{index}", extra_data={"choice": index, "source_type": "pansou"}, )) if action == "best_plan": best = self._best_scored_source_item(items) if not best: return finish({ "success": False, "message": "当前盘搜结果没有可评分条目,请先查看列表后再生成计划。", "data": self._assistant_response_data(session=session, data={ "action": "pansou_best_plan", "ok": False, "error_code": "best_item_not_found", }), }) index = self._safe_int(best.get("index"), 0) selected = dict(best) share_url = self._clean_text(selected.get("url")) if not share_url: return {"success": False, "message": "当前盘搜首选缺少分享链接,无法生成计划。"} access_code = self._clean_text(selected.get("password")) final_path = target_path or ( self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path ) actions = [{ "name": "route_share", "session": session, "session_id": cache_key, "url": share_url, "access_code": access_code, "path": final_path, }] result = self._save_assistant_pick_plan_response( workflow="pansou_best_plan", session=session, session_id=cache_key, actions=actions, execute_body={ "workflow": "pansou_best_plan", "session": session, "session_id": cache_key, "choice": index, "path": final_path, }, message="盘搜最佳候选计划已生成", score_items=[selected], extra_data={ "choice": index, "provider": "115" if self._is_115_url(share_url) else "quark" if self._is_quark_url(share_url) else selected.get("channel"), "target_path": final_path, "selected_item": selected, }, ) if state.get("recommend_handoff"): result_data = dict(result.get("data") or {}) result_data.update(self._assistant_recommend_handoff_short_metadata(state)) result_data["decision_summary"] = self._assistant_recommend_handoff_plan_summary(state) result["data"] = result_data return finish(result) if action == "detail": if index <= 0: return {"success": False, "message": "盘搜详情需要编号,例如:选择 1 详情。"} if index > len(items): return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(items)} 之间的数字。"} selected = dict(items[index - 1]) return finish({ "success": True, "message": self._format_cloud_item_detail_text(selected, title="盘搜资源详情"), "data": self._assistant_response_data(session=session, data={ "action": "pansou_result_detail", "ok": True, "choice": index, "item": selected, "score_summary": self._score_summary([selected], limit=1), }), } if not state.get("recommend_handoff") else { "success": True, "message": self._format_cloud_item_detail_text(selected, title="盘搜资源详情"), "data": self._assistant_response_data(session=session, data={ "action": "pansou_result_detail", "ok": True, "choice": index, "item": selected, "score_summary": self._score_summary([selected], limit=1), **self._assistant_recommend_handoff_short_metadata(state), "decision_summary": self._assistant_recommend_handoff_detail_summary(state), }), }) if action == "plan": if index <= 0: return {"success": False, "message": "盘搜计划需要编号,例如:计划选择 1。"} if index > len(items): return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(items)} 之间的数字。"} selected = dict(items[index - 1]) share_url = self._clean_text(selected.get("url")) if not share_url: return {"success": False, "message": "选中的盘搜结果缺少分享链接,无法生成计划。"} access_code = self._clean_text(selected.get("password")) final_path = target_path or ( self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path ) actions = [{ "name": "route_share", "session": session, "session_id": cache_key, "url": share_url, "access_code": access_code, "path": final_path, }] return finish(self._save_assistant_pick_plan_response( workflow="pansou_transfer_selected", session=session, session_id=cache_key, actions=actions, execute_body={ "workflow": "pansou_transfer_selected", "session": session, "session_id": cache_key, "choice": index, "path": final_path, }, message="盘搜转存计划已生成", score_items=[selected], extra_data={ "choice": index, "provider": "115" if self._is_115_url(share_url) else "quark" if self._is_quark_url(share_url) else selected.get("channel"), "target_path": final_path, "selected_item": selected, }, )) if action: return {"success": False, "message": "盘搜结果当前只支持:选择 编号、计划选择 编号、选择 编号 详情、最佳片源、计划最佳、执行最佳。"} if index <= 0: return {"success": False, "message": "请选择有效序号,例如:选择 1"} if index > len(items): return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(items)} 之间的数字。"} selected = dict(items[index - 1]) share_url = self._clean_text(selected.get("url")) access_code = self._clean_text(selected.get("password")) final_path = target_path or ( self._p115_default_path if self._is_115_url(share_url) else self._quark_default_path ) route_result = await self.api_share_route( _JsonRequestShim(request, { "url": share_url, "access_code": access_code, "path": final_path, "trigger": "Agent影视助手 智能入口盘搜选择", "apikey": self._extract_apikey(request, body), }) ) if not route_result.get("success"): if self._is_115_url(share_url): self._save_pending_p115_share( cache_key, share_url=share_url, access_code=access_code, target_path=final_path, source="assistant_pansou_pick", title=selected.get("note") or "", last_error=str(route_result.get("message") or ""), ) return finish({ "success": False, "message": ( f"{str(route_result.get('message') or '转存失败')}\n" f"{self._format_p115_resume_hint(selected.get('note') or '')}" ), "data": self._assistant_response_data(session=session, data=route_result.get("data") or {}), }) if self._is_quark_url(share_url) and self._is_quark_share_restricted_message( str(route_result.get("message") or "") ): recovered = await self._assistant_retry_pansou_quark_candidates( request, items=items, selected_index=index, selected_url=share_url, session=session, target_path=final_path, apikey=self._extract_apikey(request, body), ) if recovered: return finish(recovered) return finish({ "success": False, "message": str(route_result.get('message') or '转存失败'), "data": self._assistant_response_data(session=session, data=route_result.get("data") or {}), }) if self._is_115_url(share_url): self._clear_pending_p115_share(cache_key) provider = ((route_result.get("data") or {}).get("provider") or "").lower() result_payload = (route_result.get("data") or {}).get("result") or {} directory = (result_payload.get("result") or {}).get("target_path") or (result_payload.get("result") or {}).get("path") or final_path text_message = "\n".join([ "盘搜结果已执行转存", f"资源:{selected.get('note') or '未命名资源'}", f"类型:{provider or selected.get('channel') or '-'}", f"目录:{directory or '-'}", ]) return finish({ "success": True, "message": text_message, "data": self._assistant_response_data(session=session, data={"action": "share_route", "ok": True}), }) if kind == "assistant_mp_recommend": items = state.get("items") or [] selected_index = self._safe_int(state.get("selected_index"), 0) pick_action = self._normalize_pick_action(body.get("action")) next_mode = self._clean_text( body.get("mode") or body.get("search_mode") or body.get("target") or "" ).lower() if index <= 0: index = selected_index if index <= 0 and ( pick_action in {"detail", "best", "plan", "best_execute"} or next_mode ): index = 1 if index <= 0: return {"success": False, "message": "推荐结果需要先指定编号,例如:选择 1 决策,或先发:详情 1。"} if index > len(items): return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(items)} 之间的数字。"} selected = dict(items[index - 1]) title = self._clean_text(selected.get("title")) if not title: return {"success": False, "message": "选中的推荐条目缺少标题,无法继续搜索。"} if pick_action == "detail": self._save_session(cache_key, { **state, "selected_index": index, "selected_item": selected, }) return finish({ "success": True, "message": self._format_mp_recommend_item_detail_text(selected), "data": self._assistant_response_data(session=session, data={ "action": "mp_recommendation_detail", "ok": True, "choice": index, "selected_index": index, "item": selected, "detail_short_command": "详情", "decision_short_command": "决策", "plan_short_command": "计划", "confirm_short_command": "确认", }), }) mode_aliases = { "原生": "mp", "mp搜索": "mp", "影巢": "hdhive", "yc": "hdhive", "盘搜": "pansou", "ps": "pansou", "计划": "smart_plan", "smart_plan": "smart_plan", "确认": "smart_execute", "执行": "smart_execute", "直接执行": "smart_execute", "smart_execute": "smart_execute", "决策": "smart_decision", "资源决策": "smart_decision", "智能决策": "smart_decision", "smart": "smart_decision", "smart_decision": "smart_decision", } if not next_mode: if pick_action == "plan": next_mode = "smart_plan" elif pick_action == "best_execute": next_mode = "smart_execute" elif pick_action in {"best", "detail"}: next_mode = "smart_decision" next_mode = mode_aliases.get(next_mode, next_mode) if not next_mode: next_mode = "mp" if next_mode not in {"mp", "hdhive", "pansou", "smart_decision", "smart_plan", "smart_execute"}: return {"success": False, "message": "推荐选择只支持 mode=smart_decision、mode=smart_plan、mode=smart_execute、mode=mp、mode=hdhive 或 mode=pansou。"} selected_media_type = self._clean_text(selected.get("type") or state.get("media_type") or "auto").lower() media_type_aliases = { "电影": "movie", "movie": "movie", "movies": "movie", "电视剧": "tv", "剧集": "tv", "番剧": "tv", "tv": "tv", "series": "tv", "all": "auto", "全部": "auto", } selected_media_type = media_type_aliases.get(selected_media_type, "auto") next_keyword = title if next_mode == "smart_decision" and pick_action in {"best", "detail"}: next_keyword = f"{title} 详情" routed_body = { "session": session, "session_id": cache_key, "mode": next_mode, "keyword": next_keyword, "media_type": selected_media_type, "path": target_path, "apikey": self._extract_apikey(request, body), } if next_mode in {"smart_decision", "smart_plan", "smart_execute"}: routed_body["origin"] = "mp_recommend" else: routed_body["recommend_handoff"] = self._assistant_recommend_handoff_state( source=self._clean_text(state.get("source")), requested_source=self._clean_text(state.get("requested_source") or state.get("source")), media_type=selected_media_type, selected_index=index, selected_item=selected, ) return finish(await self.api_assistant_route( _JsonRequestShim(request, routed_body) )) if kind == "assistant_smart_search": if action in { "decision_continue", "decision_hdhive", "decision_pansou", "decision_mp_pt", "decision_conservative", "decision_aggressive", "decision_only_quark", "decision_only_115", "decision_cloud_both", "decision_only_mp_pt", "decision_only_pansou", "decision_only_hdhive", "decision_disable_pansou", "decision_disable_hdhive", "decision_disable_mp_pt", "decision_reset_preferences", }: return finish(await self._assistant_smart_resource_decision_adjust( request, session=session, cache_key=cache_key, state=state, adjust_action=action, )) if action == "best_execute": return finish(await self._assistant_smart_best_execute_response( request, session=session, cache_key=cache_key, state=state, target_path=target_path, )) if action == "best_plan": return finish(self._assistant_smart_best_plan_response( session=session, cache_key=cache_key, state=state, target_path=target_path, )) if action == "best": source_states = state.get("source_states") if isinstance(state.get("source_states"), dict) else {} active_source = self._clean_text(state.get("active_source")).lower() delegate_state = source_states.get(active_source) if active_source else None if not isinstance(delegate_state, dict) or not delegate_state: delegate_state = state.get("active_state") if isinstance(state.get("active_state"), dict) else {} if not isinstance(delegate_state, dict) or not delegate_state: return {"success": False, "message": "智能搜索会话缺少可继续状态,请重新发送:智能搜索 片名"} self._save_session(cache_key, delegate_state) try: result = await self.api_assistant_pick( _JsonRequestShim(request, { "session": session, "session_id": cache_key, "choice": 0, "action": "best", "path": target_path, "compact": compact, "apikey": self._extract_apikey(request, body), }) ) finally: self._save_session(cache_key, state) return finish(result) source_states = state.get("source_states") if isinstance(state.get("source_states"), dict) else {} requested_mode = self._clean_text(body.get("mode") or body.get("search_mode")).lower() mode_aliases = { "盘搜": "pansou", "ps": "pansou", "影巢": "hdhive", "yc": "hdhive", "原生": "mp_pt", "mp": "mp_pt", "pt": "mp_pt", } requested_mode = mode_aliases.get(requested_mode, requested_mode) active_source = requested_mode or self._clean_text(state.get("active_source")).lower() delegate_state = source_states.get(active_source) if active_source else None if not isinstance(delegate_state, dict) or not delegate_state: delegate_state = state.get("active_state") if isinstance(state.get("active_state"), dict) else {} if not isinstance(delegate_state, dict) or not delegate_state: return {"success": False, "message": "智能搜索会话缺少可继续状态,请重新发送:智能搜索 片名"} self._save_session(cache_key, delegate_state) return finish(await self.api_assistant_pick( _JsonRequestShim(request, { "session": session, "session_id": cache_key, "choice": index, "action": action, "path": target_path, "compact": compact, "apikey": self._extract_apikey(request, body), }) )) if kind == "assistant_mp_candidate": candidates = state.get("candidates") or [] page_size = max(1, self._safe_int(state.get("page_size"), self._hdhive_candidate_page_size)) current_page = max(1, self._safe_int(state.get("page"), 1)) if action == "detail": start = (current_page - 1) * page_size end = start + page_size enriched = [dict(item or {}) for item in candidates] enriched[start:end] = self._enrich_hdhive_candidates_with_actors(enriched[start:end]) self._save_session(cache_key, {**state, "candidates": enriched}) return finish({ "success": True, "message": self._format_mp_candidate_lines(enriched, page=current_page, page_size=page_size), "data": self._assistant_response_data(session=session, data={ "action": "mp_candidates_detail", "ok": True, "page": current_page, "candidates": enriched, }), }) if action == "next_page": total_pages = max(1, (len(candidates) + page_size - 1) // page_size) if current_page >= total_pages: return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} next_page = current_page + 1 self._save_session(cache_key, {**state, "page": next_page}) return finish({ "success": True, "message": self._format_mp_candidate_lines(candidates, page=next_page, page_size=page_size), "data": self._assistant_response_data(session=session, data={ "action": "mp_candidates_next_page", "ok": True, "page": next_page, "total_pages": total_pages, }), }) if action in {"best", "best_execute", "best_plan", "plan"}: return {"success": False, "message": "MP 候选阶段还没有 PT 资源评分,请先回复编号选定电影/剧集。"} if action: return {"success": False, "message": "MP 候选阶段只支持:选择 编号、详情/审查、下一页。"} if index <= 0: return {"success": False, "message": "请选择有效候选编号,例如:选择 1"} if index > len(candidates): return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(candidates)} 之间的数字。"} candidate = dict(candidates[index - 1] or {}) candidate_title = self._clean_text(candidate.get("title")) or self._clean_text(state.get("keyword")) candidate_year = self._clean_text(candidate.get("year")) search_keyword = f"{candidate_title} {candidate_year}".strip() if candidate_year and candidate_year not in candidate_title else candidate_title pending_action = dict(state.get("pending_action") or {}) if isinstance(state.get("pending_action"), dict) else {} pending_mode = self._clean_text(pending_action.get("mode")) if pending_mode == "mp_download_title": result = await self._assistant_smart_resource_execute( request, keyword=search_keyword, session=session, cache_key=cache_key, media_type=self._clean_text(candidate.get("media_type") or state.get("media_type") or "auto").lower() or "auto", year=candidate_year or self._clean_text(state.get("year")), source_order=["mp_pt"], target_path=self._clean_text(state.get("target_path")), origin="mp_download_title", ) if result.get("success"): result["message"] = ( f"已选择:{self._format_candidate_label(candidate)}\n" f"{result.get('message') or ''}" ).strip() result_data = dict(result.get("data") or {}) result_data["selected_candidate"] = candidate result_data["original_keyword"] = self._clean_text(state.get("keyword")) result["data"] = result_data return finish(result) if pending_mode == "cloud_transfer_execute": cloud_provider = self._clean_text(pending_action.get("cloud_provider")).lower() overrides: Dict[str, Any] = {} if cloud_provider == "quark": overrides = {"has_quark": True, "has_115": False, "prefer_cloud_provider": "quark"} elif cloud_provider == "115": overrides = {"has_quark": False, "has_115": True, "prefer_cloud_provider": "115"} result = await self._assistant_cloud_transfer_execute( request, keyword=search_keyword, session=session, cache_key=cache_key, media_type=self._clean_text(candidate.get("media_type") or state.get("media_type") or "auto").lower() or "auto", year=candidate_year or self._clean_text(state.get("year")), source_order=["pansou", "hdhive"], target_path=self._clean_text(state.get("target_path")), session_preference_overrides=overrides, origin=cloud_provider or "cloud_transfer", ) if result.get("success"): result["message"] = ( f"已选择:{self._format_candidate_label(candidate)}\n" f"{result.get('message') or ''}" ).strip() result_data = dict(result.get("data") or {}) result_data["selected_candidate"] = candidate result_data["original_keyword"] = self._clean_text(state.get("keyword")) result["data"] = result_data return finish(result) preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) result = await self._assistant_mp_media_search( keyword=search_keyword, session=session, cache_key=cache_key, preferences=preferences, ) result_data = dict(result.get("data") or {}) result_data["selected_candidate"] = candidate result_data["original_keyword"] = self._clean_text(state.get("keyword")) result["data"] = result_data if result.get("success"): result["message"] = ( f"已选择:{self._format_candidate_label(candidate)}\n" f"{result.get('message') or ''}" ).strip() return finish(result) if kind == "assistant_mp": preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) current_page = max(1, self._safe_int(state.get("page"), 1)) total = self._safe_int(state.get("total"), 0) if action == "next_page": total_pages = max(1, (max(0, total) + page_size - 1) // page_size) if total else 1 if current_page >= total_pages: return {"success": False, "message": "已经是最后一页了,可以直接回复编号下载。"} next_page = current_page + 1 all_items = state.get("all_items") if isinstance(state.get("all_items"), list) else [] if all_items: preview = self._slice_mp_preview_items(all_items, page=next_page, page_size=page_size) else: preview = self._mp_search_cache_preview( cache_key, preferences=preferences, page=next_page, page_size=page_size, ) all_items = self._mp_search_all_preview_items(cache_key, preferences=preferences) updated_state = { **state, "items": preview, "all_items": all_items, "page": next_page, "page_size": page_size, "total": total, } self._save_session(cache_key, updated_state) return finish({ "success": True, "message": self._format_mp_search_text( self._clean_text(state.get("keyword")), f"{self._clean_text(state.get('keyword'))} — MP搜索", preview, total=total, page=next_page, page_size=page_size, result_filter=self._clean_text(state.get("result_filter")), latest_episode=self._safe_int(state.get("latest_episode"), 0), episode_filter=self._safe_int(state.get("episode_filter"), 0), ), "data": self._assistant_response_data(session=session, data={ "action": "mp_media_search_next_page", "ok": True, "keyword": self._clean_text(state.get("keyword")), "items": preview, "page": next_page, "page_size": page_size, "total": total, "total_pages": total_pages, }), }) if action == "best": result = await self._assistant_mp_best_result_detail( session=session, cache_key=cache_key, preferences=preferences, ) if state.get("recommend_handoff"): result_data = dict(result.get("data") or {}) result_data.update(self._assistant_recommend_handoff_short_metadata(state)) result_data["decision_summary"] = self._assistant_recommend_handoff_detail_summary(state) result["data"] = result_data return finish(result) if action == "best_execute": plan_result = await self._assistant_mp_best_download_plan( session=session, cache_key=cache_key, preferences=preferences, ) return finish(await self._assistant_execute_prepared_plan_result( request, session=session, cache_key=cache_key, plan_result=plan_result, message_prefix="已自动选择当前评分最高的 PT 候选并执行计划", extra_data={"source_type": "mp_pt"}, )) if action == "best_plan": result = await self._assistant_mp_best_download_plan( session=session, cache_key=cache_key, preferences=preferences, ) if state.get("recommend_handoff"): result_data = dict(result.get("data") or {}) result_data.update(self._assistant_recommend_handoff_short_metadata(state)) result_data["decision_summary"] = self._assistant_recommend_handoff_plan_summary(state) result["data"] = result_data return finish(result) if action == "plan": action = "" if action == "detail": return {"success": False, "message": "PT 搜索结果不提供详情,请直接回复编号下载。"} if index <= 0: return {"success": False, "message": "请选择有效编号,例如:1"} result = await self._assistant_mp_download( choice=index, session=session, cache_key=cache_key, preferences=preferences, ) if state.get("recommend_handoff"): result_data = dict(result.get("data") or {}) result_data.update(self._assistant_recommend_handoff_short_metadata(state)) result_data["decision_summary"] = self._assistant_recommend_handoff_detail_summary(state) result["data"] = result_data return finish(result) if kind == "assistant_hdhive": allowed, disabled = self._ensure_hdhive_resource_enabled() if not allowed: return finish({ "success": False, "message": disabled.get("message") or "影巢资源入口已关闭", "data": self._assistant_response_data(session=session, data={ "action": "hdhive_pick", "ok": False, "error_code": "hdhive_resource_disabled", "resource_enabled": False, }), }) stage = str(state.get("stage") or "").strip() service = self._ensure_hdhive_service() final_path = target_path or state.get("target_path") or self._hdhive_default_path if stage == "candidate": candidates = state.get("candidates") or [] page_size = max(1, self._safe_int(state.get("page_size"), self._hdhive_candidate_page_size)) current_page = max(1, self._safe_int(state.get("page"), 1)) if action == "detail": start = (current_page - 1) * page_size end = start + page_size enriched = [dict(item or {}) for item in candidates] enriched[start:end] = self._enrich_hdhive_candidates_with_actors(enriched[start:end]) self._save_session(cache_key, {**state, "candidates": enriched, "target_path": final_path}) return finish({ "success": True, "message": self._format_candidate_lines(enriched, page=current_page, page_size=page_size), "data": self._assistant_response_data(session=session, data={ "action": "hdhive_candidates_detail", "ok": True, "page": current_page, "candidates": enriched, }), }) if action == "next_page": total_pages = max(1, (len(candidates) + page_size - 1) // page_size) if current_page >= total_pages: return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} next_page = current_page + 1 self._save_session(cache_key, {**state, "page": next_page, "target_path": final_path}) return finish({ "success": True, "message": self._format_candidate_lines(candidates, page=next_page, page_size=page_size), "data": self._assistant_response_data(session=session, data={ "action": "hdhive_candidates_next_page", "ok": True, "page": next_page, "total_pages": total_pages, }), }) if action == "best": return {"success": False, "message": "影巢候选影片阶段没有评分,不能用“最佳片源”;请先回复编号选择影片,进入资源列表后再用“最佳片源”。"} if action == "best_execute": return {"success": False, "message": "影巢候选影片阶段还不能直接执行最佳;请先进入资源列表,或直接发送:智能执行 片名。"} if action == "best_plan": return {"success": False, "message": "影巢候选影片阶段还不能直接生成最佳计划;请先进入资源列表,或直接发送:智能计划 片名。"} if action == "plan": return {"success": False, "message": "影巢候选影片阶段不能生成资源计划;请先回复编号选择影片,进入资源列表后再发:计划选择 1。"} if action: return {"success": False, "message": "影巢候选阶段只支持:选择 编号、详情/审查、下一页。"} if index <= 0: return {"success": False, "message": "请选择有效影片编号,例如:选择 1"} if index > len(candidates): return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(candidates)} 之间的数字。"} candidate = dict(candidates[index - 1]) resource_ok, resource_result, resource_message = service.search_resources( media_type=candidate.get("media_type") or state.get("media_type") or "movie", tmdb_id=str(candidate.get("tmdb_id") or ""), ) if not resource_ok: return {"success": False, "message": f"影巢资源查询失败:{resource_message}", "data": resource_result} preferences = self._normalize_assistant_preferences((self._assistant_preferences or {}).get(self._normalize_preference_key(session=session))) preview = self._attach_cloud_scores( self._group_resource_preview(resource_result.get("data") or [], per_group=None), preferences=preferences, source_type="hdhive", target_path=final_path, ) self._save_session( cache_key, { **state, "stage": "resource", "selected_candidate": candidate, "resources": preview, "page": 1, "page_size": self._assistant_result_page_size, "target_path": final_path, }, ) return finish({ "success": True, "message": self._format_resource_lines(preview, candidate, page=1, page_size=self._assistant_result_page_size, total_resources=len(preview)), "data": self._assistant_response_data(session=session, data={ "action": "hdhive_search", "ok": True, "selected_candidate": candidate, "resources": preview, "page": 1, "page_size": self._assistant_result_page_size, "total_pages": max(1, (len(preview) + self._assistant_result_page_size - 1) // self._assistant_result_page_size) if preview else 1, "score_summary": self._score_summary(preview, limit=5), "decision_summary": self._assistant_hdhive_resource_entry_summary(preview), }), }) resources = state.get("resources") or [] page_size = max(1, self._safe_int(state.get("page_size"), self._assistant_result_page_size)) current_page = max(1, self._safe_int(state.get("page"), 1)) if action == "next_page": total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 if current_page >= total_pages: return finish({ "success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。", "data": self._assistant_response_data(session=session, data={ "action": "hdhive_resources_next_page", "ok": False, "error_code": "already_last_page", "page": current_page, "page_size": page_size, "total_pages": total_pages, }), }) next_page = current_page + 1 updated_state = {**state, "page": next_page, "page_size": page_size} self._save_session(cache_key, updated_state) return finish({ "success": True, "message": self._format_resource_lines(resources, dict(state.get("selected_candidate") or {}), page=next_page, page_size=page_size, total_resources=len(resources)), "data": self._assistant_response_data(session=session, data={ "action": "hdhive_resources_next_page", "ok": True, "selected_candidate": dict(state.get("selected_candidate") or {}), "resources": resources, "page": next_page, "page_size": page_size, "total_pages": total_pages, "score_summary": self._score_summary(resources, limit=5), "decision_summary": self._assistant_hdhive_resource_entry_summary(resources), }), }) if action == "best": best = self._best_scored_source_item(resources) if not best: return finish({ "success": False, "message": "当前影巢资源没有可评分条目,请直接回复编号选择。", "data": self._assistant_response_data(session=session, data={ "action": "hdhive_best_resource_detail", "ok": False, "error_code": "best_item_not_found", }), }) return finish({ "success": True, "message": self._format_cloud_item_detail_text(best, title="影巢当前最佳资源"), "data": self._assistant_response_data(session=session, data={ "action": "hdhive_best_resource_detail", "ok": True, "item": best, "score_summary": self._score_summary([best], limit=1), }), }) if action == "best_execute": best = self._best_scored_source_item(resources) if not best: return finish({ "success": False, "message": "当前影巢资源没有可评分条目,请先查看列表后再执行最佳。", "data": self._assistant_response_data(session=session, data={ "action": "hdhive_best_resource_execute", "ok": False, "error_code": "best_item_not_found", }), }) index = self._safe_int(best.get("index"), 0) slug = self._clean_text(best.get("slug")) if not slug: return {"success": False, "message": "当前影巢首选缺少 slug,无法执行最佳。"} plan_result = self._save_assistant_pick_plan_response( workflow="hdhive_best_execute", session=session, session_id=cache_key, actions=[{ "name": "unlock_hdhive_resource", "session": session, "session_id": cache_key, "slug": slug, "path": final_path, "resource": best, }], execute_body={ "workflow": "hdhive_best_execute", "session": session, "session_id": cache_key, "choice": index, "path": final_path, }, message="影巢最佳资源计划已生成", score_items=[best], extra_data={ "choice": index, "target_path": final_path, "selected_resource": best, }, ) return finish(await self._assistant_execute_prepared_plan_result( request, session=session, cache_key=cache_key, plan_result=plan_result, message_prefix=f"已自动选择影巢最佳资源并执行:#{index}", extra_data={"choice": index, "source_type": "hdhive"}, )) if action == "best_plan": best = self._best_scored_source_item(resources) if not best: return finish({ "success": False, "message": "当前影巢资源没有可评分条目,请先查看列表后再生成计划。", "data": self._assistant_response_data(session=session, data={ "action": "hdhive_best_resource_plan", "ok": False, "error_code": "best_item_not_found", }), }) index = self._safe_int(best.get("index"), 0) slug = self._clean_text(best.get("slug")) if not slug: return {"success": False, "message": "当前影巢首选缺少 slug,无法生成计划。"} return finish(self._save_assistant_pick_plan_response( workflow="hdhive_best_plan", session=session, session_id=cache_key, actions=[{ "name": "unlock_hdhive_resource", "session": session, "session_id": cache_key, "slug": slug, "path": final_path, "resource": best, }], execute_body={ "workflow": "hdhive_best_plan", "session": session, "session_id": cache_key, "choice": index, "path": final_path, }, message="影巢最佳资源计划已生成", score_items=[best], extra_data={ "choice": index, "target_path": final_path, "selected_resource": best, }, )) if action == "detail": if index <= 0: return {"success": False, "message": "影巢资源详情需要编号,例如:选择 1 详情。"} if index > len(resources): return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(resources)} 之间的数字。"} resource = dict(resources[index - 1]) return finish({ "success": True, "message": self._format_cloud_item_detail_text(resource, title="影巢资源详情"), "data": self._assistant_response_data(session=session, data={ "action": "hdhive_resource_detail", "ok": True, "choice": index, "item": resource, "score_summary": self._score_summary([resource], limit=1), }), }) if action == "plan": if index <= 0: return {"success": False, "message": "影巢资源计划需要编号,例如:计划选择 1。"} if index > len(resources): return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(resources)} 之间的数字。"} resource = dict(resources[index - 1]) slug = self._clean_text(resource.get("slug")) if not slug: return {"success": False, "message": "选中的影巢资源缺少 slug,无法生成计划。"} actions = [{ "name": "unlock_hdhive_resource", "session": session, "session_id": cache_key, "slug": slug, "path": final_path, "resource": resource, }] return finish(self._save_assistant_pick_plan_response( workflow="hdhive_unlock_selected", session=session, session_id=cache_key, actions=actions, execute_body={ "workflow": "hdhive_unlock_selected", "session": session, "session_id": cache_key, "choice": index, "path": final_path, }, message="影巢解锁/转存计划已生成", score_items=[resource], extra_data={ "choice": index, "target_path": final_path, "selected_resource": resource, }, )) if action: return {"success": False, "message": "影巢资源阶段只支持:直接回编号、计划选择 编号、选择 编号 详情;也支持短命令“详情 3”“计划 3”。"} if index <= 0: return {"success": False, "message": "请选择有效资源编号,例如:选择 1"} if index > len(resources): return {"success": False, "message": f"序号超出范围,请输入 1 到 {len(resources)} 之间的数字。"} resource = dict(resources[index - 1]) route_ok, route_result, route_message = await self._unlock_and_route( self._clean_text(resource.get("slug")), target_path=final_path, resource=resource, ) if not route_ok: route = dict((route_result or {}).get("route") or {}) share_url = self._clean_text(route.get("share_url")) if self._is_115_url(share_url) or self._clean_text(route.get("provider")) == "115": self._save_pending_p115_share( cache_key, share_url=share_url, access_code=route.get("access_code") or "", target_path=route.get("target_path") or final_path, source="assistant_hdhive_unlock", title=resource.get("title") or resource.get("matched_title") or "", last_error=route_message, ) return finish({ "success": False, "message": f"{route_message}\n{self._format_p115_resume_hint(resource.get('title') or resource.get('matched_title') or '')}", "data": self._assistant_response_data(session=session, data=route_result), }) return finish({ "success": False, "message": route_message, "data": self._assistant_response_data(session=session, data=route_result), }) return finish({ "success": True, "message": self._format_route_result(route_result), "data": self._assistant_response_data(session=session, data={ "action": "hdhive_unlock", "ok": True, "selected_resource": resource, "result": route_result, }), }) return {"success": False, "message": f"当前会话阶段不支持继续选择:{kind or 'unknown'}"} async def api_assistant_capabilities(self, request: Request): ok, message = self._check_api_access(request) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} compact = bool(self._parse_optional_bool(request.query_params.get("compact")) or False) data = self._assistant_capabilities_public_data() return { "success": True, "message": self._format_assistant_capabilities_text(), "data": self._assistant_capabilities_compact_data(data) if compact else self._assistant_response_data( session="default", data=data, ), } async def api_assistant_readiness(self, request: Request): ok, message = self._check_api_access(request) if not ok: return {"success": False, "message": message} compact = bool(self._parse_optional_bool(request.query_params.get("compact")) or False) data = self._assistant_readiness_public_data() response_data = { "action": "readiness", "ok": bool(data.get("can_start")), **data, } return { "success": bool(data.get("can_start")), "message": self._format_assistant_readiness_text(), "data": self._assistant_readiness_compact_data(response_data) if compact else self._assistant_response_data( session="default", data=response_data, ), } async def api_assistant_pulse(self, request: Request): ok, message = self._check_api_access(request) if not ok: return {"success": False, "message": message} data = self._assistant_pulse_public_data() return { "success": bool(data.get("can_start")), "message": self._format_assistant_pulse_text(), "data": data, } async def api_assistant_startup(self, request: Request): ok, message = self._check_api_access(request) if not ok: return {"success": False, "message": message} data = self._assistant_startup_public_data() return { "success": bool(data.get("ok")), "message": self._format_assistant_startup_text(), "data": data, } async def api_assistant_maintain(self, request: Request): body: Dict[str, Any] = {} if request.method.upper() != "GET": try: body = await request.json() except Exception: body = {} ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} requested_execute = self._parse_bool_value( body.get("execute") if "execute" in body else request.query_params.get("execute"), False, ) execute = requested_execute and request.method.upper() != "GET" limit = self._safe_int(body.get("limit") or request.query_params.get("limit"), 100) data = self._assistant_maintain_public_data(execute=execute, limit=limit) if requested_execute and request.method.upper() == "GET": data["execute_ignored"] = True data["warning"] = "GET 请求只返回 dry-run 维护建议;如需执行维护请使用 POST execute=true。" if execute: executed_actions = data.get("executed_actions") or [] self._record_assistant_execution( action="maintain", session=self._clean_text(body.get("session")) or "default", session_id=self._clean_text(body.get("session_id")), success=bool(data.get("ok")), message=self._format_assistant_maintain_text(data), summary={ "executed": bool(data.get("executed")), "executed_actions": [ { "name": self._clean_text(item.get("name")), "removed": self._safe_int(item.get("removed"), 0), } for item in executed_actions if isinstance(item, dict) ], "after": { "stale_sessions": (data.get("after") or {}).get("stale_sessions"), "saved_plans_executed": (data.get("after") or {}).get("saved_plans_executed"), "saved_plans_pending": (data.get("after") or {}).get("saved_plans_pending"), }, }, ) return { "success": bool(data.get("ok")), "message": self._format_assistant_maintain_text(data), "data": data, } async def api_assistant_toolbox(self, request: Request): ok, message = self._check_api_access(request) if not ok: return {"success": False, "message": message} data = self._assistant_toolbox_public_data() return { "success": True, "message": self._format_assistant_toolbox_text(), "data": data, } async def api_assistant_request_templates(self, request: Request): body: Dict[str, Any] = {} if request.method.upper() != "GET": try: body = await request.json() except Exception: body = {} ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} limit = self._safe_int(body.get("limit") or request.query_params.get("limit"), 100) names = ( body.get("names") or body.get("name") or body.get("template") or request.query_params.get("names") or request.query_params.get("name") or request.query_params.get("template") ) recipe = ( body.get("recipe") or body.get("recommended_recipe") or request.query_params.get("recipe") or request.query_params.get("recommended_recipe") ) include_templates = self._parse_bool_value( body.get("include_templates") if "include_templates" in body else request.query_params.get("include_templates"), True, ) data = self._assistant_request_templates_response_data( limit=limit, names=names, recipe=recipe, include_templates=include_templates, ) return { "success": True, "message": self._format_assistant_request_templates_text(data), "data": data, } async def api_assistant_selfcheck(self, request: Request): ok, message = self._check_api_access(request) if not ok: return {"success": False, "message": message} data = self._assistant_selfcheck_public_data() return { "success": bool(data.get("ok")), "message": self._format_assistant_selfcheck_text(), "data": data, } async def api_assistant_history(self, request: Request): ok, message = self._check_api_access(request) if not ok: return {"success": False, "message": message} session = self._clean_text(request.query_params.get("session")) session_id = self._clean_text(request.query_params.get("session_id")) compact = bool(self._parse_optional_bool(request.query_params.get("compact")) or False) limit = self._safe_int(request.query_params.get("limit"), 20) data = self._assistant_history_public_data(session=session, session_id=session_id, limit=limit) response_data = self._assistant_history_compact_data(data) if compact else self._assistant_response_data( session=session or "default", data={ "action": "history", "ok": True, **data, }, ) return { "success": True, "message": self._format_assistant_history_text(session=session, session_id=session_id, limit=limit), "data": response_data, } async def api_assistant_plans(self, request: Request): ok, message = self._check_api_access(request) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} session = self._clean_text(request.query_params.get("session")) session_id = self._clean_text(request.query_params.get("session_id")) executed = self._parse_optional_bool(request.query_params.get("executed")) include_actions = bool(self._parse_optional_bool(request.query_params.get("include_actions")) or False) compact = bool(self._parse_optional_bool(request.query_params.get("compact")) or False) limit = self._safe_int(request.query_params.get("limit"), 20) data = self._assistant_plans_public_data( session=session, session_id=session_id, executed=executed, include_actions=include_actions, limit=limit, ) response_data = self._assistant_plans_compact_data(data) if compact else self._assistant_response_data( session=session or "default", data={ "action": "plans", "ok": True, **data, }, ) return { "success": True, "message": self._format_assistant_plans_text( session=session, session_id=session_id, executed=executed, include_actions=include_actions, limit=limit, ), "data": response_data, } async def api_assistant_plans_clear(self, request: Request): body = await request.json() ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} result = self._clear_workflow_plans( plan_id=body.get("plan_id"), session=body.get("session"), session_id=body.get("session_id"), executed=self._parse_optional_bool(body.get("executed")), all_plans=self._parse_bool_value(body.get("all_plans"), False), limit=self._safe_int(body.get("limit"), 100), ) if not result.get("ok"): return { "success": False, "message": str(result.get("message") or "计划清理参数无效"), "data": result, } return { "success": True, "message": str(result.get("message") or "计划清理完成"), "data": self._assistant_response_data(session=body.get("session") or "default", data={ "action": "plans_clear", "ok": True, **result, }), } async def api_assistant_recover(self, request: Request): body: Dict[str, Any] = {} if request.method.upper() != "GET": try: body = await request.json() except Exception: body = {} ok, message = self._check_api_access(request, body or None) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} session = self._clean_text(body.get("session") or request.query_params.get("session")) session_id = self._clean_text(body.get("session_id") or request.query_params.get("session_id")) execute = self._parse_bool_value( body.get("execute") if "execute" in body else request.query_params.get("execute"), False, ) prefer_unexecuted = self._parse_bool_value( body.get("prefer_unexecuted") if "prefer_unexecuted" in body else None, True, ) stop_on_error = self._parse_bool_value( body.get("stop_on_error") if "stop_on_error" in body else None, True, ) include_raw_results = self._parse_bool_value( body.get("include_raw_results") if "include_raw_results" in body else None, False, ) compact = bool( self._parse_optional_bool(body.get("compact")) if "compact" in body else self._parse_optional_bool(request.query_params.get("compact")) ) limit = self._safe_int(body.get("limit") or request.query_params.get("limit"), 20) data = self._assistant_recover_public_data( session=session, session_id=session_id, limit=limit, ) data.update({ "action": "recover", "ok": True, "execute_requested": execute, "executed": False, }) if not execute: return { "success": True, "message": self._format_assistant_recover_text(data), "data": self._assistant_recover_response_data(data, compact=compact), } recovery = dict(data.get("recovery") or {}) if not recovery.get("can_resume"): data["ok"] = False data["execute_error"] = recovery.get("reason") or "当前没有可直接恢复的动作" return { "success": False, "message": str(data["execute_error"]), "data": self._assistant_recover_response_data(data, compact=compact), } template = dict(recovery.get("action_template") or {}) action_body = dict(template.get("action_body") or {}) if not action_body and template.get("name"): action_body = {"name": template.get("name"), **dict(template.get("body") or {})} if not self._clean_text(action_body.get("name")): data["ok"] = False data["execute_error"] = "恢复模板缺少可执行动作名" return { "success": False, "message": data["execute_error"], "data": self._assistant_recover_response_data(data, compact=compact), } action_body.setdefault("session", data.get("session") or "default") if data.get("session_id"): action_body.setdefault("session_id", data.get("session_id")) action_body.setdefault("prefer_unexecuted", prefer_unexecuted) action_body.setdefault("stop_on_error", stop_on_error) action_body.setdefault("include_raw_results", include_raw_results) action_body["apikey"] = self._extract_apikey(request, body) result = await self.api_assistant_action(_JsonRequestShim(request, action_body)) result_data = dict(result.get("data") or {}) data.update({ "ok": bool(result.get("success")), "executed": True, "execute_success": bool(result.get("success")), "execute_action": action_body.get("name"), "execute_message": result.get("message") or "", "execute_result": result if include_raw_results else { "success": bool(result.get("success")), "message": result.get("message") or "", "data": { "action": result_data.get("action"), "ok": result_data.get("ok"), "session": result_data.get("session"), "session_id": result_data.get("session_id"), "plan_id": result_data.get("plan_id"), "workflow": result_data.get("workflow"), }, }, }) return { "success": bool(result.get("success")), "message": f"恢复动作 {action_body.get('name')} 执行完成\n{result.get('message') or ''}".strip(), "data": self._assistant_recover_response_data(data, compact=compact), } async def api_assistant_session_state(self, request: Request): body: Dict[str, Any] = {} if request.method.upper() != "GET": try: body = await request.json() except Exception: body = {} ok, message = self._check_api_access(request, body or None) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} session, _ = self._normalize_assistant_session_ref( session=( (body or {}).get("session") or request.query_params.get("session") or request.query_params.get("chat_id") or request.query_params.get("user_id") or "default" ), session_id=(body or {}).get("session_id") or request.query_params.get("session_id"), ) compact = bool( self._parse_optional_bool((body or {}).get("compact")) if "compact" in (body or {}) else self._parse_optional_bool(request.query_params.get("compact")) ) summary = self._format_assistant_session_summary(session=session) session_state = self._assistant_session_public_data(session=session) if compact: return { "success": True, "message": summary, "data": self._assistant_session_compact_data(session_state), } data = self._assistant_response_data(session=session, data={ "action": "session_state", "ok": True, "session_snapshot": session_state, **session_state, }) return {"success": True, "message": summary, "data": data} async def api_assistant_session_clear(self, request: Request): body = await request.json() ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} session, cache_key = self._normalize_assistant_session_ref( session=( body.get("session") or body.get("chat_id") or body.get("user_id") or body.get("conversation_id") or "default" ), session_id=body.get("session_id"), ) existing = self._load_session(cache_key) if not existing: return { "success": True, "message": "当前没有需要清理的会话。", "data": self._assistant_response_data(session=session, data={"cleared": False}), } self._session_cache.pop(cache_key, None) self._persist_relevant_sessions() return { "success": True, "message": f"已清理会话:{session}", "data": self._assistant_response_data(session=session, data={"cleared": True}), } async def api_assistant_sessions(self, request: Request): ok, message = self._check_api_access(request) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} kind = self._clean_text(request.query_params.get("kind")) has_pending_p115_raw = request.query_params.get("has_pending_p115") has_pending_p115: Optional[bool] = None if has_pending_p115_raw is not None: has_pending_p115 = str(has_pending_p115_raw).strip().lower() in {"1", "true", "yes", "y"} compact = bool(self._parse_optional_bool(request.query_params.get("compact"))) limit = self._safe_int(request.query_params.get("limit"), 20) data = self._assistant_sessions_public_data( kind=kind, has_pending_p115=has_pending_p115, limit=limit, ) if compact: return { "success": True, "message": self._format_assistant_sessions_text( kind=kind, has_pending_p115=has_pending_p115, limit=limit, ), "data": self._assistant_sessions_compact_data(data), } return { "success": True, "message": self._format_assistant_sessions_text( kind=kind, has_pending_p115=has_pending_p115, limit=limit, ), "data": self._assistant_response_data(session="default", data={ "action": "sessions", "ok": True, **data, }), } async def api_assistant_sessions_clear(self, request: Request): body = await request.json() ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} result = self._clear_assistant_sessions( session=body.get("session"), session_id=body.get("session_id"), kind=body.get("kind"), has_pending_p115=body.get("has_pending_p115"), stale_only=self._parse_bool_value(body.get("stale_only"), False), all_sessions=self._parse_bool_value(body.get("all_sessions"), False), limit=self._safe_int(body.get("limit"), 100), ) cleared_count = self._safe_int(result.get("cleared_count"), 0) if cleared_count <= 0: return { "success": True, "message": "没有匹配到需要清理的 assistant 会话。", "data": result, } return { "success": True, "message": f"已清理 {cleared_count} 个 assistant 会话。", "data": result, } async def api_session_hdhive_search(self, request: Request): body = await request.json() ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} allowed, disabled = self._ensure_hdhive_resource_enabled() if not allowed: return disabled keyword = self._clean_text(body.get("keyword") or body.get("title")) media_type = self._clean_text(body.get("media_type") or body.get("type") or "auto").lower() year = self._clean_text(body.get("year")) target_path = self._clean_text(body.get("path") or body.get("target_path")) service = self._ensure_hdhive_service() search_ok, result, search_message = await service.resolve_candidates_by_keyword( keyword=keyword, media_type=media_type, year=year, candidate_limit=max(30, self._hdhive_candidate_page_size), ) if not search_ok: return {"success": False, "message": search_message, "data": result} session_id = self._new_session_id("hdhive") self._save_session( session_id, { "kind": "hdhive", "stage": "candidate", "keyword": keyword, "media_type": media_type, "year": year, "target_path": target_path, "candidates": result.get("candidates") or [], "page": 1, "page_size": self._hdhive_candidate_page_size, }, ) return { "success": True, "message": "success", "data": { "text": self._format_candidate_lines(result.get("candidates") or [], page=1, page_size=self._hdhive_candidate_page_size), "session_id": session_id, "stage": "candidate", "keyword": keyword, "candidates": result.get("candidates") or [], "candidate_count": len(result.get("candidates") or []), "meta": result.get("meta") or {}, }, } async def api_session_hdhive_pick(self, request: Request): body = await request.json() ok, message = self._check_api_access(request, body) if not ok: return {"success": False, "message": message} if not self._enabled: return {"success": False, "message": "插件未启用"} allowed, disabled = self._ensure_hdhive_resource_enabled() if not allowed: return disabled session_id = self._clean_text(body.get("session_id")) index = self._safe_int( body.get("index") or body.get("choice") or body.get("selection") or body.get("number"), 0, ) action = self._normalize_pick_action(body.get("action") or body.get("pick_action")) target_path = self._clean_text(body.get("path") or body.get("target_path")) if not session_id or (index <= 0 and not action): return {"success": False, "message": "session_id 和选择编号必填;详情/翻页动作可传 action"} session = self._load_session(session_id) if not session: return {"success": False, "message": "会话不存在或已过期"} stage = session.get("stage") service = self._ensure_hdhive_service() if stage == "candidate": candidates = session.get("candidates") or [] page_size = max(1, self._safe_int(session.get("page_size"), self._hdhive_candidate_page_size)) current_page = max(1, self._safe_int(session.get("page"), 1)) if action == "detail": start = (current_page - 1) * page_size end = start + page_size enriched = [dict(item or {}) for item in candidates] enriched[start:end] = self._enrich_hdhive_candidates_with_actors(enriched[start:end]) self._save_session(session_id, {**session, "candidates": enriched, "target_path": target_path or session.get("target_path") or ""}) return { "success": True, "message": "success", "data": { "text": self._format_candidate_lines(enriched, page=current_page, page_size=page_size), "session_id": session_id, "stage": "candidate", "page": current_page, "candidates": enriched, }, } if action == "next_page": total_pages = max(1, (len(candidates) + page_size - 1) // page_size) if current_page >= total_pages: return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} next_page = current_page + 1 self._save_session(session_id, {**session, "page": next_page, "target_path": target_path or session.get("target_path") or ""}) return { "success": True, "message": "success", "data": { "text": self._format_candidate_lines(candidates, page=next_page, page_size=page_size), "session_id": session_id, "stage": "candidate", "page": next_page, "total_pages": total_pages, }, } if index > len(candidates): return {"success": False, "message": "候选编号超出范围"} candidate = dict(candidates[index - 1]) resource_ok, resource_result, resource_message = service.search_resources( media_type=candidate.get("media_type") or session.get("media_type") or "movie", tmdb_id=str(candidate.get("tmdb_id") or ""), ) if not resource_ok: return {"success": False, "message": resource_message, "data": resource_result} preview = self._group_resource_preview(resource_result.get("data") or [], per_group=None) self._save_session( session_id, { **session, "stage": "resource", "selected_candidate": candidate, "resources": preview, "page": 1, "page_size": self._assistant_result_page_size, "target_path": target_path or session.get("target_path") or "", }, ) return { "success": True, "message": "success", "data": { "text": self._format_resource_lines(preview, candidate, page=1, page_size=self._assistant_result_page_size, total_resources=len(preview)), "session_id": session_id, "stage": "resource", "selected_candidate": candidate, "resources": preview, "page": 1, "page_size": self._assistant_result_page_size, "meta": { "total": len(preview), "count_115": len([x for x in preview if str(x.get("pan_type") or "").lower() == "115"]), "count_quark": len([x for x in preview if str(x.get("pan_type") or "").lower() == "quark"]), }, }, } if stage == "resource": resources = session.get("resources") or [] page_size = max(1, self._safe_int(session.get("page_size"), self._assistant_result_page_size)) current_page = max(1, self._safe_int(session.get("page"), 1)) if action == "next_page": total_pages = max(1, (len(resources) + page_size - 1) // page_size) if resources else 1 if current_page >= total_pages: return {"success": False, "message": "已经是最后一页了,可以直接回复编号继续选择。"} next_page = current_page + 1 self._save_session(session_id, {**session, "page": next_page, "page_size": page_size, "target_path": target_path or session.get("target_path") or ""}) return { "success": True, "message": "success", "data": { "text": self._format_resource_lines(resources, dict(session.get("selected_candidate") or {}), page=next_page, page_size=page_size, total_resources=len(resources)), "session_id": session_id, "stage": "resource", "page": next_page, "total_pages": total_pages, }, } if index > len(resources): return {"success": False, "message": "资源编号超出范围"} resource = dict(resources[index - 1]) slug = self._clean_text(resource.get("slug")) route_ok, route_result, route_message = await self._unlock_and_route( slug, target_path=target_path or session.get("target_path") or "", resource=resource, ) if not route_ok: return {"success": False, "message": route_message, "data": route_result} return { "success": True, "message": route_message, "data": { "text": self._format_route_result(route_result), "session_id": session_id, "selected_resource": resource, "result": route_result, }, } return {"success": False, "message": f"当前会话阶段不支持继续选择: {stage}"}