diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index 0d101317c..57df7143c 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -3,7 +3,7 @@ "apiKey": "", "baseUrl": "https://api.openai.com/v1", "model": "gpt-4.1", - "protocol": "responses", + "apiKind": "openai_responses", "stream": false, "requestTimeoutMs": 180000, "maxRetries": 0, diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 90645c2cf..89f505d1e 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -357,7 +357,7 @@ for (const snippet of [ 'requestJson?.stream === true', `requestBodies.every((body) => body.includes('"stream":true'))`, "const localConfigPath = path.join(appRoot, 'game-creator.config.local.json')", - "protocol: 'chat_completions'", + "apiKind: 'openai_chat'", 'stream: true', 'await restoreOptionalFile(localConfigPath, previousLocalConfig)', "method: 'HEAD'", diff --git a/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs b/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs index a6f1279d5..63ca78f63 100644 --- a/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs +++ b/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs @@ -577,7 +577,7 @@ async function writeSmokeLocalConfig(baseUrl) { apiKey: 'local-provider-key', baseUrl, model: 'local-game-creator-smoke', - protocol: 'chat_completions', + apiKind: 'openai_chat', stream: true, }, }, diff --git a/apps/ai-game-creator-shell/scripts/start-dev-server.mjs b/apps/ai-game-creator-shell/scripts/start-dev-server.mjs index 9a4914dcb..f1f76ff31 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-server.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-server.mjs @@ -92,6 +92,8 @@ const child = spawn( { cwd: appRoot, stdio: 'inherit', + // Node 18.20+/20+/24 on Windows rejects spawning .cmd (npm.cmd) without a shell (EINVAL). + shell: true, }, ); 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 1f0edfdff..65d04dd76 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -14,7 +14,7 @@ use platform_agent::{ route_game_creation_repair_issues, }; use platform_llm::{ - LlmClient, LlmConfig, LlmMessage, LlmProvider, LlmTextProtocol, LlmTextRequest, + LlmClient, LlmConfig, LlmMessage, LlmProvider, LlmApiKind, LlmRunRequest, DEFAULT_RETRY_BACKOFF_MS, }; use reqwest::header; @@ -92,7 +92,7 @@ struct GameCreatorLlmConfigStatus { api_key_present: bool, base_url: Option, model: Option, - protocol: String, + api_kind: String, error: Option, } @@ -109,7 +109,7 @@ struct GameCreatorLlmConfigFile { api_key: Option, base_url: Option, model: Option, - protocol: Option, + api_kind: Option, stream: Option, request_timeout_ms: Option, max_retries: Option, @@ -136,7 +136,7 @@ struct GameCreatorLlmConfig { api_key: String, base_url: String, model: String, - protocol: String, + api_kind: String, stream: bool, request_timeout_ms: u64, max_retries: u32, @@ -446,7 +446,7 @@ const GAME_CREATOR_CONFIG_FILE_NAME: &str = "game-creator.config.json"; const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.json"; const DEFAULT_GAME_CREATOR_LLM_BASE_URL: &str = "https://api.openai.com/v1"; const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-4.1"; -const DEFAULT_GAME_CREATOR_LLM_PROTOCOL: &str = "responses"; +const DEFAULT_GAME_CREATOR_LLM_API_KIND: &str = "openai_responses"; const DEFAULT_CANVAS_SYNC_API_BASE_URL: &str = "http://127.0.0.1:8082"; const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json"); const GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS: u32 = 320000; @@ -490,7 +490,7 @@ impl Default for GameCreatorLlmConfig { api_key: String::new(), base_url: DEFAULT_GAME_CREATOR_LLM_BASE_URL.to_string(), model: DEFAULT_GAME_CREATOR_LLM_MODEL.to_string(), - protocol: DEFAULT_GAME_CREATOR_LLM_PROTOCOL.to_string(), + api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(), stream: false, request_timeout_ms: GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS, max_retries: 0, @@ -1441,23 +1441,24 @@ fn build_game_creator_llm_client_from_config() -> Result { LlmClient::new(config).map_err(|error| format!("LLM client 初始化失败:{error}")) } -fn read_game_creator_llm_protocol_from_config() -> Result { +fn read_game_creator_llm_api_kind_from_config() -> Result { let app_config = load_game_creator_app_config()?; - parse_game_creator_llm_protocol(&app_config.llm.protocol) + parse_game_creator_llm_api_kind(&app_config.llm.api_kind) } -fn parse_game_creator_llm_protocol(value: &str) -> Result { +fn parse_game_creator_llm_api_kind(value: &str) -> Result { let normalized = value.trim().to_ascii_lowercase().replace('-', "_"); let normalized = if normalized.is_empty() { - DEFAULT_GAME_CREATOR_LLM_PROTOCOL + DEFAULT_GAME_CREATOR_LLM_API_KIND } else { normalized.as_str() }; match normalized { - "responses" | "response" | "responses_api" => Ok(LlmTextProtocol::Responses), - "chat_completions" | "chat_completion" | "chat" => Ok(LlmTextProtocol::ChatCompletions), + "openai_responses" => Ok(LlmApiKind::OpenAiResponses), + "openai_chat" => Ok(LlmApiKind::OpenAiChat), + "anthropic" => Ok(LlmApiKind::Anthropic), value => Err(format!( - "LLM protocol 无效:{value},请使用 responses 或 chat_completions" + "LLM api_kind 无效:{value},请使用 openai_responses、openai_chat 或 anthropic" )), } } @@ -1471,18 +1472,18 @@ fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfigStatus { api_key_present: false, base_url: None, model: None, - protocol: DEFAULT_GAME_CREATOR_LLM_PROTOCOL.to_string(), + api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(), error: Some(error), } } }; let mut status = check_game_creator_llm_config_values(&app_config.llm); - status.protocol = parse_game_creator_llm_protocol(&app_config.llm.protocol) - .map(game_creator_llm_protocol_name) + status.api_kind = parse_game_creator_llm_api_kind(&app_config.llm.api_kind) + .map(game_creator_llm_api_kind_name) .unwrap_or_else(|error| { status.configured = false; status.error = Some(error); - DEFAULT_GAME_CREATOR_LLM_PROTOCOL.to_string() + DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string() }); if status.configured { if let Err(error) = build_game_creator_llm_client_from_config() { @@ -1525,15 +1526,16 @@ fn check_game_creator_llm_config_values( api_key_present, base_url, model, - protocol: DEFAULT_GAME_CREATOR_LLM_PROTOCOL.to_string(), + api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(), error, } } -fn game_creator_llm_protocol_name(protocol: LlmTextProtocol) -> String { - match protocol { - LlmTextProtocol::ChatCompletions => "chat_completions", - LlmTextProtocol::Responses => "responses", +fn game_creator_llm_api_kind_name(api_kind: LlmApiKind) -> String { + match api_kind { + LlmApiKind::OpenAiChat => "openai_chat", + LlmApiKind::OpenAiResponses => "openai_responses", + LlmApiKind::Anthropic => "anthropic", } .to_string() } @@ -1916,7 +1918,7 @@ async fn request_planner_spec_with_client( short_memory: &str, long_memory: &str, ) -> Result { - let request = LlmTextRequest::new(vec![ + let request = LlmRunRequest::new(vec![ LlmMessage::system(game_creator_planner_system_prompt()), LlmMessage::user(game_creator_planner_user_prompt( prompt, @@ -1924,12 +1926,12 @@ async fn request_planner_spec_with_client( long_memory, )), ]) - .with_protocol(read_game_creator_llm_protocol_from_config()?) - .with_max_tokens(GAME_CREATOR_PLANNER_MAX_OUTPUT_TOKENS); + .with_api_kind(read_game_creator_llm_api_kind_from_config()?) + .with_max_output_tokens(GAME_CREATOR_PLANNER_MAX_OUTPUT_TOKENS); let response = request_game_creator_llm_text(client, request) .await .map_err(|error| format!("Planner 生成失败:{error}"))?; - let spec = strip_llm_thinking_blocks(response.content.as_str()); + let spec = strip_llm_thinking_blocks(response.text.as_str()); if spec.is_empty() { Err("Planner 未返回规格".to_string()) } else { @@ -1964,12 +1966,12 @@ async fn request_generator_game_draft_with_client( const MAX_EMPTY_RETRIES: u32 = 3; let mut empty_retries = 0u32; let response = loop { - let request = LlmTextRequest::new(vec![ + let request = LlmRunRequest::new(vec![ LlmMessage::system(system_prompt), LlmMessage::user(user_prompt.clone()), ]) - .with_protocol(read_game_creator_llm_protocol_from_config()?) - .with_max_tokens(GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS); + .with_api_kind(read_game_creator_llm_api_kind_from_config()?) + .with_max_output_tokens(GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS); match request_game_creator_llm_text(client, request).await { Ok(response) => break response, Err(platform_llm::LlmError::EmptyResponse) if empty_retries < MAX_EMPTY_RETRIES => { @@ -1997,19 +1999,19 @@ async fn request_generator_game_draft_with_client( }; // 先把原始返回落盘,再解析;解析失败(如输出截断)时仍能从仓库里拿到完整原文(仅 debug 构建)。 #[cfg(all(debug_assertions, not(test)))] - debug::persist_snapshot(response.content.as_str()); - let content = strip_llm_thinking_blocks(response.content.as_str()); + debug::persist_snapshot(response.text.as_str()); + let content = strip_llm_thinking_blocks(response.text.as_str()); parse_llm_game_draft_response(content.as_str()) } async fn request_game_creator_llm_text( client: &LlmClient, - request: LlmTextRequest, -) -> Result { + request: LlmRunRequest, +) -> Result { if game_creator_llm_stream_enabled() { - client.stream_text(request, |_| {}).await + client.stream_run(request, |_| {}).await } else { - client.request_text(request).await + client.run(request).await } } @@ -4863,8 +4865,8 @@ fn merge_game_creator_llm_config( if let Some(value) = patch.model { config.model = value; } - if let Some(value) = patch.protocol { - config.protocol = value; + if let Some(value) = patch.api_kind { + config.api_kind = value; } if let Some(value) = patch.stream { config.stream = value; @@ -4908,8 +4910,8 @@ fn normalize_game_creator_app_config( config.llm.base_url = trim_config_string(&config.llm.base_url).ok_or_else(llm_base_url_config_error)?; config.llm.model = trim_config_string(&config.llm.model).ok_or_else(llm_model_config_error)?; - config.llm.protocol = game_creator_llm_protocol_name(parse_game_creator_llm_protocol( - &config.llm.protocol, + config.llm.api_kind = game_creator_llm_api_kind_name(parse_game_creator_llm_api_kind( + &config.llm.api_kind, )?); if config.llm.request_timeout_ms == 0 { return Err("配置项 llm.requestTimeoutMs 必须大于 0".to_string()); @@ -6591,7 +6593,7 @@ fn run_cli_command(command: CliCommand) -> Result<(), String> { println!("llm.apiKeyPresent={}", status.api_key_present); println!("llm.baseUrl={}", status.base_url.unwrap_or_default()); println!("llm.model={}", status.model.unwrap_or_default()); - println!("llm.protocol={}", status.protocol); + println!("llm.apiKind={}", status.api_kind); if let Some(error) = status.error { println!("llm.error={error}"); } @@ -6823,7 +6825,7 @@ mod tests { "apiKey": "file-key", "baseUrl": "https://example.test/v1", "model": "model-from-file", - "protocol": "chat_completions", + "apiKind": "openai_chat", "stream": true, "requestTimeoutMs": 42000, "maxRetries": 2, @@ -6844,7 +6846,7 @@ mod tests { assert_eq!(config.llm.api_key, "file-key"); assert_eq!(config.llm.base_url, "https://example.test/v1"); assert_eq!(config.llm.model, "model-from-file"); - assert_eq!(config.llm.protocol, "chat_completions"); + assert_eq!(config.llm.api_kind, "openai_chat"); assert!(config.llm.stream); assert_eq!(config.llm.request_timeout_ms, 42_000); assert_eq!(config.llm.max_retries, 2); @@ -6893,7 +6895,7 @@ mod tests { api_key: " unit-test-key ".to_string(), base_url: " https://runtime.example.test/v1 ".to_string(), model: " runtime-model ".to_string(), - protocol: "chat".to_string(), + api_kind: "openai_chat".to_string(), stream: true, request_timeout_ms: 42_000, max_retries: 2, @@ -6914,7 +6916,7 @@ mod tests { ); assert_eq!(saved.config.llm.api_key, "unit-test-key"); assert_eq!(saved.config.llm.base_url, "https://runtime.example.test/v1"); - assert_eq!(saved.config.llm.protocol, "chat_completions"); + assert_eq!(saved.config.llm.api_kind, "openai_chat"); assert_eq!(saved.config.editor_api.api_key, "editor-key"); assert!(root.join(GAME_CREATOR_CONFIG_FILE_NAME).is_file()); @@ -6925,7 +6927,7 @@ mod tests { } #[test] - fn app_config_write_rejects_invalid_protocol() { + fn app_config_write_rejects_invalid_api_kind() { let root = unique_project_path(); fs::create_dir_all(&root).expect("runtime config dir"); let _guard = use_test_runtime_config_dir(root.clone()); @@ -6933,15 +6935,15 @@ mod tests { let result = write_game_creator_app_config(GameCreatorAppConfig { llm: GameCreatorLlmConfig { api_key: String::new(), - protocol: "legacy".to_string(), + api_kind: "legacy".to_string(), ..GameCreatorLlmConfig::default() }, editor_api: GameCreatorEditorApiConfig::default(), }); assert!(result - .expect_err("invalid protocol") - .contains("LLM protocol 无效")); + .expect_err("invalid api_kind") + .contains("LLM api_kind 无效")); assert!(!root.join(GAME_CREATOR_CONFIG_FILE_NAME).exists()); fs::remove_dir_all(root).expect("cleanup runtime config dir"); } @@ -7384,7 +7386,7 @@ mod tests { "apiKey": "test-key", "baseUrl": {base_url:?}, "model": "mock-game-model", - "protocol": "responses" + "apiKind": "openai_responses" }} }}"# )); @@ -7425,12 +7427,33 @@ mod tests { Some("http://127.0.0.1:1/v1") ); assert_eq!(configured.model.as_deref(), Some("mock-game-model")); - assert_eq!(configured.protocol, "responses"); + assert_eq!(configured.api_kind, "openai_responses"); assert!(!serde_json::to_string(&configured) .unwrap() .contains("unit-test-api-key")); } + #[test] + fn llm_api_kind_parses_canonical_names() { + assert_eq!( + parse_game_creator_llm_api_kind("anthropic"), + Ok(LlmApiKind::Anthropic) + ); + assert_eq!( + parse_game_creator_llm_api_kind("openai_chat"), + Ok(LlmApiKind::OpenAiChat) + ); + assert_eq!( + parse_game_creator_llm_api_kind("openai_responses"), + Ok(LlmApiKind::OpenAiResponses) + ); + assert_eq!( + parse_game_creator_llm_api_kind(""), + Ok(LlmApiKind::OpenAiResponses) + ); + assert!(parse_game_creator_llm_api_kind("legacy").is_err()); + } + #[tokio::test] async fn agent_loop_writes_spec_findings_and_retries_generator() { let root = unique_project_path(); @@ -7904,7 +7927,7 @@ mod tests { "apiKey": "test-key", "baseUrl": {base_url:?}, "model": "mock-game-model", - "protocol": "responses" + "apiKind": "openai_responses" }} }}"# )); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 729cb2bf8..a9c2b086a 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -60,18 +60,18 @@ interface GameCreatorLlmConfigStatus { apiKeyPresent: boolean; baseUrl: string | null; model: string | null; - protocol: string; + apiKind: string; error: string | null; } -type GameCreatorLlmProtocol = 'responses' | 'chat_completions'; +type GameCreatorLlmApiKind = 'openai_responses' | 'openai_chat' | 'anthropic'; interface GameCreatorAppConfig { llm: { apiKey: string; baseUrl: string; model: string; - protocol: GameCreatorLlmProtocol; + apiKind: GameCreatorLlmApiKind; stream: boolean; requestTimeoutMs: number; maxRetries: number; @@ -326,7 +326,7 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = { apiKey: '', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4.1', - protocol: 'responses', + apiKind: 'openai_responses', stream: false, requestTimeoutMs: 180000, maxRetries: 0, @@ -1739,7 +1739,7 @@ export function App() { text: status.configured ? `LLM 已配置:${status.model ?? '未命名模型'} @ ${ status.baseUrl ?? '未设置 base_url' - },${status.protocol},API Key 已读取。` + },${status.apiKind},API Key 已读取。` : `LLM 未就绪:${status.error ?? '配置不完整'}。API Key:${ status.apiKeyPresent ? '已读取' : '未读取' }。`, @@ -3753,19 +3753,20 @@ export function App() { />