diff --git a/.codex/skills/genarrative-external-editor-api/references/api-operations.md b/.codex/skills/genarrative-external-editor-api/references/api-operations.md index 9aeaf988b..44135c949 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-operations.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-operations.md @@ -51,7 +51,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP | --- | --- | --- | --- | | Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | | Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` | -| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | +| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions` | `sliceLayout`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | | UI asset extraction | `/api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` | | Character animation | `/api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | | Video generation | `/api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | @@ -92,6 +92,8 @@ For image edit/redraw, confirming an upload is not sufficient: create a project The icon-spritesheet primary `referenceId` is intentionally stricter than ordinary image references: it accepts only a current-owner project resource ID or asset ID whose authoritative `assetKind` is `icon-spec`. It does not accept an `objectKey`, URL, Data URL, or Blob URL. +`sliceLayout: "grid-2x2"` is an opt-in contract for four fixed game-runtime assets. The provider prompt and server persistence both preserve the ordered slots left-top, right-top, left-bottom, right-bottom. Omit it to retain the default connected-component slicing behaviour for ordinary free-form icon sheets. + ## Common Values Use OpenAPI as the final authority; these common values are a routing aid: diff --git a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md index 855cc0f16..21f881bc0 100644 --- a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md +++ b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md @@ -78,9 +78,9 @@ Keep the existing autonomous-build task graph. Do not add a parallel task system 1. `art-director` generates `assets/art-spec.png` with image generation, `kind: "spec"`, then registers it as `assetKind: "icon-spec"`. This image is the authoritative visual spec; `generationInputs.artSpec` is supporting structured context. 2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`. -3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. +3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. For the four-category game contract it must also send `sliceLayout: "grid-2x2"`; this is an explicit fixed-slot contract, not a client-side guessed crop. -For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. For the four-category game-chat contract, require exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet ``, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation. +For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. For the four-category game-chat contract, require response `sliceLayout: "grid-2x2"` and exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet ``, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation. Never use `assets/ui-prototype.png` as the spritesheet visual-spec reference. UI extraction is outside this canonical DAG. diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index ae9d1bedf..dec4155ed 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -2,10 +2,10 @@ "agentMode": "codex_app_server", "llm": { "apiKey": "", - "baseUrl": "https://api.openai.com/v1", - "model": "gpt-4.1", + "baseUrl": "https://dev.genarrative.world/gpt/v1", + "model": "gpt-5.6-sol", "apiKind": "openai_responses", - "reasoningEffort": "high", + "reasoningEffort": "max", "stream": false, "webSearchEnabled": false, "contextWindowTokens": 128000, diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index fcb8bc25d..5dfbcb6e1 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -312,7 +312,7 @@ function assertContractRecordsMatch(label, leftRecords, rightRecords) { function parseAppInvokeCommandNames(source) { return Array.from( - source.matchAll(/invoke(?:<[^>]*>)?\(\s*['"]([a-z0-9_]+)['"]/g), + source.matchAll(/(?:invoke|directInvoke)(?:<[^>]*>)?\(\s*['"]([a-z0-9_]+)['"]/g), ([, command]) => command, ); } @@ -1189,9 +1189,9 @@ const allowedLlmReasoningEfforts = new Set([ 'max', ]); -if (defaultAppConfig.llm?.reasoningEffort !== 'high') { +if (defaultAppConfig.llm?.reasoningEffort !== 'max') { throw new Error( - 'AI game creator shell default llm.reasoningEffort must stay high', + 'AI game creator shell default llm.reasoningEffort must stay max', ); } diff --git a/apps/ai-game-creator-shell/scripts/direct-codex-smoke.mjs b/apps/ai-game-creator-shell/scripts/direct-codex-smoke.mjs new file mode 100644 index 000000000..85e70d72f --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/direct-codex-smoke.mjs @@ -0,0 +1,25 @@ +import fs from 'node:fs'; +import { spawn } from 'node:child_process'; +import readline from 'node:readline'; + +const configPath = process.env.AGC_CONFIG_PATH ?? 'C:/Users/kdletters/AppData/Roaming/world.genarrative.ai-game-creator/game-creator.config.json'; +const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); +const args = ['app-server', '--stdio', '-c', 'mcp_servers={}', '-c', 'web_search="disabled"', '-c', 'agents.enabled=false']; +for (const feature of ['apps','browser_use','browser_use_external','browser_use_full_cdp_access','computer_use','goals','image_generation','in_app_browser','plugins','remote_plugin','shell_tool','tool_suggest','unified_exec','workspace_dependencies']) args.push('--disable', feature); +args.push('-c', 'model_provider="genarrative_agc"', '-c', 'model_providers.genarrative_agc.name="Genarrative AGC"', '-c', `model_providers.genarrative_agc.base_url="${config.llm.baseUrl.replace(/\/$/, '')}"`, '-c', 'model_providers.genarrative_agc.env_key="GENARRATIVE_AGC_API_KEY"', '-c', 'model_providers.genarrative_agc.wire_api="responses"'); +const child = spawn('C:/Program Files/nodejs/node.exe', ['C:/Users/kdletters/AppData/Roaming/npm/node_modules/@openai/codex/bin/codex.js', ...args], { cwd: process.cwd(), env: { ...process.env, GENARRATIVE_AGC_API_KEY: config.llm.apiKey }, stdio: ['pipe', 'pipe', 'pipe'] }); +const send = (value) => child.stdin.write(`${JSON.stringify(value)}\n`); +const timer = setTimeout(() => { console.log('smoke-timeout'); child.kill(); process.exit(1); }, Number(process.env.DIRECT_SMOKE_TIMEOUT_MS ?? 180000)); +readline.createInterface({ input: child.stdout }).on('line', (line) => { + let message; try { message = JSON.parse(line); } catch { return; } + if (message.id === 2) { + console.log('thread-start-ok'); + const write = process.env.DIRECT_SMOKE_WRITE === '1'; + send({ id: 3, method: 'turn/start', params: { threadId: message.result.thread.id, input: [{ type: 'text', text: process.env.DIRECT_SMOKE_PROMPT ?? (write ? '在当前工作区创建 smoke-marker.txt,写入 DIRECT_CODEX_WRITE_OK,然后回复已完成。' : '只回复 DIRECT_CODEX_SMOKE_OK,不要修改文件。') }], model: config.llm.model, approvalPolicy: 'never', sandboxPolicy: write ? { type: 'workspaceWrite', writableRoots: [process.cwd()], networkAccess: true } : { type: 'readOnly', networkAccess: false } } }); + } + if (message.id === 3 && message.error) { console.log(`turn-start-error:${message.error.message}`); clearTimeout(timer); child.kill(); process.exit(1); } + if (message.method === 'turn/completed') { console.log(`turn-completed:${message.params?.turn?.status}`); clearTimeout(timer); child.kill(); process.exit(message.params?.turn?.status === 'completed' ? 0 : 1); } +}); +send({ id: 1, method: 'initialize', params: { clientInfo: { name: 'agc-direct-smoke', version: '0.1' }, capabilities: { experimentalApi: true } } }); +send({ method: 'initialized', params: {} }); +send({ id: 2, method: 'thread/start', params: { cwd: process.cwd(), model: config.llm.model, approvalPolicy: 'never', sandbox: process.env.DIRECT_SMOKE_WRITE === '1' ? 'workspace-write' : 'read-only', ephemeral: true } }); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 8bda8dd61..8020721aa 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -11,6 +11,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; mod codex_app_server; mod codex_cli; +mod direct_runtime; mod generation; mod interaction; mod prompt; @@ -20,11 +21,13 @@ mod runtime_driver; mod runtime_protocol; mod runtime_state; mod runtime_tools; +pub(crate) use codex_app_server::direct_game_creator_codex_chat_at; use codex_app_server::*; use codex_cli::*; pub(crate) use codex_cli::{ game_creator_codex_cli_executable_path, game_creator_codex_cli_version_identity, }; +pub(crate) use direct_runtime::*; pub(crate) use generation::*; pub(crate) use interaction::*; pub(crate) use prompt::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 66982a3a4..f28aaa9e2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -1,4 +1,5 @@ use super::*; +use platform_llm::LlmMessageRole; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::process::Stdio; @@ -21,6 +22,8 @@ pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX "codex-app-server-terminal-unknown:"; pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX: &str = "codex-app-server-error:"; +const DIRECT_CODEX_BASE_INSTRUCTIONS_FALLBACK: &str = + "You are Codex working directly in the user's Genarrative game project. Follow the AGC system instructions, inspect and modify files in the current workspace when needed, and report concrete progress and failures. Do not invent completion evidence."; type RpcResult = Result; @@ -317,6 +320,7 @@ struct CodexAppServerInner { last_used: AtomicU64, closed: AtomicBool, _working_dir: tempfile::TempDir, + workspace_path: std::path::PathBuf, } #[derive(Clone)] @@ -370,6 +374,27 @@ fn game_creator_codex_app_server_pool_key( format!("{:x}", Sha256::digest(stable.to_string().as_bytes())) } +fn direct_codex_base_instructions(request: &LlmRunRequest) -> String { + request + .messages + .iter() + .find(|message| message.role == LlmMessageRole::System) + .map(|message| message.content.trim()) + .filter(|value| !value.is_empty()) + .unwrap_or(DIRECT_CODEX_BASE_INSTRUCTIONS_FALLBACK) + .to_string() +} + +fn direct_codex_user_prompt(request: &LlmRunRequest) -> String { + request + .messages + .iter() + .filter(|message| message.role != LlmMessageRole::System) + .map(|message| message.content.as_str()) + .collect::>() + .join("\n\n") +} + fn find_game_creator_codex_auth_path() -> Option { std::env::var_os("CODEX_HOME") .map(std::path::PathBuf::from) @@ -478,7 +503,7 @@ fn configure_game_creator_codex_app_server_command( .arg("web_search=\"disabled\"") .arg("-c") .arg("agents.enabled=false"); - for feature in [ + let disabled_features = [ "apps", "browser_use", "browser_use_external", @@ -489,13 +514,18 @@ fn configure_game_creator_codex_app_server_command( "in_app_browser", "plugins", "remote_plugin", - "shell_tool", "tool_suggest", - "unified_exec", "workspace_dependencies", - ] { + ]; + for feature in disabled_features { command.arg("--disable").arg(feature); } + // AGC does not implement an approval broker for shell execution. Keep the + // native file-change tool available for direct Codex turns, but disable + // shell/unified_exec so the model cannot fall back to command requests that + // the unattended direct bridge must reject. + command.arg("--disable").arg("shell_tool"); + command.arg("--disable").arg("unified_exec"); if !llm.api_key.trim().is_empty() { command .arg("-c") @@ -557,10 +587,32 @@ fn prepare_isolated_game_creator_codex_home( Ok(isolated_home) } +fn trust_isolated_game_creator_codex_workspace( + codex_home: &std::path::Path, + workspace: &std::path::Path, +) -> Result<(), platform_llm::LlmError> { + let workspace = workspace.to_string_lossy(); + let quoted_workspace = quoted_toml_string(&workspace)?; + let config = format!("[projects.{quoted_workspace}]\ntrust_level = \"trusted\"\n"); + std::fs::write(codex_home.join("config.toml"), config).map_err(|error| { + platform_llm::LlmError::Transport(format!( + "写入隔离 Codex app-server 项目信任配置失败:{error}" + )) + }) +} + impl CodexAppServerConnection { async fn acquire( snapshot: &AgentRuntimeProviderRequestSnapshot, llm: &GameCreatorLlmConfig, + ) -> Result { + Self::acquire_at_workspace(snapshot, llm, None).await + } + + async fn acquire_at_workspace( + snapshot: &AgentRuntimeProviderRequestSnapshot, + llm: &GameCreatorLlmConfig, + workspace_override: Option<&std::path::Path>, ) -> Result { game_creator_codex_app_server_validate_llm_config(llm)?; let codex_cli_version = game_creator_codex_cli_version_identity() @@ -605,7 +657,20 @@ impl CodexAppServerConnection { .await; } } - let connection = Self::spawn(llm, &credential).await?; + let connection = match workspace_override { + Some(workspace) => { + let executable = game_creator_codex_cli_executable_path() + .map_err(platform_llm::LlmError::InvalidConfig)?; + Self::spawn_with_executable_and_credential_at_workspace( + llm, + &credential, + executable.as_os_str(), + Some(workspace), + ) + .await? + } + None => Self::spawn(llm, &credential).await?, + }; pool.insert(key, Arc::clone(&connection.inner)); Ok(connection) } @@ -631,6 +696,16 @@ impl CodexAppServerConnection { llm: &GameCreatorLlmConfig, credential: &CodexAppServerCredential, executable: &std::ffi::OsStr, + ) -> Result { + Self::spawn_with_executable_and_credential_at_workspace(llm, credential, executable, None) + .await + } + + async fn spawn_with_executable_and_credential_at_workspace( + llm: &GameCreatorLlmConfig, + credential: &CodexAppServerCredential, + executable: &std::ffi::OsStr, + workspace_override: Option<&std::path::Path>, ) -> Result { let working_dir = tempfile::Builder::new() .prefix("genarrative-agc-codex-app-server-") @@ -643,16 +718,24 @@ impl CodexAppServerConnection { let isolated_codex_home = prepare_isolated_game_creator_codex_home(working_dir.path(), credential)?; let isolated_workspace = working_dir.path().join("workspace"); - std::fs::create_dir(&isolated_workspace).map_err(|error| { - platform_llm::LlmError::Transport(format!( - "创建 Codex app-server 隔离工作目录失败:{error}" - )) - })?; - std::fs::create_dir(isolated_workspace.join(".git")).map_err(|error| { - platform_llm::LlmError::Transport(format!( - "创建 Codex app-server 隔离仓库边界失败:{error}" - )) - })?; + if workspace_override.is_none() { + std::fs::create_dir(&isolated_workspace).map_err(|error| { + platform_llm::LlmError::Transport(format!( + "创建 Codex app-server 隔离工作目录失败:{error}" + )) + })?; + std::fs::create_dir(isolated_workspace.join(".git")).map_err(|error| { + platform_llm::LlmError::Transport(format!( + "创建 Codex app-server 隔离仓库边界失败:{error}" + )) + })?; + } + let workspace_path = workspace_override + .map(std::path::Path::to_path_buf) + .unwrap_or_else(|| isolated_workspace.clone()); + if workspace_override.is_some() { + trust_isolated_game_creator_codex_workspace(&isolated_codex_home, &workspace_path)?; + } let isolated_os_home = working_dir.path().join("home"); let isolated_app_data = isolated_os_home.join("appdata"); let isolated_local_app_data = isolated_os_home.join("local-appdata"); @@ -670,7 +753,7 @@ impl CodexAppServerConnection { let mut command = tokio::process::Command::new(executable); configure_game_creator_codex_app_server_command(&mut command, llm)?; command - .current_dir(&isolated_workspace) + .current_dir(&workspace_path) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -714,6 +797,7 @@ impl CodexAppServerConnection { last_used: AtomicU64::new(next_game_creator_codex_app_server_usage_tick()), closed: AtomicBool::new(false), _working_dir: working_dir, + workspace_path, }); tokio::spawn(read_game_creator_codex_app_server_stdout( Arc::downgrade(&inner), @@ -877,13 +961,25 @@ impl CodexAppServerConnection { .as_deref() .filter(|value| !value.trim().is_empty()) .unwrap_or(&llm.model); + let direct_workspace = + self.inner.workspace_path != self.inner._working_dir.path().join("workspace"); + let approval_policy = if direct_workspace { + "on-request" + } else { + "never" + }; + let base_instructions = if direct_workspace { + direct_codex_base_instructions(&request) + } else { + DIRECT_CODEX_BASE_INSTRUCTIONS_FALLBACK.to_string() + }; let mut params = serde_json::json!({ "model": model, - "cwd": self.inner._working_dir.path().join("workspace"), - "approvalPolicy": "never", - "sandbox": "read-only", + "cwd": self.inner.workspace_path, + "approvalPolicy": approval_policy, + "sandbox": if direct_workspace { "workspace-write" } else { "read-only" }, "ephemeral": true, - "baseInstructions": "You are an isolated reasoning engine for Genarrative AGC. Never use native tools, shell, files, network, MCP, skills, plugins, hooks, or sub-agents. Use only the complete message snapshot provided in each turn and return only the requested answer contract." + "baseInstructions": base_instructions }); if !llm.api_key.trim().is_empty() { params["modelProvider"] = @@ -965,8 +1061,19 @@ impl CodexAppServerConnection { let _turn_guard = self.inner.turn_gate.lock().await; let thread_lease = self.thread_for(snapshot, &request, llm).await?; let thread_id = thread_lease.thread_id.clone(); - let prompt = render_game_creator_codex_cli_prompt(&request) - .map_err(platform_llm::LlmError::InvalidRequest)?; + let direct_workspace = + self.inner.workspace_path != self.inner._working_dir.path().join("workspace"); + let approval_policy = if direct_workspace { + "on-request" + } else { + "never" + }; + let prompt = if direct_workspace { + direct_codex_user_prompt(&request) + } else { + render_game_creator_codex_cli_prompt(&request) + .map_err(platform_llm::LlmError::InvalidRequest)? + }; let model = request .model .as_deref() @@ -976,8 +1083,12 @@ impl CodexAppServerConnection { "threadId": thread_id, "input": [{ "type": "text", "text": prompt }], "model": model, - "approvalPolicy": "never", - "sandboxPolicy": { "type": "readOnly", "networkAccess": false }, + "approvalPolicy": approval_policy, + "sandboxPolicy": { + "type": "workspaceWrite", + "writableRoots": [self.inner.workspace_path], + "networkAccess": true + }, }); apply_game_creator_codex_app_server_reasoning_effort(&mut params, &request); if let Some(schema) = game_creator_codex_cli_tool_output_schema(&request) { @@ -1061,10 +1172,12 @@ impl CodexAppServerConnection { { final_text = Some(text.to_string()); } - } else if !matches!( - item_type, - "userMessage" | "plan" | "reasoning" | "contextCompaction" - ) { + } else if !direct_workspace + && !matches!( + item_type, + "userMessage" | "plan" | "reasoning" | "contextCompaction" + ) + { return Err(platform_llm::LlmError::InvalidRequest(format!( "Codex app-server 违反 AGC ToolHost 边界,产生非被动 item {item_type}" ))); @@ -1326,17 +1439,148 @@ async fn read_game_creator_codex_app_server_stdout( let Some(inner) = inner.upgrade() else { return; }; + let direct_debug = std::env::var_os("GENARRATIVE_AGC_DIRECT_DEBUG").is_some() + && inner.workspace_path != inner._working_dir.path().join("workspace"); + if direct_debug { + let method = message + .get("method") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + if !method.is_empty() { + let params_keys = message + .get("params") + .and_then(serde_json::Value::as_object) + .map(|params| params.keys().cloned().collect::>()) + .unwrap_or_default(); + eprintln!( + "agent.direct_codex.event method={} paramsKeys={:?}", + method, params_keys + ); + } else if let Some(params) = message.get("params") { + let event_name = params + .get("type") + .and_then(serde_json::Value::as_str) + .or_else(|| { + params + .get("item") + .and_then(|item| item.get("type")) + .and_then(serde_json::Value::as_str) + }) + .unwrap_or("unknown"); + let status = params + .pointer("/turn/status") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + eprintln!( + "agent.direct_codex.event notification={} status={}", + event_name, status + ); + if event_name == "item/completed" || event_name == "item/started" { + if let Some(item) = params.get("item") { + let item_type = item + .get("type") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown"); + let command = item + .get("command") + .and_then(serde_json::Value::as_str) + .map(|value| value.chars().take(512).collect::()); + let change_paths = item + .get("changes") + .and_then(serde_json::Value::as_array) + .map(|changes| { + changes + .iter() + .filter_map(|change| { + change + .get("path") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .collect::>() + }); + eprintln!( + "agent.direct_codex.item event={} type={} command={:?} changePaths={:?}", + event_name, + item_type, + command, + change_paths + ); + } + } + } + } if let Some(id) = message.get("id").and_then(serde_json::Value::as_u64) { if message.get("method").is_some() { let writer = Arc::clone(&inner); tokio::spawn(async move { - let response = serde_json::json!({ - "id": id, - "error": { - "code": -32601, - "message": "Genarrative AGC 拒绝 app-server 的交互、审批与工具请求" + let direct_workspace = + inner.workspace_path != inner._working_dir.path().join("workspace"); + let direct_debug = std::env::var_os("GENARRATIVE_AGC_DIRECT_DEBUG").is_some(); + let method = message + .get("method") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let requested_grant_root = message + .get("params") + .and_then(|params| params.get("grantRoot")) + .and_then(serde_json::Value::as_str); + if direct_workspace && direct_debug { + eprintln!( + "agent.direct_codex.server_request method={method} grantRoot={requested_grant_root:?}" + ); + } + let file_change_within_workspace = + game_creator_codex_file_change_request_is_allowed( + &inner.workspace_path, + method, + requested_grant_root, + ); + let response = if direct_workspace + && file_change_within_workspace + && matches!( + method, + "item/commandExecution/requestApproval" + | "item/fileChange/requestApproval" + | "item/mcpToolCall/requestApproval" + | "item/permissions/requestApproval" + ) { + if method == "item/permissions/requestApproval" { + serde_json::json!({ + "id": id, + "result": { + "permissions": { + "fileSystem": { + "write": [inner.workspace_path] + }, + "network": { "enabled": true } + }, + "scope": "turn" + } + }) + } else { + serde_json::json!({ + "id": id, + "result": { "decision": "accept" } + }) } - }); + } else if direct_workspace && method == "item/fileChange/requestApproval" { + serde_json::json!({ + "id": id, + "error": { + "code": -32602, + "message": "Genarrative AGC 只允许当前项目工作区的文件变更审批" + } + }) + } else { + serde_json::json!({ + "id": id, + "error": { + "code": -32601, + "message": "Genarrative AGC 拒绝 app-server 的交互、审批与工具请求" + } + }) + }; let mut bytes = match serde_json::to_vec(&response) { Ok(bytes) => bytes, Err(_) => return, @@ -1495,6 +1739,17 @@ async fn read_game_creator_codex_app_server_stderr( let Some(upgraded) = inner.upgrade() else { return; }; + if std::env::var_os("GENARRATIVE_AGC_DIRECT_DEBUG").is_some() + && upgraded.workspace_path != upgraded._working_dir.path().join("workspace") + { + let text = String::from_utf8_lossy(&buffer[..count]); + let bounded = text.chars().take(2_048).collect::(); + eprintln!( + "agent.direct_codex.stderr bytes={} text={}", + count, + redact_game_creator_codex_debug_text(&bounded) + ); + } let oversized_record = upgraded .stderr_summary .lock() @@ -1512,6 +1767,99 @@ async fn read_game_creator_codex_app_server_stderr( } } +fn redact_game_creator_codex_debug_text(text: &str) -> String { + let mut redacted = text.to_string(); + let mut bearer_search_from = 0; + while let Some(relative_start) = redacted[bearer_search_from..].find("Bearer ") { + let start = bearer_search_from + relative_start; + let value_start = start + "Bearer ".len(); + let value_end = redacted[value_start..] + .find(char::is_whitespace) + .map(|offset| value_start + offset) + .unwrap_or(redacted.len()); + redacted.replace_range(value_start..value_end, "[redacted]"); + bearer_search_from = value_start + "[redacted]".len(); + } + for marker in ["apiKey=", "api_key=", "token=", "access_token="] { + let mut search_from = 0; + while let Some(relative_start) = redacted[search_from..].find(marker) { + let start = search_from + relative_start; + let value_start = start + marker.len(); + let value_end = redacted[value_start..] + .find(|ch: char| ch.is_whitespace() || matches!(ch, ',' | ';' | '}' | '"')) + .map(|offset| value_start + offset) + .unwrap_or(redacted.len()); + redacted.replace_range(value_start..value_end, "[redacted]"); + search_from = value_start + "[redacted]".len(); + } + } + redacted +} + +fn game_creator_codex_workspace_path_key(path: &std::path::Path) -> String { + let value = path.to_string_lossy(); + value + .strip_prefix("\\\\?\\") + .unwrap_or(value.as_ref()) + .replace('/', "\\") + .trim_end_matches('\\') + .to_ascii_lowercase() +} + +fn game_creator_codex_grant_root_is_within_workspace( + workspace: &std::path::Path, + grant_root: &str, +) -> bool { + fn canonicalize_with_missing_tail(path: &std::path::Path) -> Option { + if !path.is_absolute() + || path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + return None; + } + let mut existing = path.to_path_buf(); + let mut missing_tail = Vec::new(); + while !existing.exists() { + missing_tail.push(existing.file_name()?.to_os_string()); + if !existing.pop() { + return None; + } + } + let mut normalized = existing.canonicalize().ok()?; + for component in missing_tail.iter().rev() { + normalized.push(component); + } + Some(normalized) + } + + let workspace = workspace + .canonicalize() + .unwrap_or_else(|_| workspace.to_path_buf()); + let Some(grant_root) = canonicalize_with_missing_tail(std::path::Path::new(grant_root)) else { + return false; + }; + let workspace_key = game_creator_codex_workspace_path_key(&workspace); + let grant_key = game_creator_codex_workspace_path_key(&grant_root); + grant_key == workspace_key + || grant_key + .strip_prefix(&workspace_key) + .is_some_and(|suffix| suffix.starts_with('\\')) +} + +fn game_creator_codex_file_change_request_is_allowed( + workspace: &std::path::Path, + method: &str, + grant_root: Option<&str>, +) -> bool { + method != "item/fileChange/requestApproval" + // `grantRoot: null` means the existing turn sandbox root; only an + // explicit expansion request needs an extra path check. + || grant_root.is_none() + || grant_root + .is_some_and(|grant_root| game_creator_codex_grant_root_is_within_workspace(workspace, grant_root)) +} + async fn fail_game_creator_codex_app_server_connection( inner: &Weak, error: String, @@ -1572,6 +1920,63 @@ pub(in crate::agent) async fn request_game_creator_agent_codex_app_server( .await } +/// Direct product chat path: a Codex app-server turn in the selected project +/// workspace. The pool keeps one ephemeral thread per project so follow-up +/// messages retain Codex context while still bypassing the Supervisor/runtime +/// harness. +pub(crate) async fn direct_game_creator_codex_chat_at( + root: &std::path::Path, + system_prompt: String, + user_prompt: String, +) -> Result { + let codex_root = if let Some(path) = root + .to_str() + .and_then(|value| value.strip_prefix("\\\\?\\")) + { + std::path::PathBuf::from(path) + } else { + root.to_path_buf() + }; + let config = load_game_creator_app_config()?; + game_creator_codex_app_server_validate_llm_config(&config.llm) + .map_err(|error| error.to_string())?; + let mut digest = Sha256::new(); + digest.update(root.to_string_lossy().as_bytes()); + let project_id = format!("{:x}", digest.finalize()); + let snapshot = AgentRuntimeProviderRequestSnapshot { + project_id: project_id.clone(), + agent_id: "direct-codex".to_string(), + task_id: "direct-codex-chat".to_string(), + session_id: format!("direct-codex-session-{project_id}"), + run_id: format!("direct-codex-thread-{project_id}"), + source: "direct-codex-chat".to_string(), + goal_id: None, + goal_revision: 0, + goal_snapshot_fingerprint: String::new(), + applied_steer_cursor: 0, + request_kind: "direct-chat".to_string(), + request_slot: "direct-chat".to_string(), + web_search_enabled: config.llm.web_search_enabled, + allow_idle_context_compaction: false, + }; + let api_kind = + parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| error.to_string())?; + let request = LlmRunRequest::single_turn(system_prompt, user_prompt) + .with_api_kind(api_kind) + .with_model(config.llm.model.clone()) + .with_request_timeout_ms(config.llm.request_timeout_ms) + .with_max_output_tokens(16_000); + let connection = + CodexAppServerConnection::acquire_at_workspace(&snapshot, &config.llm, Some(&codex_root)) + .await + .map_err(|error| error.to_string())?; + connection + .run_turn(&snapshot, &config.llm, request, None) + .await + .map(|value| value.text) + .map_err(|error| error.to_string()) +} + pub(in crate::agent) async fn stream_game_creator_agent_codex_app_server( snapshot: &AgentRuntimeProviderRequestSnapshot, llm: &GameCreatorLlmConfig, @@ -1653,6 +2058,14 @@ mod tests { ]) } + #[test] + fn direct_codex_turn_keeps_system_prompt_out_of_user_input() { + let request = LlmRunRequest::single_turn("AGC 系统规则", "制作一个可玩的游戏"); + assert_eq!(direct_codex_base_instructions(&request), "AGC 系统规则"); + assert_eq!(direct_codex_user_prompt(&request), "制作一个可玩的游戏"); + assert!(!direct_codex_user_prompt(&request).contains("AGC 系统规则")); + } + #[test] fn codex_app_server_reasoning_effort_preserves_max_wire_value() { let request = LlmRunRequest::single_turn("系统", "任务") @@ -1758,6 +2171,66 @@ mod tests { assert!(game_creator_codex_app_server_validate_llm_config(&llm).is_err()); } + #[test] + fn direct_debug_redaction_does_not_retain_inline_credentials() { + let text = redact_game_creator_codex_debug_text( + "Bearer secret apiKey=one token=two access_token=three", + ); + assert!(!text.contains("secret")); + assert!(!text.contains("one")); + assert!(!text.contains("two")); + assert!(!text.contains("three")); + assert!(text.contains("[redacted]")); + } + + #[test] + fn direct_file_change_approval_is_limited_to_workspace() { + let temp = tempfile::tempdir().expect("temp dir"); + let workspace = temp.path().join("demo"); + let child = workspace.join("game"); + let sibling = temp.path().join("demolition"); + std::fs::create_dir_all(&child).expect("workspace child"); + std::fs::create_dir(&sibling).expect("sibling"); + assert!(game_creator_codex_file_change_request_is_allowed( + &workspace, + "item/fileChange/requestApproval", + None + )); + assert!(game_creator_codex_grant_root_is_within_workspace( + &workspace, + workspace.to_string_lossy().as_ref() + )); + assert!(game_creator_codex_grant_root_is_within_workspace( + &workspace, + child.to_string_lossy().as_ref() + )); + assert!(!game_creator_codex_grant_root_is_within_workspace( + &workspace, + sibling.to_string_lossy().as_ref() + )); + assert!(!game_creator_codex_file_change_request_is_allowed( + &workspace, + "item/fileChange/requestApproval", + Some(sibling.to_string_lossy().as_ref()) + )); + assert!(!game_creator_codex_grant_root_is_within_workspace( + &workspace, + sibling.join("missing").to_string_lossy().as_ref() + )); + assert!(game_creator_codex_grant_root_is_within_workspace( + &workspace, + workspace.join("missing").to_string_lossy().as_ref() + )); + assert!(!game_creator_codex_grant_root_is_within_workspace( + &workspace, + workspace + .join("..") + .join("outside") + .to_string_lossy() + .as_ref() + )); + } + #[test] fn codex_app_server_command_uses_only_current_cli_feature_flags() { let mut command = tokio::process::Command::new("codex"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs new file mode 100644 index 000000000..9b233897c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -0,0 +1,1062 @@ +use super::*; +use std::path::Path; + +const MAX_CONTEXT_FILE_BYTES: usize = 6 * 1024; +const MAX_DIRECT_PROJECT_FILE_BYTES: usize = 24 * 1024; +const MAX_CONTEXT_FILES_PER_SOURCE: usize = 6; +const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 64 * 1024; +const BUNDLED_REPOSITORY_AGENTS: &str = include_str!("../../../../../AGENTS.md"); +const BUNDLED_AGC_IMPLEMENTATION_PLAN: &str = include_str!( + "../../../../../docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md" +); +const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png"; +const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png"; +const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png"; +const DIRECT_CODEX_SPRITESHEET_SLICE_PATHS: [(&str, &str); 4] = [ + ("player", "assets/art-spritesheet-slices/player.png"), + ( + "blocks-and-targets", + "assets/art-spritesheet-slices/blocks-and-targets.png", + ), + ( + "obstacles-and-scene", + "assets/art-spritesheet-slices/obstacles-and-scene.png", + ), + ( + "feedback-effects", + "assets/art-spritesheet-slices/feedback-effects.png", + ), +]; +const DIRECT_CODEX_ART_ASSET_PATHS: [&str; 3] = [ + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, +]; +const DIRECT_CODEX_GAME_OUTPUTS: [(&str, &str, &str); 3] = [ + ("game/index.html", "game-entry", "text/html"), + ("game/style.css", "game-style", "text/css"), + ("game/game.js", "game-script", "text/javascript"), +]; +const DIRECT_CODEX_BUILTIN_SKILL: &str = "技能 direct-game-creation:先检查当前 game/ 入口和已有玩法,优先最小修改;需要生成新游戏时直接创建可运行的 game/index.html、style.css 和 game.js,保证 Canvas、输入、主要玩法、重开和移动端布局。游戏必须适配 16:9 横屏逻辑视口,页面本身不得出现横向或纵向滚动条。客户端会在本回合开始前通过陶泥儿平台按标准工作流生成并登记 `assets/art-spec.png`、场景背景 `assets/direct-game-background.png`、透明主图集 `assets/art-spritesheet.png` 及四个切片:`player.png`、`blocks-and-targets.png`、`obstacles-and-scene.png`、`feedback-effects.png`。规范图只能作为统一风格来源;你必须将背景图用于实际场景,并在 `game/game.js` 中加载四个切片、通过 Canvas `drawImage` 分别绘制玩家/可操作物、核心目标或道具、障碍或场景元素、反馈或界面装饰等核心视觉。不得用 emoji、纯色块或 CSS 图形代替这些核心视觉,也不得只在回复中提及图片路径。不要自行调用生图 API、不要伪造图片或修改 .agent/manifest.json;客户端会负责平台调用与资源登记。完成文件修改后运行可用的静态检查并通过 AGC 本地预览确认真实页面;执行环境没有命令或浏览器工具时必须如实说明。只使用当前工作区文件变更,不启动任何 Supervisor、child、harness 或隐藏返工。"; +const DIRECT_TAONIER_ART_WORKFLOW: &str = "陶泥儿游戏创作工作流:客户端先按标准三阶段生成并登记首版美术:`assets/art-spec.png` 是统一视觉规范,`assets/direct-game-background.png` 是 16:9 场景背景,`assets/art-spritesheet.png` 是基于规范图生成的透明核心图集;平台还会生成四份受合同保护的切片 `assets/art-spritesheet-slices/player.png`、`assets/art-spritesheet-slices/blocks-and-targets.png`、`assets/art-spritesheet-slices/obstacles-and-scene.png`、`assets/art-spritesheet-slices/feedback-effects.png`。你只负责在当前工作区实现游戏。项目结构固定为 `assets/` 存放已登记的真实媒体、`game/index.html` 为入口、`game/style.css` 与 `game/game.js` 为游戏代码、`memory/` 存放玩法记忆、`.agent/` 为客户端维护的项目状态。不得手写或修改 `.agent/manifest.json`,不得创建伪造 PNG、data URL、emoji 或纯 CSS 图形来代替核心美术。背景图必须实际用于游戏场景;四个切片必须由 `game/game.js` 加载,并分别通过 Canvas `drawImage` 渲染到可玩画面。只有完整平台美术包被实际使用、并且游戏代码存在时,本轮才可交付完成。"; + +#[derive(Clone, Debug, Eq, PartialEq)] +struct DirectTaonierArtAssetIdentity { + resource_id: String, + asset_object_id: String, + canvas_project_id: String, +} + +fn direct_taonier_art_asset_identity( + root: &Path, + expected_path: &str, + expected_kind: &str, + expected_generation_route: &str, + expected_generation_kind: &str, + expected_reference_resource_ids: &[&str], + expected_canvas_project_id: Option<&str>, +) -> Option { + let manifest = read_manifest(&root.join(".agent/manifest.json")).ok()?; + manifest.assets.iter().find_map(|asset| { + if asset.local_path != expected_path + || asset.kind != expected_kind + || !asset.media_type.starts_with("image/") + || asset.source.kind != GameCreationAppAssetSourceKind::Canvas + || asset.source.generation_route.as_deref() != Some(expected_generation_route) + || asset.source.generation_kind.as_deref() != Some(expected_generation_kind) + || asset.source.reference_resource_ids.len() != expected_reference_resource_ids.len() + || !asset + .source + .reference_resource_ids + .iter() + .map(String::as_str) + .eq(expected_reference_resource_ids.iter().copied()) + || expected_canvas_project_id + .is_some_and(|expected| asset.source.canvas_project_id.as_deref() != Some(expected)) + || !root.join(&asset.local_path).is_file() + { + return None; + } + Some(DirectTaonierArtAssetIdentity { + resource_id: asset + .source + .resource_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty())? + .to_string(), + asset_object_id: asset + .source + .asset_object_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty())? + .to_string(), + canvas_project_id: asset + .source + .canvas_project_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty())? + .to_string(), + }) + }) +} + +fn direct_taonier_spritesheet_slice_contract_is_valid( + root: &Path, + spritesheet_resource_id: &str, + spritesheet_asset_object_id: &str, + spritesheet_canvas_project_id: &str, + art_spec_resource_id: &str, +) -> bool { + let Some(manifest) = std::fs::read(root.join("assets/art-spritesheet-slices/manifest.json")) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + else { + return false; + }; + let Some(receipt) = std::fs::read(root.join(".agent/runtime/art-spritesheet-contract.json")) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + else { + return false; + }; + DIRECT_CODEX_SPRITESHEET_SLICE_PATHS + .iter() + .all(|(usage, path)| { + root.join(path).is_file() + && manifest + .get("slices") + .and_then(serde_json::Value::as_array) + .is_some_and(|slices| { + slices.iter().any(|slice| { + slice.get("usage").and_then(serde_json::Value::as_str) == Some(*usage) + && slice.get("path").and_then(serde_json::Value::as_str) + == Some(*path) + }) + }) + && receipt + .get("slices") + .and_then(serde_json::Value::as_array) + .is_some_and(|slices| { + slices.iter().any(|slice| { + slice.get("usage").and_then(serde_json::Value::as_str) == Some(*usage) + && slice.get("path").and_then(serde_json::Value::as_str) + == Some(*path) + }) + }) + }) + && manifest.get("source").and_then(serde_json::Value::as_str) + == Some(DIRECT_CODEX_SPRITESHEET_ASSET_PATH) + && manifest + .get("sourceResourceId") + .and_then(serde_json::Value::as_str) + == Some(spritesheet_resource_id) + && manifest + .get("sourceAssetObjectId") + .and_then(serde_json::Value::as_str) + == Some(spritesheet_asset_object_id) + && manifest + .get("sourceCanvasProjectId") + .and_then(serde_json::Value::as_str) + == Some(spritesheet_canvas_project_id) + && manifest + .get("sourceReferenceResourceIds") + .and_then(serde_json::Value::as_array) + .is_some_and(|references| { + references.len() == 1 && references[0].as_str() == Some(art_spec_resource_id) + }) + && receipt.get("source").and_then(serde_json::Value::as_str) + == Some(DIRECT_CODEX_SPRITESHEET_ASSET_PATH) + && receipt + .get("sourceResourceId") + .and_then(serde_json::Value::as_str) + == Some(spritesheet_resource_id) + && receipt + .get("sourceAssetObjectId") + .and_then(serde_json::Value::as_str) + == Some(spritesheet_asset_object_id) + && receipt + .get("sourceCanvasProjectId") + .and_then(serde_json::Value::as_str) + == Some(spritesheet_canvas_project_id) + && receipt + .get("sourceReferenceResourceIds") + .and_then(serde_json::Value::as_array) + .is_some_and(|references| { + references.len() == 1 && references[0].as_str() == Some(art_spec_resource_id) + }) +} + +fn direct_taonier_art_package_is_valid(root: &Path) -> bool { + let Some(art_spec) = direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + "icon-spec", + "/api/external/v1/editor/images/generations", + "spec", + &[], + None, + ) else { + return false; + }; + let Some(background) = direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + "game-background", + "/api/external/v1/editor/images/generations", + "spec", + &[&art_spec.resource_id], + Some(&art_spec.canvas_project_id), + ) else { + return false; + }; + let Some(spritesheet) = direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + "art-spritesheet", + "/api/external/v1/editor/icon-spritesheets/generations", + "icon-spritesheet", + &[&art_spec.resource_id], + Some(&art_spec.canvas_project_id), + ) else { + return false; + }; + let _ = background; + direct_taonier_spritesheet_slice_contract_is_valid( + root, + &spritesheet.resource_id, + &spritesheet.asset_object_id, + &spritesheet.canvas_project_id, + &art_spec.resource_id, + ) +} + +fn direct_asset_file_name(path: &str) -> &str { + Path::new(path) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(path) +} + +fn direct_game_sources_reference_taonier_art_package(root: &Path) -> bool { + let background_name = direct_asset_file_name(DIRECT_CODEX_BACKGROUND_ASSET_PATH); + let sources = ["game/index.html", "game/style.css", "game/game.js"] + .iter() + .filter_map(|relative_path| std::fs::read_to_string(root.join(relative_path)).ok()) + .collect::>(); + let background_is_referenced = sources + .iter() + .any(|source| source.contains(background_name)); + let game_script = std::fs::read_to_string(root.join("game/game.js")).ok(); + let core_slices_are_rendered = game_script.is_some_and(|source| { + DIRECT_CODEX_SPRITESHEET_SLICE_PATHS + .iter() + .all(|(_, path)| source.contains(direct_asset_file_name(path))) + && source.contains("drawImage") + && source.matches("drawImage(").count() >= DIRECT_CODEX_SPRITESHEET_SLICE_PATHS.len() + && (source.contains("new Image") + || source.contains("new window.Image") + || source.contains("document.createElement('img')") + || source.contains("document.createElement(\"img\")")) + }); + background_is_referenced && core_slices_are_rendered +} + +async fn generate_direct_taonier_art_asset_at( + root: &Path, + prompt: &str, + output_path: &str, + aspect_ratio: &str, + asset_kind: &str, + asset_label: &str, + require_slices: bool, +) -> Result<(), String> { + let options = PlatformArtAssetGenerationOptions { + output_path: Some(output_path.to_string()), + aspect_ratio: aspect_ratio.to_string(), + image_size: "1K".to_string(), + asset_kind: asset_kind.to_string(), + asset_label: asset_label.to_string(), + replace_existing: root.join(output_path).is_file(), + }; + let generated = if require_slices { + generate_platform_art_asset_with_required_slices_at(root, prompt, &[], &options).await + } else { + generate_platform_art_asset_with_options_at(root, prompt, &[], &options).await + } + .map_err(|error| { + format!("陶泥儿美术包生成失败({asset_label}),已在启动智能创作前终止:{error}") + })?; + if generated.asset.local_path != output_path { + return Err(format!( + "陶泥儿美术包生成返回后未形成可用的已登记平台素材合同 {output_path},已终止代码生成" + )); + } + Ok(()) +} + +async fn ensure_direct_taonier_art_package_at( + root: &Path, + prompt: &str, +) -> Result, String> { + if direct_taonier_art_package_is_valid(root) { + return Ok(DIRECT_CODEX_ART_ASSET_PATHS + .iter() + .map(|path| (*path).to_string()) + .collect()); + } + let art_spec = match direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + "icon-spec", + "/api/external/v1/editor/images/generations", + "spec", + &[], + None, + ) { + Some(identity) => identity, + None => { + generate_direct_taonier_art_asset_at( + root, + prompt, + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + "1:1", + "icon-spec", + "陶泥儿首版游戏视觉规范图", + false, + ) + .await?; + direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + "icon-spec", + "/api/external/v1/editor/images/generations", + "spec", + &[], + None, + ) + .ok_or_else(|| "陶泥儿规范图生成后未形成可用平台合同,已终止代码生成".to_string())? + } + }; + if direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + "game-background", + "/api/external/v1/editor/images/generations", + "spec", + &[&art_spec.resource_id], + Some(&art_spec.canvas_project_id), + ) + .is_none() + { + generate_direct_taonier_art_asset_at( + root, + prompt, + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + "16:9", + "game-background", + "陶泥儿首版游戏场景背景", + false, + ) + .await?; + } + if direct_taonier_art_package_is_valid(root) { + return Ok(DIRECT_CODEX_ART_ASSET_PATHS + .iter() + .map(|path| (*path).to_string()) + .collect()); + } + generate_direct_taonier_art_asset_at( + root, + prompt, + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + "1:1", + "art-spritesheet", + "陶泥儿首版核心游戏美术图集", + true, + ) + .await?; + if !direct_taonier_art_package_is_valid(root) { + return Err("陶泥儿美术包不完整,已终止代码生成".to_string()); + } + Ok(DIRECT_CODEX_ART_ASSET_PATHS + .iter() + .map(|path| (*path).to_string()) + .collect()) +} + +fn direct_codex_generated_source() -> GameCreationAppAssetSource { + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: Some("code-prototype".to_string()), + prompt: None, + model: None, + generation_route: Some("direct-codex".to_string()), + generation_kind: Some("project-file".to_string()), + reference_resource_ids: Vec::new(), + } +} + +fn direct_codex_output_fingerprint(root: &Path) -> String { + let mut hasher = Sha256::new(); + for (local_path, _, _) in DIRECT_CODEX_GAME_OUTPUTS { + hasher.update(local_path.as_bytes()); + hasher.update([0]); + match std::fs::read(root.join(local_path)) { + Ok(bytes) => { + hasher.update([1]); + hasher.update((bytes.len() as u64).to_le_bytes()); + hasher.update(bytes); + } + Err(_) => hasher.update([0]), + } + } + format!("{:x}", hasher.finalize()) +} + +fn sync_direct_codex_project_outputs_at( + root: &Path, + previous_output_fingerprint: Option<&str>, +) -> Result<(), String> { + if !root.join("game/index.html").is_file() { + return Err("Codex 返回后未找到 game/index.html,项目未进入可运行状态".to_string()); + } + if !direct_taonier_art_package_is_valid(root) { + return Err("游戏代码已生成,但缺少完整已登记的陶泥儿平台美术包(规范图、背景、核心图集与四类切片);不会将项目标记为完成".to_string()); + } + if !direct_game_sources_reference_taonier_art_package(root) { + return Err(format!( + "游戏代码已生成,但未在实际渲染中同时使用陶泥儿背景图 {} 与四类核心切片;不会将项目标记为完成", + DIRECT_CODEX_BACKGROUND_ASSET_PATH + )); + } + let current_output_fingerprint = direct_codex_output_fingerprint(root); + let outputs_changed = previous_output_fingerprint + .map(|previous| previous != current_output_fingerprint) + .unwrap_or(true); + let manifest_before = read_manifest(&root.join(".agent/manifest.json"))?; + let expected_source = direct_codex_generated_source(); + let manifest_requires_sync = + DIRECT_CODEX_GAME_OUTPUTS + .iter() + .any(|(local_path, kind, media_type)| { + root.join(local_path).is_file() + && !manifest_before.assets.iter().any(|asset| { + asset.local_path == *local_path + && asset.kind == *kind + && asset.media_type == *media_type + && asset.source == expected_source + }) + }) + || manifest_before + .tasks + .iter() + .find(|task| task.id == "code-prototype") + .is_none_or(|task| task.status != GameCreationAppTaskStatus::Completed) + || manifest_before.versions.is_empty(); + let mut registered = 0_usize; + for (local_path, kind, media_type) in DIRECT_CODEX_GAME_OUTPUTS { + if !root.join(local_path).is_file() { + continue; + } + register_local_asset_at( + root, + local_path, + kind, + media_type, + "direct-codex", + direct_codex_generated_source(), + )?; + registered += 1; + } + if registered == 0 { + return Err("Codex 返回后没有可登记的游戏文件".to_string()); + } + update_manifest_task_status_at(root, "code-prototype", GameCreationAppTaskStatus::Completed)?; + if outputs_changed || manifest_requires_sync { + let _lock = acquire_project_write_lock(root, "direct-codex.version")?; + let version_revision = advance_agent_runtime_project_revision_locked(root)?; + append_agent_game_iteration_version_at(root, version_revision)?; + } + Ok(()) +} + +fn read_context_file_with_limit(root: &Path, relative: &str, max_bytes: usize) -> Option { + let path = root.join(relative); + let bytes = std::fs::read(path).ok()?; + let bounded = &bytes[..bytes.len().min(max_bytes)]; + let text = sanitize_direct_context(&String::from_utf8_lossy(bounded)); + (!text.trim().is_empty()).then_some(text) +} + +fn read_context_file(root: &Path, relative: &str) -> Option { + read_context_file_with_limit(root, relative, MAX_CONTEXT_FILE_BYTES) +} + +fn bundled_context_text(text: &str) -> String { + let bounded = text + .chars() + .take(MAX_CONTEXT_FILE_BYTES) + .collect::(); + sanitize_direct_context(&bounded) +} + +fn sanitize_direct_context(text: &str) -> String { + let mut output = String::new(); + for line in text.lines() { + let lower = line.to_ascii_lowercase(); + if [ + "api_key", + "apikey", + "access_token", + "bearer ", + "auth.json", + ".env", + "cookie", + "c:\\users\\", + "\\users\\", + "/users/", + ] + .iter() + .any(|marker| lower.contains(marker)) + { + output.push_str("[已省略疑似凭据或宿主私密路径的内容]\n"); + continue; + } + let line = line.chars().take(1_000).collect::(); + output.push_str(&line); + output.push('\n'); + } + output.chars().take(MAX_CONTEXT_FILE_BYTES).collect() +} + +fn read_markdown_files(root: &Path, relative_dir: &str) -> Vec<(String, String)> { + let directory = root.join(relative_dir); + let Ok(entries) = std::fs::read_dir(directory) else { + return Vec::new(); + }; + let mut paths = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_file()) + .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("md")) + .collect::>(); + paths.sort(); + paths + .into_iter() + .take(MAX_CONTEXT_FILES_PER_SOURCE) + .filter_map(|path| { + let bytes = std::fs::read(&path).ok()?; + let bounded = &bytes[..bytes.len().min(MAX_CONTEXT_FILE_BYTES)]; + let text = sanitize_direct_context(&String::from_utf8_lossy(bounded)); + let label = path + .strip_prefix(root) + .ok() + .map(|value| value.to_string_lossy().replace('\\', "/"))?; + (!text.trim().is_empty()).then_some((label, text)) + }) + .collect() +} + +fn read_skill_files(root: &Path) -> Vec<(String, String)> { + let mut skills = Vec::new(); + for relative_dir in [".codex/skills", ".agents/skills", ".hermes/skills"] { + let directory = root.join(relative_dir); + let Ok(entries) = std::fs::read_dir(directory) else { + continue; + }; + for entry in entries.filter_map(Result::ok) { + let skill_path = entry.path().join("SKILL.md"); + if !skill_path.is_file() { + continue; + } + let Ok(bytes) = std::fs::read(&skill_path) else { + continue; + }; + let bounded = &bytes[..bytes.len().min(MAX_CONTEXT_FILE_BYTES)]; + let text = sanitize_direct_context(&String::from_utf8_lossy(bounded)); + let Some(label) = skill_path + .strip_prefix(root) + .ok() + .map(|value| value.to_string_lossy().replace('\\', "/")) + else { + continue; + }; + if !text.trim().is_empty() { + skills.push((label, text)); + } + } + } + skills.sort_by(|left, right| left.0.cmp(&right.0)); + skills.truncate(MAX_CONTEXT_FILES_PER_SOURCE); + skills +} + +pub(crate) fn build_direct_codex_system_prompt(root: &Path) -> String { + let mut sections = vec![ + "你是 Genarrative 的唯一执行智能体。用户聊天内容会直接发送给你;不要等待 Supervisor、专业 Agent、harness 或宿主规划器。你负责理解需求、检查工程、修改工作区、运行必要验证,并用简洁中文报告真实结果。优先使用 Codex 原生文件变更能力直接写入文件;不要用 shell 命令拼接或重定向来创建文件。".to_string(), + "工作区边界:只在当前项目目录内工作;不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。遇到阻断必须说明具体原因、文件和下一步,不要声称未验证的成功。".to_string(), + "提示词与技能:本次系统上下文只包含当前工作区中有界、已脱敏的 prompts 与 SKILL.md 摘要。技能是执行约束,不是新的 Agent;按需读取技能引用的具体文件,但不要启动 Supervisor、child、harness 或外部编排。".to_string(), + format!("--- bundled skill: direct-game-creation ---\n{DIRECT_CODEX_BUILTIN_SKILL}"), + DIRECT_TAONIER_ART_WORKFLOW.to_string(), + ]; + sections.push("工程摘要:这是一个 Tauri + React AGC 客户端,项目游戏入口位于 `game/index.html`,样式和脚本放在 `game/`,项目状态位于 `.agent/`。普通聊天由当前 Codex 直接完成文件修改和验证;不要创建 Supervisor、专业 Agent、harness 或平行项目。修改后优先检查实际文件和运行结果。".to_string()); + for relative in ["game/index.html", "game/style.css", "game/game.js"] { + if let Some(text) = + read_context_file_with_limit(root, relative, MAX_DIRECT_PROJECT_FILE_BYTES) + { + sections.push(format!( + "\n--- current project file: {relative} ---\n{text}" + )); + } + } + for relative in [ + "AGENTS.md", + "README.md", + "CONTEXT.md", + "docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md", + "docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md", + ] { + if let Some(text) = read_context_file(root, relative) { + sections.push(format!("\n--- {relative} ---\n{text}")); + } + } + for (label, text) in [ + ("repository/AGENTS.md", BUNDLED_REPOSITORY_AGENTS), + ( + "repository/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md", + BUNDLED_AGC_IMPLEMENTATION_PLAN, + ), + ] { + let text = bundled_context_text(text); + if !text.trim().is_empty() { + sections.push(format!("\n--- bundled context: {label} ---\n{text}")); + } + } + for (label, text) in read_markdown_files(root, ".codex/prompts") + .into_iter() + .chain(read_markdown_files(root, "prompts")) + { + sections.push(format!("\n--- prompt: {label} ---\n{text}")); + } + for (label, text) in read_skill_files(root) { + sections.push(format!("\n--- skill: {label} ---\n{text}")); + } + sections.push("\n工程执行要求:优先复用现有结构;修改后运行与改动相关的测试、typecheck、编码检查和 git diff --check。需要生成游戏时,直接在当前工作区完成代码、资源、运行和验证闭环;不要创建新的 Supervisor/harness。".to_string()); + sections + .join("\n") + .chars() + .take(MAX_DIRECT_SYSTEM_PROMPT_CHARS) + .collect() +} + +pub(crate) async fn run_direct_game_creator_turn_at( + root: &Path, + prompt: &str, +) -> Result { + if !root.is_absolute() || !root.is_dir() { + return Err("当前项目目录不存在或不是绝对路径".to_string()); + } + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + let prompt = prompt.trim(); + if prompt.is_empty() { + return Err("聊天内容不能为空".to_string()); + } + ensure_direct_taonier_art_package_at(root, prompt).await?; + let previous_output_fingerprint = direct_codex_output_fingerprint(root); + let mut system_prompt = build_direct_codex_system_prompt(root); + system_prompt.push_str(&format!( + "\n本回合已由陶泥儿平台按标准合同生成并登记美术包:规范图 `/{}`、背景图 `/{}`、核心主图集 `/{}`,以及玩家 `/{}`、目标/道具 `/{}`、障碍/场景 `/{}`、反馈 `/{}` 四类切片。规范图只用于统一风格;游戏必须实际使用背景图和四个切片:`game/game.js` 要加载每个切片,并通过 Canvas `drawImage` 绘制对应核心类别。不得只在回复中提及路径。客户端将在回合结束时复核全部平台合同、文件存在、背景图被引用且四类切片进入实际 Canvas 渲染路径;任一条件不满足就不会把项目标记为完成。", + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + DIRECT_CODEX_SPRITESHEET_SLICE_PATHS[0].1, + DIRECT_CODEX_SPRITESHEET_SLICE_PATHS[1].1, + DIRECT_CODEX_SPRITESHEET_SLICE_PATHS[2].1, + DIRECT_CODEX_SPRITESHEET_SLICE_PATHS[3].1, + )); + let reply = direct_game_creator_codex_chat_at(root, system_prompt, prompt.to_string()) + .await + .map_err(|error| format!("direct-codex-error:{error}"))?; + sync_direct_codex_project_outputs_at(root, Some(&previous_output_fingerprint)) + .map_err(|error| format!("Codex 已返回,但客户端登记生成产物失败:{error}"))?; + Ok(reply) +} + +#[tauri::command] +pub(crate) async fn chat_with_game_creator_direct_codex( + project_path: String, + prompt: String, +) -> Result { + run_direct_game_creator_turn_at(Path::new(project_path.trim()), &prompt).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn system_prompt_is_bounded_and_declares_direct_runtime() { + let prompt = build_direct_codex_system_prompt(Path::new(".")); + assert!(prompt.contains("唯一执行智能体")); + assert!(prompt.contains("不要等待 Supervisor")); + assert!(prompt.contains("提示词与技能")); + } + + #[test] + fn system_prompt_loads_project_prompts_and_skills_without_secrets() { + let root = tempfile::tempdir().expect("temp dir"); + let prompt_dir = root.path().join(".codex/prompts"); + let skill_dir = root.path().join(".codex/skills/demo"); + std::fs::create_dir_all(&prompt_dir).expect("prompt dir"); + std::fs::create_dir_all(&skill_dir).expect("skill dir"); + std::fs::write( + prompt_dir.join("game.md"), + "做一个 canvas 游戏\napi_key=secret", + ) + .expect("prompt"); + std::fs::write( + skill_dir.join("SKILL.md"), + "技能要求:先验证入口\nBearer secret", + ) + .expect("skill"); + let prompt = build_direct_codex_system_prompt(root.path()); + assert!(prompt.contains("prompt: .codex/prompts/game.md")); + assert!(prompt.contains("skill: .codex/skills/demo/SKILL.md")); + assert!(prompt.contains("做一个 canvas 游戏")); + assert!(prompt.contains("bundled context: repository/AGENTS.md")); + assert!(!prompt.contains("secret")); + } + + #[test] + fn system_prompt_includes_current_game_files_for_safe_existing_project_edits() { + let root = tempfile::tempdir().expect("temp dir"); + std::fs::create_dir_all(root.path().join("game")).expect("game dir"); + std::fs::write( + root.path().join("game/game.js"), + "const directLayoutMarker = 'hud-board-gap';\napi_key=secret", + ) + .expect("game script"); + + let prompt = build_direct_codex_system_prompt(root.path()); + + assert!(prompt.contains("current project file: game/game.js")); + assert!(prompt.contains("directLayoutMarker")); + assert!(!prompt.contains("api_key=secret")); + } + + #[test] + fn system_prompt_has_a_total_size_bound() { + let root = tempfile::tempdir().expect("temp dir"); + let prompt_dir = root.path().join("prompts"); + std::fs::create_dir_all(&prompt_dir).expect("prompt dir"); + std::fs::write(prompt_dir.join("large.md"), "x".repeat(128 * 1024)).expect("large prompt"); + let prompt = build_direct_codex_system_prompt(root.path()); + assert!(prompt.chars().count() <= MAX_DIRECT_SYSTEM_PROMPT_CHARS); + } + + #[test] + fn direct_prompt_declares_taonier_art_workflow_and_project_structure() { + let prompt = build_direct_codex_system_prompt(Path::new(".")); + assert!(prompt.contains("陶泥儿游戏创作工作流")); + assert!(prompt.contains("`assets/`")); + assert!(prompt.contains(DIRECT_CODEX_ART_SPEC_ASSET_PATH)); + assert!(prompt.contains(DIRECT_CODEX_BACKGROUND_ASSET_PATH)); + assert!(prompt.contains(DIRECT_CODEX_SPRITESHEET_ASSET_PATH)); + assert!(prompt.contains(DIRECT_CODEX_SPRITESHEET_SLICE_PATHS[0].1)); + assert!(prompt.contains(DIRECT_CODEX_SPRITESHEET_SLICE_PATHS[3].1)); + assert!(prompt.contains("不得手写或修改 `.agent/manifest.json`")); + } + + fn register_direct_taonier_art_asset_fixture(root: &Path, local_path: &str, kind: &str) { + let (generation_route, generation_kind, reference_resource_ids) = match kind { + "art-spritesheet" => ( + "/api/external/v1/editor/icon-spritesheets/generations", + "icon-spritesheet", + vec!["taonier-resource-icon-spec".to_string()], + ), + "game-background" => ( + "/api/external/v1/editor/images/generations", + "spec", + vec!["taonier-resource-icon-spec".to_string()], + ), + _ => ( + "/api/external/v1/editor/images/generations", + "spec", + Vec::new(), + ), + }; + std::fs::write(root.join(local_path), b"png").expect("visual asset"); + register_local_asset_at( + root, + local_path, + kind, + "image/png", + "platform-art", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("taonier-project".to_string()), + resource_id: Some(format!("taonier-resource-{kind}")), + asset_object_id: Some(format!("taonier-object-{kind}")), + task_id: Some(format!("taonier-task-{kind}")), + prompt: None, + model: Some("gpt-image-2".to_string()), + generation_route: Some(generation_route.to_string()), + generation_kind: Some(generation_kind.to_string()), + reference_resource_ids, + }, + ) + .expect("register TaoNier art asset"); + } + + fn register_direct_taonier_art_package_fixture(root: &Path) { + std::fs::create_dir_all(root.join("assets")).expect("assets dir"); + register_direct_taonier_art_asset_fixture( + root, + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + "icon-spec", + ); + register_direct_taonier_art_asset_fixture( + root, + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + "game-background", + ); + register_direct_taonier_art_asset_fixture( + root, + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + "art-spritesheet", + ); + let slices = DIRECT_CODEX_SPRITESHEET_SLICE_PATHS + .iter() + .map(|(usage, path)| { + let absolute = root.join(path); + std::fs::create_dir_all(absolute.parent().expect("slice parent")) + .expect("slice directory"); + std::fs::write(&absolute, b"png").expect("slice png"); + serde_json::json!({ + "name": usage, + "usage": usage, + "path": path, + }) + }) + .collect::>(); + let slice_manifest = serde_json::json!({ + "schemaVersion": "game-art-slices.v1", + "source": DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + "sourceResourceId": "taonier-resource-art-spritesheet", + "sourceAssetObjectId": "taonier-object-art-spritesheet", + "sourceCanvasProjectId": "taonier-project", + "sourceReferenceResourceIds": ["taonier-resource-icon-spec"], + "slices": slices, + }); + std::fs::write( + root.join("assets/art-spritesheet-slices/manifest.json"), + serde_json::to_vec_pretty(&slice_manifest).expect("slice manifest JSON"), + ) + .expect("slice manifest"); + std::fs::create_dir_all(root.join(".agent/runtime")).expect("runtime dir"); + let contract_slices = DIRECT_CODEX_SPRITESHEET_SLICE_PATHS + .iter() + .map(|(usage, path)| serde_json::json!({ "usage": usage, "path": path })) + .collect::>(); + let receipt = serde_json::json!({ + "schemaVersion": "game-art-spritesheet-contract.v1", + "source": DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + "sourceResourceId": "taonier-resource-art-spritesheet", + "sourceAssetObjectId": "taonier-object-art-spritesheet", + "sourceCanvasProjectId": "taonier-project", + "sourceReferenceResourceIds": ["taonier-resource-icon-spec"], + "slices": contract_slices, + }); + std::fs::write( + root.join(".agent/runtime/art-spritesheet-contract.json"), + serde_json::to_vec_pretty(&receipt).expect("receipt JSON"), + ) + .expect("receipt"); + } + + #[test] + fn direct_output_sync_registers_game_files_and_marks_prototype_completed() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-sync-project", "直连产物同步") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + std::fs::write( + root.path().join("game/index.html"), + "", + ) + .expect("index"); + std::fs::write(root.path().join("game/style.css"), "body {}").expect("style"); + std::fs::write( + root.path().join("game/game.js"), + "const player = new Image(); player.src = '/assets/art-spritesheet-slices/player.png'; const target = new Image(); target.src = '/assets/art-spritesheet-slices/blocks-and-targets.png'; const obstacle = new Image(); obstacle.src = '/assets/art-spritesheet-slices/obstacles-and-scene.png'; const feedback = new Image(); feedback.src = '/assets/art-spritesheet-slices/feedback-effects.png'; const ctx = document.querySelector('#game').getContext('2d'); ctx.drawImage(player, 0, 0); ctx.drawImage(target, 1, 1); ctx.drawImage(obstacle, 2, 2); ctx.drawImage(feedback, 3, 3); requestAnimationFrame(()=>{});", + ) + .expect("script"); + + let initial_fingerprint = direct_codex_output_fingerprint(root.path()); + sync_direct_codex_project_outputs_at(root.path(), Some(&initial_fingerprint)) + .expect("sync outputs"); + + let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest"); + assert!(manifest.assets.iter().any(|asset| { + asset.local_path == "game/index.html" && asset.media_type == "text/html" + })); + assert!(manifest.assets.iter().any(|asset| { + asset.local_path == "game/style.css" && asset.media_type == "text/css" + })); + assert!(manifest.assets.iter().any(|asset| { + asset.local_path == "game/game.js" && asset.media_type == "text/javascript" + })); + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == "code-prototype") + .map(|task| task.status.clone()), + Some(GameCreationAppTaskStatus::Completed) + ); + assert_eq!(manifest.versions.len(), 1); + assert_eq!(manifest.versions[0].version_id, "initial-1"); + assert_eq!(manifest.versions[0].project_revision, 1); + assert_eq!(manifest.versions[0].resource_bindings.len(), 6); + for expected_path in DIRECT_CODEX_ART_ASSET_PATHS { + assert!(manifest.versions[0] + .resource_bindings + .iter() + .any(|binding| { + manifest + .assets + .iter() + .find(|asset| asset.id == binding.resource_id) + .is_some_and(|asset| asset.local_path == expected_path) + })); + } + + let first_asset_ids = manifest + .assets + .iter() + .map(|asset| asset.id.clone()) + .collect::>(); + let first_output_fingerprint = direct_codex_output_fingerprint(root.path()); + std::fs::write( + root.path().join("game/game.js"), + "const player = new Image(); player.src = '/assets/art-spritesheet-slices/player.png'; const target = new Image(); target.src = '/assets/art-spritesheet-slices/blocks-and-targets.png'; const obstacle = new Image(); obstacle.src = '/assets/art-spritesheet-slices/obstacles-and-scene.png'; const feedback = new Image(); feedback.src = '/assets/art-spritesheet-slices/feedback-effects.png'; const ctx = document.querySelector('#game').getContext('2d'); ctx.drawImage(player, 0, 0); ctx.drawImage(target, 1, 1); ctx.drawImage(obstacle, 2, 2); ctx.drawImage(feedback, 3, 3); requestAnimationFrame(()=>{}); // follow-up", + ) + .expect("follow-up script"); + + sync_direct_codex_project_outputs_at(root.path(), Some(&first_output_fingerprint)) + .expect("sync follow-up outputs"); + + let revision = read_game_creator_agent_runtime_project_revision(root.path()) + .expect("project revision"); + assert_eq!(revision.revision, 2); + let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest"); + assert_eq!(manifest.assets.len(), 6); + assert_eq!( + manifest + .assets + .iter() + .map(|asset| asset.id.clone()) + .collect::>(), + first_asset_ids + ); + assert_eq!(manifest.versions.len(), 2); + assert_eq!(manifest.versions[1].version_id, "agent-2"); + assert_eq!( + manifest.versions[1].parent_version_id.as_deref(), + Some("initial-1") + ); + assert_eq!(manifest.versions[1].project_revision, 2); + assert_eq!( + manifest.versions[1].created_reason, + GameIterationVersionCreatedReason::AgentRevision + ); + assert_eq!(manifest.versions[1].resource_bindings.len(), 6); + + let unchanged_fingerprint = direct_codex_output_fingerprint(root.path()); + sync_direct_codex_project_outputs_at(root.path(), Some(&unchanged_fingerprint)) + .expect("sync unchanged outputs"); + let revision = read_game_creator_agent_runtime_project_revision(root.path()) + .expect("unchanged project revision"); + assert_eq!(revision.revision, 2); + let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest"); + assert_eq!(manifest.versions.len(), 2); + } + + #[test] + fn direct_output_sync_refuses_code_without_a_complete_taonier_art_package() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-sync-project", "直连产物同步") + .expect("init project"); + std::fs::write(root.path().join("game/index.html"), "").expect("index"); + std::fs::write(root.path().join("game/style.css"), "body {}").expect("style"); + std::fs::write( + root.path().join("game/game.js"), + "requestAnimationFrame(()=>{});", + ) + .expect("script"); + + let error = sync_direct_codex_project_outputs_at(root.path(), None) + .expect_err("missing TaoNier art package must block completion"); + assert!(error.contains("缺少完整已登记的陶泥儿平台美术包")); + let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest"); + assert!(manifest + .assets + .iter() + .all(|asset| !asset.local_path.starts_with("game/"))); + assert_ne!( + manifest + .tasks + .iter() + .find(|task| task.id == "code-prototype") + .map(|task| task.status.clone()), + Some(GameCreationAppTaskStatus::Completed) + ); + } + + #[test] + fn direct_output_sync_refuses_a_spritesheet_that_is_not_rendered() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-sync-project", "直连产物同步") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + std::fs::write( + root.path().join("game/index.html"), + "", + ) + .expect("index"); + std::fs::write(root.path().join("game/style.css"), "body {}").expect("style"); + std::fs::write( + root.path().join("game/game.js"), + "const sprite = new Image(); sprite.src = '/assets/art-spritesheet-slices/player.png';", + ) + .expect("script"); + + let error = sync_direct_codex_project_outputs_at(root.path(), None) + .expect_err("non-rendered spritesheet must block completion"); + assert!(error.contains("未在实际渲染中同时使用陶泥儿背景图")); + let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest"); + assert!(manifest + .assets + .iter() + .all(|asset| !asset.local_path.starts_with("game/"))); + } + + #[test] + fn direct_taonier_art_package_rejects_missing_required_slice() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-sync-project", "直连产物同步") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + std::fs::remove_file(root.path().join(DIRECT_CODEX_SPRITESHEET_SLICE_PATHS[2].1)) + .expect("remove required slice"); + + assert!(!direct_taonier_art_package_is_valid(root.path())); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index 7d8bb5289..a14024431 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -58,10 +58,12 @@ pub(crate) use canvas_generation::request_platform_art_asset_with_options_for_te #[allow(unused_imports)] pub(crate) use canvas_generation::{ build_platform_art_asset_prompt, editor_api_key_is_configured, generate_platform_art_asset_at, - maybe_generate_platform_art_asset_step, needs_platform_art_asset_generation, - platform_art_asset_art_spec, platform_art_asset_output_extension_matches, - prepare_platform_art_asset_output_path, project_canvas_asset_media_types, - role_has_canvas_assets, suggested_canvas_tool_call, PlatformArtAssetGenerationOptions, + generate_platform_art_asset_with_options_at, + generate_platform_art_asset_with_required_slices_at, maybe_generate_platform_art_asset_step, + needs_platform_art_asset_generation, platform_art_asset_art_spec, + platform_art_asset_output_extension_matches, prepare_platform_art_asset_output_path, + project_canvas_asset_media_types, role_has_canvas_assets, suggested_canvas_tool_call, + PlatformArtAssetGenerationOptions, }; #[allow(unused_imports)] pub(crate) use draft_validation::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 0fe8bab76..24069166f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -430,7 +430,8 @@ pub(crate) async fn external_editor_json_request( .map_err(|error| format!("{action}失败:{error}"))?; let status = response.status(); if !status.is_success() { - return Err(format!("{action}失败:HTTP {}", status.as_u16())); + let body = response.text().await.unwrap_or_default(); + return Err(format_external_http_error(action, status, &body)); } response .json::() @@ -438,6 +439,92 @@ pub(crate) async fn external_editor_json_request( .map_err(|error| format!("解析{action}响应失败:{error}")) } +/// Keep provider validation details useful to the operator without copying an +/// entire response body (which may contain URLs, ids, paths or credentials). +fn format_external_http_error(action: &str, status: reqwest::StatusCode, body: &str) -> String { + let detail = summarize_external_http_error_body(body); + match detail { + Some(detail) => format!("{action}失败:HTTP {}:{detail}", status.as_u16()), + None => format!("{action}失败:HTTP {}", status.as_u16()), + } +} + +fn summarize_external_http_error_body(body: &str) -> Option { + let payload = serde_json::from_str::(body).ok()?; + let mut fields = Vec::new(); + collect_external_http_error_fields(&payload, &mut fields); + if fields.is_empty() { + return None; + } + let mut detail = fields.join(";"); + detail = redact_secret_tokens(&detail); + detail = redact_absolute_path_tokens(&detail); + for field in ["operationId", "operation_id", "api_key", "access_token"] { + detail = redact_external_http_error_assignment(&detail, field); + } + detail.chars().take(600).collect::().into() +} + +fn redact_external_http_error_assignment(value: &str, field: &str) -> String { + let marker = format!("{field}="); + let mut output = String::with_capacity(value.len()); + let mut cursor = 0usize; + while let Some(offset) = value[cursor..].find(&marker) { + let start = cursor + offset; + output.push_str(&value[cursor..start]); + output.push_str(&marker); + output.push_str("[redacted]"); + let value_start = start + marker.len(); + let value_end = value[value_start..] + .char_indices() + .find_map(|(index, character)| { + matches!( + character, + ';' | ';' | ',' | ',' | '&' | '\n' | '\r' | '\t' | ' ' + ) + .then_some(value_start + index) + }) + .unwrap_or(value.len()); + cursor = value_end; + } + output.push_str(&value[cursor..]); + output +} + +fn collect_external_http_error_fields(value: &serde_json::Value, output: &mut Vec) { + const ALLOWED_KEYS: [&str; 7] = [ + "code", "field", "message", "reason", "detail", "error", "status", + ]; + match value { + serde_json::Value::Object(map) => { + for key in ALLOWED_KEYS { + if let Some(value) = map.get(key) { + match value { + serde_json::Value::String(text) if !text.trim().is_empty() => { + output.push(format!("{key}={}", text.trim())); + } + serde_json::Value::Number(number) => { + output.push(format!("{key}={number}")); + } + _ => collect_external_http_error_fields(value, output), + } + } + } + for (key, child) in map { + if !ALLOWED_KEYS.contains(&key.as_str()) { + collect_external_http_error_fields(child, output); + } + } + } + serde_json::Value::Array(items) => { + for item in items { + collect_external_http_error_fields(item, output); + } + } + _ => {} + } +} + pub(crate) fn external_generation_poll_after_ms(payload: &serde_json::Value) -> u64 { external_editor_response_data(payload) .get("pollAfterMs") @@ -791,6 +878,7 @@ pub(in crate::agent) struct PreparedPlatformArtAssetGeneration { warning: Option, slice_warning: Option, slices: Vec, + spritesheet_slice_layout: Option, generation_route: String, generation_kind: String, reference_resource_ids: Vec, @@ -850,7 +938,11 @@ fn canonical_art_spec_reference_at( } fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec { - let project_context = truncate_inline(prompt.trim(), 320); + // External Editor validates each description independently (currently at + // 200 Unicode characters). Keep the gameplay context short enough that a + // long creation request cannot reject the atlas before it is queued. + const MAX_DESCRIPTION_CHARS: usize = 200; + const CONTEXT_PREFIX: &str = ";遵循同一项目视觉规范:"; [ "第 1 类(左上):当前玩法的玩家主体或主要操作对象;只生成一个轮廓连贯、可独立使用的完整素材", "第 2 类(右上):当前玩法的方块、目标物、收集物、敌对实体或危险物;只生成一个完整素材", @@ -858,10 +950,36 @@ fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec { "第 4 类(右下):得分、受击、成长、失败、胜利或操作反馈特效;只生成一个完整素材", ] .into_iter() - .map(|category| format!("{category};遵循同一项目视觉规范:{project_context}")) + .map(|category| { + let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub( + category + .chars() + .count() + .saturating_add(CONTEXT_PREFIX.chars().count()), + ); + let project_context = truncate_inline_bounded(prompt.trim(), context_budget); + format!("{category}{CONTEXT_PREFIX}{project_context}") + }) .collect() } +fn truncate_inline_bounded(value: &str, max_chars: usize) -> String { + let normalized = value.split_whitespace().collect::>().join(" "); + let actual_chars = normalized.chars().count(); + if actual_chars <= max_chars { + return normalized; + } + if max_chars <= 3 { + return ".".repeat(max_chars); + } + let mut output = normalized + .chars() + .take(max_chars.saturating_sub(3)) + .collect::(); + output.push_str("..."); + output +} + fn decode_platform_art_image_with_limits( download: &CanvasResourceDownload, label: &str, @@ -1099,7 +1217,7 @@ async fn prepare_platform_art_spritesheet_slices( Ok(prepared) } -pub(in crate::agent) async fn generate_platform_art_asset_with_options_at( +pub(crate) async fn generate_platform_art_asset_with_options_at( root: &Path, prompt: &str, briefs: &[AgentGroupBrief], @@ -1117,6 +1235,32 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_options_at( commit_prepared_platform_art_asset_at(root, prepared, options, |_| Ok(())) } +/// Generates the canonical game spritesheet together with the four durable +/// core slices. Callers that promise a playable game must use this instead +/// of the permissive asset path: a bare spritesheet is not enough evidence +/// that player, target, obstacle, and feedback visuals are available. +pub(crate) async fn generate_platform_art_asset_with_required_slices_at( + root: &Path, + prompt: &str, + briefs: &[AgentGroupBrief], + options: &PlatformArtAssetGenerationOptions, +) -> Result { + if options.asset_kind != "art-spritesheet" { + return Err("严格游戏切片生成只允许 art-spritesheet 资产类型".to_string()); + } + { + let recovery_lock = + acquire_project_write_lock(root, "canvas.asset_generate.strict.recover")?; + recover_interrupted_strict_platform_art_transaction_locked_at(root, &recovery_lock)?; + } + let prepared = + request_platform_art_asset_with_options_at(root, prompt, briefs, options).await?; + let lock = acquire_project_write_lock(root, "canvas.asset_generate.strict")?; + recover_interrupted_strict_platform_art_transaction_locked_at(root, &lock)?; + advance_agent_runtime_project_revision_locked(root)?; + commit_prepared_platform_art_asset_strict_slices_at(root, prepared, options, |_| Ok(())) +} + pub(in crate::agent) async fn request_platform_art_asset_with_options_at( root: &Path, prompt: &str, @@ -1249,7 +1393,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at let is_canonical_art_spritesheet = options.asset_kind == "art-spritesheet"; let canonical_reference = matches!( options.asset_kind.as_str(), - "ui-prototype" | "art-spritesheet" + "ui-prototype" | "game-background" | "art-spritesheet" ) .then(|| canonical_art_spec_reference_at(root, &canvas_context.project_id)) .transpose()?; @@ -1262,6 +1406,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at serde_json::json!({ "referenceId": reference_id, "iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt), + "sliceLayout": "grid-2x2", "screenColor": "auto", "aspectRatio": options.aspect_ratio, "imageSize": options.image_size, @@ -1346,6 +1491,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at .await?; let status = response.status(); if !status.is_success() { + let body = response.text().await.unwrap_or_default(); if external_generation_submit_rejection_is_definitive(status) { if let Some(context) = runtime_context { remove_platform_art_generation_runtime_state_at( @@ -1354,15 +1500,23 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at &context.run_id, )?; } - return Err(format!("请求平台图片生成失败:HTTP {}", status.as_u16())); + return Err(format_external_http_error( + "请求平台图片生成", + status, + &body, + )); } if runtime_context.is_some() { return Err(format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成提交返回 HTTP {},服务端是否已产生副作用未知", - status.as_u16() + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {},服务端是否已产生副作用未知", + format_external_http_error("平台图片生成提交", status, &body) )); } - return Err(format!("请求平台图片生成失败:HTTP {}", status.as_u16())); + return Err(format_external_http_error( + "请求平台图片生成", + status, + &body, + )); } let submission_payload = response .json::() @@ -1498,6 +1652,11 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at Vec::new() }; let warning = platform_art_generation_warning(generated); + let spritesheet_slice_layout = if is_canonical_art_spritesheet { + json_string_field(generated, "sliceLayout") + } else { + None + }; let resource_id = json_string_field(resource, "resourceId"); let task_id = if is_canonical_art_spritesheet { consistent_canvas_task_id("External Editor 图集主图", &[generated, resource, asset])? @@ -1553,6 +1712,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at warning, slice_warning, slices, + spritesheet_slice_layout, generation_route, generation_kind, reference_resource_ids, @@ -4869,6 +5029,7 @@ fn validate_strict_platform_art_spritesheet_contract( task_id: Option<&str>, generation_route: &str, generation_kind: &str, + spritesheet_slice_layout: Option<&str>, reference_resource_ids: &[String], has_transparent_pixels: bool, has_visible_pixels: bool, @@ -4905,6 +5066,11 @@ fn validate_strict_platform_art_spritesheet_contract( { return Err("game-chat 图集生成 route/kind 与严格图集合同不一致".to_string()); } + if spritesheet_slice_layout.map(str::trim) != Some("grid-2x2") { + return Err( + "game-chat 图集必须由 External Editor 以 grid-2x2 固定切片合同生成".to_string(), + ); + } if reference_resource_ids.len() != 1 || reference_resource_ids[0].trim().is_empty() || reference_resource_ids[0].trim() == resource_id @@ -5248,6 +5414,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( warning, mut slice_warning, slices, + spritesheet_slice_layout, generation_route, generation_kind, reference_resource_ids, @@ -5266,6 +5433,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( task_id.as_deref(), &generation_route, &generation_kind, + spritesheet_slice_layout.as_deref(), &reference_resource_ids, spritesheet_has_transparent_pixels, spritesheet_has_visible_pixels, @@ -5685,6 +5853,17 @@ pub(crate) fn platform_art_asset_art_spec( "references": [], }); } + if options.asset_kind == "game-background" { + return serde_json::json!({ + "assetType": "background", + "subject": format!("{};使用项目原创命名和原创场景设计", options.asset_label), + "style": "原创横屏 Web 游戏场景背景;层次清晰,保留可玩区域的视觉留白,不包含 UI 或文字", + "composition": "严格 16:9 单场景背景,前景、中景、远景有明确层次;不出现角色、可收集物、操作按钮、HUD、Logo 或文字", + "format": format!("{} {}", options.aspect_ratio, options.image_size), + "constraints": "必须是可见的真实图片产物,适合作为 Canvas 或 HTML 游戏场景底图;不得做成素材图集、完整游戏截图、海报或概念板;必须原创,不得复刻现有游戏场景、Logo、贴图、标志性布局或受保护视觉语言", + "references": [], + }); + } serde_json::json!({ "assetType": "art", "subject": format!("{};使用项目原创命名和原创阵营设计", options.asset_label), @@ -5713,6 +5892,12 @@ pub(crate) fn build_platform_art_asset_prompt( truncate_prompt_context(prompt.trim()) ); } + if options.asset_kind == "game-background" { + return format!( + "为 Web 小游戏生成一张可直接作为运行画面底图的原创 16:9 场景背景。严格从下方用户需求提炼自己的游戏主题、地点、季节、材质和氛围;画面要为真实可玩区域留出足够清楚的中部空间,并有前景、中景、远景层次。不得画玩家角色、道具、棋子、障碍、HUD、操作按钮、文字、Logo、完整游戏截图、海报或素材图集;这些元素会从独立透明核心图集中绘制。不得自行假设为塔防或加入玩法合同中不存在的实体;必须原创,不得复刻现有游戏场景、贴图、标志性布局或受保护视觉语言。\n\n用户需求:{}", + truncate_prompt_context(prompt.trim()) + ); + } let art_asset_brief = briefs .iter() .flat_map(|brief| brief.role_briefs.iter()) @@ -5737,6 +5922,28 @@ mod canvas_generation_tests { ColorType, ImageEncoder, }; + #[test] + fn external_http_error_summary_keeps_validation_details_without_sensitive_context() { + let body = serde_json::json!({ + "error": { + "code": "invalid-request", + "field": "sliceLayout", + "message": "只支持 grid-2x2;operationId=private-operation-id;api_key=private-key", + }, + "details": { + "path": "C:\\Users\\private\\secret.json", + } + }) + .to_string(); + let summary = summarize_external_http_error_body(&body).expect("summary"); + assert!(summary.contains("code=invalid-request"), "{summary}"); + assert!(summary.contains("field=sliceLayout"), "{summary}"); + assert!(summary.contains("只支持 grid-2x2"), "{summary}"); + assert!(!summary.contains("private-operation-id"), "{summary}"); + assert!(!summary.contains("private-key"), "{summary}"); + assert!(!summary.contains("C:\\Users\\private"), "{summary}"); + } + fn read_test_http_request(stream: &mut std::net::TcpStream) -> String { stream .set_nonblocking(false) @@ -5876,6 +6083,57 @@ mod canvas_generation_tests { assert!(slices.iter().all(|slice| slice.extension == "png")); } + #[test] + fn strict_spritesheet_contract_requires_the_grid_2x2_provider_receipt() { + let canvas_context = ExternalCanvasGenerationContext { + project_id: "canvas-project".to_string(), + asset_folder_id: "asset-folder".to_string(), + canvas_name: "contract-test".to_string(), + }; + let slices = (0..4) + .map(|index| { + let download = rgba_test_png(100 + index); + let validated = validate_platform_art_png_bytes_with_limits( + &download.bytes, + "strict layout receipt test slice", + ) + .expect("fixture slice should validate"); + PreparedPlatformArtAssetSlice { + name: format!("素材 {}", index + 1), + width: validated.width, + height: validated.height, + download, + resource_id: Some(format!("slice-resource-{index}")), + asset_object_id: Some(format!("slice-object-{index}")), + canvas_project_id: Some("canvas-project".to_string()), + task_id: Some("spritesheet-task".to_string()), + source_resource_id: Some("spritesheet-resource".to_string()), + content_sha256: validated.content_sha256, + pixel_sha256: validated.pixel_sha256, + has_visible_pixels: true, + extension: "png".to_string(), + } + }) + .collect::>(); + let error = validate_strict_platform_art_spritesheet_contract( + &slices, + &canvas_context, + Some("canvas-project"), + Some("spritesheet-resource"), + Some("spritesheet-object"), + Some("spritesheet-task"), + "/api/external/v1/editor/icon-spritesheets/generations", + "icon-spritesheet", + None, + &["art-spec-resource".to_string()], + true, + true, + ) + .expect_err("missing fixed-layout proof must not be accepted"); + + assert!(error.contains("grid-2x2")); + } + #[test] fn canonical_spritesheet_rejects_task_id_conflicts_across_all_identity_copies() { let generated = serde_json::json!({ @@ -7078,6 +7336,16 @@ mod canvas_generation_tests { } } + #[test] + fn canonical_art_spritesheet_descriptions_obey_external_editor_item_limit() { + let descriptions = canonical_art_spritesheet_icon_descriptions(&"原创玩法需求".repeat(128)); + + assert_eq!(descriptions.len(), 4); + assert!(descriptions + .iter() + .all(|description| description.chars().count() <= 200)); + } + #[test] fn derived_visual_asset_waits_for_registered_art_spec() { let temporary = tempfile::tempdir().expect("create art dependency project"); @@ -7214,6 +7482,7 @@ mod canvas_generation_tests { warning: None, slice_warning: None, slices: Vec::new(), + spritesheet_slice_layout: Some("grid-2x2".to_string()), generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(), generation_kind: "icon-spritesheet".to_string(), reference_resource_ids: vec!["art-spec-resource".to_string()], @@ -7529,6 +7798,7 @@ mod canvas_generation_tests { warning: None, slice_warning: None, slices, + spritesheet_slice_layout: Some("grid-2x2".to_string()), generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(), generation_kind: "icon-spritesheet".to_string(), reference_resource_ids: vec!["art-spec-resource".to_string()], diff --git a/apps/ai-game-creator-shell/src-tauri/src/cli.rs b/apps/ai-game-creator-shell/src-tauri/src/cli.rs index 4cc1439b4..d957fc284 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -13,6 +13,10 @@ pub(crate) enum CliCommand { agent_id: String, prompt: String, }, + DirectCodexChat { + project_path: PathBuf, + prompt: String, + }, AgentTask { project_path: PathBuf, agent_id: String, @@ -186,6 +190,7 @@ impl CliCommand { .. } => Some((project_path, *initialize)), Self::AgentChat { project_path, .. } + | Self::DirectCodexChat { project_path, .. } | Self::AgentRuntimeStatus { project_path, .. } | Self::AgentContextCompact { project_path, .. } | Self::AgentGoalStatus { project_path, .. } @@ -687,6 +692,19 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S prompt: prompt.to_string(), })); } + if args.first().map(String::as_str) == Some("--direct-codex-chat") { + if args.len() < 3 { + return Err("用法:--direct-codex-chat <本地项目绝对路径> <聊天内容>".to_string()); + } + let prompt = args[2..].join(" "); + if prompt.trim().is_empty() { + return Err("聊天内容不能为空".to_string()); + } + return Ok(Some(CliCommand::DirectCodexChat { + project_path: PathBuf::from(&args[1]), + prompt: prompt.trim().to_string(), + })); + } if args.first().map(String::as_str) == Some("--swarm-chat") { const USAGE: &str = "用法:--swarm-chat [--init] [--autonomous-game-build] [--game-chat-smoke] <本地项目绝对路径> [parentAgentId]"; let mut rest = args[1..].to_vec(); @@ -929,6 +947,36 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { println!("replyText={}", reply.reply_text); Ok(()) } + CliCommand::DirectCodexChat { + project_path, + prompt, + } => { + let project_path = canonicalize_cli_path(&project_path, "本地项目路径", true)?; + if !project_path.join(".agent/manifest.json").is_file() { + let project_name = project_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("Codex 直连项目"); + init_local_game_project_at( + &project_path, + &format!("direct-codex-{}", unix_millis()), + project_name, + )?; + } + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| format!("创建 CLI runtime 失败:{error}"))?; + let reply_result = runtime + .block_on(async { run_direct_game_creator_turn_at(&project_path, &prompt).await }); + let shutdown_result = shutdown_game_creator_codex_app_servers(); + let reply = reply_result?; + shutdown_result?; + println!("direct-codex.chat.completed"); + println!("projectPath={}", project_path.display()); + println!("replyText={reply}"); + Ok(()) + } CliCommand::AgentTask { project_path, agent_id, 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 bb23a8e46..fcfd86b3c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1215,10 +1215,10 @@ const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.jso const GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER: &str = "codex_app_server"; const GAME_CREATOR_AGENT_MODE_CODEX_CLI: &str = "codex_cli"; const GAME_CREATOR_AGENT_MODE_PROVIDER: &str = "provider"; -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_BASE_URL: &str = "https://dev.genarrative.world/gpt/v1"; +const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-5.6-sol"; const DEFAULT_GAME_CREATOR_LLM_API_KIND: &str = "openai_responses"; -const DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT: &str = "high"; +const DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT: &str = "max"; const DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS: u64 = 128_000; const DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT: u64 = 64_000; const DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT: u64 = 12_000; @@ -1841,6 +1841,11 @@ where } fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) { + if matches!(event, tauri::RunEvent::Exit) { + if let Err(error) = agent::shutdown_game_creator_codex_app_servers() { + eprintln!("agent.direct_codex.gui_exit.shutdown_failed: {error}"); + } + } match resolve_game_creator_gui_runner_shutdown(event, shutdown_external_agent_runner) { GameCreatorGuiRunnerShutdownOutcome::NotRequested => {} GameCreatorGuiRunnerShutdownOutcome::Requested => { @@ -2192,6 +2197,7 @@ fn main() { chat_with_game_creator_agent, chat_with_game_creator_role_agent, chat_with_game_creator_role_agent_stream, + chat_with_game_creator_direct_codex, start_game_creator_agent_runtime_task, start_game_creator_supervisor_runtime_task, compact_game_creator_agent_runtime_context, diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index 056d25ce3..f8846b851 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -590,10 +590,18 @@ pub(crate) fn resolve_preview_path(root: &Path, url_path: &str) -> Result Result { + if project_revision == 0 { + return Err("Agent 项目版本必须绑定大于 0 的项目 revision".to_string()); + } + mutate_manifest_at(root, |manifest| { + let resource_bindings = manifest + .assets + .iter() + .map(|asset| GameIterationVersionResourceBinding { + slot_id: format!("asset:{}", asset.id), + resource_id: asset.id.clone(), + }) + .collect::>(); + if let Some(existing) = manifest + .versions + .iter() + .find(|version| version.project_revision == project_revision) + { + if existing.resource_bindings == resource_bindings { + return Ok(false); + } + return Err(format!( + "项目 revision {project_revision} 已绑定不同的正式版本" + )); + } + + let (version_id, parent_version_id, created_reason) = + if let Some(parent) = manifest.versions.last() { + if project_revision <= parent.project_revision { + return Err(format!( + "Agent 项目版本 revision {project_revision} 必须大于父版本 revision {}", + parent.project_revision + )); + } + ( + format!("agent-{project_revision}"), + Some(parent.version_id.clone()), + GameIterationVersionCreatedReason::AgentRevision, + ) + } else { + ( + format!("initial-{project_revision}"), + None, + GameIterationVersionCreatedReason::Initial, + ) + }; + manifest.versions.push(GameIterationVersion { + version_id, + parent_version_id, + project_revision, + resource_bindings, + created_reason, + created_at: unix_timestamp(), + edit_prompt: None, + }); + Ok(true) + }) +} + pub(crate) fn ensure_manifest_has_seed_tasks( root: &Path, goal: Option<&str>, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 1fda1fa86..376e3a54c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -2839,6 +2839,7 @@ fn preview_path_rejects_traversal() { fs::create_dir_all(project.join("memory")).expect("memory dir"); fs::create_dir_all(project.join(".agent")).expect("agent dir"); fs::write(project.join("game/index.html"), "").expect("index"); + fs::write(project.join("game/style.css"), "body {}").expect("game style"); fs::write(project.join("assets/player.png"), b"png").expect("asset"); fs::write(project.join("memory/project.md"), "secret memory").expect("memory"); fs::write(project.join(".agent/run.latest.json"), "{}").expect("trace"); @@ -2861,6 +2862,13 @@ fn preview_path_rejects_traversal() { .canonicalize() .expect("canonical asset") ); + assert_eq!( + resolve_preview_path(&project, "/style.css?cache=1").unwrap(), + project + .join("game/style.css") + .canonicalize() + .expect("canonical game style") + ); fs::remove_dir_all(project).ok(); } diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 0c871fa11..84753529a 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -115,6 +115,7 @@ import { normalizeAgentRuntimeState, projectNameFromPath, projectProfessionalAgentLabel, + projectRuntimeVisibleError, projectSupervisorPendingRepairMatchesProfessional, projectSupervisorResponseStreamIdentity, readProjectSupervisorActiveSessionId, @@ -252,6 +253,7 @@ const LEGACY_GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY = 'genarrative.game-chat.auto-preview-authorization.v1'; const GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY = 'genarrative.game-chat.auto-preview-authorization.v2'; +const DIRECT_CODEX_PRODUCT_RUNTIME = true; type GameChatAutoPreviewAuthorization = { afterRevision: number; @@ -599,6 +601,11 @@ export function App({ onAgentRuntimeSummariesChange, onAgentResultsChange, }: AppProps = {}) { + const directCodexProductRuntime = + DIRECT_CODEX_PRODUCT_RUNTIME && + projectSupervisorOnly && + !supervisorChatOnly && + !gameChatOnly; const [devMode] = useState(() => projectSupervisorOnly ? false : isDeveloperMode(), ); @@ -1314,6 +1321,26 @@ export function App({ } initialProjectOpenedRef.current = true; if (projectSupervisorOnly) { + if (directCodexProductRuntime) { + if (!isAbsoluteProjectPath(initialProjectPath)) { + setWorkspaceStatus('请提供工作区绝对路径'); + return; + } + if (projectPathHasControlCharacter(initialProjectPath)) { + setWorkspaceStatus('工作区路径不能包含控制字符'); + return; + } + // This component is mounted inside the already-created project + // workbench. Hydrate localProject before the first direct turn so the + // direct runtime cannot silently create a second workspace. + void openWorkspace( + initialProjectPath, + false, + 'open', + initialProjectKind, + ); + return; + } if (gameChatOnly) { void openGameChatProjectPath(initialProjectPath); return; @@ -2937,14 +2964,46 @@ export function App({ const loadVersion = projectSupervisorHistoryLoadVersionRef.current + 1; projectSupervisorHistoryLoadVersionRef.current = loadVersion; try { - const resumeError = await resumeProjectSupervisorRuntimeTasksIfNeeded( - invoke, - nextProjectPath, - ); - const sessionId = await readProjectSupervisorActiveSession( + const resumeError = directCodexProductRuntime + ? '' + : await resumeProjectSupervisorRuntimeTasksIfNeeded( + invoke, + nextProjectPath, + ); + if (directCodexProductRuntime) { + projectSupervisorRuntimeResumeProjectPathRef.current = nextProjectPath; + agentRuntimeResumeProjectPathRef.current = nextProjectPath; + } + let sessionId = await readProjectSupervisorActiveSession( invoke, nextProjectPath, ); + let persistedSupervisorRuntimeResult: AgentRuntimeResult | null = null; + let runtimeError = ''; + if (!sessionId && directCodexProductRuntime) { + try { + const runtimeResults = await invoke( + 'read_game_creator_agent_runtimes', + { projectPath: nextProjectPath }, + ); + persistedSupervisorRuntimeResult = + runtimeResults + .filter( + (runtimeResult) => + runtimeResult.state.agentId === + PROJECT_SUPERVISOR_AGENT_ID, + ) + .sort( + (left, right) => + right.state.updatedAt - left.state.updatedAt, + )[0] ?? null; + sessionId = + persistedSupervisorRuntimeResult?.state.sessionId || null; + } catch (error) { + runtimeError = + error instanceof Error ? error.message : String(error); + } + } const projectConversation = await invoke( 'read_local_conversation', { @@ -2955,7 +3014,6 @@ export function App({ let supervisorConversation: LocalConversationResult | null = null; let runtime: AgentRuntimeState | null = null; let runtimeResponseStream: AgentRuntimeResponseStream | null = null; - let runtimeError = ''; if (sessionId) { supervisorConversation = await invoke( 'read_local_conversation', @@ -2965,6 +3023,14 @@ export function App({ sessionId, }, ); + } + if (persistedSupervisorRuntimeResult) { + runtime = agentRuntimeStateFromResult( + persistedSupervisorRuntimeResult, + ); + runtimeResponseStream = + persistedSupervisorRuntimeResult.responseStream ?? null; + } else if (sessionId) { try { const runtimeResult = await invoke( 'read_game_creator_agent_runtime', @@ -3209,6 +3275,9 @@ export function App({ }, ]); } + // Direct Codex must not resume legacy Supervisor work, but existing + // projects still need their conversation and run history hydrated as + // read-only evidence. void loadProjectConversation(openedProject.projectPath); void refreshAgentRunTrace(openedProject.projectPath); } catch (error) { @@ -5755,7 +5824,144 @@ export function App({ return sessionId; } + async function refreshDirectProjectSurface( + invoke: TauriInvoke, + nextProjectPath: string, + ) { + try { + await refreshManifest(nextProjectPath); + } catch { + // The direct turn result is still authoritative for the chat. A missing + // or legacy manifest must not hide the Codex reply. + } + try { + const current = await invoke( + 'get_local_game_preview_status', + { projectPath: nextProjectPath }, + ); + if (current.status === 'running' && current.url && current.port) { + updateClientPreview({ + url: current.url, + port: current.port, + root: current.root ?? nextProjectPath, + }); + setPreviewStatus(`运行中:127.0.0.1:${current.port}`); + return; + } + const started = await invoke( + 'start_local_game_preview', + { projectPath: nextProjectPath }, + ); + updateClientPreview(started); + setPreviewStatus(`运行中:127.0.0.1:${started.port}`); + setCommandLog((currentLog) => [...currentLog, 'preview.start']); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setPreviewStatus(`预览未启动:${message}`); + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: `智能创作已返回,但客户端预览启动失败:${message}`, + runtimeOwned: true, + updatedAt: Date.now(), + }, + ]); + } + } + async function executeChatAgentReply(prompt: string) { + // Product default: send the conversation directly to Codex app-server. + // The legacy Supervisor/harness path remains below for rollback and tests. + if (directCodexProductRuntime) { + const directInvoke = resolveTauriInvoke(); + let directProjectPath = resolveChatProjectPath(localProject); + if (!directProjectPath && directInvoke) { + try { + const created = await directInvoke( + 'create_automatic_local_game_project', + ); + await openWorkspace(created.projectPath, false, 'open'); + directProjectPath = created.projectPath; + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: `自动创建 AGC 项目失败:${message}`, + runtimeOwned: true, + updatedAt: Date.now(), + }, + ]); + setProjectSupervisorRuntimeError(`智能创作执行失败:${message}`); + return; + } + } + if (directProjectPath && directInvoke) { + setChatAgentBusy(true); + setProjectSupervisorRuntimeError(''); + try { + const reply = await directInvoke( + 'chat_with_game_creator_direct_codex', + { + projectPath: directProjectPath, + prompt, + }, + ); + if (localProjectPathRef.current === directProjectPath) { + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: reply, + runtimeOwned: true, + updatedAt: Date.now(), + }, + ]); + await refreshDirectProjectSurface(directInvoke, directProjectPath); + } + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + const visibleMessage = projectRuntimeVisibleError( + message, + '陶泥儿智能创作', + true, + ); + if (localProjectPathRef.current === directProjectPath) { + setProjectSupervisorRuntimeError(visibleMessage); + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: visibleMessage, + runtimeOwned: true, + updatedAt: Date.now(), + }, + ]); + } + } finally { + setChatAgentBusy(false); + } + return; + } + const message = directInvoke + ? '当前项目尚未准备好,无法启动智能创作。' + : '需要在 Tauri App 内运行,无法启动智能创作。'; + setProjectSupervisorRuntimeError(message); + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: message, + runtimeOwned: true, + updatedAt: Date.now(), + }, + ]); + return; + } if (agentRuntimeNeedsUserInput(projectSupervisorRuntimeRef.current)) { setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题'); return; @@ -5951,7 +6157,11 @@ export function App({ useEffect(() => { const latch = initialSupervisorMessageLatchRef.current; - if (!gameChatOnly || !latch.prompt || !localProject) { + if ( + (!directCodexProductRuntime && !gameChatOnly) || + !latch.prompt || + !localProject + ) { return; } if (localProject.projectPath !== latch.projectPath) { @@ -10334,6 +10544,9 @@ export function App({ return; } try { + if (directCodexProductRuntime) { + agentRuntimeResumeProjectPathRef.current = nextProjectPath; + } if (agentRuntimeResumeProjectPathRef.current !== nextProjectPath) { try { const resumedRuntimes = await invoke( @@ -11059,15 +11272,20 @@ export function App({ return ( ; controlBusy: boolean; + readOnly?: boolean; professionalResultsByAgentId: Record< string, ProjectAgentResultSummary | undefined @@ -776,6 +778,7 @@ export function ProjectSupervisorRuntimePanel({ ? (runtime.taskQueue?.pending ?? 0) : 0; const canRetrySupervisor = Boolean( + !readOnly && runtime && !pendingToolAction && (runtime.status === 'failed' || @@ -843,7 +846,14 @@ export function ProjectSupervisorRuntimePanel({ {compactProgress ? ( {compactProgress} ) : null} - {(needsSupervisorReconciliation || canRetrySupervisor) && runtime ? ( + {readOnly && runtime ? ( + + 历史运行记录仅供查看;新版智能创作不会恢复、确认或重试旧任务。 + + ) : null} + {!readOnly && + (needsSupervisorReconciliation || canRetrySupervisor) && + runtime ? (
- {professionalPendingAction ? ( + {professionalPendingAction && !readOnly ? (
) : null} - {userInputRequest ? ( + {userInputRequest && !readOnly ? ( - ) : needsUserInput ? ( + ) : needsUserInput && !readOnly ? (

待回答问题未能读取,请稍后重试。

diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index ca97b8d2d..5ba4ce45b 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -62,7 +62,6 @@ export function WorkspaceLauncherShell({ activeProjectAgentResults, setAgentResults: setActiveProjectAgentResults, resetLauncherHomeDraft, - createHomeDraft, createHomeDraftAutomatically, openProject, } = homeProject; @@ -259,7 +258,6 @@ export function WorkspaceLauncherShell({ onStatusChange={setStatus} homeAgentModeItems={homeAgentModeItems} recentProjectRows={recentProjectRows} - onCreateDraft={createHomeDraft} onCreateDraftAutomatically={createHomeDraftAutomatically} onProjectsOpen={() => setLauncherView('projects')} onProjectOpen={(path) => { @@ -300,6 +298,7 @@ export function WorkspaceLauncherShell({ initialProjectPath={currentProjectContext.projectPath} initialProjectManifest={currentProjectContext.manifest} initialProjectKind={currentProjectContext.projectKind} + initialSupervisorMessage={currentProjectContext.initialPrompt} orchestrationMode="single-supervisor" projectSupervisorOnly onManifestChange={syncActiveProjectManifest} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index cd73c7a55..8bf9982b1 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -35,6 +35,7 @@ export type ProjectSupervisorComponentProps = { initialProjectPath?: string; initialProjectManifest?: GameCreationAppManifest; initialProjectKind?: 'web' | 'godot'; + initialSupervisorMessage?: string; orchestrationMode?: 'single-supervisor' | 'professional-dag'; projectSupervisorOnly?: boolean; onManifestChange?: ( diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 27c66e43c..2eaef631f 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -34,11 +34,7 @@ import type { ProjectAgentResultSummary, ProjectAgentRuntimeSummary, } from '../../view/project-development'; -import { - ensureProjectSupervisorActiveSessionId, - projectNameFromPath, - submitProjectSupervisorRuntimeTask, -} from '../agent-runtime'; +import { projectNameFromPath } from '../agent-runtime'; import { isAbsoluteProjectPath, projectPathHasControlCharacter, @@ -174,22 +170,11 @@ export function useHomeProjectCreation({ result.projectPath, attachments, ); - const sessionId = await ensureProjectSupervisorActiveSessionId( - invoke, - result.projectPath, + const conversationContent = buildHomeConversationContent( + mode, + prompt, + attachments, ); - if (!sessionId) { - throw new Error('项目总控 Agent active Session 不可用'); - } - await submitProjectSupervisorRuntimeTask({ - invoke, - projectPath: result.projectPath, - sessionId, - prompt: buildHomeConversationContent(mode, prompt, attachments), - runtime: null, - runProfile: 'autonomous-game-build', - source: 'project-supervisor-game-chat', - }); enterProjectDevelopment({ projectPath: result.projectPath, projectName: @@ -202,7 +187,7 @@ export function useHomeProjectCreation({ ), mode, initialPrompt: - prompt.trim() || + conversationContent.trim() || (attachments.length > 0 ? '用户上传了参考附件,等待后续补充需求。' : ''), @@ -265,7 +250,7 @@ export function useHomeProjectCreation({ prompt, attachments, ); - setStatus('已创建项目并交给项目总控 Agent'); + setStatus('已创建项目,正在开始智能创作'); } catch (error) { throw new Error( `项目已创建;首条需求投递失败:${ @@ -291,11 +276,11 @@ export function useHomeProjectCreation({ if (!invoke) { throw new Error('需要在 Tauri App 内运行'); } - setStatus('正在启动项目总控 Agent'); + setStatus('正在启动智能创作'); try { await enterCreatedHomeProject(invoke, result, mode, prompt, attachments); - setStatus('已自动创建工作区并开始工作'); - return '已自动创建工作区并开始工作'; + setStatus('已自动创建工作区并开始智能创作'); + return '已自动创建工作区并开始智能创作'; } catch (error) { const message = `工作区已创建;首条需求投递失败:${ error instanceof Error ? error.message : String(error) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index 630a38b88..0e3fdcaa8 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -26,6 +26,7 @@ type RuntimePanelProps = ComponentProps; type ProjectSupervisorViewProps = RuntimePanelProps & { chatInput: string; + directCodex?: boolean; hiddenConversationCount: number; needsUserInput: boolean; onCancelConfirmation: () => void; @@ -44,6 +45,7 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { export function ProjectSupervisorView({ chatInput, + directCodex = false, hiddenConversationCount, needsUserInput, onCancelConfirmation, @@ -99,7 +101,9 @@ export function ProjectSupervisorView({

) : null}
- + {directCodex ? null : ( + + )} {pendingConfirmation ? (
@@ -120,7 +124,11 @@ export function ProjectSupervisorView({ disabled={runtimePanelProps.controlBusy || needsUserInput} rows={3} value={chatInput} - placeholder="告诉项目总控接下来要做什么" + placeholder={ + directCodex + ? '告诉陶泥儿接下来要做什么' + : '告诉项目总控接下来要做什么' + } onChange={(event) => onChatInputChange(event.currentTarget.value)} onKeyDown={(event) => { if ( diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index df6e2469e..d8529527a 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -24,7 +24,6 @@ import { import { resolveTauriInvoke } from '../../app/tauri'; import { type GameCreatorAgentLlmConfig, - type GameCreatorAgentMode, type GameCreatorAppConfig, type GameCreatorAppConfigView, type GameCreatorLlmApiKind, @@ -69,10 +68,10 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = { agentMode: 'codex_app_server', llm: { apiKey: '', - baseUrl: 'https://api.openai.com/v1', - model: 'gpt-4.1', + baseUrl: 'https://dev.genarrative.world/gpt/v1', + model: 'gpt-5.6-sol', apiKind: 'openai_responses', - reasoningEffort: 'high', + reasoningEffort: 'max', stream: false, webSearchEnabled: false, contextWindowTokens: 128000, @@ -90,14 +89,13 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = { mcpServers: {}, }; -const runtimeAgentModes = new Set([ - 'codex_app_server', - 'codex_cli', - 'provider', -]); - type RuntimeSettingsSection = 'general' | 'agents' | 'connections' | 'advanced'; +type RuntimeConfigToast = { + tone: 'success' | 'error'; + message: string; +}; + const runtimeSettingsSections = [ { id: 'general', @@ -579,6 +577,8 @@ export function RuntimeConfigDialog({ }) { const [runtimeConfigPath, setRuntimeConfigPath] = useState(''); const [runtimeConfigStatus, setRuntimeConfigStatus] = useState('未读取'); + const [runtimeConfigToast, setRuntimeConfigToast] = + useState(null); const [runtimeConfigDraft, setRuntimeConfigDraft] = useState(defaultRuntimeConfigDraft); const [runtimeConfigBusy, setRuntimeConfigBusy] = useState(false); @@ -615,10 +615,6 @@ export function RuntimeConfigDialog({ })); } - function updateRuntimeAgentMode(agentMode: GameCreatorAgentMode) { - setRuntimeConfigDraft((current) => ({ ...current, agentMode })); - } - function updateRuntimeLlmProviderPreset( presetId: RuntimeLlmProviderPresetId, ) { @@ -830,10 +826,12 @@ export function RuntimeConfigDialog({ runtimeConfigBusyRef.current = true; setRuntimeConfigBusy(true); + setRuntimeConfigToast(null); setRuntimeConfigStatus('正在保存'); try { const config = normalizeRuntimeConfigDraft({ ...runtimeConfigDraft, + agentMode: 'codex_app_server', mcpServers: materializeRuntimeMcpServers( runtimeConfigDraft.mcpServers, mcpStructuredDrafts, @@ -851,11 +849,20 @@ export function RuntimeConfigDialog({ ); setMcpCatalog(null); setRuntimeConfigStatus(`已保存:${result.path}`); + setRuntimeConfigToast({ + tone: 'success', + message: '保存成功,新的运行时配置已生效', + }); onLog?.('runtime_config.save'); } catch (error) { + const message = error instanceof Error ? error.message : String(error); setRuntimeConfigStatus( - error instanceof Error ? error.message : String(error), + message, ); + setRuntimeConfigToast({ + tone: 'error', + message: `保存失败:${message}`, + }); } finally { runtimeConfigBusyRef.current = false; setRuntimeConfigBusy(false); @@ -933,6 +940,24 @@ export function RuntimeConfigDialog({ role="presentation" onMouseDown={(event) => closeDialogOnBackdropMouseDown(event, onClose)} > + {runtimeConfigToast ? ( +
+ {runtimeConfigToast.tone === 'error' ? ( +
+ ) : null}
{activeSection === 'general' ? ( <> - +
+ Agent 模式 + 陶泥儿智能创作(固定) + 需求将由陶泥儿智能创作服务执行 +
{runtimeConfigDraft.agentMode !== 'codex_cli' ? ( <>
) : null} + {focusedResource.category === 'code' ? ( +
+ {focusedResourceCardPreview.status === 'loaded' && + focusedResourceCardPreview.preview.content !== undefined ? ( +
+                        {focusedResourceCardPreview.preview.content}
+                      
+ ) : focusedResourceCardPreview.status === 'failed' ? ( +

{focusedResourceCardPreview.error}

+ ) : ( +

正在载入游戏代码…

+ )} +
+ ) : null}
) : mode === 'resources' ? ( @@ -3693,41 +3707,6 @@ export default function ProjectDevelopmentView({ )} -
- - - {`测试切片 ${activeSlice + 1}`} - -
@@ -3783,7 +3762,9 @@ export default function ProjectDevelopmentView({
与陶泥儿的对话 - 项目总控 Agent + + {professionalDagVisible ? '项目总控 Agent' : '智能创作'} +
{professionalDagVisible ? (