diff --git a/.gitignore b/.gitignore index 633a781d7..d6768e7d1 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ temp*build*/ /apps/ai-game-creator-shell/src-tauri/logs/ /apps/ai-game-creator-shell/logs/ /apps/ai-game-creator-shell/.llm-drafts/ +/apps/ai-game-creator-shell/game-creator.config.local.json /apps/mobile-shell/.expo/ /apps/mobile-shell/.expo-export-smoke/ /server-rs/.spacetimedb/ diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json new file mode 100644 index 000000000..57df7143c --- /dev/null +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -0,0 +1,16 @@ +{ + "llm": { + "apiKey": "", + "baseUrl": "https://api.openai.com/v1", + "model": "gpt-4.1", + "apiKind": "openai_responses", + "stream": false, + "requestTimeoutMs": 180000, + "maxRetries": 0, + "retryBackoffMs": 500 + }, + "editorApi": { + "baseUrl": "http://127.0.0.1:8082", + "apiKey": "" + } +} diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 352721db4..613b4d7c9 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -7,8 +7,8 @@ "dev": "npm --prefix ../.. exec tauri -- dev", "dev-server": "node scripts/start-dev-server.mjs", "build": "npm --prefix ../.. exec tauri -- build", - "llm-status": "node scripts/run-cli-with-env.mjs --llm-status", - "agent-run": "node scripts/run-cli-with-env.mjs --agent-run", + "llm-status": "node scripts/run-cli-with-config.mjs --llm-status", + "agent-run": "node scripts/run-cli-with-config.mjs --agent-run", "agent-run:smoke": "node scripts/smoke-agent-run-local-provider.mjs", "typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs" }, diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 8b4c0d288..89f505d1e 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -64,6 +64,7 @@ function assertNoOpenAiApiKeys(paths) { assertNoOpenAiApiKeys([ new URL('../src/', import.meta.url), new URL('../scripts/', import.meta.url), + new URL('../game-creator.config.json', import.meta.url), new URL('../src-tauri/src/', import.meta.url), new URL('../package.json', import.meta.url), new URL('../vite.config.ts', import.meta.url), @@ -97,19 +98,19 @@ if (packageConfig.name !== '@genarrative/ai-game-creator-shell') { if ( packageConfig.scripts?.['llm-status'] !== - 'node scripts/run-cli-with-env.mjs --llm-status' + 'node scripts/run-cli-with-config.mjs --llm-status' ) { throw new Error( - 'AI game creator shell llm-status must load gitignored local env before checking LLM config', + 'AI game creator shell llm-status must use client config before checking LLM config', ); } if ( packageConfig.scripts?.['agent-run'] !== - 'node scripts/run-cli-with-env.mjs --agent-run' + 'node scripts/run-cli-with-config.mjs --agent-run' ) { throw new Error( - 'AI game creator shell agent-run must load gitignored local env before running the provider path', + 'AI game creator shell agent-run must use client config before running the provider path', ); } @@ -190,8 +191,8 @@ const devServerSource = fs.readFileSync( new URL('../scripts/start-dev-server.mjs', import.meta.url), 'utf8', ); -const runCliWithEnvSource = fs.readFileSync( - new URL('../scripts/run-cli-with-env.mjs', import.meta.url), +const runCliWithConfigSource = fs.readFileSync( + new URL('../scripts/run-cli-with-config.mjs', import.meta.url), 'utf8', ); @@ -211,15 +212,13 @@ for (const snippet of [ } for (const snippet of [ - "path.join(repoRoot, '.env.secrets.local')", - "path.join(appRoot, '.env.secrets.local')", - 'dotenv.config({ path: envPath, override: false })', + "new URL('..', import.meta.url)", "'--manifest-path'", "'src-tauri/Cargo.toml'", ]) { - if (!runCliWithEnvSource.includes(snippet)) { + if (!runCliWithConfigSource.includes(snippet)) { throw new Error( - `AI game creator shell local env CLI wrapper drifted: ${snippet}`, + `AI game creator shell config CLI wrapper drifted: ${snippet}`, ); } } @@ -230,19 +229,32 @@ const tauriMainSource = fs.readFileSync( ); for (const snippet of [ - 'fn load_game_creator_local_env()', - 'fn load_game_creator_env_file(path: &Path)', - 'directory.join(".env.secrets.local")', + '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_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json")', + 'fn configure_game_creator_runtime_config_dir(', + 'app.path().app_config_dir()?', + 'fn load_game_creator_app_config()', + 'fn read_game_creator_app_config()', + 'fn write_game_creator_app_config(', + 'fn writable_game_creator_config_path()', + 'fn normalize_game_creator_app_config(', + 'fn merge_game_creator_config_file(', '.join("apps")', '.join("ai-game-creator-shell")', - 'load_game_creator_local_env()?;', - 'let local_env_error = load_game_creator_local_env().err();', - 'local.env.load.failed', + 'configure_game_creator_runtime_config_dir(app.handle())?', + 'read_game_creator_app_config,', + 'write_game_creator_app_config,', + 'build_game_creator_llm_client_from_config()?', + 'let app_config = match load_game_creator_app_config()', '#[cfg(debug_assertions)]\nfn developer_window_url()', 'tauri::WebviewUrl::App(PathBuf::from("index.html?dev"))', '#[cfg(debug_assertions)]\nfn open_developer_window(app: &tauri::App)', 'tauri::WebviewWindowBuilder::new(app, "developer", developer_window_url())', 'open_developer_window(app)?;', + 'fn append_local_permission_log_at(', + '"command.auto"', + 'GameCreationAppPermission::Auto', ]) { if (!tauriMainSource.includes(snippet)) { throw new Error( @@ -257,6 +269,12 @@ const appSource = fs.readFileSync( ); for (const snippet of [ "'game.run_local'", + "'read_game_creator_app_config'", + "'write_game_creator_app_config'", + 'aria-label="运行时配置"', + 'LLM API Key', + '画板 API Key', + 'runtime_config.save', "'/run:运行自检,启动本地 HTTP 预览并交给外部浏览器'", 'async function openPreviewInExternalBrowser', "'open_local_game_preview'", @@ -275,6 +293,8 @@ for (const snippet of [ "'permission.pending'", "'permission.confirm'", "'permission.cancel'", + "'command.auto'", + "'agent.run_status'", 'function summarizeAgentRunTrace', '工具调用:${agentRunTrace.toolCallCount}/${agentRunTrace.maxToolCalls}', 'agentRunTrace.error ?', @@ -336,8 +356,10 @@ for (const snippet of [ 'function writeStreamingChatCompletion', 'requestJson?.stream === true', `requestBodies.every((body) => body.includes('"stream":true'))`, - "GENARRATIVE_GAME_CREATOR_LLM_API_KIND: 'openai_chat'", - "GENARRATIVE_GAME_CREATOR_LLM_STREAM: 'true'", + "const localConfigPath = path.join(appRoot, 'game-creator.config.local.json')", + "apiKind: 'openai_chat'", + 'stream: true', + 'await restoreOptionalFile(localConfigPath, previousLocalConfig)', "method: 'HEAD'", 'previewAssetHead.contentLength === String(smokeAssetBytes.length)', "previewAudioHead.contentType === 'audio/mpeg'", diff --git a/apps/ai-game-creator-shell/scripts/run-cli-with-env.mjs b/apps/ai-game-creator-shell/scripts/run-cli-with-config.mjs similarity index 66% rename from apps/ai-game-creator-shell/scripts/run-cli-with-env.mjs rename to apps/ai-game-creator-shell/scripts/run-cli-with-config.mjs index 3d17d1f8f..c8eadeda5 100644 --- a/apps/ai-game-creator-shell/scripts/run-cli-with-env.mjs +++ b/apps/ai-game-creator-shell/scripts/run-cli-with-config.mjs @@ -1,21 +1,8 @@ import { spawn } from 'node:child_process'; -import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import dotenv from 'dotenv'; - const appRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))); -const repoRoot = path.resolve(appRoot, '../..'); - -for (const envPath of [ - path.join(repoRoot, '.env.secrets.local'), - path.join(appRoot, '.env.secrets.local'), -]) { - if (fs.existsSync(envPath)) { - dotenv.config({ path: envPath, override: false }); - } -} const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo'; const child = spawn( @@ -30,7 +17,6 @@ const child = spawn( { cwd: appRoot, stdio: 'inherit', - env: process.env, }, ); 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 d20063401..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 @@ -6,6 +6,7 @@ import os from 'node:os'; import path from 'node:path'; const appRoot = path.resolve(new URL('..', import.meta.url).pathname); +const localConfigPath = path.join(appRoot, 'game-creator.config.local.json'); const projectRoot = path.join( os.tmpdir(), `genarrative-ai-game-creator-smoke-${Date.now()}`, @@ -254,9 +255,11 @@ function writeStreamingChatCompletion(response, content) { await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const address = server.address(); const baseUrl = `http://127.0.0.1:${address.port}`; +const previousLocalConfig = await readOptionalFile(localConfigPath); try { await seedLocalAsset(); + await writeSmokeLocalConfig(baseUrl); const { output, previewHtml, @@ -266,7 +269,7 @@ try { previewAudio, previewAudioHead, previewDom, - } = await runAgent(baseUrl); + } = await runAgent(); const tracePath = path.join(projectRoot, '.agent/run.latest.json'); const trace = JSON.parse(await fs.readFile(tracePath, 'utf8')); const pass2TaskGraph = JSON.parse( @@ -542,10 +545,49 @@ try { console.log(`projectPath=${projectRoot}`); console.log(`tracePath=${tracePath}`); } finally { + await restoreOptionalFile(localConfigPath, previousLocalConfig); server.close(); } -function runAgent(baseUrl) { +async function readOptionalFile(filePath) { + try { + return await fs.readFile(filePath); + } catch (error) { + if (error?.code === 'ENOENT') { + return null; + } + throw error; + } +} + +async function restoreOptionalFile(filePath, previous) { + if (previous === null) { + await fs.rm(filePath, { force: true }); + return; + } + await fs.writeFile(filePath, previous); +} + +async function writeSmokeLocalConfig(baseUrl) { + await fs.writeFile( + localConfigPath, + `${JSON.stringify( + { + llm: { + apiKey: 'local-provider-key', + baseUrl, + model: 'local-game-creator-smoke', + apiKind: 'openai_chat', + stream: true, + }, + }, + null, + 2, + )}\n`, + ); +} + +function runAgent() { return new Promise((resolve, reject) => { let previewReadStarted = false; let previewUrl = ''; @@ -568,14 +610,6 @@ function runAgent(baseUrl) { ], { cwd: appRoot, - env: { - ...process.env, - GENARRATIVE_GAME_CREATOR_LLM_API_KEY: 'local-provider-key', - GENARRATIVE_GAME_CREATOR_LLM_BASE_URL: baseUrl, - GENARRATIVE_GAME_CREATOR_LLM_MODEL: 'local-game-creator-smoke', - GENARRATIVE_GAME_CREATOR_LLM_API_KIND: 'openai_chat', - GENARRATIVE_GAME_CREATOR_LLM_STREAM: 'true', - }, stdio: ['pipe', 'pipe', 'pipe'], }, ); @@ -757,9 +791,6 @@ function readBrowserDom(url) { } function resolveChromeBin() { - if (process.env.GENARRATIVE_GAME_CREATOR_CHROME_BIN) { - return process.env.GENARRATIVE_GAME_CREATOR_CHROME_BIN; - } for (const candidate of [ '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', 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 bc82cd7dd..aa989ed36 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -5,7 +5,7 @@ use std::fs::File; use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; -use std::sync::{mpsc, Mutex}; +use std::sync::{mpsc, Mutex, OnceLock}; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -27,12 +27,13 @@ use shared_contracts::game_creation_app::{ GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource, GameCreationAppAssetSourceKind, GameCreationAppCommandRunState, GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor, - GameCreationAppManifest, GameCreationAppPreviewState, GameCreationAppPreviewStatus, - GameCreationAppTaskState, GameCreationAppTaskStatus, GAME_CREATION_AGENT_CAPABILITIES, - GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, GAME_CREATION_AGENT_TOOL_CALL_MAX, - GAME_CREATION_APP_COMMANDS, GAME_CREATION_APP_LIMITED_RUN_COMMANDS, + GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState, + GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus, + GAME_CREATION_AGENT_CAPABILITIES, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + GAME_CREATION_AGENT_TOOL_CALL_MAX, GAME_CREATION_APP_COMMANDS, + GAME_CREATION_APP_LIMITED_RUN_COMMANDS, }; -use tauri::Emitter; +use tauri::{Emitter, Manager}; use tauri_plugin_opener::OpenerExt; // 调试落盘模块(保存 LLM 原始输出 / 失败输入,排查截断、空返回等)放在 debug_drafts.rs。 @@ -95,6 +96,67 @@ struct GameCreatorLlmConfigStatus { error: Option, } +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorAppConfigFile { + llm: Option, + editor_api: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorLlmConfigFile { + api_key: Option, + base_url: Option, + model: Option, + api_kind: Option, + stream: Option, + request_timeout_ms: Option, + max_retries: Option, + retry_backoff_ms: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorEditorApiConfigFile { + base_url: Option, + api_key: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorAppConfig { + llm: GameCreatorLlmConfig, + editor_api: GameCreatorEditorApiConfig, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorLlmConfig { + api_key: String, + base_url: String, + model: String, + api_kind: String, + stream: bool, + request_timeout_ms: u64, + max_retries: u32, + retry_backoff_ms: u64, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorEditorApiConfig { + base_url: String, + api_key: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorAppConfigView { + path: String, + config: GameCreatorAppConfig, +} + #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct AgentRunControlResult { @@ -371,6 +433,13 @@ const DEFAULT_GAME_INDEX_HTML: &str = r#" "#; const DEFAULT_EDITOR_BASE_URL: &str = "http://127.0.0.1:3000"; +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_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; const GAME_CREATOR_PLANNER_MAX_OUTPUT_TOKENS: u32 = 900; const GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS: u64 = 180_000; @@ -395,6 +464,40 @@ const GAME_CREATOR_AGENT_ARTIFACT_PATHS: [&str; 14] = [ "exports/README.md", "game/index.html", ]; +static GAME_CREATOR_RUNTIME_CONFIG_DIR: OnceLock>> = OnceLock::new(); + +impl Default for GameCreatorAppConfig { + fn default() -> Self { + Self { + llm: GameCreatorLlmConfig::default(), + editor_api: GameCreatorEditorApiConfig::default(), + } + } +} + +impl Default for GameCreatorLlmConfig { + fn default() -> Self { + Self { + api_key: String::new(), + base_url: DEFAULT_GAME_CREATOR_LLM_BASE_URL.to_string(), + model: DEFAULT_GAME_CREATOR_LLM_MODEL.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, + retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS, + } + } +} + +impl Default for GameCreatorEditorApiConfig { + fn default() -> Self { + Self { + base_url: DEFAULT_CANVAS_SYNC_API_BASE_URL.to_string(), + api_key: String::new(), + } + } +} #[derive(Clone, Copy, Debug)] struct AgentRoleDefinition { @@ -783,7 +886,10 @@ fn control_agent_run( update_agent_run_lifecycle( Path::new(project_path.trim()), action.trim(), - detail.as_deref().map(str::trim).filter(|value| !value.is_empty()), + detail + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()), ) } @@ -803,7 +909,29 @@ async fn generate_local_game_draft( #[tauri::command] fn check_game_creator_llm_config() -> GameCreatorLlmConfigStatus { - check_game_creator_llm_config_from_env() + check_game_creator_llm_config_from_config() +} + +#[tauri::command] +fn read_game_creator_app_config() -> Result { + game_creator_app_config_view(load_game_creator_app_config()?) +} + +#[tauri::command] +fn write_game_creator_app_config( + config: GameCreatorAppConfig, +) -> Result { + let config = normalize_game_creator_app_config(config)?; + let path = writable_game_creator_config_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建客户端配置目录失败:{}: {error}", parent.display()))?; + } + let content = serde_json::to_string_pretty(&config) + .map_err(|error| format!("序列化客户端配置失败:{error}"))?; + fs::write(&path, format!("{content}\n")) + .map_err(|error| format!("保存客户端配置失败:{}: {error}", path.display()))?; + game_creator_app_config_view(load_game_creator_app_config()?) } #[tauri::command] @@ -1121,7 +1249,7 @@ async fn generate_local_game_draft_at( "llm.planner", "Planner 正在调用 LLM 整理规格和专业组分工", ); - let client = build_game_creator_llm_client_from_env()?; + let client = build_game_creator_llm_client_from_config()?; let loop_result = run_game_creator_agent_loop_at( root, &client, @@ -1278,85 +1406,45 @@ fn write_local_game_draft_at( }) } -fn build_game_creator_llm_client_from_env() -> Result { - load_game_creator_local_env()?; - let api_key = read_first_non_empty_env(&[ - "GENARRATIVE_GAME_CREATOR_LLM_API_KEY", - "GENARRATIVE_LLM_API_KEY", - "LLM_API_KEY", - "OPENAI_API_KEY", - ]) - .ok_or_else(|| { - "LLM 未配置:请设置 GENARRATIVE_GAME_CREATOR_LLM_API_KEY / GENARRATIVE_LLM_API_KEY / LLM_API_KEY / OPENAI_API_KEY".to_string() - })?; - let base_url = read_first_non_empty_env(&[ - "GENARRATIVE_GAME_CREATOR_LLM_BASE_URL", - "GENARRATIVE_LLM_BASE_URL", - "LLM_BASE_URL", - "OPENAI_BASE_URL", - ]) - .ok_or_else(|| { - "LLM base_url 未配置:请设置 GENARRATIVE_GAME_CREATOR_LLM_BASE_URL / GENARRATIVE_LLM_BASE_URL / LLM_BASE_URL".to_string() - })?; - let model = read_first_non_empty_env(&[ - "GENARRATIVE_GAME_CREATOR_LLM_MODEL", - "GENARRATIVE_LLM_MODEL", - "LLM_MODEL", - "OPENAI_MODEL", - ]) - .ok_or_else(|| { - "LLM model 未配置:请设置 GENARRATIVE_GAME_CREATOR_LLM_MODEL / GENARRATIVE_LLM_MODEL / LLM_MODEL".to_string() - })?; - let request_timeout_ms = read_u64_env( - &[ - "GENARRATIVE_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS", - "GENARRATIVE_LLM_REQUEST_TIMEOUT_MS", - "LLM_REQUEST_TIMEOUT_MS", - ], - GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS, - )?; - let retry_backoff_ms = read_u64_env( - &[ - "GENARRATIVE_GAME_CREATOR_LLM_RETRY_BACKOFF_MS", - "GENARRATIVE_LLM_RETRY_BACKOFF_MS", - "LLM_RETRY_BACKOFF_MS", - ], - DEFAULT_RETRY_BACKOFF_MS, - )?; - let max_retries = read_u32_env( - &[ - "GENARRATIVE_GAME_CREATOR_LLM_MAX_RETRIES", - "GENARRATIVE_LLM_MAX_RETRIES", - "LLM_MAX_RETRIES", - ], - 0, - )?; +fn build_game_creator_llm_client_from_config() -> Result { + let app_config = load_game_creator_app_config()?; + let llm = &app_config.llm; + let api_key = trim_config_string(&llm.api_key).ok_or_else(llm_api_key_config_error)?; + let base_url = trim_config_string(&llm.base_url).ok_or_else(llm_base_url_config_error)?; + let model = trim_config_string(&llm.model).ok_or_else(llm_model_config_error)?; + if llm.request_timeout_ms == 0 { + return Err("配置项 llm.requestTimeoutMs 必须大于 0".to_string()); + } + if llm.retry_backoff_ms == 0 { + return Err("配置项 llm.retryBackoffMs 必须大于 0".to_string()); + } let config = LlmConfig::new( LlmProvider::OpenAiCompatible, base_url, api_key, model, - request_timeout_ms, - max_retries, - retry_backoff_ms, + llm.request_timeout_ms, + llm.max_retries, + llm.retry_backoff_ms, ) .map_err(|error| format!("LLM 配置无效:{error}"))?; LlmClient::new(config).map_err(|error| format!("LLM client 初始化失败:{error}")) } -fn read_game_creator_llm_api_kind_from_env() -> Result { - match read_first_non_empty_env(&[ - "GENARRATIVE_GAME_CREATOR_LLM_API_KIND", - "GENARRATIVE_LLM_API_KIND", - "LLM_API_KIND", - ]) - .unwrap_or_else(|| "openai_responses".to_string()) - .trim() - .to_ascii_lowercase() - .replace('-', "_") - .as_str() - { +fn read_game_creator_llm_api_kind_from_config() -> Result { + let app_config = load_game_creator_app_config()?; + parse_game_creator_llm_api_kind(&app_config.llm.api_kind) +} + +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_API_KIND + } else { + normalized.as_str() + }; + match normalized { "openai_responses" => Ok(LlmApiKind::OpenAiResponses), "openai_chat" => Ok(LlmApiKind::OpenAiChat), "anthropic" => Ok(LlmApiKind::Anthropic), @@ -1366,42 +1454,30 @@ fn read_game_creator_llm_api_kind_from_env() -> Result { } } -fn check_game_creator_llm_config_from_env() -> GameCreatorLlmConfigStatus { - let local_env_error = load_game_creator_local_env().err(); - let api_key = read_first_non_empty_env(&[ - "GENARRATIVE_GAME_CREATOR_LLM_API_KEY", - "GENARRATIVE_LLM_API_KEY", - "LLM_API_KEY", - "OPENAI_API_KEY", - ]); - let base_url = read_first_non_empty_env(&[ - "GENARRATIVE_GAME_CREATOR_LLM_BASE_URL", - "GENARRATIVE_LLM_BASE_URL", - "LLM_BASE_URL", - "OPENAI_BASE_URL", - ]); - let model = read_first_non_empty_env(&[ - "GENARRATIVE_GAME_CREATOR_LLM_MODEL", - "GENARRATIVE_LLM_MODEL", - "LLM_MODEL", - "OPENAI_MODEL", - ]); - let mut status = - check_game_creator_llm_config_values(api_key.clone(), base_url.clone(), model.clone()); - status.api_kind = read_game_creator_llm_api_kind_from_env() +fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfigStatus { + let app_config = match load_game_creator_app_config() { + Ok(config) => config, + Err(error) => { + return GameCreatorLlmConfigStatus { + configured: false, + api_key_present: false, + base_url: None, + model: None, + 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.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); - "openai_responses".to_string() + DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string() }); - if let Some(error) = local_env_error { - status.configured = false; - status.error = Some(error); - return status; - } if status.configured { - if let Err(error) = build_game_creator_llm_client_from_env() { + if let Err(error) = build_game_creator_llm_client_from_config() { status.configured = false; status.error = Some(error); } @@ -1410,34 +1486,26 @@ fn check_game_creator_llm_config_from_env() -> GameCreatorLlmConfigStatus { } fn check_game_creator_llm_config_values( - api_key: Option, - base_url: Option, - model: Option, + config: &GameCreatorLlmConfig, ) -> GameCreatorLlmConfigStatus { + let api_key = trim_config_string(&config.api_key); + let base_url = trim_config_string(&config.base_url); + let model = trim_config_string(&config.model); let api_key_present = api_key .as_ref() .is_some_and(|value| !value.trim().is_empty()); let error = match (api_key.as_deref(), base_url.as_deref(), model.as_deref()) { - (None, _, _) => Some( - "LLM 未配置:请设置 GENARRATIVE_GAME_CREATOR_LLM_API_KEY / GENARRATIVE_LLM_API_KEY / LLM_API_KEY / OPENAI_API_KEY" - .to_string(), - ), - (_, None, _) => Some( - "LLM base_url 未配置:请设置 GENARRATIVE_GAME_CREATOR_LLM_BASE_URL / GENARRATIVE_LLM_BASE_URL / LLM_BASE_URL" - .to_string(), - ), - (_, _, None) => Some( - "LLM model 未配置:请设置 GENARRATIVE_GAME_CREATOR_LLM_MODEL / GENARRATIVE_LLM_MODEL / LLM_MODEL" - .to_string(), - ), + (None, _, _) => Some(llm_api_key_config_error()), + (_, None, _) => Some(llm_base_url_config_error()), + (_, _, None) => Some(llm_model_config_error()), (Some(api_key), Some(base_url), Some(model)) => LlmConfig::new( LlmProvider::OpenAiCompatible, base_url.to_string(), api_key.to_string(), model.to_string(), - GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS, - 0, - DEFAULT_RETRY_BACKOFF_MS, + config.request_timeout_ms, + config.max_retries, + config.retry_backoff_ms, ) .and_then(LlmClient::new) .err() @@ -1449,7 +1517,7 @@ fn check_game_creator_llm_config_values( api_key_present, base_url, model, - api_kind: "openai_responses".to_string(), + api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(), error, } } @@ -1840,7 +1908,7 @@ async fn request_planner_spec_with_client( long_memory, )), ]) - .with_api_kind(read_game_creator_llm_api_kind_from_env()?) + .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 @@ -1884,7 +1952,7 @@ async fn request_generator_game_draft_with_client( LlmMessage::system(system_prompt), LlmMessage::user(user_prompt.clone()), ]) - .with_api_kind(read_game_creator_llm_api_kind_from_env()?) + .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, @@ -1906,11 +1974,7 @@ async fn request_generator_game_draft_with_client( Err(error) => { // 调用失败(含空返回重试耗尽)时,把本次输入连同错误一并落盘,便于复现定位(仅 debug 构建)。 #[cfg(all(debug_assertions, not(test)))] - debug::persist_error_input( - system_prompt, - user_prompt.as_str(), - &error.to_string(), - ); + debug::persist_error_input(system_prompt, user_prompt.as_str(), &error.to_string()); return Err(format!("LLM 生成失败:{error}")); } } @@ -1934,17 +1998,9 @@ async fn request_game_creator_llm_text( } fn game_creator_llm_stream_enabled() -> bool { - read_first_non_empty_env(&[ - "GENARRATIVE_GAME_CREATOR_LLM_STREAM", - "GENARRATIVE_LLM_STREAM", - "LLM_STREAM", - ]) - .is_some_and(|value| { - matches!( - value.trim().to_ascii_lowercase().as_str(), - "1" | "true" | "yes" | "on" | "stream" - ) - }) + load_game_creator_app_config() + .map(|config| config.llm.stream) + .unwrap_or(false) } async fn request_agent_group_briefs_with_client( @@ -2111,7 +2167,9 @@ fn local_role_acceptance_risk( role_definition: AgentRoleDefinition, ) -> &'static str { match (group_definition.id, role_definition.id) { - ("code", _) => "缺 canvas 绘制、输入监听、胜负状态、重开或使用远程资源都会触发 Evaluator 返工。", + ("code", _) => { + "缺 canvas 绘制、输入监听、胜负状态、重开或使用远程资源都会触发 Evaluator 返工。" + } ("art", "asset") | ("audio", "sfx") => "未记录画板或本地资产占位会影响资产回流验收。", ("publishing", _) => "缺发布包装会影响最终 exports/README.md 与运营组 handoff。", _ => "输出空泛或偏离用户需求会增加 Generator 返工概率。", @@ -3364,13 +3422,11 @@ fn build_agent_run_task_graph_trace( &tasks, "preview-readiness", GameCreationAppTaskStatus::Completed, - ) - && !steps.iter().any(|step| { - step.task_id.as_deref() == Some("preview-playtest") - && step.phase == "preview" - && step.status == "running" - }) - { + ) && !steps.iter().any(|step| { + step.task_id.as_deref() == Some("preview-playtest") + && step.phase == "preview" + && step.status == "running" + }) { set_task_status_if_current( &mut tasks, "preview-playtest", @@ -3533,12 +3589,12 @@ fn task_status_from_agent_step( fn role_brief_completes_task(task_id: &str) -> bool { matches!( task_id, - "balance-director" - | "art-director" - | "art-polish" - | "audio-director" - | "code-director" - | "publish-strategy" + "balance-director" + | "art-director" + | "art-polish" + | "audio-director" + | "code-director" + | "publish-strategy" ) } @@ -3716,8 +3772,8 @@ fn append_agent_run_jsonl( fs::create_dir_all(parent) .map_err(|error| format!("创建 Agent 事件目录失败:{}: {error}", parent.display()))?; } - let mut line = serde_json::to_string(value) - .map_err(|error| format!("序列化 Agent 事件失败:{error}"))?; + let mut line = + serde_json::to_string(value).map_err(|error| format!("序列化 Agent 事件失败:{error}"))?; line.push('\n'); fs::OpenOptions::new() .create(true) @@ -3727,7 +3783,10 @@ fn append_agent_run_jsonl( .map_err(|error| format!("写入 Agent 事件失败:{}: {error}", path.display())) } -fn write_agent_run_context_bundle(root: &Path, trace: &GameCreationAgentRunTrace) -> Result<(), String> { +fn write_agent_run_context_bundle( + root: &Path, + trace: &GameCreationAgentRunTrace, +) -> Result<(), String> { let bundle_path = root.join(".agent/context.bundle.json"); if let Some(parent) = bundle_path.parent() { fs::create_dir_all(parent).map_err(|error| { @@ -3796,7 +3855,10 @@ fn update_agent_run_lifecycle( lifecycle.clone(), trace.next_step.clone(), "agent.run_status", - format!("run {} 当前状态:{} / {}", trace.run_id, trace.status, lifecycle), + format!( + "run {} 当前状态:{} / {}", + trace.run_id, trace.status, lifecycle + ), ) } "kill" => ( @@ -3859,8 +3921,14 @@ fn update_agent_run_lifecycle( .unwrap_or_else(|| agent_run_lifecycle_status("scheduled").to_string()), next_step: trace.next_step, message, - activity_path: root.join(".agent/activity.jsonl").to_string_lossy().to_string(), - output_path: root.join(".agent/output.jsonl").to_string_lossy().to_string(), + activity_path: root + .join(".agent/activity.jsonl") + .to_string_lossy() + .to_string(), + output_path: root + .join(".agent/output.jsonl") + .to_string_lossy() + .to_string(), context_bundle_path: root .join(".agent/context.bundle.json") .to_string_lossy() @@ -4027,8 +4095,8 @@ fn fnv1a64(bytes: &[u8]) -> u64 { fn parse_llm_game_draft_response(content: &str) -> Result { let content = strip_llm_thinking_blocks(content); - let payload = - extract_json_payload(content.as_str()).ok_or_else(|| "LLM 返回不是 JSON 对象".to_string())?; + let payload = extract_json_payload(content.as_str()) + .ok_or_else(|| "LLM 返回不是 JSON 对象".to_string())?; serde_json::from_str::(payload) .map_err(|error| format!("解析 LLM 游戏草案失败:{error}")) } @@ -4426,14 +4494,72 @@ fn read_optional_text(path: &Path) -> Result { } } -fn load_game_creator_local_env() -> Result<(), String> { - for path in game_creator_local_env_paths() { - load_game_creator_env_file(&path)?; +fn configure_game_creator_runtime_config_dir( + app: &tauri::AppHandle, +) -> Result<(), Box> { + let config_dir = app.path().app_config_dir()?; + fs::create_dir_all(&config_dir)?; + let config_path = config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME); + if !config_path.exists() { + fs::write(&config_path, DEFAULT_GAME_CREATOR_APP_CONFIG_JSON)?; } + set_game_creator_runtime_config_dir(config_dir); Ok(()) } -fn game_creator_local_env_paths() -> Vec { +fn game_creator_runtime_config_dir_lock() -> &'static Mutex> { + GAME_CREATOR_RUNTIME_CONFIG_DIR.get_or_init(|| Mutex::new(None)) +} + +fn set_game_creator_runtime_config_dir(path: PathBuf) { + *game_creator_runtime_config_dir_lock() + .lock() + .expect("runtime config dir lock") = Some(path); +} + +fn game_creator_runtime_config_dir() -> Option { + game_creator_runtime_config_dir_lock() + .lock() + .expect("runtime config dir lock") + .clone() +} + +fn load_game_creator_app_config() -> Result { + let mut config = GameCreatorAppConfig::default(); + for path in game_creator_config_paths() { + merge_game_creator_config_file(&mut config, &path)?; + } + Ok(config) +} + +fn game_creator_app_config_view( + config: GameCreatorAppConfig, +) -> Result { + Ok(GameCreatorAppConfigView { + path: writable_game_creator_config_path()? + .display() + .to_string(), + config, + }) +} + +fn writable_game_creator_config_path() -> Result { + if let Some(config_dir) = game_creator_runtime_config_dir() { + return Ok(config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME)); + } + std::env::current_dir() + .map(|directory| directory.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME)) + .map_err(|error| format!("读取当前目录失败:{error}")) +} + +fn game_creator_config_paths() -> Vec { + if let Some(config_dir) = game_creator_runtime_config_dir() { + return vec![ + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + config_dir.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME), + ]; + } + let mut roots = Vec::new(); if let Ok(cwd) = std::env::current_dir() { roots.push(cwd); @@ -4444,20 +4570,37 @@ fn game_creator_local_env_paths() -> Vec { } } - let mut paths = Vec::new(); + let mut default_paths = Vec::new(); + let mut local_paths = Vec::new(); for root in roots { - for directory in root.ancestors().take(8) { - push_unique_path(&mut paths, directory.join(".env.secrets.local")); + let ancestors = root.ancestors().take(8).collect::>(); + for directory in ancestors.into_iter().rev() { push_unique_path( - &mut paths, + &mut default_paths, + directory.join(GAME_CREATOR_CONFIG_FILE_NAME), + ); + push_unique_path( + &mut default_paths, directory .join("apps") .join("ai-game-creator-shell") - .join(".env.secrets.local"), + .join(GAME_CREATOR_CONFIG_FILE_NAME), + ); + push_unique_path( + &mut local_paths, + directory.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME), + ); + push_unique_path( + &mut local_paths, + directory + .join("apps") + .join("ai-game-creator-shell") + .join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME), ); } } - paths + default_paths.extend(local_paths); + default_paths } fn push_unique_path(paths: &mut Vec, path: PathBuf) { @@ -4466,79 +4609,124 @@ fn push_unique_path(paths: &mut Vec, path: PathBuf) { } } -fn load_game_creator_env_file(path: &Path) -> Result<(), String> { +fn merge_game_creator_config_file( + config: &mut GameCreatorAppConfig, + path: &Path, +) -> Result<(), String> { if !path.is_file() { return Ok(()); } let content = fs::read_to_string(path) - .map_err(|error| format!("读取本地 LLM 配置失败:{}: {error}", path.display()))?; - for raw_line in content.lines() { - let line = raw_line.trim_start_matches('\u{feff}').trim(); - if line.is_empty() || line.starts_with('#') { - continue; - } - let line = line.strip_prefix("export ").unwrap_or(line).trim_start(); - let Some((key, value)) = line.split_once('=') else { - continue; - }; - let key = key.trim(); - if key.is_empty() || key.chars().any(char::is_whitespace) { - continue; - } - let should_set = std::env::var(key) - .map(|current| current.trim().is_empty()) - .unwrap_or(true); - if should_set { - std::env::set_var(key, parse_env_file_value(value)); - } + .map_err(|error| format!("读取客户端配置失败:{}: {error}", path.display()))?; + let file_config = serde_json::from_str::(&content) + .map_err(|error| format!("解析客户端配置失败:{}: {error}", path.display()))?; + if let Some(llm) = file_config.llm { + merge_game_creator_llm_config(&mut config.llm, llm); + } + if let Some(editor_api) = file_config.editor_api { + merge_game_creator_editor_api_config(&mut config.editor_api, editor_api); } Ok(()) } -fn parse_env_file_value(value: &str) -> String { - let value = value.trim(); - if value.len() >= 2 - && ((value.starts_with('"') && value.ends_with('"')) - || (value.starts_with('\'') && value.ends_with('\''))) - { - value[1..value.len() - 1].to_string() - } else { - value.to_string() +fn merge_game_creator_llm_config( + config: &mut GameCreatorLlmConfig, + patch: GameCreatorLlmConfigFile, +) { + if let Some(value) = patch.api_key { + config.api_key = value; + } + if let Some(value) = patch.base_url { + config.base_url = value; + } + if let Some(value) = patch.model { + config.model = value; + } + if let Some(value) = patch.api_kind { + config.api_kind = value; + } + if let Some(value) = patch.stream { + config.stream = value; + } + if let Some(value) = patch.request_timeout_ms { + config.request_timeout_ms = value; + } + if let Some(value) = patch.max_retries { + config.max_retries = value; + } + if let Some(value) = patch.retry_backoff_ms { + config.retry_backoff_ms = value; } } -fn read_first_non_empty_env(names: &[&str]) -> Option { - names.iter().find_map(|name| { - std::env::var(name) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) - }) +fn merge_game_creator_editor_api_config( + config: &mut GameCreatorEditorApiConfig, + patch: GameCreatorEditorApiConfigFile, +) { + if let Some(value) = patch.base_url { + config.base_url = value; + } + if let Some(value) = patch.api_key { + config.api_key = value; + } } -fn read_u64_env(names: &[&str], default_value: u64) -> Result { - let Some(value) = read_first_non_empty_env(names) else { - return Ok(default_value); - }; - value - .parse::() - .map_err(|_| format!("环境变量 {} 必须是正整数", names.join(" / "))) - .and_then(|parsed| { - if parsed == 0 { - Err(format!("环境变量 {} 必须大于 0", names.join(" / "))) - } else { - Ok(parsed) - } - }) +fn trim_config_string(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + None + } else { + Some(value.to_string()) + } } -fn read_u32_env(names: &[&str], default_value: u32) -> Result { - let Some(value) = read_first_non_empty_env(names) else { - return Ok(default_value); - }; - value - .parse::() - .map_err(|_| format!("环境变量 {} 必须是非负整数", names.join(" / "))) +fn normalize_game_creator_app_config( + mut config: GameCreatorAppConfig, +) -> Result { + config.llm.api_key = config.llm.api_key.trim().to_string(); + 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.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()); + } + if config.llm.retry_backoff_ms == 0 { + return Err("配置项 llm.retryBackoffMs 必须大于 0".to_string()); + } + config.editor_api.base_url = trim_config_string(&config.editor_api.base_url) + .ok_or_else(|| "配置项 editorApi.baseUrl 不能为空".to_string())?; + config.editor_api.api_key = config.editor_api.api_key.trim().to_string(); + Ok(config) +} + +fn llm_api_key_config_error() -> String { + format!( + "LLM 未配置:请在 {} 的 llm.apiKey 中设置 API Key", + game_creator_config_file_label(GAME_CREATOR_CONFIG_FILE_NAME) + ) +} + +fn llm_base_url_config_error() -> String { + format!( + "LLM base_url 未配置:请在 {} 的 llm.baseUrl 中设置", + game_creator_config_file_label(GAME_CREATOR_CONFIG_FILE_NAME) + ) +} + +fn llm_model_config_error() -> String { + format!( + "LLM model 未配置:请在 {} 的 llm.model 中设置", + game_creator_config_file_label(GAME_CREATOR_CONFIG_FILE_NAME) + ) +} + +fn game_creator_config_file_label(file_name: &str) -> String { + game_creator_runtime_config_dir() + .map(|directory| directory.join(file_name).display().to_string()) + .unwrap_or_else(|| file_name.to_string()) } fn upload_local_asset_at( @@ -4988,10 +5176,10 @@ async fn resolve_external_asset_signed_url( } fn resolve_canvas_sync_api_base_url(api_base_url: Option) -> Result { + let config = load_game_creator_app_config()?; let value = trim_optional_string(api_base_url) - .or_else(|| std::env::var("GENARRATIVE_GAME_CREATOR_EDITOR_API_BASE_URL").ok()) - .or_else(|| std::env::var("GENARRATIVE_EXTERNAL_API_BASE_URL").ok()) - .unwrap_or_else(|| "http://127.0.0.1:8082".to_string()); + .or_else(|| trim_config_string(&config.editor_api.base_url)) + .unwrap_or_else(|| DEFAULT_CANVAS_SYNC_API_BASE_URL.to_string()); let value = value.trim().trim_end_matches('/').to_string(); if value.starts_with("http://") || value.starts_with("https://") { Ok(value) @@ -5001,12 +5189,14 @@ fn resolve_canvas_sync_api_base_url(api_base_url: Option) -> Result) -> Result { + let config = load_game_creator_app_config()?; trim_optional_string(api_key) - .or_else(|| std::env::var("GENARRATIVE_GAME_CREATOR_EDITOR_API_KEY").ok()) - .or_else(|| std::env::var("GENARRATIVE_EXTERNAL_API_KEY").ok()) + .or_else(|| trim_config_string(&config.editor_api.api_key)) .ok_or_else(|| { - "画板同步需要 GENARRATIVE_GAME_CREATOR_EDITOR_API_KEY 或 GENARRATIVE_EXTERNAL_API_KEY" - .to_string() + format!( + "画板同步需要在 {} 的 editorApi.apiKey 中设置 API Key", + game_creator_config_file_label(GAME_CREATOR_CONFIG_FILE_NAME) + ) }) } @@ -5403,15 +5593,18 @@ fn append_local_permission_log_at( validate_project_root(root)?; if !matches!( event, - "permission.pending" | "permission.confirm" | "permission.cancel" + "permission.pending" | "permission.confirm" | "permission.cancel" | "command.auto" ) { - return Err("不支持的权限日志事件".to_string()); + return Err("不支持的命令日志事件".to_string()); } - if !GAME_CREATION_APP_COMMANDS + let Some(command) = GAME_CREATION_APP_COMMANDS .iter() - .any(|command| command.id == command_id) - { + .find(|command| command.id == command_id) + else { return Err("不支持的内置命令".to_string()); + }; + if event == "command.auto" && command.permission != GameCreationAppPermission::Auto { + return Err("自动命令日志只能记录 auto 权限命令".to_string()); } let log_path = root.join(".agent/logs/command.log"); @@ -6172,7 +6365,7 @@ fn parse_cli_command(args: &[String]) -> Result, String> { fn run_cli_command(command: CliCommand) -> Result<(), String> { match command { CliCommand::LlmStatus => { - let status = check_game_creator_llm_config_from_env(); + let status = check_game_creator_llm_config_from_config(); println!("llm.configured={}", status.configured); println!("llm.apiKeyPresent={}", status.api_key_present); println!("llm.baseUrl={}", status.base_url.unwrap_or_default()); @@ -6251,10 +6444,6 @@ fn open_developer_window(app: &tauri::App) -> tauri::Result<()> { } fn main() { - if let Err(error) = load_game_creator_local_env() { - eprintln!("local.env.load.failed: {error}"); - } - let args = std::env::args().skip(1).collect::>(); match parse_cli_command(&args) { Ok(Some(command)) => { @@ -6275,6 +6464,7 @@ fn main() { .plugin(tauri_plugin_opener::init()) .manage(PreviewRegistry::default()) .setup(|app| { + configure_game_creator_runtime_config_dir(app.handle())?; #[cfg(debug_assertions)] open_developer_window(app)?; #[cfg(not(debug_assertions))] @@ -6288,6 +6478,8 @@ fn main() { control_agent_run, generate_local_game_draft, check_game_creator_llm_config, + read_game_creator_app_config, + write_game_creator_app_config, upload_local_asset, register_local_asset, import_canvas_asset, @@ -6321,12 +6513,41 @@ mod tests { use serde_json::Value; use std::io::{Read, Write}; use std::sync::atomic::{AtomicU64, Ordering}; - use std::sync::Mutex as StdMutex; + use std::sync::{Mutex as StdMutex, MutexGuard as StdMutexGuard}; use std::time::{SystemTime, UNIX_EPOCH}; use zip::write::SimpleFileOptions; static TEST_PROJECT_COUNTER: AtomicU64 = AtomicU64::new(0); - static TEST_ENV_LOCK: StdMutex<()> = StdMutex::new(()); + static TEST_CONFIG_LOCK: StdMutex<()> = StdMutex::new(()); + + struct TestConfigGuard { + _lock: StdMutexGuard<'static, ()>, + path: PathBuf, + previous: Option>, + } + + struct TestRuntimeConfigDirGuard { + _lock: StdMutexGuard<'static, ()>, + previous: Option, + } + + impl Drop for TestConfigGuard { + fn drop(&mut self) { + if let Some(previous) = &self.previous { + fs::write(&self.path, previous).expect("restore local config"); + } else if self.path.exists() { + fs::remove_file(&self.path).expect("remove local config"); + } + } + } + + impl Drop for TestRuntimeConfigDirGuard { + fn drop(&mut self) { + *game_creator_runtime_config_dir_lock() + .lock() + .expect("runtime config dir lock") = self.previous.clone(); + } + } fn unique_project_path() -> PathBuf { let millis = SystemTime::now() @@ -6340,72 +6561,168 @@ mod tests { )) } - fn restore_env(name: &str, value: Option) { - if let Some(value) = value { - std::env::set_var(name, value); - } else { - std::env::remove_var(name); + fn test_local_config_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("app root") + .join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME) + } + + fn write_test_local_config(content: String) -> TestConfigGuard { + let lock = TEST_CONFIG_LOCK.lock().expect("test config lock"); + let path = test_local_config_path(); + let previous = fs::read(&path).ok(); + fs::write(&path, content).expect("write local config"); + TestConfigGuard { + _lock: lock, + path, + previous, + } + } + + fn use_test_runtime_config_dir(path: PathBuf) -> TestRuntimeConfigDirGuard { + let lock = TEST_CONFIG_LOCK.lock().expect("test config lock"); + let previous = game_creator_runtime_config_dir(); + set_game_creator_runtime_config_dir(path); + TestRuntimeConfigDirGuard { + _lock: lock, + previous, } } #[test] - fn local_env_file_fills_missing_llm_config_without_overriding_process_env() { - let _env_guard = TEST_ENV_LOCK.lock().expect("test env lock"); + fn config_file_overrides_defaults_without_env() { let root = unique_project_path(); - fs::create_dir_all(&root).expect("test env dir"); - let env_path = root.join(".env.secrets.local"); + fs::create_dir_all(&root).expect("test config dir"); + let config_path = root.join(GAME_CREATOR_CONFIG_FILE_NAME); fs::write( - &env_path, - r#" -GENARRATIVE_GAME_CREATOR_LLM_API_KEY=file-key -GENARRATIVE_GAME_CREATOR_LLM_BASE_URL="https://example.test/v1" -export GENARRATIVE_GAME_CREATOR_LLM_MODEL='model-from-file' -GENARRATIVE_GAME_CREATOR_LLM_API_KIND=openai_chat -GENARRATIVE_GAME_CREATOR_LLM_STREAM=true + &config_path, + r#"{ + "llm": { + "apiKey": "file-key", + "baseUrl": "https://example.test/v1", + "model": "model-from-file", + "apiKind": "openai_chat", + "stream": true, + "requestTimeoutMs": 42000, + "maxRetries": 2, + "retryBackoffMs": 700 + }, + "editorApi": { + "baseUrl": "http://127.0.0.1:8099", + "apiKey": "editor-key" + } +} "#, ) - .expect("write local env"); + .expect("write local config"); - let api_key = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_API_KEY").ok(); - let base_url = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_BASE_URL").ok(); - let model = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_MODEL").ok(); - let api_kind = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_API_KIND").ok(); - let stream = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_STREAM").ok(); - std::env::set_var("GENARRATIVE_GAME_CREATOR_LLM_API_KEY", "process-key"); - std::env::remove_var("GENARRATIVE_GAME_CREATOR_LLM_BASE_URL"); - std::env::remove_var("GENARRATIVE_GAME_CREATOR_LLM_MODEL"); - std::env::remove_var("GENARRATIVE_GAME_CREATOR_LLM_API_KIND"); - std::env::remove_var("GENARRATIVE_GAME_CREATOR_LLM_STREAM"); + let mut config = GameCreatorAppConfig::default(); + merge_game_creator_config_file(&mut config, &config_path).expect("merge config"); - load_game_creator_env_file(&env_path).expect("load local env"); + 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.api_kind, "openai_chat"); + assert!(config.llm.stream); + assert_eq!(config.llm.request_timeout_ms, 42_000); + assert_eq!(config.llm.max_retries, 2); + assert_eq!(config.llm.retry_backoff_ms, 700); + assert_eq!(config.editor_api.base_url, "http://127.0.0.1:8099"); + assert_eq!(config.editor_api.api_key, "editor-key"); + + fs::remove_dir_all(root).expect("cleanup test config dir"); + } + + #[test] + fn runtime_config_dir_supplies_app_config_file() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("runtime config dir"); + fs::write( + root.join(GAME_CREATOR_CONFIG_FILE_NAME), + r#"{ + "llm": { + "apiKey": "runtime-key", + "baseUrl": "https://runtime.example.test/v1", + "model": "runtime-model" + } +} +"#, + ) + .expect("write runtime config"); + let _guard = use_test_runtime_config_dir(root.clone()); + + let config = load_game_creator_app_config().expect("load runtime config"); + + assert_eq!(config.llm.api_key, "runtime-key"); + assert_eq!(config.llm.base_url, "https://runtime.example.test/v1"); + assert_eq!(config.llm.model, "runtime-model"); + assert!(llm_api_key_config_error().contains(&root.to_string_lossy().to_string())); + fs::remove_dir_all(root).expect("cleanup runtime config dir"); + } + + #[test] + fn app_config_commands_write_runtime_config_file() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("runtime config dir"); + let _guard = use_test_runtime_config_dir(root.clone()); + + let saved = write_game_creator_app_config(GameCreatorAppConfig { + llm: GameCreatorLlmConfig { + api_key: " unit-test-key ".to_string(), + base_url: " https://runtime.example.test/v1 ".to_string(), + model: " runtime-model ".to_string(), + api_kind: "openai_chat".to_string(), + stream: true, + request_timeout_ms: 42_000, + max_retries: 2, + retry_backoff_ms: 700, + }, + editor_api: GameCreatorEditorApiConfig { + base_url: " http://127.0.0.1:8099 ".to_string(), + api_key: " editor-key ".to_string(), + }, + }) + .expect("write runtime config"); assert_eq!( - std::env::var("GENARRATIVE_GAME_CREATOR_LLM_API_KEY").as_deref(), - Ok("process-key") - ); - assert_eq!( - std::env::var("GENARRATIVE_GAME_CREATOR_LLM_BASE_URL").as_deref(), - Ok("https://example.test/v1") - ); - assert_eq!( - std::env::var("GENARRATIVE_GAME_CREATOR_LLM_MODEL").as_deref(), - Ok("model-from-file") - ); - assert_eq!( - std::env::var("GENARRATIVE_GAME_CREATOR_LLM_API_KIND").as_deref(), - Ok("openai_chat") - ); - assert_eq!( - std::env::var("GENARRATIVE_GAME_CREATOR_LLM_STREAM").as_deref(), - Ok("true") + saved.path, + root.join(GAME_CREATOR_CONFIG_FILE_NAME) + .display() + .to_string() ); + 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.api_kind, "openai_chat"); + assert_eq!(saved.config.editor_api.api_key, "editor-key"); + assert!(root.join(GAME_CREATOR_CONFIG_FILE_NAME).is_file()); - restore_env("GENARRATIVE_GAME_CREATOR_LLM_API_KEY", api_key); - restore_env("GENARRATIVE_GAME_CREATOR_LLM_BASE_URL", base_url); - restore_env("GENARRATIVE_GAME_CREATOR_LLM_MODEL", model); - restore_env("GENARRATIVE_GAME_CREATOR_LLM_API_KIND", api_kind); - restore_env("GENARRATIVE_GAME_CREATOR_LLM_STREAM", stream); - fs::remove_dir_all(root).expect("cleanup test env dir"); + let read_back = read_game_creator_app_config().expect("read runtime config"); + assert_eq!(read_back.config.llm.model, "runtime-model"); + assert_eq!(read_back.config.llm.request_timeout_ms, 42_000); + fs::remove_dir_all(root).expect("cleanup runtime config dir"); + } + + #[test] + 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()); + + let result = write_game_creator_app_config(GameCreatorAppConfig { + llm: GameCreatorLlmConfig { + api_key: String::new(), + api_kind: "legacy".to_string(), + ..GameCreatorLlmConfig::default() + }, + editor_api: GameCreatorEditorApiConfig::default(), + }); + + assert!(result + .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"); } fn assert_task_status(manifest: &Value, task_id: &str, status: &str) { @@ -6789,7 +7106,6 @@ GENARRATIVE_GAME_CREATOR_LLM_STREAM=true #[tokio::test] async fn generate_local_game_draft_sends_asset_context_to_llm() { - let _env_guard = TEST_ENV_LOCK.lock().expect("test env lock"); let root = unique_project_path(); let uploaded = upload_local_asset_at(&root, "../角色.png", "image/png", b"fake-png") .expect("asset upload"); @@ -6800,21 +7116,19 @@ GENARRATIVE_GAME_CREATOR_LLM_STREAM=true responses.push(serde_json::to_string(&fake_llm_game_draft()).expect("draft json")); let (sender, receiver) = mpsc::channel(); let base_url = spawn_mock_llm_server_responses_with_capture(responses, Some(sender)); - let previous_api_key = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_API_KEY").ok(); - let previous_base_url = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_BASE_URL").ok(); - let previous_model = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_MODEL").ok(); - let previous_api_kind = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_API_KIND").ok(); - std::env::set_var("GENARRATIVE_GAME_CREATOR_LLM_API_KEY", "test-key"); - std::env::set_var("GENARRATIVE_GAME_CREATOR_LLM_BASE_URL", base_url); - std::env::set_var("GENARRATIVE_GAME_CREATOR_LLM_MODEL", "mock-game-model"); - std::env::remove_var("GENARRATIVE_GAME_CREATOR_LLM_API_KIND"); + let _config_guard = write_test_local_config(format!( + r#"{{ + "llm": {{ + "apiKey": "test-key", + "baseUrl": {base_url:?}, + "model": "mock-game-model", + "apiKind": "openai_responses" + }} +}}"# + )); let result = generate_local_game_draft_at(&root, "用上传角色图做主角", None).await; - restore_env("GENARRATIVE_GAME_CREATOR_LLM_API_KEY", previous_api_key); - restore_env("GENARRATIVE_GAME_CREATOR_LLM_BASE_URL", previous_base_url); - restore_env("GENARRATIVE_GAME_CREATOR_LLM_MODEL", previous_model); - restore_env("GENARRATIVE_GAME_CREATOR_LLM_API_KIND", previous_api_kind); result.expect("generated draft"); let requests = receiver.try_iter().collect::>(); @@ -6828,16 +7142,20 @@ GENARRATIVE_GAME_CREATOR_LLM_STREAM=true #[test] fn llm_config_check_reports_status_without_leaking_key() { - let missing = check_game_creator_llm_config_values(None, None, None); + let missing = check_game_creator_llm_config_values(&GameCreatorLlmConfig { + api_key: String::new(), + ..GameCreatorLlmConfig::default() + }); assert!(!missing.configured); assert!(!missing.api_key_present); assert!(missing.error.unwrap().contains("LLM 未配置")); - let configured = check_game_creator_llm_config_values( - Some("unit-test-api-key".to_string()), - Some("http://127.0.0.1:1/v1".to_string()), - Some("mock-game-model".to_string()), - ); + let configured = check_game_creator_llm_config_values(&GameCreatorLlmConfig { + api_key: "unit-test-api-key".to_string(), + base_url: "http://127.0.0.1:1/v1".to_string(), + model: "mock-game-model".to_string(), + ..GameCreatorLlmConfig::default() + }); assert!(configured.configured); assert!(configured.api_key_present); assert_eq!( @@ -6852,29 +7170,24 @@ GENARRATIVE_GAME_CREATOR_LLM_STREAM=true } #[test] - fn llm_api_kind_env_reads_canonical_names() { - let _env_guard = TEST_ENV_LOCK.lock().expect("test env lock"); - let api_kind = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_API_KIND").ok(); - - std::env::set_var("GENARRATIVE_GAME_CREATOR_LLM_API_KIND", "anthropic"); + fn llm_api_kind_parses_canonical_names() { assert_eq!( - read_game_creator_llm_api_kind_from_env(), + parse_game_creator_llm_api_kind("anthropic"), Ok(LlmApiKind::Anthropic) ); - - std::env::set_var("GENARRATIVE_GAME_CREATOR_LLM_API_KIND", "openai_chat"); assert_eq!( - read_game_creator_llm_api_kind_from_env(), + parse_game_creator_llm_api_kind("openai_chat"), Ok(LlmApiKind::OpenAiChat) ); - - std::env::set_var("GENARRATIVE_GAME_CREATOR_LLM_API_KIND", "openai_responses"); assert_eq!( - read_game_creator_llm_api_kind_from_env(), + parse_game_creator_llm_api_kind("openai_responses"), Ok(LlmApiKind::OpenAiResponses) ); - - restore_env("GENARRATIVE_GAME_CREATOR_LLM_API_KIND", api_kind); + assert_eq!( + parse_game_creator_llm_api_kind(""), + Ok(LlmApiKind::OpenAiResponses) + ); + assert!(parse_game_creator_llm_api_kind("legacy").is_err()); } #[tokio::test] @@ -7322,7 +7635,6 @@ GENARRATIVE_GAME_CREATOR_LLM_STREAM=true #[tokio::test] async fn generate_local_game_draft_fails_after_max_passes_without_final_artifacts() { - let _env_guard = TEST_ENV_LOCK.lock().expect("test env lock"); let root = unique_project_path(); let mut invalid_draft = fake_llm_game_draft(); invalid_draft.game_html = r#" @@ -7345,23 +7657,21 @@ GENARRATIVE_GAME_CREATOR_LLM_STREAM=true responses.push(invalid_draft_json.clone()); responses.push(invalid_draft_json); let base_url = spawn_mock_llm_server_responses(responses); - let previous_api_key = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_API_KEY").ok(); - let previous_base_url = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_BASE_URL").ok(); - let previous_model = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_MODEL").ok(); - let previous_api_kind = std::env::var("GENARRATIVE_GAME_CREATOR_LLM_API_KIND").ok(); - std::env::set_var("GENARRATIVE_GAME_CREATOR_LLM_API_KEY", "test-key"); - std::env::set_var("GENARRATIVE_GAME_CREATOR_LLM_BASE_URL", base_url); - std::env::set_var("GENARRATIVE_GAME_CREATOR_LLM_MODEL", "mock-game-model"); - std::env::remove_var("GENARRATIVE_GAME_CREATOR_LLM_API_KIND"); + let _config_guard = write_test_local_config(format!( + r#"{{ + "llm": {{ + "apiKey": "test-key", + "baseUrl": {base_url:?}, + "model": "mock-game-model", + "apiKind": "openai_responses" + }} +}}"# + )); let error = generate_local_game_draft_at(&root, "做一个会失败三轮的厨房游戏", None) .await .expect_err("max-pass failure should bubble out"); - restore_env("GENARRATIVE_GAME_CREATOR_LLM_API_KEY", previous_api_key); - restore_env("GENARRATIVE_GAME_CREATOR_LLM_BASE_URL", previous_base_url); - restore_env("GENARRATIVE_GAME_CREATOR_LLM_MODEL", previous_model); - restore_env("GENARRATIVE_GAME_CREATOR_LLM_API_KIND", previous_api_kind); assert!(error.contains("已重试")); assert!(error.contains(&GAME_CREATOR_AGENT_LOOP_MAX_PASSES.to_string())); assert!(!root.join("memory/session.md").exists()); @@ -8344,11 +8654,13 @@ GENARRATIVE_GAME_CREATOR_LLM_STREAM=true .expect("confirm log"); append_local_permission_log_at(&root, "permission.cancel", "memory.write") .expect("cancel log"); + append_local_permission_log_at(&root, "command.auto", "preview.status").expect("auto log"); let log = fs::read_to_string(root.join(".agent/logs/command.log")).expect("command log"); assert!(log.contains("permission.pending preview.start")); assert!(log.contains("permission.confirm preview.start")); assert!(log.contains("permission.cancel memory.write")); + assert!(log.contains("command.auto preview.status")); fs::remove_dir_all(root).ok(); } @@ -8358,14 +8670,19 @@ GENARRATIVE_GAME_CREATOR_LLM_STREAM=true let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); - let event_error = append_local_permission_log_at(&root, "permission.grant", "preview.start") - .expect_err("unknown event should fail"); + let event_error = + append_local_permission_log_at(&root, "permission.grant", "preview.start") + .expect_err("unknown event should fail"); let command_error = append_local_permission_log_at(&root, "permission.pending", "shell.exec") .expect_err("unknown command should fail"); + let auto_permission_error = + append_local_permission_log_at(&root, "command.auto", "agent.retry") + .expect_err("confirm command should not be auto-logged"); - assert!(event_error.contains("不支持的权限日志事件")); + assert!(event_error.contains("不支持的命令日志事件")); assert!(command_error.contains("不支持的内置命令")); + assert!(auto_permission_error.contains("auto 权限命令")); fs::remove_dir_all(root).ok(); } @@ -8459,6 +8776,16 @@ GENARRATIVE_GAME_CREATOR_LLM_STREAM=true assert_eq!(retried.status, "pending"); assert_eq!(retried.lifecycle_status, "pending"); assert_eq!(retried.next_step, "runner-claim"); + let resumed = update_agent_run_lifecycle(&root, "resume", Some("继续修复输入监听")) + .expect("resume should mark pending"); + assert_eq!(resumed.status, "pending"); + assert_eq!(resumed.lifecycle_status, "pending"); + assert_eq!(resumed.next_step, "runner-claim"); + let trace: Value = + serde_json::from_str(&fs::read_to_string(root.join(".agent/run.latest.json")).unwrap()) + .expect("run trace json after resume"); + assert_eq!(trace["stopReason"], "human-resume"); + assert_eq!(trace["error"], Value::Null); let status = update_agent_run_lifecycle(&root, "status", None).expect("status should read trace"); assert_eq!(status.status, "pending"); @@ -8466,9 +8793,11 @@ GENARRATIVE_GAME_CREATOR_LLM_STREAM=true let activity = fs::read_to_string(root.join(".agent/activity.jsonl")).expect("activity"); assert!(activity.contains("agent.kill")); assert!(activity.contains("agent.retry")); + assert!(activity.contains("agent.resume")); assert!(activity.contains("agent.run_status")); let output = fs::read_to_string(root.join(".agent/output.jsonl")).expect("output"); assert!(output.contains("agent.kill")); + assert!(output.contains("agent.resume")); assert!(root.join(".agent/context.bundle.json").exists()); fs::remove_dir_all(root).ok(); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 4180a8b7d..a9c2b086a 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -10,6 +10,7 @@ import { GAME_CREATION_APP_COMMANDS, GAME_CREATION_APP_LIMITED_RUN_COMMANDS, type GameCreationAppAgentGroup, + type GameCreationAppCommandDescriptor, type GameCreationAgentRunTrace, type GameCreationAppManifest, type GameCreationAppPermission, @@ -63,6 +64,30 @@ interface GameCreatorLlmConfigStatus { error: string | null; } +type GameCreatorLlmApiKind = 'openai_responses' | 'openai_chat' | 'anthropic'; + +interface GameCreatorAppConfig { + llm: { + apiKey: string; + baseUrl: string; + model: string; + apiKind: GameCreatorLlmApiKind; + stream: boolean; + requestTimeoutMs: number; + maxRetries: number; + retryBackoffMs: number; + }; + editorApi: { + baseUrl: string; + apiKey: string; + }; +} + +interface GameCreatorAppConfigView { + path: string; + config: GameCreatorAppConfig; +} + interface UploadLocalAssetResult { id: string; localPath: string; @@ -296,6 +321,23 @@ const chatCommandHelp = [ '/import-canvas-export /绝对/导出.zip 画板项目ID:导入画板素材导出包', ]; +const defaultRuntimeConfigDraft: GameCreatorAppConfig = { + llm: { + apiKey: '', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4.1', + apiKind: 'openai_responses', + stream: false, + requestTimeoutMs: 180000, + maxRetries: 0, + retryBackoffMs: 500, + }, + editorApi: { + baseUrl: 'http://127.0.0.1:8082', + apiKey: '', + }, +}; + function taskRowsFromManifest( manifest: GameCreationAppManifest, ): GameCreationAppTaskState[] { @@ -1053,6 +1095,11 @@ export function App() { LocalProjectFileEntry[] >([]); const [agentRunStatus, setAgentRunStatus] = useState('未运行'); + const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false); + const [runtimeConfigPath, setRuntimeConfigPath] = useState(''); + const [runtimeConfigStatus, setRuntimeConfigStatus] = useState('未读取'); + const [runtimeConfigDraft, setRuntimeConfigDraft] = + useState(defaultRuntimeConfigDraft); const [messages, setMessages] = useState([ { role: 'assistant', @@ -1099,8 +1146,12 @@ export function App() { function appendLocalPermissionLog( projectPath: string | null, - event: 'permission.pending' | 'permission.confirm' | 'permission.cancel', - commandId: PendingCommand['id'], + event: + | 'permission.pending' + | 'permission.confirm' + | 'permission.cancel' + | 'command.auto', + commandId: GameCreationAppCommandDescriptor['id'], ) { const invoke = resolveTauriInvoke(); if (!invoke || !projectPath) { @@ -1160,6 +1211,80 @@ export function App() { return true; } + function updateRuntimeLlmConfig( + key: K, + value: GameCreatorAppConfig['llm'][K], + ) { + setRuntimeConfigDraft((current) => ({ + ...current, + llm: { + ...current.llm, + [key]: value, + }, + })); + } + + function updateRuntimeEditorConfig< + K extends keyof GameCreatorAppConfig['editorApi'], + >(key: K, value: GameCreatorAppConfig['editorApi'][K]) { + setRuntimeConfigDraft((current) => ({ + ...current, + editorApi: { + ...current.editorApi, + [key]: value, + }, + })); + } + + function handleRuntimeConfigOpen() { + setRuntimeConfigOpen(true); + void readRuntimeConfig(); + } + + async function readRuntimeConfig() { + const invoke = resolveTauriInvoke(); + if (!invoke) { + setRuntimeConfigStatus('需要在 Tauri App 内运行'); + return; + } + + setRuntimeConfigStatus('正在读取'); + try { + const result = await invoke( + 'read_game_creator_app_config', + ); + setRuntimeConfigPath(result.path); + setRuntimeConfigDraft(result.config); + setRuntimeConfigStatus(`已读取:${result.path}`); + setCommandLog((current) => [...current, 'runtime_config.read']); + } catch (error) { + setRuntimeConfigStatus(error instanceof Error ? error.message : String(error)); + } + } + + async function handleRuntimeConfigSave(event: FormEvent) { + event.preventDefault(); + const invoke = resolveTauriInvoke(); + if (!invoke) { + setRuntimeConfigStatus('需要在 Tauri App 内运行'); + return; + } + + setRuntimeConfigStatus('正在保存'); + try { + const result = await invoke( + 'write_game_creator_app_config', + { config: runtimeConfigDraft }, + ); + setRuntimeConfigPath(result.path); + setRuntimeConfigDraft(result.config); + setRuntimeConfigStatus(`已保存:${result.path}`); + setCommandLog((current) => [...current, 'runtime_config.save']); + } catch (error) { + setRuntimeConfigStatus(error instanceof Error ? error.message : String(error)); + } + } + function requireChatProjectForUserAction() { const nextProjectPath = resolveChatProjectPath(localProject); if (nextProjectPath) { @@ -2211,13 +2336,24 @@ export function App() { setAgentRunStatus( `${result.status} · ${result.lifecycleStatus} · ${result.nextStep}`, ); + const commandId = + action === 'status' + ? 'agent.run_status' + : (`agent.${action}` as GameCreationAppCommandDescriptor['id']); setCommandLog((current) => [ ...current, - `agent.${action}`, + commandId, 'file.write .agent/activity.jsonl', 'file.write .agent/output.jsonl', 'file.write .agent/context.bundle.json', ]); + if (announceToChat && action === 'status') { + appendLocalPermissionLog( + nextProjectPath, + 'command.auto', + 'agent.run_status', + ); + } await refreshAgentRunTrace(nextProjectPath); if (announceToChat) { setMessages((current) => [ @@ -2501,6 +2637,13 @@ export function App() { setPreviewStatus('未启动'); } setCommandLog((current) => [...current, 'preview.status']); + if (announceToChat && nextProjectPath) { + appendLocalPermissionLog( + nextProjectPath, + 'command.auto', + 'preview.status', + ); + } if (announceToChat) { setMessages((current) => [ ...current, @@ -3497,8 +3640,13 @@ export function App() { >
-

AI 游戏创作

- {devMode ? '开发模式' : seedManifest.name} +
+

AI 游戏创作

+ {devMode ? '开发模式' : seedManifest.name} +
+
{messages.map((message, index) => ( @@ -3545,6 +3693,171 @@ export function App() {
+ {runtimeConfigOpen ? ( +
+
+
+

运行时配置

+
+ + + +
+
+ {runtimeConfigPath ? ( +

{runtimeConfigPath}

+ ) : null} +
+ + + + + + + + + + +
+

{runtimeConfigStatus}

+
+
+ ) : null} + {devMode ? (
diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 10f6dfb58..c5296b4f9 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -59,11 +59,16 @@ textarea { .chat-header, .panel-header { display: flex; - align-items: baseline; + align-items: center; justify-content: space-between; gap: 12px; } +.chat-header div { + display: grid; + gap: 4px; +} + .panel-header { margin-bottom: 10px; } @@ -75,6 +80,7 @@ textarea { } .panel-header button, +.chat-header button, .local-project-form button, .panel-actions select { height: 32px; @@ -84,6 +90,7 @@ textarea { } .panel-header button, +.chat-header button, .local-project-form button { color: #fff; background: #1f6feb; @@ -114,6 +121,64 @@ h2 { color: #647084; } +.settings-overlay { + position: fixed; + inset: 0; + z-index: 10; + display: grid; + place-items: center; + padding: 18px; + background: rgb(24 32 47 / 36%); +} + +.settings-panel { + display: grid; + gap: 10px; + width: min(760px, 100%); + max-height: calc(100vh - 36px); + padding: 18px; + overflow: auto; + border: 1px solid #cfd7e6; + background: #fff; +} + +.settings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.settings-grid label { + display: grid; + gap: 6px; + min-width: 0; + color: #647084; + font-size: 13px; +} + +.settings-grid input, +.settings-grid select { + min-width: 0; + height: 36px; + padding: 0 10px; + border: 1px solid #cfd7e6; + border-radius: 6px; + color: #18202f; + background: #fff; +} + +.settings-checkbox { + grid-template-columns: auto 1fr; + align-items: center; + align-self: end; +} + +.settings-checkbox input { + width: 18px; + height: 18px; + padding: 0; +} + .message-list { flex: 1; border: 1px solid #dde3ee; @@ -453,6 +518,10 @@ iframe.preview-frame { grid-column: auto; } + .settings-grid { + grid-template-columns: 1fr; + } + .composer { grid-template-columns: 1fr auto; } diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index 40a695d23..a98eb3e5b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -33,18 +33,121 @@ afterEach(() => { }); describe('AI 游戏创作 App 界面边界', () => { - it('keeps the user surface to chat, upload and command confirmation', () => { + it('keeps the user surface to chat, upload, config and command confirmation', () => { renderAppAt('/'); expect(screen.getByLabelText('聊天')).not.toBeNull(); expect(screen.getByLabelText('创作想法')).not.toBeNull(); expect(screen.getByText('上传')).not.toBeNull(); + expect(screen.getByRole('button', { name: '配置' })).not.toBeNull(); expect(screen.getByText('想做什么游戏?')).not.toBeNull(); expect(screen.queryByLabelText('开发环境')).toBeNull(); + expect(screen.queryByLabelText('运行时配置')).toBeNull(); expect(screen.queryByText('Agent 能力')).toBeNull(); expect(screen.queryByText('编排 Trace')).toBeNull(); }); + it('edits the published runtime config without leaking API keys into chat', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_game_creator_app_config') { + return { + path: '/home/test/AppData/game-creator.config.json', + config: { + llm: { + apiKey: 'unit-loaded-secret-value', + baseUrl: 'https://llm.example.test/v1', + model: 'gpt-test', + apiKind: 'openai_responses', + stream: false, + requestTimeoutMs: 180000, + maxRetries: 0, + retryBackoffMs: 500, + }, + editorApi: { + baseUrl: 'http://127.0.0.1:8082', + apiKey: 'editor-loaded-secret', + }, + }, + }; + } + if (command === 'write_game_creator_app_config') { + return { + path: '/home/test/AppData/game-creator.config.json', + config: args?.config, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + fireEvent.click(screen.getByRole('button', { name: '配置' })); + + expect(await screen.findByDisplayValue('gpt-test')).not.toBeNull(); + expect( + screen.getByText('/home/test/AppData/game-creator.config.json'), + ).not.toBeNull(); + expect(screen.getByLabelText('聊天').textContent).not.toContain( + 'unit-loaded-secret-value', + ); + + fireEvent.change(screen.getByLabelText('LLM API Key'), { + target: { value: 'unit-new-secret-value' }, + }); + fireEvent.change(screen.getByLabelText('LLM Base URL'), { + target: { value: 'https://new-llm.example.test/v1' }, + }); + fireEvent.change(screen.getByLabelText('LLM 模型'), { + target: { value: 'gpt-next' }, + }); + fireEvent.change(screen.getByLabelText('LLM API 类型'), { + target: { value: 'openai_chat' }, + }); + fireEvent.click(screen.getByLabelText('LLM 流式请求')); + fireEvent.change(screen.getByLabelText('LLM 超时 ms'), { + target: { value: '90000' }, + }); + fireEvent.change(screen.getByLabelText('LLM 重试次数'), { + target: { value: '3' }, + }); + fireEvent.change(screen.getByLabelText('LLM 退避 ms'), { + target: { value: '800' }, + }); + fireEvent.change(screen.getByLabelText('画板 API Base URL'), { + target: { value: 'http://127.0.0.1:8099' }, + }); + fireEvent.change(screen.getByLabelText('画板 API Key'), { + target: { value: 'editor-new-secret' }, + }); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + + expect(await screen.findByText(/已保存:/)).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_game_creator_app_config'); + expect(invoke).toHaveBeenCalledWith('write_game_creator_app_config', { + config: { + llm: { + apiKey: 'unit-new-secret-value', + baseUrl: 'https://new-llm.example.test/v1', + model: 'gpt-next', + apiKind: 'openai_chat', + stream: true, + requestTimeoutMs: 90000, + maxRetries: 3, + retryBackoffMs: 800, + }, + editorApi: { + baseUrl: 'http://127.0.0.1:8099', + apiKey: 'editor-new-secret', + }, + }, + }); + expect(screen.getByLabelText('聊天').textContent).not.toContain( + 'unit-new-secret-value', + ); + }); + it('keeps multiline chat evidence readable', () => { const styles = readFileSync( resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'), @@ -1092,6 +1195,11 @@ describe('AI 游戏创作 App 界面边界', () => { expect(invoke).toHaveBeenCalledWith('get_local_game_preview_status', { projectPath: '/tmp/authorized-game', }); + expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { + projectPath: '/tmp/authorized-game', + event: 'command.auto', + commandId: 'preview.status', + }); }); it('runs static smoke and starts preview from chat through the authorized project path', async () => { @@ -2193,16 +2301,40 @@ describe('AI 游戏创作 App 界面边界', () => { }; } if (command === 'control_agent_run') { + const action = String(args?.action ?? ''); + const detail = String(args?.detail ?? ''); + const resultByAction = { + status: { + status: 'pending', + lifecycleStatus: 'pending', + nextStep: 'runner-claim', + message: 'run run-control-chat 当前状态:pending / pending', + }, + kill: { + status: 'killed', + lifecycleStatus: 'killed', + nextStep: 'resume-or-retry', + message: 'run run-control-chat 已标记为 killed', + }, + retry: { + status: 'pending', + lifecycleStatus: 'pending', + nextStep: 'runner-claim', + message: 'run run-control-chat 已重试,等待下一次 claim', + }, + resume: { + status: 'pending', + lifecycleStatus: 'pending', + nextStep: 'runner-claim', + message: `run run-control-chat 已恢复:${detail}`, + }, + }[action]; + if (!resultByAction) { + throw new Error(`unexpected agent run action ${action}`); + } return { runId: 'run-control-chat', - status: args?.action === 'kill' ? 'killed' : 'pending', - lifecycleStatus: args?.action === 'kill' ? 'killed' : 'pending', - nextStep: - args?.action === 'kill' ? 'resume-or-retry' : 'runner-claim', - message: - args?.action === 'kill' - ? 'run run-control-chat 已标记为 killed' - : 'run run-control-chat 当前状态:pending / pending', + ...resultByAction, activityPath: '/tmp/authorized-game/.agent/activity.jsonl', outputPath: '/tmp/authorized-game/.agent/output.jsonl', contextBundlePath: '/tmp/authorized-game/.agent/context.bundle.json', @@ -2236,6 +2368,11 @@ describe('AI 游戏创作 App 界面边界', () => { action: 'status', detail: undefined, }); + expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { + projectPath: '/tmp/authorized-game', + event: 'command.auto', + commandId: 'agent.run_status', + }); submitChat('/agent-kill'); expect(screen.getByText('agent.kill')).not.toBeNull(); @@ -2253,6 +2390,40 @@ describe('AI 游戏创作 App 界面边界', () => { action: 'kill', detail: undefined, }); + + submitChat('/agent-retry'); + expect(screen.getByText('agent.retry')).not.toBeNull(); + expect( + screen.getByText( + '标记 /tmp/authorized-game/.agent/run.latest.json 为 pending,等待 runner claim', + ), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText(/run run-control-chat 已重试,等待下一次 claim/), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('control_agent_run', { + projectPath: '/tmp/authorized-game', + action: 'retry', + detail: undefined, + }); + + submitChat('/agent-resume 继续修复输入监听'); + expect(screen.getByText('agent.resume')).not.toBeNull(); + expect( + screen.getByText( + '附加用户说明并标记 /tmp/authorized-game/.agent/run.latest.json 为 pending', + ), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText(/run run-control-chat 已恢复:继续修复输入监听/), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('control_agent_run', { + projectPath: '/tmp/authorized-game', + action: 'resume', + detail: '继续修复输入监听', + }); }); it('manages long memory from chat through the authorized local project path', async () => { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f8ff60519..6b7d06d85 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-30 AI 游戏创作 App 使用客户端配置文件 + +- 背景:`apps/ai-game-creator-shell` 是客户端 App,不应通过 `.env` 或进程环境变量承载 LLM / 画板同步配置;旧口径会让本地 secrets、CLI wrapper 和桌面 App 启动逻辑混在一起。 +- 决策:仓库内 `apps/ai-game-creator-shell/game-creator.config.json` 只作为默认模板;发布 App 启动时在 Tauri 应用配置目录写入默认 `game-creator.config.json`,真实密钥和本机覆盖项都保存在该运行时配置文件中。主窗口提供“配置”面板读写该运行时 JSON;开发 CLI 无 AppHandle 时才回退读取仓库旁边的模板和 gitignored 本机覆盖文件。`llm.apiKey/baseUrl/model/protocol/stream/requestTimeoutMs/maxRetries/retryBackoffMs` 驱动真实 LLM 路径,`editorApi.baseUrl/apiKey` 驱动画板项目同步;`/llm-status` 只展示 baseUrl、model、protocol 和 API Key 是否存在,不显示密钥。 +- 影响范围:AI 游戏创作 App 的 Tauri Rust 配置加载、主窗口配置面板、CLI wrapper、agent-run smoke、`check-config` 门禁、`.gitignore` 和实施计划文档。 +- 验证方式:运行 `npm run ai-game-creator-shell:typecheck`、`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + ## 2026-06-26 AI 游戏创作 App 生成过程必须在聊天可见 - 背景:普通用户窗口只保留聊天入口,但如果生成确认后只显示“已生成草案”和本地产物路径,真实 LLM / Agent loop 会被误解成固定模板落盘。 @@ -35,12 +43,12 @@ ## 2026-06-25 AI 游戏创作 App 真实 LLM 联调用流式请求 - 背景:AI 游戏创作 App 的真实 OpenAI-compatible provider 验收中,小请求可返回,但 Planner 等稍长非流式请求会在上游响应前被网关空闲连接切断,表现为 TLS record 解密失败;本地无密钥 provider smoke 不能覆盖该真实网关行为。 -- 决策:`platform-llm` 文本 client 使用系统 TLS backend,并保留底层错误链用于排障;AI 游戏创作 App 增加 `GENARRATIVE_GAME_CREATOR_LLM_STREAM=true` 开关,打开后 Planner、组内角色和 Generator 走流式请求。默认本地 smoke 继续使用非流式 OpenAI-compatible 测试 provider,避免把测试桩改重。 +- 决策:`platform-llm` 文本 client 使用系统 TLS backend,并保留底层错误链用于排障;AI 游戏创作 App 通过客户端配置项 `llm.stream=true` 开关打开流式请求,打开后 Planner、组内角色和 Generator 走流式请求。 - 影响范围:`server-rs/crates/platform-llm`、`apps/ai-game-creator-shell/src-tauri/src/main.rs` 和 AI 游戏创作智能体 App 实施计划。 -- 验证方式:运行 `cargo test -p platform-llm --manifest-path server-rs/Cargo.toml request_text_parses_non_stream_response`,并用真实 OpenAI-compatible 环境变量执行 `npm run ai-game-creator-shell:agent-run -- --no-wait /tmp/genarrative-ai-game-real-loop-test-6 "做一个像素风反弹弹幕厨房小游戏..."`,确认 36 个 trace step、36 次 tool call、`game.static_smoke`、`preview.start` 和 `preview.stop` 完成。 +- 验证方式:运行 `cargo test -p platform-llm --manifest-path server-rs/Cargo.toml request_text_parses_non_stream_response`,并用真实 OpenAI-compatible 本机配置执行 `npm run ai-game-creator-shell:agent-run -- --no-wait /tmp/genarrative-ai-game-real-loop-test-6 "做一个像素风反弹弹幕厨房小游戏..."`,确认 36 个 trace step、36 次 tool call、`game.static_smoke`、`preview.start` 和 `preview.stop` 完成。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 -2026-06-29 追加:`platform-llm` 旧 `LlmTextRequest` / `LlmTextResponse` 已直接替换为 provider-neutral 的 `LlmRunRequest` / `LlmRunResponse`,API kind 先固定为 `openai_chat`、`openai_responses`、`anthropic` 三类。AI 游戏创作 App 默认 `openai_responses`,可用 `GENARRATIVE_GAME_CREATOR_LLM_API_KIND=openai_chat` 接旧 Chat Completions 兼容网关,或用 `GENARRATIVE_GAME_CREATOR_LLM_API_KIND=anthropic` 接 Anthropic Messages。当前 run 响应只保留通用文本、finish reason、response id 和 usage,高级能力后续按 capability 扩展,不把业务层绑死到 Responses 字段。 +2026-06-27 追加,2026-06-30 更新:`platform-llm` 旧 `LlmTextRequest` / `LlmTextResponse` 已直接替换为 provider-neutral 的 `LlmRunRequest` / `LlmRunResponse`,API kind 先固定为 `openai_chat`、`openai_responses`、`anthropic` 三类。AI 游戏创作 App 改用客户端运行时配置(Tauri 应用配置目录的 `game-creator.config.json`),LLM 维度由 `llm.apiKind` 控制,默认 `openai_responses`,可设为 `openai_chat` 接旧 Chat Completions 兼容网关,或 `anthropic` 接 Anthropic Messages。当前 run 响应只保留通用文本、finish reason、response id 和 usage,高级能力后续按 capability 扩展,不把业务层绑死到 Responses 字段。 ## 2026-06-24 AI 游戏创作 App 生成编排使用文件驱动 loop @@ -3766,7 +3774,7 @@ - 决策:AI 游戏创作桌面入口新建 `apps/ai-game-creator-shell`。普通用户界面只保留聊天和上传入口;任务、能力、文件、记忆、预览和日志只放在开发模式或开发窗口。Agent 能力、manifest、内置命令和权限枚举写入 `packages/shared/src/contracts/gameCreationApp.ts` 与 `server-rs/crates/shared-contracts/src/game_creation_app.rs`;专业组和种子任务图写入 `server-rs/crates/platform-agent/src/game_creation.rs`。`canvas.project_open` 只允许打开本机 Genarrative 编辑器 `/editor/canvas?projectid=...`,默认本机端口为 `3000`,不得扩展成任意 URL 打开能力。 - 本地边界:生成代码、上传资产、短期记忆、长期记忆和预览入口必须保存到用户授权的本地项目目录;正式预览使用只读 `127.0.0.1:` HTTP server,不使用 `file://`。`game.generate_draft` 和 `asset.upload` 这类 `confirm` 命令先在聊天区形成待确认命令,用户确认后才写本地产物;开发窗口中的 `confirm` 命令使用原生确认门,取消时只写日志不执行。`command.run_limited` 只执行白名单内置命令,当前最小真实命令是 `game.static_smoke`,用于检查 `game/index.html` 是否具备 canvas、canvas 渲染上下文、绘制调用、主循环、输入监听、明确目标、失败或胜利状态和重开路径,且不使用远程资源、`eval`、`new Function`、`localStorage`、`fetch`、`WebSocket` 或 `ServiceWorker`,不得把任意 shell 执行暴露给普通用户界面。`.agent/manifest.json` 是本地最小状态源,记录专业组种子任务、资产、预览状态和受限命令运行结果;开发窗口专业组面板读取 manifest task state,不使用前端硬编码作为真相源。`file.list/read/write/delete` 只能访问本地项目目录内的相对路径,禁止绝对路径、`..`、反斜杠和符号链接逃逸。`asset.register` 只登记项目目录内已经存在的文件,并可记录 `uploaded`、`generated`、`canvas` 来源元数据。`canvas.asset_import` 是画板回流的本地落点,只导入项目内已有文件为 `canvas` 来源资产,并要求画板项目 ID 与 resourceId / assetObjectId 可追踪;`canvas.export_import` 复用现有画板素材导出 ZIP,把 `metadata.json` 引用的 `images/`、`media/`、`sequences/` 文件复制到本地项目 `assets/canvas-imports/` 并登记为 `canvas` 来源资产。 - 2026-06-24 调整:`game.generate_draft` 必须作为一次本地 agent 协作回合记录,用户确认后同时写入短期记忆、长期记忆、设计草案、数值配置、美术清单、音乐音效清单、发布包装草案、可运行 HTML、`.agent/logs/agent.log` 和 manifest `commandRuns`;manifest 任务状态必须反映策划、数值、美术、音乐、程序组首轮完成,预览试玩等待确认。 -- 2026-06-24 调整:`game.generate_draft` 必须通过 OpenAI-compatible LLM 生成结构化 JSON 草案,读取 `GENARRATIVE_GAME_CREATOR_LLM_*` 或既有 `GENARRATIVE_LLM_*` / `LLM_*` / `OPENAI_*` 环境变量;LLM 配置缺失、上游失败、返回非 JSON、HTML 非自包含、缺少 `canvas` / `requestAnimationFrame` 或把危险用户输入原样写入 HTML 时直接失败,不得静默回退固定模板并声称 AI 生成。 +- 2026-06-24 调整,2026-06-30 更新:`game.generate_draft` 必须通过 OpenAI-compatible LLM 生成结构化 JSON 草案;发布 App 读取 Tauri 应用配置目录中 `game-creator.config.json` 的 `llm.*` 配置项,开发 CLI 无 AppHandle 时才读仓库旁边的 fallback 配置。LLM 配置缺失、上游失败、返回非 JSON、HTML 非自包含、缺少 `canvas` / `requestAnimationFrame` 或把危险用户输入原样写入 HTML 时直接失败,不得静默回退固定模板并声称 AI 生成。 - 2026-06-24 调整:`game.generate_draft` 生成的 `game/index.html` 必须是可试玩原型,至少具备输入、主循环、目标、失败或胜利状态和重开路径;不得退回按钮计分、纯展示页或占位式游戏。 - 2026-06-24 调整:`game.generate_draft` 的设计草案、发布包装草案和 agent log 必须包含专业组 / 角色 / 产物交接摘要,作为 6 组 agent 协作的最小可追踪证据。 - 2026-06-25 调整:`game.generate_draft` 的 loop 不能只由 Generator 在提示词里模拟六组协作;每一轮必须在 Planner 之后分别调用策划、数值、美术、音乐、程序、运营 6 组下的角色 agent 产出 brief,写入 `.agent/passes/pass-N/groups//*.md`,再汇总为 `.agent/passes/pass-N/groups/*.md`,由 Generator 读取这些汇总 brief、spec、findings 和记忆整合成结构化草案。`.agent/run.latest.json` 必须记录 `llm.chat.group..` toolCall 和 brief artifact,作为多智能体协作的最小真实证据。 @@ -3784,7 +3792,7 @@ - 2026-06-25 调整:新增 `npm run ai-game-creator-shell:agent-run:smoke` 作为无密钥开发验证入口。脚本在本机启动 OpenAI-compatible 测试 provider,预置一个本地上传图片和一个本地上传音频,并复用真实 `--agent-run`、本地落盘、`game.static_smoke` 和本地 HTTP 预览;脚本会断言 provider 请求体包含图片与音频资产上下文、生成 HTML 引用 `/assets/...`、预览服务能用 `GET` 读取这些资产、用 `HEAD` 返回真实资源长度和对应 MIME、headless Chrome 打开预览后至少执行一帧游戏 JS,且通过确定性亮色探针采样证明 canvas 不是空白画布、第二轮重跑 Evaluator 命中任务及其下游影响任务,未受影响组 carry-over,再自动给 CLI 发送回车停止预览。该脚本仅验证 runtime,不作为产品生成 fallback。 - 2026-06-25 调整:新增根级 `npm run ai-game-creator-shell:check` 作为 v1 开发验收入口,串起壳 typecheck、`platform-agent` 编排测试、`shared-contracts` 契约测试、Tauri Rust 测试和无密钥本地 provider 端到端 smoke,避免测试口径散落成多条手工命令。 - 2026-06-25 调整:`scripts/check-native-shells.mjs` 的 AI 游戏创作项从单独 typecheck 升级为 `npm run ai-game-creator-shell:check`,让原生壳总门禁覆盖 agent loop、本地落盘、静态自检和本地 HTTP 预览 smoke。 -- 2026-06-25 调整:普通用户通过聊天输入 `/llm-status` 触发只读 `llm.config_check`,用于检查 LLM base_url、model 和 API Key 是否已从环境变量读取;状态消息不得显示或保存 API Key。终端可用 `npm run ai-game-creator-shell:llm-status` 做同类配置自检,缺配置时以非零状态退出。生成仍只从环境变量读取配置,不新增仓库文件、项目文件或普通用户界面里的 secret 持久化。 +- 2026-06-25 调整,2026-06-30 更新:普通用户通过聊天输入 `/llm-status` 触发只读 `llm.config_check`,用于检查 LLM base_url、model 和 API Key 是否已从客户端配置读取;状态消息不得显示或保存 API Key。终端可用 `npm run ai-game-creator-shell:llm-status` 做同类配置自检,缺配置时以非零状态退出。发布 App 的真实密钥只放 Tauri 应用配置目录中的 `game-creator.config.json`;主窗口“配置”面板可读写该文件,但 API Key 不写入聊天、本地项目、trace 或 manifest。 - 2026-06-25 调整:`npm run ai-game-creator-shell:dev` 固定加载 `http://127.0.0.1:3080/`,Vite 继续 `strictPort` 与 Tauri `devUrl` 对齐。`beforeDevCommand` 改为先复用已经跑在 3080 且页面标题为 `AI 游戏创作` 的本 app Vite server,避免上次 Tauri 退出后遗留的同 app Vite 进程导致二次启动失败;如果 3080 是其它服务,仍直接失败并要求释放端口,不做端口漂移。 - 2026-06-25 调整:`preview.start` / `preview.stop` 必须追加 `.agent/logs/preview.log`,并把该日志列入 Preview trace step 的输出路径和 artifact 清单;这样 `preview-playtest` 任务声明的日志产物与实际本地 HTTP 预览行为一致。 - 2026-06-25 调整:AI 游戏创作 App v1 仍只维护一个全局本地 HTTP 预览实例;启动新项目预览替换旧预览时,必须 best-effort 把旧项目的 manifest preview 状态、`.agent/logs/preview.log` 和 run trace 记录为 stopped,避免旧项目状态残留 `running`。旧项目目录已删除时不阻断新预览启动。 @@ -3796,7 +3804,7 @@ - 2026-06-25 调整:普通用户通过聊天输入 `/run` 触发待确认 `game.run_local`,确认后只能复用白名单 `game.static_smoke` 自检当前 `game/index.html`,通过后启动 `127.0.0.1` 本地 HTTP 预览。独立执行 `game.static_smoke` 时如果已有 `.agent/run.latest.json`,必须追加 `Playtest / game.static_smoke` trace step,避免“运行了代码但编排 trace 不可见”。 - 2026-06-25 调整:普通用户通过聊天输入 `/trace` 触发只读 `agent.trace_read`,读取 `.agent/run.latest.json` 并在聊天里摘要 loop 轮次、stopReason、nextStep、active / carry-over 任务、repairRoutes、agent 建议命令和最近 step。trace 面板仍只在开发窗口展示,普通用户窗口不新增面板。 - 2026-06-25 调整:普通用户通过聊天输入 `/import-canvas-export /绝对/画板素材.zip 画板项目ID` 触发待确认 `canvas.export_import`,读取现有 `/editor/canvas` 素材导出 ZIP。导入命令只读取用户指定 ZIP,写入当前本地项目 `assets/canvas-imports/`,基础护栏限制路径逃逸、文件数量和解压体积;导出包没有真实 resourceId 时,用 `canvas-export:` 作为可追踪 assetObjectId,不伪造后端画板资源行。 -- 2026-06-25 调整:普通用户通过聊天输入 `/sync-canvas-project 画板项目ID` 触发待确认 `canvas.project_sync`,复用现有 `/api/external/v1/editor/projects/{projectId}` 读取画板项目快照,再用 `/api/external/v1/assets/read-url` 对 objectKey 或 legacy path 换签,下载资源到本地项目 `assets/canvas-sync/` 并登记为 `canvas` 来源资产;该命令从 `GENARRATIVE_GAME_CREATOR_EDITOR_API_KEY` 或 `GENARRATIVE_EXTERNAL_API_KEY` 读取平台 API Key,默认 base URL 为 `http://127.0.0.1:8082`,可用 `GENARRATIVE_GAME_CREATOR_EDITOR_API_BASE_URL` 覆盖。API Key 不写入 manifest、agent.db、trace 或日志。该路径不伪装浏览器登录态,也不绕过画板生成、钱包扣费或外部生成 worker;它只同步用户 API Key 已有权限读取的画板资源。 +- 2026-06-25 调整,2026-06-30 更新:普通用户通过聊天输入 `/sync-canvas-project 画板项目ID` 触发待确认 `canvas.project_sync`,复用现有 `/api/external/v1/editor/projects/{projectId}` 读取画板项目快照,再用 `/api/external/v1/assets/read-url` 对 objectKey 或 legacy path 换签,下载资源到本地项目 `assets/canvas-sync/` 并登记为 `canvas` 来源资产;该命令从客户端配置项 `editorApi.apiKey` 读取平台 API Key,默认 base URL 为 `http://127.0.0.1:8082`,可用 `editorApi.baseUrl` 覆盖。API Key 不写入 manifest、agent.db、trace 或日志。该路径不伪装浏览器登录态,也不绕过画板生成、钱包扣费或外部生成 worker;它只同步用户 API Key 已有权限读取的画板资源。 - 2026-06-25 调整:美术组 `Asset` 和音乐组 `SFX` 角色在 loop 中读取 `.agent/manifest.json`;当本地项目还没有对应类型的 `canvas` 来源资产时,角色 step 会追加 `agent.tool.suggest.canvas.project_sync` toolCall:美术组需要 `image/*` 或 `application/vnd.genarrative.image-sequence`,音乐组需要 `audio/*`。该 toolCall 只作为 trace 中的建议,不自动调用 External Editor API,也不绕过用户确认。 - 2026-06-24 调整:同一本地项目多次 `game.generate_draft` 必须追加 `memory/session.md` 与 `memory/project.md`,不得覆盖历史对话和创作目标记录。 - 2026-06-24 调整:普通用户通过聊天输入 `/help` 发现可用内置命令;命令发现必须留在聊天消息里,不得因此暴露开发面板。 @@ -3809,7 +3817,7 @@ - 2026-06-24 调整:普通用户只能通过聊天触发内置命令;当前 `/smoke` 映射到白名单 `command.run_limited game.static_smoke` 并走待确认卡片,不允许扩展成任意 shell 或自由命令解析。 - 2026-06-24 调整:普通用户通过聊天输入 `/project /绝对路径` 触发 `project.create` 待确认命令,用于授权并初始化本地项目目录;相对路径不会生成待确认命令;不要把开发窗口项目路径输入框暴露到正式用户界面。 - 2026-06-25 调整:普通用户侧所有会写入、运行、查看 / 打开预览或导入本地产物的命令必须先完成 `/project` 初始化,包括 `game.generate_draft`、`asset.upload`、`game.run_local`、`command.run_limited`、`preview.start`、`preview.status`、`preview.open`、`preview.stop`、`memory.write`、`memory.delete`、`canvas.project_sync`、`canvas.asset_import` 和 `canvas.export_import`;没有已授权本地项目时只提示设置项目,不得落到默认 `/tmp` 草稿目录。 -- 2026-06-24 调整:终端测试入口使用同一个 Tauri Rust 二进制的 `--agent-run <本地项目绝对路径> <创作需求>`,只复用现有 `game.generate_draft`、`game.static_smoke` 和本地 HTTP 预览链路,不另建第二套 agent runtime;LLM 配置只从环境变量读取,不写入仓库或项目文件。需要自动验证时可追加 `--no-wait`,生成预览 trace 后立即停止本地预览,避免命令卡在回车等待。 +- 2026-06-24 调整,2026-06-30 更新:终端测试入口使用同一个 Tauri Rust 二进制的 `--agent-run <本地项目绝对路径> <创作需求>`,只复用现有 `game.generate_draft`、`game.static_smoke` 和本地 HTTP 预览链路,不另建第二套 agent runtime;发布 App 的 LLM 配置从 Tauri 应用配置目录读取,不写入仓库默认配置或项目文件。需要自动验证时可追加 `--no-wait`,生成预览 trace 后立即停止本地预览,避免命令卡在回车等待。 - 2026-06-24 调整:AI 游戏创作 App 的 release 配置只登记 `main` 聊天窗口,主窗口保持聊天尺寸;任务、文件、记忆、预览、日志和能力面板只能通过 debug/dev 下额外创建的 `developer` 窗口或 Vite dev `?dev/#dev` 查看,不进入普通用户窗口。 - 2026-06-24 调整:`check:native-shells` 必须静态守住 AI 游戏创作 App 的用户 / 开发边界:release 只保留 `main` 聊天窗口,`developer` 窗口只在 debug 创建,开发面板只能在 `devMode` 分支渲染。 - 2026-06-25 调整:`check:native-shells` 在 `ai-game-creator-shell:check` 之后必须追加 `ai-game-creator-shell:build -- --no-bundle`,让原生壳总门禁同时证明 AI 游戏创作独立 Tauri 壳能完成 release 编译,而不是只证明前端 / Rust 逻辑测试通过。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 6d2c0bf28..28fe84662 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -2,7 +2,7 @@ ## 目标 -在 Genarrative 内建设独立桌面 App:普通用户只看到聊天入口,通过聊天、上传文件和确认操作,让平台生成保存在本地的可运行 Web 游戏原型,并通过本地 HTTP server 预览。任务、文件、预览和日志等工程面板放到开发构建的独立开发窗口。v1 只做 Web 小游戏原型闭环,不扩展 Unity、Godot、云同步或插件市场。 +在 Genarrative 内建设独立桌面 App:普通用户主要使用聊天入口,通过聊天、上传文件和确认操作,让平台生成保存在本地的可运行 Web 游戏原型,并通过本地 HTTP server 预览;主窗口提供运行时配置入口,用于保存发布版 AppData / Tauri 配置目录里的 LLM 与画板 API 配置。任务、文件、预览和日志等工程面板放到开发构建的独立开发窗口。v1 只做 Web 小游戏原型闭环,不扩展 Unity、Godot、云同步或插件市场。 ## 技术选择 @@ -66,7 +66,7 @@ game-project/ - 默认后台生成,用户需要精修时打开画板继续编辑。 - `canvas.project_open` 只打开本机 Genarrative 编辑器的 `/editor/canvas?projectid=...`,默认地址为 `http://127.0.0.1:3000`,开发者可在开发窗口改成本机端口;不允许打开远程站点或任意 URL。 - 画板资源回流到本地项目 `assets/`,并在 manifest 中记录画板项目、资源 ID、assetObjectId、prompt、model、taskId 和 assetKind;当前最小落地提供 `asset.register` 登记项目内已有资产,并提供 `canvas.export_import` 读取现有画板素材导出 ZIP。 -- `canvas.project_sync` 复用 Genarrative External Editor API,读取用户平台 API Key 可访问的画板项目快照,通过 `/api/external/v1/assets/read-url` 换签并把资源下载到本地项目 `assets/canvas-sync/`;默认 API base URL 为 `http://127.0.0.1:8082`,可用 `GENARRATIVE_GAME_CREATOR_EDITOR_API_BASE_URL` 覆盖,API Key 从 `GENARRATIVE_GAME_CREATOR_EDITOR_API_KEY` 或 `GENARRATIVE_EXTERNAL_API_KEY` 读取,不写入项目文件、trace、manifest 或日志。 +- `canvas.project_sync` 复用 Genarrative External Editor API,读取用户平台 API Key 可访问的画板项目快照,通过 `/api/external/v1/assets/read-url` 换签并把资源下载到本地项目 `assets/canvas-sync/`;默认 API base URL 为 `http://127.0.0.1:8082`,可用 Tauri 应用配置目录中的 `game-creator.config.json` 的 `editorApi.baseUrl` 覆盖,API Key 从同一配置的 `editorApi.apiKey` 读取,不写入项目文件、trace、manifest 或日志。 - Agent loop 中美术组 `Asset` 和音乐组 `SFX` 会读取 `.agent/manifest.json`;当本地项目尚无对应类型的 `canvas` 来源资产时,会在 `.agent/run.latest.json` 的角色 step 中追加 `agent.tool.suggest.canvas.project_sync` 建议:美术组需要 `image/*` 或 `application/vnd.genarrative.image-sequence`,音乐组需要 `audio/*`。该建议提示用户通过聊天确认 `/sync-canvas-project <画板项目ID>` 回流画板资源,只进入 trace,不自动调用外部 API,也不绕过用户确认。 - `canvas.asset_import` 当前作为最小真实链路:导入项目目录内已有文件为 `canvas` 来源资产,并要求记录画板项目 ID 以及 resourceId 或 assetObjectId。 - `canvas.export_import` 复用 `/editor/canvas` 已有素材导出 ZIP 格式,读取根 `metadata.json`、复制 `images/` / `media/` / `sequences/` 到本地项目 `assets/canvas-imports/`,再按导出层登记为 `canvas` 来源资产;导出包不保存真实 resourceId 时,使用 `canvas-export:` 作为可追踪 assetObjectId,不伪造后端资源行。 @@ -90,11 +90,11 @@ game-project/ ## v1 验收证据矩阵 -- `npm run ai-game-creator-shell:check`:覆盖壳 typecheck、聊天命令单测、用户 / 开发窗口 UI 边界 smoke、聊天侧 `/capabilities` 展示标准 Agent 能力清单且不打开开发面板、聊天侧 `/audit` 从 manifest / 本地文件 / `.agent/run.latest.json` 分别汇总用户面、6 组任务配置、6 组协作证据、任务编排、loop、记忆、本地产物、HTTP 预览、画板回流和权限日志证据且不打开开发面板;未生成 `.agent/run.latest.json` 前,`/audit` 只能标记任务配置通过,不能把 6 组协作证据误判为通过;trace 已存在但状态为 `failed`、`needs-revision`、`running`、`max-passes-exhausted` 或缺少 `Evaluator passed` 步骤时,`/audit` 不能把 loop 误判为通过。聊天侧 `/llm-status` 只显示 base_url / model / API Key 已读取状态且不泄露密钥本体、聊天侧长期记忆查看 / 追加 / 覆盖 / 删除的授权本地项目路径、上传资产写入后的 manifest 刷新和 `/assets` 聊天可见性、`/smoke` 聊天侧确认后只通过授权本地项目路径执行白名单 `game.static_smoke`、`/run` 聊天侧确认后通过授权本地项目路径执行 `game.static_smoke`、启动 `127.0.0.1` 本地预览并交给外部浏览器、`/preview` 聊天侧确认后通过授权本地项目路径启动 `127.0.0.1` 本地预览并交给外部浏览器、`/status` 聊天侧项目 / 任务 / 资产 / 预览 / 最近命令汇总、`/files` 聊天侧本地文件列表、`/read` 聊天侧文件读取的授权本地项目路径、`/tasks` 聊天侧任务拆分与下一步专业组展示的授权本地项目路径、聊天确认生成后实时展示 Planner / Orchestrator / 角色 brief / Generator / Evaluator / 写盘 / 自检进度,并自动读取 `.agent/run.latest.json` 在普通聊天消息里展示 Run、LLM 对话、loop 轮次、工具调用、active / carry-over 任务、返工焦点、编排轮次、最近步骤、画板同步建议命令和本地产物快照、`/trace` 聊天侧读取 `.agent/run.latest.json` 并展示 loop 轮次 / active 任务 / 返工路线 / dependency waves 的授权本地项目路径、`platform-agent` 编排测试、共享契约测试、Tauri 本地能力测试和无密钥本地 provider 端到端 smoke;用于证明独立 App、真实 LLM-compatible loop、本地落盘、自检和 HTTP 预览闭环,并覆盖 loop 跑满 3 轮失败时不会写入最终游戏产物。 +- `npm run ai-game-creator-shell:check`:覆盖壳 typecheck、聊天命令单测、用户 / 开发窗口 UI 边界 smoke、主窗口运行时配置面板读写 Tauri 配置目录中的 `game-creator.config.json` 且不把 API Key 写入聊天、聊天侧 `/capabilities` 展示标准 Agent 能力清单且不打开开发面板、聊天侧 `/audit` 从 manifest / 本地文件 / `.agent/run.latest.json` 分别汇总用户面、6 组任务配置、6 组协作证据、任务编排、loop、记忆、本地产物、HTTP 预览、画板回流和权限日志证据且不打开开发面板;未生成 `.agent/run.latest.json` 前,`/audit` 只能标记任务配置通过,不能把 6 组协作证据误判为通过;trace 已存在但状态为 `failed`、`needs-revision`、`running`、`max-passes-exhausted` 或缺少 `Evaluator passed` 步骤时,`/audit` 不能把 loop 误判为通过。聊天侧 `/llm-status` 只显示 base_url / model / API Key 已读取状态且不泄露密钥本体、聊天侧长期记忆查看 / 追加 / 覆盖 / 删除的授权本地项目路径、上传资产写入后的 manifest 刷新和 `/assets` 聊天可见性、`/smoke` 聊天侧确认后只通过授权本地项目路径执行白名单 `game.static_smoke`、`/run` 聊天侧确认后通过授权本地项目路径执行 `game.static_smoke`、启动 `127.0.0.1` 本地预览并交给外部浏览器、`/preview` 聊天侧确认后通过授权本地项目路径启动 `127.0.0.1` 本地预览并交给外部浏览器、`/status` 聊天侧项目 / 任务 / 资产 / 预览 / 最近命令汇总、`/files` 聊天侧本地文件列表、`/read` 聊天侧文件读取的授权本地项目路径、`/tasks` 聊天侧任务拆分与下一步专业组展示的授权本地项目路径、聊天确认生成后实时展示 Planner / Orchestrator / 角色 brief / Generator / Evaluator / 写盘 / 自检进度,并自动读取 `.agent/run.latest.json` 在普通聊天消息里展示 Run、LLM 对话、loop 轮次、工具调用、active / carry-over 任务、返工焦点、编排轮次、最近步骤、画板同步建议命令和本地产物快照、`/trace` 聊天侧读取 `.agent/run.latest.json` 并展示 loop 轮次 / active 任务 / 返工路线 / dependency waves 的授权本地项目路径、`platform-agent` 编排测试、共享契约测试、Tauri 本地能力测试和无密钥本地 provider 端到端 smoke;用于证明独立 App、真实 LLM-compatible loop、本地落盘、自检和 HTTP 预览闭环,并覆盖 loop 跑满 3 轮失败时不会写入最终游戏产物。 - `npm run check:native-shells`:覆盖 AI 游戏创作壳的 release/dev 窗口边界、正式用户 App 不嵌入游戏预览 iframe、用户侧预览命令交给外部浏览器和 Tauri release `--no-bundle` 构建 smoke;用于证明正式用户窗口只登记 `main` 聊天窗口,开发面板只在 debug/dev 路径打开,独立壳能完成 release 编译。 - `npm run check:encoding` 与 `git diff --check`:覆盖中文文档、中文命令文案和补丁空白;用于避免乱码、尾随空白和无关格式漂移。 -- `npm run ai-game-creator-shell:llm-status`:只检查 LLM 环境变量是否就绪,不请求上游、不显示 API Key;用于本机联调前确认配置。CLI 和桌面 App 内的 `/llm-status` / 生成入口都会先读取仓库根目录或 `apps/ai-game-creator-shell/` 下 gitignored 的 `.env.secrets.local`,再检查当前进程环境。 -- `npm run ai-game-creator-shell:agent-run -- --no-wait /绝对项目路径 "游戏创作需求"`:使用真实 LLM provider 配置跑一次本地生成、落盘、自检和预览;用于人工验收真实 provider 路径。真实 provider 可放在 gitignored 的 `.env.secrets.local` 中,至少包含 `GENARRATIVE_GAME_CREATOR_LLM_API_KEY`、`GENARRATIVE_GAME_CREATOR_LLM_BASE_URL`、`GENARRATIVE_GAME_CREATOR_LLM_MODEL`;默认 API kind 为 `openai_responses`,旧 Chat Completions 兼容网关需显式设置 `GENARRATIVE_GAME_CREATOR_LLM_API_KIND=openai_chat`;Anthropic Messages 网关设置为 `GENARRATIVE_GAME_CREATOR_LLM_API_KIND=anthropic`,URL 会在 base URL 后拼 `/v1/messages`,例如 Minimax Anthropic base URL 可配置为 `https://api.minimaxi.com/anthropic`;真实网关长请求若在非流式响应前被 60 秒空闲连接切断,联调时设置 `GENARRATIVE_GAME_CREATOR_LLM_STREAM=true`。 +- `npm run ai-game-creator-shell:llm-status`:只检查 LLM 客户端配置是否就绪,不请求上游、不显示 API Key;用于本机联调前确认配置。发布版启动时会在 Tauri 应用配置目录生成默认 `game-creator.config.json`,仓库内 `apps/ai-game-creator-shell/game-creator.config.json` 只作为默认模板。 +- `npm run ai-game-creator-shell:agent-run -- --no-wait /绝对项目路径 "游戏创作需求"`:使用真实 OpenAI-compatible 配置跑一次本地生成、落盘、自检和预览;用于人工验收真实 provider 路径。真实 provider 配置放在 Tauri 应用配置目录的 `game-creator.config.json` 中,至少设置 `llm.apiKey`,需要覆盖默认服务时设置 `llm.baseUrl`、`llm.model`;默认 API kind 为 `openai_responses`,旧 Chat Completions 兼容网关设置 `llm.apiKind` 为 `openai_chat`,Anthropic Messages 网关设置 `llm.apiKind` 为 `anthropic`,URL 会在 base URL 后拼 `/v1/messages`,例如 Minimax Anthropic base URL 可配置为 `https://api.minimaxi.com/anthropic`;真实网关长请求若在非流式响应前被 60 秒空闲连接切断,联调时设置 `llm.stream` 为 `true`。 ## 当前最小落地 @@ -102,15 +102,16 @@ game-project/ - 本地项目初始化会创建 `game/`、`assets/`、`memory/`、`exports/`、`.agent/logs/`,写入 `.agent/manifest.json`,生成 append-only JSONL 本地产物索引 `.agent/agent.db`,并生成默认 `game/index.html`。 - 普通用户可在聊天框输入 `/project /绝对路径` 生成待确认的 `project.create` 命令,用于授权并初始化本地项目目录;相对路径不会生成待确认命令;开发窗口仍可直接编辑项目路径。 - 普通用户侧的生成、上传、运行、自检、预览状态 / 启动 / 打开 / 停止、记忆写入和画板资产导入都必须先完成 `/project` 初始化;未初始化时只提示设置本地项目,不落到默认临时目录。 -- 终端可用 `npm run ai-game-creator-shell:llm-status` 检查 LLM 环境变量是否就绪;CLI 和桌面 App 内的 `/llm-status` / 生成入口都会先读取 gitignored 的 `.env.secrets.local`,不请求上游、不显示 API Key,缺配置时以非零状态退出或在聊天里提示未就绪。 +- 终端可用 `npm run ai-game-creator-shell:llm-status` 检查 LLM 客户端配置是否就绪;桌面 App 主窗口“配置”面板可读写 Tauri 应用配置目录中的 `game-creator.config.json`,`/llm-status` / 生成入口读取同一份配置,CLI 开发入口无 AppHandle 时才回退读取仓库旁边的配置模板和 gitignored 本机覆盖文件;不请求上游、不显示 API Key,缺配置时以非零状态退出或在聊天里提示未就绪。 - 终端可用 `npm run ai-game-creator-shell:check` 跑 v1 开发验收:壳 typecheck、`platform-agent` 编排测试、共享契约测试、Tauri Rust 测试和无密钥本地 provider 端到端 smoke。 -- 终端可用 `npm run ai-game-creator-shell:agent-run -- /绝对项目路径 "游戏创作需求"` 跑一次真实 LLM 生成、落盘、`game.static_smoke` 和本地 HTTP 预览;该入口读取当前环境和 gitignored 的 `.env.secrets.local`,不把 API Key 写入仓库或项目文件。自动验证可加 `--no-wait`,例如 `npm run ai-game-creator-shell:agent-run -- --no-wait /tmp/genarrative-ai-game-test "像素风反弹弹幕厨房"`,生成预览 trace 后立即停止本地预览,避免终端卡在回车等待。默认 API kind 为 `openai_responses`;旧 Chat Completions 兼容网关设置 `GENARRATIVE_GAME_CREATOR_LLM_API_KIND=openai_chat`;Anthropic Messages 网关设置 `GENARRATIVE_GAME_CREATOR_LLM_API_KIND=anthropic`。真实 OpenAI-compatible 网关建议设置 `GENARRATIVE_GAME_CREATOR_LLM_STREAM=true` 跑 Planner 和 Generator,避免长请求非流式空闲断连。 +- 终端可用 `npm run ai-game-creator-shell:agent-run -- /绝对项目路径 "游戏创作需求"` 跑一次真实 LLM 生成、落盘、`game.static_smoke` 和本地 HTTP 预览;发布 App 读取 Tauri 应用配置目录中的 `game-creator.config.json`,开发 CLI 无 AppHandle 时才读取仓库旁边的配置模板和 gitignored 本机覆盖文件,不把 API Key 写入仓库或项目文件。自动验证可加 `--no-wait`,例如 `npm run ai-game-creator-shell:agent-run -- --no-wait /tmp/genarrative-ai-game-test "像素风反弹弹幕厨房"`,生成预览 trace 后立即停止本地预览,避免终端卡在回车等待。默认 API kind 为 `openai_responses`;旧 Chat Completions 兼容网关设置 `llm.apiKind` 为 `openai_chat`,Anthropic Messages 网关设置 `llm.apiKind` 为 `anthropic`。真实 OpenAI-compatible 网关建议设置 `llm.stream` 为 `true` 跑 Planner 和 Generator,避免长请求非流式空闲断连。 - 终端可用 `npm run ai-game-creator-shell:agent-run:smoke` 跑一次无密钥本地端到端 smoke:脚本启动本机 OpenAI-compatible SSE 流式测试 provider,预置一个本地上传图片和一个本地上传音频,复用真实 `--agent-run`、Planner / Orchestrator / 角色 agent / Generator / Evaluator loop、本地落盘、`game.static_smoke` 和本地 HTTP 预览,并断言每次 provider 请求都使用 `stream: true`、provider prompt 收到图片与音频资产上下文、生成 HTML 引用这些资产、预览服务能用 `GET` 读取 `/assets/...`、用 `HEAD` 返回真实资源长度和对应 MIME、headless Chrome 打开预览后至少执行一帧游戏 JS,且通过确定性亮色探针采样证明 canvas 不是空白画布、`.agent/run.latest.json` 的 step group 覆盖 design / balance / art / audio / code / publishing 六组、第二轮会重跑 Evaluator 命中任务及其下游影响任务,未受影响角色 carry-over;随后脚本自动给 CLI 发送回车停止预览。该脚本只用于开发验证,不进入产品生成路径。 - `npm run ai-game-creator-shell:dev` 的 Tauri `devUrl` 固定为 `http://127.0.0.1:3080/`,Vite 必须 `strictPort` 对齐;`beforeDevCommand` 先复用已经跑在 3080 且页面标题为 `AI 游戏创作` 的本 app Vite server,否则才启动新的 Vite,若端口被其它服务占用则直接失败并提示释放端口。 - `.agent/manifest.json` 会保存 6 个专业组下 16 个组内角色任务状态,当前覆盖 `Director`、`Gameplay`、`Difficulty`、`Asset`、`Polish`、`SFX`、`Code`、`Review`、`Preview`、`Playtest`、`Publish`;程序组内显式包含 `quality-review` 质量评审 gate,由 Evaluator trace 标记完成;开发窗口的专业组面板读取 manifest,而不是前端硬编码。 - 共享契约和 `platform-agent` 会按任务依赖与 `completed` 状态计算当前可执行任务,作为 v1 的最小编排选择器;每轮 `Orchestrator` 的 activeTaskIds、carriedTaskIds、repairRoutes 和 dependencyWaves 由 `platform-agent` 纯编排内核产出,`apps/ai-game-creator-shell` 只负责写入 `.agent/passes/pass-N/` 和执行本地工具;`Evaluator` 会在 `.agent/findings.md` 写出 `## Repair Routes` JSON,下一轮编排优先采用该结构化 taskIds,解析不到时才退回关键词路由;返工路由会按任务图自动扩展下游影响任务,例如美术资产变化会继续触发程序预览和运营包装重算。 -- `game.generate_draft` 使用 LLM provider 配置生成结构化 JSON 草案,读取 `GENARRATIVE_GAME_CREATOR_LLM_API_KEY` / `GENARRATIVE_LLM_API_KEY` / `LLM_API_KEY` / `OPENAI_API_KEY`、`GENARRATIVE_GAME_CREATOR_LLM_BASE_URL` / `GENARRATIVE_LLM_BASE_URL` / `LLM_BASE_URL` / `OPENAI_BASE_URL`、`GENARRATIVE_GAME_CREATOR_LLM_MODEL` / `GENARRATIVE_LLM_MODEL` / `LLM_MODEL` / `OPENAI_MODEL`;默认 API kind 为 `openai_responses`,可通过 `GENARRATIVE_GAME_CREATOR_LLM_API_KIND=openai_chat` 切回旧 Chat Completions 兼容网关,通过 `GENARRATIVE_GAME_CREATOR_LLM_API_KIND=anthropic` 走 Anthropic Messages;`GENARRATIVE_GAME_CREATOR_LLM_STREAM=true` 时 Planner 和 Generator 使用流式请求;缺少配置或模型返回非法 JSON 时直接失败,不静默回退固定模板。 -- 聊天输入 `/llm-status` 会触发只读 `llm.config_check`,确认 LLM base_url、model 和 API Key 是否已从环境变量读取;状态消息不会显示或保存 API Key。 +- `game.generate_draft` 使用 OpenAI-compatible LLM 配置生成结构化 JSON 草案,发布 App 的配置项来自 Tauri 应用配置目录中的 `game-creator.config.json`:`llm.apiKey`、`llm.baseUrl`、`llm.model`、`llm.apiKind`、`llm.stream`、`llm.requestTimeoutMs`、`llm.maxRetries`、`llm.retryBackoffMs`;默认 API kind 为 `openai_responses`,可设 `llm.apiKind=openai_chat` 切回旧 Chat Completions 兼容网关,或 `llm.apiKind=anthropic` 走 Anthropic Messages;`llm.stream=true` 时 Planner 和 Generator 使用流式请求;缺少配置或模型返回非法 JSON 时直接失败,不静默回退固定模板。 +- 主窗口“配置”面板读写 Tauri 应用配置目录中的 `game-creator.config.json`,覆盖 LLM API Key、base URL、模型、API 类型、流式请求、超时、重试和画板 External API 配置;保存时只写运行时配置文件,不写仓库模板、本地项目、trace 或 manifest。 +- 聊天输入 `/llm-status` 会触发只读 `llm.config_check`,确认 LLM base_url、model 和 API Key 是否已从客户端配置读取;状态消息不会显示或保存 API Key。 - `game.generate_draft` 的 LLM JSON 必须包含 `handoffs` 数组,覆盖 `design`、`balance`、`art`、`audio`、`code`、`publishing` 6 个专业组;每组必须给出 role、summary、outputs 和 next,缺组或交接内容不完整会判定为模型输出无效并进入返工。 - `game.generate_draft` 的真实生成路径使用最小 Planner / Orchestrator / 组内角色 agent / Generator / Evaluator loop:Planner 写 `.agent/spec.md`;每轮 Orchestrator 先写 `.agent/passes/pass-N/agenda.md` 和 `.agent/passes/pass-N/task-graph.json`,首轮全量调度 16 个角色任务,返工轮按 `.agent/findings.md` 生成结构化 `repairRoutes`,重跑命中问题的角色任务及其下游依赖任务,其余角色 brief 从上一轮 carry-over;`task-graph.json` 记录 activeTaskIds、carriedTaskIds、repairFocus、repairRoutes 和按依赖排序的 dependencyWaves;角色 brief 写入 `.agent/passes/pass-N/groups//*.md`,再汇总为 `.agent/passes/pass-N/groups/*.md`;Generator 必须读取用户需求、记忆、`.agent/spec.md`、本轮 `agenda.md`、`task-graph.json`、`.agent/findings.md` 和 6 组汇总 brief 后返回结构化 JSON;每轮会把 Generator 草案拆成 6 组交接快照,写入 `.agent/passes/pass-N/`;Evaluator 做质量评审并写 `.agent/findings.md`,通过后才进入 `game.static_smoke` 静态自检和预览试玩。 - loop 最多执行 3 轮;Evaluator 发现 HTML 非自包含、缺少 `canvas`、缺少 `requestAnimationFrame`、缺少输入监听或用户输入未转义时,把问题写入 `.agent/findings.md` 并让下一轮 Generator 修复。3 轮仍失败则 `game.generate_draft` 失败,不写最终游戏产物。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 4da0aa2ce..1d92ac841 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -455,7 +455,7 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日 结构化创作 / RPG 的 Responses JSON 链路默认不打开 `web_search`;本地和生产如需联网增强,必须显式配置 `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED=true` 或 `GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED=true`。如果上游未开通工具,Responses 可能先吐自然语言再返回 `ToolNotOpen`,这类报错应按工具不可用排查,不要先当成 JSON 解析 bug。 -`platform-llm` 文本请求默认使用 Responses 协议;需要接旧 OpenAI Chat Completions 兼容网关时,调用方必须显式选择 Chat Completions。AI 游戏创作独立 App 也默认使用 Responses,可在本地 `.env.secrets.local` 中设置 `GENARRATIVE_GAME_CREATOR_LLM_API_KIND=openai_chat` 接旧 Chat Completions 兼容网关,或 `GENARRATIVE_GAME_CREATOR_LLM_API_KIND=anthropic` 接 Anthropic Messages。 +`platform-llm` 文本请求默认使用 Responses 协议;需要接旧 OpenAI Chat Completions 兼容网关时,调用方必须显式选择 Chat Completions。AI 游戏创作独立 App 是客户端,不读取 `.env`;发布 App 启动时会在 Tauri 应用配置目录生成 `game-creator.config.json`,主窗口“配置”面板读写该运行时文件,真实密钥和本机覆盖项写入该文件,仓库内 `apps/ai-game-creator-shell/game-creator.config.json` 只作为默认模板,开发 CLI 无 AppHandle 时才回退读取仓库旁边的 gitignored 覆盖文件。LLM 维度由 `llm.apiKind` 控制,默认 `openai_responses`,可设为 `openai_chat` 接旧 Chat Completions 兼容网关,或 `anthropic` 接 Anthropic Messages。 创意 Agent `gpt-5` 文本链路已从 APIMart 切到 VectorEngine:`api-server` 读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible LLM client,并自动补齐 `/v1` 前缀用于 Responses 协议。排查或切换密钥后,可在本地运行: