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..3c333133f 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', 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 e85700ec9..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(); @@ -200,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, }, ); @@ -240,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; } @@ -879,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(); @@ -905,6 +1100,7 @@ async function main() { const handler = () => { shutdownSignal = signal; stopChild(viteChild, signal); + stopChild(adminWebChild, signal); stopChild(backendChild, signal); // 立刻清扫,避免外层 taskkill /F 抢在 finally 之前把本进程杀掉。 sweepStartedBackend(); @@ -937,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; @@ -946,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)}`, @@ -958,6 +1175,7 @@ async function main() { } finally { await Promise.all([ terminateChildTree(viteChild), + terminateChildTree(adminWebChild), terminateChildTree(backendChild), ]); sweepStartedBackend(); @@ -975,9 +1193,12 @@ function isDirectModuleExecution() { } export { + agcDevAdminWebEnvKey, + ensureAdminWeb, ensureBackend, formatChildFailure, formatOwnerLabel, + formatStartupSummary, isAiGameCreatorServer, isBackendReady, isDirectModuleExecution, @@ -985,6 +1206,7 @@ export { isWorktreeApiServerOwner, isWorktreeSpacetimeOwner, preflightExistingVite, + readAdminWebEnabled, readBackendServiceFailure, readChildFailure, readExistingViteServer, @@ -993,6 +1215,7 @@ export { resolveBackendTargetsFromState, runWindowsTaskkill, spawnChild, + startAdminWeb, stopChild, terminateChildTree, verifyAgcBackendOwnership, 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/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..f02b45374 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-game-production-workflow/SKILL.md @@ -0,0 +1,26 @@ +--- +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 stop or repair at that stage 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. + +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..72dbdf5fb 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.13", "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": "91082fdff4123f1e1fcf930af433cbea51a8c9d26991678b19028b344ea49f39" + }, { "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 19f473cca..48edc498f 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 @@ -2829,6 +2829,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, }); } @@ -3133,6 +3135,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, @@ -4999,7 +5002,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 "#, ) 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..23fd00fe9 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 @@ -589,6 +589,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 80ba114ca..53142ca6a 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,6 +601,7 @@ 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)) } // 调试队列只接收副本,写盘慢或失败时丢弃,不参与会话恢复。 @@ -548,8 +688,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 +712,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 +746,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 +776,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 +830,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 +858,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!() @@ -935,7 +1141,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()) } @@ -1171,6 +1379,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; @@ -1280,6 +1520,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 { @@ -1340,6 +1581,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/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index bf7c9ef2e..2e4cdad61 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 是用户选择的项目目录。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 不会据此替你拆任务、编排 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"; @@ -4862,8 +4862,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] @@ -4992,7 +4995,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/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/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 f5bcfdf30..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; 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_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/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/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 c4382bcef..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,7 +644,7 @@ 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, "document", @@ -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/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index c81dd2663..b3cc58f28 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( 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/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index e01525ffa..4ff699d3c 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 @@ -2685,6 +2722,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, @@ -2693,6 +2734,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/write_lock.rs b/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs index 3dd791743..883f6b073 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,11 +905,15 @@ pub(crate) fn acquire_project_write_lock_failure( } } } - if project_write_lock_is_owned_by_current_process(&path) { - // A project lock is the client-use lock. Nested calls in - // the same client process must reuse that ownership instead - // of waiting on their own durable marker. Cross-process - // contenders still take the normal retryable 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)) + { + // 持锁方就是本进程自己时必须区分重入与并发:同一条调用链(同一 + // 线程)再次取锁,以及自主流水线有意并行专家动作,返回 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_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs index 7e39fccbe..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 @@ -5818,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, @@ -5837,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 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..7fc0d7cf1 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", + "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/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 3f3d82c13..4bba11987 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -595,6 +595,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 的新默认。 @@ -820,7 +826,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; @@ -848,6 +864,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; @@ -979,6 +1017,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) => ({ @@ -986,6 +1033,7 @@ export function App({ text: message.text, runtimeOwned: true, messageId: message.id, + reasoningText: reasoningByMessageId.get(message.id)?.join('\n\n'), updatedAt: Date.now(), })); } @@ -1004,6 +1052,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()) { @@ -1037,9 +1146,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, @@ -1049,7 +1166,7 @@ export function App({ if (localProjectPathRef.current !== nextProjectPath) { return; } - applyDesignView(view, nextProjectPath); + applyDesignAgentViewAfterTransient(view, nextProjectPath, clientTurnId); } catch (error) { if (localProjectPathRef.current !== nextProjectPath) { return; @@ -1061,8 +1178,10 @@ export function App({ setProjectSupervisorRuntimeError(message); setPlanGddError(message); } finally { - designAgentTurnRef.current = null; - setPlanningV2TransientReplyTarget(''); + if (!designAgentPendingViewRef.current) { + designAgentTurnRef.current = null; + setPlanningV2TransientReplyTarget(''); + } setChatAgentBusy(false); } } @@ -1521,6 +1640,9 @@ export function App({ setProjectSupervisorRuntimeError(''); setPlanningV2Session(null); setPlanningV2TransientReplyTarget(''); + designAgentPendingViewRef.current = null; + designAgentReasoningTurnRef.current = null; + setPlanningV2Reasoning(''); setPlanningV2Active(planningStartMode); planningV2ActiveRef.current = planningStartMode; designAgentLaneRef.current = planningStartMode; @@ -1956,8 +2078,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; @@ -1974,26 +2103,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 和当前项目路径, // 把它写进依赖会在每轮回复时重订事件。 @@ -6038,6 +6187,7 @@ export function App({ setChatAgentBusy(true); setProjectSupervisorRuntimeError(''); setPlanningV2TransientReplyTarget(''); + setPlanningV2Reasoning(''); try { const result = currentSessionId ? await invoke( @@ -11762,7 +11912,11 @@ export function App({ ? directCodexTransientReply : projectSupervisorTransientReply } + showDesignReasoning={planningV2Active} designReasoning={planningV2Reasoning} + designReasoningEntries={ + useDesignAgentSurface ? (designAgentView?.reasoningEntries ?? []) : [] + } visibleMessages={visibleMessages} visibleProfessionalAgentCards={visibleProfessionalAgentCards} showProfessionalCollaboration={ @@ -11790,15 +11944,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 || @@ -11808,7 +11972,11 @@ export function App({ ) { return; } - applyDesignView(view, nextProjectPath); + applyDesignAgentViewAfterTransient( + view, + nextProjectPath, + clientTurnId, + ); }) .catch((error) => { if ( @@ -11830,8 +11998,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/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index a89c1698a..64a79bfab 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/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index bdd35e3b9..b5e01f1bd 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -14,7 +14,11 @@ import type { PendingCommand, PendingUiConfirmation, } from '../../app/types'; -import type { DesignClarificationRequest, DesignView } from '../../app/types'; +import type { + DesignClarificationRequest, + DesignReasoningEntry, + DesignView, +} from '../../app/types'; import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage'; import { projectProfessionalAgentLabel, @@ -91,7 +95,9 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { projectPath: string; showProfessionalCollaboration?: boolean; transientReply: string; + showDesignReasoning?: boolean; designReasoning?: string; + designReasoningEntries?: DesignReasoningEntry[]; visibleMessages: ChatMessage[]; visibleProfessionalAgentCards: AgentStatusCard[]; workspaceStatus: string; @@ -132,7 +138,9 @@ export function ProjectSupervisorView({ projectPath, showProfessionalCollaboration = true, transientReply, + showDesignReasoning = false, designReasoning = '', + designReasoningEntries = [], visibleMessages, visibleProfessionalAgentCards, workspaceStatus, @@ -222,11 +230,36 @@ export function ProjectSupervisorView({ role={message.role} text={projectSupervisorChatMessageText(message)} /> + {showDesignReasoning && message.reasoningText ? ( +
+ 思考过程 +
{message.reasoningText}
+
+ ) : null} ))} - {designReasoning ? ( -
- 显示思考过程 + {showDesignReasoning && + designReasoningEntries + .filter((entry) => !entry.messageId) + .map((entry) => ( +
+ 思考过程 +
{entry.text}
+
+ ))} + {showDesignReasoning && designReasoning ? ( +
+ 思考过程
{designReasoning}
) : null} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceTypePanel.css b/apps/ai-game-creator-shell/src/features/project-workspace/resourceTypePanel.css new file mode 100644 index 000000000..fb9260aa6 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceTypePanel.css @@ -0,0 +1,130 @@ +/* + * 「设置素材类型」面板的弹窗骨架、纵向单选列表与信息浮层的类型入口。 + * + * 单独一个文件而不是塞进 styles.css:与「编辑素材标签」面板当初同样的理由 —— + * 这份样式只服务本次的素材类型入口,与工作台其它区块没有共享选择器,独立文件让改动 + * 边界更清楚,也不会与同一时段其它 Agent 在 styles.css 里的编辑互相踩。 + * + * 骨架沿用「编辑素材标签」那套三段式契约(`auto / minmax(0, 1fr)`):标题常驻、 + * 中间一行可压缩、`max-height` 兜住上界。类型面板没有底部按钮,所以只有两行。 + */ +.game-resource-type-dialog { + width: min(480px, 100%); + max-height: min(720px, calc(100dvh - 40px)); + grid-template-rows: auto minmax(0, 1fr); +} + +/* + * body 分三段:提示 / 选项列表 / 错误提示。 + * + * `min-height: 0` 是网格项能被 `1fr` 压缩的前提;**滚动不在这里**——滚动权交给选项列表 + * (见下),否则往下滚时素材名和错误提示会跟着跑掉,用户看不到"改的是哪件素材、为什么失败"。 + */ +.game-resource-type-body { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 10px; + min-height: 0; +} + +/* + * 类型选项之上的一句短提示。只说这一屏要选什么,不写规则说明或开发解释。 + */ +.game-resource-type-hint { + margin: 0; + color: var(--platform-text-base); + font-size: 12px; +} + +/* + * 纵向单选列表(`role="radiogroup"`):**一行一个选项**。 + * + * 之前 6 项横排在一条里(`PlatformSegmentedTabs` 的 3~6 列网格),窄屏上互相叠字读不出来。 + * 单列网格 + 按行流向是"每项一行、互不重叠"的充分条件:只声明一列,6 个子元素必然上下排 6 行, + * 不存在两项挤一行的可能。选项多时列表自己滚(`max-height` + `overflow-y: auto`), + * 面板不会被撑高。移动端优先:360px 宽的窄屏同样是这一套声明(没有按宽度改列数的媒体查询)。 + */ +.game-resource-type-options { + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-auto-flow: row; + align-content: start; + gap: 6px; + min-height: 0; + max-height: min(320px, 40dvh); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +/* + * 单个选项行:复用共享的 `PlatformNavigableListItem` 骨架(w-full / flex / text-left / + * 圆角 / 悬停 / 焦点环都由它给),这里只补"整行可点 + 明确选中态"的表现。 + * + * `width/min-width` 显式写出来,不依赖共享件里的 Tailwind `w-full`:这一行是不是满宽 + * 决定了"一项一行"能不能成立,不能挂在另一份文件的工具类上。 + * `min-height: 44px` 是移动端点击热区下限;`overflow-wrap` 让长选项名在窄屏换行而不是溢出。 + */ +.game-resource-type-option { + width: 100%; + min-width: 0; + min-height: 44px; + padding: 8px 12px; + border: 1px solid var(--platform-subpanel-border); + background: rgb(255 255 255 / 62%); + color: var(--platform-text-base); + font-size: 13px; + font-weight: 600; + overflow-wrap: anywhere; +} + +.game-resource-type-option:hover:not(:disabled) { + border-color: var(--platform-surface-hover-border); +} + +/* + * 选中态完全由 `aria-checked="true"` 驱动:视觉与读屏读的是同一个属性,不会各说一套。 + * + * 选择器显式提权到 (0,3,0) 以上:共享列表行自带的 `.platform-navigable-list-item:hover:not(:disabled)` + * 也是 (0,3,0),只写 `.game-resource-type-option[aria-checked='true']`((0,2,0))会在悬停时 + * 被它的底色顶掉;带 `:hover:not(:disabled)` 的那条 (0,5,0) 保证选中行悬停时也不变色。 + */ +.game-resource-type-options .game-resource-type-option[aria-checked='true'], +.game-resource-type-options + .game-resource-type-option[aria-checked='true']:hover:not(:disabled) { + border-color: var(--platform-warm-border); + background: var(--platform-warm-bg); + color: var(--platform-text-strong); +} + +.game-resource-type-error { + margin: 0; + color: #b3261e; + font-size: 11px; +} + +/* + * 第二入口:信息浮层「分类」行右侧的入口按钮。 + * + * 放在 `dd` **外面**:信息字段的读取口径(`dt` / `dd` 文本逐行比对)在两处共用, + * 把按钮塞进 `dd` 会让分类值变成「角色与对象设置」这类拼接文案。 + */ +.game-resource-info-field-action { + align-self: start; + margin-left: auto; + padding: 0 6px; + border: 1px solid var(--platform-subpanel-border); + border-radius: 8px; + background: transparent; + color: var(--platform-text-base); + font-size: 11px; + line-height: 20px; + cursor: pointer; +} + +.game-resource-info-field-action:hover, +.game-resource-info-field-action:focus-visible { + border-color: var(--platform-surface-hover-border); + background: var(--platform-warm-bg); + color: var(--platform-text-strong); +} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx index 89402805e..5c36c3d56 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx @@ -22,24 +22,47 @@ export type ResourceCanvasAssetGenerationSubmitInput = { imageSize: string; }; -export type ResourceCanvasAssetGenerationPanelViewProps = { - action: ResourceCanvasAssetToolAction; - onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => Promise; - onClose: () => void; +/** 提交面板的草稿:点击即关闭之后,只有「即时失败」重开时才需要把这份草稿带回来。 */ +export type ResourceCanvasAssetGenerationPanelDraft = { + prompt: string; + assetName: string; + aspectRatio: string; + imageSize: string; }; -function assetGenerationErrorMessage(error: unknown) { - if (typeof error === 'string' && error.trim()) return error; - if (error instanceof Error && error.message) return error.message; - return '生成素材失败'; -} +export type ResourceCanvasAssetGenerationPanelViewProps = { + action: ResourceCanvasAssetToolAction; + /** + * 上一次「点击瞬间就失败」带回来的草稿。 + * + * 面板点击即关闭,草稿只活在组件里;重开时由宿主把它传回来,用户改完就能重试。 + */ + draft?: ResourceCanvasAssetGenerationPanelDraft; + /** 上一次即时失败的原因;重开时直接以 `role="alert"` 呈现。 */ + error?: string | null; + /** + * 提交回调:**同步返回**,面板不等它的结果。 + * + * 受理失败要不要把面板带回来由宿主决定(只有「从未被后端受理」的即时失败才重开), + * 面板自己不持有任何在途状态。 + */ + onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => void; + onClose: () => void; +}; /** * 栏目画布底部工具栏的图片类生成浮层(生成图片 / 生成规范 / 生成角色形象 / 生成图标素材 / * 生成 UI 设计图共用)。 * * 形态是独立弹层(`ThemedModal`,与既有「生成素材」面板同一套宿主 chrome),不在任何现有 - * 面板下面追加内容;提交链路与结果定位由宿主 `index.tsx` 负责,面板只持有草稿与失败状态。 + * 面板下面追加内容;提交链路与结果定位由宿主 `index.tsx` 负责。 + * + * **点击「生成」即关闭面板**:不等 IPC、不等排队、不等生成结束,用户立刻回到画布。所以面板里 + * 不存在「排队中。」「正在生成。」「提交中…」这类阶段文案——阶段文案的唯一去处是画布上的 + * 「生成任务」侧栏与工具栏提示条。关闭**不等于**取消:任务照常在后台跑完并把结果写回项目。 + * + * 只有「点击瞬间就失败」(校验 / 权限拒绝 / IPC 立即报错,即后端从未受理)时,宿主才会带着 + * `draft` 与 `error` 把面板重新打开,用户可以直接改后重试。 * * 比例 / 尺寸选项来自网页端美术画布的纯模型(`ImageCanvasGenerationModel.ts`)经本地 IPC * 白名单收窄后的子集:网页端面板会渲染 `4:3`,而本地通道明确拒绝它,照搬就是一个点了必 @@ -48,57 +71,52 @@ function assetGenerationErrorMessage(error: unknown) { */ export function ResourceCanvasAssetGenerationPanelView({ action, + draft, + error: initialError, onSubmit, onClose, }: ResourceCanvasAssetGenerationPanelViewProps) { - const [prompt, setPrompt] = useState(''); - const [assetName, setAssetName] = useState(action.assetName); - const [aspectRatio, setAspectRatio] = useState(action.aspectRatio); - const [imageSize, setImageSize] = useState(action.imageSize); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); + const [prompt, setPrompt] = useState(draft?.prompt ?? ''); + const [assetName, setAssetName] = useState( + draft?.assetName ?? action.assetName, + ); + const [aspectRatio, setAspectRatio] = useState( + draft?.aspectRatio ?? action.aspectRatio, + ); + const [imageSize, setImageSize] = useState( + draft?.imageSize ?? action.imageSize, + ); + const [error, setError] = useState(initialError ?? null); // 提示词上限复用资源编辑模型的同一份口径:图片类入口默认 32000,与 Rust // `LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS` 一致,不在面板里另抄常量。 const promptMaxLength = resourceEditPromptMaxLength('image-reference'); - const canSubmit = - !submitting && prompt.trim().length > 0 && assetName.trim().length > 0; + const canSubmit = prompt.trim().length > 0 && assetName.trim().length > 0; - async function submit(event: FormEvent) { + function submit(event: FormEvent) { event.preventDefault(); const normalizedPrompt = prompt.trim(); const normalizedAssetName = assetName.trim(); - if (!normalizedPrompt || !normalizedAssetName || submitting) { + if (!normalizedPrompt || !normalizedAssetName) { return; } - setSubmitting(true); setError(null); - try { - await onSubmit({ - kind: action.assetKind, - prompt: normalizedPrompt, - assetName: normalizedAssetName, - aspectRatio, - imageSize, - }); - } catch (submitError) { - // 成功路径由宿主卸载面板;失败保留草稿,用户可直接用同一份输入重试。 - setError(assetGenerationErrorMessage(submitError)); - } finally { - setSubmitting(false); - } + // 点击即关闭:不等 IPC、不等排队、不等生成结束。失败要不要把面板带回来由宿主决定 + // (只有「从未被后端受理」的即时失败才重开并带回草稿),面板不持有在途状态。 + onSubmit({ + kind: action.assetKind, + prompt: normalizedPrompt, + assetName: normalizedAssetName, + aspectRatio, + imageSize, + }); + onClose(); } return ( { - if (!submitting) { - onClose(); - } - }} + onClose={onClose} panelClassName="game-approval-dialog game-resource-generation-dialog" >
@@ -108,7 +126,6 @@ export function ResourceCanvasAssetGenerationPanelView({ + ) : null} + + ); +} + +/** + * 画布上的「生成任务」侧栏(常驻、可折叠、非模态)。 + * + * 形态对齐网页端美术画布的任务侧栏:贴边的独立 `
- + ); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceTypePanel.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceTypePanel.tsx new file mode 100644 index 000000000..3a7144160 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceTypePanel.tsx @@ -0,0 +1,238 @@ +import '../../features/project-workspace/resourceTypePanel.css'; + +import { Check } from 'lucide-react'; +import { + type KeyboardEvent as ReactKeyboardEvent, + useRef, + useState, +} from 'react'; + +import { PlatformNavigableListItem } from '../../../../../packages/shared/src/components/PlatformNavigableListItem'; +import { + GAME_CREATION_APP_ASSET_CATEGORIES, + type GameCreationAppAssetCategory, + gameCreationAppAssetCategory, + type GameCreationAppAssetManifestEntry, + gameCreationAppAssetTags, +} from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { ThemedModal } from '../../components/modal/ThemedModal'; +import { resourceReferenceCategoryLabel } from '../../features/project-workspace/resourceReferences'; +import { projectAssetCommandErrorMessage } from './projectAssetCommandErrorMessage'; +import { resourceAssetDisplayName } from './resourceAssetDisplayName'; + +type UpdateLocalProjectResourceClassificationResult = { + asset: GameCreationAppAssetManifestEntry; + committedProjectRevision: number; +}; + +/** + * 素材类型(功能分类)选项 = 合法分类枚举 × 既有中文展示名。 + * + * 展示名只从 `resourceReferenceCategoryLabel`(筛选与栏目的同一份口径)取, + * 不在业务页另写一张译名表 —— 面板说「角色与对象」而栏目说别的,用户会以为是两个东西。 + */ +const RESOURCE_TYPE_CATEGORY_OPTIONS = GAME_CREATION_APP_ASSET_CATEGORIES.map( + (category) => ({ + id: category, + label: resourceReferenceCategoryLabel(category), + }), +); + +function resourceTypeErrorMessage(error: unknown) { + // 项目身份 / 版本 CAS 拒绝翻成用户可读中文,其余原样透出; + // 与重命名、删除、标签共用同一份映射。 + return projectAssetCommandErrorMessage(error, '设置素材类型失败'); +} + +type ResourceTypePanelProps = { + projectPath: string; + projectId: string; + asset: GameCreationAppAssetManifestEntry; + onClose: () => void; + onSaved: (result: UpdateLocalProjectResourceClassificationResult) => void; +}; + +/** + * 「设置素材类型」面板:素材类型(功能分类)的独立入口,与「编辑素材标签」彻底分家。 + * + * 拆开的理由是原设计的动作语义错位 —— 类型 chip 曾长在标签弹窗里,点它只改本地 state, + * 而全弹窗唯一的保存入口是标签的「添加」。于是「改类型」必须借道一个语义上是"加标签"的 + * 按钮,只选类型就直接关窗(点遮罩 / Esc / ×)则改动静默丢失。 + * + * 本面板把动作压成一步:**选中即落盘**,不再有也只不需要任何标签动作。 + * + * 三个口径要点: + * 1. **显示**用读显示口径 `gameCreationAppAssetCategory`:它与画布栏目、资源卡角标同一份 + * 读数,用户看到的选中项恰好就是他看到的那一栏。 + * 2. **写回**用用户当次点的那个值,且只写这一个字段;`tags` 逐字回传 + * `gameCreationAppAssetTags(asset)`(落盘原值),不使用任何读时自愈口径 + * —— 改类型不许顺手改标签,也不许把自愈出来的值写回去。 + * 3. **没碰过就不写**:面板本身不产生"打开即写"或"关闭时补写",没有用户动作就没有写入。 + * 另一半对照(用户主动选了就必须写)由 `tests/resourceTypePanel.test.tsx` 钉住。 + */ +export function ResourceTypePanel({ + projectPath, + projectId, + asset, + onClose, + onSaved, +}: ResourceTypePanelProps) { + /** + * 保存在飞时先把用户点的那一项显出来(否则 await 期间面板像没反应)。 + * 写入失败就退回显示口径,不留一个"看起来成功"的选中态。 + */ + const [pendingCategory, setPendingCategory] = + useState(null); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const activeCategory = pendingCategory ?? gameCreationAppAssetCategory(asset); + const optionsRef = useRef(null); + + /** + * 单选组的键盘口径:Tab 进组只停一次(roving tabindex,见下面的 `tabIndex`); + * 方向键在选项之间移动**焦点**,Enter/Space(` + +
+ {/* 只说这一屏要选什么,不写规则说明或开发解释。 */} +

选择这件素材所属的栏目

+ {/* + 纵向单选列表(`role="radiogroup"` + 每项 `role="radio"`): + 一行一个选项,不再横排成一条 —— 6 项挤在一行时窄屏会互相叠字。 + + 选中项就是这张卡当前所在的画布栏目;点任意一项即落盘(含点当前已选中的那一项: + 用户显式确认归属,不做隐式 no-op)。视觉选中态由 `aria-checked="true"` 驱动, + 与读屏读到的状态是同一个属性。 + */} +
+ {RESOURCE_TYPE_CATEGORY_OPTIONS.map((option, index) => { + const active = option.id === activeCategory; + return ( + + ); + })} +
+ {error ? ( +

+ {error} +

+ ) : null} +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 60842a4f6..b8fc97384 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -25,6 +25,7 @@ import { Info, Layers, LayoutGrid, + ListChecks, ListFilter, Maximize2, Minus, @@ -39,6 +40,7 @@ import { RotateCcw, Search, Settings2, + Shapes, SlidersHorizontal, Sparkles, Trash2, @@ -109,9 +111,23 @@ import { } from '../../features/project-workspace/resourceReferences'; import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker'; import { + type ResourceCanvasAssetGenerationPanelDraft, ResourceCanvasAssetGenerationPanelView, type ResourceCanvasAssetGenerationSubmitInput, } from '../../features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; +import { + createResourceCanvasAssetGenerationQueue, + mergeResourceCanvasAssetGenerationTasksWithRecords, + type ResourceCanvasAssetGenerationQueue, + type ResourceCanvasAssetGenerationSettlement, +} from '../../features/resource-canvas/resourceCanvasAssetGenerationQueue'; +import { + createResourceCanvasAssetGenerationTask, + type LocalProjectAssetGenerationTaskRecord, + type ResourceCanvasAssetGenerationTask, + resourceCanvasAssetGenerationTaskIsTerminal, +} from '../../features/resource-canvas/resourceCanvasAssetGenerationTaskModel'; +import { ResourceCanvasAssetGenerationTasksPanelView } from '../../features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView'; import { defaultResourceExportFileName, isResourceCanvasExportable, @@ -321,6 +337,7 @@ import { clampProjectResourceSectionZoom, projectResourceSectionZoomFromWheel, } from './resourceSectionHeightModel'; +import { ResourceTypePanel } from './ResourceTypePanel'; import { describeProjectResourceCanvasLayoutRead, type ProjectResourceCanvasLayoutReadReport, @@ -388,13 +405,6 @@ type DeriveLocalProjectResourceResult = { manifest: GameCreationAppManifest; }; -type LocalProjectAssetGenerationResult = { - id: string; - localPath: string; - absolutePath: string; - manifestPath: string; -}; - type PendingLocalProjectResourceEdit = { operationId: string; editKind: string; @@ -974,6 +984,18 @@ const ResourceCard = memo(function ResourceCard({ data-preview-error={ preview.status === 'failed' ? preview.error : undefined } + // 这张图是否**真的**带 alpha 通道(原生头部判据:PNG colorType 4/6 或 tRNS、 + // WebP alpha 标志;JPEG 恒 false)。棋盘格底只允许铺在真透明图上 —— + // 否则「AI 把棋盘格画进像素里」的不透明图会与卡面棋盘格叠成两套,验收时反而 + // 分不出哪张真透明。 + // + // 只写 `'true'`,不写 `'false'`:缺属性表示「还没读出来 / 判据说不透明 / 这条读取链路 + // 没有 alpha 判据」,三者必须同档(CSS 里只认 `'true'`),免得出现第三种中间态。 + data-preview-has-alpha={ + preview.status === 'loaded' && preview.preview.hasAlpha === true + ? 'true' + : undefined + } data-used-by-current-version={usedByCurrentVersion ? 'true' : undefined} // 替换血缘的稳定 DOM 判据(值都是 manifest 资产 id,不是显示名): // 「被替换掉的源素材」卡上给出替换它的那张卡的 id,「替换素材」卡上给出源素材的 id。 @@ -1556,6 +1578,56 @@ export default function ProjectDevelopmentView({ /** 工具栏图片类入口打开的生成浮层;同一时刻只允许一个。 */ const [resourceAssetGenerationAction, setResourceAssetGenerationAction] = useState(null); + /** + * 图片类生成任务的本地队列 + 后端账本视图。 + * + * 与 `resourceAssetGenerationAction`(只管「哪块提交表单开着」)分开持有:表单随时可以被关掉, + * 任务必须继续活在这份列表里。所以提交回调不读面板状态,队列也不依赖面板挂载。 + */ + const [resourceAssetGenerationTasks, setResourceAssetGenerationTasks] = + useState([]); + const resourceAssetGenerationTasksRef = useRef< + ResourceCanvasAssetGenerationTask[] + >([]); + /** + * 提交面板上一次提交的上下文。 + * + * 面板点击即关闭,草稿只活在组件里,所以「点击瞬间就失败」要把面板带回来时,得从这里取回 + * 那份草稿;`dispatchedImmediately` 用来区分「这次点击本来就该立刻派发」与「排在队列后面 + * 才派发」——只有前者才值得重开面板。 + */ + const resourceAssetGenerationPanelSubmissionRef = useRef<{ + taskId: string; + action: ResourceCanvasAssetToolAction; + draft: ResourceCanvasAssetGenerationPanelDraft; + dispatchedImmediately: boolean; + } | null>(null); + /** 即时失败重开提交面板时带回去的草稿与原因;正常打开时为 null。 */ + const [ + resourceAssetGenerationPanelReopen, + setResourceAssetGenerationPanelReopen, + ] = useState<{ + actionId: string; + draft: ResourceCanvasAssetGenerationPanelDraft; + error: string; + } | null>(null); + const [ + resourceAssetGenerationTasksPanelOpen, + setResourceAssetGenerationTasksPanelOpen, + ] = useState(false); + /** + * 「定位到素材」的聚焦请求序号。 + * + * 聚焦 effect(`resolveResourceFocusIntent` 那条链)的依赖全是画布自身状态,手动点一次定位 + * 不改其中任何一项 → effect 不会重跑,intent 永远没人消费、提示条停在中转文案上。所以每次 + * 点击都要推进这个序号,让「这次请求」成为一个真实的依赖变化。 + */ + const [ + resourceAssetGenerationFocusRequest, + setResourceAssetGenerationFocusRequest, + ] = useState(0); + /** 提示条文案的 ref 版:有界兜底要判断此刻是否还停在中转文案上。 */ + const resourceWorkbenchNoticeRef = useRef(''); const [resourceBottomToolbarUploading, setResourceBottomToolbarUploading] = useState(false); const [resourcePanelNotice, setResourcePanelNotice] = useState(''); @@ -1587,6 +1659,13 @@ export default function ProjectDevelopmentView({ useState(false); const [resourceClassificationAssetId, setResourceClassificationAssetId] = useState(null); + /** + * 正在设置素材类型(功能分类)的素材;与 `resourceClassificationAssetId`(标签)分开持有: + * 两块面板是两个独立入口,一块开着不该把另一块的宿主状态也算成开着。 + */ + const [resourceTypeAssetId, setResourceTypeAssetId] = useState( + null, + ); /** 正在重命名的素材;改名沿用分类面板同一条 manifest 重载路径。 */ const [resourceRenameAssetId, setResourceRenameAssetId] = useState< string | null @@ -1824,6 +1903,14 @@ export default function ProjectDevelopmentView({ const resourceCanvasHostGenerationPanelOpen = resourceGenerationOpen || resourceAssetGenerationAction !== null; + /** + * 「编辑素材标签」与「设置素材类型」两块面板**共用一个宿主浮层判据**: + * 它们都是 portal 到 body 的模态浮层,任何一块开着,点外部清焦点与画布自己的 Esc + * 都必须让位。分开两个字段会让"只开其中一块"时漏掉一半判据。 + */ + const resourceClassificationOverlayOpen = + resourceClassificationAssetId !== null || resourceTypeAssetId !== null; + useImageCanvasFloatingOptionDismiss({ isOpen: resolveResourceCanvasFloatingPanelDismissOpen({ isCanvasVisible: mode === 'resources' && !uiEditorRoute, @@ -1831,7 +1918,7 @@ export default function ProjectDevelopmentView({ hostOverlay: { isResourcePanelOpen: resourcePanelOpen, isGenerationPanelOpen: resourceCanvasHostGenerationPanelOpen, - isClassificationPanelOpen: resourceClassificationAssetId !== null, + isClassificationPanelOpen: resourceClassificationOverlayOpen, isRenameDialogOpen: resourceRenameAssetId !== null, isRecoveryPanelOpen: resourceRecoveryPanelOpen, }, @@ -1858,7 +1945,7 @@ export default function ProjectDevelopmentView({ hostOverlay: { isResourcePanelOpen: resourcePanelOpen, isGenerationPanelOpen: resourceCanvasHostGenerationPanelOpen, - isClassificationPanelOpen: resourceClassificationAssetId !== null, + isClassificationPanelOpen: resourceClassificationOverlayOpen, isRenameDialogOpen: resourceRenameAssetId !== null, isRecoveryPanelOpen: resourceRecoveryPanelOpen, }, @@ -1878,7 +1965,8 @@ export default function ProjectDevelopmentView({ isResourceCanvasFloatingPanelOpen, mode, resourceCanvasHostGenerationPanelOpen, - resourceClassificationAssetId, + // 判据用的是合并后的开关:两块分类面板任一开着都算宿主浮层打开。 + resourceClassificationOverlayOpen, resourcePanelOpen, resourceRecoveryPanelOpen, resourceRenameAssetId, @@ -2369,7 +2457,8 @@ export default function ProjectDevelopmentView({ topology: resourceLayoutTopology, initializationReady: resourceGraphInitializationReady, renderFallbackWhileBlocked: resourceGraphFailed, - rederiveAutomaticPositions: resourceGraphReady, + // 画布不再自动重派生:新增素材只补位,整张重排只能由「整理画布」显式发起。 + rederiveAutomaticPositions: false, }); const typeLayout = useProjectResourceCanvasLayout({ projectPath, @@ -2377,8 +2466,38 @@ export default function ProjectDevelopmentView({ mode: 'type', resources: resourcesWithCanvasCardSize, initializationReady: true, - rederiveAutomaticPositions: true, + rederiveAutomaticPositions: false, }); + /** + * 依赖视图的自动重派生只做一次:关系图首次就绪、且这一侧的 sidecar 已经读完的那一刻。 + * + * 旧口径把 `rederiveAutomaticPositions` 长期等于 `resourceGraphReady`——只要关系图是 + * 现成的,任何一次资源协调签名变化(新增一张素材、改一个标签、拓扑重建)都会丢掉全部 + * 自动坐标重排整张画布。现在改成一次性:首次就绪时用显式重派生把坐标对齐到最终拓扑, + * 之后一律只补新卡。用 ref 记"这一次已经做过",而不是让布尔长期为真;判据也放在 + * `dependencyLayout.ready` 之后就绪,避免"关系图先就绪、sidecar 后读完"时把这一次重算 + * 白白吃掉。按项目作用域记账:切排序 tab 不会重新武装它。 + */ + const dependencyRederiveScopeRef = useRef(null); + const dependencyLayoutReady = dependencyLayout.ready; + const rederiveDependencyLayout = dependencyLayout.rederiveNow; + useEffect(() => { + if (!resourceGraphReady || !dependencyLayoutReady) { + return; + } + const scopeKey = JSON.stringify([projectPath, manifest.projectId]); + if (dependencyRederiveScopeRef.current === scopeKey) { + return; + } + dependencyRederiveScopeRef.current = scopeKey; + rederiveDependencyLayout(); + }, [ + dependencyLayoutReady, + manifest.projectId, + projectPath, + rederiveDependencyLayout, + resourceGraphReady, + ]); const activeResourceLayout = sortMode === 'dependency' ? dependencyLayout : typeLayout; const resourceLayout = activeResourceLayout.layout; @@ -2936,6 +3055,14 @@ export default function ProjectDevelopmentView({ : null, [manifest.assets, resourceClassificationAssetId], ); + const resourceTypeAsset = useMemo( + () => + resourceTypeAssetId + ? (manifest.assets.find((asset) => asset.id === resourceTypeAssetId) ?? + null) + : null, + [manifest.assets, resourceTypeAssetId], + ); const resourceRenameAsset = useMemo( () => resourceRenameAssetId @@ -4523,6 +4650,58 @@ export default function ProjectDevelopmentView({ ], ); + /** + * 把画布视口居中到某张资源卡:定位链在「选中了新资源」之后还必须让用户**看得见**它。 + * + * 这张画布是 transform 平移的,卡不在 DOM 滚动容器里,`scrollIntoView()` 只能碰到既有的 + * 带滚动条祖先,卡在视口外时它不会把卡带回来——所以视口必须由这里显式改。缩放保持不变 + * (只平移),与 `ensureResourceBookContentVisible` 的既有口径一致:定位不改用户的缩放预期。 + */ + const centerResourceCanvasOnResource = useCallback( + (resourceId: string) => { + const category: ResourceBookTarget | null = resourceBookOpensAllResources + ? RESOURCE_BOOK_ALL_TARGET + : activePageCategory; + if (!category || resourceBookView !== 'child') { + return; + } + const position = resourcePositionById.get(resourceId); + if (!position) { + return; + } + const cardSize = resourceCardSizeByResourceId.get(resourceId); + if (!cardSize) { + return; + } + const canvasSize = resourceCanvasElementSize(resourceCanvasRef.current); + if (canvasSize.width <= 0 || canvasSize.height <= 0) { + return; + } + const viewport = normalizeResourceBookViewport( + category === RESOURCE_BOOK_ALL_TARGET + ? resourceBookAllViewportRef.current + : resourceCanvasViewportRef.current, + ); + setResourceCanvasViewport(category, { + scale: viewport.scale, + x: + canvasSize.width / 2 - + (position.x + cardSize.width / 2) * viewport.scale, + y: + canvasSize.height / 2 - + (position.y + cardSize.height / 2) * viewport.scale, + }); + }, + [ + activePageCategory, + resourceBookOpensAllResources, + resourceBookView, + resourceCardSizeByResourceId, + resourcePositionById, + setResourceCanvasViewport, + ], + ); + useLayoutEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.code === 'Space' && !event.repeat) { @@ -4633,7 +4812,12 @@ export default function ProjectDevelopmentView({ passive: false, }); return () => manager.removeEventListener('wheel', handleResourceBookWheel); - }, [handleResourceBookWheel, mode]); + }, [ + handleResourceBookWheel, + mode, + // UI 编辑器会卸载整个资源 manager;返回时 ref 指向新节点,必须重新绑定原生 wheel。 + uiEditorRoute, + ]); const handleResourceBookMainPointerDown = useCallback( (event: ReactPointerEvent) => { @@ -5056,7 +5240,7 @@ export default function ProjectDevelopmentView({ resource.label, ...(result.asset.source.generationKind === 'ui-workflow.completed' ? { - initialStep: 'visual-binding', + initialStep: 'asset-separation', initialFurthestStepIndex: 2, } : {}), @@ -5094,7 +5278,7 @@ export default function ProjectDevelopmentView({ (asset) => asset.id === resource.manifestAssetId, )?.source.generationKind === 'ui-workflow.completed' ? { - initialStep: 'visual-binding' as const, + initialStep: 'asset-separation' as const, initialFurthestStepIndex: 2, } : {}), @@ -5103,6 +5287,72 @@ export default function ProjectDevelopmentView({ [advanceFocusGeneration, manifest.assets, openUiDesignEditor], ); + /** + * 新素材入库后自动聚焦:只认 `manifest.assets` 里**此前没见过**的 id。 + * + * 存量素材不是"刚生成":首次打开项目 / 切项目只登记基线,不然一进工作台就会跳到 + * 最后一张卡上。重命名不改 id,天然不触发。 + * + * 这里不自己造滚动或选中逻辑,只把意图交给既有的 `pendingResourceFocusRef` + + * `advanceFocusGeneration()` 裁决链:投影、两种布局落位、可见性、DOM 就绪都由那条链 + * 负责,被搜索条件挡住时也沿用现成的「清除搜索并定位」提示与动作。 + * + * 已有一条指向同一资源的聚焦意图时不再插手:显式生成链路(面板/工具栏生成)已经在 + * 提交时就挂好了意图,重复设置只会把它更精细的提示文案顶掉。 + */ + const seenManifestAssetIdsRef = useRef<{ + scopeKey: string; + ids: Set; + } | null>(null); + useEffect(() => { + const scopeKey = JSON.stringify([projectPath, manifest.projectId]); + const assetIds = manifest.assets.map((asset) => asset.id); + const previous = seenManifestAssetIdsRef.current; + if (!previous || previous.scopeKey !== scopeKey) { + seenManifestAssetIdsRef.current = { scopeKey, ids: new Set(assetIds) }; + return; + } + const addedIds = assetIds.filter((id) => !previous.ids.has(id)); + for (const id of assetIds) { + previous.ids.add(id); + } + if (addedIds.length === 0) { + return; + } + // 一次可能进多条(例如一次派生产出多个产物):聚焦清单里最后落地的那一条。 + const addedAssetId = addedIds[addedIds.length - 1]!; + const resourceId = `asset:${addedAssetId}`; + // 资源投影是同步的:这里查不到对应资源说明这条素材不会出现在画布上, + // 不为它挂聚焦意图,免得裁决链一直停在"等待投影"。 + if (!resources.some((resource) => resource.id === resourceId)) { + return; + } + if (pendingResourceFocusRef.current?.resourceId === resourceId) { + return; + } + const flowId = crypto.randomUUID(); + const focusGeneration = advanceFocusGeneration(); + activeFocusFlowIdRef.current = flowId; + pendingResourceFocusRef.current = { + flowId, + saveAttemptId: flowId, + sessionId: flowId, + draftId: flowId, + commitId: `asset-added:${addedAssetId}`, + projectPath, + projectId: manifest.projectId, + focusGeneration, + resourceId, + completed: false, + }; + }, [ + advanceFocusGeneration, + manifest.assets, + manifest.projectId, + projectPath, + resources, + ]); + useEffect(() => { if (uiEditorRoute) return; const completed = manifest.assets.find( @@ -5128,7 +5378,7 @@ export default function ProjectDevelopmentView({ resourceLabel: completed.localPath.split(/[\\/]/u).filter(Boolean).pop() ?? 'UI 设计资源', - initialStep: 'visual-binding', + initialStep: 'asset-separation', initialFurthestStepIndex: 2, }); }, [advanceFocusGeneration, manifest.assets, uiEditorRoute]); @@ -5540,6 +5790,9 @@ export default function ProjectDevelopmentView({ } if (focusedCommitIdsRef.current.has(intent.commitId)) { pendingResourceFocusRef.current = null; + // 这条资源已经聚焦过了:把中转提示一并收掉,否则「生成资源已保存,正在同步资源与布局…」 + // 这类文字会永久留在提示条上。 + setResourceWorkbenchNotice(''); return; } intent.completed = true; @@ -5547,22 +5800,62 @@ export default function ProjectDevelopmentView({ pendingResourceFocusRef.current = null; setResourceWorkbenchNotice(''); setHiddenCommittedResourceId(null); + // 既有的 scrollIntoView 只对带滚动条的祖先有效;这张画布靠 transform 平移,视口必须显式居中, + // 否则「定位过去了但素材还在屏幕外」——用户看到的仍是没定位。 + centerResourceCanvasOnResource(intent.resourceId); card.scrollIntoView?.({ block: 'nearest', inline: 'nearest' }); handleResourceSelect(intent.resourceId); }, [ activePageCategory, + centerResourceCanvasOnResource, dependencyLayout.layout.positions, dependencyLayout.settled, handleResourceSelect, manifest.projectId, projectPath, resources, + // 手动「定位到素材」不改画布任何状态,靠这个序号把「这次定位请求」变成真实的依赖变化; + // 少了它 effect 不会重跑,intent 永远没人消费。 + resourceAssetGenerationFocusRequest, selectResourceCanvasPage, typeLayout.layout.positions, typeLayout.settled, visibleResourceIds, ]); + /** + * 「生成任务」侧栏失去焦点即自动收起:点画布、点别的工具栏按钮、点对话框都算失去焦点。 + * + * 两处必须排除,否则会把用户刚做的动作吃掉: + * - 侧栏内部:点任务卡、点「定位到素材」都不能收起侧栏; + * - 工具条上那枚开合按钮:它自己负责 toggle,若在这里也被判成「点外部」就会先收起再被 toggle + * 打开,表现为按钮失灵。点它时这里的 pointerdown 直接放行,收尾交给它的 click。 + * + * 用 pointerdown 而不是 click:画布空白处的左键 pointerdown 会 `preventDefault()`,掐掉指针的 + * 兼容鼠标事件,document 上的 click 收不到那一次点击。 + */ + useEffect(() => { + if (!resourceAssetGenerationTasksPanelOpen) { + return undefined; + } + const handlePointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + if ( + target.closest('.game-resource-generation-tasks-sidebar') || + target.closest('[data-resource-generation-task-toggle]') + ) { + return; + } + setResourceAssetGenerationTasksPanelOpen(false); + }; + document.addEventListener('pointerdown', handlePointerDown, true); + return () => + document.removeEventListener('pointerdown', handlePointerDown, true); + }, [resourceAssetGenerationTasksPanelOpen]); + /** * 切换当前版本只改「哪个版本是当前版本」这条记录层状态,并让运行模块重新加载当前预览。 * @@ -6640,94 +6933,397 @@ export default function ProjectDevelopmentView({ ); /** - * 工具栏图片类入口的生成:`generate_local_project_asset`。 + * 生成任务的宿主上下文快照。 * - * 收口口径与既有音频入口一致:结果落盘后由「配对读」取回权威 (revision, 清单) 交给 - * `onManifestChange`,走既有 manifest 刷新与资源投影链路;再用 `pendingResourceFocusRef` - * 定位新卡。这里不重算依赖图、不另写布局逻辑。 - * - * 失败后不清空面板:用户可以用同一份输入直接重试(本地命令没有请求身份,前端不假装 - * 它能幂等重放)。 + * 队列实例与提交回调都要跨渲染保持同一份身份(队列的「已有在途任务」标记是它的内部状态), + * 所以项目路径 / 清单回调 / 规范图判据只能从 ref 读当前值,不能被闭包冻在某一帧。 */ - const submitResourceAssetGeneration = useCallback( - async (input: ResourceCanvasAssetGenerationSubmitInput) => { - const action = resourceAssetGenerationAction; - if (!action) { + const resourceAssetGenerationContextRef = useRef({ + projectPath, + projectId: manifest.projectId, + hasIconSpecReference, + onManifestChange, + manifest, + resources, + activePageCategory, + }); + resourceAssetGenerationContextRef.current = { + projectPath, + projectId: manifest.projectId, + hasIconSpecReference, + onManifestChange, + manifest, + resources, + activePageCategory, + }; + resourceWorkbenchNoticeRef.current = resourceWorkbenchNotice; + /** 入口按钮与折叠把手上显示的在途数量:只数当前项目的未终态任务。 */ + const resourceAssetGenerationInFlightCount = + resourceAssetGenerationTasks.filter( + (task) => + task.projectId === manifest.projectId && + !resourceCanvasAssetGenerationTaskIsTerminal(task), + ).length; + + const replaceResourceAssetGenerationTask = useCallback( + (next: ResourceCanvasAssetGenerationTask) => { + const current = resourceAssetGenerationTasksRef.current; + const nextTasks = current.some((task) => task.taskId === next.taskId) + ? current.map((task) => (task.taskId === next.taskId ? next : task)) + : [...current, next]; + resourceAssetGenerationTasksRef.current = nextTasks; + setResourceAssetGenerationTasks(nextTasks); + }, + [], + ); + + /** + * 一条生成任务收尾后的宿主动作:成功落卡 / 失败给提示 / 即时失败把提交面板带回来。 + * + * 后端把生成结果与 manifest 登记都写完才把记录置为终态,所以这里只做「配对读 + 交给 + * `onManifestChange`」这条既有链路,再用 `pendingResourceFocusRef` 定位新卡;不重算依赖图、 + * 不另写布局逻辑。 + * + * 失败分两类:**后端从未受理**(`record === null`,且这次点击本来就该立刻派发)→ 把提交面板 + * 连草稿一起带回来,错误留在面板里;**受理之后才失败**(生成中失败 / 远端失败 / 轮询超时)→ + * 不重开面板,只在「生成任务」侧栏收口为失败并给一次提示条。 + */ + const handleResourceAssetGenerationSettlement = useCallback( + async (settlement: ResourceCanvasAssetGenerationSettlement) => { + const context = resourceAssetGenerationContextRef.current; + if (settlement.projectId !== context.projectId) { + // 任务可以在切换项目之后才收尾:这时候拿当前项目的路径去刷新清单是错的, + // 账本已经把结果写在它自己的项目里,这里不再动当前项目的状态。 return; } + const submission = resourceAssetGenerationPanelSubmissionRef.current; + if (submission?.taskId === settlement.taskId) { + resourceAssetGenerationPanelSubmissionRef.current = null; + if ( + settlement.status === 'failed' && + settlement.record === null && + submission.dispatchedImmediately + ) { + setResourceAssetGenerationPanelReopen({ + actionId: submission.action.id, + draft: submission.draft, + error: settlement.error ?? '生成素材失败', + }); + setResourceAssetGenerationAction(submission.action); + setResourceWorkbenchNotice(''); + return; + } + } + if (settlement.status !== 'completed' || !settlement.record?.assetId) { + setResourceWorkbenchNotice( + `生成素材失败:${settlement.error ?? '未知原因'}`, + ); + return; + } + const assetId = settlement.record.assetId; const invoke = window.__TAURI__?.core?.invoke; if (!invoke) { - throw new Error('生成素材需要在客户端内执行'); + setResourceWorkbenchNotice('生成结果需要在客户端内读取'); + return; + } + let fresh: Awaited< + ReturnType + > = null; + try { + fresh = await rereadAuthoritativeProjectManifestSnapshot({ + projectPath: context.projectPath, + projectId: context.projectId, + readRevision: async () => + ( + await invoke<{ revision: number }>( + 'get_local_game_project_revision', + { projectPath: context.projectPath }, + ) + ).revision, + readManifest: () => + invoke('get_local_game_manifest', { + projectPath: context.projectPath, + commandId: 'asset.list', + }), + }); + } catch (error) { + setResourceWorkbenchNotice( + error instanceof Error ? error.message : String(error), + ); + return; } - const flowId = crypto.randomUUID(); - const projectId = manifest.projectId; - const result = await withPlatformSessionRefresh(() => - invoke( - 'generate_local_project_asset', - { - projectPath, - kind: input.kind, - prompt: input.prompt, - aspectRatio: input.aspectRatio, - imageSize: input.imageSize, - assetName: input.assetName, - outputPath: resourceCanvasAssetGenerationOutputPath( - action, - hasIconSpecReference, - ), - }, - ), - ); - const fresh = await rereadAuthoritativeProjectManifestSnapshot({ - projectPath, - projectId, - readRevision: async () => - ( - await invoke<{ revision: number }>( - 'get_local_game_project_revision', - { projectPath }, - ) - ).revision, - readManifest: () => - invoke('get_local_game_manifest', { - projectPath, - commandId: 'asset.list', - }), - }); if (!fresh) { - throw new Error( + setResourceWorkbenchNotice( '生成结果已落盘,但清单与版本号未能配对读回,请重新打开项目后确认', ); + return; } - onManifestChange?.(projectPath, fresh.manifest, { + context.onManifestChange?.(context.projectPath, fresh.manifest, { projectId: fresh.projectId, revision: fresh.revision, source: fresh.source, - commitId: result.id, + commitId: assetId, }); + const flowId = `asset-generation:${settlement.taskId}`; activeFocusFlowIdRef.current = flowId; pendingResourceFocusRef.current = { flowId, - saveAttemptId: result.id, - sessionId: result.id, - draftId: result.id, - commitId: result.id, - projectPath, + saveAttemptId: assetId, + sessionId: assetId, + draftId: assetId, + commitId: assetId, + projectPath: context.projectPath, projectId: fresh.projectId, focusGeneration: focusGenerationRef.current, - resourceId: `asset:${result.id}`, + resourceId: `asset:${assetId}`, completed: false, }; setResourceWorkbenchNotice('生成资源已保存,正在同步资源与布局…'); - setResourceAssetGenerationAction(null); }, - [ - hasIconSpecReference, - manifest.projectId, - onManifestChange, - projectPath, - resourceAssetGenerationAction, - ], + [], + ); + + /** + * 生成任务队列:本地排队 + 后端账本轮询的唯一驱动器。 + * + * 队列实例跨渲染保持同一份,「已有在途任务」这个标记才不会因为一次渲染就丢掉。 + */ + const resourceAssetGenerationQueueRef = + useRef(null); + if (resourceAssetGenerationQueueRef.current === null) { + resourceAssetGenerationQueueRef.current = + createResourceCanvasAssetGenerationQueue({ + invoke: (command, args) => { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + return Promise.reject(new Error('生成素材需要在客户端内执行')); + } + return invoke(command, args); + }, + projectPath: () => + resourceAssetGenerationContextRef.current.projectPath, + refreshPlatformSession: withPlatformSessionRefresh, + listTasks: () => + resourceAssetGenerationTasksRef.current.filter( + (task) => + task.projectId === + resourceAssetGenerationContextRef.current.projectId, + ), + replaceTask: (next) => replaceResourceAssetGenerationTask(next), + onSettled: (settlement) => + handleResourceAssetGenerationSettlement(settlement), + }); + } + + /** + * 提交一条图片类生成任务。 + * + * 只做「入队」,生成由队列在后台派发与轮询。**同步返回**:提交面板在点击那一刻就自己关掉了, + * 不等受理、不等排队、不等生成;只有后端从未受理的即时失败才会由收尾回调把面板连草稿一起带回来。 + */ + const submitResourceAssetGeneration = useCallback( + ( + action: ResourceCanvasAssetToolAction, + input: ResourceCanvasAssetGenerationSubmitInput, + ) => { + const queue = resourceAssetGenerationQueueRef.current; + const context = resourceAssetGenerationContextRef.current; + if (!queue) { + setResourceWorkbenchNotice( + '生成任务队列尚未就绪,请重新打开项目后重试', + ); + return; + } + // 「这次点击本来就该立刻派发」:队列里没有在途任务时才是。排在队列后面才派发的任务即使 + // 提交失败,也不该把面板弹回来打断用户。 + const dispatchedImmediately = + !resourceAssetGenerationTasksRef.current.some( + (task) => + task.dispatched && + !resourceCanvasAssetGenerationTaskIsTerminal(task), + ); + const task = createResourceCanvasAssetGenerationTask({ + taskId: crypto.randomUUID(), + action, + prompt: input.prompt, + assetName: input.assetName, + aspectRatio: input.aspectRatio, + imageSize: input.imageSize, + outputPath: resourceCanvasAssetGenerationOutputPath( + action, + context.hasIconSpecReference, + ), + projectId: context.projectId, + nowMillis: Date.now(), + }); + resourceAssetGenerationPanelSubmissionRef.current = { + taskId: task.taskId, + action, + draft: { + prompt: input.prompt, + assetName: input.assetName, + aspectRatio: input.aspectRatio, + imageSize: input.imageSize, + }, + dispatchedImmediately, + }; + setResourceAssetGenerationPanelReopen(null); + setResourceAssetGenerationTasksPanelOpen(true); + setResourceWorkbenchNotice( + `已提交「${input.assetName}」,生成在后台继续,进度见「生成任务」`, + ); + // 终局由 `onSettled` 收口(成功落卡 / 失败收口 / 即时失败重开面板),这里只吞掉拒绝, + // 避免出现未处理的 Promise 拒绝。 + void queue.submit(task).catch(() => undefined); + }, + [], + ); + + /** + * 重开项目时恢复项目内的任务账本。 + * + * 账本落在项目内的 `.agent/runtime/asset-generation-tasks/`,所以历史任务(含上次运行中断 + * 的那些)在这里回到列表;后端已经把没人推进的记录收口为失败,前端不假装它还在跑。 + * + * 读不到账本(旧壳没有这条命令 / 权限拒绝 / 文件读坏 / 返回了非数组)时**保留本地列表**并把 + * 提示落到既有提示条上:静默返回会让用户以为「历史生成任务都不见了」,而抛出去会变成这个 + * effect 的未处理 Promise 拒绝。 + */ + useEffect(() => { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke || !projectPath.trim() || !manifest.projectId.trim()) { + return undefined; + } + const projectId = manifest.projectId; + let cancelled = false; + setResourceAssetGenerationTasksPanelOpen(false); + resourceAssetGenerationPanelSubmissionRef.current = null; + setResourceAssetGenerationPanelReopen(null); + void (async () => { + const reportUnavailable = () => { + if (!cancelled) { + setResourceWorkbenchNotice( + '生成任务列表读取失败,暂时无法恢复历史任务', + ); + } + }; + let records: LocalProjectAssetGenerationTaskRecord[]; + try { + const response = await invoke( + 'list_local_project_asset_generations', + { projectPath }, + ); + if (!Array.isArray(response)) { + reportUnavailable(); + return; + } + records = response; + } catch { + reportUnavailable(); + return; + } + if (cancelled) { + return; + } + const restored = mergeResourceCanvasAssetGenerationTasksWithRecords( + resourceAssetGenerationTasksRef.current.filter( + (task) => task.projectId === projectId, + ), + records, + ); + resourceAssetGenerationTasksRef.current = restored; + setResourceAssetGenerationTasks(restored); + })(); + return () => { + cancelled = true; + }; + }, [manifest.projectId, projectPath]); + + /** + * 「生成任务」面板里点一条已完成任务:复用既有聚焦链定位到它的素材卡。 + * + * **每次点击都必须终局化**:素材不在投影里 / 不在当前栏目 / 被搜索挡住 / 画布还在布局,四种情况 + * 各有结论,不允许留下悬而未决的 intent 与中转提示。所以这里做三件事: + * + * 1. 先按当前投影与清单判一次「这次点击有没有可定位的目标」——没有就直接给可执行结论, + * 连 intent 都不挂(挂上去也没人消费); + * 2. 有目标就挂 intent,并推进 `resourceAssetGenerationFocusRequest`:聚焦 effect 的依赖全是 + * 画布自身状态,不推进这个序号时 effect 不会重跑,点了等于没点(这正是「点了没反应且提示条 + * 永久停在中转文案」的根因); + * 3. 起一个有界兜底:3 秒后仍停在中转文案就收口成可执行提示,绝不把中转态留给用户。 + */ + const focusResourceAssetGenerationTask = useCallback( + (task: ResourceCanvasAssetGenerationTask) => { + const assetId = task.assetId; + if (!assetId) { + return; + } + const context = resourceAssetGenerationContextRef.current; + const resourceId = `asset:${assetId}`; + const target = context.resources.find( + (resource) => resource.id === resourceId, + ); + const locateNotice = '正在定位生成的素材…'; + if (!target) { + // 不在投影里:还没同步到画布,或者素材已经不在项目里。两种都当场给结论, + // 不放 intent 也不留中转提示。 + pendingResourceFocusRef.current = null; + setResourceWorkbenchNotice( + (context.manifest.assets ?? []).some((asset) => asset.id === assetId) + ? '素材已登记但尚未同步到画布,请稍候重试' + : '素材已不在项目里(可能已被删除)', + ); + return; + } + // 手动定位不能复用自动落卡那条 commitId:`focusedCommitIdsRef` 会把同一个 commitId 记为 + // 「已聚焦」,重复点同一条任务就会静默失效,所以这里用一次一点击的 flowId。 + const flowId = `asset-generation-focus:${task.taskId}:${Date.now()}`; + activeFocusFlowIdRef.current = flowId; + pendingResourceFocusRef.current = { + flowId, + saveAttemptId: assetId, + sessionId: assetId, + draftId: assetId, + commitId: flowId, + projectPath: context.projectPath, + projectId: context.projectId, + focusGeneration: focusGenerationRef.current, + resourceId, + completed: false, + }; + if (target.category !== context.activePageCategory) { + // 素材在别的栏目:先切过去(切栏目本身就是 effect 的依赖变化),再让聚焦链在那边定位。 + selectResourceCanvasPage(target.category); + } + setResourceWorkbenchNotice(locateNotice); + setResourceAssetGenerationFocusRequest((current) => current + 1); + window.setTimeout(() => { + if (focusedCommitIdsRef.current.has(flowId)) { + // 真的聚焦过了。 + return; + } + const pending = pendingResourceFocusRef.current; + if (pending?.flowId === flowId) { + if (resourceWorkbenchNoticeRef.current !== locateNotice) { + // 聚焦链已经给出别的结论(例如「被当前搜索条件隐藏」+「清除搜索并定位」)。 + return; + } + pendingResourceFocusRef.current = null; + setResourceWorkbenchNotice( + '未能定位到素材:画布可能仍在布局或素材暂不可见,请稍后重试', + ); + return; + } + if (resourceWorkbenchNoticeRef.current === '') { + // intent 被判 invalid(项目 / 画布已切换)时聚焦链会清掉 intent 与提示:手动点击 + // 不能静默丢弃,给一条能解释「为什么没动」的结论。 + setResourceWorkbenchNotice( + '定位请求已失效(项目或画布已切换),请重新点击定位', + ); + } + }, 3_000); + }, + [selectResourceCanvasPage], ); /** 工具栏「上传」:与资源面板上传同一条「上传 + 配对读清单」链路。 */ @@ -6907,6 +7503,31 @@ export default function ProjectDevelopmentView({ [], ); + /** + * 「生成任务」开合入口。**两个页签下都常驻**:侧栏本体是无条件渲染的非模态浮层,运行态一样 + * 可见,入口若只在资源页签,用户切到运行后关掉侧栏就再也打不开了。资源页签里它排在 + * 「依赖 / 类型」排列方式之前(动作在前、排列方式收在行尾),所以这里做成一个可复用的元素, + * 由两处分支各自渲染一次——同一时刻只渲染一处,可访问名唯一。 + */ + const generationTasksEntry = ( + + ); + if (planningStartMode) { return (
-
- - -
-
+ {/* + 「资源管理 / 运行」与「播放」是同一组左侧控件:播放紧贴模式切换之后、整组左对齐, + 不再居中悬浮。**两个页签下都常驻**——运行视图空态与预览失败态都要靠它重跑 + (空态文案就是「点击顶部播放按钮后将在这里直接运行游戏」)。 + */} +
+
+ + +
+
+
{mode === 'run' && embeddedPreviewUrl ? ( {pendingResourceEdits.length > 0 || pendingResourceEditsLoadState === 'failed' ? (
{showRunUnavailableHint ? ( @@ -7263,6 +7929,21 @@ export default function ProjectDevelopmentView({ 编辑标签 ) : null} + {selectedResource?.manifestAssetId ? ( + } + onClick={() => + setResourceTypeAssetId( + selectedResource.manifestAssetId, + ) + } + > + 素材类型 + + ) : null} {selectedResource?.manifestAssetId ? ( { + const assetId = + selectedResource.manifestAssetId; + if (!assetId) return; + setResourceInfoPanelOpen(false); + setResourceTypeAssetId(assetId); + } + : undefined + } onClose={() => setResourceInfoPanelOpen(false)} /> ) : null} @@ -8031,11 +8729,50 @@ export default function ProjectDevelopmentView({ ) : null} {resourceAssetGenerationAction ? ( setResourceAssetGenerationAction(null)} + draft={ + resourceAssetGenerationPanelReopen?.actionId === + resourceAssetGenerationAction.id + ? resourceAssetGenerationPanelReopen.draft + : undefined + } + error={ + resourceAssetGenerationPanelReopen?.actionId === + resourceAssetGenerationAction.id + ? resourceAssetGenerationPanelReopen.error + : null + } + onSubmit={(input) => + submitResourceAssetGeneration(resourceAssetGenerationAction, input) + } + onClose={() => { + setResourceAssetGenerationPanelReopen(null); + setResourceAssetGenerationAction(null); + }} /> ) : null} + {/* + 「生成任务」侧栏:常驻、可折叠、非模态。它**不**参与 `isResourceCanvasFloatingPanelOpen` + 的模态遮挡判据——生成在后台跑,侧栏展开时画布必须照样能看能用;折叠只影响这个视图, + 任务本身活在账本与本地队列里。 + */} + task.projectId === manifest.projectId, + )} + open={resourceAssetGenerationTasksPanelOpen} + onToggleOpen={() => + setResourceAssetGenerationTasksPanelOpen((current) => !current) + } + onFocusTask={focusResourceAssetGenerationTask} + /> {resourcePanelOpen ? ( void handleResourceClassificationSaved(result)} /> ) : null} + {/* + 「设置素材类型」:与标签面板并列的独立入口(素材类型 = 功能分类)。 + 面板自己是"选中即落盘",宿主这里负责保存成功后收起面板 —— 用户必须马上在画布上 + 看到卡片落进新栏目,而不是一块挡着画布的浮层。`reloadManifestAfterAssetCommand` + 的默认分支不认这个 state,所以关闭动作显式放在 `onSaved` 里,不靠隐式副作用。 + */} + {resourceTypeAsset ? ( + setResourceTypeAssetId(null)} + onSaved={(result) => { + setResourceTypeAssetId(null); + void reloadManifestAfterAssetCommand( + result.committedProjectRevision, + `asset-type:${result.asset.id}`, + ); + }} + /> + ) : null} {/* 删除素材的二次确认面板:资源卡选中工具条删素材时用,与「编辑素材标签」面板曾用的 是同一个 `ResourceAssetDeleteDialog` 与同一条删除流程(见 `useResourceAssetDeleteFlow`)。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceAssetDisplayName.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceAssetDisplayName.ts new file mode 100644 index 000000000..a31f5c1a2 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceAssetDisplayName.ts @@ -0,0 +1,12 @@ +/** + * 素材名取 `localPath` 的 basename:manifest 资产没有独立的显示名字段, + * 与资源卡、`@` 面板的显示口径一致。 + * + * 「编辑素材标签」与「设置素材类型」两块面板共用这一份 —— 副标题是同一个素材名, + * 两处各写一份 basename 实现迟早会出现一个带目录、一个不带。 + */ +export function resourceAssetDisplayName(localPath: string) { + const normalized = localPath.replaceAll('\\', '/'); + const segments = normalized.split('/'); + return segments[segments.length - 1] || localPath; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts index 006048f62..8791b8d25 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceCardPreviewModel.ts @@ -43,6 +43,19 @@ export type ProjectResourceCardPreviewPayload = { byteLen: number; pixelWidth?: number; pixelHeight?: number; + /** + * 这张图是否**真的**带 alpha 通道(原生侧头部级判据,见 `image_inspect.rs` 的 + * `detect_raster_image_has_alpha`):PNG 颜色类型 4/6 或 `tRNS`、WebP 的 alpha 标志为 true; + * JPEG 恒 false。 + * + * 资源卡的棋盘格底只按它铺(`data-preview-has-alpha='true'`),不再按「预览分支是图片」 + * 无条件铺 —— 否则 AI 把棋盘格画进像素里的不透明图会与卡面棋盘格叠在一起, + * 验收时无法区分「真透明底」与「假棋盘格」。 + * + * 只有 `read_local_project_image_preview` 这条图像读取链路会给出该字段;文本 / 媒体预览的 + * payload 没有它(`undefined`),必须与 `false` 同档处理:不知道就不铺棋盘格。 + */ + hasAlpha?: boolean; sourceUrl?: string; content?: string; }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts index 2a2444f4b..0a7a3c789 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts @@ -67,6 +67,15 @@ type ResourceLayoutWriteIntent = { scopeEpoch: number; resourceSignature: string; conflictRetries: number; + /** + * 显式「整理画布」写意图:这一笔写回按 `rederive` 策略丢掉全部自动坐标重算。 + * + * 仍然是"坐标真的变了才落盘"(沿用既有 `changed` 门):已经整齐的画布按一下不该产生 + * 一次无意义的 CAS / revision 推进,也不该在关系图被截断这类"重算结果同样可信但不能 + * 声称变过"的场景里凭空写一笔。自动(签名变化触发)的资源同步永远是 `false`, + * 只补新卡、不动既有坐标。 + */ + rederive: boolean; }; type LayoutWriteIntent = ManualLayoutWriteIntent | ResourceLayoutWriteIntent; @@ -74,9 +83,10 @@ type LayoutWriteIntent = ManualLayoutWriteIntent | ResourceLayoutWriteIntent; const MAX_RESOURCE_SYNC_CONFLICT_RETRIES = 2; /** - * 自动坐标策略。`rederive` 在每次协调时丢弃全部自动坐标并按当前资源与拓扑重算, - * PRD 要求的「关系图首次就绪 / `dependencyDepth` / 拓扑身份签名变化后按最终拓扑 - * 重算」依赖它;`preserve` 只补新资源 ID,不重排任何已存在的坐标。 + * 自动坐标策略。`rederive` 丢掉全部自动坐标、按当前资源与拓扑重算(手动坐标原样保留); + * 它现在只由显式动作触发——用户的「整理画布」按钮,以及「关系图首次就绪」那一次 + * `rederiveNow()`。`preserve` 只补新资源 ID,不重排任何已存在的坐标,是画布默认口径: + * 新增一张素材不再牵动整张画布。 */ type AutomaticPositionPolicy = 'rederive' | 'preserve'; @@ -532,6 +542,7 @@ export function useProjectResourceCanvasLayout({ scopeEpoch, resourceSignature: signature, conflictRetries, + rederive: false, }); } if (mountedRef.current) { @@ -565,7 +576,9 @@ export function useProjectResourceCanvasLayout({ const writePolicy = intent.kind === 'manual' ? MANUAL_WRITE_AUTOMATIC_POSITION_POLICY - : automaticPositionPolicy(rederiveAutomaticPositions); + : intent.rederive + ? 'rederive' + : automaticPositionPolicy(rederiveAutomaticPositions); const reconciled = reconcileLayout( persistedLayoutRef.current, resourcesRef.current, @@ -686,6 +699,9 @@ export function useProjectResourceCanvasLayout({ if (intent.kind === 'manual') { redragRequiredScopeEpochRef.current = null; setNotice('布局已保存'); + } else if (intent.rederive) { + // 显式整理复用同一条提示:用户按了按钮,就必须看到"这次重算真的落盘了"。 + setNotice('布局已保存'); } if (needsResourceSync) { enqueueResourceSyncRef.current(currentScope.epoch); @@ -1004,6 +1020,48 @@ export function useProjectResourceCanvasLayout({ [applyLayout, initializationReady, scopeKey], ); + /** + * 显式「整理画布」:丢掉全部自动坐标、按当前资源与拓扑重算一次;坐标确有变化时写回 + * sidecar(沿用既有 `changed` 门),画布本身先乐观按重算结果显示。 + * + * 这是整张画布重排的唯一入口——画布不再因为资源协调签名变化自动重派生。走的是与自动 + * 同步同一条写队列与写回链路,只把策略换成 `rederive`;用户可见反馈仍由既有的 + * `notice` / `saving` 状态位承担,不另造一套状态。 + */ + const rederiveNow = useCallback(() => { + const scope = scopeRef.current; + if ( + scope.key !== scopeKey || + !initializationReady || + initializedScopeEpochRef.current !== scope.epoch + ) { + return; + } + const queued = writeQueueRef.current.find( + (intent): intent is ResourceLayoutWriteIntent => + intent.kind === 'resources' && + intent.scopeEpoch === scope.epoch && + intent.rederive && + intent !== activeWriteIntentRef.current, + ); + if (queued) { + queued.resourceSignature = resourceSignatureRef.current; + queued.conflictRetries = 0; + } else { + writeQueueRef.current.push({ + kind: 'resources', + scopeEpoch: scope.epoch, + resourceSignature: resourceSignatureRef.current, + conflictRetries: 0, + rederive: true, + }); + } + // 乐观视图:立刻把重算结果显示出来,别让用户以为按钮没反应。 + rebuildOptimisticLayout(scope.epoch, 'rederive'); + setSaving(true); + pumpWritesRef.current(); + }, [initializationReady, rebuildOptimisticLayout, scopeKey]); + const scopeMatches = initializationReady && scopeRef.current.key === scopeKey && @@ -1035,5 +1093,6 @@ export function useProjectResourceCanvasLayout({ readReport, scopeIdentity: scopeKey, commitPosition, + rederiveNow, }; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts index 94d17a065..06309ad28 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCardPreviews.ts @@ -164,6 +164,7 @@ function materializeProjectResourceCardPreview( byteLen: transport.byteLen, pixelWidth: transport.pixelWidth, pixelHeight: transport.pixelHeight, + hasAlpha: transport.hasAlpha, content: transport.content, }, retainedBytes: @@ -200,6 +201,8 @@ function materializeProjectResourceCardPreview( byteLen: transport.byteLen, pixelWidth: imageDimensions?.pixelWidth, pixelHeight: imageDimensions?.pixelHeight, + // 头部级 alpha 判据随图像预览 payload 一起透传:卡面棋盘格底只认它。 + hasAlpha: transport.hasAlpha, sourceUrl: objectUrl, }, retainedBytes: blob.size, diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/ImportOverview.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/ImportOverview.tsx index cd76e9fec..0823299bd 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/ImportOverview.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/ImportOverview.tsx @@ -6,7 +6,7 @@ import type { UITree } from '../../../features/ui-editor/types/UITree'; function countUiComponents(nodes: UiNode[]): number { return nodes.reduce( (total, node) => - total + node.components.length + countUiComponents(node.children), + total + (node.component ? 1 : 0) + countUiComponents(node.children), 0, ); } diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx index 495db5bd9..b473accb2 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/InputSidebar.tsx @@ -62,12 +62,12 @@ export function InputSidebar({ input }: { input: UiEditorInputProjection }) { name: 'UI Trees', description: '', layout_status: 'NoProblem', - components_status: 'NoProblem', + component_status: 'NoProblem', allow_llm_edit_layout: false, allow_llm_edit_component: false, source: 'System', }, - components: [], + component: null, children_display_mode: 'Stack', children: uiTrees.map((tree) => tree.root), }; diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentPanel.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentPanel.tsx index ab030488d..c1b9bcee7 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentPanel.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/ComponentPanel.tsx @@ -1,13 +1,7 @@ -import { - ArrowDown, - ArrowUp, - ChevronDown, - ChevronRight, - Plus, - Trash2, -} from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { ChevronDown, ChevronRight, Plus, Trash2 } from 'lucide-react'; +import { type ReactNode, useEffect, useRef, useState } from 'react'; +import { ThemedModal } from '../../../../../components/modal/ThemedModal'; import type { Component } from '../../../../../features/ui-editor/types/Component'; import type { UiEditorOperationResult } from '../../../../../features/ui-editor/useUiEditorState'; import { useInspectorReadOnly } from '../InspectorReadOnlyContext'; @@ -18,111 +12,60 @@ import { TextPanel } from './TextPanel'; import { createDefaultTextComponent } from './TextPanelDefaults'; export function ComponentPanel(props: ComponentPanelProps) { - const { - components, - readOnly: propReadOnly, - onSetComponents, - onInsertComponent, - onDeleteComponent, - onMoveComponent, - } = props; + const { component, readOnly: propReadOnly, onSetComponent } = props; const inspectorReadOnly = useInspectorReadOnly(); const readOnly = propReadOnly || inspectorReadOnly; const [addKind, setAddKind] = useState<'Image' | 'Text'>('Image'); - const [expandedIndexes, setExpandedIndexes] = useState>( - () => new Set(), - ); + const [expanded, setExpanded] = useState(Boolean(component)); const [error, setError] = useState(null); + const [replaceConfirmationOpen, setReplaceConfirmationOpen] = useState(false); + const [removeConfirmationOpen, setRemoveConfirmationOpen] = useState(false); + const previousComponentRef = useRef(component); useEffect(() => { - setExpandedIndexes((current) => { - const next = new Set( - [...current].filter((index) => index >= 0 && index < components.length), - ); - if (next.size === current.size) return current; - return next; - }); - }, [components.length]); + const previous = previousComponentRef.current; + previousComponentRef.current = component; + if (Boolean(component) !== Boolean(previous)) { + setExpanded(Boolean(component)); + } + }, [component]); - const updateComponent = (index: number, next: Component) => { + function setComponent(next: Component | null) { if (readOnly) return undefined; - const nextComponents = components.slice(); - nextComponents[index] = next; - const result = onSetComponents(nextComponents); - if (result && !result.ok) setError('组件字段无效,更新未应用。'); + const result = onSetComponent(next); + if (result && !result.ok) setError('组件更新失败。'); else setError(null); return result; - }; + } - function addComponent() { - if (readOnly) return; - let component: Component; - switch (addKind) { - case 'Image': - component = { Image: createDefaultImageComponent() }; - break; - case 'Text': - component = { Text: createDefaultTextComponent() }; - break; + function createComponent(): Component { + return addKind === 'Image' + ? { Image: createDefaultImageComponent() } + : { Text: createDefaultTextComponent() }; + } + + function replaceComponent() { + if (component) { + setReplaceConfirmationOpen(true); + return; } - const result = onInsertComponent(components.length, component); + applyReplacement(); + } + + function applyReplacement() { + const result = setComponent(createComponent()); if (result?.ok) { - setExpandedIndexes((current) => new Set(current).add(components.length)); - setError(null); - } else if (result) { - setError('组件新增失败。'); + setExpanded(true); + setReplaceConfirmationOpen(false); + } else if (result !== undefined) { + setReplaceConfirmationOpen(false); } } - function deleteComponentAt(index: number) { - if (readOnly) return; - const result = onDeleteComponent(index); - if (result?.ok) { - setExpandedIndexes((current) => { - const next = new Set(); - for (const expanded of current) { - if (expanded === index) continue; - if (expanded > index) next.add(expanded - 1); - else next.add(expanded); - } - return next; - }); - setError(null); - } else if (result) { - setError('组件删除失败。'); - } - } - - function moveComponent(index: number, direction: 'up' | 'down') { - if (readOnly) return; - // Components are rendered in array order. The last item therefore sits - // visually at the top of the stack. - let nextIndex: number; - switch (direction) { - case 'up': - nextIndex = index + 1; - break; - case 'down': - nextIndex = index - 1; - break; - } - if (nextIndex < 0 || nextIndex >= components.length) return; - const result = onMoveComponent(index, nextIndex); - if (result?.ok) { - setExpandedIndexes((current) => { - const next = new Set(current); - const wasCurrentExpanded = next.has(index); - const wasTargetExpanded = next.has(nextIndex); - next.delete(index); - next.delete(nextIndex); - if (wasCurrentExpanded) next.add(nextIndex); - if (wasTargetExpanded) next.add(index); - return next; - }); - setError(null); - } else if (result) { - setError('组件顺序更新失败。'); - } + function removeComponent() { + const result = setComponent(null); + if (result?.ok) setRemoveConfirmationOpen(false); + else if (result !== undefined) setRemoveConfirmationOpen(false); } return ( @@ -130,7 +73,7 @@ export function ComponentPanel(props: ComponentPanelProps) {

组件

- {components.length} 个 + {component ? componentKind(component) : '无'}
@@ -142,7 +85,7 @@ export function ComponentPanel(props: ComponentPanelProps) { onChange={(event) => setAddKind(event.target.value as 'Image' | 'Text') } - aria-label="新增组件类型" + aria-label="组件类型" > @@ -151,169 +94,166 @@ export function ComponentPanel(props: ComponentPanelProps) { type="button" className="flex h-8 items-center gap-1 rounded-lg bg-blue-600 px-3 text-xs font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40" disabled={readOnly} - onClick={addComponent} - aria-label="新增组件" - title="新增组件" + onClick={replaceComponent} + aria-label={component ? '替换组件' : '新增组件'} + title={component ? '替换组件' : '新增组件'} > - {componentKindLabel(addKind)} + {component ? '替换' : '新增'}
- {components.length > 0 && ( -
- {[...components].reverse().map((component, reverseIndex) => { - const index = components.length - reverseIndex - 1; - const expanded = expandedIndexes.has(index); - return ( -
-
- - - - -
- {expanded && ( -
- {renderComponentEditor( - component, - index, - props, - readOnly, - updateComponent, - )} -
- )} -
- ); - })} + {component ? ( +
+
+ + +
+ {expanded && ( +
+ {renderComponentEditor(component, props, readOnly, setComponent)} +
+ )}
- )} - {components.length === 0 && ( + ) : (

当前节点没有组件。

)} {error &&

{error}

} + setReplaceConfirmationOpen(false)} + onConfirm={applyReplacement} + /> + setRemoveConfirmationOpen(false)} + onConfirm={removeComponent} + />
); } +function ConfirmationModal({ + open, + ariaLabel, + title, + description, + confirmLabel, + confirmClassName, + onClose, + onConfirm, +}: { + open: boolean; + ariaLabel: string; + title: string; + description: ReactNode; + confirmLabel: string; + confirmClassName: string; + onClose: () => void; + onConfirm: () => void; +}) { + return ( + +

{title}

+

{description}

+
+ + +
+
+ ); +} + function componentKind(component: Component): string { - switch (true) { - case 'Image' in component: - return '图片'; - case 'Text' in component: - return '文本'; - default: - return '未知'; - } -} - -function componentKindLabel(kind: 'Image' | 'Text'): string { - switch (kind) { - case 'Image': - return '图片'; - case 'Text': - return '文本'; - } -} - -function componentLayerLabel(index: number, count: number): string { - if (index === count - 1) return '顶部'; - return `层级 ${index + 1}`; -} - -function ExpandIcon({ expanded }: { expanded: boolean }) { - if (expanded) return ; - return ; + if ('Image' in component) return '图片'; + if ('Text' in component) return '文本'; + return '未知'; } function renderComponentEditor( component: Component, - index: number, props: ComponentPanelProps, readOnly: boolean, updateComponent: ( - index: number, - next: Component, + next: Component | null, ) => UiEditorOperationResult | undefined, ) { - switch (true) { - case 'Image' in component: - return ( - updateComponent(index, { Image: next })} - /> - ); - case 'Text' in component: - return ( - updateComponent(index, { Text: next })} - /> - ); - default: - return ( -

- 当前组件类型暂不支持编辑。 -

- ); + if ('Image' in component) { + return ( + updateComponent({ Image: next })} + /> + ); } + if ('Text' in component) { + return ( + updateComponent({ Text: next })} + /> + ); + } + return ( +

+ 当前组件类型暂不支持编辑。 +

+ ); } diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/componentEditorTypes.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/componentEditorTypes.ts index 9d3adc06a..0319745b5 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/componentEditorTypes.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/Components/componentEditorTypes.ts @@ -7,24 +7,15 @@ import type { UiEditorFontFaceState } from '../../../../../features/ui-editor/us import type { UiEditorOperationResult } from '../../../../../features/ui-editor/useUiEditorState'; export type ComponentPanelProps = { - components: Component[]; + component: Component | null; sprites: Record; previewUrls: Record; fonts: Record; fontFaces: Record; projectPath: string; readOnly: boolean; - onSetComponents: ( - components: Component[], - ) => UiEditorOperationResult | undefined; - onInsertComponent: ( - index: number, - component: Component, - ) => UiEditorOperationResult | undefined; - onDeleteComponent: (index: number) => UiEditorOperationResult | undefined; - onMoveComponent: ( - fromIndex: number, - toIndex: number, + onSetComponent: ( + component: Component | null, ) => UiEditorOperationResult | undefined; }; diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx index 7557c3727..15e77c8e7 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/Inspector/InspectorSidebar.tsx @@ -115,10 +115,7 @@ export function InspectorSidebar({ fonts={inspector.fonts} fontFaces={inspector.fontFaces} projectPath={inspector.projectPath} - onSetComponents={inspector.setNodeComponents} - onInsertComponent={inspector.insertNodeComponent} - onDeleteComponent={inspector.deleteNodeComponent} - onMoveComponent={inspector.moveNodeComponent} + onSetComponent={inspector.setNodeComponent} onDeleteNode={() => inspector.deleteNode(view.node.id)} deleteDisabled={ inspector.isLocked || view.node.id === inspector.tree?.root.id @@ -293,10 +290,7 @@ function NodeInspector({ fonts, fontFaces, projectPath, - onSetComponents, - onInsertComponent, - onDeleteComponent, - onMoveComponent, + onSetComponent, onDeleteNode, deleteDisabled, }: { @@ -323,10 +317,7 @@ function NodeInspector({ fonts: UiEditorInspectorProjection['fonts']; fontFaces: UiEditorInspectorProjection['fontFaces']; projectPath: string; - onSetComponents: UiEditorInspectorProjection['setNodeComponents']; - onInsertComponent: UiEditorInspectorProjection['insertNodeComponent']; - onDeleteComponent: UiEditorInspectorProjection['deleteNodeComponent']; - onMoveComponent: UiEditorInspectorProjection['moveNodeComponent']; + onSetComponent: UiEditorInspectorProjection['setNodeComponent']; onDeleteNode: () => void; deleteDisabled: boolean; }) { @@ -414,7 +405,7 @@ function NodeInspector({ 来源:{node.metadata.source}
- 组件:{node.components.length} + 组件:{node.component ? 1 : 0}
@@ -434,18 +425,18 @@ function NodeInspector({ }} /> { - if (!isReadOnly) onMetadataChange({ components_status }); + onChange={(component_status) => { + if (!isReadOnly) onMetadataChange({ component_status }); }} />
@@ -498,17 +489,15 @@ function NodeInspector({ onChange={onLayoutChange} /> ; + fonts: Record; onFocusStatusNode: (treeId: UIDesignImageId, nodeId: NodeId) => void; }) { const overview = useMemo( - () => getBindingOverview(uiTrees, sprites), - [sprites, uiTrees], + () => getSeparationOverview(uiTrees, sprites, fonts), + [fonts, sprites, uiTrees], ); const attentionCycle = useUiTreeNodeCycle({ uiTrees, @@ -38,20 +41,20 @@ export function BindingOverview({ return (
Overview -

绑定概览

+

自动切分素材概览

- - + +
+ +

+ 发现未完成的自动切分素材 +

+

+ 上次自动切分素材留下了可恢复状态(已登记{' '} + {workflow.separationRecovery?.bound_node_count ?? 0}{' '} + 个节点)。请选择继续上次自动切分素材,或开始新的自动切分素材。 +

+
+ + + +
+
); } @@ -76,8 +115,8 @@ function getStepAction(workflow: UiEditorWorkflowProjection) { }; } return { - label: '绑定视觉素材', - runningLabel: '绑定中…', - action: workflow.bindComponents, + label: '自动切分素材', + runningLabel: '素材切分中…', + action: workflow.separateUi, }; } diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowChecks.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowChecks.ts index 54b77ef1f..5df5d0d9b 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowChecks.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowChecks.ts @@ -1,11 +1,10 @@ import { type UiEditorPrerequisiteIssue, - validateAssetRecognitionPrerequisites, + validateAssetSeparationPrerequisites, + validateAssetSeparationResult, validateComponentRecognitionPrerequisites, - validateLayoutReviewPrerequisites, validateReferenceAnalysisResult, validateStructureRecognitionResult, - validateVisualBindingResult, } from '../../../features/ui-editor/requisites'; import type { State } from '../../../features/ui-editor/types/State'; import type { UiEditorStepId } from '../model'; @@ -21,8 +20,8 @@ export function prerequisiteIssuesForStep( return []; case 'structure-recognition': return validateComponentRecognitionPrerequisites(state); - case 'visual-binding': - return validateAssetRecognitionPrerequisites(state); + case 'asset-separation': + return validateAssetSeparationPrerequisites(state); } } @@ -35,8 +34,8 @@ export function postCheckIssuesForStep( return validateReferenceAnalysisResult(state); case 'structure-recognition': return validateStructureRecognitionResult(state); - case 'visual-binding': - return validateVisualBindingResult(state); + case 'asset-separation': + return validateAssetSeparationResult(state); } } @@ -46,7 +45,7 @@ export function postCheckIssuesForSave( return [ ...validateReferenceAnalysisResult(state), ...validateStructureRecognitionResult(state), - ...validateVisualBindingResult(state), + ...validateAssetSeparationResult(state), ]; } @@ -58,8 +57,8 @@ export function activeStepPrerequisiteIssues( case 'reference-analysis': return validateComponentRecognitionPrerequisites(state); case 'structure-recognition': - return validateAssetRecognitionPrerequisites(state); - case 'visual-binding': - return validateLayoutReviewPrerequisites(state); + return validateAssetSeparationPrerequisites(state); + case 'asset-separation': + return []; } } diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowCompletionModal.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowCompletionModal.tsx index fbda3f214..c96e8d670 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowCompletionModal.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/WorkflowCompletionModal.tsx @@ -23,7 +23,7 @@ export function WorkflowCompletionModal({ {stepLabel} {outcomeLabel} -

+

{notice.message}

diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx index c923ca875..26203c35a 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/PreviewWorkspace.tsx @@ -1,18 +1,20 @@ import { CANVAS_ZOOM_IN_FACTOR, CANVAS_ZOOM_OUT_FACTOR, + canvasDisplayScaleToViewportScale, type CanvasViewport, createPanDragState, type DragState, fitViewportToBounds, + formatCanvasDisplayScalePercent, moveViewportFromPan, resolveViewportFromWheel, scaleViewportFromScreenPoint, + viewportScaleToCanvasDisplayScale, } from '@genarrative/image-canvas-core'; import { CanvasViewport as SharedCanvasViewport, CanvasWorld, - ZoomControls, } from '@genarrative/image-canvas-react'; import { Image as ImageIcon, Minus, Plus } from 'lucide-react'; import { @@ -37,7 +39,6 @@ import { } from './previewZoomKeyboard'; import { type UiEditorRenderMode, UiTreeRenderer } from './UiTreeRenderer'; import { useNodeTransformInteraction } from './useNodeTransformInteraction'; -import { ZoomPercentageInput } from './ZoomPercentageInput'; export function PreviewWorkspace({ canvas, @@ -55,6 +56,7 @@ export function PreviewWorkspace({ viewportRef.current, ); const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 }); + const canvasSizeRef = useRef(canvasSize); const [spaceHeld, setSpaceHeld] = useState(false); const [renderMode, setRenderMode] = useState('editor-overlay'); @@ -143,10 +145,6 @@ export function PreviewWorkspace({ const fitToCanvas = useCallback(() => { if (!logicalSize) return; const element = viewportElementRef.current; - const size = { - width: element?.clientWidth || 900, - height: element?.clientHeight || 640, - }; setViewport( fitViewportToBounds({ bounds: { @@ -155,11 +153,19 @@ export function PreviewWorkspace({ width: logicalSize.width, height: logicalSize.height, }, - canvasSize: size, + canvasSize: { + width: element?.clientWidth || canvasSizeRef.current.width, + height: element?.clientHeight || canvasSizeRef.current.height, + }, }), ); }, [logicalSize, setViewport]); + useEffect(() => { + if (!activeImageId || !logicalSize) return; + fitToCanvas(); + }, [activeImageId, fitToCanvas, logicalSize]); + const scaleViewportFromCenter = useCallback( (nextScale: number) => { const element = viewportElementRef.current; @@ -188,14 +194,25 @@ export function PreviewWorkspace({ scaleViewportFromCenter(viewportRef.current.scale * CANVAS_ZOOM_OUT_FACTOR); }, [scaleViewportFromCenter]); + const displayPercent = formatCanvasDisplayScalePercent(viewport.scale); + + const zoomToDisplayScale = useCallback( + (displayScale: number) => { + scaleViewportFromCenter(canvasDisplayScaleToViewportScale(displayScale)); + }, + [scaleViewportFromCenter], + ); + useEffect(() => { const element = viewportElementRef.current; if (!element) return; const updateSize = () => { - setCanvasSize({ + const nextSize = { width: element.clientWidth || 900, height: element.clientHeight || 640, - }); + }; + canvasSizeRef.current = nextSize; + setCanvasSize(nextSize); }; updateSize(); const observer = new ResizeObserver(updateSize); @@ -203,12 +220,6 @@ export function PreviewWorkspace({ return () => observer.disconnect(); }, []); - useEffect(() => { - fitToCanvas(); - // This effect intentionally follows the active image, not every controller render. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeImageId, fitToCanvas]); - useEffect(() => { const request = canvas.focusRequest; if ( @@ -282,7 +293,6 @@ export function PreviewWorkspace({ usesMetaModifier, }, { - fit: fitToCanvas, resetToActualSize, zoomIn, zoomOut, @@ -301,7 +311,7 @@ export function PreviewWorkspace({ window.removeEventListener('keyup', onKeyUp); window.removeEventListener('blur', onWindowBlur); }; - }, [fitToCanvas, logicalSize, resetToActualSize, zoomIn, zoomOut]); + }, [logicalSize, resetToActualSize, zoomIn, zoomOut]); const handlePointerDown = (event: ReactPointerEvent) => { if (event.button === 0 && !isPreviewZoomInteractiveTarget(event.target)) { @@ -507,70 +517,56 @@ export function PreviewWorkspace({ onDelete={(nodeId) => canvas.deleteNode(nodeId)} /> ) : null} - { + if ( + event.target instanceof Element && + event.target.closest('button') + ) { + event.preventDefault(); + } + }} > - {(actions) => ( -
{ - if ( - event.target instanceof Element && - event.target.closest('button') - ) { - event.preventDefault(); - } - }} - > - - - actions.zoomToDisplayScale(Number(event.target.value) / 100) - } - /> - - actions.zoomToDisplayScale(percent / 100) - } - /> - - -
- )} -
+ + + zoomToDisplayScale(Number(event.target.value) / 100) + } + /> + + +
) : (
diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx index b8873be1a..c4ca88a09 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/UiTreeRenderer.tsx @@ -191,13 +191,9 @@ function RenderNode({ {node.metadata.name} ) : null} - {node.components.map((component, index) => ( - - ))} + {node.component ? ( + + ) : null} {renderMode === 'final-preview' && selectedNodeId === node.id ? ( void; -}) { - const [draft, setDraft] = useState(() => - displayPercentToValue(displayPercent), - ); - const draftRef = useRef(draft); - const isEditingRef = useRef(false); - - useEffect(() => { - if (!isEditingRef.current) { - setDraft(displayPercentToValue(displayPercent)); - draftRef.current = displayPercentToValue(displayPercent); - } - }, [displayPercent]); - - const commit = () => { - const currentPercent = Number.parseFloat( - displayPercentToValue(displayPercent), - ); - const parsed = Number.parseFloat(draftRef.current); - const nextPercent = Number.isFinite(parsed) - ? Math.min(MAX_ZOOM_PERCENT, Math.max(MIN_ZOOM_PERCENT, parsed)) - : currentPercent; - setDraft(String(nextPercent)); - draftRef.current = String(nextPercent); - isEditingRef.current = false; - if (nextPercent !== currentPercent) { - onCommit(nextPercent); - } - }; - - return ( - - ); -} diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewZoomKeyboard.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewZoomKeyboard.ts index 85941ca1e..ce09b1495 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewZoomKeyboard.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/preview/previewZoomKeyboard.ts @@ -1,8 +1,4 @@ -export type PreviewZoomShortcut = - | 'fit' - | 'actual-size' - | 'zoom-in' - | 'zoom-out'; +export type PreviewZoomShortcut = 'actual-size' | 'zoom-in' | 'zoom-out'; export type PreviewZoomKeyboardContext = { hasZoomableViewport: boolean; @@ -12,7 +8,6 @@ export type PreviewZoomKeyboardContext = { }; export type PreviewZoomKeyboardActions = { - fit: () => void; resetToActualSize: () => void; zoomIn: () => void; zoomOut: () => void; @@ -61,7 +56,6 @@ export function resolvePreviewZoomShortcut( event: KeyboardEvent, ): PreviewZoomShortcut | null { if (event.altKey) return null; - if (event.key === '0') return 'fit'; if (event.key === '1') return 'actual-size'; if (event.code === 'NumpadAdd' || event.key === '+' || event.key === '=') { return 'zoom-in'; @@ -92,8 +86,7 @@ export function handlePreviewZoomKeyDown( event.preventDefault(); event.stopPropagation(); - if (shortcut === 'fit') actions.fit(); - else if (shortcut === 'actual-size') actions.resetToActualSize(); + if (shortcut === 'actual-size') actions.resetToActualSize(); else if (shortcut === 'zoom-in') actions.zoomIn(); else actions.zoomOut(); return true; diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.ts b/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.ts index ff90b6dd5..02c5b317a 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/components/workflowCompletionNotice.ts @@ -18,8 +18,8 @@ export function workflowStepLabel(step: UiEditorStepId): string { return '分析参考图'; case 'structure-recognition': return '识别界面结构'; - case 'visual-binding': - return '绑定视觉素材'; + case 'asset-separation': + return '自动切分素材'; } const exhaustiveCheck: never = step; return exhaustiveCheck; diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx index e60169b1f..4696d76f9 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx +++ b/apps/ai-game-creator-shell/src/view/ui-editor/index.tsx @@ -7,13 +7,13 @@ import { type IUiDesignStateStore, uiDesignStateStore, } from '../../features/ui-editor/uiDesignStateStore'; -import { BindingOverview } from './components/BindingOverview'; import { EditorDialogs } from './components/EditorDialogs'; import { ImportOverview } from './components/ImportOverview'; import { InputSidebar } from './components/InputSidebar'; import { InspectorSidebar } from './components/Inspector/InspectorSidebar'; import { PreviewWorkspace } from './components/preview/PreviewWorkspace'; import { RecognitionOverview } from './components/RecognitionOverview'; +import { SeparationOverview } from './components/SeparationOverview'; import { ToolNavigation } from './components/ToolNavigation'; import { WorkflowActionCard } from './components/WorkflowActionCard'; import { WorkflowCompletionModal } from './components/WorkflowCompletionModal'; @@ -265,13 +265,14 @@ export default function UiEditorPage({ session.input.highlightStatusField(nodeId, 'layout_status'); }} /> - ) : session.input.activeStep === 'visual-binding' ? ( - { session.input.focusNode(treeId, nodeId); - session.input.highlightStatusField(nodeId, 'components_status'); + session.input.highlightStatusField(nodeId, 'component_status'); }} /> ) : ( diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/model.ts b/apps/ai-game-creator-shell/src/view/ui-editor/model.ts index 13ec54cc7..f03f6ef47 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/model.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/model.ts @@ -6,7 +6,7 @@ import type { RemovalImpact } from '../../features/ui-editor/useUiEditorState'; export type UiEditorStepId = | 'reference-analysis' | 'structure-recognition' - | 'visual-binding'; + | 'asset-separation'; export type UiEditorImportKind = 'design-image' | 'font' | 'sprite'; export type UiEditorNodeFocusRequest = { @@ -27,7 +27,7 @@ export const UI_EDITOR_STEPS: Array<{ }> = [ { id: 'reference-analysis', label: '分析参考图' }, { id: 'structure-recognition', label: '识别界面结构' }, - { id: 'visual-binding', label: '绑定视觉素材' }, + { id: 'asset-separation', label: '自动切分素材' }, ]; export const UI_DESIGN_IMAGE_ROLES: Array<{ diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index a580a652d..6e7504963 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -2,7 +2,6 @@ import { invoke } from '@tauri-apps/api/core'; import { useCallback, useEffect, useMemo, useState } from 'react'; import type { ImportedAsset } from '../../components/AssetImporter'; -import { applyBindingResult } from '../../features/ui-editor/binding'; import { prepareDesignImageBatch, prepareFontAssetBatch, @@ -10,12 +9,15 @@ import { } from '../../features/ui-editor/importAdapter'; import { applyMergeResult } from '../../features/ui-editor/merge'; import { applyRecognitionResult } from '../../features/ui-editor/recognition'; +import { + applySeparationProblematicStatuses, + clearSeparationComponentStatus, +} from '../../features/ui-editor/separationStatus'; import { getStageStatusOverview, type StageStatusField, } from '../../features/ui-editor/stageStatusOverview'; import { collectUiNodeIds } from '../../features/ui-editor/treeUtils'; -import type { BindingDTO } from '../../features/ui-editor/types/BindingDTO'; import type { ChildrenDisplayMode } from '../../features/ui-editor/types/ChildrenDisplayMode'; import type { Component } from '../../features/ui-editor/types/Component'; import type { FontAssetId } from '../../features/ui-editor/types/FontAssetId'; @@ -23,6 +25,8 @@ import type { MergeDTO } from '../../features/ui-editor/types/MergeDTO'; import type { Node as UiNode } from '../../features/ui-editor/types/Node'; import type { NodeId } from '../../features/ui-editor/types/NodeId'; import type { RecognitionDTO } from '../../features/ui-editor/types/RecognitionDTO'; +import type { SeparationDTO } from '../../features/ui-editor/types/SeparationDTO'; +import type { SeparationRecoveryDTO } from '../../features/ui-editor/types/SeparationRecoveryDTO'; import type { SpriteAssetId } from '../../features/ui-editor/types/SpriteAssetId'; import type { SpriteBorder } from '../../features/ui-editor/types/SpriteBorder'; import type { State } from '../../features/ui-editor/types/State'; @@ -37,6 +41,7 @@ import { } from '../../features/ui-editor/uiDesignStateStore'; import { applyUiDesignSuggestions } from '../../features/ui-editor/uiDesignSuggestions'; import { useUiEditorFontFaces } from '../../features/ui-editor/useUiEditorFontFaces'; +import { addSpriteAssetsToState } from '../../features/ui-editor/useUiEditorState'; import { EMPTY_UI_EDITOR_STATE, type NodeLayoutPatch, @@ -70,7 +75,15 @@ import { } from './model'; import { useUiEditorNodeFocus } from './useUiEditorNodeFocus'; -const ASSET_BATCH_SIZE = 5; +const SEPARATION_IMPORT_BATCH_SIZE = 100; + +type LocalImageImportResponse = { + assets: Array<{ id: string; localPath: string; assetKind?: string | null }>; +}; + +function normalizeProjectRelativePath(path: string): string { + return path.replaceAll('\\', '/').replace(/^\/+/, ''); +} type StatusFieldHighlight = { nodeId: NodeId; @@ -250,11 +263,13 @@ export function useUiEditorSession( ); const [isMerging, setIsMerging] = useState(false); const [mergeStatus, setMergeStatus] = useState(null); - const [isBinding, setIsBinding] = useState(false); - const [bindingStatus, setBindingStatus] = useState(null); + const [isSeparating, setIsSeparating] = useState(false); + const [separationStatus, setSeparationStatus] = useState(null); + const [separationRecovery, setSeparationRecovery] = + useState(null); const [hasSuggested, setHasSuggested] = useState(false); const [hasRecognized, setHasRecognized] = useState(false); - const [hasBound, setHasBound] = useState(false); + const [hasSeparated, setHasSeparated] = useState(false); const [completionNotice, setCompletionNotice] = useState(null); @@ -393,7 +408,8 @@ export function useUiEditorSession( image.metadata.role === 'Page' && !isSlaveToDescendant(images, id as UIDesignImageId, activeImageId), ); - const isAiRunning = isSuggesting || isRecognizing || isBinding || isMerging; + const isAiRunning = + isSuggesting || isRecognizing || isMerging || isSeparating; const isWorkflowBusy = isAiRunning || isSaving || isGenerating || isLoading || editor.isLocked; const stateSignature = JSON.stringify(editor.state); @@ -401,18 +417,18 @@ export function useUiEditorSession( resourceId !== undefined && savedStateSignature !== null && savedStateSignature !== stateSignature; - const nextStep: UiEditorStepId | null = - activeStep === 'reference-analysis' - ? 'structure-recognition' - : activeStep === 'structure-recognition' - ? 'visual-binding' - : null; + const nextStepByStep: Partial> = { + 'reference-analysis': 'structure-recognition', + 'structure-recognition': 'asset-separation', + }; + const nextStep = nextStepByStep[activeStep] ?? null; const spriteReferenceCounts = useMemo(() => { const counts: Record = {}; function visit(nodes: UiNode[]) { for (const node of nodes) { - for (const component of node.components) { + const component = node.component; + if (component) { if ('Image' in component && component.Image.target_graphic) { counts[component.Image.target_graphic] = (counts[component.Image.target_graphic] ?? 0) + 1; @@ -429,7 +445,8 @@ export function useUiEditorSession( const counts: Record = {}; function visit(nodes: UiNode[]) { for (const node of nodes) { - for (const component of node.components) { + const component = node.component; + if (component) { if ('Text' in component && typeof component.Text.font !== 'string') { const id = component.Text.font.Bound; counts[id] = (counts[id] ?? 0) + 1; @@ -787,7 +804,7 @@ export function useUiEditorSession( if ( result.ok && (patch.layout_status !== undefined || - patch.components_status !== undefined) + patch.component_status !== undefined) ) { setHighlightedStatusField(null); } @@ -826,45 +843,14 @@ export function useUiEditorSession( return result; } - function setNodeComponents(components: Component[]) { + function setNodeComponent(component: Component | null) { if (!activeImageId || !selectedNodeId) return; - const result = editor.setNodeComponents( + const result = editor.setNodeComponent( activeImageId, selectedNodeId, - components, - ); - if (!result.ok) setStatus('组件更新失败。'); - return result; - } - - function insertNodeComponent(index: number, component: Component) { - if (!activeImageId || !selectedNodeId) return; - const result = editor.insertComponent( - activeImageId, - selectedNodeId, - index, component, ); - if (!result.ok) setStatus('组件新增失败。'); - return result; - } - - function deleteNodeComponent(index: number) { - if (!activeImageId || !selectedNodeId) return; - const result = editor.deleteComponent(activeImageId, selectedNodeId, index); - if (!result.ok) setStatus('组件删除失败。'); - return result; - } - - function moveNodeComponent(fromIndex: number, toIndex: number) { - if (!activeImageId || !selectedNodeId) return; - const result = editor.moveComponent( - activeImageId, - selectedNodeId, - fromIndex, - toIndex, - ); - if (!result.ok) setStatus('组件顺序更新失败。'); + if (!result.ok) setStatus('组件更新失败。'); return result; } @@ -1061,63 +1047,333 @@ export function useUiEditorSession( } } - async function bindComponents() { - if (isBinding || isWorkflowBusy) return; + async function runSeparationWorkflow() { + if (!resourceId || isWorkflowBusy) return; + setIsSeparating(true); + setSeparationStatus(null); setCompletionNotice(null); - setBindingStatus(null); - setIsBinding(true); + let preparedSprites: Awaited> = + []; try { + let backfillErrors: string[] = []; + let separationResult: SeparationDTO | null = null; await editor.runWithStateLocked(async (snapshot) => { - const allSpriteIds = Object.keys(snapshot.sprite_assets); - const batches: string[][] = []; + const result = await invoke('separate_ui', { + projectPath, + assetId: resourceId, + state: snapshot, + }); + separationResult = result; + + const uniquePaths = [ + ...new Set( + result.bound_nodes.map((bound) => + normalizeProjectRelativePath(bound.cut_image_path), + ), + ), + ]; + const importedByPath = new Map< + string, + { id: string; localPath: string; assetKind: string | null } + >(); for ( let index = 0; - index < allSpriteIds.length; - index += ASSET_BATCH_SIZE + index < uniquePaths.length; + index += SEPARATION_IMPORT_BATCH_SIZE ) { - batches.push(allSpriteIds.slice(index, index + ASSET_BATCH_SIZE)); + const relativePaths = uniquePaths.slice( + index, + index + SEPARATION_IMPORT_BATCH_SIZE, + ); + const imported = await invoke( + 'import_local_project_image_assets', + { projectPath, relativePaths }, + ); + if (imported.assets.length !== relativePaths.length) { + throw new Error( + `本地资源登记结果数量不匹配:请求 ${relativePaths.length} 个,返回 ${imported.assets.length} 个`, + ); + } + for (const [assetIndex, asset] of imported.assets.entries()) { + const normalizedAsset = { + id: asset.id, + localPath: normalizeProjectRelativePath(asset.localPath), + assetKind: asset.assetKind ?? null, + }; + // The importer may copy a sidecar file into assets/uploads and + // therefore return a different localPath. Keep both identities: + // the cut path is the separation contract, while the returned + // path is the SpriteAsset resource path. + importedByPath.set(normalizedAsset.localPath, normalizedAsset); + const requestedPath = relativePaths[assetIndex]; + if (requestedPath) { + importedByPath.set( + normalizeProjectRelativePath(requestedPath), + normalizedAsset, + ); + } + } } - if (batches.length === 0) batches.push([]); - let current = snapshot; - for (const [index, spriteIds] of batches.entries()) { - setBindingStatus(`绑定组件中(${index + 1}/${batches.length})…`); - const result = await invoke('bind_components', { - projectPath, - state: current, - spriteIds, - }); - current = applyBindingResult(current, result); - editor.replaceState(current, { - history: index < batches.length - 1 ? 'skip' : 'record', - }); - } - reportWorkflowCompletion( - 'visual-binding', - 'success', - `视觉素材绑定完成:已处理 ${batches.length}/${batches.length} 个批次`, - setBindingStatus, + + const missingImports = uniquePaths.filter( + (path) => !importedByPath.has(path), ); - setHasBound(true); + backfillErrors = missingImports.map( + (path) => `未能登记自动切分素材图片:${path}`, + ); + const importedAssets: ImportedAsset[] = [ + ...new Map( + [...importedByPath.values()].map((asset) => [asset.id, asset]), + ).values(), + ]; + preparedSprites = await prepareSpriteAssetBatch( + projectPath, + importedAssets, + ); + const spriteById = new Map( + preparedSprites.map((item) => [ + item.resource.asset_id, + item.resource, + ]), + ); + const spriteByPath = new Map( + [...importedByPath.entries()].flatMap(([path, asset]) => { + const sprite = spriteById.get(asset.id); + return sprite ? [[path, sprite] as const] : []; + }), + ); + const added = addSpriteAssetsToState( + snapshot, + preparedSprites.map((item) => item.resource), + ); + if (!added.ok) { + throw new Error(uiEditorOperationError(added.reason)); + } + const next = added.value; + for (const bound of result.bound_nodes) { + const path = normalizeProjectRelativePath(bound.cut_image_path); + const sprite = spriteByPath.get(path); + if (!sprite) { + backfillErrors.push( + `节点 ${bound.node_id} 缺少已登记的自动切分素材图片:${path}`, + ); + continue; + } + const location = next.ui_trees + .map((tree) => findUiNodeLocation(tree.root, bound.node_id)) + .find((candidate) => candidate !== null); + if (!location) { + backfillErrors.push(`节点 ${bound.node_id} 已不存在,素材已保留`); + continue; + } + const imageComponent = location.node.component; + if (!imageComponent || !('Image' in imageComponent)) { + backfillErrors.push( + `节点 ${bound.node_id} 没有可回填的 Image 组件,素材已保留`, + ); + continue; + } + if (imageComponent.Image.target_graphic === sprite.asset_id) { + clearSeparationComponentStatus(location.node); + continue; + } + if (imageComponent.Image.target_graphic !== null) { + backfillErrors.push( + `节点 ${bound.node_id} 已绑定其他素材,自动切分素材已保留`, + ); + continue; + } + imageComponent.Image.target_graphic = sprite.asset_id; + clearSeparationComponentStatus(location.node); + } + backfillErrors.push( + ...applySeparationProblematicStatuses( + next.ui_trees, + result.problematic_nodes, + ), + ); + editor.replaceState(next); }); + + setPreviewUrls((current) => ({ + ...current, + ...Object.fromEntries( + preparedSprites.map((item) => [ + item.resource.asset_id, + item.previewUrl, + ]), + ), + })); + if (separationResult === null) + throw new Error('自动切分素材没有返回结果'); + const completedResult = separationResult as SeparationDTO; + if (!(await save({ allowDuringSeparation: true }))) { + throw new Error( + '自动切分素材结果已写入编辑器,但 State 保存失败;sidecar 已保留,可继续恢复。', + ); + } + if ( + backfillErrors.length > 0 || + completedResult.problematic_nodes.length > 0 + ) { + if (completedResult.problematic_nodes.length > 0) { + backfillErrors.push( + `${completedResult.problematic_nodes.length} 个节点达到返工上限,需要人工处理`, + ); + } + const recovery = await invoke( + 'inspect_separation_recovery', + { projectPath, assetId: resourceId }, + ); + setSeparationRecovery(recovery); + reportWorkflowCompletion( + 'asset-separation', + 'failure', + `自动切分素材已完成,但有 ${backfillErrors.length} 项未能回填;已登记素材并保留恢复状态。\n${backfillErrors.join('\n')}`, + setSeparationStatus, + ); + return; + } + await invoke('finalize_separation', { + projectPath, + assetId: resourceId, + }); + setHasSeparated(true); + reportWorkflowCompletion( + 'asset-separation', + 'success', + `自动切分素材完成:${completedResult.bound_nodes.length} 个已切分并回填,${completedResult.problematic_nodes.length} 个待处理。`, + setSeparationStatus, + ); } catch (cause) { reportWorkflowCompletion( - 'visual-binding', + 'asset-separation', 'failure', cause instanceof Error ? cause.message : String(cause), - setBindingStatus, + setSeparationStatus, ); } finally { - setIsBinding(false); + setIsSeparating(false); } } - async function save() { + async function separateUi() { + if ( + isSeparating || + isWorkflowBusy || + separationRecovery !== null || + !resourceId + ) { + return; + } + try { + const recovery = await invoke( + 'inspect_separation_recovery', + { projectPath, assetId: resourceId }, + ); + if (recovery.exists) { + setSeparationRecovery(recovery); + return; + } + const prerequisiteIssues = prerequisiteIssuesForStep( + editor.state, + 'asset-separation', + ); + if (prerequisiteIssues.length > 0) { + reportWorkflowCompletion( + 'asset-separation', + 'failure', + prerequisiteIssues.map((issue) => issue.message).join(';'), + setSeparationStatus, + ); + return; + } + await runSeparationWorkflow(); + } catch (cause) { + reportWorkflowCompletion( + 'asset-separation', + 'failure', + cause instanceof Error ? cause.message : String(cause), + setSeparationStatus, + ); + } + } + + async function continueSeparation() { + if (!resourceId || isSeparating || isWorkflowBusy) return; + setSeparationRecovery(null); + try { + const recovery = await invoke( + 'inspect_separation_recovery', + { projectPath, assetId: resourceId }, + ); + if (!recovery.exists) { + throw new Error('自动切分恢复状态不存在,请重新开始。'); + } + const prerequisiteIssues = prerequisiteIssuesForStep( + editor.state, + 'asset-separation', + ); + if (prerequisiteIssues.length > 0) { + reportWorkflowCompletion( + 'asset-separation', + 'failure', + prerequisiteIssues.map((issue) => issue.message).join(';'), + setSeparationStatus, + ); + return; + } + await runSeparationWorkflow(); + } catch (cause) { + reportWorkflowCompletion( + 'asset-separation', + 'failure', + cause instanceof Error ? cause.message : String(cause), + setSeparationStatus, + ); + } + } + + async function restartSeparation() { + if (!resourceId || isSeparating || isWorkflowBusy) return; + setSeparationRecovery(null); + const prerequisiteIssues = prerequisiteIssuesForStep( + editor.state, + 'asset-separation', + ); + if (prerequisiteIssues.length > 0) { + reportWorkflowCompletion( + 'asset-separation', + 'failure', + prerequisiteIssues.map((issue) => issue.message).join(';'), + setSeparationStatus, + ); + return; + } + try { + await invoke('discard_separation_recovery', { + projectPath, + assetId: resourceId, + }); + await runSeparationWorkflow(); + } catch (cause) { + reportWorkflowCompletion( + 'asset-separation', + 'failure', + cause instanceof Error ? cause.message : String(cause), + setSeparationStatus, + ); + } + } + + async function save(options?: { allowDuringSeparation?: boolean }) { + const allowDuringSeparation = options?.allowDuringSeparation === true; if ( !resourceId || isSaving || isGenerating || isLoading || - isAiRunning || + (isAiRunning && !allowDuringSeparation) || loadError || persistedRevision === null || editor.isLocked @@ -1140,9 +1396,7 @@ export function useUiEditorSession( return false; } setPersistedRevision(result.revision); - if (JSON.stringify(editor.state) === snapshotSignature) { - setSavedStateSignature(snapshotSignature); - } + setSavedStateSignature(snapshotSignature); return true; }); } catch { @@ -1211,9 +1465,7 @@ export function useUiEditorSession( return null; } setPersistedRevision(saved.revision); - if (JSON.stringify(editor.state) === snapshotSignature) { - setSavedStateSignature(snapshotSignature); - } + setSavedStateSignature(snapshotSignature); return await stateStore.generateCode(resourceId); }); } catch (cause) { @@ -1244,17 +1496,15 @@ export function useUiEditorSession( selectedNodeId, focusRequest, operations: { - isBinding, isMerging, isRecognizing, isSuggesting, - bindingStatus, mergeStatus, recognitionStatus, suggestionStatus, }, checkPrerequisites, - bindComponents, + separateUi, mergeUi, recognizeUi, suggestUiDesignSemantics, @@ -1336,10 +1586,7 @@ export function useUiEditorSession( setNodeMetadata, setNodeTransform, setNodeLayout, - setNodeComponents, - insertNodeComponent, - deleteNodeComponent, - moveNodeComponent, + setNodeComponent, deleteNode, setSpriteName, setSpriteAssetType, @@ -1367,11 +1614,15 @@ export function useUiEditorSession( hasRecognized, recognitionStatus, recognizeUi, - isBinding, - hasBound, - bindingStatus, + hasSeparated, completionNotice, - bindComponents, + separateUi, + isSeparating, + separationStatus, + separationRecovery, + continueSeparation, + restartSeparation, + cancelSeparationRecovery: () => setSeparationRecovery(null), requestStepChange, continueToNextStep: () => { if (nextStep) requestStepChange(nextStep); diff --git a/apps/ai-game-creator-shell/tests/ZoomPercentageInput.test.tsx b/apps/ai-game-creator-shell/tests/ZoomPercentageInput.test.tsx deleted file mode 100644 index f02a71e3d..000000000 --- a/apps/ai-game-creator-shell/tests/ZoomPercentageInput.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -// @vitest-environment jsdom - -import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; - -import { ZoomPercentageInput } from '../src/view/ui-editor/components/preview/ZoomPercentageInput'; - -describe('ZoomPercentageInput', () => { - it('edits the displayed percentage and commits on blur without fitting', () => { - const onCommit = vi.fn(); - render(); - - const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); - expect((input as HTMLInputElement).value).toBe('50'); - - fireEvent.focus(input); - fireEvent.change(input, { target: { value: '125' } }); - expect(onCommit).not.toHaveBeenCalled(); - fireEvent.blur(input); - - expect(onCommit).toHaveBeenCalledWith(125); - expect((input as HTMLInputElement).value).toBe('125'); - }); - - it.each([ - { value: '0', expected: 25 }, - { value: '999', expected: 200 }, - ])('clamps $value to $expected on blur', ({ value, expected }) => { - const onCommit = vi.fn(); - render(); - - const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); - fireEvent.focus(input); - fireEvent.change(input, { target: { value } }); - fireEvent.blur(input); - - expect(onCommit).toHaveBeenCalledWith(expected); - expect((input as HTMLInputElement).value).toBe(String(expected)); - }); - - it('restores the current percentage when the draft is invalid', () => { - const onCommit = vi.fn(); - render(); - - const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); - fireEvent.focus(input); - fireEvent.change(input, { target: { value: '' } }); - fireEvent.blur(input); - - expect(onCommit).not.toHaveBeenCalled(); - expect((input as HTMLInputElement).value).toBe('80'); - }); - - it('tracks viewport updates while not editing', () => { - const onCommit = vi.fn(); - const view = render( - , - ); - const input = screen.getByRole('spinbutton', { name: /画布缩放/ }); - - view.rerender( - , - ); - - expect((input as HTMLInputElement).value).toBe('140'); - }); -}); diff --git a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts index 5fa401401..499ec2464 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts @@ -7,6 +7,7 @@ import { React, render, screen, + setComposerText, waitFor, } from './harness'; @@ -55,6 +56,46 @@ function designClarificationView() { }; } +function designConversationView() { + return { + session: { + sessionId: 'design-session-reasoning', + projectId: 'local-project-draft', + currentPhase: 'concept', + approvedPhases: [], + pendingApproval: null, + pendingClarification: null, + turnIndex: 1, + lastError: null, + }, + messages: [], + running: false, + canRetry: false, + }; +} + +function designHistoryView() { + return { + ...designConversationView(), + messages: [ + { id: 'assistant-1', role: 'assistant', text: '第一轮正文' }, + { id: 'assistant-2', role: 'assistant', text: '第二轮正文' }, + ], + reasoningEntries: [ + { + id: 'reasoning-1', + messageId: 'assistant-1', + text: '第一轮思考', + }, + { + id: 'reasoning-2', + messageId: 'assistant-2', + text: '第二轮思考', + }, + ], + }; +} + export function registerDesignAgentSurfaceTests() { it('hydrates an existing design session and decides approval through design commands', async () => { const harness = createProjectSupervisorRuntimeHarness({ @@ -141,4 +182,83 @@ export function registerDesignAgentSurfaceTests() { ).toBe(true); }); }); + + it('keeps the current turn reasoning after completion and supports collapse/expand', async () => { + const harness = createProjectSupervisorRuntimeHarness({ + designAgentView: designConversationView(), + designAgentContinueView: designConversationView(), + }); + window.__TAURI__ = { + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }; + window.history.pushState({}, '', '/'); + render( + React.createElement(App, { + initialProjectPath: harness.projectPath, + orchestrationMode: 'single-supervisor', + planningStartMode: true, + projectSupervisorOnly: true, + }), + ); + + const input = await screen.findByLabelText('项目需求'); + await setComposerText(input, '请给出核心玩法方案'); + fireEvent.submit(input.closest('form') as HTMLFormElement); + await waitFor(() => { + expect( + harness.invoke.mock.calls.some( + ([command]) => command === 'continue_design_agent_session', + ), + ).toBe(true); + }); + const continueCall = [...harness.invoke.mock.calls] + .reverse() + .find(([command]) => command === 'continue_design_agent_session'); + const clientTurnId = String( + (continueCall?.[1] as { clientTurnId?: string }).clientTurnId, + ); + harness.emitDesignAgentEvent({ + projectPath: harness.projectPath, + clientTurnId, + kind: 'reasoning', + reasoningText: '先分析需求,再组织方案。', + }); + + const summary = await screen.findByText('思考过程'); + const details = summary.closest('details') as HTMLDetailsElement; + expect(details.open).toBe(false); + fireEvent.click(summary); + expect(details.open).toBe(true); + expect(screen.getByText('先分析需求,再组织方案。')).not.toBeNull(); + }); + + it('renders historical reasoning as independent collapsed sections', async () => { + const harness = createProjectSupervisorRuntimeHarness({ + designAgentView: designHistoryView(), + }); + window.__TAURI__ = { + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }; + window.history.pushState({}, '', '/'); + render( + React.createElement(App, { + initialProjectPath: harness.projectPath, + orchestrationMode: 'single-supervisor', + planningStartMode: true, + projectSupervisorOnly: true, + }), + ); + + const summaries = await screen.findAllByText('思考过程'); + expect(summaries).toHaveLength(2); + const details = summaries.map( + (summary) => summary.closest('details') as HTMLDetailsElement, + ); + expect(details.every((element) => !element.open)).toBe(true); + fireEvent.click(summaries[0]); + expect(details[0].open).toBe(true); + expect(details[1].open).toBe(false); + }); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 3c6da89f6..f683a7fa7 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -741,6 +741,9 @@ function createProjectSupervisorRuntimeHarness({ }; }) => void) | null = null; + let designAgentUpdateHandler: + | ((event: { payload: Record }) => void) + | null = null; const conversationRecord = ( role: 'user' | 'assistant', @@ -1113,6 +1116,10 @@ function createProjectSupervisorRuntimeHarness({ if (eventName === 'game-creator-agent-progress') { progressHandler = handler as unknown as typeof progressHandler; } + if (eventName === 'design-agent-update') { + designAgentUpdateHandler = + handler as unknown as typeof designAgentUpdateHandler; + } return () => { if (runtimeUpdateHandler === handler) { runtimeUpdateHandler = null; @@ -1123,6 +1130,9 @@ function createProjectSupervisorRuntimeHarness({ if (progressHandler === handler) { progressHandler = null; } + if (designAgentUpdateHandler === handler) { + designAgentUpdateHandler = null; + } }; }, ); @@ -1149,6 +1159,9 @@ function createProjectSupervisorRuntimeHarness({ setPlanningV2Result(state: Record | null) { currentPlanningV2Result = state; }, + emitDesignAgentEvent(payload: Record) { + designAgentUpdateHandler?.({ payload }); + }, setPlanningV2StartResult(state: Record | null) { currentPlanningV2StartResult = state; }, @@ -1227,6 +1240,32 @@ async function openMainProject(projectPath: string) { ).not.toBeNull(); } +export function installResizeObserverStub() { + let observerCount = 0; + let observerDisconnected = false; + class TestResizeObserver { + constructor(readonly callback: ResizeObserverCallback) { + observerCount += 1; + } + + observe() {} + + unobserve() {} + + disconnect() { + observerDisconnected = true; + } + } + Object.defineProperty(window, 'ResizeObserver', { + configurable: true, + value: TestResizeObserver, + }); + return { + observerCount: () => observerCount, + observerDisconnected: () => observerDisconnected, + }; +} + beforeEach(() => { resetLlmModelCatalogCacheForTest(); vi.spyOn(clientApi, 'loadClientLlmModels').mockResolvedValue({ diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index c4c771224..b290998a1 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -34,6 +34,7 @@ import { GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, type GameCreationAgentRunTrace, getResourceSelectButton, + installResizeObserverStub, it, mockRoleAgentReply, openResourceFilterPanel, @@ -237,6 +238,7 @@ function installResourceBookBottomToolbarInvoke( manifest: GameCreationAppManifest, ) { const calls: BottomToolbarInvokeCall[] = []; + const generatedTasks = new Map>(); const invoke = vi.fn( async (command: string, args?: Record) => { calls.push({ command, args }); @@ -266,14 +268,29 @@ function installResourceBookBottomToolbarInvoke( }, }; } - if (command === 'generate_local_project_asset') { + if (command === 'start_local_project_asset_generation') { + // 生成已经后台化:提交这一条只负责入参与落账,进度由 `list_...` 轮询后端账本。 + // 桩直接回一条已完成记录,让用例仍然只验证「界面发出的载荷」这一件事。 const kind = invokeStringField(args, 'kind') ?? 'unknown'; - return { - id: `generated-${kind}`, - localPath: `assets/canvas-generated/generated-${kind}.png`, - absolutePath: `/tmp/generated-${kind}.png`, - manifestPath: '/tmp/.agent/manifest.json', + const taskId = invokeStringField(args, 'taskId') ?? `task-${kind}`; + const task = { + taskId, + projectId: String(args?.projectId ?? manifest.projectId), + kind, + assetName: invokeStringField(args, 'assetName') ?? '', + status: 'completed', + phaseDetail: '生成已完成。', + createdAtMillis: 1, + startedAtMillis: 1, + finishedAtMillis: 2, + assetId: `generated-${kind}`, + error: null, }; + generatedTasks.set(taskId, task); + return task; + } + if (command === 'list_local_project_asset_generations') { + return [...generatedTasks.values()]; } if (command === 'derive_local_project_resource') { const editKind = deriveInputEditKind({ command, args }) ?? 'unknown'; @@ -335,14 +352,22 @@ async function submitBottomToolbarPanel( ); } +/** + * 一次图片类生成提交的载荷。 + * + * 后台化之后「提交」这一步是 `start_local_project_asset_generation`(入参校验 + 落项目内账本), + * 所以载荷断言看的是它;生成本身由 Rust 后台任务跑。 + */ function generateCall(calls: readonly BottomToolbarInvokeCall[], kind: string) { const call = calls.find( (entry) => - entry.command === 'generate_local_project_asset' && + entry.command === 'start_local_project_asset_generation' && invokeStringField(entry.args, 'kind') === kind, ); if (!call?.args) { - throw new Error(`missing generate_local_project_asset call for ${kind}`); + throw new Error( + `missing start_local_project_asset_generation call for ${kind}`, + ); } return call.args; } @@ -2142,6 +2167,12 @@ export function registerProjectWorkbenchFoundationTests() { updatedAt: 0, }; } + if (command === 'list_local_project_asset_generations') { + // 生成任务账本是只读的项目内文件,与「布局读时提示 / 写回」不是同一条链路; + // 这里显式登记成「空账本」,否则严格桩会把这条读判成 unexpected invoke、 + // 让读不到账本的提示混进本用例要断言的「没有任何提示」里。 + return []; + } // 没有归并、没有丢弃就不该写回:这里失败关闭,避免「悄悄写了一次」被漏掉。 throw new Error(`unexpected invoke ${command}`); }, @@ -4017,9 +4048,10 @@ export function registerProjectWorkbenchFoundationTests() { // 音频资源的选中工具条复用美术画布的音频分支(aria-label「素材工具栏」), // 并且只渲染宿主编排层真实接通的动作:「引用」(从卡片挪进工具条的引用入口, // 资源卡上的圆钮已删除)「信息」(只读信息浮层)「编辑标签」(面板只编辑 manifest - // `assets[].tags`,分类不再有手动入口)「重命名」已接面板「删除素材」(破坏性动作放末位, - // 前置共享分隔线,复用素材删除流程)「下载按钮」复用资源面板同一条落盘链路, - // 「改造」在宿主编排层仍是空回调,不能再渲染成点了没反应的按钮。 + // `assets[].tags`)「素材类型」(功能分类的独立入口,与标签面板分家)「重命名」 + // 已接面板「删除素材」(破坏性动作放末位,前置共享分隔线,复用素材删除流程) + // 「下载按钮」复用资源面板同一条落盘链路,「改造」在宿主编排层仍是空回调, + // 不能再渲染成点了没反应的按钮。 const audioToolbar = screen.getByRole('toolbar', { name: '素材工具栏', }); @@ -4034,6 +4066,7 @@ export function registerProjectWorkbenchFoundationTests() { '引用资源 bgm.mp3', '信息', '编辑标签', + '素材类型', '重命名', '删除素材', '下载按钮', @@ -4708,25 +4741,7 @@ export function registerProjectWorkbenchFoundationTests() { manuallyPlaced: false, }, ]; - let observerCount = 0; - let observerDisconnected = false; - class TestResizeObserver { - constructor(readonly callback: ResizeObserverCallback) { - observerCount += 1; - } - - observe() {} - - unobserve() {} - - disconnect() { - observerDisconnected = true; - } - } - Object.defineProperty(window, 'ResizeObserver', { - configurable: true, - value: TestResizeObserver, - }); + const resizeObserver = installResizeObserverStub(); const animationFrame = vi.spyOn(window, 'requestAnimationFrame'); const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect; @@ -4818,7 +4833,7 @@ export function registerProjectWorkbenchFoundationTests() { /^url\(#.+-asset-reference-arrow\)$/u, ); }); - expect(observerCount).toBe(1); + expect(resizeObserver.observerCount()).toBe(1); expect( overlay .querySelector('[data-testid="resource-dependency-overlay-scene"]') @@ -4865,7 +4880,7 @@ export function registerProjectWorkbenchFoundationTests() { ), ); rendered.unmount(); - expect(observerDisconnected).toBe(true); + expect(resizeObserver.observerDisconnected()).toBe(true); expect(removeViewportListener).toHaveBeenCalledWith( 'scroll', expect.any(Function), @@ -5788,10 +5803,22 @@ export function registerProjectWorkbenchFoundationTests() { /\.game-workbench-toolbar\s*\{[^}]*position:\s*relative[^}]*padding:\s*8px 12px[^}]*background:\s*transparent/s, ); expect(styles).toMatch( - /\.game-workbench-view-actions \.game-workbench-play-button\s*\{[^}]*position:\s*absolute[^}]*left:\s*50%[^}]*transform:\s*translateX\(-50%\)/s, + /\.game-workbench-view-tabs\s*\{[^}]*display:\s*flex[^}]*align-items:\s*center/s, + ); + // 播放按钮跟着「资源管理 / 运行」左对齐,不再居中悬浮。 + expect(styles).toMatch( + /\.game-workbench-view-tabs \.game-workbench-play-button\s*\{[^}]*position:\s*static[^}]*transform:\s*none/s, + ); + expect(styles).not.toMatch( + /\.game-workbench-play-button\s*\{[^}]*position:\s*absolute/s, + ); + // 新位置必须并进按钮基础外观与焦点环的规则列表:不然播放按钮会掉成零圆角、零内边距、 + // 无边框、默认字号的裸按钮,而颜色规则看起来仍然生效。 + expect(styles).toMatch( + /\.game-workbench-tabs button,\s*\.game-workbench-view-tabs button,\s*\.game-workbench-view-actions button\s*\{[^}]*border-radius:\s*999px/s, ); expect(styles).toMatch( - /@media \(max-width: 1000px\)[\s\S]*?\.game-workbench-view-actions \.game-workbench-play-button\s*\{[^}]*position:\s*static[^}]*transform:\s*none/s, + /\.game-workbench-view-tabs button:focus-visible,\s*\.game-workbench-view-actions button:focus-visible/s, ); expect(styles).toMatch( /@media \(min-width: 761px\)[\s\S]*?\.game-project-workbench\s*\{[^}]*grid-template-rows:\s*minmax\(0, 1fr\) auto[^}]*height:\s*100dvh/, @@ -5916,6 +5943,144 @@ export function registerProjectWorkbenchFoundationTests() { ); }); + it('restores resource canvas panning after returning from the UI editor', async () => { + installResizeObserverStub(); + const manifest = createGameCreationAppManifest( + 'workbench-ui-editor-return-pan', + 'UI 编辑器返回平移测试', + ); + manifest.assets = [ + { + id: 'ui-design-resource', + kind: 'UI', + mediaType: 'application/json', + localPath: 'assets/ui-design.json', + source: { kind: 'generated' }, + }, + ]; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: manifest.projectId, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: manifest.projectId, + mode: args?.mode, + revision: 1, + positions: args?.positions, + updatedAt: 1, + }, + }; + } + if (command === 'load_ui_design_state') { + return { + revision: 0, + state: { + ui_trees: [], + ui_design_images: {}, + sprite_assets: {}, + font_assets: {}, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: '/tmp/workbench-ui-editor-return-pan', + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + await openResourceBookCategory('UI 交互'); + const canvas = await screen.findByRole('region', { name: 'UI 交互' }); + const scene = document.querySelector( + '[data-resource-book-view="child"] .game-resource-book-scene', + ); + + const wheel = (target: HTMLElement, deltaY: number) => { + const event = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaY, + clientX: 120, + clientY: 100, + }); + act(() => target.dispatchEvent(event)); + expect(event.defaultPrevented).toBe(true); + }; + wheel(scene ?? canvas, 120); + const world = canvas.querySelector('[data-resource-viewport]'); + expect(world?.getAttribute('data-resource-viewport')).toMatch( + /^(?!48,48,1)/u, + ); + + fireEvent.click( + screen.getByRole('button', { + name: '选中资源:UI 交互 ui-design.json', + }), + ); + fireEvent.click(await screen.findByRole('button', { name: 'UI 编辑器' })); + await screen.findByRole('button', { name: '返回资源' }); + fireEvent.click(screen.getByRole('button', { name: '返回资源' })); + await waitFor(() => + expect(screen.queryByRole('button', { name: '返回资源' })).toBeNull(), + ); + + const restoredCanvas = await screen.findByRole('region', { + name: 'UI 交互', + }); + const restoredWorld = restoredCanvas.querySelector( + '[data-resource-viewport]', + ); + const beforeReturn = world?.getAttribute('data-resource-viewport'); + const restoredBeforePan = restoredWorld?.getAttribute( + 'data-resource-viewport', + ); + expect(restoredBeforePan).toBe(beforeReturn); + + const restoredScene = document.querySelector( + '[data-resource-book-view="child"] .game-resource-book-scene', + ); + const restoredWheelEvent = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaY: 120, + clientX: 120, + clientY: 100, + }); + act(() => + (restoredScene ?? restoredCanvas).dispatchEvent(restoredWheelEvent), + ); + expect(restoredWheelEvent.defaultPrevented).toBe(true); + expect(restoredWorld?.getAttribute('data-resource-viewport')).not.toBe( + restoredBeforePan, + ); + }); + it('clears the resource preview cache and cancels the old preview scope on project switch', async () => { // 60d8b8fbb 删掉了从未被写入的 `resourcePreviewVersionByResourceId` 死接线,原先钉 // 那条 prop 与它的清空语句的断言随之取消;用例真正要守的意图不变——项目切换必须把 @@ -10463,7 +10628,7 @@ export function registerProjectAgentStatusTests() { expect(commands).toContain('get_local_game_project_revision'); expect(commands).toContain('get_local_game_manifest'); expect(commands.lastIndexOf('get_local_game_manifest')).toBeGreaterThan( - commands.indexOf('generate_local_project_asset'), + commands.indexOf('start_local_project_asset_generation'), ); // 4) 缺权威规范图时,「生成图标素材 / 生成 UI 设计图」保持可点击并说明原因。 @@ -10478,7 +10643,9 @@ export function registerProjectAgentStatusTests() { 'assets/art-spec.png', ); expect( - calls.filter((call) => call.command === 'generate_local_project_asset'), + calls.filter( + (call) => call.command === 'start_local_project_asset_generation', + ), ).toHaveLength(3); }, 20_000); @@ -10583,6 +10750,919 @@ export function registerProjectAgentStatusTests() { }); }, 20_000); + it('keeps a submitted generation alive after the panel closes, queues the second submission, and shows backend phases', async () => { + const manifest = createGameCreationAppManifest( + 'workbench-asset-generation-tasks', + '生成任务项目', + ); + manifest.assets = [ + { + id: 'tasks-art-spec', + kind: 'icon-spec', + mediaType: 'image/png', + localPath: 'assets/art-spec.png', + source: { kind: 'canvas', resourceId: 'tasks-art-spec-resource' }, + }, + ]; + const calls: BottomToolbarInvokeCall[] = []; + const tasks = new Map>(); + let listPolls = 0; + const invoke = vi.fn( + async (command: string, args?: Record) => { + calls.push({ command, args }); + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: 1, + positions: args?.positions, + updatedAt: 1, + }, + }; + } + if (command === 'start_local_project_asset_generation') { + const kind = invokeStringField(args, 'kind') ?? 'unknown'; + const taskId = + invokeStringField(args, 'taskId') ?? `task-${tasks.size}`; + const task = { + taskId, + projectId: String(args?.projectId ?? manifest.projectId), + kind, + assetName: invokeStringField(args, 'assetName') ?? '', + status: 'running', + phaseDetail: '正在生成。', + createdAtMillis: tasks.size + 1, + startedAtMillis: tasks.size + 1, + finishedAtMillis: null, + assetId: null, + error: null, + }; + tasks.set(taskId, task); + return task; + } + if (command === 'list_local_project_asset_generations') { + listPolls += 1; + // 第三次轮询(约 4 秒)开始收尾:既让「第一条在途 → 第二条排队」可观测, + // 又让整条链路(补发 + 落卡 + 面板收口)在同一次用例里真的跑完。 + if (listPolls >= 3) { + for (const [taskId, task] of tasks) { + if (task.status !== 'completed') { + tasks.set(taskId, { + ...task, + status: 'completed', + phaseDetail: '生成已完成。', + assetId: `generated-${String(task.kind)}`, + finishedAtMillis: 2, + }); + } + } + } + return [...tasks.values()]; + } + if (command === 'get_local_game_project_revision') { + return { revision: 7 }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: '/tmp/workbench-asset-generation-tasks', + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + await openResourceBookCategory('UI 交互'); + expect(screen.getByRole('button', { name: '生成任务' })).not.toBeNull(); + + // 1) 提交第一条;生成在途时面板能关(关闭 ≠ 取消请求)。 + fireEvent.click( + await screen.findByRole('button', { name: '生成 UI 设计图' }), + ); + const firstPanel = await screen.findByRole('dialog', { + name: '生成 UI 设计图', + }); + fireEvent.change(within(firstPanel).getByLabelText('素材名称'), { + target: { value: '第一条设计图' }, + }); + fireEvent.change(within(firstPanel).getByLabelText('生成提示词'), { + target: { value: '第一条界面' }, + }); + fireEvent.click( + within(firstPanel).getByRole('button', { name: '生成 UI 设计图' }), + ); + // 点击即关闭:同一个事件循环内提交面板就已卸载,画布立刻可用。 + expect(screen.queryByRole('dialog', { name: '生成 UI 设计图' })).toBeNull(); + expect(firstPanel.textContent).not.toMatch(/排队中。|正在生成。|提交中…/); + + // 2) 面板关掉之后任务仍在「生成任务」面板里,阶段文案来自后端记录。 + const taskPanel = await screen.findByRole('region', { name: '生成任务' }); + expect(within(taskPanel).getByText('第一条设计图')).not.toBeNull(); + expect(within(taskPanel).getByText('正在生成。')).not.toBeNull(); + expect(within(taskPanel).getByText('生成中')).not.toBeNull(); + // 非模态:没有全屏遮罩、没有 aria-modal。 + expect(taskPanel.getAttribute('aria-modal')).toBeNull(); + expect(document.querySelector('[aria-modal="true"]')).toBeNull(); + + // 3) 第一条还在途时提交第二条:第二条停在本地队列,生成提交 IPC 仍然只有一次。 + fireEvent.click(screen.getByRole('button', { name: '生成 UI 设计图' })); + const secondPanel = await screen.findByRole('dialog', { + name: '生成 UI 设计图', + }); + fireEvent.change(within(secondPanel).getByLabelText('素材名称'), { + target: { value: '第二条设计图' }, + }); + fireEvent.change(within(secondPanel).getByLabelText('生成提示词'), { + target: { value: '第二条界面' }, + }); + fireEvent.click( + within(secondPanel).getByRole('button', { name: '生成 UI 设计图' }), + ); + // 第二条提交面板同样点击即关闭;它停在本地队列里(阶段由任务面板呈现), + // 生成提交 IPC 仍然只有一次。 + expect(screen.queryByRole('dialog', { name: '生成 UI 设计图' })).toBeNull(); + await waitFor(() => + expect(within(taskPanel).getByText('排队中。')).not.toBeNull(), + ); + expect( + calls.filter( + (call) => call.command === 'start_local_project_asset_generation', + ), + ).toHaveLength(1); + await waitFor(() => + expect(within(taskPanel).getByText('第二条设计图')).not.toBeNull(), + ); + + // 4) 第一条终态后自动补发第二条,两条都收口为已完成;每条完成各走一次「配对读 + 落卡」。 + const manifestReadsBefore = calls.filter( + (call) => call.command === 'get_local_game_manifest', + ).length; + await waitFor( + () => + expect( + calls.filter( + (call) => call.command === 'start_local_project_asset_generation', + ), + ).toHaveLength(2), + { timeout: 15_000 }, + ); + await waitFor( + () => + expect( + within(screen.getByRole('region', { name: '已完成' })).getAllByRole( + 'listitem', + ), + ).toHaveLength(2), + { timeout: 15_000 }, + ); + expect(within(taskPanel).getAllByText('生成已完成。')).toHaveLength(2); + expect( + calls.filter((call) => call.command === 'get_local_game_manifest').length, + ).toBeGreaterThanOrEqual(manifestReadsBefore + 2); + }, 30_000); + + /** + * 账本读不到时的视图:`listBehavior` 决定这次读取是「返回非数组」还是「直接拒绝」。 + * + * 真实场景分别是「旧壳没有这条命令、返回 undefined」与「命令未注册 / 权限拒绝抛错」。 + */ + async function renderGenerationLedgerUnavailableView( + listBehavior: () => unknown, + ) { + const manifest = createGameCreationAppManifest( + 'workbench-asset-generation-tasks-unavailable', + '生成任务账本不可读项目', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: 1, + positions: args?.positions, + updatedAt: 1, + }, + }; + } + if (command === 'list_local_project_asset_generations') { + return listBehavior(); + } + if (command === 'get_local_game_project_revision') { + return { revision: 7 }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: '/tmp/workbench-asset-generation-tasks-unavailable', + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + await openResourceBookCategory('UI 交互'); + } + + it('survives an unreadable generation task ledger without dropping local state', async () => { + // 旧壳 / 命令未注册:返回 undefined 而不是数组。 + await renderGenerationLedgerUnavailableView(() => undefined); + + expect(screen.getByRole('button', { name: '生成任务' })).not.toBeNull(); + expect( + await screen.findByText('生成任务列表读取失败,暂时无法恢复历史任务'), + ).not.toBeNull(); + }, 20_000); + + it('reports a rejected generation task ledger read instead of failing silently', async () => { + await renderGenerationLedgerUnavailableView(() => { + throw new Error('unexpected invoke list_local_project_asset_generations'); + }); + + expect(screen.getByRole('button', { name: '生成任务' })).not.toBeNull(); + expect( + await screen.findByText('生成任务列表读取失败,暂时无法恢复历史任务'), + ).not.toBeNull(); + }, 20_000); + + /** + * 「定位到素材」的公共夹具:项目里有一个 character 资产 + 一条已完成、指向它的生成任务。 + * + * `openCategory` 决定先停在哪个栏目:停在别的栏目就能覆盖「素材在另一个栏目」这条分支。 + */ + async function renderGenerationLocateView(input: { + projectId: string; + projectPath: string; + openCategory: string; + assetId: string; + ledgerRecords: Record[]; + }) { + const manifest = createGameCreationAppManifest( + input.projectId, + '生成任务定位测试', + ); + manifest.assets = [ + { + id: input.assetId, + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/locate-target.png', + source: { kind: 'canvas', resourceId: 'locate-target-resource' }, + }, + ]; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: input.projectId, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'list_local_project_asset_generations') { + return input.ledgerRecords; + } + if (command === 'get_local_game_project_revision') { + return { revision: 7 }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: input.projectPath, + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + await openResourceBookCategory(input.openCategory); + fireEvent.click(screen.getByRole('button', { name: '生成任务' })); + return screen.findByRole('region', { name: '生成任务' }); + } + + /** + * 收起「生成任务」侧栏后等它真的走完:收起要先播退场动画(`is-leaving`)再卸载, + * 所以点完关闭按钮不能直接断言 DOM 里已经没有它。 + */ + async function waitForTasksSidebarLeaveAnimationToFinish() { + expect( + document.querySelector('.game-resource-generation-tasks-sidebar') + ?.className, + ).toContain('is-leaving'); + await waitFor(() => + expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull(), + ); + } + + function completedGenerationRecord(input: { + taskId: string; + assetId: string | null; + projectId: string; + }) { + return { + taskId: input.taskId, + projectId: input.projectId, + kind: 'character', + assetName: '定位目标素材', + status: 'completed', + phaseDetail: '生成已完成。', + createdAtMillis: 1, + startedAtMillis: 1, + finishedAtMillis: 2, + assetId: input.assetId, + error: null, + }; + } + + it('locates a generated asset that lives in another column instead of leaving the notice pending', async () => { + const panel = await renderGenerationLocateView({ + projectId: 'workbench-locate-other-column', + projectPath: '/tmp/workbench-locate-other-column', + // 停在 UI 交互栏目:目标素材在 character 栏目。 + openCategory: 'UI 交互', + assetId: 'locate-other-column-asset', + ledgerRecords: [ + completedGenerationRecord({ + taskId: 'task-locate-other-column', + assetId: 'locate-other-column-asset', + projectId: 'workbench-locate-other-column', + }), + ], + }); + + fireEvent.click( + within(panel).getByRole('button', { name: '定位素材 定位目标素材' }), + ); + + // 悬而未决的中转提示必须消失,并且真的切到目标素材所在栏目。 + await waitFor(() => + expect(screen.queryByText('正在定位生成的素材…')).toBeNull(), + ); + await waitFor(() => + expect( + document.querySelector( + '.game-resource-book-scene-titlebar.is-active[data-resource-book-category="character"]', + ), + ).not.toBeNull(), + ); + }, 20_000); + + it('focuses a generated asset that is already in the current column', async () => { + // 这条是「点了没反应」的最小复现:不切栏目、不搜索,画布状态一个都不变, + // 只靠聚焦请求序号让 effect 重跑。 + const panel = await renderGenerationLocateView({ + projectId: 'workbench-locate-same-column', + projectPath: '/tmp/workbench-locate-same-column', + openCategory: '角色与对象', + assetId: 'locate-same-column-asset', + ledgerRecords: [ + completedGenerationRecord({ + taskId: 'task-locate-same-column', + assetId: 'locate-same-column-asset', + projectId: 'workbench-locate-same-column', + }), + ], + }); + + fireEvent.click( + within(panel).getByRole('button', { name: '定位素材 定位目标素材' }), + ); + + await waitFor(() => + expect( + document.querySelector( + '.game-resource-card-select[data-resource-id="asset:locate-same-column-asset"][aria-pressed="true"]', + ), + ).not.toBeNull(), + ); + expect(screen.queryByText('正在定位生成的素材…')).toBeNull(); + }, 20_000); + + it('centers the canvas viewport on the located asset so it is actually visible', async () => { + // 「定位到素材」只把卡选中不够:这张画布是 transform 平移的,卡在视口外时 + // `scrollIntoView()` 碰不到任何滚动祖先,用户看到的就是"定位过去了但依然见不到素材"。 + const panel = await renderGenerationLocateView({ + projectId: 'workbench-locate-centers-viewport', + projectPath: '/tmp/workbench-locate-centers-viewport', + openCategory: '角色与对象', + assetId: 'locate-centers-asset', + ledgerRecords: [ + completedGenerationRecord({ + taskId: 'task-locate-centers', + assetId: 'locate-centers-asset', + projectId: 'workbench-locate-centers-viewport', + }), + ], + }); + + // 先把画布量出尺寸:jsdom 里 clientWidth/Height 恒为 0,不量就会静默走"没尺寸不居中"那条分支。 + // 居中使用的是栏目页画布(`.game-resource-page-canvas`),不是外层 `[aria-label]` 容器。 + const canvas = (await screen.findByLabelText( + '资源依赖视图', + )) as HTMLDivElement; + const pageCanvas = canvas.querySelector( + '.game-resource-page-canvas', + ); + expect(pageCanvas).not.toBeNull(); + Object.defineProperties(pageCanvas!, { + clientWidth: { configurable: true, get: () => 800 }, + clientHeight: { configurable: true, get: () => 600 }, + }); + + fireEvent.click( + within(panel).getByRole('button', { name: '定位素材 定位目标素材' }), + ); + + const card = await waitFor(() => { + const element = document.querySelector( + '.game-resource-card[data-resource-card-id="asset:locate-centers-asset"]', + ); + expect(element).not.toBeNull(); + return element!; + }); + await waitFor(() => + expect( + document.querySelector( + '.game-resource-card-select[data-resource-id="asset:locate-centers-asset"][aria-pressed="true"]', + ), + ).not.toBeNull(), + ); + + const readPixels = (name: string) => + Number( + /([\d.-]+)px/u.exec(card.style.getPropertyValue(name))?.[1] ?? 'NaN', + ); + const centerX = + readPixels('--resource-x') + readPixels('--resource-card-width') / 2; + const centerY = + readPixels('--resource-y') + readPixels('--resource-card-height') / 2; + expect(Number.isFinite(centerX)).toBe(true); + expect(Number.isFinite(centerY)).toBe(true); + + const world = document.querySelector( + '.game-resource-page-canvas[data-resource-section-scroll="character"] [data-resource-viewport]', + ); + const [viewportX, viewportY, viewportScale] = ( + world?.getAttribute('data-resource-viewport') ?? '' + ) + .split(',') + .map(Number); + expect(viewportScale).toBeGreaterThan(0); + // 视口平移量必须让卡片中心落在画布中心:`viewportX + centerX × scale = 画布宽 / 2`。 + const canvasWidth = pageCanvas!.clientWidth; + const canvasHeight = pageCanvas!.clientHeight; + expect(canvasWidth).toBeGreaterThan(0); + expect(canvasHeight).toBeGreaterThan(0); + expect(viewportX! + centerX * viewportScale!).toBeCloseTo( + canvasWidth / 2, + 0, + ); + expect(viewportY! + centerY * viewportScale!).toBeCloseTo( + canvasHeight / 2, + 0, + ); + }, 20_000); + + it('surfaces the existing clear-search action when the generated asset is filtered out', async () => { + const panel = await renderGenerationLocateView({ + projectId: 'workbench-locate-hidden', + projectPath: '/tmp/workbench-locate-hidden', + openCategory: '角色与对象', + assetId: 'locate-hidden-asset', + ledgerRecords: [ + completedGenerationRecord({ + taskId: 'task-locate-hidden', + assetId: 'locate-hidden-asset', + projectId: 'workbench-locate-hidden', + }), + ], + }); + + // 用搜索条件把目标素材挡掉:筛选面板的关键词就是画布唯一的搜索入口。 + fireEvent.keyDown(window, { key: 'f', ctrlKey: true }); + fireEvent.change(screen.getByLabelText('查找素材'), { + target: { value: 'zzz-no-such-resource' }, + }); + fireEvent.keyDown(document, { key: 'Escape' }); + + fireEvent.click( + within(panel).getByRole('button', { name: '定位素材 定位目标素材' }), + ); + + await waitFor(() => + expect(screen.queryByText('正在定位生成的素材…')).toBeNull(), + ); + expect( + screen.getByRole('button', { name: '清除搜索并定位' }), + ).not.toBeNull(); + }, 20_000); + + it('settles a locate request whose asset is not in the project at all', async () => { + const panel = await renderGenerationLocateView({ + projectId: 'workbench-locate-missing', + projectPath: '/tmp/workbench-locate-missing', + openCategory: '角色与对象', + assetId: 'locate-missing-asset', + ledgerRecords: [ + completedGenerationRecord({ + taskId: 'task-locate-missing', + assetId: 'asset-that-no-longer-exists', + projectId: 'workbench-locate-missing', + }), + ], + }); + + fireEvent.click( + within(panel).getByRole('button', { name: '定位素材 定位目标素材' }), + ); + + expect( + await screen.findByText('素材已不在项目里(可能已被删除)'), + ).not.toBeNull(); + expect(screen.queryByText('正在定位生成的素材…')).toBeNull(); + }, 20_000); + + /** + * 提交面板的公共夹具:一个带权威规范图的 UI 交互栏目视图,`startLocalAsset` 决定 + * `start_local_project_asset_generation` 这一次调用的行为。 + */ + async function renderAssetGenerationSubmitView(input: { + projectId: string; + projectPath: string; + startLocalAsset: (args: { + taskId: string; + assetName: string; + }) => Promise; + listRecords?: (started: Map>) => unknown; + /** 把首个原型标成已完成,让「运行」页签可切(`runAvailable` 为真)。 */ + runnable?: boolean; + }) { + const manifest = createGameCreationAppManifest( + input.projectId, + '生成提交面板测试', + ); + if (input.runnable) { + const codePrototype = manifest.tasks.find( + (task) => task.id === 'code-prototype', + ); + if (!codePrototype) { + throw new Error('missing code-prototype seed task'); + } + codePrototype.status = 'completed'; + } + manifest.assets = [ + { + id: 'submit-art-spec', + kind: 'icon-spec', + mediaType: 'image/png', + localPath: 'assets/art-spec.png', + source: { kind: 'canvas', resourceId: 'submit-art-spec-resource' }, + }, + ]; + const tasks = new Map>(); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: input.projectId, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'start_local_project_asset_generation') { + const started = await input.startLocalAsset({ + taskId: String(args?.taskId), + assetName: String(args?.assetName), + }); + if (started) { + tasks.set(String(args?.taskId), started as Record); + } + return started; + } + if (command === 'list_local_project_asset_generations') { + if (input.listRecords) { + return input.listRecords(tasks); + } + return [...tasks.values()]; + } + if (command === 'get_local_game_project_revision') { + return { revision: 7 }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: input.projectPath, + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + await openResourceBookCategory('UI 交互'); + fireEvent.click( + await screen.findByRole('button', { name: '生成 UI 设计图' }), + ); + const panel = await screen.findByRole('dialog', { + name: '生成 UI 设计图', + }); + fireEvent.change(within(panel).getByLabelText('素材名称'), { + target: { value: '待提交设计图' }, + }); + fireEvent.change(within(panel).getByLabelText('生成提示词'), { + target: { value: '主界面与背包页' }, + }); + return panel; + } + + it('closes the submission panel synchronously on submit and keeps stage text out of it', async () => { + // 提交这一步挂住:面板仍然必须立刻消失(不等受理、不等排队、不等生成)。 + const panel = await renderAssetGenerationSubmitView({ + projectId: 'workbench-submit-sync-close', + projectPath: '/tmp/workbench-submit-sync-close', + startLocalAsset: () => new Promise(() => undefined), + }); + expect(panel.textContent).not.toMatch(/排队中。|正在生成。|提交中…/); + + fireEvent.click( + within(panel).getByRole('button', { name: '生成 UI 设计图' }), + ); + + expect(screen.queryByRole('dialog', { name: '生成 UI 设计图' })).toBeNull(); + expect(panel.textContent).not.toMatch(/排队中。|正在生成。|提交中…/); + // 阶段文案只出现在任务面板 / 提示条里,不在提交面板里。 + expect(screen.getByRole('region', { name: '生成任务' })).not.toBeNull(); + }, 20_000); + + it('brings the submission panel back with the draft when the backend never accepted the submit', async () => { + const panel = await renderAssetGenerationSubmitView({ + projectId: 'workbench-submit-instant-failure', + projectPath: '/tmp/workbench-submit-instant-failure', + startLocalAsset: () => + Promise.reject( + new Error('项目权限策略拒绝执行:canvas.asset_generate'), + ), + }); + + fireEvent.click( + within(panel).getByRole('button', { name: '生成 UI 设计图' }), + ); + expect(screen.queryByRole('dialog', { name: '生成 UI 设计图' })).toBeNull(); + + // 后端从未受理 → 面板连草稿一起带回来,错误可见、可直接改后重试。 + const reopened = await screen.findByRole('dialog', { + name: '生成 UI 设计图', + }); + await waitFor(() => + expect(within(reopened).getByRole('alert').textContent).toContain( + '项目权限策略拒绝执行:canvas.asset_generate', + ), + ); + expect( + (within(reopened).getByLabelText('生成提示词') as HTMLTextAreaElement) + .value, + ).toBe('主界面与背包页'); + expect( + (within(reopened).getByLabelText('素材名称') as HTMLInputElement).value, + ).toBe('待提交设计图'); + }, 20_000); + + it('does not reopen the submission panel when an accepted task fails later', async () => { + const panel = await renderAssetGenerationSubmitView({ + projectId: 'workbench-submit-late-failure', + projectPath: '/tmp/workbench-submit-late-failure', + startLocalAsset: async ({ taskId }) => ({ + taskId, + projectId: 'workbench-submit-late-failure', + kind: 'ui-prototype', + assetName: '待提交设计图', + status: 'running', + phaseDetail: '正在生成。', + createdAtMillis: 1, + startedAtMillis: 1, + finishedAtMillis: null, + assetId: null, + error: null, + }), + // 后端已经受理(start 返回了记录),随后这次生成失败。 + listRecords: (started) => + [...started.values()].map((task) => ({ + ...task, + status: 'failed', + phaseDetail: '生成失败:远端拒绝', + finishedAtMillis: 2, + error: '远端拒绝', + })), + }); + + fireEvent.click( + within(panel).getByRole('button', { name: '生成 UI 设计图' }), + ); + + // 受理之后才失败:面板不回来,只在任务面板收口为失败 + 一次提示条。 + await waitFor(() => + expect(screen.getByText('生成素材失败:远端拒绝')).not.toBeNull(), + ); + expect(screen.queryByRole('dialog', { name: '生成 UI 设计图' })).toBeNull(); + expect( + within(screen.getByRole('region', { name: '生成任务' })).getByText( + '生成失败:远端拒绝', + ), + ).not.toBeNull(); + }, 20_000); + + it('keeps a generation progressing while the sidebar is collapsed', async () => { + // 前两轮轮询先保持「在途」,让折叠后的在途计数可观测,之后才收口。 + let listPolls = 0; + const panel = await renderAssetGenerationSubmitView({ + projectId: 'workbench-sidebar-collapsed', + projectPath: '/tmp/workbench-sidebar-collapsed', + // 运行页签要真的能切,末尾那条「运行态也能重开侧栏」才不是在资源态自证。 + runnable: true, + startLocalAsset: async ({ taskId }) => ({ + taskId, + projectId: 'workbench-sidebar-collapsed', + kind: 'ui-prototype', + assetName: '待提交设计图', + status: 'running', + phaseDetail: '正在生成。', + createdAtMillis: 1, + startedAtMillis: 1, + finishedAtMillis: null, + assetId: null, + error: null, + }), + listRecords: (started) => { + listPolls += 1; + return [...started.values()].map((task) => + listPolls >= 3 + ? { + ...task, + status: 'completed', + phaseDetail: '生成已完成。', + assetId: 'sidebar-collapsed-asset', + finishedAtMillis: 2, + } + : task, + ); + }, + }); + + fireEvent.click( + within(panel).getByRole('button', { name: '生成 UI 设计图' }), + ); + + // 提交后侧栏自动展开(对齐网页端:排队提交后主动弹任务栏)。 + const sidebar = await screen.findByRole('region', { name: '生成任务' }); + expect(within(sidebar).getByText('待提交设计图')).not.toBeNull(); + + // 折叠侧栏:折叠只影响这个视图,任务仍在后台推进。 + fireEvent.click( + within(sidebar).getByRole('button', { name: '关闭生成任务' }), + ); + await waitForTasksSidebarLeaveAnimationToFinish(); + // 画布上不留折叠把手:开合口只剩工具条那一枚「生成任务」按钮。 + expect( + document.querySelector('.game-resource-generation-tasks-handle'), + ).toBeNull(); + expect( + screen.getByRole('button', { name: /^生成任务(?: · \d+)?$/ }), + ).not.toBeNull(); + // 收口后工具条按钮上的在途计数跟着归零 —— 折叠期间进度照常更新; + // 工具条按钮的 aria-label 恒为「生成任务」,所以计数只能查 data 属性,不能查可访问名。 + await waitFor( + () => + expect( + ( + screen.getByRole('button', { + name: /^生成任务(?: · \d+)?$/, + }) as HTMLElement + ).dataset.resourceGenerationTaskCount, + ).toBe('0'), + { timeout: 15_000 }, + ); + + fireEvent.click(screen.getByRole('button', { name: '生成任务' })); + const reopened = await screen.findByRole('region', { name: '生成任务' }); + // 重开后条目内容与在途计数都跟上了收口结果。 + await waitFor( + () => + expect( + within(reopened).getByLabelText('在途生成任务 0').textContent, + ).toBe('0'), + { timeout: 15_000 }, + ); + expect(within(reopened).getByText('待提交设计图')).not.toBeNull(); + + // 入口在「运行」页签下同样常驻:侧栏本体在运行态可见,入口若只在资源页签就没法再打开。 + fireEvent.click( + within(reopened).getByRole('button', { name: '关闭生成任务' }), + ); + await waitForTasksSidebarLeaveAnimationToFinish(); + fireEvent.click(screen.getByRole('tab', { name: '运行' })); + // 先钉住真的切到了运行页签,否则下面那条断言只是在资源态自证。 + expect( + screen.getByRole('tab', { name: '运行' }).getAttribute('aria-selected'), + ).toBe('true'); + fireEvent.click( + screen.getByRole('button', { name: /^生成任务(?: · \d+)?$/ }), + ); + expect( + await screen.findByRole('region', { name: '生成任务' }), + ).not.toBeNull(); + }, 20_000); + it('routes the audio column entries to the existing audio generation chain', async () => { const manifest = createGameCreationAppManifest( 'workbench-bottom-toolbar-audio', diff --git a/apps/ai-game-creator-shell/tests/dev-port.test.ts b/apps/ai-game-creator-shell/tests/dev-port.test.ts index 1e2a1009b..e7a8f3036 100644 --- a/apps/ai-game-creator-shell/tests/dev-port.test.ts +++ b/apps/ai-game-creator-shell/tests/dev-port.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test, vi } from 'vitest'; import { + createAgcAdminWebEndpoint, createAgcDevEndpoint, + readConfiguredAgcAdminWebPort, + resolveAgcAdminWebEndpoint, resolveAgcDevEndpoint, withAgcDevEndpointEnv, } from '../scripts/dev-port.mjs'; @@ -128,3 +131,95 @@ describe('AI 游戏创作 dev 端口', () => { }); }); }); + +describe('AGC 开发态后台 Web 端口', () => { + test('Linux 使用用户端口段的 start + 3 槽位', async () => { + const findPort = vi.fn(async ({ preferredPort }) => preferredPort); + const endpoint = await resolveAgcAdminWebEndpoint({ + env: { USER: 'alice', LOGNAME: 'alice' }, + platform: 'linux', + reservePortRange: async () => ({ + username: 'alice', + range: { start: 10000, end: 10099, label: '10000-10099' }, + }), + findPort, + }); + + expect(endpoint).toMatchObject({ + port: 10003, + origin: 'http://127.0.0.1:10003', + basePath: '/admin/', + url: 'http://127.0.0.1:10003/admin/', + }); + expect(findPort).toHaveBeenCalledWith( + expect.objectContaining({ + preferredPort: 10003, + portRange: { start: 10000, end: 10099, label: '10000-10099' }, + strict: false, + }), + ); + }); + + test('AGC Vite 端口作为保留端口传入,避免被后台 Web 抢先占用', async () => { + const findPort = vi.fn(async ({ preferredPort }) => preferredPort); + await resolveAgcAdminWebEndpoint({ + env: {}, + platform: 'win32', + reservedPorts: [10005, 0, null as unknown as number], + findPort, + }); + + const [call] = findPort.mock.calls; + expect([...call[0].reservedPorts]).toEqual([10005]); + }); + + test('非 Linux 保留 3102 兼容优先端口并允许统一漂移', async () => { + const findPort = vi.fn(async ({ preferredPort }) => preferredPort + 1); + const endpoint = await resolveAgcAdminWebEndpoint({ + env: {}, + platform: 'win32', + findPort, + }); + + expect(endpoint.port).toBe(3103); + expect(endpoint.url).toBe('http://127.0.0.1:3103/admin/'); + expect(findPort).toHaveBeenCalledWith( + expect.objectContaining({ preferredPort: 3102, portRange: null }), + ); + }); + + test('显式 ADMIN_WEB_PORT 与脚本统一命名并支持严格占用', async () => { + const findPort = vi.fn(async ({ preferredPort }) => preferredPort); + await resolveAgcAdminWebEndpoint({ + env: { ADMIN_WEB_PORT: '3109' }, + platform: 'win32', + strictConfigured: true, + findPort, + }); + + expect(findPort).toHaveBeenCalledWith( + expect.objectContaining({ preferredPort: 3109, strict: true }), + ); + expect(readConfiguredAgcAdminWebPort({ ADMIN_WEB_PORT: '3109' })).toBe( + 3109, + ); + expect(readConfiguredAgcAdminWebPort({})).toBeNull(); + }); + + test('非法 ADMIN_WEB_PORT 直接失败而不是静默回退', () => { + expect(() => + readConfiguredAgcAdminWebPort({ ADMIN_WEB_PORT: '80' }), + ).toThrow('ADMIN_WEB_PORT 必须是 1024-65535 的有效端口'); + expect(() => + readConfiguredAgcAdminWebPort({ ADMIN_WEB_PORT: 'not-a-port' }), + ).toThrow('ADMIN_WEB_PORT 必须是 1024-65535 的有效端口'); + }); + + test('后台 Web 地址固定带 /admin/ base', () => { + expect(createAgcAdminWebEndpoint(3102, null)).toMatchObject({ + host: '127.0.0.1', + port: 3102, + url: 'http://127.0.0.1:3102/admin/', + }); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/nodeTransformGeometry.test.ts b/apps/ai-game-creator-shell/tests/nodeTransformGeometry.test.ts index cb414da56..e0ad8356b 100644 --- a/apps/ai-game-creator-shell/tests/nodeTransformGeometry.test.ts +++ b/apps/ai-game-creator-shell/tests/nodeTransformGeometry.test.ts @@ -23,7 +23,7 @@ function node(id: string, transform: Transform, children: Node[] = []): Node { id, layout: { transform } as Node['layout'], metadata: {} as Node['metadata'], - components: [], + component: null, children_display_mode: 'Stack', children, }; diff --git a/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx index 50a89c1e2..92abe5778 100644 --- a/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx +++ b/apps/ai-game-creator-shell/tests/previewWorkspaceZoom.test.tsx @@ -33,12 +33,12 @@ const root: UiNode = { 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: [], }; @@ -166,10 +166,9 @@ describe('PreviewWorkspace quick zoom', () => { ); }); - it('keeps actual-size and fit shortcuts within the preview scope', () => { + it('keeps the actual-size shortcut within the preview scope', () => { const rendered = render(); const preview = screen.getByRole('region', { name: 'UI 预览画布' }); - const fitted = logicalViewportScale(rendered.container); fireEvent.focus(preview); fireEvent.keyDown(window, { @@ -178,13 +177,21 @@ describe('PreviewWorkspace quick zoom', () => { cancelable: true, }); expect(logicalViewportScale(rendered.container)).toBe(1); + }); - fireEvent.keyDown(window, { - key: '0', - ctrlKey: true, - cancelable: true, - }); - expect(logicalViewportScale(rendered.container)).toBe(fitted); + it('refits the preview when the zoom percentage button is clicked', () => { + const rendered = render(); + const fitButton = screen.getByRole('button', { name: '适配画布' }); + const zoomInButton = screen.getByRole('button', { name: '放大画布' }); + const initial = logicalViewportScale(rendered.container); + + fireEvent.click(zoomInButton); + expect(logicalViewportScale(rendered.container)).toBeCloseTo( + initial * 1.16, + ); + + fireEvent.click(fitButton); + expect(logicalViewportScale(rendered.container)).toBeCloseTo(initial); }); it('leaves browser zoom untouched when the preview has no content', () => { diff --git a/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts b/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts index 5ef72b7db..519c3247f 100644 --- a/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts +++ b/apps/ai-game-creator-shell/tests/previewZoomKeyboard.test.ts @@ -12,7 +12,6 @@ import { function createActions(): PreviewZoomKeyboardActions { return { - fit: vi.fn(), resetToActualSize: vi.fn(), zoomIn: vi.fn(), zoomOut: vi.fn(), @@ -96,15 +95,12 @@ describe('preview zoom keyboard shortcuts', () => { expect(keyboardEvent?.defaultPrevented).toBe(false); }); - it.each([ - { key: '0', action: 'fit' as const }, - { key: '1', action: 'resetToActualSize' as const }, - ])('keeps the existing $key shortcut', ({ key, action }) => { + it('keeps the existing actual-size shortcut', () => { const { actions } = dispatchShortcut({ - event: { key, ctrlKey: true, cancelable: true }, + event: { key: '1', ctrlKey: true, cancelable: true }, }); - expect(actions[action]).toHaveBeenCalledTimes(1); + expect(actions.resetToActualSize).toHaveBeenCalledTimes(1); }); it('uses Cmd on Apple platforms and Ctrl elsewhere', () => { diff --git a/apps/ai-game-creator-shell/tests/projectAssetPickerDialogShellStyle.test.tsx b/apps/ai-game-creator-shell/tests/projectAssetPickerDialogShellStyle.test.tsx index c5d833cf5..6c37ac024 100644 --- a/apps/ai-game-creator-shell/tests/projectAssetPickerDialogShellStyle.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectAssetPickerDialogShellStyle.test.tsx @@ -99,11 +99,11 @@ function resolveStyleSpecifier(specifier: string, fromFile: string): string { /** 从 AGC 入口(`src/main.tsx`)出发,按 import / @import 关系收集它加载的样式表。 */ function collectAgcLoadedStyleSheets(): string[] { const entryFile = resolve(AGC_ROOT, 'src/main.tsx'); - const pending = [...readFileSync(entryFile, 'utf8').matchAll( - /import\s+'(?[^']+\.css)'/gu, - )].map((match) => - resolveStyleSpecifier(match.groups!.specifier!, entryFile), - ); + const pending = [ + ...readFileSync(entryFile, 'utf8').matchAll( + /import\s+'(?[^']+\.css)'/gu, + ), + ].map((match) => resolveStyleSpecifier(match.groups!.specifier!, entryFile)); const loaded: string[] = []; while (pending.length > 0) { @@ -160,7 +160,10 @@ function parseDeclarations(body: string): Map { continue; } const property = chunk.slice(0, separator).trim(); - const value = chunk.slice(separator + 1).trim().replace(/\s+/gu, ' '); + const value = chunk + .slice(separator + 1) + .trim() + .replace(/\s+/gu, ' '); if (property) { declarations.set(property, value); } @@ -229,8 +232,9 @@ function readRules(file: string): CssRule[] { /** 找"某个类自己就是一条独立选择器"的规则(不带后代/伪类限定)。 */ function findClassRule(rules: CssRule[], className: string): CssRule | null { return ( - rules.find((rule) => splitSelectorList(rule.selector).includes(className)) ?? - null + rules.find((rule) => + splitSelectorList(rule.selector).includes(className), + ) ?? null ); } @@ -254,9 +258,7 @@ describe('「选择替换素材」弹窗在 AGC 的面板底色', () => { it('AGC 真的加载到了共享样式表与主题表(清单本身可信)', () => { expect(loadedLabels).toContain(SHARED_STYLE_SHEET); expect(loadedLabels).toContain('packages/shared/src/theme.css'); - expect(loadedLabels).toContain( - 'apps/ai-game-creator-shell/src/styles.css', - ); + expect(loadedLabels).toContain('apps/ai-game-creator-shell/src/styles.css'); // 整站样式表不在 AGC 的加载清单里——这正是这个类原先"有类名没样式"的原因。 expect(loadedLabels).not.toContain('src/index.css'); // 反向对照:清单里确实有别的共享类规则,说明解析没有落空。 diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index d36de54c6..726e6bedd 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -1954,18 +1954,18 @@ describe('project resource live canvas integration', () => { fireEvent.click(await findResourceSelectButton('hero.png')); const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); - fireEvent.click(within(toolbar).getByRole('button', { name: '编辑标签' })); + fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' })); const dialog = await screen.findByRole('dialog', { - name: '编辑素材标签', + name: '设置素材类型', }); - // 面板里的类型选择器显示的就是卡片当前所在栏目。 - const categoryTab = within(dialog).getByRole('button', { + // 面板里的类型单选列表显示的就是卡片当前所在栏目(纵向列表:一行一个选项)。 + const categoryOption = within(dialog).getByRole('radio', { name: '角色与对象', }); - expect(categoryTab.getAttribute('aria-pressed')).toBe('true'); - fireEvent.click(within(dialog).getByRole('button', { name: '场景与环境' })); - fireEvent.click(within(dialog).getByRole('button', { name: '添加' })); + expect(categoryOption.getAttribute('aria-checked')).toBe('true'); + // 「选中即落盘」:这一次点击本身就是完整动作,不需要任何标签动作。 + fireEvent.click(within(dialog).getByRole('radio', { name: '场景与环境' })); await waitFor(() => { expect(classificationWrites(invoke)).toHaveLength(1); @@ -1982,12 +1982,10 @@ describe('project resource live canvas integration', () => { }, }); - // 关掉面板再看栏目(保存本身不关窗,关窗是头部 × 的职责)。 - fireEvent.click( - within(dialog).getByRole('button', { name: '关闭编辑素材标签' }), - ); + // 保存成功后由宿主收起面板(关窗不是用户的第二个动作):用户马上就能在画布上 + // 看到卡片落进新栏目,而不是隔着一块挡画布的浮层去猜。 await waitFor(() => - expect(screen.queryByRole('dialog', { name: '编辑素材标签' })).toBeNull(), + expect(screen.queryByRole('dialog', { name: '设置素材类型' })).toBeNull(), ); // 跟随一:总览「所有资源」摞里那张预览卡的角标**就地**跟着变(同一个卡片宿主, @@ -2015,6 +2013,152 @@ describe('project resource live canvas integration', () => { expect(queryResourceSelectButton('hero.png')).toBeNull(); }); + /** + * 两块分类浮层的关闭时机互不串台:类型面板与标签面板都是 portal 到 body 的模态浮层, + * 开着时「点外部清画布焦点」与画布自己的 Esc 都必须让位 —— + * 关掉的只能是浮层本身,画布选中与工具条都得留着。 + * + * 变异验证(已实测):把 `resourceTypeAssetId` 从 `isClassificationPanelOpen` 判据里去掉 + * (只留标签面板那一半),Esc 那一步会被画布的 Esc 抢走:类型面板关不掉、工具条一起消失, + * 本用例必须失败。 + */ + it('类型面板与标签面板:Esc / 点外部都只关浮层,不动画布选中', async () => { + const { invoke } = installTauri(); + render(); + + await openResourceBookCategory('角色与对象'); + fireEvent.click(await findResourceSelectButton('hero.png')); + const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); + + // 类型面板:先点外部(DOM 上落在画布管理区之外),浮层与选中都不受影响。 + fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' })); + await screen.findByRole('dialog', { name: '设置素材类型' }); + fireEvent.click(document.body); + expect(screen.getByRole('dialog', { name: '设置素材类型' })).not.toBeNull(); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); + + // Esc 只关类型面板,选中(工具条)留着 —— 用户接着还能做别的动作。 + fireEvent.keyDown(document.body, { key: 'Escape' }); + await waitFor(() => + expect(screen.queryByRole('dialog', { name: '设置素材类型' })).toBeNull(), + ); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); + + // 标签面板走同一份判据:Esc 关面板,不清选中。 + fireEvent.click( + within(screen.getByRole('toolbar', { name: '图片工具栏' })).getByRole( + 'button', + { name: '编辑标签' }, + ), + ); + await screen.findByRole('dialog', { name: '编辑素材标签' }); + fireEvent.keyDown(document.body, { key: 'Escape' }); + await waitFor(() => + expect(screen.queryByRole('dialog', { name: '编辑素材标签' })).toBeNull(), + ); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull(); + + // 收尾自检:没有写入发生 —— 这两个动作都不该改数据。 + expect(classificationWrites(invoke)).toHaveLength(0); + }); + + /** + * 只改标签不许动分类(验收判据里的"逐字不变"):落盘 `unclassified` + `kind:"ui"` 的资产 + * 在读显示口径下自愈成「UI 交互」,用户在标签面板里加一个标签后,manifest 上的 `category` + * 必须还是 `unclassified` —— 真机上这条资产正因回写自愈值同时出现过两种落盘值。 + * + * 变异验证(已实测):把标签面板的载荷改回读显示口径 + * (`gameCreationAppAssetCategory(asset)`),manifest 上的值会变成 `ui-interaction`, + * 本用例必须失败。 + */ + it('只改标签后 manifest 的 category 逐字不变(落盘 unclassified + kind ui 的资产)', async () => { + const { invoke } = installTauri(); + render(); + + await openResourceBookCategory('UI 交互'); + fireEvent.click(await findResourceSelectButton('panel.png')); + const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); + fireEvent.click(within(toolbar).getByRole('button', { name: '编辑标签' })); + const dialog = await screen.findByRole('dialog', { name: '编辑素材标签' }); + + fireEvent.change( + within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'), + { target: { value: '界面' } }, + ); + fireEvent.click(within(dialog).getByRole('button', { name: '添加' })); + + await waitFor(() => expect(classificationWrites(invoke)).toHaveLength(1)); + const persisted = canvasFixture.manifest?.assets.find( + (entry) => entry.id === 'asset-ui', + ); + // 落盘原值逐字不变(不是自愈出来的 ui-interaction),标签写进去了。 + expect(persisted?.category).toBe('unclassified'); + expect(persisted?.tags).toEqual(['界面']); + }); + + /** + * 第二入口:信息浮层的「分类」行点进「设置素材类型」。 + * + * 信息浮层是只读事实卡,新增的可点字段只有「分类」——点它等同于点工具条的「素材类型」: + * 同一块面板、同一条写入命令,不另开第二条写路径。 + * + * 变异验证:浮层不传 `onEditCategory`(或按钮渲染进 `dd`),本用例必须失败。 + */ + it('信息浮层的「分类」行可以进类型设置,落盘走同一条链路', async () => { + const { invoke } = installTauri(); + render(); + + await openResourceBookCategory('角色与对象'); + fireEvent.click(await findResourceSelectButton('hero.png')); + const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); + fireEvent.click(within(toolbar).getByRole('button', { name: '信息' })); + const infoPanel = await screen.findByRole('dialog', { name: '资源信息' }); + + // 分类值本身仍是只读文本(`dd` 里只有值,入口按钮在它外面)。 + expect( + Array.from(infoPanel.querySelectorAll('dt')).map( + (node) => node.textContent, + ), + ).toContain('分类'); + expect( + Array.from(infoPanel.querySelectorAll('dd')).map( + (node) => node.textContent, + ), + ).toContain('角色与对象'); + + fireEvent.click( + within(infoPanel).getByRole('button', { name: '设置素材类型' }), + ); + + // 信息浮层先收起(它锚在卡片位置,而卡片马上要换栏目),类型面板接着打开。 + expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull(); + const dialog = await screen.findByRole('dialog', { name: '设置素材类型' }); + expect( + within(dialog) + .getByRole('radio', { name: '角色与对象' }) + .getAttribute('aria-checked'), + ).toBe('true'); + + fireEvent.click(within(dialog).getByRole('radio', { name: '音频' })); + + await waitFor(() => { + expect(classificationWrites(invoke)).toHaveLength(1); + }); + expect(classificationWrites(invoke)[0]?.[1]).toEqual({ + input: { + projectPath, + expectedProjectId: 'live-canvas-project', + expectedProjectRevision: expect.any(Number), + assetId: 'asset-hero', + category: 'audio', + tags: [], + }, + }); + await waitFor(() => + expect(screen.queryByRole('dialog', { name: '设置素材类型' })).toBeNull(), + ); + }); + /** * 用户报的原始现象(PR #316 反馈):在「所有资源」一栏里,文档卡与图片卡**一开始**就自动 * 重叠 —— 文档那一排的第 2、3 行被图片栏的卡片整排压住。 diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts b/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts index 88e13dfaa..034388a99 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts @@ -169,7 +169,9 @@ describe('project resource live update model', () => { it('still fails closed when the protected assets change under the same revision', () => { // 对照钉子:判据面收窄到「资源 + 版本」不等于放过真正的同版本号内容漂移。 - const held = createProjectManifestMergeState(snapshot(4, 'initial', ['art'])); + const held = createProjectManifestMergeState( + snapshot(4, 'initial', ['art']), + ); const divergent = snapshot(4, 'supervisor', ['art', 'smuggled-art']); expect(mergeProjectManifestSnapshot(held, divergent).decision).toBe( 'revision-conflict', diff --git a/apps/ai-game-creator-shell/tests/resourceBookController.test.ts b/apps/ai-game-creator-shell/tests/resourceBookController.test.ts index adc8d6366..6c467d2c5 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookController.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookController.test.ts @@ -373,12 +373,12 @@ describe('resource book pile anchors', () => { now.mockReturnValue(1_300); const visual: MotionRect = { left: 650, top: 470, width: 140, height: 100 }; const clean: MotionRect = { left: 720, top: 520, width: 220, height: 160 }; - vi.mocked(boxes.get('art-4')!.getBoundingClientRect).mockImplementation(() => - toDomRect(visual), + vi.mocked(boxes.get('art-4')!.getBoundingClientRect).mockImplementation( + () => toDomRect(visual), ); flying.cancel.mockImplementation(() => { - vi.mocked(boxes.get('art-4')!.getBoundingClientRect).mockImplementation(() => - toDomRect(clean), + vi.mocked(boxes.get('art-4')!.getBoundingClientRect).mockImplementation( + () => toDomRect(clean), ); flying.reject(); }); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx new file mode 100644 index 000000000..1b7c0a8d0 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx @@ -0,0 +1,185 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; +import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; +import { ResourceCanvasGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView'; + +afterEach(() => { + cleanup(); +}); + +const uiPrototypeAction: ResourceCanvasAssetToolAction = { + id: 'generate-ui-prototype', + route: 'asset', + label: '生成 UI 设计图', + assetKind: 'ui-prototype', + audioKind: null, + assetName: 'AI 生成 UI 设计图', + promptPlaceholder: '描述这张界面要承载的玩法与操作', + adjustableDimensions: true, + aspectRatio: '16:9', + imageSize: '1K', + requiresIconSpecReference: true, + writesIconSpecReference: false, +}; + +/** 永不 resolve 的提交:音频面板仍然等生成结束,用它模拟在途状态。 */ +function pendingSubmit() { + return new Promise(() => undefined); +} + +describe('图片类生成面板:点击即关闭,面板里不出现阶段文案', () => { + test('点击生成同步调用提交并关闭面板,面板 DOM 里从不出现阶段 / 排队文案', async () => { + const onClose = vi.fn(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + await user.type(screen.getByLabelText('生成提示词'), '主界面与背包页'); + // 点击前主按钮文案就是动作名:不是阶段、也不是「已提交」。 + const panel = screen.getByRole('dialog', { name: '生成 UI 设计图' }); + expect( + ( + within(panel).getByRole('button', { + name: '生成 UI 设计图', + }) as HTMLButtonElement + ).disabled, + ).toBe(false); + expect(panel.textContent).not.toMatch(/排队中。|正在生成。|提交中…/); + + await user.click( + within(panel).getByRole('button', { name: '生成 UI 设计图' }), + ); + + // 提交与关闭在同一个事件循环里发生:面板不等受理、不等排队、不等生成。 + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + expect(panel.textContent).not.toMatch(/排队中。|正在生成。|提交中…/); + expect(screen.queryByRole('button', { name: '后台运行并关闭' })).toBeNull(); + }); + + test('× / Esc / 点遮罩仍能关面板,且关闭不等于取消', async () => { + const onClose = vi.fn(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + await user.type(screen.getByLabelText('生成提示词'), '主界面与背包页'); + + await user.click( + screen.getByRole('button', { name: '关闭生成 UI 设计图' }), + ); + expect(onClose).toHaveBeenCalledTimes(1); + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(2); + const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement; + fireEvent.pointerDown(backdrop); + fireEvent.pointerUp(backdrop); + fireEvent.click(backdrop); + expect(onClose).toHaveBeenCalledTimes(3); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + test('即时失败重开时带回草稿与失败原因,改完就能重试', async () => { + const onClose = vi.fn(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + expect(screen.getByRole('alert').textContent).toContain( + '生成素材失败:远端拒绝', + ); + expect( + (screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value, + ).toBe('主界面与背包页'); + + await user.click(screen.getByRole('button', { name: '生成 UI 设计图' })); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ + kind: 'ui-prototype', + prompt: '主界面与背包页', + assetName: 'AI 生成 UI 设计图', + }); + }); +}); + +describe('音频生成面板在提交期间可关闭', () => { + test('提交在途时点 × 能关闭面板,且不改动 pending-edit 账本语义', async () => { + const onClose = vi.fn(); + const onSubmit = vi.fn(pendingSubmit); + const user = userEvent.setup(); + render( + , + ); + await user.type(screen.getByLabelText('生成提示词'), '木门推开的声音'); + await user.click(screen.getByRole('button', { name: '生成音效' })); + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + + const closeButton = screen.getByRole('button', { + name: '关闭生成音效', + }) as HTMLButtonElement; + expect(closeButton.disabled).toBe(false); + await user.click(closeButton); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(2); + }); + + test('提交在途时提供「后台运行并关闭」', async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + await user.type(screen.getByLabelText('生成提示词'), '木门推开的声音'); + await user.click(screen.getByRole('button', { name: '生成音效' })); + + await user.click(screen.getByRole('button', { name: '后台运行并关闭' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts new file mode 100644 index 000000000..908aa2669 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts @@ -0,0 +1,489 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { + createResourceCanvasAssetGenerationQueue, + mergeResourceCanvasAssetGenerationTasksWithRecords, + RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT, + type ResourceCanvasAssetGenerationQueueDeps, + type ResourceCanvasAssetGenerationSettlement, +} from '../src/features/resource-canvas/resourceCanvasAssetGenerationQueue'; +import { + applyLocalProjectAssetGenerationRecords, + createResourceCanvasAssetGenerationTask, + type LocalProjectAssetGenerationTaskRecord, + nextResourceCanvasAssetGenerationDispatch, + RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE, + resourceCanvasAssetGenerationElapsedLabel, + resourceCanvasAssetGenerationKindLabel, + type ResourceCanvasAssetGenerationTask, + sortResourceCanvasAssetGenerationTasks, +} from '../src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel'; +import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; + +const uiPrototypeAction: ResourceCanvasAssetToolAction = { + id: 'generate-ui-prototype', + route: 'asset', + label: '生成 UI 设计图', + assetKind: 'ui-prototype', + audioKind: null, + assetName: 'AI 生成 UI 设计图', + promptPlaceholder: '描述这张界面要承载的玩法与操作', + adjustableDimensions: true, + aspectRatio: '16:9', + imageSize: '1K', + requiresIconSpecReference: true, + writesIconSpecReference: false, +}; + +function localTask(taskId: string, createdAtMillis: number) { + return createResourceCanvasAssetGenerationTask({ + taskId, + action: uiPrototypeAction, + prompt: `提示词 ${taskId}`, + assetName: `素材 ${taskId}`, + aspectRatio: '16:9', + imageSize: '1K', + outputPath: null, + projectId: 'project-1', + nowMillis: createdAtMillis, + }); +} + +function record( + taskId: string, + status: string, + extra: Partial = {}, +): LocalProjectAssetGenerationTaskRecord { + return { + taskId, + projectId: 'project-1', + kind: 'ui-prototype', + assetName: `素材 ${taskId}`, + status, + phaseDetail: status === 'running' ? '正在生成。' : '排队中。', + createdAtMillis: 1_000, + startedAtMillis: null, + finishedAtMillis: null, + assetId: null, + error: null, + ...extra, + }; +} + +describe('生成任务模型', () => { + test('新提交的任务先按本地排队呈现,阶段文案不是后端记录', () => { + const task = localTask('task-a', 10); + expect(task.dispatched).toBe(false); + expect(task.status).toBe('queued'); + expect(task.phaseDetail).toBe( + RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE, + ); + expect(task.restored).toBe(false); + }); + + test('有在途任务时下一条不可派发,前一条终态后才轮到它', () => { + const running = { + ...localTask('task-a', 10), + dispatched: true, + status: 'running' as const, + }; + const queued = localTask('task-b', 20); + expect(nextResourceCanvasAssetGenerationDispatch([running, queued])).toBe( + null, + ); + expect( + nextResourceCanvasAssetGenerationDispatch([ + { ...running, status: 'completed' }, + queued, + ])?.taskId, + ).toBe('task-b'); + expect( + nextResourceCanvasAssetGenerationDispatch([ + { ...running, status: 'failed' }, + queued, + ])?.taskId, + ).toBe('task-b'); + expect( + nextResourceCanvasAssetGenerationDispatch([ + { ...running, status: 'completed' }, + ]), + ).toBeNull(); + }); + + test('已派发但还没终态的任务同样挡住队列(不发第二条)', () => { + const dispatched = { ...localTask('task-a', 10), dispatched: true }; + expect( + nextResourceCanvasAssetGenerationDispatch([ + dispatched, + localTask('task-b', 20), + ]), + ).toBeNull(); + }); + + test('后端记录刷新本地任务:状态、阶段、时间戳与资源 id 都以后端为准', () => { + const merged = mergeResourceCanvasAssetGenerationTasksWithRecords( + [localTask('task-a', 10)], + [ + record('task-a', 'completed', { + phaseDetail: '生成已完成。', + assetId: 'asset-7', + startedAtMillis: 1_100, + finishedAtMillis: 1_900, + }), + ], + ); + expect(merged[0]?.status).toBe('completed'); + expect(merged[0]?.phaseDetail).toBe('生成已完成。'); + expect(merged[0]?.assetId).toBe('asset-7'); + expect(merged[0]?.finishedAtMillis).toBe(1_900); + expect(merged[0]?.restored).toBe(false); + }); + + test('重开项目后按账本恢复历史任务(本地没有草稿也能显示)', () => { + const merged = mergeResourceCanvasAssetGenerationTasksWithRecords( + [], + [ + record('task-old', 'completed', { + phaseDetail: '生成已完成。', + assetId: 'asset-1', + finishedAtMillis: 2_000, + }), + ], + ); + expect(merged).toHaveLength(1); + expect(merged[0]?.taskId).toBe('task-old'); + expect(merged[0]?.restored).toBe(true); + expect(merged[0]?.actionLabel).toBe('生成 UI 设计图'); + expect(merged[0]?.status).toBe('completed'); + }); + + test('后端没给过的状态按失败收口,展示顺序最新在前', () => { + const merged = mergeResourceCanvasAssetGenerationTasksWithRecords( + [localTask('task-a', 10), localTask('task-b', 20)], + [record('task-a', 'weird-status'), record('task-b', 'running')], + ); + expect(merged.find((task) => task.taskId === 'task-a')?.status).toBe( + 'failed', + ); + expect( + sortResourceCanvasAssetGenerationTasks(merged).map((task) => task.taskId), + ).toEqual(['task-b', 'task-a']); + }); + + test('kind 文案从工具栏模型派生,未知 kind 不编造', () => { + expect(resourceCanvasAssetGenerationKindLabel('ui-prototype')).toBe( + '生成 UI 设计图', + ); + expect(resourceCanvasAssetGenerationKindLabel('image')).toBe('生成图片'); + expect(resourceCanvasAssetGenerationKindLabel('nope')).toBeNull(); + }); + + test('已耗时文案按分秒呈现', () => { + expect(resourceCanvasAssetGenerationElapsedLabel(12_000)).toBe('12 秒'); + expect(resourceCanvasAssetGenerationElapsedLabel(72_000)).toBe( + '1 分 12 秒', + ); + expect(resourceCanvasAssetGenerationElapsedLabel(-5)).toBe('0 秒'); + }); +}); + +type Harness = { + deps: ResourceCanvasAssetGenerationQueueDeps; + invoke: ReturnType; + tasks: () => readonly ResourceCanvasAssetGenerationTask[]; + settlements: ResourceCanvasAssetGenerationSettlement[]; + /** 只统计生成提交命令;轮询用的读接口不计入「第二条不许发 IPC」。 */ + startCallCount: () => number; + advance: (taskId: string, status: 'completed' | 'failed') => void; + forget: (taskId: string) => void; + startError: Map; +}; + +/** + * 假后端 + 由轮询次数驱动的确定性脚本。 + * + * `wait` 在一次轮询之后被调用,所以「第 N 次轮询之后做什么」就是脚本的粒度,不需要计时器。 + */ +function createHarness(waitScript?: (pollCount: number, api: Harness) => void) { + const records = new Map(); + const startError = new Map(); + let tasks: ResourceCanvasAssetGenerationTask[] = []; + const settlements: ResourceCanvasAssetGenerationSettlement[] = []; + let pollCount = 0; + const harness: Harness = { + deps: undefined as unknown as ResourceCanvasAssetGenerationQueueDeps, + invoke: undefined as unknown as ReturnType, + tasks: () => tasks, + settlements, + startCallCount: () => + harness.invoke.mock.calls.filter( + ([command]) => command === 'start_local_project_asset_generation', + ).length, + advance: (taskId, status) => { + records.set( + taskId, + record(taskId, status, { + phaseDetail: + status === 'completed' ? '生成已完成。' : '生成失败:远端拒绝', + assetId: status === 'completed' ? `asset-${taskId}` : null, + error: status === 'completed' ? null : '远端拒绝', + finishedAtMillis: 5_000, + }), + ); + }, + startError, + forget: (taskId) => { + records.delete(taskId); + }, + }; + harness.invoke = vi.fn( + async (command: string, args: Record) => { + if (command === 'start_local_project_asset_generation') { + const taskId = String(args.taskId); + const failure = startError.get(taskId); + if (failure) { + throw failure; + } + const created = record(taskId, 'running', { startedAtMillis: 1_200 }); + records.set(taskId, created); + return created; + } + if (command === 'list_local_project_asset_generations') { + pollCount += 1; + return [...records.values()]; + } + throw new Error(`未预期的命令:${command}`); + }, + ); + harness.deps = { + invoke: harness.invoke as ResourceCanvasAssetGenerationQueueDeps['invoke'], + projectPath: () => '/tmp/project', + listTasks: () => tasks, + replaceTask: (task) => { + const index = tasks.findIndex((item) => item.taskId === task.taskId); + tasks = + index >= 0 + ? tasks.map((item) => (item.taskId === task.taskId ? task : item)) + : [...tasks, task]; + }, + onSettled: (settlement) => { + settlements.push(settlement); + }, + wait: async () => { + await Promise.resolve(); + waitScript?.(pollCount, harness); + }, + nowMillis: () => 9_999, + }; + return harness; +} + +describe('本地排队驱动器', () => { + test('第一条未完成时提交第二条:第二条仍是排队中且生成提交 IPC 只发了一次', async () => { + let assertedMidFlight = false; + const harness = createHarness((pollCount, api) => { + if (pollCount === 2 && !assertedMidFlight) { + assertedMidFlight = true; + // 第一条还在途:第二条必须停在本地队列里。 + expect(api.startCallCount()).toBe(1); + const second = api.tasks().find((task) => task.taskId === 'task-b'); + expect(second?.status).toBe('queued'); + expect(second?.dispatched).toBe(false); + expect(second?.phaseDetail).toBe( + RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE, + ); + api.advance('task-a', 'completed'); + } + if (pollCount === 4) { + api.advance('task-b', 'completed'); + } + }); + const queue = createResourceCanvasAssetGenerationQueue(harness.deps); + + const first = queue.submit(localTask('task-a', 10)); + const second = queue.submit(localTask('task-b', 20)); + await first; + await second; + + expect(assertedMidFlight).toBe(true); + // 第一条终态后自动补发第二条:生成提交 IPC 次数 = 2。 + expect(harness.startCallCount()).toBe(2); + expect( + harness.settlements.map((item) => [item.taskId, item.status]), + ).toEqual([ + ['task-a', 'completed'], + ['task-b', 'completed'], + ]); + expect(harness.tasks().map((task) => [task.taskId, task.status])).toEqual([ + ['task-a', 'completed'], + ['task-b', 'completed'], + ]); + }); + + test('第一条失败也会补发第二条,失败原因按后端记录留在任务上', async () => { + const harness = createHarness((pollCount, api) => { + if (pollCount === 1) { + // 第一条已经派发,此刻收口为失败。 + api.advance('task-a', 'failed'); + } + if (pollCount === 3) { + api.advance('task-b', 'completed'); + } + }); + const queue = createResourceCanvasAssetGenerationQueue(harness.deps); + const first = queue.submit(localTask('task-a', 10)); + const second = queue.submit(localTask('task-b', 20)); + + await expect(first).rejects.toThrow('远端拒绝'); + await expect(second).resolves.toBeUndefined(); + expect(harness.startCallCount()).toBe(2); + + const failed = harness.tasks().find((task) => task.taskId === 'task-a'); + expect(failed?.status).toBe('failed'); + expect(failed?.phaseDetail).toBe('生成失败:远端拒绝'); + expect(failed?.error).toBe('远端拒绝'); + expect(failed?.finishedAtMillis).toBe(5_000); + expect( + harness.tasks().find((task) => task.taskId === 'task-b')?.assetId, + ).toBe('asset-task-b'); + }); + + test('账本里找不到这条任务时不会无限轮询,收口为失败并放行后面的排队任务', async () => { + const harness = createHarness((pollCount, api) => { + if (pollCount === 1) { + // 第一条的记录被账本淘汰 / 项目被换掉:从这一轮起它再也查不到。 + harness.startError.set('task-a', new Error('never-used')); + api.forget('task-a'); + } + if (pollCount === 20) { + api.advance('task-b', 'completed'); + } + }); + const queue = createResourceCanvasAssetGenerationQueue(harness.deps); + const first = queue.submit(localTask('task-a', 10)); + const second = queue.submit(localTask('task-b', 20)); + + await expect(first).rejects.toThrow('生成任务账本里已找不到这条任务'); + await second; + expect(harness.startCallCount()).toBe(2); + }); + + test('生成提交 IPC 抛错时任务收口为失败,队列不会卡死', async () => { + const harness = createHarness((pollCount, api) => { + if (pollCount === 2) { + api.advance('task-b', 'completed'); + } + }); + harness.startError.set( + 'task-a', + new Error('项目权限策略拒绝执行:canvas.asset_generate'), + ); + const queue = createResourceCanvasAssetGenerationQueue(harness.deps); + const first = queue.submit(localTask('task-a', 10)); + const second = queue.submit(localTask('task-b', 20)); + + await expect(first).rejects.toThrow('项目权限策略拒绝执行'); + await second; + + const failed = harness.tasks().find((task) => task.taskId === 'task-a'); + expect(failed?.status).toBe('failed'); + expect(failed?.phaseDetail).toBe( + '生成失败:项目权限策略拒绝执行:canvas.asset_generate', + ); + // 提交失败的这一条没有终态记录也要能收口,否则第二条永远补发不出去。 + expect(harness.startCallCount()).toBe(2); + expect(harness.settlements[0]).toMatchObject({ + taskId: 'task-a', + status: 'failed', + record: null, + }); + }); +}); + +describe('非预期 IPC 形状 / 失败下的健壮性', () => { + test('账本读回 undefined / 非数组 / 垃圾条目时不抛错,本地任务列表保持原样', () => { + const tasks = [localTask('task-a', 10)]; + const unexpectedValues: unknown[] = [ + undefined, + null, + 'not-an-array', + 42, + { tasks: [] }, + [null, 7, 'x', {}, { taskId: '' }], + ]; + for (const unexpected of unexpectedValues) { + let merged: ResourceCanvasAssetGenerationTask[] = []; + expect(() => { + merged = applyLocalProjectAssetGenerationRecords( + tasks, + unexpected as readonly LocalProjectAssetGenerationTaskRecord[], + ); + }).not.toThrow(); + expect(merged).toHaveLength(1); + expect(merged[0]?.taskId).toBe('task-a'); + expect(merged[0]?.status).toBe('queued'); + } + }); + + test('轮询返回 undefined(旧壳 / 命令未注册)时不产生未处理拒绝,按上限收口为失败', async () => { + const harness = createHarness(); + harness.invoke.mockImplementation( + async (command: string, args?: Record) => { + if (command === 'start_local_project_asset_generation') { + return record(String(args?.taskId), 'running'); + } + if (command === 'list_local_project_asset_generations') { + // 旧壳 / 命令未注册:返回 undefined 而不是数组。 + return undefined; + } + throw new Error(`未预期的命令:${command}`); + }, + ); + const queue = createResourceCanvasAssetGenerationQueue(harness.deps); + const settled = queue.submit(localTask('task-a', 10)); + + await expect(settled).rejects.toThrow('生成任务状态读取失败'); + // 本地任务不被清空,只是按上限收口为失败并带上原因。 + const local = harness.tasks().find((item) => item.taskId === 'task-a'); + expect(local?.status).toBe('failed'); + expect(local?.phaseDetail).toContain('生成任务状态读取失败'); + expect(local?.phaseDetail).toContain('不是数组'); + expect(harness.settlements[0]).toMatchObject({ + taskId: 'task-a', + status: 'failed', + record: null, + }); + }); + + test('轮询 IPC 直接 reject 时同样被接住,收口为失败后继续放行排队任务', async () => { + const harness = createHarness(); + let listCalls = 0; + harness.invoke.mockImplementation( + async (command: string, args?: Record) => { + if (command === 'start_local_project_asset_generation') { + return record(String(args?.taskId), 'running'); + } + if (command === 'list_local_project_asset_generations') { + listCalls += 1; + if ( + listCalls <= + RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT + ) { + throw new Error('项目权限策略拒绝执行:asset.read'); + } + return [record('task-b', 'completed', { assetId: 'asset-task-b' })]; + } + throw new Error(`未预期的命令:${command}`); + }, + ); + const queue = createResourceCanvasAssetGenerationQueue(harness.deps); + const first = queue.submit(localTask('task-a', 10)); + const second = queue.submit(localTask('task-b', 20)); + + await expect(first).rejects.toThrow( + '生成任务状态读取失败,已停止等待:项目权限策略拒绝执行:asset.read', + ); + await expect(second).resolves.toBeUndefined(); + expect(harness.startCallCount()).toBe(2); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx new file mode 100644 index 000000000..41b20e135 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx @@ -0,0 +1,317 @@ +// @vitest-environment jsdom +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { act } from 'react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { + createResourceCanvasAssetGenerationTask, + type ResourceCanvasAssetGenerationTask, +} from '../src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel'; +import { + RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT, + RESOURCE_CANVAS_ASSET_GENERATION_TASKS_LEAVE_MILLIS, + ResourceCanvasAssetGenerationTasksPanelView, +} from '../src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView'; +import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; + +afterEach(() => { + cleanup(); +}); + +const uiPrototypeAction: ResourceCanvasAssetToolAction = { + id: 'generate-ui-prototype', + route: 'asset', + label: '生成 UI 设计图', + assetKind: 'ui-prototype', + audioKind: null, + assetName: 'AI 生成 UI 设计图', + promptPlaceholder: '描述这张界面要承载的玩法与操作', + adjustableDimensions: true, + aspectRatio: '16:9', + imageSize: '1K', + requiresIconSpecReference: true, + writesIconSpecReference: false, +}; + +function task( + patch: Partial & { taskId: string }, +): ResourceCanvasAssetGenerationTask { + return { + ...createResourceCanvasAssetGenerationTask({ + taskId: patch.taskId, + action: uiPrototypeAction, + prompt: '主界面与背包页', + assetName: '主界面设计图', + aspectRatio: '16:9', + imageSize: '1K', + outputPath: null, + projectId: 'project-1', + nowMillis: 1_000, + }), + ...patch, + }; +} + +function renderSidebar( + tasks: readonly ResourceCanvasAssetGenerationTask[], + options: { open?: boolean } = {}, +) { + const onToggleOpen = vi.fn(); + const onFocusTask = vi.fn(); + const view = render( + , + ); + return { onToggleOpen, onFocusTask, ...view }; +} + +describe('「生成任务」侧栏', () => { + test('展开时是非模态侧栏:没有全屏遮罩,也没有 aria-modal 与焦点陷阱', () => { + const { container } = renderSidebar([task({ taskId: 't1' })]); + expect(screen.getByRole('region', { name: '生成任务' })).not.toBeNull(); + expect(container.querySelector('[aria-modal="true"]')).toBeNull(); + expect(container.querySelector('.fixed.inset-0')).toBeNull(); + }); + + test('两个分栏各带条数,状态 / 阶段 / 已耗时 / 素材名逐条呈现', () => { + renderSidebar([ + task({ taskId: 't1', assetName: '主界面设计图' }), + task({ + taskId: 't2', + assetName: '背包界面设计图', + dispatched: true, + status: 'running', + // 前端的任何常量都不会产出「正在处理。」,出现它只可能来自后端记录。 + phaseDetail: '正在处理。', + startedAtMillis: 2_000, + }), + task({ + taskId: 't3', + assetName: '结算界面设计图', + dispatched: true, + status: 'completed', + phaseDetail: '生成已完成。', + assetId: 'asset-3', + finishedAtMillis: 13_000, + }), + task({ + taskId: 't4', + assetName: '设置界面设计图', + dispatched: true, + status: 'failed', + phaseDetail: '生成失败:远端拒绝', + error: '远端拒绝', + }), + ]); + + const activeSection = screen.getByRole('region', { name: '排队与生成中' }); + expect(within(activeSection).getByText('2')).not.toBeNull(); + expect(within(activeSection).getByText('主界面设计图')).not.toBeNull(); + expect(within(activeSection).getByText('排队中')).not.toBeNull(); + expect(within(activeSection).getByText('正在处理。')).not.toBeNull(); + expect(within(activeSection).getByText('生成中')).not.toBeNull(); + + // 头部标题里的在途计数:四条任务里两条未终态,徽标必须跟着走。 + expect(screen.getByLabelText('在途生成任务 2').textContent).toBe('2'); + + const doneSection = screen.getByRole('region', { name: '已完成' }); + expect(within(doneSection).getByText('2')).not.toBeNull(); + expect(within(doneSection).getByText('生成已完成。')).not.toBeNull(); + expect(within(doneSection).getByRole('alert').textContent).toBe('远端拒绝'); + expect( + screen.getAllByText(/^已耗时 \d+ (秒|分 \d+ 秒)$/).length, + ).toBeGreaterThan(0); + }); + + test('收起只有一个入口:头部那枚关闭按钮,收起即整块让出画布', async () => { + const user = userEvent.setup(); + const { onToggleOpen } = renderSidebar([task({ taskId: 't1' })]); + // 底部那枚重复的「关闭」连同它的分割线已经删掉:展开态只有一个关闭按钮。 + expect( + screen.getAllByRole('button', { name: '关闭生成任务' }), + ).toHaveLength(1); + expect( + document.querySelector('.game-resource-generation-tasks-sidebar-footer'), + ).toBeNull(); + await user.click(screen.getByRole('button', { name: '关闭生成任务' })); + expect(onToggleOpen).toHaveBeenCalledTimes(1); + }); + + test('侧栏高度有界、列表自己滚动,「已完成」条数封顶', () => { + const doneTasks = Array.from( + { length: RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT + 5 }, + (_, index) => + task({ + taskId: `done-${index}`, + assetName: `历史设计图 ${index}`, + dispatched: true, + status: 'completed', + phaseDetail: '生成已完成。', + assetId: `asset-${index}`, + finishedAtMillis: 2_000, + }), + ); + const { container } = renderSidebar(doneTasks); + + const aside = container.querySelector('aside') as HTMLElement; + // 高度有界与「列表自己滚动」在样式声明里钉(见 SidebarStyle 用例);这里只确认 + // 容器与滚动区都挂上了语义类,样式不会落空。 + expect(aside.className).toContain('game-resource-generation-tasks-sidebar'); + expect( + container.querySelector('[data-resource-generation-task-scroll]') + ?.className, + ).toContain('game-resource-generation-tasks-scroll'); + + expect( + within(screen.getByRole('region', { name: '已完成' })).getAllByRole( + 'listitem', + ), + ).toHaveLength(RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT); + expect( + screen.getByText( + `仅显示最近 ${RESOURCE_CANVAS_ASSET_GENERATION_DONE_SECTION_LIMIT} 条,另有 5 条较早记录`, + ), + ).not.toBeNull(); + }); + + test('折叠后不再渲染,画布上不留任何常驻入口或把手', async () => { + renderSidebar([], { open: false }); + expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull(); + expect(screen.queryByRole('button')).toBeNull(); + expect( + document.querySelector('.game-resource-generation-tasks-handle'), + ).toBeNull(); + expect( + document.querySelector('.game-resource-generation-tasks-sidebar'), + ).toBeNull(); + }); + + test('收起时先播完收起动画再卸载,动画期间列表内容不跳', () => { + vi.useFakeTimers(); + try { + const runningTask = task({ taskId: 't1', assetName: '主界面设计图' }); + const { rerender } = render( + , + ); + expect(screen.queryByRole('region', { name: '生成任务' })).not.toBeNull(); + + // 收起的同时把任务收口成已完成:退出动画必须沿用收起前那一份列表,内容不能跳。 + rerender( + , + ); + + const leaving = document.querySelector( + '.game-resource-generation-tasks-sidebar', + ); + expect(leaving).not.toBeNull(); + expect(leaving?.className).toContain('is-leaving'); + expect(within(leaving!).getByText('主界面设计图')).not.toBeNull(); + + // 动画时长走完就卸载,画布上不留东西。 + act(() => { + vi.advanceTimersByTime( + RESOURCE_CANVAS_ASSET_GENERATION_TASKS_LEAVE_MILLIS, + ); + }); + expect( + document.querySelector('.game-resource-generation-tasks-sidebar'), + ).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + test('只有已完成且拿到资源 id 的任务能定位到素材卡', async () => { + const user = userEvent.setup(); + const { onFocusTask } = renderSidebar([ + task({ + taskId: 't1', + assetName: '主界面设计图', + dispatched: true, + status: 'completed', + phaseDetail: '生成已完成。', + assetId: 'asset-1', + finishedAtMillis: 5_000, + }), + task({ + taskId: 't2', + assetName: '背包界面设计图', + dispatched: true, + status: 'running', + phaseDetail: '正在生成。', + }), + ]); + + expect( + screen.queryByRole('button', { name: '定位素材 背包界面设计图' }), + ).toBeNull(); + await user.click( + screen.getByRole('button', { name: '定位素材 主界面设计图' }), + ); + expect(onFocusTask).toHaveBeenCalledTimes(1); + expect(onFocusTask.mock.calls[0]?.[0]).toMatchObject({ + taskId: 't1', + assetId: 'asset-1', + }); + }); + + test('没有任务时给出空状态', () => { + renderSidebar([]); + expect(screen.getByRole('status').textContent).toBe('还没有生成任务'); + }); + + test('四种状态各自输出对应的 tone,样式按档落地', () => { + renderSidebar([ + task({ taskId: 't-queued' }), + task({ + taskId: 't-running', + dispatched: true, + status: 'running', + phaseDetail: '正在生成。', + }), + task({ + taskId: 't-completed', + dispatched: true, + status: 'completed', + phaseDetail: '生成已完成。', + assetId: 'asset-tone', + finishedAtMillis: 3_000, + }), + task({ + taskId: 't-failed', + dispatched: true, + status: 'failed', + phaseDetail: '生成失败:远端拒绝', + error: '远端拒绝', + }), + ]); + + const tones = ['queued', 'running', 'completed', 'failed'] as const; + for (const tone of tones) { + expect( + document.querySelector( + `.game-resource-generation-task-badge[data-tone='${tone}']`, + ), + ).not.toBeNull(); + } + // 每条任务一个徽标:tone 不重复、不漏档。 + expect( + document.querySelectorAll('.game-resource-generation-task-badge'), + ).toHaveLength(4); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksSidebarStyle.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksSidebarStyle.test.ts new file mode 100644 index 000000000..87c7f7f4d --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksSidebarStyle.test.ts @@ -0,0 +1,238 @@ +// @vitest-environment jsdom +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, test } from 'vitest'; + +import { + declaration, + parseStyleSheet, + resolveDeclarations, + type StyleRule, +} from './styleCascade'; + +/** + * 侧栏样式的声明级断言。 + * + * jsdom 不加载这个 CSS 文件,所以这里按仓库既有做法(`styleCascade`)直接解析**真实生效的声明**: + * 「状态 tone 映射」「等宽数字」「圆角 / 悬停」「过渡」「reduced-motion 关动效」都用声明钉住。 + */ +const SIDEBAR_CSS_PATH = resolve( + process.cwd(), + 'apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTasksSidebar.css', +); +const rules = parseStyleSheet(readFileSync(SIDEBAR_CSS_PATH, 'utf8')); + +/** + * `styleCascade` 的求值器只认识宽度媒体查询,遇到 `prefers-reduced-motion` 会主动报错; + * 那条规则的生效与否单独在下面按声明断言,所以求值时先把它排除掉。 + */ +const widthRules = rules.filter( + (rule) => !(rule.media ?? '').includes('prefers-reduced-motion'), +); + +function resolved( + selectors: readonly string[], + viewportWidth = 1280, +): Map { + return resolveDeclarations(widthRules, selectors, viewportWidth); +} + +function toneRule(tone: string): StyleRule | undefined { + return rules.find((rule) => + rule.selectors.includes( + `.game-resource-generation-task-badge[data-tone='${tone}']`, + ), + ); +} + +describe('「生成任务」侧栏样式', () => { + test('四种状态各有一档 tone,取值全部来自平台 token 而不硬编码颜色', () => { + const tones = ['queued', 'running', 'completed', 'failed'] as const; + const resolvedTones = tones.map((tone) => + resolved([ + '.game-resource-generation-task-badge', + `.game-resource-generation-task-badge[data-tone='${tone}']`, + ]), + ); + const signature = (map: Map) => + ['border-color', 'background', 'color'] + .map((property) => declaration(map, property)) + .join(' | '); + + const signatures = resolvedTones.map(signature); + // 四档必须互不相同,否则「状态徽标成体系」这条就退化成同一个样子。 + expect(new Set(signatures).size).toBe(4); + expect(signatures[0]).toContain('--platform-neutral'); + expect(signatures[1]).toContain('--platform-accent'); + expect(signatures[2]).toContain('--platform-success'); + expect(signatures[3]).toContain('--platform-button-danger'); + for (const one of signatures) { + expect(one).not.toMatch(/#[0-9a-f]{3,8}|rgba?\(|hsla?\(/iu); + } + // 四档 tone 必须在 CSS 里真实存在(少一档这条断言就红)。 + for (const tone of tones) { + expect(toneRule(tone)).toBeDefined(); + } + }); + + test('生成中只有「在动」的呼吸感,没有伪造百分比', () => { + const running = resolved([ + '.game-resource-generation-task-badge', + ".game-resource-generation-task-badge[data-tone='running']", + ]); + // 徽标本身不承载进度数值:文案由组件按后端状态给,样式里不出现百分比工具。 + expect(declaration(running, 'color')).toBe('var(--platform-accent)'); + const pulse = rules.find((rule) => + rule.selectors.includes( + ".game-resource-generation-task-badge[data-tone='running']::before", + ), + ); + expect(pulse?.declarations.get('animation')).toContain('pulse'); + }); + + test('阶段是次要信息、已耗时等宽数字右对齐', () => { + const phase = resolved(['.game-resource-generation-task-card-phase']); + expect(declaration(phase, 'color')).toBe('var(--platform-text-muted)'); + expect(declaration(phase, 'font-size')).toBe('0.7rem'); + + const elapsed = resolved(['.game-resource-generation-task-card-elapsed']); + expect(declaration(elapsed, 'font-variant-numeric')).toBe('tabular-nums'); + expect(declaration(elapsed, 'text-align')).toBe('right'); + }); + + test('条目卡片有圆角与悬停反馈,失败原因可折行不截断', () => { + const card = resolved(['.game-resource-generation-task-card']); + expect(declaration(card, 'border-radius')).toBe('0.5rem'); + + const hover = resolved([ + '.game-resource-generation-task-card', + '.game-resource-generation-task-card:hover', + ]); + expect(declaration(hover, 'box-shadow')).toBe( + 'var(--platform-desktop-hover-shadow)', + ); + expect(declaration(hover, 'transform')).toBe('translateY(-1px)'); + + const error = resolved(['.game-resource-generation-task-card-error']); + expect(declaration(error, 'white-space')).toBe('normal'); + expect(declaration(error, 'overflow-wrap')).toBe('anywhere'); + + const locate = resolved(['.game-resource-generation-task-locate']); + expect(declaration(locate, 'text-decoration')).toContain('underline'); + }); + + test('进场动画留在侧栏上,删除的折叠把手不再有样式残留', () => { + const sidebar = resolved(['.game-resource-generation-tasks-sidebar']); + expect(declaration(sidebar, 'animation')).toContain( + 'game-resource-generation-tasks-enter', + ); + expect(declaration(sidebar, 'border-radius')).toBe('0.75rem'); + expect(declaration(sidebar, 'backdrop-filter')).toBe('blur(10px)'); + + // 折叠把手与底部关闭条都已删除:对应类名不得再出现在样式表里。 + for (const removed of [ + '.game-resource-generation-tasks-handle', + '.game-resource-generation-tasks-handle-label', + '.game-resource-generation-tasks-handle-badge', + '.game-resource-generation-tasks-sidebar-footer', + ]) { + expect(rules.some((rule) => rule.selectors.includes(removed))).toBe( + false, + ); + } + }); + + test('收起与进场同源反向:is-leaving 挂上收起动画,减动效下同样关掉', () => { + const leaving = resolved([ + '.game-resource-generation-tasks-sidebar', + '.game-resource-generation-tasks-sidebar.is-leaving', + ]); + const animation = declaration(leaving, 'animation'); + expect(animation).toContain('game-resource-generation-tasks-leave'); + expect(animation).toContain('160ms'); + // 动画期间不该还能点到里面(此刻它在播退场,点它只会在卸载前留下半截状态)。 + expect(declaration(leaving, 'pointer-events')).toBe('none'); + expect( + rules.some((rule) => + rule.selectors.includes( + '.game-resource-generation-tasks-sidebar.is-leaving', + ), + ), + ).toBe(true); + + // 减少动效:收起动画同样关掉,组件那边会立即卸载,不白等 160ms。 + const reduced = rules.filter((rule) => + (rule.media ?? '').includes('prefers-reduced-motion'), + ); + expect( + reduced.some( + (rule) => + rule.selectors.includes( + '.game-resource-generation-tasks-sidebar.is-leaving', + ) && rule.declarations.get('animation') === 'none', + ), + ).toBe(true); + }); + + test('列表滚动条是细样式,滚动区域有界', () => { + const scroll = resolved(['.game-resource-generation-tasks-scroll']); + expect(declaration(scroll, 'overflow-y')).toBe('auto'); + expect(declaration(scroll, 'scrollbar-width')).toBe('thin'); + expect(declaration(scroll, 'overscroll-behavior')).toBe('contain'); + const thumb = rules.find((rule) => + rule.selectors.includes( + '.game-resource-generation-tasks-scroll::-webkit-scrollbar-thumb', + ), + ); + expect(thumb?.declarations.get('background')).toBe( + 'var(--platform-line-soft)', + ); + + // 侧栏高度有界:上下边界都给死,不靠内容撑高(内容再多也只滚列表)。 + const sidebar = resolved(['.game-resource-generation-tasks-sidebar']); + expect(declaration(sidebar, 'top')).toBe('4rem'); + expect(declaration(sidebar, 'bottom')).toBe('6rem'); + expect(declaration(sidebar, 'width')).toBe('min(300px, 80vw)'); + }); + + test('窄屏(360px)下侧栏不溢出不挡把手', () => { + const narrow = resolved(['.game-resource-generation-tasks-sidebar'], 360); + expect(declaration(narrow, 'width')).toBe('auto'); + expect(declaration(narrow, 'left')).toBe('0.5rem'); + expect(declaration(narrow, 'right')).toBe('0.5rem'); + }); + + test('prefers-reduced-motion 下进场动画与过渡都被关掉', () => { + const reduced = rules.filter((rule) => + (rule.media ?? '').includes('prefers-reduced-motion'), + ); + expect(reduced.length).toBeGreaterThan(0); + const motionless = (selector: string, property: string) => + reduced.some( + (rule) => + rule.selectors.includes(selector) && + rule.declarations.get(property) === 'none', + ); + expect( + motionless('.game-resource-generation-tasks-sidebar', 'animation'), + ).toBe(true); + expect( + motionless('.game-resource-generation-task-card', 'transition'), + ).toBe(true); + }); + + test('焦点环可见(键盘可见焦点用平台 token)', () => { + const focusRule = rules.find((rule) => + rule.selectors.includes( + '.game-resource-generation-tasks-sidebar-icon-button:focus-visible', + ), + ); + expect(focusRule?.declarations.get('outline')).toBe( + '2px solid var(--platform-accent)', + ); + expect(focusRule?.declarations.get('box-shadow')).toBe( + '0 0 0 3px var(--platform-input-focus-ring)', + ); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx index da9986e59..c228db240 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx @@ -417,16 +417,15 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { ); }); - test('空提示词不提交,失败保留草稿并可原样重试', async () => { - const onSubmit = vi - .fn<(input: unknown) => Promise>() - .mockRejectedValueOnce(new Error('图片比例不受支持:4:3')) - .mockResolvedValueOnce(undefined); - render( + test('空提示词不提交;点击即关闭,重开时带回草稿与失败原因', () => { + const onSubmit = vi.fn(); + const onClose = vi.fn(); + const action = assetActionOf('character', '生成角色形象'); + const { unmount } = render( undefined} + onClose={onClose} />, ); @@ -436,16 +435,40 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { target: { value: '披风猫骑士' }, }); fireEvent.click(submit); - expect(await screen.findByRole('alert')).not.toBeNull(); + // 点击即关闭:面板不等受理结果,失败由宿主决定要不要把它带回来。 + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + unmount(); + + // 即时失败重开:草稿与原因都带回来,用户改完就能重试(同一份草稿就是同一个请求)。 + const first = onSubmit.mock.calls[0]?.[0] as { + prompt: string; + assetName: string; + aspectRatio: string; + imageSize: string; + }; + render( + , + ); expect(screen.getByRole('alert').textContent).toContain( '图片比例不受支持:4:3', ); - // 失败不锁输入:同一份草稿再点一次就是同一个请求。 expect( - (screen.getByLabelText('生成提示词') as HTMLTextAreaElement).disabled, - ).toBe(false); + (screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value, + ).toBe('披风猫骑士'); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); - await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2)); + expect(onSubmit).toHaveBeenCalledTimes(2); expect(onSubmit.mock.calls[1]?.[0]).toEqual(onSubmit.mock.calls[0]?.[0]); }); }); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx new file mode 100644 index 000000000..5e94acb39 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationTasksSidebarDismiss.test.tsx @@ -0,0 +1,172 @@ +/** @vitest-environment jsdom */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import ProjectDevelopmentView from '../src/view/project-development'; +import { + createGameCreationAppManifest, + fireEvent, + React, + render, + screen, + waitFor, +} from './appSurface/harness'; + +/** + * 「生成任务」侧栏与工具条入口的三条口径: + * + * 1. **失去焦点即收起**:点画布、点别的工具栏按钮、点侧栏外部都算失去焦点;点侧栏内部 + * (含任务卡与「定位到素材」)与点那枚开合按钮不算——后者自己负责 toggle,否则会先被 + * 收起再被 toggle 打开,表现为按钮失灵。 + * 2. **入口顺序**:「生成任务」排在「依赖 / 类型」排列方式之前(动作在前、排列方式收行尾)。 + * 3. **行内间距只有一套**:「依赖 / 类型」与前一按钮之间不能只剩分段控件自带的那点间隙。 + */ + +function resourceGraphFor(resources: unknown) { + const resourceIds = Array.isArray(resources) + ? resources.map( + (resource) => (resource as { resourceId?: string }).resourceId ?? '', + ) + : []; + return { + resourceIds, + referenceEdges: [], + taskFlows: [], + categories: [], + diagnostics: [], + }; +} + +function installInvoke() { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphFor(args?.resources); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; +} + +async function renderWorkbench(projectId: string) { + installInvoke(); + const manifest = createGameCreationAppManifest( + projectId, + `${projectId} 项目`, + ); + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: `/tmp/${projectId}`, + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + const entry = await screen.findByRole('button', { name: '生成任务' }); + fireEvent.click(entry); + return screen.findByRole('region', { name: '生成任务' }); +} + +afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +describe('「生成任务」侧栏的开合与入口位置', () => { + it('点侧栏外部(画布 / 其他区域)自动收起', async () => { + const sidebar = await renderWorkbench('workbench-tasks-dismiss-outside'); + expect(sidebar).not.toBeNull(); + + fireEvent.pointerDown(document.body); + + await waitFor(() => + expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull(), + ); + }); + + it('点侧栏内部与开合按钮都不收起:内部保持打开,按钮仍能 toggle 收起来', async () => { + const sidebar = await renderWorkbench('workbench-tasks-dismiss-inside'); + // 侧栏内部点击(任务列表区域)不该被当成点外部。 + fireEvent.pointerDown(sidebar); + expect(screen.queryByRole('region', { name: '生成任务' })).not.toBeNull(); + + // 开合按钮自己负责 toggle:点一次必须真的收起(不能先被「点外部」收起再被 toggle 打开)。 + const entry = screen.getByRole('button', { name: '生成任务' }); + fireEvent.pointerDown(entry); + fireEvent.click(entry); + await waitFor(() => + expect(screen.queryByRole('region', { name: '生成任务' })).toBeNull(), + ); + + // 再点一次要能回来。 + fireEvent.click(entry); + expect( + await screen.findByRole('region', { name: '生成任务' }), + ).not.toBeNull(); + }); + + it('「生成任务」入口排在「依赖 / 类型」排列方式之前', async () => { + await renderWorkbench('workbench-tasks-entry-order'); + const actionsRow = document.querySelector('.game-workbench-view-actions'); + expect(actionsRow).not.toBeNull(); + const buttons = Array.from(actionsRow!.querySelectorAll('button')); + const tasksIndex = buttons.findIndex( + (button) => button.getAttribute('aria-label') === '生成任务', + ); + const sortIndex = buttons.findIndex( + (button) => button.getAttribute('aria-label') === '按依赖', + ); + expect(tasksIndex).toBeGreaterThanOrEqual(0); + expect(sortIndex).toBeGreaterThanOrEqual(0); + // 依赖 / 类型收在行尾,生成任务在它前面。 + expect(tasksIndex).toBeLessThan(sortIndex); + expect(sortIndex).toBe(buttons.length - 2); + }); + + it('「依赖 / 类型」与前一按钮之间的间距跟行内其他按钮一致', () => { + const styles = readFileSync( + resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'), + 'utf8', + ); + // 这一行是 gap: 7px 的 flex 容器;分段控件曾靠自带 padding: 3px 与前一按钮只隔 10px, + // 而其余按钮之间是 24px。margin-left 把它补回 24px,整行只剩一套间距。 + expect(styles).toMatch( + /\.game-workbench-tabs\.game-resource-sort-tabs\s*\{[^}]*margin-left:\s*17px/s, + ); + expect(styles).toMatch(/\.game-resource-sort-tabs\s*\{[^}]*padding:\s*0/s); + }); + + it('布局状态提示不参与动作行排版,不会按文案宽度顶开按钮', () => { + const styles = readFileSync( + resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'), + 'utf8', + ); + // 它是随保存过程变长的文案(空 →「保存中」→「布局已保存」→ 失败原因)。作为 flex 子项 + // 会把右侧按钮按文本宽度顶开,同一行的按钮间距就会随时刻变化;绝对定位后位置固定。 + expect(styles).toMatch( + /\.game-resource-reorder-status\s*\{[^}]*position:\s*absolute/s, + ); + expect(styles).toMatch( + /\.game-resource-reorder-status\s*\{[^}]*bottom:\s*4px[^}]*left:\s*12px/s, + ); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx new file mode 100644 index 000000000..4e92d37dc --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx @@ -0,0 +1,914 @@ +/** @vitest-environment jsdom */ + +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { + GameCreationAppAssetManifestEntry, + GameCreationAppManifest, + ProjectResourceCanvasCategory, + ProjectResourceCanvasPosition, +} from '../../../packages/shared/src/contracts/gameCreationApp'; +import ProjectDevelopmentView from '../src/view/project-development'; +import type { ResourceCanvasItem } from '../src/view/project-development/resourceCanvasLayoutModel'; +import { + createResourceSignature, + useProjectResourceCanvasLayout, +} from '../src/view/project-development/useProjectResourceCanvasLayout'; +import { + createGameCreationAppManifest, + findResourceSelectButton, + fireEvent, + openResourceFilterPanel, + React, + render, + screen, + within, +} from './appSurface/harness'; + +/** + * 「AGC 资源画布:新素材不自动重排 + 生成后自动聚焦 + 显式整理画布」的行为级验收。 + * + * 三条口径: + * 1. 画布不再因为「资源协调签名变化」重排整张画布——新增一张素材只补它的位置, + * 既有自动卡坐标逐值不变;要重排只能由用户按「整理画布」显式发起。 + * 2. 新素材入库后自动进入视口并被选中;被搜索条件挡住时沿用既有「清除搜索并定位」。 + * 3. 首次打开项目 / 切项目不触发聚焦跳转。 + */ + +const NEW_ASSET_ID = 'asset-newly-generated'; +const NEW_RESOURCE_ID = `asset:${NEW_ASSET_ID}`; + +type AssetFixture = GameCreationAppAssetManifestEntry; + +function pngAsset(id: string, fileName: string): AssetFixture { + return { + id, + kind: 'character', + category: 'character', + mediaType: 'image/png', + localPath: `assets/${fileName}`, + source: { kind: 'generated', resourceId: `${id}-resource` }, + }; +} + +function markdownAsset(id: string, fileName: string): AssetFixture { + return { + id, + kind: 'game-rules', + mediaType: 'text/markdown', + localPath: `docs/${fileName}`, + source: { kind: 'generated', resourceId: `${id}-resource` }, + }; +} + +function manifestFor( + projectId: string, + assets: AssetFixture[], +): GameCreationAppManifest { + return { + ...createGameCreationAppManifest(projectId, `${projectId} 项目`), + assets: assets.map((asset) => structuredClone(asset)), + }; +} + +type LayoutWrite = { + projectPath: string; + mode: string; + positions: ProjectResourceCanvasPosition[]; +}; + +type FakeTauri = { + invoke: ReturnType; + layoutWrites: LayoutWrite[]; + layoutReads: Array<{ projectPath: string; mode: string }>; + unexpectedCommands: string[]; +}; + +function resourceGraphFor( + resources: Array<{ resourceId: string }> | undefined, +) { + const resourceIds = (resources ?? []).map((resource) => resource.resourceId); + return { + resourceIds, + referenceEdges: [], + taskFlows: [], + connectionIndex: resourceIds.map((resourceId) => ({ + resourceId, + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: [], + referenceEdgeIds: [], + taskFlowIds: [], + })), + producerAssignments: [], + dependencyDepths: resourceIds.map((resourceId) => ({ + resourceId, + dependencyDepth: 0, + })), + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; +} + +/** + * 只铺张画布真正用到的那几个本地命令。 + * + * 未知命令返回 `undefined` 并记账(而不是抛错):这份用例验的是布局与聚焦链路, + * 不该因为画布里别的入口多调一个命令就整体转红;真要漏了关键命令, + * 目标链路自己会停在"等待"上,断言照样失败。 + */ +function installLayoutTauri( + options: { + projectIdsByPath?: Record; + layoutByScope?: Record; + } = {}, +): FakeTauri { + const layoutWrites: LayoutWrite[] = []; + const layoutReads: Array<{ projectPath: string; mode: string }> = []; + const unexpectedCommands: string[] = []; + const persisted = new Map(); + const revisions = new Map(); + for (const [key, positions] of Object.entries(options.layoutByScope ?? {})) { + persisted.set(key, structuredClone(positions)); + } + + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_local_game_project_revision') { + return { revision: 1 }; + } + if (command === 'read_local_project_resource_graph') { + return resourceGraphFor( + args?.resources as Array<{ resourceId: string }> | undefined, + ); + } + if (command === 'read_local_project_resource_canvas_layout') { + const projectPath = String(args?.projectPath ?? ''); + const mode = String(args?.mode ?? ''); + layoutReads.push({ projectPath, mode }); + const key = `${projectPath}|${mode}`; + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: options.projectIdsByPath?.[projectPath] ?? '', + mode, + revision: revisions.get(key) ?? 0, + positions: structuredClone(persisted.get(key) ?? []), + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + const projectPath = String(args?.projectPath ?? ''); + const mode = String(args?.mode ?? ''); + const key = `${projectPath}|${mode}`; + const positions = structuredClone( + (args?.positions ?? []) as ProjectResourceCanvasPosition[], + ); + persisted.set(key, positions); + revisions.set(key, Number(args?.expectedRevision ?? 0) + 1); + layoutWrites.push({ projectPath, mode, positions }); + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: String(args?.expectedProjectId ?? ''), + mode, + revision: revisions.get(key)!, + positions, + updatedAt: 1, + }, + }; + } + if (command === 'list_pending_local_project_resource_edits') { + return []; + } + if (command === 'list_local_project_asset_generations') { + /* + * 后台生成任务账本(`../src-tauri/src/asset_generation_tasks.rs` 的 + * `list_local_project_asset_generations`)。工作台打开 / 切项目时读一次,用来恢复 + * 「生成任务」面板;返回结构是 `Vec`,也就是**数组** + * (前端按 `LocalProjectAssetGenerationTaskRecord[]` 消费),不是 `{ tasks }` 包一层。 + * + * 本文件验的是布局与聚焦链路,不涉及后台生成任务,所以回空账本: + * `mergeResourceCanvasAssetGenerationTasksWithRecords(tasks, [])` 是恒等合并。 + * 这是合法的跨工作流新调用,登记它,而不是放宽下面的 `unexpectedCommands` 断言。 + */ + return []; + } + if (command === 'read_local_project_image_preview') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'image/png', + byteLen: 1, + dataUrl: 'data:image/png;base64,AA==', + }; + } + if (command === 'read_local_project_text_preview') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'text/markdown', + byteLen: 8, + content: '# 玩法规则', + }; + } + unexpectedCommands.push(command); + return undefined; + }, + ); + + window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + return { invoke, layoutWrites, layoutReads, unexpectedCommands }; +} + +function typeWrites(tauri: FakeTauri) { + return tauri.layoutWrites.filter((write) => write.mode === 'type'); +} + +function dependencyWrites(tauri: FakeTauri) { + return tauri.layoutWrites.filter((write) => write.mode === 'dependency'); +} + +function selectedResourceIdsInDom() { + return Array.from( + document.querySelectorAll('[data-resource-id]'), + ) + .filter((element) => element.getAttribute('aria-pressed') === 'true') + .map((element) => element.dataset.resourceId); +} + +/** + * 把布局读写与聚焦裁决链跑到底再下断言。 + * + * 「不该聚焦」这类否定断言最怕"跑得太早":断言时链路还没走到聚焦那一步,写什么都会绿。 + * 这里先把微任务与一个宏任务放完,让链路在该触发的情况下已经触发过。 + */ +async function settleFocusChain() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +type ProjectFixture = { + projectId: string; + projectPath: string; + assets: AssetFixture[]; +}; + +/** + * 把 manifest 交给真实工作台视图持有,并提供两个"外部世界"动作: + * 「入库新素材」(等价于生成流程落盘后 `onManifestChange` 收到多一条 asset 的清单) + * 与「切换项目」(换 projectPath + projectId + 清单)。 + */ +function LayoutWorkbench({ + projects, + appendedAsset, +}: { + projects: ProjectFixture[]; + appendedAsset?: AssetFixture; +}) { + const [activeIndex, setActiveIndex] = React.useState(0); + const [manifests, setManifests] = React.useState< + Record + >(() => + Object.fromEntries( + projects.map((project) => [ + project.projectId, + manifestFor(project.projectId, project.assets), + ]), + ), + ); + const active = projects[activeIndex]!; + const manifest = manifests[active.projectId]!; + + return ( + <> + {appendedAsset ? ( + + ) : null} + {projects.length > 1 ? ( + + ) : null} + Supervisor
} + onHomeOpen={() => undefined} + onProjectsOpen={() => undefined} + onManifestChange={(_path, nextManifest) => + setManifests((current) => { + const projectId = nextManifest.projectId; + return { ...current, [projectId]: nextManifest }; + }) + } + /> + + ); +} + +afterEach(() => { + delete window.__TAURI__; + vi.restoreAllMocks(); +}); + +describe('资源画布手动重排口径', () => { + it('hook:rederiveNow 按 rederive 策略重算自动坐标并写回一次', async () => { + const projectId = 'manual-rederive-project'; + const projectPath = '/tmp/manual-rederive-project'; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: args?.mode, + revision: 3, + positions: [ + { + resourceId: 'resource-b', + section: 'document', + x: 600, + y: 40, + manuallyPlaced: true, + }, + { + resourceId: 'resource-a', + section: 'document', + x: 900, + y: 900, + manuallyPlaced: false, + }, + ], + updatedAt: 300, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: args?.mode, + revision: 4, + positions: args?.positions, + updatedAt: 400, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { + core: { invoke }, + } as unknown as typeof window.__TAURI__; + + const resources: ResourceCanvasItem[] = ['resource-a', 'resource-b'].map( + (id) => ({ + id, + category: 'document' as ProjectResourceCanvasCategory, + subtype: 'agent-result', + label: id, + mediaType: 'text/markdown', + dependencyDepth: 0, + }), + ); + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'type', + resources, + rederiveAutomaticPositions: false, + }), + ); + + await waitFor(() => expect(result.current.ready).toBe(true)); + // 自动卡还停在落后坐标上:preserve 口径不会自己去纠正它。 + expect( + result.current.layout.positions.find( + (position) => position.resourceId === 'resource-a', + ), + ).toMatchObject({ x: 900, y: 900 }); + + await act(async () => { + result.current.rederiveNow(); + }); + + await waitFor(() => + expect( + invoke.mock.calls.filter( + ([command]) => + command === 'update_local_project_resource_canvas_layout', + ), + ).toHaveLength(1), + ); + const written = invoke.mock.calls.find( + ([command]) => command === 'update_local_project_resource_canvas_layout', + )?.[1]?.positions as ProjectResourceCanvasPosition[]; + expect(written).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'resource-a', + x: 0, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-b', + x: 600, + y: 40, + manuallyPlaced: true, + }), + ]), + ); + }); + + it('新素材入库后既有自动卡坐标逐值不变,新卡只补在末尾', async () => { + const tauri = installLayoutTauri({ + projectIdsByPath: { + '/tmp/manual-layout-project': 'manual-layout-project', + }, + }); + render( + , + ); + + await waitFor(() => expect(typeWrites(tauri).length).toBeGreaterThan(0)); + const before = typeWrites(tauri).at(-1)!; + expect( + before.positions.map((position) => position.resourceId), + ).not.toContain(NEW_RESOURCE_ID); + + fireEvent.click( + await screen.findByRole('button', { name: '测试:入库新素材' }), + ); + + await waitFor(() => + expect( + typeWrites(tauri).some((write) => + write.positions.some( + (position) => position.resourceId === NEW_RESOURCE_ID, + ), + ), + ).toBe(true), + ); + const after = typeWrites(tauri).find((write) => + write.positions.some( + (position) => position.resourceId === NEW_RESOURCE_ID, + ), + )!; + + // 核心判据:除新卡外全部既有坐标逐值不变(顺序、分区、手动标记都不许动)。 + expect( + after.positions.filter( + (position) => position.resourceId !== NEW_RESOURCE_ID, + ), + ).toEqual( + before.positions.filter( + (position) => position.resourceId !== NEW_RESOURCE_ID, + ), + ); + }); + + it('依赖画布只在关系图首次就绪时重算一次,之后新增素材不再重排', async () => { + const projectPath = '/tmp/manual-dependency-project'; + const tauri = installLayoutTauri({ + projectIdsByPath: { [projectPath]: 'manual-dependency-project' }, + layoutByScope: { + [`${projectPath}|dependency`]: [ + { + resourceId: 'asset:asset-art-b', + section: 'character', + x: 0, + y: 0, + manuallyPlaced: false, + }, + { + resourceId: 'asset:asset-art-a', + section: 'character', + x: 900, + y: 900, + manuallyPlaced: false, + }, + ], + }, + }); + render( + , + ); + + // 一次性重派生:关系图首次就绪后按最终拓扑把落后的自动坐标对齐一次。 + await waitFor(() => + expect( + dependencyWrites(tauri).some((write) => + write.positions.some( + (position) => + position.resourceId === 'asset:asset-art-a' && + (position.x !== 900 || position.y !== 900), + ), + ), + ).toBe(true), + ); + const before = dependencyWrites(tauri).at(-1)!; + expect( + before.positions.map((position) => position.resourceId), + ).not.toContain(NEW_RESOURCE_ID); + + fireEvent.click( + await screen.findByRole('button', { name: '测试:入库新素材' }), + ); + + await waitFor(() => + expect( + dependencyWrites(tauri).some((write) => + write.positions.some( + (position) => position.resourceId === NEW_RESOURCE_ID, + ), + ), + ).toBe(true), + ); + const after = dependencyWrites(tauri).find((write) => + write.positions.some( + (position) => position.resourceId === NEW_RESOURCE_ID, + ), + )!; + + expect( + after.positions.filter( + (position) => position.resourceId !== NEW_RESOURCE_ID, + ), + ).toEqual( + before.positions.filter( + (position) => position.resourceId !== NEW_RESOURCE_ID, + ), + ); + }); + + it('新素材入库后自动进入视口并被选中', async () => { + const tauri = installLayoutTauri({ + projectIdsByPath: { '/tmp/manual-focus-project': 'manual-focus-project' }, + }); + render( + , + ); + + await waitFor(() => expect(typeWrites(tauri).length).toBeGreaterThan(0)); + expect(selectedResourceIdsInDom()).toEqual([]); + + fireEvent.click( + await screen.findByRole('button', { name: '测试:入库新素材' }), + ); + + await waitFor(() => + expect(new Set(selectedResourceIdsInDom())).toEqual( + new Set([NEW_RESOURCE_ID]), + ), + ); + }); + + it('新素材被搜索条件挡住时走既有「清除搜索并定位」路径', async () => { + const tauri = installLayoutTauri({ + projectIdsByPath: { + '/tmp/manual-focus-hidden-project': 'manual-focus-hidden-project', + }, + }); + render( + , + ); + + await findResourceSelectButton('art-b.png'); + const search = openResourceFilterPanel(); + fireEvent.change(search, { target: { value: 'art-b' } }); + expect(selectedResourceIdsInDom()).toEqual([]); + + fireEvent.click(screen.getByRole('button', { name: '测试:入库新素材' })); + + expect( + await screen.findByText('新资源已保存,但被当前搜索条件隐藏'), + ).not.toBeNull(); + // 搜索条件只由显式动作清除,不静默改用户输入。 + expect(openResourceFilterPanel().value).toBe('art-b'); + fireEvent.click(screen.getByRole('button', { name: '清除搜索并定位' })); + + await waitFor(() => + expect(new Set(selectedResourceIdsInDom())).toEqual( + new Set([NEW_RESOURCE_ID]), + ), + ); + expect(tauri.unexpectedCommands).toEqual([]); + }); + + it('「整理画布」按 rederive 重算自动坐标、保留手动坐标,并给出一次可见反馈', async () => { + const projectPath = '/tmp/manual-rederive-button-project'; + const tauri = installLayoutTauri({ + projectIdsByPath: { + [projectPath]: 'manual-rederive-button-project', + }, + layoutByScope: { + [`${projectPath}|type`]: [ + { + resourceId: 'asset:asset-art-a', + section: 'character', + x: 600, + y: 40, + manuallyPlaced: true, + }, + { + resourceId: 'asset:asset-art-b', + section: 'character', + x: 800, + y: 800, + manuallyPlaced: false, + }, + ], + }, + }); + render( + , + ); + + await waitFor(() => + expect( + document.querySelector('[data-resource-id="asset:asset-art-b"]'), + ).not.toBeNull(), + ); + // 切到「类型」视图:两个排序模式各有一份 sidecar,按钮作用于当前生效的那一份。 + fireEvent.click(screen.getByRole('button', { name: '按类型' })); + // 打开项目 / 切排序 tab 这两步都不该重排:自动卡的落后坐标原样保留。 + expect(typeWrites(tauri)).toEqual([]); + + fireEvent.click(screen.getByRole('button', { name: '整理画布' })); + + await waitFor(() => expect(typeWrites(tauri)).toHaveLength(1)); + expect(typeWrites(tauri)[0]!.positions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'asset:asset-art-b', + section: 'character', + x: 0, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'asset:asset-art-a', + section: 'character', + x: 600, + y: 40, + manuallyPlaced: true, + }), + ]), + ); + expect(await screen.findByText('布局已保存')).not.toBeNull(); + + // 已经整齐之后再按一次不产生第二次落盘:重算结果与当前坐标一致时不写(既有「截断关系图 + // 不得持久化自动布局」用例依赖同一条 `changed` 门)。 + fireEvent.click(screen.getByRole('button', { name: '整理画布' })); + await settleFocusChain(); + expect(typeWrites(tauri)).toHaveLength(1); + }); + + it('「整理画布」不属于「资源排列方式」这组模式切换,而是一枚资源动作按钮', async () => { + const projectPath = '/tmp/manual-layout-surface-project'; + const tauri = installLayoutTauri({ + projectIdsByPath: { [projectPath]: 'manual-layout-surface-project' }, + }); + render( + , + ); + + const sortGroup = await screen.findByRole('group', { + name: '资源排列方式', + }); + // 这组里只有两种排列方式:用户不该把「整理画布」读成第三种排列方式。 + expect( + within(sortGroup) + .getAllByRole('button') + .map((button) => button.getAttribute('aria-label')), + ).toEqual(['按依赖', '按类型']); + expect( + within(sortGroup).queryByRole('button', { name: '整理画布' }), + ).toBeNull(); + + // 它仍是同一行里的同一枚动作按钮,只是搬出了那个 group、也离开了行尾。 + const rederiveButton = screen.getByRole('button', { name: '整理画布' }); + expect(sortGroup.contains(rederiveButton)).toBe(false); + expect(rederiveButton.closest('.game-resource-sort-tabs')).toBeNull(); + const actionsRow = rederiveButton.closest('.game-workbench-view-actions'); + expect(actionsRow).not.toBeNull(); + + // 位置:不再是这一行的最后一个按钮(行尾会被读成"针对整个工具条"的动作), + // 紧跟「生成素材」,并且在排序组左侧。 + const rowButtons = Array.from(actionsRow!.querySelectorAll('button')); + expect(rowButtons.at(-1)).not.toBe(rederiveButton); + const rederiveIndex = rowButtons.indexOf(rederiveButton); + const sortGroupIndex = rowButtons.findIndex((button) => + sortGroup.contains(button), + ); + expect(rederiveIndex).toBeGreaterThanOrEqual(0); + expect(sortGroupIndex).toBeGreaterThanOrEqual(0); + expect(rederiveIndex).toBeLessThan(sortGroupIndex); + expect(rederiveButton.previousElementSibling).toBe( + screen.getByRole('button', { name: '生成素材' }), + ); + + // 语义没变:可点性只跟布局就绪绑定,布局读完后它就是可点的。 + await waitFor(() => + expect( + screen + .getByRole('button', { name: '整理画布' }) + .hasAttribute('disabled'), + ).toBe(false), + ); + }); + + it('首次打开项目与切项目都不触发新素材聚焦跳转', async () => { + const tauri = installLayoutTauri({ + projectIdsByPath: { + '/tmp/manual-open-project-a': 'manual-open-project-a', + '/tmp/manual-open-project-b': 'manual-open-project-b', + }, + }); + render( + , + ); + + await waitFor(() => + expect( + document.querySelector('[data-resource-id="asset:asset-a1"]'), + ).not.toBeNull(), + ); + // 布局读写先跑完,再给聚焦裁决链一次"要是会被误触发就已经触发"的机会。 + await waitFor(() => expect(typeWrites(tauri).length).toBeGreaterThan(0)); + await settleFocusChain(); + expect(selectedResourceIdsInDom()).toEqual([]); + + fireEvent.click(screen.getByRole('button', { name: '测试:切换项目' })); + + await waitFor(() => + expect( + tauri.layoutReads.some( + (read) => read.projectPath === '/tmp/manual-open-project-b', + ), + ).toBe(true), + ); + await waitFor(() => + expect( + document.querySelector('[data-resource-id="asset:asset-b1"]'), + ).not.toBeNull(), + ); + await waitFor(() => + expect( + typeWrites(tauri).some( + (write) => write.projectPath === '/tmp/manual-open-project-b', + ), + ).toBe(true), + ); + await settleFocusChain(); + expect(selectedResourceIdsInDom()).toEqual([]); + }); + + it('资源协调签名仍把新增素材算作变化(重排判据没有被人为掐掉)', () => { + const single = createResourceSignature([ + { + id: 'asset:x', + category: 'character', + subtype: 'character', + label: 'x', + mediaType: 'image/png', + dependencyDepth: 0, + }, + ]); + const doubled = createResourceSignature([ + { + id: 'asset:x', + category: 'character', + subtype: 'character', + label: 'x', + mediaType: 'image/png', + dependencyDepth: 0, + }, + { + id: 'asset:y', + category: 'character', + subtype: 'character', + label: 'y', + mediaType: 'image/png', + dependencyDepth: 0, + }, + ]); + expect(single).not.toBe(doubled); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx b/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx new file mode 100644 index 000000000..b611522f5 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCardPreviewAlphaBackground.test.tsx @@ -0,0 +1,434 @@ +// @vitest-environment jsdom +/** + * 资源卡的棋盘格底必须由**这张图真实的 alpha** 决定,而不是「预览分支是图片」。 + * + * 现场缺陷(验收截图):所有 PNG/JPEG 卡一律铺 CSS 棋盘格,于是「真透明底」与 + * 「AI 把棋盘格画进像素里」在卡面上完全同形,验收时无法区分两者。 + * + * 本文件把整条链路钉住: + * 1. 真的渲染 `ProjectDevelopmentView`,用假的 `read_local_project_image_preview` + * 返回原生头部判据 `hasAlpha`,断言卡片根节点的 `data-preview-has-alpha`; + * 2. 把真实 DOM 上的 `data-preview-kind` / `data-preview-has-alpha` 喂给 + * `styles.css` 的声明级层叠求值,断言只有真透明卡片的 `.game-resource-card-visual` + * 最终生效声明里才有棋盘格(判据取 `background-size: 16px 16px` 与渐变层)。 + * + * 为什么不用 `getComputedStyle(visual).backgroundImage`:jsdom 不加载样式表,且本仓库 + * 测试环境的 cssstyle 解析不了渐变 —— 实测把 `background: linear-gradient(...)` 与 + * `background-image: linear-gradient(...)` 写进 `