diff --git a/src-tauri/src/commands/maa_agent.rs b/src-tauri/src/commands/maa_agent.rs index a884a63b..24b4ff8d 100644 --- a/src-tauri/src/commands/maa_agent.rs +++ b/src-tauri/src/commands/maa_agent.rs @@ -496,6 +496,7 @@ pub async fn start_tasks_impl( cwd: String, tcp_compat_mode: bool, pi_envs: Option>, + reset_state: bool, ) -> Result, String> { info!("start_tasks_impl called"); @@ -700,19 +701,30 @@ pub async fn start_tasks_impl( task_ids.len() ); - // 初始化后端 TaskRunState(单一真相来源)并缓存 task_ids - debug!("[start_tasks] Initializing TaskRunState..."); + // 初始化/追加后端 TaskRunState(单一真相来源)并缓存 task_ids + debug!( + "[start_tasks] Updating TaskRunState (reset_state={})...", + reset_state + ); { let mut instances = maa_state.instances.lock().map_err(|e| e.to_string())?; if let Some(instance) = instances.get_mut(&instance_id) { - instance.task_ids = task_ids.clone(); + if reset_state { + // 首批:重置任务运行状态 + instance.task_ids = task_ids.clone(); + let state = &mut instance.task_run_state; + state.statuses.clear(); + state.mappings.clear(); + state.pending_task_ids = task_ids.clone(); + state.current_task_index = 0; + } else { + // 追加批次(分段运行):保留已完成状态,仅追加新任务 + instance.task_ids.extend(task_ids.iter().copied()); + let state = &mut instance.task_run_state; + state.pending_task_ids.extend(task_ids.iter().copied()); + } - // 重置任务运行状态 let state = &mut instance.task_run_state; - state.statuses.clear(); - state.mappings.clear(); - state.pending_task_ids = task_ids.clone(); - state.current_task_index = 0; state.overall_status = Some("Running".to_string()); // 建立 maaTaskId -> selectedTaskId 映射,并将有映射的任务初始化为 "pending" @@ -724,7 +736,7 @@ pub async fn start_tasks_impl( } } } - debug!("[start_tasks] TaskRunState initialized"); + debug!("[start_tasks] TaskRunState updated"); info!( "[start_tasks] start_tasks_impl completed successfully, returning {} task_ids", @@ -748,6 +760,7 @@ pub async fn maa_start_tasks( cwd: String, tcp_compat_mode: bool, pi_envs: Option>, + reset_state: Option, ) -> Result, String> { start_tasks_impl( app, @@ -758,6 +771,7 @@ pub async fn maa_start_tasks( cwd, tcp_compat_mode, pi_envs, + reset_state.unwrap_or(true), ) .await } diff --git a/src-tauri/src/commands/maa_core.rs b/src-tauri/src/commands/maa_core.rs index 494ac6c4..6b8d1e00 100644 --- a/src-tauri/src/commands/maa_core.rs +++ b/src-tauri/src/commands/maa_core.rs @@ -575,6 +575,13 @@ pub async fn connect_controller_impl( let uuid_str = uuid.as_deref().unwrap_or(""); Controller::new_playcover(address, uuid_str).map_err(|e| e.to_string())? } + ControllerConfig::Dummy { + display_short_side, .. + } => { + let short = display_short_side.unwrap_or(720); + Controller::new_custom(crate::dummy_controller::DummyController::new(short)) + .map_err(|e| e.to_string())? + } ControllerConfig::Gamepad { handle, gamepad_type, @@ -619,6 +626,9 @@ pub async fn connect_controller_impl( } | ControllerConfig::PlayCover { display_short_side, .. + } + | ControllerConfig::Dummy { + display_short_side, .. } => display_short_side.unwrap_or(720), }; diff --git a/src-tauri/src/commands/types.rs b/src-tauri/src/commands/types.rs index ea06225a..f045b9e1 100644 --- a/src-tauri/src/commands/types.rs +++ b/src-tauri/src/commands/types.rs @@ -104,6 +104,12 @@ pub enum ControllerConfig { #[serde(default)] display_short_side: Option, }, + /// 空 controller:截图返回纯黑图、输入 no-op。 + /// 用于在游戏未连接/已关闭时执行不依赖游戏画面的 MXU 特殊任务。 + Dummy { + #[serde(default)] + display_short_side: Option, + }, } /// 连接状态 diff --git a/src-tauri/src/dummy_controller.rs b/src-tauri/src/dummy_controller.rs new file mode 100644 index 00000000..3638a325 --- /dev/null +++ b/src-tauri/src/dummy_controller.rs @@ -0,0 +1,145 @@ +//! MXU 空 controller(Dummy Controller) +//! +//! 用于在游戏未连接 / 已被关闭时仍能执行不依赖游戏画面的 MXU 特殊任务 +//! (Notify / Power / KillProc / Sleep 等)。 +//! +//! MaaFramework 任务主循环在任何识别(含 DirectHit)前都会先 `screencap()`, +//! 图像为空就跳过识别、不执行动作。真实 controller 在游戏窗口消失后截图为空, +//! 会导致后续特殊任务卡死/超时。本 controller 的 `screencap` 始终返回一张纯黑图, +//! 让主循环越过该门槛,从而使 DirectHit + Custom Action 正常执行。 +//! +//! 所有输入/应用控制均为 no-op:空 controller 只承载不需要真实画面/输入的任务。 + +use std::io::Write; + +use flate2::write::ZlibEncoder; +use flate2::Compression; +use flate2::Crc; +use log::debug; +use maa_framework::custom_controller::CustomControllerCallback; + +/// 返回纯黑图、输入全部 no-op 的自定义 controller。 +pub struct DummyController { + /// 预先编码好的纯黑 PNG 图像。 + png: Vec, +} + +impl DummyController { + /// 按目标短边构建一张 16:9 的纯黑图(默认 1280x720)。 + pub fn new(display_short_side: i32) -> Self { + let short: u32 = if display_short_side <= 0 { + 720 + } else { + display_short_side as u32 + }; + let height = short.max(1); + let width = ((u64::from(height) * 16) / 9).max(1) as u32; + let png = build_black_png(width, height); + debug!( + "[MXU_DUMMY] dummy controller created, image {}x{}, png {} bytes", + width, + height, + png.len() + ); + Self { png } + } +} + +impl CustomControllerCallback for DummyController { + fn connect(&self) -> bool { + true + } + + fn connected(&self) -> bool { + true + } + + fn request_uuid(&self) -> Option { + Some("MXU-DUMMY".to_string()) + } + + fn screencap(&self) -> Option> { + Some(self.png.clone()) + } + + fn get_info(&self) -> String { + "{\"type\":\"MXU_DUMMY\"}".to_string() + } + + // 以下输入/应用控制全部 no-op:空 controller 不承载需要真实交互的任务。 + fn click(&self, _x: i32, _y: i32) -> bool { + true + } + + fn swipe(&self, _x1: i32, _y1: i32, _x2: i32, _y2: i32, _duration: i32) -> bool { + true + } + + fn click_key(&self, _keycode: i32) -> bool { + true + } + + fn input_text(&self, _text: &str) -> bool { + true + } + + fn start_app(&self, _intent: &str) -> bool { + true + } + + fn stop_app(&self, _intent: &str) -> bool { + true + } +} + +/// 构建一张 `width`x`height` 的纯黑 PNG(8-bit RGB)。 +/// +/// 不引入额外图像库:原始扫描线全 0(黑色),用 flate2 完成 zlib 压缩与 CRC32。 +fn build_black_png(width: u32, height: u32) -> Vec { + // 每行 = 1 字节 filter(0) + width*3 字节 RGB(全 0 即黑色) + let row_len = 1 + (width as usize) * 3; + let raw = vec![0u8; row_len * height as usize]; + + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::fast()); + encoder + .write_all(&raw) + .expect("zlib write of dummy image should not fail"); + let idat = encoder + .finish() + .expect("zlib finish of dummy image should not fail"); + + let mut png = Vec::new(); + // PNG 签名 + png.extend_from_slice(&[137, 80, 78, 71, 13, 10, 26, 10]); + + // IHDR + let mut ihdr = Vec::with_capacity(13); + ihdr.extend_from_slice(&width.to_be_bytes()); + ihdr.extend_from_slice(&height.to_be_bytes()); + ihdr.push(8); // bit depth + ihdr.push(2); // color type: Truecolor(RGB) + ihdr.push(0); // compression method + ihdr.push(0); // filter method + ihdr.push(0); // interlace method + write_png_chunk(&mut png, b"IHDR", &ihdr); + + // IDAT + write_png_chunk(&mut png, b"IDAT", &idat); + + // IEND + write_png_chunk(&mut png, b"IEND", &[]); + + png +} + +/// 写入一个 PNG chunk:长度(BE) + 类型 + 数据 + CRC32(类型+数据, BE)。 +fn write_png_chunk(out: &mut Vec, chunk_type: &[u8; 4], data: &[u8]) { + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(chunk_type); + out.extend_from_slice(data); + + let mut crc = Crc::new(); + crc.update(chunk_type); + crc.update(data); + out.extend_from_slice(&crc.sum().to_be_bytes()); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4a76d36e..573c99a0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ pub mod commands; +mod dummy_controller; mod mxu_actions; pub mod screenshot_service; mod tray; diff --git a/src-tauri/src/web_server.rs b/src-tauri/src/web_server.rs index 54666e28..e4966475 100644 --- a/src-tauri/src/web_server.rs +++ b/src-tauri/src/web_server.rs @@ -889,6 +889,8 @@ struct StartTasksRequest { tcp_compat_mode: Option, #[serde(default)] pi_envs: Option>, + #[serde(default)] + reset_state: Option, } /// POST /api/maa/instances/:id/tasks/start @@ -923,6 +925,7 @@ async fn handle_start_tasks( cwd, body.tcp_compat_mode.unwrap_or(false), body.pi_envs, + body.reset_state.unwrap_or(true), ) .await { diff --git a/src/components/DashboardView.tsx b/src/components/DashboardView.tsx index bfde7acf..9f4f574a 100644 --- a/src/components/DashboardView.tsx +++ b/src/components/DashboardView.tsx @@ -33,6 +33,7 @@ import type { TaskConfig } from '@/types/maa'; import { normalizeAgentConfigs } from '@/types/interface'; import { getInterfaceLangKey } from '@/i18n'; import { getMxuSpecialTask } from '@/types/specialTasks'; +import { splitTasksIntoThreeSegments } from '@/utils/taskSegmentation'; import { startGlobalCallbackListener } from '@/components/connection/callbackCache'; import { stopInstanceTasks } from '@/services/taskStopService'; import { buildPiEnvVars } from '@/utils/piEnv'; @@ -71,6 +72,7 @@ function InstanceCard({ instanceId, instanceName, isActive, onSelect }: Instance basePath, registerTaskIdName, registerEntryTaskName, + registerCtrlIdName, screenshotFrameRate, setShowAddTaskPanel, tcpCompatMode, @@ -214,45 +216,61 @@ function InstanceCard({ instanceId, instanceName, isActive, onSelect }: Instance setIsStarting(true); try { - log.info(`[${instanceName}] 开始执行任务, 数量:`, enabledTasks.length); - - // 构建任务配置列表 - const taskConfigs: TaskConfig[] = []; - for (const selectedTask of enabledTasks) { - // 先检查是否是 MXU 特殊任务 - const specialTask = getMxuSpecialTask(selectedTask.taskName); - const taskDef = - specialTask?.taskDef || - projectInterface?.task.find((t) => t.name === selectedTask.taskName); - if (!taskDef) continue; - - taskConfigs.push({ - entry: taskDef.entry, - pipeline_override: generateTaskPipelineOverride( + const runnableTasks = enabledTasks + .map((selectedTask) => { + const specialTask = getMxuSpecialTask(selectedTask.taskName); + const taskDef = + specialTask?.taskDef || + projectInterface?.task.find((t) => t.name === selectedTask.taskName); + if (!taskDef) return null; + return { + taskName: selectedTask.taskName, selectedTask, - projectInterface, - currentControllerName, - currentResourceName, - ), - // 传递 selectedTaskId,后端用于建立 maaTaskId -> selectedTaskId 映射 - selected_task_id: selectedTask.id, - }); - // MXU 特殊任务的 label 是 MXU i18n key,需要用 t() 翻译 - const taskDisplayName = - selectedTask.customName || - (specialTask && taskDef.label - ? t(taskDef.label) - : resolveI18nText(taskDef.label, translations)) || - selectedTask.taskName; - registerEntryTaskName(taskDef.entry, taskDisplayName); - } + taskDef, + specialTask, + }; + }) + .filter((item): item is NonNullable => item !== null); + + const { leading, middle, trailing } = splitTasksIntoThreeSegments(runnableTasks); + const primaryBatch = [...leading, ...middle]; + const hasTrailingBatch = trailing.length > 0; + + log.info( + `[${instanceName}] 开始执行任务, 数量: ${runnableTasks.length}, 分段: ${[ + `primary:${primaryBatch.length}`, + `trailing:${trailing.length}`, + ].join(', ')}`, + ); - if (taskConfigs.length === 0) { + if (runnableTasks.length === 0) { log.warn(`[${instanceName}] 没有可执行的任务`); setIsStarting(false); return; } + const buildTaskConfigs = (batchTasks: typeof runnableTasks): TaskConfig[] => + batchTasks.map(({ selectedTask, taskDef, specialTask }) => { + const taskDisplayName = + selectedTask.customName || + (specialTask && taskDef.label + ? t(taskDef.label) + : resolveI18nText(taskDef.label, translations)) || + selectedTask.taskName; + registerEntryTaskName(taskDef.entry, taskDisplayName); + + return { + entry: taskDef.entry, + pipeline_override: generateTaskPipelineOverride( + selectedTask, + projectInterface, + currentControllerName, + currentResourceName, + ), + selected_task_id: selectedTask.id, + }; + }); + // 准备 Agent 配置(支持单个或多个 Agent) const agentConfigs = normalizeAgentConfigs(projectInterface?.agent); @@ -275,35 +293,55 @@ function InstanceCard({ instanceId, instanceName, isActive, onSelect }: Instance // 任务可能在 startTasks 返回前就瞬时结束,先启动全局回调缓存再提交。 await startGlobalCallbackListener(); - // 启动任务 - const taskIds = await maaService.startTasks( - instanceId, - taskConfigs, - agentConfigs, - basePath, - tcpCompatMode, - piEnvs, - ); - - log.info(`[${instanceName}] 任务已提交, task_ids:`, taskIds); + const startedTaskIds: number[] = []; + const runBatch = async ( + batchTasks: typeof runnableTasks, + resetState: boolean, + useDummyController: boolean, + ) => { + if (batchTasks.length === 0) return [] as number[]; + if (useDummyController) { + log.info(`[${instanceName}] 收尾特殊任务切换为 Dummy Controller`); + const dummyCtrlId = await maaService.connectController(instanceId, { + type: 'Dummy', + display_short_side: undefined, + }); + registerCtrlIdName(instanceId, dummyCtrlId, 'MXU Dummy Controller', 'device'); + setInstanceConnectionStatus(instanceId, 'Connected'); + } - // 注册 task_id 与任务名的映射(用于日志显示),后端管理状态 - taskIds.forEach((maaTaskId, index) => { - if (enabledTasks[index]) { - // MXU 特殊任务的 label 需要用 t() 翻译 - const specialTask = getMxuSpecialTask(enabledTasks[index].taskName); - const taskDef = - specialTask?.taskDef || - projectInterface?.task.find((t) => t.name === enabledTasks[index].taskName); + const batchTaskIds = await maaService.startTasks( + instanceId, + buildTaskConfigs(batchTasks), + agentConfigs, + basePath, + tcpCompatMode, + piEnvs, + resetState, + ); + + batchTaskIds.forEach((maaTaskId, index) => { + const runnable = batchTasks[index]; + if (!runnable) return; + const { selectedTask, taskDef, specialTask } = runnable; const taskDisplayName = - enabledTasks[index].customName || - (specialTask && taskDef?.label + selectedTask.customName || + (specialTask && taskDef.label ? t(taskDef.label) - : resolveI18nText(taskDef?.label, translations)) || - enabledTasks[index].taskName; + : resolveI18nText(taskDef.label, translations)) || + selectedTask.taskName; registerTaskIdName(maaTaskId, taskDisplayName); - } - }); + }); + + return batchTaskIds; + }; + + startedTaskIds.push(...(await runBatch(primaryBatch, true, false))); + if (hasTrailingBatch) { + startedTaskIds.push(...(await runBatch(trailing, false, true))); + } + + log.info(`[${instanceName}] 任务已提交, task_ids:`, startedTaskIds); setIsStarting(false); } catch (err) { diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 59fb2229..22e53255 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -15,7 +15,8 @@ import { isTaskCompatible } from '@/stores/helpers'; import { maaService } from '@/services/maaService'; import clsx from 'clsx'; import { loggers, generateTaskPipelineOverride, computeResourcePaths } from '@/utils'; -import { getMxuSpecialTask } from '@/types/specialTasks'; +import { getMxuSpecialTask, shouldSkipMxuScreenshot } from '@/types/specialTasks'; +import { splitTasksIntoThreeSegments } from '@/utils/taskSegmentation'; import type { TaskConfig, ControllerConfig } from '@/types/maa'; import { normalizeAgentConfigs } from '@/types/interface'; import { parseWin32ScreencapMethod, parseWin32InputMethod } from '@/types/maa'; @@ -317,6 +318,14 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr savedDevice.wlrSocketPath || savedDevice.playcoverAddress), ); + const hasVisualTasks = compatibleTasks.some((task) => !shouldSkipMxuScreenshot(task.taskName)); + const shouldUseDummyController = !hasVisualTasks; + + if (shouldUseDummyController) { + log.info(`实例 ${targetInstance.name}: 仅包含非视觉特殊任务,跳过截图/识别流程`); + } + + const canUseSavedDevice = hasSavedDevice && savedDevice && !shouldUseDummyController; let isTargetConnected = instanceConnectionStatus[targetId] === 'Connected'; const isTargetResourceLoaded = instanceResourceLoaded[targetId] || false; @@ -325,7 +334,8 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr const canStartTask = (isTargetConnected && isTargetResourceLoaded) || (hasSavedDevice && resource) || - (controller && resource); + (controller && resource) || + (shouldUseDummyController && resource); if (!canStartTask) { log.warn(`实例 ${targetInstance.name} 无法启动:未连接且没有可用的控制器或资源配置`); @@ -560,7 +570,7 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr } // 查询后端真实连接状态,纠正前端可能过时的缓存 - if (isTargetConnected && !needsReconnect) { + if (isTargetConnected && !needsReconnect && !shouldUseDummyController) { const backendState = await maaService.getInstanceState(targetId); if (!backendState || backendState.connectionStatus !== 'Connected') { log.warn( @@ -572,8 +582,8 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr } // 如果未连接(或需要重连),尝试自动连接 - if ((!isTargetConnected || needsReconnect) && controller) { - const controllerType = controller.type; + if (!isTargetConnected || needsReconnect || shouldUseDummyController) { + const controllerType = controller?.type; await ensureMaaInitialized(); await maaService.createInstance(targetId).catch((err) => { @@ -584,7 +594,7 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr let deviceName = ''; let targetType: 'device' | 'window' = 'device'; - if (hasSavedDevice && savedDevice) { + if (canUseSavedDevice && savedDevice && controllerType) { // 有保存的设备配置,按名称精确匹配 log.info(`实例 ${targetInstance.name}: 自动连接已保存的设备...`); onPhaseChange?.('searching'); @@ -662,7 +672,7 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr deviceName = savedDevice.playcoverAddress; targetType = 'device'; } - } else { + } else if (!shouldUseDummyController && controllerType) { // 没有保存的设备配置,自动搜索并连接第一个结果 log.info(`实例 ${targetInstance.name}: 自动搜索设备并连接...`); onPhaseChange?.('searching'); @@ -773,6 +783,21 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr } } + if (!shouldUseDummyController && !config) { + log.warn(`实例 ${targetInstance.name}: 无法构建控制器配置`); + return false; + } + + if (shouldUseDummyController) { + config = { + type: 'Dummy', + display_short_side: controller?.display_short_side, + }; + deviceName = 'MXU Dummy Controller'; + targetType = 'device'; + log.info(`实例 ${targetInstance.name}: 使用 Dummy Controller 执行非视觉任务`); + } + if (!config) { log.warn(`实例 ${targetInstance.name}: 无法构建控制器配置`); return false; @@ -980,6 +1005,7 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr // 构建可运行任务列表(排除无法找到定义的任务) // 这确保了 taskConfigs、taskIds 和 runnableTasks 的索引对齐 interface RunnableTask { + taskName: string; selectedTask: (typeof compatibleTasks)[0]; taskDef: NonNullable>['taskDef'] | TaskItem; specialTask: ReturnType; @@ -994,7 +1020,12 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr log.warn(`跳过任务 ${selectedTask.taskName}: 未找到任务定义`); continue; } - runnableTasks.push({ selectedTask, taskDef, specialTask }); + runnableTasks.push({ + taskName: selectedTask.taskName, + selectedTask, + taskDef, + specialTask, + }); } if (runnableTasks.length === 0) { @@ -1002,13 +1033,19 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr return false; } - log.info(`实例 ${targetInstance.name}: 开始执行任务, 数量:`, runnableTasks.length); + const { leading, middle, trailing } = splitTasksIntoThreeSegments(runnableTasks); + const primaryBatch = [...leading, ...middle]; + const hasTrailingBatch = trailing.length > 0; + + log.info( + `实例 ${targetInstance.name}: 开始执行任务, 数量: ${runnableTasks.length}, 分段: ${[ + `primary:${primaryBatch.length}`, + `trailing:${trailing.length}`, + ].join(', ')}`, + ); - // 构建任务配置列表,同时预注册 entry -> taskName 映射(解决时序问题) - const taskConfigs: TaskConfig[] = runnableTasks.map( - ({ selectedTask, taskDef, specialTask }) => { - // 预注册 entry -> taskName 映射,确保回调时能找到任务名 - // MXU 特殊任务的 label 是 MXU i18n key(如 'specialTask.sleep.label'),需要用 t() 翻译 + const buildTaskConfigs = (batchTasks: RunnableTask[]): TaskConfig[] => + batchTasks.map(({ selectedTask, taskDef, specialTask }) => { const taskDisplayName = selectedTask.customName || (specialTask && taskDef.label @@ -1022,14 +1059,60 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr pipeline_override: generateTaskPipelineOverride( selectedTask, projectInterface, - controllerName, - resourceName, + currentControllerName, + currentResourceName, ), - // 传递 selectedTaskId,后端用于建立 maaTaskId -> selectedTaskId 映射 selected_task_id: selectedTask.id, }; - }, - ); + }); + + const runTaskBatch = async ( + batchTasks: RunnableTask[], + resetState: boolean, + batchName: string, + connectDummyController: boolean = false, + ) => { + if (batchTasks.length === 0) { + return [] as number[]; + } + + if (connectDummyController) { + log.info(`实例 ${targetInstance.name}: ${batchName}段切换为 Dummy Controller`); + const dummyCtrlId = await maaService.connectController(targetId, { + type: 'Dummy', + display_short_side: undefined, + }); + registerCtrlIdName(targetId, dummyCtrlId, 'MXU Dummy Controller', 'device'); + } + + const batchTaskIds = await maaService.startTasks( + targetId, + buildTaskConfigs(batchTasks), + agentConfigs, + basePath, + tcpCompatMode, + piEnvs, + resetState, + ); + + log.info(`实例 ${targetInstance.name}: ${batchName}任务已提交, task_ids:`, batchTaskIds); + + batchTaskIds.forEach((maaTaskId, index) => { + const runnable = batchTasks[index]; + if (runnable) { + const { selectedTask, taskDef, specialTask } = runnable; + const taskDisplayName = + selectedTask.customName || + (specialTask && taskDef.label + ? t(taskDef.label) + : resolveI18nText(taskDef.label, translations)) || + selectedTask.taskName; + registerTaskIdName(maaTaskId, taskDisplayName); + } + }); + + return batchTaskIds; + }; // 准备 Agent 配置(支持单个或多个 Agent) const agentConfigs = normalizeAgentConfigs(projectInterface?.agent); @@ -1061,34 +1144,22 @@ export function Toolbar({ showAddPanel, onToggleAddPanel, className }: ToolbarPr // 任务可能在 startTasks 返回前就瞬时结束,先启动全局回调缓存再提交。 await startGlobalCallbackListener(); - // 启动任务 - const taskIds = await maaService.startTasks( - targetId, - taskConfigs, - agentConfigs, - basePath, - tcpCompatMode, - piEnvs, - ); + const startedTaskIds: number[] = []; - log.info(`实例 ${targetInstance.name}: 任务已提交, task_ids:`, taskIds); + const primaryTaskIds = await runTaskBatch(primaryBatch, true, hasTrailingBatch ? '前段' : '任务'); + startedTaskIds.push(...primaryTaskIds); - // 注册 task_id 与任务名的映射(用于日志显示),后端管理状态 - taskIds.forEach((maaTaskId, index) => { - const runnable = runnableTasks[index]; - if (runnable) { - const { selectedTask, taskDef, specialTask } = runnable; - // 注册 task_id 与任务名的映射(使用自定义名称或 label) - // MXU 特殊任务的 label 需要用 t() 翻译 - const taskDisplayName = - selectedTask.customName || - (specialTask && taskDef.label - ? t(taskDef.label) - : resolveI18nText(taskDef.label, translations)) || - selectedTask.taskName; - registerTaskIdName(maaTaskId, taskDisplayName); + if (hasTrailingBatch && primaryTaskIds.length > 0) { + const primaryResult = await maaService.waitForTasks(targetId, primaryTaskIds); + if (!primaryResult.allDone || primaryResult.stopped) { + log.warn(`实例 ${targetInstance.name}: 前段任务未正常结束,跳过收尾特殊任务`); + return false; } - }); + const trailingTaskIds = await runTaskBatch(trailing, false, '收尾', true); + startedTaskIds.push(...trailingTaskIds); + } + + log.info(`实例 ${targetInstance.name}: 任务已提交, task_ids:`, startedTaskIds); // 开始任务时折叠所有任务 collapseAllTasks(targetId, false); diff --git a/src/services/maaService.ts b/src/services/maaService.ts index 0e59fe71..cc1161ea 100644 --- a/src/services/maaService.ts +++ b/src/services/maaService.ts @@ -584,6 +584,8 @@ export const maaService = { * @param cwd 工作目录(Agent 子进程的 CWD) * @param tcpCompatMode 通信兼容模式(强制使用 TCP) * @param piEnvs PI v2.5.0 环境变量(Agent 子进程注入) + * @param resetState 是否重置后端任务运行状态(默认 true)。分段运行时,仅首段为 true, + * 后续段传 false 以追加任务、保留已完成段的状态。 * @returns 任务 ID 列表 */ async startTasks( @@ -593,6 +595,7 @@ export const maaService = { cwd?: string, tcpCompatMode?: boolean, piEnvs?: Record, + resetState: boolean = true, ): Promise { log.info('启动任务, 实例:', instanceId, ', 任务数:', tasks.length, ', cwd:', cwd || '.'); tasks.forEach((task, i) => { @@ -617,6 +620,7 @@ export const maaService = { cwd: cwd || null, tcp_compat_mode: tcpCompatMode || false, pi_envs: agentConfigs && agentConfigs.length > 0 && piEnvs ? piEnvs : null, + reset_state: resetState, }, ); log.info('任务已提交 (HTTP), taskIds:', result.taskIds); @@ -630,6 +634,7 @@ export const maaService = { cwd: cwd || '.', tcpCompatMode: tcpCompatMode || false, piEnvs: hasAgent && piEnvs ? piEnvs : null, + resetState, }); log.info('任务已提交, taskIds:', taskIds); return taskIds; @@ -753,6 +758,106 @@ export const maaService = { }); }, + /** + * 等待一批 task_id 全部到达终态(成功/失败)。用于分段运行时串接各段。 + * + * 双重判定: + * - 监听 `Tasker.Task.Succeeded` / `Tasker.Task.Failed` 匹配本批 task_id; + * - 轮询后端 `isRunning`,当该批提交后任务跑完(isRunning 变 false)时兜底完成, + * 避免漏掉早于监听器附加的回调。 + * + * @param instanceId 实例 ID + * @param taskIds 本批任务 ID 列表 + * @param options.shouldStop 返回 true 时中止等待(用于响应用户停止) + * @param options.timeoutMs 超时毫秒;<=0 表示不超时(默认不超时) + * @param options.pollIntervalMs 轮询间隔毫秒(默认 500) + * @returns allDone=是否全部完成;failed=失败的 task_id;stopped=是否因停止而中止 + */ + async waitForTasks( + instanceId: string, + taskIds: number[], + options?: { + shouldStop?: () => boolean | Promise; + timeoutMs?: number; + pollIntervalMs?: number; + }, + ): Promise<{ allDone: boolean; failed: number[]; stopped: boolean }> { + const failed: number[] = []; + if (taskIds.length === 0) { + return { allDone: true, failed, stopped: false }; + } + + const pending = new Set(taskIds); + let resolved = false; + let settle!: (value: { allDone: boolean; failed: number[]; stopped: boolean }) => void; + const promise = new Promise<{ allDone: boolean; failed: number[]; stopped: boolean }>( + (resolve) => { + settle = resolve; + }, + ); + const finish = (result: { allDone: boolean; failed: number[]; stopped: boolean }) => { + if (!resolved) { + resolved = true; + settle(result); + } + }; + + const unlisten = await this.onCallback((message, details) => { + const tid = details.task_id; + if (typeof tid !== 'number' || !pending.has(tid)) return; + if (message === 'Tasker.Task.Succeeded') { + pending.delete(tid); + } else if (message === 'Tasker.Task.Failed') { + failed.push(tid); + pending.delete(tid); + } else { + return; + } + if (pending.size === 0) { + finish({ allDone: true, failed, stopped: false }); + } + }); + + const pollMs = options?.pollIntervalMs ?? 500; + let tick = 0; + const poll = setInterval(() => { + void (async () => { + if (resolved) return; + tick += 1; + try { + if (options?.shouldStop && (await options.shouldStop())) { + finish({ allDone: false, failed, stopped: true }); + return; + } + // 首个 tick 给后端一点时间把 isRunning 翻到 true,避免误判完成 + if (tick >= 2) { + const state = await this.getInstanceState(instanceId); + if (state && !state.isRunning) { + finish({ allDone: pending.size === 0, failed, stopped: false }); + } + } + } catch { + /* 忽略轮询错误,继续等待回调 */ + } + })(); + }, pollMs); + + const timeoutMs = options?.timeoutMs ?? 0; + let timeoutId: ReturnType | undefined; + if (timeoutMs > 0) { + timeoutId = setTimeout(() => { + log.warn(`等待任务批次超时, 剩余 ${pending.size} 个未完成`); + finish({ allDone: false, failed, stopped: false }); + }, timeoutMs); + } + + return promise.finally(() => { + clearInterval(poll); + if (timeoutId) clearTimeout(timeoutId); + unlisten(); + }); + }, + /** * 获取单个实例的运行时状态(通过 Maa API 实时查询) * @param instanceId 实例 ID diff --git a/src/types/maa.ts b/src/types/maa.ts index afc3d0f0..465762f4 100644 --- a/src/types/maa.ts +++ b/src/types/maa.ts @@ -60,13 +60,23 @@ export interface GamepadControllerConfig { display_short_side?: number; } +/** + * 空控制器配置:截图返回纯黑图、输入 no-op。 + * 用于在游戏未连接/已关闭时执行不依赖游戏画面的 MXU 特殊任务。 + */ +export interface DummyControllerConfig { + type: 'Dummy'; + display_short_side?: number; +} + /** 控制器配置 */ export type ControllerConfig = | AdbControllerConfig | Win32ControllerConfig | WlRootsControllerConfig | PlayCoverControllerConfig - | GamepadControllerConfig; + | GamepadControllerConfig + | DummyControllerConfig; /** 连接状态 */ export type ConnectionStatus = 'Disconnected' | 'Connecting' | 'Connected' | { Failed: string }; diff --git a/src/types/specialTasks.ts b/src/types/specialTasks.ts index 582bc394..0e9ed046 100644 --- a/src/types/specialTasks.ts +++ b/src/types/specialTasks.ts @@ -36,6 +36,8 @@ export interface MxuSpecialTaskDefinition { | 'Power'; /** 图标颜色 CSS 类 */ iconColorClass: string; + /** 是否绕过截图/识别流程的非视觉任务 */ + skipScreenshot: boolean; } // MXU_SLEEP 特殊任务常量(保留向后兼容) @@ -541,6 +543,7 @@ export const MXU_SPECIAL_TASKS: Record = { }, iconName: 'Timer', iconColorClass: 'text-warning/80', + skipScreenshot: true, }, [MXU_WAITUNTIL_TASK_NAME]: { taskName: MXU_WAITUNTIL_TASK_NAME, @@ -551,6 +554,7 @@ export const MXU_SPECIAL_TASKS: Record = { }, iconName: 'Clock', iconColorClass: 'text-accent/80', + skipScreenshot: true, }, [MXU_NOTIFY_TASK_NAME]: { taskName: MXU_NOTIFY_TASK_NAME, @@ -561,6 +565,7 @@ export const MXU_SPECIAL_TASKS: Record = { }, iconName: 'MessageSquare', iconColorClass: 'text-info/80', + skipScreenshot: true, }, [MXU_LAUNCH_TASK_NAME]: { taskName: MXU_LAUNCH_TASK_NAME, @@ -574,6 +579,7 @@ export const MXU_SPECIAL_TASKS: Record = { }, iconName: 'Play', iconColorClass: 'text-success/80', + skipScreenshot: true, }, [MXU_KILLPROC_TASK_NAME]: { taskName: MXU_KILLPROC_TASK_NAME, @@ -585,6 +591,7 @@ export const MXU_SPECIAL_TASKS: Record = { }, iconName: 'XCircle', iconColorClass: 'text-error/80', + skipScreenshot: true, }, [MXU_POWER_TASK_NAME]: { taskName: MXU_POWER_TASK_NAME, @@ -595,6 +602,7 @@ export const MXU_SPECIAL_TASKS: Record = { }, iconName: 'Power', iconColorClass: 'text-warning/80', + skipScreenshot: true, }, [MXU_WEBHOOK_TASK_NAME]: { taskName: MXU_WEBHOOK_TASK_NAME, @@ -605,6 +613,7 @@ export const MXU_SPECIAL_TASKS: Record = { }, iconName: 'Bell', iconColorClass: 'text-accent/80', + skipScreenshot: true, }, }; @@ -667,3 +676,11 @@ export function findMxuOptionByKey(optionKey: string): OptionDefinition | undefi export function getAllMxuSpecialTasks(): MxuSpecialTaskDefinition[] { return Object.values(MXU_SPECIAL_TASKS); } + +/** + * 判断是否应跳过截图/识别流程 + * @param taskName 任务名称 + */ +export function shouldSkipMxuScreenshot(taskName: string): boolean { + return MXU_SPECIAL_TASKS[taskName]?.skipScreenshot ?? false; +} diff --git a/src/utils/taskSegmentation.ts b/src/utils/taskSegmentation.ts new file mode 100644 index 00000000..4cc52df8 --- /dev/null +++ b/src/utils/taskSegmentation.ts @@ -0,0 +1,79 @@ +import { shouldSkipMxuScreenshot } from '@/types/specialTasks'; +import type { SelectedTask } from '@/types/interface'; + +/** 三段式任务切分结果:前置特殊 / 中间游戏 / 收尾特殊 */ +export interface ThreeSegmentSplit { + leading: T[]; + middle: T[]; + trailing: T[]; +} + +/** + * 将任务列表切成最多三段: + * - leading:队首连续 MXU 特殊任务 + * - middle:第一个游戏任务到最后一个游戏任务(含夹在游戏之间的特殊任务) + * - trailing:队尾连续 MXU 特殊任务 + */ +export function splitTasksIntoThreeSegments( + tasks: T[], +): ThreeSegmentSplit { + if (tasks.length === 0) { + return { leading: [], middle: [], trailing: [] }; + } + + let leadingEnd = 0; + while (leadingEnd < tasks.length && shouldSkipMxuScreenshot(tasks[leadingEnd].taskName)) { + leadingEnd += 1; + } + + let trailingStart = tasks.length; + while ( + trailingStart > leadingEnd && + shouldSkipMxuScreenshot(tasks[trailingStart - 1].taskName) + ) { + trailingStart -= 1; + } + + return { + leading: tasks.slice(0, leadingEnd), + middle: tasks.slice(leadingEnd, trailingStart), + trailing: tasks.slice(trailingStart), + }; +} + +/** 是否存在需要 Dummy 空 controller 的特殊段(前置或收尾) */ +export function hasSpecialSegments(segments: ThreeSegmentSplit): boolean { + return segments.leading.length > 0 || segments.trailing.length > 0; +} + +/** 是否应走分段运行(存在特殊段且与游戏段组合) */ +export function needsSegmentedRun(segments: ThreeSegmentSplit): boolean { + return hasSpecialSegments(segments); +} + +export type MxuTaskSegmentKind = 'leading' | 'middle' | 'trailing'; + +export interface MxuTaskExecutionSegment { + kind: MxuTaskSegmentKind; + tasks: T[]; + useDummyController: boolean; +} + +/** + * 将任务列表分组为可执行批次: + * - leading:队首特殊任务,使用 Dummy Controller + * - middle:中间游戏段,使用当前游戏控制器 + * - trailing:队尾特殊任务,使用 Dummy Controller + */ +export function getMxuTaskExecutionSegments( + tasks: T[], +): MxuTaskExecutionSegment[] { + const { leading, middle, trailing } = splitTasksIntoThreeSegments(tasks); + return [ + { kind: 'leading' as const, tasks: leading, useDummyController: true }, + { kind: 'middle' as const, tasks: middle, useDummyController: false }, + { kind: 'trailing' as const, tasks: trailing, useDummyController: true }, + ].filter((segment) => segment.tasks.length > 0); +} + +export type { SelectedTask };