diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index b4e69e78f..f629f6251 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -31,22 +31,24 @@ env: # 让它在最少的等待下占用并发槽位;其余 job 按时长递减排列。 # # 客户端(微信壳 / Expo 移动壳 / Tauri 桌面壳 / AI 游戏创作壳)门禁原先全部串在 -# `Native shell tests` 一个 job 里,实测 18 分 37 秒。现在按门禁组拆成三个 job: +# `Native shell tests` 一个 job 里,实测 18 分 37 秒。现在按门禁组拆成一个分组一个 job: # `Native shell tests`(契约 + H5 / 微信 / 移动 / 桌面壳门禁 + 发布构建 smoke)、 -# `AI game creator shell web tests`(typecheck + 壳内测试)与 -# `AI game creator shell Rust tests`(AGC 壳 bin 单测分片并行 + agent-run smoke), -# 再把 AGC 壳依赖的共享 / 平台 crate 测试拆成 `AI game creator shell Rust crates`。 -# 各自的命令与拆分前逐一对应,本地 `npm run check:native-shells` 仍是同一条串行序列。 +# `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 片、每片一个进程并行执行,片内保持 -# `--test-threads=1`:当年线程并行会互相干扰的是进程内后台锁与异步终态,进程分片 -# 天然隔离,每片另有独立 TMPDIR,因此不必再让整套用例串成一小时级的尾巴。 +# AGC 壳的 bin 单测(2466 条)按名单分 4 片、一片一个 job:片内保持 `--test-threads=1` +# (当年线程并行会互相干扰的是进程内后台锁与异步终态),片与片之间靠 job 级并发摊开。 +# 不要改回「一个 job 里多进程并行这几片」:同一容器内它们会争抢共享 HOME、target 与固定 +# 临时路径,实测比整套串行还慢。每个分片 job 都会自校验「片并集等于全集且互斥」。 jobs: - # 客户端 AGC 壳自身的 Rust 门禁:bin target 单测按名单分 4 片并行(片内仍保持 - # `--test-threads=1`)加 agent-run smoke。这里不装 npm 依赖:壳 Rust 门禁与 smoke - # 只用 cargo 与 node 内建模块,也只需要 AGC 壳自己那份锁定依赖。 - ai-game-creator-shell-rust-tests: - name: AI game creator shell Rust tests + # 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 @@ -78,8 +80,142 @@ jobs: sleep $((attempt * 2)) done - - name: Run AI game creator shell Rust gates - run: npm run check:native-shells:agc-rust-shell + - name: Run AI game creator shell Rust shard 1/4 + run: npm run check:native-shells:agc-rust-shard-1 + + ai-game-creator-shell-rust-shard-2: + name: AI game creator shell Rust shard 2/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 2/4 + run: npm run check:native-shells:agc-rust-shard-2 + + 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 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 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 预热、单独跑。 diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index da7eddb68..c08cb6ac9 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -1,7 +1,7 @@ { "name": "@genarrative/ai-game-creator-shell", "private": true, - "version": "0.1.27", + "version": "0.1.29", "type": "module", "scripts": { "dev": "node scripts/start-tauri-dev.mjs", diff --git a/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs b/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs index 7cefb0ed2..9e0ae58bb 100644 --- a/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs +++ b/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs @@ -6,18 +6,25 @@ // **同一进程内**互相干扰(见 development-workflow 的 Tauri suite 单线程口径)。代价是 // 整套用例串行跑满 507 秒,占掉 CI 上 `AI game creator shell Rust tests` job 的大头。 // -// 这里保留「片内串行」的既有口径,只把用例集合切成 N 片、让每片在**独立进程**里并行: -// 当年线程并行的两个根因(进程内全局锁、异步终态)在多进程下不存在,每片还会拿到自己的 -// TMPDIR,避免 tempfile 目录互相踩。片并集必须等于全集、且不得重复,数量不符即失败, -// 防止分片规则改动后静默漏跑。 +// 这里保留「片内串行」的既有口径,只把用例集合切成 N 片: +// - CI 用 `--shard-index=` 让**每个 job 只跑一片**,靠多个 job 并发把整套用例摊开; +// - 本地不传 `--shard-index` 时把 N 片放进 N 个**独立进程**并行(--concurrency 可调), +// 保留一条命令跑全量的入口。 +// 片并集必须等于全集、且不得重复,数量不符即失败,防止分片规则改动后静默漏跑;该校验 +// 与「只跑一片」无关,因此在每个 job 上都会执行。 +// +// 注意:同一容器内多进程并行这套用例(共享 HOME、target、固定临时路径)实测会互相拖慢, +// 比串行还慢,所以 CI 走「一个 job 一片」而不是单 job 内并行。 // // 用法: -// node scripts/run-rust-shell-test-shards.mjs [--shards=4] [--concurrency=4] +// node scripts/run-rust-shell-test-shards.mjs --shards=4 --shard-index=2 # CI:只跑第 2 片 +// node scripts/run-rust-shell-test-shards.mjs [--shards=4] [--concurrency=4] # 本地:全量 // node scripts/run-rust-shell-test-shards.mjs --manifest= --target-kind=lib --no-locked // // 参数: // --shards= 分片数,默认 4 -// --concurrency= 同时运行的片数,默认等于分片数 +// --shard-index= 只跑第 i 片(1..shards);不传则跑全部分片 +// --concurrency= 同时运行的片数,默认等于分片数;--shard-index 时恒为 1 // --manifest= Cargo.toml,默认 ../src-tauri/Cargo.toml(相对本脚本) // --target-kind= bin | lib,默认 bin(本地自测小 crate 时用 lib) // --bin= bin target 名,默认 genarrative-ai-game-creator-shell @@ -49,6 +56,7 @@ function parsePositiveInteger(name, rawValue) { const options = { shards: 4, + shardIndex: undefined, concurrency: undefined, manifestPath: path.join(shellRoot, 'src-tauri', 'Cargo.toml'), targetKind: 'bin', @@ -75,6 +83,9 @@ for (const rawArgument of process.argv.slice(2)) { 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; @@ -105,6 +116,12 @@ 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); @@ -380,17 +397,35 @@ async function main() { const shards = splitTestNames(testNames, options.shards); assertShardsCoverEveryTest(testNames, shards); - console.log( - `[rust-shards] ${testNames.length} tests, ${shards.length} shard(s), concurrency ${Math.min(concurrency, shards.length)}`, - ); - shards.forEach((shardTestNames, index) => { - console.log( - `[rust-shards] shard ${index + 1}/${shards.length}: ${shardTestNames.length} test(s)`, - ); - }); + const selectedShards = + options.shardIndex === undefined + ? shards.map((shardTestNames, index) => ({ index, shardTestNames })) + : [ + { + index: options.shardIndex - 1, + shardTestNames: shards[options.shardIndex - 1], + }, + ]; - const results = await runWithConcurrency(shards, (shardTestNames, index) => - runShard(executable, index, shards.length, shardTestNames), + 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; diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 60032820b..7e07a3c9c 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "genarrative-ai-game-creator-shell" -version = "0.1.27" +version = "0.1.29" dependencies = [ "agent-runtime-core", "axum", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 370777cf3..4fe37d25a 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "genarrative-ai-game-creator-shell" -version = "0.1.27" +version = "0.1.29" edition = "2021" publish = false diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index 8cf521851..7fc0d7cf1 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Genarrative AI Game Creator", - "version": "0.1.27", + "version": "0.1.29", "identifier": "world.genarrative.ai-game-creator", "build": { "beforeDevCommand": "npm --prefix ../.. run agc:serve", diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceTypePanel.css b/apps/ai-game-creator-shell/src/features/project-workspace/resourceTypePanel.css index f582f3885..fb9260aa6 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceTypePanel.css +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceTypePanel.css @@ -1,12 +1,12 @@ /* - * 「设置素材类型」面板的弹窗骨架与信息浮层的类型入口。 + * 「设置素材类型」面板的弹窗骨架、纵向单选列表与信息浮层的类型入口。 * * 单独一个文件而不是塞进 styles.css:与「编辑素材标签」面板当初同样的理由 —— * 这份样式只服务本次的素材类型入口,与工作台其它区块没有共享选择器,独立文件让改动 * 边界更清楚,也不会与同一时段其它 Agent 在 styles.css 里的编辑互相踩。 * * 骨架沿用「编辑素材标签」那套三段式契约(`auto / minmax(0, 1fr)`):标题常驻、 - * 中间一行可压缩可滚动、`max-height` 兜住上界。类型面板没有底部按钮,所以只有两行。 + * 中间一行可压缩、`max-height` 兜住上界。类型面板没有底部按钮,所以只有两行。 */ .game-resource-type-dialog { width: min(480px, 100%); @@ -15,16 +15,16 @@ } /* - * 滚动落在 body 这一行:`min-height: 0` 是网格项能被 `1fr` 压缩的前提, - * 否则内容高度会顶回轨道、`overflow-y` 永远不触发。 + * body 分三段:提示 / 选项列表 / 错误提示。 + * + * `min-height: 0` 是网格项能被 `1fr` 压缩的前提;**滚动不在这里**——滚动权交给选项列表 + * (见下),否则往下滚时素材名和错误提示会跟着跑掉,用户看不到"改的是哪件素材、为什么失败"。 */ .game-resource-type-body { display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; gap: 10px; min-height: 0; - overflow-y: auto; - overscroll-behavior: contain; - scrollbar-gutter: stable; } /* @@ -36,6 +36,67 @@ font-size: 12px; } +/* + * 纵向单选列表(`role="radiogroup"`):**一行一个选项**。 + * + * 之前 6 项横排在一条里(`PlatformSegmentedTabs` 的 3~6 列网格),窄屏上互相叠字读不出来。 + * 单列网格 + 按行流向是"每项一行、互不重叠"的充分条件:只声明一列,6 个子元素必然上下排 6 行, + * 不存在两项挤一行的可能。选项多时列表自己滚(`max-height` + `overflow-y: auto`), + * 面板不会被撑高。移动端优先:360px 宽的窄屏同样是这一套声明(没有按宽度改列数的媒体查询)。 + */ +.game-resource-type-options { + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-auto-flow: row; + align-content: start; + gap: 6px; + min-height: 0; + max-height: min(320px, 40dvh); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +/* + * 单个选项行:复用共享的 `PlatformNavigableListItem` 骨架(w-full / flex / text-left / + * 圆角 / 悬停 / 焦点环都由它给),这里只补"整行可点 + 明确选中态"的表现。 + * + * `width/min-width` 显式写出来,不依赖共享件里的 Tailwind `w-full`:这一行是不是满宽 + * 决定了"一项一行"能不能成立,不能挂在另一份文件的工具类上。 + * `min-height: 44px` 是移动端点击热区下限;`overflow-wrap` 让长选项名在窄屏换行而不是溢出。 + */ +.game-resource-type-option { + width: 100%; + min-width: 0; + min-height: 44px; + padding: 8px 12px; + border: 1px solid var(--platform-subpanel-border); + background: rgb(255 255 255 / 62%); + color: var(--platform-text-base); + font-size: 13px; + font-weight: 600; + overflow-wrap: anywhere; +} + +.game-resource-type-option:hover:not(:disabled) { + border-color: var(--platform-surface-hover-border); +} + +/* + * 选中态完全由 `aria-checked="true"` 驱动:视觉与读屏读的是同一个属性,不会各说一套。 + * + * 选择器显式提权到 (0,3,0) 以上:共享列表行自带的 `.platform-navigable-list-item:hover:not(:disabled)` + * 也是 (0,3,0),只写 `.game-resource-type-option[aria-checked='true']`((0,2,0))会在悬停时 + * 被它的底色顶掉;带 `:hover:not(:disabled)` 的那条 (0,5,0) 保证选中行悬停时也不变色。 + */ +.game-resource-type-options .game-resource-type-option[aria-checked='true'], +.game-resource-type-options + .game-resource-type-option[aria-checked='true']:hover:not(:disabled) { + border-color: var(--platform-warm-border); + background: var(--platform-warm-bg); + color: var(--platform-text-strong); +} + .game-resource-type-error { margin: 0; color: #b3261e; diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceTypePanel.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceTypePanel.tsx index 07b254267..3a7144160 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceTypePanel.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceTypePanel.tsx @@ -1,8 +1,13 @@ import '../../features/project-workspace/resourceTypePanel.css'; -import { useState } from 'react'; +import { Check } from 'lucide-react'; +import { + type KeyboardEvent as ReactKeyboardEvent, + useRef, + useState, +} from 'react'; -import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs'; +import { PlatformNavigableListItem } from '../../../../../packages/shared/src/components/PlatformNavigableListItem'; import { GAME_CREATION_APP_ASSET_CATEGORIES, type GameCreationAppAssetCategory, @@ -81,6 +86,36 @@ export function ResourceTypePanel({ const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const activeCategory = pendingCategory ?? gameCreationAppAssetCategory(asset); + const optionsRef = useRef(null); + + /** + * 单选组的键盘口径:Tab 进组只停一次(roving tabindex,见下面的 `tabIndex`); + * 方向键在选项之间移动**焦点**,Enter/Space(`