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 9046ab892..a643fc1d6 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 @@ -3,6 +3,7 @@ use super::*; mod canvas_generation; mod draft_validation; mod draft_writer; +mod external_generation_state; mod loop_orchestration; mod pass_artifacts; mod prompt_context; @@ -13,9 +14,22 @@ mod tests; mod trace; pub(in crate::agent) use canvas_generation::{ - commit_prepared_platform_art_asset_at, request_platform_art_asset_with_options_at, + commit_prepared_platform_art_asset_at, platform_art_generation_error_needs_reconciliation, + request_platform_art_asset_with_runtime_options_at, }; pub(in crate::agent) use draft_validation::validate_closed_game_script_blocks; +pub(in crate::agent) use external_generation_state::{ + game_creator_agent_runtime_external_generation_exists, + platform_art_generation_runtime_context_from_pending, + platform_art_generation_runtime_recovery_at, remove_platform_art_generation_runtime_state_at, + PlatformArtGenerationRuntimeContext, PlatformArtGenerationRuntimeRecovery, + PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION, +}; +#[cfg(test)] +pub(crate) use external_generation_state::{ + setup_platform_art_generation_runtime_accepted_for_recovery_test, + write_platform_art_generation_runtime_accepted_for_test, +}; pub(in crate::agent) use loop_orchestration::build_game_creator_agent_runtime_llm_client; pub(in crate::agent) use trace::game_creation_agent_group_id; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 74d514b2e..6f7f7d55f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -1,10 +1,24 @@ +use super::external_generation_state::{ + mark_platform_art_generation_runtime_accepted, + mark_platform_art_generation_runtime_legacy_completed, + platform_art_generation_external_configuration_fingerprint, + platform_art_generation_runtime_idempotency_key, platform_art_generation_runtime_legacy_result, + platform_art_generation_runtime_request_body_json, + platform_art_generation_runtime_request_snapshot, platform_art_generation_runtime_status, + platform_art_generation_runtime_submission_payload, + prepare_platform_art_generation_runtime_state, read_platform_art_generation_runtime_state, + validate_platform_art_generation_external_configuration, +}; use super::*; const EXTERNAL_GENERATION_POLL_TIMEOUT: Duration = Duration::from_secs(35 * 60); +const EXTERNAL_GENERATION_SUBMIT_TIMEOUT: Duration = EXTERNAL_GENERATION_POLL_TIMEOUT; const EXTERNAL_GENERATION_DEFAULT_POLL_AFTER_MS: u64 = 2_000; +const EXTERNAL_GENERATION_MIN_POLL_AFTER_MS: u64 = 250; const EXTERNAL_GENERATION_MAX_POLL_AFTER_MS: u64 = 5_000; -const EXTERNAL_GENERATION_SUBMIT_MAX_ATTEMPTS: usize = 3; -const EXTERNAL_GENERATION_SUBMIT_RETRY_BACKOFF_MS: u64 = 250; +const EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX: &str = "platform-generation-result-unknown:"; +const EXTERNAL_GENERATION_SOURCE_PRESERVED_PREFIX: &str = + "platform-generation-source-preserved-no-retry:"; pub(crate) fn project_canvas_asset_media_types(root: &Path) -> Vec { read_manifest_for_project(root) @@ -258,6 +272,96 @@ fn external_editor_response_data(payload: &serde_json::Value) -> &serde_json::Va payload.get("data").unwrap_or(payload) } +#[derive(Clone, Debug, Eq, PartialEq)] +enum ExternalGenerationInitialResponse { + LegacyCompleted(serde_json::Value), + AsyncSubmission(serde_json::Value), +} + +fn external_generation_result_has_download_reference(generated: &serde_json::Value) -> bool { + let has_download_reference = |value: &serde_json::Value| { + json_string_field(value, "objectKey").is_some() + || json_string_field(value, "imageSrc").is_some_and(|image_src| { + image_src.starts_with('/') + || image_src.starts_with("http://") + || image_src.starts_with("https://") + }) + }; + has_download_reference(generated) + || json_string_field(generated, "spritesheetImageSrc").is_some_and(|image_src| { + image_src.starts_with('/') + || image_src.starts_with("http://") + || image_src.starts_with("https://") + }) + || generated + .get("resource") + .is_some_and(has_download_reference) + || generated + .get("spritesheetResource") + .is_some_and(has_download_reference) +} + +fn external_generation_download_source( + generated: &serde_json::Value, + resource: &serde_json::Value, + is_canonical_art_spritesheet: bool, +) -> serde_json::Value { + if resource.is_object() { + return resource.clone(); + } + if is_canonical_art_spritesheet { + if let Some(image_src) = + json_string_field(generated, "spritesheetImageSrc").filter(|image_src| { + image_src.starts_with('/') + || image_src.starts_with("http://") + || image_src.starts_with("https://") + }) + { + return serde_json::json!({ "imageSrc": image_src }); + } + } + generated.clone() +} + +fn classify_external_generation_initial_response( + status: reqwest::StatusCode, + payload: &serde_json::Value, +) -> Result { + match status { + reqwest::StatusCode::OK => { + let generated = external_editor_response_data(payload); + if !external_generation_result_has_download_reference(generated) { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台旧同步图片生成响应缺少可下载结果" + )); + } + Ok(ExternalGenerationInitialResponse::LegacyCompleted( + generated.clone(), + )) + } + reqwest::StatusCode::ACCEPTED => { + let submission = external_editor_response_data(payload); + if json_string_field(submission, "operationId").is_none() { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台已接受图片生成请求,但响应缺少 operationId" + )); + } + Ok(ExternalGenerationInitialResponse::AsyncSubmission( + payload.clone(), + )) + } + _ => Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成返回未识别的成功状态 HTTP {}", + status.as_u16() + )), + } +} + +pub(in crate::agent) fn platform_art_generation_error_needs_reconciliation(error: &str) -> bool { + error.starts_with(EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX) + || error.starts_with(EXTERNAL_GENERATION_SOURCE_PRESERVED_PREFIX) +} + async fn external_editor_json_request( request: reqwest::RequestBuilder, action: &str, @@ -281,9 +385,19 @@ fn external_generation_poll_after_ms(payload: &serde_json::Value) -> u64 { .get("pollAfterMs") .and_then(serde_json::Value::as_u64) .unwrap_or(EXTERNAL_GENERATION_DEFAULT_POLL_AFTER_MS) + .max(EXTERNAL_GENERATION_MIN_POLL_AFTER_MS) .min(EXTERNAL_GENERATION_MAX_POLL_AFTER_MS) } +fn external_generation_submit_rejection_is_definitive(status: reqwest::StatusCode) -> bool { + matches!( + status, + reqwest::StatusCode::BAD_REQUEST + | reqwest::StatusCode::UNAUTHORIZED + | reqwest::StatusCode::FORBIDDEN + ) +} + async fn wait_for_external_generation_result( client: &reqwest::Client, api_base_url: &str, @@ -302,7 +416,7 @@ async fn wait_for_external_generation_result( loop { if started_at.elapsed() >= EXTERNAL_GENERATION_POLL_TIMEOUT { return Err(format!( - "平台图片生成任务仍在执行,已停止本地等待;operationId={operation_id}" + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务仍在执行,已停止本地等待;operationId={operation_id}" )); } if poll_after_ms > 0 { @@ -324,20 +438,30 @@ async fn wait_for_external_generation_result( poll_after_ms = EXTERNAL_GENERATION_DEFAULT_POLL_AFTER_MS; continue; } - Err(error) => return Err(format!("{error};operationId={operation_id}")), + Err(error) => { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error};operationId={operation_id}" + )); + } }; let generation = external_editor_response_data(&payload); match json_string_field(generation, "status").as_deref() { Some("completed") => { - return generation + let result = generation .get("result") .filter(|result| !result.is_null()) .cloned() .ok_or_else(|| { format!( - "平台图片生成任务已完成但响应缺少 result;operationId={operation_id}" + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务已完成但响应缺少 result;operationId={operation_id}" ) - }); + })?; + if !external_generation_result_has_download_reference(&result) { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务完成结果缺少可下载媒体;operationId={operation_id}" + )); + } + return Ok(result); } Some("failed") => { let error = json_string_field(generation, "error") @@ -352,12 +476,12 @@ async fn wait_for_external_generation_result( } Some(status) => { return Err(format!( - "平台图片生成任务返回未知状态 {status};operationId={operation_id}" + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务返回未知状态 {status};operationId={operation_id}" )); } None => { return Err(format!( - "平台图片生成任务状态响应缺少 status;operationId={operation_id}" + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务状态响应缺少 status;operationId={operation_id}" )); } } @@ -370,36 +494,21 @@ async fn submit_external_generation_request( endpoint: &str, api_key: &str, idempotency_key: &str, - request_body: &serde_json::Value, + request_body_json: &str, ) -> Result { - let mut last_error = None; - for attempt in 1..=EXTERNAL_GENERATION_SUBMIT_MAX_ATTEMPTS { - match client - .post(format!("{api_base_url}{endpoint}")) - .bearer_auth(api_key) - .header("Idempotency-Key", idempotency_key) - .json(request_body) - .send() - .await - { - Ok(response) => return Ok(response), - Err(error) => { - last_error = Some(error); - if attempt < EXTERNAL_GENERATION_SUBMIT_MAX_ATTEMPTS { - tokio::time::sleep(Duration::from_millis( - EXTERNAL_GENERATION_SUBMIT_RETRY_BACKOFF_MS * attempt as u64, - )) - .await; - } - } - } - } - Err(format!( - "请求平台图片生成失败:{}", - last_error - .map(|error| error.to_string()) - .unwrap_or_else(|| "未知传输错误".to_string()) - )) + client + .post(format!("{api_base_url}{endpoint}")) + .bearer_auth(api_key) + .header("Idempotency-Key", idempotency_key) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(request_body_json.to_string()) + .send() + .await + .map_err(|error| { + format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后未取得确定响应:{error}" + ) + }) } async fn prepare_external_canvas_generation_context( @@ -537,6 +646,7 @@ pub(in crate::agent) struct PreparedPlatformArtAssetGeneration { generated_prompt: Option, model: Option, provider: Option, + warning: Option, slice_warning: Option, generation_route: String, generation_kind: String, @@ -612,13 +722,26 @@ fn platform_art_generation_postprocess_failure(generated: &serde_json::Value) -> .get("warning") .filter(|warning| !warning.is_null())?; let code = json_string_field(warning, "code").unwrap_or_else(|| "unknown".to_string()); + if code != "postprocess-failed-source-preserved" { + return None; + } let reason = json_string_field(warning, "reason") .unwrap_or_else(|| "透明背景后处理未生成可用衍生物".to_string()); Some(format!( - "平台图片生成完成但透明后处理失败({code}):{reason};provider 源图已由服务端保留,不得登记为透明图集或自动重试" + "{EXTERNAL_GENERATION_SOURCE_PRESERVED_PREFIX} 平台图片生成完成但透明后处理失败({code}):{reason};provider 源图已由服务端保留,不得登记为透明图集或自动重试" )) } +fn platform_art_generation_warning(generated: &serde_json::Value) -> Option { + let warning = generated + .get("warning") + .filter(|warning| !warning.is_null())?; + let code = json_string_field(warning, "code").unwrap_or_else(|| "unknown".to_string()); + let reason = json_string_field(warning, "reason") + .unwrap_or_else(|| "平台生成结果包含非阻断降级".to_string()); + Some(format!("{code}:{reason}")) +} + pub(in crate::agent) async fn generate_platform_art_asset_with_options_at( root: &Path, prompt: &str, @@ -637,107 +760,300 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at( prompt: &str, briefs: &[AgentGroupBrief], options: &PlatformArtAssetGenerationOptions, +) -> Result { + request_platform_art_asset_with_runtime_options_at(root, prompt, briefs, options, None).await +} + +pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at( + root: &Path, + prompt: &str, + briefs: &[AgentGroupBrief], + options: &PlatformArtAssetGenerationOptions, + runtime_context: Option<&PlatformArtGenerationRuntimeContext>, ) -> Result { enforce_project_permission_policy(root, "canvas.asset_generate")?; - let prepared_output_path = prepare_platform_art_asset_output_path_for_mode( - root, - options.output_path.as_deref(), - options.replace_existing, - )?; + let persisted_runtime_state = runtime_context + .map(|context| { + read_platform_art_generation_runtime_state(root, context).map_err(|error| { + format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 无法读取或校验 External Editor 生成账本:{error}" + ) + }) + }) + .transpose()? + .flatten(); + if persisted_runtime_state + .as_ref() + .is_some_and(|state| platform_art_generation_runtime_status(state) == "prepared") + { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本停在 prepared,POST 是否已受理未知;禁止自动重放" + )); + } + // 首次提交必须在任何远端副作用前完成本地输出校验。accepted / legacy-completed + // 恢复则先读取已有持久结果,再校验本地安装目标,避免本地漂移阻断 GET-only 恢复。 + let prepared_output_path_before_submit = if persisted_runtime_state.is_none() { + Some(prepare_platform_art_asset_output_path_for_mode( + root, + options.output_path.as_deref(), + options.replace_existing, + )?) + } else { + None + }; + let api_base_url = resolve_canvas_sync_api_base_url(None)?; + let api_key = resolve_canvas_sync_api_key(None)?; + if let Some(state) = persisted_runtime_state.as_ref() { + validate_platform_art_generation_external_configuration(state, &api_base_url, &api_key) + .map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}"))?; + } + let external_configuration_fingerprint = + platform_art_generation_external_configuration_fingerprint(&api_base_url, &api_key); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(60)) + .build() + .map_err(|error| format!("创建 External Editor HTTP 客户端失败:{error}"))?; + let submit_client = reqwest::Client::builder() + .timeout(EXTERNAL_GENERATION_SUBMIT_TIMEOUT) + .build() + .map_err(|error| format!("创建 External Editor 生成提交客户端失败:{error}"))?; + let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); + let ( + generated, + canvas_context, + generation_route, + generation_kind, + is_canonical_art_spritesheet, + reference_resource_ids, + effective_generation_prompt, + ) = if let Some(state) = persisted_runtime_state { + let snapshot = platform_art_generation_runtime_request_snapshot(&state).map_err(|error| { + format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本请求快照无法恢复:{error}" + ) + })?; + let generated = if platform_art_generation_runtime_status(&state) == "accepted" { + let submission = platform_art_generation_runtime_submission_payload(&state) + .map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}"))?; + wait_for_external_generation_result(&client, &api_base_url, &api_key, &submission) + .await? + } else if platform_art_generation_runtime_status(&state) == "legacy-completed" { + platform_art_generation_runtime_legacy_result(&state) + .map_err(|error| format!("{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {error}"))? + } else { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本状态无法恢复" + )); + }; + let is_canonical_art_spritesheet = snapshot.generation_kind == "icon-spritesheet"; + ( + generated, + ExternalCanvasGenerationContext { + project_id: snapshot.canvas_project_id, + asset_folder_id: snapshot.asset_folder_id, + canvas_name: snapshot.canvas_name, + }, + snapshot.endpoint, + snapshot.generation_kind, + is_canonical_art_spritesheet, + snapshot.reference_resource_ids, + snapshot.generation_prompt, + ) + } else { + let canvas_context = + prepare_external_canvas_generation_context(root, &client, &api_base_url, &api_key) + .await?; + let generation_kind = match options.asset_kind.as_str() { + "ui-prototype" => "ui-design", + "art-spritesheet" => "icon-spritesheet", + _ => "spec", + }; + let is_canonical_art_spritesheet = options.asset_kind == "art-spritesheet"; + let canonical_reference = matches!( + options.asset_kind.as_str(), + "ui-prototype" | "art-spritesheet" + ) + .then(|| canonical_art_spec_reference_at(root, &canvas_context.project_id)) + .transpose()?; + let (endpoint, request_body) = if is_canonical_art_spritesheet { + let reference_image_src = canonical_reference + .as_deref() + .ok_or_else(|| "透明美术图集缺少规范图引用".to_string())?; + ( + "/api/external/v1/editor/icon-spritesheets/generations", + serde_json::json!({ + "referenceImageSrc": reference_image_src, + "iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt), + "screenColor": "auto", + "aspectRatio": options.aspect_ratio, + "imageSize": options.image_size, + "assetLabel": options.asset_label, + "projectId": canvas_context.project_id, + "assetFolderId": canvas_context.asset_folder_id, + "generationInputs": { + "artSpec": platform_art_asset_art_spec(options), + }, + "canvasCompletion": { + "title": options.asset_label, + "placeholder": external_canvas_placeholder(&options.aspect_ratio), + }, + }), + ) + } else { + ( + "/api/external/v1/editor/images/generations", + serde_json::json!({ + "prompt": generation_prompt, + "kind": generation_kind, + "aspectRatio": options.aspect_ratio, + "imageSize": options.image_size, + "assetKind": options.asset_kind, + "assetLabel": options.asset_label, + "projectId": canvas_context.project_id, + "assetFolderId": canvas_context.asset_folder_id, + "generationInputs": { + "artSpec": platform_art_asset_art_spec(options), + }, + "referenceImageSrcs": canonical_reference.clone().into_iter().collect::>(), + "canvasCompletion": { + "title": options.asset_label, + "placeholder": external_canvas_placeholder(&options.aspect_ratio), + }, + }), + ) + }; + let runtime_state = runtime_context + .map(|context| { + prepare_platform_art_generation_runtime_state( + root, + context, + endpoint, + &canvas_context.canvas_name, + &generation_prompt, + &request_body, + &external_configuration_fingerprint, + ) + .map_err(|error| { + format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 无法建立 External Editor 生成账本:{error}" + ) + }) + }) + .transpose()?; + if runtime_state.as_ref().is_some_and(|(_, created)| !created) { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本在首次恢复读取后发生变化;禁止自动 POST" + )); + } + let runtime_state = runtime_state.map(|(state, _)| state); + let idempotency_key = runtime_state + .as_ref() + .map(|state| platform_art_generation_runtime_idempotency_key(state).to_string()) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let request_body_json = runtime_state + .as_ref() + .map(|state| platform_art_generation_runtime_request_body_json(state).to_string()) + .unwrap_or_else(|| { + serde_json::to_string(&request_body) + .expect("External Editor request body Value must serialize") + }); + let response = submit_external_generation_request( + &submit_client, + &api_base_url, + endpoint, + &api_key, + &idempotency_key, + &request_body_json, + ) + .await?; + let status = response.status(); + if !status.is_success() { + if external_generation_submit_rejection_is_definitive(status) { + if let Some(context) = runtime_context { + remove_platform_art_generation_runtime_state_at( + root, + &context.agent_id, + &context.run_id, + )?; + } + return Err(format!("请求平台图片生成失败:HTTP {}", status.as_u16())); + } + if runtime_context.is_some() { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成提交返回 HTTP {},服务端是否已产生副作用未知", + status.as_u16() + )); + } + return Err(format!("请求平台图片生成失败:HTTP {}", status.as_u16())); + } + let submission_payload = response + .json::() + .await + .map_err(|error| { + format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 解析平台图片生成提交响应失败:{error}" + ) + })?; + let generated = match classify_external_generation_initial_response( + status, + &submission_payload, + )? { + ExternalGenerationInitialResponse::LegacyCompleted(generated) => { + if let Some(state) = runtime_state { + mark_platform_art_generation_runtime_legacy_completed(root, state, &generated) + .map_err(|error| { + format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 旧同步生成结果无法持久化:{error}" + ) + })?; + } + generated + } + ExternalGenerationInitialResponse::AsyncSubmission(submission) => { + let operation_id = + json_string_field(external_editor_response_data(&submission), "operationId") + .expect("202 submission was classified with operationId"); + let poll_after_ms = external_generation_poll_after_ms(&submission); + if let Some(state) = runtime_state { + mark_platform_art_generation_runtime_accepted( + root, + state, + &operation_id, + poll_after_ms, + ) + .map_err(|error| { + format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} operationId 无法持久化:{error}" + ) + })?; + } + wait_for_external_generation_result(&client, &api_base_url, &api_key, &submission) + .await? + } + }; + ( + generated, + canvas_context, + endpoint.to_string(), + generation_kind.to_string(), + is_canonical_art_spritesheet, + canonical_reference.into_iter().collect::>(), + generation_prompt.clone(), + ) + }; + let prepared_output_path = match prepared_output_path_before_submit { + Some(prepared) => prepared, + None => prepare_platform_art_asset_output_path_for_mode( + root, + options.output_path.as_deref(), + options.replace_existing, + )?, + }; let requested_output_path = prepared_output_path .as_ref() .map(|(local_path, _, _)| local_path.clone()); let replacement_fingerprint = prepared_output_path.and_then(|(_, _, replacement_fingerprint)| replacement_fingerprint); - let api_base_url = resolve_canvas_sync_api_base_url(None)?; - let api_key = resolve_canvas_sync_api_key(None)?; - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(60)) - .build() - .map_err(|error| format!("创建 External Editor HTTP 客户端失败:{error}"))?; - let canvas_context = - prepare_external_canvas_generation_context(root, &client, &api_base_url, &api_key).await?; - let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); - let generation_kind = match options.asset_kind.as_str() { - "ui-prototype" => "ui-design", - "art-spritesheet" => "icon-spritesheet", - _ => "spec", - }; - let is_canonical_art_spritesheet = options.asset_kind == "art-spritesheet"; - let canonical_reference = matches!( - options.asset_kind.as_str(), - "ui-prototype" | "art-spritesheet" - ) - .then(|| canonical_art_spec_reference_at(root, &canvas_context.project_id)) - .transpose()?; - let (endpoint, request_body) = if is_canonical_art_spritesheet { - let reference_image_src = canonical_reference - .as_deref() - .ok_or_else(|| "透明美术图集缺少规范图引用".to_string())?; - ( - "/api/external/v1/editor/icon-spritesheets/generations", - serde_json::json!({ - "referenceImageSrc": reference_image_src, - "iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt), - "screenColor": "auto", - "aspectRatio": options.aspect_ratio, - "imageSize": options.image_size, - "assetLabel": options.asset_label, - "projectId": canvas_context.project_id, - "assetFolderId": canvas_context.asset_folder_id, - "generationInputs": { - "artSpec": platform_art_asset_art_spec(options), - }, - "canvasCompletion": { - "title": options.asset_label, - "placeholder": external_canvas_placeholder(&options.aspect_ratio), - }, - }), - ) - } else { - ( - "/api/external/v1/editor/images/generations", - serde_json::json!({ - "prompt": generation_prompt, - "kind": generation_kind, - "aspectRatio": options.aspect_ratio, - "imageSize": options.image_size, - "assetKind": options.asset_kind, - "assetLabel": options.asset_label, - "projectId": canvas_context.project_id, - "assetFolderId": canvas_context.asset_folder_id, - "generationInputs": { - "artSpec": platform_art_asset_art_spec(options), - }, - "referenceImageSrcs": canonical_reference.clone().into_iter().collect::>(), - "canvasCompletion": { - "title": options.asset_label, - "placeholder": external_canvas_placeholder(&options.aspect_ratio), - }, - }), - ) - }; - let idempotency_key = uuid::Uuid::new_v4().to_string(); - let response = submit_external_generation_request( - &client, - &api_base_url, - endpoint, - &api_key, - &idempotency_key, - &request_body, - ) - .await?; - let status = response.status(); - if !status.is_success() { - return Err(format!("请求平台图片生成失败:HTTP {}", status.as_u16())); - } - let submission_payload = response - .json::() - .await - .map_err(|error| format!("解析平台图片生成提交响应失败:{error}"))?; - let generated = - wait_for_external_generation_result(&client, &api_base_url, &api_key, &submission_payload) - .await?; let generated = &generated; if let Some(error) = platform_art_generation_postprocess_failure(generated) { return Err(error); @@ -753,13 +1069,10 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at( } else { generated.get("asset").unwrap_or(&null) }; - let download_source = if resource.is_object() { - resource - } else { - generated - }; + let download_source = + external_generation_download_source(generated, resource, is_canonical_art_spritesheet); let download = - resolve_canvas_resource_download(&client, &api_base_url, &api_key, download_source) + resolve_canvas_resource_download(&client, &api_base_url, &api_key, &download_source) .await? .ok_or_else(|| "平台图片生成响应缺少可下载图片".to_string())?; if is_canonical_art_spritesheet && !platform_art_spritesheet_has_transparent_pixels(&download) { @@ -772,6 +1085,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at( .get("sliceWarning") .filter(|warning| !warning.is_null()) .and_then(|warning| json_string_field(warning, "reason")); + let warning = platform_art_generation_warning(generated); let resource_id = json_string_field(resource, "resourceId"); let task_id = json_string_field(generated, "taskId").or_else(|| json_string_field(resource, "taskId")); @@ -785,7 +1099,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at( .or_else(|| json_string_field(generated, "prompt")) .or_else(|| json_string_field(resource, "actualPrompt")) .or_else(|| json_string_field(resource, "prompt")) - .or_else(|| Some(generation_prompt.clone())); + .or_else(|| Some(effective_generation_prompt)); let model = json_string_field(generated, "model").or_else(|| json_string_field(resource, "model")); let provider = json_string_field(generated, "provider") @@ -808,10 +1122,11 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at( generated_prompt, model, provider, + warning, slice_warning, - generation_route: endpoint.to_string(), - generation_kind: generation_kind.to_string(), - reference_resource_ids: canonical_reference.into_iter().collect(), + generation_route, + generation_kind, + reference_resource_ids, extension, }) } @@ -884,6 +1199,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( generated_prompt, model, provider, + warning, slice_warning, generation_route, generation_kind, @@ -1071,6 +1387,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( "taskId": task_id.clone(), "model": model.clone(), "provider": provider.clone(), + "warning": warning.clone(), "assetFolderId": canvas_context.asset_folder_id, "canvasName": canvas_context.canvas_name, "sliceWarning": slice_warning.clone(), @@ -1085,6 +1402,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( asset_object_id, task_id, model, + warning, slice_warning, }) } @@ -1220,34 +1538,27 @@ mod canvas_generation_tests { } #[tokio::test] - async fn generation_submit_transport_retry_reuses_body_and_idempotency_key() { + async fn generation_submit_response_loss_is_not_retried() { let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind retry fixture"); let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); let (sender, receiver) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - for attempt in 0..2 { - let (mut stream, _) = listener.accept().expect("accept submit request"); - let request = read_test_http_request(&mut stream); - sender.send(request).expect("capture submit request"); - if attempt == 0 { - continue; + let (stop_sender, stop_receiver) = std::sync::mpsc::channel(); + listener + .set_nonblocking(true) + .expect("set retry fixture nonblocking"); + let server = std::thread::spawn(move || loop { + if stop_receiver.try_recv().is_ok() { + break; + } + match listener.accept() { + Ok((mut stream, _)) => { + let request = read_test_http_request(&mut stream); + sender.send(request).expect("capture submit request"); } - let body = serde_json::json!({ - "data": { - "operationId": "task-retry-1", - "status": "queued", - "pollAfterMs": 1 - } - }) - .to_string(); - let response = format!( - "HTTP/1.1 202 Accepted\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body, - ); - stream - .write_all(response.as_bytes()) - .expect("write accepted response"); + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept submit request: {error}"), } }); @@ -1257,35 +1568,785 @@ mod canvas_generation_tests { .expect("build retry client"); let idempotency_key = uuid::Uuid::new_v4().to_string(); let body = serde_json::json!({ "prompt": "stable retry" }); - let response = submit_external_generation_request( + let body_json = serde_json::to_string(&body).expect("serialize stable request body"); + let error = submit_external_generation_request( &client, &base_url, "/generation", "test-key", &idempotency_key, - &body, + &body_json, ) .await - .expect("transport retry should succeed"); - assert_eq!(response.status(), reqwest::StatusCode::ACCEPTED); + .expect_err("response loss must remain outcome unknown"); + assert!(platform_art_generation_error_needs_reconciliation(&error)); let first = receiver .recv_timeout(Duration::from_secs(2)) .expect("first request"); - let second = receiver - .recv_timeout(Duration::from_secs(2)) - .expect("retried request"); assert_eq!( test_request_header(&first, "idempotency-key"), &idempotency_key ); + stop_sender.send(()).expect("stop retry fixture"); + server.join().expect("join retry fixture"); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + } + + #[tokio::test] + async fn async_generation_202_polls_queued_running_and_completed_result() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind polling fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let (sender, receiver) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + for (status, result) in [ + ("queued", serde_json::Value::Null), + ("running", serde_json::Value::Null), + ( + "completed", + serde_json::json!({ + "imageSrc": "https://example.invalid/generated.png", + "resource": { + "resourceId": "resource-async", + "imageSrc": "https://example.invalid/generated.png" + } + }), + ), + ] { + let (mut stream, _) = listener.accept().expect("accept polling request"); + let request = read_test_http_request(&mut stream); + sender.send(request).expect("capture polling request"); + let body = serde_json::json!({ + "data": { + "operationId": "task-async", + "status": status, + "pollAfterMs": 0, + "result": result, + } + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body, + ); + stream + .write_all(response.as_bytes()) + .expect("write polling response"); + } + }); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .expect("build polling client"); + let result = wait_for_external_generation_result( + &client, + &base_url, + "test-api-key", + &serde_json::json!({ + "data": { + "operationId": "task-async", + "status": "queued", + "pollAfterMs": 0 + } + }), + ) + .await + .expect("poll completed result"); + assert_eq!(result["resource"]["resourceId"], "resource-async"); + for _ in 0..3 { + let request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("polling request"); + assert!(request.starts_with("GET /api/external/v1/generations/task-async ")); + } + assert!(receiver.recv_timeout(Duration::from_millis(100)).is_err()); + } + + #[tokio::test] + async fn accepted_runtime_generation_resumes_with_operation_get_only_and_prepares_download() { + let temporary = tempfile::tempdir().expect("create accepted recovery project"); + let root = temporary.path(); + init_local_game_project_at(root, "accepted-recovery", "当前本地项目名") + .expect("init accepted recovery project"); + write_project_permission_policy_at( + root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow accepted recovery generation"); + + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind accepted recovery fixture"); + listener + .set_nonblocking(true) + .expect("set accepted recovery fixture nonblocking"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let download_url = format!("{base_url}/download.png"); + let png = rgba_test_png(u8::MAX).bytes; + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let (stop_sender, stop_receiver) = std::sync::mpsc::channel(); + let server_download_url = download_url.clone(); + let server = std::thread::spawn(move || loop { + if stop_receiver.try_recv().is_ok() { + break; + } + let (mut stream, _) = match listener.accept() { + Ok(connection) => connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + continue; + } + Err(error) => panic!("accept accepted recovery request: {error}"), + }; + let request = read_test_http_request(&mut stream); + request_sender + .send(request.clone()) + .expect("capture accepted recovery request"); + if request.starts_with("GET /api/external/v1/generations/accepted-operation-1 ") { + let body = serde_json::json!({ + "data": { + "operationId": "accepted-operation-1", + "status": "completed", + "pollAfterMs": 0, + "result": { + "resource": { + "resourceId": "persisted-resource-1", + "projectId": "persisted-canvas-project", + "imageSrc": server_download_url + }, + "warning": { + "code": "unsupported-image-style", + "reason": "已保留可用原图" + }, + "sliceWarning": { + "reason": "测试切片告警" + } + } + } + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body, + ); + stream + .write_all(response.as_bytes()) + .expect("write accepted operation response"); + } else if request.starts_with("GET /download.png ") { + let headers = format!( + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + png.len() + ); + stream + .write_all(headers.as_bytes()) + .and_then(|_| stream.write_all(&png)) + .expect("write accepted recovery download"); + } else { + let body = b"unexpected request"; + let response = format!( + "HTTP/1.1 405 Method Not Allowed\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream + .write_all(response.as_bytes()) + .and_then(|_| stream.write_all(body)) + .expect("reject unexpected accepted recovery request"); + } + }); + let _config_guard = crate::tests::write_test_local_config( + serde_json::json!({ + "editorApi": { + "baseUrl": base_url, + "apiKey": "accepted-recovery-key" + } + }) + .to_string(), + ); + let runtime_context = PlatformArtGenerationRuntimeContext { + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "accepted-recovery-session".to_string(), + run_id: "accepted-recovery-run".to_string(), + source: "agent-ready-task-scheduler".to_string(), + action_id: "accepted-recovery-action".to_string(), + action_fingerprint: "accepted-recovery-fingerprint".to_string(), + }; + let request_body = serde_json::json!({ + "prompt": "持久化的原始生成正文", + "kind": "spec", + "projectId": "persisted-canvas-project", + "assetFolderId": "persisted-asset-folder", + "referenceImageSrcs": [] + }); + let configuration_fingerprint = platform_art_generation_external_configuration_fingerprint( + &base_url, + "accepted-recovery-key", + ); + let (state, created) = prepare_platform_art_generation_runtime_state( + root, + &runtime_context, + "/api/external/v1/editor/images/generations", + "持久化画布名", + "持久化的生成提示词", + &request_body, + &configuration_fingerprint, + ) + .expect("prepare accepted recovery ledger"); + assert!(created); + mark_platform_art_generation_runtime_accepted(root, state, "accepted-operation-1", 0) + .expect("mark accepted recovery ledger"); + + let prepared = request_platform_art_asset_with_runtime_options_at( + root, + "重启后已变化的输入不得覆盖账本", + &[], + &PlatformArtAssetGenerationOptions { + output_path: None, + asset_kind: "game-art".to_string(), + asset_label: "当前标签".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }, + Some(&runtime_context), + ) + .await + .expect("resume accepted generation through GET-only path"); + stop_sender + .send(()) + .expect("stop accepted recovery fixture"); + server.join().expect("join accepted recovery fixture"); + assert_eq!( - test_request_header(&second, "idempotency-key"), - &idempotency_key + prepared.canvas_context.project_id, + "persisted-canvas-project" ); assert_eq!( - first.split_once("\r\n\r\n").map(|(_, body)| body), - second.split_once("\r\n\r\n").map(|(_, body)| body), + prepared.canvas_context.asset_folder_id, + "persisted-asset-folder" + ); + assert_eq!(prepared.canvas_context.canvas_name, "持久化画布名"); + assert_eq!( + prepared.generated_prompt.as_deref(), + Some("持久化的生成提示词") + ); + assert_eq!( + prepared.warning.as_deref(), + Some("unsupported-image-style:已保留可用原图") + ); + assert_eq!(prepared.slice_warning.as_deref(), Some("测试切片告警")); + assert_eq!(prepared.download.media_type, "image/png"); + let requests = std::iter::from_fn(|| { + request_receiver + .recv_timeout(Duration::from_millis(100)) + .ok() + }) + .collect::>(); + assert_eq!( + requests.len(), + 2, + "accepted recovery must only poll and download" + ); + assert!(requests[0].starts_with("GET /api/external/v1/generations/accepted-operation-1 ")); + assert!(requests[1].starts_with("GET /download.png ")); + assert!(requests.iter().all(|request| !request.starts_with("POST "))); + assert!(requests + .iter() + .all(|request| !request.contains("/api/external/v1/editor/projects"))); + assert!(requests + .iter() + .all(|request| !request.contains("/api/external/v1/editor/assets/library"))); + } + + #[tokio::test] + async fn accepted_runtime_generation_rejects_external_configuration_drift_before_get() { + let temporary = tempfile::tempdir().expect("create configuration drift project"); + let root = temporary.path(); + init_local_game_project_at(root, "configuration-drift", "External Editor 配置漂移") + .expect("init project"); + write_project_permission_policy_at( + root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow generation recovery"); + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind configuration drift fixture"); + listener + .set_nonblocking(true) + .expect("set configuration drift fixture nonblocking"); + let current_base_url = + format!("http://{}", listener.local_addr().expect("fixture address")); + let _config_guard = crate::tests::write_test_local_config( + serde_json::json!({ + "editorApi": { + "baseUrl": current_base_url, + "apiKey": "current-editor-key" + } + }) + .to_string(), + ); + let runtime_context = PlatformArtGenerationRuntimeContext { + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "configuration-drift-session".to_string(), + run_id: "configuration-drift-run".to_string(), + source: "agent-ready-task-scheduler".to_string(), + action_id: "configuration-drift-action".to_string(), + action_fingerprint: "configuration-drift-fingerprint".to_string(), + }; + let stale_configuration_fingerprint = + platform_art_generation_external_configuration_fingerprint( + "https://old-editor.example.test", + "old-editor-key", + ); + let (state, _) = prepare_platform_art_generation_runtime_state( + root, + &runtime_context, + "/api/external/v1/editor/images/generations", + "旧画布", + "旧生成提示词", + &serde_json::json!({ + "prompt": "旧生成提示词", + "kind": "spec", + "projectId": "old-canvas-project", + "assetFolderId": "old-asset-folder", + "referenceImageSrcs": [] + }), + &stale_configuration_fingerprint, + ) + .expect("prepare stale configuration ledger"); + mark_platform_art_generation_runtime_accepted(root, state, "stale-operation", 0) + .expect("mark stale operation accepted"); + + let error = match request_platform_art_asset_with_runtime_options_at( + root, + "不得覆盖旧请求", + &[], + &PlatformArtAssetGenerationOptions::default(), + Some(&runtime_context), + ) + .await + { + Err(error) => error, + Ok(_) => panic!("configuration drift must block GET-only recovery"), + }; + assert!(error.contains("baseUrl/API Key"), "{error}"); + assert!(matches!( + listener.accept(), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock + )); + assert!(game_creator_agent_runtime_external_generation_exists( + root, + &runtime_context.agent_id, + &runtime_context.run_id + )); + } + + #[tokio::test] + async fn accepted_runtime_generation_keeps_ledger_until_failed_observation_is_persisted() { + let temporary = tempfile::tempdir().expect("create accepted failure project"); + let root = temporary.path(); + init_local_game_project_at(root, "accepted-failure", "External Editor 失败恢复") + .expect("init project"); + write_project_permission_policy_at( + root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow generation recovery"); + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind accepted failure fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept failed operation GET"); + let request = read_test_http_request(&mut stream); + request_sender + .send(request) + .expect("capture failed operation GET"); + let body = serde_json::json!({ + "data": { + "operationId": "failed-operation", + "status": "failed", + "error": "provider rejected request" + } + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body, + ); + stream + .write_all(response.as_bytes()) + .expect("write failed operation response"); + }); + let _config_guard = crate::tests::write_test_local_config( + serde_json::json!({ + "editorApi": { "baseUrl": base_url.clone(), "apiKey": "accepted-failure-key" } + }) + .to_string(), + ); + let runtime_context = PlatformArtGenerationRuntimeContext { + agent_id: "art-director".to_string(), + task_id: "art-director".to_string(), + session_id: "accepted-failure-session".to_string(), + run_id: "accepted-failure-run".to_string(), + source: "agent-ready-task-scheduler".to_string(), + action_id: "accepted-failure-action".to_string(), + action_fingerprint: "accepted-failure-fingerprint".to_string(), + }; + let configuration_fingerprint = platform_art_generation_external_configuration_fingerprint( + &base_url, + "accepted-failure-key", + ); + let (state, _) = prepare_platform_art_generation_runtime_state( + root, + &runtime_context, + "/api/external/v1/editor/images/generations", + "失败恢复画布", + "失败恢复提示词", + &serde_json::json!({ + "prompt": "失败恢复提示词", + "kind": "spec", + "projectId": "failed-canvas-project", + "assetFolderId": "failed-asset-folder", + "referenceImageSrcs": [] + }), + &configuration_fingerprint, + ) + .expect("prepare accepted failure ledger"); + mark_platform_art_generation_runtime_accepted(root, state, "failed-operation", 0) + .expect("mark failed operation accepted"); + + let error = match request_platform_art_asset_with_runtime_options_at( + root, + "不得重新提交", + &[], + &PlatformArtAssetGenerationOptions::default(), + Some(&runtime_context), + ) + .await + { + Err(error) => error, + Ok(_) => panic!("explicit operation failure must be returned"), + }; + server.join().expect("join accepted failure fixture"); + assert!(error.contains("平台图片生成任务失败"), "{error}"); + assert!(request_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("failed operation request") + .starts_with("GET /api/external/v1/generations/failed-operation ")); + assert!(game_creator_agent_runtime_external_generation_exists( + root, + &runtime_context.agent_id, + &runtime_context.run_id + )); + } + + #[tokio::test] + async fn recovery_scan_resumes_accepted_generation_on_default_worker_stack() { + let temporary = tempfile::tempdir().expect("create accepted scan project"); + let root = temporary.path(); + init_local_game_project_at(root, "accepted-scan", "恢复扫描测试") + .expect("init accepted scan project"); + write_project_permission_policy_at( + root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow accepted scan recovery"); + + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind accepted scan fixture"); + listener + .set_nonblocking(true) + .expect("set accepted scan fixture nonblocking"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let image_url = format!("{base_url}/artifact.png"); + let image_bytes = rgba_test_png(u8::MAX).bytes; + let (request_sender, request_receiver) = std::sync::mpsc::channel(); + let (stop_sender, stop_receiver) = std::sync::mpsc::channel(); + let server = std::thread::spawn(move || loop { + if stop_receiver.try_recv().is_ok() { + break; + } + let (mut stream, _) = match listener.accept() { + Ok(connection) => connection, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + continue; + } + Err(error) => panic!("accept accepted scan request: {error}"), + }; + let request = read_test_http_request(&mut stream); + request_sender + .send(request.clone()) + .expect("capture accepted scan request"); + if request.starts_with("GET /api/external/v1/generations/test-operation-id ") { + let body = serde_json::json!({ + "data": { + "operationId": "test-operation-id", + "status": "completed", + "result": { + "resource": { + "resourceId": "recovered-resource", + "projectId": "test-canvas-project", + "imageSrc": image_url + }, + "taskId": "recovered-task", + "model": "recovered-model" + } + } + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body, + ); + stream + .write_all(response.as_bytes()) + .expect("write accepted scan operation response"); + } else if request.starts_with("GET /artifact.png ") { + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + image_bytes.len(), + ); + stream + .write_all(response.as_bytes()) + .and_then(|_| stream.write_all(&image_bytes)) + .expect("write accepted scan image response"); + } else { + stream + .write_all(b"HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .expect("reject unexpected accepted scan request"); + } + }); + let llm_base_url = crate::tests::spawn_mock_llm_server_responses(vec![ + crate::tests::final_tool_plan_response("已恢复视觉规范图。"), + ]); + let _config_guard = crate::tests::write_test_local_config( + serde_json::json!({ + "editorApi": { "baseUrl": base_url, "apiKey": "recovery-editor-key" }, + "agentLlm": { + "art-director": { + "apiKey": "recovery-llm-key", + "baseUrl": llm_base_url, + "model": "recovery-model", + "apiKind": "openai_responses" + } + } + }) + .to_string(), + ); + let run_id = "accepted-recovery-scan-run"; + setup_platform_art_generation_runtime_accepted_for_recovery_test(root, run_id) + .expect("setup accepted generation recovery state"); + + resume_game_creator_agent_background_tasks_at(root) + .expect("resume accepted generation through recovery scan"); + let first = request_receiver + .recv_timeout(Duration::from_secs(3)) + .expect("operation GET after recovery scan"); + let second = request_receiver + .recv_timeout(Duration::from_secs(3)) + .expect("download GET after recovery scan"); + for _ in 0..100 { + if root.join("assets/art-spec.png").is_file() + && !game_creator_agent_runtime_external_generation_exists( + root, + "art-director", + run_id, + ) + { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + stop_sender.send(()).expect("stop accepted scan fixture"); + server.join().expect("join accepted scan fixture"); + + assert!(first.starts_with("GET /api/external/v1/generations/test-operation-id ")); + assert!(second.starts_with("GET /artifact.png ")); + assert!(request_receiver + .recv_timeout(Duration::from_millis(100)) + .is_err()); + assert!(root.join("assets/art-spec.png").is_file()); + assert!(!game_creator_agent_runtime_external_generation_exists( + root, + "art-director", + run_id + )); + } + + #[tokio::test] + async fn legacy_200_spritesheet_top_level_image_src_downloads_without_resource() { + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind legacy spritesheet fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let signed_url = format!("{base_url}/signed/legacy-spritesheet.png"); + let expected_bytes = rgba_test_png(0).bytes; + let response_bytes = expected_bytes.clone(); + let (sender, receiver) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + for index in 0..2 { + let (mut stream, _) = listener.accept().expect("accept legacy download request"); + let request = read_test_http_request(&mut stream); + sender + .send(request) + .expect("capture legacy download request"); + if index == 0 { + let body = serde_json::json!({ + "read": { + "signedUrl": signed_url, + "objectKey": "generated/legacy-spritesheet.png" + } + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body, + ); + stream + .write_all(response.as_bytes()) + .expect("write legacy read URL response"); + } else { + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + response_bytes.len(), + ); + stream + .write_all(response.as_bytes()) + .expect("write legacy image header"); + stream + .write_all(&response_bytes) + .expect("write legacy image body"); + } + } + }); + + let generated = serde_json::json!({ + "spritesheetImageSrc": "/generated/legacy-spritesheet.png", + "spritesheetResource": null + }); + let download_source = + external_generation_download_source(&generated, &serde_json::Value::Null, true); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .expect("build legacy download client"); + let download = + resolve_canvas_resource_download(&client, &base_url, "test-api-key", &download_source) + .await + .expect("resolve legacy spritesheet download") + .expect("legacy spritesheet download"); + assert_eq!(download.bytes, expected_bytes); + assert_eq!(download.media_type, "image/png"); + let read_url_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("legacy read URL request"); + assert!(read_url_request.starts_with( + "GET /api/external/v1/assets/read-url?legacyPublicPath=%2Fgenerated%2Flegacy-spritesheet.png " + )); + let signed_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("legacy signed image request"); + assert!(signed_request.starts_with("GET /signed/legacy-spritesheet.png ")); + } + + #[test] + fn generation_initial_response_uses_status_for_legacy_and_async_contracts() { + let legacy = serde_json::json!({ + "data": { + "resource": { "resourceId": "resource-legacy" }, + "imageSrc": "/generated/legacy.png" + } + }); + assert_eq!( + classify_external_generation_initial_response(reqwest::StatusCode::OK, &legacy) + .expect("legacy 200 response"), + ExternalGenerationInitialResponse::LegacyCompleted(legacy["data"].clone()) + ); + let legacy_top_level = serde_json::json!({ + "resource": { "resourceId": "resource-legacy-top-level" }, + "imageSrc": "/generated/legacy-top-level.png" + }); + assert_eq!( + classify_external_generation_initial_response( + reqwest::StatusCode::OK, + &legacy_top_level, + ) + .expect("legacy top-level 200 response"), + ExternalGenerationInitialResponse::LegacyCompleted(legacy_top_level) + ); + + let submission = serde_json::json!({ + "data": { + "operationId": "task-async", + "status": "queued", + "pollAfterMs": 1 + } + }); + assert_eq!( + classify_external_generation_initial_response( + reqwest::StatusCode::ACCEPTED, + &submission, + ) + .expect("async 202 response"), + ExternalGenerationInitialResponse::AsyncSubmission(submission) + ); + + let error = classify_external_generation_initial_response( + reqwest::StatusCode::ACCEPTED, + &serde_json::json!({"data": {"status": "queued"}}), + ) + .expect_err("accepted response without operationId must not fall back to legacy"); + assert!(platform_art_generation_error_needs_reconciliation(&error)); + + let error = classify_external_generation_initial_response( + reqwest::StatusCode::OK, + &serde_json::json!({"data": {}}), + ) + .expect_err("malformed legacy response must remain outcome unknown"); + assert!(platform_art_generation_error_needs_reconciliation(&error)); + + let legacy_spritesheet = serde_json::json!({ + "spritesheetImageSrc": "/generated/legacy-spritesheet.png", + "spritesheetResource": null, + "spritesheetAsset": null + }); + let generated = match classify_external_generation_initial_response( + reqwest::StatusCode::OK, + &legacy_spritesheet, + ) + .expect("legacy spritesheet 200 response") + { + ExternalGenerationInitialResponse::LegacyCompleted(generated) => generated, + ExternalGenerationInitialResponse::AsyncSubmission(_) => { + panic!("legacy 200 must not become an async submission") + } + }; + let download_source = + external_generation_download_source(&generated, &serde_json::Value::Null, true); + assert_eq!( + download_source, + serde_json::json!({"imageSrc": "/generated/legacy-spritesheet.png"}) ); } @@ -1330,6 +2391,72 @@ mod canvas_generation_tests { assert!(error.contains("抠图服务暂不可用,已保留源图。")); assert!(error.contains("不得登记为透明图集或自动重试")); assert!(!error.contains("不应覆盖通用告警")); + assert!(platform_art_generation_error_needs_reconciliation(&error)); + } + + #[test] + fn nonblocking_generation_warning_remains_a_completed_result() { + for code in [ + "dimension-restore-fallback", + "unsupported-image-style", + "multiple-generation-warnings", + ] { + let payload = serde_json::json!({ + "warning": { + "code": code, + "reason": "非阻断降级" + } + }); + assert!(platform_art_generation_postprocess_failure(&payload).is_none()); + assert_eq!( + platform_art_generation_warning(&payload).as_deref(), + Some(format!("{code}:非阻断降级").as_str()) + ); + } + } + + #[test] + fn generation_poll_interval_clamps_to_openapi_bounds() { + assert_eq!( + external_generation_poll_after_ms(&serde_json::json!({"pollAfterMs": 0})), + EXTERNAL_GENERATION_MIN_POLL_AFTER_MS + ); + assert_eq!( + external_generation_poll_after_ms(&serde_json::json!({"pollAfterMs": 60_000})), + EXTERNAL_GENERATION_MAX_POLL_AFTER_MS + ); + } + + #[test] + fn only_contractual_pre_enqueue_rejections_can_discard_generation_ledger() { + for status in [ + reqwest::StatusCode::BAD_REQUEST, + reqwest::StatusCode::UNAUTHORIZED, + reqwest::StatusCode::FORBIDDEN, + ] { + assert!(external_generation_submit_rejection_is_definitive(status)); + } + for status in [ + reqwest::StatusCode::REQUEST_TIMEOUT, + reqwest::StatusCode::CONFLICT, + reqwest::StatusCode::TOO_MANY_REQUESTS, + reqwest::StatusCode::BAD_GATEWAY, + ] { + assert!(!external_generation_submit_rejection_is_definitive(status)); + } + } + + #[test] + fn invalid_legacy_spritesheet_image_src_does_not_hide_object_key() { + let generated = serde_json::json!({ + "objectKey": "stable/generated/spritesheet.png", + "spritesheetImageSrc": "not-a-download-reference", + "spritesheetResource": null + }); + assert_eq!( + external_generation_download_source(&generated, &serde_json::Value::Null, true), + generated + ); } fn replacement_options() -> PlatformArtAssetGenerationOptions { @@ -1366,6 +2493,7 @@ mod canvas_generation_tests { generated_prompt: Some("原创替换图集".to_string()), model: Some("test-image-model".to_string()), provider: Some("test-provider".to_string()), + warning: None, slice_warning: None, generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(), generation_kind: "icon-spritesheet".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs new file mode 100644 index 000000000..16bcd0a62 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs @@ -0,0 +1,965 @@ +use super::*; + +pub(in crate::agent) const PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION: &str = + "agent-runtime-canvas-generation-request.v2"; +const PLATFORM_ART_GENERATION_RUNTIME_MAX_BYTES: usize = 256 * 1024; +const PLATFORM_ART_GENERATION_STATUS_PREPARED: &str = "prepared"; +const PLATFORM_ART_GENERATION_STATUS_ACCEPTED: &str = "accepted"; +const PLATFORM_ART_GENERATION_STATUS_LEGACY_COMPLETED: &str = "legacy-completed"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(in crate::agent) struct PlatformArtGenerationRuntimeContext { + pub(in crate::agent) agent_id: String, + pub(in crate::agent) task_id: String, + pub(in crate::agent) session_id: String, + pub(in crate::agent) run_id: String, + pub(in crate::agent) source: String, + pub(in crate::agent) action_id: String, + pub(in crate::agent) action_fingerprint: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(super) struct PlatformArtGenerationRuntimeState { + schema_version: String, + project_id: String, + agent_id: String, + task_id: String, + session_id: String, + run_id: String, + source: String, + action_id: String, + action_fingerprint: String, + external_configuration_fingerprint: String, + endpoint: String, + canvas_name: String, + generation_prompt: String, + request_body_sha256: String, + request_body_json: String, + idempotency_key: String, + status: String, + #[serde(default)] + operation_id: Option, + #[serde(default)] + poll_after_ms: Option, + #[serde(default)] + legacy_result: Option, + created_at: u64, + updated_at: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct PlatformArtGenerationRuntimeRequestSnapshot { + pub(super) endpoint: String, + pub(super) canvas_project_id: String, + pub(super) asset_folder_id: String, + pub(super) canvas_name: String, + pub(super) generation_prompt: String, + pub(super) generation_kind: String, + pub(super) reference_resource_ids: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(in crate::agent) enum PlatformArtGenerationRuntimeRecovery { + Missing, + PreparedResultUnknown, + ResumeAccepted, + ResumeLegacyCompleted, +} + +fn platform_art_generation_runtime_relative_path(agent_id: &str, run_id: &str) -> String { + format!( + ".agent/runtime/canvas-generation-requests/{}/{}.json", + agent_runtime_confirmation_path_component(agent_id, "agent"), + agent_runtime_confirmation_path_component(run_id, "run") + ) +} + +fn platform_art_generation_runtime_path( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + resolve_local_project_path( + root, + &platform_art_generation_runtime_relative_path(agent_id, run_id), + ) +} + +pub(in crate::agent) fn game_creator_agent_runtime_external_generation_exists( + root: &Path, + agent_id: &str, + run_id: &str, +) -> bool { + let Ok(path) = platform_art_generation_runtime_path(root, agent_id, run_id) else { + // Invalid control paths are reconciliation evidence, not proof that no durable + // generation exists. Fail closed so callers cannot downgrade to an ordinary retry. + return true; + }; + path.exists() || agent_runtime_json_sidecar_backup_path(&path).exists() +} + +pub(in crate::agent) fn platform_art_generation_runtime_context_from_pending( + pending: &AgentRuntimePendingToolAction, +) -> PlatformArtGenerationRuntimeContext { + PlatformArtGenerationRuntimeContext { + agent_id: pending.agent_id.clone(), + task_id: pending.task_id.clone(), + session_id: pending.session_id.clone(), + run_id: pending.run_id.clone(), + source: pending.source.clone(), + action_id: pending.action_id.clone(), + action_fingerprint: pending.action_fingerprint.clone(), + } +} + +fn request_body_json_and_sha256( + request_body: &serde_json::Value, +) -> Result<(String, String), String> { + let request_body_json = serde_json::to_string(request_body) + .map_err(|error| format!("序列化 External Editor 生成请求失败:{error}"))?; + let request_body_sha256 = format!("{:x}", Sha256::digest(request_body_json.as_bytes())); + Ok((request_body_json, request_body_sha256)) +} + +pub(super) fn platform_art_generation_external_configuration_fingerprint( + api_base_url: &str, + api_key: &str, +) -> String { + let normalized_base_url = api_base_url.trim().trim_end_matches('/'); + let api_key_sha256 = format!("{:x}", Sha256::digest(api_key.as_bytes())); + format!( + "{:x}", + Sha256::digest(format!("{normalized_base_url}\n{api_key_sha256}").as_bytes()) + ) +} + +pub(super) fn validate_platform_art_generation_external_configuration( + state: &PlatformArtGenerationRuntimeState, + api_base_url: &str, + api_key: &str, +) -> Result<(), String> { + let current = platform_art_generation_external_configuration_fingerprint(api_base_url, api_key); + if state.external_configuration_fingerprint != current { + return Err("External Editor 生成账本与当前 baseUrl/API Key 身份不一致".to_string()); + } + Ok(()) +} + +fn validate_platform_art_generation_runtime_identity( + root: &Path, + state: &PlatformArtGenerationRuntimeState, + context: &PlatformArtGenerationRuntimeContext, +) -> Result<(), String> { + if state.schema_version != PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION { + return Err(format!( + "不支持的 External Editor 生成账本版本:{}", + state.schema_version + )); + } + let project_id = game_creator_agent_runtime_context_project_id(root)?; + if state.project_id != project_id + || state.agent_id != context.agent_id + || state.task_id != context.task_id + || state.session_id != context.session_id + || state.run_id != context.run_id + || state.source != context.source + || state.action_id != context.action_id + || state.action_fingerprint != context.action_fingerprint + { + return Err("External Editor 生成账本与当前 pending action 身份不一致".to_string()); + } + if !matches!( + state.status.as_str(), + PLATFORM_ART_GENERATION_STATUS_PREPARED + | PLATFORM_ART_GENERATION_STATUS_ACCEPTED + | PLATFORM_ART_GENERATION_STATUS_LEGACY_COMPLETED + ) { + return Err("External Editor 生成账本状态无效".to_string()); + } + let request_body_sha256 = format!("{:x}", Sha256::digest(state.request_body_json.as_bytes())); + if state.request_body_sha256 != request_body_sha256 { + return Err("External Editor 生成账本请求正文指纹不匹配".to_string()); + } + platform_art_generation_runtime_request_snapshot(state)?; + if state.status == PLATFORM_ART_GENERATION_STATUS_ACCEPTED + && state.operation_id.as_deref().is_none_or(str::is_empty) + { + return Err("External Editor accepted 生成账本缺少 operationId".to_string()); + } + if state.status == PLATFORM_ART_GENERATION_STATUS_LEGACY_COMPLETED + && state.legacy_result.is_none() + { + return Err("External Editor 旧同步完成账本缺少 result".to_string()); + } + Ok(()) +} + +pub(super) fn read_platform_art_generation_runtime_state( + root: &Path, + context: &PlatformArtGenerationRuntimeContext, +) -> Result, String> { + let relative_path = + platform_art_generation_runtime_relative_path(&context.agent_id, &context.run_id); + let state = read_agent_runtime_json_sidecar_with_max_bytes( + root, + &relative_path, + "External Editor 生成账本", + PLATFORM_ART_GENERATION_RUNTIME_MAX_BYTES, + )?; + if let Some(state) = state.as_ref() { + validate_platform_art_generation_runtime_identity(root, state, context)?; + } + Ok(state) +} + +fn write_platform_art_generation_runtime_state( + root: &Path, + state: &PlatformArtGenerationRuntimeState, +) -> Result<(), String> { + let relative_path = + platform_art_generation_runtime_relative_path(&state.agent_id, &state.run_id); + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &relative_path, + "External Editor 生成账本", + state, + PLATFORM_ART_GENERATION_RUNTIME_MAX_BYTES, + )?; + let context = PlatformArtGenerationRuntimeContext { + agent_id: state.agent_id.clone(), + task_id: state.task_id.clone(), + session_id: state.session_id.clone(), + run_id: state.run_id.clone(), + source: state.source.clone(), + action_id: state.action_id.clone(), + action_fingerprint: state.action_fingerprint.clone(), + }; + let persisted = read_platform_art_generation_runtime_state(root, &context)? + .ok_or_else(|| "External Editor 生成账本写入后缺失".to_string())?; + if persisted != *state { + return Err("External Editor 生成账本写入后回读不一致".to_string()); + } + Ok(()) +} + +pub(super) fn prepare_platform_art_generation_runtime_state( + root: &Path, + context: &PlatformArtGenerationRuntimeContext, + endpoint: &str, + canvas_name: &str, + generation_prompt: &str, + request_body: &serde_json::Value, + external_configuration_fingerprint: &str, +) -> Result<(PlatformArtGenerationRuntimeState, bool), String> { + let (request_body_json, request_body_sha256) = request_body_json_and_sha256(request_body)?; + if let Some(existing) = read_platform_art_generation_runtime_state(root, context)? { + if existing.endpoint != endpoint + || existing.canvas_name != canvas_name + || existing.generation_prompt != generation_prompt + || existing.request_body_sha256 != request_body_sha256 + || existing.request_body_json != request_body_json + || existing.external_configuration_fingerprint != external_configuration_fingerprint + { + return Err("External Editor 生成账本请求与当前精确动作不一致".to_string()); + } + return Ok((existing, false)); + } + let now = unix_timestamp(); + let state = PlatformArtGenerationRuntimeState { + schema_version: PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION.to_string(), + project_id: game_creator_agent_runtime_context_project_id(root)?, + agent_id: context.agent_id.clone(), + task_id: context.task_id.clone(), + session_id: context.session_id.clone(), + run_id: context.run_id.clone(), + source: context.source.clone(), + action_id: context.action_id.clone(), + action_fingerprint: context.action_fingerprint.clone(), + external_configuration_fingerprint: external_configuration_fingerprint.to_string(), + endpoint: endpoint.to_string(), + canvas_name: canvas_name.to_string(), + generation_prompt: generation_prompt.to_string(), + request_body_sha256, + request_body_json, + idempotency_key: uuid::Uuid::new_v4().to_string(), + status: PLATFORM_ART_GENERATION_STATUS_PREPARED.to_string(), + operation_id: None, + poll_after_ms: None, + legacy_result: None, + created_at: now, + updated_at: now, + }; + write_platform_art_generation_runtime_state(root, &state)?; + Ok((state, true)) +} + +pub(super) fn platform_art_generation_runtime_request_snapshot( + state: &PlatformArtGenerationRuntimeState, +) -> Result { + let request_body = serde_json::from_str::(&state.request_body_json) + .map_err(|error| format!("External Editor 生成账本请求正文无法解析:{error}"))?; + let canvas_project_id = json_string_field(&request_body, "projectId") + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "External Editor 生成账本请求缺少 projectId".to_string())?; + let asset_folder_id = json_string_field(&request_body, "assetFolderId") + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "External Editor 生成账本请求缺少 assetFolderId".to_string())?; + if state.canvas_name.trim().is_empty() { + return Err("External Editor 生成账本缺少 canvasName".to_string()); + } + if state.generation_prompt.trim().is_empty() { + return Err("External Editor 生成账本缺少 generationPrompt".to_string()); + } + let (generation_kind, reference_resource_ids) = match state.endpoint.as_str() { + "/api/external/v1/editor/images/generations" => { + let generation_kind = json_string_field(&request_body, "kind") + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "External Editor 图片生成账本请求缺少 kind".to_string())?; + let reference_resource_ids = request_body + .get("referenceImageSrcs") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + "External Editor 图片生成账本请求缺少 referenceImageSrcs".to_string() + })? + .iter() + .map(|value| { + value + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| "External Editor 图片生成账本引用资源 ID 无效".to_string()) + }) + .collect::, _>>()?; + (generation_kind, reference_resource_ids) + } + "/api/external/v1/editor/icon-spritesheets/generations" => { + let reference_resource_id = json_string_field(&request_body, "referenceImageSrc") + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + "External Editor 图集生成账本请求缺少 referenceImageSrc".to_string() + })?; + ("icon-spritesheet".to_string(), vec![reference_resource_id]) + } + _ => return Err("External Editor 生成账本 endpoint 不受支持".to_string()), + }; + Ok(PlatformArtGenerationRuntimeRequestSnapshot { + endpoint: state.endpoint.clone(), + canvas_project_id, + asset_folder_id, + canvas_name: state.canvas_name.clone(), + generation_prompt: state.generation_prompt.clone(), + generation_kind, + reference_resource_ids, + }) +} + +pub(super) fn mark_platform_art_generation_runtime_accepted( + root: &Path, + mut state: PlatformArtGenerationRuntimeState, + operation_id: &str, + poll_after_ms: u64, +) -> Result { + if state.status != PLATFORM_ART_GENERATION_STATUS_PREPARED { + return Err("External Editor 生成账本只有 prepared 可升级为 accepted".to_string()); + } + state.status = PLATFORM_ART_GENERATION_STATUS_ACCEPTED.to_string(); + state.operation_id = Some(operation_id.to_string()); + state.poll_after_ms = Some(poll_after_ms); + state.updated_at = unix_timestamp(); + write_platform_art_generation_runtime_state(root, &state)?; + Ok(state) +} + +pub(super) fn mark_platform_art_generation_runtime_legacy_completed( + root: &Path, + mut state: PlatformArtGenerationRuntimeState, + result: &serde_json::Value, +) -> Result { + if state.status != PLATFORM_ART_GENERATION_STATUS_PREPARED { + return Err("External Editor 旧同步完成账本必须来自 prepared".to_string()); + } + let durable_result = durable_legacy_generation_result(result)?; + state.status = PLATFORM_ART_GENERATION_STATUS_LEGACY_COMPLETED.to_string(); + state.legacy_result = Some(durable_result); + state.updated_at = unix_timestamp(); + write_platform_art_generation_runtime_state(root, &state)?; + Ok(state) +} + +fn safe_legacy_media_reference(value: &str) -> Option { + let value = value.trim(); + (value.starts_with('/') && !value.contains(['?', '#'])).then(|| value.to_string()) +} + +fn safe_legacy_object_key(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty() + && !value.starts_with("http://") + && !value.starts_with("https://") + && !value.contains(['?', '#'])) + .then(|| value.to_string()) +} + +fn copy_legacy_string_field( + source: &serde_json::Value, + target: &mut serde_json::Map, + field: &str, +) { + if let Some(value) = json_string_field(source, field) { + target.insert(field.to_string(), serde_json::Value::String(value)); + } +} + +fn durable_legacy_generation_object( + source: &serde_json::Value, +) -> serde_json::Map { + let mut target = serde_json::Map::new(); + for field in [ + "resourceId", + "projectId", + "taskId", + "assetObjectId", + "actualPrompt", + "prompt", + "model", + "provider", + ] { + copy_legacy_string_field(source, &mut target, field); + } + for field in ["objectKey", "spritesheetObjectKey"] { + if let Some(value) = + json_string_field(source, field).and_then(|value| safe_legacy_object_key(&value)) + { + target.insert(field.to_string(), serde_json::Value::String(value)); + } + } + for field in ["imageSrc", "spritesheetImageSrc"] { + if let Some(value) = + json_string_field(source, field).and_then(|value| safe_legacy_media_reference(&value)) + { + target.insert(field.to_string(), serde_json::Value::String(value)); + } + } + target +} + +fn durable_legacy_generation_result( + result: &serde_json::Value, +) -> Result { + let mut durable = durable_legacy_generation_object(result); + for field in [ + "resource", + "spritesheetResource", + "asset", + "spritesheetAsset", + ] { + if result.get(field).is_some_and(serde_json::Value::is_object) { + let nested = durable_legacy_generation_object(&result[field]); + if !nested.is_empty() { + durable.insert(field.to_string(), serde_json::Value::Object(nested)); + } + } + } + for field in ["warning", "sliceWarning"] { + if let Some(value) = result.get(field).filter(|value| value.is_object()) { + let mut warning = serde_json::Map::new(); + for key in ["code", "reason"] { + copy_legacy_string_field(value, &mut warning, key); + } + if !warning.is_empty() { + durable.insert(field.to_string(), serde_json::Value::Object(warning)); + } + } + } + let durable = serde_json::Value::Object(durable); + let has_safe_download = |value: &serde_json::Value| { + json_string_field(value, "objectKey").is_some() + || json_string_field(value, "spritesheetObjectKey").is_some() + || json_string_field(value, "imageSrc").is_some() + || json_string_field(value, "spritesheetImageSrc").is_some() + }; + if !has_safe_download(&durable) + && !durable.get("resource").is_some_and(has_safe_download) + && !durable + .get("spritesheetResource") + .is_some_and(has_safe_download) + { + return Err( + "External Editor 旧同步结果缺少可安全持久化的 objectKey 或相对媒体路径".to_string(), + ); + } + Ok(durable) +} + +pub(super) fn platform_art_generation_runtime_submission_payload( + state: &PlatformArtGenerationRuntimeState, +) -> Result { + match state.status.as_str() { + PLATFORM_ART_GENERATION_STATUS_ACCEPTED => Ok(serde_json::json!({ + "operationId": state.operation_id, + "status": "running", + "pollAfterMs": state.poll_after_ms.unwrap_or(2_000), + })), + _ => Err("External Editor 生成账本尚未 accepted,不能恢复轮询".to_string()), + } +} + +pub(super) fn platform_art_generation_runtime_legacy_result( + state: &PlatformArtGenerationRuntimeState, +) -> Result { + if state.status != PLATFORM_ART_GENERATION_STATUS_LEGACY_COMPLETED { + return Err("External Editor 生成账本不是旧同步完成状态".to_string()); + } + state + .legacy_result + .clone() + .ok_or_else(|| "External Editor 旧同步完成账本缺少 result".to_string()) +} + +pub(super) fn platform_art_generation_runtime_idempotency_key( + state: &PlatformArtGenerationRuntimeState, +) -> &str { + &state.idempotency_key +} + +pub(super) fn platform_art_generation_runtime_request_body_json( + state: &PlatformArtGenerationRuntimeState, +) -> &str { + &state.request_body_json +} + +pub(super) fn platform_art_generation_runtime_status( + state: &PlatformArtGenerationRuntimeState, +) -> &str { + &state.status +} + +pub(in crate::agent) fn platform_art_generation_runtime_recovery_at( + root: &Path, + pending: &AgentRuntimePendingToolAction, +) -> Result { + if pending.action.tool != "canvas.asset_generate" { + return Ok(PlatformArtGenerationRuntimeRecovery::Missing); + } + let context = platform_art_generation_runtime_context_from_pending(pending); + let Some(state) = read_platform_art_generation_runtime_state(root, &context)? else { + return Ok(PlatformArtGenerationRuntimeRecovery::Missing); + }; + Ok(match state.status.as_str() { + PLATFORM_ART_GENERATION_STATUS_PREPARED => { + PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown + } + PLATFORM_ART_GENERATION_STATUS_ACCEPTED => { + PlatformArtGenerationRuntimeRecovery::ResumeAccepted + } + PLATFORM_ART_GENERATION_STATUS_LEGACY_COMPLETED => { + PlatformArtGenerationRuntimeRecovery::ResumeLegacyCompleted + } + _ => unreachable!("validated External Editor generation state status"), + }) +} + +pub(in crate::agent) fn remove_platform_art_generation_runtime_state_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result<(), String> { + let path = platform_art_generation_runtime_path(root, agent_id, run_id)?; + let backup_path = agent_runtime_json_sidecar_backup_path(&path); + remove_agent_runtime_json_sidecar_backup(&backup_path, "External Editor 生成账本")?; + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err("External Editor 生成账本必须是普通文件".to_string()) + } + Ok(_) => { + fs::remove_file(&path).map_err(|error| { + format!( + "删除 External Editor 生成账本失败:{}: {error}", + path.display() + ) + })?; + sync_agent_runtime_sidecar_parent(&path, "External Editor 生成账本") + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "读取 External Editor 生成账本元数据失败:{}: {error}", + path.display() + )), + } +} + +#[cfg(test)] +pub(crate) fn write_platform_art_generation_runtime_accepted_for_test( + root: &Path, + pending: &AgentRuntimePendingToolAction, +) -> Result<(), String> { + let context = platform_art_generation_runtime_context_from_pending(pending); + let api_base_url = + resolve_canvas_sync_api_base_url(None).unwrap_or_else(|_| "http://127.0.0.1:1".to_string()); + let api_key = resolve_canvas_sync_api_key(None).unwrap_or_else(|_| "test-api-key".to_string()); + let external_configuration_fingerprint = + platform_art_generation_external_configuration_fingerprint(&api_base_url, &api_key); + let (state, created) = prepare_platform_art_generation_runtime_state( + root, + &context, + "/api/external/v1/editor/images/generations", + "durable-test-canvas", + "durable test generation", + &serde_json::json!({ + "prompt": "durable test generation", + "kind": "spec", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "referenceImageSrcs": [] + }), + &external_configuration_fingerprint, + )?; + if !created { + return Err("External Editor 测试账本已存在".to_string()); + } + mark_platform_art_generation_runtime_accepted(root, state, "test-operation-id", 1_500)?; + Ok(()) +} + +#[cfg(test)] +pub(crate) fn setup_platform_art_generation_runtime_accepted_for_recovery_test( + root: &Path, + run_id: &str, +) -> Result { + let mut runtime = start_game_creator_agent_runtime_task_at( + root, + "art-director", + "恢复已受理视觉规范图", + run_id, + "agent-ready-task-scheduler", + "等待外部生成", + vec!["恢复外部生成".to_string()], + )?; + runtime.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "canvas.asset_generate".to_string(), + reason: Some("恢复已受理视觉规范图".to_string()), + input: serde_json::json!({ + "prompt": "恢复已受理视觉规范图", + "outputPath": "assets/art-spec.png" + }), + }; + let plan = AgentRuntimeToolPlan { + thinking_summary: "恢复平台生成".to_string(), + plan_update: None, + plan: vec!["恢复外部生成".to_string()], + actions: vec![action.clone()], + response: String::new(), + }; + let revision = read_game_creator_agent_runtime_project_revision(root)?; + let repository_fingerprint = build_repository_startup_context_at(root)?.fingerprint; + let pending = build_game_creator_agent_runtime_pending_tool_action( + root, + &runtime, + &runtime.current_task, + &plan, + &[], + &revision, + &repository_fingerprint, + &action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + )?; + write_game_creator_agent_runtime_pending_tool_action(root, &pending)?; + write_platform_art_generation_runtime_accepted_for_test(root, &pending)?; + runtime.pending_tool_action = Some(pending.summary()); + runtime.status = "running".to_string(); + runtime.phase = "action".to_string(); + runtime.current_action = "等待已受理生成".to_string(); + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, &runtime)?; + write_game_creator_agent_runtime_state(root, &runtime)?; + Ok(pending) +} + +#[cfg(test)] +mod external_generation_state_tests { + use super::*; + + fn pending_canvas_generation(root: &Path) -> AgentRuntimePendingToolAction { + let mut runtime = start_game_creator_agent_runtime_task_at( + root, + "art-director", + "生成视觉规范图", + "external-generation-ledger-run", + "agent-ready-task-scheduler", + "准备生成视觉规范图", + vec!["生成视觉规范图".to_string()], + ) + .expect("start runtime"); + runtime.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "canvas.asset_generate".to_string(), + reason: Some("生成统一视觉规范".to_string()), + input: serde_json::json!({ + "prompt": "生成统一视觉规范图", + "outputPath": "assets/art-spec.png" + }), + }; + let plan = AgentRuntimeToolPlan { + thinking_summary: "准备生成".to_string(), + plan_update: None, + plan: vec!["生成视觉规范图".to_string()], + actions: vec![action.clone()], + response: String::new(), + }; + let revision = + read_game_creator_agent_runtime_project_revision(root).expect("read project revision"); + let repository_fingerprint = build_repository_startup_context_at(root) + .expect("repository context") + .fingerprint; + build_game_creator_agent_runtime_pending_tool_action( + root, + &runtime, + &runtime.current_task, + &plan, + &[], + &revision, + &repository_fingerprint, + &action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ) + .expect("build pending action") + } + + #[test] + fn prepared_generation_state_reuses_identity_and_only_accepted_can_resume() { + let temporary = tempfile::tempdir().expect("create generation ledger project"); + let root = temporary.path(); + init_local_game_project_at(root, "generation-ledger", "生成账本测试") + .expect("init project"); + let pending = pending_canvas_generation(root); + let context = platform_art_generation_runtime_context_from_pending(&pending); + let endpoint = "/api/external/v1/editor/images/generations"; + let request_body = serde_json::json!({ + "prompt": "生成统一视觉规范图", + "kind": "spec", + "projectId": "canvas-project", + "assetFolderId": "asset-folder", + "referenceImageSrcs": [] + }); + let configuration_fingerprint = platform_art_generation_external_configuration_fingerprint( + "https://editor.example.test", + "test-api-key", + ); + + let (prepared, created) = prepare_platform_art_generation_runtime_state( + root, + &context, + endpoint, + "generation-ledger-canvas", + "生成统一视觉规范图", + &request_body, + &configuration_fingerprint, + ) + .expect("prepare generation ledger"); + assert!(created); + validate_platform_art_generation_external_configuration( + &prepared, + "https://editor.example.test/", + "test-api-key", + ) + .expect("matching External Editor configuration"); + assert!(validate_platform_art_generation_external_configuration( + &prepared, + "https://other-editor.example.test", + "test-api-key", + ) + .is_err()); + assert!(validate_platform_art_generation_external_configuration( + &prepared, + "https://editor.example.test", + "different-api-key", + ) + .is_err()); + assert_eq!( + platform_art_generation_runtime_recovery_at(root, &pending) + .expect("read prepared recovery"), + PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown + ); + let stable_key = prepared.idempotency_key.clone(); + let (reloaded, created_again) = prepare_platform_art_generation_runtime_state( + root, + &context, + endpoint, + "generation-ledger-canvas", + "生成统一视觉规范图", + &request_body, + &configuration_fingerprint, + ) + .expect("reload generation ledger"); + assert!(!created_again); + assert_eq!(reloaded.idempotency_key, stable_key); + assert_eq!( + serde_json::from_str::(&reloaded.request_body_json) + .expect("parse persisted exact request body"), + request_body + ); + + let accepted = mark_platform_art_generation_runtime_accepted( + root, + reloaded, + "operation-durable-1", + 1_500, + ) + .expect("persist accepted operation"); + assert_eq!( + accepted.operation_id.as_deref(), + Some("operation-durable-1") + ); + assert_eq!( + platform_art_generation_runtime_recovery_at(root, &pending) + .expect("read accepted recovery"), + PlatformArtGenerationRuntimeRecovery::ResumeAccepted + ); + assert!(prepare_platform_art_generation_runtime_state( + root, + &context, + endpoint, + "generation-ledger-canvas", + "生成统一视觉规范图", + &serde_json::json!({"prompt": "different request"}), + &configuration_fingerprint, + ) + .is_err()); + + remove_platform_art_generation_runtime_state_at(root, &pending.agent_id, &pending.run_id) + .expect("remove generation ledger"); + assert!(!game_creator_agent_runtime_external_generation_exists( + root, + &pending.agent_id, + &pending.run_id + )); + } + + #[test] + fn legacy_completed_generation_persists_only_allowlisted_safe_download_fields() { + let temporary = tempfile::tempdir().expect("create legacy generation ledger project"); + let root = temporary.path(); + init_local_game_project_at(root, "legacy-generation-ledger", "旧同步生成账本测试") + .expect("init project"); + let pending = pending_canvas_generation(root); + let context = platform_art_generation_runtime_context_from_pending(&pending); + let fingerprint = platform_art_generation_external_configuration_fingerprint( + "https://editor.example.test", + "test-api-key", + ); + let request_body = serde_json::json!({ + "prompt": "生成统一视觉规范图", + "kind": "spec", + "projectId": "canvas-project", + "assetFolderId": "asset-folder", + "referenceImageSrcs": [] + }); + let (state, _) = prepare_platform_art_generation_runtime_state( + root, + &context, + "/api/external/v1/editor/images/generations", + "legacy-generation-canvas", + "生成统一视觉规范图", + &request_body, + &fingerprint, + ) + .expect("prepare legacy generation ledger"); + let completed = mark_platform_art_generation_runtime_legacy_completed( + root, + state, + &serde_json::json!({ + "resource": { + "resourceId": "legacy-resource", + "objectKey": "generated/legacy.png", + "imageSrc": "https://signed.example.test/legacy.png?token=secret" + }, + "warning": { "code": "source-only", "reason": "保留原图", "secret": "drop" }, + "unknownSensitiveField": "drop-me" + }), + ) + .expect("persist allowlisted legacy result"); + let durable = platform_art_generation_runtime_legacy_result(&completed) + .expect("read durable legacy result"); + assert_eq!(durable["resource"]["resourceId"], "legacy-resource"); + assert_eq!(durable["resource"]["objectKey"], "generated/legacy.png"); + assert!(durable["resource"].get("imageSrc").is_none()); + assert!(durable.get("unknownSensitiveField").is_none()); + assert!(durable["warning"].get("secret").is_none()); + + remove_platform_art_generation_runtime_state_at(root, &pending.agent_id, &pending.run_id) + .expect("remove completed legacy ledger"); + let (unsafe_state, _) = prepare_platform_art_generation_runtime_state( + root, + &context, + "/api/external/v1/editor/images/generations", + "legacy-generation-canvas", + "生成统一视觉规范图", + &request_body, + &fingerprint, + ) + .expect("prepare unsafe legacy generation ledger"); + assert!(mark_platform_art_generation_runtime_legacy_completed( + root, + unsafe_state, + &serde_json::json!({ + "imageSrc": "https://signed.example.test/legacy.png?token=secret" + }), + ) + .is_err()); + assert_eq!( + platform_art_generation_runtime_recovery_at(root, &pending) + .expect("read prepared unsafe legacy recovery"), + PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown + ); + } + + #[cfg(unix)] + #[test] + fn generation_ledger_removal_rejects_symlinked_parent_directory() { + use std::os::unix::fs::symlink; + + let project = tempfile::tempdir().expect("create symlink removal project"); + let root = project.path(); + init_local_game_project_at(root, "generation-symlink-removal", "生成账本符号链接测试") + .expect("init project"); + let outside = tempfile::tempdir().expect("create outside ledger directory"); + let agent_id = "art-director"; + let run_id = "symlinked-generation-run"; + let outside_agent = outside + .path() + .join(agent_runtime_confirmation_path_component(agent_id, "agent")); + fs::create_dir_all(&outside_agent).expect("create outside agent directory"); + let outside_ledger = outside_agent.join(format!( + "{}.json", + agent_runtime_confirmation_path_component(run_id, "run") + )); + fs::write(&outside_ledger, b"outside-sentinel").expect("write outside sentinel"); + let runtime_directory = root.join(".agent/runtime"); + fs::create_dir_all(&runtime_directory).expect("create runtime directory"); + let linked_directory = runtime_directory.join("canvas-generation-requests"); + if linked_directory.exists() { + fs::remove_dir_all(&linked_directory).expect("remove existing ledger directory"); + } + symlink(outside.path(), &linked_directory).expect("link outside ledger directory"); + + let error = remove_platform_art_generation_runtime_state_at(root, agent_id, run_id) + .expect_err("symlinked ledger parent must be rejected"); + assert!(error.contains("符号链接"), "{error}"); + assert_eq!( + fs::read(&outside_ledger).expect("outside sentinel remains"), + b"outside-sentinel" + ); + assert!(game_creator_agent_runtime_external_generation_exists( + root, agent_id, run_id + )); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index cbaaae623..ddf2f9d82 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -382,6 +382,7 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ run_id, task, &action.input, + pending_action, ) .await } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs index ed94dd930..a70863b64 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/parallel_ledger.rs @@ -235,6 +235,7 @@ pub(in crate::agent) fn game_creator_agent_runtime_has_pending_action_ledger( game_creator_agent_runtime_pending_tool_action_exists(root, agent_id, run_id) || game_creator_agent_runtime_parallel_read_batch_exists(root, agent_id, run_id) || game_creator_agent_runtime_provider_action_batch_exists(root, agent_id, run_id) + || game_creator_agent_runtime_external_generation_exists(root, agent_id, run_id) } pub(in crate::agent) fn agent_runtime_parallel_read_batch_id( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs index 82b6476b8..fa5493585 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs @@ -529,6 +529,10 @@ pub(in crate::agent) fn remove_game_creator_agent_runtime_pending_tool_action( { let _ = cancel_game_creator_agent_user_input_request_for_pending_at(root, &pending); } + // pending action 是 generation / parallel sidecar 的 durable 身份锚点。先收束附属账本, + // 确保任何清理失败或进程中断都不会留下无法归属、却持续触发恢复扫描的孤儿。 + remove_platform_art_generation_runtime_state_at(root, agent_id, run_id)?; + remove_game_creator_agent_runtime_parallel_read_batch(root, agent_id, run_id)?; let path = game_creator_agent_runtime_pending_tool_action_path(root, agent_id, run_id); let backup_path = agent_runtime_json_sidecar_backup_path(&path); remove_agent_runtime_json_sidecar_backup(&backup_path, "Agent Runtime 待确认动作")?; @@ -547,8 +551,7 @@ pub(in crate::agent) fn remove_game_creator_agent_runtime_pending_tool_action( "读取 Agent Runtime 待确认动作元数据失败:{}: {error}", path.display() )), - }?; - remove_game_creator_agent_runtime_parallel_read_batch(root, agent_id, run_id) + } } pub(in crate::agent) fn remove_game_creator_agent_runtime_confirmations( @@ -682,9 +685,61 @@ pub(in crate::agent) fn consume_game_creator_agent_runtime_tool_confirmation( #[cfg(test)] mod tests { - use super::validate_agent_runtime_pending_serialized_content; + use super::*; use std::path::Path; + fn pending_external_generation_action( + root: &Path, + run_id: &str, + ) -> AgentRuntimePendingToolAction { + let mut runtime = start_game_creator_agent_runtime_task_at( + root, + "art-director", + "生成视觉规范图", + run_id, + "agent-ready-task-scheduler", + "准备生成视觉规范图", + vec!["生成视觉规范图".to_string()], + ) + .expect("start runtime"); + runtime.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "canvas.asset_generate".to_string(), + reason: Some("生成统一视觉规范".to_string()), + input: serde_json::json!({ + "prompt": "生成统一视觉规范图", + "outputPath": "assets/art-spec.png" + }), + }; + let plan = AgentRuntimeToolPlan { + thinking_summary: "准备生成".to_string(), + plan_update: None, + plan: vec!["生成视觉规范图".to_string()], + actions: vec![action.clone()], + response: String::new(), + }; + let revision = + read_game_creator_agent_runtime_project_revision(root).expect("read project revision"); + let repository_fingerprint = build_repository_startup_context_at(root) + .expect("repository context") + .fingerprint; + build_game_creator_agent_runtime_pending_tool_action( + root, + &runtime, + &runtime.current_task, + &plan, + &[], + &revision, + &repository_fingerprint, + &action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ) + .expect("build pending external generation action") + } + #[test] fn pending_content_allows_api_key_security_guidance_without_secret_material() { for task in [ @@ -732,4 +787,59 @@ mod tests { assert!(error.contains(&format!("#{rule}")), "{content}: {error}"); } } + + #[test] + fn generation_cleanup_failure_preserves_pending_identity_anchor() { + let temporary = tempfile::tempdir().expect("create pending cleanup project"); + let root = temporary.path(); + let run_id = "generation-cleanup-order-run"; + init_local_game_project_at(root, "generation-cleanup-order", "生成账本清理顺序测试") + .expect("init project"); + let pending = pending_external_generation_action(root, run_id); + write_game_creator_agent_runtime_pending_tool_action(root, &pending) + .expect("write pending action"); + write_platform_art_generation_runtime_accepted_for_test(root, &pending) + .expect("write accepted generation state"); + + let generation_path = root.join(format!( + ".agent/runtime/canvas-generation-requests/art-director/{run_id}.json" + )); + fs::remove_file(&generation_path).expect("remove generation state fixture"); + fs::create_dir(&generation_path).expect("replace generation state with invalid directory"); + + let error = remove_game_creator_agent_runtime_pending_tool_action( + root, + &pending.agent_id, + &pending.run_id, + ) + .expect_err("generation cleanup failure must stop pending removal"); + assert!(error.contains("External Editor 生成账本必须是普通文件")); + assert!(game_creator_agent_runtime_pending_tool_action_exists( + root, + &pending.agent_id, + &pending.run_id + )); + assert_eq!( + read_game_creator_agent_runtime_pending_tool_action( + root, + &pending.agent_id, + &pending.run_id, + ) + .expect("read preserved pending identity"), + pending + ); + + fs::remove_dir(&generation_path).expect("remove invalid generation fixture"); + remove_game_creator_agent_runtime_pending_tool_action( + root, + &pending.agent_id, + &pending.run_id, + ) + .expect("retry cleanup after generation state is absent"); + assert!(!game_creator_agent_runtime_pending_tool_action_exists( + root, + &pending.agent_id, + &pending.run_id + )); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs index ba11e9fd4..ae7de3fc0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs @@ -503,6 +503,7 @@ fn response_stream_finalization_commits_exactly_one_canonical_assistant() { #[test] fn non_stream_professional_final_reply_remains_queryable_after_later_project_revision() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); assert!( !GameCreatorLlmConfig::default().stream, "the production default exercises the non-stream final-reply path" @@ -559,10 +560,10 @@ fn non_stream_professional_final_reply_remains_queryable_after_later_project_rev &[], ) .expect("finalize non-stream professional reply"); - assert!(matches!( - completed, - AgentBackgroundFinalizationOutcome::Completed(_) - )); + assert!( + matches!(completed, AgentBackgroundFinalizationOutcome::Completed(_)), + "unexpected finalization outcome: {completed:?}" + ); let mut later_revision = read_game_creator_agent_runtime_project_revision(root) .expect("read project revision before later stage mutation"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index 8f4a1992b..153fd95dd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -332,28 +332,51 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at( session_id: &str, fallback: AgentRuntimeState, ) -> AgentBackgroundTaskOutcome { - let runtime = latest_game_chat_deadline_runtime_at(root, fallback); + let mut runtime = latest_game_chat_deadline_runtime_at(root, fallback); let pending_action = read_game_creator_agent_runtime_pending_tool_action( root, &runtime.agent_id, &runtime.run_id, ) .ok(); - let error = format!( - "{GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX}: root Run 自 bound_at 起已达到 {} 秒绝对硬截止;在途动作已取消并进入失败收尾", - GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS - ); - // The generic background failure helper deliberately preserves a durable - // needs-reconciliation state. A hard deadline is different: no action may - // remain recoverable after the root budget expires. Persist the terminal - // failure first, explicitly bypassing that guard, and only then remove the - // recovery material. - let terminal_failure_error = fail_game_creator_agent_runtime_turn_at( - root, - runtime.clone(), - &redact_agent_runtime_error(root, &error, 500), - ) - .err(); + let preserves_external_reconciliation = pending_action.as_ref().is_some_and(|pending| { + pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING + && pending.action.tool == "canvas.asset_generate" + }); + let external_generation_record_preserved = preserves_external_reconciliation + && game_creator_agent_runtime_external_generation_exists( + root, + &runtime.agent_id, + &runtime.run_id, + ); + let error = if preserves_external_reconciliation { + format!( + "{GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX}: root Run 自 bound_at 起已达到 {} 秒绝对硬截止;外部生成结果未知,已结束本轮并保留人工对账证据", + GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS + ) + } else { + format!( + "{GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX}: root Run 自 bound_at 起已达到 {} 秒绝对硬截止;在途动作已取消并进入失败收尾", + GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS + ) + }; + let terminal_failure_error = if let Some(pending) = pending_action + .as_ref() + .filter(|_| preserves_external_reconciliation) + { + mark_game_creator_agent_runtime_needs_reconciliation_at(root, &mut runtime, pending, &error) + .err() + } else { + // Local and read-only work remains safe to cancel at the product hard + // deadline. Unknown external generation side effects are handled above + // and must retain their durable pending action instead. + fail_game_creator_agent_runtime_turn_at( + root, + runtime.clone(), + &redact_agent_runtime_error(root, &error, 500), + ) + .err() + }; let terminal_runtime = latest_game_chat_deadline_runtime_at(root, runtime); let (_, preview_stopped) = game_creator_preview_registry().stop_for_project(Some(root)); let process_cleanup_error = terminate_process_sessions_for_run_at( @@ -362,18 +385,7 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at( &terminal_runtime.run_id, ) .err(); - let mut cleanup_errors = Vec::new(); - for result in [ - remove_game_creator_agent_runtime_pending_tool_action( - root, - &terminal_runtime.agent_id, - &terminal_runtime.run_id, - ), - remove_game_creator_agent_runtime_provider_action_batch( - root, - &terminal_runtime.agent_id, - &terminal_runtime.run_id, - ), + let mut cleanup_results = vec![ remove_game_creator_agent_runtime_confirmations( root, &terminal_runtime.agent_id, @@ -384,7 +396,21 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at( &terminal_runtime.agent_id, &terminal_runtime.run_id, ), - ] { + ]; + if !preserves_external_reconciliation { + cleanup_results.push(remove_game_creator_agent_runtime_pending_tool_action( + root, + &terminal_runtime.agent_id, + &terminal_runtime.run_id, + )); + cleanup_results.push(remove_game_creator_agent_runtime_provider_action_batch( + root, + &terminal_runtime.agent_id, + &terminal_runtime.run_id, + )); + } + let mut cleanup_errors = Vec::new(); + for result in cleanup_results { if let Err(error) = result { cleanup_errors.push(sanitize_agent_runtime_text(&error, 160)); } @@ -406,6 +432,8 @@ pub(super) fn finish_game_chat_absolute_deadline_timeout_at( "hardBudgetSeconds": GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS, "pendingActionId": pending_action.as_ref().map(|pending| pending.action_id.as_str()), "pendingTool": pending_action.as_ref().map(|pending| pending.action.tool.as_str()), + "reconciliationPreserved": preserves_external_reconciliation, + "externalGenerationRecordPreserved": external_generation_record_preserved, "previewStopped": preview_stopped, "cleanupErrorCount": cleanup_errors.len(), }), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs index 9c584173a..843dc4c06 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_deadline_tests.rs @@ -62,8 +62,8 @@ async fn game_chat_absolute_deadline_returns_an_in_flight_result_before_expiry() assert_eq!(result.expect("in-flight action completes"), "completed"); } -#[test] -fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_cleanup() { +#[tokio::test] +async fn game_chat_absolute_deadline_preserves_external_generation_reconciliation() { let root = std::env::temp_dir().join(format!( "genarrative-game-chat-deadline-reconciliation-{}-{}", std::process::id(), @@ -74,11 +74,218 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea )); init_local_game_project_at(&root, "deadline-reconciliation", "硬截止收尾测试") .expect("project init"); + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "game-chat-deadline-reconciliation-root-run", + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous game-chat root profile"); + bind_game_creator_agent_runtime_run_profile_at( + &root, + "art-director", + "game-chat-deadline-reconciliation-run", + "agent-ready-task-scheduler", + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + Some(&AgentRuntimeTaskLink { + parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()), + parent_run_id: Some("game-chat-deadline-reconciliation-root-run".to_string()), + delegation_id: None, + }), + ) + .expect("bind autonomous game-chat art profile"); + let mut runtime = start_game_creator_agent_runtime_task_at( + &root, + "art-director", + "执行可能悬挂的外部图片生成", + "game-chat-deadline-reconciliation-run", + "agent-ready-task-scheduler", + "正在执行外部图片生成", + vec!["执行外部图片生成".to_string()], + ) + .expect("start runtime"); + runtime.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "canvas.asset_generate".to_string(), + reason: Some("模拟截止时仍在途的外部生成".to_string()), + input: serde_json::json!({ + "prompt": "生成首版统一视觉规范图", + "outputPath": "assets/art-spec.png" + }), + }; + let queued_action = AgentRuntimeToolAction { + tool: "canvas.asset_generate".to_string(), + reason: Some("验证同批次后续外部生成不会在恢复时重放".to_string()), + input: serde_json::json!({ + "prompt": "生成首版角色立绘", + "outputPath": "assets/hero.png" + }), + }; + let plan = AgentRuntimeToolPlan { + thinking_summary: "准备外部图片生成".to_string(), + plan_update: None, + plan: vec![ + "生成首版统一视觉规范图".to_string(), + "生成首版角色立绘".to_string(), + ], + actions: vec![action.clone(), queued_action], + response: String::new(), + }; + let project_revision = + read_game_creator_agent_runtime_project_revision(&root).expect("read project revision"); + let repository_fingerprint = build_repository_startup_context_at(&root) + .expect("repository context") + .fingerprint; + let prepared_batch = prepare_game_creator_agent_runtime_provider_action_batch( + &root, + &runtime, + &runtime.current_task, + &plan, + &[], + &project_revision, + &repository_fingerprint, + ) + .await + .expect("prepare durable provider action batch"); + let batch = match prepared_batch { + AgentRuntimeProviderActionBatchPreparation::Ready(batch) => batch, + other => panic!("expected ready provider action batch, got {other:?}"), + }; + assert_eq!(batch.actions.len(), 2); + let mut pending = batch.actions[0].clone(); + pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string(); + write_game_creator_agent_runtime_pending_tool_action(&root, &pending) + .expect("write pending action"); + write_platform_art_generation_runtime_accepted_for_test(&root, &pending) + .expect("write accepted External Editor generation ledger"); + update_game_creator_agent_runtime_provider_batch_member(&root, &pending) + .expect("persist executing provider batch member"); + let executing_batch = read_game_creator_agent_runtime_provider_action_batch( + &root, + &runtime.agent_id, + &runtime.run_id, + ) + .expect("read executing provider action batch"); + assert_eq!( + executing_batch.actions[0].status, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING + ); + assert_eq!( + executing_batch.actions[1].status, + AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED + ); + assert!(game_creator_agent_runtime_external_generation_exists( + &root, + &runtime.agent_id, + &runtime.run_id + )); + runtime.pending_tool_action = Some(pending.summary()); + runtime.status = "running".to_string(); + runtime.phase = "action".to_string(); + runtime.current_action = "调用工具 canvas.asset_generate".to_string(); + runtime.waiting_on.clear(); + runtime.next_step = "等待外部生成结果".to_string(); + runtime.error = None; + append_game_creator_agent_runtime_task(&root, &runtime).expect("append reconciliation task"); + write_game_creator_agent_runtime_state(&root, &runtime).expect("write reconciliation state"); + + let outcome = finish_game_chat_absolute_deadline_timeout_at( + &root, + &runtime.agent_id, + &runtime.session_id, + runtime.clone(), + ); + + assert!(matches!(outcome, AgentBackgroundTaskOutcome::Finished)); + let terminal = read_game_creator_agent_runtime_at(&root, &runtime.agent_id) + .expect("read terminal runtime") + .state; + assert_eq!(terminal.run_id, runtime.run_id); + assert_eq!(terminal.status, "failed"); + assert_eq!(terminal.phase, "needs-reconciliation"); + assert!(terminal.pending_tool_action.is_some()); + assert!(game_creator_agent_runtime_pending_tool_action_exists( + &root, + &runtime.agent_id, + &runtime.run_id + )); + let durable_pending = read_game_creator_agent_runtime_pending_tool_action( + &root, + &runtime.agent_id, + &runtime.run_id, + ) + .expect("preserved external generation pending action"); + assert_eq!( + durable_pending.status, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING + ); + let preserved_batch = read_game_creator_agent_runtime_provider_action_batch( + &root, + &runtime.agent_id, + &runtime.run_id, + ) + .expect("read preserved provider action batch"); + assert_eq!(preserved_batch, executing_batch); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert!(agent_db.contains("\"reconciliationPreserved\":true")); + assert!(agent_db.contains("\"externalGenerationRecordPreserved\":true")); + assert!(agent_db.contains("agent.runtime.tool_action.needs_reconciliation")); + assert!(!agent_db.contains("test-operation-id")); + + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("scan durable runtime state after simulated runner restart"); + assert!(resumed.iter().any(|result| { + result.state.agent_id == runtime.agent_id + && result.state.run_id == runtime.run_id + && result.state.phase == "needs-reconciliation" + })); + let recovered_pending = read_game_creator_agent_runtime_pending_tool_action( + &root, + &runtime.agent_id, + &runtime.run_id, + ) + .expect("read pending action after recovery scan"); + assert_eq!(recovered_pending, durable_pending); + let recovered_batch = read_game_creator_agent_runtime_provider_action_batch( + &root, + &runtime.agent_id, + &runtime.run_id, + ) + .expect("read provider action batch after recovery scan"); + assert_eq!(recovered_batch, preserved_batch); + assert!(game_creator_agent_runtime_external_generation_exists( + &root, + &runtime.agent_id, + &runtime.run_id + )); + assert_eq!( + fs::read_to_string(root.join(".agent/agent.db")).expect("agent db after recovery scan"), + agent_db, + "needs-reconciliation recovery barrier must not append a replay receipt" + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn game_chat_absolute_deadline_still_cleans_local_action_recovery() { + let root = std::env::temp_dir().join(format!( + "genarrative-game-chat-deadline-local-cleanup-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos() + )); + init_local_game_project_at(&root, "deadline-local-cleanup", "硬截止本地清理测试") + .expect("project init"); let mut runtime = start_game_creator_agent_runtime_task_at( &root, "code-prototype", "执行可能悬挂的首版写入", - "game-chat-deadline-reconciliation-run", + "game-chat-deadline-local-cleanup-run", "agent-ready-task-scheduler", "正在执行首版写入", vec!["执行首版写入".to_string()], @@ -87,7 +294,7 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea runtime.loop_iteration = 1; let action = AgentRuntimeToolAction { tool: "file.write".to_string(), - reason: Some("模拟截止时仍在途的写入".to_string()), + reason: Some("模拟截止时仍在途的本地写入".to_string()), input: serde_json::json!({ "path": "game/index.html", "content": "deadline" @@ -105,7 +312,7 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea let repository_fingerprint = build_repository_startup_context_at(&root) .expect("repository context") .fingerprint; - let pending = build_game_creator_agent_runtime_pending_tool_action( + let mut pending = build_game_creator_agent_runtime_pending_tool_action( &root, &runtime, &runtime.current_task, @@ -120,17 +327,12 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea None, ) .expect("build pending action"); + pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING.to_string(); write_game_creator_agent_runtime_pending_tool_action(&root, &pending) .expect("write pending action"); runtime.pending_tool_action = Some(pending.summary()); - runtime.status = "failed".to_string(); - runtime.phase = "needs-reconciliation".to_string(); - runtime.current_action = "等待人工核对在途动作".to_string(); - runtime.waiting_on = "开发者核对副作用".to_string(); - runtime.next_step = "核对后恢复".to_string(); - runtime.error = Some("模拟 needs-reconciliation".to_string()); - append_game_creator_agent_runtime_task(&root, &runtime).expect("append reconciliation task"); - write_game_creator_agent_runtime_state(&root, &runtime).expect("write reconciliation state"); + append_game_creator_agent_runtime_task(&root, &runtime).expect("append runtime task"); + write_game_creator_agent_runtime_state(&root, &runtime).expect("write runtime state"); let outcome = finish_game_chat_absolute_deadline_timeout_at( &root, @@ -143,7 +345,6 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea let terminal = read_game_creator_agent_runtime_at(&root, &runtime.agent_id) .expect("read terminal runtime") .state; - assert_eq!(terminal.run_id, runtime.run_id); assert_eq!(terminal.status, "failed"); assert_eq!(terminal.phase, "failed"); assert!(terminal.pending_tool_action.is_none()); @@ -152,11 +353,8 @@ fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_clea &runtime.agent_id, &runtime.run_id )); - assert!(!game_creator_agent_runtime_provider_action_batch_exists( - &root, - &runtime.agent_id, - &runtime.run_id - )); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert!(agent_db.contains("\"reconciliationPreserved\":false")); fs::remove_dir_all(root).ok(); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs index 672ea98f7..cdbb3c0e6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_execution.rs @@ -1,5 +1,19 @@ use super::*; +async fn run_join_owned_pending_task( + future: std::pin::Pin + Send + 'static>>, +) -> Result +where + T: Send + 'static, +{ + let mut tasks = tokio::task::JoinSet::new(); + tasks.spawn(future); + tasks + .join_next() + .await + .expect("pending continuation task must exist") +} + async fn run_after_pending_stack_boundary( future: std::pin::Pin + Send + 'static>>, ) -> T @@ -10,39 +24,13 @@ where // poll frames. The boxed future keeps that large frame out of its caller before a joined child // task gives it an independent poll boundary. JoinSet still aborts the child if its parent // continuation is dropped. - let mut tasks = tokio::task::JoinSet::new(); - tasks.spawn(future); - match tasks - .join_next() - .await - .expect("pending continuation task must exist") - { + match run_join_owned_pending_task(future).await { Ok(output) => output, Err(error) if error.is_panic() => std::panic::resume_unwind(error.into_panic()), Err(error) => panic!("pending continuation task was cancelled: {error}"), } } -async fn run_game_creator_agent_background_task_after_pending_stack_boundary( - root: PathBuf, - agent_id: String, - task: String, - runtime: AgentRuntimeState, - continuation: AgentRuntimeContinuationContext, -) -> AgentBackgroundTaskOutcome { - run_after_pending_stack_boundary(Box::pin(async move { - run_game_creator_agent_background_task_with_context( - root, - agent_id, - task, - runtime, - continuation, - ) - .await - })) - .await -} - async fn drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary( root: PathBuf, agent_id: String, @@ -53,6 +41,116 @@ async fn drain_next_game_creator_agent_background_tasks_after_pending_stack_boun .await; } +async fn run_recovered_game_creator_context_on_fresh_task( + root: PathBuf, + agent_id: String, + task: String, + runtime: AgentRuntimeState, + continuation: AgentRuntimeContinuationContext, +) -> Result { + run_join_owned_pending_task(Box::pin(async move { + run_game_creator_agent_background_task_with_context( + root, + agent_id, + task, + runtime, + continuation, + ) + .await + })) + .await + .map_err(|error| format!("恢复 Agent Runtime continuation 的独立任务异常结束:{error}")) +} + +fn mark_game_creator_agent_runtime_continuation_needs_reconciliation_at( + root: &Path, + runtime: &mut AgentRuntimeState, + error: &str, +) -> Result<(), String> { + runtime.status = "failed".to_string(); + runtime.phase = "needs-reconciliation".to_string(); + runtime.current_action = "恢复后的 Agent continuation 需要人工核对".to_string(); + runtime.waiting_on = "开发者核对已持久化工具观察与 Provider 状态".to_string(); + runtime.next_step = "核对外部结果后显式取消或恢复当前 run".to_string(); + runtime.pending_tool_action = None; + runtime.error = Some(redact_agent_runtime_error(root, error, 500)); + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, runtime)?; + refresh_game_creator_agent_runtime_task_queue(root, runtime)?; + write_game_creator_agent_runtime_state(root, runtime)?; + append_game_creator_agent_runtime_event( + root, + runtime, + "runtime.continuation.needs_reconciliation", + "failed", + "needs-reconciliation", + "恢复后的 Runtime continuation 异常结束,已保留持久化证据并停止自动续跑。", + runtime.error.as_deref(), + )?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.continuation.needs_reconciliation", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "source": runtime.source, + "error": runtime.error, + }), + )?; + emit_game_creator_agent_runtime_update(root, &runtime.agent_id); + Ok(()) +} + +fn persist_game_creator_agent_runtime_continuation_reconciliation_emergency_at( + root: &Path, + runtime: &mut AgentRuntimeState, + join_error: &str, + persistence_error: &str, +) { + let error = redact_agent_runtime_error( + root, + &format!( + "恢复 continuation 异常结束,且正式 reconciliation 持久化不完整;joinError={join_error};persistenceError={persistence_error}" + ), + 500, + ); + runtime.status = "failed".to_string(); + runtime.phase = "needs-reconciliation".to_string(); + runtime.current_action = "恢复后的 Agent continuation 需要人工核对".to_string(); + runtime.waiting_on = "开发者核对 Runtime 持久化证据".to_string(); + runtime.next_step = "修复持久化链后显式取消或恢复当前 run".to_string(); + runtime.pending_tool_action = None; + runtime.error = Some(error.clone()); + runtime.updated_at = unix_timestamp(); + let _ = append_game_creator_agent_runtime_task(root, runtime); + let _ = refresh_game_creator_agent_runtime_task_queue(root, runtime); + let _ = write_game_creator_agent_runtime_state(root, runtime); + let _ = append_game_creator_agent_runtime_event( + root, + runtime, + "runtime.continuation.reconciliation_persistence_failed", + "failed", + "needs-reconciliation", + "Runtime continuation 异常后的正式对账记录未完整落盘,已尝试写入紧急阻断。", + Some(&error), + ); + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.continuation.reconciliation_persistence_failed", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "source": runtime.source, + "error": error, + }), + ); + emit_game_creator_agent_runtime_update(root, &runtime.agent_id); +} + pub(crate) async fn continue_game_creator_agent_pending_tool_action( root: PathBuf, agent_id: String, @@ -129,14 +227,40 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary( usize::try_from(batch.loop_iteration.saturating_sub(1)).unwrap_or(usize::MAX); continuation.context_stalled = false; continuation.applied_steer_cursor = batch.planned_steer_cursor; - let outcome = run_game_creator_agent_background_task_after_pending_stack_boundary( + let runtime_fallback = runtime.clone(); + let outcome = match run_recovered_game_creator_context_on_fresh_task( root.clone(), agent_id.clone(), pending.task.clone(), runtime, continuation, ) - .await; + .await + { + Ok(outcome) => outcome, + Err(error) => { + let mut failed_runtime = read_game_creator_agent_runtime_at(&root, &agent_id) + .ok() + .filter(|current| current.state.run_id == pending.run_id) + .map(|current| current.state) + .unwrap_or(runtime_fallback); + if let Err(persistence_error) = + mark_game_creator_agent_runtime_provider_batch_needs_reconciliation_at( + &root, + &mut failed_runtime, + &error, + ) + { + persist_game_creator_agent_runtime_continuation_reconciliation_emergency_at( + &root, + &mut failed_runtime, + &error, + &persistence_error, + ); + } + return; + } + }; if matches!(outcome, AgentBackgroundTaskOutcome::Finished) { drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary( root, agent_id, @@ -276,7 +400,48 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary( &root, &pending, ); } - let observation = + let observation = if action.tool == "canvas.asset_generate" + && game_creator_agent_runtime_external_generation_exists( + &root, + &pending.agent_id, + &pending.run_id, + ) { + // Recovery already adds a deep pending/runtime continuation stack. Poll the + // durable external-generation execution in a fresh Tokio task so the normal + // 2 MiB worker stack is sufficient while this task keeps the Agent lock held. + let execution_root = root.clone(); + let execution_agent_id = agent_id.clone(); + let execution_pending = pending.clone(); + let execution_action = action.clone(); + match tauri::async_runtime::spawn(async move { + execute_game_creator_agent_runtime_tool_action_with_pending_action( + &execution_root, + &execution_agent_id, + &execution_pending.run_id, + &execution_pending.task, + &execution_action, + Some(&execution_pending.action_id), + Some(&execution_pending), + ) + .await + }) + .await + { + Ok(observation) => observation, + Err(error) => AgentRuntimeToolObservation { + tool: action.tool.clone(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + .to_string(), + summary: "External Editor 恢复执行任务异常结束,结果需要人工核对" + .to_string(), + detail: Some(redact_agent_runtime_error( + &root, + &error.to_string(), + 500, + )), + }, + } + } else { execute_game_creator_agent_runtime_tool_action_with_pending_action( &root, &agent_id, @@ -286,7 +451,8 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary( Some(&pending.action_id), Some(&pending), ) - .await; + .await + }; if observation.is_waiting_for_confirmation() && auto_execution { pending.execution_mode = AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION.to_string(); @@ -870,14 +1036,40 @@ async fn continue_game_creator_agent_pending_tool_action_within_stack_boundary( return; } } - let outcome = run_game_creator_agent_background_task_after_pending_stack_boundary( + let runtime_fallback = runtime.clone(); + let outcome = match run_recovered_game_creator_context_on_fresh_task( root.clone(), agent_id.clone(), pending.task.clone(), runtime, continuation, ) - .await; + .await + { + Ok(outcome) => outcome, + Err(error) => { + let mut failed_runtime = read_game_creator_agent_runtime_at(&root, &agent_id) + .ok() + .filter(|current| current.state.run_id == pending.run_id) + .map(|current| current.state) + .unwrap_or(runtime_fallback); + if let Err(persistence_error) = + mark_game_creator_agent_runtime_continuation_needs_reconciliation_at( + &root, + &mut failed_runtime, + &error, + ) + { + persist_game_creator_agent_runtime_continuation_reconciliation_emergency_at( + &root, + &mut failed_runtime, + &error, + &persistence_error, + ); + } + return; + } + }; if matches!(outcome, AgentBackgroundTaskOutcome::Finished) { drain_next_game_creator_agent_background_tasks_after_pending_stack_boundary(root, agent_id) .await; @@ -999,6 +1191,12 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_provider_batch_needs_rec runtime: &mut AgentRuntimeState, error: &str, ) -> Result<(), String> { + let batch = read_game_creator_agent_runtime_provider_action_batch( + root, + &runtime.agent_id, + &runtime.run_id, + ) + .ok(); runtime.status = "failed".to_string(); runtime.phase = "needs-reconciliation".to_string(); runtime.current_action = "Provider action 批次需要人工核对".to_string(); @@ -1027,9 +1225,137 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_provider_batch_needs_rec "taskId": runtime.task_id, "sessionId": runtime.session_id, "runId": runtime.run_id, + "batchId": batch.as_ref().map(|batch| batch.batch_id.as_str()), + "nextActionIndex": batch.as_ref().map(|batch| batch.next_action_index), + "actionCount": batch.as_ref().map(|batch| batch.actions.len()), "error": runtime.error, }), )?; emit_game_creator_agent_runtime_update(root, &runtime.agent_id); Ok(()) } + +#[cfg(test)] +mod continuation_join_error_tests { + use super::*; + + struct PendingChildDropSignal(Option>); + + impl Drop for PendingChildDropSignal { + fn drop(&mut self) { + if let Some(sender) = self.0.take() { + let _ = sender.send(()); + } + } + } + + fn started_runtime(root: &Path, run_id: &str) -> AgentRuntimeState { + init_local_game_project_at(root, "continuation-join-error", "continuation 对账测试") + .expect("init project"); + start_game_creator_agent_runtime_task_at( + root, + "code-prototype", + "恢复已持久化工具观察", + run_id, + "agent-ready-task-scheduler", + "恢复 continuation", + vec!["恢复 continuation".to_string()], + ) + .expect("start runtime") + } + + #[test] + fn continuation_join_error_persists_full_redacted_reconciliation_projection() { + let temporary = tempfile::tempdir().expect("create continuation reconciliation project"); + let root = temporary.path(); + let mut runtime = started_runtime(root, "continuation-reconciliation-run"); + let sensitive_error = format!( + "panic at {}/private.rs with api key sk-test-secret-value", + root.display() + ); + + mark_game_creator_agent_runtime_continuation_needs_reconciliation_at( + root, + &mut runtime, + &sensitive_error, + ) + .expect("persist continuation reconciliation"); + + let current = read_game_creator_agent_runtime_at(root, &runtime.agent_id) + .expect("read reconciled runtime") + .state; + assert_eq!(current.phase, "needs-reconciliation"); + assert!(current.task_queue.failed >= 1); + let persisted = fs::read_to_string(root.join(".agent/agent.db")).expect("read Agent DB"); + assert!(persisted.contains("agent.runtime.continuation.needs_reconciliation")); + assert!(!persisted.contains(&root.display().to_string())); + assert!(!persisted.contains("sk-test-secret-value")); + let events = fs::read_to_string(game_creator_agent_runtime_event_path( + root, + &runtime.agent_id, + )) + .expect("read runtime events"); + assert!(events.contains("runtime.continuation.needs_reconciliation")); + } + + #[test] + fn continuation_join_error_uses_emergency_audit_when_formal_audit_fails() { + let temporary = tempfile::tempdir().expect("create continuation emergency project"); + let root = temporary.path(); + let mut runtime = started_runtime(root, "continuation-emergency-run"); + fs::create_dir_all(root.join(".agent/runtime")).expect("create runtime directory"); + fs::write( + root.join(".agent/runtime/test-fail-next-agent-db-record"), + "agent.runtime.continuation.needs_reconciliation", + ) + .expect("inject formal audit failure"); + let join_error = "panic at /private/path with sk-emergency-secret"; + let persistence_error = + mark_game_creator_agent_runtime_continuation_needs_reconciliation_at( + root, + &mut runtime, + join_error, + ) + .expect_err("formal reconciliation audit must fail once"); + + persist_game_creator_agent_runtime_continuation_reconciliation_emergency_at( + root, + &mut runtime, + join_error, + &persistence_error, + ); + + let current = read_game_creator_agent_runtime_at(root, &runtime.agent_id) + .expect("read emergency reconciled runtime") + .state; + assert_eq!(current.phase, "needs-reconciliation"); + let persisted = fs::read_to_string(root.join(".agent/agent.db")).expect("read Agent DB"); + assert!(persisted.contains("agent.runtime.continuation.reconciliation_persistence_failed")); + assert!(!persisted.contains("/private/path")); + assert!(!persisted.contains("sk-emergency-secret")); + } + + #[tokio::test] + async fn pending_child_task_is_aborted_when_its_parent_is_cancelled() { + let (started_sender, started_receiver) = tokio::sync::oneshot::channel(); + let (dropped_sender, dropped_receiver) = tokio::sync::oneshot::channel(); + let parent = tokio::spawn(async move { + run_join_owned_pending_task(Box::pin(async move { + let _drop_signal = PendingChildDropSignal(Some(dropped_sender)); + let _ = started_sender.send(()); + std::future::pending::<()>().await; + })) + .await + }); + + started_receiver + .await + .expect("pending child must start before parent cancellation"); + parent.abort(); + let _ = parent.await; + tokio::time::timeout(Duration::from_secs(1), dropped_receiver) + .await + .expect("owned pending child must be aborted with its parent") + .expect("pending child drop signal must be delivered"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs index 0a6e6de58..38c31ad35 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/pending_recovery.rs @@ -769,6 +769,51 @@ pub(crate) fn resume_game_creator_agent_pending_tool_action_at( } } } + if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING + && pending.action.tool == "canvas.asset_generate" + { + match platform_art_generation_runtime_recovery_at(root, &pending) { + Ok( + PlatformArtGenerationRuntimeRecovery::ResumeAccepted + | PlatformArtGenerationRuntimeRecovery::ResumeLegacyCompleted, + ) => { + pending.status = AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED.to_string(); + pending.observation = None; + pending.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_pending_tool_action(root, &pending)?; + } + Ok(PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown) => { + mark_game_creator_agent_runtime_needs_reconciliation_at( + root, + &mut runtime, + &pending, + "External Editor 生成账本停在 prepared,POST 是否受理未知;Runtime 禁止自动重放", + )?; + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } + Ok(PlatformArtGenerationRuntimeRecovery::Missing) => { + mark_game_creator_agent_runtime_needs_reconciliation_at( + root, + &mut runtime, + &pending, + "canvas.asset_generate 已进入 executing 但缺少 durable External Editor 生成账本", + )?; + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } + Err(error) => { + mark_game_creator_agent_runtime_needs_reconciliation_at( + root, + &mut runtime, + &pending, + &format!("External Editor 生成账本无法通过恢复校验:{error}"), + )?; + return read_game_creator_agent_runtime_at(root, agent_id) + .map(AgentRuntimePendingActionResume::Handled); + } + } + } if pending.status == AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING { let recovered_mcp_observation = match recover_game_creator_mcp_observation_from_sidecar_at(root, &pending) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs index ce4e26baf..974216689 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs @@ -434,6 +434,7 @@ pub(crate) fn has_recoverable_game_creator_agent_background_tasks_at( ".agent/runtime/pending-actions", ".agent/runtime/parallel-read-batches", ".agent/runtime/provider-action-batches", + ".agent/runtime/canvas-generation-requests", ".agent/runtime/cancel", ] { if durable_agent_runtime_recovery_directory_has_entries(&root.join(relative_directory)) { @@ -479,6 +480,143 @@ fn durable_agent_runtime_recovery_directory_has_entries(directory: &Path) -> boo false } +fn cleanup_orphaned_platform_art_generation_runtime_states_at( + root: &Path, +) -> Result { + let directory = resolve_local_project_path(root, ".agent/runtime/canvas-generation-requests")?; + let agent_entries = match fs::read_dir(&directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => { + return Err(format!( + "读取 External Editor 生成账本目录失败:{}: {error}", + directory.display() + )); + } + }; + let mut identities = std::collections::BTreeSet::<(String, String)>::new(); + for agent_entry in agent_entries { + let agent_entry = agent_entry.map_err(|error| { + format!( + "遍历 External Editor 生成账本 Agent 目录失败:{}: {error}", + directory.display() + ) + })?; + let agent_metadata = fs::symlink_metadata(agent_entry.path()).map_err(|error| { + format!( + "读取 External Editor 生成账本 Agent 目录元数据失败:{}: {error}", + agent_entry.path().display() + ) + })?; + if agent_metadata.file_type().is_symlink() || !agent_metadata.is_dir() { + return Err("External Editor 生成账本 Agent 路径必须是普通目录".to_string()); + } + let entries = fs::read_dir(agent_entry.path()).map_err(|error| { + format!( + "读取 External Editor 生成账本 Agent 目录失败:{}: {error}", + agent_entry.path().display() + ) + })?; + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "遍历 External Editor 生成账本失败:{}: {error}", + agent_entry.path().display() + ) + })?; + let metadata = fs::symlink_metadata(entry.path()).map_err(|error| { + format!( + "读取 External Editor 生成账本元数据失败:{}: {error}", + entry.path().display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("External Editor 生成账本必须是普通文件".to_string()); + } + if metadata.len() > 256 * 1024 { + return Err("External Editor 生成账本超过 262144 字节上限".to_string()); + } + let file_name = entry + .file_name() + .to_str() + .map(str::to_string) + .ok_or_else(|| "External Editor 生成账本文件名不是 UTF-8".to_string())?; + if !file_name.ends_with(".json") && !file_name.ends_with(".json.previous") { + return Err(format!("External Editor 生成账本文件名无效:{file_name}")); + } + let payload = fs::read(&entry.path()).map_err(|error| { + format!( + "读取 External Editor 生成账本失败:{}: {error}", + entry.path().display() + ) + })?; + let payload = + serde_json::from_slice::(&payload).map_err(|error| { + format!( + "解析 External Editor 生成账本失败:{}: {error}", + entry.path().display() + ) + })?; + let schema_version = payload + .get("schemaVersion") + .and_then(serde_json::Value::as_str) + .unwrap_or("(missing)"); + if schema_version != PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION { + return Err(format!( + "External Editor 生成账本版本无效:{file_name}: {schema_version}" + )); + } + let agent_id = payload + .get("agentId") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "External Editor 生成账本缺少 agentId".to_string())?; + let run_id = payload + .get("runId") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "External Editor 生成账本缺少 runId".to_string())?; + let expected_agent_component = + agent_runtime_confirmation_path_component(agent_id, "agent"); + let expected_file_name = format!( + "{}.json", + agent_runtime_confirmation_path_component(run_id, "run") + ); + let expected_backup_name = format!(".{expected_file_name}.previous"); + if agent_entry.file_name().to_str() != Some(expected_agent_component.as_str()) + || (file_name != expected_file_name && file_name != expected_backup_name) + { + return Err("External Editor 生成账本路径与内部身份不一致".to_string()); + } + identities.insert((agent_id.to_string(), run_id.to_string())); + } + } + + let mut removed = 0_usize; + for (agent_id, run_id) in identities { + if game_creator_agent_runtime_pending_tool_action_exists(root, &agent_id, &run_id) { + continue; + } + let task = read_latest_game_creator_agent_runtime_task_by_run_id(root, &agent_id, &run_id)? + .ok_or_else(|| { + format!( + "External Editor 孤儿生成账本缺少所属任务,已保留供人工核对:agent={agent_id} runId={run_id}" + ) + })?; + if !matches!(task.status.as_str(), "completed" | "cancelled") + || task.phase == "needs-reconciliation" + { + return Err(format!( + "External Editor 孤儿生成账本所属任务未安全终结,已保留供人工核对:agent={agent_id} runId={run_id} status={} phase={}", + task.status, task.phase + )); + } + remove_platform_art_generation_runtime_state_at(root, &agent_id, &run_id)?; + removed = removed.saturating_add(1); + } + Ok(removed) +} + fn durable_process_session_recovery_exists_at(root: &Path) -> bool { let directory = root.join(".agent/runtime/process-sessions"); let entries = match fs::read_dir(&directory) { @@ -532,6 +670,7 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at resume_external_agent_runner(root)?; return read_game_creator_agent_runtimes_at(root); } + cleanup_orphaned_platform_art_generation_runtime_states_at(root)?; let agent_ids = collect_game_creator_agent_runtime_agent_ids(root)?; if !current_game_creator_agent_runtime_finalization_exists_at(root, &agent_ids)? { cleanup_game_creator_agent_runtime_completed_finalizations_at(root)?; @@ -1075,3 +1214,132 @@ pub(crate) fn resume_game_creator_agent_pending_action_for_agent_at( } } } + +#[cfg(test)] +mod orphaned_external_generation_recovery_tests { + use super::*; + + #[test] + fn recovery_scan_preserves_active_generation_orphan_then_cleans_terminal_legacy_orphan() { + let temporary = tempfile::tempdir().expect("create orphan generation recovery project"); + let root = temporary.path(); + let run_id = "orphan-generation-recovery-run"; + init_local_game_project_at(root, "orphan-generation-recovery", "孤儿生成账本恢复测试") + .expect("init project"); + let mut runtime = start_game_creator_agent_runtime_task_at( + root, + "art-director", + "生成视觉规范图", + run_id, + "agent-ready-task-scheduler", + "准备生成视觉规范图", + vec!["生成视觉规范图".to_string()], + ) + .expect("start runtime"); + runtime.loop_iteration = 1; + let action = AgentRuntimeToolAction { + tool: "canvas.asset_generate".to_string(), + reason: Some("生成统一视觉规范".to_string()), + input: serde_json::json!({ + "prompt": "生成统一视觉规范图", + "outputPath": "assets/art-spec.png" + }), + }; + let plan = AgentRuntimeToolPlan { + thinking_summary: "准备生成".to_string(), + plan_update: None, + plan: vec!["生成视觉规范图".to_string()], + actions: vec![action.clone()], + response: String::new(), + }; + let revision = + read_game_creator_agent_runtime_project_revision(root).expect("read project revision"); + let repository_fingerprint = build_repository_startup_context_at(root) + .expect("repository context") + .fingerprint; + let pending = build_game_creator_agent_runtime_pending_tool_action( + root, + &runtime, + &runtime.current_task, + &plan, + &[], + &revision, + &repository_fingerprint, + &action, + 0, + AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO, + AGENT_RUNTIME_PENDING_ACTION_STATUS_EXECUTING, + None, + ) + .expect("build pending identity without writing its sidecar"); + write_platform_art_generation_runtime_accepted_for_test(root, &pending) + .expect("write legacy orphan generation state"); + let active_error = resume_game_creator_agent_background_tasks_at(root) + .expect_err("active generation orphan must remain fail closed"); + assert!(active_error.contains("未安全终结")); + assert!(game_creator_agent_runtime_external_generation_exists( + root, + &pending.agent_id, + &pending.run_id + )); + runtime.status = "completed".to_string(); + runtime.phase = "completed".to_string(); + runtime.current_action = "测试任务已完成".to_string(); + runtime.waiting_on.clear(); + runtime.next_step.clear(); + runtime.pending_tool_action = None; + runtime.updated_at = unix_timestamp(); + append_game_creator_agent_runtime_task(root, &runtime).expect("append terminal task"); + write_game_creator_agent_runtime_state(root, &runtime).expect("write terminal state"); + + assert!(game_creator_agent_runtime_external_generation_exists( + root, + &pending.agent_id, + &pending.run_id + )); + assert!(has_recoverable_game_creator_agent_background_tasks_at(root) + .expect("orphan initially looks recoverable")); + + let resumed = resume_game_creator_agent_background_tasks_at(root) + .expect("recovery scan cleans generation orphan"); + assert!(resumed.is_empty()); + assert!(!game_creator_agent_runtime_external_generation_exists( + root, + &pending.agent_id, + &pending.run_id + )); + assert!( + !has_recoverable_game_creator_agent_background_tasks_at(root) + .expect("cleaned orphan must not trigger permanent recovery") + ); + } + + #[cfg(unix)] + #[test] + fn orphan_scan_rejects_symlinked_generation_ledger_root() { + use std::os::unix::fs::symlink; + + let project = tempfile::tempdir().expect("create orphan symlink project"); + let root = project.path(); + init_local_game_project_at(root, "orphan-symlink", "孤儿生成账本符号链接测试") + .expect("init project"); + let outside = tempfile::tempdir().expect("create outside orphan directory"); + let sentinel = outside.path().join("sentinel.json"); + fs::write(&sentinel, b"outside-sentinel").expect("write outside sentinel"); + let runtime_directory = root.join(".agent/runtime"); + fs::create_dir_all(&runtime_directory).expect("create runtime directory"); + let linked_directory = runtime_directory.join("canvas-generation-requests"); + if linked_directory.exists() { + fs::remove_dir_all(&linked_directory).expect("remove existing ledger directory"); + } + symlink(outside.path(), &linked_directory).expect("link outside orphan directory"); + + let error = cleanup_orphaned_platform_art_generation_runtime_states_at(root) + .expect_err("orphan scan must reject symlinked ledger root"); + assert!(error.contains("符号链接"), "{error}"); + assert_eq!( + fs::read(&sentinel).expect("outside sentinel remains"), + b"outside-sentinel" + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index 90c87ba3a..c38d8e71e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -1549,6 +1549,7 @@ fn gui_ready_child_still_rejects_pending_manifest_status() { #[test] fn autonomous_ready_child_missing_or_invalid_owner_artifact_is_blocked() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); let (_temporary, root, parent_state, _contract) = autonomous_fixture("做一个完整小游戏", "autonomous-ready-child-artifact-parent"); update_manifest_task_status_at(&root, "balance-seed", GameCreationAppTaskStatus::Running) @@ -1613,10 +1614,13 @@ fn autonomous_ready_child_missing_or_invalid_owner_artifact_is_blocked() { let code_state = agent_runtime_state_from_task_record(&code_record); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state) .expect("initial code placeholder must block child completion"); - assert!(blocker - .detail - .as_deref() - .is_some_and(|detail| detail.contains("game/index.html(initial-placeholder)"))); + assert!( + blocker + .detail + .as_deref() + .is_some_and(|detail| detail.contains("game/index.html(initial-placeholder)")), + "unexpected blocker: {blocker:?}" + ); } #[test] @@ -2222,6 +2226,7 @@ fn superseded_or_cancelled_autonomous_root_cannot_project_or_schedule() { #[test] fn autonomous_completion_requires_changed_index_static_smoke_and_bound_playtest() { + let _config_guard = crate::tests::write_test_local_config("{}".to_string()); let (_temporary, root, mut state, contract) = autonomous_fixture( "做一个塔防游戏,选择植物阻挡敌人并正常闯关", "autonomous-completion-evidence-run", @@ -2243,7 +2248,10 @@ fn autonomous_completion_requires_changed_index_static_smoke_and_bound_playtest( mark_verification_passed(&root, &state, "project.verify"); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state) .expect("project.verify cannot replace static smoke"); - assert!(blocker.summary.contains("game.static_smoke")); + assert!( + blocker.summary.contains("game.static_smoke"), + "unexpected blocker: {blocker:?}" + ); mark_verification_passed(&root, &state, "game.static_smoke"); let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index c5c017088..fcbb42f59 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -492,6 +492,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio run_id: &str, task: &str, input: &serde_json::Value, + pending_action: Option<&AgentRuntimePendingToolAction>, ) -> AgentRuntimeToolObservation { let prompt = agent_runtime_tool_input_text(input, &["prompt", "assetPrompt", "description"]); let prompt = if prompt.trim().is_empty() { @@ -670,7 +671,30 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio detail: None, }; } - if !options.replace_existing { + let resumes_durable_generation = match pending_action { + Some(pending) => match platform_art_generation_runtime_recovery_at(root, pending) { + Ok(PlatformArtGenerationRuntimeRecovery::Missing) => false, + Ok( + PlatformArtGenerationRuntimeRecovery::PreparedResultUnknown + | PlatformArtGenerationRuntimeRecovery::ResumeAccepted + | PlatformArtGenerationRuntimeRecovery::ResumeLegacyCompleted, + ) => true, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), + summary: redact_agent_runtime_project_paths( + root, + &format!("External Editor 生成账本无法通过恢复预检:{error}"), + 240, + ), + detail: None, + }; + } + }, + None => false, + }; + if !options.replace_existing && !resumes_durable_generation { if let Err(error) = prepare_platform_art_asset_output_path(root, options.output_path.as_deref()) { @@ -687,11 +711,13 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio { return blocker; } - let prepared = match request_platform_art_asset_with_options_at( + let runtime_context = pending_action.map(platform_art_generation_runtime_context_from_pending); + let prepared = match request_platform_art_asset_with_runtime_options_at( root, prompt.trim(), &[], &options, + runtime_context.as_ref(), ) .await { @@ -699,7 +725,8 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio Err(error) => { return AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), - status: "failed".to_string(), + status: platform_art_generation_observation_status(root, agent_id, run_id, &error) + .to_string(), summary: redact_agent_runtime_project_paths(root, &error, 240), detail: None, }; @@ -808,34 +835,56 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio .as_deref() .map(|reason| format!(";透明图集可用,但自动切片未完成:{reason}")) .unwrap_or_default(); + let warning_summary = generated + .warning + .as_deref() + .map(|reason| format!(";平台非阻断告警:{reason}")) + .unwrap_or_default(); AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), status: "ok".to_string(), summary: format!( - "已生成美术素材:{}{slice_warning_summary}", + "已生成美术素材:{}{warning_summary}{slice_warning_summary}", generated.asset.local_path ), detail: Some(format!( - "assetId={}, localPath={}, resourceId={}, assetObjectId={}, taskId={}, model={}, sliceWarning={}, verifiedRevision={mutation_revision}", + "assetId={}, localPath={}, resourceId={}, assetObjectId={}, taskId={}, model={}, warning={}, sliceWarning={}, verifiedRevision={mutation_revision}", generated.asset.id, generated.asset.local_path, generated.resource_id.as_deref().unwrap_or(""), generated.asset_object_id.as_deref().unwrap_or(""), generated.task_id.as_deref().unwrap_or(""), generated.model.as_deref().unwrap_or(""), + generated.warning.as_deref().unwrap_or(""), generated.slice_warning.as_deref().unwrap_or("") )), } } Err(error) => AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), - status: "failed".to_string(), + status: platform_art_generation_observation_status(root, agent_id, run_id, &error) + .to_string(), summary: redact_agent_runtime_project_paths(root, &error, 240), detail: None, }, } } +fn platform_art_generation_observation_status( + root: &Path, + agent_id: &str, + run_id: &str, + error: &str, +) -> &'static str { + if platform_art_generation_error_needs_reconciliation(error) + || game_creator_agent_runtime_external_generation_exists(root, agent_id, run_id) + { + AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + } else { + "failed" + } +} + #[cfg(test)] pub(crate) async fn observe_agent_runtime_platform_art_asset_generation_after_dispatch_for_test( root: &Path, @@ -844,5 +893,43 @@ pub(crate) async fn observe_agent_runtime_platform_art_asset_generation_after_di task: &str, input: &serde_json::Value, ) -> AgentRuntimeToolObservation { - observe_agent_runtime_platform_art_asset_generation(root, agent_id, run_id, task, input).await + observe_agent_runtime_platform_art_asset_generation(root, agent_id, run_id, task, input, None) + .await +} + +#[cfg(test)] +mod platform_art_generation_observation_tests { + use super::*; + + #[test] + fn unknown_external_generation_result_requires_runtime_reconciliation() { + let root = tempfile::tempdir().expect("create observation status root"); + assert_eq!( + platform_art_generation_observation_status( + root.path(), + "art-director", + "run-unknown", + "platform-generation-result-unknown: 平台已受理但响应丢失" + ), + AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + ); + assert_eq!( + platform_art_generation_observation_status( + root.path(), + "art-asset-plan", + "run-source-preserved", + "platform-generation-source-preserved-no-retry: provider 源图已保留" + ), + AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION + ); + assert_eq!( + platform_art_generation_observation_status( + root.path(), + "art-director", + "run-failed", + "平台明确返回生成失败" + ), + "failed" + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs index e8769c440..c005c80e3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/task_ops.rs @@ -141,12 +141,81 @@ fn root_run_source_is_game_chat(root: &Path, agent_id: &str, run_id: &str) -> Re mod tests { use super::*; + fn register_task_list_visual_fixture( + root: &Path, + local_path: &str, + kind: &str, + generation_kind: &str, + alpha: u8, + reference_resource_ids: Vec, + ) { + image::RgbaImage::from_pixel(4, 4, image::Rgba([80, 140, 220, alpha])) + .save(root.join(local_path)) + .expect("write task list visual fixture"); + register_local_asset_at( + root, + local_path, + kind, + "image/png", + "canvas", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("fixture-canvas".to_string()), + resource_id: Some(format!("fixture-{kind}-resource")), + asset_object_id: Some(format!("fixture-{kind}-object")), + task_id: Some(format!("fixture-{kind}-task")), + prompt: None, + model: None, + generation_route: Some( + if kind == "art-spritesheet" { + "/api/external/v1/editor/icon-spritesheets/generations" + } else { + "/api/external/v1/editor/images/generations" + } + .to_string(), + ), + generation_kind: Some(generation_kind.to_string()), + reference_resource_ids, + }, + ) + .expect("register task list visual fixture"); + } + + fn register_task_list_visual_fixtures(root: &Path) { + let art_spec_resource_id = "fixture-icon-spec-resource".to_string(); + register_task_list_visual_fixture( + root, + "assets/art-spec.png", + "icon-spec", + "spec", + u8::MAX, + Vec::new(), + ); + register_task_list_visual_fixture( + root, + "assets/ui-prototype.png", + "ui-prototype", + "ui-design", + u8::MAX, + vec![art_spec_resource_id.clone()], + ); + register_task_list_visual_fixture( + root, + "assets/art-spritesheet.png", + "art-spritesheet", + "icon-spritesheet", + 0, + vec![art_spec_resource_id], + ); + } + #[test] fn game_chat_task_list_hides_publish_tasks_and_counts() { let temporary = tempfile::tempdir().expect("create task list project"); let root = temporary.path(); init_local_game_project_at(root, "game-chat-task-list", "game-chat task list") .expect("initialize project"); + register_task_list_visual_fixtures(root); bind_game_creator_agent_runtime_run_profile_at( root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, 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 b27d185f0..ac768693f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -888,6 +888,7 @@ struct GeneratedPlatformArtAsset { asset_object_id: Option, task_id: Option, model: Option, + warning: Option, slice_warning: Option, } 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 e85374e95..7eb2310e6 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 @@ -1265,7 +1265,7 @@ fn spawn_mock_llm_server(response_content: String) -> String { spawn_mock_llm_server_responses(vec![response_content]) } -fn spawn_mock_llm_server_responses(response_contents: Vec) -> String { +pub(crate) fn spawn_mock_llm_server_responses(response_contents: Vec) -> String { spawn_mock_llm_server_responses_with_capture(response_contents, None) } @@ -1309,7 +1309,7 @@ pub(crate) fn spawn_mock_llm_tool_plan_then_invalid_final_reply( base_url } -fn final_tool_plan_response(response: impl Into) -> String { +pub(crate) fn final_tool_plan_response(response: impl Into) -> String { serde_json::json!({ "thinkingSummary": "已有工具观察足够,可以收束后台任务", "planUpdate": null, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 072b763de..48305afff 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -791,6 +791,10 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { assert!(agent_db.contains("\"agentId\":\"art-asset-plan\"")); assert!(agent_db.contains("测试图集保持整图,未生成独立切片。")); assert!(!agent_db.contains("editor-runtime-key")); + assert!(!agent_db.contains("idempotencyKey")); + assert!(!root + .join(".agent/runtime/canvas-generation-requests/art-asset-plan/art-generate-run.json") + .exists()); let canvas_requests = (0..8) .map(|_| { canvas_receiver diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b5962f16f..0c8bd3164 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3638,6 +3638,8 @@ - 决策:`PlatformSegmentedTabs` 继续承接首页 / 结果页剩余的横向 rail 与二选一切换;`RpgEntryHomeView.tsx` 的 discover channel bar、移动端 / 桌面端分类 chip rail,`CustomWorldEntityCatalog.tsx` 的 `RESULT_TABS` sticky rail,以及 `PlatformProfileRechargeModal.tsx` 的“泥点充值 / 会员卡”切换条已迁移。像 `CustomWorldEntityCatalog` 这种“标题 + count”内容直接走 `ReactNode label`;首页 / 创作入口 / 作品架 / 个人中心里稳定复用的频道下划线、创作 pill rail、二列 option segment 皮肤走 `PlatformSegmentedTabPresets`。同类切换在测试里应优先按 `role="tablist" / "tab"` 查询,而不是把它们继续当普通 button。 - 决策:简单泥点确认流的开关状态机统一收口到 `src/components/common/useMudPointConfirmController.ts`,只暴露 `open / requestOpen / close / confirm`,不持有点数、标题、描述或禁用态等业务字段;`PuzzleCreationWorkspace.tsx`、`Match3DCreationWorkspace.tsx` 与 `Match3DResultView.tsx` 的两个批量素材面板已接入。`PuzzleResultView.tsx` 和 `RpgCreationRoleAssetStudioModalImpl.tsx` 这类节奏不同或携带 pending payload 的场景继续保留本地状态机,避免把简单 hook 扩成泛型动作路由器。 - 决策:标准平台 modal header 的关闭入口继续统一到 `PlatformModalCloseButton variant="platformIcon"`;结果页 / 工具页重复的白底 portal 弹窗壳层收口到 `src/components/common/PlatformToolModalShell.tsx`,由它统一承接平台主题 overlay、白底 remap panel、标准 header/body/footer spacing、关闭按钮和遮罩 / Escape 关闭策略。`PuzzleResultView.tsx` 的关卡详情 / 发布弹窗、`Match3DResultView.tsx` 的封面 / 发布工具弹窗,以及 `PuzzleHistoryAssetPickerDialog.tsx` 的历史素材弹窗已迁移;`UnifiedModal` 新增 `ariaLabel` 支持可见标题动态、可访问名称固定的场景。像素风 runtime、drawer collapse、玩法规则面板和运行态 overlay 不跟这条线混收,继续保留局部 close 语义。 +- 决策:平台 portal 主题恢复下沉到 `UnifiedModal`;`portal=true` 默认从 `AuthUiContext` 注入当前 light / dark 主题,已显式给出主题的调用保留原选择,无 Provider 回退 light。`portalTheme="none"` 只用于全黑图片预览等完全自绘弹层,`portal=false` 仍使用原 DOM 主题作用域。图片信息、修改图片与画布快捷键弹窗在完整支持暗色样式前显式使用 `portalTheme="light"`,不将固定白底面板与暗色文本变量混用。共享业务壳不再重复读取 AuthUi 只为 portal 补 class,画布私有变量则继续通过 `ImageCanvasEditorPortal` 桥接。已退役玩法不因该底层修复恢复入口或维护范围。 +- 决策:`PlatformUtilityInfoModal` 未显式传主题时必须沿用 `UnifiedModal` 的 auto 主题,不在共享壳里默认锁定 light。`PublishShareModal` 跟随当前 light / dark 主题;`PlatformReportDialog` 因包含二维码 / 扫码展示区,显式固定 light 以保证白底对比度和识别率。 - 决策:平台入口的创作前置泥点阻断提示只在 `platform-entry` 局部抽成 `src/components/platform-entry/PlatformDraftGenerationPointNoticeDialog.tsx`,并使用 `DraftGenerationPointNotice` union(`insufficient-points` / `balance-load-failed`)承接业务真相;不要在 `common/` 再抽一个泛化 `BlockingNoticeDialog`,否则会把 `PlatformAcknowledgeStatusDialog` 的样式透传再包装一层而不缩小调用面。 - 决策:`PlatformAsyncStatePanel` 从 profile modal 扩展到作品架类白底 panel;`CustomWorldCreationHub.tsx` 的作品架主体现在也统一走 `loadingState / emptyState / children` 三段 slot,但 error + 重试继续留在业务层外侧,不把共享组件扩成“banner + retry + content”全能状态机。后续白底作品架或列表 panel 若只是互斥的 `loading / empty / content`,优先直接复用这套骨架。 - 决策:`CopyFeedbackButton.tsx` 的 `actionSurface` 分支继续收口到 `PlatformActionButton`,`pill` 分支继续保留 `PlatformPillBadge` 风格;复制反馈按钮不再直接调用 `getPlatformActionButtonClassName` 手拼平台按钮基础 chrome。后续同类“复制状态机 + 平台动作按钮”组合优先直接复用 `CopyFeedbackButton`,不要在业务页重新混写图标、文案、aria 和动作按钮 class。 @@ -5926,7 +5928,9 @@ ## 2026-07-31 External v1 生成统一异步并提供托管 MCP 与完整 Skill 包 - 异步契约:External v1 的图片生成、图片编辑、图标图集、UI 素材提取、角色动画、视频、音效和背景音乐八类 POST 固定持久化入 `external_generation_job` 并返回 HTTP `202 + operationId/statusUrl/pollAfterMs`;不受站内 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 影响。每次逻辑生成必须携带稳定 `Idempotency-Key`,网络结果未知或调用方轮询超时时复用原键和原 operationId,不得换键重提。 +- 发布窗口兼容:AI 游戏创作桌面客户端严格按 HTTP 状态分流生成首响应;旧服务 `200` 只作为已经完成且含可下载媒体的同步结果消费,旧图集允许从顶层 `spritesheetImageSrc` 换签下载且无效值不得遮蔽可用 `objectKey`;新服务 `202` 必须取得 `operationId` 后轮询,轮询间隔按 OpenAPI 限制在 `250..=5000ms`,其他 2xx 失败关闭。Runtime 在 POST 前原子持久化精确请求体、请求 SHA-256 与稳定幂等键,`202` 后先原子追加 `operationId` 并回读一致再查询;重启时 `accepted` 账本只恢复 GET,`prepared` 表示提交结果未知并禁止自动 POST。生成 POST 使用独立三十五分钟等待预算且不自动重提;game-chat 仍受父 run 五分钟总截止约束,但截止时若 `canvas.asset_generate` 已进入 executing,客户端与预览照常退出,Runtime 保留 pending action、provider batch、生成账本与 `needs-reconciliation`。响应丢失、旧 `200` 结果损坏、`202` 缺 operationId、轮询超时、状态损坏、透明派生失败或外部完成后的本地提交失败统一投影为不可自动重生的对账边界。非阻断 general warning 继续消费结果并与 `sliceWarning` 分别展示。权威 External v1 OpenAPI 仍只声明新异步 `202`,不把部署过渡兼容公开成正式双协议。 - 查询与结果:新增 owner-safe `GET /api/external/v1/generations/{operationId}`。`queued/running` 返回 phase/progress,`completed` 返回 compact 稳定 artifact 引用,`failed` 返回脱敏错误,跨 owner 按不存在处理。compact result 允许 objectKey、resource/asset ID、assetObjectId、尺寸、媒体类型、taskId 和告警;禁止完整 project/canvas、Data URL、Blob URL、临时 signed URL、内部 provider 原文和 lease/fencing 控制字段。 +- 客户端 durable 查询约束:私有生成账本同时绑定 base URL/API Key 配置指纹,指纹不一致不查询旧 operation。旧 `200` 兼容结果只持久恢复允许字段和安全媒体引用。operation 明确 failed 的账本保留到 pending observation 和 Provider batch 终态落盘后再清理。生成提交只有契约明确的 `400 / 401 / 403` 可判定为入队前拒绝并清理 prepared 账本;其它非成功状态一律保留账本进入对账。账本路径解析、扫描和删除逐级拒绝符号链接,非法控制路径失败关闭。 - MCP:新增托管 `/api/external/v1/mcp`,使用现有 External API Key Bearer 鉴权和无协议 session 的 Streamable HTTP JSON direct 模式。MCP tools 从同一 OpenAPI operation 形成并复用 External REST router;生成 tool 显式要求 `idempotencyKey`,另有统一任务查询 tool。MCP resources 提供使用说明、OpenAPI、Skill 入口 `SKILL.md` 和 `references/capability-routing.md`、`references/api-operations.md`、`references/authentication-and-safety.md`、`references/requests-and-outputs.md` 四篇稳定 reference;日后新增 reference 时必须同步新增独立 resource。MCP Agent 直接调用托管 tools,不安装 CLI,也不将脚本、测试或 workflow 暴露为 MCP resources。禁止开放内部 SpacetimeDB MCP、worker procedure、controller 或队列控制面。 - Agent 发现:新增公开 `agent-integration.json`、`skill/SKILL.md` 和 `skill.zip`。manifest 同时声明 MCP、OpenAPI、完整 Skill archive、SHA-256 和包内清单;archive 必须包含 `SKILL.md`、上述四篇 references、stdlib Python helper 和 `agents/openai.yaml` 七个声明文件,不能只提供 OpenAPI JSON,也不能包含 API Key、本机路径或个人配置。完整 `skill.zip` 只供不支持 MCP 或需要本地文件上传编排的 Agent 使用,不作为 MCP resource。 - 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index d2eebce7a..203f91101 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3579,8 +3579,8 @@ - 现象:项目库点击“重命名”后,标题、输入框和按钮仍显示,但弹窗面板及遮罩背景变透明,看起来像“改名界面的背景没了”。 - 原因:`UnifiedModal` 默认 portal 到 `document.body`;若业务入口只在页面内层继承 `platform-theme`,portal 根节点不会继承该容器的 CSS 变量。此时 `.platform-modal-shell` 的 `background: var(--platform-modal-fill)` 和 `.platform-overlay` 的背景声明都会失效。 -- 处理:平台白底工具弹窗优先复用 `PlatformToolModalShell`,由共享壳读取当前 `AuthUiContext.platformTheme`,并把 `platform-theme platform-theme--` 挂到 portal overlay;不要用硬编码白底掩盖主题变量缺失。必须直接使用 `UnifiedModal` 的特殊场景,也要在 `overlayClassName` 显式传递当前平台主题。 -- 验证:在 light / dark 主题下打开 portal 弹窗,断言 dialog 的 overlay 携带对应主题类,并在真实浏览器核对 panel 与遮罩的 computed background 均非透明。 +- 处理:`UnifiedModal` 在 `portal=true` 时默认把 `AuthUiContext.platformTheme` 注入 overlay,共享白底弹窗和直接调用都不应再手工拼接主题 class。完全自绘的黑底预览显式使用 `portalTheme="none"`;已明确固定主题的弹窗使用 `light` / `dark`;局部 CSS 仍固定白底且未完成暗色样式的弹窗,必须暂时显式固定 `light`,否则会出现白底白字或深浅样式混杂;`portal=false` 继续依赖原 DOM 主题作用域。裸 `createPortal` 若使用平台或画布 CSS 变量,必须改用相应的主题 portal 壳,不要用硬编码白底掩盖主题变量缺失。 +- 验证:在真实 `AuthUiContext.platformTheme="dark"` Provider 下打开 portal 弹窗,断言 auto 弹窗的 overlay 携带暗色主题类,固定浅色弹窗只携带浅色主题类,panel 与遮罩的 computed background 均非透明;同时断言 `portalTheme="none"` 的黑底预览不被平台 remap。 - 关联:`src/components/project/ProjectGalleryView.tsx`、`src/components/common/PlatformToolModalShell.tsx`、`src/components/common/UnifiedModal.tsx`。 ## 自主试玩失败后的修复责任不能同时落给总控和专业 Agent @@ -4027,7 +4027,9 @@ - 现象:生成提交发生客户端超时、连接中断或响应丢失后,调用方创建新的 `Idempotency-Key` 再提交一次;原任务其实已经入队,最终造成重复生成、重复扣费和重复画布 / 素材库写入。 - 原因:把“客户端没有收到结果”误判为“服务端没有受理”,又没有持久保留逻辑请求的幂等键和服务端返回的 `operationId`。托管 MCP 若绕过 External REST router 直接调用 worker 或 SpacetimeDB,也会形成第二套去重与状态语义。 -- 处理:一次逻辑生成只分配一个稳定幂等键;传输重试必须使用完全相同的请求体和原键。收到 `operationId` 后只查询 `/api/external/v1/generations/{operationId}`,调用方轮询超时不改变服务端任务状态。结果未知且尚未拿到 operationId 时也只用原键重试提交。MCP 生成工具必须把 `idempotencyKey` 映射到同一 REST header,并复用同一 External router、owner 和任务账本。 +- 处理:一次逻辑生成只分配一个稳定幂等键。桌面 Runtime 在 POST 前先把精确请求体、SHA-256 和幂等键原子写入私有生成账本并回读一致;收到 `202 + operationId` 后先把账本升级为 `accepted` 再轮询。重启时 `accepted` 只恢复 GET,`prepared`、响应丢失、`202` 缺 operationId、轮询超时和状态损坏都进入 `needs-reconciliation`,绝不自动 POST。game-chat 五分钟硬截止可以结束本轮、关闭预览和客户端,但 executing 的 `canvas.asset_generate` 必须保留 pending action、provider batch 与生成账本;旧 `200` 图集的 `spritesheetResource` 允许为空,此时只在顶层 `spritesheetImageSrc` 是有效下载引用时优先使用,否则回退可用 `objectKey`。`postprocess-failed-source-preserved` 进入不可自动重生的对账边界;其它 non-blocking warning 继续消费成功结果并单独展示。旧 `200` 兼容不改变权威 External v1 的异步契约。MCP 生成工具必须把 `idempotencyKey` 映射到同一 REST header,并复用同一 External router、owner 和任务账本。 +- 补充:不能把“accepted 分支里没有生成 POST”误当成 GET-only 恢复。若读取账本前仍重做项目/素材目录准备、输出路径预检或请求正文构造,恢复仍可能创建远端资源或在查询 operation 前失败。恢复必须直接使用 durable snapshot;清理必须最后删除 pending 身份锚点,活动 orphan 不得自动删除。完整恢复 future 还要在默认 Tokio worker 栈下验证,不能靠测试环境调大 `RUST_MIN_STACK` 掩盖栈溢出。 +- 加固:durable snapshot 必须绑定不含明文凭据的 base URL/API Key 配置指纹,配置漂移时连 GET 也必须阻断。accepted operation 明确 failed 也不能在 observation 持久化前删账本。旧 `200` durable result 只保留允许字段与安全 objectKey/相对路径,签名 URL、query/fragment 和未知字段不落盘。提交只有契约明确的 `400 / 401 / 403` 可证明未入队并清理 prepared 账本;超时、冲突、限流、网关错误及其它意外状态均保留账本进入对账。账本根目录、扫描和删除必须通过受控路径解析逐级拒绝符号链接,不能让项目内链接把清理目标指向项目外。 - 验证:覆盖“服务端已入队但提交响应丢失”后原键重试仍返回同一 operation、换 owner 不可见、查询最终只出现一份 completed result 和一次计费 / 写回;MCP 与 REST 对同一 owner、同一请求和同一键必须命中同一 operation。 - 关联:`server-rs/crates/api-server/src/external_generation.rs`、`server-rs/crates/api-server/src/external_mcp.rs`、`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`。 diff --git a/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md index 9e7d27f4c..95936bc0b 100644 --- a/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md +++ b/docs/technical/【前端架构】PlatformUiKit弹窗组件收口计划-2026-06-08.md @@ -283,6 +283,7 @@ 19.3.53. 认证入口白底弹窗壳层收口到 `src/components/auth/PlatformAuthModalShell.tsx`;该 Module 只承接平台主题 overlay、`platform-auth-card`、标准标题栏、关闭按钮、点击遮罩关闭和禁用 Escape 的认证弹窗策略,不持有短信 / 密码登录、重置密码、邀请码规范化、法律协议或错误状态。`LoginScreen.tsx` 与 `RegistrationInviteModal.tsx` 已接入,业务组件只保留表单状态与提交流程。后续认证域新增同形态白底弹窗时优先复用该壳层;账号安全详情和绑定手机号这类布局差异较大的卡片先独立评估,不把 auth shell 扩成万能认证容器。验证命令:`npx vitest run src/components/auth/PlatformAuthModalShell.test.tsx src/components/auth/AuthGate.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 19.3.54. 账号 / 运行态 / onboarding 这轮继续分场景收口:`AccountModal.tsx` 的设置入口外层 overlay 与 auth card 壳层复用 `PlatformAuthModalShell`,并通过 `overlaySpacing`、`overlayStyle`、`showHeader` 和尺寸透传保留账号弹窗的 safe-area 与 direct account 唯一 dialog 语义;拼图运行态新增 `src/components/puzzle-runtime/PuzzleRuntimeModalShell.tsx`,只在 `puzzle-runtime` 内承接道具确认、设置、退出改造提示、失败弹窗和通关结算的 overlay / dialog / footer / button 骨架,原图查看、拖拽 ghost、飞行动画和全屏 runtime 容器不纳入 modal 收口;抓大鹅与跳一跳结算弹窗分别在 `Match3DRuntimeShell.tsx` 和 `JumpHopRuntimeShell.tsx` 内提取本地结算壳层 / summary / actions,保留玩法视觉身份;拼图 onboarding 首屏继续保留沉浸式全屏体验,只把登录保存覆盖层迁入 `UnifiedModal`,保持无关闭按钮、禁用遮罩关闭和禁用 Escape。后续 runtime 专属弹窗优先先抽玩法目录内薄壳;只有出现跨玩法稳定同构接口时再上升到 `common/`,不要把 `PlatformToolModalShell` 强行套到像素 / 游戏运行态 overlay。验证命令:`npm run test -- src/components/auth/AccountModal.test.tsx src/components/auth/PlatformAuthModalShell.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 19.3.55. 拼图 / 拼消消运行态的剩余阻断层继续按玩法目录局部收口:`src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleRuntimeBlockingOverlay.tsx` 只承接平台入口里拼图“正在准备下一关”的短暂阻断层,继续复用 `UnifiedModal` 的遮罩、dialog 语义和关闭禁用策略,但不把这类运行态等待面板直接提升到 `common/`;`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx` 则在玩法目录内新增 `PuzzleClearRuntimeOverlayShell`、`PuzzleClearRuntimePendingOverlay` 与 `PuzzleClearRuntimeSettlementDialog`,把 `!activeRun` 的等待层和 `level_cleared / finished / level_failed` 的结算层统一成一条本地结构线,同时保留拼消消自己的视觉和动作分流。拖拽 ghost、swap flight、补牌 / 消除动画、全屏 runtime 容器和其它强玩法视觉层不算旧 modal 债务,不跟这条线混收。验证命令:`npm run test -- src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleRuntimeBlockingOverlay.test.tsx src/components/platform-entry/PlatformEntryFlowShellImpl.test.ts src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +19.3.56. `UnifiedModal` 默认在 `portal=true` 时读取 `AuthUiContext.platformTheme`,并把 `platform-theme platform-theme--` 注入 portal overlay,从底层保证 `--platform-modal-fill`、`--platform-overlay-fill` 等 CSS 变量不因挂到 `document.body` 而丢失。新增 `portalTheme="auto|light|dark|none"`:默认 `auto` 优先保留调用方已给出的明确主题,否则使用当前主题并在无 Provider 时回退 light;纯黑底图片 / 素材预览使用 `none` 保持完全自绘;`portal=false` 不改变原 DOM 继承。`PlatformToolModalShell`、`PlatformDangerConfirmDialog`、认证与工具信息壳不再重复维护 portal 主题桥,但继续保留各自的 panel、间距、按钮和层级语义。编辑器裸 portal 菜单应复用 `ImageCanvasEditorPortal` 同时获得平台与画布变量;图片信息、修改图片和画布快捷键三个面板仍使用固定浅色 CSS,在完整支持暗色样式前显式传入 `portalTheme="light"`;已退役玩法目录不因本次收口重新进入维护范围。验证命令:`npm run test -- src/components/image-editor/UnifiedModalPortalTheme.test.tsx src/components/image-editor/ImageCanvasShortcutDialogView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 19.3. creative-agent 首页的侧边栏菜单、账号入口、开启新对话、我的创作、首页激励 CTA 和 prompt suggestion 按钮迁移到 `PlatformIconButton` / `PlatformActionButton`;首页继续保留 `creative-agent-home__*` 本地 class 承接透明顶栏、抽屉和品牌化胶囊视觉,不把视觉回收和语义收口绑成一次大改。`Beta` 徽标和历史记录纯文本行暂保留本地实现,等出现更多同构轻量列表行后再评估是否抽新的共享 row primitive。 19.4. 大鱼吃小鱼结果页 hero 的返回入口迁移到 `PlatformIconButton variant="darkMini"`,测试 / 发布动作迁移到 `PlatformActionButton surface="editorDark"`;结果页只保留测试运行、发布提交和文案状态语义,不再手写 hero 顶栏按钮壳。 19.4.1. 大鱼吃小鱼结果页的发布失败弹层迁移到 `src/components/common/PlatformStatusDialog.tsx`;`PlatformStatusDialog` 补充自定义图标、可访问标签和动作按钮样式透传后,`BigFishResultView` 不再保留 `BigFishResultErrorModal` 内联的 `UnifiedConfirmDialog + PlatformIconBadge` 组合。结果页只保留失败文案和关闭回调,发布失败的状态图标、遮罩、白底面板和“知道了”主动作统一由共享状态弹层承接。验证命令:`npm run test -- src/components/common/PlatformStatusDialog.test.tsx src/components/big-fish-result/BigFishResultView.test.tsx`、`npm run typecheck`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 605d98f1d..ddb278e0a 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -259,7 +259,9 @@ Agent Runtime 负责: - 2026-07-10 补充:后台任务工具箱已加入 `project.restore`。Agent 可在 diff 或自检发现本轮修改走偏后请求恢复到指定 checkpoint;Runtime 复用 `project.restore` 权限策略和项目写锁,observation 只返回 checkpoint id、恢复文件数和删除文件数,不返回本机绝对路径。默认确认策略下不会静默回滚用户项目。 - 2026-07-10 补充:单 Agent 聊天和后台 planning prompt 会读取同一个 Agent 的 Runtime 连续上下文,把本 Agent 最近 status / phase / runId / 当前任务 / 下一步、最近回复、计划、观察、最近 3 条工具动作、最近事件、最近 3 条任务记录和工具策略摘要带入下一轮推理;上下文按规范 taskId 隔离,不读取其他 Agent 的 runtime 文件,并在进入 prompt 前过滤密钥和本机绝对路径。新后台 run 启动时会继承本 Agent 上次 `recentToolCalls` 和 `lastResponse`,让多轮任务不丢失结构化行动证据。 - 2026-07-10 补充:后台任务工具箱已加入 `preview.start`。Agent 可在 loop 中自行请求启动当前项目的本地 HTTP 预览;Runtime 会复用 `preview.start` 策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑,并把 `agent.runtime.preview.start` 写入 `.agent/agent.db`。该 observation 只向 LLM 返回 localhost URL 与端口,不返回用户项目绝对路径。 -- 2026-07-10 补充,2026-07-31 收紧:后台任务工具箱提供 `canvas.asset_generate`。Agent 在 loop 中给出素材 prompt、`outputPath`、比例、尺寸、kind 与展示名;Runtime 通过 AppData / Tauri 配置里的 `editorApi` 调用 External Editor API。生成前按本地项目名称创建或复用同名画布项目和同名素材库目录,请求必须携带 `projectId + assetFolderId + canvasCompletion`,生成结果同时进入平台画布、平台素材库和本地项目。canonical 视觉 DAG 固定为:`art-director` 通过 `POST /api/external/v1/editor/images/generations` + `kind=spec` 生成 `assets/art-spec.png`;`design-foundation` 精确引用该 resourceId,通过同一路由 + `kind=ui-design` 生成 `assets/ui-prototype.png`;`art-asset-plan` 使用同一 resourceId 和具体 `iconDescriptions`,通过 `POST /api/external/v1/editor/icon-spritesheets/generations` 生成真实透明的 `assets/art-spritesheet.png`。每次生成提交必须携带稳定 `Idempotency-Key`,持久保留返回的 `operationId`,按 `pollAfterMs` 查询 `/api/external/v1/generations/{operationId}`;只有 `completed` 才消费 compact result 并换签下载,客户端超时或结果未知时不得换键重提。UI extraction 只处理已有带标注 UI 图,不属于这条 DAG;图集不得回退到普通生图。UI 原型 prompt、`generationInputs.artSpec` 和 `ui-prototype.v2` 验收必须从当前项目玩法合同提取 HUD、可玩区域、关键实体、操作、失败/重开与移动布局,禁止预设塔防或补入合同中不存在的卡牌、波次、敌人入口。canonical UI 原型固定请求 `2K + 16:9`。旧正式图不合格时,普通原合同只能返回 `needs-repair`;Supervisor 认领后仅可签发一次完整继承原合同的 repair,由原 owner 使用 `replaceExisting=true` 原位替换,禁止先删除正式图。completed result 含 `warning.code=postprocess-failed-source-preserved` 或对应媒体不含任意 `alpha < 255` 时不得登记为正式透明图集;仅有 `sliceWarning` 时可保留完整透明图,但不宣称已有独立切片。本地 manifest 持久生成 route、kind、operationId 与精确参考 resourceId;登记失败时删除本轮刚写入的新文件。API Key 和幂等键不进入 observation、manifest、agent.db 或日志。 +- 2026-07-10 补充,2026-07-31 收紧,2026-08-03 增加发布窗口兼容与 durable 生成账本:后台任务工具箱提供 `canvas.asset_generate`。Agent 在 loop 中给出素材 prompt、`outputPath`、比例、尺寸、kind 与展示名;Runtime 通过 AppData / Tauri 配置里的 `editorApi` 调用 External Editor API。生成前按本地项目名称创建或复用同名画布项目和同名素材库目录,请求必须携带 `projectId + assetFolderId + canvasCompletion`,生成结果同时进入平台画布、素材库和本地项目。canonical 视觉 DAG 固定为:`art-director` 通过 `POST /api/external/v1/editor/images/generations` + `kind=spec` 生成 `assets/art-spec.png`;`design-foundation` 精确引用该 resourceId,通过同一路由 + `kind=ui-design` 生成 `assets/ui-prototype.png`;`art-asset-plan` 使用同一 resourceId 和具体 `iconDescriptions`,通过 `POST /api/external/v1/editor/icon-spritesheets/generations` 生成真实透明的 `assets/art-spritesheet.png`。Runtime 在 POST 前把精确请求体、SHA-256 与稳定 `Idempotency-Key` 原子写入 `.agent/runtime/canvas-generation-requests/` 私有账本并回读一致;正式新契约收到 HTTP `202` 后先原子追加 `operationId`,再按限制到 `250..=5000ms` 的 `pollAfterMs` 查询统一状态端点。重启时 `accepted` 账本只恢复 GET,`prepared` 代表提交结果未知并进入人工对账,绝不自动 POST。桌面客户端在滚动发布窗口内仍按 HTTP 状态兼容旧同步 `200` 完整结果;旧图集只在 `spritesheetImageSrc` 是有效下载引用时优先使用,否则回退 `objectKey`。生成 POST 使用独立三十五分钟等待预算,game-chat 仍受父 run 五分钟总截止约束。截止时普通本地动作按失败清理;若 `canvas.asset_generate` 已进入 executing,则结束本轮并关闭预览和客户端,但保留 pending action、provider batch、生成账本与 `needs-reconciliation`。旧 `200` 结果损坏、`202` 缺 operationId、响应丢失、轮询超时、状态损坏、透明派生失败或外部完成后的本地提交失败均进入不可自动重生的对账边界。`postprocess-failed-source-preserved` 不得登记为透明图集或自动重试;其它 general warning 保持 completed 并与 `sliceWarning` 分别展示。本地 manifest 只在生成完成后持久化 generation route、kind、服务端 taskId 与精确参考 resourceId。UI extraction 只处理已有带标注 UI 图,不属于这条 DAG;图集不得回退到普通生图。UI 原型 prompt、`generationInputs.artSpec` 和 `ui-prototype.v2` 验收必须从当前项目玩法合同提取 HUD、可玩区域、关键实体、操作、失败/重开与移动布局,禁止预设塔防或补入合同中不存在的卡牌、波次、敌人入口。canonical UI 原型固定请求 `2K + 16:9`。旧正式图不合格时,普通原合同只能返回 `needs-repair`;Supervisor 认领后仅可签发一次完整继承原合同的 repair,由原 owner 使用 `replaceExisting=true` 原位替换,禁止先删除正式图。API Key 不进入项目文件;幂等键只进入受权限约束的私有生成账本,不进入 observation、manifest、agent.db 或日志。 +- 2026-08-03 durable 恢复补充:`accepted / legacy-completed` 恢复必须先从私有账本读取持久化的画布 ID、素材目录 ID、画布名、生成提示词、route、kind 与引用资源,再查询既有 operation;不得在读取账本前重建请求、重新列举或创建远端项目/目录,也不得让本地输出路径漂移挡住 operation GET。恢复执行和后续 continuation 使用独立 Tokio task 栈边界,同时继续持有原 Agent lock。终态清理固定先删 generation / parallel 附属 sidecar,最后删 pending 身份锚点;历史孤儿只有所属任务已明确 completed/cancelled 时可自动清理,活动、未知或 `needs-reconciliation` orphan 必须保留并失败关闭。 +- 2026-08-03 durable 恢复加固:生成账本还必须绑定归一化 base URL 与 API Key 哈希组成的配置指纹,当前 External Editor 服务或租户身份变更时禁止查询旧 operation。旧同步 `200` 结果只持久恢复必需的允许字段;绝对 signed URL、query/fragment 和未知扩展字段不得进入项目账本,只有安全相对路径或 objectKey 可作为 durable 下载引用。accepted operation 明确 failed 时也保留账本,直到 pending observation 和 Provider batch 成员终态持久化后再按统一清理链删除。生成提交只有契约明确的 `400 / 401 / 403` 可视为入队前拒绝并清理 prepared 账本;其它非成功状态保留账本进入对账。生成账本根目录、扫描与删除使用受控路径解析逐级拒绝符号链接,非法控制路径失败关闭。独立恢复任务异常必须落盘 task queue、state、event 和 agent.db 对账阻断,公共记录不得复制未脱敏 panic payload。 - 2026-07-10 补充:后台任务工具箱已加入 `task.list`。Agent 可在 loop 中读取 manifest 任务图、每个 seed task 的状态 / 依赖 / 产物交接,以及按依赖计算的 `readyTaskIds`;Runtime 复用 `task.list` 项目权限策略,策略要求确认或拒绝时只返回策略 observation,不向 LLM 暴露任务图细节。 - 2026-07-10 补充:后台任务工具箱已加入 `task.update`。Agent 可在 loop 中把 manifest 种子任务状态更新为 `pending / running / waiting-for-confirmation / completed / failed`,用于表达长期后台任务的当前进度;Runtime 复用 `task.update` 策略和项目写锁,实际只修改 `.agent/manifest.json` 中已有 taskId 的 `status`,并写入 `agent.runtime.task.update` 审计记录。策略要求确认或拒绝时不会修改 manifest,也不会创建新任务。 - 2026-07-10 补充:后台任务工具箱已加入 `file.list`。Agent 可在 loop 中自行列出项目文件摘要或某个相对目录下的文件摘要,再决定是否继续读取具体文件;Runtime 复用 `file.list` 项目权限策略,observation 只包含项目相对路径、类型和大小,不读取文件内容、不返回项目绝对路径。 @@ -346,7 +348,7 @@ game-project/ - `canvas.project_open` 只打开本机 Genarrative 编辑器的 `/editor/canvas?projectid=...`,默认地址为 `http://127.0.0.1:3000`,开发者可在开发窗口改成本机端口;不允许打开远程站点或任意 URL。 - 画板资源回流到本地项目 `assets/`,并在 manifest 中记录画板项目、资源 ID、assetObjectId、prompt、model、taskId 和 assetKind;当前最小落地提供 `asset.register` 登记项目内已有资产,并提供 `canvas.export_import` 读取现有画板素材导出 ZIP。 - `canvas.project_sync` 复用 Genarrative External Editor API,读取用户平台 API Key 可访问的画板项目快照,通过 `/api/external/v1/assets/read-url` 换签并把资源下载到本地项目 `assets/canvas-sync/`;默认 API base URL 为 `http://127.0.0.1:8082`,可用 Tauri 应用配置目录中的 `game-creator.config.json` 的 `editorApi.baseUrl` 覆盖,API Key 从同一配置的 `editorApi.apiKey` 读取,不写入项目文件、trace、manifest 或日志。 -- Agent loop 中美术组 `Asset` 和音乐组 `SFX` 会读取 `.agent/manifest.json`;图片生成先通过 External Editor API 项目与素材库接口准备同名画布会话,再带稳定 `Idempotency-Key` 调用 `/api/external/v1/editor/images/generations`,请求携带 `projectId`、`assetFolderId`、`assetLabel`、`generationInputs.artSpec` 和 `canvasCompletion`。Runtime 必须保存返回的 `operationId` 并按 `pollAfterMs` 查询统一状态端点;completed 后从 compact result 取得稳定 objectKey/resourceId,再通过 `/api/external/v1/assets/read-url` 换签下载到受控本地 `assets/` 路径,登记为 `canvas` 来源资产并追加 `canvas.asset_generate` 本地索引记录。API Key 和幂等键不写入项目文件、agent.db、trace、manifest 或日志;operationId 只作为该生成动作的可恢复身份保存。未配置 Key、查询 failed 或 compact result 缺少稳定媒体引用时,图片产物型任务保持阻塞/失败,不能以文字计划完成。音乐组仍只建议同步已有音频资源,不调用图片生成接口。 +- Agent loop 中美术组 `Asset` 和音乐组 `SFX` 会读取 `.agent/manifest.json`;图片生成先通过 External Editor API 项目与素材库接口准备同名画布会话,再带稳定 `Idempotency-Key` 调用生成端点。Runtime 在私有生成账本持久化精确请求、幂等键和返回的 `operationId`,并按 `pollAfterMs` 查询统一状态端点;completed 后从 compact result 取得稳定 objectKey/resourceId,再通过 `/api/external/v1/assets/read-url` 换签下载到受控本地 `assets/` 路径,登记为 `canvas` 来源资产并追加 `canvas.asset_generate` 本地索引记录。API Key 不写入项目文件;幂等键只作为该动作的私有可恢复身份保存,不进入 agent.db、trace、manifest、observation 或日志;operationId 允许出现在脱敏的对账错误与私有账本中,但不进入 manifest。未配置 Key、查询 failed 或 compact result 缺少稳定媒体引用时,图片产物型任务保持阻塞/失败,不能以文字计划完成。音乐组仍只建议同步已有音频资源,不调用图片生成接口。 - `canvas.asset_import` 当前作为最小真实链路:导入项目目录内已有文件为 `canvas` 来源资产,并要求记录画板项目 ID 以及 resourceId 或 assetObjectId。 - 项目工作台点击已登记图片时必须在客户端资源浮层中直接渲染图片,而不是只展示路径与 MIME。图片通过受控 Tauri 命令从项目 `assets/` / `game/` 读取,只允许 manifest 已登记资产或已完成任务产物,并复用 `file.read` auto 权限、图片魔数、文件大小、像素尺寸、普通文件、路径漂移和符号链接校验后以 data URL 返回;首版只支持 PNG、JPEG、WEBP,不向 WebView 暴露任意本机文件协议或绝对路径。 - `canvas.export_import` 复用 `/editor/canvas` 已有素材导出 ZIP 格式,读取根 `metadata.json`、复制 `images/` / `media/` / `sequences/` 到本地项目 `assets/canvas-imports/`,再按导出层登记为 `canvas` 来源资产;导出包不保存真实 resourceId 时,使用 `canvas-export:` 作为可追踪 assetObjectId,不伪造后端资源行。 diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index 2499ad946..4a1c4c217 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -65,6 +65,7 @@ - Enter 发送必须同时排除 `isComposing` 和旧 Safari / WebKit 候选词确认事件的 `keyCode === 229`,避免输入法选词时误发送。 - 用户消息必须包含去除首尾空白后的非空文本;附件只能随文本消息发送,前端发送门禁与后端 `module-editor-agent` 领域校验必须同时拒绝纯附件消息。 - 会话管理入口在对话框头部:当前会话标题 + 历史会话下拉(按更新时间倒序)+ 新建对话按钮,全部包在对话框内。 +- 右上角删除当前对话的危险确认框继续使用 `PlatformDangerConfirmDialog`;其 portal 主题由 `UnifiedModal` 统一恢复,panel 使用 `platform-remap-surface`,且层级与附件选择弹窗一致,避免背景透明、错色或被画布控件遮挡。 - 当前会话没有任何已发送消息时,新建对话按钮置灰且不可点击;输入框草稿和未发送附件不算会话内容。当前会话已有消息时可新建,新建成功后只切换到返回的空白会话,输入文字、附件及附件选择状态与切换历史会话时一样原样保留,旧会话继续保留在历史会话下拉中;创建失败同样不修改草稿。 - 新会话创建请求 pending 时禁用历史会话下拉和发送动作,但输入框与附件仍可编辑;会话列表或历史消息加载期间同样禁用发送。表单提交处理器必须复用相同门禁,不能先清空草稿再由 hook 静默跳过发送。 - 快速切换会话或会话轮询刷新产生并发详情请求时,每个请求必须获得唯一且单调递增的请求序号;前端只允许最后发起且有权生效的请求更新当前会话、消息、错误和加载态。被正在进行的会话切换压制的旧会话 refresh 不得提前结束新切换的加载态,旧响应也不得覆盖用户最新选择。 @@ -78,7 +79,7 @@ - 「素材库」页签:账号级素材库(复用 `ImageCanvasAssetLibrary` 数据源); - 多选 + 底部「取消 / 应用」。 - 网格末尾上传格为后续补齐项;在上传格未落地前,对话附件只从已有画布资源和账号素材库选择。后续若从对话入口上传图片,必须复用素材库 / 画布资源登记链路,不新增对话私有图片类型。 -- 附件选择弹窗使用 `PlatformToolModalShell` 承接 portal 主题变量和不透明 panel 背景;不能直接把未注入 `platform-theme` 的 `UnifiedModal` portal 到 `document.body`,否则 `--platform-modal-fill` 失效后面板会变透明。 +- 附件选择弹窗使用 `PlatformToolModalShell` 承接白底 panel 和标准间距;底层 `UnifiedModal` 会把当前 `platform-theme` 自动注入 portal overlay,保证 `--platform-modal-fill` 在 `document.body` 下仍有效。 - 应用后附件以胶囊 chip 挂在输入框上方;发出的消息内附件渲染为纯文本胶囊 chip(名称 + 小图标),**默认无缩略图,鼠标悬浮才浮出缩略图预览**。 - 附件领域形状:统一为画布资源 / 素材库对象引用(`resourceId` / `assetId` + 可选 `objectKey`),不存在只属于对话的第三种图;单条消息上限 9 张(前后端共同校验)。前端可携带展示用 `imageSrc` / `thumbnailSrc`,后端必须按当前工程和当前账号重新归一、校验归属与 `objectKey`。 - 附件 `label` 是人类可读的展示元数据,统一限制为最多 24 个 Unicode 码点。归一化时先去掉首尾空白,删除控制字符以及除 `-`、`_`、`.` 之外的 ASCII 标点,把连续空白折叠为一个半角空格,再按 24 码点截断;只含被过滤字符的 label 视为缺失。中文等非 ASCII 标点不属于本轮过滤范围。 diff --git a/src/components/auth/PlatformAuthModalShell.tsx b/src/components/auth/PlatformAuthModalShell.tsx index e7ce21e21..c6c719a7e 100644 --- a/src/components/auth/PlatformAuthModalShell.tsx +++ b/src/components/auth/PlatformAuthModalShell.tsx @@ -57,9 +57,10 @@ export function PlatformAuthModalShell({ closeOnEscape={false} size={size} showHeader={showHeader} + portalTheme={platformTheme} zIndexClassName={zIndexClassName} overlayClassName={joinClassNames( - `platform-theme platform-theme--${platformTheme} text-[var(--platform-text-strong)]`, + 'text-[var(--platform-text-strong)]', overlaySpacing === 'default' && '!px-3 !py-4 sm:!p-4', overlayClassName, )} diff --git a/src/components/common/CreativeImageInputPanel.test.tsx b/src/components/common/CreativeImageInputPanel.test.tsx index 052491da1..cf0fa8ab9 100644 --- a/src/components/common/CreativeImageInputPanel.test.tsx +++ b/src/components/common/CreativeImageInputPanel.test.tsx @@ -289,6 +289,7 @@ test('creative image input panel confirms before removing uploaded image', () => fireEvent.click(screen.getByRole('button', { name: '移除拼图图片' })); const dialog = screen.getByRole('dialog', { name: '移除拼图图片?' }); + expect(dialog.parentElement?.className).toContain('platform-theme--light'); expect(within(dialog).getByText('移除后需要重新上传图片。')).toBeTruthy(); fireEvent.click(within(dialog).getByRole('button', { name: '移除' })); expect(onMainImageRemove).toHaveBeenCalledTimes(1); diff --git a/src/components/common/LegalDocumentModal.tsx b/src/components/common/LegalDocumentModal.tsx index f0291cd60..d6c6e93e2 100644 --- a/src/components/common/LegalDocumentModal.tsx +++ b/src/components/common/LegalDocumentModal.tsx @@ -88,9 +88,7 @@ export function LegalDocumentModal({ size="md" closeLabel="关闭法律信息" zIndexClassName={zIndexClassName ?? 'z-[150]'} - overlayClassName={`platform-theme ${ - platformTheme ? `platform-theme--${platformTheme}` : '' - }`} + portalTheme={platformTheme ?? 'auto'} panelClassName="platform-remap-surface rounded-t-[1.4rem] sm:rounded-[1.4rem]" headerClassName="items-center" bodyClassName="px-4 py-0 sm:px-5" diff --git a/src/components/common/PlatformDangerConfirmDialog.test.tsx b/src/components/common/PlatformDangerConfirmDialog.test.tsx index 9db88d4c3..f52395f95 100644 --- a/src/components/common/PlatformDangerConfirmDialog.test.tsx +++ b/src/components/common/PlatformDangerConfirmDialog.test.tsx @@ -24,6 +24,10 @@ test('renders a standard danger confirmation with cancel and confirm actions', ( const dialog = screen.getByRole('dialog', { name: '删除作品' }); + expect(dialog.parentElement?.className).toContain('platform-theme--light'); + expect(dialog.parentElement?.className).toContain('z-[140]'); + expect(dialog.className).toContain('platform-remap-surface'); + expect(dialog.className).toContain('shadow-[0_24px_80px_rgba(0,0,0,0.55)]'); expect(within(dialog).getByText('确认删除《潮雾列岛》吗?')).toBeTruthy(); expect(within(dialog).getByText('删除后不可恢复。')).toBeTruthy(); diff --git a/src/components/common/PlatformDangerConfirmDialog.tsx b/src/components/common/PlatformDangerConfirmDialog.tsx index 391bed0df..86e18ac29 100644 --- a/src/components/common/PlatformDangerConfirmDialog.tsx +++ b/src/components/common/PlatformDangerConfirmDialog.tsx @@ -21,8 +21,13 @@ type PlatformDangerConfirmDialogProps = { panelClassName?: string; footerClassName?: string; confirmClassName?: string; + zIndexClassName?: string; }; +function joinClassNames(...classNames: Array) { + return classNames.filter(Boolean).join(' '); +} + /** * 平台危险确认弹窗。 * 统一承接需要“确认 / 取消 + 危险主动作”语义的标准弹窗壳层。 @@ -46,6 +51,7 @@ export function PlatformDangerConfirmDialog({ panelClassName, footerClassName, confirmClassName, + zIndexClassName = 'z-[140]', }: PlatformDangerConfirmDialogProps) { return ( {children} diff --git a/src/components/common/PlatformImagePreviewModal.test.tsx b/src/components/common/PlatformImagePreviewModal.test.tsx index c09d35b2c..52a80d5cb 100644 --- a/src/components/common/PlatformImagePreviewModal.test.tsx +++ b/src/components/common/PlatformImagePreviewModal.test.tsx @@ -103,6 +103,7 @@ test('renders full-screen image preview with zoom controls and dark backdrop', ( expect( dialog.parentElement?.className.includes('!bg-black'), ).toBe(true); + expect(dialog.parentElement?.className).not.toContain('platform-theme--'); expect(screen.getByRole('button', { name: '放大图片' })).toBeTruthy(); expect(screen.getByRole('button', { name: '缩小图片' })).toBeTruthy(); expect(screen.getByRole('button', { name: '重置图片缩放' })).toBeTruthy(); diff --git a/src/components/common/PlatformImagePreviewModal.tsx b/src/components/common/PlatformImagePreviewModal.tsx index 8b9c2cf57..ab29a5087 100644 --- a/src/components/common/PlatformImagePreviewModal.tsx +++ b/src/components/common/PlatformImagePreviewModal.tsx @@ -243,6 +243,8 @@ export function PlatformImagePreviewModal({ showHeader={false} showCloseButton={false} size="fullscreen" + // 全黑自绘查看器不需要平台变量;避免 light / dark remap 改写黑底、白字和暗色控件。 + portalTheme="none" zIndexClassName={zIndexClassName} overlayClassName="!items-stretch !justify-stretch !bg-black !p-0 !backdrop-blur-none" panelClassName="platform-image-preview-modal !h-[100dvh] !max-h-none !max-w-none !rounded-none border-0 bg-black text-white shadow-none" diff --git a/src/components/common/PlatformReportDialog.test.tsx b/src/components/common/PlatformReportDialog.test.tsx index b6243ef07..993fa47cf 100644 --- a/src/components/common/PlatformReportDialog.test.tsx +++ b/src/components/common/PlatformReportDialog.test.tsx @@ -10,6 +10,10 @@ vi.mock('../../services/clipboard', () => ({ copyTextToClipboard: vi.fn(), })); +vi.mock('../auth/AuthUiContext', () => ({ + useAuthUi: () => ({ platformTheme: 'dark' }), +})); + afterEach(() => { vi.clearAllMocks(); }); @@ -31,6 +35,8 @@ test('renders report fields and copies the joined report lines', async () => { ); const dialog = screen.getByRole('dialog', { name: '统一报告' }); + expect(dialog.parentElement?.className).toContain('platform-theme--light'); + expect(dialog.parentElement?.className).not.toContain('platform-theme--dark'); expect(within(dialog).getByText('拼图草稿 puzzle-session-1')).toBeTruthy(); expect(within(dialog).getByText('已完成')).toBeTruthy(); diff --git a/src/components/common/PlatformReportDialog.tsx b/src/components/common/PlatformReportDialog.tsx index 8aaedf52f..92cc58bbc 100644 --- a/src/components/common/PlatformReportDialog.tsx +++ b/src/components/common/PlatformReportDialog.tsx @@ -54,6 +54,8 @@ export function PlatformReportDialog({ open={open} title={title} onClose={onClose} + // 报告中的二维码 / 扫码区固定使用浅色背景,保证对比度和识别率。 + platformTheme="light" overlayClassName={overlayClassName} panelClassName={panelClassName} bodyClassName="space-y-3" diff --git a/src/components/common/PlatformToolModalShell.tsx b/src/components/common/PlatformToolModalShell.tsx index ea069b80c..dbfb60907 100644 --- a/src/components/common/PlatformToolModalShell.tsx +++ b/src/components/common/PlatformToolModalShell.tsx @@ -1,6 +1,5 @@ import type { ReactNode } from 'react'; -import { useAuthUi } from '../auth/AuthUiContext'; import { UnifiedModal } from './UnifiedModal'; type PlatformToolModalShellProps = { @@ -50,9 +49,6 @@ export function PlatformToolModalShell({ bodyClassName, footerClassName, }: PlatformToolModalShellProps) { - const resolvedPlatformTheme = - useAuthUi()?.platformTheme ?? 'light'; - return ( ({ + useAuthUi: () => ({ platformTheme: 'dark' }), +})); + test('renders platform utility info modal shell with default platform styling', () => { render( ({ copyTextToClipboard: vi.fn(), })); +vi.mock('../auth/AuthUiContext', () => ({ + useAuthUi: () => ({ platformTheme: 'dark' }), +})); + const payload: PublishShareModalPayload = { title: '暖灯猫街', publicWorkCode: 'PZ-00000001', @@ -119,7 +123,7 @@ describe('PublishShareModal', () => { const dialog = screen.getByRole('dialog', { name: '分享给朋友' }); expect(dialog.parentElement?.className).toContain('!items-center'); - expect(dialog.parentElement?.className).toContain('platform-theme--light'); + expect(dialog.parentElement?.className).toContain('platform-theme--dark'); expect(dialog.className).toContain('platform-modal-shell'); expect(dialog.className).toContain('rounded-[1.75rem]'); expect(dialog.getAttribute('style')).toBeNull(); diff --git a/src/components/common/PublishShareModal.tsx b/src/components/common/PublishShareModal.tsx index 16e193aa6..eac1385f4 100644 --- a/src/components/common/PublishShareModal.tsx +++ b/src/components/common/PublishShareModal.tsx @@ -10,7 +10,6 @@ import { openHostShare, openHostShareGrid, } from '../../services/host-bridge/hostBridge'; -import { useAuthUi } from '../auth/AuthUiContext'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { PlatformUtilityInfoModal } from './PlatformUtilityInfoModal'; import { downloadPublishShareCardImage } from './publishShareCardImage'; @@ -77,7 +76,6 @@ export function PublishShareModal({ payload, onClose, }: PublishShareModalProps) { - const platformTheme = useAuthUi()?.platformTheme ?? 'light'; const [copyState, setCopyState] = useState('idle'); const [downloadState, setDownloadState] = useState('idle'); const [gridState, setGridState] = useState('idle'); @@ -233,7 +231,6 @@ export function PublishShareModal({ open={open && Boolean(payload)} title="分享给朋友" onClose={onClose} - platformTheme={platformTheme} panelClassName="rounded-[1.75rem]" footerClassName="border-t-0 px-4 pb-5 pt-0 sm:px-5" footer={ diff --git a/src/components/common/UnifiedConfirmDialog.tsx b/src/components/common/UnifiedConfirmDialog.tsx index 8bc3fb4b0..a6f59d718 100644 --- a/src/components/common/UnifiedConfirmDialog.tsx +++ b/src/components/common/UnifiedConfirmDialog.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; import { PlatformActionButton } from './PlatformActionButton'; -import { UnifiedModal } from './UnifiedModal'; +import { UnifiedModal, type UnifiedModalPortalTheme } from './UnifiedModal'; type UnifiedConfirmDialogTone = 'primary' | 'danger'; @@ -24,6 +24,7 @@ type UnifiedConfirmDialogProps = { closeOnBackdrop?: boolean; showCloseButton?: boolean; portal?: boolean; + portalTheme?: UnifiedModalPortalTheme; size?: 'sm' | 'md'; overlayClassName?: string; panelClassName?: string; @@ -54,6 +55,7 @@ export function UnifiedConfirmDialog({ closeOnBackdrop = true, showCloseButton = true, portal = true, + portalTheme = 'auto', size = 'sm', overlayClassName, panelClassName, @@ -74,6 +76,7 @@ export function UnifiedConfirmDialog({ closeOnBackdrop={closeOnBackdrop && !busy} showCloseButton={showCloseButton} portal={portal} + portalTheme={portalTheme} size={size} overlayClassName={overlayClassName} panelClassName={panelClassName} diff --git a/src/components/common/UnifiedModal.tsx b/src/components/common/UnifiedModal.tsx index fc789b4f0..24f0a56e2 100644 --- a/src/components/common/UnifiedModal.tsx +++ b/src/components/common/UnifiedModal.tsx @@ -8,6 +8,8 @@ import { } from 'react'; import { createPortal } from 'react-dom'; +import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime'; +import { useAuthUi } from '../auth/AuthUiContext'; import { PlatformModalCloseButton } from './PlatformModalCloseButton'; type UnifiedModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen'; @@ -18,6 +20,8 @@ type UnifiedModalCloseIcon = ComponentProps< typeof PlatformModalCloseButton >['icon']; +export type UnifiedModalPortalTheme = PlatformTheme | 'auto' | 'none'; + type UnifiedModalProps = { open: boolean; title: string; @@ -37,6 +41,7 @@ type UnifiedModalProps = { closeVariant?: UnifiedModalCloseVariant; closeIcon?: UnifiedModalCloseIcon; portal?: boolean; + portalTheme?: UnifiedModalPortalTheme; zIndexClassName?: string; overlayClassName?: string; overlayStyle?: CSSProperties; @@ -63,6 +68,44 @@ function joinClassNames( return classNames.filter(Boolean).join(' '); } +function resolvePortalOverlayClassName({ + overlayClassName, + portalTheme, + contextualTheme, +}: { + overlayClassName?: string; + portalTheme: UnifiedModalPortalTheme; + contextualTheme: PlatformTheme; +}) { + if (portalTheme === 'none') { + return overlayClassName; + } + + const classNames = overlayClassName?.split(/\s+/u).filter(Boolean) ?? []; + const explicitTheme = classNames.find( + (className): className is `platform-theme--${PlatformTheme}` => + className === 'platform-theme--light' || + className === 'platform-theme--dark', + ); + const resolvedTheme = + portalTheme === 'auto' + ? ((explicitTheme?.replace('platform-theme--', '') as + PlatformTheme | undefined) ?? contextualTheme) + : portalTheme; + const remainingClassNames = classNames.filter( + (className) => + className !== 'platform-theme' && + className !== 'platform-theme--light' && + className !== 'platform-theme--dark', + ); + + return joinClassNames( + 'platform-theme', + `platform-theme--${resolvedTheme}`, + ...remainingClassNames, + ); +} + function UnifiedModalContent({ open, title, @@ -91,7 +134,7 @@ function UnifiedModalContent({ bodyClassName, footerClassName, panelStyle, -}: Omit) { +}: Omit) { const generatedTitleId = useId(); const descriptionId = useId(); const titleId = titleIdProp ?? generatedTitleId; @@ -231,10 +274,30 @@ function UnifiedModalContent({ * 统一模态窗口外壳。 * 业务组件只传入标题、内容和操作区;遮罩、无障碍属性、Escape 与移动端布局在这里收口。 */ -export function UnifiedModal({ portal = true, ...props }: UnifiedModalProps) { +export function UnifiedModal({ + portal = true, + portalTheme = 'auto', + overlayClassName, + ...props +}: UnifiedModalProps) { + const contextualTheme = useAuthUi()?.platformTheme ?? 'light'; + const resolvedProps = { + ...props, + overlayClassName: portal + ? resolvePortalOverlayClassName({ + overlayClassName, + portalTheme, + contextualTheme, + }) + : overlayClassName, + }; + if (!portal || typeof document === 'undefined') { - return ; + return ; } - return createPortal(, document.body); + return createPortal( + , + document.body, + ); } diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx index a85a5d614..d80b00858 100644 --- a/src/components/creation-home/CreationLandingView.test.tsx +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -569,6 +569,10 @@ describe('CreationLandingView', () => { ); const dialog = await screen.findByRole('dialog', { name: '角色英雄' }); + expect(dialog.parentElement?.className).not.toContain('platform-theme--'); + expect(dialog.parentElement?.className).toContain( + 'creation-landing__showcase-modal-overlay', + ); expect(within(dialog).getByText('赞')).toBeTruthy(); expect(within(dialog).getByText('3')).toBeTruthy(); expect(within(dialog).queryByRole('button', { name: /点赞/ })).toBeNull(); diff --git a/src/components/creation-home/CreationLandingView.tsx b/src/components/creation-home/CreationLandingView.tsx index b3496951f..1455f8c35 100644 --- a/src/components/creation-home/CreationLandingView.tsx +++ b/src/components/creation-home/CreationLandingView.tsx @@ -409,6 +409,8 @@ function CreationShowcaseModal({ showHeader={false} showCloseButton={false} size="fullscreen" + // 素材舞台是独立黑底视觉;避免平台主题 remap 把它改成普通白底工具弹窗。 + portalTheme="none" zIndexClassName="z-[120]" overlayClassName="creation-landing__showcase-modal-overlay" panelClassName="creation-landing__showcase-modal-panel" @@ -1174,7 +1176,8 @@ export function CreationLandingView({ showHeader={false} showCloseButton={false} size="sm" - overlayClassName="platform-theme platform-theme--light platform-mobile-home-welcome-overlay !items-center !p-4" + portalTheme="light" + overlayClassName="platform-mobile-home-welcome-overlay !items-center !p-4" panelClassName="platform-remap-surface platform-mobile-home-welcome-dialog" bodyClassName="platform-mobile-home-welcome-dialog__body" footerClassName="platform-mobile-home-welcome-dialog__footer" diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx index 03eb280bf..2bc4f979c 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx @@ -338,9 +338,7 @@ describe('EditorAgentConversationPanelView', () => { expect( screen.getByText('一二三四五六七八九十甲乙丙丁戊己庚辛壬癸子丑寅卯'), ).toBeTruthy(); - expect( - screen.getByRole('option', { name: '角色参考' }), - ).toBeTruthy(); + expect(screen.getByRole('option', { name: '角色参考' })).toBeTruthy(); expect(screen.getByRole('option', { name: '新对话' })).toBeTruthy(); fireEvent.change(screen.getByLabelText('发送给画布 Agent'), { @@ -530,9 +528,9 @@ describe('EditorAgentConversationPanelView', () => { (screen.getByLabelText('发送给画布 Agent') as HTMLTextAreaElement).value, ).toBe('需要保留的草稿'); expect(screen.getByText('已经看到画布内容')).toBeTruthy(); - expect( - (screen.getByLabelText('当前对话') as HTMLSelectElement).value, - ).toBe('conversation-1'); + expect((screen.getByLabelText('当前对话') as HTMLSelectElement).value).toBe( + 'conversation-1', + ); }); it('preserves newer draft edits while creating a conversation', async () => { @@ -1989,6 +1987,14 @@ describe('EditorAgentConversationPanelView', () => { fireEvent.click(screen.getByRole('button', { name: '删除当前对话' })); const confirmDialog = screen.getByRole('dialog', { name: '删除对话' }); + expect(confirmDialog.parentElement?.className).toContain( + 'platform-theme--light', + ); + expect(confirmDialog.parentElement?.className).toContain('z-[140]'); + expect(confirmDialog.className).toContain('platform-remap-surface'); + expect( + within(confirmDialog).getByText('确认删除这个对话吗?'), + ).toBeTruthy(); fireEvent.click( within(confirmDialog).getByRole('button', { name: '确认删除' }), ); diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx index 4b553436f..43416aeea 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx @@ -391,7 +391,9 @@ export function EditorAgentConversationPanelView({ setDeleteConfirmOpen(false), ); }} - /> + > + 确认删除这个对话吗? + ); } diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx index 2702e41fb..b73105242 100644 --- a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx @@ -118,9 +118,11 @@ describe('MessageBubble', () => { clientX: 30, clientY: 40, }); + const menu = screen.getByRole('menu', { name: '消息右键菜单' }); + expect(menu.style.zIndex).toBe('60'); expect( - screen.getByRole('menu', { name: '消息右键菜单' }).style.zIndex, - ).toBe('60'); + menu.closest('.image-canvas-editor__portal-theme')?.className, + ).toContain('platform-theme--light'); fireEvent.click(screen.getByRole('menuitem', { name: '复制文本' })); await waitFor(() => diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx index 39fe51442..4147147d7 100644 --- a/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx +++ b/src/components/image-editor/EditorAgentConversation/MessageBubbleRightClickMenu.tsx @@ -1,8 +1,8 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; import type { ImageCanvasActionResult } from '@/src/components/image-editor/ImageCanvasActionsContext.ts'; +import { ImageCanvasEditorPortal } from '../ImageCanvasEditorPortal.tsx'; import { contextAssetMediaSrc, type EditorAgentContextAsset, @@ -158,110 +158,115 @@ export function MessageBubbleRightClickMenu({ }; }, [onClose]); - if (typeof document === 'undefined') { - return null; - } - - return createPortal( -
event.preventDefault()} - > - {target.kind === 'text' ? ( - - ) : ( - <> - {target.asset.kind === 'generated_media' && - target.asset.resourceId?.trim() ? ( - - ) : null} - {target.asset.mediaType === 'image' ? ( - <> - {contextAssetMediaSrc(target.asset).trim() ? ( - - ) : null} - - - ) : null} + return ( + +
event.preventDefault()} + > + {target.kind === 'text' ? ( - - )} -
, - document.body, + ) : ( + <> + {target.asset.kind === 'generated_media' && + target.asset.resourceId?.trim() ? ( + + ) : null} + {target.asset.mediaType === 'image' ? ( + <> + {contextAssetMediaSrc(target.asset).trim() ? ( + + ) : null} + + + ) : null} + + + )} +
+ ); } diff --git a/src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx b/src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx index 0cd04f714..082a6c920 100644 --- a/src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx @@ -1,12 +1,25 @@ /* @vitest-environment jsdom */ import { fireEvent, render, screen } from '@testing-library/react'; -import { useState } from 'react'; +import { type ComponentProps, type ReactNode, useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; +import { AuthUiContext } from '../auth/AuthUiContext'; import { ImageCanvasEditGenerationModalView } from './ImageCanvasEditGenerationModalView'; import type { GenerateDialogState } from './ImageCanvasEditorTypes'; +const DARK_AUTH_UI_VALUE = { + platformTheme: 'dark', +} as ComponentProps['value']; + +function withDarkAuthUi(children: ReactNode) { + return ( + + {children} + + ); +} + function createDialog( patch: Partial = {}, ): GenerateDialogState { @@ -48,13 +61,18 @@ function EditGenerationModalHarness({ describe('ImageCanvasEditGenerationModalView', () => { it('updates prompt and submits edit generation', () => { const submitEdit = vi.fn(); - render(); + render( + withDarkAuthUi(), + ); - const prompt = screen.getByLabelText('生成提示词'); - expect(prompt.className).toContain('auto-grow-text-area'); - expect(prompt.className).not.toContain('platform-text-field'); - - fireEvent.change(prompt, { + const modal = screen + .getAllByRole('dialog', { name: '修改图片' }) + .find((element) => element.classList.contains('platform-modal-shell')); + expect(modal?.parentElement?.className).toContain('platform-theme--light'); + expect(modal?.parentElement?.className).not.toContain( + 'platform-theme--dark', + ); + fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '新的修改提示' }, }); fireEvent.click(screen.getByRole('button', { name: '修改3泥点' })); diff --git a/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx b/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx index 7ebb37464..114f31175 100644 --- a/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx +++ b/src/components/image-editor/ImageCanvasEditGenerationModalView.tsx @@ -3,9 +3,7 @@ import { type Dispatch, type SetStateAction } from 'react'; import { UnifiedModal } from '../common/UnifiedModal'; import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView'; import type { GenerateDialogState } from './ImageCanvasEditorTypes'; -import { - calculateEditorImageGenerationPrice, -} from './ImageCanvasGenerationModel'; +import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel'; type ImageCanvasEditGenerationModalViewProps = { dialog: GenerateDialogState | null; @@ -27,11 +25,13 @@ export function ImageCanvasEditGenerationModalView({ imageSize: dialogImageSize, }); + // TODO: Remove this override after full dark style support. return ( setGenerateDialog(null)} diff --git a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx index e47cddfde..283468d8f 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx @@ -1,11 +1,25 @@ /* @vitest-environment jsdom */ import { fireEvent, render, screen, within } from '@testing-library/react'; +import type { ComponentProps, ReactNode } from 'react'; import { describe, expect, it, vi } from 'vitest'; +import { AuthUiContext } from '../auth/AuthUiContext'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { ImageCanvasMetadataModalView } from './ImageCanvasMetadataModalView'; +const DARK_AUTH_UI_VALUE = { + platformTheme: 'dark', +} as ComponentProps['value']; + +function withDarkAuthUi(children: ReactNode) { + return ( + + {children} + + ); +} + function createLayer(overrides: Partial = {}): CanvasLayer { return { id: 'layer-1', @@ -27,37 +41,45 @@ function createLayer(overrides: Partial = {}): CanvasLayer { describe('ImageCanvasMetadataModalView', () => { it('renders generated layer metadata with generation inputs and references', () => { render( - , + withDarkAuthUi( + , + ), ); const dialog = screen.getByRole('dialog', { name: '图片信息' }); + expect(dialog.parentElement?.className).toContain('platform-theme--light'); + expect(dialog.parentElement?.className).not.toContain( + 'platform-theme--dark', + ); expect(within(dialog).queryByText('生成主图')).toBeNull(); expect(within(dialog).getByText('生成图片')).toBeTruthy(); expect(within(dialog).getByText('生成提示词')).toBeTruthy(); expect(within(dialog).getByText('清爽游戏按钮')).toBeTruthy(); expect(within(dialog).getByText('参考图')).toBeTruthy(); expect(within(dialog).getByText('角色立绘')).toBeTruthy(); - expect(within(dialog).getByText('项目资源 · resource-reference')).toBeTruthy(); + expect( + within(dialog).getByText('项目资源 · resource-reference'), + ).toBeTruthy(); expect(within(dialog).getByText('Model')).toBeTruthy(); expect(within(dialog).getByText('gpt-image-2')).toBeTruthy(); expect(within(dialog).getByText('1024 x 768 px')).toBeTruthy(); @@ -157,7 +179,9 @@ describe('ImageCanvasMetadataModalView', () => { expect(within(dialog).getByText('上传图片')).toBeTruthy(); expect(within(dialog).getAllByText('-').length).toBeGreaterThanOrEqual(3); - fireEvent.click(within(dialog).getByRole('button', { name: '关闭图片信息' })); + fireEvent.click( + within(dialog).getByRole('button', { name: '关闭图片信息' }), + ); expect(onClose).toHaveBeenCalledTimes(1); }); @@ -193,7 +217,9 @@ describe('ImageCanvasMetadataModalView', () => { expect(within(dialog).getByText('kling3.0-omni')).toBeTruthy(); expect(within(dialog).getByText('1280 x 720 px')).toBeTruthy(); - fireEvent.click(within(dialog).getByRole('button', { name: '关闭视频信息' })); + fireEvent.click( + within(dialog).getByRole('button', { name: '关闭视频信息' }), + ); expect(onClose).toHaveBeenCalledTimes(1); }); diff --git a/src/components/image-editor/ImageCanvasMetadataModalView.tsx b/src/components/image-editor/ImageCanvasMetadataModalView.tsx index 3b5a1768c..d0fb16e55 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.tsx @@ -40,13 +40,15 @@ export function ImageCanvasMetadataModalView({ ? '视频' : layer?.mediaType === 'image-sequence' ? '序列帧' - : '图片'; + : '图片'; + // TODO: Remove this override after full dark style support. return (
Model
-
{getEditorLayerModelDisplayName(layer.model)}
+
+ {getEditorLayerModelDisplayName(layer.model)} +
{layer.mediaType === 'audio' ? ( <>
时长
-
- {formatLayerDurationForDisplay(layer)} -
+
{formatLayerDurationForDisplay(layer)}
) : ( <> diff --git a/src/components/image-editor/ImageCanvasShortcutDialogView.test.tsx b/src/components/image-editor/ImageCanvasShortcutDialogView.test.tsx index 53dd3356e..5b12684ff 100644 --- a/src/components/image-editor/ImageCanvasShortcutDialogView.test.tsx +++ b/src/components/image-editor/ImageCanvasShortcutDialogView.test.tsx @@ -1,16 +1,30 @@ /* @vitest-environment jsdom */ import { render, screen, within } from '@testing-library/react'; +import type { ComponentProps } from 'react'; import { describe, expect, it, vi } from 'vitest'; +import { AuthUiContext } from '../auth/AuthUiContext'; import { ImageCanvasShortcutDialogView } from './ImageCanvasShortcutDialogView'; +const DARK_AUTH_UI_VALUE = { + platformTheme: 'dark', +} as ComponentProps['value']; + describe('ImageCanvasShortcutDialogView', () => { it('renders the Windows canvas shortcut table', () => { - render(); + render( + + + , + ); const dialog = screen.getByRole('dialog', { name: '画布快捷键' }); + expect(dialog.parentElement?.className).toContain('platform-theme--light'); + expect(dialog.parentElement?.className).not.toContain( + 'platform-theme--dark', + ); expect(within(dialog).getByRole('heading', { name: '编辑' })).toBeTruthy(); expect(within(dialog).getByText('全选画布元素')).toBeTruthy(); expect(within(dialog).getAllByText('Ctrl').length).toBeGreaterThan(0); diff --git a/src/components/image-editor/ImageCanvasShortcutDialogView.tsx b/src/components/image-editor/ImageCanvasShortcutDialogView.tsx index 265d88def..f8f56340e 100644 --- a/src/components/image-editor/ImageCanvasShortcutDialogView.tsx +++ b/src/components/image-editor/ImageCanvasShortcutDialogView.tsx @@ -10,11 +10,13 @@ export function ImageCanvasShortcutDialogView({ open, onClose, }: ImageCanvasShortcutDialogViewProps) { + // TODO: Remove this override after full dark style support. return ( ['value']; + +afterEach(() => { + cleanup(); +}); + +function withDarkAuthUi(children: ReactNode) { + return ( + + {children} + + ); +} + +function getOverlay() { + return screen.getByRole('dialog', { name: '主题弹窗' }) + .parentElement as HTMLElement; +} + +test('restores the contextual platform theme on the portal overlay', () => { + render( + withDarkAuthUi( + {}}> + 弹窗内容 + , + ), + ); + + expect(getOverlay().className).toContain('platform-theme'); + expect(getOverlay().className).toContain('platform-theme--dark'); + expect(getOverlay().parentElement).toBe(document.body); +}); + +test('falls back to light when no AuthUi provider is available', () => { + render( + {}}> + 弹窗内容 + , + ); + + expect(getOverlay().className).toContain('platform-theme--light'); +}); + +test('respects an existing explicit theme and supports a forced override', () => { + const { rerender } = render( + withDarkAuthUi( + {}} + overlayClassName="platform-theme platform-theme--light custom-overlay" + > + 弹窗内容 + , + ), + ); + + expect(getOverlay().className).toContain('platform-theme--light'); + expect(getOverlay().className).not.toContain('platform-theme--dark'); + expect(getOverlay().className).toContain('custom-overlay'); + + rerender( + withDarkAuthUi( + {}} + portalTheme="dark" + overlayClassName="platform-theme platform-theme--light custom-overlay" + > + 弹窗内容 + , + ), + ); + + expect(getOverlay().className).toContain('platform-theme--dark'); + expect(getOverlay().className).not.toContain('platform-theme--light'); +}); + +test('keeps custom and inline modals outside automatic portal theming', () => { + const { rerender } = render( + withDarkAuthUi( + {}} + portalTheme="none" + overlayClassName="custom-overlay" + > + 弹窗内容 + , + ), + ); + + expect(getOverlay().className).toContain('custom-overlay'); + expect(getOverlay().className).not.toContain('platform-theme--'); + + rerender( + withDarkAuthUi( + {}} + portal={false} + overlayClassName="inline-overlay" + > + 弹窗内容 + , + ), + ); + + expect(getOverlay().className).toContain('inline-overlay'); + expect(getOverlay().className).not.toContain('platform-theme--'); +}); diff --git a/src/components/platform-entry/PlatformActiveProfileView.test.tsx b/src/components/platform-entry/PlatformActiveProfileView.test.tsx index 8b3e46468..ad71ea2d2 100644 --- a/src/components/platform-entry/PlatformActiveProfileView.test.tsx +++ b/src/components/platform-entry/PlatformActiveProfileView.test.tsx @@ -83,6 +83,12 @@ describe('PlatformActiveProfileView', () => { expect( screen.getByRole('button', { name: /开发者 API Key/u }), ).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '用户协议' })); + const legalDialog = screen.getByRole('dialog', { name: '用户协议' }); + expect(legalDialog.parentElement?.className).toContain( + 'platform-theme--light', + ); }); it('edits the nickname through the profile identity action', async () => { diff --git a/vitest.config.ts b/vitest.config.ts index 36f4f8d27..77757e179 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -51,6 +51,12 @@ export default defineConfig({ 'src/components/creation-home/**/*.test.ts', 'src/components/creation-home/**/*.test.tsx', 'src/components/common/AutoGrowTextArea.test.tsx', + 'src/components/common/CreativeImageInputPanel.test.tsx', + 'src/components/common/PlatformDangerConfirmDialog.test.tsx', + 'src/components/common/PlatformImagePreviewModal.test.tsx', + 'src/components/common/PlatformReportDialog.test.tsx', + 'src/components/common/PlatformUtilityInfoModal.test.tsx', + 'src/components/common/PublishShareModal.test.tsx', 'src/components/image-editor/**/*.test.ts', 'src/components/image-editor/**/*.test.tsx', 'src/components/project/**/*.test.ts',