diff --git a/.env.example b/.env.example index d8988060b..bf06357e1 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,8 @@ # Server-side OpenAI-compatible LLM endpoint base URL. -LLM_BASE_URL="https://api.vectorengine.cn/v1" +LLM_BASE_URL="https://api.tiantoken.com/v1" # Server-side API key used by the local Vite proxy. -# Recommended: set `LLM_API_KEY` locally, or use `VECTOR_ENGINE_API_KEY` +# Recommended: set `LLM_API_KEY` locally, or use `TIANTOKEN_API_KEY` # through the Rust api-server proxy. # Legacy compatibility: `VITE_LLM_API_KEY` is still supported by the proxy, # but it should not be relied on by browser code. @@ -122,7 +122,7 @@ WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY="" # Model name for chat completions. VITE_LLM_MODEL="gpt-5.4-mini" GENARRATIVE_LLM_PROVIDER="openai-compatible" -GENARRATIVE_LLM_BASE_URL="https://api.vectorengine.cn/v1" +GENARRATIVE_LLM_BASE_URL="https://api.tiantoken.com/v1" GENARRATIVE_LLM_API_KEY="" GENARRATIVE_LLM_MODEL="gpt-5.4-mini" @@ -130,10 +130,15 @@ GENARRATIVE_LLM_MODEL="gpt-5.4-mini" DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1" DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY" -# VectorEngine LLM and GPT-image-2 / Gemini image generation config. +# Tiantoken LLM and GPT-image-2 / Gemini image generation config. +TIANTOKEN_BASE_URL="https://api.tiantoken.com" +TIANTOKEN_API_KEY="" +TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS="1000000" + +# VectorEngine is retained for Suno audio generation only. VECTOR_ENGINE_BASE_URL="https://api.vectorengine.cn" VECTOR_ENGINE_API_KEY="" -VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS="1000000" +VECTOR_ENGINE_AUDIO_REQUEST_TIMEOUT_MS="180000" # ElevenLabs editor sound-effect generation is server-side only. ELEVENLABS_BASE_URL="https://api.elevenlabs.io" diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index d796f3d91..f629f6251 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -27,9 +27,28 @@ env: RUSTC_WRAPPER: '' CARGO_BUILD_RUSTC_WRAPPER: '' +# job 声明顺序就是 runner 领取顺序,因此把最长尾的客户端 Rust 门禁排在前面, +# 让它在最少的等待下占用并发槽位;其余 job 按时长递减排列。 +# +# 客户端(微信壳 / Expo 移动壳 / Tauri 桌面壳 / AI 游戏创作壳)门禁原先全部串在 +# `Native shell tests` 一个 job 里,实测 18 分 37 秒。现在按门禁组拆成一个分组一个 job: +# `Native shell tests`(契约 + H5 / 微信 / 移动 / 桌面壳门禁 + 发布构建 smoke)、 +# `AI game creator shell web tests`(typecheck + 壳内测试)、`AI game creator shell +# Rust shard 1/4` 到 `4/4`(AGC 壳 bin 单测按名单分 4 片)、`AI game creator shell +# Rust smoke`(agent-run smoke)与 `AI game creator shell Rust crates`(AGC 壳依赖的 +# 共享 / 平台 crate 测试)。各自的命令与拆分前逐一对应,本地 +# `npm run check:native-shells` 仍是同一条串行序列。 +# +# AGC 壳的 bin 单测(2466 条)按名单分 4 片、一片一个 job:片内保持 `--test-threads=1` +# (当年线程并行会互相干扰的是进程内后台锁与异步终态),片与片之间靠 job 级并发摊开。 +# 不要改回「一个 job 里多进程并行这几片」:同一容器内它们会争抢共享 HOME、target 与固定 +# 临时路径,实测比整套串行还慢。每个分片 job 都会自校验「片并集等于全集且互斥」。 jobs: - repository-checks: - name: Repository checks + # AGC 壳自身的 Rust bin 单测分片,4 片各自独立 job 并发执行、片内仍保持 + # `--test-threads=1`。这里不装 npm 依赖:壳 Rust 门禁只用 cargo 与 node 内建模块, + # 也只需要 AGC 壳自己那份锁定依赖。 + ai-game-creator-shell-rust-shard-1: + name: AI game creator shell Rust shard 1/4 runs-on: genarrative-ci steps: - name: Checkout full history from Gitea @@ -41,76 +60,228 @@ jobs: - name: Validate preinstalled CI job image and sandbox run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh - - name: Resolve comparison base + - name: Prepare AI game creator shell Rust dependencies shell: bash run: | set -euo pipefail - base_ref="$(node -e ' - const fs = require("node:fs"); - const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); - process.stdout.write(event.pull_request?.base?.sha ?? event.before ?? ""); - ')" - if [[ -n "${base_ref}" && ! "${base_ref}" =~ ^0+$ ]]; then - git cat-file -e "${base_ref}^{commit}" 2>/dev/null || { - echo "comparison base commit is unavailable: ${base_ref}" >&2 + # AGC 壳有独立 Cargo.lock,其 path 依赖已含 platform-llm、platform-agent、 + # agent-runtime-core 与 shared-contracts,因此只锁这一份 manifest 就能覆盖壳测试 + # 与 smoke 的全部第三方依赖;server-rs 那次预热归 crate 级 job,不在这里重复。 + for attempt in $(seq 1 5); do + if cargo fetch --locked \ + --target x86_64-unknown-linux-gnu \ + --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2 exit 1 - } - else - base_ref="$(git merge-base HEAD origin/master 2>/dev/null || git rev-parse HEAD)" - fi - resolved_base_ref="$(git rev-parse --verify "${base_ref}^{commit}" 2>/dev/null || true)" - head_ref="$(git rev-parse HEAD)" - if [[ "${resolved_base_ref}" == "${head_ref}" ]]; then - resolved_base_ref="$(git rev-parse --verify HEAD^ 2>/dev/null || true)" - fi - if [[ -z "${resolved_base_ref}" ]]; then - echo 'comparison base must resolve to a commit distinct from HEAD.' >&2 - exit 1 - fi - base_ref="${resolved_base_ref}" - if [[ "${GITHUB_EVENT_NAME:-}" == 'pull_request' ]] \ - && ! git merge-base --is-ancestor "${base_ref}" HEAD; then - echo 'pull request head does not contain the latest base commit; update the branch and rerun CI.' >&2 - exit 1 - fi - echo "SPACETIME_SCHEMA_BASE_REF=${base_ref}" >> "${GITHUB_ENV}" + fi + sleep $((attempt * 2)) + done - - name: Install npm dependencies - run: bash scripts/ci-npm-ci-with-retry.sh + - name: Run AI game creator shell Rust shard 1/4 + run: npm run check:native-shells:agc-rust-shard-1 - - name: Run repository checks - run: npm run check:repository-ci - - frontend-tests: - name: Frontend tests + ai-game-creator-shell-rust-shard-2: + name: AI game creator shell Rust shard 2/4 runs-on: genarrative-ci steps: - - name: Checkout source from Gitea + - name: Checkout full history from Gitea env: - GENARRATIVE_GITEA_FETCH_DEPTH: '1' + GENARRATIVE_GITEA_FETCH_DEPTH: '0' GENARRATIVE_GITEA_TOKEN: ${{ github.token }} run: genarrative-gitea-checkout - name: Validate preinstalled CI job image and sandbox run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh - - name: Install npm dependencies - run: bash scripts/ci-npm-ci-with-retry.sh + - name: Prepare AI game creator shell Rust dependencies + shell: bash + run: | + set -euo pipefail + for attempt in $(seq 1 5); do + if cargo fetch --locked \ + --target x86_64-unknown-linux-gnu \ + --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done - - name: Run frontend and script tests - run: npm run test + - name: Run AI game creator shell Rust shard 2/4 + run: npm run check:native-shells:agc-rust-shard-2 - - name: Run BgFilter worker smoke harness tests - run: npm run bgfilter-worker:smoke-test + ai-game-creator-shell-rust-shard-3: + name: AI game creator shell Rust shard 3/4 + runs-on: genarrative-ci + steps: + - name: Checkout full history from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '0' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout - - name: Validate production health patrol behavior - run: npm run check:production-health-patrol + - name: Validate preinstalled CI job image and sandbox + run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh - - name: Validate production API release behavior - run: npm run check:production-api-release + - name: Prepare AI game creator shell Rust dependencies + shell: bash + run: | + set -euo pipefail + for attempt in $(seq 1 5); do + if cargo fetch --locked \ + --target x86_64-unknown-linux-gnu \ + --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done - - name: Validate production API deploy behavior - run: npm run check:production-api-deploy + - name: Run AI game creator shell Rust shard 3/4 + run: npm run check:native-shells:agc-rust-shard-3 + + ai-game-creator-shell-rust-shard-4: + name: AI game creator shell Rust shard 4/4 + runs-on: genarrative-ci + steps: + - name: Checkout full history from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '0' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout + + - name: Validate preinstalled CI job image and sandbox + run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + + - name: Prepare AI game creator shell Rust dependencies + shell: bash + run: | + set -euo pipefail + for attempt in $(seq 1 5); do + if cargo fetch --locked \ + --target x86_64-unknown-linux-gnu \ + --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done + + - name: Run AI game creator shell Rust shard 4/4 + run: npm run check:native-shells:agc-rust-shard-4 + + # agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样不装 npm 依赖, + # 单独一个 job,免得把已经压到 4 分钟级的片 job 拖长。 + ai-game-creator-shell-rust-smoke: + name: AI game creator shell Rust smoke + runs-on: genarrative-ci + steps: + - name: Checkout full history from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '0' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout + + - name: Validate preinstalled CI job image and sandbox + run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + + - name: Prepare AI game creator shell Rust dependencies + shell: bash + run: | + set -euo pipefail + for attempt in $(seq 1 5); do + if cargo fetch --locked \ + --target x86_64-unknown-linux-gnu \ + --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo 'AI game creator shell Cargo dependency fetch failed after 5 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done + + - name: Run AI game creator shell agent-run smoke + run: npm run check:native-shells:agc-rust-smoke + + # AGC 壳依赖的共享 / 平台 crate 测试用的是 server-rs workspace 与两个无锁独立 crate + # 的 manifest,属另一套依赖图,因此单独一个 job 预热、单独跑。 + ai-game-creator-shell-rust-crates: + name: AI game creator shell Rust crates + runs-on: genarrative-ci + steps: + - name: Checkout full history from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '0' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout + + - name: Validate preinstalled CI job image and sandbox + run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + + - name: Prepare server-rs Rust dependencies + shell: bash + run: | + set -euo pipefail + for attempt in $(seq 1 5); do + if cargo fetch --locked \ + --target x86_64-unknown-linux-gnu \ + --manifest-path server-rs/Cargo.toml; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo 'server-rs Cargo dependency fetch failed after 5 attempts.' >&2 + exit 1 + fi + sleep $((attempt * 2)) + done + + - name: Prepare standalone Rust crate dependencies + shell: bash + run: | + set -euo pipefail + # agent-runtime-core / agent-runtime-orchestration 被 server-rs/Cargo.toml 的 + # exclude 排除,不参与上面的 workspace 锁文件,因此上面那次锁定 fetch 覆盖不到它们; + # 而 `npm run ai-game-creator-shell:check:rust:crates` 会用 + # `cargo test --manifest-path` 单独跑这两个 crate。不在这里预热的话,这两条测试 + # 会在测试阶段自己 `Updating crates.io index`,crates.io 一抖动整条 job 就红 + # (见 #327 / PR #316 run 1950)。 + # 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch: + # 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内, + # 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本 + # 解析,不再触碰 registry index。 + for manifest_path in \ + server-rs/crates/agent-runtime-core/Cargo.toml \ + server-rs/crates/agent-runtime-orchestration/Cargo.toml; do + for attempt in $(seq 1 5); do + if cargo fetch \ + --target x86_64-unknown-linux-gnu \ + --manifest-path "${manifest_path}"; then + break + fi + if [[ "${attempt}" -eq 5 ]]; then + echo "standalone crate dependency fetch failed after 5 attempts: ${manifest_path}" >&2 + exit 1 + fi + sleep $((attempt * 2)) + done + done + + - name: Run AI game creator shell shared crate gates + run: npm run check:native-shells:agc-rust-crates backend-tests: name: Backend tests @@ -194,6 +365,8 @@ jobs: - name: Check SpacetimeDB module run: cargo check --locked -p spacetime-module --manifest-path server-rs/Cargo.toml + # 客户端的壳级与契约门禁:静态契约断言、H5 / 微信 / 移动 / 桌面壳运行时门禁, + # 以及依赖发布产物的构建 smoke。 native-shell-tests: name: Native shell tests runs-on: genarrative-ci @@ -215,7 +388,6 @@ jobs: run: | set -euo pipefail for manifest_path in \ - server-rs/Cargo.toml \ apps/desktop-shell/src-tauri/Cargo.toml \ apps/ai-game-creator-shell/src-tauri/Cargo.toml; do for attempt in $(seq 1 5); do @@ -232,39 +404,118 @@ jobs: done done - - name: Prepare standalone Rust crate dependencies - shell: bash - run: | - set -euo pipefail - # agent-runtime-core / agent-runtime-orchestration 被 server-rs/Cargo.toml 的 - # exclude 排除,不参与上面的 workspace 锁文件,因此上面那次锁定 fetch 覆盖不到它们; - # 而 check:native-shells 会经 agent-runtime-*:check 用 `cargo test --manifest-path` - # 单独跑这两个 crate。不在这里预热的话,这两条测试会在测试阶段自己 - # `Updating crates.io index`,crates.io 一抖动整条 native shell 作业就红 - # (见 #327 / PR #316 run 1950)。 - # 两个 crate 都没有提交 Cargo.lock,所以这里只能做不带锁标志的 fetch: - # 加锁标志会因为缺少锁文件直接失败。生成的 Cargo.lock 落在两个 crate 目录内, - # 已被各自的 .gitignore 忽略,只留在容器里;随后的测试阶段因此能用锁定版本 - # 解析,不再触碰 registry index。 - for manifest_path in \ - server-rs/crates/agent-runtime-core/Cargo.toml \ - server-rs/crates/agent-runtime-orchestration/Cargo.toml; do - for attempt in $(seq 1 5); do - if cargo fetch \ - --target x86_64-unknown-linux-gnu \ - --manifest-path "${manifest_path}"; then - break - fi - if [[ "${attempt}" -eq 5 ]]; then - echo "standalone crate dependency fetch failed after 5 attempts: ${manifest_path}" >&2 - exit 1 - fi - sleep $((attempt * 2)) - done - done + - name: Run native shell contract gates + run: npm run check:native-shells:contract - name: Run native shell gates - run: npm run check:native-shells + run: npm run check:native-shells:shells + + - name: Run native shell release build smoke + run: npm run check:native-shells:release - name: Ensure native lockfiles are unchanged run: git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock + + frontend-tests: + name: Frontend tests + runs-on: genarrative-ci + steps: + - name: Checkout source from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '1' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout + + - name: Validate preinstalled CI job image and sandbox + run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + + - name: Install npm dependencies + run: bash scripts/ci-npm-ci-with-retry.sh + + - name: Run frontend and script tests + run: npm run test + + - name: Run BgFilter worker smoke harness tests + run: npm run bgfilter-worker:smoke-test + + - name: Validate production health patrol behavior + run: npm run check:production-health-patrol + + - name: Validate production API release behavior + run: npm run check:production-api-release + + - name: Validate production API deploy behavior + run: npm run check:production-api-deploy + + repository-checks: + name: Repository checks + runs-on: genarrative-ci + steps: + - name: Checkout full history from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '0' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout + + - name: Validate preinstalled CI job image and sandbox + run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + + - name: Resolve comparison base + shell: bash + run: | + set -euo pipefail + base_ref="$(node -e ' + const fs = require("node:fs"); + const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); + process.stdout.write(event.pull_request?.base?.sha ?? event.before ?? ""); + ')" + if [[ -n "${base_ref}" && ! "${base_ref}" =~ ^0+$ ]]; then + git cat-file -e "${base_ref}^{commit}" 2>/dev/null || { + echo "comparison base commit is unavailable: ${base_ref}" >&2 + exit 1 + } + else + base_ref="$(git merge-base HEAD origin/master 2>/dev/null || git rev-parse HEAD)" + fi + resolved_base_ref="$(git rev-parse --verify "${base_ref}^{commit}" 2>/dev/null || true)" + head_ref="$(git rev-parse HEAD)" + if [[ "${resolved_base_ref}" == "${head_ref}" ]]; then + resolved_base_ref="$(git rev-parse --verify HEAD^ 2>/dev/null || true)" + fi + if [[ -z "${resolved_base_ref}" ]]; then + echo 'comparison base must resolve to a commit distinct from HEAD.' >&2 + exit 1 + fi + base_ref="${resolved_base_ref}" + if [[ "${GITHUB_EVENT_NAME:-}" == 'pull_request' ]] \ + && ! git merge-base --is-ancestor "${base_ref}" HEAD; then + echo 'pull request head does not contain the latest base commit; update the branch and rerun CI.' >&2 + exit 1 + fi + echo "SPACETIME_SCHEMA_BASE_REF=${base_ref}" >> "${GITHUB_ENV}" + + - name: Install npm dependencies + run: bash scripts/ci-npm-ci-with-retry.sh + + - name: Run repository checks + run: npm run check:repository-ci + + # 客户端的 AI 游戏创作壳前端门禁:typecheck 与壳内测试,不触碰 Cargo。 + ai-game-creator-shell-web-tests: + name: AI game creator shell web tests + runs-on: genarrative-ci + steps: + - name: Checkout full history from Gitea + env: + GENARRATIVE_GITEA_FETCH_DEPTH: '0' + GENARRATIVE_GITEA_TOKEN: ${{ github.token }} + run: genarrative-gitea-checkout + + - name: Validate preinstalled CI job image and sandbox + run: GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh + + - name: Install npm dependencies + run: bash scripts/ci-npm-ci-with-retry.sh + + - name: Run AI game creator shell web gates + run: npm run check:native-shells:agc-web diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index da7eddb68..c08cb6ac9 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -1,7 +1,7 @@ { "name": "@genarrative/ai-game-creator-shell", "private": true, - "version": "0.1.27", + "version": "0.1.29", "type": "module", "scripts": { "dev": "node scripts/start-tauri-dev.mjs", diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index efa6f62be..e32dfb4b7 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -108,9 +108,16 @@ const rustSharedContractSource = fs.readFileSync( ); const allowedUncalledTauriCommands = [ 'append_direct_project_conversation_message', + // TODO: Remove the retired binding command after the legacy runtime path is removed. + 'bind_components', 'chat_with_game_creator_agent', 'check_ui_editor_font_glyph_coverage', 'create_ui_design_resource', + // 图片类生成的同步变体:GUI 已改为 `start_local_project_asset_generation` + 项目内任务账本 + // (提交即返回、后台生成)。这条命令**没有生产调用方**,只有 Rust 集成测试 + // (`src/tests/project.rs`)与 `commands.rs` 单测在调;待后续批次删除,或改为转调 + // `start_local_project_asset_generation`。 + 'generate_local_project_asset', 'open_game_creator_launcher_window', 'open_game_creator_workspace_window', 'read_direct_project_conversation', @@ -1288,7 +1295,7 @@ for (const requiredSource of [ } } -if (tauriConfig.productName !== 'Genarrative AI Game Creator') { +if (tauriConfig.productName !== '陶泥儿') { throw new Error('AI game creator shell productName drifted'); } @@ -1300,19 +1307,19 @@ const expectedBundledDesignAgentResources = { 'design-agent': 'design-agent', }; const expectedBundledWindowsResources = { - 'resources/codex/win-x64/bin/codex.exe': 'codex/win-x64/bin/codex.exe', + 'resources/codex/win-x64/bin/codex.exe': 'coding-agent/win-x64/bin/codex.exe', 'resources/codex/win-x64/bin/codex-code-mode-host.exe': - 'codex/win-x64/bin/codex-code-mode-host.exe', + 'coding-agent/win-x64/bin/codex-code-mode-host.exe', 'resources/codex/win-x64/codex-path/rg.exe': - 'codex/win-x64/codex-path/rg.exe', + 'coding-agent/win-x64/codex-path/rg.exe', 'resources/codex/win-x64/codex-resources/codex-command-runner.exe': - 'codex/win-x64/codex-resources/codex-command-runner.exe', + 'coding-agent/win-x64/codex-resources/codex-command-runner.exe', 'resources/codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe': - 'codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe', + 'coding-agent/win-x64/codex-resources/codex-windows-sandbox-setup.exe', 'resources/codex/win-x64/codex-package.json': - 'codex/win-x64/codex-package.json', - 'resources/codex/win-x64/NOTICE.md': 'codex/win-x64/NOTICE.md', - 'resources/codex/win-x64/manifest.json': 'codex/win-x64/manifest.json', + 'coding-agent/win-x64/codex-package.json', + 'resources/codex/win-x64/NOTICE.md': 'coding-agent/win-x64/NOTICE.md', + 'resources/codex/win-x64/manifest.json': 'coding-agent/win-x64/manifest.json', 'resources/plugins': 'plugins', }; assert.deepEqual( diff --git a/apps/ai-game-creator-shell/scripts/dev-port.mjs b/apps/ai-game-creator-shell/scripts/dev-port.mjs index 2e47a7d92..03559d017 100644 --- a/apps/ai-game-creator-shell/scripts/dev-port.mjs +++ b/apps/ai-game-creator-shell/scripts/dev-port.mjs @@ -9,6 +9,10 @@ import { const agcDevHost = '127.0.0.1'; const legacyAgcDevPort = 3080; const agcVitePortEnvKey = 'GENARRATIVE_AGC_VITE_PORT'; +const agcAdminWebHost = '127.0.0.1'; +const legacyAgcAdminWebPort = 3102; +// 与 scripts/dev.mjs 的后台 Web 端口配置保持同一环境变量名。 +const agcAdminWebPortEnvKey = 'ADMIN_WEB_PORT'; function readConfiguredAgcDevPort(env = process.env) { const rawPort = String(env[agcVitePortEnvKey] ?? '').trim(); @@ -99,13 +103,100 @@ function withAgcDevEndpointEnv(endpoint, env = process.env) { }; } +function readConfiguredAgcAdminWebPort(env = process.env) { + const rawPort = String(env[agcAdminWebPortEnvKey] ?? '').trim(); + if (!rawPort) { + return null; + } + + const port = normalizePort(rawPort, -1); + if (port < 1024) { + throw new Error(`${agcAdminWebPortEnvKey} 必须是 1024-65535 的有效端口`); + } + return port; +} + +function createAgcAdminWebEndpoint(port, portRange = null) { + const origin = `http://${agcAdminWebHost}:${port}`; + return { + host: agcAdminWebHost, + port, + origin, + basePath: '/admin/', + url: `${origin}/admin/`, + portRange, + }; +} + +// AGC 开发态的后台 Web 与 `npm run dev` 的后台 Vite 共用同一套优先端口约定: +// Linux 取当前用户端口段的 `start + 3` 槽位,非 Linux 保留 `3102` 兼容首选并允许统一漂移。 +async function resolveAgcAdminWebEndpoint({ + env = process.env, + platform = process.platform, + strictConfigured = false, + reservedPorts = [], + reservePortRange = reserveLinuxDevPortRange, + findPort = findAvailablePort, +} = {}) { + const configuredPort = readConfiguredAgcAdminWebPort(env); + let portRange = null; + let preferredPort = configuredPort ?? legacyAgcAdminWebPort; + + if (platform === 'linux') { + const allocation = await reservePortRange({ env }); + if (!allocation?.range) { + throw new Error('无法取得当前 Linux 用户的 dev 端口段'); + } + portRange = allocation.range; + const mappedAdminWebPort = mapDevPortsToPortRange(portRange)?.adminWebPort; + if (!Number.isInteger(mappedAdminWebPort)) { + throw new Error( + `当前 Linux dev 端口段 ${portRange.label} 缺少后台 Web 槽位;请先迁移为至少 6 个端口且不与其它用户重叠的端口段`, + ); + } + preferredPort = configuredPort ?? mappedAdminWebPort; + } + + const reservedPortSet = new Set( + reservedPorts.filter((value) => Number.isInteger(value) && value > 0), + ); + const port = await findPort({ + host: agcAdminWebHost, + preferredPort, + portRange, + reservedPorts: reservedPortSet, + strict: strictConfigured && configuredPort != null, + }); + console.log( + formatPortDecision({ + name: 'ai-game-creator-shell-admin-web', + host: agcAdminWebHost, + preferredPort, + resolvedPort: port, + }), + ); + if (portRange) { + console.log( + `[ai-game-creator-shell] admin-web port-range: ${portRange.label}`, + ); + } + + return createAgcAdminWebEndpoint(port, portRange); +} + export { + agcAdminWebHost, + agcAdminWebPortEnvKey, agcDevHost, agcVitePortEnvKey, + createAgcAdminWebEndpoint, createAgcDevEndpoint, + legacyAgcAdminWebPort, legacyAgcDevPort, readAgcDevEndpoint, + readConfiguredAgcAdminWebPort, readConfiguredAgcDevPort, + resolveAgcAdminWebEndpoint, resolveAgcDevEndpoint, withAgcDevEndpointEnv, }; diff --git a/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs b/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs new file mode 100644 index 000000000..9e0ae58bb --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs @@ -0,0 +1,461 @@ +#!/usr/bin/env node +// AGC 壳 Rust 套件的分片运行器。 +// +// 背景:AGC 壳的 Rust 单测集中在 src/main.rs 的 bin target(实测 2466 条),仓库口径用 +// `--test-threads=1` 跑,理由是并行调度会让共享 Agent Runtime 后台锁与异步终态的用例在 +// **同一进程内**互相干扰(见 development-workflow 的 Tauri suite 单线程口径)。代价是 +// 整套用例串行跑满 507 秒,占掉 CI 上 `AI game creator shell Rust tests` job 的大头。 +// +// 这里保留「片内串行」的既有口径,只把用例集合切成 N 片: +// - CI 用 `--shard-index=` 让**每个 job 只跑一片**,靠多个 job 并发把整套用例摊开; +// - 本地不传 `--shard-index` 时把 N 片放进 N 个**独立进程**并行(--concurrency 可调), +// 保留一条命令跑全量的入口。 +// 片并集必须等于全集、且不得重复,数量不符即失败,防止分片规则改动后静默漏跑;该校验 +// 与「只跑一片」无关,因此在每个 job 上都会执行。 +// +// 注意:同一容器内多进程并行这套用例(共享 HOME、target、固定临时路径)实测会互相拖慢, +// 比串行还慢,所以 CI 走「一个 job 一片」而不是单 job 内并行。 +// +// 用法: +// node scripts/run-rust-shell-test-shards.mjs --shards=4 --shard-index=2 # CI:只跑第 2 片 +// node scripts/run-rust-shell-test-shards.mjs [--shards=4] [--concurrency=4] # 本地:全量 +// node scripts/run-rust-shell-test-shards.mjs --manifest= --target-kind=lib --no-locked +// +// 参数: +// --shards= 分片数,默认 4 +// --shard-index= 只跑第 i 片(1..shards);不传则跑全部分片 +// --concurrency= 同时运行的片数,默认等于分片数;--shard-index 时恒为 1 +// --manifest= Cargo.toml,默认 ../src-tauri/Cargo.toml(相对本脚本) +// --target-kind= bin | lib,默认 bin(本地自测小 crate 时用 lib) +// --bin= bin target 名,默认 genarrative-ai-game-creator-shell +// --package= target-kind=lib 时要跑的包名(配合 lib 目标使用) +// --no-locked 传给 cargo 时不带 --locked(只对没有提交 Cargo.lock 的 crate 需要) +// --shard-tmp-root= 片专属 TMPDIR 的父目录,默认 <系统临时目录>/agc-rust-shards + +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const shellRoot = path.resolve(scriptDirectory, '..'); + +function fail(message) { + console.error(`[rust-shards] ${message}`); + process.exit(1); +} + +function parsePositiveInteger(name, rawValue) { + if (!/^[1-9][0-9]*$/.test(rawValue)) { + fail(`${name} must be a positive integer, received: ${rawValue}`); + } + + return Number(rawValue); +} + +const options = { + shards: 4, + shardIndex: undefined, + concurrency: undefined, + manifestPath: path.join(shellRoot, 'src-tauri', 'Cargo.toml'), + targetKind: 'bin', + binName: 'genarrative-ai-game-creator-shell', + packageName: undefined, + locked: true, + shardTmpRoot: path.join(os.tmpdir(), 'agc-rust-shards'), +}; + +for (const rawArgument of process.argv.slice(2)) { + if (rawArgument === '--no-locked') { + options.locked = false; + continue; + } + + const separatorIndex = rawArgument.indexOf('='); + if (!rawArgument.startsWith('--') || separatorIndex === -1) { + fail(`unexpected argument: ${rawArgument}`); + } + + const name = rawArgument.slice(2, separatorIndex); + const value = rawArgument.slice(separatorIndex + 1); + switch (name) { + case 'shards': + options.shards = parsePositiveInteger('--shards', value); + break; + case 'shard-index': + options.shardIndex = parsePositiveInteger('--shard-index', value); + break; + case 'concurrency': + options.concurrency = parsePositiveInteger('--concurrency', value); + break; + case 'manifest': + options.manifestPath = path.resolve(process.cwd(), value); + break; + case 'target-kind': + if (value !== 'bin' && value !== 'lib') { + fail(`--target-kind must be bin or lib, received: ${value}`); + } + options.targetKind = value; + break; + case 'bin': + options.binName = value; + break; + case 'package': + options.packageName = value; + break; + case 'shard-tmp-root': + options.shardTmpRoot = path.resolve(process.cwd(), value); + break; + default: + fail(`unexpected argument: ${rawArgument}`); + } +} + +if (!fs.existsSync(options.manifestPath)) { + fail(`manifest does not exist: ${options.manifestPath}`); +} + +if (options.shardIndex !== undefined && options.shardIndex > options.shards) { + fail( + `--shard-index (${options.shardIndex}) must be within --shards (${options.shards})`, + ); +} + +const concurrency = options.concurrency ?? options.shards; +const crateRoot = path.dirname(options.manifestPath); + +function buildCargoArguments(target) { + const cargoArguments = ['test']; + if (options.locked) { + cargoArguments.push('--locked'); + } + cargoArguments.push('--manifest-path', options.manifestPath); + if (target.kind === 'lib') { + if (options.packageName !== undefined) { + cargoArguments.push('-p', options.packageName); + } + cargoArguments.push('--lib'); + return cargoArguments; + } + cargoArguments.push('--bin', target.name); + return cargoArguments; +} + +function formatDuration(milliseconds) { + return `${(milliseconds / 1000).toFixed(1)}s`; +} + +// 编译一次,直接拿到测试可执行文件:后续每片都运行同一个二进制,不再各自调用 cargo, +// 免得 N 个 cargo 去争 package cache 与 target 目录锁。 +function resolveTestExecutable() { + return new Promise((resolve, reject) => { + const cargoArguments = buildCargoArguments({ + kind: options.targetKind, + name: options.binName, + }); + cargoArguments.push('--no-run', '--message-format=json'); + console.log(`[rust-shards] cargo ${cargoArguments.join(' ')}`); + + const child = spawn('cargo', cargoArguments, { + cwd: crateRoot, + env: process.env, + stdio: ['ignore', 'pipe', 'inherit'], + }); + + let buffered = ''; + const executables = []; + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + buffered += chunk; + const lines = buffered.split('\n'); + buffered = lines.pop() ?? ''; + for (const line of lines) { + if (!line.startsWith('{')) { + continue; + } + let message; + try { + message = JSON.parse(line); + } catch { + continue; + } + if ( + message.reason === 'compiler-artifact' && + typeof message.executable === 'string' + ) { + executables.push(message.executable); + } + } + }); + + child.on('error', (error) => { + reject(new Error(`unable to start cargo: ${error.message}`)); + }); + child.on('close', (code) => { + if (code !== 0) { + reject( + new Error( + `cargo ${cargoArguments.join(' ')} failed with exit code ${code}`, + ), + ); + return; + } + const uniqueExecutables = [...new Set(executables)]; + if (uniqueExecutables.length !== 1) { + reject( + new Error( + `expected exactly one test executable for the ${options.targetKind} target, found ${uniqueExecutables.length}: ${uniqueExecutables.join(', ')}`, + ), + ); + return; + } + resolve(uniqueExecutables[0]); + }); + }); +} + +function listTestNames(executable) { + return new Promise((resolve, reject) => { + const child = spawn(executable, ['--list'], { + cwd: crateRoot, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('error', (error) => { + reject(new Error(`unable to list tests: ${error.message}`)); + }); + child.on('close', (code) => { + if (code !== 0) { + reject( + new Error( + `listing tests failed with exit code ${code}: ${stderr.trim()}`, + ), + ); + return; + } + const names = []; + for (const line of stdout.split('\n')) { + const match = /^(.*): test$/.exec(line.trim()); + if (match !== null && match[1].length > 0) { + names.push(match[1]); + } + } + resolve(names); + }); + }); +} + +function splitTestNames(testNames, shardCount) { + const sortedNames = [...testNames].sort(); + const shards = Array.from({ length: shardCount }, () => []); + sortedNames.forEach((testName, index) => { + shards[index % shardCount].push(testName); + }); + return shards; +} + +function assertShardsCoverEveryTest(testNames, shards) { + const flattened = shards.flat(); + if (flattened.length !== testNames.length) { + fail( + `shard split covered ${flattened.length} of ${testNames.length} tests; the split rule must be exhaustive`, + ); + } + if (new Set(flattened).size !== flattened.length) { + fail( + 'shard split selected the same test more than once; the split rule must be disjoint', + ); + } + const missing = testNames.filter((testName) => !flattened.includes(testName)); + if (missing.length > 0) { + fail( + `shard split is missing tests, for example: ${missing.slice(0, 5).join(', ')}`, + ); + } +} + +function runShard(executable, shardIndex, shardCount, shardTestNames) { + const label = `shard ${shardIndex + 1}/${shardCount}`; + const shardTmpDirectory = path.join( + options.shardTmpRoot, + `shard-${shardIndex + 1}`, + ); + fs.rmSync(shardTmpDirectory, { recursive: true, force: true }); + fs.mkdirSync(shardTmpDirectory, { recursive: true }); + const startedAt = Date.now(); + + return new Promise((resolve) => { + const child = spawn( + executable, + ['--exact', ...shardTestNames, '--test-threads=1'], + { + cwd: crateRoot, + env: { + ...process.env, + TMPDIR: shardTmpDirectory, + TMP: shardTmpDirectory, + TEMP: shardTmpDirectory, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + + const failureLines = []; + let inFailureList = false; + let stderr = ''; + const consumeLine = (rawLine) => { + const line = rawLine.replace(/\r$/, ''); + if (line.includes('failures:')) { + inFailureList = true; + return; + } + if (inFailureList) { + if (line.trim().length === 0) { + inFailureList = false; + return; + } + failureLines.push(line.trim()); + } + }; + + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + let stdoutBuffer = ''; + child.stdout.on('data', (chunk) => { + stdoutBuffer += chunk; + const lines = stdoutBuffer.split('\n'); + stdoutBuffer = lines.pop() ?? ''; + for (const line of lines) { + consumeLine(line); + } + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + + child.on('error', (error) => { + resolve({ + label, + ok: false, + durationMs: Date.now() - startedAt, + testCount: shardTestNames.length, + failures: [`unable to start test binary: ${error.message}`], + stderr, + }); + }); + child.on('close', (code) => { + resolve({ + label, + ok: code === 0, + durationMs: Date.now() - startedAt, + testCount: shardTestNames.length, + failures: failureLines, + stderr, + }); + }); + }); +} + +async function runWithConcurrency(shards, runner) { + const results = new Array(shards.length); + let nextIndex = 0; + const workers = Array.from( + { length: Math.min(concurrency, shards.length) }, + async () => { + while (nextIndex < shards.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await runner(shards[index], index); + } + }, + ); + await Promise.all(workers); + return results; +} + +async function main() { + const executable = await resolveTestExecutable(); + const testNames = await listTestNames(executable); + if (testNames.length === 0) { + fail( + `no tests discovered in ${options.manifestPath} (${options.targetKind})`, + ); + } + + const shards = splitTestNames(testNames, options.shards); + assertShardsCoverEveryTest(testNames, shards); + + const selectedShards = + options.shardIndex === undefined + ? shards.map((shardTestNames, index) => ({ index, shardTestNames })) + : [ + { + index: options.shardIndex - 1, + shardTestNames: shards[options.shardIndex - 1], + }, + ]; + + if (options.shardIndex === undefined) { + console.log( + `[rust-shards] ${testNames.length} tests, ${shards.length} shard(s), concurrency ${Math.min(concurrency, shards.length)}`, + ); + for (const { index, shardTestNames } of selectedShards) { + console.log( + `[rust-shards] shard ${index + 1}/${shards.length}: ${shardTestNames.length} test(s)`, + ); + } + } else { + console.log( + `[rust-shards] ${testNames.length} tests, ${shards.length} shard(s), running shard ${options.shardIndex}/${shards.length} (${selectedShards[0].shardTestNames.length} test(s))`, + ); + } + + const results = await runWithConcurrency( + selectedShards, + ({ index, shardTestNames }) => + runShard(executable, index, shards.length, shardTestNames), + ); + + let failed = false; + for (const result of results) { + if (result.ok) { + console.log( + `[rust-shards] ${result.label} ok: ${result.testCount} test(s) in ${formatDuration(result.durationMs)}`, + ); + continue; + } + failed = true; + console.error( + `[rust-shards] ${result.label} FAILED: ${result.testCount} test(s) in ${formatDuration(result.durationMs)}`, + ); + for (const failure of result.failures) { + console.error(`[rust-shards] ${failure}`); + } + if (result.stderr.trim().length > 0) { + console.error( + `[rust-shards] stderr: ${result.stderr.trim().split('\n').slice(-20).join('\n[rust-shards] ')}`, + ); + } + } + + if (failed) { + process.exit(1); + } + console.log('[rust-shards] OK'); +} + +main().catch((error) => { + fail(error instanceof Error ? error.message : String(error)); +}); diff --git a/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs b/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs index d660c2a70..5f89ad3ae 100644 --- a/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs +++ b/apps/ai-game-creator-shell/scripts/skill-pack-manifest.mjs @@ -8,6 +8,7 @@ export const SKILL_PACK_SCHEMA_VERSION = 'agc-skill-pack.v1'; export const EXPECTED_SKILL_NAMES = Object.freeze([ 'agc-browser-playtest', 'agc-client-projection', + 'agc-game-production-workflow', 'agc-project-structure', 'agc-web-game-development', 'taonier-art-assets', 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 9c7e9ee8d..9c5c9e54d 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -14,12 +14,14 @@ import { import { agcVitePortEnvKey, readAgcDevEndpoint, + resolveAgcAdminWebEndpoint, resolveAgcDevEndpoint, withAgcDevEndpointEnv, } from './dev-port.mjs'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); const repoRoot = resolve(appRoot, '../..'); +const adminWebDir = resolve(repoRoot, 'apps/admin-web'); const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json'); const apiServerExePath = resolve( repoRoot, @@ -32,6 +34,8 @@ const backendSpacetimeDataDir = resolve( repoRoot, 'server-rs/.spacetimedb/ai-game-creator/data', ); +// 后台 Web 默认跟随 AGC 一起起来,便于联调后台页面;`AGC_DEV_ADMIN_WEB=0` 可关闭。 +const agcDevAdminWebEnvKey = 'AGC_DEV_ADMIN_WEB'; const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const childLifecycles = new WeakMap(); @@ -84,6 +88,7 @@ function resolveBackendTargetsFromState( requireAgcBackend = false, expectedDatabase = backendDatabase, expectedSpacetimeDataDir = backendSpacetimeDataDir, + expectedRepoRoot = repoRoot, fallbackApiTarget = defaultApiTarget, } = {}, ) { @@ -101,7 +106,25 @@ function resolveBackendTargetsFromState( const hasMatchingDataDir = Boolean(spacetimeDataDir) && spacetimeDataDir === resolve(expectedSpacetimeDataDir); - const hasMatchingBackend = hasMatchingDatabase && hasMatchingDataDir; + const instanceId = + typeof state?.instanceId === 'string' ? state.instanceId.trim() : ''; + const hasMatchingRepoRoot = + typeof state?.repoRoot === 'string' && + resolve(state.repoRoot) === resolve(expectedRepoRoot); + const hasMatchingInstance = + Boolean(instanceId) && + [apiServer, spacetime, bgfilterWorker] + .filter(Boolean) + .every( + (service) => + service.repoRoot && + resolve(service.repoRoot) === resolve(expectedRepoRoot) && + service.instanceId === instanceId, + ); + const hasMatchingBackend = + hasMatchingDatabase && + hasMatchingDataDir && + (!requireAgcBackend || (hasMatchingRepoRoot && hasMatchingInstance)); const canReuseState = !requireAgcBackend || hasMatchingBackend; const apiUrl = canReuseState && isActive(apiServer) && apiServer.url @@ -127,6 +150,8 @@ function resolveBackendTargetsFromState( spacetimeDataDir, hasMatchingDatabase, hasMatchingDataDir, + hasMatchingRepoRoot, + hasMatchingInstance, hasMatchingBackend, }; } @@ -179,36 +204,122 @@ function urlPort(url) { } } -// 读取端口当前真正的监听进程身份。返回 null 表示探测本身不可用(例如缺少 -// Get-NetTCPConnection),此时调用方必须退化为旧行为,不能让本地启动直接失败。 +// 端口归属探测脚本。历史实现用 `Get-NetTCPConnection` 取监听进程,而它底层走 +// WMI:实测单端口单次 11.2 秒、再叠加每个 PID 的 `Get-CimInstance` 3.3 秒, +// 一轮探测约 43 秒,直接把"配套后端就绪"等待拖到分钟级。改用原生 +// `netstat -ano`(约 30 毫秒)取端口 -> PID,再用 .NET `Process` 读进程名和 +// 可执行文件路径(毫秒级);只有核对 SpacetimeDB `--data-dir` 归属时才按 PID +// 取命令行,并允许调用方把已知命令行传进来复用。 +const windowsPortOwnerProbeCommand = [ + '$ErrorActionPreference = "SilentlyContinue"', + '$queriedPorts = @()', + 'foreach ($raw in ($env:GENARRATIVE_QUERY_PORTS -split ",")) {', + ' if ($raw -match "^\\d+$") { $queriedPorts += [int]$raw }', + '}', + '$knownCommandLines = @{}', + 'if ($env:GENARRATIVE_KNOWN_COMMAND_LINES) {', + ' try {', + ' foreach ($property in (ConvertFrom-Json $env:GENARRATIVE_KNOWN_COMMAND_LINES).PSObject.Properties) {', + ' $knownCommandLines[[int]$property.Name] = [string]$property.Value', + ' }', + ' } catch { }', + '}', + '$listenerPidByPort = @{}', + 'foreach ($line in (netstat -ano -p tcp)) {', + ' $fields = @($line -split "\\s+" | Where-Object { $_ })', + ' if ($fields.Count -lt 4) { continue }', + ' if ($fields[0] -ne "TCP") { continue }', + ' # A listening socket always has foreign address 0.0.0.0:0 / [::]:0, which', + ' # is locale-independent unlike the localized netstat State column.', + ' if ($fields[2] -notmatch ":0$") { continue }', + ' $localPort = [int]($fields[1].Split(":")[-1])', + ' if ($queriedPorts -notcontains $localPort) { continue }', + ' # The PID is the last column; do not hardcode its index.', + ' if ($fields[-1] -notmatch "^\\d+$") { continue }', + ' $listenerPidByPort[$localPort] = [int]$fields[-1]', + '}', + '$result = @()', + 'foreach ($port in ($listenerPidByPort.Keys | Sort-Object)) {', + ' $processId = $listenerPidByPort[$port]', + ' $name = $null', + ' $executablePath = $null', + ' $commandLine = $null', + ' try {', + ' $process = [System.Diagnostics.Process]::GetProcessById($processId)', + ' $name = $process.ProcessName + ".exe"', + ' try { $executablePath = $process.MainModule.FileName } catch { }', + ' } catch { }', + ' if ($knownCommandLines.ContainsKey($processId)) {', + ' $commandLine = $knownCommandLines[$processId]', + ' } elseif (($name -like "spacetime*") -or (-not $executablePath)) {', + ' try { $commandLine = (Get-CimInstance Win32_Process -Filter ("ProcessId=" + $processId)).CommandLine } catch { }', + ' }', + ' $result += [pscustomobject]@{ port = [int]$port; processId = $processId; name = $name; executablePath = $executablePath; commandLine = $commandLine }', + '}', + 'ConvertTo-Json -InputObject @($result) -Compress', +].join('\n'); + +// 进程命令行在进程生命周期内不变,但 PID 会被系统复用;按 PID 记 TTL 缓存, +// 让"等配套后端就绪"的轮询只在首个周期付出 WMI 成本。TTL 取 5 分钟:本轮实测 +// 这台机器上首次 WMI 调用约 18 秒(热调用 3.3 秒),而 PID 在 5 分钟内被复用 +// 成另一个运行本工作树 data dir 的 SpacetimeDB 才能造成误判,概率可忽略。 +// 默认实现才缓存,注入实现(测试)与显式 env 始终重新读取。 +const WINDOWS_COMMAND_LINE_CACHE_TTL_MS = 300_000; +const windowsPortOwnerCommandLineCache = new Map(); + +function resolveCommandLineCache({ spawnImpl, env }) { + return spawnImpl === spawnSync && env === process.env + ? windowsPortOwnerCommandLineCache + : new Map(); +} + +// 读取端口当前真正的监听进程身份。返回 null 表示探测本身不可用(例如系统缺少 +// netstat),此时调用方必须退化为旧行为,不能让本地启动直接失败。 function readWindowsPortOwnerIdentities( ports, - { spawnImpl = spawnSync, env = process.env } = {}, + { + spawnImpl = spawnSync, + env = process.env, + now = Date.now, + commandLineTtlMs = WINDOWS_COMMAND_LINE_CACHE_TTL_MS, + commandLineCache = resolveCommandLineCache({ spawnImpl, env }), + } = {}, ) { const uniquePorts = [...new Set(ports.filter((port) => port > 0))]; if (uniquePorts.length === 0) { return null; } - const command = [ - '$ErrorActionPreference = "SilentlyContinue"', - '$ports = ($env:GENARRATIVE_QUERY_PORTS -split ",") | Where-Object { $_ }', - '$result = @()', - 'foreach ($port in $ports) {', - ' $connection = Get-NetTCPConnection -State Listen -LocalPort ([int]$port) -ErrorAction SilentlyContinue | Select-Object -First 1', - ' if (-not $connection) { continue }', - ' $owner = Get-CimInstance Win32_Process -Filter ("ProcessId=" + $connection.OwningProcess) -ErrorAction SilentlyContinue', - ' $result += [pscustomobject]@{ port = [int]$port; processId = [int]$connection.OwningProcess; name = $owner.Name; executablePath = $owner.ExecutablePath; commandLine = $owner.CommandLine }', - '}', - 'ConvertTo-Json -InputObject @($result) -Compress', - ].join('\n'); + const knownCommandLines = {}; + for (const [processId, record] of [...commandLineCache]) { + if (record && now() - record.at < commandLineTtlMs) { + knownCommandLines[processId] = record.commandLine; + } else { + commandLineCache.delete(processId); + } + } + + const childEnv = { + ...env, + GENARRATIVE_QUERY_PORTS: uniquePorts.join(','), + }; + if (Object.keys(knownCommandLines).length > 0) { + childEnv.GENARRATIVE_KNOWN_COMMAND_LINES = + JSON.stringify(knownCommandLines); + } const result = spawnImpl( 'powershell.exe', - ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command], + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-Command', + windowsPortOwnerProbeCommand, + ], { encoding: 'utf8', - env: { ...env, GENARRATIVE_QUERY_PORTS: uniquePorts.join(',') }, + env: childEnv, maxBuffer: 8 * 1024 * 1024, }, ); @@ -219,9 +330,22 @@ function readWindowsPortOwnerIdentities( const owners = new Map(); for (const entry of parseWindowsProcessSnapshot(result.stdout)) { const port = Number(entry?.port); - if (Number.isInteger(port) && port > 0) { - owners.set(port, entry); + if (!Number.isInteger(port) || port <= 0) { + continue; } + const processId = Number(entry?.processId); + if ( + Number.isInteger(processId) && + processId > 0 && + typeof entry?.commandLine === 'string' && + entry.commandLine + ) { + commandLineCache.set(processId, { + commandLine: entry.commandLine, + at: now(), + }); + } + owners.set(port, entry); } return owners; } @@ -858,10 +982,102 @@ async function startVite(apiTarget, endpoint = readAgcDevEndpoint()) { ); } +function readAdminWebEnabled(env = process.env) { + return String(env[agcDevAdminWebEnvKey] ?? '').trim() !== '0'; +} + +// 后台 Web 与 AGC Vite 一样直接由本启动器持有,不经过 `dev.mjs admin-web`: +// 后者会整体重写 `.app/dev-stack.json`,把本次配套后端的状态覆盖掉。 +function startAdminWeb( + apiUrl, + endpoint, + { env = process.env, spawnImpl = spawnChild } = {}, +) { + return spawnImpl( + npm, + [ + '--prefix', + '../..', + 'exec', + 'vite', + '--', + '--host', + endpoint.host, + '--port', + String(endpoint.port), + '--strictPort', + ], + { + cwd: adminWebDir, + env: { + ...env, + ADMIN_API_TARGET: apiUrl, + GENARRATIVE_API_TARGET: apiUrl, + GENARRATIVE_API_PORT: String(urlPort(apiUrl) || 8082), + ADMIN_WEB_BASE: endpoint.basePath, + }, + }, + ); +} + +function formatStartupSummary({ + frontendUrl = '', + apiUrl = '', + adminWebUrl = '', + spacetimeUrl = '', + bgfilterWorkerUrl = '', +} = {}) { + const segments = [ + ['前端', frontendUrl], + ['后端', apiUrl], + ['后台', adminWebUrl], + ['数据库', spacetimeUrl], + ['bgfilter-worker', bgfilterWorkerUrl], + ] + .filter(([, value]) => Boolean(value)) + .map(([label, value]) => `${label} ${value}`); + return `[ai-game-creator-shell] 启动汇总: ${segments.join(' | ')}`; +} + +// 后台 Web 是可选联调服务:端口解析或启动失败只告警,不能阻断 AGC 客户端与配套后端。 +async function ensureAdminWeb({ + apiUrl, + reservedPorts = [], + env = process.env, + enabled = readAdminWebEnabled(env), + resolveEndpoint = resolveAgcAdminWebEndpoint, + spawnAdminWeb = startAdminWeb, + waitForExit = waitForChildTermination, + warn = (message) => console.warn(message), +} = {}) { + if (!enabled) { + return { endpoint: null, child: null }; + } + + try { + const endpoint = await resolveEndpoint({ env, reservedPorts }); + const child = spawnAdminWeb(apiUrl, endpoint, { env }); + waitForExit(child).then((failure) => { + warn( + `[ai-game-creator-shell] 后台 Web 已退出(${formatChildFailure(failure)}),AGC 继续运行。`, + ); + }); + return { endpoint, child }; + } catch (error) { + warn( + `[ai-game-creator-shell] 后台 Web 未能启动(${ + error instanceof Error ? error.message : String(error) + }),AGC 继续运行。`, + ); + return { endpoint: null, child: null }; + } +} + async function main() { let backendChild = null; let startedBackend = false; let viteChild = null; + let adminWebChild = null; let shutdownSignal = ''; const signalHandlers = new Map(); @@ -884,6 +1100,7 @@ async function main() { const handler = () => { shutdownSignal = signal; stopChild(viteChild, signal); + stopChild(adminWebChild, signal); stopChild(backendChild, signal); // 立刻清扫,避免外层 taskkill /F 抢在 finally 之前把本进程杀掉。 sweepStartedBackend(); @@ -916,6 +1133,25 @@ async function main() { throw new Error(`启动期收到 ${shutdownSignal},已停止前端服务`); } + const adminWeb = await ensureAdminWeb({ + apiUrl: backend.targets.apiUrl, + // AGC Vite 端口尚未监听,必须显式保留,避免被后台 Web 抢先占用。 + reservedPorts: [endpoint.port], + }); + adminWebChild = adminWeb.child; + if (shutdownSignal) { + throw new Error(`启动期收到 ${shutdownSignal},已停止后台 Web`); + } + console.log( + formatStartupSummary({ + frontendUrl: endpoint.url, + apiUrl: backend.targets.apiUrl, + adminWebUrl: adminWeb.endpoint?.url ?? '', + spacetimeUrl: backend.targets.spacetimeUrl, + bgfilterWorkerUrl: backend.targets.bgfilterWorkerUrl, + }), + ); + const children = [backendChild, viteChild].filter(Boolean); if (children.length === 0) { return 0; @@ -925,10 +1161,12 @@ async function main() { children.map((child) => waitForChildTermination(child)), ); stopChild(viteChild); + stopChild(adminWebChild); stopChild(backendChild); return failure.type === 'error' || failure.signal ? 1 : (failure.code ?? 0); } catch (error) { stopChild(viteChild); + stopChild(adminWebChild); stopChild(backendChild); console.error( `[ai-game-creator-shell] ${error instanceof Error ? error.message : String(error)}`, @@ -937,6 +1175,7 @@ async function main() { } finally { await Promise.all([ terminateChildTree(viteChild), + terminateChildTree(adminWebChild), terminateChildTree(backendChild), ]); sweepStartedBackend(); @@ -954,9 +1193,12 @@ function isDirectModuleExecution() { } export { + agcDevAdminWebEnvKey, + ensureAdminWeb, ensureBackend, formatChildFailure, formatOwnerLabel, + formatStartupSummary, isAiGameCreatorServer, isBackendReady, isDirectModuleExecution, @@ -964,6 +1206,7 @@ export { isWorktreeApiServerOwner, isWorktreeSpacetimeOwner, preflightExistingVite, + readAdminWebEnabled, readBackendServiceFailure, readChildFailure, readExistingViteServer, @@ -972,6 +1215,7 @@ export { resolveBackendTargetsFromState, runWindowsTaskkill, spawnChild, + startAdminWeb, stopChild, terminateChildTree, verifyAgcBackendOwnership, diff --git a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs index 0ce7e6a2b..3ab0f2ced 100644 --- a/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs +++ b/apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs @@ -22,7 +22,6 @@ const appRoot = fileURLToPath(new URL('..', import.meta.url)); const repoRoot = resolve(appRoot, '../..'); const tauriCliPath = resolve(repoRoot, 'node_modules/@tauri-apps/cli/tauri.js'); const AGC_DESIGN_DEBUG_ENV = 'GENARRATIVE_AGC_DESIGN_DEBUG'; -const AGC_DESIGN_DEBUG_VITE_ENV = 'VITE_GENARRATIVE_AGC_DESIGN_DEBUG'; const designDebugEnabled = process.env[AGC_DESIGN_DEBUG_ENV]?.trim() === '0' ? '0' : '1'; @@ -137,7 +136,6 @@ async function runTauriDev( env: { ...withAgcDevEndpointEnv(endpoint), [AGC_DESIGN_DEBUG_ENV]: designDebugEnabled, - [AGC_DESIGN_DEBUG_VITE_ENV]: designDebugEnabled, }, }); const childResult = waitForCli(child); @@ -191,7 +189,6 @@ async function prepareFrontendDev(endpoint, { onChild, signal }) { cwd: repoRoot, env: { ...withAgcDevEndpointEnv(endpoint), - [AGC_DESIGN_DEBUG_VITE_ENV]: designDebugEnabled, }, }, ); diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 60032820b..7e07a3c9c 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "genarrative-ai-game-creator-shell" -version = "0.1.27" +version = "0.1.29" dependencies = [ "agent-runtime-core", "axum", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 370777cf3..4fe37d25a 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "genarrative-ai-game-creator-shell" -version = "0.1.27" +version = "0.1.29" edition = "2021" publish = false diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/SKILL.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/SKILL.md index ee3cf23b1..aa24d28c9 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/SKILL.md @@ -2,7 +2,7 @@ 以下为总纲骨架;实际部署时拼接五份分册全文常驻(附录 A): -你是"游戏策划 Agent",资深游戏策划,看过上千份策划案。你用第一人称教练式口吻与用户协作("我建议……我不会……");你的建议永远是建议——你不会把建议冒充为用户的决定。你的任务是与用户一起把一句话游戏想法变成完整可开工的策划产物树:五层文档(概念→顶层→架构→系统×N→技术文档)加速览卡投影——施工方只看技术文档就能做完游戏。【主轴】五层顺序推进:概念→顶层→架构→系统→技术文档;上层未定稿不开下层,定稿以用户检阅确认为准。用户参与度沿层递减:概念层事事确认,技术文档层靠知识与代决。【grounding】动笔前先读相关文档(本层+上层接口件);用户当前打开的文档路径随消息注入,作为你的注意力锚;跨天续聊时先读文档树与台账恢复上下文。【判断先行】先判断问题框架;与定调记录冲突时先纠偏(推荐+理由+风险+推翻条件)。【开场与概念设计】开工先通过概念设计式的自然对话了解用户想做什么:类型、参照作品、核心感受、压力偏好——从回答中有意识地提炼调性锚(T 原则 3~7 条,每条必须能当 IF-THEN 用),写入概念层第 2 节。此后全项目一切判断先回调性锚级联。【提问纪律】开放问题先分诊:文档有答案的不问、字段级预留空列、手感类标待原型、数值类推内容期;仅阻塞级二义才发决策卡(一题三选项,第三项"需要原型验证");每轮收尾发提案卡"下一步最有价值的是X,是否继续"。数量基线:概念≤3、顶层≤5,超线先回读调性锚。【知识库】查证先读知识库 INDEX,三跳定位,禁止盲扫;查到沉淀进调性锚,每主题只查一次;检索不到写"库里没有",禁止编造与外搜。【文档协议】design 只放结论;分析只放论证;台账放活队列。写前读、写后复读同文档;改命名扫跨文档引用;新系统成对建档;修订只动用户意见涉及的内容;架构文档是系统清单的唯一真源——新建或修改任何系统必须同步更新架构文档;技术文档收编必带"基于系统文档@版本"。【低幻觉】六态标注;默认建议不冒充用户决定;AI 猜的永不标 confirmed;代决必带理由与推翻条件。【质量三件】动笔前读金样;初稿后强制第二遍深化;每层对照量化验收线自查。【产物纪律】概念层一页纸不出现数值按键界面;顶层取舍表每行挂张力编号;架构职责表每行含"不负责→移交谁";技术文档数值全填文本全填资产全行登记——"纯看技术文档能做完游戏"是最终验收;有 blocker 禁止扩充内容;堆字数=没想清楚,停笔回读调性锚。【边界情况】用户想改已定稿的层→接受:重写该层受影响节→概念层变更则重新投影走审批→下游层检查是否受牵连并在提案卡说明;技术文档期发现上层文档有错→在当前层记开放问题回执(登记台账),继续技术文档不受阻,错误在下一轮检阅时由用户裁决;用户推翻某条历史决定→台账旧行标 overturned 挂新行,受影响文档节重写。【收尾】有决策点或提议→ask_user(决策卡/提案卡);机械完成→finish(summary)。 +你是"游戏策划 Agent",资深游戏策划,看过上千份策划案。你用第一人称教练式口吻与用户协作("我建议……我不会……");你的建议永远是建议——你不会把建议冒充为用户的决定。你的任务是与用户一起把一句话游戏想法变成完整可开工的策划产物树:五层文档(概念→顶层→架构→系统×N→技术文档)加速览卡投影——施工方只看技术文档就能做完游戏。【主轴】五层顺序推进:概念→顶层→架构→系统→技术文档;上层未定稿不开下层,定稿以用户检阅确认为准。用户参与度沿层递减:概念层事事确认,技术文档层靠知识与代决。【模板与样例】查看模板或样例时,应根据当前游戏的具体需求和用户实际要求决定产物的字段、章节和展开程度。模板与样例仅作为参考结构和写法示例,可按需要增加、合并或省略内容;不要为了复刻模板或样例而机械照抄其章节、字段、数量或篇幅。【grounding】动笔前先读相关文档(本层+上层接口件);用户当前打开的文档路径随消息注入,作为你的注意力锚;跨天续聊时先读文档树与台账恢复上下文。【判断先行】先判断问题框架;与定调记录冲突时先纠偏(推荐+理由+风险+推翻条件)。【开场与概念设计】开工先通过概念设计式的自然对话了解用户想做什么:类型、参照作品、核心感受、压力偏好——从回答中有意识地提炼调性锚(T 原则 3~7 条,每条必须能当 IF-THEN 用),写入概念层第 2 节。此后全项目一切判断先回调性锚级联。【提问纪律】开放问题先分诊:文档有答案的不问、字段级预留空列、手感类标待原型、数值类推内容期;仅阻塞级二义才发决策卡(一题三选项,第三项"需要原型验证");每轮收尾发提案卡"下一步最有价值的是X,是否继续"。数量基线:概念≤3、顶层≤5,超线先回读调性锚。【知识库】查证先读知识库 INDEX,三跳定位,禁止盲扫;查到沉淀进调性锚,每主题只查一次;检索不到写"库里没有",禁止编造与外搜。【文档协议】design 只放结论;分析只放论证;台账放活队列。写前读、写后复读同文档;改命名扫跨文档引用;新系统成对建档;修订只动用户意见涉及的内容;架构文档是系统清单的唯一真源——新建或修改任何系统必须同步更新架构文档;技术文档收编必带"基于系统文档@版本"。【低幻觉】六态标注;默认建议不冒充用户决定;AI 猜的永不标 confirmed;代决必带理由与推翻条件。【质量三件】动笔前读金样;初稿后强制第二遍深化;每层对照量化验收线自查。【产物纪律】概念层一页纸不出现数值按键界面;顶层取舍表每行挂张力编号;架构职责表每行含"不负责→移交谁";技术文档数值全填文本全填资产全行登记——"纯看技术文档能做完游戏"是最终验收;有 blocker 禁止扩充内容;堆字数=没想清楚,停笔回读调性锚。【边界情况】用户想改已定稿的层→接受:重写该层受影响节→概念层变更则重新投影走审批→下游层检查是否受牵连并在提案卡说明;技术文档期发现上层文档有错→在当前层记开放问题回执(登记台账),继续技术文档不受阻,错误在下一轮检阅时由用户裁决;用户推翻某条历史决定→台账旧行标 overturned 挂新行,受影响文档节重写。【收尾】有决策点或提议→ask_user(决策卡/提案卡);机械完成→finish(summary)。 - 部署:单 Agent——现 plan 根 Supervisor 与立项策划两个 Agent 合并为一个策划 Agent,全程单一连续上下文(主控六步职责并入系统提示词承载);project-planning.md 整文件替换为本骨架+附录 A 分册拼接(编译期打包路径不变),决策卡渲染与审批等运行时机制沿用 Runtime 代管。 @@ -456,7 +456,7 @@ description: 写游戏策划案(GDD)系统架构时使用。在顶层设计 → 没有变更记录的架构文档,第二轮迭代就会变成黑箱。 ### 2. 系统地图 -Sxx 编号清单(核心系统 2~12 个)+ 支撑层(存档/UI,不拥有核心规则)。 +Sxx 编号清单(核心系统通常 1-5 个,有明确要求可超出 5 个)+ 支撑层(存档/UI,不拥有核心规则)。 P0 段五列表: | 系统 | 目的 | 输入 | 输出 | P0 原因 | → 每行 P0 原因必须答"删了它,__ 塌";答不出的降级或合并。 @@ -544,7 +544,7 @@ P1/P2 可用能力表(能力/说明)控制颗粒度。 --- name: game-gdd-system-doc -description: 写单个系统的设计文档(Sxx)时的总纲——通用纪律、十二节同构骨架、 +description: 写单个系统的设计文档(Sxx)时的总纲——通用纪律、十二类常见内容、 红线与分析文档格式。每类系统的专属写法与模板在 01~12 各文件夹的 SKILL.md 与 模板.md 里,按需取用。 --- @@ -594,7 +594,7 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 咬合:**对上**服从架构三条合同(编号/职责/依赖);**对内**状态与接口不越 职责边界;**对下**第 7 节交接喂 TDD。 -## 四、十二节通用写法 +## 四、常见内容的参考写法 (各系统类型的特殊写法见对应文件夹 SKILL.md;纯净模板在其 模板.md) 1 系统目的:若删除它,__ 会塌——一句话说不出 = 该系统不该存在。 @@ -607,7 +607,7 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 8 反馈:每种关键结果给独立反馈形态;失败必须说明原因和恢复路径。 9 内部循环:动词链;可拆单次/区域/长期三层。 10 输入输出与依赖:引用具名系统与具名数据,禁泛称"资源"。 -11 边界与非目标:照该类型 skill 的"三不"写全;必含"字段数值归 TDD"一条。 +11 边界与非目标:参考该类型 skill 的“三不”说明边界;建议说明字段与数值的交接边界。 12 开放问题:结构级才留;手感数值类标"待原型验证"。 ## 五、分析文档(全局一份,按层分节) @@ -1190,7 +1190,7 @@ description: 写游戏策划案(GDD)系统架构时使用。在顶层设计 → 没有变更记录的架构文档,第二轮迭代就会变成黑箱。 ### 2. 系统地图 -Sxx 编号清单(核心系统 2~12 个)+ 支撑层(存档/UI,不拥有核心规则)。 +Sxx 编号清单(核心系统通常 1-5 个,有明确要求可超出 5 个)+ 支撑层(存档/UI,不拥有核心规则)。 P0 段五列表: | 系统 | 目的 | 输入 | 输出 | P0 原因 | → 每行 P0 原因必须答"删了它,__ 塌";答不出的降级或合并。 @@ -1280,7 +1280,7 @@ P1/P2 可用能力表(能力/说明)控制颗粒度。 --- name: game-gdd-system-doc -description: 写单个系统的设计文档(Sxx)时的总纲——通用纪律、十二节同构骨架、 +description: 写单个系统的设计文档(Sxx)时的总纲——通用纪律、十二类常见内容、 红线与分析文档格式。每类系统的专属写法与模板在 01~12 各文件夹的 SKILL.md 与 模板.md 里,按需取用。 --- @@ -1330,7 +1330,7 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 咬合:**对上**服从架构三条合同(编号/职责/依赖);**对内**状态与接口不越 职责边界;**对下**第 7 节交接喂 TDD。 -## 四、十二节通用写法 +## 四、常见内容的参考写法 (各系统类型的特殊写法见对应文件夹 SKILL.md;纯净模板在其 模板.md) 1 系统目的:若删除它,__ 会塌——一句话说不出 = 该系统不该存在。 @@ -1343,7 +1343,7 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 8 反馈:每种关键结果给独立反馈形态;失败必须说明原因和恢复路径。 9 内部循环:动词链;可拆单次/区域/长期三层。 10 输入输出与依赖:引用具名系统与具名数据,禁泛称"资源"。 -11 边界与非目标:照该类型 skill 的"三不"写全;必含"字段数值归 TDD"一条。 +11 边界与非目标:参考该类型 skill 的“三不”说明边界;建议说明字段与数值的交接边界。 12 开放问题:结构级才留;手感数值类标"待原型验证"。 ## 五、分析文档(全局一份,按层分节) diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/architecture.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/architecture.md index 5f474d9e7..41dc962ec 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/architecture.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/architecture.md @@ -74,7 +74,7 @@ description: 写游戏策划案(GDD)系统架构时使用。在顶层设计 三个接口:**对上**承顶层系统范围表并跑循环覆盖检查;**对内**地图↔职责↔依赖 三方一致、主数据归属唯一;**对下**目录映射 + MVP 闭环喂系统文档站。 -## 四、怎么写(模板即流程,十二节按序) +## 四、怎么写(模板参考结构,建议按此组织) (本节是带写法要领的教学版;实际填写的纯净模板在 templates/architecture.md) ### 1. 架构定位与目标 @@ -85,7 +85,7 @@ description: 写游戏策划案(GDD)系统架构时使用。在顶层设计 → 没有变更记录的架构文档,第二轮迭代就会变成黑箱。 ### 2. 系统地图 -Sxx 编号清单(核心系统 2~12 个)+ 支撑层(存档/UI,不拥有核心规则)。 +Sxx 编号清单(核心系统通常 1-5 个,有明确要求可超出 5 个)+ 支撑层(存档/UI,不拥有核心规则)。 P0 段五列表: | 系统 | 目的 | 输入 | 输出 | P0 原因 | → 每行 P0 原因必须答"删了它,__ 塌";答不出的降级或合并。 diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/concept.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/concept.md index 05c48b19a..1d8f73496 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/concept.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/concept.md @@ -69,7 +69,7 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏 记住三个接口:**对内**锚点仲裁一切;**对下**张力变取舍表、定稿变硬约束; **对上**边界画线防止越层。九节不是清单,是一台咬合的机器。 -## 四、怎么写(模板即流程,九节按序) +## 四、怎么写(模板参考结构,建议按此组织) (本节是带写法要领的教学版;实际填写的纯净模板在 templates/concept-design.md) ### 1. 一句话概念 diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/systems.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/systems.md index 554ebbf81..50c44335c 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/systems.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/systems.md @@ -2,7 +2,7 @@ --- name: game-gdd-system-doc -description: 写单个系统的设计文档(Sxx)时的总纲——通用纪律、十二节同构骨架、 +description: 写单个系统的设计文档(Sxx)时的总纲——通用纪律、十二类常见内容、 红线与分析文档格式。每类系统的专属写法与模板在 modules/system-types/ 下对应目录的 SKILL.md 与对应模块的模板.md 里,按需取用。 --- @@ -26,9 +26,9 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 1. 架构已定稿:找到本系统的 Sxx 编号、职责表行、依赖方向——这是合同。 2. 在 01~12 文件夹里选最接近的系统类型(可组合,如"钓鱼"=05 采集+06 战斗 的判定部分),读取对应的 `SKILL.md` 与 `模板.md`。 -3. 该文件夹标注"必读例子"的,先读例子全文做密度锚。 +3. 该文件夹标注"参考例子"的,可先读例子了解写法。 -## 三、十二节总览:写什么、为什么、怎么咬合 +## 三、常见内容总览:写什么、为什么、怎么咬合 系统文档回答四个问题: **这个系统为什么存在(1~2)→ 玩家怎么用它(3~5)→ 它怎么运转(6~8)→ @@ -52,7 +52,7 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 咬合:**对上**服从架构三条合同(编号/职责/依赖);**对内**状态与接口不越 职责边界;**对下**第 7 节交接喂 TDD。 -## 四、十二节通用写法 +## 四、常见内容的参考写法 (各系统类型的特殊写法见对应文件夹 SKILL.md;纯净模板在其 模板.md) 1 系统目的:若删除它,__ 会塌——一句话说不出 = 该系统不该存在。 @@ -65,7 +65,7 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪 8 反馈:每种关键结果给独立反馈形态;失败必须说明原因和恢复路径。 9 内部循环:动词链;可拆单次/区域/长期三层。 10 输入输出与依赖:引用具名系统与具名数据,禁泛称"资源"。 -11 边界与非目标:照该类型 skill 的"三不"写全;必含"字段数值归 TDD"一条。 +11 边界与非目标:参考该类型 skill 的“三不”说明边界;建议说明字段与数值的交接边界。 12 开放问题:结构级才留;手感数值类标"待原型验证"。 ## 五、分析文档(全局一份,按层分节) diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/tdd.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/tdd.md index cf7faffe5..b2b6e6c50 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/tdd.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/tdd.md @@ -97,737 +97,18 @@ GDD 喂的(系统文档交接节就是订单);程序侧的加载与验证 -## A1 概念层分册(game-gdd-concept) +## A1 概念层分册(简介) ---- -name: game-gdd-concept -description: 写游戏策划案(GDD)概念层时使用。把一句话游戏想法写成一份 - "一次写对、之后不动"的立项概念文档——它是后续所有设计争议的仲裁依据。 - 任何游戏类型通用。配套:templates/concept-design.md、templates/analysis.md(全局一份)、 - exemplars/stardew-concept.md、exemplars/stardew-analysis.md(全局一份)。 ---- +本分册说明概念设计的目标、边界、核心张力、分析记录和交接要求。完整内容请阅读 `resources/skills/concept.md`;概念设计模板请阅读 `resources/templates/concept-design.md`。 -# 概念层写法(策划 agent · 概念层分册) +## A2 顶层设计分册(简介) -> 本文件是概念层唯一承载写作流程的教学件。 -> 模板与例子文件保持纯净:不含任何步骤、检验提示与标记。 +本分册说明顶层循环、资源流、节奏、取舍、范围和验证标准。完整内容请阅读 `resources/skills/top_design.md`;顶层设计模板请阅读 `resources/templates/top-design.md`。 -## 一、这一层的判断立场 -你是资深游戏策划,看过上千份概念案,清楚绝大多数死在"什么都说、什么都不尖"。 -在这个层里你相信: -- 概念的成败在取舍,不在丰富:一句话里卖点只许有一个。 -- 你写的是裁判文档:后续每一层的设计争议,都要能回到这里找到仲裁。 -- 具体压倒抽象:"压力很大"是废字,"每开一扇门都在烧自己的命"才是概念。 -- 用户没说过的话不当他说过:宁可标"待确认",不替人拍板。 -- 发现自己在堆形容词 = 概念没想清楚:停笔回去问,别用空话盖过去。 -- 概念层是"一次写对、之后不动"的层(实作中它的返工率远低于架构与 - 系统层),所以判断力要前置堆足,不要指望后面回来改。 +## A3 系统架构分册(简介) -## 二、动笔前 -1. 拿到用户真实回答过的定调信息(参照对象、题材偏好、压力档位)。 - 没有 → 先问一个定调问题,禁止自问自答充当用户。 -2. 读 exemplars/stardew-concept.md 做质量锚(模仿密度,不抄内容), - 然后往 templates/concept-design.md 里填。 -3. 零参照时在文档头注明"零参照"。 +本分册说明系统职责、依赖、数据归属、MVP 闭环、目录映射和架构校验。完整内容请阅读 `resources/skills/architecture.md`;架构模板请阅读 `resources/templates/architecture.md`。 -## 三、九节总览:写什么、为什么、怎么咬合 +## A4 系统文档分册(简介) -概念文档回答四个问题: -**这是什么(1~5)→ 它不是什么(6)→ 它靠什么让人一直玩(7)→ -它管到哪、交出什么(8~9)。** - -第 1 节是全案的压缩态,第 9 节是全案的判断态重述,首尾呼应; -中间各节从"设计锚点"这个枢纽长出来,争议又都回头接受它的仲裁。 - -| # | 节 | 是什么 | 为什么写 | 和谁咬合 | -|---|---|---|---|---| -| 1 | 一句话概念 | 全案压缩成一句:品类+融合+唯一卖点 | 概念的第一命运是被转述;这句立不住,后面写得再好都救不回来 | 9 是它的重述;2 是它的展开 | -| 2 | 定调与设计锚点 | 定调记录(参照/滑杆/T 原则,调性真源)+ 六个仲裁位:幻想/体验/动机/循环/跑偏/非目标 | 概念层把调定死:后续所有开放问题先回定调记录级联(约八成可就地定),级联不掉的才上决策卡;概念文档的核心职能是当裁判 | **全文档枢纽**:3~6 由它长出;7 由它的循环与动机抽出;定调记录被顶层及以下所有层引用 | -| 3 | 玩家身份与基调 | 玩家在虚构里是谁 + 情绪温度与红线 | 幻想需要一张脸和一种温度,否则是空话;基调边界句防调性漂移 | 身份 = 幻想的具象化;基调 = 目标体验的情绪面 | -| 4 | 风格与世界观 | 支撑玩法的世界规则 + 叙事载体 | 世界观是给玩法供氧的背景板,不是设定集 | 服务 3 的身份与基调;世界规则支撑 2 的核心循环成立 | -| 5 | 目标玩家与情境 | 为谁、什么场景、门槛多高 | 同一设计对不同人是不同游戏;受众映射防止"谁都适合=谁都不适合" | 反面校验 2 的目标体验;情境(一局多久)给 7 的循环定参数 | -| 6 | 不是什么 | 负面定位表:不是 X,因为 Y | 正面定义写多必然发散;负面定位用"误会方向+封死原因"收边界,比光秃的非目标锋利一档 | 2 的非目标与跑偏风险的表化展开;与 5 的防串味声明呼应 | -| 7 | 核心张力 | 玩家持续面对的两难,两端各有代价 | 长期游玩的根本动力;没有张力,再丰富的内容玩几次就腻 | **向下接口**:每条张力必须在顶层变成取舍表里的具体决策 | -| 8 | 边界与约束 | 本层只定什么、什么留给后面 + 规模回流 | 防止概念层越层写数值和系统(越层是下游返工之源);给写作画线 | 保护 2 的纯度;告诉顶层"你们的地盘从哪开始" | -| 9 | 概念定稿 | "核心不是 __ 而是 __"重述 + 给顶层的硬约束 | 收口重锤:写完九节重述一遍,检验整份文档有没有写散;把承诺变成对下的契约 | 回环呼应 1;把 8 的交接具体化成 2~4 条硬约束 | - -咬合一图: - -``` - 1 一句话概念(压缩态) - ↓ 展开 - 2 设计锚点(枢纽 · 仲裁位)◄── 所有节的争议回来找它 - ├→ 3 身份基调 ──→ 4 风格世界观(给玩法供氧) - ├→ 5 目标玩家(反面校验)──→ 6 不是什么(负面收边) - └→ 7 核心张力(动力结构)──→ 【交给顶层】取舍表 - 8 边界与约束(画线:本层到此为止) - ↓ 回环 - 9 概念定稿(判断态重述 + 交接契约) -``` - -记住三个接口:**对内**锚点仲裁一切;**对下**张力变取舍表、定稿变硬约束; -**对上**边界画线防止越层。九节不是清单,是一台咬合的机器。 - -## 四、怎么写(模板即流程,九节按序) -(本节是带写法要领的教学版;实际填写的纯净模板在 templates/concept-design.md) - -### 1. 一句话概念 -《__》是一款 __(品类与融合):玩家通过 __,把 __ 逐步 __。 -→ 45~90 字,卖点唯一。检验:删掉那个卖点句子依然成立,说明没写对。 - -### 2. 定调与设计锚点(先定调,再立仲裁位) -**定调记录**(全项目调性真源,此节定死): -- 参照选择:以 __ 为主、__ 学 __(参照即定调,选完调性随之而来)。 -- 调性滑杆:压力感/战斗比重/管理深度/叙事比重/节奏,各一档。 -- 调性锚 T 原则:3~7 条逐条具名(如"T2 不劝退——凡惩罚类问题默认取最轻档")。 - 检验:每条 T 都能当一句 IF-THEN 用——"凡__类问题默认__";写不出口径的 T 是空话。 - → 下游每个开放问题先来这里级联批量起草,级联不了的才升级提问。 -**设计锚点(六项,争议时的仲裁原则,全部具名)** -- 核心幻想:一句描述 + 一句玩家念头(引号写出玩家脑中的自言自语)。 - 检验:念头句写不出来 = 幻想没立住,回去重想,不要用描述糊弄。 -- 目标体验:何时感到什么。 -- 玩家动机:短期 __;长期 __。 -- 核心循环:__ → __ → __ → __ → 回到 __(箭头式)。 -- 跑偏风险:本项目可能的真实偏航,不放万金油。 -- 非目标:一行带过,详表见第 6 节。 - -### 3. 玩家身份与基调 -- 玩家身份:玩家在虚构里是谁 + 本项目的核心节奏,一口气说清。 -- 情绪基调:正面定调 + 边界句——"可以 __,不可以 __"。 - -### 4. 风格与世界观 -世界观为 __(玩法)服务;叙事通过 __(载体)展开。禁编年史、种族志。 - -### 5. 目标玩家与情境(受众映射三件套) -- 与谁的受众重合;吸收了谁的什么需求;**为什么不会变成它**(防串味声明, - 参照越多越必须有这句)。 -- 情境与门槛:单人/多人;一局多久;需要理解 __,不应要求 __。 - -### 6. 不是什么(负面定位表) -| 不是 | 因为 | -→ 每行原因要封死一条具体误会方向(例:不是武器店经营|武器主要拿去 - 战斗,不是卖给顾客)。从锚点的非目标与跑偏风险长出来,通常 4~6 行。 - -### 7. 核心张力 -- __ 有限,但 __。 -- __ vs __(两端的代价各是什么)。 -→ 每条两端都必须有代价,只有一端的"假张力"删掉。这些是顶层取舍表的 - 种子,后面要逐条对应。 - -### 8. 边界与约束 -- 概念边界放首位:本层只定幻想、用户、基调与排除方向;具体数值、 - 系统清单、MVP 内容留给顶层及以后。 -- 规模与回流:单人可维护;所有系统回流核心循环。 -- 参照声明:学组织方式,不复制角色/文本/美术/数值。 - -### 9. 概念定稿(收口重锤) -这个游戏的核心不是 __,而是: -> (一句话重述核心承诺) -交给下一层的约束:__ 必须 __(2~4 条,顶层必须围绕它们展开)。 - -某节对本项目没意义 → 写一行"略,因为 __",不硬凑。 - -## 五、分析文档(全局一份,按层分节) - -**全局唯一一份《分析.md》**(项目根),本层不另设分析文件(2026-09-06 收敛: -原每层一份 analysis 合并为全局一份——论证按发生层归节,决定登记表全项目 -只此一张,跨层引用只查这里)。模板与例子:资源 `templates/analysis.md`、 -`exemplars/stardew-analysis.md`。状态池(灵感池/代决/待原型等活队列)在决策台账, -不放分析文档——本文件只放已决论证与登记。 - -- 条目格式:`## 问题:<一句话>` + 状态(agent_proposal / user_confirmed / - superseded,登记 D-__)+ 广度分析(牵动面+候选 ≥2)+ 深度分析 - (逐候选利弊依据,必须引 T 原则/锚点/张力编号,写不出依据的偏好不进分析) - + 综合判断(建议取 __ 因为 __;推翻条件:__)。 -- 分诊三条件全满足才进:① 影响项目方向或边界;② ≥2 合理候选;③ 一时定不了。 - 不满足的:就地小权衡直接进登记表一行,不写条目。 -- 本层标准两问:① 什么是本项目不可替代的核心承诺;② 什么内容扩张会稀释它。 -- 数量纪律:概念期问题通常 ≤3;开始堆第 4 问时先怀疑概念层没想清楚,重读定调记录而不是继续开新争议。 -- user_confirmed 后三件事:结论一句话迁入 design.md 对应节(留修订痕迹); - 登记表加行(编号全项目连续,跨层引用写 D-__);本条目改状态记 D 号保留不删。 - 推翻时新增行挂旧行编号,旧行不删。 - - -## 六、写完自查(参考,不是闸门) -- 卖点唯一吗?念头句立得住吗? -- 随便挑一个后续设计问题,锚点六项之一能当裁判吗? -- "不是什么"表封死了最可能的误会方向吗? -- 张力每条都两端有代价吗? - -## 七、红线(只有三条) -1. 不冒充用户决定:用户没说的方向标"待确认",正文不写死。 -2. 不越层:出现具体数值、按键、界面即删。 -3. 不凑数:写不满就说明缺什么,禁止万金油句填充。 - - - - -## A2 顶层设计分册(game-gdd-top-design) - ---- -name: game-gdd-top-design -description: 写游戏策划案(GDD)顶层设计时使用。在概念层定稿之后, - 回答"玩家为什么一直玩"——把概念变成可玩的时间结构(循环/资源/取舍/节奏), - 并向架构层交付系统范围。配套:templates/top-design.md、templates/analysis.md(全局一份)、 - exemplars/stardew-top-design.md、exemplars/stardew-analysis.md(全局一份)。 ---- - -# 顶层设计写法(策划 agent · 顶层设计分册) - -> 本文件是顶层设计唯一承载写作流程的教学件。 -> 模板与例子文件保持纯净:不含任何步骤、检验提示与标记。 - -## 一、这一层的判断立场 -你是资深游戏策划,正在写全 GDD 最重要的一份文档——概念说"凭什么成立", -顶层说"好玩在哪"。核心循环无趣,后面写再多系统也救不回来。在这个层里你相信: -- 循环优先:先把大循环、小循环、最小体验单位三层跑通,再谈其他一切。 -- 用玩家的手写,不用系统的嘴写:写"玩家在做什么、在想什么", - 不写"系统提供了什么功能"。 -- 每个时间段的痛苦和甜都要有来处:取舍表接概念层的张力,节奏接情绪摆动。 -- 资源守恒直觉:每种资源必问来源、储存、消耗——无来源是白给, - 无消耗是废物,环环相扣成套利。 -- 你不替概念层翻案(张力与定稿已定),也不替架构层拆系统(只划边界)。 - -## 二、动笔前 -1. 概念层 design.md 已定稿可用——顶层定位与取舍表直接从它长出来。 -2. 读 exemplars/stardew-top-design.md 做质量锚(模仿密度,不抄内容), - 往 templates/top-design.md 里填。 -3. 把概念层的核心张力清单摊开放在手边——取舍表必须逐条挂上编号。 - -## 三、十六节总览:写什么、为什么、怎么咬合 - -顶层文档回答四个问题: -**玩家在玩什么(1~9)→ 玩家面对什么选择与后果(10~11)→ -交给架构什么(12~14)→ 没想清什么、定了什么(15~16)。** - -第 1 节承概念定稿开篇,第 16 节给架构硬约束收口,首尾呼应; -中段三层循环互检,资源流从底下供血。 - -| # | 节 | 是什么 | 为什么写 | 和谁咬合 | -|---|---|---|---|---| -| 1 | 顶层定位与规模锚点 | 承概念定稿 + "让玩家每天都在想"念头句 + 不是X不是Y + 规模参数表(循环单位/段落/复杂度/长期主轴) | 循环单位定错全盘错;定位句防止顶层漂离概念 | 承概念层"概念定稿";念头句是概念层玩家念头的时间维度版 | -| 2 | 设计目标 | 几种回报、如何互相供给 | 回报并列=小游戏拼盘;互相供给才是循环 | 供给关系落到 4~5 的循环里 | -| 3 | 核心推动力 | 动机主次 + 即时/日程/季节/长期四层推动 | 玩家"什么时候被什么推着走"的完整图谱 | 时间四层对应 10 节奏结构的四层 | -| 4 | 大循环 | 跨较长时间的循环:文字箭头 + 核心循环图 | 长期留存的结构骨架 | 与 5、7 三层互检:大循环的每环应有小循环供血 | -| 5 | 小循环 | 几十秒到几分钟的具名动词链 ×3+ | 真正被玩到的那层;动词链可直接复制进实现 | 检验:删掉某条,游戏是否少了一块可命名的乐趣 | -| 6 | 资源流与输入输出 | 资源流图(来源→储存→消耗)+ 输入输出清单 + 反馈四层 | 资源是循环的血液;防白给、防废物、防套利 | 供血给 4~5 的每个循环环节 | -| 7 | 最小体验单位 | 多短一段玩法就能体现独有乐趣 + 反馈铁律 | 原型只做这一个单位——定原型规模 | 是 5 的最小切片;14 验证标准的试验对象 | -| 8 | 核心活动流程 | 段落表:阶段/玩家行为/**设计目的** | "玩这个游戏的一天"的可复述剧本 | 设计目的列写不出的段=该删的段 | -| 9 | 取舍表 | 决策/立即收益/延迟收益/主要代价 | 张力的具体化——玩家决策的路口 | **逐条对应概念层核心张力**(对上接口) | -| 10 | 节奏结构 | 日内/周内/季节/长期四层 + 情绪摆动 | 防止"一直紧张"或"一直平";摆动才有呼吸 | 四层对应 3 的推动力四层 | -| 11 | 失败与回收 | 亏损定性 + 情况/结果表 | 失败的形态决定调性——"少拿"还是"毁掉" | 对齐概念层情绪基调的边界句 | -| 12 | 系统范围 | 系统/顶层目的/**边界** 表 | 架构层接口:系统地图的种子 | **对下接口**:架构照此拆系统 | -| 13 | 范围与非目标 | 最小完整版本清单 + 不做清单 | 立项交付物的边界 | 承概念层"不是什么";给 14 提供验证范围 | -| 14 | 验证标准 | 验证点/成功标准(行为判据) | "好玩"不可测,"玩家能复述循环"可测 | 判据对象=7 的最小体验单位 | -| 15 | 开放问题 | 留给架构前必须想清的 | 显式债务清单 | 进分析文档或架构层开题 | -| 16 | 顶层定稿 | 收口重锤 + 给架构的硬约束(必须__/不得__) | 检验全文档没写散;架构的紧箍咒 | 回环呼应 1;承概念层定稿的接力棒 | - -咬合一图: - -``` -概念层定稿(硬约束 + 张力) - ↓ 承接 -1 定位与规模锚点 ───张力落位───► 9 取舍表(逐条对应) - ↓ 展开 -2 设计目标 → 3 核心推动力 → 4 大循环 ⇄ 5 小循环 ⇄ 7 最小体验单位 - ↓ 供血 -6 资源流与输入输出(防无来源/无消耗/套利) - ↓ 后果侧 -8 活动流程(段落表)→ 10 节奏结构 → 11 失败与回收 - ↓ 交付 -12 系统范围(→架构系统地图的种子)+ 13 范围 + 14 验证标准 - ↓ 收口 -15 开放问题 → 16 顶层定稿(给架构的硬约束) -``` - -三个接口:**对上**承概念定稿、张力逐条变取舍表;**对内**三层循环互检 -(大⇄小⇄最小单位)+ 资源三段全;**对下**系统范围表喂架构的系统地图、 -顶层定稿当架构的紧箍咒、验证标准当原型试玩判据。 - -## 四、怎么写(模板即流程,十六节按序) -(本节是带写法要领的教学版;实际填写的纯净模板在 templates/top-design.md) - -### 1. 顶层定位与规模锚点 -顶层不是做 __,也不是做 __,而是让玩家每天都在想: -> "__(玩家每天惦记的那件事)" -规模锚点表:循环单位 / 段落构成 / 操作复杂度 / 经营复杂度 / 长期主轴排序。 -→ 循环单位先行,定错全盘错。复杂度行可内联参照与"不做"。 - -### 2. 设计目标 -玩家在 __ 循环中同时获得 __、__、__——三者不是并列小游戏,而是互相供给:__。 -→ 检验:砍掉任何一种回报,另外两种是否受伤。 - -### 3. 核心推动力 -- 动机主次:__。 -- 即时推动 __;日程推动 __;季节推动 __;长期推动 __。 -→ 四层都要有实指;空着的那层就是将来留存崩塌的地方。 - -### 4. 大循环 -**__ → __ → __ → __ → 回到 __。**(附核心循环图) -→ 检验:断掉任何一环,后面是否塌;每一环应有对应小循环供血。 - -### 5. 小循环(具名动词链 ×3+) -**__循环**:__ → __ → __ → __ → __。 -→ 必须具名("农务循环"不是"资源循环");动词链完整到可以直接照做。 - -### 6. 资源流与输入输出 -(资源流图:每种核心资源 来源 → 储存 → 消耗 三段全) -主要输入 __;主要输出 __;反馈四层:立即 __ / 短期 __ / 中期 __ / 长期 __。 -→ 三问:这资源哪来的?存在哪?花在哪去?答不出=资源设计未完成。 - -### 7. 最小体验单位 -__(多短一段玩法体现独有乐趣——原型只做这一个单位)。 -单个行动必须至少提供一种清晰反馈:资源/进度/能力/关系/信息/视觉状态之一。 - -### 8. 核心活动流程(段落表) -| 阶段 | 玩家行为 | 设计目的 | -→ 设计目的列必填;写不出目的的段落删掉。这份表要能让陌生人复述 -"玩这个游戏的一天"。 - -### 9. 取舍表 -| 决策 | 立即收益 | 延迟收益 | 主要代价 | -→ 每行挂概念层张力编号;避免唯一最优解;不同选择应产生不同但都合理的玩法方式。 - -### 10. 节奏结构 -日内 __ → 周内 __ → 季节/章节 __ → 长期 __。 -整体情绪在"__"与"__"之间摆动(恢复来源 __;变化来源 __)。 - -### 11. 失败与回收 -先定性:失败主要表现为 __(少拿收益 / 延迟成长 / 毁掉积累——三选一档位), -再列表: -| 情况 | 结果 | -→ 亏损档位必须与概念层情绪基调一致;治愈基调配"少拿"档。 - -### 12. 系统范围(架构层接口) -| 系统 | 顶层目的 | 边界(本层不做什么) | -→ 只写目的与边界,不写系统内部规则;每行将来对应架构层一个 Sxx。 - -### 13. 范围与非目标 -最小完整版本包含:__。不做清单:__。 - -### 14. 验证标准 -| 验证点 | 成功标准 | -→ 成功标准必须是行为判据("玩家能复述__""玩家出现__行为"), - "感觉好玩"不算。 - -### 15. 开放问题 -→ 逐条列出;值得跨轮保留的进分析文档,其余留待架构层开题。 - -### 16. 顶层定稿(收口重锤) -顶层当前定稿为:__(循环单位、核心结构、关键档位一句话说全)。 -后续架构必须围绕 __ 拆系统;不得 __。 - -某节对本项目没意义 → 写一行"略,因为 __",不硬凑。 - -## 五、分析文档(全局一份,按层分节) - -**全局唯一一份《分析.md》**(项目根),本层不另设分析文件(2026-09-06 收敛: -原每层一份 analysis 合并为全局一份——论证按发生层归节,决定登记表全项目 -只此一张,跨层引用只查这里)。模板与例子:资源 `templates/analysis.md`、 -`exemplars/stardew-analysis.md`。状态池(灵感池/代决/待原型等活队列)在决策台账, -不放分析文档——本文件只放已决论证与登记。 - -- 条目格式:`## 问题:<一句话>` + 状态(agent_proposal / user_confirmed / - superseded,登记 D-__)+ 广度分析(牵动面+候选 ≥2)+ 深度分析 - (逐候选利弊依据,必须引 T 原则/锚点/张力编号,写不出依据的偏好不进分析) - + 综合判断(建议取 __ 因为 __;推翻条件:__)。 -- 分诊三条件全满足才进:① 影响项目方向或边界;② ≥2 合理候选;③ 一时定不了。 - 不满足的:就地小权衡直接进登记表一行,不写条目。 -- 本层标准两问:① 一天/一局怎样形成清楚但不拖沓的循环;② 风险、收益与长期成长怎样互相支撑。 -- 数量纪律:顶层期问题通常 ≤5(结构性争议天然更多);堆问题时先回读第 1 节定位句。 -- user_confirmed 后三件事:结论一句话迁入 design.md 对应节(留修订痕迹); - 登记表加行(编号全项目连续,跨层引用写 D-__);本条目改状态记 D 号保留不删。 - 推翻时新增行挂旧行编号,旧行不删。 - - -## 六、写完自查(参考,不是闸门) -- 三层循环互检了吗:大循环每环有小循环供血?最小单位切得出来? -- 概念层张力每条都在取舍表有对应行吗? -- 每种资源三段全吗(来源/储存/消耗)? -- 验证标准是行为判据吗,还是写了"好玩"? -- 架构层拿到系统范围表能直接开工吗——有没有该划没划的系统? -- 失败档位和概念层基调一致吗? - -## 七、红线(只有三条) -1. 不冒充用户决定:用户没说的方向标"待确认",正文不写死。 -2. 不越层:向上不翻概念层的案,向下不写系统内部规则与具体数值。 -3. 不凑数:写不满就说明缺什么,禁止万金油句填充。 - - - - -## A3 系统架构分册(game-gdd-architecture) - ---- -name: game-gdd-architecture -description: 写游戏策划案(GDD)系统架构时使用。在顶层设计定稿之后, - 把顶层的系统范围表正式切成 Sxx 系统:编号、职责、依赖、数据流、优先级, - 并向系统文档站交付目录映射与 MVP 闭环。配套:templates/architecture.md、 - templates/analysis.md(全局一份)、exemplars/stardew-architecture.md、exemplars/stardew-analysis.md(全局一份)。 ---- - -# 系统架构写法(策划 agent · 系统架构分册) - -> 本文件是系统架构层唯一承载写作流程的教学件。 -> 模板与例子文件保持纯净:不含任何步骤、检验提示与标记。 - -## 一、这一层的判断立场 -你是架构师,切系统的刀在你手里。在这个层里你相信: -- 切分是为了**职责清晰、可独立讨论**,不是为了凑数量——每个系统必须能 - 一句话答出"删了它,什么塌"(P0 原因)。 -- **数据所有权唯一**:同一事实只由一个系统维护,其他系统只引用稳定 ID, - 不复制主数据。两个系统管同一件事 = 架构事故。 -- **依赖无环**是硬要求;信息呈现层只读状态、只经行动入口写入。 -- 架构是全项目返工最多的一份(实测 11 版 vs 概念层 2 版)——所以每次改刀 - 都要写变更记录,让"为什么这么切"可追溯。 -- 你不越层:上不重定义玩法循环(那是顶层的),下不写单系统内部规则 - (那是系统文档的),字段定义与数值配置归技术文档层(数值策划)。 - -## 二、动笔前 -1. 顶层设计已定稿可用——把它的**系统范围表**(粗清单)和**顶层定稿约束** - 摊开当输入;切分是对粗清单的正式化(拆、并、裁都在这层做)。 -2. 读 exemplars/stardew-architecture.md 做质量锚(模仿密度,不抄内容), - 往 templates/architecture.md 里填。 -3. 记住顶层的核心循环图——切完必须跑覆盖检查。 - -## 三、十二节总览:写什么、为什么、怎么咬合 - -架构文档回答四个问题: -**这个架构为什么这样切(1~3)→ 系统是什么、怎么连接(4~6)→ -怎么落地、怎么验证(7~11)→ 还有什么没想清(12)。** - -第 1 节承顶层的定稿约束开篇,MVP 闭环在中间当守门员,开放问题收尾。 - -| # | 节 | 是什么 | 为什么写 | 和谁咬合 | -|---|---|---|---|---| -| 1 | 架构定位与目标 | 阶段边界(定哪些系统、不展开内部)+ 划分原则 + 一句话架构 + **变更记录** | 防止架构漂离顶层;改刀可追溯 | 承顶层定稿;变更记录引登记编号 | -| 2 | 系统地图 | Sxx 编号清单(=系统文档目录真源)+ 支撑层 + P0 段五列表(目的/输入/输出/P0原因) | 编号让系统可引用;P0 原因逼答"删了塌什么" | **对下真源**:Sxx ↔ 04 系统文档一一对应 | -| 3 | 系统职责 | 职责表(负责/不负责→移交谁)+ 逐系统说明段 | 边界写死,防两个系统管同一件事 | 系统文档的"边界与非目标"必须与此对齐 | -| 4 | 依赖与数据流 | 依赖图(无环)+ 数据流图 + 主要状态 + 主数据归属规则 | 谁读谁、数据从哪到哪——接口的真源 | 顶层的资源流图在此展开成系统级 | -| 5 | 核心循环覆盖检查 | 顶层每个循环环节 → 认领系统 | 顶层→架构的验收线,防切系统切碎循环 | 对上接口:逐环节对照顶层循环图 | -| 6 | 目录映射 | 职责 → 物理文档目录的归并表 | 职责数≠文档数;归并规则显式化 | **对下接口**:系统文档站照此开工 | -| 7 | MVP 最小闭环 | 编号验证链 + 守门句("闭环不成立不许加东西") | 立项后第一条要跑通的链 | 对应顶层验证标准;失败回顶层而非加系统 | -| 8 | 统一数值基准 | 单位清单 + 四类定性基准(时间/货币/成长/体力风险的风格约束) | 各系统单独配数值会互相失衡;先定全局尺度 | **数值换算与验算归技术文档层**,此处只到定性 | -| 9 | 系统边界 | 哪些功能明确不属于任何系统/归引擎层/归呈现层 | 显式排除,防范围蔓延 | 承概念层"不是什么" | -| 10 | 优先级与范围 | P0/P1/P2 三档(P1/P2 可用能力表) | 拆分≠全做;裁剪顺序显式化 | P0 = MVP 闭环的系统集 | -| 11 | 风险与校验 | 风险/校验方式表 | 架构级风险提前挂出,每条带检验法 | 对应顶层验证标准与概念层跑偏风险 | -| 12 | 开放的结构问题 | 结构级未定案 | 显式债务 | 进分析文档或系统文档开题 | - -咬合一图: - -``` -顶层定稿 + 系统范围表(粗清单) - ↓ 正式切分(拆/并/裁) -1 定位与目标 ──► 2 系统地图(Sxx 真源)──► 3 职责表 - ↓ ↓ ↓ -5 循环覆盖检查 ◄── 4 依赖与数据流(接口真源) - ↓ -6 目录映射 ──► 7 MVP 最小闭环(守门员) - ↓ -8 数值基准(定性)· 9 边界 · 10 优先级 · 11 风险校验 - ↓ -12 开放问题 →(进分析文档 / 系统文档站开题) -``` - -三个接口:**对上**承顶层系统范围表并跑循环覆盖检查;**对内**地图↔职责↔依赖 -三方一致、主数据归属唯一;**对下**目录映射 + MVP 闭环喂系统文档站。 - -## 四、怎么写(模板即流程,十二节按序) -(本节是带写法要领的教学版;实际填写的纯净模板在 templates/architecture.md) - -### 1. 架构定位与目标 -本阶段确定"哪些系统支撑一轮玩法",不展开单系统内部规则。 -划分原则:__。一句话架构: -> (玩家通过哪些系统、以什么因果,把一轮玩法的输入变成下一轮的选择) -变更记录:日期 + 改了什么 + 为什么(引登记编号)。 -→ 没有变更记录的架构文档,第二轮迭代就会变成黑箱。 - -### 2. 系统地图 -Sxx 编号清单(核心系统 2~12 个)+ 支撑层(存档/UI,不拥有核心规则)。 -P0 段五列表: -| 系统 | 目的 | 输入 | 输出 | P0 原因 | -→ 每行 P0 原因必须答"删了它,__ 塌";答不出的降级或合并。 - -### 3. 系统职责 -| 系统 | 主要职责 | 不负责 → 移交谁 | -→ "不负责"列必填且指向具名系统;再为争议最大的 2~3 个系统各写一段 -说明(负责什么 / 不负责什么 / 只负责什么)。 - -### 4. 依赖与数据流 -依赖图(mermaid,呈现层用虚线"读取状态")+ 数据流图(资源从产到耗)。 -主要状态:全局/玩家/场景/社会 四类。 -主数据归属规则:规则与数据表分工 / 稳定 ID 关联 / 任何系统不复制他系统主数据。 -→ 依赖图出现环 = 回去重切。 - -### 5. 核心循环覆盖检查 -| 顶层循环环节 | 认领系统 | -→ 逐环节对照顶层循环图;有环节无人认领或多人认领都是切分错误。 - -### 6. 目录映射 -| 目录 | 本阶段定位 | -→ 职责可以归并进同一文档目录(官方版 8 职责→3 文档);归并规则写明。 -系统文档站以此开工:地图上没有的系统不许有文档。 - -### 7. MVP 最小闭环 -1. __ 2. __ …(编号验证链,一条玩家可走的完整因果) -守门句:如果这条闭环不成立,不应继续增加 __。 -→ 闭环失败回顶层改设计,不是加系统打补丁。 - -### 8. 统一数值基准(定性) -全局单位清单(如时间片/游戏日/货币/体力/经验)+ 四类风格约束 -(时间节奏/货币量级感/成长回报取向/体力风险档位)。 -→ 只写到定性;具体换算、验算数值由技术文档层(数值策划)承接。 - -### 9. 系统边界 -明确排除项(不拆出独立 __ 系统 / __ 归引擎层 / __ 归呈现层)。 - -### 10. 优先级与范围 -P0(最小闭环必需):__;P1(完整体验):__;P2(扩展内容):__。 -P1/P2 可用能力表(能力/说明)控制颗粒度。 - -### 11. 风险与校验 -| 风险 | 校验方式 | -→ 从概念层跑偏风险和顶层失败档位反推;校验方式要可观察。 - -### 12. 开放的结构问题 -→ 结构级(接口归属/统一格式/合并拆分)才留这里;数值细节不留。 - -## 五、分析文档(全局一份,按层分节) - -**全局唯一一份《分析.md》**(项目根),本层不另设分析文件(2026-09-06 收敛: -原每层一份 analysis 合并为全局一份——论证按发生层归节,决定登记表全项目 -只此一张,跨层引用只查这里)。模板与例子:资源 `templates/analysis.md`、 -`exemplars/stardew-analysis.md`。状态池(灵感池/代决/待原型等活队列)在决策台账, -不放分析文档——本文件只放已决论证与登记。 - -- 条目格式:`## 问题:<一句话>` + 状态(agent_proposal / user_confirmed / - superseded,登记 D-__)+ 广度分析(牵动面+候选 ≥2)+ 深度分析 - (逐候选利弊依据,必须引 T 原则/锚点/张力编号,写不出依据的偏好不进分析) - + 综合判断(建议取 __ 因为 __;推翻条件:__)。 -- 分诊三条件全满足才进:① 影响项目方向或边界;② ≥2 合理候选;③ 一时定不了。 - 不满足的:就地小权衡直接进登记表一行,不写条目。 -- 本层标准问题:结构级争议——接口统一、系统归并、主数据归属划分。(本层原本不配独立分析文件,结构争议全归全局文件本节。) -- 数量纪律:按需;架构期问题多为接口与归属二义。 -- user_confirmed 后三件事:结论一句话迁入 design.md 对应节(留修订痕迹); - 登记表加行(编号全项目连续,跨层引用写 D-__);本条目改状态记 D 号保留不删。 - 推翻时新增行挂旧行编号,旧行不删。 - - -## 六、写完自查(参考,不是闸门) -- 每个 Sxx 都能一句话答"删了它什么塌"吗? -- 顶层的循环环节全覆盖、无重复认领吗? -- 依赖图无环?主数据无一物两管? -- 系统文档站拿到目录映射能直接开工吗? -- 有没有字段定义或数值配置偷偷写进来?(该在技术文档层) -- 变更记录补了吗——这次切分和上次的差异说得清吗? - -## 七、红线(只有三条) -1. 不冒充用户决定:用户没说的方向标"待确认",正文不写死。 -2. 不越层:向上不翻顶层的案,向下不写系统内部规则,数值字段归技术文档层。 -3. 不凑数:系统数量不是成绩,写不出 P0 原因的系统就是该删的系统。 - - - - -## A4 系统文档分册(game-gdd-system-doc) - ---- -name: game-gdd-system-doc -description: 写单个系统的设计文档(Sxx)时的总纲——通用纪律、十二节同构骨架、 - 红线与分析文档格式。每类系统的专属写法与模板在 modules/system-types/ 下对应目录的 SKILL.md - 与对应模块的模板.md 里,按需取用。 ---- - -# 系统文档写法(策划 agent · 系统文档分册 · 总纲) - -> 本文件是系统文档层的总纲;各系统的专属写法在 `modules/system-types/` 下对应目录的 `SKILL.md`, - 专属模板在 `modules/system-types/` 对应目录的 `模板.md`。通用纪律不在各系统 skill 里重复。 - -## 一、这一层的判断立场 -你是写单个系统的策划。在这个层里你相信: -- 系统文档是**执行层**:刀已经在架构层切好——服从系统地图编号、职责表 - 边界、依赖图方向,无权改刀;发现切错了,提分析、记登记,不私自扩边界。 -- 一个系统文档的成败在**边界节**:"不负责什么、移交给谁"那几行是 - 防返工价值最高的几行。 -- 接口纪律:引用具名系统与具名数据,禁泛称;别家主数据只引 ID 不复制。 -- 字段定义、数值配置、表结构不归你——写交接声明,交技术文档层(数值策划)。 -- 所有系统同构:读者读熟一份就能读所有份。 - -## 二、动笔前 -1. 架构已定稿:找到本系统的 Sxx 编号、职责表行、依赖方向——这是合同。 -2. 在 01~12 文件夹里选最接近的系统类型(可组合,如"钓鱼"=05 采集+06 战斗 - 的判定部分),读取对应的 `SKILL.md` 与 `模板.md`。 -3. 该文件夹标注"必读例子"的,先读例子全文做密度锚。 - -## 三、十二节总览:写什么、为什么、怎么咬合 - -系统文档回答四个问题: -**这个系统为什么存在(1~2)→ 玩家怎么用它(3~5)→ 它怎么运转(6~8)→ -它怎么和别人连接、不碰什么(9~12)。** - -| # | 节 | 是什么 | 为什么写 | 和谁咬合 | -|---|---|---|---|---| -| 1 | 系统目的 | 一句话:删了它什么塌 | 存在性检验 | 架构 P0 原因的展开 | -| 2 | 支撑的玩家体验 | 对应顶层目标第几条 | 防系统自嗨 | 顶层设计目标 ↔ 本系统 | -| 3 | 进入与退出 | 何时进入、何时/如何退出 | 循环的接口时刻 | 顶层的循环环节 | -| 4 | 玩家行动 | 具名动词组 | 玩家用手玩 | 系统类型卡给动词组 | -| 5 | 取舍表 | 玩家在本系统内的决策 | 张力在系统内的落地 | 概念张力→顶层取舍表→本表 | -| 6 | 状态与规则 | 对象/状态/转换/异常,枚举表达 | 定性规则真源 | 架构职责表对齐 | -| 7 | 数值与数据交接 | 本系统交 TDD 的数据类别+定性约束 | 分层边界 | 技术文档层承接 | -| 8 | 反馈 | 何时/何强度/何通道 | 无反馈=没发生 | 顶层反馈四层 | -| 9 | 内部循环 | 本系统内的小循环 | 系统自己的心跳 | 顶层小循环的组成 | -| 10 | 输入、输出与依赖 | 消费/交付/依赖谁 | 接口真源 | 架构依赖图逐边对齐 | -| 11 | 边界与非目标 | 不负责什么→移交谁 | **防返工价值最高** | 架构职责表"不负责"列 | -| 12 | 开放问题 | 本系统未定案 | 显式债务 | 进分析文档 | - -咬合:**对上**服从架构三条合同(编号/职责/依赖);**对内**状态与接口不越 -职责边界;**对下**第 7 节交接喂 TDD。 - -## 四、十二节通用写法 -(各系统类型的特殊写法见对应文件夹 SKILL.md;纯净模板在其 模板.md) - -1 系统目的:若删除它,__ 会塌——一句话说不出 = 该系统不该存在。 -2 支撑体验:对应顶层目标第__条、调性原则第__条。 -3 进入与退出:常规进入/读档恢复/特殊事件后返回,三入口必写。 -4 玩家行动:≥4 个具名动词组;编排类写"安排"动词,活动类写"操作"动词。 -5 取舍表:决策/立即收益/延迟收益/主要代价;挂顶层张力编号。 -6 状态与规则:对象-状态-转换-异常,全部枚举表达,不许整段散文。 -7 数值与数据交接:列数据类别名 + 设计侧定性约束;字段定义归 TDD。 -8 反馈:每种关键结果给独立反馈形态;失败必须说明原因和恢复路径。 -9 内部循环:动词链;可拆单次/区域/长期三层。 -10 输入输出与依赖:引用具名系统与具名数据,禁泛称"资源"。 -11 边界与非目标:照该类型 skill 的"三不"写全;必含"字段数值归 TDD"一条。 -12 开放问题:结构级才留;手感数值类标"待原型验证"。 - -## 五、分析文档(全局一份,按层分节) - -**全局唯一一份《分析.md》**(项目根),本层不另设分析文件(2026-09-06 收敛: -原每层一份 analysis 合并为全局一份——论证按发生层归节,决定登记表全项目 -只此一张,跨层引用只查这里)。模板与例子:资源 `templates/analysis.md`、 -`exemplars/stardew-analysis.md`。状态池(灵感池/代决/待原型等活队列)在决策台账, -不放分析文档——本文件只放已决论证与登记。 - -- 条目格式:`## 问题:<一句话>` + 状态(agent_proposal / user_confirmed / - superseded,登记 D-__)+ 广度分析(牵动面+候选 ≥2)+ 深度分析 - (逐候选利弊依据,必须引 T 原则/锚点/张力编号,写不出依据的偏好不进分析) - + 综合判断(建议取 __ 因为 __;推翻条件:__)。 -- 分诊三条件全满足才进:① 影响项目方向或边界;② ≥2 合理候选;③ 一时定不了。 - 不满足的:就地小权衡直接进登记表一行,不写条目。 -- 本层标准问题:① 本系统与相邻系统的边界在哪;② 本系统内部哪个规则影响顶层取舍。条目标系统号(如 S06)。 -- 数量纪律:按需;每系统通常 0~1 条,超了先回读架构职责表。 -- user_confirmed 后三件事:结论一句话迁入 design.md 对应节(留修订痕迹); - 登记表加行(编号全项目连续,跨层引用写 D-__);本条目改状态记 D 号保留不删。 - 推翻时新增行挂旧行编号,旧行不删。 - - -## 六、写完自查(参考,不是闸门) -- 目的一句话成立吗?边界节和架构职责表逐行对齐吗? -- 输入输出和依赖图逐边对上吗?有没有泛称漏网? -- 状态是枚举还是散文?失败路径给了原因和恢复吗? -- 有没有字段或数值偷偷写进来?(该在 TDD) -- 同构检查:另一份系统文档的读者能按同样方式读这份吗? - -## 七、红线(只有三条) -1. 不冒充用户决定:用户没说的方向标"待确认",正文不写死。 -2. 不越层:不翻架构的案(要改走分析文档+登记表),不写字段数值(归 TDD), - 不替别的系统定规则。 -3. 不凑数:写不出"删了塌什么"、填不满的节,说明缺料——停笔说明,不硬凑。 - - - - -## A5 技术文档分册(game-tdd) - ---- -name: game-tdd -description: 写游戏技术文档(TDD)时使用的总纲。GDD 四层定稿后的第五步:把 - "怎么做"写实——程序怎么写、美术怎么做、字段怎么定义、怎么配表。 - 三大件各有专属分册:技术实现(程序侧)/ 美术圣经(美术侧)/ 数据与配表(数据侧)。 ---- - -# 技术文档写法(策划 agent · TDD 分册 · 总纲) - -> 本文件是 TDD 层唯一承载写作流程的教学件;各分册 SKILL 与模板配套使用。 -> 模板与例子文件保持纯净:不含任何步骤、检验提示与标记。 - -## 〇、TDD 的完成判据(总纲) - -**TDD 是自足构建包:一个施工 agent 只看 TDD,就能做完完整游戏。** -GDD 是设计真源(给人看、给迭代看);TDD 是构建真源(给施工看)。 -检验方式=自足性检查(见总册):不看 GDD 能否回答——每个系统怎么行为、 -每张表多少行内容、每个界面怎么走、每份素材什么规格。答不出的项就是缺口, -缺口回 GDD 同步后**收编**进 TDD(带版本锁)。收编是构建期快照:GDD 定稿 -变更 → 触发对应收编节重同步(与 fast_gdd 投影同一机制,方向相反)。 - -## 一、这一层的判断立场 - -你是工程师思维的策划。GDD 是"用户视角的功能描述",TDD 是"实现者视角的 -架构性描述"——你不重复设计的论证(为什么这样设计,去 GDD 和 analysis 查), -只写怎么落地。你相信: - -- **交接契约是 TDD 最大的价值**:美术交给程序的素材、程序读的表、加载的 - 顺序——每一条缝都写死。缝上不写死,返工就在缝里发生。 -- **平台事实优先**:目标运行时由 GDD 平台事实锁定——**HTML / Unity / Godot / - Cocos 四选一**。HTML 项纯 HTML/CSS/JS 交付;引擎项支持打开引擎工程、自然 - 语言协作改素材与代码,由陶泥儿驱动引擎**弹窗预览**、驱动引擎 **CLI 导出**。 - 一切技术选择先过所选运行时这道闸,不推荐该运行时做不出来的东西; - TDD 不擅自换运行时。 -- **一个事实只有一个写权**:每张表、每条主数据都有唯一拥有者系统, - 其他系统只引用不复制(GDD 架构层主数据归属规则在 TDD 落成表结构)。 -- **验收是硬闸不是仪式**:有 blocker 禁止扩充内容——这条竞品四十轮实测 - 验证过,照抄。 -- **先少量验证再量产**(美术)/ **先建索引再转表**(数据)——任何方向都 - 不做"做完一大批才发现不对"的事。 - -## 二、TDD 与 GDD 的接口(输入从哪来) - -| 输入 | 来自 | 喂给哪件 | -|---|---|---| -| 系统范围表 + P0 清单 + 主数据归属规则 | 架构层 | 三件共用(拆表与拆模块依据) | -| 各系统「数值与数据交接」节 + 定性约束 | 系统文档 | 数据侧(直接订单) | -| 定调记录(参照/滑杆/T 原则)+ 身份基调 | 概念层 | 美术圣经(视觉翻译源头) | -| 技能选型卡 | skill 库 | 程序侧+美术圣经(@版本+参数实例化) | - -TDD 不回头改 GDD:发现 GDD 没写清楚的点,走「开放问题回执」——该问用户 -的升级决策卡,该代决的记台账(带理由和推翻条件),结论回写对应层,TDD 只 -登记去向。顾问期(开发阶段)同一出口:程序美术卡点、成品与文档偏差,都从 -回执进、修订出(v{N+1})。 - -## 三、三大件与开工顺序 - -| 件 | 管什么 | 读者 | 分册 | -|---|---|---|---| -| 数据与配表 | 字段定义、表结构、数值、验收 | 数值策划 + 程序 | 03 | -| 技术实现 | 代码组织、场景镜头、输入、音频、性能预算、验证 | 程序 | 01 | -| 美术圣经 | 视觉锚、素材规格契约、量产流程 | 美术 | 02 | - -**顺序:数据侧 → 程序侧 → 美术圣经**。数据侧先开的理由:它是唯一直接被 -GDD 喂的(系统文档交接节就是订单);程序侧的加载与验证要引用表结构;美术 -圣经的素材总清单要引用物品表(每个可见对象绑定 item_id 或显式豁免)。小型 -项目三件可交叉,但**表结构永远先于数值填充**。 - -## 四、怎么写(总纲级;细节在各分册) - -1. 数据侧:总清单拆表 → ID 与字段字典 → 公共条件表 → 建表顺序(物品表 - 起步)→ 表结构契约(程序签名)→ 数值填充(代决+台账)→ 验收七查。 -2. 程序侧:系统实现总览(每系统一段话写死怎么做)→ 技术选型与 skill 引用 - → 场景与镜头 → 输入与操作 → 音频 → 验证方式与性能预算。 -3. 美术圣经:视觉锚(从概念层定调翻译)→ 素材规格契约逐素材一行 → - 量产流程(概念候选→锚点确认→小批→验收→扩产)→ 资产总清单。 - -## 五、写完自查(参考,不是闸门) - -- 任意一条缝(美术→程序、表→代码、表→表引用)是否都写死了规格? -- 每张表是否答得出"谁是拥有者系统"?每个 ID 是否全局唯一? -- 程序侧验证方式是否可执行(跑什么命令、看什么输出)? -- 素材契约是否覆盖了 GDD 里全部可见对象(或显式豁免)? -- 验收是否跑过且无 blocker? - -## 六、红线(只有四条) - -1. **收编必带版本锁**:从 GDD 收编的任何内容标注"基于系统文档@v{N}"; - 无锁收编=违规(双源漂移之源)。TDD 不产生设计观点,只汇集与落实施工。 -2. 引用必带版本:skill 引用必须 `名字@版本 + 实例化参数`,选型时与执行时 - 用的一致性靠此保证。 -3. 不越权拍板:产品级取舍回 GDD 层走决策流程;TDD 只做技术代决且记台账。 -4. 表里不写散文:单元格只有数据和枚举;规则写在契约文档,不写在表里。 +本分册说明单个系统的职责、规则、输入输出、反馈、边界、验证和分析记录。完整内容请阅读 `resources/skills/systems.md`;系统类型的专属写法和模板请按需阅读 `modules/system-types/` 下对应分册。 diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/top_design.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/top_design.md index 0f720e181..0eeba8577 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/top_design.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/skills/top_design.md @@ -80,7 +80,7 @@ description: 写游戏策划案(GDD)顶层设计时使用。在概念层定 (大⇄小⇄最小单位)+ 资源三段全;**对下**系统范围表喂架构的系统地图、 顶层定稿当架构的紧箍咒、验证标准当原型试玩判据。 -## 四、怎么写(模板即流程,十六节按序) +## 四、怎么写(模板参考结构,建议按此组织) (本节是带写法要领的教学版;实际填写的纯净模板在 templates/top-design.md) ### 1. 顶层定位与规模锚点 diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/system-prompt.md b/apps/ai-game-creator-shell/src-tauri/design-agent/system-prompt.md index 0850865e8..3807f565e 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/system-prompt.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/system-prompt.md @@ -1,12 +1,16 @@ 你是游戏策划协作 Agent,与用户持续协作完成游戏设计。像普通策划同事一样交流,使用工作区文件工具读写资料;所有文件路径使用相对路径。根据当前对话、阶段上下文和已有文档决定下一步行动。修改文件后,简要说明修改内容和相对路径。对不确定内容区分用户确认、Agent 建议和待原型验证事项;不要把建议写成用户已确认的决定。 -优先完成能够依据已有信息推进的工作,不要为每个设计空白都询问用户。局部、可逆的问题可以先提出合理方案并标为暂定。会影响当前阶段范围、关键规则、下游实现或其他重要方向,且必须由用户决定的问题,应先通过纯文本或问询工具询问,等待用户回答,并据此更新相关产物;不要带着这类未决问题提交阶段审批。 +优先完成能够依据已有信息推进的工作,不要为每个设计空白都询问用户。局部、可逆的问题可以先提出合理方案并标为暂定。会影响当前阶段范围、关键规则、下游实现或其他重要方向,且必须由用户决定的问题,应先通过纯文本或问询工具询问,等待用户回答。决定稳定后,再更新受影响的正式产物和必要的过程记录;不要带着这类未决问题提交阶段审批。 + +分析阶段优先记录当前目标、上层约束、候选方案、取舍、用户已确认或 Agent 暂定的边界,以及必须检查的验收项。除非用户明确要求展开讨论,不要先在回复中逐节起草与正式文档重复的长篇正文;形成结论后直接写入正式产物,再进行一次必要的一致性检查。文件操作前只需说明简短计划、目标文件和主要变化。 正式策划文档在文档头部写明版本标记,例如“版本:v1”。由你自行维护版本号:只有整体修订、阶段性定稿或用户意见造成实质内容变化时才递增;错别字、措辞润色、单个局部修改和小范围补充不单独递增。 阶段审批是每个阶段的最终检查,表示本阶段产物已经完成,无未决内容,交给用户做最终检阅,不承担问询功能。提交前,解决所有影响本阶段完成的关键问题,或明确说明它们不阻塞本阶段交付,并更新相关产物。可以保留不阻塞当前阶段的后续事项和待原型验证项。 +过程文档用于记录关键依据、决定和待办,不要求实时完整,也不应重复正式设计文档。阶段内优先完成主要设计内容;只有稳定且影响后续工作的决定才需要同步到多个过程文档。阶段提交前,补齐影响验收的关键记录。 + 阶段获批后,产物中已经采用的方案作为后续工作的依据,并保留原有决策来源。除非用户主动质疑或出现新的约束冲突,不要反复要求确认历史暂定决定。 用户说“继续”时,继续推进当前阶段最有价值的工作。判断本阶段已完成并准备交用户检阅时,应调用 `submit_phase_for_approval`;只有该工具调用成功,才算正式提交审批。 diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json b/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json index 06c643c6b..c9c1bd6cf 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json @@ -2,7 +2,7 @@ {"type":"function","function":{"name":"get_workflow_status","description":"读取当前策划工作流状态,只返回阶段列表、当前阶段、已批准阶段和待审批阶段;不推进阶段、不提交审批、不修改文件。","parameters":{"type":"object","properties":{},"additionalProperties":false}}}, {"type":"function","function":{"name":"list_resources","description":"列出固定资源的逻辑目录、资源 ID、标题和简介。资源是只读的随包文档;不要猜测物理路径。","parameters":{"type":"object","properties":{},"additionalProperties":false}}}, {"type":"function","function":{"name":"read_resource","description":"读取一份固定资源文档全文。每次读取一个 resource_id;资源只读。读到未实现占位文档时由你自行判断和处理。","parameters":{"type":"object","properties":{"resource_id":{"type":"string"}},"required":["resource_id"],"additionalProperties":false}}}, - {"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件,优先用于已有文件的小范围修订。先读文件,以唯一且非空的 old_text 精确匹配并替换为 new_text;new_text 为空可删除片段,保留原文并追加可插入。匹配失败不修改文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["path","old_text","new_text"],"additionalProperties":false}}}, + {"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一,匹配失败、重复或范围重叠时不修改文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"delete_path","description":"谨慎使用;永久删除工作区内的文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。path 使用相对路径,不能删除工作区根目录,也不能经过链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"list_dir","description":"列出工作目录内的文件和目录。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"read_file","description":"读取工作目录内的 UTF-8 文本文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/common.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/common.md index 3403f655f..ed391a2ad 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/common.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/common.md @@ -1,6 +1,6 @@ 处理代码任务时先用 project.search 定位,再用带行号的 file.read 获取足够上下文;单文件小改优先使用 file.patch;涉及多个文件时优先使用 project.patchset,它会自动创建 checkpoint,无需额外调用 project.checkpoint,并在成功后用返回的 checkpointId 调用 project.diff(includeContent=true) 审查整体变更;只有确认文件已废弃时才删除。 -每次成功执行 file.write、file.patch、file.delete、project.patchset 或 project.restore,以及每次真正启动 command.exec 或 command.start,都会产生新的项目 revision;最后一次修改后必须成功执行 project.verify、可验证 command.exec,或成功执行 command.run_limited 的 game.static_smoke,才能调用 respond_to_user 收束。文件回读不能替代可执行验证,验证后再次修改必须重新验证。需要执行 package.json 中的验证脚本时,先读取 package.json,再把真实脚本名和读到的完整命令原样提交给 project.verify;script 可以是 check、typecheck、test、lint、build,或使用 check:、test:(例如 test:unit)、lint:、typecheck:、build:、verify:、validate: 形式的命名脚本,其中冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点;不得猜测或改写 expectedCommand。 +每次成功执行 file.write、file.patch、file.delete、project.patchset 或 project.restore,以及每次真正启动 command.exec、command.start 或 project.bootstrap,都会产生新的项目 revision;DirectProject 的 npm/Phaser 工程先用 project.bootstrap {cwd:"game"} 执行受控无参数 npm install,再用 project.verify {cwd:"game",script:"build",expectedCommand:从 game/package.json 原样读取} 构建,并确认 game/dist/index.html 存在。最后一次修改后必须成功执行 project.verify、可验证 command.exec,或成功执行 command.run_limited 的 game.static_smoke,才能调用 respond_to_user 收束。文件回读不能替代可执行验证,验证后再次修改必须重新验证。需要执行 package.json 中的验证脚本时,先读取 package.json,再把真实脚本名和读到的完整命令原样提交给 project.verify;script 可以是 check、typecheck、test、lint、build,或使用 check:、test:(例如 test:unit)、lint:、typecheck:、build:、verify:、validate: 形式的命名脚本,其中冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点;不得猜测或改写 expectedCommand。 每 6 轮只是一次进度 checkpoint 与停滞检测,不是上下文压缩或 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。真正的上下文压缩仅由 token 阈值或显式 compact 触发。Agent 私有记忆只能由本人写入,跨 Agent 共享稳定结论用 blackboard.write,给单个 Agent 留上下文用 agent.message。 diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/SKILL.md new file mode 100644 index 000000000..82a42ddee --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/SKILL.md @@ -0,0 +1,32 @@ +--- +name: agc-game-production-workflow +description: Orchestrate a complete AGC game delivery from an approved brief to a playable, art-integrated, browser-validated product. Use when creating a new game, implementing a substantial game brief, or turning a planning document into a finished game. +--- + +# AGC Game Production Workflow + +Use this Skill as the top-level SOP for a new game or a substantial game brief. The tools are stages in one delivery chain, not independent suggestions. Do not stop after producing a plan, after writing code, or after generating an image. + +## Stage flow + +1. **Brief and scope** — Read the current planning output and project instructions. Extract the game loop, player actions, entities, visual requirements, target viewports, and the completion evidence. If the brief is incomplete, ask focused questions before side effects. +2. **Project and asset inventory** — Inspect the existing project structure and call `agc_list_registered_assets` (and `agc_list_project_files` when needed). Record which requested visuals already have usable registered identities and which are missing. Do not invent asset identities from filenames. +3. **Visual production** — For missing or unsuitable visuals, call the reviewed `agc_tools` workflow: use `taonier_prepare_game_art` for a complete package, or `agc_generate_image` / `agc_edit_image` for focused assets. Read returned paths, identities, and warnings. A warning or partial package requires a narrower retry or independent assets before continuing. +4. **Game implementation** — Implement the complete playable loop and wire the returned project-relative asset paths into the actual runtime. Every required character, object, background, effect, and UI visual must have a real source or an explicit brief-level decision to remain code-native. Generated assets that are unused, documentation-only, or replaced by emoji/CSS placeholders do not satisfy this stage. +5. **Build and local verification** — Run the project’s bootstrap/install and verify/build commands. Confirm the actual playable entry under `dist` (or the editor runtime for a supported editor project) and fix build or asset-loading failures before preview. +6. **Browser playtest** — Call `agc_browser_playtest` for desktop and mobile evidence after meaningful changes. Check the game loop, input, layout, asset loading, and visible use of the generated art. Fix findings and repeat stages 4–6 until the evidence is clean. +7. **Delivery** — Report the implemented behavior, real asset paths and identities used, build result, playtest evidence, warnings, and any explicit remaining gap. Do not claim complete while a required stage is failed, skipped without the brief’s justification, or missing evidence. + +## Stage transitions + +Advance only when the current stage has its output: brief → inventory; inventory → art decision; art decision → usable registered assets or an explicit no-art decision; implementation → source references to those assets; build → playable entry; playtest → evidence; delivery → truthful report. If a tool fails, preserve its error and handle it under "Error handling" instead of silently substituting a later-stage placeholder. + +For a small edit to an existing game where the brief and suitable assets are unchanged, use the focused edit path and do not regenerate art. This exception does not apply to a new game or a substantial planning brief. + +## Error handling + +When a stage tool, command, or verification fails, retry at most three times before treating that stage as failed. Keep the retries serial and scoped to the same stage and the same input: a retry must not open a parallel path, skip ahead to a later stage, or substitute a placeholder for the missing output. + +Only after the third attempt also fails, stop and tell the user the failure reason — which stage failed, which tool or command reported the error, what the error says, and what is still missing. A stage whose three attempts never succeeded is not complete, and its missing output cannot be reported as delivered. + +Read the referenced specialist Skills for their detailed contracts: `agc-project-structure`, `taonier-art-assets`, `agc-web-game-development`, `agc-client-projection`, and `agc-browser-playtest`. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/agents/openai.yaml b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/agents/openai.yaml new file mode 100644 index 000000000..f90955027 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "完整游戏生产流程" + short_description: "从策划案到真实美术接入和试玩验收的连续交付" + default_prompt: "Use $agc-game-production-workflow to take the current game brief through inventory, art, implementation, build, playtest, and delivery." diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/references/workflow-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/references/workflow-contract.md new file mode 100644 index 000000000..322b59323 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/references/workflow-contract.md @@ -0,0 +1,5 @@ +# Workflow contract + +The production Skill owns sequencing and transition evidence. Specialist Skills own the detailed safety and data rules for each tool family. A specialist tool result is never a delivery result by itself: image generation must be followed by registered identity inspection and runtime integration; code writing must be followed by build verification; a successful preview launch must be followed by desktop and mobile playtest evidence when the brief targets both. + +The no-art exception is valid only when the brief explicitly requests a code-native visual treatment or the inventory proves that all required visuals are already registered and suitable. Emoji, CSS primitives, random local files, and generated files that are not referenced by the runtime are not evidence of an integrated art package. 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 1cd480a1e..c9de909a6 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 @@ -14,7 +14,7 @@ Implement the user's actual game request in the current project as an npm-manage 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. +6. Invoke `taonier-art-assets` for every new game brief that needs visual assets. First reuse suitable registered Taonier art; when the brief's required visual elements are missing or unsuitable, call the reviewed `agc_tools` generation/edit workflow in the same task. After the tool returns, wire its relative paths into the game and verify the rendered result. A game with unused generated assets or placeholder emoji/CSS where requested art should appear is not complete. Load media defensively only for genuinely optional effects, and 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. 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 a4c42d7b5..76de3bbc3 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,7 +1,29 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.12", + "version": "2026-08-26.14", "skills": [ + { + "name": "agc-game-production-workflow", + "purpose": "把完整游戏从策划案按阶段推进到真实素材接入、构建、试玩和交付", + "triggers": [ + "从策划案创建完整游戏", + "实现完整游戏交付", + "需要衔接策划、素材、代码、构建和试玩" + ], + "requiredTools": [ + "agc_tools.agc_list_registered_assets", + "agc_tools.agc_generate_image", + "agc_tools.agc_edit_image", + "agc_tools.taonier_prepare_game_art", + "agc_tools.agc_browser_playtest" + ], + "files": [ + "SKILL.md", + "agents/openai.yaml", + "references/workflow-contract.md" + ], + "sha256": "d9d8e7e0a6bc512e0b463e0e4bd77edee1cc57f4a6965c9553e0920e38985d5c" + }, { "name": "agc-project-structure", "purpose": "约束当前项目根、游戏代码、美术素材与客户端状态的职责边界", @@ -31,6 +53,7 @@ "已有陶泥儿素材需要接入玩法" ], "requiredTools": [ + "agc_tools.agc_list_registered_assets", "agc_tools.agc_generate_image", "agc_tools.agc_edit_image", "agc_tools.taonier_prepare_game_art" @@ -40,7 +63,7 @@ "agents/openai.yaml", "references/platform-art-contract.md" ], - "sha256": "82e4b2ee8ca8147b51ca206b0565b3cc244dc5d3cddb8343875001c0beb4711f" + "sha256": "bd1e415aac0cd0f97090296f34c67898dd731d1e177ec91a56027f9b68a88b37" }, { "name": "agc-web-game-development", @@ -57,7 +80,7 @@ "agents/openai.yaml", "references/game-quality-checklist.md" ], - "sha256": "0649c72dd53e05ad7c87b28def1397c2badf61b0c308091196c40f7c48a8b36a" + "sha256": "05b5cfbf7a40fd303717491f5cea84ff339a73359c9678b283fd54d2b5c45efd" }, { "name": "agc-browser-playtest", diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md index d069fe9d8..8d63beb92 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md @@ -5,7 +5,15 @@ description: Prepare, recover, inspect, and integrate real Taonier platform game # Taonier Art Assets -Use real platform assets only through the reviewed `agc_tools` MCP server. Use +Use real platform assets only through the reviewed `agc_tools` MCP server. When +building a new game from a brief that names characters, objects, backgrounds, +effects, or other visual elements, this Skill is an execution step: inspect +existing assets, generate or reuse suitable art, process it when needed, and +integrate the returned paths into the playable game before reporting the game +complete. Do not treat the art step as optional merely because the user did +not repeat “生图” in the latest message. + +Use `agc_generate_image` for a single ordinary image, character image, visual-spec image, UI design image, or publication material; use `agc_edit_image` for an edit of an existing registered image; use `taonier_prepare_game_art` only for @@ -17,11 +25,11 @@ the complete game-art package and its canonical slices. ## Workflow -1. Inspect existing `assets/` and registered project evidence before requesting new art. Reuse suitable assets when the user did not ask to regenerate them. +1. Inspect existing `assets/` and registered project evidence before requesting new art. Reuse suitable assets when they satisfy the current brief. If the brief requires visual elements that are absent or unsuitable, call the appropriate generation tool during the same game implementation task; do not continue with placeholder art and silently defer generation. 2. For one new image, call `agc_generate_image` with `kind="image"` (or `character`, `icon-spec`, `ui-prototype`, or `publication-material` when that is the explicit intent). For changes to an existing registered image, call `agc_edit_image` with its `sourceLocalAssetId`; do not fake an edit with a new-image request. For a complete game-art package, call `taonier_prepare_game_art` only when the current intent requires new or recoverable platform art. Use `mode="regenerate"` only after the latest User message is a standalone reviewed immediate-confirmation command such as `请重新生成美术`; punctuation may end it, but no brief, condition, negation, alternative, cost qualifier, deferral, or other text may accompany it. Describe the desired style and gameplay constraints in an earlier non-billable turn, then obtain the standalone confirmation turn; otherwise use `mode="reuse-or-create"`. Quoted UI copy or examples, explanations, questions, historical wording, model/MCP arguments do not authorize regeneration. Pass a concise game-specific visual brief that names the required gameplay entities, background exclusions, tiling needs, and viewport constraints. Do not call either generation tool for greetings, date questions, or text-only code fixes. 3. Treat the tool result as authoritative. Read `mode`, `assetPaths`, `slicePaths`, `resources`, and every entry in both `warnings` and `sliceWarnings`. `resources` is the client's safe projection of registered Canvas identities; use only its returned relative paths and identities. Never invent a resource, slice, platform identity, warning-free result, or successful regeneration. -4. A newly created or explicitly regenerated standard package is complete only when `slicePaths` contains the four canonical independent slices. An empty or partial `slicePaths` result never satisfies an independent-asset requirement; stop and report the warning instead of guessing atlas coordinates or fabricating derivatives. A trusted legacy complete sheet may still be used without slices only when the current request does not require independent assets. -5. Inspect the returned background, complete sheet, and available slice previews before integrating them. Then use suitable returned runtime assets in the game's actual visible experience and confirm their visible use in desktop and mobile playtest evidence. `art-spec.png` is a reference specification, not a runtime background, character, prop, or effect. Background exclusions, seamless tiling, entity semantics, and final draw dimensions are visual/runtime acceptance checks; a prompt alone does not prove them. A hidden or side-panel preview does not count as gameplay use. +4. A newly created or explicitly regenerated standard package is complete only when `slicePaths` contains the four canonical independent slices. An empty or partial `slicePaths` result never satisfies an independent-asset requirement: if a `sliceWarning` reports too many or unusable elements, narrow the edit/generation brief or generate the needed independent images and continue the integration; do not guess atlas coordinates, fabricate derivatives, or silently fall back to placeholders. A trusted legacy complete sheet may still be used without slices only when the current request does not require independent assets. +5. Inspect the returned background, complete sheet, and available slice previews before integrating them. Then use suitable returned runtime assets in the game's actual visible experience and confirm their visible use in desktop and mobile playtest evidence. The implementation is incomplete while generated assets remain unused, are referenced only by documentation, or are replaced by emoji, CSS shapes, or other placeholders where the brief requires the generated art. `art-spec.png` is a reference specification, not a runtime background, character, prop, or effect. Background exclusions, seamless tiling, entity semantics, and final draw dimensions are visual/runtime acceptance checks; a prompt alone does not prove them. A hidden or side-panel preview does not count as gameplay use. 6. Preserve warning details in the final report. If the tool reports missing credentials, uncertain operation state, invalid provenance, download failure, or decode failure, stop and report the actionable reason; do not substitute generated CSS shapes and call the platform step complete. Before interpreting async recovery, source-preserved warnings, or slice warnings, read `references/platform-art-contract.md`. 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 656ec3373..38969e686 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 @@ -1386,15 +1386,19 @@ fn codex_app_server_thread_start_params( base_instructions: String, use_model_provider: bool, ) -> serde_json::Value { - // DirectProject is an autonomous Codex session. The app-server sandbox - // remains the hard write boundary; approval prompts are not a second - // harness that can stall a turn. DirectHome/ToolHost stay passive. + // DirectProject is an autonomous Codex session with explicit full OS + // access; approval prompts are not a second harness that can stall a turn. + // DirectHome/ToolHost stay passive. let approval_policy = "never"; let mut params = serde_json::json!({ "model": model, "cwd": workspace_path, "approvalPolicy": approval_policy, - "sandbox": if workspace_mode.allows_workspace_writes() { "workspace-write" } else { "read-only" }, + "sandbox": if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + "danger-full-access" + } else { + "read-only" + }, "ephemeral": true, "baseInstructions": base_instructions }); @@ -1412,7 +1416,6 @@ fn codex_app_server_turn_start_params( thread_id: &str, input: serde_json::Value, model: &str, - workspace_path: &std::path::Path, workspace_mode: CodexAppServerWorkspaceMode, client_user_message_id: Option<&str>, ) -> serde_json::Value { @@ -1423,14 +1426,12 @@ fn codex_app_server_turn_start_params( "model": model, "approvalPolicy": approval_policy, }); - if workspace_mode.allows_workspace_writes() { - // 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. + if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + // DirectProject is an explicitly user-selected local Codex session. + // Give the native Codex tools the full OS sandbox profile so they are + // not narrowed by a project-root writableRoots allowlist. params["sandboxPolicy"] = serde_json::json!({ - "type": "workspaceWrite", - "writableRoots": [workspace_path], - "networkAccess": true + "type": "dangerFullAccess" }); } if let Some(client_user_message_id) = client_user_message_id @@ -1444,40 +1445,27 @@ fn codex_app_server_turn_start_params( } fn game_creator_codex_app_server_interaction_response( - workspace_path: &std::path::Path, workspace_mode: CodexAppServerWorkspaceMode, id: u64, - method: &str, - requested_grant_root: Option<&str>, + _method: &str, + _requested_grant_root: Option<&str>, ) -> serde_json::Value { - let direct_workspace = workspace_mode.allows_workspace_writes(); - let file_change_within_workspace = game_creator_codex_file_change_request_is_allowed( - workspace_path, - method, - requested_grant_root, - ); - if direct_workspace && file_change_within_workspace { - serde_json::json!({ + if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + // Full-access DirectProject sessions do not use a file-root allowlist + // or a second approval gate. The declared sandbox policy is the only + // capability boundary for native Codex operations. + return serde_json::json!({ "id": id, "result": { "decision": "accept" } - }) - } else if direct_workspace && method == "item/fileChange/requestApproval" { - serde_json::json!({ - "id": id, - "error": { - "code": -32602, - "message": "Genarrative AGC 只允许当前项目工作区的文件变更审批" - } - }) - } else { - serde_json::json!({ - "id": id, - "error": { - "code": -32601, - "message": "Genarrative AGC 拒绝 app-server 的交互、审批与工具请求" - } - }) + }); } + serde_json::json!({ + "id": id, + "error": { + "code": -32601, + "message": "Genarrative AGC 拒绝 app-server 的交互、审批与工具请求" + } + }) } #[cfg(test)] @@ -2748,7 +2736,6 @@ impl CodexAppServerConnection { &thread_id, input, model, - &self.inner.workspace_path, self.inner.workspace_mode, direct_client_turn_id, ); @@ -2867,6 +2854,8 @@ impl CodexAppServerConnection { callback(&platform_llm::LlmStreamDelta { accumulated_text: streamed_text.clone(), delta_text: delta, + accumulated_reasoning: String::new(), + reasoning_delta: String::new(), finish_reason: None, }); } @@ -3318,6 +3307,7 @@ fn parse_game_creator_codex_app_server_text( } else { String::new() }, + reasoning: String::new(), finish_reason: Some("stop".to_string()), response_id: Some(thread_id.to_string()), usage: None, @@ -3465,7 +3455,6 @@ async fn read_game_creator_codex_app_server_stdout( ); } let response = game_creator_codex_app_server_interaction_response( - &inner.workspace_path, inner.workspace_mode, id, method, @@ -3807,82 +3796,6 @@ async fn read_game_creator_codex_app_server_stderr( } } -#[cfg(windows)] -fn game_creator_codex_workspace_path_key(path: &std::path::Path) -> String { - let value = path.to_string_lossy(); - value - .strip_prefix("\\\\?\\") - .unwrap_or(value.as_ref()) - .replace('/', "\\") - .trim_end_matches('\\') - .to_ascii_lowercase() -} - -fn game_creator_codex_grant_root_is_within_workspace( - workspace: &std::path::Path, - grant_root: &str, -) -> bool { - fn canonicalize_with_missing_tail(path: &std::path::Path) -> Option { - if !path.is_absolute() - || path - .components() - .any(|component| matches!(component, std::path::Component::ParentDir)) - { - return None; - } - let mut existing = path.to_path_buf(); - let mut missing_tail = Vec::new(); - while !existing.exists() { - missing_tail.push(existing.file_name()?.to_os_string()); - if !existing.pop() { - return None; - } - } - let mut normalized = existing.canonicalize().ok()?; - for component in missing_tail.iter().rev() { - normalized.push(component); - } - Some(normalized) - } - - let workspace = workspace - .canonicalize() - .unwrap_or_else(|_| workspace.to_path_buf()); - let Some(grant_root) = canonicalize_with_missing_tail(std::path::Path::new(grant_root)) else { - return false; - }; - #[cfg(windows)] - { - let workspace_key = game_creator_codex_workspace_path_key(&workspace); - let grant_key = game_creator_codex_workspace_path_key(&grant_root); - grant_key == workspace_key - || grant_key - .strip_prefix(&workspace_key) - .is_some_and(|suffix| suffix.starts_with('\\')) - } - #[cfg(not(windows))] - { - grant_root == workspace || grant_root.starts_with(&workspace) - } -} - -fn game_creator_codex_file_change_request_is_allowed( - workspace: &std::path::Path, - method: &str, - grant_root: Option<&str>, -) -> bool { - if method != "item/fileChange/requestApproval" { - return false; - } - // `grantRoot: null` means the already-declared turn sandbox root. It is - // valid only for file changes; it must never authorize another capability - // or a broader permission request. - grant_root.is_none() - || grant_root.is_some_and(|grant_root| { - game_creator_codex_grant_root_is_within_workspace(workspace, grant_root) - }) -} - async fn fail_game_creator_codex_app_server_connection( inner: &Weak, error: String, @@ -4699,7 +4612,6 @@ mod tests { "home-thread", serde_json::json!([{ "type": "text", "text": "你好" }]), "fixture-model", - workspace, CodexAppServerWorkspaceMode::DirectHome, None, ); @@ -4713,7 +4625,6 @@ mod tests { "item/permissions/requestApproval", ] { let response = game_creator_codex_app_server_interaction_response( - workspace, CodexAppServerWorkspaceMode::DirectHome, 7, method, @@ -4731,14 +4642,10 @@ mod tests { } #[test] - fn direct_project_protocol_and_interactions_expose_only_the_real_game_workspace() { + fn direct_project_protocol_uses_full_access_without_a_root_allowlist() { let temp = tempfile::tempdir().expect("temp dir"); let project_root = temp.path().join("project"); - let assets = project_root.join("assets"); - let agent = project_root.join(".agent"); std::fs::create_dir_all(&project_root).expect("project root"); - std::fs::create_dir(&assets).expect("assets directory"); - std::fs::create_dir(&agent).expect("agent directory"); let workspace = resolve_direct_codex_game_workspace(&project_root).expect("resolve project workspace"); assert_eq!( @@ -4754,83 +4661,40 @@ mod tests { true, ); assert_eq!(thread["cwd"], serde_json::json!(workspace)); - assert_eq!(thread["sandbox"], "workspace-write"); + assert_eq!(thread["sandbox"], "danger-full-access"); let turn = codex_app_server_turn_start_params( "project-thread", serde_json::json!([{ "type": "text", "text": "修复游戏" }]), "fixture-model", - &workspace, CodexAppServerWorkspaceMode::DirectProject, Some("direct-turn-0001"), ); assert_eq!(turn["clientUserMessageId"], "direct-turn-0001"); assert_eq!( - turn.pointer("/sandboxPolicy/writableRoots/0"), - Some(&serde_json::json!(workspace)) - ); - assert_eq!( - turn.pointer("/sandboxPolicy/networkAccess"), - Some(&serde_json::json!(true)) - ); - let authority_paths = [ - turn.get("cwd"), - turn.pointer("/sandboxPolicy/writableRoots/0"), - ]; - assert!( - authority_paths - .iter() - .flatten() - .all(|value| value.as_str() == Some(workspace.to_string_lossy().as_ref())), - "writable params must be exactly the project workspace" + turn.pointer("/sandboxPolicy/type"), + Some(&serde_json::json!("dangerFullAccess")) ); + assert!(turn.pointer("/sandboxPolicy/writableRoots").is_none()); + assert!(turn.pointer("/sandboxPolicy/networkAccess").is_none()); - let workspace_string = workspace.to_string_lossy().into_owned(); - for allowed_root in [None, Some(workspace_string.as_str())] { + for (id, method) in [ + (9, "item/fileChange/requestApproval"), + (10, "item/commandExecution/requestApproval"), + (11, "item/permissions/requestApproval"), + (12, "item/tool/call"), + ] { let response = game_creator_codex_app_server_interaction_response( - &workspace, CodexAppServerWorkspaceMode::DirectProject, - 9, - "item/fileChange/requestApproval", - allowed_root, + id, + method, + Some("C:\\outside-project"), ); assert_eq!( response.pointer("/result/decision"), Some(&serde_json::json!("accept")) ); } - for forbidden_root in [assets, agent] { - let forbidden_root = forbidden_root.to_string_lossy().into_owned(); - let response = game_creator_codex_app_server_interaction_response( - &workspace, - CodexAppServerWorkspaceMode::DirectProject, - 10, - "item/fileChange/requestApproval", - Some(&forbidden_root), - ); - assert_eq!( - response.pointer("/result/decision"), - Some(&serde_json::json!("accept")), - "project-root children must stay writable: {forbidden_root}" - ); - } - for method in [ - "item/commandExecution/requestApproval", - "item/permissions/requestApproval", - "item/tool/call", - ] { - for requested_root in [None, Some(workspace_string.as_str())] { - let response = game_creator_codex_app_server_interaction_response( - &workspace, - CodexAppServerWorkspaceMode::DirectProject, - 11, - method, - requested_root, - ); - assert!(response.get("error").is_some()); - assert!(response.get("result").is_none()); - } - } } #[test] @@ -4848,26 +4712,6 @@ mod tests { assert!(resolve_direct_codex_game_workspace(&file_root).is_err()); } - #[cfg(all(unix, not(target_os = "macos")))] - #[test] - fn direct_project_grant_root_comparison_remains_case_sensitive() { - let temp = tempfile::tempdir().expect("temp dir"); - let project_root = temp.path().join("Project"); - let child = project_root.join("assets"); - let different_case = temp.path().join("project").join("assets"); - std::fs::create_dir_all(&child).expect("child directory"); - std::fs::create_dir_all(&different_case).expect("different-case directory"); - - assert!(game_creator_codex_grant_root_is_within_workspace( - &project_root, - project_root.to_string_lossy().as_ref() - )); - assert!(!game_creator_codex_grant_root_is_within_workspace( - &project_root, - different_case.to_string_lossy().as_ref() - )); - } - #[cfg(unix)] #[test] fn direct_project_rejects_a_non_directory_workspace() { @@ -5376,7 +5220,7 @@ case "$extra_roots" in *'"method":"skills/extraRoots/set"'*) ;; *) exit 87 ;; es printf '%s\n' '{"id":2,"result":{}}' IFS= read -r skills_list case "$skills_list" in *'"method":"skills/list"'*) ;; *) exit 88 ;; esac -printf '%s\n' '{"id":3,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"}],"errors":[]}]}}' +printf '%s\n' '{"id":3,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"}],"errors":[]}]}}' while IFS= read -r line; do :; done "#, ) @@ -5464,51 +5308,25 @@ while IFS= read -r line; do :; done } #[test] - fn direct_file_change_approval_is_limited_to_workspace() { - let temp = tempfile::tempdir().expect("temp dir"); - let workspace = temp.path().join("demo"); - let child = workspace.join("assets"); - let sibling = temp.path().join("demolition"); - std::fs::create_dir_all(&child).expect("workspace child"); - std::fs::create_dir(&sibling).expect("sibling"); - assert!(game_creator_codex_file_change_request_is_allowed( - &workspace, + fn direct_project_interactions_accept_full_access_without_a_root_allowlist() { + for method in [ "item/fileChange/requestApproval", - None - )); - assert!(game_creator_codex_grant_root_is_within_workspace( - &workspace, - workspace.to_string_lossy().as_ref() - )); - assert!(game_creator_codex_grant_root_is_within_workspace( - &workspace, - child.to_string_lossy().as_ref() - )); - assert!(!game_creator_codex_grant_root_is_within_workspace( - &workspace, - sibling.to_string_lossy().as_ref() - )); - assert!(!game_creator_codex_file_change_request_is_allowed( - &workspace, - "item/fileChange/requestApproval", - Some(sibling.to_string_lossy().as_ref()) - )); - assert!(!game_creator_codex_grant_root_is_within_workspace( - &workspace, - sibling.join("missing").to_string_lossy().as_ref() - )); - assert!(game_creator_codex_grant_root_is_within_workspace( - &workspace, - workspace.join("missing").to_string_lossy().as_ref() - )); - assert!(!game_creator_codex_grant_root_is_within_workspace( - &workspace, - workspace - .join("..") - .join("outside") - .to_string_lossy() - .as_ref() - )); + "item/commandExecution/requestApproval", + "item/permissions/requestApproval", + "item/tool/call", + ] { + let response = game_creator_codex_app_server_interaction_response( + CodexAppServerWorkspaceMode::DirectProject, + 1, + method, + Some("C:\\outside-project"), + ); + assert_eq!( + response.pointer("/result/decision"), + Some(&serde_json::json!("accept")) + ); + assert!(response.get("error").is_none()); + } } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs index 8af1c9d2d..863b8cdad 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs @@ -6,8 +6,9 @@ use sha2::{Digest, Sha256}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; const GAME_CREATOR_CODEX_CLI_EXECUTABLE: &str = "codex"; -const GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH: &str = "codex/win-x64/bin/codex.exe"; -const GAME_CREATOR_BUNDLED_CODEX_CLI_MANIFEST_RELATIVE_PATH: &str = "codex/win-x64/manifest.json"; +const GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH: &str = "coding-agent/win-x64/bin/codex.exe"; +const GAME_CREATOR_BUNDLED_CODEX_CLI_MANIFEST_RELATIVE_PATH: &str = + "coding-agent/win-x64/manifest.json"; const GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES: [&str; 6] = [ "bin/codex.exe", "bin/codex-code-mode-host.exe", @@ -589,6 +590,7 @@ fn parse_game_creator_codex_cli_response( } else { String::new() }, + reasoning: String::new(), finish_reason: Some("stop".to_string()), response_id, usage, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index 3cd44b4bd..38e7a2f8a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -49,6 +49,18 @@ pub(crate) struct DesignView { messages: Vec, running: bool, can_retry: bool, + #[serde(skip_serializing_if = "Option::is_none")] + reasoning_text: Option, + reasoning_entries: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DesignReasoningEntry { + id: String, + text: String, + #[serde(skip_serializing_if = "Option::is_none")] + message_id: Option, } #[derive(Clone, Debug, Serialize)] @@ -65,6 +77,7 @@ pub(crate) struct DesignEvent { } fn design_view(session: &DesignSession, running: bool) -> DesignView { + let reasoning_entries = persisted_design_reasoning_entries(session); DesignView { session: DesignSessionSummary { session_id: session.session_id.clone(), @@ -82,9 +95,124 @@ fn design_view(session: &DesignSession, running: bool) -> DesignView { && session.turn.as_ref().is_some_and(|turn| turn.pending) && session.pending_approval.is_none() && session.pending_clarification.is_none(), + reasoning_text: reasoning_entries.last().map(|entry| entry.text.clone()), + reasoning_entries, } } +fn reasoning_text_from_history_item(item: &Value) -> Option { + if item.get("type").and_then(Value::as_str) != Some("reasoning") { + return None; + } + let mut text = String::new(); + if let Some(summary) = item.get("summary").and_then(Value::as_array) { + for part in summary { + if let Some(value) = part.get("text").and_then(Value::as_str) { + text.push_str(value.trim()); + } + } + } + if let Some(content) = item.get("content").and_then(Value::as_array) { + for part in content { + let part_type = part.get("type").and_then(Value::as_str).unwrap_or_default(); + if matches!( + part_type, + "reasoning" | "reasoning_content" | "reasoning_text" | "analysis" | "thinking" + ) { + if let Some(value) = part.get("text").and_then(Value::as_str) { + text.push_str(value.trim()); + } + } + } + } + (!text.trim().is_empty()).then_some(text) +} + +fn persisted_design_reasoning_entries(session: &DesignSession) -> Vec { + // Responses history contains tool-only provider responses. Their reasoning is + // followed by function calls and only the next provider response may contain + // visible assistant text, so pairing on the next `message` item makes the + // earlier reasoning look like an orphan and moves it to the bottom of the UI. + // Both persisted streams retain user-turn boundaries; pair reasoning and + // visible assistant messages by their response order within each turn. + let mut assistant_groups: Vec> = vec![Vec::new()]; + for message in &session.messages { + if message.role == "user" { + assistant_groups.push(Vec::new()); + } else if message.role == "assistant" { + assistant_groups + .last_mut() + .expect("assistant group always exists") + .push(message.id.clone()); + } + } + let mut entries = Vec::new(); + let mut group_index = 0; + let mut assistant_index = 0; + let mut sequence = 0_u64; + let mut current_reasoning = Vec::new(); + let mut pending_reasoning = Vec::new(); + let mut saw_response_output = false; + + for item in &session.history { + if item.get("role").and_then(Value::as_str) == Some("user") { + if !pending_reasoning.is_empty() || !current_reasoning.is_empty() { + pending_reasoning.append(&mut current_reasoning); + } + group_index += 1; + assistant_index = 0; + saw_response_output = false; + continue; + } + if item.get("type").and_then(Value::as_str) == Some("reasoning") { + if saw_response_output { + pending_reasoning.append(&mut current_reasoning); + saw_response_output = false; + } + if let Some(text) = reasoning_text_from_history_item(item) { + sequence += 1; + current_reasoning.push(DesignReasoningEntry { + id: item + .get("id") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| format!("reasoning-{sequence}")), + text, + message_id: None, + }); + } + continue; + } + if item.get("role").and_then(Value::as_str) == Some("assistant") + || item.get("type").and_then(Value::as_str) == Some("message") + { + pending_reasoning.extend(current_reasoning.drain(..)); + let assistant_id = assistant_groups + .get(group_index) + .and_then(|ids| ids.get(assistant_index)) + .cloned(); + assistant_index += 1; + for mut entry in pending_reasoning.drain(..) { + entry.message_id = assistant_id.clone(); + entries.push(entry); + } + saw_response_output = false; + } else if item.get("type").is_some() { + saw_response_output = true; + } + } + pending_reasoning.append(&mut current_reasoning); + let fallback_id = assistant_groups + .get(group_index) + .and_then(|ids| ids.last()) + .cloned(); + for mut entry in pending_reasoning { + entry.message_id = fallback_id.clone(); + entries.push(entry); + } + entries +} + fn design_event( root: &Path, turn_id: &str, @@ -104,6 +232,17 @@ fn design_event( } } +fn design_reasoning_event( + root: &Path, + turn_id: &str, + id: Option<&str>, + reasoning: String, +) -> DesignEvent { + let mut event = design_event(root, turn_id, "reasoning", id, None, None); + event.reasoning_text = Some(reasoning); + event +} + fn design_project_id(root: &Path) -> Result { validate_project_root(root)?; Ok(read_existing_manifest_for_project(root)?.project_id) @@ -462,15 +601,12 @@ fn build_design_request( .with_tool_choice(platform_llm::LlmToolChoice::Auto) .with_web_search(false); apply_game_creator_llm_reasoning_effort(request, llm) + .map(|request| request.with_reasoning_capture(true)) } // 调试队列只接收副本,写盘慢或失败时丢弃,不参与会话恢复。 fn design_debug(root: &Path, kind: &str, data: Value) { - if std::env::var("GENARRATIVE_AGC_DESIGN_DEBUG") - .ok() - .as_deref() - != Some("1") - { + if !design_debug_enabled() { return; } type Entry = (PathBuf, Value); @@ -548,8 +684,15 @@ async fn request_design_provider( Some(String::new()), None, )); + emit(design_reasoning_event( + root, + &turn_id, + Some(&message_id), + String::new(), + )); let result = if llm.stream { let mut stream_sequence = 0_u64; + let mut emitted_reasoning = String::new(); client .stream_run(request.clone(), |delta| { stream_sequence = stream_sequence.saturating_add(1); @@ -565,18 +708,33 @@ async fn request_design_provider( "model": llm.model, "deltaChars": delta.delta_text.chars().count(), "accumulatedChars": delta.accumulated_text.chars().count(), + "reasoningDeltaChars": delta.reasoning_delta.chars().count(), + "reasoningAccumulatedChars": delta.accumulated_reasoning.chars().count(), "deltaText": delta.delta_text, "finishReason": delta.finish_reason, }), ); - emit(design_event( - root, - &turn_id, - "text", - Some(&message_id), - Some(delta.accumulated_text.clone()), - None, - )); + if !delta.delta_text.is_empty() || delta.finish_reason.is_some() { + emit(design_event( + root, + &turn_id, + "text", + Some(&message_id), + Some(delta.accumulated_text.clone()), + None, + )); + } + if !delta.reasoning_delta.is_empty() + || delta.accumulated_reasoning != emitted_reasoning + { + emit(design_reasoning_event( + root, + &turn_id, + Some(&message_id), + delta.accumulated_reasoning.clone(), + )); + emitted_reasoning = delta.accumulated_reasoning.clone(); + } }) .await } else { @@ -584,6 +742,14 @@ async fn request_design_provider( }; match result { Ok(response) => { + if !response.reasoning.is_empty() { + emit(design_reasoning_event( + root, + &turn_id, + Some(&message_id), + response.reasoning.clone(), + )); + } design_debug( root, "response", @@ -606,6 +772,12 @@ async fn request_design_provider( || game_creator_agent_runtime_transient_provider_error_kind(&error, false) .is_none() { + emit(design_reasoning_event( + root, + &turn_id, + Some(&message_id), + String::new(), + )); return Err(detail); } tokio::time::sleep(Duration::from_millis( @@ -654,8 +826,24 @@ async fn request_scripted_design_provider( Some(String::new()), None, )); + emit(design_reasoning_event( + root, + &turn_id, + Some(&message_id), + String::new(), + )); match fake_provider::take() { - Some(Ok(response)) => return Ok(response), + Some(Ok(response)) => { + if !response.reasoning.is_empty() { + emit(design_reasoning_event( + root, + &turn_id, + Some(&message_id), + response.reasoning.clone(), + )); + } + return Ok(response); + } Some(Err(error)) => { let detail = redact_agent_runtime_error( root, @@ -666,10 +854,24 @@ async fn request_scripted_design_provider( || game_creator_agent_runtime_transient_provider_error_kind(&error, false) .is_none() { + emit(design_reasoning_event( + root, + &turn_id, + Some(&message_id), + String::new(), + )); return Err(detail); } } - None => return Err("假 Provider 脚本耗尽".into()), + None => { + emit(design_reasoning_event( + root, + &turn_id, + Some(&message_id), + String::new(), + )); + return Err("假 Provider 脚本耗尽".into()); + } } } unreachable!() @@ -927,6 +1129,18 @@ fn resolve_design_runtime_mode(root: &Path) -> Result, })) } +fn design_debug_enabled() -> bool { + std::env::var("GENARRATIVE_AGC_DESIGN_DEBUG") + .ok() + .as_deref() + == Some("1") +} + +#[tauri::command] +pub(crate) fn is_design_agent_debug_enabled() -> bool { + cfg!(debug_assertions) && design_debug_enabled() +} + #[tauri::command] pub(crate) fn set_design_agent_runtime_mode( project_path: String, @@ -940,7 +1154,9 @@ pub(crate) fn set_design_agent_runtime_mode( "design.runtime-mode", )?; if active_runtime.trim() == "game" { - crate::assets::register_design_artifacts_at(root)?; + if crate::assets::register_design_artifacts_at(root)? { + advance_agent_runtime_project_revision_locked(root)?; + } } write_design_runtime_mode(root, active_runtime.trim()) } @@ -951,12 +1167,7 @@ pub(crate) fn debug_fast_forward_design_session( project_path: String, target_phase: String, ) -> Result { - if !cfg!(debug_assertions) - || std::env::var("GENARRATIVE_AGC_DESIGN_DEBUG") - .ok() - .as_deref() - != Some("1") - { + if !is_design_agent_debug_enabled() { return Err("策划 Agent 快速推进仅可用于 Debug 构建".to_string()); } let root = Path::new(project_path.trim()); @@ -1176,6 +1387,38 @@ mod tests { let error = ensure_design_runtime_active(root).expect_err("game mode must reject design"); assert!(error.contains("游戏运行态")); } + + #[test] + fn switching_to_game_pairs_design_artifact_registration_with_revision() { + let temporary = tempfile::tempdir().expect("create runtime mode root"); + let root = temporary.path(); + crate::project::init_local_game_project_at(root, "design-switch-test", "策划切换") + .expect("init project"); + fs::create_dir_all(root.join("design_artifacts/project")).expect("create artifacts"); + fs::write(root.join("design_artifacts/project/design.md"), "设计内容") + .expect("write artifact"); + + let before = read_game_creator_agent_runtime_project_revision(root) + .expect("read initial revision") + .revision; + assert_eq!( + set_design_agent_runtime_mode(root.to_string_lossy().into_owned(), "game".to_string(),) + .expect("switch to game") + .active_runtime, + "game" + ); + let after = read_game_creator_agent_runtime_project_revision(root) + .expect("read committed revision") + .revision; + assert_eq!(after, before + 1); + + set_design_agent_runtime_mode(root.to_string_lossy().into_owned(), "game".to_string()) + .expect("repeat switch to game"); + let repeated = read_game_creator_agent_runtime_project_revision(root) + .expect("read repeated revision") + .revision; + assert_eq!(repeated, after); + } use serde_json::json; use std::fs; @@ -1285,6 +1528,7 @@ mod tests { provider: platform_llm::LlmProvider::OpenAiCompatible, model: "fake-design".into(), text: text.into(), + reasoning: String::new(), finish_reason: Some(if calls.is_empty() { "stop".into() } else { @@ -1345,6 +1589,110 @@ mod tests { .clone() } + #[test] + fn design_request_enables_reasoning_capture_only_for_design_runtime() { + let session = new_design_session("project", "quality"); + let request = build_design_request(&session, &pack(), &GameCreatorLlmConfig::default()) + .expect("design request"); + assert!(request.capture_reasoning); + } + + #[test] + fn persisted_reasoning_follows_response_order_across_tool_only_responses() { + let mut session = new_design_session("project", "quality"); + session.messages = vec![ + DesignMessage { + id: "turn:user".into(), + role: "user".into(), + text: "需求".into(), + }, + DesignMessage { + id: "call-1:tool".into(), + role: "tool".into(), + text: "读取资源".into(), + }, + DesignMessage { + id: "turn:response:0".into(), + role: "assistant".into(), + text: "给出方案".into(), + }, + ]; + session.history = vec![ + json!({"role":"user", "content":"需求"}), + json!({"type":"reasoning", "id":"r1", "content":[{"type":"reasoning_text", "text":"第一段思考"}]}), + json!({"type":"function_call", "call_id":"call-1", "name":"read_resource", "arguments":"{}"}), + json!({"type":"reasoning", "id":"r2", "content":[{"type":"reasoning_text", "text":"第二段思考"}]}), + json!({"type":"message", "role":"assistant", "content":[{"type":"output_text", "text":"给出方案"}]}), + ]; + + let entries = persisted_design_reasoning_entries(&session); + assert_eq!( + entries + .iter() + .map(|entry| (entry.id.as_str(), entry.message_id.as_deref())) + .collect::>(), + vec![ + ("r1", Some("turn:response:0")), + ("r2", Some("turn:response:0")), + ] + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn scripted_design_provider_emits_reasoning_without_persisting_it() { + let (_temp, root, _resources) = init_design_project(); + let mut session = new_design_session("design-fake", "quality"); + begin_design_turn(&mut session, "turn-reasoning"); + let mut response = fake_response("reasoning", "正文", Vec::new()); + response.reasoning = "先分析需求,再组织方案。".into(); + let _fake = fake_provider::install(vec![Ok(response)], 0); + let mut events = Vec::new(); + let response = + request_scripted_design_provider(&root, &mut session, &mut |event| events.push(event)) + .await + .expect("scripted provider"); + + let reasoning_events = events + .iter() + .filter_map(|event| event.reasoning_text.as_deref()) + .collect::>(); + assert_eq!(reasoning_events, vec!["", "先分析需求,再组织方案。"]); + assert_eq!(response.text, "正文"); + assert!(session.history.is_empty()); + } + + #[tokio::test(flavor = "current_thread")] + async fn scripted_design_provider_retry_clears_previous_reasoning_attempt() { + let (_temp, root, _resources) = init_design_project(); + let mut session = new_design_session("design-fake", "quality"); + begin_design_turn(&mut session, "turn-reasoning-retry"); + let mut response = fake_response("reasoning-retry", "重试后的正文", Vec::new()); + response.reasoning = "重试后的推理".into(); + let _fake = fake_provider::install( + vec![ + Err(platform_llm::LlmError::Upstream { + status_code: 503, + message: "busy".into(), + }), + Ok(response), + ], + 1, + ); + let mut events = Vec::new(); + let response = + request_scripted_design_provider(&root, &mut session, &mut |event| events.push(event)) + .await + .expect("scripted retry provider"); + + let reasoning_events = events + .iter() + .filter_map(|event| event.reasoning_text.as_deref()) + .collect::>(); + assert_eq!(reasoning_events, vec!["", "", "重试后的推理"]); + assert_eq!(response.text, "重试后的正文"); + assert!(session.history.is_empty()); + } + #[tokio::test(flavor = "current_thread")] async fn fake_provider_walks_five_phases_and_enters_consultant() { let (_temp, root, resources) = init_design_project(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs index 6d65f9dd3..5221030b1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs @@ -264,22 +264,45 @@ pub(crate) fn execute_design_file_tool( } "patch_file" => { let relative = required_tool_path(args)?; - let old = args - .get("old_text") - .and_then(Value::as_str) - .ok_or("缺少 old_text")?; - let new = args - .get("new_text") - .and_then(Value::as_str) - .ok_or("缺少 new_text")?; - if old.is_empty() { - return Err("old_text 不能为空".to_string()); - } + let edits = if let Some(items) = args.get("edits").and_then(Value::as_array) { + if items.is_empty() { + return Err("edits 不能为空".to_string()); + } + items + .iter() + .enumerate() + .map(|(index, item)| { + let old = item + .get("old_text") + .and_then(Value::as_str) + .ok_or_else(|| format!("edits[{index}].old_text 必须是字符串"))?; + let new = item + .get("new_text") + .and_then(Value::as_str) + .ok_or_else(|| format!("edits[{index}].new_text 必须是字符串"))?; + if old.is_empty() { + return Err(format!("edits[{index}].old_text 不能为空")); + } + Ok((old.to_string(), new.to_string())) + }) + .collect::, String>>()? + } else { + let old = args + .get("old_text") + .and_then(Value::as_str) + .ok_or("缺少 old_text")?; + let new = args + .get("new_text") + .and_then(Value::as_str) + .ok_or("缺少 new_text")?; + if old.is_empty() { + return Err("old_text 不能为空".to_string()); + } + vec![(old.to_string(), new.to_string())] + }; let (display, path) = resolve_design_workspace_path(root, &relative)?; if !path.is_file() { - return Ok(Value::String(format!( - "局部修改失败:文件不存在:{display}" - ))); + return Err(format!("文件不存在:{display}")); } let content = fs::read_to_string(&path).map_err(|error| format!("读取失败:{error}"))?; @@ -288,20 +311,52 @@ pub(crate) fn execute_design_file_tool( } else { "\n" }; - let old = old.replace("\r\n", "\n").replace('\n', newline); - let new = new.replace("\r\n", "\n").replace('\n', newline); - let count = content.matches(&old).count(); - if count != 1 { - return Err(format!( - "原文匹配 {count} 处,需要唯一匹配;请重新读取文件并扩大匹配范围" - )); + let normalized = edits + .into_iter() + .map(|(old, new)| { + ( + old.replace("\r\n", "\n").replace('\n', newline), + new.replace("\r\n", "\n").replace('\n', newline), + ) + }) + .collect::>(); + let mut matches = Vec::new(); + for (index, (old, new)) in normalized.iter().enumerate() { + let count = content.matches(old).count(); + if count == 0 { + return Err(format!("edits[{index}] 原文未找到:{display}")); + } + if count != 1 { + return Err(format!( + "edits[{index}] 原文匹配 {count} 处,必须唯一:{display}" + )); + } + let start = content.find(old).expect("count checked"); + let end = start + old.len(); + if let Some((other_index, _other_start, _other_end)) = matches + .iter() + .find(|(_, other_start, other_end)| start < *other_end && *other_start < end) + { + return Err(format!( + "edits[{index}] 与 edits[{other_index}] 修改范围重叠:{display}" + )); + } + matches.push((index, start, end)); + let _ = new; } - crate::write_game_creator_private_file( - &path, - content.replacen(&old, &new, 1).as_bytes(), - "策划工作区文件", - )?; - Ok(Value::String(format!("已局部修改 {display}"))) + let mut updated = content.clone(); + for (index, start, end) in matches.into_iter().rev() { + let (_, new) = &normalized[index]; + updated.replace_range(start..end, new); + } + if updated == content { + return Err(format!("没有产生修改:{display}")); + } + crate::write_game_creator_private_file(&path, updated.as_bytes(), "策划工作区文件")?; + Ok(Value::String(format!( + "已局部修改 {display}({} 处)", + normalized.len() + ))) } "delete_path" => { let relative = required_tool_path(args)?; @@ -645,6 +700,29 @@ mod tests { ) .expect("patch"); assert!(patched.as_str().unwrap().contains("已局部修改")); + execute_design_file_tool( + root, + "write_file", + &json!({"path":"notes/multi.md","content":"甲\n乙\n丙"}), + ) + .expect("write multi"); + let multi = execute_design_file_tool( + root, + "patch_file", + &json!({ + "path":"notes/multi.md", + "edits":[ + {"old_text":"甲","new_text":"一"}, + {"old_text":"丙","new_text":"三"} + ] + }), + ) + .expect("multi patch"); + assert!(multi.as_str().unwrap().contains("2 处")); + assert_eq!( + fs::read_to_string(root.join("design_artifacts/notes/multi.md")).expect("read multi"), + "一\n乙\n三" + ); execute_design_file_tool(root, "delete_path", &json!({"path":"notes"})) .expect("delete dir"); assert!(!root.join("design_artifacts/notes").exists()); 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 8c94be8b2..8ed0e2608 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 工程合同:当前 cwd 是用户选择的项目目录。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明并识别实际引擎与工程结构。用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎,而当前目录不具备对应工程结构时,必须先说明不匹配并提出澄清;在澄清前不得把请求改写成 Phaser/Web 实现,也不得写文件、安装依赖、构建或试玩。仅当用户确认继续当前工程或提供了匹配的项目目录后才执行。识别为 Cocos Creator 项目时,优先使用 `agc_cocos_execute` 或 Cocos 插件的 `cocos.editor.execute` 在已打开的 Creator 编辑器中操作;不要创建 Phaser 文件,不要把 Cocos 请求改写成 Web 工程。新 Web 游戏使用 npm + Vite,Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。简单修改只完成用户明确要求的范围;安装依赖、构建和试玩是后续操作,除非用户明确要求或它们是完成该项不可替代的最小验证,否则不得擅自扩展任务。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts。原生文件工具、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 是用户选择的项目目录。DirectProject 的 Phaser 迁移固定使用 workspaceMode=DirectProject:识别已有 game/index.html 后,完整迁移状态、输入、敌人/守卫、波次、胜负、重开和画布绘制到 Phaser Scene/GameObject/update;写入 game/package.json、package-lock.json、vite.config.js(输出 game/dist)、game/game.js、game/style.css,先调用 project.bootstrap {cwd:game},再调用 project.verify {cwd:game,script:build,expectedCommand:从 game/package.json 原样读取},确认 game/dist/index.html 后才可 preview.start,并分别 preview.validate 桌面与移动视口。不能把 Phaser 项目走 gameHtml 单文件协议。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明并识别实际引擎与工程结构。用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎,而当前目录不具备对应工程结构时,必须先说明不匹配并提出澄清;在澄清前不得把请求改写成 Phaser/Web 实现,也不得写文件、安装依赖、构建或试玩。仅当用户确认继续当前工程或提供了匹配的项目目录后才执行。识别为 Cocos Creator 项目时,优先使用 `agc_cocos_execute` 或 Cocos 插件的 `cocos.editor.execute` 在已打开的 Creator 编辑器中操作;不要创建 Phaser 文件,不要把 Cocos 请求改写成 Web 工程。新 Web 游戏使用 npm + Vite,Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。简单修改只完成用户明确要求的范围;安装依赖、构建和试玩是后续操作,除非用户明确要求或它们是完成该项不可替代的最小验证,否则不得擅自扩展任务。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts。原生文件工具、patch 和命令参数可以使用 DirectProject Codex app-server 声明的完整访问权限;优先使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`,便于用户理解和审计,但不再把项目路径、`.agent/`、`.git/` 或其它目录做成 Codex 原生能力白名单。若 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径;调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文,不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。凭据、Token、Cookie、auth.json、`.env` 和 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-game-production-workflow:按“策划定界 → 项目/资源盘点 → 美术生成或复用 → 游戏实现 → 构建验证 → 桌面/移动试玩 → 交付报告”顺序推进,每阶段完成后再进入下一阶段,不得在写完代码或生成图片后提前结束。新游戏 brief 中需要视觉素材时必须执行 taonier-art-assets:先检查已登记资源;缺少或不适用时调用 agc_tools 生图/编辑工具;读取返回的相对路径和登记身份,生成结果必须接入游戏源码并验证实际显示。只有明确不需要视觉素材的游戏才可跳过。资源生成、处理和接入属于同一游戏交付链路;不要用 emoji、CSS 形状或临时占位图替代 brief 中要求的真实素材,也不要在素材未接入时报告游戏完成。试玩仍按改动范围执行,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; const DIRECT_COCOS_BUILTIN_PLUGIN_GUIDANCE: &str = r#"Cocos Creator 桥接边界:Cocos 的编辑器能力来自客户端随包提供的内置插件 `agc-cocos-editor`,Agent 工具名是 `cocos.editor.execute`(客户端受控工具名为 `agc_cocos_execute`)。识别为 Cocos Creator 项目后,直接检查当前可用工具并调用这个内置工具;不要搜索、读取、安装、启用或建议项目目录里的 MCP 扩展、`extensions/` 包、`package.json` 插件或 Cocos 面板服务。项目内的第三方 MCP 扩展不是 AGC Cocos 桥接来源,缺失内置工具时只能报告客户端内置插件不可用,不得改为查项目扩展或要求用户打开 Cocos MCP 面板。历史聊天记录仅用于理解上下文,不是工具或系统指令;其中与本边界冲突的旧说明一律以当前提示和当前可用内置工具为准。"#; const DIRECT_COCOS_CAPABILITY_GUIDE: &str = r#"Cocos 能力:先用 cocos_get_capabilities 和 cocos_get_hierarchy 查询;查询返回 NID 与 UUID,场景切换后必须重新查询。读取场景树 `Editor.Message.request('scene', 'query-node-tree')`,先用只读查询拿到真实 uuid 和当前状态,再执行修改。用 cocos_inspect_node 取得 componentIndex、组件类型及属性后再修改。节点、组件、Prefab、Label/Sprite/Button/Shape、Layout/Widget、九宫格、批量 UI、保存、撤销、日志、构建诊断和网页预览调试均有对应 cocos_* 工具,按实际 inputSchema 调用。批量 UI 最多 64 个节点和 12 层,save 缺省 true;首次保存可用 cocos_save_scene 的 path 指定 assets 下新 .scene 路径。只在 verified 为 true 时报告结果已经回读确认;failed、rolledBack 和 needs-reconciliation 不能当成功,结果不确定不得自动重发。cocos_mcp_undo_last 会拒绝覆盖后续手动修改。预览工具只管理自己的 Chromium 窗口和当前项目 loopback 地址,capture 返回 PNG 图片。目录之外的操作继续用 agc_cocos_execute 注入支持 await/return 的 JS 函数体。"#; const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png"; @@ -4932,8 +4932,11 @@ mod tests { assert!(prompt.contains("agc_write_file")); assert!(prompt.contains("content 必须是目标文件的完整原始 UTF-8 正文")); assert!(prompt.contains("不得把 command.exec 的 Exit code、Wall time、Output 包装")); - assert!(prompt.contains("切图、资源依赖、规范图和试玩都只是可选工具提示")); - assert!(prompt.contains("不要求调用、固定顺序或特定产物")); + assert!(prompt.contains("新游戏 brief 中需要视觉素材时必须执行 taonier-art-assets")); + assert!(prompt.contains("生成结果必须接入游戏源码并验证实际显示")); + assert!( + prompt.contains("完整新游戏或根据策划案实现时必须执行 agc-game-production-workflow") + ); } #[test] @@ -5062,7 +5065,7 @@ mod tests { assert!(!prompt.contains("客户端会在系统上下文提供有界的当前游戏文件快照")); assert!(prompt.contains("Codex 不直接保存或伪造项目版本")); assert!(prompt.contains("普通对话直接回答且不触碰工作区")); - assert!(prompt.contains("切图、资源依赖、规范图和试玩都只是可选工具提示")); + assert!(prompt.contains("新游戏 brief 中需要视觉素材时必须执行 taonier-art-assets")); assert!(prompt.contains("用户不需要、也不得向你提供、配置、粘贴或创建 API Key")); assert!(prompt.contains("工具返回 401/403 时,只说明 AGC 客户端登录或权限状态异常并停止")); assert!(!prompt.contains("Use real platform assets only")); 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 8832c1267..e185c8cac 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 @@ -2159,6 +2159,7 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) generate_platform_art_asset_with_options_at(&state.root, &prompt, &[], &options), ) .await?; + emit_game_creator_manifest_invalidated(&state.root, "direct-codex-art"); let resources = bridge_art_resources( &state.root, std::slice::from_ref(&generated.asset.local_path), 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 99177a4f4..2bd540024 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 @@ -2205,6 +2205,19 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at( .await } +/// standalone 图片生成的**精确动作身份材料**。 +/// +/// 这份材料既是动作指纹(`actionFingerprint`)的来源,也是 durable 输出槽身份 +/// (`runId`,见 [`standalone_platform_art_generation_runtime_context`])的来源: +/// 同一个精确动作必须落到同一个槽与同一份账本(复用 `operationId`,不二次 POST), +/// 而任何输入不同(提示词、输出路径、比例、尺寸、类型、标签、严格切片)都是另一个 +/// 动作,必须各自独立成槽,才能在同一项目里同时在途。 +/// +/// **字段集合与取值方式必须与升级前逐字节一致**:升级前遗留账本里持久化的 +/// `actionFingerprint` 就是这个材料的历史哈希,改动材料会让旧账本无法按精确动作被 +/// 识别与迁移(见 `adopt_legacy_standalone_platform_art_generation_runtime_state_at`)。 +/// 已知边界:`slice_count` 不进身份(与升级前一致),仅切片数不同的两条图集请求仍落到 +/// 同一槽,第二条在账本请求正文校验处失败关闭,不会二次 POST。 #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct StandalonePlatformArtGenerationFingerprintMaterial<'a> { @@ -2218,6 +2231,21 @@ struct StandalonePlatformArtGenerationFingerprintMaterial<'a> { require_slices: bool, } +/// 把输出路径收口成稳定的旧槽材料:空路径与未指定路径都落到 `(automatic-output)`, +/// 其余按项目内相对路径规范化。这与升级前的槽材料逐字节一致,只用于定位旧账本。 +fn legacy_standalone_platform_art_generation_output_slot( + options: &PlatformArtAssetGenerationOptions, +) -> Result { + Ok(options + .output_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(normalize_relative_path) + .transpose()? + .unwrap_or_else(|| "(automatic-output)".to_string())) +} + fn standalone_platform_art_generation_runtime_context( prompt: &str, options: &PlatformArtAssetGenerationOptions, @@ -2228,33 +2256,23 @@ fn standalone_platform_art_generation_runtime_context( } else { "manual-canvas-asset-generate" }; - let output_slot = options - .output_path - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(normalize_relative_path) - .transpose()? - .unwrap_or_else(|| "(automatic-output)".to_string()); - let slot_bytes = serde_json::to_vec(&serde_json::json!({ - "outputPath": output_slot, - "requireSlices": require_slices, - })) - .map_err(|error| format!("序列化 standalone 图片生成输出槽失败:{error}"))?; - let run_id = format!("slot-{:x}", Sha256::digest(slot_bytes)); + let identity_bytes = serde_json::to_vec(&StandalonePlatformArtGenerationFingerprintMaterial { + prompt, + output_path: options.output_path.as_deref(), + aspect_ratio: &options.aspect_ratio, + image_size: &options.image_size, + asset_kind: &options.asset_kind, + asset_label: &options.asset_label, + replace_existing: options.replace_existing, + require_slices, + }) + .map_err(|error| format!("序列化 standalone 图片生成动作身份失败:{error}"))?; + let action_fingerprint = format!("{:x}", Sha256::digest(&identity_bytes)); + // durable 输出槽身份就是这个精确动作的稳定唯一身份:同项目内不同动作 + // (不同 prompt / 素材名 / 参数)各自独立成槽,可以同时在途;重复提交同一精确动作 + // 命中同一槽与同一账本,因此仍然复用原 operationId,不二次 POST。 + let run_id = format!("slot-{action_fingerprint}"); let identity = format!("{agent_id}:{run_id}"); - let fingerprint_bytes = - serde_json::to_vec(&StandalonePlatformArtGenerationFingerprintMaterial { - prompt, - output_path: options.output_path.as_deref(), - aspect_ratio: &options.aspect_ratio, - image_size: &options.image_size, - asset_kind: &options.asset_kind, - asset_label: &options.asset_label, - replace_existing: options.replace_existing, - require_slices, - }) - .map_err(|error| format!("序列化 standalone 图片生成动作指纹失败:{error}"))?; Ok(PlatformArtGenerationRuntimeContext { agent_id: agent_id.to_string(), task_id: identity.clone(), @@ -2262,10 +2280,25 @@ fn standalone_platform_art_generation_runtime_context( run_id, source: "tauri-command".to_string(), action_id: identity, - action_fingerprint: format!("{:x}", Sha256::digest(fingerprint_bytes)), + action_fingerprint, }) } +/// 升级前的槽身份材料:只由 `{outputPath, requireSlices}` 派生,因此同一项目里 +/// 所有图片类生成共用一个槽。这个函数只用于定位升级前遗留的账本,不属于新的身份规则。 +fn legacy_standalone_platform_art_generation_run_id( + options: &PlatformArtAssetGenerationOptions, + require_slices: bool, +) -> Result { + let output_slot = legacy_standalone_platform_art_generation_output_slot(options)?; + let slot_bytes = serde_json::to_vec(&serde_json::json!({ + "outputPath": output_slot, + "requireSlices": require_slices, + })) + .map_err(|error| format!("序列化 standalone 图片生成旧输出槽失败:{error}"))?; + Ok(format!("slot-{:x}", Sha256::digest(slot_bytes))) +} + fn resolve_standalone_platform_art_generation_result( root: &Path, runtime_context: Option<&PlatformArtGenerationRuntimeContext>, @@ -2459,6 +2492,19 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at( root, runtime_context, )?; + // 一次性兼容:升级前的槽身份只由 {outputPath, requireSlices} 派生,升级后同一精确 + // 动作会指向新路径。若旧槽里的账本仍属于本次精确动作,就在任何远端 POST 前把它迁移 + // 到新身份路径,继续复用原 operationId;属于其他动作的旧账本原样保留,由对应动作 + // 自己迁移,既不阻塞新动作也不丢弃已受理的计费操作。 + if super::external_generation_state::is_standalone_platform_art_generation_runtime_context( + runtime_context, + ) { + super::external_generation_state::adopt_legacy_standalone_platform_art_generation_runtime_state_at( + root, + runtime_context, + &legacy_standalone_platform_art_generation_run_id(options, require_slices)?, + )?; + } { // Direct Codex chat persists the user turn concurrently with the first // platform-art request. Both operations are short-lived project writes; @@ -7787,79 +7833,6 @@ mod canvas_generation_tests { ColorType, ImageEncoder, }; - #[test] - fn standalone_generation_binds_complete_request_to_stable_output_slot() { - let options = PlatformArtAssetGenerationOptions { - output_path: Some("assets/manual-art.png".to_string()), - aspect_ratio: "16:9".to_string(), - image_size: "2K".to_string(), - 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) - .expect("ordinary standalone context"); - let repeated = - standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false) - .expect("repeat standalone context"); - let strict = - standalone_platform_art_generation_runtime_context("完整生成提示词", &options, true) - .expect("strict standalone context"); - assert_eq!(ordinary, repeated); - assert_ne!(ordinary.run_id, strict.run_id); - assert_eq!(ordinary.source, "tauri-command"); - assert_eq!(strict.source, "tauri-command"); - assert_eq!( - ordinary.task_id, - format!("{}:{}", ordinary.agent_id, ordinary.run_id) - ); - assert_eq!(ordinary.task_id, ordinary.session_id); - assert_eq!(ordinary.task_id, ordinary.action_id); - - let different_prompt = - standalone_platform_art_generation_runtime_context("另一个生成提示词", &options, false) - .expect("different prompt context"); - assert_eq!(ordinary.run_id, different_prompt.run_id); - assert_ne!( - ordinary.action_fingerprint, - different_prompt.action_fingerprint - ); - - let mut changed_options = Vec::new(); - let mut changed = options.clone(); - changed.output_path = Some("assets/another-art.png".to_string()); - changed_options.push(changed); - let mut changed = options.clone(); - changed.aspect_ratio = "1:1".to_string(); - changed_options.push(changed); - let mut changed = options.clone(); - changed.image_size = "1K".to_string(); - changed_options.push(changed); - let mut changed = options.clone(); - changed.asset_kind = "ui-prototype".to_string(); - changed_options.push(changed); - let mut changed = options.clone(); - changed.asset_label = "另一个标签".to_string(); - changed_options.push(changed); - let mut changed = options.clone(); - changed.replace_existing = false; - changed_options.push(changed); - for changed in changed_options { - let context = standalone_platform_art_generation_runtime_context( - "完整生成提示词", - &changed, - false, - ) - .expect("changed option context"); - assert_ne!( - ordinary.action_fingerprint, context.action_fingerprint, - "every option field must participate in the action fingerprint" - ); - } - } - #[test] fn generation_kind_catalog_normalizes_spec_onto_the_verified_icon_spec() { // 目录就是两条调用路径共同的可生成集合,必须逐字固定。 @@ -8145,9 +8118,12 @@ mod canvas_generation_tests { let second = tokio::time::timeout( Duration::from_secs(2), crate::assets::with_external_editor_api_credentials(second_credentials, async move { + // 同一个精确动作(同一 prompt + 同一 options):必须命中同一个 durable 输出槽, + // 在任何远端 POST 前被拒;不同动作可以同时在途,见 + // `concurrent_distinct_standalone_generations_both_succeed_with_one_post_each`。 generate_platform_art_asset_with_options_at( &second_root, - "不同的第二个手工请求", + "第一个手工请求", &[], &options, ) @@ -8326,6 +8302,688 @@ mod canvas_generation_tests { ); } + #[test] + fn standalone_generation_binds_each_exact_request_to_its_own_stable_slot() { + let options = PlatformArtAssetGenerationOptions { + output_path: Some("assets/manual-art.png".to_string()), + aspect_ratio: "16:9".to_string(), + image_size: "2K".to_string(), + 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) + .expect("ordinary standalone context"); + let repeated = + standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false) + .expect("repeat standalone context"); + let strict = + standalone_platform_art_generation_runtime_context("完整生成提示词", &options, true) + .expect("strict standalone context"); + assert_eq!(ordinary, repeated); + assert_ne!(ordinary.run_id, strict.run_id); + assert_eq!(ordinary.source, "tauri-command"); + assert_eq!(strict.source, "tauri-command"); + assert_eq!( + ordinary.task_id, + format!("{}:{}", ordinary.agent_id, ordinary.run_id) + ); + assert_eq!(ordinary.task_id, ordinary.session_id); + assert_eq!(ordinary.task_id, ordinary.action_id); + // 槽身份就是这条精确动作的稳定唯一身份,不再是"按输出路径合并"的共享槽。 + assert_eq!( + ordinary.run_id, + format!("slot-{}", ordinary.action_fingerprint), + "durable 输出槽身份必须等于该精确动作的身份" + ); + + // 不同提示词是两条不同动作:必须各自独立成槽,才能在同一项目里同时在途。 + let different_prompt = + standalone_platform_art_generation_runtime_context("另一个生成提示词", &options, false) + .expect("different prompt context"); + assert_ne!( + ordinary.run_id, different_prompt.run_id, + "不同 prompt 不得共用同一个 durable 输出槽" + ); + assert_ne!( + ordinary.action_fingerprint, + different_prompt.action_fingerprint + ); + + // 同一条动作重复构造必须逐字节稳定(幂等复用 operationId 的前提)。 + assert_eq!( + different_prompt, + standalone_platform_art_generation_runtime_context("另一个生成提示词", &options, false) + .expect("repeat different prompt context") + ); + + let mut changed_options = Vec::new(); + let mut changed = options.clone(); + changed.output_path = Some("assets/another-art.png".to_string()); + changed_options.push(changed); + let mut changed = options.clone(); + changed.aspect_ratio = "1:1".to_string(); + changed_options.push(changed); + let mut changed = options.clone(); + changed.image_size = "1K".to_string(); + changed_options.push(changed); + let mut changed = options.clone(); + changed.asset_kind = "ui-prototype".to_string(); + changed_options.push(changed); + let mut changed = options.clone(); + changed.asset_label = "另一个标签".to_string(); + changed_options.push(changed); + let mut changed = options.clone(); + changed.replace_existing = false; + changed_options.push(changed); + for changed in changed_options { + let context = standalone_platform_art_generation_runtime_context( + "完整生成提示词", + &changed, + false, + ) + .expect("changed option context"); + assert_ne!( + ordinary.action_fingerprint, context.action_fingerprint, + "every option field must participate in the action fingerprint" + ); + assert_ne!( + ordinary.run_id, context.run_id, + "every option field must also move the durable output slot" + ); + } + + // 旧槽公式仍可重现:它只用于定位升级前遗留的账本,必须与新身份区分开。 + let legacy = legacy_standalone_platform_art_generation_run_id(&options, false) + .expect("legacy output slot"); + assert_eq!( + legacy, + legacy_standalone_platform_art_generation_run_id(&options, false) + .expect("repeat legacy output slot") + ); + assert_ne!(legacy, ordinary.run_id); + assert_ne!( + legacy, + legacy_standalone_platform_art_generation_run_id(&options, true) + .expect("strict legacy output slot") + ); + } + + #[test] + fn distinct_standalone_actions_hold_independent_durable_output_slots() { + let temporary = tempfile::tempdir().expect("create independent slot project"); + let root = temporary.path(); + init_local_game_project_at(root, "manual-independent-slots", "独立输出槽测试") + .expect("init independent slot project"); + let options = PlatformArtAssetGenerationOptions { + output_path: None, + asset_kind: "icon-spec".to_string(), + asset_label: "图标规范".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }; + let first_prompt = build_platform_art_asset_prompt("第一个手工请求", &[], &options); + let second_prompt = build_platform_art_asset_prompt("第二个手工请求", &[], &options); + let first = + standalone_platform_art_generation_runtime_context(&first_prompt, &options, false) + .expect("first standalone slot context"); + let second = + standalone_platform_art_generation_runtime_context(&second_prompt, &options, false) + .expect("second standalone slot context"); + assert_ne!(first.run_id, second.run_id); + + let first_guard = + super::external_generation_state::acquire_durable_platform_art_generation_guard( + root, &first, + ) + .expect("acquire first standalone slot") + .expect("standalone context requires a durable slot guard"); + let second_guard = + super::external_generation_state::acquire_durable_platform_art_generation_guard( + root, &second, + ) + .expect("a different exact action must not conflict with an in-flight slot") + .expect("standalone context requires a durable slot guard"); + let repeated_same_action = + super::external_generation_state::acquire_durable_platform_art_generation_guard( + root, &first, + ) + .err() + .expect("the same exact action must still conflict before any remote POST"); + assert!( + repeated_same_action.contains("任何远端 POST 前拒绝并发请求"), + "{repeated_same_action}" + ); + drop(first_guard); + drop(second_guard); + assert!( + super::external_generation_state::acquire_durable_platform_art_generation_guard( + root, &first, + ) + .expect("reacquire released standalone slot") + .is_some() + ); + } + + /// 并发夹具:两条 POST 必须同时到达才放行,用来证明两条不同动作真的同时在途。 + #[derive(Default)] + struct ConcurrentPostBarrier { + arrived: std::sync::Mutex, + released: std::sync::Condvar, + both_in_flight: std::sync::atomic::AtomicBool, + } + + impl ConcurrentPostBarrier { + fn arrive(&self) { + let mut arrived = self.arrived.lock().expect("lock concurrent POST arrivals"); + *arrived += 1; + if *arrived >= 2 { + self.both_in_flight + .store(true, std::sync::atomic::Ordering::SeqCst); + self.released.notify_all(); + return; + } + let (arrived, _) = self + .released + .wait_timeout(arrived, Duration::from_secs(10)) + .expect("wait for the second concurrent POST"); + assert!( + *arrived >= 2, + "两条不同动作的 POST 必须同时在途,否则第一条已经串行等待了第二条" + ); + } + + fn both_in_flight(&self) -> bool { + self.both_in_flight + .load(std::sync::atomic::Ordering::SeqCst) + } + } + + fn serve_concurrent_standalone_generation_request( + stream: &mut std::net::TcpStream, + base_url: &str, + barrier: &ConcurrentPostBarrier, + post_index: &std::sync::atomic::AtomicUsize, + posts: &std::sync::Mutex>, + ) { + let request = read_test_http_request(stream); + if request.starts_with("POST /api/external/v1/editor/images/generations ") { + let index = post_index.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + posts + .lock() + .expect("record concurrent generation posts") + .push(request.clone()); + barrier.arrive(); + write_test_json_response( + stream, + "202 Accepted", + &serde_json::json!({ + "data": { + "operationId": format!("concurrent-operation-{index}"), + "status": "queued", + "pollAfterMs": 250, + } + }), + ); + return; + } + if let Some(operation_id) = request + .split_whitespace() + .nth(1) + .and_then(|path| path.strip_prefix("/api/external/v1/generations/")) + .map(str::to_string) + { + write_test_json_response( + stream, + "200 OK", + &serde_json::json!({ + "data": { + "operationId": operation_id, + "status": "completed", + "pollAfterMs": 0, + "result": { + "resource": { + "resourceId": format!("resource-{operation_id}"), + // durable 账本只允许相对媒体路径或 objectKey, + // 下载再经 read-url 换签,与真实平台链路一致。 + "objectKey": format!("manual-concurrent-{operation_id}.png"), + } + } + } + }), + ); + return; + } + if request.starts_with("GET /api/external/v1/assets/read-url?") { + let object_key = request + .split_whitespace() + .nth(1) + .and_then(|path| path.split("objectKey=").nth(1)) + .map(str::to_string) + .expect("read-url fixture must carry an objectKey"); + write_test_json_response( + stream, + "200 OK", + &serde_json::json!({ + "read": {"signedUrl": format!("{base_url}/{object_key}")} + }), + ); + return; + } + if request.starts_with("GET /manual-concurrent-concurrent-operation-") { + let png = rgba_test_png(u8::MAX).bytes; + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + png.len() + ) + .expect("write concurrent generation png headers"); + stream + .write_all(&png) + .expect("write concurrent generation png body"); + return; + } + if request.starts_with("GET /api/external/v1/editor/projects ") { + // 项目绑定已由 `install_test_external_project_binding` 预置,远端只需回认同一组身份。 + write_test_json_response( + stream, + "200 OK", + &serde_json::json!({ + "data": {"projects": [{ + "projectId": "manual-test-canvas", + "title": "并发手工生成画布", + }]} + }), + ); + return; + } + if request.starts_with("GET /api/external/v1/editor/assets/library ") { + write_test_json_response( + stream, + "200 OK", + &serde_json::json!({ + "data": {"library": {"folders": [{ + "folderId": "manual-test-assets", + "label": "并发手工生成素材", + }]}} + }), + ); + return; + } + panic!("unexpected concurrent standalone generation fixture request: {request}"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_distinct_standalone_generations_both_succeed_with_one_post_each() { + let temporary = tempfile::tempdir().expect("create concurrent generation project"); + let root = temporary.path(); + init_local_game_project_at(root, "manual-concurrent-generations", "并发手工生成测试") + .expect("init concurrent generation project"); + write_project_permission_policy_at( + root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow concurrent standalone generation"); + + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind concurrent generation fixture"); + listener + .set_nonblocking(true) + .expect("set concurrent generation fixture nonblocking"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let api_key = "manual-concurrent-key"; + install_test_external_project_binding(root, &base_url, api_key); + + let barrier = std::sync::Arc::new(ConcurrentPostBarrier::default()); + let post_index = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let posts = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let server_base_url = base_url.clone(); + let server_barrier = barrier.clone(); + let server_post_index = post_index.clone(); + let server_posts = posts.clone(); + let server_stop = stop.clone(); + let server = std::thread::spawn(move || { + let mut handlers = Vec::new(); + loop { + if server_stop.load(std::sync::atomic::Ordering::SeqCst) { + break; + } + match listener.accept() { + Ok((mut stream, _)) => { + let base_url = server_base_url.clone(); + let barrier = server_barrier.clone(); + let post_index = server_post_index.clone(); + let posts = server_posts.clone(); + handlers.push(std::thread::spawn(move || { + serve_concurrent_standalone_generation_request( + &mut stream, + &base_url, + &barrier, + &post_index, + &posts, + ); + })); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + } + Err(error) => { + panic!("accept concurrent standalone generation request: {error}") + } + } + } + for handler in handlers { + handler.join().expect("join concurrent fixture handler"); + } + }); + + // 两条动作都用「自动输出」(outputPath 为空),这正是工具栏除首次图标规范外的常态: + // 升级前它们共用同一个 (automatic-output) 槽,因此第二条必然被拒;升级后各自成槽。 + let first_options = PlatformArtAssetGenerationOptions { + output_path: None, + asset_kind: "icon-spec".to_string(), + asset_label: "并发素材一".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }; + let second_options = PlatformArtAssetGenerationOptions { + output_path: None, + asset_kind: "icon-spec".to_string(), + asset_label: "并发素材二".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }; + let first_credentials = crate::assets::external_editor_api_credentials_for_test( + base_url.clone(), + api_key.to_string(), + ); + let second_credentials = crate::assets::external_editor_api_credentials_for_test( + base_url.clone(), + api_key.to_string(), + ); + let first_root = root.to_path_buf(); + let second_root = root.to_path_buf(); + let first = tokio::spawn(async move { + crate::assets::with_external_editor_api_credentials(first_credentials, async move { + generate_platform_art_asset_with_options_at( + &first_root, + "第一个并发手工请求", + &[], + &first_options, + ) + .await + }) + .await + }); + let second = tokio::spawn(async move { + crate::assets::with_external_editor_api_credentials(second_credentials, async move { + generate_platform_art_asset_with_options_at( + &second_root, + "第二个并发手工请求", + &[], + &second_options, + ) + .await + }) + .await + }); + let first = tokio::time::timeout(Duration::from_secs(30), first) + .await + .expect("first concurrent generation must finish") + .expect("join first concurrent generation"); + let second = tokio::time::timeout(Duration::from_secs(30), second) + .await + .expect("second concurrent generation must finish") + .expect("join second concurrent generation"); + let first = first.expect("first standalone generation must succeed"); + let second = second.expect("second standalone generation must succeed"); + + assert!( + barrier.both_in_flight(), + "两条不同手工生成必须同时在途,两条 POST 必须同时到达远端" + ); + assert_eq!( + post_index.load(std::sync::atomic::Ordering::SeqCst), + 2, + "每条精确动作只允许一次远端 POST" + ); + let posts = posts.lock().expect("read concurrent generation posts"); + assert_eq!(posts.len(), 2); + assert!( + posts.iter().any(|post| post.contains("第一个并发手工请求")), + "{posts:#?}" + ); + assert!( + posts.iter().any(|post| post.contains("第二个并发手工请求")), + "{posts:#?}" + ); + drop(posts); + assert_ne!(first.resource_id, second.resource_id); + let installed = std::fs::read_dir(root.join("assets/canvas-generated")) + .expect("read concurrent generation output directory") + .map(|entry| { + entry + .expect("read concurrent generation output entry") + .file_name() + .to_string_lossy() + .to_string() + }) + .collect::>(); + assert_eq!( + installed.len(), + 2, + "两条并发生成必须各自落地一个输出文件:{installed:#?}" + ); + for resource_id in [&first.resource_id, &second.resource_id] { + let resource_id = resource_id + .as_deref() + .expect("fixture returns a resourceId"); + assert!( + installed + .iter() + .any(|file_name| file_name.contains(resource_id)), + "并发生成结果必须按自己的 resourceId 落盘:{resource_id}: {installed:#?}" + ); + } + stop.store(true, std::sync::atomic::Ordering::SeqCst); + server.join().expect("join concurrent fixture"); + } + + #[test] + fn legacy_output_slot_ledger_is_adopted_by_the_same_exact_action_only() { + let temporary = tempfile::tempdir().expect("create legacy slot project"); + let root = temporary.path(); + init_local_game_project_at(root, "manual-legacy-slot", "旧输出槽兼容测试") + .expect("init legacy slot project"); + let options = PlatformArtAssetGenerationOptions { + output_path: None, + asset_kind: "icon-spec".to_string(), + asset_label: "图标规范".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }; + let prompt = build_platform_art_asset_prompt("旧槽兼容手工请求", &[], &options); + let context = standalone_platform_art_generation_runtime_context(&prompt, &options, false) + .expect("current standalone context"); + let legacy_run_id = legacy_standalone_platform_art_generation_run_id(&options, false) + .expect("legacy standalone run id"); + assert_ne!(legacy_run_id, context.run_id); + let legacy_identity = format!("{}:{legacy_run_id}", context.agent_id); + let legacy_context = PlatformArtGenerationRuntimeContext { + task_id: legacy_identity.clone(), + session_id: legacy_identity.clone(), + run_id: legacy_run_id.clone(), + action_id: legacy_identity, + ..context.clone() + }; + assert!( + super::external_generation_state::is_standalone_platform_art_generation_runtime_context( + &legacy_context + ), + "升级前的 slot- 槽身份必须继续被接受,不得 panic 或直接失败关闭" + ); + let access = ExternalEditorBindingAccess::for_developer( + "https://editor.example.test", + "legacy-slot-key", + ) + .expect("create legacy slot binding access"); + let (legacy_state, created) = prepare_platform_art_generation_runtime_state( + root, + &legacy_context, + "/api/external/v1/editor/images/generations", + "manual-test-canvas", + &prompt, + &serde_json::json!({ + "prompt": prompt, + "kind": "spec", + "projectId": "manual-test-canvas", + "assetFolderId": "manual-test-assets", + "referenceImageSrcs": [] + }), + &access, + ) + .expect("prepare legacy slot ledger"); + assert!(created); + let legacy_state = mark_platform_art_generation_runtime_accepted( + root, + legacy_state, + "legacy-slot-operation", + 1_500, + ) + .expect("mark legacy slot operation accepted"); + let legacy_idempotency_key = + platform_art_generation_runtime_idempotency_key(&legacy_state).to_string(); + assert_eq!( + read_platform_art_generation_runtime_state(root, &legacy_context) + .expect("旧格式账本必须仍然可读") + .as_ref() + .and_then( + super::external_generation_state::platform_art_generation_runtime_operation_id_for_test + ), + Some("legacy-slot-operation") + ); + + assert!( + super::external_generation_state::adopt_legacy_standalone_platform_art_generation_runtime_state_at( + root, + &context, + &legacy_run_id, + ) + .expect("adopt legacy slot ledger"), + "同一精确动作的旧槽账本必须被迁移到新身份路径" + ); + let migrated = read_platform_art_generation_runtime_state(root, &context) + .expect("read migrated ledger") + .expect("migrated ledger exists"); + assert_eq!( + super::external_generation_state::platform_art_generation_runtime_operation_id_for_test( + &migrated + ), + Some("legacy-slot-operation") + ); + assert_eq!( + platform_art_generation_runtime_idempotency_key(&migrated), + legacy_idempotency_key + ); + assert_eq!( + super::external_generation_state::platform_art_generation_runtime_run_id_for_test( + &migrated + ), + context.run_id + ); + assert_eq!( + super::external_generation_state::platform_art_generation_runtime_action_fingerprint_for_test( + &migrated + ), + context.action_fingerprint + ); + assert!( + !game_creator_agent_runtime_external_generation_exists( + root, + &context.agent_id, + &legacy_run_id + ), + "迁移后旧路径上的账本必须已清理" + ); + assert!( + !super::external_generation_state::adopt_legacy_standalone_platform_art_generation_runtime_state_at( + root, + &context, + &legacy_run_id, + ) + .expect("second adoption is a no-op"), + "重复迁移必须是幂等空操作" + ); + + // 另一个精确动作的旧槽账本不得被本次动作消费、改写或删除。 + let other_options = PlatformArtAssetGenerationOptions { + output_path: None, + asset_kind: "icon-spec".to_string(), + asset_label: "另一个图标规范".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }; + let other_prompt = + build_platform_art_asset_prompt("另一个旧槽手工请求", &[], &other_options); + let other_context = standalone_platform_art_generation_runtime_context( + &other_prompt, + &other_options, + false, + ) + .expect("other standalone context"); + let other_legacy_run_id = + legacy_standalone_platform_art_generation_run_id(&other_options, false) + .expect("other legacy standalone run id"); + let other_legacy_identity = format!("{}:{other_legacy_run_id}", other_context.agent_id); + let other_legacy_context = PlatformArtGenerationRuntimeContext { + task_id: other_legacy_identity.clone(), + session_id: other_legacy_identity.clone(), + run_id: other_legacy_run_id.clone(), + action_id: other_legacy_identity, + ..other_context.clone() + }; + prepare_platform_art_generation_runtime_state( + root, + &other_legacy_context, + "/api/external/v1/editor/images/generations", + "manual-test-canvas", + &other_prompt, + &serde_json::json!({ + "prompt": other_prompt, + "kind": "spec", + "projectId": "manual-test-canvas", + "assetFolderId": "manual-test-assets", + "referenceImageSrcs": [] + }), + &access, + ) + .expect("prepare other legacy slot ledger"); + assert!( + !super::external_generation_state::adopt_legacy_standalone_platform_art_generation_runtime_state_at( + root, + &context, + &other_legacy_run_id, + ) + .expect("other action legacy slot is not adopted"), + "属于其他精确动作的旧槽账本不得被本次动作迁移" + ); + assert!( + game_creator_agent_runtime_external_generation_exists( + root, + &other_context.agent_id, + &other_legacy_run_id + ), + "属于其他精确动作的旧槽账本必须原样保留" + ); + assert!( + read_platform_art_generation_runtime_state(root, &other_legacy_context) + .expect("other legacy ledger stays readable") + .is_some() + ); + } + #[tokio::test] async fn external_canvas_context_is_scoped_by_account_and_local_project_id() { let temporary = tempfile::tempdir().expect("create account binding project"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs index a6075cda0..03b0dcac1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs @@ -1074,6 +1074,102 @@ pub(in crate::agent) fn remove_platform_art_generation_runtime_state_at( } } +/// 一次性兼容:升级前 standalone 槽身份只由 `{outputPath, requireSlices}` 派生, +/// 同一项目所有图片类生成共用一个槽;升级后槽身份按精确动作派生,路径随之变化。 +/// +/// 若旧槽路径上的账本仍然属于本次精确动作(`agentId` 与 `actionFingerprint` 都与 +/// 当前上下文一致),就在项目写锁内把它迁移到新身份路径:保留原 `idempotencyKey` +/// 与 `operationId`,避免同一精确动作在升级后二次 POST 计费。旧账本属于其他动作时 +/// 原样保留(不迁移、不删除、不阻塞),由对应动作自己的请求迁移。 +/// +/// 返回 `Ok(false)` 表示没有需要迁移的旧账本。任何身份无法安全解释的情形都失败关闭。 +pub(super) fn adopt_legacy_standalone_platform_art_generation_runtime_state_at( + root: &Path, + context: &PlatformArtGenerationRuntimeContext, + legacy_run_id: &str, +) -> Result { + if !is_standalone_platform_art_generation_runtime_context(context) + || legacy_run_id == context.run_id + || !is_lowercase_sha256(legacy_run_id.strip_prefix("slot-").unwrap_or_default()) + { + return Ok(false); + } + // 与账本创建互斥:迁移必须在同一把项目写锁内完成,否则两个调用可能同时把同一份 + // 旧账本迁移到新路径,或与新建账本互相覆盖。 + let _claim_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "canvas.asset_generate.runtime.claim", + )?; + if game_creator_agent_runtime_external_generation_exists( + root, + &context.agent_id, + &context.run_id, + ) { + // 新身份账本已经存在:旧账本不属于本次动作的权威状态,保持两边各自的身份。 + return Ok(false); + } + let legacy_relative_path = + platform_art_generation_runtime_relative_path(&context.agent_id, legacy_run_id); + let Some(legacy_state) = + read_agent_runtime_json_sidecar_with_max_bytes::( + root, + &legacy_relative_path, + "External Editor 生成账本", + PLATFORM_ART_GENERATION_RUNTIME_MAX_BYTES, + )? + else { + return Ok(false); + }; + if legacy_state.agent_id != context.agent_id + || legacy_state.run_id != legacy_run_id + || legacy_state.action_fingerprint != context.action_fingerprint + { + // 旧槽里是另一个精确动作的账本:它仍归那个动作所有,本次调用不得消费、改写或删除它。 + return Ok(false); + } + let legacy_identity = format!("{}:{legacy_run_id}", context.agent_id); + let legacy_context = PlatformArtGenerationRuntimeContext { + task_id: legacy_identity.clone(), + session_id: legacy_identity.clone(), + run_id: legacy_run_id.to_string(), + action_id: legacy_identity, + ..context.clone() + }; + let Some(mut migrated) = read_platform_art_generation_runtime_state(root, &legacy_context)? + else { + return Ok(false); + }; + migrated.run_id = context.run_id.clone(); + migrated.task_id = context.task_id.clone(); + migrated.session_id = context.session_id.clone(); + migrated.action_id = context.action_id.clone(); + migrated.updated_at = unix_timestamp(); + write_platform_art_generation_runtime_state(root, &migrated)?; + remove_platform_art_generation_runtime_state_at(root, &context.agent_id, legacy_run_id)?; + Ok(true) +} + +#[cfg(test)] +pub(super) fn platform_art_generation_runtime_operation_id_for_test( + state: &PlatformArtGenerationRuntimeState, +) -> Option<&str> { + state.operation_id.as_deref() +} + +#[cfg(test)] +pub(super) fn platform_art_generation_runtime_run_id_for_test( + state: &PlatformArtGenerationRuntimeState, +) -> &str { + &state.run_id +} + +#[cfg(test)] +pub(super) fn platform_art_generation_runtime_action_fingerprint_for_test( + state: &PlatformArtGenerationRuntimeState, +) -> &str { + &state.action_fingerprint +} + #[cfg(test)] pub(crate) fn write_platform_art_generation_runtime_accepted_for_test( root: &Path, 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 60f63acc3..9d1f102c9 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 @@ -16,7 +16,7 @@ JSON schema: { "group": "balance", "role": "Difficulty", "summary": "数值交接摘要", "outputs": ["game/balance.json"], "next": "交给程序组读取" }, { "group": "art", "role": "Asset", "summary": "美术交接摘要", "outputs": ["assets/manifest.art.json"], "next": "进入画板或本地资产登记" }, { "group": "audio", "role": "SFX", "summary": "音乐音效交接摘要", "outputs": ["assets/manifest.audio.json"], "next": "进入画板音频链路" }, - { "group": "code", "role": "Code", "summary": "程序交接摘要", "outputs": ["game/index.html"], "next": "交给 Playtest" }, + { "group": "code", "role": "Code", "summary": "程序交接摘要", "outputs": ["game/index.html", "game/game.js", "game/package.json", "game/vite.config.js"], "next": "先 project.bootstrap,再 project.verify(build),交给 Playtest" }, { "group": "publishing", "role": "Publish", "summary": "运营交接摘要", "outputs": ["exports/README.md"], "next": "等待预览验收" } ], "handoffSummary": "六组 agent 的交接摘要,每组一行", @@ -24,6 +24,7 @@ JSON schema: } gameHtml 规则: +- 本次若目标是 Phaser/npm,workspaceMode 必须为 DirectProject;先写入完整 game/ 工程文件,再由受控项目工具安装与构建。 - 此 JSON 协议仅用于已有的单文件 HTML 项目;npm / Phaser 项目必须使用 DirectProject,不能通过 gameHtml 交付 package.json 或模块源码。 - 必须是单文件 HTML,不能加载远程脚本、远程图片、远程 CSS 或 CDN。 - 必须包含 canvas、canvas getContext、实际绘制调用、键盘或鼠标输入、requestAnimationFrame 主循环、目标、失败或胜利状态、R 或按钮重开。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs index d53f2f58e..d0cb7c0bf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/interaction.rs @@ -409,6 +409,8 @@ where (self.on_delta)(&platform_llm::LlmStreamDelta { accumulated_text, delta_text, + accumulated_reasoning: String::new(), + reasoning_delta: String::new(), finish_reason, }); } @@ -499,6 +501,7 @@ mod tests { provider: LlmProvider::OpenAiCompatible, model: "interaction-test".to_string(), text: text.to_string(), + reasoning: String::new(), finish_reason: Some("stop".to_string()), response_id: Some("interaction-response".to_string()), usage: None, 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 cdccf120d..b41e326f7 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 @@ -1156,6 +1156,10 @@ mod tests { assert!(with_canvas.contains("根据当前玩法需求编写规格和界面建议")); assert!(with_canvas.contains("用途、数量、输出路径、尺寸、参考资源和是否需要 spritesheet")); assert!(with_canvas.contains("再调用 canvas.asset_generate")); + assert!(with_canvas.contains("调用 canvas.asset_generate")); + assert!(with_canvas.contains("不要使用固定图片合同")); + assert!(with_canvas.contains("不修改 game/index.html")); + assert!(!with_canvas.contains("不调用 canvas.asset_generate")); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs index 5c9afc1a4..78a9cf3d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs @@ -801,7 +801,7 @@ fn agent_runtime_action_receipt_safe_detail_with_owner( let initial_step = route.get("initialStep")?.as_str()?; let render_mode = route.get("renderMode")?.as_str()?; if resource_id.is_empty() - || initial_step != "visual-binding" + || initial_step != "asset-separation" || render_mode != "final-preview" { return None; @@ -1192,6 +1192,7 @@ pub(in crate::agent) fn agent_runtime_public_action_input_summary( | "project.patchset" | "project.search" | "project.verify" + | "project.bootstrap" | "file.list" | "file.read" | "file.write" @@ -1259,6 +1260,7 @@ pub(crate) fn agent_runtime_tool_action_fingerprint( | "command.stdin" | "command.terminate" | "project.verify" + | "project.bootstrap" ) .then(|| { let metadata = command_sandbox_platform_metadata(); @@ -1468,11 +1470,23 @@ pub(crate) fn agent_runtime_tool_action_input_summary( .and_then(|value| value.as_bool()) .unwrap_or(false) ), + "project.bootstrap" => { + format!( + "cwd={} · timeoutSeconds={}", + relative_path(&["cwd"]), + input + .get("timeoutSeconds") + .or_else(|| input.get("timeout_seconds")) + .and_then(|value| value.as_u64()) + .unwrap_or(300) + ) + } "project.verify" => { let expected_command = text(&["expectedCommand", "expected_command"]); let command_chars = expected_command.chars().count(); format!( - "script={} · expectedCommandSha256={:x} · expectedCommandChars={} · timeoutSeconds={}", + "cwd={} · script={} · expectedCommandSha256={:x} · expectedCommandChars={} · timeoutSeconds={}", + relative_path(&["cwd"]), text(&["script"]), Sha256::digest(expected_command.as_bytes()), command_chars, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index 620aec6df..96d7f9b63 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -184,6 +184,17 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_ ) .await } + "project.bootstrap" => { + observe_agent_runtime_project_bootstrap( + root, + agent_id, + run_id, + action_id, + &action_fingerprint, + &action.input, + ) + .await + } "project.checkpoint" => observe_agent_runtime_project_checkpoint(root), "project.restore" => { observe_agent_runtime_project_restore(root, agent_id, run_id, &action.input) 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 cd032a2ee..071298d77 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 @@ -8,10 +8,15 @@ pub(crate) struct AgentRuntimeAutonomousSourcePayloadStats { } pub(in crate::agent) fn agent_runtime_autonomous_project_verify_available(root: &Path) -> bool { - let package_path = root.join("package.json"); - fs::symlink_metadata(package_path) - .map(|metadata| metadata.file_type().is_file() && !metadata.file_type().is_symlink()) - .unwrap_or(false) + [root.join("package.json"), root.join("game/package.json")] + .into_iter() + .any(|package_path| { + fs::symlink_metadata(package_path) + .map(|metadata| { + metadata.file_type().is_file() && !metadata.file_type().is_symlink() + }) + .unwrap_or(false) + }) } pub(in crate::agent) fn add_agent_runtime_autonomous_source_field( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index bfae45d71..b38ed1023 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -345,7 +345,7 @@ fn build_game_creator_agent_background_tool_plan_request_at( let project_tools_contract = if runtime_owner_artifact_validation_available { "project.search 使用 {\"query\":\"要查找的字面文本\",\"path\":\"\",\"maxResults\":20,\"caseSensitive\":false},path 为空字符串时搜索整个项目,返回 path:line 和匹配行;当前固定 owner 的函数目录不广告 project.verify;project.checkpoint 使用空对象,只用于多个 file.* 写动作前或需要独立回退点时创建本地 checkpoint;project.patchset 会自动创建 checkpoint,不要为同一批变更额外调用 project.checkpoint;project.restore 使用 {\"checkpointId\":\"checkpoint id\"};project.diff 使用 {\"checkpointId\":\"checkpoint id\",\"includeContent\":true,\"maxFiles\":20,\"maxChars\":24000};git.inspect 使用 {\"includeDiff\":true,\"maxFiles\":20,\"maxChars\":24000},只读项目根 Git 状态和有界 diff,不得用它提交、暂存、切分支、合并、重置、stash、worktree 或访问 remote。".to_string() } else { - "project.search 使用 {\"query\":\"要查找的字面文本\",\"path\":\"\",\"maxResults\":20,\"caseSensitive\":false},path 为空字符串时搜索整个项目,返回 path:line 和匹配行;project.verify 使用 {\"script\":\"check|typecheck|test|lint|build\",\"expectedCommand\":\"从 package.json 读取的完整原始脚本\",\"timeoutSeconds\":120},只执行项目根 package.json 中同名 npm 脚本,expectedCommand 不一致时拒绝执行,确认策略以当前工具策略中 project.verify 的独立权限为准;project.checkpoint 使用空对象,只用于多个 file.* 写动作前或需要独立回退点时创建本地 checkpoint;project.patchset 会自动创建 checkpoint,不要为同一批变更额外调用 project.checkpoint;project.restore 使用 {\"checkpointId\":\"checkpoint id\"};project.diff 使用 {\"checkpointId\":\"checkpoint id\",\"includeContent\":true,\"maxFiles\":20,\"maxChars\":24000};git.inspect 使用 {\"includeDiff\":true,\"maxFiles\":20,\"maxChars\":24000},只读项目根 Git 状态和有界 diff,不得用它提交、暂存、切分支、合并、重置、stash、worktree 或访问 remote。".to_string() + "project.search 使用 {\"query\":\"要查找的字面文本\",\"path\":\"\",\"maxResults\":20,\"caseSensitive\":false},path 为空字符串时搜索整个项目,返回 path:line 和匹配行;project.bootstrap 使用 {\"cwd\":\"game\",\"timeoutSeconds\":300},只执行 game 目录无参数 npm install 并记录 package/lock 指纹;project.verify 使用 {\"script\":\"check|typecheck|test|lint|build\",\"expectedCommand\":\"从对应 cwd 的 package.json 读取的完整原始脚本\",\"timeoutSeconds\":120,\"cwd\":\"game\"},只执行对应 cwd package.json 中同名 npm 脚本,build 必须确认 game/dist/index.html;expectedCommand 不一致时拒绝执行,确认策略以当前工具策略中 project.verify 的独立权限为准;project.checkpoint 使用空对象,只用于多个 file.* 写动作前或需要独立回退点时创建本地 checkpoint;project.patchset 会自动创建 checkpoint,不要为同一批变更额外调用 project.checkpoint;project.restore 使用 {\"checkpointId\":\"checkpoint id\"};project.diff 使用 {\"checkpointId\":\"checkpoint id\",\"includeContent\":true,\"maxFiles\":20,\"maxChars\":24000};git.inspect 使用 {\"includeDiff\":true,\"maxFiles\":20,\"maxChars\":24000},只读项目根 Git 状态和有界 diff,不得用它提交、暂存、切分支、合并、重置、stash、worktree 或访问 remote。".to_string() }; let prompt = format!( concat!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs index 8cd93a2c5..d0e3a92a0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs @@ -115,6 +115,7 @@ fn persist_tool_plan_handoff_repair_chain( provider: platform_llm::LlmProvider::OpenAiCompatible, model: llm.model.clone(), text: text.to_string(), + reasoning: String::new(), finish_reason: Some("stop".to_string()), response_id: None, usage: None, @@ -146,6 +147,8 @@ fn stream_delta(delta_text: &str, accumulated_text: &str) -> platform_llm::LlmSt platform_llm::LlmStreamDelta { accumulated_text: accumulated_text.to_string(), delta_text: delta_text.to_string(), + accumulated_reasoning: String::new(), + reasoning_delta: String::new(), finish_reason: None, } } @@ -1003,6 +1006,7 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon provider: platform_llm::LlmProvider::OpenAiCompatible, model: old_llm.model.clone(), text: private_response.to_string(), + reasoning: String::new(), finish_reason: Some("stop".to_string()), response_id: None, usage: None, @@ -1116,6 +1120,7 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo provider: platform_llm::LlmProvider::OpenAiCompatible, model: old_llm.model.clone(), text: text.to_string(), + reasoning: String::new(), finish_reason: Some("stop".to_string()), response_id: None, usage: None, @@ -1302,6 +1307,7 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() { provider: platform_llm::LlmProvider::OpenAiCompatible, model: llm.model.clone(), text: format!("capacity response {loop_iteration}"), + reasoning: String::new(), finish_reason: Some("stop".to_string()), response_id: None, usage: None, @@ -1436,6 +1442,7 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem provider: platform_llm::LlmProvider::OpenAiCompatible, model: llm.model.clone(), text: text.to_string(), + reasoning: String::new(), finish_reason: Some("stop".to_string()), response_id: None, usage: None, @@ -1552,6 +1559,7 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff( provider: platform_llm::LlmProvider::OpenAiCompatible, model: llm.model.clone(), text: "cleanup handoff".to_string(), + reasoning: String::new(), finish_reason: Some("stop".to_string()), response_id: None, usage: None, @@ -1623,6 +1631,7 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() { provider: platform_llm::LlmProvider::OpenAiCompatible, model: llm.model.clone(), text: "terminal handoff".to_string(), + reasoning: String::new(), finish_reason: Some("stop".to_string()), response_id: None, usage: None, @@ -1718,6 +1727,7 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat provider: platform_llm::LlmProvider::OpenAiCompatible, model: llm.model.clone(), text: "已成功但尚未消费的回复".to_string(), + reasoning: String::new(), finish_reason: Some("stop".to_string()), response_id: None, usage: None, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index 1c2fb8d7f..68e315d3d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -28,6 +28,7 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { "project.index", "project.search", "project.verify", + "project.bootstrap", "project.checkpoint", "project.restore", "project.diff", @@ -83,6 +84,7 @@ pub(crate) fn agent_runtime_acceptance_evidence_tools() -> BTreeSet<&'static str "project.index", "project.search", "project.verify", + "project.bootstrap", "project.diff", "git.inspect", "project.patchset", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index f00f8060f..f183e0f6b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -156,6 +156,7 @@ pub(super) const AGENT_RUNTIME_AUTONOMOUS_GAME_BUILD_AUTO_COMMAND_IDS: &[&str] = "file.delete", "project.patchset", "project.verify", + "project.bootstrap", "command.run_limited", "preview.validate", "canvas.asset_generate", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs index 351cbb496..a91344eda 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/interaction.rs @@ -109,6 +109,7 @@ pub(crate) fn agent_runtime_tool_requires_repository_context_fingerprint_gate(to | "project.index" | "project.search" | "project.verify" + | "project.bootstrap" | "project.checkpoint" | "project.patchset" | "project.restore" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs index dc5ad1e60..bd6f8b6b7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs @@ -798,6 +798,7 @@ mod provider_reconciliation_diagnostic_tests { let response = platform_llm::LlmRunResponse { provider: platform_llm::LlmProvider::OpenAiCompatible, model: "test-model".to_string(), + reasoning: String::new(), text: "C:\\private\\response".to_string(), finish_reason: Some("completed".to_string()), response_id: Some("response-1".to_string()), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/command_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/command_ops.rs index b99c3e022..46a867f0e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/command_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/command_ops.rs @@ -857,6 +857,12 @@ pub(crate) async fn observe_agent_runtime_project_verify( .and_then(serde_json::Value::as_str) .unwrap_or_default() .to_string(); + let cwd = input + .get("cwd") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(".") + .to_string(); let timeout_seconds = agent_runtime_tool_input_usize(input, &["timeoutSeconds", "timeout_seconds"]) .unwrap_or(AGENT_RUNTIME_PROJECT_VERIFY_DEFAULT_TIMEOUT_SECONDS); @@ -896,6 +902,7 @@ pub(crate) async fn observe_agent_runtime_project_verify( script.as_str(), expected_command.as_str(), timeout_seconds, + cwd.as_str(), || { verification_state = Some(begin_agent_runtime_project_verification_locked( root, @@ -931,6 +938,7 @@ pub(crate) async fn observe_agent_runtime_project_verify( "script": verification.script, "expectedCommand": audit_expected_command, "packageManager": verification.package_manager, + "cwd": verification.cwd_relative, "status": verification.status, "exitCode": verification.exit_code, "timedOut": verification.timed_out, @@ -1039,3 +1047,81 @@ pub(crate) async fn observe_agent_runtime_project_verify( } } } + +pub(crate) async fn observe_agent_runtime_project_bootstrap( + root: &Path, + agent_id: &str, + run_id: &str, + action_id: Option<&str>, + action_fingerprint: &str, + input: &serde_json::Value, +) -> AgentRuntimeToolObservation { + let cwd = input + .get("cwd") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let timeout = agent_runtime_tool_input_usize(input, &["timeoutSeconds", "timeout_seconds"]) + .unwrap_or(300); + if cwd != "game" { + return AgentRuntimeToolObservation { + tool: "project.bootstrap".to_string(), + status: "failed".to_string(), + summary: "project.bootstrap 只允许 cwd=game".to_string(), + detail: None, + }; + } + let _lock = match acquire_project_write_lock(root, "project.bootstrap") { + Ok(lock) => lock, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "project.bootstrap".to_string(), + status: "failed".to_string(), + summary: "project.bootstrap 无法取得项目执行锁".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 240)), + } + } + }; + let result = crate::project::run_project_bootstrap_at(root, timeout as u64).await; + match result { + Ok(value) if value.status == "completed" => { + if let Err(error) = append_agent_db_record( + root, + serde_json::json!({ + "recordType":"agent.runtime.project.bootstrap", "agentId":agent_id, "runId":run_id, + "actionId":action_id, "actionFingerprint":action_fingerprint, "cwd":"game", + "packageSha256":value.package_sha256, "lockSha256":value.lock_sha256, + "status":value.status, "logPath":value.log_path, "output":value.output + }), + ) { + return AgentRuntimeToolObservation { + tool: "project.bootstrap".to_string(), + status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(), + summary: "project.bootstrap 已安装,但审计记录无法落盘".to_string(), + detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), + }; + } + AgentRuntimeToolObservation { + tool: "project.bootstrap".to_string(), + status: "ok".to_string(), + summary: "game 依赖安装已完成".to_string(), + detail: Some(format!( + "cwd=game · packageSha256={} · lockSha256={}", + value.package_sha256, + value.lock_sha256.as_deref().unwrap_or("none") + )), + } + } + Ok(value) => AgentRuntimeToolObservation { + tool: "project.bootstrap".to_string(), + status: "failed".to_string(), + summary: "game 依赖安装失败".to_string(), + detail: Some(value.output), + }, + Err(error) => AgentRuntimeToolObservation { + tool: "project.bootstrap".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }, + } +} 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 9cb4fda28..5c5f9e602 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 @@ -6,15 +6,16 @@ use std::path::{Component, Path}; const AGC_SKILL_PACK_MANIFEST: &[u8] = include_bytes!("../../resources/agc-skills/manifest.json"); const AGC_SKILL_PACK_SCHEMA_VERSION: &str = "agc-skill-pack.v1"; -pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 5] = [ +pub(crate) const AGC_SKILL_PACK_EXPECTED_NAMES: [&str; 6] = [ "agc-browser-playtest", "agc-client-projection", + "agc-game-production-workflow", "agc-project-structure", "agc-web-game-development", "taonier-art-assets", ]; -const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 15] = [ +const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 18] = [ ( "agc-browser-playtest/SKILL.md", include_bytes!("../../resources/agc-skills/agc-browser-playtest/SKILL.md"), @@ -43,6 +44,20 @@ const AGC_SKILL_PACK_FILES: [(&str, &[u8]); 15] = [ "../../resources/agc-skills/agc-client-projection/references/projection-contract.md" ), ), + ( + "agc-game-production-workflow/SKILL.md", + include_bytes!("../../resources/agc-skills/agc-game-production-workflow/SKILL.md"), + ), + ( + "agc-game-production-workflow/agents/openai.yaml", + include_bytes!("../../resources/agc-skills/agc-game-production-workflow/agents/openai.yaml"), + ), + ( + "agc-game-production-workflow/references/workflow-contract.md", + include_bytes!( + "../../resources/agc-skills/agc-game-production-workflow/references/workflow-contract.md" + ), + ), ( "agc-project-structure/SKILL.md", include_bytes!("../../resources/agc-skills/agc-project-structure/SKILL.md"), @@ -291,10 +306,10 @@ mod tests { use super::*; #[test] - fn bundled_skill_pack_is_exactly_the_five_reviewed_skills() { + fn bundled_skill_pack_is_exactly_the_six_reviewed_skills() { let manifest = validated_skill_pack_manifest().expect("validated manifest"); assert_eq!(manifest.schema_version, "agc-skill-pack.v1"); - assert_eq!(manifest.skills.len(), 5); + assert_eq!(manifest.skills.len(), 6); assert!(manifest.skills.iter().all(|entry| entry.sha256.len() == 64)); let serialized = serde_json::to_string( &manifest 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 6a35ce93e..45ba5c505 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 @@ -1009,7 +1009,8 @@ fn runtime_tool_description(tool: &str) -> &'static str { } "project.index" => "刷新并读取有界仓库启动上下文。", "project.search" => "在项目文本文件中做有界字面量搜索。", - "project.verify" => "运行 package.json 中原样声明的验证脚本。", + "project.verify" => "在项目或指定相对 cwd 中运行 package.json 原样声明的验证脚本,并检查构建产物。", + "project.bootstrap" => "仅在项目 game 目录受控执行无参数 npm install,并记录依赖文件指纹。", "project.checkpoint" => "创建项目本地 checkpoint。", "project.restore" => "从 checkpoint 恢复当前项目。", "project.diff" => "读取 checkpoint 与当前项目之间的有界差异。", @@ -1154,10 +1155,18 @@ fn runtime_tool_input_schema(tool: &str) -> Value { } }), "project.verify" => json!({ - "type": "object", "required": ["script", "expectedCommand", "timeoutSeconds"], "additionalProperties": false, + "type": "object", "required": ["script", "expectedCommand", "timeoutSeconds", "cwd"], "additionalProperties": false, "properties": { "script": { "type": "string", "minLength": 1 }, "expectedCommand": { "type": "string", "minLength": 1 }, + "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 600 }, + "cwd": { "type": ["string", "null"], "pattern": "^[A-Za-z0-9._/-]+$" } + } + }), + "project.bootstrap" => json!({ + "type": "object", "required": ["cwd", "timeoutSeconds"], "additionalProperties": false, + "properties": { + "cwd": { "type": "string", "const": "game" }, "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 600 } } }), diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs new file mode 100644 index 000000000..91a9eae42 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs @@ -0,0 +1,755 @@ +//! 栏目画布「图片类素材生成」的**后台任务账本**。 +//! +//! 背景:`generate_local_project_asset` 一次调用最长要等 35 分钟,面板必须能在提交后立刻关闭, +//! 任务本身继续在后台跑完并把结果写回项目。所以生成不再由「一次 IPC 同步等待」承载,而是: +//! +//! 1. `start_local_project_asset_generation` 校验入参、落一条 `queued` 记录、立刻返回记录; +//! 2. 真正生成在 `tauri::async_runtime::spawn` 出来的后台任务里跑,复用既有 +//! `generate_platform_art_asset_with_options_at`(幂等账本、计费、manifest 登记、本地预览 +//! 全部还是那一条通道,这里不复制任何生成逻辑); +//! 3. `list_local_project_asset_generations` 读回账本,前端轮询它拿状态与阶段文案。 +//! +//! **阶段文案由本模块拥有**(`phase_detail`):前端只渲染后端给的字符串,不自己造百分比或 +//! 假阶段。这也是「进度可见」这条验收判据的落点。 +//! +//! 账本落在**项目内** `.agent/runtime/asset-generation-tasks/tasks.json`(复用既有 agent runtime +//! sidecar 读写原语:临时文件 + rename 替换),所以重开项目后仍能看到历史任务。进程重启时 +//! 还在 `queued` / `running` 的记录不可能再有人推进,读账本时按「上次运行中断」收口;收口结论 +//! **要跟 manifest 交叉核对**:能在清单里找到这次请求的目标素材就按已完成收口(生成通道是先写 +//! manifest 再返回的,所以「素材已登记、账本还停在 running」的窗口里崩溃是真会发生的),核不了 +//! 就不把话说死,不假装它还在跑、也不谎报「生成未完成」。 + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::agent::{ + generate_platform_art_asset_with_options_at, read_agent_runtime_json_sidecar_with_max_bytes, + write_agent_runtime_json_sidecar_with_max_bytes, PlatformArtAssetGenerationOptions, +}; +use crate::commands::prepare_local_project_asset_generation; +use crate::project::{enforce_project_permission_policy, read_existing_manifest_for_project}; + +pub(crate) const ASSET_GENERATION_TASK_SCHEMA_VERSION: &str = "agc-asset-generation-task.v1"; +pub(crate) const ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH: &str = + ".agent/runtime/asset-generation-tasks/tasks.json"; +pub(crate) const ASSET_GENERATION_TASK_LEDGER_MAX_BYTES: usize = 1024 * 1024; +/// 账本保留的记录上限:只留最近的任务,旧记录按时间淘汰,避免账本无限增长。 +pub(crate) const ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS: usize = 50; +pub(crate) const ASSET_GENERATION_TASK_ID_MAX_CHARS: usize = 128; + +pub(crate) const ASSET_GENERATION_TASK_STATUS_QUEUED: &str = "queued"; +pub(crate) const ASSET_GENERATION_TASK_STATUS_RUNNING: &str = "running"; +pub(crate) const ASSET_GENERATION_TASK_STATUS_COMPLETED: &str = "completed"; +pub(crate) const ASSET_GENERATION_TASK_STATUS_FAILED: &str = "failed"; + +const ASSET_GENERATION_TASK_PHASE_QUEUED: &str = "排队中。"; +const ASSET_GENERATION_TASK_PHASE_RUNNING: &str = "正在生成。"; +const ASSET_GENERATION_TASK_PHASE_COMPLETED: &str = "生成已完成。"; +/// 中断收口:清单里已经能查到这次请求的目标素材 → 按事实收口为已完成。 +const ASSET_GENERATION_TASK_PHASE_INTERRUPTED_SETTLED: &str = + "上次运行中断,但目标素材已登记(已完成)。"; +/// 中断收口:请求指定了精确落点,而清单里没有该素材 → 这次写入确实没落地。 +const ASSET_GENERATION_TASK_PHASE_INTERRUPTED_INCOMPLETE: &str = + "上次运行中断,目标素材未登记,生成未完成。"; +/// 中断收口:没有可核对的目标标识(或清单读不到)→ 不下结论。 +const ASSET_GENERATION_TASK_PHASE_INTERRUPTED_UNKNOWN: &str = + "上次运行中断,状态未知(可能已完成)。"; +const ASSET_GENERATION_TASK_INTERRUPTED_SETTLED_ERROR: &str = + "应用退出时生成任务仍在进行,已按清单确认目标素材登记"; +const ASSET_GENERATION_TASK_INTERRUPTED_INCOMPLETE_ERROR: &str = + "应用退出时生成任务仍在进行,目标素材未登记"; +const ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR: &str = + "应用退出时生成任务仍在进行,未能在清单里确认结果"; + +/// 一条生成任务的权威记录。字段名与前端一一对应(camelCase)。 +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AssetGenerationTaskRecord { + pub(crate) task_id: String, + pub(crate) project_id: String, + pub(crate) kind: String, + pub(crate) asset_name: String, + pub(crate) status: String, + /// 阶段文案:**由后端拥有**,前端只渲染。 + pub(crate) phase_detail: String, + /// 这次请求指定的精确落点(`outputPath`)。 + /// + /// 它是中断收口时**唯一可核对的目标标识**:有它就能拿 manifest 的 `localPath` 做精确匹配, + /// 判出「素材已登记 → 已完成」还是「落点没有素材 → 未落地」。没有它(绝大多数入口不指定 + /// 落点)就只能报「状态未知」。旧账本没有这个字段,按 `None` 读。 + #[serde(default)] + pub(crate) output_path: Option, + pub(crate) created_at_millis: u64, + pub(crate) started_at_millis: Option, + pub(crate) finished_at_millis: Option, + /// 生成成功时的 manifest 资源 id,前端据此定位新卡。 + pub(crate) asset_id: Option, + pub(crate) error: Option, +} + +impl AssetGenerationTaskRecord { + fn is_terminal(&self) -> bool { + matches!( + self.status.as_str(), + ASSET_GENERATION_TASK_STATUS_COMPLETED | ASSET_GENERATION_TASK_STATUS_FAILED + ) + } +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct AssetGenerationTaskLedger { + #[serde(default)] + schema_version: String, + #[serde(default)] + tasks: Vec, +} + +/// 账本读写与「本进程还活着哪些任务」共用一把锁:一次读-改-写必须是原子的,否则两个后台 +/// 任务同时收尾会互相覆盖。 +static ASSET_GENERATION_TASK_LOCK: Mutex<()> = Mutex::new(()); +static ASSET_GENERATION_TASK_LIVE_IDS: OnceLock>> = OnceLock::new(); + +fn live_task_ids() -> &'static Mutex> { + ASSET_GENERATION_TASK_LIVE_IDS.get_or_init(|| Mutex::new(BTreeSet::new())) +} + +fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or_default() +} + +fn lock_ledger() -> Result, String> { + ASSET_GENERATION_TASK_LOCK + .lock() + .map_err(|_| "生成任务账本锁已损坏".to_string()) +} + +fn read_ledger(root: &Path) -> Result, String> { + let ledger = read_agent_runtime_json_sidecar_with_max_bytes::( + root, + ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH, + "生成任务账本", + ASSET_GENERATION_TASK_LEDGER_MAX_BYTES, + )?; + Ok(ledger.map(|ledger| ledger.tasks).unwrap_or_default()) +} + +fn write_ledger(root: &Path, tasks: &[AssetGenerationTaskRecord]) -> Result<(), String> { + let ledger = AssetGenerationTaskLedger { + schema_version: ASSET_GENERATION_TASK_SCHEMA_VERSION.to_string(), + tasks: tasks.to_vec(), + }; + write_agent_runtime_json_sidecar_with_max_bytes( + root, + ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH, + "生成任务账本", + &ledger, + ASSET_GENERATION_TASK_LEDGER_MAX_BYTES, + ) +} + +/// 只保留最近 `ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS` 条:按创建时间排序取尾部。 +fn trim_ledger(tasks: &mut Vec) { + if tasks.len() <= ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS { + return; + } + tasks.sort_by(|left, right| { + left.created_at_millis + .cmp(&right.created_at_millis) + .then_with(|| left.task_id.cmp(&right.task_id)) + }); + let overflow = tasks.len() - ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS; + tasks.drain(0..overflow); +} + +/// manifest 的 `localPath` → 资产 id。 +/// +/// 只用来做**精确落点**的交叉核对,所以按同一个口径归一化两侧的路径串(去空白、反斜杠折成 +/// 正斜杠);不做模糊匹配、不按素材名猜,避免把另一次生成的产物算到这条任务上。 +fn registered_asset_ids_by_local_path(root: &Path) -> Option> { + let manifest = read_existing_manifest_for_project(root).ok()?; + Some( + manifest + .assets + .iter() + .map(|asset| (normalize_local_path(&asset.local_path), asset.id.clone())) + .collect(), + ) +} + +fn normalize_local_path(path: &str) -> String { + path.trim().replace('\\', "/") +} + +/// 中断收口用到的 manifest 交叉核对口径。 +/// +/// `None` = 这次读不到清单(项目还没初始化 / 读盘失败),此时任何记录都不能下结论。 +type RegisteredAssetIdsByLocalPath = Option>; + +/// 进程重启后把没人推进的记录收口,并把这次修复写回账本。 +/// +/// 判据是「本进程的 live 集合里没有它」:本进程派发的任务在 `start` 里先登记 live 再落账本, +/// 所以账本里非终态且不 live 的记录只可能来自上一次运行。 +/// +/// **收口结论要跟清单核对**:生成通道是先写 manifest 再返回的,所以「素材已经登记、账本还停在 +/// `running`」的窗口里崩溃是真会发生的;只看 live 集合会把这种任务谎报成「生成未完成」。三种结论: +/// +/// - 请求指定了精确落点、且清单里已有该落点 → 按事实收口为**已完成**(带上 assetId); +/// - 请求指定了精确落点、清单里没有 → 这次写入确实没落地,收口为失败并说明「目标素材未登记」; +/// - 没有精确落点(或清单读不到)→ 收口为失败但**不下结论**,文案是「状态未知(可能已完成)」。 +fn repair_interrupted_tasks( + tasks: &mut [AssetGenerationTaskRecord], + registered: &RegisteredAssetIdsByLocalPath, +) -> bool { + let live = live_task_ids() + .lock() + .map(|ids| ids.clone()) + .unwrap_or_default(); + let mut repaired = false; + for task in tasks.iter_mut() { + if task.is_terminal() || live.contains(&task.task_id) { + continue; + } + let registered_asset = task + .output_path + .as_deref() + .map(normalize_local_path) + .filter(|path| !path.is_empty()) + .and_then(|path| registered.as_ref().map(|assets| assets.get(&path).cloned())); + match registered_asset { + Some(Some(asset_id)) => { + task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_INTERRUPTED_SETTLED.to_string(); + task.asset_id = Some(asset_id); + task.error = Some(ASSET_GENERATION_TASK_INTERRUPTED_SETTLED_ERROR.to_string()); + } + Some(None) => { + task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_INTERRUPTED_INCOMPLETE.to_string(); + task.error = Some(ASSET_GENERATION_TASK_INTERRUPTED_INCOMPLETE_ERROR.to_string()); + } + None => { + task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_INTERRUPTED_UNKNOWN.to_string(); + task.error = Some(ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR.to_string()); + } + } + task.finished_at_millis = Some(now_millis()); + repaired = true; + } + repaired +} + +/// 读账本:顺带把「上次运行中断」的任务收口(能核对的按事实收口,核不了的只报状态未知)。 +pub(crate) fn list_local_project_asset_generation_tasks( + root: &Path, +) -> Result, String> { + let _guard = lock_ledger()?; + let mut tasks = read_ledger(root)?; + let registered = registered_asset_ids_by_local_path(root); + if repair_interrupted_tasks(&mut tasks, ®istered) { + write_ledger(root, &tasks)?; + } + Ok(tasks) +} + +fn update_task( + root: &Path, + task_id: &str, + mutate: F, +) -> Result +where + F: FnOnce(&mut AssetGenerationTaskRecord), +{ + let _guard = lock_ledger()?; + let mut tasks = read_ledger(root)?; + let record = tasks + .iter_mut() + .find(|task| task.task_id == task_id) + .ok_or_else(|| format!("生成任务不存在:{task_id}"))?; + mutate(record); + let snapshot = record.clone(); + write_ledger(root, &tasks)?; + Ok(snapshot) +} + +/// 校验前端给的本地任务 id:它同时是幂等身份,必须是非空单行短字符串。 +fn asset_generation_task_id(task_id: &str) -> Result { + let task_id = task_id.trim(); + if task_id.is_empty() { + return Err("生成任务 id 不能为空".to_string()); + } + if task_id.chars().count() > ASSET_GENERATION_TASK_ID_MAX_CHARS + || task_id.chars().any(char::is_control) + { + return Err("生成任务 id 超出安全边界".to_string()); + } + Ok(task_id.to_string()) +} + +/// 落一条排队记录。调用方保证 task id 在本进程内唯一。 +pub(crate) fn begin_local_project_asset_generation_task( + root: &Path, + project_id: &str, + task_id: &str, + task_kind: &str, + asset_name: &str, + output_path: Option<&str>, +) -> Result { + let record = AssetGenerationTaskRecord { + task_id: task_id.to_string(), + project_id: project_id.trim().to_string(), + kind: task_kind.to_string(), + asset_name: asset_name.to_string(), + status: ASSET_GENERATION_TASK_STATUS_QUEUED.to_string(), + phase_detail: ASSET_GENERATION_TASK_PHASE_QUEUED.to_string(), + output_path: output_path.map(str::to_string), + created_at_millis: now_millis(), + started_at_millis: None, + finished_at_millis: None, + asset_id: None, + error: None, + }; + let _guard = lock_ledger()?; + let mut tasks = read_ledger(root)?; + if let Some(existing) = tasks.iter().find(|task| task.task_id == record.task_id) { + if !existing.is_terminal() { + return Err(format!("生成任务 id 已在进行中:{}", record.task_id)); + } + } + tasks.retain(|task| task.task_id != record.task_id); + tasks.push(record.clone()); + trim_ledger(&mut tasks); + write_ledger(root, &tasks)?; + Ok(record) +} + +fn remove_live_task_id(task_id: &str) { + if let Ok(mut ids) = live_task_ids().lock() { + ids.remove(task_id); + } +} + +/// 后台执行:状态与阶段文案的每一次流转都由这里写账本。 +async fn run_local_project_asset_generation_task( + root: PathBuf, + task_id: String, + prompt: String, + options: PlatformArtAssetGenerationOptions, +) { + if update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_RUNNING.to_string(); + task.started_at_millis = Some(now_millis()); + }) + .is_err() + { + remove_live_task_id(&task_id); + return; + } + let outcome = generate_platform_art_asset_with_options_at(&root, &prompt, &[], &options).await; + match outcome { + Ok(generated) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string(); + task.asset_id = Some(generated.asset.id.clone()); + task.finished_at_millis = Some(now_millis()); + task.error = None; + }); + } + Err(error) => { + let _ = update_task(&root, &task_id, |task| { + task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string(); + task.phase_detail = format!("生成失败:{error}"); + task.error = Some(error.clone()); + task.finished_at_millis = Some(now_millis()); + }); + } + } + remove_live_task_id(&task_id); +} + +/// 提交即返回:校验入参 → 落排队记录 → 派发后台任务 → 返回记录。 +/// +/// 入参收口完全复用 `prepare_local_project_asset_generation`(与同步命令同一份白名单与边界), +/// 生成本身仍是 `generate_platform_art_asset_with_options_at`,本命令不复制任何生成逻辑。 +#[tauri::command] +pub(crate) async fn start_local_project_asset_generation( + project_path: String, + project_id: String, + task_id: String, + kind: String, + prompt: String, + aspect_ratio: Option, + image_size: Option, + asset_name: Option, + output_path: Option, +) -> Result { + let task_id = asset_generation_task_id(&task_id)?; + let request = prepare_local_project_asset_generation( + &project_path, + &kind, + &prompt, + aspect_ratio.as_deref(), + image_size.as_deref(), + asset_name.as_deref(), + output_path.as_deref(), + )?; + enforce_project_permission_policy(&request.root, "canvas.asset_generate")?; + enforce_project_permission_policy(&request.root, "asset.register")?; + let asset_label = request.options.asset_label.clone(); + let asset_kind = request.options.asset_kind.clone(); + let record = begin_local_project_asset_generation_task( + &request.root, + &project_id, + &task_id, + &asset_kind, + &asset_label, + request.options.output_path.as_deref(), + )?; + // 先登记 live 再派发:`list` 只把「非终态且不 live」的记录判为上次运行的残留。 + if let Ok(mut ids) = live_task_ids().lock() { + ids.insert(task_id.clone()); + } + let root = request.root.clone(); + tauri::async_runtime::spawn(run_local_project_asset_generation_task( + root, + task_id, + request.prompt, + request.options, + )); + Ok(record) +} + +/// 读回项目内账本(重开项目后仍能看到历史任务)。 +#[tauri::command] +pub(crate) fn list_local_project_asset_generations( + project_path: String, +) -> Result, String> { + let project_path = project_path.trim(); + if project_path.is_empty() { + return Err("项目路径不能为空".to_string()); + } + let root = Path::new(project_path); + // 与相邻的 manifest 读命令同口径:合法的读 command id 是 `asset.list`(`asset.read` 不在 + // 契约的 command 列表里,写进 `denied_commands` 也不可能命中 → 门禁恒不生效)。 + enforce_project_permission_policy(root, "asset.list")?; + list_local_project_asset_generation_tasks(root) +} + +#[cfg(test)] +mod asset_generation_task_tests { + use super::*; + use crate::assets::register_local_asset_at; + use crate::project::{ + init_local_game_project_at, read_manifest_for_project, write_project_permission_policy_at, + }; + use shared_contracts::game_creation_app::{ + GameCreationAppAssetSource, GameCreationAppAssetSourceKind, + }; + + fn temp_project_root(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_nanos()) + .unwrap_or_default(); + let root = std::env::temp_dir().join(format!( + "agc-asset-generation-tasks-{label}-{}-{nonce}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("temp project root"); + root + } + + /// 已初始化的项目:中断收口要读 manifest 做交叉核对,所以这几条用例必须有真清单。 + fn initialized_project_root(label: &str) -> PathBuf { + let root = temp_project_root(label); + init_local_game_project_at(&root, "project-1", "生成任务账本测试").expect("init project"); + root + } + + /// 登记一个素材到 manifest 并返回它的资产 id(中断收口按 `localPath` 精确匹配)。 + fn register_fixture_asset(root: &Path, local_path: &str) -> String { + let absolute_path = root.join(local_path); + if let Some(parent) = absolute_path.parent() { + std::fs::create_dir_all(parent).expect("create fixture asset parent"); + } + std::fs::write(&absolute_path, b"png-bytes").expect("write fixture asset"); + register_local_asset_at( + root, + local_path, + "icon-spec", + "image/png", + "generated", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: Some("fixture-generation".to_string()), + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + ) + .expect("register fixture asset"); + read_manifest_for_project(root) + .expect("read manifest after registration") + .assets + .iter() + .find(|asset| asset.local_path == local_path) + .expect("registered asset is present") + .id + .clone() + } + + fn begin(root: &Path, task_id: &str) -> AssetGenerationTaskRecord { + begin_local_project_asset_generation_task( + root, + "project-1", + task_id, + "image", + "AI 图", + None, + ) + .expect("begin task") + } + + fn begin_with_output( + root: &Path, + task_id: &str, + output_path: &str, + ) -> AssetGenerationTaskRecord { + begin_local_project_asset_generation_task( + root, + "project-1", + task_id, + "icon-spec", + "图标规范", + Some(output_path), + ) + .expect("begin task with output path") + } + + #[test] + fn started_task_is_queued_with_backend_owned_phase_detail() { + let root = temp_project_root("queued"); + let record = begin(&root, "task-queued"); + assert_eq!(record.status, ASSET_GENERATION_TASK_STATUS_QUEUED); + assert_eq!(record.phase_detail, ASSET_GENERATION_TASK_PHASE_QUEUED); + assert_eq!(record.project_id, "project-1"); + assert_eq!(record.kind, "image"); + assert_eq!(record.asset_name, "AI 图"); + assert!(record.started_at_millis.is_none()); + assert!(record.asset_id.is_none()); + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].task_id, "task-queued"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn ledger_lives_in_project_so_tasks_survive_a_reopen() { + let root = temp_project_root("durable"); + begin(&root, "task-durable"); + let ledger = root.join(ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH); + assert!(ledger.is_file(), "账本必须落在项目内的相对路径上"); + let reopened = list_local_project_asset_generation_tasks(&root).expect("reopen"); + assert_eq!(reopened.len(), 1); + assert_eq!(reopened[0].task_id, "task-durable"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn restarting_the_process_reports_unknown_state_when_the_result_cannot_be_checked() { + // 项目没初始化 → 读不到 manifest → 没有可核对的目标标识:不许断言「生成未完成」。 + let root = temp_project_root("interrupted-unknown"); + begin(&root, "task-interrupted"); + // 模拟「上一条进程留下的非终态记录」:live 集合里没有它(本测试进程从未 start 过它)。 + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_FAILED); + assert_eq!( + listed[0].phase_detail, + ASSET_GENERATION_TASK_PHASE_INTERRUPTED_UNKNOWN + ); + assert_eq!( + listed[0].error.as_deref(), + Some(ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR) + ); + assert!(listed[0].finished_at_millis.is_some()); + // 修复要写回账本,第二次读到的仍是同一条终态记录。 + let again = list_local_project_asset_generation_tasks(&root).expect("list again"); + assert_eq!(again[0].status, ASSET_GENERATION_TASK_STATUS_FAILED); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn interrupted_task_whose_output_is_already_registered_settles_as_completed() { + // 生成通道先写 manifest 再返回,所以「素材已登记、账本还停在 running」的窗口里崩溃是 + // 真会发生的:这种任务必须按事实收口为已完成,而不是谎报「生成未完成」。 + let root = initialized_project_root("interrupted-settled"); + let asset_id = register_fixture_asset(&root, "assets/art-spec.png"); + begin_with_output(&root, "task-settled", "assets/art-spec.png"); + + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_COMPLETED); + assert_eq!( + listed[0].phase_detail, + ASSET_GENERATION_TASK_PHASE_INTERRUPTED_SETTLED + ); + assert_eq!(listed[0].asset_id.as_deref(), Some(asset_id.as_str())); + assert!(listed[0].finished_at_millis.is_some()); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn interrupted_task_whose_output_was_never_registered_is_reported_as_incomplete() { + let root = initialized_project_root("interrupted-incomplete"); + begin_with_output(&root, "task-incomplete", "assets/never-written.png"); + + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_FAILED); + assert_eq!( + listed[0].phase_detail, + ASSET_GENERATION_TASK_PHASE_INTERRUPTED_INCOMPLETE + ); + assert_eq!( + listed[0].error.as_deref(), + Some(ASSET_GENERATION_TASK_INTERRUPTED_INCOMPLETE_ERROR) + ); + assert!(listed[0].asset_id.is_none()); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn interrupted_task_without_an_output_slot_never_claims_the_generation_failed() { + // 自动落点(绝大多数入口):清单里无法精确定位这次请求的产物 → 只报状态未知。 + let root = initialized_project_root("interrupted-no-output"); + register_fixture_asset(&root, "assets/art-spec.png"); + begin(&root, "task-no-output"); + + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_FAILED); + assert_eq!( + listed[0].phase_detail, + ASSET_GENERATION_TASK_PHASE_INTERRUPTED_UNKNOWN + ); + assert!(listed[0].asset_id.is_none()); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn permission_policy_can_deny_the_generation_task_ledger_read() { + // 读门禁必须用契约里真实存在的 command id(`asset.list`)。写成 `asset.read` 这种不在 + // 命令表里的名字时,`denied_commands` 永远不可能命中 → 门禁恒不生效。 + let root = initialized_project_root("denied-read"); + let mut policy = crate::ProjectPermissionPolicy::default(); + policy.denied_commands.push("asset.list".to_string()); + write_project_permission_policy_at(&root, policy).expect("write permission policy"); + + let error = list_local_project_asset_generations(root.to_string_lossy().into_owned()) + .expect_err("denied ledger read must fail closed"); + assert_eq!(error, "项目权限策略拒绝执行:asset.list"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn live_tasks_are_not_settled_while_they_are_still_running() { + let root = temp_project_root("live"); + begin(&root, "task-live"); + live_task_ids() + .lock() + .expect("live ids") + .insert("task-live".to_string()); + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_QUEUED); + remove_live_task_id("task-live"); + let settled = list_local_project_asset_generation_tasks(&root).expect("list settled"); + assert_eq!(settled[0].status, ASSET_GENERATION_TASK_STATUS_FAILED); + assert_eq!( + settled[0].phase_detail, + ASSET_GENERATION_TASK_PHASE_INTERRUPTED_UNKNOWN + ); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn completing_a_task_records_the_manifest_asset_id_and_keeps_it() { + let root = temp_project_root("completed"); + begin(&root, "task-completed"); + let updated = update_task(&root, "task-completed", |task| { + task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string(); + task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string(); + task.asset_id = Some("asset-9".to_string()); + task.finished_at_millis = Some(now_millis()); + }) + .expect("complete task"); + assert_eq!(updated.asset_id.as_deref(), Some("asset-9")); + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed[0].status, ASSET_GENERATION_TASK_STATUS_COMPLETED); + assert_eq!(listed[0].asset_id.as_deref(), Some("asset-9")); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn a_second_task_with_the_same_id_is_rejected_while_the_first_is_in_flight() { + let root = temp_project_root("duplicate"); + begin(&root, "task-dup"); + let error = begin_local_project_asset_generation_task( + &root, + "project-1", + "task-dup", + "image", + "AI 图", + None, + ) + .expect_err("duplicate in-flight task"); + assert_eq!(error, "生成任务 id 已在进行中:task-dup"); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn task_ids_are_bounded_single_line_values() { + assert_eq!( + asset_generation_task_id(" ").expect_err("empty id"), + "生成任务 id 不能为空" + ); + assert_eq!( + asset_generation_task_id("带\n换行").expect_err("control char"), + "生成任务 id 超出安全边界" + ); + assert_eq!( + asset_generation_task_id(&"x".repeat(ASSET_GENERATION_TASK_ID_MAX_CHARS + 1)) + .expect_err("oversized id"), + "生成任务 id 超出安全边界" + ); + assert_eq!( + asset_generation_task_id(" task-1 ").expect("trimmed id"), + "task-1" + ); + } + + #[test] + fn ledger_keeps_only_the_most_recent_records() { + let root = temp_project_root("trim"); + for index in 0..(ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS + 5) { + begin(&root, &format!("task-{index:03}")); + } + let listed = list_local_project_asset_generation_tasks(&root).expect("list"); + assert_eq!(listed.len(), ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS); + assert!( + listed.iter().any(|task| task.task_id + == format!("task-{:03}", ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS + 4)), + "最新一条必须保留" + ); + std::fs::remove_dir_all(&root).ok(); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index bbf431c0a..110e35161 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -604,10 +604,10 @@ pub(crate) fn register_local_asset_at( register_local_asset_entry(root, local_path, kind, media_type, id_prefix, source) } -pub(crate) fn register_design_artifacts_at(root: &Path) -> Result { +pub(crate) fn register_design_artifacts_at(root: &Path) -> Result { let design_root = root.join("design_artifacts"); if !design_root.exists() { - return Ok(0); + return Ok(false); } let mut files = Vec::new(); let mut directories = vec![design_root]; @@ -630,7 +630,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result } } files.sort(); - let mut registered = 0; + let mut changed = false; for path in files { let relative = path .strip_prefix(root) @@ -644,12 +644,12 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result Some("yaml" | "yml") => "text/yaml", _ => "application/octet-stream", }; - register_local_asset_at( + let (_, asset_changed) = register_local_asset_entry_with_change( root, &relative, - "design-document", + "document", media_type, - "design-document", + "document", GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Uploaded, canvas_project_id: None, @@ -663,9 +663,9 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result reference_resource_ids: Vec::new(), }, )?; - registered += 1; + changed |= asset_changed; } - Ok(registered) + Ok(changed) } pub(crate) fn import_canvas_asset_at( @@ -1876,6 +1876,18 @@ pub(crate) fn register_local_asset_entry( id_prefix: &str, source: GameCreationAppAssetSource, ) -> Result { + register_local_asset_entry_with_change(root, local_path, kind, media_type, id_prefix, source) + .map(|(result, _)| result) +} + +fn register_local_asset_entry_with_change( + root: &Path, + local_path: &str, + kind: &str, + media_type: &str, + id_prefix: &str, + source: GameCreationAppAssetSource, +) -> Result<(UploadLocalAssetResult, bool), String> { let normalized_path = normalize_relative_path(local_path)?; let absolute_path = resolve_local_project_path(root, &normalized_path)?; let manifest_path = root.join(".agent/manifest.json"); @@ -1888,7 +1900,7 @@ pub(crate) fn register_local_asset_entry( let mut source_for_record = source.clone(); source_for_record.prompt = None; - let (id, record_type) = mutate_manifest_at(root, |manifest| { + let (id, record_type, changed) = mutate_manifest_at(root, |manifest| { if let Some(existing) = manifest .assets .iter_mut() @@ -1898,13 +1910,16 @@ pub(crate) fn register_local_asset_entry( // 而陈旧的非 unclassified 值会被读侧无条件信任(自愈只在落盘值是 unclassified // 时才触发),于是这个资产永远停在错误栏目。 // kind 没变时刻意不动 category——落盘分类是权威值,同 kind 重登记不得抹掉它。 + let changed = existing.kind != kind + || existing.media_type != media_type + || existing.source != source; if existing.kind != kind { existing.kind = kind.to_string(); existing.category = game_creation_app_asset_category_for_kind(kind); } existing.media_type = media_type.to_string(); existing.source = source; - Ok((existing.id.clone(), "asset.update")) + Ok((existing.id.clone(), "asset.update", changed)) } else { let id = format!( "{id_prefix}-{}-{}", @@ -1922,7 +1937,7 @@ pub(crate) fn register_local_asset_entry( tags: Vec::new(), source, }); - Ok((id, "asset.register")) + Ok((id, "asset.register", true)) } })?; append_agent_db_record( @@ -1937,12 +1952,15 @@ pub(crate) fn register_local_asset_entry( }), )?; - Ok(UploadLocalAssetResult { - id, - local_path: normalized_path.clone(), - absolute_path: absolute_path.to_string_lossy().into_owned(), - manifest_path: manifest_path.to_string_lossy().into_owned(), - }) + Ok(( + UploadLocalAssetResult { + id, + local_path: normalized_path.clone(), + absolute_path: absolute_path.to_string_lossy().into_owned(), + manifest_path: manifest_path.to_string_lossy().into_owned(), + }, + changed, + )) } #[derive(Clone, Debug, Deserialize)] @@ -2137,6 +2155,27 @@ mod tests { use super::*; use std::io::{Read, Write}; + #[test] + fn design_artifact_registration_reports_only_real_manifest_changes() { + let temporary = tempfile::tempdir().expect("tempdir"); + let root = temporary.path(); + crate::project::init_local_game_project_at(root, "design-artifact-test", "策划产物登记") + .expect("init project"); + fs::create_dir_all(root.join("design_artifacts/project")).expect("create artifacts"); + fs::write(root.join("design_artifacts/project/design.md"), "设计内容") + .expect("write artifact"); + + assert!(register_design_artifacts_at(root).expect("register first time")); + assert_eq!( + read_existing_manifest_for_project(root) + .unwrap() + .assets + .len(), + 1 + ); + assert!(!register_design_artifacts_at(root).expect("register idempotently")); + } + /// 画板导出推断出的 kind 必须已经是 canonical 值。 /// /// 这个值会被原样写进 manifest 并据以派生落盘 `category`;一旦写出非 canonical 值 diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs index db05d7596..03e41a663 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -321,6 +321,44 @@ pub(crate) fn resolve_project_command_spec_at( .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error)) } +/// Resolves the one privileged npm operation used to hydrate a DirectProject. +/// It intentionally bypasses the general command.exec npm allow-list: callers +/// must use the dedicated `project.bootstrap` action, which only accepts the +/// literal `npm install` in the project's `game` directory. +pub(crate) fn resolve_project_bootstrap_spec_at( + root: &Path, + timeout_seconds: u64, +) -> Result { + validate_project_root(root) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?; + let cwd_relative = "game".to_string(); + let cwd = resolve_local_project_path(root, &cwd_relative) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?; + validate_project_command_cwd_components(root, &cwd_relative) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?; + if !(PROJECT_COMMAND_MIN_TIMEOUT_SECONDS..=PROJECT_COMMAND_MAX_TIMEOUT_SECONDS) + .contains(&timeout_seconds) + { + return Err(ProjectCommandError::new( + ProjectCommandErrorStage::Validation, + format!("project.bootstrap timeoutSeconds 必须在 {PROJECT_COMMAND_MIN_TIMEOUT_SECONDS}-{PROJECT_COMMAND_MAX_TIMEOUT_SECONDS} 之间"), + )); + } + let program = "npm".to_string(); + let (executable, safe_path) = resolve_project_command_executable(root, &program) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Validation, error))?; + Ok(ProjectCommandSpec { + program, + executable, + safe_path, + arguments: vec!["install".to_string()], + cwd_relative, + cwd, + timeout_seconds, + verification_eligible: false, + }) +} + fn resolve_project_command_spec_inner( root: &Path, program: &str, @@ -1202,6 +1240,19 @@ pub(crate) fn prepare_project_command_launch_spec( (OsString::from("NO_PROXY"), OsString::new()), (OsString::from("PATH"), spec.safe_path.clone()), ]; + if spec + .arguments + .first() + .is_some_and(|argument| argument == "install") + { + for (name, value) in &mut environment { + match name.to_string_lossy().as_ref() { + "npm_config_offline" => *value = OsString::from("false"), + "HTTP_PROXY" | "HTTPS_PROXY" | "ALL_PROXY" => *value = OsString::new(), + _ => {} + } + } + } for name in ["SystemRoot", "PATHEXT", "RUSTUP_HOME"] { if let Some(value) = std::env::var_os(name) { environment.push((OsString::from(name), value)); @@ -1998,6 +2049,16 @@ pub(crate) async fn run_project_command_with_output_at( .await } +pub(crate) async fn run_project_bootstrap_command_at( + root: &Path, + timeout_seconds: u64, +) -> Result { + let spec = resolve_project_bootstrap_spec_at(root, timeout_seconds)?; + let launch = prepare_project_command_launch_spec(root, &spec)?; + let staged = stage_project_command_launch_spec(&spec, launch)?; + run_prepared_project_command_with_output_at(root, &spec, staged, None, || Ok(())).await +} + pub(crate) async fn run_prepared_project_command_with_output_at( root: &Path, spec: &ProjectCommandSpec, diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs index ed2a9cdf1..ae02e18b7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs @@ -302,7 +302,19 @@ mod linux { cwd: &Path, environment: &[(OsString, OsString)], ) -> Result { - let metadata = CommandSandboxMetadata::enforced_linux(); + let mut metadata = CommandSandboxMetadata::enforced_linux(); + let network_enabled = executable + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| { + name.eq_ignore_ascii_case("npm") || name.eq_ignore_ascii_case("npm.cmd") + }) + && arguments + .first() + .is_some_and(|argument| argument == "install"); + if network_enabled { + metadata.network = "enabled"; + } let bwrap = find_trusted_bwrap().map_err(|error| { CommandSandboxError::new( format!("command sandbox unavailable: {error}"), @@ -363,7 +375,7 @@ mod linux { )?; let fixed_system_read_only = collect_fixed_system_mounts(); - let launch = build_linux_bwrap_launch(LinuxSandboxPlan { + let mut launch = build_linux_bwrap_launch(LinuxSandboxPlan { bwrap, root, cwd, @@ -375,6 +387,7 @@ mod linux { external_read_only, fixed_system_read_only, }); + launch.metadata = metadata.clone(); run_project_mount_preflight(&launch).map_err(|error| { CommandSandboxError::new( format!("command sandbox project mount preflight 失败:{error}"), @@ -602,7 +615,19 @@ mod linux { fn build_linux_bwrap_launch(plan: LinuxSandboxPlan) -> CommandSandboxLaunch { let mut args = Vec::::new(); - push_namespace_arguments(&mut args); + push_namespace_arguments( + &mut args, + plan.executable + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| { + name.eq_ignore_ascii_case("npm") || name.eq_ignore_ascii_case("npm.cmd") + }) + && plan + .arguments + .first() + .is_some_and(|argument| argument == "install"), + ); push_ro_bind(&mut args, Path::new("/usr"), Path::new("/usr")); for (target, destination) in &plan.merged_usr_links { push_option( @@ -673,7 +698,7 @@ mod linux { } } - fn push_namespace_arguments(args: &mut Vec) { + fn push_namespace_arguments(args: &mut Vec, share_network: bool) { for argument in [ "--die-with-parent", "--unshare-all", @@ -686,6 +711,9 @@ mod linux { ] { args.push(OsString::from(argument)); } + if share_network { + args.push(OsString::from("--share-net")); + } } fn push_option<'a, I>(args: &mut Vec, option: &str, values: I) @@ -829,7 +857,7 @@ mod linux { merged_usr_links: &[(OsString, PathBuf)], ) -> Result<(), String> { let mut args = Vec::::new(); - push_namespace_arguments(&mut args); + push_namespace_arguments(&mut args, false); push_ro_bind(&mut args, Path::new("/usr"), Path::new("/usr")); for (target, destination) in merged_usr_links { push_option( diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index e2608eb19..09fafbb2a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -517,11 +517,7 @@ pub(crate) fn create_automatic_local_game_project_at( match fs::create_dir(&project_root) { Ok(()) => { let result = (|| { - prepare_game_creator_private_path_for_read( - &project_root, - true, - "自动项目目录", - )?; + harden_new_game_creator_private_path(&project_root, true, "自动项目目录")?; enforce_project_permission_policy(&project_root, "project.create")?; let _lock = acquire_project_write_lock(&project_root, "project.create")?; init_local_game_project_at( @@ -1945,28 +1941,36 @@ pub(crate) fn read_platform_account_session_generation() -> u64 { } #[tauri::command] -pub(crate) fn install_platform_account_session( +pub(crate) async fn install_platform_account_session( user_id: String, access_token: String, api_base_url: String, generation: u64, ) -> Result<(), String> { - validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?; - install_external_agent_runner_platform_session( - &user_id, - &access_token, - &api_base_url, - generation, - )?; - install_platform_session(&user_id, &access_token, &api_base_url, generation) + tokio::task::spawn_blocking(move || { + validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?; + install_external_agent_runner_platform_session( + &user_id, + &access_token, + &api_base_url, + generation, + )?; + install_platform_session(&user_id, &access_token, &api_base_url, generation) + }) + .await + .map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))? } #[tauri::command] -pub(crate) fn clear_platform_account_session(generation: u64) -> Result<(), String> { - shutdown_game_creator_codex_app_servers()?; - clear_external_agent_runner_platform_session(generation)?; - clear_platform_session(generation); - Ok(()) +pub(crate) async fn clear_platform_account_session(generation: u64) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + shutdown_game_creator_codex_app_servers()?; + clear_external_agent_runner_platform_session(generation)?; + clear_platform_session(generation); + Ok(()) + }) + .await + .map_err(|error| format!("清除本地运行时会话任务意外终止:{error}"))? } #[tauri::command] diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 3240832c2..b66b976af 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -27,6 +27,7 @@ fn user_selected_path_grants() -> &'static Mutex Option { + let path = normalize_windows_policy_path(path); if !path.is_absolute() || path .components() @@ -42,6 +43,20 @@ fn normalize_user_selected_path_key(path: &Path) -> Option { ) } +#[cfg(windows)] +fn normalize_windows_policy_path(path: &Path) -> PathBuf { + let value = path.to_string_lossy(); + if let Some(rest) = value.strip_prefix(r"\\?\UNC\") { + return PathBuf::from(format!(r"\\{rest}")); + } + PathBuf::from(value.strip_prefix(r"\\?\").unwrap_or(&value)) +} + +#[cfg(not(windows))] +fn normalize_windows_policy_path(path: &Path) -> PathBuf { + path.to_path_buf() +} + #[cfg(windows)] pub(crate) fn register_game_creator_user_selected_path(path: &Path, is_directory: bool) { let Some(key) = normalize_user_selected_path_key(path) else { @@ -838,6 +853,7 @@ pub(crate) fn validate_game_creator_private_path_ancestors( /// separate, explicit user-selected scope below covers native picker/project /// root results, including projects stored outside the current profile. fn game_creator_private_path_allows_auto_elevation(path: &Path) -> bool { + let path = normalize_windows_policy_path(path); if !path.is_absolute() || path .components() @@ -846,7 +862,10 @@ fn game_creator_private_path_allows_auto_elevation(path: &Path) -> bool { return false; } - let starts_with_path = |root: &Path| path == root || path.starts_with(root); + let starts_with_path = |root: &Path| { + let root = normalize_windows_policy_path(root); + path == root || path.starts_with(root) + }; if game_creator_runtime_config_dir() .as_deref() .is_some_and(starts_with_path) @@ -1056,6 +1075,7 @@ pub(crate) fn parse_windows_acl_repair_scope(value: &str) -> Result WindowsAclRepairScope { + let path = normalize_windows_policy_path(path); let is_builtin_root = |root: PathBuf| path == root || path.starts_with(root); if let Some(home) = std::env::var_os("USERPROFILE") .or_else(|| std::env::var_os("HOME")) @@ -1299,15 +1319,10 @@ pub(crate) fn ensure_game_creator_private_directory_tree( #[cfg(all(windows, test))] initialize_windows_game_creator_directory_owner_for_current_user(&directory)?; #[cfg(windows)] - if game_creator_private_path_allows_auto_elevation(&directory) { - secure_windows_game_creator_path_for_current_user_with_auto_elevation( - &directory, true, true, - )?; - } else { - secure_windows_game_creator_path_for_current_user_with_owner_policy( - &directory, true, true, true, - )?; - } + // This invocation created the directory: initialize it in + // process first, with a narrowly-scoped managed-path fallback + // only if Windows rejects that local ACL update. + harden_new_game_creator_private_path(&directory, true, label)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -1346,15 +1361,7 @@ pub(crate) fn ensure_game_creator_private_directory_tree( fs::create_dir(&directory).map_err(|retry_error| { format!("创建 {label} 失败:{}: {retry_error}", directory.display()) })?; - if game_creator_private_path_allows_auto_elevation(&directory) { - secure_windows_game_creator_path_for_current_user_with_auto_elevation( - &directory, true, true, - )?; - } else { - secure_windows_game_creator_path_for_current_user_with_owner_policy( - &directory, true, true, true, - )?; - } + harden_new_game_creator_private_path(&directory, true, label)?; } Err(error) => { return Err(format!( @@ -1419,15 +1426,34 @@ pub(crate) fn harden_new_game_creator_private_path( path.display() )); } - // This invocation created the object, so its owner is the current - // user. Tighten the inherited descriptor in-process; UAC repair is - // reserved for existing, externally-owned objects. - secure_windows_game_creator_path_for_current_user_with_owner_policy( - path, - is_directory, - true, - true, - )?; + // This invocation created the object, so local hardening is always + // the first path. Some Windows configurations can nevertheless + // reject the descriptor update (for example when an inherited ACL is + // protected by the parent). Only a managed path may use the existing + // one-shot repair in that exceptional case; ordinary new projects do + // not prompt for UAC. + if let Err(local_error) = + secure_windows_game_creator_path_for_current_user_with_owner_policy( + path, + is_directory, + true, + true, + ) + { + if !game_creator_private_path_allows_auto_elevation(path) + || !windows_acl_error_may_need_elevation(&local_error) + { + return Err(local_error); + } + secure_windows_game_creator_path_for_current_user_with_auto_elevation( + path, + is_directory, + true, + ) + .map_err(|repair_error| { + format!("{local_error};新建对象的受控 ACL 修复未完成:{repair_error}") + })?; + } } #[cfg(unix)] { @@ -4506,6 +4532,23 @@ mod private_path_elevation_policy_tests { ); } + #[cfg(windows)] + #[test] + fn verbatim_packaged_appdata_path_keeps_managed_repair_scope() { + let root = std::env::var_os("LOCALAPPDATA") + .or_else(|| std::env::var_os("APPDATA")) + .map(PathBuf::from) + .expect("local appdata"); + let packaged = root.join("world.genarrative.ai-game-creator"); + let verbatim = PathBuf::from(format!(r"\\?\{}", packaged.display())); + + assert!(game_creator_private_path_allows_auto_elevation(&verbatim)); + assert_eq!( + game_creator_runtime_config_repair_scope(&verbatim), + WindowsAclRepairScope::Managed + ); + } + #[cfg(windows)] #[test] fn picker_grant_is_required_and_directory_grant_covers_descendants() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/context_compaction.rs b/apps/ai-game-creator-shell/src-tauri/src/context_compaction.rs index d605b6e2c..419e00d23 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/context_compaction.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/context_compaction.rs @@ -905,6 +905,7 @@ mod tests { provider: platform_llm::LlmProvider::OpenAiCompatible, model: "context-compaction-test".to_string(), text: summary.into(), + reasoning: String::new(), finish_reason: Some("stop".to_string()), response_id: Some("context-compaction-response".to_string()), usage: Some(platform_llm::LlmTokenUsage { diff --git a/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs index c7c4313e1..edbdeb1ab 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/image_inspect.rs @@ -26,6 +26,12 @@ pub(crate) struct LocalProjectImagePreview { pub(crate) byte_len: u64, pub(crate) pixel_width: u32, pub(crate) pixel_height: u32, + /// 这张图是否**真的**带 alpha 通道,判据见 [`detect_raster_image_has_alpha`]。 + /// + /// 资源卡只按它决定要不要铺棋盘格底:`data-preview-kind` 只说明「走图片预览分支」, + /// 与这张图有没有透明像素无关 —— 无条件铺底会让「AI 把棋盘格画进像素里」的不透明图 + /// 与卡面棋盘格叠成两套,验收时无法区分「真透明底」与「假棋盘格」。 + pub(crate) has_alpha: bool, pub(crate) data_url: String, } @@ -102,12 +108,17 @@ pub(crate) fn load_local_project_image_preview_with_cancellation( false, )?; cancellation.check()?; + // 头部级 alpha 判据:只读签名与头部标志(PNG 还会按 chunk 头跳过数据体找 `tRNS`), + // 不做熵解码、不做逐像素扫描,成本不随像素数增长,因此大图与「AI 把棋盘格画进图里」 + // 的不透明图都不会因此变慢。 + let has_alpha = detect_raster_image_has_alpha(&image.bytes, image.media_type); Ok(LocalProjectImagePreview { path: image.relative_path.clone(), media_type: image.media_type.to_string(), byte_len: image.byte_len, pixel_width: image.pixel_width, pixel_height: image.pixel_height, + has_alpha, data_url: image.data_url_with_cancellation(cancellation)?, }) } @@ -420,6 +431,84 @@ fn detect_raster_image_dimensions(bytes: &[u8], media_type: &str) -> Option<(u32 } } +/// 头部级 alpha 判据:这张图**有没有 alpha 通道 / 透明像素**,只看签名与头部标志 +/// (PNG 还会按 chunk 头跳过数据体找 `tRNS`)。 +/// +/// 为什么必须是头部级而不是像素级:资源卡预览按 8 MiB / 8192 边长 / 3270 万像素上限读取, +/// 逐像素扫描意味着对每张卡都做一次全量 RGBA 解码(真机单栏 51 张、单张均值 591 KB), +/// 成本与「卡面装饰底」的收益完全不成比例;而 alpha 是否存在在容器头部就是确定信息。 +/// +/// 判据(保守方向一致:判不出就当作不透明,宁可不铺棋盘格): +/// - PNG:颜色类型 4(灰度 + alpha)/ 6(真彩 + alpha);0 / 2 / 3 本身没有 alpha 通道, +/// 但可以用 `tRNS` 声明透明色,因此还要在第一个 `IDAT` 之前找一次 `tRNS`; +/// - WebP:扩展格式 `VP8X` 的 flags 第 4 位、无损 `VP8L` 位流头的 `alpha_is_used` 位; +/// 简单有损 `VP8 ` 不带 alpha 通道(带 alpha 的有损 WebP 一定走 `VP8X` + `ALPH`); +/// - JPEG:没有 alpha 通道,恒不透明(也绝不为了判 alpha 去扫它的段)。 +fn detect_raster_image_has_alpha(bytes: &[u8], media_type: &str) -> bool { + match media_type { + "image/png" => detect_png_has_alpha(bytes), + "image/webp" => detect_webp_has_alpha(bytes), + _ => false, + } +} + +fn detect_png_has_alpha(bytes: &[u8]) -> bool { + // 签名 8 字节 + IHDR 长度 4 + "IHDR" 4 + 宽 4 + 高 4 + 位深 1 + 颜色类型 1 = 26。 + if bytes.len() < 26 || &bytes[12..16] != b"IHDR" { + return false; + } + if matches!(bytes[25], 4 | 6) { + return true; + } + png_has_transparency_chunk(bytes) +} + +/// 按 chunk 头前进并查找 `tRNS`:只读 8 字节 chunk 头并按长度跳过数据体,不做 zlib 解压。 +fn png_has_transparency_chunk(bytes: &[u8]) -> bool { + let mut offset = 8usize; + loop { + let Some(header_end) = offset.checked_add(8) else { + return false; + }; + if header_end > bytes.len() { + return false; + } + let chunk_type = &bytes[offset + 4..header_end]; + // `tRNS` 必须出现在第一个 `IDAT` 之前;碰到 `IDAT` / `IEND` 就没有再往下扫的意义。 + if chunk_type == b"tRNS" { + return true; + } + if chunk_type == b"IDAT" || chunk_type == b"IEND" { + return false; + } + let chunk_len = + u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap_or([0_u8; 4])) as usize; + let Some(next) = header_end + .checked_add(chunk_len) + .and_then(|value| value.checked_add(4)) + else { + return false; + }; + if next <= offset || next > bytes.len() { + return false; + } + offset = next; + } +} + +fn detect_webp_has_alpha(bytes: &[u8]) -> bool { + if bytes.len() < 16 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WEBP" { + return false; + } + match &bytes[12..16] { + // `VP8X` 的 flags 第 4 位(0x10)就是 alpha 标志(第 20 字节)。 + b"VP8X" => bytes.get(20).is_some_and(|flags| flags & 0x10 != 0), + // `VP8L` 位流头第 28 位是 `alpha_is_used`,落在第 25 个字节(下标 24)的 0x10 位。 + b"VP8L" => bytes.len() >= 25 && bytes[24] & 0x10 != 0, + _ => false, + } +} + #[derive(Clone, Copy)] enum TiffByteOrder { LittleEndian, @@ -745,6 +834,74 @@ mod tests { .expect("valid 1x1 png") } + /// PNG 的「签名 + IHDR」头。判据只读这一段的位深 / 颜色类型,因此后续 chunk 由用例自行拼。 + fn png_header(color_type: u8) -> Vec { + png_header_with_size(color_type, 1, 1) + } + + fn png_header_with_size(color_type: u8, width: u32, height: u32) -> Vec { + let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec(); + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&width.to_be_bytes()); + ihdr.extend_from_slice(&height.to_be_bytes()); + ihdr.push(8); + ihdr.push(color_type); + ihdr.extend_from_slice(&[0, 0, 0]); + push_png_chunk(&mut bytes, b"IHDR", &ihdr); + bytes + } + + /// 追加一个结构合法(长度、类型、CRC 位置正确)但数据体可以是任意字节的 PNG chunk。 + /// alpha 判据不消费 CRC,因此这里填零;正因数据体不必是合法 deflate 流,它同时能证明 + /// 判据没有解码像素。 + fn push_png_chunk(bytes: &mut Vec, kind: &[u8; 4], data: &[u8]) { + bytes.extend_from_slice( + &u32::try_from(data.len()) + .expect("chunk length") + .to_be_bytes(), + ); + bytes.extend_from_slice(kind); + bytes.extend_from_slice(data); + bytes.extend_from_slice(&[0, 0, 0, 0]); + } + + /// 扩展格式 WebP(`VP8X`):`flags` 第 4 位(0x10)是 alpha 标志。 + fn webp_vp8x(flags: u8) -> Vec { + let mut bytes = b"RIFF".to_vec(); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes.extend_from_slice(b"WEBP"); + bytes.extend_from_slice(b"VP8X"); + bytes.extend_from_slice(&10_u32.to_le_bytes()); + bytes.push(flags); + bytes.extend_from_slice(&[0, 0, 0]); + bytes.extend_from_slice(&[0, 0, 0]); + bytes.extend_from_slice(&[0, 0, 0]); + bytes + } + + /// 无损 WebP(`VP8L`):位流头第 28 位是 `alpha_is_used`,落在下标 24 的 0x10 位。 + fn webp_vp8l(has_alpha: bool) -> Vec { + let mut bytes = b"RIFF".to_vec(); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes.extend_from_slice(b"WEBP"); + bytes.extend_from_slice(b"VP8L"); + bytes.extend_from_slice(&5_u32.to_le_bytes()); + bytes.push(0x2f); + bytes.extend_from_slice(&[0, 0, 0, if has_alpha { 0x10 } else { 0 }]); + bytes + } + + /// 简单有损 WebP(`VP8 `):容器上没有 alpha 通道;带 alpha 的有损 WebP 一定走 + /// `VP8X` 扩展格式(+ `ALPH` chunk)。 + fn webp_vp8_simple() -> Vec { + let mut bytes = b"RIFF".to_vec(); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes.extend_from_slice(b"WEBP"); + bytes.extend_from_slice(b"VP8 "); + bytes.extend_from_slice(&0_u32.to_le_bytes()); + bytes + } + fn jpeg_bytes(width: u16, height: u16, app1_payload: Option<&[u8]>) -> Vec { let mut bytes = vec![0xff, 0xd8]; if let Some(payload) = app1_payload { @@ -840,6 +997,138 @@ mod tests { assert_eq!(preview.media_type, "image/png"); assert_eq!(preview.byte_len, png_bytes().len() as u64); assert!(preview.data_url.starts_with("data:image/png;base64,")); + // 这份 fixture 是 PNG colorType 4(灰度 + alpha),因此预览必须报「有 alpha」—— + // 资源卡据此才铺棋盘格底。 + assert!(preview.has_alpha); + } + + #[test] + fn png_alpha_follows_color_type_and_transparency_chunk() { + let color_type_alpha = |color_type: u8| { + let mut bytes = png_header(color_type); + push_png_chunk(&mut bytes, b"IDAT", &[0, 0, 0]); + push_png_chunk(&mut bytes, b"IEND", &[]); + detect_raster_image_has_alpha(&bytes, "image/png") + }; + + // 颜色类型 4(灰度 + alpha)与 6(真彩 + alpha)才带 alpha 通道。 + assert!(color_type_alpha(4), "colorType 4 应判为有 alpha"); + assert!(color_type_alpha(6), "PNG-32(colorType 6)应判为有 alpha"); + // 0 / 2 / 3 本身没有 alpha 通道:这是「AI 把棋盘格画进像素里」那张不透明 PNG 的形状。 + assert!(!color_type_alpha(0), "colorType 0 不应判为有 alpha"); + assert!( + !color_type_alpha(2), + "PNG-24(colorType 2)不应判为有 alpha" + ); + assert!( + !color_type_alpha(3), + "colorType 3 无 tRNS 时不应判为有 alpha" + ); + // 未定义的颜色类型失败关闭为「不透明」,不能把坏文件当成透明。 + assert!(!color_type_alpha(7), "未定义 colorType 不应判为有 alpha"); + + // 灰度 / 真彩 / 调色板可以靠 tRNS 声明透明色,那也是真透明 PNG,必须铺棋盘格。 + for color_type in [0_u8, 2, 3] { + let mut bytes = png_header(color_type); + push_png_chunk(&mut bytes, b"tRNS", &[0]); + push_png_chunk(&mut bytes, b"IDAT", &[0, 0, 0]); + push_png_chunk(&mut bytes, b"IEND", &[]); + assert!( + detect_raster_image_has_alpha(&bytes, "image/png"), + "colorType {color_type} + tRNS 也是真透明 PNG" + ); + } + + // tRNS 规范上必须在 IDAT 之前:出现在之后不再继续扫 chunk(成本有界)。 + let mut late_trns = png_header(3); + push_png_chunk(&mut late_trns, b"IDAT", &[0, 0, 0]); + push_png_chunk(&mut late_trns, b"tRNS", &[0]); + push_png_chunk(&mut late_trns, b"IEND", &[]); + assert!(!detect_raster_image_has_alpha(&late_trns, "image/png")); + } + + #[test] + fn jpeg_and_webp_alpha_follow_container_flags() { + // JPEG 没有 alpha 通道:恒不透明(也绝不为了判 alpha 去解码扫描段)。 + assert!(!detect_raster_image_has_alpha( + &jpeg_bytes(40, 20, None), + "image/jpeg" + )); + // 扩展格式 VP8X 的 flags 第 4 位就是 alpha 标志。 + assert!(detect_raster_image_has_alpha( + &webp_vp8x(0x10), + "image/webp" + )); + assert!(!detect_raster_image_has_alpha( + &webp_vp8x(0x00), + "image/webp" + )); + // 只有 ICC(0x20)/ EXIF(0x08)等其它标志时不是 alpha。 + assert!(!detect_raster_image_has_alpha( + &webp_vp8x(0x28), + "image/webp" + )); + // 无损 VP8L 的 alpha_is_used 位。 + assert!(detect_raster_image_has_alpha( + &webp_vp8l(true), + "image/webp" + )); + assert!(!detect_raster_image_has_alpha( + &webp_vp8l(false), + "image/webp" + )); + // 简单有损格式不带 alpha 通道。 + assert!(!detect_raster_image_has_alpha( + &webp_vp8_simple(), + "image/webp" + )); + + // 头部被截断时失败关闭为「不透明」,且不得 panic。 + let truncated_webp = webp_vp8x(0x10); + assert!(!detect_raster_image_has_alpha( + &truncated_webp[..18], + "image/webp" + )); + let truncated_png = png_header(6); + assert!(!detect_raster_image_has_alpha( + &truncated_png[..20], + "image/png" + )); + } + + #[test] + fn alpha_judgement_never_decodes_pixels() { + // 4096×4096 的 PNG-32:真按像素解码要 64 MiB 缓冲,而下面的 IDAT 数据体不是合法 + // deflate 流(全零),任何真正的解码器都会失败。判据只看头部,所以这里必须成功, + // 并且仍然判 has_alpha=true —— 这就是「不做全量解码」的可执行证据。 + let root = tempfile::tempdir().expect("temp root"); + fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir"); + let mut bytes = png_header_with_size(6, 4_096, 4_096); + push_png_chunk(&mut bytes, b"IDAT", &[0x00, 0x00, 0x00, 0x00]); + push_png_chunk(&mut bytes, b"IEND", &[]); + fs::write(root.path().join("assets/ui/large.png"), &bytes).expect("large image"); + + let preview = load_local_project_image_preview(root.path(), "assets/ui/large.png") + .expect("header-only preview"); + + assert_eq!(preview.pixel_width, 4_096); + assert_eq!(preview.byte_len, bytes.len() as u64); + assert!(preview.has_alpha); + } + + #[test] + fn image_preview_serializes_alpha_flag_for_the_shell() { + let root = tempfile::tempdir().expect("temp root"); + fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir"); + fs::write(root.path().join("assets/ui/prototype.png"), png_bytes()).expect("image"); + + let preview = load_local_project_image_preview(root.path(), "assets/ui/prototype.png") + .expect("load project preview"); + + // 前端按 camelCase 读 `hasAlpha`(`ProjectResourceCardPreviewTransportPayload`); + // 字段名或大小写改了会让资源卡永远退回纯色底,所以这里钉住 IPC 契约。 + let serialized = serde_json::to_value(&preview).expect("serialize preview"); + assert_eq!(serialized["hasAlpha"], serde_json::json!(true)); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs b/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs index 2e267b28f..d8df54e84 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/isolated_agent.rs @@ -39,6 +39,7 @@ pub(crate) const ISOLATED_AGENT_PRIVATE_MEMORY_SCHEMA_VERSION: &str = pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_COMMAND_IDS: &[&str] = &[ "project.verify", + "project.bootstrap", "project.git_commit", "command.exec", "command.start", @@ -59,6 +60,7 @@ pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_COMMAND_IDS: &[&str] = &[ pub(crate) const ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS: &[&str] = &[ "project.verify", + "project.bootstrap", "project.git_commit", "command.exec", "command.start", 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 7f1d60fdc..631e6d162 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -244,6 +244,7 @@ macro_rules! app_log { // 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。 mod agent; mod agent_native_tools; +mod asset_generation_tasks; mod assets; mod browser; mod builtin_plugins; @@ -289,6 +290,7 @@ mod windows; use agent::*; use agent_native_tools::*; +use asset_generation_tasks::*; use assets::*; use browser::*; use cli::*; @@ -339,6 +341,41 @@ async fn recognize_ui( ui_editor::commands::recognize_ui_impl(project_path, state).await } +#[tauri::command] +async fn separate_ui( + project_path: String, + asset_id: String, + state: ui_editor::state::State, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + ui_editor::commands::separate_ui_impl(project_path, asset_id, state).await +} + +#[tauri::command] +fn inspect_separation_recovery( + project_path: String, + asset_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.list")?; + ui_editor::commands::separation::inspect_separation_recovery(root, &asset_id) +} + +#[tauri::command] +fn finalize_separation(project_path: String, asset_id: String) -> Result<(), String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + ui_editor::commands::separation::finalize_separation(root, &asset_id) +} + +#[tauri::command] +fn discard_separation_recovery(project_path: String, asset_id: String) -> Result<(), String> { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + ui_editor::commands::separation::discard_separation_recovery(root, &asset_id) +} + #[tauri::command] async fn merge_ui(state: ui_editor::state::State) -> Result { ui_editor::commands::merge_ui_impl(state).await @@ -2641,6 +2678,7 @@ fn main() { hydrate_design_agent_session, reset_design_agent_session, get_design_agent_runtime_mode, + is_design_agent_debug_enabled, set_design_agent_runtime_mode, debug_fast_forward_design_session, continue_design_agent_session, @@ -2695,6 +2733,10 @@ fn main() { check_ui_editor_font_glyph_coverage, suggest_ui_design_semantic, recognize_ui, + separate_ui, + inspect_separation_recovery, + finalize_separation, + discard_separation_recovery, merge_ui, bind_components, load_ui_design_state, @@ -2703,6 +2745,8 @@ fn main() { ensure_ui_design_resource_for_prototype, generate_platform_art_asset, generate_local_project_asset, + start_local_project_asset_generation, + list_local_project_asset_generations, open_canvas_project, get_game_creation_agent_capabilities, get_limited_local_commands, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 3cd6e4ac2..33e24e898 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -6,6 +6,7 @@ use std::io::{Seek, SeekFrom}; mod agent_db; mod asset_export; mod asset_rename; +mod bootstrap; mod checkpoint; mod conversation; mod export; @@ -23,6 +24,7 @@ mod write_lock; pub(crate) use agent_db::*; pub(crate) use asset_export::*; pub(crate) use asset_rename::*; +pub(crate) use bootstrap::*; pub(crate) use checkpoint::*; pub(crate) use conversation::*; pub(crate) use export::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/bootstrap.rs b/apps/ai-game-creator-shell/src-tauri/src/project/bootstrap.rs new file mode 100644 index 000000000..51ac834a7 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/bootstrap.rs @@ -0,0 +1,113 @@ +use super::*; +use sha2::{Digest, Sha256}; + +const BOOTSTRAP_PACKAGE_MAX_BYTES: u64 = 512 * 1024; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ProjectBootstrapResult { + pub(crate) status: String, + pub(crate) output: String, + pub(crate) package_sha256: String, + pub(crate) lock_sha256: Option, + pub(crate) log_path: String, + pub(crate) updated_at: u64, +} + +fn read_bootstrap_file(path: &Path, label: &str) -> Result, String> { + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("读取 {label} 失败:{}: {error}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!("project.bootstrap 要求 {label} 是普通文件")); + } + if metadata.len() > BOOTSTRAP_PACKAGE_MAX_BYTES { + return Err(format!( + "project.bootstrap {label} 超过 {} 字节上限", + BOOTSTRAP_PACKAGE_MAX_BYTES + )); + } + prepare_game_creator_private_path_for_read(path, false, label)?; + fs::read(path).map_err(|error| format!("读取 {label} 失败:{error}")) +} + +pub(crate) async fn run_project_bootstrap_at( + root: &Path, + timeout_seconds: u64, +) -> Result { + validate_project_root(root)?; + let game = resolve_local_project_path(root, "game")?; + if !game.is_dir() { + return Err("project.bootstrap 只允许项目内 game 目录".to_string()); + } + let package = read_bootstrap_file(&game.join("package.json"), "game/package.json")?; + let package_json: serde_json::Value = serde_json::from_slice(&package) + .map_err(|error| format!("解析 game/package.json 失败:{error}"))?; + if let Some(manager) = package_json + .get("packageManager") + .and_then(serde_json::Value::as_str) + { + if !manager.trim().starts_with("npm@") && manager.trim() != "npm" { + return Err("project.bootstrap 当前只支持 npm packageManager".to_string()); + } + } + for lock_name in ["pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"] { + if game.join(lock_name).exists() { + return Err(format!("project.bootstrap 检测到非 npm 锁文件 {lock_name}")); + } + } + if !package_json + .get("scripts") + .is_some_and(serde_json::Value::is_object) + { + return Err("project.bootstrap 要求 game/package.json 包含 scripts 对象".to_string()); + } + let lock = match fs::symlink_metadata(game.join("package-lock.json")) { + Ok(_) => Some(read_bootstrap_file( + &game.join("package-lock.json"), + "game/package-lock.json", + )?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(format!("读取 game/package-lock.json 失败:{error}")), + }; + let command = crate::command_exec::run_project_bootstrap_command_at(root, timeout_seconds) + .await + .map_err(|error| error.to_string())?; + let completed = command.exit_code == Some(0) && !command.timed_out; + let status = if completed { "completed" } else { "failed" }; + let updated_at = unix_timestamp(); + let log_path = resolve_local_project_path(root, ".agent/logs/command.log")?; + let package_sha256 = format!("{:x}", Sha256::digest(&package)); + let lock_sha256 = lock + .as_ref() + .map(|bytes| format!("{:x}", Sha256::digest(bytes))); + let output = sanitize_project_verification_output(&command.output); + let line = format!( + "{updated_at} project.bootstrap status={} packageSha256={} lockSha256={} cwd=game\n{}\n", + status, + package_sha256, + lock_sha256.as_deref().unwrap_or("none"), + output + ); + append_game_creator_private_file(&log_path, line.as_bytes(), "命令日志")?; + record_command_run( + root, + GameCreationAppCommandRunState { + command_id: "project.bootstrap".to_string(), + status: if completed { + GameCreationAppCommandRunStatus::Completed + } else { + GameCreationAppCommandRunStatus::Failed + }, + output: output.clone(), + log_path: ".agent/logs/command.log".to_string(), + updated_at, + }, + )?; + Ok(ProjectBootstrapResult { + status: status.to_string(), + output, + package_sha256, + lock_sha256, + log_path: ".agent/logs/command.log".to_string(), + updated_at, + }) +} 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 12a677216..3a358c46e 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 @@ -121,6 +121,7 @@ pub(crate) struct ProjectVerificationSpec { pub(crate) program: String, pub(crate) arguments: Vec, pub(crate) timeout_seconds: u64, + pub(crate) cwd_relative: String, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -129,6 +130,7 @@ pub(crate) struct ProjectVerificationResult { pub(crate) script: String, pub(crate) expected_command: String, pub(crate) package_manager: String, + pub(crate) cwd_relative: String, pub(crate) status: String, pub(crate) exit_code: Option, pub(crate) timed_out: bool, @@ -290,9 +292,38 @@ pub(crate) fn resolve_project_verification_spec_at( script: &str, expected_command: &str, timeout_seconds: u64, +) -> Result { + resolve_project_verification_spec_with_cwd_at( + root, + script, + expected_command, + timeout_seconds, + ".", + ) +} + +pub(crate) fn resolve_project_verification_spec_with_cwd_at( + root: &Path, + script: &str, + expected_command: &str, + timeout_seconds: u64, + cwd_relative: &str, ) -> Result { validate_project_root(root)?; - ensure_project_verification_has_no_project_npmrc(root)?; + let cwd_relative = if cwd_relative.trim().is_empty() || cwd_relative.trim() == "." { + ".".to_string() + } else { + normalize_relative_path(cwd_relative)? + }; + let package_root = if cwd_relative == "." { + root.to_path_buf() + } else { + resolve_local_project_path(root, &cwd_relative)? + }; + if !package_root.is_dir() { + return Err("project.verify cwd 必须是项目内普通目录".to_string()); + } + ensure_project_verification_has_no_project_npmrc(&package_root)?; let script = script.trim(); if script.chars().count() > PROJECT_VERIFICATION_SCRIPT_MAX_CHARS { return Err(format!( @@ -323,7 +354,7 @@ pub(crate) fn resolve_project_verification_spec_at( )); } - let package_path = root.join("package.json"); + let package_path = package_root.join("package.json"); let metadata = fs::symlink_metadata(&package_path).map_err(|error| { format!( "读取 package.json 失败:{}: {error}", @@ -348,7 +379,7 @@ pub(crate) fn resolve_project_verification_spec_at( })?; let package: serde_json::Value = serde_json::from_str(&package_content) .map_err(|error| format!("解析 package.json 失败:{error}"))?; - let package_manager = project_verification_package_manager_at(root, &package)?; + let package_manager = project_verification_package_manager_at(&package_root, &package)?; let actual_command = package .get("scripts") .and_then(serde_json::Value::as_object) @@ -360,6 +391,20 @@ pub(crate) fn resolve_project_verification_spec_at( "package.json 中的 {script} 脚本已变化,请重新读取后再确认执行" )); } + if script == "build" && cwd_relative == "game" { + let modules_path = package_root.join("node_modules"); + let modules_metadata = fs::symlink_metadata(&modules_path).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + "project.verify build 前缺少 game/node_modules;请先执行 project.bootstrap" + .to_string() + } else { + format!("project.verify 检查 game/node_modules 失败:{error}") + } + })?; + if modules_metadata.file_type().is_symlink() || !modules_metadata.is_dir() { + return Err("project.verify build 前的 game/node_modules 不是普通目录;请重新执行 project.bootstrap".to_string()); + } + } Ok(ProjectVerificationSpec { script: script.to_string(), @@ -373,6 +418,7 @@ pub(crate) fn resolve_project_verification_spec_at( script.to_string(), ], timeout_seconds, + cwd_relative, }) } @@ -485,10 +531,21 @@ async fn run_project_verification_process( where F: FnOnce() -> Result<(), String>, { - ensure_project_verification_has_no_project_npmrc(root) + let package_root = if spec.cwd_relative == "." { + root.to_path_buf() + } else { + resolve_local_project_path(root, &spec.cwd_relative) + .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))? + }; + ensure_project_verification_has_no_project_npmrc(&package_root) .map_err(|error| ProjectCommandError::new(ProjectCommandErrorStage::Preflight, error))?; - let command_spec = - resolve_project_command_spec_at(root, "npm", &spec.arguments, ".", spec.timeout_seconds)?; + let command_spec = resolve_project_command_spec_at( + root, + "npm", + &spec.arguments, + &spec.cwd_relative, + spec.timeout_seconds, + )?; let launch = prepare_project_command_launch_spec(root, &command_spec)?; let launch_metadata = launch.clone(); let staged = stage_project_command_launch_spec(&command_spec, launch)?; @@ -620,9 +677,14 @@ pub(crate) async fn run_project_verification_at( expected_command: &str, timeout_seconds: u64, ) -> Result { - run_project_verification_with_commit_at(root, script, expected_command, timeout_seconds, || { - Ok(()) - }) + run_project_verification_with_commit_at( + root, + script, + expected_command, + timeout_seconds, + ".", + || Ok(()), + ) .await } @@ -631,15 +693,21 @@ pub(crate) async fn run_project_verification_with_commit_at( script: &str, expected_command: &str, timeout_seconds: u64, + cwd_relative: &str, durable_commit: F, ) -> Result where F: FnOnce() -> Result<(), String>, { - let spec = - resolve_project_verification_spec_at(root, script, expected_command, timeout_seconds)?; + let spec = resolve_project_verification_spec_with_cwd_at( + root, + script, + expected_command, + timeout_seconds, + cwd_relative, + )?; let started_at = std::time::Instant::now(); - let process = match run_project_verification_process(root, &spec, durable_commit).await { + let mut process = match run_project_verification_process(root, &spec, durable_commit).await { Ok(process) => process, Err(error) if error.needs_reconciliation() => { return Err(format!("project.verify 执行状态需要人工核对:{error}")); @@ -658,7 +726,18 @@ where }, }; let duration_ms = u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX); - let completed = !process.timed_out && process.exit_code == Some(0); + let mut completed = !process.timed_out && process.exit_code == Some(0); + if completed && spec.script == "build" && spec.cwd_relative == "game" { + let dist_entry = root.join("game").join("dist").join("index.html"); + completed = fs::symlink_metadata(&dist_entry) + .is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink()); + if !completed { + process.output = format!( + "{}\nproject.verify build 成功但缺少 game/dist/index.html", + process.output + ); + } + } let status = if completed { "completed" } else { "failed" }; let command_id = format!("project.verify.{}", spec.script); let updated_at = unix_timestamp(); @@ -703,6 +782,7 @@ where script: spec.script, expected_command: spec.expected_command, package_manager: spec.package_manager, + cwd_relative: spec.cwd_relative, status: status.to_string(), exit_code: process.exit_code, timed_out: process.timed_out, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs b/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs index f615b2ddb..bdf5bd33d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs @@ -14,16 +14,59 @@ const PROJECT_WRITE_LOCK_UNWRITTEN_GRACE_SECONDS: u64 = 30; const PROJECT_WRITE_LOCK_PID_REUSE_TOLERANCE_SECONDS: u64 = 5; const PROJECT_WRITE_LOCK_MAX_BYTES: u64 = 4 * 1024; +/// 本进程内真正落盘持有项目写锁的线程登记表。 +/// +/// `.agent/project.lock` 的 `pid` 只能证明“锁由本进程的某条写通道持有”,它分不清 +/// 两种完全不同的局面: +/// - **同一条调用链再次取锁**:持锁方就是自己,必须放行,否则每次嵌套项目写入都要 +/// 白等一个等待预算再报“项目正在被其他写操作占用”; +/// - **本进程另一条写通道正在写**:项目 revision 侧车、steer 序号、一致快照读、 +/// pending sidecar 复核和恢复安装都靠这把锁串行化,必须照旧等待。 +/// +/// 复用判据因此不能停在 `pid`:只有**当前线程**就是真实持锁线程时才返回 advisory +/// guard,本进程其余争用继续走有界等待与终态占用。登记按路径进行、按路径注销: +/// guard 可能被移到别的线程再 Drop(例如写入路径把锁交给阻塞线程池的持有者), +/// 按线程注销会漏项,让后续的重入判断失真。 +static PROJECT_WRITE_LOCK_THREAD_OWNERS: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + +fn project_write_lock_thread_owners( +) -> std::sync::MutexGuard<'static, Vec<(PathBuf, std::thread::ThreadId)>> { + // 登记表只是复用判据的加速器:中毒时继续用内部值,不能让一次取锁失败升级成 + // 整个进程再也写不了项目。 + PROJECT_WRITE_LOCK_THREAD_OWNERS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn register_project_write_lock_thread_owner(path: &Path) { + let mut owners = project_write_lock_thread_owners(); + if owners.iter().any(|(owner, _)| owner == path) { + return; + } + owners.push((path.to_path_buf(), std::thread::current().id())); +} + +fn unregister_project_write_lock_thread_owner(path: &Path) { + project_write_lock_thread_owners().retain(|(owner, _)| owner != path); +} + +/// 当前线程是否就是这条锁路径上真实落盘的持有者(同线程重入)。 +fn project_write_lock_reentered_by_current_thread(path: &Path) -> bool { + let thread = std::thread::current().id(); + project_write_lock_thread_owners() + .iter() + .any(|(owner, owner_thread)| owner == path && *owner_thread == thread) +} + #[derive(Debug)] pub(crate) struct ProjectWriteLock { path: PathBuf, content: String, - /// In the free-form autonomous lane a single Runtime process may have - /// several specialist actions in flight at once. A file lock is still - /// useful across processes, but making same-process contenders fail turns - /// ordinary parallel work into a dead run (and can deadlock nested tool - /// calls). Such a contender receives an in-process/advisory guard instead - /// of deleting the real holder's lock on drop. + /// 两种“本进程持锁但不必自等”的争用会拿到 advisory guard:同一线程重入(同一条 + /// 调用链再次取锁)和自主游戏构建流水线(它有意让并行专家动作同时在飞)。这两种 + /// 情况下争用是进程内重叠而不是另一个客户端在改项目,返回的 guard 不拥有 + /// `.agent/project.lock`,Drop 时也不得删除真实持有者的锁。 bypassed_same_process: bool, } @@ -47,6 +90,7 @@ impl Drop for ProjectWriteLock { if self.bypassed_same_process { return; } + unregister_project_write_lock_thread_owner(&self.path); if fs::read_to_string(&self.path).is_ok_and(|content| content == self.content) { let _ = fs::remove_file(&self.path); } @@ -815,6 +859,7 @@ pub(crate) fn acquire_project_write_lock_failure( path.display() ))); } + register_project_write_lock_thread_owner(&path); return Ok(ProjectWriteLock { path, content: content.clone(), @@ -860,14 +905,15 @@ pub(crate) fn acquire_project_write_lock_failure( } } } - if crate::agent::autonomous_game_build_root_run_active_at(root) - && project_write_lock_is_owned_by_current_process(&path) + if project_write_lock_is_owned_by_current_process(&path) + && (crate::agent::autonomous_game_build_root_run_active_at(root) + || project_write_lock_reentered_by_current_thread(&path)) { - // The autonomous game-build lane intentionally permits - // parallel specialist actions. If the durable lock belongs - // to this very process, contention is an in-process overlap, - // not another application editing the project. Return an - // advisory guard and leave the real lock untouched. + // 持锁方就是本进程自己时必须区分重入与并发:同一条调用链(同一 + // 线程)再次取锁,以及自主流水线有意并行专家动作,返回 advisory + // guard、不自等、不动真实锁;本进程**其它线程**正在写则继续走 + // 有界等待,保住 revision 侧车、steer 序号、一致快照读与恢复安装 + // 的串行化。 return Ok(ProjectWriteLock { path, content: String::new(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/provider_handoff.rs b/apps/ai-game-creator-shell/src-tauri/src/provider_handoff.rs index bae190561..e4cf78d4c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/provider_handoff.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/provider_handoff.rs @@ -51,6 +51,7 @@ impl AgentRuntimeProviderHandoffRecord { provider: self.response.provider, model: self.response.model.clone(), text: self.response.text.clone(), + reasoning: String::new(), finish_reason: self.response.finish_reason.clone(), response_id: self.response.response_id.clone(), usage: self.response.usage.clone(), @@ -340,6 +341,7 @@ mod tests { provider: LlmProvider::OpenAiCompatible, model: "handoff-model".to_string(), text: text.to_string(), + reasoning: String::new(), finish_reason: Some("stop".to_string()), response_id: Some("response-handoff".to_string()), usage: Some(LlmTokenUsage { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index b0ce174bc..20a6dfc0d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -2775,6 +2775,7 @@ fn durable_provider_handoff_prevents_shutdown_even_when_corrupt() { provider: platform_llm::LlmProvider::OpenAiCompatible, model: "provider-handoff-runner-test".to_string(), text: "durable final reply".to_string(), + reasoning: String::new(), finish_reason: Some("stop".to_string()), response_id: Some("provider-handoff-response".to_string()), usage: None, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index f66a45540..28b4dcbc1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -4475,6 +4475,7 @@ fn real_e2e_tool_plan_checkpoint_response() -> platform_llm::LlmRunResponse { provider: platform_llm::LlmProvider::OpenAiCompatible, model: "real-e2e-checkpoint-model".to_string(), text: REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_RESPONSE.to_string(), + reasoning: String::new(), finish_reason: Some("tool_calls".to_string()), response_id: Some("real-e2e-checkpoint-private-response-id".to_string()), usage: None, @@ -4720,6 +4721,7 @@ fn agent_tool_plan_llm_response( provider: platform_llm::LlmProvider::OpenAiCompatible, model: "mock-game-model".to_string(), text: text.into(), + reasoning: String::new(), finish_reason: Some("tool_calls".to_string()), response_id: Some("response-tool-plan-test".to_string()), usage: None, 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 52ad3d47d..4fa58e0e2 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 @@ -1088,6 +1088,90 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { fs::remove_dir_all(ui_config_dir).ok(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn direct_image_generation_notifies_after_manifest_commit() { + let root = unique_project_path(); + let config_dir = unique_project_path(); + let canvas_base_url = spawn_mock_external_canvas_generation_api_server(None); + let _session = crate::platform_session::install_test_platform_session( + "direct-image-refresh-user", + "editor-runtime-key", + &canvas_base_url, + ); + fs::create_dir_all(&config_dir).expect("create config directory"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { "baseUrl": canvas_base_url, "apiKey": "editor-runtime-key" } + }) + .to_string(), + ) + .expect("write config"); + let _config = use_test_runtime_config_dir(config_dir.clone()); + init_local_game_project_at(&root, "direct-image-refresh", "生成图片刷新测试") + .expect("init project"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow generation"); + let listener = + TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).expect("bind event receiver"); + let sink = acquire_game_creator_manifest_invalidation_event_sink_test_guard(); + sink.configure(listener.local_addr().unwrap().port(), &"d".repeat(64)) + .expect("configure event receiver"); + let bridge = start_direct_tool_bridge(&root, false) + .await + .expect("start tool bridge"); + let client = reqwest::Client::new(); + let result: Value = client.post(bridge.url()).json(&serde_json::json!({ + "tool": "agc_generate_image", + "arguments": { "prompt": "像素月光主角", "kind": "icon-spec", "outputPath": "assets/art-spec.png" } + })).send().await.expect("generate through bridge").json().await.expect("read tool result"); + assert_eq!(result["isError"], false, "{result}"); + let manifest = read_existing_manifest_for_project(&root).expect("read committed manifest"); + assert!(manifest + .assets + .iter() + .any(|asset| asset.local_path == "assets/art-spec.png")); + assert!(root.join("assets/art-spec.png").is_file()); + let payload = read_manifest_invalidation_relay_payload_with_deadline(&listener) + .expect("generation must notify the client"); + let envelope: GameCreatorManifestInvalidationRelayEnvelope = + serde_json::from_slice(&payload).expect("event envelope"); + assert_eq!( + envelope.event.project_path, + fs::canonicalize(&root).unwrap().to_string_lossy() + ); + assert_eq!(envelope.event.agent_id, "direct-codex-art"); + + let rejected: Value = client + .post(bridge.url()) + .json(&serde_json::json!({ + "tool": "agc_generate_image", "arguments": { "prompt": "", "kind": "icon-spec" } + })) + .send() + .await + .expect("send rejected request") + .json() + .await + .expect("read rejected result"); + assert_eq!(rejected["isError"], true); + assert_eq!( + read_manifest_invalidation_relay_payload_with_deadline(&listener) + .expect_err("rejected generation must not emit a commit") + .kind(), + io::ErrorKind::TimedOut + ); + drop(bridge); + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_manifest() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs index bd77f59e6..8709bc5b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs @@ -5611,12 +5611,14 @@ fn local_project_checkpoint_diff_restore_and_index_are_recorded() { } #[test] -fn project_write_lock_rejects_parallel_writer_and_releases_on_drop() { +fn project_write_lock_reuses_same_process_owner_and_releases_on_drop() { let root = unique_project_path(); let first = acquire_project_write_lock(&root, "file.write").expect("first lock"); - let error = acquire_project_write_lock(&root, "file.delete").expect_err("second lock fails"); - assert!(error.contains("项目正在被其他写操作占用")); + let nested = acquire_project_write_lock(&root, "file.delete") + .expect("same process must reuse the client project lock"); + drop(nested); + assert!(root.join(PROJECT_WRITE_LOCK_PATH).exists()); drop(first); acquire_project_write_lock(&root, "file.delete").expect("lock released"); @@ -5816,8 +5818,27 @@ async fn agent_runtime_file_write_lock_failure_redacts_project_path() { }, ) .expect("allow direct file write"); - let lock = acquire_project_write_lock(&root, "persistent-writer") - .expect("acquire persistent project writer"); + // 持锁方必须是**另一条线程**:本用例验证的是“别的写通道正在写时 file.write 必须 + // 走满等待预算并失败关闭”,同一条调用链自持锁属于重入复用,不会失败。 + let holder_root = root.clone(); + let (release_sender, release_receiver) = mpsc::channel::<()>(); + let holder = std::thread::spawn(move || { + let lock = acquire_project_write_lock(&holder_root, "persistent-writer") + .expect("acquire persistent project writer"); + let _ = release_receiver.recv(); + drop(lock); + }); + let lock_path = root.join(PROJECT_WRITE_LOCK_PATH); + for _ in 0..400 { + if lock_path.is_file() { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + assert!( + lock_path.is_file(), + "persistent writer must hold the project write lock" + ); let observation = execute_game_creator_agent_runtime_tool_action( &root, @@ -5835,7 +5856,8 @@ async fn agent_runtime_file_write_lock_failure_redacts_project_path() { ) .await; - drop(lock); + let _ = release_sender.send(()); + holder.join().expect("join persistent project writer"); assert_eq!(observation.status, "failed"); assert!(!observation .summary @@ -6197,7 +6219,7 @@ fn local_project_resource_previews_require_registered_safe_resources() { register_local_asset_at( &root, "game/design.md", - "design-document", + "document", "text/markdown", "generated", source(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs index ca8c9da94..e532e76b7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs @@ -389,7 +389,23 @@ fn collect_native_tool_absolute_path_findings( } } } + "project.bootstrap" => { + collect_native_string_field( + root, + input, + "cwd", + &format!("{input_pointer}/cwd"), + findings, + ); + } "project.verify" => { + collect_native_string_field( + root, + input, + "cwd", + &format!("{input_pointer}/cwd"), + findings, + ); collect_native_string_field( root, input, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/model.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/model.rs index c7f5451eb..f2a14571f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/model.rs @@ -127,6 +127,7 @@ impl AgentRuntimeToolPlanHandoffEntry { provider: self.response.provider, model: self.response.model.clone(), text, + reasoning: String::new(), finish_reason: self.response.finish_reason.clone(), response_id: self.response.response_id.clone(), usage: self.response.usage.as_ref().map(LlmTokenUsage::from), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs index e9f048dbd..2e7f6fe79 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs @@ -84,6 +84,7 @@ fn response(text: &str, tool_calls: Vec) -> LlmRunResponse { provider: LlmProvider::OpenAiCompatible, model: "tool-plan-handoff-model".to_string(), text: text.to_string(), + reasoning: String::new(), finish_reason: Some("tool_calls".to_string()), response_id: Some("tool-plan-handoff-response".to_string()), usage: Some(LlmTokenUsage { 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 4585eee85..797f8c44d 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 @@ -5,11 +5,9 @@ use crate::ui_editor::commands::utils::{ strict_json_schema, }; use crate::ui_editor::component::text::FontSource; -use crate::ui_editor::component::Component; +use crate::ui_editor::component::{Component, NodeComponent}; use crate::ui_editor::layout::node::{Node, StageStatus}; -use crate::ui_editor::persistence::{ - UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE, UI_DESIGN_STATE_MAX_NODES, -}; +use crate::ui_editor::persistence::UI_DESIGN_STATE_MAX_NODES; use crate::ui_editor::state::State; use crate::ui_editor::utils::{FontAssetId, NodeId, SpriteAssetId}; use platform_llm::{ @@ -31,10 +29,10 @@ const SYSTEM_PROMPT: &str = r#" 你是游戏 UI 组件绑定器。你会看到全部 UI 参考图、可编辑节点说明,以及本批独立素材的真实像素。 * 只对视觉上确实需要改变组件的节点返回 changes; -* 每个 change 的 components 是该节点完整的新渲染栈,空数组表示明确清空。数组顺序从底到顶渲染。 -* 对每个 Component,直接完整返回其全部参数. +* 每个 change 的 component 是该节点完整的新组件;纯结构节点返回 "PureNode",有组件返回 {"WithComponent": <完整 Component>}。 +* 对 Component,直接完整返回其全部参数. * 有任何困难或者不确定把状态设为 NeedReview,说明中文原因。 -* 纯结构节点可以返回空数组并标为 NoProblem。 +* 纯结构节点可以返回 "PureNode" 并标为 NoProblem。 * 容器背景等推荐使用Simple + preserve_aspect: false 实现与node大小一致 * 面向用户的 reason 使用中文。 @@ -53,8 +51,8 @@ enum DraftStatus { #[schemars(deny_unknown_fields)] struct BindingChangeDraft { node_id: NodeId, - components: Vec, - components_status: DraftStatus, + component: NodeComponent, + component_status: DraftStatus, } #[derive(Clone, Debug, Deserialize, JsonSchema)] @@ -68,8 +66,8 @@ struct BindingResponse { #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] pub struct BindingChange { pub node_id: NodeId, - pub components: Vec, - pub components_status: StageStatus, + pub component: NodeComponent, + pub component_status: StageStatus, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] @@ -83,7 +81,7 @@ struct EditableNodeContext<'a> { node_id: &'a NodeId, name: &'a str, description: &'a str, - components: &'a [Component], + component: Option<&'a Component>, } #[derive(Debug, Serialize)] @@ -106,7 +104,7 @@ fn collect_editable_nodes<'a>(node: &'a Node, output: &mut Vec UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE { - return Err(format!( - "单个组件绑定栈不能超过 {UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE} 个组件" - )); + let Some(object) = change.as_object() else { + return Err("组件绑定 change 缺少 component 字段".to_string()); + }; + let Some(component) = object.get("component") else { + return Err("组件绑定 change 缺少 component 字段".to_string()); + }; + let valid_component = component == "PureNode" + || component + .as_object() + .and_then(|value| value.get("WithComponent")) + .is_some_and(serde_json::Value::is_object); + if !valid_component { + return Err( + "组件绑定 change 的 component 必须是 PureNode 或 WithComponent 对象".to_string(), + ); } } Ok(()) @@ -212,7 +217,7 @@ fn validate_and_materialize( if !changed_ids.insert(change.node_id.clone()) { return Err(format!("组件绑定重复返回节点:{}", change.node_id.as_str())); } - for component in &change.components { + if let NodeComponent::WithComponent(component) = &change.component { match component { Component::Image(image) => { if image @@ -232,18 +237,22 @@ fn validate_and_materialize( } } } - let components = change.components; - let components_status = match change.components_status { + let component_status = match change.component_status { DraftStatus::NoProblem => StageStatus::NoProblem, DraftStatus::NeedReview(reason) if reason.trim().is_empty() => { return Err("组件待审状态必须包含原因".to_string()) } - DraftStatus::NeedReview(reason) => StageStatus::NeedReview(reason), + DraftStatus::NeedReview(reason) => { + if matches!(&change.component, NodeComponent::PureNode) { + return Err("纯结构节点不能标记为组件待审".to_string()); + } + StageStatus::NeedReview(reason) + } }; materialized.push(BindingChange { node_id: change.node_id, - components, - components_status, + component: change.component, + component_status, }); } Ok(BindingDTO { @@ -440,8 +449,8 @@ mod tests { ]); let unapproved = BindingChangeDraft { node_id: id("other"), - components: Vec::new(), - components_status: DraftStatus::NoProblem, + component: NodeComponent::PureNode, + component_status: DraftStatus::NoProblem, }; assert!( validate_and_materialize(vec![unapproved], &editable, &known, &HashSet::new()).is_err() @@ -450,7 +459,7 @@ mod tests { // References to sprites from another batch are allowed once they exist in the project. let other_batch = BindingChangeDraft { node_id: id("editable"), - components: vec![Component::Image( + component: NodeComponent::WithComponent(Component::Image( crate::ui_editor::component::image::ImageComponent { target_graphic: Some( SpriteAssetId::new("other-batch-sprite").expect("valid sprite"), @@ -459,8 +468,8 @@ mod tests { preserve_aspect: false, }, }, - )], - components_status: DraftStatus::NoProblem, + )), + component_status: DraftStatus::NoProblem, }; assert!( validate_and_materialize(vec![other_batch], &editable, &known, &HashSet::new()).is_ok() @@ -469,15 +478,15 @@ mod tests { // References to sprites that do not exist in the project at all are still rejected. let unknown = BindingChangeDraft { node_id: id("editable"), - components: vec![Component::Image( + component: NodeComponent::WithComponent(Component::Image( crate::ui_editor::component::image::ImageComponent { target_graphic: Some(SpriteAssetId::new("unknown").expect("valid sprite")), image_type: crate::ui_editor::component::image::ImageType::Simple { preserve_aspect: false, }, }, - )], - components_status: DraftStatus::NoProblem, + )), + component_status: DraftStatus::NoProblem, }; assert!( validate_and_materialize(vec![unknown], &editable, &known, &HashSet::new()).is_err() @@ -491,8 +500,8 @@ mod tests { text.font = FontSource::Bound(FontAssetId::new("unknown-font").expect("valid font")); let change = BindingChangeDraft { node_id: id("editable"), - components: vec![Component::Text(text)], - components_status: DraftStatus::NoProblem, + component: NodeComponent::WithComponent(Component::Text(text)), + component_status: DraftStatus::NoProblem, }; let error = validate_and_materialize( @@ -506,13 +515,13 @@ mod tests { } #[test] - fn materialization_preserves_changed_only_empty_component_lists() { + fn materialization_preserves_pure_node_change() { let editable = HashSet::from([id("editable")]); let result = validate_and_materialize( vec![BindingChangeDraft { node_id: id("editable"), - components: Vec::new(), - components_status: DraftStatus::NoProblem, + component: NodeComponent::PureNode, + component_status: DraftStatus::NoProblem, }], &editable, &HashSet::new(), @@ -520,8 +529,28 @@ mod tests { ) .expect("valid changed-only clear"); assert_eq!(result.changes.len(), 1); - assert!(result.changes[0].components.is_empty()); - assert_eq!(result.changes[0].components_status, StageStatus::NoProblem); + assert!(matches!( + result.changes[0].component, + NodeComponent::PureNode + )); + assert_eq!(result.changes[0].component_status, StageStatus::NoProblem); + } + + #[test] + fn materialization_rejects_problematic_pure_node() { + let editable = HashSet::from([id("editable")]); + let error = validate_and_materialize( + vec![BindingChangeDraft { + node_id: id("editable"), + component: NodeComponent::PureNode, + component_status: DraftStatus::NeedReview("缺少可确认的组件".to_string()), + }], + &editable, + &HashSet::new(), + &HashSet::new(), + ) + .expect_err("pure node cannot carry a component review status"); + assert!(error.contains("纯结构节点")); } #[test] @@ -575,20 +604,26 @@ mod tests { } #[test] - fn binding_response_bounds_changes_and_each_component_stack() { + fn binding_response_bounds_changes_and_uses_single_component_shape() { let too_many_changes = serde_json::json!({ - "changes": [{"components": []}, {"components": []}] + "changes": [{"component": "PureNode"}, {"component": "PureNode"}] }); assert!(validate_binding_response_shape(&too_many_changes, 1).is_err()); - let too_many_components = serde_json::json!({ + let one_component = serde_json::json!({ "changes": [{ - "components": (0..=UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE) - .map(|_| serde_json::Value::Null) - .collect::>() + "node_id": "editable", + "component": "PureNode", + "component_status": "NoProblem" }] }); - assert!(validate_binding_response_shape(&too_many_components, 1).is_err()); + assert!(validate_binding_response_shape(&one_component, 1).is_ok()); + let parsed = parse_binding_response(&one_component.to_string(), 1) + .expect("explicit PureNode payload should parse"); + assert!(matches!( + parsed.changes[0].component, + NodeComponent::PureNode + )); } #[tokio::test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs index e8a789948..0a32724d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs @@ -174,7 +174,7 @@ mod materialize { }; use crate::ui_editor::layout::transform::Transform; use crate::ui_editor::state::{State, UITree}; - use crate::ui_editor::utils::{NodeId, UIDesignImageId}; + use crate::ui_editor::utils::{random_node_id, NodeId, UIDesignImageId}; use std::collections::{HashMap, HashSet}; #[derive(Clone)] @@ -230,12 +230,11 @@ mod materialize { Ok(best_index) } - fn random_node_id(occupied: &mut HashSet) -> Result { + fn unique_random_node_id(occupied: &mut HashSet) -> NodeId { loop { - let id = NodeId::new(uuid::Uuid::new_v4().simple().to_string()) - .map_err(|error| format!("生成合并容器节点 ID 失败:{error}"))?; + let id = random_node_id(); if occupied.insert(id.clone()) { - return Ok(id); + return id; } } } @@ -286,7 +285,7 @@ mod materialize { } Ok(BuiltNode { node: LayoutNode { - id: random_node_id(occupied_ids)?, + id: unique_random_node_id(occupied_ids), layout: crate::ui_editor::layout::control_layout::ControlLayout::with_transform( original_transform, @@ -295,12 +294,12 @@ mod materialize { name: container_name, description: container_description, layout_status: StageStatus::NoProblem, - components_status: StageStatus::NoProblem, + component_status: StageStatus::NoProblem, allow_llm_edit_layout: true, allow_llm_edit_component: true, source: NodeSource::Llm, }, - components: Vec::new(), + component: None, children_display_mode: ChildrenDisplayMode::Exclusive, children: members.into_iter().map(|member| member.node).collect(), }, @@ -525,7 +524,6 @@ mod tests { materialize, validate_merge_input_state, validate_merge_plan_shape, MAX_MERGE_INPUT_DEPTH, MAX_MERGE_INPUT_NODES, MAX_MERGE_PLAN_DEPTH, MAX_MERGE_PLAN_NODES, }; - use crate::ui_editor::component::Component; use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode; use crate::ui_editor::layout::control_layout::ControlLayout; use crate::ui_editor::layout::node::{Node, NodeMetadata, NodeSource, StageStatus}; @@ -542,12 +540,12 @@ mod tests { name: id.to_string(), description: String::new(), layout_status: StageStatus::NoProblem, - components_status: StageStatus::NoProblem, + component_status: StageStatus::NoProblem, allow_llm_edit_layout: true, allow_llm_edit_component: true, source: NodeSource::Human, }, - components: Vec::::new(), + component: None, children_display_mode: ChildrenDisplayMode::Stack, children, } @@ -586,7 +584,7 @@ mod tests { ChildrenDisplayMode::Exclusive ); assert_eq!( - result.root.metadata.components_status, + result.root.metadata.component_status, StageStatus::NoProblem ); assert_eq!(result.root.children.len(), 2); diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/mod.rs index e2e1a1bc5..d85432e07 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/mod.rs @@ -1,6 +1,7 @@ pub mod binding; pub mod merge; pub mod recognition; +pub mod separation; pub mod ui_design_suggestion; pub mod utils; @@ -10,5 +11,7 @@ pub use merge::MergeDTO; pub(crate) use merge::{merge_ui_impl, merge_ui_impl_with_provider}; pub use recognition::RecognitionDTO; pub(crate) use recognition::{recognize_ui_impl, recognize_ui_impl_with_provider}; +pub(crate) use separation::separate_ui_impl; +pub use separation::{SeparationDTO, SeparationRecoveryDTO}; pub(crate) use ui_design_suggestion::suggest_ui_design_semantic_impl; pub use ui_design_suggestion::UIDesignSuggestionTreeNode; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs index 081db0bb1..97e7ba78c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs @@ -4,6 +4,7 @@ use crate::ui_editor::commands::utils::{ parse_limited_llm_tool_arguments, read_ui_reference_image_data_url, request_ui_editor_llm, strict_json_schema, }; +use crate::ui_editor::component::{Component, NodeComponent}; use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode; use crate::ui_editor::layout::control_layout::ControlLayout; use crate::ui_editor::layout::dimension::UIRect; @@ -11,7 +12,7 @@ use crate::ui_editor::layout::node::{Node as LayoutNode, NodeMetadata, NodeSourc use crate::ui_editor::layout::transform::Transform; use crate::ui_editor::resource::ui_design_image::UIDesignImage; use crate::ui_editor::state::{State, UITree}; -use crate::ui_editor::utils::{NodeId, UIDesignImageId}; +use crate::ui_editor::utils::{random_node_id, NodeId, UIDesignImageId}; use nalgebra::{Point2, Vector2}; use platform_llm::{ LlmFunctionTool, LlmMessage, LlmMessageContentPart, LlmRunRequest, LlmToolChoice, @@ -27,25 +28,31 @@ const MAX_RECOGNITION_TREE_NODES: usize = 512; const MAX_RECOGNITION_TREE_DEPTH: usize = 32; const SYSTEM_PROMPT: &str = r#" -角色: -你是游戏 UI 多图结构识别器。 - 任务: 同时分析同一 UI 系统的全部参考图,建立UI树 用户会给你一些UI截图(它们从属于同一个UI系统)和对应的元数据, 请用给定的工具描述UI结构 识别规则: -* 只识别 UI,不识别场景人物、地形、建筑、光影和背景装饰。 +* 只识别 UI元素. 要区分动态内容, 不要白费力气识别应该由程序生成/绘制的内容.(此类内容应该用一个整体节点+自然语言描述) 除此之外必须完整包含所有元素,结构. * 无法确定类型、层级、关系时,在 UnSure 中写明原因。 * 返回的 trees 必须与输入图片一一对应,每张输入图片只能有一棵树,不能合并多张图片的树。 每棵树的 src_ui_design_image_id 必须等于对应输入图片标注的 id。 +* 每棵树root的 global_pos_x_px、global_pos_y_px、width_px、height_px、local_anchor 仅为占位并会被忽略,给合法值即可. * 每棵树必须使用自己的输入图片原始像素坐标系(0,0 as left top)输出 global_pos_x_px、global_pos_y_px、width_px、height_px; * 为了响应式布局, 我们提供了类似godot的Anchor参数, 可以使用语义化的预设或者可custom的直接操作min max, 请准确地根据父子布局的关系使用 * 面向用户的字段如名称描述等请用中文 * 由于每个截图未必是完整的, 可能是局部的, 每棵树描述清楚每个截图上UI的层次结构即可 * 不同树的共用框架/层次/...请使用使用相同的名称描述. 不同状态/变体名称使用相同的前缀, 用后缀区别 -* 粒度要求: 尽可能细致, 最小单元举例: 进度条的底槽、填充和外框; slider的底槽, dragger等 +* 粒度要求: 尽可能细致, 以可交互,方便程序化控制的最小单位为准. 包括不限于: icon, 进度条的底槽、填充和外框; slider的底槽, dragger等. +* 为每个节点直接返回 component. + 无背景的逻辑容器返回 "PureNode",不要返回 null. + 有背景的容器推荐使用Simple+不锁定宽高比的Image component. + 目前我们只做识别, 不要求图片字体的具体绑定参数. + 文字组件要求: 艺术字等作为图片组件, 其余正常文字要作为单独的节点识别. +* 多行文本只使用一个节点. +* 不鼓励兄弟节点相互重叠. +* 对于面板等容器的背景等, 必须作为父节点的组件, 禁止新增冗余的所谓"背景节点". 例如:对于全局的背景直接作为root节点的图片组件, 禁止另外添加节点 "#; @@ -109,13 +116,14 @@ struct RecognitionNode { description: String, children: Vec, confidence: Confidence, + component: NodeComponent, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)] #[schemars(deny_unknown_fields)] struct RecognitionTree { src_ui_design_image_id: UIDesignImageId, - children: Vec, + root: RecognitionNode, } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)] @@ -143,10 +151,14 @@ fn validate_recognition_response_shape(value: &serde_json::Value) -> Result<(), return Err(format!("识别结果最多包含 {MAX_REFERENCES} 棵界面树")); } for tree in trees { - let children = tree + let root = tree + .get("root") + .and_then(serde_json::Value::as_object) + .ok_or_else(|| "识别树缺少 root 节点".to_string())?; + let children = root .get("children") .and_then(serde_json::Value::as_array) - .ok_or_else(|| "识别树缺少 children 数组".to_string())?; + .ok_or_else(|| "识别树根节点缺少 children 数组".to_string())?; let mut stack = children .iter() .map(|node| (node, 1usize)) @@ -215,11 +227,6 @@ fn anchor_ranges(anchor: &Anchor) -> Result<(Vector2, Vector2), String Ok((min, max)) } -fn random_node_id() -> Result { - NodeId::new(uuid::Uuid::new_v4().simple().to_string()) - .map_err(|error| format!("生成节点 ID 失败:{error}")) -} - fn image_layout_size(image: &UIDesignImage) -> Result, String> { let pixels_per_unit = image.pixels_per_unit.get(); if !pixels_per_unit.is_finite() || pixels_per_unit <= 0.0 { @@ -301,21 +308,18 @@ fn convert_node( .map(|child| convert_node(child, image_id, image, target_rect)) .collect::, _>>()?; Ok(LayoutNode { - id: random_node_id()?, + id: random_node_id(), layout: ControlLayout::with_transform(transform), metadata: NodeMetadata { name: source.name.clone(), description: source.description.clone(), layout_status: status, - components_status: StageStatus::NoProblem, + component_status: StageStatus::NoProblem, allow_llm_edit_layout: true, allow_llm_edit_component: true, source: NodeSource::Llm, }, - // V1 有意把识别结果限定为“结构草稿”:组件绑定属于后续独立阶段。 - // 因此空组件不是丢失数据,而是等待 visual-binding 阶段补齐 Image/Text。 - // 约定见 docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md。 - components: Vec::new(), + component: source.component.clone().into_option(), children_display_mode: ChildrenDisplayMode::Stack, children, }) @@ -323,6 +327,26 @@ fn convert_node( fn validate_confidence(nodes: &[RecognitionNode]) -> Result<(), String> { for node in nodes { + if let NodeComponent::WithComponent(component) = &node.component { + if matches!( + component, + Component::Image(crate::ui_editor::component::image::ImageComponent { + target_graphic: Some(_), + .. + }) + ) { + return Err("识别阶段不能返回已绑定的 SpriteAssetId".to_string()); + } + if matches!( + component, + Component::Text(crate::ui_editor::component::text::TextComponent { + font: crate::ui_editor::component::text::FontSource::Bound(_), + .. + }) + ) { + return Err("识别阶段不能返回已绑定的字体素材".to_string()); + } + } if let Confidence::UnSure(reason) = &node.confidence { if reason.trim().is_empty() { return Err("UnSure 必须包含审阅原因".to_string()); @@ -346,7 +370,7 @@ fn validate_tree_image_ids( if !seen.insert(tree.src_ui_design_image_id.clone()) { return Err("LLM 为同一界面图返回了重复 UI 树".to_string()); } - validate_confidence(&tree.children)?; + validate_confidence(std::slice::from_ref(&tree.root))?; } if seen.len() != allowed.len() { return Err("LLM 未为当前识别上下文的每张界面图返回 UI 树".to_string()); @@ -387,6 +411,7 @@ mod tests { description: String::new(), children: Vec::new(), confidence: Confidence::Confident, + component: NodeComponent::PureNode, } } @@ -425,8 +450,8 @@ mod tests { .collect::>(); let response = serde_json::json!({ "trees": [ - {"children": leaves.clone()}, - {"children": leaves} + {"root": {"children": leaves.clone()}}, + {"root": {"children": leaves}} ] }); validate_recognition_response_shape(&response) @@ -439,7 +464,7 @@ mod tests { .map(|_| serde_json::json!({"children": []})) .collect::>(); assert!(validate_recognition_response_shape(&serde_json::json!({ - "trees": [{"children": oversized}] + "trees": [{"root": {"children": oversized}}] })) .is_err()); @@ -448,7 +473,7 @@ mod tests { nested = serde_json::json!({"children": [nested]}); } assert!(validate_recognition_response_shape(&serde_json::json!({ - "trees": [{"children": [nested]}] + "trees": [{"root": {"children": [nested]}}] })) .is_err()); } @@ -490,6 +515,31 @@ mod tests { description: String::new(), children: Vec::new(), confidence: Confidence::UnSure(String::new()), + component: NodeComponent::PureNode, + }; + assert!(validate_confidence(&[node]).is_err()); + } + + #[test] + fn recognition_rejects_bound_font_references() { + let mut text = crate::ui_editor::component::text::TextComponent::default(); + text.font = crate::ui_editor::component::text::FontSource::Bound( + crate::ui_editor::utils::FontAssetId::new("font").expect("valid font id"), + ); + let node = RecognitionNode { + global_pos_x_px: 0, + global_pos_y_px: 0, + width_px: 1, + height_px: 1, + local_anchor: Anchor::Preset(PresetAnchor { + horizontal: HorizontalAnchor::Left, + vertical: VerticalAnchor::Top, + }), + name: "文本".to_string(), + description: String::new(), + children: Vec::new(), + confidence: Confidence::Confident, + component: NodeComponent::WithComponent(Component::Text(text)), }; assert!(validate_confidence(&[node]).is_err()); } @@ -506,7 +556,7 @@ mod tests { converted.layout.transform.resolve(&root_rect), UIRect::new(Point2::new(50.0, 25.0), Vector2::new(100.0, 50.0)), ); - assert_eq!(converted.metadata.components_status, StageStatus::NoProblem); + assert_eq!(converted.metadata.component_status, StageStatus::NoProblem); } #[test] @@ -542,7 +592,7 @@ mod tests { let slave = UIDesignImageId::new("slave").expect("valid image id"); let tree = |id: UIDesignImageId| RecognitionTree { src_ui_design_image_id: id, - children: Vec::new(), + root: test_node(), }; assert!(validate_tree_image_ids( @@ -775,24 +825,37 @@ pub(crate) async fn recognize_ui_impl_with_provider( .ok_or_else(|| format!("缺少界面图 {} 的识别树", image_id.as_str()))?; let size = image_layout_size(image)?; let root_rect = UIRect::new(Point2::origin(), size); - let children = tree + // The recognition root is a real UI node. Its pixel geometry and + // anchor are intentionally ignored; the page root always fills + // the design image while the other recognition fields take effect. + let recognition_root = tree.root; + let children = recognition_root .children .iter() .map(|node| convert_node(node, &image_id, image, root_rect)) .collect::, _>>()?; + let root_layout_status = match recognition_root.confidence { + Confidence::Confident => StageStatus::NoProblem, + Confidence::UnSure(reason) => StageStatus::NeedReview(reason), + }; + let root_name = if recognition_root.name.trim().is_empty() { + "页面根节点".to_string() + } else { + recognition_root.name + }; let root = LayoutNode { - id: random_node_id()?, + id: random_node_id(), layout: ControlLayout::with_transform(Transform::stretch()), metadata: NodeMetadata { - name: "页面根节点".to_string(), - description: String::new(), - layout_status: StageStatus::NoProblem, - components_status: StageStatus::NoProblem, + name: root_name, + description: recognition_root.description, + layout_status: root_layout_status, + component_status: StageStatus::NoProblem, allow_llm_edit_layout: true, allow_llm_edit_component: true, - source: NodeSource::System, + source: NodeSource::Llm, }, - components: Vec::new(), + component: recognition_root.component.into_option(), children_display_mode: ChildrenDisplayMode::Stack, children, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/area.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/area.rs new file mode 100644 index 000000000..d861884d8 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/area.rs @@ -0,0 +1,480 @@ +use super::model::BindingArea; +use image::RgbaImage; +use std::time::Instant; + +/// Each edge may move by at most this many pixels from the area returned by +/// the visual model. Keep this policy explicit so changing it is an +/// intentional workflow decision rather than a scattered numeric literal. +pub(crate) const MAX_BINDING_AREA_EDGE_ADJUSTMENT_PX: u32 = 32; + +/// Alpha values below this threshold are treated as transparent for boundary +/// detection. The cropped pixels themselves are preserved unchanged. +pub(crate) const MIN_VISIBLE_ALPHA: u8 = 16; + +/// An edge needs this many consecutive visible pixels to count as supported. +/// The requirement is reduced to the edge length for one-pixel-wide elements. +pub(crate) const MIN_CONSECUTIVE_VISIBLE_EDGE_PIXELS: usize = 2; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct NormalizedBindingArea { + pub(crate) area: BindingArea, + pub(crate) changed: bool, + pub(crate) clamped: bool, + pub(crate) transparent: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum EdgeDirection { + Inward, + Outward, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct Rect { + left: u32, + top: u32, + right: u32, + bottom: u32, +} + +impl Rect { + fn from_area(area: BindingArea) -> Self { + Self { + left: area.global_pos_x_px, + top: area.global_pos_y_px, + right: area.global_pos_x_px + area.width_px, + bottom: area.global_pos_y_px + area.height_px, + } + } + + fn into_area(self) -> BindingArea { + BindingArea { + global_pos_x_px: self.left, + global_pos_y_px: self.top, + width_px: self.right - self.left, + height_px: self.bottom - self.top, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Edge { + Left, + Right, + Top, + Bottom, +} + +impl Edge { + const ALL: [Self; 4] = [Self::Left, Self::Right, Self::Top, Self::Bottom]; +} + +fn pixel_is_visible(alpha: u8) -> bool { + alpha >= MIN_VISIBLE_ALPHA +} + +fn has_consecutive_visible_pixels(alphas: I, required: usize) -> bool +where + I: IntoIterator, +{ + let required = required.max(1); + let mut consecutive = 0usize; + for alpha in alphas { + if pixel_is_visible(alpha) { + consecutive = consecutive.saturating_add(1); + if consecutive >= required { + return true; + } + } else { + consecutive = 0; + } + } + false +} + +fn edge_has_visible_pixel(image: &RgbaImage, rect: Rect, edge: Edge) -> bool { + let edge_length = match edge { + Edge::Left | Edge::Right => rect.bottom - rect.top, + Edge::Top | Edge::Bottom => rect.right - rect.left, + } as usize; + let required = MIN_CONSECUTIVE_VISIBLE_EDGE_PIXELS.max(1).min(edge_length); + match edge { + Edge::Left | Edge::Right => { + let x = if edge == Edge::Left { + rect.left + } else { + rect.right - 1 + }; + has_consecutive_visible_pixels( + (rect.top..rect.bottom).map(|y| image.get_pixel(x, y).0[3]), + required, + ) + } + Edge::Top | Edge::Bottom => { + let y = if edge == Edge::Top { + rect.top + } else { + rect.bottom - 1 + }; + has_consecutive_visible_pixels( + (rect.left..rect.right).map(|x| image.get_pixel(x, y).0[3]), + required, + ) + } + } +} + +fn rect_has_visible_pixel(image: &RgbaImage, rect: Rect) -> bool { + (rect.top..rect.bottom) + .any(|y| (rect.left..rect.right).any(|x| pixel_is_visible(image.get_pixel(x, y).0[3]))) +} + +fn edge_direction(image: &RgbaImage, rect: Rect, edge: Edge) -> EdgeDirection { + if edge_has_visible_pixel(image, rect, edge) { + EdgeDirection::Outward + } else { + EdgeDirection::Inward + } +} + +fn move_edge(rect: &mut Rect, edge: Edge, direction: EdgeDirection) { + match (edge, direction) { + (Edge::Left, EdgeDirection::Inward) => rect.left += 1, + (Edge::Left, EdgeDirection::Outward) => rect.left -= 1, + (Edge::Right, EdgeDirection::Inward) => rect.right -= 1, + (Edge::Right, EdgeDirection::Outward) => rect.right += 1, + (Edge::Top, EdgeDirection::Inward) => rect.top += 1, + (Edge::Top, EdgeDirection::Outward) => rect.top -= 1, + (Edge::Bottom, EdgeDirection::Inward) => rect.bottom -= 1, + (Edge::Bottom, EdgeDirection::Outward) => rect.bottom += 1, + } +} + +fn edge_coordinate(rect: Rect, edge: Edge) -> u32 { + match edge { + Edge::Left => rect.left, + Edge::Right => rect.right, + Edge::Top => rect.top, + Edge::Bottom => rect.bottom, + } +} + +fn edge_displacement(original: Rect, current: Rect, edge: Edge) -> u32 { + edge_coordinate(original, edge).abs_diff(edge_coordinate(current, edge)) +} + +fn edge_adjustment_limit() -> u32 { + MAX_BINDING_AREA_EDGE_ADJUSTMENT_PX +} + +fn reached_adjustment_limit(original: Rect, current: Rect, edge: Edge) -> bool { + edge_displacement(original, current, edge) >= edge_adjustment_limit() +} + +fn can_move_geometrically( + image: &RgbaImage, + current: Rect, + edge: Edge, + direction: EdgeDirection, +) -> bool { + match (edge, direction) { + (Edge::Left, EdgeDirection::Inward) => current.left + 1 < current.right, + (Edge::Left, EdgeDirection::Outward) => current.left > 0, + (Edge::Right, EdgeDirection::Inward) => current.right > current.left + 1, + (Edge::Right, EdgeDirection::Outward) => current.right < image.width(), + (Edge::Top, EdgeDirection::Inward) => current.top + 1 < current.bottom, + (Edge::Top, EdgeDirection::Outward) => current.top > 0, + (Edge::Bottom, EdgeDirection::Inward) => current.bottom > current.top + 1, + (Edge::Bottom, EdgeDirection::Outward) => current.bottom < image.height(), + } +} + +fn next_edge_rect(rect: Rect, edge: Edge, direction: EdgeDirection) -> Option { + let mut next = rect; + match (edge, direction) { + (Edge::Left, EdgeDirection::Inward) if rect.left + 1 < rect.right => next.left += 1, + (Edge::Left, EdgeDirection::Outward) if rect.left > 0 => next.left -= 1, + (Edge::Right, EdgeDirection::Inward) if rect.right > rect.left + 1 => next.right -= 1, + (Edge::Right, EdgeDirection::Outward) => next.right = next.right.checked_add(1)?, + (Edge::Top, EdgeDirection::Inward) if rect.top + 1 < rect.bottom => next.top += 1, + (Edge::Top, EdgeDirection::Outward) if rect.top > 0 => next.top -= 1, + (Edge::Bottom, EdgeDirection::Inward) if rect.bottom > rect.top + 1 => next.bottom -= 1, + (Edge::Bottom, EdgeDirection::Outward) => next.bottom = next.bottom.checked_add(1)?, + _ => return None, + } + Some(next) +} + +fn edge_requires_move(image: &RgbaImage, rect: Rect, edge: Edge, direction: EdgeDirection) -> bool { + match direction { + EdgeDirection::Inward => !edge_has_visible_pixel(image, rect, edge), + EdgeDirection::Outward => { + if !edge_has_visible_pixel(image, rect, edge) { + return false; + } + if !can_move_geometrically(image, rect, edge, direction) { + return true; + } + next_edge_rect(rect, edge, direction) + .is_some_and(|next| edge_has_visible_pixel(image, next, edge)) + } + } +} + +fn apply_edge_step( + image: &RgbaImage, + original: Rect, + current: Rect, + edge: Edge, + direction: EdgeDirection, +) -> (Rect, bool, bool) { + if !edge_requires_move(image, current, edge, direction) { + return (current, false, false); + } + if reached_adjustment_limit(original, current, edge) + || !can_move_geometrically(image, current, edge, direction) + { + return (current, false, true); + } + let mut next = current; + move_edge(&mut next, edge, direction); + (next, true, false) +} + +/// Normalizes a model-provided area using visible pixels on the processed +/// transparent image. Each edge chooses inward/outward direction once from +/// its initial scan and then moves monotonically, so sparse pixels cannot make +/// the boundary oscillate. The four edge steps are calculated from the same +/// rectangle on each round. +pub(crate) fn normalize_binding_area( + image: &RgbaImage, + original_area: BindingArea, +) -> Result { + let started = Instant::now(); + if let Err(error) = original_area.validate_in(image.width(), image.height()) { + app_log!( + "ui_separation.area.timing outcome=error elapsed_us={} rounds=0 image_width={} image_height={} area=({}, {}, {}, {})", + started.elapsed().as_micros(), + image.width(), + image.height(), + original_area.global_pos_x_px, + original_area.global_pos_y_px, + original_area.width_px, + original_area.height_px + ); + return Err(error.to_string()); + } + let original = Rect::from_area(original_area); + let directions = Edge::ALL.map(|edge| edge_direction(image, original, edge)); + let mut current = original; + let mut clamped = false; + let mut active = [true; 4]; + let mut rounds = 0u32; + + // TODO: Replace the deliberately simple pixel-by-pixel scan if real UI + // design sizes show this path to be a measurable bottleneck. + while active.iter().any(|value| *value) { + rounds = rounds.saturating_add(1); + let before = current; + let mut next = current; + let mut moved = [false; 4]; + for (index, edge) in Edge::ALL.into_iter().enumerate() { + if !active[index] { + continue; + } + let (candidate, did_move, reached_limit) = + apply_edge_step(image, original, current, edge, directions[index]); + if reached_limit { + clamped = true; + active[index] = false; + } else if !did_move { + active[index] = false; + } + moved[index] = did_move; + match edge { + Edge::Left => next.left = candidate.left, + Edge::Right => next.right = candidate.right, + Edge::Top => next.top = candidate.top, + Edge::Bottom => next.bottom = candidate.bottom, + } + } + if next.left >= next.right { + clamped = true; + if moved[0] { + active[0] = false; + } + if moved[1] { + active[1] = false; + } + next.left = current.left; + next.right = current.right; + } + if next.top >= next.bottom { + clamped = true; + if moved[2] { + active[2] = false; + } + if moved[3] { + active[3] = false; + } + next.top = current.top; + next.bottom = current.bottom; + } + current = next; + if current == before { + break; + } + } + + let area = current.into_area(); + let normalized = NormalizedBindingArea { + changed: area != original_area, + area, + clamped, + transparent: !rect_has_visible_pixel(image, current), + }; + app_log!( + "ui_separation.area.timing outcome=ok elapsed_us={} rounds={} image_width={} image_height={} area=({}, {}, {}, {}) changed={} clamped={} transparent={}", + started.elapsed().as_micros(), + rounds, + image.width(), + image.height(), + original_area.global_pos_x_px, + original_area.global_pos_y_px, + original_area.width_px, + original_area.height_px, + normalized.changed, + normalized.clamped, + normalized.transparent + ); + Ok(normalized) +} + +#[cfg(test)] +mod tests { + use super::*; + use image::{Rgba, RgbaImage}; + + fn image_with_rect( + width: u32, + height: u32, + left: u32, + top: u32, + right: u32, + bottom: u32, + ) -> RgbaImage { + let mut image = RgbaImage::from_pixel(width, height, Rgba([0, 0, 0, 0])); + for y in top..bottom { + for x in left..right { + image.put_pixel(x, y, Rgba([255, 255, 255, 255])); + } + } + image + } + + fn area(x: u32, y: u32, width: u32, height: u32) -> BindingArea { + BindingArea { + global_pos_x_px: x, + global_pos_y_px: y, + width_px: width, + height_px: height, + } + } + + #[test] + fn shrinks_empty_edges_to_visible_bounds() { + let image = image_with_rect(32, 32, 10, 11, 16, 18); + let result = normalize_binding_area(&image, area(6, 7, 14, 16)).unwrap(); + assert_eq!(result.area, area(10, 11, 6, 7)); + assert!(result.changed); + assert!(!result.clamped); + assert!(!result.transparent); + } + + #[test] + fn expands_visible_edges_to_cover_the_element() { + let image = image_with_rect(32, 32, 10, 11, 16, 18); + let result = normalize_binding_area(&image, area(11, 12, 4, 5)).unwrap(); + assert_eq!(result.area, area(10, 11, 6, 7)); + assert!(result.changed); + assert!(!result.clamped); + assert!(!result.transparent); + } + + #[test] + fn adjusts_each_edge_independently() { + let image = image_with_rect(32, 32, 10, 11, 16, 18); + let result = normalize_binding_area(&image, area(10, 12, 10, 3)).unwrap(); + assert_eq!(result.area, area(10, 11, 6, 7)); + } + + #[test] + fn ignores_low_alpha_halo_while_preserving_visible_bounds() { + let mut image = RgbaImage::from_pixel(16, 16, Rgba([0, 0, 0, 0])); + for y in 6..10 { + for x in 5..9 { + image.put_pixel(x, y, Rgba([255, 255, 255, 255])); + } + } + image.put_pixel(4, 7, Rgba([255, 255, 255, 1])); + image.put_pixel(9, 8, Rgba([255, 255, 255, 8])); + let result = normalize_binding_area(&image, area(4, 5, 6, 6)).unwrap(); + assert_eq!(result.area, area(5, 6, 4, 4)); + } + + #[test] + fn ignores_isolated_visible_edge_pixel() { + let mut image = image_with_rect(16, 16, 4, 4, 6, 8); + image.put_pixel(6, 4, Rgba([255, 255, 255, 255])); + let result = normalize_binding_area(&image, area(4, 4, 2, 4)).unwrap(); + assert_eq!(result.area, area(4, 4, 2, 4)); + } + + #[test] + fn fully_transparent_image_uses_the_same_path() { + let image = RgbaImage::from_pixel(32, 32, Rgba([0, 0, 0, 0])); + let result = normalize_binding_area(&image, area(10, 10, 10, 10)).unwrap(); + assert_eq!(result.area, area(14, 14, 2, 2)); + assert!(result.changed); + assert!(result.transparent); + } + + #[test] + fn caps_each_edge_at_absolute_pixel_limit() { + let image = image_with_rect(128, 128, 0, 0, 128, 128); + let result = normalize_binding_area(&image, area(48, 48, 8, 8)).unwrap(); + assert_eq!(result.area, area(16, 16, 72, 72)); + assert!(result.clamped); + } + + #[test] + fn clamps_expansion_to_image_edges() { + let image = image_with_rect(16, 16, 0, 0, 4, 4); + let result = normalize_binding_area(&image, area(1, 1, 2, 2)).unwrap(); + assert_eq!(result.area, area(0, 0, 4, 4)); + assert!(result.clamped); + } + + #[test] + fn exact_split_at_adjustment_limit_is_not_clamped() { + let image = image_with_rect(16, 16, 4, 4, 8, 8); + let result = normalize_binding_area(&image, area(5, 5, 2, 2)).unwrap(); + assert_eq!(result.area, area(4, 4, 4, 4)); + assert!(!result.clamped); + } + + #[test] + fn one_pixel_area_expands_with_configured_adjustment_limit() { + let image = image_with_rect(8, 8, 2, 2, 5, 5); + let result = normalize_binding_area(&image, area(3, 3, 1, 1)).unwrap(); + assert_eq!(result.area, area(2, 2, 3, 3)); + assert!(!result.clamped); + } + + #[test] + fn rejects_zero_sized_or_out_of_bounds_model_areas() { + let image = RgbaImage::from_pixel(16, 16, Rgba([0, 0, 0, 0])); + assert!(normalize_binding_area(&image, area(0, 0, 0, 1)).is_err()); + assert!(normalize_binding_area(&image, area(15, 15, 2, 2)).is_err()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/image_preprocess.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/image_preprocess.rs new file mode 100644 index 000000000..147a6dce1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/image_preprocess.rs @@ -0,0 +1,123 @@ +use super::area::MIN_VISIBLE_ALPHA; +use base64::Engine as _; +use image::{ImageFormat, ImageReader, Rgba, RgbaImage}; +use std::fs; +use std::io::Cursor; +use std::path::{Path, PathBuf}; + +pub(crate) const VISUAL_BINDING_TRANSPARENT_MARKER_RGBA: [u8; 4] = [255, 0, 255, 255]; +const MAX_PROCESSED_IMAGE_BYTES: usize = 64 * 1024 * 1024; +const MAX_PROCESSED_IMAGE_DIMENSION: u32 = 2880; + +pub(crate) async fn preprocess_for_visual_binding( + processed_url: String, + sidecar: PathBuf, +) -> Result { + tokio::task::spawn_blocking(move || { + preprocess_for_visual_binding_blocking(&processed_url, &sidecar) + }) + .await + .map_err(|error| format!("视觉绑定预处理任务失败:{error}"))? +} + +fn preprocess_for_visual_binding_blocking( + processed_url: &str, + sidecar: &Path, +) -> Result { + let encoded = processed_url + .split_once(',') + .map(|(_, data)| data) + .ok_or_else(|| "处理图 data URL 无效".to_string())?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded.trim()) + .map_err(|error| format!("解析处理图失败:{error}"))?; + if bytes.len() > MAX_PROCESSED_IMAGE_BYTES { + return Err(format!( + "处理图超过 {} MiB 字节上限", + MAX_PROCESSED_IMAGE_BYTES / 1024 / 1024 + )); + } + let dimensions = ImageReader::new(Cursor::new(&bytes)) + .with_guessed_format() + .map_err(|error| format!("解析处理图格式失败:{error}"))? + .into_dimensions() + .map_err(|error| format!("读取处理图尺寸失败:{error}"))?; + if dimensions.0 > MAX_PROCESSED_IMAGE_DIMENSION || dimensions.1 > MAX_PROCESSED_IMAGE_DIMENSION + { + return Err("处理图尺寸超出上限".to_string()); + } + let mut image = image::load_from_memory(&bytes) + .map_err(|error| format!("解码处理图失败:{error}"))? + .to_rgba8(); + for pixel in image.pixels_mut() { + if pixel.0[3] < MIN_VISIBLE_ALPHA { + *pixel = Rgba(VISUAL_BINDING_TRANSPARENT_MARKER_RGBA); + } else { + pixel.0[3] = 255; + } + } + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut Cursor::new(&mut png), ImageFormat::Png) + .map_err(|error| format!("编码视觉绑定预览失败:{error}"))?; + let debug_name = format!("binding-{}.png", uuid::Uuid::new_v4().simple()); + if let Err(error) = fs::write(sidecar.join(&debug_name), &png) { + app_log!( + "ui_separation.warning stage=visual_binding_preview_write file={} error={error}", + debug_name + ); + } + Ok(format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(png) + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use image::Rgba; + use tempfile::tempdir; + + fn data_url(image: RgbaImage) -> String { + let mut bytes = Vec::new(); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png) + .expect("encode fixture"); + format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + ) + } + + #[test] + fn preprocesses_alpha_using_existing_visibility_threshold() { + let mut image = RgbaImage::from_pixel(4, 1, Rgba([10, 20, 30, 255])); + image.put_pixel(0, 0, Rgba([1, 2, 3, 0])); + image.put_pixel(1, 0, Rgba([4, 5, 6, MIN_VISIBLE_ALPHA - 1])); + image.put_pixel(2, 0, Rgba([7, 8, 9, MIN_VISIBLE_ALPHA])); + image.put_pixel(3, 0, Rgba([11, 12, 13, 254])); + let directory = tempdir().expect("create sidecar fixture"); + + let url = preprocess_for_visual_binding_blocking(&data_url(image), directory.path()) + .expect("preprocess fixture"); + let encoded = url.split_once(',').expect("data URL").1; + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .expect("decode output"); + let output = image::load_from_memory(&bytes) + .expect("decode output png") + .to_rgba8(); + + assert_eq!( + output.get_pixel(0, 0).0, + VISUAL_BINDING_TRANSPARENT_MARKER_RGBA + ); + assert_eq!( + output.get_pixel(1, 0).0, + VISUAL_BINDING_TRANSPARENT_MARKER_RGBA + ); + assert_eq!(output.get_pixel(2, 0).0, [7, 8, 9, 255]); + assert_eq!(output.get_pixel(3, 0).0, [11, 12, 13, 255]); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs new file mode 100644 index 000000000..5bdf90008 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/mod.rs @@ -0,0 +1,550 @@ +mod area; +pub(crate) mod image_preprocess; +mod model; +mod persistence; +mod prompt; +mod tree; +mod workflow; + +pub use model::*; +pub use persistence::*; +pub use tree::*; +pub use workflow::apply_batch_patch; +pub use workflow::batch::{image_edit_dimension_for_area, next_image_batch}; +pub(crate) use workflow::separate_ui_impl; +#[cfg(test)] +mod tests { + use super::*; + use crate::ui_editor::component::image::{ImageComponent, ImageType}; + use crate::ui_editor::component::text::TextComponent; + use crate::ui_editor::component::Component; + use crate::ui_editor::layout::children_display_mode::ChildrenDisplayMode; + use crate::ui_editor::layout::control_layout::ControlLayout; + use crate::ui_editor::layout::node::Node; + use crate::ui_editor::layout::node::{NodeMetadata, NodeSource, StageStatus}; + use crate::ui_editor::resource::ui_design_image::UIDesignImage; + use crate::ui_editor::state::{State, UITree}; + use crate::ui_editor::utils::{NodeId, UIDesignImageId}; + use nalgebra::Vector2; + use std::collections::HashMap; + use std::path::Path; + use typed_floats::tf32::StrictlyPositiveFinite; + + fn node(id: &str, component: Option, children: Vec) -> Node { + Node { + id: NodeId::new(id).unwrap(), + layout: ControlLayout::default(), + metadata: NodeMetadata { + name: id.to_string(), + description: String::new(), + layout_status: StageStatus::NoProblem, + component_status: StageStatus::NoProblem, + allow_llm_edit_layout: true, + allow_llm_edit_component: true, + source: NodeSource::Llm, + }, + component, + children_display_mode: ChildrenDisplayMode::Stack, + children, + } + } + fn state(root: Node) -> State { + let image_id = UIDesignImageId::new("page").unwrap(); + State { + ui_trees: vec![UITree { + src_ui_design: image_id.clone(), + root, + }], + ui_design_images: HashMap::from([( + image_id, + UIDesignImage { + metadata: crate::ui_editor::resource::ui_design_image::UIDesignImageMetadata { + name: "page".to_string(), + description: String::new(), + role: None, + slave_to: None, + }, + path: "page.png".to_string(), + pixel_size: Vector2::new(100.0, 100.0), + pixels_per_unit: StrictlyPositiveFinite::new(1.0).unwrap(), + }, + )]), + sprite_assets: HashMap::new(), + font_assets: HashMap::new(), + } + } + #[test] + fn construction_filters_pure_nodes_and_passes_children_through() { + let image = Component::Image(ImageComponent { + target_graphic: None, + image_type: ImageType::Simple { + preserve_aspect: false, + }, + }); + let root = node( + "root", + None, + vec![node( + "container", + None, + vec![node("image", Some(image), vec![])], + )], + ); + let result = construct_separation_state(&state(root)); + assert_eq!(result.trees[0].root.children[0].id.as_str(), "image"); + } + + #[test] + fn construction_keeps_real_root_for_root_image() { + let image = Component::Image(ImageComponent { + target_graphic: None, + image_type: ImageType::Simple { + preserve_aspect: false, + }, + }); + let root = node("root-image", Some(image), vec![]); + let result = construct_separation_state(&state(root)); + let tree = &result.trees[0]; + assert_eq!(tree.root.id.as_str(), "root-image"); + assert!(tree.root_extractable); + } + + #[test] + fn construction_keeps_text_as_removal_only_context() { + let image = Component::Image(ImageComponent { + target_graphic: None, + image_type: ImageType::Simple { + preserve_aspect: false, + }, + }); + let text = Component::Text(TextComponent::new("按钮")); + let root = node( + "root", + None, + vec![node( + "outer-image", + Some(image.clone()), + vec![node("text", Some(text.clone()), vec![])], + )], + ); + let result = construct_separation_state(&state(root)); + let outer = &result.trees[0].root.children[0]; + assert_eq!(outer.id.as_str(), "outer-image"); + assert_eq!(outer.kind, SeparationNodeKind::ImageTarget); + assert_eq!(outer.children[0].kind, SeparationNodeKind::TextRemovalOnly); + + let nested_root = node( + "root", + None, + vec![node( + "outer-image", + Some(image.clone()), + vec![node( + "inner-image", + Some(image), + vec![node("text", Some(text), vec![])], + )], + )], + ); + let nested = construct_separation_state(&state(nested_root)); + let inner = &nested.trees[0].root.children[0].children[0]; + assert_eq!(inner.kind, SeparationNodeKind::ImageTarget); + assert_eq!(inner.children[0].kind, SeparationNodeKind::TextRemovalOnly); + } + + #[test] + fn root_image_keeps_text_as_removal_only_context() { + let root = node( + "root-image", + Some(Component::Image(ImageComponent { + target_graphic: None, + image_type: ImageType::Simple { + preserve_aspect: false, + }, + })), + vec![node( + "text", + Some(Component::Text(TextComponent::new("标题"))), + vec![], + )], + ); + let result = construct_separation_state(&state(root)); + assert_eq!( + result.trees[0].root.children[0].kind, + SeparationNodeKind::TextRemovalOnly + ); + } + #[test] + fn binding_validation_requires_exact_batch_coverage() { + let node = SeparationNode { + id: NodeId::new("image").unwrap(), + kind: SeparationNodeKind::ImageTarget, + global_pos_x_px: 0, + global_pos_y_px: 0, + width_px: 1, + height_px: 1, + note: SeparationNote { + description: "image".to_string(), + rework_notes: Vec::new(), + }, + children: vec![], + rework_count: 0, + }; + assert!( + validate_binding_response(&BindingResp { decisions: vec![] }, &[&node], (1, 1)) + .is_err() + ); + } + + #[test] + fn binding_validation_rejects_area_outside_processed_image() { + let node = SeparationNode { + id: NodeId::new("image").unwrap(), + kind: SeparationNodeKind::ImageTarget, + global_pos_x_px: 0, + global_pos_y_px: 0, + width_px: 1, + height_px: 1, + note: SeparationNote::default(), + children: vec![], + rework_count: 0, + }; + let response = BindingResp { + decisions: vec![BindingDecision::Ok { + to_node: node.id.clone(), + extracted_area: BindingArea { + global_pos_x_px: 1, + global_pos_y_px: 0, + width_px: 1, + height_px: 1, + }, + }], + }; + let error = validate_binding_response(&response, &[&node], (1, 1)).unwrap_err(); + assert!(error.contains("超出处理图边界")); + } + + #[test] + fn separation_note_prompt_keeps_rework_notes_in_order() { + let without_notes = SeparationNote { + description: "按钮".to_string(), + rework_notes: Vec::new(), + }; + assert_eq!(without_notes.as_prompt(), "desc: 按钮"); + + let with_notes = SeparationNote { + description: "按钮".to_string(), + rework_notes: vec!["保留圆角".to_string(), "去掉阴影".to_string()], + }; + assert_eq!( + with_notes.as_prompt(), + "desc: 按钮\nprevious rework notes:\n- 保留圆角\n- 去掉阴影" + ); + } + + #[test] + fn need_rework_appends_note_and_final_attempt_becomes_problematic() { + let image = Component::Image(ImageComponent { + target_graphic: None, + image_type: ImageType::Simple { + preserve_aspect: false, + }, + }); + let mut separation = construct_separation_state(&state(node( + "root", + None, + vec![node("image", Some(image), vec![])], + ))); + let id = NodeId::new("image").unwrap(); + let paths = HashMap::new(); + + for note in ["第一次意见", "第二次意见", "最后一次意见"] { + let batch_nodes = next_image_batch(&separation, &separation.trees[0]) + .into_iter() + .cloned() + .collect::>(); + apply_batch_patch( + &mut separation, + 0, + &batch_nodes, + &[BindingDecision::NeedRework { + to_node: id.clone(), + advice: note.to_string(), + }], + &paths, + (1, 1), + ) + .unwrap(); + } + + let node = &separation.trees[0].root.children[0]; + assert_eq!( + node.note.rework_notes, + ["第一次意见", "第二次意见", "最后一次意见"] + ); + assert_eq!(separation.problematic_nodes.len(), 1); + assert_eq!( + separation.problematic_nodes[0].rework_count, + MAX_REWORK_COUNT + ); + assert_eq!( + separation.problematic_nodes[0].problem_history, + vec!["第一次意见", "第二次意见", "最后一次意见"] + ); + assert_eq!(node.rework_count, MAX_REWORK_COUNT); + assert!(next_image_batch(&separation, &separation.trees[0]).is_empty()); + } + + #[test] + fn binding_validation_rejects_overlong_rework_note() { + let node = SeparationNode { + id: NodeId::new("image").unwrap(), + kind: SeparationNodeKind::ImageTarget, + global_pos_x_px: 0, + global_pos_y_px: 0, + width_px: 1, + height_px: 1, + note: SeparationNote::default(), + children: vec![], + rework_count: 0, + }; + let decision = BindingDecision::NeedRework { + to_node: node.id.clone(), + advice: "x".repeat(MAX_REWORK_NOTE_CHARS + 1), + }; + assert!(validate_binding_response( + &BindingResp { + decisions: vec![decision] + }, + &[&node], + (1, 1) + ) + .is_err()); + } + #[test] + fn sidecar_name_uses_asset_id_digest() { + let dir = separation_sidecar_dir(Path::new("/tmp/project"), "ui:1").unwrap(); + assert!(dir.to_string_lossy().contains("ui_1-")); + assert!(dir.to_string_lossy().ends_with("-separation")); + } + + #[test] + fn patch_collects_bound_and_keeps_tree_topology() { + let image = Component::Image(ImageComponent { + target_graphic: None, + image_type: ImageType::Simple { + preserve_aspect: false, + }, + }); + let mut state = construct_separation_state(&state(node( + "root", + None, + vec![node("image", Some(image), vec![])], + ))); + let id = NodeId::new("image").unwrap(); + let decisions = vec![BindingDecision::Ok { + to_node: id.clone(), + extracted_area: BindingArea { + global_pos_x_px: 0, + global_pos_y_px: 0, + width_px: 1, + height_px: 1, + }, + }]; + let paths = HashMap::from([(id.clone(), "ui/.sidecar/cut.png".to_string())]); + let batch_nodes = next_image_batch(&state, &state.trees[0]) + .into_iter() + .cloned() + .collect::>(); + apply_batch_patch(&mut state, 0, &batch_nodes, &decisions, &paths, (1, 1)).unwrap(); + assert_eq!(state.bound[0].node_id, id); + assert_eq!(state.trees[0].root.children.len(), 1); + } + + #[test] + fn batch_selection_keeps_dfs_order_even_when_rectangles_overlap() { + let a = SeparationNode { + id: NodeId::new("a").unwrap(), + kind: SeparationNodeKind::ImageTarget, + global_pos_x_px: 0, + global_pos_y_px: 0, + width_px: 10, + height_px: 10, + note: SeparationNote::default(), + children: vec![], + rework_count: 0, + }; + let b = SeparationNode { + id: NodeId::new("b").unwrap(), + kind: SeparationNodeKind::ImageTarget, + global_pos_x_px: 5, + global_pos_y_px: 5, + width_px: 10, + height_px: 10, + note: SeparationNote::default(), + children: vec![], + rework_count: 0, + }; + let c = SeparationNode { + id: NodeId::new("c").unwrap(), + kind: SeparationNodeKind::ImageTarget, + global_pos_x_px: 20, + global_pos_y_px: 0, + width_px: 5, + height_px: 5, + note: SeparationNote::default(), + children: vec![], + rework_count: 0, + }; + let tree = SeparationTree { + src_ui_design: UIDesignImageId::new("page").unwrap(), + root: SeparationNode { + id: NodeId::new("root").unwrap(), + kind: SeparationNodeKind::PureContainer, + global_pos_x_px: 0, + global_pos_y_px: 0, + width_px: 100, + height_px: 100, + note: SeparationNote::default(), + children: vec![a, b, c], + rework_count: 0, + }, + root_extractable: false, + }; + let state = SeparationState { + schema_version: SEPARATION_STATE_SCHEMA_VERSION.to_string(), + trees: vec![tree.clone()], + bound: vec![], + problematic_nodes: vec![], + }; + let batch = next_image_batch(&state, &tree); + assert_eq!( + batch + .iter() + .map(|node| node.id.as_str()) + .collect::>(), + vec!["a", "b", "c"] + ); + } + + #[test] + fn batch_selection_skips_text_removal_only_nodes() { + let image = Component::Image(ImageComponent { + target_graphic: None, + image_type: ImageType::Simple { + preserve_aspect: false, + }, + }); + let text = Component::Text(TextComponent::new("标题")); + let separation = construct_separation_state(&state(node( + "root", + None, + vec![ + node("text", Some(text), vec![]), + node("image", Some(image), vec![]), + ], + ))); + + let batch = next_image_batch(&separation, &separation.trees[0]); + assert_eq!( + batch + .iter() + .map(|node| node.id.as_str()) + .collect::>(), + vec!["image"] + ); + } + + #[test] + fn batch_selection_includes_extractable_root_before_children() { + let image = Component::Image(ImageComponent { + target_graphic: None, + image_type: ImageType::Simple { + preserve_aspect: false, + }, + }); + let separation = construct_separation_state(&state(node( + "root-image", + Some(image.clone()), + vec![node("child-image", Some(image), vec![])], + ))); + + let batch = next_image_batch(&separation, &separation.trees[0]); + assert_eq!( + batch + .iter() + .map(|node| node.id.as_str()) + .collect::>(), + vec!["root-image", "child-image"] + ); + } + + fn image_separation_node(id: &str, width_px: u32, height_px: u32) -> SeparationNode { + SeparationNode { + id: NodeId::new(id).unwrap(), + kind: SeparationNodeKind::ImageTarget, + global_pos_x_px: 0, + global_pos_y_px: 0, + width_px, + height_px, + note: SeparationNote::default(), + children: vec![], + rework_count: 0, + } + } + + fn separation_with_children( + children: Vec, + ) -> (SeparationState, SeparationTree) { + let tree = SeparationTree { + src_ui_design: UIDesignImageId::new("page").unwrap(), + root: SeparationNode { + id: NodeId::new("root").unwrap(), + kind: SeparationNodeKind::PureContainer, + global_pos_x_px: 0, + global_pos_y_px: 0, + width_px: 100, + height_px: 100, + note: SeparationNote::default(), + children, + rework_count: 0, + }, + root_extractable: false, + }; + let separation = SeparationState { + schema_version: SEPARATION_STATE_SCHEMA_VERSION.to_string(), + trees: vec![tree.clone()], + bound: vec![], + problematic_nodes: vec![], + }; + (separation, tree) + } + + #[test] + fn batch_selection_stops_before_second_node_that_exceeds_area_budget() { + let first_area = IMAGE_EDIT_AREA_LIMIT_PX / 2 + 1; + let first = image_separation_node("first", first_area as u32, 1); + let second = image_separation_node("second", first_area as u32, 1); + let (separation, tree) = separation_with_children(vec![first, second]); + + let batch = next_image_batch(&separation, &tree); + assert_eq!( + batch + .iter() + .map(|node| node.id.as_str()) + .collect::>(), + vec!["first"] + ); + } + + #[test] + fn batch_selection_accepts_first_oversized_node_to_guarantee_progress() { + let oversized = + image_separation_node("oversized", (IMAGE_EDIT_AREA_LIMIT_PX + 1) as u32, 1); + let (separation, tree) = separation_with_children(vec![oversized]); + + let batch = next_image_batch(&separation, &tree); + assert_eq!(batch.len(), 1); + assert_eq!(batch[0].id.as_str(), "oversized"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/binding.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/binding.rs new file mode 100644 index 000000000..e527c7613 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/binding.rs @@ -0,0 +1,97 @@ +use crate::ui_editor::utils::NodeId; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BindingAreaValidationError { + ZeroDimension, + OutOfBounds, +} + +impl std::fmt::Display for BindingAreaValidationError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::ZeroDimension => "BindingArea 宽度和高度必须大于 0", + Self::OutOfBounds => "BindingArea 超出处理图边界", + }) + } +} + +impl std::error::Error for BindingAreaValidationError {} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct BindingArea { + pub global_pos_x_px: u32, + pub global_pos_y_px: u32, + pub width_px: u32, + pub height_px: u32, +} + +impl BindingArea { + pub fn validate_in(&self, w: u32, h: u32) -> Result<(), BindingAreaValidationError> { + if self.width_px == 0 || self.height_px == 0 { + return Err(BindingAreaValidationError::ZeroDimension); + } + if self + .global_pos_x_px + .checked_add(self.width_px) + .is_none_or(|v| v > w) + || self + .global_pos_y_px + .checked_add(self.height_px) + .is_none_or(|v| v > h) + { + return Err(BindingAreaValidationError::OutOfBounds); + } + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub enum BindingDecision { + Ok { + extracted_area: BindingArea, + to_node: NodeId, + }, + NeedRework { + advice: String, + to_node: NodeId, + }, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, JsonSchema)] +pub struct BindingResp { + pub decisions: Vec, +} + +#[cfg(test)] +mod tests { + use super::{BindingArea, BindingAreaValidationError}; + + #[test] + fn validates_binding_area_with_typed_errors() { + let zero = BindingArea { + global_pos_x_px: 0, + global_pos_y_px: 0, + width_px: 0, + height_px: 1, + }; + assert_eq!( + zero.validate_in(10, 10), + Err(BindingAreaValidationError::ZeroDimension) + ); + + let outside = BindingArea { + global_pos_x_px: 10, + global_pos_y_px: 0, + width_px: 1, + height_px: 1, + }; + assert_eq!( + outside.validate_in(10, 10), + Err(BindingAreaValidationError::OutOfBounds) + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/mod.rs new file mode 100644 index 000000000..74a2c773b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/mod.rs @@ -0,0 +1,20 @@ +mod binding; +mod node; +mod note; +mod result; + +pub use binding::*; +pub use node::*; +pub use note::*; +pub use result::*; + +pub const SEPARATION_STATE_SCHEMA_VERSION: &str = "ui-editor-separation-state.v2"; +pub const MAX_REWORK_COUNT: u32 = 3; +pub const MAX_REWORK_NOTE_CHARS: usize = 512; +pub const IMAGE_EDIT_MAX_DIMENSION_PX: u64 = 2880; +pub const IMAGE_EDIT_MIN_DIMENSION_PX: u64 = 816; +pub const IMAGE_EDIT_DIMENSION_ALIGNMENT_PX: u64 = 16; +pub const IMAGE_EDIT_AREA_UTILIZATION_PERCENT: u64 = 80; +pub const IMAGE_EDIT_AREA_LIMIT_PX: u64 = + IMAGE_EDIT_MAX_DIMENSION_PX * IMAGE_EDIT_MAX_DIMENSION_PX * IMAGE_EDIT_AREA_UTILIZATION_PERCENT + / 100; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/node.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/node.rs new file mode 100644 index 000000000..d925b7e36 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/node.rs @@ -0,0 +1,43 @@ +use crate::ui_editor::utils::{NodeId, UIDesignImageId}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub enum SeparationNodeKind { + ImageTarget, + TextRemovalOnly, + PureContainer, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct SeparationNode { + pub id: NodeId, + pub kind: SeparationNodeKind, + pub global_pos_x_px: u32, + pub global_pos_y_px: u32, + pub width_px: u32, + pub height_px: u32, + pub note: super::SeparationNote, + pub children: Vec, + pub rework_count: u32, +} + +impl SeparationNode { + pub fn as_prompt(&self) -> String { + format!( + "node_id={} note: {}", + self.id.as_str(), + self.note.as_prompt() + ) + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct SeparationTree { + pub src_ui_design: UIDesignImageId, + pub root: SeparationNode, + pub root_extractable: bool, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/note.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/note.rs new file mode 100644 index 000000000..a044254b1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/note.rs @@ -0,0 +1,45 @@ +use super::MAX_REWORK_NOTE_CHARS; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +pub const MAX_NOTE_DESCRIPTION_CHARS: usize = 1024; + +pub fn sanitize_prompt_text(value: &str, max_chars: usize) -> String { + value + .chars() + .filter_map(|character| { + if character.is_control() { + Some(' ') + } else if character == '`' { + Some(''') + } else { + Some(character) + } + }) + .take(max_chars) + .collect() +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct SeparationNote { + pub description: String, + pub rework_notes: Vec, +} + +impl SeparationNote { + pub fn as_prompt(&self) -> String { + let mut prompt = format!( + "desc: {}", + sanitize_prompt_text(&self.description, MAX_NOTE_DESCRIPTION_CHARS) + ); + if !self.rework_notes.is_empty() { + prompt.push_str("\nprevious rework notes:"); + for note in &self.rework_notes { + prompt.push_str("\n- "); + prompt.push_str(&sanitize_prompt_text(note, MAX_REWORK_NOTE_CHARS)); + } + } + prompt + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/result.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/result.rs new file mode 100644 index 000000000..8dbb38383 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/model/result.rs @@ -0,0 +1,45 @@ +use crate::ui_editor::utils::NodeId; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct BoundNode { + pub node_id: NodeId, + pub cut_image_path: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct ProblematicNode { + pub node_id: NodeId, + pub problem_description: String, + #[serde(default)] + pub problem_history: Vec, + pub rework_count: u32, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct SeparationState { + pub schema_version: String, + pub trees: Vec, + pub bound: Vec, + pub problematic_nodes: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct SeparationDTO { + pub bound_nodes: Vec, + pub problematic_nodes: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub struct SeparationRecoveryDTO { + pub exists: bool, + pub bound_node_count: usize, + pub problematic_node_count: usize, + pub has_pending_tree: bool, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/persistence.rs new file mode 100644 index 000000000..ee383d9c1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/persistence.rs @@ -0,0 +1,329 @@ +use super::model::*; +use crate::ui_editor::commands::separation::*; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use uuid::Uuid; + +const SEPARATION_STATE_MAX_BYTES: usize = 8 * 1024 * 1024; +pub fn separation_sidecar_dir(root: &Path, asset_id: &str) -> Result { + if asset_id.trim().is_empty() || asset_id.trim() != asset_id { + app_log!("ui_separation.error stage=sidecar_dir reason=invalid_asset_id"); + return Err("UI 资源 ID 无效".to_string()); + } + let dir = root.join("ui").join(format!( + ".{}-separation", + crate::ui_editor::persistence::generated_file_stem(asset_id) + )); + if !dir.starts_with(root) { + app_log!("ui_separation.error stage=sidecar_dir reason=path_escape"); + return Err("separation sidecar 路径越界".to_string()); + } + app_log!( + "ui_separation.sidecar_resolved asset_id={} directory={}", + asset_id, + dir.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("") + ); + Ok(dir) +} + +pub fn separation_state_path(root: &Path, asset_id: &str) -> Result { + Ok(separation_sidecar_dir(root, asset_id)?.join("state.json")) +} + +pub fn project_relative_path(root: &Path, path: &Path) -> Result { + let relative = path + .strip_prefix(root) + .map_err(|_| "separation 产物必须位于项目目录内".to_string())?; + let value = relative.to_string_lossy().replace('\\', "/"); + if value.is_empty() || value.starts_with('/') || value.split('/').any(|part| part == "..") { + return Err("separation 产物相对路径无效".to_string()); + } + Ok(value) +} + +pub async fn write_separation_state(path: PathBuf, state: &SeparationState) -> Result<(), String> { + if state.schema_version != SEPARATION_STATE_SCHEMA_VERSION { + app_log!("ui_separation.error stage=state_write reason=schema_mismatch"); + return Err("不支持的 separation state schema".to_string()); + } + let owned = state.clone(); + tokio::task::spawn_blocking(move || { + let bytes = serde_json::to_vec_pretty(&owned) + .map_err(|error| format!("序列化 separation state 失败:{error}"))?; + write_separation_state_blocking(&path, &bytes) + }) + .await + .map_err(|error| format!("写入 separation state 任务失败:{error}"))? +} + +fn write_separation_state_blocking(path: &Path, bytes: &[u8]) -> Result<(), String> { + if bytes.len() > SEPARATION_STATE_MAX_BYTES { + app_log!( + "ui_separation.error stage=state_write reason=too_large bytes={} max_bytes={}", + bytes.len(), + SEPARATION_STATE_MAX_BYTES + ); + return Err(format!( + "separation state 超过 {} 字节上限", + SEPARATION_STATE_MAX_BYTES + )); + } + app_log!( + "ui_separation.state_write.start file={} bytes={}", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""), + bytes.len() + ); + let parent = path.parent().ok_or_else(|| { + app_log!("ui_separation.error stage=state_write reason=missing_parent"); + "separation state 路径缺少父目录".to_string() + })?; + fs::create_dir_all(parent).map_err(|error| { + app_log!("ui_separation.error stage=state_write reason=create_parent error={error}"); + format!("创建 separation sidecar 失败:{error}") + })?; + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + app_log!("ui_separation.error stage=state_write reason=unsafe_target"); + return Err("separation state 目标必须是普通文件".to_string()); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + app_log!("ui_separation.error stage=state_write reason=target_metadata error={error}"); + return Err(format!("检查 separation state 目标失败:{error}")); + } + } + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("state.json"); + let temporary = parent.join(format!(".{file_name}.tmp.{}", Uuid::new_v4())); + let mut temporary_file = OpenOptions::new(); + temporary_file.write(true).create_new(true); + let mut file = temporary_file.open(&temporary).map_err(|error| { + app_log!("ui_separation.error stage=state_write reason=write_temp error={error}"); + format!("写入 separation state 失败:{error}") + })?; + if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_data()) { + let _ = fs::remove_file(&temporary); + app_log!("ui_separation.error stage=state_write reason=write_temp error={error}"); + return Err(format!("写入 separation state 失败:{error}")); + } + drop(file); + if let Err(error) = replace_separation_state_atomically(&temporary, path) { + let _ = fs::remove_file(&temporary); + app_log!("ui_separation.error stage=state_write reason=install error={error}"); + return Err(format!("安装 separation state 失败:{error}")); + } + app_log!( + "ui_separation.state_write.completed file={} bytes={}", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""), + fs::metadata(path) + .map(|metadata| metadata.len()) + .unwrap_or(0) + ); + Ok(()) +} + +#[cfg(not(windows))] +fn replace_separation_state_atomically(temporary: &Path, target: &Path) -> std::io::Result<()> { + fs::rename(temporary, target) +} + +#[cfg(windows)] +fn replace_separation_state_atomically(temporary: &Path, target: &Path) -> std::io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let source = temporary + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let destination = target + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + // SAFETY: both buffers are owned, UTF-16 encoded, and NUL-terminated; they + // remain alive for the duration of the call, which only reads the paths. + let moved = unsafe { + MoveFileExW( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if moved == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } +} + +pub fn read_separation_state(path: &Path) -> Result { + app_log!( + "ui_separation.state_read.start file={}", + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("") + ); + let metadata = fs::metadata(path).map_err(|error| { + app_log!("ui_separation.error stage=state_read reason=metadata error={error}"); + format!("读取 separation state 信息失败:{error}") + })?; + if metadata.len() > SEPARATION_STATE_MAX_BYTES as u64 { + app_log!( + "ui_separation.error stage=state_read reason=too_large bytes={} max_bytes={}", + metadata.len(), + SEPARATION_STATE_MAX_BYTES + ); + return Err(format!( + "separation state 超过 {} 字节上限", + SEPARATION_STATE_MAX_BYTES + )); + } + let bytes = fs::read(path).map_err(|error| { + app_log!("ui_separation.error stage=state_read reason=read error={error}"); + format!("读取 separation state 失败:{error}") + })?; + if bytes.len() > SEPARATION_STATE_MAX_BYTES { + return Err(format!( + "separation state 超过 {} 字节上限", + SEPARATION_STATE_MAX_BYTES + )); + } + let state: SeparationState = serde_json::from_slice(&bytes).map_err(|error| { + app_log!("ui_separation.error stage=state_read reason=parse error={error}"); + format!("解析 separation state 失败:{error}") + })?; + if state.schema_version != SEPARATION_STATE_SCHEMA_VERSION { + app_log!("ui_separation.error stage=state_read reason=schema_mismatch"); + return Err("不支持的 separation state schema".to_string()); + } + app_log!( + "ui_separation.state_read.completed bytes={} trees={} bound={} problematic={}", + bytes.len(), + state.trees.len(), + state.bound.len(), + state.problematic_nodes.len() + ); + Ok(state) +} + +pub async fn read_separation_state_async(path: PathBuf) -> Result { + tokio::task::spawn_blocking(move || read_separation_state(&path)) + .await + .map_err(|error| format!("读取 separation state 任务失败:{error}"))? +} + +pub fn separation_dto(state: &SeparationState) -> SeparationDTO { + app_log!( + "ui_separation.dto bound_nodes={} problematic_nodes={} remaining_trees={}", + state.bound.len(), + state.problematic_nodes.len(), + state.trees.len() + ); + SeparationDTO { + bound_nodes: state.bound.clone(), + problematic_nodes: state.problematic_nodes.clone(), + } +} + +pub fn inspect_separation_recovery( + root: &Path, + asset_id: &str, +) -> Result { + let state_path = separation_state_path(root, asset_id)?; + if !state_path.exists() { + return Ok(SeparationRecoveryDTO { + exists: false, + bound_node_count: 0, + problematic_node_count: 0, + has_pending_tree: false, + }); + } + let state = read_separation_state(&state_path)?; + Ok(SeparationRecoveryDTO { + exists: true, + bound_node_count: state.bound.len(), + problematic_node_count: state.problematic_nodes.len(), + has_pending_tree: state + .trees + .iter() + .any(|tree| !tree.root.children.is_empty() || tree.root_extractable), + }) +} + +pub fn finalize_separation(root: &Path, asset_id: &str) -> Result<(), String> { + remove_separation_recovery_files(root, asset_id) +} + +pub fn discard_separation_recovery(root: &Path, asset_id: &str) -> Result<(), String> { + remove_separation_recovery_files(root, asset_id) +} + +fn remove_separation_state(root: &Path, asset_id: &str) -> Result<(), String> { + let state_path = separation_state_path(root, asset_id)?; + match fs::remove_file(&state_path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("删除 separation state 失败:{error}")), + } +} + +fn remove_separation_recovery_files(root: &Path, asset_id: &str) -> Result<(), String> { + let sidecar = separation_sidecar_dir(root, asset_id)?; + remove_separation_state(root, asset_id)?; + let entries = match fs::read_dir(&sidecar) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(format!("读取 separation 临时文件失败:{error}")), + }; + for entry in entries { + let entry = entry.map_err(|error| format!("读取 separation 临时文件失败:{error}"))?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.starts_with("processed-") || name.starts_with("binding-") { + let path = entry.path(); + if entry + .file_type() + .map_err(|error| format!("检查 separation 临时文件失败:{error}"))? + .is_file() + { + fs::remove_file(&path) + .map_err(|error| format!("删除 separation 临时文件失败:{error}"))?; + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn atomic_state_replacement_overwrites_existing_target() { + let directory = tempfile::tempdir().expect("create temporary state directory"); + let target = directory.path().join("state.json"); + let temporary = directory.path().join("state.json.tmp"); + fs::write(&target, b"old").expect("write old state"); + fs::write(&temporary, b"new").expect("write new state"); + + replace_separation_state_atomically(&temporary, &target) + .expect("replacement should overwrite existing state"); + + assert_eq!(fs::read(&target).expect("read replaced state"), b"new"); + assert!(!temporary.exists()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/prompt/binding.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/prompt/binding.rs new file mode 100644 index 000000000..e9253e810 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/prompt/binding.rs @@ -0,0 +1,46 @@ +use crate::ui_editor::commands::separation::image_preprocess::VISUAL_BINDING_TRANSPARENT_MARKER_RGBA; +use crate::ui_editor::commands::separation::SeparationNode; + +pub(crate) fn gen_binding_prompt(nodes: &[&SeparationNode]) -> String { + let [marker_red, marker_green, marker_blue, marker_alpha] = + VISUAL_BINDING_TRANSPARENT_MARKER_RGBA; + let marker_color = format!("rgba({marker_red}, {marker_green}, {marker_blue}, {marker_alpha})"); + let binding_system_prompt = format!( + r#" + You will be given a src UI design image and a processed image, where some ui elements are separated. + You need to recognize and review the separation using the given tool. + field notes: + * extracted_area MUST be the recognized area from the processed image, INSTEAD OF from the src image. + The processed image is the only authoritative image for extracted_area. + Return the pixel bounding box of the extracted element as it appears in the processed image. + Do not copy, infer, or reuse the source node rectangle. + The src image is only for identifying which semantic UI element belongs to to_node. + + Here are the separation requirements: + Preserve hard edges and the exact visible shape. + The processed image is an opaque visual-binding preview containing the requested image layers. + The solid color {marker_color} is an intentional transparency marker added by this workflow before this request. + It is not an image-edit defect and is not part of any UI element. + Do not include this marker color in the extracted area. + Do not use the source node rectangle as the extracted area. + And you should also review if the extracted's successfully meet the src image: + * shape + * color + * style + * edge process + ... + + if not, use the `NeedRework` data structure in the tool to indicate the node id and advice. + your advice (less than 20 words) will be used to improve the separation next time. + + these nodes need handling: + "# + ); + let mut result = binding_system_prompt; + result.reserve(512); + for elem in nodes { + result.push_str(&elem.as_prompt()); + result.push('\n'); + } + result +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/prompt/extract.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/prompt/extract.rs new file mode 100644 index 000000000..766363f18 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/prompt/extract.rs @@ -0,0 +1,171 @@ +use crate::ui_editor::commands::separation::model::{SeparationState, SeparationTree}; +use crate::ui_editor::commands::separation::{ + sanitize_prompt_text, SeparationNode, SeparationNodeKind, MAX_NOTE_DESCRIPTION_CHARS, + MAX_REWORK_NOTE_CHARS, +}; +use crate::ui_editor::utils::NodeId; +use serde::Serialize; +use std::collections::HashSet; + +#[derive(Serialize)] +struct ExtractPromptDocument { + ui_layer_tree: ExtractPromptNode, +} + +#[derive(Serialize)] +struct ExtractPromptNode { + index: usize, + status: ExtractPromptStatus, + rect: ExtractPromptRect, + description: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + rework_notes: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + children: Vec, +} + +#[derive(Serialize)] +enum ExtractPromptStatus { + #[serde(rename = "OUTPUT_THIS_TURN")] + OutputThisTurn, + #[serde(rename = "DONE")] + Done, + #[serde(rename = "CONTEXT_ONLY")] + ContextOnly, + #[serde(rename = "REMOVE_ONLY")] + RemoveOnly, +} + +#[derive(Serialize)] +struct ExtractPromptRect { + x: u32, + y: u32, + width: u32, + height: u32, +} + +pub(crate) fn gen_extract_prompt( + state: &SeparationState, + tree: &SeparationTree, + batch: &[&SeparationNode], +) -> Result { + let mut result = r#" + This is a UI design image, not a normal photo/illustration. Extract it strictly as UI elements/layers, not as a generic foreground/background extraction. + Treat distinct UI element as its own layer with hard, clean, pixel-accurate edges and full transparency outside the element. + Generate one transparent atlas at the requested canvas size. You may move or scale output layers so they do not cover one another. + Preserve hard edges and the exact visible shape. Never split a scene/background into multiple scene layers. + Ordinary text is editable UI text: remove it from its parent image/background and do not generate a text raster layer. + Extract only the image nodes marked OUTPUT_THIS_TURN. Reconstruct every child/text layer that is listed under a parent but is not an output target. + + UI layer tree: +"# + .to_string(); + result.reserve(2048); + let target_ids = batch + .iter() + .map(|node| node.id.clone()) + .collect::>(); + let terminal_ids = state + .bound + .iter() + .map(|node| node.node_id.clone()) + .collect::>(); + let mut index = 1; + let document = ExtractPromptDocument { + ui_layer_tree: project_node(&tree.root, &target_ids, &terminal_ids, &mut index), + }; + let yaml = serde_yaml::to_string(&document) + .map_err(|error| format!("UI separation extract prompt projection failed: {error}"))?; + + result.push_str("```yaml\n"); + result.push_str(&yaml); + result.push_str("```\n"); + Ok(result) +} + +fn project_node( + node: &SeparationNode, + target_ids: &HashSet, + terminal_ids: &HashSet, + index: &mut usize, +) -> ExtractPromptNode { + let current_index = *index; + *index += 1; + + let status = match node.kind { + SeparationNodeKind::TextRemovalOnly => ExtractPromptStatus::RemoveOnly, + SeparationNodeKind::PureContainer => ExtractPromptStatus::ContextOnly, + SeparationNodeKind::ImageTarget if target_ids.contains(&node.id) => { + ExtractPromptStatus::OutputThisTurn + } + SeparationNodeKind::ImageTarget if terminal_ids.contains(&node.id) => { + ExtractPromptStatus::Done + } + SeparationNodeKind::ImageTarget => ExtractPromptStatus::ContextOnly, + }; + + let children = node + .children + .iter() + .map(|child| project_node(child, target_ids, terminal_ids, index)) + .collect(); + + ExtractPromptNode { + index: current_index, + status, + rect: ExtractPromptRect { + x: node.global_pos_x_px, + y: node.global_pos_y_px, + width: node.width_px, + height: node.height_px, + }, + description: sanitize_prompt_text(&node.note.description, MAX_NOTE_DESCRIPTION_CHARS), + rework_notes: node + .note + .rework_notes + .iter() + .map(|note| sanitize_prompt_text(note, MAX_REWORK_NOTE_CHARS)) + .collect(), + children, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui_editor::commands::separation::model::SeparationNote; + + #[test] + fn projects_source_node_to_prompt_view() { + let node = SeparationNode { + id: NodeId::new("image").unwrap(), + kind: SeparationNodeKind::ImageTarget, + global_pos_x_px: 1, + global_pos_y_px: 2, + width_px: 3, + height_px: 4, + note: SeparationNote { + description: "按钮".to_string(), + rework_notes: vec!["保留圆角".to_string()], + }, + children: vec![], + rework_count: 0, + }; + let mut index = 1; + let target_ids = HashSet::from([node.id.clone()]); + let projected = project_node(&node, &target_ids, &HashSet::new(), &mut index); + + assert_eq!(projected.index, 1); + assert!(matches!( + projected.status, + ExtractPromptStatus::OutputThisTurn + )); + assert_eq!(projected.rect.x, 1); + assert_eq!(projected.rect.y, 2); + assert_eq!(projected.rect.width, 3); + assert_eq!(projected.rect.height, 4); + assert_eq!(projected.description, "按钮"); + assert_eq!(projected.rework_notes, vec!["保留圆角".to_string()]); + assert!(projected.children.is_empty()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/prompt/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/prompt/mod.rs new file mode 100644 index 000000000..219a1164c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/prompt/mod.rs @@ -0,0 +1,5 @@ +mod binding; +mod extract; + +pub(super) use binding::gen_binding_prompt; +pub(super) use extract::gen_extract_prompt; diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/tree.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/tree.rs new file mode 100644 index 000000000..d632796ef --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/tree.rs @@ -0,0 +1,191 @@ +use super::model::*; +use crate::ui_editor::component::{image::ImageComponent, Component}; +use crate::ui_editor::layout::node::Node; +use crate::ui_editor::state::State; +use std::collections::HashSet; + +fn is_unbound_image(node: &Node) -> bool { + matches!( + node.component.as_ref(), + Some(Component::Image(ImageComponent { + target_graphic: None, + .. + })) + ) +} + +fn has_image_component(node: &Node) -> bool { + matches!(node.component.as_ref(), Some(Component::Image(_))) +} + +fn has_text_component(node: &Node) -> bool { + matches!(node.component.as_ref(), Some(Component::Text(_))) +} + +fn node_pixel_rect( + node: &Node, + parent: &crate::ui_editor::layout::dimension::UIRect, + ppu: f32, +) -> (u32, u32, u32, u32) { + let rect = node.layout.transform.resolve(parent); + ( + (rect.min.x * ppu).max(0.0).round() as u32, + (rect.min.y * ppu).max(0.0).round() as u32, + (rect.size.x * ppu).max(0.0).round() as u32, + (rect.size.y * ppu).max(0.0).round() as u32, + ) +} + +fn node_description(node: &Node) -> String { + let name = node.metadata.name.trim(); + let description = node.metadata.description.trim(); + match (name.is_empty(), description.is_empty()) { + (true, true) => "未命名 UI 图片元素".to_string(), + (false, true) => name.to_string(), + (true, false) => description.to_string(), + (false, false) => format!("{name}:{description}"), + } +} + +fn collect_todo_nodes( + node: &Node, + parent: &crate::ui_editor::layout::dimension::UIRect, + ppu: f32, + output: &mut Vec, +) { + let rect = node.layout.transform.resolve(parent); + let mut children = Vec::new(); + for child in &node.children { + collect_todo_nodes(child, &rect, ppu, &mut children); + } + let kind = if is_unbound_image(node) { + Some(SeparationNodeKind::ImageTarget) + } else if has_text_component(node) && !has_image_component(node) { + Some(SeparationNodeKind::TextRemovalOnly) + } else { + None + }; + if let Some(kind) = kind { + let (x, y, w, h) = node_pixel_rect(node, parent, ppu); + if w > 0 && h > 0 { + output.push(SeparationNode { + id: node.id.clone(), + kind, + global_pos_x_px: x, + global_pos_y_px: y, + width_px: w, + height_px: h, + note: SeparationNote { + description: node_description(node), + rework_notes: Vec::new(), + }, + children, + rework_count: 0, + }); + } else { + output.extend(children); + } + } else { + output.extend(children); + } +} + +pub fn construct_separation_state(state: &State) -> SeparationState { + let trees = state + .ui_trees + .iter() + .filter_map(|tree| { + let image = state.ui_design_images.get(&tree.src_ui_design)?; + let ppu = image.pixels_per_unit.get(); + let size = image.pixel_size / ppu; + let root_rect = + crate::ui_editor::layout::dimension::UIRect::new(nalgebra::Point2::origin(), size); + let root_resolved = tree.root.layout.transform.resolve(&root_rect); + let mut children = Vec::new(); + for child in &tree.root.children { + collect_todo_nodes(child, &root_resolved, ppu, &mut children); + } + let root_extractable = is_unbound_image(&tree.root); + if !root_extractable && children.is_empty() { + return None; + } + let (x, y, w, h) = node_pixel_rect(&tree.root, &root_rect, ppu); + Some(SeparationTree { + src_ui_design: tree.src_ui_design.clone(), + root: SeparationNode { + id: tree.root.id.clone(), + kind: if root_extractable { + SeparationNodeKind::ImageTarget + } else if has_text_component(&tree.root) && !has_image_component(&tree.root) { + SeparationNodeKind::TextRemovalOnly + } else { + SeparationNodeKind::PureContainer + }, + global_pos_x_px: x, + global_pos_y_px: y, + width_px: w, + height_px: h, + note: SeparationNote { + description: node_description(&tree.root), + rework_notes: Vec::new(), + }, + children, + rework_count: 0, + }, + root_extractable, + }) + }) + .collect(); + SeparationState { + schema_version: SEPARATION_STATE_SCHEMA_VERSION.to_string(), + trees, + bound: Vec::new(), + problematic_nodes: Vec::new(), + } +} + +pub fn validate_binding_response( + response: &BindingResp, + batch: &[&SeparationNode], + processed_dimensions: (u32, u32), +) -> Result<(), String> { + let expected = batch + .iter() + .filter(|node| matches!(node.kind, SeparationNodeKind::ImageTarget)) + .map(|node| node.id.clone()) + .collect::>(); + let mut seen = HashSet::new(); + for decision in &response.decisions { + let node_id = match decision { + BindingDecision::Ok { to_node, .. } | BindingDecision::NeedRework { to_node, .. } => { + to_node + } + }; + if !expected.contains(node_id) { + return Err(format!("视觉绑定返回了未知节点:{}", node_id.as_str())); + } + if !seen.insert(node_id.clone()) { + return Err(format!("视觉绑定重复返回节点:{}", node_id.as_str())); + } + if let BindingDecision::Ok { extracted_area, .. } = decision { + extracted_area + .validate_in(processed_dimensions.0, processed_dimensions.1) + .map_err(|error| format!("节点 {} 的分离区域无效:{error}", node_id.as_str()))?; + } + if let BindingDecision::NeedRework { advice, .. } = decision { + if advice.trim().is_empty() { + return Err("NeedRework 必须包含问题描述".to_string()); + } + if advice.chars().count() > MAX_REWORK_NOTE_CHARS { + // TODO add this back in prompt + return Err(format!( + "NeedRework 问题描述不能超过 {MAX_REWORK_NOTE_CHARS} 个字符" + )); + } + } + } + if seen.len() != expected.len() { + return Err("视觉绑定未覆盖当前 batch 的全部节点".to_string()); + } + Ok(()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/batch.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/batch.rs new file mode 100644 index 000000000..13024a78b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/batch.rs @@ -0,0 +1,146 @@ +use crate::ui_editor::commands::separation::model::*; +use crate::ui_editor::utils::NodeId; +use std::collections::HashSet; + +#[derive(Debug)] +pub struct ImageBatch<'a> { + pub nodes: Vec<&'a SeparationNode>, + pub area_px: u64, + pub image_edit_dimension_px: u32, +} + +/// Derive the square raw image-edit canvas from the selected source area. +/// The calculation lives beside batch selection so the area budget and +/// request size cannot drift apart. +pub fn image_edit_dimension_for_area(area_px: u64) -> u32 { + let max = u128::from(IMAGE_EDIT_MAX_DIMENSION_PX); + let limit = u128::from(IMAGE_EDIT_AREA_LIMIT_PX); + let area = u128::from(area_px); + let raw = if area >= limit { + IMAGE_EDIT_MAX_DIMENSION_PX + } else if area == 0 { + 0 + } else { + // Find floor(max * sqrt(area / limit)) without floating-point rounding. + let target = max * max * area; + let mut low = 0u64; + let mut high = IMAGE_EDIT_MAX_DIMENSION_PX; + while low < high { + let mid = low + (high - low + 1) / 2; + if u128::from(mid) * u128::from(mid) * limit <= target { + low = mid; + } else { + high = mid - 1; + } + } + low + }; + let alignment = IMAGE_EDIT_DIMENSION_ALIGNMENT_PX; + let aligned = raw / alignment * alignment; + aligned.clamp(IMAGE_EDIT_MIN_DIMENSION_PX, IMAGE_EDIT_MAX_DIMENSION_PX) as u32 +} + +pub(crate) fn terminal_node_ids(state: &SeparationState) -> HashSet { + state + .bound + .iter() + .map(|n| n.node_id.clone()) + .chain(state.problematic_nodes.iter().map(|n| n.node_id.clone())) + .collect() +} + +fn collect_dfs_batch<'a>( + node: &'a SeparationNode, + is_root: bool, + root_extractable: bool, + terminal: &HashSet, + selected: &mut Vec<&'a SeparationNode>, + area: &mut u64, +) -> bool { + let is_target = matches!(node.kind, SeparationNodeKind::ImageTarget) + && (!is_root || root_extractable) + && !terminal.contains(&node.id); + if is_target { + let node_area = u64::from(node.width_px).saturating_mul(u64::from(node.height_px)); + let would_exceed = area.saturating_add(node_area) > IMAGE_EDIT_AREA_LIMIT_PX; + if selected.is_empty() || !would_exceed { + selected.push(node); + *area = area.saturating_add(node_area); + } else { + return true; + } + } + for child in &node.children { + if collect_dfs_batch(child, false, root_extractable, terminal, selected, area) { + return true; + } + } + false +} + +pub fn next_image_batch_with_size<'a>( + state: &SeparationState, + tree: &'a SeparationTree, +) -> ImageBatch<'a> { + let terminal = terminal_node_ids(state); + let mut selected = Vec::new(); + let mut area = 0; + collect_dfs_batch( + &tree.root, + true, + tree.root_extractable, + &terminal, + &mut selected, + &mut area, + ); + let image_edit_dimension_px = image_edit_dimension_for_area(area); + app_log!( + "ui_separation.batch_selected image_id={} image_nodes={} area_px={} image_edit_dimension_px={}", + tree.src_ui_design.as_str(), + selected.len(), + area, + image_edit_dimension_px + ); + ImageBatch { + nodes: selected, + area_px: area, + image_edit_dimension_px, + } +} + +pub fn next_image_batch<'a>( + state: &SeparationState, + tree: &'a SeparationTree, +) -> Vec<&'a SeparationNode> { + next_image_batch_with_size(state, tree).nodes +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn image_edit_dimension_uses_minimum_and_alignment() { + assert_eq!(image_edit_dimension_for_area(0), 816); + assert_eq!(image_edit_dimension_for_area(1), 816); + assert_eq!( + image_edit_dimension_for_area(IMAGE_EDIT_AREA_LIMIT_PX / 16), + 816 + ); + assert_eq!( + image_edit_dimension_for_area(IMAGE_EDIT_AREA_LIMIT_PX), + 2880 + ); + assert_eq!(image_edit_dimension_for_area(u64::MAX), 2880); + } + + #[test] + fn image_edit_dimension_rounds_down_to_sixteen_pixels() { + let max_squared = IMAGE_EDIT_MAX_DIMENSION_PX * IMAGE_EDIT_MAX_DIMENSION_PX; + let area_just_below_1536 = IMAGE_EDIT_AREA_LIMIT_PX * 1536 * 1536 / max_squared; + let area_at_1536 = (IMAGE_EDIT_AREA_LIMIT_PX * 1536 * 1536).div_ceil(max_squared); + + assert_eq!(image_edit_dimension_for_area(area_just_below_1536), 1520); + assert_eq!(image_edit_dimension_for_area(area_at_1536), 1536); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/binding.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/binding.rs new file mode 100644 index 000000000..255a204c7 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/binding.rs @@ -0,0 +1,164 @@ +use crate::config::{build_game_creator_llm_client_from_llm_config, load_game_creator_app_config}; +use crate::ui_editor::commands::separation::image_preprocess; +use crate::ui_editor::commands::separation::prompt::gen_binding_prompt; +use crate::ui_editor::commands::separation::{ + validate_binding_response, BindingResp, SeparationNode, +}; +use crate::ui_editor::commands::utils::{ + parse_limited_llm_tool_arguments, request_ui_editor_llm, run_with_repair_history, + strict_json_schema, +}; +use platform_llm::{ + LlmFunctionTool, LlmMessage, LlmMessageContentPart, LlmRunRequest, LlmToolChoice, +}; +use std::path::PathBuf; +use std::time::Instant; + +pub(super) async fn visual_binding( + source_url: String, + processed_url: String, + sidecar: PathBuf, + nodes: &[&SeparationNode], + processed_dimensions: (u32, u32), +) -> Result { + let started = Instant::now(); + let result = visual_binding_inner( + source_url, + processed_url, + sidecar, + nodes, + processed_dimensions, + ) + .await; + app_log!( + "ui_separation.visual_binding.timing outcome={} elapsed_ms={} nodes={}", + if result.is_ok() { "ok" } else { "error" }, + started.elapsed().as_millis(), + nodes.len() + ); + result +} + +async fn visual_binding_inner( + source_url: String, + processed_url: String, + sidecar: PathBuf, + nodes: &[&SeparationNode], + processed_dimensions: (u32, u32), +) -> Result { + app_log!( + "ui_separation.visual_binding.start nodes={} source_url_chars={} processed_url_chars={}", + nodes.len(), + source_url.chars().count(), + processed_url.chars().count() + ); + let preprocess_started = Instant::now(); + let binding_processed_url = + match image_preprocess::preprocess_for_visual_binding(processed_url, sidecar).await { + Ok(value) => { + app_log!( + "ui_separation.visual_binding.preprocess.timing outcome=ok elapsed_ms={}", + preprocess_started.elapsed().as_millis() + ); + value + } + Err(error) => { + app_log!( + "ui_separation.visual_binding.preprocess.timing outcome=error elapsed_ms={}", + preprocess_started.elapsed().as_millis() + ); + app_log!("ui_separation.error stage=visual_binding_preprocess error={error}"); + return Err(error); + } + }; + let llm_config = load_game_creator_app_config() + .map_err(|e| { + app_log!("ui_separation.error stage=visual_binding reason=load_config error={e}"); + e.to_string() + })? + .llm; + let client = + build_game_creator_llm_client_from_llm_config(&llm_config, "llm").map_err(|e| { + app_log!("ui_separation.error stage=visual_binding reason=build_client error={e}"); + e.to_string() + })?; + let schema = strict_json_schema::().map_err(|error| { + app_log!("ui_separation.error stage=visual_binding reason=build_schema error={error}"); + error + })?; + let tool = LlmFunctionTool::new( + "bind_ui_elements", + "确认处理图中的区域对应哪些 UI 节点", + schema, + ) + .with_strict(true); + let initial_history = vec![ + LlmMessage::system(gen_binding_prompt(nodes)), + LlmMessage::user_multimodal(vec![ + LlmMessageContentPart::InputText { + text: "processed image:".to_string(), + }, + LlmMessageContentPart::InputImage { + image_url: binding_processed_url, + }, + LlmMessageContentPart::InputText { + text: "src image:".to_string(), + }, + LlmMessageContentPart::InputImage { + image_url: source_url.clone(), + }, + ]), + ]; + // 严格工具 schema 由 provider 负责约束正常模型输出;这里的解析失败只代表极小概率 + // 的传输/响应损坏,因此沿用有限重试,不再为理论上的坏载荷扩展业务修复协议。 + let result = run_with_repair_history( + 2, + initial_history, + |history| { + let tool = tool.clone(); + let client = client.clone(); + let llm_config = llm_config.clone(); + async move { + let request = LlmRunRequest::new(history) + .with_function_tools(vec![tool.clone()]) + .with_tool_choice(LlmToolChoice::Required); + let request_started = Instant::now(); + let response = request_ui_editor_llm(&client, &llm_config, request).await; + app_log!( + "ui_separation.llm.timing outcome={} elapsed_ms={}", + if response.is_ok() { "ok" } else { "error" }, + request_started.elapsed().as_millis() + ); + response + .map_err(|e| e.to_string()) + .and_then(|response| { + response + .tool_calls + .into_iter() + .find(|call| call.name == "bind_ui_elements") + .map(|call| call.arguments) + .ok_or_else(|| "视觉绑定模型未返回工具调用".to_string()) + }) + .and_then(|arguments| parse_limited_llm_tool_arguments(&arguments)) + .and_then(|args| { + serde_json::from_value::(args) + .map_err(|e| format!("视觉绑定结果无效:{e}")) + }) + } + }, + |value: &BindingResp| validate_binding_response(value, nodes, processed_dimensions), + ) + .await; + match &result { + Ok(value) => app_log!( + "ui_separation.visual_binding.completed nodes={} decisions={}", + nodes.len(), + value.decisions.len() + ), + Err(error) => app_log!( + "ui_separation.error stage=visual_binding reason=failed nodes={} error={error}", + nodes.len() + ), + } + result +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/cut.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/cut.rs new file mode 100644 index 000000000..e39de4ba8 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/cut.rs @@ -0,0 +1,132 @@ +use crate::ui_editor::commands::separation::area::normalize_binding_area; +use crate::ui_editor::commands::separation::model::BindingArea; +use image::ImageFormat; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +pub(super) async fn cut_processed_image( + source: PathBuf, + area: BindingArea, + target: PathBuf, +) -> Result<(), String> { + let started = Instant::now(); + let result = cut_processed_image_inner(source, area, target).await; + app_log!( + "ui_separation.cut_image.timing outcome={} elapsed_ms={}", + if result.is_ok() { "ok" } else { "error" }, + started.elapsed().as_millis() + ); + result +} + +async fn cut_processed_image_inner( + source: PathBuf, + area: BindingArea, + target: PathBuf, +) -> Result<(), String> { + app_log!( + "ui_separation.cut_image.start source_file={} target_file={} area=({}, {}, {}, {})", + source + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""), + target + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""), + area.global_pos_x_px, + area.global_pos_y_px, + area.width_px, + area.height_px + ); + tokio::task::spawn_blocking(move || cut_processed_image_blocking(&source, &area, &target)) + .await + .map_err(|error| format!("裁切处理图任务失败:{error}"))? +} + +fn cut_processed_image_blocking( + source: &Path, + area: &BindingArea, + target: &Path, +) -> Result<(), String> { + let image = image::open(source) + .map_err(|e| format!("读取处理图失败:{e}"))? + .to_rgba8(); + let normalized = normalize_binding_area(&image, *area)?; + if normalized.transparent { + return Err("分离区域没有可见像素".to_string()); + } + let original_area = *area; + let normalized_area = normalized.area; + app_log!( + "ui_separation.cut_image.normalized changed={} clamped={} transparent={} original_area=({}, {}, {}, {}) normalized_area=({}, {}, {}, {})", + normalized.changed, + normalized.clamped, + normalized.transparent, + original_area.global_pos_x_px, + original_area.global_pos_y_px, + original_area.width_px, + original_area.height_px, + normalized_area.global_pos_x_px, + normalized_area.global_pos_y_px, + normalized_area.width_px, + normalized_area.height_px + ); + let cropped = image::imageops::crop_imm( + &image, + normalized_area.global_pos_x_px, + normalized_area.global_pos_y_px, + normalized_area.width_px, + normalized_area.height_px, + ) + .to_image(); + cropped + .save_with_format(target, ImageFormat::Png) + .map_err(|e| format!("写入 cut 图片失败:{e}"))?; + app_log!( + "ui_separation.cut_image.completed target_file={} width={} height={}", + target + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""), + normalized_area.width_px, + normalized_area.height_px + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use image::{Rgba, RgbaImage}; + + #[test] + fn cuts_using_small_processed_image_dimensions() { + let directory = tempfile::tempdir().expect("创建临时目录失败"); + let source = directory.path().join("processed.png"); + let target = directory.path().join("cut.png"); + let mut image = RgbaImage::from_pixel(816, 816, Rgba([0, 0, 0, 0])); + for y in 120..152 { + for x in 700..800 { + image.put_pixel(x, y, Rgba([255, 255, 255, 255])); + } + } + image.save(&source).expect("写入处理图失败"); + + cut_processed_image_blocking( + &source, + &BindingArea { + global_pos_x_px: 700, + global_pos_y_px: 120, + width_px: 100, + height_px: 32, + }, + &target, + ) + .expect("裁切处理图失败"); + + let cropped = image::open(target).expect("读取 cut 图片失败"); + assert_eq!(cropped.width(), 100); + assert_eq!(cropped.height(), 32); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs new file mode 100644 index 000000000..9477e2a37 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs @@ -0,0 +1,244 @@ +use crate::platform_session::PlatformSessionSnapshot; +use base64::Engine as _; +use serde::Deserialize; +use std::path::PathBuf; +use std::time::Instant; +use std::{fs, io::Cursor}; + +const MAX_IMAGE_PAYLOAD_BYTES: usize = 64 * 1024 * 1024; + +fn decode_bounded_base64(value: &str, label: &str) -> Result, String> { + if value.len() > (MAX_IMAGE_PAYLOAD_BYTES / 3) * 4 + 4 { + return Err(format!( + "{label}超过 {} MiB 字节上限", + MAX_IMAGE_PAYLOAD_BYTES / 1024 / 1024 + )); + } + let decoded = base64::engine::general_purpose::STANDARD + .decode(value.trim()) + .map_err(|error| format!("解码{label}失败:{error}"))?; + if decoded.len() > MAX_IMAGE_PAYLOAD_BYTES { + return Err(format!( + "{label}超过 {} MiB 字节上限", + MAX_IMAGE_PAYLOAD_BYTES / 1024 / 1024 + )); + } + Ok(decoded) +} + +#[derive(Deserialize)] +struct RawEditResponse { + data: Vec, +} + +#[derive(Deserialize)] +struct RawEditItem { + b64_json: String, +} + +pub(super) async fn raw_extract( + session: &PlatformSessionSnapshot, + image_data_url: &str, + prompt: &str, + width: u32, + height: u32, +) -> Result { + let started = Instant::now(); + let result = raw_extract_inner(session, image_data_url, prompt, width, height).await; + app_log!( + "ui_separation.image_edit.timing outcome={} elapsed_ms={} width={} height={}", + if result.is_ok() { "ok" } else { "error" }, + started.elapsed().as_millis(), + width, + height + ); + result +} + +async fn raw_extract_inner( + session: &PlatformSessionSnapshot, + image_data_url: &str, + prompt: &str, + width: u32, + height: u32, +) -> Result { + app_log!( + "ui_separation.image_edit.start width={} height={} prompt_chars={}", + width, + height, + prompt.chars().count() + ); + let (mime, data) = image_data_url + .split_once(',') + .ok_or_else(|| "界面图 data URL 无效".to_string())?; + let mime = mime + .strip_prefix("data:") + .and_then(|value| value.strip_suffix(";base64")); + let is_png = mime.is_some_and(|value| value.eq_ignore_ascii_case("image/png")); + let image_bytes = decode_bounded_base64(data, "源图")?; + if image_bytes.is_empty() { + return Err("源图不能为空".to_string()); + } + let image_bytes = if is_png { + image_bytes + } else { + tokio::task::spawn_blocking(move || normalize_source_image_to_png(image_bytes)) + .await + .map_err(|error| format!("转换源图任务失败:{error}"))?? + }; + let client = crate::http_client::agc_main_site_client_builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + .map_err(|error| format!("创建图片编辑客户端失败:{error}"))?; + let url = format!( + "{}/api/raw/v1/images/edit", + session.api_base_url.trim_end_matches('/') + ); + let image_part = reqwest::multipart::Part::bytes(image_bytes) + .file_name("image.png") + .mime_str("image/png") + .map_err(|error| format!("构造图片编辑文件部件失败:{error}"))?; + let body = reqwest::multipart::Form::new() + .part("image", image_part) + .text("prompt", prompt.to_string()) + .text("width", width.to_string()) + .text("height", height.to_string()) + .text("output_format", "png") + .text("background", "transparent"); + let response = crate::http_client::with_agc_main_site_marker( + client + .post(url) + .bearer_auth(&session.access_token) + .multipart(body), + ) + .send() + .await + .map_err(|error| { + app_log!("ui_separation.error stage=image_edit reason=send error={error}"); + format!("图片分离请求失败:{error}") + })?; + if !response.status().is_success() { + app_log!( + "ui_separation.error stage=image_edit reason=http_status status={}", + response.status() + ); + return Err(format!("图片分离请求失败(HTTP {})", response.status())); + } + let payload = response.json::().await.map_err(|error| { + app_log!("ui_separation.error stage=image_edit reason=parse_response error={error}"); + format!("解析图片分离响应失败:{error}") + })?; + let result = payload + .data + .into_iter() + .next() + .map(|item| format!("data:image/png;base64,{}", item.b64_json)) + .ok_or_else(|| "图片分离响应没有图像".to_string()); + match &result { + Ok(value) => app_log!( + "ui_separation.image_edit.completed data_url_chars={}", + value.chars().count() + ), + Err(error) => { + app_log!("ui_separation.error stage=image_edit reason=empty_result error={error}") + } + } + result +} + +fn normalize_source_image_to_png(image_bytes: Vec) -> Result, String> { + let image = image::load_from_memory(&image_bytes) + .map_err(|error| format!("解码非 PNG 源图失败:{error}"))?; + let mut png_bytes = Vec::new(); + image + .write_to(&mut Cursor::new(&mut png_bytes), image::ImageFormat::Png) + .map_err(|error| format!("将源图转换为 PNG 失败:{error}"))?; + Ok(png_bytes) +} + +#[cfg(test)] +mod tests { + use super::normalize_source_image_to_png; + use image::{DynamicImage, ImageFormat, Rgb, RgbImage}; + use std::io::Cursor; + + #[test] + fn converts_jpeg_source_to_png() { + let image = DynamicImage::ImageRgb8(RgbImage::from_pixel(2, 2, Rgb([255, 0, 0]))); + let mut jpeg = Vec::new(); + image + .write_to(&mut Cursor::new(&mut jpeg), ImageFormat::Jpeg) + .expect("encode jpeg"); + + let png = normalize_source_image_to_png(jpeg).expect("convert jpeg"); + assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n"); + } + + #[test] + fn converts_webp_source_to_png() { + let image = DynamicImage::ImageRgb8(RgbImage::from_pixel(2, 2, Rgb([0, 128, 255]))); + let mut webp = Vec::new(); + image + .write_to(&mut Cursor::new(&mut webp), ImageFormat::WebP) + .expect("encode webp"); + + let png = normalize_source_image_to_png(webp).expect("convert webp"); + assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n"); + } +} + +pub(super) async fn write_processed_image( + processed_url: String, + target: PathBuf, +) -> Result<(u32, u32), String> { + let started = Instant::now(); + let result = write_processed_image_inner(processed_url, target).await; + app_log!( + "ui_separation.processed_image.write.timing outcome={} elapsed_ms={}", + if result.is_ok() { "ok" } else { "error" }, + started.elapsed().as_millis() + ); + result +} + +async fn write_processed_image_inner( + processed_url: String, + target: PathBuf, +) -> Result<(u32, u32), String> { + app_log!( + "ui_separation.processed_image.write.start target_file={} data_url_chars={}", + target + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""), + processed_url.chars().count() + ); + tokio::task::spawn_blocking(move || { + let encoded = processed_url + .split_once(',') + .map(|(_, data)| data) + .ok_or_else(|| "处理图 data URL 无效".to_string())?; + let processed_bytes = decode_bounded_base64(encoded, "处理图")?; + let dimensions = image::ImageReader::new(Cursor::new(processed_bytes.as_slice())) + .with_guessed_format() + .map_err(|error| format!("识别处理图格式失败:{error}"))? + .into_dimensions() + .map_err(|error| format!("读取处理图尺寸失败:{error}"))?; + let byte_len = processed_bytes.len(); + fs::write(&target, processed_bytes) + .map_err(|error| format!("写入处理图失败:{}: {error}", target.display())) + .map(|_| { + app_log!( + "ui_separation.processed_image.write.completed target_file={} bytes={}", + target + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""), + byte_len + ); + dimensions + }) + }) + .await + .map_err(|error| format!("写入处理图任务失败:{error}"))? +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/mod.rs new file mode 100644 index 000000000..c1960f6de --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/mod.rs @@ -0,0 +1,273 @@ +pub mod batch; +mod binding; +mod cut; +mod extract; +mod patch; + +pub use patch::apply_batch_patch; + +use self::batch::next_image_batch_with_size; +use super::model::*; +use super::persistence::{ + project_relative_path, read_separation_state_async, separation_dto, separation_sidecar_dir, + separation_state_path, write_separation_state, +}; +use super::prompt::gen_extract_prompt; +use crate::platform_session::current_platform_session; +use crate::ui_editor::commands::utils::read_ui_reference_image_data_url; +use crate::ui_editor::state::State; +use std::collections::HashMap; +use std::fs; +use std::path::Path; +use std::time::Instant; +use uuid::Uuid; + +pub(crate) async fn separate_ui_impl( + project_path: String, + asset_id: String, + state: State, +) -> Result { + app_log!( + "ui_separation.start asset_id={} ui_trees={} ui_images={} sprites={}", + asset_id, + state.ui_trees.len(), + state.ui_design_images.len(), + state.sprite_assets.len() + ); + let session = current_platform_session().ok_or_else(|| "请先登录平台账号".to_string())?; + let root = Path::new(project_path.trim()); + let sidecar = separation_sidecar_dir(root, &asset_id).map_err(|error| { + app_log!( + "ui_separation.error stage=sidecar_dir asset_id={} error={error}", + asset_id + ); + error + })?; + tokio::task::spawn_blocking({ + let sidecar = sidecar.clone(); + move || fs::create_dir_all(sidecar) + }) + .await + .map_err(|error| format!("创建 separation sidecar 任务失败:{error}"))? + .map_err(|error| { + app_log!( + "ui_separation.error stage=sidecar_create asset_id={} error={error}", + asset_id + ); + format!("创建 separation sidecar 失败:{error}") + })?; + let state_path = separation_state_path(root, &asset_id)?; + let restored = state_path.exists(); + let mut separation = if restored { + app_log!("ui_separation.state_restore.start asset_id={asset_id}"); + read_separation_state_async(state_path.clone()) + .await + .map_err(|error| { + app_log!( + "ui_separation.error stage=state_restore asset_id={} error={error}", + asset_id + ); + error + })? + } else { + app_log!("ui_separation.state_construct.start asset_id={asset_id}"); + super::tree::construct_separation_state(&state) + }; + app_log!( + "ui_separation.state_ready asset_id={} restored={} trees={} bound={} problematic={}", + asset_id, + restored, + separation.trees.len(), + separation.bound.len(), + separation.problematic_nodes.len() + ); + + for tree_index in 0..separation.trees.len() { + let tree = &separation.trees[tree_index]; + let image_id = tree.src_ui_design.clone(); + let image = state + .ui_design_images + .get(&image_id) + .ok_or_else(|| "缺少源界面图".to_string())?; + let source_path = crate::project::resolve_local_project_path(root, &image.path)?; + let source_url = read_ui_reference_image_data_url(source_path) + .await + .map_err(|error| { + app_log!( + "ui_separation.error stage=read_source tree_index={} image_id={} error={error}", + tree_index, + image_id.as_str() + ); + error + })?; + app_log!( + "ui_separation.tree_start tree_index={} image_id={} width={} height={}", + tree_index, + image_id.as_str(), + image.pixel_size.x.round() as u32, + image.pixel_size.y.round() as u32 + ); + write_separation_state(state_path.clone(), &separation) + .await + .map_err(|error| { + app_log!( + "ui_separation.error stage=state_checkpoint tree_index={} error={error}", + tree_index + ); + error + })?; + let mut batch_index = 0usize; + loop { + let batch_started = Instant::now(); + let Some(current_tree) = separation.trees.get(tree_index) else { + break; + }; + let batch_selection = next_image_batch_with_size(&separation, current_tree); + let batch_area_px = batch_selection.area_px; + let image_edit_dimension_px = batch_selection.image_edit_dimension_px; + let batch_nodes = batch_selection + .nodes + .into_iter() + .cloned() + .collect::>(); + if batch_nodes.is_empty() { + app_log!( + "ui_separation.tree_completed tree_index={} image_id={} bound={} problematic={}", + tree_index, image_id.as_str(), separation.bound.len(), separation.problematic_nodes.len() + ); + break; + } + let batch = batch_nodes.iter().collect::>(); + let prompt = gen_extract_prompt(&separation, current_tree, &batch)?; + app_log!( + "ui_separation.batch_start tree_index={} batch_index={} nodes={} area_px={} image_edit_dimension_px={} prompt_chars={} rework_total={}", + tree_index, batch_index, batch.len(), batch_area_px, image_edit_dimension_px, + prompt.chars().count(), + batch.iter().map(|node| node.rework_count).sum::() + ); + let processed_url = match extract::raw_extract( + &session, + &source_url, + &prompt, + image_edit_dimension_px, + image_edit_dimension_px, + ) + .await + { + Ok(value) => value, + Err(error) => { + app_log!("ui_separation.error stage=image_edit tree_index={} batch_index={} error={error}", tree_index, batch_index); + if let Err(checkpoint_error) = + write_separation_state(state_path.clone(), &separation).await + { + app_log!("ui_separation.error stage=state_checkpoint tree_index={} batch_index={} error={checkpoint_error}", tree_index, batch_index); + } + return Err(error); + } + }; + let processed_path = sidecar.join(format!("processed-{}.png", Uuid::new_v4().simple())); + let processed_dimensions = match extract::write_processed_image( + processed_url.clone(), + processed_path.clone(), + ) + .await + { + Ok(dimensions) => dimensions, + Err(error) => { + app_log!("ui_separation.error stage=processed_image_write tree_index={} batch_index={} error={error}", tree_index, batch_index); + if let Err(checkpoint_error) = + write_separation_state(state_path.clone(), &separation).await + { + app_log!("ui_separation.error stage=state_checkpoint tree_index={} batch_index={} error={checkpoint_error}", tree_index, batch_index); + } + return Err(error); + } + }; + let binding = match binding::visual_binding( + source_url.clone(), + processed_url, + sidecar.clone(), + &batch, + processed_dimensions, + ) + .await + { + Ok(value) => value, + Err(error) => { + app_log!("ui_separation.error stage=visual_binding tree_index={} batch_index={} error={error}", tree_index, batch_index); + if let Err(checkpoint_error) = + write_separation_state(state_path.clone(), &separation).await + { + app_log!("ui_separation.error stage=state_checkpoint tree_index={} batch_index={} error={checkpoint_error}", tree_index, batch_index); + } + return Err(error); + } + }; + app_log!( + "ui_separation.binding_decisions tree_index={} batch_index={} decisions={}", + tree_index, + batch_index, + binding.decisions.len() + ); + let mut cut_paths = HashMap::new(); + let mut cut_error = None; + for decision in &binding.decisions { + if let BindingDecision::Ok { + to_node, + extracted_area, + } = decision + { + let node_id = to_node.as_str(); + let cut_path = sidecar.join(format!("cut-{}.png", Uuid::new_v4())); + match cut::cut_processed_image( + processed_path.clone(), + *extracted_area, + cut_path.clone(), + ) + .await + { + Ok(()) => { + cut_paths + .insert(to_node.clone(), project_relative_path(root, &cut_path)?); + } + Err(error) => { + app_log!("ui_separation.error stage=cut_image tree_index={} batch_index={} node_id={} error={error}", tree_index, batch_index, node_id); + cut_error = Some(format!("节点 {} 的分离区域无效:{error}", node_id)); + break; + } + } + } + } + if let Some(error) = cut_error { + app_log!("ui_separation.error stage=cut_batch tree_index={} batch_index={} error={error}", tree_index, batch_index); + if let Err(checkpoint_error) = + write_separation_state(state_path.clone(), &separation).await + { + app_log!("ui_separation.error stage=state_checkpoint tree_index={} batch_index={} error={checkpoint_error}", tree_index, batch_index); + } + return Err(error); + } + patch::apply_batch_patch( + &mut separation, + tree_index, + &batch_nodes, + &binding.decisions, + &cut_paths, + processed_dimensions, + )?; + write_separation_state(state_path.clone(), &separation).await?; + app_log!( + "ui_separation.batch_completed tree_index={} batch_index={} cuts={} bound={} problematic={} elapsed_ms={}", + tree_index, batch_index, cut_paths.len(), separation.bound.len(), separation.problematic_nodes.len(), batch_started.elapsed().as_millis() + ); + batch_index += 1; + } + } + app_log!( + "ui_separation.completed asset_id={} bound_nodes={} problematic_nodes={}", + asset_id, + separation.bound.len(), + separation.problematic_nodes.len() + ); + Ok(separation_dto(&separation)) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/patch.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/patch.rs new file mode 100644 index 000000000..5bdc82a23 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/patch.rs @@ -0,0 +1,116 @@ +use crate::ui_editor::commands::separation::{ + validate_binding_response, BindingDecision, BindingResp, BoundNode, ProblematicNode, + SeparationNode, SeparationState, MAX_REWORK_COUNT, +}; +use crate::ui_editor::utils::NodeId; +use std::collections::HashMap; + +pub fn apply_batch_patch( + state: &mut SeparationState, + tree_index: usize, + batch_nodes: &[SeparationNode], + decisions: &[BindingDecision], + cut_paths: &HashMap, + processed_dimensions: (u32, u32), +) -> Result<(), String> { + app_log!( + "ui_separation.batch_patch.start tree_index={} decisions={} cut_paths={}", + tree_index, + decisions.len(), + cut_paths.len() + ); + if state.trees.get(tree_index).is_none() { + return Err("separation tree 索引无效".to_string()); + } + let batch = batch_nodes.iter().collect::>(); + validate_binding_response( + &BindingResp { + decisions: decisions.to_vec(), + }, + &batch, + processed_dimensions, + )?; + for decision in decisions { + if let BindingDecision::Ok { to_node, .. } = decision { + if !cut_paths.contains_key(to_node) { + return Err(format!("缺少节点 {} 的 cut 图片", to_node.as_str())); + } + } + } + let rework_counts = batch_nodes + .iter() + .map(|node| (node.id.clone(), node.rework_count)) + .collect::>(); + let tree = state.trees.get_mut(tree_index).expect("tree index checked"); + for decision in decisions { + match decision { + BindingDecision::Ok { to_node, .. } => { + let path = cut_paths + .get(to_node) + .expect("cut path presence validated before patch"); + state.bound.push(BoundNode { + node_id: to_node.clone(), + cut_image_path: path.clone(), + }); + } + BindingDecision::NeedRework { + to_node, + advice: problem_description, + } => { + append_rework_note(&mut tree.root, to_node, problem_description); + let count = rework_counts.get(to_node).copied().unwrap_or(0) + 1; + increment_rework_count(&mut tree.root, to_node, count); + if count >= MAX_REWORK_COUNT { + let problem_history = rework_history(&tree.root, to_node); + state.problematic_nodes.push(ProblematicNode { + node_id: to_node.clone(), + problem_description: problem_description.clone(), + problem_history, + rework_count: count, + }); + } + } + } + } + app_log!( + "ui_separation.batch_patch.completed tree_index={} bound={} problematic={} pending_root_children={}", + tree_index, + state.bound.len(), + state.problematic_nodes.len(), + tree.root.children.len() + ); + Ok(()) +} + +fn append_rework_note(node: &mut SeparationNode, id: &NodeId, note: &str) -> bool { + if node.id == *id { + node.note.rework_notes.push(note.to_string()); + return true; + } + node.children + .iter_mut() + .any(|child| append_rework_note(child, id, note)) +} + +fn increment_rework_count(node: &mut SeparationNode, id: &NodeId, count: u32) { + if node.id == *id { + node.rework_count = count; + return; + } + for child in &mut node.children { + increment_rework_count(child, id, count); + } +} + +fn rework_history(node: &SeparationNode, id: &NodeId) -> Vec { + if node.id == *id { + return node.note.rework_notes.clone(); + } + node.children + .iter() + .find_map(|child| { + let history = rework_history(child, id); + (!history.is_empty()).then_some(history) + }) + .unwrap_or_default() +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/utils.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/utils.rs index 4faa40579..852a23c9d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/utils.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/utils.rs @@ -1,9 +1,11 @@ use crate::agent::request_game_creator_llm_text; use crate::config::{apply_game_creator_llm_reasoning_effort, parse_game_creator_llm_api_kind}; use base64::Engine as _; -use platform_llm::{LlmClient, LlmError, LlmRunRequest, LlmRunResponse}; +use platform_llm::{LlmClient, LlmError, LlmMessage, LlmRunRequest, LlmRunResponse}; use schemars::JsonSchema; +use serde::Serialize; use std::fs::File; +use std::future::Future; use std::io::Read; use std::path::{Path, PathBuf}; @@ -25,6 +27,63 @@ pub(crate) async fn request_ui_editor_llm( request_game_creator_llm_text(client, llm, request).await } +/// 按 append-only history 重试结构化 LLM 请求;仅业务校验失败会追加反馈消息。 +/// +/// 现役调用方把重试次数固定在很小的范围(生产路径为 2 次),初始提示词和工具 +/// schema 也受 provider 的请求预算约束;因此最多追加两份反馈,不会形成需要额外 +/// 截断策略的上下文无限增长。这里保留完整模型输出,便于模型修正业务校验失败。 +pub(crate) async fn run_with_repair_history( + max_retries: usize, + initial_history: Vec, + requester: Requester, + validator: Validator, +) -> Result +where + T: Serialize, + Requester: Fn(Vec) -> Fut, + Fut: Future>, + Validator: Fn(&T) -> Result<(), String>, +{ + let mut history = initial_history; + for attempt in 0..=max_retries { + let value = match requester(history.clone()).await { + Ok(value) => value, + Err(error) if attempt < max_retries => { + app_log!( + "ui_editor.llm.retry request_error attempt={} max_retries={} error={}", + attempt + 1, + max_retries, + error + ); + tokio::time::sleep(std::time::Duration::from_millis(200 * (attempt as u64 + 1))) + .await; + continue; + } + Err(error) => return Err(error), + }; + match validator(&value) { + Ok(()) => return Ok(value), + Err(error) if attempt < max_retries => { + app_log!( + "ui_editor.llm.retry validation_error attempt={} max_retries={} error={}", + attempt + 1, + max_retries, + error + ); + tokio::time::sleep(std::time::Duration::from_millis(200 * (attempt as u64 + 1))) + .await; + let serialized = serde_json::to_string(&value) + .map_err(|serialize_error| format!("序列化修复反馈失败:{serialize_error}"))?; + history.push(LlmMessage::system(format!( + "上一次模型输出:\n{serialized}\n\n业务校验失败:\n{error}\n\n请修正并完整返回。" + ))); + } + Err(error) => return Err(error), + } + } + Err("LLM 重试未返回结果".to_string()) +} + pub(crate) fn parse_limited_llm_tool_arguments( arguments: &str, ) -> Result { @@ -144,6 +203,107 @@ mod tests { ); } + #[tokio::test] + async fn repair_history_zero_retries_calls_once_with_initial_history() { + let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let seen = calls.clone(); + let initial_history = vec![LlmMessage::user("初始 prompt")]; + let result = run_with_repair_history( + 0, + initial_history.clone(), + move |history| { + let seen = seen.clone(); + async move { + seen.lock().unwrap().push(history); + Ok::<_, String>(serde_json::json!({"ok": true})) + } + }, + |_| Ok(()), + ) + .await + .expect("single turn should succeed"); + assert_eq!(result, serde_json::json!({"ok": true})); + assert_eq!(calls.lock().unwrap().as_slice(), &[initial_history]); + } + + #[tokio::test] + async fn repair_history_request_error_retries_without_appending_history() { + let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let attempts = std::sync::Arc::new(std::sync::Mutex::new(0usize)); + let seen_calls = calls.clone(); + let seen_attempts = attempts.clone(); + let initial_history = vec![LlmMessage::user("初始 prompt")]; + let result = run_with_repair_history( + 1, + initial_history.clone(), + move |history| { + seen_calls.lock().unwrap().push(history); + let attempt = { + let mut attempts = seen_attempts.lock().unwrap(); + let attempt = *attempts; + *attempts += 1; + attempt + }; + async move { + if attempt == 0 { + Err("网络错误".to_string()) + } else { + Ok::<_, String>(serde_json::json!({"ok": true})) + } + } + }, + |_| Ok(()), + ) + .await + .expect("retry-only error should recover"); + assert_eq!(result, serde_json::json!({"ok": true})); + assert_eq!( + calls.lock().unwrap().as_slice(), + &[initial_history.clone(), initial_history] + ); + } + + #[tokio::test] + async fn repair_history_business_failure_appends_serialized_value_and_error() { + let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let attempts = std::sync::Arc::new(std::sync::Mutex::new(0usize)); + let seen_calls = calls.clone(); + let seen_attempts = attempts.clone(); + let initial_history = vec![LlmMessage::user("初始 prompt")]; + let result = run_with_repair_history( + 1, + initial_history.clone(), + move |history| { + seen_calls.lock().unwrap().push(history); + let mut attempts = seen_attempts.lock().unwrap(); + let attempt = *attempts; + *attempts += 1; + async move { Ok::<_, String>(serde_json::json!({"attempt": attempt})) } + }, + |value: &serde_json::Value| { + if value["attempt"] == 0 { + Err("业务校验失败".to_string()) + } else { + Ok(()) + } + }, + ) + .await + .expect("business feedback should recover"); + assert_eq!(result, serde_json::json!({"attempt": 1})); + let calls = calls.lock().unwrap(); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0], initial_history); + assert_eq!(calls[1].len(), 2); + assert_eq!(calls[1][0], LlmMessage::user("初始 prompt")); + assert_eq!( + calls[1][1], + LlmMessage::system( + "上一次模型输出:\n{\"attempt\":0}\n\n业务校验失败:\n业务校验失败\n\n请修正并完整返回。" + ) + ); + } + #[test] fn reference_image_rejects_file_over_five_mib_before_reading() { let directory = tempfile::tempdir().expect("reference image fixture"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/mod.rs index 14ab9c854..664f40e42 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/component/mod.rs @@ -9,3 +9,27 @@ pub enum Component { Image(image::ImageComponent), Text(text::TextComponent), } + +/// LLM 工具返回的节点组件载荷。 +/// +/// 这里不能直接使用 `Option`:部分模型在严格工具 schema 下不会稳定地产生 +/// `null`。用显式的 `PureNode` / `WithComponent` 外部枚举表达两种情况,既保留纯结构节点 +/// 的语义,也让工具调用始终返回一个可判别的对象;落入编辑器 `Node` 时再映射为 +/// `Option`。 +#[derive( + Clone, Debug, PartialEq, schemars::JsonSchema, serde::Deserialize, serde::Serialize, ts_rs::TS, +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/ui-editor/types/"))] +pub enum NodeComponent { + PureNode, + WithComponent(Component), +} + +impl NodeComponent { + pub fn into_option(self) -> Option { + match self { + Self::PureNode => None, + Self::WithComponent(component) => Some(component), + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs index e0c8199e7..e908a3117 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/html_renderer/mod.rs @@ -199,14 +199,13 @@ fn render_node_with_scale( json!({"childrenDisplayMode": "Exclusive", "childrenRendered": "all"}), ) }); - let components = node - .components - .iter() + let component = node + .component + .as_ref() .map(|component| render_component(state, component)) - .collect::, _>>()? - .into_iter() + .transpose()? .map(|fragment| fragment.into_string()) - .collect::>(); + .unwrap_or_default(); let children = node .children .iter() @@ -226,7 +225,7 @@ fn render_node_with_scale( (comment) @if let Some(group_comment) = exclusive_comment { (group_comment) } div ui-node-id=(node.id.as_str()) style=(style) { - (PreEscaped(components.concat())) + (PreEscaped(component)) (PreEscaped(children.concat())) } }) diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs index 9375e913f..be70cdca6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/layout/node.rs @@ -11,7 +11,7 @@ pub struct Node { pub id: NodeId, pub layout: ControlLayout, pub metadata: NodeMetadata, - pub components: Vec, + pub component: Option, pub children_display_mode: ChildrenDisplayMode, pub children: Vec, } @@ -50,7 +50,7 @@ pub struct NodeMetadata { pub name: String, pub description: String, pub layout_status: StageStatus, - pub components_status: StageStatus, + pub component_status: StageStatus, pub allow_llm_edit_layout: bool, pub allow_llm_edit_component: bool, pub source: NodeSource, diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs index 04b72c0b3..73b55d9ce 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs @@ -25,7 +25,6 @@ const UI_DESIGN_STATE_MAX_IMAGES: usize = 4; const UI_DESIGN_STATE_MAX_SPRITES: usize = 1_024; pub(crate) const UI_DESIGN_STATE_MAX_NODES: usize = 10_000; const UI_DESIGN_STATE_MAX_DEPTH: usize = 128; -pub(crate) const UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE: usize = 64; const UI_DESIGN_STATE_MAX_SAFE_REVISION: u64 = 9_007_199_254_740_991; #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] @@ -196,7 +195,7 @@ pub(crate) fn generate_ui_design_code_at( }) } -fn generated_file_stem(asset_id: &str) -> String { +pub(crate) fn generated_file_stem(asset_id: &str) -> String { let mut stem = String::new(); for character in asset_id.chars() { if character.is_ascii_alphanumeric() || matches!(character, '_' | '-') { @@ -747,12 +746,8 @@ fn validate_node( } } } - if node.components.len() > UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE { - return Err(format!( - "单个 UI 节点最多支持 {UI_DESIGN_STATE_MAX_COMPONENTS_PER_NODE} 个组件" - )); - } - for component in &node.components { + validate_component_status(node.component.as_ref(), &node.metadata.component_status)?; + if let Some(component) = &node.component { match component { Component::Image(image) => { if image @@ -778,6 +773,22 @@ fn validate_node( Ok(()) } +fn validate_component_status( + component: Option<&Component>, + status: &crate::ui_editor::layout::node::StageStatus, +) -> Result<(), String> { + if component.is_none() + && matches!( + status, + crate::ui_editor::layout::node::StageStatus::NeedReview(_) + | crate::ui_editor::layout::node::StageStatus::Blocked(_) + ) + { + return Err("纯结构节点的 component_status 必须为 NoProblem".to_string()); + } + Ok(()) +} + fn validate_id(value: &str, label: &str) -> Result<(), String> { if value.is_empty() || value.trim() != value || value.chars().any(char::is_control) { return Err(format!("{label} 无效")); @@ -881,12 +892,12 @@ mod tests { "name": "页面根节点", "description": "", "layout_status": "NoProblem", - "components_status": "NoProblem", + "component_status": "NoProblem", "allow_llm_edit_layout": true, "allow_llm_edit_component": true, "source": "System" }, - "components": [], + "component": null, "children_display_mode": "Stack", "children": [{ "id": "dragged-node", @@ -907,17 +918,17 @@ mod tests { "name": "拖拽节点", "description": "", "layout_status": "NoProblem", - "components_status": "NoProblem", + "component_status": "NoProblem", "allow_llm_edit_layout": true, "allow_llm_edit_component": true, "source": "Human" }, - "components": [{ + "component": { "Image": { "target_graphic": "spirit", "image_type": { "Simple": { "preserve_aspect": false } } } - }], + }, "children_display_mode": "Stack", "children": [] }] @@ -1095,12 +1106,12 @@ mod tests { "name": "根节点", "description": "", "layout_status": "NoProblem", - "components_status": "NoProblem", + "component_status": "NoProblem", "allow_llm_edit_layout": true, "allow_llm_edit_component": true, "source": "System" }, - "components": [], + "component": null, "children_display_mode": "Stack", "children": [] } @@ -1214,8 +1225,28 @@ mod tests { .expect("resolve primary"); fs::write(&primary, b"{broken").expect("corrupt primary"); - let project_lock = acquire_project_write_lock(directory.path(), "test.concurrent-save") - .expect("hold project write lock"); + // 持锁方必须是**另一条线程**:本用例验证的是“另一个写者持锁时恢复安装必须失败 + // 关闭”,同一条调用链自持锁属于重入复用,不再产生占用失败。 + let holder_root = directory.path().to_path_buf(); + let (release_sender, release_receiver) = std::sync::mpsc::channel::<()>(); + let holder = std::thread::spawn(move || { + let lock = acquire_project_write_lock(&holder_root, "test.concurrent-save") + .expect("hold project write lock"); + let _ = release_receiver.recv(); + drop(lock); + }); + let lock_path = resolve_local_project_path(directory.path(), PROJECT_WRITE_LOCK_PATH) + .expect("resolve project write lock path"); + for _ in 0..400 { + if lock_path.is_file() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!( + lock_path.is_file(), + "concurrent writer must hold the project write lock" + ); let error = load_ui_design_state_at(LoadUiDesignStateInput { project_path: directory.path().to_string_lossy().into_owned(), expected_project_id: PROJECT_ID.to_string(), @@ -1224,7 +1255,8 @@ mod tests { .expect_err("recovery must not install while another writer holds the lock"); assert!(error.contains("项目正在被其他写操作占用")); assert!(read_ui_design_document_path(&primary).is_err()); - drop(project_lock); + let _ = release_sender.send(()); + holder.join().expect("join concurrent writer"); let recovered = load_ui_design_state_at(LoadUiDesignStateInput { project_path: directory.path().to_string_lossy().into_owned(), @@ -1296,12 +1328,12 @@ mod tests { "name": "根节点", "description": "", "layout_status": "NoProblem", - "components_status": "NoProblem", + "component_status": "NoProblem", "allow_llm_edit_layout": false, "allow_llm_edit_component": false, "source": "System" }, - "components": [{ + "component": { "Text": { "content": "标题", "font": {"Bound": "missing-font"}, @@ -1313,7 +1345,7 @@ mod tests { "vertical_overflow": "Truncate", "line_spacing": 1.0 } - }], + }, "children_display_mode": "Stack", "children": [] } @@ -1340,4 +1372,30 @@ mod tests { .expect_err("missing Text font reference must be rejected"); assert!(error.contains("Text 组件引用了不存在的字体素材")); } + + #[test] + fn component_status_matrix_keeps_pure_nodes_unproblematic() { + use crate::ui_editor::component::image::ImageComponent; + + assert!(validate_component_status( + None, + &crate::ui_editor::layout::node::StageStatus::NoProblem, + ) + .is_ok()); + assert!(validate_component_status( + None, + &crate::ui_editor::layout::node::StageStatus::NeedReview("原因".to_string()), + ) + .is_err()); + assert!(validate_component_status( + None, + &crate::ui_editor::layout::node::StageStatus::Blocked("原因".to_string()), + ) + .is_err()); + assert!(validate_component_status( + Some(&Component::Image(ImageComponent::new())), + &crate::ui_editor::layout::node::StageStatus::NeedReview("等待素材".to_string()), + ) + .is_ok()); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/utils.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/utils.rs index de48e841b..d282ac64e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/utils.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/utils.rs @@ -43,3 +43,10 @@ define_id!(FontAssetId); define_id!(SpriteAssetId); define_id!(UIDesignImageId); define_id!(NodeId); + +const NODE_ID_LENGTH: usize = 16; + +pub fn random_node_id() -> NodeId { + let uuid = uuid::Uuid::new_v4().simple().to_string(); + NodeId::new(&uuid[..NODE_ID_LENGTH]).expect("generated node ID is valid") +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs index 829782148..3c7d9a4f1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/workflow.rs @@ -296,7 +296,7 @@ pub(crate) async fn run_ui_workflow_at_with_provider( ) { let route = UiWorkflowFinalStageRoute { resource_id: statuses[0].ui_asset_id.clone(), - initial_step: "visual-binding".to_string(), + initial_step: "asset-separation".to_string(), render_mode: "final-preview".to_string(), }; if finalized { @@ -1145,8 +1145,8 @@ fn apply_binding_changes( ) -> usize { let mut changed = 0; if let Some(change) = changes.get(&node.id) { - node.components = change.components.clone(); - node.metadata.components_status = change.components_status.clone(); + node.component = change.component.clone().into_option(); + node.metadata.component_status = change.component_status.clone(); changed += 1; } for child in &mut node.children { @@ -1163,7 +1163,7 @@ fn apply_binding_changes( fn state_has_renderable_component(state: &crate::ui_editor::state::State) -> bool { fn has_component(node: &Node) -> bool { - !node.components.is_empty() || node.children.iter().any(has_component) + node.component.is_some() || node.children.iter().any(has_component) } state.ui_trees.iter().any(|tree| has_component(&tree.root)) } @@ -1315,14 +1315,14 @@ fn derive_page_status( } fn collect_binding_blockers(node: &Node, component_count: &mut usize, blockers: &mut Vec) { - *component_count += node.components.len(); + *component_count += node.component.is_some() as usize; if let StageStatus::NeedReview(reason) | StageStatus::Blocked(reason) = &node.metadata.layout_status { blockers.push(format!("{} 布局未通过:{reason}", node.metadata.name)); } if let StageStatus::NeedReview(reason) | StageStatus::Blocked(reason) = - &node.metadata.components_status + &node.metadata.component_status { blockers.push(format!("{} 组件未通过:{reason}", node.metadata.name)); } diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index 8cf521851..c5fa7f96b 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "Genarrative AI Game Creator", - "version": "0.1.27", + "productName": "陶泥儿", + "version": "0.1.29", "identifier": "world.genarrative.ai-game-creator", "build": { "beforeDevCommand": "npm --prefix ../.. run agc:serve", diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.windows.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.windows.conf.json index 30c71cc2e..5fa3b1b35 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.windows.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.windows.conf.json @@ -4,14 +4,14 @@ "targets": ["nsis"], "useLocalToolsDir": true, "resources": { - "resources/codex/win-x64/bin/codex.exe": "codex/win-x64/bin/codex.exe", - "resources/codex/win-x64/bin/codex-code-mode-host.exe": "codex/win-x64/bin/codex-code-mode-host.exe", - "resources/codex/win-x64/codex-path/rg.exe": "codex/win-x64/codex-path/rg.exe", - "resources/codex/win-x64/codex-resources/codex-command-runner.exe": "codex/win-x64/codex-resources/codex-command-runner.exe", - "resources/codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe": "codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe", - "resources/codex/win-x64/codex-package.json": "codex/win-x64/codex-package.json", - "resources/codex/win-x64/NOTICE.md": "codex/win-x64/NOTICE.md", - "resources/codex/win-x64/manifest.json": "codex/win-x64/manifest.json", + "resources/codex/win-x64/bin/codex.exe": "coding-agent/win-x64/bin/codex.exe", + "resources/codex/win-x64/bin/codex-code-mode-host.exe": "coding-agent/win-x64/bin/codex-code-mode-host.exe", + "resources/codex/win-x64/codex-path/rg.exe": "coding-agent/win-x64/codex-path/rg.exe", + "resources/codex/win-x64/codex-resources/codex-command-runner.exe": "coding-agent/win-x64/codex-resources/codex-command-runner.exe", + "resources/codex/win-x64/codex-resources/codex-windows-sandbox-setup.exe": "coding-agent/win-x64/codex-resources/codex-windows-sandbox-setup.exe", + "resources/codex/win-x64/codex-package.json": "coding-agent/win-x64/codex-package.json", + "resources/codex/win-x64/NOTICE.md": "coding-agent/win-x64/NOTICE.md", + "resources/codex/win-x64/manifest.json": "coding-agent/win-x64/manifest.json", "resources/plugins": "plugins" } } diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 703ebcaf7..f561b85e8 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -192,6 +192,7 @@ import { missingChatCommandArgumentMessage, projectFileActionDrafts, projectPathHasControlCharacter, + projectPathsMatchForInvalidation, readableArtifactsFromAgentRunTrace, sortCheckpointManifestFiles, summarizeAgentRunSupportFileReadDrafts, @@ -634,6 +635,12 @@ export function App({ projectPath: string; clientTurnId: string; } | null>(null); + const designAgentEventSubscriptionReadyRef = useRef | null>( + null, + ); + const designAgentEventSubscriptionResolveRef = useRef<(() => void) | null>( + null, + ); // 做方案入口独立成链:立项策划需要委派、澄清 pending 与 GDD 审批,这些只存在于 // Supervisor Runtime;direct-codex 是单回合「生成→试玩→修」循环,没有对应机制。 // 因此策划入口不走产品默认的 direct-codex,做游戏与做素材保持 master 的新默认。 @@ -955,7 +962,17 @@ export function App({ useState(''); const planningV2TransientReplyTargetRef = useRef(''); const planningV2VisibleReplyRef = useRef(''); + const designAgentPendingViewRef = useRef<{ + clientTurnId: string; + projectPath: string; + view: DesignView; + } | null>(null); const [planningV2Reasoning, setPlanningV2Reasoning] = useState(''); + const designAgentReasoningTurnRef = useRef<{ + projectPath: string; + clientTurnId: string; + text: string; + } | null>(null); const planningV2TurnRef = useRef<{ projectPath: string; clientTurnId: string; @@ -983,6 +1000,28 @@ export function App({ } } + function designAgentEventSubscriptionReady() { + // 业务动作只读取当前订阅代次;清理与下一次 effect 建立之间不能创建 + // 一个没有订阅 effect 接管的悬挂 promise。 + return designAgentEventSubscriptionReadyRef.current ?? Promise.resolve(); + } + + function createDesignAgentEventSubscriptionReady() { + if (!designAgentEventSubscriptionReadyRef.current) { + designAgentEventSubscriptionReadyRef.current = new Promise( + (resolve) => { + designAgentEventSubscriptionResolveRef.current = resolve; + }, + ); + } + return designAgentEventSubscriptionReadyRef.current; + } + + function resolveDesignAgentEventSubscriptionReady() { + designAgentEventSubscriptionResolveRef.current?.(); + designAgentEventSubscriptionResolveRef.current = null; + } + useEffect(() => { const timer = window.setInterval(() => { const target = planningV2TransientReplyTargetRef.current; @@ -1114,6 +1153,15 @@ export function App({ } function designMessagesToChat(view: DesignView): ChatMessage[] { + const reasoningByMessageId = new Map(); + for (const entry of view.reasoningEntries ?? []) { + if (!entry.messageId) { + continue; + } + const texts = reasoningByMessageId.get(entry.messageId) ?? []; + texts.push(entry.text); + reasoningByMessageId.set(entry.messageId, texts); + } return view.messages .filter((message) => message.text.trim()) .map((message) => ({ @@ -1121,6 +1169,7 @@ export function App({ text: message.text, runtimeOwned: true, messageId: message.id, + reasoningText: reasoningByMessageId.get(message.id)?.join('\n\n'), updatedAt: Date.now(), })); } @@ -1139,6 +1188,67 @@ export function App({ latestMessagesRef.current = conversation; } + function commitDesignAgentView(view: DesignView, projectPath: string) { + const pendingTurnId = designAgentPendingViewRef.current?.clientTurnId; + designAgentPendingViewRef.current = null; + applyDesignView(view, projectPath); + setPlanningV2Reasoning(''); + setPlanningV2TransientReplyTarget(''); + if (designAgentTurnRef.current?.clientTurnId === pendingTurnId) { + designAgentTurnRef.current = null; + } + } + + function applyDesignAgentViewAfterTransient( + view: DesignView, + projectPath: string, + clientTurnId: string, + ) { + let target = planningV2TransientReplyTargetRef.current; + const tracked = designAgentTurnRef.current; + if (!target.trim() && !view.running) { + const latestAssistantText = [...view.messages] + .reverse() + .find((message) => message.role !== 'user' && message.text.trim()) + ?.text.trim(); + if (latestAssistantText) { + setPlanningV2TransientReplyTarget(latestAssistantText); + target = latestAssistantText; + } + } + if ( + !view.running && + tracked?.clientTurnId === clientTurnId && + target.trim() && + planningV2VisibleReplyRef.current !== target + ) { + designAgentPendingViewRef.current = { + clientTurnId, + projectPath, + view, + }; + return; + } + commitDesignAgentView(view, projectPath); + } + + useEffect(() => { + const timer = window.setInterval(() => { + const pending = designAgentPendingViewRef.current; + if (!pending) { + return; + } + const target = planningV2TransientReplyTargetRef.current; + if (target && planningV2VisibleReplyRef.current !== target) { + return; + } + commitDesignAgentView(pending.view, pending.projectPath); + }, 50); + return () => window.clearInterval(timer); + // 收尾定时器只需注册一次;它读取 refs,避免随每次渲染重建。 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + async function hydrateDesignAgentSession(nextProjectPath: string) { const invoke = resolveTauriInvoke(); if (!invoke || !nextProjectPath.trim()) { @@ -1172,9 +1282,17 @@ export function App({ projectPath: nextProjectPath, clientTurnId, }; + designAgentReasoningTurnRef.current = { + projectPath: nextProjectPath, + clientTurnId, + text: '', + }; + designAgentPendingViewRef.current = null; + await designAgentEventSubscriptionReady(); setChatAgentBusy(true); setProjectSupervisorRuntimeError(''); setPlanningV2TransientReplyTarget(''); + setPlanningV2Reasoning(''); try { const view = await invoke('continue_design_agent_session', { projectPath: nextProjectPath, @@ -1184,7 +1302,7 @@ export function App({ if (localProjectPathRef.current !== nextProjectPath) { return; } - applyDesignView(view, nextProjectPath); + applyDesignAgentViewAfterTransient(view, nextProjectPath, clientTurnId); } catch (error) { if (localProjectPathRef.current !== nextProjectPath) { return; @@ -1196,8 +1314,10 @@ export function App({ setProjectSupervisorRuntimeError(message); setPlanGddError(message); } finally { - designAgentTurnRef.current = null; - setPlanningV2TransientReplyTarget(''); + if (!designAgentPendingViewRef.current) { + designAgentTurnRef.current = null; + setPlanningV2TransientReplyTarget(''); + } setChatAgentBusy(false); } } @@ -1689,6 +1809,9 @@ export function App({ setProjectSupervisorRuntimeError(''); setPlanningV2Session(null); setPlanningV2TransientReplyTarget(''); + designAgentPendingViewRef.current = null; + designAgentReasoningTurnRef.current = null; + setPlanningV2Reasoning(''); setPlanningV2Active(planningStartMode); planningV2ActiveRef.current = planningStartMode; designAgentLaneRef.current = planningStartMode; @@ -2133,8 +2256,15 @@ export function App({ }, [planningV2Active]); useEffect(() => { + const ready = createDesignAgentEventSubscriptionReady(); if (!canSubscribeTauriEvents() || !planningV2Active) { - return; + resolveDesignAgentEventSubscriptionReady(); + return () => { + if (designAgentEventSubscriptionReadyRef.current === ready) { + designAgentEventSubscriptionReadyRef.current = null; + createDesignAgentEventSubscriptionReady(); + } + }; } let cleanup: (() => void) | null = null; let disposed = false; @@ -2151,26 +2281,46 @@ export function App({ setPlanningV2TransientReplyTarget(payload.text); } if (payload.reasoningText != null) { - setPlanningV2Reasoning(payload.reasoningText); + const reasoningTurn = designAgentReasoningTurnRef.current; + if ( + reasoningTurn && + reasoningTurn.projectPath === payload.projectPath && + reasoningTurn.clientTurnId === payload.clientTurnId + ) { + reasoningTurn.text = payload.reasoningText; + setPlanningV2Reasoning(payload.reasoningText); + } } if (payload.kind === 'tool' && payload.text) { setPlanningV2TransientReplyTarget(payload.text); } if (payload.view) { - applyDesignView(payload.view, payload.projectPath); + applyDesignAgentViewAfterTransient( + payload.view, + payload.projectPath, + payload.clientTurnId, + ); } }) .then((unlisten) => { + resolveDesignAgentEventSubscriptionReady(); if (disposed) { unlisten(); return; } cleanup = unlisten; }) - .catch(() => undefined); + .catch(() => { + resolveDesignAgentEventSubscriptionReady(); + }); return () => { disposed = true; cleanup?.(); + resolveDesignAgentEventSubscriptionReady(); + if (designAgentEventSubscriptionReadyRef.current === ready) { + designAgentEventSubscriptionReadyRef.current = null; + createDesignAgentEventSubscriptionReady(); + } }; // applyDesignView 读的是 refs 和当前项目路径, // 把它写进依赖会在每轮回复时重订事件。 @@ -2186,10 +2336,17 @@ export function App({ void subscribeTauriEvent( 'game-creator-manifest-invalidated', (event) => { - if (event.payload.projectPath !== localProjectPathRef.current) { + const activeProjectPath = localProjectPathRef.current; + if ( + !activeProjectPath || + !projectPathsMatchForInvalidation( + event.payload.projectPath, + activeProjectPath, + ) + ) { return; } - void refreshManifest(event.payload.projectPath); + void refreshManifest(activeProjectPath); }, ) .then((unlisten) => { @@ -5217,7 +5374,7 @@ export function App({ ...current, { role: 'assistant', - text: '当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.spawn_isolated、agent.schedule_ready、agent.audit、agent.trace_read、preview.status、preview.start、preview.validate、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。', + text: '当前仅支持确认 project.index、project.status、project.bootstrap、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.spawn_isolated、agent.schedule_ready、agent.audit、agent.trace_read、preview.status、preview.start、preview.validate、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。', }, ]); return; @@ -5280,7 +5437,7 @@ export function App({ ...current, { role: 'assistant', - text: '当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.spawn_isolated、agent.schedule_ready、agent.audit、agent.trace_read、preview.status、preview.start、preview.validate、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。', + text: '当前仅支持确认 project.index、project.status、project.bootstrap、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.spawn_isolated、agent.schedule_ready、agent.audit、agent.trace_read、preview.status、preview.start、preview.validate、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。', }, ]); return; @@ -6226,6 +6383,7 @@ export function App({ setChatAgentBusy(true); setProjectSupervisorRuntimeError(''); setPlanningV2TransientReplyTarget(''); + setPlanningV2Reasoning(''); try { const result = currentSessionId ? await invoke( @@ -12180,7 +12338,11 @@ export function App({ ? directCodexTransientReply : projectSupervisorTransientReply } + showDesignReasoning={planningV2Active} designReasoning={planningV2Reasoning} + designReasoningEntries={ + useDesignAgentSurface ? (designAgentView?.reasoningEntries ?? []) : [] + } visibleMessages={visibleMessages} visibleProfessionalAgentCards={visibleProfessionalAgentCards} showProfessionalCollaboration={ @@ -12208,15 +12370,25 @@ export function App({ projectPath: nextProjectPath, clientTurnId, }; - setPlanningV2TransientReplyTarget(''); - setChatAgentBusy(true); - setPlanGddDecisionBusy(true); - void invoke('decide_design_phase', { + designAgentReasoningTurnRef.current = { projectPath: nextProjectPath, clientTurnId, - requestId, - approved, - }) + text: '', + }; + designAgentPendingViewRef.current = null; + setPlanningV2TransientReplyTarget(''); + setPlanningV2Reasoning(''); + setChatAgentBusy(true); + setPlanGddDecisionBusy(true); + void designAgentEventSubscriptionReady() + .then(() => + invoke('decide_design_phase', { + projectPath: nextProjectPath, + clientTurnId, + requestId, + approved, + }), + ) .then((view) => { if ( localProjectPathRef.current !== nextProjectPath || @@ -12226,7 +12398,11 @@ export function App({ ) { return; } - applyDesignView(view, nextProjectPath); + applyDesignAgentViewAfterTransient( + view, + nextProjectPath, + clientTurnId, + ); }) .catch((error) => { if ( @@ -12248,8 +12424,10 @@ export function App({ ) { return; } - designAgentTurnRef.current = null; - setPlanningV2TransientReplyTarget(''); + if (!designAgentPendingViewRef.current) { + designAgentTurnRef.current = null; + setPlanningV2TransientReplyTarget(''); + } setChatAgentBusy(false); setPlanGddDecisionBusy(false); }); diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 3878476a3..2c9fe3834 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -75,6 +75,7 @@ function withAuthCheckTimeout( timeoutMs: number, message: string, ) { + void promise.catch(() => undefined); let timeoutId: number | undefined; const timeout = new Promise((_, reject) => { timeoutId = window.setTimeout(() => reject(new Error(message)), timeoutMs); @@ -144,6 +145,11 @@ export function AuthenticatedClient({ const [authCheckError, setAuthCheckError] = useState(''); const [authCheckRetryKey, setAuthCheckRetryKey] = useState(0); const authCheckRunRef = useRef(0); + /** + * 登录尝试代次。UI 的 45s 围栏只约束"等待":底层 native 提交仍在队列里跑,所以围栏超时后 + * 仍要有人接手这次提交的结果。代次确保只有最近一次登录尝试的迟到结果能改变界面。 + */ + const loginAttemptRef = useRef(0); const [loginMode, setLoginMode] = useState<'code' | 'password'>('code'); const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); @@ -476,6 +482,7 @@ export function AuthenticatedClient({ return; } const loginApiBaseUrl = getClientServerBaseUrl(persistedSelection); + const loginAttempt = (loginAttemptRef.current += 1); setLoginBusy(true); setLoginStatus('正在登录'); const loginGeneration = beginPlatformSessionTransition(); @@ -492,11 +499,43 @@ export function AuthenticatedClient({ password, loginApiBaseUrl, ); - const committedGeneration = await commitAuthenticatedPlatformSession( + const commitRequest = commitAuthenticatedPlatformSession( user, loginGeneration, loginApiBaseUrl, ); + let commitFenceExpired = false; + // 围栏只放弃等待,不放弃结果:本地运行时确实装好会话时,界面必须跟着进工作区, + // 否则用户停在登录页、而后端已经认为登录成功(重试也会被已装的会话挡住)。 + void commitRequest + .then((committedGeneration) => { + if ( + committedGeneration === null || + !commitFenceExpired || + loginAttemptRef.current !== loginAttempt || + currentPlatformSessionGeneration() !== committedGeneration + ) { + return; + } + setAuthUser(user); + setAuthCheckError(''); + setAuthStatus('authenticated'); + setCode(''); + setPassword(''); + setLoginStatus('本地运行时登录态已确认'); + }) + .catch(() => undefined); + let committedGeneration: number | null; + try { + committedGeneration = await withAuthCheckTimeout( + commitRequest, + AUTH_CHECK_RUNNER_TIMEOUT_MS, + '连接本地运行时超时,请重试或重启客户端', + ); + } catch (error) { + commitFenceExpired = true; + throw error; + } if (committedGeneration === null) { return; } @@ -524,7 +563,11 @@ export function AuthenticatedClient({ clearStoredAuthAccessToken(); } try { - await clearCommittedPlatformSession(logoutGeneration); + await withAuthCheckTimeout( + clearCommittedPlatformSession(logoutGeneration), + AUTH_CHECK_RUNNER_TIMEOUT_MS, + '清理本地运行时超时,请重启客户端后再登录', + ); } catch (error) { nativeClearError = error; } diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 5e59dbcf5..cb5965504 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -993,6 +993,7 @@ export interface ChatMessage { draftCommand?: string; draftCommandLabel?: string; messageId?: string | null; + reasoningText?: string; agentId?: string | null; updatedAt?: number; runtimeOwned?: boolean; @@ -1038,11 +1039,19 @@ export interface DesignAgentMessage { text: string; } +export interface DesignReasoningEntry { + id: string; + text: string; + messageId?: string | null; +} + export interface DesignView { session: DesignSessionSummary; messages: DesignAgentMessage[]; running: boolean; canRetry: boolean; + reasoningText?: string | null; + reasoningEntries?: DesignReasoningEntry[]; } export interface DesignEvent { diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 94b415df1..58a188e6f 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -93,6 +93,8 @@ export function WorkspaceLauncherShell({ startGameFromApprovedGdd, createHomeDraftAutomatically, openProject, + homeCreationBusy, + homeCreationRecoverableProjectPath, } = homeProject; const switchedToGameRuntime = gameRuntimeSwitch !== null && @@ -522,6 +524,8 @@ export function WorkspaceLauncherShell({ onStatusChange={setStatus} recentProjectRows={recentProjectRows} onCreateDraftAutomatically={createHomeDraftAutomatically} + creationBusy={homeCreationBusy} + recoverableCreatedProjectPath={homeCreationRecoverableProjectPath} onProjectsOpen={() => setLauncherView('projects')} onProjectOpen={(path) => { setProjectPath(path); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index 6e1fd8d1d..5c54a5055 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -230,6 +230,9 @@ export function buildRecentProjectRows( >, recentWorkspaceRefreshing: boolean, ): RecentProjectRow[] { + // The refresh flag is kept for the page-level indicator. Each row owns its + // pending state so a slow directory cannot disable already inspected rows. + void recentWorkspaceRefreshing; return recentWorkspaces.map((workspace) => { const directoryStatus = recentWorkspaceStatuses[workspace]; const isPendingStatus = directoryStatus === undefined; @@ -237,34 +240,31 @@ export function buildRecentProjectRows( directoryStatus?.projectName || workspace.split(/[\\/]/).filter(Boolean).pop() || workspace; - const status = recentWorkspaceRefreshing + const status = isPendingStatus ? '检查中' - : isPendingStatus - ? '检查中' - : directoryStatus === null - ? '检查失败' - : directoryStatus?.exists === false - ? '未找到' - : directoryStatus?.isDirectory === false - ? '不是文件夹' - : directoryStatus?.manifestError - ? '无法读取' - : (directoryStatus?.isGodotProject === true || - directoryStatus?.isCocosProject === true) && - directoryStatus?.isGameCreatorProject === false - ? '可导入' - : directoryStatus?.isGameCreatorProject === false - ? '未初始化' - : directoryStatus?.recentRunStatus - ? formatRecentProjectRunStatus( - directoryStatus.recentRunStatus, - directoryStatus.recentRunStopReason, - ) - : directoryStatus?.isGodotProject - ? '可打开' - : '本地项目'; + : directoryStatus === null + ? '检查失败' + : directoryStatus?.exists === false + ? '未找到' + : directoryStatus?.isDirectory === false + ? '不是文件夹' + : directoryStatus?.manifestError + ? '无法读取' + : (directoryStatus?.isGodotProject === true || + directoryStatus?.isCocosProject === true) && + directoryStatus?.isGameCreatorProject === false + ? '可导入' + : directoryStatus?.isGameCreatorProject === false + ? '未初始化' + : directoryStatus?.recentRunStatus + ? formatRecentProjectRunStatus( + directoryStatus.recentRunStatus, + directoryStatus.recentRunStopReason, + ) + : directoryStatus?.isGodotProject + ? '可打开' + : '本地项目'; const canReveal = - !recentWorkspaceRefreshing && Boolean(directoryStatus) && directoryStatus?.exists !== false && directoryStatus?.isDirectory !== false; @@ -286,7 +286,6 @@ export function buildRecentProjectRows( recentRunStopReason: directoryStatus?.recentRunStopReason ?? null, canReveal, canOpen: - !recentWorkspaceRefreshing && Boolean(directoryStatus) && directoryStatus?.exists !== false && directoryStatus?.isDirectory !== false && diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 6b2078d3a..6e9a147ad 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -26,6 +26,11 @@ import type { TauriInvoke, UploadLocalAssetResult, } from '../../app/types'; +import { + type ClientOperation, + createClientOperation, + transitionClientOperation, +} from '../../services/clientOperation'; import type { HomeAttachmentDraft, HomeCreationType, @@ -132,6 +137,37 @@ export async function uploadLocalFilesAsAttachments( return imported; } +/** + * 自动建项的兜底期限。 + * + * 建项要跑脚手架和依赖安装,慢是正常的,所以这不是"失败期限"而是"解围期限":到点后不再让首页 + * 入口无限占用工作区闸门(底层创建不会被取消,迟到成功仍会照常进入项目)。没有这个期限时, + * 一次卡死的 `create_automatic_local_game_project` 会让首页之后的打开/新建全部无法进行。 + */ +const HOME_CREATION_DEADLINE_MS = 10 * 60_000; +const HOME_CREATION_WATCHDOG_MESSAGE = + '工作区创建超过 10 分钟仍未返回;可先打开其它项目,或在项目列表查看已创建的工作区'; + +/** + * 建项已经落盘、但没能进入项目时,给首页一个恢复入口(打开已创建的工作区)。 + * 只有"已经知道项目路径且当前没有在跑"的阶段才提示,避免和进行中的建项打架。 + */ +function resolveRecoverableHomeProjectPath( + operation: ClientOperation< + 'home-create', + { draft: HomeDraft; startMode: ProjectStartMode } + > | null, +) { + if (!operation) return ''; + if ( + operation.phase !== 'retryable-failure' && + operation.phase !== 'unknown' + ) { + return ''; + } + return operation.scope.projectPath ?? ''; +} + export function useHomeProjectCreation({ setStatus, setLauncherView, @@ -162,6 +198,21 @@ export function useHomeProjectCreation({ (state) => state.reset, ); const approvedGddStartInFlightRef = useRef(false); + const [homeCreationOperation, setHomeCreationOperation] = + useState | null>(null); + const homeCreationOperationRef = useRef(homeCreationOperation); + homeCreationOperationRef.current = homeCreationOperation; + + function homeCreationIsBusy() { + const phase = homeCreationOperationRef.current?.phase; + return phase === 'network' || phase === 'runner' || phase === 'project'; + } /** * 进项目流程的代次。 * @@ -171,6 +222,14 @@ export function useHomeProjectCreation({ * 一次代次,await 回来时已经不是最新代次的结果整体丢弃。 */ const projectEntryTokenRef = useRef(0); + /** + * 首页自动建项的尝试代次与"底层创建仍未返回"标记。 + * + * 看门狗到点后会放开工位闸门(让用户还能打开其它项目),但底层创建仍在跑。此时既不允许 + * 并发再建一个项目(会产生重复工作区),也不能让迟到的创建结果强行劫持用户已经打开的别的项目。 + */ + const homeCreationAttemptRef = useRef(0); + const homeCreationUnresolvedRef = useRef(false); function validateProjectPath(nextProjectPath: string) { const trimmedProjectPath = nextProjectPath.trim(); @@ -660,38 +719,148 @@ export function useHomeProjectCreation({ startMode: ProjectStartMode, options: { suggestName: boolean }, ) { + if (projectActionRef.current || homeCreationIsBusy()) { + return '已有项目操作进行中,请稍候'; + } + if (homeCreationUnresolvedRef.current) { + // 上一次建项的底层调用还没返回(可能已经超过看门狗期限)。此时放行会真的建出第二个 + // 工作区,所以只挡"再建一个",不挡打开/查看已有项目。 + return '上一次工作区创建仍未返回,请稍候,或重启客户端后再试'; + } const invoke = resolveTauriInvoke(); if (!invoke) { throw new Error('需要在陶泥儿客户端内运行'); } - const suggestedName = options.suggestName - ? await suggestAutomaticProjectName(invoke, draft) - : null; - const result = await invoke( - 'create_automatic_local_game_project', - { - name: suggestedName, - planning: startMode === 'planning', - }, + const attempt = (homeCreationAttemptRef.current += 1); + const isCurrentAttempt = () => homeCreationAttemptRef.current === attempt; + const operation = createClientOperation( + 'home-create', + { draft, startMode }, + { deadlineMs: HOME_CREATION_DEADLINE_MS, cancellable: false }, ); - try { - await enterCreatedHomeProject( - invoke, - result, - draft.creationType, - draft.prompt, - draft.attachments, - startMode, - ); - setStatus('已创建工作区,正在开始智能创作'); - return '已创建工作区并进入项目开发'; - } catch (error) { - const message = `工作区已创建;首条需求投递失败:${ - error instanceof Error ? error.message : String(error) - }`; - setStatus(message); - throw new Error(message); - } + setHomeCreationOperation(transitionClientOperation(operation, 'network')); + // This action is owned by WorkspaceLauncher rather than HomeView. The + // launcher survives navigation, so unmounting the home page cannot release + // the guard while project creation or first-turn import is still running. + projectActionRef.current = 'creating'; + setProjectAction('creating'); + homeCreationUnresolvedRef.current = true; + setStatus('正在创建工作区'); + let createdProjectPath: string | undefined; + let entryTokenAtWatchdog: number | null = null; + const operationScope = () => + createdProjectPath ? { scope: { projectPath: createdProjectPath } } : {}; + let watchdogId: number | undefined; + /** + * 看门狗到点后**只解围不取消**:首页入口立刻拿回控制权(否则 `await` 不结束, + * 首页按钮会一直禁用),而底层建项继续在后台跑;它真的成功时会照常进入项目。 + * 这就是为什么这里用 `return string` 而不是抛错——首页的 catch 会把错误统一压成 + * 「创建未完成,请重试」,反而丢掉"只是慢"这个信息。 + */ + const watchdog = new Promise((resolve) => { + watchdogId = window.setTimeout(() => { + if (!isCurrentAttempt() || !homeCreationUnresolvedRef.current) return; + entryTokenAtWatchdog = projectEntryTokenRef.current; + setHomeCreationOperation( + transitionClientOperation(operation, 'unknown', operationScope()), + ); + setStatus(HOME_CREATION_WATCHDOG_MESSAGE); + if (projectActionRef.current === 'creating') { + projectActionRef.current = null; + setProjectAction(null); + } + resolve(HOME_CREATION_WATCHDOG_MESSAGE); + }, HOME_CREATION_DEADLINE_MS); + }); + /** 后台继续跑的建项主体:用户可见的等待由 `watchdog` 兜底,这里只负责最终落定。 */ + const creation = (async () => { + try { + const suggestedName = options.suggestName + ? await suggestAutomaticProjectName(invoke, draft) + : null; + const result = await invoke( + 'create_automatic_local_game_project', + { + name: suggestedName, + planning: startMode === 'planning', + }, + ); + createdProjectPath = result.projectPath; + setHomeCreationOperation( + transitionClientOperation(operation, 'project', { + scope: { projectPath: result.projectPath }, + }), + ); + if ( + entryTokenAtWatchdog !== null && + projectEntryTokenRef.current !== entryTokenAtWatchdog + ) { + // 看门狗之后用户已经进了别的项目:工作区确实建好了,但不能在此时把工作区切过去。 + rememberRecentWorkspace(result.projectPath); + setHomeCreationOperation( + transitionClientOperation(operation, 'retryable-failure', { + scope: { projectPath: result.projectPath }, + }), + ); + setStatus('工作区已创建;可在项目列表打开'); + return '工作区已创建,可从项目列表打开'; + } + try { + await enterCreatedHomeProject( + invoke, + result, + draft.creationType, + draft.prompt, + draft.attachments, + startMode, + ); + setHomeCreationOperation( + transitionClientOperation(operation, 'success', { + scope: { projectPath: result.projectPath }, + }), + ); + setStatus('已创建工作区,正在开始智能创作'); + return '已创建工作区并进入项目开发'; + } catch (error) { + // 项目目录已经建好了:把它登记进最近项目,用户可以直接打开,不必重新建一遍。 + rememberRecentWorkspace(result.projectPath); + setHomeCreationOperation( + transitionClientOperation(operation, 'retryable-failure', { + scope: { projectPath: result.projectPath }, + }), + ); + const message = `工作区已创建;首条需求投递失败:${ + error instanceof Error ? error.message : String(error) + }`; + setStatus(message); + throw new Error(message); + } + } catch (error) { + setHomeCreationOperation( + transitionClientOperation( + operation, + 'retryable-failure', + operationScope(), + ), + ); + if (createdProjectPath) { + rememberRecentWorkspace(createdProjectPath); + } + throw error; + } finally { + if (watchdogId !== undefined) { + window.clearTimeout(watchdogId); + } + homeCreationUnresolvedRef.current = false; + if (isCurrentAttempt() && projectActionRef.current === 'creating') { + projectActionRef.current = null; + setProjectAction(null); + } + } + })(); + // 后台主体不会因为竞速落定而停止:这里只防止它变成未处理的拒绝。 + void creation.catch(() => undefined); + return await Promise.race([creation, watchdog]); } async function pickAndOpenProject() { @@ -802,6 +971,11 @@ export function useHomeProjectCreation({ setAgentResults, projectAction, projectBusy: projectAction !== null, + homeCreationOperation, + homeCreationBusy: homeCreationIsBusy(), + homeCreationRecoverableProjectPath: resolveRecoverableHomeProjectPath( + homeCreationOperation, + ), pendingNonEmptyProject, resetLauncherHomeDraft, startGameFromApprovedGdd, diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts index d930bb61d..2733aff91 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts @@ -19,6 +19,8 @@ import { writeRecentWorkspace, } from './model'; +const RECENT_WORKSPACE_CHECK_TIMEOUT_MS = 5_000; + export function useRecentProjects(setStatus: Dispatch>) { const [recentWorkspaces, setRecentWorkspaces] = useState(readRecentWorkspaces); @@ -34,14 +36,26 @@ export function useRecentProjects(setStatus: Dispatch>) { invoke: NonNullable>, workspace: string, ): Promise<[string, LocalProjectDirectoryStatus | null]> { + let timeoutHandle: number | undefined; try { - const result = await invoke( - 'inspect_local_project_directory', - { projectPath: workspace }, - ); + const result = await Promise.race([ + invoke('inspect_local_project_directory', { + projectPath: workspace, + }), + new Promise((_, reject) => { + timeoutHandle = window.setTimeout( + () => reject(new Error('项目目录检查超时')), + RECENT_WORKSPACE_CHECK_TIMEOUT_MS, + ); + }), + ]); return [workspace, result]; } catch { return [workspace, null]; + } finally { + if (timeoutHandle !== undefined) { + window.clearTimeout(timeoutHandle); + } } } @@ -53,18 +67,27 @@ export function useRecentProjects(setStatus: Dispatch>) { return; } let disposed = false; + let pendingCount = recentWorkspaces.length; + setRecentWorkspaceStatuses({}); setRecentWorkspaceRefreshing(true); - void Promise.all( - recentWorkspaces.map((workspace) => - inspectRecentWorkspace(invoke, workspace), - ), - ).then((entries) => { - if (disposed) { - return; - } - setRecentWorkspaceStatuses(Object.fromEntries(entries)); - setRecentWorkspaceRefreshing(false); - }); + + for (const workspace of recentWorkspaces) { + void inspectRecentWorkspace(invoke, workspace).then( + ([projectPath, status]) => { + if (disposed) { + return; + } + setRecentWorkspaceStatuses((current) => ({ + ...current, + [projectPath]: status, + })); + pendingCount -= 1; + if (pendingCount === 0) { + setRecentWorkspaceRefreshing(false); + } + }, + ); + } return () => { disposed = true; }; diff --git a/apps/ai-game-creator-shell/src/features/project-summary/projectPath.ts b/apps/ai-game-creator-shell/src/features/project-summary/projectPath.ts index b0a61e6c5..d8a54f66a 100644 --- a/apps/ai-game-creator-shell/src/features/project-summary/projectPath.ts +++ b/apps/ai-game-creator-shell/src/features/project-summary/projectPath.ts @@ -17,6 +17,27 @@ export function projectPathHasControlCharacter(value: string) { }); } +// 失效事件只是重读提示:匹配 Windows 的普通 / verbatim 路径后,调用方仍用当前项目路径 +// 读取权威清单。此比较不解析链接,也不作为文件访问授权依据。 +export function projectPathsMatchForInvalidation( + eventPath: string, + activePath: string | null, +) { + if (!eventPath || !activePath) return false; + function normalize(path: string) { + if (/^\\\\\?\\UNC\\/i.test(path)) { + path = `\\\\${path.slice(8)}`; + } else if (/^\\\\\?\\[a-z]:\\/i.test(path)) { + path = path.slice(4); + } + if (/^[a-z]:[\\/]/i.test(path) || /^\\\\[^?.\\][^\\]*\\[^\\]+/.test(path)) { + return path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(); + } + return path; + } + return normalize(eventPath) === normalize(activePath); +} + export function isSafeProjectRelativePath(value: string) { const path = value.trim(); return ( diff --git a/apps/ai-game-creator-shell/src/features/project-summary/projectSummary.ts b/apps/ai-game-creator-shell/src/features/project-summary/projectSummary.ts index 160f2e563..97b981535 100644 --- a/apps/ai-game-creator-shell/src/features/project-summary/projectSummary.ts +++ b/apps/ai-game-creator-shell/src/features/project-summary/projectSummary.ts @@ -80,6 +80,7 @@ export { isAbsoluteProjectPath, isSafeProjectRelativePath, projectPathHasControlCharacter, + projectPathsMatchForInvalidation, } from './projectPath'; export { summarizeProjectDependencyMap, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx index fb78371e2..62e782c5f 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx @@ -14,6 +14,8 @@ import type { ClientLlmModel, ClientLlmModelCatalog, } from '../../services/clientApi'; +import { ClientAuthRequestError } from '../../services/clientApi'; +import { ClientHttpTimeoutError } from '../../services/clientHttp'; import { cachedLlmModelCatalog, refreshLlmModelCatalog, @@ -27,6 +29,14 @@ export type ConversationModelSelectHandle = { /** 客户端配置读取/写回失败:与「模型目录加载失败」区分,避免误导提示。 */ class ModelSelectionConfigError extends Error {} +function modelCatalogErrorMessage(error: unknown) { + if (error instanceof ClientHttpTimeoutError) + return '模型列表请求超时,请重试'; + if (error instanceof ClientAuthRequestError && error.status) + return `模型列表加载失败(HTTP ${error.status})`; + return '模型列表加载失败'; +} + export function ConversationModelSelect({ className, disabled, @@ -51,6 +61,7 @@ export function ConversationModelSelect({ const [busy, setBusy] = useState(!initialCatalog); const [error, setError] = useState(''); const [notice, setNotice] = useState(''); + const [manualRefreshBusy, setManualRefreshBusy] = useState(false); const [open, setOpen] = useState(false); const containerRef = useRef(null); const appliedRevisionRef = useRef( @@ -180,7 +191,11 @@ export function ConversationModelSelect({ ); const syncCatalog = useCallback( - async (showBusy: boolean) => { + async (showBusy: boolean, manualRefresh = false) => { + if (manualRefresh && mountedRef.current) { + setManualRefreshBusy(true); + setNotice('正在刷新模型列表'); + } const busyToken = showBusy ? ++busyTokenRef.current : busyTokenRef.current; @@ -195,12 +210,17 @@ export function ConversationModelSelect({ try { let catalog: ClientLlmModelCatalog; let usingCachedCatalog = false; + let catalogError: unknown = null; try { catalog = await refreshLlmModelCatalog(); - } catch { + } catch (error) { + catalogError = error; const cached = cachedLlmModelCatalog(); if (!cached) { - if (mountedRef.current) setError('模型列表加载失败'); + if (mountedRef.current) { + setError(modelCatalogErrorMessage(error)); + setNotice(''); + } markReady(false); return false; } @@ -209,10 +229,14 @@ export function ConversationModelSelect({ } const ready = await applyCatalog(catalog, showBusy, epochAtRequest); if (usingCachedCatalog && mountedRef.current) - setError('模型列表加载失败'); + setError(modelCatalogErrorMessage(catalogError)); + if (manualRefresh && mountedRef.current) { + setNotice(usingCachedCatalog ? '' : '模型列表已刷新'); + } return ready; } catch (error) { if (mountedRef.current) { + setNotice(''); setError( error instanceof ModelSelectionConfigError ? error.message @@ -230,6 +254,7 @@ export function ConversationModelSelect({ ) { setBusy(false); } + if (manualRefresh && mountedRef.current) setManualRefreshBusy(false); } }, [applyCatalog, markReady], @@ -375,11 +400,12 @@ export function ConversationModelSelect({ type="button" className="conversation-model-menu-refresh" aria-label="刷新模型列表" - disabled={disabled || busy} - onClick={() => void syncCatalog(true)} + disabled={disabled || busy || manualRefreshBusy} + aria-busy={manualRefreshBusy} + onClick={() => void syncCatalog(true, true)} >