Compare commits
259 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b082addc34 | |||
| 2566ae705f | |||
| 86b830cf2d | |||
| f9bd0adae1 | |||
| 11beb65c36 | |||
| 20f109027a | |||
| 4e553bb15d | |||
| ad8f42a68f | |||
| a0325d653e | |||
| b617873f46 | |||
| 513c25898c | |||
| 9856bb7057 | |||
| 16db7ae8aa | |||
| 0ea7c7745c | |||
| 6baf358bc7 | |||
| 462fc856aa | |||
| 274bb1a3d2 | |||
| 8a96190db8 | |||
| 584da5b76b | |||
| d1a03aec0e | |||
| b90d12c5fb | |||
| 8135b4c374 | |||
| 124d94de93 | |||
| 103fe6fcd6 | |||
| 1370094c52 | |||
| be8155c4a8 | |||
| 9052ff14f8 | |||
| 9d9f1b8164 | |||
| 8629a228a1 | |||
| a92121e09d | |||
| e49d6741ca | |||
| f7666339fe | |||
| 891cf50c0e | |||
| b15a95e774 | |||
| e0f743b608 | |||
| a19684a5f5 | |||
| 46b0871e6d | |||
| 5c40abce47 | |||
| 47d09d51ef | |||
| f05f6e49ab | |||
| c7fe531b16 | |||
| d44c6ab62c | |||
| 6764950aec | |||
| 0944667c37 | |||
| bb2f080d3b | |||
| 413707da33 | |||
| 6235fa3298 | |||
| 8a9d37d5ad | |||
| 9bbfc56cd7 | |||
| e9d0180054 | |||
| 664bdd6877 | |||
| 39a64a864a | |||
| f055961714 | |||
| 3bcabe36fa | |||
| 2bfc870854 | |||
| b0e9741e81 | |||
| b52a33cc28 | |||
| 398edd6e86 | |||
| 9788087271 | |||
| 329de5fce9 | |||
| e4d70fbe1b | |||
| aaa9d3fb6f | |||
| bd127846f7 | |||
| b1751784ba | |||
| 379611d552 | |||
| ef04a19d5a | |||
| 206b4830a1 | |||
| 4b8e268816 | |||
| 7e7d6f1563 | |||
| a4b1d81123 | |||
| 983b15c3a7 | |||
| cdb6fcfe97 | |||
| d8a4e35232 | |||
| 0726106818 | |||
| 8beade5b4d | |||
| 46984a4945 | |||
| a8a30ecbc8 | |||
| 48e669d167 | |||
| 23fdc005fe | |||
| b8a4523a4f | |||
| 9c04561419 | |||
| 551fb6609a | |||
| d05440dcca | |||
| 7edf4a8f8b | |||
| afc9ecbd51 | |||
| fc2f4b71ce | |||
| 545f64cf9e | |||
| 2a50614c11 | |||
| 915006c98f | |||
| 69ce232a74 | |||
| 319aa69d6e | |||
| bec468bdc9 | |||
| eada20c199 | |||
| e8cf773aac | |||
| 12fbb61fad | |||
| 478e08fb92 | |||
| 0e67fbf80a | |||
| ed2cfbad36 | |||
| 560422286b | |||
| e04075fd95 | |||
| d7776f2a17 | |||
| 4d9e69841e | |||
| 49e88bbd9f | |||
| 86c268eca3 | |||
| c86221e3da | |||
| e7af7cf4e4 | |||
| e7cd008d7c | |||
| c743ca146e | |||
| 8c8b6cc75a | |||
| 4cf3642b53 | |||
| f933db169e | |||
| a5e936dd27 | |||
| 53008959c5 | |||
| 57dbb3de52 | |||
| e189aff485 | |||
| 248c094da9 | |||
| e66eb2fb55 | |||
| d455dc18a9 | |||
| 04a0d21351 | |||
| a9358bf3ce | |||
| c3de2ce958 | |||
| 287d2f94da | |||
| 1343f487d0 | |||
| 73fb89a8f4 | |||
| bf2ffd2bf9 | |||
| 3f55f043a1 | |||
| 26677b193c | |||
| b8691add3a | |||
| bfe010a192 | |||
| 6db6de5b99 | |||
| 87a7294a04 | |||
| 3774d7c1e1 | |||
| 8d15125f12 | |||
| cf68b8212d | |||
| 8d7ccc0d75 | |||
| 2af3d9a965 | |||
| 4749339bad | |||
| 3e85c71060 | |||
| da19bde99a | |||
| 4a32ee37c4 | |||
| 31fd1da495 | |||
| 4c88e5e924 | |||
| 99f4961c5f | |||
| 0cebc39b6a | |||
| 842d080a75 | |||
| 8cd6e34af0 | |||
| b357d36262 | |||
| 48c5638893 | |||
| 46df12c086 | |||
| 4bdbab29c0 | |||
| 9acc23b848 | |||
| b9b4ebd38e | |||
| 8f680ef376 | |||
| 7c7d55dcab | |||
| 1303592a86 | |||
| 1db7597141 | |||
| 13701d0144 | |||
| 5c83142fec | |||
| 95ed49f264 | |||
| b8d8072267 | |||
| 462caa33c5 | |||
| 4e1064266f | |||
| a50cbfb6c8 | |||
| 1eb4bd5ec0 | |||
| 5b441d389c | |||
| f200cb4d3c | |||
| ae2f5fc80b | |||
| b69bd88e5e | |||
| c5c7339c4a | |||
| 4bbc77fc32 | |||
| 8c521ca891 | |||
| 69a0804c03 | |||
| 74f726e65d | |||
| 1509235c1b | |||
| 1c992c69fe | |||
| d9b153ed78 | |||
| 30c469e9a1 | |||
| 51f629b2f9 | |||
| d6c720ecdd | |||
| 086aa91d70 | |||
| 3733e86272 | |||
| 5d0f3e6be6 | |||
| 989440173f | |||
| 22d9958d18 | |||
| 56f8c9a6a1 | |||
| c84df3d88f | |||
| 761d32cebf | |||
| b3d6543d73 | |||
| 8cd7c43b9e | |||
| 7fb7d246c0 | |||
| 5844afa14d | |||
| 615d1a28cd | |||
| 3e1e172f9c | |||
| 53a919e39b | |||
| d4b5f9fc14 | |||
| 6e4f9a6f1f | |||
| 8a0dcf8be7 | |||
| aa2a78f7e0 | |||
| 7aa96e8438 | |||
| feb6bc0ad4 | |||
| f349350d60 | |||
| df3a172bc9 | |||
| 9b4924c73e | |||
| d30bc462c5 | |||
| 26b9ad7fad | |||
| bc5398894e | |||
| 11d4d3d930 | |||
| 418d5732de | |||
| 592b6b4ee7 | |||
| 99c19e3ed9 | |||
| 15e30acb0f | |||
| 56fff8325b | |||
| 884c5ec5ee | |||
| 207b0820dd | |||
| 19f3d62d78 | |||
| 3c1e7bd599 | |||
| dda6b3946a | |||
| aff40f794d | |||
| 187faa607d | |||
| 109cc81740 | |||
| f796aef7e2 | |||
| 4cc7f4dd64 | |||
| b2d4690f94 | |||
| 0400103a19 | |||
| c049d68b8e | |||
| acfcf0c158 | |||
| 5bbedd9a5c | |||
| 40316d82c4 | |||
| 6c77de4a36 | |||
| b3693765ef | |||
| f7edc9d0ee | |||
| f60771dd00 | |||
| f7cccd8592 | |||
| 5ee2d940c1 | |||
| c75a7725ce | |||
| 3a95715990 | |||
| ed86963de2 | |||
| 65e73eee02 | |||
| 6068eeda83 | |||
| 0e244c64ea | |||
| ad7c059603 | |||
| 55d2a393d1 | |||
| e7f66ce950 | |||
| 35dcfea3ef | |||
| cea8bf77fa | |||
| 0136bfe564 | |||
| 22785b4d7b | |||
| a86a2ee4d2 | |||
| b22eae2726 | |||
| 35d0db6377 | |||
| f087ba3e2b | |||
| 7fc1b841be | |||
| a576fdcc37 | |||
| 4dcf38715b | |||
| d1f9f4e1ef | |||
| 5836aaec26 | |||
| 18d60feb04 | |||
| 4e1316e272 | |||
| 302b4addae |
+335
-84
@@ -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
|
||||
|
||||
@@ -108,6 +108,8 @@ 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',
|
||||
|
||||
@@ -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=<i>` 让**每个 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=<Cargo.toml> --target-kind=lib --no-locked
|
||||
//
|
||||
// 参数:
|
||||
// --shards=<n> 分片数,默认 4
|
||||
// --shard-index=<i> 只跑第 i 片(1..shards);不传则跑全部分片
|
||||
// --concurrency=<n> 同时运行的片数,默认等于分片数;--shard-index 时恒为 1
|
||||
// --manifest=<path> Cargo.toml,默认 ../src-tauri/Cargo.toml(相对本脚本)
|
||||
// --target-kind=<k> bin | lib,默认 bin(本地自测小 crate 时用 lib)
|
||||
// --bin=<name> bin target 名,默认 genarrative-ai-game-creator-shell
|
||||
// --package=<name> target-kind=lib 时要跑的包名(配合 lib 目标使用)
|
||||
// --no-locked 传给 cargo 时不带 --locked(只对没有提交 Cargo.lock 的 crate 需要)
|
||||
// --shard-tmp-root=<path> 片专属 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));
|
||||
});
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -339,6 +339,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<ui_editor::commands::SeparationDTO, String> {
|
||||
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<ui_editor::commands::SeparationRecoveryDTO, String> {
|
||||
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::MergeDTO, String> {
|
||||
ui_editor::commands::merge_ui_impl(state).await
|
||||
@@ -2689,6 +2724,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,
|
||||
|
||||
@@ -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<Vec<(PathBuf, std::thread::ThreadId)>> =
|
||||
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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Component>,
|
||||
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<Component>,
|
||||
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<EditableNodeConte
|
||||
node_id: &node.id,
|
||||
name: &node.metadata.name,
|
||||
description: &node.metadata.description,
|
||||
components: &node.components,
|
||||
component: node.component.as_ref(),
|
||||
});
|
||||
}
|
||||
for child in &node.children {
|
||||
@@ -172,14 +170,21 @@ fn validate_binding_response_shape(
|
||||
return Err(format!("组件绑定 changes 不能超过 {max_changes} 条"));
|
||||
}
|
||||
for change in changes {
|
||||
let components = change
|
||||
.get("components")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.ok_or_else(|| "组件绑定 change 缺少 components 数组".to_string())?;
|
||||
if components.len() > 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::<Vec<_>>()
|
||||
"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]
|
||||
|
||||
@@ -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<NodeId>) -> Result<NodeId, String> {
|
||||
fn unique_random_node_id(occupied: &mut HashSet<NodeId>) -> 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::<Component>::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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<RecognitionNode>,
|
||||
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<RecognitionNode>,
|
||||
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<f32>, Vector2<f32>), String
|
||||
Ok((min, max))
|
||||
}
|
||||
|
||||
fn random_node_id() -> Result<NodeId, String> {
|
||||
NodeId::new(uuid::Uuid::new_v4().simple().to_string())
|
||||
.map_err(|error| format!("生成节点 ID 失败:{error}"))
|
||||
}
|
||||
|
||||
fn image_layout_size(image: &UIDesignImage) -> Result<Vector2<f32>, 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::<Result<Vec<_>, _>>()?;
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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::<Result<Vec<_>, _>>()?;
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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<I>(alphas: I, required: usize) -> bool
|
||||
where
|
||||
I: IntoIterator<Item = u8>,
|
||||
{
|
||||
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<Rect> {
|
||||
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<NormalizedBindingArea, String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
+123
@@ -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<String, String> {
|
||||
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<String, String> {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+97
@@ -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<BindingDecision>,
|
||||
}
|
||||
|
||||
#[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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<SeparationNode>,
|
||||
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,
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+45
@@ -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<String>,
|
||||
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<super::SeparationTree>,
|
||||
pub bound: Vec<BoundNode>,
|
||||
pub problematic_nodes: Vec<ProblematicNode>,
|
||||
}
|
||||
|
||||
#[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<BoundNode>,
|
||||
pub problematic_nodes: Vec<ProblematicNode>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
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<PathBuf, String> {
|
||||
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("<unknown>")
|
||||
);
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
pub fn separation_state_path(root: &Path, asset_id: &str) -> Result<PathBuf, String> {
|
||||
Ok(separation_sidecar_dir(root, asset_id)?.join("state.json"))
|
||||
}
|
||||
|
||||
pub fn project_relative_path(root: &Path, path: &Path) -> Result<String, String> {
|
||||
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("<unknown>"),
|
||||
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("<unknown>"),
|
||||
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::<Vec<_>>();
|
||||
let destination = target
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect::<Vec<_>>();
|
||||
// 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<SeparationState, String> {
|
||||
app_log!(
|
||||
"ui_separation.state_read.start file={}",
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("<unknown>")
|
||||
);
|
||||
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<SeparationState, String> {
|
||||
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<SeparationRecoveryDTO, String> {
|
||||
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_state(root, asset_id)
|
||||
}
|
||||
|
||||
pub fn discard_separation_recovery(root: &Path, asset_id: &str) -> Result<(), String> {
|
||||
remove_separation_state(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}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
+46
@@ -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
|
||||
}
|
||||
+171
@@ -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<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
children: Vec<ExtractPromptNode>,
|
||||
}
|
||||
|
||||
#[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<String, String> {
|
||||
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::<HashSet<_>>();
|
||||
let terminal_ids = state
|
||||
.bound
|
||||
.iter()
|
||||
.map(|node| node.node_id.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
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<NodeId>,
|
||||
terminal_ids: &HashSet<NodeId>,
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod binding;
|
||||
mod extract;
|
||||
|
||||
pub(super) use binding::gen_binding_prompt;
|
||||
pub(super) use extract::gen_extract_prompt;
|
||||
@@ -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<SeparationNode>,
|
||||
) {
|
||||
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::<HashSet<_>>();
|
||||
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(())
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user