From bacd7a9da7bfc940bf311f73e3f8e6baa86bbef2 Mon Sep 17 00:00:00 2001 From: Git Hooks Test Date: Thu, 20 Aug 2026 23:55:00 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=20AGC=20=E7=9B=B4=E8=BF=9E?= =?UTF-8?q?=20Codex=20=E4=B8=8E=E5=AE=A1=E6=A0=B8=20Skill=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 收口首页和项目聊天为唯一 Direct Codex thread 内置五项审核 Skill Pack 与受控 MCP、浏览器及美术工具桥 补齐 Codex Windows 侧车组件、Provider 代理和凭据幂等边界 同步客户端资源投影、交互测试及技术文档 --- .gitignore | 6 + apps/ai-game-creator-shell/package-lock.json | 182 +++- apps/ai-game-creator-shell/package.json | 1 + .../scripts/check-config.mjs | 13 + .../src-tauri/Cargo.lock | 79 ++ .../src-tauri/Cargo.toml | 4 +- apps/ai-game-creator-shell/src-tauri/build.rs | 116 +++ .../agc-skills/agc-browser-playtest/SKILL.md | 23 + .../agc-browser-playtest/agents/openai.yaml | 11 + .../references/browser-evidence-contract.md | 7 + .../agc-skills/agc-client-projection/SKILL.md | 25 + .../agc-client-projection/agents/openai.yaml | 4 + .../references/projection-contract.md | 9 + .../agc-skills/agc-project-structure/SKILL.md | 26 + .../agc-project-structure/agents/openai.yaml | 4 + .../references/structure-contract.md | 13 + .../agc-web-game-development/SKILL.md | 24 + .../agents/openai.yaml | 4 + .../references/game-quality-checklist.md | 11 + .../resources/agc-skills/manifest.json | 46 + .../agc-skills/taonier-art-assets/SKILL.md | 26 + .../taonier-art-assets/agents/openai.yaml | 11 + .../references/platform-art-contract.md | 11 + .../resources/codex/win-x64/NOTICE.md | 12 + .../src-tauri/src/agent.rs | 12 +- .../src-tauri/src/agent/codex_app_server.rs | 826 +++++++++++++++--- .../src-tauri/src/agent/codex_cli.rs | 210 ++++- .../src/agent/codex_provider_proxy.rs | 272 ++++++ .../src-tauri/src/agent/direct_runtime.rs | 635 +++++++++++--- .../src-tauri/src/agent/direct_tool_bridge.rs | 237 +++++ .../src-tauri/src/agent/direct_tools_mcp.rs | 450 ++++++++++ .../src-tauri/src/agent/skill_pack.rs | 340 +++++++ .../src-tauri/src/assets.rs | 95 +- .../src-tauri/src/commands.rs | 17 +- .../src-tauri/src/main.rs | 5 +- .../src/project/asset_canvas/generation.rs | 8 +- .../src-tauri/src/tests/runtime_state.rs | 2 +- .../src-tauri/tauri.conf.json | 10 + apps/ai-game-creator-shell/src/App.tsx | 225 ++--- apps/ai-game-creator-shell/src/app/types.ts | 7 +- .../features/app-shell/WorkspaceLauncher.tsx | 41 +- .../src/features/app-shell/model.ts | 54 -- .../app-shell/useDeveloperAgentPanel.ts | 7 +- .../app-shell/useHomeProjectCreation.ts | 79 +- .../ProjectSupervisorView.tsx | 14 +- .../SupervisorChatOnlyView.tsx | 151 ++-- .../pendingCommandPresentation.ts | 5 +- .../RichInputArea/richTextToPrompt.tsx | 24 +- .../src/view/home/index.tsx | 170 ++-- .../src/view/home/useHomeDraftStore.ts | 12 +- .../src/view/project-development/index.tsx | 10 +- .../tests/appSurface/home.suite.ts | 462 +++++----- .../appSurface/project-development.suite.ts | 684 ++++----------- .../appSurface/runtime-settings.suite.ts | 2 +- ...计划】AGC直连Codex Runtime迁移-2026-08-15.md | 115 +++ docs/project-memory/shared-memory/pitfalls.md | 14 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 9 + 57 files changed, 4354 insertions(+), 1518 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/SKILL.md create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/agents/openai.yaml create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/references/browser-evidence-contract.md create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/agents/openai.yaml create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-project-structure/SKILL.md create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-project-structure/agents/openai.yaml create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-project-structure/references/structure-contract.md create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/agents/openai.yaml create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/agents/openai.yaml create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md create mode 100644 apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/NOTICE.md create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs diff --git a/.gitignore b/.gitignore index 9ef28446b..a40fde447 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,12 @@ temp*build*/ /apps/ai-game-creator-shell/src-tauri/target/ /apps/ai-game-creator-shell/src-tauri/gen/ /apps/ai-game-creator-shell/src-tauri/logs/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex.exe +/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/manifest.json +/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/bin/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-path/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-resources/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-package.json /apps/ai-game-creator-shell/logs/ /apps/ai-game-creator-shell/.llm-drafts/ /apps/ai-game-creator-shell/game-creator.config.local.json diff --git a/apps/ai-game-creator-shell/package-lock.json b/apps/ai-game-creator-shell/package-lock.json index d0e770d7d..90e0ee81a 100644 --- a/apps/ai-game-creator-shell/package-lock.json +++ b/apps/ai-game-creator-shell/package-lock.json @@ -25,6 +25,7 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@openai/codex": "0.147.0", "@tailwindcss/vite": "^4.1.14", "@tauri-apps/cli": "^2.11.2", "@types/react": "^19.2.14", @@ -61,6 +62,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -296,6 +298,17 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1295,6 +1308,135 @@ } } }, + "node_modules/@openai/codex": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.147.0.tgz", + "integrity": "sha512-EQLEXecAG2ptxI7UpBMo2TR/ga5596/c/OsYF/0LoUDh5JANZ7IoGqlzBEWbuEVQ76JePIbtTW/ihCkp1a7Z3w==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "codex": "bin/codex.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.147.0-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.147.0-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.147.0-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.147.0-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.147.0-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.147.0-win32-x64" + } + }, + "node_modules/@openai/codex-darwin-arm64": { + "name": "@openai/codex", + "version": "0.147.0-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.147.0-darwin-arm64.tgz", + "integrity": "sha512-BEUVkiOW7kLcRyrMLfAr/h9wF8sRVJyZDy6OHtVn6QGDXiv3BvAZVTY1Pu9xF7KdIdkYXbp4uayN0aDQQaAUJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-darwin-x64": { + "name": "@openai/codex", + "version": "0.147.0-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.147.0-darwin-x64.tgz", + "integrity": "sha512-Tb8McE5SvJIH0Vs5R6sq7u+quiC931yan2KOOl6km1OdZ82+Wi7eF5XrSFPs5CF7xCgoIK4Vs+byMbT5hN+ZUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-linux-arm64": { + "name": "@openai/codex", + "version": "0.147.0-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.147.0-linux-arm64.tgz", + "integrity": "sha512-SLC1JXw2TYfr/c3HhrJubyyLelq7vTOLWVmiThFA+z0+WgzCPmaseJ/kzDD3Gge/TO7fCnnj7UcPmC0d2c8XAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-linux-x64": { + "name": "@openai/codex", + "version": "0.147.0-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.147.0-linux-x64.tgz", + "integrity": "sha512-0W9MBxPpWW0cSkNqrTDN2jR7rzzT7oNMhQY5446lT2Lw5cz5yhDTck4Va9rjkQEm+HlFzP/dmEMSZbXfJsINmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-win32-arm64": { + "name": "@openai/codex", + "version": "0.147.0-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.147.0-win32-arm64.tgz", + "integrity": "sha512-e2ZstJ8zT8Rm1nvR7CUVO+Gr3cTChE41+VfOzGhynzDXEoW0wfbjUQbc2bWbh1arG94LMm4y3dqBtUIbSrfeGA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-win32-x64": { + "name": "@openai/codex", + "version": "0.147.0-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.147.0-win32-x64.tgz", + "integrity": "sha512-oT7Ss5fAPf2fiWE9QNURqZcQGAAawSVxmIUdgPzckq4KFZAM+pRz9JbM4Rr498CjtbNgTOjWvDJ+DXvIBSfOPA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, "node_modules/@preact/signals-core": { "version": "1.14.4", "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.4.tgz", @@ -1859,27 +2001,6 @@ "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "dev": true, @@ -2322,6 +2443,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2415,6 +2537,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001803", @@ -2845,7 +2968,6 @@ "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", "license": "MIT", - "peer": true, "funding": { "type": "GitHub Sponsors ❤", "url": "https://github.com/sponsors/dmonad" @@ -2857,6 +2979,7 @@ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -2913,7 +3036,6 @@ "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", "license": "MIT", - "peer": true, "dependencies": { "isomorphic.js": "^0.2.4" }, @@ -2936,6 +3058,7 @@ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "devOptional": true, "license": "MPL-2.0", + "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -4141,6 +4264,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4191,6 +4315,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4200,6 +4325,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4482,12 +4608,21 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4646,6 +4781,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 69bd74590..75ed88ac1 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -52,6 +52,7 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@openai/codex": "0.147.0", "@tailwindcss/vite": "^4.1.14", "@tauri-apps/cli": "^2.11.2", "@types/react": "^19.2.14", diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index c624b7aa8..e2389fb6b 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -969,6 +969,16 @@ assertCommandNamesSubset( parseTauriHandlerCommandNames(tauriHandlerSource), ); +if ( + parseTauriHandlerCommandNames(tauriHandlerSource).includes( + 'create_automatic_local_game_project', + ) +) { + throw new Error( + 'AI game creator shell must not expose automatic project creation to the frontend', + ); +} + if (packageConfig.name !== '@genarrative/ai-game-creator-shell') { throw new Error('AI game creator shell package name drifted'); } @@ -1635,6 +1645,9 @@ for (const snippet of [ 'fn append_local_permission_log_at(', '"command.auto"', 'GameCreationAppPermission::Auto', + 'GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH', + 'validate_game_creator_bundled_codex_cli', + '内置 Codex CLI 完整性校验失败', ]) { if (!tauriRustSource.includes(snippet)) { throw new Error( diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index bf5707012..f91ee46d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -288,6 +288,58 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.21.7" @@ -1631,6 +1683,7 @@ name = "genarrative-ai-game-creator-shell" version = "0.1.0" dependencies = [ "agent-runtime-core", + "axum", "base64 0.22.1", "chromiumoxide", "futures", @@ -2009,6 +2062,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.10.1" @@ -2023,6 +2082,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -2605,6 +2665,12 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.2" @@ -4532,6 +4598,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.20" @@ -5714,6 +5791,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -5752,6 +5830,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 6fc26eb61..8b8455448 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -11,12 +11,14 @@ game-chat-release = [] [build-dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false } tauri-build = { version = "2.6.2", features = [] } [dependencies] agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" } base64 = "0.22" +axum = "0.8" chromiumoxide = "0.9.1" futures = "0.3" getrandom = "0.3" @@ -38,7 +40,7 @@ similar = "2.7" platform-llm = { path = "../../../server-rs/crates/platform-llm" } platform-agent = { path = "../../../server-rs/crates/platform-agent" } portable-pty = "0.9" -reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] } shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false } tauri = { version = "2.11.2", features = [] } tauri-plugin-dialog = "2.7.1" diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index 2d16ab7be..4faf2aaba 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -3,11 +3,126 @@ mod frontend_dist_guard; #[path = "build_support/runtime_prompt_bundle.rs"] mod runtime_prompt_bundle; +#[cfg(windows)] +use sha2::{Digest, Sha256}; use std::collections::BTreeSet; use std::env; use std::fs; use std::path::PathBuf; +#[cfg(windows)] +use std::io::{BufReader, Read}; + +const BUNDLED_CODEX_CLI_VERSION: &str = "codex-cli 0.147.0"; + +#[cfg(windows)] +const BUNDLED_CODEX_FILES: [&str; 6] = [ + "bin/codex.exe", + "bin/codex-code-mode-host.exe", + "codex-path/rg.exe", + "codex-resources/codex-command-runner.exe", + "codex-resources/codex-windows-sandbox-setup.exe", + "codex-package.json", +]; + +#[cfg(windows)] +fn sha256_file(path: &std::path::Path) -> Result { + let file = fs::File::open(path)?; + let mut reader = BufReader::new(file); + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { + #[cfg(windows)] + { + let app_root = manifest_dir + .parent() + .expect("AI 游戏创作 Tauri manifest 必须位于应用目录下"); + let source_candidates = [ + app_root.join( + "node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc", + ), + app_root.join( + "node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc", + ), + ]; + let source = source_candidates + .iter() + .find(|path| { + BUNDLED_CODEX_FILES + .iter() + .all(|relative| path.join(relative).is_file()) + }) + .cloned() + .unwrap_or_else(|| { + panic!( + "内置 Codex CLI 缺失;请先在 apps/ai-game-creator-shell 执行 npm ci(已检查:{})", + source_candidates + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(";") + ) + }); + let target_dir = manifest_dir.join("resources/codex/win-x64"); + let notice = target_dir.join("NOTICE.md"); + if !notice.is_file() { + panic!("内置 Codex CLI 第三方声明缺失:{}", notice.display()); + } + fs::create_dir_all(&target_dir).expect("创建内置 Codex CLI 资源目录失败"); + let mut file_hashes = serde_json::Map::new(); + for relative in BUNDLED_CODEX_FILES { + let source_path = source.join(relative); + let target_path = target_dir.join(relative); + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent).expect("创建内置 Codex CLI 资源子目录失败"); + } + let source_sha256 = sha256_file(&source_path).expect("读取内置 Codex CLI 资源失败"); + let target_matches_source = target_path.is_file() + && sha256_file(&target_path) + .map(|target_sha256| target_sha256 == source_sha256) + .unwrap_or(false); + if !target_matches_source { + fs::copy(&source_path, &target_path).expect("复制内置 Codex CLI 资源失败"); + } + file_hashes.insert( + relative.to_string(), + serde_json::Value::String(source_sha256), + ); + } + let manifest = serde_json::json!({ + "schemaVersion": "genarrative-codex-sidecar.v2", + "platform": "win32-x64", + "version": BUNDLED_CODEX_CLI_VERSION, + "files": file_hashes, + }); + let manifest_path = target_dir.join("manifest.json"); + let manifest_payload = format!( + "{}\n", + serde_json::to_string_pretty(&manifest).expect("序列化内置 Codex CLI 清单失败") + ); + if fs::read_to_string(&manifest_path) + .map(|current| current != manifest_payload) + .unwrap_or(true) + { + fs::write(&manifest_path, manifest_payload).expect("写入内置 Codex CLI 清单失败"); + } + for relative in BUNDLED_CODEX_FILES { + println!("cargo:rerun-if-changed={}", source.join(relative).display()); + } + println!("cargo:rerun-if-changed={}", notice.display()); + } +} + fn seed_task_group_id( group: &shared_contracts::game_creation_app::GameCreationAppAgentGroup, ) -> &'static str { @@ -56,6 +171,7 @@ fn main() { env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be available"), ); let manifest_path = manifest_dir.join("prompts/runtime/manifest.json"); + stage_bundled_codex_cli(&manifest_dir); let compiled = runtime_prompt_bundle::compile_manifest(&manifest_path) .unwrap_or_else(|error| panic!("Prompt Bundle 编译失败:{error}")); validate_seed_task_catalog(&compiled); diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/SKILL.md new file mode 100644 index 000000000..1ff34859b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/SKILL.md @@ -0,0 +1,23 @@ +--- +name: agc-browser-playtest +description: Run and interpret real AGC desktop and mobile browser evidence through the approved client tool. Use after meaningful game creation or modification, when diagnosing runtime or layout problems, or before claiming a playable game is complete. +--- + +# AGC Browser Playtest + +Use `agc_browser_playtest` from the `agc_tools` MCP server. Do not replace it with static source inspection or a statement that the page should work. + +## Workflow + +1. Call the tool after the current game files are ready to run. Start with attempt `1`; increment the attempt only after a real corrective edit. +2. Inspect both desktop and mobile results, including page readiness, visible text, screenshots, console errors, exceptions, failed requests, Canvas probes, blocked actions, and interaction evidence. +3. Compare screenshots with the user's request. Check that the active game fills its intended area, HUD elements do not cover gameplay, controls are visible, and requested platform art appears in the core experience. +4. If evidence exposes a defect, edit the actual game files and call the tool again. Keep the loop bounded to three attempts per user request unless the user explicitly asks to continue. +5. Treat browser infrastructure failure, an unloaded page, an unhandled exception, or missing evidence as a failed validation. Do not claim success from a partial result. +6. Use game-specific reasoning for quality. Do not require a fixed board, fixed text, fixed number of slices, or a legacy harness scenario. + +Call `agc_read_skill_resource` with `skillName="agc-browser-playtest"` and `relativePath="references/browser-evidence-contract.md"` before interpreting screenshots or Canvas/WebGL asset observations. + +## Reporting + +Summarize the real actions exercised, desktop and mobile observations, defects fixed, platform art actually observed, and remaining risks. Keep internal absolute paths and diagnostic-only details out of the user reply. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/agents/openai.yaml b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/agents/openai.yaml new file mode 100644 index 000000000..ca26b9942 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/agents/openai.yaml @@ -0,0 +1,11 @@ +interface: + display_name: "浏览器试玩验收" + short_description: "调用客户端真实双视口浏览器工具试玩,并依据结构化运行证据持续修复" + default_prompt: "Use $agc-browser-playtest to run real desktop and mobile browser validation and fix observed issues." + +dependencies: + tools: + - type: "mcp" + value: "agc_tools" + description: "陶泥儿客户端提供的受限双视口浏览器试玩工具" + transport: "stdio" diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/references/browser-evidence-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/references/browser-evidence-contract.md new file mode 100644 index 000000000..b123b49d0 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-browser-playtest/references/browser-evidence-contract.md @@ -0,0 +1,7 @@ +# Browser Evidence Contract + +The browser tool launches the current project through an AGC-owned loopback preview and a restricted Chromium profile. It blocks external proxy traffic, new windows, downloads, permissions, notifications, and service workers. + +Treat each viewport independently. A global pass requires both desktop and mobile to load and remain free of configured blocking diagnostics. Screenshots are observation evidence, not proof of platform provenance. + +Canvas/WebGL probes can show whether registered image sources entered a core renderer. A visible `` beside the game proves only page visibility. Use source files, runtime probes, and screenshots together before claiming that platform art is part of gameplay. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md new file mode 100644 index 000000000..55800447c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md @@ -0,0 +1,25 @@ +--- +name: agc-client-projection +description: Preserve AGC client resource and version projection semantics after real project changes. Use when adding, replacing, or removing game files or registered art, when reasoning about project revisions and versions, or when client-visible resources appear out of sync. +--- + +# AGC Client Projection + +Let the client derive projections from real disk changes and trusted tool results. + +## Workflow + +1. Write executable source to the current `game/` files and media to the relative paths returned by approved tools. +2. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable. +3. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand. +4. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change. +5. Do not claim a resource or version is visible before the client projects it. If projection is missing, report the changed relative files and let the client re-read durable state. +6. Never move HTML, CSS, or JavaScript into documentation folders. They belong to the game-code projection; prose, design notes, and instructions remain documents. + +Call `agc_read_skill_resource` with `skillName="agc-client-projection"` and `relativePath="references/projection-contract.md"` when a request touches asset identity, revision behavior, or version history. + +## Boundaries + +- Platform provenance comes only from the approved art tool. +- Browser evidence proves runtime behavior, not resource ownership. +- Source changes, resource registration, and version projection are distinct facts; report each accurately. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/agents/openai.yaml b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/agents/openai.yaml new file mode 100644 index 000000000..2ecc32e5b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "客户端资源投影" + short_description: "遵守游戏代码、美术资源、项目 revision 与正式版本的客户端投影契约" + default_prompt: "Use $agc-client-projection to keep AGC resource and version projection consistent after file changes." diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md new file mode 100644 index 000000000..3aa48e8c6 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md @@ -0,0 +1,9 @@ +# Projection Contract + +The client projects three distinct facts: + +1. Game-code resources come from actual source files such as `game/index.html`, `game/style.css`, and `game/game.js`. +2. Art resources come from approved tool registration with durable platform provenance. +3. A project version is created only after a real game-source fingerprint change and a monotonic project revision update. + +Do not collapse these facts. A playable file can exist before projection refresh, a registered image can exist without being used by the game, and browser success does not create platform provenance. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-project-structure/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-project-structure/SKILL.md new file mode 100644 index 000000000..45082b612 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-project-structure/SKILL.md @@ -0,0 +1,26 @@ +--- +name: agc-project-structure +description: Work safely inside the current Taonier AGC game project. Use when Codex needs to inspect, create, or modify game code, local art references, project documentation, or other project-bound files without creating a parallel workspace or rewriting client-owned state. +--- + +# AGC Project Structure + +Treat the current working directory as the only project root. + +## Workflow + +1. Inspect the existing files needed for the request before editing. +2. Put the playable entry in `game/index.html`, styles in `game/style.css`, and game logic in `game/game.js` unless the existing project deliberately uses another in-project structure. +3. Keep reusable media under `assets/` and reference it with paths that resolve from the game entry. +4. Treat `.agent/` as client-owned durable state. Read it only when project identity or registered asset evidence is needed. Do not hand-edit manifests, revisions, versions, ledgers, receipts, or provenance records. +5. Reuse existing files and asset identities. Do not create a second project root, hidden harness, Supervisor workspace, or parallel implementation. +6. Make the smallest coherent change that satisfies the user request, then inspect the actual changed files. + +Call `agc_read_skill_resource` with `skillName="agc-project-structure"` and `relativePath="references/structure-contract.md"` when deciding where a new file belongs or whether a state file may be edited. + +## Boundaries + +- Stay inside the current project root. +- Do not read credentials, `.env`, authentication files, browser profiles, or unrelated host paths. +- Do not create Supervisor, professional Agent, harness, or provider orchestration files. +- Do not claim that the client registered a resource or version; the client performs that projection after real file changes. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-project-structure/agents/openai.yaml b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-project-structure/agents/openai.yaml new file mode 100644 index 000000000..dcd380b12 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-project-structure/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "陶泥儿项目结构" + short_description: "约束当前项目根、游戏代码、美术素材与状态文件的职责边界" + default_prompt: "Use $agc-project-structure to inspect and work within the current AGC game project structure." diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-project-structure/references/structure-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-project-structure/references/structure-contract.md new file mode 100644 index 000000000..43058e41f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-project-structure/references/structure-contract.md @@ -0,0 +1,13 @@ +# Structure Contract + +| Path | Owner | Allowed Codex action | +| --- | --- | --- | +| `game/index.html` | Game source | Read and edit | +| `game/style.css` | Game source | Read and edit | +| `game/game.js` | Game source | Read and edit | +| `assets/` | Project media | Read; write only through approved project or art workflows | +| `.agent/manifest.json` | AGC client | Read only | +| `.agent/generation-ledgers/` | AGC client | Do not edit | +| `.agent/runtime/` | AGC client evidence | Read only unless an approved tool writes it | + +Keep every new path relative to the current project root. Reject a path containing `..`, a drive prefix, a UNC prefix, or a leading slash when it would escape the project. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md new file mode 100644 index 000000000..5db536c2f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md @@ -0,0 +1,24 @@ +--- +name: agc-web-game-development +description: Build or modify a playable web game in the current AGC project. Use for gameplay creation, bug fixes, UI or layout changes, responsive behavior, asset integration, controls, scoring, reset flows, and other HTML, CSS, JavaScript, DOM, Canvas, or WebGL work. +--- + +# AGC Web Game Development + +Implement the user's actual game request in the current project. Choose DOM, Canvas, WebGL, or a combination based on the game rather than a fixed code template. + +## Workflow + +1. Read the existing `game/index.html`, `game/style.css`, and `game/game.js` before modifying an existing game. +2. Keep the entry self-contained and runnable from the AGC loopback preview. Avoid CDN-only dependencies and network-required runtime assets. +3. Build a complete playable loop: visible objective, responsive input, meaningful state changes, success or failure feedback, and a reliable restart path where the game needs one. +4. Fit the active game scene to desktop and mobile viewports without accidental page scrollbars. Reserve deliberate safe space for HUD elements instead of covering interactive content. +5. Reuse registered Taonier art when available. Load media defensively and keep gameplay usable when an optional derivative is absent; never relabel a local placeholder as platform art. +6. Avoid undefined animation callbacks, duplicate loops, stale event listeners, and state that survives restart unintentionally. +7. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion. + +Call `agc_read_skill_resource` with `skillName="agc-web-game-development"` and `relativePath="references/game-quality-checklist.md"` when implementing a new game loop or a broad gameplay revision. + +## Reporting + +Report the files changed, observable gameplay behavior, real validation performed, and any remaining limitation. Do not report a test or browser pass that did not run. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/agents/openai.yaml b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/agents/openai.yaml new file mode 100644 index 000000000..b99cbcfe1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Web 游戏实现" + short_description: "在当前项目内设计、实现并验证可玩的 HTML、CSS 与 JavaScript 游戏" + default_prompt: "Use $agc-web-game-development to build or modify the current playable web game." diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md new file mode 100644 index 000000000..77bdf1f9c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md @@ -0,0 +1,11 @@ +# Game Quality Checklist + +- The entry loads without uncaught exceptions or failed required requests. +- The user can understand the objective from the visible game, not from developer prose. +- Pointer and touch input both reach the intended controls. +- A valid action changes game state and an invalid action has understandable feedback. +- Score, steps, health, timer, or other core state updates consistently. +- Restart restores all state and does not duplicate timers, animation loops, or event listeners. +- Desktop and mobile layouts keep the core scene visible without accidental document scrolling. +- HUD and overlays reserve space and do not cover essential interactive content. +- Requested Taonier art is visibly integrated into the core experience when available. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json new file mode 100644 index 000000000..ed35c0b29 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -0,0 +1,46 @@ +{ + "schemaVersion": "agc-skill-pack.v1", + "version": "2026-08-20.1", + "skills": [ + { + "name": "agc-project-structure", + "purpose": "约束当前项目根、游戏代码、美术素材与客户端状态的职责边界", + "triggers": ["创建或修改游戏文件", "判断文件应放置的位置", "读取项目状态证据"], + "requiredTools": ["agc_tools.agc_read_skill_resource"], + "files": ["SKILL.md", "agents/openai.yaml", "references/structure-contract.md"], + "sha256": "69b46a2a7180a227dba587dc5eb4ce5b13fb70ab6de55420ee08133d07083225" + }, + { + "name": "taonier-art-assets", + "purpose": "通过受控平台工具生成、恢复、登记并使用真实陶泥儿游戏美术", + "triggers": ["用户要求新美术或重做美术", "新游戏需要平台素材", "已有陶泥儿素材需要接入玩法"], + "requiredTools": ["agc_tools.agc_read_skill_resource", "agc_tools.taonier_prepare_game_art"], + "files": ["SKILL.md", "agents/openai.yaml", "references/platform-art-contract.md"], + "sha256": "325bbca875aac3d3504ea61519944f3e4f695d3a389e29de5011b5095c74eaa9" + }, + { + "name": "agc-web-game-development", + "purpose": "在当前项目实现或修改可玩的 HTML、CSS 与 JavaScript 游戏", + "triggers": ["创建玩法", "修复游戏逻辑", "调整布局与交互", "接入已有素材"], + "requiredTools": ["agc_tools.agc_read_skill_resource"], + "files": ["SKILL.md", "agents/openai.yaml", "references/game-quality-checklist.md"], + "sha256": "f897a38e89bfc576be9e91ed9bf89c25f54f700aa258039168ae2f7607f147df" + }, + { + "name": "agc-browser-playtest", + "purpose": "调用客户端真实 desktop/mobile 浏览器试玩并依据证据修复", + "triggers": ["游戏代码发生实质修改", "诊断运行或布局问题", "声明游戏可玩前验收"], + "requiredTools": ["agc_tools.agc_read_skill_resource", "agc_tools.agc_browser_playtest"], + "files": ["SKILL.md", "agents/openai.yaml", "references/browser-evidence-contract.md"], + "sha256": "bc818bcd9a97b7e408d4c81de57a3e52e3e61d858a8d5fb84589e6e47a51cc65" + }, + { + "name": "agc-client-projection", + "purpose": "保持游戏代码、美术资源、revision 与正式版本的客户端投影一致", + "triggers": ["新增或替换游戏文件", "素材或版本未显示", "推理 revision 与版本关系"], + "requiredTools": ["agc_tools.agc_read_skill_resource"], + "files": ["SKILL.md", "agents/openai.yaml", "references/projection-contract.md"], + "sha256": "b7b4623032a3eafe55a74f8931c15b380392938985bce4126f16451a1b61de2a" + } + ] +} diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md new file mode 100644 index 000000000..3b6bda5a1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md @@ -0,0 +1,26 @@ +--- +name: taonier-art-assets +description: Prepare, recover, inspect, and integrate real Taonier platform game art through the approved AGC tool. Use when the user requests new or changed visual assets, when a new game needs platform art, or when existing registered Taonier art must be connected to the playable game. +--- + +# Taonier Art Assets + +Use real platform assets only through `taonier_prepare_game_art` from the `agc_tools` MCP server. + +## Workflow + +1. Inspect existing `assets/` and registered project evidence before requesting new art. Reuse suitable assets when the user did not ask to regenerate them. +2. Call `taonier_prepare_game_art` only when the current intent requires new or recoverable platform art. Pass a concise game-specific visual brief; do not call it for greetings, date questions, text-only code fixes, or layout changes that can reuse current art. +3. Treat the tool result as authoritative. Use only returned relative paths and warnings. Never invent a resource, slice, platform identity, or successful generation. +4. If a complete trusted spritesheet is returned without slices, keep using the complete sheet. `grid-2x2`, four fixed slices, fixed filenames, and a fixed number of `drawImage` calls are recommendations, not universal completion rules. +5. Integrate at least one suitable Taonier asset into the game's actual visible experience when platform art was requested. A hidden or side-panel preview does not count as gameplay use. +6. Preserve warning details in the final report. If the tool reports missing credentials, uncertain operation state, invalid provenance, download failure, or decode failure, stop and report the actionable reason; do not substitute generated CSS shapes and call the platform step complete. + +Call `agc_read_skill_resource` with `skillName="taonier-art-assets"` and `relativePath="references/platform-art-contract.md"` before interpreting async recovery, source-preserved warnings, or slice warnings. + +## Boundaries + +- Do not call remote generation endpoints directly. +- Do not create or rotate API keys. +- Do not place credentials, signed URLs, operation internals, or provider diagnostics in files or replies. +- Do not edit `.agent` provenance, ledgers, manifests, or version records. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/agents/openai.yaml b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/agents/openai.yaml new file mode 100644 index 000000000..c236e9cc1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/agents/openai.yaml @@ -0,0 +1,11 @@ +interface: + display_name: "陶泥儿美术素材" + short_description: "通过受控平台工具生成、恢复、登记并使用真实陶泥儿游戏美术素材" + default_prompt: "Use $taonier-art-assets to prepare and integrate real Taonier game art safely." + +dependencies: + tools: + - type: "mcp" + value: "agc_tools" + description: "陶泥儿客户端提供的受控平台美术工具" + transport: "stdio" diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md new file mode 100644 index 000000000..772300c2e --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md @@ -0,0 +1,11 @@ +# Platform Art Contract + +`taonier_prepare_game_art` is the only paid art entry exposed to the AGC Codex thread. The client owns authentication, stable idempotency keys, durable `operationId` recovery, download, PNG decoding, source identity, and asset registration. + +- A submission acknowledgement is not a completed image. +- On timeout or uncertain delivery, reuse the recorded operation; never create a replacement request. +- `postprocess-failed-source-preserved` means the complete provider source remains usable, but the requested transparent derivative is absent. +- `sliceWarning` means the complete transparent sheet remains usable, but individual slices are absent. +- General and slice warnings can coexist and must both be reported. +- A trusted complete image may be used without fixed slices. Never fabricate missing derivatives. +- A tool error about credentials, provenance, decoding, or operation identity is terminal for that tool call and must be surfaced accurately. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/NOTICE.md b/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/NOTICE.md new file mode 100644 index 000000000..18ff8445f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/NOTICE.md @@ -0,0 +1,12 @@ +# Bundled Codex CLI + +This installer bundles Codex CLI 0.147.0 for Windows x64. + +Codex CLI is licensed under the Apache License, Version 2.0. Its source and +license text are available at https://github.com/openai/codex. + +The native package files are staged from the pinned `@openai/codex` npm +dependency during the release build. The bundle preserves the upstream relative +layout for `codex.exe`, `codex-code-mode-host.exe`, ripgrep, the command runner, +the Windows sandbox setup helper, and `codex-package.json`. It contains no API +key, Codex login state, user configuration, or project data. 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 8020721aa..c6847ec57 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -11,7 +11,10 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; mod codex_app_server; mod codex_cli; +mod codex_provider_proxy; mod direct_runtime; +mod direct_tool_bridge; +mod direct_tools_mcp; mod generation; mod interaction; mod prompt; @@ -21,13 +24,19 @@ mod runtime_driver; mod runtime_protocol; mod runtime_state; mod runtime_tools; -pub(crate) use codex_app_server::direct_game_creator_codex_chat_at; +mod skill_pack; use codex_app_server::*; +pub(crate) use codex_app_server::{ + direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat, +}; use codex_cli::*; pub(crate) use codex_cli::{ game_creator_codex_cli_executable_path, game_creator_codex_cli_version_identity, }; +pub(crate) use codex_provider_proxy::*; pub(crate) use direct_runtime::*; +pub(crate) use direct_tool_bridge::*; +pub(crate) use direct_tools_mcp::*; pub(crate) use generation::*; pub(crate) use interaction::*; pub(crate) use prompt::*; @@ -37,6 +46,7 @@ pub(crate) use runtime_driver::*; pub(crate) use runtime_protocol::*; pub(crate) use runtime_state::*; pub(crate) use runtime_tools::*; +pub(crate) use skill_pack::*; pub(crate) fn shutdown_game_creator_codex_app_servers() -> Result<(), String> { shutdown_game_creator_codex_app_servers_impl() 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 f28aaa9e2..61dad118f 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 @@ -307,6 +307,48 @@ enum CodexTurnEvent { TransportClosed(String), } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum CodexAppServerWorkspaceMode { + /// The legacy AGC ToolHost path: use a throwaway workspace and keep the + /// app-server constrained to passive runtime items. + ToolHost, + /// A direct conversation attached to a user-selected game project. + DirectProject, + /// The home-page conversation: direct Codex instructions, but no user + /// project and no writable workspace. + DirectHome, +} + +impl CodexAppServerWorkspaceMode { + fn pool_identity(self) -> &'static str { + match self { + Self::ToolHost => "tool-host", + Self::DirectProject => "direct-project", + Self::DirectHome => "direct-home", + } + } + + fn uses_direct_conversation(self) -> bool { + matches!(self, Self::DirectProject | Self::DirectHome) + } + + fn allows_workspace_writes(self) -> bool { + matches!(self, Self::DirectProject) + } + + fn requires_passive_items(self) -> bool { + !matches!(self, Self::DirectProject) + } + + fn passive_item_boundary_name(self) -> &'static str { + match self { + Self::ToolHost => "AGC ToolHost", + Self::DirectHome => "AGC 首页只读对话", + Self::DirectProject => "AGC 直连项目", + } + } +} + struct CodexAppServerInner { writer: Mutex, child: Mutex>, @@ -321,6 +363,10 @@ struct CodexAppServerInner { closed: AtomicBool, _working_dir: tempfile::TempDir, workspace_path: std::path::PathBuf, + workspace_mode: CodexAppServerWorkspaceMode, + _provider_proxy: Option, + _tool_bridge: Option, + _skill_root: Option, } #[derive(Clone)] @@ -358,7 +404,13 @@ fn game_creator_codex_app_server_pool_key( codex_cli_version: &str, snapshot: &AgentRuntimeProviderRequestSnapshot, credential_fingerprint: &str, + workspace_mode: CodexAppServerWorkspaceMode, ) -> String { + let skill_pack_identity = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + agc_skill_pack_fingerprint().unwrap_or_else(|_| "invalid-skill-pack".to_string()) + } else { + "disabled".to_string() + }; let stable = serde_json::json!({ "credentialFingerprint": credential_fingerprint, "baseUrl": llm.base_url, @@ -370,6 +422,10 @@ fn game_creator_codex_app_server_pool_key( "sessionId": snapshot.session_id, "runId": snapshot.run_id, }, + "workspaceMode": workspace_mode.pool_identity(), + "skillPackIdentity": skill_pack_identity, + "directToolBridgeProtocol": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { DIRECT_TOOL_BRIDGE_PROTOCOL } else { "disabled" }, + "providerProxyProtocol": if workspace_mode.uses_direct_conversation() && !llm.api_key.trim().is_empty() { CODEX_PROVIDER_PROXY_PROTOCOL } else { "disabled" }, }); format!("{:x}", Sha256::digest(stable.to_string().as_bytes())) } @@ -395,6 +451,166 @@ fn direct_codex_user_prompt(request: &LlmRunRequest) -> String { .join("\n\n") } +fn validate_direct_project_skill_catalog(value: &serde_json::Value) -> Result<(), String> { + let data = value + .get("data") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "Codex app-server skills/list 缺少 data".to_string())?; + let mut names = std::collections::BTreeSet::new(); + let mut error_count = 0_usize; + for entry in data { + error_count = error_count.saturating_add( + entry + .get("errors") + .and_then(serde_json::Value::as_array) + .map(Vec::len) + .unwrap_or_default(), + ); + if let Some(skills) = entry.get("skills").and_then(serde_json::Value::as_array) { + names.extend( + skills + .iter() + .filter_map(|skill| skill.get("name")) + .filter_map(serde_json::Value::as_str) + .map(str::to_string), + ); + } + } + if error_count > 0 { + return Err(format!( + "Codex app-server 审核 Skill 加载出现 {error_count} 个错误" + )); + } + let missing = AGC_SKILL_PACK_EXPECTED_NAMES + .iter() + .filter(|name| !names.contains(**name)) + .copied() + .collect::>(); + if !missing.is_empty() { + return Err(format!( + "Codex app-server 未发现完整审核 Skill Pack:{}", + missing.join("、") + )); + } + Ok(()) +} + +fn codex_app_server_thread_start_params( + model: &str, + workspace_path: &std::path::Path, + workspace_mode: CodexAppServerWorkspaceMode, + base_instructions: String, + use_model_provider: bool, +) -> serde_json::Value { + let approval_policy = if workspace_mode.allows_workspace_writes() { + "on-request" + } else { + "never" + }; + let mut params = serde_json::json!({ + "model": model, + "cwd": workspace_path, + "approvalPolicy": approval_policy, + "sandbox": if workspace_mode.allows_workspace_writes() { "workspace-write" } else { "read-only" }, + "ephemeral": true, + "baseInstructions": base_instructions + }); + if use_model_provider { + params["modelProvider"] = + serde_json::Value::String(GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID.to_string()); + } + params +} + +fn codex_app_server_turn_start_params( + thread_id: &str, + prompt: String, + model: &str, + workspace_path: &std::path::Path, + workspace_mode: CodexAppServerWorkspaceMode, +) -> serde_json::Value { + let approval_policy = if workspace_mode.allows_workspace_writes() { + "on-request" + } else { + "never" + }; + let mut params = serde_json::json!({ + "threadId": thread_id, + "input": [{ "type": "text", "text": prompt }], + "model": model, + "approvalPolicy": approval_policy, + }); + if workspace_mode.allows_workspace_writes() { + params["sandboxPolicy"] = serde_json::json!({ + "type": "workspaceWrite", + "writableRoots": [workspace_path], + "networkAccess": true + }); + } + params +} + +fn game_creator_codex_app_server_interaction_response( + workspace_path: &std::path::Path, + workspace_mode: CodexAppServerWorkspaceMode, + id: u64, + method: &str, + requested_grant_root: Option<&str>, +) -> serde_json::Value { + let direct_workspace = workspace_mode.allows_workspace_writes(); + let file_change_within_workspace = game_creator_codex_file_change_request_is_allowed( + workspace_path, + method, + requested_grant_root, + ); + 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": [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 的交互、审批与工具请求" + } + }) + } +} + fn find_game_creator_codex_auth_path() -> Option { std::env::var_os("CODEX_HOME") .map(std::path::PathBuf::from) @@ -493,6 +709,22 @@ fn quoted_toml_string(value: &str) -> Result { fn configure_game_creator_codex_app_server_command( command: &mut tokio::process::Command, llm: &GameCreatorLlmConfig, +) -> Result<(), platform_llm::LlmError> { + configure_game_creator_codex_app_server_command_for_mode( + command, + llm, + CodexAppServerWorkspaceMode::ToolHost, + None, + None, + ) +} + +fn configure_game_creator_codex_app_server_command_for_mode( + command: &mut tokio::process::Command, + llm: &GameCreatorLlmConfig, + workspace_mode: CodexAppServerWorkspaceMode, + provider_proxy: Option<&CodexProviderProxy>, + tool_bridge: Option<&DirectToolBridge>, ) -> Result<(), platform_llm::LlmError> { command .arg("app-server") @@ -503,6 +735,44 @@ fn configure_game_creator_codex_app_server_command( .arg("web_search=\"disabled\"") .arg("-c") .arg("agents.enabled=false"); + if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + let current_executable = std::env::current_exe().map_err(|error| { + platform_llm::LlmError::InvalidConfig(format!("定位 AGC 受控工具进程失败:{error}")) + })?; + command + .arg("-c") + .arg(format!( + "mcp_servers.agc_tools.command={}", + quoted_toml_string(¤t_executable.to_string_lossy())? + )) + .arg("-c") + .arg(format!( + "mcp_servers.agc_tools.args={}", + serde_json::to_string(&[DIRECT_TOOLS_MCP_MODE_FLAG]).map_err(|error| { + platform_llm::LlmError::InvalidConfig(format!( + "序列化 AGC 受控工具参数失败:{error}" + )) + })? + )) + .arg("-c") + .arg("mcp_servers.agc_tools.required=true") + .arg("-c") + .arg("mcp_servers.agc_tools.default_tools_approval_mode=\"approve\"") + .arg("-c") + .arg("mcp_servers.agc_tools.startup_timeout_sec=20") + .arg("-c") + .arg("mcp_servers.agc_tools.tool_timeout_sec=1800"); + if tool_bridge.is_some() { + command.arg("-c").arg(format!( + "mcp_servers.agc_tools.env_vars={}", + serde_json::to_string(&[DIRECT_TOOL_BRIDGE_URL_ENV]).map_err(|error| { + platform_llm::LlmError::InvalidConfig(format!( + "序列化 AGC 受控工具桥环境白名单失败:{error}" + )) + })? + )); + } + } let disabled_features = [ "apps", "browser_use", @@ -527,6 +797,9 @@ fn configure_game_creator_codex_app_server_command( command.arg("--disable").arg("shell_tool"); command.arg("--disable").arg("unified_exec"); if !llm.api_key.trim().is_empty() { + let provider_base_url = provider_proxy + .map(CodexProviderProxy::base_url) + .unwrap_or_else(|| llm.base_url.trim_end_matches('/')); command .arg("-c") .arg(format!( @@ -541,7 +814,7 @@ fn configure_game_creator_codex_app_server_command( .arg("-c") .arg(format!( "model_providers.{GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID}.base_url={}", - quoted_toml_string(llm.base_url.trim_end_matches('/'))? + quoted_toml_string(provider_base_url)? )) .arg("-c") .arg(format!( @@ -606,15 +879,19 @@ impl CodexAppServerConnection { snapshot: &AgentRuntimeProviderRequestSnapshot, llm: &GameCreatorLlmConfig, ) -> Result { - Self::acquire_at_workspace(snapshot, llm, None).await + Self::acquire_at_workspace(snapshot, llm, None, CodexAppServerWorkspaceMode::ToolHost).await } async fn acquire_at_workspace( snapshot: &AgentRuntimeProviderRequestSnapshot, llm: &GameCreatorLlmConfig, workspace_override: Option<&std::path::Path>, + workspace_mode: CodexAppServerWorkspaceMode, ) -> Result { game_creator_codex_app_server_validate_llm_config(llm)?; + if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + agc_skill_pack_fingerprint().map_err(platform_llm::LlmError::InvalidConfig)?; + } let codex_cli_version = game_creator_codex_cli_version_identity() .map_err(platform_llm::LlmError::InvalidConfig)?; let credential = resolve_game_creator_codex_app_server_credential(llm)?; @@ -623,6 +900,7 @@ impl CodexAppServerConnection { &codex_cli_version, snapshot, credential.fingerprint(), + workspace_mode, ); let mut pool = game_creator_codex_app_server_pool().lock().await; if let Some(inner) = pool.get(&key) { @@ -666,10 +944,22 @@ impl CodexAppServerConnection { &credential, executable.as_os_str(), Some(workspace), + workspace_mode, + ) + .await? + } + None => { + 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(), + None, + workspace_mode, ) .await? } - None => Self::spawn(llm, &credential).await?, }; pool.insert(key, Arc::clone(&connection.inner)); Ok(connection) @@ -697,8 +987,14 @@ impl CodexAppServerConnection { credential: &CodexAppServerCredential, executable: &std::ffi::OsStr, ) -> Result { - Self::spawn_with_executable_and_credential_at_workspace(llm, credential, executable, None) - .await + Self::spawn_with_executable_and_credential_at_workspace( + llm, + credential, + executable, + None, + CodexAppServerWorkspaceMode::ToolHost, + ) + .await } async fn spawn_with_executable_and_credential_at_workspace( @@ -706,6 +1002,7 @@ impl CodexAppServerConnection { credential: &CodexAppServerCredential, executable: &std::ffi::OsStr, workspace_override: Option<&std::path::Path>, + workspace_mode: CodexAppServerWorkspaceMode, ) -> Result { let working_dir = tempfile::Builder::new() .prefix("genarrative-agc-codex-app-server-") @@ -733,7 +1030,7 @@ impl CodexAppServerConnection { let workspace_path = workspace_override .map(std::path::Path::to_path_buf) .unwrap_or_else(|| isolated_workspace.clone()); - if workspace_override.is_some() { + if workspace_override.is_some() && workspace_mode.allows_workspace_writes() { trust_isolated_game_creator_codex_workspace(&isolated_codex_home, &workspace_path)?; } let isolated_os_home = working_dir.path().join("home"); @@ -750,8 +1047,46 @@ impl CodexAppServerConnection { )) })?; } + let skill_root = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + install_agc_skill_pack(&isolated_os_home) + .map_err(platform_llm::LlmError::InvalidConfig)?; + Some(isolated_os_home.join(".agents").join("skills")) + } else { + None + }; + let provider_proxy = + if !workspace_mode.uses_direct_conversation() || llm.api_key.trim().is_empty() { + None + } else { + Some( + start_codex_provider_proxy(&llm.base_url) + .await + .map_err(platform_llm::LlmError::InvalidConfig)?, + ) + }; + let tool_bridge = if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + Some( + start_direct_tool_bridge(&workspace_path) + .await + .map_err(platform_llm::LlmError::InvalidConfig)?, + ) + } else { + None + }; + if std::env::var_os("GENARRATIVE_AGC_DIRECT_DEBUG").is_some() { + eprintln!( + "agent.direct_codex.provider_proxy configured={}", + provider_proxy.is_some() + ); + } let mut command = tokio::process::Command::new(executable); - configure_game_creator_codex_app_server_command(&mut command, llm)?; + configure_game_creator_codex_app_server_command_for_mode( + &mut command, + llm, + workspace_mode, + provider_proxy.as_ref(), + tool_bridge.as_ref(), + )?; command .current_dir(&workspace_path) .stdin(Stdio::piped()) @@ -759,6 +1094,9 @@ impl CodexAppServerConnection { .stderr(Stdio::piped()) .kill_on_drop(true); game_creator_codex_cli_minimal_environment(&mut command); + if let Some(tool_bridge) = tool_bridge.as_ref() { + command.env(DIRECT_TOOL_BRIDGE_URL_ENV, tool_bridge.url()); + } command .env("CODEX_HOME", &isolated_codex_home) .env("HOME", &isolated_os_home) @@ -798,6 +1136,10 @@ impl CodexAppServerConnection { closed: AtomicBool::new(false), _working_dir: working_dir, workspace_path, + workspace_mode, + _provider_proxy: provider_proxy, + _tool_bridge: tool_bridge, + _skill_root: skill_root, }); tokio::spawn(read_game_creator_codex_app_server_stdout( Arc::downgrade(&inner), @@ -826,6 +1168,27 @@ impl CodexAppServerConnection { .notify("initialized", serde_json::json!({})) .await .map_err(platform_llm::LlmError::Transport)?; + if let Some(skill_root) = connection.inner._skill_root.as_ref() { + connection + .request( + "skills/extraRoots/set", + serde_json::json!({ "extraRoots": [skill_root] }), + ) + .await + .map_err(platform_llm::LlmError::Transport)?; + let catalog = connection + .request( + "skills/list", + serde_json::json!({ + "cwds": [&connection.inner.workspace_path], + "forceReload": true, + }), + ) + .await + .map_err(platform_llm::LlmError::Transport)?; + validate_direct_project_skill_catalog(&catalog) + .map_err(platform_llm::LlmError::InvalidConfig)?; + } Ok(connection) } @@ -961,30 +1324,18 @@ 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 { + let base_instructions = if self.inner.workspace_mode.uses_direct_conversation() { direct_codex_base_instructions(&request) } else { DIRECT_CODEX_BASE_INSTRUCTIONS_FALLBACK.to_string() }; - let mut params = serde_json::json!({ - "model": model, - "cwd": self.inner.workspace_path, - "approvalPolicy": approval_policy, - "sandbox": if direct_workspace { "workspace-write" } else { "read-only" }, - "ephemeral": true, - "baseInstructions": base_instructions - }); - if !llm.api_key.trim().is_empty() { - params["modelProvider"] = - serde_json::Value::String(GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID.to_string()); - } + let params = codex_app_server_thread_start_params( + model, + &self.inner.workspace_path, + self.inner.workspace_mode, + base_instructions, + !llm.api_key.trim().is_empty(), + ); let result = self .request("thread/start", params) .await @@ -1061,14 +1412,7 @@ 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 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 { + let prompt = if self.inner.workspace_mode.uses_direct_conversation() { direct_codex_user_prompt(&request) } else { render_game_creator_codex_cli_prompt(&request) @@ -1079,17 +1423,13 @@ impl CodexAppServerConnection { .as_deref() .filter(|value| !value.trim().is_empty()) .unwrap_or(&llm.model); - let mut params = serde_json::json!({ - "threadId": thread_id, - "input": [{ "type": "text", "text": prompt }], - "model": model, - "approvalPolicy": approval_policy, - "sandboxPolicy": { - "type": "workspaceWrite", - "writableRoots": [self.inner.workspace_path], - "networkAccess": true - }, - }); + let mut params = codex_app_server_turn_start_params( + &thread_id, + prompt, + model, + &self.inner.workspace_path, + self.inner.workspace_mode, + ); apply_game_creator_codex_app_server_reasoning_effort(&mut params, &request); if let Some(schema) = game_creator_codex_cli_tool_output_schema(&request) { params["outputSchema"] = schema; @@ -1172,14 +1512,15 @@ impl CodexAppServerConnection { { final_text = Some(text.to_string()); } - } else if !direct_workspace + } else if self.inner.workspace_mode.requires_passive_items() && !matches!( item_type, "userMessage" | "plan" | "reasoning" | "contextCompaction" ) { return Err(platform_llm::LlmError::InvalidRequest(format!( - "Codex app-server 违反 AGC ToolHost 边界,产生非被动 item {item_type}" + "Codex app-server 违反 {}边界,产生非被动 item {item_type}", + self.inner.workspace_mode.passive_item_boundary_name(), ))); } } @@ -1440,7 +1781,7 @@ async fn read_game_creator_codex_app_server_stdout( return; }; let direct_debug = std::env::var_os("GENARRATIVE_AGC_DIRECT_DEBUG").is_some() - && inner.workspace_path != inner._working_dir.path().join("workspace"); + && inner.workspace_mode.uses_direct_conversation(); if direct_debug { let method = message .get("method") @@ -1456,6 +1797,18 @@ async fn read_game_creator_codex_app_server_stdout( "agent.direct_codex.event method={} paramsKeys={:?}", method, params_keys ); + if matches!(method, "error" | "account/rateLimits/updated") { + let safe = message + .get("params") + .map(serde_json::Value::to_string) + .unwrap_or_default(); + let bounded = safe.chars().take(2_048).collect::(); + eprintln!( + "agent.direct_codex.event.safeDetails method={} details={}", + method, + redact_game_creator_codex_debug_text(&bounded) + ); + } } else if let Some(params) = message.get("params") { let event_name = params .get("type") @@ -1514,8 +1867,7 @@ async fn read_game_creator_codex_app_server_stdout( if message.get("method").is_some() { let writer = Arc::clone(&inner); tokio::spawn(async move { - let direct_workspace = - inner.workspace_path != inner._working_dir.path().join("workspace"); + let direct_workspace = inner.workspace_mode.allows_workspace_writes(); let direct_debug = std::env::var_os("GENARRATIVE_AGC_DIRECT_DEBUG").is_some(); let method = message .get("method") @@ -1530,57 +1882,13 @@ async fn read_game_creator_codex_app_server_stdout( "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 response = game_creator_codex_app_server_interaction_response( + &inner.workspace_path, + inner.workspace_mode, + id, + method, + requested_grant_root, + ); let mut bytes = match serde_json::to_vec(&response) { Ok(bytes) => bytes, Err(_) => return, @@ -1740,7 +2048,7 @@ async fn read_game_creator_codex_app_server_stderr( return; }; if std::env::var_os("GENARRATIVE_AGC_DIRECT_DEBUG").is_some() - && upgraded.workspace_path != upgraded._working_dir.path().join("workspace") + && upgraded.workspace_mode.uses_direct_conversation() { let text = String::from_utf8_lossy(&buffer[..count]); let bounded = text.chars().take(2_048).collect::(); @@ -1966,10 +2274,63 @@ pub(crate) async fn direct_game_creator_codex_chat_at( .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())?; + let connection = CodexAppServerConnection::acquire_at_workspace( + &snapshot, + &config.llm, + Some(&codex_root), + CodexAppServerWorkspaceMode::DirectProject, + ) + .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()) +} + +/// Direct home-page chat never binds Codex to a user project. It gets a +/// fresh isolated read-only workspace and a stable in-process thread so a +/// normal conversation can continue without creating a project, assets, a +/// preview, or a version record. +pub(crate) async fn direct_game_creator_home_codex_chat( + system_prompt: String, + user_prompt: String, +) -> Result { + let config = load_game_creator_app_config()?; + game_creator_codex_app_server_validate_llm_config(&config.llm) + .map_err(|error| error.to_string())?; + let snapshot = AgentRuntimeProviderRequestSnapshot { + project_id: "direct-codex-home".to_string(), + agent_id: "direct-codex-home".to_string(), + task_id: "direct-codex-home-chat".to_string(), + session_id: "direct-codex-home-session".to_string(), + run_id: "direct-codex-home-thread".to_string(), + source: "direct-codex-home-chat".to_string(), + goal_id: None, + goal_revision: 0, + goal_snapshot_fingerprint: String::new(), + applied_steer_cursor: 0, + request_kind: "direct-home-chat".to_string(), + request_slot: "direct-home-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(8_000); + let connection = CodexAppServerConnection::acquire_at_workspace( + &snapshot, + &config.llm, + None, + CodexAppServerWorkspaceMode::DirectHome, + ) + .await + .map_err(|error| error.to_string())?; connection .run_turn(&snapshot, &config.llm, request, None) .await @@ -2066,6 +2427,96 @@ mod tests { assert!(!direct_codex_user_prompt(&request).contains("AGC 系统规则")); } + #[test] + fn direct_home_mode_is_read_only_and_rejects_non_passive_items() { + assert!(CodexAppServerWorkspaceMode::DirectHome.uses_direct_conversation()); + assert!(!CodexAppServerWorkspaceMode::DirectHome.allows_workspace_writes()); + assert!(CodexAppServerWorkspaceMode::DirectHome.requires_passive_items()); + assert_eq!( + CodexAppServerWorkspaceMode::DirectHome.passive_item_boundary_name(), + "AGC 首页只读对话" + ); + assert!(!CodexAppServerWorkspaceMode::DirectProject.requires_passive_items()); + } + + #[test] + fn direct_home_protocol_params_and_interactions_are_fail_closed() { + let workspace = std::path::Path::new("direct-home-isolated-workspace"); + let thread = codex_app_server_thread_start_params( + "fixture-model", + workspace, + CodexAppServerWorkspaceMode::DirectHome, + "首页系统规则".to_string(), + true, + ); + assert_eq!(thread["approvalPolicy"], "never"); + assert_eq!(thread["sandbox"], "read-only"); + assert_eq!(thread["baseInstructions"], "首页系统规则"); + assert_eq!( + thread["modelProvider"], + GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID + ); + assert!(thread.get("sandboxPolicy").is_none()); + + let turn = codex_app_server_turn_start_params( + "home-thread", + "你好".to_string(), + "fixture-model", + workspace, + CodexAppServerWorkspaceMode::DirectHome, + ); + assert_eq!(turn["threadId"], "home-thread"); + assert_eq!(turn["approvalPolicy"], "never"); + assert!(turn.get("sandboxPolicy").is_none()); + + for method in [ + "item/fileChange/requestApproval", + "item/commandExecution/requestApproval", + "item/mcpToolCall/requestApproval", + "item/permissions/requestApproval", + ] { + let response = game_creator_codex_app_server_interaction_response( + workspace, + CodexAppServerWorkspaceMode::DirectHome, + 7, + method, + None, + ); + assert_eq!(response["id"], 7); + assert_eq!( + response.pointer("/error/code"), + Some(&serde_json::json!(-32601)) + ); + assert!(response["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("拒绝"))); + } + } + + #[test] + fn codex_app_server_pool_keeps_read_only_home_sessions_separate() { + let llm = test_llm(); + let snapshot = test_snapshot(); + let credential = CodexAppServerCredential::AppDataKey { + fingerprint: "fixture-credential".to_string(), + }; + let home = game_creator_codex_app_server_pool_key( + &llm, + "codex-cli 0.147.0", + &snapshot, + credential.fingerprint(), + CodexAppServerWorkspaceMode::DirectHome, + ); + let project = game_creator_codex_app_server_pool_key( + &llm, + "codex-cli 0.147.0", + &snapshot, + credential.fingerprint(), + CodexAppServerWorkspaceMode::DirectProject, + ); + assert_ne!(home, project); + } + #[test] fn codex_app_server_reasoning_effort_preserves_max_wire_value() { let request = LlmRunRequest::single_turn("系统", "任务") @@ -2248,6 +2699,52 @@ mod tests { assert!(!arguments.iter().any(|argument| argument == "view_image")); } + #[test] + fn direct_project_command_exposes_only_the_local_agc_tool_server() { + let mut project_command = tokio::process::Command::new("codex"); + configure_game_creator_codex_app_server_command_for_mode( + &mut project_command, + &test_llm(), + CodexAppServerWorkspaceMode::DirectProject, + None, + None, + ) + .expect("configure direct project app-server"); + let project_arguments = project_command + .as_std() + .get_args() + .map(|argument| argument.to_string_lossy().into_owned()) + .collect::>(); + let serialized = project_arguments.join("\n"); + assert!(serialized.contains("mcp_servers.agc_tools.command=")); + assert!(serialized.contains(DIRECT_TOOLS_MCP_MODE_FLAG)); + assert!(serialized.contains("mcp_servers.agc_tools.required=true")); + assert!( + serialized.contains("mcp_servers.agc_tools.default_tools_approval_mode=\"approve\"") + ); + assert!(serialized.contains("mcp_servers.agc_tools.tool_timeout_sec=1800")); + assert!(!serialized.contains("bearer_token_env_var")); + assert!(!serialized.contains("tnr_sk_")); + + let mut home_command = tokio::process::Command::new("codex"); + configure_game_creator_codex_app_server_command_for_mode( + &mut home_command, + &test_llm(), + CodexAppServerWorkspaceMode::DirectHome, + None, + None, + ) + .expect("configure direct home app-server"); + let home_arguments = home_command + .as_std() + .get_args() + .map(|argument| argument.to_string_lossy().into_owned()) + .collect::>() + .join("\n"); + assert!(home_arguments.contains("mcp_servers={}")); + assert!(!home_arguments.contains("agc_tools")); + } + #[cfg(windows)] #[test] fn codex_app_server_current_cli_accepts_configured_arguments() { @@ -2266,6 +2763,30 @@ mod tests { ); } + #[cfg(windows)] + #[test] + fn codex_app_server_current_cli_accepts_direct_project_tool_arguments() { + let executable = game_creator_codex_cli_executable_path().expect("Codex CLI executable"); + let mut command = std::process::Command::new(executable); + let mut configured = tokio::process::Command::new("codex"); + configure_game_creator_codex_app_server_command_for_mode( + &mut configured, + &test_llm(), + CodexAppServerWorkspaceMode::DirectProject, + None, + None, + ) + .expect("configure direct project app-server command"); + command.args(configured.as_std().get_args()); + command.arg("--help").stdin(Stdio::null()); + let output = command.output().expect("run Codex app-server help"); + assert!( + output.status.success(), + "direct project app-server arguments must be accepted: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + #[test] fn codex_app_server_pool_key_isolated_by_credentials_and_route() { let mut base = test_llm(); @@ -2282,13 +2803,15 @@ mod tests { &base, "codex-cli 0.147.0", &snapshot, - base_credential.fingerprint() + base_credential.fingerprint(), + CodexAppServerWorkspaceMode::ToolHost, ), game_creator_codex_app_server_pool_key( &changed, "codex-cli 0.147.0", &snapshot, - changed_credential.fingerprint() + changed_credential.fingerprint(), + CodexAppServerWorkspaceMode::ToolHost, ) ); changed = base.clone(); @@ -2298,13 +2821,15 @@ mod tests { &base, "codex-cli 0.147.0", &snapshot, - base_credential.fingerprint() + base_credential.fingerprint(), + CodexAppServerWorkspaceMode::ToolHost, ), game_creator_codex_app_server_pool_key( &changed, "codex-cli 0.147.0", &snapshot, - base_credential.fingerprint() + base_credential.fingerprint(), + CodexAppServerWorkspaceMode::ToolHost, ) ); assert_ne!( @@ -2312,13 +2837,15 @@ mod tests { &base, "codex-cli 0.147.0", &snapshot, - base_credential.fingerprint() + base_credential.fingerprint(), + CodexAppServerWorkspaceMode::ToolHost, ), game_creator_codex_app_server_pool_key( &base, "codex-cli 0.148.0", &snapshot, - base_credential.fingerprint() + base_credential.fingerprint(), + CodexAppServerWorkspaceMode::ToolHost, ) ); let mut other_node = snapshot.clone(); @@ -2328,13 +2855,15 @@ mod tests { &base, "codex-cli 0.147.0", &snapshot, - base_credential.fingerprint() + base_credential.fingerprint(), + CodexAppServerWorkspaceMode::ToolHost, ), game_creator_codex_app_server_pool_key( &base, "codex-cli 0.147.0", &other_node, - base_credential.fingerprint() + base_credential.fingerprint(), + CodexAppServerWorkspaceMode::ToolHost, ) ); } @@ -2365,13 +2894,15 @@ mod tests { &llm, "codex-cli 0.147.0", &snapshot, - first.fingerprint() + first.fingerprint(), + CodexAppServerWorkspaceMode::ToolHost, ), game_creator_codex_app_server_pool_key( &llm, "codex-cli 0.147.0", &snapshot, - second.fingerprint() + second.fingerprint(), + CodexAppServerWorkspaceMode::ToolHost, ) ); @@ -2467,6 +2998,67 @@ while IFS= read -r line; do :; done assert_eq!(connection.inner.threads.lock().await.len(), 1); } + #[cfg(unix)] + #[tokio::test] + async fn direct_home_app_server_uses_read_only_protocol_and_rejects_file_change_items() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let executable = temp.path().join("fake-codex-app-server-direct-home"); + std::fs::write( + &executable, + r#"#!/bin/sh +IFS= read -r initialize +case "$initialize" in *'"method":"initialize"'*) ;; *) exit 70 ;; esac +printf '%s\n' '{"id":1,"result":{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}' +IFS= read -r initialized +case "$initialized" in *'"method":"initialized"'*) ;; *) exit 71 ;; esac +IFS= read -r thread_start +case "$thread_start" in *'"method":"thread/start"'*'"approvalPolicy":"never"'*'"sandbox":"read-only"'*) ;; *) exit 72 ;; esac +case "$thread_start" in *'workspace-write'*|*'workspaceWrite'*|*'writableRoots'*) exit 73 ;; esac +printf '%s\n' '{"id":2,"result":{"thread":{"id":"home-thread"}}}' +IFS= read -r turn_start +case "$turn_start" in *'"method":"turn/start"'*'"approvalPolicy":"never"'*) ;; *) exit 74 ;; esac +case "$turn_start" in *'"sandboxPolicy"'*|*'workspaceWrite'*|*'writableRoots'*) exit 75 ;; esac +printf '%s\n' '{"id":3,"result":{"turn":{"id":"home-turn","items":[],"status":"inProgress"}}}' +printf '%s\n' '{"method":"item/completed","params":{"threadId":"home-thread","turnId":"home-turn","item":{"id":"item-1","type":"fileChange"}}}' +while IFS= read -r line; do :; done +"#, + ) + .expect("write fake app-server"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions).expect("chmod fake app-server"); + + let llm = test_llm(); + let credential = CodexAppServerCredential::AppDataKey { + fingerprint: "fixture-credential".to_string(), + }; + let connection = + CodexAppServerConnection::spawn_with_executable_and_credential_at_workspace( + &llm, + &credential, + executable.as_os_str(), + None, + CodexAppServerWorkspaceMode::DirectHome, + ) + .await + .expect("spawn read-only direct home app-server"); + let error = connection + .run_turn( + &test_snapshot(), + &llm, + LlmRunRequest::single_turn("首页系统规则", "你好"), + None, + ) + .await + .expect_err("home file changes must be rejected"); + assert!(matches!(error, platform_llm::LlmError::InvalidRequest(_))); + assert!(error.to_string().contains("首页只读对话边界")); + } + #[cfg(unix)] #[tokio::test] async fn codex_app_server_interrupts_turn_when_request_is_dropped_before_start_response() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs index 8155044a6..b656ff431 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs @@ -6,11 +6,31 @@ use sha2::{Digest, Sha256}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; const GAME_CREATOR_CODEX_CLI_EXECUTABLE: &str = "codex"; +const GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH: &str = "codex/win-x64/bin/codex.exe"; +const GAME_CREATOR_BUNDLED_CODEX_CLI_MANIFEST_RELATIVE_PATH: &str = "codex/win-x64/manifest.json"; +const GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES: [&str; 6] = [ + "bin/codex.exe", + "bin/codex-code-mode-host.exe", + "codex-path/rg.exe", + "codex-resources/codex-command-runner.exe", + "codex-resources/codex-windows-sandbox-setup.exe", + "codex-package.json", +]; const GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024; const GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES: usize = 4 * 1024 * 1024; const GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES: usize = 256 * 1024; +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct GameCreatorBundledCodexCliManifest { + schema_version: String, + platform: String, + version: String, + files: std::collections::BTreeMap, +} + fn game_creator_codex_cli_executable_candidates_for( + resource_dir: Option<&Path>, app_data: Option<&Path>, local_app_data: Option<&Path>, runtime_config_dir: Option<&Path>, @@ -19,6 +39,9 @@ fn game_creator_codex_cli_executable_candidates_for( let mut candidates = Vec::new(); #[cfg(windows)] { + if let Some(resource_dir) = resource_dir { + candidates.push(resource_dir.join(GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH)); + } fn append_native_npm_candidates(candidates: &mut Vec, npm_root: &Path) { let vendor_root = npm_root .join("node_modules") @@ -76,6 +99,7 @@ fn game_creator_codex_cli_executable_candidates_for( fn game_creator_codex_cli_executable_candidates() -> Vec { game_creator_codex_cli_executable_candidates_for( + game_creator_bundled_resource_dir().as_deref(), std::env::var_os("APPDATA").as_deref().map(Path::new), std::env::var_os("LOCALAPPDATA").as_deref().map(Path::new), game_creator_runtime_config_dir().as_deref(), @@ -83,6 +107,68 @@ fn game_creator_codex_cli_executable_candidates() -> Vec { ) } +fn game_creator_bundled_resource_dir() -> Option { + #[cfg(windows)] + { + std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(Path::to_path_buf)) + } + #[cfg(not(windows))] + { + None + } +} + +fn game_creator_bundled_codex_cli_path(resource_dir: Option<&Path>) -> Option { + resource_dir.map(|resource_dir| resource_dir.join(GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH)) +} + +fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result { + let bundle_root = executable + .parent() + .and_then(Path::parent) + .ok_or_else(|| "内置 Codex CLI 路径无效".to_string())?; + let manifest_path = bundle_root.join( + Path::new(GAME_CREATOR_BUNDLED_CODEX_CLI_MANIFEST_RELATIVE_PATH) + .file_name() + .expect("bundled Codex manifest file name"), + ); + let manifest = std::fs::read_to_string(&manifest_path) + .map_err(|_| "内置 Codex CLI 缺少完整性清单".to_string()) + .and_then(|value| { + serde_json::from_str::(&value) + .map_err(|_| "内置 Codex CLI 完整性清单无效".to_string()) + })?; + if manifest.schema_version != "genarrative-codex-sidecar.v2" + || manifest.platform != "win32-x64" + || manifest.version.trim().is_empty() + || GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES + .iter() + .any(|relative| { + manifest.files.get(*relative).map_or(true, |hash| { + hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + }) + { + return Err("内置 Codex CLI 完整性清单不受支持".to_string()); + } + for relative in GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES { + let path = bundle_root.join(relative); + let bytes = std::fs::read(&path).map_err(|_| { + format!( + "内置 Codex CLI 缺少必需组件:{}", + path.file_name().unwrap_or_default().to_string_lossy() + ) + })?; + let actual = format!("{:x}", Sha256::digest(bytes)); + if !actual.eq_ignore_ascii_case(manifest.files.get(relative).expect("validated hash")) { + return Err(format!("内置 Codex CLI 完整性校验失败:组件 {relative}")); + } + } + Ok(manifest.version) +} + fn game_creator_codex_cli_version_at(executable: &Path) -> Result { let mut command = crate::new_windows_background_std_command(executable); let output = command @@ -106,18 +192,34 @@ fn game_creator_codex_cli_version_at(executable: &Path) -> Result Result { let mut last_error = None; let mut seen = std::collections::HashSet::new(); + let bundled = + game_creator_bundled_codex_cli_path(game_creator_bundled_resource_dir().as_deref()); for candidate in game_creator_codex_cli_executable_candidates() { let identity = candidate.to_string_lossy().to_ascii_lowercase(); if !seen.insert(identity) { continue; } - match game_creator_codex_cli_version_at(&candidate) { + let bundled_version = (bundled.as_ref() == Some(&candidate)) + .then(|| validate_game_creator_bundled_codex_cli(&candidate)) + .transpose(); + match bundled_version.and_then(|expected_version| { + game_creator_codex_cli_version_at(&candidate).and_then(|actual_version| { + if expected_version + .as_deref() + .is_some_and(|expected| expected != actual_version) + { + Err("内置 Codex CLI 版本与完整性清单不一致".to_string()) + } else { + Ok(actual_version) + } + }) + }) { Ok(_) => return Ok(candidate), Err(error) => last_error = Some(error), } } Err(format!( - "Codex CLI 未安装或当前 Agent Runner 无法启动;已检查 PATH 和 npm 全局安装目录{}", + "Codex CLI 未安装或当前 Agent Runner 无法启动;已检查内置 sidecar、PATH 和 npm 全局安装目录{}", last_error .map(|error| format!("(最后错误:{error})")) .unwrap_or_default() @@ -690,6 +792,7 @@ mod tests { std::fs::create_dir_all(&path_dir).expect("path dir"); let candidates = game_creator_codex_cli_executable_candidates_for( + None, Some(&app_data), None, None, @@ -720,6 +823,7 @@ mod tests { None, None, None, + None, Some(npm_root.as_os_str()), ); assert_eq!(candidates[0], native); @@ -738,8 +842,13 @@ mod tests { std::fs::create_dir_all(native.parent().expect("native parent")) .expect("native target directory"); - let candidates = - game_creator_codex_cli_executable_candidates_for(None, None, Some(&config_dir), None); + let candidates = game_creator_codex_cli_executable_candidates_for( + None, + None, + None, + Some(&config_dir), + None, + ); assert_eq!(candidates[0], native); } @@ -754,6 +863,7 @@ mod tests { std::fs::create_dir_all(newer.parent().expect("newer parent")).expect("newer dir"); let candidates = game_creator_codex_cli_executable_candidates_for( + None, None, Some(&local_app_data), None, @@ -763,6 +873,98 @@ mod tests { assert_eq!(candidates[1], older); } + #[cfg(windows)] + #[test] + fn codex_cli_candidates_prefer_bundled_sidecar_before_npm_and_path() { + let temp = tempfile::tempdir().expect("temp dir"); + let resources = temp.path().join("resources"); + let bundled = resources.join(GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH); + std::fs::create_dir_all(bundled.parent().expect("bundled parent")).expect("bundled dir"); + let app_data = temp.path().join("app-data"); + let native = app_data + .join("npm/node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor") + .join("x86_64-pc-windows-msvc/bin/codex.exe"); + std::fs::create_dir_all(native.parent().expect("native parent")).expect("native dir"); + + let candidates = game_creator_codex_cli_executable_candidates_for( + Some(&resources), + Some(&app_data), + None, + None, + None, + ); + assert_eq!(candidates[0], bundled); + assert_eq!(candidates[1], native); + } + + #[cfg(windows)] + #[test] + fn bundled_codex_cli_requires_matching_manifest_hash() { + let temp = tempfile::tempdir().expect("temp dir"); + let executable = temp + .path() + .join(GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH); + std::fs::create_dir_all(executable.parent().expect("sidecar parent")).expect("sidecar dir"); + let bundle_root = executable + .parent() + .and_then(Path::parent) + .expect("bundle root"); + let mut hashes = std::collections::BTreeMap::new(); + for relative in GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES { + let path = bundle_root.join(relative); + std::fs::create_dir_all(path.parent().expect("component parent")) + .expect("component dir"); + let bytes = format!("trusted {relative}"); + std::fs::write(&path, bytes.as_bytes()).expect("component bytes"); + hashes.insert(relative, format!("{:x}", Sha256::digest(bytes.as_bytes()))); + } + let files = serde_json::to_string(&hashes).expect("component hashes"); + std::fs::write( + bundle_root.join("manifest.json"), + format!( + r#"{{"schemaVersion":"genarrative-codex-sidecar.v2","platform":"win32-x64","version":"codex-cli test","files":{files}}}"# + ), + ) + .expect("sidecar manifest"); + + assert_eq!( + validate_game_creator_bundled_codex_cli(&executable).expect("trusted sidecar"), + "codex-cli test" + ); + std::fs::write(&executable, b"modified sidecar").expect("tamper sidecar"); + assert!(validate_game_creator_bundled_codex_cli(&executable) + .expect_err("tampered sidecar must be rejected") + .contains("完整性校验失败")); + } + + #[cfg(windows)] + #[test] + fn bundled_codex_cli_rejects_a_manifest_without_code_mode_host() { + let temp = tempfile::tempdir().expect("temp dir"); + let executable = temp + .path() + .join(GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH); + std::fs::create_dir_all(executable.parent().expect("sidecar parent")).expect("sidecar dir"); + let bundle_root = executable + .parent() + .and_then(Path::parent) + .expect("bundle root"); + let bytes = b"codex only"; + std::fs::write(&executable, bytes).expect("codex bytes"); + let hash = format!("{:x}", Sha256::digest(bytes)); + std::fs::write( + bundle_root.join("manifest.json"), + format!( + r#"{{"schemaVersion":"genarrative-codex-sidecar.v2","platform":"win32-x64","version":"codex-cli test","files":{{"bin/codex.exe":"{hash}"}}}}"# + ), + ) + .expect("sidecar manifest"); + + assert!(validate_game_creator_bundled_codex_cli(&executable) + .expect_err("missing code-mode host must fail closed") + .contains("完整性清单不受支持")); + } + #[cfg(windows)] #[test] fn codex_cli_resolver_finds_current_native_install() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs new file mode 100644 index 000000000..7aa03a9e1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs @@ -0,0 +1,272 @@ +use axum::body::{to_bytes, Body}; +use axum::extract::State; +use axum::http::{HeaderMap, HeaderName, Request, Response, StatusCode}; +use axum::routing::any; +use axum::Router; +use futures::StreamExt; +use std::sync::Arc; + +pub(crate) const CODEX_PROVIDER_PROXY_PROTOCOL: &str = "genarrative-codex-provider-proxy.v1"; + +const CODEX_PROVIDER_PROXY_MAX_REQUEST_BYTES: usize = 32 * 1024 * 1024; + +#[derive(Clone)] +struct CodexProviderProxyState { + upstream_base_url: String, + client: reqwest::Client, +} + +pub(crate) struct CodexProviderProxy { + base_url: String, + task: tokio::task::JoinHandle<()>, +} + +impl CodexProviderProxy { + pub(crate) fn base_url(&self) -> &str { + &self.base_url + } +} + +impl Drop for CodexProviderProxy { + fn drop(&mut self) { + self.task.abort(); + } +} + +fn normalize_codex_provider_upstream(value: &str) -> Result { + let value = value.trim().trim_end_matches('/'); + let parsed = url::Url::parse(value).map_err(|_| "Codex Provider 上游地址无效".to_string())?; + if !matches!(parsed.scheme(), "http" | "https") + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err("Codex Provider 上游地址不满足安全边界".to_string()); + } + let host = parsed + .host_str() + .ok_or_else(|| "Codex Provider 上游地址缺少 host".to_string())?; + let loopback = host == "localhost" + || host == "127.0.0.1" + || host + .parse::() + .is_ok_and(|ip| ip.is_loopback()); + if parsed.scheme() == "http" && !loopback { + return Err("Codex Provider 非 loopback 上游必须使用 HTTPS".to_string()); + } + Ok(value.to_string()) +} + +fn proxy_error(status: StatusCode, message: &'static str) -> Response { + Response::builder() + .status(status) + .header("content-type", "text/plain; charset=utf-8") + .body(Body::from(message)) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + +fn is_hop_by_hop_header(name: &HeaderName) -> bool { + matches!( + name.as_str().to_ascii_lowercase().as_str(), + "connection" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + | "host" + | "content-length" + ) +} + +fn is_codex_account_limit_header(name: &HeaderName) -> bool { + name.as_str().to_ascii_lowercase().starts_with("x-codex-") +} + +async fn proxy_codex_provider_request( + State(state): State>, + request: Request, +) -> Response { + let has_bearer_authorization = request + .headers() + .get("authorization") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("Bearer ") && value.len() > "Bearer ".len()); + if !has_bearer_authorization { + return proxy_error(StatusCode::FORBIDDEN, "provider proxy access denied"); + } + let direct_debug = std::env::var_os("GENARRATIVE_AGC_DIRECT_DEBUG").is_some(); + if direct_debug { + eprintln!( + "agent.direct_codex.provider_proxy.request method={} path={}", + request.method(), + request.uri().path(), + ); + } + let path_and_query = request + .uri() + .path_and_query() + .map(|value| value.as_str()) + .unwrap_or(request.uri().path()); + if request.method() != axum::http::Method::POST || request.uri().path() != "/responses" { + return proxy_error(StatusCode::NOT_FOUND, "provider proxy route not found"); + } + let upstream_url = format!("{}{path_and_query}", state.upstream_base_url); + let (parts, body) = request.into_parts(); + let body = match to_bytes(body, CODEX_PROVIDER_PROXY_MAX_REQUEST_BYTES).await { + Ok(body) => body, + Err(_) => return proxy_error(StatusCode::PAYLOAD_TOO_LARGE, "provider request too large"), + }; + let mut headers = HeaderMap::new(); + for (name, value) in &parts.headers { + if !is_hop_by_hop_header(name) { + headers.append(name.clone(), value.clone()); + } + } + let upstream = match state + .client + .request(parts.method, upstream_url) + .headers(headers) + .body(body) + .send() + .await + { + Ok(response) => response, + Err(_) => return proxy_error(StatusCode::BAD_GATEWAY, "provider upstream unavailable"), + }; + let status = upstream.status(); + let upstream_headers = upstream.headers().clone(); + let stream = upstream + .bytes_stream() + .map(|chunk| chunk.map_err(|_| std::io::Error::other("provider response stream failed"))); + let mut response = Response::builder().status(status); + if let Some(headers) = response.headers_mut() { + let mut stripped_limit_headers = 0_usize; + for (name, value) in &upstream_headers { + if is_codex_account_limit_header(name) { + stripped_limit_headers = stripped_limit_headers.saturating_add(1); + } else if !is_hop_by_hop_header(name) { + headers.append(name.clone(), value.clone()); + } + } + if direct_debug { + eprintln!( + "agent.direct_codex.provider_proxy.response status={} strippedCodexHeaders={}", + status.as_u16(), + stripped_limit_headers, + ); + } + } + response + .body(Body::from_stream(stream)) + .unwrap_or_else(|_| proxy_error(StatusCode::BAD_GATEWAY, "provider response invalid")) +} + +pub(crate) async fn start_codex_provider_proxy( + upstream_base_url: &str, +) -> Result { + let upstream_base_url = normalize_codex_provider_upstream(upstream_base_url)?; + let client = reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(15)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("创建 Codex Provider 代理客户端失败:{error}"))?; + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .map_err(|error| format!("启动 Codex Provider loopback 代理失败:{error}"))?; + let address = listener + .local_addr() + .map_err(|error| format!("读取 Codex Provider 代理地址失败:{error}"))?; + let state = Arc::new(CodexProviderProxyState { + upstream_base_url, + client, + }); + let app = Router::new() + .fallback(any(proxy_codex_provider_request)) + .with_state(state); + let task = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + Ok(CodexProviderProxy { + base_url: format!("http://127.0.0.1:{}", address.port()), + task, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::routing::post; + use std::sync::atomic::{AtomicUsize, Ordering}; + + async fn fake_upstream( + State(calls): State>, + headers: HeaderMap, + body: axum::body::Bytes, + ) -> Response { + calls.fetch_add(1, Ordering::SeqCst); + assert_eq!( + headers + .get("authorization") + .and_then(|value| value.to_str().ok()), + Some("Bearer fixture-provider-key") + ); + Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/json") + .header("x-codex-active-limit", "premium") + .header("x-codex-credits-unlimited", "false") + .body(Body::from(body)) + .expect("fake response") + } + + #[tokio::test] + async fn loopback_proxy_strips_false_codex_limit_headers_and_requires_bearer() { + let calls = Arc::new(AtomicUsize::new(0)); + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind upstream"); + let address = listener.local_addr().expect("upstream address"); + let app = Router::new() + .route("/responses", post(fake_upstream)) + .with_state(Arc::clone(&calls)); + let upstream_task = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + let proxy = start_codex_provider_proxy(&format!("http://127.0.0.1:{}", address.port())) + .await + .expect("start provider proxy"); + let client = reqwest::Client::new(); + let denied = client + .post(format!("{}/responses", proxy.base_url())) + .body("{}") + .send() + .await + .expect("denied response"); + assert_eq!(denied.status(), StatusCode::FORBIDDEN); + assert_eq!(calls.load(Ordering::SeqCst), 0); + + let accepted = client + .post(format!("{}/responses", proxy.base_url())) + .header("authorization", "Bearer fixture-provider-key") + .body("{\"input\":\"ok\"}") + .send() + .await + .expect("accepted response"); + assert_eq!(accepted.status(), StatusCode::OK); + assert!(accepted.headers().get("x-codex-active-limit").is_none()); + assert!(accepted + .headers() + .get("x-codex-credits-unlimited") + .is_none()); + assert_eq!( + accepted.text().await.expect("response body"), + "{\"input\":\"ok\"}" + ); + assert_eq!(calls.load(Ordering::SeqCst), 1); + upstream_task.abort(); + } +} 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 index 053c13cfb..aff061f47 100644 --- 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 @@ -1,4 +1,5 @@ use super::*; +use std::future::Future; use std::io::Write; use std::path::Path; @@ -6,10 +7,10 @@ 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 MAX_DIRECT_HOME_ATTACHMENTS: usize = 8; +const MAX_DIRECT_HOME_ATTACHMENT_NAME_CHARS: usize = 160; +const MAX_DIRECT_HOME_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96; +const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前工作目录是唯一项目根;`game/index.html` 是可运行入口,`game/style.css` 与 `game/game.js` 是标准游戏代码,`assets/` 保存项目媒体,`.agent/` 是客户端维护的持久状态。Codex 可以按用户意图读写游戏源码,但不得手改 `.agent` 中的 manifest、revision、版本、来源身份、生成账本或试玩回执。需要平台美术或真实浏览器试玩时,只调用审核 Skill 声明的 `agc_tools` 工具;工具不可用或失败时如实报告。文件真实变化后由客户端确定性投影资源和版本。"; 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"; @@ -41,6 +42,7 @@ const DIRECT_CODEX_GAME_OUTPUTS: [(&str, &str, &str); 3] = [ ("game/style.css", "game-style", "text/css"), ("game/game.js", "game-script", "text/javascript"), ]; +const DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER: &str = "[[AGC_CREATE_PROJECT]]"; fn direct_existing_game_sources_exist(root: &Path) -> bool { DIRECT_CODEX_GAME_OUTPUTS @@ -67,8 +69,6 @@ fn direct_prompt_requests_fresh_art_generation(prompt: &str) -> bool { .iter() .any(|marker| prompt.contains(marker)) } -const DIRECT_CODEX_BUILTIN_SKILL: &str = "技能 direct-game-creation:先检查当前 game/ 入口和已有玩法,优先最小修改;需要生成新游戏时直接创建可运行的 game/index.html、style.css 和 game.js,保证 Canvas、输入、主要玩法、重开和移动端布局。游戏必须适配 16:9 横屏逻辑视口,页面本身不得出现横向或纵向滚动条。客户端会在本回合开始前通过陶泥儿平台准备已登记的真实美术资源。优先使用规范图、场景背景和可用图集/切片;如果平台只返回完整透明图集或某些切片缺失,不要伪造切片,也不要因此停止生成。至少让一个真实平台素材进入实际渲染路径,并通过真实浏览器试玩检查画面和交互。不要用 emoji、纯色块或 CSS 图形冒充平台核心美术,也不要只在回复中提及图片路径。不要自行调用生图 API、不要伪造图片或修改 .agent/manifest.json;客户端会负责平台调用与资源登记。完成文件修改后运行可用的静态检查并通过 AGC 本地预览确认真实页面;执行环境没有命令或浏览器工具时必须如实说明。收到客户端试玩证据后,必须读取实际文件并按证据继续修复;最终回复结构化列出检查文件、真实启动或试玩动作、desktop/mobile 观察、平台素材实际使用、已修复问题和剩余风险。只使用当前工作区文件变更,不启动任何 Supervisor、child、harness 或隐藏返工。"; -const DIRECT_TAONIER_ART_WORKFLOW: &str = "陶泥儿游戏创作工作流:客户端先准备并登记平台美术资源。项目结构固定为 `assets/` 存放已登记的真实媒体、`game/index.html` 为入口、`game/style.css` 与 `game/game.js` 为游戏代码、`memory/` 存放玩法记忆、`.agent/` 为客户端维护的项目状态。规范图用于统一视觉风格;背景、完整图集和平台返回的切片可按实际可用性接入。不得手写或修改 `.agent/manifest.json`,不得创建伪造 PNG、data URL、emoji 或纯 CSS 图形来代替真实平台素材。至少一个已登记平台图片必须被游戏源码引用并在页面中实际加载。"; #[derive(Clone, Debug, Eq, PartialEq)] struct DirectTaonierArtAssetIdentity { @@ -804,7 +804,7 @@ async fn generate_direct_taonier_art_asset_at( Ok(()) } -async fn ensure_direct_taonier_art_package_at( +pub(crate) async fn ensure_direct_taonier_art_package_at( root: &Path, prompt: &str, ) -> Result, String> { @@ -1161,7 +1161,22 @@ fn direct_browser_evidence_prompt( ) } -async fn run_direct_browser_evidence_at( +pub(crate) fn render_direct_browser_tool_evidence( + root: &Path, + evidence: &BrowserValidationResult, + attempt: usize, +) -> String { + let completion_error = direct_game_output_completion_error(root); + direct_browser_evidence_prompt( + root, + Some(evidence), + attempt, + true, + completion_error.as_deref(), + ) +} + +pub(crate) async fn run_direct_browser_evidence_at( root: &Path, attempt: usize, ) -> Result { @@ -1356,19 +1371,10 @@ fn render_direct_browser_acceptance_report( ) } -fn sync_direct_codex_project_outputs_at( +fn sync_direct_codex_project_file_projection_at( root: &Path, previous_output_fingerprint: Option<&str>, ) -> Result<(), String> { - if !direct_taonier_art_base_is_valid(root) { - return Err( - "游戏代码已生成,但缺少可用且已登记的陶泥儿平台美术资源;不会将项目标记为完成" - .to_string(), - ); - } - if let Some(error) = direct_game_output_completion_error(root) { - return Err(error); - } let current_output_fingerprint = direct_codex_output_fingerprint(root); let outputs_changed = previous_output_fingerprint .map(|previous| previous != current_output_fingerprint) @@ -1423,6 +1429,22 @@ fn sync_direct_codex_project_outputs_at( Ok(()) } +fn sync_direct_codex_project_outputs_at( + root: &Path, + previous_output_fingerprint: Option<&str>, +) -> Result<(), String> { + if !direct_taonier_art_base_is_valid(root) { + return Err( + "游戏代码已生成,但缺少可用且已登记的陶泥儿平台美术资源;不会将项目标记为完成" + .to_string(), + ); + } + if let Some(error) = direct_game_output_completion_error(root) { + return Err(error); + } + sync_direct_codex_project_file_projection_at(root, previous_output_fingerprint) +} + 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()?; @@ -1435,14 +1457,6 @@ 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() { @@ -1500,47 +1514,14 @@ fn read_markdown_files(root: &Path, relative_dir: &str) -> Vec<(String, String)> .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 { +pub(crate) fn build_direct_codex_system_prompt(root: &Path) -> Result { + let skill_index = render_agc_skill_pack_index()?; let mut sections = vec![ - "你是 Genarrative 的唯一执行智能体。用户聊天内容会直接发送给你;不要等待 Supervisor、专业 Agent、harness 或宿主规划器。你负责理解需求、检查工程、修改工作区、运行必要验证,并用简洁中文报告真实结果。优先使用 Codex 原生文件变更能力直接写入文件;不要用 shell 命令拼接或重定向来创建文件。".to_string(), + "你是 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(), + "提示词与技能:系统上下文只给出审核 Skill 索引,不预装完整正文。根据当前用户意图选择最少的相关 Skill,并通过 Codex 原生 Skill 机制按需读取其 `SKILL.md` 和一层直接引用。Skill 是执行约束,不是新的 Agent;工具未出现或调用失败时必须如实说明,不能用文字假装获得能力。不要启动 Supervisor、child、harness 或外部编排。".to_string(), + DIRECT_AGC_ENGINEERING_GUIDANCE.to_string(), + skill_index, ]; 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"] { @@ -1552,44 +1533,154 @@ pub(crate) fn build_direct_codex_system_prompt(root: &Path) -> String { )); } } - for relative in [ - "AGENTS.md", - "README.md", - "CONTEXT.md", - "docs/【协作规范】Agent工作入口与执行准则-2026-06-22.md", - "docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md", - ] { + for relative in ["AGENTS.md", "README.md", "CONTEXT.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 + Ok(sections .join("\n") .chars() .take(MAX_DIRECT_SYSTEM_PROMPT_CHARS) - .collect() + .collect()) +} + +/// A home conversation deliberately has no project workspace. Keep its +/// instructions short, explicit, and free of project paths so a greeting or +/// general question cannot become an accidental game-generation request. +pub(crate) fn build_direct_codex_home_system_prompt() -> String { + [ + "你是陶泥儿。用户的这条首页消息正文会原样直接发送给你;如有附件,正文后会单独附带仅含文件名、媒体类型和大小的附件说明。你是唯一的对话执行主体,不要等待或启动 Supervisor、专业 Agent、harness 或宿主规划器。", + "当前没有打开任何用户项目。普通对话(例如问候、日期、知识问答)请直接正常回答。不要创建、读取或修改项目文件,不要生成素材,不要启动预览、试玩、发布、版本登记或任何付费外部动作。", + "如果用户明确希望开始创作游戏,且需求已经足以开始,请把回复的第一行严格写为 [[AGC_CREATE_PROJECT]],随后用简洁中文说明将创建项目并继续创作。这个标记是唯一允许的受限创建项目请求;绝不能为普通问答、素材查看、知识问答或含糊想法输出它。不要自行创建目录。只有用户已在项目工作台中打开工作区后,才能在该项目对话中执行文件修改或游戏验证。", + "不要输出或请求 API Key、Token、Cookie、auth.json、.env、用户路径或内部实现细节。遇到当前无项目无法执行的请求,请如实说明边界和下一步。", + ] + .join("\n") +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectCodexHomeAttachment { + name: String, + media_type: String, + size: u64, +} + +#[derive(Clone, Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectCodexHomeReply { + reply: String, + request_project_creation: bool, +} + +fn parse_direct_codex_home_reply(reply: String) -> DirectCodexHomeReply { + // The marker is a narrow protocol boundary, not a substring convention. + // In particular, leading whitespace, an explanatory prefix, or a marker + // glued to other text must stay an ordinary reply rather than creating a + // user-visible project directory. + let (request_project_creation, reply) = match reply.split_once('\n') { + Some((first_line, remainder)) + if first_line.strip_suffix('\r') == Some(DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER) + || first_line == DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER => + { + (true, remainder.trim()) + } + None if reply == DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER => (true, ""), + _ => (false, reply.trim()), + }; + DirectCodexHomeReply { + reply: if reply.is_empty() { + "陶泥儿已收到你的想法。".to_string() + } else { + reply.to_string() + }, + request_project_creation, + } +} + +fn direct_codex_home_attachment_name(value: &str) -> String { + let basename = value.rsplit(['/', '\\']).next().unwrap_or_default().trim(); + let sanitized = basename + .chars() + .filter(|character| !character.is_control()) + .take(MAX_DIRECT_HOME_ATTACHMENT_NAME_CHARS) + .collect::(); + if sanitized.is_empty() { + "未命名附件".to_string() + } else { + sanitized + } +} + +fn direct_codex_home_attachment_media_type(value: &str) -> String { + let value = value.trim(); + if value.is_empty() + || value.chars().any(|character| { + !(character.is_ascii_alphanumeric() || matches!(character, '/' | '+' | '-' | '.' | '_')) + }) + { + "application/octet-stream".to_string() + } else { + value + .chars() + .take(MAX_DIRECT_HOME_ATTACHMENT_MEDIA_TYPE_CHARS) + .collect() + } +} + +fn render_direct_codex_home_user_prompt( + prompt: &str, + attachments: &[DirectCodexHomeAttachment], +) -> Result { + let prompt = prompt.trim(); + if prompt.is_empty() && attachments.is_empty() { + return Err("聊天内容不能为空".to_string()); + } + if attachments.is_empty() { + return Ok(prompt.to_string()); + } + + let mut sections = Vec::new(); + if !prompt.is_empty() { + sections.push(prompt.to_string()); + sections.push(String::new()); + } + sections.push( + "[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]".to_string(), + ); + for attachment in attachments.iter().take(MAX_DIRECT_HOME_ATTACHMENTS) { + sections.push(format!( + "- {};类型:{};大小:{} 字节", + direct_codex_home_attachment_name(&attachment.name), + direct_codex_home_attachment_media_type(&attachment.media_type), + attachment.size, + )); + } + if attachments.len() > MAX_DIRECT_HOME_ATTACHMENTS { + sections.push(format!( + "- 另有 {} 个附件未展开", + attachments.len() - MAX_DIRECT_HOME_ATTACHMENTS + )); + } + Ok(sections.join("\n")) +} + +pub(crate) async fn run_direct_game_creator_home_turn( + prompt: &str, + attachments: &[DirectCodexHomeAttachment], +) -> Result { + let user_prompt = render_direct_codex_home_user_prompt(prompt, attachments)?; + direct_game_creator_home_codex_chat(build_direct_codex_home_system_prompt(), user_prompt) + .await + .map(parse_direct_codex_home_reply) + .map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320)) } pub(crate) async fn run_direct_game_creator_turn_at( @@ -1605,7 +1696,7 @@ pub(crate) async fn run_direct_game_creator_turn_at( if prompt.is_empty() { return Err("聊天内容不能为空".to_string()); } - emit_direct_game_creator_progress(root, "request.accepted", "已收到需求,正在准备智能创作"); + emit_direct_game_creator_progress(root, "request.accepted", "已发送消息,正在等待陶泥儿回复"); match run_direct_game_creator_turn_inner(root, prompt).await { Ok(reply) => Ok(reply), Err(failure) => Err(record_direct_codex_turn_failure(root, failure)), @@ -1616,32 +1707,52 @@ async fn run_direct_game_creator_turn_inner( root: &Path, prompt: &str, ) -> Result { - if direct_existing_game_sources_exist(root) - && !direct_prompt_requests_fresh_art_generation(prompt) - { - emit_direct_game_creator_progress( - root, - "request.edit-existing-game", - "已识别为已有游戏的继续编辑,正在直接修改并试玩", - ); - return run_direct_game_creator_turn_with_private_editor_credentials(root, prompt, false) - .await; - } - if direct_taonier_art_base_is_valid(root) { - return run_direct_game_creator_turn_with_private_editor_credentials(root, prompt, true) - .await; - } - let external_editor_credentials = ensure_private_external_editor_api_credentials() - .await - .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::ArtPreparation, error) - })?; - with_external_editor_api_credentials(external_editor_credentials, async { - run_direct_game_creator_turn_with_private_editor_credentials(root, prompt, true).await + run_direct_game_creator_turn_with(root, prompt, |system_prompt, user_prompt| async move { + direct_game_creator_codex_chat_at(root, system_prompt, user_prompt).await }) .await } +/// Default product path: one user message becomes one turn on the same +/// project-bound Codex app-server thread. The client does not classify the +/// intent or perform hidden art, preview, repair, or another LLM workflow. If +/// Codex actually changes the canonical game files, the client performs only +/// deterministic resource/version projection so its own workspace reflects +/// the files now on disk. +async fn run_direct_game_creator_turn_with( + root: &Path, + prompt: &str, + run_turn: F, +) -> Result +where + F: FnOnce(String, String) -> Fut, + Fut: Future>, +{ + emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息"); + let previous_output_fingerprint = direct_codex_output_fingerprint(root); + let system_prompt = build_direct_codex_system_prompt(root).map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + })?; + let reply = run_turn(system_prompt, prompt.to_string()) + .await + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + })?; + if direct_codex_output_fingerprint(root) != previous_output_fingerprint { + emit_direct_game_creator_progress( + root, + "project.sync", + "检测到游戏文件更新,正在同步客户端资源", + ); + sync_direct_codex_project_file_projection_at(root, Some(&previous_output_fingerprint)) + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::VersionRegistration, error) + })?; + } + Ok(reply) +} + +#[allow(dead_code)] async fn run_direct_game_creator_turn_with_private_editor_credentials( root: &Path, prompt: &str, @@ -1655,7 +1766,9 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( })?; } let previous_output_fingerprint = direct_codex_output_fingerprint(root); - let mut system_prompt = build_direct_codex_system_prompt(root); + let mut system_prompt = build_direct_codex_system_prompt(root).map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + })?; if prepare_art { system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实美术资源。请读取当前 `assets/` 与 `game/` 文件,选择实际存在且适合玩法的素材接入;不要假设四切片一定存在,也不要伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;你必须依据证据继续修复,并在最终回复中给出检查文件、试玩动作、双视口观察、平台素材实际使用、已修复问题和剩余风险。"); emit_direct_game_creator_progress(root, "codex.start", "美术素材已准备,正在生成游戏代码"); @@ -1842,25 +1955,36 @@ pub(crate) async fn chat_with_game_creator_direct_codex( run_direct_game_creator_turn_at(Path::new(project_path.trim()), &prompt).await } +#[tauri::command] +pub(crate) async fn chat_with_game_creator_home_direct_codex( + prompt: String, + attachments: Option>, +) -> Result { + run_direct_game_creator_home_turn(&prompt, attachments.as_deref().unwrap_or_default()).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(".")); + let prompt = + build_direct_codex_system_prompt(Path::new(".")).expect("build direct system prompt"); assert!(prompt.contains("唯一执行智能体")); assert!(prompt.contains("不要等待 Supervisor")); assert!(prompt.contains("提示词与技能")); } #[test] - fn system_prompt_loads_project_prompts_and_skills_without_secrets() { + fn system_prompt_loads_project_prompts_but_never_project_skill_bodies() { let root = tempfile::tempdir().expect("temp dir"); let prompt_dir = root.path().join(".codex/prompts"); let skill_dir = root.path().join(".codex/skills/demo"); + let payment_skill_dir = root.path().join(".agents/skills/wechatpay-basic-payment"); std::fs::create_dir_all(&prompt_dir).expect("prompt dir"); std::fs::create_dir_all(&skill_dir).expect("skill dir"); + std::fs::create_dir_all(&payment_skill_dir).expect("payment skill dir"); std::fs::write( prompt_dir.join("game.md"), "做一个 canvas 游戏\napi_key=secret", @@ -1871,11 +1995,20 @@ mod tests { "技能要求:先验证入口\nBearer secret", ) .expect("skill"); - let prompt = build_direct_codex_system_prompt(root.path()); + std::fs::write( + payment_skill_dir.join("SKILL.md"), + "payment-skill-must-never-enter", + ) + .expect("payment skill"); + let prompt = + build_direct_codex_system_prompt(root.path()).expect("build direct system prompt"); 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("AGC 工程合同")); + assert!(prompt.contains("审核 AGC Skill 索引")); + assert!(prompt.contains("taonier-art-assets")); + assert!(!prompt.contains("技能要求:先验证入口")); + assert!(!prompt.contains("payment-skill-must-never-enter")); assert!(!prompt.contains("secret")); } @@ -1889,7 +2022,8 @@ mod tests { ) .expect("game script"); - let prompt = build_direct_codex_system_prompt(root.path()); + let prompt = + build_direct_codex_system_prompt(root.path()).expect("build direct system prompt"); assert!(prompt.contains("current project file: game/game.js")); assert!(prompt.contains("directLayoutMarker")); @@ -1902,20 +2036,263 @@ mod tests { 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()); + let prompt = + build_direct_codex_system_prompt(root.path()).expect("build direct system prompt"); 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("规范图用于统一视觉风格")); - assert!(prompt.contains("完整图集和平台返回的切片可按实际可用性接入")); - assert!(prompt.contains("不得手写或修改 `.agent/manifest.json`")); - assert!(prompt.contains("最终回复结构化列出检查文件")); - assert!(prompt.contains("desktop/mobile 观察")); + fn home_prompt_has_no_project_or_side_effect_path_and_declares_the_only_creation_marker() { + let prompt = build_direct_codex_home_system_prompt(); + + assert!(prompt.contains("当前没有打开任何用户项目")); + assert!(prompt.contains("不要创建、读取或修改项目文件")); + assert!(prompt.contains("不要生成素材")); + assert!(prompt.contains("不要启动预览、试玩、发布、版本登记")); + assert!(prompt.contains(DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER)); + assert!(!prompt.contains("assets/")); + assert!(!prompt.contains("game/index.html")); + } + + #[test] + fn home_user_prompt_preserves_the_message_and_adds_only_bounded_attachment_metadata() { + let attachments = vec![DirectCodexHomeAttachment { + name: r"C:\Users\secret\角色参考.png".to_string(), + media_type: "image/png\nBearer secret".to_string(), + size: 3, + }]; + + let prompt = render_direct_codex_home_user_prompt(" 先看看这个附件 ", &attachments) + .expect("home prompt"); + + assert!(prompt.starts_with("先看看这个附件\n\n[首页附件说明")); + assert!(prompt.contains("角色参考.png")); + assert!(prompt.contains("类型:application/octet-stream")); + assert!(prompt.contains("大小:3 字节")); + assert!(!prompt.contains("C:\\Users")); + assert!(!prompt.contains("\nBearer secret")); + } + + #[test] + fn home_user_prompt_keeps_plain_messages_plain_and_caps_attachment_count() { + assert_eq!( + render_direct_codex_home_user_prompt("你好", &[]).expect("plain prompt"), + "你好" + ); + let attachments = (0..MAX_DIRECT_HOME_ATTACHMENTS + 2) + .map(|index| DirectCodexHomeAttachment { + name: format!("asset-{index}.png"), + media_type: "image/png".to_string(), + size: index as u64, + }) + .collect::>(); + let prompt = render_direct_codex_home_user_prompt("看看素材", &attachments) + .expect("bounded attachments"); + assert!(prompt.contains("asset-7.png")); + assert!(!prompt.contains("asset-8.png")); + assert!(prompt.contains("另有 2 个附件未展开")); + assert!(render_direct_codex_home_user_prompt("", &attachments).is_ok()); + assert!(render_direct_codex_home_user_prompt("", &[]).is_err()); + } + + #[test] + fn home_create_marker_is_accepted_only_as_the_first_reply_token() { + let requested = parse_direct_codex_home_reply(format!( + "{DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER}\n请先选择一个项目文件夹。" + )); + assert!(requested.request_project_creation); + assert_eq!(requested.reply, "请先选择一个项目文件夹。"); + + let windows_line_ending = parse_direct_codex_home_reply(format!( + "{DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER}\r\n请先选择一个项目文件夹。" + )); + assert!(windows_line_ending.request_project_creation); + assert_eq!(windows_line_ending.reply, "请先选择一个项目文件夹。"); + + let marker_only = + parse_direct_codex_home_reply(DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER.to_string()); + assert!(marker_only.request_project_creation); + assert_eq!(marker_only.reply, "陶泥儿已收到你的想法。"); + + for reply in [ + format!("说明里提到 {DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER}"), + format!("先回答问题\n{DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER}"), + format!(" {DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER}\n请先选择项目"), + format!("{DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER}请先选择项目"), + format!("{DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER}\r请先选择项目"), + ] { + let ordinary = parse_direct_codex_home_reply(reply.clone()); + assert!(!ordinary.request_project_creation, "reply={reply}"); + } + } + + #[test] + fn direct_prompt_exposes_only_the_reviewed_skill_index() { + let root = tempfile::tempdir().expect("temp dir"); + let prompt = + build_direct_codex_system_prompt(root.path()).expect("build direct system prompt"); + for skill in AGC_SKILL_PACK_EXPECTED_NAMES { + assert!(prompt.contains(skill), "missing skill {skill}"); + } + assert!(prompt.contains("agc_tools.taonier_prepare_game_art")); + assert!(prompt.contains("agc_tools.agc_browser_playtest")); + assert!( + prompt.contains("普通对话(例如问候、日期或项目无关问题)直接正常回答且不触碰工作区") + ); + assert!(!prompt.contains("Use real platform assets only")); + assert!(!prompt.contains("postprocess-failed-source-preserved")); + assert!(!prompt.contains("genarrative-play-type-integration")); + assert!(!prompt.contains("wechatpay")); + } + + #[tokio::test] + async fn default_direct_turn_forwards_a_greeting_without_client_generation_workflow() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-chat-only", "纯对话测试") + .expect("init project"); + std::fs::write( + root.path().join("game/index.html"), + "before", + ) + .expect("index"); + std::fs::write(root.path().join("game/style.css"), "body { color: black; }") + .expect("style"); + std::fs::write(root.path().join("game/game.js"), "console.info('before');") + .expect("script"); + let manifest_before = + std::fs::read(root.path().join(".agent/manifest.json")).expect("manifest before"); + let index_before = + std::fs::read(root.path().join("game/index.html")).expect("index before"); + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let captured = std::sync::Arc::clone(&seen); + + let reply = + run_direct_game_creator_turn_with(root.path(), "你好", move |system, prompt| { + let captured = std::sync::Arc::clone(&captured); + async move { + captured + .lock() + .expect("capture direct handoff") + .push((system, prompt)); + Ok("你好!我是陶泥儿。".to_string()) + } + }) + .await + .expect("greeting reply"); + + assert_eq!(reply, "你好!我是陶泥儿。"); + let calls = seen.lock().expect("read direct handoff"); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].1, "你好"); + assert!(calls[0].0.contains("普通对话")); + assert!(calls[0].0.contains("不触碰工作区")); + assert_eq!( + std::fs::read(root.path().join("game/index.html")).expect("index after"), + index_before + ); + assert_eq!( + std::fs::read(root.path().join(".agent/manifest.json")).expect("manifest after"), + manifest_before + ); + assert!( + !root + .path() + .join(".agent/runtime/direct-codex-browser-validation") + .exists(), + "ordinary chat must not start client browser validation" + ); + } + + #[tokio::test] + async fn default_direct_turn_forwards_a_date_question_without_client_art_or_version_side_effects( + ) { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-date-only", "日期对话测试") + .expect("init project"); + let manifest_before = + std::fs::read(root.path().join(".agent/manifest.json")).expect("manifest before"); + let reply = run_direct_game_creator_turn_with( + root.path(), + "今天多少号?", + |system, prompt| async move { + assert!(system.contains("普通对话")); + assert!(system.contains("不触碰工作区")); + assert_eq!(prompt, "今天多少号?"); + Ok("今天是测试日期。".to_string()) + }, + ) + .await + .expect("date reply"); + + assert_eq!(reply, "今天是测试日期。"); + assert_eq!( + std::fs::read(root.path().join(".agent/manifest.json")).expect("manifest after"), + manifest_before + ); + assert!( + !root.path().join("assets/art-spec.png").exists() + && !root + .path() + .join("assets/direct-game-background.png") + .exists() + && !root.path().join("assets/art-spritesheet.png").exists(), + "ordinary chat must not prepare a TaoNier art package" + ); + } + + #[tokio::test] + async fn default_direct_turn_projects_changed_game_files_without_supervisor_or_art() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-file-projection", "文件投影测试") + .expect("init project"); + let write_root = root.path().to_path_buf(); + + let reply = run_direct_game_creator_turn_with( + root.path(), + "创建一个纯色方块游戏", + move |system, prompt| { + let write_root = write_root.clone(); + async move { + assert!(system.contains("唯一执行智能体")); + assert_eq!(prompt, "创建一个纯色方块游戏"); + std::fs::write( + write_root.join("game/index.html"), + "", + ) + .expect("index"); + std::fs::write(write_root.join("game/style.css"), "canvas { background: red; }") + .expect("style"); + std::fs::write(write_root.join("game/game.js"), "requestAnimationFrame(() => {});") + .expect("script"); + Ok("已写入三个游戏文件。".to_string()) + } + }, + ) + .await + .expect("direct file projection"); + + assert_eq!(reply, "已写入三个游戏文件。"); + let manifest = + read_manifest(&root.path().join(".agent/manifest.json")).expect("projected manifest"); + for (local_path, kind, media_type) in DIRECT_CODEX_GAME_OUTPUTS { + assert!(manifest.assets.iter().any(|asset| { + asset.local_path == local_path + && asset.kind == kind + && asset.media_type == media_type + && asset.source == direct_codex_generated_source() + })); + } + assert_eq!(manifest.versions.len(), 1); + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == "code-prototype") + .map(|task| task.status.clone()), + Some(GameCreationAppTaskStatus::Completed) + ); + assert!(!root.path().join("assets/art-spec.png").exists()); + assert!(!root.path().join(".agent/runtime/runtimes").exists()); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs new file mode 100644 index 000000000..43fb9a519 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -0,0 +1,237 @@ +use super::*; +use axum::extract::{DefaultBodyLimit, State}; +use axum::routing::post; +use axum::{Json, Router}; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +pub(crate) const DIRECT_TOOL_BRIDGE_PROTOCOL: &str = "genarrative-agc-tool-bridge.v1"; +pub(crate) const DIRECT_TOOL_BRIDGE_URL_ENV: &str = "GENARRATIVE_AGC_TOOL_BRIDGE_URL"; + +const DIRECT_TOOL_BRIDGE_MAX_REQUEST_BYTES: usize = 16 * 1024; +const DIRECT_TOOL_BRIDGE_MAX_ART_BRIEF_CHARS: usize = 4_000; +const DIRECT_TOOL_BRIDGE_MAX_IMAGE_BYTES: u64 = 6 * 1024 * 1024; + +#[derive(Clone)] +struct DirectToolBridgeState { + root: PathBuf, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DirectToolBridgeRequest { + tool: String, + #[serde(default)] + arguments: Value, +} + +pub(crate) struct DirectToolBridge { + url: String, + task: tokio::task::JoinHandle<()>, +} + +impl DirectToolBridge { + pub(crate) fn url(&self) -> &str { + &self.url + } +} + +impl Drop for DirectToolBridge { + fn drop(&mut self) { + self.task.abort(); + } +} + +fn bridge_tool_result(text: String, images: Vec, is_error: bool) -> Value { + let mut content = vec![json!({ "type": "text", "text": text })]; + content.extend(images.into_iter().map(|data| { + json!({ + "type": "image", + "data": data, + "mimeType": "image/png" + }) + })); + json!({ "content": content, "isError": is_error }) +} + +fn bridge_bounded_string( + arguments: &Value, + field: &str, + max_chars: usize, +) -> Result { + let value = arguments + .get(field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("工具参数 {field} 不能为空"))?; + if value.chars().count() > max_chars || value.chars().any(char::is_control) { + return Err(format!("工具参数 {field} 超出安全边界")); + } + Ok(value.to_string()) +} + +fn bridge_attempt(arguments: &Value) -> Result { + let attempt = arguments + .get("attempt") + .and_then(Value::as_u64) + .ok_or_else(|| "工具参数 attempt 必须是 1 到 3 的整数".to_string())?; + if !(1..=3).contains(&attempt) { + return Err("工具参数 attempt 必须是 1 到 3 的整数".to_string()); + } + Ok(attempt as usize) +} + +fn bridge_png_content(root: &Path, path: &Path) -> Result { + let root = root + .canonicalize() + .map_err(|_| "工具桥项目根无法安全解析".to_string())?; + let path = path + .canonicalize() + .map_err(|_| "工具桥图片不存在".to_string())?; + if !path.starts_with(&root) { + return Err("工具桥图片越出当前项目边界".to_string()); + } + let metadata = + std::fs::symlink_metadata(&path).map_err(|_| "读取工具桥图片失败".to_string())?; + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() > DIRECT_TOOL_BRIDGE_MAX_IMAGE_BYTES + { + return Err("工具桥图片不满足普通文件或大小边界".to_string()); + } + let bytes = std::fs::read(&path).map_err(|_| "读取工具桥图片失败".to_string())?; + if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + return Err("工具桥图片不是有效 PNG".to_string()); + } + Ok(BASE64_STANDARD.encode(bytes)) +} + +async fn bridge_prepare_game_art(root: &Path, arguments: &Value) -> Value { + let result = async { + enforce_project_permission_policy(root, "canvas.asset_generate")?; + let brief = + bridge_bounded_string(arguments, "brief", DIRECT_TOOL_BRIDGE_MAX_ART_BRIEF_CHARS)?; + let credentials = ensure_private_external_editor_api_credentials().await?; + let paths = with_external_editor_api_credentials( + credentials, + ensure_direct_taonier_art_package_at(root, &brief), + ) + .await?; + let images = paths + .iter() + .take(3) + .filter_map(|relative| bridge_png_content(root, &root.join(relative)).ok()) + .collect::>(); + Ok::<_, String>((paths, images)) + } + .await; + match result { + Ok((paths, images)) => bridge_tool_result( + json!({ + "status": "completed", + "assetPaths": paths, + "derivativePolicy": "完整可信图集可在缺少固定切片时继续使用;不得伪造缺失切片", + "next": "读取这些相对路径并把合适素材接入核心游戏画面" + }) + .to_string(), + images, + false, + ), + Err(error) => bridge_tool_result( + redact_agent_runtime_error(root, &error, 480), + Vec::new(), + true, + ), + } +} + +async fn bridge_browser_playtest(root: &Path, arguments: &Value) -> Value { + let result = async { + enforce_project_permission_policy(root, "game.run_local")?; + let attempt = bridge_attempt(arguments)?; + let evidence = run_direct_browser_evidence_at(root, attempt).await?; + let summary = render_direct_browser_tool_evidence(root, &evidence, attempt); + let images = evidence + .viewport_results + .iter() + .take(2) + .filter_map(|viewport| bridge_png_content(root, &viewport.screenshot_path).ok()) + .collect::>(); + Ok::<_, String>((summary, images)) + } + .await; + match result { + Ok((summary, images)) => bridge_tool_result(summary, images, false), + Err(error) => bridge_tool_result( + redact_agent_runtime_error(root, &error, 480), + Vec::new(), + true, + ), + } +} + +async fn handle_direct_tool_bridge( + State(state): State>, + Json(request): Json, +) -> Json { + let result = match request.tool.as_str() { + "taonier_prepare_game_art" => { + bridge_prepare_game_art(&state.root, &request.arguments).await + } + "agc_browser_playtest" => bridge_browser_playtest(&state.root, &request.arguments).await, + _ => bridge_tool_result("未知或未审核的客户端工具".to_string(), Vec::new(), true), + }; + Json(result) +} + +pub(crate) async fn start_direct_tool_bridge(root: &Path) -> Result { + if !root.is_absolute() || !root.is_dir() || !root.join(".agent/manifest.json").is_file() { + return Err("AGC 工具桥只能绑定已初始化的绝对项目目录".to_string()); + } + let root = root + .canonicalize() + .map_err(|_| "AGC 工具桥项目目录无法安全解析".to_string())?; + let route = format!("/tool-{}", uuid::Uuid::new_v4().simple()); + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .map_err(|error| format!("启动 AGC loopback 工具桥失败:{error}"))?; + let address = listener + .local_addr() + .map_err(|error| format!("读取 AGC 工具桥地址失败:{error}"))?; + let state = Arc::new(DirectToolBridgeState { root }); + let app = Router::new() + .route(&route, post(handle_direct_tool_bridge)) + .layer(DefaultBodyLimit::max(DIRECT_TOOL_BRIDGE_MAX_REQUEST_BYTES)) + .with_state(state); + let task = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + Ok(DirectToolBridge { + url: format!("http://127.0.0.1:{}{route}", address.port()), + task, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bridge_argument_bounds_are_deterministic() { + assert_eq!( + bridge_attempt(&json!({ "attempt": 2 })).expect("valid attempt"), + 2 + ); + assert!(bridge_attempt(&json!({ "attempt": 4 })).is_err()); + assert!(bridge_bounded_string( + &json!({ "brief": "" }), + "brief", + DIRECT_TOOL_BRIDGE_MAX_ART_BRIEF_CHARS + ) + .is_err()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs new file mode 100644 index 000000000..9a953c542 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -0,0 +1,450 @@ +use super::*; +use serde_json::{json, Value}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; + +pub(crate) const DIRECT_TOOLS_MCP_MODE_FLAG: &str = "--agc-direct-tools-mcp"; +const DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES: usize = 1024 * 1024; +const DIRECT_TOOLS_MCP_MAX_ART_BRIEF_CHARS: usize = 4_000; +const DIRECT_TOOLS_MCP_MAX_BRIDGE_RESPONSE_BYTES: usize = 32 * 1024 * 1024; + +pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool { + args == [DIRECT_TOOLS_MCP_MODE_FLAG] +} + +pub(crate) fn run_direct_tools_mcp_if_requested(args: &[String]) -> Option { + if !direct_tools_mcp_mode_requested(args) { + return None; + } + let runtime = match tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(_) => return Some(1), + }; + Some(match runtime.block_on(run_direct_tools_mcp_stdio()) { + Ok(()) => 0, + Err(_) => 1, + }) +} + +fn direct_tools_mcp_specs() -> Value { + json!({ + "tools": [ + { + "name": "agc_read_skill_resource", + "description": "按需读取审核 AGC Skill 直接引用的一层 Markdown 文件。只能访问内置清单声明的 Skill 与 references 路径,不能读取项目、宿主或凭据文件。", + "inputSchema": { + "type": "object", + "properties": { + "skillName": { + "type": "string", + "enum": AGC_SKILL_PACK_EXPECTED_NAMES + }, + "relativePath": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "required": ["skillName", "relativePath"], + "additionalProperties": false + } + }, + { + "name": "taonier_prepare_game_art", + "description": "创建或安全恢复当前 AGC 项目的陶泥儿标准游戏美术包。付费提交、幂等键、operation 恢复、来源校验、下载解码和登记均由客户端确定性执行。仅在用户意图确实需要新美术时调用。", + "inputSchema": { + "type": "object", + "properties": { + "brief": { + "type": "string", + "minLength": 1, + "maxLength": DIRECT_TOOLS_MCP_MAX_ART_BRIEF_CHARS, + "description": "面向当前游戏的简洁视觉需求,不含凭据或宿主路径" + } + }, + "required": ["brief"], + "additionalProperties": false + } + }, + { + "name": "agc_browser_playtest", + "description": "使用当前客户端的受限 Chromium 对当前游戏执行真实 desktop/mobile 双视口运行、截图、控制台、网络、Canvas/WebGL 和有限交互探针。", + "inputSchema": { + "type": "object", + "properties": { + "attempt": { + "type": "integer", + "minimum": 1, + "maximum": 3, + "description": "本次用户请求内的试玩次数;只有真实修复后才递增" + } + }, + "required": ["attempt"], + "additionalProperties": false + } + } + ] + }) +} + +fn call_agc_read_skill_resource(arguments: &Value) -> Value { + let result = (|| { + let skill_name = bounded_tool_string(arguments, "skillName", 64)?; + let relative_path = bounded_tool_string(arguments, "relativePath", 256)?; + read_agc_skill_resource(&skill_name, &relative_path) + })(); + match result { + Ok(text) => mcp_tool_result(text, Vec::new(), false), + Err(error) => mcp_tool_result(error, Vec::new(), true), + } +} + +fn mcp_success(id: Value, result: Value) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "result": result }) +} + +fn mcp_error(id: Value, code: i64, message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": code, "message": message } + }) +} + +fn mcp_tool_result(text: String, images: Vec, is_error: bool) -> Value { + let mut content = vec![json!({ "type": "text", "text": text })]; + content.extend(images.into_iter().map(|data| { + json!({ + "type": "image", + "data": data, + "mimeType": "image/png" + }) + })); + json!({ "content": content, "isError": is_error }) +} + +fn validate_direct_tools_project_root(root: &Path) -> Result { + if !root.is_absolute() || !root.is_dir() { + return Err("当前 MCP 工作目录不是有效的绝对项目目录".to_string()); + } + let root = root + .canonicalize() + .map_err(|_| "当前 MCP 项目目录无法安全解析".to_string())?; + if !root.join(".agent/manifest.json").is_file() { + return Err("当前 MCP 工作目录不是已初始化的陶泥儿项目".to_string()); + } + enforce_project_permission_policy(&root, "conversation.read")?; + Ok(root) +} + +fn bounded_tool_string(arguments: &Value, field: &str, max_chars: usize) -> Result { + let value = arguments + .get(field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("工具参数 {field} 不能为空"))?; + if value.chars().count() > max_chars || value.chars().any(char::is_control) { + return Err(format!("工具参数 {field} 超出安全边界")); + } + Ok(value.to_string()) +} + +fn tool_attempt(arguments: &Value) -> Result { + let attempt = arguments + .get("attempt") + .and_then(Value::as_u64) + .ok_or_else(|| "工具参数 attempt 必须是 1 到 3 的整数".to_string())?; + if !(1..=3).contains(&attempt) { + return Err("工具参数 attempt 必须是 1 到 3 的整数".to_string()); + } + Ok(attempt as usize) +} + +fn direct_tool_bridge_url() -> Result { + let value = std::env::var(DIRECT_TOOL_BRIDGE_URL_ENV) + .map_err(|_| "客户端受控工具桥未配置".to_string())?; + let parsed = url::Url::parse(&value).map_err(|_| "客户端受控工具桥地址无效".to_string())?; + let host = parsed + .host_str() + .ok_or_else(|| "客户端受控工具桥缺少 host".to_string())?; + let loopback = host == "localhost" + || host == "127.0.0.1" + || host + .parse::() + .is_ok_and(|ip| ip.is_loopback()); + if parsed.scheme() != "http" + || !loopback + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + || !parsed.path().starts_with("/tool-") + { + return Err("客户端受控工具桥不在可信 loopback 边界内".to_string()); + } + Ok(value) +} + +async fn call_client_tool_bridge(tool: &str, arguments: &Value) -> Value { + let result = async { + let url = direct_tool_bridge_url()?; + let client = reqwest::Client::builder() + .no_proxy() + .timeout(std::time::Duration::from_secs(1_800)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| "创建客户端工具桥连接失败".to_string())?; + let response = client + .post(url) + .json(&json!({ "tool": tool, "arguments": arguments })) + .send() + .await + .map_err(|_| "连接客户端受控工具桥失败".to_string())?; + if !response.status().is_success() { + return Err(format!( + "客户端受控工具桥返回 HTTP {}", + response.status().as_u16() + )); + } + if response + .content_length() + .is_some_and(|length| length > DIRECT_TOOLS_MCP_MAX_BRIDGE_RESPONSE_BYTES as u64) + { + return Err("客户端受控工具桥响应超过大小上限".to_string()); + } + let bytes = response + .bytes() + .await + .map_err(|_| "读取客户端受控工具桥响应失败".to_string())?; + if bytes.len() > DIRECT_TOOLS_MCP_MAX_BRIDGE_RESPONSE_BYTES { + return Err("客户端受控工具桥响应超过大小上限".to_string()); + } + let value = serde_json::from_slice::(&bytes) + .map_err(|_| "客户端受控工具桥响应格式无效".to_string())?; + if !value.is_object() + || !value.get("content").is_some_and(Value::is_array) + || !value.get("isError").is_some_and(Value::is_boolean) + { + return Err("客户端受控工具桥响应缺少 MCP 完成包".to_string()); + } + Ok::<_, String>(value) + } + .await; + result.unwrap_or_else(|error| mcp_tool_result(error, Vec::new(), true)) +} + +async fn call_taonier_prepare_game_art(arguments: &Value) -> Value { + if let Err(error) = + bounded_tool_string(arguments, "brief", DIRECT_TOOLS_MCP_MAX_ART_BRIEF_CHARS) + { + return mcp_tool_result(error, Vec::new(), true); + } + call_client_tool_bridge("taonier_prepare_game_art", arguments).await +} + +async fn call_agc_browser_playtest(arguments: &Value) -> Value { + if let Err(error) = tool_attempt(arguments) { + return mcp_tool_result(error, Vec::new(), true); + } + call_client_tool_bridge("agc_browser_playtest", arguments).await +} + +async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option { + let id = request.get("id").cloned(); + let method = request.get("method").and_then(Value::as_str)?; + if id.is_none() { + return None; + } + let id = id.unwrap_or(Value::Null); + match method { + "initialize" => { + let requested_protocol = request + .pointer("/params/protocolVersion") + .and_then(Value::as_str) + .unwrap_or("2025-06-18"); + Some(mcp_success( + id, + json!({ + "protocolVersion": requested_protocol, + "capabilities": { "tools": { "listChanged": false } }, + "serverInfo": { + "name": "genarrative-agc-tools", + "version": env!("CARGO_PKG_VERSION") + } + }), + )) + } + "ping" => Some(mcp_success(id, json!({}))), + "tools/list" => Some(mcp_success(id, direct_tools_mcp_specs())), + "tools/call" => { + let tool = request + .pointer("/params/name") + .and_then(Value::as_str) + .unwrap_or_default(); + let arguments = request + .pointer("/params/arguments") + .cloned() + .unwrap_or_else(|| json!({})); + let result = match tool { + "agc_read_skill_resource" => call_agc_read_skill_resource(&arguments), + "taonier_prepare_game_art" => call_taonier_prepare_game_art(&arguments).await, + "agc_browser_playtest" => call_agc_browser_playtest(&arguments).await, + _ => mcp_tool_result("未知或未审核的 AGC 工具".to_string(), Vec::new(), true), + }; + Some(mcp_success(id, result)) + } + _ => Some(mcp_error(id, -32601, "Method not found")), + } +} + +fn read_bounded_line(reader: &mut R) -> Result>, String> { + let mut bytes = Vec::new(); + loop { + let available = reader + .fill_buf() + .map_err(|_| "读取 MCP 请求失败".to_string())?; + if available.is_empty() { + return Ok((!bytes.is_empty()).then_some(bytes)); + } + let boundary = available + .iter() + .position(|byte| *byte == b'\n') + .map(|index| index + 1) + .unwrap_or(available.len()); + if bytes.len().saturating_add(boundary) > DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES { + return Err("MCP 请求超过大小上限".to_string()); + } + let completed = available[..boundary].ends_with(b"\n"); + bytes.extend_from_slice(&available[..boundary]); + reader.consume(boundary); + if completed { + while matches!(bytes.last(), Some(b'\n' | b'\r')) { + bytes.pop(); + } + return Ok(Some(bytes)); + } + } +} + +async fn run_direct_tools_mcp_stdio() -> Result<(), String> { + let root = validate_direct_tools_project_root( + &std::env::current_dir().map_err(|_| "读取 MCP 工作目录失败".to_string())?, + )?; + let stdin = std::io::stdin(); + let mut reader = BufReader::new(stdin.lock()); + let stdout = std::io::stdout(); + let mut writer = stdout.lock(); + while let Some(line) = read_bounded_line(&mut reader)? { + if line.is_empty() { + continue; + } + let request = match serde_json::from_slice::(&line) { + Ok(request) => request, + Err(_) => { + let response = mcp_error(Value::Null, -32700, "Parse error"); + serde_json::to_writer(&mut writer, &response) + .map_err(|_| "写入 MCP 响应失败".to_string())?; + writer + .write_all(b"\n") + .and_then(|_| writer.flush()) + .map_err(|_| "写入 MCP 响应失败".to_string())?; + continue; + } + }; + if let Some(response) = handle_direct_tools_mcp_request(&root, request).await { + serde_json::to_writer(&mut writer, &response) + .map_err(|_| "写入 MCP 响应失败".to_string())?; + writer + .write_all(b"\n") + .and_then(|_| writer.flush()) + .map_err(|_| "写入 MCP 响应失败".to_string())?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn direct_tools_mode_requires_the_exact_private_flag() { + assert!(direct_tools_mcp_mode_requested(&[ + DIRECT_TOOLS_MCP_MODE_FLAG.to_string() + ])); + assert!(!direct_tools_mcp_mode_requested(&[])); + assert!(!direct_tools_mcp_mode_requested(&[ + DIRECT_TOOLS_MCP_MODE_FLAG.to_string(), + "extra".to_string() + ])); + } + + #[test] + fn tool_catalog_contains_only_the_two_reviewed_tools() { + let specs = direct_tools_mcp_specs(); + let names = specs["tools"] + .as_array() + .expect("tool array") + .iter() + .filter_map(|tool| tool["name"].as_str()) + .collect::>(); + assert_eq!( + names, + vec![ + "agc_read_skill_resource", + "taonier_prepare_game_art", + "agc_browser_playtest" + ] + ); + let serialized = specs.to_string(); + assert!(!serialized.contains("spacetimedb")); + assert!(!serialized.contains("wechatpay")); + assert!(!serialized.contains("apiKey")); + } + + #[test] + fn bounded_line_reader_rejects_oversized_requests() { + let payload = vec![b'x'; DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES + 1]; + let mut reader = BufReader::new(payload.as_slice()); + assert!(read_bounded_line(&mut reader).is_err()); + } + + #[tokio::test] + async fn unknown_tool_returns_a_model_visible_error() { + let root = tempfile::tempdir().expect("project root"); + let response = handle_direct_tools_mcp_request( + root.path(), + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { "name": "unreviewed_tool", "arguments": {} } + }), + ) + .await + .expect("response"); + assert_eq!(response["result"]["isError"], true); + assert!(response.to_string().contains("未知或未审核")); + } + + #[test] + fn skill_resource_tool_rejects_unreviewed_paths() { + let accepted = call_agc_read_skill_resource(&json!({ + "skillName": "agc-project-structure", + "relativePath": "references/structure-contract.md" + })); + assert_eq!(accepted["isError"], false); + assert!(accepted.to_string().contains("drive prefix")); + + let denied = call_agc_read_skill_resource(&json!({ + "skillName": "agc-project-structure", + "relativePath": "../../auth.json" + })); + assert_eq!(denied["isError"], true); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs new file mode 100644 index 000000000..d92414f90 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs @@ -0,0 +1,340 @@ +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::path::{Component, Path}; + +const AGC_SKILL_PACK_MANIFEST: &[u8] = include_bytes!("../../resources/agc-skills/manifest.json"); +const AGC_SKILL_PACK_SCHEMA_VERSION: &str = "agc-skill-pack.v1"; +pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 5] = [ + "agc-browser-playtest", + "agc-client-projection", + "agc-project-structure", + "agc-web-game-development", + "taonier-art-assets", +]; + +const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 15] = [ + ( + "agc-browser-playtest/SKILL.md", + include_bytes!("../../resources/agc-skills/agc-browser-playtest/SKILL.md"), + ), + ( + "agc-browser-playtest/agents/openai.yaml", + include_bytes!("../../resources/agc-skills/agc-browser-playtest/agents/openai.yaml"), + ), + ( + "agc-browser-playtest/references/browser-evidence-contract.md", + include_bytes!( + "../../resources/agc-skills/agc-browser-playtest/references/browser-evidence-contract.md" + ), + ), + ( + "agc-client-projection/SKILL.md", + include_bytes!("../../resources/agc-skills/agc-client-projection/SKILL.md"), + ), + ( + "agc-client-projection/agents/openai.yaml", + include_bytes!("../../resources/agc-skills/agc-client-projection/agents/openai.yaml"), + ), + ( + "agc-client-projection/references/projection-contract.md", + include_bytes!( + "../../resources/agc-skills/agc-client-projection/references/projection-contract.md" + ), + ), + ( + "agc-project-structure/SKILL.md", + include_bytes!("../../resources/agc-skills/agc-project-structure/SKILL.md"), + ), + ( + "agc-project-structure/agents/openai.yaml", + include_bytes!("../../resources/agc-skills/agc-project-structure/agents/openai.yaml"), + ), + ( + "agc-project-structure/references/structure-contract.md", + include_bytes!( + "../../resources/agc-skills/agc-project-structure/references/structure-contract.md" + ), + ), + ( + "agc-web-game-development/SKILL.md", + include_bytes!("../../resources/agc-skills/agc-web-game-development/SKILL.md"), + ), + ( + "agc-web-game-development/agents/openai.yaml", + include_bytes!("../../resources/agc-skills/agc-web-game-development/agents/openai.yaml"), + ), + ( + "agc-web-game-development/references/game-quality-checklist.md", + include_bytes!( + "../../resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md" + ), + ), + ( + "taonier-art-assets/SKILL.md", + include_bytes!("../../resources/agc-skills/taonier-art-assets/SKILL.md"), + ), + ( + "taonier-art-assets/agents/openai.yaml", + include_bytes!("../../resources/agc-skills/taonier-art-assets/agents/openai.yaml"), + ), + ( + "taonier-art-assets/references/platform-art-contract.md", + include_bytes!( + "../../resources/agc-skills/taonier-art-assets/references/platform-art-contract.md" + ), + ), +]; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AgcSkillPackManifest { + schema_version: String, + version: String, + skills: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AgcSkillManifestEntry { + name: String, + purpose: String, + triggers: Vec, + required_tools: Vec, + files: Vec, + sha256: String, +} + +fn is_safe_skill_relative_path(value: &str) -> bool { + let path = Path::new(value); + !value.is_empty() + && !path.is_absolute() + && path + .components() + .all(|component| matches!(component, Component::Normal(part) if !part.is_empty())) +} + +fn bundled_skill_file(path: &str) -> Option<&'static [u8]> { + AGC_SKILL_PACK_FILES + .iter() + .find_map(|(candidate, bytes)| (*candidate == path).then_some(*bytes)) +} + +fn skill_content_fingerprint(entry: &AgcSkillManifestEntry) -> Result { + let mut files = entry.files.clone(); + files.sort(); + let mut digest = Sha256::new(); + for relative in files { + if !is_safe_skill_relative_path(&relative) { + return Err(format!("AGC Skill {} 包含不安全路径", entry.name)); + } + let bundled_path = format!("{}/{}", entry.name, relative.replace('\\', "/")); + let bytes = bundled_skill_file(&bundled_path) + .ok_or_else(|| format!("AGC Skill {} 缺少审核文件 {relative}", entry.name))?; + digest.update(relative.as_bytes()); + digest.update([0]); + digest.update(bytes); + digest.update([0]); + } + Ok(format!("{:x}", digest.finalize())) +} + +fn validated_skill_pack_manifest() -> Result { + let manifest = serde_json::from_slice::(AGC_SKILL_PACK_MANIFEST) + .map_err(|error| format!("解析内置 AGC Skill 清单失败:{error}"))?; + if manifest.schema_version != AGC_SKILL_PACK_SCHEMA_VERSION { + return Err("内置 AGC Skill 清单版本不受支持".to_string()); + } + if manifest.version.trim().is_empty() { + return Err("内置 AGC Skill 清单缺少版本".to_string()); + } + let names = manifest + .skills + .iter() + .map(|entry| entry.name.as_str()) + .collect::>(); + if names != AGC_SKILL_PACK_EXPECTED_NAMES.into_iter().collect() { + return Err("内置 AGC Skill 清单不等于审核白名单".to_string()); + } + let mut declared_files = BTreeSet::new(); + for entry in &manifest.skills { + if entry.purpose.trim().is_empty() + || entry.triggers.is_empty() + || entry + .triggers + .iter() + .any(|trigger| trigger.trim().is_empty()) + || !entry.files.iter().any(|path| path == "SKILL.md") + || entry.files.is_empty() + { + return Err(format!("内置 AGC Skill {} 元数据不完整", entry.name)); + } + if entry.files.iter().collect::>().len() != entry.files.len() { + return Err(format!("内置 AGC Skill {} 文件声明重复", entry.name)); + } + let actual = skill_content_fingerprint(entry)?; + if actual != entry.sha256 { + return Err(format!("内置 AGC Skill {} 内容指纹不匹配", entry.name)); + } + for relative in &entry.files { + declared_files.insert(format!("{}/{}", entry.name, relative.replace('\\', "/"))); + } + } + let bundled_files = AGC_SKILL_PACK_FILES + .iter() + .map(|(path, _)| (*path).to_string()) + .collect::>(); + if declared_files != bundled_files { + return Err("内置 AGC Skill 文件集合与审核清单不一致".to_string()); + } + Ok(manifest) +} + +pub(crate) fn agc_skill_pack_fingerprint() -> Result { + validated_skill_pack_manifest()?; + Ok(format!("{:x}", Sha256::digest(AGC_SKILL_PACK_MANIFEST))) +} + +pub(crate) fn render_agc_skill_pack_index() -> Result { + let manifest = validated_skill_pack_manifest()?; + let mut lines = vec![format!( + "审核 AGC Skill 索引({};完整正文由 Codex 命中后按需读取):", + manifest.version + )]; + for entry in manifest.skills { + lines.push(format!( + "- {} | 用途:{} | 触发:{} | 工具:{} | sha256:{}", + entry.name, + entry.purpose, + entry.triggers.join("、"), + if entry.required_tools.is_empty() { + "无".to_string() + } else { + entry.required_tools.join("、") + }, + &entry.sha256[..12], + )); + } + Ok(lines.join("\n")) +} + +pub(crate) fn read_agc_skill_resource( + skill_name: &str, + relative_path: &str, +) -> Result { + let manifest = validated_skill_pack_manifest()?; + let relative_path = relative_path.replace('\\', "/"); + if !is_safe_skill_relative_path(&relative_path) + || !relative_path.starts_with("references/") + || Path::new(&relative_path) + .extension() + .and_then(|value| value.to_str()) + != Some("md") + { + return Err("AGC Skill 引用路径不在审核的一层 Markdown 边界内".to_string()); + } + let entry = manifest + .skills + .iter() + .find(|entry| entry.name == skill_name) + .ok_or_else(|| "AGC Skill 不在审核白名单内".to_string())?; + if !entry.files.iter().any(|file| file == &relative_path) { + return Err("AGC Skill 引用文件未在审核清单中声明".to_string()); + } + let bundled_path = format!("{skill_name}/{relative_path}"); + let bytes = bundled_skill_file(&bundled_path) + .ok_or_else(|| "AGC Skill 引用文件未编译进客户端".to_string())?; + let text = + std::str::from_utf8(bytes).map_err(|_| "AGC Skill 引用文件不是 UTF-8 文本".to_string())?; + Ok(text.to_string()) +} + +pub(crate) fn install_agc_skill_pack(isolated_os_home: &Path) -> Result { + let manifest = validated_skill_pack_manifest()?; + let skills_root = isolated_os_home.join(".agents").join("skills"); + std::fs::create_dir_all(&skills_root) + .map_err(|error| format!("创建隔离 AGC Skill 目录失败:{error}"))?; + for entry in &manifest.skills { + for relative in &entry.files { + let bundled_path = format!("{}/{}", entry.name, relative.replace('\\', "/")); + let bytes = bundled_skill_file(&bundled_path) + .ok_or_else(|| format!("内置 AGC Skill 缺少审核文件 {bundled_path}"))?; + let target = skills_root.join(&entry.name).join(relative); + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("创建隔离 AGC Skill 子目录失败:{error}"))?; + } + std::fs::write(&target, bytes) + .map_err(|error| format!("安装隔离 AGC Skill 文件失败:{error}"))?; + } + } + agc_skill_pack_fingerprint() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bundled_skill_pack_is_exactly_the_five_reviewed_skills() { + let manifest = validated_skill_pack_manifest().expect("validated manifest"); + assert_eq!(manifest.schema_version, "agc-skill-pack.v1"); + assert_eq!(manifest.skills.len(), 5); + assert!(manifest.skills.iter().all(|entry| entry.sha256.len() == 64)); + let serialized = serde_json::to_string( + &manifest + .skills + .iter() + .map(|entry| &entry.name) + .collect::>(), + ) + .expect("serialize names"); + for denied in [ + "spacetimedb", + "wechatpay", + "genarrative-play-type-integration", + ] { + assert!(!serialized.contains(denied)); + } + } + + #[test] + fn skill_pack_installs_under_isolated_home_without_full_body_in_index() { + let home = tempfile::tempdir().expect("temporary home"); + let fingerprint = install_agc_skill_pack(home.path()).expect("install skill pack"); + assert_eq!(fingerprint.len(), 64); + assert!(home + .path() + .join(".agents/skills/taonier-art-assets/SKILL.md") + .is_file()); + let index = render_agc_skill_pack_index().expect("render index"); + assert!(index.contains("taonier-art-assets")); + assert!(index.contains("agc_tools.taonier_prepare_game_art")); + assert!(!index.contains("Use real platform assets only")); + assert!(!index.contains("postprocess-failed-source-preserved")); + } + + #[test] + fn skill_relative_paths_reject_escape_and_absolute_forms() { + assert!(is_safe_skill_relative_path("references/contract.md")); + assert!(!is_safe_skill_relative_path("../SKILL.md")); + assert!(!is_safe_skill_relative_path("/tmp/SKILL.md")); + assert!(!is_safe_skill_relative_path(r"C:\\temp\\SKILL.md")); + } + + #[test] + fn skill_resource_reader_allows_only_manifest_references() { + let text = + read_agc_skill_resource("agc-project-structure", "references/structure-contract.md") + .expect("read reviewed reference"); + assert!(text.contains("drive prefix")); + assert!(read_agc_skill_resource("agc-project-structure", "SKILL.md").is_err()); + assert!( + read_agc_skill_resource("agc-project-structure", "references/../../private.md") + .is_err() + ); + assert!( + read_agc_skill_resource("wechatpay-basic-payment", "references/anything.md").is_err() + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index e6cc8ef2b..c6df5a8a7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -153,6 +153,53 @@ fn private_external_editor_api_credentials_from_file_at( })) } +fn unique_private_external_editor_api_credentials_at( + directory: &Path, +) -> Result, String> { + let metadata = match fs::symlink_metadata(directory) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!("读取本机陶泥儿开发者 Key 目录失败:{error}")); + } + }; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("本机陶泥儿开发者 Key 目录必须是普通目录".to_string()); + } + #[cfg(windows)] + secure_windows_game_creator_path_for_current_user(directory, true, false)?; + let mut candidates = fs::read_dir(directory) + .map_err(|error| format!("读取本机陶泥儿开发者 Key 目录失败:{error}"))? + .filter_map(Result::ok) + .filter_map(|entry| { + let name = entry.file_name(); + let name = name.to_str()?; + let fingerprint = name + .strip_prefix(PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX)? + .strip_suffix(".json")?; + (fingerprint.len() == 16 && fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit())) + .then_some(entry.path()) + }) + .collect::>(); + candidates.sort(); + if candidates.len() > 8 { + return Err("本机陶泥儿开发者 Key 候选过多,已拒绝自动选择".to_string()); + } + let mut credentials = Vec::new(); + for path in candidates { + if let Some(candidate) = private_external_editor_api_credentials_from_file_at(&path)? { + credentials.push(candidate); + } + } + match credentials.len() { + 0 => Ok(None), + 1 => Ok(credentials.pop()), + _ => Err( + "本机存在多个陶泥儿服务器的开发者 Key;请先在客户端登录目标服务器后重试".to_string(), + ), + } +} + fn ensure_plain_private_external_editor_directory( path: &Path, label: &str, @@ -350,10 +397,18 @@ async fn create_private_external_editor_api_credentials_from_platform_session( /// copied into the file or passed to the later isolated Runtime. pub(crate) async fn ensure_private_external_editor_api_credentials( ) -> Result { - let session = current_platform_session().ok_or_else(|| { - "authentication-required: 未找到本机陶泥儿开发者 Key;请先在客户端登录一次以创建本机 Key" - .to_string() - })?; + if let Some(credentials) = active_external_editor_api_credentials() { + return Ok(credentials); + } + let Some(session) = current_platform_session() else { + return unique_private_external_editor_api_credentials_at( + &private_external_editor_api_key_directory()?, + )? + .ok_or_else(|| { + "authentication-required: 未找到本机陶泥儿开发者 Key;请先在客户端登录一次以创建本机 Key" + .to_string() + }); + }; let path = private_external_editor_api_key_path_for_base_url(&session.api_base_url)?; if let Some(credentials) = private_external_editor_api_credentials_from_file_at(&path)? { return Ok(credentials); @@ -1703,6 +1758,38 @@ mod tests { .is_some_and(|name| name.starts_with(PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX))); } + #[test] + fn unique_private_external_editor_credentials_support_headless_recovery_only_when_unambiguous() + { + let root = tempfile::tempdir().expect("temp dir"); + let directory = root.path().join("config").join("genarrative"); + let first_path = directory.join("external-editor-api-0000000000000001.json"); + let first = ExternalEditorApiCredentials { + api_base_url: "https://dev.genarrative.world".to_string(), + api_key: "tnr_sk_headless_fixture_1".to_string(), + }; + write_private_external_editor_api_credentials_at(&first_path, &first) + .expect("write first private credential"); + let recovered = unique_private_external_editor_api_credentials_at(&directory) + .expect("read unique private credential") + .expect("credential present"); + assert_eq!(recovered.api_base_url, first.api_base_url); + assert_eq!(recovered.api_key, first.api_key); + + let second_path = directory.join("external-editor-api-0000000000000002.json"); + let second = ExternalEditorApiCredentials { + api_base_url: "https://www.genarrative.world".to_string(), + api_key: "tnr_sk_headless_fixture_2".to_string(), + }; + write_private_external_editor_api_credentials_at(&second_path, &second) + .expect("write second private credential"); + let error = match unique_private_external_editor_api_credentials_at(&directory) { + Err(error) => error, + Ok(_) => panic!("multiple origins must be ambiguous"), + }; + assert!(error.contains("多个陶泥儿服务器")); + } + #[test] fn private_external_editor_credentials_storage_failure_markers_are_closed_and_stable() { assert!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 5e41e3cd6..9d88d2cf0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1,14 +1,5 @@ use super::*; -const AUTOMATIC_PROJECTS_DIRECTORY_NAME: &str = "Genarrative GameAgent"; - -fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result { - app.path() - .document_dir() - .map(|documents_root| documents_root.join(AUTOMATIC_PROJECTS_DIRECTORY_NAME)) - .map_err(|error| format!("无法读取系统文档目录:{error}")) -} - pub(crate) fn closest_existing_project_picker_directory(path: &Path) -> Option { let mut candidate = if path.exists() && path.is_dir() { Some(path) @@ -24,6 +15,7 @@ pub(crate) fn closest_existing_project_picker_directory(path: &Path) -> Option

Result { @@ -79,13 +71,6 @@ pub(crate) fn create_automatic_local_game_project_at( Err("自动工作区命名冲突,请重试".to_string()) } -#[tauri::command] -pub(crate) fn create_automatic_local_game_project( - app: tauri::AppHandle, -) -> Result { - create_automatic_local_game_project_at(&automatic_local_game_projects_root(&app)?) -} - #[tauri::command] pub(crate) fn init_local_game_project( project_path: String, 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 651654b6f..438d95ce0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1908,6 +1908,9 @@ mod game_chat_release_client_exit_tests { #[cfg(not(test))] fn main() { let mut args = std::env::args().skip(1).collect::>(); + if let Some(exit_code) = run_direct_tools_mcp_if_requested(&args) { + std::process::exit(exit_code); + } #[cfg(target_os = "linux")] if command_sandbox_trampoline::is_trampoline_mode(&args) { match command_sandbox_trampoline::run_trampoline() { @@ -2182,7 +2185,6 @@ fn main() { Ok(()) }) .invoke_handler(tauri::generate_handler![ - create_automatic_local_game_project, init_local_game_project, import_local_godot_project, is_local_project_directory_non_empty, @@ -2196,6 +2198,7 @@ fn main() { chat_with_game_creator_role_agent, chat_with_game_creator_role_agent_stream, chat_with_game_creator_direct_codex, + chat_with_game_creator_home_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/project/asset_canvas/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs index b499934ed..eec7d1e65 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs @@ -1624,10 +1624,6 @@ fn build_generation_request_snapshot( "imageSize".to_string(), serde_json::json!(ledger.image_size), ); - body.insert( - "assetKind".to_string(), - serde_json::json!(ledger.asset_kind), - ); body.insert( "assetLabel".to_string(), serde_json::json!(ledger.asset_name), @@ -4069,6 +4065,10 @@ mod tests { serde_json::from_str(&request).expect("parse refine request"); assert_eq!(endpoint, "/api/external/v1/editor/images/edits"); assert_eq!(request["sourceReferenceId"], "editor-resource-source"); + assert!( + request.get("assetKind").is_none(), + "图片精修请求不得携带仅属于资源登记的 assetKind 字段" + ); assert!(request.get("sourceImageSrc").is_none()); assert!(request.get("sourceResourceId").is_none()); assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs index d19454339..114181cdf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs @@ -2512,7 +2512,7 @@ fn repository_context_v2_pending_fingerprint_blocks_project_mutation() { #[tokio::test] async fn runtime_v11_closure_repository_context_drift_replans_before_auto_mutations() { - const DRIFT_COMMAND: &str = r#"node -e "require('fs').writeFileSync('AGENTS.md','drifted rules\\n');process.stdout.write('DRIFTED')""#; + const DRIFT_COMMAND: &str = r#"node -e "require('fs').writeFileSync('AGENTS.md','drifted rules\n');process.stdout.write('DRIFTED')""#; for tool in [ "file.write", diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index 8b3778615..958af7534 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -30,6 +30,16 @@ "bundle": { "active": true, "targets": "all", + "resources": { + "resources/codex/win-x64/bin/codex.exe": "codex/win-x64/bin/codex.exe", + "resources/codex/win-x64/bin/codex-code-mode-host.exe": "codex/win-x64/bin/codex-code-mode-host.exe", + "resources/codex/win-x64/codex-path/rg.exe": "codex/win-x64/codex-path/rg.exe", + "resources/codex/win-x64/codex-resources/codex-command-runner.exe": "codex/win-x64/codex-resources/codex-command-runner.exe", + "resources/codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe": "codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe", + "resources/codex/win-x64/codex-package.json": "codex/win-x64/codex-package.json", + "resources/codex/win-x64/NOTICE.md": "codex/win-x64/NOTICE.md", + "resources/codex/win-x64/manifest.json": "codex/win-x64/manifest.json" + }, "linux": { "deb": { "depends": ["bubblewrap"] diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 7bbcc3fe5..02a86a5c5 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -591,6 +591,7 @@ export function GameChatReleaseApp({ initialProjectPath={initialProjectPath} projectSupervisorOnly gameChatOnly + directGameChatRuntime allowAdvancedExternalEditorConfig={allowAdvancedExternalEditorConfig} initialSupervisorMessage={initialSupervisorMessage} /> @@ -605,6 +606,13 @@ type AppProps = { projectSupervisorOnly?: boolean; supervisorChatOnly?: boolean; gameChatOnly?: boolean; + /** + * The product game-chat entry is direct Codex. The bare App game-chat mode + * remains an internal legacy diagnostic surface while the old Runtime source + * is retained, so callers must opt in explicitly instead of accidentally + * changing a developer-only legacy view into a product route. + */ + directGameChatRuntime?: boolean; allowAdvancedExternalEditorConfig?: boolean; initialSupervisorMessage?: string; playRequest?: ProjectSupervisorComponentProps['playRequest']; @@ -629,6 +637,7 @@ export function App({ projectSupervisorOnly = false, supervisorChatOnly = false, gameChatOnly = false, + directGameChatRuntime = false, allowAdvancedExternalEditorConfig = false, initialSupervisorMessage = '', playRequest = null, @@ -642,7 +651,7 @@ export function App({ DIRECT_CODEX_PRODUCT_RUNTIME && projectSupervisorOnly && !supervisorChatOnly && - !gameChatOnly; + (!gameChatOnly || directGameChatRuntime); const [devMode] = useState(() => projectSupervisorOnly ? false : isDeveloperMode(), ); @@ -1476,7 +1485,7 @@ export function App({ useEffect(() => { const listen = window.__TAURI__?.event?.listen; const invoke = resolveTauriInvoke(); - if (!listen) { + if (!listen || directCodexProductRuntime) { return; } let cleanup: (() => void) | null = null; @@ -1579,6 +1588,7 @@ export function App({ }; }, [ appendGameChatFinalReplyMessages, + directCodexProductRuntime, gameChatOnly, refreshManifest, updateProjectSupervisorResponseStream, @@ -1625,6 +1635,7 @@ export function App({ projectSupervisorExpectedRunId ?? projectSupervisorRuntime?.runId ?? null; if ( !invoke || + directCodexProductRuntime || !nextProjectPath || !sessionId || !trackedRunId || @@ -1719,6 +1730,7 @@ export function App({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [ localProject?.projectPath, + directCodexProductRuntime, projectSupervisorSessionId, projectSupervisorRuntime?.phase, projectSupervisorRuntime?.runId, @@ -1732,6 +1744,7 @@ export function App({ const supervisorRunId = projectSupervisorRuntime?.runId ?? null; if ( !projectSupervisorOnly || + directCodexProductRuntime || !invoke || !nextProjectPath || !supervisorRunId @@ -1814,6 +1827,7 @@ export function App({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [ localProject?.projectPath, + directCodexProductRuntime, projectSupervisorOnly, projectSupervisorRuntime?.phase, projectSupervisorRuntime?.runId, @@ -1823,7 +1837,15 @@ export function App({ useEffect(() => { const invoke = resolveTauriInvoke(); const nextProjectPath = localProject?.projectPath ?? null; - if (!gameChatOnly || !invoke || !nextProjectPath) { + // A direct Codex game-chat has no legacy accepted Run identity. It must + // never poll, start, or embed a preview merely because a prior Supervisor + // Run happens to be present in the selected project's history. + if ( + !gameChatOnly || + directCodexProductRuntime || + !invoke || + !nextProjectPath + ) { return; } let disposed = false; @@ -2094,6 +2116,7 @@ export function App({ // The interval reads the latest Runtime through refs and is recreated only for identity changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ + directCodexProductRuntime, gameChatOnly, localProject?.projectPath, projectSupervisorRuntime?.runId, @@ -3063,32 +3086,13 @@ export function App({ projectSupervisorRuntimeResumeProjectPathRef.current = nextProjectPath; agentRuntimeResumeProjectPathRef.current = nextProjectPath; } - let sessionId = await readProjectSupervisorActiveSession( - invoke, - nextProjectPath, - ); - let persistedSupervisorRuntimeResult: AgentRuntimeResult | null = null; + // Direct Codex owns its app-server conversation identity. The project + // workbench only hydrates the project conversation and must not inspect + // or create a legacy Supervisor Session while opening the project. + const sessionId = directCodexProductRuntime + ? null + : await readProjectSupervisorActiveSession(invoke, nextProjectPath); 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', { @@ -3109,11 +3113,7 @@ export function App({ }, ); } - if (persistedSupervisorRuntimeResult) { - runtime = agentRuntimeStateFromResult(persistedSupervisorRuntimeResult); - runtimeResponseStream = - persistedSupervisorRuntimeResult.responseStream ?? null; - } else if (sessionId) { + if (sessionId) { try { const runtimeResult = await invoke( 'read_game_creator_agent_runtime', @@ -3358,11 +3358,13 @@ 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. + // Direct Codex must not resume or inspect legacy Supervisor work. Its + // own project conversation is hydrated above; the old run trace remains + // available only in the explicit legacy diagnostic surface. void loadProjectConversation(openedProject.projectPath); - void refreshAgentRunTrace(openedProject.projectPath); + if (!directCodexProductRuntime) { + void refreshAgentRunTrace(openedProject.projectPath); + } } catch (error) { if (projectScopeVersionRef.current !== projectScopeVersion) { return; @@ -5907,50 +5909,13 @@ export function App({ return sessionId; } - async function refreshDirectProjectSurface( - invoke: TauriInvoke, - nextProjectPath: string, - ) { + async function refreshDirectProjectManifest(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( @@ -5961,30 +5926,7 @@ export function App({ // 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; - } - } + const directProjectPath = resolveChatProjectPath(localProject); if (directProjectPath && directInvoke) { const directAssistantMessageId = directConversationTurnId ? directCodexConversationMessageId( @@ -6018,7 +5960,7 @@ export function App({ ]; }; setChatAgentBusy(true); - setDirectCodexProgress('已提交需求,正在准备智能创作'); + setDirectCodexProgress('已发送消息,正在等待陶泥儿回复'); setProjectSupervisorRuntimeError(''); try { const reply = await directInvoke( @@ -6041,8 +5983,8 @@ export function App({ updatedAt: Date.now(), }, ]); - setDirectCodexProgress('项目已更新,正在刷新资源和运行预览'); - await refreshDirectProjectSurface(directInvoke, directProjectPath); + setDirectCodexProgress('陶泥儿已回复,正在刷新项目状态'); + await refreshDirectProjectManifest(directProjectPath); } } catch (error) { const message = @@ -9450,28 +9392,29 @@ export function App({ setLimitedCommandStatus('正在运行'); try { - const smoke = await invoke( - 'run_limited_local_command', - { - projectPath: nextProjectPath, - commandId: 'game.static_smoke', - }, - ); + const smoke = directCodexProductRuntime + ? null + : await invoke('run_limited_local_command', { + projectPath: nextProjectPath, + commandId: 'game.static_smoke', + }); const previewResult = await invoke( 'start_local_game_preview', { projectPath: nextProjectPath }, ); updateClientPreview(previewResult); setPreviewStatus(`运行中:127.0.0.1:${previewResult.port}`); - setLimitedCommandStatus(smoke.output); + setLimitedCommandStatus(smoke?.output ?? '本地预览已启动'); setCommandLog((current) => [ ...current, 'game.run_local', - `command.run_limited ${smoke.commandId}`, + ...(smoke ? [`command.run_limited ${smoke.commandId}`] : []), 'preview.start', ]); void refreshManifest(nextProjectPath); - void refreshAgentRunTrace(nextProjectPath); + if (!directCodexProductRuntime) { + void refreshAgentRunTrace(nextProjectPath); + } if (announceToChat) { setMessages((current) => [ ...current, @@ -10697,10 +10640,12 @@ export function App({ agentRuntimeResumeProjectPathRef.current = null; return; } + if (directCodexProductRuntime) { + agentRuntimeResumeProjectPathRef.current = nextProjectPath; + setAgentRuntimeById({}); + return; + } try { - if (directCodexProductRuntime) { - agentRuntimeResumeProjectPathRef.current = nextProjectPath; - } if (agentRuntimeResumeProjectPathRef.current !== nextProjectPath) { try { const resumedRuntimes = await invoke( @@ -11302,11 +11247,18 @@ export function App({ ) { event.preventDefault(); const prompt = chatInput.trim(); - if ((gameChatOnly || supervisorChatOnly) && prompt.startsWith('/')) { + if ( + !directCodexProductRuntime && + (gameChatOnly || supervisorChatOnly) && + prompt.startsWith('/') + ) { void handleChatSubmit(event); return; } - if (agentRuntimeNeedsUserInput(projectSupervisorRuntimeRef.current)) { + if ( + !directCodexProductRuntime && + agentRuntimeNeedsUserInput(projectSupervisorRuntimeRef.current) + ) { setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题'); return; } @@ -11353,6 +11305,7 @@ export function App({ void handlePendingCommandConfirm()} pendingNonEmptyProjectCreate={pendingNonEmptyProjectCreate} onCancelNonEmptyProjectCreate={cancelProjectCreateInNonEmptyFolder} onConfirmNonEmptyProjectCreate={confirmProjectCreateInNonEmptyFolder} - preview={preview} - previewRevision={gameChatPreviewRevision} - previewStatus={previewStatus} + preview={directCodexProductRuntime ? null : preview} + previewRevision={ + directCodexProductRuntime ? null : gameChatPreviewRevision + } + previewStatus={directCodexProductRuntime ? '' : previewStatus} projectPath={gameChatProjectPath} projectReady={Boolean(localProject)} projectSelectionBusy={gameChatProjectSelectionBusy} - runtime={projectSupervisorRuntime} - runtimeByAgentId={agentRuntimeById} - manifest={manifest} + runtime={directCodexProductRuntime ? null : projectSupervisorRuntime} + runtimeByAgentId={directCodexProductRuntime ? {} : agentRuntimeById} + manifest={directCodexProductRuntime ? null : manifest} runtimeConfigOpen={ allowAdvancedExternalEditorConfig ? false : runtimeConfigOpen } @@ -11392,12 +11349,20 @@ export function App({ : projectSupervisorTransientReply } transientReplyUpdatedAt={projectSupervisorResponseStream?.updatedAt} - hasConversationControls={projectSupervisorHasConversationControls} + hasConversationControls={ + directCodexProductRuntime + ? false + : projectSupervisorHasConversationControls + } hiddenConversationCount={hiddenConversationCount} - needsUserInput={projectSupervisorNeedsUserInput} + needsUserInput={ + directCodexProductRuntime ? false : projectSupervisorNeedsUserInput + } visibleMessages={visibleMessages} workspaceStatus={workspaceStatus} - expectedRunId={projectSupervisorExpectedRunId} + expectedRunId={ + directCodexProductRuntime ? null : projectSupervisorExpectedRunId + } /> {runtimeConfigOpen && allowAdvancedExternalEditorConfig ? ( { + const invoke = resolveTauriInvoke(); + if (!invoke) { + throw new Error('需要在陶泥儿客户端内运行'); + } + const result = await invoke<{ + reply: string; + requestProjectCreation: boolean; + }>('chat_with_game_creator_home_direct_codex', { + prompt: draft.prompt, + attachments: draft.attachments.map(({ file }) => ({ + name: file.name, + mediaType: file.type, + size: file.size, + })), + }); + if (!result.requestProjectCreation) { + return { status: '陶泥儿已回复', reply: result.reply }; + } + const creationResult = await createHomeDraft(draft); + return { + status: + creationResult === '已取消' + ? '陶泥儿已说明创作方向;尚未创建项目' + : creationResult, + reply: result.reply, + }; + }, + [createHomeDraft], + ); + return (

0} status={status} onStatusChange={setStatus} - homeAgentModeItems={homeAgentModeItems} recentProjectRows={recentProjectRows} - onCreateDraftAutomatically={createHomeDraftAutomatically} + onSendHomeMessage={sendHomeMessage} onProjectsOpen={() => setLauncherView('projects')} onProjectOpen={(path) => { setProjectPath(path); 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 08a635840..ac2141cc0 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 @@ -1,4 +1,3 @@ -import { FileText, Gamepad2, Image } from 'lucide-react'; import type { ComponentType } from 'react'; import type { AuthUser } from '../../../../../packages/shared/src/contracts/auth'; @@ -7,11 +6,6 @@ import type { GameCreationAppPreviewState, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import type { ChatMessage, LocalProjectDirectoryStatus } from '../../app/types'; -import type { - HomeAgentMode, - HomeAgentModeItem, - HomeAttachmentDraft, -} from '../../view/home'; import type { LauncherView } from '../../view/layout'; import type { ProjectAgentResultSummary, @@ -211,54 +205,6 @@ export function persistSupervisorChatDraft(projectPath: string, draft: string) { } } -export const homeAgentModeItems: HomeAgentModeItem[] = [ - { - mode: 'game', - label: '做游戏', - placeholder: '今天想把什么灵感做成游戏', - emptyPrompt: '请输入游戏灵感或上传参考素材', - icon: Gamepad2, - }, - { - mode: 'art', - label: '做素材', - placeholder: '今天想做什么样的美术素材', - emptyPrompt: '请输入素材需求或上传参考图', - icon: Image, - }, - { - mode: 'doc', - label: '做方案', - placeholder: '今天有什么设计需要帮你整理', - emptyPrompt: '请输入方案需求或上传资料', - icon: FileText, - }, -]; - -const homeAgentModeLabels: Record = { - game: '做游戏', - art: '做素材', - doc: '做方案', -}; - -export function buildHomeConversationContent( - mode: HomeAgentMode, - prompt: string, - attachments: HomeAttachmentDraft[], -) { - const lines = [ - `初始意图:${mode} / ${homeAgentModeLabels[mode]}`, - prompt.trim() || '用户上传了参考附件,等待后续补充需求。', - ]; - if (attachments.length > 0) { - lines.push(''); - lines.push( - `附件:${attachments.map((attachment) => attachment.file.name).join('、')}`, - ); - } - return lines.join('\n'); -} - export function buildRecentProjectRows( recentWorkspaces: string[], recentWorkspaceStatuses: Record< diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts b/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts index a18cf2355..ef39f1443 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts @@ -272,7 +272,11 @@ export function useDeveloperAgentPanel(launcherView: LauncherView) { useEffect(() => { const listen = window.__TAURI__?.event?.listen; const invoke = resolveTauriInvoke(); - if (!listen || !invoke) { + // WorkspaceLauncher stays mounted while users work in a project. The + // legacy developer Agent Runtime listener must therefore exist only on + // the explicit developer Agent chat surface, never behind direct Codex + // project workbenches. + if (launcherView !== 'agent-chat' || !listen || !invoke) { return; } let cleanup: (() => void) | null = null; @@ -367,6 +371,7 @@ export function useDeveloperAgentPanel(launcherView: LauncherView) { agentChatRuntimeSyncingRunIdsRef, agentChatSelectedAgentIdRef, agentChatSelectedSessionIdRef, + launcherView, setAgentChatActiveRuntime, setAgentChatGoal, setAgentChatRuntime, 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 2eaef631f..2cb05d5d8 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 @@ -23,11 +23,7 @@ import type { TauriInvoke, UploadLocalAssetResult, } from '../../app/types'; -import type { - HomeAgentMode, - HomeAttachmentDraft, - HomeDraft, -} from '../../view/home'; +import type { HomeAttachmentDraft, HomeDraft } from '../../view/home'; import { useLauncherHomeDraftStore } from '../../view/home/useHomeDraftStore'; import type { LauncherView } from '../../view/layout'; import type { @@ -39,7 +35,6 @@ import { isAbsoluteProjectPath, projectPathHasControlCharacter, } from '../project-summary/projectSummary'; -import { buildHomeConversationContent } from './model'; type UseHomeProjectCreationOptions = { setStatus: Dispatch>; @@ -161,7 +156,6 @@ export function useHomeProjectCreation({ async function enterCreatedHomeProject( invoke: TauriInvoke, result: InitLocalProjectResult, - mode: HomeAgentMode, prompt: string, attachments: HomeAttachmentDraft[], ) { @@ -170,11 +164,6 @@ export function useHomeProjectCreation({ result.projectPath, attachments, ); - const conversationContent = buildHomeConversationContent( - mode, - prompt, - attachments, - ); enterProjectDevelopment({ projectPath: result.projectPath, projectName: @@ -185,9 +174,8 @@ export function useHomeProjectCreation({ invoke, result.projectPath, ), - mode, initialPrompt: - conversationContent.trim() || + prompt.trim() || (attachments.length > 0 ? '用户上传了参考附件,等待后续补充需求。' : ''), @@ -201,7 +189,6 @@ export function useHomeProjectCreation({ async function createHomeProjectFromDirectory( nextProjectPath: string, - mode: HomeAgentMode, prompt: string, attachments: HomeAttachmentDraft[], skipNonEmptyCheck = false, @@ -226,9 +213,7 @@ export function useHomeProjectCreation({ setPendingNonEmptyProject({ kind: 'home-create', projectPath: trimmedProjectPath, - mode, - prompt, - attachments, + draft: { prompt, attachments }, }); setStatus('目标文件夹不是空的'); return '目标文件夹不是空的,请确认是否继续新建'; @@ -243,13 +228,7 @@ export function useHomeProjectCreation({ }, ); try { - await enterCreatedHomeProject( - invoke, - result, - mode, - prompt, - attachments, - ); + await enterCreatedHomeProject(invoke, result, prompt, attachments); setStatus('已创建项目,正在开始智能创作'); } catch (error) { throw new Error( @@ -266,30 +245,6 @@ export function useHomeProjectCreation({ } } - async function finishAutomaticallyCreatedHomeProject( - result: InitLocalProjectResult, - mode: HomeAgentMode, - prompt: string, - attachments: HomeAttachmentDraft[], - ) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - throw new Error('需要在 Tauri App 内运行'); - } - setStatus('正在启动智能创作'); - try { - await enterCreatedHomeProject(invoke, result, mode, prompt, attachments); - setStatus('已自动创建工作区并开始智能创作'); - return '已自动创建工作区并开始智能创作'; - } catch (error) { - const message = `工作区已创建;首条需求投递失败:${ - error instanceof Error ? error.message : String(error) - }`; - setStatus(message); - throw new Error(message); - } - } - async function createProjectFromProjectPage( nextProjectPath: string, skipNonEmptyCheck = false, @@ -343,7 +298,6 @@ export function useHomeProjectCreation({ invoke, result.projectPath, ), - mode: null, initialPrompt: '', attachments: [], recentRunStatus: null, @@ -432,7 +386,6 @@ export function useHomeProjectCreation({ invoke, trimmedProjectPath, ), - mode: null, initialPrompt: '', attachments: [], recentRunStatus: directoryStatus.recentRunStatus, @@ -459,9 +412,8 @@ export function useHomeProjectCreation({ if (pendingProject.kind === 'home-create') { await createHomeProjectFromDirectory( pendingProject.projectPath, - pendingProject.mode, - pendingProject.prompt, - pendingProject.attachments, + pendingProject.draft.prompt, + pendingProject.draft.attachments, true, ); return; @@ -496,24 +448,6 @@ export function useHomeProjectCreation({ } return createHomeProjectFromDirectory( selectedPath, - draft.mode, - draft.prompt, - draft.attachments, - ); - } - - async function createHomeDraftAutomatically(draft: HomeDraft) { - const invoke = resolveTauriInvoke(); - if (!invoke) { - throw new Error('需要在 Tauri App 内运行'); - } - setStatus('正在创建工作区'); - const result = await invoke( - 'create_automatic_local_game_project', - ); - return finishAutomaticallyCreatedHomeProject( - result, - draft.mode, draft.prompt, draft.attachments, ); @@ -605,7 +539,6 @@ export function useHomeProjectCreation({ pendingNonEmptyProject, resetLauncherHomeDraft, createHomeDraft, - createHomeDraftAutomatically, openProject, pickAndOpenProject, pickAndCreateProject, 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 b58c35b7c..9f87daa95 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 @@ -77,11 +77,14 @@ export function ProjectSupervisorView({ ...runtimePanelProps }: ProjectSupervisorViewProps) { return ( -
+
{hiddenConversationCount > 0 ? ( @@ -107,7 +110,9 @@ export function ProjectSupervisorView({ {transientReply ? (

@@ -130,6 +135,7 @@ export function ProjectSupervisorView({ { projectPath }, projectPath, ), + directCodex, )} @@ -157,7 +163,7 @@ export function ProjectSupervisorView({ ) : null}