diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index c3a9e7256..1f7ffeec2 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1075,7 +1075,11 @@ export interface AdminRechargeRefundActionResponse { export interface AdminWalletRestrictionResponse { wallet: AdminProfileWalletPayload; } -export type AdminAgcAgentMode = 'codex' | 'cc' | 'anthropic'; +export type AdminAgcAgentMode = 'codex' | 'cc'; +export type AdminAgcModelProtocol = + | 'openai_responses' + | 'openai_chat' + | 'anthropic'; export interface AdminAgcModel { id: string; @@ -1083,6 +1087,8 @@ export interface AdminAgcModel { modelId: string; enabled: boolean; agentMode: AdminAgcAgentMode; + /** 上游线协议,与执行器正交。 */ + protocol?: AdminAgcModelProtocol; } export interface AdminAgcModelCatalog { diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx index 77b9262bb..612d21826 100644 --- a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx +++ b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx @@ -104,3 +104,49 @@ test('new models default to Codex and save the selected Agent mode', async () => }); }); }); + +test('协议与 Agent 模式是独立字段,可按模型单独选择', async () => { + const catalog = { + revision: 3, + defaultModelId: 'quality', + models: [ + { + id: 'quality', + alias: '高质量', + modelId: 'claude-opus-5-5', + enabled: true, + agentMode: 'cc' as const, + protocol: 'anthropic' as const, + }, + ], + }; + vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog); + vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({ + ...input, + revision: 4, + })); + render(); + await screen.findByDisplayValue('claude-opus-5-5'); + + const agentMode = screen.getByLabelText('模型 1 Agent 模式') as HTMLSelectElement; + const protocol = screen.getByLabelText('模型 1 协议') as HTMLSelectElement; + expect(agentMode.value).toBe('cc'); + expect(protocol.value).toBe('anthropic'); + // 执行器下拉里不再混入协议取值。 + expect( + Array.from(agentMode.options).map((option) => option.value), + ).toEqual(['codex', 'cc']); + + fireEvent.change(agentMode, { target: { value: 'codex' } }); + fireEvent.change(protocol, { target: { value: 'openai_chat' } }); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + + await waitFor(() => { + const calls = vi.mocked(saveAgcModelCatalog).mock.calls; + const payload = calls[calls.length - 1]?.[1]; + expect(payload?.models[0]).toMatchObject({ + agentMode: 'codex', + protocol: 'openai_chat', + }); + }); +}); diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx index cab805cf7..b009aa222 100644 --- a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx +++ b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx @@ -117,6 +117,7 @@ export function AdminAgcModelsPage({ { key: 'alias', label: '别名' }, { key: 'modelId', label: '实际模型名' }, { key: 'agentMode', label: 'Agent 模式' }, + { key: 'protocol', label: '协议' }, { key: 'enabled', label: '启用' }, { key: 'default', label: '默认' }, { key: 'actions', label: '操作' }, @@ -157,6 +158,7 @@ export function AdminAgcModelsPage({ modelId: '', enabled: true, agentMode: 'codex', + protocol: 'openai_responses', }, ], }, @@ -226,7 +228,22 @@ export function AdminAgcModelsPage({ > - + + + + diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs index 263b314da..827b06682 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs @@ -313,7 +313,11 @@ pub(crate) fn game_creator_claude_code_cli_version_identity() -> Result &'static str { + #[cfg(test)] + { + "responses" + } + #[cfg(not(test))] + { + match crate::config::load_game_creator_app_config() + .ok() + .and_then(|config| config.selected_model_protocol) + .as_deref() + { + Some("openai_chat") => "chat", + _ => "responses", + } + } +} + fn configure_game_creator_codex_app_server_command_for_mode( command: &mut tokio::process::Command, llm: &GameCreatorLlmConfig, @@ -2127,6 +2149,9 @@ fn configure_game_creator_codex_app_server_command_for_mode( let provider_token = provider_proxy .map(CodexProviderProxy::downstream_bearer_token) .unwrap_or(legacy_api_key); + // 线协议由所选模型的 `protocol` 决定:openai_chat 走 /chat/completions, + // 其余走 /responses。测试里不读全局配置,避免用例依赖运行环境。 + let wire_api = game_creator_codex_provider_wire_api(); command .arg("-c") .arg(format!( @@ -2151,7 +2176,7 @@ fn configure_game_creator_codex_app_server_command_for_mode( .arg("-c") .arg(format!( "model_providers.{GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID}.wire_api={}", - quoted_toml_string("responses")? + quoted_toml_string(wire_api)? )) .env(GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV, provider_token); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 06b012c94..f5059ed82 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -7338,7 +7338,7 @@ mod tests { ); assert!(error - .starts_with("direct-codex-failure:v2 stage=art-preparation code=runtime-failure retryable=true summary=")); + .starts_with("direct-codex-failure:v2 stage=art-preparation code=runtime-unclassified retryable=true summary=")); assert!(error.contains(""), "{error}"); assert!(error.contains(""), "{error}"); assert!(!error.contains("authorization=Bearer secret"), "{error}"); @@ -7382,7 +7382,7 @@ mod tests { assert!( error.starts_with( - "direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary=" + "direct-codex-failure:v2 stage=code-generation code=runtime-unclassified retryable=false summary=" ), "{error}" ); @@ -7421,7 +7421,7 @@ mod tests { ); assert!( - error.contains("stage=art-preparation code=runtime-failure retryable=false"), + error.contains("stage=art-preparation code=runtime-unclassified retryable=false"), "{error}" ); assert!(error.contains("历史画布资源不满足安全恢复条件"), "{error}"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 4940fbfec..10ec599ea 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -1288,23 +1288,15 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value { let scope_relative = scope .and_then(|scope| scope.project_relative) .filter(|path| !path.is_empty()); - let listed = list_local_project_files_at(root)?; - let scope_prefix = scope_relative.as_ref().map(|path| format!("{path}/")); + let listed = list_local_project_files_scoped_at(root, scope_relative.as_deref(), |path| { + bridge_project_file_is_hidden_control_path(path) + || should_skip_project_snapshot_path(path) + || reject_sensitive_project_file_read(path).is_err() + })?; let mut files = listed .files .into_iter() .filter(|file| file.kind == "file") - .filter(|file| !bridge_project_file_is_hidden_control_path(&file.path)) - .filter(|file| !should_skip_project_snapshot_path(&file.path)) - .filter(|file| reject_sensitive_project_file_read(&file.path).is_ok()) - .filter(|file| { - scope_relative.as_ref().is_none_or(|scope| { - file.path == *scope - || scope_prefix - .as_ref() - .is_some_and(|prefix| file.path.starts_with(prefix)) - }) - }) .filter(|file| { let (category, _) = bridge_project_file_class(&file.path); requested_kind == "all" || requested_kind == category @@ -1622,11 +1614,13 @@ async fn bridge_import_account_assets( } } if !local_paths.is_empty() { - match crate::commands::import_local_project_assets_for_agent_with_write_permit( + match crate::commands::import_local_project_assets_for_agent_in_blocking_pool( &state.root, &local_paths, write_permit.as_ref(), - ) { + ) + .await + { Ok(result) => imported.extend(result.assets.into_iter().map(|asset| { json!({ "id": asset.id, @@ -4193,6 +4187,73 @@ mod tests { ); } + #[test] + fn bridge_project_file_listing_scopes_filters_and_paginates_visible_files() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + init_local_game_project_at(root, "scoped-file-listing", "定向文件列举").unwrap(); + for path in [ + "assets/z.png", + "assets/nested/b.png", + "assets/a.png", + "assets/notes.txt", + "assets-other/wrong.png", + "unrelated/wrong.png", + ".agent/hidden.png", + "assets/.env", + "assets/secret.key", + "assets/.codex/hidden.png", + "assets/node_modules/hidden.png", + "assets/.git/hidden.png", + ] { + let target = root.join(path); + fs::create_dir_all(target.parent().unwrap()).unwrap(); + fs::write(target, b"test-only").unwrap(); + } + let payload = |arguments: Value| { + let result = bridge_list_project_files(root, &arguments); + assert_eq!(result["isError"], false, "{result}"); + serde_json::from_str::(result["content"][0]["text"].as_str().unwrap()).unwrap() + }; + let first = payload(json!({ "path": "assets", "kind": "image", "limit": 2 })); + assert_eq!(first["total"], 3); + assert_eq!(first["offset"], 0); + assert_eq!(first["limit"], 2); + assert_eq!(first["nextOffset"], 2); + assert_eq!(first["files"][0]["path"], "assets/a.png"); + assert_eq!(first["files"][1]["path"], "assets/nested/b.png"); + let last = payload(json!({ "path": "assets", "kind": "image", "offset": 2, "limit": 2 })); + assert_eq!(last["total"], 3); + assert_eq!(last["files"].as_array().unwrap().len(), 1); + assert_eq!(last["files"][0]["path"], "assets/z.png"); + assert!(last["nextOffset"].is_null()); + for path in ["assets/nested", "assets/nested/b.png"] { + let page = payload(json!({ "path": path })); + assert_eq!(page["total"], 1); + assert_eq!(page["files"][0]["path"], "assets/nested/b.png"); + } + assert_eq!( + payload(json!({ "path": "assets", "query": "NESTED" }))["total"], + 1 + ); + assert_eq!(payload(json!({ "path": "assets" }))["total"], 4); + assert_eq!(payload(json!({ "path": "missing" }))["total"], 0); + let beyond = payload(json!({ "path": "assets", "offset": 20 })); + assert!(beyond["files"].as_array().unwrap().is_empty()); + assert!(beyond["nextOffset"].is_null()); + for path in [ + ".agent", + "assets/.env", + "assets/.codex", + "assets/node_modules", + ] { + assert_eq!( + bridge_list_project_files(root, &json!({ "path": path }))["isError"], + true + ); + } + } + #[tokio::test] async fn analytics_real_file_write_preserves_original_identity_and_failed_run_revision() { use crate::analytics::{ @@ -4748,21 +4809,39 @@ mod tests { let revision_before = read_game_creator_agent_runtime_project_revision(root) .unwrap() .revision; - session - .interrupt("fixture cancellation before asset commit".into()) - .unwrap(); - assert!( - crate::commands::import_local_project_assets_for_agent_with_write_permit( + assert!(crate::tests::with_conversation_write_contention( + root, + crate::commands::import_local_project_assets_for_agent_in_blocking_pool( root, &["game/import-source.png".to_string()], Some(&permit), - ) - .is_err() - ); - assert!( - crate::commands::commit_account_asset_for_write_permit_test(root, &bytes, &permit) - .is_err() - ); + ), + || session + .interrupt("fixture cancellation during asset lock wait".into()) + .unwrap(), + ) + .await + .is_err()); + let commit_root = root.to_path_buf(); + let commit_bytes = bytes.clone(); + let commit_permit = permit.clone(); + assert!(crate::tests::with_conversation_write_contention( + root, + async move { + tokio::task::spawn_blocking(move || { + crate::commands::commit_account_asset_for_write_permit_test( + &commit_root, + &commit_bytes, + &commit_permit, + ) + }) + .await + .unwrap() + }, + || {} + ) + .await + .is_err()); assert_eq!( fs::read(root.join(".agent/manifest.json")).unwrap(), manifest_before diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs index 8f83bfc4d..8f1896f59 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs @@ -204,6 +204,12 @@ fn single_line_log_field(value: &str) -> String { value.replace(['\r', '\n'], " ") } +/// 直连回合失败的持久化标签。 +/// +/// 这里是**唯一**的失败分类出口,标签会被写进 `.agent/runtime/errors/*.json` 与 +/// `direct-codex-failure:v2` 文案。未识别的原因必须落回专门的 `runtime-unclassified`: +/// 以前这里借用具体的 `runtime-failure` 兜底,任何新失败形态都会被读成「运行时失败」, +/// 真正的失败原因反而被盖掉,也无法从标签看出分类覆盖率。 pub(crate) fn classify_direct_codex_error(error: &str) -> &'static str { let normalized = error.to_ascii_lowercase(); if normalized.contains("等待 turn/completed 超时") { @@ -224,8 +230,16 @@ pub(crate) fn classify_direct_codex_error(error: &str) -> &'static str { "tool-invalid-arguments" } else if normalized.contains("codex app-server-error:other") { "app-server-other" + } else if normalized.contains("sidecar") && normalized.contains("超时") { + "sidecar-turn-timeout" + } else if normalized.contains("sidecar") && normalized.contains("启动失败") { + "sidecar-launch-failed" + } else if normalized.contains("codex app-server 启动失败") { + "app-server-launch-failed" + } else if normalized.contains("claude code 返回失败终态") { + "claude-code-failed" } else { - "runtime-failure" + "runtime-unclassified" } } @@ -361,4 +375,39 @@ mod tests { "tool-invalid-arguments" ); } + + #[test] + fn known_sidecar_and_app_server_failures_keep_their_own_codes() { + assert_eq!( + classify_direct_codex_error("Claude Agent SDK sidecar 回合超时"), + "sidecar-turn-timeout" + ); + assert_eq!( + classify_direct_codex_error( + "Claude Agent SDK sidecar 的 Node runtime 启动失败:拒绝访问" + ), + "sidecar-launch-failed" + ); + assert_eq!( + classify_direct_codex_error("Codex app-server 启动失败:找不到可执行文件"), + "app-server-launch-failed" + ); + assert_eq!( + classify_direct_codex_error("Claude Code 返回失败终态"), + "claude-code-failed" + ); + } + + #[test] + fn unclassified_failures_get_a_dedicated_code() { + // 任何一个未识别原因都不能被读成某个具体故障:只允许专用兜底码。 + assert_eq!( + classify_direct_codex_error("某种尚未识别的新失败"), + "runtime-unclassified" + ); + assert_eq!( + classify_direct_codex_error("读取陶泥儿画布资源失败:"), + "runtime-unclassified" + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs index f888c3f61..9ad7bbdd8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs @@ -583,7 +583,8 @@ pub(in crate::agent) async fn observe_agent_runtime_asset_import( } } if !local_paths.is_empty() { - match import_local_project_assets_for_agent(root, &local_paths) { + match import_local_project_assets_for_agent_in_blocking_pool(root, &local_paths, None).await + { Ok(result) => imported.extend(result.assets.into_iter().map(|asset| { serde_json::json!({ "id": asset.id, @@ -709,6 +710,35 @@ pub(in crate::agent) fn observe_agent_runtime_project_search( mod asset_import_input_tests { use super::*; + #[tokio::test] + async fn runtime_local_import_waits_without_blocking_worker() { + let project = tempfile::tempdir().unwrap(); + let root = project.path(); + init_local_game_project_at(root, "runtime-import-wait", "Runtime 导入等待").unwrap(); + fs::write(root.join("game/import.md"), b"# Imported").unwrap(); + let observation = crate::tests::with_conversation_write_contention( + root, + async { + let observation = observe_agent_runtime_asset_import( + root, + &serde_json::json!({"localPaths": ["game/import.md"]}), + ) + .await; + if observation.status != "ok" { + return Err(format!("{observation:?}")); + } + Ok(observation) + }, + || {}, + ) + .await + .unwrap(); + let detail: serde_json::Value = + serde_json::from_str(observation.detail.as_deref().unwrap()).unwrap(); + assert_eq!(detail["imported"].as_array().unwrap().len(), 1); + assert_eq!(detail["revisionAdvanceCount"], 1); + } + #[test] fn asset_list_observation_keeps_candidates_beyond_default_tool_limit() { let temporary = tempfile::tempdir().expect("create asset context project root"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs index df4619c02..1df7ccf9a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs @@ -422,7 +422,8 @@ pub(super) const PROBE_PLAYTEST_CONTROL_SCRIPT: &str = r#"function() { } let stylesVisible = true; - let pointerBlocked = false; + // 后代可用 auto 覆盖祖先的 none;未覆盖时计算样式仍会继承 none。 + const pointerBlocked = getComputedStyle(this).pointerEvents === 'none'; for (let current = this; current instanceof HTMLElement; current = current.parentElement) { const style = getComputedStyle(current); const opacity = Number.parseFloat(style.opacity); @@ -431,7 +432,6 @@ pub(super) const PROBE_PLAYTEST_CONTROL_SCRIPT: &str = r#"function() { && style.visibility !== 'hidden' && style.visibility !== 'collapse' && (Number.isNaN(opacity) || opacity > 0); - pointerBlocked ||= style.pointerEvents === 'none'; } const rect = this.getBoundingClientRect(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs index 0d98e7c97..9d8733862 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs @@ -270,16 +270,12 @@ fn playtest_scenario_fingerprints_are_fixed_lowercase_sha256_values() { let tetris = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::TetrisV1); let lane = browser_playtest_scenario_fingerprint(BrowserPlaytestScenario::LaneDefenseV1); assert_eq!( - generic, - "e5e1b4d01e39cb7b0dd2fd5a56982c3ddf825a926738d99152875ee1ff412a8d" - ); - assert_eq!( - tetris, - "b3c49f43f70291ea5c9431a21bb751fead2e244c8e97fecec20ee3e38215a59c" - ); - assert_eq!( - lane, - "6a24072ce7a570dd29edac0ca3fa905546140e44ca4ab6412d8fc7fe1239aa5a" + [generic.as_str(), tetris.as_str(), lane.as_str()], + [ + "85e7ec677411745ad3a7385db67a2c89441cb2ccc12a7a1853bb87ba74f15e0d", + "60c202a9fe6313dd414365db52df8b5b63110971f89708ac1563d531f510f4ff", + "a3120c8cf585f2e06091229c3b7c3a5bd8aa08821704064edc611297ee54f466", + ] ); assert!(generic.bytes().all(|byte| byte.is_ascii_hexdigit())); assert!(tetris.bytes().all(|byte| byte.is_ascii_hexdigit())); @@ -1354,7 +1350,7 @@ async fn real_chrome_generic_playtest_rejects_one_frame_playing_state() { #[tokio::test] #[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] async fn real_chrome_generic_playtest_accepts_earlier_window_capture_stop_immediate_flow() { - let result = run_stable_generic_viewport_fixture(false).await; + let result = run_stable_generic_viewport_fixture(false, None).await; assert!( result.passed, "diagnostics={:#?}\nviewports={:#?}", @@ -1388,7 +1384,7 @@ async fn real_chrome_generic_playtest_accepts_earlier_window_capture_stop_immedi #[tokio::test] #[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] async fn real_chrome_generic_playtest_rejects_mobile_only_unwired_control() { - let result = run_stable_generic_viewport_fixture(true).await; + let result = run_stable_generic_viewport_fixture(true, None).await; assert!( !result.passed, "a successful desktop cannot hide mobile gameplay failure" @@ -1410,8 +1406,117 @@ async fn real_chrome_generic_playtest_rejects_mobile_only_unwired_control() { .any(|message| message.starts_with("playtest mobile:"))); } +#[tokio::test] +#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] +async fn real_chrome_generic_playtest_pointer_events_allows_auto_under_none() { + let result = run_stable_generic_viewport_fixture( + false, + Some( + r#"
+ +
"#, + ), + ) + .await; + assert!(result.passed, "{:?}", result.diagnostics); + for viewport in REQUIRED_VIEWPORTS { + let playtest = &result + .viewport_playtests + .iter() + .find(|entry| entry.viewport == viewport) + .expect("each viewport must have gameplay evidence") + .result; + assert!(playtest.passed, "{viewport:?}: {:?}", playtest.diagnostics); + assert_eq!(playtest.initial_sequence, Some(0)); + assert_eq!(playtest.final_sequence, Some(3)); + assert_eq!(playtest.final_phase, Some(BrowserPlaytestPhase::Ready)); + assert!(playtest.assertions.iter().all(|assertion| assertion.passed)); + } +} + +#[tokio::test] +#[ignore = "requires an installed Chrome/Chromium/Edge and explicit local browser execution"] +async fn real_chrome_generic_playtest_pointer_events_keeps_unavailable_controls_blocked() { + for (case, markup) in [ + ( + "inherited-none", + r#"
"#, + ), + ( + "own-none", + r#""#, + ), + ( + "own-none-child-auto", + r#""#, + ), + ( + "disabled", + r#""#, + ), + ( + "disabled-fieldset", + r#"
"#, + ), + ( + "aria-disabled", + r#""#, + ), + ( + "inert", + r#"
"#, + ), + ( + "hidden", + r#"
"#, + ), + ( + "covered", + r#"
"#, + ), + ] { + let result = run_stable_generic_viewport_fixture(false, Some(markup)).await; + assert!(!result.passed, "{case} must fail"); + for viewport in REQUIRED_VIEWPORTS { + let playtest = &result + .viewport_playtests + .iter() + .find(|entry| entry.viewport == viewport) + .expect("each viewport must have gameplay evidence") + .result; + assert!(!playtest.passed, "{case} / {viewport:?} must fail"); + assert!( + playtest + .diagnostics + .iter() + .any(|message| message.contains("固定试玩控件 start")), + "{case} / {viewport:?}: {:?}", + playtest.diagnostics + ); + assert!( + playtest.assertions.iter().any(|assertion| { + assertion.name == "start-control-clicked" && !assertion.passed + }), + "{case} / {viewport:?} must be rejected before input" + ); + if case == "own-none-child-auto" { + assert!( + playtest.diagnostics.iter().any(|message| { + message == "固定试玩控件 start 处于 disabled 状态" + }), + "{viewport:?}: child hit must not enable the pointer-blocked target: {:?}", + playtest.diagnostics + ); + } + } + } +} + async fn run_stable_generic_viewport_fixture( mobile_action_broken: bool, + start_markup: Option<&str>, ) -> BrowserValidationResult { use std::io::{Read, Write}; use std::net::TcpListener; @@ -1482,6 +1587,11 @@ async fn run_stable_generic_viewport_fixture( "#; let html = String::from_utf8_lossy(html) + .replace( + r#""#, + start_markup + .unwrap_or(r#""#), + ) .replace( "const mobileActionBroken = false;", if mobile_action_broken { 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 b5c910a84..565de0461 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1078,6 +1078,7 @@ fn select_game_creator_model_inner( model_id: String, is_default: bool, catalog_agent_mode: Option, + catalog_protocol: Option, ) -> Result { let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK .lock() @@ -1098,13 +1099,34 @@ fn select_game_creator_model_inner( if let Some(agent_mode) = catalog_agent_mode { config.agent_mode = match agent_mode.trim() { "codex" => GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string(), - // `cc` 是 Claude 执行器的历史别名,`anthropic` 是同一执行器的显式协议值。 - "cc" | "anthropic" => GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI.to_string(), + "cc" => GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI.to_string(), value => return Err(format!("模型 Agent 模式无效:{value}")), }; } + // 协议与执行器正交,但合法组合是封闭的;缺省按执行器推导。 + let protocol = match catalog_protocol.as_deref().map(str::trim) { + Some("openai_responses") => "openai_responses", + Some("openai_chat") => "openai_chat", + Some("anthropic") => "anthropic", + Some("") | None => { + if config.agent_mode == GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI { + "anthropic" + } else { + "openai_responses" + } + } + Some(value) => return Err(format!("模型协议无效:{value}")), + }; + let claude = config.agent_mode == GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI; + if claude != (protocol == "anthropic") { + return Err(format!( + "执行器与协议组合无效:{} 不支持 {protocol}", + config.agent_mode + )); + } config.selected_model_id = model_id; config.selected_model_is_default = is_default; + config.selected_model_protocol = Some(protocol.to_string()); persist_game_creator_app_config(config, overlays, true) } @@ -1113,8 +1135,9 @@ pub(crate) fn select_game_creator_model( model_id: String, is_default: bool, agent_mode: Option, + protocol: Option, ) -> Result { - select_game_creator_model_inner(model_id, is_default, agent_mode) + select_game_creator_model_inner(model_id, is_default, agent_mode, protocol) } fn persist_game_creator_app_config( @@ -1981,6 +2004,126 @@ mod agent_asset_import_tests { ); } + fn account_import_contention_downloads( + ) -> Vec<(AgentEditorAssetRecord, String, String, Vec)> { + vec![( + AgentEditorAssetRecord { + asset_id: "account-contention".into(), + origin: AgentEditorAssetOrigin::AccountLibrary, + canvas_project_id: None, + folder_id: None, + folder_label: None, + label: "账户争用测试".into(), + object_key: None, + image_src: None, + asset_object_id: None, + asset_kind: Some("image".into()), + source_type: None, + width: None, + height: None, + size_bytes: None, + }, + "image/png".into(), + "assets/uploads/account-contention.png".into(), + tiny_png(), + )] + } + + #[tokio::test] + async fn asset_imports_wait_for_conversation_without_blocking_or_losing_updates() { + let project = crate::tests::canonical_test_tempdir("import-contention-"); + let root = project.path(); + init_local_game_project_at(root, "import-contention", "导入争用").unwrap(); + fs::write(root.join("game/local.png"), tiny_png()).unwrap(); + let before = read_game_creator_agent_runtime_project_revision(root) + .unwrap() + .revision; + let local_paths = vec!["game/local.png".to_string()]; + let (local, account) = crate::tests::with_conversation_write_contention( + root, + async { + tokio::try_join!( + import_local_project_assets_for_agent_in_blocking_pool( + root, + &local_paths, + None, + ), + commit_account_editor_asset_downloads_in_blocking_pool( + root, + account_import_contention_downloads(), + None, + None, + ), + ) + }, + || {}, + ) + .await + .unwrap(); + let manifest = read_existing_manifest_for_project(root).unwrap(); + for result in [&local, &account] { + assert_eq!(result.assets.len(), 1); + let asset = &result.assets[0]; + assert!(manifest.assets.iter().any(|entry| entry.id == asset.id)); + assert_eq!(fs::read(root.join(&asset.local_path)).unwrap(), tiny_png()); + } + assert_eq!( + read_game_creator_agent_runtime_project_revision(root) + .unwrap() + .revision, + before + 2 + ); + } + + #[tokio::test] + async fn account_asset_commit_rechecks_session_after_conversation_wait() { + let _session_guard = install_test_platform_session( + "import-owner-a", + "import-token-a", + "https://import.example.test", + ); + let session = current_platform_session().unwrap(); + let project = crate::tests::canonical_test_tempdir("import-session-contention-"); + let root = project.path(); + init_local_game_project_at(root, "import-session-contention", "导入身份争用").unwrap(); + let before = fs::read(root.join(".agent/manifest.json")).unwrap(); + let before_revision = read_game_creator_agent_runtime_project_revision(root) + .unwrap() + .revision; + let generation = session.identity_generation + 1; + let error = crate::tests::with_conversation_write_contention( + root, + commit_account_editor_asset_downloads_in_blocking_pool( + root, + account_import_contention_downloads(), + None, + Some(session), + ), + || { + install_platform_session( + "import-owner-b", + "import-token-b", + "https://import.example.test", + generation, + generation, + ) + .unwrap(); + }, + ) + .await + .err() + .expect("旧账号不能在等待后提交"); + assert!(error.contains("authentication-required"), "{error}"); + assert_eq!(fs::read(root.join(".agent/manifest.json")).unwrap(), before); + assert_eq!( + read_game_creator_agent_runtime_project_revision(root) + .unwrap() + .revision, + before_revision + ); + assert!(!root.join("assets/uploads/account-contention.png").exists()); + } + #[test] fn local_project_asset_import_rejects_absolute_and_case_insensitive_agent_paths() { let project = crate::tests::canonical_test_tempdir("agent-local-import-"); @@ -3237,6 +3380,25 @@ pub(crate) fn import_local_project_assets_for_agent( import_local_project_assets_for_agent_with_write_permit(root, relative_paths, None) } +pub(crate) async fn import_local_project_assets_for_agent_in_blocking_pool( + root: &Path, + relative_paths: &[String], + write_permit: Option<&crate::agent::WritePermit>, +) -> Result { + let root = root.to_path_buf(); + let relative_paths = relative_paths.to_vec(); + let write_permit = write_permit.cloned(); + tokio::task::spawn_blocking(move || { + import_local_project_assets_for_agent_with_write_permit( + &root, + &relative_paths, + write_permit.as_ref(), + ) + }) + .await + .map_err(|error| format!("本地素材导入任务未返回:{error}"))? +} + pub(crate) fn import_local_project_assets_for_agent_with_write_permit( root: &Path, relative_paths: &[String], @@ -3301,7 +3463,11 @@ pub(crate) fn import_local_project_assets_for_agent_with_write_permit( )); } - let _lock = acquire_project_write_lock(root, "canvas.asset_import")?; + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "canvas.asset_import", + )?; + enforce_project_permission_policy(root, "canvas.asset_import")?; let commit = || { let manifest = read_existing_manifest_for_project(root)?; let mut imported = Vec::with_capacity(inputs.len()); @@ -3497,20 +3663,50 @@ pub(crate) async fn import_account_editor_assets_for_agent_with_write_permit( } access.validate_frozen_session()?; - let _platform_session_lease = frozen_session - .as_ref() - .map(|session| acquire_platform_session_identity_lease(&session.identity())) - .transpose()?; - // 身份租约覆盖本地提交;持租约期间不能再次读取登录态互斥锁。 - commit_account_editor_asset_downloads(root, downloads, write_permit) + commit_account_editor_asset_downloads_in_blocking_pool( + root, + downloads, + write_permit, + frozen_session, + ) + .await +} + +async fn commit_account_editor_asset_downloads_in_blocking_pool( + root: &Path, + downloads: Vec<(AgentEditorAssetRecord, String, String, Vec)>, + write_permit: Option<&crate::agent::WritePermit>, + frozen_session: Option, +) -> Result { + let root = root.to_path_buf(); + let write_permit = write_permit.cloned(); + tokio::task::spawn_blocking(move || { + commit_account_editor_asset_downloads( + &root, + downloads, + write_permit.as_ref(), + frozen_session.as_ref(), + ) + }) + .await + .map_err(|error| format!("账户素材提交任务未返回:{error}"))? } fn commit_account_editor_asset_downloads( root: &Path, downloads: Vec<(AgentEditorAssetRecord, String, String, Vec)>, write_permit: Option<&crate::agent::WritePermit>, + frozen_session: Option<&PlatformSessionSnapshot>, ) -> Result { - let _lock = acquire_project_write_lock(root, "canvas.asset_import")?; + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "canvas.asset_import", + )?; + enforce_project_permission_policy(root, "canvas.asset_import")?; + // 等待后才取得身份租约;等待期间允许换号,提交时拒绝旧账号结果。 + let _platform_session_lease = frozen_session + .map(|session| acquire_platform_session_identity_lease(&session.identity())) + .transpose()?; let commit = || { let manifest = read_existing_manifest_for_project(root)?; let mut imported = Vec::with_capacity(downloads.len()); @@ -3639,6 +3835,7 @@ pub(crate) fn commit_account_asset_for_write_permit_test( bytes.to_vec(), )], Some(write_permit), + None, ) } 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 6eb153861..294a3b49d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -583,7 +583,12 @@ fn check_game_creator_codex_app_server_available() -> Result<(), String> { .stdin(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .output() - .map_err(|_| "Codex CLI 不支持 app-server 子命令".to_string())?; + .map_err(|error| { + // 进程起不来(可执行文件缺失、路径错误、权限不足或被安全软件拦截)与 + // 「不支持 app-server 子命令」是两码事:报成后者会让用户去升级 CLI, + // 升级完仍是同一个错。这里保留真实 OS 错误。 + format!("Codex CLI 启动失败:{error}") + })?; if !output.status.success() || !String::from_utf8_lossy(&output.stdout).contains("codex app-server") { @@ -4037,6 +4042,9 @@ fn merge_game_creator_config_content( if let Some(selected_model_is_default) = file_config.selected_model_is_default { config.selected_model_is_default = selected_model_is_default; } + if let Some(selected_model_protocol) = file_config.selected_model_protocol { + config.selected_model_protocol = Some(selected_model_protocol); + } Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs index 4378f32d7..8fa08dcd6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs @@ -302,6 +302,46 @@ async fn host_npm( .map_err(str::to_string) } +/// 浏览器验证失败的宿主错误码。 +/// +/// 验证层会为不同原因返回不同文本:没有可用浏览器、启动失败或启动超时、宿主取消、 +/// 浏览器收束未确认、证据写入失败、输入或页面校验失败。把它们统一压成「浏览器有问题」, +/// 用户和值班人员都会朝浏览器方向排查却查不到原因,所以这里按真实原因分流; +/// 未识别的原因落回专用兜底码 `web-preflight-unclassified`,不再借用某个具体子系统。 +fn browser_validation_failure_code(error: &str) -> &'static str { + if error.contains("未发现可用的") { + "web-preflight-browser-missing" + } else if error.contains("启动浏览器超时") { + "web-preflight-browser-launch-timeout" + } else if error.contains("启动浏览器失败") { + "web-preflight-browser-launch-failed" + } else if error.contains("宿主已停止本轮浏览器验证") { + "web-preflight-cancelled" + } else if error.contains("browser-cleanup") + || error.contains("browser-tree-reap") + || error.contains("browser-version-invalid") + { + "web-preflight-browser-cleanup-failed" + } else if error.contains("浏览器临时") || error.contains("浏览器配置") { + "web-preflight-browser-environment-failed" + } else if error.contains("evidenceRoot") + || error.contains("证据") + || error.contains("浏览器验证报告") + || error.contains("浏览器验证截图") + { + "web-preflight-evidence-failed" + } else if error.contains("预览 URL") + || error.contains("viewports") + || error.contains("settleMs") + { + "web-preflight-input-invalid" + } else if error.contains("页面导航失败") || error.contains("截图不是有效 PNG") { + "web-preflight-page-check-failed" + } else { + "web-preflight-unclassified" + } +} + pub(crate) async fn host_web_creation_preflight() -> Value { let started = Instant::now(); // 预检前顺手清扫陈旧的无头浏览器,避免残留进程放大本轮超时。 @@ -322,7 +362,7 @@ pub(crate) async fn host_web_creation_preflight() -> Value { let validation = crate::browser::validate_local_preview_in_browser(BrowserValidationInput { url: preview.url, viewports: vec![BrowserValidationViewport::Desktop, BrowserValidationViewport::Mobile], expected_text: vec![], settle_ms: 100, fail_on_console_error: true, playtest_scenario: None, evidence_root: root.join("evidence"), }).await; - let result = validation.map_err(|_| "web-preflight-browser-failed")?; + let result = validation.map_err(|error| browser_validation_failure_code(&error).to_string())?; if !result.passed || result.viewport_results.len() != 2 { return Err("web-preflight-page-check-failed".into()); } let mut pngs = Vec::new(); for viewport in result.viewport_results { @@ -382,6 +422,24 @@ fn claim_preparation(root: &Path) -> Result String { + let candidate = error + .split(|ch: char| !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')) + .find(|token| { + token.len() >= 4 + && token.chars().any(|ch| ch.is_ascii_lowercase()) + && token.chars().any(|ch| ch == '-') + }); + candidate + .unwrap_or("web-scaffold-preparation-failed") + .to_string() +} + pub(crate) async fn prepare_new_web_project_at( root: &Path, creation_type: Option<&str>, @@ -457,7 +515,7 @@ pub(crate) async fn prepare_new_web_project_at( receipt.last_failure_code = result .as_ref() .err() - .map(|_| "web-scaffold-preparation-failed".into()); + .map(|error| scaffold_failure_code(error)); if result.is_ok() { let entry = fs::read(ordinary_path(root, "game/dist/index.html")?) .map_err(|_| "web-scaffold-dist-missing")?; @@ -474,6 +532,78 @@ pub(crate) async fn prepare_new_web_project_at( mod tests { use super::*; + #[test] + fn browser_validation_failures_keep_their_own_reason_codes() { + assert_eq!( + browser_validation_failure_code( + "未发现可用的 Google Chrome、Chromium 或 Microsoft Edge" + ), + "web-preflight-browser-missing" + ); + assert_eq!( + browser_validation_failure_code("启动浏览器超时"), + "web-preflight-browser-launch-timeout" + ); + assert_eq!( + browser_validation_failure_code("启动浏览器失败:2"), + "web-preflight-browser-launch-failed" + ); + assert_eq!( + browser_validation_failure_code("宿主已停止本轮浏览器验证"), + "web-preflight-cancelled" + ); + assert_eq!( + browser_validation_failure_code( + "browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程" + ), + "web-preflight-browser-cleanup-failed" + ); + assert_eq!( + browser_validation_failure_code("创建浏览器临时目录失败:拒绝访问"), + "web-preflight-browser-environment-failed" + ); + assert_eq!( + browser_validation_failure_code("evidenceRoot 必须是真实目录且不能是符号链接"), + "web-preflight-evidence-failed" + ); + assert_eq!( + browser_validation_failure_code("只允许带显式端口的 http://127.0.0.1 预览 URL"), + "web-preflight-input-invalid" + ); + assert_eq!( + browser_validation_failure_code("desktop 页面导航失败:timeout"), + "web-preflight-page-check-failed" + ); + // 未识别的原因必须落回专用兜底码,不能借上面任何一个具体子系统码。 + assert_eq!( + browser_validation_failure_code("某种尚未识别的新失败"), + "web-preflight-unclassified" + ); + } + + #[test] + fn scaffold_receipt_keeps_the_real_failure_code() { + assert_eq!( + scaffold_failure_code("web-build-timeout"), + "web-build-timeout" + ); + assert_eq!( + scaffold_failure_code("Web 新项目依赖准备失败:web-build-command-failed;尚未启动生成"), + "web-build-command-failed" + ); + assert_eq!( + scaffold_failure_code( + "Web 游戏环境预检未通过:web-preflight-browser-missing;尚未启动生成" + ), + "web-preflight-browser-missing" + ); + // 拿不到机器码时才使用专用兜底。 + assert_eq!( + scaffold_failure_code("没有机器码的中文说明"), + "web-scaffold-preparation-failed" + ); + } + #[test] fn only_new_unmodified_scaffolds_receive_a_preparation_receipt() { let root = tempfile::tempdir().unwrap(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs index 0944e4f9f..7e89cb64f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs @@ -16,6 +16,10 @@ fn default_codex_agent_mode() -> String { "codex".to_string() } +fn default_model_protocol() -> String { + "openai_responses".to_string() +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct WireModelSummary { @@ -23,6 +27,9 @@ struct WireModelSummary { display_name: String, #[serde(default = "default_codex_agent_mode")] agent_mode: String, + /// 线协议与执行器正交;旧服务端不下发时按 OpenAI Responses 兼容。 + #[serde(default = "default_model_protocol")] + protocol: String, } #[derive(Debug, Deserialize)] @@ -56,6 +63,7 @@ fn map_catalog(catalog: WireModelCatalog) -> LlmModelsResponse { id: model.id, display_name: model.display_name, agent_mode: model.agent_mode, + protocol: model.protocol, }) .collect(), revision: catalog.revision, 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 ea1b8dc56..befa993db 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -812,6 +812,9 @@ struct GameCreatorAppConfigFile { selected_model_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] selected_model_is_default: Option, + /// 所选模型的上游线协议,与 `agent_mode`(执行器)正交。 + #[serde(default, skip_serializing_if = "Option::is_none")] + selected_model_protocol: Option, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] @@ -873,6 +876,9 @@ struct GameCreatorAppConfig { selected_model_id: String, #[serde(default)] selected_model_is_default: bool, + /// 所选模型的上游线协议;缺省按执行器推导。 + #[serde(default)] + selected_model_protocol: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -1384,6 +1390,7 @@ impl Default for GameCreatorAppConfig { editor_api: GameCreatorEditorApiConfig::default(), selected_model_id: String::new(), selected_model_is_default: false, + selected_model_protocol: None, } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index dd1c045bb..d5089ff54 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -1,5 +1,8 @@ use super::*; +#[cfg(test)] +mod listing_tests; + /** * Cocos Creator 工程根目录下的**生成目录**(导入缓存、构建临时目录、编辑器本地配置)。 * @@ -18,6 +21,24 @@ fn is_engine_generated_root_directory(relative_path: &str) -> bool { pub(crate) fn list_local_project_files_at( root: &Path, +) -> Result { + list_local_project_files_scoped_at(root, None, |_| false) +} + +/// 在读取元数据前裁剪范围;调用方的可见性规则不改变通用文件树。 +pub(crate) fn list_local_project_files_scoped_at( + root: &Path, + scope: Option<&str>, + exclude: impl Fn(&str) -> bool, +) -> Result { + list_local_project_files_with_metadata(root, scope, exclude, |path| fs::symlink_metadata(path)) +} + +fn list_local_project_files_with_metadata( + root: &Path, + scope: Option<&str>, + exclude: impl Fn(&str) -> bool, + mut read_metadata: impl FnMut(&Path) -> std::io::Result, ) -> Result { validate_project_root(root)?; if !root.exists() { @@ -37,28 +58,58 @@ pub(crate) fn list_local_project_files_at( */ let skip_engine_generated_directories = discover_local_cocos_project_root(root)?.is_some(); let is_unity_project = discover_local_unity_project_root(root)?.is_some(); + let scope_prefix = scope.map(|path| format!("{path}/")); let mut files = Vec::new(); let mut dirs = vec![root.to_path_buf()]; while let Some(dir) = dirs.pop() { - for entry in fs::read_dir(&dir) - .map_err(|error| format!("读取项目目录失败:{}: {error}", dir.display()))? - { + // 目录排队后可能消失或被换成链接,进入前重新核验。 + let metadata = match read_metadata(&dir) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(format!("读取文件元数据失败:{}: {error}", dir.display())), + }; + if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { + continue; + } + let entries = match fs::read_dir(&dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => return Err(format!("读取项目目录失败:{}: {error}", dir.display())), + }; + for entry in entries { let entry = entry.map_err(|error| format!("读取项目文件失败:{}: {error}", dir.display()))?; let path = entry.path(); - let metadata = fs::symlink_metadata(&path) - .map_err(|error| format!("读取文件元数据失败:{}: {error}", path.display()))?; - if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { + let relative_path = relative_project_path(root, &path)?; + let in_scope = scope.is_none_or(|scope| { + relative_path == scope + || scope_prefix + .as_ref() + .is_some_and(|prefix| relative_path.starts_with(prefix)) + }); + let scope_ancestor = + scope.is_some_and(|scope| scope.starts_with(&format!("{relative_path}/"))); + if (!in_scope && !scope_ancestor) || exclude(&relative_path) { continue; } - let file_type = metadata.file_type(); - let relative_path = relative_project_path(root, &path)?; if is_agent_runtime_private_control_path(&relative_path) || is_agent_checkpoint_control_path(&relative_path) || is_agent_workbench_control_path(&relative_path) { continue; } + let metadata = match read_metadata(&path) { + Ok(metadata) => metadata, + // read_dir 只观察名字,不保证随后还能读取该条目。 + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(format!("读取文件元数据失败:{}: {error}", path.display())) + } + }; + if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { + continue; + } + let file_type = metadata.file_type(); let modified_at = metadata .modified() .ok() @@ -80,14 +131,16 @@ pub(crate) fn list_local_project_files_at( { continue; } - files.push(LocalProjectFileEntry { - path: relative_path, - kind: "directory".to_string(), - size: 0, - modified_at, - }); + if in_scope { + files.push(LocalProjectFileEntry { + path: relative_path, + kind: "directory".to_string(), + size: 0, + modified_at, + }); + } dirs.push(path); - } else if file_type.is_file() { + } else if file_type.is_file() && in_scope { let size = metadata.len(); files.push(LocalProjectFileEntry { path: relative_path, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem/listing_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem/listing_tests.rs new file mode 100644 index 000000000..2bf6134f1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem/listing_tests.rs @@ -0,0 +1,288 @@ +use super::*; +use std::io::{Error, ErrorKind}; +use std::sync::mpsc; +use std::time::Duration; + +#[test] +fn project_file_listing_survives_lock_release_between_enumeration_and_metadata() { + for scope in [None, Some("assets")] { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().canonicalize().unwrap(); + fs::create_dir(root.join("assets")).unwrap(); + fs::write(root.join("assets/hero.png"), b"image").unwrap(); + let (ready_tx, ready_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let (released_tx, released_rx) = mpsc::channel(); + let timeout = Duration::from_secs(10); + + std::thread::scope(|threads| { + let owner_root = &root; + let owner = threads.spawn(move || { + let lock = acquire_project_write_lock(owner_root, "file-listing-race").unwrap(); + ready_tx.send(()).unwrap(); + release_rx.recv_timeout(timeout).unwrap(); + drop(lock); + released_tx.send(()).unwrap(); + }); + ready_rx.recv_timeout(timeout).unwrap(); + let mut observed_gap = false; + let listed = list_local_project_files_with_metadata( + &root, + scope, + |_| false, + |path| { + if scope.is_some() { + assert!( + !path.starts_with(root.join(".agent")), + "定向列举不得检查锁目录" + ); + } + let trigger = if scope.is_some() { + root.join("assets") + } else { + root.join(PROJECT_WRITE_LOCK_PATH) + }; + if path == trigger && !observed_gap { + // 全量列举复现锁的 TOCTOU;定向列举在访问 assets 时同时释放锁。 + release_tx.send(()).unwrap(); + released_rx.recv_timeout(timeout).unwrap(); + let result = fs::symlink_metadata(path); + if scope.is_none() { + assert_eq!(result.as_ref().unwrap_err().kind(), ErrorKind::NotFound); + } + observed_gap = true; + return result; + } + fs::symlink_metadata(path) + }, + ) + .unwrap(); + owner.join().unwrap(); + assert!(observed_gap, "夹具必须在列举期间释放真实锁"); + assert!(listed + .files + .iter() + .any(|file| file.path == "assets/hero.png")); + assert!(!listed + .files + .iter() + .any(|file| file.path == PROJECT_WRITE_LOCK_PATH)); + }); + } +} + +#[test] +fn project_file_listing_prunes_scope_and_private_paths_before_metadata() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + for path in [ + "assets/nested", + "assets-other", + ".agent/runtime", + ".agent/checkpoints", + ".agent/workbench", + ] { + fs::create_dir_all(root.join(path)).unwrap(); + } + fs::write(root.join("assets/nested/hero.png"), b"image").unwrap(); + fs::write(root.join("assets/other.png"), b"other").unwrap(); + fs::write(root.join(".env"), b"test-only").unwrap(); + for scope in ["assets/nested", "assets/nested/hero.png"] { + let listed = list_local_project_files_with_metadata( + root, + Some(scope), + |_| false, + |path| { + if path != root + && path != root.join("assets") + && !path.starts_with(root.join("assets/nested")) + { + panic!("不应读取范围外元数据:{}", path.display()); + } + fs::symlink_metadata(path) + }, + ) + .unwrap(); + assert_eq!( + listed + .files + .iter() + .filter(|file| file.kind == "file") + .map(|file| file.path.as_str()) + .collect::>(), + ["assets/nested/hero.png"] + ); + } + let listed = list_local_project_files_with_metadata( + root, + None, + |path| path == ".env", + |path| { + for excluded in [ + ".env", + ".agent/runtime", + ".agent/checkpoints", + ".agent/workbench", + ] { + assert!( + !path.starts_with(root.join(excluded)), + "过滤必须早于元数据读取" + ); + } + fs::symlink_metadata(path) + }, + ) + .unwrap(); + assert!( + listed.files.iter().any(|file| file.path == ".agent"), + "通用文件树仍可见普通控制面目录" + ); +} + +#[test] +fn project_file_listing_skips_disappearing_entries_but_preserves_io_errors() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + fs::create_dir(root.join("assets")).unwrap(); + let target = root.join("assets/hero.png"); + fs::write(&target, b"image").unwrap(); + for kind in [ErrorKind::PermissionDenied, ErrorKind::Other] { + let error = list_local_project_files_with_metadata( + root, + Some("assets"), + |_| false, + |path| { + if path == target { + return Err(Error::new(kind, "listing-error-sentinel")); + } + fs::symlink_metadata(path) + }, + ) + .unwrap_err(); + assert!( + error.contains("hero.png") && error.contains("listing-error-sentinel"), + "{error}" + ); + } + let listed = list_local_project_files_with_metadata( + root, + Some("assets"), + |_| false, + |path| { + if path == target { + fs::remove_file(path).unwrap(); + } + fs::symlink_metadata(path) + }, + ) + .unwrap(); + assert!(!listed.files.iter().any(|file| file.kind == "file")); +} + +#[test] +fn project_file_listing_handles_queued_directory_disappearance_and_type_change() { + for change in ["before-metadata", "before-read-dir", "replace-with-file"] { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + let target = root.join("assets"); + fs::create_dir(&target).unwrap(); + let mut visits = 0; + let result = list_local_project_files_with_metadata( + root, + Some("assets"), + |_| false, + |path| { + if path == target { + visits += 1; + if visits == 2 { + let previous = fs::symlink_metadata(path); + fs::remove_dir(path).unwrap(); + if change == "before-read-dir" { + return previous; + } + if change == "replace-with-file" { + fs::write(path, b"not a directory").unwrap(); + } + } + } + fs::symlink_metadata(path) + }, + ); + assert_eq!(visits, 2); + if change == "replace-with-file" { + assert!(result.unwrap_err().contains("读取项目目录失败")); + } else { + assert!(!result.unwrap().files.iter().any(|file| file.kind == "file")); + } + } +} + +#[cfg(unix)] +#[test] +fn project_file_listing_reports_target_permission_errors_but_ignores_unrelated_ones() { + use std::os::unix::fs::PermissionsExt; + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + let blocked = root.join("blocked"); + fs::create_dir(&blocked).unwrap(); + fs::create_dir(root.join("assets")).unwrap(); + fs::write(root.join("assets/hero.png"), b"image").unwrap(); + fs::set_permissions(&blocked, fs::Permissions::from_mode(0o000)).unwrap(); + // 先恢复权限再断言,避免失败时影响临时目录回收。 + let scoped = list_local_project_files_scoped_at(root, Some("assets"), |_| false); + let blocked_result = list_local_project_files_scoped_at(root, Some("blocked"), |_| false); + let permission_denied = fs::read_dir(&blocked).is_err(); + fs::set_permissions(&blocked, fs::Permissions::from_mode(0o700)).unwrap(); + assert!(scoped + .unwrap() + .files + .iter() + .any(|file| file.path == "assets/hero.png")); + // root / CAP_DAC_OVERRIDE 环境不能制造真实 EACCES;注入用例仍覆盖错误分支。 + if permission_denied { + let error = blocked_result.unwrap_err(); + assert!( + error.contains("读取项目目录失败") && error.contains("blocked"), + "{error}" + ); + } +} + +#[cfg(unix)] +#[test] +fn project_file_listing_never_enters_linked_or_replaced_directories() { + use std::os::unix::fs::symlink; + let temporary = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let root = temporary.path(); + fs::write(outside.path().join("private.txt"), b"outside").unwrap(); + symlink(outside.path(), root.join("linked")).unwrap(); + fs::create_dir(root.join("assets")).unwrap(); + let mut visits = 0; + let listed = list_local_project_files_with_metadata( + root, + None, + |_| false, + |path| { + if path == root.join("assets") { + visits += 1; + if visits == 2 { + fs::remove_dir(path).unwrap(); + symlink(outside.path(), path).unwrap(); + } + } + fs::symlink_metadata(path) + }, + ) + .unwrap(); + assert_eq!(visits, 2); + assert!(!listed.files.iter().any(|file| file.kind == "file")); + for scope in ["linked", "linked/private.txt", "assets/private.txt"] { + assert!( + list_local_project_files_scoped_at(root, Some(scope), |_| false) + .unwrap() + .files + .is_empty() + ); + } +} 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 472dad7a1..407dbe42f 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 @@ -4302,7 +4302,8 @@ fn commit_resource_edit_asset_internal( reference_resource_ids: vec![source.canonical_resource_id.clone()], }, }; - let _project_lock = acquire_project_write_lock(root, "resource.edit")?; + let _project_lock = + acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "resource.edit")?; let mut manifest = read_existing_manifest_for_project(root)?; if manifest.project_id != input.expected_project_id { return Err("project-identity-conflict".to_string()); @@ -4615,6 +4616,10 @@ fn commit_resource_edit_asset_with_frozen_platform_session( ledger: &mut ResourceEditLedger, platform_session: Option<&PlatformSessionSnapshot>, ) -> Result { + // 项目锁先于平台身份租约,避免等待时挡住换号或与其它提交形成锁顺序反转。 + // 内层提交在同一线程复用项目锁,整个事务不能跨 await 或线程迁移。 + let _project_lock = + acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "resource.edit")?; with_frozen_resource_edit_platform_session(platform_session, || { commit_resource_edit_asset(root, input, source, prompt, asset_name, ledger) }) @@ -4696,7 +4701,10 @@ fn commit_resource_edit_version( ledger: &mut ResourceEditLedger, ) -> Result { let version_id = format!("edit-{}", input.operation_id); - let _project_lock = acquire_project_write_lock(root, "resource.edit.version")?; + let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "resource.edit.version", + )?; let mut manifest = read_existing_manifest_for_project(root)?; if manifest.project_id != input.expected_project_id { return Err("project-identity-conflict".to_string()); @@ -5518,7 +5526,7 @@ pub(crate) async fn derive_local_project_resource_at( let root = Path::new(input.project_path.trim()); validate_project_root(root)?; let operation_lock = resource_edit_operation_lock(root, &input.operation_id)?; - let _operation_guard = operation_lock.lock().await; + let operation_guard = operation_lock.lock_owned().await; let manifest = read_existing_manifest_for_project(root)?; if manifest.project_id != input.expected_project_id { return Err("project-identity-conflict".to_string()); @@ -5652,23 +5660,36 @@ pub(crate) async fn derive_local_project_resource_at( ledger } }; - if ledger.phase == ResourceEditLedgerPhase::Committed { + if ledger.phase == ResourceEditLedgerPhase::Committed + || input.edit_kind == LocalProjectResourceEditKind::Version + { let project_lock = resource_edit_project_mutation_lock(root)?; - let _project_guard = project_lock.lock().await; - let _project_write_lock = acquire_project_write_lock(root, "resource.edit.cleanup")?; - cleanup_committed_resource_edit_staging(root, &mut ledger)?; - return committed_resource_edit_result( - root, - &input, - &source.canonical_resource_id, - ledger.result_asset_id.as_deref(), - ledger.result_version_id.as_deref(), - ); - } - if input.edit_kind == LocalProjectResourceEditKind::Version { - let project_lock = resource_edit_project_mutation_lock(root)?; - let _project_guard = project_lock.lock().await; - return commit_resource_edit_version(root, &input, &source, &prompt, &mut ledger); + let project_guard = project_lock.lock_owned().await; + let root = root.to_path_buf(); + return tokio::task::spawn_blocking(move || { + // 调用方取消等待时,互斥仍由正在运行的提交持有。 + let _operation_guard = operation_guard; + let _project_guard = project_guard; + if ledger.phase == ResourceEditLedgerPhase::Committed { + let _project_write_lock = + acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "resource.edit.cleanup", + )?; + cleanup_committed_resource_edit_staging(&root, &mut ledger)?; + committed_resource_edit_result( + &root, + &input, + &source.canonical_resource_id, + ledger.result_asset_id.as_deref(), + ledger.result_version_id.as_deref(), + ) + } else { + commit_resource_edit_version(&root, &input, &source, &prompt, &mut ledger) + } + }) + .await + .map_err(|error| format!("资源编辑提交任务未返回:{error}"))?; } let remote_credentials = if input.edit_kind.is_remote_media() && ledger.phase != ResourceEditLedgerPhase::MediaDownloaded @@ -5775,16 +5796,28 @@ 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; - let result = commit_resource_edit_asset_with_frozen_platform_session( - root, - &input, - &source, - &prompt, - &asset_name, - &mut ledger, - frozen_platform_session.as_ref(), - )?; + let project_guard = project_lock.lock_owned().await; + let commit_root = root.to_path_buf(); + let commit_input = input.clone(); + let commit_session = frozen_platform_session.clone(); + let (result, ledger, _operation_guard, _project_guard) = + tokio::task::spawn_blocking(move || { + let operation_guard = operation_guard; + let project_guard = project_guard; + let result = commit_resource_edit_asset_with_frozen_platform_session( + &commit_root, + &commit_input, + &source, + &prompt, + &asset_name, + &mut ledger, + commit_session.as_ref(), + )?; + // 正常返回仍覆盖后续 binding 写入;取消等待则在阻塞任务结束后释放。 + Ok::<_, String>((result, ledger, operation_guard, project_guard)) + }) + .await + .map_err(|error| format!("资源编辑提交任务未返回:{error}"))??; if let (Some((api_base_url, api_key, _)), Some(principal)) = (remote_credentials.as_ref(), binding_principal.as_ref()) { @@ -6462,7 +6495,7 @@ mod tests { drop(other_project_guard); } - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[tokio::test] async fn project_mutation_lock_serializes_independent_asset_commits_without_lost_updates() { let directory = tempfile::tempdir().expect("create concurrent commit fixture"); let root = directory.path(); @@ -6513,14 +6546,31 @@ mod tests { requests.push(request); } - let (first, second) = tokio::join!( - derive_local_project_resource_at(requests[0].clone()), - derive_local_project_resource_at(requests[1].clone()) - ); - first.expect("commit first operation"); - second.expect("commit second operation"); + fs::write(root.join("game/parallel.md"), b"# Imported").unwrap(); + let local_paths = vec!["game/parallel.md".to_string()]; + let (_, _, imported) = crate::tests::with_conversation_write_contention( + root, + async { + tokio::try_join!( + derive_local_project_resource_at(requests[0].clone()), + derive_local_project_resource_at(requests[1].clone()), + crate::commands::import_local_project_assets_for_agent_in_blocking_pool( + root, + &local_paths, + None, + ), + ) + }, + || {}, + ) + .await + .expect("并行导入和资源提交必须等待后成功"); let committed = read_existing_manifest_for_project(root).expect("read committed manifest"); + assert!(committed + .assets + .iter() + .any(|asset| asset.id == imported.assets[0].id)); for request in &requests { assert_eq!( committed @@ -6535,10 +6585,185 @@ mod tests { read_game_creator_agent_runtime_project_revision(root) .expect("read committed revision") .revision, - 2 + 3 ); } + fn staged_background_removal_for_contention(root: &Path) -> DeriveLocalProjectResourceInput { + init_local_game_project_at(root, PROJECT_ID, "资源提交争用").unwrap(); + let uploaded = + upload_local_asset_at(root, "source.png", "image/png", &resource_editor_test_png()) + .unwrap(); + let manifest = read_existing_manifest_for_project(root).unwrap(); + let mut request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::BackgroundRemoval, + format!("asset:{}", uploaded.id), + ); + request.source_asset_id = Some(uploaded.id); + request.source_path = Some(uploaded.local_path); + request.source_media_type = Some("image/png".into()); + let source = resolve_resource_edit_source(root, &manifest, &request).unwrap(); + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::MediaDownloaded); + ledger.request_fingerprint = resource_edit_request_fingerprint( + &request, + &source, + &request.prompt, + &request.asset_name, + ) + .unwrap(); + ledger.remote_operation_id = Some("completed-background-removal".into()); + ledger.remote_object_key = Some("generated/completed.png".into()); + ledger.staged_media_type = Some("image/png".into()); + ledger.staged_extension = Some("png".into()); + write_resource_edit_ledger(root, &ledger).unwrap(); + write_resource_edit_staging(root, &request.operation_id, &resource_editor_test_png()) + .unwrap(); + request + } + + #[tokio::test] + async fn downloaded_resource_commit_and_replay_wait_without_new_remote_work() { + let _session_guard = clear_test_platform_session(); + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + let request = staged_background_removal_for_contention(root); + let before = read_game_creator_agent_runtime_project_revision(root) + .unwrap() + .revision; + // 不提供远端凭据:下载完成后的提交与重放必须只使用原账本和 staging。 + let result = crate::tests::with_conversation_write_contention( + root, + derive_local_project_resource_at(request.clone()), + || {}, + ) + .await + .unwrap(); + assert_eq!(result.operation_id, request.operation_id); + assert_eq!(result.committed_project_revision, before + 1); + let ledger = read_resource_edit_ledger(root, &request.operation_id) + .unwrap() + .unwrap(); + assert_eq!(ledger.phase, ResourceEditLedgerPhase::Committed); + assert_eq!(ledger.idempotency_key, request.idempotency_key); + assert_eq!( + ledger.remote_operation_id.as_deref(), + Some("completed-background-removal") + ); + let replay = crate::tests::with_conversation_write_contention( + root, + derive_local_project_resource_at(request.clone()), + || {}, + ) + .await + .unwrap(); + assert_eq!(replay.operation_id, result.operation_id); + assert_eq!( + replay.committed_project_revision, + result.committed_project_revision + ); + assert_eq!(replay.asset, result.asset); + assert_eq!( + replay + .manifest + .assets + .iter() + .filter(|asset| asset.id == format!("edit-{}", request.operation_id)) + .count(), + 1 + ); + } + + #[tokio::test] + async fn cancelled_resource_commit_wait_keeps_operation_and_project_guards() { + let _session_guard = clear_test_platform_session(); + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + let request = staged_background_removal_for_contention(root); + let operation_lock = resource_edit_operation_lock(root, &request.operation_id).unwrap(); + let project_lock = resource_edit_project_mutation_lock(root).unwrap(); + let before = read_game_creator_agent_runtime_project_revision(root) + .unwrap() + .revision; + crate::tests::with_conversation_write_contention( + root, + async { + let mut commit = Box::pin(derive_local_project_resource_at(request.clone())); + assert!(futures::poll!(commit.as_mut()).is_pending()); + drop(commit); + // JoinHandle 被丢弃后阻塞任务仍会继续,互斥必须随任务保留。 + assert!(operation_lock.try_lock().is_err()); + assert!(project_lock.try_lock().is_err()); + let _finished = + tokio::time::timeout(Duration::from_secs(10), operation_lock.lock()) + .await + .expect("阻塞提交应在项目锁释放后完成"); + Ok(()) + }, + || {}, + ) + .await + .unwrap(); + assert_eq!( + read_resource_edit_ledger(root, &request.operation_id) + .unwrap() + .unwrap() + .phase, + ResourceEditLedgerPhase::Committed + ); + assert_eq!( + read_game_creator_agent_runtime_project_revision(root) + .unwrap() + .revision, + before + 1 + ); + assert!(project_lock.try_lock().is_ok()); + } + + #[tokio::test] + async fn resource_commit_timeout_preserves_staging_and_resumes_original_operation() { + let _session_guard = clear_test_platform_session(); + let directory = tempfile::tempdir().unwrap(); + let root = directory.path(); + let request = staged_background_removal_for_contention(root); + let before = read_game_creator_agent_runtime_project_revision(root) + .unwrap() + .revision; + let ledger_before = read_resource_edit_ledger(root, &request.operation_id).unwrap(); + let lock = acquire_project_write_lock(root, "conversation.write").unwrap(); + let started = std::time::Instant::now(); + let error = derive_local_project_resource_at(request.clone()) + .await + .unwrap_err(); + assert!( + started.elapsed() >= Duration::from_secs(9), + "未使用完整有界等待:{error}" + ); + assert!(error.contains("commandId=conversation.write"), "{error}"); + assert!(error.contains("ownerIsSelf=true"), "{error}"); + assert_eq!( + read_resource_edit_ledger(root, &request.operation_id).unwrap(), + ledger_before + ); + assert_eq!( + read_resource_edit_staging(root, &request.operation_id).unwrap(), + resource_editor_test_png() + ); + assert_eq!( + read_game_creator_agent_runtime_project_revision(root) + .unwrap() + .revision, + before + ); + drop(lock); + let recovered = derive_local_project_resource_at(request.clone()) + .await + .unwrap(); + assert_eq!(recovered.operation_id, request.operation_id); + assert_eq!(recovered.committed_project_revision, before + 1); + } + #[test] fn edit_kind_provenance_uses_stable_kebab_case_values() { assert_eq!( 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 56d9b20f3..a90ab61fe 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 @@ -38,9 +38,9 @@ fn custom_llm_config_save_reload_and_selection_preserve_overlay_and_credentials( write_game_creator_config_atomically(&overlay, r#"{"llm":{"customEnabled":true,"apiKey":"fixture-key","baseUrl":"https://custom.example/v1","visibleModels":["a/v1","b:v2"]},"selectedModelId":"a/v1","selectedModelIsDefault":true}"#).unwrap(); let mut config = read_game_creator_app_config().unwrap().config; assert_eq!(config.llm.model, "a/v1"); - let selected = select_game_creator_model("b:v2".into(), false, None).unwrap(); + let selected = select_game_creator_model("b:v2".into(), false, None, None).unwrap(); assert_eq!(selected.config.llm.model, "b:v2"); - assert!(select_game_creator_model("not-listed".into(), false, None).is_err()); + assert!(select_game_creator_model("not-listed".into(), false, None, None).is_err()); config.llm.visible_models = vec!["b:v2".into()]; config.llm.api_key = "changed-fixture-key".into(); write_game_creator_app_config(config).unwrap(); @@ -817,6 +817,7 @@ fn app_config_commands_write_runtime_config_file() { agent_llm, selected_model_id: "default".to_string(), selected_model_is_default: false, + selected_model_protocol: None, }) .expect("write runtime config"); @@ -964,7 +965,7 @@ fn app_config_model_selection_only_updates_model_overlay() { ) .expect("write overlay"); let saved = - select_game_creator_model("new-model".to_string(), true, None).expect("select model"); + select_game_creator_model("new-model".to_string(), true, None, None).expect("select model"); let effective = load_game_creator_app_config().expect("effective config"); assert_eq!(saved.config.selected_model_id, "new-model"); assert_eq!(effective.selected_model_id, "new-model"); @@ -986,13 +987,13 @@ fn model_selection_default_flag_round_trips() { fs::create_dir_all(&root).expect("runtime config dir"); let _guard = use_test_runtime_config_dir(root.clone()); - let saved = - select_game_creator_model("quality".to_string(), true, None).expect("select default model"); + let saved = select_game_creator_model("quality".to_string(), true, None, None) + .expect("select default model"); assert_eq!(saved.config.selected_model_id, "quality"); assert!(saved.config.selected_model_is_default); - let saved = - select_game_creator_model("fast".to_string(), false, None).expect("select explicit model"); + let saved = select_game_creator_model("fast".to_string(), false, None, None) + .expect("select explicit model"); assert_eq!(saved.config.selected_model_id, "fast"); assert!(!saved.config.selected_model_is_default); @@ -1021,6 +1022,7 @@ fn app_config_write_rejects_invalid_api_kind() { agent_llm: BTreeMap::new(), selected_model_id: "default".to_string(), selected_model_is_default: false, + selected_model_protocol: None, }); assert!(result @@ -1048,6 +1050,7 @@ fn app_config_write_rejects_invalid_reasoning_effort() { agent_llm: BTreeMap::new(), selected_model_id: "default".to_string(), selected_model_is_default: false, + selected_model_protocol: None, }); assert!(result @@ -1075,6 +1078,7 @@ fn app_config_write_rejects_too_small_request_timeout() { agent_llm: BTreeMap::new(), selected_model_id: "default".to_string(), selected_model_is_default: false, + selected_model_protocol: None, }); assert!(result diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 37aa5d9eb..7eec7b3da 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -16,6 +16,44 @@ const MANIFEST_INVALIDATION_RELAY_TEST_ACCEPT_TIMEOUT: Duration = Duration::from const MANIFEST_INVALIDATION_RELAY_TEST_PAYLOAD_TIMEOUT: Duration = Duration::from_millis(500); const MANIFEST_INVALIDATION_RELAY_TEST_MAX_BYTES: usize = 64 * 1024; +/// 在另一线程持有真实项目锁;单线程 runtime 必须能在释放锁前推进心跳。 +pub(crate) async fn with_conversation_write_contention( + root: &Path, + operation: impl std::future::Future>, + while_waiting: impl FnOnce(), +) -> Result { + let root = root.to_path_buf(); + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let holder = std::thread::spawn(move || { + let _lock = acquire_project_write_lock(&root, "conversation.write").unwrap(); + ready_tx.send(()).unwrap(); + // 若回归为 runtime 同步阻塞,限时释放以让断言失败而不是挂死测试。 + let _ = release_rx.recv_timeout(Duration::from_secs(5)); + }); + ready_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + let finished = AtomicBool::new(false); + let started = Instant::now(); + let (result, ()) = tokio::join!( + async { + let result = operation.await; + finished.store(true, Ordering::SeqCst); + result + }, + async { + tokio::time::sleep(Duration::from_millis(100)).await; + let elapsed = started.elapsed(); + let completed_while_locked = finished.load(Ordering::SeqCst); + while_waiting(); + let _ = release_tx.send(()); + assert!(elapsed < Duration::from_secs(3), "项目锁等待阻塞了 runtime"); + assert!(!completed_while_locked, "项目锁释放前操作已返回"); + } + ); + holder.join().unwrap(); + result +} + // 委派测试显式持有与正式 action 执行链相同的项目写锁,随后调用现役核心。 fn observe_agent_runtime_agent_delegate( root: &Path, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index f1ed549ff..0ec6629fe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -3860,6 +3860,23 @@ fn local_project_file_listing_skips_engine_generated_directories_only_for_engine .expect("write cocos package.json"); let listed = list_local_project_files_at(cocos).expect("list cocos project files"); + for scope in ["library", "library/imported/hero.json", "temp/programming"] { + assert!( + list_local_project_files_scoped_at(cocos, Some(scope), |_| false) + .expect("list generated scope") + .files + .is_empty() + ); + } + fs::create_dir_all(cocos.join("assets/library")).unwrap(); + fs::write(cocos.join("assets/library/kept.png"), b"image").unwrap(); + assert!( + list_local_project_files_scoped_at(cocos, Some("assets/library"), |_| false) + .unwrap() + .files + .iter() + .any(|file| file.path == "assets/library/kept.png") + ); let paths = listed .files .iter() diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/run_workflow.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/run_workflow.rs index a92e00b21..5c937bc74 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/run_workflow.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/run_workflow.rs @@ -122,7 +122,7 @@ pub(crate) async fn run_ui_design_doc_workflow( ) .await?; // 切图登记按路径复用 manifest 条目;失败只回报说明,不回滚已登记资源。 - let cut_images = register_cut_image_sprites(root, &dto)?; + let cut_images = register_cut_image_sprites(root, &dto).await?; let mut backfill_errors = cut_images.errors; progress.state = add_sprite_assets(&progress.state, &cut_images.sprites)?; backfill_errors.extend(apply_separation( diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/steps/separate/cut_images.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/steps/separate/cut_images.rs index 28997580e..795e8bd49 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/steps/separate/cut_images.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/agent_tools/steps/separate/cut_images.rs @@ -6,7 +6,7 @@ //! 说明,不回滚已经登记成功的资源。 use super::normalize_cut_image_path; -use crate::import_local_project_assets_for_agent; +use crate::import_local_project_assets_for_agent_in_blocking_pool; use crate::ui_editor::commands::SeparationDTO; use crate::ui_editor::resource::sprite::SpriteAsset; use crate::ui_editor::utils::SpriteAssetId; @@ -29,7 +29,7 @@ pub(crate) struct CutImageSprites { pub(crate) errors: Vec, } -pub(crate) fn register_cut_image_sprites( +pub(crate) async fn register_cut_image_sprites( root: &Path, dto: &SeparationDTO, ) -> Result { @@ -43,7 +43,8 @@ pub(crate) fn register_cut_image_sprites( } let mut imported_by_path: Vec<(String, ImportedAsset)> = Vec::new(); for batch in unique_paths.chunks(CUT_IMAGE_IMPORT_BATCH_SIZE) { - let imported = import_local_project_assets_for_agent(root, batch)?; + let imported = + import_local_project_assets_for_agent_in_blocking_pool(root, batch, None).await?; if imported.assets.len() != batch.len() { return Err(format!( "本地资源登记结果数量不匹配:请求 {} 个,返回 {} 个", @@ -140,6 +141,36 @@ mod tests { } } + #[tokio::test] + async fn cut_image_registration_waits_without_blocking_worker() { + let project = tempfile::tempdir().unwrap(); + let root = project.path(); + crate::init_local_game_project_at(root, "cut-import-wait", "切图导入等待").unwrap(); + image::DynamicImage::new_rgba8(2, 2) + .save(root.join("game/cut.png")) + .unwrap(); + let dto = SeparationDTO { + bound_nodes: vec![bound("game/cut.png"), bound("game/cut.png")], + problematic_nodes: Vec::new(), + }; + let result = crate::tests::with_conversation_write_contention( + root, + register_cut_image_sprites(root, &dto), + || {}, + ) + .await + .unwrap(); + assert_eq!(result.sprites.len(), 1); + assert!(result.by_path.contains_key("game/cut.png")); + assert!(result.errors.is_empty()); + assert_eq!( + crate::read_game_creator_agent_runtime_project_revision(root) + .unwrap() + .revision, + 1 + ); + } + #[test] fn unique_paths_normalize_separators_and_drop_duplicates() { let dto = SeparationDTO { diff --git a/apps/ai-game-creator-shell/src/features/app-shell/homeCreationFailure.ts b/apps/ai-game-creator-shell/src/features/app-shell/homeCreationFailure.ts new file mode 100644 index 000000000..2ac9e3fbe --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/app-shell/homeCreationFailure.ts @@ -0,0 +1,116 @@ +/** + * 首页自动创建失败的用户可见文案与证据出口。 + * + * 两条硬约束: + * + * 1. 首页状态区只显示**本模块算出来的**文案。宿主返回的原始文本(Provider 英文、IPC 报错、 + * 测试夹具字符串)一律不上屏,只作为证据写进 `application.log`;原来的写法把整段底层文本 + * 透传上屏,或者反过来把它覆盖成通用文案,两种都不对。 + * 2. 兜底必须是专门的兜底文案,不能借用某个具体原因冒充(把 IPC 断连说成「环境预检未通过」、 + * 把已经建好的工作区说成「创建未完成」都属此类)。宿主给了机器码就显示机器码,给不出就 + * 落回本模块的专用兜底,并按「工作区是否已经建好」分成两条,避免用户重做一遍。 + */ + +/** 未分类失败的专用兜底文案。 */ +export const HOME_CREATION_UNCLASSIFIED_FAILURE = '创建未完成,请重试'; + +export const HOME_CREATION_AFTER_PROJECT_FAILURE = + '工作区已创建,但未能进入项目开发;请从「已创建的工作区」打开后重试。'; + +export const HOME_CREATION_IPC_FAILURE = + '与客户端本体的连接中断,创建未完成;请重启客户端后重试。'; + +export const HOME_CREATION_AFTER_PROJECT_IPC_FAILURE = + '工作区已创建;与客户端本体的连接中断,未能进入项目开发;请重启客户端后从「已创建的工作区」打开。'; + +/** WebView / Tauri 调用通道自身失联时的固定形状。 */ +const HOME_CREATION_TRANSPORT_PATTERNS = [ + 'failed to fetch', + 'ipc custom protocol failed', + 'window.__tauri__', +] as const; + +/** 宿主错误里的机器码(`project-revision-conflict`、`web-build-timeout` 这类连字符标识)。 */ +const HOME_CREATION_HOST_CODE = /[a-z][a-z0-9]*(?:-[a-z0-9]+)+/; + +/** + * 已经整理好用户文案的创建失败。 + * + * `diagnostic` 保留宿主原始文本,只用于日志与诊断;因为它可能包含 Provider、路径或凭据文本, + * 不允许直接进入首页状态区。 + */ +export class HomeCreationFailure extends Error { + readonly userMessage: string; + readonly diagnostic: string; + + constructor(userMessage: string, diagnostic: string) { + super(userMessage); + this.name = 'HomeCreationFailure'; + this.userMessage = userMessage; + this.diagnostic = diagnostic; + } +} + +export function homeCreationDiagnosticText(error: unknown): string { + if (error instanceof Error) return error.message.trim(); + if (typeof error === 'string') return error.trim(); + return ''; +} + +function isHomeCreationTransportText(text: string): boolean { + const normalized = text.toLowerCase(); + return HOME_CREATION_TRANSPORT_PATTERNS.some((pattern) => + normalized.includes(pattern), + ); +} + +/** + * 由宿主错误算出用户可见文案。 + * + * `projectCreated` 必须由调用方按自己掌握的事实传入:工作区已经建好时,文案要保留这个事实 + * 并指向「已创建的工作区」恢复入口。 + */ +export function homeCreationFailureMessage( + error: unknown, + options: { projectCreated?: boolean } = {}, +): string { + const projectCreated = options.projectCreated === true; + const text = homeCreationDiagnosticText(error); + if (isHomeCreationTransportText(text)) { + return projectCreated + ? HOME_CREATION_AFTER_PROJECT_IPC_FAILURE + : HOME_CREATION_IPC_FAILURE; + } + const code = HOME_CREATION_HOST_CODE.exec(text)?.[0]; + if (code) { + return projectCreated + ? `工作区已创建;未能进入项目开发(${code});请从「已创建的工作区」打开后重试。` + : `创建未完成(${code});请重试。`; + } + return projectCreated + ? HOME_CREATION_AFTER_PROJECT_FAILURE + : HOME_CREATION_UNCLASSIFIED_FAILURE; +} + +/** 包装成带证据的创建失败;控制器负责把 `diagnostic` 写进日志后再抛出。 */ +export function createHomeCreationFailure( + error: unknown, + options: { projectCreated?: boolean } = {}, +): HomeCreationFailure { + return new HomeCreationFailure( + homeCreationFailureMessage(error, options), + homeCreationDiagnosticText(error), + ); +} + +/** + * 首页出口:只认控制器包装过的文案。 + * + * 走到这里却不是 `HomeCreationFailure`,说明失败发生在控制器之外,只能落回专用兜底—— + * 绝不把未知文本原样渲染到状态区。 + */ +export function resolveHomeCreationFailureMessage(error: unknown): string { + return error instanceof HomeCreationFailure + ? error.userMessage + : HOME_CREATION_UNCLASSIFIED_FAILURE; +} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts b/apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts index 674160eb8..976be5cbd 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts @@ -1,8 +1,14 @@ import type { ProjectStartMode, TauriInvoke } from '../../app/types'; import type { HomeCreationType } from '../../view/home/useHomeDraftStore'; +/** + * 宿主返回 blocked 但没有携带原因码时的专用兜底文案。 + * + * 不能像旧文案那样写「请检查 Node/npm 或浏览器」:那是三个未被证实的候选原因, + * 用户照它排查会白费力气,而真正的宿主原因码在上面的分支里已经会原样显示。 + */ export const HOME_WEB_PREFLIGHT_FAILURE = - 'Web 游戏环境预检未通过,请检查 Node/npm 或浏览器后重试;尚未开始生成。'; + 'Web 游戏环境预检未通过,宿主没有返回具体原因码;尚未开始生成。'; export const HOME_WEB_PREFLIGHT_IPC_FAILURE = 'Web 游戏环境预检调用失败,客户端连接异常;请重启客户端后重试,尚未开始生成。'; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index bd065b4fa..33c5f5a7c 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -36,6 +36,7 @@ import { createClientOperation, transitionClientOperation, } from '../../services/clientOperation'; +import { appendWebviewLog } from '../../services/errorReporting'; import type { HomeAttachmentDraft, HomeCreationType, @@ -55,6 +56,10 @@ import { } from '../project-summary/projectSummary'; import { importDesignFiles } from '../project-workspace/importDesignFiles'; import { clearAclElevationDenials } from './aclElevation'; +import { + createHomeCreationFailure, + type HomeCreationFailure, +} from './homeCreationFailure'; import { ensureHomeWebCreationEnvironment, homeWebPreflightErrorMessage, @@ -416,6 +421,24 @@ export function useHomeProjectCreation({ resetLauncherHomeDraft(); } + /** + * 创建失败的统一收口。 + * + * 用户可见文案由 `createHomeCreationFailure` 按真实原因算出(宿主机器码 / 调用通道失联 / + * 专用未分类兜底),宿主原始文本只写进 `application.log` 作为证据,不渲染到首页状态区。 + */ + function failHomeCreation( + error: unknown, + projectCreated: boolean, + ): HomeCreationFailure { + const failure = createHomeCreationFailure(error, { projectCreated }); + if (failure.diagnostic) { + appendWebviewLog('error', [`[home-creation] ${failure.diagnostic}`]); + } + setStatus(failure.userMessage); + return failure; + } + async function createHomeProjectFromDirectory( nextProjectPath: string, creationType: HomeCreationType, @@ -439,6 +462,8 @@ export function useHomeProjectCreation({ ? beginProjectOpenAnalytics(invoke, 'create') : analytics; setStatus('正在创建项目'); + // 兜底文案必须区分「还没建」和「建好了但没进去」:只有 init 成功后才置位。 + let createdProjectPath: string | null = null; try { if (!skipNonEmptyCheck) { const nonEmpty = await invoke( @@ -471,29 +496,23 @@ export function useHomeProjectCreation({ name: projectNameFromPath(trimmedProjectPath), }, ); - try { - await enterCreatedHomeProject( - invoke, - result, - creationType, - prompt, - attachments, - startMode, - analytics, - ); - setStatus('已创建项目,正在开始智能创作'); - } catch (error) { - throw new Error( - `项目已创建;首条需求投递失败:${ - error instanceof Error ? error.message : String(error) - }`, - ); - } + createdProjectPath = result.projectPath; + // 这里失败时项目目录已经建好:文案由外层按「已建好」分类,不拼底层文本, + // 也不指认具体是哪一步失败(运行时切换、附件导入、进入工作台都可能抛)。 + await enterCreatedHomeProject( + invoke, + result, + creationType, + prompt, + attachments, + startMode, + analytics, + ); + setStatus('已创建项目,正在开始智能创作'); return '已创建项目并进入项目开发'; } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setStatus(message); - throw error; + // 目录已经建好时保留「已创建」这一事实,避免用户重做一遍。 + throw failHomeCreation(error, createdProjectPath !== null); } } @@ -955,17 +974,14 @@ export function useHomeProjectCreation({ return '已创建工作区并进入项目开发'; } catch (error) { // 项目目录已经建好了:把它登记进最近项目,用户可以直接打开,不必重新建一遍。 + // 文案交给外层统一按「已建好」分类,这里不拼底层文本。 rememberRecentWorkspace(result.projectPath); setHomeCreationOperation( transitionClientOperation(operation, 'retryable-failure', { scope: { projectPath: result.projectPath }, }), ); - const message = `工作区已创建;首条需求投递失败:${ - error instanceof Error ? error.message : String(error) - }`; - setStatus(message); - throw new Error(message); + throw error; } } catch (error) { setHomeCreationOperation( @@ -978,7 +994,7 @@ export function useHomeProjectCreation({ if (createdProjectPath) { rememberRecentWorkspace(createdProjectPath); } - throw error; + throw failHomeCreation(error, Boolean(createdProjectPath)); } finally { if (watchdogId !== undefined) { window.clearTimeout(watchdogId); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx index b7b44234b..2bc9df75c 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx @@ -19,6 +19,22 @@ import { refreshLlmModelCatalog, } from '../../services/llmModelCatalog'; +/** + * 线协议与执行器正交;目录没下发时按执行器推导默认协议,保证发往后端的组合始终合法。 + */ +function resolveModelProtocol( + model: { agentMode?: 'codex' | 'cc'; protocol?: string } | undefined, +): 'openai_responses' | 'openai_chat' | 'anthropic' { + if ( + model?.protocol === 'openai_responses' || + model?.protocol === 'openai_chat' || + model?.protocol === 'anthropic' + ) { + return model.protocol; + } + return model?.agentMode === 'cc' ? 'anthropic' : 'openai_responses'; +} + export type ConversationModelSelectHandle = { /** 发送前校验:刷新目录,并在所选模型已停用/删除时回退默认模型。 */ ensureUsable: () => Promise; @@ -174,6 +190,9 @@ export function ConversationModelSelect({ agentMode: catalog.models.find((model) => model.id === next)?.agentMode ?? 'codex', + protocol: resolveModelProtocol( + catalog.models.find((model) => model.id === next), + ), }), ); } catch { @@ -341,11 +360,15 @@ export function ConversationModelSelect({ if (!invoke) throw new Error('Native host unavailable'); const agentMode = models.find((model) => model.id === id)?.agentMode ?? 'codex'; + const protocol = resolveModelProtocol( + models.find((model) => model.id === id), + ); const result = await queueConfigWrite(() => invoke('select_game_creator_model', { modelId: id, isDefault: false, agentMode, + protocol, }), ); if (result.config.selectedModelId !== id) 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 1b4a52659..0d62ed6dc 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 @@ -36,7 +36,7 @@ import { type GameCreatorLlmReasoningEffort, gameCreatorLlmReasoningEfforts, } from '../../app/types'; -import { checkForAppUpdate } from '../../services/appUpdate'; +import { checkForAppUpdateResult } from '../../services/appUpdate'; import { notifyLlmConfigChanged } from '../../services/llmModelCatalog'; import { AGC_PLUGIN_STATE_CHANGED_EVENT, @@ -84,12 +84,7 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = { }; type RuntimeSettingsSection = - | 'general' - | 'workspace' - | 'agents' - | 'extensions' - | 'advanced' - | 'about'; + 'general' | 'workspace' | 'agents' | 'extensions' | 'advanced' | 'about'; type RuntimeConfigToast = { tone: 'success' | 'error'; @@ -724,13 +719,21 @@ export function RuntimeConfigDialog({ setAppUpdateChecking(true); setAppUpdateStatus('正在检查更新…'); try { - const update = await checkForAppUpdate({ force: true }); - setAppUpdateStatus( - update - ? `发现新版本 v${update.version},可在更新弹窗中下载` - : '当前已是最新版本', - ); - } catch { + const result = await checkForAppUpdateResult({ force: true }); + if (result.status === 'available') { + setAppUpdateStatus( + `发现新版本 v${result.update.version},可在更新弹窗中下载`, + ); + } else if (result.status === 'current') { + setAppUpdateStatus('当前已是最新版本'); + } else if (result.status === 'failed') { + // 检查失败必须如实说明,不能显示成「当前已是最新版本」。 + setAppUpdateStatus(`检查更新失败:${result.message}`); + } else { + setAppUpdateStatus('当前渠道未启用更新检查'); + } + } catch (error) { + // 结果型入口已经收口了检查失败;这里只兜住调用方自身的意外抛错。 setAppUpdateStatus('检查更新失败,请稍后重试'); } finally { setAppUpdateChecking(false); diff --git a/apps/ai-game-creator-shell/src/services/appUpdate.ts b/apps/ai-game-creator-shell/src/services/appUpdate.ts index 188909bc8..12e82a9ec 100644 --- a/apps/ai-game-creator-shell/src/services/appUpdate.ts +++ b/apps/ai-game-creator-shell/src/services/appUpdate.ts @@ -19,8 +19,17 @@ export type AppUpdateProgress = { totalBytes?: number; }; +/** + * 更新检查结果。必须能区分「有更新 / 已是最新 / 检查失败」,失败不得冒充「已是最新」。 + */ +export type AppUpdateCheckResult = + | { status: 'available'; update: AppUpdateInfo } + | { status: 'current' } + | { status: 'failed'; message: string } + | { status: 'disabled' }; + let pendingUpdate: Update | null = null; -let updateCheckPromise: Promise | null = null; +let updateCheckResultPromise: Promise | null = null; const updateListeners = new Set<(update: AppUpdateInfo | null) => void>(); function toAppUpdateInfo(update: Update): AppUpdateInfo { @@ -31,29 +40,53 @@ function toAppUpdateInfo(update: Update): AppUpdateInfo { }; } -async function runAppUpdateCheck(): Promise { +function appUpdateFailureMessage(error: unknown): string { + const text = + error instanceof Error ? error.message.trim() : String(error ?? '').trim(); + return text || '更新服务未返回原因'; +} + +async function runAppUpdateCheck(): Promise { try { const update = await check(); pendingUpdate = update; const info = update ? toAppUpdateInfo(update) : null; updateListeners.forEach((listener) => listener(info)); - return info; - } catch { - // 清单 404、渠道缺少当前平台条目、网络或签名错误都按“无更新”收口,不阻塞启动。 + return info ? { status: 'available', update: info } : { status: 'current' }; + } catch (error) { + // 启动期仍然不阻塞(调用方是后台提示,不是用户操作),但这次检查必须记为失败: + // 清单 404、渠道缺少当前平台条目、网络或签名错误都不能再冒充「已是最新」。 pendingUpdate = null; - return null; + return { status: 'failed', message: appUpdateFailureMessage(error) }; } } -/** 同一客户端生命周期内只请求一次清单;`force` 供「关于」页手动检查使用。 */ -export function checkForAppUpdate( +/** + * 同一客户端生命周期内只请求一次清单;`force` 供「关于」页手动检查使用。 + * + * 手动检查必须用这个结果型入口:旧入口把失败也返回 null,界面就会显示「当前已是最新版本」。 + */ +export async function checkForAppUpdateResult( + options: { force?: boolean } = {}, +): Promise { + // 开发态(`agc` 启动)默认关闭更新检查:不请求清单,也不显示更新入口。 + if (!appUpdateCheckEnabled) return { status: 'disabled' }; + if (options.force) updateCheckResultPromise = null; + updateCheckResultPromise ??= runAppUpdateCheck(); + return updateCheckResultPromise; +} + +/** + * 只关心「有没有更新」的投影,供启动期更新提示使用。 + * + * 失败在这里仍然是 null(不阻塞启动)。**不要**再用这个 null 去显示「已是最新」—— + * 那需要 `checkForAppUpdateResult()`。 + */ +export async function checkForAppUpdate( options: { force?: boolean } = {}, ): Promise { - // 开发态(`agc` 启动)默认关闭更新检查:不请求清单,也不显示更新入口。 - if (!appUpdateCheckEnabled) return Promise.resolve(null); - if (options.force) updateCheckPromise = null; - updateCheckPromise ??= runAppUpdateCheck(); - return updateCheckPromise; + const result = await checkForAppUpdateResult(options); + return result.status === 'available' ? result.update : null; } export function subscribeToAppUpdate( @@ -103,5 +136,5 @@ function restartAppAfterUpdate() { export function resetAppUpdateCheckForTests() { pendingUpdate = null; - updateCheckPromise = null; + updateCheckResultPromise = null; } diff --git a/apps/ai-game-creator-shell/src/services/clientApi.ts b/apps/ai-game-creator-shell/src/services/clientApi.ts index 2221e5cfd..5c1bff3cc 100644 --- a/apps/ai-game-creator-shell/src/services/clientApi.ts +++ b/apps/ai-game-creator-shell/src/services/clientApi.ts @@ -3,7 +3,10 @@ import { resolveTauriInvoke } from '../app/tauri'; export type ClientLlmModel = { displayName: string; id: string; - agentMode: 'codex' | 'cc' | 'anthropic'; + /** 执行器:codex 或 cc。线协议是与之正交的独立维度。 */ + agentMode: 'codex' | 'cc'; + /** 上游线协议;服务端未下发时按执行器的默认协议解释。 */ + protocol?: 'openai_responses' | 'openai_chat' | 'anthropic'; }; export type ClientLlmModelCatalog = { diff --git a/apps/ai-game-creator-shell/src/view/home/index.tsx b/apps/ai-game-creator-shell/src/view/home/index.tsx index 3d605d066..79fbbc52a 100644 --- a/apps/ai-game-creator-shell/src/view/home/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/index.tsx @@ -13,6 +13,7 @@ import { useRef, useState } from 'react'; import BRAND_ICON from '../../../../../packages/shared/src/icons/taonier-product-ip.png'; import type { ProjectStartMode } from '../../app/types'; +import { resolveHomeCreationFailureMessage } from '../../features/app-shell/homeCreationFailure'; import { ConversationModelSelect } from '../../features/project-workspace/ConversationModelSelect'; import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel'; import RichInputArea, { UploadButton } from './components/RichInputArea'; @@ -192,10 +193,10 @@ export default function HomeView({ startMode, ), ); - } catch { - // 详细错误已由工作区创建控制器记录;首页只显示稳定的用户可执行提示, - // 避免把底层 Provider/IPC 文本直接渲染到首页状态区。 - onStatusChange('创建未完成,请重试'); + } catch (error) { + // 控制器已经给出用户可见文案(宿主机器码、IPC 提示或专用兜底);首页只透传它, + // 既不覆盖成通用文案,也不把宿主原始文本渲染到状态区。 + onStatusChange(resolveHomeCreationFailureMessage(error)); } finally { homeCreationBusyRef.current = false; setHomeCreationBusy(false); diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiEditorCopyPathButton.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiEditorCopyPathButton.tsx index ea19e48bf..b33d9e62a 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiEditorCopyPathButton.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiEditorCopyPathButton.tsx @@ -1,31 +1,36 @@ import { writeText } from '@tauri-apps/plugin-clipboard-manager'; import { Copy } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; + +type CopyState = 'idle' | 'copied' | 'failed'; export function UiEditorCopyPathButton({ relativePath, }: { relativePath: string; }) { - const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>( - 'idle', - ); - - useEffect(() => setCopyState('idle'), [relativePath]); + // 复制态跟着具体路径走:路径变了就等于回到未复制。这里不用 effect 事后重置, + // 因为延迟执行的 effect 会晚于复制结果落盘,把「已复制」覆盖回未复制。 + const [copyState, setCopyState] = useState<{ + relativePath: string; + value: CopyState; + }>({ relativePath, value: 'idle' }); + const value = + copyState.relativePath === relativePath ? copyState.value : 'idle'; async function copyPath() { - setCopyState('idle'); + setCopyState({ relativePath, value: 'idle' }); try { await writeText(relativePath); - setCopyState('copied'); + setCopyState({ relativePath, value: 'copied' }); } catch { - setCopyState('failed'); + setCopyState({ relativePath, value: 'failed' }); } } return ( <> - {copyState === 'failed' ? ( + {value === 'failed' ? (

复制失败,请手动复制路径。

@@ -36,7 +41,7 @@ export function UiEditorCopyPathButton({ onClick={() => void copyPath()} >