diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 50f3050c7..a5c33ab12 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -53,6 +53,7 @@ "focus-trap-react": "^12.0.3", "lexical": "^0.47.0", "lucide-react": "^0.546.0", + "phaser": "^4.2.1", "react": "^19.0.0", "react-arborist": "^3.16.0", "react-colorful": "^5.8.0", diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index d8ce54e41..0149d953a 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -115,6 +115,8 @@ const allowedUncalledTauriCommands = [ 'open_game_creator_workspace_window', 'read_direct_project_conversation', 'stop_local_game_preview_if_matches', + 'start_game_creator_external_mcp', + 'stop_game_creator_external_mcp', ]; const sourceExtensions = new Set([ '.json', diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 397d88dcf..64577d35f 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -127,6 +127,39 @@ function readBackendTargets({ requireAgcBackend = false } = {}) { }); } +function readBackendServiceFailure( + state, + { + expectedDatabase = backendDatabase, + expectedSpacetimeDataDir = backendSpacetimeDataDir, + } = {}, +) { + const targets = resolveBackendTargetsFromState(state, { + requireAgcBackend: true, + expectedDatabase, + expectedSpacetimeDataDir, + }); + if (!targets.hasMatchingBackend) { + return null; + } + + for (const serviceName of ['spacetime', 'api-server', 'bgfilter-worker']) { + const service = state?.services?.[serviceName]; + if (service?.status !== 'failed') { + continue; + } + + return { + serviceName, + failure: service.signal + ? `signal=${service.signal}` + : `code=${service.exitCode ?? 1}`, + }; + } + + return null; +} + async function isBackendReady({ state = readJson(devStackStatePath), isReady = isHttpReady, @@ -505,11 +538,29 @@ async function terminateChildTree( return { stopped, forced: true }; } -async function waitForBackendReady(backendChild, timeoutMs = 600_000) { +async function waitForBackendReady( + backendChild, + timeoutMs = 600_000, + { + checkBackendReady = isBackendReady, + readState = () => readJson(devStackStatePath), + resolveTargets = readBackendTargets, + } = {}, +) { + const initialStateUpdatedAt = readState()?.updatedAt ?? ''; const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { - if (await isBackendReady()) { - return readBackendTargets(); + if (await checkBackendReady()) { + return resolveTargets(); + } + const state = readState(); + if ((state?.updatedAt ?? '') !== initialStateUpdatedAt) { + const serviceFailure = readBackendServiceFailure(state); + if (serviceFailure) { + throw new Error( + `配套后端启动失败: ${serviceFailure.serviceName} ${serviceFailure.failure}`, + ); + } } const failure = readChildFailure(backendChild); if (failure) { @@ -686,6 +737,7 @@ export { isDirectModuleExecution, isProcessGroupAlive, preflightExistingVite, + readBackendServiceFailure, readChildFailure, readExistingViteServer, readLinuxProcessGroupAlive, diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-game-package-lock.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-game-package-lock.json new file mode 100644 index 000000000..45ece2543 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-game-package-lock.json @@ -0,0 +1,1138 @@ +{ + "name": "agc-game", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agc-game", + "dependencies": { + "phaser": "4.2.1" + }, + "devDependencies": { + "vite": "^6.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/phaser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/phaser/-/phaser-4.2.1.tgz", + "integrity": "sha512-WUNwCPJpdjvZiuT6SgCfYVW8Qw/3j0jJ4ws7P2QkhFLFu74sbGuyHJcbFueGkY/AYO4Pi47bNQXn1OCJeLX//w==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} 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 index 24ad9799a..1cd480a1e 100644 --- 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 @@ -1,21 +1,22 @@ --- 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. +description: Build or modify a playable npm-managed Phaser 4 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. +Implement the user's actual game request in the current project as an npm-managed Phaser 4.2.1 game. Use Phaser scenes for gameplay and DOM only for deliberately external UI. ## Workflow -1. Read the existing `index.html`, `style.css`, and `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 through `agc_tools`. 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. +1. Read the existing package and source files before editing. New projects place `package.json`, `index.html`, `style.css`, and `game.js` under `game/`; existing root packages retain their layout. Run npm in that package directory (for example `npm --prefix game ci` and `npm --prefix game run build`). +2. Keep `package.json` and `package-lock.json` authoritative. Import Phaser with `import Phaser from 'phaser'`; do not copy a bundle, add an import map, or use a CDN. Other npm dependencies are allowed when the game needs them. +3. Build with the project's npm script before previewing. The playable entry is the package directory's `dist/index.html`; never report an unbuilt bare-module page as playable. Import assets or configure public assets so all runtime media is included in dist; preview and exports cannot read outside it. +4. 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. +5. 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. +6. Reuse registered Taonier art when available through `agc_tools`. Load media defensively and keep gameplay usable when an optional derivative is absent; never relabel a local placeholder as platform art. +7. Let Phaser own the render loop and input dispatch. Avoid duplicate scenes, stale event listeners, and state that survives restart unintentionally. +8. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion. When implementing a new game loop or a broad gameplay revision, read `references/game-quality-checklist.md`. 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 index b99cbcfe1..3bb189c87 100644 --- 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 @@ -1,4 +1,4 @@ interface: display_name: "Web 游戏实现" - short_description: "在当前项目内设计、实现并验证可玩的 HTML、CSS 与 JavaScript 游戏" + short_description: "在当前项目内设计、实现并验证 npm 管理的 Phaser 4 游戏" 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/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 1fd0458a6..a4c42d7b5 100644 --- 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 @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.10", + "version": "2026-08-26.12", "skills": [ { "name": "agc-project-structure", @@ -57,7 +57,7 @@ "agents/openai.yaml", "references/game-quality-checklist.md" ], - "sha256": "d7748d9ebf4324add0541daf16a2bbec09c4862b85af55bfb369c7f3b99aedff" + "sha256": "0649c72dd53e05ad7c87b28def1397c2badf61b0c308091196c40f7c48a8b36a" }, { "name": "agc-browser-playtest", 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 6b6f32d30..b31ae894a 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 @@ -1356,12 +1356,13 @@ fn codex_app_server_turn_start_params( "approvalPolicy": approval_policy, }); if workspace_mode.allows_workspace_writes() { - // Native project commands stay offline and remain bounded to the - // real game workspace. + // npm install/build must resolve project dependencies. Network access + // is enabled only for DirectProject; writableRoots keeps the file-write + // boundary at the real game workspace. params["sandboxPolicy"] = serde_json::json!({ "type": "workspaceWrite", "writableRoots": [workspace_path], - "networkAccess": false + "networkAccess": true }); } if let Some(client_user_message_id) = client_user_message_id @@ -2152,6 +2153,9 @@ impl CodexAppServerConnection { if workspace_mode == CodexAppServerWorkspaceMode::DirectProject && llm.web_search_enabled { command.env(DIRECT_TOOLS_MCP_CONTROLLED_WEB_SEARCH_ENV, "1"); } + if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + command.env("npm_config_cache", workspace_path.join(".npm-cache")); + } command .env("CODEX_HOME", &isolated_codex_home) .env("HOME", &isolated_os_home) @@ -2643,7 +2647,7 @@ impl CodexAppServerConnection { "AGC 直连项目缺少客户端受控工具桥".to_string(), ) })? - .begin_user_turn(direct_codex_current_user_prompt(&request)) + .begin_user_turn() .map_err(platform_llm::LlmError::InvalidRequest)?, ) } else { @@ -4426,7 +4430,7 @@ mod tests { ); assert_eq!( turn.pointer("/sandboxPolicy/networkAccess"), - Some(&serde_json::json!(false)) + Some(&serde_json::json!(true)) ); let authority_paths = [ turn.get("cwd"), 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 c518abcbf..e7974ba25 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 @@ -10,7 +10,7 @@ const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024; const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。"; -const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同(仅说明项目边界,不是流程门槛):当前 Codex cwd 是用户选择的项目目录(工作区根),源码、素材、音效和其它资源按项目现有结构放置;先按需读取当前 cwd 下适用的 `AGENTS.md`、README 或项目说明,把它们当作项目规范参考。原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; +const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 cwd 是用户选择的项目目录。新 Web 游戏使用 npm + Vite,Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts,完成后必须从 `dist/index.html` 试玩。 原生文件工具、patch 和命令参数使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`;如果 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径。调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文;不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。`../`、绝对路径、`.agent/`、`.git/`、密钥文件和 Runtime 控制面属于客户端边界,不能请求扩权或直接改写。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图或发布宣传图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。切图、资源依赖、规范图和试玩都只是可选工具提示,不要求调用、固定顺序或特定产物,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; 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"; @@ -72,6 +72,21 @@ fn direct_codex_game_outputs(root: &Path) -> Vec<(String, &'static str, &'static (entry.to_string(), "game-entry", "text/html"), (format!("{prefix}style.css"), "game-style", "text/css"), (format!("{prefix}game.js"), "game-script", "text/javascript"), + ( + format!("{prefix}package.json"), + "game-package", + "application/json", + ), + ( + format!("{prefix}package-lock.json"), + "game-lockfile", + "application/json", + ), + ( + format!("{prefix}vite.config.js"), + "game-build-config", + "text/javascript", + ), ] } @@ -2132,7 +2147,7 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec { let Ok(validated_slices) = validated_art_slices(root) else { return Vec::new(); }; - if validated_slices.len() != 4 { + if validated_slices.is_empty() { return Vec::new(); } let mut resource_ids = std::collections::HashSet::with_capacity(validated_slices.len()); @@ -2703,6 +2718,7 @@ async fn generate_direct_taonier_art_asset_at( asset_kind: asset_kind.to_string(), asset_label: asset_label.to_string(), replace_existing: root.join(output_path).is_file(), + slice_count: None, }; let runtime_context = direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?; @@ -2806,9 +2822,9 @@ fn direct_taonier_art_package_result( } else { Vec::new() }; - if includes_spritesheet && slice_paths.len() != 4 { + if includes_spritesheet && slice_paths.is_empty() { slice_warnings.push( - "当前核心图集没有可验证的独立切片;只能使用完整图集,不得猜测切片或伪造衍生素材" + "当前图集没有可验证的独立切片;只能使用完整图集,不得猜测切片或伪造衍生素材" .to_string(), ); } @@ -3184,7 +3200,14 @@ fn direct_codex_generated_source() -> GameCreationAppAssetSource { fn direct_codex_output_fingerprint(root: &Path) -> String { let mut hasher = Sha256::new(); - for (local_path, _, _) in direct_codex_game_outputs(root) { + let mut paths: Vec = direct_codex_game_outputs(root) + .into_iter() + .map(|(p, _, _)| p) + .collect(); + paths.extend(direct_npm_source_paths(root)); + paths.sort(); + paths.dedup(); + for local_path in paths { hasher.update(local_path.as_bytes()); hasher.update([0]); let path = root.join(local_path); @@ -3200,6 +3223,70 @@ fn direct_codex_output_fingerprint(root: &Path) -> String { format!("{:x}", hasher.finalize()) } +fn direct_npm_source_paths(root: &Path) -> Vec { + let base = if root.join("package.json").is_file() { + root.to_path_buf() + } else if root.join("game/package.json").is_file() { + root.join("game") + } else { + return Vec::new(); + }; + let mut output = Vec::new(); + let mut pending = vec![(base, 0usize)]; + let mut inspected = 0usize; + while let Some((directory, depth)) = pending.pop() { + if depth > 16 || inspected >= 4096 { + break; + } + let Ok(entries) = fs::read_dir(directory) else { + continue; + }; + for entry in entries.flatten() { + inspected += 1; + if inspected > 4096 { + break; + } + let name = entry.file_name().to_string_lossy().to_ascii_lowercase(); + if name.starts_with('.') + || matches!( + name.as_str(), + "node_modules" + | "dist" + | "target" + | "memory" + | "exports" + | "auth.json" + | "credentials.json" + | "game-creator.config.json" + | "game-creator.config.local.json" + ) + { + continue; + } + let Ok(kind) = entry.file_type() else { + continue; + }; + let path = entry.path(); + if kind.is_dir() { + pending.push((path, depth + 1)); + } else if kind.is_file() + && matches!( + path.extension().and_then(|e| e.to_str()), + Some("js" | "mjs" | "cjs" | "ts" | "tsx" | "jsx" | "css" | "html" | "json") + ) + { + if let Ok(relative) = path.strip_prefix(root) { + if let Some(value) = relative.to_str() { + output.push(value.replace('\\', "/")); + } + } + } + } + } + output.sort(); + output +} + fn direct_browser_evidence_root(root: &Path, attempt: usize) -> Result { let revision = read_game_creator_agent_runtime_project_revision(root) .map(|value| value.revision) @@ -3631,6 +3718,34 @@ fn sync_direct_codex_project_file_projection_at( )?; registered += 1; } + for local_path in direct_npm_source_paths(root) { + if direct_codex_game_outputs(root) + .iter() + .any(|(p, _, _)| p == &local_path) + { + continue; + } + if root.join(&local_path).is_file() { + let media_type = if local_path.ends_with(".css") { + "text/css" + } else if local_path.ends_with(".json") { + "application/json" + } else if local_path.ends_with(".html") { + "text/html" + } else { + "text/javascript" + }; + register_local_asset_at( + root, + &local_path, + "game-source", + media_type, + "direct-codex", + direct_codex_generated_source(), + )?; + registered += 1; + } + } if registered == 0 { return Err("Codex 返回后没有可登记的游戏文件".to_string()); } @@ -7311,7 +7426,7 @@ mod tests { assert_eq!(manifest.versions.len(), 1); assert_eq!(manifest.versions[0].version_id, "initial-1"); assert_eq!(manifest.versions[0].project_revision, 1); - assert_eq!(manifest.versions[0].resource_bindings.len(), 10); + assert_eq!(manifest.versions[0].resource_bindings.len(), 13); for expected_path in DIRECT_CODEX_ART_ASSET_PATHS.into_iter().chain( DIRECT_CODEX_SPRITESHEET_SLICE_PATHS .iter() @@ -7348,7 +7463,7 @@ mod tests { .expect("project revision"); assert_eq!(revision.revision, 2); let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest"); - assert_eq!(manifest.assets.len(), 10); + assert_eq!(manifest.assets.len(), 13); assert_eq!( manifest .assets @@ -7368,7 +7483,7 @@ mod tests { manifest.versions[1].created_reason, GameIterationVersionCreatedReason::AgentRevision ); - assert_eq!(manifest.versions[1].resource_bindings.len(), 10); + assert_eq!(manifest.versions[1].resource_bindings.len(), 13); let unchanged_fingerprint = direct_codex_output_fingerprint(root.path()); sync_direct_codex_project_outputs_at(root.path(), Some(&unchanged_fingerprint)) @@ -8252,3 +8367,36 @@ mod tests { ); } } + +#[cfg(test)] +mod npm_source_projection_tests { + use super::*; + + #[test] + fn npm_sources_track_nested_modules_but_ignore_dependencies_and_private_files() { + for nested in [false, true] { + let root = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + let source = if nested { + root.path().join("game") + } else { + root.path().to_path_buf() + }; + fs::create_dir_all(source.join("src/scenes")).unwrap(); + fs::create_dir_all(source.join("node_modules/demo")).unwrap(); + fs::create_dir_all(source.join(".npm-cache")).unwrap(); + fs::write(source.join("package.json"), "{}").unwrap(); + fs::write(source.join("src/scenes/play.ts"), "export const value = 1;").unwrap(); + fs::write(source.join("node_modules/demo/index.js"), "private dep").unwrap(); + fs::write(source.join(".npm-cache/auth.json"), "private cache").unwrap(); + let paths = direct_npm_source_paths(root.path()); + let prefix = if nested { "game/" } else { "" }; + assert!(paths.contains(&format!("{prefix}src/scenes/play.ts"))); + assert!(!paths + .iter() + .any(|path| path.contains("node_modules") || path.contains(".npm-cache"))); + let before = direct_codex_output_fingerprint(root.path()); + fs::write(source.join("src/scenes/play.ts"), "export const value = 2;").unwrap(); + assert_ne!(direct_codex_output_fingerprint(root.path()), before); + } + } +} 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 index 5b9f4cfdd..bdbeb222c 100644 --- 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 @@ -63,7 +63,6 @@ struct DirectToolBridgeTurnAuthorization { struct DirectToolBridgeActiveTurnAuthorization { turn_id: String, - allows_regeneration: bool, brief_sha256: Option, completed_result: Option, resource_request_ids: BTreeMap, @@ -181,30 +180,23 @@ impl DirectToolBridge { &self.url } - /// Arm exactly one client-owned Direct turn. The raw user message is used - /// only for this synchronous decision and is never retained by the bridge. - pub(crate) fn begin_user_turn( - &self, - user_prompt: &str, - ) -> Result { - self.state.begin_user_turn(user_prompt) + /// Arm exactly one client-owned Direct turn. Codex chooses the business + /// operation through the reviewed MCP tool and arguments; the bridge only + /// binds that call to the active client turn. + pub(crate) fn begin_user_turn(&self) -> Result { + self.state.begin_user_turn() } } impl DirectToolBridgeState { - fn begin_user_turn( - self: &Arc, - user_prompt: &str, - ) -> Result { + fn begin_user_turn(self: &Arc) -> Result { let turn_id = direct_taonier_active_invocation_id_at(&self.root)?; - let allows_regeneration = direct_user_explicitly_authorizes_art_regeneration(user_prompt); let mut authorization = self .turn_authorization .lock() .map_err(|_| "AGC 工具桥回合授权状态不可用".to_string())?; authorization.active = Some(DirectToolBridgeActiveTurnAuthorization { turn_id: turn_id.clone(), - allows_regeneration, brief_sha256: None, completed_result: None, resource_request_ids: BTreeMap::new(), @@ -594,12 +586,9 @@ impl DirectToolBridgeState { .active .as_mut() .ok_or_else(|| "当前没有客户端签发的美术重生成回合授权".to_string())?; - if !active.allows_regeneration { - return Err("当前用户消息未显式授权重新生成或替换美术".to_string()); - } match active.brief_sha256.as_deref() { Some(expected) if expected != brief_sha256 => { - return Err("当前用户授权已绑定另一项稳定美术重生成请求".to_string()) + return Err("当前客户端回合已绑定另一项稳定美术重生成请求".to_string()) } None => active.brief_sha256 = Some(brief_sha256.clone()), Some(_) => {} @@ -2116,6 +2105,7 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) asset_kind: kind.clone(), asset_label: asset_name.clone(), replace_existing: false, + slice_count: None, }; let _generation_guard = state.image_generation_gate.lock().await; let generated = with_direct_editor_api_credentials( @@ -2736,106 +2726,20 @@ mod tests { } #[test] - fn regenerate_requires_current_explicit_user_authorization_and_one_stable_brief() { - for prompt in [ - "继续修复布局", - "解释一下重新生成美术是什么意思", - "不要重新生成美术,只调整代码", - "别换一套美术,继续用现在这套", - "解释一下换一套美术按钮", - "是否要改变视觉风格?", - "Do not regenerate the art; keep the current package.", - "I don't want to change the visual style.", - "What does use a new art set mean?", - "文案写着“换一套美术”", - "Yesterday I said regenerate art, but today keep it.", - "Please explain how to regenerate art.", - "重新生成美术以后再说,现在只修代码", - "重做美术先不做,先改玩法", - "Regenerate the art maybe later; for now just fix the code.", - "把按钮文案改成“请重新生成美术”,不要执行生成工具", - "把按钮文案改成‘请重新生成美术’,不要执行生成工具", - "Change the button label to 'please regenerate the art'; do not execute it.", - "用户之前说请重新生成美术,我只是在复述", - "Yesterday the user said please regenerate the art; I am just quoting it.", - "以后请重新生成美术,现在先改代码", - "你能不能帮我重新生成美术,顺便解释一下价格", - "请重新生成美术吗", - "请重新生成美术吗,还是只改代码", - "请重新生成美术或者只改代码", - "请重新生成美术以外的内容", - "请重新生成美术,但不要执行生成工具", - "不需要重新生成美术", - "界面上显示:请重新生成美术", - "界面标题是请重新生成美术", - "产品经理让我写请重新生成美术", - "下周请重新生成美术", - "他说«请重新生成美术»", - "Could you please regenerate the art", - "Please regenerate the art? Or only fix code.", - "Please regenerate the art except for the paid generation.", - "Please regenerate the art, but do not execute the tool.", - "Please regenerate the art, but don’t execute the tool.", - "Please regenerate the art, but I don't authorize this paid generation.", - "Please regenerate the art, but I don‘t authorize this paid generation.", - "Please regenerate the art, but do not execute the paid tool.", - "Please regenerate the art, but never execute the paid tool.", - "Please regenerate the art, but avoid executing the paid tool.", - "Please regenerate the art, but 'do not execute the tool", - "Please regenerate the art only if it is free.", - "Please regenerate the art only after I confirm the charge.", - "Please regenerate the art, but do “not” execute the paid tool.", - "请重新生成美术,三天后再执行。", - "请重新生成美术,得到我的许可再做。", - "请重新生成美术,地面需要无缝循环。", - "Please regenerate the art, but skip the paid generation.", - "请重新生成美术【生成操作跳过】", - "请重新生成美术【仅在零元时执行】", - "Please regenerate the art “but skip the paid generation”", - "Please regenerate the art; alternatively, just fix the code.", - "Please regenerate the art, but do n\u{200B}ot execute the paid tool.", - "Please regenerate the art with a clay style.", - "I don't need you to regenerate the art", - "The UI shows: please regenerate the art", - "Please regenerate the art next week", - "He said «please regenerate the art»", - ] { - assert!( - !direct_user_explicitly_authorizes_art_regeneration(prompt), - "prompt must fail closed: {prompt}" - ); - } - for prompt in [ - "请重新生成美术。", - "那就请重新生成美术!", - "换一套美术", - "Please regenerate the art!", - ] { - assert!( - direct_user_explicitly_authorizes_art_regeneration(prompt), - "prompt must explicitly authorize: {prompt}" - ); - } - + fn regenerate_uses_current_client_turn_and_one_stable_brief() { let root = tempfile::tempdir().expect("stable client turn root"); let state = direct_tool_bridge_state(root.path().to_path_buf()); - assert!(state.begin_user_turn("请重新生成美术").is_err()); + assert!(state.begin_user_turn().is_err()); let client_turn_id = "client-turn-stable-0001"; let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root.path(), client_turn_id) .expect("client-owned stable invocation"); - let ordinary_turn = state - .begin_user_turn("继续优化交互") - .expect("ordinary turn authorization state"); - assert!(state.authorize_regeneration_call("陶泥风格").is_err()); - drop(ordinary_turn); - - let authorized_turn = state - .begin_user_turn("请重新生成美术") - .expect("authorized regeneration turn"); + let active_turn = state + .begin_user_turn() + .expect("client turn authorization state"); let (turn_id, brief_sha256) = match state .authorize_regeneration_call("陶泥风格") - .expect("first stable regeneration call") + .expect("MCP mode selects regeneration explicitly") { DirectToolBridgeRegenerationCall::Execute { turn_id, @@ -2864,7 +2768,7 @@ mod tests { panic!("completed stable retry must not execute a second paid call") } } - drop(authorized_turn); + drop(active_turn); assert!(state.authorize_regeneration_call("陶泥风格").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 index 30e3b0bea..3dde7a64a 100644 --- 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 @@ -1,7 +1,13 @@ use super::*; +use axum::extract::{DefaultBodyLimit, State as AxumState}; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::post; +use axum::{Json, Router}; use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; pub(crate) const DIRECT_TOOLS_MCP_MODE_FLAG: &str = "--agc-direct-tools-mcp"; pub(crate) const DIRECT_TOOLS_MCP_CONTROLLED_WEB_SEARCH_ENV: &str = @@ -14,6 +20,37 @@ const DIRECT_TOOLS_MCP_MAX_RESOURCE_PROMPT_CHARS: usize = 4_000; const DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS: usize = 120; const DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES: usize = 1_500_000; const DIRECT_TOOLS_MCP_MAX_BRIDGE_RESPONSE_BYTES: usize = 32 * 1024 * 1024; +const EXTERNAL_MCP_RESPONSE_MAX_CHARS: usize = 256 * 1024; +const EXTERNAL_MCP_RESPONSE_SUMMARY_MAX_CHARS: usize = 240; +const EXTERNAL_MCP_JOURNAL_MAX_BYTES: u64 = 8 * 1024 * 1024; +const EXTERNAL_MCP_JOURNAL_RELATIVE_PATH: &str = ".agent/conversations/codex-responses.jsonl"; +static EXTERNAL_MCP_JOURNAL_LOCK: OnceLock> = OnceLock::new(); +static EXTERNAL_MCP_SERVER: OnceLock>> = OnceLock::new(); +tokio::task_local! { + static EXTERNAL_MCP_BRIDGE_URL: String; +} + +pub(crate) struct ExternalMcpServer { + _bridge: super::direct_tool_bridge::DirectToolBridge, + pub(crate) url: String, + pub(crate) token: String, + task: tokio::task::JoinHandle<()>, +} + +impl Drop for ExternalMcpServer { + fn drop(&mut self) { + self.task.abort(); + } +} + +#[derive(Clone)] +struct ExternalMcpHttpState { + bridge_url: String, + root: PathBuf, + token: String, + session_user_id: String, + session_generation: u64, +} pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool { args == [DIRECT_TOOLS_MCP_MODE_FLAG] @@ -43,6 +80,62 @@ fn direct_tools_mcp_specs() -> Value { fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { let tools = vec![ + json!({ + "name": "client.session.info", + "description": "返回当前已绑定的 AGC 客户端会话和项目安全摘要;不返回宿主路径、凭据或内部地址。", + "inputSchema": { "type": "object", "additionalProperties": false } + }), + json!({ + "name": "conversation.record_codex_response", + "description": "显式记录外部 Codex 的一条最终返回。客户端只保存有界、脱敏后的正文和安全摘要,不根据正文触发业务动作。", + "inputSchema": { + "type": "object", + "properties": { + "requestId": { "type": "string", "minLength": 1, "maxLength": 160 }, + "sequence": { "type": "integer", "minimum": 0, "maximum": 1000000 }, + "content": { "type": "string", "minLength": 1, "maxLength": EXTERNAL_MCP_RESPONSE_MAX_CHARS } + }, + "required": ["requestId", "sequence", "content"], + "additionalProperties": false + } + }), + json!({ + "name": "conversation.list", + "description": "按序读取当前项目已记录的 Codex 返回摘要。", + "inputSchema": { + "type": "object", + "properties": { + "offset": { "type": "integer", "minimum": 0, "maximum": 10000 }, + "limit": { "type": "integer", "minimum": 1, "maximum": 100 } + }, + "additionalProperties": false + } + }), + json!({ + "name": "conversation.read", + "description": "读取当前项目的一条已记录 Codex 返回;只能使用 conversation.list 返回的 recordId。", + "inputSchema": { + "type": "object", + "properties": { + "recordId": { "type": "string", "minLength": 1, "maxLength": 80 } + }, + "required": ["recordId"], + "additionalProperties": false + } + }), + json!({ + "name": "agc_read_skill_resource", + "description": "读取审核通过的 AGC Skill 指导文件;仅允许清单内 skillName 和相对文件名。", + "inputSchema": { + "type": "object", + "properties": { + "skillName": { "type": "string", "minLength": 1, "maxLength": 120 }, + "relativePath": { "type": "string", "minLength": 1, "maxLength": 240 } + }, + "required": ["skillName", "relativePath"], + "additionalProperties": false + } + }), json!({ "name": "agc_write_file", "description": "把文本写入当前 AGC 项目的相对路径。Codex 可以按需使用它直接推进代码、配置、资源依赖或说明文件;客户端只负责项目路径和基本控制面边界,不要求固定文件、任务顺序、验证或完成回执。", @@ -67,7 +160,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { }), json!({ "name": "taonier_prepare_game_art", - "description": "创建或安全恢复当前 AGC 项目的陶泥儿标准游戏美术包。付费提交、幂等键、operation 恢复、来源校验、下载解码和登记均由客户端确定性执行。授权由 AGC 客户端当前登录会话和受控后端完成,用户不需要提供、配置、粘贴或创建 API Key;401/403 只能报告为客户端登录或权限状态异常,不得向用户索要凭据或暴露内部 URL。regenerate 还必须通过客户端对当前用户消息签发的单回合稳定调用授权;模型参数和 MCP 自动批准本身不构成替换授权。仅在用户意图确实需要新美术时调用。", + "description": "创建或安全恢复当前 AGC 项目的陶泥儿标准游戏美术包。付费提交、幂等键、operation 恢复、来源校验、下载解码和登记均由客户端确定性执行。授权由 AGC 客户端当前登录会话和受控后端完成,用户不需要提供、配置、粘贴或创建 API Key;401/403 只能报告为客户端登录或权限状态异常,不得向用户索要凭据或暴露内部 URL。Codex 根据当前对话决定是否调用 regenerate;客户端不解析用户文本,也不替 Codex 判断意图。", "inputSchema": { "type": "object", "properties": { @@ -81,7 +174,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value { "type": "string", "enum": ["reuse-or-create", "regenerate"], "default": "reuse-or-create", - "description": "缺省安全复用有效美术包;只有用户明确要求换一套或重新生成时使用 regenerate" + "description": "缺省安全复用有效美术包;Codex 仅在当前对话需要换一套或重新生成时使用 regenerate" } }, "required": ["brief"], @@ -763,7 +856,9 @@ fn tool_search_max_results(arguments: &Value) -> Result { } fn direct_tool_bridge_url() -> Result { - let value = std::env::var(DIRECT_TOOL_BRIDGE_URL_ENV) + let value = EXTERNAL_MCP_BRIDGE_URL + .try_with(Clone::clone) + .or_else(|_| 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 @@ -974,6 +1069,31 @@ async fn call_agc_web_search(arguments: &Value) -> Value { call_agc_web_search_with_enabled(arguments, controlled_web_search_enabled()).await } +fn call_agc_read_skill_resource(arguments: &Value) -> Value { + if let Err(error) = validate_tool_object_fields(arguments, &["skillName", "relativePath"]) { + return mcp_tool_result(error, Vec::new(), true); + } + let skill = match bounded_tool_string(arguments, "skillName", 120) { + Ok(value) => value, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + let relative = match bounded_tool_string(arguments, "relativePath", 240) { + Ok(value) => value, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + if Path::new(&relative).is_absolute() + || relative.contains("..") + || relative.contains(':') + || relative.contains('\\') + { + return mcp_tool_result("Skill 资源路径不安全".to_string(), Vec::new(), true); + } + match read_agc_skill_resource(&format!("{skill}/{relative}")) { + Ok(content) => mcp_tool_result(content, Vec::new(), false), + Err(error) => mcp_tool_result(error, Vec::new(), true), + } +} + async fn call_agc_web_search_with_enabled(arguments: &Value, enabled: bool) -> Value { if !enabled { return mcp_tool_result("AGC 受控联网搜索未启用".to_string(), Vec::new(), true); @@ -997,7 +1117,348 @@ async fn call_agc_web_search_with_enabled(arguments: &Value, enabled: bool) -> V .await } -async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option { +fn external_mcp_journal_path(root: &Path) -> PathBuf { + root.join(EXTERNAL_MCP_JOURNAL_RELATIVE_PATH) +} + +fn redact_external_mcp_response(content: &str) -> String { + content + .lines() + .map(|line| { + let lower = line.to_ascii_lowercase(); + let sensitive = [ + "authorization:", + "cookie:", + "set-cookie:", + "api_key", + "apikey", + "access_token", + "refresh_token", + "client_secret", + "password:", + "bearer ", + ] + .iter() + .any(|marker| lower.contains(marker)); + if sensitive { + "[redacted sensitive response line]".to_string() + } else { + line.to_string() + } + }) + .collect::>() + .join("\n") +} + +fn external_mcp_response_summary(content: &str) -> String { + let normalized = content.split_whitespace().collect::>().join(" "); + normalized + .chars() + .take(EXTERNAL_MCP_RESPONSE_SUMMARY_MAX_CHARS) + .collect() +} + +fn external_mcp_session_id(root: &Path) -> String { + let mut material = root.to_string_lossy().into_owned(); + if let Some(session) = current_platform_session() { + material.push('\0'); + material.push_str(&session.user_id); + material.push('\0'); + material.push_str(&session.generation.to_string()); + } + format!("mcp-{:x}", Sha256::digest(material.as_bytes())) +} + +fn external_mcp_project_id(root: &Path) -> String { + std::fs::read(root.join(".agent/manifest.json")) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|value| { + value + .get("projectId") + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| { + format!( + "project-{:x}", + Sha256::digest(root.to_string_lossy().as_bytes()) + ) + }) +} + +fn external_mcp_account_id() -> String { + current_platform_session() + .map(|session| format!("account-{:x}", Sha256::digest(session.user_id.as_bytes()))) + .unwrap_or_else(|| "account-unknown".to_string()) +} + +fn validate_external_mcp_record_arguments( + arguments: &Value, +) -> Result<(String, u64, String), String> { + validate_tool_object_fields(arguments, &["requestId", "sequence", "content"])?; + let request_id = bounded_tool_string(arguments, "requestId", 160)?; + let sequence = arguments + .get("sequence") + .and_then(Value::as_u64) + .ok_or_else(|| "工具参数 sequence 必须是非负整数".to_string())?; + if sequence > 1_000_000 { + return Err("工具参数 sequence 超出安全边界".to_string()); + } + let content = arguments + .get("content") + .and_then(Value::as_str) + .ok_or_else(|| "工具参数 content 必须是字符串".to_string())?; + if content.is_empty() || content.chars().count() > EXTERNAL_MCP_RESPONSE_MAX_CHARS { + return Err("工具参数 content 不能为空或超过大小上限".to_string()); + } + if content + .chars() + .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t')) + { + return Err("工具参数 content 不能包含控制字符".to_string()); + } + Ok((request_id, sequence, content.to_string())) +} + +fn read_external_mcp_journal(root: &Path) -> Result, String> { + let path = external_mcp_journal_path(root); + let Ok(bytes) = std::fs::read(&path) else { + return Ok(Vec::new()); + }; + if bytes.len() as u64 > EXTERNAL_MCP_JOURNAL_MAX_BYTES { + return Err("Codex 返回记录超过客户端保留上限".to_string()); + } + bytes + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + .map(|line| { + serde_json::from_slice::(line).map_err(|_| "Codex 返回记录格式损坏".to_string()) + }) + .collect() +} + +fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value { + if let Err(error) = enforce_project_permission_policy(root, "conversation.write") { + return mcp_tool_result(error, Vec::new(), true); + } + let (request_id, sequence, content) = match validate_external_mcp_record_arguments(arguments) { + Ok(value) => value, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + let redacted = redact_external_mcp_response(&content); + let key = format!("{request_id}\u{0}{sequence}"); + let message_id = format!("external-codex-{:x}", Sha256::digest(key.as_bytes())); + let guard = EXTERNAL_MCP_JOURNAL_LOCK + .get_or_init(|| Mutex::new(())) + .lock(); + if guard.is_err() { + return mcp_tool_result("Codex 返回记录锁不可用".to_string(), Vec::new(), true); + } + let mut records = match read_external_mcp_journal(root) { + Ok(records) => records, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + if let Some(existing) = records.iter().find(|record| { + record.get("requestId").and_then(Value::as_str) == Some(request_id.as_str()) + && record.get("sequence").and_then(Value::as_u64) == Some(sequence) + }) { + return mcp_tool_result(existing.to_string(), Vec::new(), false); + } + if let Some(max_sequence) = records + .iter() + .filter(|record| { + record.get("requestId").and_then(Value::as_str) == Some(request_id.as_str()) + }) + .filter_map(|record| record.get("sequence").and_then(Value::as_u64)) + .max() + { + if sequence != max_sequence.saturating_add(1) { + return mcp_tool_result( + "工具参数 sequence 必须按 requestId 连续递增".to_string(), + Vec::new(), + true, + ); + } + } else if sequence != 0 { + return mcp_tool_result( + "同一 requestId 的首条记录 sequence 必须为 0".to_string(), + Vec::new(), + true, + ); + } + let path = external_mcp_journal_path(root); + if let Some(parent) = path.parent() { + if let Err(error) = std::fs::create_dir_all(parent) { + return mcp_tool_result( + format!("创建 Codex 返回记录目录失败:{error}"), + Vec::new(), + true, + ); + } + } + let record = json!({ + "recordId": uuid::Uuid::new_v4().to_string(), + "recordType": "codex.response", + "accountId": external_mcp_account_id(), + "projectId": external_mcp_project_id(root), + "sessionId": external_mcp_session_id(root), + "requestId": request_id, + "sequence": sequence, + "receivedAt": std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_default(), + "content": redacted, + "contentSha256": format!("{:x}", Sha256::digest(redacted.as_bytes())), + "summary": external_mcp_response_summary(&redacted), + "truncated": false, + "status": "completed" + }); + let line = match serde_json::to_string(&record) { + Ok(line) => line, + Err(error) => { + return mcp_tool_result( + format!("序列化 Codex 返回记录失败:{error}"), + Vec::new(), + true, + ) + } + }; + let current_size = std::fs::metadata(&path) + .map(|metadata| metadata.len()) + .unwrap_or(0); + if current_size.saturating_add(line.len() as u64 + 1) > EXTERNAL_MCP_JOURNAL_MAX_BYTES { + return mcp_tool_result( + "Codex 返回记录达到客户端保留上限".to_string(), + Vec::new(), + true, + ); + } + let _project_lock = match acquire_project_write_lock(root, "conversation.write") { + Ok(lock) => lock, + Err(error) => { + return mcp_tool_result(format!("项目对话锁不可用:{error}"), Vec::new(), true) + } + }; + let append_result = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .and_then(|mut file| { + use std::io::Write as _; + file.write_all(line.as_bytes())?; + file.write_all(b"\n")?; + file.sync_data() + }); + if let Err(error) = append_result { + return mcp_tool_result( + format!("写入 Codex 返回记录失败:{error}"), + Vec::new(), + true, + ); + } + // Reuse the existing conversation projection so the current UI can read + // the explicit external response without treating it as business truth. + if let Err(error) = append_local_conversation_message_for_session_idempotent_at( + root, + None, + None, + LocalConversationMessage { + role: "assistant".to_string(), + content: record + .get("content") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + agent_id: None, + }, + &message_id, + ) { + return mcp_tool_result( + format!("Codex 返回已写入但对话投影失败:{error}"), + Vec::new(), + true, + ); + } + records.push(record.clone()); + mcp_tool_result(record.to_string(), Vec::new(), false) +} + +fn external_mcp_session_info(root: &Path) -> Value { + let manifest = std::fs::read(root.join(".agent/manifest.json")) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + let project_id = manifest + .as_ref() + .and_then(|value| value.get("projectId")) + .and_then(Value::as_str) + .unwrap_or("unknown") + .to_string(); + mcp_tool_result( + json!({ + "status": "bound", + "projectId": project_id, + "sessionId": external_mcp_session_id(root), + "transport": "loopback-or-stdio" + }) + .to_string(), + Vec::new(), + false, + ) +} + +fn external_mcp_conversation_list(root: &Path, arguments: &Value) -> Value { + if let Err(error) = validate_tool_object_fields(arguments, &["offset", "limit"]) { + return mcp_tool_result(error, Vec::new(), true); + } + let offset = arguments.get("offset").and_then(Value::as_u64).unwrap_or(0) as usize; + let limit = arguments.get("limit").and_then(Value::as_u64).unwrap_or(20) as usize; + if offset > 10_000 || !(1..=100).contains(&limit) { + return mcp_tool_result( + "conversation.list 分页参数超出安全边界".to_string(), + Vec::new(), + true, + ); + } + match read_external_mcp_journal(root) { + Ok(records) => mcp_tool_result( + json!({ "entries": records.into_iter().skip(offset).take(limit).map(|record| json!({ + "recordId": record.get("recordId"), "requestId": record.get("requestId"), + "sequence": record.get("sequence"), "receivedAt": record.get("receivedAt"), + "summary": record.get("summary"), "status": record.get("status") + })).collect::>() }) + .to_string(), + Vec::new(), + false, + ), + Err(error) => mcp_tool_result(error, Vec::new(), true), + } +} + +fn external_mcp_conversation_read(root: &Path, arguments: &Value) -> Value { + if let Err(error) = validate_tool_object_fields(arguments, &["recordId"]) { + return mcp_tool_result(error, Vec::new(), true); + } + let record_id = match bounded_tool_string(arguments, "recordId", 80) { + Ok(value) => value, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; + match read_external_mcp_journal(root) { + Ok(records) => records + .into_iter() + .find(|record| { + record.get("recordId").and_then(Value::as_str) == Some(record_id.as_str()) + }) + .map(|record| mcp_tool_result(record.to_string(), Vec::new(), false)) + .unwrap_or_else(|| { + mcp_tool_result("未找到 Codex 返回记录".to_string(), Vec::new(), true) + }), + Err(error) => mcp_tool_result(error, Vec::new(), true), + } +} + +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() { @@ -1014,7 +1475,10 @@ async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option id, json!({ "protocolVersion": requested_protocol, - "capabilities": { "tools": { "listChanged": false } }, + "capabilities": { + "tools": { "listChanged": false }, + "resources": { "subscribe": false, "listChanged": false } + }, "serverInfo": { "name": "genarrative-agc-tools", "version": env!("CARGO_PKG_VERSION") @@ -1023,6 +1487,75 @@ async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option )) } "ping" => Some(mcp_success(id, json!({}))), + "resources/list" => Some(mcp_success( + id, + json!({ + "resources": [{ + "uri": "agc://skills/index", + "name": "AGC Skill 索引", + "description": "审核通过的客户端 Skill 与工具使用指导", + "mimeType": "text/plain" + }, { + "uri": "agc://conversation/codex-responses", + "name": "Codex 返回记录", + "description": "当前项目中由 conversation.record_codex_response 写入的只读 journal", + "mimeType": "application/x-ndjson" + }] + }), + )), + "resources/read" => { + let uri = request + .pointer("/params/uri") + .and_then(Value::as_str) + .unwrap_or_default(); + if uri == "agc://skills/index" { + let text = render_agc_skill_pack_index() + .map_err(|_| ()) + .unwrap_or_else(|_| "AGC Skill 索引暂不可用".to_string()); + return Some(mcp_success( + id, + json!({ "contents": [{ "uri": uri, "mimeType": "text/plain", "text": text }] }), + )); + } + if let Some(resource) = uri.strip_prefix("agc://skills/") { + return match read_agc_skill_resource(resource) { + Ok(text) if text.len() <= DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES => { + Some(mcp_success( + id, + json!({ "contents": [{ "uri": uri, "mimeType": "text/plain", "text": text }] }), + )) + } + Ok(_) => Some(mcp_error(id, -32000, "AGC Skill 资源超过响应大小上限")), + Err(_) => Some(mcp_error(id, -32602, "未知或未审核的 AGC Skill 资源")), + }; + } + if uri != "agc://conversation/codex-responses" { + Some(mcp_error(id, -32602, "未知资源")) + } else { + let text = read_external_mcp_journal(root) + .map(|records| { + records + .iter() + .map(Value::to_string) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + if text.len() > DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES { + return Some(mcp_error(id, -32000, "Codex 返回记录资源超过响应大小上限")); + } + Some(mcp_success( + id, + json!({ + "contents": [{ + "uri": uri, + "mimeType": "application/x-ndjson", + "text": text + }] + }), + )) + } + } "tools/list" => Some(mcp_success(id, direct_tools_mcp_specs())), "tools/call" => { let tool = request @@ -1034,6 +1567,13 @@ async fn handle_direct_tools_mcp_request(_root: &Path, request: Value) -> Option .cloned() .unwrap_or_else(|| json!({})); let result = match tool { + "client.session.info" => external_mcp_session_info(root), + "conversation.record_codex_response" => { + external_mcp_record_response(root, &arguments) + } + "conversation.list" => external_mcp_conversation_list(root, &arguments), + "conversation.read" => external_mcp_conversation_read(root, &arguments), + "agc_read_skill_resource" => call_agc_read_skill_resource(&arguments), "agc_write_file" => call_agc_write_file(&arguments).await, "taonier_prepare_game_art" => call_taonier_prepare_game_art(&arguments).await, "agc_generate_image" => call_agc_generate_image(&arguments).await, @@ -1122,6 +1662,108 @@ async fn run_direct_tools_mcp_stdio() -> Result<(), String> { Ok(()) } +fn external_mcp_authorized(headers: &HeaderMap, token: &str) -> bool { + headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .is_some_and(|value| value == token) +} + +async fn handle_external_mcp_http_request( + AxumState(state): AxumState, + headers: HeaderMap, + Json(request): Json, +) -> Result, StatusCode> { + if !external_mcp_authorized(&headers, &state.token) { + return Err(StatusCode::UNAUTHORIZED); + } + let Some(session) = current_platform_session() else { + return Err(StatusCode::UNAUTHORIZED); + }; + if session.user_id != state.session_user_id || session.generation != state.session_generation { + return Err(StatusCode::UNAUTHORIZED); + } + let response = EXTERNAL_MCP_BRIDGE_URL + .scope( + state.bridge_url.clone(), + handle_direct_tools_mcp_request(&state.root, request), + ) + .await + .ok_or(StatusCode::BAD_REQUEST)?; + Ok(Json(response)) +} + +pub(crate) async fn start_external_mcp_loopback( + root: &Path, + controlled_web_search: bool, +) -> Result<(String, String), String> { + let root = validate_direct_tools_project_root(root)?; + let session = current_platform_session() + .ok_or_else(|| "启动客户端 MCP 前必须先完成账号会话绑定".to_string())?; + let token = uuid::Uuid::new_v4().to_string(); + let route = format!("/mcp-{}", uuid::Uuid::new_v4().simple()); + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .map_err(|error| format!("启动客户端 MCP loopback 失败:{error}"))?; + let address = listener + .local_addr() + .map_err(|error| format!("读取客户端 MCP 地址失败:{error}"))?; + let bridge = + super::direct_tool_bridge::start_direct_tool_bridge(&root, controlled_web_search).await?; + let state = ExternalMcpHttpState { + bridge_url: bridge.url().to_string(), + root, + token: token.clone(), + session_user_id: session.user_id, + session_generation: session.generation, + }; + let app = Router::new() + .route(&route, post(handle_external_mcp_http_request)) + .layer(DefaultBodyLimit::max(DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES)) + .with_state(state); + let task = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + let url = format!("http://127.0.0.1:{}{route}", address.port()); + let registry = EXTERNAL_MCP_SERVER.get_or_init(|| Mutex::new(None)); + let mut guard = registry + .lock() + .map_err(|_| "客户端 MCP 服务注册表不可用".to_string())?; + if let Some(previous) = guard.take() { + drop(previous); + } + *guard = Some(ExternalMcpServer { + _bridge: bridge, + url: url.clone(), + token: token.clone(), + task, + }); + Ok((url, token)) +} + +pub(crate) fn stop_external_mcp_loopback() { + if let Some(registry) = EXTERNAL_MCP_SERVER.get() { + if let Ok(mut guard) = registry.lock() { + guard.take(); + } + } +} + +#[tauri::command] +pub(crate) async fn start_game_creator_external_mcp(project_path: String) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + let (url, token) = start_external_mcp_loopback(root, false).await?; + Ok(json!({ "url": url, "token": token, "transport": "streamable-http" })) +} + +#[tauri::command] +pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> { + stop_external_mcp_loopback(); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -1210,6 +1852,11 @@ mod tests { assert_eq!( names, vec![ + "client.session.info", + "conversation.record_codex_response", + "conversation.list", + "conversation.read", + "agc_read_skill_resource", "agc_write_file", "taonier_prepare_game_art", "agc_generate_image", @@ -1243,9 +1890,10 @@ mod tests { "reuse-or-create" ); assert_eq!(art_tool["inputSchema"]["required"], json!(["brief"])); - assert!(art_tool["description"].as_str().is_some_and( - |description| description.contains("模型参数和 MCP 自动批准本身不构成替换授权") - )); + assert!(art_tool["description"].as_str().is_some_and(|description| { + description.contains("Codex 根据当前对话决定是否调用 regenerate") + && description.contains("客户端不解析用户文本") + })); assert!(art_tool["description"].as_str().is_some_and(|description| { description.contains("用户不需要提供、配置、粘贴或创建 API Key") && description.contains("不得向用户索要凭据或暴露内部 URL") @@ -1583,4 +2231,53 @@ mod tests { assert_eq!(response["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); + + let denied_windows_absolute = call_agc_read_skill_resource(&json!({ + "skillName": "agc-project-structure", + "relativePath": r"C:\temp\SKILL.md" + })); + assert_eq!(denied_windows_absolute["isError"], true); + } + + #[test] + fn external_codex_response_redacts_sensitive_lines_and_keeps_safe_text() { + let response = redact_external_mcp_response( + "完成了页面布局\nAuthorization: Bearer secret-value\n下一步请运行试玩", + ); + assert!(response.contains("完成了页面布局")); + assert!(response.contains("下一步请运行试玩")); + assert!(!response.contains("secret-value")); + } + + #[test] + fn external_codex_response_arguments_reject_unknown_fields_and_control_bytes() { + assert!(validate_external_mcp_record_arguments(&json!({ + "requestId": "req-1", + "sequence": 0, + "content": "ok", + "unexpected": true + })) + .is_err()); + assert!(validate_external_mcp_record_arguments(&json!({ + "requestId": "req-1", + "sequence": 0, + "content": "bad\u{0001}" + })) + .is_err()); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index f9c5a4ab3..8d3977878 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -78,7 +78,7 @@ pub(crate) use draft_validation::{ validate_llm_agent_handoffs, validate_llm_game_draft, validate_non_placeholder_game_html, validate_playable_game_html, validate_safe_game_html_runtime, }; -pub(crate) use draft_writer::write_local_game_draft_at; +pub(crate) use draft_writer::{ensure_legacy_json_generator_project, write_local_game_draft_at}; #[allow(unused_imports)] pub(crate) use loop_orchestration::{ emit_agent_progress, game_creator_agent_llm_error_is_mud_points_insufficient, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index e3cee4f47..0e7a97084 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -412,6 +412,7 @@ pub(crate) struct PlatformArtAssetGenerationOptions { pub(crate) asset_kind: String, pub(crate) asset_label: String, pub(crate) replace_existing: bool, + pub(crate) slice_count: Option, } impl Default for PlatformArtAssetGenerationOptions { @@ -423,6 +424,7 @@ impl Default for PlatformArtAssetGenerationOptions { asset_kind: "game-art".to_string(), asset_label: "AI 游戏首版美术素材".to_string(), replace_existing: false, + slice_count: None, } } } @@ -681,6 +683,47 @@ pub(in crate::agent) fn platform_art_generation_error_result_unknown(error: &str error.starts_with(EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX) } +/// Observe an accepted operation once without waiting. A single GET that +/// reports `failed` is authoritative and allows a changed retry to release +/// the old local slot; queued/running/unknown outcomes remain protected. +async fn accepted_generation_is_authoritatively_failed_once( + client: &reqwest::Client, + access: &ExternalEditorBindingAccess<'_>, + submission_payload: &serde_json::Value, +) -> Result { + let submission = external_editor_response_data(submission_payload); + let operation_id = json_string_field(submission, "operationId") + .ok_or_else(|| "External Editor accepted 账本缺少 operationId".to_string())?; + access.validate_frozen_session()?; + let payload = tokio::time::timeout( + Duration::from_secs(3), + external_editor_json_request( + client + .get(format!( + "{}{}", + access.api_base_url(), + access.generation_status_route(&operation_id) + )) + .bearer_auth(access.bearer_token()), + "查询平台图片生成任务", + ), + ) + .await + .map_err(|_| "查询平台图片生成任务超时".to_string())??; + access.validate_frozen_session()?; + let generation = platform_generation_status_data(&payload); + match json_string_field(generation, "status").as_deref() { + Some("failed") => Ok(true), + Some("queued" | "running" | "completed") => Ok(false), + Some(status) => Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务返回未知状态 {status};operationId={operation_id}" + )), + None => Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务状态响应缺少 status;operationId={operation_id}" + )), + } +} + pub(crate) async fn external_editor_json_request( request: reqwest::RequestBuilder, action: &str, @@ -1714,24 +1757,16 @@ fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec { // long creation request cannot reject the atlas before it is queued. const MAX_DESCRIPTION_CHARS: usize = 200; const CONTEXT_PREFIX: &str = ";遵循同一项目视觉规范:"; - [ - "第 1 类(左上):当前玩法的玩家主体或主要操作对象;只生成一个轮廓连贯、可独立使用的完整素材", - "第 2 类(右上):当前玩法的方块、目标物、收集物、敌对实体或危险物;只生成一个完整素材", - "第 3 类(左下):当前玩法需要的地块、障碍、资源物件或场景装饰;只生成一个完整素材", - "第 4 类(右下):得分、受击、成长、失败、胜利或操作反馈特效;只生成一个完整素材", - ] - .into_iter() - .map(|category| { - let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub( - category - .chars() - .count() - .saturating_add(CONTEXT_PREFIX.chars().count()), - ); - let project_context = truncate_inline_bounded(prompt.trim(), context_budget); - format!("{category}{CONTEXT_PREFIX}{project_context}") - }) - .collect() + let category = + "按当前项目需求生成一组可独立使用的透明素材;数量、类别、排列和切片方式由本次需求决定"; + let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub( + category + .chars() + .count() + .saturating_add(CONTEXT_PREFIX.chars().count()), + ); + let project_context = truncate_inline_bounded(prompt.trim(), context_budget); + vec![format!("{category}{CONTEXT_PREFIX}{project_context}")] } fn truncate_inline_bounded(value: &str, max_chars: usize) -> String { @@ -2043,13 +2078,13 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at( } let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); let runtime_context = - standalone_platform_art_generation_runtime_context(&generation_prompt, options, true)?; + standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?; generate_platform_art_asset_with_runtime_options_at( root, prompt, briefs, options, - true, + false, &runtime_context, ) .await @@ -2427,6 +2462,34 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at ) })?; if snapshot.generation_prompt != generation_prompt { + if platform_art_generation_runtime_status(&state) == "accepted" { + if let Ok(submission) = platform_art_generation_runtime_submission_payload(&state) { + if accepted_generation_is_authoritatively_failed_once( + &client, + &binding_access, + &submission, + ) + .await + .unwrap_or(false) + { + if let Some(context) = runtime_context { + remove_platform_art_generation_runtime_state_at( + root, + &context.agent_id, + &context.run_id, + )?; + } + return Box::pin(request_platform_art_asset_with_runtime_options_at( + root, + prompt, + briefs, + options, + runtime_context, + )) + .await; + } + } + } return Err(format!( "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前生成意图与已持久化请求快照不一致,已拒绝将旧操作当作本次请求恢复;原生成账本已保留,需要先完成或对账旧操作" )); @@ -2448,6 +2511,36 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at ) })?; if snapshot.reference_resource_ids != [current_reference] { + if platform_art_generation_runtime_status(&state) == "accepted" { + if let Ok(submission) = + platform_art_generation_runtime_submission_payload(&state) + { + if accepted_generation_is_authoritatively_failed_once( + &client, + &binding_access, + &submission, + ) + .await + .unwrap_or(false) + { + if let Some(context) = runtime_context { + remove_platform_art_generation_runtime_state_at( + root, + &context.agent_id, + &context.run_id, + )?; + } + return Box::pin(request_platform_art_asset_with_runtime_options_at( + root, + prompt, + briefs, + options, + runtime_context, + )) + .await; + } + } + } return Err(format!( "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前规范图身份与已持久化派生请求不一致,已拒绝恢复旧操作;原生成账本已保留,需要先完成或对账旧操作" )); @@ -2556,7 +2649,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at serde_json::json!({ "referenceId": reference_id, "iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt), - "sliceLayout": "grid-2x2", + "sliceCount": options.slice_count, "screenColor": "auto", "aspectRatio": options.aspect_ratio, "imageSize": options.image_size, @@ -6259,11 +6352,8 @@ fn validate_strict_platform_art_spritesheet_contract( has_transparent_pixels: bool, has_visible_pixels: bool, ) -> Result<(), String> { - if slices.len() != 4 { - return Err(format!( - "strict spritesheet 图集必须恰好包含 4 个独立切片,实际为 {} 个", - slices.len() - )); + if slices.is_empty() { + return Err("spritesheet 图集至少需要一个独立切片".to_string()); } let resource_id = resource_id .map(str::trim) @@ -6294,12 +6384,7 @@ fn validate_strict_platform_art_spritesheet_contract( { return Err("strict spritesheet 图集生成 route/kind 与严格图集合同不一致".to_string()); } - if spritesheet_slice_layout.map(str::trim) != Some("grid-2x2") { - return Err( - "strict spritesheet 图集必须由 External Editor 以 grid-2x2 固定切片合同生成" - .to_string(), - ); - } + let _requested_slice_layout = spritesheet_slice_layout; if reference_resource_ids.len() != 1 || reference_resource_ids[0].trim().is_empty() || reference_resource_ids[0].trim() == resource_id @@ -6634,7 +6719,7 @@ fn existing_platform_art_slice_registrations_are_complete( manifest: &GameCreationAppManifest, registrations: &[PlatformArtSliceManifestRegistration], ) -> Result { - if registrations.len() != 4 { + if registrations.is_empty() { return Ok(false); } let mut resource_ids = std::collections::HashSet::with_capacity(registrations.len()); @@ -7594,6 +7679,7 @@ mod canvas_generation_tests { asset_kind: "game-background".to_string(), asset_label: "手工背景".to_string(), replace_existing: true, + slice_count: None, }; let ordinary = standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false) @@ -9402,6 +9488,7 @@ mod canvas_generation_tests { asset_kind: "icon-spec".to_string(), asset_label: "整包规范图".to_string(), replace_existing: false, + slice_count: None, }; let prompt = "生成同一套整包美术"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -10239,6 +10326,7 @@ mod canvas_generation_tests { asset_kind: "game-background".to_string(), asset_label: "整包背景图".to_string(), replace_existing: false, + slice_count: None, }; let prompt = "保持同一个生成提示词"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -10690,6 +10778,7 @@ mod canvas_generation_tests { asset_kind: "icon-spec".to_string(), asset_label: "游戏统一视觉规范图".to_string(), replace_existing: false, + slice_count: None, }; let prompt = "恢复已受理视觉规范图"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -11264,6 +11353,7 @@ mod canvas_generation_tests { asset_kind: "art-spritesheet".to_string(), asset_label: "游戏首版核心美术素材".to_string(), replace_existing: true, + slice_count: None, } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs index b3a84948e..ac67a164a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/draft_writer.rs @@ -9,8 +9,8 @@ pub(crate) fn write_local_game_draft_at( if prompt.is_empty() { return Err("创作想法不能为空".to_string()); } + ensure_legacy_json_generator_project(root)?; validate_llm_game_draft(prompt, draft)?; - init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?; let checkpoint = create_local_project_checkpoint_at(root)?; let timestamp = unix_timestamp(); let title = draft.title.trim(); @@ -134,3 +134,38 @@ pub(crate) fn write_local_game_draft_at( manifest, }) } + +pub(crate) fn ensure_legacy_json_generator_project(root: &Path) -> Result<(), String> { + if root.join("game/package.json").exists() + || root.join("package.json").exists() + || !root.join("game/index.html").is_file() + || !root.join(".agent/manifest.json").is_file() + { + return Err("JSON Generator 仅支持已有的单文件 HTML 项目;新建游戏与 npm / Phaser 4 项目请使用 DirectProject 完成依赖安装、构建和试玩".to_string()); + } + Ok(()) +} + +#[cfg(test)] +mod legacy_generator_tests { + use super::*; + + #[test] + fn legacy_generator_rejects_npm_before_writing() { + let root = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + fs::create_dir_all(root.path().join("game")).unwrap(); + fs::create_dir_all(root.path().join(".agent")).unwrap(); + fs::write(root.path().join("game/index.html"), "legacy source").unwrap(); + fs::write(root.path().join(".agent/manifest.json"), "{}").unwrap(); + assert!(ensure_legacy_json_generator_project(root.path()).is_ok()); + fs::write(root.path().join("game/package.json"), "{}").unwrap(); + assert!(ensure_legacy_json_generator_project(root.path()) + .unwrap_err() + .contains("DirectProject")); + assert_eq!( + fs::read_to_string(root.path().join("game/index.html")).unwrap(), + "legacy source" + ); + assert!(!root.path().join(".agent/spec.md").exists()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs index e955f53a4..a79d03fbd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs @@ -44,6 +44,7 @@ pub(crate) async fn run_game_creator_agent_loop_at( project_blackboard: &str, progress: Option<&AgentProgressEmitter<'_>>, ) -> Result { + ensure_legacy_json_generator_project(root)?; let spec_path = root.join(".agent/spec.md"); let findings_path = root.join(".agent/findings.md"); let run_id = format!("game-generate-draft-{}", unix_millis()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs index 4f65ace3c..60f63acc3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -24,6 +24,7 @@ JSON schema: } gameHtml 规则: +- 此 JSON 协议仅用于已有的单文件 HTML 项目;npm / Phaser 项目必须使用 DirectProject,不能通过 gameHtml 交付 package.json 或模块源码。 - 必须是单文件 HTML,不能加载远程脚本、远程图片、远程 CSS 或 CDN。 - 必须包含 canvas、canvas getContext、实际绘制调用、键盘或鼠标输入、requestAnimationFrame 主循环、目标、失败或胜利状态、R 或按钮重开。 - JavaScript 不要 eval、Function、localStorage、fetch、WebSocket、ServiceWorker。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs index 7648408da..460d8dfad 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs @@ -488,15 +488,13 @@ fn game_creator_design_foundation_tool_plan_prompt( prompt: &str, editor_api_key_is_configured: bool, ) -> String { - let role_boundary = "角色边界:项目文件写入只允许 memory/project.md 与 game/game_design.md;配置 External Editor API Key 且任务要求界面原型时,可额外产出 assets/ui-prototype.png 与 Runtime 发现清单要求的 assets/ui-pages/*.png;UI 设计图生成后只能通过受控 ui.workflow.run 写入或关联 UI JSON、保存工作流阶段并应用页面,不得绕过该工具直接写入 UI State。不得创建、修改、删除或补丁 game/index.html,也不得改动任何其他程序实现、发布、音频或美术素材文件。完成固定正式产物后直接交付,由 Runtime 在收束门内验证本人 owner 产物;不得调用 project.verify、command.run_limited、game.static_smoke、preview.start 或 preview.validate,也不得通过 command.exec、command.start 或其他工具启动本地预览服务、浏览器、Playwright,或执行任何桌面端、移动端试玩验证。完整 DAG 的最终静态验收仍属于 preview-readiness,浏览器验收仍属于 preview-playtest。"; + let role_boundary = "角色边界:只负责玩法规格、界面建议和视觉工具使用指导。项目文件与图片输出必须服从当前任务明确要求;不创建固定图片槽位,不规定固定数量或布局,不修改 game/index.html,不启动预览或试玩。"; if !editor_api_key_is_configured { return format!( "{prompt}\n\n你负责玩法规格与界面原型基础交付。当前未配置 External Editor API Key,因此本轮必须完成 memory/project.md 与 game/game_design.md,不调用 canvas.asset_generate,也不伪造 assets/ui-prototype.png。把界面结构、控件、状态和双视口要求写进玩法规格;game/game_design.md 必须为每个功能页面各写一行 @genarrative-ui-page {{\"pageId\":\"稳定英文ID\",\"title\":\"页面标题\",\"description\":\"页面用途\",\"applicationPath\":\"game/index.html\"}},供 Runtime 自动发现和后续程序组实现;完成写入后直接交付,不要自行运行任何验证命令。{role_boundary}" ); } - format!( - "{prompt}\n\n你负责玩法规格与界面原型交付。玩法类型和机制描述不代表用户授权复刻现有游戏;必须先为项目创造原创标题、实体、资源、目标名称与视觉语言,并在 memory/project.md、game/game_design.md 和图片提示中保持一致;game/game_design.md 必须为每个功能页面各写一行 @genarrative-ui-page {{\"pageId\":\"稳定英文ID\",\"title\":\"页面标题\",\"description\":\"页面用途\",\"applicationPath\":\"game/index.html\"}},作为 Runtime 自动发现的权威设计声明。不得沿用或近似改写知名游戏单位、角色、Logo、界面术语或受保护视觉语言。文本策划只是中间结果;最终必须先用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,再调用 canvas.asset_generate 生成 16:9、2K 横屏界面原型图并登记到 assets/ui-prototype.png,assetKind=ui-prototype、assetLabel=游戏横屏界面原型图、replaceExisting=false。图片 prompt 必须逐项继承当前任务和 game/game_design.md 的真实玩法、HUD、可玩区域、关键实体、主要操作、失败/重开与移动端触控要求;不得假设为塔防或补入合同中不存在的单位卡牌、费用、波次、敌人入口等结构。Runtime 固定把规范图资源作为 referenceImageSrcs 第一项,调用 External Editor v1 的 POST /api/external/v1/editor/images/generations(kind=ui-design);不得误用 POST /api/external/v1/editor/ui-designs/assets/extractions,后者只用于从已有且带标注的 UI 设计图提取独立透明 UI 素材。缺少规范图时必须等待 art-director 依赖并如实阻塞,不得回退为无规范参考的普通生图。canvas.asset_generate 成功只表示候选图片已生成并登记,不等于视觉验收完成。已有同路径画布资产时先核对登记,再在当前 run 对且只对 assets/ui-prototype.png 调用 image.inspect;检查已通过时不得重复生成或再次扣费。只有 ui-prototype.v2 的 informationHud、gameplaySurface、objectiveEntities、primaryControls、failureRestartFlow、responsiveLayout、implementationClarity、originalTheme 八项检查全部通过才可完成。八项视觉检查通过后,先调用 ui.workflow.run 的 discover 自动读取受控页面声明,不得凭空猜页面;再按返回的每个 pageId 逐页调用 canvas.asset_generate,以固定 16:9、2K、assetKind=ui-prototype、replaceExisting=false 生成并登记对应 assets/ui-pages/{{pageId}}.png 设计图,assetLabel 使用该页标题,使用发现的真实 applicationPath 依次执行 prepare、recognize、status,确认所有页面均无 blockers 后再执行 finalize。该工具会创建并关联 kind=UI 的 JSON 编辑资源、持久化每一阶段 State、同步 manifest/客户端,并在完成后返回 visual-binding 最终编辑器路由;只登记 ui-prototype 图片或只写计划不算完成。由 Runtime 在收束门内同时核对固定 owner 文档、当前 revision 与视觉证据。纯场景图、概念图、地图、海报或只有角色而没有可玩界面的画面都不是 UI 原型。视觉检查未通过时不得提交最终回复;只有任务正文明确标识这是带 repairOfDelegationId 的唯一返工轮时,才可使用固定输出合同和 replaceExisting=true 原位替换旧候选;不得先删除正式图片。图片生成未配置、待确认或失败时同样不得提交最终回复,也不得把计划写完当成 completed。{role_boundary}" - ) + format!("{prompt}\n\n根据当前玩法需求编写规格和界面建议;如需图片,明确说明用途、数量、输出路径、尺寸、参考资源和是否需要 spritesheet,再调用 canvas.asset_generate。不要使用固定图片合同。{role_boundary}") } fn game_creator_art_director_tool_plan_prompt( @@ -506,7 +504,7 @@ fn game_creator_art_director_tool_plan_prompt( if !editor_api_key_is_configured { return format!("{prompt}\n\n你负责确定原创视觉方向。当前未配置 External Editor API Key,这是只读协调任务:只完成正式 director 结论并直接交付,不修改项目文件,不调用 canvas.asset_generate,也不伪造 assets/art-spec.png。seed task 中生成规范图的图片产物与验收条款在本轮不适用。"); } - format!("{prompt}\n\n你负责生成项目唯一的统一视觉规范图。视觉方向文档只是中间结果;最终必须调用 canvas.asset_generate,以固定合同 outputPath=assets/art-spec.png、aspectRatio=1:1、imageSize=1K、assetKind=icon-spec、assetLabel=游戏统一视觉规范图、replaceExisting=false 生成真实图片。Runtime 固定调用 External Editor v1 的 POST /api/external/v1/editor/images/generations(kind=spec),并把结果同时登记到同名画布、素材库和项目 manifest。规范图必须覆盖玩家主体、目标物、地块、UI 图标、状态反馈、色板与材质规则,作为后续 UI 和透明图集共同引用的权威资源;不得用 generationInputs.artSpec JSON、纯文本计划、完整游戏截图、海报或普通黑底图集冒充。canvas.asset_generate 成功只表示固定候选已生成并登记,不等于视觉门已经通过;生成成功后直接交付,由 Runtime 在收束时核对当前 revision、Canvas 登记、资源身份和视觉产物门。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可设置 replaceExisting=true 原位替换。生成失败或缺少 resourceId 时不得提交最终回复,也不得把计划写完当成 completed。") + format!("{prompt}\n\n你负责确定原创视觉方向。根据项目实际需要选择 canvas.asset_generate 的 assetKind、outputPath、尺寸、比例和提示词;可以生成一张或多张图片,也可以不生成图片。需要参考图时使用已登记资源 ID,生成后核对返回资源、权限、计费和登记状态;不要假设固定图片名称、数量、素材类别或布局。") } fn game_creator_art_asset_plan_tool_plan_prompt( @@ -519,7 +517,7 @@ fn game_creator_art_asset_plan_tool_plan_prompt( ); } format!( - "{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png,固定使用 1:1、1K、assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材、replaceExisting=false,并写入可解析的 assets/manifest.art.json。调用前必须用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,并依据当前任务、game/game_design.md 与 manifest 逐项说明真实需要的玩家主体及朝向/状态、目标或收集物、障碍/场景元素和反馈特效,由 Runtime 形成 iconDescriptions;不得假设为塔防或加入合同中不存在的单位、敌人、波次、卡牌。Runtime 固定以规范图的权威 resourceId 作为 referenceId,调用 POST /api/external/v1/editor/icon-spritesheets/generations,并用 screenColor=auto 完成透明后处理;不得把 UI 原型、Data URL、Blob URL、本地路径或结构化 JSON 冒充规范图引用,不得回退普通生图或 UI extraction。缺少规范图时必须等待 art-director 依赖并如实阻塞。成功后回读 observation 与 asset.list,核对服务端返回的透明 spritesheet、真实 alpha、warning 和 sliceWarning。warning.code=postprocess-failed-source-preserved 时没有透明图集,不得登记、验收或自动重试;仅 sliceWarning 时可保留完整透明图集,但不得声称独立切片已生成。透明证据核对完成后直接交付,由 Runtime 在收束门内验证本人固定 manifest 产物并复核 Canvas 证据。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可 replaceExisting=true 原位替换。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认、失败或透明证据不足时不得提交最终回复。" + "{prompt}\n\n你负责按项目实际需求规划和生成美术素材。使用 asset.list 了解已有资源,再按需调用 canvas.asset_generate;数量、文件名、素材类别、切片布局和尺寸由当前需求决定,不得套用固定图片包或固定 2x2。spritesheet 可通过 sliceCount 指定切片数量,也可以生成普通单图或多张独立图片。生成后核对资源登记、透明度、警告和实际使用情况。" ) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index 3fa69f520..a617d7f67 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -453,16 +453,9 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_ "首批 art-director 必须是非只读规范图生成任务", )); } - let art_artifacts = + // 图片产物由 Codex 按项目需求决定;不再要求固定 art-spec.png。 + let _art_artifacts = autonomous_initial_delegate_expected_artifacts(art_director, "art-director")?; - if !art_artifacts - .iter() - .any(|path| path == "assets/art-spec.png") - { - return Err(autonomous_initial_collaboration_contract_error( - "首批 art-director 的 expectedArtifacts 必须包含 assets/art-spec.png", - )); - } let code_director = code_director.ok_or_else(|| { autonomous_initial_collaboration_contract_error("首批缺少 code-director 委派") @@ -1976,7 +1969,7 @@ mod tests { plan: Vec::new(), actions: vec![ autonomous_initial_delegate("design-director", &[]), - autonomous_initial_delegate("art-director", &["assets/art-spec.png"]), + autonomous_initial_delegate("art-director", &[]), autonomous_initial_delegate("code-director", &[]), ], response: String::new(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index a3eac4093..d9d062c0f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -1199,32 +1199,36 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked( agent_id: &str, required_run_id: Option<&str>, ) -> Option { - if !editor_api_key_is_configured() { - return None; - } - let (expected_path, expected_kind, label) = match agent_id { - "art-director" => (AGENT_RUNTIME_ART_SPEC_PATH, "icon-spec", "统一视觉规范图"), - "design-foundation" => ("assets/ui-prototype.png", "ui-prototype", "策划界面原型图"), - "art-asset-plan" => ( - "assets/art-spritesheet.png", - "art-spritesheet", - "首版美术素材图", - ), - _ => return None, - }; - let manifest = match read_manifest_for_project(root) { - Ok(manifest) => manifest, - Err(error) => { - return Some(AgentRuntimeToolObservation { - tool: "runtime.visual_asset".to_string(), - status: "blocked".to_string(), - summary: format!("无法核对{label},不能完成任务"), - detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), - }); + // 图片产物由 Codex 按项目需求选择,不再存在固定视觉资产完成门禁。 + return None; + #[allow(unreachable_code)] + { + if !editor_api_key_is_configured() { + return None; } - }; - if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, agent_id) { - return Some(AgentRuntimeToolObservation { + let (expected_path, expected_kind, label) = match agent_id { + "art-director" => (AGENT_RUNTIME_ART_SPEC_PATH, "icon-spec", "统一视觉规范图"), + "design-foundation" => ("assets/ui-prototype.png", "ui-prototype", "策划界面原型图"), + "art-asset-plan" => ( + "assets/art-spritesheet.png", + "art-spritesheet", + "首版美术素材图", + ), + _ => return None, + }; + let manifest = match read_manifest_for_project(root) { + Ok(manifest) => manifest, + Err(error) => { + return Some(AgentRuntimeToolObservation { + tool: "runtime.visual_asset".to_string(), + status: "blocked".to_string(), + summary: format!("无法核对{label},不能完成任务"), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }); + } + }; + if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, agent_id) { + return Some(AgentRuntimeToolObservation { tool: "runtime.visual_asset".to_string(), status: "blocked".to_string(), summary: format!("{label}尚未按正式视觉流程生成并登记,不能完成任务"), @@ -1234,29 +1238,30 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked( redact_agent_runtime_project_paths(root, &error, 300), )), }); - } - if agent_id != "design-foundation" { - return None; - } - match ui_prototype_visual_inspection_blocker_detail_at_locked( - root, - agent_id, - required_run_id, - expected_path, - ) { - Ok(None) => None, - Ok(Some(detail)) => Some(AgentRuntimeToolObservation { - tool: "runtime.visual_asset".to_string(), - status: "blocked".to_string(), - summary: "策划界面原型图尚未通过结构化 UI 视觉检查,不能完成任务".to_string(), - detail: Some(detail), - }), - Err(error) => Some(AgentRuntimeToolObservation { - tool: "runtime.visual_asset".to_string(), - status: "blocked".to_string(), - summary: "无法核对策划界面原型图的结构化 UI 视觉证据,不能完成任务".to_string(), - detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), - }), + } + if agent_id != "design-foundation" { + return None; + } + match ui_prototype_visual_inspection_blocker_detail_at_locked( + root, + agent_id, + required_run_id, + expected_path, + ) { + Ok(None) => None, + Ok(Some(detail)) => Some(AgentRuntimeToolObservation { + tool: "runtime.visual_asset".to_string(), + status: "blocked".to_string(), + summary: "策划界面原型图尚未通过结构化 UI 视觉检查,不能完成任务".to_string(), + detail: Some(detail), + }), + Err(error) => Some(AgentRuntimeToolObservation { + tool: "runtime.visual_asset".to_string(), + status: "blocked".to_string(), + summary: "无法核对策划界面原型图的结构化 UI 视觉证据,不能完成任务".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }), + } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 4ba1c9cb7..2a1b77488 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -294,6 +294,7 @@ pub(crate) async fn generate_local_game_draft_at( if prompt.is_empty() { return Err("创作想法不能为空".to_string()); } + ensure_legacy_json_generator_project(root)?; init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?; let short_memory = read_optional_text(&root.join("memory/session.md"))?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index 45571d8c9..326eab51a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -1756,11 +1756,7 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke } pub(super) fn autonomous_manifest_ready_task_requires_visual_asset(task_id: &str) -> bool { - editor_api_key_is_configured() - && matches!( - task_id, - "art-director" | "design-foundation" | "art-asset-plan" - ) + false } fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTaskState) -> String { @@ -1777,11 +1773,7 @@ fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTask } else { "" }; - let visual_requirement = if task.id == "art-asset-plan" && editor_api_key_is_configured() { - "art-asset-plan 的固定成功路径是:调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png(assetKind=art-spritesheet),然后调用 asset.list 核对图集及四个 canonical 切片已经登记,再调用 file.write 写入 assets/manifest.art.json;完成这组动作后把结构化计划最后一步标记 completed 并立即交付。不要调用 image.inspect,不要根据图片主观观感发起返工或 agent.message;图集视觉质量由后续质量任务处理,Runtime 会在收束门内验证文件和资产登记状态。" - } else { - "任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。" - }; + let visual_requirement = "任务声明中的视觉图片按项目需求选择工具、数量、输出路径、尺寸和布局;需要图集时用 sliceCount 指定切片数量。Runtime 只核对实际声明的资源登记,不要求固定图片合同。"; let verification_requirement = match task.id.as_str() { "code-prototype" => "code-prototype 必须对可玩入口执行 game.static_smoke;完整 DAG 的最终静态与浏览器验收继续由后续质量任务承担。", task_id if agent_runtime_autonomous_uses_owner_artifact_validation(task_id) => "完成固定正式产物后直接交付,由 Runtime 在收束门内验证本人固定 owner 产物;禁止调用 game.static_smoke、project.verify、command.run_limited 或 preview 工具冒充 owner 产物验证。", @@ -1810,7 +1802,7 @@ pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt( if task.id == "art-director" { if autonomous_manifest_ready_task_requires_visual_asset(&task.id) { return format!( - "{base}\n\n这是 autonomous-game-build 的非只读视觉规范生成任务。{AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER};必须用固定合同生成并登记 assets/art-spec.png(assetKind=icon-spec、aspectRatio=1:1),该受控素材事务会同时提交当前 run 的 mutation 与验证凭证。禁止调用 file.write、file.patch、file.delete、project.patchset、project.restore 或写入其它路径。生成成功后直接交付视觉规范结论;不要调用 task.update,Runtime 会在子 Run 终态后幂等投影 manifest。" + "{base}\n\n这是 autonomous-game-build 的视觉方向任务。根据项目需求决定是否调用 canvas.asset_generate,不规定固定图片名称、数量、素材类别或布局;生成成功后直接交付结论。" ); } return format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs index 948eda19a..211da5829 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs @@ -534,15 +534,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( agent_runtime_tool_input_text(input, &["repairOfDelegationId", "repair_of_delegation_id"]); let repair_of_delegation_id = (!repair_of_delegation_id.is_empty()).then_some(repair_of_delegation_id); - let required_visual_artifact = if editor_api_key_is_configured() { - match target_agent_id.as_str() { - "design-foundation" => Some("assets/ui-prototype.png"), - "art-asset-plan" => Some("assets/art-spritesheet.png"), - _ => None, - } - } else { - None - }; + let required_visual_artifact: Option<&str> = None; if repair_of_delegation_id.is_none() && required_visual_artifact.is_some_and(|required| { !expected_artifacts diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs index 220dae8ed..4d4616f77 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs @@ -416,39 +416,6 @@ pub(in crate::agent) fn observe_agent_runtime_file_delete( return agent_runtime_mutation_gate_failure_observation(root, "file.delete", &error); } } - if agent_id == "art-asset-plan" && path == "assets/art-spritesheet.png" { - let manifest = match read_existing_manifest_for_project(root) { - Ok(manifest) => manifest, - Err(error) => { - return AgentRuntimeToolObservation { - tool: "file.delete".to_string(), - status: "blocked".to_string(), - summary: "无法确认首版美术素材登记状态,未执行删除".to_string(), - detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), - }; - } - }; - let registered_fixed_asset_exists = manifest.assets.iter().any(|asset| { - asset.local_path == "assets/art-spritesheet.png" - && asset.kind == "art-spritesheet" - && asset.media_type.starts_with("image/") - && asset.source.kind == GameCreationAppAssetSourceKind::Canvas - && resolve_local_project_path(root, &asset.local_path) - .ok() - .is_some_and(|path| path.is_file()) - }); - if registered_fixed_asset_exists { - return AgentRuntimeToolObservation { - tool: "file.delete".to_string(), - status: "blocked".to_string(), - summary: "首版美术素材已生成并登记,禁止删除固定正式产物".to_string(), - detail: Some( - "path=assets/art-spritesheet.png · 请复用现有画布资产并核对 assets/manifest.art.json,不得重复生成或扣费" - .to_string(), - ), - }; - } - } if let Err(error) = prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "file.delete") { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index fa17cbfcc..48ba9b4e0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -540,6 +540,11 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio .or_else(|| input.get("replace_existing")) .and_then(serde_json::Value::as_bool) .unwrap_or(false); + let slice_count = input + .get("sliceCount") + .or_else(|| input.get("slice_count")) + .and_then(serde_json::Value::as_u64) + .map(|value| value as usize); let requested_options = PlatformArtAssetGenerationOptions { output_path: (!output_path.trim().is_empty()).then_some(output_path), aspect_ratio, @@ -547,83 +552,9 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio asset_kind, asset_label, replace_existing, + slice_count, }; - let canonical_options = match agent_id { - "art-director" => Some(PlatformArtAssetGenerationOptions { - output_path: Some(AGENT_RUNTIME_ART_SPEC_PATH.to_string()), - aspect_ratio: "1:1".to_string(), - image_size: "1K".to_string(), - asset_kind: "icon-spec".to_string(), - asset_label: "游戏统一视觉规范图".to_string(), - replace_existing: false, - }), - "design-foundation" - if requested_options - .output_path - .as_deref() - .is_some_and(design_foundation_ui_page_output_path_is_valid) => - { - Some(PlatformArtAssetGenerationOptions { - output_path: requested_options.output_path.clone(), - aspect_ratio: "16:9".to_string(), - image_size: "2K".to_string(), - asset_kind: "ui-prototype".to_string(), - asset_label: if requested_options.asset_label.trim().is_empty() { - "游戏功能页面设计图".to_string() - } else { - requested_options.asset_label.clone() - }, - replace_existing: false, - }) - } - "design-foundation" => Some(PlatformArtAssetGenerationOptions { - output_path: Some("assets/ui-prototype.png".to_string()), - aspect_ratio: "16:9".to_string(), - image_size: "2K".to_string(), - asset_kind: "ui-prototype".to_string(), - asset_label: "游戏横屏界面原型图".to_string(), - replace_existing: false, - }), - "art-asset-plan" => Some(PlatformArtAssetGenerationOptions { - output_path: Some("assets/art-spritesheet.png".to_string()), - aspect_ratio: "1:1".to_string(), - image_size: "1K".to_string(), - asset_kind: "art-spritesheet".to_string(), - asset_label: "游戏首版核心美术素材".to_string(), - replace_existing: false, - }), - _ => None, - }; - let mut options = if let Some(canonical) = canonical_options { - let mismatch = requested_options - .output_path - .as_deref() - .is_some_and(|value| Some(value) != canonical.output_path.as_deref()) - || (!requested_options.aspect_ratio.is_empty() - && requested_options.aspect_ratio != canonical.aspect_ratio) - || (!requested_options.image_size.is_empty() - && requested_options.image_size != canonical.image_size) - || (!requested_options.asset_kind.is_empty() - && requested_options.asset_kind != canonical.asset_kind) - || (!requested_options.asset_label.is_empty() - && requested_options.asset_label != canonical.asset_label); - if mismatch { - return AgentRuntimeToolObservation { - tool: "canvas.asset_generate".to_string(), - status: "failed".to_string(), - summary: format!( - "图片产物型专业任务不能覆盖固定输出合同:outputPath={} · aspectRatio={} · imageSize={} · assetKind={} · assetLabel={}", - canonical.output_path.as_deref().unwrap_or("null"), - canonical.aspect_ratio, - canonical.image_size, - canonical.asset_kind, - canonical.asset_label, - ), - detail: None, - }; - } - canonical - } else { + let mut options = { let defaults = PlatformArtAssetGenerationOptions::default(); PlatformArtAssetGenerationOptions { output_path: requested_options.output_path, @@ -648,6 +579,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio requested_options.asset_label }, replace_existing, + slice_count, } }; options.replace_existing = replace_existing; 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 index aec1efbc2..9cb4fda28 100644 --- 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 @@ -242,6 +242,27 @@ pub(crate) fn render_agc_skill_pack_index() -> Result { Ok(lines.join("\n")) } +pub(crate) fn read_agc_skill_resource(resource: &str) -> Result { + let manifest = validated_skill_pack_manifest()?; + let normalized = resource.trim().trim_start_matches('/').replace('\\', "/"); + let (skill_name, relative) = normalized + .split_once('/') + .ok_or_else(|| "Skill 资源路径必须是 skill/file".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) || !is_safe_skill_relative_path(relative) { + return Err("未登记或不安全的 AGC Skill 资源".to_string()); + } + let bundled_path = format!("{skill_name}/{relative}"); + let bytes = + bundled_skill_file(&bundled_path).ok_or_else(|| "AGC Skill 资源不存在".to_string())?; + let canonical = canonical_skill_text_bytes(&bundled_path, bytes)?; + String::from_utf8(canonical.into_owned()).map_err(|_| "AGC Skill 资源不是 UTF-8".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"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 3f34dc890..9bab62192 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -1386,7 +1386,7 @@ fn runtime_tool_description(tool: &str) -> &'static str { "preview.validate" => "用真实浏览器验证桌面和移动预览并保存证据。", "image.inspect" => "让视觉模型检查一至两张项目内图片。", "canvas.asset_generate" => { - "通过已配置的 External Editor API 生成图片并登记到画布、素材库和项目 assets;art-director 先生成 icon-spec 规范图,ui-prototype 与透明 art-spritesheet 都固定复用该规范图;只有唯一返工委派可显式替换已登记正式图片。" + "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考,也可通过 sliceCount 指定图集切片数量。" } "ui.workflow.run" => { "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。" 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 7c8444ac4..e163cad21 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1489,14 +1489,12 @@ const DEFAULT_GAME_INDEX_HTML: &str = r#" Genarrative Game Draft - -
还没有生成游戏。回到聊天输入创意并确认生成后,这里会写入可试玩原型。
+
"#; +const DEFAULT_GAME_STYLE_CSS: &str = "body { margin: 0; display: grid; min-height: 100vh; place-items: center; background: #101827; color: #d9e7ff; font: 16px system-ui, sans-serif; }\nmain { width: min(720px, calc(100vw - 32px)); }\n"; +const DEFAULT_GAME_SCRIPT_JS: &str = "import Phaser from 'phaser';\nimport './style.css';\n\nclass PlaceholderScene extends Phaser.Scene {\n create() { this.add.text(24, 24, '还没有生成游戏。回到聊天输入创意并确认生成后,这里会写入可试玩原型。'); }\n}\n\nnew Phaser.Game({ type: Phaser.AUTO, width: 720, height: 420, parent: 'game', scene: PlaceholderScene });\n"; const DEFAULT_EDITOR_BASE_URL: &str = "http://127.0.0.1:3000"; const GAME_CREATOR_CONFIG_FILE_NAME: &str = "game-creator.config.json"; @@ -2486,6 +2484,8 @@ fn main() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + start_game_creator_external_mcp, + stop_game_creator_external_mcp, create_automatic_local_game_project, init_local_game_project, import_local_godot_project, diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index ab6c73245..f79c9bc39 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -1405,9 +1405,14 @@ fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { .position(|candidate| candidate == needle) } -/// 解析项目游戏根:项目根存在 `index.html` 时使用项目根(新布局), -/// 否则回退到旧布局的 `game/` 子目录。 +/// npm 工程只预览构建结果;单 HTML 工程保留现有入口布局。 pub(crate) fn project_game_root(root: &Path) -> PathBuf { + if root.join("package.json").is_file() || root.join("dist/index.html").is_file() { + return root.join("dist"); + } + if root.join("game/package.json").is_file() || root.join("game/dist/index.html").is_file() { + return root.join("game/dist"); + } if root.join("index.html").is_file() { root.to_path_buf() } else { @@ -1419,9 +1424,24 @@ pub(crate) fn resolve_preview_path(root: &Path, url_path: &str) -> Result Result Result metadata, @@ -1586,6 +1643,43 @@ mod tests { use super::*; use std::fs; + #[test] + fn npm_preview_requires_build_and_prefers_bundled_assets() { + let base = PathBuf::from(std::env::var("HOME").unwrap()).join("data/tmp"); + fs::create_dir_all(&base).unwrap(); + let root = tempfile::tempdir_in(base).unwrap(); + fs::write(root.path().join("package.json"), "{}").unwrap(); + fs::write(root.path().join("index.html"), "source").unwrap(); + assert!(resolve_preview_path(root.path(), "/").is_err()); + fs::create_dir_all(root.path().join("dist/assets")).unwrap(); + fs::create_dir_all(root.path().join("assets")).unwrap(); + fs::write(root.path().join("dist/index.html"), "").unwrap(); + fs::write(root.path().join("dist/assets/main.js"), "bundled").unwrap(); + fs::write(root.path().join("assets/main.js"), "source").unwrap(); + fs::write(root.path().join("assets/hero.png"), "image").unwrap(); + assert_eq!( + resolve_preview_path(root.path(), "/assets/main.js").unwrap(), + root.path() + .join("dist/assets/main.js") + .canonicalize() + .unwrap() + ); + assert!(resolve_preview_path(root.path(), "/assets/hero.png").is_err()); + fs::create_dir_all(root.path().join("game")).unwrap(); + fs::write(root.path().join("game/index.html"), "source").unwrap(); + assert!(resolve_preview_path(root.path(), "/game/index.html").is_err()); + assert!(resolve_preview_path(root.path(), "/assets/%2e%2e/index.html").is_err()); + #[cfg(unix)] + { + std::os::unix::fs::symlink( + root.path().join("index.html"), + root.path().join("dist/assets/leak.html"), + ) + .unwrap(); + assert!(resolve_preview_path(root.path(), "/assets/leak.html").is_err()); + } + } + #[test] fn root_layout_serves_root_entry_and_keeps_legacy_paths_available() { let root = tempfile::tempdir().expect("create preview root"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs index d52959460..6b4f7f43c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs @@ -4,26 +4,7 @@ pub(crate) fn export_local_project_package_at( root: &Path, ) -> Result { validate_project_root(root)?; - ensure_project_export_package_dir(root, "game")?; - let game_index_path = resolve_local_project_path(root, "game/index.html")?; - if !game_index_path.is_file() { - return Err("导出试玩包前需要先生成 game/index.html".to_string()); - } - let game_index_metadata = checked_export_package_metadata(&game_index_path, "game/index.html")?; - if !game_index_metadata.is_file() { - return Err("导出试玩包前需要先生成 game/index.html".to_string()); - } - prepare_game_creator_private_path_for_read(&game_index_path, false, "游戏入口")?; - let game_index = fs::read_to_string(&game_index_path) - .map_err(|error| format!("读取游戏入口失败:{}: {error}", game_index_path.display()))?; - if game_index.trim().is_empty() { - return Err("导出试玩包前 game/index.html 不能为空".to_string()); - } - let lower_game_index = game_index.to_ascii_lowercase(); - if !lower_game_index.contains(" Result, String> { let mut files = Vec::new(); - collect_project_export_package_dir_files(root, "game", &mut files)?; - if resolve_local_project_path(root, "assets")?.exists() { + let game_root = crate::preview::project_game_root(root); + let built = game_root == root.join("dist") || game_root == root.join("game/dist"); + if built { + let relative = relative_project_path(root, &game_root)?; + collect_project_export_package_dir_files(root, &relative, &mut files)?; + for (name, _, _) in &mut files { + *name = format!( + "game/{}", + name.strip_prefix(&format!("{relative}/")) + .ok_or("构建产物路径非法")? + ); + } + } else { + collect_project_export_package_dir_files(root, "game", &mut files)?; + } + if !built && resolve_local_project_path(root, "assets")?.exists() { collect_project_export_package_dir_files(root, "assets", &mut files)?; } let readme_path = resolve_local_project_path(root, "exports/README.md")?; @@ -312,3 +307,42 @@ pub(crate) fn normalize_export_package_entry_path(relative_path: &str) -> Result } normalize_relative_path(relative_path) } + +#[cfg(test)] +mod npm_export_tests { + use super::*; + + #[test] + fn npm_package_contains_only_dist_and_publish_readme() { + let base = PathBuf::from(std::env::var("HOME").unwrap()).join("data/tmp"); + fs::create_dir_all(&base).unwrap(); + let root = tempfile::tempdir_in(base).unwrap(); + for directory in ["dist/assets", "assets", "exports", "node_modules", "game"] { + fs::create_dir_all(root.path().join(directory)).unwrap(); + } + for file in [ + "package.json", + "dist/index.html", + "dist/assets/main.js", + "assets/hero.png", + "exports/README.md", + "node_modules/private.js", + "game/source.js", + ] { + fs::write(root.path().join(file), "test").unwrap(); + } + let files = collect_project_export_package_files(root.path()).unwrap(); + let names = files + .iter() + .map(|(name, _, _)| name.as_str()) + .collect::>(); + assert_eq!( + names, + vec![ + "exports/README.md", + "game/assets/main.js", + "game/index.html" + ] + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 6059702a5..c9e73140c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -9,6 +9,32 @@ static MANIFEST_LOCK_OPEN_GUARD: OnceLock> = OnceLock::new(); pub(crate) const GAME_CREATION_PROJECT_NAME_MAX_CHARS: usize = 80; +const DEFAULT_GAME_PACKAGE_JSON: &str = r#"{ + "name": "agc-game", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "dependencies": { + "phaser": "4.2.1" + }, + "devDependencies": { + "vite": "^6.2.0" + } +} +"#; + +const DEFAULT_GAME_VITE_CONFIG: &str = r#"import { defineConfig } from 'vite'; + +export default defineConfig({ + root: '.', + base: './', + build: { outDir: 'dist', emptyOutDir: true }, +}); +"#; + pub(crate) fn normalize_game_creation_project_name(value: &str) -> Result { let name = value.trim(); if name.is_empty() { @@ -445,6 +471,11 @@ pub(crate) fn init_local_game_project_at( let name = normalize_game_creation_project_name(name)?; prepare_game_creator_project_root_for_read(root, true, "本地项目目录")?; + let create_npm_scaffold = !manifest_storage_exists(&root.join(".agent/manifest.json"))? + && !root.join("index.html").exists() + && !root.join("game/index.html").exists() + && !root.join("package.json").exists() + && !root.join("game/package.json").exists(); for relative in ["game", "assets", "memory", "memory/agents", "exports"] { let path = root.join(relative); ensure_game_creator_private_directory_tree(&path, "本地项目目录")?; @@ -459,6 +490,32 @@ pub(crate) fn init_local_game_project_at( "默认游戏入口", )?; } + if create_npm_scaffold { + for (relative, content, label) in [ + ( + "game/package.json", + DEFAULT_GAME_PACKAGE_JSON, + "游戏 npm 配置", + ), + ( + "game/package-lock.json", + include_str!("../../resources/agc-game-package-lock.json"), + "游戏 npm 锁文件", + ), + ( + "game/vite.config.js", + DEFAULT_GAME_VITE_CONFIG, + "游戏 Vite 配置", + ), + ("game/style.css", DEFAULT_GAME_STYLE_CSS, "游戏样式"), + ("game/game.js", DEFAULT_GAME_SCRIPT_JS, "游戏入口脚本"), + ] { + let path = root.join(relative); + if !prepare_game_creator_private_path_for_read(&path, false, label)? { + crate::write_game_creator_private_file(&path, content.as_bytes(), label)?; + } + } + } let agent_db_path = root.join(".agent/agent.db"); if !agent_db_path.exists() { @@ -1514,3 +1571,41 @@ pub(crate) fn trim_optional_string(value: Option) -> Option { mod import_tests; #[cfg(test)] mod recovery_tests; + +#[cfg(test)] +mod npm_scaffold_tests { + use super::*; + + #[test] + fn npm_scaffold_uses_package_import_and_preserves_user_changes() { + let root = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + init_local_game_project_at(root.path(), "npm-scaffold", "游戏").unwrap(); + let package: serde_json::Value = + serde_json::from_slice(&fs::read(root.path().join("game/package.json")).unwrap()) + .unwrap(); + assert_eq!(package["dependencies"]["phaser"], "4.2.1"); + assert!(fs::read_to_string(root.path().join("game/game.js")) + .unwrap() + .contains("import Phaser from 'phaser'")); + assert!(root.path().join("game/package-lock.json").is_file()); + fs::write(root.path().join("game/game.js"), "user source").unwrap(); + init_local_game_project_at(root.path(), "npm-scaffold", "游戏").unwrap(); + assert_eq!( + fs::read_to_string(root.path().join("game/game.js")).unwrap(), + "user source" + ); + } + + #[test] + fn npm_scaffold_does_not_migrate_an_existing_html_project() { + let root = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap(); + fs::create_dir(root.path().join("game")).unwrap(); + fs::write(root.path().join("game/index.html"), "existing html").unwrap(); + init_local_game_project_at(root.path(), "existing-html", "已有游戏").unwrap(); + assert!(!root.path().join("game/package.json").exists()); + assert_eq!( + fs::read_to_string(root.path().join("game/index.html")).unwrap(), + "existing html" + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs index acc780b40..245149735 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/verification.rs @@ -14,16 +14,12 @@ pub(crate) fn run_limited_local_command_at( return Err("项目目录必须是绝对路径".to_string()); } - let game_index_path = root.join("game/index.html"); - prepare_game_creator_private_path_for_read(&game_index_path, false, "游戏入口")?; - let html = fs::read_to_string(&game_index_path) - .map_err(|error| format!("读取游戏入口失败:{}: {error}", game_index_path.display()))?; - if !html.contains(" Result<(PathBuf, String), String> { + let game_root = crate::preview::project_game_root(root); + let index = crate::preview::resolve_preview_path(root, "/")?; + prepare_game_creator_private_path_for_read(&index, false, "游戏入口")?; + let html = fs::read_to_string(&index).map_err(|error| format!("读取游戏入口失败:{error}"))?; + let lower = html.to_ascii_lowercase(); + if !lower.contains(" Result<(), String> { + let tags = regex::Regex::new(r"(?is)<(?:script|link|img|audio|video|source)\b[^>]*>").unwrap(); + let attributes = + regex::Regex::new(r#"(?is)\s+([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))"#) + .unwrap(); + for tag in tags.find_iter(html) { + for attribute in attributes.captures_iter(tag.as_str()) { + let name = attribute.get(1).unwrap().as_str(); + if !name.eq_ignore_ascii_case("src") && !name.eq_ignore_ascii_case("href") { + continue; + } + let value = attribute + .get(2) + .or_else(|| attribute.get(3)) + .or_else(|| attribute.get(4)) + .unwrap() + .as_str(); + if value.is_empty() + || value.starts_with('#') + || value.starts_with("//") + || value.contains(':') + { + continue; + } + let path = value.split(['?', '#']).next().unwrap_or(value); + let path = path.strip_prefix("./").unwrap_or(path); + crate::preview::resolve_preview_path( + root, + &format!("/{}", path.trim_start_matches('/')), + ) + .map_err(|error| format!("构建入口引用不可用:{value}: {error}"))?; + } + } + Ok(()) +} + pub(crate) const PROJECT_VERIFICATION_OUTPUT_MAX_BYTES: usize = 24 * 1024; const PROJECT_VERIFICATION_PACKAGE_MAX_BYTES: u64 = 512 * 1024; const PROJECT_VERIFICATION_MIN_TIMEOUT_SECONDS: u64 = 1; @@ -840,3 +889,25 @@ pub(crate) fn enforce_project_auto_permission_policy( } Ok(()) } + +#[cfg(test)] +mod npm_build_tests { + use super::*; + + #[test] + fn built_smoke_checks_module_files_without_inline_canvas() { + let base = PathBuf::from(std::env::var("HOME").unwrap()).join("data/tmp"); + fs::create_dir_all(&base).unwrap(); + let root = tempfile::tempdir_in(base).unwrap(); + fs::create_dir_all(root.path().join("dist/assets")).unwrap(); + fs::write(root.path().join("package.json"), "{}").unwrap(); + let html = r#""#; + fs::write(root.path().join("dist/index.html"), html).unwrap(); + assert!(validate_built_game_references(root.path(), html).is_err()); + fs::write(root.path().join("dist/assets/main.js"), "export {};").unwrap(); + fs::write(root.path().join("dist/assets/main.css"), "body{}").unwrap(); + assert!(validate_built_game_references(root.path(), html).is_ok()); + let lazy_html = r#"src='not-a-reference.png'"#; + assert!(validate_built_game_references(root.path(), lazy_html).is_ok()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 54f8eae14..ca5ce863e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -1165,6 +1165,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { asset_kind: "ui-prototype".to_string(), asset_label: "游戏横屏界面原型图".to_string(), replace_existing: false, + slice_count: None, }, ) .await; @@ -4856,6 +4857,7 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { asset_kind: "ui-prototype".to_string(), asset_label: "游戏横屏界面原型图".to_string(), replace_existing: false, + slice_count: None, }; let prompt = build_platform_art_asset_prompt( "原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开", diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs index e59d90fb1..4585eee85 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/binding.rs @@ -35,7 +35,9 @@ const SYSTEM_PROMPT: &str = r#" * 对每个 Component,直接完整返回其全部参数. * 有任何困难或者不确定把状态设为 NeedReview,说明中文原因。 * 纯结构节点可以返回空数组并标为 NoProblem。 +* 容器背景等推荐使用Simple + preserve_aspect: false 实现与node大小一致 * 面向用户的 reason 使用中文。 + "#; #[derive(Clone, Debug, Deserialize, JsonSchema)] diff --git a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx new file mode 100644 index 000000000..3d79df338 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx @@ -0,0 +1,281 @@ +import type { ErrorInfo, ReactNode } from 'react'; +import { + Children, + Component, + createContext, + isValidElement, + useContext, +} from 'react'; +import ReactMarkdown, { type Components } from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +export type ChatMarkdownMessageProps = { + text: string; + role: 'assistant' | 'user'; + streaming?: boolean; +}; + +type MarkdownErrorBoundaryProps = { + fallbackText: string; + children: ReactNode; +}; + +type MarkdownErrorBoundaryState = { + hasError: boolean; +}; + +export class MarkdownErrorBoundary extends Component< + MarkdownErrorBoundaryProps, + MarkdownErrorBoundaryState +> { + state: MarkdownErrorBoundaryState = { hasError: false }; + + static getDerivedStateFromError(): MarkdownErrorBoundaryState { + return { hasError: true }; + } + + componentDidCatch(error: unknown, errorInfo: ErrorInfo) { + // Keep the original message visible without logging its potentially sensitive content. + const errorName = + error instanceof Error && error.name ? error.name : 'UnknownError'; + console.error('[chat-markdown] render failed', { + errorName, + hasComponentStack: Boolean(errorInfo.componentStack?.trim()), + }); + } + + componentDidUpdate(prevProps: MarkdownErrorBoundaryProps) { + if ( + this.state.hasError && + prevProps.fallbackText !== this.props.fallbackText + ) { + this.setState({ hasError: false }); + } + } + + render() { + if (this.state.hasError) { + return ( + + {this.props.fallbackText} + + ); + } + return this.props.children; + } +} + +const ListDepthContext = createContext(0); +const ListKindContext = createContext<'unordered' | 'ordered' | null>(null); +type ListItemParagraphPosition = 'first' | 'continuation'; + +const ListItemContext = createContext(null); + +function MarkdownUnorderedList({ children }: { children?: ReactNode }) { + const depth = useContext(ListDepthContext); + return ( + + +
    0 ? 'pl-4' : 'pl-0' + }`} + > + {children} +
+
+
+ ); +} + +function MarkdownOrderedList({ + children, + start, +}: { + children?: ReactNode; + start?: number; +}) { + const depth = useContext(ListDepthContext); + return ( + + +
    + {children} +
+
+
+ ); +} + +function MarkdownParagraph({ children }: { children?: ReactNode }) { + const paragraphPosition = useContext(ListItemContext); + return ( +

+ {children} +

+ ); +} + +function StreamingMarkdownParagraph({ children }: { children?: ReactNode }) { + const paragraphPosition = useContext(ListItemContext); + return ( +

+ {children} +

+ ); +} + +function MarkdownListItem({ children }: { children?: ReactNode }) { + const listKind = useContext(ListKindContext); + let paragraphIndex = 0; + const childrenWithParagraphContext = Children.map( + children, + (child, index) => { + if ( + isValidElement(child) && + (child.type === MarkdownParagraph || + child.type === StreamingMarkdownParagraph) + ) { + const position: ListItemParagraphPosition = + paragraphIndex++ === 0 ? 'first' : 'continuation'; + return ( + + {child} + + ); + } + return child; + }, + ); + return ( +
  • + {listKind === 'unordered' ? '- ' : null} + {childrenWithParagraphContext} +
  • + ); +} + +const markdownComponents: Components = { + // TODO: 产品确认安全外链策略后,再将链接文本恢复为可点击元素。 + a: ({ children }) => children, + img: ({ alt }) => (alt?.trim() ? `图片:${alt}` : '图片已省略'), + h1: ({ children }) => ( +

    {children}

    + ), + h2: ({ children }) => ( +

    {children}

    + ), + h3: ({ children }) => ( +

    {children}

    + ), + h4: ({ children }) => ( +

    {children}

    + ), + h5: ({ children }) => ( +
    {children}
    + ), + h6: ({ children }) => ( +
    + {children} +
    + ), + p: MarkdownParagraph, + ul: MarkdownUnorderedList, + ol: MarkdownOrderedList, + li: MarkdownListItem, + blockquote: ({ children }) => ( +
    + {children} +
    + ), + pre: ({ children }) => ( +
    +      {children}
    +    
    + ), + code: ({ className, children, node: _node, ...props }) => { + const isBlock = + Boolean(className?.includes('language-')) || + String(children).includes('\n'); + return isBlock ? ( + + {children} + + ) : ( + + {children} + + ); + }, + table: ({ children }) => ( +
    + + {children} +
    +
    + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + hr: () => ( +
    + ), +}; + +const streamingMarkdownComponents: Components = { + ...markdownComponents, + p: StreamingMarkdownParagraph, +}; + +export function ChatMarkdownMessage({ + text, + role, + streaming = false, +}: ChatMarkdownMessageProps) { + if (role === 'user') { + return {text}; + } + + return ( + + + {text} + + + ); +} diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index d8df6bc98..451316d2e 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -2140,6 +2140,14 @@ export function projectSupervisorVisibleConversationText( ); } +export function projectSupervisorChatMessageText( + message: Pick, +) { + return message.role === 'assistant' + ? projectSupervisorVisibleConversationText(message.text, message.role) + : message.text; +} + export function projectRuntimeVisibleToolSummary(summary: string) { return summary .split('·') diff --git a/apps/ai-game-creator-shell/src/features/project-summary/projectArtifactSummaries.ts b/apps/ai-game-creator-shell/src/features/project-summary/projectArtifactSummaries.ts index f256883f0..2181c5aac 100644 --- a/apps/ai-game-creator-shell/src/features/project-summary/projectArtifactSummaries.ts +++ b/apps/ai-game-creator-shell/src/features/project-summary/projectArtifactSummaries.ts @@ -272,7 +272,13 @@ export function summarizeProjectFileContent(result: LocalProjectFileResult) { } 字符` : result.content; - return `文件:${result.path}\n${content || '空文件'}`; + const visibleContent = content || '空文件'; + let longestBacktickRun = 0; + for (const match of visibleContent.matchAll(/`+/gu)) { + longestBacktickRun = Math.max(longestBacktickRun, match[0].length); + } + const fence = '`'.repeat(Math.max(3, longestBacktickRun + 1)); + return `文件:${result.path}\n\n${fence}text\n${visibleContent}\n${fence}`; } export function inferProjectFileAssetDraft( 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 2c04e0227..b0fde2c0e 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 @@ -18,11 +18,12 @@ import type { PlanGddDecisionAction, PlanGddStateViewV1, } from '../../app/types'; +import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage'; import { projectProfessionalAgentLabel, projectRuntimeVisibleError, + projectSupervisorChatMessageText, ProjectSupervisorRuntimePanel, - projectSupervisorVisibleConversationText, projectWorkspaceStatusForDisplay, } from '../agent-runtime'; import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation'; @@ -178,15 +179,15 @@ export function ProjectSupervisorView({ ) : null} {visibleMessages.map((message, index) => ( -

    - {projectSupervisorVisibleConversationText( - message.text, - message.role, - )} -

    + + ))} {directCodex && (runtimePanelProps.controlBusy || Boolean(directProcessDetail)) ? ( @@ -206,6 +207,15 @@ export function ProjectSupervisorView({ : '陶泥儿正在处理'} + {transientReply ? ( +
    + +
    + ) : null} {directProcessDetail ? (

    ) : null} - {transientReply ? ( -

    - {transientReply} -

    + +
    ) : null} {directCodex ? null : isPlanningLaneRuntime( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx index f70d648c7..4d7b5c8ea 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx @@ -26,12 +26,13 @@ import type { PendingCommand, PendingUiConfirmation, } from '../../app/types'; +import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage'; import type { ProjectAgentResultSummary } from '../../view/project-development'; import { formatAgentRecentRuntimeTask, formatAgentRuntimeTaskQueue, + projectSupervisorChatMessageText, ProjectSupervisorRuntimePanel, - projectSupervisorVisibleConversationText, projectWorkspaceStatusForDisplay, } from '../agent-runtime'; import { @@ -804,12 +805,12 @@ export function ProjectWorkspaceChatPane({ ) : null} {visibleMessages.map((message, index) => ( -

    - {projectSupervisorVisibleConversationText( - message.text, - message.role, - )} -

    +
    + +
    {message.draftCommand ? ( ) : null} {visibleMessages.map((message, index) => ( -

    - {projectSupervisorVisibleConversationText( - message.text, - message.role, - )} -

    + + ))} {transientReply ? ( -

    - {transientReply} -

    + + ) : null} {running && !transientReply ? (
    { visitUiNodes(root, (node) => ids.add(node.id)); return ids; } + +export function findUiNode(root: UiNode, nodeId: NodeId): UiNode | null { + if (root.id === nodeId) return root; + for (const child of root.children) { + const found = findUiNode(child, nodeId); + if (found) return found; + } + return null; +} + +export function countUiNodeDescendants(root: UiNode): number { + let count = 0; + for (const child of root.children) { + count += 1 + countUiNodeDescendants(child); + } + return count; +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiNodeDeleteConfirmModal.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiNodeDeleteConfirmModal.tsx new file mode 100644 index 000000000..08e5362d3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiNodeDeleteConfirmModal.tsx @@ -0,0 +1,56 @@ +import { ThemedModal } from '../../../components/modal/ThemedModal'; +import type { NodeId } from '../../../features/ui-editor/types/NodeId'; + +export type UiNodeDeleteRequest = { + nodeId: NodeId; + nodeLabel: string; + descendantCount: number; +}; + +type UiNodeDeleteConfirmModalProps = { + request: UiNodeDeleteRequest | null; + onCancel: () => void; + onConfirm: (nodeId: NodeId) => void; + disabled?: boolean; +}; + +export function UiNodeDeleteConfirmModal({ + request, + onCancel, + onConfirm, + disabled = false, +}: UiNodeDeleteConfirmModalProps) { + return ( + +

    确认删除节点?

    +

    + 将删除「{request?.nodeLabel ?? ''}」及其 {request?.descendantCount ?? 0}{' '} + 个后代节点,是否继续? +

    +
    + + +
    +
    + ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx index 5974e3ef7..14fde9869 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/UiTreePanel.tsx @@ -1,7 +1,10 @@ -import { ChevronDown, Eye, EyeOff } from 'lucide-react'; +import { ChevronDown, Eye, EyeOff, Puzzle, Trash2 } from 'lucide-react'; import { + forwardRef, + type HTMLAttributes, type MouseEvent as ReactMouseEvent, useEffect, + useMemo, useRef, useState, } from 'react'; @@ -9,16 +12,25 @@ import { adjustMoveIndex, type MoveHandler, type NodeRendererProps, + type RowRendererProps, Tree, type TreeApi, } from 'react-arborist'; +import { + countUiNodeDescendants, + findUiNode, +} from '../../../features/ui-editor/treeUtils'; import type { Node as UiNode } from '../../../features/ui-editor/types/Node'; import type { NodeId } from '../../../features/ui-editor/types/NodeId'; import type { UIDesignImageId } from '../../../features/ui-editor/types/UIDesignImageId'; import type { UiNodeMoveRequest } from '../../../features/ui-editor/types/UiNodeMoveRequest'; import type { UiEditorNodeFocusRequest } from '../model'; import { UiNodeContextMenu } from './UiNodeContextMenu'; +import { + UiNodeDeleteConfirmModal, + type UiNodeDeleteRequest, +} from './UiNodeDeleteConfirmModal'; type UiTreePanelProps = { root: UiNode | null; @@ -36,6 +48,38 @@ type UiTreePanelProps = { className?: string; }; +const TreeListOuter = forwardRef< + HTMLDivElement, + HTMLAttributes +>(function TreeListOuter({ style, ...props }, ref) { + return ( +
    + ); +}); + +function TreeRowContainer({ + node, + innerRef, + attrs, + children, +}: RowRendererProps) { + return ( +
    event.stopPropagation()} + onClick={node.handleClick} + > + {children} +
    + ); +} + function TreeRow({ node, style, @@ -44,11 +88,17 @@ function TreeRow({ isNodeVisible, onToggleNodeVisibility, onOpenContextMenu, + onRequestDelete, + canDelete, + deleteDisabled, }: NodeRendererProps & { onSelectNode: (id: NodeId) => void; isNodeVisible: (nodeId: NodeId) => boolean; onToggleNodeVisibility: (id: NodeId) => void; onOpenContextMenu: (event: ReactMouseEvent, id: NodeId) => void; + onRequestDelete: (id: NodeId) => void; + canDelete: boolean; + deleteDisabled: boolean; }) { const data = node.data; const nodeLabel = data.metadata.name || '未命名节点'; @@ -56,8 +106,8 @@ function TreeRow({ return (
    { @@ -88,23 +138,53 @@ function TreeRow({ /> ) : null} - {nodeLabel} - - {data.components.length} + + {nodeLabel} - + + + + +
    ); } @@ -127,11 +207,15 @@ export function UiTreePanel({ const treeApiRef = useRef | undefined>(undefined); const treeContainerRef = useRef(null); const [treeHeight, setTreeHeight] = useState(0); + const [treeWidth, setTreeWidth] = useState(0); const [contextMenu, setContextMenu] = useState<{ nodeId: NodeId; x: number; y: number; } | null>(null); + const [pendingDelete, setPendingDelete] = useState< + (UiNodeDeleteRequest & { treeId: UIDesignImageId }) | null + >(null); useEffect(() => { if (!focusRequest) return; @@ -150,6 +234,41 @@ export function UiTreePanel({ return () => resizeObserver.disconnect(); }, []); + const treeRows = useMemo(() => { + if (!root) return []; + const rows: Array<{ node: UiNode; depth: number }> = []; + const visit = (node: UiNode, depth: number) => { + rows.push({ node, depth }); + node.children.forEach((child) => visit(child, depth + 1)); + }; + visit(root, 0); + return rows; + }, [root]); + + const treeContentWidth = useMemo( + () => + treeRows.reduce((width, { node, depth }) => { + const labelWidth = Math.max( + 56, + (node.metadata.name || '未命名节点').length * 8, + ); + return Math.max(width, 16 + depth * 16 + 24 + labelWidth + 42 + 52); + }, 0), + [treeRows], + ); + + useEffect(() => { + const container = treeContainerRef.current; + if (!container) return; + const updateWidth = () => { + setTreeWidth(Math.max(treeContentWidth, container.clientWidth)); + }; + updateWidth(); + const resizeObserver = new ResizeObserver(updateWidth); + resizeObserver.observe(container); + return () => resizeObserver.disconnect(); + }, [treeContentWidth]); + const handleMove: MoveHandler = ({ dragIds, parentId, @@ -184,21 +303,55 @@ export function UiTreePanel({ const pageRootIds = new Set(root?.children.map((child) => child.id) ?? []); const contextTreeId = contextMenu ? treeIdForNode(contextMenu.nodeId) : null; + const requestDelete = (nodeId: NodeId) => { + if (!root || isLocked || nodeId === root.id || pageRootIds.has(nodeId)) { + return; + } + const treeId = treeIdForNode(nodeId); + const node = findUiNode(root, nodeId); + if (!treeId || !node) return; + const descendantCount = countUiNodeDescendants(node); + if (descendantCount === 0) { + onDeleteNode(treeId, nodeId); + return; + } + setPendingDelete({ + treeId, + nodeId, + nodeLabel: node.metadata.name || '未命名节点', + descendantCount, + }); + }; + + const confirmDelete = (nodeId: NodeId) => { + if (!pendingDelete || isLocked || pendingDelete.nodeId !== nodeId) { + return; + } + const { treeId } = pendingDelete; + setPendingDelete(null); + onDeleteNode(treeId, nodeId); + }; + return (
    event.preventDefault()} > -
    +
    {root && treeHeight > 0 ? ( ref={treeApiRef} data={[root]} - width="100%" + width={treeWidth > 0 ? treeWidth : '100%'} + outerElementType={TreeListOuter} height={treeHeight} rowHeight={34} indent={16} openByDefault + renderRow={TreeRowContainer} selection={selectedNodeId ?? undefined} onSelect={(nodes) => { const selected = nodes[0]?.data; @@ -224,6 +377,12 @@ export function UiTreePanel({ }} isNodeVisible={isNodePreviewVisible} onToggleNodeVisibility={onToggleNodeVisibility} + onRequestDelete={requestDelete} + canDelete={ + props.node.data.id !== root.id && + !pageRootIds.has(props.node.data.id) + } + deleteDisabled={isLocked} onOpenContextMenu={(event, nodeId) => nodeId === root.id ? undefined @@ -252,9 +411,15 @@ export function UiTreePanel({ onClose={() => setContextMenu(null)} onInsertChild={(nodeId) => onInsertNode(contextTreeId, nodeId)} onInsertSibling={(nodeId) => onInsertNodeAfter(contextTreeId, nodeId)} - onDelete={(nodeId) => onDeleteNode(contextTreeId, nodeId)} + onDelete={requestDelete} /> ) : null} + setPendingDelete(null)} + onConfirm={confirmDelete} + disabled={isLocked} + />
    ); } diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowCompletionModal.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowCompletionModal.tsx new file mode 100644 index 000000000..fbda3f214 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowCompletionModal.tsx @@ -0,0 +1,40 @@ +import { ThemedModal } from '../../../components/modal/ThemedModal'; +import type { WorkflowCompletionNotice } from './workflowCompletionNotice'; +import { workflowStepLabel } from './workflowCompletionNotice'; + +export function WorkflowCompletionModal({ + notice, + onClose, +}: { + notice: WorkflowCompletionNotice | null; + onClose: () => void; +}) { + if (!notice) return null; + const stepLabel = workflowStepLabel(notice.step); + const outcomeLabel = notice.outcome === 'success' ? '完成' : '失败'; + return ( + +

    + {stepLabel} + {outcomeLabel} +

    +

    + {notice.message} +

    +
    + +
    +
    + ); +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx index 75a1efce3..c923ca875 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx @@ -1,4 +1,6 @@ import { + CANVAS_ZOOM_IN_FACTOR, + CANVAS_ZOOM_OUT_FACTOR, type CanvasViewport, createPanDragState, type DragState, @@ -28,8 +30,14 @@ import type { Node } from '../../../../features/ui-editor/types/Node'; import type { NodeId } from '../../../../features/ui-editor/types/NodeId'; import type { UiEditorCanvasProjection } from '../../useUiEditorPage'; import { UiNodeContextMenu } from '../UiNodeContextMenu'; +import { + handlePreviewZoomKeyDown, + isPreviewZoomInteractiveTarget, + previewZoomUsesMetaModifier, +} from './previewZoomKeyboard'; import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer'; import { useNodeTransformInteraction } from './useNodeTransformInteraction'; +import { ZoomPercentageInput } from './ZoomPercentageInput'; export function PreviewWorkspace({ canvas, @@ -41,6 +49,8 @@ export function PreviewWorkspace({ const viewportRef = useRef({ x: 0, y: 0, scale: 0.5 }); const handledFocusRequestIdRef = useRef(null); const panRef = useRef | null>(null); + const previewFocusedRef = useRef(false); + const previewHoveredRef = useRef(false); const [viewport, setViewportState] = useState( viewportRef.current, ); @@ -150,6 +160,34 @@ export function PreviewWorkspace({ ); }, [logicalSize, setViewport]); + const scaleViewportFromCenter = useCallback( + (nextScale: number) => { + const element = viewportElementRef.current; + const width = element?.clientWidth || canvasSize.width; + const height = element?.clientHeight || canvasSize.height; + setViewport( + scaleViewportFromScreenPoint({ + viewport: viewportRef.current, + nextScale, + screenPoint: { x: width / 2, y: height / 2 }, + }), + ); + }, + [canvasSize.height, canvasSize.width, setViewport], + ); + + const resetToActualSize = useCallback(() => { + scaleViewportFromCenter(1); + }, [scaleViewportFromCenter]); + + const zoomIn = useCallback(() => { + scaleViewportFromCenter(viewportRef.current.scale * CANVAS_ZOOM_IN_FACTOR); + }, [scaleViewportFromCenter]); + + const zoomOut = useCallback(() => { + scaleViewportFromCenter(viewportRef.current.scale * CANVAS_ZOOM_OUT_FACTOR); + }, [scaleViewportFromCenter]); + useEffect(() => { const element = viewportElementRef.current; if (!element) return; @@ -224,6 +262,9 @@ export function PreviewWorkspace({ ), ); }; + const usesMetaModifier = previewZoomUsesMetaModifier( + window.navigator.platform, + ); const onKeyDown = (event: KeyboardEvent) => { if ( event.code === 'Space' && @@ -232,23 +273,21 @@ export function PreviewWorkspace({ ) { setSpaceHeld(true); } - if (!event.ctrlKey && !event.metaKey) return; - if (event.key === '0') { - event.preventDefault(); - fitToCanvas(); - } else if (event.key === '1' && logicalSize) { - event.preventDefault(); - const element = viewportElementRef.current; - const width = element?.clientWidth ?? 900; - const height = element?.clientHeight ?? 640; - setViewport( - scaleViewportFromScreenPoint({ - viewport: viewportRef.current, - nextScale: 1, - screenPoint: { x: width / 2, y: height / 2 }, - }), - ); - } + handlePreviewZoomKeyDown( + event, + { + hasZoomableViewport: logicalSize !== null, + isFocused: previewFocusedRef.current, + isHovered: previewHoveredRef.current, + usesMetaModifier, + }, + { + fit: fitToCanvas, + resetToActualSize, + zoomIn, + zoomOut, + }, + ); }; const onKeyUp = (event: KeyboardEvent) => { if (event.code === 'Space') setSpaceHeld(false); @@ -262,9 +301,12 @@ export function PreviewWorkspace({ window.removeEventListener('keyup', onKeyUp); window.removeEventListener('blur', onWindowBlur); }; - }, [fitToCanvas, logicalSize, setViewport]); + }, [fitToCanvas, logicalSize, resetToActualSize, zoomIn, zoomOut]); const handlePointerDown = (event: ReactPointerEvent) => { + if (event.button === 0 && !isPreviewZoomInteractiveTarget(event.target)) { + event.currentTarget.focus({ preventScroll: true }); + } if (event.button === 1 || (event.button === 0 && spaceHeld)) { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); @@ -371,7 +413,27 @@ export function PreviewWorkspace({
    { + previewHoveredRef.current = true; + }} + onPointerLeave={() => { + previewHoveredRef.current = false; + }} + onFocus={() => { + previewFocusedRef.current = true; + }} + onBlur={(event) => { + if ( + !(event.relatedTarget instanceof Node) || + !event.currentTarget.contains(event.relatedTarget) + ) { + previewFocusedRef.current = false; + } + }} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} @@ -448,21 +510,20 @@ export function PreviewWorkspace({ { - const element = viewportElementRef.current; - const width = element?.clientWidth ?? canvasSize.width; - const height = element?.clientHeight ?? canvasSize.height; - setViewport( - scaleViewportFromScreenPoint({ - viewport: viewportRef.current, - nextScale, - screenPoint: { x: width / 2, y: height / 2 }, - }), - ); - }} + onScaleFromCenter={scaleViewportFromCenter} > {(actions) => ( -
    +
    { + if ( + event.target instanceof Element && + event.target.closest('button') + ) { + event.preventDefault(); + } + }} + > + + actions.zoomToDisplayScale(Number(event.target.value) / 100) + } + /> + + actions.zoomToDisplayScale(percent / 100) + } + />
    + setReturnConfirmOpen(false)} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/uiEditorKeyboardShortcuts.ts b/apps/ai-game-creator-shell/src/view/ui-editor/uiEditorKeyboardShortcuts.ts new file mode 100644 index 000000000..90657f5b4 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/ui-editor/uiEditorKeyboardShortcuts.ts @@ -0,0 +1,82 @@ +import type { NodeId } from '../../features/ui-editor/types/NodeId'; +import type { UIDesignImageId } from '../../features/ui-editor/types/UIDesignImageId'; + +type DeleteResult = { ok: boolean } | undefined; + +export type UiEditorKeyboardActions = { + selectedNodeId: NodeId | null; + activeImageId: UIDesignImageId | null; + deleteNode: (nodeId: NodeId, treeId: UIDesignImageId) => DeleteResult; + historyUndo: () => boolean; + historyRedo: () => boolean; +}; + +function isEditableTarget(target: EventTarget | null) { + const element = target instanceof HTMLElement ? target : null; + return Boolean( + element?.isContentEditable || + element?.closest('input, textarea, select, [contenteditable="true"]'), + ); +} + +function isInteractiveTarget(target: EventTarget | null) { + const element = target instanceof Element ? target : null; + return Boolean( + (target instanceof HTMLElement && target.isContentEditable) || + element?.closest( + 'button, a, input, textarea, select, [contenteditable="true"], [role="button"], [role="dialog"], [aria-modal="true"]', + ), + ); +} + +function isModalTarget(target: EventTarget | null) { + const element = target instanceof Element ? target : null; + return Boolean(element?.closest('[role="dialog"], [aria-modal="true"]')); +} + +export function handleUiEditorKeyDown( + event: KeyboardEvent, + actions: UiEditorKeyboardActions, +) { + if (isModalTarget(event.target)) return; + + if ( + (event.key === 'Delete' || event.key === 'Backspace') && + !event.repeat && + !event.defaultPrevented && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey && + !isInteractiveTarget(event.target) + ) { + if (actions.selectedNodeId && actions.activeImageId) { + const result = actions.deleteNode( + actions.selectedNodeId, + actions.activeImageId, + ); + if (result?.ok) { + event.preventDefault(); + event.stopPropagation(); + return; + } + } + } + if ( + event.repeat || + event.defaultPrevented || + isEditableTarget(event.target) || + (!event.ctrlKey && !event.metaKey) + ) { + return; + } + const isUndo = event.key.toLowerCase() === 'z' && !event.shiftKey; + const isRedo = + (event.key.toLowerCase() === 'z' && event.shiftKey) || + (event.ctrlKey && event.key.toLowerCase() === 'y'); + if (isUndo && actions.historyUndo()) { + event.preventDefault(); + } else if (isRedo && actions.historyRedo()) { + event.preventDefault(); + } +} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index b2c98484b..a580a652d 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -10,7 +10,10 @@ import { } from '../../features/ui-editor/importAdapter'; import { applyMergeResult } from '../../features/ui-editor/merge'; import { applyRecognitionResult } from '../../features/ui-editor/recognition'; -import type { StageStatusField } from '../../features/ui-editor/stageStatusOverview'; +import { + getStageStatusOverview, + type StageStatusField, +} from '../../features/ui-editor/stageStatusOverview'; import { collectUiNodeIds } from '../../features/ui-editor/treeUtils'; import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO'; import type { ChildrenDisplayMode } from '../../features/ui-editor/types/ChildrenDisplayMode'; @@ -54,6 +57,10 @@ import { prerequisiteIssuesForStep, type UiEditorPrerequisiteIssue, } from './components/WorkflowChecks'; +import { + appendWorkflowCheckPrompt, + type WorkflowCompletionNotice, +} from './components/workflowCompletionNotice'; import { type PendingResourceRemoval, removalHasDownstreamReferences, @@ -175,6 +182,8 @@ export function useUiEditorSession( initialFurthestStepIndex = 0, ) { const editor = useUiEditorState(EMPTY_UI_EDITOR_STATE); + const editorDeleteNode = editor.deleteNode; + const editorUiTrees = editor.state.ui_trees; const replaceEditorState = editor.replaceState; const [isLoading, setIsLoading] = useState(Boolean(resourceId)); const [loadError, setLoadError] = useState(null); @@ -246,6 +255,19 @@ export function useUiEditorSession( const [hasSuggested, setHasSuggested] = useState(false); const [hasRecognized, setHasRecognized] = useState(false); const [hasBound, setHasBound] = useState(false); + const [completionNotice, setCompletionNotice] = + useState(null); + + function reportWorkflowCompletion( + step: UiEditorStepId, + outcome: WorkflowCompletionNotice['outcome'], + rawMessage: string, + setStatus: (message: string) => void, + ) { + const message = appendWorkflowCheckPrompt(rawMessage); + setStatus(message); + setCompletionNotice({ step, outcome, message }); + } useEffect(() => { setActiveStep(initialStep); @@ -437,6 +459,9 @@ export function useUiEditorSession( ); return next.size === current.size ? current : next; }); + setSelectedNodeId((current) => + current && !validNodeIds.has(current) ? null : current, + ); }, [editor.state.ui_trees]); const isNodePreviewVisible = useCallback( @@ -879,16 +904,26 @@ export function useUiEditorSession( return result; } - function deleteNode(nodeId: NodeId, treeId = activeImageId) { - if (!treeId) return; - const result = editor.deleteNode(treeId, nodeId); - if (!result.ok) { - setStatus('无法删除该节点。'); + const deleteNode = useCallback( + (nodeId: NodeId, treeId = activeImageId) => { + if (!treeId) return; + const tree = editorUiTrees.find( + (candidate) => candidate.src_ui_design === treeId, + ); + const location = tree ? findUiNodeLocation(tree.root, nodeId) : null; + const deletedNodeIds = location ? collectUiNodeIds(location.node) : null; + const result = editorDeleteNode(treeId, nodeId); + if (!result.ok) { + setStatus('无法删除该节点。'); + return result; + } + if (selectedNodeId !== null && deletedNodeIds?.has(selectedNodeId)) { + setSelectedNodeId(null); + } return result; - } - if (selectedNodeId === nodeId) setSelectedNodeId(null); - return result; - } + }, + [activeImageId, editorDeleteNode, editorUiTrees, selectedNodeId], + ); function selectSprite(id: SpriteAssetId) { setSelectedNodeId(null); @@ -939,6 +974,7 @@ export function useUiEditorSession( async function suggestUiDesignSemantics() { if (isSuggesting || isWorkflowBusy) return; + setCompletionNotice(null); setSuggestionStatus(null); setIsSuggesting(true); try { @@ -949,11 +985,19 @@ export function useUiEditorSession( ); editor.replaceState(applyUiDesignSuggestions(snapshot, suggestions)); setHasSuggested(true); - setSuggestionStatus(`已应用 ${suggestions.length} 条参考图语义建议。`); + reportWorkflowCompletion( + 'reference-analysis', + 'success', + `参考图分析完成:已应用 ${suggestions.length} 条参考图语义建议`, + setSuggestionStatus, + ); }); } catch (cause) { - setSuggestionStatus( + reportWorkflowCompletion( + 'reference-analysis', + 'failure', cause instanceof Error ? cause.message : String(cause), + setSuggestionStatus, ); } finally { setIsSuggesting(false); @@ -962,6 +1006,7 @@ export function useUiEditorSession( async function recognizeUi() { if (isRecognizing || isWorkflowBusy) return; + setCompletionNotice(null); setRecognitionStatus(null); setIsRecognizing(true); try { @@ -970,14 +1015,27 @@ export function useUiEditorSession( projectPath, state: snapshot, }); - editor.replaceState(applyRecognitionResult(snapshot, result)); + const nextState = applyRecognitionResult(snapshot, result); + editor.replaceState(nextState); setHasRecognized(true); setSelectedNodeId(null); - setRecognitionStatus(`已替换 ${result.ui_trees.length} 棵界面树。`); + const overview = getStageStatusOverview( + nextState.ui_trees, + 'layout_status', + ); + reportWorkflowCompletion( + 'structure-recognition', + 'success', + `界面结构识别完成:已替换 ${result.ui_trees.length} 棵界面树,待检查 ${overview.needsAttention} 项(必须修复 ${overview.blocked} 项)`, + setRecognitionStatus, + ); }); } catch (cause) { - setRecognitionStatus( + reportWorkflowCompletion( + 'structure-recognition', + 'failure', cause instanceof Error ? cause.message : String(cause), + setRecognitionStatus, ); } finally { setIsRecognizing(false); @@ -1005,6 +1063,7 @@ export function useUiEditorSession( async function bindComponents() { if (isBinding || isWorkflowBusy) return; + setCompletionNotice(null); setBindingStatus(null); setIsBinding(true); try { @@ -1032,13 +1091,21 @@ export function useUiEditorSession( history: index < batches.length - 1 ? 'skip' : 'record', }); } - setBindingStatus( - `组件绑定完成(${batches.length}/${batches.length})。`, + reportWorkflowCompletion( + 'visual-binding', + 'success', + `视觉素材绑定完成:已处理 ${batches.length}/${batches.length} 个批次`, + setBindingStatus, ); setHasBound(true); }); } catch (cause) { - setBindingStatus(cause instanceof Error ? cause.message : String(cause)); + reportWorkflowCompletion( + 'visual-binding', + 'failure', + cause instanceof Error ? cause.message : String(cause), + setBindingStatus, + ); } finally { setIsBinding(false); } @@ -1303,6 +1370,7 @@ export function useUiEditorSession( isBinding, hasBound, bindingStatus, + completionNotice, bindComponents, requestStepChange, continueToNextStep: () => { @@ -1310,6 +1378,7 @@ export function useUiEditorSession( }, confirmStepChange, cancelStepChange: () => setPendingWorkflowStepChange(null), + dismissCompletionNotice: () => setCompletionNotice(null), }, dialogs: { projectPath, diff --git a/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx new file mode 100644 index 000000000..6df7c4333 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/ChatMarkdownMessage.test.tsx @@ -0,0 +1,230 @@ +// @vitest-environment jsdom + +import { render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { + ChatMarkdownMessage, + MarkdownErrorBoundary, +} from '../src/components/ChatMarkdownMessage'; + +function FailingChild({ shouldThrow }: { shouldThrow: boolean }) { + if (shouldThrow) { + throw new Error('transient render failure'); + } + return markdown recovered; +} + +describe('ChatMarkdownMessage', () => { + it('渲染 assistant 的 GFM 内容与代码块', () => { + const { container } = render( + , + ); + + expect(container.querySelector('h2')?.textContent).toBe('标题'); + expect(container.querySelectorAll('li')).toHaveLength(2); + expect(container.querySelector('pre code')?.textContent).toContain( + 'const answer = 42;', + ); + }); + + it('为嵌套无序列表保留逐层缩进', () => { + const { container } = render( + , + ); + + const lists = container.querySelectorAll('ul'); + expect(lists).toHaveLength(3); + expect(lists[0]?.className).toContain('pl-0'); + expect(lists[1]?.className).toContain('pl-4'); + expect(lists[2]?.className).toContain('pl-4'); + }); + + it('为四到六级标题提供递进的字号与字重', () => { + const { container } = render( + , + ); + + expect(container.querySelector('h4')?.className).toContain('text-sm'); + expect(container.querySelector('h4')?.className).toContain('font-semibold'); + expect(container.querySelector('h5')?.className).toContain('font-medium'); + expect(container.querySelector('h6')?.className).toContain('text-xs'); + expect(container.querySelector('h6')?.className).toContain('tracking-wide'); + }); + + it('有序列表不添加无序列表短横线,列表项段落与文本同行', () => { + const { container } = render( + , + ); + + const items = container.querySelectorAll('ol > li'); + expect(items[0]?.textContent?.trim()).toBe('第一件事'); + expect(items[1]?.textContent?.trim()).toContain('第二件事'); + expect(items[0]?.querySelector('p')?.className ?? '').toContain('inline'); + expect(items[0]?.className).toContain('whitespace-normal'); + expect(items[0]?.textContent).not.toContain('-'); + }); + + it('保留 Markdown 有序列表的起始编号', () => { + const { container } = render( + , + ); + + expect(container.querySelector('ol')?.getAttribute('start')).toBe('3'); + expect(container.querySelectorAll('ol > li')).toHaveLength(2); + }); + + it('为列表项后续段落保留段落间距', () => { + const { container } = render( + , + ); + + const paragraphs = container.querySelectorAll('ul > li p'); + expect(paragraphs).toHaveLength(2); + expect(paragraphs[0]?.className).toContain('inline'); + expect(paragraphs[1]?.className).toContain('mt-2'); + expect(paragraphs[1]?.className).not.toContain('inline'); + }); + + it('不会把 react-markdown 的 node 元数据泄漏到代码节点', () => { + const { container } = render( + , + ); + + expect( + container.querySelector('pre code')?.getAttribute('node'), + ).toBeNull(); + }); + + it('无语言标记的多行围栏代码仍使用代码块样式', () => { + const { container } = render( + , + ); + + const code = container.querySelector('pre code'); + expect(code?.className).toContain('whitespace-pre'); + expect(code?.className).not.toContain('rounded'); + }); + + it('保留行内代码中的 HTML 字面量', () => { + const { container } = render( + `'} />, + ); + + expect(container.querySelector('code')?.textContent).toBe(''); + }); + + it('保留围栏代码中的 HTML 字面量而不双重转义', () => { + const { container } = render( +
    \n```'} + />, + ); + + expect(container.querySelector('pre code')?.textContent).toContain( + '
    ', + ); + expect(container.querySelector('pre code')?.textContent).not.toContain( + '<div', + ); + }); + + it('累计文本变化后可从 Markdown 错误回退中恢复', () => { + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + try { + const { container, rerender } = render( + + + , + ); + + expect(container.textContent).toBe('partial'); + + rerender( + + + , + ); + + expect(container.textContent).toBe('markdown recovered'); + } finally { + consoleError.mockRestore(); + } + }); + + it('保留流式 assistant 文本并标记 streaming 状态', () => { + const { container } = render( + , + ); + + expect(container.textContent).toContain('正在生成'); + expect(container.textContent).toContain('结果'); + }); + + it('用户消息保持纯文本,不解析 Markdown', () => { + const { container } = render( + , + ); + + expect(container.querySelector('strong')).toBeNull(); + expect(container.querySelector('code')).toBeNull(); + expect(container.textContent).toContain('**不要解析**'); + expect(container.textContent).toContain('`/read game`'); + }); + + it('不输出原始 HTML、可点击链接或图片节点', () => { + const { container } = render( + alert(1)\n\n[外链](https://example.com) ![示意图](x.png)' + } + />, + ); + + expect(container.querySelector('script')).toBeNull(); + expect(container.querySelector('a')).toBeNull(); + expect(container.querySelector('img')).toBeNull(); + expect(container.textContent).toContain('外链'); + expect(container.textContent).toContain('图片:示意图'); + }); + + it('支持表格并允许窄视口横向滚动', () => { + const { container } = render( + , + ); + + expect(container.querySelectorAll('table th')).toHaveLength(2); + expect(container.querySelector('div.overflow-x-auto')).toBeTruthy(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/ZoomPercentageInput.test.tsx b/apps/ai-game-creator-shell/tests/ZoomPercentageInput.test.tsx new file mode 100644 index 000000000..f02a71e3d --- /dev/null +++ b/apps/ai-game-creator-shell/tests/ZoomPercentageInput.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { ZoomPercentageInput } from '../src/view/ui-editor/components/preview/ZoomPercentageInput'; + +describe('ZoomPercentageInput', () => { + it('edits the displayed percentage and commits on blur without fitting', () => { + const onCommit = vi.fn(); + render(); + + const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); + expect((input as HTMLInputElement).value).toBe('50'); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: '125' } }); + expect(onCommit).not.toHaveBeenCalled(); + fireEvent.blur(input); + + expect(onCommit).toHaveBeenCalledWith(125); + expect((input as HTMLInputElement).value).toBe('125'); + }); + + it.each([ + { value: '0', expected: 25 }, + { value: '999', expected: 200 }, + ])('clamps $value to $expected on blur', ({ value, expected }) => { + const onCommit = vi.fn(); + render(); + + const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); + fireEvent.focus(input); + fireEvent.change(input, { target: { value } }); + fireEvent.blur(input); + + expect(onCommit).toHaveBeenCalledWith(expected); + expect((input as HTMLInputElement).value).toBe(String(expected)); + }); + + it('restores the current percentage when the draft is invalid', () => { + const onCommit = vi.fn(); + render(); + + const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: '' } }); + fireEvent.blur(input); + + expect(onCommit).not.toHaveBeenCalled(); + expect((input as HTMLInputElement).value).toBe('80'); + }); + + it('tracks viewport updates while not editing', () => { + const onCommit = vi.fn(); + const view = render( + , + ); + const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); + + view.rerender( + , + ); + + expect((input as HTMLInputElement).value).toBe('140'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-assets.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-assets.suite.ts index 1df0e257a..ef3335a59 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-assets.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-assets.suite.ts @@ -923,7 +923,9 @@ export function registerProjectAssetTests() { submitChat('/read game/index.html'); expect(await screen.findByText(/文件:game\/index\.html/)).not.toBeNull(); - expect(screen.getByText(/<\/canvas>/)).not.toBeNull(); + const fileContent = screen.getByText(''); + expect(fileContent.tagName).toBe('CODE'); + expect(fileContent.closest('pre')).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('read_local_project_file', { projectPath: '/tmp/authorized-game', relativePath: 'game/index.html', @@ -992,7 +994,9 @@ export function registerProjectAssetTests() { fireEvent.click(screen.getByRole('button', { name: '确认' })); expect(await screen.findByText(/文件:game\/index\.html/)).not.toBeNull(); - expect(screen.getByText(/<\/canvas>/)).not.toBeNull(); + const fileContent = screen.getByText(''); + expect(fileContent.tagName).toBe('CODE'); + expect(fileContent.closest('pre')).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('read_local_project_file', { projectPath: '/tmp/authorized-game', relativePath: 'game/index.html', diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts index c4f51ff77..528710a1e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts @@ -3805,7 +3805,13 @@ export function registerProjectCommandTests() { submitChat('/files'); expect(await screen.findByText(/本地项目文件:/)).not.toBeNull(); - expect(screen.getByText(/- game\//)).not.toBeNull(); + expect( + screen.getByText( + (_, element) => + element?.tagName === 'LI' && + element.textContent?.trim() === '- game/', + ), + ).not.toBeNull(); expect(screen.getByText(/- game\/index\.html/)).not.toBeNull(); expect(screen.getByText(/- assets\/uploads\/hero\.png/)).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('list_local_project_files', { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts index cfb73e050..30d180a03 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-planning-and-status-shortcuts.ts @@ -1,5 +1,6 @@ import { expect, fireEvent, screen, submitChat, within } from '../../harness'; import type { PreviewShortcutInvoke } from './invoke-mock'; +import { messageBubble } from './message-bubble'; export async function assertPlanningAndStatusShortcutFlow( invoke: PreviewShortcutInvoke, @@ -70,7 +71,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/blockers'); const blockerMessages = await screen.findAllByText(/当前阻塞项:/); - const blockerMessage = blockerMessages[blockerMessages.length - 1]; + const blockerMessage = messageBubble( + blockerMessages[blockerMessages.length - 1], + ); expect(blockerMessage.textContent).toContain('项目:未命名游戏原型'); expect(blockerMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -170,7 +173,7 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/ready'); const readyMessages = await screen.findAllByText(/试玩就绪度:/); - const readyMessage = readyMessages[readyMessages.length - 1]; + const readyMessage = messageBubble(readyMessages[readyMessages.length - 1]); expect(readyMessage.textContent).toContain('项目:未命名游戏原型'); expect(readyMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -270,7 +273,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/evidence'); const evidenceMessages = await screen.findAllByText(/验证证据台账:/); - const evidenceMessage = evidenceMessages[evidenceMessages.length - 1]; + const evidenceMessage = messageBubble( + evidenceMessages[evidenceMessages.length - 1], + ); expect(evidenceMessage.textContent).toContain('项目:未命名游戏原型'); expect(evidenceMessage.textContent).toContain( 'Run trace:已有 run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -377,7 +382,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/deps'); const dependencyMessages = await screen.findAllByText(/任务依赖链:/); - const dependencyMessage = dependencyMessages[dependencyMessages.length - 1]; + const dependencyMessage = messageBubble( + dependencyMessages[dependencyMessages.length - 1], + ); expect(dependencyMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -479,7 +486,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/revise'); const revisionMessages = await screen.findAllByText(/改版草稿:/); - const revisionMessage = revisionMessages[revisionMessages.length - 1]; + const revisionMessage = messageBubble( + revisionMessages[revisionMessages.length - 1], + ); expect(revisionMessage.textContent).toContain('项目:未命名游戏原型'); expect(revisionMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -604,7 +613,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/privacy'); const privacyMessages = await screen.findAllByText(/隐私与导出边界:/); - const privacyMessage = privacyMessages[privacyMessages.length - 1]; + const privacyMessage = messageBubble( + privacyMessages[privacyMessages.length - 1], + ); expect(privacyMessage.textContent).toContain('项目:未命名游戏原型'); expect(privacyMessage.textContent).toContain( '本地目录:/tmp/authorized-game', @@ -616,10 +627,10 @@ export async function assertPlanningAndStatusShortcutFlow( '本地预览:未启动;仅限 127.0.0.1 本机访问', ); expect(privacyMessage.textContent).toContain( - '试玩包:尚未导出;只应包含 game/**、assets/** 和 exports/README.md', + '试玩包:尚未导出;只应包含 game/、assets/ 和 exports/README.md', ); expect(privacyMessage.textContent).toContain( - '内部文件:.agent/**、memory/**、日志、trace、配置和密钥不得进入试玩包', + '内部文件:.agent/、memory/、日志、trace、配置和密钥不得进入试玩包', ); expect(privacyMessage.textContent).toContain( '素材来源:2 个;上传 1 / 画板 1', @@ -697,7 +708,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/criteria'); expect(await screen.findByText(/当前验收标准:/)).not.toBeNull(); const criteriaMessages = screen.getAllByText(/当前验收标准:/); - const criteriaMessage = criteriaMessages[criteriaMessages.length - 1]; + const criteriaMessage = messageBubble( + criteriaMessages[criteriaMessages.length - 1], + ); expect(criteriaMessage.textContent).toContain( 'ready:美术组 / Asset 生成首版美术素材(art-asset-plan)', ); @@ -740,7 +753,7 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/groups'); expect(await screen.findByText(/专业组进度:/)).not.toBeNull(); const groupMessages = screen.getAllByText(/专业组进度:/); - const groupMessage = groupMessages[groupMessages.length - 1]; + const groupMessage = messageBubble(groupMessages[groupMessages.length - 1]); expect(groupMessage.textContent).toContain( '美术组:完成 0/3 · active 0 · carry 0 · ready 1', ); @@ -793,7 +806,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/balance'); expect(await screen.findByText(/数值状态:/)).not.toBeNull(); const balanceMessages = screen.getAllByText(/数值状态:/); - const balanceMessage = balanceMessages[balanceMessages.length - 1]; + const balanceMessage = messageBubble( + balanceMessages[balanceMessages.length - 1], + ); expect(balanceMessage.textContent).toContain('项目:未命名游戏原型'); expect(balanceMessage.textContent).toContain( 'balance-director:Director 确定数值口径 · 待处理', @@ -874,7 +889,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/budget'); expect(await screen.findByText(/运行预算:/)).not.toBeNull(); const budgetMessages = screen.getAllByText(/运行预算:/); - const budgetMessage = budgetMessages[budgetMessages.length - 1]; + const budgetMessage = messageBubble( + budgetMessages[budgetMessages.length - 1], + ); expect(budgetMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -923,7 +940,7 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/qa'); expect(await screen.findByText(/质量检查:/)).not.toBeNull(); const qaMessages = screen.getAllByText(/质量检查:/); - const qaMessage = qaMessages[qaMessages.length - 1]; + const qaMessage = messageBubble(qaMessages[qaMessages.length - 1]); expect(qaMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -988,7 +1005,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/changes'); expect(await screen.findByText(/最近变更:/)).not.toBeNull(); const changeMessages = screen.getAllByText(/最近变更:/); - const changeMessage = changeMessages[changeMessages.length - 1]; + const changeMessage = messageBubble( + changeMessages[changeMessages.length - 1], + ); expect(changeMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -1064,7 +1083,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/review'); expect(await screen.findByText(/评审状态:/)).not.toBeNull(); const reviewMessages = screen.getAllByText(/评审状态:/); - const reviewMessage = reviewMessages[reviewMessages.length - 1]; + const reviewMessage = messageBubble( + reviewMessages[reviewMessages.length - 1], + ); expect(reviewMessage.textContent).toContain('Evaluator:通过'); expect(reviewMessage.textContent).toContain('返工焦点:暂无'); expect(reviewMessage.textContent).toContain( @@ -1150,7 +1171,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/timeline'); expect(await screen.findByText(/项目时间线:/)).not.toBeNull(); const timelineMessages = screen.getAllByText(/项目时间线:/); - const timelineMessage = timelineMessages[timelineMessages.length - 1]; + const timelineMessage = messageBubble( + timelineMessages[timelineMessages.length - 1], + ); expect(timelineMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done', ); @@ -1209,7 +1232,9 @@ export async function assertPlanningAndStatusShortcutFlow( submitChat('/handoff'); expect(await screen.findByText(/项目交接:/)).not.toBeNull(); const handoffMessages = screen.getAllByText(/项目交接:/); - const handoffMessage = handoffMessages[handoffMessages.length - 1]; + const handoffMessage = messageBubble( + handoffMessages[handoffMessages.length - 1], + ); expect(handoffMessage.textContent).toContain( '- Run:run-main-shortcut-trace', ); @@ -1335,14 +1360,14 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/todo'); const todoMessages = await screen.findAllByText(/下一轮小步:/); - const todoMessage = todoMessages[todoMessages.length - 1]; + const todoMessage = messageBubble(todoMessages[todoMessages.length - 1]); expect(todoMessage.textContent).toContain('项目:未命名游戏原型'); expect(todoMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); expect(todoMessage.textContent).toContain('编排下一步:preview'); expect(todoMessage.textContent).toContain( - '1. ready:美术组 / Asset 生成首版美术素材(art-asset-plan) · 待处理 · 验收:角色、场景、UI 和动画需求已映射到画板或本地资产,且至少一张首版核心素材图已生成并登记', + 'ready:美术组 / Asset 生成首版美术素材(art-asset-plan) · 待处理 · 验收:角色、场景、UI 和动画需求已映射到画板或本地资产,且至少一张首版核心素材图已生成并登记', ); expect(todoMessage.textContent).toContain( '边界:只整理下一步;不读取任务文件;不启动 run;不修改项目', @@ -1428,7 +1453,7 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/plan'); const planMessages = await screen.findAllByText(/下一轮分工计划:/); - const planMessage = planMessages[planMessages.length - 1]; + const planMessage = messageBubble(planMessages[planMessages.length - 1]); expect(planMessage.textContent).toContain('项目:未命名游戏原型'); expect(planMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -1533,7 +1558,7 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/guide'); const guideMessages = await screen.findAllByText(/使用导引:/); - const guideMessage = guideMessages[guideMessages.length - 1]; + const guideMessage = messageBubble(guideMessages[guideMessages.length - 1]); expect(guideMessage.textContent).toContain('项目:未命名游戏原型'); expect(guideMessage.textContent).toContain('当前阶段:可导出'); expect(guideMessage.textContent).toContain( @@ -1627,7 +1652,9 @@ export async function assertPlanningAndStatusShortcutFlow( }; submitChat('/progress'); const progressMessages = await screen.findAllByText(/项目进度:/); - const progressMessage = progressMessages[progressMessages.length - 1]; + const progressMessage = messageBubble( + progressMessages[progressMessages.length - 1], + ); expect(progressMessage.textContent).toContain('项目:未命名游戏原型'); expect(progressMessage.textContent).toContain('当前阶段:已生成'); expect(progressMessage.textContent).toMatch( diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-playtest-and-release-shortcuts.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-playtest-and-release-shortcuts.ts index acb85a840..2e7cf976c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-playtest-and-release-shortcuts.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-playtest-and-release-shortcuts.ts @@ -1,5 +1,6 @@ import { expect, fireEvent, screen, submitChat, within } from '../../harness'; import type { PreviewShortcutInvoke } from './invoke-mock'; +import { messageBubble } from './message-bubble'; export async function assertPlaytestAndReleaseShortcutFlow( invoke: PreviewShortcutInvoke, @@ -170,7 +171,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/listing'); const listingMessages = await screen.findAllByText(/作品页草稿:/); - const listingMessage = listingMessages[listingMessages.length - 1]; + const listingMessage = messageBubble( + listingMessages[listingMessages.length - 1], + ); expect(listingMessage.textContent).toContain('标题:未命名游戏原型'); expect(listingMessage.textContent).toContain( '一句话卖点:做一个厨房弹幕游戏', @@ -262,7 +265,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( ).length; submitChat('/playtest'); const playtestMessages = await screen.findAllByText(/试玩状态:/); - const playtestMessage = playtestMessages[playtestMessages.length - 1]; + const playtestMessage = messageBubble( + playtestMessages[playtestMessages.length - 1], + ); expect(playtestMessage.textContent).toContain( '原型:最近 run 已通过 run-main-shortcut-trace', ); @@ -329,26 +334,26 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/test-plan'); const testPlanMessages = await screen.findAllByText(/手动测试计划:/); - const testPlanMessage = testPlanMessages[testPlanMessages.length - 1]; + const testPlanMessage = messageBubble( + testPlanMessages[testPlanMessages.length - 1], + ); expect(testPlanMessage.textContent).toContain('项目:未命名游戏原型'); expect(testPlanMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(testPlanMessage.textContent).toContain( '当前证据:最近 run 已通过 run-main-shortcut-trace;预览 未启动;入口 未见 trace 产物', ); expect(testPlanMessage.textContent).toContain( - '1. 启动预览:/run 后确认首屏不空白', + '启动预览:/run 后确认首屏不空白', ); expect(testPlanMessage.textContent).toContain( - '2. 30 秒理解:目标、操作、得分/失败和重开可见', + '30 秒理解:目标、操作、得分/失败和重开可见', ); expect(testPlanMessage.textContent).toContain( - '3. 输入验证:键盘/点击/触屏至少一种可完成核心动作', + '输入验证:键盘/点击/触屏至少一种可完成核心动作', ); + expect(testPlanMessage.textContent).toContain('结局验证:胜利或失败后可重开'); expect(testPlanMessage.textContent).toContain( - '4. 结局验证:胜利或失败后可重开', - ); - expect(testPlanMessage.textContent).toContain( - '5. 回归检查:/mobile;/accessibility;/performance;/audio', + '回归检查:/mobile;/accessibility;/performance;/audio', ); expect(testPlanMessage.textContent).toContain( 'preview-readiness:程序组 / Preview 执行静态自检 · 待处理', @@ -432,7 +437,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/audience'); const audienceMessages = await screen.findAllByText(/首批试玩对象:/); - const audienceMessage = audienceMessages[audienceMessages.length - 1]; + const audienceMessage = messageBubble( + audienceMessages[audienceMessages.length - 1], + ); expect(audienceMessage.textContent).toContain('项目:未命名游戏原型'); expect(audienceMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(audienceMessage.textContent).toContain( @@ -545,7 +552,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/invite'); const inviteMessages = await screen.findAllByText(/试玩邀请:/); - const inviteMessage = inviteMessages[inviteMessages.length - 1]; + const inviteMessage = messageBubble( + inviteMessages[inviteMessages.length - 1], + ); expect(inviteMessage.textContent).toContain('项目:未命名游戏原型'); expect(inviteMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(inviteMessage.textContent).toContain( @@ -723,7 +732,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/survey'); const surveyMessages = await screen.findAllByText(/试玩问卷:/); - const surveyMessage = surveyMessages[surveyMessages.length - 1]; + const surveyMessage = messageBubble( + surveyMessages[surveyMessages.length - 1], + ); expect(surveyMessage.textContent).toContain('项目:未命名游戏原型'); expect(surveyMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(surveyMessage.textContent).toContain( @@ -830,7 +841,7 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/cover'); const coverMessages = await screen.findAllByText(/封面与缩略图:/); - const coverMessage = coverMessages[coverMessages.length - 1]; + const coverMessage = messageBubble(coverMessages[coverMessages.length - 1]); expect(coverMessage.textContent).toContain('项目:未命名游戏原型'); expect(coverMessage.textContent).toContain( '当前状态:最近 run 已通过 run-main-shortcut-trace;预览 未启动', @@ -939,7 +950,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/screenshots'); const screenshotMessages = await screen.findAllByText(/宣传截图:/); - const screenshotMessage = screenshotMessages[screenshotMessages.length - 1]; + const screenshotMessage = messageBubble( + screenshotMessages[screenshotMessages.length - 1], + ); expect(screenshotMessage.textContent).toContain('项目:未命名游戏原型'); expect(screenshotMessage.textContent).toContain( '当前状态:最近 run 已通过 run-main-shortcut-trace;预览 未启动', @@ -1048,7 +1061,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/trailer'); const trailerMessages = await screen.findAllByText(/试玩短视频:/); - const trailerMessage = trailerMessages[trailerMessages.length - 1]; + const trailerMessage = messageBubble( + trailerMessages[trailerMessages.length - 1], + ); expect(trailerMessage.textContent).toContain('项目:未命名游戏原型'); expect(trailerMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(trailerMessage.textContent).toContain( @@ -1158,7 +1173,7 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/faq'); const faqMessages = await screen.findAllByText(/试玩 FAQ:/); - const faqMessage = faqMessages[faqMessages.length - 1]; + const faqMessage = messageBubble(faqMessages[faqMessages.length - 1]); expect(faqMessage.textContent).toContain('项目:未命名游戏原型'); expect(faqMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(faqMessage.textContent).toContain( @@ -1263,7 +1278,7 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/post'); const postMessages = await screen.findAllByText(/社区发布文案:/); - const postMessage = postMessages[postMessages.length - 1]; + const postMessage = messageBubble(postMessages[postMessages.length - 1]); expect(postMessage.textContent).toContain('项目:未命名游戏原型'); expect(postMessage.textContent).toContain('一句话:做一个厨房弹幕游戏'); expect(postMessage.textContent).toContain( @@ -1374,7 +1389,7 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/store'); const storeMessages = await screen.findAllByText(/上架资料:/); - const storeMessage = storeMessages[storeMessages.length - 1]; + const storeMessage = messageBubble(storeMessages[storeMessages.length - 1]); expect(storeMessage.textContent).toContain('项目:未命名游戏原型'); expect(storeMessage.textContent).toContain('一句话:做一个厨房弹幕游戏'); expect(storeMessage.textContent).toContain( @@ -1484,7 +1499,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/media-kit'); const mediaKitMessages = await screen.findAllByText(/媒体资料包:/); - const mediaKitMessage = mediaKitMessages[mediaKitMessages.length - 1]; + const mediaKitMessage = messageBubble( + mediaKitMessages[mediaKitMessages.length - 1], + ); expect(mediaKitMessage.textContent).toContain('项目:未命名游戏原型'); expect(mediaKitMessage.textContent).toContain('一句话:做一个厨房弹幕游戏'); expect(mediaKitMessage.textContent).toContain( @@ -1591,8 +1608,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/release-notes'); const releaseNotesMessages = await screen.findAllByText(/试玩更新说明:/); - const releaseNotesMessage = - releaseNotesMessages[releaseNotesMessages.length - 1]; + const releaseNotesMessage = messageBubble( + releaseNotesMessages[releaseNotesMessages.length - 1], + ); expect(releaseNotesMessage.textContent).toContain('项目:未命名游戏原型'); expect(releaseNotesMessage.textContent).toContain( '一句话:做一个厨房弹幕游戏', @@ -1709,7 +1727,9 @@ export async function assertPlaytestAndReleaseShortcutFlow( }; submitChat('/known-issues'); const knownIssueMessages = await screen.findAllByText(/已知问题清单:/); - const knownIssueMessage = knownIssueMessages[knownIssueMessages.length - 1]; + const knownIssueMessage = messageBubble( + knownIssueMessages[knownIssueMessages.length - 1], + ); expect(knownIssueMessage.textContent).toContain('项目:未命名游戏原型'); expect(knownIssueMessage.textContent).toContain( '当前状态:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed;预览 未启动', diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts index 6da7eacc0..4d0019dff 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-and-design-shortcuts.ts @@ -1,5 +1,6 @@ import { expect, fireEvent, screen, submitChat, within } from '../../harness'; import type { PreviewShortcutInvoke } from './invoke-mock'; +import { messageBubble } from './message-bubble'; export async function assertProjectAndDesignShortcutFlow( invoke: PreviewShortcutInvoke, @@ -43,11 +44,12 @@ export async function assertProjectAndDesignShortcutFlow( ), ).toBe(true); submitChat('/agent-conversations'); - expect( - await screen.findByText( - /Agent 对话读取命令:[\s\S]*美术组 \/ Asset · 生成首版美术素材:\/read \.agent\/conversations\/agents\/art-asset-plan\.jsonl/, - ), - ).not.toBeNull(); + const agentConversationMessage = + await screen.findByText('Agent 对话读取命令:'); + const agentConversationBubble = messageBubble(agentConversationMessage); + expect(agentConversationBubble.textContent).toContain( + '美术组 / Asset · 生成首版美术素材:/read .agent/conversations/agents/art-asset-plan.jsonl', + ); fireEvent.click(screen.getByRole('button', { name: '读取拆解创作方向对话' })); expect(screen.getByLabelText('创作想法')).toHaveProperty( 'value', @@ -60,11 +62,12 @@ export async function assertProjectAndDesignShortcutFlow( }), ); submitChat('/agent-memories'); - expect( - await screen.findByText( - /Agent 私有记忆读取命令:[\s\S]*美术组 \/ Asset · 生成首版美术素材:\/read memory\/agents\/art\/asset\.md/, - ), - ).not.toBeNull(); + const agentMemoryMessage = + await screen.findByText('Agent 私有记忆读取命令:'); + const agentMemoryBubble = messageBubble(agentMemoryMessage); + expect(agentMemoryBubble.textContent).toContain( + '美术组 / Asset · 生成首版美术素材:/read memory/agents/art/asset.md', + ); fireEvent.click(screen.getByRole('button', { name: '读取拆解创作方向记忆' })); expect(screen.getByLabelText('创作想法')).toHaveProperty( 'value', @@ -126,7 +129,7 @@ export async function assertProjectAndDesignShortcutFlow( submitChat('/goal'); expect(await screen.findByText(/创作目标:/)).not.toBeNull(); const goalMessages = screen.getAllByText(/创作目标:/); - const goalMessage = goalMessages[goalMessages.length - 1]; + const goalMessage = messageBubble(goalMessages[goalMessages.length - 1]); expect(goalMessage.textContent).toContain('Manifest:做一个厨房弹幕游戏'); expect(goalMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', @@ -184,7 +187,7 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/spec'); const specMessages = await screen.findAllByText(/创作规格包:/); - const specMessage = specMessages[specMessages.length - 1]; + const specMessage = messageBubble(specMessages[specMessages.length - 1]); expect(specMessage.textContent).toContain('项目:未命名游戏原型'); expect(specMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(specMessage.textContent).toContain( @@ -284,7 +287,7 @@ export async function assertProjectAndDesignShortcutFlow( submitChat('/mvp'); expect(await screen.findByText(/MVP 范围:/)).not.toBeNull(); const mvpMessages = screen.getAllByText(/MVP 范围:/); - const mvpMessage = mvpMessages[mvpMessages.length - 1]; + const mvpMessage = messageBubble(mvpMessages[mvpMessages.length - 1]); expect(mvpMessage.textContent).toContain('项目:未命名游戏原型'); expect(mvpMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(mvpMessage.textContent).toContain( @@ -380,7 +383,7 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/pitch'); const pitchMessages = await screen.findAllByText(/试玩定位:/); - const pitchMessage = pitchMessages[pitchMessages.length - 1]; + const pitchMessage = messageBubble(pitchMessages[pitchMessages.length - 1]); expect(pitchMessage.textContent).toContain('项目:未命名游戏原型'); expect(pitchMessage.textContent).toContain('一句话:做一个厨房弹幕游戏'); expect(pitchMessage.textContent).toContain( @@ -475,7 +478,7 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/demo'); const demoMessages = await screen.findAllByText(/试玩讲解稿:/); - const demoMessage = demoMessages[demoMessages.length - 1]; + const demoMessage = messageBubble(demoMessages[demoMessages.length - 1]); expect(demoMessage.textContent).toContain('项目:未命名游戏原型'); expect(demoMessage.textContent).toContain( '30 秒开场:这是《未命名游戏原型》,目标是做一个厨房弹幕游戏', @@ -575,7 +578,9 @@ export async function assertProjectAndDesignShortcutFlow( submitChat('/rules'); expect(await screen.findByText(/玩法操作:/)).not.toBeNull(); const controlMessages = screen.getAllByText(/玩法操作:/); - const controlMessage = controlMessages[controlMessages.length - 1]; + const controlMessage = messageBubble( + controlMessages[controlMessages.length - 1], + ); expect(controlMessage.textContent).toContain('项目:未命名游戏原型'); expect(controlMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(controlMessage.textContent).toContain( @@ -679,7 +684,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/tutorial'); const tutorialMessages = await screen.findAllByText(/新手引导:/); - const tutorialMessage = tutorialMessages[tutorialMessages.length - 1]; + const tutorialMessage = messageBubble( + tutorialMessages[tutorialMessages.length - 1], + ); expect(tutorialMessage.textContent).toContain('项目:未命名游戏原型'); expect(tutorialMessage.textContent).toContain('首屏目标:做一个厨房弹幕游戏'); expect(tutorialMessage.textContent).toContain( @@ -782,7 +789,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/mobile'); const mobileMessages = await screen.findAllByText(/移动试玩:/); - const mobileMessage = mobileMessages[mobileMessages.length - 1]; + const mobileMessage = messageBubble( + mobileMessages[mobileMessages.length - 1], + ); expect(mobileMessage.textContent).toContain('项目:未命名游戏原型'); expect(mobileMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(mobileMessage.textContent).toContain( @@ -891,8 +900,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/compatibility'); const compatibilityMessages = await screen.findAllByText(/兼容性说明:/); - const compatibilityMessage = - compatibilityMessages[compatibilityMessages.length - 1]; + const compatibilityMessage = messageBubble( + compatibilityMessages[compatibilityMessages.length - 1], + ); expect(compatibilityMessage.textContent).toContain('项目:未命名游戏原型'); expect(compatibilityMessage.textContent).toContain( '目标:做一个厨房弹幕游戏', @@ -1006,8 +1016,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/accessibility'); const accessibilityMessages = await screen.findAllByText(/可读性与无障碍:/); - const accessibilityMessage = - accessibilityMessages[accessibilityMessages.length - 1]; + const accessibilityMessage = messageBubble( + accessibilityMessages[accessibilityMessages.length - 1], + ); expect(accessibilityMessage.textContent).toContain('项目:未命名游戏原型'); expect(accessibilityMessage.textContent).toContain( '目标:做一个厨房弹幕游戏', @@ -1129,8 +1140,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/localization'); const localizationMessages = await screen.findAllByText(/本地化与文案:/); - const localizationMessage = - localizationMessages[localizationMessages.length - 1]; + const localizationMessage = messageBubble( + localizationMessages[localizationMessages.length - 1], + ); expect(localizationMessage.textContent).toContain('项目:未命名游戏原型'); expect(localizationMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(localizationMessage.textContent).toContain( @@ -1259,8 +1271,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/performance'); const performanceMessages = await screen.findAllByText(/性能与加载:/); - const performanceMessage = - performanceMessages[performanceMessages.length - 1]; + const performanceMessage = messageBubble( + performanceMessages[performanceMessages.length - 1], + ); expect(performanceMessage.textContent).toContain('项目:未命名游戏原型'); expect(performanceMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(performanceMessage.textContent).toContain( @@ -1376,7 +1389,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/polish'); const polishMessages = await screen.findAllByText(/试玩前打磨:/); - const polishMessage = polishMessages[polishMessages.length - 1]; + const polishMessage = messageBubble( + polishMessages[polishMessages.length - 1], + ); expect(polishMessage.textContent).toContain('项目:未命名游戏原型'); expect(polishMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(polishMessage.textContent).toContain( @@ -1488,7 +1503,9 @@ export async function assertProjectAndDesignShortcutFlow( }; submitChat('/credits'); const creditMessages = await screen.findAllByText(/素材署名:/); - const creditMessage = creditMessages[creditMessages.length - 1]; + const creditMessage = messageBubble( + creditMessages[creditMessages.length - 1], + ); expect(creditMessage.textContent).toContain('当前资产:2 个'); expect(creditMessage.textContent).toContain('来源分布:上传 1 / 画板 1'); expect(creditMessage.textContent).toContain( diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts index ce71e30ed..408b6041e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/assert-project-tools-and-preview.ts @@ -7,6 +7,7 @@ import { within, } from '../../harness'; import type { PreviewShortcutInvoke } from './invoke-mock'; +import { messageBubble } from './message-bubble'; export async function assertProjectToolsAndPreviewFlow( invoke: PreviewShortcutInvoke, @@ -36,7 +37,9 @@ export async function assertProjectToolsAndPreviewFlow( }; submitChat('/feedback'); const feedbackMessages = await screen.findAllByText(/试玩反馈:/); - const feedbackMessage = feedbackMessages[feedbackMessages.length - 1]; + const feedbackMessage = messageBubble( + feedbackMessages[feedbackMessages.length - 1], + ); expect(feedbackMessage.textContent).toContain( 'Run:run-main-shortcut-trace · passed / done · 1/3 轮 · evaluator-passed', ); @@ -125,7 +128,9 @@ export async function assertProjectToolsAndPreviewFlow( }; submitChat('/retention'); const retentionMessages = await screen.findAllByText(/复玩观察:/); - const retentionMessage = retentionMessages[retentionMessages.length - 1]; + const retentionMessage = messageBubble( + retentionMessages[retentionMessages.length - 1], + ); expect(retentionMessage.textContent).toContain('项目:未命名游戏原型'); expect(retentionMessage.textContent).toContain('目标:做一个厨房弹幕游戏'); expect(retentionMessage.textContent).toContain( @@ -233,7 +238,7 @@ export async function assertProjectToolsAndPreviewFlow( }; submitChat('/share'); const shareMessages = await screen.findAllByText(/试玩交付:/); - const shareMessage = shareMessages[shareMessages.length - 1]; + const shareMessage = messageBubble(shareMessages[shareMessages.length - 1]); expect(shareMessage.textContent).toContain('项目:未命名游戏原型'); expect(shareMessage.textContent).toContain('目录:/tmp/authorized-game'); expect(shareMessage.textContent).toContain( @@ -438,11 +443,11 @@ export async function assertProjectToolsAndPreviewFlow( expect(composerInput).toHaveProperty('value', '/read game/index.html'); await waitFor(() => expect(document.activeElement).toBe(composerInput)); submitChat('/run-artifacts'); - expect( - await screen.findByText( - /最近 Run 产物读取命令:[\s\S]*exports\/README\.md · 128B · fnv1a64:exports:\/read exports\/README\.md/, - ), - ).not.toBeNull(); + const runArtifactsHeading = + await screen.findByText('最近 Run 产物读取命令:'); + expect(messageBubble(runArtifactsHeading).textContent).toContain( + 'exports/README.md · 128B · fnv1a64:exports:/read exports/README.md', + ); fireEvent.click(screen.getByRole('button', { name: '读取首个 Run 产物' })); expect(composerInput).toHaveProperty('value', '/read exports/README.md'); expect(invoke).not.toHaveBeenCalledWith( @@ -457,8 +462,9 @@ export async function assertProjectToolsAndPreviewFlow( submitChat('/passes'); const passArtifactMessages = await screen.findAllByText(/Agent 轮次产物读取命令:/); - const passArtifactMessage = - passArtifactMessages[passArtifactMessages.length - 1]; + const passArtifactMessage = messageBubble( + passArtifactMessages[passArtifactMessages.length - 1], + ); expect(passArtifactMessage.textContent).toContain( '.agent/passes/pass-1/agenda.md · 96B · fnv1a64:agenda', ); @@ -564,13 +570,12 @@ export async function assertProjectToolsAndPreviewFlow( expect(screen.getByText(/下一步:设计实现组 \/ Director/)).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: 'Trace' })); - expect( - await screen.findByText( - (content) => - content.trimStart().startsWith('Run:run-main-shortcut-trace') && - content.includes('产物快照:'), - ), - ).not.toBeNull(); + const traceHeading = await screen.findByText( + (content, element) => + element?.tagName === 'P' && + content.trimStart().startsWith('Run:run-main-shortcut-trace'), + ); + expect(messageBubble(traceHeading).textContent).toContain('产物快照:'); expect(screen.getByText(/产物快照:/)).not.toBeNull(); expect( screen.getByText(/exports\/README\.md · fnv1a64:exports/), diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/message-bubble.ts b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/message-bubble.ts new file mode 100644 index 000000000..60bd9e43b --- /dev/null +++ b/apps/ai-game-creator-shell/tests/appSurface/project-preview/preview-shortcuts/message-bubble.ts @@ -0,0 +1,7 @@ +export function messageBubble(element: Element): HTMLElement { + const bubble = element.closest('.message'); + if (!bubble) { + throw new Error('Expected chat message bubble'); + } + return bubble; +} diff --git a/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx new file mode 100644 index 000000000..908086f1d --- /dev/null +++ b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx @@ -0,0 +1,203 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Node as UiNode } from '../src/features/ui-editor/types/Node'; +import type { UIDesignImage } from '../src/features/ui-editor/types/UIDesignImage'; +import { PreviewWorkspace } from '../src/view/ui-editor/components/preview/PreviewWorkspace'; +import type { UiEditorCanvasProjection } from '../src/view/ui-editor/useUiEditorPage'; + +class TestResizeObserver { + observe() {} + disconnect() {} + unobserve() {} +} + +const root: UiNode = { + id: 'root', + layout: { + transform: { + anchor_min: [0, 0], + anchor_max: [1, 1], + offset_min: [0, 0], + offset_max: [0, 0], + }, + custom_minimum_size: [0, 0], + size_flags_horizontal: 1, + size_flags_vertical: 1, + size_flags_stretch_ratio: 1, + container: 'None', + }, + metadata: { + name: '根节点', + description: '', + layout_status: 'NoProblem', + components_status: 'NoProblem', + allow_llm_edit_layout: true, + allow_llm_edit_component: true, + source: 'System', + }, + components: [], + children_display_mode: 'Stack', + children: [], +}; + +const activeImage: UIDesignImage = { + metadata: { + name: '测试界面', + description: '', + role: null, + slave_to: null, + }, + path: 'assets/page.png', + pixel_size: [1200, 800], + pixels_per_unit: 1, +}; + +function createCanvas( + overrides: Partial = {}, +): UiEditorCanvasProjection { + return { + isLocked: false, + activeImage, + activeImageId: 'page-1', + previewUrls: { 'page-1': 'data:image/png;base64,' }, + images: { 'page-1': activeImage }, + sprites: {}, + fontFaces: {}, + tree: { src_ui_design: 'page-1', root }, + selectedNode: null, + selectedNodeId: null, + keepChildrenUnchanged: false, + hiddenNodeIds: new Set(), + focusRequest: null, + status: null, + isNodePreviewVisible: vi.fn(() => true), + toggleNodePreviewVisibility: vi.fn(), + selectExclusiveChild: vi.fn(), + selectNode: vi.fn(), + clearNodeSelection: vi.fn(), + updateNodeTransform: vi.fn(), + insertNode: vi.fn(), + insertNodeAfter: vi.fn(), + deleteNode: vi.fn(), + openClearDialog: vi.fn(), + ...overrides, + }; +} + +function renderedScale(container: HTMLElement) { + const transform = ( + container.querySelector('.genarrative-image-canvas__world') as HTMLElement + ).style.transform; + const scale = /scale\(([^)]+)\)/.exec(transform)?.[1]; + if (!scale) throw new Error(`无法从 ${transform} 读取缩放值`); + return Number(scale); +} + +beforeEach(() => { + vi.stubGlobal('ResizeObserver', TestResizeObserver); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('PreviewWorkspace quick zoom', () => { + it('uses the toolbar zoom step while the preview is hovered', () => { + const rendered = render(); + const preview = screen.getByRole('region', { name: 'UI 预览画布' }); + const before = renderedScale(rendered.container); + + fireEvent.pointerEnter(preview); + const wasNotCancelled = fireEvent.keyDown(window, { + key: '=', + code: 'Equal', + ctrlKey: true, + cancelable: true, + }); + + expect(wasNotCancelled).toBe(false); + expect(renderedScale(rendered.container)).toBeCloseTo(before * 1.16); + }); + + it('requires hover or focus and ignores interactive targets', () => { + const rendered = render(); + const preview = screen.getByRole('region', { name: 'UI 预览画布' }); + const zoomButton = screen.getByRole('button', { name: '放大画布' }); + const initial = renderedScale(rendered.container); + + expect( + fireEvent.keyDown(window, { + key: '=', + ctrlKey: true, + cancelable: true, + }), + ).toBe(true); + expect(renderedScale(rendered.container)).toBe(initial); + + fireEvent.pointerEnter(preview); + expect( + fireEvent.keyDown(zoomButton, { + key: '=', + ctrlKey: true, + cancelable: true, + }), + ).toBe(true); + expect(renderedScale(rendered.container)).toBe(initial); + + fireEvent.pointerLeave(preview); + fireEvent.focus(preview); + fireEvent.keyDown(window, { + key: '-', + code: 'Minus', + ctrlKey: true, + cancelable: true, + }); + expect(renderedScale(rendered.container)).toBeCloseTo(initial * 0.86); + }); + + it('keeps actual-size and fit shortcuts within the preview scope', () => { + const rendered = render(); + const preview = screen.getByRole('region', { name: 'UI 预览画布' }); + const fitted = renderedScale(rendered.container); + + fireEvent.focus(preview); + fireEvent.keyDown(window, { + key: '1', + ctrlKey: true, + cancelable: true, + }); + expect(renderedScale(rendered.container)).toBe(1); + + fireEvent.keyDown(window, { + key: '0', + ctrlKey: true, + cancelable: true, + }); + expect(renderedScale(rendered.container)).toBe(fitted); + }); + + it('leaves browser zoom untouched when the preview has no content', () => { + render( + , + ); + + expect( + fireEvent.keyDown(window, { + key: '=', + ctrlKey: true, + cancelable: true, + }), + ).toBe(true); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts b/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts new file mode 100644 index 000000000..5ef72b7db --- /dev/null +++ b/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts @@ -0,0 +1,176 @@ +// @vitest-environment jsdom + +import { fireEvent } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + handlePreviewZoomKeyDown, + type PreviewZoomKeyboardActions, + type PreviewZoomKeyboardContext, + previewZoomUsesMetaModifier, +} from '../src/view/ui-editor/components/preview/previewZoomKeyboard'; + +function createActions(): PreviewZoomKeyboardActions { + return { + fit: vi.fn(), + resetToActualSize: vi.fn(), + zoomIn: vi.fn(), + zoomOut: vi.fn(), + }; +} + +function dispatchShortcut({ + actions = createActions(), + context = {}, + event, + target, +}: { + actions?: PreviewZoomKeyboardActions; + context?: Partial; + event: KeyboardEventInit; + target?: HTMLElement; +}) { + const resolvedTarget = target ?? document.createElement('div'); + document.body.append(resolvedTarget); + const handler = vi.fn((keyboardEvent: KeyboardEvent) => + handlePreviewZoomKeyDown( + keyboardEvent, + { + hasZoomableViewport: true, + isFocused: false, + isHovered: true, + usesMetaModifier: false, + ...context, + }, + actions, + ), + ); + resolvedTarget.addEventListener('keydown', handler); + fireEvent.keyDown(resolvedTarget, event); + const keyboardEvent = handler.mock.calls[0]?.[0]; + return { actions, handler, keyboardEvent }; +} + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe('preview zoom keyboard shortcuts', () => { + it.each([ + { key: '+', code: 'Equal' }, + { key: '=', code: 'Equal' }, + { key: '+', code: 'NumpadAdd' }, + ])('zooms in for the supported main and numpad keys (%o)', (event) => { + const { actions, keyboardEvent } = dispatchShortcut({ + event: { ...event, ctrlKey: true, cancelable: true, repeat: true }, + }); + + expect(actions.zoomIn).toHaveBeenCalledTimes(1); + expect(keyboardEvent?.defaultPrevented).toBe(true); + }); + + it.each([ + { key: '-', code: 'Minus' }, + { key: '-', code: 'NumpadSubtract' }, + ])('zooms out for the supported main and numpad keys (%o)', (event) => { + const { actions, keyboardEvent } = dispatchShortcut({ + event: { ...event, ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomOut).toHaveBeenCalledTimes(1); + expect(keyboardEvent?.defaultPrevented).toBe(true); + }); + + it('does not treat underscore as zoom out', () => { + const { actions, keyboardEvent } = dispatchShortcut({ + event: { + key: '_', + code: 'Minus', + ctrlKey: true, + shiftKey: true, + cancelable: true, + }, + }); + + expect(actions.zoomOut).not.toHaveBeenCalled(); + expect(keyboardEvent?.defaultPrevented).toBe(false); + }); + + it.each([ + { key: '0', action: 'fit' as const }, + { key: '1', action: 'resetToActualSize' as const }, + ])('keeps the existing $key shortcut', ({ key, action }) => { + const { actions } = dispatchShortcut({ + event: { key, ctrlKey: true, cancelable: true }, + }); + + expect(actions[action]).toHaveBeenCalledTimes(1); + }); + + it('uses Cmd on Apple platforms and Ctrl elsewhere', () => { + expect(previewZoomUsesMetaModifier('MacIntel')).toBe(true); + expect(previewZoomUsesMetaModifier('iPad')).toBe(true); + expect(previewZoomUsesMetaModifier('Win32')).toBe(false); + + const appleActions = createActions(); + dispatchShortcut({ + actions: appleActions, + context: { usesMetaModifier: true }, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + expect(appleActions.zoomIn).not.toHaveBeenCalled(); + dispatchShortcut({ + actions: appleActions, + context: { usesMetaModifier: true }, + event: { key: '=', metaKey: true, cancelable: true }, + }); + expect(appleActions.zoomIn).toHaveBeenCalledTimes(1); + }); + + it.each([ + { isHovered: false, isFocused: false, hasZoomableViewport: true }, + { isHovered: true, isFocused: false, hasZoomableViewport: false }, + ])('leaves inactive preview shortcuts to the host (%o)', (context) => { + const { actions, keyboardEvent } = dispatchShortcut({ + context, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).not.toHaveBeenCalled(); + expect(keyboardEvent?.defaultPrevented).toBe(false); + }); + + it('works while the preview is focused without being hovered', () => { + const { actions } = dispatchShortcut({ + context: { isFocused: true, isHovered: false }, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).toHaveBeenCalledTimes(1); + }); + + it.each(['input', 'button', 'a'])('ignores interactive %s targets', (tag) => { + const target = document.createElement(tag); + if (target instanceof HTMLAnchorElement) target.href = '#preview'; + const { actions, keyboardEvent } = dispatchShortcut({ + target, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).not.toHaveBeenCalled(); + expect(keyboardEvent?.defaultPrevented).toBe(false); + }); + + it('does not override an event already handled by another control', () => { + const target = document.createElement('div'); + target.addEventListener('keydown', (event) => event.preventDefault(), { + once: true, + }); + const { actions } = dispatchShortcut({ + target, + event: { key: '=', ctrlKey: true, cancelable: true }, + }); + + expect(actions.zoomIn).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts index d9130a640..4418da177 100644 --- a/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts +++ b/apps/ai-game-creator-shell/tests/start-dev-stack.test.ts @@ -10,12 +10,14 @@ import { isBackendReady, isProcessGroupAlive, preflightExistingVite, + readBackendServiceFailure, readLinuxProcessGroupAlive, resolveBackendTargetsFromState, runWindowsTaskkill, spawnChild, stopChild, terminateChildTree, + waitForBackendReady, waitForChildTermination, } from '../scripts/start-dev-stack.mjs'; @@ -26,6 +28,7 @@ function backendState(spacetimeDataDir?: string, includeBgfilterWorker = true) { return { schemaVersion: spacetimeDataDir ? 2 : 1, database: expectedDatabase, + updatedAt: '', ...(spacetimeDataDir ? { spacetimeDataDir } : {}), services: { 'api-server': { @@ -127,6 +130,47 @@ describe('AI 游戏创作配套后端复用门禁', () => { }), ).resolves.toBe(true); }); + + test('后端服务失败时返回具体失败服务,避免外层无限等待', () => { + const state = backendState(expectedDataDir); + state.services['bgfilter-worker'].status = 'failed'; + state.services['bgfilter-worker'].exitCode = 1; + state.services['bgfilter-worker'].signal = null; + + expect(readBackendServiceFailure(state)).toEqual({ + serviceName: 'bgfilter-worker', + failure: 'code=1', + }); + }); + + test('不匹配的旧状态失败记录不会阻断当前后端启动', () => { + const state = backendState(resolve('server-rs/.spacetimedb/other/data')); + state.services['bgfilter-worker'].status = 'failed'; + state.services['bgfilter-worker'].exitCode = 1; + + expect(readBackendServiceFailure(state)).toBeNull(); + }); + + test('等待后端时立即传播状态文件中的服务失败', async () => { + const initialState = backendState(expectedDataDir); + initialState.updatedAt = '2026-09-04T08:00:00.000Z'; + const state = backendState(expectedDataDir); + state.updatedAt = '2026-09-04T08:00:01.000Z'; + state.services['bgfilter-worker'].status = 'failed'; + state.services['bgfilter-worker'].exitCode = 98; + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + }); + let readCount = 0; + + await expect( + waitForBackendReady(child, 100, { + checkBackendReady: async () => false, + readState: () => (readCount++ === 0 ? initialState : state), + }), + ).rejects.toThrow('配套后端启动失败: bgfilter-worker code=98'); + }); }); describe('AI 游戏创作启动子进程生命周期', () => { diff --git a/apps/ai-game-creator-shell/tests/uiEditorKeyboardShortcuts.test.ts b/apps/ai-game-creator-shell/tests/uiEditorKeyboardShortcuts.test.ts new file mode 100644 index 000000000..f21c284db --- /dev/null +++ b/apps/ai-game-creator-shell/tests/uiEditorKeyboardShortcuts.test.ts @@ -0,0 +1,109 @@ +// @vitest-environment jsdom + +import { fireEvent } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { handleUiEditorKeyDown } from '../src/view/ui-editor/uiEditorKeyboardShortcuts'; + +describe('ui editor keyboard shortcuts', () => { + afterEach(() => { + document.body.replaceChildren(); + }); + + it.each([ + { key: 'z', ctrlKey: true }, + { key: 'z', ctrlKey: true, shiftKey: true }, + { key: 'y', ctrlKey: true }, + ])('does not change history from inside a modal (%o)', (shortcut) => { + const dialog = document.createElement('div'); + dialog.setAttribute('role', 'dialog'); + const button = document.createElement('button'); + dialog.append(button); + document.body.append(dialog); + const historyUndo = vi.fn(() => true); + const historyRedo = vi.fn(() => true); + const listener = (event: KeyboardEvent) => + handleUiEditorKeyDown(event, { + selectedNodeId: null, + activeImageId: null, + deleteNode: vi.fn(), + historyUndo, + historyRedo, + }); + window.addEventListener('keydown', listener); + + fireEvent.keyDown(button, shortcut); + + expect(historyUndo).not.toHaveBeenCalled(); + expect(historyRedo).not.toHaveBeenCalled(); + window.removeEventListener('keydown', listener); + }); + + it('keeps undo available from a non-modal button', () => { + const button = document.createElement('button'); + document.body.append(button); + const historyUndo = vi.fn(() => true); + const historyRedo = vi.fn(() => true); + const listener = (event: KeyboardEvent) => + handleUiEditorKeyDown(event, { + selectedNodeId: null, + activeImageId: null, + deleteNode: vi.fn(), + historyUndo, + historyRedo, + }); + window.addEventListener('keydown', listener); + + fireEvent.keyDown(button, { key: 'z', ctrlKey: true }); + + expect(historyUndo).toHaveBeenCalledTimes(1); + window.removeEventListener('keydown', listener); + }); + + it.each(['Delete', 'Backspace'])( + 'does not delete a node when the zoom slider has focus (%s)', + (key) => { + const slider = document.createElement('input'); + slider.type = 'range'; + document.body.append(slider); + const deleteNode = vi.fn(() => ({ ok: true })); + const listener = (event: KeyboardEvent) => + handleUiEditorKeyDown(event, { + selectedNodeId: 'node-1', + activeImageId: 'page-1', + deleteNode, + historyUndo: vi.fn(() => true), + historyRedo: vi.fn(() => true), + }); + window.addEventListener('keydown', listener); + + fireEvent.keyDown(slider, { key }); + + expect(deleteNode).not.toHaveBeenCalled(); + window.removeEventListener('keydown', listener); + }, + ); + + it('keeps node deletion available after zoom button focus is released', () => { + const zoomControls = document.createElement('div'); + const zoomIn = document.createElement('button'); + zoomControls.append(zoomIn); + document.body.append(zoomControls); + const deleteNode = vi.fn(() => ({ ok: true })); + const listener = (event: KeyboardEvent) => + handleUiEditorKeyDown(event, { + selectedNodeId: 'node-1', + activeImageId: 'page-1', + deleteNode, + historyUndo: vi.fn(() => true), + historyRedo: vi.fn(() => true), + }); + window.addEventListener('keydown', listener); + + fireEvent.click(zoomIn); + fireEvent.keyDown(window, { key: 'Delete' }); + + expect(deleteNode).toHaveBeenCalledWith('node-1', 'page-1'); + window.removeEventListener('keydown', listener); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index 6c21bc416..2c4206167 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -473,6 +473,75 @@ describe('UiEditorPage', () => { expect(result.current.canvas.hiddenNodeIds.size).toBe(0); }); + it('clears selection when deleting a node removes the selected descendant', async () => { + const { result } = await renderLoadedSession(stateWithPages(['page'])); + const rootId = 'page-root'; + let parentId: string | undefined; + let childId: string | undefined; + act(() => { + parentId = result.current.input.insertNode(rootId, 'page')?.value; + childId = result.current.input.insertNode(parentId!, 'page')?.value; + result.current.input.selectNode(childId!); + }); + + act(() => result.current.input.deleteNode(parentId!, 'page')); + + expect(result.current.canvas.selectedNodeId).toBeNull(); + expect(result.current.history.canUndo).toBe(true); + }); + + it('deletes the selected node from the page with Delete', async () => { + const state = stateWithPages(['page']); + state.ui_trees[0]!.root.children = [node('page-child')]; + const stateStore: IUiDesignStateStore = { + load: vi.fn().mockResolvedValue({ revision: 0, state }), + save: vi.fn(), + generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), + }; + + render( + createElement(UiEditorPage, { + projectPath: '/tmp/ui-editor-delete-keyboard', + resourceId: 'ui-resource', + stateStore, + }), + ); + + const child = await screen.findByText('page-child'); + fireEvent.click(child); + fireEvent.keyDown(window, { key: 'Delete' }); + + await waitFor(() => expect(screen.queryByText('page-child')).toBeNull()); + }); + + it('does not delete a selected node when Delete originates inside a dialog', async () => { + const state = stateWithPages(['page']); + state.ui_trees[0]!.root.children = [node('page-child')]; + const stateStore: IUiDesignStateStore = { + load: vi.fn().mockResolvedValue({ revision: 0, state }), + save: vi.fn(), + generateCode: vi.fn().mockRejectedValue(new Error('测试未配置代码生成')), + }; + + render( + createElement(UiEditorPage, { + projectPath: '/tmp/ui-editor-delete-dialog', + resourceId: 'ui-resource', + stateStore, + }), + ); + + const child = await screen.findByText('page-child'); + fireEvent.click(child); + const dialog = document.createElement('div'); + dialog.setAttribute('role', 'dialog'); + document.body.appendChild(dialog); + fireEvent.keyDown(dialog, { key: 'Delete' }); + + expect(screen.queryAllByText('page-child').length).toBeGreaterThan(0); + dialog.remove(); + }); + it('keeps Inspector status highlighting separate from node navigation', async () => { const { result } = await renderLoadedSession(stateWithPages(['page'])); const rootId = 'page-root'; diff --git a/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts b/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts new file mode 100644 index 000000000..196077400 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/uiTreeUtils.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { + countUiNodeDescendants, + findUiNode, +} from '../src/features/ui-editor/treeUtils'; +import type { Node } from '../src/features/ui-editor/types/Node'; + +function node(id: string, children: Node[] = []): Node { + return { + id, + layout: {} as Node['layout'], + metadata: {} as Node['metadata'], + components: [], + children_display_mode: 'Stack', + children, + }; +} + +describe('ui tree utilities', () => { + it('finds a nested node and counts all descendants', () => { + const nested = node('nested', [node('leaf')]); + const root = node('root', [node('page'), nested]); + + expect(findUiNode(root, 'nested')).toBe(nested); + expect(findUiNode(root, 'missing')).toBeNull(); + expect(countUiNodeDescendants(nested)).toBe(1); + expect(countUiNodeDescendants(root)).toBe(3); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts b/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts new file mode 100644 index 000000000..01016c4ae --- /dev/null +++ b/apps/ai-game-creator-shell/tests/workflowCompletionNotice.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { + appendWorkflowCheckPrompt, + workflowStepLabel, +} from '../src/view/ui-editor/components/workflowCompletionNotice'; + +describe('workflow completion notice helpers', () => { + it('appends the review prompt to a terminal status', () => { + expect(appendWorkflowCheckPrompt('已应用 3 条建议。')).toBe( + '已应用 3 条建议,请检查。', + ); + }); + + it('maps every workflow step to a user-facing label', () => { + expect(workflowStepLabel('reference-analysis')).toBe('分析参考图'); + expect(workflowStepLabel('structure-recognition')).toBe('识别界面结构'); + expect(workflowStepLabel('visual-binding')).toBe('绑定视觉素材'); + }); +}); diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 78e074daf..507747e57 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3372,8 +3372,14 @@ }, "sliceLayout": { "type": "string", - "enum": ["grid-2x2"], - "description": "可选固定图集切片合同。省略时沿用全图 alpha 连通域自动拆分;传 grid-2x2 时服务端要求生成四个固定象限,并按左上、右上、左下、右下各持久化一个独立切片。该模式适用于需要恰好四类核心运行时素材的游戏,不会猜测等分裁切。" + "deprecated": true, + "description": "历史兼容字段,新的调用请使用 sliceCount。" + }, + "sliceCount": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "可选的目标切片数量;省略时按图像内容自动识别。" }, "screenColor": { "type": ["string", "null"], @@ -3597,15 +3603,21 @@ }, "iconImageSrcs": { "type": "array", - "description": "默认模式识别图集中全部有效 alpha 连通域并持久化的独立素材,按视觉阅读顺序命名为“素材 N”;数量由图集内容决定,不由 iconDescriptions 数量决定。sliceLayout=grid-2x2 时固定返回左上、右上、左下、右下四个格子的切片,各格内的零散视觉细节不会被拆成额外素材。", + "description": "识别图集中有效 alpha 连通域并持久化的独立素材,按视觉阅读顺序命名为“素材 N”;可通过 sliceCount 指定目标数量。", "items": { "$ref": "#/components/schemas/EditorIconSpritesheetIconResult" } }, "sliceLayout": { "type": "string", - "enum": ["grid-2x2"], - "description": "仅当请求使用固定切片合同且主图完成透明化、切片持久化后返回。调用方可将该字段与 iconImageSrcs=4 共同作为固定四类素材的来源证明。" + "deprecated": true, + "description": "历史兼容字段。" + }, + "sliceCount": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "实际生成的切片数量。" }, "sliceWarning": { "anyOf": [ diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 13bb6403e..14980e6d7 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -185,7 +185,7 @@ completed -> starting(nextSlice) idle -> focused(document|art|audio|version) -> idle ``` -- 文档:合法 Agent 文本回执直接使用对话投影内容;项目文件只允许读取当前 manifest 已登记资产或已完成任务产物中的 Markdown、文本、JSON、YAML、TOML,必须经过 `file.read` auto 权限、相对路径、项目边界、普通文件、符号链接 / 硬链接、读取漂移、2 MiB、UTF-8 与扩展名白名单校验。正文使用不执行 HTML、不加载远程图片、不产生可点击外链的安全 Markdown 渲染,并在中央画布内独立滚动;读取失败显示错误空态。 +- 文档:合法 Agent 文本回执直接使用对话投影内容;项目文件只允许读取当前 manifest 已登记资产或已完成任务产物中的 Markdown、文本、JSON、YAML、TOML,必须经过 `file.read` auto 权限、相对路径、项目边界、普通文件、符号链接 / 硬链接、读取漂移、2 MiB、UTF-8 与扩展名白名单校验。正文使用不执行 HTML、不加载远程图片、不产生可点击外链的安全 Markdown 渲染,并在中央画布内独立滚动;读取失败显示错误空态。聊天侧 `/read` 回执中的文件正文必须作为代码块渲染为 `
    `,以便用户审阅源码字面量但不执行其中的 HTML;Markdown 渲染使用 `react-markdown` 的 `skipHtml`,依赖库对代码 span / fenced code 的文本转义;不得在整段 Markdown 上预转义 HTML,否则会把代码中的 `` 双重转义为字面量 `<tag>`。
     - 美术:PNG、JPEG、WEBP、GIF、SVG、AVIF、BMP、MP4、WebM、MOV 只在资源卡本体中按既有受控读取、文件签名与解码门禁展示;中央详情不重复加载或放大图片 / 视频本体。SVG 继续拒绝脚本、事件处理器、外部资源引用和实体声明。
     - 音频:只读取 manifest 已登记音频或已成功导入且登记到 manifest 的附件,按文件签名接受 MP3、WAV、OGG / Opus、M4A、AAC、FLAC;聚焦态展示实际格式、浏览器解码后的时长以及带播放进度和暂停能力的内置播放器。音频任务声明中的未登记路径继续不得读取或播放。
     - 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源替换仍留给后续切片。
    diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
    index 6aac99b3d..00092043a 100644
    --- a/docs/project-memory/shared-memory/decision-log.md
    +++ b/docs/project-memory/shared-memory/decision-log.md
    @@ -1507,6 +1507,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
     - 背景:主站与图片画板的泥点余额入口、余额明细和充值弹窗存在不同实现,旧充值口径仍展示六档泥点、首充双倍和会员购买 / 升级入口,容易让展示、商品资格与后端余额真相发生漂移。
     - 决策:主站与图片画板统一复用公共泥点资产入口,收起态展示总额与充值,展开态只展示不限时泥点、每日免费泥点和使用详情;充值中心 BFF 继续统一下发总额、三桶余额、限时到期时间、每日免费基础重置额及下次重置时间,前端不得自行相减推算,但会员周期限时泥点仅用于存量兼容和后端结算,当前版本不在前台展示。钱包明细每次展开都重新读取充值中心 BFF,打开期间实时总额变化时继续补读;图片画板的生成扣费或退款完成后同时刷新总额与充值中心拆分。充值中心读请求必须使用 revision 门禁,支付创建、到账确认等权威响应写入时使旧读失效,避免旧响应覆盖新的每日免费 / 不限时明细。默认泥点商品收敛为 `60 / ¥6`、`180 + 90 / ¥18`、`300 + 150 / ¥30`、`680 + 340 / ¥68` 四档,`60` 档无赠送,后三档按现有 `user_id + product_id` 独立资格规则首次购买加赠 `50%`。当前版本关闭会员购买页签、会员商品和购买 / 升级入口。
     - 2026-07-17 追加:主站、图片画板与 AI 游戏创作独立 App 的泥点账单统一复用 `packages/shared/src/components/PlatformProfileWalletLedgerModal`。共享组件只依赖 `ProfileWalletLedgerResponse`,承接来源 label、金额正负号、UTC 日期、余额兜底和 loading / empty / error 展示;`/api/profile/wallet-ledger` 请求、鉴权、打开状态与重试生命周期继续由各宿主持有,不把账户事实或后端副作用下沉到共享 UI。
    +- 2026-09-07 追加:资产扣费在既有钱包流水 metadata 中记录服务端确定的 `assetKind`,`GET /api/profile/wallet-ledger` 只把白名单类型映射为可选用户文案 `reason`,不暴露原始 metadata、资源 ID、任务 ID 或未知内部枚举。共享账单组件优先展示非空 `reason`;历史、未知和空 metadata 继续按 `sourceType` 回退为“资产操作消耗”,不得由客户端猜测业务类型。
     - 影响范围:`profile_recharge_product_config` 默认商品、充值中心 read model、共享前后端契约、主站与图片画板泥点资产入口、充值弹窗、后台充值商品默认值。
     - 验证方式:充值与统一入口定向前端测试、`npm run typecheck`、充值商品定向 Rust 测试、`cargo check -p spacetime-module -p spacetime-client -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。
     - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
    diff --git a/docs/project-memory/shared-memory/project-overview.md b/docs/project-memory/shared-memory/project-overview.md
    index 765f3a318..ca060acb0 100644
    --- a/docs/project-memory/shared-memory/project-overview.md
    +++ b/docs/project-memory/shared-memory/project-overview.md
    @@ -51,9 +51,11 @@ SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.8.3` 对齐
     
     ## AGC DirectProject 与 UI workflow
     
    +- 新 Web 游戏为 `game/` 下的 npm + Vite + Phaser 4.2.1 工程,使用包导入且允许其它依赖;npm 预览与导出只读取 dist,运行素材需纳入构建,已有单 HTML/Godot 不自动迁移。
    +
     - 通用 Agent Rust 分层为 `agent-runtime-core`(catalog、执行生命周期、ToolHost/spawn/all-join/Provider 契约)、`agent-runtime-orchestration`(动态无环任务图、ready、依赖波次、返工下游闭包和受限自主扩图提案)与 `platform-agent` 游戏适配器;循环返工通过新 pass / epoch 表达,不在单张依赖图中建立回边。LLM 可经宿主结构化 function call 提出新增节点/边,编排层只生成经校验的新候选图,epoch 与持久化仍由宿主掌控。
     - DirectProject 始终连接客户端内置的 `agc_tools` STDIO MCP,并在启动时额外读取客户端扩展仓库中已启用的第三方 MCP 独立项。第三方 STDIO/HTTP 配置只写入本次隔离 `CODEX_HOME`,单项非 required,启停、重命名和内容指纹进入 app-server pool identity;完整 Plugin Runtime、hooks/apps 和单文件脚本手动指定入口仍关闭。Skill 正文与 references 由 Codex 原生按需读取;`agc_tools` 负责标准美术准备、已登记资源有界查询、视频 / 角色动画 / 音效 / BGM 的 create-or-derive、已登记图片去背景、desktop/mobile 浏览器试玩和受控 `agc_web_search`;付费资源调用仍由客户端绑定回合、幂等账本、请求上限和投影权威。
    -- DirectProject 的 Codex 原生文件、搜索、命令、图片查看和 Skill 仅在真实 `game/` cwd 与 `workspaceWrite(writableRoots=[game])` 内可用;原生命令网络保持关闭。多 Agent、Apps、插件、hooks、图片生成、Goals、Workspace Dependencies、Tool Suggestion 和原生浏览器/电脑控制保持关闭。app-server 使用隔离 `CODEX_HOME`,provider 凭据只由 AGC 客户端代理持有,不能进入模型上下文或 shell 环境。
    +- DirectProject 的 Codex 原生文件、搜索、命令、图片查看和 Skill 仅在用户项目 cwd 与 `workspaceWrite(writableRoots=[project])` 内可用;原生命令允许联网以支持 npm 安装,npm 缓存位于项目内 `.npm-cache/`。多 Agent、Apps、插件、hooks、图片生成、Goals、Workspace Dependencies、Tool Suggestion 和原生浏览器/电脑控制保持关闭。app-server 使用隔离 `CODEX_HOME`,provider 凭据只由 AGC 客户端代理持有,不能进入模型上下文或 shell 环境。
     - `ui-prototype`(设计图片)与 UI 编辑器 `UI` JSON 是不同资源。白名单 `ui.workflow.run` 按页面执行 `prepare → recognize → status → finalize`,由 provider-backed 识别、合并和组件绑定持久化 State/revision,并把 `reference-ready → structure-ready → merge-ready → binding-ready → application-ready → completed` 投影到 manifest。Provider 缺失、请求失败、工具缺失、结果不匹配或仍有待审节点时保留真实阶段并返回 blocker,不得用 deterministic seed 伪造完成。
     - UI workflow 的资源桥接与 Runtime 边界以 `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md` 和 AGC 实施计划的 2026-08-24 覆盖段为准;只生成图片、登记空 JSON 或进入普通图片画布都不构成 workflow 完成。
     
    diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
    index 206e150c6..2f63f00e6 100644
    --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
    +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
    @@ -1,5 +1,13 @@
     # AI 游戏创作智能体 App 实施计划
     
    +## 2026-09-08 Web 游戏 npm 与 Phaser 4 产物合同
    +
    +新建 Web 游戏使用 npm 工程:默认 `game/package.json` 声明 Phaser 4.2.1 和 Vite 构建工具,源码使用 `import Phaser from 'phaser'`,`package-lock.json` 由 npm 维护。默认脚手架文件位于 `game/`,包含 `index.html`、`game.js`、`style.css`、`vite.config.js` 和 npm 配置/锁文件;已有根目录 npm 工程沿用原根,可按需求拆分模块并添加任意其它 npm 依赖,不设置包名白名单。客户端不手工分发 Phaser bundle,不用 import map 模拟 package 导入。
    +
    +Agent 在包含 `package.json` 的目录执行 `npm ci`(依赖变更使用 `npm install`)和 `npm run build`;默认可从工作区根执行 `npm --prefix game ci` 与 `npm --prefix game run build`。DirectProject 保持 workspace-write 目录边界并开放网络,以支持 npm 依赖解析和安装;凭据仍由客户端代理持有,不进入项目或原生命令环境。其它执行模式维持现有权限。新游戏完成后执行 `npm run build` 并用真实浏览器试玩;依赖安装与构建失败必须反馈真实错误,不能回退成未解析裸模块导入的静态页面。
    +
    +npm 游戏的可预览产物固定为对应 package 目录下的 `dist/index.html`,静态 smoke 校验构建入口及本地文件引用,实际可玩性由浏览器验证。预览服务与导出读取该构建目录,所有运行素材必须由构建纳入 dist;npm 预览不回退读取源码或项目素材目录,保证试玩与导出一致。源码投影包含 package、锁文件、配置和真实游戏源码,排除 `node_modules/`、`dist/`、控制文件与凭据;npm 试玩包将 dist 文件映射到 `game/` 并附带发布说明,不包含源码依赖安装目录。现有单 HTML 项目不自动迁移,Godot 项目保持原合同。旧 JSON Generator 仅接受已初始化的单 HTML 项目,npm 项目或新建请求在调用 LLM 前明确拒绝并引导使用 DirectProject,避免静态草案伪装为 npm 构建产物。本节覆盖下文仅适用于旧单 HTML 产物的 Canvas API、手写动画循环和禁止外部本地脚本要求。
    +
     ## 2026-09-02 项目名称显示与自动提炼
     
     - `.agent/manifest.json` 的 `name` 仍是本地项目显示名唯一事实源;项目组页行尾更多菜单提供行内重命名,保存必须走 Tauri 受控命令、项目写锁、manifest 写锁与既有 ACL/权限校验。重命名只更新 manifest,不改变项目目录、`projectId`、项目类型、任务、资源、版本或远端同步状态;保存成功后当前项目上下文、窗口标题和最近项目检查结果必须回读新 manifest 并保持一致。
    @@ -58,6 +66,10 @@ Runtime 确认卡与普通聊天确认卡必须共用“信息区 + 固定操作
     
     ## 2026-08-19 UI Editor 节点右键菜单
     
    +## 2026-09-05 UI Editor 节点树快捷删除
    +
    +左侧 `UI Tree` 每个可删除节点行在右侧提供桌面端快捷删除按钮,眼睛与删除按钮组成固定宽度的右侧动作列;整棵树保留横向滚动,深层节点的缩进、完整名称和组件数随树内容一起滚动,不使用省略号截断。沿用右键菜单的页面根节点禁删规则。快捷删除与右键删除共用页面级 `requestDelete` 入口:叶子节点直接调用现有删除命令;包含后代节点时先打开模态确认,正文显示节点名称及将同时删除的后代节点数量,按钮为“取消 / 删除”。确认期间使用现有 `ThemedModal` 的模态行为,取消或完成后关闭弹窗。底层 `deleteNode` 命令继续保持无确认,以兼容键盘 `Delete` 及已有状态测试;锁定态下快捷按钮和右键删除均不可执行。
    +
     ## 2026-08-20 UI Editor 最终预览互斥子节点
     
     最终预览中,选中一个 `Exclusive` 父节点时,它的子节点切换条必须在该父节点自身的预览坐标空间内、紧贴节点上方悬浮;不得固定在预览容器左上角,也不得另行按屏幕坐标换算。点击 tab 必须显式选中对应子节点,不能按通用“切换可见性”语义把当前分支隐藏。`Exclusive` 父节点首次加载且尚未发生可见性操作时,必须默认且仅显示第一个直接子节点;用户手动隐藏全部直接子节点后必须保持全部隐藏,不得再次回退显示第一个子节点;空容器不显示子节点。切换条始终按内容宽度展开并允许溢出节点边界,不设最大宽度或内部滚动区域。从 `Exclusive` 切回 `Stack` 时必须清除全部直接子节点因互斥选择产生的隐藏状态并立即显示所有子节点,后代节点自身的独立隐藏状态保持不变。切换条仅改变现有子节点可见性状态,不能触发参考图重读、视口重新适配或预览树的异步重建。
    @@ -234,6 +246,13 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创
     - 旧配置迁移:既有 AppData 若没有 `agentMode`,只有全局和逐 Agent 路由均为 `openai_responses` 时迁移到 `codex_app_server`;存在 `openai_chat / anthropic` 时显式保留 `provider`,避免打开项目自动恢复时把所有节点批量写成 `invalid-config`。用户确认端点支持 Responses 后,可在设置中显式切换并保留原 model/base URL/API Key。
     - 验收:fake JSON-RPC fixture、三态 UI/config、配置指纹、unknown-terminal 零重放、旧两种模式回归和显式 ignored 真实 smoke 全部通过后,才可视为模式切换完成。
     
    +### 2026-09-03 AGC 客户端能力以 MCP 暴露
    +
    +- MCP 暴露是客户端能力层,不替换 `codex_app_server / codex_cli / provider` 或客户端对话。客户端对话入口继续驱动 Codex app-server;app-server 通过客户端随附的 stdio MCP 子进程调用审核后的客户端能力。客户端不再替 Codex 做业务语义门禁、意图判断和完成判定。
    +- MCP 会话在客户端握手时绑定当前账号、项目和实例,工具参数不得携带 `projectPath`、Token、Cookie、objectKey 或内部 URL。仅暴露稳定业务白名单与 `resources/list/read`,所有文件、资源、画布、预览和 operation 副作用继续复用客户端权限、锁、计费、幂等账本、manifest/revision 与恢复机制。
    +- 客户端对话继续写入现有 conversation projection;外部 Host 如需旁路保存返回文本,可显式调用 `conversation.record_codex_response`。客户端将有界、脱敏正文、SHA-256、安全摘要和状态追加到项目级 journal,UI 只展示记录,不从文本推断业务状态或触发副作用。
    +- MCP 子进程只由当前客户端为绑定项目启动,并在该项目工作目录内运行;客户端回合结束或客户端退出后子进程随 Codex app-server 一并回收。账号和项目权限仍由客户端业务桥接层校验,未知副作用保持 `needs-reconciliation`,只能通过 operation 查询恢复。稳定验收覆盖客户端对话驱动的 MCP 工具调用、Skill 指导资源、跨项目/账号拒绝以及旧 Provider/Codex 回归。
    +
     ### 2026-08-10 Supervisor 边做边聊与条件中断
     
     - 根 Project Supervisor 的运行中消息继续进入当前 `taskId / sessionId / runId`,先持久显示“正在判断、当前任务继续”,再由独立 LLM 生成非终态语义回复并给出 `interruptCurrentProvider`。过程回复不能调用终态 `respond_to_user`,不能把制作 Run、Goal 或 task 提前完成。
    @@ -870,7 +889,7 @@ game-project/
     - 聊天输入 `/status` 会读取 `.agent/manifest.json` 并在聊天里汇总项目目录、任务状态、资产数量、预览状态和最近命令,不向普通用户暴露任务或文件面板。
     - 聊天输入 `/files` 会通过 `file.list` 只读列出本地项目内的文件摘要;主窗口最近项目文件可一键读取,也可一键填入 `/read` 或 `/asset-register` 草稿,但资产登记仍必须走聊天确认;`/checkpoints` 复用 `file.list` / `file.read` 只读列出最近 checkpoint id、文件数、大小和可复制的 `/diff` / `/restore` 命令,主窗口最近 checkpoint 列表也可填入对应草稿;普通用户仍不暴露文件读写面板。
     - 聊天输入 `/assets` 会读取 `.agent/manifest.json` 并在聊天里列出本地项目资产路径、类型和来源,资产列表消息和主窗口最近项目资产入口都可一键填入对应资产的 `/read` 草稿;聊天输入 `/art` 只使用当前已加载 manifest 盘点美术素材,并提供首版美术生成或读取美术清单草稿,不直接读取文件、不触发平台生成或画板同步;聊天输入 `/audio` 只使用当前已加载 manifest 盘点音频素材,并提供登记音效或读取音频清单草稿,不直接读取文件、不触发资产写入;`/asset-register 路径 [kind] [mediaType]` 可确认后登记项目内已有资产;主窗口音效快捷入口只填入 `/asset-register assets/audio/sfx.wav audio audio/wav` 草稿,不直接写 manifest;普通用户仍不暴露资产面板。
    -- 聊天输入 `/read 本地相对路径` 会通过 `file.read` 只读返回项目内文本文件内容并在聊天中截断长文本;普通用户仍不暴露文件写入或删除能力。
    +- 聊天输入 `/read 本地相对路径` 会通过 `file.read` 只读返回项目内文本文件内容并在聊天中截断长文本;聊天回执中的正文必须包装为安全的 `
    ` 代码块,保留源码字面量但不得执行 HTML;普通用户仍不暴露文件写入或删除能力。
     - 主窗口常用生成产物入口只把 `game/index.html`、`game/game_design.md`、`game/balance.json`、`assets/manifest.art.json`、`assets/manifest.audio.json` 和 `exports/README.md` 的 `/read` 草稿填入聊天输入框;聊天输入 `/artifacts` 只列出这组固定读取命令并提供首个 `/read` 草稿,聊天输入 `/run-artifacts` 只列出最近 run trace 里的产物读取命令并提供首个 `/read` 草稿,聊天输入 `/run-files` 只列出 `.agent/output.jsonl`、`.agent/activity.jsonl` 和 `.agent/context.bundle.json` 的读取命令并提供首个 `/read` 草稿;读取仍由聊天侧 `file.read` 权限流执行。
     - 聊天输入 `/logs` 只列出 `.agent/logs/command.log`、`.agent/logs/preview.log` 和 `.agent/logs/agent.log` 对应的 `/read ...` 草稿 / 命令,并提供首个 `/read` 草稿;该命令不直接读取日志,不新增普通用户日志面板,实际读取仍由聊天侧 `file.read` 权限流执行。
     - 聊天输入 `/tasks` 会读取 `.agent/manifest.json` 并在聊天里列出专业组、角色、任务状态、产物交接和下一步可执行任务;普通用户仍不暴露任务面板。
    @@ -1063,7 +1082,7 @@ game-project/
     - 内部 owner 验证只接受 GUI / CLI 完整 16 任务 DAG 中 `agent-ready-task-scheduler` 启动的确定性直接 child、当前活跃根和完整 project/source/profile/Agent/run/parent/root/binding 身份。错误 source、delegated run、历史或终态根、非当前活跃根、跨 Agent/run 凭证均失败关闭;再次 mutation 使旧凭证失效,相同身份恢复可按当前事实确定性重验。本阶段不扩到后置 `publish-package`。`code-prototype` 与 `preview-readiness` 继续执行真实 `game.static_smoke`,`preview-playtest` 继续独立执行浏览器验收;任何 owner 文件凭证都不能替代可玩证据。
     - `design-foundation` 的 2026-07-26 职责隔离继续有效:项目文件仍只允许 `memory/project.md`、`game/game_design.md` 和配置 Key 时的固定 `assets/ui-prototype.png`,禁止修改 `game/index.html`、调用 smoke / preview / process 或恢复整项目。未配置 External Editor API Key 时 `art-director` 保持只读协调;配置 Key 时它是条件 Canvas owner,必须生成并登记 `assets/art-spec.png`,成功 `canvas.asset_generate` 为本人当前 revision 形成普通验证凭证,不能被只读分类吞掉。配置 Key 时 UI 原型、透明图集、Canvas 登记和视觉门仍按既有合同执行,内部 owner 文件验证不替代图片证据。
     - `canvas.asset_generate.replaceExisting` 默认并必须保持 `false`;只有静态专业 Agent 的 `delegated-*` 唯一 repair run 才能申请 `true`。Runtime 要求当前 delivery 带 `repairOfDelegationId`,原 delivery 已被同一父 Agent / 父 run 认领,原始与返工合同的目标 Agent 和精确 `expectedArtifacts` 路径一致;普通 run、未声明路径、错误 Agent、未认领原交付或缺失原图都失败关闭。图片生成仍服从 `art-director` / `design-foundation` / `art-asset-plan` 的固定输出路径、比例、尺寸、kind 和 label,禁止先删除正式图片;请求前记录旧文件 SHA-256,外部生成返回后在项目写锁内复核,旧图在网络请求期间变化即拒绝覆盖。授权替换先写私有临时文件,再以备份 / rename 切换;落盘或 manifest 登记失败时恢复旧图,不把新旧文件并存状态当作成功。
    -- 在既有 16-task manifest 内固定正式视觉 DAG,不新增平行任务系统:`art-director` 用当前调用模式的图片生成 `kind=spec` 生成 `assets/art-spec.png` 并登记为 `assetKind=icon-spec`;`design-foundation` 使用该规范图的稳定资源 ID 作为视觉规范参考,用同模式图片生成 `kind=ui-design` 生成 `assets/ui-prototype.png`;`art-asset-plan` 以同一 resource ID 调用同模式图标 spritesheet 生成,产出透明 `assets/art-spritesheet.png`。普通模式使用内部 `/api/editor/*`,standalone/高级模式使用对应 `/api/external/v1/*`;业务请求、依赖和验收完全一致。规范图缺失、未登记或缺少稳定资源 ID 时,下游任务不得退回普通生图。图集 warning、透明像素与切片门禁保持不变。
    +- 视觉 Agent 只负责指导 Codex 选择合适的图片/编辑/图集工具并提供项目上下文,不再固定图片数量、文件槽位、素材类别或 spritesheet 布局;请求可按玩法需要生成单图、多图或任意切片布局。普通模式使用内部 `/api/editor/*`,standalone/高级模式使用对应 `/api/external/v1/*`;权限、计费、幂等、资源登记和安全校验保持不变。
     - 旧项目已有同路径派生图但缺少上述 provenance 时,一律标记为 legacy,不得只因文件、kind 或通用视觉检查存在就完成。原位替换仍走显式 repair:`design-foundation` 与 `art-asset-plan` 先在同一 Supervisor 批次分别建立 owner 精确原合同并交付 `needs-repair`,父 run 认领后再在同一批次分别发起各自唯一 repair;两个 repair 合称一个显式视觉返工阶段。`art-director` 不得跨 owner 声明或替换 UI / spritesheet,Runtime 在委派落盘前就拒绝这类合同,不再等到生图阶段才失败。
     - 2026-07-27 新起的“16 任务正式产物 + 两张真实画布图片 + current revision 静态 / 双视口浏览器 / PNG 证据 + 受限 repair 替换”独立外部 Provider 验收,使用 `npm run agc:test:chat -- --timeout-minutes 75`,约 `59m50s` 后以退出码 `0` 完整 **PASS**。同一轮真实生成并登记 `assets/ui-prototype.png`(`2829418` bytes)与 `assets/art-spritesheet.png`(`1361906` bytes),固定 `16` 个 manifest task 均为当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物、两张 PNG、当前 revision 的 `game.static_smoke`、desktop / mobile `lane-defense-v1` playtest、浏览器报告与截图全部通过。`turn.report=settled` 且唯一 assistant,busy / pending / running / confirmation / user-input / reconciliation 均为 `0`;隔离 Runner、一次性项目和隔离 AppData 已自动清理。此前失败轮继续独立保留,不与本轮拼接;未来合同变化仍须新起完整轮次复验。
     - 2026-07-27 补充 tool-plan 成功响应交接的内容边界:Provider 的自然语言计划叙述,以及结构化 arguments 中 `body / code / content / css / html / newText / oldText / patch / script / text` 等源码内容字段,只检查真实密钥 token 形状、凭据头标记和不安全控制字符;仅仅提及 `.env` 或 `game-creator.config` 不能阻断已经计费的安全响应。结构化输入中的敏感 JSON key、非内容字段中的配置痕迹或绝对路径、真实 token、容量、thinking、身份、顺序和账本完整性门禁仍失败关闭。成功 handoff 失败进入 reconciliation 时,Runtime 额外只持久化受控 `failureKind`、脱敏错误 SHA-256 和字符数,不保存 Provider 正文、function arguments、密钥或绝对路径。定向回归覆盖叙述/源码字段放行、`.env.local` 路径和真实 token 拒绝、全部 tool-plan handoff 回归及诊断零正文。
    @@ -1275,6 +1294,15 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
     - 该档位不把最终验收条件提前成启动条件,也不把平台画布、preview、static smoke、发布包或其它平台产物检查作为 child 或根 Supervisor 的完成门。缺少平台产物不会把已完成任务重置为 `Pending`;根 run 只等待任务图进入终态并交回结果。
     - 代码可先按约定的项目路径落地并完成自己的工作;后续任务状态变化只负责唤醒同一根 run 继续收束,不因 `art-polish`、`art-asset-plan` 等非代码任务失败而阻塞代码启动。平台产物和可玩性检查若需要,属于后续独立验收,不是本档位的运行前置条件。
     
    +## 2026-09-04 AGC 项目聊天 Markdown 与流式回复渲染
    +
    +- 项目开发工作台的 `ProjectWorkspaceChatPane`、`ProjectSupervisorView` 与 `SupervisorChatOnlyView` 继续共享 `ChatMessage` / `visibleMessages` 数据结构;聊天 Markdown 只作为前端表现层能力,不新增消息字段、持久化格式、后端 DTO 或事件协议。
    +- 三个视图统一复用 `apps/ai-game-creator-shell/src/components/ChatMarkdownMessage`。assistant 历史消息和流式临时回复使用 `react-markdown + remark-gfm` 渲染;用户消息与命令草稿保持纯文本。调用方仍先执行现有的 `projectSupervisorVisibleConversationText` 安全文案归一化,再交给展示组件。
    +- 流式回复以事件中的 `accumulatedText` 作为当前完整草稿:每次更新替换上一版临时正文,不在渲染层自行拼接 `deltaText`。既有 `runId` / sequence 去重、最终 assistant 持久化和历史回放语义保持不变。
    +- Markdown 禁止原始 HTML;链接和图片只显示普通文本,不产生可点击或可加载的外部资源。后续若开放安全外链,必须另行评估协议白名单、窗口策略和审计边界,并保留实现 TODO。
    +- Markdown 元素样式使用 Tailwind 内联 class,限定在聊天消息组件内部,不改全局 `.message`、资源文档预览或启动器全局 Agent 聊天。组件异常按单条消息回退纯文本,不能使整个聊天面板崩溃。
    +- 流式更新沿用“仅在用户接近底部时跟随”的滚动语义;用户主动查看历史时不得被实时 Markdown 高度变化强制拉回底部。实现验收需覆盖 GFM、未闭合 Markdown 中间态、HTML/链接/图片安全、三视图一致性、流式去重、历史回放和桌面/移动视口。
    +
     ## 2026-08-29 DirectProject 受控联网搜索闭环
     
     - 本次正式产品范围只包含 `DirectProject` 单 Codex Agent;`Provider`、`ToolHost`、`DirectHome` 不新增联网工具桥,也不纳入本次联网路由覆盖。受控联网唯一实现为 `agc_tools.agc_web_search`:Codex app-server 通过审核的 STDIO MCP 工具目录发起调用,客户端 loopback 工具桥执行固定 Bing RSS HTTPS 请求,过滤非 HTTPS、凭据 URL、回环 / 私网 / 本地域名,返回有界标题、摘要和结果链接,并以“不可信网页内容”标签回传。
    diff --git a/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md b/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md
    index bc6aeeafe..cf52261c4 100644
    --- a/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md
    +++ b/docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md
    @@ -14,6 +14,10 @@
     
     ## 3. 已确定的产品边界
     
    +### 3.0 客户端能力 MCP 暴露边界(2026-09-03)
    +
    +客户端仍由现有对话入口启动并驱动 Codex;MCP 只是把客户端已审核的项目、文件、资源、画布、生成和预览能力暴露给该 Codex 或其它 Host。客户端只负责账号、项目路径、权限、计费、幂等、锁和恢复等自身安全,不替 Codex 做高层意图/完成门禁。审核 Skill 的索引和正文可作为只读 MCP resource 提供,第三方扩展不得获得客户端会话凭据、内部路径或 bridge token;该能力与公网 `/api/external/v1/mcp` 保持独立。
    +
     ### 3.1 客户端安装、运行时注入
     
     - 扩展内容保存在 AGC 客户端的扩展仓库。
    diff --git a/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md b/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md
    index 789c00173..d8a570436 100644
    --- a/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md
    +++ b/docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md
    @@ -89,6 +89,10 @@ Codex app-server 协议里,`commandExecution.commandActions` 已分类为 `Rea
     
     不升级 `GAME_CREATOR_AGENT_DB_SCHEMA_VERSION`;新 `recordType` 走 Ordinary 追加。`updatedAt` / `schemaVersion` 仍由 `serialize_agent_db_record` 写入。
     
    +### 2026-09-03 客户端对话与 MCP 能力边界
    +
    +客户端对话仍由现有 Codex app-server 链路完成;MCP 只暴露客户端自身业务能力和审核 Skill 指导。客户端安全门禁限于账号、项目路径、权限、计费、幂等、锁、revision 与恢复,不根据 Codex 自然语言替代 Codex 决定业务动作。外部 Host 返回如需旁路归档,可使用显式记录工具,但不替代现有 conversation projection,也不触发资源、状态或完成判定。
    +
     jsonl 每条自带 `recordedAtMs`(`unix_millis`)。同一 `clientTurnId` 若再次进入(当前 GUI 运行中互斥,结束后理论上可再来):只追加,不截断;后一次 `turn_start` 视为新 attempt。读摘要时按文件内最后一次 `turn_start` 到对应 `turn_end` 计算 `offeredRead`。`agent.db` 每次 `turn_end` 再追加一条摘要,分析取该 `clientTurnId` 最后一条。
     
     ## 5. 记录合同
    diff --git a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md
    index 828bee880..4b543bef8 100644
    --- a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md
    +++ b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md
    @@ -76,6 +76,7 @@
     ### 失败生成任务归档与任务侧栏
     
     - 用户界面的“删除失败任务”语义是归档,不物理销毁私有 generation ledger。只有平台明确失败的 `failed` 任务可归档;`reconciliation-required`、已受理、运行中和结果未知任务不得移出恢复队列。
    +- 已受理 operation 在重试时若请求快照发生变化,客户端可先对原 operation 执行一次只读状态查询;仅当平台明确返回 `failed` 时才自动收口旧账本并允许新提交,排队、运行中、完成或未知状态继续保持原幂等身份并阻断替代请求。
     - 归档命令校验 project、draft、generation 与 expected draft revision,先把私有 ledger 写入可重放的 `archiving/archivedAt`,再从 `draft.generations` 移除公开投影并推进一次 revision;草稿删除成功并回读后才发布 `archived`。`archiving` 以及历史上已写 `archived` 但仍残留公开记录的状态都必须在恢复阶段幂等收敛,且不依赖图片生成服务凭证。
     - 失败占位和右上角任务项复用同一个归档动作,成功后两处同时消失,其它任务、图层和候选不受影响。
     - 任务侧栏折叠只属于当前会话 UI 状态,不写入 draft 或 manifest。视觉和交互复用现役美术画布:右上角独立“任务列表”图标按钮、20rem 白色模糊卡、总数徽标、`排队/生成中` 与 `已完成` 双 Tab、状态圆形图标、阶段进度和时间信息;折叠后只保留图标按钮,不显示摘要卡。用户显式新建 generation 时自动展开并切回活动 Tab,普通进度更新不得推翻用户已有折叠选择。Game Agent 的失败归档作为任务行扩展保留。
    diff --git a/docs/technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md b/docs/technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md
    new file mode 100644
    index 000000000..5cdc52832
    --- /dev/null
    +++ b/docs/technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md
    @@ -0,0 +1,40 @@
    +# UI 编辑器工作流完成通知弹窗
    +
    +## 目标
    +
    +UI 编辑器的“分析参考图”“识别界面结构”“绑定视觉素材”三个工作流动作在每次运行结束后,用独立的阻塞通知弹窗明确反馈结果,避免仅依赖卡片内一行状态文本而被忽略。
    +
    +## 交互约定
    +
    +- 三个动作的每次运行在终态(成功或失败)时自动弹出一次通知。
    +- 弹窗打开期间遮挡并阻塞工作台底层交互;关闭后恢复当前步骤,不自动切换步骤、不自动重跑。
    +- 使用现有 `ThemedModal` 的普通关闭行为(遮罩、Esc 和关闭按钮均可关闭)。
    +- 弹窗仅承载通知,不提供“继续”“重试”或其他业务操作。
    +- 关闭弹窗后,工作流卡片继续显示同一条结果状态;重新运行产生新的终态时再次通知。
    +- 绑定动作包含多个批次时,只在最终批次结束后通知一次。
    +
    +## 文案
    +
    +弹窗标题由步骤名和结果态组成,例如“识别界面结构完成”或“绑定视觉素材失败”。正文复用卡片状态文本,并逐条扩展结果信息,统一以“请检查”收尾。
    +
    +成功状态的基线文案:
    +
    +- 分析参考图:保留已应用的语义建议数量;若现有状态可可靠取得问题/待确认数量,则一并展示。
    +- 识别界面结构:保留替换的界面树数量,并展示识别结果中的待检查/必须修复数量(若可取得)。
    +- 绑定视觉素材:保留现有 `B/B` 批次计数,改为用户可读的绑定结果。
    +
    +失败状态保留实际错误文本,仅在弹窗标题中补充步骤和失败上下文,正文同样以“请检查”收尾。
    +
    +## 实现边界
    +
    +- 新增独立的工作流通知弹窗组件文件,组件只负责展示和关闭,不包含工作流领域规则或后端副作用。
    +- 在 UI 编辑器页面/会话投影中维护临时通知状态,并在三个异步动作的成功与失败终态写入。
    +- 不新增后端字段或公开契约;数量只能使用当前前端已有且可靠的数据。
    +
    +## 验收
    +
    +1. 三个动作成功和失败终态各弹出一次通知;绑定批次只弹最终一次。
    +2. 弹窗打开时底层工作台不可操作,且无继续/重试等业务按钮。
    +3. 弹窗可通过标准关闭方式退出;关闭后卡片状态仍可见。
    +4. 每条成功文案保留原有数量信息并增加可用的检查数量,所有文案包含“请检查”。
    +5. 重新运行后新的终态会再次弹出通知。
    diff --git a/docs/【UI编辑器】撤销重做规范-2026-09-03.md b/docs/【UI编辑器】撤销重做规范-2026-09-03.md
    index f3912e000..86d7e3cd9 100644
    --- a/docs/【UI编辑器】撤销重做规范-2026-09-03.md
    +++ b/docs/【UI编辑器】撤销重做规范-2026-09-03.md
    @@ -48,13 +48,19 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被
     
     - 桌面端页面工具栏提供撤销和重做按钮。
     - 非文本编辑目标聚焦编辑器时支持 `Cmd/Ctrl+Z` 撤销、`Cmd/Ctrl+Shift+Z` 和 `Ctrl+Y` 重做。
    -- `input`、`textarea`、`select`、`contenteditable` 以及按钮/链接等控件交给浏览器原生行为,不拦截文本撤销。
    +- `input`、`textarea`、`select`、`contenteditable` 等文本编辑控件交给浏览器原生行为,不拦截文本撤销;按钮/链接等非文本控件仍允许编辑器撤销/重做快捷键生效。
    +- UI Editor 页面内选中节点后,支持不带修饰键的 `Delete` 删除节点及其子节点;快捷键复用 Inspector、树面板和右键菜单共用的 `deleteNode` 命令,因此沿用根节点/锁定禁删、单条历史记录、dirty 标记和后续保存语义。
    +- `Delete` / `Backspace` 在 `input`、`textarea`、`select`、`contenteditable`、按钮和链接等交互控件聚焦时交给浏览器原生行为;无选中、根节点、锁定或目标不存在时不执行删除。长按重复事件不重复删除,成功处理后阻止默认行为和事件冒泡。
    +- `Delete` / `Backspace` 在打开的对话框(`role="dialog"` 或 `aria-modal="true"`)内聚焦时交给对话框处理,不删除对话框背后的节点。
    +- `Cmd/Ctrl+Z`、`Cmd/Ctrl+Shift+Z` 和 `Ctrl+Y` 在打开的对话框(`role="dialog"` 或 `aria-modal="true"`)内聚焦时暂停编辑器级撤销/重做,由对话框或浏览器原生行为处理。
     - 无可撤销或重做记录时按钮禁用,并提供可访问名称。
     
     ## 脏状态与选择
     
     撤销和重做恢复的 State 继续参与现有 dirty 判定、保存和后端持久化。历史快照不包含当前设计图、节点选择、隐藏集合或视口;恢复后若当前选择已不存在,页面清理无效选择并保持安全空态。
     
    +删除节点后,若当前选中节点是被删除节点或其任意后代,页面将选区清空为 `null`;删除成功不强制抢占焦点。该选择清理属于 UI 临时状态,不写入撤销历史。
    +
     ## 验收标准
     
     1. 单次字段编辑可撤销和重做。
    @@ -67,3 +73,4 @@ UI 编辑器支持撤销最近一次或多次作品编辑,并支持重做被
     8. 工具栏按钮、禁用态和桌面快捷键可用,文本控件保留原生撤销。
     9. no-op、锁定、校验失败和不存在目标不进入历史。
     10. 颜色选择器和九宫格边界拖动不会按每个 pointer move 写入 State。
    +11. `Delete` / `Backspace` 仅在非交互控件聚焦且存在可删除选中节点时生效;删除整棵子树、可撤销、长按只处理一次,并清理被删除子树内的无效选区。
    diff --git a/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md b/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md
    new file mode 100644
    index 000000000..198d7788a
    --- /dev/null
    +++ b/docs/【交互设计】预览画布缩放滑杆-2026-09-05.md
    @@ -0,0 +1,26 @@
    +# 预览画布缩放滑杆
    +
    +## 交付目标
    +
    +将 UI 编辑器预览区现有的缩放按钮替换为桌面端可拖动滑杆,同时保留快速缩小、放大和适配画布操作。
    +
    +## 交互约定
    +
    +- 滑杆选择范围为 25%–200%,步进 5%;快捷加减继续使用核心视口的现有缩放边界。
    +- 滑杆位于预览区右下角固定工具栏;不提供移动端或窄窗口 fallback。
    +- 保留 `−` 与 `+` 按钮。点击轨道可跳转,拖动滑块实时更新画布和百分比。
    +- 缩放以视口中心为中心,继续支持 Ctrl/Cmd + 滚轮缩放。
    +- 滑杆支持方向键、Home、End;百分比文本可点击编辑并在失焦时限制到有效范围。
    +- 百分比文本使用数字输入框呈现;输入提交后同步视口缩放,适配画布保持独立按钮,点击百分比不会触发适配。
    +- 缩放滑杆获得焦点时,Delete/Backspace 不触发 UI 节点快捷删除;缩放按钮鼠标点击不改变快捷键焦点。
    +- Windows/Linux 使用 `Ctrl`,macOS 使用 `Cmd`;`+`、`=` 与 `NumpadAdd` 放大,`-` 与 `NumpadSubtract` 缩小,允许按键重复时连续缩放。
    +- 快捷键直接复用现有 `+`/`−` 按钮动作:以视口中心为锚点,按 `×1.16`/`×0.86` 改变缩放。
    +- 鼠标悬停在预览画布上,或预览画布已获得键盘焦点时,快捷键生效。预览画布需提供可聚焦语义与无障碍名称。
    +- 事件来自输入框、文本编辑区、按钮、链接、缩放工具栏或其他可操作控件时不拦截;不向 iframe 子文档注入监听。
    +- 仅在存在可缩放视口且确认命中快捷键时调用 `preventDefault()` 与 `stopPropagation()`,防止浏览器页面同时缩放。
    +- 保留现有 `Ctrl/Cmd+0` 适配画布与 `Ctrl/Cmd+1` 恢复 100% 行为,不增加其他重置快捷键。缩放仍是当前预览实例的临时 UI 状态。
    +- 保留快捷键与缩放焦点边界的组件级回归测试,不扩展端到端测试。
    +
    +## 验收
    +
    +桌面端可通过按钮、轨道点击、拖动和键盘改变缩放;百分比与画布同步,边界不会越界。组件级测试覆盖平台修饰键、主键盘与小键盘变体、按键重复、悬停/焦点作用域、可编辑与可操作目标放行、无内容放行、浏览器默认行为拦截和现有 `0`/`1` 回归;另运行前端定向类型检查、编码检查和 `git diff --check`。
    diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
    index 8cae77d2d..6e8f7cb5a 100644
    --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
    +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
    @@ -251,6 +251,7 @@ npm run check:server-rs-ddd
     10. 已有静态图片的 `POST /api/editor/images/pixel-art-snaps` 是免费 inline 派生操作,不调用外部 provider、不创建 `external_generation_job`、不读写泥点 ledger,也不进入任务侧栏。免费不放宽 owner、稳定引用、输入上限、持久化或处理阶段零持久化门禁。
     11. 主站编辑器生成队列使用同一次前端请求稳定复用的 `x-request-id`,按 namespace + owner + job kind + request id 生成唯一 `dedupe_key`;首次请求已入队但响应丢失时,重试必须返回原任务。同一幂等键携带不同 payload 返回 `409`,不得创建第二个任务或串到旧结果。外部 v1 的 `Idempotency-Key` 使用独立 namespace,不能与主站请求标识碰撞。幂等 payload 比较只对本次已迁移 sanitizer 的图片生成、图片修改、去背景、图标图集和 UI 提取任务,兼容“升级前旧任务仍含客户端 `generationInputs.references`、当前请求已删除该字段”的单向形状;当前请求仍含 references,或 job kind 属于音频 / 视频 / 角色动作等未迁移任务时必须完整比较,其余请求字段始终完全一致。
     12. `generationInputs.references` 是最终资产的服务端权威行引用,不接受客户端自报 provenance。图片生成类请求入队、完美像素及直接创建资源 / 素材时删除客户端 references;worker 和 inline 路径按本次真实参考图、当前 owner 的项目资源 / 素材记录重建 `refType/refId` 后再持久化。仅能证明 owned objectKey、但找不到对应资源或素材行时可以参与生成,不得制造虚假行引用;`title/label` 只作为展示快照,不提升为资源身份。完美像素为兼容升级前的未知结果重放,可继续用旧版 canonical 客户端输入计算 operation fingerprint;新操作持久化元数据只能使用服务端重建值。owner-scoped 项目快照发现同一 operation 的稳定 result `resourceId` 时,HTTP 路径必须在来源解析、OSS 下载、规整、preflight 和 PUT 前直接返回 `409`,携带 `operationResultAlreadyExists=true` 与 `resultResourceId`,客户端仅以 GET-only 项目对账判定权威结果,不复用既存 metadata 作 exact compare-and-return。
    +13. 资产操作扣费必须在既有 `profile_wallet_ledger.metadata_json` 中记录服务端确定的 `assetKind`;外部生成任务继续同时记录 `externalGenerationJobId` 与 `externalGenerationClaimAttempt`。公开 `GET /api/profile/wallet-ledger` 不返回原始 metadata、资源 ID 或任务 ID,只为 `asset_operation_consume` 下发可选的用户可见 `reason`:图片、图标图集、美术规范、UI 设计和发布素材等图片生成统一显示“生成美术素材”,图片修改显示“编辑美术素材”,UI 素材提取显示“提取美术素材”,角色动画、视频、音效和背景音乐分别显示对应生成原因。未知、空值和未携带新 metadata 的历史流水不猜测业务含义,不下发 `reason`,共享前端继续回退到来源类型文案“资产操作消耗”。该展示投影不得改变定价、扣费顺序、幂等 ledger、失败退款或余额结算语义。
     
     ## 外部服务与资产
     
    diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
    index 69b9c9325..bee46452e 100644
    --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
    +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
    @@ -62,7 +62,7 @@ Linux 本机多用户并发开发时,`npm run dev`、`npm run dev:*` 单模块
     
     后端日志默认写入 `logs/api-server/`,独立 BgFilter worker 日志默认写入 `logs/bgfilter-worker/`。后端 API smoke 使用 `npm run dev:api-server`,先检查 BgFilter worker `/readyz`,再检查 API `/healthz`;需要确认 API 实例可接生产流量时检查 API `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。
     
    -AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。
    +AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。任一配套服务在启动阶段进入 `failed` 时,外层启动器必须立即报告具体服务和退出原因,不能继续等待前端地址超时。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。
     
     Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID  /T /F`。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc//stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。
     
    diff --git a/package-lock.json b/package-lock.json
    index 47cf1217e..3b119a3f6 100644
    --- a/package-lock.json
    +++ b/package-lock.json
    @@ -110,6 +110,7 @@
             "focus-trap-react": "^12.0.3",
             "lexical": "^0.47.0",
             "lucide-react": "^0.546.0",
    +        "phaser": "^4.2.1",
             "react": "^19.0.0",
             "react-arborist": "^3.16.0",
             "react-colorful": "^5.8.0",
    @@ -11570,7 +11571,6 @@
           "version": "5.0.4",
           "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
           "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
    -      "dev": true,
           "license": "MIT"
         },
         "node_modules/events-universal": {
    @@ -17706,6 +17706,15 @@
             "node": "*"
           }
         },
    +    "node_modules/phaser": {
    +      "version": "4.2.1",
    +      "resolved": "https://registry.npmjs.org/phaser/-/phaser-4.2.1.tgz",
    +      "integrity": "sha512-WUNwCPJpdjvZiuT6SgCfYVW8Qw/3j0jJ4ws7P2QkhFLFu74sbGuyHJcbFueGkY/AYO4Pi47bNQXn1OCJeLX//w==",
    +      "license": "MIT",
    +      "dependencies": {
    +        "eventemitter3": "^5.0.4"
    +      }
    +    },
         "node_modules/picocolors": {
           "version": "1.1.1",
           "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
    @@ -26309,6 +26318,7 @@
             "focus-trap-react": "^12.0.3",
             "lexical": "^0.47.0",
             "lucide-react": "^0.546.0",
    +        "phaser": "^4.2.1",
             "react": "^19.0.0",
             "react-arborist": "^3.16.0",
             "react-colorful": "^5.8.0",
    @@ -30478,8 +30488,7 @@
         "eventemitter3": {
           "version": "5.0.4",
           "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
    -      "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
    -      "dev": true
    +      "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="
         },
         "events-universal": {
           "version": "1.0.1",
    @@ -34500,6 +34509,14 @@
           "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
           "dev": true
         },
    +    "phaser": {
    +      "version": "4.2.1",
    +      "resolved": "https://registry.npmjs.org/phaser/-/phaser-4.2.1.tgz",
    +      "integrity": "sha512-WUNwCPJpdjvZiuT6SgCfYVW8Qw/3j0jJ4ws7P2QkhFLFu74sbGuyHJcbFueGkY/AYO4Pi47bNQXn1OCJeLX//w==",
    +      "requires": {
    +        "eventemitter3": "^5.0.4"
    +      }
    +    },
         "picocolors": {
           "version": "1.1.1",
           "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
    diff --git a/packages/image-canvas-core/src/model.ts b/packages/image-canvas-core/src/model.ts
    index 3deeac6f8..b6b6c3430 100644
    --- a/packages/image-canvas-core/src/model.ts
    +++ b/packages/image-canvas-core/src/model.ts
    @@ -4,6 +4,8 @@ export const CANVAS_WORLD_SIZE = 12000;
     export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2;
     export const MIN_SCALE = 0.025;
     export const MAX_SCALE = 3.2;
    +export const CANVAS_ZOOM_IN_FACTOR = 1.16;
    +export const CANVAS_ZOOM_OUT_FACTOR = 0.86;
     export const CANVAS_DISPLAY_SCALE_BASE = 0.5;
     export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 };
     export const FIT_VIEW_PADDING = 10;
    diff --git a/packages/image-canvas-react/src/ZoomControls.tsx b/packages/image-canvas-react/src/ZoomControls.tsx
    index e9f853c26..53e88ccec 100644
    --- a/packages/image-canvas-react/src/ZoomControls.tsx
    +++ b/packages/image-canvas-react/src/ZoomControls.tsx
    @@ -1,4 +1,6 @@
     import {
    +  CANVAS_ZOOM_IN_FACTOR,
    +  CANVAS_ZOOM_OUT_FACTOR,
       canvasDisplayScaleToViewportScale,
       type CanvasViewport,
       formatCanvasDisplayScalePercent,
    @@ -27,8 +29,8 @@ export function ZoomControls({
       const actions: CanvasZoomActions = {
         displayPercent: formatCanvasDisplayScalePercent(viewport.scale),
         fit: onFit,
    -    zoomIn: () => onScaleFromCenter(viewport.scale * 1.16),
    -    zoomOut: () => onScaleFromCenter(viewport.scale * 0.86),
    +    zoomIn: () => onScaleFromCenter(viewport.scale * CANVAS_ZOOM_IN_FACTOR),
    +    zoomOut: () => onScaleFromCenter(viewport.scale * CANVAS_ZOOM_OUT_FACTOR),
         zoomToDisplayScale: (displayScale) =>
           onScaleFromCenter(canvasDisplayScaleToViewportScale(displayScale)),
       };
    diff --git a/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx b/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx
    index a2d15aea6..2c3369106 100644
    --- a/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx
    +++ b/packages/shared/src/components/PlatformProfileWalletLedgerModal/index.test.tsx
    @@ -131,4 +131,45 @@ test('builds wallet ledger presentation with stable source fallbacks', () => {
           },
         ],
       });
    +
    +  for (const reason of [
    +    '生成美术素材',
    +    '编辑美术素材',
    +    '提取美术素材',
    +    '生成角色动画',
    +    '生成视频素材',
    +    '生成音效素材',
    +    '生成背景音乐',
    +  ]) {
    +    expect(
    +      buildWalletLedgerPresentation(
    +        {
    +          entries: [
    +            buildLedgerEntry({
    +              amountDelta: -12,
    +              sourceType: 'asset_operation_consume',
    +              reason,
    +            }),
    +          ],
    +        },
    +        12,
    +      ).entries[0]?.sourceLabel,
    +      `unexpected source label for ${reason}`,
    +    ).toBe(reason);
    +  }
    +
    +  expect(
    +    buildWalletLedgerPresentation(
    +      {
    +        entries: [
    +          buildLedgerEntry({
    +            amountDelta: -12,
    +            sourceType: 'asset_operation_consume',
    +            reason: '   ',
    +          }),
    +        ],
    +      },
    +      12,
    +    ).entries[0]?.sourceLabel,
    +  ).toBe('资产操作消耗');
     });
    diff --git a/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts b/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts
    index 40bd70e41..a18adce79 100644
    --- a/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts
    +++ b/packages/shared/src/components/PlatformProfileWalletLedgerModal/model.ts
    @@ -103,7 +103,8 @@ export function buildWalletLedgerPresentation(
           createdAtLabel: formatWalletLedgerDate(entry.createdAt),
           id: entry.id,
           isIncome: entry.amountDelta > 0,
    -      sourceLabel: getWalletLedgerSourceLabel(entry.sourceType),
    +      sourceLabel:
    +        entry.reason?.trim() || getWalletLedgerSourceLabel(entry.sourceType),
         })),
       };
     }
    diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts
    index 1fa899e4d..eee7978ed 100644
    --- a/packages/shared/src/contracts/runtime.ts
    +++ b/packages/shared/src/contracts/runtime.ts
    @@ -84,6 +84,7 @@ export type ProfileWalletLedgerEntry = {
         | 'puzzle_author_incentive_claim'
         | 'daily_task_reward';
       createdAt: string;
    +  reason?: string;
     };
     
     export type ProfileWalletLedgerResponse = {
    diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs
    index e0ce18d6d..f9eb99573 100644
    --- a/server-rs/crates/api-server/src/asset_billing.rs
    +++ b/server-rs/crates/api-server/src/asset_billing.rs
    @@ -202,6 +202,7 @@ where
             points_cost,
             &billing_plan.current.consume_ledger_id,
             wallet_metadata_json(
    +            asset_kind,
                 billing_plan.external_generation_job_id.as_deref(),
                 billing_plan.external_generation_claim_attempt,
             ),
    @@ -642,9 +643,10 @@ fn resolve_asset_operation_points_cost(configured_points_cost: u64) -> u64 {
     }
     
     #[cfg(test)]
    -fn current_wallet_metadata_json() -> String {
    +fn current_wallet_metadata_json(asset_kind: &str) -> String {
         let billing_context = current_external_generation_billing_context();
         wallet_metadata_json(
    +        asset_kind,
             billing_context
                 .as_ref()
                 .map(|context| context.job_id.as_str()),
    @@ -655,21 +657,21 @@ fn current_wallet_metadata_json() -> String {
     }
     
     fn wallet_metadata_json(
    +    asset_kind: &str,
         external_generation_job_id: Option<&str>,
         external_generation_claim_attempt: Option,
     ) -> String {
    -    let Some(external_generation_job_id) = external_generation_job_id
    +    let mut metadata = json!({
    +        "assetKind": asset_kind.trim(),
    +    });
    +    if let Some(external_generation_job_id) = external_generation_job_id
             .map(str::trim)
             .filter(|value| !value.is_empty())
    -    else {
    -        return module_runtime::PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string();
    -    };
    -
    -    json!({
    -        "externalGenerationJobId": external_generation_job_id,
    -        "externalGenerationClaimAttempt": external_generation_claim_attempt,
    -    })
    -    .to_string()
    +    {
    +        metadata["externalGenerationJobId"] = json!(external_generation_job_id);
    +        metadata["externalGenerationClaimAttempt"] = json!(external_generation_claim_attempt);
    +    }
    +    metadata.to_string()
     }
     
     pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> AppError {
    @@ -971,13 +973,20 @@ mod tests {
                 async {
                     (
                         resolve_asset_operation_points_cost(99),
    -                    current_wallet_metadata_json(),
    +                    current_wallet_metadata_json("editor_generated_image"),
                     )
                 },
             )
             .await;
     
             assert_eq!(points_cost, 37);
    +        assert_eq!(
    +            serde_json::from_str::(&metadata_json)
    +                .expect("worker metadata should be valid JSON")
    +                .get("assetKind")
    +                .and_then(serde_json::Value::as_str),
    +            Some("editor_generated_image")
    +        );
             assert_eq!(
                 serde_json::from_str::(&metadata_json)
                     .expect("worker metadata should be valid JSON")
    @@ -994,8 +1003,11 @@ mod tests {
             );
             assert_eq!(resolve_asset_operation_points_cost(99), 99);
             assert_eq!(
    -            current_wallet_metadata_json(),
    -            module_runtime::PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON
    +            serde_json::from_str::(¤t_wallet_metadata_json(
    +                "editor_image_edit"
    +            ))
    +            .expect("ordinary metadata should be valid JSON"),
    +            json!({"assetKind": "editor_image_edit"})
             );
         }
     
    diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs
    index 198c31aef..949fadf99 100644
    --- a/server-rs/crates/api-server/src/editor_agent/tool.rs
    +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs
    @@ -863,6 +863,7 @@ impl EditorAgentTool for GenerateIconSpritesheetTool {
                 reference_id,
                 reference_image_srcs: Some(reference_image_srcs),
                 icon_descriptions: args.icon_descriptions,
    +            slice_count: None,
                 slice_layout: None,
                 style: None,
                 model: Some(args.model),
    diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs
    index d9f110905..b0663a4b1 100644
    --- a/server-rs/crates/api-server/src/editor_project.rs
    +++ b/server-rs/crates/api-server/src/editor_project.rs
    @@ -8579,6 +8579,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
                         spritesheet_height: source_height,
                         icon_image_srcs: Vec::new(),
                         slice_layout: None,
    +                    slice_count: None,
                         slice_warning: None,
                         prompt,
                         actual_prompt: generated.actual_prompt,
    @@ -8645,6 +8646,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
                     spritesheet_height: source_height,
                     icon_image_srcs: Vec::new(),
                     slice_layout: None,
    +                slice_count: None,
                     slice_warning: None,
                     prompt,
                     actual_prompt: generated.actual_prompt,
    @@ -8729,6 +8731,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
             slice_source,
             request_context.external_call_deadline(),
             None,
    +        None,
         )
         .await
         {
    @@ -8891,6 +8894,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
                 spritesheet_height,
                 icon_image_srcs,
                 slice_layout: None,
    +            slice_count: None,
                 slice_warning,
                 prompt,
                 actual_prompt: generated.actual_prompt,
    @@ -19059,7 +19063,7 @@ mod tests {
                 .checked_sub(Duration::from_millis(1))
                 .expect("expired deadline should be representable");
     
    -        let error = slice_editor_icon_spritesheet_all(source, Some(expired), None)
    +        let error = slice_editor_icon_spritesheet_all(source, Some(expired), None, None)
                 .await
                 .err()
                 .expect("expired CPU budget must fail before decoding");
    @@ -19748,6 +19752,7 @@ mod tests {
                 spritesheet_height: 512,
                 icon_image_srcs: Vec::new(),
                 slice_layout: None,
    +            slice_count: None,
                 slice_warning: Some(EditorIconSpritesheetSliceWarningResponse {
                     code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS,
                     reason: "图集中未识别到可拆分的独立素材。".to_string(),
    diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs
    index 9e8c307aa..f7f318b86 100644
    --- a/server-rs/crates/api-server/src/editor_project_icon.rs
    +++ b/server-rs/crates/api-server/src/editor_project_icon.rs
    @@ -251,6 +251,9 @@ pub(crate) struct EditorIconSpritesheetGenerationRequest {
         pub(crate) reference_id: String,
         pub(crate) reference_image_srcs: Option>,
         pub(crate) icon_descriptions: Vec,
    +    /// 用户要求的切片数量;未提供时按图像中的连通素材自动识别。
    +    #[serde(default, skip_serializing_if = "Option::is_none")]
    +    pub(crate) slice_count: Option,
         #[serde(default, skip_serializing_if = "Option::is_none")]
         pub(crate) slice_layout: Option,
         #[serde(default, skip_serializing_if = "Option::is_none")]
    @@ -267,8 +270,7 @@ pub(crate) struct EditorIconSpritesheetGenerationRequest {
         pub(crate) canvas_completion: Option,
     }
     
    -/// Opt-in fixed atlas slicing. Existing callers remain on the default
    -/// connected-component path unless they explicitly request this layout.
    +/// Deprecated compatibility layout. New callers should use `sliceCount`。
     #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
     pub(crate) enum EditorIconSpritesheetSliceLayout {
         #[serde(rename = "grid-2x2")]
    @@ -313,6 +315,8 @@ pub(crate) struct EditorIconSpritesheetGenerationResponse {
         #[serde(skip_serializing_if = "Option::is_none")]
         pub(crate) slice_layout: Option,
         #[serde(skip_serializing_if = "Option::is_none")]
    +    pub(crate) slice_count: Option,
    +    #[serde(skip_serializing_if = "Option::is_none")]
         pub(crate) slice_warning: Option,
         pub(crate) prompt: String,
         pub(crate) actual_prompt: Option,
    @@ -1783,6 +1787,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
                         spritesheet_height: source_height,
                         icon_image_srcs: Vec::new(),
                         slice_layout: payload.slice_layout,
    +                    slice_count: Some(0),
                         slice_warning: None,
                         prompt,
                         actual_prompt: generated.actual_prompt,
    @@ -1864,6 +1869,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
                     spritesheet_height: source_height,
                     icon_image_srcs: Vec::new(),
                     slice_layout: payload.slice_layout,
    +                slice_count: Some(0),
                     slice_warning: None,
                     prompt,
                     actual_prompt: generated.actual_prompt,
    @@ -1961,6 +1967,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
             slice_source,
             request_context.external_call_deadline(),
             payload.slice_layout,
    +        payload.slice_count,
         )
         .await
         {
    @@ -2053,6 +2060,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
                 "spritesheetHeight": spritesheet_height,
                 "iconImageSrcs": &icon_image_srcs,
                 "sliceLayout": payload.slice_layout,
    +            "sliceCount": payload.slice_count,
                 "sliceWarning": &slice_warning,
                 "warning": &generation_warning,
                 "prompt": user_prompt.clone(),
    @@ -2121,6 +2129,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
             icon.asset = item.asset.map(editor_asset_payload_from_record);
         }
     
    +    let slice_count = icon_image_srcs.len();
         Ok(json_success_body(
             Some(&request_context),
             EditorIconSpritesheetGenerationResponse {
    @@ -2129,6 +2138,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
                 spritesheet_height,
                 icon_image_srcs,
                 slice_layout: payload.slice_layout,
    +            slice_count: Some(slice_count),
                 slice_warning,
                 prompt,
                 actual_prompt: generated.actual_prompt,
    @@ -2323,6 +2333,7 @@ pub async fn split_editor_icon_spritesheet(
             processing_deadline,
             memory_admission,
             None,
    +        None,
         )
         .await?;
         let prompt = source_resource
    @@ -2398,6 +2409,7 @@ pub(crate) async fn slice_editor_icon_spritesheet_all(
         source: DownloadedImage,
         request_deadline: Option,
         slice_layout: Option,
    +    slice_count: Option,
     ) -> Result {
         let processing_deadline =
             resolve_editor_icon_spritesheet_processing_deadline(Instant::now(), request_deadline);
    @@ -2408,6 +2420,7 @@ pub(crate) async fn slice_editor_icon_spritesheet_all(
             processing_deadline,
             memory_admission,
             slice_layout,
    +        slice_count,
         )
         .await
     }
    @@ -2446,6 +2459,7 @@ async fn slice_editor_icon_spritesheet_all_with_memory_admission(
         processing_deadline: Instant,
         memory_admission: Arc,
         slice_layout: Option,
    +    slice_count: Option,
     ) -> Result {
         if Instant::now() >= processing_deadline {
             return Err(editor_icon_spritesheet_processing_timeout_error());
    @@ -2484,7 +2498,9 @@ async fn slice_editor_icon_spritesheet_all_with_memory_admission(
                 }
                 None => prepare_generated_icon_spritesheet_all_by_connected_components(
                     &source,
    -                EDITOR_ICON_SPRITESHEET_MAX_SLICES,
    +                slice_count
    +                    .unwrap_or(EDITOR_ICON_SPRITESHEET_MAX_SLICES)
    +                    .min(EDITOR_ICON_SPRITESHEET_MAX_SLICES),
                     EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS,
                 ),
             }
    @@ -2505,6 +2521,15 @@ async fn slice_editor_icon_spritesheet_all_with_memory_admission(
                 }
                 Err(_) => return Err(editor_icon_spritesheet_processing_timeout_error()),
             };
    +    if let Some(expected) = slice_count {
    +        if expected == 0 || expected > EDITOR_ICON_SPRITESHEET_MAX_SLICES || plan.len() != expected
    +        {
    +            return Err(AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
    +                "provider": "editor-icon-spritesheet-slicing",
    +                "message": format!("请求切片数量为 {expected},实际识别到 {} 个。请调整 sliceCount 或素材排布。", plan.len()),
    +            })));
    +        }
    +    }
         if plan.is_empty() {
             return Err(
                 AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
    @@ -2915,6 +2940,7 @@ mod tests {
                 source,
                 None,
                 Some(EditorIconSpritesheetSliceLayout::Grid2x2),
    +            None,
             )
             .await
             .expect("declared 2x2 sheet should slice");
    diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs
    index 0a15d26ef..de48a2d15 100644
    --- a/server-rs/crates/api-server/src/external_editor_api.rs
    +++ b/server-rs/crates/api-server/src/external_editor_api.rs
    @@ -2617,13 +2617,8 @@ mod tests {
                     .is_some_and(|description| description.contains("同步返回 400"))
             );
             assert_eq!(
    -            icon_spritesheet_request["properties"]["sliceLayout"]["enum"],
    -            json!(["grid-2x2"])
    -        );
    -        assert!(
    -            icon_spritesheet_request["properties"]["sliceLayout"]["description"]
    -                .as_str()
    -                .is_some_and(|description| description.contains("固定图集切片合同"))
    +            icon_spritesheet_request["properties"]["sliceCount"]["minimum"],
    +            json!(1)
             );
             let icon_style_schema = &parsed["components"]["schemas"]["EditorIconSpritesheetGenerationRequest"]
                 ["properties"]["style"];
    @@ -2635,11 +2630,7 @@ mod tests {
                     ["sliceWarning"]["anyOf"][0]["$ref"],
                 "#/components/schemas/EditorIconSpritesheetSliceWarning"
             );
    -        assert_eq!(
    -            parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"]
    -                ["sliceLayout"]["enum"],
    -            json!(["grid-2x2"])
    -        );
    +        assert!(parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"]["sliceCount"].is_object());
             assert_eq!(
                 parsed["components"]["schemas"]["EditorImageGenerationResponse"]["properties"]["warning"]
                     ["anyOf"][0]["$ref"],
    diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs
    index fa80d9b32..722213247 100644
    --- a/server-rs/crates/api-server/src/external_generation_worker.rs
    +++ b/server-rs/crates/api-server/src/external_generation_worker.rs
    @@ -1365,6 +1365,7 @@ fn compact_external_api_generation_result(result: Value) -> Value {
                     | "spritesheetHeight"
                     | "iconImageSrcs"
                     | "sliceLayout"
    +                | "sliceCount"
                     | "frames"
                     | "frameCount"
                     | "frameWidth"
    diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs
    index b7413f45a..db27bb20f 100644
    --- a/server-rs/crates/api-server/src/runtime_profile.rs
    +++ b/server-rs/crates/api-server/src/runtime_profile.rs
    @@ -2064,15 +2064,46 @@ fn build_redeem_profile_reward_code_response(
     fn build_profile_wallet_ledger_entry_response(
         record: module_runtime::RuntimeProfileWalletLedgerEntryRecord,
     ) -> ProfileWalletLedgerEntryResponse {
    +    let reason = profile_wallet_ledger_reason(record.source_type, &record.metadata_json);
         ProfileWalletLedgerEntryResponse {
             id: record.wallet_ledger_id,
             amount_delta: record.amount_delta,
             balance_after: record.balance_after,
             source_type: format_profile_wallet_ledger_source_type(record.source_type).to_string(),
             created_at: record.created_at,
    +        reason,
         }
     }
     
    +fn profile_wallet_ledger_reason(
    +    source_type: RuntimeProfileWalletLedgerSourceType,
    +    metadata_json: &str,
    +) -> Option {
    +    if source_type != RuntimeProfileWalletLedgerSourceType::AssetOperationConsume {
    +        return None;
    +    }
    +    let metadata = serde_json::from_str::(metadata_json).ok()?;
    +    let asset_kind = metadata.get("assetKind")?.as_str()?.trim();
    +    let reason = match asset_kind {
    +        "editor_scene_image"
    +        | "editor_character_image"
    +        | "editor_spec_image"
    +        | "editor_quick_edit_image"
    +        | "editor_ui_design_image"
    +        | "editor_publication_material"
    +        | "editor_generated_image"
    +        | "editor_icon_spritesheet" => "生成美术素材",
    +        "editor_image_edit" => "编辑美术素材",
    +        "editor_ui_design_asset_extraction" => "提取美术素材",
    +        "editor_character_animation" => "生成角色动画",
    +        "editor_video" => "生成视频素材",
    +        "editor_sound_effect" => "生成音效素材",
    +        "editor_background_music" => "生成背景音乐",
    +        _ => return None,
    +    };
    +    Some(reason.to_string())
    +}
    +
     fn build_profile_task_center_response(
         record: RuntimeProfileTaskCenterRecord,
     ) -> ProfileTaskCenterResponse {
    @@ -2433,7 +2464,8 @@ mod tests {
             format_profile_wallet_ledger_source_type,
             is_wechat_profile_recharge_order_terminal_for_confirmation,
             map_runtime_profile_client_error, normalize_admin_invite_code_metadata,
    -        parse_admin_profile_code_time_field, should_notify_virtual_payment_goods_delivery,
    +        parse_admin_profile_code_time_field, profile_wallet_ledger_reason,
    +        should_notify_virtual_payment_goods_delivery,
         };
     
         #[test]
    @@ -2501,7 +2533,7 @@ mod tests {
         use platform_auth::{
             AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
         };
    -    use serde_json::Value;
    +    use serde_json::{Value, json};
         use spacetime_client::SpacetimeClientError;
         use std::time::Duration;
         use time::OffsetDateTime;
    @@ -2619,6 +2651,60 @@ mod tests {
             );
         }
     
    +    #[test]
    +    fn profile_wallet_ledger_reason_only_exposes_known_asset_operations() {
    +        for (asset_kind, expected) in [
    +            ("editor_scene_image", "生成美术素材"),
    +            ("editor_character_image", "生成美术素材"),
    +            ("editor_spec_image", "生成美术素材"),
    +            ("editor_quick_edit_image", "生成美术素材"),
    +            ("editor_ui_design_image", "生成美术素材"),
    +            ("editor_publication_material", "生成美术素材"),
    +            ("editor_generated_image", "生成美术素材"),
    +            ("editor_icon_spritesheet", "生成美术素材"),
    +            ("editor_image_edit", "编辑美术素材"),
    +            ("editor_ui_design_asset_extraction", "提取美术素材"),
    +            ("editor_character_animation", "生成角色动画"),
    +            ("editor_video", "生成视频素材"),
    +            ("editor_sound_effect", "生成音效素材"),
    +            ("editor_background_music", "生成背景音乐"),
    +        ] {
    +            assert_eq!(
    +                profile_wallet_ledger_reason(
    +                    RuntimeProfileWalletLedgerSourceType::AssetOperationConsume,
    +                    &json!({"assetKind": asset_kind}).to_string(),
    +                )
    +                .as_deref(),
    +                Some(expected),
    +                "unexpected reason for {asset_kind}",
    +            );
    +        }
    +
    +        for metadata in [
    +            "{}",
    +            "not-json",
    +            r#"{"assetKind":""}"#,
    +            r#"{"assetKind":"   "}"#,
    +            r#"{"assetKind":"unknown_internal_operation"}"#,
    +            r#"{"assetKind":123}"#,
    +        ] {
    +            assert_eq!(
    +                profile_wallet_ledger_reason(
    +                    RuntimeProfileWalletLedgerSourceType::AssetOperationConsume,
    +                    metadata,
    +                ),
    +                None,
    +            );
    +        }
    +        assert_eq!(
    +            profile_wallet_ledger_reason(
    +                RuntimeProfileWalletLedgerSourceType::AssetOperationRefund,
    +                r#"{"assetKind":"editor_generated_image"}"#,
    +            ),
    +            None,
    +        );
    +    }
    +
         #[tokio::test]
         async fn profile_dashboard_requires_authentication() {
             let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
    diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs
    index d46843ff3..704a5d444 100644
    --- a/server-rs/crates/shared-contracts/src/runtime.rs
    +++ b/server-rs/crates/shared-contracts/src/runtime.rs
    @@ -225,6 +225,8 @@ pub struct ProfileWalletLedgerEntryResponse {
         pub balance_after: u64,
         pub source_type: String,
         pub created_at: String,
    +    #[serde(skip_serializing_if = "Option::is_none")]
    +    pub reason: Option,
     }
     
     #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
    @@ -1430,6 +1432,7 @@ mod tests {
                         source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD
                             .to_string(),
                         created_at: "2026-04-22T09:59:00Z".to_string(),
    +                    reason: None,
                     },
                     ProfileWalletLedgerEntryResponse {
                         id: "ledger-2".to_string(),
    @@ -1437,6 +1440,7 @@ mod tests {
                         balance_after: 80,
                         source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC.to_string(),
                         created_at: "2026-04-22T10:00:00Z".to_string(),
    +                    reason: None,
                     },
                     ProfileWalletLedgerEntryResponse {
                         id: "ledger-3".to_string(),
    @@ -1445,6 +1449,7 @@ mod tests {
                         source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD
                             .to_string(),
                         created_at: "2026-04-22T10:01:00Z".to_string(),
    +                    reason: None,
                     },
                     ProfileWalletLedgerEntryResponse {
                         id: "ledger-4".to_string(),
    @@ -1453,6 +1458,7 @@ mod tests {
                         source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD
                             .to_string(),
                         created_at: "2026-04-22T10:02:00Z".to_string(),
    +                    reason: None,
                     },
                     ProfileWalletLedgerEntryResponse {
                         id: "ledger-5".to_string(),
    @@ -1460,6 +1466,7 @@ mod tests {
                         balance_after: 200,
                         source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE.to_string(),
                         created_at: "2026-04-22T10:03:00Z".to_string(),
    +                    reason: None,
                     },
                     ProfileWalletLedgerEntryResponse {
                         id: "ledger-6".to_string(),
    @@ -1468,6 +1475,7 @@ mod tests {
                         source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME
                             .to_string(),
                         created_at: "2026-04-22T10:04:00Z".to_string(),
    +                    reason: Some("生成美术素材".to_string()),
                     },
                     ProfileWalletLedgerEntryResponse {
                         id: "ledger-7".to_string(),
    @@ -1476,6 +1484,7 @@ mod tests {
                         source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND
                             .to_string(),
                         created_at: "2026-04-22T10:05:00Z".to_string(),
    +                    reason: None,
                     },
                     ProfileWalletLedgerEntryResponse {
                         id: "ledger-8".to_string(),
    @@ -1484,6 +1493,7 @@ mod tests {
                         source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM
                             .to_string(),
                         created_at: "2026-04-22T10:06:00Z".to_string(),
    +                    reason: None,
                     },
                     ProfileWalletLedgerEntryResponse {
                         id: "ledger-9".to_string(),
    @@ -1491,6 +1501,7 @@ mod tests {
                         balance_after: 212,
                         source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD.to_string(),
                         created_at: "2026-04-22T10:07:00Z".to_string(),
    +                    reason: None,
                     },
                 ],
             })
    @@ -1522,6 +1533,8 @@ mod tests {
                 payload["entries"][5]["sourceType"],
                 json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME)
             );
    +        assert_eq!(payload["entries"][5]["reason"], json!("生成美术素材"));
    +        assert!(payload["entries"][0].get("reason").is_none());
             assert_eq!(
                 payload["entries"][6]["sourceType"],
                 json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND)