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..feb4ce9d3 100644 --- a/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx +++ b/apps/admin-web/src/pages/AdminAgcModelsPage.test.tsx @@ -104,3 +104,52 @@ 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/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/thread_manager/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs index 5eabb23b6..be5bc35cb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs @@ -13,13 +13,16 @@ use std::path::{Path, PathBuf}; +use futures::FutureExt; + #[cfg(test)] use crate::agent::PendingTurn; use crate::agent::{ append_direct_project_user_message_at, claim_pending_turn, complete_turn_if_reserved, - direct_codex_user_item_to_prompt, direct_now_ms, redact_agent_runtime_error, - run_direct_game_creator_turn_at_with_creation_type_and_emitter, thread_id_for_project, - DirectGameCreatorTurnUpdateEmitter, DirectTurnError, DirectTurnTerminal, DispatchedTurn, + direct_codex_user_item_to_prompt, direct_now_ms, record_direct_codex_failure, + redact_agent_runtime_error, run_direct_game_creator_turn_at_with_creation_type_and_emitter, + thread_id_for_project, DirectCodexFailureStage, DirectGameCreatorTurnUpdateEmitter, + DirectTurnError, DirectTurnTerminal, DispatchedTurn, }; /// 一次放行的占用。持有它就代表这一轮还没收口。 @@ -84,13 +87,90 @@ impl Drop for TurnReservation { fn drop(&mut self) { // 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。 // 这类失败说不出原因,只给分类;能说清原因的错误必须由调用方在更早的地方显式收口。 - let _ = self.finish_if_unfinished(DirectTurnTerminal::host_dropped()); + let finalized_by_guard = self.finish_if_unfinished(DirectTurnTerminal::host_dropped()); + // 兜底一旦真的收口,就说明这一轮**从未写下终态**:深层既没成功也没失败地退出了。 + // 这条必须留应用日志,否则离线只剩一个 `phase=working` 的账本,无从判断是哪一层 + // 提前退出(真实案例:2026-10-02 连续两轮只留下 working 账本,errors/ 与 + // application.log 全空)。测试态不写,避免用例污染本机 AppData。 + #[cfg(not(test))] + if finalized_by_guard { + // 项目侧也留一份脱敏诊断:用户在项目目录里就能看到这一轮的收场, + // 不必只依赖 AppData 的应用日志。 + let failure = DirectTurnError::TurnFailed { + stage: DirectCodexFailureStage::CodeGeneration, + detail: format!( + "DirectProject 宿主任务提前结束(panic、future 被丢弃或被取消),本轮未写下终态;threadId={} turnToken={}", + self.thread_id, self.token + ), + }; + let _ = record_direct_codex_failure(Path::new(&self.thread_id), &failure, None); + let _ = crate::append_application_log( + "error".to_string(), + "direct-turn".to_string(), + format!( + "agent.direct_turn.host_dropped threadId={} turnToken={} userItemId={} panicking={}", + self.thread_id, + self.token, + self.user_item_id.as_deref().unwrap_or("-"), + // true = 这一轮是被 panic 带走的;false = future 被丢弃或取消。 + std::thread::panicking() + ), + ); + } // 占用的释放就是"这个项目腾出了跑回合的位置":踢一脚,队里排着的消息不必等下一次用户动作。 // panic 也走这里,所以队列不会因为一个任务炸掉而永久停住。 kick_queue_dispatch(Path::new(&self.thread_id)); } } +// 运行段经 task-local 携带回合身份,panic hook 据此把 panic 位置与负载写进应用日志。 +// task-local 而非 thread-local:多线程 runtime 下 future 会跨 worker 迁移。 +tokio::task_local! { + static DIRECT_TURN_PANIC_CONTEXT: Option<(String, String)>; +} + +/// 兜底诊断:Direct 回合任务 panic 时,把位置与负载写进应用日志。 +/// +/// 没有它时,panic 只会让 `TurnReservation::drop` 把回合收成 `HostDropped`——那是"说不出原因" +/// 的分类,离线只留一个 `phase=working` 的账本,`.agent/runtime/errors/` 与 `application.log` +/// 全空,无法判断是哪一层退出的(真实案例:2026-10-02 连续两轮如此)。 +fn ensure_direct_turn_panic_hook() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + if let Ok(Some((thread_id, token))) = DIRECT_TURN_PANIC_CONTEXT.try_with(Clone::clone) { + let location = info + .location() + .map(|location| { + format!( + "{}:{}:{}", + location.file(), + location.line(), + location.column() + ) + }) + .unwrap_or_else(|| "未知位置".to_string()); + #[cfg(not(test))] + let _ = crate::append_application_log( + "error".to_string(), + "direct-turn".to_string(), + format!( + "agent.direct_turn.panic threadId={} turnToken={} location={} payload={}", + thread_id, + token, + location, + info.payload_as_str().unwrap_or("未知 panic 负载") + ), + ); + #[cfg(test)] + let _ = (thread_id, token, location); + } + previous(info); + })); + }); +} + /// 踢一脚:让队首那条待发消息有机会变成一轮真的回合。 /// /// 幂等,三个调用点:**入队之后**(队列空且没有回合在跑时,放行就是这一脚,不必再等一个调度周期)、 @@ -109,10 +189,23 @@ pub(crate) fn kick_queue_dispatch(root: &Path) { crate::platform_session::current_platform_session_write_state().identity_generation; let reservation = TurnReservation::resume(&thread_id, &dispatched); let root = root.to_path_buf(); - tauri::async_runtime::spawn(async move { + ensure_direct_turn_panic_hook(); + let panic_context = Some((thread_id.clone(), dispatched.token.clone())); + tauri::async_runtime::spawn(DIRECT_TURN_PANIC_CONTEXT.scope(panic_context, async move { run_dispatched_direct_turn(root, dispatched, reservation, release_identity_generation) .await; - }); + })); +} + +/// 把 panic 负载转成可读文本:`panic!("…")` 的负载是 `&str`,`panic!("{x}")` 是 `String`。 +fn direct_turn_panic_detail(payload: &(dyn std::any::Any + Send)) -> String { + if let Some(text) = payload.downcast_ref::<&str>() { + (*text).to_string() + } else if let Some(text) = payload.downcast_ref::() { + text.clone() + } else { + "未知 panic 负载".to_string() + } } /// 放行之后这一段:取这一轮的调用身份 → 落盘用户条目 → 下发 → 跑整轮。 @@ -149,16 +242,38 @@ async fn run_dispatched_direct_turn( crate::agent::codex_app_server::emit_direct_thread_user_item(&root, &canonical_user_item); let capture = crate::analytics::gui::capture_writer_context(); let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, turn_id); - let outcome = run_direct_game_creator_turn_at_with_creation_type_and_emitter( - &root, - &prompt, - dispatched.pending.creation_type.as_deref(), - Some(&emitter), - Some(canonical_user_item.clone()), - capture, - release_identity_generation, + // 回合体 panic 不能落到 `TurnReservation` 的 Drop 兜底:那会把这一轮收成"说不出原因"的 + // `HostDropped`,症状正是用户看到的「本轮执行已中断,请重试」,而项目侧诊断与应用日志 + // **一行都不会有**(真实案例:2026-10-02 连续两轮)。这里把 panic 转成分类失败, + // 走与正常失败同一条收口:先写脱敏诊断,再写终态。 + let outcome = match std::panic::AssertUnwindSafe( + run_direct_game_creator_turn_at_with_creation_type_and_emitter( + &root, + &prompt, + dispatched.pending.creation_type.as_deref(), + Some(&emitter), + Some(canonical_user_item.clone()), + capture, + release_identity_generation, + ), ) - .await; + .catch_unwind() + .await + { + Ok(result) => result, + Err(payload) => { + let failure = DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + format!( + "DirectProject 宿主任务 panic:{}", + direct_turn_panic_detail(payload.as_ref()) + ), + ); + let stage = failure.turn_failure_stage(); + let detail = record_direct_codex_failure(&root, &failure, Some(turn_id.as_str())); + Err(DirectTurnError::TurnFailed { stage, detail }) + } + }; match outcome { Ok(_) => { // 深层的终态出口已经在 `run_turn` 里写出 `turn.completed`;这里只补最后一条回合更新。 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 9be6df9bb..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( 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 b239d8a16..b580e5bf2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -813,6 +813,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)] @@ -874,6 +877,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)] @@ -1385,6 +1391,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/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/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..4556f232c 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, @@ -724,13 +724,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()} >