diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index dec4155ed..2c566be25 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -16,9 +16,5 @@ "retryBackoffMs": 500 }, "agentLlm": {}, - "editorApi": { - "baseUrl": "http://127.0.0.1:8082", - "apiKey": "" - }, "mcpServers": {} } diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index fccc48f04..c624b7aa8 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1211,9 +1211,9 @@ for (const [agentId, agentConfig] of Object.entries( } } -if (defaultAppConfig.editorApi?.apiKey !== '') { +if (defaultAppConfig.editorApi !== undefined) { throw new Error( - 'AI game creator shell default editorApi.apiKey must stay empty', + 'AI game creator shell ordinary default config must not contain editorApi', ); } 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 24069166f..7cfddbb1b 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 @@ -133,6 +133,9 @@ pub(crate) fn needs_platform_art_asset_generation(root: &Path, briefs: &[AgentGr } pub(crate) fn editor_api_key_is_configured() -> bool { + if editor_api_mode() == EditorApiMode::PlatformAccount { + return platform_session_is_available(); + } load_game_creator_app_config() .ok() .and_then(|config| trim_config_string(&config.editor_api.api_key)) @@ -383,6 +386,19 @@ pub(crate) fn classify_external_generation_initial_response( match status { reqwest::StatusCode::OK => { let generated = external_editor_response_data(payload); + if let Some(queue_state) = generated + .get("queueState") + .filter(|queue_state| queue_state.is_object()) + { + if let Some(operation_id) = json_string_field(queue_state, "operationId") { + return Ok(ExternalGenerationInitialResponse::AsyncSubmission( + serde_json::json!({ + "operationId": operation_id, + "pollAfterMs": 2_000, + }), + )); + } + } if !external_generation_result_has_download_reference(generated) { return Err(format!( "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台旧同步图片生成响应缺少可下载结果" @@ -554,9 +570,10 @@ pub(crate) async fn wait_for_external_generation_result( 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/external/v1/generations/{operation_id_path}"); + let status_url = format!( + "{api_base_url}{}", + resolve_platform_generation_status_route(&operation_id) + ); let started_at = tokio::time::Instant::now(); let mut poll_after_ms = external_generation_poll_after_ms(submission_payload); @@ -591,7 +608,7 @@ pub(crate) async fn wait_for_external_generation_result( )); } }; - let generation = external_editor_response_data(&payload); + let generation = platform_generation_status_data(&payload); match json_string_field(generation, "status").as_deref() { Some("completed") => { let result = generation @@ -643,12 +660,32 @@ pub(crate) async fn submit_external_generation_request( idempotency_key: &str, request_body_json: &str, ) -> Result { + let mut request_body = serde_json::from_str::(request_body_json) + .map_err(|error| format!("解析平台图片生成请求失败:{error}"))?; + if editor_api_mode() == EditorApiMode::PlatformAccount { + let inputs = request_body.as_object_mut().and_then(|body| { + body.entry("generationInputs") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + }); + if let Some(inputs) = inputs { + inputs.insert( + "source".to_string(), + serde_json::Value::String("ai-game-creator-client".to_string()), + ); + } + } + let request_body_json = serde_json::to_string(&request_body) + .map_err(|error| format!("序列化平台图片生成请求失败:{error}"))?; client - .post(format!("{api_base_url}{endpoint}")) + .post(format!( + "{api_base_url}{}", + resolve_platform_editor_api_route(endpoint) + )) .bearer_auth(api_key) .header("Idempotency-Key", idempotency_key) .header(reqwest::header::CONTENT_TYPE, "application/json") - .body(request_body_json.to_string()) + .body(request_body_json) .send() .await .map_err(|error| { @@ -739,7 +776,10 @@ pub(crate) async fn prepare_external_canvas_generation_context( }; let projects_payload = external_editor_json_request( client - .get(format!("{api_base_url}/api/external/v1/editor/projects")) + .get(format!( + "{api_base_url}{}", + resolve_platform_editor_api_route("/api/external/v1/editor/projects") + )) .bearer_auth(api_key), "读取外部画布项目", ) @@ -757,7 +797,10 @@ pub(crate) async fn prepare_external_canvas_generation_context( None => { let payload = external_editor_json_request( client - .post(format!("{api_base_url}/api/external/v1/editor/projects")) + .post(format!( + "{api_base_url}{}", + resolve_platform_editor_api_route("/api/external/v1/editor/projects") + )) .bearer_auth(api_key) .json(&serde_json::json!({ "title": canvas_name })), "创建外部画布项目", @@ -773,7 +816,8 @@ pub(crate) async fn prepare_external_canvas_generation_context( let library_payload = external_editor_json_request( client .get(format!( - "{api_base_url}/api/external/v1/editor/assets/library" + "{api_base_url}{}", + resolve_platform_editor_api_route("/api/external/v1/editor/assets/library") )) .bearer_auth(api_key), "读取外部素材库", @@ -794,7 +838,8 @@ pub(crate) async fn prepare_external_canvas_generation_context( let payload = external_editor_json_request( client .post(format!( - "{api_base_url}/api/external/v1/editor/assets/folders" + "{api_base_url}{}", + resolve_platform_editor_api_route("/api/external/v1/editor/assets/folders") )) .bearer_auth(api_key) .json(&serde_json::json!({ "label": canvas_name })), 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 index 26744bda3..da77a8314 100644 --- 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 @@ -134,7 +134,11 @@ fn request_body_json_and_sha256( pub(crate) fn platform_art_generation_external_service_fingerprint(api_base_url: &str) -> String { let normalized_base_url = api_base_url.trim().trim_end_matches('/'); - format!("{:x}", Sha256::digest(normalized_base_url.as_bytes())) + let account_identity = platform_session_service_identity().unwrap_or_default(); + format!( + "{:x}", + Sha256::digest(format!("{normalized_base_url}\n{account_identity}").as_bytes()) + ) } pub(crate) fn platform_art_generation_external_service_origin( 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 8a4ac0483..75f8bb086 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -247,9 +247,12 @@ pub(crate) async fn sync_canvas_project_assets_at( let api_key = resolve_canvas_sync_api_key(api_key)?; let client = reqwest::Client::new(); let project_url = format!( - "{}/api/external/v1/editor/projects/{}", + "{}{}", api_base_url, - percent_encode_query_component(canvas_project_id) + resolve_platform_editor_api_route(&format!( + "/api/external/v1/editor/projects/{}", + percent_encode_query_component(canvas_project_id) + )) ); let project_response = client .get(project_url) @@ -615,7 +618,7 @@ pub(crate) async fn resolve_canvas_resource_download_with_limit( api_key, resource, max_bytes, - "/api/external/v1/assets/read-url", + &resolve_platform_editor_api_route("/api/external/v1/assets/read-url"), ) .await } @@ -776,6 +779,11 @@ pub(crate) async fn resolve_external_asset_signed_url( pub(crate) fn resolve_canvas_sync_api_base_url( api_base_url: Option, ) -> Result { + if editor_api_mode() == EditorApiMode::PlatformAccount { + return current_platform_session() + .map(|session| session.api_base_url) + .ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string()); + } let config = load_game_creator_app_config()?; let value = trim_optional_string(api_base_url) .or_else(|| trim_config_string(&config.editor_api.base_url)) @@ -789,17 +797,113 @@ pub(crate) fn resolve_canvas_sync_api_base_url( } pub(crate) fn resolve_canvas_sync_api_key(api_key: Option) -> Result { + if editor_api_mode() == EditorApiMode::PlatformAccount { + return current_platform_session() + .map(|session| session.access_token) + .ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string()); + } let config = load_game_creator_app_config()?; trim_optional_string(api_key) .or_else(|| trim_config_string(&config.editor_api.api_key)) .ok_or_else(|| { format!( - "画板同步需要在 {} 的 editorApi.apiKey 中设置 API Key", + "请在 {} 的 editorApi.apiKey 中设置开发者 API Key", game_creator_config_file_label(GAME_CREATOR_CONFIG_FILE_NAME) ) }) } +pub(crate) fn resolve_canvas_sync_api_credentials( + api_base_url: Option, + api_key: Option, +) -> Result<(String, String, Option), String> { + if editor_api_mode() == EditorApiMode::PlatformAccount { + let session = current_platform_session() + .ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string())?; + return Ok(( + session.api_base_url.clone(), + session.access_token.clone(), + Some(session), + )); + } + Ok(( + resolve_canvas_sync_api_base_url(api_base_url)?, + resolve_canvas_sync_api_key(api_key)?, + None, + )) +} + +pub(crate) fn resolve_platform_editor_api_route(external_route: &str) -> String { + resolve_platform_editor_api_route_for_mode( + external_route, + editor_api_mode() == EditorApiMode::PlatformAccount, + ) +} + +fn resolve_platform_editor_api_route_for_mode( + external_route: &str, + platform_session_available: bool, +) -> String { + if !platform_session_available { + return external_route.to_string(); + } + if let Some(suffix) = external_route.strip_prefix("/api/external/v1/editor/") { + return format!("/api/editor/{suffix}"); + } + if let Some(suffix) = external_route.strip_prefix("/api/external/v1/assets/") { + return format!("/api/assets/{suffix}"); + } + external_route.to_string() +} + +pub(crate) fn resolve_platform_generation_status_route(operation_id: &str) -> String { + resolve_platform_generation_status_route_for_mode( + operation_id, + editor_api_mode() == EditorApiMode::PlatformAccount, + ) +} + +pub(crate) fn editor_api_authentication_error() -> String { + match editor_api_mode() { + EditorApiMode::PlatformAccount => { + "authentication-required: 陶泥儿登录已失效,请重新登录".to_string() + } + EditorApiMode::ExternalDeveloper => { + "authentication-required: External Editor API Key 无效或权限不足".to_string() + } + } +} + +pub(crate) fn editor_api_authorization_error() -> String { + match editor_api_mode() { + EditorApiMode::PlatformAccount => { + "permission-denied: 当前陶泥儿账号没有执行此操作的权限".to_string() + } + EditorApiMode::ExternalDeveloper => { + "permission-denied: External Editor API Key 权限不足".to_string() + } + } +} + +fn resolve_platform_generation_status_route_for_mode( + operation_id: &str, + platform_session_available: bool, +) -> String { + let operation_id = percent_encode_query_component(operation_id); + if platform_session_available { + format!("/api/runtime/external-generation/jobs/{operation_id}") + } else { + format!("/api/external/v1/generations/{operation_id}") + } +} + +pub(crate) fn platform_generation_status_data(payload: &serde_json::Value) -> &serde_json::Value { + let data = payload.get("data").unwrap_or(payload); + data.get("job") + .filter(|job| job.is_object()) + .unwrap_or(data) +} + pub(crate) fn json_string_field(value: &serde_json::Value, field: &str) -> Option { value .get(field) @@ -1111,6 +1215,51 @@ mod tests { use super::*; use std::io::{Read, Write}; + #[test] + fn platform_session_maps_external_shaped_calls_to_first_party_routes() { + assert_eq!( + resolve_platform_editor_api_route_for_mode("/api/external/v1/editor/projects", true,), + "/api/editor/projects" + ); + assert_eq!( + resolve_platform_editor_api_route_for_mode("/api/external/v1/assets/read-url", true,), + "/api/assets/read-url" + ); + assert_eq!( + resolve_platform_generation_status_route_for_mode("operation/a b", true), + "/api/runtime/external-generation/jobs/operation%2Fa%20b" + ); + assert_eq!( + resolve_platform_generation_status_route_for_mode("operation/a b", false), + "/api/external/v1/generations/operation%2Fa%20b" + ); + let payload = serde_json::json!({ + "data": { + "job": { + "operationId": "operation-1", + "status": "completed", + "result": { "objectKey": "generated/result.png" }, + } + } + }); + assert_eq!( + platform_generation_status_data(&payload)["result"]["objectKey"], + "generated/result.png" + ); + } + + #[test] + fn advanced_external_mode_keeps_external_routes_without_platform_session() { + assert_eq!( + resolve_platform_editor_api_route_for_mode("/api/external/v1/editor/projects", false,), + "/api/external/v1/editor/projects" + ); + assert_eq!( + resolve_platform_generation_status_route_for_mode("operation-1", false), + "/api/external/v1/generations/operation-1" + ); + } + fn read_asset_test_request(stream: &mut std::net::TcpStream) { stream .set_read_timeout(Some(Duration::from_secs(2))) diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 3497aae4a..5e41e3cd6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1270,6 +1270,30 @@ pub(crate) fn check_game_creator_llm_config() -> GameCreatorLlmConfigStatus { check_game_creator_llm_config_from_config() } +#[tauri::command] +pub(crate) fn install_platform_account_session( + user_id: String, + access_token: String, + api_base_url: String, + generation: u64, +) -> Result<(), String> { + install_platform_session(&user_id, &access_token, &api_base_url, generation)?; + let _ = install_external_agent_runner_platform_session( + &user_id, + &access_token, + &api_base_url, + generation, + ); + Ok(()) +} + +#[tauri::command] +pub(crate) fn clear_platform_account_session(generation: u64) -> Result<(), String> { + clear_platform_session(generation); + let _ = clear_external_agent_runner_platform_session(generation); + Ok(()) +} + #[tauri::command] pub(crate) fn read_game_creator_app_config() -> Result { game_creator_app_config_view(load_game_creator_app_config()?) @@ -1281,8 +1305,7 @@ pub(crate) fn write_game_creator_app_config( ) -> Result { let config = normalize_game_creator_app_config(config)?; let path = writable_game_creator_config_path()?; - let content = serde_json::to_string_pretty(&config) - .map_err(|error| format!("序列化客户端配置失败:{error}"))?; + let content = serialize_game_creator_app_config_for_renderer_write(&config)?; write_game_creator_config_atomically(&path, &format!("{content}\n"))?; game_creator_app_config_view(load_game_creator_app_config()?) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 4b47ff186..5a6922531 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -1329,14 +1329,31 @@ pub(crate) fn load_game_creator_app_config() -> Result Result { + if editor_api_mode() == EditorApiMode::PlatformAccount { + config.editor_api = GameCreatorEditorApiConfig::default(); + } Ok(GameCreatorAppConfigView { path: writable_game_creator_config_path()?.display().to_string(), config, }) } +pub(crate) fn serialize_game_creator_app_config_for_renderer_write( + config: &GameCreatorAppConfig, +) -> Result { + let mut value = + serde_json::to_value(config).map_err(|error| format!("序列化客户端配置失败:{error}"))?; + if editor_api_mode() == EditorApiMode::PlatformAccount { + value + .as_object_mut() + .ok_or_else(|| "客户端配置必须是 JSON object".to_string())? + .remove("editorApi"); + } + serde_json::to_string_pretty(&value).map_err(|error| format!("序列化客户端配置失败:{error}")) +} + pub(crate) fn writable_game_creator_config_path() -> Result { if let Some(config_dir) = game_creator_runtime_config_dir() { return Ok(config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME)); 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 63d5b0c5c..5783fa333 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -67,6 +67,7 @@ mod image_inspect; mod isolated_agent; mod mcp; mod patchset; +mod platform_session; mod preview; mod process_session; mod process_session_bridge; @@ -101,6 +102,7 @@ use image_inspect::*; use isolated_agent::*; use mcp::*; use patchset::*; +use platform_session::*; use preview::*; use process_session::*; use project::*; @@ -1239,7 +1241,7 @@ fn default_game_creator_llm_auto_compact_token_limit() -> u64 { fn default_game_creator_llm_tool_output_token_limit() -> u64 { DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT } -const DEFAULT_CANVAS_SYNC_API_BASE_URL: &str = "http://127.0.0.1:8082"; +const DEFAULT_CANVAS_SYNC_API_BASE_URL: &str = "https://dev.genarrative.world"; const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json"); const GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS: u32 = 320000; const GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS: u32 = 1800; @@ -2216,6 +2218,8 @@ fn main() { confirm_resume_game_creator_agent_runtime_tasks, schedule_game_creator_agent_ready_tasks, check_game_creator_llm_config, + install_platform_account_session, + clear_platform_account_session, read_game_creator_app_config, write_game_creator_app_config, read_game_creator_mcp_catalog, diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs new file mode 100644 index 000000000..7b9ec1341 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs @@ -0,0 +1,320 @@ +use std::sync::{Mutex, OnceLock}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PlatformSessionSnapshot { + pub(crate) user_id: String, + pub(crate) access_token: String, + pub(crate) api_base_url: String, + pub(crate) generation: u64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum EditorApiMode { + PlatformAccount, + ExternalDeveloper, +} + +pub(crate) fn editor_api_mode_for_build( + debug_assertions: bool, + game_chat_release_feature: bool, +) -> EditorApiMode { + if !debug_assertions && game_chat_release_feature { + EditorApiMode::ExternalDeveloper + } else { + EditorApiMode::PlatformAccount + } +} + +pub(crate) fn editor_api_mode() -> EditorApiMode { + editor_api_mode_for_build(cfg!(debug_assertions), cfg!(feature = "game-chat-release")) +} + +#[derive(Default)] +struct PlatformSessionState { + generation: u64, + snapshot: Option, +} + +static PLATFORM_SESSION: OnceLock> = OnceLock::new(); + +fn platform_session() -> &'static Mutex { + PLATFORM_SESSION.get_or_init(|| Mutex::new(PlatformSessionState::default())) +} + +fn install_platform_session_in( + current: &mut PlatformSessionState, + user_id: &str, + access_token: &str, + api_base_url: &str, + generation: u64, +) { + if generation < current.generation { + return; + } + if generation == current.generation { + if current.snapshot.as_ref().is_some_and(|snapshot| { + snapshot.user_id == user_id + && snapshot.access_token == access_token + && snapshot.api_base_url == api_base_url + }) { + return; + } + // Equal-generation retries may only repeat the exact committed snapshot. In + // particular, a late install cannot revive a generation that was cleared. + return; + } + current.generation = generation; + current.snapshot = Some(PlatformSessionSnapshot { + user_id: user_id.to_string(), + access_token: access_token.to_string(), + api_base_url: api_base_url.to_string(), + generation, + }); +} + +fn clear_platform_session_in(current: &mut PlatformSessionState, generation: u64) { + if generation < current.generation { + return; + } + current.generation = generation; + current.snapshot = None; +} + +pub(crate) fn install_platform_session( + user_id: &str, + access_token: &str, + api_base_url: &str, + generation: u64, +) -> Result<(), String> { + if editor_api_mode() == EditorApiMode::ExternalDeveloper { + return Err("独立 game-chat 高级模式不接受陶泥儿网站登录态".to_string()); + } + let user_id = user_id.trim(); + let access_token = access_token.trim(); + let api_base_url = normalize_platform_api_base_url(api_base_url)?; + if user_id.is_empty() || user_id.len() > 256 { + return Err("陶泥儿登录用户身份无效".to_string()); + } + if access_token.is_empty() || access_token.len() > 16 * 1024 { + return Err("陶泥儿登录凭据无效".to_string()); + } + let mut current = platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + install_platform_session_in( + &mut current, + user_id, + access_token, + &api_base_url, + generation, + ); + Ok(()) +} + +fn normalize_platform_api_base_url(value: &str) -> Result { + let value = value.trim().trim_end_matches('/'); + let parsed = url::Url::parse(value).map_err(|_| "陶泥儿服务地址无效".to_string())?; + if !matches!(parsed.scheme(), "http" | "https") + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + || parsed.path() != "/" + { + return Err("陶泥儿服务地址必须是纯 HTTP(S) origin".to_string()); + } + let host = parsed + .host_str() + .ok_or_else(|| "陶泥儿服务地址缺少 host".to_string())?; + let production = matches!(host, "www.genarrative.world" | "dev.genarrative.world"); + let loopback = host == "localhost" + || host == "127.0.0.1" + || host + .parse::() + .is_ok_and(|ip| ip.is_loopback()); + if !production && !(cfg!(debug_assertions) && loopback && parsed.scheme() == "http") { + return Err("陶泥儿服务地址不在受信任白名单内".to_string()); + } + Ok(value.to_string()) +} + +pub(crate) fn clear_platform_session(generation: u64) { + let mut current = platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + clear_platform_session_in(&mut current, generation); +} + +pub(crate) fn current_platform_session() -> Option { + platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .snapshot + .clone() +} + +pub(crate) fn validate_platform_session_snapshot( + expected: &PlatformSessionSnapshot, +) -> Result<(), String> { + if platform_session_snapshot_matches(current_platform_session().as_ref(), expected) { + Ok(()) + } else { + Err( + "authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试" + .to_string(), + ) + } +} + +fn platform_session_snapshot_matches( + current: Option<&PlatformSessionSnapshot>, + expected: &PlatformSessionSnapshot, +) -> bool { + current == Some(expected) +} + +pub(crate) fn platform_session_is_available() -> bool { + current_platform_session().is_some() +} + +pub(crate) fn platform_session_service_identity() -> Option { + current_platform_session() + .map(|snapshot| format!("{}\nuser:{}", snapshot.api_base_url, snapshot.user_id)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cleared_generation_rejects_late_install_and_older_clear() { + let mut state = PlatformSessionState::default(); + install_platform_session_in( + &mut state, + "user-a", + "token-a", + "https://dev.genarrative.world", + 1, + ); + clear_platform_session_in(&mut state, 2); + install_platform_session_in( + &mut state, + "user-a", + "late-token-a", + "https://dev.genarrative.world", + 1, + ); + install_platform_session_in( + &mut state, + "user-a", + "same-generation-token", + "https://dev.genarrative.world", + 2, + ); + assert!(state.snapshot.is_none()); + assert_eq!(state.generation, 2); + + install_platform_session_in( + &mut state, + "user-b", + "token-b", + "https://dev.genarrative.world", + 3, + ); + clear_platform_session_in(&mut state, 2); + assert_eq!( + state.snapshot.as_ref().map(|value| value.user_id.as_str()), + Some("user-b") + ); + assert_eq!(state.generation, 3); + } + + #[test] + fn equal_generation_only_accepts_the_exact_idempotent_snapshot() { + let mut state = PlatformSessionState::default(); + install_platform_session_in( + &mut state, + "user-a", + "token-a", + "https://dev.genarrative.world", + 4, + ); + install_platform_session_in( + &mut state, + "user-a", + "token-a", + "https://dev.genarrative.world", + 4, + ); + install_platform_session_in( + &mut state, + "user-b", + "token-b", + "https://dev.genarrative.world", + 4, + ); + assert_eq!( + state.snapshot.as_ref().map(|value| value.user_id.as_str()), + Some("user-a") + ); + assert_eq!( + state + .snapshot + .as_ref() + .map(|value| value.access_token.as_str()), + Some("token-a") + ); + } + + #[test] + fn editor_api_mode_is_fixed_by_the_trusted_build_flavor() { + assert_eq!( + editor_api_mode_for_build(true, false), + EditorApiMode::PlatformAccount + ); + assert_eq!( + editor_api_mode_for_build(true, true), + EditorApiMode::PlatformAccount + ); + assert_eq!( + editor_api_mode_for_build(false, false), + EditorApiMode::PlatformAccount + ); + assert_eq!( + editor_api_mode_for_build(false, true), + EditorApiMode::ExternalDeveloper + ); + } + + #[test] + fn frozen_platform_session_rejects_logout_account_switch_and_token_rotation() { + let expected = PlatformSessionSnapshot { + user_id: "user-a".to_string(), + access_token: "token-a".to_string(), + api_base_url: "https://dev.genarrative.world".to_string(), + generation: 4, + }; + assert!(platform_session_snapshot_matches( + Some(&expected), + &expected + )); + + for current in [ + None, + Some(PlatformSessionSnapshot { + user_id: "user-b".to_string(), + ..expected.clone() + }), + Some(PlatformSessionSnapshot { + access_token: "token-b".to_string(), + generation: 5, + ..expected.clone() + }), + ] { + assert!(!platform_session_snapshot_matches( + current.as_ref(), + &expected + )); + } + } +} 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 ee4eea522..4a882ae7d 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 @@ -266,6 +266,10 @@ struct AssetCanvasGenerationLedger { #[serde(default, alias = "externalConfigurationFingerprint")] api_identity_fingerprint: Option, #[serde(default)] + platform_owner_user_id: Option, + #[serde(default)] + platform_owner_api_base_url: Option, + #[serde(default)] service_identity_confirmation: Option, canvas_context: Option, endpoint: Option, @@ -312,11 +316,14 @@ struct ReferenceMaterial { media_type: Option, file_name: Option, sha256: Option, + width: Option, + height: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ReferencePreparationError { AuthenticationRequired, + PermissionDenied, MaterialInvalid, TicketFailed, ObjectUploadFailed, @@ -328,6 +335,7 @@ impl ReferencePreparationError { fn code(self) -> &'static str { match self { Self::AuthenticationRequired => "authentication-required", + Self::PermissionDenied => "permission-denied", Self::MaterialInvalid => "reference-material-invalid", Self::TicketFailed => "reference-ticket-failed", Self::ObjectUploadFailed => "reference-object-upload-failed", @@ -337,8 +345,10 @@ impl ReferencePreparationError { } fn from_http_error(error: &str, fallback: Self) -> Self { - if error.contains("HTTP 401") || error.contains("HTTP 403") { + if error.contains("HTTP 401") { Self::AuthenticationRequired + } else if error.contains("HTTP 403") { + Self::PermissionDenied } else { fallback } @@ -362,11 +372,48 @@ impl CanvasGenerationApiMode { } fn resolve_generation_api_mode() -> Result<(String, CanvasGenerationApiMode), String> { - let api_base_url = resolve_canvas_sync_api_base_url(None)?; - let api_key = resolve_canvas_sync_api_key(None)?; + let (api_base_url, api_key, _) = resolve_canvas_sync_api_credentials(None, None)?; Ok((api_base_url, CanvasGenerationApiMode { api_key })) } +fn bind_generation_platform_owner( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, + session: Option<&PlatformSessionSnapshot>, +) -> Result<(), String> { + let Some(session) = session else { + return Ok(()); + }; + match ( + ledger.platform_owner_user_id.as_deref(), + ledger.platform_owner_api_base_url.as_deref(), + ) { + (None, None) if ledger.operation_id.is_none() && ledger.remote_result.is_none() => { + ledger.platform_owner_user_id = Some(session.user_id.clone()); + ledger.platform_owner_api_base_url = Some(session.api_base_url.clone()); + write_generation_ledger(root, ledger) + } + (Some(user_id), Some(api_base_url)) + if user_id == session.user_id && api_base_url == session.api_base_url => + { + Ok(()) + } + _ => Err( + "authentication-required: 此素材生成属于其他陶泥儿账号,当前账号不能恢复或提交" + .to_string(), + ), + } +} + +fn ensure_frozen_generation_platform_session( + session: Option<&PlatformSessionSnapshot>, +) -> Result<(), String> { + if let Some(session) = session { + validate_platform_session_snapshot(session)?; + } + Ok(()) +} + fn canvas_api_identity_fingerprint(api_base_url: &str, mode: &CanvasGenerationApiMode) -> String { let _ = mode; platform_art_generation_external_service_fingerprint(api_base_url) @@ -1041,6 +1088,8 @@ fn validate_and_prepare_ledger( resolved_reference_ids: Vec::new(), api_identity_scheme: None, api_identity_fingerprint: None, + platform_owner_user_id: None, + platform_owner_api_base_url: None, service_identity_confirmation: None, canvas_context: None, endpoint: None, @@ -1076,6 +1125,10 @@ fn stable_manifest_reference(asset: &GameCreationAppAssetManifestEntry) -> Optio .map(str::to_string) } +fn is_registered_editor_reference_id(value: &str) -> bool { + value.starts_with("editor-resource-") || value.starts_with("editor-asset-") +} + fn reference_material_at( root: &Path, ledger: &AssetCanvasGenerationLedger, @@ -1090,16 +1143,22 @@ fn reference_material_at( || asset_canvas_source_resource_id(asset) == resource_id || asset.source.resource_id.as_deref() == Some(resource_id) }) { - if let Some(stable_reference) = stable_manifest_reference(asset) { + if let Some(stable_reference) = stable_manifest_reference(asset).filter(|reference| { + ledger.intent != AssetCanvasIntent::Refine + || ledger.source_resource_id.as_deref() != Some(resource_id) + || is_registered_editor_reference_id(reference) + }) { return Ok(ReferenceMaterial { stable_reference: Some(stable_reference), bytes: None, media_type: None, file_name: None, sha256: None, + width: None, + height: None, }); } - let (bytes, _, _) = open_and_validate_image_file( + let (bytes, width, height) = open_and_validate_image_file( &resolve_local_project_path(root, &asset.local_path)?, &asset.media_type, None, @@ -1112,6 +1171,8 @@ fn reference_material_at( media_type: Some(asset.media_type.clone()), file_name: Some(format!("reference-{sha256}.{extension}")), sha256: Some(sha256), + width: Some(width), + height: Some(height), }); } let layer = draft @@ -1122,7 +1183,7 @@ fn reference_material_at( .ok_or_else(|| "参考资源不属于当前草稿或项目 manifest".to_string())?; let (relative_path, media_type, expected_sha256) = draft_media_relative_path(&draft.draft_id, &layer.media_ref)?; - let (bytes, _, _) = open_and_validate_image_file( + let (bytes, width, height) = open_and_validate_image_file( &resolve_local_project_path(root, &relative_path)?, &media_type, expected_sha256.as_deref(), @@ -1135,6 +1196,8 @@ fn reference_material_at( media_type: Some(media_type), file_name: Some(format!("reference-{sha256}.{extension}")), sha256: Some(sha256), + width: Some(width), + height: Some(height), }) } @@ -1147,7 +1210,7 @@ async fn try_confirm_uploaded_reference( material: &ReferenceMaterial, asset_kind: &str, ) -> Result, ReferencePreparationError> { - let endpoint = "/api/external/v1/assets/objects/confirm"; + let endpoint = resolve_platform_editor_api_route("/api/external/v1/assets/objects/confirm"); let response = authorize_canvas_request( client .post(format!("{api_base_url}{endpoint}")) @@ -1165,12 +1228,12 @@ async fn try_confirm_uploaded_reference( .send() .await .map_err(|_| ReferencePreparationError::ConfirmFailed)?; - if matches!( - response.status(), - reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN - ) { + if matches!(response.status(), reqwest::StatusCode::UNAUTHORIZED) { return Err(ReferencePreparationError::AuthenticationRequired); } + if response.status() == reqwest::StatusCode::FORBIDDEN { + return Err(ReferencePreparationError::PermissionDenied); + } if !response.status().is_success() { return Ok(None); } @@ -1199,7 +1262,8 @@ async fn request_upload_ticket( ledger: &AssetCanvasGenerationLedger, material: &ReferenceMaterial, ) -> Result { - let endpoint = "/api/external/v1/assets/direct-upload-tickets"; + let endpoint = + resolve_platform_editor_api_route("/api/external/v1/assets/direct-upload-tickets"); let payload = external_editor_json_request( authorize_canvas_request( client @@ -1313,6 +1377,62 @@ async fn upload_reference( Ok(()) } +async fn register_uploaded_image_reference( + client: &reqwest::Client, + api_base_url: &str, + api_mode: &CanvasGenerationApiMode, + ledger: &AssetCanvasGenerationLedger, + material: &ReferenceMaterial, + object_key: &str, + asset_object_id: &str, +) -> Result { + let context = ledger + .canvas_context + .as_ref() + .ok_or(ReferencePreparationError::Internal)?; + let width = material + .width + .ok_or(ReferencePreparationError::MaterialInvalid)?; + let height = material + .height + .ok_or(ReferencePreparationError::MaterialInvalid)?; + let endpoint = format!( + "/api/external/v1/editor/projects/{}/resources", + percent_encode_query_component(&context.project_id) + ); + let payload = external_editor_json_request( + authorize_canvas_request( + client + .post(format!( + "{api_base_url}{}", + resolve_platform_editor_api_route(&endpoint) + )) + .json(&serde_json::json!({ + "imageSrc": format!("/{}", object_key.trim_start_matches('/')), + "objectKey": object_key, + "assetObjectId": asset_object_id, + "width": width, + "height": height, + "sourceType": "uploaded", + "assetKind": ledger.asset_kind, + "generationInputs": { + "source": ASSET_CANVAS_RESOURCE_EDIT_QUEUE_SOURCE, + "operationId": ledger.generation_id, + }, + })), + api_mode, + ), + "登记参考图片项目资源", + ) + .await + .map_err(|error| { + ReferencePreparationError::from_http_error(&error, ReferencePreparationError::ConfirmFailed) + })?; + let data = external_editor_response_data(&payload); + let resource = data.get("resource").unwrap_or(data); + json_string_field(resource, "resourceId").ok_or(ReferencePreparationError::ConfirmFailed) +} + async fn ensure_reference_states( root: &Path, ledger: &mut AssetCanvasGenerationLedger, @@ -1350,7 +1470,20 @@ async fn ensure_reference_states( } }; if ledger.reference_states[index].stable_reference.is_some() { - continue; + let requires_registered_source = ledger.intent == AssetCanvasIntent::Refine + && ledger.source_resource_id.as_deref() == Some(resource_id.as_str()); + if !requires_registered_source + || ledger.reference_states[index] + .stable_reference + .as_deref() + .is_some_and(is_registered_editor_reference_id) + { + continue; + } + // A legacy prepared refine ledger may carry an objectKey. It cannot + // be sent as sourceReferenceId, so re-register it before submission. + ledger.reference_states[index].stable_reference = None; + write_generation_ledger(root, ledger)?; } if let Some(stable_reference) = material.stable_reference.clone() { ledger.reference_states[index].stable_reference = Some(stable_reference); @@ -1373,7 +1506,23 @@ async fn ensure_reference_states( ) .await? { - ledger.reference_states[index].stable_reference = Some(object_key); + let stable_reference = if ledger.intent == AssetCanvasIntent::Refine + && ledger.source_resource_id.as_deref() == Some(resource_id.as_str()) + { + register_uploaded_image_reference( + client, + api_base_url, + api_mode, + ledger, + &material, + &object_key, + &asset_object_id, + ) + .await? + } else { + object_key + }; + ledger.reference_states[index].stable_reference = Some(stable_reference); ledger.reference_states[index].asset_object_id = Some(asset_object_id); ledger.reference_states[index].upload_bucket = None; ledger.reference_states[index].upload_object_key = None; @@ -1402,7 +1551,23 @@ async fn ensure_reference_states( ) .await? .ok_or(ReferencePreparationError::ConfirmFailed)?; - ledger.reference_states[index].stable_reference = Some(ticket.object_key); + let stable_reference = if ledger.intent == AssetCanvasIntent::Refine + && ledger.source_resource_id.as_deref() == Some(resource_id.as_str()) + { + register_uploaded_image_reference( + client, + api_base_url, + api_mode, + ledger, + &material, + &ticket.object_key, + &asset_object_id, + ) + .await? + } else { + ticket.object_key + }; + ledger.reference_states[index].stable_reference = Some(stable_reference); ledger.reference_states[index].asset_object_id = Some(asset_object_id); ledger.reference_states[index].upload_bucket = None; ledger.reference_states[index].upload_object_key = None; @@ -1487,7 +1652,7 @@ fn build_generation_request_snapshot( "operationId": ledger.generation_id, }), ); - let source_image_src = ledger + let source_reference_id = ledger .source_resource_id .as_ref() .and_then(|resource_id| { @@ -1497,30 +1662,15 @@ fn build_generation_request_snapshot( .find(|state| state.resource_id == *resource_id) }) .and_then(|state| state.stable_reference.clone()) - .ok_or_else(|| "精修生成缺少稳定源图片引用".to_string())?; + .ok_or_else(|| "精修生成缺少正式源资源 ID".to_string())?; body.insert( - "sourceImageSrc".to_string(), - serde_json::json!(source_image_src), + "sourceReferenceId".to_string(), + serde_json::json!(source_reference_id), ); - if let Some(source_resource_id) = ledger - .source_resource_id - .as_deref() - .map(str::trim) - .filter(|value| { - !value.is_empty() - && !value.starts_with("local-asset:") - && !value.starts_with("draft-media:") - }) - { - body.insert( - "sourceResourceId".to_string(), - serde_json::json!(source_resource_id), - ); - } let additional = ledger .resolved_reference_ids .iter() - .filter(|reference| **reference != source_image_src) + .filter(|reference| **reference != source_reference_id) .cloned() .collect::>(); body.insert( @@ -1654,10 +1804,19 @@ fn mark_generation_error( fn sanitized_generation_error(code: &str) -> String { match code { - "configuration-missing" => "External Editor API 配置缺失,未创建任何资源".to_string(), - "authentication-required" => { - "External Editor API Key 无效或权限不足,请检查客户端私有配置".to_string() - } + "configuration-missing" => match editor_api_mode() { + EditorApiMode::PlatformAccount => "陶泥儿登录态缺失,请先登录后再生成图片".to_string(), + EditorApiMode::ExternalDeveloper => { + "External Editor API 配置缺失,未创建任何资源".to_string() + } + }, + "authentication-required" => match editor_api_mode() { + EditorApiMode::PlatformAccount => "陶泥儿登录已失效,请重新登录".to_string(), + EditorApiMode::ExternalDeveloper => { + "External Editor API Key 无效或权限不足,请检查客户端私有配置".to_string() + } + }, + "permission-denied" => editor_api_authorization_error(), "insufficient-mud-points" => "泥点余额不足,请充值后重试".to_string(), "platform-service-configuration" => { "平台图片生成服务暂不可用,请稍后重试或联系管理员".to_string() @@ -1683,7 +1842,15 @@ fn sanitized_generation_error(code: &str) -> String { fn sanitized_classified_generation_error(code: &str, reconciliation: bool) -> String { if reconciliation && code == "authentication-required" { - return "reconciliation-required: External Editor API Key 无效或权限不足;修正私有配置后使用原 operation 恢复".to_string(); + return match editor_api_mode() { + EditorApiMode::PlatformAccount => { + "reconciliation-required: 陶泥儿登录态已失效;重新登录后使用原 operation 恢复" + .to_string() + } + EditorApiMode::ExternalDeveloper => { + "reconciliation-required: External Editor API Key 无效或权限不足;修正私有配置后使用原 operation 恢复".to_string() + } + }; } sanitized_generation_error(code) } @@ -1732,12 +1899,12 @@ async fn wait_for_canvas_generation_result( async fn classify_canvas_submit_error(response: reqwest::Response) -> (&'static str, bool) { let status = response.status(); - if matches!( - status, - reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN - ) { + if matches!(status, reqwest::StatusCode::UNAUTHORIZED) { return ("authentication-required", false); } + if status == reqwest::StatusCode::FORBIDDEN { + return ("permission-denied", false); + } let body = response.text().await.unwrap_or_default(); if status == reqwest::StatusCode::CONFLICT && body.contains("泥点余额不足") { return ("insufficient-mud-points", false); @@ -1761,6 +1928,9 @@ fn classify_canvas_generation_error(error: &str) -> (bool, &'static str) { if error.contains("authentication-required") { return (true, "authentication-required"); } + if error.contains("permission-denied") { + return (false, "permission-denied"); + } if error.contains("insufficient-mud-points") || error.contains("泥点余额不足") { return (false, "insufficient-mud-points"); } @@ -1789,6 +1959,7 @@ async fn reconcile_generation( mut ledger: AssetCanvasGenerationLedger, api_base_url: &str, api_mode: &CanvasGenerationApiMode, + platform_session: Option<&PlatformSessionSnapshot>, emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), ) -> Result { if ledger.phase == GenerationLedgerPhase::AssetDurableCommitted { @@ -1799,6 +1970,8 @@ async fn reconcile_generation( ledger.error_code.as_deref().unwrap_or("generation-failed"), )); } + bind_generation_platform_owner(root, &mut ledger, platform_session)?; + ensure_frozen_generation_platform_session(platform_session)?; let configuration_fingerprint = canvas_api_identity_fingerprint(api_base_url, api_mode); match prepare_generation_service_identity(root, &mut ledger, api_base_url, api_mode) { Ok(CanvasServiceIdentityDecision::Ready) => {} @@ -1911,6 +2084,7 @@ async fn reconcile_generation( return Err(sanitized_generation_error("submit-result-unknown")); } }; + ensure_frozen_generation_platform_session(platform_session)?; let status = response.status(); if !status.is_success() { let (code, reconciliation) = classify_canvas_submit_error(response).await; @@ -1945,7 +2119,10 @@ async fn reconcile_generation( ) .await { - Ok(result) => result, + Ok(result) => { + ensure_frozen_generation_platform_session(platform_session)?; + result + } Err(error) => { let (reconciliation, code) = classify_canvas_generation_error(&error); mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; @@ -1971,7 +2148,10 @@ async fn reconcile_generation( publish_public_phase(root, &mut ledger, emit)?; match wait_for_canvas_generation_result(&client, api_base_url, api_mode, &submission).await { - Ok(result) => result, + Ok(result) => { + ensure_frozen_generation_platform_session(platform_session)?; + result + } Err(error) => { let (reconciliation, code) = classify_canvas_generation_error(&error); mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; @@ -2026,7 +2206,10 @@ async fn reconcile_generation( ) .await; let download = match download_result { - Ok(Some(download)) => download, + Ok(Some(download)) => { + ensure_frozen_generation_platform_session(platform_session)?; + download + } _ => { mark_generation_error( root, @@ -2080,6 +2263,7 @@ async fn reconcile_generation( } if ledger.phase == GenerationLedgerPhase::MediaDownloaded { + ensure_frozen_generation_platform_session(platform_session)?; if !synchronize_staged_image_revision(root, &mut ledger)? { mark_generation_error( root, @@ -2255,15 +2439,25 @@ pub(crate) async fn generate_asset_canvas_image_at( let _guard = generation_singleflight_lock(&input.expected_project_id, &input.generation_id).await; let mut ledger = validate_and_prepare_ledger(root, input)?; - let (api_base_url, api_mode) = match resolve_generation_api_mode() { - Ok(value) => value, - Err(_) => { - let code = "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 + let (api_base_url, api_key, platform_session) = + match resolve_canvas_sync_api_credentials(None, None) { + Ok(value) => value, + Err(_) => { + let code = "configuration-missing"; + mark_generation_error(root, &mut ledger, false, code, &mut emit)?; + return Err(sanitized_generation_error(code)); + } + }; + let api_mode = CanvasGenerationApiMode { api_key }; + reconcile_generation( + root, + ledger, + &api_base_url, + &api_mode, + platform_session.as_ref(), + &mut emit, + ) + .await } pub(crate) async fn recover_asset_canvas_generations_at( @@ -2314,8 +2508,9 @@ pub(crate) async fn recover_asset_canvas_generations_at( events: Vec::new(), }); } - let (api_base_url, api_mode) = resolve_generation_api_mode() + let (api_base_url, api_key, platform_session) = resolve_canvas_sync_api_credentials(None, None) .map_err(|_| sanitized_generation_error("configuration-missing"))?; + let api_mode = CanvasGenerationApiMode { api_key }; let mut resumed = Vec::new(); let mut service_identity_confirmations = Vec::new(); let mut events = Vec::new(); @@ -2354,7 +2549,19 @@ pub(crate) async fn recover_asset_canvas_generations_at( Err(_) => {} } resumed.push(generation_id.clone()); - match reconcile_generation(root, ledger, &api_base_url, &api_mode, &mut emit).await { + if bind_generation_platform_owner(root, &mut ledger, platform_session.as_ref()).is_err() { + continue; + } + match reconcile_generation( + root, + ledger, + &api_base_url, + &api_mode, + platform_session.as_ref(), + &mut emit, + ) + .await + { Ok(execution) => { if let Some(event) = execution.event { events.push(event); @@ -2670,6 +2877,8 @@ mod tests { api_identity_fingerprint: Some( platform_art_generation_external_configuration_fingerprint(base_url, api_key), ), + platform_owner_user_id: None, + platform_owner_api_base_url: None, service_identity_confirmation: None, canvas_context: Some(PrivateCanvasContext { project_id: "remote-project".to_string(), @@ -2945,7 +3154,7 @@ mod tests { let source_png_len = source_png.len(); let (sender, receiver) = mpsc::channel(); let server = std::thread::spawn(move || { - for _ in 0..9 { + for _ in 0..10 { let (mut stream, _) = listener.accept().expect("accept refine generation request"); let request = read_http_request(&mut stream); sender @@ -2998,6 +3207,19 @@ mod tests { "assetObjectId": "confirmed-reference-object" }}}), ); + } else if request + .starts_with("POST /api/external/v1/editor/projects/remote-project/resources ") + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"resource": { + "resourceId": "editor-resource-source", + "projectId": "remote-project", + "objectKey": server_object_key.clone(), + "assetObjectId": "confirmed-reference-object" + }}}), + ); } else if request.starts_with("POST /api/external/v1/editor/images/edits ") { write_json( &mut stream, @@ -3051,7 +3273,7 @@ mod tests { server.join().expect("join refine generation server"); let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); - assert_eq!(requests.len(), 9); + assert_eq!(requests.len(), 10); let ticket_index = requests .iter() .position(|request| { @@ -3072,16 +3294,27 @@ mod tests { .iter() .position(|request| request.starts_with("POST /api/external/v1/editor/images/edits ")) .expect("refine submit request exists"); + let register_index = requests + .iter() + .position(|request| { + request + .starts_with("POST /api/external/v1/editor/projects/remote-project/resources ") + }) + .expect("project resource registration exists"); assert!(ticket_index < upload_index && upload_index < confirm_index); - assert!(confirm_index < submit_index); + assert!(confirm_index < register_index && register_index < submit_index); assert!(requests[ticket_index].contains("\"legacyPrefix\":\"generated-character-drafts\"")); assert!(requests[ticket_index].contains(&format!( "\"pathSegments\":[\"editor\",\"asset-canvas-references\",\"{}\",\"{}\",\"{}\"]", project_id, draft.draft_id, input.generation_id ))); assert!(requests[confirm_index].contains(&expected_object_key)); - assert!(requests[submit_index] - .contains(&format!("\"sourceImageSrc\":\"{}\"", expected_object_key))); + assert!(requests[register_index] + .contains(&format!("\"imageSrc\":\"/{}\"", expected_object_key))); + assert!(requests[register_index].contains("\"width\":2")); + assert!(requests[register_index].contains("\"height\":2")); + assert!(requests[submit_index].contains("\"sourceReferenceId\":\"editor-resource-source\"")); + assert!(!requests[submit_index].contains("sourceImageSrc")); assert!(!requests[submit_index].contains("sourceResourceId")); let manifest = execution.result.commit.manifest; @@ -3698,7 +3931,7 @@ mod tests { reference_states: vec![ PrivateReferenceState { resource_id: source_resource_id.clone(), - stable_reference: Some("objects/source.png".to_string()), + stable_reference: Some("editor-resource-source".to_string()), asset_object_id: Some("source-object".to_string()), upload_bucket: None, upload_object_key: None, @@ -3714,11 +3947,13 @@ mod tests { }, ], resolved_reference_ids: vec![ - "objects/source.png".to_string(), + "editor-resource-source".to_string(), "objects/style.png".to_string(), ], api_identity_scheme: Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()), api_identity_fingerprint: Some("configuration".to_string()), + platform_owner_user_id: None, + platform_owner_api_base_url: None, service_identity_confirmation: None, canvas_context: Some(PrivateCanvasContext { project_id: "remote-project".to_string(), @@ -3744,7 +3979,8 @@ mod tests { let request: serde_json::Value = serde_json::from_str(&request).expect("parse refine request"); assert_eq!(endpoint, "/api/external/v1/editor/images/edits"); - assert_eq!(request["sourceImageSrc"], "objects/source.png"); + assert_eq!(request["sourceReferenceId"], "editor-resource-source"); + assert!(request.get("sourceImageSrc").is_none()); assert!(request.get("sourceResourceId").is_none()); assert_eq!( request["referenceImageSrcs"], diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index f054e8ee9..b33846dea 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -327,6 +327,10 @@ struct ResourceEditLedger { #[serde(default)] api_identity_fingerprint: Option, #[serde(default)] + platform_owner_user_id: Option, + #[serde(default)] + platform_owner_api_base_url: Option, + #[serde(default)] service_identity_confirmation: Option, phase: ResourceEditLedgerPhase, endpoint: Option, @@ -735,6 +739,10 @@ fn sha256_hex(bytes: &[u8]) -> String { format!("{:x}", hasher.finalize()) } +fn is_registered_editor_reference_id(value: &str) -> bool { + value.starts_with("editor-resource-") || value.starts_with("editor-asset-") +} + fn resource_edit_state_sha256(value: &T, label: &str) -> Result { serde_json::to_vec(value) .map(|bytes| sha256_hex(&bytes)) @@ -831,6 +839,46 @@ fn update_resource_edit_phase( write_resource_edit_ledger(root, ledger) } +fn bind_resource_edit_platform_owner( + root: &Path, + ledger: &mut ResourceEditLedger, + session: Option<&PlatformSessionSnapshot>, +) -> Result<(), String> { + let Some(session) = session else { + return Ok(()); + }; + match ( + ledger.platform_owner_user_id.as_deref(), + ledger.platform_owner_api_base_url.as_deref(), + ) { + (None, None) + if ledger.remote_operation_id.is_none() && ledger.remote_object_key.is_none() => + { + ledger.platform_owner_user_id = Some(session.user_id.clone()); + ledger.platform_owner_api_base_url = Some(session.api_base_url.clone()); + write_resource_edit_ledger(root, ledger) + } + (Some(user_id), Some(api_base_url)) + if user_id == session.user_id && api_base_url == session.api_base_url => + { + Ok(()) + } + _ => Err( + "authentication-required: 此资源编辑属于其他陶泥儿账号,当前账号不能恢复或提交" + .to_string(), + ), + } +} + +fn ensure_frozen_resource_edit_platform_session( + session: Option<&PlatformSessionSnapshot>, +) -> Result<(), String> { + if let Some(session) = session { + validate_platform_session_snapshot(session)?; + } + Ok(()) +} + fn read_stable_resource_edit_file( root: &Path, relative_path: &str, @@ -1379,7 +1427,8 @@ async fn request_resource_edit_upload_ticket( .unwrap_or_else(|| "source.bin".to_string()); let response = client .post(format!( - "{api_base_url}/api/external/v1/assets/direct-upload-tickets" + "{api_base_url}{}", + resolve_platform_editor_api_route("/api/external/v1/assets/direct-upload-tickets") )) .bearer_auth(api_key) .json(&resource_edit_upload_ticket_payload( @@ -1391,10 +1440,11 @@ async fn request_resource_edit_upload_ticket( .send() .await .map_err(|_| "创建源资源上传凭证失败".to_string())?; - if response.status() == reqwest::StatusCode::UNAUTHORIZED - || response.status() == reqwest::StatusCode::FORBIDDEN - { - return Err("authentication-required: External Editor API Key 无效或权限不足".to_string()); + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(editor_api_authentication_error()); + } + if response.status() == reqwest::StatusCode::FORBIDDEN { + return Err(editor_api_authorization_error()); } if !response.status().is_success() { return Err(format!( @@ -1523,7 +1573,8 @@ async fn confirm_resource_edit_source( .ok_or_else(|| "确认源媒体上传缺少文件内容".to_string())?; let response = client .post(format!( - "{api_base_url}/api/external/v1/assets/objects/confirm" + "{api_base_url}{}", + resolve_platform_editor_api_route("/api/external/v1/assets/objects/confirm") )) .bearer_auth(api_key) .json(&serde_json::json!({ @@ -1538,10 +1589,11 @@ async fn confirm_resource_edit_source( .send() .await .map_err(|_| "确认源资源上传失败".to_string())?; - if response.status() == reqwest::StatusCode::UNAUTHORIZED - || response.status() == reqwest::StatusCode::FORBIDDEN - { - return Err("authentication-required: External Editor API Key 无效或权限不足".to_string()); + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(editor_api_authentication_error()); + } + if response.status() == reqwest::StatusCode::FORBIDDEN { + return Err(editor_api_authorization_error()); } if !response.status().is_success() { return Err(format!( @@ -1564,6 +1616,92 @@ async fn confirm_resource_edit_source( .ok_or_else(|| "源资源确认响应缺少 assetObjectId".to_string()) } +async fn resolve_resource_edit_source_project( + root: &Path, + client: &reqwest::Client, + api_base_url: &str, + api_key: &str, + source: &ResourceEditSourceSnapshot, +) -> Result { + if let Some(project_id) = source + .source_asset + .as_ref() + .and_then(|asset| asset.source.canvas_project_id.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Ok(project_id.to_string()); + } + prepare_external_canvas_generation_context(root, client, api_base_url, api_key) + .await + .map(|context| context.project_id) +} + +async fn register_resource_edit_source_image( + root: &Path, + client: &reqwest::Client, + api_base_url: &str, + api_key: &str, + input: &DeriveLocalProjectResourceInput, + source: &ResourceEditSourceSnapshot, + ticket: &ResourceEditUploadTicket, + asset_object_id: &str, +) -> Result { + let bytes = source + .bytes + .as_deref() + .ok_or_else(|| "登记源图片缺少文件内容".to_string())?; + let decoded = + image::load_from_memory(bytes).map_err(|_| "登记源图片前无法解析图片尺寸".to_string())?; + let project_id = + resolve_resource_edit_source_project(root, client, api_base_url, api_key, source).await?; + let endpoint = format!( + "/api/external/v1/editor/projects/{}/resources", + percent_encode_query_component(&project_id) + ); + let response = client + .post(format!( + "{api_base_url}{}", + resolve_platform_editor_api_route(&endpoint) + )) + .bearer_auth(api_key) + .json(&serde_json::json!({ + "imageSrc": format!("/{}", ticket.object_key.trim_start_matches('/')), + "objectKey": ticket.object_key, + "assetObjectId": asset_object_id, + "width": decoded.width(), + "height": decoded.height(), + "sourceType": "uploaded", + "assetKind": source.asset_kind, + "generationInputs": { + "source": RESOURCE_EDIT_QUEUE_SOURCE, + "operationId": input.operation_id, + }, + })) + .send() + .await + .map_err(|_| "登记源图片项目资源失败".to_string())?; + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(editor_api_authentication_error()); + } + if response.status() == reqwest::StatusCode::FORBIDDEN { + return Err(editor_api_authorization_error()); + } + if !response.status().is_success() { + return Err(format!( + "登记源图片项目资源失败:HTTP {}", + response.status().as_u16() + )); + } + let payload = response + .json::() + .await + .map_err(|_| "解析源图片项目资源响应失败".to_string())?; + let data = external_editor_response_data(&payload); + json_string_field(data.get("resource").unwrap_or(data), "resourceId") + .ok_or_else(|| "源图片项目资源响应缺少 resourceId".to_string()) +} + async fn ensure_resource_edit_source_reference( root: &Path, client: &reqwest::Client, @@ -1574,27 +1712,39 @@ async fn ensure_resource_edit_source_reference( ledger: &mut ResourceEditLedger, ) -> Result { if let Some(reference) = ledger.source_stable_reference.clone() { - return Ok(reference); - } - if let Some(asset) = source.source_asset.as_ref() { - if let Some(reference) = committed_resource_edit_object_key_for_asset(root, &asset.id)? { - ledger.source_stable_reference = Some(reference.clone()); - write_resource_edit_ledger(root, ledger)?; + if input.edit_kind != LocalProjectResourceEditKind::ImageReference + || is_registered_editor_reference_id(&reference) + { return Ok(reference); } - if let Some(reference) = asset - .source - .resource_id - .as_deref() - .map(str::trim) - .filter(|value| { - !value.is_empty() - && !value.starts_with("local-asset:") - && !value.starts_with("draft-media:") - && !value.starts_with("task:") - }) - .map(str::to_string) - .or_else(|| asset.source.asset_object_id.clone()) + // Legacy prepared ledgers could store an objectKey here. An image edit + // accepts only a registered resource or asset, so discard that transient + // preparation before any remote operation is accepted and register again. + ledger.source_stable_reference = None; + write_resource_edit_ledger(root, ledger)?; + } + if let Some(asset) = source.source_asset.as_ref() { + if input.edit_kind == LocalProjectResourceEditKind::ImageReference { + if let Some(reference) = asset + .source + .resource_id + .as_deref() + .map(str::trim) + .filter(|value| { + !value.is_empty() + && !value.starts_with("local-asset:") + && !value.starts_with("draft-media:") + && !value.starts_with("task:") + }) + .map(str::to_string) + .filter(|_| asset.source.canvas_project_id.is_some()) + { + ledger.source_stable_reference = Some(reference.clone()); + write_resource_edit_ledger(root, ledger)?; + return Ok(reference); + } + } else if let Some(reference) = + committed_resource_edit_object_key_for_asset(root, &asset.id)? { ledger.source_stable_reference = Some(reference.clone()); write_resource_edit_ledger(root, ledger)?; @@ -1604,11 +1754,26 @@ async fn ensure_resource_edit_source_reference( let ticket = request_resource_edit_upload_ticket(client, api_base_url, api_key, input, source).await?; upload_resource_edit_source(&ticket, source, api_base_url).await?; - let _asset_object_id = + let asset_object_id = confirm_resource_edit_source(client, api_base_url, api_key, &ticket, source).await?; - ledger.source_stable_reference = Some(ticket.object_key.clone()); + let reference = if input.edit_kind == LocalProjectResourceEditKind::ImageReference { + register_resource_edit_source_image( + root, + client, + api_base_url, + api_key, + input, + source, + &ticket, + &asset_object_id, + ) + .await? + } else { + ticket.object_key.clone() + }; + ledger.source_stable_reference = Some(reference.clone()); write_resource_edit_ledger(root, ledger)?; - Ok(ticket.object_key) + Ok(reference) } fn take_resource_edit_chars(value: &str, max_chars: usize) -> String { @@ -1670,8 +1835,7 @@ fn resource_edit_remote_request( "/api/external/v1/editor/images/edits", serde_json::json!({ "prompt": prompt, - "sourceImageSrc": source_reference.ok_or_else(|| "图片编辑缺少稳定源引用".to_string())?, - "assetKind": source.asset_kind, + "sourceReferenceId": source_reference.ok_or_else(|| "图片编辑缺少正式源资源 ID".to_string())?, "assetLabel": asset_name, "generationInputs": generation_inputs, }), @@ -1764,18 +1928,38 @@ async fn submit_resource_edit_remote( .request_body_json .as_deref() .ok_or_else(|| "资源编辑账本缺少请求正文".to_string())?; + let mut body_value = serde_json::from_str::(body) + .map_err(|_| "资源编辑账本请求正文无效".to_string())?; + if editor_api_mode() == EditorApiMode::PlatformAccount { + if let Some(inputs) = body_value.as_object_mut().and_then(|body| { + body.entry("generationInputs") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + }) { + inputs.insert( + "source".to_string(), + serde_json::Value::String("ai-game-creator-client".to_string()), + ); + } + } let response = client - .post(format!("{api_base_url}{endpoint}")) + .post(format!( + "{api_base_url}{}", + resolve_platform_editor_api_route(endpoint) + )) .bearer_auth(api_key) .header("Idempotency-Key", &ledger.idempotency_key) .header(reqwest::header::CONTENT_TYPE, "application/json") - .body(body.to_string()) + .body(body_value.to_string()) .send() .await .map_err(|_| "result-unknown: 资源编辑请求已发出但未取得确定响应".to_string())?; let status = response.status(); - if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { - return Err("authentication-required: External Editor API Key 无效或权限不足".to_string()); + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err(editor_api_authentication_error()); + } + if status == reqwest::StatusCode::FORBIDDEN { + return Err(editor_api_authorization_error()); } if status == reqwest::StatusCode::BAD_REQUEST { ledger.terminal_failure_code = Some("remote-request-bad-request".to_string()); @@ -1783,18 +1967,15 @@ async fn submit_resource_edit_remote( update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::RemoteFailed)?; return Err("remote-terminal-failed: 资源编辑请求被拒绝:HTTP 400".to_string()); } - if status != reqwest::StatusCode::ACCEPTED { + if !matches!( + status, + reqwest::StatusCode::OK | reqwest::StatusCode::ACCEPTED + ) { update_resource_edit_phase( root, ledger, ResourceEditLedgerPhase::ReconciliationRequired, )?; - if status.is_success() { - return Err(format!( - "result-unknown: External v1 资源编辑必须返回 HTTP 202,实际为 HTTP {}", - status.as_u16() - )); - } return Err(format!( "result-unknown: 资源编辑请求返回不确定响应 HTTP {}", status.as_u16() @@ -1833,9 +2014,10 @@ async fn wait_for_resource_edit_remote( .remote_operation_id .as_deref() .ok_or_else(|| "资源编辑账本缺少远端 operationId".to_string())?; - let operation_id = - url::form_urlencoded::byte_serialize(operation_id.as_bytes()).collect::(); - let status_url = format!("{api_base_url}/api/external/v1/generations/{operation_id}"); + let status_url = format!( + "{api_base_url}{}", + resolve_platform_generation_status_route(&operation_id) + ); let started_at = tokio::time::Instant::now(); let mut poll_after_ms = 1_000; loop { @@ -1849,12 +2031,11 @@ async fn wait_for_resource_edit_remote( .send() .await .map_err(|_| "result-unknown: 查询资源编辑任务失败".to_string())?; - if response.status() == reqwest::StatusCode::UNAUTHORIZED - || response.status() == reqwest::StatusCode::FORBIDDEN - { - return Err( - "authentication-required: External Editor API Key 无效或权限不足".to_string(), - ); + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(editor_api_authentication_error()); + } + if response.status() == reqwest::StatusCode::FORBIDDEN { + return Err(editor_api_authorization_error()); } if [429, 502, 503, 504].contains(&response.status().as_u16()) { poll_after_ms = 2_000; @@ -1870,11 +2051,7 @@ async fn wait_for_resource_edit_remote( .json::() .await .map_err(|_| "result-unknown: 资源编辑任务响应无法解析".to_string())?; - let data = external_editor_response_data(&payload); - let job = data - .get("job") - .filter(|value| value.is_object()) - .unwrap_or(data); + let job = platform_generation_status_data(&payload); match json_string_field(job, "status").as_deref() { Some("completed") => { let result = job @@ -2140,11 +2317,18 @@ async fn prepare_remote_resource_edit( prompt: &str, asset_name: &str, ledger: &mut ResourceEditLedger, + frozen_platform_session: Option<&PlatformSessionSnapshot>, ) -> Result<(), String> { - let api_base_url = resolve_canvas_sync_api_base_url(None) - .map_err(|_| "External Editor API base URL 配置无效".to_string())?; - let api_key = resolve_canvas_sync_api_key(None) - .map_err(|_| "External Editor API Key 配置缺失".to_string())?; + let (api_base_url, api_key, platform_session) = + resolve_canvas_sync_api_credentials(None, None)?; + if platform_session.as_ref() != frozen_platform_session { + return Err( + "authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试" + .to_string(), + ); + } + bind_resource_edit_platform_owner(root, ledger, frozen_platform_session)?; + ensure_frozen_resource_edit_platform_session(frozen_platform_session)?; match prepare_resource_edit_service_identity(root, ledger, &api_base_url, &api_key)? { ResourceEditServiceIdentityDecision::Ready => {} ResourceEditServiceIdentityDecision::ConfirmationRequired(_) => { @@ -2171,7 +2355,11 @@ async fn prepare_remote_resource_edit( .ok_or_else(|| "远端已完成的资源编辑缺少稳定 objectKey".to_string())? } else { let generated = if ledger.remote_operation_id.is_some() { - wait_for_resource_edit_remote(root, &client, &api_base_url, &api_key, ledger).await? + let result = + wait_for_resource_edit_remote(root, &client, &api_base_url, &api_key, ledger) + .await?; + ensure_frozen_resource_edit_platform_session(frozen_platform_session)?; + result } else { let source_reference = if matches!( input.edit_kind, @@ -2192,6 +2380,23 @@ async fn prepare_remote_resource_edit( } else { None }; + if input.edit_kind == LocalProjectResourceEditKind::ImageReference + && ledger.remote_operation_id.is_none() + && ledger.request_body_json.as_deref().is_some_and(|body| { + serde_json::from_str::(body) + .ok() + .is_some_and(|value| { + value.get("sourceImageSrc").is_some() + || value.get("sourceReferenceId").is_none() + }) + }) + { + // Only a not-yet-submitted legacy request may be rebuilt. Once an + // operation exists, preserve its immutable request and reconcile it. + ledger.endpoint = None; + ledger.request_body_json = None; + write_resource_edit_ledger(root, ledger)?; + } if ledger.endpoint.is_none() || ledger.request_body_json.is_none() { let (endpoint, body) = resource_edit_remote_request( input, @@ -2209,12 +2414,17 @@ async fn prepare_remote_resource_edit( } let submission = submit_resource_edit_remote(root, &client, &api_base_url, &api_key, ledger).await?; + ensure_frozen_resource_edit_platform_session(frozen_platform_session)?; let operation_id = resource_edit_operation_id(&submission).ok_or_else(|| { "result-unknown: 资源编辑已受理但响应缺少 operationId".to_string() })?; ledger.remote_operation_id = Some(operation_id.clone()); update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::Accepted)?; - wait_for_resource_edit_remote(root, &client, &api_base_url, &api_key, ledger).await? + let result = + wait_for_resource_edit_remote(root, &client, &api_base_url, &api_key, ledger) + .await?; + ensure_frozen_resource_edit_platform_session(frozen_platform_session)?; + result }; let (resource_id, object_key, asset_object_id, model) = extract_resource_edit_remote_identity(&generated)?; @@ -2236,6 +2446,7 @@ async fn prepare_remote_resource_edit( .await .map_err(|_| "result-unknown: 远端资源编辑结果下载或换签失败".to_string())? .ok_or_else(|| "远端资源编辑结果缺少可下载媒体".to_string())?; + ensure_frozen_resource_edit_platform_session(platform_session.as_ref())?; let (media_type, extension) = validate_downloaded_media(&input.edit_kind, &download.media_type, &download.bytes)?; write_resource_edit_staging(root, &input.operation_id, &download.bytes)?; @@ -3598,10 +3809,8 @@ pub(crate) async fn request_resource_edit_service_identity_confirmation_at( return Err("资源编辑服务身份确认对应的 operation 身份无效".to_string()); } ensure_resource_edit_phase_resumable(&ledger.phase)?; - let api_base_url = resolve_canvas_sync_api_base_url(None) - .map_err(|_| "External Editor API base URL 配置无效".to_string())?; - let api_key = resolve_canvas_sync_api_key(None) - .map_err(|_| "External Editor API Key 配置缺失".to_string())?; + let api_base_url = resolve_canvas_sync_api_base_url(None)?; + let api_key = resolve_canvas_sync_api_key(None)?; match prepare_resource_edit_service_identity(root, &mut ledger, &api_base_url, &api_key)? { ResourceEditServiceIdentityDecision::ConfirmationRequired(confirmation) => Ok(confirmation), ResourceEditServiceIdentityDecision::Ready => { @@ -3632,10 +3841,8 @@ pub(crate) async fn confirm_resource_edit_service_identity_at( if manifest.project_id != input.expected_project_id { return Err("project-identity-conflict".to_string()); } - let api_base_url = resolve_canvas_sync_api_base_url(None) - .map_err(|_| "External Editor API base URL 配置无效".to_string())?; - let api_key = resolve_canvas_sync_api_key(None) - .map_err(|_| "External Editor API Key 配置缺失".to_string())?; + let api_base_url = resolve_canvas_sync_api_base_url(None)?; + let api_key = resolve_canvas_sync_api_key(None)?; let service_fingerprint = platform_art_generation_external_service_fingerprint(&api_base_url); let service_origin = platform_art_generation_external_service_origin(&api_base_url)?; let mut ledger = read_resource_edit_ledger(root, &input.operation_id)? @@ -3887,6 +4094,8 @@ pub(crate) async fn derive_local_project_resource_at( provider_request_issued_at: None, api_identity_scheme: None, api_identity_fingerprint: None, + platform_owner_user_id: None, + platform_owner_api_base_url: None, service_identity_confirmation: None, phase: ResourceEditLedgerPhase::Prepared, endpoint: None, @@ -3929,6 +4138,12 @@ pub(crate) async fn derive_local_project_resource_at( let _project_guard = project_lock.lock().await; return commit_resource_edit_version(root, &input, &source, &prompt, &mut ledger); } + let frozen_platform_session = if input.edit_kind.is_remote_media() { + current_platform_session() + } else { + None + }; + bind_resource_edit_platform_owner(root, &mut ledger, frozen_platform_session.as_ref())?; if ledger.phase != ResourceEditLedgerPhase::MediaDownloaded { let generation_result = if input.edit_kind.is_text() { let staged = read_optional_resource_edit_staging(root, &input.operation_id)?; @@ -3957,8 +4172,16 @@ pub(crate) async fn derive_local_project_resource_at( })(), } } else if input.edit_kind.is_remote_media() { - prepare_remote_resource_edit(root, &input, &source, &prompt, &asset_name, &mut ledger) - .await + prepare_remote_resource_edit( + root, + &input, + &source, + &prompt, + &asset_name, + &mut ledger, + frozen_platform_session.as_ref(), + ) + .await } else { Err("当前资源类型没有编辑实现".to_string()) }; @@ -3978,6 +4201,7 @@ pub(crate) async fn derive_local_project_resource_at( } let project_lock = resource_edit_project_mutation_lock(root)?; let _project_guard = project_lock.lock().await; + ensure_frozen_resource_edit_platform_session(frozen_platform_session.as_ref())?; commit_resource_edit_asset(root, &input, &source, &prompt, &asset_name, &mut ledger) } @@ -4108,6 +4332,8 @@ mod tests { provider_request_issued_at: None, api_identity_scheme: None, api_identity_fingerprint: None, + platform_owner_user_id: None, + platform_owner_api_base_url: None, service_identity_confirmation: None, phase, endpoint: None, @@ -5210,7 +5436,7 @@ mod tests { let mut image = bgm; image.edit_kind = LocalProjectResourceEditKind::ImageReference; - let (image_endpoint, _) = resource_edit_remote_request( + let (image_endpoint, image_body) = resource_edit_remote_request( &image, &source, prompt, @@ -5219,6 +5445,11 @@ mod tests { ) .expect("build image request"); assert_eq!(image_endpoint, "/api/external/v1/editor/images/edits"); + assert_eq!( + image_body["sourceReferenceId"], + serde_json::json!("stable-image-reference") + ); + assert!(image_body.get("sourceImageSrc").is_none()); assert!(!is_external_resource_edit_endpoint( "/api/editor/videos/generations" )); diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index f6e1099b2..fac9699bf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -9,9 +9,10 @@ mod state; #[allow(unused_imports)] pub(crate) use client::{ attach_external_agent_runner_gui_owner, cancel_external_agent_runner_goal, - compact_external_agent_runner_context, configure_external_agent_runner, - configure_external_agent_runner_read_only, continue_external_agent_runner_action, - ensure_external_agent_runner_started, ensure_external_agent_runner_started_for_gui, + clear_external_agent_runner_platform_session, compact_external_agent_runner_context, + configure_external_agent_runner, configure_external_agent_runner_read_only, + continue_external_agent_runner_action, ensure_external_agent_runner_started, + ensure_external_agent_runner_started_for_gui, install_external_agent_runner_platform_session, interrupt_external_agent_runner_provider_for_steer_decision, notify_external_agent_runner, pause_external_agent_runner, read_external_agent_runner_mcp_catalog, read_external_agent_runner_status, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 4f626d26b..405a58e64 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -106,7 +106,7 @@ fn redact_url_queries(line: &str) -> String { .join(" ") } -fn sanitize_agent_runner_output(line: &str, config_dir: &Path) -> String { +pub(super) fn sanitize_agent_runner_output(line: &str, config_dir: &Path) -> String { let lowercase = line.to_ascii_lowercase(); if [ "authorization", @@ -123,6 +123,9 @@ fn sanitize_agent_runner_output(line: &str, config_dir: &Path) -> String { "set-cookie", "secret", "access_token", + "accesstoken", + "platform_access_token", + "platformaccesstoken", "refresh_token", "\"token\"", "'token'", @@ -993,18 +996,125 @@ pub(crate) fn attach_external_agent_runner_gui_owner( let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); let config_dir = external_agent_runner_config_dir() .ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?; + let platform_session = crate::current_platform_session(); register_external_agent_runner_gui_owner_attachment( external_agent_runner_gui_owner_attachment_state(), &config_dir, ExternalAgentRunnerRequestParams { event_sink_port: Some(event_sink.port), event_sink_token: Some(event_sink.token.clone()), + platform_user_id: platform_session + .as_ref() + .map(|session| session.user_id.clone()), + platform_access_token: platform_session + .as_ref() + .map(|session| session.access_token.clone()), + platform_api_base_url: platform_session + .as_ref() + .map(|session| session.api_base_url.clone()), + platform_auth_generation: platform_session.map(|session| session.generation), ..ExternalAgentRunnerRequestParams::default() }, ); ensure_external_agent_runner(&config_dir).map(|_| ()) } +pub(crate) fn install_external_agent_runner_platform_session( + user_id: &str, + access_token: &str, + api_base_url: &str, + generation: u64, +) -> Result<(), String> { + remember_external_agent_runner_platform_session( + external_agent_runner_gui_owner_attachment_state(), + Some((user_id, access_token, api_base_url)), + generation, + ); + let config_dir = external_agent_runner_config_dir() + .ok_or_else(|| "外部 Agent Runner 尚未配置 AppData".to_string())?; + let endpoint = { + let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); + ensure_external_agent_runner(&config_dir)? + }; + send_external_agent_runner_request( + &endpoint, + "platform.session.install", + ExternalAgentRunnerRequestParams { + platform_user_id: Some(user_id.to_string()), + platform_access_token: Some(access_token.to_string()), + platform_api_base_url: Some(api_base_url.to_string()), + platform_auth_generation: Some(generation), + ..ExternalAgentRunnerRequestParams::default() + }, + ) + .map(|_| ()) +} + +pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> Result<(), String> { + remember_external_agent_runner_platform_session( + external_agent_runner_gui_owner_attachment_state(), + None, + generation, + ); + let Some(config_dir) = external_agent_runner_config_dir() else { + return Ok(()); + }; + let endpoint_path = external_agent_runner_endpoint_path(&config_dir); + let Ok(endpoint) = read_external_agent_runner_endpoint(&endpoint_path) else { + return Ok(()); + }; + send_external_agent_runner_request( + &endpoint, + "platform.session.clear", + ExternalAgentRunnerRequestParams { + platform_auth_generation: Some(generation), + ..ExternalAgentRunnerRequestParams::default() + }, + ) + .map(|_| ()) +} + +pub(super) fn remember_external_agent_runner_platform_session( + state: &Mutex, + session: Option<(&str, &str, &str)>, + generation: u64, +) { + let mut state = lock_unpoisoned(state); + let Some(registration) = state.registration.as_mut() else { + return; + }; + let current_generation = registration.params.platform_auth_generation.unwrap_or(0); + if generation < current_generation { + return; + } + if generation == current_generation { + match session { + Some((user_id, access_token, api_base_url)) + if registration.params.platform_user_id.as_deref() == Some(user_id) + && registration.params.platform_access_token.as_deref() + == Some(access_token) + && registration.params.platform_api_base_url.as_deref() + == Some(api_base_url) => + { + return; + } + Some(_) => return, + None if registration.params.platform_user_id.is_none() + && registration.params.platform_access_token.is_none() => + { + return; + } + None => {} + } + } + registration.params.platform_user_id = session.map(|(user_id, _, _)| user_id.to_string()); + registration.params.platform_access_token = + session.map(|(_, access_token, _)| access_token.to_string()); + registration.params.platform_api_base_url = + session.map(|(_, _, api_base_url)| api_base_url.to_string()); + registration.params.platform_auth_generation = Some(generation); +} + pub(super) fn validate_external_agent_runner_gui_owner_attachment_result( result: &Value, ) -> Result<(), String> { @@ -1295,6 +1405,10 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity( steer_id: steer_id.map(str::to_string), event_sink_port: None, event_sink_token: None, + platform_user_id: None, + platform_access_token: None, + platform_api_base_url: None, + platform_auth_generation: None, }; match stable_identity { Some(stable_identity) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index bbf6a918d..cbee001a8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -653,6 +653,30 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim( redact_runner_secret(&error, &token), ); } + if let ( + Some(user_id), + Some(access_token), + Some(api_base_url), + Some(generation), + ) = ( + request.params.platform_user_id.as_deref(), + request.params.platform_access_token.as_deref(), + request.params.platform_api_base_url.as_deref(), + request.params.platform_auth_generation, + ) { + if let Err(error) = crate::install_platform_session( + user_id, + access_token, + api_base_url, + generation, + ) { + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "platform-session-invalid", + error, + ); + } + } state.gui_owner_attached.store(true, Ordering::Release); ExternalAgentRunnerResponse::success( &request.request_id, @@ -671,6 +695,41 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim( ), } } + "platform.session.install" => { + let result = request + .params + .platform_user_id + .as_deref() + .zip(request.params.platform_access_token.as_deref()) + .zip(request.params.platform_api_base_url.as_deref()) + .zip(request.params.platform_auth_generation) + .ok_or_else(|| "平台登录态同步参数不完整".to_string()) + .and_then(|(((user_id, access_token), api_base_url), generation)| { + crate::install_platform_session(user_id, access_token, api_base_url, generation) + }); + match result { + Ok(()) => ExternalAgentRunnerResponse::success( + &request.request_id, + json!({ "installed": true }), + ), + Err(error) => ExternalAgentRunnerResponse::failure( + &request.request_id, + "platform-session-invalid", + error, + ), + } + } + "platform.session.clear" => { + let Some(generation) = request.params.platform_auth_generation else { + return ExternalAgentRunnerResponse::failure( + &request.request_id, + "platform-session-invalid", + "平台登录态清除缺少 authGeneration", + ); + }; + crate::clear_platform_session(generation); + ExternalAgentRunnerResponse::success(&request.request_id, json!({ "cleared": true })) + } "runner.shutdown" | "shutdown" => { let provider_requests_interrupted = request_external_agent_runner_forced_shutdown(state); @@ -898,6 +957,8 @@ pub(super) fn handle_external_agent_runner_request( | "runtime.cancel" | "runtime.compact" | "runner.attach_gui_owner" + | "platform.session.install" + | "platform.session.clear" | "runner.shutdown" | "shutdown" | "runner.shutdown_for_client_exit" diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs index 451309200..579987f45 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs @@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64}; use std::sync::{Mutex, OnceLock}; use std::time::Duration; -pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 5; +pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 6; pub(super) const EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json"; pub(super) const EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock"; @@ -268,6 +268,14 @@ pub(super) struct ExternalAgentRunnerRequestParams { pub(super) event_sink_port: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) event_sink_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) platform_user_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) platform_access_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) platform_api_base_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) platform_auth_generation: Option, } #[derive(Deserialize, Serialize)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index 719895e28..718e95266 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -90,7 +90,7 @@ fn context_compaction_client_uses_long_response_timeout_without_widening_other_m assert!( external_agent_runner_client_read_timeout("mcp.status") > EXTERNAL_AGENT_RUNNER_IO_TIMEOUT ); - assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 5); + assert_eq!(EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, 6); } fn test_endpoint(token: &str, boot_id: &str, port: u16) -> ExternalAgentRunnerEndpoint { @@ -644,6 +644,80 @@ fn gui_owner_registration_replays_once_for_each_runner_boot() { ); } +#[test] +fn gui_owner_registration_replays_only_the_latest_platform_session() { + let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + let config_dir = PathBuf::from("platform-session-replay-appdata"); + register_external_agent_runner_gui_owner_attachment( + &state, + &config_dir, + ExternalAgentRunnerRequestParams { + event_sink_port: Some(31_330), + event_sink_token: Some("f".repeat(64)), + ..ExternalAgentRunnerRequestParams::default() + }, + ); + remember_external_agent_runner_platform_session( + &state, + Some(("user-a", "token-a", "https://dev.genarrative.world")), + 4, + ); + remember_external_agent_runner_platform_session(&state, None, 5); + remember_external_agent_runner_platform_session( + &state, + Some(("user-a", "late-token-a", "https://dev.genarrative.world")), + 4, + ); + remember_external_agent_runner_platform_session( + &state, + Some(( + "user-a", + "same-generation-token", + "https://dev.genarrative.world", + )), + 5, + ); + remember_external_agent_runner_platform_session( + &state, + Some(("user-b", "token-b", "https://dev.genarrative.world")), + 6, + ); + + let endpoint = test_endpoint( + "platform-session-replay-runner-token", + "platform-session-replay-boot", + 31_330, + ); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, params| { + assert_eq!(params.platform_user_id.as_deref(), Some("user-b")); + assert_eq!(params.platform_access_token.as_deref(), Some("token-b")); + assert_eq!( + params.platform_api_base_url.as_deref(), + Some("https://dev.genarrative.world") + ); + assert_eq!(params.platform_auth_generation, Some(6)); + Ok(()) + }, + ) + .expect("latest platform session is replayed"); +} + +#[test] +fn runner_output_redacts_camel_case_platform_access_token_fields() { + let config_dir = Path::new("private-appdata"); + assert_eq!( + sanitize_agent_runner_output( + r#"request={"platformAccessToken":"must-not-leak"}"#, + config_dir, + ), + "" + ); +} + #[test] fn gui_owner_registration_failed_replay_remains_pending_for_same_boot() { let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index 4179467de..778ae1180 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -606,8 +606,12 @@ fn app_config_commands_write_runtime_config_file() { Some(false) ); assert!(!saved.config.agent_llm.contains_key("generator")); - assert_eq!(saved.config.editor_api.api_key, "editor-key"); + assert_eq!(saved.config.editor_api.api_key, ""); assert!(root.join(GAME_CREATOR_CONFIG_FILE_NAME).is_file()); + let persisted = fs::read_to_string(root.join(GAME_CREATOR_CONFIG_FILE_NAME)) + .expect("read persisted runtime config"); + assert!(!persisted.contains("editorApi")); + assert!(!persisted.contains("editor-key")); let read_back = read_game_creator_app_config().expect("read runtime config"); assert_eq!(read_back.config.llm.model, "runtime-model"); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index cdc9ffb45..d17f9e717 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -552,15 +552,18 @@ export function WorkspaceLauncher(props: WorkspaceLauncherProps) { export function GameChatReleaseApp({ initialProjectPath = '', initialSupervisorMessage = '', + allowAdvancedExternalEditorConfig = false, }: { initialProjectPath?: string; initialSupervisorMessage?: string; + allowAdvancedExternalEditorConfig?: boolean; }) { return ( ); @@ -574,6 +577,7 @@ type AppProps = { projectSupervisorOnly?: boolean; supervisorChatOnly?: boolean; gameChatOnly?: boolean; + allowAdvancedExternalEditorConfig?: boolean; initialSupervisorMessage?: string; onManifestChange?: ( projectPath: string, @@ -595,6 +599,7 @@ export function App({ projectSupervisorOnly = false, supervisorChatOnly = false, gameChatOnly = false, + allowAdvancedExternalEditorConfig = false, initialSupervisorMessage = '', onManifestChange, onPreviewChange, @@ -11192,53 +11197,65 @@ export function App({ if (projectSupervisorOnly && gameChatOnly) { const gameChatProjectPath = localProject?.projectPath ?? ''; return ( - setRuntimeConfigOpen(false)} - onConfirmConfirmation={confirmUiCommand} - onOpenRuntimeConfig={() => setRuntimeConfigOpen(true)} - onProjectPick={() => void handleGameChatProjectPick()} - onScroll={handleSupervisorChatScroll} - onShowEarlierMessages={showEarlierConversationMessages} - onSubmit={handleProjectSupervisorOnlySubmit} - onToolAction={handleProjectSupervisorToolAction} - onUserInput={handleProjectSupervisorUserInput} - pendingConfirmation={pendingUiConfirmation} - pendingCommand={pendingCommand} - onCancelPendingCommand={handlePendingCommandCancel} - onConfirmPendingCommand={() => void handlePendingCommandConfirm()} - pendingNonEmptyProjectCreate={pendingNonEmptyProjectCreate} - onCancelNonEmptyProjectCreate={cancelProjectCreateInNonEmptyFolder} - onConfirmNonEmptyProjectCreate={confirmProjectCreateInNonEmptyFolder} - preview={preview} - previewRevision={gameChatPreviewRevision} - previewStatus={previewStatus} - projectPath={gameChatProjectPath} - projectReady={Boolean(localProject)} - projectSelectionBusy={gameChatProjectSelectionBusy} - runtime={projectSupervisorRuntime} - runtimeByAgentId={agentRuntimeById} - manifest={manifest} - runtimeConfigOpen={runtimeConfigOpen} - runtimeError={projectSupervisorRuntimeError} - transientReply={ - directCodexProductRuntime - ? directCodexProgress - : projectSupervisorTransientReply - } - transientReplyUpdatedAt={projectSupervisorResponseStream?.updatedAt} - hasConversationControls={projectSupervisorHasConversationControls} - hiddenConversationCount={hiddenConversationCount} - needsUserInput={projectSupervisorNeedsUserInput} - visibleMessages={visibleMessages} - workspaceStatus={workspaceStatus} - expectedRunId={projectSupervisorExpectedRunId} - /> + <> + setRuntimeConfigOpen(false)} + onConfirmConfirmation={confirmUiCommand} + onOpenRuntimeConfig={() => setRuntimeConfigOpen(true)} + onProjectPick={() => void handleGameChatProjectPick()} + onScroll={handleSupervisorChatScroll} + onShowEarlierMessages={showEarlierConversationMessages} + onSubmit={handleProjectSupervisorOnlySubmit} + onToolAction={handleProjectSupervisorToolAction} + onUserInput={handleProjectSupervisorUserInput} + pendingConfirmation={pendingUiConfirmation} + pendingCommand={pendingCommand} + onCancelPendingCommand={handlePendingCommandCancel} + onConfirmPendingCommand={() => void handlePendingCommandConfirm()} + pendingNonEmptyProjectCreate={pendingNonEmptyProjectCreate} + onCancelNonEmptyProjectCreate={cancelProjectCreateInNonEmptyFolder} + onConfirmNonEmptyProjectCreate={confirmProjectCreateInNonEmptyFolder} + preview={preview} + previewRevision={gameChatPreviewRevision} + previewStatus={previewStatus} + projectPath={gameChatProjectPath} + projectReady={Boolean(localProject)} + projectSelectionBusy={gameChatProjectSelectionBusy} + runtime={projectSupervisorRuntime} + runtimeByAgentId={agentRuntimeById} + manifest={manifest} + runtimeConfigOpen={ + allowAdvancedExternalEditorConfig ? false : runtimeConfigOpen + } + runtimeError={projectSupervisorRuntimeError} + transientReply={ + directCodexProductRuntime + ? directCodexProgress + : projectSupervisorTransientReply + } + transientReplyUpdatedAt={projectSupervisorResponseStream?.updatedAt} + hasConversationControls={projectSupervisorHasConversationControls} + hiddenConversationCount={hiddenConversationCount} + needsUserInput={projectSupervisorNeedsUserInput} + visibleMessages={visibleMessages} + workspaceStatus={workspaceStatus} + expectedRunId={projectSupervisorExpectedRunId} + /> + {runtimeConfigOpen && allowAdvancedExternalEditorConfig ? ( + setRuntimeConfigOpen(false)} + onLog={(entry) => setCommandLog((current) => [...current, entry])} + /> + ) : null} + ); } diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 74db6f576..80cfec5b6 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -18,9 +18,16 @@ import { loginClientWithPhoneCode, logoutClientAuthSession, normalizeAuthPhoneInput, - refreshClientAuthAccessToken, sendClientPhoneLoginCode, } from '../services/clientAuth'; +import { + beginPlatformSessionTransition, + clearCommittedPlatformSession, + commitAuthenticatedPlatformSession, + currentPlatformSessionGeneration, + refreshPlatformSessionForGeneration, + subscribePlatformSessionRefresh, +} from '../services/platformSession'; type ClientRuntimeErrorBoundaryProps = { children: ReactNode; @@ -92,15 +99,30 @@ export function AuthenticatedClient({ useEffect(() => { let disposed = false; async function hydrateAuth() { + const hydrationGeneration = currentPlatformSessionGeneration(); try { if (!getStoredAuthAccessToken()) { - await refreshClientAuthAccessToken(); + const refreshed = + await refreshPlatformSessionForGeneration(hydrationGeneration); + if (!refreshed) { + return; + } } const user = await getCurrentClientAuthUser(); if (disposed) { return; } if (user) { + const committedGeneration = await commitAuthenticatedPlatformSession( + user, + hydrationGeneration, + ); + if (committedGeneration === null) { + return; + } + if (disposed) { + return; + } setAuthUser(user); setAuthStatus('authenticated'); return; @@ -123,12 +145,27 @@ export function AuthenticatedClient({ } if (getStoredAuthAccessToken()) { try { - await refreshClientAuthAccessToken(); + const refreshed = + await refreshPlatformSessionForGeneration(hydrationGeneration); + if (!refreshed) { + return; + } const user = await getCurrentClientAuthUser(); if (disposed) { return; } if (user) { + const committedGeneration = + await commitAuthenticatedPlatformSession( + user, + hydrationGeneration, + ); + if (committedGeneration === null) { + return; + } + if (disposed) { + return; + } setAuthUser(user); setAuthStatus('authenticated'); return; @@ -159,6 +196,25 @@ export function AuthenticatedClient({ }; }, []); + useEffect( + () => + subscribePlatformSessionRefresh((result) => { + if (result.status === 'refreshed') { + setAuthUser((current) => + current?.id === result.user.id ? result.user : current, + ); + return; + } + if (result.status === 'failed') { + clearStoredAuthAccessToken(); + setAuthUser(null); + setAuthStatus('unauthenticated'); + setLoginStatus('登录已失效,请重新登录'); + } + }), + [], + ); + useEffect(() => { if (codeCooldownSeconds <= 0) { return; @@ -211,11 +267,19 @@ export function AuthenticatedClient({ } setLoginBusy(true); setLoginStatus('正在登录'); + const loginGeneration = beginPlatformSessionTransition(); try { const user = loginMode === 'code' ? await loginClientWithPhoneCode(normalizedPhone, code) : await loginClientWithPassword(normalizedPhone, password); + const committedGeneration = await commitAuthenticatedPlatformSession( + user, + loginGeneration, + ); + if (committedGeneration === null) { + return; + } setAuthUser(user); setAuthStatus('authenticated'); setCode(''); @@ -228,11 +292,13 @@ export function AuthenticatedClient({ } async function logout() { + const logoutGeneration = beginPlatformSessionTransition(); try { await logoutClientAuthSession(); } catch { clearStoredAuthAccessToken(); } + await clearCommittedPlatformSession(logoutGeneration); setAuthUser(null); setAuthStatus('unauthenticated'); setLoginStatus('已退出登录'); diff --git a/apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts b/apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts index 7d563820f..172e37abf 100644 --- a/apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts +++ b/apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts @@ -16,6 +16,7 @@ import type { GameCreationAppAssetManifestEntry, GameCreationAppManifest, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { requestPlatformSessionRefresh } from '../../services/platformSession'; const MAX_SAFE_REVISION = Number.MAX_SAFE_INTEGER; export const LOCAL_ASSET_COMMITTED_EVENT = @@ -190,6 +191,14 @@ function failure(error: unknown): ImageCanvasHostResult { }; } +function isAuthenticationRequiredError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes('authentication-required') || + message.includes('登录已失效') + ); +} + function conflict( kind: 'project-identity' | 'host-revision' | 'draft-revision', draft: ImageCanvasDraft | null, @@ -237,6 +246,25 @@ export function createTauriImageCanvasHostAdapter(input: { }): TauriImageCanvasHostAdapter { const invokeInput = (command: string, value: Record) => input.invoke(command, { input: value }); + const invokeAuthenticatedInput = async ( + command: string, + value: Record, + ) => { + try { + return await invokeInput(command, value); + } catch (error) { + if (!isAuthenticationRequiredError(error)) throw error; + const refresh = await requestPlatformSessionRefresh(); + if (refresh.status !== 'refreshed') { + throw new Error( + refresh.status === 'stale' + ? '登录账号已变化,原生成操作已停止' + : '登录已失效,请重新登录', + ); + } + return invokeInput(command, value); + } + }; const baseScope = (scope: ImageCanvasHostScope) => { if (scope.projectId !== input.expectedProjectId) { throw new Error( @@ -286,7 +314,7 @@ export function createTauriImageCanvasHostAdapter(input: { generationInput.generationId, generationInput.onProgress, ); - const result = await invokeInput( + const result = await invokeAuthenticatedInput( 'generate_local_project_asset_canvas_image', { ...baseScope(generationInput.scope), @@ -326,7 +354,7 @@ export function createTauriImageCanvasHostAdapter(input: { null, recoverInput.onProgress, ); - const result = await invokeInput<{ + const result = await invokeAuthenticatedInput<{ resumedGenerationIds: string[]; serviceIdentityConfirmations: ImageCanvasGenerationServiceIdentityConfirmation[]; }>( diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index d8529527a..2f8007f69 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -83,7 +83,7 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = { }, agentLlm: {}, editorApi: { - baseUrl: 'http://127.0.0.1:8082', + baseUrl: 'https://dev.genarrative.world', apiKey: '', }, mcpServers: {}, @@ -499,6 +499,7 @@ function normalizeRuntimeMcpServerConfig( function normalizeRuntimeConfigDraft( config: GameCreatorAppConfig, + allowAdvancedExternalEditorConfig: boolean, ): GameCreatorAppConfig { const apiKind: GameCreatorLlmApiKind = [ 'openai_responses', @@ -562,16 +563,21 @@ function normalizeRuntimeConfigDraft( retryBackoffMs: clampRuntimeConfigNumber(config.llm.retryBackoffMs, 1), }, agentLlm, + editorApi: allowAdvancedExternalEditorConfig + ? { ...defaultRuntimeConfigDraft.editorApi, ...config.editorApi } + : { ...defaultRuntimeConfigDraft.editorApi }, mcpServers, }; } export function RuntimeConfigDialog({ projectPath, + allowAdvancedExternalEditorConfig = false, onClose, onLog, }: { projectPath?: string; + allowAdvancedExternalEditorConfig?: boolean; onClose: () => void; onLog?: (entry: string) => void; }) { @@ -690,18 +696,6 @@ export function RuntimeConfigDialog({ })); } - function updateRuntimeEditorConfig< - K extends keyof GameCreatorAppConfig['editorApi'], - >(key: K, value: GameCreatorAppConfig['editorApi'][K]) { - setRuntimeConfigDraft((current) => ({ - ...current, - editorApi: { - ...current.editorApi, - [key]: value, - }, - })); - } - function updateRuntimeMcpServer( serverId: string, key: K, @@ -797,7 +791,10 @@ export function RuntimeConfigDialog({ 'read_game_creator_app_config', ); setRuntimeConfigPath(result.path); - const config = normalizeRuntimeConfigDraft(result.config); + const config = normalizeRuntimeConfigDraft( + result.config, + allowAdvancedExternalEditorConfig, + ); setRuntimeConfigDraft(config); setMcpStructuredDrafts(runtimeMcpStructuredDrafts(config.mcpServers)); setMcpCatalog(null); @@ -829,20 +826,29 @@ export function RuntimeConfigDialog({ setRuntimeConfigToast(null); setRuntimeConfigStatus('正在保存'); try { - const config = normalizeRuntimeConfigDraft({ - ...runtimeConfigDraft, - agentMode: 'codex_app_server', - mcpServers: materializeRuntimeMcpServers( - runtimeConfigDraft.mcpServers, - mcpStructuredDrafts, - ), - }); + const config = normalizeRuntimeConfigDraft( + { + ...runtimeConfigDraft, + agentMode: 'codex_app_server', + editorApi: allowAdvancedExternalEditorConfig + ? runtimeConfigDraft.editorApi + : defaultRuntimeConfigDraft.editorApi, + mcpServers: materializeRuntimeMcpServers( + runtimeConfigDraft.mcpServers, + mcpStructuredDrafts, + ), + }, + allowAdvancedExternalEditorConfig, + ); const result = await invoke( 'write_game_creator_app_config', { config }, ); setRuntimeConfigPath(result.path); - const savedConfig = normalizeRuntimeConfigDraft(result.config); + const savedConfig = normalizeRuntimeConfigDraft( + result.config, + allowAdvancedExternalEditorConfig, + ); setRuntimeConfigDraft(savedConfig); setMcpStructuredDrafts( runtimeMcpStructuredDrafts(savedConfig.mcpServers), @@ -856,9 +862,7 @@ export function RuntimeConfigDialog({ onLog?.('runtime_config.save'); } catch (error) { const message = error instanceof Error ? error.message : String(error); - setRuntimeConfigStatus( - message, - ); + setRuntimeConfigStatus(message); setRuntimeConfigToast({ tone: 'error', message: `保存失败:${message}`, @@ -1555,43 +1559,49 @@ export function RuntimeConfigDialog({ })} ) : null} - {activeSection === 'connections' ? ( - <> - - - - ) : null} {activeSection === 'connections' ? (
+ {allowAdvancedExternalEditorConfig ? ( +
+ + +
+ ) : null}

MCP servers

diff --git a/apps/ai-game-creator-shell/src/main.tsx b/apps/ai-game-creator-shell/src/main.tsx index b72f486e2..a4baa7eaa 100644 --- a/apps/ai-game-creator-shell/src/main.tsx +++ b/apps/ai-game-creator-shell/src/main.tsx @@ -38,6 +38,7 @@ const gameChatApp = ( ); diff --git a/apps/ai-game-creator-shell/src/services/platformSession.ts b/apps/ai-game-creator-shell/src/services/platformSession.ts new file mode 100644 index 000000000..a9fa4d801 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/platformSession.ts @@ -0,0 +1,211 @@ +import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; +import { resolveTauriInvoke } from '../app/tauri'; +import { + getCurrentClientAuthUser, + getStoredAuthAccessToken, + refreshClientAuthAccessToken, +} from './clientAuth'; + +const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; + +type CommittedPlatformSession = { + user: AuthUser; + accessToken: string; + apiBaseUrl: string; + generation: number; +}; + +export type PlatformSessionRefreshResult = + | { status: 'refreshed'; user: AuthUser; generation: number } + | { status: 'stale' } + | { status: 'failed'; error: unknown }; + +type PlatformSessionRefreshListener = ( + result: PlatformSessionRefreshResult, +) => void; + +let platformAuthGeneration = 0; +let committedPlatformSession: CommittedPlatformSession | null = null; +let platformSessionRefreshPromise: Promise | null = + null; +const platformSessionRefreshListeners = + new Set(); + +function restoreCommittedAccessToken() { + if (committedPlatformSession?.accessToken) { + window.localStorage.setItem( + ACCESS_TOKEN_STORAGE_KEY, + committedPlatformSession.accessToken, + ); + return; + } + window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY); +} + +function notifyPlatformSessionRefresh(result: PlatformSessionRefreshResult) { + for (const listener of platformSessionRefreshListeners) { + listener(result); + } +} + +async function installCommittedPlatformSession( + session: CommittedPlatformSession, +) { + const invoke = resolveTauriInvoke(); + if (!invoke) return; + await invoke('install_platform_account_session', { + userId: session.user.id, + accessToken: session.accessToken, + apiBaseUrl: session.apiBaseUrl, + generation: session.generation, + }); +} + +async function resolvePlatformApiBaseUrl() { + if (!import.meta.env.DEV || import.meta.env.MODE === 'test') { + return 'https://dev.genarrative.world'; + } + const response = await fetch('/__agc_dev_server.json', { + cache: 'no-store', + credentials: 'same-origin', + }); + if (!response.ok) { + throw new Error('无法读取本地陶泥儿 API 服务地址'); + } + const marker = (await response.json()) as { apiTarget?: unknown }; + const apiBaseUrl = + typeof marker.apiTarget === 'string' ? marker.apiTarget.trim() : ''; + if (!/^http:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?$/u.test(apiBaseUrl)) { + throw new Error('本地陶泥儿 API 服务地址不在受信任白名单内'); + } + return apiBaseUrl; +} + +async function commitPlatformSession( + user: AuthUser, + expectedGeneration: number, +): Promise { + const accessToken = getStoredAuthAccessToken(); + if (!accessToken) { + throw new Error('陶泥儿登录凭据缺失,请重新登录'); + } + if (platformAuthGeneration !== expectedGeneration) { + if ( + committedPlatformSession?.user.id === user.id && + committedPlatformSession.accessToken === accessToken + ) { + return committedPlatformSession; + } + restoreCommittedAccessToken(); + return null; + } + platformAuthGeneration += 1; + const apiBaseUrl = await resolvePlatformApiBaseUrl(); + committedPlatformSession = { + user, + accessToken, + apiBaseUrl, + generation: platformAuthGeneration, + }; + return committedPlatformSession; +} + +export function currentPlatformSessionGeneration() { + return platformAuthGeneration; +} + +export function beginPlatformSessionTransition() { + platformAuthGeneration += 1; + committedPlatformSession = null; + return platformAuthGeneration; +} + +export async function commitAuthenticatedPlatformSession( + user: AuthUser, + expectedGeneration: number, +) { + const session = await commitPlatformSession(user, expectedGeneration); + if (!session) return null; + await installCommittedPlatformSession(session); + return session.generation; +} + +export async function refreshPlatformSessionForGeneration( + expectedGeneration: number, +) { + const token = await refreshClientAuthAccessToken(); + if (platformAuthGeneration !== expectedGeneration) { + restoreCommittedAccessToken(); + return null; + } + return token; +} + +export function requestPlatformSessionRefresh(expectedUserId?: string) { + if (platformSessionRefreshPromise) return platformSessionRefreshPromise; + + const expectedGeneration = platformAuthGeneration; + const expectedSessionUserId = + expectedUserId?.trim() || committedPlatformSession?.user.id || ''; + platformSessionRefreshPromise = + (async (): Promise => { + try { + const refreshed = + await refreshPlatformSessionForGeneration(expectedGeneration); + if (!refreshed) return { status: 'stale' }; + const user = await getCurrentClientAuthUser(); + if ( + platformAuthGeneration !== expectedGeneration || + !user || + (expectedSessionUserId && user.id !== expectedSessionUserId) + ) { + restoreCommittedAccessToken(); + return { status: 'stale' }; + } + const session = await commitPlatformSession(user, expectedGeneration); + if (!session) return { status: 'stale' }; + await installCommittedPlatformSession(session); + return { + status: 'refreshed', + user, + generation: session.generation, + }; + } catch (error) { + if (platformAuthGeneration === expectedGeneration) { + const clearGeneration = beginPlatformSessionTransition(); + await clearCommittedPlatformSession(clearGeneration); + } + return { status: 'failed', error }; + } + })().then((result) => { + notifyPlatformSessionRefresh(result); + return result; + }); + platformSessionRefreshPromise.finally(() => { + platformSessionRefreshPromise = null; + }); + return platformSessionRefreshPromise; +} + +export function subscribePlatformSessionRefresh( + listener: PlatformSessionRefreshListener, +) { + platformSessionRefreshListeners.add(listener); + return () => { + platformSessionRefreshListeners.delete(listener); + }; +} + +export async function clearCommittedPlatformSession(generation: number) { + restoreCommittedAccessToken(); + const invoke = resolveTauriInvoke(); + if (!invoke) return; + await invoke('clear_platform_account_session', { generation }); +} + +export function resetPlatformSessionStateForTests() { + platformAuthGeneration = 0; + committedPlatformSession = null; + platformSessionRefreshPromise = null; + platformSessionRefreshListeners.clear(); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 4e6dfce34..ecfd267b9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -60,6 +60,7 @@ import { LocalGamePreviewFrame, resolveEmbeddedPreviewUrl, } from '../../features/project-workspace/LocalGamePreviewFrame'; +import { requestPlatformSessionRefresh } from '../../services/platformSession'; import { type ProjectManifestSnapshotMetadata, resolveResourceFocusIntent, @@ -118,6 +119,31 @@ type WorkbenchMode = 'resources' | 'run'; type ApprovalMode = 'strict' | 'risk' | 'none'; type OrchestrationMode = 'single-supervisor' | 'professional-dag'; +function isPlatformAuthenticationRequired(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes('authentication-required') || + message.includes('登录已失效') + ); +} + +async function withPlatformSessionRefresh(operation: () => Promise) { + try { + return await operation(); + } catch (error) { + if (!isPlatformAuthenticationRequired(error)) throw error; + const refresh = await requestPlatformSessionRefresh(); + if (refresh.status !== 'refreshed') { + throw new Error( + refresh.status === 'stale' + ? '登录账号已变化,原资源操作已停止' + : '登录已失效,请重新登录', + ); + } + return operation(); + } +} + type WebKitGestureEvent = Event & { clientX?: number; clientY?: number; @@ -2299,15 +2325,17 @@ export default function ProjectDevelopmentView({ setPendingResourceEditsError(''); setAssetCanvasNotice(`正在继续“${pending.assetName}”的原生成 operation…`); try { - const result = await invoke( - 'resume_local_project_resource_edit', - { - input: { - projectPath: actionProject.projectPath, - expectedProjectId: actionProject.projectId, - operationId: pending.operationId, + const result = await withPlatformSessionRefresh(() => + invoke( + 'resume_local_project_resource_edit', + { + input: { + projectPath: actionProject.projectPath, + expectedProjectId: actionProject.projectId, + operationId: pending.operationId, + }, }, - }, + ), ); if (!isCurrentRecoveryProject(actionProject)) return; if (result.manifest.projectId !== actionProject.projectId) { @@ -2604,30 +2632,32 @@ export default function ProjectDevelopmentView({ ); } const resource = route.resource; - const result = await invoke( - 'derive_local_project_resource', - { - input: { - projectPath, - expectedProjectId: manifest.projectId, - expectedProjectRevision, - operationId: route.operationId, - idempotencyKey: route.idempotencyKey, - editKind: route.capability.editKind, - sourceResourceId: resource.id, - sourceAssetId: resource.manifestAssetId, - sourcePath: - resource.version || resource.subtype === 'agent-result' - ? null - : resource.path, - sourceMediaType: route.capability.sourceMediaType, - sourceSubtype: resource.subtype, - producerTaskId: resource.producerTaskId, - sourceVersionId: resource.version?.versionId ?? null, - prompt, - assetName, + const result = await withPlatformSessionRefresh(() => + invoke( + 'derive_local_project_resource', + { + input: { + projectPath, + expectedProjectId: manifest.projectId, + expectedProjectRevision, + operationId: route.operationId, + idempotencyKey: route.idempotencyKey, + editKind: route.capability.editKind, + sourceResourceId: resource.id, + sourceAssetId: resource.manifestAssetId, + sourcePath: + resource.version || resource.subtype === 'agent-result' + ? null + : resource.path, + sourceMediaType: route.capability.sourceMediaType, + sourceSubtype: resource.subtype, + producerTaskId: resource.producerTaskId, + sourceVersionId: resource.version?.versionId ?? null, + prompt, + assetName, + }, }, - }, + ), ); if ( result.manifest.projectId !== manifest.projectId || diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index 67f247cdd..2d9ec0ad4 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -1,3 +1,12 @@ +import { afterEach } from 'vitest'; + +import { + beginPlatformSessionTransition, + commitAuthenticatedPlatformSession, + currentPlatformSessionGeneration, + requestPlatformSessionRefresh, + resetPlatformSessionStateForTests, +} from '../../src/services/platformSession'; import { AuthenticatedClient, expect, @@ -8,9 +17,182 @@ import { screen, testAuthUser, vi, + waitFor, } from './harness'; export function registerAuthTests() { + afterEach(() => { + resetPlatformSessionStateForTests(); + delete window.__TAURI__; + }); + + it('keeps the client at login when the native platform session cannot be installed', async () => { + window.__TAURI__ = { + core: { + invoke: vi.fn(async (command: string) => { + if (command === 'install_platform_account_session') { + throw new Error('runner unavailable'); + } + return null; + }), + }, + }; + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + return new Response('', { status: 401 }); + } + if (url === '/api/auth/phone/login') { + return new Response( + JSON.stringify({ + token: 'phone-token', + user: { ...testAuthUser, loginMethod: 'phone' }, + created: false, + referral: null, + }), + { status: 200 }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + render( + React.createElement(AuthenticatedClient, null, ({ user }) => + React.createElement('main', { 'aria-label': '已登录' }, user.id), + ), + ); + await screen.findByRole('main', { name: '登录' }); + fireEvent.change(screen.getByLabelText('手机号'), { + target: { value: '13800000000' }, + }); + fireEvent.change(screen.getByLabelText('验证码'), { + target: { value: '123456' }, + }); + fireEvent.click(screen.getByRole('button', { name: '登录' })); + + expect(await screen.findByText('runner unavailable')).not.toBeNull(); + expect(screen.queryByLabelText('已登录')).toBeNull(); + expect( + window.localStorage.getItem('genarrative.auth.access-token.v1'), + ).toBe('phone-token'); + }); + + it('singleflights 401 refresh, installs the new token, and rejects a late old-account refresh', async () => { + const invoke = vi.fn(async () => null); + window.__TAURI__ = { core: { invoke } }; + const initialGeneration = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'account-a-token', + ); + await commitAuthenticatedPlatformSession(testAuthUser, initialGeneration); + + let resolveRefresh: ((response: Response) => void) | null = null; + let refreshCalls = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + refreshCalls += 1; + return await new Promise((resolve) => { + resolveRefresh = resolve; + }); + } + if (url === '/api/auth/me') { + return new Response( + JSON.stringify({ + user: testAuthUser, + availableLoginMethods: ['password'], + }), + { status: 200 }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + const first = requestPlatformSessionRefresh(testAuthUser.id); + const second = requestPlatformSessionRefresh(testAuthUser.id); + expect(first).toBe(second); + expect(refreshCalls).toBe(1); + + const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; + const accountBGeneration = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'account-b-token', + ); + await commitAuthenticatedPlatformSession(accountB, accountBGeneration); + resolveRefresh?.( + new Response(JSON.stringify({ token: 'late-account-a-token' }), { + status: 200, + }), + ); + + await expect(first).resolves.toEqual({ status: 'stale' }); + expect( + window.localStorage.getItem('genarrative.auth.access-token.v1'), + ).toBe('account-b-token'); + expect(invoke).toHaveBeenLastCalledWith( + 'install_platform_account_session', + expect.objectContaining({ + userId: 'user-b', + accessToken: 'account-b-token', + generation: currentPlatformSessionGeneration(), + }), + ); + }); + + it('refreshes the current account once and synchronizes the replacement token', async () => { + const invoke = vi.fn(async () => null); + window.__TAURI__ = { core: { invoke } }; + const generation = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'expired-token', + ); + await commitAuthenticatedPlatformSession(testAuthUser, generation); + let refreshCalls = 0; + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + refreshCalls += 1; + return new Response(JSON.stringify({ token: 'replacement-token' }), { + status: 200, + }); + } + if (url === '/api/auth/me') { + return new Response( + JSON.stringify({ + user: testAuthUser, + availableLoginMethods: ['password'], + }), + { status: 200 }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + const result = await requestPlatformSessionRefresh(testAuthUser.id); + expect(result).toEqual( + expect.objectContaining({ status: 'refreshed', user: testAuthUser }), + ); + expect(refreshCalls).toBe(1); + await waitFor(() => { + expect(invoke).toHaveBeenLastCalledWith( + 'install_platform_account_session', + expect.objectContaining({ + userId: testAuthUser.id, + accessToken: 'replacement-token', + }), + ); + }); + }); + it('deduplicates startup auth refresh when React StrictMode hydrates twice', async () => { const fetchSpy = vi .spyOn(globalThis, 'fetch') diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index e1f1a26c9..46b3a5e37 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -4265,47 +4265,111 @@ export function registerProjectSupervisorSurfaceTests() { ); }); - it('exposes External Editor credentials from the game-chat settings', async () => { + it('keeps External Editor credentials hidden from ordinary game-chat settings', async () => { const invoke = vi.fn(async (command: string) => { if (command === 'read_game_creator_app_config') { return { path: '/home/test/AppData/game-creator.config.json', config: { + agentMode: 'codex_app_server', + llm: { + apiKey: '', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4.1', + apiKind: 'openai_responses', + reasoningEffort: 'high', + stream: false, + webSearchEnabled: false, + contextWindowTokens: 128000, + autoCompactTokenLimit: 64000, + toolOutputTokenLimit: 12000, + requestTimeoutMs: 180000, + maxRetries: 2, + retryBackoffMs: 500, + }, + agentLlm: {}, editorApi: { baseUrl: 'http://127.0.0.1:8082', apiKey: 'game-chat-editor-secret', }, + mcpServers: {}, }, }; } throw new Error(`unexpected invoke ${command}`); }); window.__TAURI__ = { core: { invoke } }; - renderGameChatStatus({ - runtime: gameChatRuntimeState(), - viewOverrides: { - onOpenRuntimeConfig: vi.fn(), - runtimeConfigOpen: true, - }, - }); + render( + React.createElement(App, { + projectSupervisorOnly: true, + gameChatOnly: true, + }), + ); + fireEvent.click(screen.getByRole('button', { name: '设置' })); + + expect( + await screen.findByRole('dialog', { name: '运行时配置' }), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: /连接与工具/ })); + expect(screen.queryByLabelText('External Editor Base URL')).toBeNull(); + expect(screen.queryByLabelText('External Editor API Key')).toBeNull(); + expect(screen.queryByDisplayValue('game-chat-editor-secret')).toBeNull(); + }); + + it('shows External Editor credentials only in the standalone game-chat release', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_game_creator_app_config') { + return { + path: '/home/test/AppData/game-chat/game-creator.config.json', + config: { + agentMode: 'codex_app_server', + llm: { + apiKey: '', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4.1', + apiKind: 'openai_responses', + reasoningEffort: 'high', + stream: false, + webSearchEnabled: false, + contextWindowTokens: 128000, + autoCompactTokenLimit: 64000, + toolOutputTokenLimit: 12000, + requestTimeoutMs: 180000, + maxRetries: 2, + retryBackoffMs: 500, + }, + agentLlm: {}, + editorApi: { + baseUrl: 'https://editor.example.test', + apiKey: 'game-chat-editor-secret', + }, + mcpServers: {}, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + render( + React.createElement(GameChatReleaseApp, { + allowAdvancedExternalEditorConfig: true, + }), + ); + + fireEvent.click(screen.getByRole('button', { name: '设置' })); expect( await screen.findByRole('dialog', { name: '运行时配置' }), ).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: /连接与工具/ })); expect(screen.getByLabelText('External Editor Base URL')).toHaveProperty( 'value', - 'http://127.0.0.1:8082', + 'https://editor.example.test', ); expect(screen.getByLabelText('External Editor API Key')).toHaveProperty( - 'type', - 'password', + 'value', + 'game-chat-editor-secret', ); - expect( - screen - .getByLabelText('External Editor API Key') - .getAttribute('autocomplete'), - ).toBe('off'); }); it('opens the dedicated game-chat release without platform authentication', async () => { diff --git a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts index ad9a41087..9ba8040b1 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts @@ -282,9 +282,7 @@ export function registerRuntimeSettingsTests() { renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); - expect( - await screen.findByText('陶泥儿智能创作(固定)'), - ).not.toBeNull(); + expect(await screen.findByText('陶泥儿智能创作(固定)')).not.toBeNull(); expect(screen.queryByLabelText('Agent 模式')).toBeNull(); expect(screen.getByLabelText('LLM 模型')).toHaveProperty( 'value', @@ -350,9 +348,7 @@ export function registerRuntimeSettingsTests() { renderLauncherAt('/?launcher'); fireEvent.click(screen.getByRole('button', { name: '配置' })); - expect( - await screen.findByText('陶泥儿智能创作(固定)'), - ).not.toBeNull(); + expect(await screen.findByText('陶泥儿智能创作(固定)')).not.toBeNull(); expect(screen.queryByLabelText('Agent 模式')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '保存' })); @@ -880,15 +876,8 @@ export function registerPublishedRuntimeSettingsTests() { screen.getByLabelText('LLM API Key').getAttribute('autocomplete'), ).toBe('off'); fireEvent.click(screen.getByRole('button', { name: /连接与工具/ })); - expect(screen.getByLabelText('External Editor API Key')).toHaveProperty( - 'type', - 'password', - ); - expect( - screen - .getByLabelText('External Editor API Key') - .getAttribute('autocomplete'), - ).toBe('off'); + expect(screen.queryByLabelText('External Editor Base URL')).toBeNull(); + expect(screen.queryByLabelText('External Editor API Key')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: /Agent 模型/ })); fireEvent.click(screen.getByRole('button', { name: /Planner planner/ })); fireEvent.click( @@ -1062,12 +1051,8 @@ export function registerPublishedRuntimeSettingsTests() { }, ); fireEvent.click(screen.getByRole('button', { name: /连接与工具/ })); - fireEvent.change(screen.getByLabelText('External Editor Base URL'), { - target: { value: 'http://127.0.0.1:8099' }, - }); - fireEvent.change(screen.getByLabelText('External Editor API Key'), { - target: { value: 'editor-new-secret' }, - }); + expect(screen.queryByLabelText('External Editor Base URL')).toBeNull(); + expect(screen.queryByLabelText('External Editor API Key')).toBeNull(); fireEvent.click( within(runtimeConfigDialog).getByRole('button', { name: '保存' }), ); @@ -1129,12 +1114,15 @@ export function registerPublishedRuntimeSettingsTests() { }, }, editorApi: { - baseUrl: 'http://127.0.0.1:8099', - apiKey: 'editor-new-secret', + baseUrl: 'https://dev.genarrative.world', + apiKey: '', }, mcpServers: {}, }, }); + expect(JSON.stringify(persistedConfig)).not.toContain( + 'editor-loaded-secret', + ); fireEvent.click( within(runtimeConfigDialog).getByRole('button', { name: '读取' }), ); @@ -1223,10 +1211,8 @@ export function registerPublishedRuntimeSettingsTests() { '12000', ); fireEvent.click(screen.getByRole('button', { name: /连接与工具/ })); - expect(screen.getByLabelText('External Editor Base URL')).toHaveProperty( - 'value', - 'http://127.0.0.1:8082', - ); + expect(screen.queryByLabelText('External Editor Base URL')).toBeNull(); + expect(screen.queryByLabelText('External Editor API Key')).toBeNull(); fireEvent.click( within(runtimeConfigDialog).getByRole('button', { name: '保存' }), ); @@ -1252,7 +1238,7 @@ export function registerPublishedRuntimeSettingsTests() { }, agentLlm: {}, editorApi: { - baseUrl: 'http://127.0.0.1:8082', + baseUrl: 'https://dev.genarrative.world', apiKey: '', }, mcpServers: {}, diff --git a/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx b/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx index 28bc88819..53be11dd0 100644 --- a/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx +++ b/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx @@ -37,6 +37,11 @@ import { clearStoredAuthAccessToken, setStoredAuthAccessToken, } from '../src/services/clientApi'; +import { + beginPlatformSessionTransition, + commitAuthenticatedPlatformSession, + resetPlatformSessionStateForTests, +} from '../src/services/platformSession'; class TestPointerEvent extends MouseEvent { readonly pointerId: number; @@ -579,6 +584,7 @@ function expectLockedBackgroundCannotCallPaidPorts( } beforeEach(() => { + resetPlatformSessionStateForTests(); clearStoredAuthAccessToken(); Object.defineProperty(window, 'PointerEvent', { configurable: true, @@ -595,7 +601,9 @@ beforeEach(() => { }); afterEach(() => { + resetPlatformSessionStateForTests(); clearStoredAuthAccessToken(); + delete window.__TAURI__; vi.restoreAllMocks(); }); @@ -2654,4 +2662,123 @@ describe('Tauri 素材创作无限画布独立 Surface', () => { expect(confirmationInput).not.toHaveProperty('apiKey'); expect(invokeSpy).toHaveBeenCalledTimes(3); }); + + it('Tauri adapter 在 401 后刷新内存登录态并只重放原生成命令一次', async () => { + const accessToken = 'expired-platform-token'; + setStoredAuthAccessToken(accessToken); + const installInvoke = vi.fn(async () => null); + window.__TAURI__ = { core: { invoke: installInvoke } }; + const authUser = { + id: 'user-test', + publicUserCode: 'tn-test', + displayName: '测试用户', + avatarUrl: null, + phoneNumber: null, + phoneNumberMasked: '138****0000', + loginMethod: 'password' as const, + bindingStatus: 'active' as const, + wechatBound: false, + wechatDisplayName: null, + wechatAccount: null, + }; + const generation = beginPlatformSessionTransition(); + await commitAuthenticatedPlatformSession(authUser, generation); + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + return new Response(JSON.stringify({ token: 'refreshed-token' }), { + status: 200, + }); + } + if (url === '/api/auth/me') { + return new Response( + JSON.stringify({ + user: authUser, + availableLoginMethods: ['password'], + }), + { status: 200 }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + const manifest = manifestFixture(scope.projectId); + let generationCalls = 0; + const invokeSpy = vi.fn( + async (command: string, args?: Record) => { + if (command !== 'generate_local_project_asset_canvas_image') { + throw new Error(`unexpected command: ${command}`); + } + generationCalls += 1; + if (generationCalls === 1) { + throw new Error('authentication-required: 登录已失效'); + } + return { + generation: { + generationId: '22222222-2222-4222-8222-222222222222', + intentId: '11111111-1111-4111-8111-111111111111', + phase: 'asset-durable-committed', + referenceResourceIds: [], + outputAssetId: 'canvas-commit', + errorCode: null, + createdAt: 1, + updatedAt: 2, + }, + images: [], + commit: { + resourceId: 'local-asset:canvas-commit', + assetId: 'canvas-commit', + projectId: scope.projectId, + commitId: '44444444-4444-4444-8444-444444444444', + committedProjectRevision: 1, + draftRevision: 1, + hostRevision: '1', + commitStatus: 'committed', + manifest, + eventId: 'event-auth-refresh', + }, + requestWasTransient: Boolean(args), + }; + }, + ); + const adapter = createTauriImageCanvasHostAdapter({ + projectPath: '/fixture/project', + expectedProjectId: scope.projectId, + expectedHostRevision: 0, + invoke: invokeSpy as unknown as ( + command: string, + args?: Record, + ) => Promise, + }); + const input = { + scope, + expectedHostRevision: '0', + expectedDraftRevision: 0, + intentId: '11111111-1111-4111-8111-111111111111', + generationId: '22222222-2222-4222-8222-222222222222', + idempotencyKey: '33333333-3333-4333-8333-333333333333', + commitId: '44444444-4444-4444-8444-444444444444', + commitIdempotencyKey: '55555555-5555-4555-8555-555555555555', + prompt: '生成主角', + aspectRatio: '1:1', + imageSize: '1K', + assetKind: 'illustration', + assetName: '主角', + referenceResourceIds: [], + }; + + await expect(adapter.generation.generateImage(input)).resolves.toEqual( + expect.objectContaining({ status: 'ok' }), + ); + expect(invokeSpy).toHaveBeenCalledTimes(2); + expect(invokeSpy.mock.calls[0]?.[1]).toEqual(invokeSpy.mock.calls[1]?.[1]); + expect(installInvoke).toHaveBeenLastCalledWith( + 'install_platform_account_session', + expect.objectContaining({ + userId: authUser.id, + accessToken: 'refreshed-token', + }), + ); + }); }); diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 55b058981..d175f6a82 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -109,7 +109,7 @@ - 主站账号、钱包、服务端项目、云端素材库和生成面板仍留在网站宿主;客户端本地项目、manifest、Runtime、审批、草稿、生成与正式提交仍留在 Tauri 宿主。共享视觉组件不得读取这些业务事实。 - 客户端 UI 对齐采用“同视觉、同组件、保留四区布局”,不复制主站整页布局。资源总览、运行视图和 Supervisor 对话继续是 Game Agent 工作台独有语义。 - 客户端正式产品仍只按最小 `1280×720` 横屏合同交付,并保留 `1280×800` 默认窗口与既有基线验收;更窄浏览器样式只负责不崩溃和开发兼容,不改成移动端创作工作台。 -- 普通用户界面不默认展示内部错误码或本机绝对路径;External Editor 配置只进入独立“运行时配置”对话框,不与素材画布主要动作并列,确需诊断的信息进入受控详情或开发模式。 +- 普通用户界面不默认展示内部错误码或本机绝对路径,也不展示 External Editor Base URL 或 Developer API Key;官方服务地址由客户端构建固定,远端编辑自动使用当前陶泥儿登录态。仅独立 standalone game-chat release 或显式高级自定义模式在隔离配置页维护 External v1 URL/Key,确需诊断的信息进入受控详情或开发模式。 - 素材画布的“素材名称”是用户可编辑的正式输出名称;“资源用途”是 manifest subtype,不向普通用户开放自由文本。新增资源默认“普通游戏美术”,可从普通游戏美术、统一视觉规范、游戏界面原型、核心美术图集四项中选择;精修资源继承源用途且不可改。导出格式继续限定 PNG/JPEG/WebP。工具动作与保存设置分层展示,“保存到项目”在 `1280×800` 和窄容器中都必须完整可见。 ### 3.8 现有 Godot 项目 @@ -538,7 +538,7 @@ type ProjectAgentMudPointAttribution = { ### 7.6 素材创作无限画布阶段一至五最终验收 -实现状态(2026-08-12):当前产品切片禁用“新增资源”,只从现有资源进入非破坏性编辑。图片进入中央 refine 画布,其他现役类型进入统一派生编辑壳;取消恢复、正式 manifest/revision 实时合并、依赖图重建、dependency/type 双布局协调和三阶段自动定位已经接通。command/event 任意顺序按项目、commit、event 与 revision 去重;低 revision、旧 graph/layout 和失效 focus generation 均不能倒灌。Tauri 远端媒体编辑固定使用 AppData 私有 `editorApi.baseUrl/apiKey` 访问 `/api/external/v1/*`,普通 Launcher、开发工作台和独立 game-chat 的“运行时配置”都可编辑这两个字段;不把 Key 传入 WebView、项目事实、账本、日志或普通错误,素材画布工作区自身不提供凭据输入。重启恢复只继续原 operation,不以新请求、新幂等键或新 operationId 替代结果未知的旧任务。 +实现状态(2026-08-15):当前产品切片禁用“新增资源”,只从现有资源进入非破坏性编辑。图片进入中央 refine 画布,其他现役类型进入统一派生编辑壳;取消恢复、正式 manifest/revision 实时合并、依赖图重建、dependency/type 双布局协调和三阶段自动定位已经接通。command/event 任意顺序按项目、commit、event 与 revision 去重;低 revision、旧 graph/layout 和失效 focus generation 均不能倒灌。普通 Tauri 客户端登录陶泥儿后直接使用固定官方 origin 下的网站现役 `/api/editor/*`、`/api/assets/*` 与 `/api/runtime/external-generation/jobs/*`,不展示或要求填写 Base URL/API Key;短期 Access Token 只进入 WebView、GUI 与 Runner 内存。独立 standalone game-chat release/高级自定义模式保留隔离 AppData Developer API Key 与 `/api/external/v1/*`。重启恢复只继续原 operation,不以新请求、新幂等键或新 operationId 替代结果未知的旧任务。 1. 网站与 Tauri 实际 import 同一份 `@genarrative/image-canvas-core` 和 `@genarrative/image-canvas-react`,客户端没有复制的主站画布目录;viewport、selection、变换、renderer 与 history 算法位于共享层,宿主只保留事件接线与 adapter 副作用。 2. “新增资源”在当前产品切片中保持禁用;“编辑资源”只接受现有资源。图片 refine 保留原资产与原文件、创建新资产,并用规范 `referenceResourceIds` 登记直接血缘;其他类型同样只追加派生文件/asset 或子版本,不覆盖、删除或重排源记录。 @@ -548,15 +548,15 @@ type ProjectAgentMudPointAttribution = { 6. 搜索/筛选隐藏新资源时保留条件,明确提示“新资源已保存,当前筛选条件下不可见”,只通过显式动作清除条件并定位。 7. 现有资源编辑、生成、保存、取消、失败和恢复必须覆盖权威专题 §13 中与当前非破坏性编辑切片对应的验收矩阵;只完成画布 UI 或只完成本地写文件都不能算正式闭环。 8. 自动定位必须分别证明资源已投影、dependency/type 两份布局都 settled 且存在目标位置、目标卡 DOM 已提交;搜索隐藏走显式清除/定位,任何 commit 最多自动聚焦一次。 -9. Tauri 远端媒体编辑只使用发布 AppData 私有 External v1 配置;后端按 owner 预扣/退款泥点并返回可轮询 operation。凭据失效、余额不足、平台生成配置故障和远端失败必须在画布内可见;生成状态不得因固定高度或 `overflow` 裁剪而消失,终态后刷新钱包余额。 -10. 素材画布生成账本的服务身份固定为 `service-origin-v1`,绑定规范化 External base URL 的服务指纹,不绑定 Developer API Key;确认面板只展示去除路径与凭据的服务 origin。无法用当前 Key 验证的升级前 Key-bound 账本在任何网络动作前显示当前服务 origin 并要求用户确认;确认挑战过期、账本变化或 base URL 变化均失败关闭。确认后 `accepted/running` 只恢复原 GET,`prepared` 只可精确重放冻结的原 POST。全类型资源编辑复用相同显式确认迁移:旧 Key-bound 指纹无法验证时确认前保持零网络动作,确认后已存在 `operationId` 的任务只 GET 原 operation,尚未受理的冻结请求才可复用原幂等键精确 POST。 +9. 普通 Tauri 远端媒体编辑只使用当前陶泥儿登录态与固定官方 origin;后端按 owner 预扣/退款泥点并返回可轮询 operation。凭据失效、余额不足、平台生成配置故障和远端失败必须在画布内可见;生成状态不得因固定高度或 `overflow` 裁剪而消失,终态后刷新钱包余额。External v1 配置仅属于独立 standalone game-chat release/高级自定义模式。 +10. 普通素材画布生成账本的服务身份固定为 `official-platform-v1 + 官方 origin + ownerUserId`,不绑定 Access Token;高级 External v1 账本只绑定显式服务 origin,不绑定 Developer API Key。两种模式的 `accepted/running` 都只恢复原 GET,`prepared` 只可精确重放冻结的原 POST、原正文和原幂等键。普通模式退出或换号后提升账号 generation,中止并脱离旧请求;旧账号账本在新账号下零网络、零安装,只有重新登录同一 owner 后才可恢复。不能通过更换 Token、Key、URL、请求正文或 operationId 绕过该隔离。 11. 资源编辑恢复面板必须为独立 modal,展示后端权威队列的全部 operation。用户可继续任意可恢复项;`remote-failed` 只允许显式移出活动队列,并保留私有账本审计;`reconciliation-required` 只读展示对账。读取失败必须提供重试,不得伪装空队列;操作后必须重读后端。 12. `remote-failed` 已是远端明确终态,重启后不再 POST、不再轮询、不再扣费;`archived` 仅表示用户已将它移出活动恢复队列,不等于 `committed`。`result-unknown`、鉴权临时失败和 `reconciliation-required` 均不允许归档或重新生成。 13. 派生资产提交必须通过 durable asset transaction journal 串起最终文件、manifest 和 project revision。任一崩溃阶段恢复后只有一份派生文件、一条 manifest asset,revision 精确推进一次;manifest 已写而 revision 未写时只前向补 revision,无法证明的组合进入人工对账。journal 已证明目标 asset、媒体和 target revision 写入后,即使后续合法提交继续推进 manifest/revision,也应按目标 asset 精确身份与 `currentRevision >= targetRevision` 补齐 ledger/draft,不得要求整个 manifest 永远等于历史 after 快照。durable committed 后遗留的 staging 只有在 staging/正式媒体摘要一致,且 manifest 中按 asset ID 或路径唯一命中并与 journal asset 精确相等时才尽力删除;删除失败不降级已提交结果,身份或媒体漂移则保留 staging 并进入对账。 14. generation progress、草稿保存队列、生成/提交回包与延迟 `loadDraft` 必须共用单调 revision 门禁,低 revision 不得覆盖已落地的新草稿。Shift 指针与键盘选择必须与共享 core 一致;单选自身 Shift 不能清空选择,已选多图层普通指针拖动应保持并同步移动选择集。零位移不得产生 undo、documentVersion 或草稿保存。 15. 失败 UI 必须按 `generation / draft-save / asset-commit / recovery / cancellation` 五类 operation 显示可访问名称与安全动作。只有生成失败可以保留参数并“返回修改/重新确认”;草稿、CAS、提交、恢复和取消故障不得出现会发起新生成的按钮。任何非 `editing` 生命周期都必须同时禁用或 inert 背景画布/顶部工具栏,并由生成 handler 再次校验当前状态;modal 遮罩、视觉 disabled 或旧闭包都不能充当业务门禁。 16. 派生子版本 journal 必须冻结 project revision before/after 身份和目标 after 记录。manifest 已有目标子版本但 journal 缺失时失败关闭;旧 journal 缺少 revision 身份且当前 revision 已推进、无法证明是同一事务写入时进入人工对账,不得把现状猜测为已提交。 -17. 文本、SVG 与 Agent 回执编辑必须在调用 Provider 前持久化 request-issued;Provider 成功正文必须在解析、格式校验和 staging 之前原子写入私有 durable handoff,并绑定原 operation、请求指纹和内容摘要。issued 后缺少可信 handoff 只能对账,已有可信 handoff 则只消费原正文,两者都不得再次调用 Provider。401/403 只表示当前凭据不能继续授权,不能把已受理 operation 改写为永久对账;修正 Key 后仍只查询原 operation。 +17. 文本、SVG 与 Agent 回执编辑必须在调用 Provider 前持久化 request-issued;Provider 成功正文必须在解析、格式校验和 staging 之前原子写入私有 durable handoff,并绑定原 operation、请求指纹和内容摘要。issued 后缺少可信 handoff 只能对账,已有可信 handoff 则只消费原正文,两者都不得再次调用 Provider。普通模式首次 `401` 只允许当前账号 generation 的单飞 refresh,成功后以更高 generation CAS 安装 Token,并使用原正文、原幂等键重试一次;refresh 失败、换号或再次 `401` 时保留原 operation,停止网络。`403` 不触发 refresh。高级 External v1 模式修正 Developer Key 后仍只查询或恢复原 operation。 ### 7.7 主站 UI 对齐验收 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f6feb269c..0d37ae003 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -88,18 +88,17 @@ - 终态与队列:资源编辑账本正式区分可继续阶段、`reconciliation-required`、`remote-failed` 和 `archived`。远端明确失败只保存稳定分类与终态时间,不得再 POST、轮询或重新扣费;只有该终态能由用户显式归档并移出活动恢复队列,归档保留账本且不伪装 `committed`。结果未知和需对账项继续失败关闭。 - 本地提交:派生 asset 提交新增 `game-creator-resource-edit-asset-transaction.v1` durable journal,冻结 operation/project/source、最终路径/摘要、manifest before/after 和 project revision before/after,以 `prepared -> media-installed -> manifest-written -> revision-written -> committed` 推进。只有文件、manifest、revision 与 journal 全部回读相等才提交 ledger;manifest after/revision before 只前向补 revision,无法证明的组合进入对账,不做猜测回滚。durable committed 后遗留 staging 只有在 staging 与正式媒体摘要一致、manifest 按 asset ID 或路径唯一命中且精确等于 journal asset 时才尽力清理;删除 I/O 失败不改变 committed,身份或媒体漂移则保留 staging,并把 journal 与 ledger 转入对账。 - 版本提交:派生子版本 journal 冻结 project revision before/after 摘要和目标 after 记录。manifest 已有目标子版本但 journal 缺失时失败关闭;旧 journal 缺少 revision 身份时,只有当前 revision 仍为 base 才允许补齐身份,revision 已推进且无法证明由同一事务写入时必须进入 `reconciliation-required`;缺少上述 journal 证明时,不得仅以子版本存在或 revision 数值已到达推断 committed。 -- 服务身份:新生成账本统一写 `service-origin-v1`,绑定规范化 External base URL 的服务指纹,Developer API Key 只负责授权而不拥有 operation;确认面板只展示去除路径与凭据的服务 origin。升级前 Key-bound 指纹可被当前 Key 精确验证时自动迁移;对素材画布生成账本,无法验证时在任何网络动作前显示脱敏服务 origin 并要求用户确认带有期限且绑定账本快照的挑战值,确认后已受理任务只恢复原 GET,prepared 任务只精确重放冻结 POST。全类型资源编辑复用同一身份分类,旧指纹无法验证时保留原 operation 进入对账,不自动接管或重放。 +- 服务身份:普通模式新生成账本写入 `official-platform-v1 + 固定官方 origin + ownerUserId`,高级 External v1 模式写入显式 service origin;Access Token 与 Developer API Key 都只负责授权而不拥有 operation。两种模式的已受理任务只恢复原 GET,prepared 任务只精确重放冻结 POST;普通模式 owner 不匹配时零网络,高级模式 origin 不匹配时失败关闭。 - 前端一致性:generation progress、保存队列、生成/提交回包和延迟草稿读取共用单调 revision 门禁;当前 scope 低 revision 不得回退已落地草稿,同 revision 只接受完整相等的幂等回包。Tauri 指针与键盘 Shift 选择共用 `resolveLayerPointerSelection`;移动、缩放和平移只在首次真实变化时 capture history,零位移不生成 undo、documentVersion 或保存。 - 交互恢复:工作台用独立 modal 展示全部后端权威 operation,允许选择任意可恢复项;`remote-failed` 只提供归档,`reconciliation-required` 只读展示。读取失败显式重试,操作后重读后端,项目切换后丢弃迟到结果。`canvas.failed` 统一携带 `generation / draft-save / asset-commit / recovery / cancellation` 五类 operation,只有生成失败显示“返回修改/重新确认”。 - 产品语义:当前仍禁用“新增资源”,只对现有资源做非破坏性派生编辑;新结果追加为新文件、asset 或子版本,源资源、源文件和原版本保留不变。 -## 2026-08-10 Tauri 客户端远端资源编辑固定使用 External v1 +## 2026-08-10 Tauri 客户端远端资源编辑统一复用平台生成链路 -- 产品边界:主站网页画布继续使用登录态 `/api/editor/*`、`/api/assets/*` 与 `/api/runtime/external-generation/jobs/*`;AI 游戏创作 Tauri 客户端没有网页画布宿主,图片、图片引用、视频、音效和背景音乐等远端媒体编辑固定使用 `/api/external/v1/*`。两条入口继续复用相同请求 DTO、owner 归属、统一生成队列和正式资产结果,不新建平行生成服务。 -- 凭据边界:客户端沿用发布 AppData 私有运行时配置中的 `editorApi.baseUrl/apiKey`,不实现登录后自动签发 Developer API Key,不把站内 Access Token 传给 Tauri 生成命令,也不把 API Key 打包进仓库、传入 WebView、写入项目、账本、日志或普通错误。Key 缺失投影安全配置错误,`401/403` 投影 External 凭据无效或权限不足,不再描述为站内登录失效。 -- 配置入口:普通 Launcher、开发工作台和独立 game-chat 复用同一“运行时配置”对话框,均可编辑 AppData 私有 `editorApi.baseUrl/apiKey`;字段可见性不依赖开发模式或 Agent 模式。素材画布工作区自身不承接凭据输入,密钥输入保持 password 类型并禁用浏览器自动填充。 -- 路由边界:图片编辑、视频、音效和 BGM 分别使用 `/api/external/v1/editor/images/edits`、`/api/external/v1/editor/videos/generations`、`/api/external/v1/editor/audios/sound-effects/generations` 与 `/api/external/v1/editor/audios/background-music/generations`;上传、确认、轮询和换签固定使用 External v1 对应端点。SVG、UTF-8 文档、代码、Agent 回执与项目版本仍是本地派生,不制造无意义的远端请求。 -- 可靠性边界:External POST 固定携带原稳定 `Idempotency-Key` 并只接受 `202 + operationId`;`prepared` 只重放原正文和原键,`accepted/running` 只查询 `/api/external/v1/generations/{operationId}`。账本使用 `service-origin-v1` 绑定规范化 External base URL 的服务指纹,不绑定或保存明文 Key;升级前 Key-bound 素材画布生成账本无法用当前 Key 验证时,必须由用户显式确认面板中展示的当前 origin 才能恢复冻结请求;全类型资源编辑的旧指纹无法验证时保留原 operation 对账。升级前已保存的站内 endpoint 只保留为待对账状态,禁止拿 External Key 自动重放。本地参考媒体继续严格执行 ticket → OSS form → confirm,结果只消费稳定 `objectKey/resource/asset`,旧资源保留且派生资源追加。 +- 产品边界:主站网页画布与普通 AI 游戏创作 Tauri 客户端统一使用登录态 `/api/editor/*`、`/api/assets/*` 与 `/api/runtime/external-generation/jobs/*`;第三方、standalone game-chat 和显式高级模式使用 `/api/external/v1/*`。两条入口复用相同请求 DTO、owner 归属、统一生成队列和正式资产结果,不新建平行生成服务。 +- 凭据与配置:普通客户端的 Access Token 只通过专用 session install/clear 协议进入 GUI/Runner 内存,普通生成/恢复命令参数和运行时配置页不携带或展示 Token、URL、Developer Key。高级模式使用隔离 AppData 的 `editorApi.baseUrl/apiKey`,不与普通 release 自动共享。 +- 路由边界:普通图片编辑、视频、音效和 BGM 使用对应 `/api/editor/*`,上传/确认/换签使用 `/api/assets/*`,状态查询使用 `/api/runtime/external-generation/jobs/*`;高级模式使用对应 External v1 路由。SVG、UTF-8 文档、代码、Agent 回执与项目版本仍是本地派生。 +- 可靠性边界:两种模式都携带原稳定 `Idempotency-Key`;普通站内响应兼容 `200 + queueState.operationId` 与 inline 完成,高级 External v1 固定 `202 + operationId`。`prepared` 只重放原正文和原键,`accepted/running` 只查询原 operation。账本绑定调用模式、服务 origin 和普通模式 ownerUserId,不绑定 Token/Key;换号后旧 owner 账本零网络、零安装。 - 关联:`apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts`、`apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs`、`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`。 ## 2026-08-10 客户端参考媒体直传复用已授权私有前缀 @@ -5163,9 +5162,9 @@ - 2026-06-25 调整:普通用户通过聊天输入 `/run` 触发待确认 `game.run_local`,确认后只能复用白名单 `game.static_smoke` 自检当前 `game/index.html`,通过后启动 `127.0.0.1` 本地 HTTP 预览。独立执行 `game.static_smoke` 时如果已有 `.agent/run.latest.json`,必须追加 `Playtest / game.static_smoke` trace step,避免“运行了代码但编排 trace 不可见”。 - 2026-06-25 调整:普通用户通过聊天输入 `/trace` 触发只读 `agent.trace_read`,读取 `.agent/run.latest.json` 并在聊天里摘要 loop 轮次、stopReason、nextStep、active / carry-over 任务、repairRoutes、agent 建议命令和最近 step。trace 面板仍只在开发窗口展示,普通用户窗口不新增面板。 - 2026-06-25 调整:普通用户通过聊天输入 `/import-canvas-export /绝对/画板素材.zip 画板项目ID` 触发待确认 `canvas.export_import`,读取现有 `/editor/canvas` 素材导出 ZIP。导入命令只读取用户指定 ZIP,写入当前本地项目 `assets/canvas-imports/`,基础护栏限制路径逃逸、文件数量和解压体积;导出包没有真实 resourceId 时,用 `canvas-export:` 作为可追踪 assetObjectId,不伪造后端画板资源行。 -- 2026-06-25 调整,2026-06-30 更新:普通用户通过聊天输入 `/sync-canvas-project 画板项目ID` 触发待确认 `canvas.project_sync`,复用现有 `/api/external/v1/editor/projects/{projectId}` 读取画板项目快照,再用 `/api/external/v1/assets/read-url` 对 objectKey 或 legacy path 换签,下载资源到本地项目 `assets/canvas-sync/` 并登记为 `canvas` 来源资产;该命令从客户端配置项 `editorApi.apiKey` 读取平台 API Key,默认 base URL 为 `http://127.0.0.1:8082`,可用 `editorApi.baseUrl` 覆盖。API Key 不写入 manifest、agent.db、trace 或日志。该路径不伪装浏览器登录态,也不绕过画板生成、钱包扣费或外部生成 worker;它只同步用户 API Key 已有权限读取的画板资源。 -- 2026-06-30 调整:`game.generate_draft` 在 `editorApi.apiKey` 已配置且美术组缺少 `canvas` 来源图片资产时,直接调用 External Editor API 的 `/api/external/v1/editor/images/generations` 生成首版美术素材,再通过 `/api/external/v1/assets/read-url` 换签下载到本地 `assets/canvas-generated/`,登记为 `canvas` 来源资产并追加 `canvas.asset_generate` 本地索引记录。API Key 不写入 manifest、agent.db、trace 或日志;生成失败不阻断本地原型生成,会在 trace 中记录失败并保留同步建议。 -- 2026-06-25 调整,2026-06-30 更新:美术组 `Asset` 和音乐组 `SFX` 角色在 loop 中读取 `.agent/manifest.json`;当本地项目还没有对应类型的 `canvas` 来源资产时,角色 step 会追加 `agent.tool.suggest.canvas.project_sync` toolCall:美术组需要 `image/*` 或 `application/vnd.genarrative.image-sequence`,音乐组需要 `audio/*`。美术组在未配置 `editorApi.apiKey` 或平台生图失败时仍只给出同步建议;音乐组不调用图片生成接口,只建议同步已有音频资源。 +- 普通用户通过聊天输入 `/sync-canvas-project 画板项目ID` 触发待确认 `canvas.project_sync`;普通模式用当前陶泥儿登录态读取 `/api/editor/projects/{projectId}` 并通过 `/api/assets/read-url` 换签,高级模式使用对应 External v1 路由。固定官方 origin、owner 和凭据均不写入 manifest、Agent DB、trace 或日志。 +- `game.generate_draft` 在当前模式具备画板服务授权且美术组缺少 `canvas` 来源图片资产时,复用同一平台生成链路生成首版美术素材并下载到本地;普通模式使用登录态内部路由,高级模式使用 External v1。 +- 美术组 `Asset` 和音乐组 `SFX` 在缺少对应 `canvas` 来源资产时建议同步;普通模式未登录或高级模式 Developer Key 缺失时只给出准确的能力不可用说明,不伪造生成结果。 - 2026-06-24 调整:同一本地项目多次 `game.generate_draft` 必须追加 `memory/session.md` 与 `memory/project.md`,不得覆盖历史对话和创作目标记录。 - 2026-07-01 调整:AI 游戏创作 App 在 `memory/session.md` 与 `memory/project.md` 之外新增项目级黑板 `memory/blackboard.md`,只记录重要跨 agent 决策、依赖和风险摘要;每个角色 agent 拥有私有记忆 `memory/agents//.md`。角色 brief 必须读取自己的私有记忆和项目黑板;`game.generate_draft` 通过 Evaluator 与 `game.static_smoke` 后,追加项目黑板摘要和各角色成功产出摘要,不得覆盖既有记忆。失败 run 仍只保留 trace 和 pass 快照,不写最终记忆摘要。 - 2026-07-06 调整:AI 游戏创作 App 主聊天普通文本改为进入主聊天 Agent,而不是直接排队 `game.generate_draft`;主聊天 Agent 读取短期记忆、长期记忆、项目黑板、最近项目对话和本地资产摘要作为背景,支持 `agentLlm.chat` 单独 provider 配置,但只做自然语言交互、澄清和 slash 命令建议,不写项目、不运行工具、不伪装生成结果。显式 `/generate <创作想法>` 或 `/draft <创作想法>` 才进入 `game.generate_draft` 待确认流。 @@ -6738,7 +6737,7 @@ - MCP 边界:动态 MCP 函数的 `arguments.input` 必须在创建 durable pending 前按当前 catalog 的原始 `inputSchema` 本地校验;native parser 负责把错误归类为可修复的 arguments-schema,统一 enrichment 覆盖 legacy 兼容解析并把错误接回同一 repair 链。实际 MCP 调用前还必须按当前 catalog schema 重验一次,阻断升级前遗留的 schema 外 durable pending。校验器关闭 HTTP 与文件解析能力,外部 `$ref`、无效 schema、required/type/enum/additionalProperties 不匹配全部失败关闭,错误不得回显参数或 schema 私密值。 - Native Prompt:Provider 请求只描述实际广告的 `update_agent_plan`、动作函数、`respond_to_user` 和动态 MCP 函数;内部 `mcp.call` wrapper、`thinkingSummary/planUpdate` envelope、空 actions 以及无 function-tools 文本回退不再进入实时 Prompt。required-nullable 字段未使用时显式传 JSON `null`,空对象 input 只允许权威空 schema 工具。 -- Supervisor 合同:配置 External Editor API Key 时,`art-asset-plan` 的 owner 产物统一为 `assets/manifest.art.json` 与 `assets/art-spritesheet.png`;未配置 Key 时只要求 `assets/manifest.art.json`,不得伪造或要求三个 PNG。版本化 Bundle 的视觉合同和 playbook 不得给出互斥 expectedArtifacts。 +- Supervisor 合同:当前模式具备画板服务授权时,`art-asset-plan` 的 owner 产物统一为 `assets/manifest.art.json` 与 `assets/art-spritesheet.png`;普通模式未登录或高级模式未配置 Developer Key 时只要求 `assets/manifest.art.json`,不得伪造或要求三个 PNG。版本化 Bundle 的视觉合同和 playbook 必须从同一授权事实派生。 ## 2026-08-04 图集事务与 Tetris 完成门使用句柄和 AST 收口 @@ -7163,6 +7162,17 @@ - 决策:普通 AGC Tauri release 的平台认证与客户端 API 固定访问 `https://dev.genarrative.world/api/*`;本地 `npm run agc` 继续使用 Vite `/api` 代理,普通网页构建继续使用同源相对路径,game-chat release 继续绕过平台登录。 - 传输边界:dev 公网入口不接受 Tauri WebView 的跨域 OPTIONS 预检,因此 release 使用 `tauri-plugin-http` 原生 transport。插件的 npm 依赖只归属 AGC 子包及其 lockfile,根 H5 package 与根 lockfile 不得引入任何 Tauri guest 依赖。插件 capability 与前端 URL 解析双重限制为 `https://dev.genarrative.world/api/*`,不为 WebView CSP 增加远程 `connect-src`,也不开放任意 HTTP(S) 目标。 - 会话边界:插件默认 Cookie Store 持久化 refresh Cookie;访问 Token 继续保存在现有客户端存储并通过 Authorization header 发送,不把 Token、Cookie 或登录正文写入日志、配置文件或仓库。 +- 编辑器通道边界:普通/debug 构建由可信 native build flavor 固定为平台账号模式,缺少 native 会话时失败关闭并要求重新登录,绝不回退读取 `editorApi`。只有正式 `game-chat-release` 构建进入 External Developer 模式并读取其独立 AppData;普通配置读视图不暴露 Developer Key,renderer 保存会移除 `editorApi`。图片 refine 的本地源在 upload/confirm 后必须登记为项目 resource,再以 `sourceReferenceId` 提交,不能把 objectKey 或 assetObjectId 冒充业务引用。 + +## 2026-08-15 AGC 普通远端编辑复用网站登录态路由 + +- 路由决策:普通 AGC 登录后直接调用固定官方 origin 下的网站现役 `/api/editor/*`、`/api/assets/*` 与 `/api/runtime/external-generation/jobs/*`,不再要求用户配置 External Editor Base URL 或 `tnr_sk_...`。普通 release 的官方 origin 继续服从 2026-08-10 的 dev API 固定规则,本地 dev 使用配套代理。`/api/external/v1` 的 Bearer Developer API Key 契约保持独立且不接受网站 Access Token,只供第三方 Agent/CLI、独立 standalone game-chat release 与显式高级自定义模式;服务端不为普通用户自动签发、代管或下发 Developer Key。 +- UI 与 standalone 例外:普通 Launcher、工作台、已认证 debug game-chat 和运行时配置页不展示或维护 External Editor URL/Key。独立 standalone game-chat release 仍绕过 `AuthenticatedClient`,只能在用户显式配置其隔离 AppData 的高级 External v1 URL/Key 后使用云端画板能力;凭据缺失时明确不可用,不得回退到普通登录态、内置 Key 或隐式公共账号。 +- 会话 CAS:WebView 登录、refresh、退出和换号通过窄 Tauri/Runner 协议同步 `userId + 短期 Access Token + authGeneration` 到 GUI/Runner 进程内存。generation 单调递增,只有不旧于当前值的安装/清除可以生效;每个业务请求在发出前冻结 `ownerUserId + authGeneration`,在响应解析、账本推进、媒体安装和正式项目提交前都重新核对。旧 generation 的迟到安装、响应或清除不得覆盖新账号,也不得把旧账号产物安装到新账号项目。 +- 凭据边界:Access Token、refresh Cookie、Authorization 和 authGeneration 不进入 AppData、项目、manifest、生成账本、Agent prompt、conversation、Agent DB、trace、日志或普通错误;durable 账本只保存非敏感 ownerUserId。Runner IPC 只允许专用 session install/clear 消息携带 Token,普通生成/恢复命令参数继续不得携带 Access Token 或 Developer Key。 +- 401/refresh:普通模式首次 `401` 进入当前 authGeneration 的全局单飞 refresh;其它请求等待同一 Promise,但各自 deadline、取消和账号归属独立。refresh 成功只在原 generation/owner 仍当前时以更高 generation CAS 发布新 Token,然后每个调用最多使用原 endpoint、原始请求字节和原 `Idempotency-Key` 重试一次。refresh 失败、账号已切换或重试仍为 `401` 时停止网络并保留原 operation;`403` 表示当前账号权限不足,不触发 refresh。高级 External v1 模式只允许用户修正自己的 Developer Key,并继续原 operation。 +- Durable 身份与换号:普通账本身份固定为 `official-platform-v1 + 官方 origin + ownerUserId`,高级账本身份固定为 External service origin,二者均不绑定可轮换 Token/Key。退出或换号会提升 generation、中止并脱离旧请求;新账号对旧 owner 的 `prepared/accepted/running` 账本必须零 POST、零 GET、零下载、零安装、零归档,只有重新登录同一 owner 后才能恢复。`401/403/404`、网关错误或轮询超时不能在 owner 不匹配时证明旧 operation 的终态。 +- 幂等与站内响应:`prepared` 只可精确重放账本冻结的原 endpoint、原始正文和原幂等键;`accepted/running` 只查询原 operationId。Access Token/Key 轮换、refresh、重启、退出或换号都不得创建替代 operation、修改正文或重新扣费。站内生成适配 `200 + queueState.operationId`、inline 完成和 `job` 状态包装;External v1 继续固定 `202 + operationId`,两者共享相同 owner、计费、稳定引用与本地事务门禁。 ## 2026-08-12 子 Agent 澄清回执由 Supervisor 中转(Issue #163) @@ -12199,9 +12209,9 @@ - 2026-06-25 调整:普通用户通过聊天输入 `/run` 触发待确认 `game.run_local`,确认后只能复用白名单 `game.static_smoke` 自检当前 `game/index.html`,通过后启动 `127.0.0.1` 本地 HTTP 预览。独立执行 `game.static_smoke` 时如果已有 `.agent/run.latest.json`,必须追加 `Playtest / game.static_smoke` trace step,避免“运行了代码但编排 trace 不可见”。 - 2026-06-25 调整:普通用户通过聊天输入 `/trace` 触发只读 `agent.trace_read`,读取 `.agent/run.latest.json` 并在聊天里摘要 loop 轮次、stopReason、nextStep、active / carry-over 任务、repairRoutes、agent 建议命令和最近 step。trace 面板仍只在开发窗口展示,普通用户窗口不新增面板。 - 2026-06-25 调整:普通用户通过聊天输入 `/import-canvas-export /绝对/画板素材.zip 画板项目ID` 触发待确认 `canvas.export_import`,读取现有 `/editor/canvas` 素材导出 ZIP。导入命令只读取用户指定 ZIP,写入当前本地项目 `assets/canvas-imports/`,基础护栏限制路径逃逸、文件数量和解压体积;导出包没有真实 resourceId 时,用 `canvas-export:` 作为可追踪 assetObjectId,不伪造后端画板资源行。 -- 2026-06-25 调整,2026-06-30 更新:普通用户通过聊天输入 `/sync-canvas-project 画板项目ID` 触发待确认 `canvas.project_sync`,复用现有 `/api/external/v1/editor/projects/{projectId}` 读取画板项目快照,再用 `/api/external/v1/assets/read-url` 对 objectKey 或 legacy path 换签,下载资源到本地项目 `assets/canvas-sync/` 并登记为 `canvas` 来源资产;该命令从客户端配置项 `editorApi.apiKey` 读取平台 API Key,默认 base URL 为 `http://127.0.0.1:8082`,可用 `editorApi.baseUrl` 覆盖。API Key 不写入 manifest、agent.db、trace 或日志。该路径不伪装浏览器登录态,也不绕过画板生成、钱包扣费或外部生成 worker;它只同步用户 API Key 已有权限读取的画板资源。 -- 2026-06-30 调整:`game.generate_draft` 在 `editorApi.apiKey` 已配置且美术组缺少 `canvas` 来源图片资产时,直接调用 External Editor API 的 `/api/external/v1/editor/images/generations` 生成首版美术素材,再通过 `/api/external/v1/assets/read-url` 换签下载到本地 `assets/canvas-generated/`,登记为 `canvas` 来源资产并追加 `canvas.asset_generate` 本地索引记录。API Key 不写入 manifest、agent.db、trace 或日志;生成失败不阻断本地原型生成,会在 trace 中记录失败并保留同步建议。 -- 2026-06-25 调整,2026-06-30 更新:美术组 `Asset` 和音乐组 `SFX` 角色在 loop 中读取 `.agent/manifest.json`;当本地项目还没有对应类型的 `canvas` 来源资产时,角色 step 会追加 `agent.tool.suggest.canvas.project_sync` toolCall:美术组需要 `image/*` 或 `application/vnd.genarrative.image-sequence`,音乐组需要 `audio/*`。美术组在未配置 `editorApi.apiKey` 或平台生图失败时仍只给出同步建议;音乐组不调用图片生成接口,只建议同步已有音频资源。 +- 普通用户通过聊天输入 `/sync-canvas-project 画板项目ID` 触发待确认 `canvas.project_sync`;普通模式用当前陶泥儿登录态读取 `/api/editor/projects/{projectId}` 并通过 `/api/assets/read-url` 换签,高级模式使用对应 External v1 路由。固定官方 origin、owner 和凭据均不写入 manifest、Agent DB、trace 或日志。 +- `game.generate_draft` 在当前模式具备画板服务授权且美术组缺少 `canvas` 来源图片资产时,复用同一平台生成链路生成首版美术素材并下载到本地;普通模式使用登录态内部路由,高级模式使用 External v1。 +- 美术组 `Asset` 和音乐组 `SFX` 在缺少对应 `canvas` 来源资产时建议同步;普通模式未登录或高级模式 Developer Key 缺失时只给出准确的能力不可用说明,不伪造生成结果。 - 2026-06-24 调整:同一本地项目多次 `game.generate_draft` 必须追加 `memory/session.md` 与 `memory/project.md`,不得覆盖历史对话和创作目标记录。 - 2026-07-01 调整:AI 游戏创作 App 在 `memory/session.md` 与 `memory/project.md` 之外新增项目级黑板 `memory/blackboard.md`,只记录重要跨 agent 决策、依赖和风险摘要;每个角色 agent 拥有私有记忆 `memory/agents//.md`。角色 brief 必须读取自己的私有记忆和项目黑板;`game.generate_draft` 通过 Evaluator 与 `game.static_smoke` 后,追加项目黑板摘要和各角色成功产出摘要,不得覆盖既有记忆。失败 run 仍只保留 trace 和 pass 快照,不写最终记忆摘要。 - 2026-07-06 调整:AI 游戏创作 App 主聊天普通文本改为进入主聊天 Agent,而不是直接排队 `game.generate_draft`;主聊天 Agent 读取短期记忆、长期记忆、项目黑板、最近项目对话和本地资产摘要作为背景,支持 `agentLlm.chat` 单独 provider 配置,但只做自然语言交互、澄清和 slash 命令建议,不写项目、不运行工具、不伪装生成结果。显式 `/generate <创作想法>` 或 `/draft <创作想法>` 才进入 `game.generate_draft` 待确认流。 @@ -13774,7 +13784,7 @@ - MCP 边界:动态 MCP 函数的 `arguments.input` 必须在创建 durable pending 前按当前 catalog 的原始 `inputSchema` 本地校验;native parser 负责把错误归类为可修复的 arguments-schema,统一 enrichment 覆盖 legacy 兼容解析并把错误接回同一 repair 链。实际 MCP 调用前还必须按当前 catalog schema 重验一次,阻断升级前遗留的 schema 外 durable pending。校验器关闭 HTTP 与文件解析能力,外部 `$ref`、无效 schema、required/type/enum/additionalProperties 不匹配全部失败关闭,错误不得回显参数或 schema 私密值。 - Native Prompt:Provider 请求只描述实际广告的 `update_agent_plan`、动作函数、`respond_to_user` 和动态 MCP 函数;内部 `mcp.call` wrapper、`thinkingSummary/planUpdate` envelope、空 actions 以及无 function-tools 文本回退不再进入实时 Prompt。required-nullable 字段未使用时显式传 JSON `null`,空对象 input 只允许权威空 schema 工具。 -- Supervisor 合同:配置 External Editor API Key 时,`art-asset-plan` 的 owner 产物统一为 `assets/manifest.art.json` 与 `assets/art-spritesheet.png`;未配置 Key 时只要求 `assets/manifest.art.json`,不得伪造或要求三个 PNG。版本化 Bundle 的视觉合同和 playbook 不得给出互斥 expectedArtifacts。 +- Supervisor 合同:当前模式具备画板服务授权时,`art-asset-plan` 的 owner 产物统一为 `assets/manifest.art.json` 与 `assets/art-spritesheet.png`;普通模式未登录或高级模式未配置 Developer Key 时只要求 `assets/manifest.art.json`,不得伪造或要求三个 PNG。版本化 Bundle 的视觉合同和 playbook 必须从同一授权事实派生。 ## 2026-08-04 图集事务与 Tetris 完成门使用句柄和 AST 收口 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 56db608f3..03d646f58 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -52,7 +52,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 - 入口例外:debug 构建接受 CLI `--game-chat`,并可选接受 `--project-path ` 与内部验收用 `--initial-message <首条消息>`;宿主内部统一映射为 `index.html?game-chat&projectPath=...`。独立 game-chat release flavor 使用 `npm run agc:build:game-chat-release` 构建,并由 Rust `game-chat-release` feature 在无参数启动时固定同一初始页面;前端也在编译期固定为 game-chat 页面,不能导航回普通客户端首页、项目组或开发窗口。该 URL 必须在 Tauri 创建 `client` WebView 前写入初始 `WindowConfig`,禁止在 `.setup()` 阶段读取尚未完成首航的 `client.url()` 后二次导航。页面首屏读取初始消息后必须立即从当前 URL 删除 `initialMessage`,并以页面级闩锁绑定启动项目;React StrictMode、Runtime 终态、HMR、整页重载或项目切换都不得再次提交同一启动消息。普通 dev / release 的入口、参数与页面集合保持不变,普通 release 不因该 flavor 注册 game-chat 参数或窗口。未传入项目路径时由页面选择目录:已有 AI 游戏项目直接打开,空目录按目录名初始化,非空且未初始化目录继续复用现有二次确认。 - 独立发布身份:game-chat release 使用独立 Tauri 配置,`productName` 固定为 `Genarrative Game Chat`,`identifier` 固定为 `world.genarrative.ai-game-creator.game-chat`,当前专用 release 版本为 `0.1.1`,因此安装身份和 AppData 均与普通 `Genarrative AI Game Creator` / `world.genarrative.ai-game-creator` 隔离。该 flavor 只生成自己的 NSIS release 包,不覆盖普通客户端构建配置或已有 AppData。 -- 本地页面边界:独立 game-chat release 在前端入口直接渲染本地 `GameChatReleaseApp`,不经过平台 `AuthenticatedClient`,启动和使用本地项目工作台不依赖 `api-server` 或平台登录态。该例外只由编译期 game-chat-only 标记启用;普通 release、`npm run agc` 和 debug game-chat 继续经过既有认证包装,认证与后端依赖均不改变。 +- 本地页面边界:独立 standalone game-chat release 在前端入口直接渲染本地 `GameChatReleaseApp`,不经过平台 `AuthenticatedClient`,启动和使用本地项目工作台不依赖 `api-server` 或平台登录态。该 flavor 只有在用户显式启用隔离 AppData 中的高级 External v1 配置后,才使用自备 Developer API Key 调用 `/api/external/v1/*`;未配置时云端画板同步与生成能力明确不可用,不得回退、自动签发或内置 `tnr_sk_...`。普通 release、`npm run agc` 和 debug game-chat 继续经过既有认证包装,并统一使用网站 Access Token 与内部编辑器路由。 - Windows AppData 安全迁移:首次创建客户端 AppData 时必须以进程 `TokenUser` SID 显式设置 owner,并写入当前用户私有 DACL,不能把可能为 Administrators 的 `TokenOwner` 当作用户身份。发现历史目录 owner 不属于当前 `TokenUser` 时,不在原目录上放宽权限,而是拒绝 reparse point / junction / symlink 后,将旧目录原子重命名到同级唯一 `.owner-mismatch-backup-*` 备份,再新建并验证当前用户 owner 与私有 DACL;迁移或备份失败必须失败关闭,不覆盖旧配置。 - Windows 私有文件初始化:父目录已归当前 `TokenUser` 后,新建 `.agent/.manifest.json.lock`、`agent-runner.lock`、endpoint 临时文件、project-owner 诊断临时文件与 real-E2E 私有文件的 owner 仍可能采用 token 默认 owner `Administrators`。manifest 固定锁和 Runner 固定 stale lock 只有在 Windows 不共享独占句柄已取得、且句柄确认普通文件、非 reparse point、链接数为一时才允许初始化或修复为当前 `TokenUser`,随后必须再次复核句柄并按既有 owner/DACL 门禁验证;其它临时文件只允许在本进程 `create_new` 成功且仍持有同一独占句柄时初始化 `TokenUser` owner / DACL,再写入、原子安装并严格复核,初始化失败必须清理刚创建的文件。既有 durable endpoint / diagnostic 读取不得自动接管;活锁不得截断,只有 sharing / lock violation `32/33` 表示占用,access denied 等其它错误立即返回。父进程观察到 Runner 子进程退出后立即返回错误,不等待完整 30 秒 deadline。 - 启动诊断:独立 release 的 `startup.log` 和 `agent-runner.log` 只记录有界、脱敏的阶段与 stdout / stderr 摘要,凭据、AppData 路径和其它绝对路径不得原样落盘;单文件达到 256 KiB 后只轮转保留一份 `.previous.log`。`startup.log` 优先写独立 AppData,目录不可写时回退到系统 TEMP 下的 `Genarrative-Game-Chat-Diagnostics`;Tauri context、窗口 URL、AppData、Runner 或 `.setup()` / `.build()` 初始化失败时,Windows 必须显示可见错误对话框并给出诊断日志位置,不能只在无控制台 release 中静默退出。 @@ -230,13 +230,13 @@ V1.17 同时把 finalization journal 升级为 v2 并绑定最终完整计划快 V1.18 开发窗口把模式扩展为 `执行 / 聊天 / 目标`,Goal 创建和编辑使用独立弹层,正式用户窗口不暴露该开发控制面。assistant 写入后必须先可靠投影 Runtime completed,再提交 Goal completed 并补齐 Goal 终态 projection。2026-07-16 正式 `openai_chat / gpt-5.5` 的隔离 `goal-runtime` 已完成 edit、真实失败回灌、pause、Runner pidfd 强杀、重启静默、显式同 run resume、唯一 assistant 和零重放/零公共泄漏全链路,V1.18 真实 Provider 门禁已 PASS。 -2026-07-12 真实验收:发布 AppData 中的真实 `gpt-5.5` 已通过最终安全收紧后的 `llm-runtime` 套件,覆盖 Runner 强杀恢复且 run/session 身份稳定、仓库上下文、checkpoint/精确修改、失败命令诊断与修复复验、6 套确认生命周期、项目验证、桌面与移动非空画布证据、3 个隔离实例并行和唯一 all-join;95 条 task、161 条 event、137 条 Agent DB、13 条合法工具协议、副作用判重、终态投影、assistant audit、消息、回执和密钥泄露均以结构化落盘事实验收。`full` 套件仍要求 External Editor API 配置,缺失时必须返回 `BLOCKED(editorApi)`,不得记为通过。 +2026-07-12 真实验收:发布 AppData 中的真实 `gpt-5.5` 已通过最终安全收紧后的 `llm-runtime` 套件,覆盖 Runner 强杀恢复且 run/session 身份稳定、仓库上下文、checkpoint/精确修改、失败命令诊断与修复复验、6 套确认生命周期、项目验证、桌面与移动非空画布证据、3 个隔离实例并行和唯一 all-join;95 条 task、161 条 event、137 条 Agent DB、13 条合法工具协议、副作用判重、终态投影、assistant audit、消息、回执和密钥泄露均以结构化落盘事实验收。`full` 套件仍要求当前运行模式具备画板服务授权:普通模式为有效陶泥儿登录态,standalone/高级模式为有效 External v1 Developer Key;缺失时必须返回 `BLOCKED(editorApi)`,不得记为通过。 2026-07-13 V1.3 真实验收:同一真实 Provider 套件已改为先读取 SHA-256,再用唯一一次 `project.patchset` 同时更新和创建文件,并使用自动 checkpointId 读取 2 项内容 hunks;prepared / completed 审计各 1 条、patchset revision 增量为 1,Runner 强杀恢复、命令和项目验证、双视口浏览器验证、隔离 Agent join、重复副作用与密钥扫描继续全部通过。 -2026-07-13 V1.6 最终真实验收:`llm-runtime` 形成 94 条 task、158 条 event、164 条 Agent DB、11 条合法工具协议、13 次成功工具执行和 24 条 terminal receipt;主 run receipt 为 18。`agent.action_history` 实际调用 1 次、返回 1 条、递归结果 0,且只在父 run 认领唯一 all-join 后执行;Runner 强杀恢复、revision 3、3 个隔离实例 / 2 个模板、双视口浏览器证据、重复项、身份冲突、半完成文件、密钥和诱饵泄漏均通过结构化检查。本次竞态走活跃父 run 直接认领路径,join continuation 数量为 0;`parent-wake` 等待路径由确定性 Rust 测试覆盖。这些数字是单次观测结果,不是脚本固定阈值;`full` 套件仍需 External Editor API,缺失时保持 `BLOCKED(editorApi)`。 +2026-07-13 V1.6 最终真实验收:`llm-runtime` 形成 94 条 task、158 条 event、164 条 Agent DB、11 条合法工具协议、13 次成功工具执行和 24 条 terminal receipt;主 run receipt 为 18。`agent.action_history` 实际调用 1 次、返回 1 条、递归结果 0,且只在父 run 认领唯一 all-join 后执行;Runner 强杀恢复、revision 3、3 个隔离实例 / 2 个模板、双视口浏览器证据、重复项、身份冲突、半完成文件、密钥和诱饵泄漏均通过结构化检查。本次竞态走活跃父 run 直接认领路径,join continuation 数量为 0;`parent-wake` 等待路径由确定性 Rust 测试覆盖。这些数字是单次观测结果,不是脚本固定阈值;`full` 套件仍需当前模式的画板服务授权,缺失时保持 `BLOCKED(editorApi)`。 -2026-07-13 V1.7 最终真实验收:`llm-runtime` 形成 95 条 task、161 条 event、166 条 Agent DB、12 条合法工具协议、14 次成功工具执行和 24 条 terminal receipt。真实 Provider 在 `preview.validate` 后实际调用 `image.inspect` 1 次并读取 desktop / mobile 两张 PNG;专用 audit 与 receipt 各 1 条、responseId 存在,视觉 observation 在最终回复前落盘。`agent.action_history` 与视觉检查彼此独立,不要求固定先后顺序。Runner 强杀恢复保持原 run / session,revision 3,3 个隔离实例、项目验证和浏览器验证通过;图片载荷、重复 action / message / receipt、密钥和诱饵泄漏均为 0。`full` 套件仍需 External Editor API,缺失时保持 `BLOCKED(editorApi)`。 +2026-07-13 V1.7 最终真实验收:`llm-runtime` 形成 95 条 task、161 条 event、166 条 Agent DB、12 条合法工具协议、14 次成功工具执行和 24 条 terminal receipt。真实 Provider 在 `preview.validate` 后实际调用 `image.inspect` 1 次并读取 desktop / mobile 两张 PNG;专用 audit 与 receipt 各 1 条、responseId 存在,视觉 observation 在最终回复前落盘。`agent.action_history` 与视觉检查彼此独立,不要求固定先后顺序。Runner 强杀恢复保持原 run / session,revision 3,3 个隔离实例、项目验证和浏览器验证通过;图片载荷、重复 action / message / receipt、密钥和诱饵泄漏均为 0。`full` 套件仍需当前模式的画板服务授权,缺失时保持 `BLOCKED(editorApi)`。 2026-07-13 V1.8 实现口径:`command.exec` 的清洗后有界输出在命令日志和 manifest 投影前写入 `.agent/runtime/command-outputs/.json`,并由 terminal receipt 只记录 outputRef、SHA-256、行数、截断和终态元数据。`command.output_read` 默认 `auto`,输入只接受源 actionId 和分页参数;Runtime 从 terminal receipt 反查唯一源 run,交叉复核 task ledger、command audit 和 sidecar 身份。读取不推进 revision 或 verification gate,模板级 per-Agent policy 对动态 `child-*` 实例继续生效;同页恢复只续 planning,不重跑源命令。 @@ -324,9 +324,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 收紧,2026-08-03 增加发布窗口兼容与 durable 生成账本,2026-08-04 对齐 External v1 幂等恢复:后台任务工具箱提供 `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 前把 endpoint、精确请求体、SHA-256 与稳定 `Idempotency-Key` 原子写入 `.agent/runtime/canvas-generation-requests/` 私有账本并回读一致;正式新契约收到 HTTP `202` 后先原子追加 `operationId`,再按限制到 `250..=5000ms` 的 `pollAfterMs` 查询统一状态端点。重启时 `accepted` 账本只恢复 GET;`prepared` 表示首次提交结果未知,只允许在账本身份、配置指纹和请求 SHA 校验通过后,使用账本保存的原 endpoint、原始 JSON 字节与同一 `Idempotency-Key` 恢复 POST,不得重建远端项目/目录、重组正文或分配新键。恢复收到 `202` 后升级同一账本为 `accepted` 并继续 GET,旧 `200` 仍走既有兼容完成路径;恢复再次 transport 失败或返回不能证明首次请求未入队的响应时保留原 `prepared` 账本。桌面客户端在滚动发布窗口内仍按 HTTP 状态兼容旧同步 `200` 完整结果;旧图集只在 `spritesheetImageSrc` 是有效下载引用时优先使用,否则回退 `objectKey`。生成 POST 使用独立三十五分钟等待预算;game-chat 的两次串行生成纳入父 run `4200` 秒软预算与 `4500` 秒总截止。截止时普通本地动作按失败清理;若 `canvas.asset_generate` 已进入 executing,则结束本轮并关闭预览和客户端,但保留 pending action、provider batch、生成账本与 `needs-reconciliation`。轮询超时只保留 `accepted + operationId` 并在恢复时继续 GET;旧 `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 或日志。 +- 后台任务工具箱提供 `canvas.asset_generate`。Agent 在 loop 中给出素材 prompt、`outputPath`、比例、尺寸、kind 与展示名;普通已认证客户端通过固定官方 origin 的 `/api/editor/*`、`/api/assets/*` 和 `/api/runtime/external-generation/jobs/*` 调用平台,独立 standalone game-chat/显式高级模式才通过 `/api/external/v1/*` 与自备 Developer Key 调用。生成前按本地项目名称创建或复用同名画布项目和同名素材库目录,请求必须携带 `projectId + assetFolderId + canvasCompletion`,生成结果同时进入平台画布、素材库和本地项目。canonical 视觉 DAG 固定为:`art-director` 用图片生成 `kind=spec` 生成 `assets/art-spec.png`;`design-foundation` 精确引用该 resourceId,用图片生成 `kind=ui-design` 生成 `assets/ui-prototype.png`;`art-asset-plan` 使用同一 resourceId 和具体 `iconDescriptions` 调用图标 spritesheet 生成,产出真实透明的 `assets/art-spritesheet.png`。Runtime 在 POST 前把调用模式、相对 endpoint、精确请求体、SHA-256 与稳定 `Idempotency-Key` 原子写入 `.agent/runtime/canvas-generation-requests/` 私有账本并回读一致;普通模式还冻结 `official-platform-v1 + 官方 origin + ownerUserId`,高级模式冻结 External service origin,两者均不保存 Token 或 Key。`accepted` 账本只恢复 GET;`prepared` 表示首次提交结果未知,只允许使用原 endpoint、原始 JSON 字节与同一键恢复 POST,不得重建远端项目/目录、重组正文或分配新键。普通模式站内响应兼容 `200 + queueState.operationId`、inline 完成和 `job` 状态包装;高级 External v1 固定 `202 + operationId`。生成 POST 使用独立三十五分钟等待预算;game-chat 的两次串行生成纳入父 run `4200` 秒软预算与 `4500` 秒总截止。轮询超时、临时鉴权或网关错误只保留原 operation 并在恢复时继续;结果损坏、状态损坏、透明派生失败或远端完成后的本地提交失败进入对账。`postprocess-failed-source-preserved` 不得登记为透明图集或自动重试;其它 general warning 保持 completed 并与 `sliceWarning` 分别展示。UI extraction 只处理已有带标注 UI 图,不属于这条 DAG;图集不得回退到普通生图。UI 原型 prompt、`generationInputs.artSpec` 和 `ui-prototype.v2` 验收必须从当前项目玩法合同提取 HUD、可玩区域、关键实体、操作、失败/重开与移动布局。旧正式图不合格时只返回 `needs-repair`;Supervisor 认领后由原 owner 使用 `replaceExisting=true` 原位替换。凭据不进入项目文件;幂等键只进入受权限约束的私有生成账本,不进入 observation、manifest、agent.db 或日志。 - 2026-08-03 durable 恢复补充,2026-08-04 扩展 `prepared`:`prepared / accepted / legacy-completed` 恢复必须先从私有账本读取持久化的画布 ID、素材目录 ID、画布名、生成提示词、route、kind 与引用资源;`prepared` 只原样恢复同一逻辑 POST,`accepted` 只查询既有 operation。不得在读取账本前重建请求、重新列举或创建远端项目/目录,也不得让本地输出路径漂移挡住恢复。显式“继续/恢复”复用原 action、pending、provider batch 与生成账本身份,不创建 successor 请求或新幂等键。恢复执行和后续 continuation 使用独立 Tokio task 栈边界,同时继续持有原 Agent lock。终态清理固定先删 generation / parallel 附属 sidecar,最后删 pending 身份锚点;历史孤儿只有所属任务已明确 completed/cancelled 时可自动清理,活动、未知或 `needs-reconciliation` orphan 必须保留并失败关闭。 -- 2026-08-03 durable 恢复加固,2026-08-04 收紧清理边界,2026-08-10 允许 Key 轮换:生成账本必须绑定归一化 base URL 的服务身份指纹,External Editor 服务地址变化时禁止恢复 POST 或查询旧 operation;Developer API Key 只负责当前请求授权,轮换不得改变已有 operation 的归属。旧同步 `200` 结果只持久恢复必需的允许字段;绝对 signed URL、query/fragment 和未知扩展字段不得进入项目账本,只有安全相对路径或 objectKey 可作为 durable 下载引用。accepted operation 明确 failed 时也保留账本,直到 pending observation 和 Provider batch 成员终态持久化后再按统一清理链删除。只有首次提交直接取得契约明确的 `400 / 401 / 403` 才可视为入队前拒绝并清理 prepared 账本;首次结果已经未知后,恢复 POST 的临时鉴权或其它非成功响应不能证明原请求未入队,必须保留原账本。生成账本根目录、扫描与删除使用受控路径解析逐级拒绝符号链接,非法控制路径失败关闭。独立恢复任务异常必须落盘 task queue、state、event 和 agent.db 对账阻断,公共记录不得复制未脱敏 panic payload。 +- 生成账本必须绑定调用模式与服务身份:普通模式为固定官方 origin + ownerUserId,高级 External v1 模式为显式 service origin;Access Token 或 Developer Key 只负责当前请求授权,轮换不得改变已有 operation 的归属。退出或换号提升单调账号 generation,中止并脱离旧请求;旧 owner 账本在新 owner 下禁止 POST、GET、下载、安装和归档,只有重新登录同一 owner 后才能恢复。绝对 signed URL、query/fragment 和未知扩展字段不得进入项目账本,只有安全相对路径或 objectKey 可作为 durable 下载引用。accepted operation 明确 failed 时也保留账本,直到 pending observation 和 Provider batch 成员终态持久化后再按统一清理链删除。首次请求结果已经未知后,鉴权或其它非成功响应不能证明原请求未入队,必须保留原账本。生成账本根目录、扫描与删除使用受控路径解析逐级拒绝符号链接,非法控制路径失败关闭。 - 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 只包含项目相对路径、类型和大小,不读取文件内容、不返回项目绝对路径。 @@ -415,8 +415,8 @@ 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` 调用生成端点。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.project_sync` 在普通模式读取当前陶泥儿账号可访问的 `/api/editor/projects/{projectId}` 画板快照,通过 `/api/assets/read-url` 换签并把资源下载到本地项目 `assets/canvas-sync/`;官方 origin 固定,Access Token 只来自当前进程内存。独立 standalone game-chat/高级模式才使用其隔离 `editorApi.baseUrl/apiKey` 调用对应 External v1 路由。凭据均不写入项目文件、trace、manifest 或日志。 +- Agent loop 中美术组 `Asset` 和音乐组 `SFX` 会读取 `.agent/manifest.json`;图片生成先通过当前调用模式的项目与素材库接口准备同名画布会话,再带稳定 `Idempotency-Key` 调用生成端点。Runtime 在私有生成账本持久化精确请求、幂等键、owner 和返回的 `operationId`,并查询统一状态端点;completed 后取得稳定 objectKey/resourceId,再通过当前模式的 `/api/assets/read-url` 或 External v1 read-url 换签下载到受控本地 `assets/` 路径。Token/Key 不写入项目文件;幂等键只作为私有可恢复身份保存。未登录、账号不匹配、查询 failed 或结果缺少稳定媒体引用时,图片产物型任务保持阻塞/失败,不能以文字计划完成。 - `canvas.asset_import` 当前作为最小真实链路:导入项目目录内已有文件为 `canvas` 来源资产,并要求记录画板项目 ID 以及 resourceId 或 assetObjectId。 - 项目工作台只在资源卡本体中渲染已登记图片与安全 SVG,并在卡内展示视频首个可解码画面;中央资源聚焦以路径、MIME、来源、Rust 权威依赖和版本信息为主,不重复挂载或放大图片、SVG、视频。媒体通过受控 Tauri 命令从项目 `assets/` / `game/` 读取,只允许 manifest 已登记资产或已完成任务产物,并复用 `file.read` auto 权限、图片魔数、文件大小、像素尺寸、普通文件、路径漂移和符号链接校验。Rust data URL 只承担一次 IPC 传输,前端立即按实际解码字节建立 Blob URL 并丢弃 base64 字符串;不向 WebView 暴露任意本机文件协议或绝对路径。 - 2026-08-03 阶段四在上述图片链路外新增 `read_local_project_text_preview` 与 `read_local_project_media_preview`。前者只接收当前 manifest 已登记文档或已完成任务中的 Markdown / 文本 / JSON / YAML / TOML,限制 2 MiB 与 UTF-8;Agent 文本回执继续直接消费合法对话投影,不反查本地路径。后者的美术分支接收 GIF、安全 SVG、AVIF、BMP、MP4、WebM、MOV,音频分支只接收 manifest 已登记的 MP3、WAV、OGG / Opus、M4A、AAC、FLAC,二进制媒体限制 32 MiB。两条命令统一执行 `file.read` auto 权限、规范化相对路径、项目边界、敏感路径、普通文件、父目录链接、硬链接、读取漂移和重开身份复核;媒体按文件签名而非只按扩展名或 MIME 建立 data URL,SVG 额外拒绝活动内容与外部引用。 @@ -671,7 +671,7 @@ game-project/ - `npm run check:native-shells`:覆盖 AI 游戏创作壳的 release/dev 边界、正式用户 App 只嵌入当前 loopback 游戏预览、用户侧预览命令不调用系统浏览器和 Tauri release `--no-bundle` 构建 smoke;该 smoke 必须实际经过最新 `frontendDist` 门禁。门禁 helper 的定向测试同时覆盖干净嵌套产物、嵌套 JS 命中旧 `/tmp` 默认值、产物外测试 fixture 不受影响和链接绕过失败关闭。上述证据用于证明正式发布只登记一个用户窗口,登录后同一 WebView 渲染首页、项目组和项目开发页,Tauri 不自动额外打开 `developer` 窗口,开发面板只在 Vite dev 的 `?dev` / `#dev` 分支渲染,旧窗口切换 command 仅保留兼容、不进入用户主流程。 - `npm run check:encoding` 与 `git diff --check`:覆盖中文文档、中文命令文案和补丁空白;用于避免乱码、尾随空白和无关格式漂移。 - `npm run ai-game-creator-shell:llm-status`:只检查 LLM 客户端配置是否就绪,不请求上游、不显示 API Key;用于本机联调前确认配置。发布版启动时会在 Tauri 应用配置目录生成默认 `game-creator.config.json`,仓库内 `apps/ai-game-creator-shell/game-creator.config.json` 只作为默认模板。 -- `npm run agc:config`:提供终端交互式配置向导,支持 OpenAI、DeepSeek、Anthropic、火山 Ark 和自定义 Provider,依次采集 Base URL、模型与隐藏输入的 API Key,并写入与 GUI“配置”面板相同的系统 AppData `game-creator.config.json`。向导更新 LLM 主配置时必须保留已有 `agentLlm`、`editorApi`、`mcpServers` 等其它配置,POSIX 下保持 AppData 目录 `0700`、配置文件 `0600`,通过同目录临时文件原子替换,保存后复用真实 `llm-status` 检查。禁止提供或接受 `--api-key`,避免密钥进入 shell history、进程参数、日志或测试报告。`agc:test:chat` 找不到有效配置时,只在 stdin / stdout 均为 TTY 的人工运行中询问是否进入该向导;非 TTY 直接失败并提示先运行 `npm run agc:config`,不得等待输入或写入默认密钥。 +- `npm run agc:config`:提供终端交互式 LLM 配置向导,支持 OpenAI、DeepSeek、Anthropic、火山 Ark 和自定义 Provider,依次采集 Base URL、模型与隐藏输入的 LLM API Key,并写入与 GUI“配置”面板相同的系统 AppData `game-creator.config.json`。向导不采集普通画板 URL/Key;更新 LLM 主配置时必须保留其它现役配置,POSIX 下保持 AppData 目录 `0700`、配置文件 `0600`,通过同目录临时文件原子替换,保存后复用真实 `llm-status` 检查。禁止提供或接受 `--api-key`,避免密钥进入 shell history、进程参数、日志或测试报告。 - `npm run ai-game-creator-shell:agent-run -- --no-wait /绝对项目路径 "游戏创作需求"`:使用真实 OpenAI-compatible 配置跑一次本地生成、落盘、自检和预览;用于人工验收真实 provider 路径。真实 provider 配置放在 Tauri 应用配置目录的 `game-creator.config.json` 中,至少设置 `llm.apiKey`,需要覆盖默认服务时设置 `llm.baseUrl`、`llm.model`;默认 API kind 为 `openai_responses`,旧 Chat Completions 兼容网关设置 `llm.apiKind` 为 `openai_chat`,Anthropic Messages 网关设置 `llm.apiKind` 为 `anthropic`,URL 会在 base URL 后拼 `/v1/messages`,例如 Minimax Anthropic base URL 可配置为 `https://api.minimaxi.com/anthropic`;真实网关长请求若在非流式响应前被 60 秒空闲连接切断,联调时设置 `llm.stream` 为 `true`。 ## 当前最小落地 @@ -713,7 +713,7 @@ game-project/ - 主窗口的 agent 状态列表以 manifest 角色任务为底表,再合并最近 run trace 中 `taskGraph.tasks` 的任务状态、同 taskId / group / role 的最新 step 状态、输入输出路径、错误摘要、lifecycleStatus 和 `activeTaskIds` / `carriedTaskIds` / `readyTaskIds` 编排标记;如果 trace 缺失或过期,只展示 manifest 的静态任务状态和“暂无最近运行证据”。 - 共享契约和 `platform-agent` 会按任务依赖与 `completed` 状态计算当前可执行任务,作为 v1 的最小编排选择器;每轮 `Orchestrator` 的 activeTaskIds、carriedTaskIds、repairRoutes 和 dependencyWaves 由 `platform-agent` 纯编排内核产出,`apps/ai-game-creator-shell` 只负责写入 `.agent/passes/pass-N/` 和执行本地工具;`Evaluator` 会在 `.agent/findings.md` 写出 `## Repair Routes` JSON,下一轮编排优先采用该结构化 taskIds,解析不到时才退回关键词路由;返工路由会按任务图自动扩展下游影响任务,例如美术资产变化会继续触发程序预览和运营包装重算。 - `game.generate_draft` 使用 OpenAI-compatible LLM 配置生成结构化 JSON 草案,发布 App 的配置项来自 Tauri 应用配置目录中的 `game-creator.config.json`:`llm.apiKey`、`llm.baseUrl`、`llm.model`、`llm.apiKind`、`llm.stream`、`llm.requestTimeoutMs`、`llm.maxRetries`、`llm.retryBackoffMs`;默认 API kind 为 `openai_responses`,可设 `llm.apiKind=openai_chat` 切回旧 Chat Completions 兼容网关,或 `llm.apiKind=anthropic` 走 Anthropic Messages;`llm.stream=true` 时 Planner 和 Generator 使用流式请求;缺少配置或模型返回非法 JSON 时直接失败,不静默回退固定模板。 -- 主窗口“配置”面板和聊天 `/config` 命令读写 Tauri 应用配置目录中的 `game-creator.config.json`,覆盖 LLM API Key、base URL、模型、API 类型、流式请求、超时、重试和画板 External API 配置;主聊天 Agent、Planner、Orchestrator、Generator、Evaluator 和 16 个角色 agent 都可在 `agentLlm` 中单独覆盖 API Key、base URL、模型、API 类型和流式请求,空项继承全局 LLM 配置;正式主聊天优先使用 `agentLlm.project-supervisor`,旧 `agentLlm.chat` 只作兼容回退。默认生成链路仍只让 Planner / Generator 调 LLM,配置了 `agentLlm.` 的角色 agent 会改用自己的 provider 生成 brief,未配置的角色 agent 继续使用本地 brief。旧生成链路或平台美术返回 LLM / editorApi 缺配置错误时可沿用运行时配置弹窗;正式 `project-supervisor` 项目页只展示错误,由单窗口壳的全局“配置”入口处理,不自动弹出开发配置框。API Key 输入框使用密码字段并关闭自动填充,数值项在 UI 层夹住下限,Rust 写配置时也拒绝过低超时,保存时只写运行时配置文件,不写仓库模板、本地项目、trace 或 manifest。 +- 普通主窗口“配置”面板和聊天 `/config` 命令只维护 LLM、Agent LLM 与 MCP 等运行时配置,不展示画板 Base URL 或 Developer API Key;正式平台美术缺少登录态时引导重新登录,不引导填写 `editorApi`。主聊天 Agent、Planner、Orchestrator、Generator、Evaluator 和 16 个角色 agent 可在 `agentLlm` 中单独覆盖 LLM 路由,空项继承全局配置。仅独立 standalone game-chat release/显式高级自定义模式的隔离设置可维护 External v1 URL/Key,且不得被普通 release 自动读取。 - 聊天输入 `/llm-status` 会触发只读 `llm.config_check`,确认全局 LLM 以及各 agent resolved 后的 base_url、model、API 类型和 API Key 是否已从客户端配置读取;状态消息不会显示或保存 API Key;当前生成链路只要求 Planner / Generator 就绪。`/llm-routes` 复用同一检查结果,但输出按 agent 展开的路由清单和缺口摘要,用于确认哪些 agent 解析后走全局路由、哪些 agent 走单独 provider。 - `game.generate_draft` 的 LLM JSON 必须包含 `handoffs` 数组,覆盖 `design`、`balance`、`art`、`audio`、`code`、`publishing` 6 个专业组;每组必须给出 role、summary、outputs 和 next,缺组或交接内容不完整会判定为模型输出无效并进入返工。 - `game.generate_draft` 的真实生成路径使用最小 Planner / Orchestrator / 组内角色 agent / Generator / Evaluator loop:Planner 写 `.agent/spec.md`;每轮 Orchestrator 先写 `.agent/passes/pass-N/agenda.md` 和 `.agent/passes/pass-N/task-graph.json`,首轮全量调度 16 个角色任务,返工轮按 `.agent/findings.md` 生成结构化 `repairRoutes`,重跑命中问题的角色任务及其下游依赖任务,其余角色 brief 从上一轮 carry-over;`task-graph.json` 记录 activeTaskIds、carriedTaskIds、repairFocus、repairRoutes 和按依赖排序的 dependencyWaves;角色 brief 执行层按 dependencyWaves 调度,wave 之间串行、同一 wave 内多个角色 Agent 以 async task 并行运行;每个角色 brief 必须读取自己的私有记忆 `memory/agents//.md`、本 Agent 对话和项目黑板 `memory/blackboard.md`,写入 `.agent/passes/pass-N/groups//*.md`,再按原组顺序汇总为 `.agent/passes/pass-N/groups/*.md`;Generator 必须读取用户需求、记忆、`.agent/spec.md`、本轮 `agenda.md`、`task-graph.json`、`.agent/findings.md` 和 6 组汇总 brief 后返回结构化 JSON;每轮会把 Generator 草案拆成 6 组交接快照,写入 `.agent/passes/pass-N/`;Evaluator 做质量评审并写 `.agent/findings.md`,通过后才进入 `game.static_smoke` 静态自检和预览试玩。 @@ -828,8 +828,8 @@ game-project/ - 原生文件上传会写入 `assets/uploads/`,并追加登记到 `.agent/manifest.json`。 - 开发模式可把项目内已有文件登记为资产;`asset.register` 支持 `uploaded`、`generated`、`canvas` 三种来源,并可记录画板 project/resource/object 元数据。 - 开发模式可执行 `canvas.project_open`,打开本机 Genarrative 编辑器里的指定画板项目。 -- 开发模式可执行 `canvas.project_sync`,同步平台画板项目中的已有资源到本地项目资产目录;该命令要求 External API Key 具备 `editor:project` 与 `editor:asset` 相关 scope。 -- 开发模式可执行 `canvas.asset_import`,将项目内已有文件按画板来源导入 manifest;也可执行 `canvas.export_import`,把现有画板素材导出 ZIP 回流为本地项目资产。`game.generate_draft` 在配置 `editorApi.apiKey` 时会自动复用 External Editor API 的图片生成接口生成首版美术素材,不新增平行资产模型。 +- 开发模式可执行 `canvas.project_sync`,同步当前陶泥儿账号可访问的画板项目资源;普通已认证构建使用网站 Access Token,高级 External v1 模式才要求 Developer Key 的 `editor:project` 与 `editor:asset` scope。 +- 开发模式可执行 `canvas.asset_import`,将项目内已有文件按画板来源导入 manifest;也可执行 `canvas.export_import`,把现有画板素材导出 ZIP 回流为本地项目资产。`game.generate_draft` 在普通模式登录态有效或高级模式凭据有效时复用同一平台图片生成能力,不新增平行资产模型。 - 普通模式已在登录后的单窗口项目开发页渲染 GameAgent 工作台、`project-supervisor` 主聊天和专业 Agent 协作只读状态,不提供专业 Agent / child 单 Agent 对话;首页首条需求直接投递 active Supervisor Session,全新项目没有该 Session 时先通过既有 Session 命令创建并设为 active,已有项目恢复该 Session 与历史后继续交互。Tauri 主窗口承载当前授权项目的 loopback 游戏画面,但不承载开发 Agent picker、调度工具台或任意地址预览调试面板。 - 聊天生成草案后会尝试启动只读 `127.0.0.1:` 静态 HTTP server,把预览地址回写客户端项目运行工作台并切换到运行视图;不再调用系统外部浏览器。 - 开发模式仅在 Vite dev 环境响应 `?dev` 或 `#dev`;`npm run agc` 与 release 都只打开 `client` 用户窗口,显式 `?agent-chat` 与 `supervisor-chat` 调试入口不随启动自动弹出。正式构建忽略 dev 参数,release 配置只登记一个用户窗口,登录后同窗口进入首页并在项目组 / 项目开发页之间切换。 @@ -857,7 +857,7 @@ game-project/ - 2026-07-16 起,同一 Runtime 文档的“V1.26 Provider 原生工具目录”作为 OpenAI-compatible planning 协议事实源。Chat / Responses 不再只广告 `submit_agent_tool_plan` 包装函数,而是直接提供 `update_agent_plan`、`respond_to_user`、全部内置 Runtime action 和动态 MCP function;每个函数使用独立 schema,Runtime 继续负责身份、权限、确认、沙箱、revision、验证、恢复与副作用防重放。2026-07-27 起 Anthropic 也走同一套原生工具目录,text JSON / wrapper 解析只作为历史响应与确定性 fixture 的兼容入口,不再是任何 Provider 的正常请求路径;新请求和 repair 不能静默降级。plan-only 是合法持久 checkpoint,未完成计划仍阻止最终化。 - 2026-07-16 V1.26 已完成真实验收:正式 `openai_chat / gpt-5.5` 的 `project-skill` suite 中 9/9 个成功工具计划与 6/6 个格式修复全部使用 `native_runtime_tools`,wrapper/text fallback 均为 0。Agent 自主读取匹配 Skill、只改唯一目标文件并完成 Agent/宿主双重验证;15 个 tool-plan 和 1 个 final-reply lifecycle 唯一闭合,最终 assistant/completed 各 1,重复、Skill 正文、API Key、诱饵、项目/配置路径和报告泄漏均为 0,隔离 Runner/AppData/项目完整清理。 - 2026-07-16 V1.26 后重新加强并复验 `goal-runtime`:Goal suite 现在把成功计划、repair、call metadata、wrapper/text fallback 和协议审计零 payload 纳入硬门禁。正式 `openai_chat / gpt-5.5` 最终复跑的成功计划 21/21、repair 17/17 全为 `native_runtime_tools`;Goal edit、旧动作失效、真实失败修复、pause、Runner 强杀、显式同 run resume、verification、finalization 和唯一回复全部 PASS,重复、重放、正文、密钥、诱饵与路径泄漏均为 0。Goal 阶段等待同时增加 terminal fail-fast,Provider transport failure 不再占满 30 分钟验收超时。 -- 2026-07-24 补充:`agc:test:chat / autonomous-game-build` 不再只因 `game/index.html` 可试玩就跳过已配置的画布能力。有效运行时配置存在 `editorApi.apiKey` 且项目尚无规范 `canvas / image/* / art-spritesheet` 本地素材时,缺省 Supervisor 首批协作固定加入 `art-asset-plan`,并要求真实交付 `assets/art-spritesheet.png`;该 Agent 继续通过 `canvas.asset_generate` 把结果同时写入 External Editor 画布、素材库、本地 assets 与 manifest。已有有效首版素材的后续修复轮不重复生成或扣费。该工具仅在自主构建 profile 的 `design-foundation / art-asset-plan` 视觉职责中作为固定 auto-safe 动作,Supervisor、程序和其他非视觉 Agent 一律拒绝,避免同一路径并发生成和重复扣费;普通模式和显式 deny 不变,Key 缺失时不伪造图片产物。 +- `agc:test:chat / autonomous-game-build` 不再只因 `game/index.html` 可试玩就跳过可用的画布能力。普通模式当前平台会话有效,或 standalone game-chat/高级模式 Developer Key 有效,且项目尚无规范 `canvas / image/* / art-spritesheet` 本地素材时,缺省 Supervisor 首批协作固定加入 `art-asset-plan`,并要求真实交付 `assets/art-spritesheet.png`;已有有效首版素材的后续修复轮不重复生成或扣费。该工具仅在自主构建 profile 的视觉职责中作为固定 auto-safe 动作;未登录或高级凭据缺失时不伪造图片产物。 - 同一轮程序与美术并行时,External Editor 网络请求和图片下载不能持有 `.agent/project.lock`,请求阶段也不能借 `read_manifest_for_project` 补 seed task 或重写 manifest;只允许读取已初始化 manifest 的只读快照,下载完成后先校验声明图片类型的文件魔数,再取得项目锁提交固定路径、资产登记、revision 和验证凭证。视觉专业 Agent 按同一 run 的 `verifiedRevision >= mutationRevision` 完成交付,不受其它专业 Agent 后续推进全局 revision 的影响,也允许在更晚 revision 复验;Supervisor 的静态自检和浏览器试玩仍必须精确覆盖最新全局 revision。已有自定义项目协作策略只补缺失的 `art-asset-plan`,不额外注入缺省程序/质量职责;规范素材复用额外要求本地文件具备有效 PNG 签名,损坏占位不能阻止重新生成。 - 2026-07-16 起,V1.27 只允许同一 Agent 把连续 2-3 个自动批准的严格只读 action 组成持久并行批次。首版白名单为 `memory.read / conversation.read / asset.list / project.search / project.diff / git.inspect / file.list / file.read / task.list`;写入、确认、命令/进程、验证、生成、MCP、Git commit、委派和回执认领仍按 Provider 顺序串行。批次用项目一致性锁形成 steer/Goal/取消的线性化边界,物理读取由独立工作线程重叠执行,observation/task/event/receipt/context 仍按 Provider action index 稳定投影;`executing` Runner 恢复只重放严格只读观察,`observed` 只补投影。 - V1.27 的 8 项确定性回归已证明:2 个读取真实非空重叠;第二个物理读取先结束时仍按 Provider 顺序投影;3 个动作 `executing` 恢复保持原 actionId;`observed` sidecar、部分公共投影、部分 context 和删除 sidecar 前故障都从持久前缀继续且零重复;确认策略阻止自动批次;取消、Goal/repository drift 阻止旧读取重放;steer 先获得锁时旧批次零执行,批次先获得锁时 steer 等到全部读取 observed 后才接受。正式 `openai_chat / gpt-5.5` 的隔离 `parallel-read` suite 同样 PASS:真实模型同轮提交 2 个独立 `project.search`,物理重叠 `10,969,247ns`,4/4 个成功工具计划与 2/2 个 repair 全为 `native_runtime_tools`,6 个 tool-plan 与 1 个 final-reply lifecycle 唯一闭合,最终 assistant/completed 各 1;重复、正文/API Key/诱饵/项目与配置路径泄漏均为 0,Runner/AppData/项目完整清理。V1.27 当前真实行为门禁为 PASS。 @@ -927,11 +927,11 @@ game-project/ - 2026-07-25 补充:Swarm CLI 将“Agent 执行通道仍忙”和“canonical run 可接受 steer”拆为两个判定。`task_queue.pending > 0` 继续用于 Runner 存活观察,但只有底层 steer 门禁认可的 `running / waiting-for-confirmation` Runtime 才能接收新指令;`completed/cancelled run A + pending run B` 必须先通过 Runner 恢复 B,禁止把消息追加到 A。start mutation 必须返回实际 `acceptedRunId`,CLI 以它建立 turn baseline,失败扫描、交互、收束和 `turn.report` 都只认 baseline run;若 Runner 已连续推进到 C,则从 task journal 读取 B 的终态。旧 A 的 cancel tombstone 不得阻断队首 B 恢复。若用户重复输入的内容正是已经落盘的 pending 任务且它仍是对话最后一条 user 消息,CLI 只观察原 run,不再追加第二份对话或创建新任务;active Goal 同样必须同时匹配 Agent、Session、runId、Run Profile 和可 steer 状态。连续 B/C 的 assistant 回复按 finalization message ID 归属,observer 不输出非目标 run 的状态或流;历史失败和报告计数都读取完整 task journal,失败聚合使用 `(agentId, runId)`,journal 失败终态不能被滞后的 state 投影覆盖。 - 2026-07-25 已落地:`autonomous-game-build` 的完成合同从“交付可玩原型”升级为“交付正式项目产物”。Runtime 继续按 seed manifest 的固定 16 个 task 分波推进;配置画布 Key 时,视觉链先由现有 `art-director` 生成正式规范图,再让 `design-foundation / art-asset-plan` 分别引用该规范图生成 UI 原型与透明图集,数值和音频需求仍按依赖并行,随后由程序整合、质量审查、当前 revision 静态检查、真实试玩和发布包装收束。每个新根 run 先重置本轮 seed task;普通 preview / smoke bookkeeping 不得代替自主 task 的真实执行和终态投影。 - DAG 只能在 Project Supervisor 的 `agent.run_status` 回执认领已可靠观察、静态委派屏障清空后启动;正常执行、pending 续跑和重启恢复复用同一入口。调度在项目锁内预占 ready child 的 Agent Runtime lane 并落 durable journal,释放项目锁后才启动 drain;Runtime 的项目写锁统一提供约 10 秒有界等待,覆盖 Provider 首轮 tool-plan、并行只读结果投影和其它同 run 控制面写入,不能因 manifest 波次的短暂锁竞争把专业 child 投影为 failed。专业 child 终态只按父 run 身份、活跃合同和自身完成门禁投影,不等待独立静态委派屏障;该屏障仍约束下一波调度与父 run 收束。专业子 run 的验证允许 `verifiedRevision >= mutationRevision`,但 Supervisor 的最终静态和试玩证据仍必须精确覆盖最新全局 revision。 -- 正式项目基础产物固定为 `memory/project.md`、`game/game_design.md`、`game/balance.json`、`assets/manifest.art.json`、`assets/manifest.audio.json`、`game/index.html` 和 `exports/README.md`。未配置画布 API Key 时,设计与美术 Agent 交付明确记录界面结构、素材需求和“尚未生成”状态的文本 / JSON,不暴露 `canvas.asset_generate`,也不得伪造图片;配置 Key 时额外强制生成、登记并验收 `assets/art-spec.png`、`assets/ui-prototype.png` 与 `assets/art-spritesheet.png`,生成失败不得完成。`assets/manifest.audio.json` 当前只表示 BGM 与关键音效需求,不得宣称已生成真实音频文件。 -- 2026-07-28 画布配置最终一致性补充:未配置 External Editor API Key 时,`design-foundation / art-asset-plan` 的委派 `expectedArtifacts` 与对应 manifest task 终态投影必须同步采用上述文本产物降级,不得继续强制 `assets/ui-prototype.png / assets/art-spritesheet.png`;配置 Key 时三张固定图片仍为严格完成条件。委派合同、完成合同与 manifest 投影必须以同一配置事实派生。 +- 正式项目基础产物固定为 `memory/project.md`、`game/game_design.md`、`game/balance.json`、`assets/manifest.art.json`、`assets/manifest.audio.json`、`game/index.html` 和 `exports/README.md`。普通模式未登录,或 standalone game-chat/高级模式未配置有效 Developer Key 时,设计与美术 Agent 交付明确记录界面结构、素材需求和“尚未生成”状态的文本 / JSON,不暴露 `canvas.asset_generate`,也不得伪造图片;凭据可用时额外强制生成、登记并验收 `assets/art-spec.png`、`assets/ui-prototype.png` 与 `assets/art-spritesheet.png`,生成失败不得完成。 +- 画布能力最终一致性:凭据不可用时,`design-foundation / art-asset-plan` 的委派 `expectedArtifacts` 与对应 manifest task 终态投影必须同步采用文本产物降级;凭据可用时三张固定图片仍为严格完成条件。委派合同、完成合同与 manifest 投影必须从同一“普通平台会话或高级 External 凭据”事实派生。 - Project Supervisor 只有在本轮必需 manifest tasks 全部 `completed`、当前配置对应的正式路径齐全且通过类型 / 可解析性检查、最新 project revision 的 `game.static_smoke` 与 `preview.validate` 都通过后,才能写入唯一最终回复。delivery 的 `completed / evidence-ready`、历史 revision 成功或单个文件存在都不能替代最终集成验收。已 `ready / claimed-by-parent` 的相同终态 delivery 在恢复扫描中按幂等重放,保留首次冻结结果,不再制造重复 `agent.delegate.result_failed`;真实终态冲突仍失败关闭。 - 验证:`npm run agc:test` 已通过确定性 loopback Provider、真实 Runtime、项目写入和浏览器链路验收:同一父 Run 下 16 个 manifest task 均只有一个 logical run、一次 start、一次 completed 和一次 manifest projection,且无 failed / cancelled;父 run 与全部子 run 完成,最终 revision 为 `11`,基础正式产物、静态 smoke、桌面 / 移动 `37/37` 试玩通过,pending、reconciliation、Provider 失败、重复和泄漏计数均为 `0`。该结果不替代独立外部 Provider 验收。 -- 2026-07-26 本轮已验证 `npm run agc:config` 的终端配置链路。向导与 GUI 使用同一 Tauri identifier 对应的系统 AppData 和同名 `game-creator.config.json` / 可选 local overlay;读取已有配置时只更新有效 LLM 层,保留 `agentLlm`、`editorApi`、`mcpServers` 等其它配置。API Key 只从隐藏输入读取,拒绝 `--api-key`、仓库内目录、Git 已跟踪配置、符号链接,以及不是以 `world.genarrative.ai-game-creator` 为独立叶目录的 `--config-dir`,防止把任意父目录整体改成私有权限。保存使用同目录 `0600` 临时文件原子替换,POSIX AppData 目录保持 `0700`,Windows 使用当前用户独占 DACL,写后复用真实 `--llm-status` 检查;隐藏输入收到 `SIGINT / SIGTERM / SIGHUP` 时先恢复 raw mode 和 pause 状态再重发原信号,向导启动的 Cargo / npm 使用独立进程组并在信号路径有界收束整棵子进程树。 +- 2026-07-26 本轮已验证 `npm run agc:config` 的终端 LLM 配置链路。向导与 GUI 使用同一 Tauri identifier 对应的系统 AppData 和同名 `game-creator.config.json` / 可选 local overlay;读取已有配置时只更新有效 LLM 层,保留其它现役配置,不提供普通画板凭据输入。LLM API Key 只从隐藏输入读取,拒绝 `--api-key`、仓库内目录、Git 已跟踪配置、符号链接,以及不是以 `world.genarrative.ai-game-creator` 为独立叶目录的 `--config-dir`。保存使用同目录 `0600` 临时文件原子替换,POSIX AppData 目录保持 `0700`,Windows 使用当前用户独占 DACL,写后复用真实 `--llm-status` 检查。 - 2026-07-27 Windows DACL 启动回归修正,2026-08-03 补充 pwsh 模块隔离:`powershell.exe -Command` 后追加的位置参数会被 PowerShell 5.1 拼接进命令文本,不能用 `$args` 安全接收包含空格的 AppData / 临时目录。DACL 脚本改为从仅传给该子进程的环境变量读取目标绝对路径和目录标记,并复用 `Get-Item` 返回的 `FileSystemInfo.GetAccessControl()` 读取 ACL,不调用会因父 PowerShell 7 `PSModulePath` 污染而自动加载不兼容模块的 `Get-Acl`;`npm run agc:typecheck` 必须在真实 Windows 上执行配置回归,保证 `npm run agc` 的 `beforeDevCommand` 不因路径解析或模块加载失败退出。 - 2026-07-31 macOS 临时路径回归修正:配置目的地安全检查返回解析过现存父目录的真实路径,回归 fixture 的期望值也必须先使用平台原生 `realpath` 规范化临时根目录。macOS 下 `/var/folders/...` 与 `/private/var/folders/...` 是同一目录身份,不得用未规范化字符串阻断 `agc:typecheck`。CI 还必须使用“真实目录 + 符号链接父目录 + 不存在叶目录”确定性复现该语义;Windows 使用 junction 覆盖驱动器号、大小写与链接路径差异。fixture 的规范化与断言必须位于同一 `try/finally` 清理边界内。 - 2026-07-27 项目总控右栏空态与持久状态水合修正:项目尚未产生 Runtime 时仍显示“尚未开始”状态块和创作入口,不把消息列表的弹性剩余空间裸露为空白;若 active Session 索引缺失但项目内已有 `project-supervisor` Runtime,工作台必须从 `read_game_creator_agent_runtimes` 的权威项目列表恢复总控 Session 与状态。`needs-reconciliation` 统一显示为“失败 / 待核对”,不能因对话索引缺失隐藏已落盘的失败事实。 @@ -942,10 +942,10 @@ game-project/ - 2026-07-28 Windows Provider retry 恢复修正:`provider_retry::list_at` 从绝对路径剥离项目 root 后,按路径组件重组成 `/` 分隔的 portable UTF-8 相对路径,再交给 Runtime JSON sidecar 读取器。不能直接使用 Windows `Path::to_str()` 的反斜杠文本,否则应用重启、Runner recovery scan 和正式 `--agent-resume` 都无法推进已到期的 `waiting-for-provider-retry` run。全部 provider retry 列举、previous 恢复、去重和路径冲突回归必须在真实 Windows 通过。 - 2026-08-12 Windows Codex 启动链修正:AGC 不再直接依赖可能命中 WindowsApps shim 的 `codex` 命令,而是逐个执行 `--version` 验证候选,优先发现 npm 安装中的原生 `codex.exe`,并回退到 Codex Desktop 的原生 CLI;app-server 启动参数只关闭当前 CLI 仍支持的 feature flag。可信根 Project Supervisor 尚未冻结 Goal Contract 时,首轮和协议修复轮都只广告 `agent.goal_contract`,禁止计划更新、回复或其它动作抢跑。GUI 显式传入的 `--config-dir` 必须贯穿 Tauri 与 Cargo 的参数分隔并作为应用参数保留,setup 优先复用该目录,避免开发版或发布版误占默认 AppData 的 GUI owner lock。发布验收必须使用 release/安装目录 EXE 启动真实 Runner,并核对 CLI 版本、app-server 生命周期、Goal Contract 持久化、后续项目观察以及最终 completed/idle 状态,不能只以构建成功或 mock 测试代替。 - `npm run agc:test:chat` 未显式指定配置且找不到 AppData 配置时,只在 stdin / stdout 都是 TTY 时询问并启动同一 `agc:config --configure-only` 向导,非 TTY 或显式无效 `--config-dir` 直接失败。测试环境只把主配置和存在时的 local overlay 复制到带随机 sentinel 的单次隔离 AppData;副本必须是独立的无符号链接普通文件,POSIX 权限为目录 `0700` / 文件 `0600`,不复制正式 Runner endpoint、lock 或其它 AppData。自动任务默认 50 分钟且可用 `--timeout-minutes` 显式设置;超时或信号会终止独立子进程树,POSIX 先向进程组发送 `SIGTERM`、等待 10 秒后发送 `SIGKILL` 并再等待 5 秒,Windows 使用 `taskkill /T` 并在强制阶段追加 `/F`。超时和信号分别以 `124 / 130 / 143` 失败退出,隔离 Runner 收束另有 20 秒上限;Runner 未空闲或收束失败时保留隔离配置和项目,验收未完成但 Runner 已安全退出时只保留一次性项目证据,不把中断报告为成功,也不误删正式 AppData。 -- 自动验收现在严格要求 manifest 恰好包含固定 16 个不重复 task ID 且全部为 `completed`,并逐任务核对当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物存在并满足文件 / JSON / 非占位入口检查,配置画布 API Key 时再增加 `art-spec / ui-prototype / art-spritesheet` 三张图片。PNG 验收不止检查 magic / IHDR / 比例,还会校验 chunk CRC、zlib 解压、scanline 长度、索引色 PLTE 和未知 critical chunk。Runtime 根 Supervisor 的完成合同已升级为 `game-creator-autonomous-completion-contract.v2`,`baselineArtifacts` 必填并纳入指纹,旧 v1 或缺基线合同失败关闭;最终门禁要求最后一次验证工具是 `game.static_smoke`、状态通过且 `verifiedRevision == currentRevision`。`preview.validate` 回执必须绑定同一 Agent、run、current revision、当前 `game/index.html` 摘要、固定试玩场景、持久浏览器报告以及 desktop / mobile 两张截图的路径、摘要和 PNG 身份,任一证据缺失、变化、过期或来自其它 run / revision 都阻止最终回复。旧两图合同的确定性证据不替代新三图 DAG 验收;新合同实现后必须新起独立单轮。 -- `design-foundation` 已增加专属职责边界:项目文件只允许写 `memory/project.md` 与 `game/game_design.md`;配置 External Editor API Key 且合同要求界面原型时,只额外允许固定 `assets/ui-prototype.png`。它不得创建、修改、删除或补丁 `game/index.html`,不得改动其它程序实现、发布、音频或美术素材,也不得调用 `preview.start`、`preview.validate`、`game.static_smoke`,或借 `command.exec / command.start / command.run_limited` 启动预览服务、浏览器、Playwright 和桌面 / 移动试玩。程序和质量 Agent 的共享 Runtime 工具合同不因此缩减;有 / 无画布配置和其它 Agent 不受影响的聚焦回归为 `3/3` 通过。 +- 自动验收现在严格要求 manifest 恰好包含固定 16 个不重复 task ID 且全部为 `completed`,并逐任务核对当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物存在并满足文件 / JSON / 非占位入口检查,当前模式具备画板服务授权时再增加 `art-spec / ui-prototype / art-spritesheet` 三张图片。PNG 验收不止检查 magic / IHDR / 比例,还会校验 chunk CRC、zlib 解压、scanline 长度、索引色 PLTE 和未知 critical chunk。Runtime 根 Supervisor 的完成合同已升级为 `game-creator-autonomous-completion-contract.v2`,`baselineArtifacts` 必填并纳入指纹。 +- `design-foundation` 已增加专属职责边界:项目文件只允许写 `memory/project.md` 与 `game/game_design.md`;当前模式具备画板服务授权且合同要求界面原型时,只额外允许固定 `assets/ui-prototype.png`。它不得创建、修改、删除或补丁 `game/index.html`,不得改动其它程序实现、发布、音频或美术素材,也不得调用预览或试玩工具。 - `canvas.asset_generate.replaceExisting` 默认并必须保持 `false`;只有静态专业 Agent 的 `delegated-*` 唯一 repair run 才能申请 `true`。Runtime 要求当前 delivery 带 `repairOfDelegationId`,原 delivery 已被同一父 Agent / 父 run 认领,原始与返工合同的目标 Agent 和精确 `expectedArtifacts` 路径一致;普通 run、未声明路径、错误 Agent、未认领原交付或缺失原图都失败关闭。图片生成仍服从 `art-director` / `design-foundation` / `art-asset-plan` 的固定输出路径、比例、尺寸、kind 和 label,禁止先删除正式图片;请求前记录旧文件 SHA-256,外部生成返回后在项目写锁内复核,旧图在网络请求期间变化即拒绝覆盖。授权替换先写私有临时文件,再以备份 / rename 切换;落盘或 manifest 登记失败时恢复旧图,不把新旧文件并存状态当作成功。 -- 2026-07-28 起,在既有 16-task manifest 内固定正式视觉 DAG,不新增平行任务系统:`art-director` 先通过 `/api/external/v1/editor/images/generations` 的 `kind=spec` 生成 `assets/art-spec.png`,并登记为 `assetKind=icon-spec`;`design-foundation` 使用该规范图的 External Editor 稳定资源 ID 作为 `referenceImageSrcs` 中的视觉规范参考,再通过同一 images 接口的 `kind=ui-design` 生成完整 `assets/ui-prototype.png`;`art-asset-plan` 以同一 `assets/art-spec.png` 资源 ID 作为必填 `referenceId` 调用 `/api/external/v1/editor/icon-spritesheets/generations`,传入具体 `iconDescriptions`、`screenColor=auto`、同名画布 / 素材库与 `canvasCompletion`,生成透明 `assets/art-spritesheet.png`。`generationInputs.artSpec` 只是辅助结构化上下文,不能代替真实 `art-spec.png`;严禁把 `assets/ui-prototype.png` 当作图集规范图。规范图缺失、未登记为当前画布的 `icon-spec` 或缺少稳定资源 ID 时,两个下游任务均等待 `art-director`,不得退回普通生图。UI extraction 只适用于已有且带红框标注的 UI 设计图,不用于生成完整 UI,也不进入本次 canonical DAG。单波最多 `3` 个静态职责的资源上限保持不变,只调整现有任务的依赖边与就绪顺序。图集返回 `warning` 时以 `postprocess-failed-source-preserved` 源图保留语义失败关闭,不把不透明源图登记为正式 spritesheet,也不自动重试;仅有 `sliceWarning` 时完整透明图集仍可登记,但必须原样保留切片失败原因。客户端下载后还要解码 PNG 并确认至少存在一个 alpha 小于 255 的像素,未形成真实透明像素时拒绝落盘和 manifest 登记。 +- 在既有 16-task manifest 内固定正式视觉 DAG,不新增平行任务系统:`art-director` 用当前调用模式的图片生成 `kind=spec` 生成 `assets/art-spec.png` 并登记为 `assetKind=icon-spec`;`design-foundation` 使用该规范图的稳定资源 ID 作为视觉规范参考,用同模式图片生成 `kind=ui-design` 生成 `assets/ui-prototype.png`;`art-asset-plan` 以同一 resource ID 调用同模式图标 spritesheet 生成,产出透明 `assets/art-spritesheet.png`。普通模式使用内部 `/api/editor/*`,standalone/高级模式使用对应 `/api/external/v1/*`;业务请求、依赖和验收完全一致。规范图缺失、未登记或缺少稳定资源 ID 时,下游任务不得退回普通生图。图集 warning、透明像素与切片门禁保持不变。 - 旧项目已有同路径派生图但缺少上述 provenance 时,一律标记为 legacy,不得只因文件、kind 或通用视觉检查存在就完成。原位替换仍走显式 repair:`design-foundation` 与 `art-asset-plan` 先在同一 Supervisor 批次分别建立 owner 精确原合同并交付 `needs-repair`,父 run 认领后再在同一批次分别发起各自唯一 repair;两个 repair 合称一个显式视觉返工阶段。`art-director` 不得跨 owner 声明或替换 UI / spritesheet,Runtime 在委派落盘前就拒绝这类合同,不再等到生图阶段才失败。 - 2026-07-27 新起的“16 任务正式产物 + 两张真实画布图片 + current revision 静态 / 双视口浏览器 / PNG 证据 + 受限 repair 替换”独立外部 Provider 验收,使用 `npm run agc:test:chat -- --timeout-minutes 75`,约 `59m50s` 后以退出码 `0` 完整 **PASS**。同一轮真实生成并登记 `assets/ui-prototype.png`(`2829418` bytes)与 `assets/art-spritesheet.png`(`1361906` bytes),固定 `16` 个 manifest task 均为当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物、两张 PNG、当前 revision 的 `game.static_smoke`、desktop / mobile `lane-defense-v1` playtest、浏览器报告与截图全部通过。`turn.report=settled` 且唯一 assistant,busy / pending / running / confirmation / user-input / reconciliation 均为 `0`;隔离 Runner、一次性项目和隔离 AppData 已自动清理。此前失败轮继续独立保留,不与本轮拼接;未来合同变化仍须新起完整轮次复验。 - 2026-07-27 补充 tool-plan 成功响应交接的内容边界:Provider 的自然语言计划叙述,以及结构化 arguments 中 `body / code / content / css / html / newText / oldText / patch / script / text` 等源码内容字段,只检查真实密钥 token 形状、凭据头标记和不安全控制字符;仅仅提及 `.env` 或 `game-creator.config` 不能阻断已经计费的安全响应。结构化输入中的敏感 JSON key、非内容字段中的配置痕迹或绝对路径、真实 token、容量、thinking、身份、顺序和账本完整性门禁仍失败关闭。成功 handoff 失败进入 reconciliation 时,Runtime 额外只持久化受控 `failureKind`、脱敏错误 SHA-256 和字符数,不保存 Provider 正文、function arguments、密钥或绝对路径。定向回归覆盖叙述/源码字段放行、`.env.local` 路径和真实 token 拒绝、全部 tool-plan handoff 回归及诊断零正文。 @@ -1009,7 +1009,7 @@ game-project/ ## 2026-08-05 客户端素材创作无限画布阶段一合同 - 资源总览画布与素材创作无限画布是项目工作台中央主视窗的两个状态。资源总览继续消费 manifest、dependency/type 布局与 Rust 依赖图;素材画布以 `sessionId + draftId + create/refine` 建立独立生命周期。“新增资源”和“精修资源”只切换中央区域,右侧 Supervisor 与底部 Agent 状态栏常驻。 -- 网站与 Tauri 共用 `packages/image-canvas-core` 和 `packages/image-canvas-react`;网站保留账户、钱包、服务端项目和云端素材库 adapter,Tauri 保留本地项目、受控文件、manifest、项目 revision 和 External Editor API adapter。现役网站画布是抽取来源,不允许整体复制到客户端。 +- 网站与 Tauri 共用 `packages/image-canvas-core` 和 `packages/image-canvas-react`;网站保留账户、钱包、服务端项目和云端素材库 adapter,Tauri 保留本地项目、受控文件、manifest、项目 revision,以及普通登录态内部编辑器/高级 External v1 双模式 adapter。现役网站画布是抽取来源,不允许整体复制到客户端。 - 本地草稿固定保存到 `.agent/workbench/asset-canvas/`,schema 为 `game-creator-asset-canvas-draft.v1`,以 `expectedProjectId + expectedDraftRevision` 做 OS 锁内 CAS;单 JSON `2 MiB`、最多 `4096` 层,媒体正文只进入受控 media/staging 文件。损坏、未知 schema、身份错配、超限和恢复摘要不匹配全部失败关闭。 - 正式资产命令固定为 `commit_local_project_asset`,同时绑定 `expectedProjectId`、项目 `expectedRevision`、`expectedDraftRevision`、`commitId`、`idempotencyKey` 和 staging 摘要。事务在项目锁内按 prepared、最终图片、manifest/revision 逻辑原子更新、回读、ledger/草稿提交推进,释放锁后最后发布 `game-creator-local-asset-committed`;事件至少一次并按固定 eventId 去重。 - refine 不覆盖源文件或复用源 asset ID。源缺少外部 resourceId 时补齐 `local-asset:`,新 `canvas-` 资产用 `referenceResourceIds` 登记源和其它直接引用,避免混淆 manifest asset ID 与 External Editor resource ID。 @@ -1049,8 +1049,9 @@ game-project/ - 阶段五只接图片生成与图片精修,通过阶段二共享 `ImageCanvasGenerationPort` 调用客户端 Tauri adapter,并复用现有编辑器项目、统一生成队列、资产上传/换签、稳定引用解析和受控下载能力;不复制网站 `useImageCanvasGenerationWorkflow`,不新增角色动画、视频、音频、图集或平行生成系统。 - 生成面板和确认摘要只操作本地状态;用户点击独立“确认生成”后才通过 `canvas.asset_generate` confirm 权限边界。本地生成意图建立时即分配稳定 `intentId/generationId/idempotencyKey`,取消确认则直接丢弃且零 External 调用;一旦提交,重复点击、重启和未知结果保持原身份,不能自动确认或换键重扣。 -- 主站网页画布继续使用当前平台登录态调用 `/api/editor/*`、`/api/assets/*` 与 `/api/runtime/external-generation/jobs/*`。AI 游戏创作 Tauri 客户端的图片、图片引用、视频、音效和背景音乐远端编辑固定调用 `/api/external/v1/*`,凭据只读取发布 AppData 私有 `editorApi.baseUrl/apiKey`;不实现登录后自动签发 Developer API Key,不把站内 Access Token 传给 Tauri 生成命令,也不把开发者 Key 打包进客户端或传入 WebView。两条入口继续由后端解析 owner、沿统一生成队列预扣/退款泥点并只在服务端读取供应商配置。 -- 调用模式、精确相对 endpoint、精确请求、prompt、idempotency key、operationId 和稳定产物引用只进入 `.agent/workbench/asset-canvas/generations/` 私有账本。本地参考图 direct-upload ticket 的 host/formFields/policy/signature/临时 Authorization 只存在内存;恢复账本只允许保存稳定 bucket/objectKey。API Key、Authorization、Provider URL、签名 URL和绝对路径不进入账本或项目事实。当前账本用 `service-origin-v1` 绑定规范化 External base URL 的服务指纹,不把 Key 当作 operation 身份;确认面板只展示去除路径与凭据的服务 origin。升级前 Key-bound 指纹只在当前 Key 仍能验证时自动迁移,无法验证时在零网络动作前要求用户显式确认当前服务;确认后 `accepted/running` 只 GET 原 operation,`prepared` 只可精确重放冻结 endpoint、原始请求字节和原幂等键的 POST。草稿/manifest/event/日志只投影安全阶段与本地身份,远端 taskId 不写 manifest `source.taskId`。 +- 主站网页画布与普通 AI 游戏创作 Tauri 客户端统一使用当前平台登录态调用固定官方 origin 的 `/api/editor/*`、`/api/assets/*` 与 `/api/runtime/external-generation/jobs/*`。网站 Access Token 延续现有 WebView 客户端存储与刷新 Cookie 机制;登录成功或刷新后,AGC 只把 `userId + 短期 Access Token + 单调 authGeneration` 的当前快照同步到 GUI/Runner 进程内存,退出或换号以更高 generation 清除旧快照。Token 不写 Rust AppData 配置、项目、账本或日志。`/api/external/v1/*` 继续只供第三方开发者和独立 standalone game-chat/高级自定义模式使用自备 `tnr_sk_...`,不自动签发或下发 Developer API Key。 +- 每个网络动作在发出前冻结 `ownerUserId + authGeneration`,并在响应解析、账本推进、媒体安装和正式提交前复核当前账号。旧 generation 的迟到响应或清除不得覆盖新账号。普通模式首次 `401` 时,每个 generation 只允许一个共享 refresh;其它请求等待它,但保留各自 deadline/取消。refresh 成功后以更高 generation CAS 安装新 Token,并仅用原 endpoint、原始请求字节和原 `Idempotency-Key` 重试一次;refresh 失败、账号切换或再次 `401` 时停止网络并保留账本,`403` 不触发 refresh。 +- 调用模式、精确相对 endpoint、精确请求、prompt、idempotency key、operationId 和稳定产物引用只进入 `.agent/workbench/asset-canvas/generations/` 私有账本。本地参考图 direct-upload ticket 的 host/formFields/policy/signature/临时 Authorization 只存在内存;恢复账本只允许保存稳定 bucket/objectKey。普通账本身份固定为 `official-platform-v1 + 官方 origin + ownerUserId`,高级账本身份固定为 External service origin;均不绑定或保存 Token/Key。换号后旧 owner 账本零网络、零安装,只有重新登录同一 owner 后才能恢复。`accepted/running` 只 GET 原 operation,`prepared` 只精确重放冻结 endpoint、原始请求字节和原幂等键。草稿/manifest/event/日志只投影安全阶段与本地身份。 - 固定状态顺序为 `generation-accepted -> generation-running -> remote-completed -> media-downloaded -> asset-durable-committed -> manifest-projected -> layout-ready -> selected`。远端 completed 后必须先验证稳定引用,再经 `/assets/read-url` 下载并写入阶段三 staging;随后用账本中的固定本地 commit 身份进入既有 journal/ledger 事务。正式提交失败只恢复本地事务,不重新生成或下载。 - 应用重启时 `accepted/running` 只恢复 GET,`prepared` 只以私有账本原请求字节和原键恢复同一 POST。用户停止等待只失效当前 focus generation;不可取消的远端操作继续 reconcile,迟到 completed 可以更新原项目但不能抢焦点。配置缺失、结果引用不稳定、下载/提交失败和未知结果都不得显示资源创建成功。 - 最终审计后 Tauri 撤销/重做直接使用与网站相同的共享 `useCanvasHistory`,共享 history 同时恢复 viewport、选择、图层位置和缩放边界;Tauri 自身只保留 Pointer 事件接线,平移、缩放、选择、图层变换和 renderer 算法继续来自共享 core/react。 diff --git a/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md b/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md index b3c50e6ce..8931e92ae 100644 --- a/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md +++ b/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md @@ -1,8 +1,8 @@ # 客户端素材创作无限画布阶段一合同 -更新时间:`2026-08-10` +更新时间:`2026-08-15` -状态:阶段一产品与技术合同已冻结;截至 2026-08-10,客户登录态图片生成、泥点计费链路、中央进度/失败态、Tauri 正式资产提交及画布 UI 对齐阶段三至五已落地。当前产品入口按第 16 节禁用新增,并把编辑入口扩展到资源总览的全部现役类型;后续增量仍受本文合同约束。 +状态:阶段一产品与技术合同已冻结;截至 2026-08-15,普通客户远端编辑统一使用陶泥儿网站登录态与固定官方服务地址,泥点计费链路、中央进度/失败态、Tauri 正式资产提交及画布 UI 对齐阶段三至五已落地。当前产品入口按第 16 节禁用新增,并把编辑入口扩展到资源总览的全部现役类型;后续增量仍受本文合同约束。 本文是网站与 AI 游戏创作 Tauri 客户端共享图片画布能力的下一阶段编码依据。若本文与资源管理阶段七的“美术编辑暂缓”口径冲突,以本文对后续素材创作切片的更新决定为准;资源总览既有布局、依赖图和只读聚焦合同继续有效。 @@ -58,7 +58,7 @@ apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter - `image-canvas-core` 只含纯 TypeScript 的画布模型、几何、选择、图层命令、历史、序列化、防御校验和状态机;不得依赖 React、DOM、Tauri、HTTP、账号、钱包或浏览器存储。 - `image-canvas-react` 只含 React 视图、hooks、交互控制器和通用 UI,依赖 core 和注入的 Host Port;不得直接 import Tauri API、站点请求客户端、账户 store 或钱包 store。 - 网站 adapter 可以依赖账户、钱包、现有服务端 editor project、云端素材库、OSS/asset object 和生成 API。 -- Tauri adapter 可以依赖 `invoke/listen`、本地项目上下文、受控媒体命令、manifest、项目 revision、草稿 sidecar 和 AppData 私有 External Editor API 配置;该配置由独立“运行时配置”对话框维护,不进入共享画布或项目事实。 +- Tauri adapter 可以依赖 `invoke/listen`、本地项目上下文、受控媒体命令、manifest、项目 revision、草稿 sidecar,以及宿主进程内的陶泥儿账号会话快照。普通模式的服务 origin 由构建环境固定;网站 Access Token 延续现有 WebView 客户端存储,GUI 与 Runner 只接收当前 `userId + Token + authGeneration` 内存快照,不把 Token 写入 Rust AppData 配置、共享画布或项目事实。仅独立 standalone game-chat release 或显式高级自定义模式可以读取其隔离 AppData 中的 External v1 Developer API Key 配置。 - 依赖方向只能是“宿主 adapter -> React/UI -> core”。core/react 不得反向 import 任一宿主。 ### 3.2 禁止复制的验收门 @@ -200,7 +200,7 @@ interface ImageCanvasHostPort { - `hostRevision` 是宿主权威提交版本的字符串表示:Tauri 使用十进制项目 mutation revision,网站使用现有服务端 editor project revision。共享 UI 只透传/展示,不比较不同宿主的 revision。 - `commitId/idempotencyKey` 由共享流程在第一次正式保存前生成;响应未知时两宿主都复用原完整请求。`expectedHostRevision` 由 adapter 从已加载的权威宿主快照提供,Tauri 必须无损解析为本文的安全整数 `expectedRevision`。 - Web adapter 把草稿、导入、生成、导出和提交映射到现有服务端 editor project、云端素材库及账户/钱包链路。 -- Tauri adapter 把草稿、导入、导出和提交映射到本文第 7 至 11 节的本地合同;远端媒体编辑通过 AppData 私有 `editorApi.baseUrl/apiKey` 使用 External v1,后端按 owner 进入统一生成队列与泥点预扣/退款。素材画布工作区不显示或要求填写 Base URL / API Key,普通 Launcher、开发工作台和独立 game-chat 的独立“运行时配置”对话框均可维护这两个字段;凭据不能进入共享画布、项目 sidecar、manifest、事件或错误正文。 +- Tauri adapter 把草稿、导入、导出和提交映射到本文第 7 至 11 节的本地合同;普通模式远端媒体编辑固定使用官方 origin 下的 `/api/editor/*`、`/api/assets/*` 与 `/api/runtime/external-generation/jobs/*`,后端从网站 Access Token 解析 owner 并进入统一生成队列与泥点预扣/退款。普通 Launcher、开发工作台和已认证 game-chat 均不显示或维护 Base URL / API Key。仅独立 standalone game-chat release 或显式高级自定义模式使用隔离 AppData 的 `editorApi.baseUrl/apiKey` 调用 `/api/external/v1/*`;两种模式的凭据都不能进入共享画布、项目 sidecar、manifest、事件或错误正文。 ### 3.4 主站 UI 对齐与共享画布 chrome @@ -952,13 +952,16 @@ confirmation-required ### 14.4 首版请求范围与恢复 -- 主站网页画布继续使用 `POST /api/editor/images/generations`、`POST /api/editor/images/edits` 与 `/api/runtime/external-generation/jobs/{operationId}`。AI 游戏创作 Tauri 客户端固定使用对应 `/api/external/v1/editor/images/*`,携带发布 AppData 私有 `editorApi.apiKey` 和稳定 `Idempotency-Key`,状态只查询 `GET /api/external/v1/generations/{operationId}`。两条入口复用相同请求 DTO、owner 归属和统一生成队列;客户端不读取或透传主站 Access Token,也不实现登录后自动签发 Developer API Key。 -- External 图片生成和编辑支持相同 prompt、`1:1 | 2:3 | 3:2 | 9:16 | 16:9`、`0.5K | 1K | 2K`、合法 `assetKind` 与参考资源约束。refine 的 `sourceImageSrc` 必须来自源资产的 objectKey、服务端项目 resourceId 或素材 assetId;`assetObjectId` 本身不是该字段接受的引用。本地独有图片在用户确认后先走 External direct-upload ticket → OSS form → object confirm,确认后的 objectKey 才可进入生成请求。`sourceResourceId` 只在源资产确有服务端 resourceId 时发送,不能用本地 `local-asset:*`、objectKey 或 assetObjectId 冒充。额外参考最多 8 个。 +- 主站网页画布与普通 AI 游戏创作 Tauri 客户端统一使用 `POST /api/editor/images/generations`、`POST /api/editor/images/edits` 与 `GET /api/runtime/external-generation/jobs/{operationId}`。普通 AGC 使用登录后的平台 Access Token 和稳定 `Idempotency-Key`;提交同时兼容站内 `200 + queueState.operationId` 与 inline 完成响应,队列状态读取 `job` 包装。官方 origin 由构建环境固定且不可由普通用户修改。第三方 Agent/CLI、独立 standalone game-chat release 或显式高级自定义模式仍走 `/api/external/v1` 与 Developer API Key。 +- 普通模式图片生成和编辑支持相同 prompt、`1:1 | 2:3 | 3:2 | 9:16 | 16:9`、`0.5K | 1K | 2K`、合法 `assetKind` 与参考资源约束。refine 的 `sourceReferenceId` 必须是当前账号已登记的服务端项目 resourceId 或素材 assetId;`objectKey`、URL、本地 `local-asset:*` 与 `assetObjectId` 都不能冒充该业务引用。本地独有图片在用户确认后先走 `/api/assets/direct-upload-tickets` → OSS form → `/api/assets/objects/confirm`,再创建当前账号拥有的项目资源或素材记录,取得正式 ID 后才可进入编辑请求。额外参考最多 8 个。高级 External v1 模式使用同一业务引用约束,但走其独立外部路由与 Developer Key 鉴权。 - 客户端参考媒体直传固定复用 `legacyPrefix=generated-character-drafts`,不得把内部用途目录作为新 legacy prefix,也不得扩大服务端白名单。图片画布的 `pathSegments` 固定为 `editor / asset-canvas-references / / / `;全类型资源编辑的本地视频、音频等源媒体固定为 `editor / resource-editor-references / / `。两条路径都只持久化 confirm 后的稳定 objectKey,不持久化 ticket 或签名 URL。 -- 图片参考资源准备失败按阶段投影安全错误码:本地读取/校验为 `reference-material-invalid`,票据为 `reference-ticket-failed`,OSS 表单上传为 `reference-object-upload-failed`,对象确认为 `reference-confirm-failed`;`401/403` 仍为 `authentication-required`。任一阶段失败都必须保持 External `operationId=null`、生成 endpoint/request body 未建立,不得进入扣费或生成提交。普通错误不得包含 ticket host、formFields、policy、signature、Token、API Key、Provider 响应正文或本机绝对路径。 +- 图片参考资源准备失败按阶段投影安全错误码:本地读取/校验为 `reference-material-invalid`,票据为 `reference-ticket-failed`,OSS 表单上传为 `reference-object-upload-failed`,对象确认为 `reference-confirm-failed`;`401` 进入当前账号代际的单飞刷新,刷新失败或重试后仍未授权才返回 `authentication-required`,`403` 直接按权限不足处理。任一阶段失败都必须保持 `operationId=null`、生成 endpoint/request body 未建立,不得进入扣费或生成提交。普通错误不得包含 ticket host、formFields、policy、signature、Token、API Key、Provider 响应正文或本机绝对路径。 - `accepted/running` 重启只查询原 operation;`prepared` 只以账本中的原 endpoint、原始字节和原键恢复同一 POST。轮询超时、临时鉴权/网关错误或结果未知保持可恢复,不自动转 failed 或换键扣费。 - 用户“停止等待”只撤销当前 session 的聚焦意图,不伪装远端取消。后台 command 可以继续安全 reconcile;应用退出后由项目恢复扫描继续原 operation。迟到完成可以更新原项目权威缓存和 manifest,但不能切回旧画布、清搜索或抢焦点。 -- External API Key 缺失、无效或权限不足,余额不足、平台生成服务配置错误、远端 terminal failed、稳定引用缺失、下载失败、staging/正式提交失败都不得返回资源创建成功。错误只说明需要配置或检查 External Editor 凭据,不回显 Key、Authorization 或上游正文。只有 terminal failed 才允许用户通过新的独立确认建立新 intent;其它未知或对账状态必须保留原身份。 +- 普通模式缺少有效登录态、刷新失败或账号权限不足,以及余额不足、平台生成服务配置错误、远端 terminal failed、稳定引用缺失、下载失败、staging/正式提交失败,都不得返回资源创建成功。错误只引导用户重新登录或检查账号权限,不要求填写 Developer API Key,也不回显 Token、Authorization 或上游正文。高级 External v1 模式才可提示检查 Developer API Key。只有 terminal failed 才允许用户通过新的独立确认建立新 intent;其它未知或对账状态必须保留原身份。 +- 普通模式会话使用单调 `authGeneration` 做安装与清除 CAS。登录、刷新或换号只允许更高 generation 覆盖 GUI/Runner 内存快照;每个网络动作在发出前冻结 `ownerUserId + authGeneration`,响应解析、账本推进、媒体安装和正式提交前都必须复核仍属于当前账号。旧 generation 的迟到成功、失败或清除不得覆盖新账号状态,也不得把旧账号媒体提交到新账号项目。 +- 普通模式首次遇到 `401` 时,每个 `authGeneration` 全局只允许一个 refresh 请求;其它调用等待该 Promise,但各自仍受自己的 deadline/取消信号约束。refresh 成功后以更高 generation CAS 安装新 Token,并只用原 endpoint、原始请求字节和原 `Idempotency-Key` 重试一次;不能重建正文、创建新 operation 或重新扣费。refresh 失败、账号已切换或重试仍为 `401` 时停止网络动作并保留原账本;`403` 不触发 refresh。 +- 私有生成账本的普通模式服务身份固定为 `official-platform-v1 + 官方 origin + ownerUserId`,并保存创建该账本时的账号身份,不保存 Access Token、refresh Cookie 或 authGeneration。Access Token 轮换不改变 operation 身份;切号或退出会提升 generation、中止并脱离旧请求,旧账号的 `prepared/accepted/running` 账本在新账号下零网络动作、零安装、零归档。只有重新登录同一 owner 后才能继续原 POST/GET;不能把新账号下的 `401/403/404` 当作旧 operation 的终态证明。 ## 15. 2026-08-06 画布 UI 对齐阶段三至五收口 @@ -979,19 +982,19 @@ confirmation-required - 资源聚焦态的所有现役资源均提供“编辑资源”,覆盖 manifest asset、已完成任务产物、已导入附件、Agent 文本回执和项目版本。后端必须按 manifest、任务完成态、上传登记或回执身份重新核验来源;没有唯一来源身份的本地媒体不得仅凭前端路径进入编辑。 - 静态 PNG / JPEG / WebP 继续使用 `AssetCanvasSurface + intent=refine`,自动加载唯一源图片,并把源资源身份作为图片编辑请求的必选引用。任务产物或附件中的静态图片必须先正规化为正式 manifest asset,再进入现有图片画布。 - refine 入口不能只在 WebView 进程内缓存 `draftId`。每次打开先在正式 draft sidecar 中按 `projectId + intent=refine + sourceAssetId + active status` 有界发现:唯一命中沿用原 `draftId` 并生成新 `sessionId`,零命中才创建,多命中进入 `reconciliation-required`;`committed/cancelled` 不属于 active 候选。 -- 客户端确认编辑后固定调用 `POST /api/external/v1/editor/images/edits`;提示词、比例、尺寸、资源用途、泥点计费和原 operation 恢复继续复用现有生成合同,凭据只来自发布 AppData 私有 `editorApi.apiKey`。 +- 普通客户端确认编辑后固定调用 `POST /api/editor/images/edits`;提示词、比例、尺寸、资源用途、泥点计费和原 operation 恢复继续复用现有生成合同,请求凭据来自当前平台登录态的 native 内存快照,不进入资源编辑账本。独立 game-chat/高级自定义模式保留 `/api/external/v1/editor/images/edits`。 - SVG、UTF-8 文档、代码和 Agent 文本回执使用文本差异派生:把源内容当作不可信数据交给当前客户端 LLM,响应必须是完整、唯一的结构化内容 envelope;JSON、SVG 等可校验格式必须在落盘前重新校验。结果写入新的本地路径和 manifest asset,不能直接写回源文件。Agent 回执原记录不转写、不删除,新 asset 以回执资源身份登记血缘。 - 文本、SVG 与 Agent 回执在 Provider 调用前必须先持久化 request-issued;成功响应必须先原子安装到与原 operation、请求指纹和内容摘要绑定的私有 durable handoff,再做 envelope 解析、格式校验和 staging。issued 后缺少可信 handoff 只能对账;handoff 已存在且校验通过时恢复只消费该正文,两种情况都禁止再次调用 Provider。 -- 视频使用 `POST /api/external/v1/editor/videos/generations`。有稳定远端引用时直接作为 `referenceVideoSrcs`,只有本地文件时先走 External direct-upload ticket、OSS 表单上传和 confirm,再提交同一逻辑生成;结果必须下载到新的本地文件并登记远端稳定身份。 -- 音效和背景音乐分别使用 `POST /api/external/v1/editor/audios/sound-effects/generations` 与 `POST /api/external/v1/editor/audios/background-music/generations`。当前接口没有源音频引用字段,因此产品语义固定为“基于原资源语义的派生重制”,界面不得描述为对源波形的裁剪、变声或局部修改;结果仍必须引用源资源身份并保留原音频。 +- 普通客户端视频使用 `POST /api/editor/videos/generations`。有稳定远端引用时直接作为 `referenceVideoSrcs`,只有本地文件时先走 `/api/assets/direct-upload-tickets`、OSS 表单上传和 `/api/assets/objects/confirm`,再提交同一逻辑生成;结果必须下载到新的本地文件并登记远端稳定身份。 +- 普通客户端音效和背景音乐分别使用 `POST /api/editor/audios/sound-effects/generations` 与 `POST /api/editor/audios/background-music/generations`。当前接口没有源音频引用字段,因此产品语义固定为“基于原资源语义的派生重制”,界面不得描述为对源波形的裁剪、变声或局部修改;结果仍必须引用源资源身份并保留原音频。 - 项目版本编辑固定追加 `parentVersionId` 指向源版本的子版本,继承源版本资源绑定并记录本轮编辑提示;已有版本数组元素不可修改、删除或重排。 - 所有编辑结果固定创建新的本地 asset、文件路径、版本 ID 或资源身份。源 asset、源文件、Agent 回执与源版本不得删除、覆盖或复用;新 asset 的 `referenceResourceIds` 必须包含源资源规范身份,资源总览同时保留新旧资源。 -- 各类型统一使用稳定 `operationId / Idempotency-Key`。远端 `202` 只表示受理,必须轮询原 operation;提交结果未知或登录失效时保留原身份供恢复,不能换键重提。签名 URL 不得写入 manifest,manifest 只保存稳定 objectKey 对应的资源 / 资产身份。 -- External 图片 refine、视频、音效和背景音乐请求统一在 `generationInputs.source` 写入专用消费身份 `game-creator-resource-editor`;所有 External 路由必须实际读取并传递同一稳定 `Idempotency-Key`。队列完成态只向该消费身份返回经过裁剪的稳定 `objectKey / resource / asset` 引用和必要媒体元数据,不暴露 provider、worker、队列内部字段或临时签名 URL。 +- 各类型统一使用稳定 `operationId / Idempotency-Key`。普通站内响应的 queued operation 与高级 External v1 的 `202` 都只表示受理,必须轮询原 operation;inline 完成则直接消费同一请求的稳定结果。提交结果未知或登录失效时保留原身份供恢复,不能换键重提。签名 URL 不得写入 manifest,manifest 只保存稳定 objectKey 对应的资源 / 资产身份。 +- 图片 refine、视频、音效和背景音乐请求统一在 `generationInputs.source` 写入专用消费身份 `game-creator-resource-editor`;普通内部路由和高级 External 路由都必须实际读取并传递同一稳定 `Idempotency-Key`。队列完成态只向该消费身份返回经过裁剪的稳定 `objectKey / resource / asset` 引用和必要媒体元数据,不暴露 provider、worker、队列内部字段或临时签名 URL。 - 生成公开状态每次写入草稿都必须推进草稿 revision,并把最新 revision 同步到私有生成账本、进度事件、staging 与正式 commit;旧 UI 快照不得覆盖 accepted/running/reconciliation 状态。 -- operation 已受理或首次提交结果未知后遇到 401/403,不得写成 terminal failed 或不可逆 `reconciliation-required`。私有账本保留原 remote phase、operation、原请求字节和原幂等身份;修正 External Editor API Key 配置后,已有 `operationId` 只继续 GET 同一 operation,未受理请求才允许用冻结正文和原幂等键重试 POST。 +- operation 已受理或首次提交结果未知后遇到 `401`,不得写成 terminal failed 或不可逆 `reconciliation-required`。普通模式先按上述单飞流程刷新;刷新失败、换号或当前 owner 不匹配时保留原 remote phase、operation、原请求字节和原幂等身份,停止网络。重新登录同一 owner 后,已有 `operationId` 只继续 GET 同一 operation,未受理请求才允许用冻结正文和原幂等键重试 POST。高级 External v1 模式修正 Developer API Key 后遵守相同恢复规则。 - 全类型资源编辑私有账本必须冻结 `sourceAssetId / sourceMediaType / sourceAssetKind / producerTaskId / sourceVersionId` 源快照;新账本不得依赖 UI 仍停留在原资源,旧账本则只允许从当前 manifest、已完成任务产物、版本数组和受控扩展名兼容恢复。恢复结果仍要重新校验唯一来源、项目归属与媒体类型,不能把账本快照当成越权凭据。 -- 私有账本的 External 配置身份只绑定规范服务地址,不绑定可轮换的 Developer API Key。升级前仍使用“服务地址 + Key”指纹的 Agent 生成账本,在原 Key 尚可用时首次恢复必须原子迁移为仅服务地址指纹;无法用当前 Key 验证时先返回与账本快照绑定的显式确认挑战,确认前零网络动作,确认时复核 operation、挑战值与当前规范服务地址,成功后原子迁移。迁移后 Key 轮换不影响原 operation;401/403/404、轮询超时或网关暂时失败都必须保留原 endpoint、请求字节、幂等键和 operation,恢复命令不允许前端重新拼请求或携带 Key。 +- 普通模式私有账本绑定固定官方 origin 与 ownerUserId,不绑定可轮换的 Access Token;恢复命令不得让前端重新拼请求或把 Token 写入参数。高级 External v1 账本绑定显式服务 origin,同样不绑定可轮换的 Developer API Key。两种模式都不得因凭据轮换改变原 operation;`401/403/404`、轮询超时或网关暂时失败必须保留原 endpoint、请求字节、幂等键和 operation,并先按账本记录的模式与 owner 做隔离校验。 - pending 恢复扫描的目录上限统计所有实际目录条目,包括未知文件、非 pending 账本和损坏条目;达到上限后停止扫描并返回安全错误,不能通过堆放无关文件绕过边界。已 committed 的账本不得出现在 pending 列表中。 - 派生媒体提交必须以账本冻结的源摘要再次校验源文件;摘要、媒体身份或版本父子关系变化时失败关闭。远端派生视频二次编辑必须复用已提交账本中的稳定 `objectKey`,不能把仅用于本地登记的 `assetObjectId` 当成 generation reference。版本 journal 重放只能返回既有子版本,不得重复追加。 - 返回资源总览时,clean 草稿直接保留并退出;dirty 草稿必须使用独立确认面板提供“保留草稿并退出”和“放弃草稿”,默认保留。保留前必须等待当前保存或主动 flush,放弃才允许调用 discard。 diff --git a/server-rs/crates/api-server/src/editor_generation_queue.rs b/server-rs/crates/api-server/src/editor_generation_queue.rs index 3d0388147..240d85843 100644 --- a/server-rs/crates/api-server/src/editor_generation_queue.rs +++ b/server-rs/crates/api-server/src/editor_generation_queue.rs @@ -30,6 +30,8 @@ const EDITOR_GENERATION_QUEUE_PROVIDER: &str = "editor-generation-worker"; const MAX_EDITOR_GENERATION_JOB_PAYLOAD_BYTES: usize = 512 * 1024; const EXTERNAL_API_GENERATION_DEDUPE_PREFIX: &str = "external-api-generation"; const EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX: &str = "editor-api-request-generation"; +const GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX: &str = "game-creator-client-generation"; +pub(crate) const GAME_CREATOR_CLIENT_GENERATION_SOURCE: &str = "ai-game-creator-client"; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -282,7 +284,8 @@ where let request_label = request_label.into(); match external_idempotency_key { Some(idempotency_key) => { - enqueue_external_api_editor_generation_job( + let dedupe_namespace = editor_generation_idempotency_namespace(payload); + enqueue_idempotent_editor_generation_job( state, owner_user_id, job_kind, @@ -291,6 +294,7 @@ where price_mud_points, payload, idempotency_key, + dedupe_namespace, ) .await } @@ -310,6 +314,92 @@ where } } +fn editor_generation_idempotency_namespace(payload: &T) -> &'static str +where + T: Serialize, +{ + let source = serde_json::to_value(payload) + .ok() + .and_then(|payload| editor_generation_client_source(&payload).map(str::to_string)); + if source.as_deref() == Some(GAME_CREATOR_CLIENT_GENERATION_SOURCE) { + GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX + } else { + EXTERNAL_API_GENERATION_DEDUPE_PREFIX + } +} + +pub(crate) fn editor_generation_client_source(payload: &Value) -> Option<&str> { + [ + "/generationInputs/source", + "/request/generationInputs/source", + ] + .into_iter() + .find_map(|pointer| { + payload + .pointer(pointer) + .and_then(Value::as_str) + .map(str::trim) + .filter(|source| !source.is_empty()) + }) +} + +#[allow(clippy::too_many_arguments)] +async fn enqueue_idempotent_editor_generation_job( + state: &AppState, + owner_user_id: &str, + job_kind: &str, + source_entity_id: impl Into, + request_label: impl Into, + price_mud_points: u64, + payload: &T, + idempotency_key: &str, + dedupe_namespace: &str, +) -> Result +where + T: Serialize, +{ + if dedupe_namespace == EXTERNAL_API_GENERATION_DEDUPE_PREFIX { + return enqueue_external_api_editor_generation_job( + state, + owner_user_id, + job_kind, + source_entity_id, + request_label, + price_mud_points, + payload, + idempotency_key, + ) + .await; + } + + let request_payload_json = serialize_editor_generation_job_payload(payload)?; + let dedupe_key = build_editor_generation_dedupe_key( + dedupe_namespace, + owner_user_id, + job_kind, + idempotency_key, + ); + let job = enqueue_serialized_editor_generation_job_with_identity( + state, + owner_user_id, + job_kind, + source_entity_id, + request_label, + price_mud_points, + request_payload_json.clone(), + build_prefixed_uuid_id("task-"), + dedupe_key, + ) + .await?; + ensure_editor_generation_job_matches_request( + job, + owner_user_id, + job_kind, + request_payload_json.as_str(), + "Idempotency-Key 已用于不同的生成请求,请复用原请求参数或更换幂等键。", + ) +} + #[allow(clippy::too_many_arguments)] async fn enqueue_serialized_editor_generation_job_with_identity( state: &AppState, @@ -532,6 +622,61 @@ mod tests { assert!(first.starts_with("editor-api-request-generation:editor_image_generation:")); } + #[test] + fn game_creator_client_uses_its_own_stable_idempotency_namespace() { + let game_creator = json!({ + "prompt": "生成主角", + "generationInputs": { + "source": GAME_CREATOR_CLIENT_GENERATION_SOURCE, + }, + }); + let ordinary = json!({ + "prompt": "生成主角", + "generationInputs": { + "source": "editor-web", + }, + }); + + assert_eq!( + editor_generation_idempotency_namespace(&game_creator), + GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX + ); + assert_eq!( + editor_generation_idempotency_namespace(&ordinary), + EXTERNAL_API_GENERATION_DEDUPE_PREFIX + ); + + let game_creator_key = build_editor_generation_dedupe_key( + editor_generation_idempotency_namespace(&game_creator), + "user-1", + EDITOR_IMAGE_GENERATION_JOB_KIND, + "stable-key-1", + ); + let external_key = build_editor_generation_dedupe_key( + editor_generation_idempotency_namespace(&ordinary), + "user-1", + EDITOR_IMAGE_GENERATION_JOB_KIND, + "stable-key-1", + ); + assert!( + game_creator_key.starts_with("game-creator-client-generation:editor_image_generation:") + ); + assert_ne!(game_creator_key, external_key); + + let wrapped_edit = json!({ + "version": 2, + "request": { + "generationInputs": { + "source": GAME_CREATOR_CLIENT_GENERATION_SOURCE, + }, + }, + }); + assert_eq!( + editor_generation_idempotency_namespace(&wrapped_edit), + GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX + ); + } + #[test] fn external_api_dedupe_key_preserves_legacy_hash_bytes() { let dedupe_key = build_editor_generation_dedupe_key( diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index c8744d3b8..fb8ef4e33 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -80,6 +80,7 @@ use crate::{ EDITOR_BACKGROUND_REMOVAL_JOB_KIND, EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, EDITOR_IMAGE_EDIT_JOB_KIND, EDITOR_IMAGE_GENERATION_JOB_KIND, EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, EditorGenerationQueuedResponse, + GAME_CREATOR_CLIENT_GENERATION_SOURCE, editor_generation_client_source, editor_generation_queue_state, editor_generation_source_entity_id, enqueue_editor_generation_job, enqueue_editor_generation_job_for_caller, }, @@ -531,13 +532,7 @@ impl EditorGenerationQueueResultContext { pub(crate) fn from_job(job: &ExternalGenerationJobRecord) -> Self { let generation_source = serde_json::from_str::(job.request_payload_json.as_str()) .ok() - .and_then(|payload| { - payload - .pointer("/generationInputs/source") - .and_then(Value::as_str) - .map(str::trim) - .map(str::to_string) - }); + .and_then(|payload| editor_generation_client_source(&payload).map(str::to_string)); let consumer = if job .dedupe_key .trim() @@ -546,7 +541,10 @@ impl EditorGenerationQueueResultContext { EditorGenerationQueueConsumer::ExternalApi } else if generation_source.as_deref() == Some("editor-agent") { EditorGenerationQueueConsumer::EditorAgent - } else if generation_source.as_deref() == Some(GAME_CREATOR_RESOURCE_EDITOR_SOURCE) { + } else if matches!( + generation_source.as_deref(), + Some(GAME_CREATOR_RESOURCE_EDITOR_SOURCE | GAME_CREATOR_CLIENT_GENERATION_SOURCE) + ) { EditorGenerationQueueConsumer::GameCreatorResourceEditor } else { EditorGenerationQueueConsumer::Standard @@ -1132,7 +1130,7 @@ fn serialize_atomic_editor_generation_job_result( { object.insert( "result".to_string(), - compact_editor_generation_result(response.clone()), + compact_external_api_generation_result(response.clone()), ); } if queue_result_context.consumer == EditorGenerationQueueConsumer::ExternalApi @@ -12805,7 +12803,14 @@ pub(crate) fn current_utc_micros() -> i64 { #[cfg(test)] mod tests { use super::*; - use crate::{config::AppConfig, editor_green_screen::parse_editor_screen_background_color}; + use crate::{ + config::AppConfig, + editor_generation_queue::{ + EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND, + EDITOR_VIDEO_GENERATION_JOB_KIND, + }, + editor_green_screen::parse_editor_screen_background_color, + }; use std::{ io::{Read as _, Write as _}, net::{TcpListener, TcpStream}, @@ -18969,6 +18974,32 @@ mod tests { .get("ownerUserId") .is_none() ); + + ordinary_job.dedupe_key = + "game-creator-client-generation:editor_image_generation:fingerprint".to_string(); + ordinary_job.request_payload_json = json!({ + "generationInputs": { + "source": GAME_CREATOR_CLIENT_GENERATION_SOURCE, + }, + }) + .to_string(); + let game_creator_context = EditorGenerationQueueResultContext::from_job(&ordinary_job); + assert_eq!( + game_creator_context.consumer, + EditorGenerationQueueConsumer::GameCreatorResourceEditor + ); + let game_creator_payload: Value = serde_json::from_str( + serialize_atomic_editor_generation_job_result(&game_creator_context, &result) + .expect("game creator queue payload") + .as_str(), + ) + .expect("game creator queue payload should be JSON"); + assert_eq!(game_creator_payload["result"], external_payload["result"]); + assert!( + game_creator_payload + .get("editor-agent-tool-call-result") + .is_none() + ); } #[test] @@ -19011,6 +19042,145 @@ mod tests { } } + #[test] + fn game_creator_completed_jobs_keep_stable_results_for_all_current_media_kinds() { + let fixtures = [ + ( + EDITOR_IMAGE_GENERATION_JOB_KIND, + json!({ + "objectKey": "generated/image.png", + "provider": "internal-provider", + "assetObjectId": "asset-object-image", + "resource": { + "resourceId": "resource-image", + "objectKey": "generated/image.png", + "assetObjectId": "asset-object-image", + }, + "asset": { + "assetId": "asset-image", + "objectKey": "generated/image.png", + "assetObjectId": "asset-object-image", + }, + "warning": { + "code": "dimension-restore-fallback", + "reason": "保留稳定源图。", + }, + }), + ), + ( + EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, + json!({ + "objectKey": "generated/sheet.png", + "provider": "internal-provider", + "spritesheetResource": { + "resourceId": "resource-sheet", + "objectKey": "generated/sheet.png", + }, + "spritesheetAsset": { + "assetId": "asset-sheet", + "objectKey": "generated/sheet.png", + }, + "sliceWarning": { + "code": "slice-persistence-failed", + "reason": "完整图集仍可使用。", + }, + }), + ), + ( + EDITOR_VIDEO_GENERATION_JOB_KIND, + json!({ + "objectKey": "generated/video.mp4", + "provider": "internal-provider", + "resource": { + "resourceId": "resource-video", + "objectKey": "generated/video.mp4", + }, + "asset": { + "assetId": "asset-video", + "objectKey": "generated/video.mp4", + }, + }), + ), + ( + EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND, + json!({ + "objectKey": "generated/sfx.mp3", + "provider": "internal-provider", + "audioKind": "sound-effect", + "resource": { + "resourceId": "resource-sfx", + "objectKey": "generated/sfx.mp3", + }, + "asset": { + "assetId": "asset-sfx", + "objectKey": "generated/sfx.mp3", + }, + }), + ), + ( + EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, + json!({ + "objectKey": "generated/bgm.mp3", + "provider": "internal-provider", + "audioKind": "background-music", + "resource": { + "resourceId": "resource-bgm", + "objectKey": "generated/bgm.mp3", + }, + "asset": { + "assetId": "asset-bgm", + "objectKey": "generated/bgm.mp3", + }, + }), + ), + ]; + + for (job_kind, result) in fixtures { + let mut job = atomic_editor_generation_job_fixture(); + job.job_kind = job_kind.to_string(); + job.dedupe_key = format!("game-creator-client-generation:{job_kind}:fingerprint"); + job.request_payload_json = json!({ + "generationInputs": { + "source": GAME_CREATOR_CLIENT_GENERATION_SOURCE, + }, + }) + .to_string(); + let context = EditorGenerationQueueResultContext::from_job(&job); + let payload: Value = serde_json::from_str( + serialize_atomic_editor_generation_job_result(&context, &result) + .unwrap_or_else(|error| panic!("{job_kind} result should compact: {error}")) + .as_str(), + ) + .unwrap_or_else(|error| panic!("{job_kind} payload should deserialize: {error}")); + + assert_eq!( + context.consumer, + EditorGenerationQueueConsumer::GameCreatorResourceEditor, + "{job_kind}" + ); + assert_eq!( + payload["result"]["objectKey"], result["objectKey"], + "{job_kind}" + ); + assert!(payload["result"].get("provider").is_none(), "{job_kind}"); + for field in [ + "resource", + "asset", + "spritesheetResource", + "spritesheetAsset", + "warning", + "sliceWarning", + ] { + if result.get(field).is_some() { + assert_eq!( + payload["result"][field], result[field], + "{job_kind}:{field}" + ); + } + } + } + } + /// 画布 Agent 音频回填只读取稳定产物引用;provider 不属于工具卡片或回填 DTO。 #[test] fn atomic_agent_audio_result_hides_provider_and_stays_backfillable() { diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index 1383059c0..9e8c307aa 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -4,7 +4,7 @@ use crate::editor_project::*; use axum::{ Json, extract::{Extension, State, rejection::JsonRejection}, - http::StatusCode, + http::{HeaderMap, StatusCode}, }; use module_assets::{ AssetObjectAccessPolicy, build_asset_object_upsert_input, generate_asset_object_id, @@ -1349,9 +1349,11 @@ pub async fn generate_editor_icon_spritesheet( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, + headers: HeaderMap, payload: Result, JsonRejection>, ) -> Result, AppError> { let Json(payload) = parse_editor_generation_json_payload(payload)?; + let idempotency_key = optional_editor_idempotency_key(&headers)?; let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { let queue_job = enqueue_editor_icon_spritesheet_generation_for_owner( @@ -1359,7 +1361,7 @@ pub async fn generate_editor_icon_spritesheet( &request_context, &caller, payload, - None, + idempotency_key, ) .await?; return Ok(json_success_body( @@ -2713,6 +2715,23 @@ pub(crate) fn validate_editor_icon_spritesheet_dimensions( mod tests { use super::*; + #[test] + fn icon_spritesheet_logged_in_handler_forwards_stable_idempotency_key() { + let source = include_str!("editor_project_icon.rs"); + let start = source + .find("pub async fn generate_editor_icon_spritesheet(") + .expect("logged-in icon spritesheet handler should exist"); + let branch = &source[start..]; + let end = branch + .find("pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner(") + .expect("icon spritesheet enqueue helper should follow handler"); + let handler = &branch[..end]; + + assert!(handler.contains("headers: HeaderMap")); + assert!(handler.contains("optional_editor_idempotency_key(&headers)?")); + assert!(handler.contains("idempotency_key,")); + } + #[test] fn game_genre_serde_uses_chinese_slugs() { for genre in GameGenre::ALL {