mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-06 16:07:01 +08:00
feat: add batch AI re-organize for transfer history and search result recommendation
- Implement batch AI re-organize endpoint for transfer history with progress tracking - Add batch_manual_transfer_redo system task template and prompt generation - Refactor agent_manager to support generic background prompt execution - Add AIRecommendChain for search result recommendation using agent background prompt - Update search endpoints to use new AIRecommendChain and remove legacy code - Enhance test cases for batch manual transfer redo - Minor code cleanup and style fixes
This commit is contained in:
+29
-200
@@ -32,7 +32,6 @@ from app.agent.runtime import agent_runtime_manager
|
|||||||
from app.agent.tools.factory import MoviePilotToolFactory
|
from app.agent.tools.factory import MoviePilotToolFactory
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.transferhistory_oper import TransferHistoryOper
|
|
||||||
from app.helper.llm import LLMHelper
|
from app.helper.llm import LLMHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import Notification, NotificationType
|
from app.schemas import Notification, NotificationType
|
||||||
@@ -731,21 +730,12 @@ class AgentManager:
|
|||||||
同一会话的消息按顺序排队处理,不同会话之间互不影响。
|
同一会话的消息按顺序排队处理,不同会话之间互不影响。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 批量重试整理的等待时间(秒),同一批次内的失败记录会合并为一次agent调用
|
|
||||||
RETRY_TRANSFER_DEBOUNCE_SECONDS = 300
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.active_agents: Dict[str, MoviePilotAgent] = {}
|
self.active_agents: Dict[str, MoviePilotAgent] = {}
|
||||||
# 每个会话的消息队列
|
# 每个会话的消息队列
|
||||||
self._session_queues: Dict[str, asyncio.Queue] = {}
|
self._session_queues: Dict[str, asyncio.Queue] = {}
|
||||||
# 每个会话的worker任务
|
# 每个会话的worker任务
|
||||||
self._session_workers: Dict[str, asyncio.Task] = {}
|
self._session_workers: Dict[str, asyncio.Task] = {}
|
||||||
# 重试整理的 debounce 缓冲区: group_key -> List[history_id]
|
|
||||||
self._retry_transfer_buffer: Dict[str, List[int]] = {}
|
|
||||||
# 重试整理的 debounce 定时器: group_key -> asyncio.TimerHandle
|
|
||||||
self._retry_transfer_timers: Dict[str, asyncio.TimerHandle] = {}
|
|
||||||
# 重试整理缓冲区锁
|
|
||||||
self._retry_transfer_lock = asyncio.Lock()
|
|
||||||
|
|
||||||
def get_session_status(self, session_id: str) -> dict[str, Any]:
|
def get_session_status(self, session_id: str) -> dict[str, Any]:
|
||||||
"""获取会话当前模型与 token 使用状态。"""
|
"""获取会话当前模型与 token 使用状态。"""
|
||||||
@@ -790,11 +780,6 @@ class AgentManager:
|
|||||||
关闭管理器
|
关闭管理器
|
||||||
"""
|
"""
|
||||||
await memory_manager.close()
|
await memory_manager.close()
|
||||||
# 取消所有重试整理的延迟定时器
|
|
||||||
for timer in self._retry_transfer_timers.values():
|
|
||||||
timer.cancel()
|
|
||||||
self._retry_transfer_timers.clear()
|
|
||||||
self._retry_transfer_buffer.clear()
|
|
||||||
# 取消所有会话worker
|
# 取消所有会话worker
|
||||||
for task in self._session_workers.values():
|
for task in self._session_workers.values():
|
||||||
task.cancel()
|
task.cancel()
|
||||||
@@ -995,67 +980,40 @@ class AgentManager:
|
|||||||
memory_manager.clear_memory(session_id, user_id)
|
memory_manager.clear_memory(session_id, user_id)
|
||||||
logger.info(f"会话 {session_id} 的记忆已清空")
|
logger.info(f"会话 {session_id} 的记忆已清空")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def run_background_prompt(
|
||||||
|
message: str,
|
||||||
|
session_prefix: str = "__agent_background",
|
||||||
|
output_callback: Optional[Callable[[str], None]] = None,
|
||||||
|
suppress_user_reply: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
以独立后台会话执行一段 prompt。
|
||||||
|
"""
|
||||||
|
session_id = f"{session_prefix}_{uuid.uuid4().hex[:8]}__"
|
||||||
|
user_id = SYSTEM_INTERNAL_USER_ID
|
||||||
|
agent = MoviePilotAgent(
|
||||||
|
session_id=session_id,
|
||||||
|
user_id=user_id,
|
||||||
|
channel=None,
|
||||||
|
source=None,
|
||||||
|
username=settings.SUPERUSER,
|
||||||
|
)
|
||||||
|
agent.output_callback = output_callback
|
||||||
|
agent.force_streaming = bool(output_callback)
|
||||||
|
agent.suppress_user_reply = suppress_user_reply
|
||||||
|
|
||||||
|
try:
|
||||||
|
await agent.process(message)
|
||||||
|
finally:
|
||||||
|
await agent.cleanup()
|
||||||
|
memory_manager.clear_memory(session_id, user_id)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_heartbeat_prompt() -> str:
|
def _build_heartbeat_prompt() -> str:
|
||||||
"""使用程序内置 System Tasks 定义构建心跳任务提示词。"""
|
"""使用程序内置 System Tasks 定义构建心跳任务提示词。"""
|
||||||
return prompt_manager.render_system_task_message("heartbeat")
|
return prompt_manager.render_system_task_message("heartbeat")
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_retry_transfer_template_context(
|
|
||||||
history_ids: list[int],
|
|
||||||
) -> tuple[str, dict[str, int | str]]:
|
|
||||||
"""仅负责把失败重试任务的动态数据映射成模板变量。"""
|
|
||||||
is_batch = len(history_ids) > 1
|
|
||||||
task_type = (
|
|
||||||
"batch_transfer_failed_retry" if is_batch else "transfer_failed_retry"
|
|
||||||
)
|
|
||||||
template_context: dict[str, int | str] = {
|
|
||||||
"history_ids_csv": ", ".join(str(item) for item in history_ids),
|
|
||||||
"history_count": len(history_ids),
|
|
||||||
}
|
|
||||||
if not is_batch:
|
|
||||||
template_context["history_id"] = history_ids[0]
|
|
||||||
return task_type, template_context
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_retry_transfer_prompt(
|
|
||||||
history_ids: list[int],
|
|
||||||
) -> str:
|
|
||||||
"""根据失败记录数量构建统一的重试整理后台任务提示词。"""
|
|
||||||
task_type, template_context = AgentManager._build_retry_transfer_template_context(
|
|
||||||
history_ids
|
|
||||||
)
|
|
||||||
return prompt_manager.render_system_task_message(
|
|
||||||
task_type,
|
|
||||||
template_context=template_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_manual_redo_template_context(history) -> dict[str, int | str]:
|
|
||||||
"""仅负责把整理历史对象映射成 System Tasks 需要的模板变量。"""
|
|
||||||
src_fileitem = history.src_fileitem or {}
|
|
||||||
source_path = src_fileitem.get("path") if isinstance(src_fileitem, dict) else ""
|
|
||||||
source_path = source_path or history.src or ""
|
|
||||||
season_episode = f"{history.seasons or ''}{history.episodes or ''}".strip()
|
|
||||||
# 这里故意只做数据整形,具体行为定义全部交给内置 System Tasks YAML。
|
|
||||||
return {
|
|
||||||
"history_id": history.id,
|
|
||||||
"current_status": "success" if history.status else "failed",
|
|
||||||
"recognized_title": history.title or "unknown",
|
|
||||||
"media_type": history.type or "unknown",
|
|
||||||
"category": history.category or "unknown",
|
|
||||||
"year": history.year or "unknown",
|
|
||||||
"season_episode": season_episode or "unknown",
|
|
||||||
"source_path": source_path or "unknown",
|
|
||||||
"source_storage": history.src_storage or "local",
|
|
||||||
"destination_path": history.dest or "unknown",
|
|
||||||
"destination_storage": history.dest_storage or "unknown",
|
|
||||||
"transfer_mode": history.mode or "unknown",
|
|
||||||
"tmdbid": history.tmdbid or "none",
|
|
||||||
"doubanid": history.doubanid or "none",
|
|
||||||
"error_message": history.errmsg or "none",
|
|
||||||
}
|
|
||||||
|
|
||||||
async def heartbeat_check_jobs(self):
|
async def heartbeat_check_jobs(self):
|
||||||
"""
|
"""
|
||||||
心跳唤醒:检查并执行待处理的定时任务(Jobs)。
|
心跳唤醒:检查并执行待处理的定时任务(Jobs)。
|
||||||
@@ -1097,135 +1055,6 @@ class AgentManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"智能体心跳唤醒失败: {e}")
|
logger.error(f"智能体心跳唤醒失败: {e}")
|
||||||
|
|
||||||
async def retry_failed_transfer(self, history_id: int, group_key: str = ""):
|
|
||||||
"""
|
|
||||||
触发智能体重新整理失败的历史记录。
|
|
||||||
由文件整理模块在检测到整理失败后调用。
|
|
||||||
同一 group_key 的失败记录会在缓冲期内合并为一次agent调用,避免重复浪费token。
|
|
||||||
:param history_id: 失败的整理历史记录ID
|
|
||||||
:param group_key: 分组键,相同key的记录会被合并处理(如download_hash、源目录等)
|
|
||||||
"""
|
|
||||||
if not group_key:
|
|
||||||
group_key = f"_default_{history_id}"
|
|
||||||
|
|
||||||
async with self._retry_transfer_lock:
|
|
||||||
# 将 history_id 加入缓冲区
|
|
||||||
if group_key not in self._retry_transfer_buffer:
|
|
||||||
self._retry_transfer_buffer[group_key] = []
|
|
||||||
if history_id not in self._retry_transfer_buffer[group_key]:
|
|
||||||
self._retry_transfer_buffer[group_key].append(history_id)
|
|
||||||
logger.info(
|
|
||||||
f"智能体重试整理:记录 ID={history_id} 已加入缓冲区 "
|
|
||||||
f"(group={group_key}, 当前{len(self._retry_transfer_buffer[group_key])}条)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 取消该分组的旧定时器
|
|
||||||
if group_key in self._retry_transfer_timers:
|
|
||||||
self._retry_transfer_timers[group_key].cancel()
|
|
||||||
|
|
||||||
# 设置新的延迟定时器
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
self._retry_transfer_timers[group_key] = loop.call_later(
|
|
||||||
self.RETRY_TRANSFER_DEBOUNCE_SECONDS,
|
|
||||||
lambda gk=group_key: asyncio.ensure_future(
|
|
||||||
self._flush_retry_transfer(gk)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _flush_retry_transfer(self, group_key: str):
|
|
||||||
"""
|
|
||||||
延迟定时器到期后,取出该分组的所有 history_id 并合并为一次agent调用。
|
|
||||||
"""
|
|
||||||
async with self._retry_transfer_lock:
|
|
||||||
history_ids = self._retry_transfer_buffer.pop(group_key, [])
|
|
||||||
self._retry_transfer_timers.pop(group_key, None)
|
|
||||||
|
|
||||||
if not history_ids:
|
|
||||||
return
|
|
||||||
|
|
||||||
session_id = f"__agent_retry_transfer_batch_{uuid.uuid4().hex[:8]}__"
|
|
||||||
user_id = SYSTEM_INTERNAL_USER_ID
|
|
||||||
|
|
||||||
ids_str = ", ".join(str(i) for i in history_ids)
|
|
||||||
logger.info(
|
|
||||||
f"智能体重试整理:开始批量处理失败记录 IDs=[{ids_str}] (group={group_key})"
|
|
||||||
)
|
|
||||||
retry_message = self._build_retry_transfer_prompt(history_ids)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self.process_message(
|
|
||||||
session_id=session_id,
|
|
||||||
user_id=user_id,
|
|
||||||
message=retry_message,
|
|
||||||
channel=None,
|
|
||||||
source=None,
|
|
||||||
username=settings.SUPERUSER,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 等待消息队列处理完成
|
|
||||||
if session_id in self._session_queues:
|
|
||||||
await self._session_queues[session_id].join()
|
|
||||||
|
|
||||||
# 等待worker结束
|
|
||||||
if session_id in self._session_workers:
|
|
||||||
try:
|
|
||||||
await self._session_workers[session_id]
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"智能体重试整理:批量处理完成 IDs=[{ids_str}] (group={group_key})"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 用完即弃,清理资源
|
|
||||||
await self.clear_session(session_id, user_id)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"智能体重试整理失败 (IDs=[{ids_str}], group={group_key}): {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _build_manual_redo_prompt(history) -> str:
|
|
||||||
"""
|
|
||||||
构建手动 AI 整理提示词。
|
|
||||||
"""
|
|
||||||
return prompt_manager.render_system_task_message(
|
|
||||||
"manual_transfer_redo",
|
|
||||||
template_context=AgentManager._build_manual_redo_template_context(history),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def manual_redo_transfer(
|
|
||||||
self,
|
|
||||||
history_id: int,
|
|
||||||
output_callback: Optional[Callable[[str], None]] = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
手动触发单条历史记录的 AI 整理。
|
|
||||||
"""
|
|
||||||
session_id = f"__agent_manual_redo_{history_id}_{uuid.uuid4().hex[:8]}__"
|
|
||||||
user_id = SYSTEM_INTERNAL_USER_ID
|
|
||||||
agent = MoviePilotAgent(
|
|
||||||
session_id=session_id,
|
|
||||||
user_id=user_id,
|
|
||||||
channel=None,
|
|
||||||
source=None,
|
|
||||||
username=settings.SUPERUSER,
|
|
||||||
)
|
|
||||||
agent.output_callback = output_callback
|
|
||||||
agent.force_streaming = True
|
|
||||||
agent.suppress_user_reply = True
|
|
||||||
|
|
||||||
try:
|
|
||||||
history = TransferHistoryOper().get(history_id)
|
|
||||||
if not history:
|
|
||||||
raise ValueError(f"整理记录不存在: {history_id}")
|
|
||||||
|
|
||||||
await agent.process(self._build_manual_redo_prompt(history))
|
|
||||||
finally:
|
|
||||||
await agent.cleanup()
|
|
||||||
memory_manager.clear_memory(session_id, user_id)
|
|
||||||
|
|
||||||
|
|
||||||
# 全局智能体管理器实例
|
# 全局智能体管理器实例
|
||||||
agent_manager = AgentManager()
|
agent_manager = AgentManager()
|
||||||
|
|||||||
@@ -95,3 +95,45 @@ task_types:
|
|||||||
- "Do NOT reorganize blindly when media identity is uncertain."
|
- "Do NOT reorganize blindly when media identity is uncertain."
|
||||||
- "If the previous record was successful but obviously identified as the wrong media, still use the tool-based flow above instead of `/redo`."
|
- "If the previous record was successful but obviously identified as the wrong media, still use the tool-based flow above instead of `/redo`."
|
||||||
- "Keep the final response short and focused on outcome."
|
- "Keep the final response short and focused on outcome."
|
||||||
|
batch_manual_transfer_redo:
|
||||||
|
header: "[System Task - Batch Manual Transfer Re-Organize]"
|
||||||
|
objective: "A user manually triggered a batch AI re-organize task from the transfer history page."
|
||||||
|
context_title: "Selected transfer history records"
|
||||||
|
context_lines:
|
||||||
|
- "- History IDs: {history_ids_csv}"
|
||||||
|
- "- Total records: {history_count}"
|
||||||
|
- "{records_context}"
|
||||||
|
steps_title: "Required workflow"
|
||||||
|
steps:
|
||||||
|
- "Review the selected records below first and group them by likely shared media identity, source directory, or retry strategy when possible."
|
||||||
|
- "Use the provided record context as the primary source of truth. Call `query_transfer_history` only when you need extra confirmation."
|
||||||
|
- "For each group, decide whether the current recognition is trustworthy."
|
||||||
|
- "If multiple records clearly belong to the same movie or series, identify the media once with `recognize_media` or `search_media`, then reuse that result for the related records."
|
||||||
|
- "If a source file no longer exists or cannot be safely processed, skip that record and note the reason."
|
||||||
|
- "Before re-organizing a record, delete the old transfer history record with `delete_transfer_history` so the system will not skip the source file."
|
||||||
|
- "Then use `transfer_file` to organize the source path directly."
|
||||||
|
- "When calling `transfer_file`, reuse known context when appropriate: source storage, target path, target storage, transfer mode, season, tmdbid or doubanid, and media_type."
|
||||||
|
- "If a record is already correct and no re-organize is needed, do not perform destructive actions; simply mark it as skipped."
|
||||||
|
- "Report only the aggregate outcome, including how many records succeeded, skipped, and failed."
|
||||||
|
task_rules:
|
||||||
|
- "Do NOT assume every selected record belongs to the same media."
|
||||||
|
- "When several records obviously share the same media identity, avoid repeated `recognize_media` or `search_media` calls."
|
||||||
|
- "Process every selected record exactly once."
|
||||||
|
- "Keep the final response short and focused on the aggregate outcome."
|
||||||
|
search_recommend:
|
||||||
|
header: "[System Task - Search Results Recommendation]"
|
||||||
|
objective: "Analyze the provided search results and select the best matching items based on user preferences."
|
||||||
|
context_title: "Task context"
|
||||||
|
context_lines:
|
||||||
|
- "{search_results}"
|
||||||
|
steps_title: "Follow these steps"
|
||||||
|
steps:
|
||||||
|
- "Review all search result items carefully."
|
||||||
|
- "Evaluate each item based on the user preference criteria."
|
||||||
|
- "Select the top items that best match the preferences."
|
||||||
|
- "Return ONLY a JSON array of item indices."
|
||||||
|
task_rules:
|
||||||
|
- "Return ONLY a JSON array of index numbers, e.g., [0, 3, 1]."
|
||||||
|
- "Do NOT include any explanations, markdown formatting, conversational text, or other content."
|
||||||
|
- "Do NOT call any tools. Simply analyze and return the JSON result directly."
|
||||||
|
- "Respond in JSON format only."
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from app.agent.tools.base import MoviePilotTool
|
from app.agent.tools.base import MoviePilotTool
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.scheduler import Scheduler
|
|
||||||
|
|
||||||
|
|
||||||
class QuerySchedulersInput(BaseModel):
|
class QuerySchedulersInput(BaseModel):
|
||||||
@@ -27,6 +26,8 @@ class QuerySchedulersTool(MoviePilotTool):
|
|||||||
async def run(self, **kwargs) -> str:
|
async def run(self, **kwargs) -> str:
|
||||||
logger.info(f"执行工具: {self.name}")
|
logger.info(f"执行工具: {self.name}")
|
||||||
try:
|
try:
|
||||||
|
from app.scheduler import Scheduler
|
||||||
|
|
||||||
scheduler = Scheduler()
|
scheduler = Scheduler()
|
||||||
schedulers = scheduler.list()
|
schedulers = scheduler.list()
|
||||||
if schedulers:
|
if schedulers:
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from app.agent.tools.base import MoviePilotTool
|
from app.agent.tools.base import MoviePilotTool
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.scheduler import Scheduler
|
|
||||||
|
|
||||||
|
|
||||||
class RunSchedulerInput(BaseModel):
|
class RunSchedulerInput(BaseModel):
|
||||||
@@ -36,6 +35,8 @@ class RunSchedulerTool(MoviePilotTool):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _run_scheduler_sync(job_id: str) -> tuple[bool, str]:
|
def _run_scheduler_sync(job_id: str) -> tuple[bool, str]:
|
||||||
"""同步触发定时服务,避免调度器扫描阻塞事件循环。"""
|
"""同步触发定时服务,避免调度器扫描阻塞事件循环。"""
|
||||||
|
from app.scheduler import Scheduler
|
||||||
|
|
||||||
scheduler = Scheduler()
|
scheduler = Scheduler()
|
||||||
for scheduler_item in scheduler.list():
|
for scheduler_item in scheduler.list():
|
||||||
if scheduler_item.id == job_id:
|
if scheduler_item.id == job_id:
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from typing import Optional, Type
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.agent.tools.base import MoviePilotTool
|
from app.agent.tools.base import MoviePilotTool
|
||||||
from app.chain.transfer import TransferChain
|
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas import FileItem, MediaType
|
from app.schemas import FileItem, MediaType
|
||||||
|
|
||||||
@@ -124,6 +123,8 @@ class TransferFileTool(MoviePilotTool):
|
|||||||
if not media_type_enum:
|
if not media_type_enum:
|
||||||
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
|
return f"错误:无效的媒体类型 '{media_type}',支持的类型:'movie', 'tv'"
|
||||||
|
|
||||||
|
from app.chain.transfer import TransferChain
|
||||||
|
|
||||||
state, errormsg = TransferChain().manual_transfer(
|
state, errormsg = TransferChain().manual_transfer(
|
||||||
fileitem=fileitem,
|
fileitem=fileitem,
|
||||||
target_storage=target_storage,
|
target_storage=target_storage,
|
||||||
|
|||||||
+193
-10
@@ -1,14 +1,15 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from typing import List, Any, Optional
|
from typing import List, Any, Optional
|
||||||
|
|
||||||
import jieba
|
import jieba
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.agent import prompt_manager, agent_manager
|
||||||
from app.chain.storage import StorageChain
|
from app.chain.storage import StorageChain
|
||||||
from app.core.config import settings, global_vars
|
from app.core.config import settings, global_vars
|
||||||
from app.core.event import eventmanager
|
from app.core.event import eventmanager
|
||||||
@@ -24,13 +25,99 @@ from app.schemas.types import EventType
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _start_ai_redo_task(history_id: int, progress_key: str):
|
def normalize_history_ids(history_ids: list[int]) -> list[int]:
|
||||||
from app.agent import agent_manager
|
"""对输入的历史记录 ID 列表进行规范化处理,去除重复项并保持原有顺序。"""
|
||||||
|
normalized_ids: list[int] = []
|
||||||
|
for history_id in history_ids:
|
||||||
|
if history_id not in normalized_ids:
|
||||||
|
normalized_ids.append(history_id)
|
||||||
|
return normalized_ids
|
||||||
|
|
||||||
|
|
||||||
|
def build_manual_redo_template_context(history: TransferHistory) -> dict[str, int | str]:
|
||||||
|
"""仅负责把整理历史对象映射成 System Tasks 需要的模板变量。"""
|
||||||
|
src_fileitem = history.src_fileitem or {}
|
||||||
|
source_path = src_fileitem.get("path") if isinstance(src_fileitem, dict) else ""
|
||||||
|
source_path = source_path or history.src or ""
|
||||||
|
season_episode = f"{history.seasons or ''}{history.episodes or ''}".strip()
|
||||||
|
return {
|
||||||
|
"history_id": history.id,
|
||||||
|
"current_status": "success" if history.status else "failed",
|
||||||
|
"recognized_title": history.title or "unknown",
|
||||||
|
"media_type": history.type or "unknown",
|
||||||
|
"category": history.category or "unknown",
|
||||||
|
"year": history.year or "unknown",
|
||||||
|
"season_episode": season_episode or "unknown",
|
||||||
|
"source_path": source_path or "unknown",
|
||||||
|
"source_storage": history.src_storage or "local",
|
||||||
|
"destination_path": history.dest or "unknown",
|
||||||
|
"destination_storage": history.dest_storage or "unknown",
|
||||||
|
"transfer_mode": history.mode or "unknown",
|
||||||
|
"tmdbid": history.tmdbid or "none",
|
||||||
|
"doubanid": history.doubanid or "none",
|
||||||
|
"error_message": history.errmsg or "none",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_manual_redo_record_context(history: Any) -> str:
|
||||||
|
"""把单条整理记录格式化为批量任务可直接消费的上下文块。"""
|
||||||
|
context = build_manual_redo_template_context(history)
|
||||||
|
return "\n".join(
|
||||||
|
[
|
||||||
|
f"Record #{context['history_id']}:",
|
||||||
|
f"- Current status: {context['current_status']}",
|
||||||
|
f"- Current recognized title: {context['recognized_title']}",
|
||||||
|
f"- Media type: {context['media_type']}",
|
||||||
|
f"- Category: {context['category']}",
|
||||||
|
f"- Year: {context['year']}",
|
||||||
|
f"- Season/Episode: {context['season_episode']}",
|
||||||
|
f"- Source path: {context['source_path']}",
|
||||||
|
f"- Source storage: {context['source_storage']}",
|
||||||
|
f"- Destination path: {context['destination_path']}",
|
||||||
|
f"- Destination storage: {context['destination_storage']}",
|
||||||
|
f"- Transfer mode: {context['transfer_mode']}",
|
||||||
|
f"- Current TMDB ID: {context['tmdbid']}",
|
||||||
|
f"- Current Douban ID: {context['doubanid']}",
|
||||||
|
f"- Error message: {context['error_message']}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_manual_redo_prompt(history: Any) -> str:
|
||||||
|
"""构建手动 AI 整理提示词。"""
|
||||||
|
return prompt_manager.render_system_task_message(
|
||||||
|
"manual_transfer_redo",
|
||||||
|
template_context=build_manual_redo_template_context(history),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_batch_manual_redo_template_context(
|
||||||
|
histories: list[Any],
|
||||||
|
) -> dict[str, int | str]:
|
||||||
|
"""仅负责把多条整理历史对象映射成批量 System Tasks 需要的模板变量。"""
|
||||||
|
return {
|
||||||
|
"history_ids_csv": ", ".join(str(history.id) for history in histories),
|
||||||
|
"history_count": len(histories),
|
||||||
|
"records_context": "\n\n".join(
|
||||||
|
format_manual_redo_record_context(history) for history in histories
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_batch_manual_redo_prompt(histories: list[Any]) -> str:
|
||||||
|
"""构建批量手动 AI 整理提示词。"""
|
||||||
|
return prompt_manager.render_system_task_message(
|
||||||
|
"batch_manual_transfer_redo",
|
||||||
|
template_context=build_batch_manual_redo_template_context(histories),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_ai_redo_task(history_id: int, prompt: str, progress_key: str):
|
||||||
|
"""在后台线程中启动单条 AI 重新整理任务,并通过 ProgressHelper 实时更新进度。"""
|
||||||
progress = ProgressHelper(progress_key)
|
progress = ProgressHelper(progress_key)
|
||||||
progress.start()
|
progress.start()
|
||||||
progress.update(
|
progress.update(
|
||||||
text=f"智能助正在准备整理记录 #{history_id} ...",
|
text=f"智能助手正在准备整理记录 #{history_id} ...",
|
||||||
data={"history_id": history_id, "success": True},
|
data={"history_id": history_id, "success": True},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,9 +126,11 @@ def _start_ai_redo_task(history_id: int, progress_key: str):
|
|||||||
|
|
||||||
async def runner():
|
async def runner():
|
||||||
try:
|
try:
|
||||||
await agent_manager.manual_redo_transfer(
|
await agent_manager.run_background_prompt(
|
||||||
history_id=history_id,
|
message=prompt,
|
||||||
|
session_prefix=f"__agent_manual_redo_{history_id}",
|
||||||
output_callback=update_output,
|
output_callback=update_output,
|
||||||
|
suppress_user_reply=True,
|
||||||
)
|
)
|
||||||
progress.update(
|
progress.update(
|
||||||
text="智能助手整理完成",
|
text="智能助手整理完成",
|
||||||
@@ -63,6 +152,50 @@ def _start_ai_redo_task(history_id: int, progress_key: str):
|
|||||||
asyncio.run_coroutine_threadsafe(runner(), global_vars.loop)
|
asyncio.run_coroutine_threadsafe(runner(), global_vars.loop)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_batch_ai_redo_task(
|
||||||
|
history_ids: list[int],
|
||||||
|
prompt: str,
|
||||||
|
progress_key: str,
|
||||||
|
):
|
||||||
|
"""在后台线程中启动批量 AI 重新整理任务,并通过 ProgressHelper 实时更新进度。"""
|
||||||
|
progress = ProgressHelper(progress_key)
|
||||||
|
progress.start()
|
||||||
|
progress.update(
|
||||||
|
text=f"智能助手正在准备批量整理 {len(history_ids)} 条记录 ...",
|
||||||
|
data={"history_ids": history_ids, "success": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_output(text: str):
|
||||||
|
progress.update(text=text, data={"history_ids": history_ids})
|
||||||
|
|
||||||
|
async def runner():
|
||||||
|
try:
|
||||||
|
await agent_manager.run_background_prompt(
|
||||||
|
message=prompt,
|
||||||
|
session_prefix="__agent_manual_redo_batch",
|
||||||
|
output_callback=update_output,
|
||||||
|
suppress_user_reply=True,
|
||||||
|
)
|
||||||
|
progress.update(
|
||||||
|
text="智能助手批量整理完成",
|
||||||
|
data={"history_ids": history_ids, "success": True, "completed": True},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
progress.update(
|
||||||
|
text=f"智能助手批量整理失败:{str(e)}",
|
||||||
|
data={
|
||||||
|
"history_ids": history_ids,
|
||||||
|
"success": False,
|
||||||
|
"completed": True,
|
||||||
|
"error": str(e),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
progress.end()
|
||||||
|
|
||||||
|
asyncio.run_coroutine_threadsafe(runner(), global_vars.loop)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/download", summary="查询下载历史记录", response_model=List[schemas.DownloadHistory])
|
@router.get("/download", summary="查询下载历史记录", response_model=List[schemas.DownloadHistory])
|
||||||
async def download_history(page: Optional[int] = 1,
|
async def download_history(page: Optional[int] = 1,
|
||||||
count: Optional[int] = 30,
|
count: Optional[int] = 30,
|
||||||
@@ -159,9 +292,9 @@ def delete_transfer_history(history_in: schemas.TransferHistory,
|
|||||||
|
|
||||||
@router.post("/transfer/{history_id}/ai-redo", summary="智能助手重新整理", response_model=schemas.Response)
|
@router.post("/transfer/{history_id}/ai-redo", summary="智能助手重新整理", response_model=schemas.Response)
|
||||||
def ai_redo_transfer_history(
|
def ai_redo_transfer_history(
|
||||||
history_id: int,
|
history_id: int,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
_: User = Depends(get_current_active_superuser),
|
_: User = Depends(get_current_active_superuser),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
手动触发单条历史记录的 AI 重新整理,并返回进度键。
|
手动触发单条历史记录的 AI 重新整理,并返回进度键。
|
||||||
@@ -173,12 +306,62 @@ def ai_redo_transfer_history(
|
|||||||
if not history:
|
if not history:
|
||||||
return schemas.Response(success=False, message="整理记录不存在")
|
return schemas.Response(success=False, message="整理记录不存在")
|
||||||
|
|
||||||
|
prompt = build_manual_redo_prompt(history)
|
||||||
progress_key = f"ai_redo_transfer_{history_id}_{int(time.time() * 1000)}"
|
progress_key = f"ai_redo_transfer_{history_id}_{int(time.time() * 1000)}"
|
||||||
_start_ai_redo_task(history_id=history_id, progress_key=progress_key)
|
_start_ai_redo_task(
|
||||||
|
history_id=history_id,
|
||||||
|
prompt=prompt,
|
||||||
|
progress_key=progress_key,
|
||||||
|
)
|
||||||
|
|
||||||
return schemas.Response(success=True, data={"progress_key": progress_key})
|
return schemas.Response(success=True, data={"progress_key": progress_key})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/transfer/ai-redo", summary="智能助手批量重新整理", response_model=schemas.Response)
|
||||||
|
def batch_ai_redo_transfer_history(
|
||||||
|
payload: schemas.BatchTransferHistoryRedoRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_active_superuser),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
手动触发多条历史记录的 AI 批量重新整理,并返回进度键。
|
||||||
|
"""
|
||||||
|
if not settings.AI_AGENT_ENABLE:
|
||||||
|
return schemas.Response(success=False, message="MoviePilot智能助手未启用")
|
||||||
|
|
||||||
|
history_ids = normalize_history_ids(payload.history_ids)
|
||||||
|
if not history_ids:
|
||||||
|
return schemas.Response(success=False, message="未提供有效的整理记录")
|
||||||
|
|
||||||
|
histories = []
|
||||||
|
missing_ids = []
|
||||||
|
for history_id in history_ids:
|
||||||
|
history = TransferHistory.get(db, history_id)
|
||||||
|
if not history:
|
||||||
|
missing_ids.append(history_id)
|
||||||
|
continue
|
||||||
|
histories.append(history)
|
||||||
|
|
||||||
|
if missing_ids:
|
||||||
|
return schemas.Response(
|
||||||
|
success=False,
|
||||||
|
message="整理记录不存在: " + ", ".join(str(history_id) for history_id in missing_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = build_batch_manual_redo_prompt(histories)
|
||||||
|
progress_key = f"ai_redo_transfer_batch_{int(time.time() * 1000)}"
|
||||||
|
_start_batch_ai_redo_task(
|
||||||
|
history_ids=history_ids,
|
||||||
|
prompt=prompt,
|
||||||
|
progress_key=progress_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
return schemas.Response(
|
||||||
|
success=True,
|
||||||
|
data={"progress_key": progress_key, "history_ids": history_ids},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/empty/transfer", summary="清空整理记录", response_model=schemas.Response)
|
@router.get("/empty/transfer", summary="清空整理记录", response_model=schemas.Response)
|
||||||
async def empty_transfer_history(db: AsyncSession = Depends(get_async_db),
|
async def empty_transfer_history(db: AsyncSession = Depends(get_async_db),
|
||||||
_: User = Depends(get_current_active_superuser_async)) -> Any:
|
_: User = Depends(get_current_active_superuser_async)) -> Any:
|
||||||
|
|||||||
+11
-15
@@ -5,9 +5,9 @@ from fastapi import APIRouter, Depends, Body, Request
|
|||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.chain.agent import AIRecommendChain
|
||||||
from app.chain.media import MediaChain
|
from app.chain.media import MediaChain
|
||||||
from app.chain.search import SearchChain
|
from app.chain.search import SearchChain
|
||||||
from app.chain.ai_recommend import AIRecommendChain
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.event import eventmanager
|
from app.core.event import eventmanager
|
||||||
from app.core.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo
|
||||||
@@ -73,7 +73,6 @@ async def search_by_id_stream(request: Request,
|
|||||||
"""
|
"""
|
||||||
根据TMDBID/豆瓣ID渐进式搜索站点资源,返回格式为SSE
|
根据TMDBID/豆瓣ID渐进式搜索站点资源,返回格式为SSE
|
||||||
"""
|
"""
|
||||||
AIRecommendChain().cancel_ai_recommend()
|
|
||||||
|
|
||||||
media_type = MediaType(mtype) if mtype else None
|
media_type = MediaType(mtype) if mtype else None
|
||||||
media_season = int(season) if season else None
|
media_season = int(season) if season else None
|
||||||
@@ -206,8 +205,7 @@ async def search_by_id(mediaid: str,
|
|||||||
根据TMDBID/豆瓣ID精确搜索站点资源 tmdb:/douban:/bangumi:
|
根据TMDBID/豆瓣ID精确搜索站点资源 tmdb:/douban:/bangumi:
|
||||||
"""
|
"""
|
||||||
# 取消正在运行的AI推荐(会清除数据库缓存)
|
# 取消正在运行的AI推荐(会清除数据库缓存)
|
||||||
AIRecommendChain().cancel_ai_recommend()
|
|
||||||
|
|
||||||
if mtype:
|
if mtype:
|
||||||
media_type = MediaType(mtype)
|
media_type = MediaType(mtype)
|
||||||
else:
|
else:
|
||||||
@@ -332,7 +330,6 @@ async def search_by_title_stream(request: Request,
|
|||||||
"""
|
"""
|
||||||
根据名称渐进式模糊搜索站点资源,返回格式为SSE
|
根据名称渐进式模糊搜索站点资源,返回格式为SSE
|
||||||
"""
|
"""
|
||||||
AIRecommendChain().cancel_ai_recommend()
|
|
||||||
|
|
||||||
event_source = SearchChain().async_search_by_title_stream(
|
event_source = SearchChain().async_search_by_title_stream(
|
||||||
title=keyword,
|
title=keyword,
|
||||||
@@ -352,8 +349,7 @@ async def search_by_title(keyword: Optional[str] = None,
|
|||||||
根据名称模糊搜索站点资源,支持分页,关键词为空是返回首页资源
|
根据名称模糊搜索站点资源,支持分页,关键词为空是返回首页资源
|
||||||
"""
|
"""
|
||||||
# 取消正在运行的AI推荐并清除数据库缓存
|
# 取消正在运行的AI推荐并清除数据库缓存
|
||||||
AIRecommendChain().cancel_ai_recommend()
|
|
||||||
|
|
||||||
torrents = await SearchChain().async_search_by_title(
|
torrents = await SearchChain().async_search_by_title(
|
||||||
title=keyword, page=page,
|
title=keyword, page=page,
|
||||||
sites=_parse_site_list(sites),
|
sites=_parse_site_list(sites),
|
||||||
@@ -396,9 +392,9 @@ async def recommend_search_results(
|
|||||||
return schemas.Response(success=False, message="没有可用的搜索结果", data={
|
return schemas.Response(success=False, message="没有可用的搜索结果", data={
|
||||||
"status": "error"
|
"status": "error"
|
||||||
})
|
})
|
||||||
|
|
||||||
recommend_chain = AIRecommendChain()
|
recommend_chain = AIRecommendChain()
|
||||||
|
|
||||||
# 如果是强制模式,先取消并清除旧结果,然后直接启动新任务
|
# 如果是强制模式,先取消并清除旧结果,然后直接启动新任务
|
||||||
if force:
|
if force:
|
||||||
# 检查功能是否启用
|
# 检查功能是否启用
|
||||||
@@ -413,7 +409,7 @@ async def recommend_search_results(
|
|||||||
return schemas.Response(success=True, data={
|
return schemas.Response(success=True, data={
|
||||||
"status": "running"
|
"status": "running"
|
||||||
})
|
})
|
||||||
|
|
||||||
# 如果是仅检查模式,不传递 filtered_indices(避免触发请求变化检测)
|
# 如果是仅检查模式,不传递 filtered_indices(避免触发请求变化检测)
|
||||||
if check_only:
|
if check_only:
|
||||||
# 返回当前运行状态,不做任何任务启动或取消操作
|
# 返回当前运行状态,不做任何任务启动或取消操作
|
||||||
@@ -423,14 +419,14 @@ async def recommend_search_results(
|
|||||||
error_msg = current_status.pop("error", "未知错误")
|
error_msg = current_status.pop("error", "未知错误")
|
||||||
return schemas.Response(success=False, message=error_msg, data=current_status)
|
return schemas.Response(success=False, message=error_msg, data=current_status)
|
||||||
return schemas.Response(success=True, data=current_status)
|
return schemas.Response(success=True, data=current_status)
|
||||||
|
|
||||||
# 获取当前状态(会检测请求是否变化)
|
# 获取当前状态(会检测请求是否变化)
|
||||||
status_data = recommend_chain.get_status(filtered_indices, len(results))
|
status_data = recommend_chain.get_status(filtered_indices, len(results))
|
||||||
|
|
||||||
# 如果功能未启用,直接返回禁用状态
|
# 如果功能未启用,直接返回禁用状态
|
||||||
if status_data.get("status") == "disabled":
|
if status_data.get("status") == "disabled":
|
||||||
return schemas.Response(success=True, data=status_data)
|
return schemas.Response(success=True, data=status_data)
|
||||||
|
|
||||||
# 如果是空闲状态,启动新任务
|
# 如果是空闲状态,启动新任务
|
||||||
if status_data["status"] == "idle":
|
if status_data["status"] == "idle":
|
||||||
recommend_chain.start_recommend_task(filtered_indices, len(results), results)
|
recommend_chain.start_recommend_task(filtered_indices, len(results), results)
|
||||||
@@ -438,11 +434,11 @@ async def recommend_search_results(
|
|||||||
return schemas.Response(success=True, data={
|
return schemas.Response(success=True, data={
|
||||||
"status": "running"
|
"status": "running"
|
||||||
})
|
})
|
||||||
|
|
||||||
# 如果有错误,将错误信息放到message中
|
# 如果有错误,将错误信息放到message中
|
||||||
if status_data.get("status") == "error":
|
if status_data.get("status") == "error":
|
||||||
error_msg = status_data.pop("error", "未知错误")
|
error_msg = status_data.pop("error", "未知错误")
|
||||||
return schemas.Response(success=False, message=error_msg, data=status_data)
|
return schemas.Response(success=False, message=error_msg, data=status_data)
|
||||||
|
|
||||||
# 返回当前状态
|
# 返回当前状态
|
||||||
return schemas.Response(success=True, data=status_data)
|
return schemas.Response(success=True, data=status_data)
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import re
|
|
||||||
from typing import List, Optional, Dict, Any
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from app.agent import agent_manager, prompt_manager
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.utils.common import log_execution_time
|
|
||||||
from app.utils.singleton import Singleton
|
from app.utils.singleton import Singleton
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
|
|
||||||
@@ -16,17 +16,16 @@ class AIRecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
AI推荐处理链,单例运行
|
AI推荐处理链,单例运行
|
||||||
用于基于搜索结果的AI智能推荐
|
用于基于搜索结果的AI智能推荐
|
||||||
|
使用 agent_manager.run_background_prompt 统一后台任务机制
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 缓存文件名
|
|
||||||
__ai_indices_cache_file = "__ai_recommend_indices__"
|
__ai_indices_cache_file = "__ai_recommend_indices__"
|
||||||
|
|
||||||
# AI推荐状态
|
|
||||||
_ai_recommend_running = False
|
_ai_recommend_running = False
|
||||||
_ai_recommend_task: Optional[asyncio.Task] = None
|
_ai_recommend_task: Optional[asyncio.Task] = None
|
||||||
_current_request_hash: Optional[str] = None # 当前请求的哈希值
|
_current_request_hash: Optional[str] = None
|
||||||
_ai_recommend_result: Optional[List[int]] = None # AI推荐索引缓存(索引列表)
|
_ai_recommend_result: Optional[List[int]] = None
|
||||||
_ai_recommend_error: Optional[str] = None # AI推荐错误信息
|
_ai_recommend_error: Optional[str] = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _calculate_request_hash(
|
def _calculate_request_hash(
|
||||||
@@ -53,7 +52,6 @@ class AIRecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
def _build_status(self) -> Dict[str, Any]:
|
def _build_status(self) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
构建AI推荐状态字典
|
构建AI推荐状态字典
|
||||||
:return: 状态字典
|
|
||||||
"""
|
"""
|
||||||
if not self.is_enabled:
|
if not self.is_enabled:
|
||||||
return {"status": "disabled"}
|
return {"status": "disabled"}
|
||||||
@@ -61,13 +59,11 @@ class AIRecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
if self._ai_recommend_running:
|
if self._ai_recommend_running:
|
||||||
return {"status": "running"}
|
return {"status": "running"}
|
||||||
|
|
||||||
# 尝试从数据库加载缓存
|
|
||||||
if self._ai_recommend_result is None:
|
if self._ai_recommend_result is None:
|
||||||
cached_indices = self.load_cache(self.__ai_indices_cache_file)
|
cached_indices = self.load_cache(self.__ai_indices_cache_file)
|
||||||
if cached_indices is not None:
|
if cached_indices is not None:
|
||||||
self._ai_recommend_result = cached_indices
|
self._ai_recommend_result = cached_indices
|
||||||
|
|
||||||
# 只要有结果,始终返回completed状态和数据
|
|
||||||
if self._ai_recommend_result is not None:
|
if self._ai_recommend_result is not None:
|
||||||
return {"status": "completed", "results": self._ai_recommend_result}
|
return {"status": "completed", "results": self._ai_recommend_result}
|
||||||
|
|
||||||
@@ -89,76 +85,16 @@ class AIRecommendChain(ChainBase, metaclass=Singleton):
|
|||||||
获取AI推荐状态并检查请求是否变化(用于首次请求或force模式)
|
获取AI推荐状态并检查请求是否变化(用于首次请求或force模式)
|
||||||
如果请求变化(筛选条件变化),返回idle状态
|
如果请求变化(筛选条件变化),返回idle状态
|
||||||
"""
|
"""
|
||||||
# 计算当前请求的hash
|
|
||||||
request_hash = self._calculate_request_hash(
|
request_hash = self._calculate_request_hash(
|
||||||
filtered_indices, search_results_count
|
filtered_indices, search_results_count
|
||||||
)
|
)
|
||||||
|
|
||||||
# 检查请求是否变化
|
|
||||||
is_same_request = request_hash == self._current_request_hash
|
is_same_request = request_hash == self._current_request_hash
|
||||||
|
|
||||||
# 如果请求变化了(筛选条件改变),返回idle状态
|
|
||||||
if not is_same_request:
|
if not is_same_request:
|
||||||
return {"status": "idle"} if self.is_enabled else {"status": "disabled"}
|
return {"status": "idle"} if self.is_enabled else {"status": "disabled"}
|
||||||
|
|
||||||
# 请求未变化,返回当前实际状态
|
|
||||||
return self._build_status()
|
return self._build_status()
|
||||||
|
|
||||||
@log_execution_time(logger=logger)
|
|
||||||
async def async_ai_recommend(self, items: List[str], preference: str = None) -> str:
|
|
||||||
"""
|
|
||||||
AI推荐
|
|
||||||
:param items: 候选资源列表(JSON字符串格式)
|
|
||||||
:param preference: 用户偏好(可选)
|
|
||||||
:return: AI返回的推荐结果
|
|
||||||
"""
|
|
||||||
# 设置运行状态
|
|
||||||
self._ai_recommend_running = True
|
|
||||||
try:
|
|
||||||
# 导入LLMHelper
|
|
||||||
from app.helper.llm import LLMHelper
|
|
||||||
|
|
||||||
# 获取LLM实例
|
|
||||||
llm = LLMHelper.get_llm()
|
|
||||||
|
|
||||||
# 构建提示词
|
|
||||||
user_preference = (
|
|
||||||
preference
|
|
||||||
or settings.AI_RECOMMEND_USER_PREFERENCE
|
|
||||||
or "Prefer high-quality resources with more seeders"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 添加指令
|
|
||||||
instruction = """
|
|
||||||
Task: Select the best matching items from the list based on user preferences.
|
|
||||||
|
|
||||||
Each item contains:
|
|
||||||
- index: Item number
|
|
||||||
- title: Full torrent title
|
|
||||||
- size: File size
|
|
||||||
- seeders: Number of seeders
|
|
||||||
|
|
||||||
Output Format: Return ONLY a JSON array of "index" numbers (e.g., [0, 3, 1]). Do NOT include any explanations or other text.
|
|
||||||
"""
|
|
||||||
message = (
|
|
||||||
f"User Preference: {user_preference}\n{instruction}\nCandidate Resources:\n"
|
|
||||||
+ "\n".join(items)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 调用LLM
|
|
||||||
response = await llm.ainvoke(message)
|
|
||||||
return response.content
|
|
||||||
|
|
||||||
except ValueError as e:
|
|
||||||
logger.error(f"AI推荐配置错误: {e}")
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
# 清除运行状态
|
|
||||||
self._ai_recommend_running = False
|
|
||||||
self._ai_recommend_task = None
|
|
||||||
|
|
||||||
def is_ai_recommend_running(self) -> bool:
|
def is_ai_recommend_running(self) -> bool:
|
||||||
"""
|
"""
|
||||||
检查AI推荐是否正在运行
|
检查AI推荐是否正在运行
|
||||||
@@ -186,44 +122,34 @@ Output Format: Return ONLY a JSON array of "index" numbers (e.g., [0, 3, 1]). Do
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
启动AI推荐任务
|
启动AI推荐任务
|
||||||
|
使用 agent_manager.run_background_prompt 后台Agent机制执行推荐
|
||||||
:param filtered_indices: 筛选后的索引列表
|
:param filtered_indices: 筛选后的索引列表
|
||||||
:param search_results_count: 搜索结果总数
|
:param search_results_count: 搜索结果总数
|
||||||
:param results: 搜索结果列表
|
:param results: 搜索结果列表
|
||||||
"""
|
"""
|
||||||
# 防护检查:确保AI推荐功能已启用
|
|
||||||
if not self.is_enabled:
|
if not self.is_enabled:
|
||||||
logger.warning("AI推荐功能未启用,跳过任务执行")
|
logger.warning("AI推荐功能未启用,跳过任务执行")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 计算新请求的哈希值
|
|
||||||
new_request_hash = self._calculate_request_hash(
|
new_request_hash = self._calculate_request_hash(
|
||||||
filtered_indices, search_results_count
|
filtered_indices, search_results_count
|
||||||
)
|
)
|
||||||
|
|
||||||
# 如果请求变化了,取消旧任务
|
|
||||||
if new_request_hash != self._current_request_hash:
|
if new_request_hash != self._current_request_hash:
|
||||||
self.cancel_ai_recommend()
|
self.cancel_ai_recommend()
|
||||||
|
|
||||||
# 更新请求哈希值
|
|
||||||
self._current_request_hash = new_request_hash
|
self._current_request_hash = new_request_hash
|
||||||
|
|
||||||
# 重置状态
|
|
||||||
self._ai_recommend_result = None
|
self._ai_recommend_result = None
|
||||||
self._ai_recommend_error = None
|
self._ai_recommend_error = None
|
||||||
|
|
||||||
# 启动新任务
|
|
||||||
async def run_recommend():
|
async def run_recommend():
|
||||||
# 获取当前任务对象,用于在finally中比对
|
|
||||||
current_task = asyncio.current_task()
|
current_task = asyncio.current_task()
|
||||||
try:
|
try:
|
||||||
self._ai_recommend_running = True
|
self._ai_recommend_running = True
|
||||||
|
|
||||||
# 准备数据
|
|
||||||
items = []
|
items = []
|
||||||
valid_indices = []
|
valid_indices = []
|
||||||
max_items = settings.AI_RECOMMEND_MAX_ITEMS or 50
|
max_items = settings.AI_RECOMMEND_MAX_ITEMS or 50
|
||||||
|
|
||||||
# 如果提供了筛选索引,先筛选结果;否则使用所有结果
|
|
||||||
if filtered_indices is not None and len(filtered_indices) > 0:
|
if filtered_indices is not None and len(filtered_indices) > 0:
|
||||||
results_to_process = [
|
results_to_process = [
|
||||||
results[i]
|
results[i]
|
||||||
@@ -259,27 +185,54 @@ Output Format: Return ONLY a JSON array of "index" numbers (e.g., [0, 3, 1]). Do
|
|||||||
self._ai_recommend_error = "没有可用于AI推荐的资源"
|
self._ai_recommend_error = "没有可用于AI推荐的资源"
|
||||||
return
|
return
|
||||||
|
|
||||||
# 调用AI推荐
|
user_preference = (
|
||||||
ai_response = await self.async_ai_recommend(items)
|
settings.AI_RECOMMEND_USER_PREFERENCE
|
||||||
|
or "Prefer high-quality resources with more seeders"
|
||||||
|
)
|
||||||
|
|
||||||
|
search_results_text = "User Preference: {preference}\n\nCandidate Resources:\n{items}".format(
|
||||||
|
preference=user_preference, items="\n".join(items)
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = prompt_manager.render_system_task_message(
|
||||||
|
"search_recommend",
|
||||||
|
template_context={"search_results": search_results_text},
|
||||||
|
)
|
||||||
|
|
||||||
|
full_output = [""]
|
||||||
|
|
||||||
|
def on_output(text: str):
|
||||||
|
full_output[0] = text
|
||||||
|
|
||||||
|
await agent_manager.run_background_prompt(
|
||||||
|
message=prompt,
|
||||||
|
session_prefix="__agent_search_recommend",
|
||||||
|
output_callback=on_output,
|
||||||
|
suppress_user_reply=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
ai_response = full_output[0]
|
||||||
|
if not ai_response:
|
||||||
|
self._ai_recommend_error = "AI推荐未返回结果"
|
||||||
|
return
|
||||||
|
|
||||||
# 解析AI返回的索引
|
|
||||||
try:
|
try:
|
||||||
# 使用正则提取JSON数组(非贪婪模式,避免匹配多个数组)
|
json_match = re.search(r"\[.*?]", ai_response, re.DOTALL)
|
||||||
json_match = re.search(r'\[.*?\]', ai_response, re.DOTALL)
|
|
||||||
if not json_match:
|
if not json_match:
|
||||||
raise ValueError(ai_response)
|
raise ValueError(f"无法从响应中提取JSON数组: {ai_response}")
|
||||||
|
|
||||||
ai_indices = json.loads(json_match.group())
|
ai_indices = json.loads(json_match.group())
|
||||||
if not isinstance(ai_indices, list):
|
if not isinstance(ai_indices, list):
|
||||||
raise ValueError(f"AI返回格式错误: {ai_response}")
|
raise ValueError(f"AI返回格式错误: {ai_response}")
|
||||||
|
|
||||||
# 映射回原始索引
|
|
||||||
if filtered_indices:
|
if filtered_indices:
|
||||||
original_indices = [
|
original_indices = [
|
||||||
filtered_indices[valid_indices[i]]
|
filtered_indices[valid_indices[i]]
|
||||||
for i in ai_indices
|
for i in ai_indices
|
||||||
if i < len(valid_indices)
|
if i < len(valid_indices)
|
||||||
and 0 <= filtered_indices[valid_indices[i]] < len(results)
|
and 0
|
||||||
|
<= filtered_indices[valid_indices[i]]
|
||||||
|
< len(results)
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
original_indices = [
|
original_indices = [
|
||||||
@@ -289,10 +242,7 @@ Output Format: Return ONLY a JSON array of "index" numbers (e.g., [0, 3, 1]). Do
|
|||||||
and 0 <= valid_indices[i] < len(results)
|
and 0 <= valid_indices[i] < len(results)
|
||||||
]
|
]
|
||||||
|
|
||||||
# 只返回索引列表,不返回完整数据
|
|
||||||
self._ai_recommend_result = original_indices
|
self._ai_recommend_result = original_indices
|
||||||
|
|
||||||
# 保存到数据库
|
|
||||||
self.save_cache(original_indices, self.__ai_indices_cache_file)
|
self.save_cache(original_indices, self.__ai_indices_cache_file)
|
||||||
logger.info(f"AI推荐完成: {len(original_indices)}项")
|
logger.info(f"AI推荐完成: {len(original_indices)}项")
|
||||||
|
|
||||||
@@ -308,11 +258,8 @@ Output Format: Return ONLY a JSON array of "index" numbers (e.g., [0, 3, 1]). Do
|
|||||||
logger.error(f"AI推荐任务失败: {e}")
|
logger.error(f"AI推荐任务失败: {e}")
|
||||||
self._ai_recommend_error = str(e)
|
self._ai_recommend_error = str(e)
|
||||||
finally:
|
finally:
|
||||||
# 只有当 self._ai_recommend_task 仍然是当前任务时,才清理状态
|
|
||||||
# 如果任务被取消并启动了新任务,self._ai_recommend_task 已经指向新任务,不应重置
|
|
||||||
if self._ai_recommend_task == current_task:
|
if self._ai_recommend_task == current_task:
|
||||||
self._ai_recommend_running = False
|
self._ai_recommend_running = False
|
||||||
self._ai_recommend_task = None
|
self._ai_recommend_task = None
|
||||||
|
|
||||||
# 创建并启动任务
|
|
||||||
self._ai_recommend_task = asyncio.create_task(run_recommend())
|
self._ai_recommend_task = asyncio.create_task(run_recommend())
|
||||||
+210
-159
@@ -1,16 +1,15 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional, Dict, Union, List
|
from typing import Any, Optional, Dict, Union, List
|
||||||
from urllib.parse import unquote, urlparse
|
from urllib.parse import unquote, urlparse
|
||||||
import uuid
|
|
||||||
|
|
||||||
import base64
|
from app.agent import agent_manager, prompt_manager
|
||||||
|
|
||||||
from app.agent import agent_manager
|
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.chain.interaction import (
|
from app.chain.interaction import (
|
||||||
MediaInteractionChain,
|
MediaInteractionChain,
|
||||||
@@ -20,6 +19,8 @@ from app.chain.interaction import (
|
|||||||
from app.chain.skills import SkillsChain, skills_interaction_manager
|
from app.chain.skills import SkillsChain, skills_interaction_manager
|
||||||
from app.chain.transfer import TransferChain
|
from app.chain.transfer import TransferChain
|
||||||
from app.core.config import settings, global_vars
|
from app.core.config import settings, global_vars
|
||||||
|
from app.db.models import TransferHistory
|
||||||
|
from app.db.transferhistory_oper import TransferHistoryOper
|
||||||
from app.helper.llm import LLMHelper
|
from app.helper.llm import LLMHelper
|
||||||
from app.helper.voice import VoiceHelper
|
from app.helper.voice import VoiceHelper
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
@@ -92,17 +93,17 @@ class MessageChain(ChainBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def handle_message(
|
def handle_message(
|
||||||
self,
|
self,
|
||||||
channel: MessageChannel,
|
channel: MessageChannel,
|
||||||
source: str,
|
source: str,
|
||||||
userid: Union[str, int],
|
userid: Union[str, int],
|
||||||
username: str,
|
username: str,
|
||||||
text: str,
|
text: str,
|
||||||
original_message_id: Optional[Union[str, int]] = None,
|
original_message_id: Optional[Union[str, int]] = None,
|
||||||
original_chat_id: Optional[str] = None,
|
original_chat_id: Optional[str] = None,
|
||||||
images: Optional[List[CommingMessage.MessageImage]] = None,
|
images: Optional[List[CommingMessage.MessageImage]] = None,
|
||||||
audio_refs: Optional[List[str]] = None,
|
audio_refs: Optional[List[str]] = None,
|
||||||
files: Optional[List[CommingMessage.MessageAttachment]] = None,
|
files: Optional[List[CommingMessage.MessageAttachment]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
识别消息内容,执行操作
|
识别消息内容,执行操作
|
||||||
@@ -171,21 +172,21 @@ class MessageChain(ChainBase):
|
|||||||
|
|
||||||
if skills_interaction_manager.get_by_user(userid):
|
if skills_interaction_manager.get_by_user(userid):
|
||||||
if SkillsChain().handle_text_interaction(
|
if SkillsChain().handle_text_interaction(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
username=username,
|
username=username,
|
||||||
text=text,
|
text=text,
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
if media_interaction_manager.get_by_user(userid):
|
if media_interaction_manager.get_by_user(userid):
|
||||||
if MediaInteractionChain().handle_text_interaction(
|
if MediaInteractionChain().handle_text_interaction(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
username=username,
|
username=username,
|
||||||
text=text,
|
text=text,
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -202,8 +203,8 @@ class MessageChain(ChainBase):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if (
|
if (
|
||||||
settings.AI_AGENT_ENABLE
|
settings.AI_AGENT_ENABLE
|
||||||
and (settings.AI_AGENT_GLOBAL or images or files or has_audio_input)
|
and (settings.AI_AGENT_GLOBAL or images or files or has_audio_input)
|
||||||
):
|
):
|
||||||
self._handle_ai_message(
|
self._handle_ai_message(
|
||||||
text=text,
|
text=text,
|
||||||
@@ -217,11 +218,11 @@ class MessageChain(ChainBase):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if MediaInteractionChain().handle_text_interaction(
|
if MediaInteractionChain().handle_text_interaction(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
username=username,
|
username=username,
|
||||||
text=text,
|
text=text,
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -236,14 +237,14 @@ class MessageChain(ChainBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _handle_callback(
|
def _handle_callback(
|
||||||
self,
|
self,
|
||||||
text: str,
|
text: str,
|
||||||
channel: MessageChannel,
|
channel: MessageChannel,
|
||||||
source: str,
|
source: str,
|
||||||
userid: Union[str, int],
|
userid: Union[str, int],
|
||||||
username: str,
|
username: str,
|
||||||
original_message_id: Optional[Union[str, int]] = None,
|
original_message_id: Optional[Union[str, int]] = None,
|
||||||
original_chat_id: Optional[str] = None,
|
original_chat_id: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
处理按钮回调
|
处理按钮回调
|
||||||
@@ -254,44 +255,44 @@ class MessageChain(ChainBase):
|
|||||||
logger.info(f"处理按钮回调:{callback_data}")
|
logger.info(f"处理按钮回调:{callback_data}")
|
||||||
|
|
||||||
if self._handle_transfer_callback(
|
if self._handle_transfer_callback(
|
||||||
callback_data=callback_data,
|
callback_data=callback_data,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
username=username,
|
username=username,
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
if SkillsChain().handle_callback_interaction(
|
if SkillsChain().handle_callback_interaction(
|
||||||
callback_data=callback_data,
|
callback_data=callback_data,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
username=username,
|
username=username,
|
||||||
original_message_id=original_message_id,
|
original_message_id=original_message_id,
|
||||||
original_chat_id=original_chat_id,
|
original_chat_id=original_chat_id,
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
if MediaInteractionChain().handle_callback_interaction(
|
if MediaInteractionChain().handle_callback_interaction(
|
||||||
callback_data=callback_data,
|
callback_data=callback_data,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
username=username,
|
username=username,
|
||||||
original_message_id=original_message_id,
|
original_message_id=original_message_id,
|
||||||
original_chat_id=original_chat_id,
|
original_chat_id=original_chat_id,
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
if self._handle_agent_choice_callback(
|
if self._handle_agent_choice_callback(
|
||||||
callback_data=callback_data,
|
callback_data=callback_data,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
userid=userid,
|
userid=userid,
|
||||||
username=username,
|
username=username,
|
||||||
original_message_id=original_message_id,
|
original_message_id=original_message_id,
|
||||||
original_chat_id=original_chat_id,
|
original_chat_id=original_chat_id,
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -327,14 +328,14 @@ class MessageChain(ChainBase):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_transfer_callback(
|
def _parse_transfer_callback(
|
||||||
callback_data: str,
|
callback_data: str,
|
||||||
) -> Optional[tuple[str, int]]:
|
) -> Optional[tuple[str, int]]:
|
||||||
"""
|
"""
|
||||||
解析整理失败通知按钮回调。
|
解析整理失败通知按钮回调。
|
||||||
"""
|
"""
|
||||||
for prefix, action in (
|
for prefix, action in (
|
||||||
("transfer_retry_", "retry"),
|
("transfer_retry_", "retry"),
|
||||||
("transfer_ai_retry_", "ai_retry"),
|
("transfer_ai_retry_", "ai_retry"),
|
||||||
):
|
):
|
||||||
if callback_data.startswith(prefix):
|
if callback_data.startswith(prefix):
|
||||||
history_id = callback_data.replace(prefix, "", 1)
|
history_id = callback_data.replace(prefix, "", 1)
|
||||||
@@ -343,12 +344,12 @@ class MessageChain(ChainBase):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _handle_transfer_callback(
|
def _handle_transfer_callback(
|
||||||
self,
|
self,
|
||||||
callback_data: str,
|
callback_data: str,
|
||||||
channel: MessageChannel,
|
channel: MessageChannel,
|
||||||
source: str,
|
source: str,
|
||||||
userid: Union[str, int],
|
userid: Union[str, int],
|
||||||
username: str,
|
username: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
处理整理失败通知中的重试类按钮。
|
处理整理失败通知中的重试类按钮。
|
||||||
@@ -378,7 +379,7 @@ class MessageChain(ChainBase):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _parse_agent_choice_callback(
|
def _parse_agent_choice_callback(
|
||||||
callback_data: str,
|
callback_data: str,
|
||||||
) -> Optional[tuple[str, int]]:
|
) -> Optional[tuple[str, int]]:
|
||||||
"""
|
"""
|
||||||
解析 Agent 按钮选择回调。
|
解析 Agent 按钮选择回调。
|
||||||
@@ -401,14 +402,14 @@ class MessageChain(ChainBase):
|
|||||||
return request_id, int(option_index)
|
return request_id, int(option_index)
|
||||||
|
|
||||||
def _handle_agent_choice_callback(
|
def _handle_agent_choice_callback(
|
||||||
self,
|
self,
|
||||||
callback_data: str,
|
callback_data: str,
|
||||||
channel: MessageChannel,
|
channel: MessageChannel,
|
||||||
source: str,
|
source: str,
|
||||||
userid: Union[str, int],
|
userid: Union[str, int],
|
||||||
username: str,
|
username: str,
|
||||||
original_message_id: Optional[Union[str, int]] = None,
|
original_message_id: Optional[Union[str, int]] = None,
|
||||||
original_chat_id: Optional[str] = None,
|
original_chat_id: Optional[str] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
将 Agent 按钮选择回传为同一会话中的下一条用户消息。
|
将 Agent 按钮选择回传为同一会话中的下一条用户消息。
|
||||||
@@ -465,14 +466,14 @@ class MessageChain(ChainBase):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def _update_interaction_message_feedback(
|
def _update_interaction_message_feedback(
|
||||||
self,
|
self,
|
||||||
channel: MessageChannel,
|
channel: MessageChannel,
|
||||||
source: str,
|
source: str,
|
||||||
original_message_id: Optional[Union[str, int]],
|
original_message_id: Optional[Union[str, int]],
|
||||||
original_chat_id: Optional[str],
|
original_chat_id: Optional[str],
|
||||||
prompt: str,
|
prompt: str,
|
||||||
selected_label: str,
|
selected_label: str,
|
||||||
title: Optional[str] = None,
|
title: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
在用户点击交互按钮后,立即更新原消息,明确显示已选择的内容。
|
在用户点击交互按钮后,立即更新原消息,明确显示已选择的内容。
|
||||||
@@ -494,12 +495,12 @@ class MessageChain(ChainBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _retry_transfer_history(
|
def _retry_transfer_history(
|
||||||
self,
|
self,
|
||||||
history_id: int,
|
history_id: int,
|
||||||
channel: MessageChannel,
|
channel: MessageChannel,
|
||||||
source: str,
|
source: str,
|
||||||
userid: Union[str, int],
|
userid: Union[str, int],
|
||||||
username: str,
|
username: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
立即重新整理一条失败的整理记录。
|
立即重新整理一条失败的整理记录。
|
||||||
@@ -541,16 +542,46 @@ class MessageChain(ChainBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _take_over_transfer_history_by_ai(
|
def _take_over_transfer_history_by_ai(
|
||||||
self,
|
self,
|
||||||
history_id: int,
|
history_id: int,
|
||||||
channel: MessageChannel,
|
channel: MessageChannel,
|
||||||
source: str,
|
source: str,
|
||||||
userid: Union[str, int],
|
userid: Union[str, int],
|
||||||
username: str,
|
username: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
由智能助手接管一条失败的整理记录。
|
由智能助手接管一条失败的整理记录。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def __build_manual_redo_prompt(his: TransferHistory) -> str:
|
||||||
|
"""构建手动 AI 整理提示词。"""
|
||||||
|
|
||||||
|
src_fileitem = his.src_fileitem or {}
|
||||||
|
source_path = src_fileitem.get("path") if isinstance(src_fileitem, dict) else ""
|
||||||
|
source_path = source_path or his.src or ""
|
||||||
|
season_episode = f"{his.seasons or ''}{his.episodes or ''}".strip()
|
||||||
|
template_context = {
|
||||||
|
"his_id": his.id,
|
||||||
|
"current_status": "success" if his.status else "failed",
|
||||||
|
"recognized_title": his.title or "unknown",
|
||||||
|
"media_type": his.type or "unknown",
|
||||||
|
"category": his.category or "unknown",
|
||||||
|
"year": his.year or "unknown",
|
||||||
|
"season_episode": season_episode or "unknown",
|
||||||
|
"source_path": source_path or "unknown",
|
||||||
|
"source_storage": his.src_storage or "local",
|
||||||
|
"destination_path": his.dest or "unknown",
|
||||||
|
"destination_storage": his.dest_storage or "unknown",
|
||||||
|
"transfer_mode": his.mode or "unknown",
|
||||||
|
"tmdbid": his.tmdbid or "none",
|
||||||
|
"doubanid": his.doubanid or "none",
|
||||||
|
"error_message": his.errmsg or "none",
|
||||||
|
}
|
||||||
|
return prompt_manager.render_system_task_message(
|
||||||
|
"manual_transfer_redo",
|
||||||
|
template_context=template_context,
|
||||||
|
)
|
||||||
|
|
||||||
if not settings.AI_AGENT_ENABLE:
|
if not settings.AI_AGENT_ENABLE:
|
||||||
self.post_message(
|
self.post_message(
|
||||||
Notification(
|
Notification(
|
||||||
@@ -563,6 +594,23 @@ class MessageChain(ChainBase):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
history = TransferHistoryOper().get(history_id)
|
||||||
|
if not history:
|
||||||
|
self.post_message(
|
||||||
|
Notification(
|
||||||
|
channel=channel,
|
||||||
|
source=source,
|
||||||
|
userid=userid,
|
||||||
|
username=username,
|
||||||
|
title="重新整理失败",
|
||||||
|
text=f"整理记录 #{history_id} 不存在",
|
||||||
|
link=settings.MP_DOMAIN("#/history"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
redo_prompt = __build_manual_redo_prompt(history)
|
||||||
|
|
||||||
self.post_message(
|
self.post_message(
|
||||||
Notification(
|
Notification(
|
||||||
channel=channel,
|
channel=channel,
|
||||||
@@ -583,9 +631,11 @@ class MessageChain(ChainBase):
|
|||||||
final_output = text_output or ""
|
final_output = text_output or ""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await agent_manager.manual_redo_transfer(
|
await agent_manager.run_background_prompt(
|
||||||
history_id=history_id,
|
message=redo_prompt,
|
||||||
|
session_prefix=f"__agent_manual_redo_{history_id}",
|
||||||
output_callback=_capture_output,
|
output_callback=_capture_output,
|
||||||
|
suppress_user_reply=True,
|
||||||
)
|
)
|
||||||
await self.async_post_message(
|
await self.async_post_message(
|
||||||
Notification(
|
Notification(
|
||||||
@@ -595,7 +645,7 @@ class MessageChain(ChainBase):
|
|||||||
username=username,
|
username=username,
|
||||||
title="智能助手整理完成",
|
title="智能助手整理完成",
|
||||||
text=final_output.strip()
|
text=final_output.strip()
|
||||||
or f"整理记录 #{history_id} 已由智能助手处理完成。",
|
or f"整理记录 #{history_id} 已由智能助手处理完成。",
|
||||||
link=settings.MP_DOMAIN("#/history"),
|
link=settings.MP_DOMAIN("#/history"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -650,12 +700,12 @@ class MessageChain(ChainBase):
|
|||||||
self._user_sessions[userid] = (session_id, datetime.now())
|
self._user_sessions[userid] = (session_id, datetime.now())
|
||||||
|
|
||||||
def _record_user_message(
|
def _record_user_message(
|
||||||
self,
|
self,
|
||||||
channel: MessageChannel,
|
channel: MessageChannel,
|
||||||
source: str,
|
source: str,
|
||||||
userid: Union[str, int],
|
userid: Union[str, int],
|
||||||
username: str,
|
username: str,
|
||||||
text: str,
|
text: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
保存一条用户消息到消息历史与数据库。
|
保存一条用户消息到消息历史与数据库。
|
||||||
@@ -690,10 +740,10 @@ class MessageChain(ChainBase):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def remote_clear_session(
|
def remote_clear_session(
|
||||||
self,
|
self,
|
||||||
channel: MessageChannel,
|
channel: MessageChannel,
|
||||||
userid: Union[str, int],
|
userid: Union[str, int],
|
||||||
source: Optional[str] = None,
|
source: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
清除用户会话(远程命令接口)
|
清除用户会话(远程命令接口)
|
||||||
@@ -735,10 +785,10 @@ class MessageChain(ChainBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def remote_stop_agent(
|
def remote_stop_agent(
|
||||||
self,
|
self,
|
||||||
channel: MessageChannel,
|
channel: MessageChannel,
|
||||||
userid: Union[str, int],
|
userid: Union[str, int],
|
||||||
source: Optional[str] = None,
|
source: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
应急停止当前正在执行的Agent推理(远程命令接口)。
|
应急停止当前正在执行的Agent推理(远程命令接口)。
|
||||||
@@ -805,7 +855,7 @@ class MessageChain(ChainBase):
|
|||||||
f"({context_ratio * 100:.2f}%)"
|
f"({context_ratio * 100:.2f}%)"
|
||||||
if context_ratio is not None
|
if context_ratio is not None
|
||||||
else f"{cls._format_token_count(last_input_tokens)} / "
|
else f"{cls._format_token_count(last_input_tokens)} / "
|
||||||
f"{cls._format_token_count(context_window_tokens)}"
|
f"{cls._format_token_count(context_window_tokens)}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
context_usage_text = "暂无模型调用数据"
|
context_usage_text = "暂无模型调用数据"
|
||||||
@@ -825,10 +875,10 @@ class MessageChain(ChainBase):
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def remote_session_status(
|
def remote_session_status(
|
||||||
self,
|
self,
|
||||||
channel: MessageChannel,
|
channel: MessageChannel,
|
||||||
userid: Union[str, int],
|
userid: Union[str, int],
|
||||||
source: Optional[str] = None,
|
source: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""查询当前用户的智能体会话状态。"""
|
"""查询当前用户的智能体会话状态。"""
|
||||||
session_info = self._user_sessions.get(userid)
|
session_info = self._user_sessions.get(userid)
|
||||||
@@ -856,15 +906,15 @@ class MessageChain(ChainBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _handle_ai_message(
|
def _handle_ai_message(
|
||||||
self,
|
self,
|
||||||
text: str,
|
text: str,
|
||||||
channel: MessageChannel,
|
channel: MessageChannel,
|
||||||
source: str,
|
source: str,
|
||||||
userid: Union[str, int],
|
userid: Union[str, int],
|
||||||
username: str,
|
username: str,
|
||||||
images: Optional[List[CommingMessage.MessageImage]] = None,
|
images: Optional[List[CommingMessage.MessageImage]] = None,
|
||||||
files: Optional[List[CommingMessage.MessageAttachment]] = None,
|
files: Optional[List[CommingMessage.MessageAttachment]] = None,
|
||||||
session_id: Optional[str] = None,
|
session_id: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
处理AI智能体消息
|
处理AI智能体消息
|
||||||
@@ -928,10 +978,10 @@ class MessageChain(ChainBase):
|
|||||||
elif images:
|
elif images:
|
||||||
image_attachments = self._build_image_attachments(images)
|
image_attachments = self._build_image_attachments(images)
|
||||||
if (
|
if (
|
||||||
original_images
|
original_images
|
||||||
and not image_attachments
|
and not image_attachments
|
||||||
and not user_message
|
and not user_message
|
||||||
and not files
|
and not files
|
||||||
):
|
):
|
||||||
self.post_message(
|
self.post_message(
|
||||||
Notification(
|
Notification(
|
||||||
@@ -986,7 +1036,7 @@ class MessageChain(ChainBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _transcribe_audio_refs(
|
def _transcribe_audio_refs(
|
||||||
self, audio_refs: List[str], channel: MessageChannel, source: str
|
self, audio_refs: List[str], channel: MessageChannel, source: str
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
下载并识别语音消息,仅处理当前已接入的渠道。
|
下载并识别语音消息,仅处理当前已接入的渠道。
|
||||||
@@ -1119,10 +1169,10 @@ class MessageChain(ChainBase):
|
|||||||
return default
|
return default
|
||||||
|
|
||||||
def _download_attachments_to_data_urls(
|
def _download_attachments_to_data_urls(
|
||||||
self,
|
self,
|
||||||
attachments: List[CommingMessage.MessageImage],
|
attachments: List[CommingMessage.MessageImage],
|
||||||
channel: MessageChannel,
|
channel: MessageChannel,
|
||||||
source: str,
|
source: str,
|
||||||
) -> Optional[List[str]]:
|
) -> Optional[List[str]]:
|
||||||
"""
|
"""
|
||||||
下载可直接提供给 LLM 的附件内容,并统一转换为 data URL。
|
下载可直接提供给 LLM 的附件内容,并统一转换为 data URL。
|
||||||
@@ -1147,7 +1197,7 @@ class MessageChain(ChainBase):
|
|||||||
if base64_data:
|
if base64_data:
|
||||||
data_urls.append(f"data:image/jpeg;base64,{base64_data}")
|
data_urls.append(f"data:image/jpeg;base64,{base64_data}")
|
||||||
elif attachment_ref.startswith(
|
elif attachment_ref.startswith(
|
||||||
"wxwork://media_id/"
|
"wxwork://media_id/"
|
||||||
) or attachment_ref.startswith(
|
) or attachment_ref.startswith(
|
||||||
"wxbot://image/"
|
"wxbot://image/"
|
||||||
):
|
):
|
||||||
@@ -1208,7 +1258,7 @@ class MessageChain(ChainBase):
|
|||||||
return data_urls if data_urls else None
|
return data_urls if data_urls else None
|
||||||
|
|
||||||
def _build_image_attachments(
|
def _build_image_attachments(
|
||||||
self, images: List[CommingMessage.MessageImage]
|
self, images: List[CommingMessage.MessageImage]
|
||||||
) -> List[CommingMessage.MessageAttachment]:
|
) -> List[CommingMessage.MessageAttachment]:
|
||||||
"""
|
"""
|
||||||
将图片引用转换为附件描述,以便按文件方式交给 Agent 处理。
|
将图片引用转换为附件描述,以便按文件方式交给 Agent 处理。
|
||||||
@@ -1235,11 +1285,11 @@ class MessageChain(ChainBase):
|
|||||||
return attachments
|
return attachments
|
||||||
|
|
||||||
def _prepare_agent_files(
|
def _prepare_agent_files(
|
||||||
self,
|
self,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
files: Optional[List[CommingMessage.MessageAttachment]],
|
files: Optional[List[CommingMessage.MessageAttachment]],
|
||||||
channel: MessageChannel,
|
channel: MessageChannel,
|
||||||
source: str,
|
source: str,
|
||||||
) -> Optional[List[dict]]:
|
) -> Optional[List[dict]]:
|
||||||
"""
|
"""
|
||||||
下载用户上传的附件,落盘到临时目录,并生成 Agent 可消费的文件描述。
|
下载用户上传的附件,落盘到临时目录,并生成 Agent 可消费的文件描述。
|
||||||
@@ -1286,7 +1336,7 @@ class MessageChain(ChainBase):
|
|||||||
return prepared_files or None
|
return prepared_files or None
|
||||||
|
|
||||||
def _download_message_file_bytes(
|
def _download_message_file_bytes(
|
||||||
self, file_ref: str, channel: MessageChannel, source: str
|
self, file_ref: str, channel: MessageChannel, source: str
|
||||||
) -> Optional[bytes]:
|
) -> Optional[bytes]:
|
||||||
"""
|
"""
|
||||||
下载消息附件的原始字节内容。
|
下载消息附件的原始字节内容。
|
||||||
@@ -1359,11 +1409,11 @@ class MessageChain(ChainBase):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _save_agent_attachment(
|
def _save_agent_attachment(
|
||||||
self,
|
self,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
filename: Optional[str],
|
filename: Optional[str],
|
||||||
content: bytes,
|
content: bytes,
|
||||||
mime_type: Optional[str] = None,
|
mime_type: Optional[str] = None,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""
|
"""
|
||||||
将用户上传文件写入临时目录,并返回本地路径。
|
将用户上传文件写入临时目录,并返回本地路径。
|
||||||
@@ -1379,7 +1429,7 @@ class MessageChain(ChainBase):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _sanitize_attachment_name(
|
def _sanitize_attachment_name(
|
||||||
filename: Optional[str], mime_type: Optional[str] = None
|
filename: Optional[str], mime_type: Optional[str] = None
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
规范化附件文件名,避免路径穿越和非法字符。
|
规范化附件文件名,避免路径穿越和非法字符。
|
||||||
@@ -1449,5 +1499,6 @@ class MessageChain(ChainBase):
|
|||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return base64.b64decode(payload)
|
return base64.b64decode(payload)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.error(e)
|
||||||
return None
|
return None
|
||||||
|
|||||||
+2
-6
@@ -724,8 +724,7 @@ class SkillsChain(ChainBase):
|
|||||||
"""
|
"""
|
||||||
if request.view == "installed":
|
if request.view == "installed":
|
||||||
title, text, buttons = self._build_installed_view(
|
title, text, buttons = self._build_installed_view(
|
||||||
request=request,
|
request=request
|
||||||
force_market_refresh=force_market_refresh,
|
|
||||||
)
|
)
|
||||||
elif request.view == "market":
|
elif request.view == "market":
|
||||||
title, text, buttons = self._build_market_view(
|
title, text, buttons = self._build_market_view(
|
||||||
@@ -735,7 +734,6 @@ class SkillsChain(ChainBase):
|
|||||||
elif request.view == "sources":
|
elif request.view == "sources":
|
||||||
title, text, buttons = self._build_sources_view(
|
title, text, buttons = self._build_sources_view(
|
||||||
request=request,
|
request=request,
|
||||||
force_market_refresh=force_market_refresh,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
title, text, buttons = self._build_root_view(
|
title, text, buttons = self._build_root_view(
|
||||||
@@ -808,8 +806,7 @@ class SkillsChain(ChainBase):
|
|||||||
|
|
||||||
def _build_installed_view(
|
def _build_installed_view(
|
||||||
self,
|
self,
|
||||||
request: PendingSkillsInteraction,
|
request: PendingSkillsInteraction
|
||||||
force_market_refresh: bool = False, # noqa: ARG002
|
|
||||||
) -> Tuple[str, str, Optional[List[List[dict]]]]:
|
) -> Tuple[str, str, Optional[List[List[dict]]]]:
|
||||||
"""
|
"""
|
||||||
构建已安装技能视图,列出来源和可删除状态。
|
构建已安装技能视图,列出来源和可删除状态。
|
||||||
@@ -971,7 +968,6 @@ class SkillsChain(ChainBase):
|
|||||||
def _build_sources_view(
|
def _build_sources_view(
|
||||||
self,
|
self,
|
||||||
request: PendingSkillsInteraction,
|
request: PendingSkillsInteraction,
|
||||||
force_market_refresh: bool = False, # noqa: ARG002
|
|
||||||
) -> Tuple[str, str, Optional[List[List[dict]]]]:
|
) -> Tuple[str, str, Optional[List[List[dict]]]]:
|
||||||
"""
|
"""
|
||||||
构建技能源管理视图,提供自定义 GitHub 源的增删入口。
|
构建技能源管理视图,提供自定义 GitHub 源的增删入口。
|
||||||
|
|||||||
+227
-127
@@ -8,6 +8,7 @@ from pathlib import Path
|
|||||||
from typing import List, Optional, Tuple, Union, Dict, Callable
|
from typing import List, Optional, Tuple, Union, Dict, Callable
|
||||||
|
|
||||||
from app import schemas
|
from app import schemas
|
||||||
|
from app.agent import prompt_manager, agent_manager
|
||||||
from app.chain import ChainBase
|
from app.chain import ChainBase
|
||||||
from app.chain.media import MediaChain
|
from app.chain.media import MediaChain
|
||||||
from app.chain.storage import StorageChain
|
from app.chain.storage import StorageChain
|
||||||
@@ -162,10 +163,10 @@ class JobManager:
|
|||||||
else:
|
else:
|
||||||
# 不重复添加任务
|
# 不重复添加任务
|
||||||
if any(
|
if any(
|
||||||
[
|
[
|
||||||
t.fileitem == task.fileitem
|
t.fileitem == task.fileitem
|
||||||
for t in self._job_view[__mediaid__].tasks
|
for t in self._job_view[__mediaid__].tasks
|
||||||
]
|
]
|
||||||
):
|
):
|
||||||
logger.debug(f"任务 {task.fileitem.name} 已存在,跳过重复添加")
|
logger.debug(f"任务 {task.fileitem.name} 已存在,跳过重复添加")
|
||||||
return False
|
return False
|
||||||
@@ -301,7 +302,7 @@ class JobManager:
|
|||||||
return task
|
return task
|
||||||
|
|
||||||
def __remove_task_with_job_id(
|
def __remove_task_with_job_id(
|
||||||
self, fileitem: FileItem
|
self, fileitem: FileItem
|
||||||
) -> Tuple[Optional[TransferJobTask], Optional[Tuple]]:
|
) -> Tuple[Optional[TransferJobTask], Optional[Tuple]]:
|
||||||
"""
|
"""
|
||||||
根据文件项移除任务,并返回任务所在的作业ID
|
根据文件项移除任务,并返回任务所在的作业ID
|
||||||
@@ -462,10 +463,10 @@ class JobManager:
|
|||||||
"""
|
"""
|
||||||
with job_lock:
|
with job_lock:
|
||||||
if any(
|
if any(
|
||||||
task.state not in {"completed", "failed"}
|
task.state not in {"completed", "failed"}
|
||||||
for job in self._job_view.values()
|
for job in self._job_view.values()
|
||||||
for task in job.tasks
|
for task in job.tasks
|
||||||
if task.download_hash == download_hash
|
if task.download_hash == download_hash
|
||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
@@ -476,19 +477,19 @@ class JobManager:
|
|||||||
"""
|
"""
|
||||||
with job_lock:
|
with job_lock:
|
||||||
if any(
|
if any(
|
||||||
task.state != "completed"
|
task.state != "completed"
|
||||||
for job in self._job_view.values()
|
for job in self._job_view.values()
|
||||||
for task in job.tasks
|
for task in job.tasks
|
||||||
if task.download_hash == download_hash
|
if task.download_hash == download_hash
|
||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def has_tasks(
|
def has_tasks(
|
||||||
self,
|
self,
|
||||||
meta: MetaBase,
|
meta: MetaBase,
|
||||||
mediainfo: Optional[MediaInfo] = None,
|
mediainfo: Optional[MediaInfo] = None,
|
||||||
season: Optional[int] = None,
|
season: Optional[int] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
判断作业是否还有任务正在处理
|
判断作业是否还有任务正在处理
|
||||||
@@ -501,12 +502,12 @@ class JobManager:
|
|||||||
|
|
||||||
__metaid__ = self.__get_meta_id(meta=meta, season=season)
|
__metaid__ = self.__get_meta_id(meta=meta, season=season)
|
||||||
return (
|
return (
|
||||||
__metaid__ in self._job_view
|
__metaid__ in self._job_view
|
||||||
and len(self._job_view[__metaid__].tasks) > 0
|
and len(self._job_view[__metaid__].tasks) > 0
|
||||||
)
|
)
|
||||||
|
|
||||||
def success_tasks(
|
def success_tasks(
|
||||||
self, media: MediaInfo, season: Optional[int] = None
|
self, media: MediaInfo, season: Optional[int] = None
|
||||||
) -> List[TransferJobTask]:
|
) -> List[TransferJobTask]:
|
||||||
"""
|
"""
|
||||||
获取作业中所有成功的任务
|
获取作业中所有成功的任务
|
||||||
@@ -522,7 +523,7 @@ class JobManager:
|
|||||||
]
|
]
|
||||||
|
|
||||||
def all_tasks(
|
def all_tasks(
|
||||||
self, media: MediaInfo, season: Optional[int] = None
|
self, media: MediaInfo, season: Optional[int] = None
|
||||||
) -> List[TransferJobTask]:
|
) -> List[TransferJobTask]:
|
||||||
"""
|
"""
|
||||||
获取作业中全部任务
|
获取作业中全部任务
|
||||||
@@ -586,7 +587,7 @@ class JobManager:
|
|||||||
return list(self._job_view.values())
|
return list(self._job_view.values())
|
||||||
|
|
||||||
def season_episodes(
|
def season_episodes(
|
||||||
self, media: MediaInfo, season: Optional[int] = None
|
self, media: MediaInfo, season: Optional[int] = None
|
||||||
) -> List[int]:
|
) -> List[int]:
|
||||||
"""
|
"""
|
||||||
获取作业的季集清单
|
获取作业的季集清单
|
||||||
@@ -596,6 +597,107 @@ class JobManager:
|
|||||||
return self._season_episodes.get(__mediaid__) or []
|
return self._season_episodes.get(__mediaid__) or []
|
||||||
|
|
||||||
|
|
||||||
|
class FailedRetryScheduler:
|
||||||
|
"""
|
||||||
|
负责失败整理记录的 debounce 聚合与 AI 重试调度。
|
||||||
|
"""
|
||||||
|
|
||||||
|
RETRY_TRANSFER_DEBOUNCE_SECONDS = 300
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._retry_transfer_buffer: dict[str, list[int]] = {}
|
||||||
|
self._retry_transfer_timers: dict[str, asyncio.TimerHandle] = {}
|
||||||
|
self._retry_transfer_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
async with self._retry_transfer_lock:
|
||||||
|
timers = list(self._retry_transfer_timers.values())
|
||||||
|
self._retry_transfer_timers.clear()
|
||||||
|
self._retry_transfer_buffer.clear()
|
||||||
|
|
||||||
|
for timer in timers:
|
||||||
|
timer.cancel()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_retry_transfer_template_context(
|
||||||
|
history_ids: list[int],
|
||||||
|
) -> tuple[str, dict[str, int | str]]:
|
||||||
|
"""仅负责把失败重试任务的动态数据映射成模板变量。"""
|
||||||
|
is_batch = len(history_ids) > 1
|
||||||
|
task_type = "batch_transfer_failed_retry" if is_batch else "transfer_failed_retry"
|
||||||
|
template_context: dict[str, int | str] = {
|
||||||
|
"history_ids_csv": ", ".join(str(item) for item in history_ids),
|
||||||
|
"history_count": len(history_ids),
|
||||||
|
}
|
||||||
|
if not is_batch:
|
||||||
|
template_context["history_id"] = history_ids[0]
|
||||||
|
return task_type, template_context
|
||||||
|
|
||||||
|
def _build_retry_transfer_prompt(self, history_ids: list[int]) -> str:
|
||||||
|
"""根据失败记录数量构建统一的重试整理后台任务提示词。"""
|
||||||
|
task_type, template_context = self._build_retry_transfer_template_context(history_ids)
|
||||||
|
return prompt_manager.render_system_task_message(
|
||||||
|
task_type,
|
||||||
|
template_context=template_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def schedule_retry(self, history_id: int, group_key: str = ""):
|
||||||
|
"""
|
||||||
|
同一 group_key 的失败记录会在缓冲期内合并为一次 agent 调用。
|
||||||
|
"""
|
||||||
|
if not group_key:
|
||||||
|
group_key = f"_default_{history_id}"
|
||||||
|
|
||||||
|
async with self._retry_transfer_lock:
|
||||||
|
if group_key not in self._retry_transfer_buffer:
|
||||||
|
self._retry_transfer_buffer[group_key] = []
|
||||||
|
if history_id not in self._retry_transfer_buffer[group_key]:
|
||||||
|
self._retry_transfer_buffer[group_key].append(history_id)
|
||||||
|
logger.info(
|
||||||
|
f"智能体重试整理:记录 ID={history_id} 已加入缓冲区 "
|
||||||
|
f"(group={group_key}, 当前{len(self._retry_transfer_buffer[group_key])}条)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if group_key in self._retry_transfer_timers:
|
||||||
|
self._retry_transfer_timers[group_key].cancel()
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
self._retry_transfer_timers[group_key] = loop.call_later(
|
||||||
|
self.RETRY_TRANSFER_DEBOUNCE_SECONDS,
|
||||||
|
lambda gk=group_key: asyncio.create_task(self._flush_retry_transfer(gk)),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _flush_retry_transfer(self, group_key: str):
|
||||||
|
"""
|
||||||
|
延迟定时器到期后,取出该分组的所有 history_id 并合并为一次 agent 调用。
|
||||||
|
"""
|
||||||
|
async with self._retry_transfer_lock:
|
||||||
|
history_ids = self._retry_transfer_buffer.pop(group_key, [])
|
||||||
|
self._retry_transfer_timers.pop(group_key, None)
|
||||||
|
|
||||||
|
if not history_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
ids_str = ", ".join(str(item) for item in history_ids)
|
||||||
|
logger.info(
|
||||||
|
f"智能体重试整理:开始批量处理失败记录 IDs=[{ids_str}] (group={group_key})"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await agent_manager.run_background_prompt(
|
||||||
|
message=self._build_retry_transfer_prompt(history_ids),
|
||||||
|
session_prefix="__agent_retry_transfer_batch",
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"智能体重试整理:批量处理完成 IDs=[{ids_str}] (group={group_key})"
|
||||||
|
)
|
||||||
|
except Exception as err:
|
||||||
|
logger.error(
|
||||||
|
f"智能体重试整理失败 (IDs=[{ids_str}], group={group_key}): {err}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||||
"""
|
"""
|
||||||
文件整理处理链
|
文件整理处理链
|
||||||
@@ -623,6 +725,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
self._transfer_interval = 15
|
self._transfer_interval = 15
|
||||||
# 事件管理器
|
# 事件管理器
|
||||||
self.jobview = JobManager()
|
self.jobview = JobManager()
|
||||||
|
# Agent重试管理器
|
||||||
|
self.retry_scheduler = FailedRetryScheduler()
|
||||||
# 转移成功的文件清单
|
# 转移成功的文件清单
|
||||||
self._success_target_files: Dict[str, List[str]] = {}
|
self._success_target_files: Dict[str, List[str]] = {}
|
||||||
# 整理进度进度
|
# 整理进度进度
|
||||||
@@ -713,7 +817,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def __default_callback(
|
def __default_callback(
|
||||||
self, task: TransferTask, transferinfo: TransferInfo, /
|
self, task: TransferTask, transferinfo: TransferInfo, /
|
||||||
) -> Tuple[bool, str]:
|
) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
整理完成后处理
|
整理完成后处理
|
||||||
@@ -730,12 +834,12 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
"""
|
"""
|
||||||
# 更新文件数量
|
# 更新文件数量
|
||||||
transferinfo.file_count = (
|
transferinfo.file_count = (
|
||||||
self.jobview.count(task.mediainfo, task.meta.begin_season) or 1
|
self.jobview.count(task.mediainfo, task.meta.begin_season) or 1
|
||||||
)
|
)
|
||||||
# 更新文件大小
|
# 更新文件大小
|
||||||
transferinfo.total_size = (
|
transferinfo.total_size = (
|
||||||
self.jobview.size(task.mediainfo, task.meta.begin_season)
|
self.jobview.size(task.mediainfo, task.meta.begin_season)
|
||||||
or task.fileitem.size
|
or task.fileitem.size
|
||||||
)
|
)
|
||||||
# 更新文件清单
|
# 更新文件清单
|
||||||
with job_lock:
|
with job_lock:
|
||||||
@@ -866,13 +970,11 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
|
|
||||||
# AI智能体自动重试整理
|
# AI智能体自动重试整理
|
||||||
if (
|
if (
|
||||||
history
|
history
|
||||||
and settings.AI_AGENT_ENABLE
|
and settings.AI_AGENT_ENABLE
|
||||||
and settings.AI_AGENT_RETRY_TRANSFER
|
and settings.AI_AGENT_RETRY_TRANSFER
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
from app.agent import agent_manager
|
|
||||||
|
|
||||||
# 使用 download_hash 或源文件父目录作为分组键,
|
# 使用 download_hash 或源文件父目录作为分组键,
|
||||||
# 同一批次(如同一个种子)的失败记录会被合并为一次agent调用
|
# 同一批次(如同一个种子)的失败记录会被合并为一次agent调用
|
||||||
group_key = (
|
group_key = (
|
||||||
@@ -881,7 +983,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
asyncio.run_coroutine_threadsafe(
|
asyncio.run_coroutine_threadsafe(
|
||||||
agent_manager.retry_failed_transfer(
|
self.retry_scheduler.schedule_retry(
|
||||||
history.id, group_key=group_key
|
history.id, group_key=group_key
|
||||||
),
|
),
|
||||||
global_vars.loop,
|
global_vars.loop,
|
||||||
@@ -996,11 +1098,11 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
if self.jobview.is_torrent_success(t.download_hash):
|
if self.jobview.is_torrent_success(t.download_hash):
|
||||||
processed_hashes.add(t.download_hash)
|
processed_hashes.add(t.download_hash)
|
||||||
if self._can_delete_torrent(
|
if self._can_delete_torrent(
|
||||||
t.download_hash, t.downloader, transfer_exclude_words
|
t.download_hash, t.downloader, transfer_exclude_words
|
||||||
):
|
):
|
||||||
# 移除种子及文件
|
# 移除种子及文件
|
||||||
if self.remove_torrents(
|
if self.remove_torrents(
|
||||||
t.download_hash, downloader=t.downloader
|
t.download_hash, downloader=t.downloader
|
||||||
):
|
):
|
||||||
logger.info(
|
logger.info(
|
||||||
f"移动模式删除种子成功:{t.download_hash}"
|
f"移动模式删除种子成功:{t.download_hash}"
|
||||||
@@ -1156,7 +1258,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
logger.error(f"整理队列处理出现错误:{e} - {traceback.format_exc()}")
|
logger.error(f"整理队列处理出现错误:{e} - {traceback.format_exc()}")
|
||||||
|
|
||||||
def __handle_transfer(
|
def __handle_transfer(
|
||||||
self, task: TransferTask, callback: Optional[Callable] = None
|
self, task: TransferTask, callback: Optional[Callable] = None
|
||||||
) -> Optional[Tuple[bool, str]]:
|
) -> Optional[Tuple[bool, str]]:
|
||||||
"""
|
"""
|
||||||
处理整理任务
|
处理整理任务
|
||||||
@@ -1223,13 +1325,11 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
|
|
||||||
# AI智能体自动重试整理
|
# AI智能体自动重试整理
|
||||||
if (
|
if (
|
||||||
his
|
his
|
||||||
and settings.AI_AGENT_ENABLE
|
and settings.AI_AGENT_ENABLE
|
||||||
and settings.AI_AGENT_RETRY_TRANSFER
|
and settings.AI_AGENT_RETRY_TRANSFER
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
from app.agent import agent_manager
|
|
||||||
|
|
||||||
# 使用 download_hash 或源文件父目录作为分组键
|
# 使用 download_hash 或源文件父目录作为分组键
|
||||||
group_key = (
|
group_key = (
|
||||||
task.download_hash
|
task.download_hash
|
||||||
@@ -1238,7 +1338,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
asyncio.run_coroutine_threadsafe(
|
asyncio.run_coroutine_threadsafe(
|
||||||
agent_manager.retry_failed_transfer(
|
self.retry_scheduler.schedule_retry(
|
||||||
his.id, group_key=group_key
|
his.id, group_key=group_key
|
||||||
),
|
),
|
||||||
global_vars.loop,
|
global_vars.loop,
|
||||||
@@ -1393,8 +1493,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
|
|
||||||
# 如果没有下载器监控的目录则不处理
|
# 如果没有下载器监控的目录则不处理
|
||||||
if not any(
|
if not any(
|
||||||
dir_info.monitor_type == "downloader" and dir_info.storage == "local"
|
dir_info.monitor_type == "downloader" and dir_info.storage == "local"
|
||||||
for dir_info in download_dirs
|
for dir_info in download_dirs
|
||||||
):
|
):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -1408,8 +1508,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
torrent
|
torrent
|
||||||
for torrent in torrents_list
|
for torrent in torrents_list
|
||||||
if (h := torrent.hash) not in existing_hashes
|
if (h := torrent.hash) not in existing_hashes
|
||||||
# 排除多下载器返回的重复种子
|
# 排除多下载器返回的重复种子
|
||||||
and (h not in seen and (seen.add(h) or True))
|
and (h not in seen and (seen.add(h) or True))
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
torrents = []
|
torrents = []
|
||||||
@@ -1480,7 +1580,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
fileitem=FileItem(
|
fileitem=FileItem(
|
||||||
storage="local",
|
storage="local",
|
||||||
path=file_path.as_posix()
|
path=file_path.as_posix()
|
||||||
+ ("/" if file_path.is_dir() else ""),
|
+ ("/" if file_path.is_dir() else ""),
|
||||||
type="dir" if not file_path.is_file() else "file",
|
type="dir" if not file_path.is_file() else "file",
|
||||||
name=file_path.name,
|
name=file_path.name,
|
||||||
size=file_path.stat().st_size,
|
size=file_path.stat().st_size,
|
||||||
@@ -1498,10 +1598,10 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def __get_trans_fileitems(
|
def __get_trans_fileitems(
|
||||||
self,
|
self,
|
||||||
fileitem: FileItem,
|
fileitem: FileItem,
|
||||||
predicate: Optional[Callable[[FileItem, bool], bool]],
|
predicate: Optional[Callable[[FileItem, bool], bool]],
|
||||||
verify_file_exists: bool = True,
|
verify_file_exists: bool = True,
|
||||||
) -> List[Tuple[FileItem, bool]]:
|
) -> List[Tuple[FileItem, bool]]:
|
||||||
"""
|
"""
|
||||||
获取待整理文件项列表
|
获取待整理文件项列表
|
||||||
@@ -1541,7 +1641,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _apply_predicate(
|
def _apply_predicate(
|
||||||
file_item: FileItem, is_bluray_dir: bool
|
file_item: FileItem, is_bluray_dir: bool
|
||||||
) -> List[Tuple[FileItem, bool]]:
|
) -> List[Tuple[FileItem, bool]]:
|
||||||
if predicate is None or predicate(file_item, is_bluray_dir):
|
if predicate is None or predicate(file_item, is_bluray_dir):
|
||||||
return [(file_item, is_bluray_dir)]
|
return [(file_item, is_bluray_dir)]
|
||||||
@@ -1586,10 +1686,10 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_download_history(
|
def _resolve_download_history(
|
||||||
downloadhis: DownloadHistoryOper,
|
downloadhis: DownloadHistoryOper,
|
||||||
file_path: Path,
|
file_path: Path,
|
||||||
bluray_dir: bool = False,
|
bluray_dir: bool = False,
|
||||||
download_hash: Optional[str] = None,
|
download_hash: Optional[str] = None,
|
||||||
) -> Optional[DownloadHistory]:
|
) -> Optional[DownloadHistory]:
|
||||||
"""
|
"""
|
||||||
根据显式 hash、文件路径或种子根目录回查下载历史。
|
根据显式 hash、文件路径或种子根目录回查下载历史。
|
||||||
@@ -1624,26 +1724,26 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def do_transfer(
|
def do_transfer(
|
||||||
self,
|
self,
|
||||||
fileitem: FileItem,
|
fileitem: FileItem,
|
||||||
meta: MetaBase = None,
|
meta: MetaBase = None,
|
||||||
mediainfo: MediaInfo = None,
|
mediainfo: MediaInfo = None,
|
||||||
target_directory: TransferDirectoryConf = None,
|
target_directory: TransferDirectoryConf = None,
|
||||||
target_storage: Optional[str] = None,
|
target_storage: Optional[str] = None,
|
||||||
target_path: Path = None,
|
target_path: Path = None,
|
||||||
transfer_type: Optional[str] = None,
|
transfer_type: Optional[str] = None,
|
||||||
scrape: Optional[bool] = None,
|
scrape: Optional[bool] = None,
|
||||||
library_type_folder: Optional[bool] = None,
|
library_type_folder: Optional[bool] = None,
|
||||||
library_category_folder: Optional[bool] = None,
|
library_category_folder: Optional[bool] = None,
|
||||||
season: Optional[int] = None,
|
season: Optional[int] = None,
|
||||||
epformat: EpisodeFormat = None,
|
epformat: EpisodeFormat = None,
|
||||||
min_filesize: Optional[int] = 0,
|
min_filesize: Optional[int] = 0,
|
||||||
downloader: Optional[str] = None,
|
downloader: Optional[str] = None,
|
||||||
download_hash: Optional[str] = None,
|
download_hash: Optional[str] = None,
|
||||||
force: Optional[bool] = False,
|
force: Optional[bool] = False,
|
||||||
background: Optional[bool] = True,
|
background: Optional[bool] = True,
|
||||||
manual: Optional[bool] = False,
|
manual: Optional[bool] = False,
|
||||||
continue_callback: Callable = None,
|
continue_callback: Callable = None,
|
||||||
) -> Tuple[bool, str]:
|
) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
执行一个复杂目录的整理操作
|
执行一个复杂目录的整理操作
|
||||||
@@ -1690,7 +1790,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
# 汇总错误信息
|
# 汇总错误信息
|
||||||
err_msgs: List[str] = []
|
err_msgs: List[str] = []
|
||||||
|
|
||||||
def _filter(file_item: FileItem, is_bluray_dir: bool) -> bool:
|
def _filter(item: FileItem, is_bluray_dir: bool) -> bool:
|
||||||
"""
|
"""
|
||||||
过滤文件项
|
过滤文件项
|
||||||
|
|
||||||
@@ -1699,30 +1799,30 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
if continue_callback and not continue_callback():
|
if continue_callback and not continue_callback():
|
||||||
raise OperationInterrupted()
|
raise OperationInterrupted()
|
||||||
# 有集自定义格式,过滤文件
|
# 有集自定义格式,过滤文件
|
||||||
if formaterHandler and not formaterHandler.match(file_item.name):
|
if formaterHandler and not formaterHandler.match(item.name):
|
||||||
return False
|
return False
|
||||||
# 过滤后缀和大小(蓝光目录、附加文件不过滤)
|
# 过滤后缀和大小(蓝光目录、附加文件不过滤)
|
||||||
if (
|
if (
|
||||||
not is_bluray_dir
|
not is_bluray_dir
|
||||||
and not self.__is_subtitle_file(file_item)
|
and not self.__is_subtitle_file(item)
|
||||||
and not self.__is_audio_file(file_item)
|
and not self.__is_audio_file(item)
|
||||||
):
|
):
|
||||||
if not self.__is_media_file(file_item):
|
if not self.__is_media_file(item):
|
||||||
return False
|
return False
|
||||||
if not self.__is_allow_filesize(file_item, min_filesize):
|
if not self.__is_allow_filesize(item, min_filesize):
|
||||||
return False
|
return False
|
||||||
# 回收站及隐藏的文件不处理
|
# 回收站及隐藏的文件不处理
|
||||||
if (
|
if (
|
||||||
file_item.path.find("/@Recycle/") != -1
|
item.path.find("/@Recycle/") != -1
|
||||||
or file_item.path.find("/#recycle/") != -1
|
or item.path.find("/#recycle/") != -1
|
||||||
or file_item.path.find("/.") != -1
|
or item.path.find("/.") != -1
|
||||||
or file_item.path.find("/@eaDir") != -1
|
or item.path.find("/@eaDir") != -1
|
||||||
):
|
):
|
||||||
logger.debug(f"{file_item.path} 是回收站或隐藏的文件")
|
logger.debug(f"{item.path} 是回收站或隐藏的文件")
|
||||||
return False
|
return False
|
||||||
# 整理屏蔽词不处理
|
# 整理屏蔽词不处理
|
||||||
if self._is_blocked_by_exclude_words(
|
if self._is_blocked_by_exclude_words(
|
||||||
file_item.path, transfer_exclude_words
|
item.path, transfer_exclude_words
|
||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
@@ -1929,11 +2029,11 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
return all_success, error_msg
|
return all_success, error_msg
|
||||||
|
|
||||||
def remote_transfer(
|
def remote_transfer(
|
||||||
self,
|
self,
|
||||||
arg_str: str,
|
arg_str: str,
|
||||||
channel: MessageChannel,
|
channel: MessageChannel,
|
||||||
userid: Union[str, int] = None,
|
userid: Union[str, int] = None,
|
||||||
source: Optional[str] = None,
|
source: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
远程重新整理,参数 历史记录ID TMDBID|类型
|
远程重新整理,参数 历史记录ID TMDBID|类型
|
||||||
@@ -1945,7 +2045,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
channel=channel,
|
channel=channel,
|
||||||
source=source,
|
source=source,
|
||||||
title="请输入正确的命令格式:/redo [id] 或 /redo [id] [tmdbid/豆瓣id]|[类型],"
|
title="请输入正确的命令格式:/redo [id] 或 /redo [id] [tmdbid/豆瓣id]|[类型],"
|
||||||
"[id] 为整理记录编号",
|
"[id] 为整理记录编号",
|
||||||
userid=userid,
|
userid=userid,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -2005,7 +2105,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def build_failed_transfer_buttons(
|
def build_failed_transfer_buttons(
|
||||||
history_id: Optional[int],
|
history_id: Optional[int],
|
||||||
) -> Optional[List[List[dict]]]:
|
) -> Optional[List[List[dict]]]:
|
||||||
"""
|
"""
|
||||||
构建整理失败通知的操作按钮。
|
构建整理失败通知的操作按钮。
|
||||||
@@ -2029,7 +2129,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
return self.__re_transfer(logid=history_id)
|
return self.__re_transfer(logid=history_id)
|
||||||
|
|
||||||
def __re_transfer(
|
def __re_transfer(
|
||||||
self, logid: int, mtype: MediaType = None, mediaid: Optional[str] = None
|
self, logid: int, mtype: MediaType = None, mediaid: Optional[str] = None
|
||||||
) -> Tuple[bool, str]:
|
) -> Tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
根据历史记录,重新识别整理,只支持简单条件
|
根据历史记录,重新识别整理,只支持简单条件
|
||||||
@@ -2088,25 +2188,25 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
def manual_transfer(
|
def manual_transfer(
|
||||||
self,
|
self,
|
||||||
fileitem: FileItem,
|
fileitem: FileItem,
|
||||||
target_storage: Optional[str] = None,
|
target_storage: Optional[str] = None,
|
||||||
target_path: Path = None,
|
target_path: Path = None,
|
||||||
tmdbid: Optional[int] = None,
|
tmdbid: Optional[int] = None,
|
||||||
doubanid: Optional[str] = None,
|
doubanid: Optional[str] = None,
|
||||||
mtype: MediaType = None,
|
mtype: MediaType = None,
|
||||||
season: Optional[int] = None,
|
season: Optional[int] = None,
|
||||||
episode_group: Optional[str] = None,
|
episode_group: Optional[str] = None,
|
||||||
transfer_type: Optional[str] = None,
|
transfer_type: Optional[str] = None,
|
||||||
epformat: EpisodeFormat = None,
|
epformat: EpisodeFormat = None,
|
||||||
min_filesize: Optional[int] = 0,
|
min_filesize: Optional[int] = 0,
|
||||||
scrape: Optional[bool] = None,
|
scrape: Optional[bool] = None,
|
||||||
library_type_folder: Optional[bool] = None,
|
library_type_folder: Optional[bool] = None,
|
||||||
library_category_folder: Optional[bool] = None,
|
library_category_folder: Optional[bool] = None,
|
||||||
force: Optional[bool] = False,
|
force: Optional[bool] = False,
|
||||||
background: Optional[bool] = False,
|
background: Optional[bool] = False,
|
||||||
downloader: Optional[str] = None,
|
downloader: Optional[str] = None,
|
||||||
download_hash: Optional[str] = None,
|
download_hash: Optional[str] = None,
|
||||||
) -> Tuple[bool, Union[str, list]]:
|
) -> Tuple[bool, Union[str, list]]:
|
||||||
"""
|
"""
|
||||||
手动整理,支持复杂条件,带进度显示
|
手动整理,支持复杂条件,带进度显示
|
||||||
@@ -2194,12 +2294,12 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
return state, errmsg
|
return state, errmsg
|
||||||
|
|
||||||
def send_transfer_message(
|
def send_transfer_message(
|
||||||
self,
|
self,
|
||||||
meta: MetaBase,
|
meta: MetaBase,
|
||||||
mediainfo: MediaInfo,
|
mediainfo: MediaInfo,
|
||||||
transferinfo: TransferInfo,
|
transferinfo: TransferInfo,
|
||||||
season_episode: Optional[str] = None,
|
season_episode: Optional[str] = None,
|
||||||
username: Optional[str] = None,
|
username: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
发送入库成功的消息
|
发送入库成功的消息
|
||||||
@@ -2237,7 +2337,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _can_delete_torrent(
|
def _can_delete_torrent(
|
||||||
self, download_hash: str, downloader: str, transfer_exclude_words
|
self, download_hash: str, downloader: str, transfer_exclude_words
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
检查是否可以删除种子文件
|
检查是否可以删除种子文件
|
||||||
@@ -2270,11 +2370,11 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
file_path = save_path / file.name
|
file_path = save_path / file.name
|
||||||
# 如果存在未被屏蔽的媒体文件,则不删除种子
|
# 如果存在未被屏蔽的媒体文件,则不删除种子
|
||||||
if (
|
if (
|
||||||
file_path.suffix in self._allowed_exts
|
file_path.suffix in self._allowed_exts
|
||||||
and not self._is_blocked_by_exclude_words(
|
and not self._is_blocked_by_exclude_words(
|
||||||
file_path.as_posix(), transfer_exclude_words
|
file_path.as_posix(), transfer_exclude_words
|
||||||
)
|
)
|
||||||
and file_path.exists()
|
and file_path.exists()
|
||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -506,7 +506,7 @@ class ConfigModel(BaseModel):
|
|||||||
# LLM模型名称
|
# LLM模型名称
|
||||||
LLM_MODEL: str = "deepseek-chat"
|
LLM_MODEL: str = "deepseek-chat"
|
||||||
# 思考模式/深度配置:off/auto/minimal/low/medium/high/max/xhigh
|
# 思考模式/深度配置:off/auto/minimal/low/medium/high/max/xhigh
|
||||||
LLM_THINKING_LEVEL: Optional[str] = 'off'
|
LLM_THINKING_LEVEL: Optional[str] = "off"
|
||||||
# LLM是否支持图片输入,开启后消息图片会按多模态输入发送给模型
|
# LLM是否支持图片输入,开启后消息图片会按多模态输入发送给模型
|
||||||
LLM_SUPPORT_IMAGE_INPUT: bool = True
|
LLM_SUPPORT_IMAGE_INPUT: bool = True
|
||||||
# LLM是否支持音频输入输出,开启后才会启用语音转写与语音回复
|
# LLM是否支持音频输入输出,开启后才会启用语音转写与语音回复
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
class DownloadHistory(BaseModel):
|
class DownloadHistory(BaseModel):
|
||||||
@@ -97,3 +97,7 @@ class TransferHistory(BaseModel):
|
|||||||
date: Optional[str] = None
|
date: Optional[str] = None
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class BatchTransferHistoryRedoRequest(BaseModel):
|
||||||
|
history_ids: list[int] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ class AgentInitializer:
|
|||||||
"""
|
"""
|
||||||
AI智能体初始化器
|
AI智能体初始化器
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
|
|
||||||
async def initialize(self) -> bool:
|
async def initialize(self) -> bool:
|
||||||
"""
|
"""
|
||||||
初始化AI智能体管理器
|
初始化AI智能体管理器
|
||||||
@@ -22,16 +22,16 @@ class AgentInitializer:
|
|||||||
if not settings.AI_AGENT_ENABLE:
|
if not settings.AI_AGENT_ENABLE:
|
||||||
logger.info("AI智能体功能未启用")
|
logger.info("AI智能体功能未启用")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
await agent_manager.initialize()
|
await agent_manager.initialize()
|
||||||
self._initialized = True
|
self._initialized = True
|
||||||
logger.info("AI智能体管理器初始化成功")
|
logger.info("AI智能体管理器初始化成功")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"AI智能体管理器初始化失败: {e}")
|
logger.error(f"AI智能体管理器初始化失败: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def cleanup(self) -> None:
|
async def cleanup(self) -> None:
|
||||||
"""
|
"""
|
||||||
清理AI智能体管理器
|
清理AI智能体管理器
|
||||||
@@ -39,11 +39,10 @@ class AgentInitializer:
|
|||||||
try:
|
try:
|
||||||
if not self._initialized:
|
if not self._initialized:
|
||||||
return
|
return
|
||||||
|
|
||||||
await agent_manager.close()
|
await agent_manager.close()
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
logger.info("AI智能体管理器已关闭")
|
logger.info("AI智能体管理器已关闭")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"关闭AI智能体管理器时发生错误: {e}")
|
logger.error(f"关闭AI智能体管理器时发生错误: {e}")
|
||||||
|
|
||||||
@@ -60,7 +59,7 @@ def init_agent():
|
|||||||
if not settings.AI_AGENT_ENABLE:
|
if not settings.AI_AGENT_ENABLE:
|
||||||
logger.info("AI智能体功能未启用")
|
logger.info("AI智能体功能未启用")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# 在新的事件循环中初始化AI智能体管理器
|
# 在新的事件循环中初始化AI智能体管理器
|
||||||
def run_init():
|
def run_init():
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
@@ -77,13 +76,13 @@ def init_agent():
|
|||||||
return False
|
return False
|
||||||
finally:
|
finally:
|
||||||
loop.close()
|
loop.close()
|
||||||
|
|
||||||
# 在后台线程中初始化
|
# 在后台线程中初始化
|
||||||
init_thread = threading.Thread(target=run_init, daemon=True)
|
init_thread = threading.Thread(target=run_init, daemon=True)
|
||||||
init_thread.start()
|
init_thread.start()
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"初始化AI智能体时发生错误: {e}")
|
logger.error(f"初始化AI智能体时发生错误: {e}")
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -77,6 +77,21 @@ class TestAgentPromptStyle(unittest.TestCase):
|
|||||||
self.assertIn("Total failed records: 1", message)
|
self.assertIn("Total failed records: 1", message)
|
||||||
self.assertIn("history_id=7", message)
|
self.assertIn("history_id=7", message)
|
||||||
|
|
||||||
|
def test_render_batch_manual_transfer_redo_message(self):
|
||||||
|
message = prompt_manager.render_system_task_message(
|
||||||
|
"batch_manual_transfer_redo",
|
||||||
|
template_context={
|
||||||
|
"history_ids_csv": "7, 8",
|
||||||
|
"history_count": 2,
|
||||||
|
"records_context": "Record #7:\n- Source path: /downloads/a.mkv",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("[System Task - Batch Manual Transfer Re-Organize]", message)
|
||||||
|
self.assertIn("History IDs: 7, 8", message)
|
||||||
|
self.assertIn("Total records: 2", message)
|
||||||
|
self.assertIn("Record #7:", message)
|
||||||
|
|
||||||
def test_missing_system_task_template_context_raises_clear_error(self):
|
def test_missing_system_task_template_context_raises_clear_error(self):
|
||||||
with self.assertRaises(PromptConfigError):
|
with self.assertRaises(PromptConfigError):
|
||||||
prompt_manager.render_system_task_message("transfer_failed_retry")
|
prompt_manager.render_system_task_message("transfer_failed_retry")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import unittest
|
import unittest
|
||||||
import sys
|
import sys
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
sys.modules.setdefault("qbittorrentapi", ModuleType("qbittorrentapi"))
|
sys.modules.setdefault("qbittorrentapi", ModuleType("qbittorrentapi"))
|
||||||
@@ -74,9 +75,33 @@ class TestTransferFailedRetryButtons(unittest.TestCase):
|
|||||||
|
|
||||||
def test_transfer_ai_retry_callback_schedules_agent_takeover(self):
|
def test_transfer_ai_retry_callback_schedules_agent_takeover(self):
|
||||||
chain = MessageChain()
|
chain = MessageChain()
|
||||||
|
history = SimpleNamespace(
|
||||||
|
id=34,
|
||||||
|
status=False,
|
||||||
|
title="Test Show",
|
||||||
|
type="电视剧",
|
||||||
|
category=None,
|
||||||
|
year="2024",
|
||||||
|
seasons="S01",
|
||||||
|
episodes="E01",
|
||||||
|
src="/downloads/Test.Show.S01E01.mkv",
|
||||||
|
src_storage="local",
|
||||||
|
src_fileitem={"path": "/downloads/Test.Show.S01E01.mkv"},
|
||||||
|
dest=None,
|
||||||
|
dest_storage=None,
|
||||||
|
mode="copy",
|
||||||
|
tmdbid=123,
|
||||||
|
doubanid=None,
|
||||||
|
errmsg="未识别到媒体信息",
|
||||||
|
)
|
||||||
|
|
||||||
with patch.object(settings, "AI_AGENT_ENABLE", True):
|
with patch.object(settings, "AI_AGENT_ENABLE", True):
|
||||||
with patch("app.chain.message.asyncio.run_coroutine_threadsafe") as run_task:
|
with patch(
|
||||||
|
"app.chain.message.TransferHistoryOper"
|
||||||
|
) as history_oper_cls, patch(
|
||||||
|
"app.chain.message.asyncio.run_coroutine_threadsafe"
|
||||||
|
) as run_task:
|
||||||
|
history_oper_cls.return_value.get.return_value = history
|
||||||
with patch.object(chain, "post_message") as post_message:
|
with patch.object(chain, "post_message") as post_message:
|
||||||
chain._handle_callback(
|
chain._handle_callback(
|
||||||
text="CALLBACK:transfer_ai_retry_34",
|
text="CALLBACK:transfer_ai_retry_34",
|
||||||
|
|||||||
Reference in New Issue
Block a user