diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index b22a44081..0afa333fa 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -98,10 +98,13 @@ const appInvokeSources = readSourceFiles( new URL('../src/', import.meta.url), new Set(['.ts', '.tsx']), ); -const tauriHandlerSource = fs.readFileSync( +// 生产接线在 desktop,受测的启动诊断实现仍在 main;两者共同构成启动检查范围。 +const tauriHandlerSource = [ new URL('../src-tauri/src/main.rs', import.meta.url), - 'utf8', -); + new URL('../src-tauri/src/desktop.rs', import.meta.url), +] + .map((source) => fs.readFileSync(source, 'utf8')) + .join('\n'); const tauriRustSource = readSourceTree( new URL('../src-tauri/src/', import.meta.url), '.rs', diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 752548bd7..613d25acd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -45,16 +45,19 @@ mod runtime_protocol; mod runtime_state; mod runtime_tools; mod skill_pack; +pub(crate) use codex_app_server::direct_game_creator_codex_chat_at; use codex_app_server::*; +#[cfg(not(test))] pub(crate) use codex_app_server::{ - cancel_direct_codex_turn_at, direct_game_creator_codex_chat_at, - direct_game_creator_home_codex_chat, direct_thread_id_for_project, DirectTurnCancelView, + cancel_direct_codex_turn_at, direct_game_creator_home_codex_chat, direct_thread_id_for_project, + DirectTurnCancelView, }; use codex_cli::*; pub(crate) use codex_cli::{ game_creator_codex_cli_executable_path, game_creator_codex_cli_version_identity, }; pub(crate) use codex_provider_proxy::*; +#[cfg(not(test))] pub(crate) use design_runtime::*; pub(crate) use direct_codex_attachments::*; pub(crate) use direct_codex_user_item::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index cac4568b6..26e0704e4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -2480,42 +2480,7 @@ impl CodexAppServerConnection { Ok(connection) } - #[cfg(test)] - async fn spawn( - llm: &GameCreatorLlmConfig, - credential: &CodexAppServerCredential, - ) -> Result { - let executable = game_creator_codex_cli_executable_path() - .map_err(platform_llm::LlmError::InvalidConfig)?; - Self::spawn_with_executable_and_credential(llm, credential, executable.as_os_str()).await - } - - #[cfg(test)] - async fn spawn_with_executable( - llm: &GameCreatorLlmConfig, - executable: &std::ffi::OsStr, - ) -> Result { - let credential = resolve_game_creator_codex_app_server_credential(llm)?; - Self::spawn_with_executable_and_credential(llm, &credential, executable).await - } - - #[cfg(test)] - async fn spawn_with_executable_and_credential( - llm: &GameCreatorLlmConfig, - credential: &CodexAppServerCredential, - executable: &std::ffi::OsStr, - ) -> Result { - Self::spawn_with_executable_and_credential_at_workspace( - llm, - credential, - executable, - None, - CodexAppServerWorkspaceMode::ToolHost, - ) - .await - } - - #[cfg(test)] + #[cfg(all(test, unix))] async fn spawn_with_executable_and_credential_at_workspace( llm: &GameCreatorLlmConfig, credential: &CodexAppServerCredential, @@ -7095,6 +7060,8 @@ while IFS= read -r line; do :; done .map(|argument| argument.to_string_lossy().into_owned()) .collect::>() .join("\n"); + assert!(home_arguments.contains("--disable\nshell_tool")); + assert!(home_arguments.contains("--disable\nunified_exec")); } #[cfg(windows)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index 9e1eafc0f..e42722a1a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -1688,7 +1688,6 @@ pub(crate) fn read_design_workspace_file( #[cfg(test)] mod fake_provider { - use super::*; use std::cell::{Cell, RefCell}; use std::collections::VecDeque; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 0b816ffee..1208c3d81 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -8,6 +8,7 @@ use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; mod user_input; +#[cfg(not(test))] pub(crate) use user_input::chat_with_game_creator_direct_codex; #[cfg(test)] pub(crate) use user_input::normalize_direct_client_turn_id; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index bad2f072a..960b47bfe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -49,7 +49,6 @@ pub(in crate::agent) use external_generation_state::{ }; #[cfg(test)] pub(crate) use external_generation_state::{ - platform_art_generation_external_configuration_fingerprint, platform_art_generation_legacy_external_configuration_fingerprint, setup_platform_art_generation_runtime_accepted_for_recovery_test, write_platform_art_generation_runtime_accepted_for_test, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index 3647d5979..7e3327881 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -817,24 +817,17 @@ mod tests { use super::{ acquire_game_creator_agent_provider_plan_project_write_lock_with_wait, agent_runtime_root_source_at, bind_game_creator_agent_runtime_run_profile_at, - build_game_creator_agent_background_final_reply_request, build_game_creator_agent_background_tool_plan_request_locked, - game_creator_agent_context_preload_notice, - game_creator_agent_runtime_run_profile_binding_path, - game_creator_project_supervisor_chat_system_prompt, init_local_game_project_at, - new_game_creation_app_seed_tasks, provider_command_exec_contract, - provider_command_start_contract, + game_creator_agent_context_preload_notice, init_local_game_project_at, + new_game_creation_app_seed_tasks, render_relaxed_autonomous_manifest_ready_task_background_prompt, - required_runtime_prompt_section, start_game_creator_agent_runtime_task_at, - AgentRuntimeGoalContractAcceptanceNodeDraft, AgentRuntimeGoalContractDraft, - AgentRuntimeTaskLink, AgentRuntimeToolObservation, AgentRuntimeToolPlan, + start_game_creator_agent_runtime_task_at, AgentRuntimeGoalContractAcceptanceNodeDraft, + AgentRuntimeGoalContractDraft, AgentRuntimeTaskLink, AgentRuntimeToolObservation, AgentRuntimeToolPlanRequestSnapshot, GameCreatorLlmConfig, - AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT, - AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL, AGENT_RUNTIME_RESPOND_FUNCTION_NAME, - AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_RUN_PROFILE_STANDARD, - AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION, + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT, AGENT_RUNTIME_RESPOND_FUNCTION_NAME, + AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, }; fn build_game_creator_agent_background_tool_plan_request_for_test( @@ -871,24 +864,6 @@ mod tests { ) } - fn native_input_required_fields( - request: &platform_llm::LlmRunRequest, - tool: &str, - ) -> Vec { - let function_name = crate::agent_native_tools::native_runtime_function_name(tool) - .expect("native runtime function name"); - request - .function_tools - .iter() - .find(|function| function.name == function_name) - .and_then(|function| function.parameters.pointer("/properties/input/required")) - .and_then(serde_json::Value::as_array) - .expect("native input required fields") - .iter() - .map(|field| field.as_str().expect("required field string").to_string()) - .collect() - } - #[test] fn relaxed_request_keeps_general_catalog_after_plan_rejection() { let directory = crate::tests::canonical_test_tempdir("provider-plan-rejection-repair-"); @@ -1437,20 +1412,6 @@ mod tests { assert!(!prompt.contains("本轮唯一可用工具是 agent.goal_contract")); } - fn completion_blocker_protocol_requires_tool_repair_before_response() { - let protocol = AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL; - - assert!(protocol.contains("最新 observation")); - assert!(protocol.contains("runtime.autonomous_completion")); - assert!(protocol.contains("status 为 blocked")); - assert!(protocol.contains("才能调用 respond_to_user")); - assert!(!protocol.contains("legacy response")); - assert!(protocol.contains("observation.detail 的 nextRequiredAction")); - assert!(protocol.contains("读取、修复和验证工具")); - assert!(protocol.contains("完成要求的动作、取得后续可信 observation")); - assert!(protocol.contains("通过完成门禁后")); - } - #[test] fn supervisor_request_snapshot_preserves_prompt_visible_running_sibling_after_manifest_failure() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index 54da51648..6b4519eac 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -302,10 +302,7 @@ pub(crate) use entrypoints::{ pub(crate) use finalization::resume_game_creator_agent_finalization_for_test_at; pub(crate) use finalization::AgentRuntimePendingActionResume; #[cfg(test)] -pub(crate) use interaction::{ - acquire_game_creator_agent_runtime_user_input_answer_locks_for_test, - agent_runtime_tool_requires_repository_context_fingerprint_gate, -}; +pub(crate) use interaction::agent_runtime_tool_requires_repository_context_fingerprint_gate; pub(crate) use interaction::{ answer_game_creator_agent_runtime_user_input_at, confirm_game_creator_agent_runtime_task_at, pending_repository_context_drift_observation, reject_game_creator_agent_runtime_task_at, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs index a91344eda..d1f2aa58e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs @@ -477,20 +477,6 @@ fn resolve_game_creator_agent_runtime_user_input_action_with_ordered_locks( Ok((None, runtime_lock, resolved)) } -#[cfg(test)] -pub(crate) fn acquire_game_creator_agent_runtime_user_input_answer_locks_for_test( - root: &Path, - agent_id: &str, - run_id: &str, - action_id: &str, -) -> Result<(Option, AgentRuntimeTaskLock), String> { - let (project_lock, runtime_lock, _) = - resolve_game_creator_agent_runtime_user_input_action_with_ordered_locks( - root, agent_id, run_id, action_id, - )?; - Ok((project_lock, runtime_lock)) -} - pub(crate) fn answer_game_creator_agent_runtime_user_input_at( root: &Path, agent_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs index 3618ca475..57a094da1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs @@ -11943,11 +11943,6 @@ impl ExternalGameplayJavascript { .iter() .any(|unit| unit.to_ascii_lowercase().contains(&marker)) } - - #[cfg(test)] - pub(in crate::agent) fn module_units(&self) -> &[String] { - &self.module_units - } } fn normalize_javascript_module_analysis_sources( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index a68658e4a..0ec540993 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -44,9 +44,7 @@ pub(in crate::agent) use ui_workflow::*; #[cfg(test)] pub(crate) use delegation::observe_agent_runtime_agent_delegate_at_locked; #[cfg(test)] -pub(crate) use delivery::{ - build_static_delegate_result_for_child_at, wake_waiting_static_delegate_parent_run_for_test_at, -}; +pub(crate) use delivery::build_static_delegate_result_for_child_at; #[cfg(test)] pub(crate) use isolated_joins::render_isolated_join_status_batch_with_limit; #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs index 4e29d3356..709023e9d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delivery.rs @@ -408,14 +408,6 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at( Ok(true) } -#[cfg(test)] -pub(crate) fn wake_waiting_static_delegate_parent_run_for_test_at( - root: &Path, - parent_task: &AgentRuntimeTaskRecord, -) -> Result { - wake_waiting_static_delegate_parent_run_at(root, parent_task) -} - pub(in crate::agent) fn wake_waiting_autonomous_manifest_parent_run_at( root: &Path, parent_task: &AgentRuntimeTaskRecord, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 799c26b83..b9c09ab33 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -13,8 +13,6 @@ use crate::agent::{ AgentRuntimeToolPlan, AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT, AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PLAN_STEP_LIMIT, }; -#[cfg(test)] -use crate::GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID; pub(crate) const AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME: &str = "update_agent_plan"; pub(crate) const AGENT_RUNTIME_RESPOND_FUNCTION_NAME: &str = "respond_to_user"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs index 0fd3f96d6..e76900c5b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs @@ -25,17 +25,20 @@ use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; use crate::agent::{ - generate_platform_art_asset_with_options_at, read_agent_runtime_json_sidecar_with_max_bytes, - write_agent_runtime_json_sidecar_with_max_bytes, PlatformArtAssetGenerationOptions, + read_agent_runtime_json_sidecar_with_max_bytes, write_agent_runtime_json_sidecar_with_max_bytes, }; -use crate::commands::prepare_local_project_asset_generation; use crate::project::{ enforce_project_permission_policy, prepare_local_project_audio_generation, - read_existing_manifest_for_project, run_local_project_audio_generation_at, - LocalProjectAudioGenerationRequest, LocalProjectResourceEditKind, + read_existing_manifest_for_project, LocalProjectAudioGenerationRequest, + LocalProjectResourceEditKind, }; use shared_contracts::game_creation_app::GameCreationAppAssetKind; +#[cfg(not(test))] +mod runtime; +#[cfg(not(test))] +pub(crate) use runtime::start_local_project_asset_generation; + pub(crate) const ASSET_GENERATION_TASK_SCHEMA_VERSION: &str = "agc-asset-generation-task.v1"; pub(crate) const ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH: &str = ".agent/runtime/asset-generation-tasks/tasks.json"; @@ -45,12 +48,10 @@ pub(crate) const ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS: usize = 50; pub(crate) const ASSET_GENERATION_TASK_ID_MAX_CHARS: usize = 128; pub(crate) const ASSET_GENERATION_TASK_STATUS_QUEUED: &str = "queued"; -pub(crate) const ASSET_GENERATION_TASK_STATUS_RUNNING: &str = "running"; pub(crate) const ASSET_GENERATION_TASK_STATUS_COMPLETED: &str = "completed"; pub(crate) const ASSET_GENERATION_TASK_STATUS_FAILED: &str = "failed"; const ASSET_GENERATION_TASK_PHASE_QUEUED: &str = "排队中。"; -const ASSET_GENERATION_TASK_PHASE_RUNNING: &str = "正在生成。"; const ASSET_GENERATION_TASK_PHASE_COMPLETED: &str = "生成已完成。"; /// 中断收口:清单里已经能查到这次请求的目标素材 → 按事实收口为已完成。 const ASSET_GENERATION_TASK_PHASE_INTERRUPTED_SETTLED: &str = @@ -67,9 +68,6 @@ const ASSET_GENERATION_TASK_INTERRUPTED_INCOMPLETE_ERROR: &str = "应用退出时生成任务仍在进行,目标素材未登记"; const ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR: &str = "应用退出时生成任务仍在进行,未能在清单里确认结果"; -/// 音频任务收口:通道跑完但没有登记出素材(`derive` 在有源 / 无源两条路上都必须登记 assets)。 -const ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR: &str = "生成完成但未登记素材"; - /// 一条生成任务的权威记录。字段名与前端一一对应(camelCase)。 #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] @@ -357,57 +355,6 @@ fn register_live_task_id(task_id: &str) -> bool { .unwrap_or(false) } -/// 后台执行:状态与阶段文案的每一次流转都由这里写账本。 -async fn run_local_project_asset_generation_task( - root: PathBuf, - task_id: String, - prompt: String, - options: PlatformArtAssetGenerationOptions, -) { - if update_task(&root, &task_id, |task| { - task.status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(); - task.phase_detail = ASSET_GENERATION_TASK_PHASE_RUNNING.to_string(); - task.started_at_millis = Some(now_millis()); - }) - .is_err() - { - remove_live_task_id(&task_id); - return; - } - let outcome = generate_platform_art_asset_with_options_at(&root, &prompt, &[], &options).await; - match outcome { - Ok(generated) => { - let _ = update_task(&root, &task_id, |task| { - task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(); - task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string(); - task.asset_id = Some(generated.asset.id.clone()); - task.finished_at_millis = Some(now_millis()); - task.error = None; - }); - } - Err(error) => { - let _ = update_task(&root, &task_id, |task| { - task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); - task.phase_detail = format!("生成失败:{error}"); - task.error = Some(error.clone()); - task.finished_at_millis = Some(now_millis()); - }); - } - } - remove_live_task_id(&task_id); -} - -/// 走音频无源生成链路的 kind:音效与背景音乐。 -/// -/// 这份判据是「同一命令两种通道」的唯一分叉点:它在白名单里只放这两个成员,其余 kind -/// (含图片类与 `unknown`)一律继续走图片通道的既有收口,不在这一层做兜底猜测。 -fn is_audio_asset_generation_kind(kind: GameCreationAppAssetKind) -> bool { - matches!( - kind, - GameCreationAppAssetKind::SoundEffect | GameCreationAppAssetKind::BackgroundMusic - ) -} - /// 音频(音效 / 背景音乐)提交:校验入参 → 落**同一份**排队记录 → 返回记录与派发所需的请求。 /// /// 与图片类分支的差异只有三处,且都不改变账本形状: @@ -455,167 +402,6 @@ fn begin_local_project_audio_generation_task( Ok((record, request)) } -/// 音频后台执行:状态与阶段文案的每一次流转都由这里写账本。 -/// -/// `run_local_project_audio_generation_at` 返回 `Ok(None)` 表示这次生成没有登记出素材:按失败 -/// 收口,不把一条没有 `assetId` 的记录标成「已完成」——那样前端既定位不到素材,也没有原因可看。 -async fn run_local_project_audio_generation_task( - project_path: String, - task_id: String, - request: LocalProjectAudioGenerationRequest, -) { - let root = PathBuf::from(project_path.trim()); - if update_task(&root, &task_id, |task| { - task.status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(); - task.phase_detail = ASSET_GENERATION_TASK_PHASE_RUNNING.to_string(); - task.started_at_millis = Some(now_millis()); - }) - .is_err() - { - remove_live_task_id(&task_id); - return; - } - let outcome = run_local_project_audio_generation_at(&project_path, &request).await; - match outcome { - Ok(Some(asset_id)) => { - let _ = update_task(&root, &task_id, |task| { - task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(); - task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string(); - task.asset_id = Some(asset_id); - task.finished_at_millis = Some(now_millis()); - task.error = None; - }); - } - Ok(None) => { - let _ = update_task(&root, &task_id, |task| { - task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); - task.phase_detail = - format!("生成失败:{ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR}"); - task.error = Some(ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR.to_string()); - task.finished_at_millis = Some(now_millis()); - }); - } - Err(error) => { - let _ = update_task(&root, &task_id, |task| { - task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); - task.phase_detail = format!("生成失败:{error}"); - task.error = Some(error.clone()); - task.finished_at_millis = Some(now_millis()); - }); - } - } - remove_live_task_id(&task_id); -} - -/// 提交即返回:校验入参 → 落排队记录 → 派发后台任务 → 返回记录。 -/// -/// 入参收口完全复用 `prepare_local_project_asset_generation`(与同步命令同一份白名单与边界), -/// 生成本身仍是 `generate_platform_art_asset_with_options_at`,本命令不复制任何生成逻辑。 -/// -/// 音频 kind(`sound-effect` / `background-music`)走同一条命令的音频分支:账本、阶段文案、 -/// 中断收口与本地排队全部共用,**只**把「怎么生成」换成既有音频无源生成链路(见 -/// `start_local_project_audio_generation_task`)。图片类载荷口径逐字不变。 -#[tauri::command] -pub(crate) async fn start_local_project_asset_generation( - project_path: String, - project_id: String, - task_id: String, - kind: String, - prompt: String, - aspect_ratio: Option, - image_size: Option, - asset_name: Option, - output_path: Option, - // 前端 IPC 字段 `referenceAssetIds`:当前项目 manifest 里的图片素材 id,只做参考输入, - // 不进任务账本(重试由调用方继续用同一份引用提交,账本本身不新增字段)。 - reference_asset_ids: Option>, - // 前端 IPC 字段 `targetCategory`:完成登记时要落盘的正式栏目分类。同样不进任务账本: - // 它与引用一样属于「同一次提交的本地落点」,重试由调用方继续用同一个栏目提交。 - target_category: Option, - // 前端 IPC 字段 `idempotencyKey`:**音频**生成才带——音频请求身份是一对 operation / 幂等键, - // 重试必须复用同一对,否则就变成第二次付费生成。图片类通道的载荷逐字不变,这个字段对 - // 图片 kind 不参与任何校验。 - idempotency_key: Option, -) -> Result { - let task_id = asset_generation_task_id(&task_id)?; - if is_audio_asset_generation_kind(GameCreationAppAssetKind::parse_with_context( - &kind, - "canvas.asset_kind", - )) { - let idempotency_key = idempotency_key.unwrap_or_default(); - if idempotency_key.trim().is_empty() { - return Err("音频生成缺少 idempotencyKey".to_string()); - } - // 先登记 live 再落账本:窗口期里并发 `list` 不许把这条排队记录判成上次运行的残留。 - let live_registered = register_live_task_id(&task_id); - let (record, request) = match begin_local_project_audio_generation_task( - &project_path, - &project_id, - &task_id, - &kind, - &prompt, - asset_name.as_deref().unwrap_or_default(), - &idempotency_key, - ) { - Ok(pair) => pair, - Err(error) => { - // 校验不过 / 同 id 已在跑 / 账本写不进去:这一轮什么都没派发,撤掉自己的登记。 - if live_registered { - remove_live_task_id(&task_id); - } - return Err(error); - } - }; - tauri::async_runtime::spawn(run_local_project_audio_generation_task( - project_path.trim().to_string(), - task_id, - request, - )); - return Ok(record); - } - let request = prepare_local_project_asset_generation( - &project_path, - &kind, - &prompt, - aspect_ratio.as_deref(), - image_size.as_deref(), - asset_name.as_deref(), - output_path.as_deref(), - reference_asset_ids.as_deref().unwrap_or_default(), - target_category.as_deref(), - )?; - enforce_project_permission_policy(&request.root, "canvas.asset_generate")?; - enforce_project_permission_policy(&request.root, "asset.register")?; - let asset_label = request.options.asset_label.clone(); - let asset_kind = request.options.asset_kind.clone(); - // 与音频分支同一条顺序约束:先登记 live 再落账本,中间不留「排队但还不 live」的窗口。 - let live_registered = register_live_task_id(&task_id); - let record = match begin_local_project_asset_generation_task( - &request.root, - &project_id, - &task_id, - asset_kind, - &asset_label, - request.options.output_path.as_deref(), - ) { - Ok(record) => record, - Err(error) => { - if live_registered { - remove_live_task_id(&task_id); - } - return Err(error); - } - }; - let root = request.root.clone(); - tauri::async_runtime::spawn(run_local_project_asset_generation_task( - root, - task_id, - request.prompt, - request.options, - )); - Ok(record) -} - /// 读回项目内账本(重开项目后仍能看到历史任务)。 #[tauri::command] pub(crate) fn list_local_project_asset_generations( diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks/runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks/runtime.rs new file mode 100644 index 000000000..4a3ccad0d --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks/runtime.rs @@ -0,0 +1,223 @@ +use super::*; +use crate::agent::{ + generate_platform_art_asset_with_options_at, PlatformArtAssetGenerationOptions, +}; +use crate::commands::prepare_local_project_asset_generation; +use crate::project::run_local_project_audio_generation_at; + +pub(crate) const ASSET_GENERATION_TASK_STATUS_RUNNING: &str = "running"; +const ASSET_GENERATION_TASK_PHASE_RUNNING: &str = "正在生成。"; +/// 音频任务收口:通道跑完但没有登记出素材(`derive` 在有源 / 无源两条路上都必须登记 assets)。 +const ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR: &str = "生成完成但未登记素材"; + +/// 后台执行:状态与阶段文案的每一次流转都由这里写账本。 +async fn run_local_project_asset_generation_task( + root: PathBuf, + task_id: String, + prompt: String, + options: PlatformArtAssetGenerationOptions, +) { + if update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_RUNNING.to_string(); + task.started_at_millis = Some(now_millis()); + }) + .is_err() + { + remove_live_task_id(&task_id); + return; + } + let outcome = generate_platform_art_asset_with_options_at(&root, &prompt, &[], &options).await; + match outcome { + Ok(generated) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string(); + task.asset_id = Some(generated.asset.id.clone()); + task.finished_at_millis = Some(now_millis()); + task.error = None; + }); + } + Err(error) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); + task.phase_detail = format!("生成失败:{error}"); + task.error = Some(error.clone()); + task.finished_at_millis = Some(now_millis()); + }); + } + } + remove_live_task_id(&task_id); +} + +/// 走音频无源生成链路的 kind:音效与背景音乐。 +/// +/// 这份判据是「同一命令两种通道」的唯一分叉点:它在白名单里只放这两个成员,其余 kind +/// (含图片类与 `unknown`)一律继续走图片通道的既有收口,不在这一层做兜底猜测。 +fn is_audio_asset_generation_kind(kind: GameCreationAppAssetKind) -> bool { + matches!( + kind, + GameCreationAppAssetKind::SoundEffect | GameCreationAppAssetKind::BackgroundMusic + ) +} + +/// 音频后台执行:状态与阶段文案的每一次流转都由这里写账本。 +/// +/// `run_local_project_audio_generation_at` 返回 `Ok(None)` 表示这次生成没有登记出素材:按失败 +/// 收口,不把一条没有 `assetId` 的记录标成「已完成」——那样前端既定位不到素材,也没有原因可看。 +async fn run_local_project_audio_generation_task( + project_path: String, + task_id: String, + request: LocalProjectAudioGenerationRequest, +) { + let root = PathBuf::from(project_path.trim()); + if update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_RUNNING.to_string(); + task.started_at_millis = Some(now_millis()); + }) + .is_err() + { + remove_live_task_id(&task_id); + return; + } + let outcome = run_local_project_audio_generation_at(&project_path, &request).await; + match outcome { + Ok(Some(asset_id)) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string(); + task.asset_id = Some(asset_id); + task.finished_at_millis = Some(now_millis()); + task.error = None; + }); + } + Ok(None) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); + task.phase_detail = + format!("生成失败:{ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR}"); + task.error = Some(ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR.to_string()); + task.finished_at_millis = Some(now_millis()); + }); + } + Err(error) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); + task.phase_detail = format!("生成失败:{error}"); + task.error = Some(error.clone()); + task.finished_at_millis = Some(now_millis()); + }); + } + } + remove_live_task_id(&task_id); +} + +/// 提交即返回:校验入参 → 落排队记录 → 派发后台任务 → 返回记录。 +/// +/// 入参收口完全复用 `prepare_local_project_asset_generation`(与同步命令同一份白名单与边界), +/// 生成本身仍是 `generate_platform_art_asset_with_options_at`,本命令不复制任何生成逻辑。 +/// +/// 音频 kind(`sound-effect` / `background-music`)走同一条命令的音频分支:账本、阶段文案、 +/// 中断收口与本地排队全部共用,**只**把「怎么生成」换成既有音频无源生成链路(见 +/// `start_local_project_audio_generation_task`)。图片类载荷口径逐字不变。 +#[tauri::command] +pub(crate) async fn start_local_project_asset_generation( + project_path: String, + project_id: String, + task_id: String, + kind: String, + prompt: String, + aspect_ratio: Option, + image_size: Option, + asset_name: Option, + output_path: Option, + // 前端 IPC 字段 `referenceAssetIds`:当前项目 manifest 里的图片素材 id,只做参考输入, + // 不进任务账本(重试由调用方继续用同一份引用提交,账本本身不新增字段)。 + reference_asset_ids: Option>, + // 前端 IPC 字段 `targetCategory`:完成登记时要落盘的正式栏目分类。同样不进任务账本: + // 它与引用一样属于「同一次提交的本地落点」,重试由调用方继续用同一个栏目提交。 + target_category: Option, + // 前端 IPC 字段 `idempotencyKey`:**音频**生成才带——音频请求身份是一对 operation / 幂等键, + // 重试必须复用同一对,否则就变成第二次付费生成。图片类通道的载荷逐字不变,这个字段对 + // 图片 kind 不参与任何校验。 + idempotency_key: Option, +) -> Result { + let task_id = asset_generation_task_id(&task_id)?; + if is_audio_asset_generation_kind(GameCreationAppAssetKind::parse_with_context( + &kind, + "canvas.asset_kind", + )) { + let idempotency_key = idempotency_key.unwrap_or_default(); + if idempotency_key.trim().is_empty() { + return Err("音频生成缺少 idempotencyKey".to_string()); + } + // 先登记 live 再落账本:窗口期里并发 `list` 不许把这条排队记录判成上次运行的残留。 + let live_registered = register_live_task_id(&task_id); + let (record, request) = match begin_local_project_audio_generation_task( + &project_path, + &project_id, + &task_id, + &kind, + &prompt, + asset_name.as_deref().unwrap_or_default(), + &idempotency_key, + ) { + Ok(pair) => pair, + Err(error) => { + // 校验不过 / 同 id 已在跑 / 账本写不进去:这一轮什么都没派发,撤掉自己的登记。 + if live_registered { + remove_live_task_id(&task_id); + } + return Err(error); + } + }; + tauri::async_runtime::spawn(run_local_project_audio_generation_task( + project_path.trim().to_string(), + task_id, + request, + )); + return Ok(record); + } + let request = prepare_local_project_asset_generation( + &project_path, + &kind, + &prompt, + aspect_ratio.as_deref(), + image_size.as_deref(), + asset_name.as_deref(), + output_path.as_deref(), + reference_asset_ids.as_deref().unwrap_or_default(), + target_category.as_deref(), + )?; + enforce_project_permission_policy(&request.root, "canvas.asset_generate")?; + enforce_project_permission_policy(&request.root, "asset.register")?; + let asset_label = request.options.asset_label.clone(); + let asset_kind = request.options.asset_kind.clone(); + // 与音频分支同一条顺序约束:先登记 live 再落账本,中间不留「排队但还不 live」的窗口。 + let live_registered = register_live_task_id(&task_id); + let record = match begin_local_project_asset_generation_task( + &request.root, + &project_id, + &task_id, + asset_kind, + &asset_label, + request.options.output_path.as_deref(), + ) { + Ok(record) => record, + Err(error) => { + if live_registered { + remove_live_task_id(&task_id); + } + return Err(error); + } + }; + let root = request.root.clone(); + tauri::async_runtime::spawn(run_local_project_asset_generation_task( + root, + task_id, + request.prompt, + request.options, + )); + Ok(record) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs index 41bf2cf64..0d98e7c97 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs @@ -32,6 +32,7 @@ use super::playtest::{ GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SAMPLES, GENERIC_PLAYTEST_START_OPPORTUNITY_WINDOW, PLAYABLE_GAME_STATE_SCHEMA_VERSION, }; +#[cfg(unix)] use super::process::create_browser_process_temp_dir; static PATH_TEST_LOCK: Mutex<()> = Mutex::new(()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 16a027c5e..28112d28b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1,8 +1,9 @@ +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(crate) use desktop::*; + use super::*; -use crate::agent::{ - read_direct_project_chat_history_at, read_direct_project_last_item_id_at, - DirectProjectHistoryAnchor, -}; use crate::ui_editor::resource::font::FontAsset; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashSet}; @@ -18,21 +19,8 @@ const UI_EDITOR_IMAGE_MAX_COUNT: usize = 100; const AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS: usize = 500; const AGENT_EDITOR_ASSET_ID_MAX_CHARS: usize = 512; const AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS: usize = 8_000; -const AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS: u32 = 64; -// Project naming is an optional homepage enhancement. It must never hold the -// actual project creation flow behind the normal (potentially three-minute) -// generation timeout. -const AUTOMATIC_PROJECT_NAME_TIMEOUT_MS: u64 = 15_000; -const AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT: &str = - include_str!("../prompts/automatic-project-name.md"); -// 聊天输入区的 AI 润色复用同一条短文本生成通道:只做一次单轮改写, -// 计费由平台 LLM 路由(/api/llm/chat/completions、/api/llm/responses)侧完成。 const LOCAL_PROJECT_PROMPT_POLISH_MAX_PROMPT_CHARS: usize = 4_000; const LOCAL_PROJECT_PROMPT_POLISH_MAX_CONTEXT_CHARS: usize = 1_000; -const LOCAL_PROJECT_PROMPT_POLISH_MAX_OUTPUT_TOKENS: u32 = 2_048; -const LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT: &str = - include_str!("../prompts/local-project-prompt-polish.md"); - fn is_chinese_project_name_character(value: char) -> bool { matches!( value, @@ -65,51 +53,6 @@ pub(crate) fn build_automatic_project_name_prompt(prompt: &str) -> Result Result, String> { - let user_prompt = build_automatic_project_name_prompt(prompt)?; - let app_config = load_game_creator_app_config()?; - if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER { - let reply = tokio::time::timeout( - std::time::Duration::from_millis(AUTOMATIC_PROJECT_NAME_TIMEOUT_MS), - crate::agent::direct_game_creator_home_codex_chat( - AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim().to_string(), - user_prompt, - ), - ) - .await - .map_err(|_| { - format!( - "自动项目命名超时(超过 {} 秒)", - AUTOMATIC_PROJECT_NAME_TIMEOUT_MS / 1_000 - ) - })??; - return Ok(normalize_suggested_project_name(reply.trim())); - } - let mut llm = app_config.llm.clone(); - llm.max_retries = 0; - let client = build_game_creator_llm_client_from_llm_config(&llm, "llm")?; - let request = - LlmRunRequest::single_turn(AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim(), user_prompt) - .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) - .with_model(llm.model.clone()) - .with_request_timeout_ms(llm.request_timeout_ms) - .with_max_output_tokens(AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS) - .with_web_search(false); - let response = tokio::time::timeout( - std::time::Duration::from_millis(AUTOMATIC_PROJECT_NAME_TIMEOUT_MS), - client.run(request), - ) - .await - .map_err(|_| { - format!( - "自动项目命名超时(超过 {} 秒)", - AUTOMATIC_PROJECT_NAME_TIMEOUT_MS / 1_000 - ) - })? - .map_err(|error| format!("自动项目命名失败:{error}"))?; - Ok(normalize_suggested_project_name(response.text.trim())) -} - pub(crate) fn build_local_project_prompt_polish_prompt( prompt: &str, context: Option<&str>, @@ -143,40 +86,6 @@ fn normalize_polished_prompt(value: &str) -> Option { } } -async fn request_local_project_prompt_polish( - prompt: &str, - context: Option<&str>, -) -> Result { - let user_prompt = build_local_project_prompt_polish_prompt(prompt, context)?; - let app_config = load_game_creator_app_config()?; - if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER { - let reply = crate::agent::direct_game_creator_home_codex_chat( - LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT.trim().to_string(), - user_prompt, - ) - .await?; - return normalize_polished_prompt(&reply) - .ok_or_else(|| "AI 润色没有返回可用文本".to_string()); - } - let mut llm = app_config.llm.clone(); - llm.max_retries = 0; - let client = build_game_creator_llm_client_from_llm_config(&llm, "llm")?; - let request = LlmRunRequest::single_turn( - LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT.trim(), - user_prompt, - ) - .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) - .with_model(llm.model.clone()) - .with_request_timeout_ms(llm.request_timeout_ms) - .with_max_output_tokens(LOCAL_PROJECT_PROMPT_POLISH_MAX_OUTPUT_TOKENS) - .with_web_search(false); - let response = client - .run(request) - .await - .map_err(|error| format!("AI 润色失败:{error}"))?; - normalize_polished_prompt(&response.text).ok_or_else(|| "AI 润色没有返回可用文本".to_string()) -} - #[cfg(test)] mod local_project_prompt_polish_tests { use super::*; @@ -583,61 +492,6 @@ pub(crate) fn create_automatic_local_game_project_at( Err("自动工作区命名冲突,请重试".to_string()) } -#[tauri::command] -pub(crate) fn create_automatic_local_game_project( - app: tauri::AppHandle, - name: Option, - planning: Option, - projects_root: Option, -) -> Result { - let analytics_context = crate::analytics::gui::capture_analytics_context(); - let result = create_automatic_local_game_project_at( - &resolve_game_project_creation_root(&app, projects_root.as_deref())?, - name.as_deref(), - planning.unwrap_or(false), - ); - if let Ok(project) = &result { - crate::analytics::gui::created( - analytics_context, - Path::new(&project.project_path), - project.manifest.project_id.clone(), - if planning.unwrap_or(false) { - crate::analytics::contract::CreationSource::HomeDesign - } else { - crate::analytics::contract::CreationSource::HomeGame - }, - None, - ); - } - result -} - -#[tauri::command] -pub(crate) fn init_local_game_project( - project_path: String, - project_id: String, - name: String, -) -> Result { - let analytics_context = crate::analytics::gui::capture_analytics_context(); - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.create")?; - let _lock = acquire_project_write_lock(root, "project.create")?; - let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); - let result = init_local_game_project_at(root, project_id.trim(), name.trim()); - if new_project { - if let Ok(project) = &result { - crate::analytics::gui::created( - analytics_context, - Path::new(&project.project_path), - project.manifest.project_id.clone(), - crate::analytics::contract::CreationSource::SelectedDirectory, - None, - ); - } - } - result -} - #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct RenameLocalProjectResult { @@ -701,67 +555,6 @@ pub(crate) fn import_local_godot_project( result } -#[tauri::command] -pub(crate) fn import_local_cocos_project( - project_path: String, - project_id: String, - name: String, -) -> Result { - let analytics_context = crate::analytics::gui::capture_analytics_context(); - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.create")?; - if discover_local_cocos_project_root(root)?.is_none() { - return Err( - "所选目录不是有效的 Cocos Creator 项目(需要 package.json.creator.version 和 assets/)" - .to_string(), - ); - } - let _lock = acquire_project_write_lock(root, "project.create")?; - let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); - let result = import_local_cocos_project_at(root, project_id.trim(), name.trim()); - if new_project { - if let Ok(project) = &result { - crate::analytics::gui::created( - analytics_context, - Path::new(&project.project_path), - project.manifest.project_id.clone(), - crate::analytics::contract::CreationSource::SelectedDirectory, - None, - ); - } - } - result -} - -#[tauri::command] -pub(crate) fn import_local_unity_project( - project_path: String, - project_id: String, - name: String, -) -> Result { - let analytics_context = crate::analytics::gui::capture_analytics_context(); - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.create")?; - if discover_local_unity_project_root(root)?.is_none() { - return Err("所选目录不是有效的 Unity 项目".to_string()); - } - let _lock = acquire_project_write_lock(root, "project.create")?; - let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); - let result = import_local_unity_project_at(root, project_id.trim(), name.trim()); - if new_project { - if let Ok(project) = &result { - crate::analytics::gui::created( - analytics_context, - Path::new(&project.project_path), - project.manifest.project_id.clone(), - crate::analytics::contract::CreationSource::SelectedDirectory, - None, - ); - } - } - result -} - #[tauri::command] pub(crate) fn is_local_project_directory_non_empty(project_path: String) -> Result { let root = Path::new(project_path.trim()); @@ -789,15 +582,6 @@ pub(crate) fn is_local_project_directory_non_empty(project_path: String) -> Resu .map(|entry| entry.is_some()) } -#[tauri::command] -pub(crate) async fn inspect_local_project_directory( - project_path: String, -) -> Result { - tauri::async_runtime::spawn_blocking(move || inspect_local_project_directory_sync(project_path)) - .await - .map_err(|error| format!("检查项目目录后台任务失败:{error}"))? -} - pub(crate) fn inspect_local_project_directory_sync( project_path: String, ) -> Result { @@ -891,84 +675,6 @@ pub(crate) fn recent_game_creator_run_trace(root: &Path) -> Option(&content).ok() } -/// 目录选择器标题:调用方只能给短标题,其余(超长、含控制字符、空白)一律回退默认文案。 -fn pick_project_directory_title(title: Option<&str>) -> &str { - const MAX_TITLE_CHARS: usize = 24; - title - .map(str::trim) - .filter(|value| { - !value.is_empty() - && value.chars().count() <= MAX_TITLE_CHARS - && !value.chars().any(char::is_control) - }) - .unwrap_or("选择游戏项目目录") -} - -#[tauri::command] -pub(crate) async fn pick_local_project_directory( - app: tauri::AppHandle, - initial_path: Option, - title: Option, -) -> Result, String> { - let (sender, receiver) = tokio::sync::oneshot::channel(); - let mut dialog = app - .dialog() - .file() - .set_title(pick_project_directory_title(title.as_deref())); - if let Some(initial_path) = initial_path - .as_deref() - .map(str::trim) - .filter(|path| !path.is_empty()) - { - let initial_path = Path::new(initial_path); - if initial_path.is_absolute() && !project_path_has_control_chars(initial_path) { - let starting_directory = closest_existing_project_picker_directory(initial_path); - if let Some(starting_directory) = starting_directory { - dialog = dialog.set_directory(starting_directory); - } - } - } - if let Some(window) = app.get_webview_window("client") { - dialog = dialog.set_parent(&window); - } - dialog.pick_folder(move |path| { - let _ = sender.send(path); - }); - let Some(path) = receiver - .await - .map_err(|_| "项目目录选择器意外关闭".to_string())? - else { - return Ok(None); - }; - let path = path - .into_path() - .map_err(|error| format!("读取项目目录失败:{error}"))?; - #[cfg(windows)] - crate::register_game_creator_user_selected_path(&path, true); - // The native picker is the explicit user-selection boundary. Prepare the - // selected root before returning it so inspect/create/open never races the - // first ACL read. - if let Err(error) = - crate::prepare_game_creator_project_root_for_read(&path, true, "用户选择项目目录") - { - #[cfg(windows)] - crate::revoke_game_creator_user_selected_path(&path); - return Err(error); - } - Ok(Some(path.to_string_lossy().into_owned())) -} - -#[tauri::command] -pub(crate) fn open_local_project_directory( - app: tauri::AppHandle, - project_path: String, -) -> Result<(), String> { - let path = validated_local_project_directory_path(project_path.trim())?; - app.opener() - .open_path(path.to_string_lossy().into_owned(), None::<&str>) - .map_err(|error| format!("打开项目目录失败:{error}")) -} - pub(crate) fn validated_local_project_directory_path( project_path: &str, ) -> Result { @@ -992,18 +698,6 @@ pub(crate) fn validated_local_project_directory_path( Ok(path.to_path_buf()) } -#[tauri::command] -pub(crate) async fn get_local_game_manifest( - project_path: String, - command_id: Option, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - get_local_game_manifest_sync(project_path, command_id) - }) - .await - .map_err(|error| format!("读取项目 manifest 后台任务失败:{error}"))? -} - pub(crate) fn get_local_game_manifest_sync( project_path: String, command_id: Option, @@ -1052,17 +746,6 @@ pub(crate) fn read_local_project_resource_canvas_layout( read_project_resource_canvas_layout_at(root, mode) } -#[tauri::command] -pub(crate) fn read_local_project_resource_graph( - project_path: String, - expected_project_id: String, - resources: Vec, -) -> Result { - let root = validated_local_project_directory_path(project_path.trim())?; - enforce_project_auto_permission_policy(&root, "asset.list")?; - read_project_resource_graph_at(&root, expected_project_id.trim(), resources) -} - /// 保存资源画布的持久化布局。 /// /// 权限位取 `asset.register`,依据是同批「改动项目内资源相关持久化数据」的既有用法:本文件里 @@ -1091,154 +774,6 @@ pub(crate) fn update_local_project_resource_canvas_layout( ) } -/// 读取引用了某个素材的项目版本,供删除弹窗提示「被哪些版本使用」。 -#[tauri::command] -pub(crate) fn read_local_project_asset_references( - input: ReadLocalProjectAssetReferencesInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.list")?; - read_manifest_asset_references_at(root, &input.asset_id) -} - -/// 删除一个素材登记,并按用户确认决定是否连带删除引用它的游戏版本。 -/// -/// 素材不可变:只摘掉 manifest 登记,磁盘文件保留。 -#[tauri::command] -pub(crate) fn delete_local_project_asset( - input: DeleteLocalProjectAssetInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - delete_manifest_asset_at( - root, - &input.expected_project_id, - input.expected_project_revision, - &input.asset_id, - input.delete_referenced_versions, - ) -} - -/// 读取某个版本引用的源素材可用的替换候选,并给出后端权威的三项兼容性结论。 -/// -/// 只读:不改 manifest、不推进 revision。候选渲染但禁用,不在前端重算判据。 -#[tauri::command] -pub(crate) fn read_local_project_version_resource_replacement_candidates( - input: ReadLocalProjectVersionReplacementCandidatesInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.list")?; - read_local_project_version_replacement_candidates_at( - root, - &input.source_version_id, - &input.source_resource_id, - ) -} - -/// 用另一个已登记素材替换某个版本引用的素材:改 manifest 绑定,并**追加下一迭代版本**。 -/// -/// 可运行版本不可变(既有版本记录一个字节都不改)、不动资源文件、不建文件副本; -/// 三项兼容性必须同时为 true,否则拒绝并说明哪一项不等。CAS 失败时 manifest 与 revision 都不变。 -#[tauri::command] -pub(crate) fn replace_local_project_version_resource( - input: ReplaceLocalProjectVersionResourceInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - replace_local_project_version_resource_at( - root, - &input.expected_project_id, - input.expected_project_revision, - &input.source_version_id, - &input.source_resource_id, - &input.replacement_resource_id, - ) -} - -/// 重命名一个已登记素材:磁盘文件改名 + 更新 manifest 的 `localPath`,资产 `id` 不变。 -/// -/// 只允许在资产当前所在目录内改名,扩展名必须一致,同目录不得已有同名文件;manifest 写失败 -/// 时把文件改回原名,不留半成品。与删除 / 分类更新同口径:持项目写锁后按 `expectedProjectId` -/// 与当前 revision 做 CAS,失败时磁盘、manifest 与 revision 都不动。 -#[tauri::command] -pub(crate) fn rename_local_project_asset( - input: RenameLocalProjectAssetInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - rename_local_project_asset_at( - root, - &input.expected_project_id, - input.expected_project_revision, - &input.asset_id, - &input.new_file_name, - ) -} - -#[tauri::command] -pub(crate) async fn control_agent_run( - app: tauri::AppHandle, - project_path: String, - action: String, - detail: Option, -) -> Result { - let root = Path::new(project_path.trim()); - let command_id = match action.trim() { - "status" => "agent.run_status", - "kill" => "agent.kill", - "retry" => "agent.retry", - "resume" => "agent.resume", - _ => "agent.run_status", - }; - enforce_project_permission_policy(root, command_id)?; - if matches!(action.trim(), "retry" | "resume") { - enforce_project_permission_policy(root, "game.generate_draft")?; - } - let _lock = if command_id == "agent.run_status" { - None - } else { - Some(acquire_project_write_lock(root, command_id)?) - }; - control_agent_run_at( - root, - action.trim(), - detail - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()), - Some(&AgentProgressEmitter::new(&app, project_path.trim())), - ) - .await -} - -#[tauri::command] -pub(crate) async fn generate_local_game_draft( - app: tauri::AppHandle, - project_path: String, - prompt: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "game.generate_draft")?; - let _lock = acquire_project_write_lock(root, "game.generate_draft")?; - advance_agent_runtime_project_revision_locked(root)?; - generate_local_game_draft_at( - root, - prompt.trim(), - Some(&AgentProgressEmitter::new(&app, project_path.trim())), - ) - .await -} - -#[tauri::command] -pub(crate) async fn chat_with_game_creator_agent( - project_path: String, - prompt: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - chat_with_game_creator_agent_at(root, prompt.trim()).await -} - #[tauri::command] pub(crate) async fn chat_with_game_creator_role_agent( project_path: String, @@ -1268,206 +803,6 @@ pub(crate) async fn chat_with_game_creator_role_agent( } } -#[tauri::command] -pub(crate) async fn chat_with_game_creator_role_agent_stream( - app: tauri::AppHandle, - project_path: String, - agent_id: String, - session_id: Option, - prompt: String, - run_id: String, -) -> Result { - let project_path = project_path.trim().to_string(); - let agent_id = normalize_game_creator_runtime_agent_id(agent_id.trim())?; - let run_id = run_id.trim().to_string(); - let root = Path::new(project_path.as_str()); - let session_id = - resolve_agent_conversation_session_id_at(root, &agent_id, session_id.as_deref(), true)?; - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)? - .ok_or_else(|| format!("Agent 正在执行其他前台或后台任务:{agent_id}"))?; - let emit_app = app.clone(); - let event_project_path = project_path.clone(); - let event_agent_id = agent_id.clone(); - let event_run_id = run_id.clone(); - let command_result = async { - let mut runtime_state = start_game_creator_agent_runtime_turn_for_session_at( - root, - agent_id.as_str(), - Some(&session_id), - prompt.trim(), - &run_id, - )?; - runtime_state = advance_game_creator_agent_runtime_turn_at( - root, - runtime_state, - "llm", - "请求 Agent LLM", - "已读取项目上下文,正在让 Agent 独立推理。", - )?; - let mut streaming_runtime_state = runtime_state.clone(); - streaming_runtime_state.current_action = "正在接收 Agent 回复".to_string(); - let _ = app.emit( - "game-creator-role-agent-chat-stream", - GameCreatorRoleAgentChatStreamEvent { - project_path: event_project_path.clone(), - agent_id: event_agent_id.clone(), - run_id: event_run_id.clone(), - status: "started".to_string(), - delta_text: String::new(), - accumulated_text: String::new(), - finish_reason: None, - session_id: Some(runtime_state.session_id.clone()), - runtime_status: Some(runtime_state.status.clone()), - runtime_phase: Some(runtime_state.phase.clone()), - runtime_summary: Some(runtime_state.current_action.clone()), - runtime_state: Some(runtime_state.clone()), - }, - ); - let result = chat_with_game_creator_role_agent_stream_for_session_at( - root, - agent_id.as_str(), - Some(&session_id), - prompt.trim(), - |delta| { - let _ = emit_app.emit( - "game-creator-role-agent-chat-stream", - GameCreatorRoleAgentChatStreamEvent { - project_path: event_project_path.clone(), - agent_id: event_agent_id.clone(), - run_id: event_run_id.clone(), - status: "delta".to_string(), - delta_text: delta.delta_text.clone(), - accumulated_text: delta.accumulated_text.clone(), - finish_reason: delta.finish_reason.clone(), - session_id: Some(streaming_runtime_state.session_id.clone()), - runtime_status: Some("running".to_string()), - runtime_phase: Some("llm".to_string()), - runtime_summary: Some("正在接收 Agent 回复".to_string()), - runtime_state: Some(streaming_runtime_state.clone()), - }, - ); - }, - ) - .await; - match result { - Ok(reply) => { - let completed_runtime = finish_game_creator_agent_runtime_turn_at( - root, - runtime_state, - &reply.reply_text, - )?; - let _ = app.emit( - "game-creator-role-agent-chat-stream", - GameCreatorRoleAgentChatStreamEvent { - project_path: project_path.clone(), - agent_id: agent_id.clone(), - run_id: run_id.clone(), - status: "completed".to_string(), - delta_text: String::new(), - accumulated_text: reply.reply_text.clone(), - finish_reason: None, - session_id: Some(completed_runtime.session_id.clone()), - runtime_status: Some(completed_runtime.status.clone()), - runtime_phase: Some(completed_runtime.phase.clone()), - runtime_summary: Some(completed_runtime.current_action.clone()), - runtime_state: Some(completed_runtime), - }, - ); - Ok(reply) - } - Err(error) => { - let failed_runtime = - fail_game_creator_agent_runtime_turn_at(root, runtime_state, &error).ok(); - let _ = app.emit( - "game-creator-role-agent-chat-stream", - GameCreatorRoleAgentChatStreamEvent { - project_path: project_path.clone(), - agent_id: agent_id.clone(), - run_id: run_id.clone(), - status: "failed".to_string(), - delta_text: String::new(), - accumulated_text: String::new(), - finish_reason: None, - session_id: failed_runtime - .as_ref() - .map(|runtime| runtime.session_id.clone()), - runtime_status: failed_runtime - .as_ref() - .map(|runtime| runtime.status.clone()), - runtime_phase: failed_runtime.as_ref().map(|runtime| runtime.phase.clone()), - runtime_summary: failed_runtime - .as_ref() - .map(|runtime| runtime.current_action.clone()), - runtime_state: failed_runtime, - }, - ); - Err(error) - } - } - } - .await; - spawn_next_game_creator_agent_background_task_drain_with_lock(root, &agent_id, runtime_lock); - match command_result { - Ok(reply) => Ok(reply), - Err(error) => Err(error), - } -} - -#[tauri::command] -pub(crate) fn start_game_creator_supervisor_runtime_task( - project_path: String, - session_id: Option, - task: String, - run_id: String, - run_profile: Option, - source: Option, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - enforce_project_permission_policy(root, "agent.run_status")?; - let run_profile = run_profile - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(AGENT_RUNTIME_RUN_PROFILE_STANDARD); - let source = source - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE); - if !agent_runtime_supervisor_source_is_trusted(source) { - return Err("Project Supervisor 提交 source 不受信任".to_string()); - } - start_game_creator_supervisor_background_task_for_session_at( - root, - session_id.as_deref(), - task.trim(), - run_id.trim(), - source, - run_profile, - ) -} - -#[tauri::command] -pub(crate) async fn compact_game_creator_agent_runtime_context( - project_path: String, - agent_id: String, - session_id: Option, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "agent.compact")?; - if external_agent_runner_enabled() && !external_agent_runner_is_server_process() { - compact_external_agent_runner_context(root, agent_id.trim(), session_id.as_deref()) - } else { - compact_game_creator_agent_runtime_session_at(root, agent_id.trim(), session_id.as_deref()) - .await - } -} - #[tauri::command] pub(crate) fn read_game_creator_agent_goal( project_path: String, @@ -1596,19 +931,6 @@ pub(crate) fn clear_game_creator_agent_goal( ) } -#[tauri::command] -pub(crate) fn cancel_game_creator_agent_runtime_task( - project_path: String, - agent_id: String, - run_id: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - enforce_project_permission_policy(root, "agent.run_status")?; - cancel_game_creator_agent_runtime_task_at(root, agent_id.trim(), run_id.trim()) -} - #[tauri::command] pub(crate) fn retry_game_creator_agent_runtime_task( project_path: String, @@ -1696,52 +1018,6 @@ pub(crate) fn reject_game_creator_agent_runtime_task( ) } -#[tauri::command] -pub(crate) fn answer_game_creator_agent_runtime_user_input( - project_path: String, - agent_id: String, - run_id: String, - action_id: String, - request_id: String, - response_id: String, - answers: BTreeMap, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - enforce_project_permission_policy(root, "agent.run_status")?; - enforce_project_permission_policy(root, "agent.resume")?; - answer_game_creator_agent_runtime_user_input_at( - root, - agent_id.trim(), - run_id.trim(), - action_id.trim(), - request_id.trim(), - response_id.trim(), - answers, - ) -} - -#[tauri::command] -pub(crate) fn read_game_creator_agent_runtime( - project_path: String, - agent_id: String, - session_id: Option, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - read_game_creator_agent_runtime_for_session_at(root, agent_id.trim(), session_id.as_deref()) -} - -#[tauri::command] -pub(crate) fn read_game_creator_agent_runtimes( - project_path: String, -) -> Result, String> { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - read_game_creator_agent_runtimes_at(root) -} - #[tauri::command] pub(crate) fn resume_game_creator_agent_runtime_tasks( project_path: String, @@ -1794,81 +1070,6 @@ pub(crate) async fn check_game_creator_llm_config() -> Result Result, String> { - request_automatic_project_name(prompt.trim()) - .await - .map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320)) -} - -#[tauri::command] -pub(crate) async fn polish_local_project_prompt( - prompt: String, - context: Option, -) -> Result { - request_local_project_prompt_polish(prompt.trim(), context.as_deref()) - .await - .map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320)) -} - -#[tauri::command] -pub(crate) fn read_platform_account_session_state( -) -> crate::platform_session::PlatformSessionWriteState { - crate::platform_session::current_platform_session_write_state() -} - -#[tauri::command] -pub(crate) async fn install_platform_account_session( - user_id: String, - access_token: String, - api_base_url: String, - identity_generation: u64, - revision: u64, -) -> Result<(), String> { - tokio::task::spawn_blocking(move || { - validate_platform_session_input( - &user_id, - &access_token, - &api_base_url, - identity_generation, - revision, - )?; - install_external_agent_runner_platform_session( - &user_id, - &access_token, - &api_base_url, - identity_generation, - revision, - )?; - install_platform_session( - &user_id, - &access_token, - &api_base_url, - identity_generation, - revision, - ) - }) - .await - .map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))? -} - -#[tauri::command] -pub(crate) async fn clear_platform_account_session( - identity_generation: u64, - revision: u64, -) -> Result<(), String> { - tokio::task::spawn_blocking(move || { - shutdown_game_creator_codex_app_servers()?; - clear_external_agent_runner_platform_session(identity_generation, revision)?; - clear_platform_session(identity_generation, revision); - Ok(()) - }) - .await - .map_err(|error| format!("清除本地运行时会话任务意外终止:{error}"))? -} - #[tauri::command] pub(crate) fn read_game_creator_app_config() -> Result { game_creator_app_config_view(load_game_creator_app_config()?) @@ -1891,29 +1092,6 @@ pub(crate) fn write_game_creator_app_config( persist_game_creator_app_config(config, overlays, false) } -#[tauri::command] -pub(crate) fn cancel_direct_codex_turn( - project_path: String, - client_turn_id: Option, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "agent.kill")?; - cancel_direct_codex_turn_at(root, client_turn_id.as_deref()) -} - -#[tauri::command] -pub(crate) fn select_game_creator_reasoning_effort( - effort: String, -) -> Result { - let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK - .lock() - .map_err(|_| "配置写入锁不可用")?; - let effort = game_creator_llm_reasoning_effort_name(&effort, "llm.reasoningEffort")?; - let (mut config, overlays) = load_game_creator_app_config_for_write()?; - config.llm.reasoning_effort = effort; - persist_game_creator_app_config(config, overlays, false) -} - #[tauri::command] pub(crate) fn select_game_creator_model( model_id: String, @@ -1940,16 +1118,6 @@ pub(crate) fn select_game_creator_model( persist_game_creator_app_config(config, overlays, true) } -#[tauri::command] -pub(crate) async fn discover_game_creator_llm_models( - llm: GameCreatorLlmConfig, -) -> Result, String> { - if !load_game_creator_app_config()?.llm.custom_enabled { - return Err("请先在本地配置中开启 llm.customEnabled".to_string()); - } - fetch_custom_llm_models(&llm).await -} - fn persist_game_creator_app_config( config: GameCreatorAppConfig, overlays: Vec<(PathBuf, serde_json::Value)>, @@ -2007,59 +1175,6 @@ fn update_existing_config_overlay_fields( } } -#[tauri::command] -pub(crate) fn upload_local_asset( - project_path: String, - file_name: String, - media_type: String, - bytes: Vec, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "asset.upload")?; - let _lock = acquire_project_write_lock(root, "asset.upload")?; - advance_agent_runtime_project_revision_locked(root)?; - upload_local_asset_at(root, file_name.trim(), media_type.trim(), &bytes) -} - -#[tauri::command] -pub(crate) fn register_local_asset( - project_path: String, - local_path: String, - kind: String, - media_type: String, - source_kind: String, - canvas_project_id: Option, - resource_id: Option, - asset_object_id: Option, - task_id: Option, - prompt: Option, - model: Option, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - let _lock = acquire_project_write_lock(root, "asset.register")?; - advance_agent_runtime_project_revision_locked(root)?; - register_local_asset_at( - root, - local_path.trim(), - crate::assets::registration_asset_kind(&kind, "asset.register"), - media_type.trim(), - source_kind.trim(), - GameCreationAppAssetSource { - kind: parse_asset_source_kind(source_kind.trim())?, - canvas_project_id: trim_optional_string(canvas_project_id), - resource_id: trim_optional_string(resource_id), - asset_object_id: trim_optional_string(asset_object_id), - task_id: trim_optional_string(task_id), - prompt: trim_optional_string(prompt), - model: trim_optional_string(model), - generation_route: None, - generation_kind: None, - reference_resource_ids: Vec::new(), - }, - ) -} - #[tauri::command] pub(crate) fn create_ui_design_resource( project_path: String, @@ -2176,22 +1291,6 @@ pub(crate) fn create_ui_design_resource( }) } -#[tauri::command] -pub(crate) fn update_local_project_resource_classification( - input: UpdateLocalProjectResourceClassificationInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - update_manifest_asset_classification_at( - root, - &input.expected_project_id, - input.expected_project_revision, - &input.asset_id, - &input.category, - input.tags, - ) -} - /// 为一批已登记素材追加标签:整批一次校验、一次 manifest 写入、一次 revision 推进。 /// /// 权限位与单素材分类更新同口径取 `asset.register`(命令包装层只做权限门面, @@ -2213,131 +1312,6 @@ pub(crate) fn add_local_project_resource_tags( ) } -#[tauri::command] -pub(crate) async fn derive_local_project_resource( - input: DeriveLocalProjectResourceInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - derive_local_project_resource_at(input).await -} - -#[tauri::command] -pub(crate) fn list_pending_local_project_resource_edits( - input: ListPendingLocalProjectResourceEditsInput, -) -> Result, String> { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "file.list")?; - list_pending_local_project_resource_edits_at(input) -} - -#[tauri::command] -pub(crate) async fn resume_local_project_resource_edit( - input: ResumeLocalProjectResourceEditInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - resume_local_project_resource_edit_at(input).await -} - -#[tauri::command] -pub(crate) async fn request_local_project_resource_edit_service_identity_confirmation( - input: RequestResourceEditServiceIdentityConfirmationInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - request_resource_edit_service_identity_confirmation_at(input).await -} - -#[tauri::command] -pub(crate) async fn confirm_local_project_resource_edit_service_identity( - input: ConfirmResourceEditServiceIdentityInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - confirm_resource_edit_service_identity_at(input).await -} - -#[tauri::command] -pub(crate) async fn archive_failed_local_project_resource_edit( - input: ArchiveFailedLocalProjectResourceEditInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - archive_failed_local_project_resource_edit_at(input).await -} - -#[tauri::command] -pub(crate) fn normalize_local_project_raster_resource( - input: NormalizeLocalProjectRasterResourceInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - normalize_local_project_raster_resource_at(input) -} - -#[tauri::command] -pub(crate) fn import_canvas_asset( - project_path: String, - local_path: String, - kind: String, - media_type: String, - canvas_project_id: String, - resource_id: Option, - asset_object_id: Option, - task_id: Option, - prompt: Option, - model: Option, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "canvas.asset_import")?; - let _lock = acquire_project_write_lock(root, "canvas.asset_import")?; - advance_agent_runtime_project_revision_locked(root)?; - import_canvas_asset_at( - root, - local_path.trim(), - registration_asset_kind(kind.trim(), "canvas.asset_import"), - media_type.trim(), - canvas_project_id.trim(), - trim_optional_string(resource_id), - trim_optional_string(asset_object_id), - trim_optional_string(task_id), - trim_optional_string(prompt), - trim_optional_string(model), - ) -} - -#[tauri::command] -pub(crate) fn import_canvas_export( - project_path: String, - export_path: String, - canvas_project_id: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "canvas.export_import")?; - let _lock = acquire_project_write_lock(root, "canvas.export_import")?; - advance_agent_runtime_project_revision_locked(root)?; - import_canvas_export_at( - root, - Path::new(export_path.trim()), - canvas_project_id.trim(), - ) -} - -#[tauri::command] -pub(crate) async fn sync_canvas_project_assets( - project_path: String, - canvas_project_id: String, - api_base_url: Option, - api_key: Option, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "canvas.project_sync")?; - let _lock = acquire_project_write_lock(root, "canvas.project_sync")?; - advance_agent_runtime_project_revision_locked(root)?; - sync_canvas_project_assets_at(root, canvas_project_id.trim(), api_base_url, api_key).await -} - pub(crate) fn import_ui_editor_local_files( project_path: String, source_paths: Vec, @@ -2478,25 +1452,6 @@ fn find_registered_ui_editor_font<'a>( .ok_or_else(|| "字体不是当前项目已登记资产".to_string()) } -#[tauri::command] -pub(crate) fn prepare_ui_editor_project_fonts( - project_path: String, - assets: Vec, -) -> Result, String> { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "asset.list")?; - if assets.len() > UI_EDITOR_FONT_MAX_COUNT { - return Err("一次最多选择 64 个字体面".to_string()); - } - let manifest = read_existing_manifest_for_project(root)?; - let mut fonts = Vec::with_capacity(assets.len()); - for selected in assets { - let asset = find_registered_ui_editor_font(&manifest, &selected.id, &selected.local_path)?; - fonts.push(read_registered_ui_editor_font(root, asset)?.0); - } - Ok(fonts) -} - pub(crate) fn import_ui_editor_local_fonts( project_path: String, source_paths: Vec, @@ -2659,56 +1614,6 @@ pub(crate) fn import_ui_editor_local_fonts( }) } -fn read_checked_ui_editor_font( - project_path: &str, - asset_id: &str, - relative_path: &str, - expected_sha256: &str, -) -> Result<(FontAsset, Vec), String> { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "file.read")?; - let manifest = read_existing_manifest_for_project(root)?; - let asset = find_registered_ui_editor_font(&manifest, asset_id, relative_path)?; - let (font, bytes) = read_registered_ui_editor_font(root, asset)?; - if font.content_sha256 != expected_sha256 { - return Err("字体文件内容与资源摘要不一致".to_string()); - } - Ok((font, bytes)) -} - -#[tauri::command] -pub(crate) fn read_ui_editor_font_bytes( - project_path: String, - asset_id: String, - relative_path: String, - expected_sha256: String, -) -> Result { - read_checked_ui_editor_font( - &project_path, - asset_id.trim(), - relative_path.trim(), - expected_sha256.trim(), - ) - .map(|(_, bytes)| tauri::ipc::Response::new(bytes)) -} - -#[tauri::command] -pub(crate) fn check_ui_editor_font_glyph_coverage( - project_path: String, - asset_id: String, - relative_path: String, - expected_sha256: String, - content: String, -) -> Result { - let (font, bytes) = read_checked_ui_editor_font( - &project_path, - asset_id.trim(), - relative_path.trim(), - expected_sha256.trim(), - )?; - font.has_missing_glyphs(&bytes, &content) -} - #[cfg(test)] mod ui_editor_font_tests { use super::*; @@ -4718,17 +3623,6 @@ async fn download_ui_editor_remote_asset(url: &str, max_bytes: u64) -> Result Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "canvas.asset_generate")?; - let generated = generate_platform_art_asset_at(root, prompt.trim(), &[]).await?; - Ok(generated.asset) -} - /// 栏目画布底部工具栏使用的「无源生成图片类素材」入参边界。 /// kind 目录由 `canvas_generation::PLATFORM_ART_ASSET_GENERATION_KINDS` 统一维护, /// 这里只收口比例、尺寸与文本长度,且与 agent 侧同一条通道保持同一量级。 @@ -5167,19 +4061,6 @@ mod local_project_asset_generation_tests { } } -#[tauri::command] -pub(crate) fn open_canvas_project( - app: tauri::AppHandle, - canvas_project_id: Option, - editor_base_url: Option, -) -> Result { - let url = build_canvas_project_url(editor_base_url.as_deref(), canvas_project_id.as_deref())?; - app.opener() - .open_url(&url, None::<&str>) - .map_err(|error| format!("打开画板失败:{error}"))?; - Ok(OpenCanvasProjectResult { url }) -} - #[tauri::command] pub(crate) fn run_limited_local_command( project_path: String, @@ -5196,19 +4077,6 @@ pub(crate) fn run_limited_local_command( Ok(result) } -#[tauri::command] -pub(crate) fn append_local_permission_log( - project_path: String, - event: String, - command_id: String, -) -> Result<(), String> { - append_local_permission_log_at( - Path::new(project_path.trim()), - event.trim(), - command_id.trim(), - ) -} - #[tauri::command] pub(crate) fn list_local_project_files( project_path: String, @@ -5218,25 +4086,6 @@ pub(crate) fn list_local_project_files( list_local_project_files_at(root) } -/// 登记当前项目中已经存在、但尚未写入 manifest 的本地资源。 -/// -/// 该入口只接受项目根相对路径;实际文件签名、大小、敏感路径、项目锁和 -/// manifest/revision 更新统一复用 Agent 的受控导入实现。未登记文件在调用前 -/// 只能作为候选展示,不能由前端构造 asset ID。 -#[tauri::command] -pub(crate) async fn import_local_project_image_assets( - project_path: String, - relative_paths: Vec, -) -> Result { - let root = PathBuf::from(project_path.trim()); - tokio::task::spawn_blocking(move || { - import_local_project_assets_for_agent(&root, &relative_paths) - }) - .await - .map_err(|error| format!("项目内资源登记任务意外终止:{error}")) - .and_then(|result| result) -} - #[tauri::command] pub(crate) fn read_local_project_file( project_path: String, @@ -5261,27 +4110,6 @@ pub(crate) fn read_local_project_file( read_local_project_file_at(root, &normalized_path) } -#[tauri::command] -pub(crate) fn save_local_project_asset_file( - input: SaveLocalProjectAssetFileInput, -) -> Result { - save_local_project_asset_file_at(input) -} -#[tauri::command] -pub(crate) async fn read_local_project_image_preview( - preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, - project_path: String, - relative_path: String, - scope_id: String, - request_id: String, -) -> Result { - preview_manager - .run(&scope_id, &request_id, move |cancellation| { - read_local_project_image_preview_at(&project_path, &relative_path, cancellation) - }) - .await -} - pub(crate) fn read_local_project_image_preview_at( project_path: &str, relative_path: &str, @@ -5311,21 +4139,6 @@ pub(crate) fn read_local_project_image_preview_at( load_local_project_image_preview_with_cancellation(root, &normalized_path, cancellation) } -#[tauri::command] -pub(crate) async fn read_local_project_text_preview( - preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, - project_path: String, - relative_path: String, - scope_id: String, - request_id: String, -) -> Result { - preview_manager - .run(&scope_id, &request_id, move |cancellation| { - read_local_project_text_preview_at(&project_path, &relative_path, cancellation) - }) - .await -} - pub(crate) fn read_local_project_text_preview_at( project_path: &str, relative_path: &str, @@ -5377,78 +4190,6 @@ pub(crate) fn read_local_project_text_preview_at( * UI 编辑链路的准入;这里只服务资源画布的卡面预览,且允许非 UTF-8 的二进制变体 * 降级成类型卡(`content: null`),不把「不能预览」报成错误。 */ -#[tauri::command] -pub(crate) async fn read_local_project_structured_preview( - preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, - project_path: String, - relative_path: String, - scope_id: String, - request_id: String, -) -> Result { - preview_manager - .run(&scope_id, &request_id, move |cancellation| { - read_local_project_structured_preview_at(&project_path, &relative_path, cancellation) - }) - .await -} - -pub(crate) fn read_local_project_structured_preview_at( - project_path: &str, - relative_path: &str, - cancellation: &ProjectResourcePreviewScopeCancellation, -) -> Result { - cancellation.check()?; - let root = Path::new(project_path.trim()); - enforce_project_auto_permission_policy(root, "file.read")?; - cancellation.check()?; - let normalized_path = normalize_relative_path(relative_path.trim())?; - let manifest = read_manifest_cached_for_preview(&root.join(".agent/manifest.json"))?; - cancellation.check()?; - let registered_media_type = manifest - .assets - .iter() - .find(|asset| asset.local_path == normalized_path) - .map(|asset| asset.media_type.clone()); - let is_registered_structured = registered_media_type.as_deref().is_some_and(|media_type| { - is_supported_project_structured_resource(&normalized_path, media_type) - }) || manifest.tasks.iter().any(|task| { - task.status == GameCreationAppTaskStatus::Completed - && task.artifacts.iter().any(|path| path == &normalized_path) - && is_supported_project_structured_resource(&normalized_path, "") - }); - if !is_registered_structured { - return Err("只能读取当前项目已登记的引擎资源".to_string()); - } - cancellation.check()?; - load_local_project_structured_preview_with_cancellation( - root, - &normalized_path, - registered_media_type.as_deref().unwrap_or(""), - cancellation, - ) -} - -#[tauri::command] -pub(crate) async fn read_local_project_media_preview( - preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, - project_path: String, - relative_path: String, - category: String, - scope_id: String, - request_id: String, -) -> Result { - preview_manager - .run(&scope_id, &request_id, move |cancellation| { - read_local_project_media_preview_at( - &project_path, - &relative_path, - &category, - cancellation, - ) - }) - .await -} - pub(crate) fn read_local_project_media_preview_at( project_path: &str, relative_path: &str, @@ -5505,14 +4246,6 @@ pub(crate) fn read_local_project_media_preview_at( load_local_project_media_preview_with_cancellation(root, &normalized_path, kind, cancellation) } -#[tauri::command] -pub(crate) fn cancel_local_project_resource_preview_scope( - preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, - scope_id: String, -) -> Result<(), String> { - preview_manager.cancel_scope(&scope_id) -} - type ManualAnalyticsCapture = Option<( crate::analytics::contract::Context, crate::analytics::store::AnalyticsWriter, @@ -5570,79 +4303,6 @@ pub(crate) fn write_local_game_memory( write_local_game_memory_at(root, scope.trim(), &content) } -#[tauri::command] -pub(crate) fn list_game_creator_agent_sessions( - project_path: String, - agent_id: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - list_game_creator_agent_sessions_at(root, agent_id.trim()) -} - -#[tauri::command] -pub(crate) fn create_game_creator_agent_session( - project_path: String, - agent_id: String, - title: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - // 首轮策划消息可能紧跟项目初始化写入到达;对话保存应等待这段短暂的 - // 项目锁竞争,避免把可恢复的初始化竞态直接显示成保存失败。 - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "conversation.write", - )?; - create_game_creator_agent_session_at(root, agent_id.trim(), title.trim()) -} - -#[tauri::command] -pub(crate) fn fork_game_creator_agent_session( - project_path: String, - agent_id: String, - source_session_id: String, - title: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - let _lock = acquire_project_write_lock(root, "conversation.write")?; - fork_game_creator_agent_session_at( - root, - agent_id.trim(), - source_session_id.trim(), - title.trim(), - ) -} - -#[tauri::command] -pub(crate) fn set_active_game_creator_agent_session( - project_path: String, - agent_id: String, - session_id: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - let _lock = acquire_project_write_lock(root, "conversation.write")?; - set_active_game_creator_agent_session_at(root, agent_id.trim(), session_id.trim()) -} - -#[tauri::command] -pub(crate) fn archive_game_creator_agent_session( - project_path: String, - agent_id: String, - session_id: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - enforce_project_permission_policy(root, "conversation.write")?; - let _lock = acquire_project_write_lock(root, "conversation.write")?; - archive_game_creator_agent_session_at(root, agent_id.trim(), session_id.trim()) -} - #[tauri::command] pub(crate) async fn read_local_conversation( project_path: String, @@ -5658,125 +4318,6 @@ pub(crate) async fn read_local_conversation( .map_err(|error| format!("读取项目对话后台任务失败:{error}"))? } -#[tauri::command] -pub(crate) async fn read_direct_project_conversation( - project_path: String, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - read_direct_project_chat_history_at(root) - }) - .await - .map_err(|error| format!("读取 DirectProject 历史后台任务失败:{error}"))? -} - -#[tauri::command] -pub(crate) async fn read_agent_runtime_error_detail( - project_path: String, - detail_ref: String, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - let relative = detail_ref.trim(); - let Some(file_name) = relative.strip_prefix(".agent/runtime/errors/") else { - return Err("错误诊断引用不在项目错误目录内".to_string()); - }; - if file_name.is_empty() - || file_name.contains(['/', '\\']) - || file_name.contains("..") - || !file_name.ends_with(".json") - { - return Err("错误诊断引用格式无效".to_string()); - } - let path = root.join(relative); - prepare_game_creator_private_path_for_read(&path, false, "统一错误诊断")?; - let bytes = std::fs::read(&path).map_err(|error| format!("读取错误诊断失败:{error}"))?; - if bytes.len() > 16 * 1024 { - return Err("错误诊断超过读取上限".to_string()); - } - let text = String::from_utf8(bytes).map_err(|_| "错误诊断不是 UTF-8 文本".to_string())?; - Ok(redact_agent_runtime_error(root, &text, 16 * 1024)) - }) - .await - .map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))? -} - -#[tauri::command] -pub(crate) fn list_game_creator_direct_active_turns( -) -> Result, String> { - list_direct_active_turns() -} - -#[tauri::command] -pub(crate) async fn subscribe_direct_project_thread( - project_path: String, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - let thread_id = direct_thread_id_for_project(root); - let mut bootstrap = subscribe_direct_thread(&thread_id); - if bootstrap.last_completed_item_id.is_none() { - bootstrap.last_completed_item_id = read_direct_project_last_item_id_at(root)?; - } - Ok(bootstrap) - }) - .await - .map_err(|error| format!("订阅 DirectProject 线程后台任务失败:{error}"))? -} - -#[tauri::command] -pub(crate) fn consume_direct_project_thread( - subscription_id: String, -) -> Result { - consume_direct_thread(subscription_id.trim()) -} - -/// 读一屏项目对话历史。 -/// -/// 窗口两端各由一个锚点给出,两者互斥(都传会报错):`before_item_id` 是**旧端**边界(不含 -/// 该条,向后翻页用),`through_item_id` 是**新端**边界(含该条,取 `subscribe` 回执里的 -/// `lastCompletedItemId`,比它更新的条目只从运行态事件来);都不传就是文件尾最近的一屏。 -#[tauri::command] -pub(crate) async fn read_direct_project_history_slice( - project_path: String, - before_item_id: Option, - through_item_id: Option, - limit: Option, -) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - let anchor = match (before_item_id.as_deref(), through_item_id.as_deref()) { - (Some(_), Some(_)) => { - return Err( - "DirectProject 历史切片只接受一个锚点(beforeItemId / throughItemId)" - .to_string(), - ); - } - (Some(before), None) => DirectProjectHistoryAnchor::Before(before), - (None, Some(through)) => DirectProjectHistoryAnchor::Through(through), - (None, None) => DirectProjectHistoryAnchor::Newest, - }; - let (raw_items, has_more, recorded_at_ms, first_item_id) = - read_direct_project_history_items_slice_at(root, anchor, limit.unwrap_or(20))?; - let items = direct_thread_items_from_history(root, &raw_items, |item| { - direct_thread_item_identity(item) - .and_then(|identity| recorded_at_ms.get(&identity).copied()) - .unwrap_or_default() - }); - Ok(DirectThreadHistorySlice { - items, - has_more, - first_item_id, - }) - }) - .await - .map_err(|error| format!("读取 DirectProject 历史切片后台任务失败:{error}"))? -} - #[tauri::command] pub(crate) fn append_local_conversation_message( project_path: String, @@ -5805,40 +4346,6 @@ pub(crate) fn append_local_conversation_message( } } -#[tauri::command] -pub(crate) fn append_direct_project_conversation_message( - project_path: String, - message: LocalConversationMessage, - message_id: Option, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.write")?; - let item = - direct_project_local_message_item(&message.role, &message.content, message_id.as_deref())?; - append_direct_project_history_item_at(root, &item)?; - read_direct_project_chat_history_at(root) -} - -#[tauri::command] -pub(crate) fn build_local_project_index( - project_path: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.index")?; - let _lock = acquire_project_write_lock(root, "project.index")?; - build_local_project_index_at(root) -} - -#[tauri::command] -pub(crate) fn create_local_project_checkpoint( - project_path: String, -) -> Result { - create_local_project_checkpoint_with_capture( - project_path, - crate::analytics::gui::capture_writer_context(), - ) -} - fn create_local_project_checkpoint_with_capture( project_path: String, capture: ManualAnalyticsCapture, @@ -5879,129 +4386,6 @@ pub(crate) fn checkpoint_with_capture_for_test( create_local_project_checkpoint_with_capture(project_path, capture) } -/// 为发布导出试玩包:项目还没有可玩入口时先跑项目自己的 `npm run build`。 -/// -/// 作者只点一次「发布」:已有 `game/index.html` 或 `dist/index.html` 直接打包;只有源码时 -/// 走 `project.verify` 的受控 npm 运行器构建后再打包,失败信息带构建日志尾部。 -#[tauri::command] -pub(crate) async fn export_local_project_package( - project_path: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.export_package")?; - let _lock = acquire_project_write_lock(root, "project.export_package")?; - advance_agent_runtime_project_revision_locked(root)?; - export_local_project_package_for_publish_at(root).await -} - -/// 把归一化后的发行包落到内容寻址的暂存文件,返回分片续传所需的元数据。 -/// -/// 发布链路从此只把「暂存路径 + 摘要 + 体积」交给渲染进程:整包字节不再经过 -/// WebView IPC,续传时也复用同一个暂存文件(同名同内容)。 -#[tauri::command] -pub(crate) fn prepare_local_project_game_package( - app: tauri::AppHandle, - project_path: String, - package_relative_path: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.export_package")?; - let payload = read_local_project_export_package_at(root, package_relative_path.trim())?; - let staging_dir = game_package_upload_staging_dir(&app)?; - let mut staged = crate::game_package_upload::stage_game_package_bytes( - &staging_dir, - &payload.package_sha256, - &payload.package_bytes, - )?; - staged.package_file_count = u32::try_from(payload.files.len()).unwrap_or(u32::MAX); - Ok(staged) -} - -/// 分片续传上传暂存的发行包;进度通过 `game-package-upload-progress` 事件回传。 -#[tauri::command] -pub(crate) async fn upload_local_project_game_package( - app: tauri::AppHandle, - staging_path: String, - version_id: String, - api_base_url: String, - access_token: String, - idempotency_key: String, -) -> Result { - let staging_dir = game_package_upload_staging_dir(&app)?; - let resolved_path = - crate::game_package_upload::ensure_staging_path_in_dir(&staging_dir, &staging_path)?; - let client = reqwest::Client::builder() - .build() - .map_err(|error| format!("创建上传客户端失败:{error}"))?; - let version_id = version_id.trim().to_string(); - if version_id.is_empty() { - return Err("缺少发行版本标识".to_string()); - } - let emit_handle = app.clone(); - let progress_version_id = version_id.clone(); - crate::game_package_upload::upload_staged_game_package( - &client, - crate::game_package_upload::GamePackageUploadRequest { - staging_path: &resolved_path, - version_id: &version_id, - api_base_url: api_base_url.trim(), - access_token: access_token.trim(), - idempotency_key: idempotency_key.trim(), - }, - move |received_bytes, total_bytes| { - let _ = emit_handle.emit( - crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, - crate::game_package_upload::progress_event_payload( - &progress_version_id, - received_bytes, - total_bytes, - ), - ); - }, - ) - .await -} - -fn game_package_upload_staging_dir(app: &tauri::AppHandle) -> Result { - app.path() - .app_data_dir() - .map(|app_data_root| { - crate::game_package_upload::game_package_upload_staging_dir(&app_data_root) - }) - .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) -} - -#[tauri::command] -pub(crate) fn list_local_project_export_packages( - project_path: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.export_list")?; - list_local_project_export_packages_at(root) -} - -#[tauri::command] -pub(crate) fn diff_local_project_checkpoint( - project_path: String, - checkpoint_id: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.diff")?; - diff_local_project_checkpoint_at(root, checkpoint_id.trim()) -} - -#[tauri::command] -pub(crate) fn restore_local_project_checkpoint( - project_path: String, - checkpoint_id: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.restore")?; - let _lock = acquire_project_write_lock(root, "project.restore")?; - advance_agent_runtime_project_revision_locked(root)?; - restore_local_project_checkpoint_at(root, checkpoint_id.trim()) -} - #[tauri::command] pub(crate) fn read_project_permission_policy( project_path: String, @@ -6010,14 +4394,3 @@ pub(crate) fn read_project_permission_policy( enforce_project_permission_policy(root, "project.policy_read")?; read_project_permission_policy_at(root) } - -#[tauri::command] -pub(crate) fn write_project_permission_policy( - project_path: String, - policy: ProjectPermissionPolicy, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "project.policy_write")?; - let _lock = acquire_project_write_lock(root, "project.policy_write")?; - write_project_permission_policy_at(root, policy) -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs new file mode 100644 index 000000000..c309ef745 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs @@ -0,0 +1,1663 @@ +use super::*; +use crate::agent::{ + read_direct_project_chat_history_at, read_direct_project_last_item_id_at, + DirectProjectHistoryAnchor, +}; +use tauri_plugin_dialog::DialogExt; +use tauri_plugin_opener::OpenerExt; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorRoleAgentChatStreamEvent { + project_path: String, + agent_id: String, + run_id: String, + status: String, + delta_text: String, + accumulated_text: String, + finish_reason: Option, + session_id: Option, + runtime_status: Option, + runtime_phase: Option, + runtime_summary: Option, + runtime_state: Option, +} + +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct OpenCanvasProjectResult { + url: String, +} + +const AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS: u32 = 64; +// Project naming is an optional homepage enhancement. It must never hold the +// actual project creation flow behind the normal (potentially three-minute) +// generation timeout. + +const AUTOMATIC_PROJECT_NAME_TIMEOUT_MS: u64 = 15_000; + +const AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT: &str = + include_str!("../../prompts/automatic-project-name.md"); +// 聊天输入区的 AI 润色复用同一条短文本生成通道:只做一次单轮改写, +// 计费由平台 LLM 路由(/api/llm/chat/completions、/api/llm/responses)侧完成。 + +const LOCAL_PROJECT_PROMPT_POLISH_MAX_OUTPUT_TOKENS: u32 = 2_048; + +const LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT: &str = + include_str!("../../prompts/local-project-prompt-polish.md"); + +async fn request_automatic_project_name(prompt: &str) -> Result, String> { + let user_prompt = build_automatic_project_name_prompt(prompt)?; + let app_config = load_game_creator_app_config()?; + if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER { + let reply = tokio::time::timeout( + std::time::Duration::from_millis(AUTOMATIC_PROJECT_NAME_TIMEOUT_MS), + crate::agent::direct_game_creator_home_codex_chat( + AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim().to_string(), + user_prompt, + ), + ) + .await + .map_err(|_| { + format!( + "自动项目命名超时(超过 {} 秒)", + AUTOMATIC_PROJECT_NAME_TIMEOUT_MS / 1_000 + ) + })??; + return Ok(normalize_suggested_project_name(reply.trim())); + } + let mut llm = app_config.llm.clone(); + llm.max_retries = 0; + let client = build_game_creator_llm_client_from_llm_config(&llm, "llm")?; + let request = + LlmRunRequest::single_turn(AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim(), user_prompt) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_model(llm.model.clone()) + .with_request_timeout_ms(llm.request_timeout_ms) + .with_max_output_tokens(AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS) + .with_web_search(false); + let response = tokio::time::timeout( + std::time::Duration::from_millis(AUTOMATIC_PROJECT_NAME_TIMEOUT_MS), + client.run(request), + ) + .await + .map_err(|_| { + format!( + "自动项目命名超时(超过 {} 秒)", + AUTOMATIC_PROJECT_NAME_TIMEOUT_MS / 1_000 + ) + })? + .map_err(|error| format!("自动项目命名失败:{error}"))?; + Ok(normalize_suggested_project_name(response.text.trim())) +} + +async fn request_local_project_prompt_polish( + prompt: &str, + context: Option<&str>, +) -> Result { + let user_prompt = build_local_project_prompt_polish_prompt(prompt, context)?; + let app_config = load_game_creator_app_config()?; + if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER { + let reply = crate::agent::direct_game_creator_home_codex_chat( + LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT.trim().to_string(), + user_prompt, + ) + .await?; + return normalize_polished_prompt(&reply) + .ok_or_else(|| "AI 润色没有返回可用文本".to_string()); + } + let mut llm = app_config.llm.clone(); + llm.max_retries = 0; + let client = build_game_creator_llm_client_from_llm_config(&llm, "llm")?; + let request = LlmRunRequest::single_turn( + LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT.trim(), + user_prompt, + ) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_model(llm.model.clone()) + .with_request_timeout_ms(llm.request_timeout_ms) + .with_max_output_tokens(LOCAL_PROJECT_PROMPT_POLISH_MAX_OUTPUT_TOKENS) + .with_web_search(false); + let response = client + .run(request) + .await + .map_err(|error| format!("AI 润色失败:{error}"))?; + normalize_polished_prompt(&response.text).ok_or_else(|| "AI 润色没有返回可用文本".to_string()) +} + +#[tauri::command] +pub(crate) fn create_automatic_local_game_project( + app: tauri::AppHandle, + name: Option, + planning: Option, + projects_root: Option, +) -> Result { + let analytics_context = crate::analytics::gui::capture_analytics_context(); + let result = create_automatic_local_game_project_at( + &resolve_game_project_creation_root(&app, projects_root.as_deref())?, + name.as_deref(), + planning.unwrap_or(false), + ); + if let Ok(project) = &result { + crate::analytics::gui::created( + analytics_context, + Path::new(&project.project_path), + project.manifest.project_id.clone(), + if planning.unwrap_or(false) { + crate::analytics::contract::CreationSource::HomeDesign + } else { + crate::analytics::contract::CreationSource::HomeGame + }, + None, + ); + } + result +} + +#[tauri::command] +pub(crate) fn init_local_game_project( + project_path: String, + project_id: String, + name: String, +) -> Result { + let analytics_context = crate::analytics::gui::capture_analytics_context(); + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.create")?; + let _lock = acquire_project_write_lock(root, "project.create")?; + let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); + let result = init_local_game_project_at(root, project_id.trim(), name.trim()); + if new_project { + if let Ok(project) = &result { + crate::analytics::gui::created( + analytics_context, + Path::new(&project.project_path), + project.manifest.project_id.clone(), + crate::analytics::contract::CreationSource::SelectedDirectory, + None, + ); + } + } + result +} + +#[tauri::command] +pub(crate) fn import_local_cocos_project( + project_path: String, + project_id: String, + name: String, +) -> Result { + let analytics_context = crate::analytics::gui::capture_analytics_context(); + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.create")?; + if discover_local_cocos_project_root(root)?.is_none() { + return Err( + "所选目录不是有效的 Cocos Creator 项目(需要 package.json.creator.version 和 assets/)" + .to_string(), + ); + } + let _lock = acquire_project_write_lock(root, "project.create")?; + let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); + let result = import_local_cocos_project_at(root, project_id.trim(), name.trim()); + if new_project { + if let Ok(project) = &result { + crate::analytics::gui::created( + analytics_context, + Path::new(&project.project_path), + project.manifest.project_id.clone(), + crate::analytics::contract::CreationSource::SelectedDirectory, + None, + ); + } + } + result +} + +#[tauri::command] +pub(crate) fn import_local_unity_project( + project_path: String, + project_id: String, + name: String, +) -> Result { + let analytics_context = crate::analytics::gui::capture_analytics_context(); + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.create")?; + if discover_local_unity_project_root(root)?.is_none() { + return Err("所选目录不是有效的 Unity 项目".to_string()); + } + let _lock = acquire_project_write_lock(root, "project.create")?; + let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); + let result = import_local_unity_project_at(root, project_id.trim(), name.trim()); + if new_project { + if let Ok(project) = &result { + crate::analytics::gui::created( + analytics_context, + Path::new(&project.project_path), + project.manifest.project_id.clone(), + crate::analytics::contract::CreationSource::SelectedDirectory, + None, + ); + } + } + result +} + +#[tauri::command] +pub(crate) async fn inspect_local_project_directory( + project_path: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || inspect_local_project_directory_sync(project_path)) + .await + .map_err(|error| format!("检查项目目录后台任务失败:{error}"))? +} + +/// 目录选择器标题:调用方只能给短标题,其余(超长、含控制字符、空白)一律回退默认文案。 +fn pick_project_directory_title(title: Option<&str>) -> &str { + const MAX_TITLE_CHARS: usize = 24; + title + .map(str::trim) + .filter(|value| { + !value.is_empty() + && value.chars().count() <= MAX_TITLE_CHARS + && !value.chars().any(char::is_control) + }) + .unwrap_or("选择游戏项目目录") +} + +#[tauri::command] +pub(crate) async fn pick_local_project_directory( + app: tauri::AppHandle, + initial_path: Option, + title: Option, +) -> Result, String> { + let (sender, receiver) = tokio::sync::oneshot::channel(); + let mut dialog = app + .dialog() + .file() + .set_title(pick_project_directory_title(title.as_deref())); + if let Some(initial_path) = initial_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + { + let initial_path = Path::new(initial_path); + if initial_path.is_absolute() && !project_path_has_control_chars(initial_path) { + let starting_directory = closest_existing_project_picker_directory(initial_path); + if let Some(starting_directory) = starting_directory { + dialog = dialog.set_directory(starting_directory); + } + } + } + if let Some(window) = app.get_webview_window("client") { + dialog = dialog.set_parent(&window); + } + dialog.pick_folder(move |path| { + let _ = sender.send(path); + }); + let Some(path) = receiver + .await + .map_err(|_| "项目目录选择器意外关闭".to_string())? + else { + return Ok(None); + }; + let path = path + .into_path() + .map_err(|error| format!("读取项目目录失败:{error}"))?; + #[cfg(windows)] + crate::register_game_creator_user_selected_path(&path, true); + // The native picker is the explicit user-selection boundary. Prepare the + // selected root before returning it so inspect/create/open never races the + // first ACL read. + if let Err(error) = + crate::prepare_game_creator_project_root_for_read(&path, true, "用户选择项目目录") + { + #[cfg(windows)] + crate::revoke_game_creator_user_selected_path(&path); + return Err(error); + } + Ok(Some(path.to_string_lossy().into_owned())) +} + +#[tauri::command] +pub(crate) fn open_local_project_directory( + app: tauri::AppHandle, + project_path: String, +) -> Result<(), String> { + let path = validated_local_project_directory_path(project_path.trim())?; + app.opener() + .open_path(path.to_string_lossy().into_owned(), None::<&str>) + .map_err(|error| format!("打开项目目录失败:{error}")) +} + +#[tauri::command] +pub(crate) async fn get_local_game_manifest( + project_path: String, + command_id: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + get_local_game_manifest_sync(project_path, command_id) + }) + .await + .map_err(|error| format!("读取项目 manifest 后台任务失败:{error}"))? +} + +#[tauri::command] +pub(crate) fn read_local_project_resource_graph( + project_path: String, + expected_project_id: String, + resources: Vec, +) -> Result { + let root = validated_local_project_directory_path(project_path.trim())?; + enforce_project_auto_permission_policy(&root, "asset.list")?; + read_project_resource_graph_at(&root, expected_project_id.trim(), resources) +} + +/// 读取引用了某个素材的项目版本,供删除弹窗提示「被哪些版本使用」。 +#[tauri::command] +pub(crate) fn read_local_project_asset_references( + input: ReadLocalProjectAssetReferencesInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.list")?; + read_manifest_asset_references_at(root, &input.asset_id) +} + +/// 删除一个素材登记,并按用户确认决定是否连带删除引用它的游戏版本。 +/// +/// 素材不可变:只摘掉 manifest 登记,磁盘文件保留。 +#[tauri::command] +pub(crate) fn delete_local_project_asset( + input: DeleteLocalProjectAssetInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + delete_manifest_asset_at( + root, + &input.expected_project_id, + input.expected_project_revision, + &input.asset_id, + input.delete_referenced_versions, + ) +} + +/// 读取某个版本引用的源素材可用的替换候选,并给出后端权威的三项兼容性结论。 +/// +/// 只读:不改 manifest、不推进 revision。候选渲染但禁用,不在前端重算判据。 +#[tauri::command] +pub(crate) fn read_local_project_version_resource_replacement_candidates( + input: ReadLocalProjectVersionReplacementCandidatesInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.list")?; + read_local_project_version_replacement_candidates_at( + root, + &input.source_version_id, + &input.source_resource_id, + ) +} + +/// 用另一个已登记素材替换某个版本引用的素材:改 manifest 绑定,并**追加下一迭代版本**。 +/// +/// 可运行版本不可变(既有版本记录一个字节都不改)、不动资源文件、不建文件副本; +/// 三项兼容性必须同时为 true,否则拒绝并说明哪一项不等。CAS 失败时 manifest 与 revision 都不变。 +#[tauri::command] +pub(crate) fn replace_local_project_version_resource( + input: ReplaceLocalProjectVersionResourceInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + replace_local_project_version_resource_at( + root, + &input.expected_project_id, + input.expected_project_revision, + &input.source_version_id, + &input.source_resource_id, + &input.replacement_resource_id, + ) +} + +/// 重命名一个已登记素材:磁盘文件改名 + 更新 manifest 的 `localPath`,资产 `id` 不变。 +/// +/// 只允许在资产当前所在目录内改名,扩展名必须一致,同目录不得已有同名文件;manifest 写失败 +/// 时把文件改回原名,不留半成品。与删除 / 分类更新同口径:持项目写锁后按 `expectedProjectId` +/// 与当前 revision 做 CAS,失败时磁盘、manifest 与 revision 都不动。 +#[tauri::command] +pub(crate) fn rename_local_project_asset( + input: RenameLocalProjectAssetInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + rename_local_project_asset_at( + root, + &input.expected_project_id, + input.expected_project_revision, + &input.asset_id, + &input.new_file_name, + ) +} + +#[tauri::command] +pub(crate) async fn control_agent_run( + app: tauri::AppHandle, + project_path: String, + action: String, + detail: Option, +) -> Result { + let root = Path::new(project_path.trim()); + let command_id = match action.trim() { + "status" => "agent.run_status", + "kill" => "agent.kill", + "retry" => "agent.retry", + "resume" => "agent.resume", + _ => "agent.run_status", + }; + enforce_project_permission_policy(root, command_id)?; + if matches!(action.trim(), "retry" | "resume") { + enforce_project_permission_policy(root, "game.generate_draft")?; + } + let _lock = if command_id == "agent.run_status" { + None + } else { + Some(acquire_project_write_lock(root, command_id)?) + }; + control_agent_run_at( + root, + action.trim(), + detail + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()), + Some(&AgentProgressEmitter::new(&app, project_path.trim())), + ) + .await +} + +#[tauri::command] +pub(crate) async fn generate_local_game_draft( + app: tauri::AppHandle, + project_path: String, + prompt: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "game.generate_draft")?; + let _lock = acquire_project_write_lock(root, "game.generate_draft")?; + advance_agent_runtime_project_revision_locked(root)?; + generate_local_game_draft_at( + root, + prompt.trim(), + Some(&AgentProgressEmitter::new(&app, project_path.trim())), + ) + .await +} + +#[tauri::command] +pub(crate) async fn chat_with_game_creator_agent( + project_path: String, + prompt: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + chat_with_game_creator_agent_at(root, prompt.trim()).await +} + +#[tauri::command] +pub(crate) async fn chat_with_game_creator_role_agent_stream( + app: tauri::AppHandle, + project_path: String, + agent_id: String, + session_id: Option, + prompt: String, + run_id: String, +) -> Result { + let project_path = project_path.trim().to_string(); + let agent_id = normalize_game_creator_runtime_agent_id(agent_id.trim())?; + let run_id = run_id.trim().to_string(); + let root = Path::new(project_path.as_str()); + let session_id = + resolve_agent_conversation_session_id_at(root, &agent_id, session_id.as_deref(), true)?; + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)? + .ok_or_else(|| format!("Agent 正在执行其他前台或后台任务:{agent_id}"))?; + let emit_app = app.clone(); + let event_project_path = project_path.clone(); + let event_agent_id = agent_id.clone(); + let event_run_id = run_id.clone(); + let command_result = async { + let mut runtime_state = start_game_creator_agent_runtime_turn_for_session_at( + root, + agent_id.as_str(), + Some(&session_id), + prompt.trim(), + &run_id, + )?; + runtime_state = advance_game_creator_agent_runtime_turn_at( + root, + runtime_state, + "llm", + "请求 Agent LLM", + "已读取项目上下文,正在让 Agent 独立推理。", + )?; + let mut streaming_runtime_state = runtime_state.clone(); + streaming_runtime_state.current_action = "正在接收 Agent 回复".to_string(); + let _ = app.emit( + "game-creator-role-agent-chat-stream", + GameCreatorRoleAgentChatStreamEvent { + project_path: event_project_path.clone(), + agent_id: event_agent_id.clone(), + run_id: event_run_id.clone(), + status: "started".to_string(), + delta_text: String::new(), + accumulated_text: String::new(), + finish_reason: None, + session_id: Some(runtime_state.session_id.clone()), + runtime_status: Some(runtime_state.status.clone()), + runtime_phase: Some(runtime_state.phase.clone()), + runtime_summary: Some(runtime_state.current_action.clone()), + runtime_state: Some(runtime_state.clone()), + }, + ); + let result = chat_with_game_creator_role_agent_stream_for_session_at( + root, + agent_id.as_str(), + Some(&session_id), + prompt.trim(), + |delta| { + let _ = emit_app.emit( + "game-creator-role-agent-chat-stream", + GameCreatorRoleAgentChatStreamEvent { + project_path: event_project_path.clone(), + agent_id: event_agent_id.clone(), + run_id: event_run_id.clone(), + status: "delta".to_string(), + delta_text: delta.delta_text.clone(), + accumulated_text: delta.accumulated_text.clone(), + finish_reason: delta.finish_reason.clone(), + session_id: Some(streaming_runtime_state.session_id.clone()), + runtime_status: Some("running".to_string()), + runtime_phase: Some("llm".to_string()), + runtime_summary: Some("正在接收 Agent 回复".to_string()), + runtime_state: Some(streaming_runtime_state.clone()), + }, + ); + }, + ) + .await; + match result { + Ok(reply) => { + let completed_runtime = finish_game_creator_agent_runtime_turn_at( + root, + runtime_state, + &reply.reply_text, + )?; + let _ = app.emit( + "game-creator-role-agent-chat-stream", + GameCreatorRoleAgentChatStreamEvent { + project_path: project_path.clone(), + agent_id: agent_id.clone(), + run_id: run_id.clone(), + status: "completed".to_string(), + delta_text: String::new(), + accumulated_text: reply.reply_text.clone(), + finish_reason: None, + session_id: Some(completed_runtime.session_id.clone()), + runtime_status: Some(completed_runtime.status.clone()), + runtime_phase: Some(completed_runtime.phase.clone()), + runtime_summary: Some(completed_runtime.current_action.clone()), + runtime_state: Some(completed_runtime), + }, + ); + Ok(reply) + } + Err(error) => { + let failed_runtime = + fail_game_creator_agent_runtime_turn_at(root, runtime_state, &error).ok(); + let _ = app.emit( + "game-creator-role-agent-chat-stream", + GameCreatorRoleAgentChatStreamEvent { + project_path: project_path.clone(), + agent_id: agent_id.clone(), + run_id: run_id.clone(), + status: "failed".to_string(), + delta_text: String::new(), + accumulated_text: String::new(), + finish_reason: None, + session_id: failed_runtime + .as_ref() + .map(|runtime| runtime.session_id.clone()), + runtime_status: failed_runtime + .as_ref() + .map(|runtime| runtime.status.clone()), + runtime_phase: failed_runtime.as_ref().map(|runtime| runtime.phase.clone()), + runtime_summary: failed_runtime + .as_ref() + .map(|runtime| runtime.current_action.clone()), + runtime_state: failed_runtime, + }, + ); + Err(error) + } + } + } + .await; + spawn_next_game_creator_agent_background_task_drain_with_lock(root, &agent_id, runtime_lock); + match command_result { + Ok(reply) => Ok(reply), + Err(error) => Err(error), + } +} + +#[tauri::command] +pub(crate) fn start_game_creator_supervisor_runtime_task( + project_path: String, + session_id: Option, + task: String, + run_id: String, + run_profile: Option, + source: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + enforce_project_permission_policy(root, "agent.run_status")?; + let run_profile = run_profile + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(AGENT_RUNTIME_RUN_PROFILE_STANDARD); + let source = source + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE); + if !agent_runtime_supervisor_source_is_trusted(source) { + return Err("Project Supervisor 提交 source 不受信任".to_string()); + } + start_game_creator_supervisor_background_task_for_session_at( + root, + session_id.as_deref(), + task.trim(), + run_id.trim(), + source, + run_profile, + ) +} + +#[tauri::command] +pub(crate) async fn compact_game_creator_agent_runtime_context( + project_path: String, + agent_id: String, + session_id: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "agent.compact")?; + if external_agent_runner_enabled() && !external_agent_runner_is_server_process() { + compact_external_agent_runner_context(root, agent_id.trim(), session_id.as_deref()) + } else { + compact_game_creator_agent_runtime_session_at(root, agent_id.trim(), session_id.as_deref()) + .await + } +} + +#[tauri::command] +pub(crate) fn cancel_game_creator_agent_runtime_task( + project_path: String, + agent_id: String, + run_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + enforce_project_permission_policy(root, "agent.run_status")?; + cancel_game_creator_agent_runtime_task_at(root, agent_id.trim(), run_id.trim()) +} + +#[tauri::command] +pub(crate) fn answer_game_creator_agent_runtime_user_input( + project_path: String, + agent_id: String, + run_id: String, + action_id: String, + request_id: String, + response_id: String, + answers: BTreeMap, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + enforce_project_permission_policy(root, "agent.run_status")?; + enforce_project_permission_policy(root, "agent.resume")?; + answer_game_creator_agent_runtime_user_input_at( + root, + agent_id.trim(), + run_id.trim(), + action_id.trim(), + request_id.trim(), + response_id.trim(), + answers, + ) +} + +#[tauri::command] +pub(crate) fn read_game_creator_agent_runtime( + project_path: String, + agent_id: String, + session_id: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + read_game_creator_agent_runtime_for_session_at(root, agent_id.trim(), session_id.as_deref()) +} + +#[tauri::command] +pub(crate) fn read_game_creator_agent_runtimes( + project_path: String, +) -> Result, String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + read_game_creator_agent_runtimes_at(root) +} + +#[tauri::command] +pub(crate) async fn suggest_automatic_project_name( + prompt: String, +) -> Result, String> { + request_automatic_project_name(prompt.trim()) + .await + .map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320)) +} + +#[tauri::command] +pub(crate) async fn polish_local_project_prompt( + prompt: String, + context: Option, +) -> Result { + request_local_project_prompt_polish(prompt.trim(), context.as_deref()) + .await + .map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320)) +} + +#[tauri::command] +pub(crate) fn read_platform_account_session_state( +) -> crate::platform_session::PlatformSessionWriteState { + crate::platform_session::current_platform_session_write_state() +} + +#[tauri::command] +pub(crate) async fn install_platform_account_session( + user_id: String, + access_token: String, + api_base_url: String, + identity_generation: u64, + revision: u64, +) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + validate_platform_session_input( + &user_id, + &access_token, + &api_base_url, + identity_generation, + revision, + )?; + install_external_agent_runner_platform_session( + &user_id, + &access_token, + &api_base_url, + identity_generation, + revision, + )?; + install_platform_session( + &user_id, + &access_token, + &api_base_url, + identity_generation, + revision, + ) + }) + .await + .map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))? +} + +#[tauri::command] +pub(crate) async fn clear_platform_account_session( + identity_generation: u64, + revision: u64, +) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + shutdown_game_creator_codex_app_servers()?; + clear_external_agent_runner_platform_session(identity_generation, revision)?; + clear_platform_session(identity_generation, revision); + Ok(()) + }) + .await + .map_err(|error| format!("清除本地运行时会话任务意外终止:{error}"))? +} + +#[tauri::command] +pub(crate) fn cancel_direct_codex_turn( + project_path: String, + client_turn_id: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "agent.kill")?; + cancel_direct_codex_turn_at(root, client_turn_id.as_deref()) +} + +#[tauri::command] +pub(crate) fn select_game_creator_reasoning_effort( + effort: String, +) -> Result { + let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK + .lock() + .map_err(|_| "配置写入锁不可用")?; + let effort = game_creator_llm_reasoning_effort_name(&effort, "llm.reasoningEffort")?; + let (mut config, overlays) = load_game_creator_app_config_for_write()?; + config.llm.reasoning_effort = effort; + persist_game_creator_app_config(config, overlays, false) +} + +#[tauri::command] +pub(crate) async fn discover_game_creator_llm_models( + llm: GameCreatorLlmConfig, +) -> Result, String> { + if !load_game_creator_app_config()?.llm.custom_enabled { + return Err("请先在本地配置中开启 llm.customEnabled".to_string()); + } + fetch_custom_llm_models(&llm).await +} + +#[tauri::command] +pub(crate) fn upload_local_asset( + project_path: String, + file_name: String, + media_type: String, + bytes: Vec, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.upload")?; + let _lock = acquire_project_write_lock(root, "asset.upload")?; + advance_agent_runtime_project_revision_locked(root)?; + upload_local_asset_at(root, file_name.trim(), media_type.trim(), &bytes) +} + +#[tauri::command] +pub(crate) fn register_local_asset( + project_path: String, + local_path: String, + kind: String, + media_type: String, + source_kind: String, + canvas_project_id: Option, + resource_id: Option, + asset_object_id: Option, + task_id: Option, + prompt: Option, + model: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + let _lock = acquire_project_write_lock(root, "asset.register")?; + advance_agent_runtime_project_revision_locked(root)?; + register_local_asset_at( + root, + local_path.trim(), + crate::assets::registration_asset_kind(&kind, "asset.register"), + media_type.trim(), + source_kind.trim(), + GameCreationAppAssetSource { + kind: parse_asset_source_kind(source_kind.trim())?, + canvas_project_id: trim_optional_string(canvas_project_id), + resource_id: trim_optional_string(resource_id), + asset_object_id: trim_optional_string(asset_object_id), + task_id: trim_optional_string(task_id), + prompt: trim_optional_string(prompt), + model: trim_optional_string(model), + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + ) +} + +#[tauri::command] +pub(crate) fn update_local_project_resource_classification( + input: UpdateLocalProjectResourceClassificationInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + update_manifest_asset_classification_at( + root, + &input.expected_project_id, + input.expected_project_revision, + &input.asset_id, + &input.category, + input.tags, + ) +} + +#[tauri::command] +pub(crate) async fn derive_local_project_resource( + input: DeriveLocalProjectResourceInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + derive_local_project_resource_at(input).await +} + +#[tauri::command] +pub(crate) fn list_pending_local_project_resource_edits( + input: ListPendingLocalProjectResourceEditsInput, +) -> Result, String> { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "file.list")?; + list_pending_local_project_resource_edits_at(input) +} + +#[tauri::command] +pub(crate) async fn resume_local_project_resource_edit( + input: ResumeLocalProjectResourceEditInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + resume_local_project_resource_edit_at(input).await +} + +#[tauri::command] +pub(crate) async fn request_local_project_resource_edit_service_identity_confirmation( + input: RequestResourceEditServiceIdentityConfirmationInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + request_resource_edit_service_identity_confirmation_at(input).await +} + +#[tauri::command] +pub(crate) async fn confirm_local_project_resource_edit_service_identity( + input: ConfirmResourceEditServiceIdentityInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + confirm_resource_edit_service_identity_at(input).await +} + +#[tauri::command] +pub(crate) async fn archive_failed_local_project_resource_edit( + input: ArchiveFailedLocalProjectResourceEditInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + archive_failed_local_project_resource_edit_at(input).await +} + +#[tauri::command] +pub(crate) fn normalize_local_project_raster_resource( + input: NormalizeLocalProjectRasterResourceInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + normalize_local_project_raster_resource_at(input) +} + +#[tauri::command] +pub(crate) fn import_canvas_asset( + project_path: String, + local_path: String, + kind: String, + media_type: String, + canvas_project_id: String, + resource_id: Option, + asset_object_id: Option, + task_id: Option, + prompt: Option, + model: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "canvas.asset_import")?; + let _lock = acquire_project_write_lock(root, "canvas.asset_import")?; + advance_agent_runtime_project_revision_locked(root)?; + import_canvas_asset_at( + root, + local_path.trim(), + registration_asset_kind(kind.trim(), "canvas.asset_import"), + media_type.trim(), + canvas_project_id.trim(), + trim_optional_string(resource_id), + trim_optional_string(asset_object_id), + trim_optional_string(task_id), + trim_optional_string(prompt), + trim_optional_string(model), + ) +} + +#[tauri::command] +pub(crate) fn import_canvas_export( + project_path: String, + export_path: String, + canvas_project_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "canvas.export_import")?; + let _lock = acquire_project_write_lock(root, "canvas.export_import")?; + advance_agent_runtime_project_revision_locked(root)?; + import_canvas_export_at( + root, + Path::new(export_path.trim()), + canvas_project_id.trim(), + ) +} + +#[tauri::command] +pub(crate) async fn sync_canvas_project_assets( + project_path: String, + canvas_project_id: String, + api_base_url: Option, + api_key: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "canvas.project_sync")?; + let _lock = acquire_project_write_lock(root, "canvas.project_sync")?; + advance_agent_runtime_project_revision_locked(root)?; + sync_canvas_project_assets_at(root, canvas_project_id.trim(), api_base_url, api_key).await +} + +#[tauri::command] +pub(crate) fn prepare_ui_editor_project_fonts( + project_path: String, + assets: Vec, +) -> Result, String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.list")?; + if assets.len() > UI_EDITOR_FONT_MAX_COUNT { + return Err("一次最多选择 64 个字体面".to_string()); + } + let manifest = read_existing_manifest_for_project(root)?; + let mut fonts = Vec::with_capacity(assets.len()); + for selected in assets { + let asset = find_registered_ui_editor_font(&manifest, &selected.id, &selected.local_path)?; + fonts.push(read_registered_ui_editor_font(root, asset)?.0); + } + Ok(fonts) +} + +fn read_checked_ui_editor_font( + project_path: &str, + asset_id: &str, + relative_path: &str, + expected_sha256: &str, +) -> Result<(FontAsset, Vec), String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "file.read")?; + let manifest = read_existing_manifest_for_project(root)?; + let asset = find_registered_ui_editor_font(&manifest, asset_id, relative_path)?; + let (font, bytes) = read_registered_ui_editor_font(root, asset)?; + if font.content_sha256 != expected_sha256 { + return Err("字体文件内容与资源摘要不一致".to_string()); + } + Ok((font, bytes)) +} + +#[tauri::command] +pub(crate) fn read_ui_editor_font_bytes( + project_path: String, + asset_id: String, + relative_path: String, + expected_sha256: String, +) -> Result { + read_checked_ui_editor_font( + &project_path, + asset_id.trim(), + relative_path.trim(), + expected_sha256.trim(), + ) + .map(|(_, bytes)| tauri::ipc::Response::new(bytes)) +} + +#[tauri::command] +pub(crate) fn check_ui_editor_font_glyph_coverage( + project_path: String, + asset_id: String, + relative_path: String, + expected_sha256: String, + content: String, +) -> Result { + let (font, bytes) = read_checked_ui_editor_font( + &project_path, + asset_id.trim(), + relative_path.trim(), + expected_sha256.trim(), + )?; + font.has_missing_glyphs(&bytes, &content) +} + +#[tauri::command] +pub(crate) async fn generate_platform_art_asset( + project_path: String, + prompt: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "canvas.asset_generate")?; + let generated = generate_platform_art_asset_at(root, prompt.trim(), &[]).await?; + Ok(generated.asset) +} + +#[tauri::command] +pub(crate) fn open_canvas_project( + app: tauri::AppHandle, + canvas_project_id: Option, + editor_base_url: Option, +) -> Result { + let url = build_canvas_project_url(editor_base_url.as_deref(), canvas_project_id.as_deref())?; + app.opener() + .open_url(&url, None::<&str>) + .map_err(|error| format!("打开画板失败:{error}"))?; + Ok(OpenCanvasProjectResult { url }) +} + +#[tauri::command] +pub(crate) fn append_local_permission_log( + project_path: String, + event: String, + command_id: String, +) -> Result<(), String> { + append_local_permission_log_at( + Path::new(project_path.trim()), + event.trim(), + command_id.trim(), + ) +} + +/// 登记当前项目中已经存在、但尚未写入 manifest 的本地资源。 +/// +/// 该入口只接受项目根相对路径;实际文件签名、大小、敏感路径、项目锁和 +/// manifest/revision 更新统一复用 Agent 的受控导入实现。未登记文件在调用前 +/// 只能作为候选展示,不能由前端构造 asset ID。 +#[tauri::command] +pub(crate) async fn import_local_project_image_assets( + project_path: String, + relative_paths: Vec, +) -> Result { + let root = PathBuf::from(project_path.trim()); + tokio::task::spawn_blocking(move || { + import_local_project_assets_for_agent(&root, &relative_paths) + }) + .await + .map_err(|error| format!("项目内资源登记任务意外终止:{error}")) + .and_then(|result| result) +} + +#[tauri::command] +pub(crate) fn save_local_project_asset_file( + input: SaveLocalProjectAssetFileInput, +) -> Result { + save_local_project_asset_file_at(input) +} + +#[tauri::command] +pub(crate) async fn read_local_project_image_preview( + preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, + project_path: String, + relative_path: String, + scope_id: String, + request_id: String, +) -> Result { + preview_manager + .run(&scope_id, &request_id, move |cancellation| { + read_local_project_image_preview_at(&project_path, &relative_path, cancellation) + }) + .await +} + +#[tauri::command] +pub(crate) async fn read_local_project_text_preview( + preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, + project_path: String, + relative_path: String, + scope_id: String, + request_id: String, +) -> Result { + preview_manager + .run(&scope_id, &request_id, move |cancellation| { + read_local_project_text_preview_at(&project_path, &relative_path, cancellation) + }) + .await +} + +#[tauri::command] +pub(crate) async fn read_local_project_structured_preview( + preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, + project_path: String, + relative_path: String, + scope_id: String, + request_id: String, +) -> Result { + preview_manager + .run(&scope_id, &request_id, move |cancellation| { + read_local_project_structured_preview_at(&project_path, &relative_path, cancellation) + }) + .await +} + +pub(crate) fn read_local_project_structured_preview_at( + project_path: &str, + relative_path: &str, + cancellation: &ProjectResourcePreviewScopeCancellation, +) -> Result { + cancellation.check()?; + let root = Path::new(project_path.trim()); + enforce_project_auto_permission_policy(root, "file.read")?; + cancellation.check()?; + let normalized_path = normalize_relative_path(relative_path.trim())?; + let manifest = read_manifest_cached_for_preview(&root.join(".agent/manifest.json"))?; + cancellation.check()?; + let registered_media_type = manifest + .assets + .iter() + .find(|asset| asset.local_path == normalized_path) + .map(|asset| asset.media_type.clone()); + let is_registered_structured = registered_media_type.as_deref().is_some_and(|media_type| { + is_supported_project_structured_resource(&normalized_path, media_type) + }) || manifest.tasks.iter().any(|task| { + task.status == GameCreationAppTaskStatus::Completed + && task.artifacts.iter().any(|path| path == &normalized_path) + && is_supported_project_structured_resource(&normalized_path, "") + }); + if !is_registered_structured { + return Err("只能读取当前项目已登记的引擎资源".to_string()); + } + cancellation.check()?; + load_local_project_structured_preview_with_cancellation( + root, + &normalized_path, + registered_media_type.as_deref().unwrap_or(""), + cancellation, + ) +} + +#[tauri::command] +pub(crate) async fn read_local_project_media_preview( + preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, + project_path: String, + relative_path: String, + category: String, + scope_id: String, + request_id: String, +) -> Result { + preview_manager + .run(&scope_id, &request_id, move |cancellation| { + read_local_project_media_preview_at( + &project_path, + &relative_path, + &category, + cancellation, + ) + }) + .await +} + +#[tauri::command] +pub(crate) fn cancel_local_project_resource_preview_scope( + preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, + scope_id: String, +) -> Result<(), String> { + preview_manager.cancel_scope(&scope_id) +} + +#[tauri::command] +pub(crate) fn list_game_creator_agent_sessions( + project_path: String, + agent_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + list_game_creator_agent_sessions_at(root, agent_id.trim()) +} + +#[tauri::command] +pub(crate) fn create_game_creator_agent_session( + project_path: String, + agent_id: String, + title: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + // 首轮策划消息可能紧跟项目初始化写入到达;对话保存应等待这段短暂的 + // 项目锁竞争,避免把可恢复的初始化竞态直接显示成保存失败。 + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "conversation.write", + )?; + create_game_creator_agent_session_at(root, agent_id.trim(), title.trim()) +} + +#[tauri::command] +pub(crate) fn fork_game_creator_agent_session( + project_path: String, + agent_id: String, + source_session_id: String, + title: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + let _lock = acquire_project_write_lock(root, "conversation.write")?; + fork_game_creator_agent_session_at( + root, + agent_id.trim(), + source_session_id.trim(), + title.trim(), + ) +} + +#[tauri::command] +pub(crate) fn set_active_game_creator_agent_session( + project_path: String, + agent_id: String, + session_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + let _lock = acquire_project_write_lock(root, "conversation.write")?; + set_active_game_creator_agent_session_at(root, agent_id.trim(), session_id.trim()) +} + +#[tauri::command] +pub(crate) fn archive_game_creator_agent_session( + project_path: String, + agent_id: String, + session_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + let _lock = acquire_project_write_lock(root, "conversation.write")?; + archive_game_creator_agent_session_at(root, agent_id.trim(), session_id.trim()) +} + +#[tauri::command] +pub(crate) async fn read_direct_project_conversation( + project_path: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + read_direct_project_chat_history_at(root) + }) + .await + .map_err(|error| format!("读取 DirectProject 历史后台任务失败:{error}"))? +} + +#[tauri::command] +pub(crate) async fn read_agent_runtime_error_detail( + project_path: String, + detail_ref: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + let relative = detail_ref.trim(); + let Some(file_name) = relative.strip_prefix(".agent/runtime/errors/") else { + return Err("错误诊断引用不在项目错误目录内".to_string()); + }; + if file_name.is_empty() + || file_name.contains(['/', '\\']) + || file_name.contains("..") + || !file_name.ends_with(".json") + { + return Err("错误诊断引用格式无效".to_string()); + } + let path = root.join(relative); + prepare_game_creator_private_path_for_read(&path, false, "统一错误诊断")?; + let bytes = std::fs::read(&path).map_err(|error| format!("读取错误诊断失败:{error}"))?; + if bytes.len() > 16 * 1024 { + return Err("错误诊断超过读取上限".to_string()); + } + let text = String::from_utf8(bytes).map_err(|_| "错误诊断不是 UTF-8 文本".to_string())?; + Ok(redact_agent_runtime_error(root, &text, 16 * 1024)) + }) + .await + .map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))? +} + +#[tauri::command] +pub(crate) fn list_game_creator_direct_active_turns( +) -> Result, String> { + list_direct_active_turns() +} + +#[tauri::command] +pub(crate) async fn subscribe_direct_project_thread( + project_path: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + let thread_id = direct_thread_id_for_project(root); + let mut bootstrap = subscribe_direct_thread(&thread_id); + if bootstrap.last_completed_item_id.is_none() { + bootstrap.last_completed_item_id = read_direct_project_last_item_id_at(root)?; + } + Ok(bootstrap) + }) + .await + .map_err(|error| format!("订阅 DirectProject 线程后台任务失败:{error}"))? +} + +#[tauri::command] +pub(crate) fn consume_direct_project_thread( + subscription_id: String, +) -> Result { + consume_direct_thread(subscription_id.trim()) +} + +/// 读一屏项目对话历史。 +/// +/// 窗口两端各由一个锚点给出,两者互斥(都传会报错):`before_item_id` 是**旧端**边界(不含 +/// 该条,向后翻页用),`through_item_id` 是**新端**边界(含该条,取 `subscribe` 回执里的 +/// `lastCompletedItemId`,比它更新的条目只从运行态事件来);都不传就是文件尾最近的一屏。 +#[tauri::command] +pub(crate) async fn read_direct_project_history_slice( + project_path: String, + before_item_id: Option, + through_item_id: Option, + limit: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + let anchor = match (before_item_id.as_deref(), through_item_id.as_deref()) { + (Some(_), Some(_)) => { + return Err( + "DirectProject 历史切片只接受一个锚点(beforeItemId / throughItemId)" + .to_string(), + ); + } + (Some(before), None) => DirectProjectHistoryAnchor::Before(before), + (None, Some(through)) => DirectProjectHistoryAnchor::Through(through), + (None, None) => DirectProjectHistoryAnchor::Newest, + }; + let (raw_items, has_more, recorded_at_ms, first_item_id) = + read_direct_project_history_items_slice_at(root, anchor, limit.unwrap_or(20))?; + let items = direct_thread_items_from_history(root, &raw_items, |item| { + direct_thread_item_identity(item) + .and_then(|identity| recorded_at_ms.get(&identity).copied()) + .unwrap_or_default() + }); + Ok(DirectThreadHistorySlice { + items, + has_more, + first_item_id, + }) + }) + .await + .map_err(|error| format!("读取 DirectProject 历史切片后台任务失败:{error}"))? +} + +#[tauri::command] +pub(crate) fn append_direct_project_conversation_message( + project_path: String, + message: LocalConversationMessage, + message_id: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.write")?; + let item = + direct_project_local_message_item(&message.role, &message.content, message_id.as_deref())?; + append_direct_project_history_item_at(root, &item)?; + read_direct_project_chat_history_at(root) +} + +#[tauri::command] +pub(crate) fn build_local_project_index( + project_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.index")?; + let _lock = acquire_project_write_lock(root, "project.index")?; + build_local_project_index_at(root) +} + +#[tauri::command] +pub(crate) fn create_local_project_checkpoint( + project_path: String, +) -> Result { + create_local_project_checkpoint_with_capture( + project_path, + crate::analytics::gui::capture_writer_context(), + ) +} + +/// 为发布导出试玩包:项目还没有可玩入口时先跑项目自己的 `npm run build`。 +/// +/// 作者只点一次「发布」:已有 `game/index.html` 或 `dist/index.html` 直接打包;只有源码时 +/// 走 `project.verify` 的受控 npm 运行器构建后再打包,失败信息带构建日志尾部。 +#[tauri::command] +pub(crate) async fn export_local_project_package( + project_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.export_package")?; + let _lock = acquire_project_write_lock(root, "project.export_package")?; + advance_agent_runtime_project_revision_locked(root)?; + export_local_project_package_for_publish_at(root).await +} + +/// 把归一化后的发行包落到内容寻址的暂存文件,返回分片续传所需的元数据。 +/// +/// 发布链路从此只把「暂存路径 + 摘要 + 体积」交给渲染进程:整包字节不再经过 +/// WebView IPC,续传时也复用同一个暂存文件(同名同内容)。 +#[tauri::command] +pub(crate) fn prepare_local_project_game_package( + app: tauri::AppHandle, + project_path: String, + package_relative_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.export_package")?; + let payload = read_local_project_export_package_at(root, package_relative_path.trim())?; + let staging_dir = game_package_upload_staging_dir(&app)?; + let mut staged = crate::game_package_upload::stage_game_package_bytes( + &staging_dir, + &payload.package_sha256, + &payload.package_bytes, + )?; + staged.package_file_count = u32::try_from(payload.files.len()).unwrap_or(u32::MAX); + Ok(staged) +} + +/// 分片续传上传暂存的发行包;进度通过 `game-package-upload-progress` 事件回传。 +#[tauri::command] +pub(crate) async fn upload_local_project_game_package( + app: tauri::AppHandle, + staging_path: String, + version_id: String, + api_base_url: String, + access_token: String, + idempotency_key: String, +) -> Result { + let staging_dir = game_package_upload_staging_dir(&app)?; + let resolved_path = + crate::game_package_upload::ensure_staging_path_in_dir(&staging_dir, &staging_path)?; + let client = reqwest::Client::builder() + .build() + .map_err(|error| format!("创建上传客户端失败:{error}"))?; + let version_id = version_id.trim().to_string(); + if version_id.is_empty() { + return Err("缺少发行版本标识".to_string()); + } + let emit_handle = app.clone(); + let progress_version_id = version_id.clone(); + crate::game_package_upload::upload_staged_game_package( + &client, + crate::game_package_upload::GamePackageUploadRequest { + staging_path: &resolved_path, + version_id: &version_id, + api_base_url: api_base_url.trim(), + access_token: access_token.trim(), + idempotency_key: idempotency_key.trim(), + }, + move |received_bytes, total_bytes| { + let _ = emit_handle.emit( + crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, + crate::game_package_upload::progress_event_payload( + &progress_version_id, + received_bytes, + total_bytes, + ), + ); + }, + ) + .await +} + +fn game_package_upload_staging_dir(app: &tauri::AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|app_data_root| { + crate::game_package_upload::game_package_upload_staging_dir(&app_data_root) + }) + .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) +} + +#[tauri::command] +pub(crate) fn list_local_project_export_packages( + project_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.export_list")?; + list_local_project_export_packages_at(root) +} + +#[tauri::command] +pub(crate) fn diff_local_project_checkpoint( + project_path: String, + checkpoint_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.diff")?; + diff_local_project_checkpoint_at(root, checkpoint_id.trim()) +} + +#[tauri::command] +pub(crate) fn restore_local_project_checkpoint( + project_path: String, + checkpoint_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.restore")?; + let _lock = acquire_project_write_lock(root, "project.restore")?; + advance_agent_runtime_project_revision_locked(root)?; + restore_local_project_checkpoint_at(root, checkpoint_id.trim()) +} + +#[tauri::command] +pub(crate) fn write_project_permission_policy( + project_path: String, + policy: ProjectPermissionPolicy, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.policy_write")?; + let _lock = acquire_project_write_lock(root, "project.policy_write")?; + write_project_permission_policy_at(root, policy) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index cfbc650b5..76930c5ba 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -1291,6 +1291,8 @@ fn validate_game_creator_private_path_ancestors_with_auto_elevation_scoped( ) -> Result<(), String> { #[cfg(test)] { + // 测试只校验路径,不触发宿主提权修复,因此不消费修复范围。 + let _ = scope; return validate_game_creator_private_path_ancestors(path, label); } #[cfg(not(test))] diff --git a/apps/ai-game-creator-shell/src-tauri/src/context_menu.rs b/apps/ai-game-creator-shell/src-tauri/src/context_menu.rs index df6c4ccc2..19ba517cb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/context_menu.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/context_menu.rs @@ -1,7 +1,7 @@ -use tauri::{ - plugin::{Builder, TauriPlugin}, - Runtime, -}; +#[cfg(not(test))] +mod runtime; +#[cfg(not(test))] +pub(crate) use runtime::init; /// Release 客户端只取消浏览器的默认上下文菜单。 /// @@ -22,39 +22,6 @@ fn context_menu_init_script(is_release: bool) -> Option<&'static str> { is_release.then_some(RELEASE_CONTEXT_MENU_INIT_SCRIPT) } -#[cfg(all(windows, not(debug_assertions)))] -fn disable_windows_default_context_menus(webview: tauri::Webview) { - let _ = webview.with_webview(|platform_webview| { - // WebView2 的原生设置只关闭浏览器菜单,不会取消 DOM 的 - // `contextmenu` 事件,因此业务右键菜单仍然可以正常工作。 - // SAFETY: Tauri 在 WebView2 UI 线程上提供当前存活的 controller, - // 这些 COM 接口调用只使用该闭包期间有效的句柄。 - let _ = unsafe { - platform_webview - .controller() - .CoreWebView2() - .and_then(|webview| webview.Settings()) - .and_then(|settings| settings.SetAreDefaultContextMenusEnabled(false)) - }; - }); -} - -/// 注册 release-only 的默认网页上下文菜单策略。 -pub(crate) fn init() -> TauriPlugin { - let mut builder = Builder::new("release-context-menu"); - - if let Some(script) = context_menu_init_script(!cfg!(debug_assertions)) { - builder = builder.js_init_script_on_all_frames(script); - } - - #[cfg(all(windows, not(debug_assertions)))] - { - builder = builder.on_webview_ready(disable_windows_default_context_menus); - } - - builder.build() -} - #[cfg(test)] mod tests { use super::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/context_menu/runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/context_menu/runtime.rs new file mode 100644 index 000000000..6bb451358 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/context_menu/runtime.rs @@ -0,0 +1,39 @@ +use tauri::{ + plugin::{Builder, TauriPlugin}, + Runtime, +}; + +use super::context_menu_init_script; + +#[cfg(all(windows, not(debug_assertions)))] +fn disable_windows_default_context_menus(webview: tauri::Webview) { + let _ = webview.with_webview(|platform_webview| { + // WebView2 的原生设置只关闭浏览器菜单,不会取消 DOM 的 + // `contextmenu` 事件,因此业务右键菜单仍然可以正常工作。 + // SAFETY: Tauri 在 WebView2 UI 线程上提供当前存活的 controller, + // 这些 COM 接口调用只使用该闭包期间有效的句柄。 + let _ = unsafe { + platform_webview + .controller() + .CoreWebView2() + .and_then(|webview| webview.Settings()) + .and_then(|settings| settings.SetAreDefaultContextMenusEnabled(false)) + }; + }); +} + +/// 注册 release-only 的默认网页上下文菜单策略。 +pub(crate) fn init() -> TauriPlugin { + let mut builder = Builder::new("release-context-menu"); + + if let Some(script) = context_menu_init_script(!cfg!(debug_assertions)) { + builder = builder.js_init_script_on_all_frames(script); + } + + #[cfg(all(windows, not(debug_assertions)))] + { + builder = builder.on_webview_ready(disable_windows_default_context_menus); + } + + builder.build() +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs new file mode 100644 index 000000000..fd61732b3 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs @@ -0,0 +1,795 @@ +// 桌面命令注册、GUI 生命周期与独立进程启动接线。 +// 共享业务、诊断与退出决策函数留在 crate 根及所属模块供现有测试使用。 +use super::*; +use agent::design_tools::*; +use asset_generation_tasks::*; +use client_extensions::*; +use environment_check::preflight_web_game_creation; +use error_report::*; +use plugin_host::{ + call_agc_plugin, list_agc_extensions, list_agc_plugins, read_agc_plugin_panel, + refresh_agc_plugins, reload_agc_plugin, set_agc_plugin_enabled, set_agc_plugin_project_path, + start_agc_plugin, stop_agc_plugin, PluginHost, +}; +use shared_contracts::error_reports::ErrorReportLogInput; +use template_library::*; + +/// 更新完成后的进程重启:Windows 由 NSIS 安装程序代为重启,macOS / Linux 由客户端在安装后调用。 +#[tauri::command] +fn restart_agc_app(app: tauri::AppHandle) { + analytics::gui::mark_restart(); + app.restart(); +} + +/// 把 `shared-contracts` 的「非 canonical 资源 kind」留痕接到壳层日志上。 +/// +/// 解析边界(含 manifest 反序列化、画板 assetKind、平台 assetKind)认不出 canonical 值时, +/// 会把**原始输入串**与调用上下文交回来;这里落到 `app_log!` → AppData 日志里, +/// 以便回查非 canonical kind 的写入边界。kind 本身只接受严格值。 +/// +/// 按 `(原始串, 上下文)` 去重:manifest 读取极频繁,每次都落一行会把其它诊断刷掉; +/// 去重后"有哪些非 canonical 值、分别从哪个边界进来"仍然完整。需要具体资产身份时看 +/// `log_non_canonical_manifest_asset_kinds`(按资产去重,带 assetId / localPath)。 +fn register_non_canonical_asset_kind_reporter() { + static REPORTED: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + shared_contracts::game_creation_app::set_non_canonical_asset_kind_reporter( + |raw_kind: &str, context: &str| { + let key = format!("{context}\u{1}{raw_kind}"); + let first_seen = REPORTED + .get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new())) + .lock() + .map(|mut reported| reported.insert(key)) + .unwrap_or(true); + if !first_seen { + return; + } + app_log!( + "GameCreationApp 资源 kind 不是 canonical 值,已按严格口径收口为 unknown:rawKind={raw_kind} context={context}" + ); + }, + ); +} + +#[tauri::command] +async fn suggest_ui_design_semantic( + project_path: String, + state: ui_editor::state::State, +) -> Result, String> { + ui_editor::commands::suggest_ui_design_semantic_impl(project_path, state).await +} + +#[tauri::command] +async fn recognize_ui( + project_path: String, + state: ui_editor::state::State, +) -> Result { + ui_editor::commands::recognize_ui_impl(project_path, state).await +} + +#[tauri::command] +async fn separate_ui( + project_path: String, + asset_id: String, + state: ui_editor::state::State, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + ui_editor::commands::separate_ui_impl(project_path, asset_id, state).await +} + +#[tauri::command] +fn inspect_separation_recovery( + project_path: String, + asset_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.list")?; + ui_editor::commands::separation::inspect_separation_recovery(root, &asset_id) +} + +#[tauri::command] +fn finalize_separation(project_path: String, asset_id: String) -> Result<(), String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + ui_editor::commands::separation::finalize_separation(root, &asset_id) +} + +#[tauri::command] +fn discard_separation_recovery(project_path: String, asset_id: String) -> Result<(), String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + ui_editor::commands::separation::discard_separation_recovery(root, &asset_id) +} + +#[tauri::command] +async fn merge_ui(state: ui_editor::state::State) -> Result { + ui_editor::commands::merge_ui_impl(state).await +} + +#[tauri::command] +async fn bind_components( + project_path: String, + state: ui_editor::state::State, + sprite_ids: Vec, +) -> Result { + ui_editor::commands::bind_components_impl(project_path, state, sprite_ids).await +} + +#[tauri::command] +fn load_ui_design_state( + input: ui_editor::persistence::LoadUiDesignStateInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.list")?; + ui_editor::persistence::load_ui_design_state_at(input) +} + +#[tauri::command] +fn save_ui_design_state( + input: ui_editor::persistence::SaveUiDesignStateInput, +) -> Result { + save_ui_design_state_with_capture(input, analytics::gui::capture_writer_context()) +} + +#[tauri::command] +fn generate_ui_design_code( + input: ui_editor::persistence::GenerateUiDesignCodeInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "file.write")?; + ui_editor::persistence::generate_ui_design_code_at(input) +} + +#[tauri::command] +fn ensure_ui_design_resource_for_prototype( + input: ui_editor::resource_bridge::EnsureUiDesignResourceForPrototypeInput, +) -> Result { + ui_editor::resource_bridge::ensure_ui_design_resource_for_prototype(input) +} + +#[tauri::command] +fn append_application_log(level: String, source: String, message: String) -> Result<(), String> { + let config_dir = game_creator_runtime_config_dir() + .ok_or_else(|| "客户端 AppData 配置目录未初始化".to_string())?; + let level = sanitize_diagnostic_message(&level, Some(&config_dir)); + let source = sanitize_diagnostic_message(&source, Some(&config_dir)); + let message = sanitize_diagnostic_message(&message, Some(&config_dir)); + let line = format!("WEBVIEW {level} {source}: {message}"); + append_application_log_line(&line).map_err(|error| error.to_string()) +} + +#[tauri::command] +fn read_diagnostic_logs() -> Result, String> { + let Some(config_dir) = game_creator_runtime_config_dir() else { + return Ok(Vec::new()); + }; + let _guard = DIAGNOSTIC_LOG_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let directory = config_dir.join("diagnostics"); + let mut files = Vec::new(); + for name in ["application.log", "application.previous.log", "startup.log"] { + let path = directory.join(name); + let Ok(metadata) = fs::symlink_metadata(&path) else { + continue; + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + continue; + } + let Ok(content) = fs::read_to_string(&path) else { + continue; + }; + files.push(ErrorReportLogInput { + name: name.to_string(), + content, + }); + } + Ok(files) +} + +/// 客户端产品名跟随构建期渠道身份:默认渠道是「陶泥儿」,其它渠道带渠道后缀 +/// (例如「陶泥儿 Release」)。同机并存的渠道客户端因此在窗口标题、任务栏与 +/// Alt-Tab 里可区分;默认渠道结果不变。 +pub(crate) fn game_creator_product_name(app: &tauri::AppHandle) -> String { + app.package_info().name.clone() +} + +/// 配置目录就绪前的启动日志路径:优先用已经生效的配置目录(例如 `--config-dir` +/// 已经设置好的目录),否则退到平台配置根。两者都不可用时返回 `None`,此时 +/// `StartupLogSlot::fail` 仍然必须给出用户可见提示。 +fn early_startup_log_path(identifier: &str) -> Option { + let configured_dir = game_creator_runtime_config_dir(); + resolve_early_startup_log_path(configured_dir.as_deref(), identifier) +} + +impl GameCreatorGuiRunnerShutdownFailure { + fn code(self) -> &'static str { + match self { + Self::EndpointUnavailable => "endpoint_unavailable", + Self::RunnerUnresponsive => "runner_unresponsive", + Self::ProcessIdentity => "process_identity", + Self::PlatformUnsupported => "platform_unsupported", + Self::LockTimeout => "lock_timeout", + Self::Other => "other", + } + } +} + +fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) { + if matches!(event, tauri::RunEvent::Exit) { + // 窗口关闭时已经按项目触发过一次快照同步;退出路径只负责在有界预算内 + // 等在途同步收尾,不重复发起(此刻窗口已销毁,重新枚举项目只会是空集)。 + wait_for_project_snapshot_syncs_on_exit(); + // 退出时统一收尾本地预览:进程内监听线程随进程消失,但 `.agent/manifest.json` + // 里的 preview 记录会留在 running 上,下次进项目就照着它渲染打不开的运行界面。 + if let Err(error) = + preview::stop_local_game_preview_on_exit(&game_creator_preview_registry()) + { + app_log!("preview.gui_exit.stop_failed: {error}"); + } + if let Err(error) = agent::shutdown_game_creator_codex_app_servers() { + app_log!("agent.direct_codex.gui_exit.shutdown_failed: {error}"); + } + } + match resolve_game_creator_gui_runner_shutdown( + event, + shutdown_external_agent_runner_for_gui_exit, + ) { + GameCreatorGuiRunnerShutdownOutcome::NotRequested => {} + GameCreatorGuiRunnerShutdownOutcome::Requested => { + app_log!("agent.runner.gui_exit.shutdown_requested") + } + GameCreatorGuiRunnerShutdownOutcome::Retained => { + app_log!("agent.runner.gui_exit.retained_for_other_windows") + } + GameCreatorGuiRunnerShutdownOutcome::Failed(failure) => { + app_log!("agent.runner.gui_exit.shutdown_failed.{}", failure.code()) + } + } +} + +fn install_agent_runtime_async_runtime_with_deep_stack() { + let runtime = match build_agent_runtime_async_runtime() { + Ok(runtime) => runtime, + Err(error) => { + app_log!("agent.runner.failed: {error}"); + std::process::exit(1); + } + }; + tauri::async_runtime::set(runtime.handle().clone()); + // 进程生命周期内必须持有,否则 handle 立即失效。 + Box::leak(Box::new(runtime)); +} + +#[cfg(windows)] +fn run_windows_acl_repair_if_requested(args: &[String]) -> Option { + let [command, path, target_user_sid_flag, target_user_sid, authorization_flag, nonce, scope_flag, scope_value] = + args + else { + if args.first().map(String::as_str) == Some("--repair-private-acl") { + eprintln!( + "用法:--repair-private-acl <路径> --target-user-sid --authorization <票据> --scope " + ); + return Some(1); + } + return None; + }; + if command != "--repair-private-acl" + || target_user_sid_flag != "--target-user-sid" + || authorization_flag != "--authorization" + || scope_flag != "--scope" + { + eprintln!( + "用法:--repair-private-acl <路径> --target-user-sid --authorization <票据> --scope " + ); + return Some(1); + } + let path = std::path::PathBuf::from(path); + let scope = match config::parse_windows_acl_repair_scope(scope_value) { + Ok(scope) => scope, + Err(error) => { + eprintln!("{error}"); + return Some(1); + } + }; + let result = + config::consume_windows_acl_repair_authorization(&path, target_user_sid, nonce, scope) + .and_then(|()| { + config::repair_game_creator_private_acl_for_user_sid(&path, target_user_sid) + }); + match result { + Ok(()) => Some(0), + Err(error) => { + eprintln!("AGC ACL 提权修复失败:{error}"); + Some(1) + } + } +} + +pub(super) fn run() { + install_agent_runtime_async_runtime_with_deep_stack(); + register_non_canonical_asset_kind_reporter(); + let mut args = std::env::args().skip(1).collect::>(); + if let Some(exit_code) = run_direct_tools_mcp_if_requested(&args) { + std::process::exit(exit_code); + } + #[cfg(windows)] + if let Some(exit_code) = run_windows_acl_repair_if_requested(&args) { + std::process::exit(exit_code); + } + #[cfg(target_os = "linux")] + if command_sandbox_trampoline::is_trampoline_mode(&args) { + match command_sandbox_trampoline::run_trampoline() { + Ok(exit_code) => std::process::exit(exit_code), + Err(_) => std::process::exit(125), + } + } + #[cfg(target_os = "linux")] + if command_sandbox_trampoline::is_process_session_trampoline_mode(&args) { + match command_sandbox_trampoline::run_process_session_trampoline() { + Ok(exit_code) => std::process::exit(exit_code), + Err(_) => std::process::exit(125), + } + } + #[cfg(target_os = "linux")] + if is_process_session_child_mode(&args) { + match run_process_session_child(&args) { + Ok(exit_code) => std::process::exit(exit_code), + Err(_) => std::process::exit(125), + } + } + let runtime_config_dir = match take_cli_runtime_config_dir(&mut args) { + Ok(config_dir) => config_dir, + Err(error) => { + app_log!("{error}"); + std::process::exit(1); + } + }; + if args.first().map(String::as_str) == Some("--agent-runner") { + let gui_owner_required = match args.as_slice() { + [_] => false, + [_, option] if option == "--gui-owner-required" => true, + _ => { + app_log!( + "用法:--agent-runner [--gui-owner-required] --config-dir " + ); + std::process::exit(1); + } + }; + let Some(config_dir) = runtime_config_dir else { + app_log!("Agent Runner 必须显式传入 --config-dir "); + std::process::exit(1); + }; + if let Err(error) = load_platform_session_fixture_from_env(&config_dir) { + app_log!("agent.runner.failed: {error}"); + std::process::exit(1); + } + set_game_creator_runtime_config_dir(config_dir.clone()); + if let Err(error) = run_external_agent_runner_server(config_dir, gui_owner_required) { + app_log!("agent.runner.failed: {error}"); + std::process::exit(1); + } + return; + } + match parse_cli_command(&args) { + Ok(Some(mut command)) => { + let config_dir = + match prepare_cli_command_paths(&mut command, runtime_config_dir.as_deref()) { + Ok(config_dir) => config_dir, + Err(error) => { + app_log!("agent.runner.failed: {error}"); + std::process::exit(1); + } + }; + if let Some(config_dir) = config_dir { + if let Err(error) = load_platform_session_fixture_from_env(&config_dir) { + app_log!("agent.runner.failed: {error}"); + std::process::exit(1); + } + if command.requires_external_agent_runner() || command.is_read_only_status() { + let configured = if command.is_read_only_status() { + configure_external_agent_runner_read_only(&config_dir) + } else { + configure_external_agent_runner(&config_dir) + }; + if let Err(error) = configured { + app_log!("agent.runner.failed: {error}"); + std::process::exit(1); + } + } + set_game_creator_runtime_config_dir(config_dir); + } + if command.requires_started_external_agent_runner() { + if let Err(error) = ensure_external_agent_runner_started() { + app_log!("agent.runner.failed: {error}"); + std::process::exit(1); + } + } + if let Err(error) = run_cli_command(command) { + app_log!("agent.run.failed: {error}"); + std::process::exit(1); + } + return; + } + Ok(None) => {} + Err(error) => { + app_log!("{error}"); + std::process::exit(1); + } + } + + if let Some(config_dir) = runtime_config_dir { + set_game_creator_runtime_config_dir(config_dir); + } + + let tauri_context = tauri::generate_context!(); + // 配置目录确定之前先推导启动日志路径:优先用已经生效的配置目录(例如 + // `--config-dir`),否则退到平台配置根,保证 + // `configure_game_creator_runtime_config_dir` 自身失败也有落点。 + let startup_log = Arc::new(StartupLogSlot::new(early_startup_log_path( + tauri_context.config().identifier.as_str(), + ))); + let setup_log = Arc::clone(&startup_log); + let app = tauri::Builder::default() + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_http::init()) + .plugin(tauri_plugin_clipboard_manager::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(context_menu::init()) + .on_page_load(|webview, payload| { + if matches!(payload.event(), tauri::webview::PageLoadEvent::Started) { + analytics::gui::page_loading(webview.window().label()); + } + }) + .on_window_event(|window, event| { + analytics::gui::window_event(window, event); + handle_project_snapshot_window_event(window, event); + }) + .manage(game_creator_preview_registry()) + .manage(ProjectResourcePreviewReadManager::default()) + .manage(PluginHost::default()) + .setup(move |app| { + error_report::initialize_notifications(app.handle()); + setup_log.append("startup.setup.begin"); + setup_log.append("startup.appdata.configure.begin"); + configure_game_creator_runtime_config_dir(app.handle()).inspect_err(|error| { + // 日志路径未知也必须记录并提示:不能因为拿不到路径就静默失败。 + let config_dir = game_creator_runtime_config_dir(); + let details = + sanitize_diagnostic_message(&error.to_string(), config_dir.as_deref()); + setup_log.fail(&format!( + "startup.appdata.configure.failed details={details}" + )); + })?; + if let Some(directory) = game_creator_runtime_config_dir() { + setup_log.set(directory.join("diagnostics/startup.log")); + } + // 跨会话清扫陈旧的无头浏览器(上次异常退出/被杀留下的 ga-browser-*)。 + // 后台执行,不阻塞启动;杀树前按进程身份与可信浏览器路径双重校验。 + tauri::async_runtime::spawn(async move { + let notes = + tokio::task::spawn_blocking(crate::browser::sweep_stale_browser_processes) + .await + .unwrap_or_default(); + if !notes.is_empty() { + app_log!("startup.browser-sweep: {}", notes.join("; ")); + } + }); + setup_log.append("startup.appdata.configure.complete"); + let config_dir = game_creator_runtime_config_dir().ok_or_else(|| { + let error = std::io::Error::new( + std::io::ErrorKind::NotFound, + "客户端 AppData 配置目录未初始化", + ); + setup_log.fail("startup.appdata.resolve.failed details=config-dir-uninitialized"); + error + })?; + // 主窗口标题与产品名保持一致:配置里的标题来自基线配置,渠道后缀只 + // 由构建期身份决定,因此必须在这里按产品名覆盖。 + match app.get_webview_window("client") { + Some(window) => { + if let Err(error) = window.set_title(&game_creator_product_name(app.handle())) { + app_log!("startup.window-title.failed: {error}"); + } + } + None => app_log!("startup.window-title.failed: 缺少 client 主窗口"), + } + spawn_project_snapshot_scheduler(app.handle().clone()); + if let Err(error) = builtin_plugins::initialize(&config_dir) { + app_log!("startup.builtin-plugins.initialize.failed: {error}"); + } + if let Err(error) = app.state::().initialize(&config_dir) { + app_log!("startup.plugin-host.initialize.failed: {error}"); + } + if let Some(workspace) = plugin_host::resolve_plugin_workspace(app.handle()) { + if let Err(error) = app.state::().set_plugin_workspace(workspace) { + app_log!("startup.plugin-host.workspace.failed: {error}"); + } + } + if let Err(error) = editor_adapters::register_linked_editor_adapters( + app.handle(), + app.state::().inner(), + ) { + app_log!("startup.plugin-host.adapter.failed: {error}"); + } + load_platform_session_fixture_from_env(&config_dir).map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!("加载平台登录态测试 fixture 失败:{error}"), + ) + })?; + setup_log.append("startup.runner.configure.begin"); + configure_external_agent_runner(&config_dir) + .inspect_err(|error| { + let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); + setup_log.fail(&format!( + "startup.runner.configure.failed details={details}" + )); + }) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("配置 Agent Runner 失败:{error}"), + ) + })?; + setup_log.append("startup.runner.configure.complete"); + hold_external_agent_runner_gui_participant_lock(&config_dir) + .inspect_err(|error| { + let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); + setup_log.fail(&format!( + "startup.runner.participant-lock.failed details={details}" + )); + }) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!("建立 AGC 界面参与锁失败:{error}"), + ) + })?; + setup_log.append("startup.runner.start.begin"); + set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); + set_direct_thread_manager_app_handle(app.handle().clone()); + let manifest_event_sink = + start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?; + attach_external_agent_runner_gui_owner(&manifest_event_sink) + .inspect_err(|error| { + let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); + setup_log.fail(&format!( + "startup.runner.attach-owner.failed details={details}" + )); + }) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::Other, + format!("绑定 Agent Runner GUI owner 失败:{error}"), + ) + })?; + setup_log.append("startup.runner.start.complete"); + setup_log.append("startup.setup.complete"); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + analytics::gui::capture_analytics_context, + analytics::gui::settle_direct_run_analytics, + analytics::gui::record_analytics_project_open, + analytics::gui::record_analytics_project_leave, + analytics::gui::record_analytics_ui_save, + start_game_creator_external_mcp, + stop_game_creator_external_mcp, + create_automatic_local_game_project, + create_automatic_local_game_project_from_template, + init_local_game_project, + fetch_game_template_library, + get_game_template_library_access, + download_game_template, + import_local_godot_project, + import_local_cocos_project, + import_local_unity_project, + is_local_project_directory_non_empty, + inspect_local_project_directory, + pick_local_project_directory, + rename_local_game_project, + suggest_automatic_project_name, + polish_local_project_prompt, + pick_client_extension_file, + pick_client_extension_directory, + list_client_extensions, + list_agc_skill_catalog, + import_client_extension, + set_client_extension_enabled, + rename_client_extension, + remove_client_extension, + list_agc_plugins, + list_agc_extensions, + refresh_agc_plugins, + start_agc_plugin, + stop_agc_plugin, + reload_agc_plugin, + set_agc_plugin_enabled, + call_agc_plugin, + read_agc_plugin_panel, + set_agc_plugin_project_path, + open_local_project_directory, + control_agent_run, + generate_local_game_draft, + chat_with_game_creator_agent, + chat_with_game_creator_role_agent, + chat_with_game_creator_role_agent_stream, + chat_with_game_creator_direct_codex, + preflight_web_game_creation, + cancel_direct_codex_turn, + select_game_creator_reasoning_effort, + hydrate_design_agent_session, + reset_design_agent_session, + get_design_agent_runtime_mode, + is_design_agent_debug_enabled, + set_design_agent_runtime_mode, + debug_fast_forward_design_session, + continue_design_agent_session, + decide_design_phase, + import_design_workspace_file, + list_design_workspace, + read_design_workspace_file, + start_game_creator_supervisor_runtime_task, + compact_game_creator_agent_runtime_context, + read_game_creator_agent_goal, + start_game_creator_agent_goal, + edit_game_creator_agent_goal, + pause_game_creator_agent_goal, + resume_game_creator_agent_goal, + clear_game_creator_agent_goal, + cancel_game_creator_agent_runtime_task, + retry_game_creator_agent_runtime_task, + confirm_retry_game_creator_agent_runtime_task, + confirm_game_creator_agent_runtime_task, + reject_game_creator_agent_runtime_task, + answer_game_creator_agent_runtime_user_input, + read_game_creator_agent_runtime, + read_game_creator_agent_runtimes, + resume_game_creator_agent_runtime_tasks, + confirm_resume_game_creator_agent_runtime_tasks, + schedule_game_creator_agent_ready_tasks, + check_game_creator_llm_config, + read_platform_account_session_state, + install_platform_account_session, + clear_platform_account_session, + read_game_creator_app_config, + write_game_creator_app_config, + select_game_creator_model, + discover_game_creator_llm_models, + upload_local_asset, + register_local_asset, + create_ui_design_resource, + update_local_project_resource_classification, + add_local_project_resource_tags, + derive_local_project_resource, + list_pending_local_project_resource_edits, + resume_local_project_resource_edit, + request_local_project_resource_edit_service_identity_confirmation, + confirm_local_project_resource_edit_service_identity, + archive_failed_local_project_resource_edit, + normalize_local_project_raster_resource, + import_canvas_asset, + import_canvas_export, + sync_canvas_project_assets, + import_ui_editor_assets, + prepare_ui_editor_project_fonts, + read_ui_editor_font_bytes, + check_ui_editor_font_glyph_coverage, + suggest_ui_design_semantic, + recognize_ui, + separate_ui, + inspect_separation_recovery, + finalize_separation, + discard_separation_recovery, + merge_ui, + bind_components, + load_ui_design_state, + save_ui_design_state, + generate_ui_design_code, + ensure_ui_design_resource_for_prototype, + generate_platform_art_asset, + generate_local_project_asset, + start_local_project_asset_generation, + list_local_project_asset_generations, + open_canvas_project, + run_limited_local_command, + append_local_permission_log, + list_local_project_files, + import_local_project_image_assets, + read_local_project_file, + read_local_project_image_preview, + save_local_project_asset_file, + read_local_project_text_preview, + read_local_project_structured_preview, + read_local_project_media_preview, + cancel_local_project_resource_preview_scope, + read_local_game_memory, + read_local_agent_memory, + write_local_agent_memory, + write_local_game_memory, + list_game_creator_agent_sessions, + create_game_creator_agent_session, + fork_game_creator_agent_session, + set_active_game_creator_agent_session, + archive_game_creator_agent_session, + read_local_conversation, + read_direct_project_conversation, + read_agent_runtime_error_detail, + list_game_creator_direct_active_turns, + subscribe_direct_project_thread, + consume_direct_project_thread, + read_direct_project_history_slice, + append_local_conversation_message, + append_direct_project_conversation_message, + build_local_project_index, + create_local_project_checkpoint, + export_local_project_package, + prepare_local_project_game_package, + upload_local_project_game_package, + list_local_project_export_packages, + diff_local_project_checkpoint, + restore_local_project_checkpoint, + read_project_permission_policy, + write_project_permission_policy, + open_game_creator_workspace_window, + open_game_creator_launcher_window, + start_local_game_preview, + activate_local_game_preview, + stop_local_game_preview, + stop_local_game_preview_if_matches, + get_local_game_preview_status, + read_local_project_resource_canvas_layout, + read_local_project_resource_graph, + update_local_project_resource_canvas_layout, + delete_local_project_asset, + read_local_project_asset_references, + rename_local_project_asset, + read_local_project_version_resource_replacement_candidates, + replace_local_project_version_resource, + get_local_game_project_revision, + get_local_game_manifest, + restart_agc_app, + append_application_log, + read_diagnostic_logs, + report_client_error, + get_pending_error_reports, + ack_error_reports, + sync_local_project_snapshot, + read_local_project_snapshot_state, + set_active_project_snapshot_workspace, + ]) + .build(tauri_context); + let app = match app { + Ok(app) => { + startup_log.append("startup.build.complete"); + app + } + Err(error) => { + if let Some(path) = startup_log.path() { + let details = sanitize_diagnostic_message(&error.to_string(), path.parent()); + startup_log.fail(&format!("startup.build.failed details={details}")); + } + app_log!("failed to build Genarrative AI Game Creator shell: {error}"); + std::process::exit(1); + } + }; + app.run(move |app_handle, event| { + if matches!(event, tauri::RunEvent::Ready) { + if let Some(directory) = game_creator_runtime_config_dir() { + analytics::gui::initialize( + app_handle.clone(), + directory, + app_handle.package_info().version.to_string(), + ); + } + } + if matches!(event, tauri::RunEvent::Exit) { + analytics::gui::exit(); + } + handle_game_creator_gui_run_event(&event); + }); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs index 00d7f7dcb..f9b87d922 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs @@ -525,11 +525,21 @@ pub(crate) fn disconnect_unity_editor_connection() { let _ = disconnect_managed_editor_connection(ManagedEditor::Unity); } -#[cfg(test)] +#[cfg(all( + test, + windows, + target_arch = "x86_64", + feature = "unity-editor-execute" +))] pub(super) fn unity_pending_delivery() -> &'static Mutex> { pending_delivery(ManagedEditor::Unity) } -#[cfg(test)] +#[cfg(all( + test, + windows, + target_arch = "x86_64", + feature = "unity-editor-execute" +))] pub(crate) fn unity_delivery_requires_ack(id: &str) -> bool { pending_delivery(ManagedEditor::Unity) .try_lock() diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs index facfc30f6..58084a897 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs @@ -7,9 +7,10 @@ use std::io::Read; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::{Duration, Instant}; +#[cfg(not(test))] +pub(crate) use web_creation::preflight_web_game_creation; pub(crate) use web_creation::{ - host_web_creation_preflight, preflight_web_game_creation, prepare_new_web_project_at, - record_new_web_scaffold_at, + host_web_creation_preflight, prepare_new_web_project_at, record_new_web_scaffold_at, }; use serde::Deserialize; diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs index c52745b45..4f84e9e6e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs @@ -6,24 +6,20 @@ use std::{ fs::{self, File}, - io::{Read, Seek, SeekFrom, Write}, + io::Write, path::{Path, PathBuf}, - time::Duration, }; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::Value; -pub(crate) const GAME_PACKAGE_UPLOAD_PROGRESS_EVENT: &str = "game-package-upload-progress"; - -const AGC_CLIENT_MARKER_HEADER: &str = "X-Genarrative-Client"; -const AGC_CLIENT_MARKER_VALUE: &str = "agc"; -const UPLOAD_OFFSET_HEADER: &str = "x-genarrative-upload-offset"; -/// 服务端下发的分片大小上限;客户端只按服务端给的值发,超过它必然被拒。 -const MAX_CHUNK_BYTES: u64 = 8 * 1024 * 1024; -const CHUNK_MAX_ATTEMPTS: usize = 4; -const CHUNK_RETRY_DELAY: Duration = Duration::from_millis(500); -const UPLOAD_STAGING_DIR_NAME: &str = "game-package-staging"; +#[cfg(not(test))] +mod runtime; +#[cfg(not(test))] +pub(crate) use runtime::{ + game_package_upload_staging_dir, progress_event_payload, upload_staged_game_package, + GamePackageUploadOutcome, GamePackageUploadRequest, GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, +}; /// 已暂存(归一化后)的发行包:内容寻址,重启后同一包复用同一个文件,续传才有意义。 #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] @@ -35,36 +31,12 @@ pub(crate) struct StagedGamePackage { pub(crate) package_file_count: u32, } -#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct GamePackageUploadOutcome { - pub(crate) version_id: String, - pub(crate) status: String, - pub(crate) uploaded_bytes: u64, -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct ChunkPlan { offset: u64, length: u64, } -#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -struct PackageUploadState { - #[serde(default)] - received_bytes: u64, - #[serde(default)] - chunk_bytes: u64, - #[serde(default)] - declared_package_bytes: u64, -} - -/// 暂存目录:放在应用数据目录下,不写进项目、也不参与项目快照同步。 -pub(crate) fn game_package_upload_staging_dir(app_data_dir: &Path) -> PathBuf { - app_data_dir.join(UPLOAD_STAGING_DIR_NAME) -} - /// 把归一化后的发行包暂存到内容寻址文件,返回续传所需的元数据。 pub(crate) fn stage_game_package_bytes( staging_dir: &Path, @@ -134,15 +106,6 @@ fn next_chunk_plan(received: u64, total: u64, chunk_bytes: u64) -> Option Result, String> { - file.seek(SeekFrom::Start(plan.offset)) - .map_err(|error| format!("读取发行包失败:{error}"))?; - let mut buffer = vec![0_u8; usize::try_from(plan.length).unwrap_or(0)]; - file.read_exact(&mut buffer) - .map_err(|error| format!("读取发行包失败:{error}"))?; - Ok(buffer) -} - fn upload_url(base_url: &str, version_id: &str, suffix: &str) -> String { format!( "{}/api/game-distribution/versions/{version_id}/package{suffix}", @@ -184,270 +147,6 @@ fn parse_received_bytes(body: &str) -> Option { .and_then(Value::as_u64) } -fn platform_request( - client: &reqwest::Client, - access_token: &str, - method: reqwest::Method, - url: &str, -) -> reqwest::RequestBuilder { - client - .request(method, url) - .header(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE) - .header( - reqwest::header::AUTHORIZATION, - format!("Bearer {access_token}"), - ) -} - -async fn read_upload_state( - client: &reqwest::Client, - base_url: &str, - version_id: &str, - access_token: &str, -) -> Result { - let response = platform_request( - client, - access_token, - reqwest::Method::GET, - &upload_url(base_url, version_id, "/upload-state"), - ) - .send() - .await - .map_err(|error| format!("无法连接登录服务,请确认配套后端或 API 代理已启动后重试:{error}"))?; - let status = response.status().as_u16(); - let body = response - .text() - .await - .map_err(|error| format!("读取上传状态失败:{error}"))?; - if status >= 400 { - let (_, message) = parse_server_error(status, &body); - return Err(message.unwrap_or_else(|| format!("读取上传状态失败(HTTP {status})"))); - } - let parsed: Value = serde_json::from_str(&body) - .map_err(|error| format!("上传状态响应不是合法 JSON:{error}"))?; - let payload = parsed.get("data").unwrap_or(&parsed); - serde_json::from_value(payload.clone()) - .map_err(|error| format!("上传状态响应缺少字段:{error}")) -} - -/// 上传一个分片;返回服务端确认后的已收字节。 -async fn upload_chunk( - client: &reqwest::Client, - base_url: &str, - version_id: &str, - access_token: &str, - idempotency_key: &str, - plan: ChunkPlan, - body: Vec, -) -> Result { - let response = platform_request( - client, - access_token, - reqwest::Method::PUT, - &upload_url(base_url, version_id, "/chunk"), - ) - .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") - .header(UPLOAD_OFFSET_HEADER, plan.offset.to_string()) - .header("Idempotency-Key", format!("{idempotency_key}:chunk")) - .body(body) - .send() - .await - .map_err(|error| ChunkUploadError::Retryable(format!("发行包分片发送失败:{error}")))?; - let status = response.status().as_u16(); - let text = response - .text() - .await - .map_err(|error| ChunkUploadError::Retryable(format!("读取分片响应失败:{error}")))?; - if status == 409 { - let (code, message) = parse_server_error(status, &text); - if code.as_deref() == Some("PACKAGE_UPLOAD_OFFSET_MISMATCH") { - let received = parse_received_bytes(&text).ok_or_else(|| { - ChunkUploadError::Fatal("分片偏移不一致,但服务端未返回权威偏移".to_string()) - })?; - return Ok(received); - } - return Err(ChunkUploadError::Fatal( - message.unwrap_or_else(|| "发行包分片被拒绝".to_string()), - )); - } - if status >= 500 || status == 408 || status == 429 { - let (_, message) = parse_server_error(status, &text); - return Err(ChunkUploadError::Retryable( - message.unwrap_or_else(|| format!("发行包分片上传失败(HTTP {status})")), - )); - } - if status >= 400 { - let (_, message) = parse_server_error(status, &text); - return Err(ChunkUploadError::Fatal( - message.unwrap_or_else(|| format!("发行包分片被拒绝(HTTP {status})")), - )); - } - let parsed: Value = serde_json::from_str(&text) - .map_err(|error| ChunkUploadError::Retryable(format!("分片响应不是合法 JSON:{error}")))?; - let payload = parsed.get("data").unwrap_or(&parsed); - payload - .get("receivedBytes") - .and_then(Value::as_u64) - .ok_or_else(|| ChunkUploadError::Fatal("分片响应缺少 receivedBytes".to_string())) -} - -enum ChunkUploadError { - Retryable(String), - Fatal(String), -} - -async fn complete_upload( - client: &reqwest::Client, - base_url: &str, - version_id: &str, - access_token: &str, - idempotency_key: &str, -) -> Result { - let response = platform_request( - client, - access_token, - reqwest::Method::POST, - &upload_url(base_url, version_id, "/complete"), - ) - .header("Idempotency-Key", format!("{idempotency_key}:complete")) - .send() - .await - .map_err(|error| format!("完成发行包上传失败:{error}"))?; - let status = response.status().as_u16(); - let text = response - .text() - .await - .map_err(|error| format!("读取完成响应失败:{error}"))?; - if status >= 400 { - let (_, message) = parse_server_error(status, &text); - return Err(message.unwrap_or_else(|| format!("完成发行包上传失败(HTTP {status})"))); - } - let parsed: Value = - serde_json::from_str(&text).map_err(|error| format!("完成响应不是合法 JSON:{error}"))?; - let payload = parsed.get("data").unwrap_or(&parsed); - Ok(GamePackageUploadOutcome { - version_id: payload - .get("versionId") - .and_then(Value::as_str) - .unwrap_or(version_id) - .to_string(), - status: payload - .get("status") - .and_then(Value::as_str) - .unwrap_or("uploaded") - .to_string(), - uploaded_bytes: 0, - }) -} - -pub(crate) struct GamePackageUploadRequest<'a> { - pub(crate) staging_path: &'a Path, - pub(crate) version_id: &'a str, - pub(crate) api_base_url: &'a str, - pub(crate) access_token: &'a str, - pub(crate) idempotency_key: &'a str, -} - -/// 分片续传主循环:权威偏移来自服务端,失败按可重试分类退避,偏移不符立即按权威偏移继续。 -pub(crate) async fn upload_staged_game_package( - client: &reqwest::Client, - request: GamePackageUploadRequest<'_>, - mut on_progress: impl FnMut(u64, u64), -) -> Result { - let total_bytes = fs::metadata(request.staging_path) - .map_err(|error| format!("读取发行包暂存文件失败:{error}"))? - .len(); - if total_bytes == 0 { - return Err("发行包暂存文件为空,请重新导出试玩包".to_string()); - } - let state = read_upload_state( - client, - request.api_base_url, - request.version_id, - request.access_token, - ) - .await?; - if state.declared_package_bytes != 0 && state.declared_package_bytes != total_bytes { - return Err(format!( - "本地发行包与版本声明的体积不一致(本地 {} 字节,声明 {} 字节),请重新导出后再发布", - total_bytes, state.declared_package_bytes - )); - } - let chunk_bytes = if state.chunk_bytes == 0 { - MAX_CHUNK_BYTES - } else { - state.chunk_bytes.min(MAX_CHUNK_BYTES) - }; - let mut received = state.received_bytes.min(total_bytes); - on_progress(received, total_bytes); - let mut file = File::open(request.staging_path) - .map_err(|error| format!("打开发行包暂存文件失败:{error}"))?; - while let Some(plan) = next_chunk_plan(received, total_bytes, chunk_bytes) { - let body = read_chunk(&mut file, plan)?; - let mut attempt = 1_usize; - loop { - match upload_chunk( - client, - request.api_base_url, - request.version_id, - request.access_token, - request.idempotency_key, - plan, - body.clone(), - ) - .await - { - Ok(next_received) => { - received = next_received.min(total_bytes); - on_progress(received, total_bytes); - break; - } - Err(ChunkUploadError::Fatal(error)) => return Err(error), - Err(ChunkUploadError::Retryable(error)) => { - if attempt >= CHUNK_MAX_ATTEMPTS { - return Err(format!("{error}(已尝试 {attempt} 次,可重新发布续传)")); - } - attempt += 1; - tokio::time::sleep(CHUNK_RETRY_DELAY).await; - } - } - } - // 权威偏移可能在重试期间前进(例如响应丢失后服务端已写入),按服务端口径对齐。 - let authoritative = read_upload_state( - client, - request.api_base_url, - request.version_id, - request.access_token, - ) - .await?; - received = authoritative.received_bytes.min(total_bytes); - on_progress(received, total_bytes); - } - let mut outcome = complete_upload( - client, - request.api_base_url, - request.version_id, - request.access_token, - request.idempotency_key, - ) - .await?; - outcome.uploaded_bytes = total_bytes; - Ok(outcome) -} - -/// 进度事件的载荷形状(渲染进程按它显示进度)。 -pub(crate) fn progress_event_payload( - version_id: &str, - received_bytes: u64, - total_bytes: u64, -) -> Value { - json!({ - "versionId": version_id, - "receivedBytes": received_bytes, - "totalBytes": total_bytes, - }) -} - #[cfg(test)] mod tests { use super::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs new file mode 100644 index 000000000..171c549d1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs @@ -0,0 +1,314 @@ +use super::*; +use serde_json::json; +use std::{ + io::{Read, Seek, SeekFrom}, + time::Duration, +}; + +pub(crate) const GAME_PACKAGE_UPLOAD_PROGRESS_EVENT: &str = "game-package-upload-progress"; + +const AGC_CLIENT_MARKER_HEADER: &str = "X-Genarrative-Client"; +const AGC_CLIENT_MARKER_VALUE: &str = "agc"; +const UPLOAD_OFFSET_HEADER: &str = "x-genarrative-upload-offset"; +/// 服务端下发的分片大小上限;客户端只按服务端给的值发,超过它必然被拒。 +const MAX_CHUNK_BYTES: u64 = 8 * 1024 * 1024; +const CHUNK_MAX_ATTEMPTS: usize = 4; +const CHUNK_RETRY_DELAY: Duration = Duration::from_millis(500); +const UPLOAD_STAGING_DIR_NAME: &str = "game-package-staging"; + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GamePackageUploadOutcome { + pub(crate) version_id: String, + pub(crate) status: String, + pub(crate) uploaded_bytes: u64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct PackageUploadState { + #[serde(default)] + received_bytes: u64, + #[serde(default)] + chunk_bytes: u64, + #[serde(default)] + declared_package_bytes: u64, +} + +/// 暂存目录:放在应用数据目录下,不写进项目、也不参与项目快照同步。 +pub(crate) fn game_package_upload_staging_dir(app_data_dir: &Path) -> PathBuf { + app_data_dir.join(UPLOAD_STAGING_DIR_NAME) +} + +fn read_chunk(file: &mut File, plan: ChunkPlan) -> Result, String> { + file.seek(SeekFrom::Start(plan.offset)) + .map_err(|error| format!("读取发行包失败:{error}"))?; + let mut buffer = vec![0_u8; usize::try_from(plan.length).unwrap_or(0)]; + file.read_exact(&mut buffer) + .map_err(|error| format!("读取发行包失败:{error}"))?; + Ok(buffer) +} + +fn platform_request( + client: &reqwest::Client, + access_token: &str, + method: reqwest::Method, + url: &str, +) -> reqwest::RequestBuilder { + client + .request(method, url) + .header(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {access_token}"), + ) +} + +async fn read_upload_state( + client: &reqwest::Client, + base_url: &str, + version_id: &str, + access_token: &str, +) -> Result { + let response = platform_request( + client, + access_token, + reqwest::Method::GET, + &upload_url(base_url, version_id, "/upload-state"), + ) + .send() + .await + .map_err(|error| format!("无法连接登录服务,请确认配套后端或 API 代理已启动后重试:{error}"))?; + let status = response.status().as_u16(); + let body = response + .text() + .await + .map_err(|error| format!("读取上传状态失败:{error}"))?; + if status >= 400 { + let (_, message) = parse_server_error(status, &body); + return Err(message.unwrap_or_else(|| format!("读取上传状态失败(HTTP {status})"))); + } + let parsed: Value = serde_json::from_str(&body) + .map_err(|error| format!("上传状态响应不是合法 JSON:{error}"))?; + let payload = parsed.get("data").unwrap_or(&parsed); + serde_json::from_value(payload.clone()) + .map_err(|error| format!("上传状态响应缺少字段:{error}")) +} + +/// 上传一个分片;返回服务端确认后的已收字节。 +async fn upload_chunk( + client: &reqwest::Client, + base_url: &str, + version_id: &str, + access_token: &str, + idempotency_key: &str, + plan: ChunkPlan, + body: Vec, +) -> Result { + let response = platform_request( + client, + access_token, + reqwest::Method::PUT, + &upload_url(base_url, version_id, "/chunk"), + ) + .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") + .header(UPLOAD_OFFSET_HEADER, plan.offset.to_string()) + .header("Idempotency-Key", format!("{idempotency_key}:chunk")) + .body(body) + .send() + .await + .map_err(|error| ChunkUploadError::Retryable(format!("发行包分片发送失败:{error}")))?; + let status = response.status().as_u16(); + let text = response + .text() + .await + .map_err(|error| ChunkUploadError::Retryable(format!("读取分片响应失败:{error}")))?; + if status == 409 { + let (code, message) = parse_server_error(status, &text); + if code.as_deref() == Some("PACKAGE_UPLOAD_OFFSET_MISMATCH") { + let received = parse_received_bytes(&text).ok_or_else(|| { + ChunkUploadError::Fatal("分片偏移不一致,但服务端未返回权威偏移".to_string()) + })?; + return Ok(received); + } + return Err(ChunkUploadError::Fatal( + message.unwrap_or_else(|| "发行包分片被拒绝".to_string()), + )); + } + if status >= 500 || status == 408 || status == 429 { + let (_, message) = parse_server_error(status, &text); + return Err(ChunkUploadError::Retryable( + message.unwrap_or_else(|| format!("发行包分片上传失败(HTTP {status})")), + )); + } + if status >= 400 { + let (_, message) = parse_server_error(status, &text); + return Err(ChunkUploadError::Fatal( + message.unwrap_or_else(|| format!("发行包分片被拒绝(HTTP {status})")), + )); + } + let parsed: Value = serde_json::from_str(&text) + .map_err(|error| ChunkUploadError::Retryable(format!("分片响应不是合法 JSON:{error}")))?; + let payload = parsed.get("data").unwrap_or(&parsed); + payload + .get("receivedBytes") + .and_then(Value::as_u64) + .ok_or_else(|| ChunkUploadError::Fatal("分片响应缺少 receivedBytes".to_string())) +} + +enum ChunkUploadError { + Retryable(String), + Fatal(String), +} + +async fn complete_upload( + client: &reqwest::Client, + base_url: &str, + version_id: &str, + access_token: &str, + idempotency_key: &str, +) -> Result { + let response = platform_request( + client, + access_token, + reqwest::Method::POST, + &upload_url(base_url, version_id, "/complete"), + ) + .header("Idempotency-Key", format!("{idempotency_key}:complete")) + .send() + .await + .map_err(|error| format!("完成发行包上传失败:{error}"))?; + let status = response.status().as_u16(); + let text = response + .text() + .await + .map_err(|error| format!("读取完成响应失败:{error}"))?; + if status >= 400 { + let (_, message) = parse_server_error(status, &text); + return Err(message.unwrap_or_else(|| format!("完成发行包上传失败(HTTP {status})"))); + } + let parsed: Value = + serde_json::from_str(&text).map_err(|error| format!("完成响应不是合法 JSON:{error}"))?; + let payload = parsed.get("data").unwrap_or(&parsed); + Ok(GamePackageUploadOutcome { + version_id: payload + .get("versionId") + .and_then(Value::as_str) + .unwrap_or(version_id) + .to_string(), + status: payload + .get("status") + .and_then(Value::as_str) + .unwrap_or("uploaded") + .to_string(), + uploaded_bytes: 0, + }) +} + +pub(crate) struct GamePackageUploadRequest<'a> { + pub(crate) staging_path: &'a Path, + pub(crate) version_id: &'a str, + pub(crate) api_base_url: &'a str, + pub(crate) access_token: &'a str, + pub(crate) idempotency_key: &'a str, +} + +/// 分片续传主循环:权威偏移来自服务端,失败按可重试分类退避,偏移不符立即按权威偏移继续。 +pub(crate) async fn upload_staged_game_package( + client: &reqwest::Client, + request: GamePackageUploadRequest<'_>, + mut on_progress: impl FnMut(u64, u64), +) -> Result { + let total_bytes = fs::metadata(request.staging_path) + .map_err(|error| format!("读取发行包暂存文件失败:{error}"))? + .len(); + if total_bytes == 0 { + return Err("发行包暂存文件为空,请重新导出试玩包".to_string()); + } + let state = read_upload_state( + client, + request.api_base_url, + request.version_id, + request.access_token, + ) + .await?; + if state.declared_package_bytes != 0 && state.declared_package_bytes != total_bytes { + return Err(format!( + "本地发行包与版本声明的体积不一致(本地 {} 字节,声明 {} 字节),请重新导出后再发布", + total_bytes, state.declared_package_bytes + )); + } + let chunk_bytes = if state.chunk_bytes == 0 { + MAX_CHUNK_BYTES + } else { + state.chunk_bytes.min(MAX_CHUNK_BYTES) + }; + let mut received = state.received_bytes.min(total_bytes); + on_progress(received, total_bytes); + let mut file = File::open(request.staging_path) + .map_err(|error| format!("打开发行包暂存文件失败:{error}"))?; + while let Some(plan) = next_chunk_plan(received, total_bytes, chunk_bytes) { + let body = read_chunk(&mut file, plan)?; + let mut attempt = 1_usize; + loop { + match upload_chunk( + client, + request.api_base_url, + request.version_id, + request.access_token, + request.idempotency_key, + plan, + body.clone(), + ) + .await + { + Ok(next_received) => { + received = next_received.min(total_bytes); + on_progress(received, total_bytes); + break; + } + Err(ChunkUploadError::Fatal(error)) => return Err(error), + Err(ChunkUploadError::Retryable(error)) => { + if attempt >= CHUNK_MAX_ATTEMPTS { + return Err(format!("{error}(已尝试 {attempt} 次,可重新发布续传)")); + } + attempt += 1; + tokio::time::sleep(CHUNK_RETRY_DELAY).await; + } + } + } + // 权威偏移可能在重试期间前进(例如响应丢失后服务端已写入),按服务端口径对齐。 + let authoritative = read_upload_state( + client, + request.api_base_url, + request.version_id, + request.access_token, + ) + .await?; + received = authoritative.received_bytes.min(total_bytes); + on_progress(received, total_bytes); + } + let mut outcome = complete_upload( + client, + request.api_base_url, + request.version_id, + request.access_token, + request.idempotency_key, + ) + .await?; + outcome.uploaded_bytes = total_bytes; + Ok(outcome) +} + +/// 进度事件的载荷形状(渲染进程按它显示进度)。 +pub(crate) fn progress_event_payload( + version_id: &str, + received_bytes: u64, + total_bytes: u64, +) -> Value { + json!({ + "versionId": version_id, + "receivedBytes": received_bytes, + "totalBytes": total_bytes, + }) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 7175acf53..3ac8f313f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -29,7 +29,6 @@ use platform_llm::{ use reqwest::header; use serde::{Deserialize, Serialize}; use sha2::Digest; -use shared_contracts::error_reports::ErrorReportLogInput; use shared_contracts::game_creation_app::{ game_creation_app_asset_category_for_kind, game_creation_app_asset_category_from_str, game_creation_app_asset_effective_category, new_game_creation_app_manifest, @@ -50,17 +49,7 @@ use shared_contracts::game_creation_app::{ }; // `Emitter` 同时被 `use super::*` 的子模块依赖(通知、Agent 事件等都从 crate 根取该 trait), // 不要因为根模块自身不再直接 `.emit(..)` 就删掉它。 -use environment_check::preflight_web_game_creation; use tauri::{Emitter, Manager}; -use tauri_plugin_dialog::DialogExt; -use tauri_plugin_opener::OpenerExt; - -/// 更新完成后的进程重启:Windows 由 NSIS 安装程序代为重启,macOS / Linux 由客户端在安装后调用。 -#[tauri::command] -fn restart_agc_app(app: tauri::AppHandle) { - analytics::gui::mark_restart(); - app.restart(); -} /// Rust 侧普通文本日志:保留 stderr 输出,同时将同一行持久化到 AppData。 /// 诊断包只在用户主动提交时读取这些 raw log;结构化错误事件仍只留在内存。 @@ -72,36 +61,6 @@ macro_rules! app_log { }}; } -/// 把 `shared-contracts` 的「非 canonical 资源 kind」留痕接到壳层日志上。 -/// -/// 解析边界(含 manifest 反序列化、画板 assetKind、平台 assetKind)认不出 canonical 值时, -/// 会把**原始输入串**与调用上下文交回来;这里落到 `app_log!` → AppData 日志里, -/// 以便回查非 canonical kind 的写入边界。kind 本身只接受严格值。 -/// -/// 按 `(原始串, 上下文)` 去重:manifest 读取极频繁,每次都落一行会把其它诊断刷掉; -/// 去重后"有哪些非 canonical 值、分别从哪个边界进来"仍然完整。需要具体资产身份时看 -/// `log_non_canonical_manifest_asset_kinds`(按资产去重,带 assetId / localPath)。 -fn register_non_canonical_asset_kind_reporter() { - static REPORTED: std::sync::OnceLock>> = - std::sync::OnceLock::new(); - shared_contracts::game_creation_app::set_non_canonical_asset_kind_reporter( - |raw_kind: &str, context: &str| { - let key = format!("{context}\u{1}{raw_kind}"); - let first_seen = REPORTED - .get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new())) - .lock() - .map(|mut reported| reported.insert(key)) - .unwrap_or(true); - if !first_seen { - return; - } - app_log!( - "GameCreationApp 资源 kind 不是 canonical 值,已按严格口径收口为 unknown:rawKind={raw_kind} context={context}" - ); - }, - ); -} - // 调试落盘模块(保存 LLM 原始输出 / 失败输入,排查截断、空返回等)放在 debug_drafts.rs。 // 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。 include!(concat!(env!("OUT_DIR"), "/agent_runtime_prompt_bundle.rs")); @@ -127,6 +86,11 @@ mod context_menu; #[cfg(all(debug_assertions, not(test)))] mod debug; mod delegation; +// 桌面与独立进程启动接线不参与单元测试;共享实现仍在各自模块编译。 +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(crate) use desktop::game_creator_product_name; mod editor_adapter; mod editor_adapters; mod environment_check; @@ -156,14 +120,11 @@ mod tool_plan_handoff; mod user_input; mod windows; -use agent::design_tools::*; use agent::*; use agent_native_tools::*; -use asset_generation_tasks::*; use assets::*; use browser::*; use cli::*; -use client_extensions::*; use collaboration::*; use command_exec::*; use command_output::*; @@ -172,110 +133,23 @@ use commands::*; use config::*; use context_compaction::*; use delegation::*; -use error_report::*; use git_inspect::*; use goal::*; use image_inspect::*; use isolated_agent::*; use patchset::*; use platform_session::*; -use plugin_host::{ - call_agc_plugin, list_agc_extensions, list_agc_plugins, read_agc_plugin_panel, - refresh_agc_plugins, reload_agc_plugin, set_agc_plugin_enabled, set_agc_plugin_project_path, - start_agc_plugin, stop_agc_plugin, PluginHost, -}; use preview::*; use process_session::*; use project::*; +#[cfg(not(test))] use project_snapshot::*; use repository_context::*; use resource_inspect::*; use resource_preview_scheduler::*; use runner::*; -use template_library::*; use user_input::*; use windows::*; -#[tauri::command] -async fn suggest_ui_design_semantic( - project_path: String, - state: ui_editor::state::State, -) -> Result, String> { - ui_editor::commands::suggest_ui_design_semantic_impl(project_path, state).await -} - -#[tauri::command] -async fn recognize_ui( - project_path: String, - state: ui_editor::state::State, -) -> Result { - ui_editor::commands::recognize_ui_impl(project_path, state).await -} - -#[tauri::command] -async fn separate_ui( - project_path: String, - asset_id: String, - state: ui_editor::state::State, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - ui_editor::commands::separate_ui_impl(project_path, asset_id, state).await -} - -#[tauri::command] -fn inspect_separation_recovery( - project_path: String, - asset_id: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "asset.list")?; - ui_editor::commands::separation::inspect_separation_recovery(root, &asset_id) -} - -#[tauri::command] -fn finalize_separation(project_path: String, asset_id: String) -> Result<(), String> { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - ui_editor::commands::separation::finalize_separation(root, &asset_id) -} - -#[tauri::command] -fn discard_separation_recovery(project_path: String, asset_id: String) -> Result<(), String> { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "asset.register")?; - ui_editor::commands::separation::discard_separation_recovery(root, &asset_id) -} - -#[tauri::command] -async fn merge_ui(state: ui_editor::state::State) -> Result { - ui_editor::commands::merge_ui_impl(state).await -} - -#[tauri::command] -async fn bind_components( - project_path: String, - state: ui_editor::state::State, - sprite_ids: Vec, -) -> Result { - ui_editor::commands::bind_components_impl(project_path, state, sprite_ids).await -} - -#[tauri::command] -fn load_ui_design_state( - input: ui_editor::persistence::LoadUiDesignStateInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "asset.list")?; - ui_editor::persistence::load_ui_design_state_at(input) -} - -#[tauri::command] -fn save_ui_design_state( - input: ui_editor::persistence::SaveUiDesignStateInput, -) -> Result { - save_ui_design_state_with_capture(input, analytics::gui::capture_writer_context()) -} - fn save_ui_design_state_with_capture( input: ui_editor::persistence::SaveUiDesignStateInput, capture: Option<( @@ -307,22 +181,6 @@ fn save_ui_design_state_with_capture( Ok(result) } -#[tauri::command] -fn generate_ui_design_code( - input: ui_editor::persistence::GenerateUiDesignCodeInput, -) -> Result { - let root = Path::new(input.project_path.trim()); - enforce_project_permission_policy(root, "file.write")?; - ui_editor::persistence::generate_ui_design_code_at(input) -} - -#[tauri::command] -fn ensure_ui_design_resource_for_prototype( - input: ui_editor::resource_bridge::EnsureUiDesignResourceForPrototypeInput, -) -> Result { - ui_editor::resource_bridge::ensure_ui_design_resource_for_prototype(input) -} - #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct InitLocalProjectResult { @@ -391,23 +249,6 @@ struct GameCreatorChatAgentReply { reply_text: String, } -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct GameCreatorRoleAgentChatStreamEvent { - project_path: String, - agent_id: String, - run_id: String, - status: String, - delta_text: String, - accumulated_text: String, - finish_reason: Option, - session_id: Option, - runtime_status: Option, - runtime_phase: Option, - runtime_summary: Option, - runtime_state: Option, -} - #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct AgentRuntimeState { @@ -1189,12 +1030,6 @@ struct GeneratedPlatformArtAsset { slice_warning: Option, } -#[derive(Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct OpenCanvasProjectResult { - url: String, -} - #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct CanvasExportMetadata { @@ -1728,47 +1563,6 @@ const DIAGNOSTIC_LOG_MAX_BYTES: u64 = 256 * 1024; static DIAGNOSTIC_LOG_LOCK: OnceLock> = OnceLock::new(); static STARTUP_ERROR_DIALOG_SHOWN: AtomicBool = AtomicBool::new(false); -#[tauri::command] -fn append_application_log(level: String, source: String, message: String) -> Result<(), String> { - let config_dir = game_creator_runtime_config_dir() - .ok_or_else(|| "客户端 AppData 配置目录未初始化".to_string())?; - let level = sanitize_diagnostic_message(&level, Some(&config_dir)); - let source = sanitize_diagnostic_message(&source, Some(&config_dir)); - let message = sanitize_diagnostic_message(&message, Some(&config_dir)); - let line = format!("WEBVIEW {level} {source}: {message}"); - append_application_log_line(&line).map_err(|error| error.to_string()) -} - -#[tauri::command] -fn read_diagnostic_logs() -> Result, String> { - let Some(config_dir) = game_creator_runtime_config_dir() else { - return Ok(Vec::new()); - }; - let _guard = DIAGNOSTIC_LOG_LOCK - .get_or_init(|| Mutex::new(())) - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let directory = config_dir.join("diagnostics"); - let mut files = Vec::new(); - for name in ["application.log", "application.previous.log", "startup.log"] { - let path = directory.join(name); - let Ok(metadata) = fs::symlink_metadata(&path) else { - continue; - }; - if metadata.file_type().is_symlink() || !metadata.is_file() { - continue; - } - let Ok(content) = fs::read_to_string(&path) else { - continue; - }; - files.push(ErrorReportLogInput { - name: name.to_string(), - content, - }); - } - Ok(files) -} - fn diagnostic_timestamp() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -2026,21 +1820,6 @@ fn show_startup_error_dialog(log_path: Option<&Path>) { } } -/// 客户端产品名跟随构建期渠道身份:默认渠道是「陶泥儿」,其它渠道带渠道后缀 -/// (例如「陶泥儿 Release」)。同机并存的渠道客户端因此在窗口标题、任务栏与 -/// Alt-Tab 里可区分;默认渠道结果不变。 -pub(crate) fn game_creator_product_name(app: &tauri::AppHandle) -> String { - app.package_info().name.clone() -} - -/// 配置目录就绪前的启动日志路径:优先用已经生效的配置目录(例如 `--config-dir` -/// 已经设置好的目录),否则退到平台配置根。两者都不可用时返回 `None`,此时 -/// `StartupLogSlot::fail` 仍然必须给出用户可见提示。 -fn early_startup_log_path(identifier: &str) -> Option { - let configured_dir = game_creator_runtime_config_dir(); - resolve_early_startup_log_path(configured_dir.as_deref(), identifier) -} - fn resolve_early_startup_log_path( configured_dir: Option<&Path>, identifier: &str, @@ -2141,19 +1920,6 @@ enum GameCreatorGuiRunnerShutdownFailure { Other, } -impl GameCreatorGuiRunnerShutdownFailure { - fn code(self) -> &'static str { - match self { - Self::EndpointUnavailable => "endpoint_unavailable", - Self::RunnerUnresponsive => "runner_unresponsive", - Self::ProcessIdentity => "process_identity", - Self::PlatformUnsupported => "platform_unsupported", - Self::LockTimeout => "lock_timeout", - Self::Other => "other", - } - } -} - fn classify_game_creator_gui_runner_shutdown_error( error: &str, ) -> GameCreatorGuiRunnerShutdownFailure { @@ -2196,39 +1962,6 @@ where } } -fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) { - if matches!(event, tauri::RunEvent::Exit) { - // 窗口关闭时已经按项目触发过一次快照同步;退出路径只负责在有界预算内 - // 等在途同步收尾,不重复发起(此刻窗口已销毁,重新枚举项目只会是空集)。 - wait_for_project_snapshot_syncs_on_exit(); - // 退出时统一收尾本地预览:进程内监听线程随进程消失,但 `.agent/manifest.json` - // 里的 preview 记录会留在 running 上,下次进项目就照着它渲染打不开的运行界面。 - if let Err(error) = - preview::stop_local_game_preview_on_exit(&game_creator_preview_registry()) - { - app_log!("preview.gui_exit.stop_failed: {error}"); - } - if let Err(error) = agent::shutdown_game_creator_codex_app_servers() { - app_log!("agent.direct_codex.gui_exit.shutdown_failed: {error}"); - } - } - match resolve_game_creator_gui_runner_shutdown( - event, - shutdown_external_agent_runner_for_gui_exit, - ) { - GameCreatorGuiRunnerShutdownOutcome::NotRequested => {} - GameCreatorGuiRunnerShutdownOutcome::Requested => { - app_log!("agent.runner.gui_exit.shutdown_requested") - } - GameCreatorGuiRunnerShutdownOutcome::Retained => { - app_log!("agent.runner.gui_exit.retained_for_other_windows") - } - GameCreatorGuiRunnerShutdownOutcome::Failed(failure) => { - app_log!("agent.runner.gui_exit.shutdown_failed.{}", failure.code()) - } - } -} - /// Tauri 的全局异步 runtime 默认由 `TokioRuntime::new()` 建出来,worker 线程吃 /// tokio 默认栈。Runtime 的 agent turn 调用链深到本仓库另一处专门给自己的后台 /// 线程配了 AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES;但凡经 async_runtime::spawn @@ -2246,65 +1979,6 @@ fn build_agent_runtime_async_runtime() -> Result runtime, - Err(error) => { - app_log!("agent.runner.failed: {error}"); - std::process::exit(1); - } - }; - tauri::async_runtime::set(runtime.handle().clone()); - // 进程生命周期内必须持有,否则 handle 立即失效。 - Box::leak(Box::new(runtime)); -} - -#[cfg(windows)] -fn run_windows_acl_repair_if_requested(args: &[String]) -> Option { - let [command, path, target_user_sid_flag, target_user_sid, authorization_flag, nonce, scope_flag, scope_value] = - args - else { - if args.first().map(String::as_str) == Some("--repair-private-acl") { - eprintln!( - "用法:--repair-private-acl <路径> --target-user-sid --authorization <票据> --scope " - ); - return Some(1); - } - return None; - }; - if command != "--repair-private-acl" - || target_user_sid_flag != "--target-user-sid" - || authorization_flag != "--authorization" - || scope_flag != "--scope" - { - eprintln!( - "用法:--repair-private-acl <路径> --target-user-sid --authorization <票据> --scope " - ); - return Some(1); - } - let path = std::path::PathBuf::from(path); - let scope = match config::parse_windows_acl_repair_scope(scope_value) { - Ok(scope) => scope, - Err(error) => { - eprintln!("{error}"); - return Some(1); - } - }; - let result = - config::consume_windows_acl_repair_authorization(&path, target_user_sid, nonce, scope) - .and_then(|()| { - config::repair_game_creator_private_acl_for_user_sid(&path, target_user_sid) - }); - match result { - Ok(()) => Some(0), - Err(error) => { - eprintln!("AGC ACL 提权修复失败:{error}"); - Some(1) - } - } -} - #[cfg(test)] mod async_runtime_stack_tests { /// 每帧固定占 16 KiB,用 black_box 挡住优化,让递归深度直接换算成栈用量。 @@ -2337,489 +2011,7 @@ mod async_runtime_stack_tests { #[cfg(not(test))] fn main() { - install_agent_runtime_async_runtime_with_deep_stack(); - register_non_canonical_asset_kind_reporter(); - let mut args = std::env::args().skip(1).collect::>(); - if let Some(exit_code) = run_direct_tools_mcp_if_requested(&args) { - std::process::exit(exit_code); - } - #[cfg(windows)] - if let Some(exit_code) = run_windows_acl_repair_if_requested(&args) { - std::process::exit(exit_code); - } - #[cfg(target_os = "linux")] - if command_sandbox_trampoline::is_trampoline_mode(&args) { - match command_sandbox_trampoline::run_trampoline() { - Ok(exit_code) => std::process::exit(exit_code), - Err(_) => std::process::exit(125), - } - } - #[cfg(target_os = "linux")] - if command_sandbox_trampoline::is_process_session_trampoline_mode(&args) { - match command_sandbox_trampoline::run_process_session_trampoline() { - Ok(exit_code) => std::process::exit(exit_code), - Err(_) => std::process::exit(125), - } - } - #[cfg(target_os = "linux")] - if is_process_session_child_mode(&args) { - match run_process_session_child(&args) { - Ok(exit_code) => std::process::exit(exit_code), - Err(_) => std::process::exit(125), - } - } - let runtime_config_dir = match take_cli_runtime_config_dir(&mut args) { - Ok(config_dir) => config_dir, - Err(error) => { - app_log!("{error}"); - std::process::exit(1); - } - }; - if args.first().map(String::as_str) == Some("--agent-runner") { - let gui_owner_required = match args.as_slice() { - [_] => false, - [_, option] if option == "--gui-owner-required" => true, - _ => { - app_log!( - "用法:--agent-runner [--gui-owner-required] --config-dir " - ); - std::process::exit(1); - } - }; - let Some(config_dir) = runtime_config_dir else { - app_log!("Agent Runner 必须显式传入 --config-dir "); - std::process::exit(1); - }; - if let Err(error) = load_platform_session_fixture_from_env(&config_dir) { - app_log!("agent.runner.failed: {error}"); - std::process::exit(1); - } - set_game_creator_runtime_config_dir(config_dir.clone()); - if let Err(error) = run_external_agent_runner_server(config_dir, gui_owner_required) { - app_log!("agent.runner.failed: {error}"); - std::process::exit(1); - } - return; - } - match parse_cli_command(&args) { - Ok(Some(mut command)) => { - let config_dir = - match prepare_cli_command_paths(&mut command, runtime_config_dir.as_deref()) { - Ok(config_dir) => config_dir, - Err(error) => { - app_log!("agent.runner.failed: {error}"); - std::process::exit(1); - } - }; - if let Some(config_dir) = config_dir { - if let Err(error) = load_platform_session_fixture_from_env(&config_dir) { - app_log!("agent.runner.failed: {error}"); - std::process::exit(1); - } - if command.requires_external_agent_runner() || command.is_read_only_status() { - let configured = if command.is_read_only_status() { - configure_external_agent_runner_read_only(&config_dir) - } else { - configure_external_agent_runner(&config_dir) - }; - if let Err(error) = configured { - app_log!("agent.runner.failed: {error}"); - std::process::exit(1); - } - } - set_game_creator_runtime_config_dir(config_dir); - } - if command.requires_started_external_agent_runner() { - if let Err(error) = ensure_external_agent_runner_started() { - app_log!("agent.runner.failed: {error}"); - std::process::exit(1); - } - } - if let Err(error) = run_cli_command(command) { - app_log!("agent.run.failed: {error}"); - std::process::exit(1); - } - return; - } - Ok(None) => {} - Err(error) => { - app_log!("{error}"); - std::process::exit(1); - } - } - - if let Some(config_dir) = runtime_config_dir { - set_game_creator_runtime_config_dir(config_dir); - } - - let tauri_context = tauri::generate_context!(); - // 配置目录确定之前先推导启动日志路径:优先用已经生效的配置目录(例如 - // `--config-dir`),否则退到平台配置根,保证 - // `configure_game_creator_runtime_config_dir` 自身失败也有落点。 - let startup_log = Arc::new(StartupLogSlot::new(early_startup_log_path( - tauri_context.config().identifier.as_str(), - ))); - let setup_log = Arc::clone(&startup_log); - let app = tauri::Builder::default() - .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_http::init()) - .plugin(tauri_plugin_clipboard_manager::init()) - .plugin(tauri_plugin_updater::Builder::new().build()) - .plugin(context_menu::init()) - .on_page_load(|webview, payload| { - if matches!(payload.event(), tauri::webview::PageLoadEvent::Started) { - analytics::gui::page_loading(webview.window().label()); - } - }) - .on_window_event(|window, event| { - analytics::gui::window_event(window, event); - handle_project_snapshot_window_event(window, event); - }) - .manage(game_creator_preview_registry()) - .manage(ProjectResourcePreviewReadManager::default()) - .manage(PluginHost::default()) - .setup(move |app| { - error_report::initialize_notifications(app.handle()); - setup_log.append("startup.setup.begin"); - setup_log.append("startup.appdata.configure.begin"); - configure_game_creator_runtime_config_dir(app.handle()).inspect_err(|error| { - // 日志路径未知也必须记录并提示:不能因为拿不到路径就静默失败。 - let config_dir = game_creator_runtime_config_dir(); - let details = - sanitize_diagnostic_message(&error.to_string(), config_dir.as_deref()); - setup_log.fail(&format!( - "startup.appdata.configure.failed details={details}" - )); - })?; - if let Some(directory) = game_creator_runtime_config_dir() { - setup_log.set(directory.join("diagnostics/startup.log")); - } - // 跨会话清扫陈旧的无头浏览器(上次异常退出/被杀留下的 ga-browser-*)。 - // 后台执行,不阻塞启动;杀树前按进程身份与可信浏览器路径双重校验。 - tauri::async_runtime::spawn(async move { - let notes = - tokio::task::spawn_blocking(crate::browser::sweep_stale_browser_processes) - .await - .unwrap_or_default(); - if !notes.is_empty() { - app_log!("startup.browser-sweep: {}", notes.join("; ")); - } - }); - setup_log.append("startup.appdata.configure.complete"); - let config_dir = game_creator_runtime_config_dir().ok_or_else(|| { - let error = std::io::Error::new( - std::io::ErrorKind::NotFound, - "客户端 AppData 配置目录未初始化", - ); - setup_log.fail("startup.appdata.resolve.failed details=config-dir-uninitialized"); - error - })?; - // 主窗口标题与产品名保持一致:配置里的标题来自基线配置,渠道后缀只 - // 由构建期身份决定,因此必须在这里按产品名覆盖。 - match app.get_webview_window("client") { - Some(window) => { - if let Err(error) = window.set_title(&game_creator_product_name(app.handle())) { - app_log!("startup.window-title.failed: {error}"); - } - } - None => app_log!("startup.window-title.failed: 缺少 client 主窗口"), - } - spawn_project_snapshot_scheduler(app.handle().clone()); - if let Err(error) = builtin_plugins::initialize(&config_dir) { - app_log!("startup.builtin-plugins.initialize.failed: {error}"); - } - if let Err(error) = app.state::().initialize(&config_dir) { - app_log!("startup.plugin-host.initialize.failed: {error}"); - } - if let Some(workspace) = plugin_host::resolve_plugin_workspace(app.handle()) { - if let Err(error) = app.state::().set_plugin_workspace(workspace) { - app_log!("startup.plugin-host.workspace.failed: {error}"); - } - } - if let Err(error) = editor_adapters::register_linked_editor_adapters( - app.handle(), - app.state::().inner(), - ) { - app_log!("startup.plugin-host.adapter.failed: {error}"); - } - load_platform_session_fixture_from_env(&config_dir).map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - format!("加载平台登录态测试 fixture 失败:{error}"), - ) - })?; - setup_log.append("startup.runner.configure.begin"); - configure_external_agent_runner(&config_dir) - .inspect_err(|error| { - let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); - setup_log.fail(&format!( - "startup.runner.configure.failed details={details}" - )); - }) - .map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::Other, - format!("配置 Agent Runner 失败:{error}"), - ) - })?; - setup_log.append("startup.runner.configure.complete"); - hold_external_agent_runner_gui_participant_lock(&config_dir) - .inspect_err(|error| { - let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); - setup_log.fail(&format!( - "startup.runner.participant-lock.failed details={details}" - )); - }) - .map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - format!("建立 AGC 界面参与锁失败:{error}"), - ) - })?; - setup_log.append("startup.runner.start.begin"); - set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); - set_direct_thread_manager_app_handle(app.handle().clone()); - let manifest_event_sink = - start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?; - attach_external_agent_runner_gui_owner(&manifest_event_sink) - .inspect_err(|error| { - let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); - setup_log.fail(&format!( - "startup.runner.attach-owner.failed details={details}" - )); - }) - .map_err(|error| { - std::io::Error::new( - std::io::ErrorKind::Other, - format!("绑定 Agent Runner GUI owner 失败:{error}"), - ) - })?; - setup_log.append("startup.runner.start.complete"); - setup_log.append("startup.setup.complete"); - Ok(()) - }) - .invoke_handler(tauri::generate_handler![ - analytics::gui::capture_analytics_context, - analytics::gui::settle_direct_run_analytics, - analytics::gui::record_analytics_project_open, - analytics::gui::record_analytics_project_leave, - analytics::gui::record_analytics_ui_save, - start_game_creator_external_mcp, - stop_game_creator_external_mcp, - create_automatic_local_game_project, - create_automatic_local_game_project_from_template, - init_local_game_project, - fetch_game_template_library, - get_game_template_library_access, - download_game_template, - import_local_godot_project, - import_local_cocos_project, - import_local_unity_project, - is_local_project_directory_non_empty, - inspect_local_project_directory, - pick_local_project_directory, - rename_local_game_project, - suggest_automatic_project_name, - polish_local_project_prompt, - pick_client_extension_file, - pick_client_extension_directory, - list_client_extensions, - list_agc_skill_catalog, - import_client_extension, - set_client_extension_enabled, - rename_client_extension, - remove_client_extension, - list_agc_plugins, - list_agc_extensions, - refresh_agc_plugins, - start_agc_plugin, - stop_agc_plugin, - reload_agc_plugin, - set_agc_plugin_enabled, - call_agc_plugin, - read_agc_plugin_panel, - set_agc_plugin_project_path, - open_local_project_directory, - control_agent_run, - generate_local_game_draft, - chat_with_game_creator_agent, - chat_with_game_creator_role_agent, - chat_with_game_creator_role_agent_stream, - chat_with_game_creator_direct_codex, - preflight_web_game_creation, - cancel_direct_codex_turn, - select_game_creator_reasoning_effort, - hydrate_design_agent_session, - reset_design_agent_session, - get_design_agent_runtime_mode, - is_design_agent_debug_enabled, - set_design_agent_runtime_mode, - debug_fast_forward_design_session, - continue_design_agent_session, - decide_design_phase, - import_design_workspace_file, - list_design_workspace, - read_design_workspace_file, - start_game_creator_supervisor_runtime_task, - compact_game_creator_agent_runtime_context, - read_game_creator_agent_goal, - start_game_creator_agent_goal, - edit_game_creator_agent_goal, - pause_game_creator_agent_goal, - resume_game_creator_agent_goal, - clear_game_creator_agent_goal, - cancel_game_creator_agent_runtime_task, - retry_game_creator_agent_runtime_task, - confirm_retry_game_creator_agent_runtime_task, - confirm_game_creator_agent_runtime_task, - reject_game_creator_agent_runtime_task, - answer_game_creator_agent_runtime_user_input, - read_game_creator_agent_runtime, - read_game_creator_agent_runtimes, - resume_game_creator_agent_runtime_tasks, - confirm_resume_game_creator_agent_runtime_tasks, - schedule_game_creator_agent_ready_tasks, - check_game_creator_llm_config, - read_platform_account_session_state, - install_platform_account_session, - clear_platform_account_session, - read_game_creator_app_config, - write_game_creator_app_config, - select_game_creator_model, - discover_game_creator_llm_models, - upload_local_asset, - register_local_asset, - create_ui_design_resource, - update_local_project_resource_classification, - add_local_project_resource_tags, - derive_local_project_resource, - list_pending_local_project_resource_edits, - resume_local_project_resource_edit, - request_local_project_resource_edit_service_identity_confirmation, - confirm_local_project_resource_edit_service_identity, - archive_failed_local_project_resource_edit, - normalize_local_project_raster_resource, - import_canvas_asset, - import_canvas_export, - sync_canvas_project_assets, - import_ui_editor_assets, - prepare_ui_editor_project_fonts, - read_ui_editor_font_bytes, - check_ui_editor_font_glyph_coverage, - suggest_ui_design_semantic, - recognize_ui, - separate_ui, - inspect_separation_recovery, - finalize_separation, - discard_separation_recovery, - merge_ui, - bind_components, - load_ui_design_state, - save_ui_design_state, - generate_ui_design_code, - ensure_ui_design_resource_for_prototype, - generate_platform_art_asset, - generate_local_project_asset, - start_local_project_asset_generation, - list_local_project_asset_generations, - open_canvas_project, - run_limited_local_command, - append_local_permission_log, - list_local_project_files, - import_local_project_image_assets, - read_local_project_file, - read_local_project_image_preview, - save_local_project_asset_file, - read_local_project_text_preview, - read_local_project_structured_preview, - read_local_project_media_preview, - cancel_local_project_resource_preview_scope, - read_local_game_memory, - read_local_agent_memory, - write_local_agent_memory, - write_local_game_memory, - list_game_creator_agent_sessions, - create_game_creator_agent_session, - fork_game_creator_agent_session, - set_active_game_creator_agent_session, - archive_game_creator_agent_session, - read_local_conversation, - read_direct_project_conversation, - read_agent_runtime_error_detail, - list_game_creator_direct_active_turns, - subscribe_direct_project_thread, - consume_direct_project_thread, - read_direct_project_history_slice, - append_local_conversation_message, - append_direct_project_conversation_message, - build_local_project_index, - create_local_project_checkpoint, - export_local_project_package, - prepare_local_project_game_package, - upload_local_project_game_package, - list_local_project_export_packages, - diff_local_project_checkpoint, - restore_local_project_checkpoint, - read_project_permission_policy, - write_project_permission_policy, - open_game_creator_workspace_window, - open_game_creator_launcher_window, - start_local_game_preview, - activate_local_game_preview, - stop_local_game_preview, - stop_local_game_preview_if_matches, - get_local_game_preview_status, - read_local_project_resource_canvas_layout, - read_local_project_resource_graph, - update_local_project_resource_canvas_layout, - delete_local_project_asset, - read_local_project_asset_references, - rename_local_project_asset, - read_local_project_version_resource_replacement_candidates, - replace_local_project_version_resource, - get_local_game_project_revision, - get_local_game_manifest, - restart_agc_app, - append_application_log, - read_diagnostic_logs, - report_client_error, - get_pending_error_reports, - ack_error_reports, - sync_local_project_snapshot, - read_local_project_snapshot_state, - set_active_project_snapshot_workspace, - ]) - .build(tauri_context); - let app = match app { - Ok(app) => { - startup_log.append("startup.build.complete"); - app - } - Err(error) => { - if let Some(path) = startup_log.path() { - let details = sanitize_diagnostic_message(&error.to_string(), path.parent()); - startup_log.fail(&format!("startup.build.failed details={details}")); - } - app_log!("failed to build Genarrative AI Game Creator shell: {error}"); - std::process::exit(1); - } - }; - app.run(move |app_handle, event| { - if matches!(event, tauri::RunEvent::Ready) { - if let Some(directory) = game_creator_runtime_config_dir() { - analytics::gui::initialize( - app_handle.clone(), - directory, - app_handle.package_info().version.to_string(), - ); - } - } - if matches!(event, tauri::RunEvent::Exit) { - analytics::gui::exit(); - } - handle_game_creator_gui_run_event(&event); - }); + desktop::run(); } #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index b66b1de9d..85ab8fd04 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -23,6 +23,7 @@ mod version_resource_replacement; mod write_lock; pub(crate) use agent_db::*; +#[cfg(not(test))] pub(crate) use asset_export::*; pub(crate) use asset_rename::*; pub(crate) use bootstrap::*; @@ -34,6 +35,7 @@ pub(crate) use filesystem::*; pub(crate) use manifest::*; pub(crate) use memory::*; pub(crate) use model_usage::*; +#[cfg(not(test))] pub(crate) use resource_dependency_graph::*; pub(crate) use resource_editor::*; pub(crate) use resource_layout::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs index 43fe2df3e..7ebf7e7d1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs @@ -452,6 +452,7 @@ fn write_sparse_agent_db_with_complete_tail(root: &Path, length: u64) { } } +#[cfg(unix)] fn assert_agent_db_read_and_append_rejected(root: &Path) { let read_error = read_agent_db_records_bounded(root, 1024) .expect_err("linked Agent DB read must be rejected"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index f1097a975..e10cd50a3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -31,9 +31,9 @@ pub(crate) use endpoint::validate_windows_regular_file_handle; pub(crate) use endpoint::{external_agent_runner_enabled, external_agent_runner_is_server_process}; #[allow(unused_imports)] pub(crate) use protocol::{ExternalAgentRunnerStatus, EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION}; -pub(crate) use server::{ - bind_loopback_listener_with_linux_fallback, run_external_agent_runner_server, -}; +pub(crate) use server::bind_loopback_listener_with_linux_fallback; +#[cfg(not(test))] +pub(crate) use server::run_external_agent_runner_server; #[cfg(test)] pub(crate) fn simulate_external_agent_runner_cross_boot_owner_claim_for_test( diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index 4f372d1f3..cd0e920c7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -3,6 +3,7 @@ use super::{ }; use serde_json::{json, Value}; use sha2::{Digest as _, Sha256}; +#[cfg(target_os = "linux")] use std::collections::BTreeSet; use std::ffi::OsString; use std::fs; diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs index 45f9b0330..f986be1ec 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -454,11 +454,13 @@ fn apply_template_library_fixtures(entries: Vec) -> Vec usize { parse_synthetic_count(std::env::var(SYNTHETIC_COUNT_ENV).ok().as_deref()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 421d32605..cf32c528f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -1,7 +1,7 @@ use super::*; use base64::Engine as _; use serde_json::Value; -use sha2::{Digest as _, Sha256}; +use sha2::Sha256; use std::collections::{BTreeMap, BTreeSet}; use std::io::{self, Read, Write}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -392,64 +392,6 @@ pub(crate) fn freeze_test_root_goal_contract_at( .expect("freeze test root Goal Contract") } -pub(crate) fn pass_test_root_acceptance_graph_at( - root: &Path, - state: &AgentRuntimeState, -) -> AgentRuntimeAcceptanceGraphState { - let contract = read_game_creator_agent_runtime_goal_contract_at( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &state.run_id, - ) - .expect("read test root Goal Contract") - .unwrap_or_else(|| freeze_test_root_goal_contract_at(root, &state.run_id)); - let action = AgentRuntimeToolAction { - tool: "file.list".to_string(), - reason: Some("提供测试验收图的当前 revision 机器证据".to_string()), - input: serde_json::json!({ "path": "" }), - }; - let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task); - let action_id = - agent_runtime_tool_action_id(&state.run_id, u32::MAX - 1, 0, 0, &action_fingerprint); - let revision = read_game_creator_agent_runtime_project_revision(root) - .expect("read test project revision") - .revision; - append_agent_runtime_action_receipt_with_project_revision_before( - root, - state, - &action_id, - &action_fingerprint, - "file.list", - "auto", - None, - &AgentRuntimeToolObservation { - tool: "file.list".to_string(), - status: "ok".to_string(), - summary: "测试验收证据已持久化".to_string(), - detail: None, - }, - revision, - ) - .expect("append test acceptance receipt"); - update_game_creator_agent_runtime_acceptance_graph_at( - root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - &state.run_id, - &contract.contract_fingerprint, - &[AgentRuntimeAcceptanceEvaluationDraft { - criterion_id: "test-acceptance".to_string(), - status: "passed".to_string(), - evidence: vec![AgentRuntimeAcceptanceEvidenceRef { - agent_id: state.agent_id.clone(), - run_id: state.run_id.clone(), - action_id, - }], - summary: "测试声明的验收条件已满足".to_string(), - }], - ) - .expect("pass test root Acceptance Graph") -} - pub(crate) fn canonical_test_tempdir(prefix: &str) -> tempfile::TempDir { let temp_root = std::env::temp_dir() .canonicalize() @@ -647,62 +589,6 @@ pub(crate) async fn wait_for_agent_runtime_terminal_and_lane_release_async( } } -pub(crate) async fn wait_for_agent_runtime_manifest_projection_async( - root: &Path, - agent_id: &str, - run_id: &str, - runtime_status: &str, - phase: &str, - manifest_status: GameCreationAppTaskStatus, -) -> AgentRuntimeResult { - let deadline = Instant::now() + AGENT_RUNTIME_TERMINAL_WAIT_BUDGET; - let mut terminal = wait_for_agent_runtime_terminal_and_lane_release_async( - root, - agent_id, - run_id, - runtime_status, - phase, - ) - .await; - let mut stable_samples = 0_u8; - loop { - if let Ok(project_lock) = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "test.wait_runtime_manifest_projection", - ) { - let manifest = read_manifest_for_project(root) - .expect("read manifest while waiting for terminal projection"); - let projected = manifest - .tasks - .iter() - .find(|task| task.id == agent_id) - .is_some_and(|task| task.status == manifest_status); - let reread = read_game_creator_agent_runtime_at(root, agent_id) - .expect("reread runtime while waiting for terminal projection"); - drop(project_lock); - if projected - && reread.state.run_id == run_id - && reread.state.status == runtime_status - && reread.state.phase == phase - { - stable_samples = stable_samples.saturating_add(1); - terminal = reread; - if stable_samples >= 2 { - return terminal; - } - } else { - stable_samples = 0; - terminal = reread; - } - } - assert!( - Instant::now() < deadline, - "runtime terminal manifest projection did not settle for {agent_id}/{run_id}" - ); - tokio::time::sleep(Duration::from_millis(20)).await; - } -} - async fn wait_for_agent_runtime_lane_release_async( root: &Path, agent_id: &str, @@ -3548,10 +3434,6 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( base_url } -fn spawn_mock_external_canvas_api_server() -> String { - spawn_mock_external_canvas_api_server_with_capture(3, None) -} - fn spawn_mock_external_canvas_generation_api_server( request_sender: Option>, ) -> String { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs index 36e81afb5..6e0b772d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs @@ -14,13 +14,12 @@ pub(super) use super::super::{ append_auto_tool_action_audit_pair_for_test, assert_auto_tool_action_audit_pair, assert_pending_runtime_decision_revalidates_after_lock, assert_task_status, fake_llm_game_draft, final_tool_plan_response, mock_http_request_json, - native_agent_tool_plan_chat_response, observe_agent_runtime_agent_delegate, - pending_tool_action_for_test, persist_needs_reconciliation_runtime_for_test, - persist_project_verification_for_test, read_agent_db_records_for_test, - register_canvas_visual_asset_fixture, spawn_barrier_mock_llm_server, - spawn_interruptible_mock_llm_server_with_capture, spawn_mock_llm_raw_responses_with_capture, - spawn_mock_llm_server, spawn_mock_llm_server_responses, - spawn_mock_llm_server_responses_with_capture, + native_agent_tool_plan_chat_response, pending_tool_action_for_test, + persist_needs_reconciliation_runtime_for_test, persist_project_verification_for_test, + read_agent_db_records_for_test, register_canvas_visual_asset_fixture, + spawn_barrier_mock_llm_server, spawn_interruptible_mock_llm_server_with_capture, + spawn_mock_llm_raw_responses_with_capture, spawn_mock_llm_server, + spawn_mock_llm_server_responses, spawn_mock_llm_server_responses_with_capture, spawn_releasable_mock_llm_server_responses_with_capture, spawn_releasable_mock_llm_server_responses_with_capture_at, start_agent_runtime_steer_fixture, ui_prototype_assessment_fixture, unique_project_path, use_test_runtime_config_dir, @@ -38,10 +37,9 @@ pub(super) use crate::{ agent_runtime_background_worker_threads_for_test, agent_runtime_executable_tools, agent_runtime_read_only_delivery_completion_plan_update, agent_runtime_run_profile_identity_at, agent_runtime_tool_action_fingerprint, agent_runtime_tool_action_id, - agent_runtime_tool_allowed_for_agent, agent_runtime_tool_policy_snapshot_for_run_at, + agent_runtime_tool_policy_snapshot_for_run_at, agent_runtime_tool_requires_pending_revision_gate, - agent_runtime_tool_requires_repository_context_fingerprint_gate, - agent_runtime_verified_delivery_completion_plan_update, append_agent_db_record, + agent_runtime_tool_requires_repository_context_fingerprint_gate, append_agent_db_record, append_agent_db_terminal_observation_if_missing_for_action, append_agent_runtime_action_receipt, append_agent_runtime_tool_call_record, append_game_creator_agent_runtime_action_event, append_game_creator_agent_runtime_task, @@ -67,8 +65,8 @@ pub(super) use crate::{ game_creator_agent_runtime_tool_policy_rule_for_run, init_local_game_project_at, invalidate_agent_runtime_project_verification_after_preview_failure_at, native_runtime_function_name, observe_agent_runtime_action_history, - observe_agent_runtime_agent_message, observe_agent_runtime_agent_spawn_isolated, - plan_game_creation_agent_pass, prepare_agent_runtime_project_mutation_locked, + observe_agent_runtime_agent_message, plan_game_creation_agent_pass, + prepare_agent_runtime_project_mutation_locked, prepare_game_creator_agent_runtime_provider_action_batch, project_verification_completion_blocker_at, read_all_game_creator_agent_runtime_tasks, read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_context_bundle, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs index 2e7f6fe79..79d99c905 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs @@ -18,15 +18,18 @@ use super::identity_order_validation::{ parse_tool_plan_base_request_slot, request_slot_for_attempt, }; use super::ledger::response_for_persistence; +#[cfg(unix)] +use super::model::TOOL_PLAN_HANDOFF_RELATIVE_DIRECTORY; use super::model::{ INVALID_THINKING_CLOSE_MARKER, INVALID_THINKING_OPEN_MARKER, TOOL_PLAN_HANDOFF_ARGUMENTS_MAX_BYTES, TOOL_PLAN_HANDOFF_LABEL, TOOL_PLAN_HANDOFF_MAX_ENTRIES, - TOOL_PLAN_HANDOFF_MAX_TOOL_CALLS, TOOL_PLAN_HANDOFF_RELATIVE_DIRECTORY, - TOOL_PLAN_HANDOFF_REQUEST_RESERVE_BYTES, TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES, + TOOL_PLAN_HANDOFF_MAX_TOOL_CALLS, TOOL_PLAN_HANDOFF_REQUEST_RESERVE_BYTES, + TOOL_PLAN_HANDOFF_SIDECAR_MAX_BYTES, }; +#[cfg(unix)] +use super::storage_common::serialize_ledger_for_storage; use super::storage_common::{ - path_key, response_fingerprint, serialize_ledger_for_storage, tool_plan_handoff_path, - tool_plan_handoff_relative_path, + path_key, response_fingerprint, tool_plan_handoff_path, tool_plan_handoff_relative_path, }; #[cfg(unix)] use super::storage_unix::{ @@ -43,7 +46,9 @@ use super::*; use crate::agent::{ agent_runtime_json_sidecar_backup_path, write_agent_runtime_json_sidecar_with_max_bytes, }; -use crate::provider_retry::{self, AgentRuntimeProviderRetryIdentity}; +#[cfg(unix)] +use crate::provider_retry; +use crate::provider_retry::AgentRuntimeProviderRetryIdentity; #[test] fn tool_plan_handoff_classifies_windows_handle_install_failure_as_storage() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/mod.rs index d85432e07..2584812df 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/mod.rs @@ -5,13 +5,21 @@ pub mod separation; pub mod ui_design_suggestion; pub mod utils; +#[cfg(not(test))] +pub(crate) use binding::bind_components_impl; +pub(crate) use binding::bind_components_impl_with_provider; pub use binding::BindingDTO; -pub(crate) use binding::{bind_components_impl, bind_components_impl_with_provider}; +#[cfg(not(test))] +pub(crate) use merge::merge_ui_impl; +pub(crate) use merge::merge_ui_impl_with_provider; pub use merge::MergeDTO; -pub(crate) use merge::{merge_ui_impl, merge_ui_impl_with_provider}; +#[cfg(not(test))] +pub(crate) use recognition::recognize_ui_impl; +pub(crate) use recognition::recognize_ui_impl_with_provider; pub use recognition::RecognitionDTO; -pub(crate) use recognition::{recognize_ui_impl, recognize_ui_impl_with_provider}; +#[cfg(not(test))] pub(crate) use separation::separate_ui_impl; pub use separation::{SeparationDTO, SeparationRecoveryDTO}; +#[cfg(not(test))] pub(crate) use ui_design_suggestion::suggest_ui_design_semantic_impl; pub use ui_design_suggestion::UIDesignSuggestionTreeNode; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs index 5bdf90008..431818316 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs @@ -11,6 +11,7 @@ pub use persistence::*; pub use tree::*; pub use workflow::apply_batch_patch; pub use workflow::batch::{image_edit_dimension_for_area, next_image_batch}; +#[cfg(not(test))] pub(crate) use workflow::separate_ui_impl; #[cfg(test)] mod tests { diff --git a/apps/ai-game-creator-shell/src-tauri/src/windows.rs b/apps/ai-game-creator-shell/src-tauri/src/windows.rs index 4406f2e29..e64226359 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/windows.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/windows.rs @@ -146,43 +146,8 @@ fn percent_encode_query_value(value: &str) -> String { encoded } -#[tauri::command] -pub(crate) fn open_game_creator_workspace_window( - app: tauri::AppHandle, - window: tauri::Window, - project_path: String, -) -> Result<(), String> { - let project_path = validate_workspace_window_project_path(&project_path)?; - if let Some(existing) = app.get_webview_window("main") { - existing.close().map_err(|error| error.to_string())?; - } - tauri::WebviewWindowBuilder::new(&app, "main", workspace_window_url(project_path)) - .title(crate::game_creator_product_name(&app)) - .decorations(false) - .inner_size(1180.0, 820.0) - .min_inner_size(760.0, 560.0) - .build() - .map_err(|error| error.to_string())?; - window.close().map_err(|error| error.to_string())?; - Ok(()) -} - -#[tauri::command] -pub(crate) fn open_game_creator_launcher_window( - app: tauri::AppHandle, - window: tauri::Window, -) -> Result<(), String> { - if let Some(existing) = app.get_webview_window("launcher") { - existing.set_focus().map_err(|error| error.to_string())?; - } else { - tauri::WebviewWindowBuilder::new(&app, "launcher", launcher_window_url()) - .title(crate::game_creator_product_name(&app)) - .decorations(false) - .inner_size(820.0, 640.0) - .min_inner_size(720.0, 520.0) - .build() - .map_err(|error| error.to_string())?; - } - window.close().map_err(|error| error.to_string())?; - Ok(()) -} +// Webview 接入独立于受测的窗口 URL/路径校验及后台进程配置。 +#[cfg(not(test))] +mod desktop; +#[cfg(not(test))] +pub(crate) use desktop::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/windows/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/windows/desktop.rs new file mode 100644 index 000000000..2d4602d6e --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/windows/desktop.rs @@ -0,0 +1,42 @@ +use super::*; + +#[tauri::command] +pub(crate) fn open_game_creator_workspace_window( + app: tauri::AppHandle, + window: tauri::Window, + project_path: String, +) -> Result<(), String> { + let project_path = validate_workspace_window_project_path(&project_path)?; + if let Some(existing) = app.get_webview_window("main") { + existing.close().map_err(|error| error.to_string())?; + } + tauri::WebviewWindowBuilder::new(&app, "main", workspace_window_url(project_path)) + .title(crate::game_creator_product_name(&app)) + .decorations(false) + .inner_size(1180.0, 820.0) + .min_inner_size(760.0, 560.0) + .build() + .map_err(|error| error.to_string())?; + window.close().map_err(|error| error.to_string())?; + Ok(()) +} + +#[tauri::command] +pub(crate) fn open_game_creator_launcher_window( + app: tauri::AppHandle, + window: tauri::Window, +) -> Result<(), String> { + if let Some(existing) = app.get_webview_window("launcher") { + existing.set_focus().map_err(|error| error.to_string())?; + } else { + tauri::WebviewWindowBuilder::new(&app, "launcher", launcher_window_url()) + .title(crate::game_creator_product_name(&app)) + .decorations(false) + .inner_size(820.0, 640.0) + .min_inner_size(720.0, 520.0) + .build() + .map_err(|error| error.to_string())?; + } + window.close().map_err(|error| error.to_string())?; + Ok(()) +} diff --git a/apps/ai-game-creator-shell/tests/sessionPreview.test.ts b/apps/ai-game-creator-shell/tests/sessionPreview.test.ts index 90f8e6750..558a8a6a4 100644 --- a/apps/ai-game-creator-shell/tests/sessionPreview.test.ts +++ b/apps/ai-game-creator-shell/tests/sessionPreview.test.ts @@ -171,8 +171,8 @@ describe('进入项目时的预览活体核验', () => { }); describe('本地预览记录的生命周期(Rust 侧结构性守卫)', () => { - const mainSource = readFileSync( - repoPath('apps/ai-game-creator-shell/src-tauri/src/main.rs'), + const desktopSource = readFileSync( + repoPath('apps/ai-game-creator-shell/src-tauri/src/desktop.rs'), 'utf8', ); const previewSource = readFileSync( @@ -186,7 +186,7 @@ describe('本地预览记录的生命周期(Rust 侧结构性守卫)', () => // 打架),所以按本仓既有做法直接钉源码结构。 const exitBranch = /matches!\(event, tauri::RunEvent::Exit\)\s*\{([\s\S]*?)\n {8}\}/u.exec( - mainSource, + desktopSource, )?.[1] ?? ''; expect(exitBranch).toContain('preview::stop_local_game_preview_on_exit'); }); diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 6b9ba3efb..20d4e5796 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -55,6 +55,8 @@ Windows 下的移动壳 smoke 通过 Node 启动从当前 workspace 包解析出的 Expo/EAS CLI,不直接 `spawnSync('npm.cmd')`;保留原配置与导出断言。具体入口和警告清理边界见本地开发运维文档。 +AGC 的纯桌面/生产接入按模块排除测试编译,共享实现与受测入口保持可测试;拆分后同时检查普通目标和测试目标,并保留原有行为断言。命令注册与退出接线的源码检查跟随真实模块位置更新,不能因移动文件而漏检;不通过全局允许死代码或虚假调用消除告警。具体边界见开发运维文档“编译告警的保留边界与待优化项”。 + 提示词外置变更运行 `runtime_prompt_bundle_build` 与 `prompt_source_boundaries` 两个 Rust 集成测试,验证编译期文本、目录登记和源码边界;现有 `agc-rust-shard-1` 本地/CI 入口先执行这组检查,再运行分片单测。 提示词测试验证实际请求中的片段来源、动态参数和工具结构;措辞不作为逐字契约。已有行为测试覆盖的限制不再另设整段文案检查。Direct 回合测试复用生产的消息转换和文件投影函数,不维护仅供测试调用的回合编排副本。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index ac0f0cb97..23304b384 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -291,6 +291,9 @@ npm run check ### 编译告警的保留边界与待优化项 +- AGC 纯生产接入按模块限定 `cfg(not(test))`:桌面启动、Tauri 命令注册和没有测试消费者的命令接入集中到对应生产模块;共享业务实现、被测试直接调用的入口和现有断言仍参与测试编译。拆分不改变命令名、注册集合、权限校验或启动/退出顺序,也不新增 `allow(dead_code)`、虚假引用或仅为消警的测试。每批同时检查 AGC 普通目标和测试目标,源码结构检查跟随真实接线位置更新;不能用测试目标通过代替生产目标检查,也不以清零告警为由排除已有测试覆盖。 +- AGC 测试编译使用 `cargo check --locked --offline --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -p genarrative-ai-game-creator-shell --tests` 定向核对。GUI 主入口在测试构建中被排除,不能把测试目标的 `dead_code` 一律认定为废弃生产代码;仅生产入口使用的导入限定 `not(test)`,平台测试导入与对应 Unix/Linux 测试保持相同条件,失去调用方的测试 helper 和多余重导出直接删除。DirectHome 命令配置测试明确断言禁用 `shell_tool` 与 `unified_exec`;Windows 路径校验的测试分支不触发宿主提权修复,修复范围参数仅由生产分支消费。 +- 测试 helper 的条件要覆盖真实使用范围:Unity delivery 观测 helper 限于 Windows x86_64 的 `unity-editor-execute` 测试,Agent DB 链接安全断言与 app-server 工作区启动包装限于 Unix 测试,模板假数据的环境变量读取限于 `template-library-fixtures` feature。底层生产实现与共享测试逻辑继续保留。仅检查完成阻塞协议固定中文措辞、且失去测试标记的遗留函数直接删除;现役自由执行请求使用短任务协议,不向其测试搬入标准模式的完成阻塞提示词要求,标准模式的生产协议保持不变。 - AGC 默认 Windows dev 构建在 2026-09-23 清理后的 `cargo check --locked --offline --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -p genarrative-ai-game-creator-shell` 复核通过,剩余 3 条 AuthBridge 相关 Rust warning 和 5 条 ts-rs 提示。这是当次配置的检查结果,不代表后续提交、正式 editor features、其它平台、test targets、release 链接或安装包均无告警;后续按改动范围定向验证。 - AuthBridge 的 3 条 warning 暂缓处理,支持或退役 OAuth 的决策仍未确定。完整证据、保留边界和关闭条件以 [AGC 主方案“OAuth 认证路线的契约冲突与待决边界”](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#oauth-认证路线的契约冲突与待决边界)为准。 - ts-rs 12.0.1 的 5 条提示来自四个枚举(`DirectCodexUserItem`、`DirectCodexUserContentPart`、`DirectThreadItem`、`DirectThreadEvent`)的 `serde(deny_unknown_fields)` 和图片组件的 `serde(deserialize_with = "deserialize_fill_amount")`。已静态核对 TypeScript 类型形状及基础类型正确、Serde 运行时校验仍有效;暂保留提示,不引入依赖补丁、不改业务校验或全局屏蔽,待上游正式版本支持后再评估。