From f4fed35660a433b0eb3331b13748dd887f9b991f Mon Sep 17 00:00:00 2001 From: menghao Date: Thu, 6 Aug 2026 13:00:52 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=B4=A0=E6=9D=90=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E7=99=BB=E5=BD=95=E6=80=81=E7=94=9F=E6=88=90=E9=97=AD?= =?UTF-8?q?=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用客户端登录态与服务端环境配置,移除客户手填 API Key 的生成路径。 补齐外部生成幂等查询、泥点扣费结果回填与失败错误映射。 修复确认生成后返回原界面的问题,并完善生成中、成功和失败状态展示。 更新契约、测试、技术方案、PRD 与项目决策记录。 --- .../scripts/check-config.mjs | 3 +- .../src-tauri/src/assets.rs | 47 +- .../src/project/asset_canvas/generation.rs | 698 +++++++++++++++--- apps/ai-game-creator-shell/src/App.tsx | 1 + .../asset-canvas/AssetCanvasSurface.tsx | 145 +++- .../asset-canvas/assetCanvasSurface.css | 84 ++- .../tauriImageCanvasHostAdapter.ts | 31 +- .../runtime-config/RuntimeConfigDialog.tsx | 56 +- apps/ai-game-creator-shell/src/styles.css | 10 + .../tests/assetCanvasSurface.test.tsx | 202 ++++- ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 3 +- .../shared-memory/decision-log.md | 9 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 7 +- ...客户端素材创作无限画布阶段一合同-2026-08-05.md | 22 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 6 +- .../src/contracts/externalGeneration.ts | 1 + .../crates/api-server/src/editor_project.rs | 60 +- .../api-server/src/external_generation.rs | 44 +- .../src/external_generation.rs | 4 + 19 files changed, 1244 insertions(+), 189 deletions(-) diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index cb9ca3b62..140522ae7 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1634,7 +1634,8 @@ for (const snippet of [ "'write_game_creator_app_config'", 'aria-label="运行时配置"', 'LLM API Key', - '画板 API Key', + 'showDeveloperEditorApi', + '开发者 External Editor API Key', 'runtime_config.save', "'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'", "'activate_local_game_preview'", diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 5ef6c88ed..b946f0027 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -462,6 +462,23 @@ pub(crate) async fn resolve_canvas_resource_download( .await } +pub(crate) async fn resolve_authenticated_canvas_resource_download( + client: &reqwest::Client, + api_base_url: &str, + access_token: &str, + resource: &serde_json::Value, +) -> Result, String> { + resolve_canvas_resource_download_with_limit_and_route( + client, + api_base_url, + access_token, + resource, + 20 * 1024 * 1024, + "/api/assets/read-url", + ) + .await +} + fn external_asset_url_same_origin(url: &url::Url, api_base_url: &str) -> bool { let Ok(api_base_url) = url::Url::parse(api_base_url) else { return false; @@ -608,6 +625,25 @@ pub(crate) async fn resolve_canvas_resource_download_with_limit( api_key: &str, resource: &serde_json::Value, max_bytes: usize, +) -> Result, String> { + resolve_canvas_resource_download_with_limit_and_route( + _client, + api_base_url, + api_key, + resource, + max_bytes, + "/api/external/v1/assets/read-url", + ) + .await +} + +async fn resolve_canvas_resource_download_with_limit_and_route( + _client: &reqwest::Client, + api_base_url: &str, + bearer_token: &str, + resource: &serde_json::Value, + max_bytes: usize, + read_url_route: &str, ) -> Result, String> { if max_bytes == 0 { return Err("画板资产剩余下载预算为 0,已拒绝同步".to_string()); @@ -623,23 +659,26 @@ pub(crate) async fn resolve_canvas_resource_download_with_limit( let source_hint = object_key.as_deref().or(image_src.as_deref()); let (signed_url, came_from_stable_reference) = if let Some(object_key) = object_key.as_deref() { let read_url = format!( - "{}/api/external/v1/assets/read-url?objectKey={}", + "{}{read_url_route}?objectKey={}", api_base_url, percent_encode_query_component(object_key) ); ( - Some(resolve_external_asset_signed_url(&secure_client, api_key, read_url).await?), + Some(resolve_external_asset_signed_url(&secure_client, bearer_token, read_url).await?), true, ) } else if let Some(image_src) = image_src.as_deref() { if image_src.starts_with('/') { let read_url = format!( - "{}/api/external/v1/assets/read-url?legacyPublicPath={}", + "{}{read_url_route}?legacyPublicPath={}", api_base_url, percent_encode_query_component(image_src) ); ( - Some(resolve_external_asset_signed_url(&secure_client, api_key, read_url).await?), + Some( + resolve_external_asset_signed_url(&secure_client, bearer_token, read_url) + .await?, + ), true, ) } else if image_src.starts_with("http://") || image_src.starts_with("https://") { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs index ca6f747d1..ad8f041ca 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs @@ -13,7 +13,7 @@ static ASSET_CANVAS_GENERATION_LOCKS: OnceLock< tokio::sync::Mutex>>>, > = OnceLock::new(); -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct GenerateAssetCanvasImageInput { pub(crate) project_path: String, @@ -32,14 +32,18 @@ pub(crate) struct GenerateAssetCanvasImageInput { pub(crate) asset_kind: String, pub(crate) asset_name: String, pub(crate) reference_resource_ids: Vec, + #[serde(default)] + pub(crate) access_token: Option, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct RecoverAssetCanvasGenerationsInput { pub(crate) project_path: String, pub(crate) expected_project_id: String, pub(crate) draft_id: String, + #[serde(default)] + pub(crate) access_token: Option, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -195,7 +199,8 @@ struct AssetCanvasGenerationLedger { requested_reference_resource_ids: Vec, reference_states: Vec, resolved_reference_ids: Vec, - external_configuration_fingerprint: Option, + #[serde(default, alias = "externalConfigurationFingerprint")] + api_identity_fingerprint: Option, canvas_context: Option, endpoint: Option, request_body_sha256: Option, @@ -241,6 +246,76 @@ struct ReferenceMaterial { sha256: Option, } +enum CanvasGenerationApiMode { + Authenticated { access_token: String }, + ExternalDeveloper { api_key: String }, +} + +impl CanvasGenerationApiMode { + fn bearer_token(&self) -> &str { + match self { + Self::Authenticated { access_token } => access_token, + Self::ExternalDeveloper { api_key } => api_key, + } + } + + fn is_authenticated(&self) -> bool { + matches!(self, Self::Authenticated { .. }) + } + + fn endpoint(&self, authenticated: &'static str, external: &'static str) -> &'static str { + if self.is_authenticated() { + authenticated + } else { + external + } + } +} + +fn resolve_generation_api_mode( + access_token: Option<&str>, +) -> Result<(String, CanvasGenerationApiMode), String> { + if let Some(access_token) = access_token + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if access_token.len() > 16 * 1024 || access_token.chars().any(char::is_control) { + return Err("authentication-required: 登录凭据无效,请重新登录".to_string()); + } + return Ok(( + DEFAULT_CANVAS_SYNC_API_BASE_URL.to_string(), + CanvasGenerationApiMode::Authenticated { + access_token: access_token.to_string(), + }, + )); + } + + let api_base_url = resolve_canvas_sync_api_base_url(None)?; + let api_key = resolve_canvas_sync_api_key(None)?; + Ok(( + api_base_url, + CanvasGenerationApiMode::ExternalDeveloper { api_key }, + )) +} + +fn canvas_api_identity_fingerprint(api_base_url: &str, mode: &CanvasGenerationApiMode) -> String { + if mode.is_authenticated() { + asset_canvas_sha256(format!("authenticated:{}", api_base_url.trim()).as_bytes()) + } else { + platform_art_generation_external_configuration_fingerprint( + api_base_url, + mode.bearer_token(), + ) + } +} + +fn authorize_canvas_request( + request: reqwest::RequestBuilder, + mode: &CanvasGenerationApiMode, +) -> reqwest::RequestBuilder { + request.bearer_auth(mode.bearer_token()) +} + fn generation_ledger_relative_path(generation_id: &str) -> String { format!("{ASSET_CANVAS_ROOT}/generations/{generation_id}.json") } @@ -671,7 +746,7 @@ fn validate_and_prepare_ledger( requested_reference_resource_ids: references, reference_states: Vec::new(), resolved_reference_ids: Vec::new(), - external_configuration_fingerprint: None, + api_identity_fingerprint: None, canvas_context: None, endpoint: None, request_body_sha256: None, @@ -769,29 +844,33 @@ fn reference_material_at( async fn try_confirm_uploaded_reference( client: &reqwest::Client, api_base_url: &str, - api_key: &str, + api_mode: &CanvasGenerationApiMode, bucket: &str, object_key: &str, material: &ReferenceMaterial, asset_kind: &str, ) -> Result, String> { - let response = client - .post(format!( - "{api_base_url}/api/external/v1/assets/objects/confirm" - )) - .bearer_auth(api_key) - .json(&serde_json::json!({ - "bucket": bucket, - "objectKey": object_key, - "contentType": material.media_type, - "contentLength": material.bytes.as_ref().map(Vec::len), - "contentHash": material.sha256, - "assetKind": asset_kind, - "accessPolicy": "private", - })) - .send() - .await - .map_err(|_| "确认参考资源上传结果失败".to_string())?; + let endpoint = api_mode.endpoint( + "/api/assets/objects/confirm", + "/api/external/v1/assets/objects/confirm", + ); + let response = authorize_canvas_request( + client + .post(format!("{api_base_url}{endpoint}")) + .json(&serde_json::json!({ + "bucket": bucket, + "objectKey": object_key, + "contentType": material.media_type, + "contentLength": material.bytes.as_ref().map(Vec::len), + "contentHash": material.sha256, + "assetKind": asset_kind, + "accessPolicy": "private", + })), + api_mode, + ) + .send() + .await + .map_err(|_| "确认参考资源上传结果失败".to_string())?; if !response.status().is_success() { return Ok(None); } @@ -816,17 +895,19 @@ async fn try_confirm_uploaded_reference( async fn request_upload_ticket( client: &reqwest::Client, api_base_url: &str, - api_key: &str, + api_mode: &CanvasGenerationApiMode, ledger: &AssetCanvasGenerationLedger, material: &ReferenceMaterial, ) -> Result { + let endpoint = api_mode.endpoint( + "/api/assets/direct-upload-tickets", + "/api/external/v1/assets/direct-upload-tickets", + ); let payload = external_editor_json_request( - client - .post(format!( - "{api_base_url}/api/external/v1/assets/direct-upload-tickets" - )) - .bearer_auth(api_key) - .json(&serde_json::json!({ + authorize_canvas_request( + client + .post(format!("{api_base_url}{endpoint}")) + .json(&serde_json::json!({ "legacyPrefix": "asset-canvas-references", "pathSegments": [ledger.project_id.as_str(), ledger.draft_id.as_str(), ledger.generation_id.as_str()], "fileName": material.file_name, @@ -835,6 +916,8 @@ async fn request_upload_ticket( "maxSizeBytes": material.bytes.as_ref().map(Vec::len), "successActionStatus": 204, })), + api_mode, + ), "创建参考资源上传凭证", ) .await @@ -933,7 +1016,7 @@ async fn ensure_reference_states( ledger: &mut AssetCanvasGenerationLedger, client: &reqwest::Client, api_base_url: &str, - api_key: &str, + api_mode: &CanvasGenerationApiMode, ) -> Result<(), String> { set_private_phase( root, @@ -979,7 +1062,7 @@ async fn ensure_reference_states( if let Some(asset_object_id) = try_confirm_uploaded_reference( client, api_base_url, - api_key, + api_mode, &bucket, &object_key, &material, @@ -997,7 +1080,7 @@ async fn ensure_reference_states( } } let ticket = - request_upload_ticket(client, api_base_url, api_key, ledger, &material).await?; + request_upload_ticket(client, api_base_url, api_mode, ledger, &material).await?; ledger.reference_states[index].upload_bucket = Some(ticket.bucket.clone()); ledger.reference_states[index].upload_object_key = Some(ticket.object_key.clone()); ledger.reference_states[index].upload_completed = false; @@ -1008,7 +1091,7 @@ async fn ensure_reference_states( let asset_object_id = try_confirm_uploaded_reference( client, api_base_url, - api_key, + api_mode, &ticket.bucket, &ticket.object_key, &material, @@ -1045,8 +1128,112 @@ fn external_generation_kind(asset_kind: &str) -> Option<&'static str> { } } +async fn prepare_canvas_generation_context( + root: &Path, + client: &reqwest::Client, + api_base_url: &str, + api_mode: &CanvasGenerationApiMode, +) -> Result { + if !api_mode.is_authenticated() { + return prepare_external_canvas_generation_context( + root, + client, + api_base_url, + api_mode.bearer_token(), + ) + .await; + } + + let manifest = read_existing_manifest_for_project(root)?; + let canvas_name = manifest.name.trim().chars().take(80).collect::(); + let canvas_name = if canvas_name.is_empty() { + "未命名游戏原型".to_string() + } else { + canvas_name + }; + let projects_payload = external_editor_json_request( + authorize_canvas_request( + client.get(format!("{api_base_url}/api/editor/projects")), + api_mode, + ), + "读取画布项目", + ) + .await?; + let projects = external_editor_response_data(&projects_payload) + .get("projects") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "画布项目响应缺少 projects".to_string())?; + let project_id = projects + .iter() + .find(|project| json_string_field(project, "title").as_deref() == Some(&canvas_name)) + .and_then(|project| json_string_field(project, "projectId")); + let project_id = match project_id { + Some(project_id) => project_id, + None => { + let payload = external_editor_json_request( + authorize_canvas_request( + client + .post(format!("{api_base_url}/api/editor/projects")) + .json(&serde_json::json!({ "title": canvas_name })), + api_mode, + ), + "创建画布项目", + ) + .await?; + external_editor_response_data(&payload) + .get("project") + .and_then(|project| json_string_field(project, "projectId")) + .ok_or_else(|| "创建画布项目响应缺少 projectId".to_string())? + } + }; + + let library_payload = external_editor_json_request( + authorize_canvas_request( + client.get(format!("{api_base_url}/api/editor/assets/library")), + api_mode, + ), + "读取画布素材库", + ) + .await?; + let folders = external_editor_response_data(&library_payload) + .get("library") + .and_then(|library| library.get("folders")) + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "画布素材库响应缺少 library.folders".to_string())?; + let asset_folder_id = folders + .iter() + .find(|folder| json_string_field(folder, "label").as_deref() == Some(&canvas_name)) + .and_then(|folder| json_string_field(folder, "folderId")); + let asset_folder_id = match asset_folder_id { + Some(folder_id) => folder_id, + None => { + let payload = external_editor_json_request( + authorize_canvas_request( + client + .post(format!("{api_base_url}/api/editor/assets/folders")) + .json(&serde_json::json!({ "label": canvas_name })), + api_mode, + ), + "创建画布素材目录", + ) + .await?; + external_editor_response_data(&payload) + .get("folder") + .and_then(|folder| json_string_field(folder, "folderId")) + .ok_or_else(|| "创建画布素材目录响应缺少 folderId".to_string())? + } + }; + + Ok(ExternalCanvasGenerationContext { + project_id, + asset_folder_id, + canvas_name, + }) +} + fn build_generation_request_snapshot( ledger: &AssetCanvasGenerationLedger, + api_mode: &CanvasGenerationApiMode, ) -> Result<(String, String), String> { let context = ledger .canvas_context @@ -1124,7 +1311,10 @@ fn build_generation_request_snapshot( "referenceImageSrcs".to_string(), serde_json::json!(additional), ); - "/api/external/v1/editor/images/edits" + api_mode.endpoint( + "/api/editor/images/edits", + "/api/external/v1/editor/images/edits", + ) } else { body.insert( "referenceImageSrcs".to_string(), @@ -1133,7 +1323,10 @@ fn build_generation_request_snapshot( if let Some(kind) = external_generation_kind(&ledger.asset_kind) { body.insert("kind".to_string(), serde_json::json!(kind)); } - "/api/external/v1/editor/images/generations" + api_mode.endpoint( + "/api/editor/images/generations", + "/api/external/v1/editor/images/generations", + ) }; let body_json = serde_json::to_string(&serde_json::Value::Object(body)) .map_err(|_| "无法序列化 External Editor 生成请求".to_string())?; @@ -1243,6 +1436,11 @@ fn mark_generation_error( fn sanitized_generation_error(code: &str) -> String { match code { "configuration-missing" => "图片生成配置缺失,未创建任何资源".to_string(), + "authentication-required" => "登录已失效,请重新登录后重试".to_string(), + "insufficient-mud-points" => "泥点余额不足,请充值后重试".to_string(), + "platform-service-configuration" => { + "平台图片生成服务暂不可用,请稍后重试或联系管理员".to_string() + } "generation-rejected" => "图片生成请求被平台明确拒绝,未创建资源".to_string(), "generation-failed" => "图片生成失败,未创建资源".to_string(), "stable-reference-missing" => "远端生成结果缺少稳定资源引用,未创建本地资源".to_string(), @@ -1267,9 +1465,198 @@ fn should_poll_existing_operation(ledger: &AssetCanvasGenerationLedger) -> bool && ledger.remote_result.is_none()) } +enum CanvasGenerationInitialResponse { + Completed(serde_json::Value), + Async(serde_json::Value), +} + +fn classify_canvas_generation_initial_response( + status: reqwest::StatusCode, + payload: &serde_json::Value, + api_mode: &CanvasGenerationApiMode, +) -> Result { + if !api_mode.is_authenticated() { + return match classify_external_generation_initial_response(status, payload)? { + ExternalGenerationInitialResponse::LegacyCompleted(result) => { + Ok(CanvasGenerationInitialResponse::Completed(result)) + } + ExternalGenerationInitialResponse::AsyncSubmission(submission) => { + Ok(CanvasGenerationInitialResponse::Async(submission)) + } + }; + } + + let data = external_editor_response_data(payload); + if let Some(queue_state) = data.get("queueState").filter(|value| value.is_object()) { + if json_string_field(queue_state, "operationId").is_some() { + return Ok(CanvasGenerationInitialResponse::Async(queue_state.clone())); + } + } + if external_generation_result_has_download_reference(data) { + return Ok(CanvasGenerationInitialResponse::Completed(data.clone())); + } + Err("登录态图片生成响应既没有 operationId,也没有稳定结果引用".to_string()) +} + +async fn wait_for_canvas_generation_result( + client: &reqwest::Client, + api_base_url: &str, + api_mode: &CanvasGenerationApiMode, + submission_payload: &serde_json::Value, +) -> Result { + if !api_mode.is_authenticated() { + return wait_for_external_generation_result( + client, + api_base_url, + api_mode.bearer_token(), + submission_payload, + ) + .await; + } + + let submission = external_editor_response_data(submission_payload); + let operation_id = json_string_field(submission, "operationId") + .ok_or_else(|| "图片生成提交响应缺少 operationId".to_string())?; + let operation_id_path = + url::form_urlencoded::byte_serialize(operation_id.as_bytes()).collect::(); + let status_url = + format!("{api_base_url}/api/runtime/external-generation/jobs/{operation_id_path}"); + let started_at = tokio::time::Instant::now(); + let mut poll_after_ms = external_generation_poll_after_ms(submission_payload); + + loop { + if started_at.elapsed() >= Duration::from_secs(35 * 60) { + return Err(format!( + "result-unknown: 平台图片生成任务仍在执行,已停止本地等待;operationId={operation_id}" + )); + } + if poll_after_ms > 0 { + tokio::time::sleep(Duration::from_millis(poll_after_ms)).await; + } + let response = authorize_canvas_request(client.get(&status_url), api_mode) + .send() + .await + .map_err(|_| { + format!("result-unknown: 查询平台图片生成任务失败;operationId={operation_id}") + })?; + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Err("authentication-required: 登录已失效,请重新登录".to_string()); + } + if [429, 502, 503, 504].contains(&status.as_u16()) { + poll_after_ms = 2_000; + continue; + } + if !status.is_success() { + return Err(format!( + "result-unknown: 查询平台图片生成任务返回 HTTP {};operationId={operation_id}", + status.as_u16() + )); + } + let payload = response.json::().await.map_err(|_| { + format!("result-unknown: 解析平台图片生成任务响应失败;operationId={operation_id}") + })?; + let data = external_editor_response_data(&payload); + let generation = data + .get("job") + .filter(|value| value.is_object()) + .unwrap_or(data); + match json_string_field(generation, "status").as_deref() { + Some("completed") => { + let result = generation + .get("result") + .filter(|result| !result.is_null()) + .cloned() + .ok_or_else(|| { + format!( + "result-unknown: 平台图片生成任务已完成但响应缺少 result;operationId={operation_id}" + ) + })?; + if !external_generation_result_has_download_reference(&result) { + return Err(format!( + "result-unknown: 平台图片生成任务完成结果缺少可下载媒体;operationId={operation_id}" + )); + } + return Ok(result); + } + Some("failed") => { + let error = json_string_field(generation, "error") + .or_else(|| json_string_field(generation, "phaseDetail")) + .unwrap_or_else(|| "生成任务失败".to_string()); + return Err(format!( + "平台图片生成任务失败:{error};operationId={operation_id}" + )); + } + Some("queued" | "running") => { + poll_after_ms = external_generation_poll_after_ms(generation); + } + Some(_) | None => { + return Err(format!( + "result-unknown: 平台图片生成任务状态无效;operationId={operation_id}" + )); + } + } + } +} + +async fn classify_canvas_submit_error( + response: reqwest::Response, + api_mode: &CanvasGenerationApiMode, +) -> (&'static str, bool) { + let status = response.status(); + if api_mode.is_authenticated() + && matches!( + status, + reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN + ) + { + return ("authentication-required", false); + } + let body = response.text().await.unwrap_or_default(); + if api_mode.is_authenticated() + && status == reqwest::StatusCode::CONFLICT + && body.contains("泥点余额不足") + { + return ("insufficient-mud-points", false); + } + if api_mode.is_authenticated() + && status.is_server_error() + && ["配置", "API Key", "供应商", "provider"] + .iter() + .any(|marker| body.contains(marker)) + { + return ("platform-service-configuration", false); + } + if external_generation_submit_rejection_is_definitive(status) + || status == reqwest::StatusCode::CONFLICT + { + return ("generation-rejected", false); + } + ("submit-result-unknown", true) +} + +fn classify_canvas_generation_error(error: &str) -> (bool, &'static str) { + if error.contains("authentication-required") { + return (false, "authentication-required"); + } + if error.contains("insufficient-mud-points") || error.contains("泥点余额不足") { + return (false, "insufficient-mud-points"); + } + if error.contains("platform-service-configuration") { + return (false, "platform-service-configuration"); + } + if platform_art_generation_error_needs_reconciliation(error) || error.contains("result-unknown") + { + return (true, "poll-result-unknown"); + } + (false, "generation-failed") +} + async fn reconcile_generation( root: &Path, mut ledger: AssetCanvasGenerationLedger, + api_base_url: &str, + api_mode: &CanvasGenerationApiMode, emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), ) -> Result { if ledger.phase == GenerationLedgerPhase::AssetDurableCommitted { @@ -1280,24 +1667,9 @@ async fn reconcile_generation( ledger.error_code.as_deref().unwrap_or("generation-failed"), )); } - let api_base_url = match resolve_canvas_sync_api_base_url(None) { - Ok(value) => value, - Err(_) => { - mark_generation_error(root, &mut ledger, false, "configuration-missing", emit)?; - return Err(sanitized_generation_error("configuration-missing")); - } - }; - let api_key = match resolve_canvas_sync_api_key(None) { - Ok(value) => value, - Err(_) => { - mark_generation_error(root, &mut ledger, false, "configuration-missing", emit)?; - return Err(sanitized_generation_error("configuration-missing")); - } - }; - let configuration_fingerprint = - platform_art_generation_external_configuration_fingerprint(&api_base_url, &api_key); + let configuration_fingerprint = canvas_api_identity_fingerprint(api_base_url, api_mode); if ledger - .external_configuration_fingerprint + .api_identity_fingerprint .as_ref() .is_some_and(|value| value != &configuration_fingerprint) { @@ -1320,22 +1692,46 @@ async fn reconcile_generation( | GenerationLedgerPhase::ReconciliationRequired ) && ledger.request_body_json.is_none() { - ledger.external_configuration_fingerprint = Some(configuration_fingerprint.clone()); + ledger.api_identity_fingerprint = Some(configuration_fingerprint.clone()); if ledger.canvas_context.is_none() { - let context = - prepare_external_canvas_generation_context(root, &client, &api_base_url, &api_key) - .await - .map_err(|_| "准备 External Editor 画布上下文失败".to_string())?; + let context = match prepare_canvas_generation_context( + root, + &client, + api_base_url, + api_mode, + ) + .await + { + Ok(context) => context, + Err(error) => { + let code = if api_mode.is_authenticated() + && (error.contains("HTTP 401") || error.contains("HTTP 403")) + { + "authentication-required" + } else { + "platform-service-configuration" + }; + mark_generation_error(root, &mut ledger, false, code, emit)?; + return Err(sanitized_generation_error(code)); + } + }; ledger.canvas_context = Some(context.into()); write_generation_ledger(root, &mut ledger)?; } - if let Err(_error) = - ensure_reference_states(root, &mut ledger, &client, &api_base_url, &api_key).await + if let Err(error) = + ensure_reference_states(root, &mut ledger, &client, api_base_url, api_mode).await { - mark_generation_error(root, &mut ledger, false, "reference-upload-failed", emit)?; - return Err(sanitized_generation_error("reference-upload-failed")); + let code = if api_mode.is_authenticated() + && (error.contains("HTTP 401") || error.contains("HTTP 403")) + { + "authentication-required" + } else { + "reference-upload-failed" + }; + mark_generation_error(root, &mut ledger, false, code, emit)?; + return Err(sanitized_generation_error(code)); } - let (endpoint, body_json) = build_generation_request_snapshot(&ledger)?; + let (endpoint, body_json) = build_generation_request_snapshot(&ledger, api_mode)?; ledger.endpoint = Some(endpoint); ledger.request_body_sha256 = Some(asset_canvas_sha256(body_json.as_bytes())); ledger.request_body_json = Some(body_json); @@ -1357,9 +1753,9 @@ async fn reconcile_generation( .ok_or_else(|| "私有生成账本缺少 request body".to_string())?; let response = match submit_external_generation_request( &submit_client, - &api_base_url, + api_base_url, endpoint, - &api_key, + api_mode.bearer_token(), &ledger.idempotency_key, request_body_json, ) @@ -1373,12 +1769,9 @@ async fn reconcile_generation( }; let status = response.status(); if !status.is_success() { - if external_generation_submit_rejection_is_definitive(status) { - mark_generation_error(root, &mut ledger, false, "generation-rejected", emit)?; - return Err(sanitized_generation_error("generation-rejected")); - } - mark_generation_error(root, &mut ledger, true, "submit-result-unknown", emit)?; - return Err(sanitized_generation_error("submit-result-unknown")); + let (code, reconciliation) = classify_canvas_submit_error(response, api_mode).await; + mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; + return Err(sanitized_generation_error(code)); } let submission = match response.json::().await { Ok(value) => value, @@ -1387,13 +1780,13 @@ async fn reconcile_generation( return Err(sanitized_generation_error("submit-result-unknown")); } }; - match classify_external_generation_initial_response(status, &submission) { - Ok(ExternalGenerationInitialResponse::LegacyCompleted(generated)) => { + match classify_canvas_generation_initial_response(status, &submission, api_mode) { + Ok(CanvasGenerationInitialResponse::Completed(generated)) => { set_private_phase(root, &mut ledger, GenerationLedgerPhase::Accepted, None)?; publish_public_phase(root, &ledger, emit)?; generated } - Ok(ExternalGenerationInitialResponse::AsyncSubmission(submission)) => { + Ok(CanvasGenerationInitialResponse::Async(submission)) => { let operation_id = json_string_field(external_editor_response_data(&submission), "operationId") .ok_or_else(|| "平台接受响应缺少 operationId".to_string())?; @@ -1403,23 +1796,17 @@ async fn reconcile_generation( publish_public_phase(root, &ledger, emit)?; set_private_phase(root, &mut ledger, GenerationLedgerPhase::Running, None)?; publish_public_phase(root, &ledger, emit)?; - match wait_for_external_generation_result( + match wait_for_canvas_generation_result( &client, - &api_base_url, - &api_key, + api_base_url, + api_mode, &submission, ) .await { Ok(result) => result, Err(error) => { - let reconciliation = - platform_art_generation_error_needs_reconciliation(&error); - let code = if reconciliation { - "poll-result-unknown" - } else { - "generation-failed" - }; + let (reconciliation, code) = classify_canvas_generation_error(&error); mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; return Err(sanitized_generation_error(code)); } @@ -1441,17 +1828,11 @@ async fn reconcile_generation( }); set_private_phase(root, &mut ledger, GenerationLedgerPhase::Running, None)?; publish_public_phase(root, &ledger, emit)?; - match wait_for_external_generation_result(&client, &api_base_url, &api_key, &submission) - .await + match wait_for_canvas_generation_result(&client, api_base_url, api_mode, &submission).await { Ok(result) => result, Err(error) => { - let reconciliation = platform_art_generation_error_needs_reconciliation(&error); - let code = if reconciliation { - "poll-result-unknown" - } else { - "generation-failed" - }; + let (reconciliation, code) = classify_canvas_generation_error(&error); mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; return Err(sanitized_generation_error(code)); } @@ -1502,14 +1883,24 @@ async fn reconcile_generation( .remote_result .as_ref() .ok_or_else(|| "远端完成账本缺少稳定引用".to_string())?; - let download = match resolve_canvas_resource_download( - &client, - &api_base_url, - &api_key, - &serde_json::json!({ "objectKey": remote.object_key }), - ) - .await - { + let download_result = if api_mode.is_authenticated() { + resolve_authenticated_canvas_resource_download( + &client, + api_base_url, + api_mode.bearer_token(), + &serde_json::json!({ "objectKey": remote.object_key }), + ) + .await + } else { + resolve_canvas_resource_download( + &client, + api_base_url, + api_mode.bearer_token(), + &serde_json::json!({ "objectKey": remote.object_key }), + ) + .await + }; + let download = match download_result { Ok(Some(download)) => download, _ => { mark_generation_error( @@ -1713,8 +2104,21 @@ pub(crate) async fn generate_asset_canvas_image_at( enforce_project_permission_policy(root, "canvas.asset_generate")?; let _guard = generation_singleflight_lock(&input.expected_project_id, &input.generation_id).await; - let ledger = validate_and_prepare_ledger(root, input)?; - reconcile_generation(root, ledger, &mut emit).await + let mut ledger = validate_and_prepare_ledger(root, input)?; + let (api_base_url, api_mode) = match resolve_generation_api_mode(input.access_token.as_deref()) + { + Ok(value) => value, + Err(_) => { + let code = if input.access_token.is_some() { + "authentication-required" + } else { + "configuration-missing" + }; + mark_generation_error(root, &mut ledger, false, code, &mut emit)?; + return Err(sanitized_generation_error(code)); + } + }; + reconcile_generation(root, ledger, &api_base_url, &api_mode, &mut emit).await } pub(crate) async fn recover_asset_canvas_generations_at( @@ -1755,6 +2159,22 @@ pub(crate) async fn recover_asset_canvas_generations_at( return Err("素材画布私有生成账本数量超过上限".to_string()); } generation_ids.sort(); + if generation_ids.is_empty() { + return Ok(RecoverAssetCanvasGenerationsExecution { + result: RecoverAssetCanvasGenerationsResult { + resumed_generation_ids: Vec::new(), + }, + events: Vec::new(), + }); + } + let (api_base_url, api_mode) = resolve_generation_api_mode(input.access_token.as_deref()) + .map_err(|_| { + if input.access_token.is_some() { + sanitized_generation_error("authentication-required") + } else { + sanitized_generation_error("configuration-missing") + } + })?; let mut resumed = Vec::new(); let mut events = Vec::new(); for generation_id in generation_ids { @@ -1772,7 +2192,7 @@ pub(crate) async fn recover_asset_canvas_generations_at( } resumed.push(generation_id.clone()); let _guard = generation_singleflight_lock(&ledger.project_id, &generation_id).await; - match reconcile_generation(root, ledger, &mut emit).await { + match reconcile_generation(root, ledger, &api_base_url, &api_mode, &mut emit).await { Ok(execution) => { if let Some(event) = execution.event { events.push(event); @@ -1905,6 +2325,7 @@ mod tests { asset_kind: "illustration".to_string(), asset_name: "阶段五生成图".to_string(), reference_resource_ids: Vec::new(), + access_token: None, } } @@ -1944,7 +2365,7 @@ mod tests { requested_reference_resource_ids: Vec::new(), reference_states: Vec::new(), resolved_reference_ids: Vec::new(), - external_configuration_fingerprint: Some( + api_identity_fingerprint: Some( platform_art_generation_external_configuration_fingerprint(base_url, api_key), ), canvas_context: Some(PrivateCanvasContext { @@ -2229,6 +2650,7 @@ mod tests { project_path: directory.path().to_string_lossy().into_owned(), expected_project_id: project_id.to_string(), draft_id: draft.draft_id.clone(), + access_token: None, }; let first = recover_asset_canvas_generations_at(directory.path(), &input, |_| {}) .await @@ -2325,7 +2747,7 @@ mod tests { "objects/source.png".to_string(), "objects/style.png".to_string(), ], - external_configuration_fingerprint: Some("configuration".to_string()), + api_identity_fingerprint: Some("configuration".to_string()), canvas_context: Some(PrivateCanvasContext { project_id: "remote-project".to_string(), asset_folder_id: "remote-folder".to_string(), @@ -2344,8 +2766,11 @@ mod tests { created_at: now, updated_at: now, }; + let api_mode = CanvasGenerationApiMode::ExternalDeveloper { + api_key: "test-api-key".to_string(), + }; let (endpoint, request) = - build_generation_request_snapshot(&ledger).expect("build refine request"); + build_generation_request_snapshot(&ledger, &api_mode).expect("build refine request"); let request: serde_json::Value = serde_json::from_str(&request).expect("parse refine request"); assert_eq!(endpoint, "/api/external/v1/editor/images/edits"); @@ -2444,6 +2869,69 @@ mod tests { drop(directory); } + #[test] + fn authenticated_access_token_is_ephemeral_and_never_enters_generation_ledger() { + let project_id = "phase-five-authenticated-token"; + let (directory, draft) = create_generation_fixture(project_id, "阶段五登录态凭据测试"); + let secret = "session-token-must-never-be-persisted"; + let mut input = generation_input( + directory.path(), + project_id, + &draft, + "登录态凭据不能进入私有账本", + ); + input.access_token = Some(secret.to_string()); + + let mut ledger = + validate_and_prepare_ledger(directory.path(), &input).expect("prepare ledger"); + let (api_base_url, api_mode) = + resolve_generation_api_mode(input.access_token.as_deref()).expect("resolve auth mode"); + assert!(api_mode.is_authenticated()); + assert_eq!(api_mode.bearer_token(), secret); + assert_eq!( + api_mode.endpoint("/api/assets/read-url", "/api/external/v1/assets/read-url"), + "/api/assets/read-url" + ); + ledger.canvas_context = Some(PrivateCanvasContext { + project_id: "authenticated-project".to_string(), + asset_folder_id: "authenticated-folder".to_string(), + canvas_name: "阶段五登录态凭据测试".to_string(), + }); + let (endpoint, request_body) = + build_generation_request_snapshot(&ledger, &api_mode).expect("build auth request"); + assert_eq!(endpoint, "/api/editor/images/generations"); + assert!(!request_body.contains(secret)); + assert!(matches!( + classify_canvas_generation_initial_response( + reqwest::StatusCode::OK, + &serde_json::json!({ + "data": { + "queueState": { + "operationId": "authenticated-operation", + "status": "queued" + } + } + }), + &api_mode, + ) + .expect("classify authenticated queue response"), + CanvasGenerationInitialResponse::Async(_) + )); + ledger.api_identity_fingerprint = + Some(canvas_api_identity_fingerprint(&api_base_url, &api_mode)); + write_generation_ledger(directory.path(), &mut ledger).expect("persist private ledger"); + + let persisted = fs::read_to_string( + directory + .path() + .join(generation_ledger_relative_path(&input.generation_id)), + ) + .expect("read private generation ledger"); + assert!(!persisted.contains(secret)); + assert!(!persisted.contains("accessToken")); + assert!(persisted.contains("apiIdentityFingerprint")); + } + #[tokio::test] async fn missing_configuration_fails_explicitly_without_creating_an_asset() { let project_id = "phase-five-missing-config"; diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index f2028c1dc..c94dc0ac9 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -11141,6 +11141,7 @@ export function App({ {runtimeConfigOpen ? ( setRuntimeConfigOpen(false)} onLog={(entry) => setCommandLog((current) => [...current, entry])} /> diff --git a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx index a575c1687..17f1a78a2 100644 --- a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx +++ b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx @@ -40,6 +40,7 @@ import { } from 'react'; import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { useWalletStore } from '../../stores/useWalletStore'; import type { LocalAssetCommittedEvent, TauriImageCanvasHostAdapter, @@ -119,6 +120,48 @@ type DragState = layerId: string; }; +const generationPhaseLabels: Record< + ImageCanvasGenerationProgressPhase, + string +> = { + 'confirmation-required': '正在确认生成请求', + 'generation-accepted': '请求已受理,正在排队', + 'generation-running': '正在生成图片', + 'remote-completed': '图片已生成,正在准备下载', + 'media-downloaded': '图片已下载,正在保存到项目', + 'asset-durable-committed': '图片已保存到项目', + 'manifest-projected': '资源已登记,正在更新画布', + 'layout-ready': '资源布局已更新', + selected: '新资源已选中', + failed: '图片生成失败', + 'reconciliation-required': '正在等待原任务恢复', +}; + +const generationPhaseProgress: Record< + ImageCanvasGenerationProgressPhase, + number +> = { + 'confirmation-required': 4, + 'generation-accepted': 12, + 'generation-running': 40, + 'remote-completed': 64, + 'media-downloaded': 84, + 'asset-durable-committed': 100, + 'manifest-projected': 100, + 'layout-ready': 100, + selected: 100, + failed: 0, + 'reconciliation-required': 55, +}; + +function generationFailureTitle(code: string) { + if (code === 'authentication-required') return '登录已失效'; + if (code === 'insufficient-mud-points') return '泥点余额不足'; + if (code === 'platform-service-configuration') return '平台生成服务暂不可用'; + if (code === 'reconciliation-required') return '原生成任务需要恢复'; + return '图片生成未完成'; +} + export type RenderAssetCanvasImage = (input: { layers: RuntimeCanvasLayer[]; backgroundColor: string; @@ -318,6 +361,9 @@ export function AssetCanvasSurface({ const pendingGenerationRef = useRef(null); const generationStartingRef = useRef(false); const generationFocusEpochRef = useRef(0); + const onWalletBalanceMayHaveChanged = useWalletStore( + (state) => state.onWalletBalanceMayHaveChanged, + ); layersRef.current = layers; viewportRef.current = viewport; @@ -539,6 +585,7 @@ export function AssetCanvasSurface({ `已安全恢复 ${result.value.resumedGenerationIds.length} 个原生成 operation`, ); } + void onWalletBalanceMayHaveChanged(); }); })().catch((error: unknown) => { if ( @@ -567,6 +614,7 @@ export function AssetCanvasSurface({ host, hydrateDraft, onCommitted, + onWalletBalanceMayHaveChanged, stableScope, sessionId, ]); @@ -1124,6 +1172,7 @@ export function AssetCanvasSurface({ referenceResourceIds: frozenReferences, onProgress, }); + void onWalletBalanceMayHaveChanged(); if ( epoch !== epochRef.current || focusEpoch !== generationFocusEpochRef.current @@ -1208,11 +1257,31 @@ export function AssetCanvasSurface({ host.projectPath, onCommitted, onSaveAttempt, + onWalletBalanceMayHaveChanged, persistDraft, sessionId, stableScope, ]); + const reopenGenerationAfterFailure = useCallback( + (dialog: 'edit' | 'confirm') => { + if (lifecycleRef.current.kind !== 'canvas.failed') return; + pendingGenerationRef.current = { + saveAttemptId: crypto.randomUUID(), + intentId: crypto.randomUUID(), + generationId: crypto.randomUUID(), + idempotencyKey: crypto.randomUUID(), + commitId: crypto.randomUUID(), + commitIdempotencyKey: crypto.randomUUID(), + }; + generationStartingRef.current = false; + setNotice(''); + setLifecycle({ kind: 'canvas.editing', dirty: false }); + setGenerationDialog(dialog); + }, + [], + ); + const stopWaitingForGeneration = useCallback(() => { generationFocusEpochRef.current += 1; pendingGenerationRef.current = null; @@ -1496,6 +1565,68 @@ export function AssetCanvasSurface({ ) : null} + {lifecycle.kind === 'canvas.generating' ? ( +
+
+ + AI 图片生成 + + {generationPhaseLabels[lifecycle.phase]} + +

生成会由平台按当前账号扣除泥点,完成后自动保存到本地项目。

+ +
+
+ ) : null} + + {lifecycle.kind === 'canvas.failed' ? ( +
+
+ + AI 图片生成 + + {generationFailureTitle(lifecycle.code)} +

{lifecycle.message}

+
+ + {!lifecycle.reconciliationRequired ? ( + <> + + + + ) : null} +
+
+
+ ) : null} +