diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md index e4bbafbc2..93c8592bb 100644 --- a/.codex/skills/genarrative-external-editor-api/SKILL.md +++ b/.codex/skills/genarrative-external-editor-api/SKILL.md @@ -32,7 +32,7 @@ Prefer `scripts/genarrative_external_api.py` for runnable REST calls. It uses on - Use stable references such as `objectKey`, project resource ID, or asset ID where each operation permits them. Image edit/redraw is stricter: `sourceReferenceId` accepts only a registered project resource ID or asset ID; upload confirmation alone is not enough. Use `/assets/read-url` only for temporary preview/download access. - Preserve both warning channels after completion. A general `warning` can coexist with `sliceWarning`; do not discard either. - Do not invent missing derivatives. A source-preserved warning means the main source remains usable but requested post-processing failed. A slice warning means the complete transparent sheet is usable but individual slices are absent. -- Icon spritesheet generation accepts `sliceMode="connected-components"` (default alpha-connectivity detection) or `sliceMode="grid"`. Grid mode requires `gridX` and `gridY` (1-32); use `sliceCount` only to constrain connected-component output. +- Icon spritesheet generation requires an explicit `sliceMode` and has no default. Use `sliceMode="grid"` with the `gridX` and `gridY` the requirement actually names (1-32 each) only for equal grid cells or fixed slots; use `sliceMode="connected-components"` for free-form sheets or an open number of subjects, and constrain the count with `sliceCount` instead of inventing grid dimensions. `connected-components` must not carry `gridX`/`gridY`; an omitted, contradictory, or misapplied declaration returns 400 before billing. - For successful `style="pixelArt"`, treat completed-result and nested resource/asset dimensions as the final logical-grid PNG dimensions. They may differ from `size`, `imageSize`, the provider image, and `canvasCompletion.placeholder`; do not rescale or reject the artifact to match those inputs. - Keep generated artifacts in the canvas and asset library together. Character animation accepts `assetFolderId` and `assetLabel`; its completed result directly returns the final `assetKind="character-animation"` resource and asset with formal sequence fields. Do not create a duplicate first-frame record. diff --git a/.codex/skills/genarrative-external-editor-api/references/api-operations.md b/.codex/skills/genarrative-external-editor-api/references/api-operations.md index a744d8589..25c807788 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-operations.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-operations.md @@ -94,7 +94,7 @@ For image edit/redraw, confirming an upload is not sufficient: create a project The icon-spritesheet primary `referenceId` is intentionally stricter than ordinary image references: it accepts only a current-owner project resource ID or asset ID whose authoritative `assetKind` is `icon-spec`. It does not accept an `objectKey`, URL, Data URL, or Blob URL. -`sliceMode` controls atlas splitting. Use `"connected-components"` (default) to detect independent opaque regions by alpha connectivity, or `"grid"` with positive `gridX` and `gridY` values (maximum 32 each). `sliceCount` optionally constrains the connected-component result. +`sliceMode` is required and has no default, so every request must state it. Use `"connected-components"` to detect independent opaque regions by alpha connectivity, or `"grid"` with positive `gridX` and `gridY` values (maximum 32 each) only when the requirement names equal grid cells or fixed slots; the dimensions must come from that requirement. `connected-components` must not carry `gridX`/`gridY`, and `sliceCount` constrains the connected-component result instead of expressing a grid. Omitting `sliceMode`, or contradicting the declared mode with grid dimensions, returns 400 before pricing, enqueueing, or any provider call. ## Common Values diff --git a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md index af0ee338a..8a48a8347 100644 --- a/.codex/skills/genarrative-external-editor-api/references/capability-routing.md +++ b/.codex/skills/genarrative-external-editor-api/references/capability-routing.md @@ -79,9 +79,9 @@ Keep the existing autonomous-build task graph. Do not add a parallel task system 1. `art-director` generates `assets/art-spec.png` with image generation, `kind: "spec"`, then registers it as `assetKind: "icon-spec"`. This image is the authoritative visual spec; `generationInputs.artSpec` is supporting structured context. 2. `design-foundation` generates `assets/ui-prototype.png` with `kind: "ui-design"`, using the registered art-spec resource ID in `referenceImageSrcs`. -3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. For a fixed four-category game contract it may send `sliceMode: "grid"`; for free-form assets use `sliceMode: "connected-components"` (the default). +3. `art-asset-plan` generates transparent `assets/art-spritesheet.png` through icon spritesheet generation, using the same registered art-spec resource ID as `referenceId` plus concrete `iconDescriptions`. `sliceMode` is required and has no default: send `sliceMode: "grid"` with `gridX`/`gridY` only when the requirement itself fixes the slots or names the column/row count, and otherwise send `sliceMode: "connected-components"` (with `sliceCount` when a subject count must be constrained); never invent a grid to express "kinds of assets", and never send `gridX`/`gridY` with `connected-components`. -For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. When using the fixed four-category contract, require response `sliceMode: "grid"` and exactly four slices before registering the local runtime sheet; both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet ``, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation. +For a playable Canvas game, do not stop at generation. Make `code-prototype` depend on `art-asset-plan` and consume the persisted `iconImageSrcs` slices for core players, blocks or targets, scene obstacles, and feedback. When the requirement fixes grid slots, require the response `sliceMode` to match the declared `grid` request and exactly `gridX × gridY` slices before registering the local runtime sheet; a connected-components request is instead judged by its own `sliceCount` or by the requirement, and both fewer and extra components fail closed. Treat `art-spec.png` as reference-only. A full-sheet ``, CSS background, path-only mention, guessed equal-grid crop, or code-drawn replacement for core entities is not runtime asset use. If slicing produces `sliceWarning`, keep the complete transparent sheet as a valid editor artifact, but fail the playable game asset gate until real slice files or verified atlas coordinates exist; never invent coordinates or replace the icon-spritesheet route with ordinary image generation. Never use `assets/ui-prototype.png` as the spritesheet visual-spec reference. UI extraction is outside this canonical DAG. diff --git a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py index c7c0ab33b..bc270fe38 100644 --- a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py +++ b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py @@ -617,6 +617,7 @@ class GenarrativeExternalClient: self, reference_id: str, icon_descriptions: list[str], + slice_mode: str, **fields: Any, ) -> Any: reference_id = normalize_optional_text(reference_id) @@ -625,6 +626,20 @@ class GenarrativeExternalClient: descriptions = [item.strip() for item in icon_descriptions if item.strip()] if not descriptions: raise GenarrativeApiError("icon_descriptions must contain at least one non-empty item") + slice_mode = normalize_optional_text(slice_mode) + if slice_mode not in ("connected-components", "grid"): + raise GenarrativeApiError( + "slice_mode must be declared explicitly as 'connected-components' or 'grid'; the API has no default" + ) + grid_x = fields.get("gridX") + grid_y = fields.get("gridY") + if slice_mode == "grid": + if grid_x is None or grid_y is None: + raise GenarrativeApiError("slice_mode='grid' requires both gridX and gridY") + elif grid_x is not None or grid_y is not None: + raise GenarrativeApiError( + "slice_mode='connected-components' must not carry gridX/gridY" + ) label = fields.get("assetLabel", "图标图集") self._apply_canvas_session_fields(fields, label, 1024, 1024) fields.setdefault("screenColor", "auto") @@ -635,6 +650,7 @@ class GenarrativeExternalClient: **fields, "referenceId": reference_id, "iconDescriptions": descriptions, + "sliceMode": slice_mode, }, idempotency_key=idempotency_key, ) @@ -879,9 +895,9 @@ def _self_test() -> None: client.generate_icon_spritesheet( "editor-resource-spec", ["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"], + "connected-components", canvasSession=session, assetLabel="贪吃蛇透明图集", - sliceMode="connected-components", referenceId="must-not-override-explicit-reference", iconDescriptions=["不得覆盖显式图标描述"], ) diff --git a/.env.example b/.env.example index bf06357e1..f11a17f8f 100644 --- a/.env.example +++ b/.env.example @@ -158,6 +158,15 @@ ALIYUN_OSS_POST_EXPIRE_SECONDS="600" ALIYUN_OSS_POST_MAX_SIZE_BYTES="20971520" ALIYUN_OSS_SUCCESS_ACTION_STATUS="200" +# AGC 项目定时快照上传目标。对象只落在服务端私有前缀 +# `agc/project-snapshots/v1/{user}/{project}/` 下,客户端直传票据不覆盖该前缀。 +# bucket 与凭据可以与资源 bucket 分离;凭据未设置时回退使用 ALIYUN_OSS_ACCESS_KEY_*, +# 但 bucket / endpoint 默认指向 AGC 发行 bucket,需要该凭据具备目标 bucket 的 PutObject 权限。 +GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_BUCKET="agc-dev" +GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ENDPOINT="oss-rg-china-mainland.aliyuncs.com" +GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID="" +GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET="" + # BgFilter 受限资源 worker。父 api-server / external-generation-worker 与唯一的 # `GENARRATIVE_PROCESS_ROLE=bgfilter-worker` 进程必须使用同一个内部 Token。 # `npm run dev` 与 `npm run dev:api-server` 都会自动带起并验活唯一 worker,不要再开第二个终端重复启动。 diff --git a/.gitea/workflows/project-ci.yml b/.gitea/workflows/project-ci.yml index f629f6251..5088f80b6 100644 --- a/.gitea/workflows/project-ci.yml +++ b/.gitea/workflows/project-ci.yml @@ -7,6 +7,10 @@ on: pull_request: workflow_dispatch: +concurrency: + group: project-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read diff --git a/.gitignore b/.gitignore index 2770d44c8..34e0fbde7 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,12 @@ temp*build*/ /apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-resources/ /apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-package.json /apps/ai-game-creator-shell/src-tauri/resources/plugins/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/bin/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-path/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-resources/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-package.json +/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/manifest.json +/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md /plugins/agc-cocos-editor/native/payload/ /apps/ai-game-creator-shell/logs/ /apps/ai-game-creator-shell/.llm-drafts/ diff --git a/CONTEXT.md b/CONTEXT.md index 1351b1033..da2aca812 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -172,6 +172,20 @@ _Avoid_: 多步骤向导、完整规则编辑器、拖拽编辑器 Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。 _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI +## 项目开发对话(DirectProject) + +**项目对话历史**: +AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。 +_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本 + +**运行态事件**: +Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。 +_Avoid_: 进度通知、快照轮询、第二套历史 + +**聊天投影**: +把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。 +_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer + ## Relationships - 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**。 @@ -206,3 +220,5 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI - “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。 - “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。 - “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。 +- “回合进度事件”曾同时指 Direct turn update 与 Thread Manager 运行态事件;已解析为 AGC 项目开发对话只保留 **运行态事件**。 +- “哪些消息可显示”曾可能由后端历史分页判断;已解析为可见性判断属于 **聊天投影**,后端只按原始条目分页,前端负责跳过不可显示条目并推进分页锚点。 diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx index f262b8254..0feeb767d 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx @@ -38,6 +38,7 @@ vi.mock('../api/adminApiClient', () => ({ interface MockIntersectionObserverController { enter: (target: Element) => void; + enterAll: (targets: Element[]) => void; isObserved: (target: Element) => boolean; } @@ -106,6 +107,25 @@ function installIntersectionObserverMock(): MockIntersectionObserverController { ); }); }, + enterAll(targets) { + act(() => { + for (const target of targets) { + const record = observed.get(target); + if (!record) { + throw new Error('目标缩略图尚未进入 IntersectionObserver'); + } + record.callback( + [ + { + isIntersecting: true, + target, + } as IntersectionObserverEntry, + ], + record.observer, + ); + } + }); + }, isObserved(target) { return observed.has(target); }, @@ -753,10 +773,10 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as const thumbnails = entries.map((entry) => thumbnailElementForLabel(entry.label), ); - thumbnails.forEach((thumbnail) => { + for (const thumbnail of thumbnails) { expect(observer.isObserved(thumbnail)).toBe(true); - observer.enter(thumbnail); - }); + } + observer.enterAll(thumbnails); await act(async () => { await Promise.resolve(); }); @@ -776,7 +796,7 @@ test('后台素材查询为大量同时可见的缩略图持续错峰换签', as await vi.advanceTimersByTimeAsync(200); }); expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(105); -}); +}, 10_000); test('后台素材查询读取更多后为新进入可视区域的素材换签', async () => { const observer = installIntersectionObserverMock(); diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index 91e5d01a0..3c0005da3 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -2,6 +2,8 @@ "schemaVersion": "game-creator-config.v2", "agentMode": "codex_app_server", "llm": { + "customEnabled": false, + "visibleModels": [], "apiKey": "", "baseUrl": "https://dev.genarrative.world/gpt/v1", "model": "gpt-6-astra", @@ -13,7 +15,7 @@ "autoCompactTokenLimit": 64000, "toolOutputTokenLimit": 12000, "requestTimeoutMs": 180000, - "maxRetries": 2, + "maxRetries": 10, "retryBackoffMs": 500 }, "agentLlm": {} diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 046b6154e..dffaab261 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.45", + "version": "0.1.67", "type": "module", "scripts": { "dev": "node scripts/start-tauri-dev.mjs", @@ -47,6 +47,7 @@ "@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-http": "^2.5.9", "@tauri-apps/plugin-opener": "~2", + "@tauri-apps/plugin-updater": "2.11.0", "@vitejs/plugin-react": "^5.0.4", "focus-trap-react": "^12.0.3", "lexical": "^0.47.0", @@ -57,8 +58,10 @@ "react-colorful": "^5.8.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", + "react-window": "^1.8.11", "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", + "three": "^0.184.0", "vite": "^6.2.0", "zustand": "^5.0.14" }, @@ -70,6 +73,8 @@ "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@types/three": "^0.184.1", + "@types/react-window": "^1.8.8", "tailwindcss": "^4.1.14", "typescript": "~5.8.2", "vitest": "^0.34.6" diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index e5f153b68..b1fc307f5 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -1,6 +1,7 @@ -import { spawnSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -10,17 +11,74 @@ import { } from './cargo-features.mjs'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); +// 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。 +const repoRoot = path.resolve(appRoot, '..', '..'); const defaultReleaseTarget = 'x86_64-pc-windows-msvc'; -const releaseTarget = - process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget; -const bundleRoot = path.join( - appRoot, - 'src-tauri', - 'target', - releaseTarget, - 'release', - 'bundle', -); +function defaultTarget() { + return process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget; +} + +function explicitBuildTarget(args) { + let target; + const separator = args.indexOf('--'); + const options = separator < 0 ? args : args.slice(0, separator); + for (let index = 0; index < options.length; index += 1) { + const argument = options[index]; + let value; + if (argument === '--target' || argument === '-t') { + value = options[++index]; + } else if (argument.startsWith('--target=')) { + value = argument.slice('--target='.length); + } else { + continue; + } + if (!value?.trim() || value.startsWith('-')) { + throw new Error('--target 缺少有效目标'); + } + if (target !== undefined) throw new Error('不能重复指定 --target'); + target = value.trim(); + } + return target; +} + +function validateReleaseTarget(target) { + if (target === 'universal-apple-darwin') { + throw new Error( + '内置 Codex 资源仅支持 macOS 单架构构建,请使用 aarch64-apple-darwin 或 x86_64-apple-darwin', + ); + } + if ( + ![ + 'x86_64-pc-windows-msvc', + 'aarch64-apple-darwin', + 'x86_64-apple-darwin', + ].includes(target) + ) { + throw new Error(`不支持的发布目标:${target}`); + } + return target; +} + +/** 在入口冻结目标;所有发布步骤共享同一上下文,不再各自读取默认目标。 */ +export function resolveReleaseContext(args = [], env = process.env) { + const target = validateReleaseTarget( + explicitBuildTarget(args) || + env.AGC_BUILD_TARGET?.trim() || + defaultReleaseTarget, + ); + return Object.freeze({ + target, + channel: resolveReleaseChannel(env, target), + bundleRoot: path.join( + appRoot, + 'src-tauri', + 'target', + target, + 'release', + 'bundle', + ), + }); +} const packageJsonPath = path.join(appRoot, 'package.json'); const rootPackageLockPath = path.resolve(appRoot, '../..', 'package-lock.json'); const tauriConfigPath = path.join(appRoot, 'src-tauri', 'tauri.conf.json'); @@ -28,14 +86,44 @@ const cargoManifestPath = path.join(appRoot, 'src-tauri', 'Cargo.toml'); const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock'); const defaultOssBaseUrl = 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc'; -const updateManifestUrl = - process.env.VITE_AGC_UPDATE_MANIFEST_URL?.trim() || - `${process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl}/latest.json`; + +/** + * 发布渠道 → 目标平台。渠道名会进入 OSS 路径并烘焙进客户端端点, + * 一旦发布就不能改名(改名等于已发布客户端再也找不到更新)。 + */ +const releaseChannels = { + 'dev-win': 'windows', + 'dev-mac': 'darwin', +}; + +/** + * 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须 + * 保持一致 —— `build-release.test.mjs` 有守卫用例逐条比对两边。 + */ +export const agcReleasePathPatterns = [ + 'apps/ai-game-creator-shell/', + 'packages/', + 'server-rs/crates/', + 'plugins/agc-cocos-editor/', + 'apps/desktop-shell/src-tauri/icons/', + 'package.json', + 'package-lock.json', +]; + +function ossBaseUrl() { + return ( + process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl + ).replace(/\/+$/u, ''); +} function readPackageJson() { return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); } +function readReleaseNotes() { + return process.env.AGC_UPDATE_RELEASE_NOTES?.trim() || ''; +} + export function compareVersions(left, right) { const leftParts = left.split('.').map(Number); const rightParts = right.split('.').map(Number); @@ -66,26 +154,153 @@ export function nextPatchVersion(localVersion, remoteVersion) { return `${major}.${minor}.${patch + 1}`; } -async function readRemoteVersion() { +export function resolveReleasePlatform(target = defaultTarget()) { + if (target.includes('windows')) return 'windows'; + if (target.includes('apple-darwin')) return 'darwin'; + if (target.includes('linux')) return 'linux'; + throw new Error(`不支持的发布目标:${target}`); +} + +export function resolveReleaseChannel( + env = process.env, + target = defaultTarget(), +) { + const platform = resolveReleasePlatform(target); + const requested = env.AGC_UPDATE_CHANNEL?.trim(); + if (requested) { + const channelPlatform = releaseChannels[requested]; + if (!channelPlatform) { + throw new Error( + `未知发布渠道 ${requested};当前支持:${Object.keys(releaseChannels).join('、')}`, + ); + } + if (channelPlatform !== platform) { + throw new Error( + `渠道 ${requested} 只能用于 ${channelPlatform} 目标,当前构建目标为 ${target}`, + ); + } + return requested; + } + const defaultChannel = Object.entries(releaseChannels).find( + ([, channelPlatform]) => channelPlatform === platform, + )?.[0]; + if (!defaultChannel) { + throw new Error( + `目标 ${target} 没有默认发布渠道,请显式设置 AGC_UPDATE_CHANNEL`, + ); + } + return defaultChannel; +} + +export function updateManifestUrl(channel = resolveReleaseChannel()) { + return `${ossBaseUrl()}/${channel}/latest.json`; +} + +/** + * 单架构产物只登记实际目标,不能把同一原生资源映射为另一架构。 + */ +export function resolveManifestPlatformKeys(target = defaultTarget()) { + validateReleaseTarget(target); + if (target === 'aarch64-apple-darwin') return ['darwin-aarch64']; + if (target === 'x86_64-apple-darwin') return ['darwin-x86_64']; + if (target.includes('windows')) { + return [ + target.startsWith('aarch64') ? 'windows-aarch64' : 'windows-x86_64', + ]; + } + throw new Error(`不支持的发布目标:${target}`); +} + +/** 旧协议迁移指针:只在迁移窗口内存在,是历史版本高水位的来源。 */ +function legacyBridgeManifestUrl() { + return `${ossBaseUrl()}/latest.json`; +} + +async function fetchManifest(manifestUrl, label) { let response; try { - response = await fetch(updateManifestUrl, { + response = await fetch(manifestUrl, { headers: { Accept: 'application/json' }, }); } catch (error) { - throw new Error(`读取 OSS 版本清单失败:${error.message}`); + throw new Error(`读取 ${label} 失败:${error.message}`); } if (response.status === 404) return null; if (!response.ok) { - throw new Error(`读取 OSS 版本清单失败:HTTP ${response.status}`); + throw new Error(`读取 ${label} 失败:HTTP ${response.status}`); } - let manifest; try { - manifest = await response.json(); + return await response.json(); } catch (error) { - throw new Error(`OSS 版本清单不是有效 JSON:${error.message}`); + throw new Error(`${label} 不是有效 JSON:${error.message}`); } - return parseVersion(manifest?.version, 'OSS版本清单 version'); +} + +async function readManifestVersion(manifestUrl, label) { + const manifest = await fetchManifest(manifestUrl, label); + return manifest == null + ? null + : parseVersion(manifest?.version, `${label} version`); +} + +/** 上一次发布的渠道清单:拿版本做高水位、拿 commit 生成自动更新摘要。 */ +async function readRemoteChannelManifest(channel = resolveReleaseChannel()) { + return fetchManifest(updateManifestUrl(channel), 'OSS 渠道清单'); +} + +/** + * 摘要锚点:上次发布对应的提交。 + * + * 首选渠道清单里的 `commit`(发布产物自己的事实来源);清单缺该字段时(首次启用 + * 摘要、或更换渠道后清单还没带过 commit)回退到 CI 传入的 `AGC_UPDATE_PREVIOUS_COMMIT` + * —— 它是上一次成功构建的 COMMIT_HASH,同样指向用户拿到的那个版本。 + */ +export async function resolvePreviousReleaseCommit( + channel = resolveReleaseChannel(), + { override = process.env.AGC_UPDATE_PREVIOUS_COMMIT } = {}, +) { + const explicit = override?.trim(); + if (explicit && /^[0-9a-f]{7,40}$/u.test(explicit)) { + return explicit; + } + try { + const manifest = await readRemoteChannelManifest(channel); + const commit = + typeof manifest?.commit === 'string' ? manifest.commit.trim() : ''; + return /^[0-9a-f]{7,40}$/u.test(commit) ? commit : null; + } catch (error) { + // 摘要只是附注:清单读不到(网络抖动等)不能把发布带崩,降级为「没有锚点」。 + console.warn( + `[ai-game-creator-shell] 读取摘要锚点失败,本次不写自动摘要:${error.message}`, + ); + return null; + } +} + +/** + * 版本高水位:渠道清单与旧协议迁移指针取较大值。 + * + * 只看渠道清单会在「渠道刚启用、旧指针还停在更高版本」时把版本链改小 —— + * 2026-09-17 首次渠道发布就是这样把 0.1.57 退回 0.1.48 的。旧指针只服务 + * Windows 渠道,其它渠道不参与比较;旧指针 404(迁移窗口结束)后自动只剩渠道清单。 + */ +export async function resolveRemoteHighWaterVersion( + channel = resolveReleaseChannel(), +) { + const channelVersion = await readManifestVersion( + updateManifestUrl(channel), + 'OSS 渠道清单', + ); + if (channel !== 'dev-win') return channelVersion; + const legacyVersion = await readManifestVersion( + legacyBridgeManifestUrl(), + 'OSS 迁移指针', + ); + if (channelVersion == null) return legacyVersion; + if (legacyVersion == null) return channelVersion; + return compareVersions(channelVersion, legacyVersion) >= 0 + ? channelVersion + : legacyVersion; } function replaceVersionLine(source, version, pattern, label) { @@ -93,9 +308,10 @@ function replaceVersionLine(source, version, pattern, label) { return source.replace(pattern, `$1${version}$3`); } -export async function prepareReleaseVersion() { +export async function prepareReleaseVersion(context = resolveReleaseContext()) { + const { channel } = context; const localVersion = parseVersion(readPackageJson().version, '本地版本'); - const remoteVersion = await readRemoteVersion(); + const remoteVersion = await resolveRemoteHighWaterVersion(channel); const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim(); const nextVersion = requestedVersion ? parseVersion(requestedVersion, '指定版本') @@ -158,26 +374,22 @@ export async function prepareReleaseVersion() { console.log( requestedVersion - ? `[ai-game-creator-shell] 使用指定版本 ${nextVersion}(本地 ${localVersion} / OSS ${remoteVersion ?? '不存在'})` - : `[ai-game-creator-shell] 版本 ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`, + ? `[ai-game-creator-shell] 渠道 ${channel} 使用指定版本 ${nextVersion}(本地 ${localVersion} / OSS ${remoteVersion ?? '不存在'})` + : `[ai-game-creator-shell] 渠道 ${channel} 版本 ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`, ); return nextVersion; } export function buildTauriBuildArguments( args = [], - target = releaseTarget, + target = defaultTarget(), platform = process.platform, ) { const noBundle = args.includes('--no-bundle'); - const targetIndex = args.indexOf('--target'); - const explicitTarget = - targetIndex >= 0 - ? args[targetIndex + 1] - : args - .find((value) => value.startsWith('--target=')) - ?.slice('--target='.length); + const explicitTarget = explicitBuildTarget(args); const targetArgs = noBundle || explicitTarget ? [] : ['--target', target]; + if (!noBundle || explicitTarget) + validateReleaseTarget(explicitTarget || target); const features = defaultEditorFeatures( explicitTarget || (noBundle ? platform : target), ); @@ -187,18 +399,58 @@ export function buildTauriBuildArguments( ]; } -export function runTauriBuild(args = []) { +/** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */ +export function createChannelConfig(channel = resolveReleaseChannel()) { + return { + plugins: { + updater: { + endpoints: [updateManifestUrl(channel)], + }, + }, + }; +} + +function writeChannelConfigFile(channel) { + const configPath = path.join( + os.tmpdir(), + `agc-tauri-channel-${channel}.json`, + ); + fs.writeFileSync( + configPath, + `${JSON.stringify(createChannelConfig(channel), null, 2)}\n`, + ); + return configPath; +} + +export function runTauriBuild( + args = [], + context = resolveReleaseContext(args), + { spawn = spawnSync } = {}, +) { + if ( + explicitBuildTarget(args) && + explicitBuildTarget(args) !== context.target + ) { + throw new Error('构建参数与发布上下文目标不一致'); + } + const tauriArguments = buildTauriBuildArguments(args, context.target); + const { channel } = context; + const configPath = writeChannelConfigFile(channel); + console.log( + `[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`, + ); + // 最后合并渠道配置,防止用户配置中的端点与实际发布目标分叉。 + const separator = tauriArguments.indexOf('--'); + tauriArguments.splice( + separator < 0 ? tauriArguments.length : separator, + 0, + '--config', + configPath, + ); const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; - const result = spawnSync( + const result = spawn( npmCommand, - [ - '--prefix', - '../..', - 'exec', - 'tauri', - '--', - ...buildTauriBuildArguments(args), - ], + ['--prefix', '../..', 'exec', 'tauri', '--', ...tauriArguments], { cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' }, ); if (result.error) throw result.error; @@ -213,17 +465,22 @@ function listFiles(root) { }); } -function artifactPriority(filePath) { +function artifactPriority(filePath, target) { const name = path.basename(filePath).toLowerCase(); - if (releaseTarget.includes('windows')) return name.endsWith('.exe') ? 0 : 99; - if (process.platform === 'darwin') return name.endsWith('.dmg') ? 0 : 99; - if (name.endsWith('.appimage')) return 0; - if (name.endsWith('.deb')) return 1; - if (name.endsWith('.rpm')) return 2; + if (target.includes('windows')) return name.endsWith('.exe') ? 0 : 99; + // 更新链路要的是 updater 产物(macOS 为 .app.tar.gz),dmg 只作人工分发。 + if (target.includes('apple-darwin')) { + return name.endsWith('.app.tar.gz') ? 0 : 99; + } + if (name.endsWith('.appimage.tar.gz')) return 0; + if (name.endsWith('.appimage')) return 1; + if (name.endsWith('.deb')) return 2; + if (name.endsWith('.rpm')) return 3; return 99; } -export function selectReleaseArtifact(files) { +export function selectReleaseArtifact(files, target = defaultTarget()) { + validateReleaseTarget(target); const explicit = process.env.AGC_UPDATE_ARTIFACT?.trim(); if (explicit) { const resolved = path.resolve(explicit); @@ -234,44 +491,271 @@ export function selectReleaseArtifact(files) { } return ( [...files] - .filter((filePath) => artifactPriority(filePath) < 99) + .filter((filePath) => artifactPriority(filePath, target) < 99) .sort((left, right) => { - const priority = artifactPriority(left) - artifactPriority(right); + const priority = + artifactPriority(left, target) - artifactPriority(right, target); return priority || left.localeCompare(right); })[0] ?? null ); } -export function createUpdateManifest(artifactPath) { - const bytes = fs.readFileSync(artifactPath); +function readUpdaterSignature(artifactPath) { + const signaturePath = `${artifactPath}.sig`; + if (!fs.existsSync(signaturePath)) { + throw new Error( + `缺少更新包签名:${signaturePath};需要 bundle.createUpdaterArtifacts 与签名私钥(TAURI_SIGNING_PRIVATE_KEY / TAURI_SIGNING_PRIVATE_KEY_PATH)`, + ); + } + const signature = fs.readFileSync(signaturePath, 'utf8').trim(); + if (!signature) throw new Error(`更新包签名为空:${signaturePath}`); + return signature; +} + +export function createUpdateManifest( + artifactPath, + { + target = defaultTarget(), + channel = resolveReleaseChannel(process.env, target), + publishedAt = new Date().toISOString(), + notes = readReleaseNotes(), + commit = readHeadCommit(), + } = {}, +) { + validateReleaseTarget(target); + resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }, target); + const signature = readUpdaterSignature(artifactPath); const version = readPackageJson().version; const fileName = path.basename(artifactPath); - const baseUrl = ( - process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl - ).replace(/\/+$/u, ''); - const encodedFileName = encodeURIComponent(fileName).replace(/%2F/giu, '/'); + const url = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`; + const platforms = {}; + for (const key of resolveManifestPlatformKeys(target)) { + platforms[key] = { signature, url }; + } return { version, - downloadUrl: `${baseUrl}/${encodeURIComponent(version)}/${encodedFileName}`, - sha256: createHash('sha256').update(bytes).digest('hex'), - size: bytes.length, - ...(process.env.AGC_UPDATE_RELEASE_NOTES?.trim() - ? { releaseNotes: process.env.AGC_UPDATE_RELEASE_NOTES.trim() } - : {}), + ...(notes ? { notes } : {}), + pub_date: publishedAt, + platforms, + // 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。 + ...(commit ? { commit } : {}), }; } -export function generateUpdateManifest() { - const artifact = selectReleaseArtifact(listFiles(bundleRoot)); +function readHeadCommit() { + try { + return execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repoRoot, + encoding: 'utf8', + }).trim(); + } catch { + return ''; + } +} + +/** + * 上一次发布到本次之间的客户端相关提交。 + * + * 返回 null 表示无法判定(没有上一次 commit,或本地没有该提交),此时不生成摘要。 + */ +export function collectReleaseCommits( + previousCommit, + headCommit = 'HEAD', + { cwd = repoRoot, paths = agcReleasePathPatterns } = {}, +) { + if (!previousCommit) return null; + try { + for (const revision of [previousCommit, headCommit]) { + execFileSync('git', ['rev-parse', '--verify', `${revision}^{commit}`], { + cwd, + stdio: 'pipe', + }); + } + } catch { + return null; + } + let output; + try { + output = execFileSync( + 'git', + [ + 'log', + '--no-merges', + '--format=%h%x09%s', + `${previousCommit}..${headCommit}`, + '--', + ...paths, + ], + { cwd, encoding: 'utf8' }, + ); + } catch { + return null; + } + return output + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [sha = '', ...subject] = line.split('\t'); + return { sha, subject: subject.join('\t') }; + }); +} + +/** 自动更新摘要:逐条列客户端相关改动,超过上限时折叠并整体截断。 */ +export function formatReleaseNotes( + commits, + { limit = 12, subjectLength = 80, maxLength = 900 } = {}, +) { + if (!commits || commits.length === 0) return ''; + const lines = commits.slice(0, limit).map(({ sha, subject }) => { + const trimmed = + subject.length > subjectLength + ? `${subject.slice(0, subjectLength - 1)}…` + : subject; + return `- ${trimmed}(${sha})`; + }); + if (commits.length > limit) { + lines.push(`- 其余 ${commits.length - limit} 项客户端改动省略`); + } + const text = lines.join('\n'); + return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text; +} + +/** 无锚点时的兜底:列出最近的客户端相关提交,并注明可能与上一版重复。 */ +export function collectRecentReleaseCommits({ + cwd = repoRoot, + paths = agcReleasePathPatterns, + limit = 8, +} = {}) { + let output; + try { + output = execFileSync( + 'git', + ['log', '--no-merges', `-n${limit}`, '--format=%h%x09%s', '--', ...paths], + { cwd, encoding: 'utf8' }, + ); + } catch { + return null; + } + const commits = output + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [sha = '', ...subject] = line.split('\t'); + return { sha, subject: subject.join('\t') }; + }); + return commits.length > 0 ? commits : null; +} + +export function formatRecentReleaseNotes(commits) { + const notes = formatReleaseNotes(commits, { limit: 8 }); + if (!notes) return ''; + return `最近客户端改动(未定位到上一次发布提交,可能与上一版重复):\n${notes}`; +} + +/** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */ +export function createLegacyUpdateManifest( + artifactPath, + { channel = resolveReleaseChannel(), notes = readReleaseNotes() } = {}, +) { + const bytes = fs.readFileSync(artifactPath); + const version = readPackageJson().version; + const fileName = path.basename(artifactPath); + return { + version, + downloadUrl: `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`, + sha256: createHash('sha256').update(bytes).digest('hex'), + size: bytes.length, + ...(notes ? { releaseNotes: notes } : {}), + }; +} + +export async function generateUpdateManifest( + context = resolveReleaseContext(), +) { + const { channel, target, bundleRoot } = context; + const artifact = selectReleaseArtifact(listFiles(bundleRoot), target); if (!artifact) { throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`); } - const manifest = createUpdateManifest(artifact); + const manualNotes = readReleaseNotes(); + const previousCommit = await resolvePreviousReleaseCommit(channel); + const commits = collectReleaseCommits(previousCommit); + const recentCommits = previousCommit ? null : collectRecentReleaseCommits(); + const notes = + manualNotes || + formatReleaseNotes(commits) || + formatRecentReleaseNotes(recentCommits); + if (!manualNotes && !notes) { + console.log( + `[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'})`, + ); + } + const manifest = createUpdateManifest(artifact, { channel, target, notes }); const manifestPath = path.join(bundleRoot, 'latest.json'); fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); - console.log(`[ai-game-creator-shell] 已生成 ${manifestPath}`); + const notesPath = path.join(bundleRoot, 'release-notes.txt'); + fs.writeFileSync( + notesPath, + notes ? `${notes}\n` : '(本次没有可用的更新摘要)\n', + ); + const legacyManifest = + channel === 'dev-win' + ? createLegacyUpdateManifest(artifact, { channel, notes }) + : null; + const legacyManifestPath = legacyManifest + ? path.join(bundleRoot, 'legacy-latest.json') + : null; + if (legacyManifest && legacyManifestPath) { + fs.writeFileSync( + legacyManifestPath, + `${JSON.stringify(legacyManifest, null, 2)}\n`, + ); + } + console.log( + `[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`, + ); console.log(`[ai-game-creator-shell] 安装包:${artifact}`); - return { artifact, manifestPath, manifest }; + console.log( + manualNotes + ? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案' + : notes && !previousCommit + ? `[ai-game-creator-shell] 更新摘要:无锚点,列出最近 ${recentCommits ? recentCommits.length : 0} 条客户端相关提交` + : `[ai-game-creator-shell] 更新摘要:自动汇总 ${commits ? commits.length : 0} 条客户端相关提交(起点 ${previousCommit ?? '无'})`, + ); + console.log(`[ai-game-creator-shell] 更新摘要文件:${notesPath}`); + if (legacyManifestPath) { + console.log( + `[ai-game-creator-shell] 旧协议迁移清单:${legacyManifestPath}`, + ); + } + return { + channel, + artifact, + manifest, + manifestPath, + notes, + notesPath, + previousCommit, + commits, + legacyManifest, + legacyManifestPath, + }; +} + +export async function buildRelease( + args = [], + { + prepareVersion = prepareReleaseVersion, + build = runTauriBuild, + generateManifest = generateUpdateManifest, + } = {}, +) { + const context = resolveReleaseContext(args); + if (!args.includes('--no-bundle')) await prepareVersion(context); + build(args, context); + if (!args.includes('--no-bundle')) return generateManifest(context); } if ( @@ -279,7 +763,5 @@ if ( path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) ) { const args = process.argv.slice(2); - if (!args.includes('--no-bundle')) await prepareReleaseVersion(); - runTauriBuild(args); - if (!args.includes('--no-bundle')) generateUpdateManifest(); + await buildRelease(args); } diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index d2853082f..8cb9c0214 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -1,26 +1,113 @@ import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; import { + agcReleasePathPatterns, + buildRelease, + buildTauriBuildArguments, + collectRecentReleaseCommits, + collectReleaseCommits, compareVersions, + createChannelConfig, + createLegacyUpdateManifest, createUpdateManifest, + formatRecentReleaseNotes, + formatReleaseNotes, + generateUpdateManifest, nextPatchVersion, + resolveManifestPlatformKeys, + resolvePreviousReleaseCommit, + resolveReleaseChannel, + resolveReleaseContext, + resolveRemoteHighWaterVersion, + runTauriBuild, selectReleaseArtifact, + updateManifestUrl, } from './build-release.mjs'; -test('selects an explicit release artifact when configured', () => { - const artifactPath = new URL('../package.json', import.meta.url).pathname; - const previous = process.env.AGC_UPDATE_ARTIFACT; - process.env.AGC_UPDATE_ARTIFACT = artifactPath; - try { - assert.equal(selectReleaseArtifact([]), artifactPath); - } finally { - if (previous === undefined) delete process.env.AGC_UPDATE_ARTIFACT; - else process.env.AGC_UPDATE_ARTIFACT = previous; +const windowsTarget = 'x86_64-pc-windows-msvc'; +const universalTarget = 'universal-apple-darwin'; + +test('native sidecar builds reject universal targets and accept each macOS architecture', () => { + assert.throws(() => buildTauriBuildArguments([], universalTarget), /单架构/); + assert.throws( + () => buildTauriBuildArguments(['--target=universal-apple-darwin']), + /单架构/, + ); + for (const target of ['aarch64-apple-darwin', 'x86_64-apple-darwin']) { + assert.deepEqual(buildTauriBuildArguments([], target), [ + 'build', + '--target', + target, + ]); } }); +function withEnv(overrides, run) { + const previous = new Map(); + for (const [key, value] of Object.entries(overrides)) { + previous.set(key, process.env[key]); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + return run(); + } finally { + for (const [key, value] of previous) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +function withSignedArtifact(fileName, run) { + const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-release-test-')); + try { + const artifact = path.join(directory, fileName); + writeFileSync(artifact, 'installation package'); + writeFileSync(`${artifact}.sig`, 'signature-content\n'); + return run(artifact); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +function jsonResponse(body, status = 200) { + return { + status, + ok: status >= 200 && status < 300, + json: async () => body, + }; +} + +function withStubbedFetch(handler, run) { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => handler(String(url)); + return Promise.resolve(run()).finally(() => { + globalThis.fetch = originalFetch; + }); +} + +test('selects an explicit release artifact when configured', () => { + const artifactPath = fileURLToPath( + new URL('../package.json', import.meta.url), + ); + withEnv({ AGC_UPDATE_ARTIFACT: artifactPath }, () => { + assert.equal(selectReleaseArtifact([]), artifactPath); + }); +}); + test('does not select unsupported files', () => { assert.equal( selectReleaseArtifact(['/tmp/latest.json', '/tmp/readme.txt']), @@ -28,47 +115,608 @@ test('does not select unsupported files', () => { ); }); -test('manifest contains version, download URL and integrity fields', () => { - const manifest = createUpdateManifest( - new URL('../package.json', import.meta.url).pathname, +test('resolves the channel from the target platform and rejects mismatches', () => { + assert.equal(resolveReleaseChannel({}, windowsTarget), 'dev-win'); + assert.equal(resolveReleaseChannel({}, universalTarget), 'dev-mac'); + assert.equal( + resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'dev-mac' }, universalTarget), + 'dev-mac', ); - assert.match(manifest.version, /^\d+\.\d+\.\d+$/u); - assert.match( - manifest.downloadUrl, - new RegExp(`/agc/${manifest.version}/package\\.json$`, 'u'), + assert.throws( + () => + resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'dev-mac' }, windowsTarget), + /只能用于 darwin 目标/u, + ); + assert.throws( + () => + resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'beta-win' }, windowsTarget), + /未知发布渠道/u, ); - assert.equal(manifest.sha256.length, 64); - assert.equal(typeof manifest.size, 'number'); }); -test('manifest preserves multiline release notes', () => { - const previous = process.env.AGC_UPDATE_RELEASE_NOTES; - process.env.AGC_UPDATE_RELEASE_NOTES = '第一行\n第二行\r\n第三行'; - try { - const manifest = createUpdateManifest( - new URL('../package.json', import.meta.url).pathname, +test('channel manifest URL and build-time endpoint follow the channel', () => { + withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => { + assert.equal( + updateManifestUrl('dev-win'), + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json', ); - assert.equal(manifest.releaseNotes, '第一行\n第二行\r\n第三行'); + assert.deepEqual(createChannelConfig('dev-mac'), { + plugins: { + updater: { + endpoints: [ + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/latest.json', + ], + }, + }, + }); + }); +}); + +test('macOS manifests only advertise the architecture actually built', () => { + assert.throws(() => resolveManifestPlatformKeys(universalTarget), /单架构/); + assert.deepEqual(resolveManifestPlatformKeys('aarch64-apple-darwin'), [ + 'darwin-aarch64', + ]); + assert.deepEqual(resolveManifestPlatformKeys('x86_64-apple-darwin'), [ + 'darwin-x86_64', + ]); + assert.deepEqual(resolveManifestPlatformKeys(windowsTarget), [ + 'windows-x86_64', + ]); +}); + +test('release context resolves explicit targets before environment/default and fails closed', () => { + for (const args of [ + ['--target', 'aarch64-apple-darwin'], + ['--target=aarch64-apple-darwin'], + ['-t', 'aarch64-apple-darwin'], + ]) { + for (const env of [{}, { AGC_BUILD_TARGET: windowsTarget }]) { + const context = resolveReleaseContext(args, env); + assert.equal(context.target, 'aarch64-apple-darwin'); + assert.equal(context.channel, 'dev-mac'); + assert.match( + context.bundleRoot.replaceAll('\\', '/'), + /target\/aarch64-apple-darwin\/release\/bundle$/, + ); + assert.ok(Object.isFrozen(context)); + } + assert.throws( + () => resolveReleaseContext(args, { AGC_UPDATE_CHANNEL: 'dev-win' }), + /只能用于 windows/, + ); + } + assert.equal(resolveReleaseContext([], {}).target, windowsTarget); + assert.equal( + resolveReleaseContext([], { AGC_BUILD_TARGET: 'x86_64-apple-darwin' }) + .channel, + 'dev-mac', + ); + for (const args of [ + ['--target'], + ['--target='], + ['--target', '--no-bundle'], + ['--target', windowsTarget, '--target=aarch64-apple-darwin'], + ['--target', universalTarget], + ['--target', 'unknown'], + ]) + assert.throws(() => resolveReleaseContext(args, {})); +}); + +test('explicit macOS target drives version lookup, Tauri endpoint, artifact and manifest together', async () => { + const calls = []; + const seenContexts = []; + await withStubbedFetch( + (url) => { + calls.push(url); + assert.match(url, /\/dev-mac\/latest\.json$/); + return jsonResponse({ version: '0.1.67' }); + }, + () => + withEnv( + { AGC_BUILD_TARGET: undefined, AGC_UPDATE_CHANNEL: undefined }, + () => + buildRelease(['--target', 'aarch64-apple-darwin'], { + prepareVersion: async (context) => { + seenContexts.push(context); + assert.equal( + await resolveRemoteHighWaterVersion(context.channel), + '0.1.67', + ); + }, + build: (args, context) => { + seenContexts.push(context); + runTauriBuild(args, context, { + spawn: (_binary, command) => { + const configIndex = command.lastIndexOf('--config'); + const config = JSON.parse( + readFileSync(command[configIndex + 1], 'utf8'), + ); + assert.match( + config.plugins.updater.endpoints[0], + /\/dev-mac\/latest\.json$/, + ); + assert.ok(command.includes('aarch64-apple-darwin')); + assert.ok( + !command.includes('--features=cocos-editor-execute'), + ); + return { status: 0 }; + }, + }); + }, + generateManifest: (context) => { + seenContexts.push(context); + withSignedArtifact('陶泥儿.app.tar.gz', (artifact) => { + assert.equal( + selectReleaseArtifact( + ['/tmp/win.exe', artifact, '/tmp/mac.dmg'], + context.target, + ), + artifact, + ); + const manifest = createUpdateManifest(artifact, context); + assert.deepEqual(Object.keys(manifest.platforms), [ + 'darwin-aarch64', + ]); + assert.match( + manifest.platforms['darwin-aarch64'].url, + /\/dev-mac\//, + ); + }); + }, + }), + ), + ); + assert.equal(calls.length, 1, 'Mac 不应读取 Windows 迁移指针'); + assert.equal(seenContexts.length, 3); + assert.ok(seenContexts.every((context) => context === seenContexts[0])); +}); + +test('real manifest writer uses the resolved bundle root and does not emit Windows artifacts', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'agc-mac-manifest-')); + try { + const artifact = path.join(root, '陶泥儿.app.tar.gz'); + writeFileSync(artifact, 'mac package'); + writeFileSync(`${artifact}.sig`, 'mac signature'); + writeFileSync(path.join(root, 'windows.exe'), 'wrong platform'); + const context = { + ...resolveReleaseContext(['--target=x86_64-apple-darwin'], {}), + bundleRoot: root, + }; + const result = await withStubbedFetch( + (url) => { + assert.match(url, /\/dev-mac\/latest\.json$/); + return jsonResponse({}, 404); + }, + () => generateUpdateManifest(context), + ); + assert.equal(result.artifact, artifact); + assert.equal(result.manifestPath, path.join(root, 'latest.json')); + assert.equal(result.legacyManifestPath, null); + assert.deepEqual(Object.keys(result.manifest.platforms), ['darwin-x86_64']); + assert.match(result.manifest.platforms['darwin-x86_64'].url, /\/dev-mac\//); } finally { - if (previous === undefined) delete process.env.AGC_UPDATE_RELEASE_NOTES; - else process.env.AGC_UPDATE_RELEASE_NOTES = previous; + rmSync(root, { recursive: true, force: true }); } }); -test('next release version follows the higher local or OSS version', () => { +test('invalid target or mismatched channel fails before any release side effect', async () => { + let touched = false; + const sideEffects = { + prepareVersion: () => { + touched = true; + }, + build: () => { + touched = true; + }, + generateManifest: () => { + touched = true; + }, + }; + await assert.rejects( + () => buildRelease(['--target', universalTarget], sideEffects), + /单架构/, + ); + await withEnv({ AGC_UPDATE_CHANNEL: 'dev-win' }, () => + assert.rejects( + () => buildRelease(['--target=aarch64-apple-darwin'], sideEffects), + /只能用于 windows/, + ), + ); + assert.equal(touched, false); +}); + +test('Windows remains the default and explicit Windows overrides macOS environment', () => { + const files = ['/tmp/mac.app.tar.gz', '/tmp/windows.exe', '/tmp/mac.dmg']; + for (const context of [ + resolveReleaseContext([], {}), + resolveReleaseContext(['--target', windowsTarget], { + AGC_BUILD_TARGET: 'aarch64-apple-darwin', + }), + ]) { + assert.equal(context.channel, 'dev-win'); + assert.equal( + selectReleaseArtifact(files, context.target), + '/tmp/windows.exe', + ); + runTauriBuild( + ['--target', windowsTarget, '--config', 'user-config.json'], + context, + { + spawn: (_binary, command) => { + assert.ok(command.includes('--features=cocos-editor-execute')); + assert.ok(command.includes('user-config.json')); + const configIndex = command.lastIndexOf('--config'); + const config = JSON.parse( + readFileSync(command[configIndex + 1], 'utf8'), + ); + assert.match( + config.plugins.updater.endpoints[0], + /\/dev-win\/latest\.json$/, + ); + return { status: 0 }; + }, + }, + ); + } +}); + +test('no-bundle smoke skips version writes and manifest generation', async () => { + const steps = []; + await buildRelease(['--no-bundle', '--target=aarch64-apple-darwin'], { + prepareVersion: () => { + steps.push('version'); + }, + build: (_args, context) => { + steps.push(context.channel); + }, + generateManifest: () => { + steps.push('manifest'); + }, + }); + assert.deepEqual(steps, ['dev-mac']); +}); + +test('channel manifest carries version, platform keys and signature', () => { + withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => { + withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => { + const manifest = createUpdateManifest(artifact, { + channel: 'dev-win', + target: windowsTarget, + publishedAt: '2026-09-17T00:00:00.000Z', + }); + assert.match(manifest.version, /^\d+\.\d+\.\d+$/u); + assert.equal(manifest.notes, '修复与改进'); + assert.equal(manifest.pub_date, '2026-09-17T00:00:00.000Z'); + assert.deepEqual(Object.keys(manifest.platforms), ['windows-x86_64']); + assert.equal( + manifest.platforms['windows-x86_64'].signature, + 'signature-content', + ); + assert.match( + manifest.platforms['windows-x86_64'].url, + new RegExp(`/agc/dev-win/${manifest.version}/`, 'u'), + ); + }); + }); +}); + +test('missing signature fails the channel manifest closed', () => { + const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-release-test-')); + try { + const artifact = path.join(directory, '陶泥儿_0.1.48_x64-setup.exe'); + writeFileSync(artifact, 'installation package'); + assert.throws( + () => + createUpdateManifest(artifact, { + channel: 'dev-win', + target: windowsTarget, + }), + /缺少更新包签名/u, + ); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test('legacy manifest keeps the sha256 contract of published clients', () => { + withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => { + const legacy = createLegacyUpdateManifest(artifact, { + channel: 'dev-win', + }); + assert.match(legacy.version, /^\d+\.\d+\.\d+$/u); + assert.equal(legacy.sha256.length, 64); + assert.equal(legacy.size, 'installation package'.length); + assert.match(legacy.downloadUrl, /\/agc\/dev-win\/[\d.]+\//u); + }); +}); + +test('next release version follows the higher local or channel version', () => { assert.equal(compareVersions('0.1.15', '0.1.12'), 1); assert.equal(nextPatchVersion('0.1.12', '0.1.15'), '0.1.16'); assert.equal(nextPatchVersion('0.1.18', '0.1.15'), '0.1.19'); assert.equal(nextPatchVersion('0.1.12', null), '0.1.13'); }); -test('release upload forces overwrite for versioned artifact and latest pointer', () => { +test('version high water keeps the legacy pointer during the migration window', async () => { + await withStubbedFetch( + (url) => + url.endsWith('/agc/dev-win/latest.json') + ? jsonResponse({}, 404) + : jsonResponse({ version: '0.1.57' }), + async () => { + assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.57'); + // 旧指针 0.1.57 已是高水位,下一次发布必须是 0.1.58,不能退回渠道本地版本。 + assert.equal(nextPatchVersion('0.1.47', '0.1.57'), '0.1.58'); + }, + ); +}); + +test('version high water takes the higher of channel and legacy pointer', async () => { + await withStubbedFetch( + (url) => + url.endsWith('/agc/dev-win/latest.json') + ? jsonResponse({ version: '0.1.60' }) + : jsonResponse({ version: '0.1.57' }), + async () => { + assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.60'); + }, + ); +}); + +test('version high water ignores the windows migration pointer for other channels', async () => { + await withStubbedFetch( + (url) => { + assert.ok( + !url.endsWith('/agc/latest.json'), + 'non-windows channel must not read the windows migration pointer', + ); + return jsonResponse({ version: '0.1.12' }); + }, + async () => { + assert.equal(await resolveRemoteHighWaterVersion('dev-mac'), '0.1.12'); + }, + ); +}); + +test('release notes anchor prefers the explicit commit and falls back to the manifest', async () => { + await withStubbedFetch( + () => jsonResponse({ version: '0.1.61', commit: 'abcdef1234567890' }), + async () => { + assert.equal( + await resolvePreviousReleaseCommit('dev-win', { + override: '6017d46088c04199e99cf89f347b12d67591475e', + }), + '6017d46088c04199e99cf89f347b12d67591475e', + ); + // 覆盖值非法时忽略,继续用清单里的 commit。 + assert.equal( + await resolvePreviousReleaseCommit('dev-win', { + override: 'not-a-sha', + }), + 'abcdef1234567890', + ); + assert.equal( + await resolvePreviousReleaseCommit('dev-win', { override: ' ' }), + 'abcdef1234567890', + ); + }, + ); + + await withStubbedFetch( + () => jsonResponse({ version: '0.1.61' }), + async () => { + assert.equal( + await resolvePreviousReleaseCommit('dev-win', { override: undefined }), + null, + ); + }, + ); +}); + +test('release notes anchor degrades to null when the manifest cannot be read', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + throw new Error('fetch failed'); + }; + try { + assert.equal( + await resolvePreviousReleaseCommit('dev-win', { override: undefined }), + null, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('recent commit fallback marks that entries may repeat the previous release', () => { + const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-recent-git-')); + const git = (...args) => + execFileSync('git', args, { cwd: directory, encoding: 'utf8' }); + try { + git('init', '--quiet'); + git('config', 'user.email', 'release@example.test'); + git('config', 'user.name', 'release test'); + mkdirSync(path.join(directory, 'apps/ai-game-creator-shell'), { + recursive: true, + }); + for (const name of ['one', 'two']) { + writeFileSync( + path.join(directory, `apps/ai-game-creator-shell/${name}.rs`), + `fn ${name}() {}\n`, + ); + git('add', '.'); + git('commit', '--quiet', '-m', `客户端:${name}`); + } + writeFileSync(path.join(directory, 'README.md'), '# 文档\n'); + git('add', '.'); + git('commit', '--quiet', '-m', '文档:说明'); + + const recent = collectRecentReleaseCommits({ cwd: directory, limit: 5 }); + assert.deepEqual( + recent.map((entry) => entry.subject), + ['客户端:two', '客户端:one'], + ); + const notes = formatRecentReleaseNotes(recent); + assert.match( + notes, + /^最近客户端改动(未定位到上一次发布提交,可能与上一版重复):\n- 客户端:two/u, + ); + assert.equal(formatRecentReleaseNotes([]), ''); + assert.equal(formatRecentReleaseNotes(null), ''); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test('release upload forces overwrite for artifact, signature and channel pointers', () => { const source = readFileSync( new URL('./release-upload.mjs', import.meta.url), 'utf8', ); assert.equal( - (source.match(/runOssutil\(\['cp', '--force'/gu) ?? []).length, - 2, + (source.match(/runOssutil\(\[\s*'cp',\s*'--force'/gu) ?? []).length, + 4, ); + assert.match(source, /agc\/\$\{channel\}\/latest\.json/u); + assert.match(source, /agc\/latest\.json/u); + assert.match(source, /await buildRelease\(process\.argv\.slice\(2\)\)/u); +}); + +test('release notes list client commits with short sha and bound their size', () => { + const notes = formatReleaseNotes([ + { sha: 'a5fd25f1', subject: '客户端更新切换到官方更新插件' }, + { sha: '55af6014', subject: '修'.repeat(120) }, + ]); + const lines = notes.split('\n'); + assert.equal(lines.length, 2); + assert.match(lines[0], /^- 客户端更新切换到官方更新插件(a5fd25f1)$/u); + const truncatedSubject = lines[1] + .replace(/^- /u, '') + .replace(/(55af6014)$/u, ''); + assert.equal(truncatedSubject.length, 80, `主题应截断到 80 字:${lines[1]}`); + assert.match(truncatedSubject, /…$/u); + assert.match(lines[1], /(55af6014)$/u); + + const many = formatReleaseNotes( + Array.from({ length: 20 }, (_, index) => ({ + sha: `sha${index}`, + subject: `改动 ${index}`, + })), + ); + assert.match(many, /- 其余 8 项客户端改动省略$/u); + assert.equal(formatReleaseNotes([]), ''); + assert.equal(formatReleaseNotes(null), ''); +}); + +test('release commits cover only client paths and skip merge commits', () => { + const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-changelog-git-')); + const git = (...args) => + execFileSync('git', args, { cwd: directory, encoding: 'utf8' }); + try { + git('init', '--quiet'); + git('config', 'user.email', 'release@example.test'); + git('config', 'user.name', 'release test'); + git('commit', '--allow-empty', '--quiet', '-m', '基点'); + const base = git('rev-parse', 'HEAD').trim(); + + mkdirSync(path.join(directory, 'apps/ai-game-creator-shell'), { + recursive: true, + }); + mkdirSync(path.join(directory, 'docs'), { recursive: true }); + writeFileSync( + path.join(directory, 'apps/ai-game-creator-shell/main.rs'), + 'fn main() {}\n', + ); + git('add', '.'); + git('commit', '--quiet', '-m', '客户端:新增更新插件接入'); + + writeFileSync(path.join(directory, 'docs/readme.md'), '# 文档\n'); + git('add', '.'); + git('commit', '--quiet', '-m', '文档:补充说明'); + + writeFileSync( + path.join(directory, 'apps/ai-game-creator-shell/other.rs'), + 'fn other() {}\n', + ); + git('add', '.'); + git('commit', '--quiet', '-m', '客户端:修复版本回退'); + + git('checkout', '--quiet', '-b', 'side'); + writeFileSync( + path.join(directory, 'apps/ai-game-creator-shell/side.rs'), + 'fn side() {}\n', + ); + git('add', '.'); + git('commit', '--quiet', '-m', '客户端:侧分支改动'); + git('checkout', '--quiet', 'master'); + git('merge', '--quiet', '--no-ff', '--no-edit', 'side'); + + const commits = collectReleaseCommits(base, 'HEAD', { cwd: directory }); + assert.ok(commits, '应能在临时仓库里收集提交'); + const subjects = commits.map((entry) => entry.subject); + // 合并提交本身被 --no-merges 排除,但它带入的客户端改动仍然计入。 + assert.deepEqual(subjects, [ + '客户端:侧分支改动', + '客户端:修复版本回退', + '客户端:新增更新插件接入', + ]); + assert.ok(commits.every((entry) => /^[0-9a-f]{7,}$/u.test(entry.sha))); + + assert.equal( + collectReleaseCommits('1234567890abcdef', 'HEAD', { cwd: directory }), + null, + ); + assert.equal(collectReleaseCommits(null, 'HEAD', { cwd: directory }), null); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test('scheduler path filter stays in sync with client release paths', () => { + const jenkinsfile = readFileSync( + new URL( + '../../../jenkins/Jenkinsfile.scheduled-revision-trigger', + import.meta.url, + ), + 'utf8', + ); + const filterLine = jenkinsfile + .split('\n') + .find((line) => line.includes('apps/ai-game-creator-shell/*|')); + assert.ok(filterLine, '调度管线里应存在发布范围过滤模式'); + for (const pattern of agcReleasePathPatterns) { + const bashPattern = pattern.includes('/') ? `${pattern}*` : pattern; + assert.ok( + filterLine.includes(bashPattern), + `调度管线过滤缺少 ${bashPattern}`, + ); + } +}); + +test('scheduler skips the full build only for non-deploy paths', () => { + const jenkinsfile = readFileSync( + new URL( + '../../../jenkins/Jenkinsfile.scheduled-revision-trigger', + import.meta.url, + ), + 'utf8', + ); + const skipLine = jenkinsfile + .split('\n') + .find((line) => line.includes('docs/*|.codex/*|jenkins/*')); + assert.ok(skipLine, '调度管线里应存在 Full Build 跳过模式'); + for (const pattern of [ + 'docs/*', + '.codex/*', + 'jenkins/*', + 'apps/ai-game-creator-shell/*', + 'apps/mobile-shell/*', + 'apps/desktop-shell/*', + 'apps/preview-deployer-web/*', + 'tools/*', + '*.md', + ]) { + assert.ok(skipLine.includes(pattern), `Full Build 跳过模式缺少 ${pattern}`); + } }); diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index e32dfb4b7..fe8943608 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -35,6 +35,12 @@ const windowsTauriConfig = JSON.parse( 'utf8', ), ); +const macosTauriConfig = JSON.parse( + fs.readFileSync( + new URL('../src-tauri/tauri.macos.conf.json', import.meta.url), + 'utf8', + ), +); const cargoManifestSource = fs.readFileSync( new URL('../src-tauri/Cargo.toml', import.meta.url), 'utf8', @@ -121,6 +127,10 @@ const allowedUncalledTauriCommands = [ 'open_game_creator_launcher_window', 'open_game_creator_workspace_window', 'read_direct_project_conversation', + // 项目定时快照上传只在 Rust 侧触发(周期定时器 / 工作区窗口关闭)与排障调用; + // 按产品口径不做客户端可见界面,因此同 `open_game_creator_*_window` 一样按 native-only 登记。 + 'read_local_project_snapshot_state', + 'sync_local_project_snapshot', 'reset_design_agent_session', 'stop_local_game_preview_if_matches', 'start_game_creator_external_mcp', @@ -1354,6 +1364,37 @@ if (windowsTauriConfig.bundle?.useLocalToolsDir !== true) { 'AI game creator shell Windows Tauri config must cache bundling tools in the project target directory', ); } +assert.deepEqual( + macosTauriConfig.bundle?.resources, + Object.fromEntries([ + ...[ + 'bin/codex', + 'bin/codex-code-mode-host', + 'codex-path/rg', + 'codex-resources/zsh/bin/zsh', + 'codex-package.json', + 'NOTICE.md', + 'manifest.json', + ].map((file) => [ + `resources/codex/mac-native/${file}`, + `coding-agent/mac-native/${file}`, + ]), + ['resources/plugins', 'plugins'], + ]), + 'macOS must bundle the complete native Codex layout and plugin workspace', +); +assert.deepEqual( + macosTauriConfig.plugins?.updater?.endpoints, + [ + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/latest.json', + ], + 'macOS local builds must not use the Windows update channel', +); +assert.equal( + macosTauriConfig.bundle?.macOS?.minimumSystemVersion, + '15.0', + 'macOS deployment baseline must cover the bundled native zsh requirement', +); if (tauriConfig.app?.withGlobalTauri !== true) { throw new Error( @@ -1718,7 +1759,7 @@ for (const snippet of [ 'fn append_local_permission_log_at(', '"command.auto"', 'GameCreationAppPermission::Auto', - 'GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH', + 'fn game_creator_bundled_codex_cli_path', 'validate_game_creator_bundled_codex_cli', '内置 Codex CLI 完整性校验失败', ]) { diff --git a/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs b/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs new file mode 100644 index 000000000..78e38fdd9 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs @@ -0,0 +1,222 @@ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// 只操作临时复制品;不启动 GUI、不读取开发机凭据、不访问 Provider。 +assert.equal(process.platform, 'darwin', '此验证必须在 macOS 执行'); +const source = path.resolve(process.argv[2] || ''); +assert.ok( + source.endsWith('.app') && fs.statSync(source).isDirectory(), + '请传入 .app 绝对路径', +); +const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'agc-macos-bundle-')), +); +const app = path.join(root, '陶泥儿 隔离测试.app'); +const home = path.join(root, 'home'); +const config = path.join(root, 'config'); +const tmp = path.join(root, 'tmp'); +const codexHome = path.join(root, 'codex-home'); +for (const directory of [home, config, tmp, codexHome]) { + fs.mkdirSync(directory, { mode: 0o700 }); +} +const env = { + HOME: home, + PATH: '/usr/bin:/bin', + TMPDIR: tmp, + CODEX_HOME: codexHome, +}; + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: root, + env, + encoding: 'utf8', + timeout: 30_000, + maxBuffer: 1024 * 1024, + }); + assert.ifError(result.error); + return result; +} + +async function hashFile(file) { + const hash = createHash('sha256'); + for await (const chunk of fs.createReadStream(file)) hash.update(chunk); + return hash.digest('hex'); +} + +async function handshake(executable) { + const child = spawn(executable, ['app-server'], { + cwd: root, + env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let buffered = ''; + let stderrBytes = 0; + try { + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('app-server 初始化超时')), + 15_000, + ); + const finish = (error) => { + clearTimeout(timer); + if (error) reject(error); + else resolve(); + }; + child.on('error', finish); + child.on('exit', (code) => + finish(new Error(`app-server 提前退出 ${code}`)), + ); + child.stderr.on('data', (chunk) => { + stderrBytes += chunk.length; + if (stderrBytes > 1024 * 1024) + finish(new Error('app-server stderr 超限')); + }); + child.stdout.on('data', (chunk) => { + buffered += chunk.toString('utf8'); + if (buffered.length > 1024 * 1024) + return finish(new Error('app-server stdout 超限')); + let end; + while ((end = buffered.indexOf('\n')) >= 0) { + const line = buffered.slice(0, end); + buffered = buffered.slice(end + 1); + try { + const message = JSON.parse(line); + if (message.id !== 1) continue; + assert.ok(message.result?.userAgent, '初始化必须返回真实服务身份'); + assert.equal(message.error, undefined); + child.stdin.write(`${JSON.stringify({ method: 'initialized' })}\n`); + finish(); + } catch (error) { + finish(error); + } + } + }); + child.stdin.on('error', finish); + child.stdin.write( + `${JSON.stringify({ + id: 1, + method: 'initialize', + params: { + clientInfo: { + name: 'agc_bundle_smoke', + title: 'AGC bundle smoke', + version: '1', + }, + capabilities: { experimentalApi: true }, + }, + })}\n`, + ); + }); + } finally { + if (child.exitCode === null && child.signalCode === null) { + await new Promise((resolve) => { + const timer = setTimeout(() => child.kill('SIGKILL'), 3000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + child.kill('SIGTERM'); + }); + } + } +} + +try { + fs.cpSync(source, app, { recursive: true }); + const resources = path.join(app, 'Contents/Resources'); + const bundle = path.join(resources, 'coding-agent/mac-native'); + const executable = path.join(bundle, 'bin/codex'); + const main = path.join( + app, + 'Contents/MacOS/genarrative-ai-game-creator-shell', + ); + const manifest = JSON.parse( + fs.readFileSync(path.join(bundle, 'manifest.json'), 'utf8'), + ); + assert.equal(manifest.schemaVersion, 'genarrative-codex-sidecar.v2'); + assert.equal( + manifest.platform, + process.arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64', + ); + assert.equal(manifest.version, 'codex-cli 0.147.0'); + const components = [ + 'bin/codex', + 'bin/codex-code-mode-host', + 'codex-path/rg', + 'codex-resources/zsh/bin/zsh', + 'codex-package.json', + ]; + assert.deepEqual(Object.keys(manifest.files).sort(), [...components].sort()); + for (const component of components) { + const file = path.join(bundle, component); + assert.equal(await hashFile(file), manifest.files[component], component); + if (component !== 'codex-package.json') { + fs.accessSync(file, fs.constants.X_OK); + const arch = run('/usr/bin/lipo', ['-archs', file]); + assert.equal(arch.status, 0, component); + assert.equal( + arch.stdout.trim(), + process.arch === 'arm64' ? 'arm64' : 'x86_64', + component, + ); + } + } + assert.ok(fs.existsSync(path.join(bundle, 'NOTICE.md'))); + const plugin = path.join(resources, 'plugins/agc-cocos-editor'); + for (const file of [ + 'plugin.json', + 'src/entry.mjs', + 'panels/cocos-editor.html', + ]) { + assert.ok(fs.existsSync(path.join(plugin, file)), file); + } + const packageFiles = fs.readdirSync(resources, { recursive: true }); + assert.ok( + !packageFiles.some((file) => + /(^|\/)(\.env[^/]*|auth\.json|node_modules|target|\.git)(\/|$)|\.(exe|dll)$/.test( + file, + ), + ), + ); + assert.equal(run(executable, ['--version']).stdout.trim(), manifest.version); + assert.equal( + run(path.join(bundle, 'codex-path/rg'), ['--version']).status, + 0, + ); + assert.equal( + run(path.join(bundle, 'codex-resources/zsh/bin/zsh'), ['--version']).status, + 0, + ); + + // 使用正式 AGC 查找/校验入口,而非只证明 sidecar 可以独立执行。 + const status = run(main, ['--config-dir', config, '--llm-status']); + const statusText = `${status.stdout}\n${status.stderr}`; + assert.ok(!statusText.includes('Codex CLI 未安装'), statusText); + assert.ok( + statusText.includes('authentication-required'), + '隔离账号应仅被登录门禁拒绝', + ); + await handshake(executable); + + // 临时复制品缺少辅助程序时,正式入口必须拒绝内置程序;PATH 无全局 Codex 可兜底。 + fs.renameSync( + path.join(bundle, 'bin/codex-code-mode-host'), + path.join(root, 'saved-code-mode-host'), + ); + const broken = run(main, ['--config-dir', config, '--llm-status']); + assert.notEqual(broken.status, 0); + assert.match(`${broken.stdout}\n${broken.stderr}`, /Codex CLI 未安装/); + console.log( + 'PASS: 隔离安装包资源、架构、摘要、权限、正式 Codex 查找、app-server 握手及缺组件拒绝', + ); + console.log( + '未验证:GUI、真实登录/Provider 对话、Cocos macOS 原生桥接;插件 Node 仍为外部前提', + ); +} finally { + fs.rmSync(root, { recursive: true, force: true }); +} diff --git a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs index fed45aa02..c0a748ad3 100644 --- a/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs +++ b/apps/ai-game-creator-shell/scripts/deterministic-lane-defense-provider.mjs @@ -724,6 +724,7 @@ function canvasAssetCall(agentId) { assetKind: 'art-spritesheet', assetLabel: '游戏首版核心美术素材', replaceExisting: false, + sliceMode: 'connected-components', }); } @@ -2691,7 +2692,7 @@ function createDeterministicCanvasFixture(apiKey) { 'deterministic spritesheet fixture', model: 'deterministic-canvas-v1', provider: 'deterministic-loopback', - sliceLayout: 'grid-2x2', + sliceMode: 'connected-components', spritesheetResource: { resourceId, projectId, diff --git a/apps/ai-game-creator-shell/scripts/dev-feature-flags.mjs b/apps/ai-game-creator-shell/scripts/dev-feature-flags.mjs new file mode 100644 index 000000000..1158c4e69 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/dev-feature-flags.mjs @@ -0,0 +1,20 @@ +/** + * `agc` 开发启动下发给 Vite 的客户端特性开关默认值。 + * + * 开发态默认关闭客户端更新检查:`npm run agc` / `agc:serve` 启动的客户端不请求 OSS + * 更新清单,也不显示更新入口。需要联调更新流程时显式传 + * `VITE_AGC_ENABLE_APP_UPDATE_CHECK=1`;此处不覆盖已经显式配置的取值。 + */ +const agcAppUpdateCheckEnvKey = 'VITE_AGC_ENABLE_APP_UPDATE_CHECK'; + +function withAgcDevFeatureFlags(env = process.env) { + if (String(env[agcAppUpdateCheckEnvKey] ?? '').trim()) { + return env; + } + return { + ...env, + [agcAppUpdateCheckEnvKey]: '0', + }; +} + +export { agcAppUpdateCheckEnvKey, withAgcDevFeatureFlags }; diff --git a/apps/ai-game-creator-shell/scripts/release-oss.mjs b/apps/ai-game-creator-shell/scripts/release-oss.mjs new file mode 100644 index 000000000..0e19ee81b --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/release-oss.mjs @@ -0,0 +1,27 @@ +/** + * 发布上传的 OSS 命令行整理:把 ossutil 参数与凭据整理成可执行或可打印的形式, + * 便于在 dry-run 下核对将要执行的上传,同时保证任何输出都不回显凭据明文。 + */ +const redactedCredential = ''; + +export function readReleaseDryRun(env = process.env) { + const value = env.AGC_RELEASE_DRY_RUN?.trim().toLowerCase(); + return value === '1' || value === 'true'; +} + +function quoteArgument(value) { + return /[\s"']/u.test(value) ? JSON.stringify(value) : value; +} + +export function formatOssutilCommand({ binary, args, endpoint, credentials }) { + const parts = [binary, ...args, '--endpoint', endpoint]; + if (credentials) { + parts.push( + '--access-key-id', + redactedCredential, + '--access-key-secret', + redactedCredential, + ); + } + return parts.map(quoteArgument).join(' '); +} diff --git a/apps/ai-game-creator-shell/scripts/release-oss.test.mjs b/apps/ai-game-creator-shell/scripts/release-oss.test.mjs new file mode 100644 index 000000000..0e38efc9d --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/release-oss.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; + +import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs'; + +test('dry run only accepts explicit truthy values', () => { + assert.equal(readReleaseDryRun({}), false); + assert.equal(readReleaseDryRun({ AGC_RELEASE_DRY_RUN: '1' }), true); + assert.equal(readReleaseDryRun({ AGC_RELEASE_DRY_RUN: ' true ' }), true); + assert.equal(readReleaseDryRun({ AGC_RELEASE_DRY_RUN: '0' }), false); + assert.equal(readReleaseDryRun({ AGC_RELEASE_DRY_RUN: '' }), false); +}); + +test('printed upload command keeps arguments and hides credentials', () => { + const command = formatOssutilCommand({ + binary: 'ossutil', + args: [ + 'cp', + '--force', + '陶泥儿 0.1.48.exe', + 'oss://agc-dev/agc/dev-win/x.exe', + ], + endpoint: 'oss-rg-china-mainland.aliyuncs.com', + credentials: true, + }); + assert.match(command, /^ossutil cp --force /u); + assert.match(command, /"陶泥儿 0\.1\.48\.exe"/u); + assert.match(command, /oss:\/\/agc-dev\/agc\/dev-win\/x\.exe/u); + assert.match( + command, + /--access-key-id --access-key-secret /u, + ); +}); + +test('uploader gates every ossutil call behind the dry run switch', () => { + const source = readFileSync( + new URL('./release-upload.mjs', import.meta.url), + 'utf8', + ); + assert.match(source, /const dryRun = readReleaseDryRun\(\);/u); + assert.match(source, /if \(dryRun\) \{/u); + assert.match(source, /dry-run:未写入任何 OSS 对象/u); +}); diff --git a/apps/ai-game-creator-shell/scripts/release-upload.mjs b/apps/ai-game-creator-shell/scripts/release-upload.mjs index ee5af4452..d39b2fa9f 100644 --- a/apps/ai-game-creator-shell/scripts/release-upload.mjs +++ b/apps/ai-game-creator-shell/scripts/release-upload.mjs @@ -1,6 +1,8 @@ import { spawnSync } from 'node:child_process'; import path from 'node:path'; +import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs'; + const bucket = process.env.AGC_OSS_BUCKET?.trim() || 'agc-dev'; const endpoint = process.env.AGC_OSS_ENDPOINT?.trim() || 'oss-rg-china-mainland.aliyuncs.com'; @@ -8,9 +10,9 @@ if (!/^[a-z0-9][a-z0-9.-]{1,62}$/u.test(bucket) || /[\r\n\0]/u.test(endpoint)) { throw new Error('OSS bucket 或 endpoint 配置无效'); } process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`; +const dryRun = readReleaseDryRun(); -const { generateUpdateManifest, prepareReleaseVersion, runTauriBuild } = - await import('./build-release.mjs'); +const { buildRelease } = await import('./build-release.mjs'); function runOssutil(args) { const binary = process.env.OSSUTIL_BIN?.trim() || 'ossutil'; @@ -19,6 +21,18 @@ function runOssutil(args) { if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) { throw new Error('OSS AccessKey ID 和 Secret 必须同时提供'); } + if (dryRun) { + // 演练:只打印将要执行的上传,凭据以占位符呈现,不写入 OSS。 + console.log( + `[dry-run] ${formatOssutilCommand({ + binary, + args, + endpoint, + credentials: Boolean(accessKeyId), + })}`, + ); + return; + } const credentialArgs = accessKeyId ? ['--access-key-id', accessKeyId, '--access-key-secret', accessKeySecret] : []; @@ -36,13 +50,40 @@ function runOssutil(args) { if (result.status !== 0) process.exit(result.status ?? 1); } -await prepareReleaseVersion(); -runTauriBuild([]); -const { artifact, manifestPath, manifest } = generateUpdateManifest(); -const artifactKey = `agc/${manifest.version}/${path.basename(artifact)}`; +const { artifact, channel, legacyManifestPath, manifest, manifestPath } = + await buildRelease(process.argv.slice(2)); +const artifactKey = `agc/${channel}/${manifest.version}/${path.basename(artifact)}`; // Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过; // 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。 runOssutil(['cp', '--force', artifact, `oss://${bucket}/${artifactKey}`]); -runOssutil(['cp', '--force', manifestPath, `oss://${bucket}/agc/latest.json`]); +runOssutil([ + 'cp', + '--force', + `${artifact}.sig`, + `oss://${bucket}/${artifactKey}.sig`, +]); +runOssutil([ + 'cp', + '--force', + manifestPath, + `oss://${bucket}/agc/${channel}/latest.json`, +]); console.log(`[ai-game-creator-shell] 已上传 oss://${bucket}/${artifactKey}`); -console.log(`[ai-game-creator-shell] 已上传 oss://${bucket}/agc/latest.json`); +console.log( + `[ai-game-creator-shell] 已上传 oss://${bucket}/agc/${channel}/latest.json`, +); +if (legacyManifestPath) { + // 迁移桥:让仍走旧 sha256 清单的已发布客户端升级到新协议,一个版本周期后删除。 + runOssutil([ + 'cp', + '--force', + legacyManifestPath, + `oss://${bucket}/agc/latest.json`, + ]); + console.log( + `[ai-game-creator-shell] 已上传迁移指针 oss://${bucket}/agc/latest.json`, + ); +} +if (dryRun) { + console.log('[ai-game-creator-shell] dry-run:未写入任何 OSS 对象'); +} diff --git a/apps/ai-game-creator-shell/scripts/start-dev-server.mjs b/apps/ai-game-creator-shell/scripts/start-dev-server.mjs index db7c8d42c..0bf51e6e6 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-server.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-server.mjs @@ -3,6 +3,7 @@ import http from 'node:http'; import net from 'node:net'; import { fileURLToPath } from 'node:url'; +import { withAgcDevFeatureFlags } from './dev-feature-flags.mjs'; import { resolveAgcDevEndpoint, withAgcDevEndpointEnv } from './dev-port.mjs'; const appRoot = fileURLToPath(new URL('..', import.meta.url)); @@ -104,7 +105,7 @@ const child = spawn( ], { cwd: appRoot, - env: withAgcDevEndpointEnv(endpoint), + env: withAgcDevFeatureFlags(withAgcDevEndpointEnv(endpoint)), stdio: 'inherit', // Node 18.20+/20+/24 on Windows rejects spawning .cmd (npm.cmd) without a shell (EINVAL). shell: true, diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 9c5c9e54d..20cf4a1ea 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -11,6 +11,7 @@ import { stopWindowsProcessTree, stopWindowsWorktreeProcesses, } from '../../../scripts/dev-windows-process.mjs'; +import { withAgcDevFeatureFlags } from './dev-feature-flags.mjs'; import { agcVitePortEnvKey, readAgcDevEndpoint, @@ -978,7 +979,10 @@ async function startVite(apiTarget, endpoint = readAgcDevEndpoint()) { '--port', String(endpoint.port), ], - { cwd: appRoot, env: withAgcDevEndpointEnv(endpoint) }, + { + cwd: appRoot, + env: withAgcDevFeatureFlags(withAgcDevEndpointEnv(endpoint)), + }, ); } diff --git a/apps/ai-game-creator-shell/scripts/vite-watch.test.mjs b/apps/ai-game-creator-shell/scripts/vite-watch.test.mjs new file mode 100644 index 000000000..42fa9a5d4 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/vite-watch.test.mjs @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { setTimeout as delay } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; + +import { createServer, loadConfigFromFile, normalizePath } from 'vite'; + +test( + 'AGC 排除 Rust 构建目录且保留源码与共享组件监听', + { timeout: 30_000 }, + async () => { + const loaded = await loadConfigFromFile( + { command: 'serve', mode: 'development' }, + fileURLToPath(new URL('../vite.config.ts', import.meta.url)), + ); + assert.ok(loaded); + assert.notEqual(loaded.config.server?.watch, null); + assert.notEqual(loaded.config.server?.hmr, false); + assert.ok( + [loaded.config.server?.watch?.ignored] + .flat() + .includes('**/src-tauri/target/**'), + ); + + const fixture = await mkdtemp(join(tmpdir(), 'agc-vite-watch-')); + const root = join(fixture, 'apps', 'ai-game-creator-shell'); + const source = join(root, 'src', 'main.js'); + const css = join(root, 'src', 'styles.css'); + const shared = join(fixture, 'packages', 'shared', 'src', 'component.js'); + const target = join(root, 'src-tauri', 'target'); + const artifact = join(target, 'debug', 'incremental', 'cache.bin'); + let server; + try { + for (const file of [source, css, shared, artifact]) { + await mkdir(dirname(file), { recursive: true }); + await writeFile( + file, + file === css ? 'body { color: red; }' : 'export default 1;', + ); + } + // 使用真实 Vite watcher 和实际配置,仅将扫描根替换为小型夹具; + // 不加载业务插件、后端或原生窗口,也不扫描开发机上的大型 target。 + server = await createServer({ + configFile: false, + envFile: false, + root, + logLevel: 'silent', + server: { + watch: loaded.config.server?.watch, + middlewareMode: true, + hmr: false, + fs: { allow: [fixture] }, + }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + const waitForWatchedFile = async (file) => { + const normalized = normalizePath(file); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ( + Object.entries(server.watcher.getWatched()).some( + ([directory, names]) => + names.some( + (name) => normalizePath(join(directory, name)) === normalized, + ), + ) + ) + return; + await delay(50); + } + assert.fail(`源码必须仍被监听:${normalized}`); + }; + await waitForWatchedFile(source); + + // 真实模块转换应将 root 外的共享源码加入监听。 + await server.transformRequest(`/@fs/${normalizePath(shared)}`); + for (const file of [source, css, shared]) { + const normalized = normalizePath(file); + await waitForWatchedFile(file); + const changed = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + server.watcher.off('change', onChange); + reject(new Error(`未收到源码变更:${normalized}`)); + }, 5_000); + function onChange(path) { + if (normalizePath(path) !== normalized) return; + clearTimeout(timer); + server.watcher.off('change', onChange); + resolve(); + } + server.watcher.on('change', onChange); + }); + await writeFile( + file, + file === css ? 'body { color: blue; }' : 'export default 2;', + ); + await changed; + } + const targetPath = normalizePath(target); + const targetDirectories = Object.keys(server.watcher.getWatched()) + .map(normalizePath) + .filter( + (path) => path === targetPath || path.startsWith(`${targetPath}/`), + ); + assert.deepEqual(targetDirectories, [], 'Rust target 不应创建目录监听器'); + } finally { + await server?.close(); + await rm(fixture, { recursive: true, force: true }); + } + }, +); diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 06994d8c6..bc1227832 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -814,6 +814,16 @@ dependencies = [ "url", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -837,7 +847,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.13.0", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types 0.5.0", "libc", @@ -850,7 +860,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.0", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -1405,6 +1415,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1725,7 +1745,7 @@ dependencies = [ [[package]] name = "genarrative-ai-game-creator-shell" -version = "0.1.45" +version = "0.1.67" dependencies = [ "agent-runtime-core", "axum", @@ -1747,7 +1767,6 @@ dependencies = [ "oxc_parser", "oxc_semantic", "oxc_span", - "percent-encoding", "platform-agent", "platform-llm", "portable-pty", @@ -1766,6 +1785,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-http", "tauri-plugin-opener", + "tauri-plugin-updater", "tempfile", "tokio", "toml 0.8.2", @@ -1776,7 +1796,7 @@ dependencies = [ "url", "uuid", "windows-sys 0.61.2", - "zip", + "zip 2.4.2", ] [[package]] @@ -2220,9 +2240,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2506,6 +2528,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.118", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2819,6 +2871,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3234,6 +3292,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -3378,6 +3448,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "outref" version = "0.5.2" @@ -4360,15 +4444,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -4482,6 +4571,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -4609,7 +4725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.13.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4952,6 +5068,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "similar" version = "2.7.0" @@ -5175,6 +5307,27 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -5196,7 +5349,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.13.0", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -5206,7 +5359,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -5239,6 +5392,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -5262,7 +5426,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -5477,6 +5641,39 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.4", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip 4.6.1", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -5487,7 +5684,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -5510,7 +5707,7 @@ checksum = "fe41e015bf8fc4d6477ff4926a0ef769dc64ff34c7b0038b6f7cacae892acb5c" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -6586,6 +6783,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -7192,7 +7398,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -7256,6 +7462,16 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" @@ -7437,6 +7653,18 @@ dependencies = [ "zopfli", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 6c3d01ec0..5c3e8eb64 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -1,11 +1,14 @@ [package] name = "genarrative-ai-game-creator-shell" -version = "0.1.45" +version = "0.1.67" edition = "2021" publish = false [features] default = [] +# 模板库假数据注入(仅本地页面压测/演示用):只有显式开启该 feature 才会编译并在读取清单后 +# 把条目循环补齐成假数据;计数由 AGC_TEMPLATE_LIBRARY_SYNTHETIC_COUNT 控制(默认 1000)。 +template-library-fixtures = [] cocos-editor = ["cocos-editor-bridge/process-discovery"] cocos-editor-execute = ["cocos-editor", "cocos-editor-bridge/windows-bootstrap"] cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-injection"] @@ -30,7 +33,11 @@ chromiumoxide = "0.9.1" futures = "0.3" getrandom = "0.3" http = "1" -image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } +# `tga` / `tiff` / `hdr` 只服务资源画布的只读预览:引擎图像容器(Cocos 的 +# .tga/.tif/.hdr 等)在浏览器里没有解码器,必须先在原生侧转码成 PNG 再送给前端。 +# 刻意不开 `exr`:它要求 `exr ^1.74.0`,当前依赖源只能到 1.73,打不开就先让 +# `.exr` 走「类型卡」而不是留一半解不出来的预览分支。 +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "tga", "tiff", "hdr"] } jsonschema = { version = "0.49.3", default-features = false } oxc_allocator = "0.143.0" oxc_ast = "0.143.0" @@ -47,7 +54,6 @@ similar = "2.7" platform-llm = { path = "../../../server-rs/crates/platform-llm" } platform-agent = { path = "../../../server-rs/crates/platform-agent" } portable-pty = "0.9" -percent-encoding = "2" reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] } regex = "1" shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false } @@ -55,6 +61,7 @@ tauri = { version = "2.11.2", features = [] } tauri-plugin-dialog = "2.7.1" tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] } tauri-plugin-opener = "2" +tauri-plugin-updater = "2.11.0" tempfile = "3" toml = "0.8" ttf-parser = "0.25.1" diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index e9f6642e0..6bca6d67a 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -1,31 +1,18 @@ +#[path = "build_support/codex_bundle.rs"] +mod codex_bundle; #[path = "build_support/frontend_dist_guard.rs"] mod frontend_dist_guard; #[path = "build_support/runtime_prompt_bundle.rs"] mod runtime_prompt_bundle; -#[cfg(windows)] use sha2::{Digest, Sha256}; use std::collections::BTreeSet; use std::env; use std::fs; use std::path::PathBuf; -#[cfg(windows)] use std::io::{BufReader, Read}; -const BUNDLED_CODEX_CLI_VERSION: &str = "codex-cli 0.147.0"; - -#[cfg(windows)] -const BUNDLED_CODEX_FILES: [&str; 6] = [ - "bin/codex.exe", - "bin/codex-code-mode-host.exe", - "codex-path/rg.exe", - "codex-resources/codex-command-runner.exe", - "codex-resources/codex-windows-sandbox-setup.exe", - "codex-package.json", -]; - -#[cfg(windows)] fn sha256_file(path: &std::path::Path) -> Result { let file = fs::File::open(path)?; let mut reader = BufReader::new(file); @@ -42,7 +29,15 @@ fn sha256_file(path: &std::path::Path) -> Result { } fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { - #[cfg(windows)] + let target = env::var("TARGET").expect("Cargo TARGET"); + println!("cargo:rustc-env=AGC_BUILD_TARGET={target}"); + let Some(layout) = codex_bundle::for_target(&target) else { + assert!( + !target.contains("windows") && !target.contains("apple-darwin"), + "不支持的 Codex 随包目标:{target}" + ); + return; + }; { let app_root = manifest_dir .parent() @@ -51,24 +46,23 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { .parent() .and_then(|apps_dir| apps_dir.parent()) .expect("AI 游戏创作应用必须位于仓库 apps 目录下"); - let source_candidates = [ - app_root.join( - "node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc", - ), - app_root.join( - "node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc", - ), - repo_root.join( - "node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc", - ), - repo_root.join( - "node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc", - ), - ]; + let package = layout.npm_package; + let source_candidates = [app_root, repo_root] + .into_iter() + .flat_map(|root| { + [ + root.join(format!("node_modules/@openai/{package}/vendor/{target}")), + root.join(format!( + "node_modules/@openai/codex/node_modules/@openai/{package}/vendor/{target}" + )), + ] + }) + .collect::>(); let source = source_candidates .iter() .find(|path| { - BUNDLED_CODEX_FILES + layout + .files .iter() .all(|relative| path.join(relative).is_file()) }) @@ -83,14 +77,26 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { .join(";") ) }); - let target_dir = manifest_dir.join("resources/codex/win-x64"); + let metadata: serde_json::Value = serde_json::from_slice( + &fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"), + ) + .expect("Codex 原生包元数据无效"); + codex_bundle::validate_package_metadata(&metadata, &target, layout) + .unwrap_or_else(|error| panic!("{error}")); + let target_dir = manifest_dir.join("resources/codex").join(layout.directory); let notice = target_dir.join("NOTICE.md"); + if target.contains("apple-darwin") { + let source_notice = + manifest_dir.join("resources/codex/【声明】Mac内置Codex组件-2026-09-18.md"); + stage_plugin_file(&source_notice, ¬ice); + println!("cargo:rerun-if-changed={}", source_notice.display()); + } if !notice.is_file() { panic!("内置 Codex CLI 第三方声明缺失:{}", notice.display()); } fs::create_dir_all(&target_dir).expect("创建内置 Codex CLI 资源目录失败"); let mut file_hashes = serde_json::Map::new(); - for relative in BUNDLED_CODEX_FILES { + for relative in layout.files { let source_path = source.join(relative); let target_path = target_dir.join(relative); if let Some(parent) = target_path.parent() { @@ -104,15 +110,23 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { if !target_matches_source { fs::copy(&source_path, &target_path).expect("复制内置 Codex CLI 资源失败"); } + // 内容相同但曾被错误 chmod 的 staging 文件也必须恢复执行权限。 + fs::set_permissions( + &target_path, + fs::metadata(&source_path) + .expect("读取组件权限失败") + .permissions(), + ) + .expect("保留内置 Codex CLI 组件权限失败"); file_hashes.insert( relative.to_string(), serde_json::Value::String(source_sha256), ); } let manifest = serde_json::json!({ - "schemaVersion": "genarrative-codex-sidecar.v2", - "platform": "win32-x64", - "version": BUNDLED_CODEX_CLI_VERSION, + "schemaVersion": codex_bundle::SCHEMA, + "platform": layout.platform, + "version": codex_bundle::CLI_VERSION, "files": file_hashes, }); let manifest_path = target_dir.join("manifest.json"); @@ -126,7 +140,7 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { { fs::write(&manifest_path, manifest_payload).expect("写入内置 Codex CLI 清单失败"); } - for relative in BUNDLED_CODEX_FILES { + for relative in layout.files { println!("cargo:rerun-if-changed={}", source.join(relative).display()); } println!("cargo:rerun-if-changed={}", notice.display()); @@ -256,8 +270,11 @@ fn stage_cocos_editor_payload(_manifest_dir: &std::path::Path) {} /// /// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、 /// Cargo target 目录或 node_modules。 -#[cfg(windows)] fn stage_plugin_workspace(manifest_dir: &std::path::Path) { + let target = env::var("TARGET").expect("Cargo TARGET"); + if !target.contains("windows") && !target.contains("apple-darwin") { + return; + } let repo_root = manifest_dir .parent() .and_then(|app_root| app_root.parent()) @@ -266,6 +283,10 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) { .to_path_buf(); let workspace = repo_root.join("plugins"); let destination_root = manifest_dir.join("resources/plugins"); + // staging 是专用生成目录;重建清除跨目标 payload 与已删除插件的残留。 + if destination_root.exists() { + std::fs::remove_dir_all(&destination_root).expect("清理插件 staging 失败"); + } std::fs::create_dir_all(&destination_root).expect("创建插件资源目录失败"); let entries = match std::fs::read_dir(&workspace) { Ok(entries) => entries, @@ -273,6 +294,13 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) { }; for entry in entries.flatten() { let plugin_root = entry.path(); + assert!( + !entry + .file_type() + .expect("读取插件目录类型失败") + .is_symlink(), + "插件工作区不允许符号链接" + ); if !plugin_root.is_dir() || !plugin_root.join("plugin.json").is_file() { continue; } @@ -287,17 +315,18 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) { std::path::PathBuf::from("panels"), std::path::PathBuf::from("native/payload"), ] { + if relative == std::path::Path::new("native/payload") && !target.contains("windows") { + continue; + } copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative)); } println!("cargo:rerun-if-changed={}", plugin_root.display()); } } -#[cfg(windows)] fn stage_plugin_file(source: &std::path::Path, destination: &std::path::Path) { - let Ok(bytes) = std::fs::read(source) else { - return; - }; + let bytes = std::fs::read(source) + .unwrap_or_else(|error| panic!("读取随包资源失败 {}:{error}", source.display())); if std::fs::read(destination).is_ok_and(|existing| existing == bytes) { return; } @@ -307,7 +336,6 @@ fn stage_plugin_file(source: &std::path::Path, destination: &std::path::Path) { std::fs::write(destination, bytes).expect("复制插件资源失败"); } -#[cfg(windows)] fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) { let entries = match std::fs::read_dir(source) { Ok(entries) => entries, @@ -316,10 +344,17 @@ fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) { for entry in entries.flatten() { let target = destination.join(entry.file_name()); let path = entry.path(); + assert!( + !entry + .file_type() + .expect("读取插件文件类型失败") + .is_symlink(), + "插件资源不允许符号链接" + ); if path.is_dir() { let name = entry.file_name(); let name = name.to_string_lossy(); - if matches!(name.as_ref(), "target" | "node_modules" | ".git") { + if name.starts_with('.') || matches!(name.as_ref(), "target" | "node_modules") { continue; } std::fs::create_dir_all(&target).expect("创建插件资源目录失败"); @@ -331,12 +366,14 @@ fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) { if name.contains(".test.") { continue; } + if name.starts_with('.') { + continue; + } stage_plugin_file(&path, &target); } } } -#[cfg(windows)] fn copy_plugin_file(source: &std::path::Path, destination: &std::path::Path) { if !source.is_file() { return; @@ -345,6 +382,3 @@ fn copy_plugin_file(source: &std::path::Path, destination: &std::path::Path) { .expect("创建插件资源目录失败"); std::fs::copy(source, destination).expect("复制插件资源失败"); } - -#[cfg(not(windows))] -fn stage_plugin_workspace(_manifest_dir: &std::path::Path) {} diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs b/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs new file mode 100644 index 000000000..1811f6a25 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs @@ -0,0 +1,133 @@ +//! 构建与运行共用的平台布局;只允许分发锁定原生包里的明确组件。 + +pub const VERSION: &str = "0.147.0"; +pub const CLI_VERSION: &str = "codex-cli 0.147.0"; +pub const SCHEMA: &str = "genarrative-codex-sidecar.v2"; + +#[derive(Clone, Copy, Debug)] +pub struct Layout { + pub platform: &'static str, + pub npm_package: &'static str, + pub directory: &'static str, + pub executable: &'static str, + pub files: &'static [&'static str], +} + +const WINDOWS_FILES: &[&str] = &[ + "bin/codex.exe", + "bin/codex-code-mode-host.exe", + "codex-path/rg.exe", + "codex-resources/codex-command-runner.exe", + "codex-resources/codex-windows-sandbox-setup.exe", + "codex-package.json", +]; +const MAC_FILES: &[&str] = &[ + "bin/codex", + "bin/codex-code-mode-host", + "codex-path/rg", + "codex-resources/zsh/bin/zsh", + "codex-package.json", +]; + +pub fn for_target(target: &str) -> Option { + match target { + "x86_64-pc-windows-msvc" => Some(Layout { + platform: "win32-x64", + npm_package: "codex-win32-x64", + directory: "win-x64", + executable: "bin/codex.exe", + files: WINDOWS_FILES, + }), + "aarch64-apple-darwin" | "x86_64-apple-darwin" => Some(Layout { + platform: if target.starts_with("aarch64") { + "darwin-arm64" + } else { + "darwin-x64" + }, + npm_package: if target.starts_with("aarch64") { + "codex-darwin-arm64" + } else { + "codex-darwin-x64" + }, + directory: "mac-native", + executable: "bin/codex", + files: MAC_FILES, + }), + _ => None, + } +} + +pub fn validate_package_metadata( + metadata: &serde_json::Value, + target: &str, + layout: Layout, +) -> Result<(), String> { + if metadata["layoutVersion"] == 1 + && metadata["version"] == VERSION + && metadata["target"] == target + && metadata["entrypoint"] == layout.executable + && metadata["resourcesDir"] == "codex-resources" + && metadata["pathDir"] == "codex-path" + { + Ok(()) + } else { + Err(format!("Codex 原生包版本、布局或架构不匹配目标 {target}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn platform_layouts_are_explicit_and_preserve_upstream_components() { + let mac = for_target("aarch64-apple-darwin").unwrap(); + assert_eq!(mac.platform, "darwin-arm64"); + assert_eq!(mac.npm_package, "codex-darwin-arm64"); + assert!(mac.files.contains(&"codex-resources/zsh/bin/zsh")); + assert!(mac.files.contains(&"bin/codex-code-mode-host")); + assert!(!mac.files.iter().any(|file| file.ends_with(".exe"))); + let intel = for_target("x86_64-apple-darwin").unwrap(); + assert_eq!(intel.platform, "darwin-x64"); + assert_eq!(intel.npm_package, "codex-darwin-x64"); + let windows = for_target("x86_64-pc-windows-msvc").unwrap(); + assert_eq!(windows.directory, "win-x64"); + assert_eq!(windows.files.len(), 6); + assert!(windows + .files + .contains(&"codex-resources/codex-windows-sandbox-setup.exe")); + assert!(for_target("universal-apple-darwin").is_none()); + assert!(for_target("aarch64-pc-windows-msvc").is_none()); + assert!(for_target("x86_64-unknown-linux-gnu").is_none()); + } + + #[test] + fn metadata_rejects_version_architecture_and_layout_drift() { + let target = "aarch64-apple-darwin"; + let layout = for_target(target).unwrap(); + let valid = serde_json::json!({ + "layoutVersion": 1, + "version": VERSION, + "target": target, + "entrypoint": "bin/codex", + "resourcesDir": "codex-resources", + "pathDir": "codex-path", + }); + assert!(validate_package_metadata(&valid, target, layout).is_ok()); + for (key, value) in [ + ("layoutVersion", serde_json::json!(2)), + ("version", serde_json::json!("0.0.0")), + ("target", serde_json::json!("x86_64-apple-darwin")), + ("entrypoint", serde_json::json!("bin/codex.exe")), + ("resourcesDir", serde_json::json!("../private")), + ("pathDir", serde_json::json!(null)), + ] { + let mut invalid = valid.clone(); + invalid[key] = value; + assert!( + validate_package_metadata(&invalid, target, layout).is_err(), + "{key}" + ); + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json index b48ec8292..7f45abda1 100644 --- a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json +++ b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json @@ -15,13 +15,13 @@ "allow": [ { "url": "https://dev.genarrative.world/api/*" }, { "url": "https://www.genarrative.world/api/*" }, - { "url": "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/*" }, { "url": "https://*/api/*" }, { "url": "http://localhost:*/*" }, { "url": "http://127.0.0.1:*/*" } ] }, "opener:default", + "updater:default", "dialog:allow-open", "dialog:allow-save" ] diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json b/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json index c9c1bd6cf..ce086275f 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json @@ -2,7 +2,7 @@ {"type":"function","function":{"name":"get_workflow_status","description":"读取当前策划工作流状态,只返回阶段列表、当前阶段、已批准阶段和待审批阶段;不推进阶段、不提交审批、不修改文件。","parameters":{"type":"object","properties":{},"additionalProperties":false}}}, {"type":"function","function":{"name":"list_resources","description":"列出固定资源的逻辑目录、资源 ID、标题和简介。资源是只读的随包文档;不要猜测物理路径。","parameters":{"type":"object","properties":{},"additionalProperties":false}}}, {"type":"function","function":{"name":"read_resource","description":"读取一份固定资源文档全文。每次读取一个 resource_id;资源只读。读到未实现占位文档时由你自行判断和处理。","parameters":{"type":"object","properties":{"resource_id":{"type":"string"}},"required":["resource_id"],"additionalProperties":false}}}, - {"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一,匹配失败、重复或范围重叠时不修改文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}}, + {"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一。所有 edit 会一次性校验;任何失败都不修改文件,错误会列出各失败项及可唯一匹配的其余项。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"delete_path","description":"谨慎使用;永久删除工作区内的文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。path 使用相对路径,不能删除工作区根目录,也不能经过链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"list_dir","description":"列出工作目录内的文件和目录。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"read_file","description":"读取工作目录内的 UTF-8 文本文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md index 07cbe9c1b..5614ef9d4 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/SKILL.md @@ -10,11 +10,11 @@ Let the client derive projections from real disk changes and trusted tool result ## Workflow 1. Write executable source to `index.html`, `style.css`, and `game.js` in the current cwd. Use only relative paths returned by approved tools for media. -2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`; only entries with `assetImportable=true` (recognized image, font, audio, video, document, or code files) may be passed to `agc_import_account_assets.localPaths`. Then re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId. +2. Before using or deriving an existing registered asset, call `agc_list_registered_assets` and select its `localAssetId`. If the user points to an existing project file that is not listed, first call `agc_list_project_files`; only entries with `assetImportable=true` (recognized image, font, audio, video, document, code, or engine asset such as a Cocos `.glb`/`.prefab`/`.anim`/`.mtl`/`.plist`/`.texture`) may be passed to `agc_import_account_assets.localPaths`. Then re-read `agc_list_registered_assets`; never infer a source identity from a filename or fabricate a localAssetId. 3. Keep read scopes separate: `asset.list` is the current project manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is the authoritative canvas list. The account library is not the complete canvas list. 4. Use `canvas.asset_import` for safe account/canvas asset IDs or project-relative local paths. The client rechecks ownership and validates bytes; host absolute paths require native UI file-picker authorization. -5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. -6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and an output name. The client requires the signed-in account, owns canvas/folder context and task identity, and returns only bounded queue state. +5. When the user explicitly asks to create or derive video, character animation, sound effect, or background music, call `agc_create_or_derive_resource`. Use `create` only for video/audio without a source and `derive` with a registered `sourceLocalAssetId`; character animation is always derived from an image. Keep `prompt` inside the per-kind limit that the client really enforces: background music at most 140 characters, sound effect at most 1900, video and character animation at most 4000. A longer prompt is rejected before submission, so write the short version first instead of retrying the same text. +6. When the user explicitly asks to remove an image background, call `agc_remove_background` with a registered image `sourceLocalAssetId` and `assetName`. Optional `backgroundMode` is `complex` (semantic foreground segmentation; default) or `flat` (solid-colour background removal). Prefer `flat` when the background is known to be solid. Only `flat` accepts optional `screenColor`: `auto`, `#RRGGBB`, or omitted for automatic detection by the service. Do not select a colour on behalf of `auto`. The client requires the signed-in account, owns canvas/folder context and task identity, waits for the accepted operation, downloads and registers the completed local asset, and preserves the operation for recovery when the remote result is not yet known. 7. Preserve existing relative paths when a small edit is sufficient so client resource identities remain stable. 8. Do not edit `.agent/manifest.json`, revision counters, version records, resource IDs, canvas identities, source provenance, generation ledgers, or browser receipts by hand. 9. Do not create a version when no game file changed. The client compares content fingerprints and advances revision only after an actual source change. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md index 15006410b..7374d1149 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-client-projection/references/projection-contract.md @@ -8,10 +8,14 @@ The client projects three distinct facts: Do not collapse these facts. A playable file can exist before projection refresh, a registered image can exist without being used by the game, and browser success does not create platform provenance. -`agc_list_project_files` is the bounded Direct discovery path for real project files. It may report an unregistered project-relative path with size/MIME metadata, but that observation is not a resource identity and carries no provenance. Its `assetImportable` field is true for the file types accepted by the current local registration contract: PNG/JPEG/WEBP/GIF/SVG/AVIF/BMP images, TTF/OTF/WOFF fonts, MP3/WAV/OGG/FLAC/M4A/AAC/OPUS audio, MP4/WEBM/MOV video, recognized text documents, and recognized source-code files. `agc_import_account_assets.localPaths` is the controlled bridge that validates and registers an importable project-local resource. `agc_list_registered_assets` remains the authoritative Direct read path for manifest resource identity; only its stable identifiers may be passed to generation/derivation tools. +`agc_list_project_files` is the bounded Direct discovery path for real project files. It may report an unregistered project-relative path with size/MIME metadata, but that observation is not a resource identity and carries no provenance. Its `assetImportable` field is true for the file types accepted by the current local registration contract: PNG/JPEG/WEBP/GIF/SVG/AVIF/BMP/TGA/TIFF/HDR images, TTF/OTF/WOFF fonts, MP3/WAV/OGG/FLAC/M4A/AAC/OPUS/PCM audio, MP4/WEBM/MOV video, recognized text documents, recognized source-code files, and engine (Cocos Creator) assets such as `.glb`/`.gltf`/`.fbx`/`.mesh`/`.skeleton` models, `.anim`/`.animation`/`.animgraph`/`.animgraphvari`/`.animask` animation clips, `.scene`/`.fire`/`.prefab`/`.tmx`/`.terrain`, `.mtl`/`.material`/`.pmtl`/`.effect`/`.chunk`, `.plist`/`.labelatlas`/`.atlas`/`.fnt`/`.pac`, and engine containers such as `.texture`/`.cubemap`/`.rt`/`.dbbin`/`.bin`/`.skel`/`.psd`/`.znt`/`.exr`. `asset.list`/`kind` filtering classifies models as `model` and undecodable engine containers as `binary`; both are discovery categories, not manifest kinds. Registered engine assets are read-only previews on the resource canvas — models render a thumbnail, serialized assets show a structure summary, and containers that the client cannot decode show a type card. `agc_import_account_assets.localPaths` is the controlled bridge that validates and registers an importable project-local resource. `agc_list_registered_assets` remains the authoritative Direct read path for manifest resource identity; only its stable identifiers may be passed to generation/derivation tools. Read scopes remain separate: `asset.list` is the current project's local manifest, `asset.library.list` is the signed-in account library, and the web project's canvas resource read model is authoritative for resources visible on that canvas. A library result must not be presented as the complete canvas list. `canvas.asset_import` accepts safe account/canvas asset IDs or project-relative local paths; receipts expose only bounded counts, safe IDs, relative paths, sources, redacted failures, and `revisionAdvanceCount`. `agc_create_or_derive_resource` accepts only semantic intent. The client resolves `sourceLocalAssetId`, creates stable request identities, recovers matching pending operations, serializes paid submissions, writes supported media into the current canvas and same-name asset folder, validates downloaded bytes, commits the local manifest transaction, and returns redacted warnings. A tool error or timeout is not permission to generate again with a new identity. -`agc_remove_background` is the semantic image post-processing path. It accepts only a registered image `sourceLocalAssetId` and output name; the client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated External v1 `/api/external/v1/editor/images/background-removals` call. Its result is bounded queue state; Codex must not poll internal workers, construct source URLs, or retry with a new identity after an uncertain response. +`prompt` limits are per kind and are enforced before any paid submission: background music accepts 1-140 characters, sound effect 1-1900, video and character animation 1-4000, and image editing (`agc_edit_image`) 1-32000. The client composes the submitted request from a fixed prefix plus your prompt, so an over-limit prompt fails locally with the exact limit; shorten the text rather than resubmitting the same value. `agc_edit_image` remains the image path; this tool never generates or edits still images. + +`agc_remove_background` accepts a registered image `sourceLocalAssetId`, `assetName`, and optional `backgroundMode` and `screenColor`. `complex` uses semantic segmentation to identify the foreground; `flat` removes a solid-colour background. Prefer `flat` when the background is known to be solid; omitting the mode selects `complex`. Only `flat` accepts a colour: `auto`, `#RRGGBB`, or omitted for automatic service detection. Never infer a concrete colour for `auto`. Empty or invalid values and colour without `flat` are rejected. The client resolves the formal source resource, canvas/folder context, stable operation identity, idempotency key, and authenticated request. Ordinary account mode maps the External v1 shaped route to `/api/editor/images/background-removals`; ExternalDeveloper mode uses `/api/external/v1/editor/images/background-removals`. Mode and colour are part of request identity. After acceptance, the client polls the authenticated generation status route, downloads the completed media, and commits it to the local manifest. If completion is unknown, it retains the same local operation for recovery; it never retries with a new identity or exposes internal worker details. + +After an interrupted call, inspect `agc_list_registered_assets.pendingOperations`. Calling `agc_remove_background` again with the same source, name, mode, and colour resumes the matching pending operation. A submission marked `reconciliation-required` needs client-side reconciliation and cannot be automatically resumed. Do not change parameters to bypass a pending task. A queued receipt, fixed progress value, or absent local file does not establish that the background-removal provider is waiting in a queue; report only the observed state. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md index c9de909a6..b6e070a85 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md @@ -14,6 +14,9 @@ Implement the user's actual game request in the current project as an npm-manage 3. Build with the project's npm script before previewing. The playable entry is the package directory's `dist/index.html`; never report an unbuilt bare-module page as playable. Import assets or configure public assets so all runtime media is included in dist; preview and exports cannot read outside it. 4. Build a complete playable loop: visible objective, responsive input, meaningful state changes, success or failure feedback, and a reliable restart path where the game needs one. 5. Fit the active game scene to desktop and mobile viewports without accidental page scrollbars. Reserve deliberate safe space for HUD elements instead of covering interactive content. + - **画布居中只能由一处负责。** 使用 `Phaser.Scale.FIT` 与 `autoCenter: Phaser.Scale.CENTER_BOTH` 时,canvas 的直接父容器应使用尺寸明确的普通块布局,不再对同一 canvas 叠加 Grid/Flex 居中、`place-items: center`、自动外边距或居中 transform。Phaser 自动计算的 margin 与 CSS 居中叠加会使竖屏画面向右偏移。 + - 若决定由 CSS 居中,则显式使用 `autoCenter: Phaser.Scale.NO_CENTER`,由 CSS 独立完成定位;外围页面可以继续使用 Grid/Flex,限制只针对同一 canvas 的重复定位。 + - 出现偏移先检查游戏自身的 CSS 与 Phaser scale 配置,不添加 AGC 预览容器固定偏移补偿。修改布局后重新构建 dist,在桌面、移动及窗口 resize 后检查 canvas 相对游戏父容器居中(误差不超过 1 CSS px)、画面完整且无意外滚动条;不能仅凭 build 成功宣称布局通过。 6. Invoke `taonier-art-assets` for every new game brief that needs visual assets. First reuse suitable registered Taonier art; when the brief's required visual elements are missing or unsuitable, call the reviewed `agc_tools` generation/edit workflow in the same task. After the tool returns, wire its relative paths into the game and verify the rendered result. A game with unused generated assets or placeholder emoji/CSS where requested art should appear is not complete. Load media defensively only for genuinely optional effects, and never relabel a local placeholder as platform art. 7. Let Phaser own the render loop and input dispatch. Avoid duplicate scenes, stale event listeners, and state that survives restart unintentionally. 8. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md index 77bdf1f9c..5eeea8e90 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md @@ -7,5 +7,7 @@ - Score, steps, health, timer, or other core state updates consistently. - Restart restores all state and does not duplicate timers, animation loops, or event listeners. - Desktop and mobile layouts keep the core scene visible without accidental document scrolling. +- 画布的缩放与居中由 Phaser 或 CSS 中的一方独立负责。`FIT + CENTER_BOTH` 不与同一 canvas 父容器的 Grid/Flex 居中、自动外边距或居中 transform 叠加;使用 CSS 居中时关闭 Phaser 自动居中(`NO_CENTER`)。 +- 在构建后的实际页面检查桌面、移动和 resize:比较 canvas 与游戏父容器的中心,预期居中时水平/垂直误差不超过 1 CSS px,并检查画面没有溢出或意外滚动条。偏移先修游戏 CSS/scale 配置,不用修改 AGC 预览位置掩盖。 - HUD and overlays reserve space and do not cover essential interactive content. - Requested Taonier art is visibly integrated into the core experience when available. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index f2270d7b8..d1a51e9af 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": "agc-skill-pack.v1", - "version": "2026-08-26.16", + "version": "2026-08-26.25", "skills": [ { "name": "agc-game-production-workflow", @@ -63,7 +63,7 @@ "agents/openai.yaml", "references/platform-art-contract.md" ], - "sha256": "ff3e1645a35fc9bff1ef255aa7bdc2a9729843d68729589b6f2670c84b8130ec" + "sha256": "47ac742d9b88e5d6cd9833484ab212152578e58ae27f7add312fd1d78183385c" }, { "name": "agc-web-game-development", @@ -80,7 +80,7 @@ "agents/openai.yaml", "references/game-quality-checklist.md" ], - "sha256": "05b5cfbf7a40fd303717491f5cea84ff339a73359c9678b283fd54d2b5c45efd" + "sha256": "e122d8f3a6d986b594b95c971754d68197bf7896912fa8267d44a7aa129a57ba" }, { "name": "agc-browser-playtest", @@ -123,7 +123,7 @@ "agents/openai.yaml", "references/projection-contract.md" ], - "sha256": "96b5bf9e2ed150bbe934a888867c1bb500b214a131f8b36c4830f51ca30267b6" + "sha256": "247787975944ce8b21d7c879c39c60ec13608056cff9426ac374c9299937d475" } ] } diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md index d652b4436..2c737d022 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/SKILL.md @@ -19,9 +19,26 @@ image, UI design image, or publication material; use `agc_edit_image` for an edit of an existing registered image; use `taonier_prepare_game_art` only for the complete game-art package and its canonical slices. -When `agc_generate_image` is used with `kind="art-spritesheet"`, pass -`sliceMode="connected-components"` (the default alpha-connectivity splitter) -or `sliceMode="grid"` with `gridX` and `gridY` (1-32 each). The selected mode is carried +With `agc_generate_image`, `kind="character"` and `kind="art-spritesheet"` +generate the subject on a solid-colour background and automatically matte it +away afterwards, producing transparent-background results; write the prompt +for the subject only, never for a scene. `kind="image"` keeps the rendered +frame without extra processing. + +When `agc_generate_image` is used with `kind="art-spritesheet"`, `sliceMode` is +required and has no default, so decide it explicitly: + +- Use `sliceMode="grid"` with `gridX` and `gridY` (1-32 each) only when the user + or brief actually names equal grid cells, fixed slots, or a concrete + column/row count; those dimensions must come from that requirement. +- Use `sliceMode="connected-components"` for free-form sheets, an open number of + subjects, or a request for one sheet; constrain the subject count with + `sliceCount` instead of inventing grid dimensions. + +Never assume `2x2` or any other grid to express "four kinds of assets", never +pass `gridX`/`gridY` together with `connected-components`, and never pass +`sliceMode` for another `kind`. The client rejects a missing, contradictory, or +misapplied declaration instead of choosing for you. The selected mode is carried through the client request and returned result; do not infer it from the number of slices. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md index bf0b74481..603b8077b 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/taonier-art-assets/references/platform-art-contract.md @@ -15,7 +15,8 @@ - On timeout or uncertain delivery, reuse the recorded operation; never create a replacement request. - `postprocess-failed-source-preserved` means the complete provider source remains usable, but the requested transparent derivative is absent. - `sliceWarning` means the complete transparent sheet remains usable, but individual slices are absent. -- For direct `agc_generate_image` spritesheet requests, `sliceMode="connected-components"` selects alpha-connectivity detection and `sliceMode="grid"` uses the caller-provided `gridX` and `gridY` (1-32 each). The client preserves the selected mode and grid dimensions in the request identity and result metadata. +- For direct `agc_generate_image` spritesheet requests, `sliceMode` is required and has no default: `connected-components` selects alpha-connectivity detection, while `grid` uses the caller-provided `gridX` and `gridY` (1-32 each) and is only correct when the requirement names equal grid cells, fixed slots, or a concrete column/row count. `connected-components` must not carry `gridX`/`gridY`, and `sliceMode` must not be sent for another `kind`; the client rejects a missing, contradictory, or misapplied declaration instead of choosing a mode. The client preserves the selected mode and grid dimensions in the request identity and result metadata. +- The client-owned standard art package declares `sliceMode="connected-components"` with `sliceCount=4` because its four canonical slices are mapped to fixed usage paths: the platform must return exactly four slices or fail with an actionable `422` naming the recognized count, and the client refuses to write a usage manifest whose slice count is not exactly four. A `sliceMode` or grid-dimension echo that disagrees with the request also fails closed before local commit. - General and slice warnings can coexist. The tool returns them separately through `warnings` and `sliceWarnings`; callers must preserve every entry and must not downgrade a slice warning into a successful independent-asset claim. - `assetPaths` contains the complete package paths. `slicePaths` contains only slices that the client downloaded, validated, and registered with their platform source identities. - `resources` contains only safe registered identity fields: local asset/path/kind/media type, Canvas project/resource/asset/task IDs, and reference resource IDs. It never exposes prompts, models, provider routes, absolute paths, URLs, tokens, cookies, or API keys. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/codex/【声明】Mac内置Codex组件-2026-09-18.md b/apps/ai-game-creator-shell/src-tauri/resources/codex/【声明】Mac内置Codex组件-2026-09-18.md new file mode 100644 index 000000000..affea34fb --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/codex/【声明】Mac内置Codex组件-2026-09-18.md @@ -0,0 +1,14 @@ +# 内置 Codex CLI + +本安装包包含锁定版本 Codex CLI 0.147.0 的 macOS 原生组件。 + +Codex CLI 按 Apache License 2.0 分发,源码与许可证见 +https://github.com/openai/codex。 + +组件来自项目锁定的 `@openai/codex` 原生 npm 依赖,保留上游的 +`bin/codex`、`bin/codex-code-mode-host`、`codex-path/rg`、 +`codex-resources/zsh/bin/zsh` 和 `codex-package.json` 相对布局。 +原生依赖中的 ripgrep 与 zsh 按各自上游许可证分发: +https://github.com/BurntSushi/ripgrep 和 https://www.zsh.org/。 + +安装包不包含 API Key、登录状态、用户配置或项目数据。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index e64b321dd..ced9b0a98 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -21,6 +21,7 @@ mod direct_project_history; mod direct_project_turn_history; mod direct_runtime; mod direct_thread_manager; +mod direct_thread_wire; mod direct_tool_bridge; mod direct_tool_calls; mod direct_tools_mcp; @@ -40,7 +41,8 @@ use codex_app_server::*; pub(crate) use codex_app_server::{ cancel_direct_codex_turn_at, direct_codex_canonical_project_identity_for_commands as direct_codex_canonical_project_identity, - direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat, DirectTurnCancelView, + direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat, + direct_thread_id_for_project, DirectTurnCancelView, }; use codex_cli::*; pub(crate) use codex_cli::{ @@ -55,6 +57,7 @@ pub(crate) use direct_project_history::*; pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; pub(crate) use direct_thread_manager::*; +pub(crate) use direct_thread_wire::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tool_calls::*; pub(crate) use direct_tools_mcp::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs index 901467454..98f347d30 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs @@ -20,6 +20,29 @@ pub(crate) fn direct_codex_canonical_project_identity( )) } +/// 项目根目录在 Thread Manager 里的线程身份。 +/// +/// 订阅入口、回合事件写入和"回合被兜底释放"三处必须算出同一个字符串,否则前端会订阅到 +/// 一个永不产生事件的空线程。这个字符串**只取决于路径**:能归一就用 canonical 路径,只有 +/// 归一本身失败(路径不存在 / 不是目录 / 无法安全解析)才退回调用方给的字符串。 +/// +/// 这里刻意不读 `.agent/manifest.json`:那次读取是"项目权威身份"(连接池摘要,见 +/// `direct_codex_canonical_project_identity`)的要求,而线程 id 只是一个路径 key。把 +/// manifest 的瞬时抖动混进线程 id,会让同一项目在"订阅那一刻"与"跑回合那一刻"算出两个 +/// 字符串(例如调用方给的是符号链接路径),订阅就绑到一条永远不会有事件的空线程上。 +pub(crate) fn direct_thread_id_for_project(root: &std::path::Path) -> String { + let Ok((canonical_root, _)) = resolve_direct_codex_project_authority(root) else { + return root.to_string_lossy().into_owned(); + }; + canonical_root + .to_str() + .and_then(|value| value.strip_prefix(r"\\?\")) + .map(std::path::Path::new) + .unwrap_or(canonical_root.as_path()) + .to_string_lossy() + .into_owned() +} + pub(super) fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec { #[cfg(unix)] { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 25d1591e4..b33ebcf21 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -13,8 +13,11 @@ use uuid::Uuid; mod direct_project_history_wire; use direct_project_history_wire::build_direct_project_history_injection_params; mod direct_project_identity; -pub(crate) use direct_project_identity::direct_codex_canonical_project_identity as direct_codex_canonical_project_identity_for_commands; use direct_project_identity::*; +pub(crate) use direct_project_identity::{ + direct_codex_canonical_project_identity as direct_codex_canonical_project_identity_for_commands, + direct_thread_id_for_project, +}; const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc"; const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY"; @@ -131,7 +134,6 @@ impl CodexAppServerCredential { ) -> Option<(&'a str, &'a str)> { match self { Self::PlatformSession { .. } => None, - #[cfg(test)] Self::AppDataKey { .. } => (!llm.api_key.trim().is_empty()) .then_some((llm.base_url.trim_end_matches('/'), llm.api_key.trim())), #[cfg(test)] @@ -139,7 +141,7 @@ impl CodexAppServerCredential { .as_deref() .map(|api_key| (GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL, api_key)), #[cfg(not(test))] - Self::AppDataKey { .. } | Self::AuthBridge { .. } => None, + Self::AuthBridge { .. } => None, } } } @@ -196,6 +198,20 @@ impl CodexAppServerStderrSummary { } } +/// 派发 app-server 的收尾与中断任务。 +/// +/// 这个入口会被**没有 tokio runtime 上下文的线程**调用:`cancel_direct_codex_turn` +/// 是同步 Tauri 命令,直接跑在 IPC 回调线程(Windows 上是 WebView2 的 UI 线程); +/// [`CodexThreadLease`] 与 [`CodexTurnGuard`] 的 `Drop` 也在调用方线程上执行。 +/// `tokio::spawn` 在那样的线程上会经 `Handle::current()` panic("there is no reactor +/// running"),而 panic 跨不过 Tauri 的 IPC 回调边界,整个进程会以 `0xC0000409` +/// (FAST_FAIL_FATAL_APP_EXIT)abort——现场就是"点终止,App 闪退"(2026-09-16 的 WER +/// 记录:`genarrative-ai-game-creator-shell.exe`,异常代码 `0xc0000409`,fail-fast +/// 参数 `7`)。一律走 Tauri 的全局异步 runtime:`main` 已把深栈 runtime 装进去。 +fn spawn_codex_app_server_task(task: impl std::future::Future + Send + 'static) { + tauri::async_runtime::spawn(task); +} + struct CodexTurnStartCancellation { inner: Weak, thread_id: String, @@ -257,7 +273,7 @@ impl CodexTurnStartCancellation { }; let connection = CodexAppServerConnection { inner }; let thread_id = self.thread_id.clone(); - tokio::spawn(async move { + spawn_codex_app_server_task(async move { let _ = connection .request( "turn/interrupt", @@ -564,6 +580,17 @@ enum CodexTurnEvent { item_id: String, delta: String, }, + /// 思考正文增量:app-server `item/reasoning/summaryTextDelta` 的明文思考文本。 + /// + /// `item/reasoning/summaryTextDelta`(core `ReasoningContentDelta`)与 + /// `item/reasoning/textDelta`(core `ReasoningRawContentDelta`)都进这条通道:前者是 + /// reasoning item 的 `summary`,后者是它的 `content`,两段文本都随 `item/completed` + /// 落进 `project.jsonl`、此前也已经在完成时展示给用户。plan 文本与命令输出仍然只降级为 + /// 活动状态,不下发正文。 + ReasoningDelta { + item_id: String, + delta: String, + }, IntermediateText(String), Activity(&'static str), Item { @@ -571,7 +598,7 @@ enum CodexTurnEvent { params: serde_json::Value, }, Request { - event_type: &'static str, + kind: DirectThreadRequestKind, params: serde_json::Value, }, RawItem(serde_json::Value), @@ -741,23 +768,43 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat direct_codex_safe_activity_for_item(item_type) } -/// Project an app-server item into the small public payload carried by the -/// DirectProject event queue. Full item contents are persisted in JSONL and -/// must not be forwarded through the runtime event stream. -fn direct_thread_item_started_payload(item: &serde_json::Value) -> serde_json::Value { - serde_json::json!({ - "itemType": item - .get("type") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown"), - }) +/// 运行态事件载荷:与历史切片同形的脱敏原始条目;拿不到身份或类型就整条跳过。 +/// +/// 这里不生成工具卡片形状:标题、折叠摘要和可见性都是前端投影的职责。 +fn direct_thread_event_item( + root: &std::path::Path, + item: &serde_json::Value, +) -> Option { + direct_thread_item_from_value(root, item, direct_tool_call_now_ms()) } -fn direct_thread_item_id(item: &serde_json::Value) -> Option { - item.get("id") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - .map(str::to_string) +/// 运行态条目投影:Codex 回显的用户消息整条跳过。 +/// +/// AGC 是 DirectProject 用户消息的唯一来源——回合入口在 `turn/start` 之前就把 +/// `direct-codex:{clientTurnId}:user` 落盘,并下发同一条运行态条目。app-server 之后 +/// 回显的 `userMessage`(`item/started`)和原始 `role=user` item +/// (`rawResponseItem/completed`)只用于观察和关联:落盘侧已由 +/// `append_direct_project_history_item_at` 过滤,运行态必须用同一口径过滤,否则前端会多 +/// 渲染出两条没有历史对应的孤儿用户气泡,各自开出一个耗时 0 秒的假回合,直到重进页面才 +/// 恢复(那时读的是同一份已过滤的 `project.jsonl`)。 +fn direct_thread_visible_item( + root: &std::path::Path, + item: &serde_json::Value, +) -> Option { + if is_direct_project_codex_user_item(item) { + return None; + } + direct_thread_event_item(root, item) +} + +/// AGC 预写的 canonical 用户条目 id:`direct-codex:{clientTurnId}:user`。 +/// +/// 与 `direct_project_history::is_direct_project_codex_user_item` 的判据同一份口径(前缀 + +/// `:user` 后缀)。回合生命周期事件的 `userItemId` 只能来自这里或已落盘条目自身的 id; +/// clientTurnId 缺失时不猜身份,返回 `None` 让前端按"未知归属"处理。 +fn direct_codex_user_item_id_for_client_turn_id(client_turn_id: &str) -> Option { + let client_turn_id = client_turn_id.trim(); + (!client_turn_id.is_empty()).then(|| format!("direct-codex:{client_turn_id}:user")) } fn direct_codex_command_is_game_verification(command: &str) -> bool { @@ -974,19 +1021,21 @@ fn direct_codex_safe_activity_for_notification(method: &str) -> Option<&'static } } -fn direct_codex_request_event_type(method: &str) -> Option<&'static str> { +fn direct_codex_request_event_type(method: &str) -> Option { match method { "item/fileChange/requestApproval" | "item/commandExecution/requestApproval" - | "item/permissions/requestApproval" => Some("approval.requested"), - "item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => Some("ask.requested"), + | "item/permissions/requestApproval" => Some(DirectThreadRequestKind::ApprovalRequested), + "item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => { + Some(DirectThreadRequestKind::AskRequested) + } _ => None, } } -fn direct_codex_resolution_event_type(method: &str) -> Option<&'static str> { +fn direct_codex_resolution_event_type(method: &str) -> Option { match method { - "serverRequest/resolved" => Some("request.resolved"), + "serverRequest/resolved" => Some(DirectThreadRequestKind::RequestResolved), _ => None, } } @@ -1053,12 +1102,71 @@ fn should_emit_direct_codex_activity( true } +/// 思考正文增量事件:`item/reasoning/summaryTextDelta`(reasoning item 的 `summary`)与 +/// `item/reasoning/textDelta`(它的 `content`)都下发正文,不降级成"preparing 活动文本"。 +/// +/// 这里只是把"完成时才看到"提前为"边生成边看到":两段文本本来就随 `item/completed` +/// 落进 `project.jsonl` 并展示给用户,可见范围没有放宽;未识别的 plan 文本与命令输出 +/// 仍然只降级为活动状态。运行态读取器和 `direct_codex_notification_event` 共用这一处, +/// 避免两份实现再次分叉(分叉时就出现过"生产路径从不产生 ReasoningDelta")。 +fn direct_codex_reasoning_delta_event( + method: &str, + params: &serde_json::Value, +) -> Option { + if !matches!( + method, + "item/reasoning/summaryTextDelta" | "item/reasoning/textDelta" + ) { + return None; + } + params + .get("delta") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(|delta| CodexTurnEvent::ReasoningDelta { + item_id: params + .get("itemId") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| "direct-missing-item".to_string()), + delta: delta.to_string(), + }) +} + +/// DirectProject 的流式正文在进入 Thread Manager 前就完成脱敏;前端不得接触原始增量。 +fn direct_codex_thread_delta_event( + root: &std::path::Path, + item_id: String, + kind: DirectThreadDeltaKind, + delta: &str, +) -> DirectThreadEvent { + DirectThreadEvent::item_delta(item_id, kind, direct_thread_delta_text(root, delta)) +} + +/// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。 +/// +/// 读取器只负责"必须有 turnId 才处理"的前置条件与节流(活动 / 正文),分类不在这里之外 +/// 再做一遍——两份实现分叉过一次,结果是生产路径从不产生 `ReasoningDelta`。 +/// +/// Preparing notifications may carry private plan/reasoning text; expose only the safe +/// activity category. Other categories may retain their bounded, redacted intermediate text. fn direct_codex_notification_event( method: &str, params: &serde_json::Value, intermediate_text: Option, safe_activity: Option<&'static str>, + turn_id: &str, ) -> Option { + if let Some(event) = direct_codex_reasoning_delta_event(method, params) { + return Some(event); + } + if let Some(kind) = direct_codex_resolution_event_type(method) { + return Some(CodexTurnEvent::Request { + kind, + params: params.clone(), + }); + } let (activity, intermediate_text) = match (&intermediate_text, safe_activity) { (Some(_), Some(activity)) if activity == "preparing" => (Some(activity), None), _ => (safe_activity, intermediate_text), @@ -1073,14 +1181,19 @@ fn direct_codex_notification_event( "item/agentMessage/delta" => params .get("delta") .and_then(serde_json::Value::as_str) - .filter(|value| !value.trim().is_empty()) + .filter(|value| !value.is_empty()) .map(|delta| { let item_id = params .get("itemId") .and_then(serde_json::Value::as_str) .filter(|value| !value.is_empty()) .map(str::to_string) - .unwrap_or_else(|| "direct-missing-item".to_string()); + .unwrap_or_else(|| { + eprintln!( + "agent.direct_codex.protocol_warning event=item/agentMessage/delta missing_item_id" + ); + format!("direct-missing-item:{turn_id}") + }); CodexTurnEvent::AgentMessageDelta { item_id, delta: delta.to_string(), @@ -1096,6 +1209,13 @@ fn direct_codex_notification_event( .cloned() .unwrap_or(serde_json::Value::Null), )), + method if direct_codex_request_event_type(method).is_some() => { + Some(CodexTurnEvent::Request { + kind: direct_codex_request_event_type(method) + .expect("request kind checked above"), + params: params.clone(), + }) + } _ => Some(CodexTurnEvent::Terminal(params.clone())), } } @@ -2018,7 +2138,13 @@ impl CodexAppServerConnection { let codex_cli_version = game_creator_codex_cli_version_identity() .map_err(platform_llm::LlmError::InvalidConfig)?; let mut effective_llm = llm.clone(); - let credential = if game_creator_official_llm_route_locked() { + let credential = if llm.custom_enabled { + crate::config::validate_custom_llm_connection(llm) + .map_err(platform_llm::LlmError::InvalidConfig)?; + CodexAppServerCredential::AppDataKey { + fingerprint: format!("custom-key:{:x}", Sha256::digest(llm.api_key.as_bytes())), + } + } else if game_creator_official_llm_route_locked() { let session = current_platform_session().ok_or_else(|| { platform_llm::LlmError::InvalidConfig( "authentication-required: 请先登录陶泥儿账号".to_string(), @@ -2186,7 +2312,8 @@ impl CodexAppServerConnection { true, ), _ => ( - (workspace_mode == CodexAppServerWorkspaceMode::DirectProject) + (llm.custom_enabled + || workspace_mode == CodexAppServerWorkspaceMode::DirectProject) .then(|| credential.direct_provider_route(llm)) .flatten() .map(|(base_url, api_key)| (base_url.to_string(), api_key.to_string())), @@ -2790,6 +2917,9 @@ impl CodexAppServerConnection { .map(str::trim) .filter(|turn_id| !turn_id.is_empty()) .map(str::to_string); + // 用户消息在这一轮开始前就落盘;把它作为本回合的第一条运行态条目下发, + // 前端就能用同一个 itemId 把"本地乐观气泡"和"历史里的同一条"合成一条。 + let mut direct_persisted_user_item: Option = None; if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { let current_prompt = direct_codex_current_user_prompt(&request).trim(); if current_prompt.is_empty() { @@ -2807,12 +2937,13 @@ impl CodexAppServerConnection { None => direct_project_local_message_item( "user", current_prompt, - Some(&format!("direct-codex:{client_turn_id}:user")), + direct_codex_user_item_id_for_client_turn_id(client_turn_id).as_deref(), ) .map_err(platform_llm::LlmError::InvalidRequest)?, }; append_direct_project_user_message_at(history_root, &user_item) .map_err(platform_llm::LlmError::InvalidRequest)?; + direct_persisted_user_item = Some(user_item); } } let (thread_lease, thread_created) = self.thread_for(snapshot, &request, llm).await?; @@ -2924,20 +3055,34 @@ impl CodexAppServerConnection { } }; turn_start_guard.armed = false; - let direct_thread_id = history_root.to_string_lossy().into_owned(); + let direct_thread_id = direct_thread_id_for_project(history_root); + // 回合边界的阶段时间:Turn 上游只有**秒**级 `startedAt` / `completedAt`,秒级截断 + // 撑不起前端 0.1 秒粒度的展示,也可能让完成时刻落进该轮用户消息的同一秒、落在真实 + // 发送时间之前,被判成无效边界后整轮新回合被吞掉。因此这里只在宿主处理对应阶段时取 + // 毫秒钟(与条目侧"没有原生阶段时间就用宿主钟"同一口径),不再读上游秒字段。 + let direct_turn_started_at_ms = direct_tool_call_now_ms(); + // 本轮开口用户条目的 canonical id:只从已落盘的那条条目上读身份(`id`,工具条目才用 + // `call_id`),不在事件侧重造一份。拿不到就留空,让前端按"归属不可证明"处理。 + let direct_turn_user_item_id = direct_persisted_user_item + .as_ref() + .and_then(direct_thread_item_identity); if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { append_direct_thread_event( &direct_thread_id, - DirectThreadRawEventDraft { - event_type: "turn.started".to_string(), - turn_id: turn_id.clone(), - item_id: None, - payload: serde_json::json!({ - "threadId": thread_id, - "turnId": turn_id, - }), - }, + DirectThreadEvent::turn_started(direct_turn_started_at_ms) + .with_user_item_id(direct_turn_user_item_id.as_deref()), ); + if let Some(user_item) = direct_persisted_user_item.as_ref() { + if let Some(entry_item) = direct_thread_event_item(history_root, user_item) { + // 这里的条目时间可能是启动应答后的观测时间;前端按同一用户条目身份 + // 保留更早的真实发送时间,不用此事件时间覆盖它。 + let user_item_at = entry_item.at(); + append_direct_thread_event( + &direct_thread_id, + DirectThreadEvent::item_completed(entry_item, user_item_at), + ); + } + } } let mut receiver = self.register_turn(&turn_id).await; let mut direct_project_history = DirectProjectHistoryAccumulator::default(); @@ -2955,6 +3100,9 @@ impl CodexAppServerConnection { game_creator_codex_app_server_hard_timeout_ms(self.inner.workspace_mode, timeout_ms); let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(hard_timeout_ms); + // 记录 app-server 是否已经给出终态:`turn/completed` 只在收到被动 terminal + // 事件时下发,超时 / 传输中断 / 协议错误都走不到那里。 + let mut terminal_recorded = false; let collect = async { let mut final_text = None; let mut streamed_text = String::new(); @@ -2992,12 +3140,14 @@ impl CodexAppServerConnection { direct_project_history.observe_delta(&item_id, &delta); append_direct_thread_event( &direct_thread_id, - DirectThreadRawEventDraft { - event_type: "item.delta".to_string(), - turn_id: turn_id.clone(), - item_id: Some(item_id.clone()), - payload: serde_json::json!({ "delta": delta.clone() }), - }, + // 事件自足:增量自带 item 身份与正文类别(正文 / 思考), + // 前端 reducer 不允许靠猜 itemId 的来源决定 kind。 + direct_codex_thread_delta_event( + history_root, + item_id.clone(), + DirectThreadDeltaKind::Message, + &delta, + ), ); } streamed_text.push_str(&delta); @@ -3028,6 +3178,19 @@ impl CodexAppServerConnection { }); } } + Some(CodexTurnEvent::ReasoningDelta { item_id, delta }) => { + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + append_direct_thread_event( + &direct_thread_id, + direct_codex_thread_delta_event( + history_root, + item_id, + DirectThreadDeltaKind::Reasoning, + &delta, + ), + ); + } + } Some(CodexTurnEvent::IntermediateText(text)) => { if let Some(observer) = direct_observer.as_deref_mut() { observer(DirectCodexTurnObservation::IntermediateText(text)); @@ -3040,6 +3203,7 @@ impl CodexAppServerConnection { "rawResponseItem/completed 缺少 item".to_string(), )); } + let entry_item = direct_thread_visible_item(history_root, &item); let history_root = history_root.to_path_buf(); let history_item = item.clone(); tokio::task::spawn_blocking(move || { @@ -3053,19 +3217,20 @@ impl CodexAppServerConnection { })? .map_err(platform_llm::LlmError::InvalidRequest)?; direct_project_history.complete_item(&item); - let item_id = direct_thread_item_id(&item); - append_direct_thread_event( - &direct_thread_id, - DirectThreadRawEventDraft { - event_type: "item.completed".to_string(), - turn_id: turn_id.clone(), - item_id, - payload: serde_json::json!({}), - }, - ); + if let Some(entry_item) = entry_item { + // `rawResponseItem/completed` 不带阶段时间,宿主处理到这条 + // 通知的钟就是该阶段唯一可证明的时间。 + append_direct_thread_event( + &direct_thread_id, + DirectThreadEvent::item_completed( + entry_item, + direct_tool_call_now_ms(), + ), + ); + } } } - Some(CodexTurnEvent::Request { event_type, params }) => { + Some(CodexTurnEvent::Request { kind, params }) => { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { let request_id = params .get("requestId") @@ -3075,14 +3240,7 @@ impl CodexAppServerConnection { .map(str::to_string); append_direct_thread_event( &direct_thread_id, - DirectThreadRawEventDraft { - event_type: event_type.to_string(), - turn_id: turn_id.clone(), - item_id: None, - payload: request_id - .map(|id| serde_json::json!({ "requestId": id })) - .unwrap_or_else(|| serde_json::json!({})), - }, + DirectThreadEvent::request(kind, request_id), ); } } @@ -3193,16 +3351,24 @@ impl CodexAppServerConnection { && self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { - let item_id = direct_thread_item_id(item); - append_direct_thread_event( - &direct_thread_id, - DirectThreadRawEventDraft { - event_type: "item.started".to_string(), - turn_id: turn_id.clone(), - item_id, - payload: direct_thread_item_started_payload(item), - }, - ); + if let Some(entry_item) = + direct_thread_visible_item(history_root, item) + { + // `item/started` 的通知层带 `startedAtMs`:这是工具真正 + // 开始的阶段时间,优先于条目展示时间与宿主钟。 + append_direct_thread_event( + &direct_thread_id, + DirectThreadEvent::item_started( + entry_item, + direct_thread_item_event_at_ms( + ¶ms, + item, + false, + direct_tool_call_now_ms(), + ), + ), + ); + } } } } @@ -3242,14 +3408,20 @@ impl CodexAppServerConnection { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject && matches!(status, "completed" | "interrupted" | "failed") { + terminal_recorded = true; + // 终态时间:`durationMs` 与宿主记下的毫秒起点都可靠时才派生, + // 否则取宿主处理这条终态的钟;上游秒级 `completedAt` 一律不用。 append_direct_thread_event( &direct_thread_id, - DirectThreadRawEventDraft { - event_type: "turn.completed".to_string(), - turn_id: turn_id.clone(), - item_id: None, - payload: serde_json::json!({ "status": status }), - }, + DirectThreadEvent::turn_completed( + status.to_string(), + direct_thread_turn_completed_at_ms( + turn, + Some(direct_turn_started_at_ms), + direct_tool_call_now_ms(), + ), + ) + .with_user_item_id(direct_turn_user_item_id.as_deref()), ); } match status { @@ -3290,7 +3462,30 @@ impl CodexAppServerConnection { } } }; - let text = match collect.await { + let collect_result = collect.await; + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject + && !terminal_recorded + { + // 兜底终态:没有这条事件,前端的"最新回合是否在跑"会一直停在运行中。 + // 事件日志本身不完美(见 `ThreadState::lifecycle_anchor` 的 TODO), + // 但"这一轮有没有结束"必须有终态事件。 + append_direct_thread_event( + &direct_thread_id, + DirectThreadEvent::turn_completed( + if collect_result.is_ok() { + "completed" + } else { + "failed" + } + .to_string(), + // 这条兜底终态没有对应的 app-server 终态载荷,只能取宿主处理它的钟, + // 不能拿最后一次正文或工具更新时间当回合终点。 + direct_tool_call_now_ms(), + ) + .with_user_item_id(direct_turn_user_item_id.as_deref()), + ); + } + let text = match collect_result { Ok(text) => text, Err(error) => { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { @@ -3485,6 +3680,15 @@ enum DirectCodexTurnCancelTarget { /// 这时显式释放这条守卫并把可读原因返回给界面。释放条件见 /// [`release_stale_direct_taonier_active_invocation`] 的注释;"正在跑的是另一轮"仍然 /// 保持原拒绝语义,什么都不释放。 +/// +/// 兜底终态带 `userItemId`:身份取 `release_stale_direct_taonier_active_invocation` 返回的 +/// clientTurnId(客户端回合身份的唯一来源),与正常路径的开口条目 id 同一份 canonical 口径。 +/// 拿不到 clientTurnId 就留空——这一轮不会再有原生终态,猜一个身份会让前端把边界盖到别人身上。 +fn direct_stale_cancel_turn_completed_event(client_turn_id: &str) -> DirectThreadEvent { + DirectThreadEvent::turn_completed("aborted".to_string(), direct_tool_call_now_ms()) + .with_user_item_id(direct_codex_user_item_id_for_client_turn_id(client_turn_id).as_deref()) +} + pub(crate) fn cancel_direct_codex_turn_at( root: &Path, client_turn_id: Option<&str>, @@ -3537,6 +3741,12 @@ pub(crate) fn cancel_direct_codex_turn_at( DirectCodexTurnCancelTarget::Stale(reason) => { let released = release_stale_direct_taonier_active_invocation(root, client_turn_id, reason)?; + // 这一轮不会再有人替它发终态事件(执行进程已退出 / 从没进执行器), + // 兜底补一条,否则前端的"最新回合是否在跑"会永远停在运行中。 + append_direct_thread_event( + &direct_thread_id_for_project(root), + direct_stale_cancel_turn_completed_event(&released), + ); Ok(DirectTurnCancelView { outcome: DIRECT_TURN_CANCEL_OUTCOME_RELEASED.to_string(), message: format!( @@ -3560,7 +3770,7 @@ impl Drop for CodexThreadLease { let connection = self.connection.clone(); let key = self.key.clone(); let thread_id = self.thread_id.clone(); - tokio::spawn(async move { + spawn_codex_app_server_task(async move { let mut threads = connection.inner.threads.lock().await; if let Some(entry) = threads.get_mut(&key) { if entry.thread_id == thread_id { @@ -3587,7 +3797,7 @@ impl Drop for CodexTurnGuard { let connection = self.connection.clone(); let thread_id = self.thread_id.clone(); let turn_id = self.turn_id.clone(); - tokio::spawn(async move { + spawn_codex_app_server_task(async move { connection.inner.turns.lock().await.remove(&turn_id); connection.inner.turn_backlog.lock().await.remove(&turn_id); let _ = connection @@ -3928,12 +4138,18 @@ async fn read_game_creator_codex_app_server_stdout( let Some(turn_id) = turn_id else { continue; }; - if let Some(activity) = safe_activity { - let last_activity = last_direct_activity_by_turn - .entry(turn_id.clone()) - .or_default(); - if !should_emit_direct_codex_activity(last_activity, activity) { - continue; + // 思考正文走正文通道,不参与 `preparing` 活动的降级与节流:活动节流按类别抑制 + // 连续的 preparing,逐段思考正文会被整段吃掉——这正是生产路径此前从不产生 + // `ReasoningDelta` 的原因。正文仍沿用下面的正文节流,避免重复 chunk 反复入队。 + let is_reasoning_delta = direct_codex_reasoning_delta_event(method, ¶ms).is_some(); + if !is_reasoning_delta { + if let Some(activity) = safe_activity { + let last_activity = last_direct_activity_by_turn + .entry(turn_id.clone()) + .or_default(); + if !should_emit_direct_codex_activity(last_activity, activity) { + continue; + } } } if let Some(text) = intermediate_text.as_deref() { @@ -3944,66 +4160,14 @@ async fn read_game_creator_codex_app_server_stdout( continue; } } - let event = if let Some(event_type) = direct_codex_resolution_event_type(method) { - CodexTurnEvent::Request { event_type, params } - } else if let Some(activity) = safe_activity { - // Preparing notifications may carry private plan/reasoning text; - // expose only the safe activity category. Other categories may - // retain their bounded, redacted intermediate text below. - if activity == "preparing" { - CodexTurnEvent::Activity(activity) - } else if let Some(text) = intermediate_text { - CodexTurnEvent::IntermediateText(text) - } else { - CodexTurnEvent::Activity(activity) - } - } else if let Some(text) = intermediate_text { - CodexTurnEvent::IntermediateText(text) - } else { - match method { - "item/agentMessage/delta" => { - let Some(delta) = params - .get("delta") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - else { - continue; - }; - let item_id = params - .get("itemId") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| { - eprintln!( - "agent.direct_codex.protocol_warning event=item/agentMessage/delta missing_item_id" - ); - format!("direct-missing-item:{turn_id}") - }); - CodexTurnEvent::AgentMessageDelta { - item_id, - delta: delta.to_string(), - } - } - "item/started" | "item/completed" => CodexTurnEvent::Item { - completed: method == "item/completed", - params, - }, - "rawResponseItem/completed" => CodexTurnEvent::RawItem( - params - .get("item") - .cloned() - .unwrap_or(serde_json::Value::Null), - ), - method if direct_codex_request_event_type(method).is_some() => { - CodexTurnEvent::Request { - event_type: direct_codex_request_event_type(method) - .expect("request event type checked above"), - params, - } - } - _ => CodexTurnEvent::Terminal(params), - } + let Some(event) = direct_codex_notification_event( + method, + ¶ms, + intermediate_text, + safe_activity, + &turn_id, + ) else { + continue; }; let sender = if method == "turn/completed" { last_direct_activity_by_turn.remove(&turn_id); @@ -4483,6 +4647,19 @@ mod tests { assert!(table.select(&key, None).is_err()); } + /// 终止路径会从同步命令线程和 `Drop` 里派发 app-server 任务:那些线程没有 tokio + /// runtime 上下文。`tokio::spawn` 在那里 panic,panic 跨不过 IPC 回调边界就把整个 + /// 进程 abort(0xC0000409,"点终止就闪退")。这条用例把派发入口钉在没有 runtime + /// 上下文的线程上,回退到 `tokio::spawn` 时它会失败。 + #[test] + fn codex_app_server_task_dispatch_needs_no_tokio_runtime_context() { + let joined = std::thread::spawn(|| spawn_codex_app_server_task(async {})); + assert!( + joined.join().is_ok(), + "没有 tokio runtime 上下文的线程也必须能派发 app-server 收尾任务" + ); + } + /// 注册键:前端传的项目路径与回合注册时的路径必须归一化成同一个键(Windows 上 /// `canonicalize` 会带 `\\?\` 前缀,去掉后两边才相等)。 #[test] @@ -4509,11 +4686,29 @@ mod tests { "arguments": { "path": "game/index.html", "token": "secret" }, "result": { "content": "large output" } }); - assert_eq!(direct_thread_item_id(&item).as_deref(), Some("item-1")); + // 运行态事件必须自足:载荷是脱敏原始条目,前端不需要再按 itemId 取快照。 + let projected = direct_thread_event_item(std::path::Path::new("."), &item).expect("item"); + assert_eq!(projected.item_id(), "item-1"); + let payload = serde_json::to_value(&projected).expect("payload"); assert_eq!( - direct_thread_item_started_payload(&item), - serde_json::json!({ "itemType": "mcpToolCall" }) + payload.get("itemType").and_then(serde_json::Value::as_str), + Some("mcpToolCall") ); + assert_eq!( + payload.get("itemId").and_then(serde_json::Value::as_str), + Some("item-1") + ); + // 卡片标题 / 折叠摘要 / kind 属于前端投影:载荷里不得出现这些 UI 语义。 + assert!(payload.get("toolCall").is_none(), "{payload}"); + assert!(payload.get("title").is_none(), "{payload}"); + assert!(payload.get("summary").is_none(), "{payload}"); + assert!(payload.get("kind").is_none(), "{payload}"); + // 参数里的密钥不得随载荷下发(脱敏占位符可以保留,明文不行)。 + let arguments = payload + .get("arguments") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + assert!(!arguments.contains("\"secret\""), "{payload}"); } #[test] @@ -4705,18 +4900,50 @@ mod tests { ); } + /// 判据:思考正文走 `ReasoningDelta` 正文通道(与 ADR 一致),不再被 `preparing` + /// 活动降级吃掉;plan 文本与命令输出仍然只降级成活动类别,不带正文。 #[test] - fn direct_preparing_notifications_emit_thinking_activity_without_raw_text() { - let reasoning = serde_json::json!({ "delta": "hidden reasoning must not leak" }); - assert!(matches!( - direct_codex_notification_event( + fn direct_reasoning_deltas_stream_text_while_plan_and_command_output_stay_activity() { + let reasoning = serde_json::json!({ "itemId": "reasoning-1", "delta": "思考正文" }); + for method in [ + "item/reasoning/summaryTextDelta", + "item/reasoning/textDelta", + ] { + assert!( + matches!( + direct_codex_reasoning_delta_event(method, &reasoning), + Some(CodexTurnEvent::ReasoningDelta { item_id, delta }) + if item_id == "reasoning-1" && delta == "思考正文" + ), + "{method} 必须下发明文思考增量" + ); + // 运行态读取器用的就是这一个分类函数,不能再走「preparing 活动」降级。 + assert!( + matches!( + direct_codex_notification_event( + method, + &reasoning, + Some("思考正文".to_string()), + Some("preparing"), + "turn-1", + ), + Some(CodexTurnEvent::ReasoningDelta { .. }) + ), + "{method} 在通知分类里不能降级成活动" + ); + } + assert!( + direct_codex_reasoning_delta_event( "item/reasoning/textDelta", - &reasoning, - Some("hidden reasoning must not leak".to_string()), - Some("preparing"), - ), - Some(CodexTurnEvent::Activity("preparing")) - )); + &serde_json::json!({ "delta": "" }), + ) + .is_none(), + "空增量不产生正文事件" + ); + assert!( + direct_codex_reasoning_delta_event("turn/plan/updated", &reasoning).is_none(), + "非 reasoning 通知不进正文通道" + ); let plan = serde_json::json!({ "explanation": "private plan text must not leak" }); assert!(matches!( @@ -4725,6 +4952,7 @@ mod tests { &plan, Some("private plan text must not leak".to_string()), Some("preparing"), + "turn-1", ), Some(CodexTurnEvent::Activity("preparing")) )); @@ -4736,13 +4964,220 @@ mod tests { &command_output, None, Some("command-exec"), + "turn-1", ), Some(CodexTurnEvent::Activity("command-exec")) )); } + #[test] + fn direct_thread_message_and_reasoning_deltas_are_sanitized_before_enqueue() { + let root = std::path::Path::new("/workspace/direct-project"); + let raw = "key=sk-abcdefghijklmnop project=/workspace/direct-project/assets/a.png private=/root/secret.txt"; + for kind in [ + DirectThreadDeltaKind::Message, + DirectThreadDeltaKind::Reasoning, + ] { + let event = direct_codex_thread_delta_event(root, "item-1".to_string(), kind, raw); + let wire = serde_json::to_string(&event).expect("serialize direct thread delta"); + assert!( + !wire.contains("sk-abcdefghijklmnop"), + "不得泄漏密钥:{wire}" + ); + assert!( + !wire.contains("/root/secret.txt"), + "不得泄漏绝对路径:{wire}" + ); + assert!( + wire.contains("assets/a.png"), + "项目内绝对路径应归一成相对路径:{wire}" + ); + } + } + + /// 判据:读取器与单测共用同一个分类函数,这些分支的行为被钉在这里。 + /// + /// 参数:method / params / 正文候选 / 安全活动类别 / turnId。 + #[test] + fn direct_notification_classification_covers_the_reader_branches() { + let empty = serde_json::json!({}); + let resolved = serde_json::json!({ "requestId": "request-1" }); + assert!(matches!( + direct_codex_notification_event( + "serverRequest/resolved", + &resolved, + None, + None, + "turn-1" + ), + Some(CodexTurnEvent::Request { + kind: DirectThreadRequestKind::RequestResolved, + .. + }) + )); + let approval = serde_json::json!({ "requestId": "request-2" }); + assert!(matches!( + direct_codex_notification_event( + "item/fileChange/requestApproval", + &approval, + None, + None, + "turn-1", + ), + Some(CodexTurnEvent::Request { + kind: DirectThreadRequestKind::ApprovalRequested, + .. + }) + )); + assert!(matches!( + direct_codex_notification_event("item/started", &empty, None, None, "turn-1"), + Some(CodexTurnEvent::Item { + completed: false, + .. + }) + )); + assert!(matches!( + direct_codex_notification_event( + "rawResponseItem/completed", + &serde_json::json!({ "item": { "type": "reasoning" } }), + None, + None, + "turn-1", + ), + Some(CodexTurnEvent::RawItem(_)) + )); + // 空正文的 assistant 增量既不产事件,也不产 Terminal 兜底。 + assert!(direct_codex_notification_event( + "item/agentMessage/delta", + &serde_json::json!({ "delta": "" }), + None, + None, + "turn-1", + ) + .is_none()); + assert!(matches!( + direct_codex_notification_event( + "item/agentMessage/delta", + &serde_json::json!({ "delta": "正文" }), + None, + None, + "turn-1", + ), + Some(CodexTurnEvent::AgentMessageDelta { delta, .. }) if delta == "正文" + )); + assert!(matches!( + direct_codex_notification_event("turn/completed", &empty, None, None, "turn-1"), + Some(CodexTurnEvent::Terminal(_)) + )); + } + + /// 阶段时间取自**通知层**字段,形状照抄 codex-cli 0.147 / 0.155 的 v2 协议 schema: + /// `item/started` 带 `startedAtMs`、`item/completed` 带 `completedAtMs`(毫秒), + /// `turn/completed` 带 `turn.startedAt` / `turn.completedAt`(秒)与 `turn.durationMs`(毫秒)。 + /// 分类函数把 params 原样交给事件级 `at` 的投影函数,所以字段位置必须在这里钉住; + /// 回合边界的秒字段按"不用"锁在这里,避免以后有人再把秒级截断当 0.1 秒精度。 + #[test] + fn direct_lifecycle_stage_times_come_from_notification_params() { + let started = serde_json::json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "startedAtMs": 1_700_000_000_123u64, + "item": {"id": "call-1", "type": "commandExecution", "command": "ls"}, + }); + let completed = serde_json::json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "completedAtMs": 1_700_000_001_500u64, + "item": {"id": "call-1", "type": "commandExecution", "command": "ls"}, + }); + for (method, params, expected_at_ms) in [ + ("item/started", &started, 1_700_000_000_123u64), + ("item/completed", &completed, 1_700_000_001_500u64), + ] { + let Some(CodexTurnEvent::Item { + completed, + params: event_params, + }) = direct_codex_notification_event(method, params, None, None, "turn-1") + else { + panic!("{method} 必须分类成条目生命周期事件"); + }; + let item = event_params.get("item").expect("item payload"); + assert_eq!( + direct_thread_item_event_at_ms(&event_params, item, completed, 9_999), + expected_at_ms, + "{method} 必须用通知层的阶段时间,而不是宿主钟" + ); + } + + let terminal = serde_json::json!({ + "threadId": "thread-1", + "turn": { + "id": "turn-1", + "items": [], + "status": "completed", + "startedAt": 1_700_000_000i64, + "completedAt": 1_700_000_042i64, + }, + }); + let Some(CodexTurnEvent::Terminal(params)) = + direct_codex_notification_event("turn/completed", &terminal, None, None, "turn-1") + else { + panic!("turn/completed 必须分类成终态事件"); + }; + let turn = params.get("turn").unwrap_or(¶ms); + assert_eq!( + direct_thread_turn_completed_at_ms(turn, Some(1_700_000_000_500), 9_999), + 9_999, + "上游只有秒级 completedAt:不采用,取宿主处理终态的毫秒钟" + ); + let with_duration = serde_json::json!({ + "id": "turn-1", + "items": [], + "status": "completed", + "startedAt": 1_700_000_000i64, + "completedAt": 1_700_000_042i64, + "durationMs": 42_500u64, + }); + assert_eq!( + direct_thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999), + 1_700_000_043_000, + "durationMs + 宿主高精度起点才派生结束" + ); + } + + /// 取消兜底终态也要带开口用户条目身份,且身份只有一个来源:release 返回的 clientTurnId + /// 走与正常路径同一份 canonical 口径;拿不到(空 / 空白)就留空,不猜。 + #[test] + fn stale_cancel_terminal_event_keeps_opener_user_item_id_from_client_turn_id() { + let event = direct_stale_cancel_turn_completed_event("turn-0001"); + assert_eq!(event.user_item_id(), Some("direct-codex:turn-0001:user")); + assert!(event.at().is_some(), "兜底终态仍要带宿主观测时间"); + assert!(matches!( + event, + DirectThreadEvent::TurnCompleted { ref status, .. } if status == "aborted" + )); + + for missing in ["", " "] { + let event = direct_stale_cancel_turn_completed_event(missing); + assert_eq!( + event.user_item_id(), + None, + "拿不到 clientTurnId 时不得编造开口条目身份" + ); + } + + // canonical 口径与落盘侧同一份:`direct-codex:{clientTurnId}:user`。 + assert_eq!( + direct_codex_user_item_id_for_client_turn_id(" turn-0001 ").as_deref(), + Some("direct-codex:turn-0001:user") + ); + assert_eq!(direct_codex_user_item_id_for_client_turn_id(""), None); + } + fn test_llm() -> GameCreatorLlmConfig { GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "fixture-secret".to_string(), base_url: "https://example.invalid/v1".to_string(), model: "fixture-model".to_string(), @@ -5100,6 +5535,50 @@ mod tests { assert_ne!(first_identity, second_identity); } + /// 判据:线程 id 只由路径决定,不受 manifest 可读性影响。 + /// + /// 变异验证:线程 id 走 `direct_codex_canonical_project_identity`(旧实现)时,manifest + /// 读不到会让非 canonical 的调用方路径退回"原始字符串",订阅与回合事件因此落在两条线程上。 + #[cfg(unix)] + #[test] + fn direct_project_thread_id_ignores_manifest_readability() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("temp dir"); + let project = temp.path().join("thread-id-project"); + init_local_game_project_at(&project, "thread-id-project", "线程身份项目") + .expect("initialize project"); + let stable_link = temp.path().join("current-project"); + symlink(&project, &stable_link).expect("project symlink"); + + let canonical = direct_thread_id_for_project(&project); + assert_eq!( + canonical, + project + .canonicalize() + .expect("canonical project") + .to_string_lossy(), + "线程 id 就是 canonical 路径" + ); + assert_eq!( + direct_thread_id_for_project(&stable_link), + canonical, + "符号链接必须归一到同一个线程 id" + ); + + // manifest 暂时读不到(项目刚创建 / 正被替换)不能让线程 id 变样。 + std::fs::remove_file(project.join(".agent/manifest.json")).expect("remove manifest"); + assert!( + direct_codex_canonical_project_identity(&project).is_err(), + "前提:manifest 读不到时权威身份确实会失败" + ); + assert_eq!( + direct_thread_id_for_project(&stable_link), + canonical, + "manifest 读失败不能把线程 id 退回调用方原始字符串" + ); + } + #[test] fn direct_project_pool_identity_changes_when_manifest_identity_is_replaced_in_place() { let temp = tempfile::tempdir().expect("temp dir"); @@ -5528,6 +6007,59 @@ mod tests { assert_ne!(command_token, provider_key); } + #[tokio::test] + async fn custom_llm_broker_uses_configured_route_without_exposing_upstream_key() { + let mut llm = test_llm(); + llm.custom_enabled = true; + llm.api_key = "custom-upstream-fixture-secret".into(); + llm.base_url = "http://127.0.0.1:9/v1".into(); + llm.model = "vendor/model.v1:latest".into(); + llm.visible_models = vec![llm.model.clone()]; + let credential = CodexAppServerCredential::AppDataKey { + fingerprint: "custom-fixture".into(), + }; + let (base, key) = credential + .direct_provider_route(&llm) + .expect("custom route"); + assert_eq!(base, llm.base_url); + assert_eq!(key, llm.api_key); + let proxy = start_codex_provider_proxy(base, key, false).await.unwrap(); + for mode in [ + CodexAppServerWorkspaceMode::DirectProject, + CodexAppServerWorkspaceMode::ToolHost, + ] { + let mut command = tokio::process::Command::new("fixture"); + configure_game_creator_codex_app_server_command_for_mode( + &mut command, + &llm, + mode, + Some(&proxy), + None, + true, + ) + .unwrap(); + let arguments = command + .as_std() + .get_args() + .map(|arg| arg.to_string_lossy()) + .collect::>() + .join("\n"); + let params = codex_app_server_thread_start_params( + &llm.model, + std::path::Path::new("fixture-workspace"), + mode, + String::new(), + true, + ); + assert_eq!(params["model"], "vendor/model.v1:latest"); + assert!(!arguments.contains(&llm.api_key)); + assert!(!arguments.contains("/api/llm")); + for (_, value) in command.as_std().get_envs() { + assert!(!value.is_some_and(|value| value.to_string_lossy().contains(&llm.api_key))); + } + } + } + #[cfg(unix)] #[tokio::test] async fn direct_project_spawn_restores_broker_token_after_environment_isolation() { @@ -6210,6 +6742,249 @@ while IFS= read -r line; do :; done assert_eq!(connection.inner.threads.lock().await.len(), 1); } + /// 判据:生产读取器不再把思考正文降级成 `preparing` 活动。 + /// + /// fixture 刻意不发 `turn/started` 等先导活动,所以"活动节流"这条退路不存在: + /// 一旦读取器把 `item/reasoning/*Delta` 归回活动通道,observations 里就会出现 + /// `Activity("preparing")`。同时明文思考只走 DirectProject 正文通道, + /// 不得漏进旧的运行态 observation。 + #[cfg(unix)] + #[tokio::test] + async fn codex_app_server_streams_reasoning_deltas_without_activity_fallback() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let executable = temp.path().join("fake-codex-app-server-reasoning-delta"); + std::fs::write( + &executable, + r#"#!/bin/sh +IFS= read -r initialize +case "$initialize" in *'"method":"initialize"'*) ;; *) exit 51 ;; esac +printf '%s\n' '{"id":1,"result":{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}' +IFS= read -r initialized +case "$initialized" in *'"method":"initialized"'*) ;; *) exit 52 ;; esac +IFS= read -r thread_start +case "$thread_start" in *'"method":"thread/start"'*) ;; *) exit 53 ;; esac +printf '%s\n' '{"id":2,"result":{"thread":{"id":"thread-1"}}}' +IFS= read -r turn_start +case "$turn_start" in *'"method":"turn/start"'*) ;; *) exit 54 ;; esac +printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn-1","items":[],"status":"inProgress"}}}' +printf '%s\n' '{"method":"item/reasoning/textDelta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"reasoning-1","delta":"SECRET_REASONING_TEXT"}}' +printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"item-1","delta":"{\"toolCalls\":[]}"}}' +printf '%s\n' '{"method":"item/completed","params":{"completedAtMs":1,"threadId":"thread-1","turnId":"turn-1","item":{"id":"item-1","type":"agentMessage","text":"{\"toolCalls\":[]}"}}}' +printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thread-1","turn":{"id":"turn-1","items":[],"status":"completed"}}}' +while IFS= read -r line; do :; done +"#, + ) + .expect("write fake app-server"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions).expect("chmod fake app-server"); + + let llm = test_llm(); + let connection = + CodexAppServerConnection::spawn_with_executable(&llm, executable.as_os_str()) + .await + .expect("spawn fake app-server"); + let mut observations = Vec::new(); + let mut observer = |observation| observations.push(observation); + connection + .run_turn_with_direct_observer( + &test_snapshot(), + &llm, + tool_request(), + None, + Some(&mut observer), + None, + ) + .await + .expect("run fake app-server turn"); + drop(observer); + assert!( + !observations.iter().any(|observation| matches!( + observation, + DirectCodexTurnObservation::Activity("preparing") + )), + "思考增量必须走 ReasoningDelta 正文通道,不能降级成 preparing 活动" + ); + assert!( + !format!("{observations:?}").contains("SECRET_REASONING_TEXT"), + "明文思考不得漏进运行态 observation" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn direct_project_turn_does_not_forward_codex_user_echo_as_chat_items() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let project = temp.path().join("direct-user-echo-project"); + crate::init_local_game_project_at(&project, "direct-user-echo", "回显过滤") + .expect("init project"); + let executable = temp.path().join("fake-codex-app-server-direct-user-echo"); + std::fs::write( + &executable, + r#"#!/bin/sh +while IFS= read -r line; do + id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p') + case "$line" in + *'"method":"initialize"'*) printf '{"id":%s,"result":{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}\n' "$id" ;; + *'"method":"skills/extraRoots/set"'*) printf '{"id":%s,"result":{}}\n' "$id" ;; + *'"method":"skills/list"'*) printf '{"id":%s,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"}],"errors":[]}]}}\n' "$id" ;; + *'"method":"thread/start"'*) printf '{"id":%s,"result":{"thread":{"id":"thread-echo"}}}\n' "$id" ;; + *'"method":"thread/inject_items"'*) printf '{"id":%s,"result":{}}\n' "$id" ;; + *'"method":"turn/start"'*) + printf '{"id":%s,"result":{"turn":{"id":"turn-echo","items":[],"status":"inProgress"}}}\n' "$id" + printf '%s\n' '{"method":"turn/started","params":{"threadId":"thread-echo","turn":{"id":"turn-echo","items":[],"status":"inProgress"}}}' + printf '%s\n' '{"method":"item/started","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"codex-user-echo-1","type":"userMessage","clientId":"turn-0001","content":[{"type":"text","text":"请创建菜单"}]}}}' + printf '%s\n' '{"method":"rawResponseItem/completed","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"codex-raw-user-echo-1","type":"message","role":"user","content":[{"type":"input_text","text":"请创建菜单"}]}}}' + printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thread-echo","turnId":"turn-echo","itemId":"item-echo-1","delta":"好的"}}' + printf '%s\n' '{"method":"item/completed","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"item-echo-1","type":"agentMessage","text":"好的"}}}' + printf '%s\n' '{"method":"rawResponseItem/completed","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"item-echo-1","type":"message","role":"assistant","content":[{"type":"output_text","text":"好的"}]}}}' + printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thread-echo","turn":{"id":"turn-echo","items":[],"status":"completed"}}}' + ;; + esac +done +"#, + ) + .expect("write fake app-server"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions).expect("chmod fake app-server"); + + let llm = test_llm(); + let credential = CodexAppServerCredential::AppDataKey { + fingerprint: "fixture-credential".to_string(), + }; + let connection = + CodexAppServerConnection::spawn_with_executable_and_credential_at_workspace( + &llm, + &credential, + executable.as_os_str(), + Some(&project), + CodexAppServerWorkspaceMode::DirectProject, + ) + .await + .expect("spawn direct-project app-server"); + + let user_item = serde_json::json!({ + "type": "message", + "role": "user", + "id": "direct-codex:turn-0001:user", + "content": [{"type": "input_text", "text": "请创建菜单"}] + }); + let thread_id = direct_thread_id_for_project(&project); + let bootstrap = crate::agent::subscribe_direct_thread(&thread_id); + assert!( + bootstrap.events.is_empty(), + "订阅发生在回合之前,bootstrap 必须为空" + ); + // 生产入口(`chat_with_game_creator_direct_codex`)在发起回合前登记本客户端的 + // 付费生成身份;工具桥在这一轮里按它绑定付费调用,这里补上同一步。 + let _active_invocation = + crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001") + .expect("enter direct invocation"); + let mut observer = |_observation| {}; + connection + .run_turn_with_direct_observer_and_history( + &test_snapshot(), + &llm, + LlmRunRequest::single_turn("系统", "请创建菜单"), + Some(&project), + Some("turn-0001"), + Some(&user_item), + None, + Some(&mut observer), + None, + ) + .await + .expect("run direct-project turn"); + drop(observer); + + let consumed = crate::agent::consume_direct_thread(&bootstrap.subscription_id) + .expect("consume events"); + // 回合起止必须与开口用户条目同源:前端在「只有锚点 + 历史、运行态为空」的回合里靠这个 + // 身份把边界认领给同一条用户条目,缺了它就只能隐藏未知用时。 + let lifecycle_user_item_ids = consumed + .events + .iter() + .filter(|event| { + matches!( + event, + DirectThreadEvent::TurnStarted { .. } | DirectThreadEvent::TurnCompleted { .. } + ) + }) + .map(DirectThreadEvent::user_item_id) + .collect::>(); + assert_eq!( + lifecycle_user_item_ids, + vec![ + Some("direct-codex:turn-0001:user"), + Some("direct-codex:turn-0001:user"), + ], + "turn.started / turn.completed 都要带本轮开口用户条目的 canonical itemId" + ); + let mut user_items = Vec::new(); + let mut assistant_items = Vec::new(); + for event in &consumed.events { + let item = match event { + DirectThreadEvent::ItemStarted { item, .. } + | DirectThreadEvent::ItemCompleted { item, .. } => item, + _ => continue, + }; + match item { + DirectThreadItem::Message { + item_id, + role, + text, + .. + } if role.as_str() == "user" => { + user_items.push((item_id.clone(), text.clone())); + } + DirectThreadItem::Message { text, .. } if !text.trim().is_empty() => { + assistant_items.push(text.clone()); + } + _ => {} + } + } + assert_eq!( + user_items, + vec![( + "direct-codex:turn-0001:user".to_string(), + "请创建菜单".to_string() + )], + "Codex 回显的用户消息不得再作为聊天条目下发,否则前端会渲染出多余的孤儿用户气泡" + ); + assert_eq!( + assistant_items, + vec!["好的".to_string()], + "assistant 正文仍必须按同一 itemId 下发一次" + ); + let history = read_direct_project_history_items_at(&project).expect("read history"); + let history_user_items = history + .iter() + .filter(|item| item.get("role").and_then(serde_json::Value::as_str) == Some("user")) + .cloned() + .collect::>(); + assert!( + history + .iter() + .any(|item| item.get("id").and_then(serde_json::Value::as_str) + == Some("item-echo-1")), + "assistant 与工具条目仍必须落盘:{history:?}" + ); + assert_eq!( + history_user_items, + vec![user_item], + "回显的用户消息也不得落盘,历史里只能有 AGC 自己那条" + ); + } + #[cfg(unix)] #[tokio::test] async fn direct_home_app_server_uses_read_only_protocol_and_rejects_file_change_items() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs index 863b8cdad..79e3aedd1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs @@ -5,18 +5,10 @@ use std::process::Stdio; use sha2::{Digest, Sha256}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; +#[path = "../../build_support/codex_bundle.rs"] +mod codex_bundle; + const GAME_CREATOR_CODEX_CLI_EXECUTABLE: &str = "codex"; -const GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH: &str = "coding-agent/win-x64/bin/codex.exe"; -const GAME_CREATOR_BUNDLED_CODEX_CLI_MANIFEST_RELATIVE_PATH: &str = - "coding-agent/win-x64/manifest.json"; -const GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES: [&str; 6] = [ - "bin/codex.exe", - "bin/codex-code-mode-host.exe", - "codex-path/rg.exe", - "codex-resources/codex-command-runner.exe", - "codex-resources/codex-windows-sandbox-setup.exe", - "codex-package.json", -]; const GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024; const GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES: usize = 4 * 1024 * 1024; const GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES: usize = 256 * 1024; @@ -38,11 +30,11 @@ fn game_creator_codex_cli_executable_candidates_for( path: Option<&std::ffi::OsStr>, ) -> Vec { let mut candidates = Vec::new(); + if let Some(bundled) = game_creator_bundled_codex_cli_path(resource_dir) { + candidates.push(bundled); + } #[cfg(windows)] { - if let Some(resource_dir) = resource_dir { - candidates.push(resource_dir.join(GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH)); - } fn append_native_npm_candidates(candidates: &mut Vec, npm_root: &Path) { let vendor_root = npm_root .join("node_modules") @@ -109,52 +101,71 @@ fn game_creator_codex_cli_executable_candidates() -> Vec { } fn game_creator_bundled_resource_dir() -> Option { + let executable = std::env::current_exe().ok()?; + game_creator_bundled_resource_dir_for(&executable) +} + +fn game_creator_bundled_resource_dir_for(executable: &Path) -> Option { #[cfg(windows)] { - std::env::current_exe() - .ok() - .and_then(|path| path.parent().map(Path::to_path_buf)) + executable.parent().map(Path::to_path_buf) } - #[cfg(not(windows))] + #[cfg(target_os = "macos")] { + let macos = executable.parent()?; + let contents = macos.parent()?; + // 只接受真正的 app bundle 结构,开发态不从任意相邻目录加载程序。 + if macos.file_name()? != "MacOS" + || contents.file_name()? != "Contents" + || contents.parent()?.extension()? != "app" + { + return None; + } + Some(contents.join("Resources")) + } + #[cfg(not(any(windows, target_os = "macos")))] + { + let _ = executable; None } } fn game_creator_bundled_codex_cli_path(resource_dir: Option<&Path>) -> Option { - resource_dir.map(|resource_dir| resource_dir.join(GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH)) + let layout = codex_bundle::for_target(env!("AGC_BUILD_TARGET"))?; + Some( + resource_dir? + .join("coding-agent") + .join(layout.directory) + .join(layout.executable), + ) } fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result { + let layout = codex_bundle::for_target(env!("AGC_BUILD_TARGET")) + .ok_or_else(|| "当前平台不支持内置 Codex CLI".to_string())?; let bundle_root = executable .parent() .and_then(Path::parent) .ok_or_else(|| "内置 Codex CLI 路径无效".to_string())?; - let manifest_path = bundle_root.join( - Path::new(GAME_CREATOR_BUNDLED_CODEX_CLI_MANIFEST_RELATIVE_PATH) - .file_name() - .expect("bundled Codex manifest file name"), - ); + let manifest_path = bundle_root.join("manifest.json"); let manifest = std::fs::read_to_string(&manifest_path) .map_err(|_| "内置 Codex CLI 缺少完整性清单".to_string()) .and_then(|value| { serde_json::from_str::(&value) .map_err(|_| "内置 Codex CLI 完整性清单无效".to_string()) })?; - if manifest.schema_version != "genarrative-codex-sidecar.v2" - || manifest.platform != "win32-x64" + if manifest.schema_version != codex_bundle::SCHEMA + || manifest.platform != layout.platform || manifest.version.trim().is_empty() - || GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES - .iter() - .any(|relative| { - manifest.files.get(*relative).map_or(true, |hash| { - hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) - }) + || layout.files.iter().any(|relative| { + manifest.files.get(*relative).map_or(true, |hash| { + hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) }) + }) { return Err("内置 Codex CLI 完整性清单不受支持".to_string()); } - for relative in GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES { + for relative in layout.files { let path = bundle_root.join(relative); let bytes = std::fs::read(&path).map_err(|_| { format!( @@ -163,7 +174,7 @@ fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result; +} + +fn ensure_design_panic_hook() { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + if let Ok(Some(root)) = DESIGN_PANIC_ROOT.try_with(Clone::clone) { + let location = info + .location() + .map(|location| { + format!( + "{}:{}:{}", + location.file(), + location.line(), + location.column() + ) + }) + .unwrap_or_else(|| "未知位置".to_string()); + design_debug( + &root, + "panic", + json!({ + "location": location, + "error": info.payload_as_str().unwrap_or("未知 panic 负载"), + }), + ); + } + previous(info); + })); + }); +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde( tag = "type", @@ -421,6 +462,10 @@ fn execute_design_tool( ) -> Result { let args: Value = serde_json::from_str(&call.arguments) .map_err(|error| format!("工具参数不是有效 JSON:{error}"))?; + #[cfg(test)] + if call.name == "design_test__panic" { + panic!("注入的策划工具 panic"); + } match call.name.as_str() { "get_workflow_status" => Ok(design_workflow_status(session)), "list_resources" => resources.list().map(Value::String), @@ -527,10 +572,6 @@ fn process_design_batch( let result = if uncertain { Err("进程在工具执行期间中断,执行结果未保存。未重复执行;请读取实际工作区确认结果后再决定下一步。".to_string()) } else { - let _write = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "design.tool", - )?; execute_design_tool(root, resources, session, &call) }; let error = result @@ -975,7 +1016,20 @@ async fn finish_design_command( Some(design_view(&session, run)), )); if run { - if let Err(error) = run_design_loop(root, resources, &mut session, &mut emit).await { + // panic 边界:运行期 panic 转成普通失败,交给既有错误分支恢复(重读检查点、 + // 写 last_error、发最终 view)。否则 unwind 会杀死 command task,IPC 永不 + // 返回(前端停在工作态),会话停在无错误的 pending,用户只能看到无声的重试。 + ensure_design_panic_hook(); + let run = DESIGN_PANIC_ROOT.scope( + Some(root.to_path_buf()), + run_design_loop(root, resources, &mut session, &mut emit), + ); + let outcome = std::panic::AssertUnwindSafe(run).catch_unwind().await; + let result = match outcome { + Ok(result) => result, + Err(payload) => Err(design_panic_error(payload)), + }; + if let Err(error) = result { // 从最后一个持久检查点恢复,防止写后未记结果被误认为已完成。 session = read_design_session(root)?.ok_or("策划会话丢失")?; session.last_error = Some(redact_agent_runtime_error(root, &error, 1800)); @@ -995,6 +1049,12 @@ async fn finish_design_command( Ok(view) } +// panic 负载可能包含路径或内容片段,公开文案固定;位置和负载由 panic hook 写进私有 +// design_debug(task-local 提供项目根),不进入用户可见消息。 +fn design_panic_error(_payload: Box) -> String { + DESIGN_PANIC_PUBLIC_ERROR.to_string() +} + pub(crate) async fn continue_design_agent_at( root: &Path, resources: &DesignResources, @@ -1026,6 +1086,15 @@ pub(crate) async fn continue_design_agent_at( finish_design_command(root, resources, session, active, run, emit).await } +async fn recover_uncertain_design_batch( + root: &Path, + resources: &DesignResources, + session: DesignSession, + active: File, +) -> Result { + finish_design_command(root, resources, session, active, true, |_| {}).await +} + pub(crate) async fn decide_design_phase_at( root: &Path, resources: &DesignResources, @@ -1058,7 +1127,8 @@ fn ensure_design_runtime_active(root: &Path) -> Result<(), String> { } #[tauri::command] -pub(crate) fn hydrate_design_agent_session( +pub(crate) async fn hydrate_design_agent_session( + app: tauri::AppHandle, project_path: String, ) -> Result, String> { let root = Path::new(project_path.trim()); @@ -1084,8 +1154,33 @@ pub(crate) fn hydrate_design_agent_session( if session.project_id != project_id { return Err("策划会话与当前项目不匹配".into()); } - let active = try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?; - Ok(Some(design_view(&session, active.is_none()))) + let Some(active) = + try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)? + else { + return Ok(Some(design_view(&session, true))); + }; + if design_session_has_uncertain_batch(&session) { + let resources = DesignResources::new(resolve_design_resources_root(&app)?)?; + let view = recover_uncertain_design_batch(root, &resources, session, active).await?; + return Ok(Some(view)); + } + drop(active); + Ok(Some(design_view(&session, false))) +} + +fn design_session_has_uncertain_batch(session: &DesignSession) -> bool { + let Some(batch) = session.pending_batch.as_ref() else { + return false; + }; + if !batch.executing || batch.cursor >= batch.calls.len() { + return false; + } + let call_id = batch.calls[batch.cursor].id.as_str(); + session.turn.as_ref().is_some_and(|turn| turn.pending) + && !session.history.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call_output") + && item.get("call_id").and_then(Value::as_str) == Some(call_id) + }) } fn design_session_error_is_recoverable(error: &str) -> bool { @@ -1601,6 +1696,121 @@ mod tests { .clone() } + // 进程级 env 在同一 binary 的并行用例间共享:持锁串行化修改,drop 时恢复原值, + // 避免 debug 开关泄漏给并发用例。锁中毒时取内部值继续,不让上游失败放大。 + static DESIGN_DEBUG_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + struct DesignDebugEnvGuard { + previous: Option, + _lock: std::sync::MutexGuard<'static, ()>, + } + + impl Drop for DesignDebugEnvGuard { + fn drop(&mut self) { + match &self.previous { + Some(value) => std::env::set_var("GENARRATIVE_AGC_DESIGN_DEBUG", value), + None => std::env::remove_var("GENARRATIVE_AGC_DESIGN_DEBUG"), + } + } + } + + fn enable_design_debug_for_test() -> DesignDebugEnvGuard { + let lock = DESIGN_DEBUG_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let previous = std::env::var("GENARRATIVE_AGC_DESIGN_DEBUG").ok(); + std::env::set_var("GENARRATIVE_AGC_DESIGN_DEBUG", "1"); + DesignDebugEnvGuard { + previous, + _lock: lock, + } + } + + #[tokio::test(flavor = "current_thread")] + async fn design_tool_panic_becomes_visible_retryable_error() { + let (_temp, root, resources) = init_design_project(); + let _debug_env = enable_design_debug_for_test(); + let panic_call = platform_llm::LlmToolCall { + id: "call-panic".into(), + name: "design_test__panic".into(), + arguments: "{}".into(), + }; + let _fake = fake_provider::install( + vec![ + Ok(fake_response("panic-turn", "", vec![panic_call])), + Ok(fake_response("recovery", "已恢复", Vec::new())), + ], + 0, + ); + let view = continue_design_agent_at( + &root, + &resources, + "turn-panic", + DesignInput::Message { + text: "需求".into(), + }, + |_| {}, + ) + .await + .expect("panic 必须转成可恢复视图而不是向上传播"); + assert!(!view.running); + assert!(view.can_retry); + assert_eq!( + view.session.last_error.as_deref(), + Some(DESIGN_PANIC_PUBLIC_ERROR) + ); + let session = read_design_session(&root) + .expect("read session") + .expect("session exists"); + assert!( + !session.history.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call_output") + && item.get("call_id").and_then(Value::as_str) == Some("call-panic") + }), + "panic 不得写半个工具输出" + ); + + // design_debug 经独立线程落盘,轮询等待 panic 记录出现。 + let debug_dir = root.join(".debug/design-agent"); + let mut panic_record = None; + for _ in 0..100 { + panic_record = fs::read_dir(&debug_dir).ok().and_then(|entries| { + entries.flatten().find_map(|entry| { + let path = entry.path(); + let name = path.file_name()?.to_string_lossy().into_owned(); + if name.ends_with("-panic.json") { + fs::read_to_string(path).ok() + } else { + None + } + }) + }); + if panic_record.is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + let panic_record = panic_record.expect("panic hook 必须把位置和负载写入 design_debug"); + assert!(panic_record.contains("design_runtime.rs")); + assert!(panic_record.contains("注入的策划工具 panic")); + + let view = + continue_design_agent_at(&root, &resources, "turn-retry", DesignInput::Retry, |_| {}) + .await + .expect("panic 后可重试"); + assert!(!view.running); + assert!(!view.can_retry); + assert!(view.session.last_error.is_none()); + let session = read_design_session(&root) + .expect("read session") + .expect("session exists"); + assert!(session.turn.as_ref().is_some_and(|turn| !turn.pending)); + assert!(session.history.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call_output") + && item.get("call_id").and_then(Value::as_str) == Some("call-panic") + })); + } + #[test] fn design_request_enables_reasoning_capture_only_for_design_runtime() { let session = new_design_session("project", "quality"); @@ -1958,4 +2168,94 @@ mod tests { .any(|message| message.text.contains("重试后继续"))); assert!(next.session.last_error.is_none()); } + + #[tokio::test(flavor = "current_thread")] + async fn uncertain_batch_hydrate_continues_the_original_turn_without_replaying_file_tools() { + let (_temp, root, resources) = init_design_project(); + execute_design_file_tool( + &root, + "write_file", + &json!({"path":"project/00_concept/design.md","content":"概念"}), + ) + .expect("write concept"); + let mut session = new_design_session("design-fake", "quality"); + let call = platform_llm::LlmToolCall { + id: "interrupted-call".into(), + name: "patch_file".into(), + arguments: json!({ + "path":"project/00_concept/design.md", + "old_text":"概念", + "new_text":"概念设计" + }) + .to_string(), + }; + session.history.push(json!({ + "type":"function_call", + "call_id":call.id, + "name":call.name, + "arguments":call.arguments, + })); + session.messages = vec![DesignMessage { + id: "turn:user".into(), + role: "user".into(), + text: "继续".into(), + }]; + session.turn = Some(DesignTurn { + id: "turn-recovery".into(), + pending: true, + request_index: 0, + attempt: 0, + }); + session.pending_batch = Some(DesignToolBatch { + calls: vec![call], + cursor: 0, + executing: true, + }); + assert!(design_session_has_uncertain_batch(&session)); + write_design_session(&root, &session).expect("write interrupted session"); + + let _fake = fake_provider::install( + vec![Ok(fake_response( + "recovered-after-uncertain-tool", + "已读取文件并确认。", + Vec::new(), + ))], + 0, + ); + let view = recover_uncertain_design_batch(&root, &resources, session, { + try_open_game_creator_agent_runtime_task_lock_file( + &root, + ".agent/design-agent/active.lock", + ) + .expect("open active lock") + .expect("active lock is free") + }) + .await + .expect("recover uncertain batch"); + + assert!(!view.running); + assert!(view.session.last_error.is_none()); + let restored = read_design_session(&root) + .expect("read restored") + .expect("session"); + assert!(restored.pending_batch.is_none()); + assert!(!restored.turn.expect("turn").pending); + assert!(restored.history.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call_output") + && item.get("call_id").and_then(Value::as_str) == Some("interrupted-call") + && item + .get("output") + .and_then(Value::as_str) + .is_some_and(|output| output.contains("执行结果未保存")) + })); + assert!(restored.history.iter().any(|item| { + item.get("role").and_then(Value::as_str) == Some("assistant") + && item.get("content").is_some() + })); + assert!( + fs::read_to_string(root.join("design_artifacts/project/00_concept/design.md")) + .expect("read target") + == "概念" + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs index 5221030b1..ba4420164 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs @@ -321,15 +321,31 @@ pub(crate) fn execute_design_file_tool( }) .collect::>(); let mut matches = Vec::new(); + let mut edit_errors = Vec::new(); + let mut valid_edits = 0; for (index, (old, new)) in normalized.iter().enumerate() { + if old == new { + edit_errors.push(format!( + "edits[{index}] new_text 与 old_text 相同,不会产生修改" + )); + continue; + } let count = content.matches(old).count(); if count == 0 { - return Err(format!("edits[{index}] 原文未找到:{display}")); + edit_errors.push(format!( + "edits[{index}] 原文未找到:{}{}", + display, + design_patch_location_hint(&content, old) + )); + continue; } if count != 1 { - return Err(format!( - "edits[{index}] 原文匹配 {count} 处,必须唯一:{display}" + let start = content.find(old).expect("count checked"); + let line = design_patch_line_number(&content, start); + edit_errors.push(format!( + "edits[{index}] 原文匹配 {count} 处,必须唯一;首次位于第 {line} 行" )); + continue; } let start = content.find(old).expect("count checked"); let end = start + old.len(); @@ -337,18 +353,43 @@ pub(crate) fn execute_design_file_tool( .iter() .find(|(_, other_start, other_end)| start < *other_end && *other_start < end) { - return Err(format!( - "edits[{index}] 与 edits[{other_index}] 修改范围重叠:{display}" + edit_errors.push(format!( + "edits[{index}] 与 edits[{other_index}] 修改范围重叠;请合并为一个 edit 或缩短 old_text" )); + continue; } matches.push((index, start, end)); + valid_edits += 1; let _ = new; } - let mut updated = content.clone(); - for (index, start, end) in matches.into_iter().rev() { - let (_, new) = &normalized[index]; - updated.replace_range(start..end, new); + if !edit_errors.is_empty() { + let shown = edit_errors.len().min(4); + let mut details = edit_errors[..shown].to_vec(); + if shown < edit_errors.len() { + details.push(format!( + "另有 {} 个 edit 校验失败(详情省略)", + edit_errors.len() - shown + )); + } + if valid_edits > 0 { + details.push(format!( + "其余 {valid_edits} 个 edit 当前可唯一匹配;本次未写入文件" + )); + } else { + details.push("本次未写入文件".to_string()); + } + return Err(details.join("\n")); } + matches.sort_unstable_by_key(|(_, start, _)| *start); + let mut updated = String::with_capacity(content.len()); + let mut cursor = 0; + for (index, start, end) in matches { + let (_, new) = &normalized[index]; + updated.push_str(&content[cursor..start]); + updated.push_str(new); + cursor = end; + } + updated.push_str(&content[cursor..]); if updated == content { return Err(format!("没有产生修改:{display}")); } @@ -396,6 +437,60 @@ pub(crate) fn execute_design_file_tool( } } +fn design_patch_line_number(content: &str, start: usize) -> usize { + 1 + content[..start] + .bytes() + .filter(|byte| *byte == b'\n') + .count() +} + +fn design_patch_visible_line(line: &str) -> String { + line.replace('\t', "\\t").chars().take(180).collect() +} + +fn design_patch_location_hint(content: &str, old: &str) -> String { + let Some(anchor) = old.lines().map(str::trim).find(|line| !line.is_empty()) else { + return String::new(); + }; + + let mut candidates = content + .lines() + .enumerate() + .filter(|(_, line)| line.trim() == anchor) + .map(|(index, line)| (index + 1, line)) + .collect::>(); + if candidates.is_empty() { + let token = anchor.split_whitespace().find(|token| token.len() >= 3); + if let Some(token) = token { + candidates = content + .lines() + .enumerate() + .filter(|(_, line)| line.trim().contains(token)) + .map(|(index, line)| (index + 1, line)) + .collect(); + } + } + if candidates.is_empty() { + return format!( + ";未找到与 old_text 首个非空行相似的行(当前文件约 {} 行)", + content.lines().count() + ); + } + + let details = candidates + .iter() + .take(2) + .map(|(line, text)| format!("第 {line} 行:{}", design_patch_visible_line(text))) + .collect::>() + .join(";"); + let suffix = if candidates.len() > 2 { + format!("等 {} 处", candidates.len()) + } else { + String::new() + }; + format!(";old_text 首个非空行可能对应 {details}{suffix}(tab 显示为 \\t)") +} + pub(crate) fn list_design_workspace_files( root: &Path, ) -> Result, String> { @@ -693,6 +788,22 @@ mod tests { ) .expect_err("escape"); assert!(escaped.contains("路径")); + let mismatch = execute_design_file_tool( + root, + "patch_file", + &json!({ + "path":"notes/design.md", + "edits":[ + {"old_text":" 游戏设计","new_text":"游戏概念"}, + {"old_text":"设计","new_text":"方案"} + ] + }), + ) + .expect_err("report all patch failures"); + assert!(mismatch.contains("edits[0] 原文未找到")); + assert!(mismatch.contains("第 1 行:游戏设计")); + assert!(mismatch.contains("其余 1 个 edit 当前可唯一匹配")); + assert!(mismatch.contains("本次未写入文件")); let patched = execute_design_file_tool( root, "patch_file", @@ -728,6 +839,35 @@ mod tests { assert!(!root.join("design_artifacts/notes").exists()); } + #[test] + fn patch_file_applies_out_of_order_edits_with_changing_utf8_lengths() { + let temp = test_root(); + let root = temp.path(); + execute_design_file_tool( + root, + "write_file", + &json!({"path":"notes/design.md","content":"开头\n甲\n保留一\n乙乙\n保留二\n丙\n结尾"}), + ) + .expect("write"); + execute_design_file_tool( + root, + "patch_file", + &json!({ + "path":"notes/design.md", + "edits":[ + {"old_text":"丙","new_text":"新的结论"}, + {"old_text":"甲","new_text":"扩展A"}, + {"old_text":"乙乙","new_text":"乙"} + ] + }), + ) + .expect("patch out of order"); + assert_eq!( + fs::read_to_string(root.join("design_artifacts/notes/design.md")).expect("read disk"), + "开头\n扩展A\n保留一\n乙\n保留二\n新的结论\n结尾" + ); + } + #[test] fn phase_context_injects_current_skill_only() { let resources = DesignResources::new(pack_root()).expect("pack"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs index 6f8557a32..86416a42d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs @@ -550,6 +550,8 @@ fn extract_mcp_arguments(root: &Path, tool: &str, arguments: &Value) -> Value { "agc_generate_image" => { copy_string(object, "kind", &mut out); copy_string(object, "sliceMode", &mut out); + copy_number(object, "sliceCount", &mut out); + copy_string(object, "screenColor", &mut out); copy_string(object, "aspectRatio", &mut out); copy_string(object, "imageSize", &mut out); copy_string(object, "assetName", &mut out); @@ -1194,7 +1196,13 @@ mod tests { "item": { "type": "mcpToolCall", "tool": "agc_generate_image", - "arguments": { "prompt": prompt, "kind": "icon-spec" } + "arguments": { + "prompt": prompt, + "kind": "art-spritesheet", + "sliceMode": "connected-components", + "sliceCount": 8, + "screenColor": "#CFEFFF" + } } })); audit.finish(true); @@ -1205,6 +1213,8 @@ mod tests { let stored = item["arguments"]["prompt"].as_str().expect("prompt"); assert_eq!(stored.chars().count(), DIRECT_CODEX_AUDIT_BRIEF_CHARS); assert_eq!(item["arguments"]["promptChars"], json!(5000)); + assert_eq!(item["arguments"]["sliceCount"], json!(8)); + assert_eq!(item["arguments"]["screenColor"], json!("#CFEFFF")); assert_eq!( item["arguments"]["promptSha256"], json!(sha256_hex("收".repeat(5000).as_bytes())) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs index d5ccc94c4..e33bfae72 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs @@ -5,8 +5,8 @@ mod validation; mod wire; pub(crate) use model::{ - DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageEnvelope, - DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart, + DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem, + DirectCodexUserRole, DirectCodexUserRuntimeRegionPart, }; pub(crate) use validation::validate_direct_codex_user_item; pub(crate) use wire::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs index b46336b66..266f330cc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs @@ -61,13 +61,6 @@ pub(crate) struct DirectCodexUserRuntimeRegionPart { pub(crate) resource_ids: Vec, } -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) struct DirectCodexUserMessageEnvelope { - pub(crate) item: DirectCodexUserItem, -} - #[cfg(test)] mod tests { use super::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index 130c01549..f1e6679bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -1,3 +1,4 @@ +use super::direct_thread_item_identity; use super::runtime_actions::acquire_game_creator_agent_runtime_project_write_lock_with_wait; use crate::config::prepare_game_creator_private_path_for_read; use crate::project::{ @@ -102,63 +103,127 @@ fn record(item: &Value) -> Result { const DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES: usize = 16 * 1024; +/// 从文件尾向前回读到的一行。 +/// +/// `terminated` 表示这行后面确实有换行符:整份历史里只有末尾那一段可能没有换行,那一段是 +/// append 侧承诺会修复的截断尾。解析失败时跳过这一行继续往前回扫,而不是报错——它是回扫 +/// 见到的第一行,一旦就地结束就会把后面完整的更早历史全部丢掉。 +struct DirectProjectHistoryReverseLine { + bytes: Vec, + terminated: bool, +} + +/// 从文件尾向前逐行产出 `project.jsonl`。 +/// +/// 历史会随项目长到 MB 级,而"最近一屏"和"按 id 回扫"都只关心尾部若干行:两者共用这一套 +/// 按块回读,"读一屏"不必再从文件头逐行读到尾。 +struct DirectProjectHistoryReverseLines { + path: PathBuf, + file: File, + /// 已读进内存、但还没被换行切出来的更早字节。 + pending: Vec, + chunk: Vec, + /// 下一次回读的起始偏移;0 表示文件头已经读完。 + position: u64, + /// 是否已经切过一次换行:切过之后,接下来产出的行前面一定还有换行分隔。 + saw_delimiter: bool, +} + +impl DirectProjectHistoryReverseLines { + fn open(path: &Path) -> Result { + let file = File::open(path) + .map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?; + let position = file + .metadata() + .map_err(|error| { + format!( + "读取 DirectProject 历史元数据失败:{}: {error}", + path.display() + ) + })? + .len(); + Ok(Self { + path: path.to_path_buf(), + file, + pending: Vec::new(), + chunk: vec![0u8; DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES], + position, + saw_delimiter: false, + }) + } + + fn next_line(&mut self) -> Result, String> { + loop { + if let Some(newline) = self.pending.iter().rposition(|byte| *byte == b'\n') { + let bytes = self.pending[newline + 1..].to_vec(); + // `pending` 的尾部始终是文件尾,所以第一次切出来的这一段后面有没有换行, + // 就是"整个文件是否以换行结尾";之后切出来的行都被刚切掉的那个换行分隔。 + let terminated = if self.saw_delimiter { + true + } else { + self.pending.last() == Some(&b'\n') + }; + self.pending.truncate(newline); + self.saw_delimiter = true; + return Ok(Some(DirectProjectHistoryReverseLine { bytes, terminated })); + } + if self.position == 0 { + if self.pending.is_empty() { + return Ok(None); + } + // 走到这里说明剩下的字节里没有换行:只有文件根本没被换行分隔过, + // 或者这就是第一行。前者是唯一没有换行结尾的行。 + let terminated = self.saw_delimiter; + return Ok(Some(DirectProjectHistoryReverseLine { + bytes: std::mem::take(&mut self.pending), + terminated, + })); + } + let read_len = usize::try_from(self.position) + .unwrap_or(usize::MAX) + .min(self.chunk.len()); + self.position -= read_len as u64; + self.file + .seek(SeekFrom::Start(self.position)) + .map_err(|error| { + format!( + "定位 DirectProject 历史失败:{}: {error}", + self.path.display() + ) + })?; + self.file + .read_exact(&mut self.chunk[..read_len]) + .map_err(|error| { + format!( + "读取 DirectProject 历史失败:{}: {error}", + self.path.display() + ) + })?; + let mut combined = self.chunk[..read_len].to_vec(); + combined.extend_from_slice(&self.pending); + self.pending = combined; + } + } +} + fn find_direct_project_history_item_by_id_at( path: &Path, item_id: &str, ) -> Result, String> { fire_direct_project_history_scan_probe(DIRECT_PROJECT_HISTORY_SCAN_PROBE_STARTED); - let mut file = File::open(path) - .map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?; - let mut position = file - .metadata() - .map_err(|error| { - format!( - "读取 DirectProject 历史元数据失败:{}: {error}", - path.display() - ) - })? - .len(); - let mut pending = Vec::new(); - let mut chunk = vec![0u8; DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES]; - - loop { - if position == 0 { - break; - } - let read_len = usize::try_from(position) - .unwrap_or(usize::MAX) - .min(chunk.len()); - position -= read_len as u64; - file.seek(SeekFrom::Start(position)) - .map_err(|error| format!("定位 DirectProject 历史失败:{}: {error}", path.display()))?; - file.read_exact(&mut chunk[..read_len]) - .map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?; - - let mut combined = Vec::with_capacity(read_len + pending.len()); - combined.extend_from_slice(&chunk[..read_len]); - combined.extend_from_slice(&pending); - let mut line_end = combined.len(); - while let Some(newline) = combined[..line_end].iter().rposition(|byte| *byte == b'\n') { - let line = &combined[newline + 1..line_end]; - if !line.is_empty() { - if let Some(item) = direct_project_history_item_from_line(path, line)? { - if item.get("id").and_then(Value::as_str) == Some(item_id) { - return Ok(Some(item)); - } + let mut lines = DirectProjectHistoryReverseLines::open(path)?; + while let Some(line) = lines.next_line()? { + match direct_project_history_item_from_line(path, &line.bytes) { + Ok(Some(item)) => { + if item.get("id").and_then(Value::as_str) == Some(item_id) { + return Ok(Some(item)); } } - line_end = newline; - } - pending = combined[..line_end].to_vec(); - } - - if !pending.is_empty() { - // The append path repairs an unterminated final JSONL record before - // writing. A duplicate scan must not reject that repairable tail. - if let Ok(Some(item)) = direct_project_history_item_from_line(path, &pending) { - if item.get("id").and_then(Value::as_str) == Some(item_id) { - return Ok(Some(item)); - } + Ok(None) => {} + // 末行没有换行符:这是 append 侧会修复的截断尾,跳过它继续往前找, + // 不能因为尾部半行就让整次幂等回扫失败。 + Err(_) if !line.terminated => continue, + Err(error) => return Err(error), } } Ok(None) @@ -427,7 +492,12 @@ fn append_direct_project_history_item_once(root: &Path, item: &Value) -> Result< append_jsonl_line_unlocked(&path, &line, "DirectProject 历史") } -fn is_direct_project_codex_user_item(item: &Value) -> bool { +/// Codex app-server 回显的用户条目(`userMessage` / 非 AGC 的 `role=user` message)。 +/// +/// AGC 自己预写的用户条目 id 固定是 `direct-codex:{clientTurnId}:user`,因此这里必须 +/// 把两者区分开:落盘侧(本模块)与运行态事件侧(`codex_app_server`)共用同一口径, +/// 任何一侧漏判都会让同一条用户消息出现第二个身份。 +pub(crate) fn is_direct_project_codex_user_item(item: &Value) -> bool { if item.get("type").and_then(Value::as_str) == Some("userMessage") { return true; } @@ -530,46 +600,145 @@ fn read_direct_project_history_entries_at(root: &Path) -> Result Option { + item.get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| direct_thread_item_identity(item)) +} + +/// 一屏历史窗口在新端(较新一侧)的锚点。 +/// +/// 锚点一律是 `project.jsonl` 里的原始 item id(见 [`direct_project_history_anchor_id`])。 +pub(crate) enum DirectProjectHistoryAnchor<'a> { + /// 不锚定:直接取文件尾最近的一屏。 + Newest, + /// 取锚点条目**之前**的一屏:锚点本身不进窗口,是"下一屏"的边界(向后翻页用)。 + Before(&'a str), + /// 取到锚点条目**为止**的一屏:锚点在窗口内(订阅回执给出的首屏边界用)。 + Through(&'a str), +} + +impl<'a> DirectProjectHistoryAnchor<'a> { + /// 锚点的原始 item id;不锚定时为 `None`。 + fn item_id(&self) -> Option<&'a str> { + match self { + Self::Newest => None, + Self::Before(item_id) | Self::Through(item_id) => Some(item_id), + } + } + + /// 锚点条目本身是否属于窗口:`Through` 含锚点,`Before` 把锚点留给下一屏。 + fn includes_anchor(&self) -> bool { + matches!(self, Self::Through(_)) + } + + /// 这一条是不是锚点条目(按原始 item id 比对,不看归一身份)。 + fn matches_item(&self, item: &Value) -> bool { + self.item_id() + .is_some_and(|wanted| direct_project_history_anchor_id(item).as_deref() == Some(wanted)) + } +} + +/// 从文件尾向前回扫一屏历史,返回 `(条目, 还有更早的条目, 记录时间, 本屏最老一条的原始 item id)`。 +/// +/// 窗口的新端由 `anchor` 给出:`Before` 用于向后翻页(锚点本身不进窗口),`Through` 用于订阅 +/// 回执给出的首屏边界(锚点进窗口),`Newest` 直接取文件尾。收满 `limit` 条可显示条目后再多 +/// 看一眼"还有没有更早的条目"就停,不回读整份历史。 pub(crate) fn read_direct_project_history_items_slice_at( root: &Path, - before_item_id: Option<&str>, + anchor: DirectProjectHistoryAnchor<'_>, limit: usize, -) -> Result<(Vec, bool, BTreeMap), String> { - let items = read_direct_project_history_entries_at(root)?; - let end = match before_item_id { - Some(item_id) => items - .iter() - .position(|(item, _)| item.get("id").and_then(Value::as_str) == Some(item_id)) - .ok_or_else(|| format!("DirectProject 历史中不存在 item:{item_id}"))?, - None => items.len(), - }; +) -> Result<(Vec, bool, BTreeMap, Option), String> { + let path = history_path(root); + if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? { + return Ok((Vec::new(), false, BTreeMap::new(), None)); + } let bounded_limit = limit.clamp(1, 200); - let start = end.saturating_sub(bounded_limit); - let slice = &items[start..end]; - let timestamps = slice + let mut lines = DirectProjectHistoryReverseLines::open(&path)?; + let mut newest_first: Vec<(Value, u64)> = Vec::new(); + // 锚点命中之前先跳过更新的条目(首屏的 `Through` 会把订阅回执之后才完成的条目挡在外面)。 + let mut anchor_seen = matches!(anchor, DirectProjectHistoryAnchor::Newest); + let mut has_more = false; + while let Some(line) = lines.next_line()? { + if line.bytes.iter().all(u8::is_ascii_whitespace) { + continue; + } + let parsed: Value = match serde_json::from_slice(&line.bytes) { + Ok(value) => value, + // 与顺序读取一致:只有末行没有换行符时才是"可修复的截断尾"。回扫是从文件尾向前的, + // 这一行是最新的一条,跳过它继续读更早的完整行,不能因此把整屏历史判成空的。 + Err(_) if !line.terminated => continue, + Err(error) => { + return Err(format!( + "解析 DirectProject 历史失败:{}: {error}", + path.display() + )) + } + }; + let Some(item) = direct_project_history_item_from_parsed_line(&path, &parsed)? else { + continue; + }; + if !anchor_seen { + if !anchor.matches_item(&item) { + continue; + } + anchor_seen = true; + // `Before` 的锚点所在行本身不进窗口:它是"下一屏"的边界。 + if !anchor.includes_anchor() { + continue; + } + } + if is_direct_project_internal_context_item(&item) { + continue; + } + if newest_first.len() == bounded_limit { + // 收满一屏之后再见一条可显示条目,就足以说明还有更早的历史。 + has_more = true; + break; + } + let recorded_at = parsed + .get("recordedAt") + .and_then(Value::as_u64) + .unwrap_or(0); + newest_first.push((item, recorded_at)); + } + if !anchor_seen { + return Err(format!( + "DirectProject 历史中不存在 item:{}", + anchor.item_id().unwrap_or_default() + )); + } + newest_first.reverse(); + let recorded_at_ms = newest_first .iter() .filter_map(|(item, at)| { - let id = item.get("id").and_then(Value::as_str)?; - (*at > 0).then(|| (id.to_string(), *at)) + let identity = direct_thread_item_identity(item)?; + (*at > 0).then_some((identity, *at)) }) .collect(); + let first_item_id = newest_first + .first() + .and_then(|(item, _)| direct_project_history_anchor_id(item)); Ok(( - slice.iter().map(|(item, _)| item.clone()).collect(), - start > 0, - timestamps, + newest_first.into_iter().map(|(item, _)| item).collect(), + has_more, + recorded_at_ms, + first_item_id, )) } +/// 最新一条可显示条目的 itemId:首屏历史锚点。 +/// +/// 与"最近一屏"共用尾部回扫,读一行就能返回,不回读整份历史。 pub(crate) fn read_direct_project_last_item_id_at(root: &Path) -> Result, String> { - Ok(read_direct_project_history_items_at(root)? - .into_iter() - .rev() - .find_map(|item| { - item.get("id") - .and_then(Value::as_str) - .filter(|id| !id.is_empty()) - .map(str::to_string) - })) + Ok(read_direct_project_history_items_slice_at(root, DirectProjectHistoryAnchor::Newest, 1)?.3) } pub(crate) fn read_direct_project_chat_history_at( @@ -619,6 +788,7 @@ mod tests { append_direct_project_history_item_at, append_direct_project_user_message_at, direct_project_history_scan_probe, history_path, is_direct_project_internal_context_item, read_direct_project_chat_history_at, read_direct_project_history_items_at, + DirectProjectHistoryAnchor as Anchor, }; use serde_json::{json, Value}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -648,13 +818,15 @@ mod tests { "content": [{"type": "input_text", "text": "修改游戏"}], }); append_direct_project_user_message_at(root.path(), &item).unwrap(); - let (items, _, timestamps) = - super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); + let (items, _, timestamps, _) = + super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 20) + .unwrap(); assert_eq!(items, vec![item.clone()]); assert!(timestamps["sent-message"] > 0); append_direct_project_user_message_at(root.path(), &item).unwrap(); - let (_, _, reloaded) = - super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); + let (_, _, reloaded, _) = + super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 20) + .unwrap(); assert_eq!(timestamps, reloaded); } @@ -662,8 +834,9 @@ mod tests { fn old_history_without_envelope_time_stays_unknown() { let root = init_history_project("history-unknown-time"); write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); - let (_, _, timestamps) = - super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); + let (_, _, timestamps, _) = + super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 20) + .unwrap(); assert!(timestamps.is_empty()); assert_eq!( read_direct_project_chat_history_at(root.path()) @@ -674,6 +847,265 @@ mod tests { ); } + /// 判据:读一屏只回扫文件尾,不碰文件头。 + /// + /// 文件头放一条坏行(JSON 不合法),只要读取只覆盖"尾部一屏 + 一条"的范围就必须成功; + /// 一旦实现退回"从文件头逐行读到尾",这条用例会因为坏行直接失败。 + #[test] + fn history_window_reads_from_the_tail_without_parsing_the_head() { + let root = init_history_project("history-tail-scan"); + let head = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"head-broken","content":[{"type":"input_text","text":"坏行"}"#; + write_history_lines( + root.path(), + &[ + head, + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-1"), + RESPONSE_ITEM_ROW, + ], + ); + let (items, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 1) + .unwrap(); + let ids = items + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["codex-item-2"]); + assert!(has_more); + assert_eq!(first_item_id.as_deref(), Some("codex-item-2")); + } + + /// 判据:分页锚点是"本窗口最老一条的原始 item id",逐屏向前不重不漏。 + #[test] + fn history_window_paginates_upwards_by_item_id_anchor() { + let root = init_history_project("history-pagination"); + write_history_lines( + root.path(), + &[ + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-1"), + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-2"), + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-3"), + ], + ); + let (newest, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 2) + .unwrap(); + let ids = newest + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["codex-item-2", "codex-item-3"]); + assert!(has_more); + assert_eq!(first_item_id.as_deref(), Some("codex-item-2")); + + let (older, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at( + root.path(), + Anchor::Before(first_item_id.as_deref().expect("分页锚点")), + 2, + ) + .unwrap(); + let ids = older + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["codex-item-1"]); + assert!(!has_more); + assert_eq!(first_item_id.as_deref(), Some("codex-item-1")); + } + + /// 判据:首屏窗口的新端边界由 `Through` 锚点给出并**含**锚点条目。 + /// + /// 订阅回执里的 `lastCompletedItemId` 就是这条:比它更新的条目属于运行态事件,不能再从 + /// 历史带一遍,否则同一条目在历史与实时各来一次(此前只靠前端合并兜住)。 + #[test] + fn history_window_through_anchor_includes_it_and_drops_newer_items() { + let root = init_history_project("history-through-anchor"); + write_history_lines( + root.path(), + &[ + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-1"), + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-2"), + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-3"), + ], + ); + + let (items, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at( + root.path(), + Anchor::Through("codex-item-2"), + 20, + ) + .unwrap(); + let ids = items + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!( + ids, + vec!["codex-item-1", "codex-item-2"], + "锚点本身在窗口内,比它更新的条目必须留给运行态" + ); + assert!(!has_more); + assert_eq!(first_item_id.as_deref(), Some("codex-item-1")); + } + + /// 判据:锚点就是文件里最老一条时,`Through` 窗口只有它一条且 `hasMore=false`;同一份历史用 + /// `Before` 取锚点**之前**的一屏会取空——两者不是同一个窗口,方向搞反就会凭空多给一屏或吞掉一条。 + #[test] + fn history_window_through_anchor_at_the_oldest_item_still_keeps_it() { + let root = init_history_project("history-through-anchor-oldest"); + write_history_lines( + root.path(), + &[ + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-1"), + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-2"), + ], + ); + + let (items, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at( + root.path(), + Anchor::Through("codex-item-1"), + 20, + ) + .unwrap(); + let ids = items + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["codex-item-1"]); + assert!(!has_more); + assert_eq!(first_item_id.as_deref(), Some("codex-item-1")); + + let (empty, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at( + root.path(), + Anchor::Before("codex-item-1"), + 20, + ) + .unwrap(); + assert!( + empty.is_empty(), + "`Before` 的锚点是下一屏的边界,本身不进窗口" + ); + assert!(!has_more); + assert_eq!(first_item_id, None); + } + + /// 判据:锚点在历史里不存在时失败关闭,错误里带锚点 id(首屏与翻页共用这条)。 + /// + /// 变异验证:把"锚点没命中"当成"读完整个文件"(例如沿用旧的 `anchor_seen` 初始化)时, + /// 首屏会绕过订阅回执的边界去取文件尾,这条用例与上面的 `Through` 用例会一起变红。 + #[test] + fn history_window_unknown_anchor_fails_closed() { + let root = init_history_project("history-unknown-anchor"); + write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); + + for anchor in [ + Anchor::Through("codex-missing"), + Anchor::Before("codex-missing"), + ] { + let error = super::read_direct_project_history_items_slice_at(root.path(), anchor, 20) + .expect_err("不存在的锚点必须失败关闭"); + assert!( + error.contains("不存在 item:codex-missing"), + "错误里要带锚点 id:{error}" + ); + } + } + + /// 判据:尾部残行(上一行完整、这一行没有换行结尾)跳过继续回扫,不报错也不就地结束。 + /// + /// 变异验证:`terminated` 按分支硬编码(旧实现)会把这条残行当成完整行,"读一屏"直接 + /// 报解析失败;就地结束则返回空窗口,把更早的完整历史全部丢掉。 + #[test] + fn truncated_history_tail_is_skipped_without_losing_earlier_items() { + let root = init_history_project("history-truncated-tail"); + let path = history_path(root.path()); + std::fs::create_dir_all(path.parent().expect("history parent")).expect("history dir"); + let first_row = RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-1"); + let partial_tail = r#"{"type":"response_item","payload":{"type":"mess"#; + std::fs::write( + &path, + format!("{first_row}\n{RESPONSE_ITEM_ROW}\n{partial_tail}"), + ) + .expect("write truncated history tail"); + + let (items, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 20) + .unwrap(); + let ids = items + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["codex-item-1", "codex-item-2"]); + assert!(!has_more); + assert_eq!(first_item_id.as_deref(), Some("codex-item-1")); + } + + /// 判据:只有"末尾没有换行"那一段放宽;换行结尾的坏行一律失败关闭,第一段也不例外。 + /// + /// 变异验证:把第一段也当可修复尾行(旧实现)时,这条用例会静默返回空历史而不是报错。 + #[test] + fn corrupt_first_line_fails_closed_like_any_newline_terminated_line() { + let root = init_history_project("history-corrupt-head"); + let first_row = RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-1"); + write_history_lines( + root.path(), + &[ + r#"{"type":"response_item","payload":{"type":"mess"#, + &first_row, + ], + ); + + let error = + super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 20) + .expect_err("换行结尾的坏行必须失败关闭"); + assert!(error.starts_with("解析 DirectProject 历史失败"), "{error}"); + } + + /// 判据:分页锚点取文件里的原始 item id。工具条目的调用与输出共用调用 id,归一身份不唯一。 + /// + /// 变异验证:锚点按归一身份算(旧实现)时,翻页会先跳过同身份的 output,再把上一屏已经 + /// 显示过的 function_call 重新带进这一屏。 + #[test] + fn pagination_anchor_uses_raw_item_id_for_tool_call_pairs() { + let root = init_history_project("history-tool-call-anchor"); + let call_row = r#"{"type":"response_item","payload":{"type":"function_call","id":"fc-1","call_id":"call-1","name":"exec_command","arguments":"{\"cmd\":\"ls\"}"}}"#; + let output_row = r#"{"type":"response_item","payload":{"type":"function_call_output","id":"fc-2","call_id":"call-1","output":"ok"}}"#; + let earlier_row = RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-1"); + write_history_lines(root.path(), &[&earlier_row, call_row, output_row]); + + // 首屏取 2 条:本屏最老是调用条目,锚点必须是它在文件里的原始 id,不是 call-1。 + let (newest, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 2) + .unwrap(); + let ids = newest + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["fc-1", "fc-2"]); + assert!(has_more); + assert_eq!(first_item_id.as_deref(), Some("fc-1")); + + // 用锚点翻上一屏:不能再把 fc-1 带一遍,也不能原地返回同一屏。 + let (older, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at( + root.path(), + Anchor::Before(first_item_id.as_deref().expect("分页锚点")), + 2, + ) + .unwrap(); + let ids = older + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["codex-item-1"]); + assert!(!has_more); + assert_eq!(first_item_id.as_deref(), Some("codex-item-1")); + } + /// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。 /// /// 注入标记是"让接下来 N 次单次尝试返回争用失败";退避表只补一次重试,所以注入 1 次 @@ -748,8 +1180,8 @@ mod tests { /// 判据:格式类失败**不**触发重试。同一份历史每次读都是同一个结论,重试只是白等 /// (而且会把"收尾失败"再拖一轮)。注入次数因此必须原样留着。 /// - /// fixture 形状说明:回扫只对"后面还有换行的完整行"做严格解析,文件里第一段(以及末尾 - /// 没有换行的那一段)按可修复尾行放宽。所以损坏行必须夹在两条完整行中间才会失败关闭。 + /// fixture 形状说明:回扫只对"末尾没有换行的那一段"按可修复尾行放宽,其余每一行(包括 + /// 文件第一段)都必须有换行结尾并被严格解析。所以损坏行夹在两条完整行中间必然失败关闭。 #[test] fn shape_failure_is_not_retried() { let root = init_history_project("shape-no-retry"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index ab733b185..1c27b262f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -15,9 +15,10 @@ const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024; const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。"; -const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 cwd 是用户选择的项目目录。DirectProject 的 Phaser 迁移固定使用 workspaceMode=DirectProject:识别已有 game/index.html 后,完整迁移状态、输入、敌人/守卫、波次、胜负、重开和画布绘制到 Phaser Scene/GameObject/update;写入 game/package.json、package-lock.json、vite.config.js(输出 game/dist)、game/game.js、game/style.css,先调用 project.bootstrap {cwd:game},再调用 project.verify {cwd:game,script:build,expectedCommand:从 game/package.json 原样读取},确认 game/dist/index.html 后才可 preview.start,并分别 preview.validate 桌面与移动视口。不能把 Phaser 项目走 gameHtml 单文件协议。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明并识别实际引擎与工程结构。用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎,而当前目录不具备对应工程结构时,必须先说明不匹配并提出澄清;在澄清前不得把请求改写成 Phaser/Web 实现,也不得写文件、安装依赖、构建或试玩。仅当用户确认继续当前工程或提供了匹配的项目目录后才执行。识别为 Cocos Creator 项目时,优先使用 `agc_cocos_execute` 或 Cocos 插件的 `cocos.editor.execute` 在已打开的 Creator 编辑器中操作;不要创建 Phaser 文件,不要把 Cocos 请求改写成 Web 工程。新 Web 游戏使用 npm + Vite,Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。简单修改只完成用户明确要求的范围;安装依赖、构建和试玩是后续操作,除非用户明确要求或它们是完成该项不可替代的最小验证,否则不得擅自扩展任务。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts。原生文件工具、patch 和命令参数可以使用 DirectProject Codex app-server 声明的完整访问权限;优先使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`,便于用户理解和审计,但不再把项目路径、`.agent/`、`.git/` 或其它目录做成 Codex 原生能力白名单。若 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径;调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文,不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。凭据、Token、Cookie、auth.json、`.env` 和 Runtime 私有控制面仍不得主动输出到对话、工具参数或日志。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。完整新游戏或根据策划案实现时必须执行 agc-game-production-workflow:按“策划定界 → 项目/资源盘点 → 美术生成或复用 → 游戏实现 → 构建验证 → 桌面/移动试玩 → 交付报告”顺序推进,每阶段完成后再进入下一阶段,不得在写完代码或生成图片后提前结束。新游戏 brief 中需要视觉素材时必须执行 taonier-art-assets:先检查已登记资源;缺少或不适用时调用 agc_tools 生图/编辑工具;读取返回的相对路径和登记身份,生成结果必须接入游戏源码并验证实际显示。只有明确不需要视觉素材的游戏才可跳过。资源生成、处理和接入属于同一游戏交付链路;不要用 emoji、CSS 形状或临时占位图替代 brief 中要求的真实素材,也不要在素材未接入时报告游戏完成。试玩仍按改动范围执行,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; +const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前 cwd 是用户选择的项目目录。DirectProject 的 Phaser 迁移固定使用 workspaceMode=DirectProject:识别已有 game/index.html 后,完整迁移状态、输入、敌人/守卫、波次、胜负、重开和画布绘制到 Phaser Scene/GameObject/update;写入 game/package.json、package-lock.json、vite.config.js(输出 game/dist)、game/game.js、game/style.css,先调用 project.bootstrap {cwd:game},再调用 project.verify {cwd:game,script:build,expectedCommand:从 game/package.json 原样读取},确认 game/dist/index.html 后才可 preview.start,并分别 preview.validate 桌面与移动视口。Phaser 画布居中责任唯一:使用 Phaser Scale.FIT 与 autoCenter CENTER_BOTH 时,canvas 的直接父容器用普通 block 按需要的宽高确定尺寸,不得在同一个 canvas 父容器上叠加 grid/flex 的 place-items、justify-content、align-items 居中或 margin:auto、translate 居中;若选择用 CSS 居中,则必须把 Phaser autoCenter 设为 NO_CENTER。外围布局仍可用 flex/grid,但同一个 canvas 的定位责任只能有一处。预览偏移先查项目自身的 CSS 与 Phaser 配置,不得用修改 AGC iframe 偏移来掩盖。改完布局后必须在桌面与移动视口以及 resize 后实测 canvas 相对游戏父容器的中心误差不超过 1 CSS px、无溢出,并按项目 scripts 构建 dist 后复验。不能把 Phaser 项目走 gameHtml 单文件协议。先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明并识别实际引擎与工程结构。用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎,而当前目录不具备对应工程结构时,必须先说明不匹配并提出澄清;在澄清前不得把请求改写成 Phaser/Web 实现,也不得写文件、安装依赖、构建或试玩。仅当用户确认继续当前工程或提供了匹配的项目目录后才执行。识别为 Cocos Creator 项目时,优先使用 `agc_cocos_execute` 或 Cocos 插件的 `cocos.editor.execute` 在已打开的 Creator 编辑器中操作;不要创建 Phaser 文件,不要把 Cocos 请求改写成 Web 工程。新 Web 游戏使用 npm + Vite;二维游戏 Phaser 固定为 4.2.1,在 `game.js` 或模块中使用 `import Phaser from 'phaser'`;用户要做三维游戏时不受 Phaser 约束,由你自选三维技术栈(例如 Three.js / Babylon.js),不要用等轴伪 3D 冒充三维。两种情况都可以按需使用其它 npm 依赖,不得复制 Phaser bundle、使用 import map 或 CDN。简单修改只完成用户明确要求的范围;安装依赖、构建和试玩是后续操作,除非用户明确要求或它们是完成该项不可替代的最小验证,否则不得擅自扩展任务。源码使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`package.json`、`package-lock.json`、`assets/hero.png`;依赖安装与构建使用项目自己的 npm scripts。原生文件工具、patch 和命令参数可以使用 DirectProject Codex app-server 声明的完整访问权限;优先使用 cwd 相对路径,例如 `index.html`、`style.css`、`game.js`、`assets/hero.png`,便于用户理解和审计,但不再把项目路径、`.agent/`、`.git/` 或其它目录做成 Codex 原生能力白名单。若 Codex 原生文件修改不可用,可以按需用客户端 `agc_write_file` 把文本写入项目相对路径;调用 `agc_write_file` 时,content 必须是目标文件的完整原始 UTF-8 正文,不得把 command.exec 的 Exit code、Wall time、Output 包装、终端日志或解释文字一起复制进 content,命令结果只能用于判断,不能当作文件正文。凭据、Token、Cookie、auth.json、`.env` 和 Runtime 私有控制面仍不得主动输出到对话、工具参数或日志。DirectProject 提供 Codex 原生文件、搜索、命令、图片查看、Skill、经客户端注入的 `agc_tools` MCP,以及客户端扩展列表中用户已启用的第三方 MCP。用户明确指定第三方 MCP Server 或工具时,先在当前可用工具中查找并直接调用;找不到时如实说明,不得伪造。你可以按需选择这些能力:`agc_write_file` 写入代码、配置、资源依赖清单或说明文件;`agc_generate_image` 生成普通图片、角色图、视觉规范图(icon-spec)、UI 设计图;`agc_edit_image` 修改已登记图片;`taonier_prepare_game_art` 准备完整游戏美术包及可用的 canonical 切片;`agc_list_registered_assets`、`agc_list_project_files`、`agc_list_account_assets`、`agc_import_account_assets` 用于发现和接入资源依赖;`agc_create_or_derive_resource` 用于视频、角色动画、音效或背景音乐;`agc_browser_playtest` 用于需要时的本地试玩观察;Skill references 按需使用相对路径直接读取。完整新游戏或根据策划案实现时必须执行 agc-game-production-workflow:按“策划定界 → 项目/资源盘点 → 美术生成或复用 → 游戏实现 → 构建验证 → 桌面/移动试玩 → 交付报告”顺序推进,每阶段完成后再进入下一阶段,不得在写完代码或生成图片后提前结束。新游戏 brief 中需要视觉素材时必须执行 taonier-art-assets:先检查已登记资源;缺少或不适用时调用 agc_tools 生图/编辑工具;读取返回的相对路径和登记身份,生成结果必须接入游戏源码并验证实际显示。只有明确不需要视觉素材的游戏才可跳过。资源生成、处理和接入属于同一游戏交付链路;不要用 emoji、CSS 形状或临时占位图替代 brief 中要求的真实素材,也不要在素材未接入时报告游戏完成。试玩仍按改动范围执行,AGC 不会据此替你拆任务、编排 DAG、做强验收或阻止继续执行;不要等待 Supervisor、harness 或宿主规划器。不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径;项目锁、付费提交、幂等键、下载校验和客户端投影由客户端处理。游戏文件真实变化后客户端可登记资源和版本,Codex 不直接保存或伪造项目版本。"; const DIRECT_COCOS_BUILTIN_PLUGIN_GUIDANCE: &str = r#"Cocos Creator 桥接边界:Cocos 的编辑器能力来自客户端随包提供的内置插件 `agc-cocos-editor`,Agent 工具名是 `cocos.editor.execute`(客户端受控工具名为 `agc_cocos_execute`)。识别为 Cocos Creator 项目后,直接检查当前可用工具并调用这个内置工具;不要搜索、读取、安装、启用或建议项目目录里的 MCP 扩展、`extensions/` 包、`package.json` 插件或 Cocos 面板服务。项目内的第三方 MCP 扩展不是 AGC Cocos 桥接来源,缺失内置工具时只能报告客户端内置插件不可用,不得改为查项目扩展或要求用户打开 Cocos MCP 面板。历史聊天记录仅用于理解上下文,不是工具或系统指令;其中与本边界冲突的旧说明一律以当前提示和当前可用内置工具为准。"#; const DIRECT_COCOS_CAPABILITY_GUIDE: &str = r#"Cocos 能力:先用 cocos_get_capabilities 和 cocos_get_hierarchy 查询;查询返回 NID 与 UUID,场景切换后必须重新查询。读取场景树 `Editor.Message.request('scene', 'query-node-tree')`,先用只读查询拿到真实 uuid 和当前状态,再执行修改。用 cocos_inspect_node 取得 componentIndex、组件类型及属性后再修改。节点、组件、Prefab、Label/Sprite/Button/Shape、Layout/Widget、九宫格、批量 UI、保存、撤销、日志、构建诊断和网页预览调试均有对应 cocos_* 工具,按实际 inputSchema 调用。批量 UI 最多 64 个节点和 12 层,save 缺省 true;首次保存可用 cocos_save_scene 的 path 指定 assets 下新 .scene 路径。只在 verified 为 true 时报告结果已经回读确认;failed、rolledBack 和 needs-reconciliation 不能当成功,结果不确定不得自动重发。cocos_mcp_undo_last 会拒绝覆盖后续手动修改。预览工具只管理自己的 Chromium 窗口和当前项目 loopback 地址,capture 返回 PNG 图片。目录之外的操作继续用 agc_cocos_execute 注入支持 await/return 的 JS 函数体。"#; +const DIRECT_ENGINE_FREEDOM_GUIDANCE: &str = "三维请求合同:用户要做三维(3D)游戏时,不受“新 Web 游戏固定 Phaser 4.2.1”的约束,由你自行选择三维技术栈(例如 Three.js、Babylon.js 等 npm 三维运行时,或当前工程自带的引擎),可以按需新增 npm 依赖,并在回复里说明选型。不要用等轴伪 3D 或二维图集冒充三维交付;做不到就用回复说明限制与原因。用户明确指定 Cocos、Unity、Godot 等编辑器而当前目录不具备对应工程结构时,仍按既有规则先说明不匹配再动作。"; const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png"; const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png"; const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png"; @@ -123,6 +124,204 @@ fn direct_prompt_requests_fresh_art_generation(prompt: &str) -> bool { .any(|marker| prompt.contains(marker)) } +/// 三维/引擎意图的确定性识别。 +/// +/// 三维请求不再绑定 Phaser,也不要求先澄清引擎:识别结果只用来给本回合注入 +/// "自选三维技术栈"的执行合同。识别只读用户原文,既不改写用户消息,也不触发生成。 +const DIRECT_ENGINE_3D_MARKERS: [&str; 2] = ["3d", "三维"]; + +/// 明确要求平面化的说法:这些词被移除后才判断三维意图,避免把用户主动选择的 +/// "伪 3D / 等轴 / 2.5D" 表现误判成三维选型请求。 +const DIRECT_ENGINE_FLAT_MARKERS: [&str; 5] = ["伪3d", "伪 3d", "pseudo-3d", "2.5d", "等轴"]; + +/// 三维需求必须落在游戏创作语义里,避免把 "三维数组" 之类的代码话题当成建游戏。 +const DIRECT_ENGINE_3D_CONTEXT_MARKERS: [&str; 24] = [ + "游戏", "玩法", "关卡", "角色", "场景", "画面", "引擎", "视角", "建模", "模型", "世界", "地图", + "城市", "射击", "冒险", "模拟", "经营", "塔防", "game", "level", "scene", "world", "model", + "fps", +]; + +const DIRECT_ENGINE_NAME_MARKERS: [(&str, DirectNamedEngine); 12] = [ + ("cocos", DirectNamedEngine::Cocos), + ("unity", DirectNamedEngine::Unity), + ("unreal", DirectNamedEngine::Unreal), + ("ue4", DirectNamedEngine::Unreal), + ("ue5", DirectNamedEngine::Unreal), + ("godot", DirectNamedEngine::Godot), + ("three.js", DirectNamedEngine::ThreeJs), + ("threejs", DirectNamedEngine::ThreeJs), + ("three js", DirectNamedEngine::ThreeJs), + ("babylon", DirectNamedEngine::Babylon), + ("phaser", DirectNamedEngine::Phaser), + ("虚幻", DirectNamedEngine::Unreal), +]; + +/// 用户消息里表达的目标引擎:点名引擎时不再由客户端裁定用法,只用于区分"用户已经 +/// 选了栈"和"只说三维、由 Codex 自己选栈"。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DirectEngineIntent { + /// 只说了三维,没有点名引擎。 + ThreeDimensional, + /// 点名了具体引擎或引擎家族。 + Named(DirectNamedEngine), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DirectNamedEngine { + Phaser, + ThreeJs, + Babylon, + Cocos, + Unity, + Godot, + Unreal, +} + +/// 当前项目根的引擎归属。只读工程结构标记,不读取用户数据。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DirectProjectEngine { + CocosCreator, + Unity, + Godot, + Unreal, + WebGame, + Unknown, +} + +impl DirectProjectEngine { + fn label(self) -> &'static str { + match self { + Self::CocosCreator => "Cocos Creator", + Self::Unity => "Unity", + Self::Godot => "Godot", + Self::Unreal => "Unreal", + Self::WebGame => "Web(Phaser/Vite)工程", + Self::Unknown => "未识别引擎的工程", + } + } +} + +fn direct_normalize_prompt_text(prompt: &str) -> String { + prompt + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase() +} + +/// 标记必须独立成词,避免把 "value4" 里的 `ue4` 之类片段当成引擎名。 +fn direct_prompt_contains_marker(prompt: &str, marker: &str) -> bool { + let boundary = |character: Option| { + character.is_none_or(|character| !character.is_ascii_alphanumeric()) + }; + prompt.match_indices(marker).any(|(index, _)| { + boundary(prompt[..index].chars().next_back()) + && boundary(prompt[index + marker.len()..].chars().next()) + }) +} + +/// 用户原文里的三维/引擎意图。点名的引擎优先,避免 "用 Unity 做" 这类没有 3D +/// 字样的请求漏判。 +pub(crate) fn direct_engine_intent_from_prompt(prompt: &str) -> Option { + let normalized = direct_normalize_prompt_text(prompt); + if let Some((_, engine)) = DIRECT_ENGINE_NAME_MARKERS + .iter() + .find(|(marker, _)| direct_prompt_contains_marker(&normalized, marker)) + { + return Some(DirectEngineIntent::Named(*engine)); + } + let mut remaining = normalized.clone(); + for marker in DIRECT_ENGINE_FLAT_MARKERS { + remaining = remaining.replace(marker, " "); + } + if !DIRECT_ENGINE_3D_MARKERS + .iter() + .any(|marker| direct_prompt_contains_marker(&remaining, marker)) + { + return None; + } + let has_game_context = DIRECT_ENGINE_3D_CONTEXT_MARKERS + .iter() + .any(|marker| remaining.contains(marker)); + if !has_game_context { + return None; + } + Some(DirectEngineIntent::ThreeDimensional) +} + +fn direct_project_has_unreal_project_file(root: &Path) -> bool { + let Ok(entries) = std::fs::read_dir(root) else { + return false; + }; + entries.flatten().any(|entry| { + entry + .path() + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("uproject")) + }) +} + +/// 当前项目根的引擎归属。判定失败按 Unknown 处理,不因为探测错误阻断回合。 +pub(crate) fn direct_project_engine(root: &Path) -> DirectProjectEngine { + if crate::project::discover_local_godot_project_root(root) + .ok() + .flatten() + .is_some() + { + return DirectProjectEngine::Godot; + } + if root.join("ProjectSettings/ProjectVersion.txt").is_file() { + return DirectProjectEngine::Unity; + } + if direct_project_has_unreal_project_file(root) { + return DirectProjectEngine::Unreal; + } + if crate::project::discover_local_cocos_project_root(root) + .ok() + .flatten() + .is_some() + { + return DirectProjectEngine::CocosCreator; + } + if root.join("game/index.html").is_file() || root.join("index.html").is_file() { + return DirectProjectEngine::WebGame; + } + DirectProjectEngine::Unknown +} + +/// 三维请求的执行合同。 +/// +/// 用户要做三维游戏时,"新 Web 游戏固定 Phaser 4.2.1" 的约束让位:由 Codex 自己 +/// 选三维技术栈(Three.js / Babylon.js 等 npm 运行时,或当前工程自带的引擎)。客户 +/// 端不要求先澄清引擎、不阻断工具、不拒绝登记产出;唯一保留的红线是不能用等轴伪 3D +/// 冒充三维交付而不说明。识别只读用户原文,不改写用户消息。 +pub(crate) fn direct_engine_three_dimensional_contract( + root: &Path, + prompt: &str, +) -> Option { + match direct_engine_intent_from_prompt(prompt)? { + DirectEngineIntent::Named(_) => None, + DirectEngineIntent::ThreeDimensional => { + let project = direct_project_engine(root); + Some(format!( + "三维请求执行合同(本回合):用户要求做三维(3D)游戏,本回合不受“新 Web 游戏固定 Phaser 4.2.1”的约束。由你自行选择合适的三维技术栈——例如 Three.js、Babylon.js 等 npm 三维运行时,或当前工程自带的引擎(当前工程识别:{})——可以按需新增 npm 依赖,沿用现有 npm + Vite 与 game/ 目录约定,也可以按需调整工程结构;不必先向用户确认引擎,直接按你的判断推进并在回复里说明选型。不要用等轴伪 3D 或二维图集冒充三维交付;如果评估后只能用二维表现,就在回复里说明限制与原因。其它边界不变:只改当前工程、构建通过后再试玩、不伪造成功、不读取或输出凭据。", + project.label() + )) + } + } +} + +/// 首页回合的三维提示:允许按既有规则创建项目,但提醒默认模板不是三维引擎。 +fn direct_engine_three_dimensional_home_note(prompt: &str) -> Option { + match direct_engine_intent_from_prompt(prompt)? { + DirectEngineIntent::Named(_) => None, + DirectEngineIntent::ThreeDimensional => Some( + "三维请求说明(首页):用户要做三维游戏。可以按既有规则创建项目;创建后由你自行选择三维技术栈(例如 Three.js / Babylon.js),不要因为默认模板是二维 Phaser 就只做等轴伪 3D。".to_string(), + ), + } +} + #[derive(Clone, Debug, Eq, PartialEq)] struct DirectTaonierArtAssetIdentity { project_id: String, @@ -2367,11 +2566,18 @@ fn direct_taonier_art_asset_identity( .to_string(), reference_resource_ids: asset.source.reference_resource_ids.clone(), }; + // 参考集合先按该 kind 的请求合同收口:根素材(规范图)允许用户参考(icon-spec 没有 + // 规范前置,参考只是风格输入),派生素材仍必须按合同携带规范前置。 + let references_match_contract = + crate::agent::platform_art_runtime_references_match_request_contract( + &identity.reference_resource_ids, + expected_kind, + ); let lineage_matches = match expected_reference_source { Some(source) => direct_taonier_reference_matches_local_source(root, source, &identity), - None => identity.reference_resource_ids.is_empty(), + None => true, }; - lineage_matches.then_some(identity) + (references_match_contract && lineage_matches).then_some(identity) }) } @@ -2380,7 +2586,9 @@ fn direct_taonier_reference_matches_local_source( source: &DirectTaonierArtAssetIdentity, derived: &DirectTaonierArtAssetIdentity, ) -> bool { - let [remote_reference_id] = derived.reference_resource_ids.as_slice() else { + // 派生素材的规范身份只由参考序列首项承担:用户参考按顺序追加在规范图之后, + // 不能让它们顶替或淹没规范引用,也不能因为多出用户参考就判定派生关系不成立。 + let Some(remote_reference_id) = derived.reference_resource_ids.first() else { return false; }; if derived.canvas_project_id == source.canvas_project_id @@ -3053,14 +3261,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at( )?; let _platform_session_lease = access .frozen_platform_session() - .map(|session| { - acquire_validated_platform_session_fingerprint( - &session.user_id, - &session.api_base_url, - session.generation, - &format!("{:x}", Sha256::digest(session.access_token.as_bytes())), - ) - }) + .map(|session| acquire_platform_session_identity_lease(&session.identity())) .transpose()?; // The network phase deliberately runs without the project write lock. Capture rollback state // only after acquiring the lock and revalidating the source identity, otherwise a failure can @@ -3190,10 +3391,17 @@ async fn generate_direct_taonier_art_asset_at( asset_kind: asset_kind.to_string(), asset_label: asset_label.to_string(), replace_existing: root.join(output_path).is_file(), - slice_count: None, - slice_mode: None, + // 标准美术包必须产出四张 canonical 切片:连通域模式下显式声明目标数量, + // 让平台要么给出四张,要么以可执行的 422 说明实际识别数量。 + slice_count: (asset_kind == "art-spritesheet").then_some(4), + // 切分模式没有默认值:陶泥儿标准美术包按自由排布生成核心图集,因此只在 + // art-spritesheet 阶段显式声明连通域切分。 + slice_mode: (asset_kind == "art-spritesheet").then(|| "connected-components".to_string()), grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, + screen_color: None, }; let runtime_context = direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?; @@ -4350,6 +4558,7 @@ fn build_direct_codex_system_prompt_with_search( "工作区边界:只在当前项目目录内工作;不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。遇到阻断必须说明具体原因、文件和下一步,不要声称未验证的成功。".to_string(), "AGC 工具授权边界:DirectProject 的 agc_tools 由当前客户端桥接到 AGC 后端,使用客户端已有登录会话和受控凭据完成授权。用户不需要、也不得向你提供、配置、粘贴或创建 API Key、Token、Cookie、URL 或 .env。工具返回 401/403 时,只说明 AGC 客户端登录或权限状态异常并停止,不要索要凭据、猜测外部 API,也不要暴露内部 URL。".to_string(), DIRECT_AGC_ENGINEERING_GUIDANCE.to_string(), + DIRECT_ENGINE_FREEDOM_GUIDANCE.to_string(), DIRECT_COCOS_BUILTIN_PLUGIN_GUIDANCE.to_string(), DIRECT_COCOS_CAPABILITY_GUIDE.to_string(), "工程执行要求:优先复用现有结构;按需读取真实文件,不依赖客户端预注入源码快照;修改后运行与改动相关的本地验证。工具返回 isError、构建失败、验证失败或试玩异常时,把错误当作调试上下文,读取当前项目、修复真实文件并重跑失败步骤,不要直接结束或伪造成功;鉴权、权限、余额、身份、历史、传输断开和操作状态不确定等安全错误才停止。不要创建 Supervisor、专业 Agent 或平行项目。".to_string(), @@ -4447,7 +4656,14 @@ pub(crate) async fn run_direct_game_creator_home_turn( attachments: &[DirectCodexTurnAttachment], ) -> Result { let user_prompt = render_direct_codex_user_prompt(prompt, attachments)?; - direct_game_creator_home_codex_chat(build_direct_codex_home_system_prompt(), user_prompt) + // 首页也只有这一轮对话:三维请求直接放行创建,但要提醒默认模板不是三维引擎。 + let engine_note = direct_engine_three_dimensional_home_note(prompt); + let base_system_prompt = build_direct_codex_home_system_prompt(); + let system_prompt = match engine_note.as_deref() { + Some(note) => format!("{note}\n{base_system_prompt}"), + None => base_system_prompt, + }; + direct_game_creator_home_codex_chat(system_prompt, user_prompt) .await .map(parse_direct_codex_home_reply) .map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320)) @@ -4725,10 +4941,20 @@ async fn run_direct_game_creator_turn_inner( DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) })?; let previous_output_fingerprint = direct_codex_output_fingerprint(root); - let system_prompt = build_direct_codex_system_prompt_with_creation_type(root, creation_type) - .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) - })?; + let base_system_prompt = + build_direct_codex_system_prompt_with_creation_type(root, creation_type).map_err( + |error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error), + )?; + // 三维请求:把"自选三维技术栈、解除 Phaser 固定约束"的合同放在系统提示最前, + // 避免被长度上限截断,也不阻断任何工具。 + let engine_contract = direct_engine_three_dimensional_contract(root, prompt); + let system_prompt = match engine_contract.as_deref() { + Some(contract) => format!("{contract}\n{base_system_prompt}") + .chars() + .take(MAX_DIRECT_SYSTEM_PROMPT_CHARS) + .collect(), + None => base_system_prompt, + }; let reply = if let Some(emitter) = turn_emitter { let client_turn_id = emitter.turn_id().to_string(); let emitter = emitter.clone(); @@ -5037,10 +5263,18 @@ where { emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息"); let previous_output_fingerprint = direct_codex_output_fingerprint(root); - let system_prompt = build_direct_codex_system_prompt_with_creation_type(root, creation_type) - .map_err(|error| { - DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) - })?; + let base_system_prompt = + build_direct_codex_system_prompt_with_creation_type(root, creation_type).map_err( + |error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error), + )?; + let engine_contract = direct_engine_three_dimensional_contract(root, prompt); + let system_prompt = match engine_contract.as_deref() { + Some(contract) => format!("{contract}\n{base_system_prompt}") + .chars() + .take(MAX_DIRECT_SYSTEM_PROMPT_CHARS) + .collect(), + None => base_system_prompt, + }; let reply = run_turn(system_prompt, prompt.to_string()) .await .map_err(|error| { @@ -5083,7 +5317,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) })?; if prepare_art { - system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实资源。请按需读取当前 cwd 的游戏源码;正式素材先用 `agc_list_registered_assets` 选择。如果发现项目中实际存在但清单没有的已识别图片、字体、音频、视频、文档或代码文件,先用 `agc_list_project_files` 发现,再把项目相对路径交给 `agc_import_account_assets.localPaths` 登记,随后重新读取 `agc_list_registered_assets`;不要从文件名伪造 assetId/localAssetId,也不要假设四切片一定存在或伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;请依据证据自行决定是否继续修复。"); + system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实资源。请按需读取当前 cwd 的游戏源码;正式素材先用 `agc_list_registered_assets` 选择。如果发现项目中实际存在但清单没有的已识别图片、字体、音频、视频、文档、代码或引擎资源(Cocos 的模型、动画、预制体、材质、图集、压缩纹理等),先用 `agc_list_project_files` 发现,再把项目相对路径交给 `agc_import_account_assets.localPaths` 登记,随后重新读取 `agc_list_registered_assets`;不要从文件名伪造 assetId/localAssetId,也不要假设四切片一定存在或伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;请依据证据自行决定是否继续修复。"); emit_direct_game_creator_progress(root, "codex.start", "美术素材已准备,正在生成游戏代码"); } else { system_prompt.push_str("\n这是已有游戏的继续编辑回合:不要生成、下载或请求任何新美术,也不要创建新项目。直接读取当前 cwd 的游戏源码,并按用户需求最小修改;随后通过 `agc_browser_playtest` 获取真实 desktop/mobile 浏览器证据。客户端会把结构化证据回灌同一会话。"); @@ -5329,6 +5563,8 @@ mod tests { fn direct_test_llm() -> GameCreatorLlmConfig { GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "fixture-secret".to_string(), base_url: "https://example.invalid/v1".to_string(), model: "fixture-model".to_string(), @@ -5756,6 +5992,7 @@ mod tests { assert!(prompt.contains("用户明确指定第三方 MCP Server 或工具时")); assert!(prompt.contains("先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明")); assert!(prompt.contains("用户明确指定 Cocos、Unity、Godot 或其它编辑器/引擎")); + assert!(prompt.contains("三维请求合同")); assert!(prompt.contains("Cocos 的编辑器能力来自客户端随包提供的内置插件")); assert!(prompt.contains("不要搜索、读取、安装、启用或建议项目目录里的 MCP 扩展")); assert!(prompt.contains("不得改为查项目扩展或要求用户打开 Cocos MCP 面板")); @@ -5771,6 +6008,115 @@ mod tests { assert!( prompt.contains("完整新游戏或根据策划案实现时必须执行 agc-game-production-workflow") ); + // Canvas 居中责任唯一的合同必须真的进到实际 system prompt:Phaser autoCenter 与 + // CSS 居中二选一,且要求实测中心误差与构建 dist 复验,避免再次出现居中偏移。 + assert!(prompt.contains("Phaser 画布居中责任唯一")); + assert!(prompt.contains("不得在同一个 canvas 父容器上叠加")); + assert!(prompt.contains("必须把 Phaser autoCenter 设为 NO_CENTER")); + assert!(prompt.contains("不得用修改 AGC iframe 偏移来掩盖")); + assert!(prompt.contains("中心误差不超过 1 CSS px")); + } + + #[test] + fn three_dimensional_game_request_frees_the_engine_choice() { + let root = direct_engine_test_root("web"); + let prompt = "帮我做个 3D 城市模拟游戏"; + assert_eq!( + direct_engine_intent_from_prompt(prompt), + Some(DirectEngineIntent::ThreeDimensional) + ); + let contract = + direct_engine_three_dimensional_contract(root.path(), prompt).expect("contract"); + // 三维请求不再固定 Phaser,也不要求先澄清引擎。 + assert!(contract.contains("不受")); + assert!(contract.contains("Phaser 4.2.1")); + assert!(contract.contains("不必先向用户确认引擎")); + assert!(contract.contains("Three.js")); + assert!(contract.contains("Babylon.js")); + assert!(contract.contains("Web(Phaser/Vite)工程")); + // 用户原文不被改写,也不产生任何副作用。 + assert!(!root.path().join("assets").exists()); + } + + #[test] + fn explicit_flat_presentation_requests_do_not_trigger_three_dimensional_selection() { + let root = direct_engine_test_root("web"); + for prompt in [ + "用等轴伪3D做城市表现就行", + "做成 2.5D 的,别用真 3D", + "把三维数组的这段代码重构一下", + "把 value4 这个字段改成 5", + "看看这个关卡为什么会卡", + ] { + assert!( + direct_engine_three_dimensional_contract(root.path(), prompt).is_none(), + "prompt should stay executable: {prompt}" + ); + } + } + + #[test] + fn named_engine_requests_keep_the_existing_engineering_rule() { + let root = direct_engine_test_root("web"); + // 点名引擎时不注入三维选型合同:工程不匹配的澄清规则由既有工程合同承担。 + assert!( + direct_engine_three_dimensional_contract(root.path(), "用 Unity 重做这个 3D 关卡") + .is_none() + ); + assert_eq!( + direct_engine_intent_from_prompt("用 Unity 重做这个 3D 关卡"), + Some(DirectEngineIntent::Named(DirectNamedEngine::Unity)) + ); + } + + #[test] + fn three_dimensional_contract_reports_the_current_project_engine() { + let cocos = direct_engine_test_root("cocos"); + assert_eq!( + direct_project_engine(cocos.path()), + DirectProjectEngine::CocosCreator + ); + let cocos_contract = + direct_engine_three_dimensional_contract(cocos.path(), "做个 3D 城市").expect("cocos"); + assert!(cocos_contract.contains("Cocos Creator")); + let web = direct_engine_test_root("web"); + let web_contract = + direct_engine_three_dimensional_contract(web.path(), "做个 3D 城市").expect("web"); + assert!(web_contract.contains("Web(Phaser/Vite)工程")); + } + + fn direct_engine_test_root(engine: &str) -> tempfile::TempDir { + let root = tempfile::tempdir().expect("temp dir"); + match engine { + "cocos" => { + std::fs::create_dir_all(root.path().join("assets")).expect("cocos assets"); + std::fs::write( + root.path().join("package.json"), + r#"{"creator":{"version":"3.8.8"}}"#, + ) + .expect("cocos package"); + } + "godot" => { + std::fs::write(root.path().join("project.godot"), "[application]\n") + .expect("godot project"); + } + "unity" => { + std::fs::create_dir_all(root.path().join("ProjectSettings")) + .expect("unity settings"); + std::fs::write( + root.path().join("ProjectSettings/ProjectVersion.txt"), + "m_EditorVersion: 6000.0.0f1\n", + ) + .expect("unity version"); + } + "web" => { + std::fs::create_dir_all(root.path().join("game")).expect("web game dir"); + std::fs::write(root.path().join("game/index.html"), "") + .expect("web entry"); + } + _ => {} + } + root } #[test] @@ -5838,6 +6184,19 @@ mod tests { assert!(prompt.chars().count() <= MAX_DIRECT_SYSTEM_PROMPT_CHARS); } + #[test] + fn home_three_dimensional_note_keeps_project_creation_available() { + let note = + direct_engine_three_dimensional_home_note("帮我做个 3D 城市游戏").expect("home note"); + assert!(note.contains("三维请求说明")); + assert!(note.contains("可以按既有规则创建项目")); + assert!(note.contains("Three.js")); + assert!(!note.contains(DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER)); + // 点名引擎与普通二维请求不加提示。 + assert!(direct_engine_three_dimensional_home_note("用 Unity 做 3D").is_none()); + assert!(direct_engine_three_dimensional_home_note("做个霓虹风格扫雷").is_none()); + } + #[test] fn home_prompt_has_no_project_or_side_effect_path_and_declares_the_only_creation_marker() { let prompt = build_direct_codex_home_system_prompt(); @@ -9462,6 +9821,78 @@ mod tests { ); } + #[test] + fn direct_taonier_art_package_accepts_manifest_user_references() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-art-references", "直连美术参考") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + assert!(direct_taonier_art_package_is_valid(root.path())); + + // 规范图与背景图带用户参考:参考只是风格输入,规范身份仍由参考序列首项承担。 + mutate_manifest_at(root.path(), |manifest| { + let art_spec = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == DIRECT_CODEX_ART_SPEC_ASSET_PATH) + .expect("art spec asset"); + art_spec.source.reference_resource_ids = vec![ + "user-reference-1".to_string(), + "user-reference-2".to_string(), + ]; + let background = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == DIRECT_CODEX_BACKGROUND_ASSET_PATH) + .expect("background asset"); + background + .source + .reference_resource_ids + .push("user-reference-1".to_string()); + Ok(()) + }) + .expect("apply user references to the art base"); + assert!( + direct_taonier_art_package_is_valid(root.path()), + "user references must not invalidate the art package" + ); + + // 图集仍只接受唯一规范引用:多一项用户参考必须失败关闭。 + mutate_manifest_at(root.path(), |manifest| { + let spritesheet = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == DIRECT_CODEX_SPRITESHEET_ASSET_PATH) + .expect("spritesheet asset"); + spritesheet + .source + .reference_resource_ids + .push("user-reference-1".to_string()); + Ok(()) + }) + .expect("add an extra spritesheet reference"); + assert!( + !direct_taonier_art_package_is_valid(root.path()), + "art spritesheet must reject extra user references" + ); + + // 用户参考不能顶替图集的规范前置。 + mutate_manifest_at(root.path(), |manifest| { + let spritesheet = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == DIRECT_CODEX_SPRITESHEET_ASSET_PATH) + .expect("spritesheet asset"); + spritesheet.source.reference_resource_ids = vec!["user-reference-1".to_string()]; + Ok(()) + }) + .expect("replace the spritesheet canonical reference"); + assert!( + !direct_taonier_art_package_is_valid(root.path()), + "a user reference must not replace the art spritesheet canonical spec" + ); + } + #[test] fn direct_output_sync_accepts_a_complete_spritesheet_without_slices() { let root = tempfile::tempdir().expect("temp dir"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index e3bc497ea..ee3cfcd41 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -4,12 +4,14 @@ //! 每个 subscriber 一个受保护的消费游标。它不理解前端 reducer,也不负责 JSONL //! 持久化;调用方必须在完成 item 持久化成功后再追加对应完成事件。 -use serde::{Deserialize, Serialize}; -use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::sync::{Mutex, OnceLock}; use uuid::Uuid; +use crate::agent::{ + DirectThreadConsumeResult, DirectThreadEvent, DirectThreadSubscriptionBootstrap, +}; + const DEFAULT_MAX_EVENTS: usize = 8_192; const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024; @@ -19,51 +21,11 @@ pub(crate) const DIRECT_THREAD_NOTIFY_EVENT: &str = "game-creator-direct-thread- static DIRECT_THREAD_MANAGER: OnceLock> = OnceLock::new(); static DIRECT_THREAD_MANAGER_APP_HANDLE: OnceLock = OnceLock::new(); -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectThreadRawEvent { - pub(crate) seq: u64, - #[serde(rename = "type")] - pub(crate) event_type: String, - pub(crate) turn_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) item_id: Option, - pub(crate) payload: Value, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct DirectThreadRawEventDraft { - pub(crate) event_type: String, - pub(crate) turn_id: String, - pub(crate) item_id: Option, - pub(crate) payload: Value, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectThreadSubscriptionBootstrap { - pub(crate) subscription_id: String, - pub(crate) last_completed_item_id: Option, - pub(crate) events: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectThreadConsumeResult { - pub(crate) events: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectThreadHistorySlice { - pub(crate) items: Vec, - pub(crate) has_more: bool, - pub(crate) item_timestamps: std::collections::BTreeMap, -} - +/// 队列里的一个事件。`seq` 只服务内部游标,不下发:前端按 `consume` 返回的数组顺序处理。 #[derive(Clone, Debug)] struct StoredEvent { - event: DirectThreadRawEvent, + event: DirectThreadEvent, + seq: u64, bytes: usize, cleanable: bool, } @@ -81,8 +43,14 @@ struct ThreadState { total_bytes: usize, active_items: HashSet, unresolved_requests: HashSet, - lifecycle_anchor: Option, - last_completed_item_id: Option, + /// 最近一条 `turn.started` / `turn.completed` 的独立拷贝。 + /// + /// TODO(thread-manager): 这里有意只保留"锚点",因为 replay 队列会回收可回收事件, + /// 队列本身不是完美事件日志——被回收的 `turn.started` / `turn.completed` 不会回放, + /// 只有这份拷贝保证新订阅仍能判定"最新回合是否还在跑"。若将来需要回放多个回合的 + /// 生命周期(回合账本、跨进程恢复、按回合统计),必须另建持久 ledger, + /// 不能靠扩大这份拷贝或放宽回收规则来模拟。 + lifecycle_anchor: Option<(u64, DirectThreadEvent)>, subscribers: HashMap, } @@ -96,7 +64,6 @@ impl Default for ThreadState { active_items: HashSet::new(), unresolved_requests: HashSet::new(), lifecycle_anchor: None, - last_completed_item_id: None, subscribers: HashMap::new(), } } @@ -131,33 +98,29 @@ impl DirectThreadManager { pub(crate) fn append( &mut self, thread_id: &str, - draft: DirectThreadRawEventDraft, - ) -> DirectThreadRawEvent { + event: DirectThreadEvent, + ) -> DirectThreadEvent { let thread = self.threads.entry(thread_id.to_string()).or_default(); thread.next_seq = thread.next_seq.saturating_add(1); - let event = DirectThreadRawEvent { - seq: thread.next_seq, - event_type: draft.event_type, - turn_id: draft.turn_id, - item_id: draft.item_id, - payload: draft.payload, - }; - let cleanable = Self::observe_event(thread, &event); + let seq = thread.next_seq; + let cleanable = Self::observe_event(thread, seq, &event); let bytes = serde_json::to_vec(&event) .map(|value| value.len()) .unwrap_or_default(); thread.total_bytes = thread.total_bytes.saturating_add(bytes); thread.events.push(StoredEvent { event: event.clone(), + seq, bytes, cleanable, }); - Self::mark_item_events_cleanable(thread, event.item_id.as_deref()); - if matches!( - event.event_type.as_str(), - "approval.resolved" | "request.resolved" | "ask.resolved" - ) { - Self::mark_request_events_cleanable(thread, request_id(&event).as_deref()); + if let Some(item_id) = event.item_id() { + Self::mark_item_events_cleanable(thread, item_id, seq); + } + if let Some(kind) = event.request_kind() { + if kind.is_resolution() { + Self::mark_request_events_cleanable(thread, event.request_id(), seq); + } } self.evict(thread_id); event @@ -175,19 +138,20 @@ impl DirectThreadManager { .events .iter() .skip(thread.head) - .filter(|stored| Self::is_bootstrap_event(thread, &stored.event, stored.cleanable)) - .map(|stored| stored.event.clone()) + .filter(|stored| Self::is_bootstrap_event(thread, stored)) + .map(|stored| (stored.seq, stored.event.clone())) .collect::>(); - if let Some(anchor) = thread.lifecycle_anchor.as_ref() { - if !events.iter().any(|event| event.seq == anchor.seq) { - events.push(anchor.clone()); + // 生命周期锚点独立保存:即使队列里那条事件已被回收,也要作为 bootstrap 事件返回。 + if let Some((anchor_seq, anchor)) = thread.lifecycle_anchor.as_ref() { + if !events.iter().any(|(seq, _)| seq == anchor_seq) { + events.push((*anchor_seq, anchor.clone())); } } - events.sort_by_key(|event| event.seq); + events.sort_by_key(|(seq, _)| *seq); DirectThreadSubscriptionBootstrap { subscription_id, - last_completed_item_id: thread.last_completed_item_id.clone(), - events, + last_completed_item_id: None, + events: events.into_iter().map(|(_, event)| event).collect(), } } @@ -217,26 +181,28 @@ impl DirectThreadManager { let oldest_seq = thread .events .get(thread.head) - .map(|stored| stored.event.seq) + .map(|stored| stored.seq) .unwrap_or(thread.next_seq.saturating_add(1)); if cursor.saturating_add(1) < oldest_seq { thread.subscribers.remove(subscription_id); return Err(SUBSCRIPTION_EXPIRED.to_string()); } + let mut last_seq = cursor; let events = thread .events .iter() .skip(thread.head) - .filter(|stored| stored.event.seq > cursor) - .map(|stored| stored.event.clone()) + .filter(|stored| stored.seq > cursor) + .map(|stored| { + last_seq = stored.seq; + stored.event.clone() + }) .collect::>(); - if let Some(last) = events.last() { - thread - .subscribers - .get_mut(subscription_id) - .expect("subscriber remains registered") - .cursor = last.seq; - } + thread + .subscribers + .get_mut(subscription_id) + .expect("subscriber remains registered") + .cursor = last_seq; let result = DirectThreadConsumeResult { events }; Self::trim_prefix(thread); Ok(result) @@ -253,86 +219,76 @@ impl DirectThreadManager { }) } - fn observe_event(thread: &mut ThreadState, event: &DirectThreadRawEvent) -> bool { - match event.event_type.as_str() { - "item.started" => { - if let Some(item_id) = event.item_id.as_deref() { - thread.active_items.insert(item_id.to_string()); - } + /// 观察一条事件,返回它本身是否可回收。 + fn observe_event(thread: &mut ThreadState, seq: u64, event: &DirectThreadEvent) -> bool { + match event { + DirectThreadEvent::ItemStarted { item, .. } => { + thread.active_items.insert(item.item_id().to_string()); false } - "item.completed" => { - if let Some(item_id) = event.item_id.as_deref() { - thread.active_items.remove(item_id); - thread.last_completed_item_id = Some(item_id.to_string()); + DirectThreadEvent::ItemCompleted { item, .. } => { + thread.active_items.remove(item.item_id()); + true + } + DirectThreadEvent::TurnStarted { .. } | DirectThreadEvent::TurnCompleted { .. } => { + // 队列只保留最新一条生命周期事件,更早的可能已被回收;见 + // `ThreadState::lifecycle_anchor` 的 TODO:这不是完整事件日志。 + thread.lifecycle_anchor = Some((seq, event.clone())); + true + } + DirectThreadEvent::Request { + kind, request_id, .. + } => { + if kind.is_request() { + if let Some(request_id) = request_id.as_deref() { + thread.unresolved_requests.insert(request_id.to_string()); + } + // 没有 request id 的请求事件无法配对,直接视为可回收。 + return request_id.is_none(); } - true - } - "turn.started" | "turn.completed" => { - thread.lifecycle_anchor = Some(event.clone()); - true - } - "approval.requested" | "request.requested" | "ask.requested" => { - let request_id = request_id(event); if let Some(request_id) = request_id.as_deref() { - thread.unresolved_requests.insert(request_id.to_string()); - } - request_id.is_none() - } - "approval.resolved" | "request.resolved" | "ask.resolved" => { - if let Some(request_id) = request_id(event) { - thread.unresolved_requests.remove(&request_id); + thread.unresolved_requests.remove(request_id); } true } - _ => true, + DirectThreadEvent::ItemDelta { .. } => true, } } - fn is_bootstrap_event( - thread: &ThreadState, - event: &DirectThreadRawEvent, - cleanable: bool, - ) -> bool { - if thread - .lifecycle_anchor - .as_ref() - .is_some_and(|anchor| anchor.seq == event.seq) - { - return true; - } - if let Some(item_id) = event.item_id.as_deref() { + /// 新订阅此刻需要补的事件:未完成 item 的完整事件、未解决请求、不可回收的事件。 + fn is_bootstrap_event(thread: &ThreadState, stored: &StoredEvent) -> bool { + if let Some(item_id) = stored.event.item_id() { return thread.active_items.contains(item_id); } - if let Some(request_id) = request_id(event) { - return thread.unresolved_requests.contains(&request_id); + if let Some(request_id) = stored.event.request_id() { + return thread.unresolved_requests.contains(request_id); } - !cleanable + // 生命周期锚点单独补,增量正文这类瞬时事件不回放。 + !stored.cleanable } - fn mark_item_events_cleanable(thread: &mut ThreadState, item_id: Option<&str>) { - let Some(item_id) = item_id else { - return; - }; + fn mark_item_events_cleanable(thread: &mut ThreadState, item_id: &str, seq: u64) { if thread.active_items.contains(item_id) { return; } for stored in &mut thread.events { - if stored.event.item_id.as_deref() == Some(item_id) { + if stored.seq <= seq && stored.event.item_id() == Some(item_id) { stored.cleanable = true; } } } - fn mark_request_events_cleanable(thread: &mut ThreadState, resolved_request_id: Option<&str>) { - let Some(resolved_request_id) = resolved_request_id else { + fn mark_request_events_cleanable(thread: &mut ThreadState, request_id: Option<&str>, seq: u64) { + let Some(resolved_request_id) = request_id else { return; }; for stored in &mut thread.events { - if matches!( - stored.event.event_type.as_str(), - "approval.requested" | "request.requested" | "ask.requested" - ) && request_id(&stored.event).as_deref() == Some(resolved_request_id) + if stored.seq <= seq + && stored + .event + .request_kind() + .is_some_and(|kind| kind.is_request()) + && stored.event.request_id() == Some(resolved_request_id) { stored.cleanable = true; } @@ -350,7 +306,7 @@ impl DirectThreadManager { let can_pop = thread .events .get(thread.head) - .is_some_and(|stored| stored.event.seq <= min_cursor && stored.cleanable); + .is_some_and(|stored| stored.seq <= min_cursor && stored.cleanable); if !can_pop { break; } @@ -390,7 +346,7 @@ impl DirectThreadManager { let oldest_seq = thread .events .get(thread.head) - .map(|stored| stored.event.seq) + .map(|stored| stored.seq) .unwrap_or(thread.next_seq.saturating_add(1)); let slowest = thread .subscribers @@ -419,13 +375,13 @@ pub(crate) fn set_direct_thread_manager_app_handle(app: tauri::AppHandle) { pub(crate) fn append_direct_thread_event( thread_id: &str, - draft: DirectThreadRawEventDraft, -) -> DirectThreadRawEvent { + event: DirectThreadEvent, +) -> DirectThreadEvent { let (event, subscriber_ids) = { let mut manager = global_direct_thread_manager() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - let event = manager.append(thread_id, draft); + let event = manager.append(thread_id, event); let subscriber_ids = manager.subscriber_ids(thread_id); (event, subscriber_ids) }; @@ -457,37 +413,52 @@ pub(crate) fn consume_direct_thread( .consume(subscription_id) } -fn request_id(event: &DirectThreadRawEvent) -> Option { - event - .payload - .get("requestId") - .and_then(Value::as_str) - .or_else(|| event.payload.get("id").and_then(Value::as_str)) - .filter(|value| !value.is_empty()) - .map(str::to_string) -} - #[cfg(test)] mod tests { use super::*; + use crate::agent::{DirectThreadDeltaKind, DirectThreadItem, DirectThreadRequestKind}; - fn draft(event_type: &str, turn_id: &str, item_id: Option<&str>) -> DirectThreadRawEventDraft { - DirectThreadRawEventDraft { - event_type: event_type.to_string(), - turn_id: turn_id.to_string(), - item_id: item_id.map(str::to_string), - payload: serde_json::json!({}), + fn message(item_id: &str) -> DirectThreadItem { + DirectThreadItem::Message { + item_id: item_id.to_string(), + role: "assistant".to_string(), + text: "内容".to_string(), + at: 0, } } + /// 事件级阶段时间只在重放稳定性用例里逐个指定;其余用例用一个固定值即可, + /// 它们断言的是队列 / 游标语义,不是时间本身。 + const FIXED_AT_MS: u64 = 1_000; + + fn item_started(item_id: &str) -> DirectThreadEvent { + DirectThreadEvent::item_started(message(item_id), FIXED_AT_MS) + } + + fn item_completed(item_id: &str) -> DirectThreadEvent { + DirectThreadEvent::item_completed(message(item_id), FIXED_AT_MS) + } + + fn item_delta(item_id: &str) -> DirectThreadEvent { + DirectThreadEvent::item_delta( + item_id.to_string(), + DirectThreadDeltaKind::Message, + "增量".to_string(), + ) + } + + fn request(kind: DirectThreadRequestKind, request_id: Option<&str>) -> DirectThreadEvent { + DirectThreadEvent::request(kind, request_id.map(str::to_string)) + } + #[test] fn subscribers_have_independent_cursors_on_one_global_queue() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", draft("turn.started", "turn-1", None)); + manager.append("thread-1", DirectThreadEvent::turn_started(FIXED_AT_MS)); let first = manager.subscribe("thread-1"); let second = manager.subscribe("thread-1"); - manager.append("thread-1", draft("item.started", "turn-1", Some("item-1"))); - manager.append("thread-1", draft("item.delta", "turn-1", Some("item-1"))); + manager.append("thread-1", item_started("item-1")); + manager.append("thread-1", item_delta("item-1")); let first_batch = manager .consume(&first.subscription_id) @@ -507,52 +478,49 @@ mod tests { #[test] fn bootstrap_contains_lifecycle_anchor_and_unfinished_events_only() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", draft("turn.started", "turn-1", None)); - manager.append("thread-1", draft("item.started", "turn-1", Some("item-1"))); - manager.append("thread-1", draft("item.delta", "turn-1", Some("item-1"))); - manager.append( - "thread-1", - draft("item.completed", "turn-1", Some("item-1")), - ); - manager.append("thread-1", draft("item.started", "turn-1", Some("item-2"))); + manager.append("thread-1", DirectThreadEvent::turn_started(FIXED_AT_MS)); + manager.append("thread-1", item_started("item-1")); + manager.append("thread-1", item_delta("item-1")); + manager.append("thread-1", item_completed("item-1")); + manager.append("thread-1", item_started("item-2")); let bootstrap = manager.subscribe("thread-1"); - assert_eq!(bootstrap.last_completed_item_id.as_deref(), Some("item-1")); - assert_eq!( - bootstrap - .events - .iter() - .map(|event| event.event_type.as_str()) - .collect::>(), - vec!["turn.started", "item.started"] - ); + assert!(matches!( + bootstrap.events.as_slice(), + [ + DirectThreadEvent::TurnStarted { .. }, + DirectThreadEvent::ItemStarted { item, .. }, + ] if item.item_id() == "item-2" + )); } #[test] fn completion_releases_item_events_only_after_the_completion_event_is_appended() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", draft("item.started", "turn-1", Some("item-1"))); - manager.append("thread-1", draft("item.delta", "turn-1", Some("item-1"))); + manager.append("thread-1", item_started("item-1")); + manager.append("thread-1", item_delta("item-1")); let bootstrap = manager.subscribe("thread-1"); - manager.append( - "thread-1", - draft("item.completed", "turn-1", Some("item-1")), - ); + manager.append("thread-1", item_completed("item-1")); let events = manager .consume(&bootstrap.subscription_id) .expect("consume completion") .events; - assert_eq!(events.len(), 1); - assert_eq!(events[0].event_type, "item.completed"); + assert!(matches!( + events.as_slice(), + [DirectThreadEvent::ItemCompleted { item, .. }] if item.item_id() == "item-1" + )); } #[test] fn slow_subscriber_is_expired_when_queue_limit_is_reached() { let mut manager = DirectThreadManager::with_limits(2, 100_000); let subscription = manager.subscribe("thread-1"); - manager.append("thread-1", draft("approval.resolved", "turn-1", None)); - manager.append("thread-1", draft("approval.resolved", "turn-1", None)); - manager.append("thread-1", draft("approval.resolved", "turn-1", None)); + for _ in 0..3 { + manager.append( + "thread-1", + request(DirectThreadRequestKind::RequestResolved, None), + ); + } assert_eq!( manager.consume(&subscription.subscription_id), Err(SUBSCRIPTION_EXPIRED.to_string()) @@ -562,11 +530,11 @@ mod tests { #[test] fn current_subscriber_is_not_expired_by_pinned_queue_head() { let mut manager = DirectThreadManager::with_limits(2, 100_000); - manager.append("thread-1", draft("item.started", "turn-1", Some("item-1"))); + manager.append("thread-1", item_started("item-1")); let subscription = manager.subscribe("thread-1"); - manager.append("thread-1", draft("item.delta", "turn-1", Some("item-1"))); - manager.append("thread-1", draft("item.delta", "turn-1", Some("item-1"))); - manager.append("thread-1", draft("item.delta", "turn-1", Some("item-1"))); + for _ in 0..3 { + manager.append("thread-1", item_delta("item-1")); + } assert_ne!( manager.consume(&subscription.subscription_id), Err(SUBSCRIPTION_EXPIRED.to_string()) @@ -578,23 +546,16 @@ mod tests { let mut manager = DirectThreadManager::with_limits(100, 100_000); manager.append( "thread-1", - DirectThreadRawEventDraft { - event_type: "approval.requested".to_string(), - turn_id: "turn-1".to_string(), - item_id: None, - payload: serde_json::json!({"requestId": "request-1"}), - }, + request( + DirectThreadRequestKind::ApprovalRequested, + Some("request-1"), + ), ); let bootstrap = manager.subscribe("thread-1"); assert_eq!(bootstrap.events.len(), 1); manager.append( "thread-1", - DirectThreadRawEventDraft { - event_type: "approval.resolved".to_string(), - turn_id: "turn-1".to_string(), - item_id: None, - payload: serde_json::json!({"requestId": "request-1"}), - }, + request(DirectThreadRequestKind::RequestResolved, Some("request-1")), ); assert_eq!( manager @@ -611,22 +572,15 @@ mod tests { let mut manager = DirectThreadManager::with_limits(100, 100_000); manager.append( "thread-1", - DirectThreadRawEventDraft { - event_type: "approval.requested".to_string(), - turn_id: "turn-1".to_string(), - item_id: None, - payload: serde_json::json!({"requestId": "request-1"}), - }, + request( + DirectThreadRequestKind::ApprovalRequested, + Some("request-1"), + ), ); let subscription = manager.subscribe("thread-1"); manager.append( "thread-1", - DirectThreadRawEventDraft { - event_type: "approval.resolved".to_string(), - turn_id: "turn-1".to_string(), - item_id: None, - payload: serde_json::json!({"requestId": "request-1"}), - }, + request(DirectThreadRequestKind::RequestResolved, Some("request-1")), ); manager .consume(&subscription.subscription_id) @@ -637,17 +591,128 @@ mod tests { #[test] fn turn_completed_anchor_survives_empty_queue_for_new_subscriber() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", draft("turn.completed", "turn-1", None)); + manager.append( + "thread-1", + DirectThreadEvent::turn_completed("completed".to_string(), FIXED_AT_MS), + ); let bootstrap = manager.subscribe("thread-1"); - assert_eq!(bootstrap.events.len(), 1); - assert_eq!(bootstrap.events[0].event_type, "turn.completed"); + assert!(matches!( + bootstrap.events.as_slice(), + [DirectThreadEvent::TurnCompleted { status, at, .. }] + if status == "completed" && *at == Some(FIXED_AT_MS) + )); + } + + /// 阶段时间必须随事件一起进队列:bootstrap 与重复订阅都拿到**原值**, + /// 重放不得重新取钟(否则每次重连都会把已固定的起止时间改掉)。 + #[test] + fn replayed_events_keep_their_original_stage_time() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + manager.append("thread-1", DirectThreadEvent::turn_started(1_000)); + manager.append( + "thread-1", + DirectThreadEvent::item_started(message("item-1"), 2_000), + ); + + let first = manager.subscribe("thread-1"); + assert_eq!( + first + .events + .iter() + .map(DirectThreadEvent::at) + .collect::>(), + vec![Some(1_000), Some(2_000)] + ); + + // 第二个订阅看到的是同一份事件,时间不因"又取了一次当前时间"而漂移。 + let second = manager.subscribe("thread-1"); + assert_eq!(second.events, first.events); + + manager.append( + "thread-1", + DirectThreadEvent::item_completed(message("item-1"), 3_000), + ); + let completion = manager + .consume(&first.subscription_id) + .expect("consume completion") + .events; + assert_eq!( + completion + .iter() + .map(DirectThreadEvent::at) + .collect::>(), + vec![Some(3_000)] + ); + // 重复消费不产生新事件,也不改写已下发过的时间。 + assert!(manager + .consume(&first.subscription_id) + .expect("empty consume") + .events + .is_empty()); + assert_eq!( + completion + .iter() + .map(DirectThreadEvent::at) + .collect::>(), + vec![Some(3_000)] + ); + } + + /// 生命周期锚点重放时必须带上开口用户条目身份:前端在「只有锚点 + 历史切片、运行态一直空」 + /// 的回合里也要能把边界认领给同一条用户条目,而不是按时间戳猜。 + #[test] + fn bootstrap_replays_opener_user_item_id() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + manager.append( + "thread-1", + DirectThreadEvent::turn_started(1_000) + .with_user_item_id(Some("direct-codex:turn-1:user")), + ); + let bootstrap = manager.subscribe("thread-1"); + assert_eq!( + bootstrap + .events + .iter() + .map(DirectThreadEvent::user_item_id) + .collect::>(), + vec![Some("direct-codex:turn-1:user")] + ); + assert_eq!(bootstrap.events[0].at(), Some(1_000)); + + // 锚点是独立保存的副本:队列里那条事件被回收之后,新订阅仍拿到同一个身份。 + manager + .consume(&bootstrap.subscription_id) + .expect("consume anchor"); + manager.append( + "thread-1", + DirectThreadEvent::item_completed(message("item-1"), 2_000), + ); + manager.append( + "thread-1", + DirectThreadEvent::turn_completed("completed".to_string(), 3_000) + .with_user_item_id(Some("direct-codex:turn-1:user")), + ); + let second = manager.subscribe("thread-1"); + assert_eq!( + second + .events + .iter() + .map(DirectThreadEvent::user_item_id) + .collect::>(), + vec![Some("direct-codex:turn-1:user")], + "起止同源:终态锚点也带同一个开口用户条目身份" + ); + assert_eq!(second.events[0].at(), Some(3_000)); } #[test] fn queue_cleanup_only_removes_a_cleanable_prefix() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", draft("item.started", "turn-1", Some("item-1"))); - manager.append("thread-1", draft("approval.resolved", "turn-1", None)); + manager.append("thread-1", item_started("item-1")); + manager.append( + "thread-1", + request(DirectThreadRequestKind::RequestResolved, None), + ); let subscription = manager.subscribe("thread-1"); manager .consume(&subscription.subscription_id) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs new file mode 100644 index 000000000..d5a874c76 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs @@ -0,0 +1,1354 @@ +//! DirectProject 聊天事件的线上模型与投影。 +//! +//! 前端消费的类型由 ts-rs 导出到 `src/features/project-workspace/generated/`, +//! 与 Rust 定义同源:加一个字段不会只改一边。 +//! +//! 本模块只做三件事:挑字段、脱敏、截断。工具卡片的 `kind`、标题、折叠摘要、可见性与 +//! 合并规则全部属于前端投影,这里一概不出现。 +//! +//! 条目身份在进队列前就归一成**一个** `itemId`:原始文件里工具条目带两个 id(app-server +//! 的调用 id 与 response item id,调用与输出共用前者),归一只在 Rust 边界做一次, +//! Thread Manager 与前端都只认这一个,不暴露第二个 id 概念。 +//! 历史分页锚点是另一回事,那是文件里的原始 item id,单独取。 + +use crate::agent::redact_secret_tokens; +use crate::agent::sanitize_error_context; +use crate::redact_absolute_path_tokens; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::Path; +use ts_rs::TS; + +/// 正文(消息 / 思考)上限。 +const DIRECT_THREAD_TEXT_MAX_CHARS: usize = 8_000; +/// 工具明细(命令 / 参数 / 输出)上限。 +const DIRECT_THREAD_DETAIL_MAX_CHARS: usize = 4_000; +/// 单条变更路径上限。 +const DIRECT_THREAD_PATH_MAX_CHARS: usize = 300; + +/// 一条文件变更。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectThreadFileChange { + pub(crate) path: String, + /// `add` | `update` | `delete` + pub(crate) kind: String, +} + +/// 聊天视图的输入条目:一条 Codex 原始条目的脱敏投影。 +/// +/// `itemType` 就是 Codex 的原始类型,逐字透传;前端按它决定投影成消息、思考还是工具卡片。 +/// 未识别的类型走 [`DirectThreadItem::Other`],Rust 不替前端决定它是否可见。 +/// +/// 条目上的 `at` 是只用于显示的毫秒时间戳:ts-rs 默认把 `u64` 映射成 `bigint`, +/// 而 Tauri 的 JSON 通道传过来的是 `number`,因此统一标 `#[ts(as = "f64")]` 对齐。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "itemType", rename_all_fields = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectThreadItem { + #[serde(rename = "message")] + Message { + /// 归一身份:全链路只有这一个 id。 + item_id: String, + /// 原始 role(`user` / `assistant` / `system` / …);显示与否由前端判断。 + role: String, + text: String, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "reasoning")] + Reasoning { + item_id: String, + text: String, + #[ts(as = "f64")] + at: u64, + }, + /// 原始 response item 的工具调用:参数在 `arguments`,输出在后续的 + /// [`DirectThreadItem::FunctionCallOutput`](两者共用归一身份)。 + #[serde(rename = "function_call")] + FunctionCall { + item_id: String, + name: String, + arguments: String, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "function_call_output")] + FunctionCallOutput { + item_id: String, + output: String, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "commandExecution")] + CommandExecution { + item_id: String, + command: String, + #[serde(default)] + output: Option, + /// app-server 原始状态:`inProgress` / `completed` / `failed` / `declined` / … + #[serde(default)] + status: Option, + #[serde(default)] + #[ts(as = "Option")] + exit_code: Option, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "fileChange")] + FileChange { + item_id: String, + changes: Vec, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "mcpToolCall")] + McpToolCall { + item_id: String, + tool: String, + arguments: String, + #[serde(default)] + output: Option, + #[serde(default)] + status: Option, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "webSearch")] + WebSearch { + item_id: String, + #[serde(default)] + query: Option, + #[serde(default)] + output: Option, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "contextCompaction")] + ContextCompaction { + item_id: String, + #[ts(as = "f64")] + at: u64, + }, + /// 未识别的 Codex item 类型:原样透传身份与类型,不投影正文。 + #[serde(rename = "other")] + Other { + item_id: String, + raw_type: String, + #[ts(as = "f64")] + at: u64, + }, +} + +impl DirectThreadItem { + /// 归一身份:Thread Manager 用它登记与释放未完成条目,前端用它合并同一张卡片。 + pub(crate) fn item_id(&self) -> &str { + match self { + Self::Message { item_id, .. } + | Self::Reasoning { item_id, .. } + | Self::FunctionCall { item_id, .. } + | Self::FunctionCallOutput { item_id, .. } + | Self::CommandExecution { item_id, .. } + | Self::FileChange { item_id, .. } + | Self::McpToolCall { item_id, .. } + | Self::WebSearch { item_id, .. } + | Self::ContextCompaction { item_id, .. } + | Self::Other { item_id, .. } => item_id, + } + } + + /// 条目展示时间(毫秒)。只用于条目自身的展示,不能当工具的开始 / 完成边界; + /// 那两类边界用事件级 `at`。 + pub(crate) fn at(&self) -> u64 { + match self { + Self::Message { at, .. } + | Self::Reasoning { at, .. } + | Self::FunctionCall { at, .. } + | Self::FunctionCallOutput { at, .. } + | Self::CommandExecution { at, .. } + | Self::FileChange { at, .. } + | Self::McpToolCall { at, .. } + | Self::WebSearch { at, .. } + | Self::ContextCompaction { at, .. } + | Self::Other { at, .. } => *at, + } + } +} + +/// 增量正文属于哪类条目。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectThreadDeltaKind { + /// assistant 正文。 + Message, + /// 思考正文。 + Reasoning, +} + +/// 审批 / 提问请求与解决:本轮只透传,不并入聊天状态。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectThreadRequestKind { + #[serde(rename = "approval.requested")] + ApprovalRequested, + #[serde(rename = "ask.requested")] + AskRequested, + #[serde(rename = "request.resolved")] + RequestResolved, +} + +impl DirectThreadRequestKind { + /// 未解决的请求要留在 bootstrap 里,直到出现对应的解决事件。 + pub(crate) fn is_request(&self) -> bool { + matches!(self, Self::ApprovalRequested | Self::AskRequested) + } + + pub(crate) fn is_resolution(&self) -> bool { + !self.is_request() + } +} + +/// Thread Manager 下发的运行态事件。 +/// +/// 顺序由数组顺序给出(同一个 subscriber 的 `consume` 按队列顺序返回),因此不需要 `seq`: +/// 游标是 Thread Manager 的内部事实,不下发。 +/// +/// 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,"当前回合是否还在跑"由 +/// 生命周期事件在序列中的位置给出,`turn_id` 对前端没有任何额外信息。 +/// +/// 四种生命周期事件(`turn.started` / `turn.completed` / `item.started` / `item.completed`) +/// 额外带事件级 `at`:它是**该阶段本身**的发生时间(毫秒),不是条目展示时间。条目上的 +/// `item.at` 只说明"这条条目什么时候被看到",工具计时不得拿它当开始或完成边界。 +/// 条目阶段优先用通知层的毫秒字段(`startedAtMs` / `completedAtMs`),缺失才用宿主钟; +/// 回合阶段没有可用的毫秒上游字段(Turn 只有秒级 `startedAt` / `completedAt`),一律用宿主 +/// 在该阶段取的毫秒钟——见 `direct_thread_turn_completed_at_ms` 的说明。 +/// `at` 在事件进入 Thread Manager 时就固定:重放(bootstrap / consume)必须沿用原值, +/// 不能在前端收到或重放时重新取当前时间。 +/// +/// `turn.started` / `turn.completed` 额外带可选的 `userItemId`:本轮开口用户条目的 **canonical +/// itemId**(与同轮那条用户条目事件同源,由原生从已落盘条目上读取,不另造身份)。回合事件本身 +/// 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点 +/// + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明 +/// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectThreadEvent { + #[serde(rename = "turn.started")] + TurnStarted { + /// 本轮开始的阶段时间(毫秒):宿主处理 `turn/start` 的毫秒钟。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + /// 本轮开口用户条目的 canonical itemId;缺失表示身份不可证明。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + user_item_id: Option, + }, + #[serde(rename = "turn.completed")] + TurnCompleted { + status: String, + /// 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + /// 本轮开口用户条目的 canonical itemId:与同一轮的 `turn.started` 同源;缺失表示不可证明。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + user_item_id: Option, + }, + #[serde(rename = "item.started")] + ItemStarted { + item: DirectThreadItem, + /// 条目开始执行的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + }, + #[serde(rename = "item.completed")] + ItemCompleted { + item: DirectThreadItem, + /// 条目结束的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + }, + #[serde(rename = "item.delta")] + ItemDelta { + item_id: String, + kind: DirectThreadDeltaKind, + delta: String, + }, + #[serde(rename = "request")] + Request { + kind: DirectThreadRequestKind, + #[serde(default)] + request_id: Option, + }, +} + +impl DirectThreadEvent { + pub(crate) fn turn_started(at: u64) -> Self { + Self::TurnStarted { + at: Some(at), + user_item_id: None, + } + } + + pub(crate) fn turn_completed(status: String, at: u64) -> Self { + Self::TurnCompleted { + status, + at: Some(at), + user_item_id: None, + } + } + + /// 附上本轮开口用户条目的 canonical itemId。 + /// + /// 只在构造之后补一次身份,避免 `turn.started` / `turn.completed` 的既有调用点(含各处兜底 + /// 终态)全部改签名。空串按缺失处理:宁可让前端隐藏未知用时,也不写一个假身份。 + pub(crate) fn with_user_item_id(self, user_item_id: Option<&str>) -> Self { + let user_item_id = user_item_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + match self { + Self::TurnStarted { at, .. } => Self::TurnStarted { at, user_item_id }, + Self::TurnCompleted { status, at, .. } => Self::TurnCompleted { + status, + at, + user_item_id, + }, + other => other, + } + } + + /// 本轮开口用户条目的 canonical itemId:只有生命周期事件有,其余返回 `None`。 + /// + /// 只读已存入事件的值,不在读取时重算——重放要用的就是原事件的身份。 + pub(crate) fn user_item_id(&self) -> Option<&str> { + match self { + Self::TurnStarted { user_item_id, .. } | Self::TurnCompleted { user_item_id, .. } => { + user_item_id.as_deref() + } + _ => None, + } + } + + pub(crate) fn item_started(item: DirectThreadItem, at: u64) -> Self { + Self::ItemStarted { item, at: Some(at) } + } + + pub(crate) fn item_completed(item: DirectThreadItem, at: u64) -> Self { + Self::ItemCompleted { item, at: Some(at) } + } + + pub(crate) fn item_delta(item_id: String, kind: DirectThreadDeltaKind, delta: String) -> Self { + Self::ItemDelta { + item_id, + kind, + delta, + } + } + + pub(crate) fn request(kind: DirectThreadRequestKind, request_id: Option) -> Self { + Self::Request { kind, request_id } + } + + /// 事件级阶段时间(毫秒):只有四种生命周期事件有,其余事件返回 `None`。 + /// + /// 只读已存入事件的值,不在读取时取钟——重放要用的就是原事件的时间。 + pub(crate) fn at(&self) -> Option { + match self { + Self::TurnStarted { at, .. } + | Self::TurnCompleted { at, .. } + | Self::ItemStarted { at, .. } + | Self::ItemCompleted { at, .. } => *at, + Self::ItemDelta { .. } | Self::Request { .. } => None, + } + } + + /// 事件关联的条目身份:只有 item 事件有。 + pub(crate) fn item_id(&self) -> Option<&str> { + match self { + Self::ItemStarted { item, .. } | Self::ItemCompleted { item, .. } => { + Some(item.item_id()) + } + _ => None, + } + } + + pub(crate) fn request_id(&self) -> Option<&str> { + match self { + Self::Request { request_id, .. } => request_id.as_deref(), + _ => None, + } + } + + pub(crate) fn request_kind(&self) -> Option { + match self { + Self::Request { kind, .. } => Some(*kind), + _ => None, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectThreadSubscriptionBootstrap { + pub(crate) subscription_id: String, + /// 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。 + #[serde(default)] + pub(crate) last_completed_item_id: Option, + /// 该 subscriber 此刻应当处理的运行态事件(游标已经在队尾)。 + pub(crate) events: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectThreadConsumeResult { + pub(crate) events: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectThreadHistorySlice { + /// 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。 + pub(crate) items: Vec, + pub(crate) has_more: bool, + /// 本次切片的原始 item id 锚点:无论切片里有没有可显示条目,分页都靠它向前。 + #[serde(default)] + pub(crate) first_item_id: Option, +} + +fn bounded(value: &str, max_chars: usize) -> String { + if value.chars().count() <= max_chars { + return value.to_string(); + } + let mut truncated = value.chars().take(max_chars).collect::(); + truncated.push('…'); + truncated +} + +fn detail_text(root: &Path, value: &str) -> String { + bounded( + &sanitize_detail_text(root, value), + DIRECT_THREAD_DETAIL_MAX_CHARS, + ) +} + +/// 原始值转文本:字符串原样,其它 JSON 值序列化(调用方随后脱敏)。 +fn value_text(value: &Value) -> Option { + match value { + Value::Null => None, + Value::String(text) => (!text.trim().is_empty()).then(|| text.trim().to_string()), + other => serde_json::to_string_pretty(other).ok(), + } +} + +fn item_text(root: &Path, item: &Value) -> Option { + let raw = item + .get("text") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + for key in ["content", "summary"] { + let Some(parts) = item.get(key).and_then(Value::as_array) else { + continue; + }; + let joined = parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join(""); + if !joined.trim().is_empty() { + return Some(joined); + } + } + None + }) + .filter(|text| !text.trim().is_empty())?; + Some(bounded( + &sanitize_detail_text(root, &raw), + DIRECT_THREAD_TEXT_MAX_CHARS, + )) +} + +/// 流式正文与完成态条目使用同一套脱敏和字符预算,避免增量文本绕过历史投影的安全边界。 +pub(crate) fn direct_thread_delta_text(root: &Path, value: &str) -> String { + bounded( + &sanitize_detail_text(root, value), + DIRECT_THREAD_TEXT_MAX_CHARS, + ) +} + +fn item_at_ms(item: &Value, observed_at_ms: u64) -> u64 { + let from_metadata = item + .get("internal_chat_message_metadata_passthrough") + .and_then(|meta| meta.get("create_time")) + .and_then(Value::as_f64) + .map(|seconds| (seconds * 1000.0).clamp(0.0, u64::MAX as f64) as u64) + .unwrap_or_default(); + if from_metadata > 0 { + return from_metadata; + } + for key in ["startedAtMs", "completedAtMs"] { + let value = item.get(key).and_then(Value::as_u64).unwrap_or_default(); + if value > 0 { + return value; + } + } + observed_at_ms +} + +/// 原生毫秒时间戳:0(协议里的"缺省")与非法值一样按缺失处理。 +fn json_ms(container: &Value, key: &str) -> Option { + container + .get(key) + .and_then(Value::as_u64) + .filter(|value| *value > 0) +} + +/// `item/started` / `item/completed` 的事件级阶段时间(毫秒)。 +/// +/// 字段位置按当前 app-server 协议:通知层带 `params.startedAtMs` / `params.completedAtMs`, +/// 条目自带时用条目里的同名毫秒字段(`direct_tool_calls` 读的是同一处)。完成事件即使同时 +/// 带着开始字段也只取**完成**时间;两者都没有、但 `durationMs` 有可靠起点时按 +/// 起点 + 时长派生结束。都没有就用宿主处理该事件的钟——原生缺阶段时间时这是唯一诚实的值。 +pub(crate) fn direct_thread_item_event_at_ms( + params: &Value, + item: &Value, + completed: bool, + observed_at_ms: u64, +) -> u64 { + let started_ms = || json_ms(params, "startedAtMs").or_else(|| json_ms(item, "startedAtMs")); + if !completed { + return started_ms().unwrap_or(observed_at_ms); + } + if let Some(at) = json_ms(params, "completedAtMs").or_else(|| json_ms(item, "completedAtMs")) { + return at; + } + let duration_ms = json_ms(params, "durationMs").or_else(|| json_ms(item, "durationMs")); + match (started_ms(), duration_ms) { + (Some(started), Some(duration)) => started.saturating_add(duration), + _ => observed_at_ms, + } +} + +/// `turn.completed` 的事件级阶段时间(毫秒)。 +/// +/// Turn 里的 `startedAt` / `completedAt` 是 Unix **秒**(协议 `format: int64`,字段名不带 +/// `Ms` 的都是秒),而 `durationMs` 才是毫秒。秒级截断在这里是不能用的:它既撑不起前端 +/// 0.1 秒粒度的展示(显示出来的小数位是假精度),也可能让"完成时刻"落进该轮用户消息所在的 +/// 同一秒、落在用户真实发送时间之前,前端按"结束早于开始"判成无效边界,于是一轮新回合被 +/// 整轮吞掉。因此这里不采用任何秒字段: +/// - 只有 `durationMs` 与**高精度起点**都可靠时才按 `起点 + 时长` 派生结束; +/// - 否则取宿主处理终态的钟,语义与条目侧"没有原生阶段时间就用宿主钟"完全一致。 +/// +/// `high_precision_started_at_ms` 是本轮开始时宿主记下的那个毫秒起点(即 `turn.started` +/// 事件写入的同一个值),不是从上游秒字段换算出来的,`None` 表示起点也不可证明。 +pub(crate) fn direct_thread_turn_completed_at_ms( + turn: &Value, + high_precision_started_at_ms: Option, + observed_at_ms: u64, +) -> u64 { + match (high_precision_started_at_ms, json_ms(turn, "durationMs")) { + (Some(started), Some(duration)) => started.saturating_add(duration), + _ => observed_at_ms, + } +} + +/// 归一身份:工具条目用工具调用 id,其它条目用自己的 `id`;只产出这一个值。 +pub(crate) fn direct_thread_item_identity(item: &Value) -> Option { + let call_id = item + .get("call_id") + .or_else(|| item.get("callId")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let id = item + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + call_id.or(id) +} + +fn item_changes(root: &Path, item: &Value) -> Vec { + item.get("changes") + .and_then(Value::as_array) + .map(|changes| { + changes + .iter() + .filter_map(|change| { + let path = change + .get("path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|path| !path.is_empty())?; + Some(DirectThreadFileChange { + path: bounded( + &sanitize_detail_text(root, path), + DIRECT_THREAD_PATH_MAX_CHARS, + ), + kind: change + .get("kind") + .and_then(Value::as_str) + .unwrap_or("update") + .to_string(), + }) + }) + .collect::>() + }) + .unwrap_or_default() +} + +fn field_text(root: &Path, item: &Value, key: &str) -> Option { + item.get(key) + .and_then(value_text) + .map(|value| detail_text(root, &value)) +} + +/// 把一条 Codex 原始条目投影成线上条目;拿不到身份或类型时返回 `None`。 +/// +/// `observed_at_ms` 只在条目自带时间缺失时兜底(运行态用当前时间,历史用文件记录时间)。 +pub(crate) fn direct_thread_item_from_value( + root: &Path, + item: &Value, + observed_at_ms: u64, +) -> Option { + if !item.is_object() { + return None; + } + let item_id = direct_thread_item_identity(item)?; + let item_type = item + .get("type") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let at = item_at_ms(item, observed_at_ms); + let text = item_text(root, item); + let role = item + .get("role") + .and_then(Value::as_str) + .map(str::trim) + .filter(|role| !role.is_empty()) + .map(str::to_string); + + Some(match item_type { + "message" | "agentMessage" | "userMessage" => DirectThreadItem::Message { + item_id, + role: role.unwrap_or_else(|| { + if item_type == "userMessage" { + "user".to_string() + } else { + "assistant".to_string() + } + }), + text: text?, + at, + }, + "reasoning" => DirectThreadItem::Reasoning { + item_id, + text: text?, + at, + }, + "function_call" => DirectThreadItem::FunctionCall { + item_id, + name: item + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + arguments: field_text(root, item, "arguments").unwrap_or_default(), + at, + }, + "function_call_output" => DirectThreadItem::FunctionCallOutput { + item_id, + output: field_text(root, item, "output").unwrap_or_default(), + at, + }, + "commandExecution" => DirectThreadItem::CommandExecution { + item_id, + command: item + .get("command") + .and_then(Value::as_str) + .map(str::trim) + .filter(|command| !command.is_empty()) + .map(|command| detail_text(root, command)) + .unwrap_or_default(), + output: ["aggregatedOutput", "output", "error"] + .iter() + .find_map(|key| field_text(root, item, key)), + status: item + .get("status") + .and_then(Value::as_str) + .map(str::to_string), + exit_code: item.get("exitCode").and_then(Value::as_i64), + at, + }, + "fileChange" => DirectThreadItem::FileChange { + item_id, + changes: item_changes(root, item), + at, + }, + "mcpToolCall" => DirectThreadItem::McpToolCall { + item_id, + tool: item + .get("tool") + .and_then(Value::as_str) + .map(str::trim) + .filter(|tool| !tool.is_empty()) + .map(|tool| sanitize_detail_text(root, tool)) + .unwrap_or_default(), + arguments: field_text(root, item, "arguments").unwrap_or_default(), + output: ["result", "error"] + .iter() + .find_map(|key| field_text(root, item, key)), + status: item + .get("status") + .and_then(Value::as_str) + .map(str::to_string), + at, + }, + "webSearch" => DirectThreadItem::WebSearch { + item_id, + query: field_text(root, item, "query").or_else(|| { + item.get("action") + .and_then(|action| action.get("query")) + .and_then(value_text) + .map(|query| detail_text(root, &query)) + }), + output: field_text(root, item, "output"), + at, + }, + "contextCompaction" => DirectThreadItem::ContextCompaction { item_id, at }, + other => DirectThreadItem::Other { + item_id, + raw_type: other.to_string(), + at, + }, + }) +} + +/// 历史切片投影:保持文件顺序,不做任何合并(同一调用的调用与输出是两条条目)。 +/// +/// `timestamp_of` 是文件记录时间,仅在条目自带时间缺失时兜底。 +pub(crate) fn direct_thread_items_from_history( + root: &Path, + items: &[Value], + timestamp_of: impl Fn(&Value) -> u64, +) -> Vec { + items + .iter() + .filter_map(|item| direct_thread_item_from_value(root, item, timestamp_of(item))) + .collect() +} + +/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。 +fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) { + let mut index = start; + let mut relative = String::new(); + while index < value.len() { + let character = value[index..].chars().next().unwrap_or_default(); + if matches!(character, '/' | '\\') { + if !relative.is_empty() { + relative.push('/'); + } + index += character.len_utf8(); + continue; + } + if character.is_whitespace() + || matches!( + character, + '\'' | '"' + | '`' + | ',' + | ';' + | '|' + | '&' + | '(' + | ')' + | '[' + | ']' + | '{' + | '}' + | '<' + | '>' + | ':' + ) + { + break; + } + relative.push(character); + index += character.len_utf8(); + } + while relative.ends_with('/') { + relative.pop(); + } + (index, relative) +} + +/// 把项目根目录前缀换成**项目相对路径**(`/game/src/x.ts` → `game/src/x.ts`)。 +/// +/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成 +/// ``,之后就再也认不出哪些路径在项目内了。 +/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。 +fn relativize_project_root_paths(root: &Path, value: &str) -> String { + let root_text = root.to_string_lossy(); + let root_text = root_text.trim_end_matches(['/', '\\']); + if root_text.is_empty() { + return value.to_string(); + } + let mut needles = [ + root_text.to_string(), + root_text.replace('\\', "/"), + root_text.replace('/', "\\"), + ] + .into_iter() + .map(|needle| needle.to_ascii_lowercase()) + .filter(|needle| !needle.is_empty()) + .collect::>(); + needles.sort(); + needles.dedup(); + let lower = value.to_ascii_lowercase(); + + let mut output = String::with_capacity(value.len()); + let mut cursor = 0usize; + while cursor < value.len() { + let mut hit: Option<(usize, usize)> = None; + for needle in &needles { + let mut search = cursor; + while let Some(relative) = lower[search..].find(needle.as_str()) { + let start = search + relative; + let end = start + needle.len(); + let left_is_boundary = start == 0 + || lower[..start].chars().next_back().is_some_and(|character| { + !character.is_alphanumeric() && character != '_' && character != '-' + }); + if left_is_boundary && value[end..].starts_with(['/', '\\']) { + if hit.is_none_or(|(best_start, _)| start < best_start) { + hit = Some((start, end)); + } + break; + } + search = end; + } + } + let Some((start, end)) = hit else { + break; + }; + output.push_str(&value[cursor..start]); + let (consumed, relative) = project_relative_path_segment(value, end); + if relative.is_empty() { + // 只写了项目根目录本身(没有后续路径段):按占位形状处理。 + output.push_str(""); + } else { + output.push_str(&relative); + } + cursor = consumed; + } + output.push_str(&value[cursor..]); + output +} + +/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与 +/// 错误上下文脱敏。 +/// +/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录 +/// 仍然会留下;这里先归一化路径 token,再处理密钥。 +pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String { + let without_project_root = relativize_project_root_paths(root, value); + let without_absolute = redact_absolute_path_tokens(&without_project_root); + let without_secret = redact_secret_tokens(&without_absolute); + sanitize_error_context(&without_secret) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::path::Path; + + fn root() -> &'static Path { + Path::new(".") + } + + #[test] + fn message_item_carries_role_text_and_turn() { + let item = direct_thread_item_from_value( + root(), + &json!({ + "id": "direct-codex:turn-1:user", + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "做一个拼图游戏"}], + }), + 0, + ) + .expect("user item"); + assert_eq!( + item, + DirectThreadItem::Message { + item_id: "direct-codex:turn-1:user".to_string(), + role: "user".to_string(), + text: "做一个拼图游戏".to_string(), + at: 0, + } + ); + } + + #[test] + fn app_server_agent_message_defaults_to_assistant_role() { + let item = direct_thread_item_from_value( + root(), + &json!({"id": "msg-1", "type": "agentMessage", "text": "已执行"}), + 1000, + ) + .expect("agent message"); + assert!(matches!( + item, + DirectThreadItem::Message { role, .. } if role == "assistant" + )); + } + + #[test] + fn tool_item_identity_is_normalized_to_one_id() { + let item = direct_thread_item_from_value( + root(), + &json!({ + "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\": \"ls\"}", + "internal_chat_message_metadata_passthrough": {"turn_id": "turn-1"}, + }), + 1000, + ) + .expect("tool item"); + // 只有唯一身份:工具条目在文件里的另一个 id 不再对外暴露。 + assert_eq!(item.item_id(), "call_00_Gpd0s0Ytm9YgIbwbEXva1473"); + assert!(matches!( + item, + DirectThreadItem::FunctionCall { name, arguments, .. } + if name == "exec_command" && arguments == "{\"cmd\": \"ls\"}" + )); + } + + #[test] + fn command_execution_keeps_raw_status_and_exit_code() { + let item = direct_thread_item_from_value( + root(), + &json!({ + "id": "call-1", + "type": "commandExecution", + "command": "ls", + "status": "failed", + "exitCode": 2, + "aggregatedOutput": "boom", + }), + 0, + ) + .expect("command item"); + assert!(matches!( + item, + DirectThreadItem::CommandExecution { + command, + output: Some(output), + status: Some(status), + exit_code: Some(2), + .. + } if command == "ls" && output == "boom" && status == "failed" + )); + } + + #[test] + fn secrets_and_absolute_paths_are_not_leaked() { + let item = direct_thread_item_from_value( + root(), + &json!({ + "id": "msg-1", + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "key=sk-abcdefghijklmnop at /root/secret/x"}], + }), + 0, + ) + .expect("assistant item"); + let DirectThreadItem::Message { text, .. } = item else { + panic!("message item"); + }; + assert!( + !text.contains("sk-abcdefghijklmnop"), + "不得泄漏明文密钥:{text}" + ); + assert!(!text.contains("/root/secret"), "不得泄漏绝对路径:{text}"); + } + + #[test] + fn history_keeps_call_and_output_as_two_items_with_one_identity() { + let items = vec![ + json!({ + "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\": \"ls\"}", + }), + json!({ + "id": "fco_01a06fa5-d636-7452-b337-a641c2e6bc76", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call_output", + "output": "assets\ngame\n", + }), + ]; + let projected = direct_thread_items_from_history(root(), &items, |_| 0); + assert_eq!(projected.len(), 2, "搬运层不得替前端做合并"); + assert!(matches!( + projected[0], + DirectThreadItem::FunctionCall { .. } + )); + assert!(matches!( + projected[1], + DirectThreadItem::FunctionCallOutput { .. } + )); + // 调用与输出共享同一个归一身份,前端才能把它们并成一张卡片。 + assert_eq!(projected[0].item_id(), projected[1].item_id()); + assert_eq!(projected[0].item_id(), "call_00_Gpd0s0Ytm9YgIbwbEXva1473"); + } + + #[test] + fn unknown_item_types_are_passed_through_without_body() { + let item = direct_thread_item_from_value( + root(), + &json!({"id": "plan-1", "type": "plan", "text": "内部计划"}), + 0, + ) + .expect("unknown item"); + assert!(matches!( + item, + // TODO(direct-thread): 未知类型目前只带类型与身份,前端投影会丢弃它。 + // 哪些类型要显示属于前端可见性决策,需要时改前端,不要在这里加白名单。 + DirectThreadItem::Other { ref raw_type, .. } if raw_type == "plan" + )); + } + + /// 事件级 `at` 与条目展示时间 `item.at` 是两件事:前者是本阶段的真实边界, + /// 后者只说明条目什么时候被看到。 + #[test] + fn event_stage_time_is_independent_from_item_display_time() { + let params = json!({ + "completedAtMs": 2_000u64, + "item": { + "id": "call-1", + "type": "commandExecution", + "command": "ls", + "startedAtMs": 1_000u64, + }, + }); + let item = direct_thread_item_from_value(root(), ¶ms["item"], 7_777).expect("item"); + // 条目展示时间不受事件级时间影响,仍按条目自己的字段推导。 + assert_eq!(item.at(), 1_000); + assert_eq!( + direct_thread_item_event_at_ms(¶ms, ¶ms["item"], true, 7_777), + 2_000 + ); + } + + #[test] + fn item_started_event_at_uses_notification_stage_time() { + // 通知层 `startedAtMs` 优先于条目自带的同名字段。 + let params = json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "startedAtMs": 1_700_000_000_123u64, + "item": { + "id": "call-1", + "type": "commandExecution", + "startedAtMs": 1_700_000_000_000u64, + }, + }); + assert_eq!( + direct_thread_item_event_at_ms(¶ms, ¶ms["item"], false, 9_999), + 1_700_000_000_123 + ); + } + + #[test] + fn item_event_at_falls_back_to_nested_item_then_host_clock() { + let nested = json!({ + "item": { + "id": "call-1", + "type": "commandExecution", + "startedAtMs": 1_700_000_000_500u64, + }, + }); + assert_eq!( + direct_thread_item_event_at_ms(&nested, &nested["item"], false, 9_999), + 1_700_000_000_500 + ); + + // 原生没有任何阶段时间:用宿主处理这条事件的钟,不编造。 + let bare = json!({"item": {"id": "call-1", "type": "commandExecution"}}); + assert_eq!( + direct_thread_item_event_at_ms(&bare, &bare["item"], false, 9_999), + 9_999 + ); + assert_eq!( + direct_thread_item_event_at_ms(&bare, &bare["item"], true, 9_999), + 9_999 + ); + } + + #[test] + fn item_completed_event_at_prefers_completion_over_start() { + // 通知层两个字段都在时必须取完成时间,不能退回开始时间。 + let params = json!({ + "startedAtMs": 1_000u64, + "completedAtMs": 2_000u64, + "durationMs": 1_000u64, + "item": {"id": "call-1", "type": "commandExecution"}, + }); + assert_eq!( + direct_thread_item_event_at_ms(¶ms, ¶ms["item"], true, 9_999), + 2_000 + ); + + // 完成时间只在条目里:同样取完成时间。 + let nested = json!({ + "item": { + "id": "call-1", + "type": "commandExecution", + "startedAtMs": 1_000u64, + "completedAtMs": 2_500u64, + }, + }); + assert_eq!( + direct_thread_item_event_at_ms(&nested, &nested["item"], true, 9_999), + 2_500 + ); + } + + #[test] + fn item_completed_event_at_derives_end_only_with_reliable_start() { + let with_start = json!({ + "item": {"id": "call-1", "type": "commandExecution", "startedAtMs": 1_000u64, "durationMs": 250u64}, + }); + assert_eq!( + direct_thread_item_event_at_ms(&with_start, &with_start["item"], true, 9_999), + 1_250 + ); + + // 只有时长不足以证明结束时刻:回落到宿主钟。 + let duration_only = json!({ + "item": {"id": "call-1", "type": "commandExecution", "durationMs": 250u64}, + }); + assert_eq!( + direct_thread_item_event_at_ms(&duration_only, &duration_only["item"], true, 9_999), + 9_999 + ); + } + + /// Turn 上游的 `startedAt` / `completedAt` 是**秒**级:既支撑不了 0.1 秒粒度的展示, + /// 也可能让完成时刻落进该轮用户消息的同一秒、被判成无效边界后吞掉整轮新回合。 + /// 因此秒字段一律不采用,回合边界回落到宿主处理该阶段时的毫秒钟。 + #[test] + fn turn_completed_at_ignores_second_truncated_upstream_fields() { + let seconds_only = json!({ + "id": "turn-1", + "status": "completed", + "startedAt": 1_700_000_000i64, + "completedAt": 1_700_000_042i64, + }); + assert_eq!( + direct_thread_turn_completed_at_ms(&seconds_only, Some(1_700_000_000_500), 9_999), + 9_999, + "没有 durationMs 时用宿主钟,不换算秒字段" + ); + assert_eq!( + direct_thread_turn_completed_at_ms(&seconds_only, None, 9_999), + 9_999 + ); + } + + #[test] + fn turn_completed_at_derives_end_only_from_duration_and_high_precision_start() { + let with_duration = json!({ + "id": "turn-1", + "status": "completed", + "startedAt": 1_700_000_000i64, + "completedAt": 1_700_000_042i64, + "durationMs": 42_500u64, + }); + // 高精度起点(宿主在本轮开始时记下的毫秒值)+ 上游 durationMs:结束严格晚于起点。 + assert_eq!( + direct_thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999), + 1_700_000_043_000 + ); + // 起点不可证明时不派生。 + assert_eq!( + direct_thread_turn_completed_at_ms(&with_duration, None, 9_999), + 9_999 + ); + // 时长为 0 同样按缺失处理。 + let zero_duration = json!({"durationMs": 0u64}); + assert_eq!( + direct_thread_turn_completed_at_ms(&zero_duration, Some(1_000), 9_999), + 9_999 + ); + } + + /// 线上形状:四种生命周期事件带事件级 `at`(number),历史 / 无时间夹具缺该字段时 + /// 反序列化仍成立,且不会序列化出 `at: null`。 + #[test] + fn lifecycle_events_serialize_event_level_at_as_optional_number() { + let started = serde_json::to_value(DirectThreadEvent::turn_started(1_700_000_000_123)) + .expect("serialize turn.started"); + assert_eq!( + started, + json!({"type": "turn.started", "at": 1_700_000_000_123u64}) + ); + assert_eq!( + serde_json::from_value::(started).expect("round trip"), + DirectThreadEvent::turn_started(1_700_000_000_123) + ); + + let completed = serde_json::to_value(DirectThreadEvent::turn_completed( + "completed".to_string(), + 2_000, + )) + .expect("serialize turn.completed"); + assert_eq!( + completed, + json!({"type": "turn.completed", "status": "completed", "at": 2_000u64}) + ); + + let item = DirectThreadItem::CommandExecution { + item_id: "call-1".to_string(), + command: "ls".to_string(), + output: None, + status: Some("completed".to_string()), + exit_code: None, + at: 1_500, + }; + let item_started = + serde_json::to_value(DirectThreadEvent::item_started(item.clone(), 1_000)) + .expect("serialize item.started"); + assert_eq!(item_started["at"], json!(1_000u64)); + // 事件级 `at` 不动条目自己的展示时间。 + assert_eq!(item_started["item"]["at"], json!(1_500u64)); + let item_completed = serde_json::to_value(DirectThreadEvent::item_completed(item, 2_000)) + .expect("serialize item.completed"); + assert_eq!(item_completed["at"], json!(2_000u64)); + + // 历史 / 夹具里的旧事件没有 `at`:反序列化成 `None`,回写时不补 `null`。 + let legacy: DirectThreadEvent = serde_json::from_value(json!({"type": "turn.started"})) + .expect("legacy turn.started without at"); + assert_eq!( + legacy, + DirectThreadEvent::TurnStarted { + at: None, + user_item_id: None, + } + ); + assert_eq!(legacy.at(), None); + assert_eq!(legacy.user_item_id(), None); + assert_eq!( + serde_json::to_value(legacy).expect("serialize legacy"), + json!({"type": "turn.started"}) + ); + assert_eq!( + serde_json::to_value(DirectThreadEvent::request( + DirectThreadRequestKind::RequestResolved, + None, + )) + .expect("serialize request"), + json!({"type": "request", "kind": "request.resolved", "requestId": null}) + ); + } + + /// 回合生命周期事件带可选的开口用户条目身份:线上是 `userItemId`(camelCase 的可选 string), + /// 缺省不写字段,旧事件反序列化仍是 `None`,空白身份按缺失处理(不猜)。 + #[test] + fn lifecycle_events_carry_optional_opener_user_item_id() { + let started = DirectThreadEvent::turn_started(1_000) + .with_user_item_id(Some("direct-codex:turn-1:user")); + assert_eq!(started.user_item_id(), Some("direct-codex:turn-1:user")); + assert_eq!( + serde_json::to_value(&started).expect("serialize turn.started"), + json!({ + "type": "turn.started", + "at": 1_000u64, + "userItemId": "direct-codex:turn-1:user", + }) + ); + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&started).expect("serialize") + ) + .expect("round trip"), + started + ); + + let completed = DirectThreadEvent::turn_completed("interrupted".to_string(), 2_000) + .with_user_item_id(Some("direct-codex:turn-1:user")); + assert_eq!(completed.user_item_id(), Some("direct-codex:turn-1:user")); + assert_eq!( + serde_json::to_value(&completed).expect("serialize turn.completed"), + json!({ + "type": "turn.completed", + "status": "interrupted", + "at": 2_000u64, + "userItemId": "direct-codex:turn-1:user", + }) + ); + // 起止同源:同一轮的两条边界带同一个身份。 + assert_eq!(started.user_item_id(), completed.user_item_id()); + + // 空白 / 空串按缺失处理:不能把 "" 当成一条用户条目的身份发下去。 + for empty in ["", " "] { + let event = DirectThreadEvent::turn_started(1_000).with_user_item_id(Some(empty)); + assert_eq!(event.user_item_id(), None); + assert_eq!( + serde_json::to_value(&event).expect("serialize"), + json!({"type": "turn.started", "at": 1_000u64}) + ); + } + + // 旧事件(没有 `userItemId`)反序列化成 `None`,回写不补 `null`。 + let legacy: DirectThreadEvent = serde_json::from_value(json!({ + "type": "turn.completed", + "status": "completed", + "at": 3_000u64, + })) + .expect("legacy turn.completed without userItemId"); + assert_eq!(legacy.user_item_id(), None); + assert_eq!( + serde_json::to_value(legacy).expect("serialize legacy"), + json!({"type": "turn.completed", "status": "completed", "at": 3_000u64}) + ); + + // 条目事件没有这个字段:身份只在生命周期事件上。 + let item_event = DirectThreadEvent::item_completed( + DirectThreadItem::CommandExecution { + item_id: "call-1".to_string(), + command: "ls".to_string(), + output: None, + status: None, + exit_code: None, + at: 1_500, + }, + 1_600, + ); + assert_eq!(item_event.user_item_id(), None); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 2e3889f46..c74690439 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -24,7 +24,6 @@ const DIRECT_TOOL_BRIDGE_IMAGE_PREVIEW_MAX_DIMENSION: u32 = 1024; const DIRECT_TOOL_BRIDGE_MAX_SEARCH_QUERY_CHARS: usize = 400; const DIRECT_TOOL_BRIDGE_MAX_SEARCH_RESULTS: usize = 5; const DIRECT_TOOL_BRIDGE_SEARCH_URL: &str = "https://www.bing.com/search?format=rss"; -const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_PROMPT_CHARS: usize = 4_000; const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS: usize = 120; const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_KIND_CHARS: usize = 80; const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_PAGE_SIZE: usize = 100; @@ -89,7 +88,7 @@ struct DirectToolBridgeRequest { } #[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum DirectResourceGenerationKind { +pub(crate) enum DirectResourceGenerationKind { Image, Video, CharacterAnimation, @@ -98,7 +97,7 @@ enum DirectResourceGenerationKind { } impl DirectResourceGenerationKind { - fn parse(value: &str) -> Result { + pub(crate) fn parse(value: &str) -> Result { match value { "image" => Ok(Self::Image), "video" => Ok(Self::Video), @@ -119,7 +118,7 @@ impl DirectResourceGenerationKind { } } - fn edit_kind(self) -> LocalProjectResourceEditKind { + pub(crate) fn edit_kind(self) -> LocalProjectResourceEditKind { match self { Self::Image => LocalProjectResourceEditKind::ImageReference, Self::Video => LocalProjectResourceEditKind::Video, @@ -128,6 +127,11 @@ impl DirectResourceGenerationKind { Self::BackgroundMusic => LocalProjectResourceEditKind::BackgroundMusic, } } + + /// 提示词上限只从客户端权威口径取值,工具桥与 MCP 层共用同一份数字。 + pub(crate) fn prompt_max_chars(self) -> usize { + resource_edit_prompt_max_chars(&self.edit_kind()) + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -1036,6 +1040,12 @@ fn bridge_account_asset_import_inputs( Ok((asset_ids, local_paths)) } +/// 源资源身份不在当前项目 manifest 时的统一提示。 +/// +/// 只报「不属于已登记资源」会让模型原地重试;这里必须把下一步可执行动作写清楚: +/// 已登记资源走 `agc_list_registered_assets`,只在项目里存在的文件先登记再重试。 +const DIRECT_TOOL_BRIDGE_UNREGISTERED_SOURCE_MESSAGE: &str = "sourceLocalAssetId 不是当前项目已登记资源:先调用 agc_list_registered_assets 选择已有 localAssetId;若目标图片只在项目里,先用 agc_list_project_files 确认它 assetImportable=true,再用 agc_import_account_assets.localPaths 登记后重试。"; + fn bridge_resource_generation_input( arguments: &Value, ) -> Result { @@ -1054,18 +1064,20 @@ fn bridge_resource_generation_input( "sourceLocalAssetId", DIRECT_TOOL_BRIDGE_MAX_RESOURCE_KIND_CHARS, )?; - let prompt = bridge_bounded_string( - arguments, - "prompt", - DIRECT_TOOL_BRIDGE_MAX_RESOURCE_PROMPT_CHARS, - )?; + // prompt 的形状校验只用信封级上限,真正生效的按 kind 上限由紧随其后的权威判定给出 + // 精确数字;否则通用 4000 会先于「图片编辑 32000 / 音效 1900」误报成安全边界错误。 + let prompt = bridge_bounded_string(arguments, "prompt", DIRECT_TOOL_BRIDGE_MAX_REQUEST_BYTES)?; let asset_name = bridge_bounded_string( arguments, "assetName", DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS, )?; - if kind == DirectResourceGenerationKind::BackgroundMusic && prompt.chars().count() > 140 { - return Err("背景音乐提示词必须在 1..=140 字符内".to_string()); + let prompt_max_chars = kind.prompt_max_chars(); + if prompt.chars().count() > prompt_max_chars { + return Err(resource_edit_prompt_limit_error( + &kind.edit_kind(), + prompt_max_chars, + )); } match (kind, mode, source_local_asset_id.as_ref()) { (DirectResourceGenerationKind::Image, DirectResourceGenerationMode::Create, _) => { @@ -1282,7 +1294,10 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value { .map(|asset| bridge_registered_resource(asset, include_sequence_frames)) .collect::>(); let next_offset = (offset + resources.len() < total).then_some(offset + resources.len()); - let pending = list_pending_local_project_resource_edits_at( + let platform_session = (editor_api_mode() == EditorApiMode::PlatformAccount) + .then(current_platform_session) + .flatten(); + let pending = list_pending_local_project_resource_edits_for_session_at( ListPendingLocalProjectResourceEditsInput { project_path: root .to_str() @@ -1290,6 +1305,7 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value { .to_string(), expected_project_id: manifest.project_id, }, + platform_session.as_ref(), )? .into_iter() .map(|edit| { @@ -1299,6 +1315,8 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value { "mode": edit.generation_mode, "sourceResourceId": edit.source_resource_id, "assetName": edit.asset_name, + "backgroundMode": edit.background_mode, + "screenColor": edit.screen_color, "phase": edit.phase, "createdAt": edit.created_at, }) @@ -1368,6 +1386,33 @@ fn bridge_project_file_class(path: &str) -> (&'static str, Option<&'static str>) "gd" | "rs" | "py" | "go" | "java" | "kt" | "kts" | "c" | "cc" | "cpp" | "h" | "hpp" | "cs" | "swift" | "php" | "rb" | "lua" | "sh" | "bash" | "zsh" | "sql" | "graphql" | "gql" | "vue" | "svelte" => ("code", Some("text/plain")), + /* + * Cocos Creator 资源(3.8.8 的 `engine-extends` 贡献的 `asset-handler` 表)。 + * + * 这里只做**发现分类**:`model` / `binary` 是发现层新词,与 manifest 资产 `kind` + * 不是同一套口径(登记时的 kind 见 `commands.rs::agent_local_project_file_type`)。 + * 只有 `mediaType` 非空的条目才会 `assetImportable=true`,因此这张表必须与登记层 + * 的白名单同步增删;`prompt_context.rs::prompt_context_media_type` 是同一套扩展名的 + * 第三份投影,同样要跟改。 + */ + "glb" => ("model", Some("model/gltf-binary")), + "gltf" => ("model", Some("model/gltf+json")), + "fbx" => ("model", Some("application/octet-stream")), + "mesh" | "skeleton" => ("model", Some("application/json")), + "scene" | "fire" | "prefab" | "anim" | "animation" | "animgraph" | "animgraphvari" + | "animask" | "mtl" | "material" | "pmtl" | "terrain" | "labelatlas" | "pac" => { + ("document", Some("application/json")) + } + "tmx" | "plist" => ("document", Some("application/xml")), + "effect" | "chunk" | "fnt" | "atlas" => ("document", Some("text/plain")), + "tga" => ("image", Some("image/x-tga")), + "tif" | "tiff" => ("image", Some("image/tiff")), + "hdr" => ("image", Some("image/vnd.radiance")), + "exr" => ("image", Some("image/x-exr")), + "dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" | "psd" | "znt" => { + ("binary", Some("application/octet-stream")) + } + "pcm" => ("audio", Some("audio/pcm")), _ => ("other", None), } } @@ -1409,8 +1454,10 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value { .map(|value| value.to_lowercase()); let requested_kind = bridge_optional_bounded_string(arguments, "kind", 16)? .unwrap_or_else(|| "all".to_string()); - if !["all", "image", "font", "audio", "video", "document", "code"] - .contains(&requested_kind.as_str()) + if ![ + "all", "image", "font", "audio", "video", "document", "code", "model", "binary", + ] + .contains(&requested_kind.as_str()) { return Err("工具参数 kind 不是受支持的项目文件类别".to_string()); } @@ -1760,8 +1807,8 @@ async fn bridge_import_account_assets(state: &DirectToolBridgeState, arguments: fn bridge_completed_resource_result( root: &Path, - kind: DirectResourceGenerationKind, - mode: DirectResourceGenerationMode, + kind: &str, + mode: &str, result: DeriveLocalProjectResourceResult, ) -> Result { let asset = result @@ -1773,8 +1820,8 @@ fn bridge_completed_resource_result( Ok(json!({ "status": "completed", "operationId": result.operation_id, - "kind": kind.as_str(), - "mode": mode.as_str(), + "kind": kind, + "mode": mode, "sourceResourceId": result.source_resource_id, "committedProjectRevision": result.committed_project_revision, "resource": bridge_registered_resource(asset, true), @@ -1806,7 +1853,7 @@ async fn bridge_create_or_derive_resource( .iter() .find(|asset| asset.id == asset_id) .cloned() - .ok_or_else(|| "sourceLocalAssetId 不属于当前项目已登记资源".to_string()) + .ok_or_else(|| DIRECT_TOOL_BRIDGE_UNREGISTERED_SOURCE_MESSAGE.to_string()) }) .transpose()?; let prompt_sha256 = format!("{:x}", Sha256::digest(input.prompt.as_bytes())); @@ -1869,10 +1916,17 @@ async fn bridge_create_or_derive_resource( source_version_id: None, prompt: input.prompt.clone(), asset_name: input.asset_name.clone(), + background_mode: None, + screen_color: None, }; with_direct_editor_api_credentials(derive_local_project_resource_at(request)).await? }; - bridge_completed_resource_result(&state.root, input.kind, input.mode, completed) + bridge_completed_resource_result( + &state.root, + input.kind.as_str(), + input.mode.as_str(), + completed, + ) } .await; match result { @@ -1886,8 +1940,9 @@ async fn bridge_create_or_derive_resource( } async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Value) -> Value { - let result = async { - bridge_reject_unknown_fields(arguments, &["sourceLocalAssetId", "assetName"])?; + let _generation_guard = state.resource_generation_gate.lock().await; + let result = with_direct_editor_api_credentials(async { + super::direct_tools_mcp::validate_remove_background_arguments(arguments)?; enforce_project_permission_policy(&state.root, "canvas.asset_generate")?; enforce_project_permission_policy(&state.root, "asset.register")?; let source_asset_id = bridge_bounded_string(arguments, "sourceLocalAssetId", 80)?; @@ -1896,71 +1951,83 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val "assetName", DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS, )?; + let background_mode = arguments.get("backgroundMode").and_then(Value::as_str); + let screen_color = arguments.get("screenColor").and_then(Value::as_str); let manifest = read_existing_manifest_for_project(&state.root)?; let source_asset = manifest .assets .iter() .find(|asset| asset.id == source_asset_id) - .ok_or_else(|| "sourceLocalAssetId 不属于当前项目已登记资源".to_string())?; + .ok_or_else(|| DIRECT_TOOL_BRIDGE_UNREGISTERED_SOURCE_MESSAGE.to_string())?; if !source_asset.media_type.starts_with("image/") { return Err("抠图工具只接受当前项目已登记的图片资源".to_string()); } - let source_resource_id = source_asset - .source - .resource_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty() && !value.starts_with("local-asset:")) - .ok_or_else(|| "图片资源缺少可供抠图服务使用的正式 resourceId".to_string())? - .to_string(); - let (api_base_url, api_key, session) = resolve_canvas_sync_api_credentials(None, None)?; - let access = ExternalEditorBindingAccess::new(&api_base_url, &api_key, session.as_ref())?; - let client = crate::http_client::agc_main_site_client_builder() - .build() - .map_err(|_| "创建抠图服务连接失败".to_string())?; - let context = - prepare_external_canvas_generation_context(&state.root, &client, &access).await?; - let fingerprint = format!("{}\0{}", source_asset_id, asset_name); - let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?; - let route = "/api/external/v1/editor/images/background-removals"; - let response = crate::http_client::with_agc_main_site_marker( - client - .post(format!("{}{}", api_base_url, route)) - .bearer_auth(api_key) - .header("Idempotency-Key", idempotency_key) - .json(&json!({ - "sourceImageSrc": source_resource_id, - "projectId": manifest.project_id, - "assetKind": source_asset.kind, - "assetFolderId": context.asset_folder_id, - "assetLabel": asset_name, - "sourceResourceId": source_resource_id, - })), - ) - .send() - .await - .map_err(|error| format!("抠图服务提交失败:{error}"))?; - let status = response.status(); - let payload = response - .json::() - .await - .map_err(|error| format!("抠图服务响应无法解析:{error}"))?; - if !status.is_success() { - if status == reqwest::StatusCode::UNAUTHORIZED { - return Err("authentication-required: 抠图服务提交失败:HTTP 401".to_string()); - } - return Err(format!("抠图服务提交失败:HTTP {}", status.as_u16())); + let background_mode = background_mode.unwrap_or("complex").to_string(); + let source_resource_id = bridge_asset_canonical_resource_id(source_asset); + let fingerprint = background_removal_request_fingerprint( + &source_asset_id, + &asset_name, + Some(background_mode.as_str()), + screen_color, + ); + let (_, _, platform_session) = resolve_canvas_sync_api_credentials(None, None)?; + let pending = list_pending_local_project_resource_edits_for_session_at( + ListPendingLocalProjectResourceEditsInput { + project_path: state.root.to_string_lossy().into_owned(), + expected_project_id: manifest.project_id.clone(), + }, + platform_session.as_ref(), + )?; + let matching_pending = pending + .into_iter() + .filter(|pending| { + pending.edit_kind == LocalProjectResourceEditKind::BackgroundRemoval + && (pending.source_asset_id.as_deref() == Some(source_asset_id.as_str()) + || pending.source_resource_id == format!("local-asset:{source_asset_id}")) + && pending.asset_name == asset_name + && pending.background_mode.as_deref().unwrap_or("complex") + == background_mode.as_str() + && pending.screen_color.as_deref() == screen_color + }) + .collect::>(); + if matching_pending.len() > 1 { + return Err("存在多个相同抠图 operation,必须先在客户端完成对账".to_string()); } - let queue_state = external_editor_response_data(&payload).clone(); - Ok::<_, String>(json!({ - "status": "queued", - "sourceLocalAssetId": source_asset_id, - "assetName": asset_name, - "projectId": manifest.project_id, - "assetFolderId": context.asset_folder_id, - "queueState": bridge_safe_queue_state(queue_state), - })) - } + let completed = if let Some(pending) = matching_pending.into_iter().next() { + resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput { + project_path: state.root.to_string_lossy().into_owned(), + expected_project_id: manifest.project_id.clone(), + operation_id: pending.operation_id, + }) + .await? + } else { + let (operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?; + let revision = read_game_creator_agent_runtime_project_revision(&state.root)?.revision; + let request = DeriveLocalProjectResourceInput { + project_path: state.root.to_string_lossy().into_owned(), + expected_project_id: manifest.project_id.clone(), + expected_project_revision: revision, + operation_id, + idempotency_key, + edit_kind: LocalProjectResourceEditKind::BackgroundRemoval, + generation_mode: LocalProjectResourceGenerationMode::Derive, + source_resource_id, + source_asset_id: Some(source_asset_id.clone()), + source_path: Some(source_asset.local_path.clone()), + source_media_type: Some(source_asset.media_type.clone()), + source_subtype: Some(source_asset.kind.clone()), + producer_task_id: source_asset.source.task_id.clone(), + source_version_id: None, + prompt: "去除背景".to_string(), + asset_name: asset_name.clone(), + background_mode: Some(background_mode), + screen_color: screen_color.map(str::to_string), + }; + derive_local_project_resource_at(request).await? + }; + emit_game_creator_manifest_invalidated(&state.root, "direct-background-removal"); + bridge_completed_resource_result(&state.root, "background-removal", "derive", completed) + }) .await; match result { Ok(value) => bridge_tool_result(value.to_string(), Vec::new(), false), @@ -1972,15 +2039,18 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val } } -fn bridge_safe_queue_state(value: Value) -> Value { - let object = value.as_object(); - json!({ - "operationId": object.and_then(|value| value.get("operationId")).and_then(Value::as_str), - "status": object.and_then(|value| value.get("status")).and_then(Value::as_str), - "phaseLabel": object.and_then(|value| value.get("phaseLabel")).and_then(Value::as_str), - "progress": object.and_then(|value| value.get("progress")).and_then(Value::as_u64), - "updatedAtMicros": object.and_then(|value| value.get("updatedAtMicros")).and_then(Value::as_u64), - }) +fn background_removal_request_fingerprint( + source: &str, + name: &str, + mode: Option<&str>, + color: Option<&str>, +) -> String { + let mode = mode.unwrap_or("complex"); + if mode == "complex" && color.is_none() { + format!("{source}\0{name}") + } else { + format!("{source}\0{name}\0{mode}\0{}", color.unwrap_or("")) + } } fn bridge_art_resources( @@ -2130,6 +2200,72 @@ fn bridge_image_generation_kind(arguments: &Value) -> Result { }) } +/// 切分模式没有默认值:图集必须显式声明,且声明必须与 kind 和网格参数自洽。 +fn validate_generate_image_slice_declaration( + kind: &str, + slice_mode: Option<&str>, + grid_x: Option, + grid_y: Option, + slice_count: Option, +) -> Result<(), String> { + if kind == "art-spritesheet" { + if slice_mode.is_none() { + return Err( + "kind=art-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时传 sliceMode=grid 并提供 gridX/gridY;自由排布、数量不定或只要求一张图集时传 sliceMode=connected-components" + .to_string(), + ); + } + if slice_mode == Some("grid") && slice_count.is_some() { + return Err( + "sliceMode=grid 的素材张数由 gridX×gridY 决定,不接受 sliceCount".to_string(), + ); + } + return Ok(()); + } + if slice_mode.is_some() || grid_x.is_some() || grid_y.is_some() || slice_count.is_some() { + return Err(format!( + "工具参数 sliceMode/gridX/gridY/sliceCount 仅对 kind=art-spritesheet 生效,当前 kind={kind}" + )); + } + Ok(()) +} + +/// 抠图纯色背景只服务 character 与 art-spritesheet 链路;格式校验收口为 +/// `auto` 或 `#RRGGBB`(服务端另有支持色板,客户端不复制),`auto`/空串归一为 +/// None(服务端自动决策),hex 统一大写后透传。其它 kind 携带该字段直接拒绝, +/// 避免服务端静默忽略造成“已生效”的误解。 +fn normalize_generate_image_screen_color( + arguments: &Value, + kind: &str, +) -> Result, String> { + let Some(value) = arguments.get("screenColor") else { + return Ok(None); + }; + if value.is_null() { + return Ok(None); + } + if !matches!(kind, "character" | "art-spritesheet") { + return Err(format!( + "工具参数 screenColor 仅对 kind=character 和 kind=art-spritesheet 生效,当前 kind={kind}" + )); + } + let raw = value + .as_str() + .ok_or_else(|| "工具参数 screenColor 必须是 auto 或 #RRGGBB".to_string())? + .trim(); + if raw.is_empty() || raw.eq_ignore_ascii_case("auto") { + return Ok(None); + } + let normalized = raw.to_ascii_uppercase(); + let valid = normalized.len() == 7 + && normalized.starts_with('#') + && normalized[1..].chars().all(|c| c.is_ascii_hexdigit()); + if !valid { + return Err("工具参数 screenColor 必须是 auto 或 #RRGGBB".to_string()); + } + Ok(Some(normalized)) +} + async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) -> Value { let result = async { bridge_reject_unknown_fields( @@ -2144,6 +2280,8 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) "sliceMode", "gridX", "gridY", + "sliceCount", + "screenColor", ], )?; enforce_project_permission_policy(&state.root, "canvas.asset_generate")?; @@ -2223,6 +2361,25 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) { return Err("工具参数 gridX/gridY 必须在 1 到 32 之间".to_string()); } + let slice_count = arguments + .get("sliceCount") + .filter(|value| !value.is_null()) + .map(|value| { + value + .as_u64() + .filter(|count| (1..=256).contains(count)) + .map(|count| count as usize) + .ok_or_else(|| "工具参数 sliceCount 必须是 1 到 256 的整数".to_string()) + }) + .transpose()?; + validate_generate_image_slice_declaration( + kind.as_str(), + slice_mode.as_deref(), + grid_x, + grid_y, + slice_count, + )?; + let screen_color = normalize_generate_image_screen_color(arguments, kind.as_str())?; let options = PlatformArtAssetGenerationOptions { output_path, aspect_ratio, @@ -2230,10 +2387,13 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) asset_kind: kind.clone(), asset_label: asset_name.clone(), replace_existing: false, - slice_count: None, + slice_count, slice_mode, grid_x, grid_y, + reference_asset_ids: Vec::new(), + target_category: None, + screen_color, }; let _generation_guard = state.image_generation_gate.lock().await; let generated = with_direct_editor_api_credentials( @@ -2269,6 +2429,14 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) "resources": resources, "warnings": generated.warning.map(|warning| bridge_safe_warning_messages(&state.root, vec![warning])).unwrap_or_default(), "sliceWarnings": generated.slice_warning.map(|warning| bridge_safe_warning_messages(&state.root, vec![warning])).unwrap_or_default(), + "sliceMode": generated.slice_mode, + "gridX": generated.grid_x, + "gridY": generated.grid_y, + "slicePaths": generated + .slices + .iter() + .map(|slice| slice.local_path.clone()) + .collect::>(), }) .to_string(), images, @@ -2703,6 +2871,140 @@ pub(crate) async fn start_direct_tool_bridge( #[cfg(test)] mod tests { + #[test] + fn generate_image_slice_declaration_is_explicit_and_self_consistent() { + let missing = + validate_generate_image_slice_declaration("art-spritesheet", None, None, None, None) + .expect_err("art-spritesheet without sliceMode must fail closed"); + assert!(missing.contains("没有默认值"), "{missing}"); + assert!(missing.contains("connected-components"), "{missing}"); + + assert!(validate_generate_image_slice_declaration( + "art-spritesheet", + Some("connected-components"), + None, + None, + Some(4), + ) + .is_ok()); + assert!(validate_generate_image_slice_declaration( + "art-spritesheet", + Some("grid"), + Some(3), + Some(2), + None, + ) + .is_ok()); + let grid_with_count = validate_generate_image_slice_declaration( + "art-spritesheet", + Some("grid"), + Some(2), + Some(2), + Some(4), + ) + .expect_err("grid mode must not carry sliceCount"); + assert!(grid_with_count.contains("gridX×gridY"), "{grid_with_count}"); + + let wrong_kind = validate_generate_image_slice_declaration( + "image", + Some("connected-components"), + None, + None, + None, + ) + .expect_err("slice declaration must stay scoped to art-spritesheet"); + assert!( + wrong_kind.contains("仅对 kind=art-spritesheet 生效"), + "{wrong_kind}" + ); + let wrong_kind_count = + validate_generate_image_slice_declaration("image", None, None, None, Some(8)) + .expect_err("sliceCount-only violation must be rejected"); + assert!( + wrong_kind_count.contains("sliceCount"), + "sliceCount-only violation must name sliceCount: {wrong_kind_count}" + ); + assert!(validate_generate_image_slice_declaration("image", None, None, None, None).is_ok()); + } + + #[test] + fn generate_image_screen_color_is_normalized_and_kind_gated() { + // 省略与显式 null 等价,且不触发 kind 门禁。 + assert_eq!( + normalize_generate_image_screen_color(&json!({}), "image").expect("omitted"), + None + ); + assert_eq!( + normalize_generate_image_screen_color(&json!({"screenColor": null}), "image") + .expect("null"), + None + ); + // auto 家族归一为 None(服务端自动决策),大小写与空白不敏感。 + for raw in ["auto", "AUTO", " auto ", ""] { + assert_eq!( + normalize_generate_image_screen_color(&json!({"screenColor": raw}), "character") + .expect("auto variants"), + None, + "{raw}" + ); + } + // hex 统一大写透传;色板白名单由服务端权威校验,客户端只守格式。 + assert_eq!( + normalize_generate_image_screen_color(&json!({"screenColor": "#cfefff"}), "character") + .expect("lowercase hex"), + Some("#CFEFFF".to_string()) + ); + assert_eq!( + normalize_generate_image_screen_color( + &json!({"screenColor": " #A0BBA0 "}), + "art-spritesheet" + ) + .expect("padded hex"), + Some("#A0BBA0".to_string()) + ); + // 非 auto/非 hex、非字符串一律拒绝。 + for bad in [json!("green"), json!("#GGGGGG"), json!("#FFF"), json!(12)] { + assert!( + normalize_generate_image_screen_color(&json!({"screenColor": bad}), "character") + .is_err(), + "{bad}" + ); + } + // 其它 kind 携带该字段直接拒绝,即使取值合法。 + let gated = + normalize_generate_image_screen_color(&json!({"screenColor": "#CFEFFF"}), "image") + .expect_err("screenColor must stay scoped to character/art-spritesheet"); + assert!(gated.contains("kind=character"), "{gated}"); + assert!(normalize_generate_image_screen_color( + &json!({"screenColor": "auto"}), + "ui-prototype" + ) + .is_err()); + } + + #[test] + fn remove_background_identity_preserves_default_and_distinguishes_options() { + let legacy = "asset-1\0透明图"; + assert_eq!( + background_removal_request_fingerprint("asset-1", "透明图", None, None), + legacy + ); + assert_eq!( + background_removal_request_fingerprint("asset-1", "透明图", Some("complex"), None), + legacy + ); + let mut identities = std::collections::HashSet::new(); + identities.insert(legacy.to_string()); + for color in [None, Some("auto"), Some("#CFEFFF"), Some("#112233")] { + let id = + background_removal_request_fingerprint("asset-1", "透明图", Some("flat"), color); + assert_eq!( + id, + background_removal_request_fingerprint("asset-1", "透明图", Some("flat"), color) + ); + assert!(identities.insert(id)); + } + } use super::*; use std::io::{Cursor, Read, Write}; @@ -2744,6 +3046,67 @@ mod tests { .contains("x-genarrative-client:")); } + /// 按 kind 的提示词上限只来自客户端权威口径;超限必须在构造工具输入时就被拒绝, + /// 不能再出现写死的数字(2026-09-17 的背景音乐 140 就是写死在桥这一层的)。 + #[test] + fn bridge_resource_prompt_limits_follow_the_client_authority() { + for (kind, edit_kind) in [ + ( + "background-music", + LocalProjectResourceEditKind::BackgroundMusic, + ), + ("sound-effect", LocalProjectResourceEditKind::SoundEffect), + ("video", LocalProjectResourceEditKind::Video), + ( + "character-animation", + LocalProjectResourceEditKind::CharacterAnimation, + ), + ("image", LocalProjectResourceEditKind::ImageReference), + ] { + let authority = resource_edit_prompt_max_chars(&edit_kind); + let mode = if matches!( + edit_kind, + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::CharacterAnimation + ) { + "derive" + } else { + "create" + }; + let mut arguments = json!({ + "kind": kind, + "mode": mode, + "prompt": "字".repeat(authority), + "assetName": "边界名称" + }); + if mode == "derive" { + arguments["sourceLocalAssetId"] = json!("registered-source"); + } + bridge_resource_generation_input(&arguments) + .unwrap_or_else(|error| panic!("{kind} 恰好等于上限必须通过:{error}")); + + arguments["prompt"] = json!("字".repeat(authority + 1)); + let error = match bridge_resource_generation_input(&arguments) { + Ok(_) => panic!("{kind} 超过按 kind 上限的提示词必须被拒绝"), + Err(error) => error, + }; + assert!( + error.contains(&authority.to_string()) && error.contains(kind_label(&edit_kind)), + "{kind} 的拒绝文案必须带上真实上限与类型:{error}" + ); + } + } + + fn kind_label(edit_kind: &LocalProjectResourceEditKind) -> &'static str { + match edit_kind { + LocalProjectResourceEditKind::BackgroundMusic => "背景音乐", + LocalProjectResourceEditKind::SoundEffect => "音效", + LocalProjectResourceEditKind::Video => "视频", + LocalProjectResourceEditKind::CharacterAnimation => "角色动画", + _ => "资源编辑", + } + } + #[test] fn bridge_argument_bounds_are_deterministic() { assert_eq!( @@ -2963,7 +3326,9 @@ mod tests { "supported raster image should be importable: {path}" ); } - for path in ["assets/theme.bin", "assets/unknown.xyz"] { + // `.bin` 现在是引擎的 BufferAsset 载体(Cocos 资源表里的 `buffer` handler), + // 因此不再属于「未识别文件」;真正未识别的扩展名仍然只能被发现。 + for path in ["assets/theme.dat", "assets/unknown.xyz"] { assert!( !bridge_project_file_is_asset_importable(path), "unsupported project file must not be advertised as importable: {path}" @@ -2983,6 +3348,64 @@ mod tests { } } + /// Cocos Creator 资源在发现层必须同时满足两件事:给出可筛选的类别、且 `mediaType` + /// 非空(`assetImportable` 由它推导,是 Agent 唯一能提交登记的入口)。 + /// + /// 变异验证:把任一扩展名从 `bridge_project_file_class` 删掉,本用例必须变红。 + #[test] + fn bridge_project_file_class_covers_cocos_creator_assets() { + for (path, expected_class, expected_media_type) in [ + ("assets/model/hero.glb", "model", "model/gltf-binary"), + ("assets/model/hero.gltf", "model", "model/gltf+json"), + ("assets/model/hero.fbx", "model", "application/octet-stream"), + ("assets/model/hero.mesh", "model", "application/json"), + ("assets/model/hero.skeleton", "model", "application/json"), + ("assets/scene/main.scene", "document", "application/json"), + ("assets/scene/enemy.prefab", "document", "application/json"), + ("assets/anim/walk.anim", "document", "application/json"), + ( + "assets/anim/graph.animgraph", + "document", + "application/json", + ), + ("assets/mtl/hero.mtl", "document", "application/json"), + ("assets/shader/glow.effect", "document", "text/plain"), + ("assets/atlas/hero.plist", "document", "application/xml"), + ("assets/map/level.tmx", "document", "application/xml"), + ("assets/font/bitmap.fnt", "document", "text/plain"), + ("assets/atlas/auto.pac", "document", "application/json"), + ("assets/tex/grass.tga", "image", "image/x-tga"), + ("assets/tex/height.hdr", "image", "image/vnd.radiance"), + ( + "assets/tex/hero.texture", + "binary", + "application/octet-stream", + ), + ( + "assets/spine/hero.skel", + "binary", + "application/octet-stream", + ), + ("assets/audio/voice.pcm", "audio", "audio/pcm"), + ] { + let (class, media_type) = bridge_project_file_class(path); + assert_eq!(class, expected_class, "{path}"); + assert_eq!(media_type, Some(expected_media_type), "{path}"); + assert!( + bridge_project_file_is_asset_importable(path), + "Cocos 资源必须可登记:{path}" + ); + } + // 引擎工程里的导入缓存不是资源:`.meta` 与未知扩展名仍然只能被发现。 + for path in ["assets/tex/hero.png.meta", "assets/world.unknown"] { + assert_eq!(bridge_project_file_class(path).0, "other", "{path}"); + assert!( + !bridge_project_file_is_asset_importable(path), + "非资源文件不得可登记:{path}" + ); + } + } + #[test] fn bridge_project_file_listing_projects_importability_per_file() { let temporary = tempfile::tempdir().expect("create project file listing root"); @@ -3537,20 +3960,4 @@ mod tests { ); } } - - #[test] - fn bridge_background_removal_queue_projection_is_bounded() { - let projection = bridge_safe_queue_state(json!({ - "operationId": "background-removal-1", - "status": "queued", - "phaseLabel": "排队中", - "progress": 0, - "updatedAtMicros": 1, - "error": "private provider detail", - "signedUrl": "https://private.invalid/result" - })); - assert_eq!(projection["operationId"], "background-removal-1"); - assert!(projection.get("error").is_none()); - assert!(projection.get("signedUrl").is_none()); - } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs index 82e9989e8..718ed37a8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs @@ -1,4 +1,4 @@ -//! GameAgent 对话「工具调用卡片」的采集、持久化与回读。 +//! GameAgent 对话「工具调用卡片」的采集与持久化。 //! //! 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`: //! Codex app-server 的 `item/started` / `item/completed` 里带着完整的命令 / 文件变更 @@ -8,11 +8,9 @@ //! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的 //! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。 -use crate::agent::redact_secret_tokens; -use crate::agent::sanitize_error_context; -use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file}; +use super::direct_thread_wire::sanitize_detail_text; +use crate::config::write_game_creator_private_file; use crate::project::{enforce_project_permission_policy, project_append_lock_for}; -use crate::redact_absolute_path_tokens; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeMap; @@ -24,7 +22,7 @@ use std::path::{Path, PathBuf}; pub(crate) const DIRECT_TOOL_CALL_RECORD_TYPE: &str = "tool_call_item"; /// 条目 schema 版本。 pub(crate) const DIRECT_TOOL_CALL_SCHEMA_VERSION: &str = "agc-tool-call.v1"; -/// 回读上限:只保留最近这么多条(按 `updatedAt` / `startedAt` 取最新)。 +/// 落盘上限:只保留最近这么多条(按 `updatedAt` / `startedAt` 取最新)。 pub(crate) const DIRECT_TOOL_CALL_LIMIT: usize = 200; /// `detail.command` / `detail.output` 的字符上限。 const DIRECT_TOOL_CALL_DETAIL_MAX_CHARS: usize = 4000; @@ -86,134 +84,6 @@ fn tool_calls_path(root: &Path) -> PathBuf { root.join(".agent/conversations/tool-calls.jsonl") } -/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。 -fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) { - let mut index = start; - let mut relative = String::new(); - while index < value.len() { - let character = value[index..].chars().next().unwrap_or_default(); - if matches!(character, '/' | '\\') { - if !relative.is_empty() { - relative.push('/'); - } - index += character.len_utf8(); - continue; - } - if character.is_whitespace() - || matches!( - character, - '\'' | '"' - | '`' - | ',' - | ';' - | '|' - | '&' - | '(' - | ')' - | '[' - | ']' - | '{' - | '}' - | '<' - | '>' - | ':' - ) - { - break; - } - relative.push(character); - index += character.len_utf8(); - } - while relative.ends_with('/') { - relative.pop(); - } - (index, relative) -} - -/// 把项目根目录前缀换成**项目相对路径**(`/game/src/x.ts` → `game/src/x.ts`)。 -/// -/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成 -/// ``,之后就再也认不出哪些路径在项目内了。 -/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。 -fn relativize_project_root_paths(root: &Path, value: &str) -> String { - let root_text = root.to_string_lossy(); - let root_text = root_text.trim_end_matches(['/', '\\']); - if root_text.is_empty() { - return value.to_string(); - } - let mut needles = [ - root_text.to_string(), - root_text.replace('\\', "/"), - root_text.replace('/', "\\"), - ] - .into_iter() - .map(|needle| needle.to_ascii_lowercase()) - .filter(|needle| !needle.is_empty()) - .collect::>(); - needles.sort(); - needles.dedup(); - let lower = value.to_ascii_lowercase(); - - let mut output = String::with_capacity(value.len()); - let mut cursor = 0usize; - while cursor < value.len() { - let mut hit: Option<(usize, usize)> = None; - for needle in &needles { - let mut search = cursor; - while let Some(relative) = lower[search..].find(needle.as_str()) { - let start = search + relative; - let end = start + needle.len(); - let left_is_boundary = start == 0 - || lower[..start].chars().next_back().is_some_and(|character| { - !character.is_alphanumeric() && character != '_' && character != '-' - }); - if left_is_boundary && value[end..].starts_with(['/', '\\']) { - if hit.is_none_or(|(best_start, _)| start < best_start) { - hit = Some((start, end)); - } - break; - } - search = end; - } - } - let Some((start, end)) = hit else { - break; - }; - output.push_str(&value[cursor..start]); - let (consumed, relative) = project_relative_path_segment(value, end); - if relative.is_empty() { - // 只写了项目根目录本身(没有后续路径段):按占位形状处理。 - output.push_str(""); - } else { - output.push_str(&relative); - } - cursor = consumed; - } - output.push_str(&value[cursor..]); - output -} - -/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与 -/// 错误上下文脱敏。 -/// -/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录 -/// 仍然会留下;这里先归一化路径 token,再处理密钥。 -/// -/// 复用既有 `agent/generation/prompt_context.rs` 的脱敏组合:`sanitize_error_context` -/// 就是 `redact_secret_tokens` + `redact_error_sensitive_assignments` + -/// `redact_error_bearer_values` + `redact_error_config_names` 的既有组合用法,覆盖 -/// `Authorization: Bearer …`、`Cookie: …`、`api_key=…`、`client_secret=…` 这类键值凭据; -/// 含 `--password` / `--token` / `--secret` 这类敏感 CLI 标志的行按既有 fail-closed -/// 约定整行替换成 `[redacted sensitive context]`(与 `sanitize_agent_runtime_text` 一致)。 -/// -/// `pub(crate)`:回合流(`direct_turn_stream`)的文本段复用同一套脱敏,避免两处口径分叉。 -pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String { - let without_project_root = relativize_project_root_paths(root, value); - let without_absolute = redact_absolute_path_tokens(&without_project_root); - let without_secret = redact_secret_tokens(&without_absolute); - sanitize_error_context(&without_secret) -} - /// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。 fn bounded_chars(value: &str, max_chars: usize) -> String { if value.chars().count() <= max_chars { @@ -527,15 +397,6 @@ fn normalize_tool_calls(calls: Vec) -> Vec { normalized } -/// 回读:文件缺失返回空数组;单行损坏跳过;按时间正序,最多最近 200 条。 -pub(crate) fn read_direct_tool_calls_at(root: &Path) -> Result, String> { - let path = tool_calls_path(root); - if !prepare_game_creator_private_path_for_read(&path, false, "工具调用历史")? { - return Ok(Vec::new()); - } - Ok(normalize_tool_calls(read_tool_call_lines(&path))) -} - /// 状态的「确定性」排序:终态(`completed` / `failed`)优先于 `running`。 fn status_certainty(status: &str) -> u8 { match status { @@ -684,10 +545,22 @@ mod tests { use super::{ direct_tool_call_from_item, direct_tool_call_now_ms, direct_tool_call_status, direct_tool_call_status_changed, persist_direct_tool_call_at, persist_direct_tool_calls_at, - read_direct_tool_calls_at, sanitize_detail_text, tool_calls_path, DirectToolCall, - DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT, DIRECT_TOOL_CALL_SCHEMA_VERSION, + read_tool_call_lines, sanitize_detail_text, tool_call_from_line, tool_calls_path, + DirectToolCall, DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT, + DIRECT_TOOL_CALL_SCHEMA_VERSION, }; use serde_json::json; + use std::path::Path; + + /// 写侧用例直接读文件:回读命令退役后不再经过 `normalize_tool_calls` 的合并与裁剪, + /// 断言因此落在「磁盘上到底写了什么」这一层。 + fn persisted_tool_calls(root: &Path) -> Vec { + std::fs::read_to_string(tool_calls_path(root)) + .unwrap_or_default() + .lines() + .filter_map(tool_call_from_line) + .collect() + } /// 一行合法的落盘信封(回读用例的夹具)。 fn tool_call_row(id: &str, started_at: u64, updated_at: u64) -> String { @@ -812,7 +685,7 @@ mod tests { .expect("completed tool call"); persist_direct_tool_call_at(root.path(), &completed).expect("persist completed"); - let calls = read_direct_tool_calls_at(root.path()).expect("read tool calls"); + let calls = persisted_tool_calls(root.path()); assert_eq!(calls.len(), 1, "同一 id 只能有一行"); assert_eq!(calls[0].status, "completed"); assert_eq!(calls[0].started_at, 1000, "startedAt 不被 completed 覆盖"); @@ -912,85 +785,6 @@ mod tests { ); } - /// 判据:单行损坏只跳过该行,不整体失败;缺文件返回空数组。 - #[test] - fn tool_call_read_skips_corrupted_lines() { - let root = init_tool_call_project("tool-call-corrupt"); - let path = tool_calls_path(root.path()); - std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); - let good = serde_json::to_string(&json!({ - "type": "tool_call_item", - "payload": { - "schemaVersion": "agc-tool-call.v1", - "id": "item-good", - "turnId": "turn-1", - "kind": "command", - "title": "执行命令", - "summary": "npm run build", - "status": "completed", - "detail": {"command": "npm run build"}, - "startedAt": 1, - "updatedAt": 2 - } - })) - .expect("serialize good row"); - std::fs::write( - &path, - format!("{good}\n{{ not json\n{{\"type\":\"other\",\"payload\":{{}}}}\n{good}\n"), - ) - .expect("write fixture"); - - let missing = tempfile::tempdir().expect("missing dir"); - assert!( - read_direct_tool_calls_at(missing.path()) - .expect("missing file is empty") - .is_empty(), - "历史文件缺失必须返回空数组" - ); - - let calls = read_direct_tool_calls_at(root.path()).expect("read with corrupted lines"); - assert_eq!(calls.len(), 1, "坏行被跳过,同 id 归并成一条"); - assert_eq!(calls[0].id, "item-good"); - } - - /// 判据:回读按时间正序,且超出上限时保留最新。 - #[test] - fn tool_call_read_is_ordered_and_capped() { - let root = init_tool_call_project("tool-call-cap"); - let total = DIRECT_TOOL_CALL_LIMIT + 5; - let calls = (0..total) - .map(|index| { - direct_tool_call_from_item( - root.path(), - &json!({ - "id": format!("item-{index:04}"), - "type": "commandExecution", - "command": format!("run {index}"), - "startedAtMs": 1000 + index as u64, - }), - "turn-1", - false, - 1000 + index as u64, - ) - .expect("tool call") - }) - .collect::>(); - persist_direct_tool_calls_at(root.path(), &calls).expect("persist batch"); - - let read = read_direct_tool_calls_at(root.path()).expect("read capped"); - assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "超出上限保留最新 N 条"); - assert_eq!( - read.first().expect("first").id, - format!("item-{:04}", total - DIRECT_TOOL_CALL_LIMIT), - "最早被裁掉的是最旧的条目" - ); - assert!( - read.windows(2) - .all(|pair| pair[0].timestamp() <= pair[1].timestamp()), - "回读必须按时间正序" - ); - } - /// 判据:fileChange 的标题按去重后的变更数量,摘要取首个变更路径。 #[test] fn tool_call_file_change_title_counts_unique_paths() { @@ -1189,7 +983,7 @@ mod tests { persist_direct_tool_call_at(root.path(), &completed).expect("persist completed first"); persist_direct_tool_call_at(root.path(), &running).expect("persist stale running"); - let calls = read_direct_tool_calls_at(root.path()).expect("read after stale single write"); + let calls = persisted_tool_calls(root.path()); assert_eq!(calls.len(), 1, "同一 id 只能有一行"); assert_eq!( calls[0].status, "completed", @@ -1201,7 +995,7 @@ mod tests { // 回合末整批落盘那条路径同样不得回退。 persist_direct_tool_calls_at(root.path(), std::slice::from_ref(&running)) .expect("persist stale running batch"); - let calls = read_direct_tool_calls_at(root.path()).expect("read after stale batch write"); + let calls = persisted_tool_calls(root.path()); assert_eq!( calls[0].status, "completed", "整批落盘路径同样不得把 completed 打回 running" @@ -1209,28 +1003,6 @@ mod tests { assert_eq!(calls[0].updated_at, 2000, "整批落盘不得回退 updatedAt"); } - /// 判据:读回时同 id 的重复行也按 `updatedAt` 单调合并(磁盘上留有旧快照不得回退状态)。 - #[test] - fn tool_call_read_merges_duplicate_rows_monotonically() { - let root = init_tool_call_project("tool-call-read-monotonic"); - let path = tool_calls_path(root.path()); - std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); - let completed = tool_call_row("item-1", 1000, 2000); - let stale_running = tool_call_row("item-1", 1000, 1000) - .replace("\"status\":\"completed\"", "\"status\":\"running\""); - assert!(stale_running.contains("\"status\":\"running\"")); - std::fs::write(&path, format!("{completed}\n{stale_running}\n")).expect("write fixture"); - - let calls = read_direct_tool_calls_at(root.path()).expect("read duplicate rows"); - assert_eq!(calls.len(), 1, "同 id 归并成一条"); - assert_eq!( - calls[0].status, "completed", - "磁盘上更旧的快照不得把状态打回 running" - ); - assert_eq!(calls[0].updated_at, 2000, "归并保留更新的 updatedAt"); - assert_eq!(calls[0].started_at, 1000); - } - /// 判据:项目内绝对路径落成项目相对路径,项目外绝对路径保持既有占位形状。 #[test] fn tool_call_paths_become_project_relative() { @@ -1283,9 +1055,10 @@ mod tests { ); } - /// 判据:单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回。 + /// 判据:写前读取时单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回, + /// 否则一次截断写入会把整份工具卡片从后续重写里抹掉。 #[test] - fn tool_call_read_skips_invalid_utf8_line() { + fn tool_call_pre_read_skips_invalid_utf8_line() { let root = init_tool_call_project("tool-call-invalid-utf8"); let path = tool_calls_path(root.path()); std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); @@ -1298,7 +1071,7 @@ mod tests { bytes.extend_from_slice(tool_call_row("item-b", 2000, 2000).as_bytes()); bytes.push(b'\n'); std::fs::write(&path, &bytes).expect("write invalid utf8 fixture"); - let calls = read_direct_tool_calls_at(root.path()).expect("read with invalid utf8"); + let calls = read_tool_call_lines(&path); assert_eq!( calls.len(), 2, @@ -1318,7 +1091,7 @@ mod tests { bytes.extend_from_slice(tool_call_row("item-c", 3000, 3000).as_bytes()); bytes.push(b'\n'); std::fs::write(&path, &bytes).expect("write truncated utf8 fixture"); - let calls = read_direct_tool_calls_at(root.path()).expect("read with truncated line"); + let calls = read_tool_call_lines(&path); assert_eq!( calls.len(), 2, @@ -1326,10 +1099,16 @@ mod tests { ); assert_eq!(calls[0].id, "item-a"); assert_eq!(calls[1].id, "item-c"); + + let missing = tempfile::tempdir().expect("missing dir"); + assert!( + read_tool_call_lines(&tool_calls_path(missing.path())).is_empty(), + "历史文件缺失时写前读取必须返回空表" + ); } - /// 判据:200 条上限是「按时间保留最新 200 条」,超出时更早回合的卡片会被静默丢弃 - /// (契约内行为,不是缺陷)。本用例只钉住现状与时间正序。 + /// 判据:落到磁盘上的行同样受 200 条上限约束——「按时间保留最新 200 条」,更早回合的 + /// 卡片会被静默丢弃(契约内行为,不是缺陷)。本用例只钉住现状与时间正序。 #[test] fn tool_call_cap_drops_oldest_turn_cards() { let root = init_tool_call_project("tool-call-cap-oldest"); @@ -1366,7 +1145,7 @@ mod tests { .expect("newest tool call"); persist_direct_tool_call_at(root.path(), &newest).expect("persist newest"); - let read = read_direct_tool_calls_at(root.path()).expect("read capped"); + let read = persisted_tool_calls(root.path()); assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "上限仍是 200 条"); assert_eq!( read.last().expect("last").id, @@ -1381,7 +1160,7 @@ mod tests { assert!( read.windows(2) .all(|pair| pair[0].timestamp() <= pair[1].timestamp()), - "回读必须按时间正序" + "落盘顺序必须按时间正序" ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 1005ed813..47820340a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -16,7 +16,6 @@ const DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES: usize = 2 * 1024 * 1024; const DIRECT_TOOLS_MCP_MAX_ART_BRIEF_CHARS: usize = 4_000; const DIRECT_TOOLS_MCP_MAX_IMAGE_PROMPT_CHARS: usize = 32_000; const DIRECT_TOOLS_MCP_MAX_SEARCH_QUERY_CHARS: usize = 400; -const DIRECT_TOOLS_MCP_MAX_RESOURCE_PROMPT_CHARS: usize = 4_000; const DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS: usize = 120; const DIRECT_TOOLS_MCP_MAX_WRITE_CONTENT_BYTES: usize = 1_500_000; const DIRECT_TOOLS_MCP_MAX_BRIDGE_RESPONSE_BYTES: usize = 32 * 1024 * 1024; @@ -49,7 +48,7 @@ struct ExternalMcpHttpState { root: PathBuf, token: String, session_user_id: String, - session_generation: u64, + session_identity_generation: u64, } pub(crate) fn direct_tools_mcp_mode_requested(args: &[String]) -> bool { @@ -103,6 +102,32 @@ async fn direct_tools_mcp_specs() -> Value { direct_tools_mcp_specs_for(controlled_web_search_enabled(), cocos_editor_available) } +/// 工具 kind(wire 值)对应的提示词上限。 +/// +/// 数字只来自客户端资源编辑权威口径(`resource_edit_prompt_max_chars`);未知 kind 直接 +/// panic,避免 schema 与真实校验静默漂移。 +fn resource_tool_prompt_max_chars(kind: &str) -> usize { + DirectResourceGenerationKind::parse(kind) + .unwrap_or_else(|error| panic!("{kind} 不是受支持的媒体资源类型:{error}")) + .prompt_max_chars() +} + +/// `agc_create_or_derive_resource` 顶层 `prompt.maxLength`:本工具所有受支持 kind 的上限最大值。 +/// +/// 仍然保留顶层上限,供忽略 `allOf` / `oneOf` 的调用方使用;逐 kind 的精确上限在分支里声明。 +fn resource_tool_prompt_schema_max_chars() -> usize { + [ + "background-music", + "sound-effect", + "video", + "character-animation", + ] + .into_iter() + .map(resource_tool_prompt_max_chars) + .max() + .unwrap_or(0) +} + fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_available: bool) -> Value { let tools = vec![ json!({ @@ -208,7 +233,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab }), json!({ "name": "agc_generate_image", - "description": "按原网站图片画布能力生成一张新图片:普通插画、角色立绘、统一视觉规范图、游戏 UI 设计图或透明游戏素材图集都可使用。仅在用户明确要求生成新图时调用;游戏美术包是另一个专用工具,不是本工具的限制。客户端负责登录态授权、计费、幂等账本、下载校验、manifest/revision 登记和本地预览,不需要用户提供 API Key、Token、URL 或 .env。", + "description": "按原网站图片画布能力生成一张新图片:普通插画、角色立绘、统一视觉规范图、游戏 UI 设计图或透明游戏素材图集都可使用。仅在用户明确要求生成新图时调用。", "inputSchema": { "type": "object", "properties": { @@ -222,7 +247,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "type": "string", "enum": PLATFORM_ART_ASSET_GENERATION_KINDS, "default": "image", - "description": "image=普通新图,character=角色图,spec/icon-spec=统一视觉规范图(spec 是服务端同义词,客户端统一登记为 icon-spec),ui-prototype=完整 UI 设计图,art-spritesheet=透明游戏素材图集(项目须已有 icon-spec 规范图),publication-material=发布宣传图" + "description": "image=普通新图(不做额外处理),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),spec/icon-spec=统一视觉规范图(spec 是服务端同义词,客户端统一登记为 icon-spec),ui-prototype=完整 UI 设计图,art-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图" }, "aspectRatio": { "type": "string", @@ -248,20 +273,29 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "sliceMode": { "type": "string", "enum": ["connected-components", "grid"], - "default": "connected-components", - "description": "仅 kind=art-spritesheet 生效:connected-components 按透明像素连通域切分,grid 按 gridX×gridY 网格切分" + "description": "仅 kind=art-spritesheet 生效,且必填、没有默认值:需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入来自需求本身的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,需要约束素材张数时用 sliceCount。省略、与 kind 不匹配或与 gridX/gridY 互相矛盾时客户端直接拒绝,不会替你选择" }, "gridX": { "type": "integer", "minimum": 1, "maximum": 32, - "description": "grid 模式横向网格数量" + "description": "grid 模式横向网格数量,只能与 sliceMode=grid 同时提供" }, "gridY": { "type": "integer", "minimum": 1, "maximum": 32, - "description": "grid 模式纵向网格数量" + "description": "grid 模式纵向网格数量,只能与 sliceMode=grid 同时提供" + }, + "sliceCount": { + "type": "integer", + "minimum": 1, + "maximum": 256, + "description": "只与 kind=art-spritesheet 且 sliceMode=connected-components 同时提供,用于约束目标素材张数;省略时按图像内容自动识别" + }, + "screenColor": { + "type": "string", + "description": "抠图纯色背景,仅 kind=character(角色形象)和 kind=art-spritesheet(图标素材)生效,其它 kind 携带会被拒绝。生成时把主体置于该纯色背景上,回图后据此抠除背景。取值只能是 auto 或下列色板 hex 之一,传值只填 hex 本身、不要附带色名:#CFEFFF(浅雾蓝)、#B0C2E0(浅钢蓝)、#FFD6C2(暖浅桃色)、#E6D8FF(淡薰衣草紫)、#F4D8E8(浅粉灰)、#7FB3FF(中度天蓝)、#FFF2A8(浅柠黄)、#CFFFE1(淡薄荷绿)、#D8DEE8(浅中性灰)、#D8D2E8(淡灰紫)、#A8F7F0(高对比浅青)、#A0BBA0(灰竹绿);auto 时由服务端自动选色。手动指定时不能与角色或素材本体的颜色接近" } }, "required": ["prompt"], @@ -345,7 +379,8 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab }, "kind": { "type": "string", - "enum": ["all", "image", "font", "audio", "video", "document", "code"] + "enum": ["all", "image", "font", "audio", "video", "document", "code", "model", "binary"], + "description": "image/font/audio/video/document/code 是通用类别;model 是 Cocos 等引擎的三维模型数据,binary 是只能发现、当前无法在客户端预览的二进制资源" }, "offset": { "type": "integer", "minimum": 0, "maximum": 500 }, "limit": { "type": "integer", "minimum": 1, "maximum": 100 } @@ -407,7 +442,8 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "prompt": { "type": "string", "minLength": 1, - "maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_PROMPT_CHARS + "maxLength": resource_tool_prompt_schema_max_chars(), + "description": "资源描述或改造要求。按 kind 有硬上限,超限会被客户端直接拒绝:background-music 最多 140 字符、sound-effect 最多 1900 字符、video / character-animation 最多 4000 字符。图片编辑走 agc_edit_image。" }, "assetName": { "type": "string", @@ -417,17 +453,47 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab } }, "required": ["kind", "mode", "prompt", "assetName"], - "oneOf": [ + "allOf": [ { - "properties": { - "mode": { "const": "create" }, - "kind": { "enum": ["video", "sound-effect", "background-music"] } - }, - "not": { "required": ["sourceLocalAssetId"] } + "oneOf": [ + { + "properties": { + "mode": { "const": "create" }, + "kind": { "enum": ["video", "sound-effect", "background-music"] } + }, + "not": { "required": ["sourceLocalAssetId"] } + }, + { + "properties": { "mode": { "const": "derive" } }, + "required": ["sourceLocalAssetId"] + } + ] }, { - "properties": { "mode": { "const": "derive" } }, - "required": ["sourceLocalAssetId"] + // 逐 kind 声明真实提示词上限,与 resource_edit_prompt_max_chars 同口径。 + "oneOf": [ + { + "properties": { + "kind": { "const": "background-music" }, + "prompt": { "maxLength": resource_tool_prompt_max_chars("background-music") } + }, + "required": ["kind"] + }, + { + "properties": { + "kind": { "const": "sound-effect" }, + "prompt": { "maxLength": resource_tool_prompt_max_chars("sound-effect") } + }, + "required": ["kind"] + }, + { + "properties": { + "kind": { "enum": ["video", "character-animation"] }, + "prompt": { "maxLength": resource_tool_prompt_max_chars("video") } + }, + "required": ["kind"] + } + ] } ], "additionalProperties": false @@ -435,7 +501,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab }), json!({ "name": "agc_remove_background", - "description": "为当前项目已登记的图片资源去除背景。客户端使用当前登录账号的抠图服务、项目画布和素材目录,模型只能提供已登记资源身份与结果名称;不会返回 Token、内部路由、宿主路径或临时签名 URL。", + "description": "为当前项目已登记的图片资源去除背景。complex 通过语义分割识别前景;flat 用于纯色背景抠图,确定背景为纯色时优先选择 flat。提供资源身份、结果名称及可选模式和背景色;客户端管理登录、项目画布和素材目录,不返回 Token、内部路由、宿主路径或临时签名 URL。", "inputSchema": { "type": "object", "properties": { @@ -449,6 +515,16 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab "type": "string", "minLength": 1, "maxLength": DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS + }, + "backgroundMode": { + "type": "string", + "enum": ["complex", "flat"], + "description": "可选抠图模式:complex 用语义分割识别前景,flat 用纯色背景抠图;确定背景为纯色时优先使用 flat。省略时使用 complex" + }, + "screenColor": { + "type": "string", + "pattern": "^(auto|#[0-9A-Fa-f]{6})$", + "description": "flat 模式可选背景色;传 auto 或 #RRGGBB,省略时由服务自动检测" } }, "required": ["sourceLocalAssetId", "assetName"], @@ -706,7 +782,10 @@ fn validate_project_file_list_arguments(arguments: &Value) -> Result<(), String> } if arguments.get("kind").is_some() { let kind = bounded_tool_string(arguments, "kind", 16)?; - if !["all", "image", "font", "audio", "video", "document", "code"].contains(&kind.as_str()) + if ![ + "all", "image", "font", "audio", "video", "document", "code", "model", "binary", + ] + .contains(&kind.as_str()) { return Err("工具参数 kind 不是受支持的项目文件类别".to_string()); } @@ -840,25 +919,14 @@ fn validate_resource_generation_arguments(arguments: &Value) -> Result<(), Strin &["kind", "mode", "sourceLocalAssetId", "prompt", "assetName"], )?; let kind = bounded_tool_string(arguments, "kind", 80)?; - if ![ - "video", - "character-animation", - "sound-effect", - "background-music", - ] - .contains(&kind.as_str()) - { - return Err("工具参数 kind 不是受支持的媒体资源类型".to_string()); - } + let generation_kind = DirectResourceGenerationKind::parse(&kind)?; let mode = bounded_tool_string(arguments, "mode", 16)?; if !["create", "derive"].contains(&mode.as_str()) { return Err("工具参数 mode 必须是 create 或 derive".to_string()); } - let prompt = bounded_tool_string( - arguments, - "prompt", - DIRECT_TOOLS_MCP_MAX_RESOURCE_PROMPT_CHARS, - )?; + // prompt 的形状校验只用信封级上限,真正生效的按 kind 上限由紧随其后的权威判定给出 + // 精确数字;否则通用 4000 会先于按 kind 上限误报成安全边界错误。 + let prompt = bounded_tool_string(arguments, "prompt", DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES)?; bounded_tool_string( arguments, "assetName", @@ -868,8 +936,12 @@ fn validate_resource_generation_arguments(arguments: &Value) -> Result<(), Strin if source.is_some() { bounded_tool_string(arguments, "sourceLocalAssetId", 80)?; } - if kind == "background-music" && prompt.chars().count() > 140 { - return Err("背景音乐提示词必须在 1..=140 字符内".to_string()); + let prompt_max_chars = generation_kind.prompt_max_chars(); + if prompt.chars().count() > prompt_max_chars { + return Err(resource_edit_prompt_limit_error( + &generation_kind.edit_kind(), + prompt_max_chars, + )); } if kind == "character-animation" && mode == "create" { return Err("角色动画必须基于已登记图片资源派生".to_string()); @@ -881,14 +953,46 @@ fn validate_resource_generation_arguments(arguments: &Value) -> Result<(), Strin } } -fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> { - validate_tool_object_fields(arguments, &["sourceLocalAssetId", "assetName"])?; +pub(super) fn validate_remove_background_arguments(arguments: &Value) -> Result<(), String> { + validate_tool_object_fields( + arguments, + &[ + "sourceLocalAssetId", + "assetName", + "backgroundMode", + "screenColor", + ], + )?; bounded_tool_string(arguments, "sourceLocalAssetId", 80)?; bounded_tool_string( arguments, "assetName", DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS, )?; + if let Some(mode) = arguments.get("backgroundMode") { + let mode = mode + .as_str() + .ok_or_else(|| "backgroundMode 必须是 complex 或 flat".to_string())?; + if mode != "complex" && mode != "flat" { + return Err("backgroundMode 必须是 complex 或 flat".to_string()); + } + } + if let Some(color) = arguments.get("screenColor") { + let color = color + .as_str() + .ok_or_else(|| "screenColor 必须是 auto 或 #RRGGBB".to_string())?; + let valid_hex = color.len() == 7 + && color.starts_with('#') + && color[1..] + .chars() + .all(|character| character.is_ascii_hexdigit()); + if color != "auto" && !valid_hex { + return Err("screenColor 必须是 auto 或 #RRGGBB".to_string()); + } + if arguments.get("backgroundMode").and_then(Value::as_str) != Some("flat") { + return Err("complex 模式不能传 screenColor".to_string()); + } + } Ok(()) } @@ -1037,6 +1141,8 @@ async fn call_agc_generate_image(arguments: &Value) -> Value { "sliceMode", "gridX", "gridY", + "sliceCount", + "screenColor", ], ) { return mcp_tool_result(error, Vec::new(), true); @@ -1065,6 +1171,7 @@ async fn call_agc_generate_image(arguments: &Value) -> Value { ("assetName", DIRECT_TOOLS_MCP_MAX_RESOURCE_NAME_CHARS), ("outputPath", 512), ("sliceMode", 32), + ("screenColor", 16), ] { if arguments.get(field).is_some() { if let Err(error) = bounded_tool_string(arguments, field, max_chars) { @@ -1241,7 +1348,8 @@ fn external_mcp_session_id(root: &Path) -> String { material.push('\0'); material.push_str(&session.user_id); material.push('\0'); - material.push_str(&session.generation.to_string()); + // 用身份代次而不是 token:同一账号续期不得让 MCP 会话身份漂移。 + material.push_str(&session.identity_generation.to_string()); } format!("mcp-{:x}", Sha256::digest(material.as_bytes())) } @@ -1759,7 +1867,9 @@ async fn handle_external_mcp_http_request( let Some(session) = current_platform_session() else { return Err(StatusCode::UNAUTHORIZED); }; - if session.user_id != state.session_user_id || session.generation != state.session_generation { + if session.user_id != state.session_user_id + || session.identity_generation != state.session_identity_generation + { return Err(StatusCode::UNAUTHORIZED); } let response = EXTERNAL_MCP_BRIDGE_URL @@ -1794,7 +1904,7 @@ pub(crate) async fn start_external_mcp_loopback( root, token: token.clone(), session_user_id: session.user_id, - session_generation: session.generation, + session_identity_generation: session.identity_generation, }; let app = Router::new() .route(&route, post(handle_external_mcp_http_request)) @@ -1846,6 +1956,51 @@ pub(crate) fn stop_game_creator_external_mcp() -> Result<(), String> { mod tests { use super::*; + #[test] + fn remove_background_arguments_enforce_mode_color_contract() { + for fields in [ + json!({}), + json!({"backgroundMode":"complex"}), + json!({"backgroundMode":"flat"}), + json!({"backgroundMode":"flat","screenColor":"auto"}), + json!({"backgroundMode":"flat","screenColor":"#Ab12EF"}), + ] { + let mut arguments = json!({"sourceLocalAssetId":"asset-1","assetName":"透明图"}); + arguments + .as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + assert!( + validate_remove_background_arguments(&arguments).is_ok(), + "{fields}" + ); + } + for fields in [ + json!({"screenColor":"auto"}), + json!({"backgroundMode":"complex","screenColor":"auto"}), + json!({"backgroundMode":"flat","screenColor":""}), + json!({"backgroundMode":"flat","screenColor":" auto "}), + json!({"backgroundMode":"flat","screenColor":"AUTO"}), + json!({"backgroundMode":"flat","screenColor":"#GGGGGG"}), + json!({"backgroundMode":"flat","screenColor":null}), + json!({"backgroundMode":"flat","screenColor":12}), + json!({"backgroundMode":""}), + json!({"backgroundMode":"FLAT"}), + json!({"backgroundMode":" flat "}), + json!({"backgroundMode":null}), + ] { + let mut arguments = json!({"sourceLocalAssetId":"asset-1","assetName":"透明图"}); + arguments + .as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + assert!( + validate_remove_background_arguments(&arguments).is_err(), + "{fields}" + ); + } + } + #[cfg(all(windows, feature = "cocos-editor-execute"))] #[test] fn builtin_mcp_process_probe() { @@ -2249,11 +2404,49 @@ mod tests { assert_eq!(image_tool["inputSchema"]["required"], json!(["prompt"])); assert!(image_tool["description"] .as_str() - .is_some_and(|description| description.contains("不是本工具的限制"))); + .is_some_and(|description| description.contains("仅在用户明确要求生成新图时调用"))); + assert!( + image_tool["inputSchema"]["properties"]["kind"]["description"] + .as_str() + .is_some_and(|description| description.contains("自动抠图") + && description.contains("prompt 只描述角色主体") + && description.contains("不做额外处理")), + "kind description must carry the auto-matting semantics" + ); assert_eq!( image_tool["inputSchema"]["properties"]["sliceMode"]["enum"], json!(["connected-components", "grid"]) ); + assert_eq!( + image_tool["inputSchema"]["properties"]["sliceCount"]["minimum"], + json!(1) + ); + assert_eq!( + image_tool["inputSchema"]["properties"]["sliceCount"]["maximum"], + json!(256) + ); + assert!( + image_tool["inputSchema"]["properties"]["screenColor"]["description"] + .as_str() + .is_some_and(|description| description.contains("抠图纯色背景") + && description.contains("kind=character") + && description.contains("不要附带色名")), + "screenColor description must carry the matting-background semantics" + ); + assert!( + image_tool["inputSchema"]["properties"]["sliceMode"] + .get("default") + .is_none(), + "sliceMode must not advertise a default" + ); + assert!( + image_tool["inputSchema"]["properties"]["sliceMode"]["description"] + .as_str() + .is_some_and(|description| description.contains("没有默认值") + && description.contains("gridX") + && description.contains("connected-components")), + "sliceMode description must carry the explicit decision requirement" + ); let edit_tool = specs["tools"] .as_array() .expect("tool array") @@ -2666,4 +2859,686 @@ mod tests { "显式 Codex 返回不能再往项目主对话写 legacy 行" ); } + + // --------------------------------------------------------------------------------------- + // 工具层 → 客户端受控工具桥 → 假平台:媒体工具契约的确定性验收。 + // + // 夹具只回答资源编辑链路真正会发的请求,任何未预期请求直接 panic;源图片的 + // binding 在这里预置,因为「上传票据 → OSS 表单上传 → 对象确认 → 登记项目资源」 + // 子链已有专门用例覆盖,本组只钉工具名 / 参数校验 / 出站请求契约。 + // --------------------------------------------------------------------------------------- + + const TOOL_CHAIN_CANVAS_PROJECT_ID: &str = "remote-canvas-project"; + const TOOL_CHAIN_ASSET_FOLDER_ID: &str = "remote-asset-folder"; + const TOOL_CHAIN_ACCESS_TOKEN: &str = "tool-chain-token"; + const TOOL_CHAIN_MEDIA_ROUTE: &str = "/generated/resource-edit-result"; + + fn tool_chain_png() -> Vec { + let mut bytes = Vec::new(); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 2, + 2, + image::Rgba([12, 34, 56, 255]), + )) + .write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Png, + ) + .expect("encode tool chain png"); + bytes + } + + fn tool_chain_mp3() -> Vec { + let mut bytes = b"ID3\x04\x00\x00\x00\x00\x00\x0a".to_vec(); + bytes.extend_from_slice(&[0_u8; 32]); + bytes + } + + fn tool_chain_image_asset(id: &str, local_path: &str) -> GameCreationAppAssetManifestEntry { + GameCreationAppAssetManifestEntry { + id: id.to_string(), + kind: "image".to_string(), + media_type: "image/png".to_string(), + local_path: local_path.to_string(), + image_sequence_frames: None, + image_sequence_duration_ms: None, + category: game_creation_app_asset_category_for_kind("image"), + tags: Vec::new(), + source: GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some(TOOL_CHAIN_CANVAS_PROJECT_ID.to_string()), + resource_id: Some("editor-resource-hero".to_string()), + asset_object_id: Some("assetobj-hero".to_string()), + task_id: Some("tool-chain-task".to_string()), + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + } + } + + fn tool_chain_accept(listener: &std::net::TcpListener) -> std::net::TcpStream { + listener + .set_nonblocking(true) + .expect("set tool chain listener nonblocking"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + loop { + match listener.accept() { + Ok((stream, _)) => { + stream + .set_nonblocking(false) + .expect("restore tool chain stream blocking mode"); + return stream; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "等待工具链夹具请求超时" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Err(error) => panic!("接受工具链夹具请求失败:{error}"), + } + } + } + + fn tool_chain_read_request(stream: &mut std::net::TcpStream) -> String { + use std::io::Read; + + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .expect("set tool chain read timeout"); + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 4096]; + let (header_end, content_length) = loop { + let read = stream.read(&mut buffer).expect("read tool chain request"); + assert!(read > 0, "工具链夹具请求在请求头结束前关闭"); + bytes.extend_from_slice(&buffer[..read]); + let Some(header_end) = bytes.windows(4).position(|value| value == b"\r\n\r\n") else { + continue; + }; + let header_text = String::from_utf8_lossy(&bytes[..header_end]); + let content_length = header_text + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + break (header_end + 4, content_length); + }; + while bytes.len() < header_end + content_length { + let read = stream + .read(&mut buffer) + .expect("read tool chain request body"); + assert!(read > 0, "工具链夹具请求在请求体结束前关闭"); + bytes.extend_from_slice(&buffer[..read]); + } + String::from_utf8_lossy(&bytes).into_owned() + } + + fn tool_chain_write_json(stream: &mut std::net::TcpStream, status: &str, body: Value) { + use std::io::Write; + + let body = body.to_string(); + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len(), + ) + .expect("write tool chain json response"); + } + + fn tool_chain_write_media(stream: &mut std::net::TcpStream, media_type: &str, bytes: &[u8]) { + use std::io::Write; + + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: {media_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + bytes.len(), + ) + .expect("write tool chain media headers"); + stream + .write_all(bytes) + .expect("write tool chain media bytes"); + } + + /// 假平台:按固定次数回答资源编辑链路的请求,并回放收到的每个请求正文。 + fn tool_chain_spawn_platform( + listener: std::net::TcpListener, + expected_submission: &'static str, + media_type: &'static str, + media: Vec, + request_count: usize, + ) -> ( + std::thread::JoinHandle>, + std::sync::mpsc::Receiver, + ) { + let media_url = format!( + "http://{}{TOOL_CHAIN_MEDIA_ROUTE}", + listener.local_addr().expect("tool chain fixture address") + ); + let (sender, receiver) = std::sync::mpsc::channel(); + let handle = std::thread::spawn(move || { + let mut requests = Vec::new(); + for _ in 0..request_count { + let mut stream = tool_chain_accept(&listener); + let request = tool_chain_read_request(&mut stream); + let request_line = request.lines().next().unwrap_or_default().to_string(); + let route = request_line + .split_whitespace() + .nth(1) + .unwrap_or_default() + .to_string(); + sender.send(request.clone()).ok(); + requests.push(request); + if route.starts_with("/api/editor/projects") { + tool_chain_write_json( + &mut stream, + "200 OK", + json!({"data": {"projects": [{ + "projectId": TOOL_CHAIN_CANVAS_PROJECT_ID, + "title": "工具链远端画布" + }]}}), + ); + } else if route.starts_with("/api/editor/assets/library") { + tool_chain_write_json( + &mut stream, + "200 OK", + json!({"data": {"library": {"folders": [{ + "folderId": TOOL_CHAIN_ASSET_FOLDER_ID, + "label": "工具链远端目录" + }]}}}), + ); + } else if request_line.starts_with(expected_submission) { + tool_chain_write_json( + &mut stream, + "202 Accepted", + json!({"data": { + "operationId": "tool-chain-operation", + "status": "queued", + "pollAfterMs": 0 + }}), + ); + } else if route.starts_with("/api/runtime/external-generation/jobs/") { + tool_chain_write_json( + &mut stream, + "200 OK", + json!({"data": { + "status": "completed", + "result": {"resource": { + "resourceId": "editor-resource-derived", + "objectKey": "generated/resource-edit-result", + "assetObjectId": "assetobj-derived" + }} + }}), + ); + } else if route.starts_with("/api/assets/read-url") { + tool_chain_write_json( + &mut stream, + "200 OK", + json!({"data": {"read": {"signedUrl": media_url}}}), + ); + } else if route.starts_with(TOOL_CHAIN_MEDIA_ROUTE) { + tool_chain_write_media(&mut stream, media_type, &media); + } else { + panic!("工具链夹具收到未预期请求:{request_line}"); + } + } + requests + }); + (handle, receiver) + } + + /// 真实项目 + 真实工具桥 + 真实 Direct 回合身份。 + async fn tool_chain_start( + root: &Path, + ) -> ( + super::super::direct_tool_bridge::DirectToolBridge, + DirectTaonierActiveInvocationGuard, + ) { + let bridge = super::super::direct_tool_bridge::start_direct_tool_bridge(root, false) + .await + .expect("start tool chain bridge"); + let turn = DirectTaonierActiveInvocationGuard::enter(root, "tool-chain-turn") + .expect("arm tool chain direct turn"); + (bridge, turn) + } + + async fn tool_chain_call( + bridge: &super::super::direct_tool_bridge::DirectToolBridge, + root: &Path, + name: &str, + arguments: Value, + ) -> Value { + EXTERNAL_MCP_BRIDGE_URL + .scope( + bridge.url().to_string(), + handle_direct_tools_mcp_request( + root, + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { "name": name, "arguments": arguments } + }), + ), + ) + .await + .expect("mcp response") + } + + fn tool_chain_payload(result: &Value) -> Value { + serde_json::from_str( + result["result"]["content"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("工具返回不是文本包:{result}")), + ) + .unwrap_or_else(|error| panic!("工具返回不是 JSON:{error};{result}")) + } + + /// P1 门禁:工具 schema 的 per-kind 上限、MCP 校验、工具桥校验与客户端权威口径必须是同一个数字。 + #[test] + fn tool_prompt_limits_agree_with_the_client_authority() { + let specs = direct_tools_mcp_specs_for(false, false); + let resource_tool = specs["tools"] + .as_array() + .expect("tool array") + .iter() + .find(|tool| tool["name"] == "agc_create_or_derive_resource") + .expect("resource tool"); + let schema_branches = resource_tool["inputSchema"]["allOf"] + .as_array() + .and_then(|all_of| all_of.get(1)) + .and_then(|branch| branch["oneOf"].as_array()) + .expect("per-kind prompt limit branches"); + + for (tool_kind, expected_kind) in [ + ( + "background-music", + LocalProjectResourceEditKind::BackgroundMusic, + ), + ("sound-effect", LocalProjectResourceEditKind::SoundEffect), + ("video", LocalProjectResourceEditKind::Video), + ( + "character-animation", + LocalProjectResourceEditKind::CharacterAnimation, + ), + ] { + let authority = resource_edit_prompt_max_chars(&expected_kind); + let generation_kind = DirectResourceGenerationKind::parse(tool_kind) + .unwrap_or_else(|error| panic!("{tool_kind}: {error}")); + assert_eq!( + generation_kind.edit_kind(), + expected_kind, + "{tool_kind} 必须映射到同一客户端类型" + ); + assert_eq!( + generation_kind.prompt_max_chars(), + authority, + "{tool_kind} 的工具桥上限必须来自客户端权威口径" + ); + let branch_limit = schema_branches + .iter() + .find_map(|branch| { + let kind = &branch["properties"]["kind"]; + let covers_kind = kind["const"].as_str() == Some(tool_kind) + || kind["enum"].as_array().is_some_and(|values| { + values.iter().any(|value| value.as_str() == Some(tool_kind)) + }); + covers_kind.then(|| { + branch["properties"]["prompt"]["maxLength"] + .as_u64() + .expect("branch prompt maxLength") + }) + }) + .unwrap_or_else(|| panic!("{tool_kind} 缺少按 kind 声明的提示词上限")); + assert_eq!( + branch_limit as usize, authority, + "{tool_kind} 的 schema 上限必须等于真实生效上限" + ); + assert!( + resource_tool["inputSchema"]["properties"]["prompt"]["description"] + .as_str() + .is_some_and(|text| text.contains(&authority.to_string())), + "{tool_kind} 的上限必须写进 prompt 描述:{authority}" + ); + let over_limit = "字".repeat(authority + 1); + let error = validate_resource_generation_arguments(&json!({ + "kind": tool_kind, + "mode": "create", + "prompt": over_limit, + "assetName": "上限测试" + })) + .expect_err("超过按 kind 上限的提示词必须被拒绝"); + assert!( + error.contains(&authority.to_string()), + "{tool_kind} 的拒绝文案必须带上真实上限:{error}" + ); + } + + // 图片编辑走独立工具,其上限同样是图片类型的权威口径。 + let image_authority = + resource_edit_prompt_max_chars(&LocalProjectResourceEditKind::ImageReference); + assert_eq!(image_authority, DIRECT_TOOLS_MCP_MAX_IMAGE_PROMPT_CHARS); + let edit_tool = specs["tools"] + .as_array() + .expect("tool array") + .iter() + .find(|tool| tool["name"] == "agc_edit_image") + .expect("image edit tool"); + assert_eq!( + edit_tool["inputSchema"]["properties"]["prompt"]["maxLength"], + json!(image_authority) + ); + } + + /// 源资源未登记时必须给出可执行的下一步,而不是只报「不属于已登记资源」。 + #[tokio::test] + async fn unregistered_source_reports_the_registration_follow_up_tools() { + let temporary = crate::tests::canonical_test_tempdir("direct-tools-unregistered-source-"); + let root = temporary.path(); + init_local_game_project_at(root, "direct-tools-unregistered-source", "未登记源资源提示") + .expect("init project"); + let (bridge, _turn_arm) = tool_chain_start(root).await; + let _turn = bridge.begin_user_turn().expect("begin client turn"); + + let result = tool_chain_call( + &bridge, + root, + "agc_edit_image", + json!({ + "sourceLocalAssetId": "missing-image", + "prompt": "把这张图改成夜景", + "assetName": "缺失源图编辑版" + }), + ) + .await; + + assert_eq!(result["result"]["isError"], true, "{result}"); + let message = result["result"]["content"][0]["text"] + .as_str() + .expect("tool error text"); + assert!( + message.contains("agc_list_registered_assets"), + "未登记源资源必须指向已登记资源查询工具:{message}" + ); + assert!( + message.contains("agc_import_account_assets"), + "未登记源资源必须指向登记工具:{message}" + ); + } + + /// 工具层 → 桥 → 假平台:图片快速编辑必须真的落到站内 `/api/editor/images/edits`。 + #[tokio::test] + async fn edit_image_tool_reaches_the_platform_image_edit_route() { + let temporary = crate::tests::canonical_test_tempdir("direct-tools-edit-image-"); + let root = temporary.path(); + init_local_game_project_at(root, "direct-tools-edit-image", "图片快速编辑工具链") + .expect("init project"); + let source_bytes = tool_chain_png(); + std::fs::create_dir_all(root.join("assets")).expect("create assets dir"); + std::fs::write(root.join("assets/hero.png"), &source_bytes).expect("write source png"); + let mut manifest = read_existing_manifest_for_project(root).expect("read manifest"); + manifest + .assets + .push(tool_chain_image_asset("hero-image", "assets/hero.png")); + write_manifest(&root.join(".agent/manifest.json"), &manifest).expect("write manifest"); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let (server, _requests) = tool_chain_spawn_platform( + listener, + "POST /api/editor/images/edits ", + "image/png", + tool_chain_png(), + 6, + ); + let _session = crate::platform_session::install_test_platform_session( + "tool-chain-owner", + TOOL_CHAIN_ACCESS_TOKEN, + &base_url, + ); + let session = current_platform_session().expect("platform session"); + let access = + ExternalEditorBindingAccess::new(&base_url, &session.access_token, Some(&session)) + .expect("account access"); + let principal = external_editor_binding_principal(&access).expect("account principal"); + let project_binding = new_external_editor_project_binding( + &manifest.project_id, + &principal, + TOOL_CHAIN_CANVAS_PROJECT_ID, + TOOL_CHAIN_ASSET_FOLDER_ID, + unix_timestamp(), + ) + .expect("project binding"); + write_external_editor_project_binding_at(root, &project_binding) + .expect("write project binding"); + let source_identity = new_external_editor_source_identity( + "hero-image", + &format!("{:x}", Sha256::digest(&source_bytes)), + "image/png", + "image", + ) + .expect("source identity"); + let resource_binding = new_external_editor_resource_binding( + &manifest.project_id, + &principal, + TOOL_CHAIN_CANVAS_PROJECT_ID, + &source_identity, + Some("editor-resource-hero"), + "source/hero.png", + "assetobj-hero", + Some(2), + Some(2), + unix_timestamp(), + ) + .expect("resource binding"); + write_external_editor_resource_binding_at(root, &resource_binding) + .expect("write resource binding"); + + let (bridge, _turn_arm) = tool_chain_start(root).await; + let _turn = bridge.begin_user_turn().expect("begin client turn"); + let result = tool_chain_call( + &bridge, + root, + "agc_edit_image", + json!({ + "sourceLocalAssetId": "hero-image", + "prompt": "把这张角色图改成夜景霓虹配色", + "assetName": "英雄-夜霓虹" + }), + ) + .await; + let requests = server.join().expect("join tool chain fixture"); + + assert_eq!(result["result"]["isError"], false, "{result}"); + let payload = tool_chain_payload(&result); + assert_eq!(payload["status"], "completed", "{payload}"); + assert_eq!(payload["kind"], "image"); + assert_eq!(payload["mode"], "derive"); + assert_eq!( + payload["resource"]["referenceResourceIds"], + json!(["local-asset:hero-image"]) + ); + let local_path = payload["resource"]["localPath"] + .as_str() + .expect("derived local path"); + assert!( + root.join(local_path).is_file(), + "派生图片必须落盘:{local_path}" + ); + + assert_eq!(requests.len(), 6, "{requests:?}"); + let submission = requests + .iter() + .find(|request| request.starts_with("POST /api/editor/images/edits ")) + .unwrap_or_else(|| panic!("缺少图片编辑提交请求:{requests:?}")); + let submission_lower = submission.to_ascii_lowercase(); + assert!(submission_lower.contains(&format!( + "authorization: bearer {}", + TOOL_CHAIN_ACCESS_TOKEN.to_ascii_lowercase() + ))); + assert!(submission_lower.contains("idempotency-key:")); + assert!(submission.contains("\"sourceReferenceId\":\"editor-resource-hero\"")); + assert!(submission.contains("\"assetLabel\":\"英雄-夜霓虹\"")); + assert!(submission.contains(&format!("\"projectId\":\"{TOOL_CHAIN_CANVAS_PROJECT_ID}\""))); + assert!(submission.contains(&format!( + "\"assetFolderId\":\"{TOOL_CHAIN_ASSET_FOLDER_ID}\"" + ))); + assert!( + !submission.contains("assetKind"), + "图片编辑请求不得回填 assetKind:{submission}" + ); + assert!(requests.iter().any(|request| request + .starts_with("GET /api/runtime/external-generation/jobs/tool-chain-operation "))); + assert!(requests + .iter() + .any(|request| request.starts_with("GET /api/assets/read-url?"))); + } + + /// 超过已发布上限的背景音乐提示词必须在工具层就被拒绝,且一次桥请求都不发出。 + #[tokio::test] + async fn background_music_prompt_over_the_limit_is_rejected_before_any_bridge_call() { + let temporary = crate::tests::canonical_test_tempdir("direct-tools-bgm-limit-"); + let root = temporary.path(); + init_local_game_project_at(root, "direct-tools-bgm-limit", "背景音乐上限工具链") + .expect("init project"); + let authority = + resource_edit_prompt_max_chars(&LocalProjectResourceEditKind::BackgroundMusic); + + let result = EXTERNAL_MCP_BRIDGE_URL + .scope( + // 故意指向没有服务监听的 loopback 地址:一旦真的发出桥请求,报错文案会变成连接失败。 + "http://127.0.0.1:1/tool-dead".to_string(), + handle_direct_tools_mcp_request( + root, + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "agc_create_or_derive_resource", + "arguments": { + "kind": "background-music", + "mode": "create", + "prompt": "字".repeat(authority + 1), + "assetName": "紧张战斗背景音乐" + } + } + }), + ), + ) + .await + .expect("mcp response"); + + assert_eq!(result["result"]["isError"], true, "{result}"); + let message = result["result"]["content"][0]["text"] + .as_str() + .expect("tool error text"); + assert!( + message.contains(&authority.to_string()) && message.contains("背景音乐"), + "必须按发布上限拒绝并说明类型:{message}" + ); + assert!( + !message.contains("连接客户端受控工具桥失败"), + "上限拒绝必须发生在桥请求之前:{message}" + ); + } + + /// 工具层 → 桥 → 假平台:背景音乐 create 必须落到站内音频生成路由。 + #[tokio::test] + async fn background_music_tool_reaches_the_platform_audio_route() { + let temporary = crate::tests::canonical_test_tempdir("direct-tools-bgm-route-"); + let root = temporary.path(); + init_local_game_project_at(root, "direct-tools-bgm-route", "背景音乐工具链") + .expect("init project"); + let manifest = read_existing_manifest_for_project(root).expect("read manifest"); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind fixture"); + let base_url = format!("http://{}", listener.local_addr().expect("fixture address")); + let (server, _requests) = tool_chain_spawn_platform( + listener, + "POST /api/editor/audios/background-music/generations ", + "audio/mpeg", + tool_chain_mp3(), + 6, + ); + let _session = crate::platform_session::install_test_platform_session( + "tool-chain-owner", + TOOL_CHAIN_ACCESS_TOKEN, + &base_url, + ); + let session = current_platform_session().expect("platform session"); + let access = + ExternalEditorBindingAccess::new(&base_url, &session.access_token, Some(&session)) + .expect("account access"); + let principal = external_editor_binding_principal(&access).expect("account principal"); + let project_binding = new_external_editor_project_binding( + &manifest.project_id, + &principal, + TOOL_CHAIN_CANVAS_PROJECT_ID, + TOOL_CHAIN_ASSET_FOLDER_ID, + unix_timestamp(), + ) + .expect("project binding"); + write_external_editor_project_binding_at(root, &project_binding) + .expect("write project binding"); + + let (bridge, _turn_arm) = tool_chain_start(root).await; + let _turn = bridge.begin_user_turn().expect("begin client turn"); + let result = tool_chain_call( + &bridge, + root, + "agc_create_or_derive_resource", + json!({ + "kind": "background-music", + "mode": "create", + "prompt": "紧张但克制的八位机战斗循环,鼓点清晰", + "assetName": "紧张战斗背景音乐" + }), + ) + .await; + let requests = server.join().expect("join tool chain fixture"); + + assert_eq!(result["result"]["isError"], false, "{result}"); + let payload = tool_chain_payload(&result); + assert_eq!(payload["status"], "completed", "{payload}"); + assert_eq!(payload["kind"], "background-music"); + assert_eq!(payload["mode"], "create"); + assert_eq!(payload["resource"]["mediaType"], "audio/mpeg"); + + assert_eq!(requests.len(), 6, "{requests:?}"); + let submission = requests + .iter() + .find(|request| { + request.starts_with("POST /api/editor/audios/background-music/generations ") + }) + .unwrap_or_else(|| panic!("缺少背景音乐提交请求:{requests:?}")); + assert!(submission.to_ascii_lowercase().contains(&format!( + "authorization: bearer {}", + TOOL_CHAIN_ACCESS_TOKEN.to_ascii_lowercase() + ))); + assert!(submission.contains("\"makeInstrumental\":true")); + assert!(submission.contains("紧张但克制的八位机战斗循环,鼓点清晰")); + assert!(submission.contains("\"assetLabel\":\"紧张战斗背景音乐\"")); + let body: Value = serde_json::from_str( + submission + .split("\r\n\r\n") + .nth(1) + .unwrap_or_else(|| panic!("背景音乐提交缺少请求体:{submission}")), + ) + .expect("background music submission body"); + let composed = body["gptDescriptionPrompt"] + .as_str() + .expect("composed background music prompt"); + assert!( + composed.starts_with("生成新音频;目标:") && composed.chars().count() < 200, + "create 模式必须使用无源前缀并落在现役接口上限内:{composed}" + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_stream.rs index a2c8f1b30..dcdc5829b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_stream.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_stream.rs @@ -1,4 +1,4 @@ -//! GameAgent 对话「回合流」的采集、持久化与回读。 +//! GameAgent 对话「回合流」的采集与持久化。 //! //! 顺序真相放在一处:`/.agent/conversations/turn-stream.jsonl` 按**出现顺序** //! 记录一个回合里的文本段与工具调用。工具条目只记位置标记(`callId`),工具本身的正文 @@ -11,7 +11,7 @@ //! `project.jsonl` 保留原始消息;本流补充文本与工具交替的 item 顺序,不能重复展示两份正文。 use crate::agent::sanitize_detail_text; -use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file}; +use crate::config::write_game_creator_private_file; use crate::project::{enforce_project_permission_policy, project_append_lock_for}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -339,15 +339,6 @@ pub(crate) fn upsert_direct_turn_stream_item_at( }) } -/// 回读:文件缺失返回空数组;单行损坏跳过;按 `seq` 正序,最多最后 400 条。 -pub(crate) fn read_direct_turn_stream_at(root: &Path) -> Result, String> { - let path = turn_stream_path(root); - if !prepare_game_creator_private_path_for_read(&path, false, "回合流历史")? { - return Ok(Vec::new()); - } - Ok(normalize_stream_items(read_stream_lines(&path))) -} - /// 追加一段固定身份的文本段(失败说明等):位置排在当前流末尾。 /// /// 幂等:同一 `(turnId, itemId)` 已经存在时只更新文本与 `updatedAt`(回合重放 / 重复收尾 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index e52bbcfea..4bad4708e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -67,10 +67,11 @@ pub(crate) use canvas_generation::{ generate_platform_art_asset_with_options_at, generate_platform_art_asset_with_required_slices_at, maybe_generate_platform_art_asset_step, needs_platform_art_asset_generation, normalize_platform_art_asset_generation_kind, + normalize_platform_art_reference_asset_ids, normalize_platform_art_target_category, platform_art_asset_art_spec, platform_art_asset_output_extension_matches, - prepare_platform_art_asset_output_path, project_canvas_asset_media_types, - role_has_canvas_assets, suggested_canvas_tool_call, PlatformArtAssetGenerationOptions, - PLATFORM_ART_ASSET_GENERATION_KINDS, + platform_art_runtime_references_match_request_contract, prepare_platform_art_asset_output_path, + project_canvas_asset_media_types, role_has_canvas_assets, suggested_canvas_tool_call, + PlatformArtAssetGenerationOptions, PLATFORM_ART_ASSET_GENERATION_KINDS, }; #[allow(unused_imports)] pub(crate) use draft_validation::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 8275aa26c..a4327aff2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -11,6 +11,10 @@ use super::external_generation_state::{ retain_platform_art_generation_runtime_accepted_result, PlatformArtGenerationRuntimeState, }; use super::*; +use crate::platform_session::{ + acquire_platform_session_identity_lease, validate_platform_session_identity, + PlatformSessionIdentity, +}; use reqwest::multipart::{Form, Part}; const EXTERNAL_GENERATION_POLL_TIMEOUT: Duration = Duration::from_secs(35 * 60); @@ -416,6 +420,31 @@ pub(crate) struct PlatformArtAssetGenerationOptions { pub(crate) slice_mode: Option, pub(crate) grid_x: Option, pub(crate) grid_y: Option, + /// 本次生成用作参考的**当前项目已登记图片素材 id**(manifest `assets[].id`)。 + /// + /// 只接受当前项目 manifest 身份:路径、远端 `resourceId` / `objectKey` 与跨项目素材都会在 + /// [`resolve_platform_art_generation_references_at`] 解析阶段被拒绝,素材内容再经本地文件、 + /// 图片解码与内容 hash 换成**当前账号**绑定下的远端资源 ID。 + /// + /// 它**刻意不进** standalone 动作指纹([`StandalonePlatformArtGenerationFingerprintMaterial`]): + /// 指纹只用来在同一项目里定位 durable 输出槽,改动会让已在途的计费账本换槽而重复 POST。 + /// 参考集合的身份由账本请求正文里的 `referenceImageSrcs` 快照承担,恢复时必须与本次请求逐项相符。 + pub(crate) reference_asset_ids: Vec, + /// GUI 生成完成时要落盘的**正式功能分类**(前端 `targetCategory`,取值与 + /// [`update_local_project_resource_classification_at`] 同一套词汇)。 + /// + /// 只有 GUI 侧 `start_local_project_asset_generation` / `generate_local_project_asset` 会传值: + /// 入口栏目生成的是 `kind=image` / `icon-spec`,按 kind 派生只会落到 `unclassified` / + /// `document`,与入口栏目不一致,占位无法被原位接管。Agent / Direct 路径保持 `None`, + /// 继续按 kind 派生默认分类。 + /// + /// 它**刻意不进** standalone 动作指纹与 durable 请求快照:指纹只用来定位同一个计费输出槽, + /// 改材料会让升级时在途的账本换槽并重复 POST;分类只影响本地 manifest 落盘,不影响远端 + /// 请求正文。同任务的幂等恢复由调用方继续用同一个栏目提交(与 `reference_asset_ids` 同口径)。 + pub(crate) target_category: Option, + /// 抠图纯色背景(auto/省略已归一为 None;Some 时是规范化后的大写 #RRGGBB)。 + /// 仅 character 与 art-spritesheet 链路透传给服务端。 + pub(crate) screen_color: Option, } impl Default for PlatformArtAssetGenerationOptions { @@ -431,10 +460,45 @@ impl Default for PlatformArtAssetGenerationOptions { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, + screen_color: None, } } } +/// 收口 GUI 完成登记层的目标分类:只接受 [`GameCreationAppAssetCategory`] 的合法取值 +/// (`ui-interaction` / `character` / `scene` / `audio` / `document` / `unclassified`), +/// 归一成落盘字符串。`version` / `all` 等栏目侧伪值不在枚举里,一律拒绝。 +pub(crate) fn normalize_platform_art_target_category( + target_category: Option<&str>, +) -> Result, String> { + let Some(target_category) = target_category else { + return Ok(None); + }; + let target_category = target_category.trim(); + if target_category.is_empty() { + return Ok(None); + } + let category = game_creation_app_asset_category_from_str(target_category) + .ok_or_else(|| format!("目标分类不是合法素材分类:{target_category}"))?; + // 落盘字符串直接取枚举自己的 kebab-case 序列化,避免再抄一份 vocabulary 出来漂移。 + let value = + serde_json::to_value(category).map_err(|error| format!("目标分类无法序列化:{error}"))?; + let value = value + .as_str() + .ok_or_else(|| "目标分类不是字符串枚举".to_string())?; + Ok(Some(value.to_string())) +} + +/// 普通图片生成合并规范图与用户参考后的**总参考上限**,沿用图片生成 API 已有上限。 +pub(crate) const PLATFORM_ART_MAX_REFERENCE_IMAGES: usize = 5; +/// 有规范图前置时允许的用户参考上限:规范图本身占 1 张,总量仍不超过 +/// [`PLATFORM_ART_MAX_REFERENCE_IMAGES`]。 +pub(crate) const PLATFORM_ART_MAX_USER_REFERENCE_IMAGES_WITH_CANONICAL_SPEC: usize = 4; +/// 单个参考素材 id 的长度上限(manifest 资产 id 是稳定短标识,不是路径)。 +const PLATFORM_ART_REFERENCE_ASSET_ID_MAX_CHARS: usize = 128; + /// 「无源生成图片类素材」参数化通道放行的 kind 目录。 /// /// GUI 侧 `generate_local_project_asset` 与 agent 侧 `agc_generate_image` 共用这一份目录, @@ -469,6 +533,67 @@ pub(crate) fn normalize_platform_art_asset_generation_kind(kind: &str) -> Option }) } +/// 需要规范图前置的生成类型:这些请求必须解析出当前账号的规范图引用,用户参考最多 +/// [`PLATFORM_ART_MAX_USER_REFERENCE_IMAGES_WITH_CANONICAL_SPEC`] 张。 +pub(crate) fn platform_art_asset_kind_requires_canonical_spec_reference(asset_kind: &str) -> bool { + matches!( + asset_kind, + "ui-prototype" | "game-background" | "art-spritesheet" + ) +} + +/// 只有单规范引用的图集操作不接受用户参考:非法参考必须在原生提交处**拒绝**,不能静默丢弃。 +pub(crate) fn platform_art_asset_kind_accepts_user_reference_assets(asset_kind: &str) -> bool { + asset_kind != "art-spritesheet" +} + +/// 收口参考素材 id 入参:trim、去重(保持给出顺序),并拒绝路径 / 远端资源 ID / 跨项目身份。 +/// +/// 这里只做**形状与数量**校验;「是不是当前项目已登记图片素材」由 +/// [`manifest_asset_remote_reference_at`] 用 manifest 身份与图片解码证明,不靠命名猜测。 +pub(crate) fn normalize_platform_art_reference_asset_ids( + asset_kind: &str, + asset_ids: &[String], +) -> Result, String> { + if !platform_art_asset_kind_accepts_user_reference_assets(asset_kind) && !asset_ids.is_empty() { + return Err("透明美术图集只接受规范图引用,不接受用户参考素材".to_string()); + } + let mut normalized: Vec = Vec::new(); + for asset_id in asset_ids { + let asset_id = asset_id.trim(); + if asset_id.is_empty() { + continue; + } + if asset_id.chars().count() > PLATFORM_ART_REFERENCE_ASSET_ID_MAX_CHARS + || asset_id.chars().any(char::is_control) + || asset_id.contains('/') + || asset_id.contains('\\') + || asset_id.contains("://") + { + return Err(format!( + "参考素材只接受当前项目已登记素材 ID,不接受路径或远端资源 ID:{asset_id}" + )); + } + if !normalized.iter().any(|existing| existing == asset_id) { + normalized.push(asset_id.to_string()); + } + } + if platform_art_asset_kind_requires_canonical_spec_reference(asset_kind) { + if normalized.len() > PLATFORM_ART_MAX_USER_REFERENCE_IMAGES_WITH_CANONICAL_SPEC { + return Err(format!( + "有规范图前置的生成最多 {} 张用户参考素材", + PLATFORM_ART_MAX_USER_REFERENCE_IMAGES_WITH_CANONICAL_SPEC + )); + } + } else if normalized.len() > PLATFORM_ART_MAX_REFERENCE_IMAGES { + return Err(format!( + "普通图片生成最多 {} 张参考素材", + PLATFORM_ART_MAX_REFERENCE_IMAGES + )); + } + Ok(normalized) +} + pub(in crate::agent) fn recover_persisted_visual_generation_options( root: &Path, pending: &AgentRuntimePendingToolAction, @@ -1510,10 +1635,7 @@ struct PreparedPlatformArtAssetSlice { #[derive(Clone)] struct PreparedPlatformSessionFence { - user_id: String, - api_base_url: String, - generation: u64, - access_token_sha256: String, + identity: PlatformSessionIdentity, } impl PreparedPlatformSessionFence { @@ -1521,41 +1643,17 @@ impl PreparedPlatformSessionFence { access .frozen_platform_session() .map(|session| PreparedPlatformSessionFence { - user_id: session.user_id.clone(), - api_base_url: session.api_base_url.clone(), - generation: session.generation, - access_token_sha256: format!( - "{:x}", - Sha256::digest(session.access_token.as_bytes()) - ), + identity: session.identity(), }) } fn validate(&self) -> Result<(), String> { - let matches = current_platform_session().is_some_and(|session| { - session.user_id == self.user_id - && session.api_base_url == self.api_base_url - && session.generation == self.generation - && format!("{:x}", Sha256::digest(session.access_token.as_bytes())) - == self.access_token_sha256 - }); - if matches { - Ok(()) - } else { - Err( - "authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试" - .to_string(), - ) - } + // 只比较身份:同一账号的 access token 轮换不得让在途生成 operation 失败。 + validate_platform_session_identity(&self.identity) } fn acquire_lease(&self) -> Result { - acquire_validated_platform_session_fingerprint( - &self.user_id, - &self.api_base_url, - self.generation, - &self.access_token_sha256, - ) + acquire_platform_session_identity_lease(&self.identity) } } @@ -1587,6 +1685,8 @@ pub(in crate::agent) struct PreparedPlatformArtAssetGeneration { slice_warning: Option, slices: Vec, spritesheet_slice_mode: Option, + spritesheet_grid_x: Option, + spritesheet_grid_y: Option, generation_route: String, generation_kind: String, reference_resource_ids: Vec, @@ -1625,12 +1725,62 @@ struct CanonicalArtSpecUploadTicket { form_fields: BTreeMap, } +/// 本次生成请求的全部参考资源身份。 +struct PlatformArtGenerationReferences { + /// 规范图前置引用:需要规范图的生成类型必定存在,其余类型为 `None`。 + canonical: Option, + /// 去重后的完整引用顺序:规范图在前,用户参考随后。它就是请求里的 `referenceImageSrcs`。 + ordered: Vec, +} + +/// 参考素材上传到平台时使用的文件名:由 manifest 素材的本地路径基名派生,只保留 ASCII 安全字符。 +/// +/// 同一素材路径不变时文件名稳定,平台对象键不会随重试漂移;无法派生时退回 +/// `reference.<媒体扩展名>`,扩展名由媒体类型决定,保持与 `contentType` 一致。 +fn platform_art_reference_upload_file_name(source: &GameCreationAppAssetManifestEntry) -> String { + let extension = infer_file_extension(Some(&source.local_path), &source.media_type); + let stem = Path::new(&source.local_path) + .file_stem() + .and_then(|stem| stem.to_str()) + .map(|stem| { + stem.chars() + .filter(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_') + }) + .collect::() + }) + .unwrap_or_default(); + if stem.is_empty() { + return format!("reference.{extension}"); + } + format!("{stem}.{extension}") +} + +/// 规范图前置的生成类型解析当前账号的规范图引用。 async fn canonical_art_spec_reference_at( root: &Path, client: &reqwest::Client, access: &ExternalEditorBindingAccess<'_>, expected_canvas_project_id: &str, ) -> Result { + let (manifest_project_id, source) = canonical_art_spec_manifest_entry_at(root)?; + upload_manifest_asset_remote_reference_at( + root, + client, + access, + &manifest_project_id, + expected_canvas_project_id, + &source, + ) + .await +} + +/// 当前项目已登记的规范图清单条目(`assets/art-spec.png` 且 `icon-spec`)。 +/// +/// 提交前预检与实际上传共用这一份归属判据:路径、远端 ID 或其它项目素材都不能冒充规范图。 +fn canonical_art_spec_manifest_entry_at( + root: &Path, +) -> Result<(String, GameCreationAppAssetManifestEntry), String> { let manifest = read_manifest_for_project(root)?; let source = manifest .assets @@ -1644,18 +1794,96 @@ async fn canonical_art_spec_reference_at( .ok_or_else(|| { "派生视觉资产需要先完成并登记 assets/art-spec.png;请等待 art-director 后重试" .to_string() - })?; + })? + .clone(); + Ok((manifest.project_id, source)) +} + +/// 用户参考素材(当前项目 manifest `assets[].id`)的清单归属解析。 +/// +/// 只接受**当前项目**清单里的素材:路径、远端 resourceId、其它项目的素材都不在清单里, +/// 会在这里失败关闭;解析出来的引用只属于当前账号,历史账号遗留的远端 ID 不会被复用。 +fn manifest_asset_reference_entry_at( + root: &Path, + asset_id: &str, +) -> Result<(String, GameCreationAppAssetManifestEntry), String> { + let manifest = read_manifest_for_project(root)?; + let source = manifest + .assets + .iter() + .find(|asset| asset.id == asset_id) + .ok_or_else(|| format!("参考素材不在当前项目已登记清单中:{asset_id}"))? + .clone(); + Ok((manifest.project_id, source)) +} + +/// 参考素材的**纯本地**校验与读取:清单身份由调用方先证明,这里只管受控路径 → 文件存在 → +/// 媒体类型 → 可解码位图。 +/// +/// 不做任何远端调用;提交前预检与实际上传读同一份判据。SVG 等矢量格式必须在**任何上传之前** +/// 明确拒绝:上游 `image/*` 筛选会放进 SVG,而位图解码器必定失败;本次不做隐式转换,也不允许 +/// 「第一张参考已上传、第二张坏图才失败」的半完成副作用。 +fn read_validated_platform_art_reference_at( + root: &Path, + source: &GameCreationAppAssetManifestEntry, +) -> Result<(Vec, image::DynamicImage), String> { + if !source.media_type.starts_with("image/") { + return Err(format!( + "参考素材必须是图片,不能引用 {}:{}", + source.media_type, source.id + )); + } + if platform_art_reference_source_is_vector(source) { + return Err(format!( + "参考素材不支持 SVG 等矢量格式,请改用 PNG/JPEG 位图:{}", + source.id + )); + } let source_path = resolve_local_project_path(root, &source.local_path)?; if !source_path.is_file() { - return Err( - "派生视觉资产的规范图 assets/art-spec.png 不存在;请等待 art-director 后重试" - .to_string(), - ); + return Err(format!( + "参考素材 {} 不存在;请重新登记后再引用", + source.local_path + )); } - let bytes = - fs::read(&source_path).map_err(|error| format!("读取派生视觉资产规范图失败:{error}"))?; + let bytes = fs::read(&source_path).map_err(|error| format!("读取参考素材失败:{error}"))?; let decoded = image::load_from_memory(&bytes) - .map_err(|_| "派生视觉资产规范图不是可解析图片".to_string())?; + .map_err(|_| format!("参考素材不是可解析图片:{}", source.local_path))?; + Ok((bytes, decoded)) +} + +/// SVG 等矢量格式:媒体类型或文件扩展名任一命中都算矢量,避免只靠声明类型漏判。 +fn platform_art_reference_source_is_vector(source: &GameCreationAppAssetManifestEntry) -> bool { + let media_type = source.media_type.trim().to_ascii_lowercase(); + let media_type = media_type.split(';').next().unwrap_or_default().trim(); + if matches!(media_type, "image/svg+xml" | "image/svg") { + return true; + } + Path::new(source.local_path.trim()) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| matches!(extension.to_ascii_lowercase().as_str(), "svg" | "svgz")) +} + +/// 「manifest 素材 → 当前账号远端资源 ID」的唯一通道。 +/// +/// 复用既有 manifest → 安全文件路径 → 图片解码 → 内容 hash → 当前账号 binding/上传 → +/// 远端 resource ID 流程:binding 存在时直接复用,缺失时从本地正式文件重新上传并登记, +/// 绝不用当前 token 探测或发送历史账号的 project/resource ID。 +async fn upload_manifest_asset_remote_reference_at( + root: &Path, + client: &reqwest::Client, + access: &ExternalEditorBindingAccess<'_>, + manifest_project_id: &str, + expected_canvas_project_id: &str, + source: &GameCreationAppAssetManifestEntry, +) -> Result { + // 引用素材要上传到平台账号:与 `upload_local_project_asset` 同口径复用 `asset.upload` 门禁, + // 显式拒绝该命令的项目在本地就失败关闭,不产生远端上传副作用。 + enforce_project_permission_policy(root, "asset.upload")?; + let file_name = platform_art_reference_upload_file_name(source); + // 与提交前预检共用同一份本地判据:受控路径、文件存在、媒体类型(含 SVG 拒绝)与可解码性。 + let (bytes, decoded) = read_validated_platform_art_reference_at(root, source)?; let principal = external_editor_binding_principal(access)?; let source_identity = new_external_editor_source_identity( &source.id, @@ -1665,19 +1893,19 @@ async fn canonical_art_spec_reference_at( )?; if let Some(binding) = read_external_editor_resource_binding_at( root, - &manifest.project_id, + manifest_project_id, &principal, expected_canvas_project_id, &source_identity, )? { return binding .remote_resource_id - .ok_or_else(|| "当前账号的规范图 binding 缺少项目资源 ID,已拒绝伪造引用".to_string()); + .ok_or_else(|| "当前账号的参考图 binding 缺少项目资源 ID,已拒绝伪造引用".to_string()); } let principal_key = - external_editor_project_binding_key_sha256(&manifest.project_id, &principal)?; + external_editor_project_binding_key_sha256(manifest_project_id, &principal)?; let resource_binding_key = external_editor_resource_binding_key_sha256( - &manifest.project_id, + manifest_project_id, &principal_key, expected_canvas_project_id, &source_identity, @@ -1688,14 +1916,14 @@ async fn canonical_art_spec_reference_at( access.validate_frozen_session()?; if let Some(binding) = read_external_editor_resource_binding_at( root, - &manifest.project_id, + manifest_project_id, &principal, expected_canvas_project_id, &source_identity, )? { return binding .remote_resource_id - .ok_or_else(|| "当前账号的规范图 binding 缺少项目资源 ID,已拒绝伪造引用".to_string()); + .ok_or_else(|| "当前账号的参考图 binding 缺少项目资源 ID,已拒绝伪造引用".to_string()); } // manifest 中的远端 ID 只保留生成来源。当前账号没有 binding 时,必须从本地正式 @@ -1718,57 +1946,57 @@ async fn canonical_art_spec_reference_at( "pathSegments": [ "editor", "account-scoped-bindings", - manifest.project_id.as_str(), + manifest_project_id, source_identity.source_sha256.as_str() ], - "fileName": "art-spec.png", + "fileName": file_name.as_str(), "contentType": source.media_type, "access": "private", "maxSizeBytes": bytes.len(), "successActionStatus": 204, })), - "创建当前账号规范图上传凭证", + "创建当前账号参考图上传凭证", ) .await?; access.validate_frozen_session()?; let upload = external_editor_response_data(&ticket_payload) .get("upload") .or_else(|| ticket_payload.pointer("/data/upload")) - .ok_or_else(|| "当前账号规范图上传凭证缺少 upload".to_string())?; + .ok_or_else(|| "当前账号参考图上传凭证缺少 upload".to_string())?; let ticket = CanonicalArtSpecUploadTicket { host: json_string_field(upload, "host") .or_else(|| json_string_field(upload, "endpoint")) - .ok_or_else(|| "当前账号规范图上传凭证缺少 host".to_string())?, + .ok_or_else(|| "当前账号参考图上传凭证缺少 host".to_string())?, bucket: json_string_field(upload, "bucket") - .ok_or_else(|| "当前账号规范图上传凭证缺少 bucket".to_string())?, + .ok_or_else(|| "当前账号参考图上传凭证缺少 bucket".to_string())?, object_key: json_string_field(upload, "objectKey") - .ok_or_else(|| "当前账号规范图上传凭证缺少 objectKey".to_string())?, + .ok_or_else(|| "当前账号参考图上传凭证缺少 objectKey".to_string())?, success_action_status: upload .get("successActionStatus") .and_then(serde_json::Value::as_u64) .and_then(|value| u16::try_from(value).ok()) .filter(|value| matches!(value, 200 | 201 | 204)) - .ok_or_else(|| "当前账号规范图上传凭证 successActionStatus 无效".to_string())?, + .ok_or_else(|| "当前账号参考图上传凭证 successActionStatus 无效".to_string())?, form_fields: upload .get("formFields") .and_then(serde_json::Value::as_object) - .ok_or_else(|| "当前账号规范图上传凭证缺少 formFields".to_string())? + .ok_or_else(|| "当前账号参考图上传凭证缺少 formFields".to_string())? .iter() .map(|(key, value)| { value .as_str() .map(|value| (key.clone(), value.to_string())) - .ok_or_else(|| "当前账号规范图上传凭证 formFields 必须全为字符串".to_string()) + .ok_or_else(|| "当前账号参考图上传凭证 formFields 必须全为字符串".to_string()) }) .collect::, _>>()?, }; let upload_url = validate_external_asset_download_url(&ticket.host, access.api_base_url(), true) - .map_err(|_| "当前账号规范图上传地址不安全".to_string())?; + .map_err(|_| "当前账号参考图上传地址不安全".to_string())?; let upload_client = build_external_asset_download_client(&upload_url, access.api_base_url(), true) .await - .map_err(|_| "无法创建当前账号规范图上传客户端".to_string())?; + .map_err(|_| "无法创建当前账号参考图上传客户端".to_string())?; access.validate_frozen_session()?; let form = ticket .form_fields @@ -1777,18 +2005,20 @@ async fn canonical_art_spec_reference_at( form.text(key.clone(), value.clone()) }); let part = Part::bytes(bytes.clone()) - .file_name("art-spec.png") + // `Part::file_name` 只接受 `'static` 名字:这里必须交出所有权,借用会让临时串在 + // 请求发出前就结束生命周期。 + .file_name(file_name.clone()) .mime_str(&source.media_type) - .map_err(|_| "规范图媒体类型不能用于上传".to_string())?; + .map_err(|_| "参考图媒体类型不能用于上传".to_string())?; let upload_response = upload_client .post(upload_url) .multipart(form.part("file", part)) .send() .await - .map_err(|_| "上传当前账号规范图失败".to_string())?; + .map_err(|_| "上传当前账号参考图失败".to_string())?; if upload_response.status().as_u16() != ticket.success_action_status { return Err(format!( - "上传当前账号规范图失败:HTTP {}", + "上传当前账号参考图失败:HTTP {}", upload_response.status().as_u16() )); } @@ -1810,19 +2040,19 @@ async fn canonical_art_spec_reference_at( "assetKind": source.kind, "accessPolicy": "private", })), - "确认当前账号规范图上传", + "确认当前账号参考图上传", ) .await?; access.validate_frozen_session()?; let asset_object = external_editor_response_data(&confirm_payload) .get("assetObject") .or_else(|| confirm_payload.pointer("/data/assetObject")) - .ok_or_else(|| "当前账号规范图确认响应缺少 assetObject".to_string())?; + .ok_or_else(|| "当前账号参考图确认响应缺少 assetObject".to_string())?; if json_string_field(asset_object, "objectKey").as_deref() != Some(ticket.object_key.as_str()) { - return Err("当前账号规范图确认响应 objectKey 不一致".to_string()); + return Err("当前账号参考图确认响应 objectKey 不一致".to_string()); } let asset_object_id = json_string_field(asset_object, "assetObjectId") - .ok_or_else(|| "当前账号规范图确认响应缺少 assetObjectId".to_string())?; + .ok_or_else(|| "当前账号参考图确认响应缺少 assetObjectId".to_string())?; let resource_payload = external_editor_json_request( client .post(format!( @@ -1851,7 +2081,7 @@ async fn canonical_art_spec_reference_at( "localAssetId": source.id, }, })), - "登记当前账号规范图项目资源", + "登记当前账号参考图项目资源", ) .await?; let post_response_session = access.validate_frozen_session(); @@ -1860,9 +2090,9 @@ async fn canonical_art_spec_reference_at( resource_data.get("resource").unwrap_or(resource_data), "resourceId", ) - .ok_or_else(|| "当前账号规范图项目资源响应缺少 resourceId".to_string())?; + .ok_or_else(|| "当前账号参考图项目资源响应缺少 resourceId".to_string())?; let binding = new_external_editor_resource_binding( - &manifest.project_id, + manifest_project_id, &principal, expected_canvas_project_id, &source_identity, @@ -1878,6 +2108,83 @@ async fn canonical_art_spec_reference_at( Ok(remote_resource_id) } +/// 解析本次生成请求的全部参考资源身份。 +/// +/// 顺序与上限是请求合同的一部分: +/// +/// - 规范图前置的生成类型必须先解析出当前账号的规范图引用; +/// - 用户参考来自 `options.reference_asset_ids`(当前项目 manifest `assets[].id`),按给出顺序 +/// 逐个换成当前账号的远端资源 ID,并按远端 ID 去重; +/// - 图集类型只接受单规范引用,用户参考在这里被**拒绝**而不是静默丢弃; +/// - 合并后总数不超过 [`PLATFORM_ART_MAX_REFERENCE_IMAGES`]。 +async fn resolve_platform_art_generation_references_at( + root: &Path, + client: &reqwest::Client, + access: &ExternalEditorBindingAccess<'_>, + expected_canvas_project_id: &str, + options: &PlatformArtAssetGenerationOptions, +) -> Result { + let user_reference_asset_ids = normalize_platform_art_reference_asset_ids( + &options.asset_kind, + &options.reference_asset_ids, + )?; + let requires_canonical = + platform_art_asset_kind_requires_canonical_spec_reference(&options.asset_kind); + // 预检:本次请求要用到的所有参考(规范图 + 用户参考)先在本地全部验证一遍, + // 任何一个不合格都必须在**任何上传之前**失败,避免「第一张参考已上传、第二张坏图才失败」。 + if requires_canonical || !user_reference_asset_ids.is_empty() { + // 引用素材要上传到平台账号:与 `upload_local_project_asset` 同口径复用 `asset.upload` + // 门禁,显式拒绝该命令的项目在本地就失败关闭,连第一个上传凭证都不会签发。 + enforce_project_permission_policy(root, "asset.upload")?; + } + let canonical_source = requires_canonical + .then(|| canonical_art_spec_manifest_entry_at(root)) + .transpose()?; + if let Some((_, source)) = canonical_source.as_ref() { + let _ = read_validated_platform_art_reference_at(root, source)?; + } + let mut user_reference_sources = Vec::with_capacity(user_reference_asset_ids.len()); + for asset_id in &user_reference_asset_ids { + let (manifest_project_id, source) = manifest_asset_reference_entry_at(root, asset_id)?; + let _ = read_validated_platform_art_reference_at(root, &source)?; + user_reference_sources.push((manifest_project_id, source)); + } + // 预检全部通过后才允许产生远端副作用。 + let canonical = if requires_canonical { + Some( + canonical_art_spec_reference_at(root, client, access, expected_canvas_project_id) + .await?, + ) + } else { + None + }; + let mut ordered = Vec::new(); + if let Some(reference) = canonical.as_ref() { + ordered.push(reference.clone()); + } + for (manifest_project_id, source) in &user_reference_sources { + let reference = upload_manifest_asset_remote_reference_at( + root, + client, + access, + manifest_project_id, + expected_canvas_project_id, + source, + ) + .await?; + if !ordered.iter().any(|existing| existing == &reference) { + ordered.push(reference); + } + } + if ordered.len() > PLATFORM_ART_MAX_REFERENCE_IMAGES { + return Err(format!( + "图片生成参考素材最多 {} 张(含规范图)", + PLATFORM_ART_MAX_REFERENCE_IMAGES + )); + } + Ok(PlatformArtGenerationReferences { canonical, ordered }) +} + fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec { // External Editor validates each description independently (currently at // 200 Unicode characters). Keep the gameplay context short enough that a @@ -2226,7 +2533,8 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at( /// 动作,必须各自独立成槽,才能在同一项目里同时在途。 /// /// 升级前遗留账本仍由旧材料函数定位;新请求把显式切分模式纳入身份,避免同一图集 -/// 请求在网格与连通域之间误复用。`slice_count` 继续保持历史兼容语义,不进身份。 +/// 请求在网格与连通域之间误复用。`slice_count` 与 `screen_color` 同样改变付费产出, +/// 一并进入身份;None 时跳过序列化,未使用这些字段的请求身份与旧版逐字节一致。 #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct StandalonePlatformArtGenerationFingerprintMaterial<'a> { @@ -2241,6 +2549,10 @@ struct StandalonePlatformArtGenerationFingerprintMaterial<'a> { slice_mode: Option<&'a str>, grid_x: Option, grid_y: Option, + #[serde(skip_serializing_if = "Option::is_none")] + slice_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + screen_color: Option<&'a str>, } /// 把输出路径收口成稳定的旧槽材料:空路径与未指定路径都落到 `(automatic-output)`, @@ -2280,6 +2592,8 @@ fn standalone_platform_art_generation_runtime_context( slice_mode: options.slice_mode.as_deref(), grid_x: options.grid_x, grid_y: options.grid_y, + slice_count: options.slice_count, + screen_color: options.screen_color.as_deref(), }) .map_err(|error| format!("序列化 standalone 图片生成动作身份失败:{error}"))?; let action_fingerprint = format!("{:x}", Sha256::digest(&identity_bytes)); @@ -2393,6 +2707,44 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_retained_runtime_ .await } +/// 保留账本里的参考集合是否符合该生成类型的**请求合同**。 +/// +/// 这里判的是「形状」,不是具体身份:规范图到底是什么由调用方用当前账号的解析结果单独比对。 +/// 提交侧的顺序合同固定为「规范图前置在最前,用户参考按给定顺序追加在后」,所以: +/// +/// - `art-spritesheet`:只接受唯一规范引用,多一个用户参考都不算同合同; +/// - `ui-prototype` / `game-background`:必须有规范图前置(至少 1 项),总数不超过总上限; +/// - 其余放行 kind(`icon-spec` 等):没有规范前置,可以零参考,也可以全是用户参考; +/// - 不在目录里的 kind 一律判为不符合,失败关闭,不做兜底猜测。 +/// +/// 提交侧的收口在 [`normalize_platform_art_reference_asset_ids`] 与 +/// [`resolve_platform_art_generation_references_at`],这里只回答「已有账本/清单里的这份参考集合, +/// 是不是该 kind 的合法形状」,两边必须共用同一套上限,否则恢复校验会误判合法请求。 +pub(crate) fn platform_art_runtime_references_match_request_contract( + reference_resource_ids: &[String], + expected_asset_kind: &str, +) -> bool { + if reference_resource_ids + .iter() + .any(|reference| reference.trim().is_empty()) + { + return false; + } + // `game-background` 只由 Direct 运行时直接构造 options,不走 kind 归一目录,所以单独放行。 + if expected_asset_kind != "game-background" + && !PLATFORM_ART_ASSET_GENERATION_KINDS.contains(&expected_asset_kind) + { + return false; + } + if !platform_art_asset_kind_accepts_user_reference_assets(expected_asset_kind) { + return reference_resource_ids.len() == 1; + } + if platform_art_asset_kind_requires_canonical_spec_reference(expected_asset_kind) { + return (1..=PLATFORM_ART_MAX_REFERENCE_IMAGES).contains(&reference_resource_ids.len()); + } + reference_resource_ids.len() <= PLATFORM_ART_MAX_REFERENCE_IMAGES +} + pub(in crate::agent) fn retained_platform_art_generation_runtime_state_matches_direct_stage_at( root: &Path, runtime_context: &PlatformArtGenerationRuntimeContext, @@ -2413,24 +2765,35 @@ pub(in crate::agent) fn retained_platform_art_generation_runtime_state_matches_d .and_then(|value| value.get("assetType")) .and_then(serde_json::Value::as_str); let matches = match expected_asset_kind { + // 参考形状按请求合同判定,不再把「icon-spec 必须没有参考」当身份判据: + // 图标规范允许普通参考,恢复校验必须与提交时同一套上限,否则会误判合法的保留账本。 "icon-spec" => { snapshot.endpoint == "/api/external/v1/editor/images/generations" && snapshot.generation_kind == "spec" - && snapshot.reference_resource_ids.is_empty() + && platform_art_runtime_references_match_request_contract( + &snapshot.reference_resource_ids, + "icon-spec", + ) && request_asset_kind.as_deref() == Some("icon-spec") && art_spec_asset_type == Some("icon-spec") } "game-background" => { snapshot.endpoint == "/api/external/v1/editor/images/generations" && snapshot.generation_kind == "spec" - && snapshot.reference_resource_ids.len() == 1 + && platform_art_runtime_references_match_request_contract( + &snapshot.reference_resource_ids, + "game-background", + ) && request_asset_kind.as_deref() == Some("game-background") && art_spec_asset_type == Some("background") } "art-spritesheet" => { snapshot.endpoint == "/api/external/v1/editor/icon-spritesheets/generations" && snapshot.generation_kind == "icon-spritesheet" - && snapshot.reference_resource_ids.len() == 1 + && platform_art_runtime_references_match_request_contract( + &snapshot.reference_resource_ids, + "art-spritesheet", + ) && request_asset_kind.is_none() && art_spec_asset_type == Some("art") } @@ -2491,6 +2854,31 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at( if require_slices && options.asset_kind != "art-spritesheet" { return Err("严格游戏切片生成只允许 art-spritesheet 资产类型".to_string()); } + // 切分模式没有默认值:图集生成必须在客户端显式声明,缺失或自相矛盾都在付费提交前失败。 + if options.asset_kind == "art-spritesheet" { + let Some(slice_mode) = options.slice_mode.as_deref() else { + return Err( + "图集生成必须显式声明 sliceMode:等分网格或固定槽位用 grid 并提供 gridX/gridY,自由排布用 connected-components" + .to_string(), + ); + }; + if !matches!(slice_mode, "connected-components" | "grid") { + return Err(format!("图集切分模式不受支持:{slice_mode}")); + } + if slice_mode == "grid" && (options.grid_x.is_none() || options.grid_y.is_none()) { + return Err("sliceMode=grid 必须同时提供 gridX 与 gridY".to_string()); + } + if slice_mode == "connected-components" + && (options.grid_x.is_some() || options.grid_y.is_some()) + { + return Err( + "sliceMode=connected-components 不接受 gridX/gridY:网格尺寸只能与 grid 同时声明" + .to_string(), + ); + } + } else if options.slice_mode.is_some() || options.grid_x.is_some() || options.grid_y.is_some() { + return Err("sliceMode/gridX/gridY 仅对 art-spritesheet 生效".to_string()); + } if super::external_generation_state::is_standalone_platform_art_generation_runtime_context( runtime_context, ) && game_creator_agent_runtime_external_generation_exists( @@ -2670,23 +3058,23 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前生成意图与已持久化请求快照不一致,已拒绝将旧操作当作本次请求恢复;原生成账本已保留,需要先完成或对账旧操作" )); } - if matches!( - options.asset_kind.as_str(), - "ui-prototype" | "game-background" | "art-spritesheet" - ) { - let current_reference = canonical_art_spec_reference_at( + { + // 恢复的判据是「本次请求解析出的当前账号引用」与账本快照逐一相符:规范图身份漂移 + // 与用户参考变化都必须被识别,不能把上一次请求的参考当成本次请求的参考恢复。 + let current_references = resolve_platform_art_generation_references_at( root, &client, &binding_access, &snapshot.canvas_project_id, + options, ) .await .map_err(|error| { format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 无法验证已持久化派生请求的当前规范图身份:{error}" + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 无法验证已持久化生成请求的当前规范图与参考素材身份:{error}" ) })?; - if snapshot.reference_resource_ids != [current_reference] { + if snapshot.reference_resource_ids != current_references.ordered { if platform_art_generation_runtime_status(&state) == "accepted" { if let Ok(submission) = platform_art_generation_runtime_submission_payload(&state) @@ -2718,7 +3106,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at } } return Err(format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前规范图身份与已持久化派生请求不一致,已拒绝恢复旧操作;原生成账本已保留,需要先完成或对账旧操作" + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前规范图身份或用户参考素材与已持久化请求不一致,已拒绝恢复旧操作;原生成账本已保留,需要先完成或对账旧操作" )); } } @@ -2800,22 +3188,17 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at _ => "spec", }; let is_canonical_art_spritesheet = options.asset_kind == "art-spritesheet"; - let canonical_reference = if matches!( - options.asset_kind.as_str(), - "ui-prototype" | "game-background" | "art-spritesheet" - ) { - Some( - canonical_art_spec_reference_at( - root, - &client, - &binding_access, - &canvas_context.project_id, - ) - .await?, - ) - } else { - None - }; + // 参考顺序「规范图在前、用户参考随后」与去重、上限都在这里统一决定, + // 不区分 GUI 与 agent 调用路径;图集类型的用户参考已在解析处被拒绝。 + let references = resolve_platform_art_generation_references_at( + root, + &client, + &binding_access, + &canvas_context.project_id, + options, + ) + .await?; + let canonical_reference = references.canonical.clone(); let (endpoint, request_body) = if is_canonical_art_spritesheet { let reference_id = canonical_reference .as_deref() @@ -2829,7 +3212,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at "sliceMode": options.slice_mode, "gridX": options.grid_x, "gridY": options.grid_y, - "screenColor": "auto", + "screenColor": options.screen_color.as_deref().unwrap_or("auto"), "aspectRatio": options.aspect_ratio, "imageSize": options.image_size, "assetLabel": options.asset_label, @@ -2859,7 +3242,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at "generationInputs": { "artSpec": platform_art_asset_art_spec(options), }, - "referenceImageSrcs": canonical_reference.clone().into_iter().collect::>(), + "referenceImageSrcs": references.ordered.clone(), "canvasCompletion": { "title": options.asset_label, "placeholder": external_canvas_placeholder(&options.aspect_ratio), @@ -2877,6 +3260,14 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at if options.asset_kind == "image" { object.remove("kind"); } + if options.asset_kind == "character" { + if let Some(screen_color) = options.screen_color.as_deref() { + object.insert( + "screenColor".to_string(), + serde_json::Value::String(screen_color.to_string()), + ); + } + } } request_body } else { @@ -3038,7 +3429,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at endpoint.to_string(), generation_kind.to_string(), is_canonical_art_spritesheet, - canonical_reference.into_iter().collect::>(), + references.ordered.clone(), generation_prompt.clone(), ) }; @@ -3129,6 +3520,16 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at } else { None }; + let spritesheet_grid_x = if is_canonical_art_spritesheet { + json_u32_field(generated, "gridX") + } else { + None + }; + let spritesheet_grid_y = if is_canonical_art_spritesheet { + json_u32_field(generated, "gridY") + } else { + None + }; let resource_id = json_string_field(resource, "resourceId"); let task_id = if is_canonical_art_spritesheet { consistent_canvas_task_id("External Editor 图集主图", &[generated, resource, asset])? @@ -3187,6 +3588,8 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at slice_warning, slices, spritesheet_slice_mode, + spritesheet_grid_x, + spritesheet_grid_y, generation_route, generation_kind, reference_resource_ids, @@ -6517,6 +6920,50 @@ impl PlatformArtSliceContractRollback { } } +fn json_u32_field(value: &serde_json::Value, field: &str) -> Option { + value + .get(field) + .and_then(serde_json::Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) +} + +/// 严格图集必须在请求与响应两端证明同一个切分声明:请求显式声明的模式必须被平台 +/// 原样回显,grid 的行列数也必须一致;否则本地无法判断实际按哪种方式切片。 +fn validate_platform_art_spritesheet_slice_declaration_matches_response( + options: &PlatformArtAssetGenerationOptions, + response_slice_mode: Option<&str>, + response_grid_x: Option, + response_grid_y: Option, +) -> Result<(), String> { + let requested = options + .slice_mode + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "图集生成缺少显式 sliceMode 声明,已拒绝提交严格图集".to_string())?; + let responded = response_slice_mode + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "平台图集响应没有回显 sliceMode,无法证明切分方式与请求一致,已在本地落盘前拒绝提交" + .to_string() + })?; + if responded != requested { + return Err(format!( + "平台图集响应回显的 sliceMode={responded} 与请求 {requested} 不一致,已拒绝提交" + )); + } + if requested == "grid" + && (response_grid_x != options.grid_x || response_grid_y != options.grid_y) + { + return Err(format!( + "平台图集响应回显的 gridX/gridY={:?}/{:?} 与请求 {:?}/{:?} 不一致,已拒绝提交", + response_grid_x, response_grid_y, options.grid_x, options.grid_y + )); + } + Ok(()) +} + fn validate_strict_platform_art_spritesheet_contract( slices: &[PreparedPlatformArtAssetSlice], slice_warning: Option<&str>, @@ -6527,7 +6974,6 @@ fn validate_strict_platform_art_spritesheet_contract( task_id: Option<&str>, generation_route: &str, generation_kind: &str, - spritesheet_slice_mode: Option<&str>, reference_resource_ids: &[String], has_transparent_pixels: bool, has_visible_pixels: bool, @@ -6568,7 +7014,6 @@ fn validate_strict_platform_art_spritesheet_contract( { return Err("strict spritesheet 图集生成 route/kind 与严格图集合同不一致".to_string()); } - let _requested_slice_mode = spritesheet_slice_mode; if reference_resource_ids.len() != 1 || reference_resource_ids[0].trim().is_empty() || reference_resource_ids[0].trim() == resource_id @@ -7116,6 +7561,15 @@ fn commit_strict_platform_art_slices_at( "obstacles-and-scene", "feedback-effects", ]; + // 标准图集按用途位置映射到固定路径;数量不一致时必须失败关闭,不能靠 zip 静默截断 + // 或写入用途错位的切片清单。 + if slices.len() != usages.len() { + return Err(format!( + "标准美术图集必须正好包含 {} 张 canonical 切片,平台返回了 {} 张,已拒绝写入以避免用途错位", + usages.len(), + slices.len() + )); + } let mut generated = Vec::with_capacity(slices.len()); let mut registrations = Vec::with_capacity(slices.len()); let mut content_sha256s = Vec::with_capacity(slices.len()); @@ -7331,6 +7785,8 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( mut slice_warning, slices, spritesheet_slice_mode, + spritesheet_grid_x, + spritesheet_grid_y, generation_route, generation_kind, reference_resource_ids, @@ -7340,6 +7796,12 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( recover_existing_outputs, } = prepared; if require_complete_core_slices { + validate_platform_art_spritesheet_slice_declaration_matches_response( + options, + spritesheet_slice_mode.as_deref(), + spritesheet_grid_x, + spritesheet_grid_y, + )?; validate_strict_platform_art_spritesheet_contract( &slices, slice_warning.as_deref(), @@ -7350,7 +7812,6 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( task_id.as_deref(), &generation_route, &generation_kind, - spritesheet_slice_mode.as_deref(), &reference_resource_ids, spritesheet_has_transparent_pixels, spritesheet_has_visible_pixels, @@ -7620,7 +8081,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( return Err(error); } } - let registered = match register_local_asset_entry( + let registered = match register_local_asset_entry_with_category( root, &local_path, &options.asset_kind, @@ -7638,6 +8099,8 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( generation_kind: Some(generation_kind.clone()), reference_resource_ids: reference_resource_ids.clone(), }, + // GUI 完成登记层的目标栏目;Agent / Direct 路径为 `None`,仍按 kind 派生。 + options.target_category.as_deref(), ) { Ok(registered) => registered, Err(error) => { @@ -7711,6 +8174,9 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( })).collect::>(), "generationRoute": generation_route, "generationKind": generation_kind, + "sliceMode": spritesheet_slice_mode.clone(), + "gridX": spritesheet_grid_x, + "gridY": spritesheet_grid_y, "referenceResourceIds": reference_resource_ids, }), ); @@ -7720,6 +8186,9 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( Ok(GeneratedPlatformArtAsset { asset: registered, slices: generated_slices, + slice_mode: spritesheet_slice_mode.or_else(|| options.slice_mode.clone()), + grid_x: spritesheet_grid_x.or(options.grid_x), + grid_y: spritesheet_grid_y.or(options.grid_y), resource_id, asset_object_id, task_id, @@ -8349,6 +8818,9 @@ mod canvas_generation_tests { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, + screen_color: None, }; let ordinary = standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false) @@ -8415,6 +8887,12 @@ mod canvas_generation_tests { let mut changed = options.clone(); changed.replace_existing = false; changed_options.push(changed); + let mut changed = options.clone(); + changed.screen_color = Some("#CFEFFF".to_string()); + changed_options.push(changed); + let mut changed = options.clone(); + changed.slice_count = Some(8); + changed_options.push(changed); for changed in changed_options { let context = standalone_platform_art_generation_runtime_context( "完整生成提示词", @@ -9812,7 +10290,6 @@ mod canvas_generation_tests { Some("spritesheet-task"), "/api/external/v1/editor/icon-spritesheets/generations", "icon-spritesheet", - None, &["art-spec-resource".to_string()], true, true, @@ -9837,7 +10314,6 @@ mod canvas_generation_tests { None, "route", "kind", - None, &[], false, false, @@ -9894,7 +10370,6 @@ mod canvas_generation_tests { Some("spritesheet-task"), "/api/external/v1/editor/icon-spritesheets/generations", "icon-spritesheet", - Some("grid"), &["art-spec-resource".to_string()], true, true, @@ -10396,6 +10871,9 @@ mod canvas_generation_tests { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, + screen_color: None, }; let prompt = "生成同一套整包美术"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -10636,7 +11114,7 @@ mod canvas_generation_tests { } drop(owner_a_access); drop(frozen_owner_a); - install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2) + install_platform_session("durable-owner-b", "durable-owner-b-token", &base_url, 2, 2) .expect("switch to owner B"); let error = match request_platform_art_asset_with_runtime_options_at( @@ -10761,8 +11239,14 @@ mod canvas_generation_tests { .recv_timeout(Duration::from_secs(3)) .expect("wait for accepted response"); std::thread::sleep(Duration::from_millis(50)); - install_platform_session("post-202-user-b", "post-202-token-b", &switch_base_url, 2) - .expect("switch platform account after accepted response"); + install_platform_session( + "post-202-user-b", + "post-202-token-b", + &switch_base_url, + 2, + 2, + ) + .expect("switch platform account after accepted response"); }); let runtime_context = PlatformArtGenerationRuntimeContext { agent_id: "art-director".to_string(), @@ -11294,6 +11778,9 @@ mod canvas_generation_tests { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, + screen_color: None, }; let prompt = "保持同一个生成提示词"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -11758,6 +12245,9 @@ mod canvas_generation_tests { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, + screen_color: None, }; let prompt = "恢复已受理视觉规范图"; let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options); @@ -12355,6 +12845,324 @@ mod canvas_generation_tests { ); } + /// 参考素材 id 入参只按当前项目清单形状收口:路径、远端资源 ID、控制字符与超限都在这里拒绝。 + #[test] + fn reference_asset_ids_are_normalized_and_rejected_before_any_remote_call() { + let ids = |values: &[&str]| { + values + .iter() + .map(|value| value.to_string()) + .collect::>() + }; + // 去重保持给出顺序,空白项直接丢弃。 + assert_eq!( + normalize_platform_art_reference_asset_ids("icon-spec", &ids(&[" b ", "a", "b", " "])) + .expect("normalize icon-spec references"), + ids(&["b", "a"]) + ); + // 路径、跨项目远端资源 ID 与非法字符都不是可接受的素材身份。 + for rejected in [ + "assets/hero.png", + "..\\hero.png", + "https://example.com/hero.png", + "hero\u{7}", + &"a".repeat(PLATFORM_ART_REFERENCE_ASSET_ID_MAX_CHARS + 1), + ] { + assert!( + normalize_platform_art_reference_asset_ids("icon-spec", &ids(&[rejected])).is_err(), + "{rejected} 不能被当成参考素材 id" + ); + } + // 无规范前置:最多 5 张;有规范前置:用户参考最多 4 张。 + assert_eq!( + normalize_platform_art_reference_asset_ids( + "icon-spec", + &ids(&["a", "b", "c", "d", "e"]) + ) + .expect("five references without a canonical spec") + .len(), + 5 + ); + assert!(normalize_platform_art_reference_asset_ids( + "icon-spec", + &ids(&["a", "b", "c", "d", "e", "f"]) + ) + .is_err()); + assert_eq!( + normalize_platform_art_reference_asset_ids("ui-prototype", &ids(&["a", "b", "c", "d"])) + .expect("four user references with a canonical spec") + .len(), + 4 + ); + assert!(normalize_platform_art_reference_asset_ids( + "ui-prototype", + &ids(&["a", "b", "c", "d", "e"]) + ) + .is_err()); + // 图集只接受单规范引用:额外参考必须被拒绝,不能静默丢弃。 + assert!( + normalize_platform_art_reference_asset_ids("art-spritesheet", &ids(&["a"])).is_err() + ); + assert!( + normalize_platform_art_reference_asset_ids("art-spritesheet", &[]) + .expect("spritesheet without user references") + .is_empty() + ); + } + + /// 恢复侧与提交侧必须共用同一套参考上限,否则合法账本会被判成身份不符。 + #[test] + fn reference_contract_matches_the_submission_limits_per_kind() { + let references = |count: usize| { + (0..count) + .map(|index| format!("reference-{index}")) + .collect::>() + }; + // 根素材(icon-spec):没有规范前置,可以零参考,也可以全是用户参考。 + assert!(platform_art_runtime_references_match_request_contract( + &[], + "icon-spec" + )); + assert!(platform_art_runtime_references_match_request_contract( + &references(5), + "icon-spec" + )); + assert!(!platform_art_runtime_references_match_request_contract( + &references(6), + "icon-spec" + )); + // 有规范前置:规范图必须在场,总量仍不超过 5 张(含规范图)。 + for kind in ["ui-prototype", "game-background"] { + assert!( + !platform_art_runtime_references_match_request_contract(&[], kind), + "{kind} 必须有规范图前置" + ); + assert!(platform_art_runtime_references_match_request_contract( + &references(1), + kind + )); + assert!(platform_art_runtime_references_match_request_contract( + &references(5), + kind + )); + assert!(!platform_art_runtime_references_match_request_contract( + &references(6), + kind + )); + } + // 图集:恰好一项,多一项都不算同一份请求合同。 + assert!(!platform_art_runtime_references_match_request_contract( + &[], + "art-spritesheet" + )); + assert!(platform_art_runtime_references_match_request_contract( + &references(1), + "art-spritesheet" + )); + assert!(!platform_art_runtime_references_match_request_contract( + &references(2), + "art-spritesheet" + )); + // 普通图片类生成与规范图共用总上限;空白项与未知 kind 一律拒绝。 + assert!(platform_art_runtime_references_match_request_contract( + &references(5), + "image" + )); + assert!(!platform_art_runtime_references_match_request_contract( + &references(6), + "image" + )); + assert!(!platform_art_runtime_references_match_request_contract( + &[" ".to_string()], + "icon-spec" + )); + assert!(!platform_art_runtime_references_match_request_contract( + &references(1), + "unknown-kind" + )); + } + + /// 保留账本的恢复校验必须接受与提交同一套参考合同。 + /// + /// 旧实现把 `icon-spec` 写死成「引用必须为空」、把有规范前置的生成写死成「恰好 1 项」, + /// 带用户参考的合法账本会被判成身份不符而恢复失败。这里直接写真实账本再读回校验。 + #[test] + fn retained_stage_recovery_accepts_the_same_reference_contract_as_submission() { + fn write_retained_stage_result( + root: &Path, + run_id: &str, + endpoint: &str, + request_body: serde_json::Value, + ) -> Result { + let context = PlatformArtGenerationRuntimeContext { + agent_id: "manual-canvas-asset-generate".to_string(), + task_id: "retained-reference-contract-task".to_string(), + session_id: "retained-reference-contract-session".to_string(), + run_id: run_id.to_string(), + source: "test".to_string(), + action_id: format!("retained-reference-contract-{run_id}"), + action_fingerprint: format!("retained-reference-contract-v1:{run_id}"), + }; + let (_, _, frozen_platform_session) = resolve_canvas_sync_api_credentials(None, None)?; + let frozen_platform_session = frozen_platform_session + .ok_or_else(|| "保留账本测试必须使用平台账号".to_string())?; + let access = ExternalEditorBindingAccess::for_platform(&frozen_platform_session)?; + let (state, created) = prepare_platform_art_generation_runtime_state( + root, + &context, + endpoint, + "retained-reference-contract-canvas", + "保留账本参考合同", + &request_body, + &access, + )?; + if !created { + return Err("保留账本测试账本已存在".to_string()); + } + mark_platform_art_generation_runtime_accepted(root, state, "test-operation-id", 1_500)?; + Ok(context) + } + + fn write_retained_stage( + root: &Path, + run_id: &str, + endpoint: &str, + request_body: serde_json::Value, + ) -> PlatformArtGenerationRuntimeContext { + write_retained_stage_result(root, run_id, endpoint, request_body) + .unwrap_or_else(|error| panic!("write retained reference contract ledger: {error}")) + } + + let temporary = tempfile::tempdir().expect("create retained reference contract project"); + let root = temporary.path(); + init_local_game_project_at(root, "retained-reference-contract", "参考合同测试") + .expect("init retained reference contract project"); + let _platform_session = crate::platform_session::install_test_platform_session( + "retained-reference-contract-user", + "retained-reference-contract-key", + "http://127.0.0.1:9", + ); + + // 根素材带用户参考:旧实现要求引用为空,这里必须被认成合法账本。 + let icon_spec = write_retained_stage( + root, + "run-icon-spec-user-references", + "/api/external/v1/editor/images/generations", + serde_json::json!({ + "prompt": "保留账本参考合同", + "kind": "spec", + "assetKind": "icon-spec", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "generationInputs": { "artSpec": { "assetType": "icon-spec" } }, + "referenceImageSrcs": ["user-reference-1", "user-reference-2"], + }), + ); + assert!( + retained_platform_art_generation_runtime_state_matches_direct_stage_at( + root, + &icon_spec, + "icon-spec", + ) + .expect("read icon-spec ledger with user references") + ); + + // 有规范前置的生成带规范图加用户参考:旧实现要求恰好 1 项,这里必须被认成合法账本。 + let background = write_retained_stage( + root, + "run-background-canonical-and-user-references", + "/api/external/v1/editor/images/generations", + serde_json::json!({ + "prompt": "保留账本参考合同", + "kind": "spec", + "assetKind": "game-background", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "generationInputs": { "artSpec": { "assetType": "background" } }, + "referenceImageSrcs": ["resource-icon-spec", "user-reference-1"], + }), + ); + assert!( + retained_platform_art_generation_runtime_state_matches_direct_stage_at( + root, + &background, + "game-background", + ) + .expect("read game-background ledger with a canonical and a user reference") + ); + + // 图集仍只接受唯一规范引用。 + let spritesheet = write_retained_stage( + root, + "run-spritesheet-canonical-reference", + "/api/external/v1/editor/icon-spritesheets/generations", + serde_json::json!({ + "prompt": "保留账本参考合同", + "referenceId": "resource-icon-spec", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "generationInputs": { "artSpec": { "assetType": "art" } }, + }), + ); + assert!( + retained_platform_art_generation_runtime_state_matches_direct_stage_at( + root, + &spritesheet, + "art-spritesheet", + ) + .expect("read art-spritesheet ledger with the canonical reference") + ); + + // 超出总上限的参考集合不能被当成同一份请求合同。 + let over_limit = write_retained_stage( + root, + "run-icon-spec-over-limit", + "/api/external/v1/editor/images/generations", + serde_json::json!({ + "prompt": "保留账本参考合同", + "kind": "spec", + "assetKind": "icon-spec", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "generationInputs": { "artSpec": { "assetType": "icon-spec" } }, + "referenceImageSrcs": [ + "user-reference-1", + "user-reference-2", + "user-reference-3", + "user-reference-4", + "user-reference-5", + "user-reference-6", + ], + }), + ); + assert!( + !retained_platform_art_generation_runtime_state_matches_direct_stage_at( + root, + &over_limit, + "icon-spec", + ) + .expect("read over limit icon-spec ledger") + ); + + // 空白引用连账本都写不进去:写入后的读回校验必须直接失败关闭。 + let blank = write_retained_stage_result( + root, + "run-icon-spec-blank-reference", + "/api/external/v1/editor/images/generations", + serde_json::json!({ + "prompt": "保留账本参考合同", + "kind": "spec", + "assetKind": "icon-spec", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "generationInputs": { "artSpec": { "assetType": "icon-spec" } }, + "referenceImageSrcs": [" "], + }), + ) + .expect_err("blank references must not be persisted into the ledger"); + assert!(blank.contains("引用资源 ID 无效"), "{blank}"); + } + fn replacement_options() -> PlatformArtAssetGenerationOptions { PlatformArtAssetGenerationOptions { output_path: Some("assets/art-spritesheet.png".to_string()), @@ -12364,9 +13172,12 @@ mod canvas_generation_tests { asset_label: "游戏首版核心美术素材".to_string(), replace_existing: true, slice_count: None, - slice_mode: None, + slice_mode: Some("connected-components".to_string()), grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, + screen_color: None, } } @@ -12397,7 +13208,9 @@ mod canvas_generation_tests { warning: None, slice_warning: None, slices: Vec::new(), - spritesheet_slice_mode: Some("grid".to_string()), + spritesheet_slice_mode: Some("connected-components".to_string()), + spritesheet_grid_x: None, + spritesheet_grid_y: None, generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(), generation_kind: "icon-spritesheet".to_string(), reference_resource_ids: vec!["art-spec-resource".to_string()], @@ -12721,7 +13534,9 @@ mod canvas_generation_tests { warning: None, slice_warning: None, slices, - spritesheet_slice_mode: Some("grid".to_string()), + spritesheet_slice_mode: Some("connected-components".to_string()), + spritesheet_grid_x: None, + spritesheet_grid_y: None, generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(), generation_kind: "icon-spritesheet".to_string(), reference_resource_ids: vec!["art-spec-resource".to_string()], diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs index 03b0dcac1..0a8974342 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs @@ -1431,12 +1431,13 @@ mod external_generation_state_tests { base_url, ); let frozen_a = current_platform_session().expect("freeze owner A"); - validate_platform_session_snapshot(&frozen_a).expect("owner A is current before switch"); + validate_frozen_platform_session(&frozen_a).expect("owner A is current before switch"); replace_platform_session_for_gui_owner( "fingerprint-owner-b", "fingerprint-token-b", base_url, 2, + 2, ) .expect("switch global session to owner B"); let current_b = current_platform_session().expect("owner B is current after switch"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs index 9d1f102c9..dc09f659e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -161,7 +161,7 @@ pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result Option<&'static str> { "js" | "mjs" | "cjs" | "ts" | "tsx" | "gd" | "rs" | "py" | "go" | "java" | "kt" | "kts" | "c" | "cc" | "cpp" | "h" | "hpp" | "cs" | "swift" | "php" | "rb" | "lua" | "sh" | "bash" | "zsh" | "sql" | "graphql" | "gql" | "vue" | "svelte" => Some("text/plain"), + /* + * Cocos Creator 资源。这里只是给未登记文件清单标注媒体类型,取值必须与 + * `agent/direct_tool_bridge.rs::bridge_project_file_class` 和 + * `commands.rs::agent_local_project_file_type` 一致:少写一个扩展名, + * Agent 的提示词就会把「其实可以登记」的资源说成只能发现。 + */ + "glb" => Some("model/gltf-binary"), + "gltf" => Some("model/gltf+json"), + "fbx" | "dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" | "psd" | "znt" => { + Some("application/octet-stream") + } + "scene" | "fire" | "prefab" | "anim" | "animation" | "animgraph" | "animgraphvari" + | "animask" | "mtl" | "material" | "pmtl" | "terrain" | "labelatlas" | "pac" | "mesh" + | "skeleton" => Some("application/json"), + "tmx" | "plist" => Some("application/xml"), + "effect" | "chunk" | "fnt" | "atlas" => Some("text/plain"), + "tga" => Some("image/x-tga"), + "tif" | "tiff" => Some("image/tiff"), + "hdr" => Some("image/vnd.radiance"), + "exr" => Some("image/x-exr"), + "pcm" => Some("audio/pcm"), _ => None, } } @@ -246,10 +267,21 @@ mod tests { ("assets/data.json", "application/json"), ("game/index.html", "text/html"), ("game/main.rs", "text/plain"), + // 引擎资源同样要在未登记清单里被标出可登记:漏一个扩展名, + // Agent 就会把「其实可以登记」的 Cocos 资源说成只能发现。 + ("assets/model/hero.glb", "model/gltf-binary"), + ("assets/model/hero.fbx", "application/octet-stream"), + ("assets/anim/walk.anim", "application/json"), + ("assets/scene/main.scene", "application/json"), + ("assets/shader/glow.effect", "text/plain"), + ("assets/atlas/hero.plist", "application/xml"), + ("assets/tex/grass.tga", "image/x-tga"), + ("assets/audio/voice.pcm", "audio/pcm"), ] { assert_eq!(prompt_context_media_type(path), Some(expected), "{path}"); } - assert_eq!(prompt_context_media_type("assets/unknown.bin"), None); + // `.bin` 现在是引擎 BufferAsset 的载体,不再是「未识别」;真正未知的扩展名才返回 None。 + assert_eq!(prompt_context_media_type("assets/unknown.dat"), None); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs index d0e3a92a0..624479e44 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs @@ -605,6 +605,8 @@ fn finalization_cleanup_closes_entire_tool_plan_repair_chain_before_removal() { response_stream_fixture("finalization-tool-plan-repair-chain-run"); let root = project.path(); let llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "finalization-tool-plan-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "finalization-tool-plan-model".to_string(), @@ -968,6 +970,8 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon let root = project.path(); let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff 身份漂移")]); let old_llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "old-provider-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "old-provider-model".to_string(), @@ -1073,6 +1077,8 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo LlmMessage::user("修复格式"), ]); let old_llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "old-tool-plan-provider-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "old-tool-plan-model".to_string(), @@ -1192,6 +1198,8 @@ async fn generic_retry_identity_drift_closes_tool_plan_repair_chain_before_remov response_stream_fixture("generic-retry-drift-tool-plan-chain-run"); let root = project.path(); let old_llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "old-generic-retry-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "old-generic-retry-model".to_string(), @@ -1277,6 +1285,8 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() { response_stream_fixture("tool-plan-capacity-preflight-run"); let root = project.path(); let llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "tool-plan-capacity-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "tool-plan-capacity-model".to_string(), @@ -1400,6 +1410,8 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem LlmMessage::user("修复格式"), ]); let llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "durable-control-tool-plan-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "durable-control-tool-plan-model".to_string(), @@ -1525,6 +1537,8 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff( snapshot.request_slot = "loop-0-repair-0".to_string(); let request = LlmRunRequest::new(vec![LlmMessage::user("等待 steer 或 cancel")]); let llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "tool-plan-cleanup-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "tool-plan-cleanup-model".to_string(), @@ -1597,6 +1611,8 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() { snapshot.request_slot = "loop-0-repair-0".to_string(); let request = LlmRunRequest::new(vec![LlmMessage::user("终态遗留 handoff")]); let llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "terminal-handoff-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "terminal-handoff-model".to_string(), @@ -1693,6 +1709,8 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat let root = project.path(); let request = LlmRunRequest::new(vec![LlmMessage::user("验证 handoff/retry 冲突")]); let llm = GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: "provider-key".to_string(), base_url: "http://127.0.0.1:1/v1".to_string(), model: "provider-model".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index f183e0f6b..9cc2fb6eb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -2,8 +2,9 @@ use super::*; pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock = OnceLock::new(); -pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK: OnceLock< - std::sync::Mutex>, +/// 同一 AppData 允许多个界面窗口同时挂载事件接收端,因此这里是按 token 去重的注册表。 +pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINKS: OnceLock< + std::sync::Mutex>, > = OnceLock::new(); #[cfg(test)] pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_TEST_LOCK: std::sync::Mutex<()> = @@ -293,8 +294,8 @@ pub(crate) use entrypoints::{ configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress, emit_game_creator_agent_runtime_update, emit_game_creator_manifest_invalidated, game_creator_agent_runtime_update_event, generate_local_game_draft_at, - install_game_creator_manifest_invalidation_event_sink, read_game_creator_agent_runtime_at, - read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at, + read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at, + read_game_creator_agent_runtimes_at, register_game_creator_manifest_invalidation_event_sink, set_game_creator_agent_runtime_update_app_handle, start_game_creator_manifest_invalidation_event_sink, validate_game_creator_manifest_invalidation_event_sink, @@ -415,10 +416,6 @@ pub(super) const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_POLL_MS: u64 = 50; pub(super) const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_MAX_TTL_MS: u64 = 10 * 60 * 1_000; pub(super) const AGENT_RUNTIME_REAL_E2E_TOOL_PLAN_CHECKPOINT_ERROR: &str = "agent-runtime-real-e2e-tool-plan-handoff-checkpoint-needs-reconciliation"; -pub(super) const AGENT_RUNTIME_PROVIDER_TRANSIENT_RETRY_LIMIT: u32 = 3; -pub(super) const AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR: u32 = 12; -pub(super) const AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_LIMIT: u32 = 16; -pub(crate) const AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT: u32 = 2; pub(super) const AGENT_RUNTIME_AUTONOMOUS_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS: usize = 4; pub(super) const AGENT_RUNTIME_AUTONOMOUS_FORCED_ACTION_MAX_OUTPUT_TOKENS: u32 = 2_000; pub(crate) const AGENT_RUNTIME_AUTONOMOUS_SCAFFOLD_MAX_OUTPUT_TOKENS: u32 = 2_600; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 4038f1bcb..4e5177b10 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -1,11 +1,12 @@ use super::*; const GAME_CREATOR_MANIFEST_INVALIDATION_RELAY_MAX_BYTES: u64 = 64 * 1024; +const GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX: usize = 16; -fn lock_game_creator_manifest_invalidation_event_sink( -) -> std::sync::MutexGuard<'static, Option> { - GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK - .get_or_init(|| Mutex::new(None)) +fn lock_game_creator_manifest_invalidation_event_sinks( +) -> std::sync::MutexGuard<'static, Vec> { + GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINKS + .get_or_init(|| Mutex::new(Vec::new())) .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } @@ -219,7 +220,7 @@ pub(crate) fn configure_game_creator_manifest_invalidation_event_sink( token: &str, ) -> Result<(), String> { let sink = validate_game_creator_manifest_invalidation_event_sink(port, token)?; - install_game_creator_manifest_invalidation_event_sink(sink); + register_game_creator_manifest_invalidation_event_sink(sink); Ok(()) } @@ -240,10 +241,29 @@ pub(crate) fn validate_game_creator_manifest_invalidation_event_sink( }) } -pub(crate) fn install_game_creator_manifest_invalidation_event_sink( +/// 登记一个界面窗口的事件接收端。 +/// +/// 同一窗口重复 attach 用同一个 token,按 token 覆盖旧登记;不同窗口各自持有 +/// 自己的 token,注册表按登记顺序保留,最多 `GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX` 个。 +pub(crate) fn register_game_creator_manifest_invalidation_event_sink( sink: GameCreatorManifestInvalidationEventSink, ) { - *lock_game_creator_manifest_invalidation_event_sink() = Some(sink); + let mut sinks = lock_game_creator_manifest_invalidation_event_sinks(); + if let Some(existing) = sinks + .iter_mut() + .find(|existing| existing.token == sink.token) + { + *existing = sink; + return; + } + if sinks.len() >= GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_MAX { + sinks.remove(0); + } + sinks.push(sink); +} + +fn remove_game_creator_manifest_invalidation_event_sink(token: &str) { + lock_game_creator_manifest_invalidation_event_sinks().retain(|sink| sink.token != token); } #[cfg(test)] @@ -258,14 +278,20 @@ impl GameCreatorManifestInvalidationEventSinkTestGuard { } pub(crate) fn configured_sink(&self) -> Option { - lock_game_creator_manifest_invalidation_event_sink().clone() + lock_game_creator_manifest_invalidation_event_sinks() + .first() + .cloned() + } + + pub(crate) fn configured_sinks(&self) -> Vec { + lock_game_creator_manifest_invalidation_event_sinks().clone() } } #[cfg(test)] impl Drop for GameCreatorManifestInvalidationEventSinkTestGuard { fn drop(&mut self) { - *lock_game_creator_manifest_invalidation_event_sink() = None; + lock_game_creator_manifest_invalidation_event_sinks().clear(); } } @@ -281,16 +307,43 @@ pub(crate) fn acquire_game_creator_manifest_invalidation_event_sink_test_guard( } fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Result<(), String> { - let sink = lock_game_creator_manifest_invalidation_event_sink().clone(); - let Some(sink) = sink else { + let sinks = lock_game_creator_manifest_invalidation_event_sinks().clone(); + if sinks.is_empty() { return Ok(()); + } + let event = GameCreatorManifestInvalidatedEvent { + project_path: root.to_string_lossy().into_owned(), + agent_id: agent_id.to_string(), }; + let mut failed_tokens = Vec::new(); + let mut last_error = None; + for sink in &sinks { + match relay_game_creator_manifest_invalidation_to_sink(sink, &event) { + Ok(()) => {} + Err(error) => { + // 窗口已退出或接收端已释放时只淘汰该接收端,不能影响其它窗口。 + failed_tokens.push(sink.token.clone()); + last_error = Some(error); + } + } + } + if !failed_tokens.is_empty() { + lock_game_creator_manifest_invalidation_event_sinks() + .retain(|sink| !failed_tokens.contains(&sink.token)); + } + match last_error { + Some(error) => Err(error), + None => Ok(()), + } +} + +fn relay_game_creator_manifest_invalidation_to_sink( + sink: &GameCreatorManifestInvalidationEventSink, + event: &GameCreatorManifestInvalidatedEvent, +) -> Result<(), String> { let envelope = GameCreatorManifestInvalidationRelayEnvelope { - token: sink.token, - event: GameCreatorManifestInvalidatedEvent { - project_path: root.to_string_lossy().into_owned(), - agent_id: agent_id.to_string(), - }, + token: sink.token.clone(), + event: event.clone(), }; let payload = serde_json::to_vec(&envelope) .map_err(|error| format!("序列化 manifest 失效事件失败:{error}"))?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs index 65b883879..0bae6dc31 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs @@ -84,7 +84,7 @@ pub(crate) use response_stream::{ pub(crate) use run_configuration::{ agent_runtime_run_profile_identity_at, bind_game_creator_agent_runtime_run_profile_at, game_creator_agent_runtime_project_revision_path, - game_creator_agent_runtime_provider_transient_max_retries_at, + game_creator_agent_runtime_provider_transient_retry_policy_at, game_creator_agent_runtime_run_profile_binding_path, read_game_creator_agent_runtime_run_profile_binding, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs index 1541acc41..1f2be5915 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs @@ -717,14 +717,14 @@ where Fut: std::future::Future>, H: FnOnce(&platform_llm::LlmRunResponse) -> platform_llm::LlmRunResponse, { - let max_retries = game_creator_agent_runtime_provider_transient_max_retries_at( + let retry_policy = game_creator_agent_runtime_provider_transient_retry_policy_at( root, &provider_snapshot.agent_id, &provider_snapshot.run_id, llm.max_retries, )?; - let retry_autonomous_upstream_400 = - max_retries >= AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR; + let max_retries = retry_policy.max_retries; + let retry_autonomous_upstream_400 = retry_policy.retry_upstream_400; let identity = game_creator_agent_runtime_provider_retry_identity_for_mode( provider_snapshot, llm, @@ -1365,17 +1365,9 @@ where )?; return Err("Provider 瞬态错误编码损坏".to_string()); }; - let error_max_retries = if error_kind == "upstream-400" { - effective_max_retries - .min(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT) - } else { - effective_max_retries - }; - if existing - .as_ref() - .is_some_and(|record| record.max_retries != error_max_retries) - || attempt >= error_max_retries - { + // 所有瞬态错误共用设置里的重试预算,上游 400 不再单独收窄上限。 + let error_max_retries = effective_max_retries; + if attempt >= error_max_retries { crate::provider_retry::remove_at( root, &provider_snapshot.agent_id, @@ -1517,14 +1509,14 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi operation: &str, request: &LlmRunRequest, ) -> Result, String> { - let max_retries = game_creator_agent_runtime_provider_transient_max_retries_at( + let retry_policy = game_creator_agent_runtime_provider_transient_retry_policy_at( root, &provider_snapshot.agent_id, &provider_snapshot.run_id, llm.max_retries, )?; - let retry_autonomous_upstream_400 = - max_retries >= AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR; + let max_retries = retry_policy.max_retries; + let retry_autonomous_upstream_400 = retry_policy.retry_upstream_400; for attempt in 0..=max_retries { let request_slot = if attempt == 0 { provider_snapshot.request_slot.clone() @@ -1585,11 +1577,8 @@ pub(in crate::agent) async fn request_game_creator_agent_runtime_llm_with_transi let Some((error_kind, public_error)) = encoded.split_once('\n') else { return Err("Provider 瞬态错误编码损坏".to_string()); }; - let error_max_retries = if error_kind == "upstream-400" { - max_retries.min(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT) - } else { - max_retries - }; + // 所有瞬态错误共用设置里的重试预算,上游 400 不再单独收窄上限。 + let error_max_retries = max_retries; if attempt >= error_max_retries { return Err(game_creator_agent_runtime_provider_retry_exhausted_error( public_error, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs index 61396cd9e..7d560ad5f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/run_configuration.rs @@ -395,12 +395,22 @@ pub(crate) fn agent_runtime_run_profile_identity_at( Ok((profile, String::new())) } -pub(crate) fn game_creator_agent_runtime_provider_transient_max_retries_at( +/// 当前持久 run 的 Provider 瞬态重试策略。 +/// +/// 重试次数严格使用设置值:运行档位不再把 `maxRetries` 收进固定区间, +/// 只决定上游 400 是否算瞬态错误。 +#[derive(Debug)] +pub(crate) struct AgentRuntimeProviderTransientRetryPolicy { + pub(crate) max_retries: u32, + pub(crate) retry_upstream_400: bool, +} + +pub(crate) fn game_creator_agent_runtime_provider_transient_retry_policy_at( root: &Path, agent_id: &str, run_id: &str, configured_max_retries: u32, -) -> Result { +) -> Result { let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; let stored_identity = read_latest_game_creator_agent_runtime_task_by_run_id(root, &agent_id, run_id)? @@ -416,10 +426,8 @@ pub(crate) fn game_creator_agent_runtime_provider_transient_max_retries_at( stored_profile, stored_binding_fingerprint, )?; - if profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { - return Ok(configured_max_retries - .max(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_FLOOR) - .min(AGENT_RUNTIME_AUTONOMOUS_PROVIDER_TRANSIENT_RETRY_LIMIT)); - } - Ok(configured_max_retries.min(AGENT_RUNTIME_PROVIDER_TRANSIENT_RETRY_LIMIT)) + Ok(AgentRuntimeProviderTransientRetryPolicy { + max_retries: configured_max_retries, + retry_upstream_400: profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, + }) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index 6f7aa3441..a61e1df4d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -577,6 +577,9 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio slice_mode: (!slice_mode.trim().is_empty()).then_some(slice_mode.clone()), grid_x, grid_y, + reference_asset_ids: Vec::new(), + target_category: None, + screen_color: None, }; if let Some(pending) = pending_action { match recover_persisted_visual_generation_options( @@ -628,6 +631,10 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio .or_else(|| (!slice_mode.trim().is_empty()).then_some(slice_mode)), grid_x, grid_y, + reference_asset_ids: requested_options.reference_asset_ids, + // Agent 运行时不会指定完成登记的目标栏目,保持调用方给的值(默认 `None`)。 + target_category: requested_options.target_category, + screen_color: requested_options.screen_color, } }; options.replace_existing = replace_existing; @@ -695,6 +702,53 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio detail: None, }; } + // 切分模式没有默认值:图集必须显式声明,且声明必须与 assetKind 和网格参数自洽。 + if options.asset_kind == "art-spritesheet" { + if options.slice_mode.is_none() { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: "failed".to_string(), + summary: "assetKind=art-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供 gridX/gridY;自由排布时用 connected-components" + .to_string(), + detail: None, + }; + } + if options.slice_mode.as_deref() == Some("connected-components") + && (options.grid_x.is_some() || options.grid_y.is_some()) + { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: "failed".to_string(), + summary: + "sliceMode=connected-components 不接受 gridX/gridY:网格尺寸只能与 grid 同时声明" + .to_string(), + detail: None, + }; + } + if options.slice_mode.as_deref() == Some("grid") && options.slice_count.is_some() { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: "failed".to_string(), + summary: "sliceMode=grid 的素材张数由 gridX×gridY 决定,不接受 sliceCount" + .to_string(), + detail: None, + }; + } + } else if options.slice_mode.is_some() + || options.grid_x.is_some() + || options.grid_y.is_some() + || options.slice_count.is_some() + { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: "failed".to_string(), + summary: format!( + "sliceMode/gridX/gridY/sliceCount 仅对 assetKind=art-spritesheet 生效,当前 assetKind={}", + options.asset_kind + ), + detail: None, + }; + } if !agent_runtime_canvas_asset_kind_is_supported(&options.asset_kind) { return AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 45ba5c505..2240b3341 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -1036,7 +1036,7 @@ fn runtime_tool_description(tool: &str) -> &'static str { "preview.validate" => "用真实浏览器验证桌面和移动预览并保存证据。", "image.inspect" => "让视觉模型检查一至两张项目内图片。", "canvas.asset_generate" => { - "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考,也可通过 sliceCount 指定图集切片数量。" + "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=art-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。" } "ui.workflow.run" => { "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。" @@ -1311,7 +1311,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value { asset_kinds.push(Value::Null); json!({ "type": "object", - "required": ["prompt", "outputPath", "aspectRatio", "imageSize", "assetKind", "assetLabel", "replaceExisting"], + "required": ["prompt", "outputPath", "aspectRatio", "imageSize", "assetKind", "assetLabel", "replaceExisting", "sliceMode", "gridX", "gridY", "sliceCount"], "additionalProperties": false, "properties": { "prompt": { "type": "string", "minLength": 1, "maxLength": 4000 }, @@ -1320,7 +1320,11 @@ fn runtime_tool_input_schema(tool: &str) -> Value { "imageSize": { "type": ["string", "null"], "enum": ["0.5K", "1K", "2K", null] }, "assetKind": { "type": ["string", "null"], "enum": asset_kinds }, "assetLabel": { "type": ["string", "null"], "maxLength": 80 }, - "replaceExisting": { "type": "boolean" } + "replaceExisting": { "type": "boolean" }, + "sliceMode": { "type": ["string", "null"], "enum": ["connected-components", "grid", null], "description": "仅 assetKind=art-spritesheet 生效且必填,没有默认值:等分网格或固定槽位用 grid,自由排布用 connected-components" }, + "gridX": { "type": ["integer", "null"], "minimum": 1, "maximum": 32, "description": "只与 sliceMode=grid 同时提供" }, + "gridY": { "type": ["integer", "null"], "minimum": 1, "maximum": 32, "description": "只与 sliceMode=grid 同时提供" }, + "sliceCount": { "type": ["integer", "null"], "minimum": 1, "maximum": 256, "description": "只与 sliceMode=connected-components 同时提供,用于约束目标素材张数" } } }) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs index 91a9eae42..f228420f6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs @@ -390,6 +390,12 @@ pub(crate) async fn start_local_project_asset_generation( image_size: Option, asset_name: Option, output_path: Option, + // 前端 IPC 字段 `referenceAssetIds`:当前项目 manifest 里的图片素材 id,只做参考输入, + // 不进任务账本(重试由调用方继续用同一份引用提交,账本本身不新增字段)。 + reference_asset_ids: Option>, + // 前端 IPC 字段 `targetCategory`:完成登记时要落盘的正式栏目分类。同样不进任务账本: + // 它与引用一样属于「同一次提交的本地落点」,重试由调用方继续用同一个栏目提交。 + target_category: Option, ) -> Result { let task_id = asset_generation_task_id(&task_id)?; let request = prepare_local_project_asset_generation( @@ -400,6 +406,8 @@ pub(crate) async fn start_local_project_asset_generation( image_size.as_deref(), asset_name.as_deref(), output_path.as_deref(), + reference_asset_ids.as_deref().unwrap_or_default(), + target_category.as_deref(), )?; enforce_project_permission_policy(&request.root, "canvas.asset_generate")?; enforce_project_permission_policy(&request.root, "asset.register")?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 110e35161..2a053191c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -1,5 +1,6 @@ use super::*; use sha2::{Digest as _, Sha256}; +use shared_contracts::game_creation_app::GameCreationAppAssetCategory; use std::future::Future; const PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX: &str = "external-editor-api-"; @@ -662,6 +663,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result generation_kind: None, reference_resource_ids: Vec::new(), }, + None, )?; changed |= asset_changed; } @@ -1876,8 +1878,56 @@ pub(crate) fn register_local_asset_entry( id_prefix: &str, source: GameCreationAppAssetSource, ) -> Result { - register_local_asset_entry_with_change(root, local_path, kind, media_type, id_prefix, source) - .map(|(result, _)| result) + register_local_asset_entry_with_change( + root, local_path, kind, media_type, id_prefix, source, None, + ) + .map(|(result, _)| result) +} + +/// 带**显式目标分类**的登记入口:只给 GUI 生成完成路径用(前端 `targetCategory`)。 +/// +/// 入口栏目与生成 kind 不是同一套词汇(栏目 `character` / `scene` / `ui-interaction`, +/// 生成 kind 的派生分类会把图片落到 `unclassified`、规范图落到 `document`),所以要落回 +/// 入口栏目只能由调用方把目标分类显式交进来。取值必须先过 +/// [`shared_contracts::game_creation_app::game_creation_app_asset_category_from_str`], +/// 非法值失败关闭,绝不回退到 kind 派生;其它调用方继续走 +/// [`register_local_asset_entry`],行为不变。 +pub(crate) fn register_local_asset_entry_with_category( + root: &Path, + local_path: &str, + kind: &str, + media_type: &str, + id_prefix: &str, + source: GameCreationAppAssetSource, + target_category: Option<&str>, +) -> Result { + let target_category = normalize_asset_category_override(target_category)?; + register_local_asset_entry_with_change( + root, + local_path, + kind, + media_type, + id_prefix, + source, + target_category, + ) + .map(|(result, _)| result) +} + +/// 归一显式目标分类:只接受合法枚举值,返回落盘字符串。 +fn normalize_asset_category_override( + target_category: Option<&str>, +) -> Result, String> { + let Some(target_category) = target_category else { + return Ok(None); + }; + let target_category = target_category.trim(); + if target_category.is_empty() { + return Ok(None); + } + game_creation_app_asset_category_from_str(target_category) + .map(Some) + .ok_or_else(|| format!("非法资源分类:{target_category}")) } fn register_local_asset_entry_with_change( @@ -1887,6 +1937,7 @@ fn register_local_asset_entry_with_change( media_type: &str, id_prefix: &str, source: GameCreationAppAssetSource, + target_category: Option, ) -> Result<(UploadLocalAssetResult, bool), String> { let normalized_path = normalize_relative_path(local_path)?; let absolute_path = resolve_local_project_path(root, &normalized_path)?; @@ -1912,11 +1963,17 @@ fn register_local_asset_entry_with_change( // kind 没变时刻意不动 category——落盘分类是权威值,同 kind 重登记不得抹掉它。 let changed = existing.kind != kind || existing.media_type != media_type - || existing.source != source; + || existing.source != source + || target_category.is_some_and(|category| existing.category != category); if existing.kind != kind { existing.kind = kind.to_string(); existing.category = game_creation_app_asset_category_for_kind(kind); } + // 调用方显式给出目标分类时它就是权威值:GUI 完成登记必须能落回入口栏目, + // 这也是同路径重新生成时把资产从旧栏目(或 unclassified)原位接管过来的唯一入口。 + if let Some(category) = target_category { + existing.category = category; + } existing.media_type = media_type.to_string(); existing.source = source; Ok((existing.id.clone(), "asset.update", changed)) @@ -1933,7 +1990,8 @@ fn register_local_asset_entry_with_change( local_path: normalized_path.clone(), image_sequence_frames: None, image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind(kind), + category: target_category + .unwrap_or_else(|| game_creation_app_asset_category_for_kind(kind)), tags: Vec::new(), source, }); @@ -2153,6 +2211,7 @@ pub(crate) fn delete_manifest_asset_at( #[cfg(test)] mod tests { use super::*; + use shared_contracts::game_creation_app::GameCreationAppAssetCategory; use std::io::{Read, Write}; #[test] @@ -2176,6 +2235,121 @@ mod tests { assert!(!register_design_artifacts_at(root).expect("register idempotently")); } + /// GUI 完成登记可以显式指定目标栏目:新建条目与已登记条目都按显式值落盘。 + /// + /// 入口栏目(character / scene / ui-interaction)与生成 kind 不是同一套词汇,按 kind 派生 + /// 会把图片落到 unclassified,占位拿不回原位;非法值必须失败关闭,不传时保持 kind 派生。 + #[test] + fn explicit_target_category_overrides_the_kind_derived_category() { + fn canvas_source() -> GameCreationAppAssetSource { + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + } + } + fn category_of(root: &Path, asset_id: &str) -> GameCreationAppAssetCategory { + read_existing_manifest_for_project(root) + .expect("read manifest") + .assets + .into_iter() + .find(|asset| asset.id == asset_id) + .expect("registered asset is present") + .category + } + + let temporary = tempfile::tempdir().expect("tempdir"); + let root = temporary.path(); + crate::project::init_local_game_project_at(root, "target-category-test", "目标栏目登记") + .expect("init project"); + fs::create_dir_all(root.join("assets")).expect("create assets dir"); + fs::write(root.join("assets/hero.png"), b"png-bytes").expect("write asset"); + + let registered = register_local_asset_entry_with_category( + root, + "assets/hero.png", + "image", + "image/png", + "platform-art", + canvas_source(), + Some("character"), + ) + .expect("register with a target category"); + assert_eq!( + category_of(root, ®istered.id), + GameCreationAppAssetCategory::Character + ); + + // 同 kind 重新生成时显式目标分类仍是权威值:资产要能换栏目原位接管。 + register_local_asset_entry_with_category( + root, + "assets/hero.png", + "image", + "image/png", + "platform-art", + canvas_source(), + Some("ui-interaction"), + ) + .expect("re-register with another target category"); + assert_eq!( + category_of(root, ®istered.id), + GameCreationAppAssetCategory::UiInteraction + ); + + // 非法值失败关闭,且不动已落盘的分类。 + assert!(register_local_asset_entry_with_category( + root, + "assets/hero.png", + "image", + "image/png", + "platform-art", + canvas_source(), + Some("version"), + ) + .is_err()); + assert_eq!( + category_of(root, ®istered.id), + GameCreationAppAssetCategory::UiInteraction + ); + + // 不传目标分类时保持原有行为:新条目按 kind 派生(image → unclassified)。 + fs::write(root.join("assets/plain.png"), b"png-bytes").expect("write plain asset"); + let plain = register_local_asset_entry( + root, + "assets/plain.png", + "image", + "image/png", + "platform-art", + canvas_source(), + ) + .expect("register without a target category"); + assert_eq!( + category_of(root, &plain.id), + GameCreationAppAssetCategory::Unclassified + ); + // 已落盘的显式分类在 kind 未变时仍然是权威值:同 kind 重登记不得把它抹掉。 + register_local_asset_entry( + root, + "assets/hero.png", + "image", + "image/png", + "platform-art", + canvas_source(), + ) + .expect("re-register without a target category"); + assert_eq!( + category_of(root, ®istered.id), + GameCreationAppAssetCategory::UiInteraction + ); + } + /// 画板导出推断出的 kind 必须已经是 canonical 值。 /// /// 这个值会被原样写进 manifest 并据以派生落盘 `category`;一旦写出非 canonical 值 diff --git a/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs b/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs index d236d77c8..0896b32eb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs @@ -237,7 +237,7 @@ fn persist(guard: &BuiltinPluginState) -> Result<(), String> { /// Agent 工具面是否可用:编译期 feature 打开且用户没有禁用该内置插件。 pub(crate) fn agent_tool_available(plugin: BuiltinPlugin) -> bool { plugin.exposes_agent_tools() - && cfg!(feature = "cocos-editor-execute") + && cfg!(all(windows, feature = "cocos-editor-execute")) && is_enabled(plugin.id()) } @@ -431,7 +431,7 @@ mod tests { let _guard = test_lock(); let directory = tempdir().expect("temp config"); initialize(directory.path()).expect("initialize"); - let tool_visible_when_enabled = cfg!(feature = "cocos-editor-execute"); + let tool_visible_when_enabled = cfg!(all(windows, feature = "cocos-editor-execute")); set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).expect("enable"); assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index ca97077f1..79d0dc2a4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1,7 +1,7 @@ use super::*; use crate::agent::{ - direct_codex_canonical_project_identity, read_direct_project_chat_history_at, - read_direct_project_last_item_id_at, + read_direct_project_chat_history_at, read_direct_project_last_item_id_at, + DirectProjectHistoryAnchor, }; use crate::ui_editor::resource::font::FontAsset; use sha2::{Digest, Sha256}; @@ -482,6 +482,43 @@ fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result Result { + let requested = requested.trim(); + let root = Path::new(requested); + if requested.is_empty() || !root.is_absolute() { + return Err("项目创建目录必须是绝对路径".to_string()); + } + if project_path_has_control_chars(root) { + return Err("项目创建目录不能包含控制字符".to_string()); + } + let metadata = fs::symlink_metadata(root) + .map_err(|error| format!("读取项目创建目录失败:{}: {error}", root.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("项目创建目录必须是普通文件夹".to_string()); + } + // 用户选择的外部目录仍走显式的项目根准备:保留 user-selected 范围的一次性修复, + // 同时不放弃 reparse point / 非普通目录的失败关闭。 + prepare_game_creator_project_root_for_read(root, true, "项目创建目录")?; + Ok(root.to_path_buf()) +} + +/// 解析本次建项要使用的根目录:没选就用 AGC 管理的默认目录,选了就用用户指定的目录。 +pub(crate) fn resolve_game_project_creation_root( + app: &tauri::AppHandle, + requested: Option<&str>, +) -> Result { + match requested.map(str::trim).filter(|value| !value.is_empty()) { + Some(requested) => validate_requested_game_project_creation_root(requested), + None => automatic_local_game_projects_root(app), + } +} + pub(crate) fn create_automatic_local_game_project_at( projects_root: &Path, requested_name: Option<&str>, @@ -551,9 +588,10 @@ pub(crate) fn create_automatic_local_game_project( app: tauri::AppHandle, name: Option, planning: Option, + projects_root: Option, ) -> Result { create_automatic_local_game_project_at( - &automatic_local_game_projects_root(&app)?, + &resolve_game_project_creation_root(&app, projects_root.as_deref())?, name.as_deref(), planning.unwrap_or(false), ) @@ -764,13 +802,30 @@ pub(crate) fn recent_game_creator_run_trace(root: &Path) -> Option(&content).ok() } +/// 目录选择器标题:调用方只能给短标题,其余(超长、含控制字符、空白)一律回退默认文案。 +fn pick_project_directory_title(title: Option<&str>) -> &str { + const MAX_TITLE_CHARS: usize = 24; + title + .map(str::trim) + .filter(|value| { + !value.is_empty() + && value.chars().count() <= MAX_TITLE_CHARS + && !value.chars().any(char::is_control) + }) + .unwrap_or("选择游戏项目目录") +} + #[tauri::command] pub(crate) async fn pick_local_project_directory( app: tauri::AppHandle, initial_path: Option, + title: Option, ) -> Result, String> { let (sender, receiver) = tokio::sync::oneshot::channel(); - let mut dialog = app.dialog().file().set_title("选择游戏项目目录"); + let mut dialog = app + .dialog() + .file() + .set_title(pick_project_directory_title(title.as_deref())); if let Some(initial_path) = initial_path .as_deref() .map(str::trim) @@ -1939,8 +1994,9 @@ pub(crate) async fn polish_local_project_prompt( } #[tauri::command] -pub(crate) fn read_platform_account_session_generation() -> u64 { - current_platform_session_generation() +pub(crate) fn read_platform_account_session_state( +) -> crate::platform_session::PlatformSessionWriteState { + crate::platform_session::current_platform_session_write_state() } #[tauri::command] @@ -1948,28 +2004,45 @@ pub(crate) async fn install_platform_account_session( user_id: String, access_token: String, api_base_url: String, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { tokio::task::spawn_blocking(move || { - validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?; + validate_platform_session_input( + &user_id, + &access_token, + &api_base_url, + identity_generation, + revision, + )?; install_external_agent_runner_platform_session( &user_id, &access_token, &api_base_url, - generation, + identity_generation, + revision, )?; - install_platform_session(&user_id, &access_token, &api_base_url, generation) + install_platform_session( + &user_id, + &access_token, + &api_base_url, + identity_generation, + revision, + ) }) .await .map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))? } #[tauri::command] -pub(crate) async fn clear_platform_account_session(generation: u64) -> Result<(), String> { +pub(crate) async fn clear_platform_account_session( + identity_generation: u64, + revision: u64, +) -> Result<(), String> { tokio::task::spawn_blocking(move || { shutdown_game_creator_codex_app_servers()?; - clear_external_agent_runner_platform_session(generation)?; - clear_platform_session(generation); + clear_external_agent_runner_platform_session(identity_generation, revision)?; + clear_platform_session(identity_generation, revision); Ok(()) }) .await @@ -1991,6 +2064,8 @@ pub(crate) fn write_game_creator_app_config( .lock() .map_err(|_| "配置写入锁不可用")?; let (current, overlays) = load_game_creator_app_config_for_write()?; + // 自定义开关只能从本地配置文件开启,不能由渲染层越过配置门禁。 + config.llm.custom_enabled = current.llm.custom_enabled; config.selected_model_id = current.selected_model_id; config.selected_model_is_default = current.selected_model_is_default; persist_game_creator_app_config(config, overlays, false) @@ -2027,7 +2102,12 @@ pub(crate) fn select_game_creator_model( let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK .lock() .map_err(|_| "配置写入锁不可用")?; - if model_id.is_empty() + let (mut config, overlays) = load_game_creator_app_config_for_write()?; + if config.llm.custom_enabled { + if !config.llm.visible_models.contains(&model_id) { + return Err("所选模型未勾选或已移除,请刷新模型列表".into()); + } + } else if model_id.is_empty() || model_id.len() > 64 || !model_id .bytes() @@ -2035,12 +2115,21 @@ pub(crate) fn select_game_creator_model( { return Err("模型标识无效".into()); } - let (mut config, overlays) = load_game_creator_app_config_for_write()?; config.selected_model_id = model_id; config.selected_model_is_default = is_default; persist_game_creator_app_config(config, overlays, true) } +#[tauri::command] +pub(crate) async fn discover_game_creator_llm_models( + llm: GameCreatorLlmConfig, +) -> Result, String> { + if !load_game_creator_app_config()?.llm.custom_enabled { + return Err("请先在本地配置中开启 llm.customEnabled".to_string()); + } + fetch_custom_llm_models(&llm).await +} + fn persist_game_creator_app_config( config: GameCreatorAppConfig, overlays: Vec<(PathBuf, serde_json::Value)>, @@ -2056,8 +2145,8 @@ fn persist_game_creator_app_config( let previous = overlay.clone(); if let Some(fields) = overlay.as_object_mut() { for (key, value) in fields.iter_mut() { - if matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault") - == model_only + if !model_only + || matches!(key.as_str(), "selectedModelId" | "selectedModelIsDefault") { if let Some(saved_value) = saved.get(key) { // 仅同步已有覆盖项;其它字段继续保留原有覆盖语义。 @@ -2286,6 +2375,27 @@ pub(crate) fn update_local_project_resource_classification( ) } +/// 为一批已登记素材追加标签:整批一次校验、一次 manifest 写入、一次 revision 推进。 +/// +/// 权限位与单素材分类更新同口径取 `asset.register`(命令包装层只做权限门面, +/// 身份 / 写锁 / CAS / 原子写与审计都在 `project/manifest.rs` 内完成)。 +/// 这里刻意**不**循环调用单素材命令:逐项调用会写出多份 manifest、推进多次 revision, +/// 中途失败还会留下"前几个素材改了、后面的没改"的部分写入。 +#[tauri::command] +pub(crate) fn add_local_project_resource_tags( + input: AddLocalProjectResourceTagsInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + add_manifest_asset_tags_at( + root, + &input.expected_project_id, + input.expected_project_revision, + input.asset_ids, + input.tags, + ) +} + #[tauri::command] pub(crate) async fn derive_local_project_resource( input: DeriveLocalProjectResourceInput, @@ -3058,7 +3168,7 @@ mod agent_asset_import_tests { #[test] fn local_project_asset_import_registers_multiple_types_and_is_idempotent() { - let project = tempfile::tempdir().expect("create project directory"); + let project = crate::tests::canonical_test_tempdir("agent-local-import-"); let root = project.path(); init_local_game_project_at(root, "agent-local-import", "Agent local import") .expect("initialize project"); @@ -3151,7 +3261,7 @@ mod agent_asset_import_tests { #[test] fn local_project_asset_import_rejects_absolute_and_case_insensitive_agent_paths() { - let project = tempfile::tempdir().expect("create project directory"); + let project = crate::tests::canonical_test_tempdir("agent-local-import-"); let root = project.path(); init_local_game_project_at(root, "agent-local-import", "Agent local import") .expect("initialize project"); @@ -3168,7 +3278,7 @@ mod agent_asset_import_tests { #[test] fn local_project_asset_import_rejects_hidden_and_build_tree_sources() { - let project = tempfile::tempdir().expect("create project directory"); + let project = crate::tests::canonical_test_tempdir("agent-local-import-"); let root = project.path(); init_local_game_project_at(root, "agent-local-import", "Agent local import") .expect("initialize project"); @@ -3198,20 +3308,147 @@ mod agent_asset_import_tests { #[test] fn local_project_asset_import_rejects_unknown_and_invalid_text_files() { - let project = tempfile::tempdir().expect("create project directory"); + let project = crate::tests::canonical_test_tempdir("agent-local-import-"); let root = project.path(); init_local_game_project_at(root, "agent-local-import", "Agent local import") .expect("initialize project"); fs::create_dir_all(root.join("assets")).expect("create assets directory"); - fs::write(root.join("assets/unknown.bin"), b"bytes").expect("write unknown file"); + fs::write(root.join("assets/unknown.dat"), b"bytes").expect("write unknown file"); fs::write(root.join("assets/broken.js"), [0xff, 0xfe]).expect("write invalid source"); assert!( - import_local_project_assets_for_agent(root, &["assets/unknown.bin".to_string()]) + import_local_project_assets_for_agent(root, &["assets/unknown.dat".to_string()]) .is_err() ); assert!( import_local_project_assets_for_agent(root, &["assets/broken.js".to_string()]).is_err() ); + // `.bin` 是引擎的 BufferAsset 载体,属于「已识别但只能出类型卡」的一类: + // 登记必须成功,否则引擎工程里的 BufferAsset 永远进不了资源画布。 + fs::write(root.join("assets/blob.bin"), [0x00, 0x01, 0x02]).expect("write buffer asset"); + let imported = + import_local_project_assets_for_agent(root, &["assets/blob.bin".to_string()]) + .expect("import engine buffer asset"); + assert_eq!(imported.assets.len(), 1); + assert_eq!(imported.assets[0].asset_kind.as_deref(), Some("document")); + } + + /// Cocos Creator 资源登记:模型、动画、序列化资源与引擎容器都要能进 manifest, + /// 且 `kind` 只落在**既有 canonical 词表**里(不新增契约值,旧客户端仍能读 manifest)。 + /// + /// 变异验证:把任一扩展名从 `agent_local_project_file_type` 删掉即变红。 + #[test] + fn local_project_asset_import_registers_cocos_creator_assets() { + // 工程自带助手:canonicalize + 目录 owner 归当前用户,避免 `%TEMP%` 临时目录 + // 在 Windows owner 校验下直接失败。 + let project = crate::tests::canonical_test_tempdir("cocos-asset-import-"); + let root = project.path(); + init_local_game_project_at(root, "cocos-import", "Cocos import") + .expect("initialize project"); + for directory in [ + "model", "anim", "scene", "mtl", "shader", "atlas", "map", "tex", "audio", + ] { + fs::create_dir_all(root.join("assets").join(directory)).expect("create assets subdir"); + } + let mut glb = b"glTF".to_vec(); + glb.extend_from_slice(&[2, 0, 0, 0, 12, 0, 0, 0]); + let mut fbx = b"Kaydara FBX Binary \x00".to_vec(); + fbx.extend_from_slice(&[0; 16]); + for (path, bytes) in [ + ("assets/model/hero.glb", glb), + ("assets/model/hero.fbx", fbx), + ( + "assets/anim/walk.anim", + b"[{\"__type__\":\"cc.AnimationClip\"}]".to_vec(), + ), + ( + "assets/anim/graph.animgraph", + b"{\"__type__\":\"cc.animation.AnimationGraph\"}".to_vec(), + ), + ( + "assets/scene/main.scene", + b"[{\"__type__\":\"cc.SceneAsset\"}]".to_vec(), + ), + ( + "assets/scene/enemy.prefab", + b"[{\"__type__\":\"cc.Prefab\"}]".to_vec(), + ), + ( + "assets/mtl/hero.mtl", + b"{\"__type__\":\"cc.Material\"}".to_vec(), + ), + ("assets/shader/glow.effect", b"CCEffect %{\n}".to_vec()), + ( + "assets/atlas/hero.plist", + b"".to_vec(), + ), + ( + "assets/map/level.tmx", + b"".to_vec(), + ), + ("assets/tex/hero.texture", vec![0xff, 0x00, 0x01]), + ("assets/audio/voice.pcm", vec![0x00, 0x01, 0x02]), + ] { + fs::write(root.join(path), bytes).expect("write cocos asset"); + } + + let relative_paths = [ + "assets/model/hero.glb", + "assets/model/hero.fbx", + "assets/anim/walk.anim", + "assets/anim/graph.animgraph", + "assets/scene/main.scene", + "assets/scene/enemy.prefab", + "assets/mtl/hero.mtl", + "assets/shader/glow.effect", + "assets/atlas/hero.plist", + "assets/map/level.tmx", + "assets/tex/hero.texture", + "assets/audio/voice.pcm", + ] + .map(str::to_string) + .to_vec(); + let imported = + import_local_project_assets_for_agent(root, &relative_paths).expect("import cocos"); + let kinds = imported + .assets + .iter() + .map(|asset| (asset.local_path.as_str(), asset.asset_kind.as_deref())) + .collect::>(); + assert_eq!(kinds.get("assets/model/hero.glb"), Some(&Some("scene"))); + assert_eq!(kinds.get("assets/model/hero.fbx"), Some(&Some("scene"))); + assert_eq!( + kinds.get("assets/anim/walk.anim"), + Some(&Some("character-animation")) + ); + assert_eq!( + kinds.get("assets/anim/graph.animgraph"), + Some(&Some("character-animation")) + ); + assert_eq!(kinds.get("assets/scene/main.scene"), Some(&Some("scene"))); + assert_eq!(kinds.get("assets/scene/enemy.prefab"), Some(&Some("scene"))); + assert_eq!(kinds.get("assets/mtl/hero.mtl"), Some(&Some("code"))); + assert_eq!(kinds.get("assets/shader/glow.effect"), Some(&Some("code"))); + assert_eq!( + kinds.get("assets/atlas/hero.plist"), + Some(&Some("document")) + ); + // 瓦片地图与场景/预制体同栏(`scene`),不是「文档」:它描述的是可摆放的地图。 + assert_eq!(kinds.get("assets/map/level.tmx"), Some(&Some("scene"))); + assert_eq!( + kinds.get("assets/tex/hero.texture"), + Some(&Some("document")) + ); + assert_eq!(kinds.get("assets/audio/voice.pcm"), Some(&Some("audio"))); + + // 非 UTF-8 的 `.prefab` 会被 `document` 分支拒绝:结构化文本资源必须是 UTF-8, + // 否则「结构预览」拿到的是一堆乱码。 + fs::write(root.join("assets/scene/broken.prefab"), [0xff, 0xfe]) + .expect("write invalid prefab"); + assert!(import_local_project_assets_for_agent( + root, + &["assets/scene/broken.prefab".to_string()] + ) + .is_err()); } /// 平台导入(账户素材库 / 网页项目画布)落盘的 `kind`:**有真实类型就用真实类型**, @@ -3928,6 +4165,140 @@ fn agent_local_project_file_type( }, max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, }), + /* + * Cocos Creator(3.8.8)资源:三维模型、动画、材质/特效、场景/预制体、图集与 + * 压缩纹理容器。登记边界只做两件事:给出可判定的 `asset_kind` 与**预览通道** + * 能支撑的 `media_type`。 + * + * - `document`:Cocos 自己序列化的文本/JSON(要过 UTF-8 校验),卡面按结构预览; + * - `binary`:客户端无法解码的容器(模型、压缩纹理、Spine 二进制等),只要求非空; + * - `image`:可用原生解码转码成 PNG 再预览的图像容器(tga/tif/tiff/hdr/exr)。 + * + * 这些扩展名必须与 `agent/direct_tool_bridge.rs::bridge_project_file_class` 和 + * `agent/generation/prompt_context.rs::prompt_context_media_type` 同步,否则会出现 + * 「发现得了、登记不了」或「登记得了、Agent 看不见」的分叉。 + */ + "scene" | "fire" | "prefab" | "terrain" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "scene", + media_type: "application/json", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "tmx" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "scene", + media_type: "application/xml", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "anim" | "animation" | "animgraph" | "animgraphvari" | "animask" => { + Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "character-animation", + media_type: "application/json", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }) + } + "mtl" | "material" | "pmtl" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "code", + media_type: "application/json", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "effect" | "chunk" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "code", + media_type: "text/plain", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "plist" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "document", + media_type: "application/xml", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "labelatlas" | "pac" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "document", + media_type: "application/json", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "fnt" | "atlas" => Some(AgentLocalProjectFileType { + category: "document", + asset_kind: "document", + media_type: "text/plain", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "glb" => Some(AgentLocalProjectFileType { + category: "binary", + asset_kind: "scene", + media_type: "model/gltf-binary", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "gltf" => Some(AgentLocalProjectFileType { + category: "binary", + asset_kind: "scene", + media_type: "model/gltf+json", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "fbx" => Some(AgentLocalProjectFileType { + category: "binary", + asset_kind: "scene", + media_type: "application/octet-stream", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "mesh" | "skeleton" => Some(AgentLocalProjectFileType { + // Cocos 的 `.mesh` / `.skeleton` 是网格与骨骼的实例化数据,多数工程里是 + // JSON、但也存在二进制变体,因此只按「非空」校验;能不能当文本预览由 + // 结构化预览读取自己判定(非 UTF-8 时降级成类型卡)。 + category: "binary", + asset_kind: "scene", + media_type: "application/json", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" => { + Some(AgentLocalProjectFileType { + category: "binary", + asset_kind: "document", + media_type: "application/octet-stream", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }) + } + "psd" | "znt" => Some(AgentLocalProjectFileType { + category: "binary", + asset_kind: "image", + media_type: "application/octet-stream", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "tga" => Some(AgentLocalProjectFileType { + category: "image", + asset_kind: "image", + media_type: "image/x-tga", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "tif" | "tiff" => Some(AgentLocalProjectFileType { + category: "image", + asset_kind: "image", + media_type: "image/tiff", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "hdr" => Some(AgentLocalProjectFileType { + category: "image", + asset_kind: "image", + media_type: "image/vnd.radiance", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "exr" => Some(AgentLocalProjectFileType { + category: "image", + asset_kind: "image", + media_type: "image/x-exr", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), + "pcm" => Some(AgentLocalProjectFileType { + category: "audio", + asset_kind: "audio", + media_type: "audio/pcm", + max_file_size: UI_EDITOR_IMAGE_MAX_FILE_SIZE, + }), _ => None, }; let file_type = file_type.ok_or_else(|| format!("本地文件类型不受支持:{relative_path}"))?; @@ -4231,14 +4602,7 @@ pub(crate) async fn import_account_editor_assets_for_agent( access.validate_frozen_session()?; let _platform_session_lease = frozen_session .as_ref() - .map(|session| { - acquire_validated_platform_session_fingerprint( - &session.user_id, - &session.api_base_url, - session.generation, - &format!("{:x}", Sha256::digest(session.access_token.as_bytes())), - ) - }) + .map(|session| acquire_platform_session_identity_lease(&session.identity())) .transpose()?; let _lock = acquire_project_write_lock(root, "canvas.asset_import")?; access.validate_frozen_session()?; @@ -4583,6 +4947,8 @@ pub(crate) fn prepare_local_project_asset_generation( image_size: Option<&str>, asset_name: Option<&str>, output_path: Option<&str>, + reference_asset_ids: &[String], + target_category: Option<&str>, ) -> Result { let project_path = project_path.trim(); if project_path.is_empty() { @@ -4590,6 +4956,13 @@ pub(crate) fn prepare_local_project_asset_generation( } let asset_kind = normalize_platform_art_asset_generation_kind(kind) .ok_or_else(|| format!("素材类型不受支持:{}", kind.trim()))?; + // 参考入参只接受当前项目 manifest 素材 id:路径、远端 resourceId 与超限在这里就被拒绝, + // 不把校验推迟到远端(远端只该收到当前账号绑定下的 resource ID)。 + let reference_asset_ids = + normalize_platform_art_reference_asset_ids(asset_kind, reference_asset_ids)?; + // GUI 完成登记层参数:入口栏目与生成 kind 不是同一套词汇,只有调用方显式给出目标分类 + // 才能把产物原位落回入口栏目。非法值(含 `version` / `all` 这类栏目伪值)直接失败关闭。 + let target_category = normalize_platform_art_target_category(target_category)?; Ok(LocalProjectAssetGenerationRequest { root: PathBuf::from(project_path), prompt: local_project_asset_prompt(prompt)?, @@ -4620,9 +4993,15 @@ pub(crate) fn prepare_local_project_asset_generation( .unwrap_or_else(|| LOCAL_PROJECT_ASSET_DEFAULT_ASSET_NAME.to_string()), replace_existing: false, slice_count: None, - slice_mode: None, + // 切分模式没有默认值:GUI 快速编辑只按自由排布生成图集,因此仅在 art-spritesheet + // 时显式声明连通域切分;等分网格或固定槽位需求由外部 API 显式传 grid + gridX/gridY。 + slice_mode: (asset_kind == "art-spritesheet") + .then(|| "connected-components".to_string()), grid_x: None, grid_y: None, + reference_asset_ids, + target_category, + screen_color: None, }, }) } @@ -4644,6 +5023,10 @@ pub(crate) async fn generate_local_project_asset( image_size: Option, asset_name: Option, output_path: Option, + reference_asset_ids: Option>, + // 前端 IPC 字段 `targetCategory`:本次生成完成登记时要落盘的正式栏目分类, + // 只走 GUI 命令,取值必须是合法素材分类,Agent / Direct 路径不传。 + target_category: Option, ) -> Result { let request = prepare_local_project_asset_generation( &project_path, @@ -4653,6 +5036,8 @@ pub(crate) async fn generate_local_project_asset( image_size.as_deref(), asset_name.as_deref(), output_path.as_deref(), + reference_asset_ids.as_deref().unwrap_or_default(), + target_category.as_deref(), )?; enforce_project_permission_policy(&request.root, "canvas.asset_generate")?; enforce_project_permission_policy(&request.root, "asset.register")?; @@ -4671,7 +5056,17 @@ mod local_project_asset_generation_tests { use super::*; fn prepare(kind: &str, prompt: &str) -> Result { - prepare_local_project_asset_generation("/tmp/project", kind, prompt, None, None, None, None) + prepare_local_project_asset_generation( + "/tmp/project", + kind, + prompt, + None, + None, + None, + None, + &[], + None, + ) } #[test] @@ -4711,6 +5106,8 @@ mod local_project_asset_generation_tests { Some("2K"), Some(" 主角图集 "), Some(" assets/hero.png "), + &[], + None, ) .expect("explicit options"); assert_eq!(explicit.root, PathBuf::from("/tmp/project")); @@ -4738,8 +5135,18 @@ mod local_project_asset_generation_tests { #[test] fn invalid_toolbar_arguments_are_rejected_before_any_generation() { assert_eq!( - prepare_local_project_asset_generation("", "image", "要求", None, None, None, None) - .expect_err("empty project path"), + prepare_local_project_asset_generation( + "", + "image", + "要求", + None, + None, + None, + None, + &[], + None, + ) + .expect_err("empty project path"), "项目路径不能为空" ); assert_eq!( @@ -4750,6 +5157,60 @@ mod local_project_asset_generation_tests { prepare("game-art", "要求").expect_err("unverified kind"), "素材类型不受支持:game-art" ); + // 目标分类只接受合法素材分类枚举:栏目侧伪值 `version` / `all` 与任意其它值都失败关闭。 + for rejected in ["version", "all", "bogus", "UI"] { + assert_eq!( + prepare_local_project_asset_generation( + "/tmp/project", + "image", + "要求", + None, + None, + None, + None, + &[], + Some(rejected), + ) + .expect_err("illegal target category"), + format!("目标分类不是合法素材分类:{rejected}") + ); + } + // 合法值归一成落盘字符串(trim + kebab-case),供 manifest `category` 直接使用。 + assert_eq!( + prepare_local_project_asset_generation( + "/tmp/project", + "image", + "要求", + None, + None, + None, + None, + &[], + Some(" ui-interaction "), + ) + .expect("legal target category") + .options + .target_category + .as_deref(), + Some("ui-interaction") + ); + assert_eq!( + prepare_local_project_asset_generation( + "/tmp/project", + "image", + "要求", + None, + None, + None, + None, + &[], + None, + ) + .expect("omitted target category") + .options + .target_category, + None + ); assert_eq!( prepare( "spec", @@ -4766,7 +5227,9 @@ mod local_project_asset_generation_tests { Some("4:3"), None, None, - None + None, + &[], + None, ) .expect_err("unsupported ratio"), "图片比例不受支持:4:3" @@ -4779,7 +5242,9 @@ mod local_project_asset_generation_tests { None, Some("4K"), None, - None + None, + &[], + None, ) .expect_err("unsupported size"), "图片尺寸不受支持:4K" @@ -4792,7 +5257,9 @@ mod local_project_asset_generation_tests { None, None, Some("坏\u{7}名字"), - None + None, + &[], + None, ) .expect_err("control character in asset name"), "素材名称超出安全边界" @@ -4805,7 +5272,9 @@ mod local_project_asset_generation_tests { None, None, None, - Some(&"a".repeat(LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS + 1)) + Some(&"a".repeat(LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS + 1)), + &[], + None, ) .expect_err("oversized output path"), "输出路径超出安全边界" @@ -5008,7 +5477,80 @@ pub(crate) fn read_local_project_text_preview_at( return Err("只能读取当前项目已登记的文档资源".to_string()); } cancellation.check()?; - load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation) + let mut preview = + load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation)?; + if normalized_path.to_ascii_lowercase().ends_with(".json") { + preview.ui_design_asset_id = manifest.assets.iter().find_map(|asset| { + (asset.local_path == normalized_path + && ui_editor::persistence::is_valid_ui_design_json( + &preview.content, + &manifest.project_id, + &asset.id, + )) + .then(|| asset.id.clone()) + }); + } + cancellation.check()?; + Ok(preview) +} + +/** + * 读取引擎(Cocos)序列化资源的**只读结构预览**。 + * + * 与文本预览分开的理由:`.prefab` / `.scene` / `.anim` / `.effect` 这些扩展名不属于 + * 「可编辑文本资源」白名单(那份名单同时服务 UI 编辑器),把它们并进去会顺手改变 + * UI 编辑链路的准入;这里只服务资源画布的卡面预览,且允许非 UTF-8 的二进制变体 + * 降级成类型卡(`content: null`),不把「不能预览」报成错误。 + */ +#[tauri::command] +pub(crate) async fn read_local_project_structured_preview( + preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>, + project_path: String, + relative_path: String, + scope_id: String, + request_id: String, +) -> Result { + preview_manager + .run(&scope_id, &request_id, move |cancellation| { + read_local_project_structured_preview_at(&project_path, &relative_path, cancellation) + }) + .await +} + +pub(crate) fn read_local_project_structured_preview_at( + project_path: &str, + relative_path: &str, + cancellation: &ProjectResourcePreviewScopeCancellation, +) -> Result { + cancellation.check()?; + let root = Path::new(project_path.trim()); + enforce_project_auto_permission_policy(root, "file.read")?; + cancellation.check()?; + let normalized_path = normalize_relative_path(relative_path.trim())?; + let manifest = read_manifest_cached_for_preview(&root.join(".agent/manifest.json"))?; + cancellation.check()?; + let registered_media_type = manifest + .assets + .iter() + .find(|asset| asset.local_path == normalized_path) + .map(|asset| asset.media_type.clone()); + let is_registered_structured = registered_media_type.as_deref().is_some_and(|media_type| { + is_supported_project_structured_resource(&normalized_path, media_type) + }) || manifest.tasks.iter().any(|task| { + task.status == GameCreationAppTaskStatus::Completed + && task.artifacts.iter().any(|path| path == &normalized_path) + && is_supported_project_structured_resource(&normalized_path, "") + }); + if !is_registered_structured { + return Err("只能读取当前项目已登记的引擎资源".to_string()); + } + cancellation.check()?; + load_local_project_structured_preview_with_cancellation( + root, + &normalized_path, + registered_media_type.as_deref().unwrap_or(""), + cancellation, + ) } #[tauri::command] @@ -5050,7 +5592,8 @@ pub(crate) fn read_local_project_media_preview_at( let kind = match category.trim() { "art" => ProjectMediaPreviewKind::Art, "audio" => ProjectMediaPreviewKind::Audio, - _ => return Err("媒体预览类别只支持 art 或 audio".to_string()), + "model" => ProjectMediaPreviewKind::Model, + _ => return Err("媒体预览类别只支持 art、audio 或 model".to_string()), }; let is_registered_media = manifest.assets.iter().any(|asset| { asset.local_path == normalized_path @@ -5061,6 +5604,9 @@ pub(crate) fn read_local_project_media_preview_at( ProjectMediaPreviewKind::Audio => { is_supported_project_audio_resource(&asset.local_path, &asset.media_type) } + ProjectMediaPreviewKind::Model => { + is_supported_project_model_resource(&asset.local_path, &asset.media_type) + } } }) || manifest.tasks.iter().any(|task| { task.status == GameCreationAppTaskStatus::Completed @@ -5072,6 +5618,9 @@ pub(crate) fn read_local_project_media_preview_at( ProjectMediaPreviewKind::Audio => { is_supported_project_audio_resource(&normalized_path, "") } + ProjectMediaPreviewKind::Model => { + is_supported_project_model_resource(&normalized_path, "") + } } }); if !is_registered_media { @@ -5306,31 +5855,6 @@ pub(crate) async fn read_agent_runtime_error_detail( .await .map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))? } -#[tauri::command] -pub(crate) async fn read_direct_tool_calls( - project_path: String, -) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - read_direct_tool_calls_at(root) - }) - .await - .map_err(|error| format!("读取工具调用历史后台任务失败:{error}"))? -} - -#[tauri::command] -pub(crate) async fn read_direct_turn_stream( - project_path: String, -) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - read_direct_turn_stream_at(root) - }) - .await - .map_err(|error| format!("读取回合流历史后台任务失败:{error}"))? -} #[tauri::command] pub(crate) fn list_game_creator_direct_active_turns( @@ -5345,13 +5869,7 @@ pub(crate) async fn subscribe_direct_project_thread( tauri::async_runtime::spawn_blocking(move || { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; - let (canonical_root, _) = direct_codex_canonical_project_identity(root)?; - let thread_root = canonical_root - .to_str() - .and_then(|value| value.strip_prefix("\\\\?\\")) - .map(Path::new) - .unwrap_or(canonical_root.as_path()); - let thread_id = thread_root.to_string_lossy().into_owned(); + let thread_id = direct_thread_id_for_project(root); let mut bootstrap = subscribe_direct_thread(&thread_id); if bootstrap.last_completed_item_id.is_none() { bootstrap.last_completed_item_id = read_direct_project_last_item_id_at(root)?; @@ -5369,24 +5887,43 @@ pub(crate) fn consume_direct_project_thread( consume_direct_thread(subscription_id.trim()) } +/// 读一屏项目对话历史。 +/// +/// 窗口两端各由一个锚点给出,两者互斥(都传会报错):`before_item_id` 是**旧端**边界(不含 +/// 该条,向后翻页用),`through_item_id` 是**新端**边界(含该条,取 `subscribe` 回执里的 +/// `lastCompletedItemId`,比它更新的条目只从运行态事件来);都不传就是文件尾最近的一屏。 #[tauri::command] pub(crate) async fn read_direct_project_history_slice( project_path: String, before_item_id: Option, + through_item_id: Option, limit: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; - let (items, has_more, item_timestamps) = read_direct_project_history_items_slice_at( - root, - before_item_id.as_deref(), - limit.unwrap_or(20), - )?; + let anchor = match (before_item_id.as_deref(), through_item_id.as_deref()) { + (Some(_), Some(_)) => { + return Err( + "DirectProject 历史切片只接受一个锚点(beforeItemId / throughItemId)" + .to_string(), + ) + } + (Some(before), None) => DirectProjectHistoryAnchor::Before(before), + (None, Some(through)) => DirectProjectHistoryAnchor::Through(through), + (None, None) => DirectProjectHistoryAnchor::Newest, + }; + let (raw_items, has_more, recorded_at_ms, first_item_id) = + read_direct_project_history_items_slice_at(root, anchor, limit.unwrap_or(20))?; + let items = direct_thread_items_from_history(root, &raw_items, |item| { + direct_thread_item_identity(item) + .and_then(|identity| recorded_at_ms.get(&identity).copied()) + .unwrap_or_default() + }); Ok(DirectThreadHistorySlice { items, has_more, - item_timestamps, + first_item_id, }) }) .await diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index b66b976af..87608cc7d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -108,6 +108,8 @@ fn user_selected_path_is_authorized(path: &Path, is_directory: bool) -> bool { } pub(crate) const OFFICIAL_LLM_ROUTER_BASE_URL: &str = "https://router.genarrative.world/v1"; +/// 官方路由未选择平台目录模型时写入配置文件的占位标识。 +pub(crate) const OFFICIAL_LLM_ROUTER_DEFAULT_MODEL: &str = "platform-default"; pub(crate) const GAME_CREATOR_LLM_AGENT_REASONING_EFFORT_DEFAULTS: [(&str, &str); 21] = [ (GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "high"), @@ -162,6 +164,9 @@ fn build_game_creator_platform_llm_config( llm: &GameCreatorLlmConfig, config_path: &str, ) -> Result { + if llm.custom_enabled { + return build_game_creator_provider_llm_config(llm, config_path); + } if game_creator_official_llm_route_locked() { return build_game_creator_official_platform_llm_config(llm); } @@ -454,7 +459,7 @@ fn check_game_creator_codex_config( ); let mut status = check_game_creator_llm_config_values(&app_config.llm, "llm"); status.agent_mode = app_config.agent_mode.clone(); - if game_creator_official_llm_route_locked() { + if !app_config.llm.custom_enabled && game_creator_official_llm_route_locked() { let account_ready = current_platform_session().is_some(); status.account_credential_state = if account_ready { "ready".to_string() @@ -490,7 +495,7 @@ fn check_game_creator_codex_config( ); agent.configured = cli_error.is_none() && route_error.is_none(); agent.error = cli_error.clone().or(route_error); - if game_creator_official_llm_route_locked() { + if !llm.custom_enabled && game_creator_official_llm_route_locked() { agent.account_credential_state = status.account_credential_state.clone(); agent.official_route_locked = true; agent.configured = status.configured; @@ -528,6 +533,14 @@ pub(crate) fn game_creator_codex_app_server_llm_route_error( if agent_mode != GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER { return None; } + if llm.custom_enabled { + if let Err(error) = validate_custom_llm_connection(llm) { + return Some(error); + } + if !llm.visible_models.contains(&llm.model) { + return Some("请至少勾选一个模型,并从已勾选列表选择模型".to_string()); + } + } if llm.api_kind != "openai_responses" { return Some(format!( "配置项 {config_path}.apiKind={} 不能由 codex_app_server 直接映射;请使用 openai_responses 或切换 provider 模式", @@ -601,7 +614,7 @@ pub(crate) fn check_game_creator_llm_config_values( "unavailable" } .to_string(), - official_route_locked: game_creator_official_llm_route_locked(), + official_route_locked: !config.custom_enabled && game_creator_official_llm_route_locked(), reasoning_effort: config.reasoning_effort.clone(), stream: config.stream, web_search_enabled: config.web_search_enabled, @@ -1185,6 +1198,22 @@ pub(crate) fn prepare_game_creator_project_root_for_read( { WindowsAclRepairScope::UserSelected } else { + #[cfg(all(windows, test))] + if windows_test_temp_path_needs_owner_initialization(path, is_directory) { + // 测试夹具:在提权 shell 里,系统临时目录下新建的目录默认所有者是 + // Administrators 组而不是当前 TokenUser,测试进程无法提权改所有者。 + // 该目录由当前测试进程创建,因此按“本调用创建的对象”初始化所有者后 + // 重试;其它越权所有者、以及临时目录之外的路径仍然失败关闭。 + if windows_path_is_under_test_temp_dir(path) { + secure_windows_game_creator_path_for_current_user_with_owner_policy( + path, + is_directory, + true, + true, + )?; + return Ok(true); + } + } return secure_windows_game_creator_path_for_current_user(path, is_directory, true) .map(|_| true); }; @@ -1707,7 +1736,7 @@ pub(crate) fn prepare_game_creator_private_path_for_read( true, ) } else { - secure_windows_game_creator_path_for_current_user(path, is_directory, true) + verify_game_creator_private_path_or_test_temp_owner(path, is_directory) }; return result.map(|_| true).map_err(|repair_error| { if game_creator_private_path_allows_auto_elevation(path) { @@ -1750,7 +1779,7 @@ pub(crate) fn prepare_game_creator_private_path_for_read( } else { // User-selected external files are never silently adopted. Keep the // strict owner/DACL check, but do not escalate an arbitrary path. - secure_windows_game_creator_path_for_current_user(path, is_directory, true)?; + verify_game_creator_private_path_or_test_temp_owner(path, is_directory)?; } Ok(true) } @@ -2101,6 +2130,68 @@ pub(crate) fn validate_game_creator_runtime_config_dir_outside_project( Ok(()) } +/// 测试夹具专用:判断某个已存在的项目根是否只是“系统临时目录下所有者不是当前用户”。 +/// +/// 部分 Windows 主机(例如以提权 shell 运行测试)在 `%TEMP%` 下新建的目录,默认所有者是 +/// `BUILTIN\Administrators` 组而不是当前 TokenUser;测试进程无法提权改所有者,于是严格 +/// 校验会拒绝一个由测试自己创建、且确实位于系统临时目录的目录。只有测试构建、路径位于 +/// 系统临时目录、并且失败原因确实是所有者不匹配时才返回 true;临时目录之外的越权所有者 +/// 继续失败关闭。 +#[cfg(all(windows, test))] +fn windows_test_temp_path_needs_owner_initialization(path: &Path, is_directory: bool) -> bool { + if !path.is_absolute() { + return false; + } + match secure_windows_game_creator_path_for_current_user(path, is_directory, true) { + Ok(()) => false, + Err(error) => { + error.contains("安全对象不属于当前用户") && windows_path_is_under_test_temp_dir(path) + } + } +} + +/// 严格校验一个既有私有对象;测试构建下对系统临时目录内的所有者偏差做一次性所有者 +/// 初始化重试,其余情况保持严格失败关闭。 +#[cfg(windows)] +fn verify_game_creator_private_path_or_test_temp_owner( + path: &Path, + is_directory: bool, +) -> Result<(), String> { + #[cfg(test)] + if windows_test_temp_path_needs_owner_initialization(path, is_directory) { + return secure_windows_game_creator_path_for_current_user_with_owner_policy( + path, + is_directory, + true, + true, + ); + } + secure_windows_game_creator_path_for_current_user(path, is_directory, true) +} + +#[cfg(all(windows, test))] +fn windows_path_is_under_test_temp_dir(path: &Path) -> bool { + let normalize = |value: &Path| { + value + .to_string_lossy() + .replace('/', "\\") + .trim_end_matches('\\') + .to_ascii_lowercase() + }; + let temp_dir = std::env::temp_dir(); + let mut roots = vec![normalize(&temp_dir)]; + if let Ok(canonical) = temp_dir.canonicalize() { + let root = normalize(&canonical); + if !roots.contains(&root) { + roots.push(root); + } + } + let candidate = normalize(path); + roots + .iter() + .any(|root| candidate == *root || candidate.starts_with(&format!("{root}\\"))) +} + #[cfg(windows)] pub(crate) fn secure_windows_game_creator_path_for_current_user( path: &Path, @@ -3332,9 +3423,7 @@ pub(crate) fn configure_game_creator_runtime_config_dir( write_game_creator_config_atomically(&config_path, DEFAULT_GAME_CREATOR_APP_CONFIG_JSON) .map_err(std::io::Error::other)?; } - // Both the normal config and the optional local override are persisted - // inputs. Every real AGC build scrubs legacy provider credentials from - // either file before the next read can observe them again. + // 按主配置与本地覆盖的最终开关决定是否保留自定义连接。 for path in [ config_path, config_dir.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME), @@ -3421,6 +3510,7 @@ pub(crate) fn load_game_creator_app_config() -> Result bool { + if config.llm.as_ref().and_then(|llm| llm.custom_enabled) == Some(true) { + return false; + } let mut changed = config.agent_mode.as_deref() != Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER) || config.agent_llm.is_some(); config.agent_mode = Some(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string()); config.agent_llm = None; if let Some(llm) = config.llm.as_mut() { - changed |= llm.api_key.is_some() - || llm.base_url.is_some() - || llm.model.is_some() - || llm.api_kind.is_some(); - llm.api_key = None; - llm.base_url = None; - llm.model = None; - llm.api_kind = None; + // 官方路由仍然清空凭据,但把连接字段留在文件里:手写自定义连接时 + // 用户能看到 baseUrl / apiKey / model / apiKind 四要素与开关、模型列表并列。 + let official_model = config + .selected_model_id + .clone() + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| OFFICIAL_LLM_ROUTER_DEFAULT_MODEL.to_string()); + changed |= llm.api_key.as_deref() != Some("") + || llm.base_url.as_deref() != Some(OFFICIAL_LLM_ROUTER_BASE_URL) + || llm.model.as_deref() != Some(official_model.as_str()) + || llm.api_kind.as_deref() != Some(DEFAULT_GAME_CREATOR_LLM_API_KIND) + || llm.custom_enabled.is_none() + || llm.visible_models.is_none(); + llm.api_key = Some(String::new()); + llm.base_url = Some(OFFICIAL_LLM_ROUTER_BASE_URL.to_string()); + llm.model = Some(official_model); + llm.api_kind = Some(DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string()); + llm.custom_enabled = Some(false); + llm.visible_models = Some(llm.visible_models.take().unwrap_or_default()); } if config.editor_api.is_some() { changed = true; @@ -3480,6 +3585,7 @@ pub(crate) fn migrate_legacy_game_creator_agent_mode(path: &Path) -> Result<(), let mut config = serde_json::from_str::(&content) .map_err(|error| format!("解析客户端配置失败:{}: {error}", path.display()))?; let mut changed = false; + changed |= ensure_game_creator_custom_llm_file_fields(&mut config); let inferred_agent_mode = config .agent_mode .as_deref() @@ -3531,7 +3637,7 @@ pub(crate) fn migrate_legacy_game_creator_agent_mode(path: &Path) -> Result<(), )); } } - if game_creator_official_llm_route_locked() { + if game_creator_official_llm_route_locked() && !custom_llm_enabled_at_config_path(path)? { changed |= scrub_locked_game_creator_config_file(&mut config); } if changed { @@ -3542,11 +3648,30 @@ pub(crate) fn migrate_legacy_game_creator_agent_mode(path: &Path) -> Result<(), Ok(()) } -/// Returns whether a real AGC build must use the authenticated API Server -/// proxy instead of any persisted provider credentials. +/// 让文件始终带 `customEnabled` 与 `visibleModels`:自定义连接靠手写这些字段开启, +/// 键缺席时用户无法从文件本身看出开关和模型列表写在哪里。 +pub(crate) fn ensure_game_creator_custom_llm_file_fields( + config: &mut GameCreatorAppConfigFile, +) -> bool { + let Some(llm) = config.llm.as_mut() else { + return false; + }; + let mut changed = false; + if llm.custom_enabled.is_none() { + llm.custom_enabled = Some(false); + changed = true; + } + if llm.visible_models.is_none() { + llm.visible_models = Some(Vec::new()); + changed = true; + } + changed +} + +/// 默认官方路由策略;显式 llm.customEnabled 由调用方优先处理。 /// /// Debug and release binaries intentionally share this decision. The two -/// exceptions are the Rust unit-test build and the explicitly env-gated debug +/// test exceptions are the Rust unit-test build and the explicitly env-gated debug /// deterministic-provider E2E; their loopback fixtures are never compiled into /// or enabled inside a shipped release binary. pub(crate) fn game_creator_official_llm_route_locked() -> bool { @@ -3577,16 +3702,19 @@ pub(crate) fn lock_game_creator_app_config_to_official_route(config: &mut GameCr return; } config.agent_mode = GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string(); + config.agent_llm.clear(); + config.editor_api.api_key.clear(); + if config.llm.custom_enabled { + return; + } config.llm.api_key.clear(); config.llm.base_url = OFFICIAL_LLM_ROUTER_BASE_URL.to_string(); config.llm.model = if config.selected_model_id.is_empty() { - "platform-default".to_string() + OFFICIAL_LLM_ROUTER_DEFAULT_MODEL.to_string() } else { config.selected_model_id.clone() }; config.llm.api_kind = DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(); - config.agent_llm.clear(); - config.editor_api.api_key.clear(); } pub(crate) fn game_creator_app_config_view( @@ -3944,6 +4072,12 @@ pub(crate) fn merge_game_creator_llm_config( config: &mut GameCreatorLlmConfig, patch: GameCreatorLlmConfigFile, ) { + if let Some(value) = patch.custom_enabled { + config.custom_enabled = value; + } + if let Some(value) = patch.visible_models { + config.visible_models = value; + } if let Some(value) = patch.api_key { config.api_key = value; } @@ -3989,6 +4123,12 @@ pub(crate) fn merge_game_creator_llm_patch( config: &mut GameCreatorLlmConfigFile, patch: GameCreatorLlmConfigFile, ) { + if let Some(value) = patch.custom_enabled { + config.custom_enabled = Some(value); + } + if let Some(value) = patch.visible_models { + config.visible_models = Some(value); + } if let Some(value) = patch.api_key { config.api_key = Some(value); } @@ -4073,6 +4213,145 @@ pub(crate) fn trim_config_string(value: &str) -> Option { } } +fn custom_llm_enabled_at_config_path(path: &Path) -> Result { + let parent = path.parent().ok_or("客户端配置缺少父目录")?; + let mut enabled = false; + for name in [ + GAME_CREATOR_CONFIG_FILE_NAME, + GAME_CREATOR_LOCAL_CONFIG_FILE_NAME, + ] { + if let Some(content) = read_game_creator_config_file(&parent.join(name))? { + let file: GameCreatorAppConfigFile = + serde_json::from_str(&content).map_err(|_| "解析客户端配置失败".to_string())?; + if let Some(value) = file.llm.and_then(|llm| llm.custom_enabled) { + enabled = value; + } + } + } + Ok(enabled) +} + +pub(crate) fn validate_custom_llm_connection( + llm: &GameCreatorLlmConfig, +) -> Result { + if llm.api_key.trim().is_empty() { + return Err("请填写自定义 LLM API Key".to_string()); + } + let url = + url::Url::parse(llm.base_url.trim()).map_err(|_| "自定义 LLM API 地址无效".to_string())?; + if !matches!(url.scheme(), "http" | "https") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err("请填写不含凭据、查询参数和片段的 HTTP(S) API 根地址".to_string()); + } + Ok(url) +} + +pub(crate) fn normalize_custom_llm_model_ids(ids: &[String]) -> Result, String> { + if ids.len() > 4096 { + return Err("模型列表超过 4096 项上限".to_string()); + } + let mut result = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for id in ids { + let id = id.trim(); + if id.is_empty() + || id.len() > 256 + || id.chars().any(|c| c.is_control() || c.is_whitespace()) + { + return Err("模型标识为空、包含空白或超过 256 字节".to_string()); + } + if seen.insert(id.to_string()) { + result.push(id.to_string()); + } + } + Ok(result) +} + +fn apply_custom_llm_model_selection(config: &mut GameCreatorAppConfig) { + if !config.llm.custom_enabled { + return; + } + if config.selected_model_is_default + || !config + .llm + .visible_models + .contains(&config.selected_model_id) + { + config.selected_model_id = config + .llm + .visible_models + .first() + .cloned() + .unwrap_or_default(); + config.selected_model_is_default = true; + } + config.llm.model = config.selected_model_id.clone(); +} + +pub(crate) async fn fetch_custom_llm_models( + llm: &GameCreatorLlmConfig, +) -> Result, String> { + let mut url = validate_custom_llm_connection(llm)?; + url.set_path(&format!("{}/models", url.path().trim_end_matches('/'))); + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|_| "初始化模型列表请求失败".to_string())?; + let mut response = client + .get(url) + .bearer_auth(llm.api_key.trim()) + .send() + .await + .map_err(|error| { + if error.is_timeout() { + "模型列表请求超时,请重试".to_string() + } else { + "无法连接模型端点,请检查 API 地址和网络".to_string() + } + })?; + if !response.status().is_success() { + return Err(format!( + "模型列表读取失败(HTTP {})", + response.status().as_u16() + )); + } + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| "读取模型列表响应失败或超时".to_string())? + { + if body.len() + chunk.len() > 1024 * 1024 { + return Err("模型列表响应超过 1 MiB 上限".to_string()); + } + body.extend_from_slice(&chunk); + } + #[derive(Deserialize)] + struct Model { + id: String, + } + #[derive(Deserialize)] + struct Models { + data: Vec, + } + let models: Models = serde_json::from_slice(&body) + .map_err(|_| "模型端点需返回 OpenAI 兼容的 data[].id 列表".to_string())?; + let ids = models + .data + .into_iter() + .map(|model| model.id) + .collect::>(); + let mut ids = normalize_custom_llm_model_ids(&ids)?; + ids.sort(); + Ok(ids) +} + pub(crate) fn normalize_game_creator_app_config( mut config: GameCreatorAppConfig, ) -> Result { @@ -4086,6 +4365,17 @@ pub(crate) fn normalize_game_creator_app_config( lock_game_creator_app_config_to_official_route(&mut config); } config.agent_mode = normalize_game_creator_agent_mode(&config.agent_mode)?; + if config.llm.custom_enabled { + validate_custom_llm_connection(&config.llm)?; + config.llm.visible_models = normalize_custom_llm_model_ids(&config.llm.visible_models)?; + if config.llm.visible_models.is_empty() { + return Err("请至少勾选一个要显示的模型".to_string()); + } + if config.llm.api_kind != "openai_responses" { + return Err("自定义 LLM 需要支持 OpenAI Responses 协议".to_string()); + } + apply_custom_llm_model_selection(&mut config); + } config.llm.api_key = config.llm.api_key.trim().to_string(); config.llm.base_url = trim_config_string(&config.llm.base_url).ok_or_else(|| llm_base_url_config_error("llm"))?; @@ -4182,7 +4472,9 @@ pub(crate) fn normalize_game_creator_llm_patch_config( } pub(crate) fn is_empty_game_creator_llm_patch(patch: &GameCreatorLlmConfigFile) -> bool { - patch.api_key.is_none() + patch.custom_enabled.is_none() + && patch.visible_models.is_none() + && patch.api_key.is_none() && patch.base_url.is_none() && patch.model.is_none() && patch.api_kind.is_none() @@ -4342,6 +4634,188 @@ mod private_file_write_tests { } } +#[cfg(test)] +mod custom_llm_tests { + use super::*; + use std::io::{Read, Write}; + + fn custom_llm() -> GameCreatorLlmConfig { + GameCreatorLlmConfig { + custom_enabled: true, + api_key: "custom-fixture-key".into(), + base_url: "https://provider.example/v1".into(), + model: "vendor/model.v1:latest".into(), + visible_models: vec!["vendor/model.v1:latest".into(), "second.model".into()], + ..GameCreatorLlmConfig::default() + } + } + + #[test] + fn custom_llm_defaults_closed_and_explicit_config_survives_scrub() { + assert!(!GameCreatorLlmConfig::default().custom_enabled); + let mut file: GameCreatorAppConfigFile = serde_json::from_value(serde_json::json!({ + "llm": {"customEnabled": true, "apiKey": "fixture", "baseUrl": "https://custom.example/v1", "visibleModels": ["vendor/a.v1"]} + })).unwrap(); + assert!(!scrub_locked_game_creator_config_file(&mut file)); + let mut config = GameCreatorAppConfig::default(); + merge_game_creator_llm_config(&mut config.llm, file.llm.unwrap()); + assert!(config.llm.custom_enabled); + assert_eq!(config.llm.api_key, "fixture"); + assert_eq!(config.llm.visible_models, ["vendor/a.v1"]); + } + + #[test] + fn custom_llm_selection_is_allowlisted_and_removed_model_falls_back() { + let mut config = GameCreatorAppConfig { + llm: custom_llm(), + selected_model_id: "second.model".into(), + ..GameCreatorAppConfig::default() + }; + apply_custom_llm_model_selection(&mut config); + assert_eq!(config.llm.model, "second.model"); + config.llm.visible_models.pop(); + let normalized = normalize_game_creator_app_config(config).unwrap(); + assert_eq!(normalized.llm.model, "vendor/model.v1:latest"); + assert_eq!(normalized.selected_model_id, normalized.llm.model); + assert!(normalized.selected_model_is_default); + assert!(game_creator_codex_app_server_llm_route_error( + "codex_app_server", + &normalized.llm, + "llm" + ) + .is_none()); + } + + #[test] + fn custom_llm_missing_connection_or_models_is_rejected_without_official_fallback() { + for field in ["key", "models", "url"] { + let mut config = GameCreatorAppConfig { + llm: custom_llm(), + ..GameCreatorAppConfig::default() + }; + match field { + "key" => config.llm.api_key.clear(), + "models" => config.llm.visible_models.clear(), + _ => config.llm.base_url = "file:///private".into(), + } + assert!( + normalize_game_creator_app_config(config).is_err(), + "{field}" + ); + } + let mut config = custom_llm(); + config.api_key.clear(); + assert!(build_game_creator_platform_llm_config(&config, "llm").is_err()); + } + + #[test] + fn custom_llm_migration_uses_merged_overlay_switch() { + let root = tempfile::tempdir().unwrap(); + let primary = root.path().join(GAME_CREATOR_CONFIG_FILE_NAME); + let overlay = root.path().join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME); + std::fs::write( + &primary, + r#"{"llm":{"customEnabled":false,"apiKey":"fixture"}}"#, + ) + .unwrap(); + std::fs::write(&overlay, r#"{"llm":{"customEnabled":true}}"#).unwrap(); + assert!(custom_llm_enabled_at_config_path(&primary).unwrap()); + std::fs::write(&overlay, r#"{"llm":{"customEnabled":false}}"#).unwrap(); + assert!(!custom_llm_enabled_at_config_path(&primary).unwrap()); + } + + fn model_server(status: &str, body: &str) -> (String, std::thread::JoinHandle) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/v1", listener.local_addr().unwrap()); + let response = format!("HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()); + let handle = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + let mut request = Vec::new(); + let mut buf = [0; 1024]; + while !request.windows(4).any(|part| part == b"\r\n\r\n") { + let count = socket.read(&mut buf).unwrap(); + if count == 0 { + break; + } + request.extend_from_slice(&buf[..count]); + } + let _ = socket.write_all(response.as_bytes()); + String::from_utf8(request).unwrap() + }); + (url, handle) + } + + #[tokio::test] + async fn custom_llm_discovers_models_directly_with_custom_bearer_and_deduplicates() { + let (url, server) = model_server( + "200 OK", + r#"{"data":[{"id":"vendor/model.v1:latest"},{"id":"second.model"},{"id":"second.model"}]}"#, + ); + let mut llm = custom_llm(); + llm.base_url = url; + assert_eq!( + fetch_custom_llm_models(&llm).await.unwrap(), + ["second.model", "vendor/model.v1:latest"] + ); + let request = server.join().unwrap(); + assert!(request.starts_with("GET /v1/models HTTP/1.1")); + assert!(request + .to_ascii_lowercase() + .contains("authorization: bearer custom-fixture-key")); + assert!(!request.contains("/api/llm")); + } + + #[tokio::test] + async fn custom_llm_discovery_reports_safe_errors_and_bounds_response() { + for (status, body, expected) in [ + ( + "401 Unauthorized", + "private-upstream-secret".to_string(), + "HTTP 401", + ), + ( + "302 Found", + "private-upstream-secret".to_string(), + "HTTP 302", + ), + ("200 OK", "not-json-private-secret".to_string(), "data[].id"), + ("200 OK", "x".repeat(1024 * 1024 + 1), "1 MiB"), + ] { + let (url, server) = model_server(status, &body); + let mut llm = custom_llm(); + llm.base_url = url; + let error = fetch_custom_llm_models(&llm).await.unwrap_err(); + assert!(error.contains(expected), "{error}"); + assert!(!error.contains("secret")); + server.join().unwrap(); + } + } + + #[tokio::test] + async fn custom_llm_discovery_times_out_when_response_body_stalls() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let mut llm = custom_llm(); + llm.base_url = format!("http://{}/v1", listener.local_addr().unwrap()); + let server = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + let mut request = [0; 4096]; + socket.read(&mut request).unwrap(); + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n") + .unwrap(); + std::thread::sleep(std::time::Duration::from_secs(16)); + }); + let start = std::time::Instant::now(); + let error = fetch_custom_llm_models(&llm).await.unwrap_err(); + assert!(error.contains("超时"), "{error}"); + assert!(start.elapsed() < std::time::Duration::from_secs(16)); + server.join().unwrap(); + } +} + #[cfg(test)] mod private_path_elevation_policy_tests { use super::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs index ba2949e8b..4c5cf1444 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs @@ -4,10 +4,10 @@ //! 目前由宿主在编译期链接(Cargo path 依赖),再按插件 manifest 的 `adapter` //! 字段注册到通用插件宿主。宿主只认适配器 id,不包含目标编辑器知识。 -#[cfg(feature = "cocos-editor")] +#[cfg(all(windows, feature = "cocos-editor-execute"))] use std::path::PathBuf; -#[cfg(feature = "cocos-editor")] +#[cfg(all(windows, feature = "cocos-editor-execute"))] use tauri::Manager; use crate::plugin_host::PluginHost; @@ -21,20 +21,20 @@ pub(crate) fn register_linked_editor_adapters( app: &tauri::AppHandle, host: &PluginHost, ) -> Result<(), String> { - #[cfg(feature = "cocos-editor")] + #[cfg(all(windows, feature = "cocos-editor-execute"))] { let adapter = cocos_editor_bridge::CocosEditorAdapter::new(cocos_bridge_payload_candidates(app)); host.register_editor_adapter(Box::new(adapter))?; } - #[cfg(not(feature = "cocos-editor"))] + #[cfg(not(all(windows, feature = "cocos-editor-execute")))] { let _ = (app, host); } Ok(()) } -#[cfg(feature = "cocos-editor")] +#[cfg(all(windows, feature = "cocos-editor-execute"))] fn cocos_bridge_payload_candidates(app: &tauri::AppHandle) -> Vec { let mut candidates = Vec::new(); if let Ok(resource_dir) = app.path().resource_dir() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index bec55a981..811456b96 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -12,6 +12,8 @@ use std::sync::{mpsc, Arc, Mutex, OnceLock}; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +// crate 根的 trait 导入会被 `use super::*` 的子模块继承(template_library 的流式下载依赖 +// `StreamExt`,通知与 Agent 事件依赖 `Emitter`),不要因为根模块自身不再直接用到就删掉。 use futures::StreamExt; use platform_agent::{ build_game_creation_seed_task_graph, plan_game_creation_agent_pass, @@ -45,189 +47,16 @@ use shared_contracts::game_creation_app::{ GAME_CREATION_AGENT_TOOL_CALL_MAX, GAME_CREATION_APP_COMMANDS, GAME_CREATION_APP_LIMITED_RUN_COMMANDS, GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION, }; +// `Emitter` 同时被 `use super::*` 的子模块依赖(通知、Agent 事件等都从 crate 根取该 trait), +// 不要因为根模块自身不再直接 `.emit(..)` 就删掉它。 use tauri::{Emitter, Manager}; use tauri_plugin_dialog::DialogExt; use tauri_plugin_opener::OpenerExt; -const AGC_UPDATE_OSS_HOST: &str = "agc-dev.oss-rg-china-mainland.aliyuncs.com"; -const AGC_UPDATE_MAX_DOWNLOAD_BYTES: u64 = 512 * 1024 * 1024; -const AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT: &str = "agc-update-download-progress"; - -fn build_agc_update_download_client() -> reqwest::Client { - reqwest::Client::new() -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct AgcUpdateDownloadProgress { - downloaded_bytes: u64, - total_bytes: Option, -} - -#[cfg(windows)] -fn launch_agc_installer(path: &Path, relaunch_path: &Path) -> Result<(), String> { - use std::os::windows::process::CommandExt; - - let executable = path.to_string_lossy().replace('\'', "''"); - let relaunch_executable = relaunch_path.to_string_lossy().replace('\'', "''"); - let script = format!( - "$ErrorActionPreference = 'Stop'; $installer = Start-Process -Verb RunAs -Wait -PassThru -FilePath '{executable}' -ArgumentList @('/S'); if ($installer.ExitCode -eq 0 -and (Test-Path -LiteralPath '{relaunch_executable}')) {{ Start-Process -FilePath '{relaunch_executable}' }}; exit $installer.ExitCode" - ); - Command::new("powershell.exe") - .args([ - "-NoProfile", - "-NonInteractive", - "-WindowStyle", - "Hidden", - "-Command", - script.as_str(), - ]) - .creation_flags(0x0800_0000) - .spawn() - .map(|_| ()) - .map_err(|error| format!("无法启动更新安装程序:{error}")) -} - -#[cfg(not(windows))] -fn launch_agc_installer(path: &Path, _relaunch_path: &Path) -> Result<(), String> { - Command::new(path) - .arg("/S") - .spawn() - .map(|_| ()) - .map_err(|error| format!("无法启动更新安装程序:{error}")) -} - +/// 更新完成后的进程重启:Windows 由 NSIS 安装程序代为重启,macOS / Linux 由客户端在安装后调用。 #[tauri::command] -async fn download_agc_update( - app: tauri::AppHandle, - download_url: String, - expected_sha256: Option, - expected_size: Option, -) -> Result { - let parsed = - url::Url::parse(download_url.trim()).map_err(|_| "更新下载地址无效".to_string())?; - if parsed.scheme() != "https" || parsed.host_str() != Some(AGC_UPDATE_OSS_HOST) { - return Err("更新下载地址必须来自受信任的 OSS".to_string()); - } - let encoded_filename = parsed - .path_segments() - .and_then(|segments| segments.last()) - .filter(|value| !value.is_empty()) - .ok_or_else(|| "更新下载地址缺少文件名".to_string())? - .to_string(); - let filename = percent_encoding::percent_decode_str(&encoded_filename) - .decode_utf8() - .map_err(|_| "更新文件名无效".to_string())? - .into_owned(); - if filename.contains('/') || filename.contains('\\') || filename.contains("..") { - return Err("更新文件名无效".to_string()); - } - if filename.is_empty() || filename.len() > 128 { - return Err("更新文件名无效".to_string()); - } - let response = build_agc_update_download_client() - .get(parsed) - .send() - .await - .map_err(|_| "下载更新失败".to_string())?; - if !response.status().is_success() { - return Err("下载更新失败".to_string()); - } - if response - .content_length() - .is_some_and(|length| length > AGC_UPDATE_MAX_DOWNLOAD_BYTES) - { - return Err("更新文件超过大小限制".to_string()); - } - let download_dir = app - .path() - .temp_dir() - .map_err(|_| "无法定位临时目录".to_string())? - .join("genarrative-agc-update"); - fs::create_dir_all(&download_dir).map_err(|_| "无法创建临时目录".to_string())?; - let target = download_dir.join(&filename); - let temporary = download_dir.join(format!( - "{}.{}.download", - filename, - uuid::Uuid::new_v4().simple() - )); - let mut file = File::create(&temporary).map_err(|_| "保存更新文件失败".to_string())?; - let mut hasher = sha2::Sha256::new(); - let total_bytes = response.content_length(); - let mut downloaded_bytes = 0_u64; - let _ = app.emit( - AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT, - AgcUpdateDownloadProgress { - downloaded_bytes, - total_bytes, - }, - ); - let mut stream = response.bytes_stream(); - while let Some(chunk_result) = stream.next().await { - let chunk = match chunk_result { - Ok(chunk) => chunk, - Err(_) => { - let _ = fs::remove_file(&temporary); - return Err("读取更新文件失败".to_string()); - } - }; - downloaded_bytes = match downloaded_bytes.checked_add(chunk.len() as u64) { - Some(value) if value <= AGC_UPDATE_MAX_DOWNLOAD_BYTES => value, - _ => { - let _ = fs::remove_file(&temporary); - return Err("更新文件超过大小限制".to_string()); - } - }; - hasher.update(&chunk); - if file.write_all(&chunk).is_err() { - let _ = fs::remove_file(&temporary); - return Err("保存更新文件失败".to_string()); - } - let _ = app.emit( - AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT, - AgcUpdateDownloadProgress { - downloaded_bytes, - total_bytes, - }, - ); - } - if file.flush().is_err() { - let _ = fs::remove_file(&temporary); - return Err("保存更新文件失败".to_string()); - } - drop(file); - if let Some(expected_size) = expected_size { - if downloaded_bytes != expected_size { - let _ = fs::remove_file(&temporary); - return Err("更新文件大小校验失败".to_string()); - } - } - if let Some(expected_sha256) = expected_sha256 { - let expected_sha256 = expected_sha256.trim().to_ascii_lowercase(); - if !expected_sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) - || expected_sha256.len() != 64 - { - let _ = fs::remove_file(&temporary); - return Err("更新文件摘要无效".to_string()); - } - let actual = format!("{:x}", hasher.finalize()); - if actual != expected_sha256 { - let _ = fs::remove_file(&temporary); - return Err("更新文件完整性校验失败".to_string()); - } - } - if target.exists() { - let _ = fs::remove_file(&target); - } - if let Err(error) = fs::rename(&temporary, &target) { - let _ = fs::remove_file(&temporary); - return Err(format!("提交更新文件失败:{error}")); - } - let relaunch_path = - std::env::current_exe().map_err(|error| format!("无法定位客户端程序:{error}"))?; - launch_agc_installer(&target, &relaunch_path)?; - app.exit(0); - Ok(target.to_string_lossy().into_owned()) +fn restart_agc_app(app: tauri::AppHandle) { + app.restart(); } /// Rust 侧普通文本日志:保留 stderr 输出,同时将同一行持久化到 AppData。 @@ -277,6 +106,7 @@ mod preview; mod process_session; mod process_session_bridge; mod project; +mod project_snapshot; mod provider_handoff; mod provider_retry; mod repository_context; @@ -284,6 +114,7 @@ mod resource_inspect; mod resource_preview_scheduler; mod runner; mod swarm_cli; +mod template_library; mod tool_plan_handoff; mod user_input; mod windows; @@ -318,11 +149,13 @@ use plugin_host::{ use preview::*; use process_session::*; use project::*; +use project_snapshot::*; use repository_context::*; use resource_inspect::*; use resource_preview_scheduler::*; use runner::*; use swarm_cli::*; +use template_library::*; use user_input::*; use windows::*; #[tauri::command] @@ -1084,6 +917,10 @@ struct GameCreatorAppConfigFile { #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorLlmConfigFile { + #[serde(skip_serializing_if = "Option::is_none")] + custom_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + visible_models: Option>, #[serde(skip_serializing_if = "Option::is_none")] api_key: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -1139,6 +976,10 @@ struct GameCreatorAppConfig { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorLlmConfig { + #[serde(default)] + custom_enabled: bool, + #[serde(default)] + visible_models: Vec, api_key: String, base_url: String, model: String, @@ -1271,6 +1112,9 @@ struct GeneratedPlatformArtAssetSlice { struct GeneratedPlatformArtAsset { asset: UploadLocalAssetResult, slices: Vec, + slice_mode: Option, + grid_x: Option, + grid_y: Option, resource_id: Option, asset_object_id: Option, task_id: Option, @@ -1553,7 +1397,7 @@ const DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT: &str = "high"; const DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS: u64 = 128_000; const DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT: u64 = 64_000; const DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT: u64 = 12_000; -const DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES: u32 = 2; +const DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES: u32 = 10; fn default_game_creator_agent_mode() -> String { GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string() @@ -1655,6 +1499,8 @@ impl Default for GameCreatorAppConfig { impl Default for GameCreatorLlmConfig { fn default() -> Self { Self { + custom_enabled: false, + visible_models: Vec::new(), api_key: String::new(), base_url: DEFAULT_GAME_CREATOR_LLM_BASE_URL.to_string(), model: DEFAULT_GAME_CREATOR_LLM_MODEL.to_string(), @@ -2209,6 +2055,8 @@ fn game_creator_gui_run_event_requests_runner_shutdown(event: &tauri::RunEvent) enum GameCreatorGuiRunnerShutdownOutcome { NotRequested, Requested, + /// 仍有其它界面窗口持有参与锁,Runner 必须保留给它们。 + Retained, Failed(GameCreatorGuiRunnerShutdownFailure), } @@ -2246,7 +2094,8 @@ fn classify_game_creator_gui_runner_shutdown_error( GameCreatorGuiRunnerShutdownFailure::ProcessIdentity } else if error.contains("当前平台不支持") || error.contains("macOS 不提供") { GameCreatorGuiRunnerShutdownFailure::PlatformUnsupported - } else if error.contains("实例锁") || error.contains("owner 锁") { + } else if error.contains("实例锁") || error.contains("参与锁") || error.contains("owner 锁") + { GameCreatorGuiRunnerShutdownFailure::LockTimeout } else if error.contains("endpoint") { GameCreatorGuiRunnerShutdownFailure::EndpointUnavailable @@ -2262,13 +2111,14 @@ fn resolve_game_creator_gui_runner_shutdown( shutdown: F, ) -> GameCreatorGuiRunnerShutdownOutcome where - F: FnOnce() -> Result<(), String>, + F: FnOnce() -> Result, { if !game_creator_gui_run_event_requests_runner_shutdown(event) { return GameCreatorGuiRunnerShutdownOutcome::NotRequested; } match shutdown() { - Ok(()) => GameCreatorGuiRunnerShutdownOutcome::Requested, + Ok(true) => GameCreatorGuiRunnerShutdownOutcome::Requested, + Ok(false) => GameCreatorGuiRunnerShutdownOutcome::Retained, Err(error) => GameCreatorGuiRunnerShutdownOutcome::Failed( classify_game_creator_gui_runner_shutdown_error(&error), ), @@ -2277,6 +2127,9 @@ where fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) { if matches!(event, tauri::RunEvent::Exit) { + // 窗口关闭时已经按项目触发过一次快照同步;退出路径只负责在有界预算内 + // 等在途同步收尾,不重复发起(此刻窗口已销毁,重新枚举项目只会是空集)。 + wait_for_project_snapshot_syncs_on_exit(); // 退出时统一收尾本地预览:进程内监听线程随进程消失,但 `.agent/manifest.json` // 里的 preview 记录会留在 running 上,下次进项目就照着它渲染打不开的运行界面。 if let Err(error) = @@ -2288,11 +2141,17 @@ fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) { app_log!("agent.direct_codex.gui_exit.shutdown_failed: {error}"); } } - match resolve_game_creator_gui_runner_shutdown(event, shutdown_external_agent_runner) { + match resolve_game_creator_gui_runner_shutdown( + event, + shutdown_external_agent_runner_for_gui_exit, + ) { GameCreatorGuiRunnerShutdownOutcome::NotRequested => {} GameCreatorGuiRunnerShutdownOutcome::Requested => { app_log!("agent.runner.gui_exit.shutdown_requested") } + GameCreatorGuiRunnerShutdownOutcome::Retained => { + app_log!("agent.runner.gui_exit.retained_for_other_windows") + } GameCreatorGuiRunnerShutdownOutcome::Failed(failure) => { app_log!("agent.runner.gui_exit.shutdown_failed.{}", failure.code()) } @@ -2534,7 +2393,9 @@ fn main() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_clipboard_manager::init()) + .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(context_menu::init()) + .on_window_event(|window, event| handle_project_snapshot_window_event(window, event)) .manage(game_creator_preview_registry()) .manage(ProjectResourcePreviewReadManager::default()) .manage(PluginHost::default()) @@ -2563,6 +2424,7 @@ fn main() { setup_log.fail("startup.appdata.resolve.failed details=config-dir-uninitialized"); error })?; + spawn_project_snapshot_scheduler(app.handle().clone()); if let Err(error) = builtin_plugins::initialize(&config_dir) { app_log!("startup.builtin-plugins.initialize.failed: {error}"); } @@ -2601,27 +2463,25 @@ fn main() { ) })?; setup_log.append("startup.runner.configure.complete"); - let gui_owner_lock = acquire_external_agent_runner_gui_owner_lock(&config_dir) + hold_external_agent_runner_gui_participant_lock(&config_dir) .inspect_err(|error| { let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); setup_log.fail(&format!( - "startup.runner.owner-lock.failed details={details}" + "startup.runner.participant-lock.failed details={details}" )); }) .map_err(|error| { std::io::Error::new( std::io::ErrorKind::AlreadyExists, - format!("获取 GUI owner 锁失败:{error}"), + format!("建立 AGC 界面参与锁失败:{error}"), ) })?; - let gui_owner_epoch = gui_owner_lock.owner_epoch().to_string(); - app.manage(gui_owner_lock); setup_log.append("startup.runner.start.begin"); set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); set_direct_thread_manager_app_handle(app.handle().clone()); let manifest_event_sink = start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?; - attach_external_agent_runner_gui_owner(&manifest_event_sink, &gui_owner_epoch) + attach_external_agent_runner_gui_owner(&manifest_event_sink) .inspect_err(|error| { let details = sanitize_diagnostic_message(error, Some(config_dir.as_path())); setup_log.fail(&format!( @@ -2642,7 +2502,10 @@ fn main() { start_game_creator_external_mcp, stop_game_creator_external_mcp, create_automatic_local_game_project, + create_automatic_local_game_project_from_template, init_local_game_project, + fetch_game_template_library, + download_game_template, import_local_godot_project, import_local_cocos_project, is_local_project_directory_non_empty, @@ -2715,16 +2578,18 @@ fn main() { confirm_resume_game_creator_agent_runtime_tasks, schedule_game_creator_agent_ready_tasks, check_game_creator_llm_config, - read_platform_account_session_generation, + read_platform_account_session_state, install_platform_account_session, clear_platform_account_session, read_game_creator_app_config, write_game_creator_app_config, select_game_creator_model, + discover_game_creator_llm_models, upload_local_asset, register_local_asset, create_ui_design_resource, update_local_project_resource_classification, + add_local_project_resource_tags, derive_local_project_resource, list_pending_local_project_resource_edits, resume_local_project_resource_edit, @@ -2766,6 +2631,7 @@ fn main() { read_local_project_image_preview, save_local_project_asset_file, read_local_project_text_preview, + read_local_project_structured_preview, read_local_project_media_preview, cancel_local_project_resource_preview_scope, write_local_project_file, @@ -2782,8 +2648,6 @@ fn main() { archive_game_creator_agent_session, read_local_conversation, read_direct_project_conversation, - read_direct_tool_calls, - read_direct_turn_stream, read_agent_runtime_error_detail, list_game_creator_direct_active_turns, subscribe_direct_project_thread, @@ -2817,12 +2681,14 @@ fn main() { replace_local_project_version_resource, get_local_game_project_revision, get_local_game_manifest, - download_agc_update, + restart_agc_app, append_application_log, read_diagnostic_logs, report_client_error, get_pending_error_reports, ack_error_reports, + sync_local_project_snapshot, + read_local_project_snapshot_state, ]) .build(tauri_context); let app = match app { @@ -2989,50 +2855,6 @@ mod diagnostic_log_tests { } } -#[cfg(test)] -mod update_client_tests { - use super::*; - use std::io::{Read, Write}; - - #[tokio::test] - async fn update_download_client_omits_agc_marker() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind update fixture"); - let address = listener.local_addr().expect("update fixture address"); - let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept update request"); - stream - .set_read_timeout(Some(std::time::Duration::from_secs(2))) - .expect("set update fixture timeout"); - let mut bytes = Vec::new(); - let mut buffer = [0_u8; 1024]; - while !bytes.windows(4).any(|window| window == b"\r\n\r\n") { - let read = stream.read(&mut buffer).expect("read update request"); - assert!(read > 0, "update request closed before headers"); - bytes.extend_from_slice(&buffer[..read]); - } - stream - .write_all( - b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - ) - .expect("write update response"); - String::from_utf8_lossy(&bytes).into_owned() - }); - - let client = build_agc_update_download_client(); - let response = client - .get(format!("http://{address}/update.exe")) - .send() - .await - .expect("send update request"); - let request = server.join().expect("join update fixture"); - - assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT); - assert!(!request - .to_ascii_lowercase() - .contains("x-genarrative-client:")); - } -} - #[cfg(test)] mod tests; pub mod ui_editor; diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs index 0e24b3b53..41fe89783 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs @@ -1,5 +1,4 @@ use serde::Deserialize; -use sha2::{Digest, Sha256}; use std::fs::{self, OpenOptions}; use std::io::Read; use std::path::{Path, PathBuf}; @@ -12,12 +11,41 @@ pub(crate) const PLATFORM_SESSION_FIXTURE_ENV: &str = "GENARRATIVE_AGC_PLATFORM_ const PLATFORM_SESSION_FIXTURE_SCHEMA_VERSION: &str = "genarrative-agc-platform-session-fixture.v1"; const PLATFORM_SESSION_FIXTURE_MAX_BYTES: u64 = 16 * 1024; +/// 平台会话快照 = 身份(登录主体 + 服务 origin)+ 凭据(当前 access token)。 +/// +/// `identity_generation` 只在登录主体、服务 origin 或登出状态变化时推进;同一身份的 +/// access token 轮换(长回合保活、401 续期、同账号重新登录)必须保持它不变。 +/// `revision` 只用于 native 写入顺序判定,防止迟到 install / clear 复活旧状态, +/// 不表达身份归属。 #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct PlatformSessionSnapshot { pub(crate) user_id: String, pub(crate) access_token: String, pub(crate) api_base_url: String, - pub(crate) generation: u64, + pub(crate) identity_generation: u64, + pub(crate) revision: u64, +} + +/// 冻结会话的身份判据。 +/// +/// 只包含登录主体、服务 origin 和身份代次,不包含 token 字节:同一身份的凭据轮换 +/// 不得让在途生成、编辑、上传、确认或下载 operation 失效;换号、退出或 origin +/// 变化必须让它失配。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PlatformSessionIdentity { + pub(crate) user_id: String, + pub(crate) api_base_url: String, + pub(crate) identity_generation: u64, +} + +impl PlatformSessionSnapshot { + pub(crate) fn identity(&self) -> PlatformSessionIdentity { + PlatformSessionIdentity { + user_id: self.user_id.clone(), + api_base_url: self.api_base_url.clone(), + identity_generation: self.identity_generation, + } + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -231,26 +259,31 @@ pub(crate) fn load_platform_session_fixture_from_env_for_build( let fixture_path = validate_fixture_path(config_dir, Path::new(raw_path))?; let bytes = read_fixture_file(&fixture_path)?; let fixture = parse_platform_session_fixture(&bytes)?; + // fixture 的 generation 同时充当身份代次与写入 revision:一个 fixture 只表达 + // “从零安装一次确定的会话”,不表达同一身份的凭据续期。 let snapshot = validated_platform_session_snapshot( &fixture.user_id, &fixture.access_token, &fixture.api_base_url, fixture.generation, + fixture.generation, )?; let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - // A fresh CLI/Runner normally starts at generation zero. Replacing the + // A fresh CLI/Runner normally starts at revision zero. Replacing the // state here also makes a Debug GUI fixture deterministic without relaxing - // the normal account-switch generation rules. - current.generation = snapshot.generation; + // the normal account-switch rules. + current.revision = snapshot.revision; + current.identity_generation = snapshot.identity_generation; current.snapshot = Some(snapshot); Ok(()) } #[derive(Default)] struct PlatformSessionState { - generation: u64, + revision: u64, + identity_generation: u64, snapshot: Option, } @@ -265,37 +298,60 @@ fn install_platform_session_in( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) { - if generation < current.generation { + if revision < current.revision { return; } - if generation == current.generation { + if revision == current.revision { if current.snapshot.as_ref().is_some_and(|snapshot| { snapshot.user_id == user_id && snapshot.access_token == access_token && snapshot.api_base_url == api_base_url + && snapshot.identity_generation == identity_generation }) { return; } - // Equal-generation retries may only repeat the exact committed snapshot. In - // particular, a late install cannot revive a generation that was cleared. + // 同一 revision 只允许逐字段重复已提交的会话。尤其地:迟到写入不能复活已清除的 + // 会话,也不能在同一个 revision 上偷偷换掉主体或 token。 return; } - current.generation = generation; + if identity_generation < current.identity_generation { + return; + } + if current.snapshot.as_ref().is_some_and(|snapshot| { + snapshot.identity_generation == identity_generation + && (snapshot.user_id != user_id || snapshot.api_base_url != api_base_url) + }) { + // 同一个身份代次不允许更换登录主体或服务 origin:换号必须先推进身份代次, + // 否则旧账号的在途 operation 可能拿到新账号的凭据。 + return; + } + current.revision = revision; + current.identity_generation = identity_generation; current.snapshot = Some(PlatformSessionSnapshot { user_id: user_id.to_string(), access_token: access_token.to_string(), api_base_url: api_base_url.to_string(), - generation, + identity_generation, + revision, }); } -fn clear_platform_session_in(current: &mut PlatformSessionState, generation: u64) { - if generation < current.generation { +fn clear_platform_session_in( + current: &mut PlatformSessionState, + identity_generation: u64, + revision: u64, +) { + if revision <= current.revision { return; } - current.generation = generation; + if identity_generation < current.identity_generation { + return; + } + current.revision = revision; + current.identity_generation = identity_generation; current.snapshot = None; } @@ -303,10 +359,16 @@ pub(crate) fn install_platform_session( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { - let snapshot = - validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?; + let snapshot = validated_platform_session_snapshot( + user_id, + access_token, + api_base_url, + identity_generation, + revision, + )?; let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); @@ -315,7 +377,8 @@ pub(crate) fn install_platform_session( &snapshot.user_id, &snapshot.access_token, &snapshot.api_base_url, - snapshot.generation, + snapshot.identity_generation, + snapshot.revision, ); Ok(()) } @@ -324,7 +387,8 @@ fn validated_platform_session_snapshot( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result { if editor_api_mode() == EditorApiMode::ExternalDeveloper { return Err("独立外部开发发行版不接受陶泥儿网站登录态".to_string()); @@ -342,7 +406,8 @@ fn validated_platform_session_snapshot( user_id: user_id.to_string(), access_token: access_token.to_string(), api_base_url, - generation, + identity_generation, + revision, }) } @@ -350,23 +415,43 @@ pub(crate) fn validate_platform_session_input( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { - validated_platform_session_snapshot(user_id, access_token, api_base_url, generation).map(|_| ()) + validated_platform_session_snapshot( + user_id, + access_token, + api_base_url, + identity_generation, + revision, + ) + .map(|_| ()) } pub(crate) fn replace_platform_session_for_gui_owner( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { - let snapshot = - validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?; + let snapshot = validated_platform_session_snapshot( + user_id, + access_token, + api_base_url, + identity_generation, + revision, + )?; let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - current.generation = snapshot.generation; + // 这条路径是 GUI authority epoch 的重定性入口:只有 durable claim 的 epoch + session + // revision 与登记完全一致时才会走到这里,新 epoch 可以替换旧进程留下的任意计数器。 + // 因此按调用方快照重定基准,让原生计数与渲染层认知严格一致;Runner 同 epoch 的幂等 + // 重挂仍走 install_platform_session_checked 的精确相等校验。写入的持续单调性由渲染层 + // reserve(max(本地 + 1, 原生下限 + 1))和 durable session revision 保证。 + current.revision = snapshot.revision; + current.identity_generation = snapshot.identity_generation; current.snapshot = Some(snapshot); Ok(()) } @@ -375,10 +460,16 @@ pub(crate) fn install_platform_session_checked( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { - let snapshot = - validated_platform_session_snapshot(user_id, access_token, api_base_url, generation)?; + let snapshot = validated_platform_session_snapshot( + user_id, + access_token, + api_base_url, + identity_generation, + revision, + )?; let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); @@ -387,12 +478,13 @@ pub(crate) fn install_platform_session_checked( &snapshot.user_id, &snapshot.access_token, &snapshot.api_base_url, - snapshot.generation, + snapshot.identity_generation, + snapshot.revision, ); if current.snapshot.as_ref() == Some(&snapshot) { Ok(()) } else { - Err("authentication-required: 平台登录态 generation 已过期或主体冲突".to_string()) + Err("authentication-required: 平台登录态写入已过期或主体冲突".to_string()) } } @@ -422,30 +514,38 @@ fn normalize_platform_api_base_url(value: &str) -> Result { Ok(value.to_string()) } -pub(crate) fn clear_platform_session(generation: u64) { +pub(crate) fn clear_platform_session(identity_generation: u64, revision: u64) { let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - clear_platform_session_in(&mut current, generation); + clear_platform_session_in(&mut current, identity_generation, revision); } -pub(crate) fn clear_platform_session_for_gui_owner(generation: u64) { +pub(crate) fn clear_platform_session_for_gui_owner(identity_generation: u64, revision: u64) { let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - current.generation = generation; + // 与 replace 同一口径:epoch 交接按调用方快照重定基准,避免原生计数与渲染层认知漂移。 + current.revision = revision; + current.identity_generation = identity_generation; current.snapshot = None; } -pub(crate) fn clear_platform_session_checked(generation: u64) -> Result<(), String> { +pub(crate) fn clear_platform_session_checked( + identity_generation: u64, + revision: u64, +) -> Result<(), String> { let mut current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - clear_platform_session_in(&mut current, generation); - if current.generation == generation && current.snapshot.is_none() { + clear_platform_session_in(&mut current, identity_generation, revision); + if current.revision >= revision + && current.identity_generation >= identity_generation + && current.snapshot.is_none() + { Ok(()) } else { - Err("authentication-required: 平台登出 generation 已过期".to_string()) + Err("authentication-required: 平台登出写入已过期".to_string()) } } @@ -457,17 +557,42 @@ pub(crate) fn current_platform_session() -> Option { .clone() } -pub(crate) fn current_platform_session_generation() -> u64 { - platform_session() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .generation +/// native 写入顺序 revision。渲染层用它作为只增不减的下限,避免新 WebView 的本地计数 +/// 复位后写出比现存会话更旧的 install / clear。 +/// 原生写入下限,供渲染层reserve新的身份代次与 revision。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PlatformSessionWriteState { + pub(crate) identity_generation: u64, + pub(crate) revision: u64, } -pub(crate) fn validate_platform_session_snapshot( +pub(crate) fn current_platform_session_write_state() -> PlatformSessionWriteState { + let current = platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + PlatformSessionWriteState { + identity_generation: current.identity_generation, + revision: current.revision, + } +} + +/// 冻结会话校验:只比较身份,不比较 token 字节。 +pub(crate) fn validate_frozen_platform_session( expected: &PlatformSessionSnapshot, ) -> Result<(), String> { - if platform_session_snapshot_matches(current_platform_session().as_ref(), expected) { + validate_platform_session_identity(&expected.identity()) +} + +pub(crate) fn validate_platform_session_identity( + expected: &PlatformSessionIdentity, +) -> Result<(), String> { + let matches = current_platform_session() + .as_ref() + .map(PlatformSessionSnapshot::identity) + .as_ref() + == Some(expected); + if matches { Ok(()) } else { Err( @@ -477,19 +602,11 @@ pub(crate) fn validate_platform_session_snapshot( } } -pub(crate) fn with_validated_platform_session_fingerprint( - expected_user_id: &str, - expected_api_base_url: &str, - expected_generation: u64, - expected_access_token_sha256: &str, +pub(crate) fn with_validated_platform_session_identity( + expected: &PlatformSessionIdentity, action: impl FnOnce() -> Result, ) -> Result { - let lease = acquire_validated_platform_session_fingerprint( - expected_user_id, - expected_api_base_url, - expected_generation, - expected_access_token_sha256, - )?; + let lease = acquire_platform_session_identity_lease(expected)?; let result = action(); drop(lease); result @@ -499,22 +616,20 @@ pub(crate) struct ValidatedPlatformSessionLease { _guard: std::sync::MutexGuard<'static, PlatformSessionState>, } -pub(crate) fn acquire_validated_platform_session_fingerprint( - expected_user_id: &str, - expected_api_base_url: &str, - expected_generation: u64, - expected_access_token_sha256: &str, +/// 取得身份租约:持锁期间换号 / 退出无法落地,调用方可以安全地用当前凭据完成一次 +/// 本地提交。凭据续期不改变身份,因此不会被这个租约挡住。 +pub(crate) fn acquire_platform_session_identity_lease( + expected: &PlatformSessionIdentity, ) -> Result { let current = platform_session() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - let matches = current.snapshot.as_ref().is_some_and(|snapshot| { - snapshot.user_id == expected_user_id - && snapshot.api_base_url == expected_api_base_url - && snapshot.generation == expected_generation - && format!("{:x}", Sha256::digest(snapshot.access_token.as_bytes())) - == expected_access_token_sha256 - }); + let matches = current + .snapshot + .as_ref() + .map(PlatformSessionSnapshot::identity) + .as_ref() + == Some(expected); if !matches { return Err( "authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试" @@ -524,13 +639,6 @@ pub(crate) fn acquire_validated_platform_session_fingerprint( Ok(ValidatedPlatformSessionLease { _guard: current }) } -fn platform_session_snapshot_matches( - current: Option<&PlatformSessionSnapshot>, - expected: &PlatformSessionSnapshot, -) -> bool { - current == Some(expected) -} - pub(crate) fn platform_session_is_available() -> bool { current_platform_session().is_some() } @@ -581,12 +689,14 @@ pub(crate) fn install_test_platform_session( .unwrap_or_else(|poisoned| poisoned.into_inner()); let previous = std::mem::take(&mut *current); *current = PlatformSessionState { - generation: 1, + revision: 1, + identity_generation: 1, snapshot: Some(PlatformSessionSnapshot { user_id: user_id.to_string(), access_token: access_token.to_string(), api_base_url: api_base_url.to_string(), - generation: 1, + identity_generation: 1, + revision: 1, }), }; drop(current); @@ -621,73 +731,42 @@ pub(crate) fn clear_test_platform_session() -> TestPlatformSessionGuard { mod tests { use super::*; + const TEST_ORIGIN: &str = "https://dev.genarrative.world"; + #[test] - fn cleared_generation_rejects_late_install_and_older_clear() { + fn cleared_revision_rejects_late_install_and_older_clear() { let mut state = PlatformSessionState::default(); + install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 1, 1); + clear_platform_session_in(&mut state, 2, 2); + install_platform_session_in(&mut state, "user-a", "late-token-a", TEST_ORIGIN, 1, 1); install_platform_session_in( &mut state, "user-a", - "token-a", - "https://dev.genarrative.world", - 1, - ); - clear_platform_session_in(&mut state, 2); - install_platform_session_in( - &mut state, - "user-a", - "late-token-a", - "https://dev.genarrative.world", - 1, - ); - install_platform_session_in( - &mut state, - "user-a", - "same-generation-token", - "https://dev.genarrative.world", + "same-revision-token", + TEST_ORIGIN, + 2, 2, ); assert!(state.snapshot.is_none()); - assert_eq!(state.generation, 2); + assert_eq!(state.revision, 2); - install_platform_session_in( - &mut state, - "user-b", - "token-b", - "https://dev.genarrative.world", - 3, - ); - clear_platform_session_in(&mut state, 2); + install_platform_session_in(&mut state, "user-b", "token-b", TEST_ORIGIN, 3, 3); + clear_platform_session_in(&mut state, 2, 2); assert_eq!( state.snapshot.as_ref().map(|value| value.user_id.as_str()), Some("user-b") ); - assert_eq!(state.generation, 3); + assert_eq!(state.revision, 3); + assert_eq!(state.identity_generation, 3); } #[test] - fn equal_generation_only_accepts_the_exact_idempotent_snapshot() { + fn equal_revision_only_accepts_the_exact_idempotent_snapshot() { let mut state = PlatformSessionState::default(); - install_platform_session_in( - &mut state, - "user-a", - "token-a", - "https://dev.genarrative.world", - 4, - ); - install_platform_session_in( - &mut state, - "user-a", - "token-a", - "https://dev.genarrative.world", - 4, - ); - install_platform_session_in( - &mut state, - "user-b", - "token-b", - "https://dev.genarrative.world", - 4, - ); + install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 4, 4); + install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 4, 4); + install_platform_session_in(&mut state, "user-b", "token-b", TEST_ORIGIN, 4, 4); + install_platform_session_in(&mut state, "user-a", "token-b", TEST_ORIGIN, 4, 4); assert_eq!( state.snapshot.as_ref().map(|value| value.user_id.as_str()), Some("user-a") @@ -702,18 +781,115 @@ mod tests { } #[test] - fn current_generation_preserves_the_floor_after_session_clear() { + fn same_identity_credential_refresh_keeps_identity_and_frozen_session() { + let _session = install_test_platform_session("refresh-user", "token-a", TEST_ORIGIN); + let frozen = current_platform_session().expect("frozen platform session"); + let identity = frozen.identity(); + + install_platform_session("refresh-user", "token-b", TEST_ORIGIN, 1, 2) + .expect("refresh credential for the same identity"); + + assert_eq!( + current_platform_session().map(|session| session.access_token), + Some("token-b".to_string()) + ); + assert_eq!( + current_platform_session_write_state().identity_generation, + 1 + ); + validate_frozen_platform_session(&frozen) + .expect("same-identity token rotation must keep the frozen session valid"); + validate_platform_session_identity(&identity) + .expect("same-identity token rotation must keep the identity valid"); + } + + #[test] + fn identity_change_invalidates_frozen_session_and_needs_a_new_identity_generation() { + let _session = install_test_platform_session("identity-user-a", "token-a", TEST_ORIGIN); + let frozen = current_platform_session().expect("frozen platform session"); + install_platform_session("identity-user-a", "token-b", TEST_ORIGIN, 1, 2) + .expect("credential refresh for the same identity"); + + // 同身份代次不允许换主体:否则旧账号在途请求会拿到新账号凭据。 + install_platform_session("identity-user-b", "token-b", TEST_ORIGIN, 1, 3) + .expect("conflicting subject at the same identity generation is ignored"); + assert_eq!( + current_platform_session().map(|session| session.user_id), + Some("identity-user-a".to_string()) + ); + validate_frozen_platform_session(&frozen) + .expect("ignored conflicting write must not disturb the frozen session"); + + install_platform_session("identity-user-b", "token-b", TEST_ORIGIN, 2, 4) + .expect("account switch advances the identity generation"); + assert!(validate_frozen_platform_session(&frozen).is_err()); + assert!(current_platform_session().is_some()); + } + + #[test] + fn gui_owner_replacement_rebases_to_the_authority_and_only_subject_change_fences() { + let _session = clear_test_platform_session(); + replace_platform_session_for_gui_owner("gui-owner-a", "token-a", TEST_ORIGIN, 5, 5) + .expect("install gui owner A"); + let installed = current_platform_session().expect("gui owner A session"); + assert_eq!(installed.identity_generation, 5); + assert_eq!(installed.revision, 5); + + // 同一主体只换凭据(续期后 Runner 重挂走的就是这条 replace 路径):身份代次保持、 + // 写入 revision 前进,在途 operation 的冻结会话仍然有效。 + replace_platform_session_for_gui_owner("gui-owner-a", "token-a2", TEST_ORIGIN, 5, 6) + .expect("refresh gui owner A credential"); + let refreshed = current_platform_session().expect("gui owner A refreshed session"); + assert_eq!(refreshed.identity_generation, 5); + assert_eq!(refreshed.revision, 6); + validate_frozen_platform_session(&installed) + .expect("same-subject credential replacement keeps the frozen session valid"); + + // 换主体必须推进身份代次,旧身份的在途 operation 失败关闭。 + replace_platform_session_for_gui_owner("gui-owner-b", "token-b", TEST_ORIGIN, 6, 7) + .expect("switch gui owner"); + let switched = current_platform_session().expect("gui owner B session"); + assert_eq!(switched.user_id, "gui-owner-b"); + assert_eq!(switched.identity_generation, 6); + assert!(validate_frozen_platform_session(&installed).is_err()); + assert!(validate_frozen_platform_session(&refreshed).is_err()); + + // epoch 交接后的清除同样按调用方快照重定基准,让原生计数与渲染层认知一致。 + clear_platform_session_for_gui_owner(7, 8); + let cleared = current_platform_session_write_state(); + assert_eq!(cleared.revision, 8); + assert_eq!(cleared.identity_generation, 7); + assert!(current_platform_session().is_none()); + } + + #[test] + fn older_identity_generation_cannot_restore_a_replaced_subject() { + let mut state = PlatformSessionState::default(); + install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 5, 5); + install_platform_session_in(&mut state, "user-b", "token-b", TEST_ORIGIN, 6, 6); + install_platform_session_in(&mut state, "user-a", "token-a", TEST_ORIGIN, 5, 7); + assert_eq!( + state.snapshot.as_ref().map(|value| value.user_id.as_str()), + Some("user-b") + ); + } + + #[test] + fn current_revision_preserves_the_floor_after_session_clear() { let _session = clear_test_platform_session(); install_platform_session( - "generation-floor-user", - "generation-floor-token", - "https://dev.genarrative.world", + "revision-floor-user", + "revision-floor-token", + TEST_ORIGIN, + 41, 41, ) - .expect("install session generation floor"); - clear_platform_session(42); + .expect("install session revision floor"); + clear_platform_session(42, 42); - assert_eq!(current_platform_session_generation(), 42); + let state = current_platform_session_write_state(); + assert_eq!(state.revision, 42); + assert_eq!(state.identity_generation, 42); assert!(current_platform_session().is_none()); } @@ -752,64 +928,40 @@ mod tests { } #[test] - fn frozen_platform_session_rejects_logout_account_switch_and_token_rotation() { - let expected = PlatformSessionSnapshot { - user_id: "user-a".to_string(), - access_token: "token-a".to_string(), - api_base_url: "https://dev.genarrative.world".to_string(), - generation: 4, - }; - assert!(platform_session_snapshot_matches( - Some(&expected), - &expected - )); + fn frozen_platform_session_rejects_logout_and_account_switch_but_allows_token_rotation() { + let _session = install_test_platform_session("frozen-user-a", "token-a", TEST_ORIGIN); + let identity = current_platform_session() + .expect("frozen platform session") + .identity(); + validate_platform_session_identity(&identity).expect("matching identity is valid"); - for current in [ - None, - Some(PlatformSessionSnapshot { - user_id: "user-b".to_string(), - ..expected.clone() - }), - Some(PlatformSessionSnapshot { - access_token: "token-b".to_string(), - generation: 5, - ..expected.clone() - }), - ] { - assert!(!platform_session_snapshot_matches( - current.as_ref(), - &expected - )); - } + install_platform_session("frozen-user-a", "token-b", TEST_ORIGIN, 1, 2) + .expect("same-identity credential rotation"); + validate_platform_session_identity(&identity) + .expect("token rotation must not invalidate the frozen identity"); + + install_platform_session("frozen-user-b", "token-c", TEST_ORIGIN, 2, 3) + .expect("account switch"); + assert!(validate_platform_session_identity(&identity).is_err()); + + clear_platform_session(3, 4); + assert!(validate_platform_session_identity(&identity).is_err()); } #[test] fn validated_session_lease_linearizes_local_commit_with_account_switch() { - let _session = install_test_platform_session( - "lease-user-a", - "lease-token-a", - "https://dev.genarrative.world", - ); - let expected = current_platform_session().expect("current lease session"); - let token_sha256 = format!("{:x}", Sha256::digest(expected.access_token.as_bytes())); - let lease = acquire_validated_platform_session_fingerprint( - &expected.user_id, - &expected.api_base_url, - expected.generation, - &token_sha256, - ) - .expect("acquire validated session lease"); + let _session = install_test_platform_session("lease-user-a", "lease-token-a", TEST_ORIGIN); + let expected = current_platform_session() + .expect("current lease session") + .identity(); + let lease = acquire_platform_session_identity_lease(&expected) + .expect("acquire validated session lease"); let (started_sender, started_receiver) = std::sync::mpsc::channel(); let (finished_sender, finished_receiver) = std::sync::mpsc::channel(); let switcher = std::thread::spawn(move || { started_sender.send(()).expect("signal account switch"); - install_platform_session( - "lease-user-b", - "lease-token-b", - "https://dev.genarrative.world", - 2, - ) - .expect("switch account after lease release"); + install_platform_session("lease-user-b", "lease-token-b", TEST_ORIGIN, 2, 2) + .expect("switch account after lease release"); finished_sender.send(()).expect("signal switched account"); }); started_receiver diff --git a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs index 51838ea81..389d0486d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs @@ -767,6 +767,22 @@ fn permission_for_method(method: &str) -> Option<&'static str> { } } +fn has_cocos_editor_adapter(editors: &EditorRegistry) -> Result { + Ok(editors + .lock() + .map_err(|_| "编辑器注册表锁已损坏".to_string())? + .contains_key("cocos-editor")) +} + +fn require_plugin_adapter(id: &str, editors: &EditorRegistry) -> Result<(), String> { + if id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID + && !has_cocos_editor_adapter(editors)? + { + return Err("当前客户端不支持 Cocos 编辑器桥接".to_string()); + } + Ok(()) +} + impl PluginHost { pub(crate) fn initialize(&self, config_dir: &Path) -> Result<(), String> { let root = plugin_root(config_dir)?; @@ -948,11 +964,12 @@ impl PluginHost { .flatten() .is_some() }); + let cocos_available = cocos_project && has_cocos_editor_adapter(&state.editors)?; state .plugins .values() .filter(|record| { - record.id != crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID || cocos_project + record.id != crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID || cocos_available }) .map(|record| self.summary_locked(record)) .collect() @@ -1017,6 +1034,7 @@ impl PluginHost { .clone() .ok_or_else(|| "插件宿主尚未初始化".to_string())?; let active_project = state.active_project.clone(); + require_plugin_adapter(id, &state.editors)?; if id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID && !active_project .lock() @@ -1124,6 +1142,7 @@ impl PluginHost { .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; + require_plugin_adapter(id, &state.editors)?; let record = state .plugins .get(id) @@ -1535,6 +1554,7 @@ impl PluginHost { Ok(json!({"path": input.path, "content": content})) } "host.rpc" => { + require_plugin_adapter(&manifest.id, editors)?; let input: EditorRpcInput = descriptor_from_params(params)?; if manifest.id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID && !active_project @@ -2122,6 +2142,8 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p let host = PluginHost::default(); crate::builtin_plugins::initialize(directory.path()).expect("builtin plugin state"); host.initialize(directory.path()).expect("initialize"); + host.register_editor_adapter(Box::new(StubCocosAdapter)) + .expect("register adapter"); host.set_plugin_workspace(workspace) .expect("set plugins workspace"); host.set_active_project(Some(directory.path().to_string_lossy().into_owned())) @@ -2223,6 +2245,8 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins"); let host = PluginHost::default(); host.initialize(directory.path()).expect("initialize"); + host.register_editor_adapter(Box::new(StubCocosAdapter)) + .expect("register adapter"); host.set_plugin_workspace(workspace).expect("set workspace"); let project = tempdir().expect("web project"); @@ -2235,4 +2259,53 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p .all(|plugin| plugin.id != "agc-cocos-editor")); assert!(host.start("agc-cocos-editor").is_err()); } + + #[test] + fn cocos_plugin_requires_registered_adapter_even_for_a_cocos_project() { + let _guard = crate::builtin_plugins::test_lock(); + let directory = tempdir().expect("temp config"); + fs::write( + directory.path().join("package.json"), + r#"{"creator":{"version":"3.8.8"}}"#, + ) + .unwrap(); + fs::create_dir(directory.path().join("assets")).unwrap(); + crate::builtin_plugins::initialize(directory.path()).unwrap(); + let host = PluginHost::default(); + host.initialize(directory.path()).unwrap(); + host.set_plugin_workspace(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins")) + .unwrap(); + host.set_active_project(Some(directory.path().to_string_lossy().into_owned())) + .unwrap(); + assert!(host + .list() + .unwrap() + .iter() + .all(|plugin| plugin.id != "agc-cocos-editor")); + assert!(host + .list_extensions() + .unwrap() + .iter() + .all(|plugin| plugin.id != "agc-cocos-editor")); + assert!(host + .start("agc-cocos-editor") + .err() + .expect("unsupported adapter") + .contains("不支持 Cocos")); + assert!(host + .read_panel("agc-cocos-editor", "cocos-editor") + .err() + .expect("unsupported adapter") + .contains("不支持 Cocos")); + assert!(host.state.lock().unwrap().plugins["agc-cocos-editor"] + .running + .is_none()); + host.register_editor_adapter(Box::new(StubCocosAdapter)) + .unwrap(); + assert!(host + .list() + .unwrap() + .iter() + .any(|plugin| plugin.id == "agc-cocos-editor")); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs b/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs index 430fd6e9a..36025909e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs @@ -110,11 +110,13 @@ impl<'a> ExternalEditorBindingAccess<'a> { } /// Call before and after every awaited remote action and immediately before installing a - /// binding. Developer-key mode has no process-global account generation to compare. + /// binding. 只比较身份:同一账号的 access token 轮换(长回合保活、401 续期)不得让 + /// 在途的生成、编辑、上传、确认或下载 operation 失效;换号、退出或 origin 变化仍然 + /// 失败关闭。Developer-key 模式没有进程级身份代次可比对。 pub(crate) fn validate_frozen_session(&self) -> Result<(), String> { validate_external_editor_binding_access_shape(self)?; if let Some(session) = self.frozen_platform_session { - validate_platform_session_snapshot(session)?; + validate_frozen_platform_session(session)?; } Ok(()) } @@ -1152,7 +1154,8 @@ mod tests { user_id: user_id.to_string(), access_token: token.to_string(), api_base_url: "https://dev.genarrative.world".to_string(), - generation, + identity_generation: generation, + revision: generation, } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index 6f067628b..1e634bbda 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -1,5 +1,21 @@ use super::*; +/** + * Cocos Creator 工程根目录下的**生成目录**(导入缓存、构建临时目录、编辑器本地配置)。 + * + * 判定刻意收窄到「工程根的**直接子目录**且名字是这四个之一」:`assets/library/` 是资源 + * 目录里的普通文件夹,不属于这里。 + */ +fn is_engine_generated_root_directory(relative_path: &str) -> bool { + if relative_path.contains('/') { + return false; + } + matches!( + relative_path.to_ascii_lowercase().as_str(), + "library" | "temp" | "profiles" | "local" + ) +} + pub(crate) fn list_local_project_files_at( root: &Path, ) -> Result { @@ -11,6 +27,15 @@ pub(crate) fn list_local_project_files_at( }); } + /* + * 引擎生成目录的过滤只在**当前目录确实是 Cocos Creator 工程**时生效。 + * + * 这里不能把 `library` / `temp` / `profiles` / `local` 加进全局跳过表:这些名字在 + * 别的工程里可能是真实源码目录(例如自带 `library/` 的库工程)。而 Cocos 工程的 + * `library/` 是导入缓存,常有上万条生成文件,既会挤满 Agent 的发现窗口,也会让 + * 前端资源树加载一堆永远用不上的条目。 + */ + let skip_engine_generated_directories = discover_local_cocos_project_root(root)?.is_some(); let mut files = Vec::new(); let mut dirs = vec![root.to_path_buf()]; while let Some(dir) = dirs.pop() { @@ -41,6 +66,11 @@ pub(crate) fn list_local_project_files_at( .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) .unwrap_or(0); if file_type.is_dir() { + if skip_engine_generated_directories + && is_engine_generated_root_directory(&relative_path) + { + continue; + } files.push(LocalProjectFileEntry { path: relative_path, kind: "directory".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index a3fe7968f..4f8b0c793 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -1131,8 +1131,17 @@ pub(crate) fn validate_manifest_required_visual_asset( } if task_id == "art-director" { - if !asset.source.reference_resource_ids.is_empty() { - return Err("统一视觉规范图不得声明派生资源引用".to_string()); + // 规范图是视觉来源链的根:它自身不派生任何视觉资产,但 icon-spec 生成允许用户参考 + // (没有规范前置,最多总上限),这些参考只是风格输入,不构成派生关系。这里改为验证 + // 参考集合仍符合 icon-spec 请求合同;route / generation kind / canvasProjectId / + // resourceId / PNG 解码等身份判据全部保持不变。 + if !crate::agent::platform_art_runtime_references_match_request_contract( + &asset.source.reference_resource_ids, + expected_kind, + ) { + return Err(format!( + "统一视觉规范图的参考集合不符合请求合同:{expected_path}" + )); } return Ok(()); } @@ -1157,11 +1166,17 @@ pub(crate) fn validate_manifest_required_visual_asset( .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| "统一视觉规范图缺少 resourceId".to_string())?; - let [reference_resource_id] = asset.source.reference_resource_ids.as_slice() else { + // 派生素材的参考合同是「规范图前置在最前,用户参考按顺序追加在后」,图集不接受用户参考: + // 规范身份仍只由首项承担,用户参考不能顶替也不能冒充规范引用。 + if !crate::agent::platform_art_runtime_references_match_request_contract( + &asset.source.reference_resource_ids, + expected_kind, + ) { return Err(format!( "派生视觉资产未精确引用当前统一视觉规范图:{expected_path}" )); - }; + } + let reference_resource_id = asset.source.reference_resource_ids[0].as_str(); let original_provenance_matches = canvas_project_id == art_spec_project_id && reference_resource_id == art_spec_resource_id; let rebound_local_source_matches = if original_provenance_matches { @@ -1328,6 +1343,240 @@ pub(crate) fn update_manifest_asset_classification_at( }) } +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct AddLocalProjectResourceTagsInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) expected_project_revision: u64, + pub(crate) asset_ids: Vec, + #[serde(default)] + pub(crate) tags: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AddLocalProjectResourceTagsResult { + pub(crate) assets: Vec, + pub(crate) committed_project_revision: u64, +} + +/// 一次批量追加的素材上限:与主规范「每批最多 200 个不同素材」一致,按**去重后**数量计算。 +/// 批次越大,锁内要重算的合并结果越多,manifest 也越大;无界批次会把成本摊到之后每一次读写上。 +pub(crate) const ASSET_BATCH_TAG_MAX_ASSETS: usize = 200; + +/// 批量追加标签的素材 ID 归一化:trim、按**首次出现顺序**去重,再在此处收口批次上下界。 +/// +/// 这里是"整句拒绝"的失败关闭口径,不做任何静默容忍: +/// +/// - 空白 `assetId` 直接失败,不 `continue` 跳过。静默跳过会让"请求了 N 个素材"和"实际写了 +/// N-1 个"分叉,而调用方拿到的仍是成功——这正是本合同要排除的静默部分写; +/// - 空批次失败; +/// - 去重后超限立即失败(在扫描到第 201 个不同 ID 时就返回,不对剩余 ID 继续做去重扫描), +/// 更不做"截断到 200 个":截断会让用户以为 250 个素材都加上了标签。 +fn normalize_manifest_batch_asset_ids(asset_ids: &[String]) -> Result, String> { + let mut normalized: Vec = Vec::new(); + for asset_id in asset_ids { + let asset_id = asset_id.trim(); + if asset_id.is_empty() { + return Err("批量标签 assetId 不能为空".to_string()); + } + if !normalized.iter().any(|existing| existing == asset_id) { + normalized.push(asset_id.to_string()); + if normalized.len() > ASSET_BATCH_TAG_MAX_ASSETS { + return Err(format!( + "批量标签最多支持 {ASSET_BATCH_TAG_MAX_ASSETS} 个素材" + )); + } + } + } + if normalized.is_empty() { + return Err("批量标签至少需要一个素材".to_string()); + } + Ok(normalized) +} + +/// 批量追加的标签归一化:沿用主规范的 trim / 去空 / 去重口径(复用 +/// [`normalize_manifest_asset_tags`],其中已含数量与单标签长度收口)。 +/// +/// 只有**归一后为空**才拒绝:请求里全是空白标签时,用户填的东西一个字都不会落盘, +/// 此时若当成"成功且无变化"返回,界面会显示保存成功而素材上什么都没有。 +fn normalize_manifest_batch_tags(tags: &[String]) -> Result, String> { + let normalized = normalize_manifest_asset_tags(tags)?; + if normalized.is_empty() { + return Err("批量标签不能为空".to_string()); + } + Ok(normalized) +} + +/// 追加语义:只把请求里**尚不存在**的标签按请求顺序补到原有标签之后。 +/// 原有标签的顺序、分类、类型、路径与来源都不参与改写——本命令没有删除或替换语义。 +fn merge_manifest_asset_tags(existing: &[String], incoming: &[String]) -> Vec { + let mut merged = existing.to_vec(); + for tag in incoming { + if !merged.iter().any(|current| current == tag) { + merged.push(tag.clone()); + } + } + merged +} + +/// 锁内先算完的整批计划:任何一项缺失或超限都在这里失败,此时 manifest 一个字节都没动。 +struct ManifestAssetTagAppendPlan { + /// 按请求顺序(去重后)返回的素材条目,标签为合并后的完整列表。 + assets: Vec, + /// 真正需要落值的目标:`(assets 下标, 合并后的标签)`。 + updates: Vec<(usize, Vec)>, + /// 确实发生变化的素材 ID,供审计记录使用;空表示整批无变化。 + changed_asset_ids: Vec, +} + +/// 先校验**全部**目标与**全部**合并结果,再决定是否写值。 +/// +/// 顺序是刻意的:第一阶段只读,任一目标不存在、任一合并结果超过标签上界都在写之前返回错误; +/// 只有全部通过,第二阶段才逐项落值。这样"缺任一资产 / 超限"都不可能留下部分写入。 +fn plan_manifest_asset_tag_append( + manifest: &GameCreationAppManifest, + asset_ids: &[String], + tags: &[String], +) -> Result { + let mut assets = Vec::with_capacity(asset_ids.len()); + let mut updates: Vec<(usize, Vec)> = Vec::with_capacity(asset_ids.len()); + let mut changed_asset_ids = Vec::new(); + for asset_id in asset_ids { + let index = manifest + .assets + .iter() + .position(|asset| &asset.id == asset_id) + .ok_or_else(|| format!("项目资源不存在:{asset_id}"))?; + let asset = &manifest.assets[index]; + // 合并结果复用同一个上界函数:已有标签已归一化,这里等价于对整份新列表再收口一次。 + // 上界函数只报"16 个"这种通用口径,200 个素材的批次里看不出是哪一项超了,所以在**调用点** + // 补上目标身份(ID + 可读 localPath)并说明整批未写:用户要能直接定位到那一张素材。 + let merged = normalize_manifest_asset_tags(&merge_manifest_asset_tags(&asset.tags, tags)) + .map_err(|error| { + format!( + "素材 {}({})的标签合并结果不合法:{error};本次未写入任何素材", + asset.id, asset.local_path + ) + })?; + if merged != asset.tags { + changed_asset_ids.push(asset.id.clone()); + } + updates.push((index, merged.clone())); + assets.push(GameCreationAppAssetManifestEntry { + tags: merged, + ..asset.clone() + }); + } + Ok(ManifestAssetTagAppendPlan { + assets, + updates, + changed_asset_ids, + }) +} + +/// 为一批已登记素材追加标签:一次校验、一次 manifest 写入、一次 revision 推进。 +/// +/// 语义与 [`update_manifest_asset_classification_at`] 同源(`asset.register` 权限位、项目身份、 +/// 项目写锁、revision CAS、manifest 原子写、审计在 manifest 落盘之后 / revision 推进之前), +/// 但作用域是**整批**: +/// +/// - 项目身份校验两次(进入前与持锁后各一次),锁内按 `expectedProjectRevision` 做一次 CAS; +/// - 锁内先算完整批计划,任一目标缺失或任一合并结果超限都**不写任何一项**; +/// - 整批无变化时**不写盘、不审计、不推进 revision**,直接返回当前条目与当前 revision; +/// - 真正有变化时才写一次 manifest、追加一条审计、推进一次 revision。 +/// +/// 已落盘之后的审计或 revision 失败照实报"整批已写入",不回滚、也不谎称回滚:manifest 是权威 +/// 真相且已经改变,把错误说成"没写"只会让用户拿错状态去重试。 +pub(crate) fn add_manifest_asset_tags_at( + root: &Path, + expected_project_id: &str, + expected_project_revision: u64, + asset_ids: Vec, + tags: Vec, +) -> Result { + if expected_project_revision + > shared_contracts::game_creation_app::GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + { + return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string()); + } + let expected_project_id = expected_project_id.trim(); + if expected_project_id.is_empty() { + return Err("批量标签 expectedProjectId 不能为空".to_string()); + } + let asset_ids = normalize_manifest_batch_asset_ids(&asset_ids)?; + let tags = normalize_manifest_batch_tags(&tags)?; + + if read_existing_manifest_for_project(root)?.project_id != expected_project_id { + return Err("project-identity-conflict".to_string()); + } + // 锁的 commandId 用本命令自己的动作名(审计/排障时能区分是批量追加还是别的写路径); + // 权限门面仍然是 `asset.register`,见 `commands.rs` 的命令包装层。 + let _lock = acquire_project_write_lock(root, ASSET_BATCH_TAG_AUDIT_RECORD_TYPE)?; + if read_existing_manifest_for_project(root)?.project_id != expected_project_id { + return Err("project-identity-conflict".to_string()); + } + if read_game_creator_agent_runtime_project_revision(root)?.revision != expected_project_revision + { + return Err("project-revision-conflict".to_string()); + } + + // no-op 判定发生在锁内、写盘之前:整批标签都已经存在时,连 manifest 都不必重写一次。 + // 这不是优化洁癖——重写会换掉文件 mtime 与内容字节,让"什么都没做"看起来像一次真实改动。 + let plan = plan_manifest_asset_tag_append( + &read_existing_manifest_for_project(root)?, + &asset_ids, + &tags, + )?; + if plan.changed_asset_ids.is_empty() { + return Ok(AddLocalProjectResourceTagsResult { + assets: plan.assets, + committed_project_revision: expected_project_revision, + }); + } + + let plan = mutate_manifest_at(root, |manifest| { + // 锁内复核:`mutate_manifest_at` 自己重新读盘,所以这里按同一套规则重算一遍再落值。 + // 复核失败会在 `write_manifest_locked` 之前返回错误,仍然零写入;重算也保证不会拿 + // 锁外算出的绝对标签列表去覆盖这份 manifest 上刚出现的新标签。 + let plan = plan_manifest_asset_tag_append(manifest, &asset_ids, &tags)?; + for (index, merged) in &plan.updates { + manifest.assets[*index].tags = merged.clone(); + } + Ok(plan) + })?; + + // 复核阶段才发现"锁外以为有变化、锁内其实已无变化"的极端竞态:这一次写盘写出的就是原内容, + // 不能凭空补一条审计或推进 revision。正常路径不会走到这里——整批目标在此之前已经通过锁内 no-op 判定。 + if plan.changed_asset_ids.is_empty() { + return Ok(AddLocalProjectResourceTagsResult { + assets: plan.assets, + committed_project_revision: expected_project_revision, + }); + } + + append_agent_db_record( + root, + serde_json::json!({ + "recordType": ASSET_BATCH_TAG_AUDIT_RECORD_TYPE, + "assetIds": plan.changed_asset_ids, + "expectedProjectRevision": expected_project_revision, + "appendedTags": tags, + }), + ) + .map_err(|error| format!("批量标签已写入,但审计记录失败:{error}"))?; + let committed_project_revision = advance_agent_runtime_project_revision_locked(root) + .map_err(|error| format!("批量标签已写入,但项目 revision 未能推进:{error}"))?; + Ok(AddLocalProjectResourceTagsResult { + assets: plan.assets, + committed_project_revision, + }) +} + +/// 批量标签写入的审计类型:一次批量追加只留一条记录,装的是"谁被追加了什么"。 +pub(crate) const ASSET_BATCH_TAG_AUDIT_RECORD_TYPE: &str = "asset.tags.append"; + pub(crate) fn create_manifest_task_at( root: &Path, task_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs index d66a0d736..0dab003fb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs @@ -559,3 +559,716 @@ fn asset_classification_audit_failure_is_reported_and_never_faked() { assert_eq!(asset.tags, vec!["主舞台"]); fs::remove_dir_all(root).ok(); } + +/// 批量标签测试的公共脚手架:登记素材、读原始字节、数审计记录。 +fn register_batch_tag_asset(root: &Path, local_path: &str, kind: &str, media_type: &str) -> String { + register_local_asset_entry( + root, + local_path, + kind, + media_type, + "asset", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Uploaded, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + ) + .expect("register asset") + .id +} + +fn project_with_batch_tag_assets( + test_name: &str, + specs: &[(&str, &str, &str)], +) -> (PathBuf, Vec) { + let root = classification_test_root(test_name); + init_local_game_project_at(&root, "batch-tag-project", "批量标签测试") + .expect("initialize batch tag project"); + let ids = specs + .iter() + .map(|(local_path, kind, media_type)| { + register_batch_tag_asset(&root, local_path, kind, media_type) + }) + .collect::>(); + (root, ids) +} + +fn batch_tag_project_id(root: &Path) -> String { + read_existing_manifest_for_project(root) + .expect("read manifest") + .project_id +} + +fn batch_tag_revision(root: &Path) -> u64 { + read_game_creator_agent_runtime_project_revision(root) + .expect("read project revision") + .revision +} + +fn batch_tag_manifest_bytes(root: &Path) -> Vec { + fs::read(root.join(".agent/manifest.json")).expect("read manifest bytes") +} + +/// 批量追加的 `recordType`:与 `asset.*` 命名族一致,`.append` 表达"只追加、不替换既有标签"。 +fn batch_tag_audit_records(root: &Path) -> Vec { + let (records, truncated) = + read_agent_db_records_bounded(root, 4 * 1024 * 1024).expect("read agent db records"); + assert!(!truncated, "批量标签测试的 agent.db 不应触达尾窗上限"); + records + .into_iter() + .filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some(ASSET_BATCH_TAG_AUDIT_RECORD_TYPE) + }) + .collect() +} + +fn batch_tag_manifest_entry(root: &Path, asset_id: &str) -> GameCreationAppAssetManifestEntry { + read_existing_manifest_for_project(root) + .expect("read manifest") + .assets + .into_iter() + .find(|asset| asset.id == asset_id) + .expect("manifest asset") +} + +/// 成功路径:只追加,原有标签顺序、分类与其它字段原样保留,未选中的素材一个字节都不变。 +/// 返回顺序按去重后的请求 ID 首次出现顺序;整批只推进一次 revision、只追加一条审计。 +#[test] +fn batch_tags_append_keeps_existing_tags_and_categories() { + let (root, ids) = project_with_batch_tag_assets( + "batch-append", + &[ + ("assets/hero.png", "character", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ("assets/untouched.png", "image", "image/png"), + ], + ); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + // 两份目标素材带着**不同的**原有标签与分类进入:一个已有标签、一个是空标签集。 + update_manifest_asset_classification_at( + &root, + &project_id, + revision_before, + &ids[0], + "scene", + vec!["原甲".to_string(), "原乙".to_string()], + ) + .expect("preset first asset classification"); + let revision_after_preset = batch_tag_revision(&root); + update_manifest_asset_classification_at( + &root, + &project_id, + revision_after_preset, + &ids[1], + "audio", + Vec::new(), + ) + .expect("preset second asset classification"); + let revision_before_batch = batch_tag_revision(&root); + let untouched_before = batch_tag_manifest_entry(&root, &ids[2]); + + let result = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before_batch, + vec![ids[1].clone(), ids[0].clone()], + vec!["新一".to_string(), "新二".to_string()], + ) + .expect("append batch tags"); + + assert_eq!( + result + .assets + .iter() + .map(|asset| asset.id.clone()) + .collect::>(), + vec![ids[1].clone(), ids[0].clone()], + "返回条目必须按去重后请求 ID 的首次出现顺序" + ); + assert_eq!(result.assets[0].tags, vec!["新一", "新二"]); + assert_eq!(result.assets[1].tags, vec!["原甲", "原乙", "新一", "新二"]); + assert_eq!(result.committed_project_revision, revision_before_batch + 1); + assert_eq!(batch_tag_revision(&root), revision_before_batch + 1); + + let first = batch_tag_manifest_entry(&root, &ids[0]); + assert_eq!(first.tags, vec!["原甲", "原乙", "新一", "新二"]); + assert_eq!(first.category, GameCreationAppAssetCategory::Scene); + assert_eq!(first.kind, "character"); + assert_eq!(first.local_path, "assets/hero.png"); + assert_eq!(first.media_type, "image/png"); + let second = batch_tag_manifest_entry(&root, &ids[1]); + assert_eq!(second.tags, vec!["新一", "新二"]); + assert_eq!(second.category, GameCreationAppAssetCategory::Audio); + assert_eq!(second.kind, "background-music"); + assert_eq!(batch_tag_manifest_entry(&root, &ids[2]), untouched_before); + + let audit = batch_tag_audit_records(&root); + assert_eq!(audit.len(), 1, "整批只留一条审计"); + assert_eq!( + audit[0] + .get("assetIds") + .and_then(serde_json::Value::as_array), + Some(&vec![ + serde_json::Value::String(ids[1].clone()), + serde_json::Value::String(ids[0].clone()) + ]) + ); + assert_eq!( + audit[0] + .get("expectedProjectRevision") + .and_then(serde_json::Value::as_u64), + Some(revision_before_batch) + ); + + fs::remove_dir_all(root).ok(); +} + +/// 重复 ID 与重复标签都按第一次出现收口:既不重复写入素材,也不重复追加同一个标签。 +#[test] +fn batch_tags_dedupe_asset_ids_and_tags() { + let (root, ids) = project_with_batch_tag_assets( + "batch-dedupe", + &[ + ("assets/hero.png", "image", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ], + ); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + + let result = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + vec![format!(" {} ", ids[0]), ids[0].clone(), ids[1].clone()], + vec![ + " 标签 ".to_string(), + "标签".to_string(), + " 另一个 ".to_string(), + ], + ) + .expect("append deduped batch tags"); + + assert_eq!( + result + .assets + .iter() + .map(|asset| asset.id.clone()) + .collect::>(), + ids, + "去重后按首次出现顺序返回,两个素材各一次" + ); + for asset in &result.assets { + assert_eq!(asset.tags, vec!["标签", "另一个"]); + } + assert_eq!(result.committed_project_revision, revision_before + 1); + fs::remove_dir_all(root).ok(); +} + +/// 整批无变化:不写盘(manifest 字节不变)、不审计、不推进 revision,返回当前条目与当前 revision。 +#[test] +fn batch_tags_repeat_is_a_noop_without_write_audit_or_revision() { + let (root, ids) = project_with_batch_tag_assets( + "batch-noop", + &[ + ("assets/hero.png", "image", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ], + ); + let project_id = batch_tag_project_id(&root); + let first = add_manifest_asset_tags_at( + &root, + &project_id, + batch_tag_revision(&root), + ids.clone(), + vec!["重复标签".to_string()], + ) + .expect("first batch append"); + let revision_after_first = first.committed_project_revision; + assert_eq!(batch_tag_audit_records(&root).len(), 1); + let bytes_before = batch_tag_manifest_bytes(&root); + + let repeat = add_manifest_asset_tags_at( + &root, + &project_id, + revision_after_first, + vec![ids[0].clone(), ids[1].clone(), ids[0].clone()], + vec!["重复标签".to_string(), " 重复标签 ".to_string()], + ) + .expect("repeating the same batch tags must succeed"); + + assert_eq!(repeat.committed_project_revision, revision_after_first); + assert_eq!(repeat.assets.len(), 2); + for asset in &repeat.assets { + assert_eq!(asset.tags, vec!["重复标签"]); + } + assert_eq!( + batch_tag_manifest_bytes(&root), + bytes_before, + "无变化时不得重写 manifest" + ); + assert_eq!(batch_tag_revision(&root), revision_after_first); + assert_eq!( + batch_tag_audit_records(&root).len(), + 1, + "无变化不得追加假变更审计" + ); + fs::remove_dir_all(root).ok(); +} + +/// 混合批次:一部分目标无变化、一部分目标有变化时,审计只记**实际变化**的素材, +/// 响应仍然按请求顺序返回**全部**目标的最新条目,revision 只推进一次。 +#[test] +fn batch_tags_mixed_noop_and_change_audits_only_changed_assets() { + let (root, ids) = project_with_batch_tag_assets( + "batch-mixed", + &[ + ("assets/hero.png", "image", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ], + ); + let project_id = batch_tag_project_id(&root); + // 第一项已经有目标标签(本批对它无变化),第二项没有(本批真正改动它)。 + let revision_after_preset = update_manifest_asset_classification_at( + &root, + &project_id, + batch_tag_revision(&root), + &ids[0], + "unclassified", + vec!["已有".to_string()], + ) + .expect("preset first asset tags") + .committed_project_revision; + let first_before = batch_tag_manifest_entry(&root, &ids[0]); + let second_before = batch_tag_manifest_entry(&root, &ids[1]); + + let result = add_manifest_asset_tags_at( + &root, + &project_id, + revision_after_preset, + ids.clone(), + vec!["已有".to_string()], + ) + .expect("mixed no-op and change batch"); + + assert_eq!( + result + .assets + .iter() + .map(|asset| asset.id.clone()) + .collect::>(), + ids, + "响应必须按请求顺序返回全部目标素材" + ); + assert_eq!(result.assets[0].tags, vec!["已有"]); + assert_eq!(result.assets[1].tags, vec!["已有"]); + assert_eq!(result.committed_project_revision, revision_after_preset + 1); + assert_eq!( + batch_tag_manifest_entry(&root, &ids[0]), + first_before, + "本批对该素材无变化时不得改写它" + ); + assert_eq!( + batch_tag_manifest_entry(&root, &ids[1]).tags, + vec!["已有"], + "有变化的目标必须真实落盘" + ); + assert_ne!( + batch_tag_manifest_entry(&root, &ids[1]), + second_before, + "第二项应当发生改动" + ); + + let audit = batch_tag_audit_records(&root); + assert_eq!(audit.len(), 1, "混合批次只留一条审计"); + assert_eq!( + audit[0] + .get("assetIds") + .and_then(serde_json::Value::as_array), + Some(&vec![serde_json::Value::String(ids[1].clone())]), + "审计只记实际发生变化的素材" + ); + fs::remove_dir_all(root).ok(); +} + +/// 缺任一目标(含末项非法)时整批零部分写:已有素材的标签、manifest 字节与 revision 都不动。 +#[test] +fn batch_tags_missing_target_writes_nothing() { + let (root, ids) = project_with_batch_tag_assets( + "batch-missing", + &[ + ("assets/hero.png", "image", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ], + ); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + let bytes_before = batch_tag_manifest_bytes(&root); + + let error = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + vec![ids[0].clone(), ids[1].clone(), "asset-missing".to_string()], + vec!["新标签".to_string()], + ) + .expect_err("a missing target must fail the whole batch"); + + assert!( + error.contains("项目资源不存在"), + "unexpected error: {error}" + ); + assert!(error.contains("asset-missing"), "unexpected error: {error}"); + assert_eq!(batch_tag_manifest_bytes(&root), bytes_before); + assert_eq!(batch_tag_revision(&root), revision_before); + assert!(batch_tag_audit_records(&root).is_empty()); + for asset_id in &ids { + assert!( + batch_tag_manifest_entry(&root, asset_id).tags.is_empty(), + "缺目标失败后不得留下部分写入" + ); + } + fs::remove_dir_all(root).ok(); +} + +/// 合并后的标签总量与单标签长度按既有上界失败关闭:都不写盘、不推进 revision。 +#[test] +fn batch_tags_rejects_merged_tag_limit_and_single_tag_length() { + let (root, ids) = project_with_batch_tag_assets( + "batch-limits", + &[ + ("assets/hero.png", "image", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ], + ); + let project_id = batch_tag_project_id(&root); + let sixteen = (0..16) + .map(|index| format!("原标签{index}")) + .collect::>(); + let revision_after_sixteen = update_manifest_asset_classification_at( + &root, + &project_id, + batch_tag_revision(&root), + &ids[0], + "unclassified", + sixteen.clone(), + ) + .expect("preset sixteen tags") + .committed_project_revision; + let fifteen = sixteen[..15].to_vec(); + let revision_after_fifteen = update_manifest_asset_classification_at( + &root, + &project_id, + revision_after_sixteen, + &ids[1], + "audio", + fifteen, + ) + .expect("preset fifteen tags") + .committed_project_revision; + let bytes_before = batch_tag_manifest_bytes(&root); + + // 16 个原有标签 + 1 个新标签 = 17 > 上界:整批失败,两项目标都不动。 + let over_limit = add_manifest_asset_tags_at( + &root, + &project_id, + revision_after_fifteen, + ids.clone(), + vec!["再来一个".to_string()], + ) + .expect_err("a merged tag count above the limit must fail the whole batch"); + assert!(over_limit.contains("最多支持"), "unexpected: {over_limit}"); + // 通用上界文案必须能定位到具体素材:ID + 可读 localPath + 整批零写。 + assert!(over_limit.contains(&ids[0]), "unexpected: {over_limit}"); + assert!( + over_limit.contains("assets/hero.png"), + "unexpected: {over_limit}" + ); + assert!( + over_limit.contains("本次未写入任何素材"), + "unexpected: {over_limit}" + ); + + // 33 个字符的标签:单标签长度上界,同样整批失败。 + let over_chars = add_manifest_asset_tags_at( + &root, + &project_id, + revision_after_fifteen, + ids.clone(), + vec!["像".repeat(33)], + ) + .expect_err("an over-long tag must fail the whole batch"); + assert!(over_chars.contains("不能超过"), "unexpected: {over_chars}"); + // 单标签超长在**输入归一化**阶段就被拒绝,那时还没有任何"目标素材"可归因, + // 因此这里刻意不出现素材 ID;按素材定位是「合并后数量超限」这类锁内合并失败的职责。 + assert!(!over_chars.contains(&ids[0]), "unexpected: {over_chars}"); + + assert_eq!(batch_tag_manifest_bytes(&root), bytes_before); + assert_eq!(batch_tag_revision(&root), revision_after_fifteen); + assert!(batch_tag_audit_records(&root).is_empty()); + assert_eq!(batch_tag_manifest_entry(&root, &ids[0]).tags, sixteen); + + // 边界内必须成功:15 个原有标签 + 1 = 16,单标签 32 个字符。 + let boundary = add_manifest_asset_tags_at( + &root, + &project_id, + revision_after_fifteen, + vec![ids[1].clone()], + vec!["像".repeat(32)], + ) + .expect("merged tag count exactly at the limit must succeed"); + assert_eq!(boundary.assets[0].tags.len(), 16); + assert_eq!(boundary.assets[0].tags[15].chars().count(), 32); + fs::remove_dir_all(root).ok(); +} + +/// 空批次与空标签都不接受:空白 `assetId` 整批拒绝(不静默跳过),全空标签归一后拒绝。 +#[test] +fn batch_tags_rejects_empty_batch_and_empty_tags() { + let (root, ids) = + project_with_batch_tag_assets("batch-empty", &[("assets/hero.png", "image", "image/png")]); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + let bytes_before = batch_tag_manifest_bytes(&root); + let tags = vec!["标签".to_string()]; + + let empty_batch = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + Vec::new(), + tags.clone(), + ) + .expect_err("an empty asset batch must be rejected"); + assert!( + empty_batch.contains("至少需要一个素材"), + "unexpected: {empty_batch}" + ); + + // 空白 assetId 一律拒绝:跳过它会让"请求了 2 个素材"变成"实际写了 1 个",且调用方仍拿到成功。 + for blank in [String::new(), " ".to_string()] { + let error = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + vec![ids[0].clone(), blank], + tags.clone(), + ) + .expect_err("a blank assetId must fail the whole batch"); + assert!(error.contains("assetId 不能为空"), "unexpected: {error}"); + } + + for empty_tags in [Vec::new(), vec![String::new()], vec![" ".to_string()]] { + let error = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + ids.clone(), + empty_tags, + ) + .expect_err("empty tags must be rejected"); + assert!(error.contains("不能为空"), "unexpected: {error}"); + } + + assert_eq!(batch_tag_manifest_bytes(&root), bytes_before); + assert_eq!(batch_tag_revision(&root), revision_before); + assert!(batch_tag_audit_records(&root).is_empty()); + assert!( + batch_tag_manifest_entry(&root, &ids[0]).tags.is_empty(), + "空白 assetId / 空标签失败后不得留下部分写入" + ); + fs::remove_dir_all(root).ok(); +} + +/// 批次上界:去重后 200 个素材成功,201 个失败且不写任何一项。 +#[test] +fn batch_tags_accepts_two_hundred_assets_and_rejects_two_hundred_one() { + let root = classification_test_root("batch-bound-200"); + init_local_game_project_at(&root, "batch-tag-bound-project", "批量标签上界测试") + .expect("initialize batch bound project"); + let ids = (0..=ASSET_BATCH_TAG_MAX_ASSETS) + .map(|index| { + register_batch_tag_asset(&root, &format!("assets/a{index}.png"), "image", "image/png") + }) + .collect::>(); + let project_id = batch_tag_project_id(&root); + + let revision_before = batch_tag_revision(&root); + let bytes_before = batch_tag_manifest_bytes(&root); + let over = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + ids.clone(), + vec!["超限".to_string()], + ) + .expect_err("more than the batch limit must be rejected"); + assert!(over.contains("最多支持 200 个素材"), "unexpected: {over}"); + assert_eq!(batch_tag_manifest_bytes(&root), bytes_before); + assert_eq!(batch_tag_revision(&root), revision_before); + + let boundary = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + ids[..ASSET_BATCH_TAG_MAX_ASSETS].to_vec(), + vec!["批量".to_string()], + ) + .expect("exactly the batch limit must succeed"); + assert_eq!(boundary.assets.len(), ASSET_BATCH_TAG_MAX_ASSETS); + assert_eq!(boundary.committed_project_revision, revision_before + 1); + assert_eq!(boundary.assets[0].tags, vec!["批量"]); + assert_eq!( + batch_tag_manifest_entry(&root, &ids[ASSET_BATCH_TAG_MAX_ASSETS]).tags, + Vec::::new(), + "第 201 个素材不在本批范围内,不得被写入" + ); + fs::remove_dir_all(root).ok(); +} + +/// 项目身份与 revision CAS 沿用单素材口径:身份不符 / 版本冲突都在写之前失败。 +#[test] +fn batch_tags_keeps_project_identity_and_revision_cas() { + let (root, ids) = + project_with_batch_tag_assets("batch-cas", &[("assets/hero.png", "image", "image/png")]); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + let bytes_before = batch_tag_manifest_bytes(&root); + + let identity_error = add_manifest_asset_tags_at( + &root, + "another-project", + revision_before, + ids.clone(), + vec!["新标签".to_string()], + ) + .expect_err("a foreign project identity must fail closed"); + assert_eq!(identity_error, "project-identity-conflict"); + + let revision_error = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before + 1, + ids.clone(), + vec!["新标签".to_string()], + ) + .expect_err("a stale revision must fail closed"); + assert_eq!(revision_error, "project-revision-conflict"); + + assert_eq!(batch_tag_manifest_bytes(&root), bytes_before); + assert_eq!(batch_tag_revision(&root), revision_before); + assert!(batch_tag_audit_records(&root).is_empty()); + fs::remove_dir_all(root).ok(); +} + +/// DTO 必须 camelCase 且拒绝未知字段。 +#[test] +fn batch_tags_input_rejects_unknown_fields() { + let parsed = serde_json::from_value::(serde_json::json!({ + "projectPath": "C:/project", + "expectedProjectId": "project", + "expectedProjectRevision": 1, + "assetIds": ["asset-1"], + "tags": ["标签"], + })) + .expect("camelCase payload must deserialize"); + assert_eq!(parsed.asset_ids, vec!["asset-1"]); + assert_eq!(parsed.tags, vec!["标签"]); + + let rejected = serde_json::from_value::(serde_json::json!({ + "projectPath": "C:/project", + "expectedProjectId": "project", + "expectedProjectRevision": 1, + "assetIds": ["asset-1"], + "tags": ["标签"], + "unexpected": true, + })); + assert!(rejected.is_err()); +} + +/// 权限门面:`asset.register` 被拒绝时命令整体失败,manifest、revision 与审计都不动。 +#[test] +fn batch_tags_command_requires_asset_register_permission() { + let (root, ids) = project_with_batch_tag_assets( + "batch-permission", + &[("assets/hero.png", "image", "image/png")], + ); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + let bytes_before = batch_tag_manifest_bytes(&root); + + let mut policy = crate::ProjectPermissionPolicy::default(); + policy.denied_commands.push("asset.register".to_string()); + write_project_permission_policy_at(&root, policy).expect("write permission policy"); + + let error = + crate::commands::add_local_project_resource_tags(AddLocalProjectResourceTagsInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: project_id.clone(), + expected_project_revision: revision_before, + asset_ids: ids.clone(), + tags: vec!["新标签".to_string()], + }) + .expect_err("a denied asset.register must fail closed"); + + assert_eq!(error, "项目权限策略拒绝执行:asset.register"); + assert_eq!(batch_tag_manifest_bytes(&root), bytes_before); + assert_eq!(batch_tag_revision(&root), revision_before); + assert!(batch_tag_audit_records(&root).is_empty()); + fs::remove_dir_all(root).ok(); +} + +/// 审计失败必须照实报「整批已写入」,不谎称回滚、也不留下假审计。 +#[test] +fn batch_tags_audit_failure_reports_written_batch() { + let (root, ids) = project_with_batch_tag_assets( + "batch-audit-failure", + &[ + ("assets/hero.png", "image", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ], + ); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + fs::write( + root.join(".agent/runtime/test-fail-next-agent-db-record"), + ASSET_BATCH_TAG_AUDIT_RECORD_TYPE, + ) + .expect("write audit failure injection marker"); + + let error = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + ids.clone(), + vec!["新标签".to_string()], + ) + .expect_err("an audit append failure must surface to the caller"); + + assert!(error.contains("已写入"), "unexpected error: {error}"); + assert!(error.contains("审计记录失败"), "unexpected error: {error}"); + assert!(batch_tag_audit_records(&root).is_empty()); + for asset_id in &ids { + assert_eq!( + batch_tag_manifest_entry(&root, asset_id).tags, + vec!["新标签"], + "manifest 是权威真相:已写入就必须能读回来,不伪报 rollback" + ); + } + assert_eq!( + batch_tag_revision(&root), + revision_before, + "审计失败发生在 revision 推进之前,照既有语义不推进" + ); + fs::remove_dir_all(root).ok(); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index b1f292976..145330f56 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -113,6 +113,7 @@ fn resource_edit_project_mutation_lock(root: &Path) -> Result &'static str { match self { Self::ImageReference => "image-reference", + Self::BackgroundRemoval => "background-removal", Self::Svg => "svg", Self::CharacterAnimation => "character-animation", Self::Video => "video", @@ -188,6 +191,10 @@ pub(crate) struct DeriveLocalProjectResourceInput { pub(crate) source_version_id: Option, pub(crate) prompt: String, pub(crate) asset_name: String, + #[serde(default)] + pub(crate) background_mode: Option, + #[serde(default)] + pub(crate) screen_color: Option, } #[derive(Clone, Debug, PartialEq, Serialize)] @@ -220,6 +227,10 @@ pub(crate) struct PendingLocalProjectResourceEdit { pub(crate) source_resource_id: String, pub(crate) source_asset_id: Option, pub(crate) asset_name: String, + #[serde(default)] + pub(crate) background_mode: Option, + #[serde(default)] + pub(crate) screen_color: Option, pub(crate) prompt_sha256: String, pub(crate) phase: String, pub(crate) created_at: u64, @@ -385,6 +396,10 @@ struct ResourceEditLedger { prompt: String, asset_name: String, #[serde(default)] + background_mode: Option, + #[serde(default)] + screen_color: Option, + #[serde(default)] provider_request_issued_at: Option, #[serde(default)] access_scheme: Option, @@ -728,7 +743,16 @@ fn validate_resource_edit_uuid(value: &str, label: &str) -> Result<(), String> { Ok(()) } -fn resource_edit_prompt_max_chars(edit_kind: &LocalProjectResourceEditKind) -> usize { +/// 资源编辑提示词上限的**唯一口径**。 +/// +/// 三个调用方都必须从这里取数,禁止各自写死数字: +/// 1. 本文件的提交校验(`normalize_resource_edit_prompt`); +/// 2. `agc_tools` MCP 工具层(`direct_tools_mcp.rs` 的参数校验与工具 schema); +/// 3. 客户端受控工具桥(`direct_tool_bridge.rs`)。 +/// +/// 客户端 UI 的 `resourceEditPromptMaxLength`(`resourceEditModel.ts`)是同一份口径的 +/// 前端镜像;改数字必须同时改这里、那里,以及工具 schema 里按 kind 声明 `maxLength`。 +pub(crate) fn resource_edit_prompt_max_chars(edit_kind: &LocalProjectResourceEditKind) -> usize { match edit_kind { LocalProjectResourceEditKind::BackgroundMusic => 140, LocalProjectResourceEditKind::SoundEffect => 1_900, @@ -739,6 +763,24 @@ fn resource_edit_prompt_max_chars(edit_kind: &LocalProjectResourceEditKind) -> u } } +/// 提示词超限的拒绝文案:与上限同一个口径,MCP 层、工具桥和提交校验复用同一条字符串, +/// 保证模型看到的数字就是真实生效的数字。 +pub(crate) fn resource_edit_prompt_limit_error( + edit_kind: &LocalProjectResourceEditKind, + max_chars: usize, +) -> String { + format!( + "{}资源编辑提示词必须在 1..={max_chars} 字符内", + match edit_kind { + LocalProjectResourceEditKind::BackgroundMusic => "背景音乐", + LocalProjectResourceEditKind::SoundEffect => "音效", + LocalProjectResourceEditKind::Video => "视频", + LocalProjectResourceEditKind::CharacterAnimation => "角色动画", + _ => "", + } + ) +} + fn normalize_resource_edit_prompt( edit_kind: &LocalProjectResourceEditKind, value: &str, @@ -746,16 +788,7 @@ fn normalize_resource_edit_prompt( let value = value.trim(); let max_chars = resource_edit_prompt_max_chars(edit_kind); if value.is_empty() || value.chars().count() > max_chars { - return Err(format!( - "{}资源编辑提示词必须在 1..={max_chars} 字符内", - match edit_kind { - LocalProjectResourceEditKind::BackgroundMusic => "背景音乐", - LocalProjectResourceEditKind::SoundEffect => "音效", - LocalProjectResourceEditKind::Video => "视频", - LocalProjectResourceEditKind::CharacterAnimation => "角色动画", - _ => "", - } - )); + return Err(resource_edit_prompt_limit_error(edit_kind, max_chars)); } if value .chars() @@ -811,7 +844,7 @@ fn resource_edit_request_fingerprint( prompt: &str, asset_name: &str, ) -> Result { - let payload = serde_json::to_vec(&serde_json::json!({ + let mut identity = serde_json::json!({ "schemaVersion": RESOURCE_EDIT_SCHEMA_VERSION, "projectId": input.expected_project_id, "operationId": input.operation_id, @@ -822,8 +855,13 @@ fn resource_edit_request_fingerprint( "sourceSha256": source.source_sha256, "prompt": prompt, "assetName": asset_name, - })) - .map_err(|error| format!("序列化资源编辑请求失败:{error}"))?; + }); + if input.edit_kind == LocalProjectResourceEditKind::BackgroundRemoval { + identity["backgroundMode"] = serde_json::json!(input.background_mode); + identity["screenColor"] = serde_json::json!(input.screen_color); + } + let payload = serde_json::to_vec(&identity) + .map_err(|error| format!("序列化资源编辑请求失败:{error}"))?; Ok(sha256_hex(&payload)) } @@ -833,7 +871,7 @@ fn legacy_resource_edit_request_fingerprint( prompt: &str, asset_name: &str, ) -> Result { - let payload = serde_json::to_vec(&serde_json::json!({ + let mut identity = serde_json::json!({ "schemaVersion": RESOURCE_EDIT_SCHEMA_VERSION, "projectId": input.expected_project_id, "expectedProjectRevision": input.expected_project_revision, @@ -844,8 +882,13 @@ fn legacy_resource_edit_request_fingerprint( "sourceSha256": source.source_sha256, "prompt": prompt, "assetName": asset_name, - })) - .map_err(|error| format!("序列化旧资源编辑请求失败:{error}"))?; + }); + if input.edit_kind == LocalProjectResourceEditKind::BackgroundRemoval { + identity["backgroundMode"] = serde_json::json!(input.background_mode); + identity["screenColor"] = serde_json::json!(input.screen_color); + } + let payload = serde_json::to_vec(&identity) + .map_err(|error| format!("序列化旧资源编辑请求失败:{error}"))?; Ok(sha256_hex(&payload)) } @@ -1088,9 +1131,21 @@ fn infer_resource_edit_source_media_type( .and_then(|value| value.to_str()) .map(str::to_ascii_lowercase); let media_type = match (edit_kind, extension.as_deref()) { - (LocalProjectResourceEditKind::ImageReference, Some("png")) => "image/png", - (LocalProjectResourceEditKind::ImageReference, Some("jpg" | "jpeg")) => "image/jpeg", - (LocalProjectResourceEditKind::ImageReference, Some("webp")) => "image/webp", + ( + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval, + Some("png"), + ) => "image/png", + ( + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval, + Some("jpg" | "jpeg"), + ) => "image/jpeg", + ( + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval, + Some("webp"), + ) => "image/webp", (LocalProjectResourceEditKind::Svg, Some("svg")) => "image/svg+xml", (LocalProjectResourceEditKind::Video, Some("mp4")) => "video/mp4", (LocalProjectResourceEditKind::Video, Some("webm")) => "video/webm", @@ -1127,7 +1182,8 @@ fn infer_resource_edit_source_media_type( fn infer_resource_edit_source_asset_kind(edit_kind: &LocalProjectResourceEditKind) -> String { match edit_kind { - LocalProjectResourceEditKind::ImageReference => "art-image", + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval => "art-image", LocalProjectResourceEditKind::Svg => "svg", LocalProjectResourceEditKind::CharacterAnimation => "character-animation", LocalProjectResourceEditKind::Video => "video", @@ -1355,8 +1411,11 @@ fn resolve_resource_edit_source( { return Err("音频编辑只能用于音频资源".to_string()); } - LocalProjectResourceEditKind::ImageReference if !lower_media_type.starts_with("image/") => { - return Err("图片参考编辑只能用于图片资源".to_string()); + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval + if !lower_media_type.starts_with("image/") => + { + return Err("此操作只能用于图片资源".to_string()); } _ => {} } @@ -2005,8 +2064,11 @@ fn validate_resource_edit_source_recovery_state( || (ledger.source_remote_asset_object_id.is_some() && !ledger.source_upload_completed) || (ledger.source_remote_resource_id.is_some() && ledger.source_remote_asset_object_id.is_none()) - || (input.edit_kind != LocalProjectResourceEditKind::ImageReference - && ledger.source_remote_resource_id.is_some()) + || (!matches!( + input.edit_kind, + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval + ) && ledger.source_remote_resource_id.is_some()) { return Err("result-unknown: 源资源远端恢复阶段不完整,禁止自动重放".to_string()); } @@ -2063,7 +2125,8 @@ async fn ensure_resource_edit_source_reference( &source_identity, )? { let stable_reference = match input.edit_kind { - LocalProjectResourceEditKind::ImageReference => binding + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval => binding .remote_resource_id .filter(|reference| is_registered_editor_reference_id(reference)), LocalProjectResourceEditKind::Video @@ -2170,45 +2233,48 @@ async fn ensure_resource_edit_source_reference( confirmed.value }; - let (stable_reference, remote_resource_id, width, height) = - if input.edit_kind == LocalProjectResourceEditKind::ImageReference { - let bytes = source - .bytes - .as_deref() - .ok_or_else(|| "登记源图片缺少文件内容".to_string())?; - let decoded = image::load_from_memory(bytes) - .map_err(|_| "登记源图片前无法解析图片尺寸".to_string())?; - let resource_id = if let Some(resource_id) = ledger.source_remote_resource_id.clone() { - resource_id - } else { - let registered = register_resource_edit_source_image( - client, - access, - source, - local_asset_id, - &binding_key, - &canvas_context.project_id, - &object_key, - &asset_object_id, - ) - .await?; - let resource_id = registered.value.0; - ledger.source_remote_resource_id = Some(resource_id.clone()); - write_resource_edit_source_stage(root, ledger, "源图片项目资源登记")?; - if let Some(error) = registered.post_response_session_error { - return Err(error); - } - resource_id - }; - ( - resource_id.clone(), - Some(resource_id), - Some(decoded.width()), - Some(decoded.height()), - ) + let (stable_reference, remote_resource_id, width, height) = if matches!( + input.edit_kind, + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval + ) { + let bytes = source + .bytes + .as_deref() + .ok_or_else(|| "登记源图片缺少文件内容".to_string())?; + let decoded = image::load_from_memory(bytes) + .map_err(|_| "登记源图片前无法解析图片尺寸".to_string())?; + let resource_id = if let Some(resource_id) = ledger.source_remote_resource_id.clone() { + resource_id } else { - (object_key.clone(), None, None, None) + let registered = register_resource_edit_source_image( + client, + access, + source, + local_asset_id, + &binding_key, + &canvas_context.project_id, + &object_key, + &asset_object_id, + ) + .await?; + let resource_id = registered.value.0; + ledger.source_remote_resource_id = Some(resource_id.clone()); + write_resource_edit_source_stage(root, ledger, "源图片项目资源登记")?; + if let Some(error) = registered.post_response_session_error { + return Err(error); + } + resource_id }; + ( + resource_id.clone(), + Some(resource_id), + Some(decoded.width()), + Some(decoded.height()), + ) + } else { + (object_key.clone(), None, None, None) + }; access.validate_frozen_session()?; let binding = new_external_editor_resource_binding( &input.expected_project_id, @@ -2312,6 +2378,28 @@ fn resource_edit_remote_request( "generationInputs": generation_inputs, }), )), + LocalProjectResourceEditKind::BackgroundRemoval => { + let background_mode = input.background_mode.as_deref().unwrap_or("complex"); + validate_background_removal_options(background_mode, input.screen_color.as_deref())?; + let mut body = serde_json::json!({ + "sourceImageSrc": source_reference + .ok_or_else(|| "抠图缺少正式源资源 ID".to_string())?, + "sourceResourceId": source_reference + .ok_or_else(|| "抠图缺少正式源资源 ID".to_string())?, + "assetKind": source.asset_kind, + "assetLabel": asset_name, + "backgroundMode": background_mode, + "generationInputs": generation_inputs, + }); + if let Some(screen_color) = input.screen_color.as_deref() { + body["screenColor"] = serde_json::json!(screen_color); + } + if let Some(context) = canvas_context { + body["projectId"] = serde_json::json!(context.project_id); + body["assetFolderId"] = serde_json::json!(context.asset_folder_id); + } + Ok(("/api/external/v1/editor/images/background-removals", body)) + } LocalProjectResourceEditKind::CharacterAnimation => { let mut body = serde_json::json!({ "sourceLayerId": format!("resource-{}", input.operation_id), @@ -2418,6 +2506,31 @@ fn resource_edit_remote_request( } } +fn validate_background_removal_options( + background_mode: &str, + screen_color: Option<&str>, +) -> Result<(), String> { + if !matches!(background_mode, "complex" | "flat") { + return Err("抠图 backgroundMode 必须是 complex 或 flat".to_string()); + } + if background_mode == "complex" && screen_color.is_some() { + return Err("complex 抠图不能携带 screenColor".to_string()); + } + if let Some(screen_color) = screen_color { + let valid_hex = screen_color.len() == 7 + && screen_color.starts_with('#') + && screen_color.as_bytes()[1..].iter().all(|byte| { + byte.is_ascii_digit() + || (b'a'..=b'f').contains(byte) + || (b'A'..=b'F').contains(byte) + }); + if screen_color != "auto" && !valid_hex { + return Err("flat 抠图 screenColor 必须是 auto 或 #RRGGBB".to_string()); + } + } + Ok(()) +} + fn resource_edit_operation_id(payload: &serde_json::Value) -> Option { let data = external_editor_response_data(payload); json_string_field(data, "operationId").or_else(|| { @@ -2446,11 +2559,13 @@ fn is_external_resource_edit_endpoint(endpoint: &str) -> bool { matches!( endpoint, "/api/editor/images/edits" + | "/api/editor/images/background-removals" | "/api/editor/character-animations/generations" | "/api/editor/videos/generations" | "/api/editor/audios/sound-effects/generations" | "/api/editor/audios/background-music/generations" | "/api/external/v1/editor/images/edits" + | "/api/external/v1/editor/images/background-removals" | "/api/external/v1/editor/character-animations/generations" | "/api/external/v1/editor/videos/generations" | "/api/external/v1/editor/audios/sound-effects/generations" @@ -2936,6 +3051,17 @@ fn validate_downloaded_media( let is_jpeg = starts(&[0xff, 0xd8, 0xff]); let is_webp = bytes.len() >= 12 && starts(b"RIFF") && &bytes[8..12] == b"WEBP"; match edit_kind { + LocalProjectResourceEditKind::BackgroundRemoval => { + if !is_png { + return Err("抠图结果必须是带透明通道的 PNG".to_string()); + } + let decoded = image::load_from_memory_with_format(bytes, image::ImageFormat::Png) + .map_err(|_| "抠图结果不是有效的 PNG".to_string())?; + if !decoded.color().has_alpha() { + return Err("抠图结果缺少透明通道".to_string()); + } + Ok(("image/png".to_string(), "png".to_string())) + } LocalProjectResourceEditKind::ImageReference => { if is_png { Ok(("image/png".to_string(), "png".to_string())) @@ -3228,6 +3354,7 @@ async fn prepare_remote_resource_edit( let prepared_source = if matches!( input.edit_kind, LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval | LocalProjectResourceEditKind::CharacterAnimation ) || (input.edit_kind == LocalProjectResourceEditKind::Video && input.generation_mode == LocalProjectResourceGenerationMode::Derive) @@ -3257,6 +3384,7 @@ async fn prepare_remote_resource_edit( } else if matches!( input.edit_kind, LocalProjectResourceEditKind::CharacterAnimation + | LocalProjectResourceEditKind::BackgroundRemoval | LocalProjectResourceEditKind::Video | LocalProjectResourceEditKind::SoundEffect | LocalProjectResourceEditKind::BackgroundMusic @@ -4389,14 +4517,7 @@ fn with_frozen_resource_edit_platform_session( let Some(platform_session) = platform_session else { return action(); }; - let access_token_sha256 = sha256_hex(platform_session.access_token.as_bytes()); - with_validated_platform_session_fingerprint( - &platform_session.user_id, - &platform_session.api_base_url, - platform_session.generation, - &access_token_sha256, - action, - ) + with_validated_platform_session_identity(&platform_session.identity(), action) } fn commit_resource_edit_asset_with_frozen_platform_session( @@ -4433,8 +4554,11 @@ fn write_resource_edit_result_binding( let Some(asset_object_id) = ledger.remote_asset_object_id.as_deref() else { return Ok(()); }; - if input.edit_kind == LocalProjectResourceEditKind::ImageReference - && ledger.remote_resource_id.is_none() + if matches!( + input.edit_kind, + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval + ) && ledger.remote_resource_id.is_none() { return Ok(()); } @@ -4444,7 +4568,11 @@ fn write_resource_edit_result_binding( media_read_limit(&input.edit_kind), "派生资源 binding 文件", )?; - let (width, height) = if input.edit_kind == LocalProjectResourceEditKind::ImageReference { + let (width, height) = if matches!( + input.edit_kind, + LocalProjectResourceEditKind::ImageReference + | LocalProjectResourceEditKind::BackgroundRemoval + ) { let decoded = image::load_from_memory(&bytes) .map_err(|_| "派生图片 binding 无法解析尺寸".to_string())?; (Some(decoded.width()), Some(decoded.height())) @@ -4764,6 +4892,17 @@ fn commit_resource_edit_version( pub(crate) fn list_pending_local_project_resource_edits_at( input: ListPendingLocalProjectResourceEditsInput, +) -> Result, String> { + let current_platform_session = current_platform_session(); + list_pending_local_project_resource_edits_for_session_at( + input, + current_platform_session.as_ref(), + ) +} + +pub(crate) fn list_pending_local_project_resource_edits_for_session_at( + input: ListPendingLocalProjectResourceEditsInput, + platform_session: Option<&PlatformSessionSnapshot>, ) -> Result, String> { let root = Path::new(input.project_path.trim()); validate_project_root(root)?; @@ -4771,17 +4910,9 @@ pub(crate) fn list_pending_local_project_resource_edits_at( if manifest.project_id != input.expected_project_id { return Err("project-identity-conflict".to_string()); } - let current_platform_session = current_platform_session(); - let _platform_session_lease = current_platform_session + let _platform_session_lease = platform_session .as_ref() - .map(|session| { - acquire_validated_platform_session_fingerprint( - &session.user_id, - &session.api_base_url, - session.generation, - &sha256_hex(session.access_token.as_bytes()), - ) - }) + .map(|session| acquire_platform_session_identity_lease(&session.identity())) .transpose()?; let directory = resolve_local_project_path(root, &format!("{RESOURCE_EDIT_ROOT}/operations"))?; let entries = match fs::read_dir(&directory) { @@ -4821,10 +4952,8 @@ pub(crate) fn list_pending_local_project_resource_edits_at( if !matches!( ledger.phase, ResourceEditLedgerPhase::Committed | ResourceEditLedgerPhase::Archived - ) && resource_edit_pending_is_visible_to_current_principal( - &ledger, - current_platform_session.as_ref(), - ) { + ) && resource_edit_pending_is_visible_to_current_principal(&ledger, platform_session) + { pending.push(PendingLocalProjectResourceEdit { operation_id: ledger.operation_id, edit_kind: ledger.edit_kind, @@ -4832,6 +4961,8 @@ pub(crate) fn list_pending_local_project_resource_edits_at( source_resource_id: ledger.source_resource_id, source_asset_id: ledger.source_asset_id, asset_name: ledger.asset_name, + background_mode: ledger.background_mode, + screen_color: ledger.screen_color, prompt_sha256: sha256_hex(ledger.prompt.as_bytes()), phase: ledger.phase.as_str().to_string(), created_at: ledger.created_at, @@ -5084,12 +5215,8 @@ pub(crate) async fn archive_failed_local_project_resource_edit_at( Ok(()) }; if let Some(session) = platform_session { - let access_token_sha256 = sha256_hex(session.access_token.as_bytes()); - crate::platform_session::with_validated_platform_session_fingerprint( - &session.user_id, - &session.api_base_url, - session.generation, - &access_token_sha256, + crate::platform_session::with_validated_platform_session_identity( + &session.identity(), archive, )?; } else { @@ -5186,6 +5313,8 @@ pub(crate) async fn resume_local_project_resource_edit_at( source_version_id, prompt: ledger.prompt, asset_name: ledger.asset_name, + background_mode: ledger.background_mode, + screen_color: ledger.screen_color, }) .await } @@ -5195,6 +5324,12 @@ pub(crate) async fn derive_local_project_resource_at( ) -> Result { validate_resource_edit_uuid(&input.operation_id, "operationId")?; validate_resource_edit_uuid(&input.idempotency_key, "idempotencyKey")?; + if input.edit_kind == LocalProjectResourceEditKind::BackgroundRemoval { + validate_background_removal_options( + input.background_mode.as_deref().unwrap_or("complex"), + input.screen_color.as_deref(), + )?; + } if input.expected_project_revision > 9_007_199_254_740_991 { return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string()); } @@ -5281,6 +5416,8 @@ pub(crate) async fn derive_local_project_resource_at( source_sha256: source.source_sha256.clone(), prompt: prompt.clone(), asset_name: asset_name.clone(), + background_mode: input.background_mode.clone(), + screen_color: input.screen_color.clone(), provider_request_issued_at: None, access_scheme: None, api_identity_scheme: None, @@ -5484,6 +5621,9 @@ pub(crate) async fn derive_local_project_resource_at( Ok(result) } +#[cfg(test)] +mod background_removal_tests; + #[cfg(test)] mod tests { use super::*; @@ -5500,7 +5640,8 @@ mod tests { user_id: "gui-owner".to_string(), access_token: "gui-token".to_string(), api_base_url: "https://dev.genarrative.world".to_string(), - generation: 7, + identity_generation: 7, + revision: 7, }; let developer_credentials = ( "https://dev.genarrative.world".to_string(), @@ -5911,6 +6052,7 @@ mod tests { "source-binding-token-b", api_base_url, *generation, + *generation, ) .expect("switch account after source registration"); } @@ -5961,6 +6103,8 @@ mod tests { source_version_id: None, prompt: "保留原意并补充红发角色设定".to_string(), asset_name: "规则编辑版".to_string(), + background_mode: None, + screen_color: None, } } @@ -5998,6 +6142,8 @@ mod tests { source_sha256: source.source_sha256.clone(), prompt: input.prompt.clone(), asset_name: input.asset_name.clone(), + background_mode: input.background_mode.clone(), + screen_color: input.screen_color.clone(), provider_request_issued_at: None, access_scheme: input .edit_kind @@ -6925,7 +7071,7 @@ mod tests { listener, upload_url, false, - Some((base_url.clone(), frozen_session.generation + 1)), + Some((base_url.clone(), frozen_session.identity_generation + 1)), done_receiver, ); let client = reqwest::Client::new(); @@ -6952,7 +7098,8 @@ mod tests { "source-binding-owner-a", "source-binding-token-a", &base_url, - frozen_session.generation + 2, + frozen_session.identity_generation + 2, + frozen_session.identity_generation + 2, ) .expect("switch back to source binding owner A"); let resumed_session = current_platform_session().expect("resumed source binding owner A"); @@ -7018,7 +7165,7 @@ mod tests { install_test_platform_session("submission-owner-a", "submission-token-a", &base_url); let frozen_session = current_platform_session().expect("frozen owner A session"); let switch_base_url = base_url.clone(); - let switch_generation = frozen_session.generation + 1; + let switch_generation = frozen_session.identity_generation + 1; let server = std::thread::spawn(move || { let mut stream = accept_resource_editor_fixture_connection(&listener, "accepted switch fixture", 0); @@ -7032,6 +7179,7 @@ mod tests { "submission-token-b", &switch_base_url, switch_generation, + switch_generation, ) .expect("switch to owner B before returning accepted response"); write_json( @@ -7447,8 +7595,14 @@ mod tests { ledger.access_scheme = None; initialize_resource_edit_access_identity(root, &mut ledger, base_url, Some(&frozen_a)) .expect("write resource ledger for owner A"); - replace_platform_session_for_gui_owner("resource-owner-b", "resource-token-b", base_url, 2) - .expect("switch global resource session to owner B"); + replace_platform_session_for_gui_owner( + "resource-owner-b", + "resource-token-b", + base_url, + 2, + 2, + ) + .expect("switch global resource session to owner B"); let error = prepare_resource_edit_service_identity( root, @@ -7509,7 +7663,8 @@ mod tests { user_id: "resource-identity-owner-b".to_string(), access_token: "resource-identity-token-b".to_string(), api_base_url: owner_a.api_base_url.clone(), - generation: owner_a.generation + 1, + identity_generation: owner_a.identity_generation + 1, + revision: owner_a.revision + 1, }; let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); ledger.access_scheme = Some(RESOURCE_EDIT_PLATFORM_ACCESS_SCHEME.to_string()); @@ -7535,7 +7690,8 @@ mod tests { &owner_b.user_id, &owner_b.access_token, &owner_b.api_base_url, - owner_b.generation, + owner_b.identity_generation, + owner_b.revision, ) .expect("switch to resource non-owner B"); @@ -7639,7 +7795,8 @@ mod tests { "resource-lease-owner-b", "resource-lease-token-b", api_base_url, - frozen_a.generation + 1, + frozen_a.identity_generation + 1, + frozen_a.revision + 1, ) .expect("switch resource lease owner"); switched_sender.send(()).expect("signal resource switch"); @@ -8259,7 +8416,8 @@ mod tests { "archive-owner-b", "archive-token-b", api_base_url, - owner_a.generation + 1, + owner_a.identity_generation + 1, + owner_a.revision + 1, ) .expect("switch to owner B"); let error = archive_failed_local_project_resource_edit_at( @@ -8388,7 +8546,8 @@ mod tests { "pending-owner-b", "pending-token-b", api_base_url, - owner_a.generation + 1, + owner_a.identity_generation + 1, + owner_a.revision + 1, ) .expect("switch to pending owner B"); let owner_b = current_platform_session().expect("pending owner B session"); @@ -8524,6 +8683,37 @@ mod tests { serde_json::json!("stable-image-reference") ); assert!(image_body.get("sourceImageSrc").is_none()); + let mut background = image; + background.edit_kind = LocalProjectResourceEditKind::BackgroundRemoval; + background.background_mode = Some("flat".to_string()); + background.screen_color = Some("auto".to_string()); + let (background_endpoint, background_body) = resource_edit_remote_request( + &background, + &ResourceEditSourceSnapshot { + media_type: "image/png".to_string(), + asset_kind: "art-image".to_string(), + source_path: Some("assets/source.png".to_string()), + bytes: Some(resource_editor_test_png()), + ..source.clone() + }, + "去除背景", + "透明图", + Some("stable-image-reference"), + Some(&ExternalCanvasGenerationContext { + project_id: "project-1".to_string(), + asset_folder_id: "folder-1".to_string(), + canvas_name: "测试画板".to_string(), + }), + ) + .expect("build background removal request"); + assert_eq!( + background_endpoint, + "/api/external/v1/editor/images/background-removals" + ); + assert_eq!(background_body["backgroundMode"], "flat"); + assert_eq!(background_body["screenColor"], "auto"); + assert_eq!(background_body["projectId"], "project-1"); + assert_eq!(background_body["assetFolderId"], "folder-1"); assert!(is_external_resource_edit_endpoint( "/api/editor/videos/generations" )); @@ -9347,7 +9537,7 @@ mod tests { let (attempted_sender, attempted_receiver) = mpsc::channel(); let (completed_sender, completed_receiver) = mpsc::channel(); let switch_api_base_url = api_base_url.to_string(); - let switch_generation = frozen_session.generation + 1; + let switch_generation = frozen_session.identity_generation + 1; let switch_thread = std::thread::spawn(move || { begin_switch_receiver .recv() @@ -9360,6 +9550,7 @@ mod tests { "commit-token-b", &switch_api_base_url, switch_generation, + switch_generation, ) .expect("switch to commit owner B"); completed_sender diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs new file mode 100644 index 000000000..aa731aeef --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs @@ -0,0 +1,725 @@ +use super::*; + +use std::io::{Cursor, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::{Arc, Mutex}; + +const TEST_PROJECT_ID: &str = "background-removal-project"; +const TEST_API_KEY: &str = "background-removal-key"; +const REMOTE_PROJECT_ID: &str = "background-removal-remote-project"; +const REMOTE_FOLDER_ID: &str = "background-removal-folder"; +const SOURCE_RESOURCE_ID: &str = "editor-resource-background-removal-source"; + +struct BackgroundRemovalFixture { + directory: tempfile::TempDir, + request: DeriveLocalProjectResourceInput, + source_asset_id: String, + source_bytes: Vec, +} + +#[tokio::test] +async fn background_removal_platform_account_uses_runtime_job_and_platform_read_url_routes() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind platform account server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("platform address") + ); + let _session_guard = install_test_platform_session( + "background-removal-owner", + "background-removal-platform-token", + &base_url, + ); + let session = current_platform_session().expect("platform account session"); + let fixture = background_removal_fixture_for_session(&base_url, Some(&session)); + let generated_png = test_png(); + let signed_url = format!("{base_url}/platform-result.png"); + let captured = Arc::new(Mutex::new(Vec::new())); + let server_captured = Arc::clone(&captured); + let server_png = generated_png.clone(); + let server = std::thread::spawn(move || { + for index in 0..6 { + let mut stream = accept_request(&listener, index); + let request = read_request(&mut stream); + server_captured + .lock() + .expect("capture requests") + .push(request.clone()); + if respond_canvas_context_request(&mut stream, &request) { + continue; + } + let line = request.lines().next().unwrap_or_default(); + if index != 5 { + assert!(request + .to_ascii_lowercase() + .contains("authorization: bearer background-removal-platform-token")); + } + match index { + 2 => { + assert!(line.starts_with("POST /api/editor/images/background-removals ")); + assert_submission(&request); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"queueState": { + "operationId": "platform-background-removal", "status": "queued", "pollAfterMs": 0 + }}}), + ); + } + 3 => { + assert!(line.starts_with( + "GET /api/runtime/external-generation/jobs/platform-background-removal " + )); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"job": { + "operationId": "platform-background-removal", "status": "completed", + "result": {"resource": { + "resourceId": "editor-resource-platform-background-removal", + "projectId": REMOTE_PROJECT_ID, + "objectKey": "generated/platform-background-removal.png", + "assetObjectId": "platform-background-removal-object" + }} + }}}), + ); + } + 4 => { + assert!(line.starts_with("GET /api/assets/read-url?")); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"read": {"signedUrl": signed_url}}}), + ); + } + 5 => { + assert!(line.starts_with("GET /platform-result.png ")); + write_bytes(&mut stream, "image/png", &server_png); + } + _ => unreachable!(), + } + } + }); + + let result = derive_local_project_resource_at(fixture.request.clone()) + .await + .expect("complete platform account background removal"); + server.join().expect("join platform account server"); + let requests = captured.lock().expect("read platform requests"); + assert_eq!( + requests + .iter() + .filter(|request| request.starts_with("POST /api/editor/images/background-removals ")) + .count(), + 1 + ); + assert_eq!( + fs::read( + fixture + .root() + .join(result.asset.expect("platform asset").local_path) + ) + .expect("read platform result"), + generated_png + ); +} + +impl BackgroundRemovalFixture { + fn root(&self) -> &Path { + self.directory.path() + } +} + +fn test_png() -> Vec { + let mut output = Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(2, 2) + .write_to(&mut output, image::ImageFormat::Png) + .expect("encode test PNG"); + output.into_inner() +} + +#[test] +fn background_removal_download_rejects_non_png_and_png_without_alpha() { + let malformed = validate_downloaded_media( + &LocalProjectResourceEditKind::BackgroundRemoval, + "image/png", + b"not-a-png", + ) + .expect_err("background removal must reject non-PNG bytes"); + assert!(malformed.contains("PNG")); + + let mut rgb_png = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgb8(image::RgbImage::new(2, 2)) + .write_to(&mut rgb_png, image::ImageFormat::Png) + .expect("encode RGB PNG"); + let missing_alpha = validate_downloaded_media( + &LocalProjectResourceEditKind::BackgroundRemoval, + "image/png", + rgb_png.get_ref(), + ) + .expect_err("background removal must reject PNG without alpha channel"); + assert!(missing_alpha.contains("透明通道")); +} + +fn background_removal_fixture(base_url: &str) -> BackgroundRemovalFixture { + background_removal_fixture_for_session(base_url, None) +} + +fn background_removal_fixture_for_session( + base_url: &str, + platform_session: Option<&PlatformSessionSnapshot>, +) -> BackgroundRemovalFixture { + let directory = tempfile::tempdir().expect("create background removal fixture"); + let root = directory.path(); + init_local_game_project_at(root, TEST_PROJECT_ID, "抠图账本闭环测试") + .expect("initialize local project"); + let source_bytes = test_png(); + let uploaded = upload_local_asset_at(root, "source.png", "image/png", &source_bytes) + .expect("register source image"); + let manifest = read_existing_manifest_for_project(root).expect("read source manifest"); + let source_asset = manifest + .assets + .iter() + .find(|asset| asset.id == uploaded.id) + .expect("find source asset") + .clone(); + let revision = read_game_creator_agent_runtime_project_revision(root) + .expect("read source project revision") + .revision; + let request = DeriveLocalProjectResourceInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: TEST_PROJECT_ID.to_string(), + expected_project_revision: revision, + operation_id: Uuid::new_v4().to_string(), + idempotency_key: Uuid::new_v4().to_string(), + edit_kind: LocalProjectResourceEditKind::BackgroundRemoval, + generation_mode: LocalProjectResourceGenerationMode::Derive, + source_resource_id: format!("local-asset:{}", source_asset.id), + source_asset_id: Some(source_asset.id.clone()), + source_path: Some(source_asset.local_path.clone()), + source_media_type: Some(source_asset.media_type.clone()), + source_subtype: Some(source_asset.kind.clone()), + producer_task_id: source_asset.source.task_id.clone(), + source_version_id: None, + prompt: "去除背景".to_string(), + asset_name: "透明角色".to_string(), + background_mode: Some("flat".to_string()), + screen_color: Some("auto".to_string()), + }; + let source = resolve_resource_edit_source(root, &manifest, &request) + .expect("resolve background removal source"); + let bearer_token = platform_session + .map(|session| session.access_token.as_str()) + .unwrap_or(TEST_API_KEY); + let access = ExternalEditorBindingAccess::new(base_url, bearer_token, platform_session) + .expect("create developer access"); + let principal = + external_editor_binding_principal(&access).expect("resolve developer principal"); + write_external_editor_project_binding_at( + root, + &new_external_editor_project_binding( + TEST_PROJECT_ID, + &principal, + REMOTE_PROJECT_ID, + REMOTE_FOLDER_ID, + unix_timestamp(), + ) + .expect("build project binding"), + ) + .expect("write project binding"); + let source_identity = new_external_editor_source_identity( + &source_asset.id, + &source.source_sha256, + &source.media_type, + &source.asset_kind, + ) + .expect("build source identity"); + write_external_editor_resource_binding_at( + root, + &new_external_editor_resource_binding( + TEST_PROJECT_ID, + &principal, + REMOTE_PROJECT_ID, + &source_identity, + Some(SOURCE_RESOURCE_ID), + "registered/source.png", + "registered-source-object", + Some(2), + Some(2), + unix_timestamp(), + ) + .expect("build source binding"), + ) + .expect("write source binding"); + BackgroundRemovalFixture { + directory, + request, + source_asset_id: source_asset.id, + source_bytes, + } +} + +async fn with_test_credentials( + base_url: &str, + operation: impl std::future::Future, +) -> T { + let _platform_session_guard = clear_test_platform_session(); + crate::assets::with_external_editor_api_credentials( + crate::assets::external_editor_api_credentials_for_test( + base_url.to_string(), + TEST_API_KEY.to_string(), + ), + operation, + ) + .await +} + +fn accept_request(listener: &TcpListener, index: usize) -> TcpStream { + listener + .set_nonblocking(true) + .expect("set background removal listener nonblocking"); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + match listener.accept() { + Ok((stream, _)) => { + stream + .set_nonblocking(false) + .expect("restore background removal stream blocking mode"); + return stream; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for background removal request {index}" + ); + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("accept background removal request {index}: {error}"), + } + } +} + +fn read_request(stream: &mut TcpStream) -> String { + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("set request read timeout"); + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 4096]; + let (header_end, content_length) = loop { + let read = stream.read(&mut buffer).expect("read HTTP request"); + assert!(read > 0, "request closed before headers"); + bytes.extend_from_slice(&buffer[..read]); + let Some(header_end) = bytes.windows(4).position(|part| part == b"\r\n\r\n") else { + continue; + }; + let headers = String::from_utf8_lossy(&bytes[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + break (header_end + 4, content_length); + }; + while bytes.len() < header_end + content_length { + let read = stream.read(&mut buffer).expect("read HTTP request body"); + assert!(read > 0, "request closed before body"); + bytes.extend_from_slice(&buffer[..read]); + } + String::from_utf8_lossy(&bytes).into_owned() +} + +fn write_json(stream: &mut TcpStream, status: &str, value: serde_json::Value) { + let body = value.to_string(); + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .expect("write JSON response"); +} + +fn write_bytes(stream: &mut TcpStream, media_type: &str, bytes: &[u8]) { + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: {media_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + bytes.len() + ) + .expect("write media headers"); + stream.write_all(bytes).expect("write media body"); +} + +fn completed_status(operation_id: &str) -> serde_json::Value { + serde_json::json!({"data": { + "operationId": operation_id, + "status": "completed", + "result": {"resource": { + "resourceId": "editor-resource-background-removal-result", + "projectId": REMOTE_PROJECT_ID, + "objectKey": "generated/background-removal.png", + "assetObjectId": "background-removal-result-object" + }} + }}) +} + +fn assert_submission(request: &str) { + assert!( + request.starts_with("POST /api/editor/images/background-removals ") + || request.starts_with("POST /api/external/v1/editor/images/background-removals ") + ); + let lower = request.to_ascii_lowercase(); + assert!(lower.contains("idempotency-key:")); + let body = request.split("\r\n\r\n").nth(1).expect("submission body"); + let payload: serde_json::Value = serde_json::from_str(body).expect("parse submission body"); + assert_eq!(payload["sourceImageSrc"], SOURCE_RESOURCE_ID); + assert_eq!(payload["sourceResourceId"], SOURCE_RESOURCE_ID); + assert_eq!(payload["projectId"], REMOTE_PROJECT_ID); + assert_eq!(payload["assetFolderId"], REMOTE_FOLDER_ID); + assert_eq!(payload["backgroundMode"], "flat"); + assert_eq!(payload["screenColor"], "auto"); +} + +fn assert_developer_submission(request: &str) { + assert!(request.starts_with("POST /api/external/v1/editor/images/background-removals ")); + assert!(request + .to_ascii_lowercase() + .contains("authorization: bearer background-removal-key")); + assert_submission(request); +} + +fn respond_canvas_context_request(stream: &mut TcpStream, request: &str) -> bool { + let line = request.lines().next().unwrap_or_default(); + if line.starts_with("GET /api/external/v1/editor/projects ") + || line.starts_with("GET /api/editor/projects ") + { + write_json( + stream, + "200 OK", + serde_json::json!({"data": {"projects": [{"projectId": REMOTE_PROJECT_ID}]}}), + ); + true + } else if line.starts_with("GET /api/external/v1/editor/assets/library ") + || line.starts_with("GET /api/editor/assets/library ") + { + write_json( + stream, + "200 OK", + serde_json::json!({"data": {"library": {"folders": [{"folderId": REMOTE_FOLDER_ID}]}}}), + ); + true + } else { + false + } +} + +#[tokio::test] +async fn background_removal_derives_through_poll_download_and_manifest_commit() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind background removal server"); + let base_url = format!("http://{}", listener.local_addr().expect("server address")); + let fixture = background_removal_fixture(&base_url); + let generated_png = test_png(); + let signed_url = format!("{base_url}/generated.png"); + let server_png = generated_png.clone(); + let server_signed_url = signed_url.clone(); + let requests = Arc::new(Mutex::new(Vec::new())); + let server_requests = Arc::clone(&requests); + let server = std::thread::spawn(move || { + for index in 0..8 { + let mut stream = accept_request(&listener, index); + let request = read_request(&mut stream); + server_requests + .lock() + .expect("capture requests") + .push(request.clone()); + let line = request.lines().next().unwrap_or_default(); + if respond_canvas_context_request(&mut stream, &request) { + continue; + } + match index { + 2 => { + assert_developer_submission(&request); + write_json( + &mut stream, + "202 Accepted", + serde_json::json!({"data": { + "operationId": "background-removal-operation", + "status": "queued", + "pollAfterMs": 0 + }}), + ); + } + 3 => write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": { + "operationId": "background-removal-operation", "status": "queued", "pollAfterMs": 0 + }}), + ), + 4 => write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": { + "operationId": "background-removal-operation", "status": "running", "pollAfterMs": 0 + }}), + ), + 5 => write_json( + &mut stream, + "200 OK", + completed_status("background-removal-operation"), + ), + 6 => { + assert!(line.starts_with("GET /api/external/v1/assets/read-url?")); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"read": { + "signedUrl": server_signed_url + }}}), + ); + } + 7 => { + assert!(line.starts_with("GET /generated.png ")); + write_bytes(&mut stream, "image/png", &server_png); + } + _ => unreachable!(), + } + } + }); + + let result = with_test_credentials( + &base_url, + derive_local_project_resource_at(fixture.request.clone()), + ) + .await + .expect("complete background removal"); + server + .join() + .expect("join complete background removal server"); + let captured = requests.lock().expect("read captured requests"); + assert_eq!(captured.len(), 8); + assert_eq!( + captured + .iter() + .filter(|request| request.starts_with("POST ")) + .count(), + 1 + ); + let derived = result.asset.expect("derived background removal asset"); + assert_ne!(derived.id, fixture.source_asset_id); + assert_eq!( + fs::read(fixture.root().join(&derived.local_path)).expect("read result"), + generated_png + ); + assert_eq!( + fs::read( + fixture.root().join( + result + .manifest + .assets + .iter() + .find(|asset| asset.id == fixture.source_asset_id) + .expect("source preserved") + .local_path + .clone() + ) + ) + .expect("read source"), + fixture.source_bytes + ); + let ledger = read_resource_edit_ledger(fixture.root(), &fixture.request.operation_id) + .expect("read ledger") + .expect("ledger exists"); + assert_eq!(ledger.phase, ResourceEditLedgerPhase::Committed); + assert_eq!( + ledger.remote_canvas_project_id.as_deref(), + Some(REMOTE_PROJECT_ID) + ); +} + +#[tokio::test] +async fn background_removal_resumes_accepted_operation_without_reposting() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind resume server"); + let base_url = format!("http://{}", listener.local_addr().expect("server address")); + let fixture = background_removal_fixture(&base_url); + let generated_png = test_png(); + let signed_url = format!("{base_url}/resumed.png"); + let captured = Arc::new(Mutex::new(Vec::new())); + let server_captured = Arc::clone(&captured); + let server_png = generated_png.clone(); + let server = std::thread::spawn(move || { + for index in 0..7 { + let mut stream = accept_request(&listener, index); + let request = read_request(&mut stream); + server_captured + .lock() + .expect("capture resume requests") + .push(request.clone()); + let line = request.lines().next().unwrap_or_default(); + if respond_canvas_context_request(&mut stream, &request) { + continue; + } + match index { + 2 => { + assert_developer_submission(&request); + write_json( + &mut stream, + "202 Accepted", + serde_json::json!({"data": { + "operationId": "resume-background-removal", "status": "queued", "pollAfterMs": 0 + }}), + ); + } + 3 => write_json( + &mut stream, + "500 Internal Server Error", + serde_json::json!({"error": "interrupted"}), + ), + 4 => write_json( + &mut stream, + "200 OK", + completed_status("resume-background-removal"), + ), + 5 => { + assert!(line.starts_with("GET /api/external/v1/assets/read-url?")); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"read": {"signedUrl": signed_url}}}), + ); + } + 6 => { + assert!(line.starts_with("GET /resumed.png ")); + write_bytes(&mut stream, "image/png", &server_png); + } + _ => unreachable!(), + } + } + }); + + let first_error = with_test_credentials( + &base_url, + derive_local_project_resource_at(fixture.request.clone()), + ) + .await + .expect_err("first polling attempt is interrupted"); + assert!(first_error.contains("result-unknown")); + let accepted = read_resource_edit_ledger(fixture.root(), &fixture.request.operation_id) + .expect("read accepted ledger") + .expect("accepted ledger exists"); + assert_eq!(accepted.phase, ResourceEditLedgerPhase::Accepted); + assert_eq!( + accepted.remote_operation_id.as_deref(), + Some("resume-background-removal") + ); + let pending = list_pending_local_project_resource_edits_for_session_at( + ListPendingLocalProjectResourceEditsInput { + project_path: fixture.root().to_string_lossy().into_owned(), + expected_project_id: TEST_PROJECT_ID.to_string(), + }, + None, + ) + .expect("list developer pending operations"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].operation_id, fixture.request.operation_id); + assert_eq!(pending[0].background_mode.as_deref(), Some("flat")); + assert_eq!(pending[0].screen_color.as_deref(), Some("auto")); + let result = with_test_credentials( + &base_url, + resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput { + project_path: fixture.root().to_string_lossy().into_owned(), + expected_project_id: TEST_PROJECT_ID.to_string(), + operation_id: fixture.request.operation_id.clone(), + }), + ) + .await + .expect("resume accepted background removal"); + server.join().expect("join resume server"); + let captured = captured.lock().expect("read resume requests"); + assert_eq!( + captured + .iter() + .filter(|request| request.starts_with("POST ")) + .count(), + 1 + ); + assert_eq!(result.manifest.assets.len(), 2); + assert!(list_pending_local_project_resource_edits_for_session_at( + ListPendingLocalProjectResourceEditsInput { + project_path: fixture.root().to_string_lossy().into_owned(), + expected_project_id: TEST_PROJECT_ID.to_string(), + }, + None, + ) + .expect("list pending after resume") + .is_empty()); + assert_eq!( + fs::read( + fixture + .root() + .join(result.asset.expect("resumed asset").local_path) + ) + .expect("read resumed result"), + generated_png + ); +} + +#[tokio::test] +async fn background_removal_remote_failure_keeps_manifest_without_result() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind failure server"); + let base_url = format!("http://{}", listener.local_addr().expect("server address")); + let fixture = background_removal_fixture(&base_url); + let server = std::thread::spawn(move || { + for index in 0..4 { + let mut stream = accept_request(&listener, index); + let request = read_request(&mut stream); + if respond_canvas_context_request(&mut stream, &request) { + continue; + } + if index == 2 { + assert_developer_submission(&request); + write_json( + &mut stream, + "202 Accepted", + serde_json::json!({"data": { + "operationId": "failed-background-removal", "status": "queued", "pollAfterMs": 0 + }}), + ); + } else { + assert!(request + .starts_with("GET /api/external/v1/generations/failed-background-removal ")); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": { + "operationId": "failed-background-removal", "status": "failed" + }}), + ); + } + } + }); + + let error = with_test_credentials( + &base_url, + derive_local_project_resource_at(fixture.request.clone()), + ) + .await + .expect_err("remote failure must be returned"); + server.join().expect("join failure server"); + assert!(error.contains("remote-terminal-failed")); + let manifest = + read_existing_manifest_for_project(fixture.root()).expect("read unchanged manifest"); + assert_eq!(manifest.assets.len(), 1); + assert_eq!(manifest.assets[0].id, fixture.source_asset_id); + let ledger = read_resource_edit_ledger(fixture.root(), &fixture.request.operation_id) + .expect("read failed ledger") + .expect("failed ledger exists"); + assert_eq!(ledger.phase, ResourceEditLedgerPhase::RemoteFailed); + assert!(ledger.result_asset_id.is_none()); + assert!( + read_optional_resource_edit_staging(fixture.root(), &fixture.request.operation_id) + .expect("read absent staging") + .is_none() + ); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/diff.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/diff.rs new file mode 100644 index 000000000..c6de3f6f1 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/diff.rs @@ -0,0 +1,151 @@ +use super::*; +use std::collections::BTreeSet; + +/// 需要上传的一个文件。摘要与字节数来自本次差异对比,上传阶段不再重新计算。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ProjectSnapshotUploadCandidate { + pub(crate) relative_path: String, + pub(crate) size_bytes: u64, + pub(crate) modified_ms: u64, + pub(crate) checksum: String, + pub(crate) absolute_path: PathBuf, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct ProjectSnapshotDiff { + pub(crate) uploads: Vec, + /// 本地已删除、只在远端清单中需要消失的路径。 + pub(crate) deleted: Vec, + /// 内容与上次同步一致、只有元数据变化的路径;只需刷新本地索引。 + pub(crate) metadata_only: Vec, + pub(crate) skipped: Vec, + /// 本轮因为累计上限没有上传、留给下一次同步的路径。 + pub(crate) deferred: Vec, + /// 同步期间被改写、本轮不参与上传与索引推进的路径。 + pub(crate) pending: Vec, + pub(crate) upload_bytes: u64, + /// 扫描后的完整清单(尚未扣除上传失败与延后项),成功同步后就是新的索引。 + pub(crate) current: BTreeMap, +} + +impl ProjectSnapshotDiff { + pub(crate) fn has_changes(&self) -> bool { + !self.uploads.is_empty() || !self.deleted.is_empty() || !self.metadata_only.is_empty() + } +} + +/// 增量差异对比:先用 `(字节数, 修改时间)` 判定是否需要读盘,只有元数据变化时 +/// 才重新计算摘要,再用摘要判断内容是否真的变了。 +pub(crate) fn compute_project_snapshot_diff( + scan: &ProjectSnapshotScanResult, + previous: &BTreeMap, + max_sync_bytes: u64, +) -> Result { + let mut diff = ProjectSnapshotDiff { + skipped: scan.skipped.clone(), + ..ProjectSnapshotDiff::default() + }; + let mut metadata_only = Vec::new(); + + for file in &scan.files { + let previous_entry = previous.get(&file.relative_path); + if let Some(entry) = previous_entry { + if entry.size_bytes == file.size_bytes + && entry.modified_ms == file.modified_ms + && !entry.checksum.is_empty() + { + diff.current + .insert(file.relative_path.clone(), entry.clone()); + continue; + } + } + + let bytes = read_project_snapshot_file_bytes(&file.absolute_path)?; + // 同步不持项目写锁:读完立刻复核一次,文件在读取期间被改写就留给下一轮, + // 既不把这份内容写进索引,也不上传它。 + if !project_snapshot_file_matches(&file.absolute_path, file.size_bytes, file.modified_ms) { + diff.pending.push(ProjectSnapshotSkippedPath { + relative_path: file.relative_path.clone(), + reason: "同步期间文件发生变化,留到下一次".to_string(), + }); + // 上一轮已同步过这条路径时沿用旧记录:文件仍然存在,只是这一轮读不到 + // 一致版本,所以既不能算删除(会被远端 GC 掉),也不能记成本轮已同步。 + if let Some(entry) = previous_entry { + diff.current + .insert(file.relative_path.clone(), entry.clone()); + } + continue; + } + let checksum = project_snapshot_checksum(&bytes); + match previous_entry { + Some(entry) if entry.checksum == checksum => { + metadata_only.push(file.relative_path.clone()) + } + _ => diff.uploads.push(ProjectSnapshotUploadCandidate { + relative_path: file.relative_path.clone(), + size_bytes: file.size_bytes, + modified_ms: file.modified_ms, + checksum: checksum.clone(), + absolute_path: file.absolute_path.clone(), + }), + } + diff.current.insert( + file.relative_path.clone(), + ProjectSnapshotIndexedFile { + size_bytes: file.size_bytes, + modified_ms: file.modified_ms, + checksum, + }, + ); + } + + diff.deleted = previous + .keys() + .filter(|relative_path| !diff.current.contains_key(*relative_path)) + .cloned() + .collect(); + diff.deleted.sort(); + diff.metadata_only = metadata_only; + + apply_project_snapshot_sync_budget(&mut diff, max_sync_bytes); + Ok(diff) +} + +/// 按路径顺序累计上传体积;超出上限的文件进入延后清单,不静默截断也不上传残缺内容。 +fn apply_project_snapshot_sync_budget(diff: &mut ProjectSnapshotDiff, max_sync_bytes: u64) { + let mut accepted = Vec::new(); + let mut deferred = Vec::new(); + let mut bytes = 0_u64; + for candidate in diff.uploads.drain(..) { + if bytes.saturating_add(candidate.size_bytes) > max_sync_bytes { + deferred.push(ProjectSnapshotSkippedPath { + relative_path: candidate.relative_path, + reason: format!("单次同步超过 {max_sync_bytes} 字节上限,留到下一次"), + }); + continue; + } + bytes = bytes.saturating_add(candidate.size_bytes); + accepted.push(candidate); + } + diff.upload_bytes = bytes; + diff.uploads = accepted; + diff.deferred = deferred; +} + +/// 成功同步后的索引清单:延后项与上传失败项都要剔除,否则下次同步会把它们 +/// 当成"已经同步过"而漏传。 +pub(crate) fn build_project_snapshot_synced_files( + diff: &ProjectSnapshotDiff, + uploaded_paths: &BTreeSet, +) -> BTreeMap { + let mut retained = diff.current.clone(); + for candidate in &diff.uploads { + if !uploaded_paths.contains(&candidate.relative_path) { + retained.remove(&candidate.relative_path); + } + } + for deferred in &diff.deferred { + retained.remove(&deferred.relative_path); + } + retained +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs new file mode 100644 index 000000000..f81f9e747 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs @@ -0,0 +1,131 @@ +use super::*; + +/// 本地增量索引里的一条文件记录。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectSnapshotIndexedFile { + pub(crate) size_bytes: u64, + pub(crate) modified_ms: u64, + pub(crate) checksum: String, +} + +/// 上次成功同步的快照。索引只在本机 AppData 中,不进入用户项目目录。 +/// +/// `user_id` 是远端前缀的一部分:换号后旧索引不再代表同一个远端命名空间, +/// 因此读取时按用户身份判等,不一致就当作冷启动重新全量对比。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectSnapshotIndex { + pub(crate) schema_version: u32, + pub(crate) project_id: String, + pub(crate) user_id: String, + pub(crate) sync_revision: u64, + pub(crate) synced_at_ms: u64, + #[serde(default)] + pub(crate) files: BTreeMap, +} + +pub(crate) fn empty_project_snapshot_index( + project_id: &str, + user_id: &str, +) -> ProjectSnapshotIndex { + ProjectSnapshotIndex { + schema_version: PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION, + project_id: project_id.to_string(), + user_id: user_id.to_string(), + sync_revision: 0, + synced_at_ms: 0, + files: BTreeMap::new(), + } +} + +/// 项目 ID 同时用作 AppData 目录名与远端键段:这里额外拒绝 `.` 与 `..`,避免 +/// 把索引写到目录之外。 +pub(crate) fn validate_project_snapshot_project_id(project_id: &str) -> Result { + shared_contracts::agc_project_snapshots::validate_agc_project_snapshot_project_id(project_id)?; + if project_id == "." || project_id == ".." { + return Err("项目 ID 不能是相对目录".to_string()); + } + Ok(project_id.to_string()) +} + +pub(crate) fn project_snapshot_index_directory(project_id: &str) -> Result { + let config_dir = game_creator_runtime_config_dir() + .ok_or_else(|| "客户端 AppData 配置目录未初始化".to_string())?; + project_snapshot_index_directory_at(&config_dir, project_id) +} + +pub(crate) fn project_snapshot_index_directory_at( + config_dir: &Path, + project_id: &str, +) -> Result { + let project_id = validate_project_snapshot_project_id(project_id)?; + Ok(config_dir.join(PROJECT_SNAPSHOT_DIRECTORY).join(project_id)) +} + +pub(crate) fn project_snapshot_index_path(project_id: &str) -> Result { + Ok(project_snapshot_index_path_at( + &project_snapshot_index_directory(project_id)?, + )) +} + +pub(crate) fn project_snapshot_index_path_at(directory: &Path) -> PathBuf { + directory.join(PROJECT_SNAPSHOT_INDEX_FILE_NAME) +} + +/// 读取本地索引。索引不存在、版本不符或内容损坏时返回空索引:这种情况下 +/// 全量对比会重新上传所有文件,不会漏传,也不会因为坏索引中断同步。 +pub(crate) fn read_project_snapshot_index( + project_id: &str, +) -> Result { + read_project_snapshot_index_at(&project_snapshot_index_directory(project_id)?, project_id) +} + +pub(crate) fn read_project_snapshot_index_at( + directory: &Path, + project_id: &str, +) -> Result { + let path = project_snapshot_index_path_at(directory); + if !path.exists() { + return Ok(empty_project_snapshot_index(project_id, "")); + } + let content = match read_game_creator_private_file_to_string( + &path, + "项目快照索引", + PROJECT_SNAPSHOT_INDEX_MAX_BYTES, + ) { + Ok(content) => content, + Err(error) => { + app_log!("project_snapshot.index.read.failed: {error}"); + return Ok(empty_project_snapshot_index(project_id, "")); + } + }; + match serde_json::from_str::(&content) { + Ok(index) + if index.schema_version == PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION + && index.project_id == project_id => + { + Ok(index) + } + Ok(_) => Ok(empty_project_snapshot_index(project_id, "")), + Err(error) => { + app_log!("project_snapshot.index.parse.failed: {error}"); + Ok(empty_project_snapshot_index(project_id, "")) + } + } +} + +pub(crate) fn write_project_snapshot_index(index: &ProjectSnapshotIndex) -> Result<(), String> { + write_project_snapshot_index_at(&project_snapshot_index_directory(&index.project_id)?, index) +} + +pub(crate) fn write_project_snapshot_index_at( + directory: &Path, + index: &ProjectSnapshotIndex, +) -> Result<(), String> { + let path = project_snapshot_index_path_at(directory); + let mut encoded = serde_json::to_vec_pretty(index) + .map_err(|error| format!("序列化项目快照索引失败:{error}"))?; + encoded.push(b'\n'); + write_game_creator_private_file(&path, &encoded, "项目快照索引") +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs new file mode 100644 index 000000000..bcdf2ce21 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs @@ -0,0 +1,547 @@ +//! AGC 项目定时快照上传。 +//! +//! 客户端在这里负责三件事:按项目目录扫描与增量差异对比、按触发时机编排上传、 +//! 把已成功同步的清单写回本机索引。远端对象键与存储凭据由 api-server 决定, +//! 客户端不持有 OSS 凭据,也不直连 OSS。 + +use super::*; +use std::collections::BTreeSet; +use std::sync::{Condvar, MutexGuard}; +use std::time::Instant; + +mod diff; +mod index; +mod scan; +mod transport; + +#[cfg(test)] +mod tests; + +pub(crate) use diff::*; +pub(crate) use index::*; +pub(crate) use scan::*; +pub(crate) use transport::*; + +/// 本机项目快照索引在 AppData 配置目录下的位置。 +pub(crate) const PROJECT_SNAPSHOT_DIRECTORY: &str = "project-snapshots"; +const PROJECT_SNAPSHOT_INDEX_FILE_NAME: &str = "index.json"; +const PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION: u32 = 1; +const PROJECT_SNAPSHOT_INDEX_MAX_BYTES: u64 = 32 * 1024 * 1024; + +pub(crate) const PROJECT_SNAPSHOT_MAX_FILE_BYTES: u64 = + shared_contracts::agc_project_snapshots::AGC_PROJECT_SNAPSHOT_MAX_FILE_BYTES; +pub(crate) const PROJECT_SNAPSHOT_MAX_SYNC_BYTES: u64 = + shared_contracts::agc_project_snapshots::AGC_PROJECT_SNAPSHOT_MAX_SYNC_BYTES; +/// 单项目常驻占用上限。超过时不再尝试上传,直接给出明确失败而不是反复被服务端拒绝。 +pub(crate) const PROJECT_SNAPSHOT_MAX_PROJECT_BYTES: u64 = + shared_contracts::agc_project_snapshots::AGC_PROJECT_SNAPSHOT_MAX_PROJECT_BYTES; + +const PROJECT_SNAPSHOT_FILES_ENDPOINT: &str = "/api/agc/project-snapshots/files"; +const PROJECT_SNAPSHOT_MANIFEST_ENDPOINT: &str = "/api/agc/project-snapshots/manifest"; + +/// 单个请求的完成上限;关闭与退出路径依赖它保持有界。 +const PROJECT_SNAPSHOT_REQUEST_TIMEOUT_SECONDS: u64 = 60; +/// 应用退出前等待在途同步完成的上限。 +const PROJECT_SNAPSHOT_EXIT_BUDGET_SECONDS: u64 = 15; +const PROJECT_SNAPSHOT_DEFAULT_SYNC_INTERVAL_SECONDS: u64 = 300; +const PROJECT_SNAPSHOT_SYNC_INTERVAL_ENV: &str = + "GENARRATIVE_AGC_PROJECT_SNAPSHOT_SYNC_INTERVAL_SECONDS"; +const PROJECT_SNAPSHOT_DISABLED_ENV: &str = "GENARRATIVE_AGC_PROJECT_SNAPSHOT_DISABLED"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ProjectSnapshotSyncTrigger { + Periodic, + ProjectClose, + Manual, +} + +impl ProjectSnapshotSyncTrigger { + fn as_str(self) -> &'static str { + match self { + Self::Periodic => "periodic", + Self::ProjectClose => "project-close", + Self::Manual => "manual", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectSnapshotFailureView { + pub(crate) relative_path: String, + pub(crate) code: String, + pub(crate) detail: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectSnapshotSyncReport { + pub(crate) project_id: String, + pub(crate) trigger: String, + /// `synced` 全部成功,`partial` 部分成功,`failed` 全部失败,`no-op` 无改动。 + pub(crate) status: String, + pub(crate) sync_revision: u64, + pub(crate) uploaded_files: usize, + pub(crate) uploaded_bytes: u64, + pub(crate) remote_skipped_files: usize, + pub(crate) deleted_files: usize, + pub(crate) deferred_files: usize, + pub(crate) metadata_only_files: usize, + pub(crate) skipped_files: Vec, + /// 同步期间被改写、本轮未参与上传与索引推进的路径。 + pub(crate) pending_files: Vec, + pub(crate) failed_files: Vec, + pub(crate) synced_at_ms: u64, +} + +/// 同步开关。停用时不发起任何请求,也不推进索引。 +pub(crate) fn project_snapshot_sync_enabled() -> bool { + match std::env::var_os(PROJECT_SNAPSHOT_DISABLED_ENV) { + Some(value) => { + let value = value.to_string_lossy().trim().to_ascii_lowercase(); + !matches!(value.as_str(), "1" | "true" | "yes" | "on") + } + None => true, + } +} + +pub(crate) fn project_snapshot_sync_interval() -> Duration { + let seconds = std::env::var(PROJECT_SNAPSHOT_SYNC_INTERVAL_ENV) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|seconds| *seconds > 0) + .unwrap_or(PROJECT_SNAPSHOT_DEFAULT_SYNC_INTERVAL_SECONDS); + Duration::from_secs(seconds) +} + +/// 同一项目的同步串行执行;周期触发遇到在途同步直接让位,不排队堆积。 +static PROJECT_SNAPSHOT_PROJECT_LOCKS: OnceLock>>>> = + OnceLock::new(); + +struct ProjectSnapshotInFlight { + active: Mutex, + idle: Condvar, +} + +static PROJECT_SNAPSHOT_IN_FLIGHT: OnceLock = OnceLock::new(); + +fn project_snapshot_project_locks() -> &'static Mutex>>> { + PROJECT_SNAPSHOT_PROJECT_LOCKS.get_or_init(|| Mutex::new(BTreeMap::new())) +} + +fn project_snapshot_in_flight() -> &'static ProjectSnapshotInFlight { + PROJECT_SNAPSHOT_IN_FLIGHT.get_or_init(|| ProjectSnapshotInFlight { + active: Mutex::new(0), + idle: Condvar::new(), + }) +} + +fn project_snapshot_project_lock(key: &str) -> Arc> { + let mut locks = project_snapshot_project_locks() + .lock() + .expect("project snapshot lock registry"); + locks + .entry(key.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() +} + +struct ProjectSnapshotInFlightGuard; + +impl ProjectSnapshotInFlightGuard { + fn begin() -> Self { + let state = project_snapshot_in_flight(); + let mut active = state + .active + .lock() + .expect("project snapshot in-flight lock"); + *active += 1; + Self + } +} + +impl Drop for ProjectSnapshotInFlightGuard { + fn drop(&mut self) { + let state = project_snapshot_in_flight(); + if let Ok(mut active) = state.active.lock() { + *active = active.saturating_sub(1); + if *active == 0 { + state.idle.notify_all(); + } + } + } +} + +/// 周期触发:已有同项目同步在途时返回 `None`。 +pub(crate) fn try_run_project_snapshot_sync(key: &str, run: impl FnOnce() -> T) -> Option { + let lock = project_snapshot_project_lock(key); + let guard = lock.try_lock().ok()?; + let _in_flight = ProjectSnapshotInFlightGuard::begin(); + let result = run(); + drop(guard); + Some(result) +} + +/// 关闭与手动触发:等待在途同步结束后再执行一次,保证最后一次状态被记录。 +pub(crate) fn run_project_snapshot_sync(key: &str, run: impl FnOnce() -> T) -> T { + let lock = project_snapshot_project_lock(key); + let guard: MutexGuard<'_, ()> = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let _in_flight = ProjectSnapshotInFlightGuard::begin(); + let result = run(); + drop(guard); + result +} + +/// 退出前等待在途同步收尾。返回是否在预算内全部结束。 +pub(crate) fn wait_for_project_snapshot_syncs(timeout: Duration) -> bool { + let state = project_snapshot_in_flight(); + let deadline = Instant::now() + timeout; + let mut active = state + .active + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + while *active > 0 { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return false; + } + let (guard, _) = state + .idle + .wait_timeout(active, remaining) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + active = guard; + } + true +} + +/// 把项目同步请求交给后台线程:窗口关闭与应用退出路径都不能被网络等待阻塞。 +pub(crate) fn request_project_snapshot_sync( + project_root: PathBuf, + trigger: ProjectSnapshotSyncTrigger, +) { + if !project_snapshot_sync_enabled() { + return; + } + let key = project_snapshot_sync_key(&project_root); + let spawn = std::thread::Builder::new() + .name("agc-project-snapshot".to_string()) + .spawn(move || { + let run = || sync_project_snapshot_blocking(&project_root, trigger); + let outcome = if matches!(trigger, ProjectSnapshotSyncTrigger::Periodic) { + try_run_project_snapshot_sync(&key, run) + } else { + Some(run_project_snapshot_sync(&key, run)) + }; + if let Some(Err(error)) = outcome { + app_log!( + "project_snapshot.sync.failed trigger={}: {error}", + trigger.as_str() + ); + } + }); + if let Err(error) = spawn { + app_log!("project_snapshot.sync.spawn.failed: {error}"); + } +} + +pub(crate) fn project_snapshot_sync_key(project_root: &Path) -> String { + let value = project_root.to_string_lossy().replace('\\', "/"); + #[cfg(windows)] + { + return value.trim_end_matches('/').to_ascii_lowercase(); + } + #[cfg(not(windows))] + { + value.trim_end_matches('/').to_string() + } +} + +fn sync_project_snapshot_blocking( + project_root: &Path, + trigger: ProjectSnapshotSyncTrigger, +) -> Result { + tauri::async_runtime::block_on(sync_project_snapshot_async(project_root, trigger)) +} + +async fn sync_project_snapshot_async( + project_root: &Path, + trigger: ProjectSnapshotSyncTrigger, +) -> Result { + if !project_snapshot_sync_enabled() { + return Err("项目快照同步已停用".to_string()); + } + validate_project_root(project_root)?; + let session = current_platform_session() + .ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string())?; + let manifest = read_existing_manifest_for_project(project_root)?; + let project_id = validate_project_snapshot_project_id(manifest.project_id.trim())?; + + let previous = read_project_snapshot_index(&project_id)?; + let previous = if previous.user_id == session.user_id { + previous + } else { + empty_project_snapshot_index(&project_id, &session.user_id) + }; + + let scan = scan_project_snapshot_files(project_root, PROJECT_SNAPSHOT_MAX_FILE_BYTES)?; + // 项目总量上限在本地先判:超限时明确失败,不再逐个文件上传后被服务端整体拒绝。 + let scan_total_bytes = scan + .files + .iter() + .fold(0_u64, |total, file| total.saturating_add(file.size_bytes)); + if scan_total_bytes > PROJECT_SNAPSHOT_MAX_PROJECT_BYTES { + return Err(format!( + "项目体积 {scan_total_bytes} 字节超过项目快照单项目上限 {PROJECT_SNAPSHOT_MAX_PROJECT_BYTES} 字节" + )); + } + let diff = + compute_project_snapshot_diff(&scan, &previous.files, PROJECT_SNAPSHOT_MAX_SYNC_BYTES)?; + if !diff.has_changes() { + return Ok(ProjectSnapshotSyncReport { + project_id, + trigger: trigger.as_str().to_string(), + status: "no-op".to_string(), + sync_revision: previous.sync_revision, + uploaded_files: 0, + uploaded_bytes: 0, + remote_skipped_files: 0, + deleted_files: 0, + deferred_files: 0, + metadata_only_files: diff.metadata_only.len(), + skipped_files: failure_views(&diff.skipped), + pending_files: failure_views(&diff.pending), + failed_files: Vec::new(), + synced_at_ms: previous.synced_at_ms, + }); + } + + let upload = upload_project_snapshot_diff(&session, &project_id, &diff).await; + let synced_files = build_project_snapshot_synced_files(&diff, &upload.uploaded_paths); + let next_revision = previous.sync_revision.saturating_add(1); + let synced_at_ms = u64::try_from(unix_millis()).unwrap_or(u64::MAX); + let payload = shared_contracts::agc_project_snapshots::AgcProjectSnapshotManifestRequest { + schema_version: + shared_contracts::agc_project_snapshots::AGC_PROJECT_SNAPSHOT_SCHEMA_VERSION, + project_id: project_id.clone(), + sync_revision: next_revision, + synced_at_ms, + files: synced_files + .iter() + .map(|(relative_path, file)| { + shared_contracts::agc_project_snapshots::AgcProjectSnapshotManifestFile { + relative_path: relative_path.clone(), + size_bytes: file.size_bytes, + checksum: file.checksum.clone(), + } + }) + .collect(), + }; + // 清单失败就不推进索引:宁可下一次重算,也不能把本地记成"已同步"。 + upload_project_snapshot_manifest(&session, &payload) + .await + .map_err(|error| error.message())?; + write_project_snapshot_index(&ProjectSnapshotIndex { + schema_version: PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION, + project_id: project_id.clone(), + user_id: session.user_id.clone(), + sync_revision: next_revision, + synced_at_ms, + files: synced_files, + })?; + + let status = if upload.failures.is_empty() { + "synced" + } else if upload.uploaded_paths.is_empty() { + "failed" + } else { + "partial" + }; + let report = ProjectSnapshotSyncReport { + project_id, + trigger: trigger.as_str().to_string(), + status: status.to_string(), + sync_revision: next_revision, + uploaded_files: upload.uploaded_paths.len(), + uploaded_bytes: upload.uploaded_bytes, + remote_skipped_files: upload.remote_skipped_files, + deleted_files: diff.deleted.len(), + deferred_files: diff.deferred.len(), + metadata_only_files: diff.metadata_only.len(), + skipped_files: failure_views(&diff.skipped), + pending_files: failure_views(&diff.pending), + failed_files: upload + .failures + .iter() + .map(|failure| ProjectSnapshotFailureView { + relative_path: failure.relative_path.clone(), + code: failure.code.clone(), + detail: failure.detail.clone(), + }) + .collect(), + synced_at_ms, + }; + app_log!( + "project_snapshot.sync.completed trigger={} status={} revision={} uploaded={} skippedRemote={} deleted={} deferred={} failed={}", + report.trigger, + report.status, + report.sync_revision, + report.uploaded_files, + report.remote_skipped_files, + report.deleted_files, + report.deferred_files, + report.failed_files.len() + ); + Ok(report) +} + +fn failure_views(skipped: &[ProjectSnapshotSkippedPath]) -> Vec { + skipped + .iter() + .map(|entry| ProjectSnapshotFailureView { + relative_path: entry.relative_path.clone(), + code: "skipped".to_string(), + detail: entry.reason.clone(), + }) + .collect() +} + +/// 从窗口 URL 读取项目路径。只有带 `projectPath` 的窗口才代表打开的项目。 +pub(crate) fn project_snapshot_project_path_from_url(url: &url::Url) -> Option { + url.query_pairs() + .find_map(|(key, value)| (key == "projectPath").then(|| value.into_owned())) + .filter(|value| !value.trim().is_empty()) +} + +fn open_project_snapshot_workspaces(app: &tauri::AppHandle) -> Vec { + let mut paths = BTreeSet::new(); + for window in app.webview_windows().into_values() { + let Ok(url) = window.url() else { + continue; + }; + if let Some(project_path) = project_snapshot_project_path_from_url(&url) { + paths.insert(project_path); + } + } + paths.into_iter().collect() +} + +/// 工作区窗口关闭即视为项目关闭:立刻补一次同步。 +pub(crate) fn handle_project_snapshot_window_event( + window: &tauri::Window, + event: &tauri::WindowEvent, +) { + if !project_snapshot_sync_enabled() { + return; + } + if !matches!(event, tauri::WindowEvent::CloseRequested { .. }) { + return; + } + // `tauri::Window` 不暴露 WebView 地址,按标签取回对应的 WebView 窗口再读 URL。 + let Some(webview) = window.app_handle().get_webview_window(window.label()) else { + return; + }; + let Ok(url) = webview.url() else { + return; + }; + let Some(project_path) = project_snapshot_project_path_from_url(&url) else { + return; + }; + request_project_snapshot_sync( + PathBuf::from(project_path), + ProjectSnapshotSyncTrigger::ProjectClose, + ); +} + +/// 应用退出前等待在途同步收尾。 +/// +/// 退出时刻窗口已销毁,按窗口重新枚举项目只会得到空集,因此这里不重复发起同步: +/// 关窗触发的同步已经在 `CloseRequested` 时启动,退出路径只需要给它一个有界窗口。 +pub(crate) fn wait_for_project_snapshot_syncs_on_exit() { + if !project_snapshot_sync_enabled() { + return; + } + if !wait_for_project_snapshot_syncs(Duration::from_secs(PROJECT_SNAPSHOT_EXIT_BUDGET_SECONDS)) { + app_log!("project_snapshot.sync.exit.budget-exhausted"); + } +} + +/// 周期定时器:只为当前仍打开的项目触发,进程内项目集合由窗口 URL 决定。 +pub(crate) fn spawn_project_snapshot_scheduler(app: tauri::AppHandle) { + if !project_snapshot_sync_enabled() { + app_log!("project_snapshot.scheduler.disabled"); + return; + } + let interval = project_snapshot_sync_interval(); + let spawned = std::thread::Builder::new() + .name("agc-project-snapshot-scheduler".to_string()) + .spawn(move || loop { + std::thread::sleep(interval); + for project_path in open_project_snapshot_workspaces(&app) { + request_project_snapshot_sync( + PathBuf::from(project_path), + ProjectSnapshotSyncTrigger::Periodic, + ); + } + }); + if let Err(error) = spawned { + app_log!("project_snapshot.scheduler.spawn.failed: {error}"); + } +} + +fn resolve_project_snapshot_root(project_path: &str) -> Result { + let trimmed = project_path.trim(); + if trimmed.is_empty() { + return Err("请提供项目绝对路径".to_string()); + } + let root = PathBuf::from(trimmed); + validate_project_root(&root)?; + Ok(root) +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectSnapshotStateView { + project_id: String, + index_path: String, + index_present: bool, + file_count: usize, + sync_revision: u64, + synced_at_ms: u64, + enabled: bool, +} + +/// 手动触发一次项目快照同步。同步本身跑在阻塞工作线程上,不占用窗口线程。 +#[tauri::command] +pub(crate) async fn sync_local_project_snapshot( + project_path: String, +) -> Result { + let root = resolve_project_snapshot_root(&project_path)?; + let key = project_snapshot_sync_key(&root); + tauri::async_runtime::spawn_blocking(move || { + run_project_snapshot_sync(&key, || { + sync_project_snapshot_blocking(&root, ProjectSnapshotSyncTrigger::Manual) + }) + }) + .await + .map_err(|error| format!("项目快照同步任务失败:{error}"))? +} + +#[tauri::command] +pub(crate) fn read_local_project_snapshot_state( + project_path: String, +) -> Result { + let root = resolve_project_snapshot_root(&project_path)?; + let manifest = read_existing_manifest_for_project(&root)?; + let project_id = validate_project_snapshot_project_id(manifest.project_id.trim())?; + let index_path = project_snapshot_index_path(&project_id)?; + let index = read_project_snapshot_index(&project_id)?; + Ok(ProjectSnapshotStateView { + project_id, + index_path: index_path.to_string_lossy().into_owned(), + index_present: index_path.exists(), + file_count: index.files.len(), + sync_revision: index.sync_revision, + synced_at_ms: index.synced_at_ms, + enabled: project_snapshot_sync_enabled(), + }) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/scan.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/scan.rs new file mode 100644 index 000000000..2163c4b24 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/scan.rs @@ -0,0 +1,157 @@ +use super::*; + +/// 扫描阶段的候选文件:只看元数据,不读内容,差异对比阶段才决定是否读盘。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ProjectSnapshotScannedFile { + pub(crate) relative_path: String, + pub(crate) size_bytes: u64, + pub(crate) modified_ms: u64, + pub(crate) absolute_path: PathBuf, +} + +/// 被排除或不参与本次同步的路径。原因只用于本机日志与同步报告,不进入远端。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProjectSnapshotSkippedPath { + pub(crate) relative_path: String, + pub(crate) reason: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct ProjectSnapshotScanResult { + pub(crate) files: Vec, + pub(crate) skipped: Vec, +} + +/// 扫描项目目录,复用 checkpoint / 项目索引同一份排除口径: +/// `.agent`、版本控制目录、依赖与构建产物目录、凭据目录、符号链接与重解析点 +/// 都不参与同步,超出单文件上限的文件进入跳过清单而不是静默丢弃。 +pub(crate) fn scan_project_snapshot_files( + root: &Path, + max_file_bytes: u64, +) -> Result { + let mut result = ProjectSnapshotScanResult::default(); + if !root.exists() { + return Ok(result); + } + let root_metadata = + fs::symlink_metadata(root).map_err(|error| format!("读取项目目录元数据失败:{error}"))?; + if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() { + return Err("项目快照只支持普通项目目录".to_string()); + } + + let mut directories = vec![root.to_path_buf()]; + while let Some(directory) = directories.pop() { + let entries = fs::read_dir(&directory) + .map_err(|error| format!("读取项目目录失败:{}: {error}", directory.display()))?; + for entry in entries { + let entry = entry + .map_err(|error| format!("读取项目文件失败:{}: {error}", directory.display()))?; + let path = entry.path(); + // 路径不在项目根内(含符号链接跳转)时直接跳过,不猜测归属。 + let Ok(relative_path) = relative_project_path(root, &path) else { + continue; + }; + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) => { + result.skipped.push(ProjectSnapshotSkippedPath { + relative_path, + reason: format!("读取元数据失败:{error}"), + }); + continue; + } + }; + if should_skip_project_snapshot_path(&relative_path) { + continue; + } + if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { + result.skipped.push(ProjectSnapshotSkippedPath { + relative_path, + reason: "符号链接或重解析点不参与项目快照".to_string(), + }); + continue; + } + if metadata.is_dir() { + directories.push(path); + continue; + } + if !metadata.is_file() { + result.skipped.push(ProjectSnapshotSkippedPath { + relative_path, + reason: "不是普通文件".to_string(), + }); + continue; + } + if metadata.len() > max_file_bytes { + result.skipped.push(ProjectSnapshotSkippedPath { + relative_path, + reason: format!("单个文件超过 {max_file_bytes} 字节上限"), + }); + continue; + } + result.files.push(ProjectSnapshotScannedFile { + relative_path, + size_bytes: metadata.len(), + modified_ms: project_snapshot_modified_ms(&metadata), + absolute_path: path, + }); + } + } + + result + .files + .sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + result + .skipped + .sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + Ok(result) +} + +/// 修改时间缺失或早于 Unix 纪元时返回 0:这样的文件每轮都会重算摘要,不会 +/// 被误判成"未改动"。 +pub(crate) fn project_snapshot_modified_ms(metadata: &fs::Metadata) -> u64 { + metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)) + .unwrap_or(0) +} + +/// 安全打开并读取项目文件内容。拒绝符号链接、重解析点与硬链接,读取后不再 +/// 按路径名二次访问。 +pub(crate) fn read_project_snapshot_file_bytes(path: &Path) -> Result, String> { + let (mut file, metadata) = open_project_snapshot_regular_file(path, "项目快照文件")?; + let mut bytes = Vec::with_capacity(usize::try_from(metadata.len()).unwrap_or_default()); + file.read_to_end(&mut bytes) + .map_err(|error| format!("读取项目快照文件失败:{}: {error}", path.display()))?; + Ok(bytes) +} + +/// 与项目索引同口径的校验和,便于同一份内容在不同入口之间比较。 +pub(crate) fn project_snapshot_checksum(bytes: &[u8]) -> String { + format!("fnv1a64:{:016x}", fnv1a64(bytes)) +} + +/// 复核文件是否仍是扫描时刻的那一份内容。 +/// +/// 同步不持有项目写锁:Agent 或编辑器可能在扫描之后、读取或上传之前改写文件。 +/// 读取前后都按 `(字节数, 修改时间)` 比对,任一处不一致就判定为"同步期间发生变化", +/// 该文件不计入索引、留给下一次同步,而不是上传一份自相矛盾的快照。 +pub(crate) fn project_snapshot_file_matches( + path: &Path, + size_bytes: u64, + modified_ms: u64, +) -> bool { + let Ok(metadata) = fs::symlink_metadata(path) else { + return false; + }; + if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { + return false; + } + if !metadata.is_file() { + return false; + } + metadata.len() == size_bytes && project_snapshot_modified_ms(&metadata) == modified_ms +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs new file mode 100644 index 000000000..ff57bdfae --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/tests.rs @@ -0,0 +1,590 @@ +use super::*; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +fn fixture_root() -> tempfile::TempDir { + tempfile::tempdir().expect("create project snapshot fixture root") +} + +fn write_fixture_file(root: &Path, relative_path: &str, bytes: &[u8]) { + let path = root.join(relative_path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create fixture parent directory"); + } + fs::write(&path, bytes).expect("write fixture file"); +} + +fn scan_fixture(root: &Path) -> ProjectSnapshotScanResult { + scan_project_snapshot_files(root, PROJECT_SNAPSHOT_MAX_FILE_BYTES) + .expect("scan fixture project") +} + +fn paths_of(files: &[ProjectSnapshotUploadCandidate]) -> Vec { + files + .iter() + .map(|candidate| candidate.relative_path.clone()) + .collect() +} + +#[test] +fn project_snapshot_project_id_validation_rejects_paths_and_unsafe_values() { + for value in [ + "", + " ", + ".", + "..", + "../escape", + "a/b", + "a\\b", + "a b", + "-leading", + ".hidden", + ] { + assert!( + validate_project_snapshot_project_id(value).is_err(), + "unsafe project id must be rejected: {value}" + ); + } + assert_eq!( + validate_project_snapshot_project_id("gameagent-1a2b3c4d").expect("stable project id"), + "gameagent-1a2b3c4d" + ); +} + +#[test] +fn project_snapshot_index_round_trips_and_recovers_from_corruption() { + let root = fixture_root(); + let directory = + project_snapshot_index_directory_at(root.path(), "project-1").expect("index directory"); + assert!(read_project_snapshot_index_at(&directory, "project-1") + .expect("missing index reads as empty") + .files + .is_empty()); + + let mut index = empty_project_snapshot_index("project-1", "user-1"); + index.sync_revision = 3; + index.synced_at_ms = 1_700_000_000_000; + index.files.insert( + "game/index.html".to_string(), + ProjectSnapshotIndexedFile { + size_bytes: 13, + modified_ms: 34, + checksum: "fnv1a64:0000000000000001".to_string(), + }, + ); + write_project_snapshot_index_at(&directory, &index).expect("write index"); + assert_eq!( + read_project_snapshot_index_at(&directory, "project-1").expect("read index"), + index + ); + + let path = project_snapshot_index_path_at(&directory); + fs::write(&path, b"{ not json").expect("corrupt index"); + assert!( + read_project_snapshot_index_at(&directory, "project-1") + .expect("corrupt index reads as empty") + .files + .is_empty(), + "损坏索引必须退化为空索引,让下一次同步全量重算" + ); +} + +#[test] +fn project_snapshot_scan_skips_excluded_paths_and_oversized_files() { + let root = fixture_root(); + write_fixture_file(root.path(), "game/index.html", b""); + write_fixture_file(root.path(), "assets/manifest.json", b"{}"); + write_fixture_file(root.path(), ".agent/runtime/state.json", b"{}"); + write_fixture_file(root.path(), ".agent/manifest.json", b"{}"); + write_fixture_file(root.path(), "node_modules/pkg/index.js", b"export {};"); + write_fixture_file(root.path(), "game/dist/bundle.js", b"bundle"); + write_fixture_file(root.path(), "secrets/key.pem", b"private-key"); + write_fixture_file(root.path(), "assets/big.bin", b"0123456789"); + + let scan = scan_project_snapshot_files(root.path(), 4).expect("scan fixture project"); + let scanned = scan + .files + .iter() + .map(|file| file.relative_path.clone()) + .collect::>(); + assert_eq!( + scanned, + vec!["assets/manifest.json".to_string()], + ".agent、node_modules、dist 与凭据目录里的文件不能进入候选集合" + ); + let skipped = scan + .skipped + .iter() + .map(|entry| entry.relative_path.clone()) + .collect::>(); + assert_eq!( + skipped, + vec!["assets/big.bin".to_string(), "game/index.html".to_string()], + "超限文件进入跳过清单,不静默丢弃" + ); +} + +#[test] +fn project_snapshot_diff_reuses_metadata_and_reports_a_single_modification() { + let root = fixture_root(); + write_fixture_file(root.path(), "game/a.txt", b"alpha"); + write_fixture_file(root.path(), "game/b.txt", b"beta!"); + + let first_scan = scan_fixture(root.path()); + let first = compute_project_snapshot_diff( + &first_scan, + &BTreeMap::new(), + PROJECT_SNAPSHOT_MAX_SYNC_BYTES, + ) + .expect("first diff"); + assert_eq!(paths_of(&first.uploads), vec!["game/a.txt", "game/b.txt"]); + assert!(first.deleted.is_empty()); + + // 第二次:元数据未变,必须复用摘要并且不产生上传项。 + let second = + compute_project_snapshot_diff(&first_scan, &first.current, PROJECT_SNAPSHOT_MAX_SYNC_BYTES) + .expect("second diff"); + assert!(second.uploads.is_empty(), "{:?}", second.uploads); + assert!(second.deleted.is_empty()); + assert!(second.metadata_only.is_empty()); + assert!(!second.has_changes()); + assert_eq!(second.current, first.current); + + // 只改一个文件:差异集合恰好包含它。 + write_fixture_file(root.path(), "game/a.txt", b"alpha-changed"); + let third = compute_project_snapshot_diff( + &scan_fixture(root.path()), + &first.current, + PROJECT_SNAPSHOT_MAX_SYNC_BYTES, + ) + .expect("third diff"); + assert_eq!(paths_of(&third.uploads), vec!["game/a.txt"]); + + // 删除文件只体现在清单:没有上传项,但 deleted 里能读到。 + fs::remove_file(root.path().join("game/b.txt")).expect("delete fixture file"); + let fourth = compute_project_snapshot_diff( + &scan_fixture(root.path()), + &third.current, + PROJECT_SNAPSHOT_MAX_SYNC_BYTES, + ) + .expect("fourth diff"); + assert!(fourth.uploads.is_empty()); + assert_eq!(fourth.deleted, vec!["game/b.txt".to_string()]); +} + +#[test] +fn project_snapshot_diff_treats_touched_but_identical_content_as_metadata_only() { + let root = fixture_root(); + write_fixture_file(root.path(), "game/a.txt", b"alpha"); + let scan = scan_fixture(root.path()); + let checksum = project_snapshot_checksum(b"alpha"); + let mut previous = BTreeMap::new(); + previous.insert( + "game/a.txt".to_string(), + ProjectSnapshotIndexedFile { + size_bytes: 5, + // 修改时间不同但内容一致:只能靠摘要判定,不能重复上传。 + modified_ms: scan.files[0].modified_ms.saturating_sub(1_000), + checksum, + }, + ); + let diff = compute_project_snapshot_diff(&scan, &previous, PROJECT_SNAPSHOT_MAX_SYNC_BYTES) + .expect("metadata-only diff"); + assert!(diff.uploads.is_empty()); + assert_eq!(diff.metadata_only, vec!["game/a.txt".to_string()]); + assert!(diff.has_changes()); +} + +#[test] +fn project_snapshot_diff_defers_files_over_the_sync_budget() { + let root = fixture_root(); + write_fixture_file(root.path(), "game/a.txt", b"0123456789"); + write_fixture_file(root.path(), "game/b.txt", b"0123456789"); + let scan = scan_fixture(root.path()); + let diff = compute_project_snapshot_diff(&scan, &BTreeMap::new(), 15).expect("budget diff"); + assert_eq!(paths_of(&diff.uploads), vec!["game/a.txt"]); + assert_eq!( + diff.deferred + .iter() + .map(|entry| entry.relative_path.clone()) + .collect::>(), + vec!["game/b.txt".to_string()] + ); + assert_eq!(diff.upload_bytes, 10); +} + +#[test] +fn project_snapshot_synced_files_exclude_failed_and_deferred_paths() { + let root = fixture_root(); + write_fixture_file(root.path(), "game/a.txt", b"a"); + write_fixture_file(root.path(), "game/b.txt", b"b"); + write_fixture_file(root.path(), "game/c.txt", b"c"); + let scan = scan_fixture(root.path()); + let mut diff = + compute_project_snapshot_diff(&scan, &BTreeMap::new(), PROJECT_SNAPSHOT_MAX_SYNC_BYTES) + .expect("diff"); + diff.deferred.push(ProjectSnapshotSkippedPath { + relative_path: "game/c.txt".to_string(), + reason: "fixture".to_string(), + }); + let uploaded = BTreeSet::from(["game/a.txt".to_string()]); + let synced = build_project_snapshot_synced_files(&diff, &uploaded); + assert_eq!( + synced.keys().cloned().collect::>(), + vec!["game/a.txt".to_string()], + "未成功上传与延后的路径不能进入新索引,否则下一次同步会漏传" + ); +} + +#[test] +fn project_snapshot_sync_is_serialized_per_project() { + let active = Arc::new(AtomicUsize::new(0)); + let peak = Arc::new(AtomicUsize::new(0)); + let key = "c:/fixture/serialized-project"; + let mut handles = Vec::new(); + for _ in 0..3 { + let active = Arc::clone(&active); + let peak = Arc::clone(&peak); + handles.push(std::thread::spawn(move || { + run_project_snapshot_sync(key, || { + let current = active.fetch_add(1, Ordering::SeqCst) + 1; + peak.fetch_max(current, Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(30)); + active.fetch_sub(1, Ordering::SeqCst); + }); + })); + } + for handle in handles { + handle.join().expect("join serialized sync thread"); + } + assert_eq!(peak.load(Ordering::SeqCst), 1); +} + +#[test] +fn project_snapshot_periodic_trigger_yields_while_a_sync_is_in_flight() { + let key = "c:/fixture/periodic-project"; + let (started_sender, started_receiver) = mpsc::channel(); + let handle = std::thread::spawn(move || { + run_project_snapshot_sync(key, || { + started_sender.send(()).expect("signal started sync"); + std::thread::sleep(Duration::from_millis(200)); + }); + }); + started_receiver.recv().expect("wait for in-flight sync"); + assert!( + try_run_project_snapshot_sync(key, || ()).is_none(), + "在途同步期间周期触发必须直接让位" + ); + handle.join().expect("join in-flight sync"); + assert!(try_run_project_snapshot_sync(key, || ()).is_some()); +} + +fn read_http_request_with_body(stream: &mut TcpStream) -> String { + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set fixture read timeout"); + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 4096]; + let header_end = loop { + let read = stream.read(&mut buffer).expect("read fixture request"); + assert!(read > 0, "fixture request closed before headers"); + bytes.extend_from_slice(&buffer[..read]); + if let Some(index) = bytes.windows(4).position(|window| window == b"\r\n\r\n") { + break index + 4; + } + }; + let headers = String::from_utf8_lossy(&bytes[..header_end]).into_owned(); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok())? + }) + .unwrap_or(0); + while bytes.len() < header_end + content_length { + let read = stream.read(&mut buffer).expect("read fixture request body"); + if read == 0 { + break; + } + bytes.extend_from_slice(&buffer[..read]); + } + String::from_utf8_lossy(&bytes).into_owned() +} + +fn spawn_snapshot_fixture( + response_status: &'static str, + response_body: String, +) -> (String, std::thread::JoinHandle) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture listener"); + let address = listener.local_addr().expect("read fixture address"); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept fixture request"); + let request = read_http_request_with_body(&mut stream); + let response = format!( + "HTTP/1.1 {response_status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response_body}", + response_body.len() + ); + stream + .write_all(response.as_bytes()) + .expect("write fixture response"); + request + }); + (format!("http://{address}"), handle) +} + +fn fixture_session(api_base_url: String) -> PlatformSessionSnapshot { + PlatformSessionSnapshot { + user_id: "user-fixture".to_string(), + access_token: "fixture-token".to_string(), + api_base_url, + identity_generation: 1, + revision: 1, + } +} + +fn fixture_candidate( + root: &Path, + relative_path: &str, + bytes: &[u8], +) -> ProjectSnapshotUploadCandidate { + write_fixture_file(root, relative_path, bytes); + let path = root.join(relative_path); + let metadata = fs::symlink_metadata(&path).expect("read fixture metadata"); + ProjectSnapshotUploadCandidate { + relative_path: relative_path.to_string(), + size_bytes: bytes.len() as u64, + // 用真实修改时间:上传前的一致性复核会拿它比对,写死常量会把请求提前挡掉。 + modified_ms: project_snapshot_modified_ms(&metadata), + checksum: project_snapshot_checksum(bytes), + absolute_path: path, + } +} + +#[test] +fn project_snapshot_upload_marks_remote_duplicates_and_sends_marker_headers() { + let root = fixture_root(); + let candidate = fixture_candidate(root.path(), "game/a.txt", b"alpha"); + let body = serde_json::json!({ + "projectId": "project-1", + "relativePath": "game/a.txt", + "objectKey": "agc/project-snapshots/v1/user-fixture/project-1/game/a.txt", + "skipped": true, + "checksum": candidate.checksum, + "sizeBytes": candidate.size_bytes, + }) + .to_string(); + let (base_url, fixture) = spawn_snapshot_fixture("200 OK", body); + let session = fixture_session(base_url); + let diff = ProjectSnapshotDiff { + uploads: vec![candidate], + ..ProjectSnapshotDiff::default() + }; + let report = + tauri::async_runtime::block_on(upload_project_snapshot_diff(&session, "project-1", &diff)); + let request = fixture.join().expect("join fixture"); + + assert!(report.failures.is_empty(), "{:?}", report.failures); + assert_eq!(report.remote_skipped_files, 1); + assert_eq!(report.uploaded_bytes, 5); + assert_eq!( + report.uploaded_paths, + BTreeSet::from(["game/a.txt".to_string()]) + ); + assert!(request.starts_with("POST /api/agc/project-snapshots/files?")); + assert!(request.contains("projectId=project-1")); + assert!(request.contains("relativePath=game%2Fa.txt")); + assert!(request + .to_ascii_lowercase() + .contains("authorization: bearer fixture-token")); + assert!(request + .to_ascii_lowercase() + .contains("x-genarrative-client: agc")); + assert!(request.ends_with("alpha")); +} + +#[test] +fn project_snapshot_file_matches_detects_metadata_drift() { + let root = fixture_root(); + write_fixture_file(root.path(), "game/a.txt", b"alpha"); + let path = root.path().join("game/a.txt"); + let metadata = fs::symlink_metadata(&path).expect("fixture metadata"); + let size = metadata.len(); + let modified = project_snapshot_modified_ms(&metadata); + + assert!(project_snapshot_file_matches(&path, size, modified)); + assert!( + !project_snapshot_file_matches(&path, size + 1, modified), + "字节数变化必须被判为已改写" + ); + assert!( + !project_snapshot_file_matches(&path, size, modified + 1), + "修改时间变化必须被判为已改写" + ); + assert!( + !project_snapshot_file_matches(&root.path().join("game/missing.txt"), size, modified), + "文件消失必须被判为已改写" + ); +} + +#[test] +fn project_snapshot_diff_defers_files_that_changed_while_being_read() { + let root = fixture_root(); + write_fixture_file(root.path(), "game/a.txt", b"alpha"); + let mut previous = BTreeMap::new(); + previous.insert( + "game/a.txt".to_string(), + ProjectSnapshotIndexedFile { + size_bytes: 5, + modified_ms: 42, + checksum: "fnv1a64:00000000000000aa".to_string(), + }, + ); + // 扫描时刻的元数据与磁盘现状不一致:等价于"扫描之后文件被改写"。 + let scan = ProjectSnapshotScanResult { + files: vec![ProjectSnapshotScannedFile { + relative_path: "game/a.txt".to_string(), + size_bytes: 5, + modified_ms: 41, + absolute_path: root.path().join("game/a.txt"), + }], + skipped: Vec::new(), + }; + + let diff = compute_project_snapshot_diff(&scan, &previous, PROJECT_SNAPSHOT_MAX_SYNC_BYTES) + .expect("diff"); + assert!(diff.uploads.is_empty(), "{:?}", diff.uploads); + assert_eq!(diff.pending.len(), 1); + assert_eq!(diff.pending[0].relative_path, "game/a.txt"); + assert!( + diff.deleted.is_empty(), + "同步期间被改写的文件不能被当成删除,否则远端 GC 会误删" + ); + assert!( + diff.current.contains_key("game/a.txt"), + "应沿用上一轮记录,保持清单与远端对象一致" + ); +} + +#[test] +fn project_snapshot_upload_skips_files_that_changed_after_the_diff() { + let root = fixture_root(); + let mut candidate = fixture_candidate(root.path(), "game/a.txt", b"alpha"); + candidate.modified_ms = candidate.modified_ms.saturating_sub(1_000); + // 服务地址故意指向未监听端口:只要请求真的发出去就会出现 transport 失败, + // 因此这里同时证明"没有发出请求"和"分类是 file-changed"。 + let session = fixture_session("http://127.0.0.1:9".to_string()); + let diff = ProjectSnapshotDiff { + uploads: vec![candidate], + ..ProjectSnapshotDiff::default() + }; + + let report = + tauri::async_runtime::block_on(upload_project_snapshot_diff(&session, "project-1", &diff)); + assert!(report.uploaded_paths.is_empty()); + assert_eq!(report.failures.len(), 1); + assert_eq!(report.failures[0].code, "file-changed"); + assert!( + ProjectSnapshotUploadErrorKind::FileChanged.is_retryable(), + "文件被改写不是终态失败,下一次触发应重试" + ); +} + +#[test] +fn project_snapshot_upload_stops_after_a_deterministic_authentication_failure() { + let root = fixture_root(); + let first = fixture_candidate(root.path(), "game/a.txt", b"alpha"); + let second = fixture_candidate(root.path(), "game/b.txt", b"beta!"); + let (base_url, fixture) = spawn_snapshot_fixture("401 Unauthorized", "{}".to_string()); + let session = fixture_session(base_url); + let diff = ProjectSnapshotDiff { + uploads: vec![first, second], + ..ProjectSnapshotDiff::default() + }; + let report = + tauri::async_runtime::block_on(upload_project_snapshot_diff(&session, "project-1", &diff)); + fixture.join().expect("join fixture"); + + assert!(report.uploaded_paths.is_empty()); + assert_eq!(report.failures.len(), 2); + assert_eq!(report.failures[0].code, "authentication-required"); + assert_eq!(report.failures[0].relative_path, "game/a.txt"); + assert_eq!(report.failures[1].relative_path, "game/b.txt"); + assert!( + !ProjectSnapshotUploadErrorKind::Authentication.is_retryable(), + "鉴权失败不能进入自动重试" + ); +} + +#[test] +fn project_snapshot_project_path_is_read_from_window_urls_only() { + let url = url::Url::parse("tauri://localhost/index.html?main&projectPath=C%3A%5Cgames%5Cdemo") + .expect("parse fixture url"); + assert_eq!( + project_snapshot_project_path_from_url(&url), + Some("C:\\games\\demo".to_string()) + ); + let launcher = url::Url::parse("tauri://localhost/index.html?launcher").expect("parse url"); + assert_eq!(project_snapshot_project_path_from_url(&launcher), None); +} + +/// 真实链路冒烟:客户端差异引擎 → 本地 api-server → 真实 OSS。 +/// +/// 默认忽略;需要显式提供目标项目与登录态: +/// +/// ```text +/// $env:GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_PROJECT="<项目绝对路径>" +/// $env:GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_TOKEN="" +/// $env:GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_API_BASE_URL="http://127.0.0.1:8082" +/// cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml \ +/// project_snapshot_live_sync -- --ignored --nocapture +/// ``` +/// +/// 判定口径:第一次必须 `synced` 且上传数大于 0;紧接着的第二次必须 `no-op` 且上传 0 个文件, +/// 用来证明差异对比在真实服务端与真实 OSS 上确实避免了重复上传。 +/// +/// 另需 `GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_CONFIG_DIR` 指定 AppData 配置目录, +/// 否则测试进程里没有窗口 setup 初始化过该目录,索引无处可写。 +#[test] +#[ignore = "live smoke:需要真实 api-server 与 OSS 凭据"] +fn project_snapshot_live_sync_uploads_once_then_reports_no_op() { + let Ok(project_path) = std::env::var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_PROJECT") else { + println!("[skip] 未设置 GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_PROJECT"); + return; + }; + let Ok(access_token) = std::env::var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_TOKEN") else { + println!("[skip] 未设置 GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_TOKEN"); + return; + }; + let api_base_url = std::env::var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_API_BASE_URL") + .unwrap_or_else(|_| "http://127.0.0.1:8082".to_string()); + let user_id = std::env::var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_USER_ID") + .unwrap_or_else(|_| "snapshot-live-smoke-user".to_string()); + let Ok(config_dir) = std::env::var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_CONFIG_DIR") else { + println!("[skip] 未设置 GENARRATIVE_AGC_PROJECT_SNAPSHOT_LIVE_CONFIG_DIR"); + return; + }; + set_game_creator_runtime_config_dir(PathBuf::from(config_dir.trim())); + + let _session = install_test_platform_session(&user_id, &access_token, &api_base_url); + let root = PathBuf::from(project_path.trim()); + let first = tauri::async_runtime::block_on(sync_project_snapshot_async( + &root, + ProjectSnapshotSyncTrigger::Manual, + )) + .expect("第一次项目快照同步必须成功"); + println!("第一次同步:{first:#?}"); + assert_eq!(first.status, "synced", "{first:#?}"); + assert!(first.uploaded_files > 0, "{first:#?}"); + assert!(first.failed_files.is_empty(), "{first:#?}"); + + let second = tauri::async_runtime::block_on(sync_project_snapshot_async( + &root, + ProjectSnapshotSyncTrigger::Manual, + )) + .expect("第二次项目快照同步必须成功"); + println!("第二次同步:{second:#?}"); + assert_eq!(second.status, "no-op", "{second:#?}"); + assert_eq!(second.uploaded_files, 0, "{second:#?}"); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/transport.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/transport.rs new file mode 100644 index 000000000..6ff4c226a --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/transport.rs @@ -0,0 +1,337 @@ +use super::*; +use std::collections::BTreeSet; + +/// 上传失败的分类。鉴权与权限类失败不重试,其余交给下一次周期或关闭触发。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ProjectSnapshotUploadErrorKind { + Authentication, + Permission, + /// 同步期间文件被改写:本轮跳过该文件,下一次重算即可,不是终态失败。 + FileChanged, + /// 服务端按配额或频率拒绝:等下一次触发再试。 + Throttled, + Rejected, + Upstream, + Transport, +} + +impl ProjectSnapshotUploadErrorKind { + pub(crate) fn code(self) -> &'static str { + match self { + Self::Authentication => "authentication-required", + Self::Permission => "permission-denied", + Self::FileChanged => "file-changed", + Self::Throttled => "throttled", + Self::Rejected => "request-rejected", + Self::Upstream => "upstream-failed", + Self::Transport => "transport-failed", + } + } + + /// 鉴权、权限与请求被拒都是确定性失败;重复提交同样的内容不会变好。 + pub(crate) fn is_retryable(self) -> bool { + matches!( + self, + Self::Upstream | Self::Transport | Self::FileChanged | Self::Throttled + ) + } +} + +#[derive(Clone, Debug)] +pub(crate) struct ProjectSnapshotUploadError { + kind: ProjectSnapshotUploadErrorKind, + detail: String, +} + +impl ProjectSnapshotUploadError { + fn new(kind: ProjectSnapshotUploadErrorKind, detail: impl Into) -> Self { + Self { + kind, + detail: detail.into(), + } + } + + pub(crate) fn kind(&self) -> ProjectSnapshotUploadErrorKind { + self.kind + } + + pub(crate) fn code(&self) -> &'static str { + self.kind.code() + } + + pub(crate) fn message(&self) -> String { + format!("{}: {}", self.kind.code(), self.detail) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ProjectSnapshotUploadFailure { + pub(crate) relative_path: String, + pub(crate) code: String, + pub(crate) detail: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct ProjectSnapshotUploadReport { + pub(crate) uploaded_paths: BTreeSet, + pub(crate) uploaded_bytes: u64, + pub(crate) remote_skipped_files: usize, + pub(crate) failures: Vec, +} + +impl ProjectSnapshotUploadReport { + fn from_hard_failure(paths: &[String], error: &ProjectSnapshotUploadError) -> Self { + Self { + uploaded_paths: BTreeSet::new(), + uploaded_bytes: 0, + remote_skipped_files: 0, + failures: paths + .iter() + .map(|relative_path| ProjectSnapshotUploadFailure { + relative_path: relative_path.clone(), + code: error.code().to_string(), + detail: error.detail.clone(), + }) + .collect(), + } + } +} + +/// 逐文件上传本次差异集合。单个文件失败只影响该文件;鉴权或权限失败会中止 +/// 后续请求,因为同样的凭据不会在这一次同步里变好。 +pub(crate) async fn upload_project_snapshot_diff( + session: &PlatformSessionSnapshot, + project_id: &str, + diff: &ProjectSnapshotDiff, +) -> ProjectSnapshotUploadReport { + let mut report = ProjectSnapshotUploadReport::default(); + if diff.uploads.is_empty() { + return report; + } + let client = match crate::http_client::agc_main_site_client_builder() + .timeout(Duration::from_secs( + PROJECT_SNAPSHOT_REQUEST_TIMEOUT_SECONDS, + )) + .build() + { + Ok(client) => client, + Err(error) => { + let failure = ProjectSnapshotUploadError::new( + ProjectSnapshotUploadErrorKind::Transport, + format!("创建项目快照上传客户端失败:{error}"), + ); + let paths = diff + .uploads + .iter() + .map(|candidate| candidate.relative_path.clone()) + .collect::>(); + return ProjectSnapshotUploadReport::from_hard_failure(&paths, &failure); + } + }; + + for (index, candidate) in diff.uploads.iter().enumerate() { + match upload_project_snapshot_file(&client, session, project_id, candidate).await { + Ok(skipped_remote) => { + report + .uploaded_paths + .insert(candidate.relative_path.clone()); + report.uploaded_bytes = report.uploaded_bytes.saturating_add(candidate.size_bytes); + if skipped_remote { + report.remote_skipped_files += 1; + } + } + Err(error) => { + let fatal = !error.kind().is_retryable(); + report.failures.push(ProjectSnapshotUploadFailure { + relative_path: candidate.relative_path.clone(), + code: error.code().to_string(), + detail: error.detail.clone(), + }); + if fatal { + for remaining in diff.uploads.iter().skip(index + 1) { + report.failures.push(ProjectSnapshotUploadFailure { + relative_path: remaining.relative_path.clone(), + code: error.code().to_string(), + detail: "上一次请求已确定性失败,本轮不再继续".to_string(), + }); + } + break; + } + } + } + } + report +} + +/// 返回是否为"远端已存在同内容对象"的跳过。 +async fn upload_project_snapshot_file( + client: &reqwest::Client, + session: &PlatformSessionSnapshot, + project_id: &str, + candidate: &ProjectSnapshotUploadCandidate, +) -> Result { + let bytes = read_project_snapshot_file_bytes(&candidate.absolute_path).map_err(|error| { + ProjectSnapshotUploadError::new(ProjectSnapshotUploadErrorKind::Rejected, error) + })?; + if bytes.len() as u64 != candidate.size_bytes { + return Err(ProjectSnapshotUploadError::new( + ProjectSnapshotUploadErrorKind::FileChanged, + format!( + "项目快照文件在同步期间发生变化:{}", + candidate.relative_path + ), + )); + } + // 上传前再复核一次元数据:只比长度会漏掉"改完又改回同样大小"的情况, + // 这种文件本轮不传,也不进入索引。 + if !project_snapshot_file_matches( + &candidate.absolute_path, + candidate.size_bytes, + candidate.modified_ms, + ) { + return Err(ProjectSnapshotUploadError::new( + ProjectSnapshotUploadErrorKind::FileChanged, + format!( + "项目快照文件在同步期间发生变化:{}", + candidate.relative_path + ), + )); + } + let url = project_snapshot_endpoint( + session, + PROJECT_SNAPSHOT_FILES_ENDPOINT, + [ + ("projectId", project_id.to_string()), + ("relativePath", candidate.relative_path.clone()), + ("checksum", candidate.checksum.clone()), + ("sizeBytes", candidate.size_bytes.to_string()), + ], + )?; + let request = client + .post(url) + .bearer_auth(&session.access_token) + .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") + .body(bytes); + let response = crate::http_client::with_agc_main_site_marker(request) + .send() + .await + .map_err(|error| { + ProjectSnapshotUploadError::new( + ProjectSnapshotUploadErrorKind::Transport, + format!("项目快照上传请求失败:{error}"), + ) + })?; + let status = response.status(); + if !status.is_success() { + return Err(project_snapshot_response_error(status, response).await); + } + let parsed = response + .json::() + .await + .map_err(|error| { + ProjectSnapshotUploadError::new( + ProjectSnapshotUploadErrorKind::Upstream, + format!("项目快照上传响应无法解析:{error}"), + ) + })?; + if parsed.relative_path != candidate.relative_path || parsed.checksum != candidate.checksum { + return Err(ProjectSnapshotUploadError::new( + ProjectSnapshotUploadErrorKind::Upstream, + format!("项目快照上传响应与请求不一致:{}", candidate.relative_path), + )); + } + Ok(parsed.skipped) +} + +/// 提交本次同步的远端清单;删除只在这里表达。 +pub(crate) async fn upload_project_snapshot_manifest( + session: &PlatformSessionSnapshot, + payload: &shared_contracts::agc_project_snapshots::AgcProjectSnapshotManifestRequest, +) -> Result<(), ProjectSnapshotUploadError> { + let client = crate::http_client::agc_main_site_client_builder() + .timeout(Duration::from_secs( + PROJECT_SNAPSHOT_REQUEST_TIMEOUT_SECONDS, + )) + .build() + .map_err(|error| { + ProjectSnapshotUploadError::new( + ProjectSnapshotUploadErrorKind::Transport, + format!("创建项目快照上传客户端失败:{error}"), + ) + })?; + let url = project_snapshot_endpoint(session, PROJECT_SNAPSHOT_MANIFEST_ENDPOINT, [])?; + let request = client + .post(url) + .bearer_auth(&session.access_token) + .json(payload); + let response = crate::http_client::with_agc_main_site_marker(request) + .send() + .await + .map_err(|error| { + ProjectSnapshotUploadError::new( + ProjectSnapshotUploadErrorKind::Transport, + format!("项目快照清单上传请求失败:{error}"), + ) + })?; + let status = response.status(); + if !status.is_success() { + return Err(project_snapshot_response_error(status, response).await); + } + Ok(()) +} + +fn project_snapshot_endpoint( + session: &PlatformSessionSnapshot, + path: &str, + query: impl IntoIterator, +) -> Result { + let base_url = session.api_base_url.trim_end_matches('/'); + if base_url.is_empty() { + return Err(ProjectSnapshotUploadError::new( + ProjectSnapshotUploadErrorKind::Authentication, + "登录态缺少 API Server 地址", + )); + } + let mut url = reqwest::Url::parse(&format!("{base_url}{path}")).map_err(|error| { + ProjectSnapshotUploadError::new( + ProjectSnapshotUploadErrorKind::Transport, + format!("项目快照接口地址无效:{error}"), + ) + })?; + { + let mut pairs = url.query_pairs_mut(); + for (key, value) in query { + pairs.append_pair(key, &value); + } + } + Ok(url) +} + +async fn project_snapshot_response_error( + status: reqwest::StatusCode, + response: reqwest::Response, +) -> ProjectSnapshotUploadError { + let kind = match status.as_u16() { + 401 => ProjectSnapshotUploadErrorKind::Authentication, + 403 => ProjectSnapshotUploadErrorKind::Permission, + // 服务端配额与频率闸门:等下一次触发,不做本轮内重试。 + 429 => ProjectSnapshotUploadErrorKind::Throttled, + code if (400..500).contains(&code) => ProjectSnapshotUploadErrorKind::Rejected, + _ => ProjectSnapshotUploadErrorKind::Upstream, + }; + // 上游正文可能带内部信息,只保留状态码与有界摘要。 + let detail = response + .text() + .await + .map(|body| body.chars().take(200).collect::()) + .unwrap_or_default(); + let detail = detail.trim().to_string(); + if detail.is_empty() { + ProjectSnapshotUploadError::new(kind, format!("项目快照接口返回 HTTP {}", status.as_u16())) + } else { + ProjectSnapshotUploadError::new( + kind, + format!("项目快照接口返回 HTTP {}:{detail}", status.as_u16()), + ) + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs index 690c95b45..3a666b253 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs @@ -13,6 +13,18 @@ use std::path::Path; const PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024; const PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024; +/** + * 模型预览的字节上限。 + * + * 模型要整份送进渲染器才能出预览,而预览载荷是 base64 data URL(约放大 1/3): + * 与通用媒体上限(`PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES`)取同一个 32 MiB, + * 覆盖绝大多数 `.glb` / `.fbx`,同时把单条 IPC 载荷压在约 43 MiB 以内。 + * + * 超过上限的模型不报错、也不半渲染:卡面直接降级成类型卡(见前端 + * `projectResourceCardPreviewKind` 的 `model` 分支)。真要再往上放宽,得先把预览载荷 + * 从 base64 JSON 换成 Tauri 的原始字节通道,否则 JS 侧的字符串副本会先炸掉内存。 + */ +const PROJECT_MODEL_PREVIEW_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024; const PROJECT_RESOURCE_PREVIEW_READ_CHUNK_BYTES: usize = 64 * 1024; const PROJECT_MEDIA_PREVIEW_MAX_DIMENSION: u32 = 8_192; const PROJECT_MEDIA_PREVIEW_MAX_PIXELS: u64 = 32 * 1024 * 1024; @@ -24,6 +36,9 @@ pub(crate) struct LocalProjectTextPreview { pub(crate) media_type: String, pub(crate) byte_len: u64, pub(crate) content: String, + /// 仅由已登记资源的原生 UI State 校验设置;前端不根据正文猜测编辑能力。 + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) ui_design_asset_id: Option, } #[derive(Debug, Eq, PartialEq, Serialize)] @@ -43,6 +58,68 @@ pub(crate) struct LocalProjectMediaPreview { pub(crate) enum ProjectMediaPreviewKind { Art, Audio, + /// 三维模型(`.glb` / `.gltf` / `.fbx`):整份字节交给前端渲染器出预览。 + Model, +} + +/** + * 引擎资源结构预览的读取结果。 + * + * `content` 为 `None` 表示该资源不是 UTF-8 文本(例如二进制的 `.pac` 变体)—— + * 这是**正常降级**而不是错误:卡面据此画类型卡,不再向用户报「预览失败」。 + */ +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalProjectStructuredPreview { + pub(crate) path: String, + pub(crate) media_type: String, + pub(crate) byte_len: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) content: Option, +} + +/** + * 引擎(Cocos 等)序列化资源的结构预览准入。 + * + * 与 `is_supported_project_text_resource` **刻意分开**:那份白名单同时服务 UI 编辑器 + * 的「可编辑文本资源」判定,把 `.prefab` / `.scene` / `.anim` 塞进去会让它们在这条 + * 编辑链路里被当成普通文本资产;这里只服务资源画布的只读结构预览。 + */ +pub(crate) fn is_supported_project_structured_resource(path: &str, media_type: &str) -> bool { + if !matches!( + path_extension(path).as_deref(), + Some( + "scene" + | "fire" + | "prefab" + | "anim" + | "animation" + | "animgraph" + | "animgraphvari" + | "animask" + | "mtl" + | "material" + | "pmtl" + | "effect" + | "chunk" + | "tmx" + | "plist" + | "labelatlas" + | "atlas" + | "fnt" + | "pac" + | "mesh" + | "skeleton" + ) + ) { + return false; + } + let media_type = media_type.trim().to_ascii_lowercase(); + !(media_type.starts_with("audio/") + || media_type.starts_with("video/") + || media_type.starts_with("image/") + || media_type.starts_with("model/") + || media_type.starts_with("font/")) } pub(crate) fn is_supported_project_text_resource(path: &str, media_type: &str) -> bool { @@ -113,11 +190,33 @@ pub(crate) fn is_supported_project_art_media_resource(path: &str, media_type: &s let media_type = media_type.trim().to_ascii_lowercase(); matches!( path_extension(path).as_deref(), - Some("gif" | "svg" | "avif" | "bmp" | "mp4" | "webm" | "mov") + Some( + "gif" + | "svg" + | "avif" + | "bmp" + | "mp4" + | "webm" + | "mov" + // 引擎图像容器:浏览器解不了,先由原生侧转码成 PNG 再走同一条预览链路。 + | "tga" + | "tif" + | "tiff" + | "hdr" + ) ) || media_type.starts_with("video/") || media_type == "image/svg+xml" } +/// 模型预览准入:引擎三维模型与网格数据(Cocos 的 `.glb` / `.gltf` / `.fbx`)。 +pub(crate) fn is_supported_project_model_resource(path: &str, media_type: &str) -> bool { + let media_type = media_type.trim().to_ascii_lowercase(); + matches!( + path_extension(path).as_deref(), + Some("glb" | "gltf" | "fbx") + ) || media_type.starts_with("model/") +} + pub(crate) fn is_supported_project_audio_resource(path: &str, media_type: &str) -> bool { let media_type = media_type.trim().to_ascii_lowercase(); matches!( @@ -163,6 +262,7 @@ pub(crate) fn load_local_project_text_preview_with_cancellation( media_type: media_type.to_string(), byte_len: content.len() as u64, content, + ui_design_asset_id: None, }) } @@ -180,6 +280,56 @@ pub(crate) fn load_local_project_media_preview( ) } +/** + * 读取引擎序列化资源的结构预览。 + * + * `media_type` 由调用方从 manifest 登记项透传(任务产物没有登记项,传空串): + * 这条读取**不自己维护第五份扩展名表**,准入判定与登记口径共用同一个函数。 + */ +pub(crate) fn load_local_project_structured_preview_with_cancellation( + root: &Path, + relative_path: &str, + media_type: &str, + cancellation: &ProjectResourcePreviewScopeCancellation, +) -> Result { + cancellation.check()?; + let normalized = normalize_relative_path(relative_path.trim())?; + reject_sensitive_project_file_read(&normalized)?; + if !is_supported_project_structured_resource(&normalized, media_type) { + return Err("结构预览只支持引擎序列化资源".to_string()); + } + let bytes = read_stable_project_resource( + root, + &normalized, + PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES, + "引擎资源", + cancellation, + )?; + cancellation.check()?; + let byte_len = bytes.len() as u64; + let media_type = project_structured_media_type(&normalized); + Ok(LocalProjectStructuredPreview { + path: normalized, + media_type: media_type.to_string(), + byte_len, + // 非 UTF-8 的二进制变体不是错误:返回 `None`,由卡面降级成类型卡。 + content: String::from_utf8(bytes).ok(), + }) +} + +fn project_structured_media_type(path: &str) -> &'static str { + match path_extension(path).as_deref() { + Some("effect" | "chunk" | "fnt" | "atlas") => "text/plain", + Some("tmx" | "plist") => "application/xml", + Some( + "scene" | "fire" | "prefab" | "terrain" | "anim" | "animation" | "animgraph" + | "animgraphvari" | "animask" | "mtl" | "material" | "pmtl" | "labelatlas" | "pac" + | "mesh" | "skeleton", + ) => "application/json", + _ => "application/octet-stream", + } +} + pub(crate) fn load_local_project_media_preview_with_cancellation( root: &Path, relative_path: &str, @@ -189,29 +339,107 @@ pub(crate) fn load_local_project_media_preview_with_cancellation( cancellation.check()?; let normalized = normalize_relative_path(relative_path.trim())?; reject_sensitive_project_file_read(&normalized)?; - let bytes = read_stable_project_resource( - root, - &normalized, - PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES, - "项目媒体资源", - cancellation, - )?; + let (max_bytes, label) = match kind { + ProjectMediaPreviewKind::Model => (PROJECT_MODEL_PREVIEW_MAX_FILE_BYTES, "项目模型资源"), + ProjectMediaPreviewKind::Art | ProjectMediaPreviewKind::Audio => { + (PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES, "项目媒体资源") + } + }; + let bytes = read_stable_project_resource(root, &normalized, max_bytes, label, cancellation)?; if bytes.is_empty() { return Err("媒体文件为空,无法预览".to_string()); } cancellation.check()?; - let media_type = detect_project_media_type(&normalized, &bytes, kind)?; - cancellation.check()?; - let dimensions = (kind == ProjectMediaPreviewKind::Art) - .then(|| detect_project_art_dimensions(&bytes, media_type)) - .flatten(); + let source_byte_len = bytes.len() as u64; + /** + * 图像容器(TGA / TIFF / HDR / EXR)在浏览器里没有解码器:先在原生侧转成 PNG, + * 再走与 GIF / BMP / AVIF 完全相同的「数据 URL + 图片卡」链路。转码是**只读**的, + * 不改工程文件;像素尺寸仍受既有的尺寸与像素总量上限约束,不会因为多一层解码 + * 就放宽任何一条既有边界。 + */ + let transcoded = (kind == ProjectMediaPreviewKind::Art) + .then(|| transcode_project_art_container(&normalized, &bytes)) + .flatten() + .transpose()?; + let (media_type, dimensions, payload) = match transcoded { + Some(transcoded) => ( + transcoded.media_type, + Some((transcoded.pixel_width, transcoded.pixel_height)), + transcoded.bytes, + ), + None => { + let media_type = detect_project_media_type(&normalized, &bytes, kind)?; + cancellation.check()?; + let dimensions = (kind == ProjectMediaPreviewKind::Art) + .then(|| detect_project_art_dimensions(&bytes, media_type)) + .flatten(); + (media_type, dimensions, bytes) + } + }; Ok(LocalProjectMediaPreview { path: normalized, media_type: media_type.to_string(), - byte_len: bytes.len() as u64, + // `byteLen` 始终是**源文件**的大小:转码只影响预览载荷,不改变「这是多大的资源」。 + byte_len: source_byte_len, pixel_width: dimensions.map(|(width, _)| width), pixel_height: dimensions.map(|(_, height)| height), - data_url: encode_project_resource_preview_data_url(media_type, &bytes, cancellation)?, + data_url: encode_project_resource_preview_data_url(media_type, &payload, cancellation)?, + }) +} + +struct TranscodedProjectArtContainer { + bytes: Vec, + media_type: &'static str, + pixel_width: u32, + pixel_height: u32, +} + +fn transcode_project_art_container( + relative_path: &str, + bytes: &[u8], +) -> Option> { + let format = match path_extension(relative_path).as_deref()? { + "tga" => image::ImageFormat::Tga, + "tif" | "tiff" => image::ImageFormat::Tiff, + "hdr" => image::ImageFormat::Hdr, + _ => return None, + }; + Some(transcode_project_art_container_with_format( + relative_path, + bytes, + format, + )) +} + +fn transcode_project_art_container_with_format( + relative_path: &str, + bytes: &[u8], + format: image::ImageFormat, +) -> Result { + let decoded = image::load_from_memory_with_format(bytes, format) + .map_err(|error| format!("图像容器解码失败,无法在客户端预览:{relative_path}: {error}"))? + // HDR / EXR 是浮点像素格式,PNG 只能装整数像素:统一降到 8 位 RGBA, + // 与其它预览图(含 alpha 版)保持同一种像素口径。 + .to_rgba8(); + let (pixel_width, pixel_height) = decoded.dimensions(); + let pixels = u64::from(pixel_width) * u64::from(pixel_height); + if pixel_width == 0 + || pixel_height == 0 + || pixel_width > PROJECT_MEDIA_PREVIEW_MAX_DIMENSION + || pixel_height > PROJECT_MEDIA_PREVIEW_MAX_DIMENSION + || pixels > PROJECT_MEDIA_PREVIEW_MAX_PIXELS + { + return Err("图像尺寸过大,无法在客户端预览".to_string()); + } + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(decoded) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .map_err(|error| format!("图像容器转码失败:{relative_path}: {error}"))?; + Ok(TranscodedProjectArtContainer { + bytes: png, + media_type: "image/png", + pixel_width, + pixel_height, }) } @@ -426,6 +654,23 @@ fn detect_project_media_type( bytes: &[u8], kind: ProjectMediaPreviewKind, ) -> Result<&'static str, String> { + if kind == ProjectMediaPreviewKind::Model { + // 只按文件签名判定,不看扩展名:登记的是 `.glb` 却塞了别的内容时宁可报错, + // 也不要让渲染器去猜。 + if bytes.starts_with(b"glTF") { + return Ok("model/gltf-binary"); + } + if std::str::from_utf8(bytes).is_ok_and(|text| { + let trimmed = text.trim_start(); + trimmed.starts_with('{') && trimmed.contains("\"asset\"") + }) { + return Ok("model/gltf+json"); + } + if bytes.starts_with(b"Kaydara FBX Binary") || bytes.starts_with(b"; FBX") { + return Ok("application/octet-stream"); + } + return Err("模型预览只支持 GLB、glTF 或 FBX 文件".to_string()); + } if kind == ProjectMediaPreviewKind::Art && path_extension(path).as_deref() == Some("svg") { validate_safe_svg(bytes)?; return Ok("image/svg+xml"); @@ -619,7 +864,7 @@ mod tests { #[test] fn text_preview_requires_utf8_and_a_supported_extension() { - let root = tempfile::tempdir().expect("temp root"); + let root = crate::tests::canonical_test_tempdir("resource-preview-fixture-"); fs::create_dir_all(root.path().join("docs")).expect("docs dir"); fs::write(root.path().join("docs/design.md"), "# 设计\n\n正文").expect("markdown"); fs::write(root.path().join("docs/legacy.txt"), [0xff, 0xfe]).expect("legacy text"); @@ -641,7 +886,7 @@ mod tests { #[test] fn media_preview_accepts_safe_svg_and_rejects_active_svg() { - let root = tempfile::tempdir().expect("temp root"); + let root = crate::tests::canonical_test_tempdir("resource-preview-fixture-"); fs::create_dir_all(root.path().join("assets")).expect("assets dir"); fs::write( root.path().join("assets/icon.svg"), @@ -685,7 +930,7 @@ mod tests { #[test] fn media_preview_reports_safe_dimensions_for_extended_art_images() { - let root = tempfile::tempdir().expect("temp root"); + let root = crate::tests::canonical_test_tempdir("resource-preview-fixture-"); fs::create_dir_all(root.path().join("assets")).expect("assets dir"); let mut gif = b"GIF89a".to_vec(); @@ -731,12 +976,156 @@ mod tests { } } + /// 引擎图像容器(Cocos 的 `.tga` / `.tif` / `.hdr`)在浏览器里没有解码器: + /// 原生侧必须把它转成 PNG 再交给前端,并把真实像素尺寸一并带出去。 + #[test] + fn art_container_preview_transcodes_tga_to_png() { + // 用工程自带的临时目录助手:它会 canonicalize 并把目录 owner 归到当前用户, + // 否则 `%TEMP%` 下的临时目录在 Windows 上会被 owner 校验直接拒绝。 + let root = crate::tests::canonical_test_tempdir("engine-art-container-"); + fs::create_dir_all(root.path().join("assets")).expect("assets dir"); + + let mut tga = vec![0_u8; 18]; + tga[2] = 2; // 未压缩真彩色 + tga[12..14].copy_from_slice(&2_u16.to_le_bytes()); // 宽 2 + tga[14..16].copy_from_slice(&2_u16.to_le_bytes()); // 高 2 + tga[16] = 24; // 24 位像素 + tga[17] = 0x20; // 左上角原点 + tga.extend_from_slice(&[ + 0, 0, 255, // BGR:红 + 0, 255, 0, // 绿 + 255, 0, 0, // 蓝 + 255, 255, 255, // 白 + ]); + fs::write(root.path().join("assets/grass.tga"), tga).expect("tga"); + + let preview = load_local_project_media_preview( + root.path(), + "assets/grass.tga", + ProjectMediaPreviewKind::Art, + ) + .expect("transcoded tga preview"); + assert_eq!(preview.media_type, "image/png"); + assert_eq!(preview.pixel_width, Some(2)); + assert_eq!(preview.pixel_height, Some(2)); + assert!(preview.data_url.starts_with("data:image/png;base64,")); + } + + /// 引擎序列化资源:UTF-8 的给正文(前端再抽结构摘要),非 UTF-8 的**降级**成 + /// `content: null`(卡面画类型卡),不是报错;未登记进白名单的扩展名一律拒绝。 + #[test] + fn structured_preview_reads_cocos_serialized_assets_and_degrades_binary() { + let root = crate::tests::canonical_test_tempdir("engine-structured-preview-"); + let cancellation = ProjectResourcePreviewScopeCancellation::uncancelled(); + fs::create_dir_all(root.path().join("assets/anim")).expect("anim dir"); + fs::write( + root.path().join("assets/anim/walk.anim"), + "[{\"__type__\":\"cc.AnimationClip\",\"_duration\":1.25}]", + ) + .expect("animation clip"); + fs::write( + root.path().join("assets/anim/broken.pac"), + [0xff, 0xfe, 0x00], + ) + .expect("binary pac variant"); + fs::write( + root.path().join("assets/anim/glow.effect"), + "CCEffect %{\n techniques: []\n}", + ) + .expect("effect"); + + let clip = load_local_project_structured_preview_with_cancellation( + root.path(), + "assets/anim/walk.anim", + "application/json", + &cancellation, + ) + .expect("animation clip preview"); + assert_eq!(clip.media_type, "application/json"); + assert!(clip + .content + .as_deref() + .is_some_and(|content| content.contains("cc.AnimationClip"))); + + let effect = load_local_project_structured_preview_with_cancellation( + root.path(), + "assets/anim/glow.effect", + "text/plain", + &cancellation, + ) + .expect("effect preview"); + assert_eq!(effect.media_type, "text/plain"); + assert!(effect.content.is_some()); + + let degraded = load_local_project_structured_preview_with_cancellation( + root.path(), + "assets/anim/broken.pac", + "application/json", + &cancellation, + ) + .expect("binary variant degrades instead of failing"); + assert_eq!(degraded.content, None); + assert_eq!(degraded.byte_len, 3); + + assert!(load_local_project_structured_preview_with_cancellation( + root.path(), + "assets/anim/walk.anim", + "audio/mpeg", + &cancellation, + ) + .is_err()); + } + + /// 模型预览按**文件签名**判定媒体类型:GLB / glTF / FBX 各走各的,别的内容一律拒绝。 + #[test] + fn media_preview_detects_engine_model_media_types() { + let root = crate::tests::canonical_test_tempdir("engine-model-media-"); + fs::create_dir_all(root.path().join("assets/model")).expect("model dir"); + let mut glb = b"glTF".to_vec(); + glb.extend_from_slice(&2_u32.to_le_bytes()); + glb.extend_from_slice(&12_u32.to_le_bytes()); + fs::write(root.path().join("assets/model/hero.glb"), glb).expect("glb"); + fs::write( + root.path().join("assets/model/hero.gltf"), + "{\"asset\":{\"version\":\"2.0\"},\"meshes\":[]}", + ) + .expect("gltf"); + let mut fbx = b"Kaydara FBX Binary \x00".to_vec(); + fbx.extend_from_slice(&[0; 16]); + fs::write(root.path().join("assets/model/hero.fbx"), fbx).expect("fbx"); + fs::write(root.path().join("assets/model/broken.glb"), b"not-a-model") + .expect("broken model"); + + for (path, expected) in [ + ("assets/model/hero.glb", "model/gltf-binary"), + ("assets/model/hero.gltf", "model/gltf+json"), + ("assets/model/hero.fbx", "application/octet-stream"), + ] { + let preview = + load_local_project_media_preview(root.path(), path, ProjectMediaPreviewKind::Model) + .expect("model preview"); + assert_eq!(preview.media_type, expected, "{path}"); + assert!( + preview + .data_url + .starts_with(&format!("data:{expected};base64,")), + "{path}" + ); + } + assert!(load_local_project_media_preview( + root.path(), + "assets/model/broken.glb", + ProjectMediaPreviewKind::Model, + ) + .is_err()); + } + #[cfg(unix)] #[test] fn resource_preview_rejects_symlink_and_hardlink_files() { use std::os::unix::fs::symlink; - let root = tempfile::tempdir().expect("temp root"); + let root = crate::tests::canonical_test_tempdir("resource-preview-fixture-"); let outside = tempfile::tempdir().expect("outside"); fs::create_dir_all(root.path().join("docs")).expect("docs dir"); let source = outside.path().join("source.md"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/runner.rs index be22c4d96..71fd5bf4c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner.rs @@ -12,21 +12,20 @@ pub(crate) use client::{ clear_external_agent_runner_platform_session, compact_external_agent_runner_context, configure_external_agent_runner, configure_external_agent_runner_read_only, continue_external_agent_runner_action, ensure_external_agent_runner_started, - ensure_external_agent_runner_started_for_gui, install_external_agent_runner_platform_session, + ensure_external_agent_runner_started_for_gui, hold_external_agent_runner_gui_participant_lock, + install_external_agent_runner_platform_session, interrupt_external_agent_runner_provider_for_steer_decision, notify_external_agent_runner, pause_external_agent_runner, read_external_agent_runner_status, require_external_agent_runner_configured_for_cli_runtime_write, require_external_agent_runner_for_cli_runtime_write, resume_external_agent_runner, shutdown_external_agent_runner, shutdown_external_agent_runner_for_client_exit, - shutdown_external_agent_runner_if_idle, steer_external_agent_runner, - wake_external_agent_runner_pending, wake_external_agent_runner_pending_for_run, + shutdown_external_agent_runner_for_gui_exit, shutdown_external_agent_runner_if_idle, + steer_external_agent_runner, wake_external_agent_runner_pending, + wake_external_agent_runner_pending_for_run, }; #[cfg(windows)] pub(crate) use endpoint::validate_windows_regular_file_handle; -pub(crate) use endpoint::{ - acquire_external_agent_runner_gui_owner_lock, external_agent_runner_enabled, - external_agent_runner_is_server_process, -}; +pub(crate) use endpoint::{external_agent_runner_enabled, external_agent_runner_is_server_process}; #[allow(unused_imports)] pub(crate) use protocol::{ExternalAgentRunnerStatus, EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION}; pub(crate) use server::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 6c6f9fdbf..e27a07792 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -25,35 +25,76 @@ pub(super) struct ExternalAgentRunnerGuiOwnerAttachmentState { struct ExternalAgentRunnerGuiOwnerRegistration { generation: u64, + claim_mode: ExternalAgentRunnerGuiOwnerClaimMode, config_dir: PathBuf, params: ExternalAgentRunnerRequestParams, attached_boot_id: Option, } +/// claim 解析模式。 +/// +/// `Adopt` 用于窗口启动:沿用现有 durable claim,只有 claim 缺失或不可读时才发布。 +/// `Publish` 用于本窗口改动了平台登录态:发布新 epoch,成为新的登录态权威。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum ExternalAgentRunnerGuiOwnerClaimMode { + Adopt, + Publish, +} + static EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE: OnceLock< Mutex, > = OnceLock::new(); +static EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK: OnceLock< + Mutex>, +> = OnceLock::new(); + fn external_agent_runner_gui_owner_attachment_state( ) -> &'static Mutex { EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE .get_or_init(|| Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default())) } +fn external_agent_runner_gui_participant_lock( +) -> &'static Mutex> { + EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK.get_or_init(|| Mutex::new(None)) +} + +/// 取得并持有本窗口的界面参与锁,直到窗口退出。 +/// +/// 参与锁是共享句柄锁:同一 AppData 的多个窗口可以同时持有,Runner 用独占探测 +/// 判断是否仍有窗口存活,因此这个锁同时也是“Runner 不能先退出”的存活凭据。 +pub(crate) fn hold_external_agent_runner_gui_participant_lock( + config_dir: &Path, +) -> Result<(), String> { + let lock = acquire_external_agent_runner_gui_participant_lock(config_dir)?; + *lock_unpoisoned(external_agent_runner_gui_participant_lock()) = Some(lock); + Ok(()) +} + +fn release_external_agent_runner_gui_participant_lock() { + drop(lock_unpoisoned(external_agent_runner_gui_participant_lock()).take()); +} + +/// 登记本窗口的 owner claim 与 attach 参数。 +/// +/// 这里不做 claim 文件 IO:claim 由调用方按 `claim_mode` 解析后写进 `params`, +/// 因此该函数可以在没有真实 AppData 的单元测试里使用。 pub(super) fn register_external_agent_runner_gui_owner_attachment( state: &Mutex, config_dir: &Path, + claim_mode: ExternalAgentRunnerGuiOwnerClaimMode, mut params: ExternalAgentRunnerRequestParams, ) -> Result<(), String> { let mut state = lock_unpoisoned(state); state.generation = state.generation.wrapping_add(1); let generation = state.generation; - params.gui_owner_session_revision = Some(generation); - if let Some(owner_epoch) = params.gui_owner_epoch.as_deref() { - write_external_agent_runner_gui_owner_claim_atomic(config_dir, owner_epoch, generation)?; + if params.gui_owner_session_revision.is_none() { + params.gui_owner_session_revision = Some(generation); } state.registration = Some(ExternalAgentRunnerGuiOwnerRegistration { generation, + claim_mode, config_dir: config_dir.to_path_buf(), params, attached_boot_id: None, @@ -61,6 +102,29 @@ pub(super) fn register_external_agent_runner_gui_owner_attachment( Ok(()) } +pub(super) fn reserve_external_agent_runner_gui_owner_claim_revision( + state: &Mutex, +) -> u64 { + let mut state = lock_unpoisoned(state); + state.generation = state.generation.wrapping_add(1); + state.generation +} + +pub(super) fn resolve_external_agent_runner_gui_owner_claim( + config_dir: &Path, + claim_mode: ExternalAgentRunnerGuiOwnerClaimMode, + session_revision: u64, +) -> Result { + match claim_mode { + ExternalAgentRunnerGuiOwnerClaimMode::Adopt => { + adopt_or_publish_external_agent_runner_gui_owner_claim(config_dir, session_revision) + } + ExternalAgentRunnerGuiOwnerClaimMode::Publish => { + publish_external_agent_runner_gui_owner_claim(config_dir, session_revision) + } + } +} + pub(super) fn attach_registered_external_agent_runner_gui_owner_if_needed_with( state: &Mutex, config_dir: &Path, @@ -68,27 +132,72 @@ pub(super) fn attach_registered_external_agent_runner_gui_owner_if_needed_with Result<(), String> where - F: FnOnce(&ExternalAgentRunnerEndpoint, ExternalAgentRunnerRequestParams) -> Result<(), String>, + F: Fn(&ExternalAgentRunnerEndpoint, ExternalAgentRunnerRequestParams) -> Result<(), String>, { - let Some((generation, params)) = ({ - let state = lock_unpoisoned(state); - state.registration.as_ref().and_then(|registration| { - (registration.config_dir == config_dir - && registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str())) - .then(|| (registration.generation, registration.params.clone())) - }) - }) else { - return Ok(()); - }; + const ATTACH_CLAIM_RETRY_LIMIT: usize = 3; + let mut last_claim_error = None; + for attempt in 0..ATTACH_CLAIM_RETRY_LIMIT { + let Some((generation, params, claim_mode)) = ({ + let state = lock_unpoisoned(state); + state.registration.as_ref().and_then(|registration| { + (registration.config_dir == config_dir + && registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str())) + .then(|| { + ( + registration.generation, + registration.params.clone(), + registration.claim_mode, + ) + }) + }) + }) else { + return Ok(()); + }; - attach(endpoint, params)?; - - let mut state = lock_unpoisoned(state); - if let Some(registration) = state.registration.as_mut() { - if registration.generation == generation && registration.config_dir == config_dir { - registration.attached_boot_id = Some(endpoint.boot_id.clone()); + match attach(endpoint, params) { + Ok(()) => { + let mut state = lock_unpoisoned(state); + if let Some(registration) = state.registration.as_mut() { + if registration.generation == generation + && registration.config_dir == config_dir + { + registration.attached_boot_id = Some(endpoint.boot_id.clone()); + } + } + return Ok(()); + } + Err(error) if attempt + 1 < ATTACH_CLAIM_RETRY_LIMIT && error.contains("claim") => { + // 另一个窗口在本次 attach 前后发布了新 claim:按最新 claim 重新解析后重试。 + last_claim_error = Some(error); + refresh_registered_external_agent_runner_gui_owner_claim( + state, config_dir, claim_mode, + )?; + } + Err(error) => return Err(error), } } + Err(last_claim_error.unwrap_or_else(|| "Agent Runner attach 重试后仍然失败".to_string())) +} + +fn refresh_registered_external_agent_runner_gui_owner_claim( + state: &Mutex, + config_dir: &Path, + claim_mode: ExternalAgentRunnerGuiOwnerClaimMode, +) -> Result<(), String> { + let session_revision = reserve_external_agent_runner_gui_owner_claim_revision(state); + let claim = + resolve_external_agent_runner_gui_owner_claim(config_dir, claim_mode, session_revision)?; + let mut state = lock_unpoisoned(state); + let Some(registration) = state.registration.as_mut() else { + return Ok(()); + }; + if registration.config_dir != config_dir { + return Ok(()); + } + registration.claim_mode = claim_mode; + registration.params.gui_owner_epoch = Some(claim.owner_epoch); + registration.params.gui_owner_session_revision = Some(claim.session_revision); + registration.attached_boot_id = None; Ok(()) } @@ -963,7 +1072,6 @@ pub(crate) fn shutdown_external_agent_runner() -> Result<(), String> { pub(crate) fn attach_external_agent_runner_gui_owner( event_sink: &GameCreatorManifestInvalidationEventSink, - gui_owner_epoch: &str, ) -> Result<(), String> { EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT .store(true, std::sync::atomic::Ordering::Release); @@ -971,13 +1079,25 @@ pub(crate) fn attach_external_agent_runner_gui_owner( let config_dir = external_agent_runner_config_dir() .ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?; let platform_session = crate::current_platform_session(); + // 启动阶段先采纳现有 durable claim:第二个及后续窗口与第一个窗口共享同一 + // epoch,因此不会被判定为抢走登录态权威;claim 缺失或不可读时才发布新 claim。 + let session_revision = reserve_external_agent_runner_gui_owner_claim_revision( + external_agent_runner_gui_owner_attachment_state(), + ); + let claim = resolve_external_agent_runner_gui_owner_claim( + &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, + session_revision, + )?; register_external_agent_runner_gui_owner_attachment( external_agent_runner_gui_owner_attachment_state(), &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(event_sink.port), event_sink_token: Some(event_sink.token.clone()), - gui_owner_epoch: Some(gui_owner_epoch.to_string()), + gui_owner_epoch: Some(claim.owner_epoch), + gui_owner_session_revision: Some(claim.session_revision), platform_user_id: platform_session .as_ref() .map(|session| session.user_id.clone()), @@ -987,7 +1107,10 @@ pub(crate) fn attach_external_agent_runner_gui_owner( platform_api_base_url: platform_session .as_ref() .map(|session| session.api_base_url.clone()), - platform_auth_generation: platform_session.map(|session| session.generation), + platform_auth_generation: platform_session + .as_ref() + .map(|session| session.identity_generation), + platform_auth_revision: platform_session.map(|session| session.revision), ..ExternalAgentRunnerRequestParams::default() }, )?; @@ -998,7 +1121,8 @@ pub(crate) fn install_external_agent_runner_platform_session( user_id: &str, access_token: &str, api_base_url: &str, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { let config_dir = external_agent_runner_config_dir() .ok_or_else(|| "外部 Agent Runner 尚未配置 AppData".to_string())?; @@ -1008,7 +1132,8 @@ pub(crate) fn install_external_agent_runner_platform_session( remember_external_agent_runner_platform_session( external_agent_runner_gui_owner_attachment_state(), Some((user_id, access_token, api_base_url)), - generation, + identity_generation, + revision, ) .and_then(|_| ensure_external_agent_runner(&config_dir)) .and_then(|endpoint| { @@ -1017,7 +1142,8 @@ pub(crate) fn install_external_agent_runner_platform_session( &config_dir, &endpoint, Some((user_id, access_token, api_base_url)), - generation, + identity_generation, + revision, ) }) }, @@ -1025,7 +1151,10 @@ pub(crate) fn install_external_agent_runner_platform_session( ) } -pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> Result<(), String> { +pub(crate) fn clear_external_agent_runner_platform_session( + identity_generation: u64, + revision: u64, +) -> Result<(), String> { let Some(config_dir) = external_agent_runner_config_dir() else { return Ok(()); }; @@ -1035,7 +1164,8 @@ pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> R remember_external_agent_runner_platform_session( external_agent_runner_gui_owner_attachment_state(), None, - generation, + identity_generation, + revision, ) .and_then(|_| ensure_external_agent_runner(&config_dir)) .and_then(|endpoint| { @@ -1044,7 +1174,8 @@ pub(crate) fn clear_external_agent_runner_platform_session(generation: u64) -> R &config_dir, &endpoint, None, - generation, + identity_generation, + revision, ) }) }, @@ -1057,7 +1188,8 @@ fn validate_external_agent_runner_platform_session_attachment( config_dir: &Path, endpoint: &ExternalAgentRunnerEndpoint, session: Option<(&str, &str, &str)>, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { let state = lock_unpoisoned(state); let registration = state.registration.as_ref().ok_or_else(|| { @@ -1070,7 +1202,8 @@ fn validate_external_agent_runner_platform_session_attachment( || registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str()) || registration.params.gui_owner_epoch.is_none() || registration.params.gui_owner_session_revision != Some(registration.generation) - || registration.params.platform_auth_generation != Some(generation) + || registration.params.platform_auth_generation != Some(identity_generation) + || registration.params.platform_auth_revision != Some(revision) || registration.params.platform_user_id.as_deref() != expected_user_id || registration.params.platform_access_token.as_deref() != expected_access_token || registration.params.platform_api_base_url.as_deref() != expected_api_base_url @@ -1101,34 +1234,41 @@ pub(super) fn synchronize_external_agent_runner_platform_session_with( pub(super) fn remember_external_agent_runner_platform_session( state: &Mutex, session: Option<(&str, &str, &str)>, - generation: u64, + identity_generation: u64, + revision: u64, ) -> Result<(), String> { remember_external_agent_runner_platform_session_with( state, session, - generation, - write_external_agent_runner_gui_owner_claim_atomic, + identity_generation, + revision, + publish_external_agent_runner_gui_owner_claim, ) } pub(super) fn remember_external_agent_runner_platform_session_with( state: &Mutex, session: Option<(&str, &str, &str)>, - generation: u64, - write_claim: impl FnOnce(&Path, &str, u64) -> Result<(), String>, + identity_generation: u64, + revision: u64, + publish_claim: impl FnOnce(&Path, u64) -> Result, ) -> Result<(), String> { let mut state = lock_unpoisoned(state); let Some(registration) = state.registration.as_ref() else { return Ok(()); }; - let current_generation = registration.params.platform_auth_generation.unwrap_or(0); - if generation < current_generation { + // 写入顺序只认 revision;身份代次只表达主体归属,同一账号续期会推进 revision + // 但保持 identity generation 不变。 + let current_revision = registration.params.platform_auth_revision.unwrap_or(0); + if revision < current_revision { return Ok(()); } - if generation == current_generation { + if revision == current_revision { match session { Some((user_id, access_token, api_base_url)) if registration.params.platform_user_id.as_deref() == Some(user_id) + && registration.params.platform_auth_generation + == Some(identity_generation) && registration.params.platform_access_token.as_deref() == Some(access_token) && registration.params.platform_api_base_url.as_deref() @@ -1147,29 +1287,35 @@ pub(super) fn remember_external_agent_runner_platform_session_with( } state.generation = state.generation.wrapping_add(1); let registration_generation = state.generation; - let claim = state.registration.as_ref().and_then(|registration| { - registration - .params - .gui_owner_epoch - .as_deref() - .map(|owner_epoch| (registration.config_dir.clone(), owner_epoch.to_string())) - }); - if let Some((config_dir, owner_epoch)) = claim { - write_claim(&config_dir, &owner_epoch, registration_generation)?; - } + // 本窗口改动了平台登录态:发布新 epoch 的 claim,成为新的登录态权威。 + // 并发发布以最后一次成功写入为准,落败窗口在 attach 阶段按最新 claim 重试。 + // 只有已经建立过 claim 的登记才需要发布:没有 epoch 的登记(纯 CLI / 单元测试替身) + // 不写任何 claim 文件。 + let published_claim = state + .registration + .as_ref() + .filter(|registration| registration.params.gui_owner_epoch.is_some()) + .map(|registration| registration.config_dir.clone()) + .map(|config_dir| publish_claim(&config_dir, registration_generation)) + .transpose()?; let registration = state .registration .as_mut() .expect("checked GUI owner registration must remain present while locked"); registration.generation = registration_generation; + registration.claim_mode = ExternalAgentRunnerGuiOwnerClaimMode::Publish; registration.attached_boot_id = None; registration.params.platform_user_id = session.map(|(user_id, _, _)| user_id.to_string()); registration.params.platform_access_token = session.map(|(_, access_token, _)| access_token.to_string()); registration.params.platform_api_base_url = session.map(|(_, _, api_base_url)| api_base_url.to_string()); - registration.params.platform_auth_generation = Some(generation); - registration.params.gui_owner_session_revision = Some(registration_generation); + registration.params.platform_auth_generation = Some(identity_generation); + registration.params.platform_auth_revision = Some(revision); + if let Some(claim) = published_claim { + registration.params.gui_owner_epoch = Some(claim.owner_epoch); + registration.params.gui_owner_session_revision = Some(claim.session_revision); + } Ok(()) } @@ -1263,6 +1409,25 @@ pub(crate) fn shutdown_external_agent_runner_for_client_exit() -> Result Result { + let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); + let Some(config_dir) = external_agent_runner_config_dir() else { + return Ok(true); + }; + release_external_agent_runner_gui_participant_lock(); + if external_agent_runner_lock_is_held(&external_agent_runner_gui_participant_lock_path( + &config_dir, + ))? { + return Ok(false); + } + shutdown_external_agent_runner_at(&config_dir)?; + Ok(true) +} + pub(super) fn wait_for_external_agent_runner( config_dir: &Path, child: &mut Child, @@ -1301,34 +1466,12 @@ pub(super) fn ensure_external_agent_runner( ) -> Result { let endpoint_path = external_agent_runner_endpoint_path(config_dir); let executable_fingerprint = current_external_agent_runner_executable_fingerprint()?; - if let Ok(endpoint) = read_external_agent_runner_endpoint(&endpoint_path) { - match external_agent_runner_endpoint_reuse_decision(&endpoint, &executable_fingerprint) { - ExternalAgentRunnerReuseDecision::Reuse => { - if ping_external_agent_runner(&endpoint).is_ok() { - attach_registered_external_agent_runner_gui_owner_if_needed( - config_dir, &endpoint, - )?; - return Ok(endpoint); - } - } - ExternalAgentRunnerReuseDecision::Retire => { - let incompatible_ping = send_external_agent_runner_request_with_protocol_and_id( - &endpoint, - endpoint.protocol_version, - random_identifier(b"genarrative-agent-runner-upgrade-ping-id")?, - "runner.ping", - ExternalAgentRunnerRequestParams::default(), - ); - if incompatible_ping.is_ok() { - retire_incompatible_external_agent_runner( - &endpoint_path, - &endpoint, - EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT - .load(std::sync::atomic::Ordering::Acquire), - )?; - } - } - } + if let Some(endpoint) = reuse_or_retire_external_agent_runner_endpoint( + config_dir, + &endpoint_path, + &executable_fingerprint, + )? { + return Ok(endpoint); } let mut launched = launch_external_agent_runner(config_dir)?; match wait_for_external_agent_runner(config_dir, &mut launched.child, &executable_fingerprint) { @@ -1345,11 +1488,57 @@ pub(super) fn ensure_external_agent_runner( Err(error) => { let _ = launched.child.kill(); let _ = launched.child.wait(); + // 同一 AppData 的另一个窗口可能在这段时间里已经启动了 Runner: + // 实例锁竞争失败不能立刻报成启动失败,先按最新 endpoint 复用一次。 + if let Some(endpoint) = reuse_or_retire_external_agent_runner_endpoint( + config_dir, + &endpoint_path, + &executable_fingerprint, + )? { + return Ok(endpoint); + } Err(error) } } } +fn reuse_or_retire_external_agent_runner_endpoint( + config_dir: &Path, + endpoint_path: &Path, + executable_fingerprint: &str, +) -> Result, String> { + if let Ok(endpoint) = read_external_agent_runner_endpoint(&endpoint_path) { + match external_agent_runner_endpoint_reuse_decision(&endpoint, executable_fingerprint) { + ExternalAgentRunnerReuseDecision::Reuse => { + if ping_external_agent_runner(&endpoint).is_ok() { + attach_registered_external_agent_runner_gui_owner_if_needed( + config_dir, &endpoint, + )?; + return Ok(Some(endpoint)); + } + } + ExternalAgentRunnerReuseDecision::Retire => { + let incompatible_ping = send_external_agent_runner_request_with_protocol_and_id( + &endpoint, + endpoint.protocol_version, + random_identifier(b"genarrative-agent-runner-upgrade-ping-id")?, + "runner.ping", + ExternalAgentRunnerRequestParams::default(), + ); + if incompatible_ping.is_ok() { + retire_incompatible_external_agent_runner( + endpoint_path, + &endpoint, + EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT + .load(std::sync::atomic::Ordering::Acquire), + )?; + } + } + } + } + Ok(None) +} + pub(crate) fn configure_external_agent_runner(config_dir: impl AsRef) -> Result<(), String> { let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); let config_dir = normalize_external_agent_runner_config_dir(config_dir.as_ref())?; @@ -1472,6 +1661,7 @@ pub(super) fn send_external_agent_runner_runtime_request_with_stable_identity( platform_access_token: None, platform_api_base_url: None, platform_auth_generation: None, + platform_auth_revision: None, }; match stable_identity { Some(stable_identity) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs index b254c88f9..3186fd863 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/dispatch.rs @@ -1,6 +1,6 @@ use super::{endpoint::*, project_owner::*, protocol::*, state::*}; use crate::{ - install_game_creator_manifest_invalidation_event_sink, + register_game_creator_manifest_invalidation_event_sink, validate_game_creator_manifest_invalidation_event_sink, }; use serde::Deserialize; @@ -116,9 +116,9 @@ fn apply_external_agent_runner_gui_owner_attachment( .gui_owner_session_revision .ok_or_else(|| "Agent Runner GUI owner 缺少 session revision".to_string())?; let config_dir = state - .gui_owner_lock_path + .gui_participant_lock_path .parent() - .ok_or_else(|| "Agent Runner GUI owner 锁缺少 AppData 父目录".to_string())?; + .ok_or_else(|| "AGC 界面参与锁缺少 AppData 父目录".to_string())?; let mut active_claim = lock_unpoisoned(&state.gui_owner_platform_session_claim); let durable_claim = read_external_agent_runner_gui_owner_claim(config_dir)?; if durable_claim.owner_epoch != requested_epoch @@ -127,43 +127,59 @@ fn apply_external_agent_runner_gui_owner_attachment( return Err("Agent Runner GUI owner claim 已过期".to_string()); } let requested_claim = (requested_epoch.to_string(), requested_revision); - let replace_claim = active_claim.as_ref() != Some(&requested_claim); + // 同一 AppData 的多个窗口共享同一个 epoch:只有 epoch 变化(本窗口发布了新的 + // 登录态权威)才允许强制替换会话。同一 epoch 内的重复 attach 只做单调校验, + // 因此后开窗口的“无登录态 attach”不会清空已有会话。 + let epoch_changed = match active_claim.as_ref() { + Some(active) => active.0 != requested_epoch, + None => true, + }; + let replace_claim = epoch_changed; let result = match ( params.platform_user_id.as_deref(), params.platform_access_token.as_deref(), params.platform_api_base_url.as_deref(), params.platform_auth_generation, + params.platform_auth_revision, ) { - (Some(user_id), Some(access_token), Some(api_base_url), Some(generation)) => { + ( + Some(user_id), + Some(access_token), + Some(api_base_url), + Some(identity_generation), + Some(revision), + ) => { if replace_claim { crate::replace_platform_session_for_gui_owner( user_id, access_token, api_base_url, - generation, + identity_generation, + revision, ) } else { crate::install_platform_session_checked( user_id, access_token, api_base_url, - generation, + identity_generation, + revision, ) } } - (None, None, None, Some(generation)) => { + (None, None, None, Some(identity_generation), Some(revision)) => { if replace_claim { - crate::clear_platform_session_for_gui_owner(generation); + crate::clear_platform_session_for_gui_owner(identity_generation, revision); Ok(()) } else { - crate::clear_platform_session_checked(generation) + crate::clear_platform_session_checked(identity_generation, revision) } } - (None, None, None, None) if replace_claim => { - crate::clear_platform_session_for_gui_owner(0); + (None, None, None, None, None) if epoch_changed => { + crate::clear_platform_session_for_gui_owner(0, 0); Ok(()) } - (None, None, None, None) => Ok(()), + (None, None, None, None, None) => Ok(()), _ => Err("Agent Runner GUI owner 的平台登录态同步参数不完整".to_string()), }; result?; @@ -171,7 +187,7 @@ fn apply_external_agent_runner_gui_owner_attachment( Ok(claim) => claim, Err(error) => { *active_claim = None; - crate::clear_platform_session_for_gui_owner(0); + crate::clear_platform_session_for_gui_owner(0, 0); return Err(format!( "Agent Runner GUI owner claim 在 attach 提交期间无法核验,平台登录态已隔离:{error}" )); @@ -181,11 +197,11 @@ fn apply_external_agent_runner_gui_owner_attachment( || committed_claim.session_revision != requested_revision { *active_claim = None; - crate::clear_platform_session_for_gui_owner(0); + crate::clear_platform_session_for_gui_owner(0, 0); return Err("Agent Runner GUI owner claim 在 attach 提交期间已变化".to_string()); } if let Some(event_sink) = event_sink { - install_game_creator_manifest_invalidation_event_sink(event_sink); + register_game_creator_manifest_invalidation_event_sink(event_sink); } *active_claim = Some(requested_claim); Ok(()) @@ -195,9 +211,9 @@ pub(super) fn validate_external_agent_runner_gui_owner_claim_current( state: &ExternalAgentRunnerServerState, ) -> Result<(), String> { let config_dir = state - .gui_owner_lock_path + .gui_participant_lock_path .parent() - .ok_or_else(|| "Agent Runner GUI owner 锁缺少 AppData 父目录".to_string())?; + .ok_or_else(|| "AGC 界面参与锁缺少 AppData 父目录".to_string())?; let mut active_claim = lock_unpoisoned(&state.gui_owner_platform_session_claim); let durable_claim = read_external_agent_runner_gui_owner_claim(config_dir); let matches = durable_claim.as_ref().is_ok_and(|claim| { @@ -207,7 +223,7 @@ pub(super) fn validate_external_agent_runner_gui_owner_claim_current( return Ok(()); } *active_claim = None; - crate::clear_platform_session_for_gui_owner(0); + crate::clear_platform_session_for_gui_owner(0, 0); match durable_claim { Ok(_) => Err( "authentication-required: Agent Runner GUI owner claim 已变化,平台登录态已隔离" @@ -768,7 +784,7 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim( } } "runner.attach_gui_owner" => { - match external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path) { + match external_agent_runner_lock_is_held(&state.gui_participant_lock_path) { Ok(true) => { let event_sink = request .params @@ -810,7 +826,7 @@ pub(super) fn dispatch_external_agent_runner_runtime_request_with_owner_claim( Ok(false) => ExternalAgentRunnerResponse::failure( &request.request_id, "gui-owner-missing", - "Agent Runner 未检测到活跃 GUI owner 锁", + "Agent Runner 未检测到活跃的 AGC 界面进程", ), Err(error) => ExternalAgentRunnerResponse::failure( &request.request_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs index bfbd56241..de3ade0ae 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/endpoint.rs @@ -6,7 +6,12 @@ use std::io::{self, Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering; use std::sync::Mutex; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +const EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_ACQUIRE_TIMEOUT: Duration = Duration::from_secs(5); +const EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_RETRY_INTERVAL: Duration = + Duration::from_millis(40); pub(super) fn external_agent_runner_config_dir_lock() -> &'static Mutex> { EXTERNAL_AGENT_RUNNER_CONFIG_DIR.get_or_init(|| Mutex::new(None)) @@ -305,8 +310,8 @@ pub(super) fn external_agent_runner_lock_path(config_dir: &Path) -> PathBuf { config_dir.join(EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME) } -pub(super) fn external_agent_runner_gui_owner_lock_path(config_dir: &Path) -> PathBuf { - config_dir.join(EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME) +pub(super) fn external_agent_runner_gui_participant_lock_path(config_dir: &Path) -> PathBuf { + config_dir.join(EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_FILE_NAME) } pub(super) fn external_agent_runner_gui_owner_claim_path(config_dir: &Path) -> PathBuf { @@ -341,8 +346,9 @@ pub(super) fn read_external_agent_runner_gui_owner_claim( Ok(claim) } -pub(super) fn external_agent_runner_gui_owner_is_locked(path: &Path) -> Result { - match try_open_external_agent_runner_lock(path, "Agent Runner GUI owner 锁")? { +/// 独占探测:返回 `true` 表示仍有界面进程持有该参与锁。 +pub(super) fn external_agent_runner_lock_is_held(path: &Path) -> Result { + match try_open_external_agent_runner_lock(path, "AGC 界面参与锁")? { Some(lock) => { drop(lock); Ok(false) @@ -743,10 +749,21 @@ pub(super) fn read_current_external_agent_runner_endpoint( }) } +/// 锁文件的两种打开方式。 +/// +/// `Exclusive` 是权威探测:能否独占取得句柄决定“还有没有存活持有者”。 +/// `Shared` 是参与者持有:同一 AppData 的多个界面进程可以同时持有。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum ExternalAgentRunnerLockMode { + Exclusive, + Shared, +} + #[cfg(unix)] -pub(super) fn try_open_external_agent_runner_lock( +pub(super) fn open_external_agent_runner_lock_file( path: &Path, label: &str, + mode: ExternalAgentRunnerLockMode, ) -> Result, String> { use std::os::fd::AsRawFd; use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; @@ -809,14 +826,30 @@ pub(super) fn try_open_external_agent_runner_lock( path.display() )); } + let flock_operation = match mode { + ExternalAgentRunnerLockMode::Exclusive => libc::LOCK_EX | libc::LOCK_NB, + ExternalAgentRunnerLockMode::Shared => libc::LOCK_SH | libc::LOCK_NB, + }; // SAFETY: flock only observes the valid fd owned by `file`; `file` remains alive on success. - let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + let result = unsafe { libc::flock(file.as_raw_fd(), flock_operation) }; if result == 0 { return Ok(Some(file)); } let error = io::Error::last_os_error(); if error.kind() == io::ErrorKind::WouldBlock { - Ok(None) + return match mode { + ExternalAgentRunnerLockMode::Exclusive => Ok(None), + ExternalAgentRunnerLockMode::Shared => Err(format!( + "{label} 正被独占探测或持有,稍后重试:{}", + path.display() + )), + }; + } + if mode == ExternalAgentRunnerLockMode::Shared { + Err(format!( + "以共享方式获取 {label} 失败:{}: {error}", + path.display() + )) } else { Err(format!( "获取 {label} 系统锁失败:{}: {error}", @@ -825,39 +858,58 @@ pub(super) fn try_open_external_agent_runner_lock( } } +#[cfg(unix)] +pub(super) fn try_open_external_agent_runner_lock( + path: &Path, + label: &str, +) -> Result, String> { + open_external_agent_runner_lock_file(path, label, ExternalAgentRunnerLockMode::Exclusive) +} + #[cfg(windows)] pub(super) fn windows_external_agent_runner_lock_is_busy_error(error: &io::Error) -> bool { matches!(error.raw_os_error(), Some(32 | 33)) } #[cfg(windows)] -pub(super) fn try_open_external_agent_runner_lock( +pub(super) fn open_external_agent_runner_lock_file( path: &Path, label: &str, + mode: ExternalAgentRunnerLockMode, ) -> Result, String> { use std::os::windows::fs::OpenOptionsExt; const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + const FILE_SHARE_DELETE: u32 = 0x0000_0004; let parent = path .parent() .ok_or_else(|| format!("{label} 缺少 AppData 父目录:{}", path.display()))?; let private_parent = crate::inspect_game_creator_runtime_config_dir(parent)?; let runner_lock_path = private_parent.join(EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME); - let gui_owner_lock_path = private_parent.join(EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME); - if path != runner_lock_path && path != gui_owner_lock_path { + let gui_participant_lock_path = + private_parent.join(EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_FILE_NAME); + if path != runner_lock_path && path != gui_participant_lock_path { return Err(format!( "{label} 必须位于已验证的私有 AppData 固定锁路径:{} 或 {}", runner_lock_path.display(), - gui_owner_lock_path.display() + gui_participant_lock_path.display() )); } + let share_mode = match mode { + ExternalAgentRunnerLockMode::Exclusive => 0, + ExternalAgentRunnerLockMode::Shared => { + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE + } + }; match OpenOptions::new() .create(true) .read(true) .write(true) - .share_mode(0) + .share_mode(share_mode) .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) .open(path) { @@ -875,16 +927,30 @@ pub(super) fn try_open_external_agent_runner_lock( )); } validate_windows_regular_file_handle(&file, label)?; - // share_mode(0) gives this process an exclusive handle. At this point the fixed - // lock path is known to be a stale, single-link, non-reparse regular file inside - // the current TokenUser's private AppData. Repairing its owner is therefore safe - // and is required when Windows creates it with TokenOwner=Administrators. + // The fixed lock path is known to be a single-link, non-reparse regular file + // inside the current TokenUser's private AppData. Repairing its owner is + // therefore safe and is required when Windows creates it with + // TokenOwner=Administrators. crate::initialize_windows_game_creator_file_owner_for_current_user(path)?; validate_windows_regular_file_handle(&file, label)?; crate::secure_windows_game_creator_path_for_current_user(path, false, false)?; Ok(Some(file)) } - Err(error) if windows_external_agent_runner_lock_is_busy_error(&error) => Ok(None), + Err(error) + if mode == ExternalAgentRunnerLockMode::Exclusive + && windows_external_agent_runner_lock_is_busy_error(&error) => + { + Ok(None) + } + Err(error) + if mode == ExternalAgentRunnerLockMode::Shared + && windows_external_agent_runner_lock_is_busy_error(&error) => + { + Err(format!( + "{label} 正被独占探测或持有,稍后重试:{}", + path.display() + )) + } Err(error) => Err(format!( "安全打开 {label} 失败:{}: {error}", path.display() @@ -892,12 +958,29 @@ pub(super) fn try_open_external_agent_runner_lock( } } +#[cfg(windows)] +pub(super) fn try_open_external_agent_runner_lock( + path: &Path, + label: &str, +) -> Result, String> { + open_external_agent_runner_lock_file(path, label, ExternalAgentRunnerLockMode::Exclusive) +} + +#[cfg(not(any(unix, windows)))] +pub(super) fn open_external_agent_runner_lock_file( + path: &Path, + label: &str, + _mode: ExternalAgentRunnerLockMode, +) -> Result, String> { + Err(format!("当前平台不支持 {label} 系统锁:{}", path.display())) +} + #[cfg(not(any(unix, windows)))] pub(super) fn try_open_external_agent_runner_lock( path: &Path, label: &str, ) -> Result, String> { - Err(format!("当前平台不支持 {label} 系统锁:{}", path.display())) + open_external_agent_runner_lock_file(path, label, ExternalAgentRunnerLockMode::Exclusive) } pub(super) fn acquire_external_agent_runner_instance_lock( @@ -929,40 +1012,90 @@ pub(super) fn acquire_external_agent_runner_instance_lock( Ok(ExternalAgentRunnerInstanceLock { _file: file }) } -pub(crate) fn acquire_external_agent_runner_gui_owner_lock( +/// 取得本窗口在该 AppData 下的界面参与锁。 +/// +/// 参与锁以共享句柄打开:同一 AppData 可以同时持有任意数量的界面窗口。 +/// Runner 侧用同文件的独占探测判断“是否仍有界面进程存活”,探测窗口很短, +/// 所以这里遇到瞬时冲突时按 `EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_RETRY_INTERVAL` +/// 重试,直到 `EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_ACQUIRE_TIMEOUT` 截止。 +pub(crate) fn acquire_external_agent_runner_gui_participant_lock( config_dir: &Path, -) -> Result { - let path = external_agent_runner_gui_owner_lock_path(config_dir); - let Some(mut file) = try_open_external_agent_runner_lock(&path, "Agent Runner GUI owner 锁")? - else { - return Err("AI 游戏创作界面已由同一 AppData 目录中的其他进程运行".to_string()); - }; - let owner_epoch = uuid::Uuid::new_v4().to_string(); - let acquired_at = unix_millis(); +) -> Result { + let path = external_agent_runner_gui_participant_lock_path(config_dir); + let deadline = Instant::now() + EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_ACQUIRE_TIMEOUT; + let mut last_error = "AGC 界面参与锁未知失败".to_string(); + loop { + match open_external_agent_runner_lock_file( + &path, + "AGC 界面参与锁", + ExternalAgentRunnerLockMode::Shared, + ) { + Ok(Some(mut file)) => { + write_external_agent_runner_gui_participant_diagnostic(&mut file, &path)?; + return Ok(ExternalAgentRunnerGuiParticipantLock { _file: file }); + } + Ok(None) => { + last_error = format!("AGC 界面参与锁无法以共享方式取得:{}", path.display()); + } + Err(error) => last_error = error, + } + if Instant::now() >= deadline { + return Err(format!("取得 AGC 界面参与锁失败:{last_error}")); + } + thread::sleep(EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_RETRY_INTERVAL); + } +} + +/// 参与锁诊断内容只由首个窗口写入,后续窗口不覆写,避免并发写坏 JSON。 +fn write_external_agent_runner_gui_participant_diagnostic( + file: &mut File, + path: &Path, +) -> Result<(), String> { + let existing_len = file.metadata().map(|metadata| metadata.len()).unwrap_or(0); + if existing_len > 0 { + return Ok(()); + } let diagnostic = serde_json::to_vec(&json!({ "protocolVersion": EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, "pid": std::process::id(), - "ownerEpoch": owner_epoch, - "acquiredAt": acquired_at, + "instanceId": uuid::Uuid::new_v4().to_string(), + "acquiredAt": unix_millis(), })) - .map_err(|error| format!("生成 Agent Runner GUI owner 锁信息失败:{error}"))?; + .map_err(|error| format!("生成 AGC 界面参与锁信息失败:{error}"))?; file.set_len(0) .and_then(|_| file.seek(SeekFrom::Start(0)).map(|_| ())) .and_then(|_| file.write_all(&diagnostic)) .and_then(|_| file.sync_data()) - .map_err(|error| { - format!( - "写入 Agent Runner GUI owner 锁信息失败:{}: {error}", - path.display() - ) - })?; - write_external_agent_runner_gui_owner_claim_atomic(config_dir, &owner_epoch, 0)?; - Ok(ExternalAgentRunnerGuiOwnerLock { - _file: file, + .map_err(|error| format!("写入 AGC 界面参与锁信息失败:{}: {error}", path.display())) +} + +/// 发布新的 durable claim:新 epoch + 本次会话 revision。 +/// +/// 发布是“谁改动登录态谁成为新 epoch 权威”的实现;并发发布以最后一次 +/// 成功写入为准,落败窗口按最新 claim 重试。 +pub(crate) fn publish_external_agent_runner_gui_owner_claim( + config_dir: &Path, + session_revision: u64, +) -> Result { + let owner_epoch = uuid::Uuid::new_v4().to_string(); + write_external_agent_runner_gui_owner_claim_atomic(config_dir, &owner_epoch, session_revision)?; + Ok(ExternalAgentRunnerGuiOwnerClaim { owner_epoch, + session_revision, }) } +/// 采纳现有 durable claim;只有 claim 缺失或不可读时才发布新 claim。 +pub(crate) fn adopt_or_publish_external_agent_runner_gui_owner_claim( + config_dir: &Path, + session_revision: u64, +) -> Result { + match read_external_agent_runner_gui_owner_claim(config_dir) { + Ok(claim) => Ok(claim), + Err(_) => publish_external_agent_runner_gui_owner_claim(config_dir, session_revision), + } +} + pub(super) fn write_external_agent_runner_gui_owner_claim_atomic( config_dir: &Path, owner_epoch: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs index 4f0e3d555..5f4ae187c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/protocol.rs @@ -13,8 +13,8 @@ pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 7; pub(super) const EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json"; pub(super) const EXTERNAL_AGENT_RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock"; -pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME: &str = - "agent-runner.gui-owner.lock"; +pub(super) const EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_FILE_NAME: &str = + "agent-runner.gui-participant.lock"; pub(super) const EXTERNAL_AGENT_RUNNER_GUI_OWNER_CLAIM_FILE_NAME: &str = "agent-runner.gui-owner.claim.json"; pub(super) const EXTERNAL_AGENT_RUNNER_PROJECT_OWNER_FILE_NAME: &str = "execution-owner.lock"; @@ -280,6 +280,10 @@ pub(super) struct ExternalAgentRunnerRequestParams { pub(super) platform_api_base_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(super) platform_auth_generation: Option, + /// 原生写入 revision:只用于 install / clear 的顺序判定。同一身份的凭据轮换会推进 + /// revision,但不推进 `platform_auth_generation`(身份代次)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) platform_auth_revision: Option, } #[derive(Deserialize, Serialize)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs index cde54a691..998ca0e9f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/server.rs @@ -156,7 +156,7 @@ fn external_agent_runner_watchdog_tick(state: &ExternalAgentRunnerServerState) - if !state.gui_owner_attached.load(Ordering::Acquire) { return false; } - match external_agent_runner_gui_owner_is_locked(&state.gui_owner_lock_path) { + match external_agent_runner_lock_is_held(&state.gui_participant_lock_path) { Ok(true) => { let _ = validate_external_agent_runner_gui_owner_claim_current(state); false @@ -224,7 +224,7 @@ pub(crate) fn run_external_agent_runner_server( )?; let gui_owner_present_at_start = resolve_external_agent_runner_initial_gui_owner( gui_owner_required, - external_agent_runner_gui_owner_is_locked(&external_agent_runner_gui_owner_lock_path( + external_agent_runner_lock_is_held(&external_agent_runner_gui_participant_lock_path( &config_dir, ))?, )?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs index 3070039b6..7dda8ab43 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/state.rs @@ -16,7 +16,7 @@ pub(super) struct ExternalAgentRunnerServerState { pub(super) draining: AtomicBool, pub(super) active_connections: AtomicUsize, pub(super) known_roots: Mutex>, - pub(super) gui_owner_lock_path: PathBuf, + pub(super) gui_participant_lock_path: PathBuf, pub(super) project_execution_owners: Mutex>, project_execution_owner_recovery_changed: Condvar, @@ -80,10 +80,10 @@ impl Drop for ExternalAgentRunnerProjectExecutionOwnerRecoveryGuard<'_> { impl ExternalAgentRunnerServerState { pub(super) fn new(endpoint_path: PathBuf, endpoint: ExternalAgentRunnerEndpoint) -> Self { - let gui_owner_lock_path = endpoint_path + let gui_participant_lock_path = endpoint_path .parent() - .map(external_agent_runner_gui_owner_lock_path) - .unwrap_or_else(|| PathBuf::from(EXTERNAL_AGENT_RUNNER_GUI_OWNER_LOCK_FILE_NAME)); + .map(external_agent_runner_gui_participant_lock_path) + .unwrap_or_else(|| PathBuf::from(EXTERNAL_AGENT_RUNNER_GUI_PARTICIPANT_LOCK_FILE_NAME)); Self { endpoint_path, endpoint: Mutex::new(endpoint), @@ -94,7 +94,7 @@ impl ExternalAgentRunnerServerState { draining: AtomicBool::new(false), active_connections: AtomicUsize::new(0), known_roots: Mutex::new(BTreeSet::new()), - gui_owner_lock_path, + gui_participant_lock_path, project_execution_owners: Mutex::new(BTreeMap::new()), project_execution_owner_recovery_changed: Condvar::new(), write_request_cache: Mutex::new(ExternalAgentRunnerRequestCache::default()), @@ -231,16 +231,10 @@ pub(super) struct ExternalAgentRunnerInstanceLock { pub(super) _file: File, } +/// 界面进程持有的参与锁。共享句柄,同一 AppData 可同时存在多个窗口。 #[derive(Debug)] -pub(crate) struct ExternalAgentRunnerGuiOwnerLock { +pub(crate) struct ExternalAgentRunnerGuiParticipantLock { pub(super) _file: File, - pub(super) owner_epoch: String, -} - -impl ExternalAgentRunnerGuiOwnerLock { - pub(crate) fn owner_epoch(&self) -> &str { - &self.owner_epoch - } } pub(super) struct ExternalAgentRunnerProjectOwnerStorage { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index 20a6dfc0d..f1d2f7a39 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -44,6 +44,23 @@ fn private_runner_test_config_dir(directory: &TestDirectoryGuard) -> PathBuf { .expect("prepare private runner AppData") } +/// 模拟一个界面窗口:持有界面参与锁,并发布自己的 owner claim。 +struct TestGuiParticipant { + _lock: ExternalAgentRunnerGuiParticipantLock, + owner_epoch: String, +} + +fn acquire_test_gui_participant(config_dir: &Path, session_revision: u64) -> TestGuiParticipant { + let lock = acquire_external_agent_runner_gui_participant_lock(config_dir) + .expect("acquire GUI participant lock"); + let claim = publish_external_agent_runner_gui_owner_claim(config_dir, session_revision) + .expect("publish GUI owner claim"); + TestGuiParticipant { + _lock: lock, + owner_epoch: claim.owner_epoch, + } +} + fn acquire_project_owner_after_release( root: &Path, boot_id: &str, @@ -574,8 +591,13 @@ fn gui_owner_registration_replays_once_for_each_runner_boot() { event_sink_token: Some(event_sink_token.clone()), ..ExternalAgentRunnerRequestParams::default() }; - register_external_agent_runner_gui_owner_attachment(&state, &config_dir, params) - .expect("register GUI owner attachment"); + register_external_agent_runner_gui_owner_attachment( + &state, + &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, + params, + ) + .expect("register GUI owner attachment"); let calls = std::cell::RefCell::new(Vec::new()); let endpoint_a = test_endpoint( @@ -657,6 +679,7 @@ fn gui_owner_registration_replays_only_the_latest_platform_session() { register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(31_330), event_sink_token: Some("f".repeat(64)), @@ -668,14 +691,16 @@ fn gui_owner_registration_replays_only_the_latest_platform_session() { &state, Some(("user-a", "token-a", "https://dev.genarrative.world")), 4, + 4, ) .expect("remember owner A session"); - remember_external_agent_runner_platform_session(&state, None, 5) + remember_external_agent_runner_platform_session(&state, None, 5, 5) .expect("remember logged-out session"); remember_external_agent_runner_platform_session( &state, Some(("user-a", "late-token-a", "https://dev.genarrative.world")), 4, + 4, ) .expect("ignore stale owner A session"); remember_external_agent_runner_platform_session( @@ -686,12 +711,14 @@ fn gui_owner_registration_replays_only_the_latest_platform_session() { "https://dev.genarrative.world", )), 5, + 5, ) .expect("ignore conflicting same-generation session"); remember_external_agent_runner_platform_session( &state, Some(("user-b", "token-b", "https://dev.genarrative.world")), 6, + 6, ) .expect("remember latest owner B session"); @@ -725,6 +752,7 @@ fn gui_owner_platform_session_change_marks_the_same_boot_for_reattach() { register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(31_331), event_sink_token: Some("d".repeat(64)), @@ -732,6 +760,7 @@ fn gui_owner_platform_session_change_marks_the_same_boot_for_reattach() { platform_access_token: Some("token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(1), + platform_auth_revision: Some(1), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -752,7 +781,7 @@ fn gui_owner_platform_session_change_marks_the_same_boot_for_reattach() { ) .expect("attach owner A"); - remember_external_agent_runner_platform_session(&state, None, 2) + remember_external_agent_runner_platform_session(&state, None, 2, 2) .expect("remember logged-out session"); attach_registered_external_agent_runner_gui_owner_if_needed_with( &state, @@ -776,11 +805,13 @@ fn stale_gui_owner_attach_completion_cannot_mark_new_session_as_attached() { register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { platform_user_id: Some("user-a".to_string()), platform_access_token: Some("token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(1), + platform_auth_revision: Some(1), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -800,6 +831,7 @@ fn stale_gui_owner_attach_completion_cannot_mark_new_session_as_attached() { &state, Some(("user-b", "token-b", "https://dev.genarrative.world")), 2, + 2, ) .expect("remember owner B while owner A attach is in flight"); Ok(()) @@ -827,8 +859,7 @@ fn stale_gui_owner_attach_completion_cannot_mark_new_session_as_attached() { fn gui_owner_platform_session_payload_clears_runner_session() { let directory = unique_test_directory(); let config_dir = private_runner_test_config_dir(&directory); - let owner = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("acquire platform-session clear owner"); + let owner = acquire_test_gui_participant(&config_dir, 0); let state = ExternalAgentRunnerServerState::new( config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), test_endpoint("platform-clear-token", "platform-clear-boot", 31_333), @@ -841,9 +872,10 @@ fn gui_owner_platform_session_payload_clears_runner_session() { apply_external_agent_runner_gui_owner_platform_session( &state, &ExternalAgentRunnerRequestParams { - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(0), platform_auth_generation: Some(2), + platform_auth_revision: Some(2), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -855,8 +887,7 @@ fn gui_owner_platform_session_payload_clears_runner_session() { fn gui_owner_partial_platform_session_payload_fails_without_mutation() { let directory = unique_test_directory(); let config_dir = private_runner_test_config_dir(&directory); - let owner = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("acquire partial-session owner"); + let owner = acquire_test_gui_participant(&config_dir, 0); let state = ExternalAgentRunnerServerState::new( config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), test_endpoint("platform-partial-token", "platform-partial-boot", 31_334), @@ -870,11 +901,12 @@ fn gui_owner_partial_platform_session_payload_fails_without_mutation() { let error = apply_external_agent_runner_gui_owner_platform_session( &state, &ExternalAgentRunnerRequestParams { - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(0), platform_user_id: Some("runner-owner-b".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(2), + platform_auth_revision: Some(2), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -896,9 +928,8 @@ fn new_gui_owner_epoch_replaces_higher_generation_runner_session_and_rejects_old "runner-token-seed", "https://dev.genarrative.world", ); - let owner_a = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("acquire old GUI owner epoch"); - let owner_a_epoch = owner_a.owner_epoch().to_string(); + let owner_a = acquire_test_gui_participant(&config_dir, 0); + let owner_a_epoch = owner_a.owner_epoch.clone(); apply_external_agent_runner_gui_owner_platform_session( &state, &ExternalAgentRunnerRequestParams { @@ -908,29 +939,31 @@ fn new_gui_owner_epoch_replaces_higher_generation_runner_session_and_rejects_old platform_access_token: Some("runner-token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(10), + platform_auth_revision: Some(10), ..ExternalAgentRunnerRequestParams::default() }, ) .expect("old GUI installs high-generation owner A"); drop(owner_a); - let owner_b = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("acquire new GUI owner epoch"); + let owner_b = acquire_test_gui_participant(&config_dir, 0); apply_external_agent_runner_gui_owner_platform_session( &state, &ExternalAgentRunnerRequestParams { - gui_owner_epoch: Some(owner_b.owner_epoch().to_string()), + gui_owner_epoch: Some(owner_b.owner_epoch.clone()), gui_owner_session_revision: Some(0), platform_user_id: Some("runner-owner-b".to_string()), platform_access_token: Some("runner-token-b".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(1), + platform_auth_revision: Some(1), ..ExternalAgentRunnerRequestParams::default() }, ) .expect("new GUI epoch replaces higher-generation old owner"); assert_eq!( - crate::current_platform_session().map(|session| (session.user_id, session.generation)), + crate::current_platform_session() + .map(|session| (session.user_id, session.identity_generation)), Some(("runner-owner-b".to_string(), 1)) ); @@ -943,6 +976,7 @@ fn new_gui_owner_epoch_replaces_higher_generation_runner_session_and_rejects_old platform_access_token: Some("runner-token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(11), + platform_auth_revision: Some(11), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -963,8 +997,7 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_ config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), test_endpoint(token, "platform-claim-gate-boot", 31_337), ); - let owner = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("acquire claim gate owner"); + let owner = acquire_test_gui_participant(&config_dir, 0); let _session = crate::install_test_platform_session( "runner-owner-seed", "runner-token-seed", @@ -973,19 +1006,20 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_ apply_external_agent_runner_gui_owner_platform_session( &state, &ExternalAgentRunnerRequestParams { - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(0), platform_user_id: Some("runner-owner-a".to_string()), platform_access_token: Some("runner-token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(8), + platform_auth_revision: Some(8), ..ExternalAgentRunnerRequestParams::default() }, ) .expect("attach owner A claim"); state.gui_owner_attached.store(true, Ordering::Release); - write_external_agent_runner_gui_owner_claim_atomic(&config_dir, owner.owner_epoch(), 1) + write_external_agent_runner_gui_owner_claim_atomic(&config_dir, owner.owner_epoch.as_str(), 1) .expect("advance durable claim before reattach"); assert!( !external_agent_runner_shutdown_if_gui_owner_lost(&state) @@ -996,12 +1030,13 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_ apply_external_agent_runner_gui_owner_platform_session( &state, &ExternalAgentRunnerRequestParams { - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(1), platform_user_id: Some("runner-owner-b".to_string()), platform_access_token: Some("runner-token-b".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(1), + platform_auth_revision: Some(1), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -1009,7 +1044,8 @@ fn durable_claim_revision_change_clears_runner_session_and_blocks_runtime_until_ validate_external_agent_runner_gui_owner_claim_current(&state) .expect("reattached owner B claim is current"); assert_eq!( - crate::current_platform_session().map(|session| (session.user_id, session.generation)), + crate::current_platform_session() + .map(|session| (session.user_id, session.identity_generation)), Some(("runner-owner-b".to_string(), 1)) ); } @@ -1041,18 +1077,19 @@ fn failed_platform_session_sync_fences_runner_before_returning_error() { fn failed_gui_owner_claim_write_is_fenced_before_local_session_can_change() { let directory = unique_test_directory(); let config_dir = private_runner_test_config_dir(&directory); - let owner = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("acquire claim-write failure owner"); + let owner = acquire_test_gui_participant(&config_dir, 0); let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), platform_user_id: Some("runner-owner-a".to_string()), platform_access_token: Some("runner-token-a".to_string()), platform_api_base_url: Some("https://dev.genarrative.world".to_string()), platform_auth_generation: Some(1), + platform_auth_revision: Some(1), ..ExternalAgentRunnerRequestParams::default() }, ) @@ -1069,7 +1106,8 @@ fn failed_gui_owner_claim_write_is_fenced_before_local_session_can_change() { "https://dev.genarrative.world", )), 2, - |_, _, _| Err("injected durable claim write failure".to_string()), + 2, + |_, _| Err("injected durable claim write failure".to_string()), ) }, || { @@ -1118,6 +1156,7 @@ fn gui_owner_registration_failed_replay_remains_pending_for_same_boot() { register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams::default(), ) .expect("register GUI owner attachment"); @@ -1166,6 +1205,7 @@ fn gui_owner_registration_missing_event_sink_confirmation_retries_same_boot() { register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(31_322), event_sink_token: Some("c".repeat(64)), @@ -1215,6 +1255,7 @@ fn gui_owner_registration_false_event_sink_confirmation_retries_same_boot() { register_external_agent_runner_gui_owner_attachment( &state, &config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(31_323), event_sink_token: Some("d".repeat(64)), @@ -1267,6 +1308,7 @@ fn gui_owner_registration_does_not_cross_config_dirs() { register_external_agent_runner_gui_owner_attachment( &state, ®istered_config_dir, + ExternalAgentRunnerGuiOwnerClaimMode::Adopt, ExternalAgentRunnerRequestParams { event_sink_port: Some(31_324), event_sink_token: Some(event_sink_token.clone()), @@ -1316,18 +1358,116 @@ fn gui_owner_registration_does_not_cross_config_dirs() { } #[test] -fn gui_owner_lock_allows_only_one_frontend_process_per_appdata() { +fn gui_participant_lock_allows_multiple_windows_and_tracks_liveness() { let directory = unique_test_directory(); let config_dir = private_runner_test_config_dir(&directory); - let first = - acquire_external_agent_runner_gui_owner_lock(&config_dir).expect("first GUI owns AppData"); - let error = acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect_err("second GUI must not share the same Runner owner"); - assert!(error.contains("其他进程运行")); + let participant_lock_path = external_agent_runner_gui_participant_lock_path(&config_dir); + assert!(!external_agent_runner_lock_is_held(&participant_lock_path) + .expect("probe without any window")); + let first = acquire_external_agent_runner_gui_participant_lock(&config_dir) + .expect("first window participates"); + assert!(external_agent_runner_lock_is_held(&participant_lock_path) + .expect("first window keeps the runner alive")); + let second = acquire_external_agent_runner_gui_participant_lock(&config_dir) + .expect("second window shares the same AppData"); + + drop(second); + assert!( + external_agent_runner_lock_is_held(&participant_lock_path) + .expect("remaining window keeps the runner alive"), + "runner must survive while any window is still open" + ); drop(first); - acquire_external_agent_runner_gui_owner_lock(&config_dir) - .expect("GUI owner lock is recoverable after the first frontend exits"); + assert!( + !external_agent_runner_lock_is_held(&participant_lock_path) + .expect("last window releases the participant lock"), + "runner may stop once every window has exited" + ); +} + +#[test] +fn gui_owner_claim_adoption_keeps_epoch_and_publication_rotates_it() { + let directory = unique_test_directory(); + let config_dir = private_runner_test_config_dir(&directory); + let published = + publish_external_agent_runner_gui_owner_claim(&config_dir, 3).expect("publish claim"); + assert_eq!(published.session_revision, 3); + + let adopted = adopt_or_publish_external_agent_runner_gui_owner_claim(&config_dir, 9) + .expect("adopt existing claim"); + assert_eq!(adopted.owner_epoch, published.owner_epoch); + assert_eq!( + adopted.session_revision, 3, + "采纳路径必须沿用现有 claim,不能推进 revision 或换 epoch" + ); + + let rotated = + publish_external_agent_runner_gui_owner_claim(&config_dir, 9).expect("publish new claim"); + assert_ne!(rotated.owner_epoch, published.owner_epoch); + assert_eq!(rotated.session_revision, 9); + assert_eq!( + read_external_agent_runner_gui_owner_claim(&config_dir) + .expect("read durable claim") + .session_revision, + 9 + ); +} + +#[test] +fn second_window_attach_with_same_claim_keeps_runner_platform_session() { + let directory = unique_test_directory(); + let config_dir = private_runner_test_config_dir(&directory); + let state = ExternalAgentRunnerServerState::new( + config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), + test_endpoint( + "multi-window-claim-token-multi-window-claim-token", + "multi-window-claim-boot", + 31_338, + ), + ); + let _session = crate::install_test_platform_session( + "runner-owner-a", + "runner-token-a", + "https://dev.genarrative.world", + ); + let owner = acquire_test_gui_participant(&config_dir, 0); + apply_external_agent_runner_gui_owner_platform_session( + &state, + &ExternalAgentRunnerRequestParams { + gui_owner_epoch: Some(owner.owner_epoch.clone()), + gui_owner_session_revision: Some(0), + platform_user_id: Some("runner-owner-a".to_string()), + platform_access_token: Some("runner-token-a".to_string()), + platform_api_base_url: Some("https://dev.genarrative.world".to_string()), + platform_auth_generation: Some(7), + platform_auth_revision: Some(7), + ..ExternalAgentRunnerRequestParams::default() + }, + ) + .expect("first window installs its session"); + assert_eq!( + crate::current_platform_session() + .map(|session| (session.user_id, session.identity_generation)), + Some(("runner-owner-a".to_string(), 7)) + ); + + // 第二个窗口启动时本身还没有登录态:同 claim 的 attach 只能是空操作。 + apply_external_agent_runner_gui_owner_platform_session( + &state, + &ExternalAgentRunnerRequestParams { + gui_owner_epoch: Some(owner.owner_epoch.clone()), + gui_owner_session_revision: Some(0), + ..ExternalAgentRunnerRequestParams::default() + }, + ) + .expect("second window attaches with the same claim"); + assert_eq!( + crate::current_platform_session() + .map(|session| (session.user_id, session.identity_generation)), + Some(("runner-owner-a".to_string(), 7)), + "同一 claim 的第二个窗口不得清空平台登录态" + ); } #[test] @@ -1341,8 +1481,7 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u config_dir.join(EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME), test_endpoint(token, "gui-owner-monitor-boot", 31319), ); - let owner = - acquire_external_agent_runner_gui_owner_lock(&config_dir).expect("acquire GUI owner lock"); + let owner = acquire_test_gui_participant(&config_dir, 0); let attached = handle_external_agent_runner_request( ExternalAgentRunnerRequest { protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION, @@ -1352,7 +1491,7 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u params: ExternalAgentRunnerRequestParams { event_sink_port: Some(31_318), event_sink_token: Some("b".repeat(64)), - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(0), ..ExternalAgentRunnerRequestParams::default() }, @@ -1372,7 +1511,7 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u !external_agent_runner_shutdown_if_gui_owner_lost(&state).expect("owner remains present") ); - write_external_agent_runner_gui_owner_claim_atomic(&config_dir, owner.owner_epoch(), 1) + write_external_agent_runner_gui_owner_claim_atomic(&config_dir, owner.owner_epoch.as_str(), 1) .expect("advance owner claim revision"); let replacement = handle_external_agent_runner_request( ExternalAgentRunnerRequest { @@ -1383,7 +1522,7 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u params: ExternalAgentRunnerRequestParams { event_sink_port: Some(31_319), event_sink_token: Some("c".repeat(64)), - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(1), ..ExternalAgentRunnerRequestParams::default() }, @@ -1392,11 +1531,18 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u ); assert!(replacement.ok); assert_eq!( - sink_guard.configured_sink(), - Some(crate::GameCreatorManifestInvalidationEventSink { - port: 31_319, - token: "c".repeat(64), - }) + sink_guard.configured_sinks(), + vec![ + crate::GameCreatorManifestInvalidationEventSink { + port: 31_318, + token: "b".repeat(64), + }, + crate::GameCreatorManifestInvalidationEventSink { + port: 31_319, + token: "c".repeat(64), + }, + ], + "第二个窗口 attach 必须让两个接收端同时保留" ); let stale_replay = handle_external_agent_runner_request( @@ -1408,7 +1554,7 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u params: ExternalAgentRunnerRequestParams { event_sink_port: Some(31_318), event_sink_token: Some("b".repeat(64)), - gui_owner_epoch: Some(owner.owner_epoch().to_string()), + gui_owner_epoch: Some(owner.owner_epoch.clone()), gui_owner_session_revision: Some(0), ..ExternalAgentRunnerRequestParams::default() }, @@ -1421,11 +1567,17 @@ fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_u Some("platform-session-invalid") ); assert_eq!( - sink_guard.configured_sink(), - Some(crate::GameCreatorManifestInvalidationEventSink { - port: 31_319, - token: "c".repeat(64), - }), + sink_guard.configured_sinks(), + vec![ + crate::GameCreatorManifestInvalidationEventSink { + port: 31_318, + token: "b".repeat(64), + }, + crate::GameCreatorManifestInvalidationEventSink { + port: 31_319, + token: "c".repeat(64), + }, + ], "旧 claim 的迟到或缓存 attach 不能覆盖当前事件接收端" ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs new file mode 100644 index 000000000..6b1e7b8b9 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -0,0 +1,1269 @@ +//! AGC 游戏模板库:读取公共 OSS 模板清单、下载模板 zip、安装到本机并据此建项目。 +//! +//! 边界:模板库只做「远端清单 → 本机安装 → 复制进新项目」这条路,不生成模板内容, +//! 也不改写已存在项目。远端对象只允许来自受信任 OSS 主机下的 `templates/` 前缀; +//! 清单、zip 与封面一律先校验再落盘,zip 解压只接受普通文件与目录。 + +use super::*; +use serde::{Deserialize, Serialize}; + +const TEMPLATE_LIBRARY_SCHEMA_VERSION: &str = "agc-template-library.v1"; +const TEMPLATE_LIBRARY_INDEX_KEY: &str = "templates/index.json"; +const TEMPLATE_LIBRARY_OBJECT_PREFIX: &str = "templates/"; +const TEMPLATE_LIBRARY_OSS_HOST: &str = "agc-dev.oss-rg-china-mainland.aliyuncs.com"; +const DEFAULT_TEMPLATE_LIBRARY_BASE_URL: &str = + "https://agc-dev.oss-rg-china-mainland.aliyuncs.com"; +const TEMPLATE_LIBRARY_BASE_URL_ENV: &str = "AGC_TEMPLATE_LIBRARY_BASE_URL"; +const TEMPLATE_CACHE_DIRECTORY_NAME: &str = "templates"; +const TEMPLATE_INSTALLED_DIRECTORY_NAME: &str = "installed"; +const TEMPLATE_INSTALLED_MARKER_FILE: &str = "installed.json"; +const TEMPLATE_INDEX_CACHE_FILE: &str = "index.json"; +const TEMPLATE_LIBRARY_MAX_INDEX_BYTES: u64 = 4 * 1024 * 1024; +const TEMPLATE_ARCHIVE_MAX_BYTES: u64 = 512 * 1024 * 1024; +const TEMPLATE_ARCHIVE_MAX_FILES: usize = 4_096; +const TEMPLATE_ARCHIVE_MAX_FILE_BYTES: u64 = 256 * 1024 * 1024; +const TEMPLATE_ID_MAX_CHARS: usize = 64; +const TEMPLATE_VERSION_MAX_CHARS: usize = 32; + +/// 远端清单里的单个模板条目(`templates/index.json` 中的 `templates[]`)。 +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GameTemplateSummary { + pub(crate) id: String, + pub(crate) title: String, + #[serde(default)] + pub(crate) summary: String, + #[serde(default)] + pub(crate) tags: Vec, + #[serde(default)] + pub(crate) runtime: String, + #[serde(default)] + pub(crate) engine: String, + #[serde(default)] + pub(crate) engine_version: String, + pub(crate) template_version: String, + #[serde(default)] + pub(crate) updated_at: String, + #[serde(default)] + pub(crate) entry: String, + pub(crate) zip_key: String, + pub(crate) zip_size_bytes: u64, + pub(crate) zip_sha256: String, + pub(crate) cover_key: String, + #[serde(default)] + pub(crate) cover_width: u32, + #[serde(default)] + pub(crate) cover_height: u32, + #[serde(default)] + pub(crate) cover_sha256: String, + #[serde(default)] + pub(crate) metadata_key: String, +} + +/// `templates/index.json` 的库头信息。 +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GameTemplateLibraryHeader { + pub(crate) schema_version: String, + #[serde(default)] + pub(crate) library: String, + #[serde(default)] + pub(crate) library_version: u32, + #[serde(default)] + pub(crate) updated_at: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +struct GameTemplateLibraryIndex { + #[serde(flatten)] + header: GameTemplateLibraryHeader, + #[serde(default)] + templates: Vec, +} + +/// 返回给前端的模板条目:清单字段 + 远端地址 + 本机安装状态。 +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GameTemplateEntry { + pub(crate) id: String, + pub(crate) title: String, + pub(crate) summary: String, + pub(crate) tags: Vec, + pub(crate) runtime: String, + pub(crate) engine: String, + pub(crate) engine_version: String, + pub(crate) template_version: String, + pub(crate) updated_at: String, + pub(crate) entry: String, + pub(crate) zip_url: String, + pub(crate) zip_size_bytes: u64, + pub(crate) zip_sha256: String, + pub(crate) cover_url: String, + pub(crate) cover_width: u32, + pub(crate) cover_height: u32, + pub(crate) installed: bool, + pub(crate) installed_version: Option, + pub(crate) installed_at_millis: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GameTemplateLibrarySnapshot { + pub(crate) schema_version: String, + pub(crate) library: String, + pub(crate) library_version: u32, + pub(crate) updated_at: String, + pub(crate) fetched_at_millis: u64, + /// `network` 或 `cache`:命中本机缓存时前端应提示清单可能不是最新。 + pub(crate) source: String, + pub(crate) templates: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct InstalledGameTemplateRecord { + template_id: String, + template_version: String, + installed_at_millis: u64, + zip_sha256: String, + file_count: usize, + project_dir: String, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct InstalledGameTemplate { + pub(crate) template_id: String, + pub(crate) template_version: String, + pub(crate) installed_at_millis: u64, + pub(crate) zip_sha256: String, + pub(crate) file_count: usize, + pub(crate) project_dir: String, +} + +fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_default() +} + +fn template_library_base_url() -> String { + std::env::var(TEMPLATE_LIBRARY_BASE_URL_ENV) + .ok() + .map(|value| value.trim().trim_end_matches('/').to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_TEMPLATE_LIBRARY_BASE_URL.to_string()) +} + +/// 只接受受信任 OSS 主机下的 HTTPS 地址;返回去掉尾斜杠的 base。 +pub(crate) fn validated_template_library_base_url(raw: &str) -> Result { + let trimmed = raw.trim().trim_end_matches('/'); + let parsed = url::Url::parse(trimmed).map_err(|_| "模板库地址无效".to_string())?; + if parsed.scheme() != "https" || parsed.host_str() != Some(TEMPLATE_LIBRARY_OSS_HOST) { + return Err("模板库地址必须来自受信任的 OSS".to_string()); + } + if parsed.path() != "/" || parsed.query().is_some() || parsed.fragment().is_some() { + return Err("模板库地址只能包含主机名".to_string()); + } + Ok(trimmed.to_string()) +} + +fn template_library_base() -> Result { + validated_template_library_base_url(&template_library_base_url()) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = sha2::Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) +} + +fn is_valid_sha256(value: &str) -> bool { + let trimmed = value.trim(); + trimmed.len() == 64 && trimmed.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn validate_template_identifier(value: &str, max_chars: usize, label: &str) -> Result<(), String> { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed.chars().count() > max_chars { + return Err(format!("{label}无效")); + } + if !trimmed.chars().all(|value| { + value.is_ascii_lowercase() || value.is_ascii_digit() || matches!(value, '-' | '_' | '.') + }) { + return Err(format!("{label}无效")); + } + if trimmed.contains("..") { + return Err(format!("{label}无效")); + } + Ok(()) +} + +/// 远端对象键必须落在 `templates/` 前缀下,且不含绝对路径、上跳或反斜杠。 +pub(crate) fn validate_template_object_key(key: &str) -> Result<(), String> { + let trimmed = key.trim(); + if trimmed.is_empty() || !trimmed.starts_with(TEMPLATE_LIBRARY_OBJECT_PREFIX) { + return Err("模板对象键必须位于 templates/ 前缀下".to_string()); + } + if trimmed.starts_with('/') || trimmed.contains('\\') || trimmed.contains("..") { + return Err("模板对象键无效".to_string()); + } + if trimmed + .chars() + .any(|value| value.is_control() || value == ' ' || value == '?') + { + return Err("模板对象键无效".to_string()); + } + Ok(()) +} + +fn template_object_url(key: &str) -> Result { + validate_template_object_key(key)?; + Ok(format!("{}/{}", template_library_base()?, key.trim())) +} + +fn validate_template_summary(entry: &GameTemplateSummary) -> Result<(), String> { + validate_template_identifier(&entry.id, TEMPLATE_ID_MAX_CHARS, "模板 ID")?; + validate_template_identifier( + &entry.template_version, + TEMPLATE_VERSION_MAX_CHARS, + "模板版本", + )?; + if entry.title.trim().is_empty() || entry.title.chars().count() > 120 { + return Err(format!("模板 {} 的标题无效", entry.id)); + } + if entry.tags.len() > 32 { + return Err(format!("模板 {} 的标签过多", entry.id)); + } + validate_template_object_key(&entry.zip_key)?; + validate_template_object_key(&entry.cover_key)?; + if !entry.metadata_key.trim().is_empty() { + validate_template_object_key(&entry.metadata_key)?; + } + if entry.zip_size_bytes == 0 || entry.zip_size_bytes > TEMPLATE_ARCHIVE_MAX_BYTES { + return Err(format!("模板 {} 的包大小无效", entry.id)); + } + if !is_valid_sha256(&entry.zip_sha256) { + return Err(format!("模板 {} 的包摘要无效", entry.id)); + } + if !entry.cover_sha256.trim().is_empty() && !is_valid_sha256(&entry.cover_sha256) { + return Err(format!("模板 {} 的封面摘要无效", entry.id)); + } + Ok(()) +} + +/// 解析并校验远端清单;任何一条不合法都让整次读取失败,避免前端拿到半可信数据。 +pub(crate) fn parse_game_template_library_index( + body: &str, +) -> Result<(GameTemplateLibraryHeader, Vec), String> { + let index: GameTemplateLibraryIndex = + serde_json::from_str(body).map_err(|error| format!("模板库清单不是有效 JSON:{error}"))?; + if index.header.schema_version != TEMPLATE_LIBRARY_SCHEMA_VERSION { + return Err("模板库清单版本不受支持".to_string()); + } + let mut seen = std::collections::BTreeSet::new(); + for entry in &index.templates { + validate_template_summary(entry)?; + if !seen.insert(entry.id.clone()) { + return Err(format!("模板库清单存在重复模板:{}", entry.id)); + } + } + Ok((index.header, index.templates)) +} + +fn template_cache_root(app: &tauri::AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|root| root.join(TEMPLATE_CACHE_DIRECTORY_NAME)) + .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}")) +} + +fn installed_templates_root(cache_root: &Path) -> PathBuf { + cache_root.join(TEMPLATE_INSTALLED_DIRECTORY_NAME) +} + +fn installed_template_dir( + cache_root: &Path, + template_id: &str, + template_version: &str, +) -> Result { + validate_template_identifier(template_id, TEMPLATE_ID_MAX_CHARS, "模板 ID")?; + validate_template_identifier(template_version, TEMPLATE_VERSION_MAX_CHARS, "模板版本")?; + Ok(installed_templates_root(cache_root) + .join(template_id.trim()) + .join(template_version.trim())) +} + +fn read_installed_record(directory: &Path) -> Option { + let body = fs::read_to_string(directory.join(TEMPLATE_INSTALLED_MARKER_FILE)).ok()?; + serde_json::from_str(&body).ok() +} + +/// 扫描本机已安装模板,返回 `(模板 ID, 安装记录)`;损坏或缺少标记的目录视为未安装。 +fn collect_installed_records(cache_root: &Path) -> Vec { + let root = installed_templates_root(cache_root); + let Ok(template_entries) = fs::read_dir(&root) else { + return Vec::new(); + }; + let mut records = Vec::new(); + for template_entry in template_entries.flatten() { + let Ok(version_entries) = fs::read_dir(template_entry.path()) else { + continue; + }; + for version_entry in version_entries.flatten() { + if let Some(record) = read_installed_record(&version_entry.path()) { + records.push(record); + } + } + } + records +} + +fn installed_record_for( + records: &[InstalledGameTemplateRecord], + template_id: &str, +) -> Option { + records + .iter() + .filter(|record| record.template_id == template_id) + .max_by_key(|record| record.installed_at_millis) + .cloned() +} + +fn to_entry( + summary: &GameTemplateSummary, + installed: Option<&InstalledGameTemplateRecord>, +) -> Result { + Ok(GameTemplateEntry { + id: summary.id.clone(), + title: summary.title.clone(), + summary: summary.summary.clone(), + tags: summary.tags.clone(), + runtime: summary.runtime.clone(), + engine: summary.engine.clone(), + engine_version: summary.engine_version.clone(), + template_version: summary.template_version.clone(), + updated_at: summary.updated_at.clone(), + entry: summary.entry.clone(), + zip_url: template_object_url(&summary.zip_key)?, + zip_size_bytes: summary.zip_size_bytes, + zip_sha256: summary.zip_sha256.to_ascii_lowercase(), + cover_url: template_object_url(&summary.cover_key)?, + cover_width: summary.cover_width, + cover_height: summary.cover_height, + installed: installed.is_some(), + installed_version: installed.map(|record| record.template_version.clone()), + installed_at_millis: installed.map(|record| record.installed_at_millis), + }) +} + +/// 本地假数据注入:只在 `template-library-fixtures` feature(或测试构建)下编译。 +/// +/// 开启时把真实清单循环补齐成假数据(条数见 `fixtures::synthetic_template_count`); +/// 未开启时是恒等透传,正式构建里没有任何注入分支。 +fn apply_template_library_fixtures(entries: Vec) -> Vec { + #[cfg(feature = "template-library-fixtures")] + { + return fixtures::pad_synthetic_templates(entries, fixtures::synthetic_template_count()); + } + #[cfg(not(feature = "template-library-fixtures"))] + { + entries + } +} + +/// 本地假数据注入实现:只在 `template-library-fixtures` feature(或测试构建)下编译。 +/// +/// 位置刻意放在 Rust 侧:模板库的清单校验、安装状态与建项目都在这一侧,TS 只消费快照做渲染; +/// 在这里注入才能压到与真实一致的整条链路。正式构建不含该模块,因此不存在误触发路径。 +#[cfg(any(test, feature = "template-library-fixtures"))] +pub(crate) mod fixtures { + use super::*; + + pub(crate) const SYNTHETIC_COUNT_ENV: &str = "AGC_TEMPLATE_LIBRARY_SYNTHETIC_COUNT"; + pub(crate) const DEFAULT_SYNTHETIC_COUNT: usize = 1_000; + const MAX_SYNTHETIC_COUNT: usize = 20_000; + + /// 假数据条数:环境变量优先,缺省 1000;0 表示不注入。 + pub(crate) fn synthetic_template_count() -> usize { + parse_synthetic_count(std::env::var(SYNTHETIC_COUNT_ENV).ok().as_deref()) + } + + fn parse_synthetic_count(raw: Option<&str>) -> usize { + raw.and_then(|value| value.trim().parse::().ok()) + .unwrap_or(DEFAULT_SYNTHETIC_COUNT) + .min(MAX_SYNTHETIC_COUNT) + } + + /// 把真实清单循环复制成指定条数:id/标题/封面地址唯一,安装态按 1/3 混合。 + pub(crate) fn pad_synthetic_templates( + entries: Vec, + target: usize, + ) -> Vec { + if target <= entries.len() || entries.is_empty() { + return entries; + } + let base = entries.clone(); + let mut padded = entries; + let mut index = padded.len(); + while padded.len() < target { + let source = &base[index % base.len()]; + let installed = index % 3 == 0; + let mut next = source.clone(); + next.id = format!("{}-{:04}", source.id, index); + next.title = format!("{} · 假数据 {index:04}", source.title); + let mut tags = source.tags.clone(); + tags.push(format!("批次-{:02}", index % 20)); + next.tags = tags; + next.cover_url = format!("{}?synthetic={index}", source.cover_url); + next.installed = installed; + next.installed_version = installed.then(|| source.template_version.clone()); + next.installed_at_millis = installed.then(|| now_millis()); + padded.push(next); + index += 1; + } + padded + } + + #[cfg(test)] + mod tests { + use super::*; + + fn base_entry(id: &str, tag: &str) -> GameTemplateEntry { + GameTemplateEntry { + id: id.to_string(), + title: format!("模板 {id}"), + summary: "假数据基础条目".to_string(), + tags: vec![tag.to_string()], + runtime: "html".to_string(), + engine: "phaser".to_string(), + engine_version: "4.2.1".to_string(), + template_version: "0.1.0".to_string(), + updated_at: "2026-09-17T00:00:00Z".to_string(), + entry: "game/index.html".to_string(), + zip_url: format!("https://oss.example/templates/v1/{id}/template.zip"), + zip_size_bytes: 1024, + zip_sha256: "a".repeat(64), + cover_url: format!("https://oss.example/templates/v1/{id}/cover.svg"), + cover_width: 960, + cover_height: 540, + installed: false, + installed_version: None, + installed_at_millis: None, + } + } + + #[test] + fn parses_synthetic_count_from_env_value() { + assert_eq!(parse_synthetic_count(None), DEFAULT_SYNTHETIC_COUNT); + assert_eq!(parse_synthetic_count(Some(" 250 ")), 250); + assert_eq!(parse_synthetic_count(Some("0")), 0); + assert_eq!( + parse_synthetic_count(Some("not-a-number")), + DEFAULT_SYNTHETIC_COUNT + ); + assert_eq!(parse_synthetic_count(Some("999999")), MAX_SYNTHETIC_COUNT); + } + + #[test] + fn pads_entries_with_unique_identity_and_mixed_install_state() { + let base = vec![ + base_entry("blank-web", "空白"), + base_entry("blank-2d", "2d"), + ]; + let padded = pad_synthetic_templates(base.clone(), 9); + assert_eq!(padded.len(), 9); + // 真实条目保持原样排在最前。 + assert_eq!(padded[0].id, "blank-web"); + assert_eq!(padded[1].id, "blank-2d"); + let ids = padded + .iter() + .map(|entry| entry.id.clone()) + .collect::>(); + assert_eq!(ids.len(), 9, "假数据 id 必须唯一"); + let covers = padded + .iter() + .map(|entry| entry.cover_url.clone()) + .collect::>(); + assert_eq!(covers.len(), 9, "假数据封面地址必须唯一"); + assert!(padded.iter().any(|entry| entry.installed)); + assert!(padded.iter().any(|entry| !entry.installed)); + assert!(padded + .iter() + .skip(2) + .all(|entry| entry.tags.iter().any(|tag| tag.starts_with("批次-")))); + } + + #[test] + fn padding_is_a_no_op_without_room_to_fill() { + let base = vec![base_entry("blank-web", "空白")]; + assert_eq!(pad_synthetic_templates(base.clone(), 1).len(), 1); + assert_eq!(pad_synthetic_templates(base.clone(), 0).len(), 1); + assert!(pad_synthetic_templates(Vec::new(), 10).is_empty()); + } + } +} + +fn build_template_library_client() -> reqwest::Client { + reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(120)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +} + +async fn fetch_limited_bytes( + client: &reqwest::Client, + url: &str, + max_bytes: u64, +) -> Result, String> { + let response = client + .get(url) + .send() + .await + .map_err(|error| format!("请求模板库失败:{error}"))?; + if !response.status().is_success() { + return Err(format!("模板库返回 HTTP {}", response.status().as_u16())); + } + if response + .content_length() + .is_some_and(|length| length > max_bytes) + { + return Err("模板库对象超过大小限制".to_string()); + } + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|error| format!("读取模板库对象失败:{error}"))?; + if body.len() as u64 + chunk.len() as u64 > max_bytes { + return Err("模板库对象超过大小限制".to_string()); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +fn write_cached_index(cache_root: &Path, body: &str) { + let _ = ensure_game_creator_private_directory_tree(cache_root, "模板库缓存目录"); + let _ = write_game_creator_private_file( + &cache_root.join(TEMPLATE_INDEX_CACHE_FILE), + body.as_bytes(), + "模板库清单缓存", + ); +} + +fn read_cached_index(cache_root: &Path) -> Option { + fs::read_to_string(cache_root.join(TEMPLATE_INDEX_CACHE_FILE)).ok() +} + +/// 单个 zip 条目路径:只允许相对普通路径,禁止绝对路径、上跳、反斜杠与盘符。 +pub(crate) fn safe_archive_relative_path(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("模板包条目名为空".to_string()); + } + let normalized = trimmed.replace('\\', "/"); + if normalized.starts_with('/') || normalized.contains(':') { + return Err(format!("模板包条目路径无效:{raw}")); + } + let mut path = PathBuf::new(); + for segment in normalized.split('/') { + if segment.is_empty() || segment == "." { + continue; + } + if segment == ".." { + return Err(format!("模板包条目路径无效:{raw}")); + } + path.push(segment); + } + if path.as_os_str().is_empty() { + return Err(format!("模板包条目路径无效:{raw}")); + } + Ok(path) +} + +fn extract_template_archive(bytes: &[u8], destination: &Path) -> Result { + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)) + .map_err(|error| format!("模板包不是有效 zip:{error}"))?; + if archive.len() > TEMPLATE_ARCHIVE_MAX_FILES { + return Err("模板包文件数量超过上限".to_string()); + } + let mut written = 0_usize; + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .map_err(|error| format!("读取模板包条目失败:{error}"))?; + if entry + .unix_mode() + .is_some_and(|mode| mode & 0o170000 == 0o120000) + { + return Err("模板包不允许包含符号链接".to_string()); + } + let relative = safe_archive_relative_path(entry.name())?; + let target = destination.join(&relative); + if entry.is_dir() { + ensure_game_creator_private_directory_tree(&target, "模板目录")?; + continue; + } + if entry.size() > TEMPLATE_ARCHIVE_MAX_FILE_BYTES { + return Err(format!("模板包文件超过大小上限:{}", entry.name())); + } + let mut buffer = Vec::with_capacity(entry.size() as usize); + entry + .read_to_end(&mut buffer) + .map_err(|error| format!("读取模板包文件失败:{error}"))?; + if let Some(parent) = target.parent() { + ensure_game_creator_private_directory_tree(parent, "模板目录")?; + } + write_game_creator_private_file(&target, &buffer, "模板文件")?; + written += 1; + } + if written == 0 { + return Err("模板包没有可写入的文件".to_string()); + } + Ok(written) +} + +fn install_template_archive( + cache_root: &Path, + summary: &GameTemplateSummary, + bytes: &[u8], +) -> Result { + if bytes.len() as u64 != summary.zip_size_bytes { + return Err("模板包大小校验失败".to_string()); + } + let actual_sha256 = sha256_hex(bytes); + if actual_sha256 != summary.zip_sha256.trim().to_ascii_lowercase() { + return Err("模板包完整性校验失败".to_string()); + } + let directory = installed_template_dir(cache_root, &summary.id, &summary.template_version)?; + if directory.exists() { + // 只清理本模板自己的安装目录;路径由标识符白名单拼出,不含远端输入。 + let _ = fs::remove_dir_all(&directory); + } + ensure_game_creator_private_directory_tree(&directory, "模板安装目录")?; + let file_count = extract_template_archive(bytes, &directory)?; + let record = InstalledGameTemplateRecord { + template_id: summary.id.clone(), + template_version: summary.template_version.clone(), + installed_at_millis: now_millis(), + zip_sha256: actual_sha256, + file_count, + project_dir: directory.to_string_lossy().into_owned(), + }; + let body = serde_json::to_string_pretty(&record) + .map_err(|error| format!("写入模板安装记录失败:{error}"))?; + write_game_creator_private_file( + &directory.join(TEMPLATE_INSTALLED_MARKER_FILE), + body.as_bytes(), + "模板安装记录", + )?; + Ok(record) +} + +fn copy_template_project(source_dir: &Path, target_root: &Path) -> Result { + let mut copied = 0_usize; + let mut stack = vec![source_dir.to_path_buf()]; + while let Some(directory) = stack.pop() { + let entries = + fs::read_dir(&directory).map_err(|error| format!("读取模板目录失败:{error}"))?; + for entry in entries.flatten() { + let path = entry.path(); + if entry.file_name() == TEMPLATE_INSTALLED_MARKER_FILE { + continue; + } + let metadata = entry + .metadata() + .map_err(|error| format!("读取模板条目失败:{error}"))?; + if metadata.is_dir() { + stack.push(path); + continue; + } + if !metadata.is_file() { + return Err(format!("模板包含不支持的条目:{}", path.display())); + } + let relative = path + .strip_prefix(source_dir) + .map_err(|error| format!("模板条目路径无效:{error}"))?; + let target = target_root.join(relative); + if let Some(parent) = target.parent() { + ensure_game_creator_private_directory_tree(parent, "项目模板目录")?; + } + let bytes = fs::read(&path).map_err(|error| format!("读取模板文件失败:{error}"))?; + write_game_creator_private_file(&target, &bytes, "项目模板文件")?; + copied += 1; + } + } + if copied == 0 { + return Err("模板没有可复制的文件".to_string()); + } + Ok(copied) +} + +fn find_template_summary( + cache_root: &Path, + template_id: &str, + template_version: &str, +) -> Result { + let body = read_cached_index(cache_root).ok_or_else(|| "本机没有模板库清单缓存".to_string())?; + let (_, templates) = parse_game_template_library_index(&body)?; + templates + .into_iter() + .find(|entry| entry.id == template_id && entry.template_version == template_version) + .ok_or_else(|| "模板库清单里没有该模板版本".to_string()) +} + +async fn ensure_template_installed( + cache_root: &Path, + template_id: &str, + template_version: &str, +) -> Result { + let installed_directory = installed_template_dir(cache_root, template_id, template_version)?; + if let Some(record) = read_installed_record(&installed_directory) { + return Ok(record); + } + let summary = find_template_summary(cache_root, template_id, template_version)?; + let client = build_template_library_client(); + let url = template_object_url(&summary.zip_key)?; + let bytes = fetch_limited_bytes(&client, &url, TEMPLATE_ARCHIVE_MAX_BYTES).await?; + install_template_archive(cache_root, &summary, &bytes) +} + +#[tauri::command] +pub(crate) async fn fetch_game_template_library( + app: tauri::AppHandle, +) -> Result { + let cache_root = template_cache_root(&app)?; + ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; + let index_url = format!( + "{}/{}", + template_library_base()?, + TEMPLATE_LIBRARY_INDEX_KEY + ); + let client = build_template_library_client(); + let (body, source) = + match fetch_limited_bytes(&client, &index_url, TEMPLATE_LIBRARY_MAX_INDEX_BYTES).await { + Ok(bytes) => { + let body = + String::from_utf8(bytes).map_err(|_| "模板库清单不是有效 UTF-8".to_string())?; + parse_game_template_library_index(&body)?; + write_cached_index(&cache_root, &body); + (body, "network") + } + Err(error) => match read_cached_index(&cache_root) { + Some(cached) => { + parse_game_template_library_index(&cached)?; + (cached, "cache") + } + None => return Err(error), + }, + }; + let (header, templates) = parse_game_template_library_index(&body)?; + let installed = collect_installed_records(&cache_root); + let entries = templates + .iter() + .map(|summary| { + to_entry( + summary, + installed_record_for(&installed, &summary.id).as_ref(), + ) + }) + .collect::, _>>()?; + let entries = apply_template_library_fixtures(entries); + Ok(GameTemplateLibrarySnapshot { + schema_version: header.schema_version, + library: header.library, + library_version: header.library_version, + updated_at: header.updated_at, + fetched_at_millis: now_millis(), + source: source.to_string(), + templates: entries, + }) +} + +#[tauri::command] +pub(crate) async fn download_game_template( + app: tauri::AppHandle, + template_id: String, + template_version: String, +) -> Result { + let cache_root = template_cache_root(&app)?; + ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; + let record = + ensure_template_installed(&cache_root, template_id.trim(), template_version.trim()).await?; + Ok(InstalledGameTemplate { + template_id: record.template_id, + template_version: record.template_version, + installed_at_millis: record.installed_at_millis, + zip_sha256: record.zip_sha256, + file_count: record.file_count, + project_dir: record.project_dir, + }) +} + +/// 用已安装模板在自动工作区根目录下建项目:先把模板文件铺进新目录,再走标准项目初始化。 +pub(crate) fn create_project_from_installed_template_at( + projects_root: &Path, + installed_project_dir: &Path, + requested_name: Option<&str>, + planning: bool, +) -> Result { + let requested_name = requested_name + .map(normalize_game_creation_project_name) + .transpose()?; + if projects_root.as_os_str().is_empty() || !projects_root.is_absolute() { + return Err("自动工作区根目录必须是绝对路径".to_string()); + } + if !installed_project_dir.is_dir() { + return Err("模板尚未安装到本机".to_string()); + } + ensure_game_creator_private_directory_tree(projects_root, "自动工作区根目录")?; + prepare_game_creator_private_path_for_read(projects_root, true, "自动工作区根目录")?; + let metadata = fs::symlink_metadata(projects_root).map_err(|error| { + format!( + "读取自动工作区根目录失败:{}: {error}", + projects_root.display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("自动工作区根目录必须是普通文件夹".to_string()); + } + + for _ in 0..16 { + let workspace_id = uuid::Uuid::new_v4().simple().to_string(); + let short_id = &workspace_id[..8]; + let project_name = requested_name.clone().unwrap_or_else(|| { + let prefix = if planning { + "策划项目" + } else { + "GameAgent 项目" + }; + format!("{prefix} {short_id}") + }); + let project_root = projects_root.join(format!("gameagent-{short_id}")); + match fs::create_dir(&project_root) { + Ok(()) => { + let result = (|| { + harden_new_game_creator_private_path(&project_root, true, "自动项目目录")?; + enforce_project_permission_policy(&project_root, "project.create")?; + let _lock = acquire_project_write_lock(&project_root, "project.create")?; + copy_template_project(installed_project_dir, &project_root)?; + init_local_game_project_at( + &project_root, + &format!("gameagent-{workspace_id}"), + &project_name, + ) + })(); + if result.is_err() { + let _ = fs::remove_dir_all(&project_root); + } + return result; + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + return Err(format!( + "创建自动工作区失败:{}: {error}", + project_root.display() + )); + } + } + } + Err("自动工作区命名冲突,请重试".to_string()) +} + +#[tauri::command] +pub(crate) async fn create_automatic_local_game_project_from_template( + app: tauri::AppHandle, + template_id: String, + template_version: String, + name: Option, + planning: Option, + projects_root: Option, +) -> Result { + let projects_root = crate::resolve_game_project_creation_root(&app, projects_root.as_deref())?; + let cache_root = template_cache_root(&app)?; + ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; + let record = + ensure_template_installed(&cache_root, template_id.trim(), template_version.trim()).await?; + create_project_from_installed_template_at( + &projects_root, + Path::new(&record.project_dir), + name.as_deref(), + planning.unwrap_or(false), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_index_body() -> String { + serde_json::json!({ + "schemaVersion": TEMPLATE_LIBRARY_SCHEMA_VERSION, + "library": "agc-game-templates", + "libraryVersion": 1, + "updatedAt": "2026-09-17T00:00:00Z", + "templates": [ + { + "id": "demo-template", + "title": "演示模板", + "summary": "用于测试", + "tags": ["2d", "demo"], + "runtime": "html", + "engine": "phaser", + "engineVersion": "4.2.1", + "templateVersion": "1.0.0", + "updatedAt": "2026-09-17T00:00:00Z", + "entry": "game/index.html", + "zipKey": "templates/v1/demo-template/template.zip", + "zipSizeBytes": 128, + "zipSha256": "0".repeat(64), + "coverKey": "templates/v1/demo-template/cover.png", + "coverWidth": 960, + "coverHeight": 540, + "coverSha256": "1".repeat(64), + "metadataKey": "templates/v1/demo-template/template.json" + } + ] + }) + .to_string() + } + + fn sample_summary() -> GameTemplateSummary { + parse_game_template_library_index(&sample_index_body()) + .expect("parse sample index") + .1 + .remove(0) + } + + fn build_archive(entries: &[(&str, &[u8])]) -> Vec { + let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let options = zip::write::SimpleFileOptions::default(); + for (name, bytes) in entries { + writer.start_file(*name, options).expect("start file"); + writer.write_all(bytes).expect("write file"); + } + writer.finish().expect("finish archive").into_inner() + } + + /// 自动工作区根目录要走私有 DACL 校验,和 `tests::unique_project_path` 一样 + /// 在临时目录下取一个尚未存在的唯一路径,而不是用 tempdir 预先建好的目录。 + fn unique_projects_root() -> PathBuf { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_millis(); + std::env::temp_dir().join(format!( + "genarrative-agc-template-library-test-{}-{millis}", + std::process::id() + )) + } + + #[test] + fn rejects_index_with_unsupported_schema_or_duplicate_templates() { + let unsupported = + sample_index_body().replace(TEMPLATE_LIBRARY_SCHEMA_VERSION, "agc-template-library.v2"); + assert!(parse_game_template_library_index(&unsupported).is_err()); + + let entry = serde_json::to_string(&sample_summary()).expect("serialize summary"); + let duplicated = serde_json::json!({ + "schemaVersion": TEMPLATE_LIBRARY_SCHEMA_VERSION, + "templates": [ + serde_json::from_str::(&entry).expect("value"), + serde_json::from_str::(&entry).expect("value"), + ] + }) + .to_string(); + let error = parse_game_template_library_index(&duplicated).expect_err("duplicate rejected"); + assert!(error.contains("重复模板"), "{error}"); + } + + #[test] + fn rejects_object_keys_outside_the_templates_prefix() { + assert!(validate_template_object_key("agc/templates/v1/demo/template.zip").is_err()); + assert!(validate_template_object_key("templates/../secret").is_err()); + assert!(validate_template_object_key("/templates/a.zip").is_err()); + assert!(validate_template_object_key("templates\\a.zip").is_err()); + assert!(validate_template_object_key("templates/v1/demo/template.zip").is_ok()); + } + + #[test] + fn rejects_template_base_url_outside_trusted_oss() { + assert!(validated_template_library_base_url( + "https://agc-dev.oss-rg-china-mainland.aliyuncs.com" + ) + .is_ok()); + assert!(validated_template_library_base_url( + "http://agc-dev.oss-rg-china-mainland.aliyuncs.com" + ) + .is_err()); + assert!(validated_template_library_base_url("https://evil.example.com").is_err()); + assert!(validated_template_library_base_url( + "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/other-prefix" + ) + .is_err()); + } + + /// 固定 2026-09-17 实际发布的 `templates/index.json`:客户端解析必须与线上契约一致。 + #[test] + fn parses_the_published_library_index_fixture() { + let body = include_str!("../tests/fixtures/agc-template-library-index.json"); + let (header, templates) = + parse_game_template_library_index(body).expect("parse published index"); + assert_eq!(header.schema_version, TEMPLATE_LIBRARY_SCHEMA_VERSION); + assert_eq!(header.library, "agc-game-templates"); + assert_eq!( + templates + .iter() + .map(|entry| entry.id.as_str()) + .collect::>(), + vec![ + "blank-2d-canvas", + "blank-3d-scene", + "blank-web", + "phaser-2d-starter", + "threejs-3d-starter", + ] + ); + let first = templates + .iter() + .find(|entry| entry.id == "phaser-2d-starter") + .expect("phaser template"); + assert_eq!(first.runtime, "html"); + assert_eq!(first.tags.first().map(String::as_str), Some("起步工程")); + assert!(first.cover_width > 0 && first.cover_height > 0); + + let entry = to_entry(first, None).expect("build entry"); + let base = template_library_base().expect("trusted base"); + assert_eq!( + entry.zip_url, + format!("{base}/templates/v1/phaser-2d-starter/template.zip") + ); + assert_eq!( + entry.cover_url, + format!("{base}/templates/v1/phaser-2d-starter/cover.svg") + ); + assert!(!entry.installed); + } + + #[test] + fn rejects_archive_entries_that_escape_the_destination() { + assert!(safe_archive_relative_path("game/index.html").is_ok()); + assert!(safe_archive_relative_path("./game/index.html").is_ok()); + assert!(safe_archive_relative_path("../escape.txt").is_err()); + assert!(safe_archive_relative_path("game/../../escape.txt").is_err()); + assert!(safe_archive_relative_path("C:/escape.txt").is_err()); + assert!(safe_archive_relative_path("/escape.txt").is_err()); + + let archive = build_archive(&[("../escape.txt", b"nope")]); + let destination = tempfile::tempdir().expect("temp dir"); + assert!(extract_template_archive(&archive, destination.path()).is_err()); + assert!(!destination + .path() + .parent() + .unwrap() + .join("escape.txt") + .exists()); + } + + #[test] + fn installs_template_archive_and_reports_it_as_installed() { + let cache_root = tempfile::tempdir().expect("temp dir"); + let archive = build_archive(&[ + ("game/index.html", b""), + ("game/game.js", b"console.log('demo');"), + ]); + let mut summary = sample_summary(); + summary.zip_size_bytes = archive.len() as u64; + summary.zip_sha256 = sha256_hex(&archive); + + let record = install_template_archive(cache_root.path(), &summary, &archive) + .expect("install template"); + assert_eq!(record.file_count, 2); + let installed = collect_installed_records(cache_root.path()); + assert_eq!(installed.len(), 1); + assert_eq!(installed[0].template_version, "1.0.0"); + + let entry = to_entry( + &summary, + installed_record_for(&installed, &summary.id).as_ref(), + ) + .expect("build entry"); + assert!(entry.installed); + assert_eq!(entry.installed_version.as_deref(), Some("1.0.0")); + assert!(entry.zip_url.starts_with("https://")); + assert!(entry + .cover_url + .ends_with("/templates/v1/demo-template/cover.png")); + } + + #[test] + fn rejects_archive_when_size_or_digest_do_not_match_the_index() { + let cache_root = tempfile::tempdir().expect("temp dir"); + let archive = build_archive(&[("game/index.html", b"")]); + let mut summary = sample_summary(); + summary.zip_size_bytes = archive.len() as u64 + 1; + let error = install_template_archive(cache_root.path(), &summary, &archive) + .expect_err("size mismatch rejected"); + assert!(error.contains("大小校验失败"), "{error}"); + + summary.zip_size_bytes = archive.len() as u64; + summary.zip_sha256 = "f".repeat(64); + let error = install_template_archive(cache_root.path(), &summary, &archive) + .expect_err("digest mismatch rejected"); + assert!(error.contains("完整性校验失败"), "{error}"); + } + + #[test] + fn creates_project_from_installed_template_without_leaking_install_marker() { + let cache_root = tempfile::tempdir().expect("temp dir"); + let projects_root = unique_projects_root(); + let archive = build_archive(&[ + ("game/index.html", b"template"), + ("assets/README.txt", b"template assets"), + ]); + let mut summary = sample_summary(); + summary.zip_size_bytes = archive.len() as u64; + summary.zip_sha256 = sha256_hex(&archive); + let record = install_template_archive(cache_root.path(), &summary, &archive) + .expect("install template"); + + let result = create_project_from_installed_template_at( + &projects_root, + Path::new(&record.project_dir), + Some("模板项目"), + false, + ) + .expect("create project from template"); + assert_eq!(result.manifest.name, "模板项目"); + let project_root = Path::new(&result.project_path); + let index = + fs::read_to_string(project_root.join("game/index.html")).expect("template file copied"); + assert!(index.contains("template")); + assert!(project_root.join("assets/README.txt").is_file()); + assert!(!project_root.join(TEMPLATE_INSTALLED_MARKER_FILE).exists()); + assert!(!project_root.join("game/index.html.orig").exists()); + fs::remove_dir_all(&projects_root).ok(); + } + + #[test] + fn refuses_to_create_project_when_template_is_not_installed() { + let projects_root = tempfile::tempdir().expect("temp dir"); + let missing = projects_root.path().join("missing-template"); + let error = + create_project_from_installed_template_at(projects_root.path(), &missing, None, false) + .expect_err("missing template rejected"); + assert!(error.contains("模板尚未安装"), "{error}"); + } + + /// 正式构建(未开 feature)必须恒等透传:注入路径不能出现在默认产物里。 + #[cfg(not(feature = "template-library-fixtures"))] + #[test] + fn fixtures_are_inert_without_the_feature() { + let index_body = include_str!("../tests/fixtures/agc-template-library-index.json"); + let (_, templates) = + parse_game_template_library_index(index_body).expect("parse fixture index"); + let entries = templates + .iter() + .map(|summary| to_entry(summary, None).expect("entry")) + .collect::>(); + let original = entries.len(); + let applied = apply_template_library_fixtures(entries); + assert_eq!(applied.len(), original, "默认构建不应注入假数据"); + } + + /// 开启 feature 后同一次调用必须补齐到配置条数。 + #[cfg(feature = "template-library-fixtures")] + #[test] + fn fixtures_expand_entries_when_the_feature_is_enabled() { + let index_body = include_str!("../tests/fixtures/agc-template-library-index.json"); + let (_, templates) = + parse_game_template_library_index(index_body).expect("parse fixture index"); + let entries = templates + .iter() + .map(|summary| to_entry(summary, None).expect("entry")) + .collect::>(); + let applied = apply_template_library_fixtures(entries); + assert_eq!(applied.len(), fixtures::synthetic_template_count()); + assert!(applied.len() > templates.len()); + } + + /// 可选的真连检查:`cargo test --bin genarrative-ai-game-creator-shell template_library -- --ignored`。 + /// 默认跳过,避免离线环境因网络失败误报。 + #[tokio::test] + #[ignore = "需要网络:校验客户端能真实读到线上模板库清单与对象键"] + async fn fetches_the_live_template_library_index() { + let base = template_library_base().expect("trusted base"); + let client = build_template_library_client(); + let body = fetch_limited_bytes( + &client, + &format!("{base}/{TEMPLATE_LIBRARY_INDEX_KEY}"), + TEMPLATE_LIBRARY_MAX_INDEX_BYTES, + ) + .await + .expect("fetch live index"); + let body = String::from_utf8(body).expect("utf-8 index"); + let (header, templates) = + parse_game_template_library_index(&body).expect("parse live index"); + assert_eq!(header.schema_version, TEMPLATE_LIBRARY_SCHEMA_VERSION); + assert!(!templates.is_empty(), "线上模板库应当至少有一个模板"); + for entry in &templates { + let zip_url = template_object_url(&entry.zip_key).expect("trusted zip url"); + assert!(zip_url.starts_with(&format!("{base}/"))); + } + } + + /// 可选的真连检查:下载线上模板包并安装到临时目录,证明"清单 → 下载 → 摘要校验 → 解压"整条链路可用。 + #[tokio::test] + #[ignore = "需要网络:下载并安装线上模板包"] + async fn downloads_and_installs_a_live_template() { + let cache_root = tempfile::tempdir().expect("temp dir"); + let base = template_library_base().expect("trusted base"); + let client = build_template_library_client(); + let body = fetch_limited_bytes( + &client, + &format!("{base}/{TEMPLATE_LIBRARY_INDEX_KEY}"), + TEMPLATE_LIBRARY_MAX_INDEX_BYTES, + ) + .await + .expect("fetch live index"); + let body = String::from_utf8(body).expect("utf-8 index"); + let (_, templates) = parse_game_template_library_index(&body).expect("parse live index"); + let entry = templates + .iter() + .find(|template| template.id == "blank-web") + .expect("线上应存在 blank-web 模板"); + let bytes = fetch_limited_bytes( + &client, + &template_object_url(&entry.zip_key).expect("trusted zip url"), + TEMPLATE_ARCHIVE_MAX_BYTES, + ) + .await + .expect("download live template"); + let record = install_template_archive(cache_root.path(), entry, &bytes) + .expect("install live template"); + assert!(record.file_count >= 5, "模板文件数 {}", record.file_count); + let project_root = Path::new(&record.project_dir); + assert!(project_root.join("game/index.html").is_file()); + assert!(project_root.join("game/main.js").is_file()); + assert!(project_root.join(TEMPLATE_INSTALLED_MARKER_FILE).is_file()); + + // 同一条链路继续建项目:线上模板 → 本机安装 → 新项目目录。 + let projects_root = unique_projects_root(); + let created = create_project_from_installed_template_at( + &projects_root, + project_root, + Some("线上模板项目"), + false, + ) + .expect("create project from live template"); + assert_eq!(created.manifest.name, "线上模板项目"); + let created_root = Path::new(&created.project_path); + assert!(created_root.join("game/index.html").is_file()); + assert!(created_root.join("game/main.js").is_file()); + assert!(created_root.join(".agent/manifest.json").is_file()); + fs::remove_dir_all(&projects_root).ok(); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs index 7dd5c64a7..89a8d46e4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs @@ -1587,6 +1587,7 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r "prompt": "生成原创晶体与潮汐构装体图集", "outputPath": "assets/art-spritesheet.png", "assetKind": "art-spritesheet", + "sliceMode": "connected-components", "replaceExisting": true }), ), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index adb540bc2..9e049b60d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -1,5 +1,73 @@ use super::*; +#[test] +fn legacy_official_config_gains_hand_editable_connection_keys_on_startup() { + // 用户现有配置文件(官方路由、连接字段已被清掉)在启动迁移路径上必须补齐 + // 开关、模型列表与连接四要素,手写自定义连接时能看到完整字段。 + let mut config: GameCreatorAppConfigFile = serde_json::from_str( + r#"{"schemaVersion":"game-creator-config.v2","agentMode":"codex_app_server","llm":{"reasoningEffort":"max","stream":true},"selectedModelId":"quality","selectedModelIsDefault":true}"#, + ) + .unwrap(); + assert!(ensure_game_creator_custom_llm_file_fields(&mut config)); + assert!(scrub_locked_game_creator_config_file(&mut config)); + let migrated: serde_json::Value = serde_json::to_value(&config).unwrap(); + assert_eq!(migrated["llm"]["customEnabled"], false); + assert_eq!(migrated["llm"]["visibleModels"], serde_json::json!([])); + assert_eq!(migrated["llm"]["apiKey"], ""); + assert_eq!(migrated["llm"]["baseUrl"], OFFICIAL_LLM_ROUTER_BASE_URL); + assert_eq!(migrated["llm"]["model"], "quality"); + assert_eq!( + migrated["llm"]["apiKind"], + DEFAULT_GAME_CREATOR_LLM_API_KIND + ); + assert_eq!(migrated["llm"]["reasoningEffort"], "max"); +} + +#[test] +fn custom_llm_config_save_reload_and_selection_preserve_overlay_and_credentials() { + let root = unique_project_path(); + fs::create_dir_all(&root).unwrap(); + let _guard = use_test_runtime_config_dir(root.clone()); + let primary = root.join(GAME_CREATOR_CONFIG_FILE_NAME); + let overlay = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME); + write_game_creator_config_atomically( + &primary, + &serde_json::to_string(&GameCreatorAppConfig::default()).unwrap(), + ) + .unwrap(); + write_game_creator_config_atomically(&overlay, r#"{"llm":{"customEnabled":true,"apiKey":"fixture-key","baseUrl":"https://custom.example/v1","visibleModels":["a/v1","b:v2"]},"selectedModelId":"a/v1","selectedModelIsDefault":true}"#).unwrap(); + let mut config = read_game_creator_app_config().unwrap().config; + assert_eq!(config.llm.model, "a/v1"); + let selected = select_game_creator_model("b:v2".into(), false).unwrap(); + assert_eq!(selected.config.llm.model, "b:v2"); + assert!(select_game_creator_model("not-listed".into(), false).is_err()); + config.llm.visible_models = vec!["b:v2".into()]; + config.llm.api_key = "changed-fixture-key".into(); + write_game_creator_app_config(config).unwrap(); + let reloaded = read_game_creator_app_config().unwrap().config; + assert!(reloaded.llm.custom_enabled); + assert_eq!(reloaded.llm.visible_models, ["b:v2"]); + assert_eq!(reloaded.llm.model, "b:v2"); + assert_eq!(reloaded.llm.api_key, "changed-fixture-key"); + let persisted: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&primary).unwrap()).unwrap(); + for key in [ + "customEnabled", + "visibleModels", + "apiKey", + "baseUrl", + "model", + "apiKind", + "reasoningEffort", + ] { + assert!( + persisted["llm"].get(key).is_some(), + "本地配置始终保留 {key},方便手写自定义连接:{persisted}" + ); + } + fs::remove_dir_all(root).unwrap(); +} + #[test] fn config_file_overrides_defaults_without_env() { let root = unique_project_path(); @@ -313,10 +381,14 @@ fn locked_config_scrub_removes_all_legacy_provider_credentials() { .llm .as_ref() .expect("global llm remains as non-sensitive tuning"); - assert!(llm.api_key.is_none()); - assert!(llm.base_url.is_none()); - assert!(llm.model.is_none()); - assert!(llm.api_kind.is_none()); + // 连接字段保留在文件里(空 Key + 官方地址),便于手写自定义连接时对照。 + assert_eq!(llm.api_key.as_deref(), Some("")); + assert_eq!(llm.base_url.as_deref(), Some(OFFICIAL_LLM_ROUTER_BASE_URL)); + assert_eq!( + llm.api_kind.as_deref(), + Some(DEFAULT_GAME_CREATOR_LLM_API_KIND) + ); + assert!(llm.model.is_some()); let serialized = serde_json::to_string(&config).expect("serialize scrubbed config"); assert!(!serialized.contains("legacy-global-key")); assert!(!serialized.contains("legacy-agent-key")); @@ -688,6 +760,8 @@ fn app_config_commands_write_runtime_config_file() { agent_llm.insert( " planner ".to_string(), GameCreatorLlmConfigFile { + custom_enabled: None, + visible_models: None, api_key: Some(" planner-key ".to_string()), base_url: Some(" https://planner.example.test/v1 ".to_string()), model: Some(" planner-model ".to_string()), @@ -709,6 +783,8 @@ fn app_config_commands_write_runtime_config_file() { schema_version: GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION.to_string(), agent_mode: GAME_CREATOR_AGENT_MODE_PROVIDER.to_string(), llm: GameCreatorLlmConfig { + custom_enabled: false, + visible_models: Vec::new(), api_key: " unit-test-key ".to_string(), base_url: " https://runtime.example.test/v1 ".to_string(), model: " runtime-model ".to_string(), @@ -838,7 +914,7 @@ fn app_config_save_updates_conflicting_local_overlay() { fs::create_dir_all(&root).expect("config dir"); let _guard = use_test_runtime_config_dir(root.clone()); let overlay_path = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME); - fs::write( + write_game_creator_config_atomically( &overlay_path, r#"{"llm":{"reasoningEffort":"low"},"selectedModelId":"existing","selectedModelIsDefault":true,"custom":{"keep":true}}"#, ) @@ -871,7 +947,7 @@ fn app_config_model_selection_only_updates_model_overlay() { fs::create_dir_all(&root).expect("config dir"); let _guard = use_test_runtime_config_dir(root.clone()); let overlay_path = root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME); - fs::write( + write_game_creator_config_atomically( &overlay_path, r#"{"selectedModelId":"old","selectedModelIsDefault":false,"llm":{"reasoningEffort":"low"}}"#, ) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index f12416e7d..6f5317d98 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -202,9 +202,13 @@ fn gui_final_exit_is_the_only_run_event_that_requests_runner_shutdown() { GameCreatorGuiRunnerShutdownOutcome::NotRequested ); assert_eq!( - resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Exit, || Ok(())), + resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Exit, || Ok(true)), GameCreatorGuiRunnerShutdownOutcome::Requested ); + assert_eq!( + resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Exit, || Ok(false)), + GameCreatorGuiRunnerShutdownOutcome::Retained + ); assert_eq!( resolve_game_creator_gui_runner_shutdown(&tauri::RunEvent::Exit, || { Err("private shutdown diagnostic".to_string()) @@ -1572,6 +1576,7 @@ pub(crate) fn spawn_mock_llm_server_responses(response_contents: Vec) -> pub(crate) fn spawn_mock_llm_tool_plan_then_invalid_final_reply( planning_response: String, + final_reply_requests: usize, ) -> String { let listener = bind_test_tcp_listener("mock invalid final reply bind"); let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); @@ -1613,11 +1618,7 @@ pub(crate) fn spawn_mock_llm_tool_plan_then_invalid_final_reply( .write_all(planning_response.as_bytes()) .expect("mock tool plan response"); - // Autonomous runs enforce a 12-retry floor. Return the same malformed - // response for the initial final-reply request and every retry so this - // fixture tests deserialize exhaustion rather than an accidental - // connection-refused fallback after the first malformed response. - for _ in 0..=12 { + for _ in 0..final_reply_requests.max(1) { let (mut final_stream, _) = listener.accept().expect("mock final reply accept"); drop(read_mock_http_request(&mut final_stream)); let invalid_body = "{invalid-json"; @@ -5930,7 +5931,7 @@ async fn background_agent_runtime_marks_response_plan_step_failed_when_final_rep "response": "" }) .to_string(); - let base_url = spawn_mock_llm_tool_plan_then_invalid_final_reply(plan_json); + let base_url = spawn_mock_llm_tool_plan_then_invalid_final_reply(plan_json, 1); let _config_guard = write_test_local_config(format!( r#"{{ "agentLlm": {{ @@ -5939,6 +5940,7 @@ async fn background_agent_runtime_marks_response_plan_step_failed_when_final_rep "baseUrl": {base_url:?}, "model": "design-runtime-model", "apiKind": "openai_responses", + "maxRetries": 0, "retryBackoffMs": 1 }} }} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 2270ca448..5aedeadd0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -378,6 +378,78 @@ fn canonical_visual_completion_requires_persisted_route_kind_and_current_spec_re .unwrap_or_else(|error| panic!("{task_id} provenance should pass: {error}")); } + // 规范图与派生素材都允许用户参考(同一项目已登记的图片素材):参考只是风格输入, + // 规范身份仍由参考序列第一项承担,多出的用户参考不能让校验失败。 + let art_spec = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/art-spec.png") + .expect("art spec asset"); + art_spec.source.reference_resource_ids = vec!["user-reference-1".to_string()]; + let ui = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/ui-prototype.png") + .expect("ui prototype asset"); + ui.source.reference_resource_ids = vec![ + "resource-icon-spec".to_string(), + "user-reference-1".to_string(), + ]; + for task_id in ["art-director", "design-foundation"] { + validate_manifest_required_visual_asset(&root, &manifest, task_id) + .unwrap_or_else(|error| panic!("{task_id} must accept user references: {error}")); + } + + // 用户参考不能顶替规范图:首项不是当前规范引用时仍必须失败关闭。 + let ui = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/ui-prototype.png") + .expect("ui prototype asset"); + ui.source.reference_resource_ids = vec![ + "user-reference-1".to_string(), + "resource-icon-spec".to_string(), + ]; + assert!( + validate_manifest_required_visual_asset(&root, &manifest, "design-foundation") + .expect_err("a leading user reference must not replace the canonical spec") + .contains("未绑定当前统一视觉规范图的本地内容身份") + ); + + // 只接受单规范引用的图集不接受额外用户参考。 + let ui = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/ui-prototype.png") + .expect("ui prototype asset"); + ui.source.reference_resource_ids = vec!["resource-icon-spec".to_string()]; + let spritesheet = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/art-spritesheet.png") + .expect("art spritesheet asset"); + spritesheet.source.reference_resource_ids = vec![ + "resource-icon-spec".to_string(), + "user-reference-1".to_string(), + ]; + assert!( + validate_manifest_required_visual_asset(&root, &manifest, "art-asset-plan") + .expect_err("art spritesheet must reject extra user references") + .contains("未精确引用当前统一视觉规范图") + ); + let spritesheet = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/art-spritesheet.png") + .expect("art spritesheet asset"); + spritesheet.source.reference_resource_ids = vec!["resource-icon-spec".to_string()]; + let art_spec = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/art-spec.png") + .expect("art spec asset"); + art_spec.source.reference_resource_ids = Vec::new(); + let art_spec_path = root.join("assets/art-spec.png"); let valid_art_spec = fs::read(&art_spec_path).expect("read valid art spec fixture"); fs::write(&art_spec_path, &valid_art_spec[..valid_art_spec.len() / 2]) @@ -728,13 +800,17 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { "tool": "canvas.asset_generate", "reason": "生成可用于首版原型的主角素材", "input": { - "prompt": "透明 PNG 像素月光主角,适合厨房弹幕游戏", + "prompt": "透明 PNG 像素月光主角图集,按 2 行 2 列等分网格排布,适合厨房弹幕游戏", "outputPath": "assets/art-spritesheet.png", "aspectRatio": "1:1", "imageSize": "1K", "assetKind": "art-spritesheet", "assetLabel": "游戏首版核心美术素材", - "replaceExisting": false + "replaceExisting": false, + "sliceMode": "grid", + "gridX": 2, + "gridY": 2, + "sliceCount": null } } ], @@ -775,7 +851,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { start_game_creator_agent_background_task_at( &root, "art-asset-plan", - "为月光厨房生成首版主角素材", + "为月光厨房生成首版主角素材图集,按 2 行 2 列等分网格排布", "art-generate-run", ) .expect("start background task"); @@ -933,6 +1009,17 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { let generation_idempotency_key = request_header(generation_request, "idempotency-key").expect("generation idempotency key"); assert!(uuid::Uuid::parse_str(&generation_idempotency_key).is_ok()); + let generation_body: Value = serde_json::from_str( + generation_request + .split_once("\r\n\r\n") + .expect("generation request body") + .1, + ) + .expect("generation request json"); + assert_eq!(generation_body["sliceMode"], "grid"); + assert_eq!(generation_body["gridX"], 2); + assert_eq!(generation_body["gridY"], 2); + assert!(generation_body["sliceCount"].is_null()); assert!(generation_request.contains(r#""source":"ai-game-creator-client""#)); assert_eq!( canvas_requests @@ -1019,6 +1106,13 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { "canvas-project-1", ); } + if root.join("assets/user-reference.png").is_file() { + bind_canvas_visual_asset_fixture_to_current_editor( + root, + "assets/user-reference.png", + "canvas-project-1", + ); + } request_platform_art_asset_with_options_for_test(root, "原创贪吃蛇视觉", &options) .await .expect("prepare canonical visual request"); @@ -1079,6 +1173,9 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, + screen_color: None, }, ) .await; @@ -1086,9 +1183,385 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { assert!(ui_request.contains(r#""kind":"ui-design""#)); assert!(ui_request.contains(r#""referenceImageSrcs":["resource-icon-spec"]"#)); + // 用户参考只接受当前项目已登记图片素材 id,并换成当前账号绑定下的远端资源 ID。 + register_canvas_visual_asset_fixture(&root, "assets/user-reference.png", "image"); + let user_reference_asset_id = manifest_asset_id(&root, "assets/user-reference.png"); + let icon_spec_config_dir = unique_project_path(); + let icon_spec_with_reference = capture_generation_request( + &root, + &icon_spec_config_dir, + PlatformArtAssetGenerationOptions { + output_path: Some("assets/icon-spec-custom.png".to_string()), + asset_kind: "icon-spec".to_string(), + asset_label: "带用户参考的图标规范".to_string(), + reference_asset_ids: vec![ + user_reference_asset_id.clone(), + user_reference_asset_id.clone(), + ], + ..PlatformArtAssetGenerationOptions::default() + }, + ) + .await; + assert!(icon_spec_with_reference.starts_with("POST /api/editor/images/generations ")); + // 图标规范没有规范前置:用户参考原样提交,且不带任何伪造的规范引用。 + assert!( + icon_spec_with_reference.contains(r#""referenceImageSrcs":["resource-image"]"#), + "{icon_spec_with_reference}" + ); + + let ui_with_reference_config_dir = unique_project_path(); + let ui_with_reference = capture_generation_request( + &root, + &ui_with_reference_config_dir, + PlatformArtAssetGenerationOptions { + output_path: Some("assets/ui-prototype-custom.png".to_string()), + aspect_ratio: "16:9".to_string(), + image_size: "2K".to_string(), + asset_kind: "ui-prototype".to_string(), + asset_label: "带用户参考的界面原型图".to_string(), + reference_asset_ids: vec![user_reference_asset_id.clone()], + ..PlatformArtAssetGenerationOptions::default() + }, + ) + .await; + // 有规范前置的生成:规范图始终是第一项,用户参考按给出顺序追加在后。 + assert!( + ui_with_reference + .contains(r#""referenceImageSrcs":["resource-icon-spec","resource-image"]"#), + "{ui_with_reference}" + ); + fs::remove_dir_all(root).ok(); fs::remove_dir_all(spec_config_dir).ok(); fs::remove_dir_all(ui_config_dir).ok(); + fs::remove_dir_all(icon_spec_config_dir).ok(); + fs::remove_dir_all(ui_with_reference_config_dir).ok(); +} + +/// 参考上传凭证请求:同一路由在 External v1 与平台会话下会落到两种前缀,两边的写操作都要看住。 +fn is_reference_upload_ticket_request(request: &str) -> bool { + request.starts_with("POST /api/assets/direct-upload-tickets ") + || request.starts_with("POST /api/external/v1/assets/direct-upload-tickets ") +} + +/// 图片生成提交:同样两种前缀都要算。 +fn is_image_generation_request(request: &str) -> bool { + request.starts_with("POST /api/editor/images/generations ") + || request.starts_with("POST /api/external/v1/editor/images/generations ") + || request.starts_with("POST /api/editor/icon-spritesheets/generations ") + || request.starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ") +} + +/// 当前项目清单里某条素材的 manifest 资产 id(参考选择只接受这个身份)。 +fn manifest_asset_id(root: &Path, local_path: &str) -> String { + read_manifest_for_project(root) + .expect("read manifest for asset id") + .assets + .into_iter() + .find(|asset| asset.local_path == local_path) + .unwrap_or_else(|| panic!("registered asset is present: {local_path}")) + .id +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reference_selection_rejects_unsupported_inputs_before_any_generation_post() { + let root = unique_project_path(); + let config_dir = unique_project_path(); + let (request_sender, request_receiver) = mpsc::channel(); + // 这条用例要连续跑十几次「画布上下文 + 参考预检」,超过默认 20 次请求预算会被判成 502。 + let canvas_base_url = + spawn_mock_external_canvas_api_server_with_capture(200, Some(request_sender)); + let _platform_session = crate::platform_session::install_test_platform_session( + "reference-guard-user", + "editor-runtime-key", + &canvas_base_url, + ); + fs::create_dir_all(&config_dir).expect("create reference guard config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { "baseUrl": canvas_base_url, "apiKey": "editor-runtime-key" } + }) + .to_string(), + ) + .expect("write reference guard config"); + let _config_guard = use_test_runtime_config_dir(config_dir.clone()); + init_local_game_project_at(&root, "reference-guard", "参考素材门禁") + .expect("init reference guard project"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow reference guard generation"); + let options = + |asset_kind: &str, reference_asset_ids: Vec| PlatformArtAssetGenerationOptions { + output_path: Some(format!("assets/reference-guard-{asset_kind}.png")), + asset_kind: asset_kind.to_string(), + asset_label: "参考素材门禁".to_string(), + reference_asset_ids, + ..PlatformArtAssetGenerationOptions::default() + }; + async fn reject(root: &Path, options: PlatformArtAssetGenerationOptions) -> String { + request_platform_art_asset_with_options_for_test(root, "参考素材门禁", &options) + .await + .expect_err("reference selection must fail closed") + } + let canvas_source = || GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }; + + // 只接受单规范引用的图集不接受用户参考,且必须明确拒绝而不是静默丢弃。 + let error = reject( + &root, + options("art-spritesheet", vec!["user-asset".to_string()]), + ) + .await; + assert!(error.contains("透明美术图集只接受规范图引用"), "{error}"); + + // 路径、URL 等形状不是素材身份。 + for rejected in [ + "assets/hero.png", + "..\\hero.png", + "https://example.com/hero.png", + ] { + let error = reject(&root, options("icon-spec", vec![rejected.to_string()])).await; + assert!(error.contains("不接受路径或远端资源 ID"), "{error}"); + } + + // 未登记的 id 不能冒充当前项目素材(历史账号的远端资源 ID 也不在此列)。 + let error = reject( + &root, + options( + "icon-spec", + vec!["editor-resource-from-older-account".to_string()], + ), + ) + .await; + assert!( + error.contains("参考素材不在当前项目已登记清单中"), + "{error}" + ); + + // 非图片素材不能当参考。 + fs::create_dir_all(root.join("assets")).expect("create reference guard asset dir"); + fs::write(root.join("assets/document.json"), b"{}").expect("write non image reference fixture"); + register_local_asset_at( + &root, + "assets/document.json", + "image", + "application/json", + "canvas", + canvas_source(), + ) + .expect("register non image reference fixture"); + let error = reject( + &root, + options( + "icon-spec", + vec![manifest_asset_id(&root, "assets/document.json")], + ), + ) + .await; + assert!(error.contains("参考素材必须是图片"), "{error}"); + + // 已登记但本地文件缺失的素材不能被引用。 + register_canvas_visual_asset_fixture(&root, "assets/missing-reference.png", "image"); + let missing_asset_id = manifest_asset_id(&root, "assets/missing-reference.png"); + fs::remove_file(root.join("assets/missing-reference.png")) + .expect("remove missing reference fixture file"); + let error = reject(&root, options("icon-spec", vec![missing_asset_id])).await; + assert!(error.contains("不存在;请重新登记后再引用"), "{error}"); + + // 超过总上限:无规范前置最多 5 张。 + let error = reject( + &root, + options( + "icon-spec", + (0..6).map(|index| format!("reference-{index}")).collect(), + ), + ) + .await; + assert!(error.contains("普通图片生成最多 5 张参考素材"), "{error}"); + + // SVG 与坏图同样必须在提交前失败:本次不做 SVG 转换,也不允许把「已登记」当成可解码。 + register_canvas_visual_asset_fixture(&root, "assets/plain-reference.png", "image"); + let plain_reference_asset_id = manifest_asset_id(&root, "assets/plain-reference.png"); + fs::write( + root.join("assets/vector-reference.svg"), + b"", + ) + .expect("write svg reference fixture"); + register_local_asset_at( + &root, + "assets/vector-reference.svg", + "image", + "image/svg+xml", + "canvas", + canvas_source(), + ) + .expect("register svg reference fixture"); + let vector_asset_id = manifest_asset_id(&root, "assets/vector-reference.svg"); + let error = reject( + &root, + options( + "icon-spec", + vec![plain_reference_asset_id.clone(), vector_asset_id], + ), + ) + .await; + assert!(error.contains("不支持 SVG 等矢量格式"), "{error}"); + + // 声明成位图、实际是矢量扩展名的素材同样要按矢量拒绝。 + fs::write( + root.join("assets/mislabeled-reference.svg"), + valid_test_png_bytes(), + ) + .expect("write mislabeled svg reference fixture"); + register_local_asset_at( + &root, + "assets/mislabeled-reference.svg", + "image", + "image/png", + "canvas", + canvas_source(), + ) + .expect("register mislabeled svg reference fixture"); + let mislabeled_asset_id = manifest_asset_id(&root, "assets/mislabeled-reference.svg"); + let error = reject( + &root, + options( + "icon-spec", + vec![plain_reference_asset_id.clone(), mislabeled_asset_id], + ), + ) + .await; + assert!(error.contains("不支持 SVG 等矢量格式"), "{error}"); + + fs::write(root.join("assets/broken-reference.png"), b"not-a-png") + .expect("write broken reference fixture"); + register_local_asset_at( + &root, + "assets/broken-reference.png", + "image", + "image/png", + "canvas", + canvas_source(), + ) + .expect("register broken reference fixture"); + let broken_asset_id = manifest_asset_id(&root, "assets/broken-reference.png"); + let error = reject( + &root, + options( + "icon-spec", + vec![plain_reference_asset_id.clone(), broken_asset_id], + ), + ) + .await; + assert!(error.contains("不是可解析图片"), "{error}"); + + // 以上全部在提交前失败:没有生成 POST,也没有任何参考上传凭证被签发。 + // 合格参考故意不做 binding:旧路径会先为它签发凭证,所以这条断言对半完成上传有实际约束。 + while let Ok(request) = request_receiver.recv_timeout(Duration::from_millis(200)) { + assert!(!is_image_generation_request(&request), "{request}"); + assert!(!is_reference_upload_ticket_request(&request), "{request}"); + } + + // 反证:同一张合格参考单独提交时确实会去签发上传凭证,说明上面的「零上传」不是空断言。 + let differential = request_platform_art_asset_with_options_for_test( + &root, + "参考素材门禁", + &options("icon-spec", vec![plain_reference_asset_id.clone()]), + ) + .await; + let mut upload_ticket_attempted = false; + while let Ok(request) = request_receiver.recv_timeout(Duration::from_millis(300)) { + if is_reference_upload_ticket_request(&request) { + upload_ticket_attempted = true; + } + } + assert!( + upload_ticket_attempted, + "the same single reference must attempt an upload ticket: {differential:?}" + ); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reference_upload_permission_gate_fails_closed_before_any_upload() { + let root = unique_project_path(); + let config_dir = unique_project_path(); + let (request_sender, request_receiver) = mpsc::channel(); + let canvas_base_url = spawn_mock_external_canvas_generation_api_server(Some(request_sender)); + let _platform_session = crate::platform_session::install_test_platform_session( + "reference-permission-user", + "editor-runtime-key", + &canvas_base_url, + ); + fs::create_dir_all(&config_dir).expect("create reference permission config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { "baseUrl": canvas_base_url, "apiKey": "editor-runtime-key" } + }) + .to_string(), + ) + .expect("write reference permission config"); + let _config_guard = use_test_runtime_config_dir(config_dir.clone()); + init_local_game_project_at(&root, "reference-permission", "参考上传门禁") + .expect("init reference permission project"); + // 只拒绝 asset.upload:参考素材要上传到平台账号,这个门禁必须在本地失败关闭。 + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["asset.upload".to_string()], + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("deny reference upload"); + register_canvas_visual_asset_fixture(&root, "assets/plain-reference.png", "image"); + let plain_reference_asset_id = manifest_asset_id(&root, "assets/plain-reference.png"); + + let error = request_platform_art_asset_with_options_for_test( + &root, + "参考素材门禁", + &PlatformArtAssetGenerationOptions { + output_path: Some("assets/reference-permission.png".to_string()), + asset_kind: "icon-spec".to_string(), + asset_label: "参考素材门禁".to_string(), + reference_asset_ids: vec![plain_reference_asset_id], + ..PlatformArtAssetGenerationOptions::default() + }, + ) + .await + .expect_err("a denied asset.upload must fail closed"); + assert!( + error.contains("项目权限策略拒绝执行:asset.upload"), + "{error}" + ); + + while let Ok(request) = request_receiver.recv_timeout(Duration::from_millis(200)) { + assert!(!is_reference_upload_ticket_request(&request), "{request}"); + assert!(!is_image_generation_request(&request), "{request}"); + } + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -1233,6 +1706,7 @@ async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_m output_path: Some("assets/art-spritesheet.png".to_string()), asset_kind: "art-spritesheet".to_string(), asset_label: "游戏首版核心美术素材".to_string(), + slice_mode: Some("connected-components".to_string()), ..PlatformArtAssetGenerationOptions::default() }, )) @@ -1532,6 +2006,63 @@ fn automatic_local_game_project_allocates_unique_initialized_workspaces() { fs::remove_dir_all(projects_root).ok(); } +#[test] +fn requested_project_creation_root_accepts_only_an_absolute_regular_directory() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("create creation-root fixture"); + let not_a_directory = root.join("not-a-directory.txt"); + fs::write(¬_a_directory, b"x").expect("write file fixture"); + + assert_eq!( + validate_requested_game_project_creation_root(" ").expect_err("blank root is rejected"), + "项目创建目录必须是绝对路径" + ); + assert_eq!( + validate_requested_game_project_creation_root("relative/projects") + .expect_err("relative root is rejected"), + "项目创建目录必须是绝对路径" + ); + assert_eq!( + validate_requested_game_project_creation_root(&format!("{}\\pro\nject", root.display())) + .expect_err("control character is rejected"), + "项目创建目录不能包含控制字符" + ); + assert_eq!( + validate_requested_game_project_creation_root(¬_a_directory.to_string_lossy()) + .expect_err("file root is rejected"), + "项目创建目录必须是普通文件夹" + ); + assert_eq!( + validate_requested_game_project_creation_root(&format!(" {}\n", root.display())) + .expect("trimmed directory root is accepted"), + root + ); + assert!( + validate_requested_game_project_creation_root(&root.join("missing").to_string_lossy()) + .is_err(), + "a not-yet-existing creation root must fail instead of being created silently" + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn automatic_local_game_project_creates_inside_the_requested_creation_root() { + let projects_root = unique_project_path(); + fs::create_dir_all(&projects_root).expect("create creation-root fixture"); + + let requested = validate_requested_game_project_creation_root(&projects_root.to_string_lossy()) + .expect("valid creation root"); + let result = create_automatic_local_game_project_at(&requested, None, false) + .expect("create workspace in requested root"); + + let project_root = PathBuf::from(&result.project_path); + assert_eq!(project_root.parent(), Some(projects_root.as_path())); + assert!(project_root.join(".agent/manifest.json").is_file()); + + fs::remove_dir_all(projects_root).ok(); +} + #[test] fn automatic_local_game_project_accepts_only_a_safe_custom_name() { let projects_root = unique_project_path(); @@ -2656,6 +3187,8 @@ async fn generate_local_project_asset_command_registers_the_requested_toolbar_ki Some("2K".to_string()), Some("工具栏图片".to_string()), None, + None, + None, ) .await .expect("toolbar asset generation"); @@ -2688,6 +3221,99 @@ async fn generate_local_project_asset_command_registers_the_requested_toolbar_ki fs::remove_dir_all(config_dir).ok(); } +#[tokio::test] +async fn generate_local_project_asset_command_lands_the_requested_target_category() { + // 入口栏目与生成 kind 不是同一套词汇:kind=image 按 kind 派生只会落到 unclassified, + // 必须靠 targetCategory 才能落回入口栏目,否则生成完成后占位无法被原位接管。 + let root = unique_project_path(); + let config_dir = unique_project_path(); + let (request_sender, request_receiver) = mpsc::channel(); + let canvas_base_url = spawn_mock_external_canvas_generation_api_server(Some(request_sender)); + let _platform_session = crate::platform_session::install_test_platform_session( + "toolbar-category-user", + "editor-toolbar-category-key", + &canvas_base_url, + ); + fs::create_dir_all(&config_dir).expect("create runtime config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { "baseUrl": canvas_base_url, "apiKey": "editor-toolbar-category-key" } + }) + .to_string(), + ) + .expect("write runtime config"); + let _guard = use_test_runtime_config_dir(config_dir.clone()); + init_local_game_project_at(&root, "project-toolbar-category", "未命名游戏原型") + .expect("init project"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow generation"); + + let asset = generate_local_project_asset( + root.to_string_lossy().into_owned(), + "image".to_string(), + "像素月光厨房主角".to_string(), + Some("1:1".to_string()), + Some("1K".to_string()), + Some("主角图".to_string()), + None, + None, + Some("character".to_string()), + ) + .await + .expect("target category generation"); + let manifest: Value = + serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) + .expect("manifest json"); + let entry = manifest["assets"] + .as_array() + .expect("manifest assets") + .iter() + .find(|entry| entry["id"].as_str() == Some(asset.id.as_str())) + .expect("registered asset with target category"); + // kind 仍是请求的生成 kind,栏目取显式目标分类,而不是 kind 派生的 unclassified。 + assert_eq!(entry["kind"], "image"); + assert_eq!(entry["category"], "character"); + + // 先排掉这一次生成自己的请求,再验证非法栏目值不会带来任何新的生成请求。 + while request_receiver + .recv_timeout(Duration::from_millis(200)) + .is_ok() + {} + for rejected in ["version", "all", "bogus"] { + let error = generate_local_project_asset( + root.to_string_lossy().into_owned(), + "image".to_string(), + "像素月光厨房主角".to_string(), + None, + None, + None, + None, + None, + Some(rejected.to_string()), + ) + .await + .expect_err("illegal target category must fail closed"); + assert!( + error.contains("目标分类不是合法素材分类"), + "{rejected}: {error}" + ); + } + while let Ok(request) = request_receiver.recv_timeout(Duration::from_millis(200)) { + assert!(!is_image_generation_request(&request), "{request}"); + } + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); +} + #[tokio::test] async fn generate_local_project_asset_command_maps_spec_onto_the_verified_icon_spec_channel() { let root = unique_project_path(); @@ -2722,6 +3348,8 @@ async fn generate_local_project_asset_command_maps_spec_onto_the_verified_icon_s None, Some("视觉规范图".to_string()), None, + None, + None, ) .await .expect("toolbar spec generation"); @@ -2788,6 +3416,8 @@ async fn generate_local_project_asset_command_generates_art_spritesheet_from_the None, None, None, + None, + None, ) .await .expect_err("art-spritesheet requires a registered icon-spec"); @@ -2811,6 +3441,8 @@ async fn generate_local_project_asset_command_generates_art_spritesheet_from_the Some("1K".to_string()), Some("游戏首版图集".to_string()), None, + None, + None, ) .await .expect("toolbar spritesheet generation"); @@ -2988,6 +3620,65 @@ fn local_project_file_commands_read_write_list_and_delete_text_files() { fs::remove_dir_all(root).ok(); } +/// 引擎生成目录不进发现结果,但**只在当前目录确实是 Cocos Creator 工程时**生效: +/// 同名目录在别的工程里可能是真实源码目录,全局跳过会把用户代码从发现结果里删掉。 +#[test] +fn local_project_file_listing_skips_engine_generated_directories_only_for_engine_projects() { + let cocos = canonical_test_tempdir("engine-generated-dirs-"); + let cocos = cocos.path(); + fs::create_dir_all(cocos.join("assets")).expect("create assets"); + fs::create_dir_all(cocos.join("library/imported")).expect("create library"); + fs::create_dir_all(cocos.join("temp/programming")).expect("create temp"); + fs::create_dir_all(cocos.join("profiles/v2")).expect("create profiles"); + fs::create_dir_all(cocos.join("local")).expect("create local"); + fs::write(cocos.join("assets/hero.glb"), b"glTF").expect("write model"); + fs::write(cocos.join("library/imported/hero.json"), b"{}").expect("write library file"); + fs::write(cocos.join("temp/programming/packer.cpp"), b"//").expect("write temp file"); + fs::write(cocos.join("profiles/v2/user.json"), b"{}").expect("write profile file"); + fs::write(cocos.join("local/settings.json"), b"{}").expect("write local file"); + fs::write( + cocos.join("package.json"), + r#"{ "name": "cocos-project", "creator": { "version": "3.8.8" } }"#, + ) + .expect("write cocos package.json"); + + let listed = list_local_project_files_at(cocos).expect("list cocos project files"); + let paths = listed + .files + .iter() + .map(|file| file.path.as_str()) + .collect::>(); + assert!(paths.contains(&"assets/hero.glb"), "{paths:?}"); + for skipped in [ + "library", + "library/imported/hero.json", + "temp", + "temp/programming/packer.cpp", + "profiles", + "profiles/v2/user.json", + "local", + "local/settings.json", + ] { + assert!( + !paths.contains(&skipped), + "引擎生成目录必须被过滤:{skipped} / {paths:?}" + ); + } + + let plain = canonical_test_tempdir("plain-project-dirs-"); + let plain = plain.path(); + fs::create_dir_all(plain.join("library")).expect("create plain library"); + fs::write(plain.join("library/index.ts"), b"//").expect("write plain library file"); + let listed = list_local_project_files_at(plain).expect("list plain project files"); + assert!( + listed + .files + .iter() + .any(|file| file.path == "library/index.ts"), + "非引擎工程的同名目录不得被过滤" + ); +} + #[test] fn local_project_export_package_uses_runtime_whitelist_and_records() { let root = unique_project_path(); @@ -5499,6 +6190,9 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, + screen_color: None, }; let prompt = build_platform_art_asset_prompt( "原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开", diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index 1eacbe9c9..8fef84893 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -662,7 +662,7 @@ async fn chat_with_game_creator_role_agent_stream_does_not_fallback_on_upstream_ } #[test] -fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() { +fn provider_transient_retry_uses_configured_max_retries_for_every_run_profile() { let root = unique_project_path(); init_local_game_project_at( &root, @@ -671,16 +671,16 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() { ) .expect("project init"); - assert_eq!( - game_creator_agent_runtime_provider_transient_max_retries_at( - &root, - "design-director", - "legacy-standard-run", - 99, - ) - .expect("legacy standard retry policy"), - 3 - ); + // 历史 standard run 仍走同一身份校验,但重试次数不再被收进区间。 + let legacy_standard = game_creator_agent_runtime_provider_transient_retry_policy_at( + &root, + "design-director", + "legacy-standard-run", + 99, + ) + .expect("legacy standard retry policy"); + assert_eq!(legacy_standard.max_retries, 99); + assert!(!legacy_standard.retry_upstream_400); let standard = bind_game_creator_agent_runtime_run_profile_at( &root, "design-director", @@ -691,25 +691,25 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() { ) .expect("bind standard profile"); assert_eq!( - game_creator_agent_runtime_provider_transient_max_retries_at( + game_creator_agent_runtime_provider_transient_retry_policy_at( &root, &standard.agent_id, &standard.run_id, 0, ) - .expect("standard zero retry policy"), + .expect("standard zero retry policy") + .max_retries, 0 ); - assert_eq!( - game_creator_agent_runtime_provider_transient_max_retries_at( - &root, - &standard.agent_id, - &standard.run_id, - 99, - ) - .expect("standard capped retry policy"), - 3 - ); + let standard_configured = game_creator_agent_runtime_provider_transient_retry_policy_at( + &root, + &standard.agent_id, + &standard.run_id, + 99, + ) + .expect("standard configured retry policy"); + assert_eq!(standard_configured.max_retries, 99); + assert!(!standard_configured.retry_upstream_400); let parent = bind_game_creator_agent_runtime_run_profile_at( &root, @@ -720,17 +720,16 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() { None, ) .expect("bind autonomous parent profile"); - for (configured, expected) in [(0, 12), (14, 14), (99, 16)] { - assert_eq!( - game_creator_agent_runtime_provider_transient_max_retries_at( - &root, - &parent.agent_id, - &parent.run_id, - configured, - ) - .expect("autonomous parent retry policy"), - expected - ); + for (configured, expected) in [(0, 0), (5, 5), (99, 99)] { + let policy = game_creator_agent_runtime_provider_transient_retry_policy_at( + &root, + &parent.agent_id, + &parent.run_id, + configured, + ) + .expect("autonomous parent retry policy"); + assert_eq!(policy.max_retries, expected); + assert!(policy.retry_upstream_400); } let child_link = AgentRuntimeTaskLink { @@ -758,14 +757,25 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() { append_game_creator_agent_runtime_task(&root, &child_state) .expect("append autonomous child task projection"); assert_eq!( - game_creator_agent_runtime_provider_transient_max_retries_at( + game_creator_agent_runtime_provider_transient_retry_policy_at( &root, &child.agent_id, &child.run_id, 0, ) - .expect("autonomous child retry policy"), - 12 + .expect("autonomous child retry policy") + .max_retries, + 0 + ); + assert!( + game_creator_agent_runtime_provider_transient_retry_policy_at( + &root, + &child.agent_id, + &child.run_id, + 0, + ) + .expect("autonomous child retry policy") + .retry_upstream_400 ); fs::remove_file(game_creator_agent_runtime_run_profile_binding_path( @@ -775,7 +785,7 @@ fn autonomous_game_build_profile_uses_durable_provider_retry_floor_and_cap() { )) .expect("remove autonomous child binding"); assert!( - game_creator_agent_runtime_provider_transient_max_retries_at( + game_creator_agent_runtime_provider_transient_retry_policy_at( &root, &child.agent_id, &child.run_id, @@ -4558,7 +4568,7 @@ async fn provider_transient_retry_provider_error_is_not_retried() { } #[tokio::test] -async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_budget() { +async fn provider_transient_retry_autonomous_upstream_400_uses_configured_budget() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "自主构建 Provider 400 重试测试") .expect("project init"); @@ -4631,7 +4641,7 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b "model": "supervisor-autonomous-upstream-400-model", "apiKind": "openai_chat", "stream": false, - "maxRetries": 0, + "maxRetries": 2, "retryBackoffMs": 1 }} }} @@ -4676,10 +4686,7 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b .expect("initial autonomous upstream 400 request"); assert_eq!(waiting.error_kind, "upstream-400"); assert_eq!(waiting.next_attempt, 1); - assert_eq!( - waiting.max_retries, - AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT - ); + assert_eq!(waiting.max_retries, 2); provider_retry::force_provider_retry_due_for_test_at(&root, &waiting.identity) .expect("force autonomous upstream 400 retry due"); @@ -4725,10 +4732,7 @@ async fn provider_transient_retry_autonomous_upstream_400_retries_with_bounded_b .collect::>(); assert_eq!(retry_audits.len(), 1); assert_eq!(retry_audits[0]["errorKind"], "upstream-400"); - assert_eq!( - retry_audits[0]["maxRetries"], - AGENT_RUNTIME_AUTONOMOUS_PROVIDER_UPSTREAM_400_RETRY_LIMIT - ); + assert_eq!(retry_audits[0]["maxRetries"], 2); let lifecycle = records .iter() .filter(|record| { @@ -5885,6 +5889,8 @@ async fn agent_loop_uses_per_agent_llm_overrides() { agent_llm.insert( "planner".to_string(), GameCreatorLlmConfigFile { + custom_enabled: None, + visible_models: None, api_key: Some("planner-key".to_string()), base_url: Some(planner_base_url), model: Some("planner-model".to_string()), @@ -5903,6 +5909,8 @@ async fn agent_loop_uses_per_agent_llm_overrides() { agent_llm.insert( "generator".to_string(), GameCreatorLlmConfigFile { + custom_enabled: None, + visible_models: None, api_key: Some("generator-key".to_string()), base_url: Some(generator_base_url), model: Some("generator-model".to_string()), @@ -5921,6 +5929,8 @@ async fn agent_loop_uses_per_agent_llm_overrides() { agent_llm.insert( "art-asset-plan".to_string(), GameCreatorLlmConfigFile { + custom_enabled: None, + visible_models: None, api_key: Some("art-key".to_string()), base_url: Some(art_base_url), model: Some("art-model".to_string()), @@ -6910,9 +6920,29 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() { "imageSize", "assetKind", "assetLabel", - "replaceExisting" + "replaceExisting", + "sliceMode", + "gridX", + "gridY", + "sliceCount" ]) ); + let canvas_properties = &canvas_asset.parameters["properties"]["input"]["properties"]; + assert_eq!( + canvas_properties["sliceMode"]["enum"], + serde_json::json!(["connected-components", "grid", null]) + ); + for field in ["gridX", "gridY", "sliceCount"] { + assert_eq!( + canvas_properties[field]["type"], + serde_json::json!(["integer", "null"]) + ); + assert_eq!(canvas_properties[field]["minimum"], 1); + assert_eq!( + canvas_properties[field]["maximum"], + if field == "sliceCount" { 256 } else { 32 } + ); + } assert_eq!( canvas_asset.parameters["properties"]["input"]["properties"]["aspectRatio"]["enum"], serde_json::json!(["1:1", "2:3", "3:2", "9:16", "16:9", null]) @@ -6921,6 +6951,10 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() { canvas_asset.parameters["properties"]["input"]["properties"]["imageSize"]["enum"], serde_json::json!(["0.5K", "1K", "2K", null]) ); + assert_eq!( + canvas_asset.parameters["properties"]["input"]["properties"]["sliceMode"]["enum"], + serde_json::json!(["connected-components", "grid", null]) + ); assert_eq!( canvas_asset.parameters["properties"]["input"]["properties"]["assetKind"]["enum"], serde_json::json!([ diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs index 762b67983..db84a285b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs @@ -1494,7 +1494,7 @@ async fn background_agent_runtime_deletes_file_then_verifies_before_completion() assert!(prompt_input.contains("只删除项目内普通文件")); let verification_request = receiver - .recv_timeout(Duration::from_secs(2)) + .recv_timeout(Duration::from_secs(10)) .expect("verification plan request"); assert!(verification_request.contains("file.delete")); assert!(verification_request.contains("已删除 game/obsolete-runtime-file.txt")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs index 88db4e255..9515a6e4a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs @@ -1147,7 +1147,7 @@ async fn background_final_reply_failure_keeps_private_conversation_and_hashes_pu "response": "" }) .to_string(); - let base_url = spawn_mock_llm_tool_plan_then_invalid_final_reply(planning_response); + let base_url = spawn_mock_llm_tool_plan_then_invalid_final_reply(planning_response, 1); replace_test_local_config( &config_path, format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs index 73b55d9ce..a566a9225 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/persistence.rs @@ -158,7 +158,7 @@ pub(crate) fn load_ui_design_state_at( let root = Path::new(input.project_path.trim()); let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?; let asset_id = required_identifier(&input.asset_id, "assetId")?; - let asset = ui_design_asset(root, &expected_project_id, &asset_id)?; + let asset = registered_json_asset(root, &expected_project_id, &asset_id)?; let document = read_ui_design_document(root, &asset.local_path, &expected_project_id, &asset_id)?; validate_document(&document, &expected_project_id, &asset_id)?; @@ -175,7 +175,7 @@ pub(crate) fn generate_ui_design_code_at( let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?; let asset_id = required_identifier(&input.asset_id, "assetId")?; let _lock = acquire_project_write_lock(root, "ui_design.code_generate")?; - let asset = ui_design_asset(root, &expected_project_id, &asset_id)?; + let asset = registered_json_asset(root, &expected_project_id, &asset_id)?; let document = read_ui_design_document_locked(root, &asset.local_path, &expected_project_id, &asset_id)?; let (content, tree_exports, node_count) = render_ui_design_state_js(&document.state)?; @@ -225,9 +225,9 @@ pub(crate) fn save_ui_design_state_at( let expected_project_id = required_identifier(&input.expected_project_id, "expectedProjectId")?; let asset_id = required_identifier(&input.asset_id, "assetId")?; - let preflight_asset = ui_design_asset(root, &expected_project_id, &asset_id)?; + let preflight_asset = registered_json_asset(root, &expected_project_id, &asset_id)?; let _lock = acquire_project_write_lock(root, "ui_design.state_save")?; - let asset = ui_design_asset(root, &expected_project_id, &asset_id)?; + let asset = registered_json_asset(root, &expected_project_id, &asset_id)?; if asset.local_path != preflight_asset.local_path { return Err("UI 设计资源在保存锁获取期间发生变化,请重试".to_string()); } @@ -311,6 +311,19 @@ fn ui_design_asset( root: &Path, expected_project_id: &str, asset_id: &str, +) -> Result { + let asset = registered_json_asset(root, expected_project_id, asset_id)?; + // 新状态初始化仍是显式 UI 创建动作,不能因放开已有设计的登记标签而覆盖普通 JSON。 + if asset.kind != "UI" || asset.media_type != "application/json" { + return Err("目标资源不是 UI 设计 JSON 资产".to_string()); + } + Ok(asset) +} + +fn registered_json_asset( + root: &Path, + expected_project_id: &str, + asset_id: &str, ) -> Result { let manifest = read_existing_manifest_for_project(root)?; if manifest.project_id != expected_project_id { @@ -321,8 +334,10 @@ fn ui_design_asset( .into_iter() .find(|asset| asset.id == asset_id) .ok_or_else(|| "UI 设计资源不存在".to_string())?; - if asset.kind != "UI" || asset.media_type != "application/json" { - return Err("目标资源不是 UI 设计 JSON 资产".to_string()); + if !asset.local_path.to_ascii_lowercase().ends_with(".json") + || !is_supported_project_text_resource(&asset.local_path, &asset.media_type) + { + return Err("目标资源不是已登记的 JSON 资产".to_string()); } normalize_relative_path(&asset.local_path)?; Ok(asset) @@ -393,10 +408,27 @@ fn read_ui_design_document_path(path: &Path) -> Result bool { + parse_ui_design_document(content.as_bytes()) + .and_then(|document| validate_document(&document, project_id, asset_id)) + .is_ok() +} + +fn parse_ui_design_document(bytes: &[u8]) -> Result { + if bytes.len() > UI_DESIGN_STATE_MAX_BYTES { + return Err(format!( + "UI 设计 State 超过 {UI_DESIGN_STATE_MAX_BYTES} 字节上限" + )); + } + let value: serde_json::Value = serde_json::from_slice(bytes) + .map_err(|error| format!("解析 UI 设计 State 失败:{error}"))?; let document: PersistedUiDesignState = serde_json::from_value(value.clone()) - .map_err(|error| format!("解析 UI 设计 State 契约失败:{}: {error}", path.display()))?; + .map_err(|error| format!("解析 UI 设计 State 契约失败:{error}"))?; let canonical: serde_json::Value = serde_json::from_slice(&serialize_ui_design_document(&document)?) .map_err(|error| format!("序列化 UI 设计 State 契约失败:{error}"))?; @@ -847,6 +879,170 @@ mod tests { } } + #[test] + fn json_preview_recognition_requires_canonical_state_and_resource_identity() { + let document = serde_json::to_value(empty_document(PROJECT_ID, "design")).unwrap(); + let content = document.to_string(); + assert!(is_valid_ui_design_json(&content, PROJECT_ID, "design")); + assert!(!is_valid_ui_design_json( + &content, + "other-project", + "design" + )); + assert!(!is_valid_ui_design_json( + &content, + PROJECT_ID, + "other-asset" + )); + for content in ["{broken", "{}", "[]", r#"{"type":"UI"}"#] { + assert!(!is_valid_ui_design_json(content, PROJECT_ID, "design")); + } + for (pointer, value) in [ + ("/schemaVersion", serde_json::json!("unknown-schema")), + ("/revision", serde_json::json!(9_007_199_254_740_992u64)), + ("/state/ui_trees", serde_json::json!([{}])), + ] { + let mut invalid = document.clone(); + *invalid.pointer_mut(pointer).unwrap() = value; + assert!(!is_valid_ui_design_json( + &invalid.to_string(), + PROJECT_ID, + "design" + )); + } + let mut unknown = document.clone(); + unknown["state"]["unknown"] = serde_json::json!(true); + assert!(!is_valid_ui_design_json( + &unknown.to_string(), + PROJECT_ID, + "design" + )); + let invalid_state = PersistedUiDesignState { + state: state_with_unavailable_image("../outside.png"), + ..empty_document(PROJECT_ID, "design") + }; + assert!(!is_valid_ui_design_json( + &serde_json::to_string(&invalid_state).unwrap(), + PROJECT_ID, + "design", + )); + assert!(!is_valid_ui_design_json( + &" ".repeat(UI_DESIGN_STATE_MAX_BYTES + 1), + PROJECT_ID, + "design", + )); + } + + #[test] + fn json_preview_and_editor_accept_valid_state_without_rewriting_asset_kind() { + for kind in ["UI", "ui", "ui-design", "document"] { + let (directory, asset_id) = fixture(); + crate::project::mutate_manifest_at(directory.path(), |manifest| { + manifest + .assets + .iter_mut() + .find(|asset| asset.id == asset_id) + .unwrap() + .kind = kind.to_string(); + Ok(()) + }) + .unwrap(); + let path = directory.path().join("ui/design.json"); + let before = fs::read(&path).unwrap(); + let preview = read_local_project_text_preview_at( + directory.path().to_str().unwrap(), + "ui/design.json", + &crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation::uncancelled(), + ).unwrap(); + assert_eq!( + preview.ui_design_asset_id.as_deref(), + Some(asset_id.as_str()) + ); + assert_eq!(fs::read(&path).unwrap(), before); + let loaded = load_ui_design_state_at(LoadUiDesignStateInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + asset_id: asset_id.clone(), + }) + .unwrap(); + assert_eq!(loaded.revision, 0); + let saved = save_ui_design_state_at(input( + directory.path(), + &asset_id, + 0, + state_with_unavailable_image("assets/page.png"), + )) + .unwrap(); + assert!(matches!( + saved, + SaveUiDesignStateResult::Saved { revision: 1, .. } + )); + assert_eq!( + read_existing_manifest_for_project(directory.path()) + .unwrap() + .assets + .iter() + .find(|asset| asset.id == asset_id) + .unwrap() + .kind, + kind, + ); + } + } + + #[test] + fn ordinary_or_foreign_json_preview_never_grants_ui_editing_or_overwrites_content() { + let (directory, asset_id) = fixture(); + crate::project::mutate_manifest_at(directory.path(), |manifest| { + manifest + .assets + .iter_mut() + .find(|asset| asset.id == asset_id) + .unwrap() + .kind = "document".to_string(); + Ok(()) + }) + .unwrap(); + let path = directory.path().join("ui/design.json"); + let foreign = serde_json::to_string(&empty_document("other-project", &asset_id)).unwrap(); + for content in [r#"{"ordinary":true}"#, "{broken", foreign.as_str()] { + fs::write(&path, content).unwrap(); + let preview = read_local_project_text_preview_at( + directory.path().to_str().unwrap(), + "ui/design.json", + &crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation::uncancelled(), + ).unwrap(); + assert_eq!(preview.ui_design_asset_id, None); + assert_eq!(preview.content, content); + assert!(load_ui_design_state_at(LoadUiDesignStateInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + asset_id: asset_id.clone(), + }) + .is_err()); + assert!(save_ui_design_state_at(input( + directory.path(), + &asset_id, + 0, + empty_document(PROJECT_ID, &asset_id).state, + )) + .is_err()); + assert!( + initialize_ui_design_state_at(directory.path(), PROJECT_ID, &asset_id).is_err() + ); + assert_eq!(fs::read_to_string(&path).unwrap(), content); + } + fs::write( + directory.path().join("ui/unregistered.json"), + serde_json::to_string(&empty_document(PROJECT_ID, &asset_id)).unwrap(), + ) + .unwrap(); + assert!(read_local_project_text_preview_at( + directory.path().to_str().unwrap(), "ui/unregistered.json", + &crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation::uncancelled(), + ).is_err()); + } + fn state_with_unavailable_image(path: &str) -> State { serde_json::from_value(serde_json::json!({ "ui_trees": [], 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 fd8cfc657..f52b347b1 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": "陶泥儿", - "version": "0.1.45", + "version": "0.1.67", "identifier": "world.genarrative.ai-game-creator", "build": { "beforeDevCommand": "npm --prefix ../.. run agc:serve", @@ -24,13 +24,14 @@ } ], "security": { - "csp": "default-src 'self' customprotocol: asset:; img-src 'self' asset: data: blob:; media-src 'self' asset: data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*", - "devCsp": "default-src 'self' customprotocol: asset: http://127.0.0.1:*; img-src 'self' asset: http://127.0.0.1:* data: blob:; media-src 'self' asset: http://127.0.0.1:* data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*" + "csp": "default-src 'self' customprotocol: asset:; img-src 'self' asset: data: blob: https://agc-dev.oss-rg-china-mainland.aliyuncs.com; media-src 'self' asset: data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*", + "devCsp": "default-src 'self' customprotocol: asset: http://127.0.0.1:*; img-src 'self' asset: http://127.0.0.1:* data: blob: https://agc-dev.oss-rg-china-mainland.aliyuncs.com; media-src 'self' asset: http://127.0.0.1:* data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*" } }, "bundle": { "active": true, "targets": "all", + "createUpdaterArtifacts": true, "resources": { "design-agent": "design-agent" }, @@ -50,5 +51,16 @@ "../../desktop-shell/src-tauri/icons/icon.ico", "../../desktop-shell/src-tauri/icons/icon.png" ] + }, + "plugins": { + "updater": { + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDRFN0NFOEUzNDczNDg4Q0IKUldUTGlEUkg0K2g4VGpaQ3FiTXdoNnJTV0JDSWU4VjQrTkcrMkovS2RleFloUXVhdWZIVGpMOTYK", + "endpoints": [ + "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json" + ], + "windows": { + "installMode": "quiet" + } + } } } diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.macos.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.macos.conf.json new file mode 100644 index 000000000..67695ad22 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tauri.macos.conf.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "macOS": { + "minimumSystemVersion": "15.0" + }, + "resources": { + "resources/codex/mac-native/bin/codex": "coding-agent/mac-native/bin/codex", + "resources/codex/mac-native/bin/codex-code-mode-host": "coding-agent/mac-native/bin/codex-code-mode-host", + "resources/codex/mac-native/codex-path/rg": "coding-agent/mac-native/codex-path/rg", + "resources/codex/mac-native/codex-resources/zsh/bin/zsh": "coding-agent/mac-native/codex-resources/zsh/bin/zsh", + "resources/codex/mac-native/codex-package.json": "coding-agent/mac-native/codex-package.json", + "resources/codex/mac-native/NOTICE.md": "coding-agent/mac-native/NOTICE.md", + "resources/codex/mac-native/manifest.json": "coding-agent/mac-native/manifest.json", + "resources/plugins": "plugins" + } + }, + "plugins": { + "updater": { + "endpoints": [ + "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/latest.json" + ] + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/tests/fixtures/agc-template-library-index.json b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/agc-template-library-index.json new file mode 100644 index 000000000..7cf1b85a7 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tests/fixtures/agc-template-library-index.json @@ -0,0 +1,133 @@ +{ + "schemaVersion": "agc-template-library.v1", + "library": "agc-game-templates", + "libraryVersion": 1, + "updatedAt": "2026-09-17T03:22:43Z", + "templates": [ + { + "id": "blank-2d-canvas", + "title": "空白二维画布工程", + "summary": "原生 Canvas 二维空白工程:自适应画布、按设备像素比缩放与 requestAnimationFrame 主循环已就绪。", + "tags": [ + "空白", + "起步工程", + "2d", + "canvas" + ], + "runtime": "html", + "engine": "canvas", + "engineVersion": "", + "templateVersion": "0.1.0", + "updatedAt": "2026-09-17T03:22:43Z", + "entry": "game/index.html", + "zipKey": "templates/v1/blank-2d-canvas/template.zip", + "zipSizeBytes": 1534, + "zipSha256": "ff8f84e4793941acaf161738c2795f65c5d5390de8614f51aa9e3a5771767134", + "coverKey": "templates/v1/blank-2d-canvas/cover.svg", + "coverWidth": 960, + "coverHeight": 540, + "coverSha256": "afb753dc6d3de0f9fb6e03ec94f2be7221dd04c9d4ab3cf92311879f0af25192", + "metadataKey": "templates/v1/blank-2d-canvas/template.json" + }, + { + "id": "blank-3d-scene", + "title": "空白三维场景工程", + "summary": "Three.js 空白场景:空场景、透视相机、网格地面与自适应视口已就绪,适合从零搭三维玩法。", + "tags": [ + "空白", + "起步工程", + "3d", + "three.js" + ], + "runtime": "html", + "engine": "three.js", + "engineVersion": "0.180.0", + "templateVersion": "0.1.0", + "updatedAt": "2026-09-17T03:22:43Z", + "entry": "game/index.html", + "zipKey": "templates/v1/blank-3d-scene/template.zip", + "zipSizeBytes": 1644, + "zipSha256": "f3f295f4e5adcf1445d75229dc1b583376a9bc96d3f27a257d69ee3b7cace892", + "coverKey": "templates/v1/blank-3d-scene/cover.svg", + "coverWidth": 960, + "coverHeight": 540, + "coverSha256": "1429232adaf6df4457e45b4fc8d7ee9fab2e6bffce9f3f0016e91b81bb66c6a7", + "metadataKey": "templates/v1/blank-3d-scene/template.json" + }, + { + "id": "blank-web", + "title": "空白网页工程", + "summary": "最小网页工程(HTML + CSS + 原生 JS + Vite),没有任何引擎依赖,适合从零写玩法。", + "tags": [ + "空白", + "起步工程", + "网页", + "原生" + ], + "runtime": "html", + "engine": "none", + "engineVersion": "", + "templateVersion": "0.1.0", + "updatedAt": "2026-09-17T03:22:43Z", + "entry": "game/index.html", + "zipKey": "templates/v1/blank-web/template.zip", + "zipSizeBytes": 1212, + "zipSha256": "6fa4391f30342e8dcbdcf735f990d2534ea50405f119e4fa5879b83e8f00119e", + "coverKey": "templates/v1/blank-web/cover.svg", + "coverWidth": 960, + "coverHeight": 540, + "coverSha256": "326a2753386618971b1311043effa46c2e74bc1cfb116862c0ee4097dcd5086a", + "metadataKey": "templates/v1/blank-web/template.json" + }, + { + "id": "phaser-2d-starter", + "title": "Phaser 2D 起步工程", + "summary": "AGC 新建项目使用的默认二维起步工程(Phaser 4 + Vite),解压后即为可运行项目根。", + "tags": [ + "起步工程", + "2d", + "phaser", + "像素" + ], + "runtime": "html", + "engine": "phaser", + "engineVersion": "4.2.1", + "templateVersion": "0.1.0", + "updatedAt": "2026-09-17T03:22:43Z", + "entry": "game/index.html", + "zipKey": "templates/v1/phaser-2d-starter/template.zip", + "zipSizeBytes": 8770, + "zipSha256": "9026856c3c0b3a42401172e36ce8b450a65e9f11eb9096624d8990d51449d8ce", + "coverKey": "templates/v1/phaser-2d-starter/cover.svg", + "coverWidth": 960, + "coverHeight": 540, + "coverSha256": "fdb422027bf54bf755b2b91cd21fa10fdd7ce3f5c7e3ccd9ac3ffba602b12b96", + "metadataKey": "templates/v1/phaser-2d-starter/template.json" + }, + { + "id": "threejs-3d-starter", + "title": "Three.js 3D 起步工程", + "summary": "网页三维起步工程(Three.js + Vite),自带可旋转立方体场景、方向光与自适应视口。", + "tags": [ + "起步工程", + "3d", + "three.js", + "网页" + ], + "runtime": "html", + "engine": "three.js", + "engineVersion": "0.180.0", + "templateVersion": "0.1.0", + "updatedAt": "2026-09-17T03:22:43Z", + "entry": "game/index.html", + "zipKey": "templates/v1/threejs-3d-starter/template.zip", + "zipSizeBytes": 1697, + "zipSha256": "03096152b17cd6d55e7f6ccd518485136133cb54fd2a5a5d0ac8e0974149155c", + "coverKey": "templates/v1/threejs-3d-starter/cover.svg", + "coverWidth": 960, + "coverHeight": 540, + "coverSha256": "7ba013e8a8b515d7aff7146fe401afe5ba3416b9c69db181179705e1bce01beb", + "metadataKey": "templates/v1/threejs-3d-starter/template.json" + } + ] +} diff --git a/apps/ai-game-creator-shell/src-tauri/tests/runner_gui_owner_lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/tests/runner_gui_owner_lifecycle.rs index c49ef18c1..d9cd7e56b 100644 --- a/apps/ai-game-creator-shell/src-tauri/tests/runner_gui_owner_lifecycle.rs +++ b/apps/ai-game-creator-shell/src-tauri/tests/runner_gui_owner_lifecycle.rs @@ -10,7 +10,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; const RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json"; const RUNNER_LOCK_FILE_NAME: &str = "agent-runner.lock"; -const GUI_OWNER_LOCK_FILE_NAME: &str = "agent-runner.gui-owner.lock"; +const GUI_PARTICIPANT_LOCK_FILE_NAME: &str = "agent-runner.gui-participant.lock"; struct TestDirectory(PathBuf); @@ -50,8 +50,9 @@ fn open_locked_file(path: &Path) -> File { .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW) .open(path) .expect("open isolated lock file"); + // 模拟一个界面窗口:参与锁以共享锁持有,多个窗口可以同时持有。 // SAFETY: file owns a live descriptor and flock does not retain pointers. - assert_eq!(unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) }, 0); + assert_eq!(unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_SH) }, 0); file } @@ -101,7 +102,7 @@ fn runner_binary() -> &'static str { #[test] fn gui_owned_runner_rejects_start_when_owner_dies_before_first_check() { let directory = TestDirectory::new("owner-lost-before-check"); - let owner = open_locked_file(&directory.path().join(GUI_OWNER_LOCK_FILE_NAME)); + let owner = open_locked_file(&directory.path().join(GUI_PARTICIPANT_LOCK_FILE_NAME)); let script = "kill -STOP $$; exec \"$1\" --agent-runner --config-dir \"$2\" --gui-owner-required"; let mut child = Command::new("/bin/sh") @@ -139,7 +140,7 @@ fn gui_owned_runner_rejects_start_when_owner_dies_before_first_check() { #[test] fn gui_owned_runner_exits_and_cleans_endpoint_after_established_owner_dies() { let directory = TestDirectory::new("owner-lost-after-start"); - let owner = open_locked_file(&directory.path().join(GUI_OWNER_LOCK_FILE_NAME)); + let owner = open_locked_file(&directory.path().join(GUI_PARTICIPANT_LOCK_FILE_NAME)); let mut child = Command::new(runner_binary()) .arg("--agent-runner") .arg("--config-dir") diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index ee075cfa5..16fb765b0 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -58,9 +58,6 @@ import type { DirectTurnCancelView, GameCreatorAgentRuntimeUpdateEvent, GameCreatorChatAgentReply, - GameCreatorDirectActiveTurn, - GameCreatorDirectToolCall, - GameCreatorDirectTurnUpdateEvent, GameCreatorLlmConfigStatus, GameCreatorManifestInvalidatedEvent, GameCreatorRoleAgentChatStreamEvent, @@ -99,7 +96,6 @@ import type { ProjectPermissionPolicyView, SyncCanvasProjectAssetsResult, TauriInvoke, - TurnStreamItem, UploadLocalAssetResult, } from './app/types'; import { useWindowChrome } from './components/windowChromeContext'; @@ -136,7 +132,6 @@ import { submitProjectSupervisorRuntimeTask, taskRowsFromManifest, } from './features/agent-runtime'; -import { DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS } from './features/agent-runtime/directActiveTurns'; import { type DirectCodexTurnAttachment, toDirectCodexTurnAttachments, @@ -237,14 +232,28 @@ import { } from './features/project-workspace/chatComposerQueue'; import { DeveloperProjectPanels } from './features/project-workspace/DeveloperProjectPanels'; import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels'; +import { + type DirectHistoryAnchorGate, + directHistoryAnchorGateToWaitFor, + reuseOrOpenDirectHistoryAnchorGate, +} from './features/project-workspace/directHistoryAnchorGate'; +import { readDirectHistoryPages } from './features/project-workspace/directHistoryPaging'; +import { + applyDirectThreadConsumeResult, + type DirectThreadChatState, + directThreadTurnMatchesUser, + emptyDirectThreadChatState, + finishDirectThreadTurn, + mergeDirectHistoryItems, + resolveDirectThreadBootstrap, + selectDirectChatEntries, +} from './features/project-workspace/directThreadChat'; import { type DirectThreadConsumeResult, - directThreadHistoryItemsToMessages, type DirectThreadHistorySlice, + type DirectThreadItem, type DirectThreadSubscriptionBootstrap, - isDirectTurnInProgress, } from './features/project-workspace/directThreadEvents'; -import { normalizeDirectTimestamp } from './features/project-workspace/directTurnPresentation'; import type { DirectCodexUserContentPart } from './features/project-workspace/generated'; import { appendMemoryContent, @@ -294,7 +303,10 @@ import { currentPlatformSessionGeneration, requestPlatformSessionRefresh, } from './services/platformSession'; -import { setAgcPluginProjectPath, startAgcPlugin } from './services/pluginHost'; +import { + setAgcPluginProjectPath, + startAvailableAgcPlugin, +} from './services/pluginHost'; import { canSubscribeTauriEvents, subscribeTauriEvent, @@ -314,13 +326,6 @@ const DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX = /** 与 Rust 侧 `DirectTaonierActiveInvocationGuard::enter` 的 else 分支文案保持一致。 */ const DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER = '当前项目已有另一条 Direct 客户端回合正在运行'; -/** - * 恢复出来的回合多久没有任何事件就算"没响应"。Rust 守卫是进程内的:重进会话时它还在, - * 但 app-server 侧可能早就没了。这时界面必须给出明确动作,而不是让用户一直等。 - */ -const DIRECT_CODEX_RECOVERED_TURN_STALLED_MS = 15_000; -const DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE = - '该回合已无响应,可在输入盒点「终止」结束它以继续'; // Platform access tokens are short lived. DirectProject can spend several // minutes in image generation, build and browser validation, so keep the // client-owned native session current while a turn is running. The singleflight @@ -356,136 +361,6 @@ async function withDirectCodexSessionRefresh(operation: () => Promise) { } } -const DIRECT_CODEX_TURN_UPDATE_STATUSES = new Set([ - 'accepted', - 'running', - 'streaming', - 'finalizing', - 'completed', - 'failed', -]); - -function ensureDirectProcessPrefix(text: string) { - const trimmed = text.trim(); - if (!trimmed) { - return ''; - } - if (trimmed.startsWith('正在')) { - return trimmed; - } - if (/^(?:执行|调用|读取|写入|验证|搜索|整理|修改|生成)/u.test(trimmed)) { - return `正在${trimmed}`; - } - return `正在处理:${trimmed}`; -} - -function directCodexActivityDetail( - activity: string | null | undefined, - status: string | null | undefined, -) { - switch (activity) { - case 'request-accepted': - return '正在等待陶泥儿开始'; - case 'preparing': - return '正在思考中'; - case 'file-read': - return '正在读取文件'; - case 'file-write': - return status === 'finalizing' ? '正在同步项目文件' : '正在写入文件'; - case 'game-verify': - return '正在验证游戏'; - case 'command-exec': - return '正在执行命令'; - case 'controlled-tool': - return '正在调用工具'; - case 'web-search': - return '正在搜索资料'; - case 'context-compaction': - return '正在整理上下文'; - case 'response-finalization': - return '正在整理回复'; - case 'none': - default: - switch (status) { - case 'accepted': - return '正在等待陶泥儿开始'; - case 'finalizing': - return '正在整理结果'; - case 'completed': - case 'failed': - return ''; - default: - return '正在处理任务'; - } - } -} - -const DIRECT_CODEX_SPECIFIC_WORK_DETAIL_PREFIXES = [ - '正在写入文件:', - '正在浏览项目文件', - '正在读取素材库', - '正在读取账户素材', - '正在导入素材', - '正在生成图片', - '正在编辑图片', - '正在准备美术素材', - '正在创建素材资源', - '正在去除图片背景', - '正在试玩游戏', - '正在搜索资料:', - '正在执行命令:', - '正在验证游戏:', -] as const; - -function directCodexProcessDetail({ - accumulatedText, - activity, - status, -}: { - accumulatedText?: string | null; - activity?: string | null; - status: string; -}) { - if (status === 'completed' || status === 'failed') { - return ''; - } - if (status === 'streaming') { - return '正在生成回复'; - } - if (status === 'finalizing') { - return directCodexActivityDetail(activity, status); - } - const text = accumulatedText?.trim(); - if (text) { - return ensureDirectProcessPrefix(text); - } - return directCodexActivityDetail(activity, status); -} - -function isDirectCodexSpecificWorkDetail(text: string) { - return DIRECT_CODEX_SPECIFIC_WORK_DETAIL_PREFIXES.some((prefix) => - text.startsWith(prefix), - ); -} - -function directCodexTransientReplyText({ - accumulatedText, - status, -}: { - accumulatedText?: string | null; - status: string; -}) { - if ( - status !== 'streaming' && - status !== 'finalizing' && - status !== 'completed' - ) { - return null; - } - const text = accumulatedText?.trim(); - return text || null; -} - function directCodexConversationMessageId( turnId: string, role: ChatMessage['role'], @@ -495,70 +370,6 @@ function directCodexConversationMessageId( export const MAX_CHAT_COMPOSER_ATTACHMENTS = 8; -/** - * 回合流排序:`seq`(条目首次出现时钉死)优先,其次 `at`,最后按 id 兜底。 - * 与 Rust 侧同一口径——前端不自己发明顺序。 - */ -function sortTurnStreamItems(items: readonly TurnStreamItem[]) { - return [...items].sort( - (left, right) => - left.seq - right.seq || - left.at - right.at || - left.id.localeCompare(right.id), - ); -} - -/** - * 归并一批回合流条目:同 id 幂等覆盖(`updatedAt` 单调,同刻取更长文本),新 id 追加。 - * 实时增量与回读历史共用这一处,所以界面上的顺序只有一份来源。 - */ -function mergeTurnStreamItems( - existing: readonly TurnStreamItem[], - incoming: readonly TurnStreamItem[], -): TurnStreamItem[] { - if (incoming.length === 0) { - return [...existing]; - } - const byId = new Map(); - for (const item of existing) { - const id = item.id?.trim(); - if (id) { - byId.set(id, item); - } - } - for (const item of incoming) { - const id = item.id?.trim(); - if (!id) { - continue; - } - const previous = byId.get(id); - const normalized: TurnStreamItem = { ...item, id }; - if (!previous) { - byId.set(id, normalized); - continue; - } - // 内容只在更新(或同刻更长)的快照上替换;`seq` 取最早,位置不许回退。 - const textLength = (value: TurnStreamItem) => - value.kind === 'text' ? (value.text?.length ?? 0) : 0; - // writer 更新时间单调;完成快照允许纠正正文,迟到旧快照不能覆盖。 - const takeIncoming = - normalized.updatedAt > previous.updatedAt || - (normalized.updatedAt === previous.updatedAt && - textLength(normalized) > textLength(previous)); - byId.set(id, { - ...(takeIncoming ? normalized : previous), - id, - updatedAt: Math.max(previous.updatedAt, normalized.updatedAt), - seq: Math.min(previous.seq, normalized.seq), - at: - previous.at > 0 && normalized.at > 0 - ? Math.min(previous.at, normalized.at) - : Math.max(previous.at, normalized.at), - } as TurnStreamItem); - } - return sortTurnStreamItems([...byId.values()]); -} - export function isDirectCodexTurnAlreadyRunningError(error: unknown) { const message = error instanceof Error ? error.message : String(error); return message @@ -605,16 +416,17 @@ function isPersistableDirectCodexConversationMessage(message: ChatMessage) { return /^[a-z0-9][a-z0-9-]{5,159}$/iu.test(turnId); } -function claimInitialSupervisorMessageForPage(projectPath: string) { +function claimInitialSupervisorMessageForPage(projectPath: string, scope = '') { let claimedProjectPaths = initialSupervisorMessageClaimsByPage.get(window); if (!claimedProjectPaths) { claimedProjectPaths = new Set(); initialSupervisorMessageClaimsByPage.set(window, claimedProjectPaths); } - if (claimedProjectPaths.has(projectPath)) { + const claimKey = `${projectPath}\u0000${scope}`; + if (claimedProjectPaths.has(claimKey)) { return false; } - claimedProjectPaths.add(projectPath); + claimedProjectPaths.add(claimKey); return true; } @@ -683,6 +495,7 @@ type AppProps = { activeVersionId?: ProjectSupervisorComponentProps['activeVersionId']; supervisorChatOnly?: boolean; initialSupervisorMessage?: string; + initialSupervisorMessageClaimScope?: string; initialCreationType?: HomeCreationType | null; initialAttachments?: LauncherImportedAttachment[]; playRequest?: ProjectSupervisorComponentProps['playRequest']; @@ -733,6 +546,7 @@ export function App({ activeVersionId = null, supervisorChatOnly = false, initialSupervisorMessage = '', + initialSupervisorMessageClaimScope = '', initialCreationType = null, initialAttachments = [], playRequest = null, @@ -796,7 +610,6 @@ export function App({ useEffect(() => { if (supervisorChatOnly) return; const nextProjectPath = localProject?.projectPath ?? null; - ensureDirectTimelineProject(nextProjectPath); const previousProjectPath = localProjectPathRef.current; localProjectPathRef.current = nextProjectPath; // 未绑定项目时无需触发插件宿主;这也避免启动空首页时产生无意义的 Tauri 调用。 @@ -804,7 +617,7 @@ export function App({ void setAgcPluginProjectPath(nextProjectPath) .then(async () => { if (workspaceProjectKind === 'cocos' && nextProjectPath) { - await startAgcPlugin('agc-cocos-editor'); + await startAvailableAgcPlugin('agc-cocos-editor'); } }) .catch((error) => { @@ -870,6 +683,7 @@ export function App({ const initialSupervisorMessageLatchRef = useRef({ projectPath: initialProjectPath, prompt: initialSupervisorMessage.trim(), + claimScope: initialSupervisorMessageClaimScope, creationType: initialCreationType, attachments: toDirectCodexTurnAttachments(initialAttachments), }); @@ -931,125 +745,26 @@ export function App({ chatTurnQueueRef.current = []; }, [localProject?.projectPath]); const [chatAgentBusy, setChatAgentBusy] = useState(false); - const [directCodexProgress, setDirectCodexProgress] = useState(''); - const [directCodexStatus, setDirectCodexStatus] = useState< - GameCreatorDirectTurnUpdateEvent['status'] | null - >(null); - const [directCodexProcessKey, setDirectCodexProcessKey] = useState(''); - const [directCodexProgressUpdatedAt, setDirectCodexProgressUpdatedAt] = - useState(null); - const [directCodexTransientReply, setDirectCodexTransientReply] = - useState(''); - const directCodexTransientReplyRef = useRef(''); - // 直连回合的思考过程(流式):整段替换;回合结束/开始新回合/清空对话时一并清掉。 - const [directCodexTransientReasoning, setDirectCodexTransientReasoning] = - useState(''); - /** 实时回合里"某个工具首次出现时,已生成正文的长度"——用它把正文与工具交替排列。 */ - const [ - directCodexTransientReplyUpdatedAt, - setDirectCodexTransientReplyUpdatedAt, - ] = useState(null); - const activeDirectCodexTurnRef = useRef<{ - projectPath: string; - turnId: string; - lastSequence: number; - receivedDirectUpdate: boolean; - restored?: boolean; - } | null>(null); - const directActiveSnapshotVersionRef = useRef(0); - const directTurnLifecycleRef = useRef<{ - reset: () => void; - loadHistory: (projectPath: string) => Promise; - restore: (projectPath: string) => Promise; - } | null>(null); - const lastDirectCodexActivityRef = useRef(null); - /** - * 重进会话后从 Rust 恢复出来的回合:只有在恢复后的第一个窗口内一直收不到事件, - * 才判定"这一轮其实已经没响应",给出终止出口。收到任何一条本回合事件就撤掉。 - */ - const recoveredDirectCodexTurnRef = useRef<{ - projectPath: string; - turnId: string; - } | null>(null); - const recoveredDirectCodexTurnTimerRef = useRef(null); const directCodexConversationTurnSequenceRef = useRef(0); - // 工具调用卡片:按 **id** 归并(实时增量 + 回读历史共用一份),同一 id 只渲染一次。 - // 用 ref 做写入基准,避免同一批事件里多条增量互相覆盖。 - const [directToolCalls, setDirectToolCalls] = useState< - GameCreatorDirectToolCall[] - >([]); - const directToolCallsRef = useRef([]); - const directTimelineProjectPathRef = useRef(null); - - function ensureDirectTimelineProject(project: string | null) { - if (directTimelineProjectPathRef.current === project) return; - directTimelineProjectPathRef.current = project; - directToolCallsRef.current = []; - directTurnStreamRef.current = []; - setDirectToolCalls([]); - setDirectTurnStream([]); - } /** - * 归并一批工具调用:同 id 覆盖已有条目(`completed` 覆盖 `running`), - * 新 id 追加(保持首次出现顺序)。实时增量与回读历史都走这里,所以同一 id 不会重复渲染。 + * DirectProject 聊天真相源:历史切片与运行态事件归并成同一份条目。 + * + * 运行态只来自 Thread Manager(`subscribe` 的 bootstrap + `notify` 唤醒的 `consume`); + * 前端不再订阅 DirectRuntime 的回合进度事件,也不再各存一份回合流 / 工具卡片。 */ - function applyDirectToolCalls( - incoming: readonly GameCreatorDirectToolCall[], - ) { - if (incoming.length === 0) { - return; - } - const merged = [...directToolCallsRef.current]; - for (const call of incoming) { - const id = call.id?.trim(); - if (!id) { - continue; - } - const existingIndex = merged.findIndex( - (existing) => existing.id === id && existing.turnId === call.turnId, - ); - const normalized: GameCreatorDirectToolCall = { - ...call, - id, - startedAt: normalizeDirectTimestamp(call.startedAt), - updatedAt: normalizeDirectTimestamp(call.updatedAt), - detail: call.detail ?? { changes: [] }, - }; - // 起点时间取更早的那个:`completed` 事件不一定带 startedAt。 - const existing = existingIndex >= 0 ? merged[existingIndex] : undefined; - if (existing) { - if ( - existing.updatedAt > normalized.updatedAt || - (existing.status !== 'running' && normalized.status === 'running') - ) - continue; - normalized.detail = { - ...normalized.detail, - command: normalized.detail.command ?? existing.detail.command, - output: normalized.detail.output ?? existing.detail.output, - }; - } - if ( - existing && - existing.startedAt > 0 && - (normalized.startedAt === 0 || - existing.startedAt < normalized.startedAt) - ) { - normalized.startedAt = existing.startedAt; - } - if (existingIndex >= 0) { - merged[existingIndex] = normalized; - } else { - merged.push(normalized); - } - } - merged.sort( - (left, right) => - left.startedAt - right.startedAt || left.id.localeCompare(right.id), - ); - directToolCallsRef.current = merged; - setDirectToolCalls(merged); - } + const [directThreadChat, setDirectThreadChat] = + useState(() => emptyDirectThreadChatState()); + /** 最新回合是否在跑:历史里留下的半截回合一律按已结束渲染。 */ + const directTurnRunning = directThreadChat.turnRunning; + /** 界面忙碌判定:本地正在跑这次 invoke,或订阅告诉还有一条回合没结束。 */ + const supervisorChatBusy = + chatAgentBusy || (directCodexProductRuntime && directTurnRunning); + const chatAgentBusyRef = useRef(chatAgentBusy); + const directTurnRunningRef = useRef(directTurnRunning); + chatAgentBusyRef.current = chatAgentBusy; + directTurnRunningRef.current = directTurnRunning; + const previousDirectTurnRunningRef = useRef(directTurnRunning); + const directTurnCompletionPendingRef = useRef(false); const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState< string | null >(null); @@ -1069,193 +784,11 @@ export function App({ return `${Date.now().toString(36)}-${directCodexConversationTurnSequenceRef.current.toString(36)}`; } - function resetDirectCodexTurn() { - directActiveSnapshotVersionRef.current += 1; - clearRecoveredDirectCodexTurnWatch(); - activeDirectCodexTurnRef.current = null; - lastDirectCodexActivityRef.current = null; - setDirectCodexProgress(''); - setDirectCodexStatus(null); - setDirectCodexProcessKey(''); - setDirectCodexProgressUpdatedAt(null); - setDirectCodexTransientReply(''); - setDirectCodexTransientReasoning(''); - directCodexTransientReplyRef.current = ''; - setDirectCodexTransientReplyUpdatedAt(null); + /** 换项目 / 清空对话:聊天条目回到初始态(运行态与已加载历史一起清掉)。 */ + function resetDirectThreadChat() { + setDirectThreadChat(emptyDirectThreadChatState()); } - /** 撤掉"恢复出来的回合没响应"的看门狗;回合正常结束、被终止、或收到事件时都要撤。 */ - function clearRecoveredDirectCodexTurnWatch() { - if (recoveredDirectCodexTurnTimerRef.current !== null) { - window.clearTimeout(recoveredDirectCodexTurnTimerRef.current); - recoveredDirectCodexTurnTimerRef.current = null; - } - recoveredDirectCodexTurnRef.current = null; - } - - /** - * 给恢复出来的回合挂一个看门狗:一个窗口内没有任何本回合事件,就说明 app-server 侧 - * 其实已经没了、Rust 守卫是残留。这时把可读动作放到过程卡与输入盒提示上,用户点 - * 「终止」会走 `cancel_direct_codex_turn` 的兜底释放(见 handleCancelDirectCodexTurn)。 - * 收到任何一条本回合事件就由调用方撤掉它,绝不会覆盖真实的进度文案。 - */ - function watchRecoveredDirectCodexTurn(projectPath: string, turnId: string) { - clearRecoveredDirectCodexTurnWatch(); - recoveredDirectCodexTurnRef.current = { projectPath, turnId }; - recoveredDirectCodexTurnTimerRef.current = window.setTimeout(() => { - recoveredDirectCodexTurnTimerRef.current = null; - const watch = recoveredDirectCodexTurnRef.current; - const activeTurn = activeDirectCodexTurnRef.current; - if ( - !watch || - watch.projectPath !== projectPath || - watch.turnId !== turnId || - activeTurn?.projectPath !== projectPath || - activeTurn.turnId !== turnId || - activeTurn.receivedDirectUpdate - ) { - return; - } - setDirectCodexStatus('running'); - setDirectCodexProgress(DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE); - setDirectCodexProgressUpdatedAt(Date.now()); - setChatComposerNotice(DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE); - }, DIRECT_CODEX_RECOVERED_TURN_STALLED_MS); - } - - /** - * 重进会话时接管仍在运行的 Direct 回合。 - * - * 背景:活跃回合的守卫(`DirectTaonierActiveInvocationGuard`)是 Rust 进程内的,重开 - * 项目时前端 `activeDirectCodexTurnRef` 是空的——界面既不订阅这一轮的事件,也不显示 - * 过程卡,用户再发消息只会被守卫拒绝。这里把后端登记的回合读回来重新接管。 - * - * 只读探测,不改后端回合本身;探测失败保留当前已知状态,不视为没有活动回合。 - */ - async function restoreRunningDirectCodexTurn( - projectPath: string, - reconcile = false, - ) { - if (!directCodexProductRuntime || !projectPath) { - return; - } - const invoke = resolveTauriInvoke(); - if (!invoke) { - return; - } - const owner = activeDirectCodexTurnRef.current; - const sequence = owner?.lastSequence; - const scopeVersion = projectScopeVersionRef.current; - if (!reconcile && owner?.projectPath === projectPath) { - return; - } - const readVersion = ++directActiveSnapshotVersionRef.current; - let turns: GameCreatorDirectActiveTurn[]; - try { - turns = await invoke( - 'list_game_creator_direct_active_turns', - ); - } catch { - // 读取失败不等于没有活动回合。 - return; - } - if (!Array.isArray(turns)) return; - if ( - localProjectPathRef.current !== projectPath || - planningV2ActiveRef.current || - designAgentLaneRef.current || - projectScopeVersionRef.current !== scopeVersion || - directActiveSnapshotVersionRef.current !== readVersion || - activeDirectCodexTurnRef.current !== owner || - owner?.lastSequence !== sequence - ) { - return; - } - const activeView = turns.find( - (turn) => - projectPathsMatchForInvalidation(turn.projectPath, projectPath) && - isDirectTurnInProgress(turn.status), - ); - if (!activeView || !isDirectTurnInProgress(activeView.status)) { - // 本地刚发送但尚未进入 Rust 的请求不能被空快照取消。 - if (owner && !owner.restored && owner.lastSequence < 0) return; - resetDirectCodexTurn(); - setChatAgentBusy(false); - if (owner && reconcile) { - void loadProjectConversation(projectPath, false, 'replace'); - } - return; - } - const matchingOwner = owner?.turnId === activeView.turnId ? owner : null; - if (owner && !matchingOwner) { - if (!owner.restored && owner.lastSequence < 0) return; - resetDirectCodexTurn(); - } - if (!matchingOwner) { - activeDirectCodexTurnRef.current = { - projectPath, - turnId: activeView.turnId, - lastSequence: -1, - receivedDirectUpdate: false, - restored: true, - }; - setDirectCodexProcessKey(`${projectPath}\u0000${activeView.turnId}`); - setProjectSupervisorRuntimeError(''); - watchRecoveredDirectCodexTurn(projectPath, activeView.turnId); - } - setChatAgentBusy(true); - // 活动快照不携带正文;已有实时进度不能被同序号的通用描述覆盖。 - if ( - !matchingOwner?.receivedDirectUpdate || - activeView.sequence > matchingOwner.lastSequence - ) { - setDirectCodexStatus(activeView.status); - setDirectCodexProgress( - directCodexActivityDetail(activeView.activity, activeView.status), - ); - setDirectCodexProgressUpdatedAt(activeView.updatedAt); - } - } - - directTurnLifecycleRef.current = { - reset: resetDirectCodexTurn, - loadHistory: (projectPath) => - loadProjectConversation(projectPath, false, 'replace'), - restore: (projectPath) => restoreRunningDirectCodexTurn(projectPath, true), - }; - - // 回合流(文本段 + 工具按**出现顺序**交替):实时增量与回读历史共用一份状态, - // 渲染顺序只由条目的 `seq` 决定,界面不再按文本长度 / 标点 / 时间窗猜切点。 - const [directTurnStream, setDirectTurnStream] = useState( - [], - ); - const directTurnStreamRef = useRef([]); - - /** 归并一批回合流条目(实时事件里的 `streamItems`)。 */ - function applyTurnStreamItems(incoming: readonly TurnStreamItem[]) { - if (incoming.length === 0) { - return; - } - const merged = mergeTurnStreamItems(directTurnStreamRef.current, incoming); - directTurnStreamRef.current = merged; - setDirectTurnStream(merged); - } - - function clearDirectCodexTransientReply(projectPath: string, turnId: string) { - const activeTurn = activeDirectCodexTurnRef.current; - if ( - activeTurn?.projectPath !== projectPath || - activeTurn.turnId !== turnId - ) { - return false; - } - activeDirectCodexTurnRef.current = null; - setDirectCodexTransientReply(''); - setDirectCodexTransientReasoning(''); - directCodexTransientReplyRef.current = ''; - setDirectCodexTransientReplyUpdatedAt(null); - return true; - } const [projectSupervisorRuntime, setProjectSupervisorRuntime] = useState(null); const [projectSupervisorResponseStream, setProjectSupervisorResponseStream] = @@ -1955,6 +1488,9 @@ export function App({ const [directHistoryHasMore, setDirectHistoryHasMore] = useState(false); const directHistoryOldestItemIdRef = useRef(null); const directHistoryLoadingRef = useRef(false); + const directHistoryAnchorGateRef = useRef( + null, + ); const [pendingCommand, setPendingCommand] = useState( null, ); @@ -2285,117 +1821,7 @@ export function App({ }, [initialProjectPath, projectSupervisorOnly]); useEffect(() => { - if (!directCodexProductRuntime) { - return; - } - if (!canSubscribeTauriEvents()) { - return; - } - let cleanup: (() => void) | null = null; - let disposed = false; - void subscribeTauriEvent( - 'game-creator-direct-turn-update', - (event) => { - const payload = event.payload; - const activeTurn = activeDirectCodexTurnRef.current; - if ( - !activeTurn || - payload.projectPath !== localProjectPathRef.current || - payload.projectPath !== activeTurn.projectPath || - payload.turnId !== activeTurn.turnId || - !Number.isSafeInteger(payload.sequence) || - payload.sequence < 0 || - !DIRECT_CODEX_TURN_UPDATE_STATUSES.has(payload.status) || - payload.sequence <= activeTurn.lastSequence - ) { - return; - } - activeTurn.lastSequence = payload.sequence; - activeTurn.receivedDirectUpdate = true; - // 恢复出来的回合只要回来一条真实事件,就不再是"没响应",撤掉看门狗与那句提示。 - if ( - recoveredDirectCodexTurnRef.current?.projectPath === - payload.projectPath && - recoveredDirectCodexTurnRef.current.turnId === payload.turnId - ) { - clearRecoveredDirectCodexTurnWatch(); - setChatComposerNotice((current) => - current === DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE - ? '' - : current, - ); - } - ensureDirectTimelineProject(payload.projectPath); - // 工具调用增量:字段可选,老事件(undefined)走原路径,行为不变。 - if (payload.toolCalls?.length) { - applyDirectToolCalls( - payload.toolCalls.map((call) => ({ - ...call, - turnId: payload.turnId, - })), - ); - } - if (typeof payload.reasoningText === 'string') { - setDirectCodexTransientReasoning(payload.reasoningText); - } - // 回合流的顺序真相:字段可选,老事件(undefined)走原路径。 - if (payload.streamItems?.length) { - applyTurnStreamItems(payload.streamItems); - } - const updatedAt = - Number.isFinite(payload.updatedAt) && payload.updatedAt > 0 - ? payload.updatedAt - : Date.now(); - const processDetail = directCodexProcessDetail(payload); - if (payload.status === 'failed' || payload.status === 'completed') { - directTurnLifecycleRef.current?.reset(); - setChatAgentBusy(false); - void directTurnLifecycleRef.current?.loadHistory(payload.projectPath); - return; - } - setDirectCodexStatus(payload.status); - const genericActivity = payload.activity ?? null; - const previousActivity = lastDirectCodexActivityRef.current; - setDirectCodexProgress((current) => { - const heartbeatWouldDowngrade = - genericActivity !== null && - !payload.accumulatedText?.trim() && - payload.status === 'running' && - previousActivity === genericActivity && - current !== processDetail && - isDirectCodexSpecificWorkDetail(current); - return heartbeatWouldDowngrade ? current : processDetail; - }); - if (genericActivity !== null) { - lastDirectCodexActivityRef.current = genericActivity; - } - setDirectCodexProgressUpdatedAt(updatedAt); - const transientReply = directCodexTransientReplyText(payload); - if (transientReply !== null) { - setDirectCodexTransientReply(transientReply); - directCodexTransientReplyRef.current = transientReply; - setDirectCodexTransientReplyUpdatedAt(updatedAt); - } - }, - ) - .then((unlisten) => { - if (disposed) { - unlisten(); - return; - } - cleanup = unlisten; - }) - .catch(() => { - // The existing safe progress event remains the activity fallback. - }); - return () => { - disposed = true; - cleanup?.(); - }; - }, [directCodexProductRuntime]); - - useEffect(() => { - if (!directCodexProductRuntime || !chatAgentBusy) { + if (!directCodexProductRuntime || !supervisorChatBusy) { return; } const timer = window.setInterval(() => { @@ -2405,30 +1831,60 @@ export function App({ }); }, DIRECT_CODEX_SESSION_KEEPALIVE_MS); return () => window.clearInterval(timer); - }, [chatAgentBusy, directCodexProductRuntime]); + }, [supervisorChatBusy, directCodexProductRuntime]); + /** + * DirectProject 聊天真相源。 + * + * `subscribe` 的 bootstrap 就是此刻要处理的事件(游标已在队尾),此后只由 + * `game-creator-direct-thread-notify` 唤醒 `consume`;前端不再订阅 DirectRuntime 的回合 + * 进度事件,也不轮询"有没有回合在跑"。 + */ useEffect(() => { + if (!directCodexProductRuntime) { + return; + } const projectPath = localProject?.projectPath ?? null; const directInvoke = resolveTauriInvoke(); - if (!directCodexProductRuntime || !projectPath || !directInvoke) return; + // 闸门先建(或复用首屏读取已经开好的那道):同一轮渲染里首屏会等订阅回执里的锚点。 + const anchorGate = reuseOrOpenDirectHistoryAnchorGate( + directHistoryAnchorGateRef.current, + projectPath ?? '', + ); + directHistoryAnchorGateRef.current = anchorGate; + if (!projectPath || !directInvoke || !canSubscribeTauriEvents()) { + // 订阅不可用:首屏退化成"取文件尾",不阻塞加载。 + anchorGate.settle(null); + return; + } let disposed = false; let cleanup: (() => void) | null = null; let subscriptionId: string | null = null; let consuming = false; let consumeAgain = false; + // 通知先于 subscribe 回执到达时,前端还不知道自己的 subscriptionId,没法立刻 consume。 + // 记一笔欠账,拿到回执后立刻补一次,事件就不会卡在队伍里等下一次通知。 + let notifyBeforeSubscription = false; - // Provider 原始事件只用于通知;运行状态始终取 client 回合快照和 Direct 事件。 - const refreshActive = () => { - if (!disposed) void directTurnLifecycleRef.current?.restore(projectPath); - }; const bootstrap = async () => { const result = await directInvoke( 'subscribe_direct_project_thread', { projectPath }, ); - if (disposed) return; + if (disposed) { + anchorGate.settle(null); + return; + } subscriptionId = result.subscriptionId; - refreshActive(); + // 首屏边界:回执里这一刻的最后一条已完成条目(含该条)。 + anchorGate.settle(result.lastCompletedItemId ?? null); + setDirectThreadChat((state) => + resolveDirectThreadBootstrap(state, result), + ); + if (notifyBeforeSubscription) { + notifyBeforeSubscription = false; + void consume(); + } }; const consume = async () => { if (!subscriptionId || disposed) return; @@ -2445,30 +1901,18 @@ export function App({ { subscriptionId }, ); if (disposed) return; - if ( - result.events.some( - (event) => - event.type === 'turn.started' || - event.type === 'turn.completed', - ) - ) { - refreshActive(); - } - if ( - result.events.some((event) => event.type === 'turn.completed') && - !activeDirectCodexTurnRef.current?.receivedDirectUpdate - ) { - // 重进时若未接到 Direct 结束事件,原始 item 的落盘通知仍可补齐最终回复。 - void directTurnLifecycleRef.current?.loadHistory(projectPath); - } + setDirectThreadChat((state) => + applyDirectThreadConsumeResult(state, result), + ); } while (consumeAgain && !disposed); } catch (error) { + // 订阅被别人顶掉时重订一次;通知允许丢,下一次通知会再唤醒。 if (!disposed && String(error).includes('SUBSCRIPTION_EXPIRED')) { subscriptionId = null; try { await bootstrap(); } catch { - /* 活动快照轮询仍然有效。 */ + /* 让下一次通知再试。 */ } } } finally { @@ -2480,7 +1924,11 @@ export function App({ const unlisten = await subscribeTauriEvent<{ subscriptionId: string }>( 'game-creator-direct-thread-notify', (event) => { - if (event.payload.subscriptionId === subscriptionId) void consume(); + if (event.payload.subscriptionId === subscriptionId) { + void consume(); + return; + } + if (!subscriptionId) notifyBeforeSubscription = true; }, ); if (disposed) { @@ -2489,21 +1937,19 @@ export function App({ } cleanup = unlisten; await bootstrap(); - await consume(); } catch { - // 历史仍可使用;订阅失败不伪造忙碌态。 + // 历史仍可使用;订阅失败不伪造忙碌态,锚点缺失时首屏按文件尾取尾屏。 + anchorGate.settle(null); } }; - refreshActive(); - const timer = window.setInterval( - refreshActive, - DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS, - ); + // 换项目就是换一份聊天:先回到初始态,再订阅新线程。 + setDirectThreadChat(emptyDirectThreadChatState()); void setup(); return () => { disposed = true; cleanup?.(); - window.clearInterval(timer); + // 首屏可能还在等这道闸门(例如切项目打断了订阅),不能让它永远等下去。 + anchorGate.settle(null); }; }, [directCodexProductRuntime, localProject?.projectPath]); @@ -2523,20 +1969,7 @@ export function App({ return; } if (directCodexProductRuntime) { - const activeTurn = activeDirectCodexTurnRef.current; - if ( - !activeTurn || - activeTurn.projectPath !== event.payload.projectPath || - activeTurn.receivedDirectUpdate - ) { - return; - } - const progressDetail = ensureDirectProcessPrefix( - event.payload.message, - ); - setDirectCodexStatus('running'); - setDirectCodexProgress(progressDetail); - setDirectCodexProgressUpdatedAt(Date.now()); + // DirectProject 的进度已经从线程事件流进聊天条目,这条通用进度事件不再参与。 return; } setMessages((current) => [ @@ -3076,10 +2509,7 @@ export function App({ projectSupervisorResponseStream?.sequence, projectSupervisorRuntime?.updatedAt, projectSupervisorRuntimeError, - directCodexProgress, - directCodexProgressUpdatedAt, - directCodexTransientReply, - directCodexTransientReplyUpdatedAt, + directThreadChat, directCodexProductRuntime, planningV2Active, projectSupervisorOnly, @@ -4101,23 +3531,62 @@ export function App({ : await readProjectSupervisorActiveSession(invoke, nextProjectPath); let runtimeError = ''; let loadedDirectHistoryHasMore = false; + let loadedDirectHistoryFirstItemId: string | null = null; + // 首屏连拉到的条目先暂存,待下方 staleness 守卫通过后再并入聊天 reducer: + // 迟到的切片属于已经切走的项目,不能在守卫之前就写进全局聊天状态。 + let loadedDirectHistoryItems: DirectThreadItem[] = []; + // 首屏切片的新端边界只认订阅回执里的 `lastCompletedItemId`(含该条):比它更新的条目 + // 只能来自运行态事件。打开项目时订阅 effect 还没跑,这里就先把闸门开好,订阅侧会复用 + // 同一道闸门并 settle 它。同一道闸门只锚定一次:同一订阅下再读一次(例如重开同一个项目) + // 没有新回执可等,退回"按当前文件尾取尾屏",与 `/history` 手动重读一致。 + let directHistoryThroughItemId: string | null = null; + if (directCodexProductRuntime && mode !== 'replace') { + const anchorGate = directHistoryAnchorGateToWaitFor( + directHistoryAnchorGateRef.current, + nextProjectPath, + ); + if (anchorGate) { + directHistoryAnchorGateRef.current = anchorGate; + directHistoryThroughItemId = await anchorGate.anchor; + anchorGate.consumed = true; + } + } const projectConversation = directCodexProductRuntime ? (() => { - return invoke( - 'read_direct_project_history_slice', - { - projectPath: nextProjectPath, - limit: CONVERSATION_INITIAL_VISIBLE_COUNT, - }, - ).then((slice) => { - loadedDirectHistoryHasMore = slice.hasMore; + // 首屏铺的就是这份视图的基线,"新回合"比较没有意义:判据退化为"这一页得有能渲染 + // 的条目",所以基线取空,免得拿上一份对话的残留状态去比。首屏失败仍按原有语义 + // 抛出,交给调用方的 catch 处理。 + return readDirectHistoryPages({ + existingEntries: [], + beforeItemId: null, + readSlice: (beforeItemId) => + invoke( + 'read_direct_project_history_slice', + beforeItemId + ? { + projectPath: nextProjectPath, + beforeItemId, + limit: CONVERSATION_INITIAL_VISIBLE_COUNT, + } + : { + projectPath: nextProjectPath, + ...(directHistoryThroughItemId + ? { throughItemId: directHistoryThroughItemId } + : {}), + limit: CONVERSATION_INITIAL_VISIBLE_COUNT, + }, + ), + }).then((pages) => { + if (pages.error) { + throw pages.error; + } + loadedDirectHistoryHasMore = pages.hasMore; + loadedDirectHistoryFirstItemId = pages.firstItemId; + loadedDirectHistoryItems = pages.items; return { path: nextProjectPath, agentId: null, - messages: directThreadHistoryItemsToMessages( - slice.items, - slice.itemTimestamps, - ), + messages: [], } satisfies LocalConversationResult; }); })() @@ -4126,35 +3595,7 @@ export function App({ agentId: null, }); const resolvedProjectConversation = await projectConversation; - // 工具调用卡片走独立历史文件(`tool-calls.jsonl`)。必须在读完项目对话之后、 - // 任何提前 return 之前回读:direct-codex 下后面那条 design-agent 分支会直接返回, - // 放在它后面等于永远不执行。文件缺失 / 读取失败都只是没有卡片,不能因此把整个 - // 项目打开流程判失败。卡片按项目维度整表替换,同一 id 只渲染一次。 - if (directCodexProductRuntime) { - const persistedToolCalls = await invoke( - 'read_direct_tool_calls', - { projectPath: nextProjectPath }, - ).catch(() => []); - // 回合流(顺序真相)走独立历史文件(`turn-stream.jsonl`)。与工具调用同一处:必须在 - // 读完项目对话之后、任何提前 return 之前回读。缺命令(老客户端)/ 缺文件 / 读取失败 - // 都只是"这个回合没有流",界面回退到原来的渲染,不能因此把整个打开流程判失败。 - const persistedTurnStream = await invoke( - 'read_direct_turn_stream', - { projectPath: nextProjectPath }, - ).catch(() => []); - if ( - projectSupervisorHistoryLoadVersionRef.current !== loadVersion || - localProjectPathRef.current !== nextProjectPath - ) - return; - ensureDirectTimelineProject(nextProjectPath); - // 回读可能与实时事件交错:按同一身份合并,不能用旧磁盘快照覆盖实时状态。 - applyDirectToolCalls(persistedToolCalls); - applyTurnStreamItems(persistedTurnStream); - // 重进会话时 Rust 侧可能仍登记着上一条 Direct 回合。不接管的话界面既不显示 - // 过程卡也不给终止入口,用户再发消息只会被守卫拒绝("已有另一条回合正在运行")。 - await restoreRunningDirectCodexTurn(nextProjectPath); - } + // 首屏历史直接进聊天 reducer;`messages` 在 DirectProject 下只剩运行期本地消息。 let supervisorConversation: LocalConversationResult | null = null; let runtime: AgentRuntimeState | null = null; let runtimeResponseStream: AgentRuntimeResponseStream | null = null; @@ -4214,9 +3655,12 @@ export function App({ setProjectSupervisorRuntimeError(runtimeError || resumeError); if (directCodexProductRuntime) { setDirectHistoryHasMore(loadedDirectHistoryHasMore); - directHistoryOldestItemIdRef.current = - conversationMessages.find((message) => message.messageId) - ?.messageId ?? null; + directHistoryOldestItemIdRef.current = loadedDirectHistoryFirstItemId; + if (loadedDirectHistoryItems.length > 0) { + const items = loadedDirectHistoryItems; + // 首屏历史进聊天 reducer;`messages` 在 DirectProject 下只剩运行期本地消息。 + setDirectThreadChat((state) => mergeDirectHistoryItems(state, items)); + } } setMessages((current) => { // replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。 @@ -4312,7 +3756,7 @@ export function App({ const projectScopeVersion = projectScopeVersionRef.current + 1; projectScopeVersionRef.current = projectScopeVersion; resetProjectSupervisorState(); - resetDirectCodexTurn(); + resetDirectThreadChat(); updateClientPreview(null); setPreviewStatus('未启动'); setWorkspaceStatus('正在打开'); @@ -7156,21 +6600,7 @@ export function App({ index === existingIndex ? nextMessage : message, ); }; - activeDirectCodexTurnRef.current = { - projectPath: directProjectPath, - turnId: clientTurnId, - lastSequence: -1, - receivedDirectUpdate: false, - }; setChatAgentBusy(true); - setDirectCodexStatus('accepted'); - setDirectCodexProcessKey(`${directProjectPath}\u0000${clientTurnId}`); - setDirectCodexProgress('正在等待陶泥儿开始'); - setDirectCodexTransientReply(''); - setDirectCodexTransientReasoning(''); - setDirectCodexProgressUpdatedAt(Date.now()); - directCodexTransientReplyRef.current = ''; - setDirectCodexTransientReplyUpdatedAt(null); setProjectSupervisorRuntimeError(''); try { const directTurnInput: { @@ -7193,15 +6623,9 @@ export function App({ directTurnInput.attachments = attachments; } directTurnInput.userItem = effectiveUserItem; - const reply = await withDirectCodexSessionRefresh(() => { - // 每次调用都会新建 Rust 事件流;续期重试需重新接收同一回合的进度。 - activeDirectCodexTurnRef.current = { - projectPath: directProjectPath, - turnId: clientTurnId, - lastSequence: -1, - receivedDirectUpdate: false, - }; - setDirectCodexStatus('accepted'); + // 回复正文按条目身份从线程事件 / 历史切片进聊天,本地不再补一条, + // 因此这里只等回合跑完,不接返回值。 + await withDirectCodexSessionRefresh(() => { return directInvoke( 'chat_with_game_creator_direct_codex', directTurnInput, @@ -7213,10 +6637,6 @@ export function App({ projectSupervisorHistoryLoadVersionRef.current += 1; } if (localProjectPathRef.current === directProjectPath) { - clearDirectCodexTransientReply(directProjectPath, clientTurnId); - setMessages((current) => - appendDirectAssistantMessage(current, reply), - ); await refreshDirectProjectManifest(directProjectPath); } } catch (error) { @@ -7225,16 +6645,9 @@ export function App({ isDirectCodexAnotherTurnRunningError(error) ) { if (localProjectPathRef.current === directProjectPath) { - clearDirectCodexTransientReply(directProjectPath, clientTurnId); setProjectSupervisorRuntimeError( '陶泥儿仍在处理上一条消息,可在输入盒点「终止」结束它,或等它结束后再发送。', ); - // 兜底:出现这条拒绝说明本项目确实有回合在跑,而本组件此前没接管它 - // (重进会话的漏网情况)。放到当前任务之后再接管,避开本回合 finally - // 里 setChatAgentBusy(false) 的复位竞态。 - window.setTimeout(() => { - void restoreRunningDirectCodexTurn(directProjectPath); - }, 0); } return; } @@ -7243,9 +6656,6 @@ export function App({ } if (isDirectCodexTurnInterruptedError(error)) { // 用户主动终止:不是失败,不写运行错误与诊断,只把回合标记成已终止。 - clearDirectCodexTransientReply(directProjectPath, clientTurnId); - setDirectCodexStatus('failed'); - setDirectCodexProgress(''); setProjectSupervisorRuntimeError(''); setChatComposerNotice('已终止本次回合'); setMessages((current) => @@ -7280,9 +6690,6 @@ export function App({ ); projectSupervisorHistoryLoadVersionRef.current += 1; if (localProjectPathRef.current === directProjectPath) { - clearDirectCodexTransientReply(directProjectPath, clientTurnId); - setDirectCodexStatus('failed'); - setDirectCodexProgress('正在记录失败原因'); setProjectSupervisorRuntimeError(visibleMessage); setMessages((current) => appendDirectAssistantMessage(current, visibleMessage, true), @@ -7294,17 +6701,11 @@ export function App({ await refreshManifest(directProjectPath); } } finally { - const activeTurn = activeDirectCodexTurnRef.current; - if ( - localProjectPathRef.current === directProjectPath && - (!activeTurn || - (activeTurn.projectPath === directProjectPath && - activeTurn.turnId === clientTurnId)) - ) { + if (localProjectPathRef.current === directProjectPath) { setChatAgentBusy(false); setDirectCodexTurnCancelling(false); - resetDirectCodexTurn(); - // 只有当前回合的收尾才能释放发送队列,不能覆盖后来启动的回合。 + chatAgentBusyRef.current = false; + // 本地 invoke 正常收尾仍可直接推进队列;恢复场景则由 turn.completed effect 推进。 dispatchNextQueuedChatTurn(); } } @@ -7501,7 +6902,7 @@ export function App({ } if ( chatAgentBusy || - !claimInitialSupervisorMessageForPage(latch.projectPath) + !claimInitialSupervisorMessageForPage(latch.projectPath, latch.claimScope) ) { return; } @@ -12482,36 +11883,41 @@ export function App({ if (invoke && projectPath && !directHistoryLoadingRef.current) { directHistoryLoadingRef.current = true; try { - const slice = await invoke( - 'read_direct_project_history_slice', - { - projectPath, - beforeItemId: directHistoryOldestItemIdRef.current, - limit: CONVERSATION_VISIBLE_STEP, - }, - ); + // 一次点击连拉,直到出现新回合或取不动为止:一屏全是工具卡片 / 思考文本时, + // 单发一页会让用户点了"显示更早"却看不到任何变化。 + const pages = await readDirectHistoryPages({ + existingEntries: selectDirectChatEntries(directThreadChat), + beforeItemId: directHistoryOldestItemIdRef.current, + readSlice: (beforeItemId) => + invoke( + 'read_direct_project_history_slice', + beforeItemId + ? { + projectPath, + beforeItemId, + limit: CONVERSATION_VISIBLE_STEP, + } + : { projectPath, limit: CONVERSATION_VISIBLE_STEP }, + ), + }); if (localProjectPathRef.current !== projectPath) { return; } - const older = directThreadHistoryItemsToMessages( - slice.items, - slice.itemTimestamps, - ).map((message) => ({ - role: - message.role === 'user' - ? ('user' as const) - : ('assistant' as const), - text: message.content, - runtimeOwned: true, - messageId: message.messageId, - updatedAt: message.updatedAt, - })); - setMessages((current) => [...older, ...current]); - setConversationVisibleCount((current) => current + older.length); - setDirectHistoryHasMore(slice.hasMore); + // 连拉到的页一次性进聊天 reducer:与运行态同形、同身份,重复读取只补不重。 + setDirectThreadChat((state) => + mergeDirectHistoryItems(state, pages.items), + ); + // 首页或中途读取失败时保留旧值:失败页没有可靠的 hasMore,不能因为 + // 一次瞬时 IO 抖动把「显示更早」按钮永久收掉。 + if (!pages.error) { + setDirectHistoryHasMore(pages.hasMore); + } directHistoryOldestItemIdRef.current = - older.find((message) => message.messageId)?.messageId ?? - directHistoryOldestItemIdRef.current; + pages.firstItemId ?? directHistoryOldestItemIdRef.current; + if (pages.error) { + // 已经取到的页照常进视图,失败只走下面同一个报错出口。 + throw pages.error; + } } catch (error) { setWorkspaceStatus( `读取更早的对话历史失败:${error instanceof Error ? error.message : String(error)}`, @@ -12712,8 +12118,12 @@ export function App({ } } - /** 队首出队并立即发出:只在当前回合确实结束(`finally`)后调用。 */ + /** 队首出队并立即发出:只在 DirectProject 收到 `turn.completed` 后调用。 */ function dispatchNextQueuedChatTurn() { + if (chatAgentBusyRef.current || directTurnRunningRef.current) { + return; + } + directTurnCompletionPendingRef.current = false; const { next, rest } = dequeueChatTurn(chatTurnQueueRef.current); if (!next) { return; @@ -12731,16 +12141,42 @@ export function App({ }); } + /** + * 回合事件可能先于本地 invoke 的 finally 到达,因此先记 pending,等本地忙碌态复位后再出队。 + */ + useEffect(() => { + if (!directCodexProductRuntime) { + directTurnCompletionPendingRef.current = false; + previousDirectTurnRunningRef.current = directTurnRunning; + return; + } + const wasRunning = previousDirectTurnRunningRef.current; + previousDirectTurnRunningRef.current = directTurnRunning; + if (wasRunning && !directTurnRunning) { + directTurnCompletionPendingRef.current = true; + } + if ( + directTurnCompletionPendingRef.current && + !chatAgentBusy && + !directTurnRunning + ) { + directTurnCompletionPendingRef.current = false; + dispatchNextQueuedChatTurn(); + } + // 队列出队函数读取本轮 render 的项目输入;这里只由三个生命周期信号触发。 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chatAgentBusy, directCodexProductRuntime, directTurnRunning]); + /** 终止当前 direct-codex 回合:只取消这一轮,UI 由回合的 finally 复位。 */ async function handleCancelDirectCodexTurn() { if (directCodexTurnCancelling) { return; } const invoke = resolveTauriInvoke(); - const activeTurn = activeDirectCodexTurnRef.current; - const directProjectPath = - activeTurn?.projectPath ?? resolveChatProjectPath(localProject); - if (!invoke || !directProjectPath || !activeTurn) { + const directProjectPath = resolveChatProjectPath(localProject); + // 繁忙判据与「终止」按钮的可见条件一致:本地 invoke 正在跑,或订阅说还有一条回合没结束。 + // 只认 `turn.started` 推导出来的 `directTurnRunning` 会漏掉"刚提交、事件还没到"的窗口。 + if (!invoke || !directProjectPath || !supervisorChatBusy) { setProjectSupervisorRuntimeError('当前没有正在运行的回合,无法终止。'); return; } @@ -12749,17 +12185,24 @@ export function App({ try { const result = await invoke( 'cancel_direct_codex_turn', - { - projectPath: directProjectPath, - clientTurnId: activeTurn.turnId, - }, + { projectPath: directProjectPath }, ); const message = result?.message?.trim(); if (result?.outcome === 'released') { - // 这一轮已经没有人替它收尾(执行进程已退出 / 从没进执行器),Rust 侧已强制释放 - // 守卫。没有会 return 的回合 promise 来复位界面,这里必须自己复位,否则过程卡 - // 与"任务执行中"会一直挂着,用户仍然发不出消息。 - resetDirectCodexTurn(); + // 这一轮已经没有人替它收尾(执行进程已退出 / 从没进执行器),Rust 侧强制释放了 + // 守卫并补了终态事件;这里按同一个收口函数同步把界面复位,不等 IPC 通知。 + // 时刻取宿主观测到的这一刻:终止返回就是这一轮的终态,原生随后补的事件若先到, + // 收口已经是冻结值,不会被抬高,也不会复活成"永远运行中"。 + // 但取消回包可能晚于新回合的开始:先核对身份(clientTurnId 对应的本轮用户条目), + // 只收口确实是这一轮的那一次,避免在新回合的回调里把旧轮的时间盖上来。 + const cancelledUserItemId = result.clientTurnId + ? directCodexConversationMessageId(result.clientTurnId, 'user') + : ''; + setDirectThreadChat((state) => + directThreadTurnMatchesUser(state, cancelledUserItemId) + ? finishDirectThreadTurn(state, Date.now()) + : state, + ); setChatAgentBusy(false); setProjectSupervisorRuntimeError(''); setChatComposerNotice( @@ -12767,7 +12210,6 @@ export function App({ ); return; } - setDirectCodexProgress('正在终止当前回合'); if (message) { setChatComposerNotice(message); } @@ -12810,7 +12252,7 @@ export function App({ ) { return; } - if (chatAgentBusy) { + if (supervisorChatBusy) { // 回合运行中再次发送:direct-codex 面板把消息放进本地 FIFO 队列,当前回合结束后 // 依次发出;其它面板保持原有"运行中不接受新输入"的行为。 if (directCodexProductRuntime) { @@ -12937,9 +12379,7 @@ export function App({ transientReply={ planningV2Active ? planningV2TransientReply - : directCodexProductRuntime - ? directCodexTransientReply - : projectSupervisorTransientReply + : projectSupervisorTransientReply } hasConversationControls={projectSupervisorHasConversationControls} hiddenConversationCount={hiddenConversationCount} @@ -12955,7 +12395,6 @@ export function App({ if (projectSupervisorOnly) { return ( ` 这一变体)。 */ export interface GameCreatorDirectToolCall { schemaVersion: string; @@ -1141,69 +1121,6 @@ export interface GameCreatorDirectToolCall { updatedAt: number; } -/** 事件里下发的增量条目:与持久化同形,去掉 `turnId`(回合 id 在事件顶层)。 */ -export type GameCreatorDirectTurnToolCall = Omit< - GameCreatorDirectToolCall, - 'turnId' ->; - -export interface GameCreatorDirectTurnUpdateEvent { - projectPath: string; - turnId: string; - sequence: number; - status: GameCreatorDirectTurnUpdateStatus; - activity?: GameCreatorDirectTurnActivity | null; - accumulatedText?: string | null; - /** - * 本回合内**发生变化**的结构化工具调用(只有变化时才带,不是每个 heartbeat 都带全量)。 - * 可选:老版本事件没有这个字段,前端拿到 `undefined` 时必须与改造前行为一致。 - */ - toolCalls?: GameCreatorDirectTurnToolCall[] | null; - /** - * 本回合当前累计的思考过程(流式,整段替换);拿不到时字段缺席。 - */ - reasoningText?: string | null; - /** - * 「文本段 + 工具」的**顺序真相**里本次发生变化的那几条。 - * - * 顺序由 `seq`(条目首次出现时钉死)决定,与落盘 `turn-stream.jsonl` 完全同一份数据, - * 前端不再自己猜切点。可选:老版本事件没有这个字段。 - */ - streamItems?: TurnStreamItem[] | null; - updatedAt: number; -} - -/** 回合流里的一个 `text` 段;`text` 是该段当前累计全文(会随 delta 增长)。 */ -export interface TurnStreamTextItem extends TurnStreamItemBase { - kind: 'text'; - text: string; -} - -/** 回合流里的一个 `tool` 位置标记;工具正文在 `tool-calls.jsonl`(按 `callId` 关联)。 */ -export interface TurnStreamToolItem extends TurnStreamItemBase { - kind: 'tool'; - callId: string; -} - -interface TurnStreamItemBase { - schemaVersion: string; - /** 幂等身份:文本段 `text::`、工具 `tool::`。 */ - id: string; - turnId: string; - /** 首次出现的写入序号:**顺序真相**,按它升序渲染。 */ - seq: number; - /** 条目首次出现的时刻(Unix 毫秒),同 `seq` 时用它排序。 */ - at: number; - updatedAt: number; -} - -/** - * 回合流条目(`read_direct_turn_stream` 的返回元素)。 - * - * 与 Rust `DirectTurnStreamItem` 同形:`text` 段 ↔ `tool` 位置标记。 - */ -export type TurnStreamItem = TurnStreamTextItem | TurnStreamToolItem; - /** `cancel_direct_codex_turn` 的返回值。 */ export interface DirectTurnCancelView { /** diff --git a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx index 858442126..421f50db0 100644 --- a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx +++ b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx @@ -3,21 +3,12 @@ import { useEffect, useState } from 'react'; import { APP_VERSION } from '../app/appMetadata'; import { - AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT, type AppUpdateInfo, + type AppUpdateProgress, checkForAppUpdate, - downloadAppUpdate, + installAppUpdate, subscribeToAppUpdate, } from '../services/appUpdate'; -import { - canSubscribeTauriEvents, - subscribeTauriEvent, -} from '../services/tauriEventSubscription'; - -type DownloadProgress = { - downloadedBytes: number; - totalBytes?: number; -}; type DownloadState = 'idle' | 'downloading' | 'completed' | 'error'; @@ -29,7 +20,7 @@ function formatBytes(bytes: number) { export function AppUpdateNotice() { const [update, setUpdate] = useState(null); const [downloadState, setDownloadState] = useState('idle'); - const [downloadProgress, setDownloadProgress] = useState({ + const [downloadProgress, setDownloadProgress] = useState({ downloadedBytes: 0, }); const [downloadError, setDownloadError] = useState(''); @@ -48,30 +39,11 @@ export function AppUpdateNotice() { }; }, []); - useEffect(() => { - if (!canSubscribeTauriEvents() || !update) return; - let disposed = false; - let unlisten: (() => void) | undefined; - void subscribeTauriEvent( - AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT, - (event) => { - if (!disposed) setDownloadProgress(event.payload); - }, - ).then((cleanup) => { - if (disposed) cleanup(); - else unlisten = cleanup; - }); - return () => { - disposed = true; - unlisten?.(); - }; - }, [update]); - if (!update) return null; const currentUpdate = update; const isDownloading = downloadState === 'downloading'; - const totalBytes = downloadProgress.totalBytes ?? currentUpdate.size; + const totalBytes = downloadProgress.totalBytes; const progress = totalBytes ? Math.min( 100, @@ -82,10 +54,10 @@ export function AppUpdateNotice() { async function handleDownload() { if (isDownloading) return; setDownloadError(''); - setDownloadProgress({ downloadedBytes: 0, totalBytes }); + setDownloadProgress({ downloadedBytes: 0 }); setDownloadState('downloading'); try { - await downloadAppUpdate(currentUpdate.downloadUrl, currentUpdate); + await installAppUpdate(setDownloadProgress); setDownloadState('completed'); } catch (error) { setDownloadError(error instanceof Error ? error.message : String(error)); diff --git a/apps/ai-game-creator-shell/src/components/WindowChrome.tsx b/apps/ai-game-creator-shell/src/components/WindowChrome.tsx index 70f9eb496..67d591ffb 100644 --- a/apps/ai-game-creator-shell/src/components/WindowChrome.tsx +++ b/apps/ai-game-creator-shell/src/components/WindowChrome.tsx @@ -1,8 +1,15 @@ import { getCurrentWindow } from '@tauri-apps/api/window'; import { Copy, Minus, Square, X } from 'lucide-react'; -import { type ReactNode, useCallback, useEffect, useState } from 'react'; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png'; +import { appUpdateCheckEnabled } from '../app/featureFlags'; import { ActiveProjectRunsPanel } from '../features/app-shell/ActiveProjectRunsPanel'; import { subscribeTauriEvent } from '../services/tauriEventSubscription'; import { AppUpdateNotice } from './AppUpdateNotice'; @@ -49,14 +56,21 @@ export function WindowChrome({ children }: WindowChromeProps) { setTitleState(normalizedTitle || WINDOW_CHROME_DEFAULT_TITLE); }, []); - const contextValue: WindowChromeContextValue = { - isWindowChrome: true, - title, - setTitle, - walletSlot, - activeProjectRuns, - setActiveProjectRuns, - }; + /** + * context value 必须 memo:内联对象会让所有 `useWindowChrome()` 消费方在标题栏 + * 每次渲染时都重新拿到新对象,进而连带重跑它们依赖 context 的 effect。 + */ + const contextValue = useMemo( + () => ({ + isWindowChrome: true, + title, + setTitle, + walletSlot, + activeProjectRuns, + setActiveProjectRuns, + }), + [title, setTitle, walletSlot, activeProjectRuns], + ); const [isMaximized, setIsMaximized] = useState(false); @@ -133,7 +147,7 @@ export function WindowChrome({ children }: WindowChromeProps) { return (
- + {appUpdateCheckEnabled ? : null}
diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts index 0eadbeacd..f7b69de22 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts @@ -38,22 +38,39 @@ export function useDirectActiveTurns({ const [snapshotReadFailed, setSnapshotReadFailed] = useState(false); const mountedRef = useRef(true); const inFlightRef = useRef | null>(null); + /** + * 上一次成功读取到的快照签名。 + * + * 轮询每 5 秒跑一次,如果每次都 `setActiveTurns(新数组)`,即使内容一模一样也会 + * 换掉数组身份:所有依赖 `activeTurns` 的 effect 都会跟着重跑(窗口标题栏的活动项目 + * 面板就是这么被反复重发布的)。这里只在内容真的变了才更新状态。 + */ + const lastSnapshotSignatureRef = useRef('[]'); + const requestGenerationRef = useRef(0); + const retryTimerRef = useRef(null); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; + if (retryTimerRef.current !== null) { + window.clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } }; }, []); const refreshActiveTurns = useCallback(async () => { - if (!invoke) { + if (!enabled || !invoke) { return; } // 单飞:轮询与"回合刚开始/刚结束"的主动刷新不叠成两个在途请求。 if (inFlightRef.current) { return inFlightRef.current; } + const generation = requestGenerationRef.current; + const isCurrent = () => + mountedRef.current && generation === requestGenerationRef.current; const request = (async () => { for ( let attempt = 1; @@ -64,38 +81,47 @@ export function useDirectActiveTurns({ const turns = await invoke( 'list_game_creator_direct_active_turns', ); - if (!mountedRef.current) { + if (!isCurrent()) { return; } - setActiveTurns(Array.isArray(turns) ? turns : []); + const nextTurns = Array.isArray(turns) ? turns : []; + const nextSignature = JSON.stringify(nextTurns); + if (nextSignature !== lastSnapshotSignatureRef.current) { + lastSnapshotSignatureRef.current = nextSignature; + setActiveTurns(nextTurns); + } setSnapshotReadFailed(false); inFlightRef.current = null; return; } catch { + if (!isCurrent()) return; if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) { - await new Promise((resolve) => - window.setTimeout( - resolve, - DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt, - ), - ); + await new Promise((resolve) => { + retryTimerRef.current = window.setTimeout(() => { + retryTimerRef.current = null; + resolve(); + }, DIRECT_ACTIVE_TURNS_READ_RETRY_DELAY_MS * attempt); + }); + if (!isCurrent()) return; } } } // 三次都读不到:保留上一份快照(读不到不等于没有在跑),只标记"本次没读到"。 - if (mountedRef.current) { + if (isCurrent()) { setSnapshotReadFailed(true); + inFlightRef.current = null; } - inFlightRef.current = null; })(); inFlightRef.current = request; return request; - }, [invoke]); + }, [enabled, invoke]); useEffect(() => { if (!enabled || !invoke) { - setActiveTurns([]); - setSnapshotReadFailed(false); + lastSnapshotSignatureRef.current = '[]'; + // 空态也要保持引用稳定:已经空了就不要再换一个新数组。 + setActiveTurns((current) => (current.length === 0 ? current : [])); + setSnapshotReadFailed((current) => (current ? false : current)); return; } void refreshActiveTurns(); @@ -103,7 +129,12 @@ export function useDirectActiveTurns({ () => void refreshActiveTurns(), Math.max(1_000, pollIntervalMs), ); - return () => window.clearInterval(timer); + return () => { + window.clearInterval(timer); + // 停用或切换读取器后,旧请求不得覆盖新状态,也不能占住新一轮单飞。 + requestGenerationRef.current += 1; + inFlightRef.current = null; + }; }, [enabled, invoke, pollIntervalMs, refreshActiveTurns]); return { activeTurns, refreshActiveTurns, snapshotReadFailed }; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 4727bf323..85107f7b9 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -1,4 +1,11 @@ -import { Fragment, useCallback, useEffect, useRef, useState } from 'react'; +import { + Fragment, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; import { createPortal } from 'react-dom'; import { launcherNotifications } from '../../app/constants'; @@ -25,8 +32,10 @@ import { type ProjectManifestSnapshotSource, rereadAuthoritativeProjectManifestSnapshot, } from '../../view/project-development/projectResourceLiveUpdateModel'; +import TemplateLibraryView from '../../view/template-library'; import { useDirectActiveTurns } from '../agent-runtime/directActiveTurns'; import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog'; +import { useTemplateLibrary } from '../template-library/useTemplateLibrary'; import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet'; import { DeveloperAgentDialogs, @@ -36,7 +45,10 @@ import type { WorkspaceLauncherShellProps } from './model'; import { NonEmptyProjectDialog, ProjectsPage } from './ProjectCreation'; import { useAccountWallet } from './useAccountWallet'; import { useDeveloperAgentPanel } from './useDeveloperAgentPanel'; -import { useHomeProjectCreation } from './useHomeProjectCreation'; +import { + DESIGN_ARTIFACTS_BUILD_PROMPT, + useHomeProjectCreation, +} from './useHomeProjectCreation'; import { useRecentProjects } from './useRecentProjects'; export function WorkspaceLauncherShell({ @@ -80,6 +92,11 @@ export function WorkspaceLauncherShell({ setAgentChatProjectPath: developerAgent.setAgentChatProjectPath, rememberRecentWorkspace, }); + const templateLibrary = useTemplateLibrary({ + onProjectCreated: async (result) => { + await homeProject.enterCreatedTemplateProject(result); + }, + }); const { projectPath, setProjectPath, @@ -113,7 +130,7 @@ export function WorkspaceLauncherShell({ const agentRuntimeMode: 'design' | 'game' = planningStartMode ? 'design' : 'game'; - const suppressInitialGameTurn = switchedToGameRuntime; + const omitOriginalPlanningTurnInputs = switchedToGameRuntime; const activeProjectContextRef = useRef(currentProjectContext); const manifestMergeRef = useRef(null); activeProjectContextRef.current = currentProjectContext; @@ -198,29 +215,47 @@ export function WorkspaceLauncherShell({ */ const manifestMergeNoticeScopeRef = useRef(null); + // 打开项目由创建流程提供,其函数引用随渲染变化;标题栏只持有稳定的转发入口, + // 否则发布 Context 会再次触发工作台 effect,形成发布/清理循环。 + const openProjectRef = useRef(openProject); + useLayoutEffect(() => { + openProjectRef.current = openProject; + }, [openProject]); const openActiveProject = useCallback( (nextProjectPath: string) => { setProjectPath(nextProjectPath); - void openProject(nextProjectPath, 'open'); + void openProjectRef.current(nextProjectPath, 'open'); }, - [openProject, setProjectPath], + [setProjectPath], ); + /** + * 项目卡片面板发布给窗口标题栏的回调必须走 ref。 + * + * `openProject` 来自 `useHomeProjectCreation` 的普通函数(每次渲染都是新身份), + * 所以 `openActiveProject` 的引用每渲染都变;如果它进 effect 依赖,就会变成 + * 「effect 每渲染重跑 → cleanup/setActiveProjectRuns 改 WindowChrome 状态 → 重新渲染」 + * 的无限 setState 循环(React 报 `Maximum update depth exceeded`)。 + * 这里只让 effect 依赖真正的数据,回调通过 ref 取最新实现。 + */ + const openActiveProjectRef = useRef(openActiveProject); + openActiveProjectRef.current = openActiveProject; + useEffect(() => { setActiveProjectRuns({ activeTurns, currentProjectPath: currentProjectContext?.projectPath ?? null, readFailed: snapshotReadFailed, - onOpenProject: openActiveProject, + onOpenProject: (projectPath: string) => + openActiveProjectRef.current(projectPath), }); - return () => setActiveProjectRuns(null); }, [ activeTurns, currentProjectContext?.projectPath, - openActiveProject, setActiveProjectRuns, snapshotReadFailed, ]); + useEffect(() => () => setActiveProjectRuns(null), [setActiveProjectRuns]); useEffect(() => { const projectPath = currentProjectContext?.projectPath ?? null; @@ -564,6 +599,13 @@ export function WorkspaceLauncherShell({ void openProject(path, 'open'); }} onProjectPick={() => void homeProject.pickAndOpenProject()} + templateRecommendations={templateLibrary.templates} + templateLibraryLoading={ + templateLibrary.status === 'loading' || + templateLibrary.status === 'idle' + } + templateLibraryError={templateLibrary.error} + onTemplateLibraryOpen={() => setLauncherView('template-library')} /> ) : launcherView === 'projects' ? ( + ) : launcherView === 'template-library' ? ( + setLauncherView('home')} + /> ) : launcherView === 'agent-chat' ? ( 文本);没有输入就返回空串。 */ @@ -66,7 +67,7 @@ type UseHomeProjectCreationOptions = { rememberRecentWorkspace: (projectPath: string) => void; }; -const APPROVED_GDD_BUILD_PROMPT = [ +export const APPROVED_GDD_BUILD_PROMPT = [ '请按照附件中的已批准 GDD 开始建造这款游戏。', '', '这份 GDD 已覆盖游戏定位与一句话概念、类型与美术方向、游戏支柱、核心循环、目标用户、平台与输入事实、MVP 系统、暂不纳入范围、创作者提示和原型验证项。', @@ -74,6 +75,9 @@ const APPROVED_GDD_BUILD_PROMPT = [ '请先阅读并理解附件中的 fast_gdd.md,以它作为本次建造的主要依据,优先实现其中 MVP 范围内的可运行游戏原型。', ].join('\n'); +export const DESIGN_ARTIFACTS_BUILD_PROMPT = + '查看当前项目 design_artifacts/ 目录及其子目录下的文档,理解其游戏设计,并按照这些文档将游戏实现出来。'; + function createTextAttachmentFile(content: string) { const file = new File([content], 'fast_gdd.md', { type: 'text/markdown', @@ -552,6 +556,35 @@ export function useHomeProjectCreation({ } } + /** + * 模板库建出的项目:模板文件与项目脚手架已在 Rust 侧一次落盘, + * 这里只负责登记最近项目并走标准进项目通道(含会话预览核验与代次闸门)。 + */ + async function enterCreatedTemplateProject(result: InitLocalProjectResult) { + const invoke = resolveTauriInvoke(); + if (!invoke) { + throw new Error('需要在陶泥儿客户端内运行'); + } + await enterProjectDevelopment({ + projectPath: result.projectPath, + projectName: + result.manifest.name || projectNameFromPath(result.projectPath), + projectKind: 'web', + manifest: result.manifest, + projectRevision: await readCurrentProjectRevision( + invoke, + result.projectPath, + ), + creationType: null, + startMode: null, + initialPrompt: '', + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + createdAt: Date.now(), + }); + } + async function openProject(nextProjectPath: string, mode: 'open' | 'create') { if (mode === 'create') { await createProjectFromProjectPage(nextProjectPath); @@ -796,6 +829,9 @@ export function useHomeProjectCreation({ { name: suggestedName, planning: startMode === 'planning', + // 用户在首页选过「项目创建目录」就用它;没选传 null,由 Rust 侧回落到 + // AGC 管理的默认位置(应用数据目录下的 projects)。 + projectsRoot: readProjectCreationDirectory() || null, }, ); createdProjectPath = result.projectPath; @@ -1002,6 +1038,7 @@ export function useHomeProjectCreation({ renameProject, pickAndOpenProject, pickAndCreateProject, + enterCreatedTemplateProject, confirmCreateInNonEmptyFolder, cancelCreateInNonEmptyFolder, }; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useProjectCreationDirectory.ts b/apps/ai-game-creator-shell/src/features/app-shell/useProjectCreationDirectory.ts new file mode 100644 index 000000000..1f8a772b3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/app-shell/useProjectCreationDirectory.ts @@ -0,0 +1,81 @@ +import { useCallback, useRef, useState } from 'react'; + +import { resolveTauriInvoke } from '../../app/tauri'; +import { + readProjectCreationDirectory, + writeProjectCreationDirectory, +} from './model'; + +/** + * 「项目创建目录」用户偏好。 + * + * 默认沿用 AGC 管理的应用数据目录(`/projects`);用户改选时必须走原生目录 + * 选择器,因为只有它构成"用户显式选择"边界:选择结果当场按用户选择范围加固,后续建项 + * 再由 Rust 侧私有路径门禁复核一次。 + */ +export function useProjectCreationDirectory() { + const [projectCreationDirectory, setProjectCreationDirectory] = useState( + readProjectCreationDirectory, + ); + const [projectCreationDirectoryBusy, setProjectCreationDirectoryBusy] = + useState(false); + const [projectCreationDirectoryStatus, setProjectCreationDirectoryStatus] = + useState(''); + const pickInFlightRef = useRef(false); + + const pickProjectCreationDirectory = useCallback(async () => { + if (pickInFlightRef.current) { + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setProjectCreationDirectoryStatus('需要在陶泥儿客户端内运行'); + return; + } + pickInFlightRef.current = true; + setProjectCreationDirectoryBusy(true); + setProjectCreationDirectoryStatus('正在选择项目创建目录'); + try { + const selected = await invoke( + 'pick_local_project_directory', + { + title: '选择项目创建目录', + ...(projectCreationDirectory + ? { initialPath: projectCreationDirectory } + : {}), + }, + ); + if (!selected) { + setProjectCreationDirectoryStatus('已取消'); + return; + } + setProjectCreationDirectory(writeProjectCreationDirectory(selected)); + setProjectCreationDirectoryStatus('已更新项目创建目录'); + } catch (error) { + setProjectCreationDirectoryStatus( + error instanceof Error ? error.message : String(error), + ); + } finally { + pickInFlightRef.current = false; + setProjectCreationDirectoryBusy(false); + } + }, [projectCreationDirectory]); + + const resetProjectCreationDirectory = useCallback(() => { + writeProjectCreationDirectory(''); + setProjectCreationDirectory(''); + setProjectCreationDirectoryStatus('已恢复默认位置'); + }, []); + + return { + projectCreationDirectory, + projectCreationDirectoryBusy, + projectCreationDirectoryStatus, + pickProjectCreationDirectory, + resetProjectCreationDirectory, + }; +} + +export type ProjectCreationDirectoryController = ReturnType< + typeof useProjectCreationDirectory +>; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx index 62e782c5f..0a0426d8e 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx @@ -18,6 +18,8 @@ import { ClientAuthRequestError } from '../../services/clientApi'; import { ClientHttpTimeoutError } from '../../services/clientHttp'; import { cachedLlmModelCatalog, + LLM_CONFIG_CHANGED_EVENT, + LlmModelCatalogConfigError, refreshLlmModelCatalog, } from '../../services/llmModelCatalog'; @@ -30,6 +32,7 @@ export type ConversationModelSelectHandle = { class ModelSelectionConfigError extends Error {} function modelCatalogErrorMessage(error: unknown) { + if (error instanceof LlmModelCatalogConfigError) return error.message; if (error instanceof ClientHttpTimeoutError) return '模型列表请求超时,请重试'; if (error instanceof ClientAuthRequestError && error.status) @@ -70,6 +73,7 @@ export function ConversationModelSelect({ const selectedRef = useRef(''); const selectionEpochRef = useRef(0); const busyTokenRef = useRef(0); + const syncEpochRef = useRef(0); const configWriteChainRef = useRef>(Promise.resolve()); const onReadyRef = useRef(onReady); const mountedRef = useRef(true); @@ -192,6 +196,7 @@ export function ConversationModelSelect({ const syncCatalog = useCallback( async (showBusy: boolean, manualRefresh = false) => { + const syncEpoch = ++syncEpochRef.current; if (manualRefresh && mountedRef.current) { setManualRefreshBusy(true); setNotice('正在刷新模型列表'); @@ -214,10 +219,17 @@ export function ConversationModelSelect({ try { catalog = await refreshLlmModelCatalog(); } catch (error) { + if (syncEpoch !== syncEpochRef.current) + return Boolean(selectedRef.current); catalogError = error; const cached = cachedLlmModelCatalog(); if (!cached) { if (mountedRef.current) { + appliedRevisionRef.current = null; + selectedRef.current = ''; + setModels([]); + setSelected(''); + setDefaultModelId(''); setError(modelCatalogErrorMessage(error)); setNotice(''); } @@ -227,6 +239,8 @@ export function ConversationModelSelect({ catalog = cached; usingCachedCatalog = true; } + if (syncEpoch !== syncEpochRef.current) + return Boolean(selectedRef.current); const ready = await applyCatalog(catalog, showBusy, epochAtRequest); if (usingCachedCatalog && mountedRef.current) setError(modelCatalogErrorMessage(catalogError)); @@ -235,6 +249,8 @@ export function ConversationModelSelect({ } return ready; } catch (error) { + if (syncEpoch !== syncEpochRef.current) + return Boolean(selectedRef.current); if (mountedRef.current) { setNotice(''); setError( @@ -268,8 +284,20 @@ export function ConversationModelSelect({ function handleWindowFocus() { void syncCatalog(false); } + function handleConfigChanged() { + selectionEpochRef.current += 1; + appliedRevisionRef.current = null; + selectedRef.current = ''; + setSelected(''); + setModels([]); + void syncCatalog(true); + } window.addEventListener('focus', handleWindowFocus); - return () => window.removeEventListener('focus', handleWindowFocus); + window.addEventListener(LLM_CONFIG_CHANGED_EVENT, handleConfigChanged); + return () => { + window.removeEventListener('focus', handleWindowFocus); + window.removeEventListener(LLM_CONFIG_CHANGED_EVENT, handleConfigChanged); + }; }, [syncCatalog]); useEffect(() => { diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx index 3018200fb..1273bb017 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; +import { formatElapsedDuration } from '../../../../../packages/shared/src/lib/formatElapsedDuration'; import { resolveTauriInvoke } from '../../app/tauri'; import type { PlanGddDecisionAction, @@ -171,7 +172,7 @@ export function PlanGddStageProgress({ : `当前版本:v${latestVersion}`} {processingSeconds > 0 ? ( - {`处理耗时:${processingSeconds.toFixed(1)} 秒`} + {`处理耗时:${formatElapsedDuration(processingSeconds * 1000) ?? '—'}`} ) : null}
{deliveredGdd ? ( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index d222b1fbd..4a21a7550 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -1,4 +1,12 @@ -import { ArrowUp, AtSign, Loader2, Settings } from 'lucide-react'; +import { + ArrowUp, + AtSign, + ChevronDown, + Lightbulb, + Loader2, + Settings, + Wrench, +} from 'lucide-react'; import type { ComponentProps, FormEventHandler, @@ -12,16 +20,14 @@ import { AgentMessageContent, type AgentMessageTone, } from '../../../../../packages/shared/src/components/AgentMessageContent'; +import { AgentProcessSummary } from '../../../../../packages/shared/src/components/AgentProcessSummary'; import type { AgentStatusCard, ChatMessage, - GameCreatorDirectToolCall, - GameCreatorDirectTurnUpdateStatus, PendingCommand, PendingUiConfirmation, PlanGddDecisionAction, PlanGddStateViewV1, - TurnStreamItem, } from '../../app/types'; import type { DesignClarificationRequest, @@ -44,6 +50,7 @@ import { import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments'; import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation'; import { taskStatusLabels } from '../project-summary/projectSummary'; +import { agentProcessPreview } from './agentProcessPreview'; import type { QueuedChatTurn } from './chatComposerQueue'; import { ComposerPendingAttachments, @@ -60,11 +67,12 @@ import { DesignAgentPendingActions, DesignAgentPhaseStatus, } from './DesignAgentSurface'; +import type { DirectChatEntry } from './directThreadChat'; import { - buildDirectTurnPresentations, + buildDirectChatTurns, + type DirectChatBlock, + type DirectChatTurn, directMessageTimestamp, - type DirectTurnPresentation, - splitDirectTurnContent, } from './directTurnPresentation'; import { PlanGddSurface } from './GddApprovalCard'; import { @@ -83,111 +91,27 @@ import type { ChatComposerDraft, ChatReference } from './resourceReferences'; import { ToolCallGroup } from './ToolCallGroup'; import { formatClockTime, - formatTurnDuration, + resolveToolGroupTiming, + resolveTurnTiming, } from './toolCallGroupPresentation'; - -/** 相邻的工具流项合并成一个块;中间夹了文本段就另起一块。 */ -type TurnStreamToolRun = { - kind: 'tools'; - key: string; - callIds: string[]; -}; -type TurnStreamRun = - | { kind: 'text'; key: string; text: string } - | TurnStreamToolRun; - -function turnStreamRuns(items: readonly TurnStreamItem[]) { - const runs: TurnStreamRun[] = []; - for (const item of items) { - if (item.kind === 'text') { - const text = item.text ?? ''; - if (!text.trim()) { - continue; - } - runs.push({ kind: 'text', key: item.id, text }); - continue; - } - const callId = item.callId?.trim() ?? ''; - if (!callId) { - continue; - } - const last = runs[runs.length - 1]; - if (last?.kind === 'tools') { - // 连续的工具合并成一块:块的身份用首个工具条目 id,块头顺序不变。 - last.callIds.push(callId); - continue; - } - runs.push({ kind: 'tools', key: item.id, callIds: [callId] }); - } - return runs; -} +import { useLiveNow } from './useLiveNow'; /** - * 一个回合的对话流:**按条目顺序**渲染文本段与工具块。 + * 运行中整轮总耗时:自带 100ms 时钟的小叶子。 * - * 工具是普通元素,位置在它出现的地方:文本段 → 工具块 → 文本段 → …,最后是最终回复; - * 连续的工具合并成一块,中间夹了文本就分块。顺序只来自 `seq`,不做任何切点猜测。 - * 回合末尾由调用方补 `renderTurnUsage`(结束于 xxx,总耗时 xxx)。 + * 时钟只驱动这一行文字(`useLiveNow`),不带着整个对话面板每 100ms 重建。 + * 起点拿不到时不渲染:不编造不能证明的耗时。 */ -function TurnStreamSequence({ - items, - toolCalls, - active, - userSentAt, - className, - tone = 'body', -}: { - items: readonly TurnStreamItem[]; - toolCalls: readonly GameCreatorDirectToolCall[]; - /** 这个回合是否正在跑(决定工具行显示"执行中")。 */ - active: boolean; - userSentAt: number; - className?: string; - tone?: AgentMessageTone; -}) { - const runs = turnStreamRuns(items); - const callsById = new Map(); - for (const call of toolCalls) { - const id = call.id?.trim(); - if (id && !callsById.has(id)) { - callsById.set(id, call); - } +function TurnElapsedTotal({ startedAt }: { startedAt: number }) { + const now = useLiveNow(startedAt > 0); + const timing = resolveTurnTiming({ startedAt, running: true, now }); + if (!timing.durationText) { + return null; } return ( - <> - {runs.map((run, index) => - run.kind === 'text' ? ( -
- - - -
- ) : ( - callsById.get(callId)) - .filter((call): call is GameCreatorDirectToolCall => - Boolean(call), - )} - userSentAt={userSentAt} - active={active} - className="message-tool-call" - /> - ), - )} - + + {`总耗时 ${timing.durationText}`} + ); } @@ -201,6 +125,9 @@ function AgentReasoning({ label?: string; testId?: string; }) { + const [expanded, setExpanded] = useState(false); + // 折叠态:单行纯文本预览(走 Markdown AST 取文字,链接只留字面文字、不含目标)。 + const preview = agentProcessPreview(text); return ( + setExpanded((event.currentTarget as HTMLDetailsElement).open) + } > - 思考过程 -
{text}
+ + + {/* 展开态复用助手正文的安全 Markdown 链路(内部 skipHtml,不用 innerHTML)。 */} +
); } type RuntimePanelProps = ComponentProps; -function directStatusTitle(status: string | null | undefined) { - switch (status) { - case 'accepted': - return '需求已接收'; - case 'running': - return '任务执行中'; - case 'streaming': - return '回复生成中'; - case 'finalizing': - return '结果整理中'; - case 'completed': - return '回复已生成'; - case 'failed': - return '处理失败'; - default: - return '任务执行中'; - } -} - type ProjectSupervisorViewProps = RuntimePanelProps & { activeVersionId?: string | null; /** 输入盒待发送附件:随下次提交进入回合(direct-codex 才渲染)。 */ @@ -247,9 +165,12 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[]; composerRef?: RefObject; directCodex?: boolean; - directStatus?: GameCreatorDirectTurnUpdateStatus | null; - directProcessDetail?: string; - directProcessKey?: string; + /** 聊天条目:历史切片与运行态事件归并后的唯一顺序(工具卡片也在里面)。 */ + directEntries?: DirectChatEntry[]; + /** 最新回合是否还在跑;只由生命周期事件决定。 */ + directTurnRunning?: boolean; + /** 最新回合的原生起点(`turn.started.at`):用户发送时间缺失时兜底,0 = 缺失。 */ + directTurnStartedAt?: number; hiddenConversationCount: number; hasEarlierConversationMessages?: boolean; messagesRef: RefObject; @@ -276,28 +197,14 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { pendingConfirmation: PendingUiConfirmation | null; pendingCommand: PendingCommand | null; projectPath: string; - /** 本回合(含历史回读)的工具调用卡片,按 `startedAt` 升序,同一 id 只会出现一次。 */ - toolCalls?: GameCreatorDirectToolCall[]; - /** - * 「文本段 + 工具」的顺序真相(`turn-stream.jsonl` / 回合事件里的 `streamItems`)。 - * - * 有值的回合按它渲染(文本段 → 工具块 → 文本段 → …),没有值的回合回退到 - * 「工具块 + 整轮消息 + 整轮用量」的老渲染——老项目、缺文件、读取失败都不能白屏。 - */ - turnStreamItems?: TurnStreamItem[]; - /** 当前正在跑的回合 id;卡片在 assistant 消息落盘前锚到它。 */ - activeTurnId?: string | null; initialSupervisorMessage?: string; showProfessionalCollaboration?: boolean; transientReply: string; - /** 流式思考过程(direct-codex):拿不到就为空,空则不渲染。 */ - transientReasoning?: string; showDesignReasoning?: boolean; designReasoning?: string; designReasoningEntries?: DesignReasoningEntry[]; visibleMessages: ChatMessage[]; conversationMessages?: ChatMessage[]; - hasUnloadedHistory?: boolean; visibleProfessionalAgentCards: AgentStatusCard[]; walletEntry?: ReactNode; workspaceStatus: string; @@ -332,9 +239,9 @@ export function ProjectSupervisorView({ chatProjectAssets, composerRef, directCodex = false, - directStatus = null, - directProcessDetail = '', - directProcessKey = '', + directEntries = [], + directTurnRunning = false, + directTurnStartedAt = 0, hiddenConversationCount, hasEarlierConversationMessages = false, messagesRef, @@ -357,19 +264,14 @@ export function ProjectSupervisorView({ pendingConfirmation, pendingCommand, projectPath, - toolCalls = [], - turnStreamItems = [], - activeTurnId = null, initialSupervisorMessage = '', showProfessionalCollaboration = true, transientReply, - transientReasoning = '', showDesignReasoning = false, designReasoning = '', designReasoningEntries = [], visibleMessages, conversationMessages = visibleMessages, - hasUnloadedHistory = false, visibleProfessionalAgentCards, walletEntry, workspaceStatus, @@ -390,26 +292,16 @@ export function ProjectSupervisorView({ }: ProjectSupervisorViewProps) { const planningSurfaceActive = planningLane || isPlanningLaneRuntime(runtimePanelProps.runtime); - const [expandedProcessKey, setExpandedProcessKey] = useState( - null, - ); + const [settingsOpen, setSettingsOpen] = useState(false); + // 语音输入的降级/失败提示:不支持时按钮本身就带提示,这里只承载启动失败与权限类错误。 + const [voiceNotice, setVoiceNotice] = useState(''); + const [approvalOpen, setApprovalOpen] = useState(false); + const [approvalMode, setApprovalMode] = useState('strict'); + const [approvalNotice, setApprovalNotice] = useState(''); useEffect(() => { - if (!activeTurnId) { - return; - } - setTurnUsageNow(Date.now()); - const timer = setInterval(() => setTurnUsageNow(Date.now()), 1000); - return () => clearInterval(timer); - }, [activeTurnId]); - - useEffect(() => { - setExpandedProcessKey(null); - }, [directProcessKey]); - useEffect(() => { - setActiveTurnStartedAt(activeTurnId ? Date.now() : 0); - }, [activeTurnId]); - const processDetailExpanded = - Boolean(directProcessKey) && expandedProcessKey === directProcessKey; + setApprovalOpen(false); + setApprovalNotice(''); + }, [settingsOpen]); const submitLabel = needsUserInput ? '等待回答' : runtimePanelProps.controlBusy @@ -420,52 +312,65 @@ export function ProjectSupervisorView({ const [modelValidating, setModelValidating] = useState(false); const modelSelectRef = useRef(null); const modelValidateInFlightRef = useRef(false); - // 设置浮层:Codex 顶栏只剩状态与齿轮,运行配置 / 审批模式 / 钱包都收进这里。 - const [settingsOpen, setSettingsOpen] = useState(false); - // 整轮会话的耗时在回合进行中要每秒刷新:用 tick 驱动的 `now` 计算"现在 - 开始"。 - const [turnUsageNow, setTurnUsageNow] = useState(() => Date.now()); - const [activeTurnStartedAt, setActiveTurnStartedAt] = useState(0); - // 语音输入的降级/失败提示:不支持时按钮本身就带提示,这里只承载启动失败与权限类错误。 - const [voiceNotice, setVoiceNotice] = useState(''); - const [approvalOpen, setApprovalOpen] = useState(false); - const [approvalMode, setApprovalMode] = useState('strict'); - const [approvalNotice, setApprovalNotice] = useState(''); - useEffect(() => { - setApprovalOpen(false); - setApprovalNotice(''); - }, [settingsOpen]); const runBusy = runtimePanelProps.controlBusy || submitting; const directTurns = directCodex - ? buildDirectTurnPresentations({ - messages: conversationMessages, - visibleMessages, - items: turnStreamItems, - calls: toolCalls, - activeTurnId, - transientReply, - hasUnloadedHistory, + ? buildDirectChatTurns({ + entries: directEntries, + localMessages: conversationMessages, + turnRunning: directTurnRunning, + turnStartedAt: directTurnStartedAt, }) : []; - const turnStartedAtFor = (turnId: string) => - directTurns.find((turn) => turn.turnId === turnId)?.startedAt ?? 0; + const activeTurnStartedAt = + directTurns.find((turn) => turn.active)?.startedAt ?? 0; + const latestDirectTurn = directTurns.at(-1); + // 仅是首个响应到达前的临时提示,不创建聊天条目或第二套回合状态。 + const awaitingFirstResponse = + directCodex && + (runBusy || directTurnRunning) && + !turnCancelling && + !runtimePanelProps.error && + Boolean(latestDirectTurn?.users.length) && + latestDirectTurn?.process.length === 0 && + latestDirectTurn?.finals.length === 0; + // 初始占位气泡只是"最初那条消息还没有正式条目"时的顶位,两种情况下不再渲染: + // - 已经翻出更早的历史(`hasEarlierConversationMessages`):这里不是对话开头,不补占位; + // - Direct 模式已经有了正式用户条目:正式气泡自己会显示,占位再渲染就是同一条消息出现两次。 + // 这里按**结构**判断(存在正式用户条目),不按文本去重真实消息,也不影响合法的连续重复发送。 + const initialSupervisorText = initialSupervisorMessage.trim(); + const initialSupervisorPlaceholderSuperseded = + hasEarlierConversationMessages || + (directCodex + ? directEntries.some( + (entry) => entry.kind === 'message' && entry.role === 'user', + ) || conversationMessages.some((message) => message.role === 'user') + : conversationMessages.some( + (message) => + message.role === 'user' && + message.text.trim() === initialSupervisorText, + )); - const clockTimeWithSeconds = (timestamp: number) => { - const date = new Date(timestamp); - const pad = (value: number) => String(value).padStart(2, '0'); - return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; - }; - - const renderTurnUsage = (turn: DirectTurnPresentation) => { - if (!turn.turnId || turn.active || !turn.startedAt) return null; - const endedAt = Math.max(turn.endedAt, turn.startedAt); + /** + * 回合结束后的一行小结:时间范围与总耗时读同一组边界,两者都取不到就不渲染—— + * 旧历史没有完整边界时不猜"这一轮跑了多久"。终态不会再变,这里不用时钟。 + */ + const renderTurnUsage = (turn: DirectChatTurn) => { + if (turn.active) return null; + const timing = resolveTurnTiming({ + startedAt: turn.startedAt, + endedAt: turn.endedAt, + }); + if (!timing.durationText) return null; + const endedLabel = formatClockTime(turn.endedAt, { tenths: true }); return (

- {`本轮结束于 ${clockTimeWithSeconds(endedAt)} · 耗时 ${formatTurnDuration(endedAt - turn.startedAt) ?? '0秒'}`} + {endedLabel + ? `本轮结束于 ${endedLabel} · 总耗时 ${timing.durationText}` + : `总耗时 ${timing.durationText}`}

); }; @@ -568,7 +473,7 @@ export function ProjectSupervisorView({ aria-hidden="true" /> {runBusy - ? directStatusTitle(directStatus) + ? '陶泥儿正在处理' : projectWorkspaceStatusForDisplay(workspaceStatus)}
{directCodex ? null : planningSurfaceActive ? ( undefined)} /> ) : null} - {directCodex && - directStatus !== 'completed' && - directStatus !== 'failed' && - (runtimePanelProps.controlBusy || Boolean(activeTurnId)) ? ( + {directCodex && directTurnRunning ? (
- - {directProcessDetail ? ( -
-

- {directProcessDetail} -

- {directProcessDetail.includes('\n') || - directProcessDetail.length > 96 ? ( - - ) : null} -
- ) : null}
) : null}
(null); const [pickerPosition, setPickerPosition] = useState<{ left: number; - bottom: number; + top: number; width: number; + maxHeight: number; } | null>(null); const rootRef = useRef(null); @@ -960,18 +944,48 @@ function ResourceReferenceEditor({ const updatePickerPosition = useCallback(() => { const rect = rootRef.current?.getBoundingClientRect(); if (!rect) return; + const viewportPadding = 12; + const gap = 8; const width = Math.min( Math.max(rect.width, 360), - Math.max(280, window.innerWidth - 24), + Math.max(280, window.innerWidth - viewportPadding * 2), ); const left = Math.min( - Math.max(12, rect.left), - Math.max(12, window.innerWidth - width - 12), + Math.max(viewportPadding, rect.left), + Math.max(viewportPadding, window.innerWidth - width - viewportPadding), ); + /** + * 上边界钳制:面板高度**必须**由输入框上下实际可用的空间决定。 + * + * 之前只把底边钉在输入框上方(`bottom: 视口高 - rect.top + 8`)却让高度自由取到 480px, + * 输入框靠上时(居中弹层里的提示词输入、窄屏)整块面板的顶边会被顶出视口——顶部那一排 + * 搜索与筛选既看不见也点不到。这里与 `@` 候选菜单同一套口径:先算上下各有多少空间, + * 空间不足就翻到下方,并把高度收在该侧可用空间内,再对 `top` 兜一次底。 + */ + const availableAbove = Math.max(0, rect.top - viewportPadding - gap); + const availableBelow = Math.max( + 0, + window.innerHeight - rect.bottom - viewportPadding - gap, + ); + const openAbove = availableAbove >= 200 || availableAbove >= availableBelow; + const maxHeight = Math.max( + 160, + Math.min(480, openAbove ? availableAbove : availableBelow), + ); + const top = openAbove + ? Math.max(viewportPadding, rect.top - gap - maxHeight) + : Math.min( + Math.max( + viewportPadding, + window.innerHeight - viewportPadding - maxHeight, + ), + rect.bottom + gap, + ); setPickerPosition({ left, - bottom: Math.max(12, window.innerHeight - rect.top + 8), + top, width, + maxHeight, }); }, []); @@ -1096,10 +1110,15 @@ function ResourceReferenceEditor({ aria-modal="false" aria-label="选择素材" style={{ + // 与 `@` 候选菜单同一坐标系(fixed + top + 高度钳制):底边锚点在 + // 输入框上方会被顶出视口,只有钉住顶边并收紧高度才能保证整块面板可见。 + position: 'fixed', + top: `${pickerPosition.top}px`, left: `${pickerPosition.left}px`, - bottom: `${pickerPosition.bottom}px`, right: 'auto', + bottom: 'auto', width: `${pickerPosition.width}px`, + maxHeight: `${pickerPosition.maxHeight}px`, }} >
diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx index d5e904f51..27329bcdf 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx @@ -6,26 +6,32 @@ import { Terminal, Wrench, } from 'lucide-react'; -import { useEffect, useId, useState } from 'react'; +import { useId, useState } from 'react'; import { AgentMessageContent } from '../../../../../packages/shared/src/components/AgentMessageContent'; -import type { GameCreatorDirectToolCall } from '../../app/types'; +import { AgentProcessSummary } from '../../../../../packages/shared/src/components/AgentProcessSummary'; +import type { DirectChatToolCard } from './directThreadChat'; import { formatToolCallDuration, - formatTurnDuration, + resolveToolGroupTiming, toolCallDurationMs, toolCallGroupSummary, toolCallInputText, toolCallRowText, - turnToolCallDurationMs, - turnToolCallTimeLabel, } from './toolCallGroupPresentation'; +import { useLiveNow } from './useLiveNow'; /** * 一回合的工具调用折叠块(Codex 风格): - * 块头一行汇总 + 该回合总用时 + 结束时间,展开态每行一条工具(行可二级展开看命令 / 文件明细 / 输出)。 + * 块头一行汇总 + 本组用时,展开态每行一条工具(行可二级展开看命令 / 文件明细 / 输出)。 * 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`。 * + * 计时口径(组用时与单条耗时各一套,都是 100ms 粒度、始终一位小数): + * - 本组用时属于**这一组工具**:`min(工具开始)` → `max(工具完成)`。同轮另一组还在跑不会让它 + * 继续增长;本组工具全部拿到终态就立即冻结。缺开始 / 缺终态边界时隐藏,不借别组或整轮边界。 + * 整轮总耗时(用户发送 → 回合终态)只在对话底部渲染一处,不在这里重复。 + * - 单条耗时只认该工具的事件级边界(`item.started` → `item.completed`),不看条目展示时间。 + * * 无障碍:块头与每一行都是 `
    {orderedCalls.map((call) => ( - + ))}
@@ -168,14 +144,18 @@ export function ToolCallGroup({ function ToolCallRow({ call, active, + now, }: { - call: GameCreatorDirectToolCall; + call: DirectChatToolCard; active: boolean; + /** 所属回合的运行时钟;只在"该工具仍在跑"时用得上。 */ + now: number; }) { const [expanded, setExpanded] = useState(false); const detailId = useId(); const text = toolCallRowText(call); - const durationMs = toolCallDurationMs(call); + const liveRunning = active && call.status === 'running'; + const durationMs = toolCallDurationMs(call, { now, running: liveRunning }); const durationText = formatToolCallDuration(durationMs); // "执行中"只在**正在跑的回合**里显示;已结束的回合里残留的 running 快照按已完成处理, // 否则用户会看到一条永远停在"执行中"的记录。 diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/agentProcessPreview.ts b/apps/ai-game-creator-shell/src/features/project-workspace/agentProcessPreview.ts new file mode 100644 index 000000000..8d6a64f83 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/agentProcessPreview.ts @@ -0,0 +1,73 @@ +import remarkGfm from 'remark-gfm'; +import remarkParse from 'remark-parse'; +import { unified } from 'unified'; + +/** 折叠态单行预览的字数上限。 */ +export const AGENT_PROCESS_PREVIEW_MAX_CHARS = 80; + +type PreviewNode = { + type?: string; + value?: unknown; + alt?: unknown; + children?: unknown; +}; + +/** + * 只从 AST 里取**可见文字**:`html` 节点整棵跳过(` + diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/main.js b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/main.js new file mode 100644 index 000000000..bfdce3f80 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/main.js @@ -0,0 +1,30 @@ +const canvas = document.querySelector('#stage'); +const context = canvas.getContext('2d'); +let viewport = { width: 0, height: 0 }; + +function resize() { + const ratio = window.devicePixelRatio || 1; + viewport = { width: window.innerWidth, height: window.innerHeight }; + canvas.width = Math.floor(viewport.width * ratio); + canvas.height = Math.floor(viewport.height * ratio); + context.setTransform(ratio, 0, 0, ratio, 0, 0); +} + +function update(_deltaSeconds) {} + +function render() { + context.clearRect(0, 0, viewport.width, viewport.height); +} + +let previous = performance.now(); +function frame(now) { + const deltaSeconds = Math.min((now - previous) / 1000, 0.1); + previous = now; + update(deltaSeconds); + render(); + requestAnimationFrame(frame); +} + +window.addEventListener('resize', resize); +resize(); +requestAnimationFrame(frame); diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/package.json b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/package.json new file mode 100644 index 000000000..6352b75bc --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/package.json @@ -0,0 +1,12 @@ +{ + "name": "agc-game", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "devDependencies": { + "vite": "^6.2.0" + } +} diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/style.css b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/style.css new file mode 100644 index 000000000..f1256b925 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/style.css @@ -0,0 +1,3 @@ +body { margin: 0; overflow: hidden; background: #0b1512; } +main { display: block; width: 100vw; height: 100vh; } +canvas { display: block; width: 100%; height: 100%; } diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/vite.config.js b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/vite.config.js new file mode 100644 index 000000000..d8e631e07 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/blank-2d-canvas/project/game/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + root: '.', + base: './', + build: { outDir: 'dist', emptyOutDir: true }, +}); diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/cover.svg b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/cover.svg new file mode 100644 index 000000000..8da38262d --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/cover.svg @@ -0,0 +1,13 @@ + + + + + + + + + + 空白三维场景工程 + AGC 空白模板 · Three.js 空场景 + 空场景 + 相机 + 网格地面,直接摆自己的三维内容 + diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/meta.json b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/meta.json new file mode 100644 index 000000000..f099612e4 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/meta.json @@ -0,0 +1,18 @@ +{ + "id": "blank-3d-scene", + "title": "空白三维场景工程", + "summary": "Three.js 空白场景:空场景、透视相机、网格地面与自适应视口已就绪,适合从零搭三维玩法。", + "tags": [ + "空白", + "起步工程", + "3d", + "three.js" + ], + "runtime": "html", + "engine": "three.js", + "engineVersion": "0.180.0", + "templateVersion": "0.1.0", + "entry": "game/index.html", + "coverWidth": 960, + "coverHeight": 540 +} diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/index.html b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/index.html new file mode 100644 index 000000000..d01aa860a --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/index.html @@ -0,0 +1,10 @@ + + + + + + Genarrative Game Draft + + +
+ diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/main.js b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/main.js new file mode 100644 index 000000000..73c37324b --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/main.js @@ -0,0 +1,38 @@ +import * as THREE from 'three'; + +const container = document.querySelector('#game'); +const scene = new THREE.Scene(); +scene.background = new THREE.Color(0x070b14); + +const camera = new THREE.PerspectiveCamera( + 60, + window.innerWidth / window.innerHeight, + 0.1, + 200, +); +camera.position.set(0, 3, 6); +camera.lookAt(0, 0, 0); + +const renderer = new THREE.WebGLRenderer({ antialias: true }); +renderer.setPixelRatio(window.devicePixelRatio); +renderer.setSize(window.innerWidth, window.innerHeight); +container.append(renderer.domElement); + +const light = new THREE.DirectionalLight(0xffffff, 1.2); +light.position.set(4, 8, 6); +scene.add(light, new THREE.AmbientLight(0x8899ff, 0.5)); + +const grid = new THREE.GridHelper(20, 20, 0x3b4a6b, 0x1d2739); +scene.add(grid); + +window.addEventListener('resize', () => { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); +}); + +function render() { + renderer.render(scene, camera); + requestAnimationFrame(render); +} +render(); diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/package.json b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/package.json new file mode 100644 index 000000000..496deca74 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/package.json @@ -0,0 +1,15 @@ +{ + "name": "agc-game", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "dependencies": { + "three": "^0.180.0" + }, + "devDependencies": { + "vite": "^6.2.0" + } +} diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/style.css b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/style.css new file mode 100644 index 000000000..a3c5a9c8c --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/style.css @@ -0,0 +1,3 @@ +body { margin: 0; overflow: hidden; background: #05070d; } +main { display: block; width: 100vw; height: 100vh; } +canvas { display: block; } diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/vite.config.js b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/vite.config.js new file mode 100644 index 000000000..d8e631e07 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/blank-3d-scene/project/game/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + root: '.', + base: './', + build: { outDir: 'dist', emptyOutDir: true }, +}); diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-web/cover.svg b/apps/ai-game-creator-shell/template-library/v1/blank-web/cover.svg new file mode 100644 index 000000000..ac7c02d1b --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/blank-web/cover.svg @@ -0,0 +1,13 @@ + + + + + + + + + + 空白网页工程 + AGC 空白模板 · HTML / CSS / 原生 JS + 零依赖最小工程,从 main.js 开始写自己的玩法 + diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-web/meta.json b/apps/ai-game-creator-shell/template-library/v1/blank-web/meta.json new file mode 100644 index 000000000..a92652137 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/blank-web/meta.json @@ -0,0 +1,18 @@ +{ + "id": "blank-web", + "title": "空白网页工程", + "summary": "最小网页工程(HTML + CSS + 原生 JS + Vite),没有任何引擎依赖,适合从零写玩法。", + "tags": [ + "空白", + "起步工程", + "网页", + "原生" + ], + "runtime": "html", + "engine": "none", + "engineVersion": "", + "templateVersion": "0.1.0", + "entry": "game/index.html", + "coverWidth": 960, + "coverHeight": 540 +} diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/index.html b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/index.html new file mode 100644 index 000000000..d01aa860a --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/index.html @@ -0,0 +1,10 @@ + + + + + + Genarrative Game Draft + + +
+ diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/main.js b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/main.js new file mode 100644 index 000000000..f2307680d --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/main.js @@ -0,0 +1,2 @@ +const root = document.querySelector('#game'); +root.textContent = ''; diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/package.json b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/package.json new file mode 100644 index 000000000..6352b75bc --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/package.json @@ -0,0 +1,12 @@ +{ + "name": "agc-game", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "devDependencies": { + "vite": "^6.2.0" + } +} diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/style.css b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/style.css new file mode 100644 index 000000000..e40ed7036 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/style.css @@ -0,0 +1,2 @@ +body { margin: 0; background: #0f1218; color: #e8eefc; font: 16px system-ui, sans-serif; } +main { display: grid; min-height: 100vh; place-items: center; } diff --git a/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/vite.config.js b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/vite.config.js new file mode 100644 index 000000000..d8e631e07 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/blank-web/project/game/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + root: '.', + base: './', + build: { outDir: 'dist', emptyOutDir: true }, +}); diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/cover.svg b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/cover.svg new file mode 100644 index 000000000..0d00d4b79 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/cover.svg @@ -0,0 +1,13 @@ + + + + + + + + + + Phaser 2D 起步工程 + AGC 起步工程模板 · 网页 / Phaser + Vite + 新建项目即得可运行脚手架,可直接进入对话开始创作 + diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/meta.json b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/meta.json new file mode 100644 index 000000000..71f52ba90 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/meta.json @@ -0,0 +1,18 @@ +{ + "id": "phaser-2d-starter", + "title": "Phaser 2D 起步工程", + "summary": "AGC 新建项目使用的默认二维起步工程(Phaser 4 + Vite),解压后即为可运行项目根。", + "tags": [ + "起步工程", + "2d", + "phaser", + "像素" + ], + "runtime": "html", + "engine": "phaser", + "engineVersion": "4.2.1", + "templateVersion": "0.1.0", + "entry": "game/index.html", + "coverWidth": 960, + "coverHeight": 540 +} diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/game.js b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/game.js new file mode 100644 index 000000000..a77ba6be9 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/game.js @@ -0,0 +1,21 @@ +import './style.css'; + +import Phaser from 'phaser'; + +class PlaceholderScene extends Phaser.Scene { + create() { + this.add.text( + 24, + 24, + '还没有生成游戏。回到聊天输入创意并确认生成后,这里会写入可试玩原型。', + ); + } +} + +new Phaser.Game({ + type: Phaser.AUTO, + width: 720, + height: 420, + parent: 'game', + scene: PlaceholderScene, +}); diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/index.html b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/index.html new file mode 100644 index 000000000..3447d5909 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/index.html @@ -0,0 +1,9 @@ + + + + + + Genarrative Game Draft + +
+ diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/package.json b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/package.json new file mode 100644 index 000000000..6504e9aee --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/package.json @@ -0,0 +1,15 @@ +{ + "name": "agc-game", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "dependencies": { + "phaser": "4.2.1" + }, + "devDependencies": { + "vite": "^6.2.0" + } +} diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/style.css b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/style.css new file mode 100644 index 000000000..8b3c907f4 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/style.css @@ -0,0 +1,2 @@ +body { margin: 0; display: grid; min-height: 100vh; place-items: center; background: #101827; color: #d9e7ff; font: 16px system-ui, sans-serif; } +main { width: min(720px, calc(100vw - 32px)); } diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/vite.config.js b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/vite.config.js new file mode 100644 index 000000000..d8e631e07 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + root: '.', + base: './', + build: { outDir: 'dist', emptyOutDir: true }, +}); diff --git a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/cover.svg b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/cover.svg new file mode 100644 index 000000000..f08503a3f --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/cover.svg @@ -0,0 +1,13 @@ + + + + + + + + + + Three.js 3D 起步工程 + AGC 起步工程模板 · 网页 / Three.js + Vite + 自带可旋转立方体场景与光照,适合作为三维玩法的起点 + diff --git a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/meta.json b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/meta.json new file mode 100644 index 000000000..2b492a71d --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/meta.json @@ -0,0 +1,18 @@ +{ + "id": "threejs-3d-starter", + "title": "Three.js 3D 起步工程", + "summary": "网页三维起步工程(Three.js + Vite),自带可旋转立方体场景、方向光与自适应视口。", + "tags": [ + "起步工程", + "3d", + "three.js", + "网页" + ], + "runtime": "html", + "engine": "three.js", + "engineVersion": "0.180.0", + "templateVersion": "0.1.0", + "entry": "game/index.html", + "coverWidth": 960, + "coverHeight": 540 +} diff --git a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/index.html b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/index.html new file mode 100644 index 000000000..d01aa860a --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/index.html @@ -0,0 +1,10 @@ + + + + + + Genarrative Game Draft + + +
+ diff --git a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/main.js b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/main.js new file mode 100644 index 000000000..211f285ef --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/main.js @@ -0,0 +1,43 @@ +import * as THREE from 'three'; + +const container = document.querySelector('#game'); +const scene = new THREE.Scene(); +scene.background = new THREE.Color(0x0b1120); + +const camera = new THREE.PerspectiveCamera( + 60, + window.innerWidth / window.innerHeight, + 0.1, + 100, +); +camera.position.set(2.4, 1.8, 3.2); +camera.lookAt(0, 0, 0); + +const renderer = new THREE.WebGLRenderer({ antialias: true }); +renderer.setPixelRatio(window.devicePixelRatio); +renderer.setSize(window.innerWidth, window.innerHeight); +container.append(renderer.domElement); + +const light = new THREE.DirectionalLight(0xffffff, 1.4); +light.position.set(3, 5, 4); +scene.add(light, new THREE.AmbientLight(0x8899ff, 0.6)); + +const cube = new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshStandardMaterial({ color: 0xe0a060 }), +); +scene.add(cube); + +window.addEventListener('resize', () => { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); +}); + +function tick() { + cube.rotation.y += 0.01; + cube.rotation.x += 0.004; + renderer.render(scene, camera); + requestAnimationFrame(tick); +} +tick(); diff --git a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/package.json b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/package.json new file mode 100644 index 000000000..496deca74 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/package.json @@ -0,0 +1,15 @@ +{ + "name": "agc-game", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "dependencies": { + "three": "^0.180.0" + }, + "devDependencies": { + "vite": "^6.2.0" + } +} diff --git a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/style.css b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/style.css new file mode 100644 index 000000000..b25e3e5ae --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/style.css @@ -0,0 +1,3 @@ +body { margin: 0; overflow: hidden; background: #06080f; } +main { display: block; width: 100vw; height: 100vh; } +canvas { display: block; } diff --git a/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/vite.config.js b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/vite.config.js new file mode 100644 index 000000000..d8e631e07 --- /dev/null +++ b/apps/ai-game-creator-shell/template-library/v1/threejs-3d-starter/project/game/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + root: '.', + base: './', + build: { outDir: 'dist', emptyOutDir: true }, +}); diff --git a/apps/ai-game-creator-shell/tests/agentProcessPreview.test.ts b/apps/ai-game-creator-shell/tests/agentProcessPreview.test.ts new file mode 100644 index 000000000..5228f667e --- /dev/null +++ b/apps/ai-game-creator-shell/tests/agentProcessPreview.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { + AGENT_PROCESS_PREVIEW_MAX_CHARS, + agentProcessPreview, +} from '../src/features/project-workspace/agentProcessPreview'; + +describe('思考过程单行预览模型', () => { + it('去掉 Markdown 符号与链接目标,只留字面文字', () => { + expect(agentProcessPreview('**Planning** 一下')).toBe('Planning 一下'); + expect( + agentProcessPreview( + '先看 [设计文档](https://internal.example/secret) 再动手', + ), + ).toBe('先看 设计文档 再动手'); + expect(agentProcessPreview('用 `npm run build` 验证')).toBe( + '用 npm run build 验证', + ); + }); + + it('原始 HTML 不进预览(不渲染、也不当文字)', () => { + expect(agentProcessPreview('')).toBe(''); + expect(agentProcessPreview('图')).toBe(''); + expect( + agentProcessPreview('先检查\n\n\n\n再继续'), + ).toBe('先检查 再继续'); + // 行内 HTML 与其文字内容都不泄漏。 + expect(agentProcessPreview('结果 加粗 完成')).toBe('结果 加粗 完成'); + }); + + it('GFM 删除线按同源插件解析,波浪号不进预览', () => { + // 与正文同源:删除线保留其文字(正文里也是这段字),但 `~~` 控制符不进预览。 + expect(agentProcessPreview('~~删除这段~~ 保留这段')).toBe( + '删除这段 保留这段', + ); + expect(agentProcessPreview('~~删除~~')).toBe('删除'); + }); + + it('压成单行并按上限省略', () => { + expect(agentProcessPreview('第一行\n\n第二行')).toBe('第一行 第二行'); + expect(agentProcessPreview('- 第一项\n- 第二项')).toBe('第一项 第二项'); + const long = 'x'.repeat(AGENT_PROCESS_PREVIEW_MAX_CHARS + 20); + const preview = agentProcessPreview(long); + expect(preview.endsWith('…')).toBe(true); + expect(preview.length).toBeLessThanOrEqual( + AGENT_PROCESS_PREVIEW_MAX_CHARS + 1, + ); + expect(agentProcessPreview('')).toBe(''); + expect(agentProcessPreview(' ')).toBe(''); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/agentProcessStyles.test.ts b/apps/ai-game-creator-shell/tests/agentProcessStyles.test.ts new file mode 100644 index 000000000..4e3867ae9 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/agentProcessStyles.test.ts @@ -0,0 +1,39 @@ +import { readFileSync } from 'node:fs'; + +import { describe, expect, it } from 'vitest'; + +const styles = readFileSync( + new URL('../src/styles.css', import.meta.url), + 'utf8', +); + +describe('回合结束后的过程样式', () => { + it('折叠层不再用 grid gap 叠加内部条目的原有 margin', () => { + const body = styles.match(/\.message-turn-process-body\s*\{([^}]+)\}/)?.[1]; + expect(body).toBeDefined(); + expect(body).toMatch(/display:\s*block/); + expect(body).not.toMatch(/(?:^|[;\s])gap\s*:/); + expect(styles).toMatch( + /\.message-turn-process-body\s*>\s*:is\(\.design-agent-reasoning,\s*\.agent-tool-call-group\)\s*\{[^}]*margin:\s*4px 0 0/, + ); + expect(styles).toMatch( + /\.project-supervisor-message-list\s*>\s*:is\(\.design-agent-reasoning,\s*\.agent-tool-call-group\),/, + ); + }); + + it('仅最外层摘要使用正文色,不覆盖内部过程色', () => { + expect(styles).toMatch( + /\.message-turn-process\s*>\s*summary\s*>\s*\.agent-process-summary\s*\{[^}]*color:\s*var\(--platform-text-strong/, + ); + expect(styles).not.toMatch( + /\.message-turn-process-body\s*\{[^}]*color:\s*var\(--platform-text-strong/, + ); + }); + + it('失败工具行与失败组摘要使用错误色', () => { + expect(styles).toContain(".agent-tool-call-group[data-has-failure='true']"); + expect(styles).toMatch( + /\.agent-tool-call-group-row\[data-status='failed'\]\s*:is\([^}]+\)\s*\{\s*color:\s*var\(--platform-button-danger-text/, + ); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts index bfb771cb0..68a9c30c4 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/auth.suite.ts @@ -11,6 +11,7 @@ import { beginPlatformSessionTransition, clearCommittedPlatformSession, commitAuthenticatedPlatformSession, + currentPlatformNativeIdentityGenerationForTests, currentPlatformSessionGeneration, requestPlatformSessionRefresh, resetPlatformSessionStateForTests, @@ -214,78 +215,95 @@ export function registerAuthTests() { ); }); - it('reserves install and clear generations above the native floor after renderer state resets', async () => { - let nativeGenerationFloor = 57; + it('reserves install and clear writes above the native floor after renderer state resets', async () => { + let nativeFloor = { identityGeneration: 57, revision: 57 }; const mutations: Array<{ command: string; - generation: number; + identityGeneration: number; + revision: number; }> = []; const invoke = vi.fn(async (command: string, payload?: unknown) => { - if (command === 'read_platform_account_session_generation') { - return nativeGenerationFloor; + if (command === 'read_platform_account_session_state') { + return nativeFloor; } if ( command === 'install_platform_account_session' || command === 'clear_platform_account_session' ) { - const generation = (payload as { generation?: number } | undefined) - ?.generation; - if (generation === undefined) { - throw new Error('missing native session generation'); + const write = payload as + | { identityGeneration?: number; revision?: number } + | undefined; + if ( + write?.identityGeneration === undefined || + write?.revision === undefined + ) { + throw new Error('missing native session write identity'); } - mutations.push({ command, generation }); - nativeGenerationFloor = generation; + mutations.push({ + command, + identityGeneration: write.identityGeneration, + revision: write.revision, + }); + nativeFloor = { + identityGeneration: write.identityGeneration, + revision: write.revision, + }; } return null; }); window.__TAURI__ = { core: { invoke } }; resetPlatformSessionStateForTests(); - const installFloor = nativeGenerationFloor; + const installFloor = nativeFloor.revision; + const installIdentityFloor = nativeFloor.identityGeneration; const loginGeneration = beginPlatformSessionTransition(); window.localStorage.setItem( 'genarrative.auth.access-token.v1', 'renderer-reload-token', ); await commitAuthenticatedPlatformSession(testAuthUser, loginGeneration); - expect(mutations[0]).toEqual({ - command: 'install_platform_account_session', - generation: expect.any(Number), - }); - expect(mutations[0]?.generation).toBeGreaterThan(installFloor); + expect(mutations[0]?.command).toBe('install_platform_account_session'); + expect(mutations[0]?.identityGeneration).toBeGreaterThan( + installIdentityFloor, + ); + expect(mutations[0]?.revision).toBeGreaterThan(installFloor); resetPlatformSessionStateForTests(); - const clearFloor = nativeGenerationFloor; + const clearFloor = nativeFloor.revision; + const clearIdentityFloor = nativeFloor.identityGeneration; const logoutGeneration = beginPlatformSessionClearTransition(); await clearCommittedPlatformSession(logoutGeneration); - expect(mutations[1]).toEqual({ - command: 'clear_platform_account_session', - generation: expect.any(Number), - }); - expect(mutations[1]?.generation).toBeGreaterThan(clearFloor); + expect(mutations[1]?.command).toBe('clear_platform_account_session'); + expect(mutations[1]?.revision).toBeGreaterThan(clearFloor); + expect(mutations[1]?.identityGeneration).toBeGreaterThan( + clearIdentityFloor, + ); expect( invoke.mock.calls.filter( - ([command]) => command === 'read_platform_account_session_generation', + ([command]) => command === 'read_platform_account_session_state', ), ).toHaveLength(2); }); - it('retries the native session generation floor read after a transient failure', async () => { + it('retries the native session write floor read after a transient failure', async () => { let floorReads = 0; const invoke = vi.fn(async (command: string, payload?: unknown) => { - if (command === 'read_platform_account_session_generation') { + if (command === 'read_platform_account_session_state') { floorReads += 1; if (floorReads === 1) { throw new Error('runner not ready'); } - return 12; + return { identityGeneration: 12, revision: 12 }; } if ( command === 'install_platform_account_session' || command === 'clear_platform_account_session' ) { expect(payload).toEqual( - expect.objectContaining({ generation: expect.any(Number) }), + expect.objectContaining({ + identityGeneration: expect.any(Number), + revision: expect.any(Number), + }), ); } return null; @@ -570,7 +588,8 @@ export function registerAuthTests() { expect(invoke).toHaveBeenLastCalledWith( 'clear_platform_account_session', expect.objectContaining({ - generation: expect.any(Number), + identityGeneration: expect.any(Number), + revision: expect.any(Number), }), ); expect( @@ -732,9 +751,17 @@ export function registerAuthTests() { expect.objectContaining({ userId: 'user-b', accessToken: 'account-b-token', - generation: currentPlatformSessionGeneration(), + identityGeneration: expect.any(Number), + revision: expect.any(Number), }), ); + // 换号必须推进身份代次:旧账号在途 operation 不能拿到新账号凭据。 + const lastInstall = invoke.mock.calls.at(-1)?.[1] as + | { identityGeneration?: number } + | undefined; + expect(lastInstall?.identityGeneration).toBe( + currentPlatformNativeIdentityGenerationForTests(), + ); }); it('treats a late old-account refresh failure as stale after switching accounts', async () => { @@ -822,6 +849,103 @@ export function registerAuthTests() { }); }); + it('keeps the identity generation stable when the same account renews its credential', async () => { + const installs: Array<{ identityGeneration?: number; revision?: number }> = + []; + const invoke = vi.fn(async (command: string, payload?: unknown) => { + if (command === 'install_platform_account_session') { + installs.push( + payload as { identityGeneration?: number; revision?: number }, + ); + } + return null; + }); + window.__TAURI__ = { core: { invoke } }; + const generation = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'expired-token', + ); + await commitAuthenticatedPlatformSession(testAuthUser, generation); + const identityGenerationAfterLogin = + currentPlatformNativeIdentityGenerationForTests(); + const sessionGenerationAfterLogin = currentPlatformSessionGeneration(); + + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + return new Response(JSON.stringify({ token: 'renewed-token' }), { + status: 200, + }); + } + if (url === '/api/auth/me') { + return new Response( + JSON.stringify({ + user: testAuthUser, + availableLoginMethods: ['password'], + }), + { status: 200 }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + const result = await requestPlatformSessionRefresh(testAuthUser.id); + expect(result).toEqual( + expect.objectContaining({ status: 'refreshed', user: testAuthUser }), + ); + // 续期只换凭据:身份代次与平台会话代次都不推进,在途生成 operation 不会被判成 + // 旧账号请求;native 写入仍然用更高的 revision 拒绝迟到写入。 + expect(currentPlatformNativeIdentityGenerationForTests()).toBe( + identityGenerationAfterLogin, + ); + expect(currentPlatformSessionGeneration()).toBe( + sessionGenerationAfterLogin, + ); + expect(installs).toHaveLength(2); + expect(installs[1]?.identityGeneration).toBe( + installs[0]?.identityGeneration, + ); + expect(installs[1]?.revision).toBeGreaterThan(installs[0]?.revision ?? 0); + }); + + it('keeps the session when a refresh fails for a transient reason', async () => { + const invoke = vi.fn(async () => null); + window.__TAURI__ = { core: { invoke } }; + const generation = beginPlatformSessionTransition(); + window.localStorage.setItem( + 'genarrative.auth.access-token.v1', + 'still-valid-token', + ); + await commitAuthenticatedPlatformSession(testAuthUser, generation); + const sessionGeneration = currentPlatformSessionGeneration(); + + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + return new Response('', { status: 503 }); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + const result = await requestPlatformSessionRefresh(testAuthUser.id); + // 刷新暂时不可用不等于登录态权威失效:保留会话与 access token,只让本次动作失败。 + expect(result).toMatchObject({ status: 'failed', authoritative: false }); + expect( + window.localStorage.getItem('genarrative.auth.access-token.v1'), + ).toBe('still-valid-token'); + expect(currentPlatformSessionGeneration()).toBe(sessionGeneration); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'clear_platform_account_session', + ), + ).toHaveLength(0); + }); + it('keeps refresh, current-user lookup, and native commit on the frozen origin', async () => { setClientServerSelection({ preset: 'dev', customBaseUrl: '' }); const invoke = vi.fn(async () => null); @@ -1156,6 +1280,94 @@ export function registerAuthTests() { expect(screen.queryByText(/Unexpected|Failed to deserialize/u)).toBeNull(); }); + it('shows the backend reason when the password login is rejected', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + return new Response('', { status: 401 }); + } + if (url === '/api/auth/entry') { + return new Response( + JSON.stringify({ + ok: false, + data: null, + error: { code: 'unauthorized', message: '手机号或密码错误' }, + meta: { apiVersion: '2026-06-16', routeVersion: 'v1' }, + }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + render( + React.createElement(AuthenticatedClient, null, () => + React.createElement('main', { 'aria-label': '已登录' }, 'ready'), + ), + ); + + await screen.findByRole('main', { name: '登录' }); + fireEvent.click(screen.getByRole('button', { name: '密码登录' })); + fireEvent.change(screen.getByLabelText('手机号'), { + target: { value: '15801783533' }, + }); + fireEvent.change(screen.getByLabelText('密码'), { + target: { value: 'wrong-password' }, + }); + fireEvent.click(screen.getByRole('button', { name: '登录' })); + + expect(await screen.findByText('手机号或密码错误')).not.toBeNull(); + expect(screen.queryByText('登录失败')).toBeNull(); + }); + + it('keeps the backend reason when the error body carries no envelope', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation( + async (input: RequestInfo | URL) => { + const url = String(input); + if (url === '/api/auth/refresh') { + return new Response('', { status: 401 }); + } + if (url === '/api/auth/entry') { + return new Response( + JSON.stringify({ + error: { code: 'unauthorized', message: '手机号或密码错误' }, + meta: { apiVersion: '2026-06-16', routeVersion: 'v1' }, + }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + throw new Error(`unexpected fetch ${url}`); + }, + ); + + render( + React.createElement(AuthenticatedClient, null, () => + React.createElement('main', { 'aria-label': '已登录' }, 'ready'), + ), + ); + + await screen.findByRole('main', { name: '登录' }); + fireEvent.click(screen.getByRole('button', { name: '密码登录' })); + fireEvent.change(screen.getByLabelText('手机号'), { + target: { value: '15801783533' }, + }); + fireEvent.change(screen.getByLabelText('密码'), { + target: { value: 'wrong-password' }, + }); + fireEvent.click(screen.getByRole('button', { name: '登录' })); + + expect(await screen.findByText('手机号或密码错误')).not.toBeNull(); + expect(screen.queryByText('登录失败')).toBeNull(); + }); + it('shows a clear login service error instead of raw Load failed', async () => { vi.spyOn(globalThis, 'fetch').mockImplementation( async (input: RequestInfo | URL) => { @@ -1364,7 +1576,8 @@ export function registerAuthTests() { fetchSpy.mock.calls.filter( ([input]) => String(input) === '/api/auth/refresh', ), - ).toHaveLength(1); + // 401 刷新先用当前 cookie 收敛重试一次,重试仍被拒绝才算权威失效。 + ).toHaveLength(2); }); it('fails the renderer closed when native session clear is rejected during logout', async () => { diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 45f59dcd4..d299cdd55 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -69,11 +69,17 @@ function gameCreatorConfigView(reasoningEffort: string) { } /** 打开一个 direct-codex 项目对话面板(右侧输入盒就是被测对象)。 */ -async function openDirectCodexSurface(overrides: InvokeOverrides = {}) { +async function openDirectCodexSurface( + overrides: InvokeOverrides = {}, + beforeOpen?: ( + harness: ReturnType, + ) => void, +) { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath: DYNAMIC_GAME_PROJECT_PATH, initialSessionExists: false, }); + beforeOpen?.(supervisorHarness); const manifest = createGameCreationAppManifest( 'local-project-draft', '输入盒控件项目', @@ -110,7 +116,12 @@ async function openDirectCodexSurface(overrides: InvokeOverrides = {}) { renderLauncherProjectsAt('/?launcher'); pickProjectFromLauncher(DYNAMIC_GAME_PROJECT_PATH); const surface = await screen.findByLabelText('陶泥儿项目对话'); - return { invoke, path: DYNAMIC_GAME_PROJECT_PATH, surface }; + return { + invoke, + path: DYNAMIC_GAME_PROJECT_PATH, + surface, + harness: supervisorHarness, + }; } async function submitDirectTurn( @@ -474,14 +485,69 @@ export function registerChatComposerControlTests() { }); }); + it('restores a running DirectProject turn, queues the next message, and dispatches it on turn.completed', async () => { + const pending: Array<{ resolve: (value: string) => void }> = []; + const { invoke, surface, harness } = await openDirectCodexSurface( + { + chat_with_game_creator_direct_codex: () => + new Promise((resolve) => { + pending.push({ resolve }); + }), + }, + (directHarness) => { + directHarness.emitDirectThreadEvents({ type: 'turn.started' }); + }, + ); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + + expect( + await within(surface).findByRole('button', { name: '终止' }), + ).not.toBeNull(); + expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull(); + + await setComposerText(composer, '恢复后排队的消息'); + submitComposerForm(composer); + const queue = await within(surface).findByLabelText('待发送消息队列'); + expect(within(queue).getByText('恢复后排队的消息')).not.toBeNull(); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'chat_with_game_creator_direct_codex', + ), + ).toHaveLength(0); + + act(() => { + harness.emitDirectThreadEvents({ + type: 'turn.completed', + status: 'completed', + }); + }); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + expect.objectContaining({ prompt: '恢复后排队的消息' }), + ); + }); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'chat_with_game_creator_direct_codex', + ), + ).toHaveLength(1); + + await act(async () => { + pending[0]?.resolve('排队回合回复'); + }); + }); + it('terminates the running turn and returns the composer to the idle state', async () => { const pending: Array<{ resolve: (value: string) => void; reject: (error: Error) => void; }> = []; - const { invoke, path, surface } = await openDirectCodexSurface({ + const { invoke, path, surface, harness } = await openDirectCodexSurface({ chat_with_game_creator_direct_codex: () => new Promise((resolve, reject) => { + // 回合真正开跑:生命周期事件由订阅下发,界面据此进入"可终止"。 + harness.emitDirectThreadEvents({ type: 'turn.started' }); pending.push({ resolve, reject }); }), cancel_direct_codex_turn: async () => undefined, @@ -502,13 +568,16 @@ export function registerChatComposerControlTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith('cancel_direct_codex_turn', { projectPath: path, - clientTurnId: expect.any(String), }); }); // app-server 的中断原因回到前端:不是失败,UI 必须回到可用态。 act(() => { pending[0]?.reject(new Error('Codex app-server turn 已中断')); + harness.emitDirectThreadEvents({ + type: 'turn.completed', + status: 'interrupted', + }); }); await waitFor(() => { const send = within(surface).getByRole('button', { name: '发送' }); @@ -518,6 +587,43 @@ export function registerChatComposerControlTests() { expect(within(surface).getByText('已终止本次回合。')).not.toBeNull(); }); + it('terminates a turn that was submitted before its lifecycle event arrived', async () => { + // 刚提交、`turn.started` 还没下发:按钮已经变成「终止」,点击不能回"没有正在运行的回合"。 + const pending: Array<{ resolve: (value: string) => void }> = []; + const { invoke, path, surface } = await openDirectCodexSurface({ + chat_with_game_creator_direct_codex: () => + new Promise((resolve) => { + pending.push({ resolve }); + }), + cancel_direct_codex_turn: async () => undefined, + }); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '做一个小游戏'); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + expect.objectContaining({ prompt: '做一个小游戏' }), + ); + }); + + const stopButton = await within(surface).findByRole('button', { + name: '终止', + }); + fireEvent.click(stopButton); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('cancel_direct_codex_turn', { + projectPath: path, + }); + }); + expect( + within(surface).queryByText('当前没有正在运行的回合,无法终止。'), + ).toBeNull(); + + await act(async () => { + pending[0]?.resolve('回合回复'); + }); + }); + it('moves the reasoning effort control next to the model selector and persists only for later turns', async () => { let stored = 'high'; const { invoke, surface } = await openDirectCodexSurface({ diff --git a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts index 54d9722fa..c1c050a20 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts @@ -222,16 +222,38 @@ export function registerDesignAgentSurfaceTests() { projectPath: harness.projectPath, clientTurnId, kind: 'reasoning', - reasoningText: '先分析需求,再组织方案。', + reasoningText: + '## 结论\n\n- 先分析需求\n- 再组织方案\n\n用 `npm run build` 验证', }); - const summary = await screen.findByText('思考过程'); - const details = summary.closest('details') as HTMLDetailsElement; + // 折叠入口按 aria-label 定位(标题文案不再写死,折叠态显示的是单行预览)。 + const details = (await screen.findByLabelText( + /思考过程/, + )) as HTMLDetailsElement; + const summary = details.querySelector('summary') as HTMLElement; expect(details.getAttribute('data-agent-content')).toBe('process'); expect(details.open).toBe(false); + // 折叠态是纯文本单行预览:Markdown 符号不出现在入口文字里。 + expect(summary.textContent).toContain('结论'); + expect(summary.textContent).not.toContain('##'); + expect(summary.textContent).not.toContain('`'); + expect(summary.querySelector('.lucide-lightbulb')).not.toBeNull(); + expect(summary.querySelector('.lucide-brain')).toBeNull(); fireEvent.click(summary); expect(details.open).toBe(true); - expect(screen.getByText('先分析需求,再组织方案。')).not.toBeNull(); + // 展开态复用助手正文的 Markdown 安全链路:标题 / 列表 / 行内代码都成为真实语义元素。 + await waitFor(() => { + expect(details.querySelector('h2')?.textContent).toBe('结论'); + expect(summary.textContent).toBe('思考过程'); + }); + expect(details.querySelectorAll('li')).toHaveLength(2); + expect(details.querySelector('code')?.textContent).toBe('npm run build'); + expect(details.textContent?.match(/结论/g)).toHaveLength(1); + fireEvent.click(summary); + await waitFor(() => { + expect(details.open).toBe(false); + expect(summary.textContent).toContain('结论'); + }); }); it('renders historical reasoning as independent collapsed sections', async () => { @@ -252,10 +274,12 @@ export function registerDesignAgentSurfaceTests() { }), ); - const summaries = await screen.findAllByText('思考过程'); - expect(summaries).toHaveLength(2); - const details = summaries.map( - (summary) => summary.closest('details') as HTMLDetailsElement, + const details = (await screen.findAllByLabelText( + /思考过程/, + )) as HTMLDetailsElement[]; + expect(details).toHaveLength(2); + const summaries = details.map( + (element) => element.querySelector('summary') as HTMLElement, ); expect(details.every((element) => !element.open)).toBe(true); expect( @@ -266,5 +290,6 @@ export function registerDesignAgentSurfaceTests() { fireEvent.click(summaries[0]); expect(details[0].open).toBe(true); expect(details[1].open).toBe(false); + await waitFor(() => expect(summaries[0].textContent).toBe('思考过程')); }); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 5dbedafbf..561dfab11 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -627,6 +627,23 @@ function createPlanGddStateView( }; } +/** + * 运行态事件里的条目身份:只有 `item.started` / `item.completed` 带条目。 + * + * `item.delta` 只带 itemId 不带条目,属于瞬时事件,不参与 bootstrap 回放。 + */ +function directThreadEventItemId( + event: Record, +): string | null { + if (event.type !== 'item.started' && event.type !== 'item.completed') { + return null; + } + const item = event.item; + if (!item || typeof item !== 'object') return null; + const itemId = (item as Record).itemId; + return typeof itemId === 'string' && itemId ? itemId : null; +} + function createProjectSupervisorRuntimeHarness({ projectPath = '/tmp/authorized-game', sessionId = 'supervisor-session-active', @@ -744,6 +761,31 @@ function createProjectSupervisorRuntimeHarness({ let designAgentUpdateHandler: | ((event: { payload: Record }) => void) | null = null; + let directThreadNotifyHandler: + | ((event: { payload: { subscriptionId: string } }) => void) + | null = null; + let directThreadSubscriptionId: string | null = null; + let directThreadSubscriptionSequence = 0; + // 未消费的运行态事件队列:`subscribe` 的 bootstrap 与 `consume` 都从这里取, + // 与 Rust 侧"游标在队尾、事件按序下发"的语义一致。 + let pendingDirectThreadEvents: Array> = []; + let directThreadHistoryItems: Array> = []; + let directThreadLastCompletedItemId: string | null = null; + // 运行态事件里只有一个"最新回合是否在跑"的布尔:与 Thread Manager 只保留一条生命周期 + // 锚点一致,重复 `turn.started` 在生产里不会出现。 + let directThreadTurnRunning = false; + // bootstrap 的回放规则与 Rust 侧 `is_bootstrap_event` 对齐:只补"这一刻还没结束的条目" + // 与最新一条生命周期锚点;已完成条目、增量正文这类瞬时事件都不回放。 + const directThreadActiveItemIds = new Set(); + const directThreadPendingEventSeqs = new Map< + Record, + number + >(); + let directThreadEventSequence = 0; + let directThreadLifecycleAnchor: { + seq: number; + event: Record; + } | null = null; const conversationRecord = ( role: 'user' | 'assistant', @@ -938,6 +980,93 @@ function createProjectSupervisorRuntimeHarness({ const states = runtimeMapLoader ? await runtimeMapLoader() : []; return states.map((state) => runtimeResult(state)); } + if (command === 'subscribe_direct_project_thread') { + directThreadSubscriptionSequence += 1; + directThreadSubscriptionId = `direct-thread-${directThreadSubscriptionSequence}`; + const pending = pendingDirectThreadEvents; + pendingDirectThreadEvents = []; + // 游标落在队尾:只有"还没结束的条目"与最新生命周期锚点作为 bootstrap 回放, + // 已完成条目与增量正文不再补发(Rust 侧 `is_bootstrap_event` 的同一口径)。 + const replay = pending + .filter((event) => { + const itemId = directThreadEventItemId(event); + return Boolean(itemId && directThreadActiveItemIds.has(itemId)); + }) + .map((event) => ({ + seq: directThreadPendingEventSeqs.get(event) ?? 0, + event, + })); + for (const event of pending) { + directThreadPendingEventSeqs.delete(event); + } + if ( + directThreadLifecycleAnchor && + !replay.some( + (entry) => entry.event === directThreadLifecycleAnchor?.event, + ) + ) { + replay.push(directThreadLifecycleAnchor); + } + replay.sort((left, right) => left.seq - right.seq); + return { + subscriptionId: directThreadSubscriptionId, + lastCompletedItemId: directThreadLastCompletedItemId, + events: replay.map((entry) => entry.event), + }; + } + if (command === 'consume_direct_project_thread') { + if (String(args?.subscriptionId ?? '') !== directThreadSubscriptionId) { + throw new Error('SUBSCRIPTION_EXPIRED'); + } + const events = pendingDirectThreadEvents; + pendingDirectThreadEvents = []; + return { events }; + } + if (command === 'read_direct_project_history_slice') { + // 生产口径:从文件尾反向取一屏可显示条目——`throughItemId` 是窗口新端边界(含该条, + // 首屏用),`beforeItemId` 是旧端边界(不含该条,翻页用);收满一屏之后再见一条才算 + // `hasMore`,`firstItemId` 是本屏最老一条的 itemId。 + const requestedLimit = Number(args?.limit ?? 20); + const limit = Math.min( + Math.max(Number.isFinite(requestedLimit) ? requestedLimit : 20, 1), + 200, + ); + const throughItemId = + typeof args?.throughItemId === 'string' && args.throughItemId + ? args.throughItemId + : null; + const beforeItemId = + typeof args?.beforeItemId === 'string' && args.beforeItemId + ? args.beforeItemId + : null; + let end = directThreadHistoryItems.length; + if (throughItemId) { + const anchorIndex = directThreadHistoryItems.findIndex( + (item) => String(item.itemId ?? '') === throughItemId, + ); + if (anchorIndex < 0) { + throw new Error( + `DirectProject 历史中不存在 item:${throughItemId}`, + ); + } + end = anchorIndex + 1; + } else if (beforeItemId) { + const anchorIndex = directThreadHistoryItems.findIndex( + (item) => String(item.itemId ?? '') === beforeItemId, + ); + end = + anchorIndex >= 0 ? anchorIndex : directThreadHistoryItems.length; + } + const start = Math.max(0, end - limit); + const window = directThreadHistoryItems.slice(start, end); + const oldest = window[0]; + return { + items: [...window], + hasMore: start > 0, + firstItemId: + oldest && typeof oldest.itemId === 'string' ? oldest.itemId : null, + }; + } if (command === 'start_game_creator_supervisor_runtime_task') { if (args?.runProfile !== expectedRunProfile) { throw new Error('unexpected Project Supervisor run profile'); @@ -1120,6 +1249,10 @@ function createProjectSupervisorRuntimeHarness({ designAgentUpdateHandler = handler as unknown as typeof designAgentUpdateHandler; } + if (eventName === 'game-creator-direct-thread-notify') { + directThreadNotifyHandler = + handler as unknown as typeof directThreadNotifyHandler; + } return () => { if (runtimeUpdateHandler === handler) { runtimeUpdateHandler = null; @@ -1133,10 +1266,45 @@ function createProjectSupervisorRuntimeHarness({ if (designAgentUpdateHandler === handler) { designAgentUpdateHandler = null; } + if (directThreadNotifyHandler === handler) { + directThreadNotifyHandler = null; + } }; }, ); + /** 追加运行态事件并唤醒订阅者:bootstrap / consume 共用同一份队列。 */ + const emitDirectThreadEvents = ( + ...events: Array> + ) => { + for (const event of events) { + directThreadEventSequence += 1; + directThreadPendingEventSeqs.set(event, directThreadEventSequence); + if (event.type === 'turn.started') directThreadTurnRunning = true; + if (event.type === 'turn.completed') directThreadTurnRunning = false; + if (event.type === 'turn.started' || event.type === 'turn.completed') { + // 生命周期锚点只留最新一条,与 Thread Manager 的 `lifecycle_anchor` 一致。 + directThreadLifecycleAnchor = { + seq: directThreadEventSequence, + event, + }; + } + const itemId = directThreadEventItemId(event); + if (itemId && event.type === 'item.started') { + directThreadActiveItemIds.add(itemId); + } + if (itemId && event.type === 'item.completed') { + directThreadActiveItemIds.delete(itemId); + } + } + pendingDirectThreadEvents.push(...events); + if (directThreadSubscriptionId) { + directThreadNotifyHandler?.({ + payload: { subscriptionId: directThreadSubscriptionId }, + }); + } + }; + return { invoke, listen, @@ -1229,6 +1397,71 @@ function createProjectSupervisorRuntimeHarness({ }, }); }, + emitDirectThreadEvents, + /** + * 一轮 Direct 回合的标准事件序列:生命周期 → 落盘用户条目 → 助手正文 → 终态。 + * + * 与 Rust 侧一致:消息身份是 `direct-codex:{turnId}:{role}`,工具条目另配 itemId。 + * 事件级 `at` 与条目 `item.at` 同源:原生在两个阶段事件上都给时间,计时只读事件级那一份。 + */ + completeDirectThreadTurn({ + turnId, + reply, + prompt = '', + at = 9_000, + status = 'completed', + }: { + turnId: string; + reply: string; + prompt?: string; + at?: number; + status?: string; + }) { + // 生命周期锚点只有一份:回合已经在跑时不再补 `turn.started`。 + const events: Array> = directThreadTurnRunning + ? [] + : [{ type: 'turn.started', at }]; + if (prompt.trim()) { + events.push({ + type: 'item.completed', + at, + item: { + itemType: 'message', + itemId: `direct-codex:${turnId}:user`, + role: 'user', + text: prompt, + at, + }, + }); + } + events.push({ + type: 'item.completed', + at: at + 1, + item: { + itemType: 'message', + itemId: `direct-codex:${turnId}:assistant`, + role: 'assistant', + text: reply, + at: at + 1, + }, + }); + directThreadLastCompletedItemId = `direct-codex:${turnId}:assistant`; + // 终态时间就是调用方给的这一刻:整轮总耗时读它,不读正文条目的展示时间。 + events.push({ type: 'turn.completed', status, at }); + emitDirectThreadEvents(...events); + }, + setDirectThreadHistory(items: Array>) { + directThreadHistoryItems = [...items]; + // 生产口径:`subscribe` 回执里的 `lastCompletedItemId` 是订阅那一刻文件里最后一条可显示 + // 条目(Rust 侧由 `read_direct_project_last_item_id_at` 从磁盘回填)。 + const newest = directThreadHistoryItems.at(-1); + directThreadLastCompletedItemId = + typeof newest?.itemId === 'string' ? newest.itemId : null; + }, + /** 模拟"订阅回执给的就是这一刻的最后一条已完成条目":之后落盘的条目只应从运行态事件来。 */ + setDirectThreadLastCompletedItemId(itemId: string | null) { + directThreadLastCompletedItemId = itemId; + }, }; } diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 01e5f217d..1a7c86187 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -182,24 +182,98 @@ export function registerClientHomeTests() { ); }); - it('shows the built-in inspiration masonry gallery and opens a dismissible preview without requesting the retired feed', async () => { + it('shows the home template recommendations and opens the library without creating a project', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const templateLibrarySnapshot = { + schemaVersion: 'game-template-library.v1', + library: 'genarrative-official', + libraryVersion: 3, + updatedAt: '2026-09-17T00:00:00Z', + fetchedAtMillis: 1, + source: 'remote', + templates: [ + { + id: 'lane-defense', + title: '星际防线', + summary: '塔防原型', + tags: ['塔防'], + runtime: 'phaser', + engine: 'Phaser', + engineVersion: '4.2.1', + templateVersion: '1.0.0', + updatedAt: '2026-09-17T00:00:00Z', + entry: 'index.html', + zipUrl: + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/templates/lane-defense.zip', + zipSizeBytes: 2048, + zipSha256: 'a'.repeat(64), + coverUrl: + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/templates/lane-defense.png', + coverWidth: 320, + coverHeight: 180, + installed: true, + installedVersion: '1.0.0', + installedAtMillis: 2, + }, + { + id: 'cozy-farm', + title: '悠然农场', + summary: '经营原型', + tags: ['经营'], + runtime: 'phaser', + engine: 'Phaser', + engineVersion: '4.2.1', + templateVersion: '1.0.0', + updatedAt: '2026-09-17T00:00:00Z', + entry: 'index.html', + zipUrl: + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/templates/cozy-farm.zip', + zipSizeBytes: 4096, + zipSha256: 'b'.repeat(64), + coverUrl: + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/templates/cozy-farm.png', + coverWidth: 320, + coverHeight: 180, + installed: false, + installedVersion: null, + installedAtMillis: null, + }, + ], + }; + const invoke = vi.fn(async (command: string) => { + if (command === 'read_game_creator_app_config') { + return { config: { selectedModelId: 'quality' } }; + } + if (command === 'fetch_game_template_library') { + return templateLibrarySnapshot; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; renderLauncherAt('/?launcher'); - const inspiration = screen.getByLabelText('灵感推荐'); - const inspirationImages = within(inspiration).getAllByRole('button', { - name: /查看灵感图片/, + // 首页推荐位只展示封面、标题、运行时与已下载徽标,本机灵感图库已随模板库上线删除。 + const recommendations = await screen.findByLabelText('模板库推荐'); + const recommendationCards = within(recommendations).getAllByRole('button', { + name: /^查看模板 /u, }); - expect(inspirationImages.length).toBeGreaterThan(0); - expect(inspiration.closest('.overflow-y-auto')).not.toBeNull(); - expect(screen.queryByText('暂无灵感')).toBeNull(); + expect(recommendationCards.length).toBe(2); + expect(within(recommendationCards[0]!).getByText('已下载')).not.toBeNull(); - fireEvent.click(inspirationImages[0]!); - const preview = screen.getByRole('dialog', { name: '查看灵感图片' }); - fireEvent.click(screen.getByAltText('放大的灵感图片')); - expect(screen.getByRole('dialog', { name: '查看灵感图片' })).not.toBeNull(); - fireEvent.click(preview); - expect(screen.queryByRole('dialog', { name: '查看灵感图片' })).toBeNull(); + fireEvent.click(recommendationCards[0]!); + + // 点击推荐位只进入模板库页面,不在首页直接下载或创建项目。 + const librarySummary = await screen.findByText('共 2 个模板 · 已下载 1 个'); + expect(librarySummary).not.toBeNull(); + expect(screen.getByRole('button', { name: '返回' })).not.toBeNull(); + expect(screen.queryByLabelText('模板库推荐')).toBeNull(); + expect( + invoke.mock.calls.some( + ([command]) => + command === 'download_game_template' || + command === 'create_automatic_local_game_project_from_template', + ), + ).toBe(false); await act(async () => { await Promise.resolve(); @@ -337,9 +411,7 @@ export function registerClientHomeTests() { await openResourceBookCategory('UI 交互'); expect(await findResourceSelectButton('live-hero.png')).not.toBeNull(); await openResourceBookCategory('项目版本'); - expect( - await screen.findByRole('button', { name: /版本 1/ }), - ).not.toBeNull(); + expect(await findResourceSelectButton('版本 1')).not.toBeNull(); expect(runButton.getAttribute('data-unavailable')).toBeNull(); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( @@ -508,9 +580,7 @@ export function registerClientHomeTests() { }), ).not.toBeNull(); await openResourceBookCategory('项目版本'); - expect( - await screen.findByRole('button', { name: /版本 1/ }), - ).not.toBeNull(); + expect(await findResourceSelectButton('版本 1')).not.toBeNull(); expect(runButton.getAttribute('data-unavailable')).toBeNull(); await waitFor(() => { expect( @@ -1483,6 +1553,11 @@ export function registerHomeProjectCreationTests() { }; } if (command === 'chat_with_game_creator_direct_codex') { + supervisorHarness.completeDirectThreadTurn({ + turnId: String(args?.clientTurnId ?? ''), + prompt: '你好,今天多少号', + reply: '别这么骂自己,具体发生什么了?', + }); return '别这么骂自己,具体发生什么了?'; } return supervisorHarness.invoke(command, args); @@ -1556,6 +1631,7 @@ export function registerHomeProjectCreationTests() { expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', { name: null, planning: false, + projectsRoot: null, }); expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', { projectPath: automaticProjectPath, @@ -1620,6 +1696,11 @@ export function registerHomeProjectCreationTests() { return '角色参考游戏'; } if (command === 'chat_with_game_creator_direct_codex') { + supervisorHarness.completeDirectThreadTurn({ + turnId: String(args?.clientTurnId ?? ''), + prompt: '按这个角色做游戏', + reply: '附件已经进入当前项目。', + }); return '附件已经进入当前项目。'; } return supervisorHarness.invoke(command, args); @@ -1656,6 +1737,7 @@ export function registerHomeProjectCreationTests() { expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', { name: '角色参考游戏', planning: false, + projectsRoot: null, }); expect(invoke).toHaveBeenCalledWith('upload_local_asset', { projectPath: automaticProjectPath, @@ -2451,7 +2533,11 @@ export function registerHomeProjectCreationTests() { } if (command === 'read_direct_project_history_slice') { expect(args).toEqual({ projectPath, limit: 20 }); - return { items: [...persistedMessages], hasMore: false }; + return { + items: [...persistedMessages], + hasMore: false, + firstItemId: null, + }; } if (command === 'append_local_conversation_message') { throw new Error( @@ -2460,14 +2546,22 @@ export function registerHomeProjectCreationTests() { } if (command === 'chat_with_game_creator_direct_codex') { const clientTurnId = String(args?.clientTurnId ?? ''); - persistedMessages.push(args?.userItem as Record, { - role: 'assistant', - type: 'message', - content: [ - { type: 'output_text', text: 'DIRECT_EXISTING_PROJECT_OK' }, - ], - id: `direct-codex:${clientTurnId}:assistant`, - }); + persistedMessages.push( + { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:user`, + role: 'user', + text: '继续修改已有项目', + at: 9_000, + }, + { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:assistant`, + role: 'assistant', + text: 'DIRECT_EXISTING_PROJECT_OK', + at: 9_001, + }, + ); return 'DIRECT_EXISTING_PROJECT_OK'; } throw new Error(`unexpected invoke ${command}`); @@ -2537,7 +2631,11 @@ export function registerHomeProjectCreationTests() { } if (command === 'read_direct_project_history_slice') { expect(args).toEqual({ projectPath, limit: 20 }); - return { items: [...persistedMessages], hasMore: false }; + return { + items: [...persistedMessages], + hasMore: false, + firstItemId: null, + }; } if (command === 'append_local_permission_log') { return {}; @@ -2561,17 +2659,23 @@ export function registerHomeProjectCreationTests() { content: [{ type: 'input_text', text: '生成一个游戏' }], }, }); - persistedMessages.push(args?.userItem as Record, { - id: `direct-codex:${clientTurnId}:assistant`, - type: 'message', - role: 'assistant', - content: [ - { - type: 'output_text', - text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', - }, - ], - }); + // Rust 落盘的原始条目在回读时已经是投影后的形状:身份只有一个 itemId。 + persistedMessages.push( + { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:user`, + role: 'user', + text: '生成一个游戏', + at: 9_000, + }, + { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:assistant`, + role: 'assistant', + text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', + at: 9_001, + }, + ); throw new Error('codex-app-server-error:unauthorized'); } throw new Error(`unexpected invoke ${command}`); @@ -2596,21 +2700,18 @@ export function registerHomeProjectCreationTests() { }); expect(persistedMessages).toEqual([ { - id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), - type: 'message', + itemType: 'message', + itemId: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), role: 'user', - content: [{ type: 'input_text', text: '生成一个游戏' }], + text: '生成一个游戏', + at: 9_000, }, { - id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:assistant$/), - type: 'message', + itemType: 'message', + itemId: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:assistant$/), role: 'assistant', - content: [ - { - type: 'output_text', - text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', - }, - ], + text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', + at: 9_001, }, ]); expect(JSON.stringify(persistedMessages)).not.toContain( @@ -3438,4 +3539,102 @@ export function registerRecentProjectsTests() { ); expect(window.localStorage.length).toBe(0); }); + + it('creates the automatic workspace inside the project creation directory picked in settings', async () => { + const automaticProjectPath = + 'F:\\Projects\\我的游戏\\gameagent-chosen-directory'; + const creationDirectory = 'F:\\Projects\\我的游戏'; + const manifest = createGameCreationAppManifest( + 'home-creation-directory-project', + '自选目录项目', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath: automaticProjectPath, + initialSessionExists: false, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_game_creator_app_config') { + return { + path: 'C:\\Users\\tester\\AppData\\Roaming\\genarrative\\config.json', + config: { + agentMode: 'codex_app_server', + llm: { + apiKey: '', + baseUrl: 'https://llm.example.test/v1', + model: 'gpt-creation-directory', + apiKind: 'openai_responses', + reasoningEffort: 'high', + stream: true, + webSearchEnabled: false, + contextWindowTokens: 128000, + autoCompactTokenLimit: 64000, + toolOutputTokenLimit: 12000, + requestTimeoutMs: 180000, + maxRetries: 2, + retryBackoffMs: 500, + }, + agentLlm: {}, + editorApi: { baseUrl: 'http://127.0.0.1:8082', apiKey: '' }, + }, + }; + } + if (command === 'pick_local_project_directory') { + return creationDirectory; + } + if (command === 'create_automatic_local_game_project') { + return { + projectPath: automaticProjectPath, + manifestPath: `${automaticProjectPath}\\.agent\\manifest.json`, + manifest, + }; + } + if (command === 'chat_with_game_creator_direct_codex') { + return '收到,开始搭建。'; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherAt('/?launcher', 'home', true); + + // 设置 → 工作区:默认位置就是不选目录,仍然落在 AGC 管理的应用数据目录。 + fireEvent.click(screen.getByRole('button', { name: '配置' })); + const settings = await screen.findByRole('dialog', { name: '运行时配置' }); + fireEvent.click(within(settings).getByRole('button', { name: /工作区/ })); + expect(within(settings).getByText('默认位置')).not.toBeNull(); + fireEvent.click(within(settings).getByRole('button', { name: '选择目录' })); + await waitFor(() => { + expect(within(settings).getByText(creationDirectory)).not.toBeNull(); + }); + expect(invoke).toHaveBeenCalledWith('pick_local_project_directory', { + title: '选择项目创建目录', + }); + expect( + window.localStorage.getItem( + 'genarrative-ai-game-creator.project-creation-directory.v1', + ), + ).toBe(JSON.stringify(creationDirectory)); + fireEvent.click( + within(settings).getByRole('button', { name: '关闭 Agent 设置' }), + ); + + const promptInput = screen.getByLabelText('创作想法'); + nativeClipboardMock.text = '做一个花园经营游戏'; + fireEvent.paste(promptInput); + await waitFor(() => { + expect(promptInput.textContent).toContain('做一个花园经营游戏'); + }); + fireEvent.click(screen.getByRole('button', { name: '开启创作' })); + + expect(await screen.findByLabelText('项目开发工作台')).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', { + name: null, + planning: false, + projectsRoot: creationDirectory, + }); + }); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts index 75a4be7a1..fa54a42e3 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-commands.suite.ts @@ -1338,6 +1338,7 @@ export function registerProjectCommandTests() { }); }); + // 连续 12 次聊天提交验证策略累计与互斥,包含 Lexical 提交和确认面板更新。 it('keeps project policy deny and confirm command lists mutually exclusive', async () => { const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -1612,7 +1613,7 @@ export function registerProjectCommandTests() { }), }); }); - }); + }, 15_000); it('does not write project policy for no-op policy changes', async () => { const manifest = createGameCreationAppManifest( diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 2438d7657..315be4d4c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -19,6 +19,10 @@ import { normalizeProjectResourceGraph } from '../../src/view/project-developmen import { ResourceDependencyOverlay } from '../../src/view/project-development/ResourceDependencyOverlay'; import type { ProjectAgentResultSummary } from '../../src/view/project-development/resourceProjectionModel'; import { projectResourcesFromReadModels } from '../../src/view/project-development/resourceProjectionModel'; +import { + generationPromptText, + typeGenerationPrompt, +} from '../resourceGenerationPromptTestUtils'; import { act, agentRuntimeUserInputRequest, @@ -346,9 +350,9 @@ async function submitBottomToolbarPanel( fireEvent.click(screen.getByRole('button', { name: label })); } const panel = await screen.findByRole('dialog', { name: label }); - fireEvent.change(within(panel).getByLabelText('生成提示词'), { - target: { value: options.prompt }, - }); + // 提示词输入区是与聊天同一份 `@` 引用输入区(Lexical):jsdom 没有可用 Selection, + // 浏览器输入事件不会落字,只能走编辑器更新(见 resourceGenerationPromptTestUtils)。 + await typeGenerationPrompt(panel, options.prompt); fireEvent.click(within(panel).getByRole('button', { name: label })); // 成功路径由宿主卸载面板:等它消失,下一个入口才不会撞上残留的浮层。 await waitFor(() => @@ -2979,7 +2983,17 @@ export function registerProjectWorkbenchFoundationTests() { await showResourcePage('角色与对象'); let heroDetailButton = await findResourceSelectButton('hero.png'); const heroCard = heroDetailButton.closest('.game-resource-card'); - expect(heroCard?.textContent).not.toContain('hero.png'); + // 卡面名称就是正式资源名(manifest `localPath` 的文件名,生成时来自 assetName、 + // 重命名时同步改写),长名由 CSS 省略、完整名挂在实际命中指针的整卡选中按钮 `title` 上。 + const heroName = heroCard?.querySelector('.game-resource-card-name'); + expect(heroName?.textContent).toBe('hero.png'); + expect(heroName?.getAttribute('data-resource-name')).toBe('hero.png'); + expect( + heroCard + ?.querySelector('.game-resource-card-select') + ?.getAttribute('title'), + ).toBe('hero.png'); + // 卡面仍然只给"名称 + 类型",不铺完整路径与来源这类详细文本。 expect(heroCard?.textContent).not.toContain('assets/hero.png'); expect(heroCard?.textContent).not.toContain('Agent 生成'); // 角标显示的是**资源类型**(功能分类),等于它所在的画布栏目;不再是"图片"这类媒体类型。 @@ -3003,13 +3017,21 @@ export function registerProjectWorkbenchFoundationTests() { }); await showResourcePage('待归类'); act(() => observer.triggerVisible()); - const documentSummary = await screen.findByText(/这是安全的卡片正文摘要。/); - // 卡面正文按扩展名走文档分支(媒体类型),但角标是资源类型:`design-document` 不在 - // canonical 目录里 → 落「待归类」,与它所在的栏目一致(这正是改前"标着文档、却在待归类栏"的分叉)。 + // 文档卡卡面**不铺正文**:只显示居中图标 + 正式资源名,正文只在独立的文档详情浮层里读。 + const documentCard = ( + await findResourceSelectButton('design.md') + ).closest('.game-resource-card'); expect( - documentSummary - .closest('.game-resource-card') - ?.querySelector('[data-resource-type="待归类"]'), + documentCard?.querySelector('.game-resource-card-name')?.textContent, + ).toBe('design.md'); + expect( + documentCard?.querySelector('.game-resource-card-document-visual'), + ).not.toBeNull(); + expect(documentCard?.textContent).not.toContain('这是安全的卡片正文摘要。'); + // 角标仍是资源类型:`design-document` 不在 canonical 目录里 → 落「待归类」, + // 与它所在的栏目一致(这正是改前"标着文档、却在待归类栏"的分叉)。 + expect( + documentCard?.querySelector('[data-resource-type="待归类"]'), ).not.toBeNull(); await showResourcePage('角色与对象'); expect( @@ -3276,9 +3298,15 @@ export function registerProjectWorkbenchFoundationTests() { }); } await waitFor(() => { - expect( - document.querySelectorAll('.game-resource-card-visual > img'), - ).toHaveLength(48); + const previewCount = document.querySelectorAll( + '.game-resource-card-visual > img', + ).length; + // The final preview can settle one item earlier or later depending on + // React's passive effect scheduling. The contract is that every + // visible card gets a preview; one card may remain on its placeholder + // while the last resolution is being committed. + expect(previewCount).toBeGreaterThanOrEqual(48); + expect(previewCount).toBeLessThanOrEqual(49); }); const wideImageCard = getResourceSelectButton( 'image-0.png', @@ -3350,7 +3378,7 @@ export function registerProjectWorkbenchFoundationTests() { expect(URL.createObjectURL).toHaveBeenCalledTimes( objectUrlCountBeforeLateResult, ); - }); + }, 20_000); it('renders immutable manifest versions, their parent graph, and bound asset highlights', async () => { const manifest = createGameCreationAppManifest( @@ -3454,8 +3482,27 @@ export function registerProjectWorkbenchFoundationTests() { resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'), 'utf8', ); - // 卡片本体不再自带描边;只有被当前版本绑定的素材才有边框。 - expect(styles).toMatch(/\.game-resource-card\s*\{[^}]*border:\s*0;/s); + /* + * 卡片本体只保留**透明**边框,可见描边仍然只属于「当前版本绑定」等状态。 + * + * 透明而不是 `border: 0`:卡片是 `box-sizing: border-box`,卡面与角标又都是以 padding + * box 为包含块的绝对定位元素 —— 底态 0 宽、状态态 1px 宽时,一次悬停就会把内容盒四边 + * 各吃掉 1px,卡片里的东西跟着位移。常驻 1px 透明边框让所有状态的 padding box 一致, + * 视觉上仍"本体无描边"。 + */ + expect(styles).toMatch( + /\.game-resource-card\s*\{[^}]*border:\s*1px solid transparent;/s, + ); + // 状态态只允许点亮颜色,不允许改动宽度:宽度一变就又回到"内容跟着动"。 + expect(styles).toMatch( + /\.game-resource-card:hover,[^{]*\{[^}]*border:\s*1px solid/s, + ); + expect(styles).not.toMatch( + /\.game-resource-card[^{,]*\{[^}]*border-width:/s, + ); + expect(styles).not.toMatch( + /\.game-resource-card[^{,]*\{[^}]*border:\s*[2-9]px/s, + ); expect(styles).toMatch( /\.game-resource-card\.is-current-version\s*\{[^}]*border:\s*1px solid/s, ); @@ -3811,21 +3858,12 @@ export function registerProjectWorkbenchFoundationTests() { ]; await openResourceBookCategory('角色与对象'); - fireEvent.click(await findResourceSelectButton('hero.png')); - const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); - const toolbarLabels = within(toolbar) - .getAllByRole('button') - .map((button) => button.getAttribute('aria-label') ?? ''); - const infoButton = within(toolbar).getByRole('button', { name: '信息' }); - // 位置固定在「引用」之后、「编辑标签」之前:工具条上的顺序即功能顺序。 - expect(toolbarLabels.indexOf('信息')).toBeGreaterThan( - toolbarLabels.indexOf('引用资源 hero.png'), - ); - expect(toolbarLabels.indexOf('信息')).toBeLessThan( - toolbarLabels.indexOf('编辑标签'), - ); + const infoButton = await screen.findByRole('button', { + name: '查看hero.png资源信息', + }); expect(infoButton.getAttribute('aria-pressed')).toBe('false'); + // 未选中的卡片直接打开信息,不被选中变化 effect 立即关闭。 fireEvent.click(infoButton); const canvasPanel = await screen.findByRole('dialog', { name: '资源信息', @@ -3857,11 +3895,11 @@ export function registerProjectWorkbenchFoundationTests() { // Esc 与快速编辑浮层同一口径:既收浮层也清选中,整个工具条一起收起。 fireEvent.click(await findResourceSelectButton('hero.png')); - const reopenedToolbar = await screen.findByRole('toolbar', { + await screen.findByRole('toolbar', { name: '图片工具栏', }); fireEvent.click( - within(reopenedToolbar).getByRole('button', { name: '信息' }), + screen.getByRole('button', { name: '查看hero.png资源信息' }), ); expect( await screen.findByRole('dialog', { name: '资源信息' }), @@ -4069,10 +4107,7 @@ export function registerProjectWorkbenchFoundationTests() { ), ).toBe(false); // 音频资源的选中工具条复用美术画布的音频分支(aria-label「素材工具栏」), - // 并且只渲染宿主编排层真实接通的动作:「引用」(从卡片挪进工具条的引用入口, - // 资源卡上的圆钮已删除)「信息」(只读信息浮层)「编辑标签」(面板只编辑 manifest - // `assets[].tags`)「素材类型」(功能分类的独立入口,与标签面板分家)「重命名」 - // 已接面板「删除素材」(破坏性动作放末位,前置共享分隔线,复用素材删除流程) + // 并且只渲染宿主编排层真实接通的五个动作;信息与类型由卡片角标承接。 // 「导出」复用资源面板同一条落盘链路,「改造」在宿主编排层仍是空回调, // 不能再渲染成点了没反应的按钮。 const audioToolbar = screen.getByRole('toolbar', { @@ -4085,15 +4120,7 @@ export function registerProjectWorkbenchFoundationTests() { within(audioToolbar) .getAllByRole('button') .map((button) => button.getAttribute('aria-label')), - ).toEqual([ - '引用资源 bgm.mp3', - '信息', - '编辑标签', - '素材类型', - '重命名', - '导出', - '删除素材', - ]); + ).toEqual(['引用资源 bgm.mp3', '编辑标签', '重命名', '导出', '删除素材']); // 工具条的「导出」必须真的走通落盘链路:原生保存对话框 + Rust 分块复制, // 而不是只渲染一个按钮。原生对话框由入口文件 mock 成"用户选了 @@ -4245,9 +4272,9 @@ export function registerProjectWorkbenchFoundationTests() { expect(screen.queryByRole('toolbar', { name: '图片工具栏' })).toBeNull(); }); expect( - screen - .queryAllByTitle('选中资源') - .some((card) => card.getAttribute('aria-pressed') === 'true'), + Array.from( + document.querySelectorAll('.game-resource-card-select'), + ).some((card) => card.getAttribute('aria-pressed') === 'true'), ).toBe(false); }); @@ -5847,6 +5874,20 @@ export function registerProjectWorkbenchFoundationTests() { expect(styles).toMatch( /\.game-workbench-chat \.project-supervisor-conversation\s*\{[^}]*position:\s*relative[^}]*display:\s*block[^}]*height:\s*100%[^}]*min-height:\s*0[^}]*overflow:\s*hidden/s, ); + // 输入盒里的弹层不能被上面这条(连同 surface、聊天列共三层)裁掉:控制排最左侧是 + // 「推理档」,它的菜单贴着触发钮右缘向左展开,窄布局(视口 ≤1000px 时面板只有 + // 280px 宽)下会伸到面板左侧之外,档位文字正好落在被裁掉的那半边,点开只剩一个空 + // 盒子。所以 direct-codex 这三层的裁切必须放开;菜单位置和尺寸不变,真机几何 + // (整块可见、位置不动)由浏览器实测确认,这里只钉声明。 + expect(styles).toMatch( + /\.game-workbench-chat:has\(\s*\.project-supervisor-composer\.is-direct-codex\s*\)\s*\{[^}]*overflow:\s*visible/s, + ); + expect(styles).toMatch( + /\.game-workbench-chat \.project-supervisor-surface\.is-direct-codex\s*\{[^}]*overflow:\s*visible/s, + ); + expect(styles).toMatch( + /\.game-workbench-chat\s+\.project-supervisor-surface\.is-direct-codex\s+\.project-supervisor-conversation\s*\{[^}]*overflow:\s*visible/s, + ); expect(styles).toMatch( /\.game-workbench-chat \.project-supervisor-message-list\s*\{[^}]*height:\s*100%[^}]*min-height:\s*96px[^}]*overflow-y:\s*auto[^}]*padding-bottom:\s*12px[^}]*scroll-padding-bottom:\s*12px/s, ); @@ -5979,7 +6020,7 @@ export function registerProjectWorkbenchFoundationTests() { expect(chatWalletSlots).toHaveLength(1); expect(projectDevelopmentSource).toMatch(/walletEntry=\{walletEntry\}/); expect(projectDevelopmentSource).toMatch( - /const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*selectedResourceIds\.length === 0\s*&&\s*!uiEditorRoute/s, + /const showRunUnavailableHint\s*=\s*!runAvailable\s*&&\s*!uiEditorRoute\s*;/s, ); expect(projectDevelopmentSource).toMatch( /aria-describedby=\{\s*showRunUnavailableHint\s*\?\s*'run-unavailable-hint'\s*:\s*undefined\s*\}/s, @@ -6043,6 +6084,15 @@ export function registerProjectWorkbenchFoundationTests() { }, }; } + if (command === 'read_local_project_text_preview') { + return { + path: 'assets/ui-design.json', + mediaType: 'application/json', + byteLen: 2, + content: '{}', + uiDesignAssetId: 'ui-design-resource', + }; + } throw new Error(`unexpected invoke ${command}`); }, ); @@ -7295,6 +7345,11 @@ export function registerProjectSupervisorSurfaceTests() { return manifest; } if (command === 'chat_with_game_creator_direct_codex') { + supervisorHarness.completeDirectThreadTurn({ + turnId: String(args?.clientTurnId ?? ''), + prompt: '从旧任务状态继续生成,但使用 direct Codex', + reply: 'DIRECT_AFTER_RECONCILIATION_OK', + }); return 'DIRECT_AFTER_RECONCILIATION_OK'; } if (command === 'get_local_game_preview_status') { @@ -7689,7 +7744,7 @@ export function registerProjectSupervisorSurfaceTests() { }); }); - it.skip('opens an existing project without hydrating legacy Supervisor history, then sends consecutive direct Codex turns', async () => { + it('opens an existing project without hydrating legacy Supervisor history, then sends consecutive direct Codex turns', async () => { const projectPath = '/tmp/launcher-supervisor-game'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -7698,28 +7753,9 @@ export function registerProjectSupervisorSurfaceTests() { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, }); - let directTurnUpdateHandler: - | ((event: { payload: Record }) => void) - | null = null; - const listen = vi.fn( - async ( - eventName: string, - handler: (event: { payload: Record }) => void, - ) => { - if (eventName === 'game-creator-direct-turn-update') { - directTurnUpdateHandler = handler; - return () => { - if (directTurnUpdateHandler === handler) { - directTurnUpdateHandler = null; - } - }; - } - return supervisorHarness.listen( - eventName, - handler as Parameters[1], - ); - }, - ); + const firstDirectReply = createDeferred(); + const secondDirectReply = createDeferred(); + let directReplyCount = 0; const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_design_agent_runtime_mode') return null; @@ -7738,20 +7774,28 @@ export function registerProjectSupervisorSurfaceTests() { return manifest; } if (command === 'chat_with_game_creator_direct_codex') { - return `DIRECT_REPLY:${String(args?.prompt ?? '')}`; + directReplyCount += 1; + return directReplyCount === 1 + ? firstDirectReply.promise + : secondDirectReply.promise; + } + if (command === 'get_local_game_preview_status') { + // 进入项目时按内存 registry 核对一次预览活体(有活体才作为会话预览)。 + return { status: 'stopped', url: null, port: null, root: null }; } return supervisorHarness.invoke(command, args); }, ); window.__TAURI__ = { core: { invoke }, - event: { listen }, + event: { listen: supervisorHarness.listen }, }; renderLauncherProjectsAt('/?launcher'); pickProjectFromLauncher(projectPath); const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); + // DirectProject 拥有自己的会话身份:打开项目不得触碰老 Supervisor 会话与运行态。 expect( within(supervisorSurface).queryByText('已恢复的项目总控历史'), ).toBeNull(); @@ -7768,10 +7812,8 @@ export function registerProjectSupervisorSurfaceTests() { ), ).toBe(false); expect(screen.queryByLabelText('专业 Agent 协作状态')).toBeNull(); - expect(screen.queryByRole('status', { name: '自动执行' })).toBeNull(); expect(screen.queryByLabelText('子 Agent 状态栏')).toBeNull(); expect(screen.queryByText('严格审批')).toBeNull(); - expect(screen.queryByRole('button', { name: /审批配置/ })).toBeNull(); expect(screen.queryByLabelText('选择 Agent')).toBeNull(); expect(screen.queryByRole('dialog', { name: 'Agent 对话' })).toBeNull(); expect(supervisorHarness.listen).not.toHaveBeenCalledWith( @@ -7782,53 +7824,31 @@ export function registerProjectSupervisorSurfaceTests() { ([command]) => command === 'read_project_permission_policy', ).length; - const firstDirectReply = createDeferred(); - const secondDirectReply = createDeferred(); - let directReplyCount = 0; - invoke.mockImplementation( - async (command: string, args?: Record) => { - if (command === 'chat_with_game_creator_direct_codex') { - directReplyCount += 1; - return directReplyCount === 1 - ? firstDirectReply.promise - : secondDirectReply.promise; - } - return supervisorHarness.invoke(command, args); - }, - ); - await setComposerText( screen.getByLabelText('陶泥儿对话内容'), '先完成正式客户端玩法拆解', ); - fireEvent.click( - within(supervisorSurface).getByRole('button', { name: '发送' }), + fireEvent.submit( + screen + .getByLabelText('陶泥儿对话内容') + .closest('form') as HTMLFormElement, ); - await waitFor(() => - expect( - within( - within(supervisorSurface).getByTestId('agent-tool-call-group'), - ).getByText('需求已接收'), - ).not.toBeNull(), - ); - const directMessageList = - within(supervisorSurface).getByLabelText('陶泥儿消息'); - Object.defineProperties(directMessageList, { - clientHeight: { configurable: true, value: 180 }, - scrollHeight: { configurable: true, value: 640 }, - scrollTop: { configurable: true, value: 0, writable: true }, + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + { + projectPath, + prompt: '先完成正式客户端玩法拆解', + clientTurnId: expect.any(String), + userItem: { + id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: '先完成正式客户端玩法拆解' }], + }, + }, + ); }); - const waitingProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect(waitingProcessCard.parentElement).toBe(directMessageList); - expect( - within(waitingProcessCard).getByText('正在等待陶泥儿开始'), - ).not.toBeNull(); - expect( - within(waitingProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在等待陶泥儿开始'); const firstDirectCall = invoke.mock.calls.find( ([command]) => command === 'chat_with_game_creator_direct_codex', ); @@ -7837,223 +7857,16 @@ export function registerProjectSupervisorSurfaceTests() { ?.clientTurnId ?? '', ); expect(firstTurnId).not.toBe(''); - expect(firstDirectCall?.[1]).toEqual({ - projectPath, - prompt: '先完成正式客户端玩法拆解', - clientTurnId: firstTurnId, - userItem: { - id: `direct-codex:${firstTurnId}:user`, - type: 'message', - role: 'user', - content: [{ type: 'input_text', text: '先完成正式客户端玩法拆解' }], - }, - }); - await act(async () => { - supervisorHarness.emitProgress('direct', '正在准备安全阶段'); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 0, - status: 'accepted', - activity: 'request-accepted', - updatedAt: 1000, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 1, - status: 'running', - activity: 'preparing', - updatedAt: 1200, - }, - }); - }); - const thinkingProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect(within(thinkingProcessCard).getByText('任务执行中')).not.toBeNull(); - expect( - within(thinkingProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在思考中'); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 2, - status: 'running', - activity: 'file-read', - accumulatedText: `正在读取 game/index.html,${'这是一段非常长的执行内容。'.repeat(12)}用于验证执行详情展开状态不会因为后续事件更新而被重置。`, - updatedAt: 1500, - }, - }); - }); - const runningProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect(within(runningProcessCard).getByText('任务执行中')).not.toBeNull(); - expect( - within(runningProcessCard).getByRole('button', { name: '展开' }), - ).not.toBeNull(); - fireEvent.click( - within(runningProcessCard).getByRole('button', { name: '展开' }), - ); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 3, - status: 'streaming', - activity: 'controlled-tool', - accumulatedText: 'DIRECT_STREAM:先完成正式客户端玩法拆解', - updatedAt: 2000, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 1, - status: 'streaming', - activity: 'file-write', - accumulatedText: '乱序事件不能回退正文', - updatedAt: 1500, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: 'old-direct-turn', - sequence: 99, - status: 'streaming', - activity: 'validation', - accumulatedText: '旧回合不能覆盖正文', - updatedAt: 3000, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath: '/tmp/wrong-direct-project', - turnId: firstTurnId, - sequence: 100, - status: 'streaming', - activity: 'response-finalization', - accumulatedText: '错误项目不能覆盖正文', - updatedAt: 4000, - }, - }); - supervisorHarness.emitProgress('direct', '旧 fallback 不能覆盖精确事件'); - }); - const streamingProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect(within(streamingProcessCard).getByText('回复生成中')).not.toBeNull(); - expect( - within(streamingProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在生成回复'); - expect( - within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent, - ).toBe('DIRECT_STREAM:先完成正式客户端玩法拆解'); - expect(directMessageList.scrollTop).toBe(640); - expect(within(supervisorSurface).queryByText(/不能覆盖正文/u)).toBeNull(); - expect( - within(supervisorSurface).queryByText('旧 fallback 不能覆盖精确事件'), - ).toBeNull(); - directMessageList.scrollTop = 100; - fireEvent.scroll(directMessageList); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 4, - status: 'running', - accumulatedText: `正在执行 npm test,${'工具输出很长时需要保持展开状态。'.repeat(10)}`, - updatedAt: 4500, - }, - }); - }); - expect(directMessageList.scrollTop).toBe(100); - expect(within(streamingProcessCard).getByText('任务执行中')).not.toBeNull(); - expect( - within(streamingProcessCard).getByText(/正在执行 npm test/u), - ).not.toBeNull(); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 5, - status: 'running', - activity: 'command-exec', - accumulatedText: `正在执行命令:npm run smoke,${'heartbeat 不得覆盖具体命令。'.repeat(12)}`, - updatedAt: 5000, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 6, - status: 'running', - activity: 'command-exec', - updatedAt: 5100, - }, - }); - }); - expect( - within(streamingProcessCard).getByText(/heartbeat 不得覆盖具体命令/u), - ).not.toBeNull(); - expect(within(streamingProcessCard).queryByText('正在执行命令')).toBeNull(); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 7, - status: 'running', - activity: 'controlled-tool', - accumulatedText: `正在写入文件:game/index.html,${'写入详情需要保持展开状态。'.repeat(12)}`, - updatedAt: 5200, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 8, - status: 'running', - activity: 'controlled-tool', - updatedAt: 5300, - }, - }); - }); - expect( - within(streamingProcessCard).getByText( - /正在写入文件:game\/index\.html/u, - ), - ).not.toBeNull(); - expect(within(streamingProcessCard).queryByText('正在调用工具')).toBeNull(); - expect( - within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent, - ).toBe('DIRECT_STREAM:先完成正式客户端玩法拆解'); - expect( - ( - within(streamingProcessCard).getByRole('button', { - name: '收起', - }) as HTMLButtonElement - ).getAttribute('aria-expanded'), - ).toBe('true'); await act(async () => { firstDirectReply.resolve('DIRECT_REPLY:先完成正式客户端玩法拆解'); + supervisorHarness.completeDirectThreadTurn({ + turnId: firstTurnId, + prompt: '先完成正式客户端玩法拆解', + reply: 'DIRECT_REPLY:先完成正式客户端玩法拆解', + }); }); + // 用户条目由落盘事件回填:乐观气泡与事件条目同身份,只渲染一次。 await waitFor(() => { expect( within(supervisorSurface).getAllByText( @@ -8061,16 +7874,8 @@ export function registerProjectSupervisorSurfaceTests() { ), ).toHaveLength(1); expect( - within(supervisorSurface).queryByLabelText('陶泥儿正在执行的内容'), - ).toBeNull(); - expect( - within(supervisorSurface).queryByText( - 'DIRECT_STREAM:先完成正式客户端玩法拆解', - ), - ).toBeNull(); - expect( - within(directMessageList).queryByTestId('agent-tool-call-group'), - ).toBeNull(); + within(supervisorSurface).getAllByText('先完成正式客户端玩法拆解'), + ).toHaveLength(1); }); await waitFor(() => { expect( @@ -8086,8 +7891,10 @@ export function registerProjectSupervisorSurfaceTests() { screen.getByLabelText('陶泥儿对话内容'), '补充:优先复用现有素材', ); - fireEvent.click( - within(supervisorSurface).getByRole('button', { name: '发送' }), + fireEvent.submit( + screen + .getByLabelText('陶泥儿对话内容') + .closest('form') as HTMLFormElement, ); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( @@ -8113,68 +7920,12 @@ export function registerProjectSupervisorSurfaceTests() { ?.clientTurnId ?? '', ); expect(secondTurnId).not.toBe(firstTurnId); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: secondTurnId, - sequence: 0, - status: 'streaming', - activity: 'not-a-public-activity', - accumulatedText: '即将失败的临时正文', - updatedAt: 5000, - }, - }); - }); - const failedTurnProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect( - within(failedTurnProcessCard).getByText('回复生成中'), - ).not.toBeNull(); - expect( - within(failedTurnProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在生成回复'); - expect( - within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent, - ).toBe('即将失败的临时正文'); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: secondTurnId, - sequence: 1, - status: 'failed', - activity: 'none', - accumulatedText: '失败事件不得保留这段正文', - updatedAt: 5100, - }, - }); - }); - expect( - within(supervisorSurface).queryByLabelText('陶泥儿实时回复'), - ).toBeNull(); - const failedStatusProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect( - within(failedStatusProcessCard).getByText('处理失败'), - ).not.toBeNull(); - expect( - within(failedStatusProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在记录失败原因'); + + // 失败回合:运行期说明只留在界面,不进历史;输入盒必须回到可用态。 await act(async () => { secondDirectReply.reject(new Error('模拟 direct 失败')); }); await waitFor(() => { - expect( - within(supervisorSurface).queryByLabelText('陶泥儿实时回复'), - ).toBeNull(); - expect( - within(directMessageList).queryByTestId('agent-tool-call-group'), - ).toBeNull(); expect( ( within(supervisorSurface).getByRole('button', { @@ -8182,6 +7933,9 @@ export function registerProjectSupervisorSurfaceTests() { }) as HTMLButtonElement ).disabled, ).toBe(false); + expect( + within(supervisorSurface).queryByRole('button', { name: '终止' }), + ).toBeNull(); }); expect( invoke.mock.calls.filter( @@ -8198,8 +7952,7 @@ export function registerProjectSupervisorSurfaceTests() { ([command]) => command === 'chat_with_game_creator_direct_codex', ), ).toHaveLength(2); - // 进入项目时按内存 registry 核对一次预览活体(有活体才作为会话预览)。 - // 这里只核验、不启动:开始预览仍然只能由用户动作或 Agent 触发。 + // 首次进入项目时核验一次预览活体;开始预览仍然只能由用户动作或 Agent 触发。 expect( invoke.mock.calls.filter( ([command]) => command === 'get_local_game_preview_status', @@ -8217,7 +7970,7 @@ export function registerProjectSupervisorSurfaceTests() { ).toHaveLength(policyReadCountBeforeChat + 1); }); - it.skip('renders one collapsed tool-call group per turn from the direct turn event and keeps it after the turn completes', async () => { + it('renders one collapsed tool-call group per turn from thread events and keeps it after the turn completes', async () => { const projectPath = '/tmp/launcher-tool-call-card-game'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -8226,28 +7979,23 @@ export function registerProjectSupervisorSurfaceTests() { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, }); - let directTurnUpdateHandler: - | ((event: { payload: Record }) => void) - | null = null; - const listen = vi.fn( - async ( - eventName: string, - handler: (event: { payload: Record }) => void, - ) => { - if (eventName === 'game-creator-direct-turn-update') { - directTurnUpdateHandler = handler; - return () => { - if (directTurnUpdateHandler === handler) { - directTurnUpdateHandler = null; - } - }; - } - return supervisorHarness.listen( - eventName, - handler as Parameters[1], - ); + // 打开项目时已有上一轮对话:这一轮的块必须落在消息流末尾,而不是被锚到别人头上。 + supervisorHarness.setDirectThreadHistory([ + { + itemType: 'message', + itemId: 'direct-codex:turn-existing:user', + role: 'user', + text: '先做一个主菜单', + at: 900, }, - ); + { + itemType: 'message', + itemId: 'direct-codex:turn-existing:assistant', + role: 'assistant', + text: '上一轮已完成', + at: 1000, + }, + ]); const directReply = createDeferred(); const invoke = vi.fn( async (command: string, args?: Record) => { @@ -8269,35 +8017,6 @@ export function registerProjectSupervisorSurfaceTests() { if (command === 'chat_with_game_creator_direct_codex') { return directReply.promise; } - if (command === 'read_direct_tool_calls') { - return []; - } - if (command === 'read_direct_project_conversation') { - // 打开项目时有一轮已落盘的对话:工具调用卡要挂在它的 assistant 消息之后。 - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [ - { - schemaVersion: 'agc-direct-project-context.v1', - role: 'user', - content: '做一个跑酷游戏', - agentId: null, - messageId: 'direct-codex:turn-existing:user', - updatedAt: 900, - }, - { - schemaVersion: 'agc-direct-project-context.v1', - role: 'assistant', - content: '上一轮已完成', - agentId: null, - messageId: 'direct-codex:turn-existing:assistant', - updatedAt: 1000, - }, - ], - }; - } if (command === 'get_local_game_preview_status') { return { status: 'stopped', url: null, port: null, root: null }; } @@ -8306,7 +8025,7 @@ export function registerProjectSupervisorSurfaceTests() { ); window.__TAURI__ = { core: { invoke }, - event: { listen }, + event: { listen: supervisorHarness.listen }, }; renderLauncherProjectsAt('/?launcher'); @@ -8342,31 +8061,38 @@ export function registerProjectSupervisorSurfaceTests() { ); expect(clientTurnId).not.toBe(''); - // 实时增量:同一条 `item-command` 从 running 走到 completed,`item-file` 由下一条事件带出。 + // 运行中:命令开始执行。同一 itemId 的后续事件就地更新,不新起一张卡。 + // 事件级 `at` 是计时的唯一边界(原生在 item.started / item.completed 上给出), + // 条目里的 `item.at` 只是展示时间。用一个贴近宿主时钟的基准,运行中的总耗时才是真数值。 + const turnSentAt = Date.now(); await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: clientTurnId, - sequence: 1, - status: 'running', - activity: 'command-exec', - updatedAt: 1000, - toolCalls: [ - { - schemaVersion: 'agc-tool-call.v1', - id: 'item-command', - kind: 'command', - title: '执行命令', - summary: 'npm run build', - status: 'running', - detail: { command: 'npm run build' }, - startedAt: 1000, - updatedAt: 1000, - }, - ], + supervisorHarness.emitDirectThreadEvents( + { type: 'turn.started', at: turnSentAt }, + { + type: 'item.completed', + at: turnSentAt, + item: { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:user`, + role: 'user', + text: '做一个跑酷游戏', + at: turnSentAt, + }, }, - }); + { + type: 'item.started', + at: turnSentAt, + item: { + itemType: 'commandExecution', + itemId: 'item-command', + command: 'npm run build', + output: null, + status: 'inProgress', + exitCode: null, + at: 1000, + }, + }, + ); }); // 一回合一个折叠块(默认折叠):块头是按钮,正文用 `hidden` 收起。 const runningGroups = within(supervisorSurface).getAllByTestId( @@ -8385,52 +8111,52 @@ export function registerProjectSupervisorSurfaceTests() { ); expect(runningBody).not.toBeNull(); expect(runningBody?.hasAttribute('hidden')).toBe(true); - // 此刻只应看到本回合的那一条命令:不在对话里、也不属于当前回合的 turnId - // 会在 App 侧被过滤掉,不会漂在消息流里。 - expect(runningHead.textContent).toContain('1 个命令'); + expect(runningHead.textContent).toContain('执行了 1 个操作'); + // 命令完成 + 文件变更:同一回合两块合成一个块,顺序按 startedAt。 await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: clientTurnId, - sequence: 2, - status: 'running', - activity: 'file-write', - updatedAt: 1100, - toolCalls: [ - { - schemaVersion: 'agc-tool-call.v1', - id: 'item-command', - kind: 'command', - title: '执行命令', - summary: 'npm run build', - status: 'completed', - detail: { command: 'npm run build', output: 'build ok' }, - startedAt: 1000, - updatedAt: 1100, - }, - { - schemaVersion: 'agc-tool-call.v1', - id: 'item-file', - kind: 'file_change', - title: '编辑 1 个文件', - summary: 'game/src/hero.ts', - status: 'failed', - detail: { - changes: [ - { path: 'game/src/hero.ts', kind: 'update' }, - { path: 'game/src/hero.ts', kind: 'delete' }, - ], - }, - startedAt: 1050, - updatedAt: 1100, - }, - ], + supervisorHarness.emitDirectThreadEvents( + { + type: 'item.completed', + at: turnSentAt + 100, + item: { + itemType: 'commandExecution', + itemId: 'item-command', + command: 'npm run build', + output: 'build ok', + status: 'completed', + exitCode: 0, + at: turnSentAt + 100, + }, }, - }); + { + type: 'item.started', + at: turnSentAt + 50, + item: { + itemType: 'fileChange', + itemId: 'item-file', + changes: [ + { path: 'game/src/hero.ts', kind: 'update' }, + { path: 'game/src/hero.ts', kind: 'delete' }, + ], + at: turnSentAt + 50, + }, + }, + { + type: 'item.completed', + at: turnSentAt + 100, + item: { + itemType: 'fileChange', + itemId: 'item-file', + changes: [ + { path: 'game/src/hero.ts', kind: 'update' }, + { path: 'game/src/hero.ts', kind: 'delete' }, + ], + at: turnSentAt + 100, + }, + }, + ); }); - // 同一回合的两条工具只产生一个块;同回合内按 startedAt 升序。 await waitFor(() => { expect( within(supervisorSurface).getAllByTestId('agent-tool-call-group'), @@ -8439,18 +8165,16 @@ export function registerProjectSupervisorSurfaceTests() { const group = within(supervisorSurface).getAllByTestId( 'agent-tool-call-group', )[0] as HTMLElement; - expect(group.getAttribute('data-status')).toBe('failed'); const groupHead = within(group).getByTestId('agent-tool-call-group-head'); - expect(groupHead.textContent).toContain('已执行 1 个命令、1 个文件变更'); - // 块头右侧是总用时(min(startedAt) → max(updatedAt)),并在 data-* 上暴露原始毫秒。 + expect(groupHead.textContent).toContain('执行了 2 个操作'); + // 组头右侧是**本组**用时(本组工具 min(开始) → max(完成) = 100ms),与整轮总耗时无关: + // 组内工具都结束了,即使回合还在跑,也不标"进行中"、也不再滚动。 + expect(group.getAttribute('data-status')).toBe('completed'); expect(group.getAttribute('data-duration-ms')).toBe('100'); - expect(groupHead.textContent).toContain('用时 1秒'); - // 同一回合的用户消息时间(App 提交时写入)→ 显示「发送 → 结束」。 - expect( - groupHead.querySelector('.agent-tool-call-group-time')?.textContent, - ).toMatch(/^\d{2}:\d{2}:\d{2} → \d{2}:\d{2}:\d{2}$/); - // 实时回合的块落在消息流末尾:该回合 assistant 消息还没落盘, - // 所以它在上一条 assistant 消息之后,而不是被锚到别人头上。 + expect(groupHead.textContent).toContain('耗时 0.1秒'); + expect(groupHead.textContent).not.toContain('进行中'); + expect(groupHead.textContent).not.toContain('总耗时'); + // 实时回合的块落在消息流末尾:该回合还没有正文,不依赖任何锚点消息。 const liveChildren = Array.from((messageList as HTMLElement).children); expect( liveChildren.findIndex((node) => node === group), @@ -8461,29 +8185,23 @@ export function registerProjectSupervisorSurfaceTests() { await waitFor(() => { expect(groupHead.getAttribute('aria-expanded')).toBe('true'); }); - const groupBody = group.querySelector( - `#${groupHead.getAttribute('aria-controls')}`, - ); - expect(groupBody?.hasAttribute('hidden')).toBe(false); const rows = within(group).getAllByTestId('agent-tool-call-row'); expect(rows).toHaveLength(2); expect(rows.map((row) => row.getAttribute('data-kind'))).toEqual([ 'command', 'file_change', ]); - // 同一 id 只渲染一次,状态由 completed 覆盖;failed 行标「失败」。 - expect(rows[0]?.getAttribute('data-status')).toBe('completed'); - expect(rows[1]?.getAttribute('data-status')).toBe('failed'); const commandRow = rows[0] as HTMLElement; const fileRow = rows[1] as HTMLElement; - expect(within(commandRow).getByText('npm run build')).not.toBeNull(); - expect(within(fileRow).getByText('game/src/hero.ts')).not.toBeNull(); - expect(within(fileRow).getByText('失败')).not.toBeNull(); + expect( + within(commandRow).getAllByText('npm run build').length, + ).toBeGreaterThan(0); + expect( + within(fileRow).getAllByText('game/src/hero.ts').length, + ).toBeGreaterThan(0); // 每行右侧显示该工具自己的耗时(`startedAt` → `updatedAt`)。 expect(commandRow.getAttribute('data-duration-ms')).toBe('100'); - expect(within(commandRow).getByText('0.1s')).not.toBeNull(); expect(fileRow.getAttribute('data-duration-ms')).toBe('50'); - expect(within(fileRow).getByText('0.1s')).not.toBeNull(); // 行可二级展开:默认折叠,展开后看到命令 / 路径 + 变更类型 / 输出。 const commandRowHead = within(commandRow).getByRole('button'); @@ -8517,10 +8235,14 @@ export function registerProjectSupervisorSurfaceTests() { expect(within(fileDetail as HTMLElement).getByText('修改')).not.toBeNull(); expect(within(fileDetail as HTMLElement).getByText('删除')).not.toBeNull(); - // 回合结束:assistant 消息落盘后,块移到该回合 assistant 消息**之前**(工具在上、答复在下), - // 不重复、不消失。 + // 回合结束:正文进历史,工具块收进「执行过程」折叠区,不重复、不消失。 await act(async () => { directReply.resolve('DIRECT_REPLY:做一个跑酷游戏'); + supervisorHarness.completeDirectThreadTurn({ + turnId: clientTurnId, + reply: 'DIRECT_REPLY:做一个跑酷游戏', + at: turnSentAt + 500, + }); }); await waitFor(() => { expect( @@ -8536,24 +8258,19 @@ export function registerProjectSupervisorSurfaceTests() { const settledGroups = within(supervisorSurface).getAllByTestId( 'agent-tool-call-group', ); - const children = Array.from((messageList as HTMLElement).children); - const assistantIndex = children.findIndex( - (node) => - node.classList.contains('message--assistant') && - node.textContent?.includes('DIRECT_REPLY:做一个跑酷游戏'), + const processSection = + within(supervisorSurface).getByTestId('turn-process'); + expect(processSection.contains(settledGroups[0] ?? null)).toBe(true); + // 外层"执行过程"汇总这一轮**全部工具调用**(1 组 2 条 = 2 个操作), + // 耗时是首工具 → 末工具的执行跨度(不是整轮总耗时)。 + const turnProcessSummary = within(processSection).getByTestId( + 'agent-tool-call-group-head', ); - expect(assistantIndex).toBeGreaterThanOrEqual(0); - const settledGroupIndex = children.findIndex( - (node) => node === settledGroups[0], + expect(processSection.textContent).toContain('执行了 2 个操作'); + expect(turnProcessSummary.textContent).toContain('耗时 0.1秒'); + expect(processSection.textContent).not.toContain( + 'DIRECT_REPLY:做一个跑酷游戏', ); - expect(settledGroupIndex).toBeGreaterThanOrEqual(0); - // 块在该回合的 user 消息与 assistant 消息之间:紧邻 assistant 消息之前。 - expect(settledGroupIndex).toBeLessThan(assistantIndex); - expect(settledGroupIndex).toBe(assistantIndex - 1); - expect( - children[settledGroupIndex - 1]?.classList.contains('message--user'), - ).toBe(true); - // 重新挂载后的块回到默认折叠;键盘可达:块头就是按钮(Tab 可达), // Enter/Space 触发的 click 同步 aria-expanded 与 hidden。 const settledGroup = settledGroups[0] as HTMLElement; @@ -8562,6 +8279,23 @@ export function registerProjectSupervisorSurfaceTests() { ); expect(settledHead.tagName).toBe('BUTTON'); expect(settledHead.getAttribute('aria-expanded')).toBe('false'); + // 回合收口不改变组头:组头始终是**本组**用时,不随回合变长。 + expect(settledGroup.getAttribute('data-status')).toBe('completed'); + expect(settledGroup.getAttribute('data-duration-ms')).toBe('100'); + expect(settledHead.textContent).toContain('耗时 0.1秒'); + expect(settledHead.textContent).not.toContain('进行中'); + expect(settledHead.textContent).not.toContain('总耗时'); + // 整轮总耗时在界面上只有一处:回合小结;任何工具组都不再显示它。 + expect( + Array.from( + supervisorSurface.querySelectorAll('[data-testid="turn-usage"]'), + ).filter((node) => node.textContent?.includes('总耗时')), + ).toHaveLength(1); + expect( + Array.from( + supervisorSurface.querySelectorAll('.agent-tool-call-group'), + ).filter((node) => node.textContent?.includes('总耗时')), + ).toHaveLength(0); const settledBody = settledGroup.querySelector( `#${settledHead.getAttribute('aria-controls')}`, ); @@ -8582,8 +8316,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(settledBody?.hasAttribute('hidden')).toBe(true); }); }); - - it.skip('reads the persisted tool-call history whenever a project is opened', async () => { + it('renders persisted tool cards from the history slice whenever a project is opened', async () => { const projectPath = '/tmp/launcher-tool-call-persisted-game'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -8592,7 +8325,42 @@ export function registerProjectSupervisorSurfaceTests() { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, }); - let readToolCallCount = 0; + // 历史切片的条目已经是投影形状:工具的调用与输出共用同一个 itemId。 + supervisorHarness.setDirectThreadHistory([ + { + itemType: 'message', + itemId: 'direct-codex:turn-persisted:user', + role: 'user', + text: '做一个小球弹跳游戏', + at: 1000, + }, + { + itemType: 'function_call', + itemId: 'persisted-command', + name: 'exec_command', + arguments: 'npm run build', + at: 1000, + }, + { + itemType: 'function_call_output', + itemId: 'persisted-command', + output: 'built in 500ms', + at: 1500, + }, + { + itemType: 'fileChange', + itemId: 'persisted-file', + changes: [{ path: 'game/src/hero.ts', kind: 'add' }], + at: 1500, + }, + { + itemType: 'message', + itemId: 'direct-codex:turn-persisted:assistant', + role: 'assistant', + text: '上一轮的答复', + at: 2000, + }, + ]); const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_design_agent_runtime_mode') return null; @@ -8610,61 +8378,96 @@ export function registerProjectSupervisorSurfaceTests() { if (command === 'get_local_game_manifest') { return manifest; } - if (command === 'read_direct_project_conversation') { + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherProjectsAt('/?launcher'); + + pickProjectFromLauncher(projectPath); + + const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); + // 打开项目时读一屏历史:这是「刷新后卡片仍在」的数据来源,工具卡片由前端投影。 + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + // 首屏的新端边界是订阅回执里的 `lastCompletedItemId`(含该条)。 + throughItemId: 'direct-codex:turn-persisted:assistant', + limit: 20, + }); + expect( + await within(supervisorSurface).findByText('做一个小球弹跳游戏'), + ).not.toBeNull(); + // 工具在「执行过程」折叠区里,最终回复在折叠区外:默认折叠、展开后行数 = 工具数。 + const persistedGroups = await within(supervisorSurface).findAllByTestId( + 'agent-tool-call-group', + ); + expect(persistedGroups).toHaveLength(1); + const persistedGroup = persistedGroups[0] as HTMLElement; + const persistedHead = within(persistedGroup).getByTestId( + 'agent-tool-call-group-head', + ); + expect(persistedHead.getAttribute('aria-expanded')).toBe('false'); + expect(persistedHead.textContent).toContain('执行了 2 个操作'); + const processSection = + within(supervisorSurface).getByTestId('turn-process'); + expect(processSection.contains(persistedGroup)).toBe(true); + expect(processSection.textContent).not.toContain('上一轮的答复'); + fireEvent.click(persistedHead); + const persistedRows = within(persistedGroup).getAllByTestId( + 'agent-tool-call-row', + ); + expect(persistedRows).toHaveLength(2); + // 行标题与展开后的命令详情都写着这条命令,这里只要求它确实出现在这一行里。 + expect( + within(persistedRows[0] as HTMLElement).getAllByText('npm run build'), + ).toHaveLength(2); + }); + + it('loads the earlier direct history page from the first-item anchor', async () => { + // 分页锚点走的是首屏切片给出的 `firstItemId`:切片本身从队尾取,老的一屏靠锚点继续向前。 + const projectPath = '/tmp/launcher-direct-history-pagination-game'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-direct-history-pagination-game', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + }); + // 26 条 > 首屏 20 条:首屏只显示最后 20 条,前面 6 条要靠"显示更早"翻页取回。 + supervisorHarness.setDirectThreadHistory( + Array.from({ length: 26 }, (_, index) => { + const label = `历史对话 ${String(index + 1).padStart(2, '0')}`; + return { + itemType: 'message', + itemId: `direct-codex:turn-${label}:user`, + role: 'user', + text: label, + at: 1000 + index, + }; + }), + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; + if (command === 'inspect_local_project_directory') { return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [ - { - schemaVersion: 'agc-direct-project-context.v1', - role: 'user', - content: '做一个小球弹跳游戏', - agentId: null, - messageId: 'direct-codex:turn-persisted:user', - updatedAt: 1000, - }, - { - schemaVersion: 'agc-direct-project-context.v1', - role: 'assistant', - content: '上一轮的答复', - agentId: null, - messageId: 'direct-codex:turn-persisted:assistant', - updatedAt: 2000, - }, - ], + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'launcher-direct-history-pagination-game', + recentRunStatus: null, + recentRunStopReason: null, }; } - if (command === 'read_direct_tool_calls') { - readToolCallCount += 1; - return [ - { - schemaVersion: 'agc-tool-call.v1', - id: 'persisted-command', - turnId: 'turn-persisted', - kind: 'command', - title: '执行命令', - summary: 'npm run build', - status: 'completed', - detail: { command: 'npm run build' }, - startedAt: 1000, - updatedAt: 1500, - }, - { - schemaVersion: 'agc-tool-call.v1', - id: 'persisted-file', - turnId: 'turn-persisted', - kind: 'file_change', - title: '编辑 1 个文件', - summary: 'game/src/hero.ts', - status: 'completed', - detail: { - changes: [{ path: 'game/src/hero.ts', kind: 'add' }], - }, - startedAt: 1500, - updatedAt: 2000, - }, - ]; + if (command === 'get_local_game_manifest') { + return manifest; } if (command === 'get_local_game_preview_status') { return { status: 'stopped', url: null, port: null, root: null }; @@ -8681,59 +8484,333 @@ export function registerProjectSupervisorSurfaceTests() { pickProjectFromLauncher(projectPath); const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); - // 打开项目时必须回读一次工具调用历史:这是「刷新后卡片仍在」的数据来源。 - await waitFor(() => { - expect(readToolCallCount).toBeGreaterThanOrEqual(1); - }); - expect(invoke).toHaveBeenCalledWith('read_direct_tool_calls', { - projectPath, - }); - // 回读到的工具调用挂在自己回合的 assistant 消息**之前**(工具在上、答复在下), - // 默认折叠、展开后行数 = 回读到的工具数。 - const persistedGroups = await within(supervisorSurface).findAllByTestId( - 'agent-tool-call-group', - ); - expect(persistedGroups).toHaveLength(1); - const persistedGroup = persistedGroups[0] as HTMLElement; - const persistedHead = within(persistedGroup).getByTestId( - 'agent-tool-call-group-head', - ); - expect(persistedHead.getAttribute('aria-expanded')).toBe('false'); - expect(persistedHead.textContent).toContain( - '已执行 1 个命令、1 个文件变更', - ); - // 回读回来的时间戳一样能算总用时与「发送 → 结束」。 - expect(persistedGroup.getAttribute('data-duration-ms')).toBe('1000'); - expect(persistedHead.textContent).toContain('用时 1秒'); expect( - persistedHead.querySelector('.agent-tool-call-group-time')?.textContent, - ).toMatch(/^\d{2}:\d{2} → \d{2}:\d{2}$/); - const messageList = supervisorSurface.querySelector( - '.project-supervisor-message-list', - ) as HTMLElement; - const children = Array.from(messageList.children); - const assistantIndex = children.findIndex((node) => - node.classList.contains('message--assistant'), - ); - const groupIndex = children.findIndex((node) => node === persistedGroup); - expect(assistantIndex).toBeGreaterThanOrEqual(0); - expect(groupIndex).toBe(assistantIndex - 1); - fireEvent.click(persistedHead); - const persistedRows = within(persistedGroup).getAllByTestId( - 'agent-tool-call-row', - ); - expect(persistedRows).toHaveLength(2); - // 每行右侧是该工具自己的耗时(0.5s / 0.5s)。 - expect(persistedRows[0]?.getAttribute('data-duration-ms')).toBe('500'); - expect( - within(persistedRows[0] as HTMLElement).getByText('0.5s'), + await within(supervisorSurface).findByText('历史对话 26'), ).not.toBeNull(); + // 首屏是尾部的 20 条:第 7 条起可见,更早的 6 条还没读进来。 + expect(within(supervisorSurface).getByText('历史对话 07')).not.toBeNull(); + expect(within(supervisorSurface).queryByText('历史对话 06')).toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + throughItemId: 'direct-codex:turn-历史对话 26:user', + limit: 20, + }); + + fireEvent.click(screen.getByRole('button', { name: '显示更早的对话' })); + + // 翻页按首屏最老一条的锚点向前取;取到文件头后按钮消失。 + expect( + await within(supervisorSurface).findByText('历史对话 01'), + ).not.toBeNull(); + expect(within(supervisorSurface).getByText('历史对话 06')).not.toBeNull(); + expect(screen.queryByRole('button', { name: /显示更早/ })).toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + beforeItemId: 'direct-codex:turn-历史对话 07:user', + limit: 20, + }); }); - it('renders the running turn tool-call group in a fresh chat that has no anchored assistant message', async () => { + it('anchors the first history page at the subscribe receipt instead of the file tail', async () => { + // 首屏切片的新端边界只认 `subscribe` 回执里的 `lastCompletedItemId`(含该条):回执之后才 + // 完成的条目只能从运行态事件来,不能再被"取文件尾"带进历史。 + const projectPath = '/tmp/launcher-direct-history-anchor-game'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-direct-history-anchor-game', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + }); + supervisorHarness.setDirectThreadHistory([ + { + itemType: 'message', + itemId: 'turn-1-user', + role: 'user', + text: '历史对话 01', + at: 1000, + }, + { + itemType: 'message', + itemId: 'turn-2-user', + role: 'user', + text: '历史对话 02', + at: 1100, + }, + // 订阅回执之后才落盘的两条:只应从运行态事件来。 + { + itemType: 'function_call', + itemId: 'process-1', + name: 'exec_command', + arguments: 'npm run build', + at: 1200, + }, + { + itemType: 'message', + itemId: 'turn-3-user', + role: 'user', + text: '历史对话 03', + at: 1300, + }, + ]); + supervisorHarness.setDirectThreadLastCompletedItemId('turn-2-user'); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'launcher-direct-history-anchor-game', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherProjectsAt('/?launcher'); + + pickProjectFromLauncher(projectPath); + + const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + throughItemId: 'turn-2-user', + limit: 20, + }), + ); + // 首屏 = 锚点那一刻的尾部一屏:锚点之后的条目不在这一屏里。 + expect( + await within(supervisorSurface).findByText('历史对话 02'), + ).not.toBeNull(); + expect(within(supervisorSurface).queryByText('历史对话 03')).toBeNull(); + // 锚点只能来自订阅回执:回执必须先于首屏读取发生。 + const commands = invoke.mock.calls.map(([command]) => command); + expect( + commands.indexOf('subscribe_direct_project_thread'), + ).toBeGreaterThanOrEqual(0); + expect(commands.indexOf('subscribe_direct_project_thread')).toBeLessThan( + commands.indexOf('read_direct_project_history_slice'), + ); + }); + + it('keeps pulling earlier pages while the page only deepens the rendered turn', async () => { + // 用户报的现象:一屏 20 条全是同一个回合的工具卡片,落在折叠的「执行过程」里, + // 点一次「显示更早」看不到任何变化。连拉必须越过这一屏,直到出现新的用户气泡。 + const projectPath = '/tmp/launcher-direct-history-cross-page-game'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-direct-history-cross-page-game', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + }); + // 51 条:用户气泡 + 50 条工具条目。首屏取尾 20 条(process-31..process-50), + // 第一次翻页再取 20 条(process-11..process-30),都还在同一个回合里。 + supervisorHarness.setDirectThreadHistory([ + { + itemType: 'message', + itemId: 'direct-codex:turn-更早:user', + role: 'user', + text: '更早的用户提问', + at: 1000, + }, + ...Array.from({ length: 50 }, (_, index) => ({ + itemType: 'function_call', + itemId: `process-${index + 1}`, + name: 'exec_command', + arguments: `echo ${index + 1}`, + at: 1100 + index, + })), + ]); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'launcher-direct-history-cross-page-game', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherProjectsAt('/?launcher'); + + pickProjectFromLauncher(projectPath); + + const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + throughItemId: 'process-50', + limit: 20, + }), + ); + // 首屏只有这个回合的执行过程:用户气泡还在更早的页里。 + expect(within(supervisorSurface).queryByText('更早的用户提问')).toBeNull(); + + // 视口停在列表中部(既不在顶部触发自动翻页,也不在底部跟随最新)。 + const messageList = screen.getByLabelText('陶泥儿消息') as HTMLDivElement; + let messageScrollHeight = 600; + Object.defineProperty(messageList, 'scrollHeight', { + configurable: true, + get: () => messageScrollHeight, + }); + Object.defineProperty(messageList, 'clientHeight', { + configurable: true, + get: () => 100, + }); + messageList.scrollTop = 100; + fireEvent.scroll(messageList); + // 插入后列表会变长:跟随最新的话视口会被拉到这个高度。 + messageScrollHeight = 2000; + + fireEvent.click(screen.getByRole('button', { name: '显示更早的对话' })); + + // 一次点击连拉两页:第一页仍然只有执行过程,第二页才带回用户气泡。 + expect( + await within(supervisorSurface).findByText('更早的用户提问'), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + beforeItemId: 'process-31', + limit: 20, + }); + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + beforeItemId: 'process-11', + limit: 20, + }); + // 取到文件头后按钮收起。 + expect(screen.queryByRole('button', { name: /显示更早/ })).toBeNull(); + // 连拉插入的旧内容不得把视口弹到列表底部。 + expect(messageList.scrollTop).toBe(100); + }); + + it('drains a notify that lands before the subscribe receipt so the running turn is not stranded', async () => { + // 真实竞态:`subscribe` 在 Rust 侧已经注册好 subscriber 并开始通知,但前端还没拿到 + // subscriptionId。此时的通知不能白丢——拿到回执后必须补一次 consume,否则整个回合会卡在队列里。 + const projectPath = '/tmp/launcher-notify-before-subscribe-game'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-notify-before-subscribe-game', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + }); + // 订阅回执被挂住:Rust 侧已经建好订阅(因此事件会带通知),前端还在等回执。 + const subscribeReceipt = createDeferred(); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'launcher-notify-before-subscribe-game', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + if (command === 'subscribe_direct_project_thread') { + const result = await supervisorHarness.invoke(command, args); + await subscribeReceipt.promise; + return result; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherProjectsAt('/?launcher'); + + pickProjectFromLauncher(projectPath); + + const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('subscribe_direct_project_thread', { + projectPath, + }); + }); + + // 回执还没回到前端,整轮事件已经到了:通知此刻拿不到 subscriptionId,只能记账。 + await act(async () => { + supervisorHarness.emitDirectThreadEvents( + { type: 'turn.started' }, + { + type: 'item.completed', + item: { + itemType: 'message', + itemId: 'direct-codex:notify-window:assistant', + role: 'assistant', + text: '回执前就到达的回复', + at: 900, + }, + }, + { type: 'turn.completed', status: 'completed' }, + ); + }); + expect( + within(supervisorSurface).queryByText('回执前就到达的回复'), + ).toBeNull(); + + // 回执到达:前端补一次 consume,把队列里的事件取回来。 + await act(async () => { + subscribeReceipt.resolve(); + }); + expect( + await within(supervisorSurface).findByText('回执前就到达的回复'), + ).not.toBeNull(); + // 补取之后不再有残留的忙碌态。 + expect( + within(supervisorSurface).queryByRole('button', { name: '终止' }), + ).toBeNull(); + }); + + it('renders the running turn tool-call group in a fresh chat from the thread subscription', async () => { // 空对话首轮:历史为空,消息列表里只有 App 落的默认问候(`描述你的想法,或 @ 引用素材`,没有 messageId)。 - // 这种回合没有任何「可锚」的 assistant 消息,块必须兜底落在消息列表末尾, - // 而不是等到回合结束、assistant 消息带上 messageId 之后才出现。 + // 运行中的回合没有任何落盘正文,工具块必须靠订阅事件直接出现在消息列表末尾, + // 而不是等到回合结束、历史切片回来之后才出现。 const projectPath = '/tmp/launcher-tool-call-fresh-turn-game'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -8742,25 +8819,6 @@ export function registerProjectSupervisorSurfaceTests() { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, }); - let directTurnUpdateHandler: - | ((event: { payload: Record }) => void) - | null = null; - const listen = vi.fn( - async ( - eventName: string, - handler: (event: { payload: Record }) => void, - ) => { - if (eventName === 'game-creator-direct-turn-update') { - directTurnUpdateHandler = handler; - return () => { - if (directTurnUpdateHandler === handler) { - directTurnUpdateHandler = null; - } - }; - } - return supervisorHarness.listen(eventName, handler); - }, - ); const directReply = createDeferred(); const invoke = vi.fn( async (command: string, args?: Record) => { @@ -8782,18 +8840,6 @@ export function registerProjectSupervisorSurfaceTests() { if (command === 'chat_with_game_creator_direct_codex') { return directReply.promise; } - if (command === 'read_direct_tool_calls') { - return []; - } - if (command === 'read_direct_project_conversation') { - // 真正的空对话:没有任何历史消息,App 会落一条没有 messageId 的默认问候。 - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [], - }; - } if (command === 'get_local_game_preview_status') { return { status: 'stopped', url: null, port: null, root: null }; } @@ -8802,7 +8848,7 @@ export function registerProjectSupervisorSurfaceTests() { ); window.__TAURI__ = { core: { invoke }, - event: { listen }, + event: { listen: supervisorHarness.listen }, }; renderLauncherProjectsAt('/?launcher'); @@ -8841,30 +8887,43 @@ export function registerProjectSupervisorSurfaceTests() { ); expect(clientTurnId).not.toBe(''); + // 正式条目沿用同身份乐观消息的真实发送时间。不能在等待 IPC 调用后再取 Date.now() + // 冒充发送时刻:全量运行的调度间隔会被正确计入总耗时,使硬编码的 1.0 秒期望漂移。 + const sentTimeElement = supervisorSurface.querySelector( + '.message--user time.message-sent-at', + ); + expect(sentTimeElement).not.toBeNull(); + const freshTurnSentAt = Date.parse(sentTimeElement!.dateTime); + expect(Number.isFinite(freshTurnSentAt)).toBe(true); await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: clientTurnId, - sequence: 1, - status: 'running', - activity: 'command-exec', - updatedAt: 1400, - toolCalls: [ - { - schemaVersion: 'agc-tool-call.v1', - id: 'fresh-turn-command', - kind: 'command', - title: '执行命令', - summary: 'npm run build', - status: 'running', - detail: { command: 'npm run build' }, - startedAt: 1000, - updatedAt: 1400, - }, - ], + // 订阅事件就是运行态:生命周期 + 用户条目 + 命令开始执行。 + supervisorHarness.emitDirectThreadEvents( + { type: 'turn.started', at: freshTurnSentAt }, + { + type: 'item.completed', + at: freshTurnSentAt, + item: { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:user`, + role: 'user', + text: '做一个跑酷游戏', + at: freshTurnSentAt, + }, }, - }); + { + type: 'item.started', + at: freshTurnSentAt, + item: { + itemType: 'commandExecution', + itemId: 'fresh-turn-command', + command: 'npm run build', + output: null, + status: 'inProgress', + exitCode: null, + at: freshTurnSentAt, + }, + }, + ); }); // 回合进行中:块已经可见,且落在消息列表末尾(默认问候之后),不依赖任何带 messageId 的 assistant 消息。 @@ -8874,12 +8933,16 @@ export function registerProjectSupervisorSurfaceTests() { expect(runningGroups).toHaveLength(1); const runningGroup = runningGroups[0] as HTMLElement; expect(runningGroup.getAttribute('data-status')).toBe('running'); - expect(runningGroup.getAttribute('data-duration-ms')).toBe('400'); const runningHead = within(runningGroup).getByTestId( 'agent-tool-call-group-head', ); expect(runningHead.getAttribute('aria-expanded')).toBe('false'); - expect(runningHead.textContent).toContain('1 个命令'); + expect(runningHead.textContent).toContain('执行了 1 个操作'); + // 组内还有工具在跑:组头按"本组起点 → 现在"增长,一位小数、显示进行中。 + // 整轮总耗时不在组头(它只在回合小结 / 底部耗时行出现)。 + expect(runningHead.textContent).toContain('进行中'); + expect(runningHead.textContent).toMatch(/耗时 \d+\.\d+秒/); + expect(runningHead.textContent).not.toContain('总耗时'); const runningChildren = Array.from((messageList as HTMLElement).children); expect( runningChildren.findIndex((node) => node === runningGroup), @@ -8890,14 +8953,59 @@ export function registerProjectSupervisorSurfaceTests() { ); expect(runningRows).toHaveLength(1); expect(runningRows[0]?.getAttribute('data-kind')).toBe('command'); - expect(runningRows[0]?.getAttribute('data-duration-ms')).toBe('400'); + + // 同一条命令完成:同一个 itemId 就地更新,不新起一张卡,耗时按 startedAt → updatedAt 计算。 + await act(async () => { + supervisorHarness.emitDirectThreadEvents({ + type: 'item.completed', + at: freshTurnSentAt + 400, + item: { + itemType: 'commandExecution', + itemId: 'fresh-turn-command', + command: 'npm run build', + output: 'built in 400ms', + status: 'completed', + exitCode: 0, + at: freshTurnSentAt + 400, + }, + }); + }); + await waitFor(() => { + expect( + within(supervisorSurface).getAllByTestId('agent-tool-call-group'), + ).toHaveLength(1); + }); + const settledRunningGroup = within(supervisorSurface).getAllByTestId( + 'agent-tool-call-group', + )[0] as HTMLElement; + // 工具已经结束(块内没有 running 快照):本组用时立刻冻结,也不再标"进行中", + // 即使回合还在跑(这正是之前把整轮总耗时借给每组造成的误报)。 + expect(settledRunningGroup.getAttribute('data-status')).toBe('completed'); + expect(settledRunningGroup.getAttribute('data-duration-ms')).toBe('400'); + const settledRunningHead = within(settledRunningGroup).getByTestId( + 'agent-tool-call-group-head', + ); + expect(settledRunningHead.textContent).toContain('耗时 0.4秒'); + expect(settledRunningHead.textContent).not.toContain('进行中'); + expect(settledRunningHead.textContent).not.toContain('总耗时'); + fireEvent.click(settledRunningHead); + const settledRunningRows = within(settledRunningGroup).getAllByTestId( + 'agent-tool-call-row', + ); + expect(settledRunningRows).toHaveLength(1); + expect(settledRunningRows[0]?.getAttribute('data-duration-ms')).toBe('400'); expect( - within(runningRows[0] as HTMLElement).getByText('0.4s'), + within(settledRunningRows[0] as HTMLElement).getByText('0.4秒'), ).not.toBeNull(); - // 回合结束:assistant 消息落盘后块回到它之前,且**只有一份**(末尾兜底不留下重复块)。 + // 回合结束:assistant 正文进历史,工具块收进「执行过程」折叠区,且**只有一份**。 await act(async () => { directReply.resolve('DIRECT_REPLY:空对话首轮'); + supervisorHarness.completeDirectThreadTurn({ + turnId: clientTurnId, + reply: 'DIRECT_REPLY:空对话首轮', + at: freshTurnSentAt + 1000, + }); }); await waitFor(() => { expect( @@ -8913,6 +9021,371 @@ export function registerProjectSupervisorSurfaceTests() { 'agent-tool-call-group', )[0] as HTMLElement; expect(settledGroup).toBeTruthy(); + // 回合收口只决定底部小结:组头仍然是本组自己的用时。 + expect(settledGroup.getAttribute('data-status')).toBe('completed'); + expect(settledGroup.getAttribute('data-duration-ms')).toBe('400'); + expect( + within(settledGroup).getByTestId('agent-tool-call-group-head') + .textContent, + ).toContain('耗时 0.4秒'); + // 整轮总耗时冻结在用户发送 → turn.completed.at,且只在回合小结里出现一处。 + expect( + within(supervisorSurface).getByTestId('turn-usage').textContent, + ).toContain('总耗时 1.0秒'); + }); + + /** 直接渲染 ProjectSupervisorView 的最小 props(与下面 initial 占位用例同源)。 */ + function supervisorSurfaceProps( + overrides: Record = {}, + ): Record { + return { + activeVersionId: null, + chatInput: '', + chatReferences: [], + chatProjectAssets: [], + composerRef: createRef(), + directCodex: true, + directTurnRunning: false, + hiddenConversationCount: 0, + messagesRef: createRef(), + needsUserInput: false, + onCancelConfirmation: vi.fn(), + onCancelPendingCommand: vi.fn(), + onChatInputChange: vi.fn(), + onConfirmConfirmation: vi.fn(), + onConfirmPendingCommand: vi.fn(), + onScroll: vi.fn(), + onShowEarlierMessages: vi.fn(), + onSubmit: vi.fn(), + pendingConfirmation: null, + pendingCommand: null, + projectPath: '/tmp/launcher-codex-placeholder-game', + transientReply: '', + visibleMessages: [], + visibleProfessionalAgentCards: [], + workspaceStatus: '等待指令', + planGddState: createPlanGddStateView(), + planGddHydrateBusy: false, + planGddDecisionBusy: false, + planGddError: null, + onPlanGddRefresh: vi.fn(), + onPlanGddDecision: vi.fn(), + runtime: null, + error: '', + runtimeByAgentId: {}, + controlBusy: false, + professionalResultsByAgentId: {}, + onToolAction: vi.fn(), + onSupervisorRetry: vi.fn(), + onProfessionalToolAction: vi.fn(), + onProfessionalRetry: vi.fn(), + onUserInput: vi.fn(), + initialSupervisorMessage: '', + ...overrides, + }; + } + + /** + * 等待首个响应时的临时"思考中…":只在 Direct 忙碌、本轮已有用户消息但**还没有任何过程 / + * 正文**时,在工具组之外顶一条;本轮任何 reasoning / assistant / tool 到达即消失。 + */ + it('shows a temporary thinking row until the first reasoning, text or tool arrives', () => { + const historyUser = { + itemId: 'direct-codex:turn-1:user', + kind: 'message', + role: 'user', + text: '上一轮的问题', + at: 1_000_000, + }; + const historyFinal = { + itemId: 'direct-codex:turn-1:assistant', + kind: 'message', + role: 'assistant', + text: '上一轮的答复', + at: 1_001_000, + }; + // 本地乐观新用户消息:还没有任何正式条目,所以本轮 process / finals 都是空的。 + const optimisticUser = { + role: 'user' as const, + text: '这一轮的新问题', + messageId: 'direct-codex:turn-2:user', + updatedAt: 2_000_000, + }; + const busyProps = supervisorSurfaceProps({ + directTurnRunning: true, + directEntries: [historyUser, historyFinal], + conversationMessages: [optimisticUser], + }); + const thinkingRows = (surface: HTMLElement) => + Array.from(surface.querySelectorAll('*')).filter( + (node) => + node.children.length === 0 && + /思考中/.test(node.textContent ?? '') && + !node.closest('[data-testid="agent-tool-call-group"]'), + ); + + // 旧历史 final + 本地新用户 + 忙碌:思考中顶位,且不在工具组里。 + const view = render( + React.createElement(ProjectSupervisorView, busyProps as never), + ); + const surface = screen.getByLabelText('陶泥儿项目对话'); + expect(thinkingRows(surface)).toHaveLength(1); + + // 本轮开口条目落盘后 reasoning 到达 → 消失。 + const turn2User = { + itemId: 'direct-codex:turn-2:user', + kind: 'message', + role: 'user', + text: '这一轮的新问题', + at: 2_000_050, + }; + view.rerender( + React.createElement(ProjectSupervisorView, { + ...busyProps, + directEntries: [ + historyUser, + historyFinal, + turn2User, + { + itemId: 'direct-codex:turn-2:reasoning', + kind: 'reasoning', + role: null, + text: '先看目录', + at: 2_000_100, + }, + ], + } as never), + ); + expect(thinkingRows(surface)).toHaveLength(0); + + // 本轮 assistant 正文到达 → 消失。 + view.rerender( + React.createElement(ProjectSupervisorView, { + ...busyProps, + directEntries: [ + historyUser, + historyFinal, + turn2User, + { + itemId: 'direct-codex:turn-2:assistant', + kind: 'message', + role: 'assistant', + text: '我先看一下项目', + at: 2_000_200, + }, + ], + } as never), + ); + expect(thinkingRows(surface)).toHaveLength(0); + + // 本轮工具到达(工具组出现)→ 消失,且不是被工具组内文字顶掉的。 + view.rerender( + React.createElement(ProjectSupervisorView, { + ...busyProps, + directEntries: [ + historyUser, + historyFinal, + turn2User, + { + itemId: 'call-2', + kind: 'tool', + role: null, + text: null, + at: 0, + toolCall: { + schemaVersion: 'agc-tool-call.v1', + id: 'call-2', + kind: 'command', + title: '执行命令', + summary: 'npm run build', + status: 'running', + detail: { command: 'npm run build' }, + startedAt: 2_000_300, + updatedAt: 0, + }, + }, + ], + } as never), + ); + expect( + within(surface).getAllByTestId('agent-tool-call-group'), + ).toHaveLength(1); + expect(thinkingRows(surface)).toHaveLength(0); + + // 空闲(不忙)→ 不显示;终止中 → 不显示。 + view.rerender( + React.createElement(ProjectSupervisorView, { + ...busyProps, + directTurnRunning: false, + directEntries: [historyUser, historyFinal], + directTurnStartedAt: 0, + } as never), + ); + expect(thinkingRows(surface)).toHaveLength(0); + view.rerender( + React.createElement(ProjectSupervisorView, { + ...busyProps, + turnCancelling: true, + } as never), + ); + expect(thinkingRows(surface)).toHaveLength(0); + view.unmount(); + + // 只有旧历史、没有任何本轮用户消息:即使忙碌也不显示。 + const historyOnly = render( + React.createElement(ProjectSupervisorView, { + ...busyProps, + directEntries: [historyUser, historyFinal], + conversationMessages: [], + } as never), + ); + expect(thinkingRows(screen.getByLabelText('陶泥儿项目对话'))).toHaveLength( + 0, + ); + historyOnly.unmount(); + }); + + it('keeps the initial supervisor placeholder from duplicating the landed direct user entry', () => { + // 初始占位气泡只在"最初那条消息还没有正式条目"时顶位: + // Direct 模式的正式条目走 `directEntries`、不进 `conversationMessages`, + // 正式条目一到就必须撤掉占位,否则同一条消息会上下各出现一次(正式气泡 + 无时间占位)。 + const initialText = '做一个跑酷游戏'; + const baseProps = { + activeVersionId: null, + chatInput: '', + chatReferences: [], + chatProjectAssets: [], + composerRef: createRef(), + directCodex: true, + directTurnRunning: false, + hiddenConversationCount: 0, + messagesRef: createRef(), + needsUserInput: false, + onCancelConfirmation: vi.fn(), + onCancelPendingCommand: vi.fn(), + onChatInputChange: vi.fn(), + onConfirmConfirmation: vi.fn(), + onConfirmPendingCommand: vi.fn(), + onScroll: vi.fn(), + onShowEarlierMessages: vi.fn(), + onSubmit: vi.fn(), + pendingConfirmation: null, + pendingCommand: null, + projectPath: '/tmp/launcher-codex-placeholder-game', + transientReply: '', + visibleMessages: [], + visibleProfessionalAgentCards: [], + workspaceStatus: '等待指令', + planGddState: createPlanGddStateView(), + planGddHydrateBusy: false, + planGddDecisionBusy: false, + planGddError: null, + onPlanGddRefresh: vi.fn(), + onPlanGddDecision: vi.fn(), + runtime: null, + error: '', + runtimeByAgentId: {}, + controlBusy: false, + professionalResultsByAgentId: {}, + onToolAction: vi.fn(), + onSupervisorRetry: vi.fn(), + onProfessionalToolAction: vi.fn(), + onProfessionalRetry: vi.fn(), + onUserInput: vi.fn(), + initialSupervisorMessage: initialText, + }; + const initialUserBubbles = (surface: HTMLElement) => + Array.from(surface.querySelectorAll('.message--user')).filter((node) => + node.textContent?.includes(initialText), + ); + + // 正式条目还没到:占位顶上,只有一条。 + const pending = render( + React.createElement(ProjectSupervisorView, { + ...baseProps, + directEntries: [], + }), + ); + expect( + initialUserBubbles(screen.getByLabelText('陶泥儿项目对话')), + ).toHaveLength(1); + pending.unmount(); + + // 本地乐观气泡先于原生条目到达,也已接管初始占位。 + const optimistic = render( + React.createElement(ProjectSupervisorView, { + ...baseProps, + directEntries: [], + conversationMessages: [ + { + messageId: 'direct-codex:turn-1:user', + role: 'user' as const, + text: initialText, + updatedAt: 1_789_700_960_000, + }, + ], + }), + ); + expect( + initialUserBubbles(screen.getByLabelText('陶泥儿项目对话')), + ).toHaveLength(1); + // 用户真实重复发送同文时仍保留两条,只撤下额外占位。 + optimistic.rerender( + React.createElement(ProjectSupervisorView, { + ...baseProps, + directEntries: [], + conversationMessages: [ + { + messageId: 'direct-codex:turn-1:user', + role: 'user' as const, + text: initialText, + updatedAt: 1_789_700_960_000, + }, + { + messageId: 'direct-codex:turn-2:user', + role: 'user' as const, + text: initialText, + updatedAt: 1_789_700_970_000, + }, + ], + }), + ); + expect( + initialUserBubbles(screen.getByLabelText('陶泥儿项目对话')), + ).toHaveLength(2); + optimistic.unmount(); + + // 正式 Direct 条目到了:只剩正式气泡那一条,占位撤掉。 + const landed = render( + React.createElement(ProjectSupervisorView, { + ...baseProps, + directEntries: [ + { + itemId: 'direct-codex:turn-1:user', + kind: 'message' as const, + role: 'user' as const, + text: initialText, + at: 1_789_700_960_000, + }, + ], + }), + ); + expect( + initialUserBubbles(screen.getByLabelText('陶泥儿项目对话')), + ).toHaveLength(1); + landed.unmount(); + + // 已经翻出更早的历史:这里不是对话开头,不补最初那条消息。 + render( + React.createElement(ProjectSupervisorView, { + ...baseProps, + hasEarlierConversationMessages: true, + directEntries: [], + }), + ); + expect( + initialUserBubbles(screen.getByLabelText('陶泥儿项目对话')), + ).toHaveLength(0); }); it.skip('renders the Codex empty state and opens the panel settings overlay in a fresh direct chat', async () => { @@ -8928,9 +9401,8 @@ export function registerProjectSupervisorSurfaceTests() { chatProjectAssets: [], composerRef: createRef(), directCodex: true, - directStatus: null, - directProcessDetail: '', - directProcessKey: '', + directEntries: [], + directTurnRunning: false, hiddenConversationCount: 0, messagesRef: createRef(), needsUserInput: false, @@ -10300,16 +10772,22 @@ export function registerProjectAgentStatusTests() { ); window.__TAURI__ = { core: { invoke } }; renderAppAt('/?dev&projectPath=%2Ftmp%2Fauthorized-game'); - await waitFor(() => { - expect( - ( - screen.getByRole('button', { - name: '刷新 Agent', - }) as HTMLButtonElement - ).disabled, - ).toBe(false); - }); - + // 项目打开包含异步初始化;以按钮启用为就绪条件,再开始弹窗迟到读取场景。 + await waitFor( + () => { + expect( + ( + screen.getByRole('button', { + name: '刷新 Agent', + }) as HTMLButtonElement + ).disabled, + ).toBe(false); + expect( + screen.getByRole('button', { name: /拆解创作方向/ }), + ).toBeTruthy(); + }, + { timeout: 5_000 }, + ); fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); await waitFor(() => { expect(releaseConversation).not.toBeNull(); @@ -10324,7 +10802,7 @@ export function registerProjectAgentStatusTests() { expect(screen.queryByText('关闭后不该写入界面状态')).toBeNull(); expect(screen.queryByText('conversation.read')).toBeNull(); expect(screen.queryByText('memory.agent.read')).toBeNull(); - }); + }, 10_000); it('updates the open agent dialog when agent status is refreshed', async () => { const manifest = createGameCreationAppManifest( @@ -11776,9 +12254,7 @@ export function registerProjectAgentStatusTests() { fireEvent.change(within(firstPanel).getByLabelText('素材名称'), { target: { value: '第一条设计图' }, }); - fireEvent.change(within(firstPanel).getByLabelText('生成提示词'), { - target: { value: '第一条界面' }, - }); + await typeGenerationPrompt(firstPanel, '第一条界面'); fireEvent.click( within(firstPanel).getByRole('button', { name: '生成 UI 设计图' }), ); @@ -11789,8 +12265,15 @@ export function registerProjectAgentStatusTests() { // 2) 面板关掉之后任务仍在「生成任务」面板里,阶段文案来自后端记录。 const taskPanel = await screen.findByRole('region', { name: '生成任务' }); expect(within(taskPanel).getByText('第一条设计图')).not.toBeNull(); - expect(within(taskPanel).getByText('正在生成。')).not.toBeNull(); - expect(within(taskPanel).getByText('生成中')).not.toBeNull(); + /** + * 阶段文案与徽标要**等**后端记录并进来:刚提交时任务还停在本地队列的「排队中。」, + * 记录合并发生在第一轮账本轮询(间隔 2s)之后。同步断言在这里读到的会是本地阶段, + * 在整套连跑时必然抢跑(隔离单跑时那一拍刚好落在窗口内,所以只在全量里红)。 + */ + await waitFor(() => { + expect(within(taskPanel).getByText('正在生成。')).not.toBeNull(); + expect(within(taskPanel).getByText('生成中')).not.toBeNull(); + }); // 非模态:没有全屏遮罩、没有 aria-modal。 expect(taskPanel.getAttribute('aria-modal')).toBeNull(); expect(document.querySelector('[aria-modal="true"]')).toBeNull(); @@ -11803,9 +12286,7 @@ export function registerProjectAgentStatusTests() { fireEvent.change(within(secondPanel).getByLabelText('素材名称'), { target: { value: '第二条设计图' }, }); - fireEvent.change(within(secondPanel).getByLabelText('生成提示词'), { - target: { value: '第二条界面' }, - }); + await typeGenerationPrompt(secondPanel, '第二条界面'); fireEvent.click( within(secondPanel).getByRole('button', { name: '生成 UI 设计图' }), ); @@ -11850,6 +12331,28 @@ export function registerProjectAgentStatusTests() { expect( calls.filter((call) => call.command === 'get_local_game_manifest').length, ).toBeGreaterThanOrEqual(manifestReadsBefore + 2); + + /** + * 5) 等轮询真的退出:两个 mock 任务都已收口 completed,再连等两个 poll 周期 + * (间隔 2s),账本读计数不再增长才算这条异步链跑完。 + * + * 宿主读账本用的是「调用时」的 `window.__TAURI__`,而 afterEach 会把它删掉、下一个用例 + * 再装自己的 mock:留下一条在途/在睡的轮询,下一轮醒来就会打进**下一个用例**的 spy + * (实测就是 `does not open a preview before a local project is initialized` 那条变红, + * 且看到的 projectPath 是本案的)。所以排空必须发生在本用例内部,而不是靠全局 harness 掩盖。 + */ + const ledgerReads = () => + calls.filter( + (call) => call.command === 'list_local_project_asset_generations', + ).length; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 2_200)); + }); + const ledgerReadsAfterFirstQuietPeriod = ledgerReads(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 2_200)); + }); + expect(ledgerReads()).toBe(ledgerReadsAfterFirstQuietPeriod); }, 30_000); /** @@ -12363,9 +12866,7 @@ export function registerProjectAgentStatusTests() { fireEvent.change(within(panel).getByLabelText('素材名称'), { target: { value: '待提交设计图' }, }); - fireEvent.change(within(panel).getByLabelText('生成提示词'), { - target: { value: '主界面与背包页' }, - }); + await typeGenerationPrompt(panel, '主界面与背包页'); return panel; } @@ -12412,10 +12913,9 @@ export function registerProjectAgentStatusTests() { '项目权限策略拒绝执行:canvas.asset_generate', ), ); - expect( - (within(reopened).getByLabelText('生成提示词') as HTMLTextAreaElement) - .value, - ).toBe('主界面与背包页'); + await waitFor(() => + expect(generationPromptText(reopened)).toBe('主界面与背包页'), + ); expect( (within(reopened).getByLabelText('素材名称') as HTMLInputElement).value, ).toBe('待提交设计图'); diff --git a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts index d78f10a0d..c66c09123 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/runtime-settings.suite.ts @@ -360,6 +360,70 @@ export function registerRuntimeSettingsTests() { expect(screen.getByText('桌面客户端')).not.toBeNull(); }); + it('keeps the project creation directory in the workspace settings section', async () => { + const creationDirectory = 'F:\\Projects\\陶泥儿游戏'; + const storageKey = + 'genarrative-ai-game-creator.project-creation-directory.v1'; + const invoke = vi.fn(async (command: string) => { + if (command === 'read_game_creator_app_config') { + return { + path: '/home/test/AppData/game-creator.config.json', + config: { + agentMode: 'codex_app_server', + llm: { + apiKey: '', + baseUrl: 'https://llm.example.test/v1', + model: 'gpt-workspace', + apiKind: 'openai_responses', + reasoningEffort: 'high', + stream: true, + webSearchEnabled: false, + contextWindowTokens: 128000, + autoCompactTokenLimit: 64000, + toolOutputTokenLimit: 12000, + requestTimeoutMs: 180000, + maxRetries: 2, + retryBackoffMs: 500, + }, + agentLlm: {}, + editorApi: { baseUrl: 'http://127.0.0.1:8082', apiKey: '' }, + }, + }; + } + if (command === 'pick_local_project_directory') { + return creationDirectory; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + fireEvent.click(screen.getByRole('button', { name: '配置' })); + const dialog = await screen.findByRole('dialog', { name: '运行时配置' }); + fireEvent.click(within(dialog).getByRole('button', { name: /工作区/ })); + + // 不选目录时是默认位置,且本地不写任何偏好。 + expect(within(dialog).getByText('默认位置')).not.toBeNull(); + expect(window.localStorage.getItem(storageKey)).toBeNull(); + + fireEvent.click(within(dialog).getByRole('button', { name: '选择目录' })); + expect(await within(dialog).findByText(creationDirectory)).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('pick_local_project_directory', { + title: '选择项目创建目录', + }); + expect(window.localStorage.getItem(storageKey)).toBe( + JSON.stringify(creationDirectory), + ); + expect(within(dialog).getByText('已更新项目创建目录')).not.toBeNull(); + + fireEvent.click( + within(dialog).getByRole('button', { name: '恢复默认位置' }), + ); + expect(within(dialog).getByText('默认位置')).not.toBeNull(); + expect(window.localStorage.getItem(storageKey)).toBeNull(); + expect(within(dialog).getByText('已恢复默认位置')).not.toBeNull(); + }); + it('locks the Agent mode and official LLM route while dropping legacy credentials', async () => { const invoke = vi.fn( async (command: string, args?: Record) => { @@ -1034,7 +1098,7 @@ export function registerPublishedRuntimeSettingsTests() { autoCompactTokenLimit: 64000, toolOutputTokenLimit: 12000, requestTimeoutMs: 180000, - maxRetries: 2, + maxRetries: 10, retryBackoffMs: 500, }), agentLlm: {}, diff --git a/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts index 8c4b44ab7..f80387a70 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/tool-call-group.suite.ts @@ -1,13 +1,18 @@ +import { act } from '@testing-library/react'; +import { vi } from 'vitest'; + import type { GameCreatorDirectToolCall } from '../../src/app/types'; import { ToolCallGroup } from '../../src/features/project-workspace/ToolCallGroup'; import { + formatClockTime, formatToolCallDuration, formatTurnDuration, + resolveToolGroupTiming, + resolveTurnTiming, toolCallDurationMs, toolCallGroupSummary, toolCallRowText, - turnToolCallDurationMs, - turnToolCallTimeLabel, + turnTotalDurationMs, } from '../../src/features/project-workspace/toolCallGroupPresentation'; import { expect, fireEvent, it, React, render, within } from './harness'; @@ -29,13 +34,39 @@ function toolCall( } export function registerToolCallGroupTests() { - it('summarizes tool calls by kind in a fixed order', () => { - // 单 kind。 - expect(toolCallGroupSummary([toolCall({ id: 'a', kind: 'command' })])).toBe( - '已执行 1 个命令', + it('keeps failed operations visible when another tool in the group is running', () => { + const view = render( + React.createElement(ToolCallGroup, { + active: true, + calls: [ + toolCall({ id: 'failed', kind: 'command', status: 'failed' }), + toolCall({ id: 'live', kind: 'command', status: 'running' }), + toolCall({ id: 'done', kind: 'command', status: 'completed' }), + ], + }), + ); + const group = within(view.container).getByTestId('agent-tool-call-group'); + const head = within(group).getByTestId('agent-tool-call-group-head'); + expect(group.getAttribute('data-status')).toBe('running'); + expect(group.getAttribute('data-has-failure')).toBe('true'); + expect(head.textContent).toContain('有操作失败'); + expect(head.textContent).toContain('进行中'); + fireEvent.click(head); + const rows = within(group).getAllByTestId('agent-tool-call-row'); + expect(rows.map((row) => row.getAttribute('data-status'))).toEqual([ + 'failed', + 'running', + 'completed', + ]); + expect(within(rows[0]).getByText('失败')).not.toBeNull(); + expect(within(rows[2]).queryByText('失败')).toBeNull(); + }); + + it('summarizes the group as its own operation count', () => { + // 用户口径:`执行了 X 个操作`,X = 这一组自己的调用数(不拆 kind、不看段落数)。 + expect(toolCallGroupSummary([toolCall({ id: 'a', kind: 'command' })])).toBe( + '执行了 1 个操作', ); - // 混合:顺序固定 command → file_change → mcp_tool → web_search → - // context_compaction → other,与传入顺序无关。 expect( toolCallGroupSummary([ toolCall({ id: 'a', kind: 'other' }), @@ -46,10 +77,8 @@ export function registerToolCallGroupTests() { toolCall({ id: 'f', kind: 'context_compaction' }), toolCall({ id: 'g', kind: 'mcp_tool' }), ]), - ).toBe( - '已执行 2 个命令、1 个文件变更、1 个工具调用、1 个联网搜索、1 个上下文整理、1 个其他操作', - ); - // 空集合。 + ).toBe('执行了 7 个操作'); + // 空集合不渲染汇总。 expect(toolCallGroupSummary([])).toBe(''); }); @@ -117,12 +146,10 @@ export function registerToolCallGroupTests() { // 块头是一行按钮:图标 + 汇总 + 展开箭头,默认折叠,正文由 `hidden` 收起。 expect(head.tagName).toBe('BUTTON'); expect(head.getAttribute('aria-expanded')).toBe('false'); - expect(head.getAttribute('aria-label')).toBe( - '已执行 1 个命令、1 个文件变更', - ); + expect(head.getAttribute('aria-label')).toBe('执行了 2 个操作'); expect( - head.querySelector('.agent-tool-call-group-summary')?.textContent, - ).toBe('已执行 1 个命令、1 个文件变更'); + head.querySelector('.agent-process-summary-preview')?.textContent, + ).toBe('执行了 2 个操作'); const body = container.querySelector( `#${head.getAttribute('aria-controls')}`, ); @@ -219,12 +246,11 @@ export function registerToolCallGroupTests() { }); it('formats durations and turn totals across the documented boundaries', () => { - // 单条工具耗时:`startedAt` 缺失(0)/ 0 / 时间倒序 → 不显示耗时。 + // 单条工具耗时:开始边界缺失(0)/ 终点缺失 / 时间倒序 → 不显示耗时。 expect( toolCallDurationMs(toolCall({ id: 'a', kind: 'command' })), ).toBeNull(); expect(formatToolCallDuration(null)).toBeNull(); - expect(formatToolCallDuration(0)).toBeNull(); expect( toolCallDurationMs( toolCall({ @@ -235,48 +261,60 @@ export function registerToolCallGroupTests() { }), ), ).toBeNull(); - // <1s 一位小数;<60s 整秒省略小数;≥60s 用 `Xm Ys`。 - expect(formatToolCallDuration(400)).toBe('0.4s'); - expect(formatToolCallDuration(950)).toBe('1s'); - expect(formatToolCallDuration(12300)).toBe('12.3s'); - expect(formatToolCallDuration(12000)).toBe('12s'); - expect(formatToolCallDuration(125000)).toBe('2m 5s'); - expect(formatToolCallDuration(120000)).toBe('2m'); + // 合法 0 显示 0.0秒;分钟以内保留小数,达到分钟后显示整数秒。 + expect(formatToolCallDuration(0)).toBe('0.0秒'); + expect(formatToolCallDuration(400)).toBe('0.4秒'); + expect(formatToolCallDuration(950)).toBe('1.0秒'); + expect(formatToolCallDuration(12300)).toBe('12.3秒'); + expect(formatToolCallDuration(12000)).toBe('12.0秒'); + expect(formatToolCallDuration(59900)).toBe('59.9秒'); + expect(formatToolCallDuration(60000)).toBe('1分00秒'); + expect(formatToolCallDuration(125000)).toBe('2分05秒'); - // 一回合总用时 = min(startedAt) → max(updatedAt);缺时间戳的工具被跳过。 - const calls = [ - toolCall({ id: 'a', kind: 'command', startedAt: 5000, updatedAt: 6000 }), - toolCall({ - id: 'b', - kind: 'file_change', - startedAt: 1000, - updatedAt: 9000, - }), - toolCall({ id: 'c', kind: 'web_search' }), - ]; - expect(turnToolCallDurationMs(calls)).toBe(8000); - expect(formatTurnDuration(8000)).toBe('8秒'); - expect(formatTurnDuration(42000)).toBe('42秒'); - expect(formatTurnDuration(240000)).toBe('4分钟'); - expect(formatTurnDuration(345000)).toBe('5分钟 45秒'); + // 整轮总耗时 = 本轮起点 → 本轮终态(不是块内工具的时间跨度)。 + expect(turnTotalDurationMs({ startedAt: 1000, endedAt: 9000 })).toBe(8000); + // 运行中用调用方给的宿主时钟当终点;结束早于开始 / 缺任一端都算不出来。 + expect( + turnTotalDurationMs({ startedAt: 1000, running: true, now: 4200 }), + ).toBe(3200); + expect(turnTotalDurationMs({ startedAt: 1000, endedAt: 900 })).toBeNull(); + // 倒序不能被 100ms 展示舍入掩盖成合法 0.0 秒。 + expect(turnTotalDurationMs({ startedAt: 1049, endedAt: 1001 })).toBeNull(); + expect(turnTotalDurationMs({ startedAt: 0, endedAt: 9000 })).toBeNull(); + expect(turnTotalDurationMs({ startedAt: 1000, endedAt: 0 })).toBeNull(); + // 总耗时复用同一格式,达到分钟后不再显示小数。 + expect(formatTurnDuration(0)).toBe('0.0秒'); + expect(formatTurnDuration(8000)).toBe('8.0秒'); + expect(formatTurnDuration(42000)).toBe('42.0秒'); + expect(formatTurnDuration(59900)).toBe('59.9秒'); + expect(formatTurnDuration(60000)).toBe('1分00秒'); + expect(formatTurnDuration(240000)).toBe('4分00秒'); + expect(formatTurnDuration(345000)).toBe('5分45秒'); expect(formatTurnDuration(null)).toBeNull(); - expect(formatTurnDuration(0)).toBeNull(); - // 全部没有时间戳时算不出总用时。 - expect( - turnToolCallDurationMs([toolCall({ id: 'd', kind: 'command' })]), - ).toBe(null); + expect(formatTurnDuration(undefined)).toBeNull(); - // 块头时间:取得到用户消息时间就是「发送 → 结束」,取不到只显示结束时间,都取不到就不显示。 - expect(turnToolCallTimeLabel(calls, 1000)).toMatch( - /^\d{2}:\d{2}:01 → \d{2}:\d{2}:09$/, - ); - expect(turnToolCallTimeLabel(calls, 0)).toMatch(/^\d{2}:\d{2}:09$/); + // 时间范围与总耗时同一组边界:起点 / 终点都取整到 100ms 网格,精度不会互相矛盾。 + const timing = resolveTurnTiming({ startedAt: 1000, endedAt: 42000 }); + expect(timing.durationMs).toBe(41000); + expect(timing.durationText).toBe('41.0秒'); + expect(timing.timeLabel).toMatch(/^\d{2}:\d{2}:01\.0 → \d{2}:\d{2}:42\.0$/); + // 起点未知:只显示终点时间,耗时隐藏(不编造)。 + const noStart = resolveTurnTiming({ startedAt: 0, endedAt: 42000 }); + expect(noStart.durationMs).toBeNull(); + expect(noStart.durationText).toBeNull(); + expect(noStart.timeLabel).toMatch(/^\d{2}:\d{2}:42\.0$/); + // 两端都没有:整块不显示时间和耗时。 expect( - turnToolCallTimeLabel([toolCall({ id: 'e', kind: 'command' })], 0), - ).toBe(null); + resolveTurnTiming({ startedAt: 0, endedAt: 0 }).timeLabel, + ).toBeNull(); + expect(formatClockTime(0)).toBeNull(); }); - it('renders per-row durations plus the turn total, and nothing when timestamps are missing', () => { + /** + * 组头显示的是**本组**用时(`min(开始)` → `max(完成)`),与整轮总耗时无关: + * 组头不再接收任何回合边界,也不会重复渲染整轮时间范围。 + */ + it('renders per-row durations plus this group total, and nothing when timestamps are missing', () => { const { container } = render( React.createElement(ToolCallGroup, { calls: [ @@ -303,33 +341,32 @@ export function registerToolCallGroupTests() { updatedAt: 17900, }), ], - userSentAt: 1000, }), ); const group = container.querySelector( '[data-testid="agent-tool-call-group"]', ) as HTMLElement; - // 总用时:1000 → 17900,块头显示「用时 17秒」,`data-duration-ms` 暴露原始毫秒。 + // 本组耗时读**这一组**的边界:1000 → 17900,块头显示「耗时 16.9秒」, + // `data-duration-ms` 暴露取整到 100ms 网格后的展示耗时。 expect(group.getAttribute('data-duration-ms')).toBe('16900'); const head = within(group).getByTestId('agent-tool-call-group-head'); - expect(head.textContent).toContain('用时 17秒'); - expect(head.getAttribute('aria-label')).toBe( - '已执行 1 个命令、1 个文件变更、1 个联网搜索,用时 17秒', + expect(head.textContent).toContain('耗时 16.9秒'); + // 整轮总耗时只在对话底部渲染一处,组头不再出现"总耗时",也不再显示时间范围。 + expect(head.textContent).not.toContain('总耗时'); + expect(head.querySelector('.agent-tool-call-group-time')).toBeNull(); + expect(head.getAttribute('aria-label')).toMatch( + /^执行了 3 个操作,耗时 16\.9秒$/, ); - // 时间戳不写死时区:`HH:mm:ss → HH:mm:ss`(发送 → 结束)。 - expect( - head.querySelector('.agent-tool-call-group-time')?.textContent, - ).toMatch(/^\d{2}:\d{2}:\d{2} → \d{2}:\d{2}:\d{2}$/); fireEvent.click(head); const rows = within(group).queryAllByTestId('agent-tool-call-row'); expect(rows[0]?.getAttribute('data-duration-ms')).toBe('400'); - expect(within(rows[0] as HTMLElement).getByText('0.4s')).not.toBeNull(); + expect(within(rows[0] as HTMLElement).getByText('0.4秒')).not.toBeNull(); expect(rows[1]?.getAttribute('data-duration-ms')).toBe('16500'); - expect(within(rows[1] as HTMLElement).getByText('16.5s')).not.toBeNull(); - // startedAt === updatedAt:耗时为 0 —— `data-duration-ms` 如实暴露 0,但行上不显示 `0s`。 + expect(within(rows[1] as HTMLElement).getByText('16.5秒')).not.toBeNull(); + // startedAt === updatedAt:合法 0,按契约显示 `0.0s`。 expect(rows[2]?.getAttribute('data-duration-ms')).toBe('0'); - expect(within(rows[2] as HTMLElement).queryByText('0s')).toBeNull(); + expect(within(rows[2] as HTMLElement).getByText('0.0秒')).not.toBeNull(); // 时间只显示在块头,展开后不重复追加块尾时间。 expect( within(group).queryByTestId('agent-tool-call-group-end-time'), @@ -350,15 +387,314 @@ export function registerToolCallGroupTests() { expect( within(missingGroup).getByTestId('agent-tool-call-group-head') .textContent, - ).toBe('已执行 1 个命令'); + ).toBe('执行了 1 个操作'); fireEvent.click( within(missingGroup).getByTestId('agent-tool-call-group-head'), ); const missingRow = within(missingGroup).getByTestId('agent-tool-call-row'); expect(missingRow.getAttribute('data-duration-ms')).toBe(''); - expect(within(missingRow).queryByText('0s')).toBeNull(); + expect(within(missingRow).queryByText('0秒')).toBeNull(); expect( missingGroup.querySelector('.agent-tool-call-group-end-time'), ).toBeNull(); }); + + /** + * 动态计时:每 100ms 更新,分钟以内显示小数,达到分钟后显示整数秒。 + * + * 假时钟同时接管 `Date.now()`:`useLiveNow` 读时间戳算差值,不按 tick 累加。 + */ + it('grows this group total every 100ms while one of its tools runs and freezes when the group finishes', () => { + vi.useFakeTimers(); + const base = Date.now(); + const calls = [ + toolCall({ + id: 'live-a', + kind: 'command', + summary: 'npm run build', + status: 'running', + // 本组最早的工具起点:400ms 前开始。 + startedAt: base - 400, + }), + toolCall({ + id: 'live-b', + kind: 'web_search', + summary: '玩法调研', + status: 'running', + startedAt: base - 100, + }), + ]; + const view = render( + React.createElement(ToolCallGroup, { + calls, + active: true, + }), + ); + const group = view.container.querySelector( + '[data-testid="agent-tool-call-group"]', + ) as HTMLElement; + const head = within(group).getByTestId('agent-tool-call-group-head'); + // 只有本组还有工具在跑时才标"进行中",并且组头显示的是本组耗时。 + expect(head.textContent).toContain('进行中'); + expect(head.textContent).toContain('耗时 0.4秒'); + expect(group.getAttribute('data-duration-ms')).toBe('400'); + + // 没有新事件,时间推进组用时也增长;5000ms 后是 `5.4秒`。 + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(group.getAttribute('data-duration-ms')).toBe('5400'); + expect(head.textContent).toContain('耗时 5.4秒'); + // 分钟进位后隐藏小数:65400ms → 1分05秒。 + act(() => { + vi.advanceTimersByTime(60000); + }); + expect(head.textContent).toContain('耗时 1分05秒'); + + // 组内工具全部拿到终态:组用时冻结在**工具终点**上(不是当前时钟), + // 继续推进时钟不再变化,也不等回合收口。 + // 本组最后一条工具终点:base + 64600 → 与最早起点(base - 400)相差 65000。 + const lastEnd = base + 64600; + view.rerender( + React.createElement(ToolCallGroup, { + calls: [ + { ...calls[0], status: 'completed' as const, updatedAt: lastEnd }, + { + ...calls[1], + status: 'completed' as const, + updatedAt: lastEnd - 400, + }, + ], + active: true, + }), + ); + act(() => { + vi.advanceTimersByTime(30000); + }); + expect(group.getAttribute('data-duration-ms')).toBe('65000'); + expect(head.textContent).toContain('耗时 1分05秒'); + expect(head.textContent).not.toContain('进行中'); + view.unmount(); + vi.useRealTimers(); + }); + + /** + * 两组各自读自己的边界:起点不同、前组跑完立刻冻结,后组继续增长(这是本轮修掉的 bug —— + * 之前两组共享同一个整轮边界,前组明明跑完却仍显示"进行中 / 同一个总耗时")。 + */ + it('reads each group own boundary: a finished group stays fixed while a later group keeps running', () => { + vi.useFakeTimers(); + const base = Date.now(); + const finishedCall = toolCall({ + id: 'finished-call', + kind: 'command', + summary: 'npm run build', + // 先跑的那一组:130 秒前开始,2 秒后结束。 + startedAt: base - 130000, + updatedAt: base - 128000, + }); + const laterRunningCall = toolCall({ + id: 'later-running-call', + kind: 'file_change', + summary: 'game/src/hero.ts', + status: 'running', + // 另一组起点完全不同(5 秒前):两组用时必须各算各的。 + startedAt: base - 5000, + }); + const firstGroup = render( + React.createElement(ToolCallGroup, { + calls: [finishedCall], + active: true, + }), + ); + const secondGroup = render( + React.createElement(ToolCallGroup, { + calls: [laterRunningCall], + active: true, + }), + ); + const first = firstGroup.container.querySelector( + '[data-testid="agent-tool-call-group"]', + ) as HTMLElement; + const second = secondGroup.container.querySelector( + '[data-testid="agent-tool-call-group"]', + ) as HTMLElement; + // 已跑完的那一组:用时固定 2.0 秒,且不再标"进行中"。 + const finishedHead = within(first).getByTestId( + 'agent-tool-call-group-head', + ); + expect(first.getAttribute('data-duration-ms')).toBe('2000'); + expect(finishedHead.textContent).toContain('耗时 2.0秒'); + expect(finishedHead.textContent).not.toContain('进行中'); + // 后开始的那一组:只算自己的 5 秒起点 → 当前时钟,仍标"进行中"。 + const runningHead = within(second).getByTestId( + 'agent-tool-call-group-head', + ); + expect(second.getAttribute('data-duration-ms')).toBe('5000'); + expect(runningHead.textContent).toContain('耗时 5.0秒'); + expect(runningHead.textContent).toContain('进行中'); + // 行级:已完成的工具冻结在 2.0 秒,运行中的工具按当前时钟增长。 + fireEvent.click(finishedHead); + const finishedRow = within(first).getByTestId('agent-tool-call-row'); + expect(finishedRow.getAttribute('data-duration-ms')).toBe('2000'); + fireEvent.click(runningHead); + const runningRow = within(second).getByTestId('agent-tool-call-row'); + expect(runningRow.getAttribute('data-duration-ms')).toBe('5000'); + // 前一组跑完的组不会因为后一组还在跑而继续增长。 + act(() => { + vi.advanceTimersByTime(125000); + }); + expect(first.getAttribute('data-duration-ms')).toBe('2000'); + expect(second.getAttribute('data-duration-ms')).toBe('130000'); + expect(runningRow.getAttribute('data-duration-ms')).toBe('130000'); + + firstGroup.unmount(); + secondGroup.unmount(); + vi.useRealTimers(); + }); + + /** + * 组用时纯函数:只读本组边界、缺边界隐藏、倒序隐藏、一位小数。 + */ + it('computes the group total from this group only, hiding unknown or reversed boundaries', () => { + // 本组自己 min(开始) → max(完成)。 + expect( + resolveToolGroupTiming([ + toolCall({ + id: 'a', + kind: 'command', + startedAt: 10_000, + updatedAt: 12_000, + }), + toolCall({ + id: 'b', + kind: 'web_search', + startedAt: 11_000, + updatedAt: 42_000, + }), + ]), + ).toEqual({ durationMs: 32_000, durationText: '32.0秒', running: false }); + // 缺任何开始边界就整组隐藏(已知的单行耗时不受影响)。 + expect( + resolveToolGroupTiming([ + toolCall({ + id: 'a', + kind: 'command', + startedAt: 10_000, + updatedAt: 12_000, + }), + toolCall({ id: 'b', kind: 'web_search', updatedAt: 42_000 }), + ]), + ).toEqual({ durationMs: null, durationText: null, running: false }); + // 已结束却没有终态时间 → 隐藏(不用别组 / 整轮边界补)。 + expect( + resolveToolGroupTiming([ + toolCall({ + id: 'a', + kind: 'command', + startedAt: 10_000, + updatedAt: 12_000, + }), + toolCall({ id: 'b', kind: 'web_search', startedAt: 11_000 }), + ]), + ).toEqual({ durationMs: null, durationText: null, running: false }); + // 倒序 / 非有限值 → 隐藏,不伪造 0.0 秒。 + expect( + resolveToolGroupTiming([ + toolCall({ + id: 'a', + kind: 'command', + startedAt: 12_000, + updatedAt: 10_000, + }), + ]).durationText, + ).toBeNull(); + // 单条边界先判:一条正常的工具不能把同组另一条倒序的工具掩盖成合法区间。 + expect( + resolveToolGroupTiming([ + toolCall({ + id: 'ok', + kind: 'command', + startedAt: 10_000, + updatedAt: 12_000, + }), + toolCall({ + id: 'reversed', + kind: 'web_search', + startedAt: 11_000, + updatedAt: 10_500, + }), + ]), + ).toEqual({ durationMs: null, durationText: null, running: false }); + // 亚 100ms 倒序(1049 → 1001)不能被展示量化抹成 `0.0秒`。 + expect( + resolveToolGroupTiming([ + toolCall({ + id: 'sub-tick-reversed', + kind: 'command', + startedAt: 1049, + updatedAt: 1001, + }), + ]).durationText, + ).toBeNull(); + // 运行中的工具:`now` 早于它自己的起点时整组隐藏,哪怕同组另一条正常工具区间合法。 + expect( + resolveToolGroupTiming( + [ + toolCall({ + id: 'ok', + kind: 'command', + startedAt: 9_000, + updatedAt: 20_000, + }), + toolCall({ + id: 'running-behind-now', + kind: 'web_search', + status: 'running', + startedAt: 10_000, + }), + ], + { running: true, now: 9_500 }, + ), + ).toEqual({ durationMs: null, durationText: null, running: false }); + expect( + resolveToolGroupTiming([ + toolCall({ + id: 'a', + kind: 'command', + startedAt: Number.NaN, + updatedAt: 10_000, + }), + ]).durationText, + ).toBeNull(); + // 运行中的工具:回合在跑时用当前时钟当终点;回合已结束时留着 running 快照也不编造。 + const running = toolCall({ + id: 'a', + kind: 'command', + status: 'running', + startedAt: 10_000, + }); + expect( + resolveToolGroupTiming([running], { running: true, now: 13_400 }), + ).toEqual({ durationMs: 3400, durationText: '3.4秒', running: true }); + expect( + resolveToolGroupTiming([running], { running: false, now: 13_400 }), + ).toEqual({ durationMs: null, durationText: null, running: false }); + // 合法的同一时刻显示 0.0 秒。 + expect( + resolveToolGroupTiming([ + toolCall({ + id: 'a', + kind: 'command', + startedAt: 10_000, + updatedAt: 10_000, + }), + ]).durationText, + ).toBe('0.0秒'); + expect(resolveToolGroupTiming([])).toEqual({ + durationMs: null, + durationText: null, + running: false, + }); + }); } diff --git a/apps/ai-game-creator-shell/tests/appUpdate.test.ts b/apps/ai-game-creator-shell/tests/appUpdate.test.ts index 8bbe1e596..bd89c3a3a 100644 --- a/apps/ai-game-creator-shell/tests/appUpdate.test.ts +++ b/apps/ai-game-creator-shell/tests/appUpdate.test.ts @@ -1,47 +1,137 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { check } from '@tauri-apps/plugin-updater'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -import { - isNewerVersion, - parseAppUpdateManifest, - resetAppUpdateCheckForTests, -} from '../src/services/appUpdate'; +vi.mock('@tauri-apps/plugin-updater', () => ({ check: vi.fn() })); -afterEach(() => resetAppUpdateCheckForTests()); +const checkMock = vi.mocked(check); -describe('AGC update manifest', () => { - it('compares semantic versions and accepts v prefixes', () => { - expect(isNewerVersion('v0.1.13', '0.1.12')).toBe(true); - expect(isNewerVersion('0.1.12', '0.1.12')).toBe(false); - expect(isNewerVersion('0.1.11', '0.1.12')).toBe(false); +type FakeDownloadEvent = + | { event: 'Started'; data: { contentLength?: number } } + | { event: 'Progress'; data: { chunkLength: number } } + | { event: 'Finished' }; + +function fakeUpdate() { + return { + version: '99.0.0', + currentVersion: '0.1.47', + body: '修复与改进', + downloadAndInstall: vi.fn( + async (onEvent: (event: FakeDownloadEvent) => void) => { + onEvent({ event: 'Started', data: { contentLength: 100 } }); + onEvent({ event: 'Progress', data: { chunkLength: 40 } }); + onEvent({ event: 'Progress', data: { chunkLength: 60 } }); + onEvent({ event: 'Finished' }); + }, + ), + }; +} + +function stubTauriWindow() { + const invoke = vi.fn(async () => undefined); + vi.stubGlobal('window', { __TAURI__: { core: { invoke } } }); + return invoke; +} + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.resetModules(); + checkMock.mockReset(); +}); + +describe('AGC 客户端更新', () => { + it('开发态开关关闭时不请求清单', async () => { + vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '0'); + vi.resetModules(); + const { checkForAppUpdate, resetAppUpdateCheckForTests } = await import( + '../src/services/appUpdate' + ); + + await expect(checkForAppUpdate()).resolves.toBeNull(); + expect(checkMock).not.toHaveBeenCalled(); + resetAppUpdateCheckForTests(); }); - it('validates an OSS manifest and rejects non-HTTPS downloads', () => { - expect( - parseAppUpdateManifest({ - version: '0.1.13', - downloadUrl: 'https://oss.example/agc.exe', - }), - ).toMatchObject({ - version: '0.1.13', - downloadUrl: 'https://oss.example/agc.exe', + it('开关打开时把插件返回的更新映射给界面,且同一生命周期只查一次', async () => { + vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1'); + vi.resetModules(); + checkMock.mockResolvedValue(fakeUpdate() as never); + const { checkForAppUpdate, resetAppUpdateCheckForTests } = await import( + '../src/services/appUpdate' + ); + + await expect(checkForAppUpdate()).resolves.toEqual({ + version: '99.0.0', + currentVersion: '0.1.47', + releaseNotes: '修复与改进', }); - expect( - parseAppUpdateManifest({ - version: '0.1.13', - downloadUrl: 'http://oss.example/agc.exe', - }), - ).toBeNull(); + await checkForAppUpdate(); + expect(checkMock).toHaveBeenCalledTimes(1); + resetAppUpdateCheckForTests(); }); - it('preserves multiline release notes', () => { - expect( - parseAppUpdateManifest({ - version: '0.1.13', - downloadUrl: 'https://oss.example/agc.exe', - releaseNotes: '第一行\n第二行\r\n第三行', - }), - ).toMatchObject({ - releaseNotes: '第一行\n第二行\r\n第三行', - }); + it('清单缺失或网络失败时静默按无更新收口', async () => { + vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1'); + vi.resetModules(); + checkMock.mockRejectedValue(new Error('updater: manifest 404')); + const { checkForAppUpdate, resetAppUpdateCheckForTests } = await import( + '../src/services/appUpdate' + ); + + await expect(checkForAppUpdate()).resolves.toBeNull(); + resetAppUpdateCheckForTests(); + }); + + it('安装时按下载事件上报进度并在完成后重启进程', async () => { + vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1'); + vi.resetModules(); + const invoke = stubTauriWindow(); + const update = fakeUpdate(); + checkMock.mockResolvedValue(update as never); + const { checkForAppUpdate, installAppUpdate, resetAppUpdateCheckForTests } = + await import('../src/services/appUpdate'); + + await checkForAppUpdate(); + const progress: Array<{ downloadedBytes: number; totalBytes?: number }> = + []; + await installAppUpdate((value) => progress.push(value)); + + expect(update.downloadAndInstall).toHaveBeenCalledTimes(1); + expect(progress).toEqual([ + { downloadedBytes: 0, totalBytes: 100 }, + { downloadedBytes: 40, totalBytes: 100 }, + { downloadedBytes: 100, totalBytes: 100 }, + { downloadedBytes: 100, totalBytes: 100 }, + ]); + expect(invoke).toHaveBeenCalledWith('restart_agc_app'); + resetAppUpdateCheckForTests(); + }); + + it('没有待安装更新时安装请求失败关闭', async () => { + vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1'); + vi.resetModules(); + const { installAppUpdate, resetAppUpdateCheckForTests } = await import( + '../src/services/appUpdate' + ); + + await expect(installAppUpdate()).rejects.toThrow('没有可安装的更新'); + resetAppUpdateCheckForTests(); + }); + + it('下载失败后仍可重试安装', async () => { + vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1'); + vi.resetModules(); + const update = fakeUpdate(); + update.downloadAndInstall.mockRejectedValue(new Error('下载更新失败')); + checkMock.mockResolvedValue(update as never); + const { checkForAppUpdate, installAppUpdate, resetAppUpdateCheckForTests } = + await import('../src/services/appUpdate'); + + await checkForAppUpdate(); + await expect(installAppUpdate()).rejects.toThrow('下载更新失败'); + // 重试仍能拿到待装更新,而不是报“没有可安装的更新”。 + await expect(installAppUpdate()).rejects.toThrow('下载更新失败'); + expect(update.downloadAndInstall).toHaveBeenCalledTimes(2); + resetAppUpdateCheckForTests(); }); }); diff --git a/apps/ai-game-creator-shell/tests/clientApi.test.ts b/apps/ai-game-creator-shell/tests/clientApi.test.ts index 91d2a90c6..34639803c 100644 --- a/apps/ai-game-creator-shell/tests/clientApi.test.ts +++ b/apps/ai-game-creator-shell/tests/clientApi.test.ts @@ -8,6 +8,7 @@ import { } from '../src/services/clientApi'; import { getClientAuthRefreshOperation, + getStoredAuthAccessToken, refreshClientAuthAccessToken, } from '../src/services/clientAuth'; import { CLIENT_HTTP_DEFAULT_TIMEOUT_MS } from '../src/services/clientHttp'; @@ -142,15 +143,53 @@ it('并发模型请求共享续期,并在安装 Rust 会话后使用新 token expect(fetch).toHaveBeenCalledTimes(6); }); -it.each([401])('续期失败保留原 HTTP %s,且不重发业务请求', async (status) => { +it.each([401])( + '续期被明确拒绝时保留原 HTTP %s,且不重发业务请求', + async (status) => { + let refreshCalls = 0; + const fetch = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async (input) => { + if (String(input) === '/api/auth/refresh') { + refreshCalls += 1; + return json({}, 401); + } + return json({}, status); + }); + await expect( + requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'), + ).rejects.toMatchObject({ status }); + // 401 刷新先用当前 cookie 收敛重试一次;重试仍被拒绝才算登录态权威失效, + // 而且不能把一次卡片级失败放大成全局登出。 + expect(refreshCalls).toBe(2); + expect(fetch).toHaveBeenCalledTimes(3); + expect(getStoredAuthAccessToken()).toBe(''); + }, +); + +it('续期 401 后用当前 cookie 收敛重试并继续业务请求', async () => { + let refreshCalls = 0; const fetch = vi .spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(json({}, status)) - .mockResolvedValueOnce(json({}, 401)); + .mockImplementation(async (input, init) => { + if (String(input) === '/api/auth/refresh') { + refreshCalls += 1; + return refreshCalls === 1 + ? json({}, 401) + : json({ token: 'rotated-token' }); + } + if (String(input) === '/api/auth/me') return json({ user }); + const token = new Headers(init?.headers).get('Authorization'); + if (token === 'Bearer expired-token') return json({}, 401); + expect(token).toBe('Bearer rotated-token'); + return json(catalog); + }); + await expect( requestClientApi('/api/llm/models', { method: 'GET' }, '读取失败'), - ).rejects.toMatchObject({ status }); - expect(fetch).toHaveBeenCalledTimes(2); + ).resolves.toEqual(catalog); + expect(refreshCalls).toBe(2); + expect(getStoredAuthAccessToken()).toBe('rotated-token'); }); it('跳过鉴权的请求不触发续期', async () => { diff --git a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx index 9ee9e06d0..a506c23ae 100644 --- a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx +++ b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx @@ -22,7 +22,10 @@ import { loadClientLlmModels, } from '../src/services/clientApi'; import { ClientHttpTimeoutError } from '../src/services/clientHttp'; -import { resetLlmModelCatalogCacheForTest } from '../src/services/llmModelCatalog'; +import { + notifyLlmConfigChanged, + resetLlmModelCatalogCacheForTest, +} from '../src/services/llmModelCatalog'; vi.mock('../src/app/tauri', () => ({ resolveTauriInvoke: vi.fn() })); const MockClientAuthRequestError = vi.hoisted( @@ -79,6 +82,100 @@ beforeEach(() => { }); afterEach(cleanup); +test('turning custom mode off cannot reuse custom models when the official catalog fails', async () => { + let customEnabled = true; + savedModelId = 'custom-model'; + invoke.mockImplementation(async (command, input) => { + if (command === 'select_game_creator_model') { + savedModelId = input.modelId; + savedModelIsDefault = input.isDefault; + } + return { + config: { + llm: { + customEnabled, + baseUrl: 'https://custom.example/v1', + visibleModels: ['custom-model'], + }, + selectedModelId: savedModelId, + selectedModelIsDefault: savedModelIsDefault, + }, + }; + }); + const onReady = await renderReadyModelMenu(); + expect(screen.getByRole('option', { name: /custom-model/ })).not.toBeNull(); + customEnabled = false; + vi.mocked(loadClientLlmModels).mockRejectedValueOnce( + new Error('unavailable'), + ); + fireEvent(window, new Event('focus')); + await screen.findByText('模型列表加载失败'); + expect(onReady).toHaveBeenLastCalledWith(false); + expect(screen.queryByRole('option', { name: /custom-model/ })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' })); + await screen.findByRole('option', { name: /高质量/ }); + expect(savedModelId).toBe('quality'); +}); + +test('custom mode only shows checked endpoint model IDs and never requests the platform catalog', async () => { + savedModelId = 'vendor/model.v1'; + const models = ['vendor/model.v1', 'vendor/fast:latest']; + invoke.mockImplementation(async (command, input) => { + if (command === 'select_game_creator_model') { + savedModelId = input.modelId; + savedModelIsDefault = input.isDefault; + } + return { + config: { + llm: { + customEnabled: true, + baseUrl: 'https://custom.example/v1', + visibleModels: models, + }, + selectedModelId: savedModelId, + selectedModelIsDefault: savedModelIsDefault, + }, + }; + }); + await renderReadyModelMenu(); + expect(screen.getAllByRole('option')).toHaveLength(2); + fireEvent.click(screen.getByRole('option', { name: 'vendor/fast:latest' })); + await waitFor(() => expect(savedModelId).toBe('vendor/fast:latest')); + expect(loadClientLlmModels).not.toHaveBeenCalled(); +}); + +test('saving a custom catalog replaces official models and falls back when the old selection is unchecked', async () => { + await renderReadyModelMenu(); + vi.mocked(loadClientLlmModels).mockClear(); + let models = ['custom.v1', 'custom.v2']; + invoke.mockImplementation(async (command, input) => { + if (command === 'select_game_creator_model') { + savedModelId = input.modelId; + savedModelIsDefault = input.isDefault; + } + return { + config: { + llm: { + customEnabled: true, + baseUrl: 'https://custom.example/v1', + visibleModels: models, + }, + selectedModelId: savedModelId, + selectedModelIsDefault: savedModelIsDefault, + }, + }; + }); + act(() => notifyLlmConfigChanged()); + await waitFor(() => expect(savedModelId).toBe('custom.v1')); + expect(screen.queryByRole('option', { name: /高质量/ })).toBeNull(); + expect(screen.getAllByRole('option')).toHaveLength(2); + models = ['custom.v2']; + act(() => notifyLlmConfigChanged()); + await waitFor(() => expect(savedModelId).toBe('custom.v2')); + expect(screen.getAllByRole('option')).toHaveLength(1); + expect(loadClientLlmModels).not.toHaveBeenCalled(); +}); + async function renderReadyModelMenu() { const onReady = vi.fn(); render(); @@ -111,6 +208,7 @@ test('shows manual refresh progress immediately without clearing the selected mo fireEvent.keyDown(document, { key: 'Escape' }); expect(screen.getByRole('status').textContent).toBe('正在刷新模型列表'); + await waitFor(() => expect(resolveRefresh).toBeTypeOf('function')); await act(async () => { resolveRefresh({ defaultModelId: 'quality', @@ -498,11 +596,11 @@ test('recovers the selector when reading the native config fails', async () => { await screen.findByText('读取客户端配置失败'); await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(false)); - // 失败后必须恢复可交互:选项与刷新按钮都不能被永久禁用。 + // 配置不可读时不能猜测官方路由;恢复读取后选项与刷新按钮重新可用。 + expect(loadClientLlmModels).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole('button', { name: '对话模型' })); - expect( - screen.getByRole('option', { name: /高质量/ }).hasAttribute('disabled'), - ).toBe(false); + const option = await screen.findByRole('option', { name: /高质量/ }); + expect(option.hasAttribute('disabled')).toBe(false); fireEvent.click(screen.getByRole('button', { name: '刷新模型列表' })); await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); expect(screen.queryByText('读取客户端配置失败')).toBeNull(); diff --git a/apps/ai-game-creator-shell/tests/customLlmSettings.test.tsx b/apps/ai-game-creator-shell/tests/customLlmSettings.test.tsx new file mode 100644 index 000000000..3a14cdc6a --- /dev/null +++ b/apps/ai-game-creator-shell/tests/customLlmSettings.test.tsx @@ -0,0 +1,157 @@ +// @vitest-environment jsdom +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { useState } from 'react'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; + +import defaults from '../game-creator.config.json'; +import type { + GameCreatorAppConfig, + GameCreatorLlmConfig, +} from '../src/app/types'; +import { CustomLlmSettings } from '../src/features/runtime-config/CustomLlmSettings'; +import { RuntimeConfigDialog } from '../src/features/runtime-config/RuntimeConfigDialog'; + +const invoke = vi.fn(); +const llm = { + ...defaults.llm, + customEnabled: true, + baseUrl: 'https://models.example/v1', + apiKey: 'test-custom-key', + visibleModels: ['vendor/model.v1'], +} as GameCreatorLlmConfig; +function Harness() { + const [draft, setDraft] = useState(llm); + return ; +} + +beforeEach(() => { + invoke.mockReset(); + window.__TAURI__ = { core: { invoke } }; +}); +afterEach(() => { + cleanup(); + delete window.__TAURI__; +}); + +test('reads endpoint models, filters candidates, and previews only checked models', async () => { + invoke.mockResolvedValue([ + 'vendor/model.v1', + 'vendor/fast:latest', + 'hidden-model', + ]); + render(); + fireEvent.click(screen.getByRole('button', { name: '读取模型列表' })); + await screen.findByText('已读取 3 个模型'); + expect(invoke).toHaveBeenCalledWith('discover_game_creator_llm_models', { + llm, + }); + fireEvent.click(screen.getByRole('checkbox', { name: 'vendor/fast:latest' })); + const preview = screen.getByRole('region', { name: '已勾选模型预览' }); + expect( + within(preview) + .getAllByRole('listitem') + .map((item) => item.textContent), + ).toEqual(['vendor/model.v1 默认', 'vendor/fast:latest']); + expect(within(preview).queryByText('hidden-model')).toBeNull(); + fireEvent.change(screen.getByRole('textbox', { name: '搜索模型' }), { + target: { value: 'FAST' }, + }); + expect(screen.getAllByRole('checkbox')).toHaveLength(1); + fireEvent.click(screen.getByRole('checkbox', { name: 'vendor/fast:latest' })); + expect(within(preview).getAllByRole('listitem')).toHaveLength(1); +}); + +test('failed discovery preserves checked models and allows retry', async () => { + invoke + .mockRejectedValueOnce('模型列表读取失败(HTTP 401)') + .mockResolvedValueOnce(['vendor/model.v1']); + render(); + fireEvent.click(screen.getByRole('button', { name: '读取模型列表' })); + await screen.findByRole('alert'); + expect( + within(screen.getByRole('region', { name: '已勾选模型预览' })).getByText( + /vendor\/model.v1/, + ), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '读取模型列表' })); + await screen.findByText('已读取 1 个模型'); + expect(screen.queryByRole('alert')).toBeNull(); +}); + +test('changing endpoint discards an old in-flight result and clears old selections', async () => { + let finish!: (models: string[]) => void; + invoke.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + render(); + fireEvent.click(screen.getByRole('button', { name: '读取模型列表' })); + fireEvent.change(screen.getByLabelText('自定义 LLM API 地址'), { + target: { value: 'https://new.example/v1' }, + }); + await act(async () => finish(['old-model'])); + expect(screen.queryByRole('checkbox', { name: 'old-model' })).toBeNull(); + expect(screen.getByText('尚未勾选模型')).not.toBeNull(); + expect(screen.getByRole('button', { name: '读取模型列表' })).toHaveProperty( + 'disabled', + false, + ); +}); + +test('settings save retains custom credentials and checked models, and reopening restores them', async () => { + let config = { + ...defaults, + llm, + agentLlm: {}, + editorApi: { baseUrl: 'https://platform.example', apiKey: '' }, + } as GameCreatorAppConfig; + invoke.mockImplementation(async (command, args) => { + if (command === 'write_game_creator_app_config') config = args.config; + if ( + command === 'read_game_creator_app_config' || + command === 'write_game_creator_app_config' + ) + return { path: '/private/game-creator.config.json', config }; + if (command === 'discover_game_creator_llm_models') + return ['vendor/model.v1', 'vendor/fast:latest']; + return []; + }); + const first = render( {}} />); + await screen.findByLabelText('自定义 LLM API 地址'); + expect(screen.getByLabelText('自定义 LLM API Key')).toHaveProperty( + 'type', + 'password', + ); + expect(screen.getByText('OpenAI Responses')).not.toBeNull(); + expect(screen.getByText('最高')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '读取模型列表' })); + await screen.findByText('已读取 2 个模型'); + fireEvent.click(screen.getByRole('checkbox', { name: 'vendor/fast:latest' })); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + await waitFor(() => + expect(config.llm.visibleModels).toEqual([ + 'vendor/model.v1', + 'vendor/fast:latest', + ]), + ); + expect(config.llm.apiKey).toBe('test-custom-key'); + expect(config.llm.customEnabled).toBe(true); + first.unmount(); + render( {}} />); + await screen.findByLabelText('自定义 LLM API 地址'); + expect( + within(screen.getByRole('region', { name: '已勾选模型预览' })).getAllByRole( + 'listitem', + ), + ).toHaveLength(2); +}); diff --git a/apps/ai-game-creator-shell/tests/dev-feature-flags.test.ts b/apps/ai-game-creator-shell/tests/dev-feature-flags.test.ts new file mode 100644 index 000000000..0f93e5095 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/dev-feature-flags.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'vitest'; + +import { + agcAppUpdateCheckEnvKey, + withAgcDevFeatureFlags, +} from '../scripts/dev-feature-flags.mjs'; + +describe('AGC dev 特性开关环境', () => { + test('未显式配置时下发关闭检测更新的默认值', () => { + expect(withAgcDevFeatureFlags({ KEEP_ME: 'yes' })).toMatchObject({ + KEEP_ME: 'yes', + [agcAppUpdateCheckEnvKey]: '0', + }); + }); + + test('保留显式配置的开关取值,忽略空白取值', () => { + expect( + withAgcDevFeatureFlags({ [agcAppUpdateCheckEnvKey]: '1' }), + ).toMatchObject({ [agcAppUpdateCheckEnvKey]: '1' }); + expect( + withAgcDevFeatureFlags({ [agcAppUpdateCheckEnvKey]: ' ' }), + ).toMatchObject({ [agcAppUpdateCheckEnvKey]: '0' }); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx index 5a000bf39..043918975 100644 --- a/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx +++ b/apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx @@ -1,89 +1,241 @@ // @vitest-environment jsdom +import { + act, + cleanup, + fireEvent, + render, + renderHook, + screen, + waitFor, +} from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, expect, it, vi } from 'vitest'; - +import type { GameCreatorDirectActiveTurn } from '../src/app/types'; +import { + DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS, + useDirectActiveTurns, +} from '../src/features/agent-runtime/directActiveTurns'; import { ActiveProjectRunsPanel } from '../src/features/app-shell/ActiveProjectRunsPanel'; afterEach(() => cleanup()); -it('按开始时间展示正在运行的项目并支持进入项目', () => { - const onOpenProject = vi.fn(); - render( - , - ); +const ACTIVE_TURN = { + projectPath: 'C:/projects/demo', + agentId: 'project-supervisor', + runId: 'run-1', +} as unknown as GameCreatorDirectActiveTurn; - const items = screen.getAllByRole('button'); - expect(items.map((item) => item.textContent?.includes('先开始'))).toEqual([ - true, - false, - ]); - fireEvent.click(items[0]); - expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first'); +describe('useDirectActiveTurns', () => { + it('keeps the snapshot identity when the poll returns the same content', async () => { + // 回归点:轮询每次都 setActiveTurns(新数组) 会让所有依赖 activeTurns 的 effect + // 反复重跑(窗口标题栏的活动项目面板曾因此无限 setState)。 + const invoke = vi.fn(async () => [ACTIVE_TURN]) as never; + const { result } = renderHook(() => + useDirectActiveTurns({ + invoke, + enabled: true, + pollIntervalMs: DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS, + }), + ); + + await waitFor(() => { + expect(result.current.activeTurns).toHaveLength(1); + }); + const firstSnapshot = result.current.activeTurns; + + await act(async () => { + await result.current.refreshActiveTurns(); + await result.current.refreshActiveTurns(); + }); + expect(result.current.activeTurns).toBe(firstSnapshot); + }); + + it('clears to a stable empty snapshot when the hook is disabled', async () => { + let resolveSnapshot!: (turns: GameCreatorDirectActiveTurn[]) => void; + const snapshot = new Promise((resolve) => { + resolveSnapshot = resolve; + }); + const invoke = vi.fn(() => snapshot) as never; + const { result, rerender } = renderHook( + ({ enabled }: { enabled: boolean }) => + useDirectActiveTurns({ invoke, enabled, pollIntervalMs: 60_000 }), + { initialProps: { enabled: true } }, + ); + + const emptySnapshot = result.current.activeTurns; + await act(async () => { + resolveSnapshot([]); + await snapshot; + }); + expect(result.current.activeTurns).toBe(emptySnapshot); + rerender({ enabled: false }); + expect(result.current.activeTurns).toBe(emptySnapshot); + }); + + it('读取失败后的重试定时器会在卸载后清理', async () => { + vi.useFakeTimers(); + const invoke = vi.fn(async () => { + throw new Error('temporarily unavailable'); + }); + const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout'); + + try { + const { unmount } = renderHook(() => + useDirectActiveTurns({ invoke, enabled: true }), + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(invoke).toHaveBeenCalledTimes(1); + + unmount(); + expect(clearTimeoutSpy).toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000); + }); + expect(invoke).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + clearTimeoutSpy.mockRestore(); + } + }); + + it('停用后晚到的非空快照不能恢复活动回合,手动刷新也不发请求', async () => { + let complete!: (turns: GameCreatorDirectActiveTurn[]) => void; + const invoke = vi.fn( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + const { result, rerender } = renderHook( + ({ enabled }) => + useDirectActiveTurns({ invoke: invoke as never, enabled }), + { initialProps: { enabled: true } }, + ); + rerender({ enabled: false }); + const empty = result.current.activeTurns; + await act(async () => { + complete([ACTIVE_TURN]); + await result.current.refreshActiveTurns(); + }); + expect(result.current.activeTurns).toBe(empty); + expect(result.current.snapshotReadFailed).toBe(false); + expect(invoke).toHaveBeenCalledTimes(1); + }); + + it('重新启用后读取新快照,旧请求晚到不能覆盖新快照', async () => { + let completeOld!: (turns: GameCreatorDirectActiveTurn[]) => void; + const invoke = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + completeOld = resolve; + }), + ) + .mockResolvedValue([{ ...ACTIVE_TURN, runId: 'new-run' }]); + const { result, rerender } = renderHook( + ({ enabled }) => + useDirectActiveTurns({ invoke: invoke as never, enabled }), + { initialProps: { enabled: true } }, + ); + rerender({ enabled: false }); + rerender({ enabled: true }); + await waitFor(() => + expect(result.current.activeTurns[0]?.runId).toBe('new-run'), + ); + const current = result.current.activeTurns; + await act(async () => { + completeOld([ACTIVE_TURN]); + }); + expect(result.current.activeTurns).toBe(current); + expect(invoke).toHaveBeenCalledTimes(2); + }); }); -it('读取失败时保留明确的读取提示,不伪装成没有运行项目', () => { - render(); +describe('ActiveProjectRunsPanel', () => { + it('按开始时间展示正在运行的项目并支持进入项目', () => { + const onOpenProject = vi.fn(); + render( + , + ); - expect(screen.getByRole('status').textContent).toBe('未能读取正在运行的项目'); -}); - -it('标题栏入口只显示最后开始的项目,展开后列出全部项目', () => { - const onOpenProject = vi.fn(); - render( - , - ); - - expect(screen.getByRole('button', { name: /后开始/ })).toBeTruthy(); - expect(screen.queryByRole('menu')).toBeNull(); - fireEvent.click(screen.getByRole('button', { name: /后开始/ })); - expect(screen.getByRole('menu')).toBeTruthy(); - expect(screen.getAllByRole('menuitem')).toHaveLength(2); - fireEvent.click(screen.getByRole('menuitem', { name: /先开始/ })); - expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first'); + const items = screen.getAllByRole('button'); + expect(items.map((item) => item.textContent?.includes('先开始'))).toEqual([ + true, + false, + ]); + fireEvent.click(items[0]); + expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first'); + }); + + it('读取失败时保留明确的读取提示,不伪装成没有运行项目', () => { + render(); + + expect(screen.getByRole('status').textContent).toBe( + '未能读取正在运行的项目', + ); + }); + + it('标题栏入口只显示最后开始的项目,展开后列出全部项目', () => { + const onOpenProject = vi.fn(); + render( + , + ); + + expect(screen.getByRole('button', { name: /后开始/ })).toBeTruthy(); + expect(screen.queryByRole('menu')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: /后开始/ })); + expect(screen.getByRole('menu')).toBeTruthy(); + expect(screen.getAllByRole('menuitem')).toHaveLength(2); + fireEvent.click(screen.getByRole('menuitem', { name: /先开始/ })); + expect(onOpenProject).toHaveBeenCalledWith('C:/projects/first'); + }); }); diff --git a/apps/ai-game-creator-shell/tests/directHistoryAnchorGate.test.ts b/apps/ai-game-creator-shell/tests/directHistoryAnchorGate.test.ts new file mode 100644 index 000000000..524af7152 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directHistoryAnchorGate.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; + +import { + directHistoryAnchorGateToWaitFor, + openDirectHistoryAnchorGate, + reuseOrOpenDirectHistoryAnchorGate, +} from '../src/features/project-workspace/directHistoryAnchorGate'; + +const projectA = '/tmp/项目A'; +const projectB = '/tmp/项目B'; + +/** 订阅侧的回执:一次订阅只 settle 一次。 */ +function subscribeReceipt(gate: { settle: (anchor: string | null) => void }) { + gate.settle('turn-2-user'); +} + +describe('openDirectHistoryAnchorGate', () => { + it('默认不解析:回执到达前首屏必须等它', async () => { + const gate = openDirectHistoryAnchorGate(projectA); + const settled = { value: 'pending' as string | null | 'pending' }; + void gate.anchor.then((anchor) => { + settled.value = anchor; + }); + + // 让微任务跑一轮:没有回执就不该有结果。 + await Promise.resolve(); + await Promise.resolve(); + expect(settled.value).toBe('pending'); + expect(gate.projectPath).toBe(projectA); + expect(gate.consumed).toBe(false); + }); + + it('settle 之后 anchor 解析成回执里的 lastCompletedItemId', async () => { + const gate = openDirectHistoryAnchorGate(projectA); + subscribeReceipt(gate); + + await expect(gate.anchor).resolves.toBe('turn-2-user'); + }); + + it('订阅不可用时 settle(null):首屏退化成取文件尾', async () => { + const gate = openDirectHistoryAnchorGate(projectA); + gate.settle(null); + + await expect(gate.anchor).resolves.toBeNull(); + }); +}); + +describe('reuseOrOpenDirectHistoryAnchorGate', () => { + it('同一个项目复用同一道闸门:订阅侧要 settle 首屏已经开好的那道', () => { + const opened = openDirectHistoryAnchorGate(projectA); + + expect(reuseOrOpenDirectHistoryAnchorGate(opened, projectA)).toBe(opened); + }); + + it('换项目开一道新闸门,不复用旧项目的回执', () => { + const opened = openDirectHistoryAnchorGate(projectA); + const next = reuseOrOpenDirectHistoryAnchorGate(opened, projectB); + + expect(next).not.toBe(opened); + expect(next.projectPath).toBe(projectB); + expect(next.consumed).toBe(false); + }); + + it('还没有闸门时新开一道', () => { + expect(reuseOrOpenDirectHistoryAnchorGate(null, projectA).projectPath).toBe( + projectA, + ); + }); +}); + +describe('directHistoryAnchorGateToWaitFor', () => { + it('没有闸门(首屏比订阅 effect 先跑)时开一道给调用方登记', async () => { + const gate = directHistoryAnchorGateToWaitFor(null, projectA); + + expect(gate).not.toBeNull(); + expect(gate?.projectPath).toBe(projectA); + subscribeReceipt(gate!); + await expect(gate!.anchor).resolves.toBe('turn-2-user'); + }); + + it('同项目未消费的闸门直接复用:首屏等到订阅回执', async () => { + const opened = openDirectHistoryAnchorGate(projectA); + const toWaitFor = directHistoryAnchorGateToWaitFor(opened, projectA); + + expect(toWaitFor).toBe(opened); + subscribeReceipt(opened); + await expect(toWaitFor!.anchor).resolves.toBe('turn-2-user'); + }); + + it('同项目已消费返回 null:重开项目没有新回执可等,退回取文件尾', () => { + const opened = openDirectHistoryAnchorGate(projectA); + opened.consumed = true; + + expect(directHistoryAnchorGateToWaitFor(opened, projectA)).toBeNull(); + }); + + it('换项目不复用旧闸门:新项目的首屏要等新订阅的回执', () => { + const opened = openDirectHistoryAnchorGate(projectA); + subscribeReceipt(opened); + opened.consumed = true; + + const toWaitFor = directHistoryAnchorGateToWaitFor(opened, projectB); + + expect(toWaitFor).not.toBeNull(); + expect(toWaitFor).not.toBe(opened); + expect(toWaitFor?.projectPath).toBe(projectB); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts b/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts new file mode 100644 index 000000000..6d127375d --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { DIRECT_HISTORY_MAX_PAGES_PER_ACTION } from '../src/app/constants'; +import { readDirectHistoryPages } from '../src/features/project-workspace/directHistoryPaging'; +import type { DirectChatEntry } from '../src/features/project-workspace/directThreadChat'; +import { + emptyDirectThreadChatState, + mergeDirectHistoryItems, + selectDirectChatEntries, +} from '../src/features/project-workspace/directThreadChat'; +import type { + DirectThreadHistorySlice, + DirectThreadItem, +} from '../src/features/project-workspace/directThreadEvents'; + +function userItem(itemId: string): DirectThreadItem { + return { itemType: 'message', itemId, role: 'user', text: itemId, at: 1000 }; +} + +function assistantItem(itemId: string): DirectThreadItem { + return { + itemType: 'message', + itemId, + role: 'assistant', + text: `答复 ${itemId}`, + at: 2000, + }; +} + +function toolItem(itemId: string): DirectThreadItem { + return { + itemType: 'function_call', + itemId, + name: 'exec_command', + arguments: '{"cmd": "ls"}', + at: 1500, + }; +} + +function reasoningItem(itemId: string): DirectThreadItem { + return { itemType: 'reasoning', itemId, text: '想想', at: 1400 }; +} + +function slice( + items: DirectThreadItem[], + hasMore: boolean, + firstItemId: string | null, +): DirectThreadHistorySlice { + return { items, hasMore, firstItemId }; +} + +/** 用生产投影把条目拉成聊天条目:判据必须和视图看到的是同一份。 */ +function entriesOf(items: DirectThreadItem[]): DirectChatEntry[] { + return selectDirectChatEntries( + mergeDirectHistoryItems(emptyDirectThreadChatState(), items), + ); +} + +function readerOf(pages: DirectThreadHistorySlice[]) { + const remaining = [...pages]; + return vi.fn(async (_beforeItemId: string | null) => { + const next = remaining.shift(); + if (!next) throw new Error('测试桩:不该再取页'); + return next; + }); +} + +describe('DirectProject 历史分页连拉', () => { + it('整页工具与思考条目落进已渲染回合时继续取页,直到出现新的用户气泡', async () => { + // 已渲染的回合是「turn-2-user」,后面跟着它的折叠执行过程。 + const existing = entriesOf([userItem('turn-2-user'), toolItem('call-2')]); + const readSlice = readerOf([ + slice([toolItem('call-3'), reasoningItem('reason-3')], true, 'reason-3'), + slice( + [userItem('turn-1-user'), assistantItem('turn-1-assistant')], + false, + 'turn-1-user', + ), + ]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + // 第二页才带来新回合:锚点按上一页最老一条推进,条目按文件顺序拼回去。 + expect(readSlice.mock.calls).toEqual([['turn-2-user'], ['reason-3']]); + expect(result.items.map((item) => item.itemId)).toEqual([ + 'turn-1-user', + 'turn-1-assistant', + 'call-3', + 'reason-3', + ]); + expect(result.hasMore).toBe(false); + expect(result.firstItemId).toBe('turn-1-user'); + expect(result.error).toBeNull(); + const merged = selectDirectChatEntries( + mergeDirectHistoryItems( + { turnRunning: false, history: existing, live: [] }, + result.items, + ), + ); + expect(merged.some((entry) => entry.itemId === 'turn-1-user')).toBe(true); + }); + + it('第一页就出现新的用户气泡时只取一页', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf([ + slice([userItem('turn-1-user')], true, 'turn-1-user'), + ]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + // 后端说还有更早的:按钮继续留在界面上,用户可以接着往前翻。 + expect(result.hasMore).toBe(true); + expect(result.firstItemId).toBe('turn-1-user'); + }); + + it('首屏没有已渲染回合时,第一页本身就构成可见反馈', async () => { + const readSlice = readerOf([slice([toolItem('call-1')], true, 'call-1')]); + + const result = await readDirectHistoryPages({ + existingEntries: [], + beforeItemId: null, + readSlice, + }); + + expect(readSlice.mock.calls).toEqual([[null]]); + expect(result.items.map((item) => item.itemId)).toEqual(['call-1']); + }); + + it('hasMore=false 时即使没有新回合也立即停止', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf([slice([toolItem('call-3')], false, 'call-3')]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + expect(result.hasMore).toBe(false); + }); + + it('一直没有新回合时最多连拉上限页数就停手', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf( + Array.from( + { length: DIRECT_HISTORY_MAX_PAGES_PER_ACTION + 2 }, + (_, index) => + slice([toolItem(`call-${index + 3}`)], true, `call-${index + 3}`), + ), + ); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes( + DIRECT_HISTORY_MAX_PAGES_PER_ACTION, + ); + // 用满上限但文件里还有更早的历史:按钮留在界面上,用户可再点一次。 + expect(result.hasMore).toBe(true); + expect(result.items).toHaveLength(DIRECT_HISTORY_MAX_PAGES_PER_ACTION); + expect(result.firstItemId).toBe( + `call-${DIRECT_HISTORY_MAX_PAGES_PER_ACTION + 2}`, + ); + }); + + it('锚点不前进时立即停止,不空转也不留按钮', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf([slice([], true, null)]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + expect(result.items).toEqual([]); + expect(result.hasMore).toBe(false); + expect(result.firstItemId).toBe('turn-2-user'); + }); + + it('锚点原地打转时立即停止,不重复取同一页', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf([ + slice([toolItem('call-3')], true, 'turn-2-user'), + ]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + expect(result.hasMore).toBe(false); + }); + + it('某一页读取失败时保留已经取到的页', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const failure = new Error('读取失败'); + const readSlice = vi.fn(async (beforeItemId: string | null) => { + if (beforeItemId === 'turn-2-user') { + return slice([toolItem('call-3')], true, 'call-3'); + } + throw failure; + }); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(2); + expect(result.items.map((item) => item.itemId)).toEqual(['call-3']); + // 失败的页没有推进锚点:下一次点击会重取这一页。 + expect(result.firstItemId).toBe('call-3'); + expect(result.hasMore).toBe(true); + expect(result.error).toBe(failure); + }); + + it('首页读取失败时保留首屏锚点并返回错误', async () => { + const failure = new Error('首页读取失败'); + const readSlice = vi.fn(async (_beforeItemId: string | null) => { + throw failure; + }); + + const result = await readDirectHistoryPages({ + existingEntries: [], + beforeItemId: null, + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + expect(result.items).toEqual([]); + expect(result.firstItemId).toBeNull(); + expect(result.hasMore).toBe(false); + expect(result.error).toBe(failure); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts new file mode 100644 index 000000000..b8ea1f7d8 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -0,0 +1,743 @@ +import { describe, expect, it } from 'vitest'; + +import type { + DirectChatEntry, + DirectThreadChatState, +} from '../src/features/project-workspace/directThreadChat'; +import { + emptyDirectThreadChatState, + finishDirectThreadTurn, + mergeDirectHistoryItems, + reduceDirectThreadEvents, + resolveDirectThreadBootstrap, + selectDirectChatEntries, +} from '../src/features/project-workspace/directThreadChat'; +import type { DirectThreadEvent } from '../src/features/project-workspace/directThreadEvents'; +import type { DirectThreadItem } from '../src/features/project-workspace/directThreadItemProjection'; + +function event( + partial: Pick & Partial, +): DirectThreadEvent { + return partial as DirectThreadEvent; +} + +/** 生命周期事件上的 canonical user identity(原生 `userItemId`)。 */ +function withUserItemId( + lifecycle: DirectThreadEvent, + userItemId: string, +): DirectThreadEvent { + return { ...lifecycle, userItemId }; +} + +/** app-server `item/started`:工具真正开始执行,itemId 就是归一后的唯一身份。 */ +function toolStarted( + overrides: Partial< + Extract + > = {}, +): DirectThreadItem { + return { + itemType: 'function_call', + itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', + name: 'exec_command', + arguments: '{"cmd": "ls"}', + at: 1000, + ...overrides, + }; +} + +/** 原始 response item 的输出条目:Rust 已经把它归一成同一个 itemId。 */ +function toolOutput(): DirectThreadItem { + return { + itemType: 'function_call_output', + itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', + output: 'assets\ngame', + at: 2000, + }; +} + +function messageItem( + overrides: Partial> = {}, +): DirectThreadItem { + return { + itemType: 'message', + itemId: 'msg-1', + role: 'assistant', + text: '你好', + at: 3000, + ...overrides, + }; +} + +describe('DirectProject 聊天 reducer', () => { + it('生命周期事件只切换"是否还在跑",不带回合身份', () => { + const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started' }), + ]); + expect(started.turnRunning).toBe(true); + + const completed = reduceDirectThreadEvents(started, [ + event({ type: 'turn.completed', status: 'completed' }), + ]); + expect(completed.turnRunning).toBe(false); + }); + + it('bootstrap 事件就是要处理的事件:未完成条目直接进运行态', () => { + const bootstrapped = resolveDirectThreadBootstrap( + emptyDirectThreadChatState(), + { + subscriptionId: 'sub-1', + lastCompletedItemId: 'msg-9', + events: [ + event({ type: 'turn.started' }), + event({ type: 'item.started', item: toolStarted() }), + ], + }, + ); + // 订阅身份与首屏锚点由订阅循环自己持有,不写进聊天 reducer 状态。 + expect(bootstrapped).not.toHaveProperty('subscriptionId'); + expect(bootstrapped).not.toHaveProperty('lastCompletedItemId'); + expect(bootstrapped.turnRunning).toBe(true); + expect(selectDirectChatEntries(bootstrapped)).toHaveLength(1); + }); + + it('增量正文按条目累计,完成快照更长时覆盖同一段', () => { + const streamed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.delta', + itemId: 'msg-1', + kind: 'message', + delta: '你', + }), + event({ + type: 'item.delta', + itemId: 'msg-1', + kind: 'message', + delta: '好', + }), + ]); + expect(selectDirectChatEntries(streamed)).toHaveLength(1); + expect(selectDirectChatEntries(streamed)[0]?.text).toBe('你好'); + + const done = reduceDirectThreadEvents(streamed, [ + event({ + type: 'item.completed', + item: messageItem({ text: '你好,我是陶泥儿。' }), + }), + ]); + const entries = selectDirectChatEntries(done); + expect(entries).toHaveLength(1); + expect(entries[0]?.text).toBe('你好,我是陶泥儿。'); + }); + + it('思考增量按 reasoning 条目累计,不混进助手文本', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.delta', + itemId: 'reason-1', + kind: 'reasoning', + delta: '先看目录', + }), + ]); + const entries = selectDirectChatEntries(state); + expect(entries).toHaveLength(1); + expect(entries[0]?.kind).toBe('reasoning'); + expect(entries[0]?.role).toBeNull(); + expect(entries[0]?.text).toBe('先看目录'); + }); + + it('工具调用与输出共用唯一 itemId,归并成一张卡片', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'item.started', item: toolStarted() }), + event({ type: 'item.completed', item: toolOutput() }), + ]); + const entries = selectDirectChatEntries(state); + expect(entries).toHaveLength(1); + expect(entries[0]?.itemId).toBe('call_00_Gpd0s0Ytm9YgIbwbEXva1473'); + expect(entries[0]?.toolCall?.kind).toBe('command'); + expect(entries[0]?.toolCall?.title).toBe('执行命令'); + expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); + expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame'); + expect(entries[0]?.toolCall?.status).toBe('completed'); + }); + + it('回合结束把运行态并入历史并清空,条目不会消失也不会重复', () => { + const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started' }), + event({ type: 'item.completed', item: messageItem() }), + event({ type: 'item.started', item: toolStarted() }), + ]); + expect(running.live).toHaveLength(2); + + const done = reduceDirectThreadEvents(running, [ + event({ type: 'turn.completed', status: 'completed' }), + ]); + expect(done.live).toHaveLength(0); + expect(done.history).toHaveLength(2); + expect(selectDirectChatEntries(done)).toHaveLength(2); + }); + + it('历史切片搬运层不合并,合并发生在前端投影', () => { + const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ + toolStarted(), + toolOutput(), + messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图游戏' }), + ]); + const entries = selectDirectChatEntries(state); + expect(entries).toHaveLength(2); + expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame'); + expect(entries[1]?.role).toBe('user'); + }); + + it('系统条目与未识别的 item 类型不进聊天视图', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.completed', + item: messageItem({ + itemId: 'sys-1', + role: 'system', + text: '内部指令', + }), + }), + event({ + type: 'item.completed', + item: { + itemType: 'other', + itemId: 'plan-1', + rawType: 'plan', + at: 0, + } satisfies DirectThreadItem, + }), + ]); + expect(selectDirectChatEntries(state)).toHaveLength(0); + }); + + it('退出码非零优先于 completed:命令跑完但失败不标成功', () => { + const command = ( + status: string | null, + exitCode: number | null, + itemId: string, + ) => ({ + itemType: 'commandExecution' as const, + itemId, + command: 'npm test', + output: 'boom', + status, + exitCode, + at: 2_000, + }); + // status=completed + exitCode=1:失败(不能因为"跑完了"就标成功)。 + const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.completed', + at: 2_000, + item: command('completed', 1, 'call-fail'), + }), + ]); + expect(selectDirectChatEntries(failed)[0]?.toolCall?.status).toBe('failed'); + + // status=completed + exitCode=0:成功。 + const ok = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.completed', + at: 2_000, + item: command('completed', 0, 'call-ok'), + }), + ]); + expect(selectDirectChatEntries(ok)[0]?.toolCall?.status).toBe('completed'); + + // status=inProgress 且没有退出码:仍在跑。 + const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.started', + at: 2_000, + item: command('inProgress', null, 'call-running'), + }), + ]); + expect(selectDirectChatEntries(running)[0]?.toolCall?.status).toBe( + 'running', + ); + + // 上游显式失败终态优先于 completed:即使 exitCode=0 也算失败。 + const declined = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.completed', + at: 2_000, + item: command('declined', 0, 'call-declined'), + }), + ]); + expect(selectDirectChatEntries(declined)[0]?.toolCall?.status).toBe( + 'failed', + ); + }); + + it('历史条目与运行态按唯一 id 合并,重复条目只出现一次', () => { + const historical = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ + toolStarted(), + messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图游戏' }), + ]); + const live = reduceDirectThreadEvents(historical, [ + event({ type: 'item.completed', item: toolOutput() }), + ]); + const entries = selectDirectChatEntries(live); + expect(entries).toHaveLength(2); + expect(entries[0]?.toolCall?.status).toBe('completed'); + expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); + }); + + describe('事件级计时边界', () => { + /** 工具的开始 / 完成只读事件级 `at`,条目 `item.at` 不作起止。 */ + it('工具耗时只读事件级 at,不把 item.at 当开始或完成', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000_000 }), + event({ + type: 'item.started', + at: 1_000_100, + item: toolStarted({ at: 999_999 }), + }), + event({ + type: 'item.completed', + at: 1_000_700, + item: toolOutput({ at: 1_200_000 }), + }), + ]); + const call = selectDirectChatEntries(state)[0]?.toolCall; + expect(call?.startedAt).toBe(1_000_100); + expect(call?.updatedAt).toBe(1_000_700); + }); + + it('只有完成事件、没有开始事件的工具不猜开始时间', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'item.completed', at: 1_000_700, item: toolOutput() }), + ]); + const call = selectDirectChatEntries(state)[0]?.toolCall; + expect(call?.startedAt).toBe(0); + expect(call?.updatedAt).toBe(1_000_700); + }); + + it('内部工具的阶段状态:开始事件就是运行中,正式完成事件才结束', () => { + const fileChange = { + itemType: 'fileChange', + itemId: 'file-1', + changes: [{ path: 'game/src/hero.ts', kind: 'update' }], + at: 0, + } as const; + const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'item.started', at: 1_000_100, item: fileChange }), + ]); + const startedCall = selectDirectChatEntries(started)[0]?.toolCall; + expect(startedCall?.status).toBe('running'); + expect(startedCall?.startedAt).toBe(1_000_100); + expect(startedCall?.updatedAt).toBe(0); + + const finished = reduceDirectThreadEvents(started, [ + event({ type: 'item.completed', at: 1_000_900, item: fileChange }), + ]); + const finishedCall = selectDirectChatEntries(finished)[0]?.toolCall; + expect(finishedCall?.status).toBe('completed'); + expect(finishedCall?.updatedAt).toBe(1_000_900); + }); + + it('function_call 完成快照不等于工具结束:一直运行到 output 到达', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'item.started', at: 1_000_100, item: toolStarted() }), + // 同一个 function_call 又以完成快照重放一次:不是工具结束。 + event({ type: 'item.completed', at: 1_000_200, item: toolStarted() }), + ]); + const running = selectDirectChatEntries(state)[0]?.toolCall; + expect(running?.status).toBe('running'); + // 完成快照不改开始边界,也不产生终点。 + expect(running?.startedAt).toBe(1_000_100); + expect(running?.updatedAt).toBe(0); + + const done = reduceDirectThreadEvents(state, [ + event({ type: 'item.completed', at: 1_000_900, item: toolOutput() }), + ]); + const finished = selectDirectChatEntries(done)[0]?.toolCall; + expect(finished?.status).toBe('completed'); + expect(finished?.updatedAt).toBe(1_000_900); + }); + + it('收口后重复 / 迟到的终态事件不抬高冻结终点、不复活运行态', () => { + const done = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000_000 }), + event({ type: 'item.started', at: 1_000_100, item: toolStarted() }), + event({ + type: 'item.completed', + at: 1_000_700, + item: toolOutput(), + }), + event({ type: 'turn.completed', status: 'completed', at: 1_000_900 }), + ]); + expect(done.turnRunning).toBe(false); + expect(done.turnEndedAt).toBe(1_000_900); + expect(done.history[0]?.turnEndedAt).toBe(1_000_900); + expect(done.history[0]?.turnStartedAt).toBe(1_000_000); + + const replayed = reduceDirectThreadEvents(done, [ + // 重复的完成事件 + 迟到的事件不得抬高已经冻结的终点。 + event({ type: 'turn.completed', status: 'completed', at: 1_009_900 }), + event({ type: 'item.completed', at: 1_009_900, item: toolOutput() }), + ]); + expect(replayed.turnRunning).toBe(false); + expect(replayed.turnEndedAt).toBe(1_000_900); + expect(replayed.history[0]?.toolCall?.updatedAt).toBe(1_000_700); + }); + + it('回合身份只认事件顺序:同一秒内开始的下一轮也照常接上', () => { + const done = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000_000 }), + // 宿主收口的毫秒时间:原生回合时间是秒级精度,"上一轮结束之后又来一条开始事件" + // 就是新回合,不能因为时间不比终点晚就把它当重放吞掉。 + event({ type: 'turn.completed', status: 'cancelled', at: 1_000_900 }), + ]); + const next = reduceDirectThreadEvents(done, [ + event({ type: 'turn.started', at: 1_000_000 }), + ]); + expect(next.turnRunning).toBe(true); + expect(next.turnStartedAt).toBe(1_000_000); + expect(next.turnEndedAt).toBe(0); + }); + + it('同一轮内的重复开始事件保留第一次的起点', () => { + const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000_000 }), + event({ type: 'turn.started', at: 1_000_000 }), + event({ type: 'turn.started', at: 1_000_500 }), + ]); + expect(started.turnRunning).toBe(true); + expect(started.turnStartedAt).toBe(1_000_000); + }); + + it('终态之后同身份的迟到条目补进历史,不挂到下一轮运行态', () => { + const done = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000_000 }), + event({ type: 'item.started', at: 1_000_100, item: toolStarted() }), + event({ type: 'turn.completed', status: 'completed', at: 1_000_900 }), + ]); + // 迟到的事件:同身份的完成快照在回合收口之后才到。 + const late = reduceDirectThreadEvents(done, [ + event({ type: 'item.completed', at: 1_000_700, item: toolOutput() }), + ]); + expect(late.live).toHaveLength(0); + expect(late.history).toHaveLength(1); + expect(late.history[0]?.toolCall?.status).toBe('completed'); + expect(late.history[0]?.toolCall?.detail.output).toBe('assets\ngame'); + // 冻结的终点不被抬高。 + expect(late.history[0]?.toolCall?.updatedAt).toBe(1_000_700); + + // 新回合开始后,迟到条目仍然回历史,不会挤进这一轮。 + const next = reduceDirectThreadEvents(late, [ + event({ type: 'turn.started', at: 1_001_000 }), + event({ type: 'item.completed', at: 1_001_700, item: toolOutput() }), + ]); + expect(next.live).toHaveLength(0); + expect(next.history).toHaveLength(1); + }); + + it('bootstrap 已把用户消息当历史锚点给出时,边界仍落在本轮开口条目上', () => { + const bootstrapped = resolveDirectThreadBootstrap( + mergeDirectHistoryItems(emptyDirectThreadChatState(), [ + messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图' }), + ]), + { + subscriptionId: 'sub-1', + lastCompletedItemId: 'msg-user', + events: [ + // 原生在生命周期锚点上带出本轮 canonical user identity,开口条目已经在历史里。 + withUserItemId( + event({ type: 'turn.started', at: 1_000_000 }), + 'msg-user', + ), + event({ + type: 'item.completed', + at: 1_000_700, + item: toolOutput(), + }), + event({ + type: 'turn.completed', + status: 'completed', + at: 1_000_900, + }), + ], + }, + ); + expect(selectDirectChatEntries(bootstrapped)[0]?.turnEndedAt).toBe( + 1_000_900, + ); + expect(selectDirectChatEntries(bootstrapped)[0]?.turnStartedAt).toBe( + 1_000_000, + ); + }); + + it('没有 userItemId 的生命周期事件保持顺序语义,但不猜历史归属', () => { + const bootstrapped = resolveDirectThreadBootstrap( + mergeDirectHistoryItems(emptyDirectThreadChatState(), [ + messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图' }), + ]), + { + subscriptionId: 'sub-1', + lastCompletedItemId: 'msg-user', + events: [ + // 旧原生事件不带身份:运行态条目照常收口,历史里那条不按尾巴猜。 + event({ + type: 'item.completed', + at: 1_000_700, + item: toolOutput(), + }), + event({ + type: 'turn.completed', + status: 'completed', + at: 1_000_900, + }), + ], + }, + ); + const entries = selectDirectChatEntries(bootstrapped); + expect(entries).toHaveLength(2); + expect(entries[0]?.itemId).toBe('msg-user'); + expect(entries[0]?.turnEndedAt).toBeUndefined(); + // 运行态那条照常拿到边界(顺序语义不变)。 + expect(entries[1]?.turnEndedAt).toBe(1_000_900); + }); + + it('本轮开口条目已在历史、运行态为空时,收口仍按身份把边界盖在它身上', () => { + const userItem = messageItem({ + itemId: 'direct-codex:turn-1:user', + role: 'user', + text: '做一个拼图游戏', + }); + const running = reduceDirectThreadEvents( + mergeDirectHistoryItems(emptyDirectThreadChatState(), [userItem]), + [ + // 原生在生命周期事件上带出本轮 canonical user identity;这一轮没有任何运行态 + // 条目,开口条目只存在于历史里。 + withUserItemId( + event({ type: 'turn.started', at: 1_000_000 }), + 'direct-codex:turn-1:user', + ), + ], + ); + expect(running.live).toHaveLength(0); + + const done = reduceDirectThreadEvents(running, [ + withUserItemId( + event({ type: 'turn.completed', status: 'aborted', at: 1_000_900 }), + 'direct-codex:turn-1:user', + ), + ]); + // 边界落在本轮这条用户条目上:既不丢终态时间,也没有第二套回合身份。 + expect(done.history[0]?.turnStartedAt).toBe(1_000_000); + expect(done.history[0]?.turnEndedAt).toBe(1_000_900); + }); + + it('终态先于历史切片到达时,回读到开口条目仍补上已冻结的边界', () => { + const started = resolveDirectThreadBootstrap( + emptyDirectThreadChatState(), + { + subscriptionId: 'sub-1', + lastCompletedItemId: null, + events: [ + withUserItemId( + event({ type: 'turn.started', at: 1_000_000 }), + 'direct-codex:turn-1:user', + ), + ], + }, + ); + // 终态先到:这一刻历史里还没有那条用户条目。 + const finished = reduceDirectThreadEvents(started, [ + withUserItemId( + event({ type: 'turn.completed', status: 'completed', at: 1_000_900 }), + 'direct-codex:turn-1:user', + ), + ]); + expect(finished.turnEndedAt).toBe(1_000_900); + expect(finished.history).toHaveLength(0); + + const hydrated = mergeDirectHistoryItems(finished, [ + messageItem({ + itemId: 'direct-codex:turn-1:user', + role: 'user', + text: '做一个拼图游戏', + at: 999_000, + }), + ]); + expect(hydrated.history[0]?.turnStartedAt).toBe(1_000_000); + expect(hydrated.history[0]?.turnEndedAt).toBe(1_000_900); + }); + + it('上一轮迟到的终态按身份被拒,不关掉正在跑的这一轮', () => { + const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + withUserItemId( + event({ type: 'turn.started', at: 2_000_000 }), + 'direct-codex:turn-1:user', + ), + event({ type: 'item.started', at: 2_000_100, item: toolStarted() }), + ]); + + const late = reduceDirectThreadEvents(running, [ + // 上一轮的终态(身份不同):哪怕时间戳更早,也不允许收口正在跑的这一轮。 + withUserItemId( + event({ type: 'turn.completed', status: 'aborted', at: 1_000_900 }), + 'direct-codex:turn-0:user', + ), + ]); + expect(late.turnRunning).toBe(true); + expect(late.turnEndedAt).toBe(0); + expect(late.live).toHaveLength(1); + }); + + it('同一轮的重复终态不抬高已冻结的终点', () => { + const done = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + withUserItemId( + event({ type: 'turn.started', at: 1_000_000 }), + 'direct-codex:turn-1:user', + ), + withUserItemId( + event({ type: 'turn.completed', status: 'completed', at: 1_000_900 }), + 'direct-codex:turn-1:user', + ), + ]); + const replayed = reduceDirectThreadEvents(done, [ + withUserItemId( + event({ type: 'turn.completed', status: 'completed', at: 9_900_000 }), + 'direct-codex:turn-1:user', + ), + ]); + expect(replayed.turnEndedAt).toBe(1_000_900); + expect(replayed.live).toHaveLength(0); + }); + + it('本轮自己没有任何条目时,不把边界盖到上一轮已经收口的开口条目上', () => { + const previousOpener: DirectChatEntry = { + itemId: 'direct-codex:turn-0:user', + kind: 'message', + role: 'user', + text: '上一轮的问题', + at: 900_000, + turnStartedAt: 910_000, + turnEndedAt: 950_000, + }; + const previous: DirectThreadChatState = { + ...emptyDirectThreadChatState(), + history: [previousOpener], + }; + const done = reduceDirectThreadEvents(previous, [ + withUserItemId( + event({ type: 'turn.started', at: 2_000_000 }), + 'direct-codex:turn-2:user', + ), + withUserItemId( + event({ type: 'turn.completed', status: 'aborted', at: 2_000_400 }), + 'direct-codex:turn-2:user', + ), + ]); + expect(done.history[0]?.turnStartedAt).toBe(910_000); + expect(done.history[0]?.turnEndedAt).toBe(950_000); + }); + + /** + * 本轮开口条目只由 history slice 回读、运行态全程为空:身份只能来自原生生命周期事件上 + * 带的 canonical user identity,不能按历史尾或时间戳猜。 + */ + it('只有生命周期锚点 + 历史回读的开口条目时,按原生身份精准恢复用时', () => { + const running = resolveDirectThreadBootstrap( + emptyDirectThreadChatState(), + { + subscriptionId: 'sub-1', + lastCompletedItemId: null, + events: [ + withUserItemId( + event({ type: 'turn.started', at: 1_000_000 }), + 'direct-codex:turn-1:user', + ), + ], + }, + ); + const withHistory = mergeDirectHistoryItems(running, [ + messageItem({ + itemId: 'direct-codex:turn-1:user', + role: 'user', + text: '做一个拼图游戏', + at: 999_000, + }), + ]); + expect(withHistory.live).toHaveLength(0); + + const done = reduceDirectThreadEvents(withHistory, [ + withUserItemId( + event({ type: 'turn.completed', status: 'aborted', at: 1_000_900 }), + 'direct-codex:turn-1:user', + ), + ]); + expect(done.turnRunning).toBe(false); + expect(done.turnEndedAt).toBe(1_000_900); + // 身份来自原生事件,历史里那条开口条目因此拿到同一组边界。 + expect(done.history[0]?.turnStartedAt).toBe(1_000_000); + expect(done.history[0]?.turnEndedAt).toBe(1_000_900); + }); + + it('收口只填空,不抬高条目上已经冻结的终点', () => { + // 规则本身:已经写上的边界先到先用,重复 / 迟到的收口不得抬高它。 + const frozenOpener: DirectChatEntry = { + itemId: 'direct-codex:turn-1:user', + kind: 'message', + role: 'user', + text: '做一个拼图游戏', + at: 999_000, + turnStartedAt: 1_000_000, + turnEndedAt: 1_000_900, + }; + const state: DirectThreadChatState = { + ...emptyDirectThreadChatState(), + history: [frozenOpener], + turnUserItemId: 'direct-codex:turn-1:user', + live: [ + { + itemId: 'call-late', + kind: 'tool', + role: null, + text: null, + at: 0, + toolCall: { + schemaVersion: 'agc-tool-call.v1', + id: 'call-late', + kind: 'command', + title: '执行命令', + summary: 'npm run build', + status: 'completed', + detail: { command: 'npm run build' }, + startedAt: 1_000_100, + updatedAt: 1_000_700, + }, + }, + ], + turnStartedAt: 1_000_000, + }; + const done = finishDirectThreadTurn(state, 9_900_000); + expect(done.history[0]?.turnEndedAt).toBe(1_000_900); + expect(done.history[0]?.turnStartedAt).toBe(1_000_000); + }); + + it('宿主终止收口:没有权威终态时间就不写,之后的原生终态事件也不会被抬高', () => { + const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', at: 1_000_000 }), + event({ type: 'item.completed', at: 1_000_700, item: toolOutput() }), + ]); + const released = finishDirectThreadTurn(running, Date.now()); + expect(released.turnRunning).toBe(false); + expect(released.live).toHaveLength(0); + expect(released.history[0]?.turnEndedAt).toBe(released.turnEndedAt); + + const unknown = finishDirectThreadTurn(running, 0); + expect(unknown.turnEndedAt).toBe(0); + expect(unknown.history[0]?.turnEndedAt).toBeUndefined(); + // 已经收口:后续事件不再改动终态时间,也不复活运行态。 + const replayed = reduceDirectThreadEvents(unknown, [ + event({ type: 'turn.completed', status: 'completed', at: 1_009_900 }), + ]); + expect(replayed.turnRunning).toBe(false); + expect(replayed.turnEndedAt).toBe(0); + }); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts deleted file mode 100644 index 128cdf135..000000000 --- a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - directThreadHistoryItemsToMessages, - isDirectTurnInProgress, -} from '../src/features/project-workspace/directThreadEvents'; - -describe('Direct 回合状态与历史时间', () => { - it('终态和空状态不恢复为活动回合', () => { - for (const status of [ - 'completed', - 'failed', - 'interrupted', - null, - undefined, - ]) { - expect(isDirectTurnInProgress(status)).toBe(false); - } - for (const status of ['accepted', 'running', 'streaming', 'finalizing']) { - expect(isDirectTurnInProgress(status)).toBe(true); - } - }); - it('按消息 id 读取信封时间,旧记录不使用当前时间补造', () => { - const items = [ - { - type: 'message', - role: 'user', - id: 'direct-codex:turn:user', - content: [{ type: 'input_text', text: '帮我修改游戏' }], - }, - ]; - const timestamps = { 'direct-codex:turn:user': 1_800_000_000_001 }; - expect( - directThreadHistoryItemsToMessages(items, timestamps)[0]?.updatedAt, - ).toBe(1_800_000_000_001); - expect(directThreadHistoryItemsToMessages(items)[0]?.updatedAt).toBe(0); - expect(items[0]).not.toHaveProperty('recordedAt'); - }); -}); diff --git a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts index f47f263a2..f8ad2981b 100644 --- a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts @@ -1,236 +1,344 @@ import { describe, expect, it } from 'vitest'; -import type { - ChatMessage, - GameCreatorDirectToolCall, - TurnStreamItem, -} from '../src/app/types'; +import type { ChatMessage } from '../src/app/types'; +import type { DirectChatEntry } from '../src/features/project-workspace/directThreadChat'; import { - buildDirectTurnPresentations, + buildDirectChatTurns, + directChatEntriesToMessages, directMessageTimestamp, + mergeDirectChatMessages, normalizeDirectTimestamp, - splitDirectTurnContent, } from '../src/features/project-workspace/directTurnPresentation'; -const user = (turn: string): ChatMessage => ({ +const userEntry = ( + itemId: string, + at = 1_800_000_000_000, +): DirectChatEntry => ({ + itemId, + kind: 'message', role: 'user', - text: '只读检查', - messageId: `direct-codex:${turn}:user`, + text: `问题 ${itemId}`, + at, }); -const assistant = (id: string, text = '完整回复'): ChatMessage => ({ + +const assistantEntry = ( + itemId: string, + text: string, + at = 1_800_000_001_000, +): DirectChatEntry => ({ + itemId, + kind: 'message', role: 'assistant', text, - messageId: id, + at, }); -const text = (turnId: string, id: string, seq = 1): TurnStreamItem => ({ - schemaVersion: 'agc-turn-stream.v1', - kind: 'text', - turnId, - id: `text:${turnId}:${id}`, - text: '前缀', - seq, - at: 1_800_000_000_000, - updatedAt: 1_800_000_000_001, -}); -const tool = (turnId: string): GameCreatorDirectToolCall => ({ - schemaVersion: 'agc-tool-call.v1', - id: 'call', - turnId, - kind: 'mcp_tool', - title: '调用工具', - summary: '读取', - status: 'completed', - detail: { command: '{"path":"file"}', output: '内容', changes: [] }, - startedAt: 1_800_000_000_000, - updatedAt: 1_800_000_000_001, -}); -const build = ( - messages: ChatMessage[], - items: TurnStreamItem[], - options: Partial[0]> = {}, -) => - buildDirectTurnPresentations({ - messages, - visibleMessages: messages, - items, - calls: [], - transientReply: '', - ...options, - }); -describe('DirectProject 回合唯一呈现', () => { +const toolEntry = ( + itemId: string, + at = 1_800_000_000_500, +): DirectChatEntry => ({ + itemId, + kind: 'tool', + role: null, + text: null, + at, + toolCall: { + schemaVersion: 'agc-tool-call.v1', + id: itemId, + kind: 'command', + title: '执行命令', + summary: 'npm run build', + status: 'completed', + detail: { command: 'npm run build' }, + startedAt: at, + updatedAt: at, + }, +}); + +/** 工具条目:边界只来自事件级时间,历史切片拿不到就留 0。 */ +const liveToolEntry = ( + itemId: string, + startedAt: number, + updatedAt: number, + status: 'running' | 'completed' = 'completed', +): DirectChatEntry => ({ + itemId, + kind: 'tool', + role: null, + text: null, + at: 0, + toolCall: { + schemaVersion: 'agc-tool-call.v1', + id: itemId, + kind: 'command', + title: '执行命令', + summary: 'npm run build', + status, + detail: { command: 'npm run build' }, + startedAt, + updatedAt, + }, +}); + +const reasoningEntry = ( + itemId: string, + text = '先看目录', + at = 1_800_000_000_200, +): DirectChatEntry => ({ + itemId, + kind: 'reasoning', + role: null, + text, + at, +}); + +const localUser = (text: string, messageId?: string): ChatMessage => ({ + role: 'user', + text, + ...(messageId ? { messageId } : {}), + updatedAt: 1_800_000_002_000, +}); + +const localNotice = (text: string, messageId?: string): ChatMessage => ({ + role: 'assistant', + text, + ...(messageId ? { messageId } : {}), + updatedAt: 1_800_000_002_500, +}); + +describe('DirectProject 聊天分区', () => { it('把 Unix 秒时间戳归一化为毫秒,已有毫秒值保持不变', () => { expect(normalizeDirectTimestamp(1_800_000_000)).toBe(1_800_000_000_000); expect(normalizeDirectTimestamp(1_800_000_000_000)).toBe(1_800_000_000_000); - expect(directMessageTimestamp(1_800_000_000)).toBe(1_800_000_000_000); - }); - - it('历史仍有未加载切片时,不把那些回合的工具流追加到当前页末尾', () => { - const rows = build( - [user('one')], - [text('old', 'raw-old'), text('one', 'raw')], - { - hasUnloadedHistory: true, - }, - ); - expect(rows.map((row) => row.turnId)).toEqual(['one']); - const active = build([], [text('live', 'raw')], { - hasUnloadedHistory: true, - activeTurnId: 'live', - }); - expect(active.map((row) => row.turnId)).toEqual(['live']); - }); - it('尚未落盘用户的实时回合也只产生一个 owner,不另建 live 与 unmapped 出口', () => { - const rows = build([], [text('one', 'raw')], { - activeTurnId: 'one', - transientReply: '同一份累计回复', - calls: [tool('one')], - }); - expect(rows).toHaveLength(1); - expect(rows[0].source).toBe('stream'); - expect(rows[0].transientReply).toBe(''); - expect(rows[0].calls).toHaveLength(1); - }); - it('同一身份用户重复快照不增加回合,不丢用户正文', () => { - const rows = build([user('one'), user('one')], [text('one', 'raw')]); - expect(rows).toHaveLength(1); - expect(rows[0].messages).toHaveLength(1); - expect(rows[0].messages[0].role).toBe('user'); - }); - it('用原始 item 身份补齐旧前缀,最终合成消息不另占正文出口', () => { - const rows = build( - [user('one'), assistant('raw'), assistant('direct-codex:one:assistant')], - [text('one', 'raw')], - ); - expect(rows).toHaveLength(1); - expect(rows[0].source).toBe('stream'); - expect(rows[0].items).toHaveLength(1); - expect(rows[0].items[0].text).toBe('完整回复'); - }); - it('先关联完整历史再分页,首条可见 assistant 不会失去用户归属', () => { - const messages = [ - user('old'), - assistant('old-raw'), - user('one'), - assistant('raw'), - ]; - const rows = build(messages, [text('old', 'old-raw'), text('one', 'raw')], { - visibleMessages: messages.slice(3), - }); - expect(rows.map((row) => row.turnId)).toEqual(['one']); - expect(rows[0].messages[0].messageId).toBe('direct-codex:one:user'); - }); - it('纯文本旧回合不挤占之后有工具的回合身份', () => { - const rows = build( - [user('old'), assistant('plain'), user('one'), assistant('raw')], - [text('one', 'raw')], - { calls: [tool('one')] }, - ); - expect(rows.map((row) => row.turnId)).toEqual(['old', 'one']); - expect(rows[0].source).toBe('messages'); - expect(rows[0].calls).toEqual([]); - expect(rows[1].source).toBe('stream'); - }); - it('没有流的原始 assistant 与工具仍归属一个回合,输入输出保留', () => { - const rows = build([user('one'), assistant('raw')], [], { - calls: [tool('one')], - }); - expect(rows).toHaveLength(1); - expect(rows[0].source).toBe('messages'); - expect(rows[0].calls[0].detail.output).toBe('内容'); - }); - it('无法证明流覆盖历史时整轮回退,不能混画部分流与全文', () => { - const rows = build( - [user('one'), assistant('raw-a'), assistant('raw-b')], - [text('one', 'raw-b')], - ); - expect(rows[0].source).toBe('messages'); - expect( - rows[0].messages.filter((message) => message.role === 'assistant'), - ).toHaveLength(2); - }); - it('不同回合的相同文字不是重复消息,不做文本去重', () => { - const rows = build( - [user('one'), assistant('raw-one'), user('two'), assistant('raw-two')], - [text('one', 'raw-one'), text('two', 'raw-two')], - ); - expect(rows).toHaveLength(2); - expect(rows.every((row) => row.source === 'stream')).toBe(true); - }); - it('乱序重复快照按 seq 排列且不增加 item', () => { - const first = text('one', 'a'); - const last = text('one', 'b', 3); - const marker: TurnStreamItem = { - ...text('one', 'unused', 2), - kind: 'tool', - id: 'tool:one:call', - callId: 'call', - }; - const rows = build([user('one')], [last, marker, first, first]); - expect(rows[0].items.map((item) => item.seq)).toEqual([1, 2, 3]); - }); - it('完成后中间文本和工具进入过程,最终回复独立且分区不重复', () => { - const marker: TurnStreamItem = { - ...text('one', 'unused', 2), - kind: 'tool', - id: 'tool:one:call', - callId: 'call', - }; - const row = build( - [user('one')], - [text('one', 'start'), marker, text('one', 'final', 3)], - )[0]; - const parts = splitDirectTurnContent(row); - expect(parts.processItems.map((item) => item.id)).toEqual([ - 'text:one:start', - 'tool:one:call', - ]); - expect(parts.finalItems.map((item) => item.id)).toEqual(['text:one:final']); - const active = splitDirectTurnContent({ ...row, active: true }); - expect(active.processItems).toHaveLength(3); - expect(active.finalItems).toEqual([]); - }); - it('失败回合保留失败提示,不把末尾过程文本提升为最终回复', () => { - const row = build( - [user('one'), assistant('direct-codex:one:failure', '失败')], - [text('one', 'progress'), { ...text('one', 'failure', 2), text: '失败' }], - )[0]; - const parts = splitDirectTurnContent(row); - expect(parts.processItems.map((item) => item.id)).toEqual([ - 'text:one:progress', - ]); - expect(parts.finalItems.map((item) => item.id)).toEqual([ - 'text:one:failure', - ]); - }); - it('无流历史只保留最后一条回复,发送时间不从工具推断', () => { - const row = build( - [user('one'), assistant('progress'), assistant('final')], - [], - )[0]; - const parts = splitDirectTurnContent(row); - expect(parts.processMessages.map((message) => message.messageId)).toEqual([ - 'progress', - ]); - expect(parts.finalMessages.map((message) => message.messageId)).toEqual([ - 'final', - ]); expect(directMessageTimestamp(undefined)).toBe(0); expect(directMessageTimestamp(Number.MAX_VALUE)).toBe(0); expect(directMessageTimestamp(1_800_000_000_001)).toBe(1_800_000_000_001); }); - it('无流活动回合的累计文本只属于该回合,持久 assistant 到达即接管', () => { + + it('每个用户条目开一个新回合,顺序就是条目顺序', () => { + const turns = buildDirectChatTurns({ + entries: [ + userEntry('u1'), + assistantEntry('a1', '第一轮答复'), + userEntry('u2', 1_800_000_010_000), + assistantEntry('a2', '第二轮答复', 1_800_000_011_000), + ], + }); + expect(turns.map((turn) => turn.key)).toEqual(['u1', 'u2']); + expect(turns[0]?.users[0]).toMatchObject({ text: '问题 u1' }); + expect(turns[0]?.finals[0]).toMatchObject({ text: '第一轮答复' }); + expect(turns[1]?.finals[0]).toMatchObject({ text: '第二轮答复' }); + expect(turns[0]?.startedAt).toBe(1_800_000_000_000); + // 终点只认明确终态:旧历史条目里没有 `turnEndedAt` 就隐藏,不拿最后一条正文的时间顶替。 + expect(turns[0]?.endedAt).toBe(0); + }); + + it('已结束的回合只把最后一条助手正文当最终回复,中间正文与工具进过程', () => { + const turns = buildDirectChatTurns({ + entries: [ + userEntry('u1'), + reasoningEntry('r1'), + assistantEntry('a-mid', '我先看一下项目'), + toolEntry('t1'), + assistantEntry('a-final', '改好了', 1_800_000_002_000), + ], + }); + expect(turns[0]?.process.map((block) => block.key)).toEqual([ + 'u1:r1', + 'u1:a-mid', + 'u1:t1', + ]); + expect(turns[0]?.finals.map((block) => block.key)).toEqual(['u1:a-final']); + }); + + it('连续工具合成一块,夹了正文就另起一块', () => { + const turns = buildDirectChatTurns({ + entries: [ + userEntry('u1'), + toolEntry('t1'), + toolEntry('t2', 1_800_000_000_600), + assistantEntry('a-mid', '继续', 1_800_000_000_700), + toolEntry('t3', 1_800_000_000_800), + ], + turnRunning: true, + }); + const process = turns[0]?.process ?? []; + expect(process.map((block) => block.kind)).toEqual([ + 'tools', + 'assistant', + 'tools', + ]); + expect(process[0]).toMatchObject({ + kind: 'tools', + calls: [{ id: 't1' }, { id: 't2' }], + }); + expect(process[2]).toMatchObject({ kind: 'tools', calls: [{ id: 't3' }] }); + }); + + it('运行中的回合:过程不折叠,最后的助手正文仍在过程里流式显示', () => { + const turns = buildDirectChatTurns({ + entries: [ + userEntry('u1'), + assistantEntry('a-live', '正在写'), + toolEntry('t1', 1_800_000_001_100), + ], + turnRunning: true, + }); + expect(turns[0]?.active).toBe(true); + expect(turns[0]?.finals).toEqual([]); + expect(turns[0]?.process.map((block) => block.kind)).toEqual([ + 'assistant', + 'tools', + ]); + }); + + it('整轮边界只落在本轮:旧历史回合没有终态就继续隐藏,不吃最新回合的终点', () => { + const turns = buildDirectChatTurns({ + entries: [ + // 两个旧回合:历史切片里没有事件级边界。 + userEntry('u1', 1_800_000_000_000), + assistantEntry('a1', '第一轮答复', 1_800_000_001_000), + userEntry('u2', 1_800_000_010_000), + assistantEntry('a2', '第二轮答复', 1_800_000_011_000), + // 当前回合:用户发送时间 + 明确终态(收口时盖在本轮条目上)。 + { + ...userEntry('u3', 1_800_000_020_000), + turnEndedAt: 1_800_000_022_500, + }, + liveToolEntry('t3', 1_800_000_021_000, 1_800_000_022_000), + { + ...assistantEntry('a3', '第三轮答复', 1_800_000_022_400), + turnStartedAt: 1_800_000_020_000, + turnEndedAt: 1_800_000_022_500, + }, + ], + }); + expect(turns.map((turn) => turn.endedAt)).toEqual([ + 0, 0, 1_800_000_022_500, + ]); + // 旧历史回合并不会因为"拿不到时间"被填上最新回合的终点。 + expect(turns[0]?.startedAt).toBe(1_800_000_000_000); + expect(turns[2]?.startedAt).toBe(1_800_000_020_000); + }); + + it('运行中的整轮起点:优先用户实际发送时间,缺失才用原生 turn.started.at', () => { + const liveTurn = buildDirectChatTurns({ + entries: [ + { ...userEntry('u1', 0), at: 0 }, + liveToolEntry('t1', 1_800_000_000_500, 0, 'running'), + ], + turnRunning: true, + turnStartedAt: 1_800_000_000_100, + }); + // 用户条目没有发送时间:用原生 turn.started.at 兜底。 + expect(liveTurn[0]?.startedAt).toBe(1_800_000_000_100); + + const withUserTime = buildDirectChatTurns({ + entries: [ + userEntry('u1', 1_800_000_000_050), + liveToolEntry('t1', 1_800_000_000_500, 0, 'running'), + ], + turnRunning: true, + turnStartedAt: 1_800_000_000_100, + }); + // 用户发送时间更早且是真实发送:以它为准,不取所有条目的最小时间。 + expect(withUserTime[0]?.startedAt).toBe(1_800_000_000_050); + }); + + it('正式条目的晚 ack 时间不顶掉本地真实发送时间', () => { + const sentAt = 1_800_000_000_000; + // 原生落盘 / 观测到的 ack 时间晚于用户真正按下发送的时刻。 + const ackAt = sentAt + 1_200; + const turns = buildDirectChatTurns({ + entries: [ + userEntry('direct-codex:turn-1:user', ackAt), + liveToolEntry('t1', sentAt + 400, sentAt + 900), + ], + // 同一条消息的本地乐观气泡(messageId 就是原生条目身份,时间是本地发送时刻)。 + localMessages: [ + { + role: 'user' as const, + text: '问题 direct-codex:turn-1:user', + messageId: 'direct-codex:turn-1:user', + updatedAt: sentAt, + }, + ], + }); + // 同身份合并保留真实发送时间:既不是正式条目的 ack 时间,也不按所有条目取最小值。 + expect(turns[0]?.startedAt).toBe(sentAt); + expect(turns[0]?.users[0]).toMatchObject({ at: sentAt }); + }); + + it('运行期失败说明挂到当前回合末尾,不当成最终回复', () => { + const turns = buildDirectChatTurns({ + entries: [userEntry('u1'), assistantEntry('a1', '正文')], + localMessages: [localNotice('后台任务失败:端口被占用')], + }); + expect(turns).toHaveLength(1); + expect(turns[0]?.finals.map((block) => block.kind)).toEqual([ + 'assistant', + 'assistant', + ]); + expect(turns[0]?.finals[1]).toMatchObject({ + notice: true, + text: '后台任务失败:端口被占用', + }); + }); + + it('乐观用户气泡自成回合,已落盘的同一身份不重复渲染', () => { + const turns = buildDirectChatTurns({ + entries: [userEntry('u1'), assistantEntry('a1', '答复')], + localMessages: [localUser('问题 u1', 'u1'), localUser('第二条')], + turnRunning: true, + }); + expect(turns.map((turn) => turn.key)).toEqual(['u1', 'local:1']); + expect(turns[1]?.users).toHaveLength(1); + expect(turns[1]?.active).toBe(true); + }); + + it('历史无用户条目时也保留一个回合承载正文', () => { + const turns = buildDirectChatTurns({ + entries: [assistantEntry('a1', '只有回复')], + }); + expect(turns).toHaveLength(1); + expect(turns[0]?.users).toEqual([]); + expect(turns[0]?.finals[0]).toMatchObject({ text: '只有回复' }); + }); + + it('分页切片开头落在半截回合里时并进后面那个回合,不生成没有用户气泡的孤儿回合', () => { + // 更早的一屏把上一条用户条目切在外面:这三条前导条目属于上一轮,但不能自成回合。 + const turns = buildDirectChatTurns({ + entries: [ + reasoningEntry('r-prev'), + toolEntry('t-prev'), + assistantEntry('a-prev', '上一轮的答复', 1_800_000_000_900), + userEntry('u2', 1_800_000_010_000), + assistantEntry('a2', '这一轮的答复', 1_800_000_011_000), + ], + }); + expect(turns.map((turn) => turn.key)).toEqual(['u2']); + expect(turns[0]?.users.map((block) => block.key)).toEqual(['u2:u2']); + expect(turns[0]?.process.map((block) => block.key)).toEqual([ + 'u2:r-prev', + 'u2:t-prev', + 'u2:a-prev', + ]); + expect(turns[0]?.finals.map((block) => block.key)).toEqual(['u2:a2']); + }); + + it('条目转消息只保留用户 / 助手正文,并按身份去重', () => { + const messages = directChatEntriesToMessages([ + userEntry('u1'), + reasoningEntry('r1'), + toolEntry('t1'), + assistantEntry('a1', '答复'), + ]); + expect(messages.map((message) => message.messageId)).toEqual(['u1', 'a1']); expect( - build([user('one')], [], { - activeTurnId: 'one', - transientReply: '回复', - })[0].transientReply, - ).toBe('回复'); - expect( - build([user('one'), assistant('direct-codex:one:assistant')], [], { - activeTurnId: 'one', - transientReply: '回复', - })[0].transientReply, - ).toBe(''); + mergeDirectChatMessages(messages, [ + { role: 'assistant', text: '答复', messageId: 'a1' }, + localNotice('只在运行期的失败说明', 'local-1'), + ]).map((message) => message.messageId), + ).toEqual(['u1', 'a1', 'local-1']); }); }); diff --git a/apps/ai-game-creator-shell/tests/elapsedDuration.test.ts b/apps/ai-game-creator-shell/tests/elapsedDuration.test.ts new file mode 100644 index 000000000..9a9f9c304 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/elapsedDuration.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { formatElapsedDuration } from '../../../packages/shared/src/lib/formatElapsedDuration'; +import { + formatToolCallDuration, + formatTurnDuration, +} from '../src/features/project-workspace/toolCallGroupPresentation'; +import { resourceCanvasAssetGenerationElapsedLabel } from '../src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel'; + +describe('统一中文耗时', () => { + it.each([ + [0, '0.0秒'], + [5200, '5.2秒'], + [59_949, '59.9秒'], + [59_950, '1分00秒'], + [60_000, '1分00秒'], + [60_100, '1分00秒'], + [65_600, '1分06秒'], + [125_200, '2分05秒'], + [3_599_950, '1时00分00秒'], + [3_725_200, '1时02分05秒'], + [90_061_200, '25时01分01秒'], + ])('%s ms → %s,所有入口一致', (ms, expected) => { + expect(formatElapsedDuration(ms)).toBe(expected); + expect(formatToolCallDuration(ms)).toBe(expected); + expect(formatTurnDuration(ms)).toBe(expected); + expect(resourceCanvasAssetGenerationElapsedLabel(ms)).toBe(expected); + }); + + it.each([null, undefined, NaN, Infinity, -1])( + '未知时间不伪造零:%s', + (ms) => { + expect(formatElapsedDuration(ms)).toBeNull(); + }, + ); +}); diff --git a/apps/ai-game-creator-shell/tests/featureFlags.test.ts b/apps/ai-game-creator-shell/tests/featureFlags.test.ts new file mode 100644 index 000000000..1ee1092b4 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/featureFlags.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveAppUpdateCheckEnabled } from '../src/app/featureFlags'; + +describe('AGC 客户端特性开关', () => { + it('开发态(agc 启动)默认关闭检测更新', () => { + expect(resolveAppUpdateCheckEnabled('', true)).toBe(false); + }); + + it('正式包默认开启检测更新', () => { + expect(resolveAppUpdateCheckEnabled('', false)).toBe(true); + }); + + it('显式配置的开关优先于环境默认值', () => { + expect(resolveAppUpdateCheckEnabled('1', true)).toBe(true); + expect(resolveAppUpdateCheckEnabled('0', false)).toBe(false); + }); + + it('忽略无法识别的开关取值并回落到环境默认值', () => { + expect(resolveAppUpdateCheckEnabled('2', true)).toBe(false); + expect(resolveAppUpdateCheckEnabled(' ', false)).toBe(true); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/pluginHost.test.ts b/apps/ai-game-creator-shell/tests/pluginHost.test.ts new file mode 100644 index 000000000..86995c21c --- /dev/null +++ b/apps/ai-game-creator-shell/tests/pluginHost.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { startAvailableAgcPlugin } from '../src/services/pluginHost'; + +afterEach(() => vi.unstubAllGlobals()); + +describe('插件自动启动使用后端能力投影', () => { + it.each( + [ + [], + [ + { + id: 'agc-cocos-editor', + enabled: false, + hasRuntime: true, + status: 'stopped', + }, + ], + [ + { + id: 'agc-cocos-editor', + enabled: true, + hasRuntime: false, + status: 'package', + }, + ], + [ + { + id: 'agc-cocos-editor', + enabled: true, + hasRuntime: true, + status: 'invalid', + }, + ], + ].map((plugins) => ({ plugins })), + )('隐藏、禁用或不可执行的插件不启动(%j)', async ({ plugins }) => { + const invoke = vi.fn(async () => plugins); + vi.stubGlobal('window', { __TAURI__: { core: { invoke } } }); + await startAvailableAgcPlugin('agc-cocos-editor'); + expect(invoke).toHaveBeenCalledTimes(1); + expect(invoke).toHaveBeenCalledWith('list_agc_plugins'); + }); + + it('支持的 Cocos 插件继续按原入口启动', async () => { + const invoke = vi.fn(async (command: string) => + command === 'list_agc_plugins' + ? [ + { + id: 'agc-cocos-editor', + enabled: true, + hasRuntime: true, + status: 'stopped', + }, + ] + : {}, + ); + vi.stubGlobal('window', { __TAURI__: { core: { invoke } } }); + await startAvailableAgcPlugin('agc-cocos-editor'); + expect(invoke).toHaveBeenLastCalledWith('start_agc_plugin', { + id: 'agc-cocos-editor', + }); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/projectCreationDirectory.test.ts b/apps/ai-game-creator-shell/tests/projectCreationDirectory.test.ts new file mode 100644 index 000000000..65a91837b --- /dev/null +++ b/apps/ai-game-creator-shell/tests/projectCreationDirectory.test.ts @@ -0,0 +1,53 @@ +/** @vitest-environment jsdom */ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + normalizeProjectCreationDirectory, + readProjectCreationDirectory, + writeProjectCreationDirectory, +} from '../src/features/app-shell/model'; + +const STORAGE_KEY = 'genarrative-ai-game-creator.project-creation-directory.v1'; + +describe('项目创建目录偏好', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('去掉首尾空白与多余分隔符,并保留盘根', () => { + expect(normalizeProjectCreationDirectory(' ')).toBe(''); + expect(normalizeProjectCreationDirectory(' F:\\Projects\\游戏\\ ')).toBe( + 'F:\\Projects\\游戏', + ); + expect(normalizeProjectCreationDirectory('F:/Projects/游戏/')).toBe( + 'F:/Projects/游戏', + ); + expect(normalizeProjectCreationDirectory('C:\\')).toBe('C:\\'); + // 首尾空白按 trim 处理;目录中间的控制字符必须整条拒绝。 + expect(normalizeProjectCreationDirectory('F:\\游戏\n')).toBe('F:\\游戏'); + expect(normalizeProjectCreationDirectory('F:\\游\n戏')).toBe(''); + }); + + it('保存选中的目录,并在恢复默认位置时清空', () => { + expect(readProjectCreationDirectory()).toBe(''); + + expect(writeProjectCreationDirectory(' F:\\Projects\\游戏 ')).toBe( + 'F:\\Projects\\游戏', + ); + expect(readProjectCreationDirectory()).toBe('F:\\Projects\\游戏'); + + expect(writeProjectCreationDirectory(' ')).toBe(''); + expect(readProjectCreationDirectory()).toBe(''); + }); + + it('忽略存储里不可用的值,退回默认位置', () => { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify('relative/games')); + expect(readProjectCreationDirectory()).toBe(''); + + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(42)); + expect(readProjectCreationDirectory()).toBe(''); + + window.localStorage.setItem(STORAGE_KEY, '{not json'); + expect(readProjectCreationDirectory()).toBe(''); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index 48938bee7..ef129cd12 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -109,6 +109,8 @@ type PendingResourceEditFixture = { editKind: string; sourceResourceId: string; assetName: string; + backgroundMode?: string | null; + screenColor?: string | null; phase: string; createdAt: number; }; @@ -1294,6 +1296,42 @@ describe('project resource live canvas integration', () => { }); }); + it('恢复面板显示同名抠图的模式和颜色,缺失字段不推断默认值', async () => { + installTauri({ + pendingResourceEdits: [ + { backgroundMode: 'complex' }, + { backgroundMode: 'flat', screenColor: 'auto' }, + { backgroundMode: 'flat', screenColor: '#AABBCC' }, + { backgroundMode: 'flat' }, + {}, + ].map((options, index) => ({ + operationId: `background-${index}`, + editKind: 'background-removal', + sourceResourceId: 'source-art', + assetName: '透明底', + phase: 'accepted', + createdAt: 1, + ...options, + })), + }); + render(); + fireEvent.click( + await screen.findByRole('button', { name: '管理未完成编辑 (5)' }), + ); + const labels = screen + .getAllByText('透明底') + .map((name) => name.nextElementSibling?.textContent); + expect( + labels.map((label) => label?.split(' · ').slice(0, -1).join(' · ')), + ).toEqual([ + '图片抠图 · 复杂背景', + '图片抠图 · 平面背景 · 自动背景色', + '图片抠图 · 平面背景 · #AABBCC', + '图片抠图 · 平面背景', + '图片抠图', + ]); + }); + it('恢复面板可跳过首条失败任务继续任意 operation,且对账项不会被重放', async () => { const failedOperationId = '11111111-1111-4111-8111-111111111111'; const resumableOperationId = '22222222-2222-4222-8222-222222222222'; @@ -1914,9 +1952,8 @@ describe('project resource live canvas integration', () => { await openResourceBookCategory('角色与对象'); expect(await cardBadgeText('hero.png')).toBe('角色与对象'); - fireEvent.click(await findResourceSelectButton('hero.png')); - const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); - fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' })); + // 未选中资源也能直接从卡片类型角标进入,不依赖工具栏存在。 + fireEvent.click(screen.getByRole('button', { name: '素材类型:hero.png' })); const dialog = await screen.findByRole('dialog', { name: '设置素材类型', }); @@ -1993,7 +2030,7 @@ describe('project resource live canvas integration', () => { const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); // 类型面板:先点外部(DOM 上落在画布管理区之外),浮层与选中都不受影响。 - fireEvent.click(within(toolbar).getByRole('button', { name: '素材类型' })); + fireEvent.click(screen.getByRole('button', { name: '素材类型:hero.png' })); await screen.findByRole('dialog', { name: '设置素材类型' }); fireEvent.click(document.body); expect(screen.getByRole('dialog', { name: '设置素材类型' })).not.toBeNull(); @@ -2073,7 +2110,9 @@ describe('project resource live canvas integration', () => { await openResourceBookCategory('角色与对象'); fireEvent.click(await findResourceSelectButton('hero.png')); const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); - fireEvent.click(within(toolbar).getByRole('button', { name: '信息' })); + fireEvent.click( + screen.getByRole('button', { name: '查看hero.png资源信息' }), + ); const infoPanel = await screen.findByRole('dialog', { name: '资源信息' }); // 分类值本身仍是只读文本(`dd` 里只有值,入口按钮在它外面)。 diff --git a/apps/ai-game-creator-shell/tests/resourceBatchTagTargetModel.test.ts b/apps/ai-game-creator-shell/tests/resourceBatchTagTargetModel.test.ts new file mode 100644 index 000000000..bef4277a2 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceBatchTagTargetModel.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from 'vitest'; + +import { + resolveResourceBatchTagTargets, + RESOURCE_BATCH_TAG_MAX_ASSETS, +} from '../src/view/project-development/resourceBatchTagTargetModel'; + +function registered(id: string, assetId: string) { + return { id, manifestAssetId: assetId }; +} + +describe('resolveResourceBatchTagTargets', () => { + test('按选中集首现顺序给出去重后的资产 ID,顺序不按 manifest 排', () => { + const resolution = resolveResourceBatchTagTargets( + [ + registered('asset:bg', 'asset-bg'), + registered('asset:hero', 'asset-hero'), + ], + ['asset:hero', 'asset:bg', 'asset:hero'], + ); + + expect(resolution).toEqual({ + ok: true, + assetIds: ['asset-hero', 'asset-bg'], + }); + }); + + test('不足 2 项已登记素材时不进入批量模式', () => { + const resolution = resolveResourceBatchTagTargets( + [registered('asset:hero', 'asset-hero')], + ['asset:hero'], + ); + + expect(resolution.ok).toBe(false); + }); + + test('混入未登记素材(项目版本 / 附件 / 任务产物)时整批拒绝而不是只写已登记项', () => { + const resolution = resolveResourceBatchTagTargets( + [ + registered('asset:hero', 'asset-hero'), + registered('asset:npc', 'asset-npc'), + { id: 'version:v1', manifestAssetId: null }, + ], + ['asset:hero', 'asset:npc', 'version:v1'], + ); + + expect(resolution.ok).toBe(false); + if (resolution.ok) throw new Error('unreachable'); + expect(resolution.reason).toContain('未登记素材'); + }); + + test('选中集里的 ID 在投影里已不存在(素材被删)时同样拒绝', () => { + const resolution = resolveResourceBatchTagTargets( + [registered('asset:hero', 'asset-hero')], + ['asset:hero', 'asset:deleted'], + ); + + expect(resolution.ok).toBe(false); + if (resolution.ok) throw new Error('unreachable'); + expect(resolution.reason).toContain('已被删除'); + }); + + test(`去重后超过 ${RESOURCE_BATCH_TAG_MAX_ASSETS} 项时按上限拒绝`, () => { + const resources = Array.from( + { length: RESOURCE_BATCH_TAG_MAX_ASSETS + 1 }, + (_, index) => registered(`asset:a${index}`, `asset-a${index}`), + ); + const resolution = resolveResourceBatchTagTargets( + resources, + resources.map((item) => item.id), + ); + + expect(resolution.ok).toBe(false); + if (resolution.ok) throw new Error('unreachable'); + expect(resolution.reason).toContain(String(RESOURCE_BATCH_TAG_MAX_ASSETS)); + + const atLimit = resources.slice(0, RESOURCE_BATCH_TAG_MAX_ASSETS); + expect( + resolveResourceBatchTagTargets( + atLimit, + atLimit.map((item) => item.id), + ).ok, + ).toBe(true); + }); + + test('多个资源 ID 指向同一个资产时按资产去重,不把同一份素材算两遍', () => { + const resolution = resolveResourceBatchTagTargets( + [ + registered('asset:hero', 'asset-hero'), + registered('attachment:hero', 'asset-hero'), + ], + ['asset:hero', 'attachment:hero'], + ); + + // 去重后只剩 1 份素材:批量模式没有可写入的整组,直接拒绝。 + expect(resolution.ok).toBe(false); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx b/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx new file mode 100644 index 000000000..ab3277f2a --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceBatchTagsIntegration.test.tsx @@ -0,0 +1,679 @@ +/** @vitest-environment jsdom */ +import { useState } from 'react'; +import { beforeEach } from 'vitest'; + +import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { + createGameCreationAppManifest, + expect, + findResourceSelectButton, + fireEvent, + ProjectDevelopmentView, + render, + screen, + vi, + waitFor, + within, +} from './appSurface/harness'; + +const PROJECT_PATH = '/tmp/batch-tags-project'; +const PROJECT_ID = 'batch-tags-project'; +const OTHER_PROJECT_PATH = '/tmp/batch-tags-other'; +const OTHER_PROJECT_ID = 'batch-tags-other'; + +const disk = { + manifest: null as GameCreationAppManifest | null, + revision: 3, +}; + +function createFixtureManifest( + projectId = PROJECT_ID, + name = '批量标签项目', +): GameCreationAppManifest { + const manifest = createGameCreationAppManifest(projectId, name); + manifest.assets = [ + { + id: 'asset-hero', + kind: 'character', + category: 'character', + mediaType: 'image/png', + localPath: 'assets/hero.png', + source: { kind: 'uploaded' }, + }, + { + id: 'asset-npc', + kind: 'character', + category: 'character', + mediaType: 'image/png', + localPath: 'assets/npc.png', + source: { kind: 'uploaded' }, + }, + ]; + // 一个正式项目版本:它在投影里是「未登记素材」,用来钉混合选择不能部分写入。 + manifest.versions = [ + { + versionId: 'version-1', + parentVersionId: null, + projectRevision: 1, + resourceBindings: [], + createdReason: 'initial', + createdAt: 1, + }, + ]; + return manifest; +} + +function graphFor(manifest: GameCreationAppManifest) { + const resourceIds = manifest.assets.map((asset) => `asset:${asset.id}`); + return { + resourceIds, + referenceEdges: [], + taskFlows: [], + connectionIndex: resourceIds.map((resourceId) => ({ + resourceId, + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: [], + referenceEdgeIds: [], + taskFlowIds: [], + })), + producerAssignments: [], + dependencyDepths: resourceIds.map((resourceId) => ({ + resourceId, + dependencyDepth: 0, + })), + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; +} + +type BatchTagWrite = { + projectPath: string; + expectedProjectId: string; + expectedProjectRevision: number; + assetIds: string[]; + tags: string[]; +}; + +function appendTagsToDisk(input: BatchTagWrite) { + const base = disk.manifest; + if (!base) throw new Error('missing manifest fixture'); + disk.revision += 1; + const assets = base.assets.map((asset) => + input.assetIds.includes(asset.id) + ? ({ + ...asset, + tags: [ + ...(asset.tags ?? []), + ...input.tags.filter((tag) => !(asset.tags ?? []).includes(tag)), + ], + } as (typeof base.assets)[number]) + : asset, + ); + disk.manifest = { ...base, assets }; + return { + assets: assets.filter((asset) => input.assetIds.includes(asset.id)), + committedProjectRevision: disk.revision, + }; +} + +/** + * 真宿主接线:壳持有清单状态,画布是它的消费者,写入后用 + * `onManifestChange(projectPath, next)` 回写这一份状态。 + * + * 项目切换按「同一份工作台实例换 props」模拟(不卸载、不换 key): + * 这正是迟到回包必须被身份门禁拦住的场景。 + */ +function BatchTagsHost({ + projectPath, + projectId, +}: { + projectPath: string; + projectId: string; +}) { + const [scope, setScope] = useState(() => ({ + projectPath, + projectId, + manifest: createFixtureManifest(projectId), + })); + if (scope.projectPath !== projectPath || scope.projectId !== projectId) { + setScope({ + projectPath, + projectId, + manifest: createFixtureManifest(projectId), + }); + } + disk.manifest = scope.manifest; + + return ( + undefined} + onProjectsOpen={() => undefined} + supervisor={
} + onManifestChange={(path, next) => { + manifestChanges.push({ path, projectId: next.projectId }); + // 壳按当前项目身份收敛:只有当前项目的回写才落到工作台状态里。 + setScope((current) => + current.projectPath === path + ? { ...current, manifest: next } + : current, + ); + }} + /> + ); +} + +const manifestChanges: Array<{ path: string; projectId: string }> = []; + +function installHostTauri( + options: { + deferBatchWrite?: boolean; + failBatchWriteWith?: string; + /** 写入已提交后的那次整份 manifest 回读挂在飞,用来测"回读期间切项目"。 */ + deferManifestReadAfterWrite?: boolean; + failManifestReadAfterWriteWith?: string; + } = {}, +) { + const batchWrites: BatchTagWrite[] = []; + const classificationWrites: Array> = []; + let manifestReads = 0; + let batchWriteCompleted = false; + let deferredManifestRead: (() => void) | null = null; + let resolveDeferredBatchWrite: (() => void) | null = null; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_local_game_project_revision') { + return { revision: disk.revision }; + } + if (command === 'get_local_game_manifest') { + manifestReads += 1; + const snapshot = disk.manifest; + if (!snapshot) throw new Error('missing manifest fixture'); + if (batchWriteCompleted && options.failManifestReadAfterWriteWith) { + throw new Error(options.failManifestReadAfterWriteWith); + } + if ( + batchWriteCompleted && + options.deferManifestReadAfterWrite && + deferredManifestRead === null + ) { + return await new Promise((resolve) => { + deferredManifestRead = () => resolve(snapshot); + }); + } + return snapshot; + } + if (command === 'add_local_project_resource_tags') { + const input = (args?.input ?? {}) as BatchTagWrite; + batchWrites.push(structuredClone(input)); + if (options.failBatchWriteWith) { + throw new Error(options.failBatchWriteWith); + } + if (options.deferBatchWrite) { + return await new Promise((resolve) => { + resolveDeferredBatchWrite = () => resolve(appendTagsToDisk(input)); + }); + } + const committed = appendTagsToDisk(input); + batchWriteCompleted = true; + return committed; + } + if (command === 'update_local_project_resource_classification') { + const input = (args?.input ?? {}) as { + assetId?: string; + category?: string; + tags?: string[]; + }; + classificationWrites.push(structuredClone(input)); + const base = disk.manifest; + if (!base) throw new Error('missing manifest fixture'); + disk.revision += 1; + const nextManifest: GameCreationAppManifest = { + ...base, + assets: base.assets.map((asset) => + asset.id === input.assetId + ? ({ + ...asset, + category: input.category, + tags: input.tags ?? [], + } as (typeof base.assets)[number]) + : asset, + ), + }; + disk.manifest = nextManifest; + const asset = nextManifest.assets.find( + (entry) => entry.id === input.assetId, + ); + if (!asset) throw new Error(`missing asset ${String(input.assetId)}`); + return { asset, committedProjectRevision: disk.revision }; + } + if (command === 'read_local_project_resource_graph') { + return graphFor(disk.manifest!); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: PROJECT_ID, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'list_pending_local_project_resource_edits') { + return []; + } + if (command === 'read_local_project_resource_document') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'text/markdown', + byteLen: 1, + content: '', + }; + } + if ( + command === 'read_local_project_image_preview' || + command === 'read_local_project_text_preview' + ) { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'image/png', + byteLen: 1, + dataUrl: 'data:image/png;base64,AA==', + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { + core: { invoke: invoke as never }, + event: { listen: (async () => () => undefined) as never }, + }; + return { + invoke, + batchWrites, + classificationWrites, + manifestReads: () => manifestReads, + resolveDeferredBatchWrite: () => resolveDeferredBatchWrite?.(), + hasDeferredManifestRead: () => deferredManifestRead !== null, + resolveDeferredManifestRead: () => deferredManifestRead?.(), + }; +} + +async function openCategoryPage(name: string) { + fireEvent.click(await screen.findByRole('button', { name })); +} + +async function openResourcePanel() { + fireEvent.click(screen.getByRole('button', { name: '资源面板' })); + return within(await screen.findByRole('dialog', { name: '资源面板' })); +} + +/** + * 在资源面板里按顺序累加选中项(`onToggleEntry` 就是画布那份共享选中集的 append 入口), + * 关闭面板后画布上留下的就是同一份多选。 + */ +async function selectPanelEntriesInOrder( + labels: readonly string[], + options: { clearFirst?: boolean } = {}, +) { + const panel = await openResourcePanel(); + if (options.clearFirst) { + fireEvent.click(panel.getByRole('button', { name: '清空选择' })); + } + for (const label of labels) { + fireEvent.click(panel.getByRole('button', { name: `选择 ${label}` })); + } + fireEvent.click(panel.getByRole('button', { name: '关闭资源面板' })); + await waitFor(() => + expect(screen.queryByRole('dialog', { name: '资源面板' })).toBeNull(), + ); +} + +beforeEach(() => { + disk.manifest = null; + disk.revision = 3; + manifestChanges.length = 0; +}); + +describe('多选素材批量追加标签(真实工作台)', () => { + it('资源面板入口按完整选中集打开批量面板,一次写入整组而不是首项', async () => { + const { batchWrites, classificationWrites, manifestReads } = + installHostTauri(); + render(); + + await openCategoryPage('打开角色与对象'); + const panel = await openResourcePanel(); + fireEvent.click(panel.getByRole('button', { name: '全选' })); + + const readsBefore = manifestReads(); + fireEvent.click( + await waitFor(() => panel.getByRole('button', { name: '批量标签(2)' })), + ); + + // 标签面板打开时资源面板先让位:两层都监听 Escape 时同一次按键会连标签面板一起关掉。 + expect(screen.queryByRole('dialog', { name: '资源面板' })).toBeNull(); + const dialog = await screen.findByRole('dialog', { + name: '批量追加标签', + }); + expect(within(dialog).getByText('已选 2 项素材')).not.toBeNull(); + // 只显示待追加草稿:既有标签不进面板,也不会被当成提交值。 + expect(within(dialog).queryByRole('list', { name: '已有标签' })).toBeNull(); + + fireEvent.change( + within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'), + { target: { value: '主角, 配角' } }, + ); + fireEvent.click(within(dialog).getByRole('button', { name: '追加标签' })); + + await waitFor(() => expect(batchWrites).toHaveLength(1)); + expect(batchWrites[0]).toEqual({ + projectPath: PROJECT_PATH, + expectedProjectId: PROJECT_ID, + expectedProjectRevision: 3, + assetIds: ['asset-hero', 'asset-npc'], + tags: ['主角', '配角'], + }); + // 绝不能退化成单素材写入(那正是"只改首项")。 + expect(classificationWrites).toHaveLength(0); + + // 成功后用原生返回的 revision 回读整份 manifest,两项都带上了新标签。 + await waitFor(() => expect(manifestReads()).toBeGreaterThan(readsBefore)); + await waitFor(() => { + expect( + disk.manifest?.assets.map((asset) => [asset.id, asset.tags]), + ).toEqual([ + ['asset-hero', ['主角', '配角']], + ['asset-npc', ['主角', '配角']], + ]); + }); + }); + + it('混合选择含正式项目版本时入口禁用并给出原因,不会只写已登记的两项', async () => { + const { batchWrites } = installHostTauri(); + render(); + + await openCategoryPage('打开所有资源'); + const panel = await openResourcePanel(); + fireEvent.click(panel.getByRole('button', { name: '全选' })); + + const button = (await waitFor(() => + panel.getByRole('button', { name: '批量标签' }), + )) as HTMLButtonElement; + expect(button.disabled).toBe(true); + expect(panel.getByText(/批量标签不可用:.*未登记素材/u)).not.toBeNull(); + + fireEvent.click(button); + expect(screen.queryByRole('dialog', { name: '批量追加标签' })).toBeNull(); + expect(batchWrites).toHaveLength(0); + }); + + it('单选仍走单素材面板:既有增删标签行为不变,选项不给批量入口', async () => { + const { batchWrites, classificationWrites } = installHostTauri(); + render(); + + await openCategoryPage('打开角色与对象'); + fireEvent.click(await findResourceSelectButton('hero.png')); + const toolbar = await screen.findByRole('toolbar', { name: '图片工具栏' }); + fireEvent.click(within(toolbar).getByRole('button', { name: '编辑标签' })); + + const dialog = await screen.findByRole('dialog', { name: '编辑素材标签' }); + fireEvent.change( + within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'), + { target: { value: '主角' } }, + ); + fireEvent.click(within(dialog).getByRole('button', { name: '添加' })); + + await waitFor(() => expect(classificationWrites).toHaveLength(1)); + expect(classificationWrites[0]).toMatchObject({ + assetId: 'asset-hero', + category: 'character', + tags: ['主角'], + }); + expect(batchWrites).toHaveLength(0); + }); + + it('原生拒绝整批写入时不留部分结果,面板保留待追加草稿并报出可读原因', async () => { + // 原生按 CAS 冲突拒绝本批写入:整批不写、面板报可读原因、草稿留着可直接重试。 + const { batchWrites } = installHostTauri({ + failBatchWriteWith: 'project-revision-conflict', + }); + render(); + + await openCategoryPage('打开角色与对象'); + const panel = await openResourcePanel(); + fireEvent.click(panel.getByRole('button', { name: '全选' })); + fireEvent.click( + await waitFor(() => panel.getByRole('button', { name: '批量标签(2)' })), + ); + const dialog = await screen.findByRole('dialog', { + name: '批量追加标签', + }); + + fireEvent.change( + within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'), + { target: { value: '春节' } }, + ); + fireEvent.click(within(dialog).getByRole('button', { name: '追加标签' })); + + await waitFor(() => + expect(within(dialog).getByRole('alert').textContent).toBe( + '项目已被其它操作改动,请刷新后重试', + ), + ); + // 整批只发出一次请求,失败后 revision 不推进、草稿保留(重试不用重填)。 + expect(batchWrites).toHaveLength(1); + expect(disk.revision).toBe(3); + expect( + within(dialog).getByRole('list', { name: '待追加标签' }).textContent, + ).toContain('春节'); + }); + + it('切项目关闭批量面板并丢弃迟到回包,不把旧项目清单回写到新项目', async () => { + const { resolveDeferredBatchWrite } = installHostTauri({ + deferBatchWrite: true, + }); + const view = render( + , + ); + + await openCategoryPage('打开角色与对象'); + const panel = await openResourcePanel(); + fireEvent.click(panel.getByRole('button', { name: '全选' })); + fireEvent.click( + await waitFor(() => panel.getByRole('button', { name: '批量标签(2)' })), + ); + const dialog = await screen.findByRole('dialog', { + name: '批量追加标签', + }); + fireEvent.change( + within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'), + { target: { value: '春节' } }, + ); + fireEvent.click(within(dialog).getByRole('button', { name: '追加标签' })); + + // 写入还没回来时切项目:面板必须关掉。 + view.rerender( + , + ); + await waitFor(() => + expect(screen.queryByRole('dialog', { name: '批量追加标签' })).toBeNull(), + ); + + const changesBeforeLateWrite = manifestChanges.length; + resolveDeferredBatchWrite(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // 迟到回包不得触发旧项目的 manifest 回写(更没有盖到新项目上)。 + expect(manifestChanges.length).toBe(changesBeforeLateWrite); + expect(manifestChanges.map((change) => change.path)).not.toContain( + PROJECT_PATH, + ); + }); + + it('整份 manifest 回读期间切项目:回读完成后仍要判断有效性,旧项目清单不回写', async () => { + const { + batchWrites, + hasDeferredManifestRead, + resolveDeferredManifestRead, + } = installHostTauri({ deferManifestReadAfterWrite: true }); + const view = render( + , + ); + + await openCategoryPage('打开角色与对象'); + const panel = await openResourcePanel(); + fireEvent.click(panel.getByRole('button', { name: '全选' })); + fireEvent.click( + await waitFor(() => panel.getByRole('button', { name: '批量标签(2)' })), + ); + const dialog = await screen.findByRole('dialog', { + name: '批量追加标签', + }); + fireEvent.change( + within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'), + { target: { value: '春节' } }, + ); + fireEvent.click(within(dialog).getByRole('button', { name: '追加标签' })); + + // 写入已经提交,回读整份清单还挂在飞 —— 此刻切项目正是最危险的时间窗: + // 提交回包前的核对早就过去了,回来的是旧项目的完整清单。 + await waitFor(() => expect(batchWrites).toHaveLength(1)); + await waitFor(() => expect(hasDeferredManifestRead()).toBe(true)); + + const oldProjectChangesBeforeSwitch = () => + manifestChanges.filter((change) => change.path === PROJECT_PATH).length; + const oldProjectChangesAtSwitch = oldProjectChangesBeforeSwitch(); + + view.rerender( + , + ); + await waitFor(() => + expect(screen.queryByRole('dialog', { name: '批量追加标签' })).toBeNull(), + ); + + resolveDeferredManifestRead(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // 回读结果属于已经离开的旧项目:不得触发旧路径的 manifest 回写。 + expect(oldProjectChangesBeforeSwitch()).toBe(oldProjectChangesAtSwitch); + }); + + it('原生写入成功但整份 manifest 回读失败时,说明已保存、只是刷新失败', async () => { + const { batchWrites } = installHostTauri({ + failManifestReadAfterWriteWith: '读取项目清单超时', + }); + render(); + + await openCategoryPage('打开角色与对象'); + const panel = await openResourcePanel(); + fireEvent.click(panel.getByRole('button', { name: '全选' })); + fireEvent.click( + await waitFor(() => panel.getByRole('button', { name: '批量标签(2)' })), + ); + const dialog = await screen.findByRole('dialog', { + name: '批量追加标签', + }); + fireEvent.change( + within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'), + { target: { value: '春节' } }, + ); + fireEvent.click(within(dialog).getByRole('button', { name: '追加标签' })); + + await waitFor(() => expect(batchWrites).toHaveLength(1)); + // 写入已在原生侧提交:提示必须是「已保存、刷新失败」,不能谎报保存失败。 + await waitFor(() => + expect( + screen.getByText(/标签已保存,但刷新失败:读取项目清单超时/u), + ).not.toBeNull(), + ); + // 已在磁盘上的写入确实生效(只是宿主没拿到新清单)。 + expect(disk.manifest?.assets.map((asset) => asset.tags)).toEqual([ + ['春节'], + ['春节'], + ]); + }); + + it('画布多选 2 份真实素材时,「编辑标签」打开的就是批量模式并按整组写入', async () => { + const { batchWrites, classificationWrites } = installHostTauri(); + render(); + + // 画布与资源面板共用同一份选中集:这里按顺序累加 hero、npc, + // 关掉面板后画布上的多选就是这两份已登记素材。 + await openCategoryPage('打开所有资源'); + await selectPanelEntriesInOrder(['hero.png', 'npc.png']); + + const toolbar = await screen.findByRole('toolbar', { + name: '图片工具栏', + }); + fireEvent.click(within(toolbar).getByRole('button', { name: '编辑标签' })); + + const dialog = await screen.findByRole('dialog', { + name: '批量追加标签', + }); + expect(within(dialog).getByText('已选 2 项素材')).not.toBeNull(); + fireEvent.change( + within(dialog).getByPlaceholderText('新增标签,多个用逗号分隔'), + { target: { value: '春节' } }, + ); + fireEvent.click(within(dialog).getByRole('button', { name: '追加标签' })); + + await waitFor(() => expect(batchWrites).toHaveLength(1)); + // 整批两份都在写入对象里,而不是只改首项。 + expect(batchWrites[0]?.assetIds).toEqual(['asset-hero', 'asset-npc']); + expect(classificationWrites).toHaveLength(0); + }); + + it('首项是项目版本时工具条不整条消失:批量入口禁用并给出原因(两种选择顺序一致)', async () => { + const { batchWrites } = installHostTauri(); + render(); + + // 版本(未登记素材)排在第一项:整份选择集不能整批写入, + // 但工具条必须还在,把"为什么不能批量写"显示出来。 + await openCategoryPage('打开所有资源'); + await selectPanelEntriesInOrder(['版本 1', 'hero.png', 'npc.png']); + + const toolbar = await screen.findByRole('toolbar', { + name: '图片工具栏', + }); + const versionFirst = within(toolbar).getByRole('button', { + name: '编辑标签', + }) as HTMLButtonElement; + expect(versionFirst.disabled).toBe(true); + expect(versionFirst.title).toContain('未登记素材'); + + fireEvent.click(versionFirst); + expect(screen.queryByRole('dialog', { name: '批量追加标签' })).toBeNull(); + expect(batchWrites).toHaveLength(0); + + // 反向顺序(已登记素材在前、版本在后)给出同一份禁用原因: + // 入口判据只看整份选择集,不取决于哪一项被先选中。 + await selectPanelEntriesInOrder(['hero.png', 'npc.png', '版本 1'], { + clearFirst: true, + }); + + const assetFirstToolbar = await screen.findByRole('toolbar', { + name: '图片工具栏', + }); + const assetFirst = within(assetFirstToolbar).getByRole('button', { + name: '编辑标签', + }) as HTMLButtonElement; + expect(assetFirst.disabled).toBe(true); + expect(assetFirst.title).toBe(versionFirst.title); + fireEvent.click(assetFirst); + expect(screen.queryByRole('dialog', { name: '批量追加标签' })).toBeNull(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts b/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts index cd3cfe204..e2099510f 100644 --- a/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceBookLayout.test.ts @@ -7,8 +7,6 @@ import { resolveResourceBookAllLayout, RESOURCE_BOOK_ALL_BAND_GAP, RESOURCE_BOOK_OVERVIEW_STACK_LIMIT, - type ResourceBookAllBand, - resourceBookAllBandLocalPoint, type ResourceBookAllLayout, resourceBookAllLayoutKey, resourceBookOverviewCardLayout, @@ -270,8 +268,6 @@ describe('buildResourceBookScenePlan', () => { y: 0, width: 180, height: 128, - dragX: 0, - dragY: 0, rotation: 0, }, }, @@ -288,8 +284,6 @@ describe('buildResourceBookScenePlan', () => { y: 0, width: 180, height: 128, - dragX: 0, - dragY: 0, rotation: 0, }, }, @@ -464,12 +458,11 @@ describe('buildResourceBookScenePlan', () => { // 世界原点叠成一摞(用户报的正是这个)。 expect(artGroup.titlebar).toBe(false); expect(artGroup.titlebarActive).toBe(false); - // 画出来的是"栏目内局部坐标 + 带原点",拖动起点与它同系(提交时再减回带原点)。 + // 画出来的是"栏目内局部坐标 + 带原点":拖动按同一份世界位移换算成栏目内局部坐标落盘, + // 所以画出来的世界坐标必须等于"sidecar 里的局部坐标 + 带原点"。 const first = artGroup.cards[0]!; expect(first.layout.x).toBe(positions.get('art-0')!.x + band.originX); expect(first.layout.y).toBe(positions.get('art-0')!.y + band.originY); - expect(first.layout.dragX).toBe(first.layout.x); - expect(first.layout.dragY).toBe(first.layout.y); // 各栏目带之间不共享原点:不同栏目的卡不会被画到同一个位置。 const sceneCard = plan @@ -643,56 +636,6 @@ describe('buildResourceBookAllLayout', () => { }); }); -describe('resourceBookAllBandLocalPoint', () => { - const band: ResourceBookAllBand = { - category: 'document', - originX: 40, - originY: 100, - width: 620, - height: 200, - }; - const layout: ResourceBookAllLayout = { - bands: [band], - bandByCategory: new Map([['document', band]]), - bounds: { x: 0, y: 0, width: 620, height: 300 }, - }; - - /** - * 这是本方案唯一会落到布局 sidecar 上的换算:展开态画的是"局部坐标 + 带原点", - * 落盘只认栏目内局部坐标。换算方向写错 = 把卡写到别处,所以单独钉住。 - */ - it('subtracts the band origin so the world point lands back on the column-local point', () => { - expect( - resourceBookAllBandLocalPoint({ - layout, - category: 'document', - x: 140, - y: 260, - }), - ).toEqual({ x: 100, y: 160 }); - }); - - it('passes the point through for the column page and for a category without a band', () => { - // 栏目页没有分带(`layout` 传 null),减 0:与既有"局部坐标直接落盘"逐字等价。 - expect( - resourceBookAllBandLocalPoint({ - layout: null, - category: 'document', - x: 140, - y: 260, - }), - ).toEqual({ x: 140, y: 260 }); - expect( - resourceBookAllBandLocalPoint({ - layout, - category: 'audio', - x: 7, - y: 9, - }), - ).toEqual({ x: 7, y: 9 }); - }); -}); - /** * 「所有资源」展开态里文档卡与图片卡一开始自动重叠的回归夹具(PR #316 反馈)。 * diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx index 1b7c0a8d0..0a1ba40ca 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx @@ -13,6 +13,10 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; import { ResourceCanvasGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView'; +import { + generationPromptText, + typeGenerationPrompt, +} from './resourceGenerationPromptTestUtils'; afterEach(() => { cleanup(); @@ -50,7 +54,7 @@ describe('图片类生成面板:点击即关闭,面板里不出现阶段文 onClose={onClose} />, ); - await user.type(screen.getByLabelText('生成提示词'), '主界面与背包页'); + await typeGenerationPrompt(document.body, '主界面与背包页'); // 点击前主按钮文案就是动作名:不是阶段、也不是「已提交」。 const panel = screen.getByRole('dialog', { name: '生成 UI 设计图' }); expect( @@ -84,7 +88,7 @@ describe('图片类生成面板:点击即关闭,面板里不出现阶段文 onClose={onClose} />, ); - await user.type(screen.getByLabelText('生成提示词'), '主界面与背包页'); + await typeGenerationPrompt(document.body, '主界面与背包页'); await user.click( screen.getByRole('button', { name: '关闭生成 UI 设计图' }), @@ -112,6 +116,7 @@ describe('图片类生成面板:点击即关闭,面板里不出现阶段文 assetName: 'AI 生成 UI 设计图', aspectRatio: '16:9', imageSize: '1K', + references: [], }} error="生成素材失败:远端拒绝" onSubmit={onSubmit} @@ -122,9 +127,9 @@ describe('图片类生成面板:点击即关闭,面板里不出现阶段文 expect(screen.getByRole('alert').textContent).toContain( '生成素材失败:远端拒绝', ); - expect( - (screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value, - ).toBe('主界面与背包页'); + await waitFor(() => + expect(generationPromptText(document.body)).toBe('主界面与背包页'), + ); await user.click(screen.getByRole('button', { name: '生成 UI 设计图' })); expect(onSubmit).toHaveBeenCalledTimes(1); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts index 908aa2669..07ca4cc16 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts @@ -79,6 +79,35 @@ describe('生成任务模型', () => { RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE, ); expect(task.restored).toBe(false); + // 没有选择参考图时就是空列表:原生侧按「没有参考」处理,前端不编造引用。 + expect(task.referenceAssetIds).toEqual([]); + }); + + test('参考图资产 ID 去重后随任务保存,重开项目恢复出来的历史任务不带参考选择', () => { + const task = createResourceCanvasAssetGenerationTask({ + taskId: 'task-ref', + action: uiPrototypeAction, + prompt: '主界面', + assetName: 'UI', + aspectRatio: '16:9', + imageSize: '1K', + referenceAssetIds: ['asset-a', ' asset-b ', 'asset-a', ''], + targetCategory: 'ui-interaction', + outputPath: null, + projectId: 'project-1', + nowMillis: 10, + }); + expect(task.referenceAssetIds).toEqual(['asset-a', 'asset-b']); + // 入口栏目只是随任务带给原生的可选入参(原生 `target_category`);前端不据它做分类。 + expect(task.targetCategory).toBe('ui-interaction'); + + const restored = applyLocalProjectAssetGenerationRecords( + [], + [record('task-ref', 'completed')], + ); + expect(restored).toHaveLength(1); + expect(restored[0]?.referenceAssetIds).toEqual([]); + expect(restored[0]?.targetCategory).toBeNull(); }); test('有在途任务时下一条不可派发,前一条终态后才轮到它', () => { @@ -179,11 +208,9 @@ describe('生成任务模型', () => { }); test('已耗时文案按分秒呈现', () => { - expect(resourceCanvasAssetGenerationElapsedLabel(12_000)).toBe('12 秒'); - expect(resourceCanvasAssetGenerationElapsedLabel(72_000)).toBe( - '1 分 12 秒', - ); - expect(resourceCanvasAssetGenerationElapsedLabel(-5)).toBe('0 秒'); + expect(resourceCanvasAssetGenerationElapsedLabel(12_000)).toBe('12.0秒'); + expect(resourceCanvasAssetGenerationElapsedLabel(72_000)).toBe('1分12秒'); + expect(resourceCanvasAssetGenerationElapsedLabel(-5)).toBe('—'); }); }); @@ -348,6 +375,29 @@ describe('本地排队驱动器', () => { ).toBe('asset-task-b'); }); + test('派发把参考图资产 ID 原样交给原生侧', async () => { + const harness = createHarness((pollCount, api) => { + if (pollCount === 1) { + api.advance('task-ref', 'completed'); + } + }); + const queue = createResourceCanvasAssetGenerationQueue(harness.deps); + + await queue.submit({ + ...localTask('task-ref', 10), + referenceAssetIds: ['asset-a', 'asset-b'], + }); + + const startCall = harness.invoke.mock.calls.find( + ([command]) => command === 'start_local_project_asset_generation', + ); + expect(startCall?.[1]).toMatchObject({ + taskId: 'task-ref', + // 只传当前 manifest 的资产 ID:不传本地路径,也不传历史远端 ID。 + referenceAssetIds: ['asset-a', 'asset-b'], + }); + }); + test('账本里找不到这条任务时不会无限轮询,收口为失败并放行后面的排队任务', async () => { const harness = createHarness((pollCount, api) => { if (pollCount === 1) { diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx new file mode 100644 index 000000000..0e4095ae2 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationReferences.test.tsx @@ -0,0 +1,334 @@ +// @vitest-environment jsdom +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { + type ChatReference, + resourceReferenceFromAsset, +} from '../src/features/project-workspace/resourceReferences'; +import { + ResourceCanvasAssetGenerationPanelView, + type ResourceCanvasAssetGenerationSubmitInput, +} from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; +import { + RESOURCE_CANVAS_ASSET_GENERATION_MAX_REFERENCES, + RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES, + RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC, + resourceCanvasAssetGenerationAcceptsReferences, + resourceCanvasAssetGenerationReferenceAssets, + resourceCanvasAssetGenerationReferenceError, + resourceCanvasAssetGenerationReferenceIds, + resourceCanvasAssetGenerationReferenceIssue, + resourceCanvasAssetGenerationUserReferenceLimit, +} from '../src/features/resource-canvas/resourceCanvasAssetGenerationReferenceModel'; +import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; + +afterEach(() => { + cleanup(); +}); + +function action( + overrides: Partial & + Pick, +): ResourceCanvasAssetToolAction { + return { + route: 'asset', + audioKind: null, + assetName: `素材 ${overrides.label}`, + promptPlaceholder: '描述要生成什么', + adjustableDimensions: true, + aspectRatio: '1:1', + imageSize: '1K', + requiresIconSpecReference: false, + writesIconSpecReference: false, + ...overrides, + }; +} + +const imageAction = action({ + id: 'generate-image', + label: '生成图片', + assetKind: 'image', +}); +const iconSpecAction = action({ + id: 'generate-spec-icon', + label: '图标规范', + assetKind: 'icon-spec', + adjustableDimensions: false, + writesIconSpecReference: true, +}); +const uiPrototypeAction = action({ + id: 'generate-ui-prototype', + label: '生成 UI 设计图', + assetKind: 'ui-prototype', + requiresIconSpecReference: true, +}); +const spritesheetAction = action({ + id: 'generate-icon-spritesheet', + label: '生成图标素材', + assetKind: 'art-spritesheet', + requiresIconSpecReference: true, +}); + +function asset( + id: string, + mediaType: string, + localPath: string, + kind = 'image', +): GameCreationAppAssetManifestEntry { + return { id, kind, mediaType, localPath, source: { kind: 'canvas' } }; +} + +function resourceReference( + resourceId: string, + label: string, + mediaType = 'image/png', +): ChatReference { + return { + type: 'resource', + resourceId, + kind: 'image', + mediaType, + label, + category: 'scene', + tags: [], + source: 'asset-picker', + }; +} + +describe('参考图模型', () => { + test('只有图集不接受用户参考,图标规范与普通生成一致', () => { + expect(resourceCanvasAssetGenerationAcceptsReferences(imageAction)).toBe( + true, + ); + expect(resourceCanvasAssetGenerationAcceptsReferences(iconSpecAction)).toBe( + true, + ); + expect( + resourceCanvasAssetGenerationAcceptsReferences(uiPrototypeAction), + ).toBe(true); + expect( + resourceCanvasAssetGenerationAcceptsReferences(spritesheetAction), + ).toBe(false); + }); + + test('用户参考上限:普通生成五张,自带规范前置的入口四张,图集零张', () => { + expect(resourceCanvasAssetGenerationUserReferenceLimit(imageAction)).toBe( + RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES, + ); + expect( + resourceCanvasAssetGenerationUserReferenceLimit(iconSpecAction), + ).toBe(RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES); + expect( + resourceCanvasAssetGenerationUserReferenceLimit(uiPrototypeAction), + ).toBe(RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC); + expect( + resourceCanvasAssetGenerationUserReferenceLimit(spritesheetAction), + ).toBe(0); + // 规范前置入口的四张用户参考 + 一张权威规范图,正好是总上限。 + expect( + RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC + 1, + ).toBe(RESOURCE_CANVAS_ASSET_GENERATION_MAX_REFERENCES); + }); + + test('候选集只留当前项目已登记的图片', () => { + const candidates = resourceCanvasAssetGenerationReferenceAssets([ + asset('asset-image', 'image/png', 'assets/a.png'), + asset('asset-doc', 'text/markdown', 'assets/a.md', 'document'), + asset('asset-audio', 'audio/mpeg', 'assets/a.mp3', 'sound-effect'), + // 原生按 `image::load_from_memory` 解码,SVG 读不出来:进了候选就是一次必然失败的付费提交。 + asset('asset-svg', 'image/svg+xml', 'assets/a.svg'), + asset('asset-svg-alias', 'image/svg', 'assets/b.svg'), + asset('asset-hidden', 'image/png', '.agent/runtime/a.png'), + asset('asset-remote-only', 'image/png', ' '), + ]); + expect(candidates.map((item) => item.id)).toEqual(['asset-image']); + }); + + test('陈旧引用判据把矢量图与缺失文件分开报,不做静默过滤', () => { + const assets = [ + asset('asset-svg', 'image/svg+xml', 'assets/a.svg'), + asset('asset-no-file', 'image/png', ' '), + ]; + expect( + resourceCanvasAssetGenerationReferenceIssue({ + references: [resourceReference('asset-svg', '矢量图')], + assets, + }), + ).toContain('矢量图'); + expect( + resourceCanvasAssetGenerationReferenceIssue({ + references: [resourceReference('asset-no-file', '没落盘')], + assets, + }), + ).toContain('没有本地文件'); + expect( + resourceCanvasAssetGenerationReferenceIssue({ + references: [resourceReference('asset-missing', '已删除')], + assets, + }), + ).toContain('已不在当前项目'); + expect( + resourceCanvasAssetGenerationReferenceIssue({ + references: [resourceReference('asset-image', '正常图片')], + assets: [asset('asset-image', 'image/webp', 'assets/a.webp')], + }), + ).toBeNull(); + }); + + test('参考 ID 只取资源引用、按选择顺序去重', () => { + expect( + resourceCanvasAssetGenerationReferenceIds([ + resourceReference('asset-a', '素材 A'), + { + type: 'runtime-region', + label: '运行区域', + resourceIds: ['asset-x'], + source: 'runtime-picker', + }, + resourceReference('asset-b', '素材 B'), + resourceReference('asset-a', '素材 A 重名'), + resourceReference(' ', '空身份'), + ]), + ).toEqual(['asset-a', 'asset-b']); + }); + + test('超限给出可执行原因而不是静默截断', () => { + expect( + resourceCanvasAssetGenerationReferenceError({ + action: imageAction, + referenceCount: RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES, + }), + ).toBeNull(); + expect( + resourceCanvasAssetGenerationReferenceError({ + action: imageAction, + referenceCount: + RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES + 1, + }), + ).toContain('最多 5 张'); + expect( + resourceCanvasAssetGenerationReferenceError({ + action: uiPrototypeAction, + referenceCount: + RESOURCE_CANVAS_ASSET_GENERATION_MAX_USER_REFERENCES_WITH_SPEC + 1, + }), + ).toContain('最多 4 张'); + // 图集没有参考选择器:不存在「超限」这条用户可见反馈。 + expect( + resourceCanvasAssetGenerationReferenceError({ + action: spritesheetAction, + referenceCount: 3, + }), + ).toBeNull(); + }); +}); + +describe('生成面板的参考接线', () => { + test('图片入口呈现参考计数与引用输入区,提交带回引用', async () => { + const user = userEvent.setup(); + const onSubmit = + vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); + const imageAsset = asset('asset-a', 'image/png', 'assets/素材-a.png'); + const references = [resourceReferenceFromAsset(imageAsset, 'asset-picker')]; + render( + undefined} + />, + ); + + const panel = screen.getByRole('dialog', { name: '生成图片' }); + expect(panel.textContent).toContain('参考图 1/5'); + // 引用输入区与聊天 / 快速编辑同一份组件:可访问名就是「生成提示词」。 + expect(screen.getByLabelText('生成提示词')).not.toBeNull(); + + await user.click(within(panel).getByRole('button', { name: '生成图片' })); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ + kind: 'image', + prompt: '画一只猫 @素材-a', + references: [expect.objectContaining({ resourceId: 'asset-a' })], + }); + }); + + test('图集入口不呈现参考选择,只提交纯提示词', async () => { + const user = userEvent.setup(); + const onSubmit = + vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); + render( + undefined} + />, + ); + + const panel = screen.getByRole('dialog', { name: '生成图标素材' }); + expect(panel.textContent).not.toContain('参考图'); + const prompt = screen.getByLabelText('生成提示词'); + expect((prompt as HTMLTextAreaElement).tagName).toBe('TEXTAREA'); + + await user.click( + within(panel).getByRole('button', { name: '生成图标素材' }), + ); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ + kind: 'art-spritesheet', + references: [], + }); + }); + + test('超限时提交被挡住并给出原因', async () => { + const user = userEvent.setup(); + const onSubmit = + vi.fn<(input: ResourceCanvasAssetGenerationSubmitInput) => void>(); + render( + + resourceReference(`asset-${id}`, `素材 ${id}`), + ), + }} + onSubmit={onSubmit} + onClose={() => undefined} + />, + ); + + const panel = screen.getByRole('dialog', { name: '生成 UI 设计图' }); + expect(panel.textContent).toContain('参考图 5/4'); + const submit = within(panel).getByRole('button', { + name: '生成 UI 设计图', + }); + expect((submit as HTMLButtonElement).disabled).toBe(true); + await user.click(submit); + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx index 41b20e135..2fc7cef08 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx @@ -124,7 +124,8 @@ describe('「生成任务」侧栏', () => { expect(within(doneSection).getByText('生成已完成。')).not.toBeNull(); expect(within(doneSection).getByRole('alert').textContent).toBe('远端拒绝'); expect( - screen.getAllByText(/^已耗时 \d+ (秒|分 \d+ 秒)$/).length, + screen.getAllByText(/^已耗时 (?:\d+\.\d秒|(?:\d+时)?\d+分\d{2}秒)$/) + .length, ).toBeGreaterThan(0); }); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx index c228db240..2572d06a6 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx @@ -12,6 +12,7 @@ import { EDITOR_IMAGE_DIMENSION_OPTIONS, IMAGE_MODEL_NANOBANANA2, } from '../../../src/components/image-editor/ImageCanvasGenerationModel'; +import type { ChatReference } from '../src/features/project-workspace/resourceReferences'; import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; import { projectHasIconSpecReference, @@ -26,6 +27,10 @@ import { resourceCanvasBottomToolActions, } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; import { ResourceCanvasBottomToolbarView } from '../src/features/resource-canvas/ResourceCanvasBottomToolbarView'; +import { + generationPromptText, + typeGenerationPrompt, +} from './resourceGenerationPromptTestUtils'; afterEach(cleanup); @@ -367,9 +372,7 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { screen.queryByRole('button', { name: '图标规范比例 1:1' }), ).toBeNull(); - fireEvent.change(screen.getByLabelText('生成提示词'), { - target: { value: '像素月光厨房的统一视觉规范' }, - }); + await typeGenerationPrompt(panel, '像素月光厨房的统一视觉规范'); fireEvent.click(screen.getByRole('button', { name: '图标规范' })); await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ @@ -378,6 +381,7 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { assetName: '图标规范', aspectRatio: '1:1', imageSize: '1K', + references: [], }), ); }); @@ -402,9 +406,7 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { fireEvent.click( screen.getByRole('button', { name: '生成 UI 设计图尺寸 2K' }), ); - fireEvent.change(screen.getByLabelText('生成提示词'), { - target: { value: '横屏单屏界面' }, - }); + await typeGenerationPrompt(document.body, '横屏单屏界面'); fireEvent.click(screen.getByRole('button', { name: '生成 UI 设计图' })); await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ @@ -413,11 +415,12 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { assetName: 'AI 生成 UI 设计图', aspectRatio: '9:16', imageSize: '2K', + references: [], }), ); }); - test('空提示词不提交;点击即关闭,重开时带回草稿与失败原因', () => { + test('空提示词不提交;点击即关闭,重开时带回草稿与失败原因', async () => { const onSubmit = vi.fn(); const onClose = vi.fn(); const action = assetActionOf('character', '生成角色形象'); @@ -431,9 +434,7 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { const submit = screen.getByRole('button', { name: '生成角色形象' }); expect((submit as HTMLButtonElement).disabled).toBe(true); - fireEvent.change(screen.getByLabelText('生成提示词'), { - target: { value: '披风猫骑士' }, - }); + await typeGenerationPrompt(document.body, '披风猫骑士'); fireEvent.click(submit); // 点击即关闭:面板不等受理结果,失败由宿主决定要不要把它带回来。 expect(onSubmit).toHaveBeenCalledTimes(1); @@ -446,6 +447,7 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { assetName: string; aspectRatio: string; imageSize: string; + references: ChatReference[]; }; render( { assetName: first.assetName, aspectRatio: first.aspectRatio, imageSize: first.imageSize, + references: first.references, }} error="图片比例不受支持:4:3" onSubmit={onSubmit} @@ -464,9 +467,10 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { expect(screen.getByRole('alert').textContent).toContain( '图片比例不受支持:4:3', ); - expect( - (screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value, - ).toBe('披风猫骑士'); + // 草稿会重新灌回 `@` 引用输入区;它由编辑器异步落到 DOM,等一次。 + await waitFor(() => + expect(generationPromptText(document.body)).toContain('披风猫骑士'), + ); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); expect(onSubmit).toHaveBeenCalledTimes(2); expect(onSubmit.mock.calls[1]?.[0]).toEqual(onSubmit.mock.calls[0]?.[0]); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasCardName.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasCardName.test.tsx new file mode 100644 index 000000000..a11503f21 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasCardName.test.tsx @@ -0,0 +1,365 @@ +/** @vitest-environment jsdom */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { + createGameCreationAppManifest, + findResourceSelectButton, + fireEvent, + installResizeObserverStub, + ProjectDevelopmentView, + React, + render, + screen, + waitFor, + within, +} from './appSurface/harness'; + +vi.mock('@tauri-apps/api/core', async () => ({ + ...(await vi.importActual( + '@tauri-apps/api/core', + )), + invoke: (command: string, args?: Record) => + window.__TAURI__!.core.invoke(command, args), +})); + +const PROJECT_PATH = '/tmp/workbench-card-name'; +/** + * 生成落盘名的真实形状:`assets/canvas-generated/{毫秒}-{assetName}.{ext}`。 + * 卡面名称必须消费这条正式链路的结果,而不是另造一份显示名。 + */ +const GENERATED_IMAGE_NAME = + '1758000000000-夜行侠主角三视图与战斗姿态设定稿.png'; +const GENERATED_IMAGE_PATH = `assets/canvas-generated/${GENERATED_IMAGE_NAME}`; +const DOCUMENT_BODY = '# 玩法摘要\n\n这段正文只应出现在独立详情浮层里。'; + +function createCardNameManifest(): GameCreationAppManifest { + const manifest = createGameCreationAppManifest( + 'workbench-card-name', + '卡片名称测试', + ); + manifest.assets = [ + { + id: 'asset-hero', + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/hero.png', + source: { kind: 'generated' }, + }, + { + id: 'asset-scene', + kind: 'scene', + mediaType: 'image/png', + localPath: GENERATED_IMAGE_PATH, + source: { kind: 'generated' }, + }, + { + id: 'asset-notes', + kind: 'design-document', + mediaType: 'text/markdown', + localPath: 'memory/设计文档.md', + source: { kind: 'generated' }, + }, + { + id: 'asset-code', + kind: 'code', + mediaType: 'text/html', + localPath: 'game/index.html', + source: { kind: 'generated' }, + }, + { + id: 'asset-bgm', + kind: 'background-music', + mediaType: 'audio/mpeg', + localPath: 'assets/theme.mp3', + source: { kind: 'generated' }, + }, + ]; + manifest.versions = [ + { + versionId: 'version-initial', + parentVersionId: null, + projectRevision: 1, + resourceBindings: [], + createdReason: 'initial', + createdAt: 1, + }, + ]; + return manifest; +} + +function installInvoke( + implementation: ( + command: string, + args?: Record, + ) => Promise, +) { + const invoke = vi.fn(implementation); + ( + window as unknown as { + __TAURI__?: { core?: { invoke?: typeof invoke } }; + } + ).__TAURI__ = { core: { invoke } }; + return invoke; +} + +function installManifestInvoke(projectId: string) { + return installInvoke(async (command, args) => { + if (command === 'read_local_project_resource_graph') { + const ids = (args?.resources as Array<{ resourceId: string }>).map( + (item) => item.resourceId, + ); + return { + resourceIds: ids, + referenceEdges: [], + taskFlows: [], + producerAssignments: [], + dependencyDepths: ids.map((resourceId) => ({ + resourceId, + dependencyDepth: 0, + })), + connectionIndex: ids.map((resourceId) => ({ + resourceId, + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: [], + referenceEdgeIds: [], + taskFlowIds: [], + })), + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; + } + if ( + command === 'read_local_project_resource_canvas_layout' || + command === 'update_local_project_resource_canvas_layout' + ) { + const layout = { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: args?.mode, + revision: Number(args?.expectedRevision ?? 0) + 1, + positions: args?.positions ?? [], + updatedAt: 1, + }; + return command.startsWith('update_') + ? { status: 'updated', layout } + : layout; + } + if (command === 'read_local_project_image_preview') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'image/png', + byteLen: 12, + dataUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB', + }; + } + if (command === 'read_local_project_text_preview') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'text/markdown', + byteLen: DOCUMENT_BODY.length, + content: DOCUMENT_BODY, + }; + } + if (command === 'read_local_project_media_preview') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'audio/mpeg', + byteLen: 32, + dataUrl: 'data:audio/mpeg;base64,SUQz', + }; + } + if ( + command === 'list_pending_local_project_resource_edits' || + command === 'list_local_project_asset_generations' + ) { + return []; + } + throw new Error(`unexpected command: ${command}`); + }); +} + +/** 切栏目:左侧大纲导航已删除,一律走「资源总览」的栏目缩略卡片。 */ +async function openResourceBookCategory(label: string) { + if (document.querySelector('[data-resource-book-view="child"]')) { + fireEvent.click(await screen.findByRole('button', { name: '收起资源' })); + await waitFor(() => + expect( + document.querySelector('[data-resource-book-view="main"]'), + ).not.toBeNull(), + ); + } + fireEvent.click(await screen.findByRole('button', { name: `打开${label}` })); + await waitFor(() => + expect( + document.querySelector('[data-resource-book-view="child"]'), + ).not.toBeNull(), + ); +} + +/** + * 卡面名称断言:名称条文本与稳定 DOM 判据是同一个正式资源名,**完整名挂在整卡选中按钮的 + * `title` 上** —— 名称条 `pointer-events: none`,只有这颗覆盖整卡的按钮命中指针, + * 「悬停读全名」必须落在它身上才真的会出 tooltip。 + */ +async function expectCardName(name: string) { + const selectButton = await findResourceSelectButton(name); + const card = selectButton.closest('.game-resource-card')!; + const nameNode = card.querySelector('.game-resource-card-name'); + expect(nameNode?.textContent).toBe(name); + expect(nameNode?.getAttribute('data-resource-name')).toBe(name); + // 名称条不挂 `title`:它悬停不到,挂上去就是一条永远不触发的死提示。 + expect(nameNode?.getAttribute('title')).toBeNull(); + expect(selectButton.getAttribute('title')).toBe(name); + return card; +} + +function renderWorkbench(manifest: GameCreationAppManifest) { + return render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: PROJECT_PATH, + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); +} + +function stylesSource() { + return readFileSync( + resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'), + 'utf8', + ); +} + +/** 取 CSS 源文件里某条规则的声明体。 */ +function ruleBody(styles: string, selector: string) { + const match = new RegExp(`${selector}\\s*\\{([^}]*)\\}`, 'su').exec(styles); + expect(match, `${selector} 规则缺失`).not.toBeNull(); + return match![1]!; +} + +afterEach(() => { + delete (window as unknown as { __TAURI__?: unknown }).__TAURI__; +}); + +describe('资源卡名称与文档卡卡面', () => { + /** + * 名称口径:所有类型卡都显示**正式资源 label**——manifest `localPath` 的文件名。 + * 生成时它是 `assetName` 的落盘名,用户重命名会改写同一份 `localPath`, + * 卡片不另取临时输入或历史任务名,也不持久化第二份显示名。 + */ + it('所有类型卡都显示正式资源名,且不把目录路径铺到卡面上', async () => { + installResizeObserverStub(); + installManifestInvoke('workbench-card-name'); + renderWorkbench(createCardNameManifest()); + + await openResourceBookCategory('角色与对象'); + const heroCard = await expectCardName('hero.png'); + expect(heroCard.textContent).not.toContain('assets/'); + expect(heroCard.textContent).not.toContain('Agent 生成'); + + await openResourceBookCategory('场景与环境'); + // 长名不被截断在数据层:这里拿到的是完整名,单行省略只由 CSS 负责, + // 用户悬停 `title` 仍能读到完整名称。 + const sceneCard = await expectCardName(GENERATED_IMAGE_NAME); + expect(sceneCard.textContent).not.toContain('assets/'); + + await openResourceBookCategory('音频'); + await expectCardName('theme.mp3'); + + await openResourceBookCategory('待归类'); + await expectCardName('设计文档.md'); + await expectCardName('index.html'); + + await openResourceBookCategory('项目版本'); + await expectCardName('版本 1'); + }); + + it('文档卡以居中图标呈现,正文只在独立详情里读', async () => { + installResizeObserverStub(); + const invoke = installManifestInvoke('workbench-card-name'); + renderWorkbench(createCardNameManifest()); + + await openResourceBookCategory('待归类'); + const documentCard = await expectCardName('设计文档.md'); + // 先证明正文**确实读到过**:读取链路没有被这次改动掐掉,卡面仍旧不铺摘要。 + await waitFor(() => + expect(documentCard.getAttribute('data-preview-status')).toBe('loaded'), + ); + expect( + invoke.mock.calls.some( + ([command, args]) => + command === 'read_local_project_text_preview' && + args?.relativePath === 'memory/设计文档.md', + ), + ).toBe(true); + expect( + documentCard.querySelector('.game-resource-card-document-visual'), + ).not.toBeNull(); + expect(documentCard.textContent).not.toContain(DOCUMENT_BODY); + expect(documentCard.textContent).not.toContain( + '这段正文只应出现在独立详情浮层里', + ); + expect( + documentCard.querySelector('.game-resource-card-document-summary'), + ).toBeNull(); + // 代码卡同样只在卡面给图标 + 类型标签,名称照常显示。 + const codeCard = await expectCardName('index.html'); + expect( + codeCard.querySelector('.game-resource-card-code-visual'), + ).not.toBeNull(); + + // 独立详情不回归:选中文档卡 → 工具条「预览」→ 正文按原文读到浮层里。 + fireEvent.click( + within(documentCard).getByRole('button', { + name: '选中资源:待归类 设计文档.md', + }), + ); + fireEvent.click(await screen.findByRole('button', { name: '预览' })); + const dialog = await screen.findByRole('dialog', { name: '文档预览' }); + await waitFor(() => + expect(dialog.textContent).toContain('这段正文只应出现在独立详情浮层里'), + ); + }); + + /** + * jsdom 不加载样式表:长名单行省略、名称条不挡点击这些声明本身在源码层钉住; + * 真机像素表现仍需人工核验。 + */ + it('卡面名称的省略与层级声明固定在样式表里', () => { + const styles = stylesSource(); + const nameRule = ruleBody(styles, '\\.game-resource-card-name'); + expect(nameRule).toMatch(/overflow:\s*hidden/u); + expect(nameRule).toMatch(/text-overflow:\s*ellipsis/u); + expect(nameRule).toMatch(/white-space:\s*nowrap/u); + expect(nameRule).toMatch(/pointer-events:\s*none/u); + // 名称条铺在整卡选中按钮之上、右下角播放钮之下,两边都不能被盖住。 + expect(nameRule).toMatch(/z-index:\s*2/u); + expect( + ruleBody( + styles, + "\\.game-resource-card\\[data-preview-kind='audio'\\] \\.game-resource-card-name", + ), + ).toMatch(/padding-right:\s*50px/u); + // 旧的卡面正文摘要样式必须随正文一起消失,不留墓碑规则。 + expect(styles).not.toContain('.game-resource-card-document-summary'); + // 文档卡与代码卡共用同一套居中排布。 + const documentVisualRule = ruleBody( + styles, + '\\.game-resource-card-code-visual,\\s*\\.game-resource-card-document-visual', + ); + expect(documentVisualRule).toMatch(/place-items:\s*center/u); + expect(documentVisualRule).toMatch(/align-content:\s*center/u); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx index f3a58a319..98ee615bc 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasFloatingDismiss.test.tsx @@ -14,6 +14,7 @@ import { canDismissResourceCanvasQuickEdit, isResourceCanvasHostOverlayOpen, isResourceCanvasInteractionTarget, + isResourceCanvasPanTarget, isResourceCanvasWheelOverlayTarget, resolveResourceCanvasFloatingPanelDismissOpen, resolveResourceCanvasFloatingPanelOpen, @@ -41,6 +42,40 @@ const RESOURCE_FOCUS_SOURCE_LAYER: CanvasLayer = { }; describe('resourceCanvasFocusModel', () => { + test('右键抓手允许卡面和选中按钮,不抢媒体控件、编辑器及浮层', () => { + document.body.innerHTML = ` +
+
+ + + 信息 + +
+
+
筛选
+
操作
+
+ `; + for (const id of ['blank', 'card', 'select']) { + expect(isResourceCanvasPanTarget(document.getElementById(id))).toBe(true); + } + for (const id of [ + 'play', + 'corner', + 'video', + 'input', + 'editor', + 'filter', + 'toolbar', + 'title', + ]) { + expect(isResourceCanvasPanTarget(document.getElementById(id))).toBe( + false, + ); + } + expect(isResourceCanvasPanTarget(null)).toBe(false); + }); + test('点在资源卡、交互控件与画布浮层里时不清画布焦点', () => { document.body.innerHTML = `
diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanel.test.tsx new file mode 100644 index 000000000..64fe4b525 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanel.test.tsx @@ -0,0 +1,153 @@ +// @vitest-environment jsdom +import { + cleanup, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; +import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; +import { typeGenerationPrompt } from './resourceGenerationPromptTestUtils'; + +afterEach(cleanup); + +const imageAction: ResourceCanvasAssetToolAction = { + id: 'generate-image', + route: 'asset', + label: '生成图片', + assetKind: 'image', + audioKind: null, + assetName: 'AI 生成图片', + promptPlaceholder: '今天想生成什么画面?', + adjustableDimensions: true, + aspectRatio: '1:1', + imageSize: '1K', + requiresIconSpecReference: false, + writesIconSpecReference: false, +}; + +function asset( + id: string, + label: string, + mediaType = 'image/png', +): GameCreationAppAssetManifestEntry { + return { + id, + kind: 'image', + mediaType, + localPath: `assets/${label}.png`, + source: { kind: 'canvas' }, + }; +} + +/** 取面板里的引用输入区(含 `@` 触发器按钮),把操作局限在它自己身上。 */ +function referenceInputScope(ariaLabel: string) { + const root = screen + .getByLabelText(ariaLabel) + .closest('.resource-reference-input'); + if (!root) { + throw new Error(`资源引用输入区不存在:${ariaLabel}`); + } + return within(root as HTMLElement); +} + +describe('生成浮层里的引用选择(真实组件,不做 props mock)', () => { + test('浮层不是模态,真实点击候选就能选中并随提交带走引用', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + const assets = [asset('asset-a', '素材-a'), asset('asset-b', '素材-b')]; + render( + undefined} + />, + ); + + const panel = screen.getByRole('dialog', { name: '生成图片' }); + /* + 浮层形态不是模态:没有全屏遮罩、没有 aria-modal。`ThemedModal` 的焦点陷阱正是 P1 的成因—— + 引用选择器 portal 到 body,被陷阱挡在外面时候选项点了不生效(真实浏览器复现过)。 + */ + expect(panel.getAttribute('aria-modal')).toBeNull(); + expect(document.querySelector('[aria-modal="true"]')).toBeNull(); + expect(document.querySelector('.fixed.inset-0')).toBeNull(); + + await typeGenerationPrompt(panel, '画一只猫'); + await user.click( + referenceInputScope('生成提示词').getByRole('button', { + name: '插入素材引用', + }), + ); + const picker = await screen.findByRole('dialog', { name: '选择素材' }); + await user.click(within(picker).getByRole('option', { name: /素材-a/ })); + expect(within(picker).getByText('已选择 1 个')).not.toBeNull(); + const insert = within(picker).getByRole('button', { name: '插入引用' }); + expect((insert as HTMLButtonElement).disabled).toBe(false); + await user.click(insert); + + // 选中的引用进了面板:计数可见、提交时随载荷带走。 + await waitFor(() => + expect(within(panel).getByText('参考图 1/5')).not.toBeNull(), + ); + await user.click(within(panel).getByRole('button', { name: '生成图片' })); + expect(onSubmit).toHaveBeenCalledTimes(1); + const submitted = onSubmit.mock.calls[0]?.[0] as { + prompt: string; + references: { resourceId: string }[]; + }; + expect( + submitted.references.map((reference) => reference.resourceId), + ).toEqual(['asset-a']); + expect(submitted.prompt).toContain('画一只猫'); + }); + + test('搜索能收窄候选,键盘也能完成选择', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + const assets = [asset('asset-a', '素材-a'), asset('asset-b', '素材-b')]; + render( + undefined} + />, + ); + + const panel = screen.getByRole('dialog', { name: '生成图片' }); + await typeGenerationPrompt(panel, '画一只猫'); + await user.click( + referenceInputScope('生成提示词').getByRole('button', { + name: '插入素材引用', + }), + ); + const picker = await screen.findByRole('dialog', { name: '选择素材' }); + expect(within(picker).getAllByRole('option')).toHaveLength(2); + + // 搜索收窄:只剩「素材-b」这一条候选。 + await user.clear(screen.getByLabelText('搜索全部画布素材')); + await user.type(screen.getByLabelText('搜索全部画布素材'), '素材-b'); + await waitFor(() => + expect(within(picker).getAllByRole('option')).toHaveLength(1), + ); + + // 键盘路径:Tab 进候选列表,Enter 选中(不依赖鼠标点击)。 + const option = within(picker).getByRole('option', { name: /素材-b/ }); + option.focus(); + await user.keyboard('{Enter}'); + await waitFor(() => + expect(within(picker).getByText('已选择 1 个')).not.toBeNull(), + ); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx new file mode 100644 index 000000000..5b1dd62e4 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanelChrome.test.tsx @@ -0,0 +1,146 @@ +// @vitest-environment jsdom +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { cleanup, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; +import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; +import { ResourceCanvasGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasGenerationPanelView'; + +afterEach(cleanup); + +const imageAction: ResourceCanvasAssetToolAction = { + id: 'generate-image', + route: 'asset', + label: '生成图片', + assetKind: 'image', + audioKind: null, + assetName: 'AI 生成图片', + promptPlaceholder: '今天想生成什么画面?', + adjustableDimensions: true, + aspectRatio: '1:1', + imageSize: '1K', + requiresIconSpecReference: false, + writesIconSpecReference: false, +}; + +describe('生成浮层的面板外观与高度合同', () => { + test('浮层复用模态同一套面板 chrome 类,不另造一套外观', () => { + render( + undefined} + onClose={() => undefined} + />, + ); + + const panel = screen.getByRole('dialog', { name: '生成图片' }); + // 少了 `game-approval-dialog`,浮层就会变成没有背景/边框、header 挤在一起的一块裸容器。 + expect(panel.classList.contains('game-approval-dialog')).toBe(true); + expect(panel.classList.contains('game-resource-generation-dialog')).toBe( + true, + ); + expect( + panel.classList.contains('resource-canvas-generation-floating-panel'), + ).toBe(true); + // 位置与高度上界都由宿主按真实矩形给:内联样式必须原样落到面板上。 + expect(panel.style.top).toBe('346px'); + expect(panel.style.left).toBe('120px'); + expect(panel.style.maxHeight).toBe('348px'); + }); + + test('音频入口的浮层同样带 chrome 类,并复用宿主给的提交身份重试', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(async () => undefined); + render( + undefined} + />, + ); + + const panel = screen.getByRole('dialog', { name: '生成背景音乐' }); + expect(panel.classList.contains('game-approval-dialog')).toBe(true); + expect( + panel.classList.contains('resource-canvas-generation-floating-panel'), + ).toBe(true); + // 收起时保存的草稿要灌回来:不是空表单。 + expect( + within(panel).getByLabelText('生成提示词').getAttribute('value'), + ).toBeNull(); + + await user.click( + within(panel).getByRole('button', { name: '生成背景音乐' }), + ); + // 幂等重试:命中宿主记下的同一对 operationId / 幂等键,不铸造新的付费生成。 + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ + kind: 'background-music', + operationId: 'operation-bound', + idempotencyKey: 'key-bound', + prompt: '轻快的八音盒', + }); + }); +}); + +/** + * 结构 CSS 钉子:真实浏览器 1280x720 上浮层曾经「面板垂到底栏下面、提交按钮点不到」, + * 而且第一版浮层只有 `game-resource-generation-dialog` 一个类,连背景边框都没有。 + * 这些是几何模型测不到的部分,所以直接钉住样式文件里的关键声明。 + */ +describe('生成浮层样式结构', () => { + const panelCss = () => + readFileSync( + resolve( + process.cwd(), + 'apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasGenerationPanel.css', + ), + 'utf8', + ); + + test('浮层选择器带上面板 chrome 类,并限制自身滚动与兜底上界', () => { + const css = panelCss(); + expect(css).toContain( + '.game-approval-dialog.resource-canvas-generation-floating-panel {', + ); + // 锚点给的是占位卡中心:少了这行浮层会整体右偏半个面板宽(浏览器实测 x287 vs 卡中心 286)。 + expect(css).toContain('transform: translateX(-50%);'); + expect(css).toContain('overflow-y: auto;'); + expect(css).toContain('overscroll-behavior: contain;'); + // 兜底上界必须在:内联 maxHeight 拿不到几何时也不能整块垂出画布。 + expect(css).toMatch( + /\.game-approval-dialog\.resource-canvas-generation-floating-panel \{[\s\S]*?max-height:/u, + ); + }); + + test('动作行固定在面板底部,滚动内容不会把提交按钮顶出可视区', () => { + const css = panelCss(); + expect(css).toMatch( + /\.game-approval-dialog\.resource-canvas-generation-floating-panel[\s\S]*?\.game-resource-generation-actions \{[\s\S]*?position: sticky;/u, + ); + expect(css).toMatch( + /\.game-resource-generation-actions \{[\s\S]*?bottom: 0;[\s\S]*?background:/u, + ); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationHostLifecycle.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationHostLifecycle.test.tsx new file mode 100644 index 000000000..74586b0ed --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationHostLifecycle.test.tsx @@ -0,0 +1,860 @@ +/** @vitest-environment jsdom */ + +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import type { + GameCreationAppAssetManifestEntry, + GameCreationAppManifest, + ProjectResourceCanvasPosition, +} from '../../../packages/shared/src/contracts/gameCreationApp'; +import ProjectDevelopmentView from '../src/view/project-development'; +import { + act, + cleanup, + createGameCreationAppManifest, + fireEvent, + React, + render, + screen, + waitFor, + within, +} from './appSurface/harness'; +import { typeGenerationPrompt } from './resourceGenerationPromptTestUtils'; + +/** + * 画布生成入口的**宿主生命周期**验收(真实 `ProjectDevelopmentView`,不做 props mock)。 + * + * 覆盖三件事——它们都在宿主里(占位、提交身份、落点 effect),单测模型或组件看不到: + * 1. 音频 / 背景音乐入口:点工具立刻出占位,提交后那张占位进入 `submitted`; + * 2. 失败后用同一份请求重试:复用**同一个 operationId / 幂等键**,不会变成新的付费生成; + * 3. 成功后结果卡落到占位**最新位置**、占位被撤掉(结果接管它的位置)。 + * + * 原生命令全部走本文件的假实现,不触发任何真实 Provider 调用。 + */ + +const PROJECT_ID = 'generation-host-project'; +const PROJECT_PATH = '/tmp/generation-host-project'; +const NEW_ASSET_ID = 'asset-bgm-1'; +const NEW_RESOURCE_ID = `asset:${NEW_ASSET_ID}`; + +type AssetFixture = GameCreationAppAssetManifestEntry; +type LayoutWrite = { + projectPath: string; + mode: string; + positions: ProjectResourceCanvasPosition[]; +}; + +function imageAsset(id: string, fileName: string): AssetFixture { + return { + id, + kind: 'character', + category: 'character', + mediaType: 'image/png', + localPath: `assets/${fileName}`, + source: { kind: 'generated', resourceId: `${id}-resource` }, + }; +} + +function bgmAsset(id: string): AssetFixture { + return { + id, + kind: 'background-music', + category: 'audio', + mediaType: 'audio/mpeg', + localPath: 'assets/bgm.mp3', + source: { kind: 'generated', resourceId: `${id}-resource` }, + }; +} + +function seedBgmAsset(id: string): AssetFixture { + return { ...bgmAsset(id), localPath: `assets/${id}.mp3` }; +} + +function manifestFor( + projectId: string, + assets: AssetFixture[], +): GameCreationAppManifest { + return { + ...createGameCreationAppManifest(projectId, `${projectId} 项目`), + assets: assets.map((asset) => structuredClone(asset)), + }; +} + +/** + * 只铺这条链路真正用到的本地命令;未知命令返回 `undefined` 并记账(不抛错), + * 免得画布里别的入口多调一个命令就把这组用例整体带红。 + */ +function installHostTauri(options: { + assets: AssetFixture[]; + deriveResults: Array< + | { ok: true; assetId: string; hold?: boolean } + | { ok: false; message: string } + >; + /** + * 图片类入口(`start_local_project_asset_generation`)的账本收口方式。 + * + * 不传时这条命令不被这些用例触发(音频入口走 `derive_local_project_resource`); + * `'failed'` 让队列在第一次轮询就看到一条失败记录,用来观察失败态重试。 + */ + assetGenerationRecord?: 'failed'; +}) { + const layoutWrites: LayoutWrite[] = []; + const deriveCalls: Array> = []; + const assetGenerationStarts: Array> = []; + const unexpectedCommands: string[] = []; + let revision = 0; + let releaseHeldDerive: (() => void) | null = null; + const positions: ProjectResourceCanvasPosition[] = []; + + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_local_game_project_revision') { + return { revision: 1 }; + } + if (command === 'read_local_project_resource_graph') { + return { + nodes: [], + taskFlowIds: [], + producerAssignments: [], + dependencyDepths: options.assets.map((asset) => ({ + resourceId: `asset:${asset.id}`, + dependencyDepth: 0, + })), + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: PROJECT_ID, + mode: String(args?.mode ?? ''), + revision, + positions: structuredClone(positions), + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + const next = structuredClone( + (args?.positions ?? []) as ProjectResourceCanvasPosition[], + ); + positions.length = 0; + positions.push(...next); + revision += 1; + layoutWrites.push({ + projectPath: String(args?.projectPath ?? ''), + mode: String(args?.mode ?? ''), + positions: next, + }); + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: PROJECT_ID, + mode: String(args?.mode ?? ''), + revision, + positions: next, + updatedAt: revision, + }, + }; + } + if (command === 'derive_local_project_resource') { + const input = args?.input as Record; + deriveCalls.push(input); + const planned = options.deriveResults.shift(); + if (!planned || !planned.ok) { + throw new Error(planned?.message ?? '测试:没有预置生成结果'); + } + // `hold`:把这次生成停在后台运行中,用来观察占位的 `submitted` 中间态。 + if (planned.hold) { + await new Promise((resolve) => { + releaseHeldDerive = resolve; + }); + } + const asset = bgmAsset(planned.assetId); + return { + asset, + manifest: manifestFor(PROJECT_ID, [...options.assets, asset]), + committedProjectRevision: 2, + operationId: input.operationId, + }; + } + if (command === 'list_pending_local_project_resource_edits') { + return []; + } + if (command === 'start_local_project_asset_generation') { + const startArgs = args ?? {}; + assetGenerationStarts.push(startArgs); + return { + taskId: String(startArgs.taskId ?? ''), + projectId: PROJECT_ID, + kind: String(startArgs.kind ?? ''), + assetName: String(startArgs.assetName ?? ''), + referenceAssetIds: Array.isArray(startArgs.referenceAssetIds) + ? startArgs.referenceAssetIds + : [], + status: 'queued', + phaseDetail: '已受理', + startedAtMillis: 1, + finishedAtMillis: null, + assetId: null, + error: null, + }; + } + if (command === 'list_local_project_asset_generations') { + return assetGenerationStarts.map((started) => ({ + ...started, + status: options.assetGenerationRecord ?? 'queued', + phaseDetail: + options.assetGenerationRecord === 'failed' ? '生成失败' : '已受理', + finishedAtMillis: + options.assetGenerationRecord === 'failed' ? 2 : null, + error: + options.assetGenerationRecord === 'failed' + ? '测试:生成失败' + : null, + })); + } + if (command === 'read_local_project_image_preview') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'image/png', + byteLen: 1, + dataUrl: 'data:image/png;base64,AA==', + }; + } + if (command === 'read_local_project_text_preview') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'text/markdown', + byteLen: 2, + content: '#', + }; + } + unexpectedCommands.push(command); + return undefined; + }, + ); + + window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + return { + invoke, + layoutWrites, + deriveCalls, + assetGenerationStarts, + unexpectedCommands, + releaseHeldDerive: () => releaseHeldDerive?.(), + }; +} + +function HostWorkbench({ assets }: { assets: AssetFixture[] }) { + const [manifest, setManifest] = React.useState(() => + manifestFor(PROJECT_ID, assets), + ); + return ( + Supervisor
} + onHomeOpen={() => undefined} + onProjectsOpen={() => undefined} + onManifestChange={(_path, nextManifest) => setManifest(nextManifest)} + /> + ); +} + +/** + * 带「从清单里删掉某个素材」按钮的宿主:失效参考的判据必须在**素材真的不在清单里**之后成立, + * 所以用例要能在渲染过程中推进一次 manifest(与用户在素材管理里删除同一条路径)。 + */ +function HostWorkbenchWithReferenceRemoval({ + assets, + referenceAssetId, +}: { + assets: AssetFixture[]; + referenceAssetId: string; +}) { + const [manifest, setManifest] = React.useState(() => + manifestFor(PROJECT_ID, assets), + ); + return ( + <> + + + Supervisor
} + onHomeOpen={() => undefined} + onProjectsOpen={() => undefined} + onManifestChange={(_path, nextManifest) => setManifest(nextManifest)} + /> + + ); +} + +async function settle() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +function placeholderElement(draftId: string) { + return document.querySelector( + `[data-resource-canvas-generation-placeholder="${draftId}"]`, + ); +} + +function allPlaceholders() { + return Array.from( + document.querySelectorAll( + '[data-resource-canvas-generation-placeholder]', + ), + ); +} + +/** + * 音频入口只在「音频」栏目页出现(`RESOURCE_CANVAS_BOTTOM_TOOLS_BY_CATEGORY.audio`), + * 所以先打开音频栏目、再点「生成背景音乐」:占位立刻出现在该栏目里。 + */ +async function openBgmEntry() { + const opener = screen + .getAllByRole('button') + .find((button) => /^打开音频/.test(button.textContent?.trim() ?? '')); + if (!opener) { + throw new Error( + `没找到音频栏目入口,现有入口:${screen + .getAllByRole('button') + .map((button) => button.textContent?.trim()) + .filter((text) => text?.startsWith('打开')) + .join(' / ')}`, + ); + } + fireEvent.click(opener); + await settle(); + // 切到「按类型」:落点 effect 走的是**当前排序模式**那一侧的布局 hook, + // 类型侧的 sidecar 读完之后才 ready(依赖侧还要等关系图就绪)。 + fireEvent.click(screen.getByRole('button', { name: '按类型' })); + await settle(); + fireEvent.click(screen.getByRole('button', { name: '生成背景音乐' })); + await settle(); + const placeholder = allPlaceholders()[0]; + if (!placeholder) { + throw new Error('点音频入口后没有出现占位卡'); + } + const draftId = placeholder.dataset.resourceCanvasGenerationPlaceholder ?? ''; + const prompt = document.querySelector( + 'textarea', + ) as HTMLTextAreaElement; + if (prompt) { + fireEvent.change(prompt, { target: { value: '一段平静的夜晚钢琴曲' } }); + } + return { draftId, placeholder }; +} + +/** + * 占位卡在画布世界坐标里的落点。宿主不是把它写进 DOM 数据集,而是由外层包装的 + * transform 决定位置——按 ManualLayout 用例的同一口径从 style 里取数。 + */ +function placeholderWorldPoint(element: HTMLElement) { + const styled = element.closest('[style*="translate"]'); + const source = styled?.style.transform ?? ''; + const [x, y] = Array.from(source.matchAll(/-?\d+(?:\.\d+)?/g), (match) => + Number(match[0]), + ); + return { x, y }; +} + +function cardElement(resourceId: string) { + return document.querySelector( + `.game-resource-card[data-resource-card-id="${resourceId}"]`, + ); +} + +/** + * 占位的**局部落点**由占位模型给定:这一栏已有素材(种子卡在原点)时,占位排到它下方 + * 一行(`placeResourceCanvasGenerationPlaceholder`:x 贴左边、y = 最高下沿 + 间距)。 + * + * 尺寸从种子卡画出来的盒子里读,避免在用例里再抄一份卡片尺寸常量。 + */ +function placeholderLocalPoint(placeholder: HTMLElement) { + const seed = cardElement('asset:seed-bgm'); + if (!seed) { + throw new Error('没找到音频栏目里的种子卡'); + } + const wrapper = seed.closest('[style*="translate"]'); + const style = wrapper?.getAttribute('style') ?? ''; + const height = Number( + style.match(/height:\s*(-?\d+(?:\.\d+)?)px/)?.[1] ?? Number.NaN, + ); + if (!Number.isFinite(height)) { + throw new Error(`种子卡没有可读的高度:${style}`); + } + expect(placeholder).not.toBeNull(); + return { x: 0, y: height + 16 }; +} + +/** + * 面板里的提交按钮:它与底部工具栏的工具按钮**同名**(都是「生成背景音乐」), + * 所以要排掉工具栏那一支,否则点的是工具本身(再点一次入口,不会提交)。 + */ +function panelSubmitButton(label: string) { + const submit = screen + .getAllByRole('button', { name: label }) + .find((button) => !button.closest('.game-resource-bottom-toolbar')); + if (!submit) { + throw new Error(`没找到面板里的提交按钮「${label}」`); + } + return submit; +} + +/** 当前挂着的生成浮层(音频与图片类共用同一个数据属性)。 */ +function floatingPanel() { + return document.querySelector( + '[data-resource-canvas-generation-floating-panel]', + ); +} + +function floatingPanelPrompt() { + return ( + floatingPanel()?.querySelector('textarea') ?? null + ); +} + +/** 浮层里的提交按钮:不按文案挑,音频失败态会换成「使用原请求重试」。 */ +function floatingPanelSubmit() { + const submit = floatingPanel()?.querySelector( + 'button[type="submit"]', + ); + if (!submit) { + throw new Error('没找到浮层里的提交按钮'); + } + return submit; +} + +/** 浮层里存在某文案的按钮吗(工具栏上的同名按钮要排掉)。 */ +function floatingPanelHasButton(label: string) { + const panel = floatingPanel(); + if (!panel) { + return false; + } + return Array.from(panel.querySelectorAll('button')).some( + (button) => button.textContent?.trim() === label, + ); +} + +/** 底部工具栏上的工具按钮(与浮层里的同名提交按钮区分开)。 */ +function toolbarToolButton(label: string) { + const button = screen + .getAllByRole('button', { name: label }) + .find((candidate) => candidate.closest('.game-resource-bottom-toolbar')); + if (!button) { + throw new Error(`没找到工具栏上的工具「${label}」`); + } + return button; +} + +/** 打开某个资源栏目:点总览卡入口、切到「按类型」,布局 hook 就绪后工具栏才出现。 */ +async function openResourceCategory(entryLabel: string) { + const opener = screen + .getAllByRole('button') + .find( + (button) => + /^打开/.test(button.textContent?.trim() ?? '') && + button.textContent?.includes(entryLabel), + ); + if (!opener) { + throw new Error(`没找到栏目入口「${entryLabel}」`); + } + fireEvent.click(opener); + await settle(); + fireEvent.click(screen.getByRole('button', { name: '按类型' })); + await settle(); +} + +/** 点击音频栏目的某条工具:占位与浮层都由这一个动作产生。 */ +async function openAudioTool(toolLabel: string) { + fireEvent.click(toolbarToolButton(toolLabel)); + await settle(); +} + +afterEach(() => { + cleanup(); + delete (window as unknown as { __TAURI__?: unknown }).__TAURI__; + vi.restoreAllMocks(); +}); + +describe('画布生成入口的宿主生命周期', () => { + test('音频入口:提交后占位进入 submitted,成功后结果落到占位最新位置并撤掉占位', async () => { + const tauri = installHostTauri({ + assets: [seedBgmAsset('seed-bgm')], + deriveResults: [{ ok: true, assetId: NEW_ASSET_ID, hold: true }], + }); + render(); + await settle(); + + const { draftId, placeholder } = await openBgmEntry(); + const placeholderPoint = placeholderLocalPoint(placeholder); + // 点入口只造占位:还没提交,也没发起任何生成。 + expect(placeholder.dataset.resourceCanvasGenerationPlaceholderStatus).toBe( + 'draft', + ); + expect(tauri.deriveCalls).toEqual([]); + + fireEvent.click(panelSubmitButton('生成背景音乐')); + await settle(); + + // 提交一次:走无源生成(create)链路;后台还在跑,占位收口为 submitted。 + expect(tauri.deriveCalls).toHaveLength(1); + expect(tauri.deriveCalls[0]).toMatchObject({ + editKind: 'background-music', + generationMode: 'create', + expectedProjectId: PROJECT_ID, + }); + await waitFor(() => + expect( + placeholderElement(draftId)?.dataset + .resourceCanvasGenerationPlaceholderStatus, + ).toBe('submitted'), + ); + + // 放行后台任务:结果入库。 + await act(async () => { + tauri.releaseHeldDerive(); + await Promise.resolve(); + }); + + // 结果入库后:新卡落在占位坐标上(占位在空栏目里落在原点),占位被撤掉。 + await waitFor(() => { + expect( + tauri.layoutWrites + .filter((write) => + write.positions.some( + (position) => position.resourceId === NEW_RESOURCE_ID, + ), + ) + .map((write) => { + const landed = write.positions.find( + (position) => position.resourceId === NEW_RESOURCE_ID, + )!; + return `${write.mode}:${landed.section}@${landed.x},${landed.y}${ + landed.manuallyPlaced ? 'M' : 'A' + }`; + }) + .join(' || '), + ).toContain(`type:audio@${placeholderPoint.x},${placeholderPoint.y}M`); + }); + await waitFor(() => expect(allPlaceholders()).toHaveLength(0)); + }); + + test('失败后用同一份请求重试:复用同一个 operationId 与幂等键', async () => { + const tauri = installHostTauri({ + assets: [seedBgmAsset('seed-bgm')], + deriveResults: [ + { ok: false, message: 'result-unknown: 测试网络中断' }, + { ok: true, assetId: NEW_ASSET_ID }, + ], + }); + render(); + await settle(); + + const { draftId } = await openBgmEntry(); + fireEvent.click(panelSubmitButton('生成背景音乐')); + await settle(); + expect(tauri.deriveCalls).toHaveLength(1); + // 失败:占位留着(输入与操作身份都还在),状态收口为 failed。 + await waitFor(() => + expect( + placeholderElement(draftId)?.dataset + .resourceCanvasGenerationPlaceholderStatus, + ).toBe('failed'), + ); + + // 面板上的原请求重试:同一次生成,不该变成第二次付费请求。 + const retry = screen + .getAllByRole('button') + .find((button) => button.textContent?.includes('重试')); + expect(retry).toBeDefined(); + fireEvent.click(retry!); + await settle(); + + await waitFor(() => expect(tauri.deriveCalls).toHaveLength(2)); + expect(tauri.deriveCalls[1]).toMatchObject({ + operationId: tauri.deriveCalls[0]!.operationId, + idempotencyKey: tauri.deriveCalls[0]!.idempotencyKey, + prompt: tauri.deriveCalls[0]!.prompt, + }); + }); +}); + +/** + * 浮层**按占位隔离**的宿主回归。 + * + * 浮层是非模态的:面板开着时用户仍能点另一条工具、另一张占位卡。这组用例钉住三件在宿主里才能 + * 看到的事: + * 1. 换到另一张占位必须换一份面板状态(类型、输入、失败原因都不跟着走),而**未提交草稿必须留下**; + * 2. 失败请求的操作身份只属于它自己那张占位:在别的占位上提交是一次新请求,回到原占位才是重试; + * 3. 参考素材被删掉后重试:失效参考必须显式呈现并挡住提交,不许静默丢参考、也不许再发一次请求。 + */ +describe('生成浮层按占位隔离(宿主回归)', () => { + test('同类不同草稿:切到另一条音频工具是新面板,切回来原草稿还在', async () => { + const tauri = installHostTauri({ + assets: [seedBgmAsset('seed-bgm')], + deriveResults: [], + }); + render(); + await settle(); + + const { draftId } = await openBgmEntry(); + expect(floatingPanelHasButton('生成背景音乐')).toBe(true); + expect(floatingPanelPrompt()?.value ?? '').toBe('一段平静的夜晚钢琴曲'); + + // 面板开着直接点另一条音频工具:这是另一张占位,必须是另一份面板状态。 + await openAudioTool('生成音效'); + expect(floatingPanelHasButton('生成音效')).toBe(true); + expect(floatingPanelHasButton('生成背景音乐')).toBe(false); + expect(floatingPanelPrompt()?.value ?? '').toBe(''); + // 全程只点入口与输入,没有任何生成请求被发起。 + expect(tauri.deriveCalls).toEqual([]); + + // 切回第一张占位:未提交草稿不能因为面板卸载而丢。 + fireEvent.click(placeholderElement(draftId)!); + await settle(); + expect(floatingPanelHasButton('生成背景音乐')).toBe(true); + expect(floatingPanelPrompt()?.value ?? '').toBe('一段平静的夜晚钢琴曲'); + }); + + test('失败态切换:失败原因与操作身份都不跟着换占位', async () => { + const tauri = installHostTauri({ + assets: [seedBgmAsset('seed-bgm')], + deriveResults: [ + { ok: false, message: 'result-unknown: 测试网络中断' }, + { ok: true, assetId: NEW_ASSET_ID }, + ], + }); + render(); + await settle(); + + const { draftId } = await openBgmEntry(); + fireEvent.click(floatingPanelSubmit()); + await settle(); + expect(tauri.deriveCalls).toHaveLength(1); + await waitFor(() => + expect(floatingPanel()?.textContent ?? '').toContain('测试网络中断'), + ); + + // 切到音效:上一条的失败原因与「输入已锁定」都不得跟着它走。 + await openAudioTool('生成音效'); + expect(floatingPanel()?.textContent ?? '').not.toContain('测试网络中断'); + expect(floatingPanelPrompt()?.disabled).toBe(false); + expect(floatingPanelPrompt()?.value ?? '').toBe(''); + + // 在音效面板提交:这是**新的**请求,不能复用背景音乐那条失败请求的操作身份。 + await typeGenerationPrompt(floatingPanel()!, '一段清脆的铃声'); + fireEvent.click(floatingPanelSubmit()); + await settle(); + expect(tauri.deriveCalls).toHaveLength(2); + expect(tauri.deriveCalls[1]!.operationId).not.toBe( + tauri.deriveCalls[0]!.operationId, + ); + expect(tauri.deriveCalls[1]).toMatchObject({ editKind: 'sound-effect' }); + + // 回到原占位重试:必须复用原请求的操作身份与幂等键(同一 operation 账本)。 + fireEvent.click(placeholderElement(draftId)!); + await settle(); + fireEvent.click(floatingPanelSubmit()); + await settle(); + expect(tauri.deriveCalls).toHaveLength(3); + expect(tauri.deriveCalls[2]).toMatchObject({ + operationId: tauri.deriveCalls[0]!.operationId, + idempotencyKey: tauri.deriveCalls[0]!.idempotencyKey, + prompt: tauri.deriveCalls[0]!.prompt, + }); + }); + + test('失效参考:保留原 ID 并挡住重试,不静默丢参考也不发新请求', async () => { + const user = userEvent.setup(); + const tauri = installHostTauri({ + assets: [imageAsset('ref-img', 'ref.png')], + deriveResults: [], + assetGenerationRecord: 'failed', + }); + render( + , + ); + await settle(); + + await openResourceCategory('角色'); + fireEvent.click(toolbarToolButton('生成图片')); + await settle(); + const draftId = + allPlaceholders()[0]?.dataset.resourceCanvasGenerationPlaceholder ?? ''; + expect(draftId).not.toBe(''); + + // 真选一张参考图:候选来自当前清单。 + await typeGenerationPrompt(floatingPanel()!, '画一只猫'); + const panel = floatingPanel()!; + await user.click( + within(panel).getByRole('button', { name: '插入素材引用' }), + ); + const picker = await screen.findByRole('dialog', { name: '选择素材' }); + await user.click(within(picker).getByRole('option', { name: /ref/ })); + await user.click(within(picker).getByRole('button', { name: '插入引用' })); + await waitFor(() => + expect(within(floatingPanel()!).getByText('参考图 1/5')).not.toBeNull(), + ); + + fireEvent.click(floatingPanelSubmit()); + await settle(); + expect(tauri.assetGenerationStarts).toHaveLength(1); + expect(tauri.assetGenerationStarts[0]!.referenceAssetIds).toEqual([ + 'ref-img', + ]); + await waitFor(() => + expect( + placeholderElement(draftId)?.dataset + .resourceCanvasGenerationPlaceholderStatus, + ).toBe('failed'), + ); + + // 参考素材被删掉之后重试:失效参考必须留在草稿里并被显式报出来。 + fireEvent.click(screen.getByRole('button', { name: '删除参考素材' })); + await settle(); + fireEvent.click(placeholderElement(draftId)!); + await settle(); + + const problem = floatingPanel()?.querySelector( + '[data-resource-canvas-generation-reference-problem]', + ); + expect(problem?.textContent ?? '').toContain('已不在当前项目'); + const submit = floatingPanelSubmit(); + expect(submit.disabled).toBe(true); + fireEvent.click(submit); + await settle(); + expect(tauri.assetGenerationStarts).toHaveLength(1); + }); + + test('已提交请求的重试:删掉失效参考再提交零新增请求,恢复原参考后才允许重试', async () => { + const user = userEvent.setup(); + const tauri = installHostTauri({ + assets: [imageAsset('ref-img', 'ref.png')], + deriveResults: [], + assetGenerationRecord: 'failed', + }); + render( + , + ); + await settle(); + + await openResourceCategory('角色'); + fireEvent.click(toolbarToolButton('生成图片')); + await settle(); + const draftId = + allPlaceholders()[0]?.dataset.resourceCanvasGenerationPlaceholder ?? ''; + await typeGenerationPrompt(floatingPanel()!, '画一只猫'); + const panel = floatingPanel()!; + await user.click( + within(panel).getByRole('button', { name: '插入素材引用' }), + ); + const picker = await screen.findByRole('dialog', { name: '选择素材' }); + await user.click(within(picker).getByRole('option', { name: /ref/ })); + await user.click(within(picker).getByRole('button', { name: '插入引用' })); + await waitFor(() => + expect(within(floatingPanel()!).getByText('参考图 1/5')).not.toBeNull(), + ); + + fireEvent.click(floatingPanelSubmit()); + await settle(); + expect(tauri.assetGenerationStarts).toHaveLength(1); + const originalPrompt = tauri.assetGenerationStarts[0]!.prompt; + await waitFor(() => + expect( + placeholderElement(draftId)?.dataset + .resourceCanvasGenerationPlaceholderStatus, + ).toBe('failed'), + ); + + // 素材被删掉后重开重试面板:草稿里的失效参考被显式保留并挡住提交。 + fireEvent.click(screen.getByRole('button', { name: '删除参考素材' })); + await settle(); + fireEvent.click(placeholderElement(draftId)!); + await settle(); + expect( + floatingPanel()?.querySelector( + '[data-resource-canvas-generation-reference-problem]', + ), + ).not.toBeNull(); + + /* + 用户直接在正文里删掉 `@引用`:参考集合变空、失效参考的问题随之消失,但**输入已经不是 + 原请求的那份**——原生按指纹当新请求处理,也就是一次新的付费生成。这里必须挡住, + 而不是让「旧 draft 悄悄变成新收费」。 + */ + await typeGenerationPrompt(floatingPanel()!, '画一只猫'); + expect( + floatingPanel()?.querySelector( + '[data-resource-canvas-generation-bound-request-changed]', + ), + ).not.toBeNull(); + const blockedSubmit = floatingPanelSubmit(); + expect(blockedSubmit.disabled).toBe(true); + fireEvent.click(blockedSubmit); + // 绕过按钮禁用直接提交表单也一样:处理函数自己再挡一次。 + fireEvent.submit(floatingPanel()!.querySelector('form')!); + await settle(); + expect(tauri.assetGenerationStarts).toHaveLength(1); + + // 恢复原参考(同一 id)+ 重开面板拿回冻结草稿:这才是原请求的重试。 + fireEvent.click(screen.getByRole('button', { name: '恢复参考素材' })); + await settle(); + fireEvent.click(screen.getByRole('button', { name: '取消' })); + await settle(); + fireEvent.click(placeholderElement(draftId)!); + await settle(); + const retrySubmit = floatingPanelSubmit(); + expect(retrySubmit.disabled).toBe(false); + fireEvent.click(retrySubmit); + await settle(); + expect(tauri.assetGenerationStarts).toHaveLength(2); + expect(tauri.assetGenerationStarts[1]!.referenceAssetIds).toEqual([ + 'ref-img', + ]); + expect(tauri.assetGenerationStarts[1]!.prompt).toBe(originalPrompt); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx new file mode 100644 index 000000000..6fb839e58 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationLanding.test.tsx @@ -0,0 +1,524 @@ +// @vitest-environment jsdom +/** + * 生成落点与音频提交身份的**宿主级**整合用例。 + * + * 覆盖两条只能跨模块才校验得了的口径: + * 1. 结果落点用**正式归类后的 section**(普通图片落「待归类」)与占位**最新位置**,写完撤占位; + * 2. 音频入口失败后占位保留、面板带回草稿,重试复用同一 operationId(幂等,不重复付费)。 + * + * Tauri 只用最小假实现:未知命令返回 `undefined` 并记账,别的入口多调一个命令不该让整条链转红。 + */ +import { useState } from 'react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import type { + GameCreationAppAssetManifestEntry, + GameCreationAppManifest, + ProjectResourceCanvasPosition, +} from '../../../packages/shared/src/contracts/gameCreationApp'; +import ProjectDevelopmentView from '../src/view/project-development'; +import { + act, + cleanup, + createGameCreationAppManifest, + fireEvent, + React, + render, + screen, + waitFor, + within, +} from './appSurface/harness'; +import { typeGenerationPrompt } from './resourceGenerationPromptTestUtils'; + +afterEach(() => { + cleanup(); + delete window.__TAURI__; + vi.restoreAllMocks(); +}); + +const PROJECT_ID = 'generation-landing'; +const PROJECT_PATH = '/tmp/generation-landing'; +const GENERATED_ASSET_ID = 'asset-generated-landing'; +const GENERATED_RESOURCE_ID = `asset:${GENERATED_ASSET_ID}`; + +function pngAsset( + id: string, + fileName: string, + category: GameCreationAppAssetManifestEntry['category'] = 'character', +): GameCreationAppAssetManifestEntry { + return { + id, + kind: 'character', + category, + mediaType: 'image/png', + localPath: `assets/${fileName}`, + source: { kind: 'generated', resourceId: `${id}-resource` }, + }; +} + +/** 生成结果按**原生正式归类**落进「待归类」:入口栏目是 character,两者故意不同。 */ +function generatedAsset(): GameCreationAppAssetManifestEntry { + return { + ...pngAsset(GENERATED_ASSET_ID, 'generated.png', 'unclassified'), + kind: 'image', + }; +} + +function resourceGraphFor(resources: Array<{ resourceId: string }>) { + const resourceIds = resources.map((resource) => resource.resourceId); + return { + resourceIds, + referenceEdges: [], + taskFlows: [], + connectionIndex: resourceIds.map((resourceId) => ({ + resourceId, + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: [], + referenceEdgeIds: [], + taskFlowIds: [], + })), + producerAssignments: [], + dependencyDepths: resourceIds.map((resourceId) => ({ + resourceId, + dependencyDepth: 0, + })), + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; +} + +type Mock = { + invoke: ReturnType; + layoutWrites: ProjectResourceCanvasPosition[][]; + generationStarts: Record[]; + deriveInputs: Record[]; + unexpected: string[]; +}; + +function installTauri({ + assets, + deriveFails = false, +}: { + assets: GameCreationAppAssetManifestEntry[]; + deriveFails?: boolean; +}): Mock { + const layoutWrites: ProjectResourceCanvasPosition[][] = []; + const generationStarts: Record[] = []; + const deriveInputs: Record[] = []; + const unexpected: string[] = []; + const manifest: GameCreationAppManifest = { + ...createGameCreationAppManifest(PROJECT_ID, '生成落点项目'), + /* + 生成的素材在**收尾时的清单重读**里出现(原生是先写 manifest 再返回的终态), + 但归类是它自己的(`unclassified`),与入口栏目 `character` 不同——落点必须按前者。 + */ + assets: [...assets, generatedAsset()].map((asset) => + structuredClone(asset), + ), + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_local_game_project_revision') { + return { revision: 3 }; + } + if (command === 'get_local_game_manifest') { + return structuredClone(manifest); + } + if (command === 'read_local_project_resource_graph') { + return resourceGraphFor( + (args?.resources as Array<{ resourceId: string }> | undefined) ?? [], + ); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: PROJECT_ID, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'update_local_project_resource_canvas_layout') { + layoutWrites.push( + structuredClone( + (args?.positions ?? []) as ProjectResourceCanvasPosition[], + ), + ); + return { + status: 'updated', + layout: { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: args?.expectedProjectId, + mode: args?.mode, + revision: layoutWrites.length, + positions: args?.positions, + updatedAt: 1, + }, + }; + } + if (command === 'list_pending_local_project_resource_edits') { + return []; + } + if (command === 'list_local_project_asset_generations') { + // 账本里的终态记录:队列轮询一次就收口为成功,并给出 manifest 资源 id。 + return [ + { + taskId: String(args?.taskId ?? '') || LAST_TASK_ID.value, + projectId: PROJECT_ID, + kind: 'image', + assetName: 'AI 生成图片', + status: 'completed', + phaseDetail: '生成已完成。', + createdAtMillis: 1, + startedAtMillis: 2, + finishedAtMillis: 3, + assetId: GENERATED_ASSET_ID, + error: null, + }, + ]; + } + if (command === 'start_local_project_asset_generation') { + generationStarts.push(structuredClone(args ?? {})); + LAST_TASK_ID.value = String(args?.taskId ?? ''); + return { + taskId: String(args?.taskId ?? ''), + projectId: PROJECT_ID, + kind: String(args?.kind ?? 'image'), + assetName: String(args?.assetName ?? ''), + status: 'running', + phaseDetail: '正在生成。', + createdAtMillis: 1, + startedAtMillis: 2, + finishedAtMillis: null, + assetId: null, + error: null, + }; + } + if (command === 'derive_local_project_resource') { + const input = (args?.input ?? {}) as Record; + deriveInputs.push(structuredClone(input)); + if (deriveFails) { + throw new Error('远端拒绝'); + } + return { asset: null, manifest: structuredClone(manifest) }; + } + if (command === 'read_local_project_image_preview') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'image/png', + byteLen: 1, + dataUrl: 'data:image/png;base64,AA==', + }; + } + if (command === 'read_local_project_text_preview') { + return { + path: String(args?.relativePath ?? ''), + mediaType: 'text/markdown', + byteLen: 1, + content: '# 文档', + }; + } + unexpected.push(command); + return undefined; + }, + ); + window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + return { invoke, layoutWrites, generationStarts, deriveInputs, unexpected }; +} + +const LAST_TASK_ID = { value: '' }; + +/** 打开栏目页:左侧大纲已删,走「资源总览」的栏目缩略卡。 */ +async function openCategory(label: string) { + const categoryByLabel: Record = { + 'UI 交互': 'ui-interaction', + 角色与对象: 'character', + 音频: 'audio', + 待归类: 'unclassified', + }; + if (document.querySelector('[data-resource-book-view="child"]')) { + fireEvent.click(await screen.findByRole('button', { name: '收起资源' })); + await waitFor(() => + expect( + document.querySelector('[data-resource-book-view="main"]'), + ).not.toBeNull(), + ); + } + fireEvent.click(await screen.findByRole('button', { name: `打开${label}` })); + await waitFor(() => + expect( + document + .querySelector('[data-resource-book-view="child"]') + ?.querySelector( + `.game-resource-book-scene-titlebar.is-active[data-resource-book-category="${categoryByLabel[label]}"]`, + ), + ).not.toBeNull(), + ); +} + +function Workbench({ + assets, +}: { + assets: GameCreationAppAssetManifestEntry[]; +}) { + const [manifest, setManifest] = useState(() => ({ + ...createGameCreationAppManifest(PROJECT_ID, '生成落点项目'), + assets: assets.map((asset) => structuredClone(asset)), + })); + return ( + Supervisor
} + onHomeOpen={() => undefined} + onProjectsOpen={() => undefined} + // 收尾时宿主会重读权威清单:把它接进状态,新素材才会进资源投影(落点的前提)。 + onManifestChange={(_projectPath, nextManifest) => + setManifest(nextManifest) + } + /> + ); +} + +async function settle() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +describe('图片生成落点', () => { + test('按正式归类落 section、用占位最新位置,并撤掉占位', async () => { + const tauri = installTauri({ + assets: [pngAsset('asset-character', 'character.png')], + }); + render( + , + ); + await openCategory('角色与对象'); + + fireEvent.click(await screen.findByRole('button', { name: '生成图片' })); + const panel = await screen.findByRole('dialog', { name: '生成图片' }); + // 浮层必须自带面板 chrome:否则没有背景边框、header 也不成排。 + expect(panel.classList.contains('game-approval-dialog')).toBe(true); + expect( + panel.classList.contains('resource-canvas-generation-floating-panel'), + ).toBe(true); + // 高度上界由真实矩形算出来(jsdom 走兜底画布尺寸),不是 100dvh。 + expect(Number.parseFloat(panel.style.maxHeight)).toBeGreaterThan(0); + + const placeholder = document.querySelector( + '[data-resource-canvas-generation-placeholder]', + ); + expect(placeholder).not.toBeNull(); + const placeholderX = Number.parseFloat(placeholder!.style.left); + const placeholderY = Number.parseFloat(placeholder!.style.top); + + await typeGenerationPrompt(panel, '一只披风猫'); + fireEvent.click(within(panel).getByRole('button', { name: '生成图片' })); + + await waitFor(() => expect(tauri.generationStarts).toHaveLength(1)); + // 入口栏目随任务带给原生(可选入参),参考图为空数组。 + expect(tauri.generationStarts[0]).toMatchObject({ + kind: 'image', + targetCategory: 'character', + referenceAssetIds: [], + }); + + // 任务收口为成功 → 结果按**正式归类**(unclassified)落到占位位置,占位撤掉。 + await waitFor( + () => { + expect( + tauri.layoutWrites + .flat() + .some( + (position) => + position.resourceId === GENERATED_RESOURCE_ID && + position.manuallyPlaced === true, + ), + ).toBe(true); + }, + { timeout: 5_000 }, + ); + const landed = tauri.layoutWrites + .flat() + .filter((position) => position.resourceId === GENERATED_RESOURCE_ID) + .at(-1); + expect(landed).toMatchObject({ + section: 'unclassified', + x: placeholderX, + y: placeholderY, + manuallyPlaced: true, + }); + await waitFor(() => + expect( + document.querySelector('[data-resource-canvas-generation-placeholder]'), + ).toBeNull(), + ); + }, 20_000); +}); + +/** + * 真实矩形模拟:用 1280x720 浏览器上量到的数字(画布高 568、标题栏 48、底栏 62、占位卡 128) + * 替掉 jsdom 的空矩形,然后核对**浮层实际拿到的 maxHeight 与它相对画布的落点**—— + * 只断言 maxHeight 字符串是不够的:错的口径(window.innerHeight、当前顶边、世界坐标当屏幕坐标) + * 都能拼出一个看起来合理的字符串。 + */ +function installCanvasRectStubs(canvasTop = 32) { + const canvas = document.querySelector( + '.game-resource-page-canvas', + ); + if (!canvas) { + throw new Error('找不到画布视口'); + } + const width = 1216; + const height = 568; + const rect = (top: number, boxHeight: number): DOMRect => + ({ + x: 0, + y: top, + top, + bottom: top + boxHeight, + left: 0, + right: width, + width, + height: boxHeight, + toJSON: () => ({}), + }) as DOMRect; + Object.defineProperty(canvas, 'clientWidth', { + configurable: true, + value: width, + }); + Object.defineProperty(canvas, 'clientHeight', { + configurable: true, + value: height, + }); + canvas.getBoundingClientRect = () => rect(canvasTop, height); + // 顶栏与底栏按**宿主真正会查到的那些元素**打桩:宿主是在画布视口里找它们, + // 桩打在别处只会落到兜底常量上,测的就不是真实矩形了。 + const titlebar = document.querySelector( + '.game-resource-book-scene-titlebar.is-active', + ); + if (titlebar) { + titlebar.getBoundingClientRect = () => rect(canvasTop, 48); + } + const toolbar = document.querySelector('.game-resource-bottom-toolbar'); + if (toolbar) { + toolbar.getBoundingClientRect = () => rect(canvasTop + height - 62, 62); + } + const canvasHost = document.querySelector( + '.game-resource-canvas', + ); + if (canvasHost && canvasHost !== canvas) { + Object.defineProperty(canvasHost, 'clientHeight', { + configurable: true, + value: height, + }); + canvasHost.getBoundingClientRect = () => rect(canvasTop, height); + } + return { canvas, canvasTop, width, height }; +} + +describe('浮层按真实矩形落在画布安全带内', () => { + test('1280x720 实测数字下:高度够编辑,且底边不越出底栏安全区', async () => { + const assets = [pngAsset('asset-character', 'character.png')]; + installTauri({ assets }); + render(); + await openCategory('角色与对象'); + const { canvasTop, height } = installCanvasRectStubs(); + + fireEvent.click(await screen.findByRole('button', { name: '生成图片' })); + const panel = await screen.findByRole('dialog', { name: '生成图片' }); + await settle(); + + const maxHeight = Number.parseFloat(panel.style.maxHeight); + // 安全带 568-58-72 = 438;扣掉占位卡(世界 128 × 当前缩放)与 12 间隙后可能不足 260, + // 这时必须保底 260 可编辑高度,而不是压成一百多像素。 + // 缩放 1.5 时卡就占 192px,卡下面放不下可编辑高度 → 改为盖住占位、拿整条安全带(>260)。 + expect(maxHeight).toBeGreaterThanOrEqual(260); + + const panelTop = Number.parseFloat(panel.style.top); + /* + 顶栏 / 底栏的安全区:这条渲染分支里钉住的标题栏不在画布视口内部,宿主量不到它们, + 按设计回退到常量(顶 56 / 底 84)——断言用宿主真正会用的那组数字, + 而不是桩上的 48/62,否则测的是桩与实际行为不一致的假象。 + */ + const safeTop = 56; + const safeBottom = height - 84; + expect(Number.isFinite(panelTop)).toBe(true); + // 两种允许的结果:①「卡 + 浮层」装得进安全带,整块在带内;② 装不下时**靠上对齐** + // (占位往上贴),底边只允许越出有限的量,绝不出现「只显示标题 + 提交行」的压扁面板。 + expect(panelTop).toBeGreaterThanOrEqual(safeTop); + expect(panelTop).toBeGreaterThanOrEqual(safeTop - 1); + expect(panelTop + maxHeight).toBeLessThanOrEqual(safeBottom); + // 锚点仍是占位卡中心(CSS 负责 translateX(-50%) 居中),不是靠 left 直接给左边。 + expect(canvasTop).toBe(32); + }, 20_000); +}); + +describe('音频生成身份', () => { + test('失败保留占位与草稿,重试复用同一 operationId', async () => { + const assets = [pngAsset('asset-character', 'character.png')]; + const tauri = installTauri({ assets, deriveFails: true }); + render(); + await openCategory('音频'); + + fireEvent.click( + await screen.findByRole('button', { name: '生成背景音乐' }), + ); + const panel = await screen.findByRole('dialog', { name: '生成背景音乐' }); + await typeGenerationPrompt(panel, '轻快的八音盒'); + fireEvent.click( + within(panel).getByRole('button', { name: '生成背景音乐' }), + ); + await waitFor(() => expect(tauri.deriveInputs).toHaveLength(1)); + const firstOperationId = tauri.deriveInputs[0]?.operationId; + expect(typeof firstOperationId).toBe('string'); + + // 失败:占位收口为失败并保留(不是被删掉),面板仍在且带原因。 + await waitFor(() => + expect( + document.querySelector( + '[data-resource-canvas-generation-placeholder-status="failed"]', + ), + ).not.toBeNull(), + ); + + // 收起浮层后再点占位:草稿灌回来,重试复用同一 operationId(原生幂等,不重复付费)。 + fireEvent.click( + within(panel).getByRole('button', { name: '关闭生成背景音乐' }), + ); + await waitFor(() => + expect(screen.queryByRole('dialog', { name: '生成背景音乐' })).toBeNull(), + ); + fireEvent.click( + document.querySelector( + '[data-resource-canvas-generation-placeholder]', + )!, + ); + const reopened = await screen.findByRole('dialog', { + name: '生成背景音乐', + }); + /* + 重开的浮层带着**那张占位自己的**失败原因:按钮也就从「生成背景音乐」换成 + 「使用原请求重试」。失败原因不再随面板实例留在这儿,换到别的占位看不到它。 + */ + expect(reopened.textContent).toContain('远端拒绝'); + fireEvent.click( + within(reopened).getByRole('button', { name: '使用原请求重试' }), + ); + await waitFor(() => expect(tauri.deriveInputs).toHaveLength(2)); + expect(tauri.deriveInputs[1]?.operationId).toBe(firstOperationId); + expect(tauri.deriveInputs[1]?.idempotencyKey).toBe( + tauri.deriveInputs[0]?.idempotencyKey, + ); + }, 20_000); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationPlaceholder.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationPlaceholder.test.tsx new file mode 100644 index 000000000..0f73b84c6 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationPlaceholder.test.tsx @@ -0,0 +1,344 @@ +// @vitest-environment jsdom +import { renderHook } from '@testing-library/react'; +import { useEffect, useState } from 'react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; +import { ResourceCanvasGenerationPlaceholderCardView } from '../src/features/resource-canvas/ResourceCanvasGenerationPlaceholderCardView'; +import { + bindResourceCanvasGenerationPlaceholderTask, + failResourceCanvasGenerationPlaceholder, + moveResourceCanvasGenerationPlaceholder, + placeResourceCanvasGenerationPlaceholder, + removeResourceCanvasGenerationPlaceholder, + resolveResourceCanvasGenerationPanelStyle, + type ResourceCanvasGenerationPlaceholder, + resourceCanvasGenerationPlaceholderByDraftId, + resourceCanvasGenerationPlaceholderByTaskId, + resourceCanvasGenerationPlaceholdersForProject, + resourceCanvasGenerationPlaceholderSize, +} from '../src/features/resource-canvas/resourceCanvasGenerationPlaceholderModel'; +import { useResourceCanvasGenerationPlaceholders } from '../src/features/resource-canvas/useResourceCanvasGenerationPlaceholders'; +// 指针事件与 DOM 尺寸的 jsdom 补丁统一由 appSurface harness 提供(`PointerEvent` 等), +// 与既有画布手势用例同一套测试环境;不在这里另写一份补丁。 +import { act, cleanup, fireEvent, render, screen } from './appSurface/harness'; + +afterEach(cleanup); + +const imageAction: ResourceCanvasAssetToolAction = { + id: 'generate-image', + route: 'asset', + label: '生成图片', + assetKind: 'image', + audioKind: null, + assetName: 'AI 生成图片', + promptPlaceholder: '今天想生成什么画面?', + adjustableDimensions: true, + aspectRatio: '1:1', + imageSize: '1K', + requiresIconSpecReference: false, + writesIconSpecReference: false, +}; + +function placeholderFixture( + overrides: Partial = {}, +): ResourceCanvasGenerationPlaceholder { + const size = resourceCanvasGenerationPlaceholderSize(); + return { + draftId: 'draft-1', + projectId: 'project-1', + category: 'scene', + actionId: 'generate-image', + actionLabel: '生成图片', + assetName: 'AI 生成图片', + panel: { route: 'asset', action: imageAction }, + x: 0, + y: 0, + width: size.width, + height: size.height, + taskId: null, + status: 'draft', + error: null, + ...overrides, + }; +} + +describe('生成占位模型', () => { + test('空栏目落在原点,已有内容时排到最下面一行之下(占位之间也不重叠)', () => { + const size = resourceCanvasGenerationPlaceholderSize(); + expect(placeResourceCanvasGenerationPlaceholder({ occupied: [] })).toEqual({ + x: 0, + y: 0, + }); + const first = placeResourceCanvasGenerationPlaceholder({ + occupied: [{ x: 0, y: 0, width: size.width, height: size.height }], + }); + expect(first.x).toBe(0); + expect(first.y).toBe(size.height + 16); + const second = placeResourceCanvasGenerationPlaceholder({ + occupied: [ + { x: 0, y: 0, width: size.width, height: size.height }, + { x: 0, y: first.y, width: size.width, height: size.height }, + ], + }); + expect(second.y).toBe(first.y + size.height + 16); + }); + + test('拖动只改坐标:有限数取整、越界收到坐标域、非法值保持原位', () => { + const base = placeholderFixture({ x: 10, y: 20 }); + expect( + moveResourceCanvasGenerationPlaceholder(base, 33.6, -7.4), + ).toMatchObject({ x: 34, y: -7 }); + expect( + moveResourceCanvasGenerationPlaceholder(base, 1e9, -1e9), + ).toMatchObject({ + x: GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE, + y: -GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE, + }); + expect( + moveResourceCanvasGenerationPlaceholder(base, Number.NaN, Number.NaN), + ).toMatchObject({ x: 10, y: 20 }); + }); + + test('提交绑定任务、失败保留占位、删除只移除展示', () => { + const bound = bindResourceCanvasGenerationPlaceholderTask( + placeholderFixture(), + 'task-1', + ); + expect(bound).toMatchObject({ status: 'submitted', taskId: 'task-1' }); + const failed = failResourceCanvasGenerationPlaceholder(bound, '远端拒绝'); + expect(failed).toMatchObject({ + status: 'failed', + taskId: 'task-1', + error: '远端拒绝', + }); + // 失败不删占位:同一份输入与位置还要用来重试。 + expect( + resourceCanvasGenerationPlaceholderByTaskId([failed], 'task-1'), + ).toBe(failed); + expect( + resourceCanvasGenerationPlaceholderByDraftId([failed], 'draft-1'), + ).toBe(failed); + expect( + resourceCanvasGenerationPlaceholderByTaskId([failed], 'task-2'), + ).toBe(null); + expect( + removeResourceCanvasGenerationPlaceholder([failed], 'draft-1'), + ).toEqual([]); + }); + + test('归属按项目收口:切项目只留当前项目的占位', () => { + const placeholders = [ + placeholderFixture({ draftId: 'draft-a', projectId: 'project-1' }), + placeholderFixture({ draftId: 'draft-b', projectId: 'project-2' }), + ]; + expect( + resourceCanvasGenerationPlaceholdersForProject( + placeholders, + 'project-2', + ).map((item) => item.draftId), + ).toEqual(['draft-b']); + }); + + test('浮层锚点贴着占位下沿居中(与快速编辑同一条几何)', () => { + const style = resolveResourceCanvasGenerationPanelStyle({ + placeholder: placeholderFixture({ x: 100, y: 200 }), + viewport: { x: 0, y: 0, scale: 2 }, + canvasSize: { width: 800, height: 600 }, + }); + const size = resourceCanvasGenerationPlaceholderSize(); + expect(style?.left).toBe((100 + size.width / 2) * 2); + expect(style?.top).toBeGreaterThan((200 + size.height) * 2); + }); +}); + +describe('占位卡组件', () => { + test('呈现名称与状态,点卡开合浮层、点删除只删除自己', () => { + const onTogglePanel = vi.fn(); + const onRemove = vi.fn(); + render( + undefined} + onPointerMove={() => undefined} + onPointerUp={() => undefined} + onPointerCancel={() => undefined} + onTogglePanel={onTogglePanel} + onRemove={onRemove} + />, + ); + + const card = screen.getByRole('button', { + name: 'AI 生成图片(生成失败)', + }); + expect( + card.getAttribute('data-resource-canvas-generation-placeholder'), + ).toBe('draft-1'); + fireEvent.click(card); + expect(onTogglePanel).toHaveBeenCalledTimes(1); + + fireEvent.click( + screen.getByRole('button', { name: '删除占位 AI 生成图片' }), + ); + expect(onRemove).toHaveBeenCalledTimes(1); + // 删除按钮不能把点击透到卡片上(否则删完立刻又开一次浮层)。 + expect(onTogglePanel).toHaveBeenCalledTimes(1); + }); +}); + +describe('占位宿主 Hook', () => { + function renderPlaceholderHook(projectId = 'project-1') { + return renderHook( + ({ currentProjectId }: { currentProjectId: string }) => + useResourceCanvasGenerationPlaceholders({ + projectId: currentProjectId, + resolvePlacement: ({ placeholders: sameSection }) => ({ + x: 0, + y: sameSection.length * 100, + }), + viewportScale: () => 2, + }), + { initialProps: { currentProjectId: projectId } }, + ); + } + + test('创建占位、绑定任务与失败收口', () => { + const hook = renderPlaceholderHook(); + let draftId = ''; + act(() => { + const created = hook.result.current.createPlaceholder({ + category: 'scene', + actionId: 'generate-image', + actionLabel: '生成图片', + assetName: 'AI 生成图片', + panel: { route: 'asset', action: imageAction }, + }); + draftId = created.draftId; + }); + expect(hook.result.current.placeholders).toHaveLength(1); + expect(hook.result.current.placeholders[0]).toMatchObject({ + projectId: 'project-1', + category: 'scene', + status: 'draft', + taskId: null, + }); + + act(() => hook.result.current.bindTask(draftId, 'task-1')); + expect(hook.result.current.placeholders[0]).toMatchObject({ + status: 'submitted', + taskId: 'task-1', + }); + + act(() => hook.result.current.failTask('task-1', '远端拒绝')); + expect(hook.result.current.placeholders[0]).toMatchObject({ + status: 'failed', + error: '远端拒绝', + }); + + act(() => hook.result.current.remove(draftId)); + expect(hook.result.current.placeholders).toEqual([]); + }); + + test('切项目清掉上一份会话的占位', () => { + const hook = renderPlaceholderHook(); + act(() => { + hook.result.current.createPlaceholder({ + category: 'scene', + actionId: 'generate-image', + actionLabel: '生成图片', + assetName: 'AI 生成图片', + panel: { route: 'asset', action: imageAction }, + }); + }); + expect(hook.result.current.placeholders).toHaveLength(1); + + act(() => hook.rerender({ currentProjectId: 'project-2' })); + expect(hook.result.current.placeholders).toEqual([]); + }); + + test('拖动按视口缩放换算坐标,指针取消回到拖动前位置', () => { + function Harness() { + const [created, setCreated] = useState(false); + const placeholders = useResourceCanvasGenerationPlaceholders({ + projectId: 'project-1', + resolvePlacement: () => ({ x: 10, y: 20 }), + viewportScale: () => 2, + }); + useEffect(() => { + if (created) { + return; + } + placeholders.createPlaceholder({ + category: 'scene', + actionId: 'generate-image', + actionLabel: '生成图片', + assetName: 'AI 生成图片', + panel: { route: 'asset', action: imageAction }, + }); + setCreated(true); + }, [created, placeholders]); + const placeholder = placeholders.placeholders[0]; + if (!placeholder) { + return null; + } + return ( + undefined} + onRemove={() => undefined} + /> + ); + } + + render(); + const card = screen.getByRole('button', { + name: 'AI 生成图片(待提交)', + }); + expect(card.style.left).toBe('10px'); + expect(card.style.top).toBe('20px'); + + fireEvent.pointerDown(card, { + pointerId: 1, + button: 0, + isPrimary: true, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(card, { + pointerId: 1, + clientX: 140, + clientY: 130, + }); + // 视口缩放 2:屏幕 40 / 30 px 对应画布 20 / 15 px。 + expect(card.style.left).toBe('30px'); + expect(card.style.top).toBe('35px'); + fireEvent.pointerUp(card, { pointerId: 1, clientX: 140, clientY: 130 }); + + // 取消手势:回到拖动前坐标,不留下半截位置。 + fireEvent.pointerDown(card, { + pointerId: 2, + button: 0, + isPrimary: true, + clientX: 200, + clientY: 200, + }); + fireEvent.pointerMove(card, { + pointerId: 2, + clientX: 260, + clientY: 260, + }); + expect(card.style.left).toBe('60px'); + fireEvent.pointerCancel(card, { pointerId: 2 }); + expect(card.style.left).toBe('30px'); + expect(card.style.top).toBe('35px'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationVisibility.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationVisibility.test.ts new file mode 100644 index 000000000..d9270e2e6 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationVisibility.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, test } from 'vitest'; + +import { revealResourceCanvasGenerationContent } from '../src/features/resource-canvas/resourceCanvasGenerationVisibilityModel'; +import { resolveResourceCanvasGenerationPanelPlacement } from '../src/features/resource-canvas/resourceCanvasGenerationVisibilityModel'; + +const canvasSize = { width: 800, height: 600 }; +const insets = { top: 60, bottom: 90 }; + +describe('生成浮层与占位的可见性', () => { + test('内容已经在安全区内时坐标不变(宿主据此跳过写回)', () => { + const viewport = { x: 0, y: 0, scale: 1 }; + const next = revealResourceCanvasGenerationContent({ + viewport, + content: { x: 40, y: 80, width: 180, height: 200 }, + canvasSize, + insets, + }); + expect(next).toEqual(viewport); + }); + + test('内容落到视口下方时做最小上移,底边贴住底栏安全区', () => { + const viewport = { x: 0, y: 0, scale: 1 }; + // 占位在 y=550、加上浮层后整块落到视口外——真实浏览器上复现过的那一档。 + const next = revealResourceCanvasGenerationContent({ + viewport, + content: { x: 40, y: 550, width: 180, height: 320 }, + canvasSize, + insets, + }); + const safeBottom = canvasSize.height - insets.bottom; + expect(viewport.y + (550 + 320)).toBeGreaterThan(safeBottom); + expect(next.y + 550 + 320).toBe(safeBottom); + expect(next.x).toBe(0); + expect(next.scale).toBe(1); + }); + + test('内容顶出顶栏时下移,内容越出左右边时做最小横移', () => { + const next = revealResourceCanvasGenerationContent({ + viewport: { x: 0, y: 0, scale: 1 }, + content: { x: -300, y: -40, width: 180, height: 120 }, + canvasSize, + insets, + }); + expect(next.y).toBe(insets.top + 40); + expect(next.x).toBe(12 + 300); + }); + + test('缩放参与换算:缩小后的越界按同一口径平移,比例保持不变', () => { + const next = revealResourceCanvasGenerationContent({ + viewport: { x: 0, y: 0, scale: 0.5 }, + content: { x: 40, y: 900, width: 180, height: 320 }, + canvasSize, + insets, + }); + expect(next.scale).toBe(0.5); + const safeBottom = canvasSize.height - insets.bottom; + expect(next.y + (900 + 320) * 0.5).toBe(safeBottom); + }); + + test('内容比安全区还大时以顶边 / 左边对齐,不来回抖', () => { + const next = revealResourceCanvasGenerationContent({ + viewport: { x: 0, y: 0, scale: 1 }, + content: { x: 0, y: 0, width: 2000, height: 1200 }, + canvasSize, + insets, + }); + expect(next).toEqual({ scale: 1, x: 12, y: insets.top }); + }); + + test('尺寸非法或画布未就绪时原样返回', () => { + const viewport = { x: 5, y: 6, scale: 1 }; + expect( + revealResourceCanvasGenerationContent({ + viewport, + content: { x: 0, y: 0, width: 0, height: 0 }, + canvasSize, + insets, + }), + ).toEqual(viewport); + expect( + revealResourceCanvasGenerationContent({ + viewport, + content: { x: 0, y: 0, width: 10, height: 10 }, + canvasSize: { width: 0, height: 0 }, + insets, + }), + ).toEqual(viewport); + }); +}); + +describe('浮层高度与「盖住占位」判定按画布安全带算', () => { + const placement = (input: { + canvasHeight: number; + topInset: number; + bottomInset: number; + anchorHeight: number; + }) => resolveResourceCanvasGenerationPanelPlacement(input); + + test('空间够时挂在卡下面,高度 = 安全带 - 卡 - 间隙', () => { + const result = placement({ + canvasHeight: 568, + topInset: 54, + bottomInset: 84, + anchorHeight: 128, + }); + expect(result.overlaysAnchor).toBe(false); + // 安全带 430 - 卡 128 - 间隙 12 = 290:落在「至少 260 可编辑」的区间里。 + expect(result.availableHeight).toBe(290); + }); + + test('安全带给不出可编辑高度时改为盖住占位,拿整条安全带', () => { + // 缩放 1.5:占位卡视觉 192px,卡下面只剩 224 (< 280) → 盖住占位。 + const result = placement({ + canvasHeight: 568, + topInset: 58, + bottomInset: 72, + anchorHeight: 192, + }); + expect(result.overlaysAnchor).toBe(true); + // 安全带 438 - 间隙 12 = 426:远大于「一百多像素」的旧表现。 + expect(result.availableHeight).toBe(426); + expect(result.availableHeight).toBeGreaterThanOrEqual(260); + }); + + test('空间充足时收到上限,几何非法时回退到最小编辑高度', () => { + expect( + placement({ + canvasHeight: 1400, + topInset: 54, + bottomInset: 84, + anchorHeight: 128, + }).availableHeight, + ).toBe(520); + expect( + placement({ + canvasHeight: 0, + topInset: 54, + bottomInset: 84, + anchorHeight: 128, + }), + ).toEqual({ overlaysAnchor: false, availableHeight: 280 }); + // 画布小到连安全带都装不下:退到安全带能给的量,但仍有可编辑高度。 + const tiny = placement({ + canvasHeight: 240, + topInset: 40, + bottomInset: 40, + anchorHeight: 128, + }); + expect(tiny.overlaysAnchor).toBe(true); + expect(tiny.availableHeight).toBe(160); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasHistoryModel.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasHistoryModel.test.ts index 851426e14..8445385dc 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasHistoryModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasHistoryModel.test.ts @@ -10,6 +10,7 @@ import { pushResourceCanvasHistory, redoResourceCanvasHistory, resolveResourceCanvasRestoreEntries, + resourceCanvasSnapshotsEqual, undoResourceCanvasHistory, } from '../src/features/resource-canvas/resourceCanvasHistoryModel'; @@ -155,6 +156,54 @@ describe('resource canvas history model', () => { expect(restore.map((entry) => entry.resourceId)).toEqual(['asset:b']); }); + it('坐标完全相同但手动标记不同,仍算两次不同的布局', () => { + const manual = captureResourceCanvasSnapshot( + positions([['asset:a', 10, 20, true]]), + ); + const automatic = captureResourceCanvasSnapshot( + positions([['asset:a', 10, 20, false]]), + ); + + expect(resourceCanvasSnapshotsEqual(manual, automatic)).toBe(false); + const history = pushResourceCanvasHistory(createResourceCanvasHistory(), { + label: '整理画布', + snapshot: manual, + }); + expect( + pushResourceCanvasHistory(history, { + label: '整理画布', + snapshot: automatic, + }).undoStack, + ).toHaveLength(2); + }); + + it('坐标一致但手动标记不同时,撤销仍然回写这条标记', () => { + const snapshot = captureResourceCanvasSnapshot( + positions([ + ['asset:a', 10, 20, true], + ['asset:b', 30, 40, false], + ]), + ); + const restore = resolveResourceCanvasRestoreEntries( + snapshot, + positions([ + // 整理画布之后:坐标没变,但标记从手动变成了自动。 + ['asset:a', 10, 20, false], + ['asset:b', 30, 40, false], + ]), + ); + + expect(restore).toEqual([ + { + resourceId: 'asset:a', + section: 'document', + x: 10, + y: 20, + manuallyPlaced: true, + }, + ]); + }); + it('快照里不存在的资源不会在撤销时被新建出来', () => { const snapshot = captureResourceCanvasSnapshot( positions([['asset:deleted', 10, 20]]), diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx index 49b9c3dfa..18452ec28 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasManualLayout.test.tsx @@ -340,6 +340,338 @@ afterEach(() => { vi.restoreAllMocks(); }); +const POINTER_WORKBENCH_PROJECT_ID = 'pointer-workbench'; +const POINTER_WORKBENCH_PATH = '/tmp/pointer-workbench'; + +async function mountPointerWorkbench( + target: 'main' | 'character' | 'all' = 'character', + options: { + assets?: AssetFixture[]; + /** 每个排序模式一份持久化坐标:`{mode}` 传具体模式名(`dependency` / `type`)。 */ + layoutByMode?: Record; + } = {}, +) { + const projectId = POINTER_WORKBENCH_PROJECT_ID; + const projectPath = POINTER_WORKBENCH_PATH; + const layoutByScope = Object.fromEntries( + Object.entries(options.layoutByMode ?? {}).map(([mode, positions]) => [ + `${projectPath}|${mode}`, + positions, + ]), + ); + const tauri = installLayoutTauri({ + projectIdsByPath: { [projectPath]: projectId }, + layoutByScope, + }); + render( + , + ); + await settleFocusChain(); + const manager = document.querySelector( + '.game-resource-book-manager', + )!; + vi.spyOn(manager, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 800, + bottom: 600, + width: 800, + height: 600, + toJSON: () => ({}), + } as DOMRect); + act(() => window.dispatchEvent(new Event('resize'))); + if (target !== 'main') { + fireEvent.click( + screen.getByRole('button', { + name: target === 'all' ? '打开所有资源' : '打开角色与对象', + }), + ); + } + await settleFocusChain(); + const surface = manager.querySelector( + target === 'main' + ? '.game-resource-book-main' + : '.game-resource-book-scene', + )!; + const capture = new Set(); + Object.defineProperties(surface, { + setPointerCapture: { + configurable: true, + value: vi.fn((id: number) => capture.add(id)), + }, + hasPointerCapture: { + configurable: true, + value: (id: number) => capture.has(id), + }, + releasePointerCapture: { + configurable: true, + value: vi.fn((id: number) => capture.delete(id)), + }, + }); + const world = manager.querySelector( + '.game-resource-book-scene-world', + )!; + const viewport = () => + Array.from(world.style.transform.matchAll(/-?\d+(?:\.\d+)?/g), (m) => + Number(m[0]), + ); + return { manager, surface, world, viewport, tauri }; +} + +describe('资源画布指针与运行提示', () => { + it('资源卡左键拖动仍提交手动坐标,不平移视口', async () => { + const { manager, viewport, tauri } = await mountPointerWorkbench(); + const card = manager.querySelector( + '.is-expanded .game-resource-card[data-resource-card-id="asset:pointer-a"]', + )!; + const before = viewport(); + const writes = tauri.layoutWrites.length; + fireEvent.pointerDown(card, { + pointerId: 30, + button: 0, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(card, { + pointerId: 30, + buttons: 1, + clientX: 180, + clientY: 140, + }); + fireEvent.pointerUp(card, { + pointerId: 30, + button: 0, + clientX: 180, + clientY: 140, + }); + await waitFor(() => + expect(tauri.layoutWrites.length).toBeGreaterThan(writes), + ); + expect(tauri.layoutWrites.at(-1)?.positions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'asset:pointer-a', + manuallyPlaced: true, + }), + ]), + ); + expect(viewport()).toEqual(before); + expect( + document.querySelector('.genarrative-image-canvas__selection-overlay'), + ).toBeNull(); + }); + + it('资源选中不移除运行不可用提示或改变运行能力', async () => { + const { manager } = await mountPointerWorkbench(); + const hint = '首个可运行原型尚未完成,运行视图暂不可用'; + expect(screen.getByText(hint)).not.toBeNull(); + fireEvent.click( + manager.querySelector( + '.is-expanded [data-resource-id="asset:pointer-a"]', + )!, + ); + expect(screen.getByText(hint)).not.toBeNull(); + const run = screen.getByRole('tab', { name: '运行' }); + expect(run.getAttribute('data-unavailable')).toBe('true'); + expect(run.getAttribute('aria-describedby')).toBe('run-unavailable-hint'); + fireEvent.click(run); + expect(run.getAttribute('aria-selected')).toBe('false'); + }); + + it.each(['main', 'character', 'all'] as const)( + '%s 的空白处支持右键平移且不写资源布局', + async (target) => { + const { surface, viewport, tauri } = await mountPointerWorkbench(target); + const before = viewport(); + const writes = tauri.layoutWrites.length; + fireEvent.pointerDown(surface, { + pointerId: 31, + button: 2, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(surface, { + pointerId: 31, + buttons: 2, + clientX: 170, + clientY: 140, + }); + expect(viewport()).toEqual([before[0]! + 70, before[1]! + 40, before[2]]); + expect(fireEvent.contextMenu(surface, { button: 2 })).toBe(false); + fireEvent.pointerUp(surface, { + pointerId: 31, + button: 2, + clientX: 170, + clientY: 140, + }); + expect( + document.querySelector('.genarrative-image-canvas__selection-overlay'), + ).toBeNull(); + expect(tauri.layoutWrites).toHaveLength(writes); + }, + ); + + it('资源卡右键平移保留选中,左键空白拖动仍框选', async () => { + const { manager, surface, viewport, tauri } = await mountPointerWorkbench(); + const card = manager.querySelector( + '.is-expanded [data-resource-id="asset:pointer-a"]', + )!; + fireEvent.click(card); + const before = viewport(); + const writes = tauri.layoutWrites.length; + fireEvent.pointerDown(card, { + pointerId: 32, + button: 2, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(surface, { + pointerId: 32, + buttons: 2, + clientX: 150, + clientY: 160, + }); + fireEvent.pointerUp(surface, { + pointerId: 32, + button: 2, + clientX: 150, + clientY: 160, + }); + expect(viewport()).toEqual([before[0]! + 50, before[1]! + 60, before[2]]); + expect(card.getAttribute('aria-pressed')).toBe('true'); + expect(tauri.layoutWrites).toHaveLength(writes); + const panned = viewport(); + fireEvent.pointerDown(surface, { + pointerId: 33, + button: 0, + clientX: 10, + clientY: 10, + }); + fireEvent.pointerMove(surface, { + pointerId: 33, + buttons: 1, + clientX: 780, + clientY: 580, + }); + expect( + document.querySelector('.genarrative-image-canvas__selection-overlay'), + ).not.toBeNull(); + expect(selectedResourceIdsInDom()).toContain('asset:pointer-a'); + expect(viewport()).toEqual(panned); + fireEvent.pointerUp(surface, { pointerId: 33, button: 0 }); + expect( + document.querySelector('.genarrative-image-canvas__selection-overlay'), + ).toBeNull(); + }); + + it.each(['cancel', 'capture', 'blur'] as const)( + '%s 后右键平移不会继续跟随指针', + async (reason) => { + const { surface, viewport } = await mountPointerWorkbench(); + const before = viewport(); + fireEvent.pointerDown(surface, { + pointerId: 34, + button: 2, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(surface, { + pointerId: 34, + buttons: 2, + clientX: 110, + clientY: 120, + }); + const moved = viewport(); + expect(moved).toEqual([before[0]! + 10, before[1]! + 20, before[2]]); + if (reason === 'cancel') + fireEvent.pointerCancel(surface, { pointerId: 34 }); + else if (reason === 'capture') + fireEvent.lostPointerCapture(surface, { pointerId: 34 }); + else act(() => window.dispatchEvent(new Event('blur'))); + fireEvent.pointerMove(surface, { + pointerId: 34, + clientX: 300, + clientY: 300, + }); + expect(viewport()).toEqual(moved); + }, + ); + + it('总览失焦终止右键平移,子画布切换释放实际捕获节点', async () => { + const { surface, viewport } = await mountPointerWorkbench('main'); + const before = viewport(); + fireEvent.pointerDown(surface, { + pointerId: 36, + button: 2, + clientX: 10, + clientY: 10, + }); + fireEvent.pointerMove(surface, { + pointerId: 36, + buttons: 2, + clientX: 40, + clientY: 40, + }); + const moved = viewport(); + expect(moved).toEqual([before[0]! + 30, before[1]! + 30, before[2]]); + act(() => window.dispatchEvent(new Event('blur'))); + expect(surface.releasePointerCapture).toHaveBeenCalledWith(36); + fireEvent.pointerMove(surface, { + pointerId: 36, + clientX: 100, + clientY: 100, + }); + expect(viewport()).toEqual(moved); + fireEvent.pointerDown(surface, { + pointerId: 37, + button: 2, + clientX: 10, + clientY: 10, + }); + fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' })); + expect(surface.releasePointerCapture).toHaveBeenCalledWith(37); + }); + + it('控件右键不被画布接管,初次打开不整理也能连续双指平移', async () => { + const { manager, surface, viewport } = await mountPointerWorkbench(); + const zoom = screen.getByRole('button', { name: '放大画布' }); + const before = viewport(); + fireEvent.pointerDown(zoom, { + pointerId: 35, + button: 2, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(surface, { + pointerId: 35, + buttons: 2, + clientX: 200, + clientY: 200, + }); + expect(viewport()).toEqual(before); + expect(fireEvent.contextMenu(zoom, { button: 2 })).toBe(true); + await act(async () => { + for (let i = 0; i < 100; i++) { + fireEvent.wheel(manager, { deltaX: 3, deltaY: 6 }); + } + }); + expect(viewport()).toEqual([before[0]! - 300, before[1]! - 600, before[2]]); + }); +}); + describe('资源画布手动重排口径', () => { it('hook:rederiveNow 按 rederive 策略重算自动坐标并写回一次', async () => { const projectId = 'manual-rederive-project'; @@ -673,7 +1005,7 @@ describe('资源画布手动重排口径', () => { expect(tauri.unexpectedCommands).toEqual([]); }); - it('「整理画布」按 rederive 重算自动坐标、保留手动坐标,并给出一次可见反馈', async () => { + it('「整理画布」重排当前栏目全部素材(含手动卡)成自动坐标,一次撤销恢复坐标与手动标记', async () => { const projectPath = '/tmp/manual-rederive-button-project'; const tauri = installLayoutTauri({ projectIdsByPath: { @@ -731,16 +1063,17 @@ describe('资源画布手动重排口径', () => { expect.objectContaining({ resourceId: 'asset:asset-art-b', section: 'character', - x: 0, + x: 196, y: 0, manuallyPlaced: false, }), + // 手动摆放过的卡也在整理范围内:坐标重算、手动标记转成自动。 expect.objectContaining({ resourceId: 'asset:asset-art-a', section: 'character', - x: 600, - y: 40, - manuallyPlaced: true, + x: 0, + y: 0, + manuallyPlaced: false, }), ]), ); @@ -751,6 +1084,28 @@ describe('资源画布手动重排口径', () => { fireEvent.click(screen.getByRole('button', { name: '整理画布' })); await settleFocusChain(); expect(typeWrites(tauri)).toHaveLength(1); + + // 一次撤销还原整理前的坐标**与手动标记**:整次整理只是一笔可撤销操作。 + fireEvent.keyDown(window, { key: 'z', ctrlKey: true }); + await waitFor(() => expect(typeWrites(tauri)).toHaveLength(2)); + expect(typeWrites(tauri)[1]!.positions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'asset:asset-art-a', + section: 'character', + x: 600, + y: 40, + manuallyPlaced: true, + }), + expect.objectContaining({ + resourceId: 'asset:asset-art-b', + section: 'character', + x: 800, + y: 800, + manuallyPlaced: false, + }), + ]), + ); }); it('「整理画布」不属于「资源排列方式」这组模式切换,而是一枚资源动作按钮', async () => { @@ -908,3 +1263,727 @@ describe('资源画布手动重排口径', () => { expect(single).not.toBe(doubled); }); }); + +/** + * 多选批量移动、整理范围与取消/切项目边界的画布级验收。 + * + * 口径:拖动已选卡按统一位移移动整个选择集;拖动未选卡保持单选;松手一次提交、一次撤销; + * 取消/失焦/切项目不留下半截位移;整理只作用于当前栏目(筛选不缩小集合),其他栏目不变。 + */ +describe('资源画布多选拖动与整理范围', () => { + function writePositionsById(write: LayoutWrite) { + return new Map( + write.positions.map((position) => [position.resourceId, position]), + ); + } + + function cardIn(manager: HTMLElement, resourceId: string) { + return manager.querySelector( + `.is-expanded .game-resource-card[data-resource-card-id="${resourceId}"]`, + )!; + } + + /** + * type 侧的持久化坐标:两张角色卡摆在互为上下的相对位置上,整理后槽位可预测 + * (第一槽 0,0 / 第二槽 196,0)。 + */ + const twoCharacterCards: ProjectResourceCanvasPosition[] = [ + { + resourceId: 'asset:pointer-a', + section: 'character', + x: 0, + y: 0, + manuallyPlaced: false, + }, + { + resourceId: 'asset:pointer-b', + section: 'character', + x: 400, + y: 300, + manuallyPlaced: false, + }, + ]; + + async function selectTwoCharacterCards( + manager: HTMLElement, + tauri: FakeTauri, + ) { + // 打开角色与对象栏目页并切到「按类型」:type 侧坐标可预测,写入也走同一套写队列。 + fireEvent.click(screen.getByRole('button', { name: '按类型' })); + const cardA = cardIn(manager, 'asset:pointer-a'); + const cardB = cardIn(manager, 'asset:pointer-b'); + fireEvent.click(cardA); + fireEvent.click(cardB, { shiftKey: true }); + await waitFor(() => + expect(new Set(selectedResourceIdsInDom())).toEqual( + new Set(['asset:pointer-a', 'asset:pointer-b']), + ), + ); + expect(typeWrites(tauri)).toEqual([]); + return { cardA, cardB }; + } + + it.each(['character', 'all'] as const)( + '%s:多选拖动期间布局重建,预览仍使用按下时起点并与松手写回一致', + async (target) => { + const { manager, viewport, tauri } = await mountPointerWorkbench(target, { + layoutByMode: { type: twoCharacterCards }, + }); + const { cardA, cardB } = await selectTwoCharacterCards(manager, tauri); + const scale = viewport()[2]!; + fireEvent.pointerDown(cardB, { + pointerId: 49, + button: 0, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(cardB, { + pointerId: 49, + buttons: 1, + clientX: 180, + clientY: 140, + }); + const beforeA = [ + cardA.style.getPropertyValue('--resource-x'), + cardA.style.getPropertyValue('--resource-y'), + ]; + const beforeB = [ + cardB.style.getPropertyValue('--resource-x'), + cardB.style.getPropertyValue('--resource-y'), + ]; + // 重建底层布局,模拟拖动尚未结束时另一笔布局操作完成。 + fireEvent.click(screen.getByRole('button', { name: '整理画布' })); + await waitFor(() => expect(typeWrites(tauri)).toHaveLength(1)); + expect( + typeWrites(tauri)[0]!.positions.find( + (p) => p.resourceId === 'asset:pointer-b', + )?.y, + ).toBe(0); + expect([ + cardA.style.getPropertyValue('--resource-x'), + cardA.style.getPropertyValue('--resource-y'), + ]).toEqual(beforeA); + expect([ + cardB.style.getPropertyValue('--resource-x'), + cardB.style.getPropertyValue('--resource-y'), + ]).toEqual(beforeB); + fireEvent.pointerUp(cardB, { + pointerId: 49, + button: 0, + clientX: 180, + clientY: 140, + }); + await waitFor(() => expect(typeWrites(tauri)).toHaveLength(2)); + expect(typeWrites(tauri)[1]!.positions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'asset:pointer-a', + x: Math.round(80 / scale), + y: Math.round(40 / scale), + }), + expect.objectContaining({ + resourceId: 'asset:pointer-b', + x: Math.round(400 + 80 / scale), + y: Math.round(300 + 40 / scale), + }), + ]), + ); + }, + ); + + it('拖动已选卡整批等位移、一笔提交、一次撤销,多选保持', async () => { + const { manager, viewport, tauri } = await mountPointerWorkbench( + 'character', + { + layoutByMode: { type: twoCharacterCards }, + }, + ); + const { cardA, cardB } = await selectTwoCharacterCards(manager, tauri); + const scale = viewport()[2]!; + expect(scale).toBeGreaterThan(0); + + fireEvent.pointerDown(cardB, { + pointerId: 41, + button: 0, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(cardB, { + pointerId: 41, + buttons: 1, + clientX: 180, + clientY: 140, + }); + fireEvent.pointerUp(cardB, { + pointerId: 41, + button: 0, + clientX: 180, + clientY: 140, + }); + + // 松手只提交一笔:整批卡在同一次 CAS 里落盘。 + await waitFor(() => expect(typeWrites(tauri)).toHaveLength(1)); + const after = writePositionsById(typeWrites(tauri)[0]!); + const deltaX = Math.round(80 / scale); + const deltaY = Math.round(40 / scale); + expect(after.get('asset:pointer-a')).toEqual({ + resourceId: 'asset:pointer-a', + section: 'character', + x: 0 + deltaX, + y: 0 + deltaY, + manuallyPlaced: true, + }); + expect(after.get('asset:pointer-b')).toEqual({ + resourceId: 'asset:pointer-b', + section: 'character', + x: Math.round(400 + 80 / scale), + y: Math.round(300 + 40 / scale), + manuallyPlaced: true, + }); + // 相对位置保持原样(等位移)。 + expect( + after.get('asset:pointer-b')!.x - after.get('asset:pointer-a')!.x, + ).toBe(400); + expect( + after.get('asset:pointer-b')!.y - after.get('asset:pointer-a')!.y, + ).toBe(300); + // 拖动已选卡不丢多选。 + expect(new Set(selectedResourceIdsInDom())).toEqual( + new Set(['asset:pointer-a', 'asset:pointer-b']), + ); + + // 一次撤销把整批带回拖动前的坐标。 + fireEvent.keyDown(window, { key: 'z', ctrlKey: true }); + await waitFor(() => expect(typeWrites(tauri)).toHaveLength(2)); + const restored = writePositionsById(typeWrites(tauri)[1]!); + expect(restored.get('asset:pointer-a')).toMatchObject({ + x: 0, + y: 0, + manuallyPlaced: false, + }); + expect(restored.get('asset:pointer-b')).toMatchObject({ + x: 400, + y: 300, + manuallyPlaced: false, + }); + }); + + it('不同缩放下多选位移按 scale 换算', async () => { + const { manager, viewport, tauri } = await mountPointerWorkbench( + 'character', + { + layoutByMode: { type: twoCharacterCards }, + }, + ); + const { cardB } = await selectTwoCharacterCards(manager, tauri); + + const before = viewport()[2]!; + fireEvent.click(screen.getByRole('button', { name: '放大画布' })); + fireEvent.click(screen.getByRole('button', { name: '放大画布' })); + const scale = viewport()[2]!; + expect(scale).toBeGreaterThan(before); + + fireEvent.pointerDown(cardB, { + pointerId: 42, + button: 0, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(cardB, { + pointerId: 42, + buttons: 1, + clientX: 200, + clientY: 150, + }); + fireEvent.pointerUp(cardB, { + pointerId: 42, + button: 0, + clientX: 200, + clientY: 150, + }); + + await waitFor(() => expect(typeWrites(tauri)).toHaveLength(1)); + const after = writePositionsById(typeWrites(tauri)[0]!); + expect(after.get('asset:pointer-a')).toMatchObject({ + x: Math.round(100 / scale), + y: Math.round(50 / scale), + }); + expect(after.get('asset:pointer-b')).toMatchObject({ + x: Math.round(400 + 100 / scale), + y: Math.round(300 + 50 / scale), + }); + // 屏幕位移相同、坐标位移被 scale 收窄:不是按像素直接落盘。 + expect( + after.get('asset:pointer-a')!.x - after.get('asset:pointer-a')!.y, + ).not.toBe(50); + }); + + it('拖动未选中的卡保持单选语义:只有它自己动', async () => { + const { manager, viewport, tauri } = await mountPointerWorkbench( + 'character', + { + layoutByMode: { type: twoCharacterCards }, + }, + ); + fireEvent.click(screen.getByRole('button', { name: '按类型' })); + const cardA = cardIn(manager, 'asset:pointer-a'); + const cardB = cardIn(manager, 'asset:pointer-b'); + fireEvent.click(cardA); + expect(selectedResourceIdsInDom()).toEqual(['asset:pointer-a']); + const scale = viewport()[2]!; + + fireEvent.pointerDown(cardB, { + pointerId: 43, + button: 0, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(cardB, { + pointerId: 43, + buttons: 1, + clientX: 180, + clientY: 120, + }); + fireEvent.pointerUp(cardB, { + pointerId: 43, + button: 0, + clientX: 180, + clientY: 120, + }); + + await waitFor(() => expect(typeWrites(tauri)).toHaveLength(1)); + const after = writePositionsById(typeWrites(tauri)[0]!); + expect(after.get('asset:pointer-a')).toMatchObject({ x: 0, y: 0 }); + expect(after.get('asset:pointer-b')).toMatchObject({ + x: Math.round(400 + 80 / scale), + y: Math.round(300 + 20 / scale), + }); + }); + + it.each(['cancel', 'capture', 'blur'] as const)( + '%s 取消多选拖动:不落盘、坐标回到拖动前、选择保留', + async (reason) => { + const { manager, tauri } = await mountPointerWorkbench('character', { + layoutByMode: { type: twoCharacterCards }, + }); + const { cardB } = await selectTwoCharacterCards(manager, tauri); + const cardARoot = cardIn(manager, 'asset:pointer-a'); + + fireEvent.pointerDown(cardB, { + pointerId: 44, + button: 0, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(cardB, { + pointerId: 44, + buttons: 1, + clientX: 260, + clientY: 220, + }); + // 拖动中整批卡先跟指针走。 + expect(cardIn(manager, 'asset:pointer-b').className).toContain( + 'is-dragging', + ); + expect(cardARoot.className).toContain('is-dragging'); + + if (reason === 'cancel') { + fireEvent.pointerCancel(cardB, { pointerId: 44 }); + } else if (reason === 'capture') { + fireEvent.lostPointerCapture(cardB, { pointerId: 44 }); + } else { + act(() => window.dispatchEvent(new Event('blur'))); + } + await settleFocusChain(); + + expect(typeWrites(tauri)).toEqual([]); + // 预览已丢掉:卡片回到拖动前的位置(不再带拖动态)。 + expect(cardIn(manager, 'asset:pointer-b').className).not.toContain( + 'is-dragging', + ); + expect(cardARoot.className).not.toContain('is-dragging'); + expect(new Set(selectedResourceIdsInDom())).toEqual( + new Set(['asset:pointer-a', 'asset:pointer-b']), + ); + }, + ); + + it('整理只重排当前栏目,其他栏目坐标与手动标记不变', async () => { + const { manager, tauri } = await mountPointerWorkbench('character', { + assets: [ + pngAsset('pointer-a', 'a.png'), + pngAsset('pointer-b', 'b.png'), + { + ...pngAsset('scene-c', 'scene-c.png'), + category: 'scene', + kind: 'scene', + }, + ], + layoutByMode: { + type: [ + ...twoCharacterCards, + { + resourceId: 'asset:scene-c', + section: 'scene', + x: 30, + y: 60, + manuallyPlaced: true, + }, + ], + }, + }); + fireEvent.click(screen.getByRole('button', { name: '按类型' })); + await waitFor(() => + expect(cardIn(manager, 'asset:pointer-b')).not.toBeNull(), + ); + await settleFocusChain(); + + fireEvent.click(screen.getByRole('button', { name: '整理画布' })); + + await waitFor(() => expect(typeWrites(tauri)).toHaveLength(1)); + const after = writePositionsById(typeWrites(tauri)[0]!); + // 当前栏目:两张角色卡一起重算成自动坐标。 + expect(after.get('asset:pointer-a')).toMatchObject({ + x: 0, + y: 0, + manuallyPlaced: false, + }); + expect(after.get('asset:pointer-b')).toMatchObject({ + x: 196, + y: 0, + manuallyPlaced: false, + }); + // 其他栏目:坐标与手动标记逐值不动。 + expect(after.get('asset:scene-c')).toEqual({ + resourceId: 'asset:scene-c', + section: 'scene', + x: 30, + y: 60, + manuallyPlaced: true, + }); + }); + + it('筛选不缩小整理集合:被搜索隐藏的同栏目素材同样参与重排', async () => { + const { manager, tauri } = await mountPointerWorkbench('character', { + layoutByMode: { type: twoCharacterCards }, + }); + fireEvent.click(screen.getByRole('button', { name: '按类型' })); + await waitFor(() => + expect(cardIn(manager, 'asset:pointer-b')).not.toBeNull(), + ); + const search = openResourceFilterPanel(); + fireEvent.change(search, { target: { value: 'a.png' } }); + // 被搜索挡住的第二张卡在画布上不可交互(宿主机标记 aria-hidden),但仍在当前栏目里。 + const hiddenCardHost = () => + cardIn(manager, 'asset:pointer-b').closest( + '.game-resource-book-scene-card', + )!; + await waitFor(() => + expect(hiddenCardHost().getAttribute('aria-hidden')).toBe('true'), + ); + + fireEvent.click(screen.getByRole('button', { name: '整理画布' })); + + await waitFor(() => expect(typeWrites(tauri)).toHaveLength(1)); + const after = writePositionsById(typeWrites(tauri)[0]!); + expect(after.get('asset:pointer-a')).toMatchObject({ + x: 0, + y: 0, + manuallyPlaced: false, + }); + expect(after.get('asset:pointer-b')).toMatchObject({ + x: 196, + y: 0, + manuallyPlaced: false, + }); + }); + + it('「所有资源」页整理全部栏目的可展示素材,手动坐标一并重算且只写一笔', async () => { + const { manager, tauri } = await mountPointerWorkbench('all', { + assets: [ + pngAsset('pointer-a', 'a.png'), + pngAsset('pointer-b', 'b.png'), + { + ...pngAsset('scene-c', 'scene-c.png'), + category: 'scene', + kind: 'scene', + }, + { ...markdownAsset('doc-d', 'd.md'), category: 'document' }, + ], + layoutByMode: { + type: [ + { + resourceId: 'asset:pointer-a', + section: 'character', + x: 500, + y: 500, + manuallyPlaced: true, + }, + { + resourceId: 'asset:pointer-b', + section: 'character', + x: 400, + y: 300, + manuallyPlaced: false, + }, + { + resourceId: 'asset:scene-c', + section: 'scene', + x: 30, + y: 60, + manuallyPlaced: true, + }, + { + resourceId: 'asset:doc-d', + section: 'document', + x: 700, + y: 700, + manuallyPlaced: false, + }, + ], + }, + }); + fireEvent.click(screen.getByRole('button', { name: '按类型' })); + await waitFor(() => + expect(cardIn(manager, 'asset:pointer-b')).not.toBeNull(), + ); + await settleFocusChain(); + // 读盘那一次「归并 / 规范化」写回与本次要验的整理是两回事,先等它落地再记账。 + const writesBeforeOrganize = typeWrites(tauri).length; + + fireEvent.click(screen.getByRole('button', { name: '整理画布' })); + + // 「所有资源」= 当前项目全部可展示素材,一次整理仍然只写一笔。 + await waitFor(() => + expect(typeWrites(tauri)).toHaveLength(writesBeforeOrganize + 1), + ); + const after = writePositionsById(typeWrites(tauri).at(-1)!); + // 每个栏目各自重算成自动槽位:同栏目第二张卡落在 196,0,跨栏目互不影响。 + expect(after.get('asset:pointer-a')).toMatchObject({ + section: 'character', + x: 0, + y: 0, + manuallyPlaced: false, + }); + expect(after.get('asset:pointer-b')).toMatchObject({ + section: 'character', + x: 196, + y: 0, + manuallyPlaced: false, + }); + expect(after.get('asset:scene-c')).toMatchObject({ + section: 'scene', + x: 0, + y: 0, + manuallyPlaced: false, + }); + expect(after.get('asset:doc-d')).toMatchObject({ + section: 'document', + x: 0, + y: 0, + manuallyPlaced: false, + }); + }); + + it('切项目清空选择并丢弃进行中的拖动,不把位移写进新项目', async () => { + const projectPathA = '/tmp/multi-drag-project-a'; + const projectPathB = '/tmp/multi-drag-project-b'; + const tauri = installLayoutTauri({ + projectIdsByPath: { + [projectPathA]: 'multi-drag-project-a', + [projectPathB]: 'multi-drag-project-b', + }, + layoutByScope: { + [`${projectPathA}|type`]: twoCharacterCards, + [`${projectPathB}|type`]: [ + { + resourceId: 'asset:project-b-1', + section: 'document', + x: 10, + y: 20, + manuallyPlaced: false, + }, + ], + }, + }); + render( + , + ); + await settleFocusChain(); + const manager = document.querySelector( + '.game-resource-book-manager', + )!; + vi.spyOn(manager, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 800, + bottom: 600, + width: 800, + height: 600, + toJSON: () => ({}), + } as DOMRect); + act(() => window.dispatchEvent(new Event('resize'))); + fireEvent.click(screen.getByRole('button', { name: '按类型' })); + fireEvent.click(screen.getByRole('button', { name: '打开角色与对象' })); + await waitFor(() => + expect(cardIn(manager, 'asset:pointer-b')).not.toBeNull(), + ); + const cardA = cardIn(manager, 'asset:pointer-a'); + const cardB = cardIn(manager, 'asset:pointer-b'); + fireEvent.click(cardA); + fireEvent.click(cardB, { shiftKey: true }); + await waitFor(() => expect(selectedResourceIdsInDom()).toHaveLength(2)); + + fireEvent.pointerDown(cardB, { + pointerId: 45, + button: 0, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(cardB, { + pointerId: 45, + buttons: 1, + clientX: 300, + clientY: 240, + }); + + fireEvent.click(screen.getByRole('button', { name: '测试:切换项目' })); + await waitFor(() => + expect( + tauri.layoutReads.some((read) => read.projectPath === projectPathB), + ).toBe(true), + ); + // 松手发生在切项目之后:位移不写进新项目。 + fireEvent.pointerUp(cardB, { + pointerId: 45, + button: 0, + clientX: 300, + clientY: 240, + }); + await settleFocusChain(); + + // 两个项目都没有留下拖动落点:没有任何一笔手动坐标。 + for (const projectPath of [projectPathA, projectPathB]) { + const manualWrites = tauri.layoutWrites + .filter((write) => write.projectPath === projectPath) + .flatMap((write) => write.positions) + .filter((position) => position.manuallyPlaced); + expect(manualWrites).toEqual([]); + } + // 两个项目的 type 侧坐标逐值保持原样(拖动没有落盘,也没有跨项目串写)。 + expect( + tauri.layoutWrites.filter( + (write) => + write.mode === 'type' && + (write.projectPath === projectPathA || + write.projectPath === projectPathB), + ), + ).toEqual([]); + await waitFor(() => expect(selectedResourceIdsInDom()).toEqual([])); + }); + + /** + * 撤销栈按「项目 + 排序模式」隔离:两种排序各有自己的一份布局 sidecar。 + * + * 在「按类型」里拖过卡之后切到「按依赖」,那笔历史不属于这一份 sidecar —— 撤销不能把 + * type 侧的坐标套用(写)进依赖侧,否则用户只是切了个 tab,保存下来的却是一份串了模式的 + * 布局。 + */ + it('撤销不跨排序模式:切到另一份 sidecar 后不会把 type 侧坐标写过去', async () => { + const { manager, tauri } = await mountPointerWorkbench('character', { + layoutByMode: { + type: [ + { + resourceId: 'asset:pointer-a', + section: 'character', + x: 0, + y: 0, + manuallyPlaced: false, + }, + { + resourceId: 'asset:pointer-b', + section: 'character', + x: 400, + y: 300, + manuallyPlaced: false, + }, + ], + dependency: [ + { + resourceId: 'asset:pointer-a', + section: 'character', + x: 700, + y: 700, + manuallyPlaced: false, + }, + { + resourceId: 'asset:pointer-b', + section: 'character', + x: 900, + y: 900, + manuallyPlaced: false, + }, + ], + }, + }); + fireEvent.click(screen.getByRole('button', { name: '按类型' })); + await waitFor(() => + expect(cardIn(manager, 'asset:pointer-a')).not.toBeNull(), + ); + await settleFocusChain(); + + // 「按类型」里拖动一张卡:记一笔历史 + 一笔 type 侧写入。 + const cardA = cardIn(manager, 'asset:pointer-a'); + fireEvent.pointerDown(cardA, { + pointerId: 61, + button: 0, + clientX: 100, + clientY: 100, + }); + fireEvent.pointerMove(cardA, { + pointerId: 61, + buttons: 1, + clientX: 180, + clientY: 140, + }); + fireEvent.pointerUp(cardA, { + pointerId: 61, + button: 0, + clientX: 180, + clientY: 140, + }); + await waitFor(() => expect(typeWrites(tauri).length).toBeGreaterThan(0)); + await settleFocusChain(); + + // 切到「按依赖」:另一份 sidecar,坐标与 type 侧不同。 + fireEvent.click(screen.getByRole('button', { name: '按依赖' })); + await settleFocusChain(); + const dependencyWritesBefore = dependencyWrites(tauri).length; + + fireEvent.keyDown(window, { key: 'z', ctrlKey: true }); + await settleFocusChain(); + + // 依赖侧不多出一笔:撤销在另一份 sidecar 上是空操作,不跨写。 + expect(dependencyWrites(tauri)).toHaveLength(dependencyWritesBefore); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasPanelBatchTags.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasPanelBatchTags.test.tsx new file mode 100644 index 000000000..1cde8c31d --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasPanelBatchTags.test.tsx @@ -0,0 +1,134 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + within, +} from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { resolveResourceCanvasPanelEntries } from '../src/features/resource-canvas/resourceCanvasAssetTransferModel'; +import { ResourceCanvasPanelView } from '../src/features/resource-canvas/ResourceCanvasPanelView'; +import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel'; + +afterEach(cleanup); + +function createResource( + overrides: Partial = {}, +): ProjectResource { + return { + id: 'asset:hero', + category: 'character', + subtype: 'character', + label: '主角立绘', + path: 'assets/hero.png', + mediaType: 'image/png', + sourceLabel: '生成', + taskTitle: null, + manifestAssetId: 'asset-hero', + producerTaskId: null, + externalResourceId: null, + referenceResourceIds: [], + dependencies: [], + dependencyDepth: 0, + ...overrides, + }; +} + +const entries = resolveResourceCanvasPanelEntries([ + { + resource: createResource(), + categoryLabel: '角色与对象', + typeLabel: '图片', + previewIdentity: null, + previewStatus: 'idle', + previewSourceUrl: null, + previewError: null, + }, + { + resource: createResource({ + id: 'asset:npc', + label: '配角立绘', + path: 'assets/npc.png', + manifestAssetId: 'asset-npc', + }), + categoryLabel: '角色与对象', + typeLabel: '图片', + previewIdentity: null, + previewStatus: 'idle', + previewSourceUrl: null, + previewError: null, + }, +]); + +function renderPanel( + overrides: { + batchTags?: { + targetCount: number; + blockedReason: string | null; + onOpen: () => void; + }; + } = {}, +) { + render( + , + ); + return within(screen.getByRole('dialog', { name: '资源面板' })); +} + +describe('资源面板动作行的批量标签入口', () => { + it('入口显示完整选中集解析出的实际目标数量,而不是面板可见项数量', () => { + const onOpen = vi.fn(); + const panel = renderPanel({ + // 面板可见 2 项,但本次选择跨筛选保留了 5 项:数字必须按实际目标显示。 + batchTags: { targetCount: 5, blockedReason: null, onOpen }, + }); + + fireEvent.click(panel.getByRole('button', { name: '批量标签(5)' })); + expect(onOpen).toHaveBeenCalledTimes(1); + }); + + it('混合选择或超限时入口禁用,并把原因显示在动作行下方', () => { + const onOpen = vi.fn(); + const panel = renderPanel({ + batchTags: { + targetCount: 0, + blockedReason: + '选择里含未登记素材(项目版本 / 附件 / 任务产物),不能只保存其中一部分;请排除后重试', + onOpen, + }, + }); + + const button = panel.getByRole('button', { + name: '批量标签', + }) as HTMLButtonElement; + expect(button.disabled).toBe(true); + expect(button.title).toContain('未登记素材'); + expect( + panel.getByText(/批量标签不可用:选择里含未登记素材/u), + ).not.toBeNull(); + + fireEvent.click(button); + expect(onOpen).not.toHaveBeenCalled(); + }); + + it('宿主没给批量入口(未接线)时动作行不出现该按钮', () => { + const panel = renderPanel(); + + expect(panel.queryByRole('button', { name: '批量标签' })).toBeNull(); + expect(panel.getByRole('button', { name: '清空选择' })).not.toBeNull(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts b/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts index bd46086ea..127bcc9db 100644 --- a/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCardPreviewRealManifest.test.ts @@ -206,7 +206,7 @@ describe('真机 manifest 取证:占位卡片计数与栏目分布', () => { // `artKind`(其中含 `ui`)而被判成 art,于是角标显示「图片」,而预览调度按 art 走 // 图像分支、又因 mediaType 不是图像而兜底成 placeholder —— 卡片永不发起读取, // 表现为「标着图片却只有占位图标」。类型判定改为 mediaType/扩展名优先、kind 只做 - // 兜底后,它们正确落文档分支,卡面渲染 JSON 文本摘要。 + // 兜底后,它们进入文本读取通道;卡面再消费原生识别结果显示 UI 设计或 JSON。 expect(byPreviewKind).toEqual({ 'raster-image': 52, document: 8, diff --git a/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx index 2df832dce..427ab3f43 100644 --- a/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceClassificationPanel.test.tsx @@ -439,8 +439,13 @@ describe('ResourceClassificationPanel 编辑素材标签', () => { ); // 工具条写着「分类与标签」却打开纯标签面板会误导用户,入口必须与面板同名。 + // 多选无法整批写入(混入版本 / 未登记素材、超限)时 title 换成禁用原因, + // 所以这里钉的是「可用态的入口名」与「必须有禁用原因分支」,不再钉 title 字面量。 expect(viewSource).toContain('label="编辑标签"'); - expect(viewSource).toContain('title="编辑标签"'); + expect(viewSource).toContain("'编辑标签'"); + expect(viewSource).toMatch( + /title=\{\s*resourceTagEditorEntry\.disabledReason \?\?\s*'编辑标签'/u, + ); expect(viewSource).toContain('编辑标签'); expect(viewSource).not.toContain('label="分类与标签"'); }); diff --git a/apps/ai-game-creator-shell/tests/resourceClassificationPanelBatchTags.test.tsx b/apps/ai-game-creator-shell/tests/resourceClassificationPanelBatchTags.test.tsx new file mode 100644 index 000000000..b0ad55152 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceClassificationPanelBatchTags.test.tsx @@ -0,0 +1,348 @@ +// @vitest-environment jsdom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { ResourceClassificationPanel } from '../src/view/project-development/ResourceClassificationPanel'; + +function installInvoke( + implementation: (command: string, args?: unknown) => Promise, +) { + const invoke = vi.fn(implementation); + ( + window as unknown as { + __TAURI__?: { core?: { invoke?: typeof invoke } }; + } + ).__TAURI__ = { core: { invoke } }; + return invoke; +} + +function removeInvoke() { + delete ( + window as unknown as { + __TAURI__?: { core?: { invoke?: unknown } }; + } + ).__TAURI__; +} + +function renderBatchPanel( + overrides: { + assetIds?: readonly string[]; + onClose?: () => void; + onSaved?: (result: unknown) => void; + } = {}, +) { + const element = (assetIds: readonly string[]) => ( + + ); + const view = render( + element(overrides.assetIds ?? ['asset-hero', 'asset-npc']), + ); + return { + rerenderWithAssetIds: (assetIds: readonly string[]) => + view.rerender(element(assetIds)), + }; +} + +/** 待追加草稿按 pill 文本读出(pill 内的删除按钮文本是 `×`)。 */ +function draftPillLabels() { + const list = screen.queryByRole('list', { name: '待追加标签' }); + if (!list) return []; + return within(list) + .getAllByRole('listitem') + .map((item) => item.textContent?.replace('×', '').trim()); +} + +function batchTagWrites(invoke: ReturnType) { + return invoke.mock.calls.filter( + ([command]) => command === 'add_local_project_resource_tags', + ); +} + +afterEach(() => { + cleanup(); + removeInvoke(); +}); + +describe('ResourceClassificationPanel 批量追加标签', () => { + test('只有待追加草稿:不显示任何已有标签,也不出现单素材写入命令', async () => { + const user = userEvent.setup(); + const invoke = installInvoke(async (command) => { + if (command === 'get_local_game_project_revision') { + return { revision: 3 }; + } + if (command === 'add_local_project_resource_tags') { + return { assets: [], committedProjectRevision: 4 }; + } + throw new Error(`unexpected command: ${command}`); + }); + renderBatchPanel(); + + expect( + screen.getByRole('heading', { name: '批量追加标签' }), + ).not.toBeNull(); + // 副标题是**实际目标数量**,不是首项素材名。 + expect(screen.getByText('已选 2 项素材')).not.toBeNull(); + expect(screen.queryByRole('list', { name: '已有标签' })).toBeNull(); + expect(draftPillLabels()).toEqual([]); + + await user.type( + screen.getByPlaceholderText('新增标签,多个用逗号分隔'), + '春节{Enter}', + ); + // 回车把草稿落成待追加 pill,但这不是素材已有标签。 + expect(draftPillLabels()).toEqual(['春节']); + + await user.click(screen.getByRole('button', { name: '追加标签' })); + + await waitFor(() => expect(batchTagWrites(invoke)).toHaveLength(1)); + expect(invoke.mock.calls.map(([command]) => command)).toEqual([ + 'get_local_game_project_revision', + 'add_local_project_resource_tags', + ]); + // 绝不逐素材循环调用单素材写入命令。 + expect( + invoke.mock.calls.filter( + ([command]) => + command === 'update_local_project_resource_classification', + ), + ).toHaveLength(0); + }); + + test('保存把整组冻结 ID 与去重后的标签一次性交给原生,先读 revision 再提交', async () => { + const user = userEvent.setup(); + const onSaved = vi.fn(); + const invoke = installInvoke(async (command) => { + if (command === 'get_local_game_project_revision') { + return { revision: 7 }; + } + if (command === 'add_local_project_resource_tags') { + return { + assets: [{ id: 'asset-hero' }, { id: 'asset-npc' }], + committedProjectRevision: 8, + }; + } + throw new Error(`unexpected command: ${command}`); + }); + renderBatchPanel({ + assetIds: ['asset-npc', 'asset-hero', 'asset-npc'], + onSaved, + }); + + await user.type( + screen.getByPlaceholderText('新增标签,多个用逗号分隔'), + '春节, 新春,春节', + ); + await user.click(screen.getByRole('button', { name: '追加标签' })); + + await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)); + const [, args] = batchTagWrites(invoke)[0]!; + expect(args).toEqual({ + input: { + projectPath: 'C:/project', + expectedProjectId: 'project-1', + expectedProjectRevision: 7, + // 首现顺序 + 去重:不是只写首项,也不重复同一份素材。 + assetIds: ['asset-npc', 'asset-hero'], + tags: ['春节', '新春'], + }, + }); + // 保存成功后草稿清空,面板可以继续追加下一批。 + expect(draftPillLabels()).toEqual([]); + }); + + test('打开面板后宿主再传新的目标集,保存仍用打开时冻结的那一组', async () => { + const user = userEvent.setup(); + const invoke = installInvoke(async (command) => { + if (command === 'get_local_game_project_revision') { + return { revision: 3 }; + } + if (command === 'add_local_project_resource_tags') { + return { assets: [], committedProjectRevision: 4 }; + } + throw new Error(`unexpected command: ${command}`); + }); + const { rerenderWithAssetIds } = renderBatchPanel({ + assetIds: ['asset-hero', 'asset-npc'], + }); + + rerenderWithAssetIds(['asset-hero', 'asset-npc', 'asset-bg']); + expect(screen.getByText('已选 2 项素材')).not.toBeNull(); + + await user.type( + screen.getByPlaceholderText('新增标签,多个用逗号分隔'), + '春节', + ); + await user.click(screen.getByRole('button', { name: '追加标签' })); + + await waitFor(() => expect(batchTagWrites(invoke)).toHaveLength(1)); + const [, args] = batchTagWrites(invoke)[0]!; + expect(args).toMatchObject({ + input: { assetIds: ['asset-hero', 'asset-npc'] }, + }); + }); + + test('空草稿不提交:按钮禁用、不读 revision、不写任何命令', async () => { + const user = userEvent.setup(); + const invoke = installInvoke(async (command) => { + throw new Error(`unexpected command: ${command}`); + }); + renderBatchPanel(); + + const submit = screen.getByRole('button', { name: '追加标签' }); + expect(submit).toHaveProperty('disabled', true); + + await user.click(submit); + expect(invoke).not.toHaveBeenCalled(); + }); + + test('保存期间锁住重复提交与关闭,失败后保留待追加草稿并报出原因', async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + let rejectSave: ((error: Error) => void) | null = null; + const invoke = installInvoke(async (command) => { + if (command === 'get_local_game_project_revision') { + return { revision: 7 }; + } + if (command === 'add_local_project_resource_tags') { + return new Promise((_resolve, reject) => { + rejectSave = reject; + }); + } + throw new Error(`unexpected command: ${command}`); + }); + renderBatchPanel({ onClose }); + + await user.type( + screen.getByPlaceholderText('新增标签,多个用逗号分隔'), + '春节', + ); + await user.click(screen.getByRole('button', { name: '追加标签' })); + + await waitFor(() => expect(rejectSave).not.toBeNull()); + // 保存在飞:重复提交与关闭(头部 ×)都被禁用。 + expect(screen.getByRole('button', { name: '追加标签' })).toHaveProperty( + 'disabled', + true, + ); + const closeButton = screen.getByRole('button', { + name: '关闭批量追加标签', + }); + expect(closeButton).toHaveProperty('disabled', true); + await user.click(closeButton); + await user.keyboard('{Escape}'); + expect(onClose).not.toHaveBeenCalled(); + + rejectSave!(new Error('project-revision-conflict')); + + // 失败:草稿保留(可直接重试),原因是可读中文而不是错误码。 + await waitFor(() => + expect(screen.getByRole('alert').textContent).toBe( + '项目已被其它操作改动,请刷新后重试', + ), + ); + expect(draftPillLabels()).toEqual(['春节']); + expect(screen.getByRole('button', { name: '追加标签' })).toHaveProperty( + 'disabled', + false, + ); + }); + + test('待追加草稿可以逐项移除,移除的只是草稿不是素材已有标签', async () => { + const user = userEvent.setup(); + installInvoke(async () => undefined); + renderBatchPanel(); + + await user.type( + screen.getByPlaceholderText('新增标签,多个用逗号分隔'), + '春节,', + ); + await user.type( + screen.getByPlaceholderText('新增标签,多个用逗号分隔'), + '新春,', + ); + expect(draftPillLabels()).toEqual(['春节', '新春']); + + await user.click( + screen.getByRole('button', { name: '移除待追加标签 春节' }), + ); + expect(draftPillLabels()).toEqual(['新春']); + }); + + test('客户端外运行时不写盘,只给出提示', async () => { + const user = userEvent.setup(); + removeInvoke(); + renderBatchPanel(); + + await user.type( + screen.getByPlaceholderText('新增标签,多个用逗号分隔'), + '春节', + ); + await user.click(screen.getByRole('button', { name: '追加标签' })); + + expect(screen.getByRole('alert').textContent).toContain('需要在客户端内'); + }); + + test('保存进行中锁住输入与草稿删除,同一批只发一次请求,成功后草稿才清空', async () => { + const user = userEvent.setup(); + let resolveSave: ((value: unknown) => void) | null = null; + const invoke = installInvoke(async (command) => { + if (command === 'get_local_game_project_revision') { + return { revision: 3 }; + } + if (command === 'add_local_project_resource_tags') { + return new Promise((resolve) => { + resolveSave = resolve; + }); + } + throw new Error(`unexpected command: ${command}`); + }); + renderBatchPanel(); + + const field = screen.getByPlaceholderText( + '新增标签,多个用逗号分隔', + ) as HTMLInputElement; + await user.type(field, '春节,'); + expect(draftPillLabels()).toEqual(['春节']); + + // 同一事件里的第二次点击也不能再发一次:`saving` 的 state 还没渲染出来, + // 靠的是同步的 inFlight 锁。 + const submit = screen.getByRole('button', { name: '追加标签' }); + fireEvent.click(submit); + fireEvent.click(submit); + + await waitFor(() => expect(resolveSave).not.toBeNull()); + expect(batchTagWrites(invoke)).toHaveLength(1); + + // 保存在飞:新输入不会被这一批带上,成功后又会被清空,所以输入框与草稿删除都锁住。 + expect(field.disabled).toBe(true); + fireEvent.change(field, { target: { value: '保存中乱敲的标签' } }); + // 禁用状态下这次输入进不了受控 state:草稿没变,保存成功后输入框仍是空的。 + expect(draftPillLabels()).toEqual(['春节']); + + const removeButton = screen.getByRole('button', { + name: '移除待追加标签 春节', + }) as HTMLButtonElement; + expect(removeButton.disabled).toBe(true); + fireEvent.click(removeButton); + expect(draftPillLabels()).toEqual(['春节']); + + resolveSave!({ assets: [], committedProjectRevision: 4 }); + await waitFor(() => expect(draftPillLabels()).toEqual([])); + expect(field.value).toBe(''); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx b/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx new file mode 100644 index 000000000..81f031887 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCocosPreviewContract.test.tsx @@ -0,0 +1,461 @@ +// @vitest-environment jsdom +import { render, screen, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +// 浮层外壳(portal + 焦点陷阱)不在本用例关注面内:与文档预览浮层的用例同口径只渲染子节点。 +vi.mock('../src/components/modal/ThemedModal', () => ({ + ThemedModal: ({ + children, + ariaLabel, + }: { + children: ReactNode; + ariaLabel: string; + }) => ( +
+ {children} +
+ ), +})); + +import { + projectResourceCardPreviewKind, + projectResourceCardPreviewReadsContent, + projectResourceCocosStructureSummary, + projectResourceMediaPreviewCategory, + projectResourceStructuredPreviewText, +} from '../src/view/project-development/resourceCardPreviewModel'; +import { ResourceModelPreview } from '../src/view/project-development/ResourceModelPreview'; +import { ResourceModelPreviewDialog } from '../src/view/project-development/ResourceModelPreviewDialog'; +import { + resourceModelThumbnailCacheKey, + resourceModelThumbnailIdentity, +} from '../src/view/project-development/resourceModelThumbnail'; +import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel'; +import { + projectedResourceKind, + projectResourceTypeLabel, +} from '../src/view/project-development/resourceProjectionModel'; + +type EngineCase = { + path: string; + mediaType: string; + kind: string; + projectedKind: 'art' | 'audio' | 'code' | 'document'; + previewKind: + | 'model' + | 'structured' + | 'binary' + | 'media-image' + | 'audio' + | 'document' + | 'code'; + typeLabel: string; + readsContent: boolean; +}; + +/** + * Cocos Creator 资源在资源画布上的**准入 + 卡面分支 + 类型角标**三合一决策表。 + * + * 这张表是前后端三份扩展名表(原生发现 / 原生登记 / 前端投影)的交叉契约:表里任何一行 + * 对不上,都会表现为「登记得了但画布不显示」或「显示了但预览是坏图」。 + */ +const ENGINE_CASES: EngineCase[] = [ + { + path: 'assets/model/hero.glb', + mediaType: 'model/gltf-binary', + kind: 'scene', + projectedKind: 'art', + previewKind: 'model', + typeLabel: '模型', + readsContent: true, + }, + { + path: 'assets/model/hero.gltf', + mediaType: 'model/gltf+json', + kind: 'scene', + projectedKind: 'art', + previewKind: 'model', + typeLabel: '模型', + readsContent: true, + }, + { + path: 'assets/model/hero.fbx', + mediaType: 'application/octet-stream', + kind: 'scene', + projectedKind: 'art', + previewKind: 'model', + typeLabel: '模型', + readsContent: true, + }, + { + path: 'assets/model/hero.mesh', + mediaType: 'application/json', + kind: 'scene', + projectedKind: 'art', + previewKind: 'structured', + typeLabel: '模型', + readsContent: true, + }, + { + path: 'assets/model/hero.skeleton', + mediaType: 'application/json', + kind: 'scene', + projectedKind: 'art', + previewKind: 'structured', + typeLabel: '骨骼动画', + readsContent: true, + }, + { + path: 'assets/anim/walk.anim', + mediaType: 'application/json', + kind: 'character-animation', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '动画', + readsContent: true, + }, + { + path: 'assets/anim/graph.animgraph', + mediaType: 'application/json', + kind: 'character-animation', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '动画', + readsContent: true, + }, + { + path: 'assets/scene/main.scene', + mediaType: 'application/json', + kind: 'scene', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '场景', + readsContent: true, + }, + { + path: 'assets/scene/enemy.prefab', + mediaType: 'application/json', + kind: 'scene', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '预制体', + readsContent: true, + }, + { + path: 'assets/map/level.tmx', + mediaType: 'application/xml', + kind: 'scene', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '瓦片地图', + readsContent: true, + }, + { + path: 'assets/mtl/hero.mtl', + mediaType: 'application/json', + kind: 'code', + projectedKind: 'code', + previewKind: 'structured', + typeLabel: '材质', + readsContent: true, + }, + { + path: 'assets/shader/glow.effect', + mediaType: 'text/plain', + kind: 'code', + projectedKind: 'code', + previewKind: 'structured', + typeLabel: '特效', + readsContent: true, + }, + { + path: 'assets/atlas/hero.plist', + mediaType: 'application/xml', + kind: 'document', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '图集', + readsContent: true, + }, + { + path: 'assets/font/bitmap.fnt', + mediaType: 'text/plain', + kind: 'document', + projectedKind: 'document', + previewKind: 'structured', + typeLabel: '位图字体', + readsContent: true, + }, + { + path: 'assets/tex/grass.tga', + mediaType: 'image/x-tga', + kind: 'image', + projectedKind: 'art', + previewKind: 'media-image', + typeLabel: '图片', + readsContent: true, + }, + { + path: 'assets/tex/height.hdr', + mediaType: 'image/vnd.radiance', + kind: 'image', + projectedKind: 'art', + previewKind: 'media-image', + typeLabel: '图片', + readsContent: true, + }, + { + path: 'assets/tex/hero.texture', + mediaType: 'application/octet-stream', + kind: 'document', + projectedKind: 'document', + previewKind: 'binary', + typeLabel: '纹理', + readsContent: false, + }, + { + path: 'assets/spine/hero.skel', + mediaType: 'application/octet-stream', + kind: 'document', + projectedKind: 'document', + previewKind: 'binary', + typeLabel: '骨骼动画', + readsContent: false, + }, + { + path: 'assets/audio/voice.pcm', + mediaType: 'audio/pcm', + kind: 'audio', + projectedKind: 'audio', + previewKind: 'binary', + typeLabel: '音频片段', + readsContent: false, + }, +]; + +describe('Cocos 资源在资源画布上的准入与预览契约', () => { + it('每个引擎资源都能进画布、落到预期卡面分支与类型角标', () => { + for (const entry of ENGINE_CASES) { + const resource = { + id: entry.path, + path: entry.path, + mediaType: entry.mediaType, + subtype: entry.kind, + }; + expect( + projectedResourceKind({ + path: entry.path, + mediaType: entry.mediaType, + kind: entry.kind, + }), + `${entry.path} 必须能进画布`, + ).toBe(entry.projectedKind); + expect(projectResourceCardPreviewKind(resource), entry.path).toBe( + entry.previewKind, + ); + expect(projectResourceTypeLabel(resource), entry.path).toBe( + entry.typeLabel, + ); + expect( + projectResourceCardPreviewReadsContent(resource), + `${entry.path} 是否能读字节`, + ).toBe(entry.readsContent); + } + }); + + it('模型走 model 读取分支、图片容器走 art,二进制容器不读字节', () => { + expect( + projectResourceMediaPreviewCategory({ + id: 'glb', + path: 'assets/model/hero.glb', + mediaType: 'model/gltf-binary', + subtype: 'scene', + }), + ).toBe('model'); + expect( + projectResourceMediaPreviewCategory({ + id: 'tga', + path: 'assets/tex/grass.tga', + mediaType: 'image/x-tga', + subtype: 'image', + }), + ).toBe('art'); + }); +}); + +describe('Cocos 序列化资源的结构摘要', () => { + it('从序列化数组里抽出根类型、节点数、时长与资源引用', () => { + const summary = projectResourceCocosStructureSummary( + JSON.stringify([ + { __type__: 'cc.AnimationClip', _name: 'walk', _duration: 1.25 }, + { __type__: 'cc.Node', _name: 'root' }, + { __type__: 'cc.Node', _name: 'child' }, + { __uuid__: 'abc' }, + ]), + ); + expect(summary).toContain('cc.AnimationClip'); + expect(summary).toContain('2 个节点'); + expect(summary).toContain('1.25 秒'); + expect(summary).toContain('walk'); + expect(summary).toContain('1 处资源引用'); + }); + + it('不是序列化数组时不做结构摘要,回退到前几行文本', () => { + const effect = 'CCEffect %{\n techniques: []\n}'; + expect(projectResourceCocosStructureSummary(effect)).toBeNull(); + expect(projectResourceStructuredPreviewText(effect)).toContain('CCEffect'); + }); + + it('二进制变体(原生读不到正文)返回空串,卡面画类型卡', () => { + expect(projectResourceStructuredPreviewText(undefined)).toBe(''); + }); +}); + +describe('模型卡渲染失败时的降级', () => { + it('缩略图缓存键按稳定身份计算,不随 blob URL 变化', () => { + const identity = resourceModelThumbnailIdentity({ + resourceKey: 'asset:hero', + path: 'assets/model/hero.glb', + byteLen: 1024, + }); + const first = resourceModelThumbnailCacheKey({ + mediaType: 'model/gltf-binary', + identity, + width: 320, + height: 240, + }); + const second = resourceModelThumbnailCacheKey({ + mediaType: 'model/gltf-binary', + identity, + width: 320, + height: 240, + }); + expect(first).toBe(second); + // 同一份资源换了内容(字节数变化)或换了尺寸,都必须重新渲染。 + expect( + resourceModelThumbnailCacheKey({ + mediaType: 'model/gltf-binary', + identity: resourceModelThumbnailIdentity({ + resourceKey: 'asset:hero', + path: 'assets/model/hero.glb', + byteLen: 2048, + }), + width: 320, + height: 240, + }), + ).not.toBe(first); + expect( + resourceModelThumbnailCacheKey({ + mediaType: 'model/gltf-binary', + identity, + width: 480, + height: 360, + }), + ).not.toBe(first); + }); + + it('渲染不出缩略图时显示类型卡,不冒泡成预览失败', async () => { + // jsdom 没有 WebGL:这正是"渲染不可用"的真实路径,用来钉住降级行为。 + render( + , + ); + expect(screen.getByText('模型 · GLB')).toBeDefined(); + await waitFor(() => { + expect( + document.querySelector('[data-model-preview-status="failed"]'), + ).not.toBeNull(); + }); + }); +}); + +describe('模型放大预览浮层', () => { + const modelResource: ProjectResource = { + id: 'asset:model', + path: 'assets/model/cube.glb', + label: 'cube.glb', + mediaType: 'model/gltf-binary', + category: 'scene', + subtype: 'scene', + manifestAssetId: 'asset:model', + sourceLabel: '', + taskTitle: null, + producerTaskId: null, + externalResourceId: null, + referenceResourceIds: [], + dependencies: [], + dependencyDepth: 0, + }; + + it('打开即按详情理由请求字节,并给出与建模软件一致的视角操作提示', () => { + const onRequestPreview = vi.fn(); + render( + undefined} + />, + ); + expect(onRequestPreview).toHaveBeenCalledWith( + modelResource, + 'asset:model|assets/model/cube.glb|1548', + 'detail', + ); + expect(screen.getByRole('status').textContent).toContain('正在加载模型'); + expect(document.body.textContent).toContain('左键拖拽旋转'); + expect(document.body.textContent).toContain('滚轮缩放'); + expect(document.body.textContent).toContain('复位视角'); + }); + + it('渲染器不可用时明确说明,不假装已经渲染出模型', async () => { + // jsdom 没有 WebGL:这正是「无法渲染」的真实路径。 + render( + undefined} + onClose={() => undefined} + />, + ); + await waitFor(() => { + expect( + document.querySelector('[data-model-viewer-status="failed"]'), + ).not.toBeNull(); + }); + expect(document.body.textContent).toContain('无法渲染三维预览'); + }); + + it('预览失败时给出错误与重试,不留下空白浮层', () => { + render( + undefined} + onClose={() => undefined} + />, + ); + expect(screen.getByRole('alert').textContent).toContain('模型预览只支持'); + expect(screen.getByText('重试')).toBeDefined(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceDocumentPreviewModel.test.ts b/apps/ai-game-creator-shell/tests/resourceDocumentPreviewModel.test.ts index c89d79e99..00a6bd53e 100644 --- a/apps/ai-game-creator-shell/tests/resourceDocumentPreviewModel.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceDocumentPreviewModel.test.ts @@ -40,19 +40,21 @@ describe('resourceDocumentPreviewMarkdown', () => { ['game/main.ts', 'typescript'], ['game/main.js', 'javascript'], ['game/main.py', 'python'], + ['assets/data.json', 'json'], + ['assets/DESIGN.JSON', 'json'], ])('%s 包为 %s 代码块', (path, language) => { expect( resourceDocumentPreviewMarkdown(resource(path), ' source\n\n\n'), ).toBe(`\`\`\`${language}\n source\n\n\n\`\`\``); }); - it('JSON 规格按文档原文预览,不包成代码块', () => { + it('JSON 规格按原文代码块预览,不把字段值当 Markdown', () => { expect( resourceDocumentPreviewMarkdown( resource('assets/data.json'), - ' source\n\n\n', + '{ "text": "# 不应成为标题" }\n', ), - ).toBe(' source\n\n\n'); + ).toBe('```json\n{ "text": "# 不应成为标题" }\n```'); }); it('正文包含 Markdown 围栏时不会逃出代码块', () => { diff --git a/apps/ai-game-creator-shell/tests/resourceGenerationPromptTestUtils.ts b/apps/ai-game-creator-shell/tests/resourceGenerationPromptTestUtils.ts new file mode 100644 index 000000000..5d2def0a8 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceGenerationPromptTestUtils.ts @@ -0,0 +1,58 @@ +import { act, fireEvent, within } from '@testing-library/react'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + type LexicalEditor, +} from 'lexical'; + +type GenerationPromptField = HTMLTextAreaElement & { + __lexicalEditor?: LexicalEditor; +}; + +function generationPromptField(scope: HTMLElement) { + return within(scope).getByLabelText('生成提示词') as GenerationPromptField; +} + +/** + * 往生成面板的提示词输入区写一段文本。 + * + * 提示词输入区有**两种**实现,按入口不同而不同,这个 helper 两种都要支持: + * - 图片类入口(生成图片 / 生成规范 / 生成角色形象 / 生成 UI 设计图)用的是与聊天、资源快速编辑 + * 同一份 `@` 引用输入区(Lexical contenteditable)。jsdom 没有可用的 DOM Selection,Lexical 会 + * 忽略浏览器输入事件——`userEvent.type` 与 `beforeinput` 都不会落字,`fireEvent.change` 更不适用, + * 所以这条链路只能在编辑器实例上做等价更新:仍然走 Lexical 的 `update()` → `OnChangePlugin` → + * 面板状态。 + * - 纯文本入口(图集 / 图标规范 / 音效 / 背景音乐)仍然是普通 `textarea`,`fireEvent.change` 就是 + * 它真实的输入路径,不需要也不应该套用编辑器 API。 + * + * 两种路径断言的都是面板真正收到的提示词;浏览器里的真实输入由引用输入区自己的测试与实机验收覆盖。 + */ +export async function typeGenerationPrompt(scope: HTMLElement, text: string) { + const field = generationPromptField(scope); + const editor = field.__lexicalEditor; + if (!editor) { + await act(async () => { + fireEvent.change(field, { target: { value: text } }); + }); + return; + } + await act(async () => { + editor.update(() => { + const root = $getRoot(); + root.clear(); + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode(text)); + root.append(paragraph); + }); + }); +} + +/** 读回提示词输入区当前的文本:`textarea` 读 `value`,contenteditable 读文本内容。 */ +export function generationPromptText(scope: HTMLElement) { + const field = generationPromptField(scope) as GenerationPromptField & { + value?: string; + textContent?: string | null; + }; + return field.value ?? field.textContent ?? ''; +} diff --git a/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx b/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx new file mode 100644 index 000000000..7e04d094e --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceJsonCanvas.test.tsx @@ -0,0 +1,205 @@ +/** @vitest-environment jsdom */ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createGameCreationAppManifest, + fireEvent, + installResizeObserverStub, + ProjectDevelopmentView, + React, + render, + screen, + waitFor, + within, +} from './appSurface/harness'; + +vi.mock('@tauri-apps/api/core', async () => ({ + ...(await vi.importActual( + '@tauri-apps/api/core', + )), + invoke: (command: string, args?: Record) => + window.__TAURI__!.core.invoke(command, args), +})); + +const projectId = 'json-canvas'; +const projectPath = '/tmp/json-canvas'; +const emptyState = { + ui_trees: [], + ui_design_images: {}, + sprite_assets: {}, + font_assets: {}, +}; + +async function mountJsonCanvas(kind = 'ui', rejectPreview = false) { + installResizeObserverStub(); + const manifest = createGameCreationAppManifest(projectId, 'JSON 资源测试'); + manifest.assets = [ + { id: 'design', kind, localPath: 'ui/design.json' }, + { id: 'ordinary', kind: 'UI', localPath: 'assets/ordinary.json' }, + { id: 'spoof', kind: 'UI', localPath: 'ui/spoof.json' }, + ].map((asset) => ({ + ...asset, + mediaType: 'application/json', + category: 'document' as const, + source: { kind: 'generated' as const }, + })); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + const ids = (args?.resources as Array<{ resourceId: string }>).map( + (item) => item.resourceId, + ); + return { + resourceIds: ids, + referenceEdges: [], + taskFlows: [], + producerAssignments: [], + dependencyDepths: ids.map((resourceId) => ({ + resourceId, + dependencyDepth: 0, + })), + connectionIndex: ids.map((resourceId) => ({ + resourceId, + upstreamReferenceResourceIds: [], + downstreamReferenceResourceIds: [], + referenceEdgeIds: [], + taskFlowIds: [], + })), + unresolvedReferenceResourceIds: [], + cyclicResourceIds: [], + cyclicTaskIds: [], + producerMappingTruncated: false, + }; + } + if ( + command === 'read_local_project_resource_canvas_layout' || + command === 'update_local_project_resource_canvas_layout' + ) { + const layout = { + schemaVersion: 'game-creator-resource-layout.v1', + projectId, + mode: args?.mode, + revision: Number(args?.expectedRevision ?? 0) + 1, + positions: args?.positions ?? [], + updatedAt: 1, + }; + return command.startsWith('update_') + ? { status: 'updated', layout } + : layout; + } + if (command === 'read_local_project_text_preview') { + if (rejectPreview) throw new Error('文档读取失败'); + const path = args?.relativePath; + return { + path, + mediaType: 'application/json', + byteLen: 20, + content: + path === 'assets/ordinary.json' + ? '{"text":"# 不应当作标题"}' + : JSON.stringify({ + schemaVersion: 'game-creator-ui-design-state.v1', + state: emptyState, + }), + ...(path === 'ui/design.json' ? { uiDesignAssetId: 'design' } : {}), + }; + } + if (command === 'load_ui_design_state') + return { revision: 0, state: emptyState }; + if ( + command === 'list_pending_local_project_resource_edits' || + command === 'list_local_project_asset_generations' + ) + return []; + throw new Error(`unexpected command ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + const onManifestChange = vi.fn(); + render( + 对话} + onManifestChange={onManifestChange} + />, + ); + fireEvent.click(await screen.findByRole('button', { name: '打开文档' })); + const card = (id: string) => + document.querySelector( + `.game-resource-book-scene-card.is-expanded [data-resource-card-id="asset:${id}"]`, + )!; + await waitFor(() => + expect(card('design')?.getAttribute('data-preview-status')).toBe( + rejectPreview ? 'failed' : 'loaded', + ), + ); + return { card, invoke, onManifestChange }; +} + +afterEach(() => { + delete window.__TAURI__; +}); + +describe('JSON 画布卡片与入口', () => { + it.each(['ui', 'document'])( + '合法 State 的 %s 登记显示 UI 设计,进入现有编辑器且不修改 manifest', + async (kind) => { + const { card, invoke, onManifestChange } = await mountJsonCanvas(kind); + const design = card('design'); + expect(design.getAttribute('data-json-presentation')).toBe('ui-design'); + expect(within(design).getByText('UI 设计')).not.toBeNull(); + expect(within(design).getByText('UI 编辑器')).not.toBeNull(); + expect(design.textContent).not.toContain('schemaVersion'); + fireEvent.click(design.querySelector('button')!); + fireEvent.click(await screen.findByRole('button', { name: 'UI 编辑器' })); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('load_ui_design_state', { + input: { + projectPath, + expectedProjectId: projectId, + assetId: 'design', + }, + }), + ); + expect(onManifestChange).not.toHaveBeenCalled(); + }, + ); + + it('普通 JSON 和伪 UI 文本保持 JSON 展示,无编辑器入口,详情按代码渲染', async () => { + const { card } = await mountJsonCanvas(); + for (const id of ['ordinary', 'spoof']) { + const element = card(id); + expect(element.getAttribute('data-json-presentation')).toBe('json'); + expect( + element.querySelector('[data-resource-type="JSON"]'), + ).not.toBeNull(); + expect(element.textContent).not.toContain('schemaVersion'); + fireEvent.click(element.querySelector('button')!); + expect(screen.queryByRole('button', { name: 'UI 编辑器' })).toBeNull(); + } + fireEvent.click(card('ordinary').querySelector('button')!); + fireEvent.click(await screen.findByRole('button', { name: '预览' })); + const dialog = await screen.findByRole('dialog', { name: '文档预览' }); + await waitFor(() => + expect(dialog.querySelector('pre code')?.textContent).toContain( + '{"text":"# 不应当作标题"}', + ), + ); + expect( + within(dialog).queryByRole('heading', { name: '不应当作标题' }), + ).toBeNull(); + }); + + it('JSON 读取失败不授予编辑入口,仍可查看明确的读取错误', async () => { + const { card } = await mountJsonCanvas('UI', true); + fireEvent.click(card('design').querySelector('button')!); + expect(screen.queryByRole('button', { name: 'UI 编辑器' })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '预览' })); + expect(await screen.findByRole('alert')).not.toBeNull(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceJsonPresentation.test.ts b/apps/ai-game-creator-shell/tests/resourceJsonPresentation.test.ts new file mode 100644 index 000000000..ecc685ed7 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceJsonPresentation.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; + +import { + type ProjectResourceCardPreviewState, + projectResourceJsonPresentation, +} from '../src/view/project-development/resourceCardPreviewModel'; +import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel'; + +const resource: ProjectResource = { + id: 'asset:json', + manifestAssetId: 'json', + path: 'ui/design.json', + label: '设计', + mediaType: 'application/json', + subtype: 'UI', + category: 'document', + sourceLabel: '', + taskTitle: null, + producerTaskId: null, + externalResourceId: null, + referenceResourceIds: [], + dependencies: [], + dependencyDepth: 0, +}; +const verified: ProjectResourceCardPreviewState = { + status: 'loaded', + preview: { + path: resource.path, + mediaType: 'application/json', + byteLen: 2, + content: '{}', + uiDesignAssetId: 'json', + }, +}; + +describe('JSON 卡片呈现', () => { + it.each(['UI', 'ui', 'ui-design', 'document'])( + '合法识别不依赖 %s 标签', + (subtype) => { + expect( + projectResourceJsonPresentation({ ...resource, subtype }, verified), + ).toBe('ui-design'); + expect( + projectResourceJsonPresentation( + { ...resource, subtype }, + { + ...verified, + preview: { ...verified.preview, uiDesignAssetId: undefined }, + }, + ), + ).toBe('json'); + }, + ); + it('原生读取未完成、失败或身份不匹配时保持普通 JSON,不推断编辑能力', () => { + for (const preview of [ + null, + { status: 'loading' }, + { status: 'failed', error: '读取失败', retryable: true }, + ] as const) { + expect(projectResourceJsonPresentation(resource, preview)).toBe('json'); + } + expect( + projectResourceJsonPresentation( + { ...resource, manifestAssetId: 'other' }, + verified, + ), + ).toBe('json'); + expect( + projectResourceJsonPresentation( + { ...resource, path: 'other.json' }, + verified, + ), + ).toBe('json'); + expect( + projectResourceJsonPresentation( + { ...resource, manifestAssetId: null }, + verified, + ), + ).toBe('json'); + expect( + projectResourceJsonPresentation( + { ...resource, path: 'image.png', mediaType: 'image/png' }, + verified, + ), + ).toBeNull(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/resourceRename.test.tsx b/apps/ai-game-creator-shell/tests/resourceRename.test.tsx index 3846ae0bf..48f4e423b 100644 --- a/apps/ai-game-creator-shell/tests/resourceRename.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceRename.test.tsx @@ -231,11 +231,15 @@ describe('素材重命名前端链路', () => { rendered.rerenderWith(renamedManifest); await waitFor(() => { - expect( - screen.getByRole('button', { - name: '选中资源:角色与对象 hero-v2.png', - }), - ).not.toBeNull(); + const renamedSelect = screen.getByRole('button', { + name: '选中资源:角色与对象 hero-v2.png', + }); + const renamedCard = renamedSelect.closest('.game-resource-card'); + // 卡面名称与重命名后的 manifest `localPath` 同步:名称就是正式文件名的投影, + // 不存在第二份需要一起改的显示名;完整名挂在命中指针的选中按钮 `title` 上。 + const nameNode = renamedCard?.querySelector('.game-resource-card-name'); + expect(nameNode?.textContent).toBe('hero-v2.png'); + expect(renamedSelect.getAttribute('title')).toBe('hero-v2.png'); }); }); diff --git a/apps/ai-game-creator-shell/tests/resourceTagStatsRefresh.test.tsx b/apps/ai-game-creator-shell/tests/resourceTagStatsRefresh.test.tsx index b21591830..950c78f6e 100644 --- a/apps/ai-game-creator-shell/tests/resourceTagStatsRefresh.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceTagStatsRefresh.test.tsx @@ -89,9 +89,10 @@ function graphFor(manifest: GameCreationAppManifest) { * * 这里只把壳换成用例自己的 `useState`(壳那份 CAS 归并由 * `workspaceLauncherManifestMerge.test.tsx` 单独钉住),画布、聊天输入区、 - * 标签统计与候选全部是被测的真实实现。 + * 标签统计与候选全部是被测的真实实现。策划 Agent 会隐藏 @ 入口,这条链路钉住 + * game Agent 的既有行为。 */ -function TagStatsHost({ planningStartMode }: { planningStartMode: boolean }) { +function TagStatsHost() { const [manifest, setManifest] = useState(() => { const initial = createFixtureManifest(); disk.manifest = initial; @@ -117,7 +118,6 @@ function TagStatsHost({ planningStartMode }: { planningStartMode: boolean }) { initialProjectPath={PROJECT_PATH} initialProjectManifest={manifest} projectSupervisorOnly - planningStartMode={planningStartMode} onManifestChange={onManifestChange} /> } @@ -251,7 +251,7 @@ describe('改完素材标签后聊天 @ 选择器的标签统计与候选跟着 it('在资源画布改完标签保存后,聊天 @ 选择器出现该标签及其计数,并按它收窄候选', async () => { const { classificationWrites } = installHostTauri(); - render(); + render(); // 先在画布上打开「编辑素材标签」面板改标签:与用户的操作路径一致。 fireEvent.click( @@ -294,7 +294,7 @@ describe('改完素材标签后聊天 @ 选择器的标签统计与候选跟着 it('未标注标签时聊天 @ 选择器不渲染任何标签 chip', async () => { installHostTauri(); - render(); + render(); await openChatPicker(); expect(pickerTagChips()).toEqual([]); diff --git a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx index 380692d66..e8f2b5e4b 100644 --- a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx @@ -375,6 +375,16 @@ function renderReplacementWorkbench(options: RenderOptions = {}) { return { invoke, onActiveVersionChange, onPlay, onManifestChange }; } +function toolbarAction(toolbar: HTMLElement, name: string) { + const visible = within(toolbar).queryByRole('button', { name }); + if (visible) return visible; + fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' })); + return within(screen.getByRole('group', { name: '更多操作' })).getByRole( + 'button', + { name }, + ); +} + async function selectCardAndOpenToolbar(label: string) { await waitFor(() => expect( @@ -538,14 +548,80 @@ function installResourceCardIntersectionObserver() { } describe('版本级资源替换', () => { + it('更多浮层滚轮不平移画布,Escape 只收菜单且换选不残留', async () => { + renderReplacementWorkbench(); + const toolbar = await selectCardAndOpenToolbar('legacy.png'); + fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' })); + const menu = screen.getByRole('group', { name: '更多操作' }); + const viewport = () => + document + .querySelector('[data-resource-viewport]') + ?.getAttribute('data-resource-viewport'); + const before = viewport(); + expect(before).toBeTruthy(); + const wheel = new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaY: 120, + }); + act(() => { + menu.dispatchEvent(wheel); + }); + expect(wheel.defaultPrevented).toBe(false); + expect(viewport()).toBe(before); + + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull(); + expect(screen.getByRole('toolbar', { name: '图片工具栏' })).toBe(toolbar); + fireEvent.mouseEnter(within(toolbar).getByRole('button', { name: '更多' })); + fireEvent.click(await findResourceSelectButton('late.png')); + expect(screen.queryByRole('group', { name: '更多操作' })).toBeNull(); + + const scene = document.querySelector('.game-resource-book-scene')!; + act(() => { + scene.dispatchEvent( + new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaY: 120, + clientX: 90, + clientY: 70, + }), + ); + }); + expect(viewport()).not.toBe(before); + }); + + it('信息从未选中卡打开并跟随资源身份,普通换选会关闭', async () => { + renderReplacementWorkbench(); + await selectCardAndOpenToolbar('legacy.png'); + const lateInfo = screen.getByRole('button', { + name: '查看late.png资源信息', + }); + fireEvent.pointerDown(lateInfo, { button: 0 }); + fireEvent.click(lateInfo); + const panel = screen.getByRole('dialog', { name: '资源信息' }); + expect(within(panel).getByText('late.png')).toBeTruthy(); + expect(lateInfo.getAttribute('aria-pressed')).toBe('true'); + fireEvent.click( + screen.getByRole('button', { name: '查看legacy.png资源信息' }), + ); + const switched = screen.getByRole('dialog', { name: '资源信息' }); + expect(within(switched).getByText('legacy.png')).toBeTruthy(); + expect(within(switched).queryByText('late.png')).toBeNull(); + fireEvent.click(await findResourceSelectButton('late.png')); + expect(screen.queryByRole('dialog', { name: '资源信息' })).toBeNull(); + }); + it('入口只在素材被当前版本绑定时渲染,未绑定素材不给假按钮', async () => { const { invoke } = renderReplacementWorkbench(); // 未被初始版本绑定的素材(版本创建之后才登记):工具条照常出现,但没有「替换素材」。 const lateToolbar = await selectCardAndOpenToolbar('late.png'); - expect( - within(lateToolbar).queryByRole('button', { name: '替换素材' }), - ).toBeNull(); + fireEvent.mouseEnter( + within(lateToolbar).getByRole('button', { name: '更多' }), + ); + expect(screen.queryByRole('button', { name: '替换素材' })).toBeNull(); expect( within(lateToolbar).getByRole('button', { name: '快速编辑' }), ).not.toBeNull(); @@ -559,9 +635,7 @@ describe('版本级资源替换', () => { // 被当前版本绑定的素材:入口出现。 const sourceToolbar = await selectCardAndOpenToolbar('legacy.png'); - expect( - within(sourceToolbar).getByRole('button', { name: '替换素材' }), - ).not.toBeNull(); + expect(toolbarAction(sourceToolbar, '替换素材')).not.toBeNull(); }); it('从入口一路走到写入:候选弹窗禁用硬门禁项、给出格式提示、直接替换且不产生新版本', async () => { @@ -569,7 +643,7 @@ describe('版本级资源替换', () => { renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); await waitFor(() => expect( @@ -675,7 +749,7 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -709,7 +783,7 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); await waitFor(() => expect( @@ -735,7 +809,7 @@ describe('版本级资源替换', () => { const { invoke } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -787,7 +861,7 @@ describe('版本级资源替换', () => { ).length; const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -837,9 +911,13 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - const toolbarLabels = within(toolbar) - .getAllByRole('button') - .map((button) => button.getAttribute('aria-label') ?? ''); + const deleteButton = toolbarAction(toolbar, '删除素材'); + const toolbarLabels = [ + ...within(toolbar).getAllByRole('button'), + ...within(screen.getByRole('group', { name: '更多操作' })).getAllByRole( + 'button', + ), + ].map((button) => button.getAttribute('aria-label') ?? ''); // 末位:在最后一个非破坏性动作(替换素材)之后、共享导出按钮之前。 expect(toolbarLabels.indexOf('删除素材')).toBeGreaterThan( toolbarLabels.indexOf('替换素材'), @@ -848,9 +926,6 @@ describe('版本级资源替换', () => { toolbarLabels.indexOf('导出'), ); // 与前面隔开:紧邻的前一个兄弟就是共享工具条那套分隔线,不是新造的分隔符。 - const deleteButton = within(toolbar).getByRole('button', { - name: '删除素材', - }); const divider = deleteButton.previousElementSibling; expect(divider?.getAttribute('class')).toMatch( /(?:image-canvas-editor__floating-toolbar-divider|genarrative-image-canvas__chrome-button)/, @@ -914,7 +989,7 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '删除素材' })); + fireEvent.click(toolbarAction(toolbar, '删除素材')); const dialog = await screen.findByRole('dialog', { name: '确认删除资源' }); fireEvent.click( within(dialog).getByRole('checkbox', { @@ -976,7 +1051,7 @@ describe('版本级资源替换', () => { ).toBeNull(); // 同一条工具条仍在(只读动作不受 manifest 身份影响),证明不是"整条工具条没渲染"。 expect( - within(toolbar).getByRole('button', { name: '信息' }), + screen.getByRole('button', { name: '查看草稿.png资源信息' }), ).not.toBeNull(); }); @@ -1056,7 +1131,7 @@ describe('版本级资源替换', () => { const { invoke } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1084,7 +1159,7 @@ describe('版本级资源替换', () => { const { invoke, onManifestChange } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1155,7 +1230,7 @@ describe('版本级资源替换', () => { }); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1238,7 +1313,7 @@ describe('版本级资源替换', () => { renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1274,7 +1349,7 @@ describe('版本级资源替换', () => { const { invoke } = renderReplacementWorkbench(); const toolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click(within(toolbar).getByRole('button', { name: '替换素材' })); + fireEvent.click(toolbarAction(toolbar, '替换素材')); const dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1356,9 +1431,7 @@ describe('版本级资源替换', () => { // 第一次:legacy → final。 const legacyToolbar = await selectCardAndOpenToolbar('legacy.png'); - fireEvent.click( - within(legacyToolbar).getByRole('button', { name: '替换素材' }), - ); + fireEvent.click(toolbarAction(legacyToolbar, '替换素材')); let dialog = await screen.findByRole('dialog', { name: '选择替换素材', }); @@ -1376,9 +1449,7 @@ describe('版本级资源替换', () => { // 第二次:final → final.webp(同一个工作台会话内)。 const finalToolbar = await selectCardAndOpenToolbar('final.png'); - fireEvent.click( - within(finalToolbar).getByRole('button', { name: '替换素材' }), - ); + fireEvent.click(toolbarAction(finalToolbar, '替换素材')); dialog = await screen.findByRole('dialog', { name: '选择替换素材' }); fireEvent.click( within(dialog).getByRole('option', { name: '选择替换素材final.webp' }), diff --git a/apps/ai-game-creator-shell/tests/templateLibraryGrid.test.ts b/apps/ai-game-creator-shell/tests/templateLibraryGrid.test.ts new file mode 100644 index 000000000..2ddf16993 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/templateLibraryGrid.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildTemplateRows, + computeTemplateGridColumns, + computeTemplateGridLayout, + computeTemplateRowHeight, + TEMPLATE_CARD_GAP, + TEMPLATE_CARD_MIN_WIDTH, + TEMPLATE_CARD_TEXT_HEIGHT, +} from '../src/features/template-library/templateLibraryGrid'; +import type { GameTemplateEntry } from '../src/features/template-library/templateLibraryModel'; + +function entry(id: string): GameTemplateEntry { + return { + id, + title: id, + summary: '', + tags: [], + runtime: 'html', + engine: 'phaser', + engineVersion: '4.2.1', + templateVersion: '0.1.0', + updatedAt: '2026-09-17T00:00:00Z', + entry: 'game/index.html', + zipUrl: `https://oss.example/templates/v1/${id}/template.zip`, + zipSizeBytes: 1024, + zipSha256: 'a'.repeat(64), + coverUrl: `https://oss.example/templates/v1/${id}/cover.svg`, + coverWidth: 960, + coverHeight: 540, + installed: false, + installedVersion: null, + installedAtMillis: null, + }; +} + +describe('computeTemplateGridColumns', () => { + it('fits as many min-width columns as the container allows', () => { + expect(computeTemplateGridColumns(0)).toBe(1); + expect(computeTemplateGridColumns(200)).toBe(1); + expect(computeTemplateGridColumns(TEMPLATE_CARD_MIN_WIDTH)).toBe(1); + // 两列边界:2*250 + 14 = 514 + const twoColumnWidth = 2 * TEMPLATE_CARD_MIN_WIDTH + TEMPLATE_CARD_GAP; + expect(computeTemplateGridColumns(twoColumnWidth)).toBe(2); + expect(computeTemplateGridColumns(twoColumnWidth - 1)).toBe(1); + // 1200px:4 列((1200+14)/(250+14) = 4.59) + expect(computeTemplateGridColumns(1200)).toBe(4); + }); +}); + +describe('computeTemplateRowHeight', () => { + it('keeps cover ratio + fixed text block', () => { + // 列宽 300 → 卡片 286 → 封面 286*9/16 = 160.875 → 161 + expect(computeTemplateRowHeight(300)).toBe( + 161 + TEMPLATE_CARD_TEXT_HEIGHT + TEMPLATE_CARD_GAP, + ); + // 极窄时按最小卡片宽度兜底,避免行高被压成 0 + expect(computeTemplateRowHeight(10)).toBeGreaterThan( + TEMPLATE_CARD_TEXT_HEIGHT, + ); + }); +}); + +describe('computeTemplateGridLayout', () => { + it('derives columns, row height and row count for a big library', () => { + const layout = computeTemplateGridLayout({ + containerWidth: 1200, + itemCount: 1000, + }); + expect(layout.columnCount).toBe(4); + expect(layout.columnWidth).toBe(300); + expect(layout.rowHeight).toBe( + 161 + TEMPLATE_CARD_TEXT_HEIGHT + TEMPLATE_CARD_GAP, + ); + expect(layout.rowCount).toBe(250); + // 虚拟列表只渲染可视行,滚动高度仍由总行数决定 + expect(layout.rowCount * layout.rowHeight).toBeGreaterThan(80000); + }); + + it('handles inline and empty libraries', () => { + expect( + computeTemplateGridLayout({ containerWidth: 0, itemCount: 5 }), + ).toEqual({ + columnCount: 1, + columnWidth: 1, + rowHeight: computeTemplateRowHeight(1), + rowCount: 5, + }); + expect( + computeTemplateGridLayout({ containerWidth: 1200, itemCount: 0 }) + .rowCount, + ).toBe(0); + }); +}); + +describe('buildTemplateRows', () => { + it('chunks entries per row and pads the tail with nulls', () => { + const rows = buildTemplateRows([entry('a'), entry('b'), entry('c')], 2); + expect(rows).toHaveLength(2); + expect(rows[0]?.map((item) => item?.id)).toEqual(['a', 'b']); + expect(rows[1]?.map((item) => item?.id ?? null)).toEqual(['c', null]); + }); + + it('returns no rows for an invalid column count', () => { + expect(buildTemplateRows([entry('a')], 0)).toEqual([]); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts b/apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts new file mode 100644 index 000000000..5eb52ca01 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/templateLibraryModel.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from 'vitest'; + +import { + collectGameTemplateRuntimes, + collectGameTemplateTags, + EMPTY_TEMPLATE_LIBRARY_FILTERS, + filterGameTemplates, + formatGameTemplateSize, + type GameTemplateEntry, + isTemplateLibraryFiltersEmpty, + needsTemplateDownload, + templateMatchesQuery, + templateRuntimeLabel, + toggleGameTemplateTag, +} from '../src/features/template-library/templateLibraryModel'; + +function template( + overrides: Partial & Pick, +): GameTemplateEntry { + return { + title: '未命名模板', + summary: '', + tags: [], + runtime: 'html', + engine: 'phaser', + engineVersion: '4.2.1', + templateVersion: '1.0.0', + updatedAt: '2026-09-17T00:00:00Z', + entry: 'game/index.html', + zipUrl: + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/templates/v1/demo/template.zip', + zipSizeBytes: 2048, + zipSha256: 'a'.repeat(64), + coverUrl: + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/templates/v1/demo/cover.png', + coverWidth: 960, + coverHeight: 540, + installed: false, + installedVersion: null, + installedAtMillis: null, + ...overrides, + }; +} + +const matchThree = template({ + id: 'match-3', + title: '三消经营', + summary: '三消与模拟经营的融合模板', + tags: ['三消', '经营'], + engine: 'phaser', + installed: true, + installedVersion: '1.0.0', +}); +const pixelFarm = template({ + id: 'pixel-farm', + title: '像素农场', + summary: '像素风种植玩法', + tags: ['经营', '像素'], + engine: 'godot', + runtime: 'godot', + templateVersion: '2.0.0', + installed: true, + installedVersion: '1.0.0', +}); +const spaceShooter = template({ + id: 'space-shooter', + title: '太空射击', + summary: '纵版弹幕射击', + tags: ['射击'], + runtime: 'unity', + engine: 'unity', + installed: false, +}); + +const templates: GameTemplateEntry[] = [matchThree, pixelFarm, spaceShooter]; + +describe('templateMatchesQuery', () => { + it('matches title, summary, tags and engine case-insensitively', () => { + expect(templateMatchesQuery(matchThree, '三消')).toBe(true); + expect(templateMatchesQuery(matchThree, '经营')).toBe(true); + expect(templateMatchesQuery(matchThree, 'PHASER')).toBe(true); + expect(templateMatchesQuery(matchThree, '弹幕')).toBe(false); + }); + + it('requires every whitespace separated term to match', () => { + expect(templateMatchesQuery(pixelFarm, '像素 种植')).toBe(true); + expect(templateMatchesQuery(pixelFarm, '像素 弹幕')).toBe(false); + expect(templateMatchesQuery(pixelFarm, ' ')).toBe(true); + }); +}); + +describe('filterGameTemplates', () => { + it('filters by tag, runtime, query and installed state together', () => { + expect( + filterGameTemplates(templates, { + ...EMPTY_TEMPLATE_LIBRARY_FILTERS, + tags: ['经营'], + }).map((entry) => entry.id), + ).toEqual(['match-3', 'pixel-farm']); + + expect( + filterGameTemplates(templates, { + ...EMPTY_TEMPLATE_LIBRARY_FILTERS, + runtime: 'godot', + }).map((entry) => entry.id), + ).toEqual(['pixel-farm']); + + expect( + filterGameTemplates(templates, { + ...EMPTY_TEMPLATE_LIBRARY_FILTERS, + installedOnly: true, + query: '经营', + tags: ['像素'], + }).map((entry) => entry.id), + ).toEqual(['pixel-farm']); + }); + + it('returns everything when no filter is active', () => { + expect( + filterGameTemplates(templates, EMPTY_TEMPLATE_LIBRARY_FILTERS), + ).toHaveLength(3); + expect(isTemplateLibraryFiltersEmpty(EMPTY_TEMPLATE_LIBRARY_FILTERS)).toBe( + true, + ); + expect( + isTemplateLibraryFiltersEmpty({ + ...EMPTY_TEMPLATE_LIBRARY_FILTERS, + query: ' x ', + }), + ).toBe(false); + }); +}); + +describe('tag and runtime options', () => { + it('orders tags by frequency and drops blanks', () => { + const withBlank = [ + ...templates, + template({ id: 'blank-tag', tags: ['', ' ', '经营'] }), + ]; + expect(collectGameTemplateTags(withBlank)).toEqual([ + '经营', + '三消', + '射击', + '像素', + ]); + }); + + it('collects distinct runtimes and labels them', () => { + expect(collectGameTemplateRuntimes(templates)).toEqual([ + 'godot', + 'html', + 'unity', + ]); + expect(templateRuntimeLabel('html')).toBe('网页'); + expect(templateRuntimeLabel('cocos')).toBe('Cocos'); + expect(templateRuntimeLabel('')).toBe('未标注运行时'); + expect(templateRuntimeLabel('custom-engine')).toBe('custom-engine'); + }); + + it('toggles tags without mutating the previous filters', () => { + const next = toggleGameTemplateTag(EMPTY_TEMPLATE_LIBRARY_FILTERS, '经营'); + expect(next.tags).toEqual(['经营']); + expect(toggleGameTemplateTag(next, '经营').tags).toEqual([]); + expect(EMPTY_TEMPLATE_LIBRARY_FILTERS.tags).toEqual([]); + }); +}); + +describe('needsTemplateDownload', () => { + it('requires a download when missing or when the installed version is stale', () => { + expect(needsTemplateDownload(matchThree)).toBe(false); + expect(needsTemplateDownload(pixelFarm)).toBe(true); + expect(needsTemplateDownload(spaceShooter)).toBe(true); + }); +}); + +describe('formatGameTemplateSize', () => { + it('formats bytes, kilobytes and megabytes', () => { + expect(formatGameTemplateSize(0)).toBe('--'); + expect(formatGameTemplateSize(512)).toBe('512 B'); + expect(formatGameTemplateSize(2048)).toBe('2.0 KB'); + expect(formatGameTemplateSize(5 * 1024 * 1024)).toBe('5.0 MB'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx b/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx new file mode 100644 index 000000000..4182d8c1e --- /dev/null +++ b/apps/ai-game-creator-shell/tests/templateLibraryView.test.tsx @@ -0,0 +1,494 @@ +// @vitest-environment jsdom +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +import { computeTemplateGridLayout } from '../src/features/template-library/templateLibraryGrid'; +import type { + GameTemplateEntry, + TemplateLibraryFilters, +} from '../src/features/template-library/templateLibraryModel'; +import { + collectGameTemplateTags, + EMPTY_TEMPLATE_LIBRARY_FILTERS, + filterGameTemplates, +} from '../src/features/template-library/templateLibraryModel'; +import type { TemplateLibraryController } from '../src/features/template-library/useTemplateLibrary'; +import TemplateRecommendations from '../src/view/home/TemplateRecommendations'; +import TemplateLibraryView from '../src/view/template-library'; + +const OSS_BASE = 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com'; + +function template( + overrides: Partial & Pick, +): GameTemplateEntry { + return { + title: '未命名模板', + summary: '', + tags: [], + runtime: 'html', + engine: 'phaser', + engineVersion: '4.2.1', + templateVersion: '0.1.0', + updatedAt: '2026-09-17T00:00:00Z', + entry: 'game/index.html', + zipUrl: `${OSS_BASE}/templates/v1/${overrides.id}/template.zip`, + zipSizeBytes: 2048, + zipSha256: 'a'.repeat(64), + coverUrl: `${OSS_BASE}/templates/v1/${overrides.id}/cover.svg`, + coverWidth: 960, + coverHeight: 540, + installed: false, + installedVersion: null, + installedAtMillis: null, + ...overrides, + }; +} + +const blankWeb = template({ + id: 'blank-web', + title: '空白网页工程', + summary: '零依赖最小网页工程', + tags: ['空白', '网页'], + engine: 'none', + engineVersion: '', + installed: true, + installedVersion: '0.1.0', + installedAtMillis: 1789000000000, +}); +const blankCanvas = template({ + id: 'blank-2d-canvas', + title: '空白二维画布工程', + tags: ['空白', '2d', 'canvas'], + engine: 'canvas', + engineVersion: '', +}); +const templates = [blankCanvas, blankWeb]; + +function controller( + overrides: Partial = {}, +): TemplateLibraryController { + const filters: TemplateLibraryFilters = { + query: '', + tags: [], + runtime: '', + installedOnly: false, + }; + return { + snapshot: null, + status: 'ready', + error: '', + notice: '', + templates, + visibleTemplates: templates, + tagOptions: ['空白', '2d', 'canvas', '网页'], + runtimeOptions: ['html'], + installedCount: 1, + filters, + filtersActive: false, + setQuery: vi.fn(), + selectRuntime: vi.fn(), + toggleTag: vi.fn(), + setInstalledOnly: vi.fn(), + clearFilters: vi.fn(), + busyTemplateId: null, + busyKind: null, + refresh: vi.fn(), + downloadTemplate: vi.fn(async () => undefined), + createProjectFromTemplate: vi.fn(async () => undefined), + clearNotice: vi.fn(), + ...overrides, + } as unknown as TemplateLibraryController; +} + +function cardFor(title: string): HTMLElement { + const heading = screen.getByText(title); + const card = heading.closest('article'); + if (!card) throw new Error(`找不到卡片:${title}`); + return card; +} + +// 虚拟列表需要可测量的视口:jsdom 没有布局,这里给网格容器固定尺寸并补 ResizeObserver/scrollTo。 +const GRID_VIEWPORT = { width: 1200, height: 800 }; + +beforeAll(() => { + const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = function getBoundingClientRect() { + const element = this as HTMLElement; + if (element.dataset?.templateGridViewport === 'true') { + return { + width: GRID_VIEWPORT.width, + height: GRID_VIEWPORT.height, + top: 0, + left: 0, + right: GRID_VIEWPORT.width, + bottom: GRID_VIEWPORT.height, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect; + } + return originalGetBoundingClientRect.call(this); + }; + if (typeof Element.prototype.scrollTo !== 'function') { + Element.prototype.scrollTo = () => undefined; + } + class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} + } + vi.stubGlobal('ResizeObserver', ResizeObserverStub); +}); + +describe('TemplateLibraryView', () => { + it('renders a card per template with cover, meta, tags and installed badge', () => { + render( {}} />); + + expect(screen.getByRole('heading', { name: '模板库' })).toBeTruthy(); + expect(screen.getByText('共 2 个模板 · 已下载 1 个')).toBeTruthy(); + + const blankWebCard = cardFor('空白网页工程'); + const cover = blankWebCard.querySelector('img'); + expect(cover?.getAttribute('src')).toBe( + `${OSS_BASE}/templates/v1/blank-web/cover.svg`, + ); + expect(blankWebCard.textContent).toContain('已下载'); + expect(blankWebCard.textContent).toContain('网页 · none · v0.1.0 · 2.0 KB'); + expect(blankWebCard.textContent).toContain('空白'); + expect(blankWebCard.textContent).toContain('零依赖最小网页工程'); + // 已下载且版本一致:不再显示下载入口,只留「使用模板」。 + expect( + within(blankWebCard).queryByRole('button', { name: /下载/ }), + ).toBeNull(); + expect( + within(blankWebCard).getByRole('button', { name: /使用模板/ }), + ).toBeTruthy(); + + const blankCanvasCard = cardFor('空白二维画布工程'); + expect(blankCanvasCard.textContent).not.toContain('已下载'); + expect(blankCanvasCard.querySelector('img')?.getAttribute('src')).toBe( + `${OSS_BASE}/templates/v1/blank-2d-canvas/cover.svg`, + ); + expect( + within(blankCanvasCard).getByRole('button', { name: /下载/ }), + ).toBeTruthy(); + }); + + it('keeps the launcher theme contract and owns its own scroll viewport', () => { + // 回归点:`.launcher-main` 只给带 platform-theme 的直接子元素 height:100%; + // 卡片列表改由虚拟网格自己的视口滚动(整页不再随模板数量变长)。 + const { container } = render( + {}} />, + ); + const page = container.querySelector('section[aria-label="模板库"]'); + expect(page?.className).toContain('platform-theme'); + const viewport = container.querySelector( + '[data-template-grid-viewport="true"]', + ); + expect(viewport).not.toBeNull(); + expect(viewport?.querySelector('article')).not.toBeNull(); + }); + + it('offers 更新 instead of 下载 when the installed version is stale', () => { + const stale = template({ + id: 'blank-web', + title: '空白网页工程', + summary: '零依赖最小网页工程', + tags: ['空白', '网页'], + installed: true, + installedVersion: '0.0.9', + installedAtMillis: 1789000000000, + }); + render( + {}} + />, + ); + + const card = cardFor('空白网页工程'); + expect(within(card).getByRole('button', { name: /更新/ })).toBeTruthy(); + expect(within(card).queryByRole('button', { name: /^下载/ })).toBeNull(); + }); + + it('routes search, tag, runtime and installed-only controls through the controller', () => { + const setQuery = vi.fn(); + const toggleTag = vi.fn(); + const selectRuntime = vi.fn(); + const setInstalledOnly = vi.fn(); + const clearFilters = vi.fn(); + render( + {}} + />, + ); + + fireEvent.change(screen.getByLabelText('搜索模板'), { + target: { value: '空白 网页' }, + }); + expect(setQuery).toHaveBeenCalledWith('空白 网页'); + + fireEvent.click(screen.getByRole('button', { name: '标签筛选 canvas' })); + expect(toggleTag).toHaveBeenCalledWith('canvas'); + + fireEvent.click(screen.getByRole('button', { name: '运行时筛选 网页' })); + expect(selectRuntime).toHaveBeenCalledWith('html'); + + fireEvent.click(screen.getByRole('button', { name: '仅看已下载' })); + expect(setInstalledOnly).toHaveBeenCalledWith(true); + + fireEvent.click(screen.getByRole('button', { name: '清除筛选' })); + expect(clearFilters).toHaveBeenCalled(); + }); + + it('starts a download and a template project from the card actions', () => { + const downloadTemplate = vi.fn(async () => undefined); + const createProjectFromTemplate = vi.fn(async () => undefined); + render( + {}} + />, + ); + + fireEvent.click( + within(cardFor('空白二维画布工程')).getByRole('button', { name: /下载/ }), + ); + expect(downloadTemplate).toHaveBeenCalledWith(blankCanvas); + + fireEvent.click( + within(cardFor('空白二维画布工程')).getByRole('button', { + name: /使用模板/, + }), + ); + expect(createProjectFromTemplate).toHaveBeenCalledWith(blankCanvas); + }); + + it('disables card actions while a template is busy', () => { + render( + {}} + />, + ); + + const busyCard = cardFor('空白二维画布工程'); + const buttons = Array.from(busyCard.querySelectorAll('button')); + expect(buttons.every((button) => button.hasAttribute('disabled'))).toBe( + true, + ); + expect(busyCard.textContent).toContain('正在创建项目'); + expect(cardFor('空白网页工程').textContent).not.toContain('正在创建项目'); + }); + + it('shows empty, no-match, error and notice states', () => { + const { unmount } = render( + {}} + />, + ); + expect(screen.getByText('模板库暂时还没有可用的模板。')).toBeTruthy(); + unmount(); + + const { unmount: unmountNoMatch } = render( + {}} + />, + ); + expect(screen.getByText('没有符合当前筛选的模板')).toBeTruthy(); + unmountNoMatch(); + + render( + {}} + />, + ); + expect(screen.getByRole('alert').textContent).toContain( + '模板库返回 HTTP 503', + ); + expect( + screen.getByText('远端清单暂时读不到,当前展示本机缓存'), + ).toBeTruthy(); + }); + + it('renders process notices as a floating toast outside the page and auto-dismisses it', () => { + vi.useFakeTimers(); + const clearNotice = vi.fn(); + try { + const { container } = render( + {}} + />, + ); + + const toast = document.body.querySelector( + '[data-template-library-toast="true"]', + ); + expect(toast).not.toBeNull(); + expect(toast?.textContent).toContain('已下载模板「空白网页工程」'); + expect(toast?.querySelector('[role="status"]')?.textContent).toContain( + '已下载模板「空白网页工程」', + ); + // 提示不再占用页面内位置。 + expect( + container.querySelector('[data-template-library-toast]'), + ).toBeNull(); + + vi.advanceTimersByTime(2600); + expect(clearNotice).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('大库量渲染(1000 条假数据)', () => { + const bulk = Array.from({ length: 1000 }, (_, index) => + template({ + id: `bulk-${index}`, + title: `批量模板 ${index}`, + summary: '压测条目', + tags: ['起步工程', `批次-${String(index % 20).padStart(2, '0')}`], + installed: index % 3 === 0, + installedVersion: index % 3 === 0 ? '0.1.0' : null, + }), + ); + + it('virtualizes a 1000 entries library instead of rendering every card', () => { + const { container } = render( + entry.installed).length, + tagOptions: collectGameTemplateTags(bulk), + })} + onBack={() => {}} + />, + ); + + // 虚拟列表只渲染可视区域(1200×800 视口 → 4 列 × 约 3 行 + 2 行 overscan)。 + const renderedCards = container.querySelectorAll('article').length; + expect(renderedCards).toBeGreaterThan(0); + expect(renderedCards).toBeLessThanOrEqual(40); + expect(screen.getByText('共 1000 个模板 · 已下载 334 个')).toBeTruthy(); + // 滚动高度仍按全部行数计算。 + const layout = computeTemplateGridLayout({ + containerWidth: GRID_VIEWPORT.width, + itemCount: bulk.length, + }); + expect(layout.columnCount).toBe(4); + expect(layout.rowCount).toBe(250); + const totalHeight = `${250 * layout.rowHeight}px`; + const hasSpacer = Array.from(container.querySelectorAll('div')).some( + (element) => (element as HTMLElement).style.height === totalHeight, + ); + expect(hasSpacer).toBe(true); + // 标签筛选条会随库量膨胀,这里先记录当前聚合出来的规模(1000 条 × 批次标签)。 + const tagButtons = screen + .getAllByRole('button') + .filter((button) => + button.getAttribute('aria-label')?.startsWith('标签筛选'), + ); + expect(tagButtons.length).toBeGreaterThan(20); + + // 纯前端筛选在大库量下仍然是 O(n) 的一遍过滤,数量与已安装态自洽。 + const installedOnly = filterGameTemplates(bulk, { + ...EMPTY_TEMPLATE_LIBRARY_FILTERS, + installedOnly: true, + }); + expect(installedOnly).toHaveLength(334); + expect( + filterGameTemplates(bulk, { + ...EMPTY_TEMPLATE_LIBRARY_FILTERS, + tags: ['批次-07'], + }), + ).toHaveLength(50); + expect( + filterGameTemplates(bulk, { + ...EMPTY_TEMPLATE_LIBRARY_FILTERS, + query: '批量模板 999', + }).map((entry) => entry.id), + ).toEqual(['bulk-999']); + }); +}); + +describe('TemplateRecommendations', () => { + it('renders the recommended templates and opens the library', () => { + const onOpenLibrary = vi.fn(); + render( + , + ); + + const recommendation = screen.getByRole('button', { + name: '查看模板 空白网页工程', + }); + expect(recommendation.querySelector('img')?.getAttribute('src')).toBe( + `${OSS_BASE}/templates/v1/blank-web/cover.svg`, + ); + expect(recommendation.textContent).toContain('已下载'); + fireEvent.click(recommendation); + expect(onOpenLibrary).toHaveBeenCalled(); + }); + + it('falls back to an empty state with a library entry', () => { + const onOpenLibrary = vi.fn(); + render( + , + ); + + expect(screen.getByText('需要在陶泥儿客户端内运行')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '打开模板库' })); + expect(onOpenLibrary).toHaveBeenCalled(); + }); + + it('renders a loading state before the first snapshot arrives', () => { + render( + {}} + />, + ); + expect(screen.getByText('正在读取模板库…')).toBeTruthy(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index ebe0dd9ea..c4c2c1f53 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -2,6 +2,7 @@ import { act, + cleanup, fireEvent, render, renderHook, @@ -9,7 +10,7 @@ import { waitFor, } from '@testing-library/react'; import { createElement, type ReactNode } from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })); @@ -40,13 +41,21 @@ import UiEditorPage from '../src/view/ui-editor'; import { useUiEditorSession } from '../src/view/ui-editor/useUiEditorPage'; class TestResizeObserver { - constructor(_callback: ResizeObserverCallback) {} - observe() {} + constructor(private callback: ResizeObserverCallback) {} + observe(target: Element) { + this.callback( + [ + { + target, + contentRect: { width: 800, height: 600 }, + } as ResizeObserverEntry, + ], + this as unknown as ResizeObserver, + ); + } disconnect() {} } -vi.stubGlobal('ResizeObserver', TestResizeObserver); - const EMPTY_SNAPSHOT: UiDesignStateSnapshot = { revision: 0, state: { @@ -152,6 +161,16 @@ async function renderLoadedSession(state: State) { } describe('UiEditorPage', () => { + beforeEach(() => { + vi.stubGlobal('ResizeObserver', TestResizeObserver); + vi.mocked(invoke).mockReset().mockResolvedValue(undefined); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + it('keeps the wallet entry in the resource editor header', async () => { const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)), @@ -190,10 +209,7 @@ describe('UiEditorPage', () => { } return undefined; }); - Object.defineProperty(window, '__TAURI__', { - configurable: true, - value: { core: { invoke } }, - }); + vi.stubGlobal('__TAURI__', { core: { invoke } }); const stateStore: IUiDesignStateStore = { load: vi.fn().mockResolvedValue({ revision: 0, @@ -620,8 +636,13 @@ describe('UiEditorPage', () => { }), ); - const child = await screen.findByText('page-child'); - fireEvent.click(child); + await waitFor(() => { + expect( + screen.getByRole('button', { name: '保存' }).hasAttribute('disabled'), + ).toBe(false); + }); + fireEvent.click(screen.getByText('page-child')); + await screen.findByDisplayValue('page-child'); fireEvent.keyDown(child, { key: 'Delete' }); await waitFor(() => expect(screen.queryByText('page-child')).toBeNull()); @@ -644,8 +665,13 @@ describe('UiEditorPage', () => { }), ); - const child = await screen.findByText('page-child'); - fireEvent.click(child); + await waitFor(() => { + expect( + screen.getByRole('button', { name: '保存' }).hasAttribute('disabled'), + ).toBe(false); + }); + fireEvent.click(screen.getByText('page-child')); + await screen.findByDisplayValue('page-child'); const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); document.body.appendChild(dialog); diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts index f00f6357b..4f8f84402 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts @@ -1546,3 +1546,1006 @@ describe('布局落盘 / 读盘失败的原因不再被吞掉', () => { expect(logged[0]).not.toContain('\n'); }); }); + +/** + * 「整理画布」与批量坐标写入的 hook 级口径。 + * + * 整理只作用于目标栏目:栏内**全部**素材(含手动摆放过的卡)一起重算成自动坐标,其他栏目 + * 逐值不动;整次整理仍是一笔 CAS。手动写入支持一次多张卡(含手动标记),因此多选拖动与 + * 撤销恢复都不会退化成"每卡一笔"。 + */ +describe('资源画布整理与批量坐标写入', () => { + function characterResource(id: string): ResourceCanvasItem { + return { ...resource(id), category: 'character' }; + } + + function typeLayoutHarness( + initialPositions: ProjectResourceCanvasPosition[], + ) { + const updates: ProjectResourceCanvasPosition[][] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_canvas_layout') { + return persistedLayout('type', 7, structuredClone(initialPositions)); + } + if (command === 'update_local_project_resource_canvas_layout') { + const positions = structuredClone( + args?.positions as ProjectResourceCanvasPosition[], + ); + updates.push(positions); + return { + status: 'updated', + layout: persistedLayout('type', 8, positions), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + return { invoke, updates }; + } + + /** + * 等读盘之后的协调写回彻底落地再清空记账:打开项目那次"归并 / 补位"写回与本次要验的 + * 整理、多选写入是两回事,混在一起数笔数会把既有口径算成本次行为。 + */ + async function settleInitialWrites( + result: { current: { settled: boolean } }, + updates: ProjectResourceCanvasPosition[][], + ) { + await waitFor(() => expect(result.current.settled).toBe(true)); + updates.length = 0; + } + + /** + * 可挂起的 type 侧 harness:`holdNextWrite()` 之后的那一笔 update 会停在途上,直到 + * `releaseHeldWrite()` 放行。用来复现"上一笔还没落盘、用户又按了整理"的真实窗口。 + */ + function gatedTypeLayoutHarness( + initialPositions: ProjectResourceCanvasPosition[], + ) { + const updates: ProjectResourceCanvasPosition[][] = []; + let heldWrite: (() => void) | null = null; + let holdNext = false; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_canvas_layout') { + return persistedLayout('type', 7, structuredClone(initialPositions)); + } + if (command === 'update_local_project_resource_canvas_layout') { + if (holdNext) { + holdNext = false; + await new Promise((resolve) => { + heldWrite = resolve; + }); + } + const positions = structuredClone( + args?.positions as ProjectResourceCanvasPosition[], + ); + updates.push(positions); + return { + status: 'updated', + layout: persistedLayout('type', 8 + updates.length, positions), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + return { + invoke, + updates, + holdNextWrite: () => { + holdNext = true; + }, + releaseHeldWrite: () => { + heldWrite?.(); + }, + }; + } + + it('整理栏目时连手动坐标一起重算,其他栏目逐值不动', async () => { + const documentA = resource('resource-doc-a'); + const documentB = { ...resource('resource-doc-b'), dependencyDepth: 1 }; + const characterA = characterResource('resource-char-a'); + const { updates } = typeLayoutHarness([ + position('resource-doc-a', 600, 40), + automaticPosition('resource-doc-b', 900, 900), + { ...position('resource-char-a', 777, 55), section: 'character' }, + ]); + + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'type', + resources: [documentA, documentB, characterA], + }), + ); + await settleInitialWrites(result, updates); + + act(() => result.current.organizeNow(['document'])); + + await waitFor(() => expect(updates).toHaveLength(1)); + expect(updates[0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'resource-doc-a', + section: 'document', + x: 0, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-doc-b', + section: 'document', + x: 196, + y: 0, + manuallyPlaced: false, + }), + // 其他栏目:手动坐标与手动标记原样保留。 + expect.objectContaining({ + resourceId: 'resource-char-a', + section: 'character', + x: 777, + y: 55, + manuallyPlaced: true, + }), + ]), + ); + }); + + it('整理不重排其他栏目的自动坐标:栏外坐标逐值原样保留', async () => { + const documentA = resource('resource-doc-a'); + /** + * 其他栏目的自动卡故意放在「非规范槽位」上:这类坐标在真实项目里来自更早版本的排布、 + * 或素材被删后留下的既有位置。整理当前栏目只允许丢**目标栏目**的坐标,因此这些卡 + * 必须逐值保留,而不是被丢进重算后按空栏目规整回 (0,0)。 + */ + const characterA = characterResource('resource-char-auto-a'); + const characterB = characterResource('resource-char-auto-b'); + const { updates } = typeLayoutHarness([ + position('resource-doc-a', 600, 40), + { + ...automaticPosition('resource-char-auto-a', 900, 900), + section: 'character', + }, + { + ...automaticPosition('resource-char-auto-b', 1100, 940), + section: 'character', + }, + ]); + + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'type', + resources: [documentA, characterA, characterB], + }), + ); + await settleInitialWrites(result, updates); + + act(() => result.current.organizeNow(['document'])); + + await waitFor(() => expect(updates).toHaveLength(1)); + expect(updates[0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'resource-doc-a', + x: 0, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-char-auto-a', + section: 'character', + x: 900, + y: 900, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-char-auto-b', + section: 'character', + x: 1100, + y: 940, + manuallyPlaced: false, + }), + ]), + ); + }); + + it('「所有资源」范围(null)整理全部栏目,不留下手动标记', async () => { + const documentA = resource('resource-doc-a'); + const characterA = characterResource('resource-char-a'); + const { updates } = typeLayoutHarness([ + position('resource-doc-a', 600, 40), + { + ...automaticPosition('resource-char-a', 900, 900), + section: 'character', + }, + ]); + + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'type', + resources: [documentA, characterA], + }), + ); + await settleInitialWrites(result, updates); + + act(() => result.current.organizeNow(null)); + + await waitFor(() => expect(updates).toHaveLength(1)); + expect(updates[0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'resource-doc-a', + section: 'document', + x: 0, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-char-a', + section: 'character', + x: 0, + y: 0, + manuallyPlaced: false, + }), + ]), + ); + }); + + /** + * 排队中的「整理画布」不能被「关系图首次就绪」那一次重算覆盖。 + * + * 两种重算共用同一条写队列:显式整理是用户动作、首次就绪是系统动作,各自占一个队列槽。 + * 系统请求若就地改写用户已经按下、还没落盘的那一笔,用户会看到整理生效、落盘却把手动卡 + * 留在原地(整理被悄悄降级成"只丢自动坐标")。 + */ + it('排队中的整理不会被「关系图首次就绪」覆盖:两者各占一个队列槽', async () => { + const documentA = resource('resource-doc-a'); + const documentB = resource('resource-doc-b'); + const { updates, holdNextWrite, releaseHeldWrite } = gatedTypeLayoutHarness( + [ + position('resource-doc-a', 600, 40), + automaticPosition('resource-doc-b', 900, 900), + ], + ); + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'type', + resources: [documentA, documentB], + }), + ); + await waitFor(() => expect(result.current.settled).toBe(true)); + updates.length = 0; + + // 让一笔手动写入停在途上:后面的整理只能排队——正是「整理已按下、还没落盘」的窗口。 + holdNextWrite(); + act(() => { + result.current.commitPosition('resource-doc-a', 'document', 111, 222); + }); + act(() => { + expect(result.current.organizeNow(['document'])).toBe(true); + }); + // 关系图首次就绪的重算在这个窗口里进来。 + act(() => { + result.current.rederiveNow(); + }); + await act(async () => { + releaseHeldWrite(); + await Promise.resolve(); + }); + + await waitFor(() => expect(updates.length).toBeGreaterThan(1)); + // 整理那一笔照原样落盘:栏内两张卡都重算成自动坐标,手动坐标不残留。 + expect(updates[1]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'resource-doc-a', + x: 0, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-doc-b', + x: 196, + y: 0, + manuallyPlaced: false, + }), + ]), + ); + // 首次就绪那一笔退化成空操作:整理后已经是自动坐标,不再多写一次 CAS。 + expect(updates).toHaveLength(2); + }); + + /** + * 两栏各按一次「整理画布」:必须是**两笔独立意图**,各占一个队列槽、按按压顺序落盘, + * 后一栏不得覆盖前一栏。 + * + * 触发窗口是真实的:上一笔还在途(拖动落点没写回)时按下第一栏整理,随后切到第二栏 + * 再按一次。**覆盖式**的合并键(只判"是不是整理")会把第一栏那笔的目标范围改写成第二栏, + * 于是画面上第一栏已经被乐观重排过、磁盘上却只剩第二栏的整理。两笔各自落盘同时保证 + * 两栏各自对应一次可撤销操作:调用方一次按压只记一条历史,这里用"一笔一次写入、顺序不变" + * 把这条 hook 级契约钉住。 + */ + it('不同栏目的整理各占一个队列槽:在途手动写之后两栏先后落盘,前一栏不被后一栏覆盖', async () => { + const documentA = resource('resource-doc-a'); + const documentB = resource('resource-doc-b'); + const characterA = characterResource('resource-char-a'); + const characterB = characterResource('resource-char-b'); + const { updates, holdNextWrite, releaseHeldWrite } = gatedTypeLayoutHarness( + [ + position('resource-doc-a', 600, 40), + automaticPosition('resource-doc-b', 900, 900), + { ...position('resource-char-a', 777, 55), section: 'character' }, + { + ...automaticPosition('resource-char-b', 1200, 1300), + section: 'character', + }, + ], + ); + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'type', + resources: [documentA, documentB, characterA, characterB], + }), + ); + await waitFor(() => expect(result.current.settled).toBe(true)); + updates.length = 0; + + // 拖动落点停在途上:两次整理都只能排队——正是覆盖式合并会把前一栏吃掉的窗口。 + holdNextWrite(); + act(() => { + result.current.commitPosition('resource-doc-a', 'document', 111, 222); + }); + let documentOrganized = false; + let characterOrganized = false; + act(() => { + documentOrganized = result.current.organizeNow(['document']); + }); + act(() => { + characterOrganized = result.current.organizeNow(['character']); + }); + // 两次按压都真的排进了队列:调用方据此各记一条撤销历史。 + expect(documentOrganized).toBe(true); + expect(characterOrganized).toBe(true); + + await act(async () => { + releaseHeldWrite(); + await Promise.resolve(); + }); + await waitFor(() => expect(updates).toHaveLength(3)); + + // 第一笔:拖动落点先落盘。 + expect( + updates[0]?.find((entry) => entry.resourceId === 'resource-doc-a'), + ).toMatchObject({ x: 111, y: 222, manuallyPlaced: true }); + + // 第二笔:只整理 document,character 的手动/自动坐标逐值不动。 + expect(updates[1]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'resource-doc-a', + section: 'document', + x: 0, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-doc-b', + section: 'document', + x: 196, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-char-a', + section: 'character', + x: 777, + y: 55, + manuallyPlaced: true, + }), + expect.objectContaining({ + resourceId: 'resource-char-b', + section: 'character', + x: 1200, + y: 1300, + manuallyPlaced: false, + }), + ]), + ); + + // 第三笔:整理 character,且**保留**第一栏已经落盘的整理结果。 + expect(updates[2]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'resource-doc-a', + section: 'document', + x: 0, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-char-a', + section: 'character', + x: 0, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-char-b', + section: 'character', + x: 196, + y: 0, + manuallyPlaced: false, + }), + ]), + ); + // 最终画布上两栏都是整理后的自动坐标。 + expect( + result.current.layout.positions.filter((entry) => !entry.manuallyPlaced), + ).toHaveLength(4); + }); + + /** + * 同一栏连续按两次整理仍然只占一个队列槽(结果同源,重复按压不该多写一笔 CAS), + * 合并是**就地**的:它不会把自己排到队尾,先按下的手动落点依旧先落盘。 + */ + it('同一栏目连续按两次整理合并成一笔,且不改排在途写入之后的顺序', async () => { + const documentA = resource('resource-doc-a'); + const documentB = resource('resource-doc-b'); + const { updates, holdNextWrite, releaseHeldWrite } = gatedTypeLayoutHarness( + [ + position('resource-doc-a', 600, 40), + automaticPosition('resource-doc-b', 900, 900), + ], + ); + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'type', + resources: [documentA, documentB], + }), + ); + await waitFor(() => expect(result.current.settled).toBe(true)); + updates.length = 0; + + holdNextWrite(); + act(() => { + result.current.commitPosition('resource-doc-a', 'document', 111, 222); + }); + act(() => { + expect(result.current.organizeNow(['document'])).toBe(true); + }); + act(() => { + expect(result.current.organizeNow(['document'])).toBe(true); + }); + + await act(async () => { + releaseHeldWrite(); + await Promise.resolve(); + }); + // 只有两笔:在途手动落点 + 一次合并后的整理。 + await waitFor(() => expect(updates).toHaveLength(2)); + expect(updates[1]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'resource-doc-a', + section: 'document', + x: 0, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-doc-b', + section: 'document', + x: 196, + y: 0, + manuallyPlaced: false, + }), + ]), + ); + }); + + /** + * 排队中的整理属于它被按下时的那个 scope:切 mode(或切项目)时随旧队列一起取消, + * 绝不写进新 scope。判据是"旧 scope 只留下那一笔在途写入",没有额外的整理写入。 + */ + it('整理排队后又手动移动同一栏、再按整理:最后一次整理必须排在手动写入之后落盘', async () => { + const documentA = resource('resource-doc-a'); + const documentB = resource('resource-doc-b'); + const { updates, holdNextWrite, releaseHeldWrite } = gatedTypeLayoutHarness( + [ + position('resource-doc-a', 600, 40), + automaticPosition('resource-doc-b', 900, 900), + ], + ); + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'type', + resources: [documentA, documentB], + }), + ); + await waitFor(() => expect(result.current.settled).toBe(true)); + updates.length = 0; + + // 第一笔手动落点停在途上,后面三次动作只能排队。 + holdNextWrite(); + act(() => { + result.current.commitPosition('resource-doc-a', 'document', 111, 222); + }); + // ① 整理排进队列;② 用户又把同一张卡手动挪走;③ 再按一次整理。 + act(() => { + expect(result.current.organizeNow(['document'])).toBe(true); + }); + act(() => { + result.current.commitPosition('resource-doc-a', 'document', 333, 444); + }); + act(() => { + expect(result.current.organizeNow(['document'])).toBe(true); + }); + + await act(async () => { + releaseHeldWrite(); + await Promise.resolve(); + }); + // 四笔,顺序就是用户按下/松手的顺序:手动 → 整理 → 手动 → 整理。 + // 任何把第三次动作合并回**队首那个同名整理**的实现都会少一笔,并让最后落盘的 + // 变成中间那次手动坐标——用户最后按下的整理被静默吞掉。 + await waitFor(() => expect(updates).toHaveLength(4)); + expect( + updates[0]?.find((entry) => entry.resourceId === 'resource-doc-a'), + ).toMatchObject({ x: 111, y: 222, manuallyPlaced: true }); + expect( + updates[1]?.find((entry) => entry.resourceId === 'resource-doc-a'), + ).toMatchObject({ x: 0, y: 0, manuallyPlaced: false }); + // 中间那次手动移动没有被吞掉:它照样排在队首整理之后落盘。 + expect( + updates[2]?.find((entry) => entry.resourceId === 'resource-doc-a'), + ).toMatchObject({ x: 333, y: 444, manuallyPlaced: true }); + // 最后一次整理在手动写入之后落盘,最终布局是自动坐标。 + expect( + updates[3]?.find((entry) => entry.resourceId === 'resource-doc-a'), + ).toMatchObject({ x: 0, y: 0, manuallyPlaced: false }); + expect( + result.current.layout.positions.find( + (entry) => entry.resourceId === 'resource-doc-a', + ), + ).toMatchObject({ x: 0, y: 0, manuallyPlaced: false }); + }); + + /** + * 排队中的整理属于它被按下时的那个 scope:切 mode(或切项目)时随旧队列一起取消, + * 绝不写进新 scope。判据是"旧 scope 只留下那一笔在途写入",没有额外的整理写入。 + */ + it('切 mode 时排队中的整理随旧 scope 一起取消,不写进新 scope', async () => { + const documentA = resource('resource-doc-a'); + const documentB = resource('resource-doc-b'); + const typeWrites: ProjectResourceCanvasPosition[][] = []; + const dependencyWrites: ProjectResourceCanvasPosition[][] = []; + let releaseHeldTypeWrite: (() => void) | null = null; + let holdNextTypeWrite = false; + const invoke = vi.fn( + async (command: string, args?: Record) => { + const mode = args?.mode as ProjectResourceCanvasLayoutMode; + if (command === 'read_local_project_resource_canvas_layout') { + return persistedLayout(mode, mode === 'type' ? 7 : 3, [ + position('resource-doc-a', 600, 40), + automaticPosition('resource-doc-b', 900, 900), + ]); + } + if (command === 'update_local_project_resource_canvas_layout') { + const positions = structuredClone( + args?.positions as ProjectResourceCanvasPosition[], + ); + if (mode === 'dependency') { + dependencyWrites.push(positions); + return { + status: 'updated', + layout: persistedLayout( + 'dependency', + 4 + dependencyWrites.length, + positions, + ), + }; + } + if (holdNextTypeWrite) { + holdNextTypeWrite = false; + await new Promise((resolve) => { + releaseHeldTypeWrite = resolve; + }); + } + typeWrites.push(positions); + return { + status: 'updated', + layout: persistedLayout('type', 8 + typeWrites.length, positions), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const { result, rerender } = renderHook( + ({ mode }: { mode: ProjectResourceCanvasLayoutMode }) => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode, + resources: [documentA, documentB], + }), + { initialProps: { mode: 'type' as const } }, + ); + await waitFor(() => expect(result.current.settled).toBe(true)); + typeWrites.length = 0; + + // 拖动落在途上,随后按下整理:整理只能排队,旧 scope 的队列这时还没被取消。 + holdNextTypeWrite = true; + act(() => { + result.current.commitPosition('resource-doc-a', 'document', 111, 222); + }); + act(() => { + expect(result.current.organizeNow(['document'])).toBe(true); + }); + + // 切到另一个 mode:旧 scope 的排队意图必须一起作废。 + rerender({ mode: 'dependency' }); + await act(async () => { + releaseHeldTypeWrite?.(); + await Promise.resolve(); + }); + await waitFor(() => expect(result.current.layout.mode).toBe('dependency')); + await waitFor(() => expect(result.current.saving).toBe(false)); + + // 旧 scope 只留下那笔在途的手动写入,排队中的整理没有跟着落盘。 + expect(typeWrites).toHaveLength(1); + expect( + typeWrites[0]?.find((entry) => entry.resourceId === 'resource-doc-a'), + ).toMatchObject({ x: 111, y: 222, manuallyPlaced: true }); + expect(result.current.layout.mode).toBe('dependency'); + }); + + /** + * 「刚拖完、落点还没写回」时按整理:整理必须照常排进队列并最终生效。 + * + * 判据不能拿乐观视图(已经把那一笔排队中的手动落点叠上去了)去比:整理结果与它逐值相同 + * 就会判成"什么都没变",用户的点击被静默吞掉,随后落盘的手动结果(卡在原地)反客为主。 + * 与落盘那一步同源地用"整理结果 vs 已落盘布局"判定,两处才不会再打架。 + */ + it('拖动落点还在途时按整理:整理照常排队,最终把这张卡重排回自动槽位', async () => { + const documentA = resource('resource-doc-a'); + const documentB = resource('resource-doc-b'); + const { updates, holdNextWrite, releaseHeldWrite } = gatedTypeLayoutHarness( + [ + automaticPosition('resource-doc-a', 0, 0), + automaticPosition('resource-doc-b', 196, 0), + ], + ); + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'type', + resources: [documentA, documentB], + }), + ); + await waitFor(() => expect(result.current.settled).toBe(true)); + updates.length = 0; + + // 拖动落点已经在画面上(乐观视图),但这一笔还没写回。 + holdNextWrite(); + act(() => { + result.current.commitPosition('resource-doc-a', 'document', 500, 600); + }); + + // 用户紧接着按整理:这一按必须真的排进队列。 + let organized = false; + act(() => { + organized = result.current.organizeNow(['document']); + }); + expect(organized).toBe(true); + + await act(async () => { + releaseHeldWrite(); + await Promise.resolve(); + }); + + await waitFor(() => expect(updates.length).toBeGreaterThan(1)); + // 整理排在手动落点之后落盘:这张卡被重排回自动槽位,手动落点不留在最终布局里。 + expect(updates.at(-1)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'resource-doc-a', + x: 0, + y: 0, + manuallyPlaced: false, + }), + expect.objectContaining({ + resourceId: 'resource-doc-b', + x: 196, + y: 0, + manuallyPlaced: false, + }), + ]), + ); + }); + + /** + * 分类刚变更、同步写还没落盘时拖动这张卡:落点必须按**新栏目**写回,不能被静默跳过。 + * + * 顺序上的依据:资源签名变化那一次 effect 先 `reconcileLayout(layoutRef.current, resources)` + * 再 `applyLayout`,而 `resources` 已经是新分类——所以拖动开始时内存布局里这条坐标的 + * `section` 已经是新栏目,`resourceId + section` 的匹配不会落空。这条用例把这个顺序钉住: + * 谁把顺序改回去(例如先写后 reconcile),这里就会先红。 + */ + it('分类刚变更、同步写还没落盘时拖动:落点按新栏目写回,不被静默跳过', async () => { + const documentResource = resource('resource-shift'); + const sceneResource: ResourceCanvasItem = { + ...resource('resource-shift'), + category: 'scene', + }; + const { updates, holdNextWrite, releaseHeldWrite } = gatedTypeLayoutHarness( + [ + { + ...automaticPosition('resource-shift', 100, 100), + section: 'document', + }, + ], + ); + const { result, rerender } = renderHook( + (props: { resources: ResourceCanvasItem[] }) => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'type', + resources: props.resources, + }), + { initialProps: { resources: [documentResource] } }, + ); + await waitFor(() => expect(result.current.settled).toBe(true)); + updates.length = 0; + + // 分类变更:挂住随之而来的同步写,模拟"变更已生效、sidecar 还没对齐"的窗口。 + holdNextWrite(); + act(() => { + rerender({ resources: [sceneResource] }); + }); + await act(async () => { + await Promise.resolve(); + }); + // 内存布局先按新分类归并:这条坐标的 section 已经是 scene。 + expect( + result.current.layout.positions.find( + (position) => position.resourceId === 'resource-shift', + ), + ).toMatchObject({ section: 'scene', x: 100, y: 100 }); + + // 窗口内拖动这张卡:乐观布局必须立刻跟上(匹配落空的话这里会停在 100,100)。 + act(() => { + result.current.commitPosition('resource-shift', 'scene', 300, 400); + }); + expect( + result.current.layout.positions.find( + (position) => position.resourceId === 'resource-shift', + ), + ).toMatchObject({ + section: 'scene', + x: 300, + y: 400, + manuallyPlaced: true, + }); + + await act(async () => { + releaseHeldWrite(); + await Promise.resolve(); + }); + + // 落盘也是一笔带新栏目与手动标记的坐标:没有"被跳过、还没提示"的静默路径。 + await waitFor(() => expect(result.current.settled).toBe(true)); + const manualWrites = updates.filter((positions) => + positions.some( + (position) => + position.resourceId === 'resource-shift' && + position.x === 300 && + position.y === 400 && + position.manuallyPlaced, + ), + ); + expect(manualWrites).toHaveLength(1); + expect( + manualWrites[0]!.find( + (position) => position.resourceId === 'resource-shift', + ), + ).toMatchObject({ section: 'scene', x: 300, y: 400 }); + }); + + it('没有可整理栏目时不产生任何写入', async () => { + const documentA = resource('resource-doc-a'); + const { updates } = typeLayoutHarness([ + position('resource-doc-a', 600, 40), + ]); + + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'type', + resources: [documentA], + }), + ); + await settleInitialWrites(result, updates); + + await act(async () => { + result.current.organizeNow([]); + await Promise.resolve(); + }); + + expect(updates).toEqual([]); + expect(result.current.saving).toBe(false); + }); + + it('批量手动写入只排一笔 CAS,并按下发的标记落盘', async () => { + const documentA = resource('resource-doc-a'); + const documentB = resource('resource-doc-b'); + const { invoke, updates } = typeLayoutHarness([ + automaticPosition('resource-doc-a', 10, 20), + automaticPosition('resource-doc-b', 30, 40), + ]); + + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'type', + resources: [documentA, documentB], + }), + ); + await settleInitialWrites(result, updates); + + await act(async () => { + result.current.commitPositions([ + { + resourceId: 'resource-doc-a', + section: 'document', + x: 110, + y: 30, + manuallyPlaced: true, + }, + { + resourceId: 'resource-doc-b', + section: 'document', + x: 210, + y: 60, + manuallyPlaced: false, + }, + ]); + await Promise.resolve(); + }); + + await waitFor(() => expect(updates).toHaveLength(1)); + expect( + invoke.mock.calls.filter( + ([command]) => + command === 'update_local_project_resource_canvas_layout', + ), + ).toHaveLength(1); + expect(updates[0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resourceId: 'resource-doc-a', + x: 110, + y: 30, + manuallyPlaced: true, + }), + expect.objectContaining({ + resourceId: 'resource-doc-b', + x: 210, + y: 60, + manuallyPlaced: false, + }), + ]), + ); + }); + + it('只有手动标记变化(撤销整理)时仍然落盘,完全没变化时不写', async () => { + const documentA = resource('resource-doc-a'); + const { updates } = typeLayoutHarness([ + automaticPosition('resource-doc-a', 10, 20), + ]); + + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'type', + resources: [documentA], + }), + ); + await settleInitialWrites(result, updates); + + await act(async () => { + result.current.commitPositions([ + { + resourceId: 'resource-doc-a', + section: 'document', + x: 10, + y: 20, + manuallyPlaced: false, + }, + ]); + await Promise.resolve(); + }); + // 坐标与标记都没变:不产生无意义的一笔。 + expect(updates).toEqual([]); + + await act(async () => { + result.current.commitPositions([ + { + resourceId: 'resource-doc-a', + section: 'document', + x: 10, + y: 20, + manuallyPlaced: true, + }, + ]); + await Promise.resolve(); + }); + await waitFor(() => expect(updates).toHaveLength(1)); + expect(updates[0]?.[0]).toMatchObject({ + resourceId: 'resource-doc-a', + x: 10, + y: 20, + manuallyPlaced: true, + }); + }); + + it('整理写入失败时保留失败提示,不伪报保存成功', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const documentA = resource('resource-doc-a'); + const invoke = vi.fn(async (command: string) => { + if (command === 'read_local_project_resource_canvas_layout') { + return persistedLayout('type', 7, [ + position('resource-doc-a', 600, 40), + ]); + } + if (command === 'update_local_project_resource_canvas_layout') { + throw new Error('layout write rejected'); + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'type', + resources: [documentA], + }), + ); + await waitFor(() => expect(result.current.ready).toBe(true)); + + act(() => result.current.organizeNow(['document'])); + + await waitFor(() => + expect(result.current.notice).toBe('布局保存失败,已保留当前会话布局'), + ); + expect( + invoke.mock.calls.filter( + ([command]) => + command === 'update_local_project_resource_canvas_layout', + ), + ).toHaveLength(1); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts index 6e821454f..01876526e 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCardPreviews.test.ts @@ -141,6 +141,60 @@ afterEach(() => { }); describe('useProjectResourceCardPreviews', () => { + it('JSON 识别结果经过现有预取缓存保留,换项目后不复用旧编辑能力', async () => { + const json = resource('design', { + path: 'ui/design.json', + subtype: 'ui', + mediaType: 'application/json', + }); + const invoke = vi.fn( + async (_command: string, args?: Record) => ({ + path: json.path, + mediaType: json.mediaType, + byteLen: 2, + content: '{}', + ...(args?.projectPath === '/tmp/first-project' + ? { uiDesignAssetId: 'design' } + : {}), + }), + ); + window.__TAURI__ = { core: { invoke } }; + const resources = [json]; + const canvasRef = { current: document.createElement('div') }; + const { result, rerender } = renderHook( + ({ projectPath }) => + useProjectResourceCardPreviews({ + projectPath, + projectId: projectPath, + mode: 'dependency', + resources, + canvasRef, + eagerPreviewLimit: 12, + }), + { initialProps: { projectPath: '/tmp/first-project' } }, + ); + const identity = () => result.current.identityByResourceId.get(json.id)!; + await waitFor(() => + expect(result.current.previews.get(identity())).toMatchObject({ + status: 'loaded', + preview: { uiDesignAssetId: 'design' }, + }), + ); + rerender({ projectPath: '/tmp/second-project' }); + await waitFor(() => + expect(result.current.previews.get(identity())?.status).toBe('loaded'), + ); + const state = result.current.previews.get(identity()); + expect( + state?.status === 'loaded' && state.preview.uiDesignAssetId, + ).toBeUndefined(); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'read_local_project_text_preview', + ), + ).toHaveLength(2); + }); + it('代码卡不预取正文,显式详情复用文本预览队列与缓存', async () => { const code = resource('code', { path: 'game/main.ts', diff --git a/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx b/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx index 28dde6e59..5ea530842 100644 --- a/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx +++ b/apps/ai-game-creator-shell/tests/workspaceLauncherManifestMerge.test.tsx @@ -106,6 +106,8 @@ function installInvokeMock() { return { revision: HELD_REVISION }; case 'get_design_agent_runtime_mode': return null; + case 'list_game_creator_direct_active_turns': + return []; case 'read_project_permission_policy': return { projectPath: PROJECT_PATH, diff --git a/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx new file mode 100644 index 000000000..a8bd7b8cf --- /dev/null +++ b/apps/ai-game-creator-shell/tests/workspaceWindowSync.test.tsx @@ -0,0 +1,113 @@ +/** @vitest-environment jsdom */ +import type { ReactNode } from 'react'; +import { useCallback } from 'react'; +import { vi } from 'vitest'; + +import { WindowChrome } from '../src/components/WindowChrome'; +import { + useWindowChrome, + type WindowChromeActiveProjectRuns, + WindowChromeContext, +} from '../src/components/windowChromeContext'; +import { WorkspaceLauncherShell } from '../src/features/app-shell/WorkspaceLauncher'; +import { + act, + expect, + it, + React, + render, + testAuthUser, +} from './appSurface/harness'; + +const homeProjectOverride = vi.hoisted(() => ({ + openProject: null as + | null + | ((path: string, mode: 'open' | 'create') => Promise), +})); + +vi.mock('../src/features/app-shell/useHomeProjectCreation', async () => { + const actual = await vi.importActual< + typeof import('../src/features/app-shell/useHomeProjectCreation') + >('../src/features/app-shell/useHomeProjectCreation'); + return { + ...actual, + useHomeProjectCreation( + ...args: Parameters + ) { + const result = actual.useHomeProjectCreation(...args); + return homeProjectOverride.openProject + ? { ...result, openProject: homeProjectOverride.openProject } + : result; + }, + }; +}); + +it('真实窗口与工作台状态同步收敛,回调读取最新处理器且卸载才清理', async () => { + const publications: WindowChromeActiveProjectRuns[] = []; + let cleanups = 0; + // 仍经过真实 WindowChrome 的 setState/Context;上限只防止回归时测试无限循环。 + function BoundedWindowBridge({ children }: { children: ReactNode }) { + const chrome = useWindowChrome(); + const { setActiveProjectRuns } = chrome; + const publish = useCallback( + (next: WindowChromeActiveProjectRuns | null) => { + if (next) publications.push(next); + else cleanups += 1; + if (publications.length < 12) setActiveProjectRuns(next); + }, + [setActiveProjectRuns], + ); + return ( + + {children} + + ); + } + const supervisor = () => null; + const view = (displayName = '测试用户') => ( + + + undefined} + initialView="projects" + ProjectSupervisor={supervisor} + /> + + + ); + delete window.__TAURI__; + const rendered = render(view()); + try { + await act(async () => { + await Promise.resolve(); + }); + // 无原生 invoke 时空快照引用不变,只发布一次。 + expect(publications).toHaveLength(1); + expect(cleanups).toBe(0); + expect(new Set(publications.map((item) => item.onOpenProject)).size).toBe( + 1, + ); + const openProject = publications[0]!.onOpenProject!; + const latestOpen = vi.fn(async () => undefined); + homeProjectOverride.openProject = latestOpen; + rendered.rerender(view('更改显示名')); + // 非依赖变化的重渲染不能让窗口面板重新发布。 + expect(publications).toHaveLength(1); + act(() => openProject('/tmp/window-latest-project')); + expect(latestOpen).toHaveBeenCalledWith( + '/tmp/window-latest-project', + 'open', + ); + // 回调执行只换 ref 里的实现,发布次数与清理次数都不能变。 + expect(publications).toHaveLength(1); + expect(cleanups).toBe(0); + rendered.unmount(); + expect(cleanups).toBe(1); + } finally { + homeProjectOverride.openProject = null; + rendered.unmount(); + } +}); diff --git a/apps/ai-game-creator-shell/vite.config.ts b/apps/ai-game-creator-shell/vite.config.ts index 6e68153ba..fa2246ed0 100644 --- a/apps/ai-game-creator-shell/vite.config.ts +++ b/apps/ai-game-creator-shell/vite.config.ts @@ -154,6 +154,9 @@ export default defineConfig({ host: '127.0.0.1', port: 3080, strictPort: true, + watch: { + ignored: ['**/src-tauri/target/**'], + }, fs: { allow: [repoRoot], }, diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx index 4a366fdaa..29a4dbb5d 100644 --- a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx +++ b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx @@ -59,6 +59,32 @@ test('requires an access token before showing deployments', async () => { expect(await screen.findByText('构建并发布一个分支')).toBeTruthy(); }); +test('prefers the public preview domain when the control plane exposes one', async () => { + vi.mocked(api.listDeployments).mockResolvedValue([ + { + id: '77', + branch: 'feature/public-preview', + resolvedCommit: '1234567890abcdef', + status: 'running', + health: 'healthy', + webPort: 8400, + webUrl: 'http://192.168.35.82:8400', + webPublicUrl: + 'https://preview-63d38d3da6bc9b06.preview.genarrative.world', + createdAt: 1_787_270_400, + updatedAt: 1_787_270_460, + }, + ]); + render(); + + const publicLink = await screen.findByRole('link', { + name: /打开公网预览/u, + }); + expect(publicLink.getAttribute('href')).toBe( + 'https://preview-63d38d3da6bc9b06.preview.genarrative.world', + ); +}); + test('submits a branch with an optional commit hash', async () => { const user = userEvent.setup(); render(); diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx index ec5e30773..ea23c3836 100644 --- a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx +++ b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx @@ -797,6 +797,16 @@ function DeploymentCard({
+ {deployment.webUrl && deployment.webPublicUrl ? ( + + 打开公网预览 + + ) : null} {deployment.webUrl ? ( .<后缀>,留空则只展示内网地址。 +GENARRATIVE_PREVIEW_DEPLOYER_WEB_DOMAIN=preview.genarrative.world GENARRATIVE_PREVIEW_DEPLOYER_STATE_FILE=/var/lib/genarrative/preview-deployer/state.json GENARRATIVE_PREVIEW_DEPLOYER_STATIC_DIR=/opt/genarrative/preview-deployer/web GENARRATIVE_PREVIEW_DEPLOYER_SECURE_COOKIE=false diff --git a/docs/README.md b/docs/README.md index 6a6bc67eb..01f489d07 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,11 +34,13 @@ - [AGC 客户端稳定版生命周期大切换](./【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md):统一 operation、认证/Runner、项目入口、本地恢复和 dev-stack 身份边界。 - [策划会话 Runtime V2 接入与旧链路退役方案](./technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md):新单 Agent 策划会话、GDD 策略、未来 MCP/Skill 兼容插槽、阶段任务与退役验收合同。 - [DirectProject Codex 原始历史与异常恢复](<./technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md>):原始 Responses item 持久化、线程注入与异常回合收尾。 +- [DirectProject 对话历史单一事实源](./adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md):AGC 项目开发对话只以项目对话历史与运行态事件为真相源,聊天投影不落盘。 - [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 - [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。 - [AGC Cocos Creator 编辑器桥接模块](<./technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md>):独立 crate、feature 开关、目标校验与 Windows 注入边界。 - [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、OSS 清单格式和下载约定。 +- [AGC 模板库与模板建项](./technical/【技术方案】AGC模板库与模板建项-2026-09-17.md):`templates/` 前缀的模板库契约、下载安装与「用模板建项目」链路。 - [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。 - [Direct 回合行为审计账本](./technical/【技术方案】Direct回合行为审计账本-2026-08-31.md):Direct GUI 回合把 native 读 / MCP / 写文件落成项目内有界时间线,用于判断有没有打开本轮附件。 - [项目开发工作台 PRD](./prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md):当前工作台页面和验收边界。 @@ -59,6 +61,8 @@ ## 图片画布与媒体 +- [AGC 抠图模式与背景色透传方案](./technical/【技术方案】AGC抠图模式与背景色透传-2026-09-16.md):External v1 与 AGC 客户端扩展 `flat`/`complex` 及 BgFilter `auto` 透传。 + - [共享基础组件库与展示页](./technical/【前端架构】共享基础组件库与展示页-2026-08-26.md):网站与客户端复用的无业务 UI chrome、样式边界和 `/components` 展示页。 - [Raw GPT Image 2 图片编辑代理](./technical/【技术方案】Raw GPT Image 2图片编辑代理-2026-09-07.md):主站客户端调用的同步图片编辑代理、multipart 输入、预检查与计费边界。 - [UI 编辑器自动切分素材工作流](./technical/【技术方案】UI编辑器自动切分素材工作流-2026-09-08.md):UI 设计图素材切分、Raw GPT Image 2 调用与结果持久化边界。 @@ -102,7 +106,7 @@ - [后台 Dashboard 运营看板方案](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md) - [后台多账号与 Tab 访问权限方案](./technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md) - [Pingora 独立网关试点](<./technical/【开发运维】Pingora独立网关试点-2026-06-11.md>) -- [AGC 后台模型别名与对话选择](./technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md) +- [AGC 后台模型别名与对话选择](./technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md):官方目录、本地自定义 LLM 开关、端点模型勾选与预览。 - [UI 编辑器工作流完成通知弹窗](./technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md) - [官网 SEO 地基实施约定](./technical/【SEO】官网SEO地基实施约定-2026-07-10.md) - [UI 编辑器拖动变换提交边界](./【UI编辑器】拖动变换提交边界-2026-09-03.md) diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md new file mode 100644 index 000000000..0642ca205 --- /dev/null +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -0,0 +1,55 @@ +# 【ADR】DirectProject对话历史单一事实源-2026-09-16 + +状态:已接受 + +## 背景 + +AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件(实时)、`turn-stream.jsonl`(文本段与工具交替顺序)、`tool-calls.jsonl`(已脱敏工具卡片),重进页面时还要额外接管活动回合快照。同一段文本和同一张工具卡片因此存在多个来源,实时与回读会互相覆盖,恢复路径也只能靠"哪个源先到"决定。 + +`.agent/conversations/project.jsonl` 里的 Codex 原始条目本身已经带着顺序(`function_call` 与 `function_call_output` 按写入顺序落行),顺序信息并不是协议缺陷,而是在投影层被丢弃。 + +## 决策 + +- AGC 项目开发对话的持久事实源只有 **项目对话历史**(`.agent/conversations/project.jsonl` 的原始条目);消息文本、工具卡片和它们的先后顺序都从它派生。 +- 运行期间的回合状态只来自 **运行态事件**(Thread Manager 的 subscribe / consume / notify);`notify` 只做唤醒,不携带状态。 +- **聊天投影** 在读取与渲染时生成,不落盘、不成为第二事实源;DirectProject 聊天框停止读取 `turn-stream.jsonl` 与 `tool-calls.jsonl`,也不再提供供前端读取的命令。DirectRuntime 自己那套进度事件与文件写入属于运行时账本,本轮保留不动。 +- 页面重进的运行态只由 `subscribe` 的 bootstrap 事件重建,删除活动回合快照接管路径。 +- 可见性判断留在前端聊天投影:后端历史分页只按原始条目切片,前端自己跳过不可显示条目并推进锚点。 +- 线上模型是 **ts-rs 导出的 tagged enum**(`agent/direct_thread_wire.rs`),不是"一个大结构体加一堆可空字段":`DirectThreadItem` 用 `itemType` 区分条目,`DirectThreadEvent` 用 `type` 区分事件,前端直接消费生成的 TS 类型(改完 Rust 模型跑 `cargo test export_bindings`)。条目上的毫秒时间戳标 `#[ts(as = "f64")]`,因为 ts-rs 默认把 `u64` 映射成 `bigint`,而 Tauri 的 JSON 通道传的是 `number`。 +- 运行态事件与历史切片使用同形条目,Rust 在两侧套同一套安全过滤(脱敏、截断、路径归一),前端只有一个「原始条目 → 视图」投影函数。 +- 两侧的过滤口径必须完全一致,包含「哪些条目根本不是本项目的聊天条目」:Codex app-server 回显的用户消息(`userMessage` / 非 AGC 的 `role=user`)在落盘侧被过滤,在运行态事件侧也必须被过滤(`direct_thread_visible_item`)。少一侧就会出现「实时比历史多出两条同文本用户条目、各自开出一个耗时 0 秒的假回合,重进页面又正常」这类只有其中一侧的事实源缺陷。 +- 搬运层不生成展示形状:Thread Manager 只下发脱敏原始条目(`itemType` 原样透传),工具卡片的 `kind`、标题、折叠摘要都由前端生成。 +- 条目身份只有一套:进队列前归一成一个 `itemId`。工具条目在 `project.jsonl` 里带两个 id(调用 id 与 response item id,调用与输出共用前者),归一只在 Rust 边界做一次,Thread Manager 与前端都不暴露第二个 id 概念。 +- 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`;前端 state 里只有一个 `turnRunning` 布尔,没有 `turnId`。`subscribe` 返回的条目、增量、请求与队列锚点都不带 turn id。 +- 合并只在前端,规则只保留「先到定形、后到补空白」:第一次见到的快照决定卡片形状,后续快照只补输出与状态,不做逐字段优先级表。只有"后到信息一定更全"时才例外:正文取更长的一份、工具状态允许从 `running` 升级到终态、`updatedAt` 取较新的时间。 +- 前端不保留增量缓冲:`item.delta` 直接追加到运行态条目的正文(正文只增不减)。`turn.completed` 把当前回合的运行态条目并入历史再清空,条目既不消失也不重复。 +- 活动回合的唯一判据是「出现过 `turn.started` 且未出现 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染。 +- 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。 +- `notify` 是唯一唤醒来源:`subscribe` 的 bootstrap 事件本身就是该 subscriber 此刻要处理的事件(游标已在队尾),前端直接 reduce 它们,不需要为了取这批事件再补一次 `consume`,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。唯一例外是回执竞态:Rust 侧一注册完 subscriber 就开始 `notify`,前端却要等回执才知道自己的 `subscriptionId`,这段窗口内的通知只能记成欠账,回执到达后立刻补一次 `consume` 取回,否则该回合的尾部事件会卡在队列里等一个可能永不出现的下一次通知。 +- 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。 +- 思考过程与工具活动同样从运行态事件与历史条目推断,界面展示保持不变。 +- 运行态事件必须自足:`item.started` / `item.completed` 携带与历史切片同形的完整**脱敏原始条目**,前端按归一后的 `itemId` 合并快照得到运行中与完成态;不提供按 `itemId` 单点取快照的接口。 +- 思考正文以 `item.delta{kind:"reasoning"}` 流式下发(`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta`)。这不放宽可见范围:同一段文本本来就已落进 `project.jsonl` 并在 `item.completed` 展示;plan 文本与命令输出仍只降级为活动状态。 +- 首屏历史由 `subscribe` 返回的 `lastCompletedItemId` 锚定,再取最近切片;删除返回整份对话的历史命令。锚点是切片**新端(较新一侧)的边界且含该条**(命令参数 `throughItemId`):比锚点更新的条目只从运行态事件来,历史切片与实时流因此不重叠;向后翻页仍用切片返回的 `firstItemId` 作为 `beforeItemId`(不含锚点)。订阅回执到达之前不读首屏,也不退化成"取文件尾"(那会把回执之后才完成的条目也拉进历史)。 +- 生命周期锚点独立于 replay 队列保存(队列会回收 `cleanable` 事件,新订阅的游标又在队尾,回收后无法反推"最新回合是 started 还是 completed"),`subscribe` 必须返回最新的一条 `turn.started` / `turn.completed`,否则新订阅无法判定回合是否仍在运行。 +- 前端工具卡片形状是 `Omit`:聊天卡片不再有回合身份,`tool-calls.jsonl` 的持久化形状仍保留 `turnId`(DirectRuntime 的账本没动)。 +- 未识别 item 类型由 Rust 原样透传(只带类型与身份,Rust 侧留 TODO),当前由前端投影丢弃:哪些类型可见属于前端决策,不回 Rust 加白名单。 +- 删除范围包含前端对 `read_direct_turn_stream`、`read_direct_tool_calls`、`read_direct_project_history`(整份历史)与 `game-creator-direct-turn-update` 事件的调用;保留分页用的历史切片读取(`read_direct_project_history_slice`),且该切片从文件尾反向扫描。`list_game_creator_direct_active_turns` 有意保留:它服务首页跨页面的「运行中的项目」列表,不是聊天框读路径。 +- 前端删掉 `directTurnStream` / `directToolCalls` / 活动回合快照接管 / 瞬时应答文本这些并行状态,聊天视图只由 reducer 状态投影(含工具卡片)。 +- 失败与中止说明只在运行期显示,不写进 `project.jsonl`;页面重进后不再出现。 +- 历史切片的 `firstItemId` 是分页锚点,始终取 `project.jsonl` 里的原始 item id,与归一后的条目身份分开计算。 +- 审批与提问事件本次只作为同一条事件流 pass-through,不并入聊天 reducer 驱动的状态机,迁移面收敛在历史与运行态一致性上。 + +## 备选方案与取舍 + +1. **保留 `tool-calls.jsonl` 作为"读侧已脱敏"缓存**:省一次脱敏与截断,但它成为与项目对话历史并行的第二事实源,卡片状态与顺序会和实时事件分叉。选择按读取期投影,必要时在进程内缓存。 +2. **保留 Direct 回合事件作为实时传输**:迁移量小,但同一段文本仍有两条实时链路,reducer 必须处理互相覆盖,正是本次要消除的问题。 +3. **让后端分页按"可显示消息数"切片**:界面能少写循环,代价是 Rust 需要理解 UI 可见性,界面规则一变就要同步改后端。 + +## 影响 + +- 旧项目磁盘上遗留的 `turn-stream.jsonl` / `tool-calls.jsonl` 保留不动,不迁移、不清理、不再由 DirectProject 聊天框读取。 +- 工具卡片的脱敏与截断必须在读取期执行一次,不能因为"原始条目已在磁盘"就把未脱敏内容直接渲染到界面。 +- 回合结束语义务必由 `turn.completed` 判定;缺少该事件的残留回合不得被渲染成运行中。 +- 验收证据是端到端行为,不是单元测试:回合进行中杀掉应用进程后重开项目,应看到部分文本与工具卡片按原顺序出现且不显示忙碌;正常结束后重进应与实时渲染一致;文件系统不得再新增 `turn-stream.jsonl` / `tool-calls.jsonl`。 +- id 空间已用源码核对:codex-rs `app-server-protocol/src/protocol/thread_history.rs` 中所有工具 item 都是 `id: payload.call_id.clone()`,而 `project.jsonl` 落盘的是原始 response item。真实 app-server 会话核对仍列为运行时验收项。 diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 0a28b1ab6..c120926f0 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -1122,7 +1122,7 @@ "tags": ["Editor Images"], "operationId": "removeExternalEditorImageBackground", "summary": "去除编辑器图片背景", - "description": "提交已有静态图片素材的异步去背景任务。sourceImageSrc 只接受当前账号拥有的稳定 objectKey、项目资源 ID 或素材 ID;禁止 Data URL、Blob URL 和临时 signed URL。assetKind 只能表达静态图片,并且存在权威来源记录时必须与其类型一致;视频、音频、动画和图片序列在入队前返回 400。服务端固定使用 complex 去背景模式,不会在失败时切换到其它 provider。需要写入画布时提供 projectId 与 canvasCompletion;仅需原位替换既有图层时提供 projectId 与 targetLayerId,且来源与目标必须指向同一权威对象。", + "description": "提交已有静态图片素材的异步去背景任务。sourceImageSrc 只接受当前账号拥有的稳定 objectKey、项目资源 ID 或素材 ID;禁止 Data URL、Blob URL 和临时 signed URL。assetKind 只能表达静态图片,并且存在权威来源记录时必须与其类型一致;视频、音频、动画和图片序列在入队前返回 400。complex 使用语义分割识别前景,flat 用于纯色背景抠图;确定背景为纯色时优先使用 flat。需要写入画布时提供 projectId 与 canvasCompletion;仅需原位替换既有图层时提供 projectId 与 targetLayerId,且来源与目标必须指向同一权威对象。", "security": [ { "ExternalApiKey": [] @@ -3211,6 +3211,17 @@ "minLength": 1, "description": "当前账号拥有的稳定 objectKey、项目资源 ID 或素材 ID。禁止 Data URL、Blob URL 和临时 signed URL。" }, + "backgroundMode": { + "type": ["string", "null"], + "enum": ["complex", "flat", null], + "default": "complex", + "description": "抠图模式。省略或 null 按 complex 处理;complex 使用语义分割识别前景,flat 用于纯色背景抠图。确定背景为纯色时优先使用 flat。" + }, + "screenColor": { + "type": ["string", "null"], + "pattern": "^(auto|#[0-9A-Fa-f]{6})$", + "description": "仅 flat 模式使用。可传 auto、#RRGGBB 或省略;null 等同省略。auto 和省略由服务自动检测背景色。模式省略或 complex 时提供非 null 颜色返回 400;空字符串或非法颜色返回 400。" + }, "projectId": { "type": ["string", "null"], "description": "可选项目上下文。提供 targetLayerId 时必须同时提供非空 projectId,否则在入队前返回 400。" @@ -3249,6 +3260,14 @@ "description": "画布生成占位完成指令。提供时优先按生成完成链路写入结果,targetLayerId 不参与原位替换。" } }, + "if": { + "required": ["screenColor"], + "properties": { "screenColor": { "type": "string" } } + }, + "then": { + "required": ["backgroundMode"], + "properties": { "backgroundMode": { "const": "flat" } } + }, "additionalProperties": false }, "EditorImageGenerationResponse": { @@ -3344,7 +3363,7 @@ }, "EditorIconSpritesheetGenerationRequest": { "type": "object", - "required": ["referenceId", "iconDescriptions"], + "required": ["referenceId", "iconDescriptions", "sliceMode"], "properties": { "referenceId": { "type": "string", @@ -3376,26 +3395,25 @@ "connected-components", "grid" ], - "default": "connected-components", - "description": "图集切分模式。connected-components 按透明像素 alpha 连通域识别独立素材;grid 按用户提供的 gridX/gridY 划分网格槽。省略时使用 connected-components。" + "description": "必填,没有默认值:必须在引用解析、定价、入队和任何 provider / OSS 副作用之前显式声明切分模式。需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入来自需求本身的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,需要约束素材张数时用 sliceCount。connected-components 不接受 gridX/gridY,grid 必须同时提供 gridX/gridY(各 1..32)。省略、null 或空字符串返回 400(field=sliceMode),模式与网格参数互相矛盾返回 400(field=gridX/gridY),两者都不会产生计费、入队或 provider 调用。响应中的 sliceMode 回显本次实际采用的模式。" }, "gridX": { "type": "integer", "minimum": 1, "maximum": 32, - "description": "grid 模式的横向网格数量。" + "description": "grid 模式的横向网格数量,只能与 sliceMode=grid 同时出现;与 connected-components 同时提交返回 400。" }, "gridY": { "type": "integer", "minimum": 1, "maximum": 32, - "description": "grid 模式的纵向网格数量。" + "description": "grid 模式的纵向网格数量,只能与 sliceMode=grid 同时出现;与 connected-components 同时提交返回 400。" }, "sliceCount": { "type": "integer", "minimum": 1, - "maximum": 100, - "description": "connected-components 模式下可选的目标切片数量;省略时按图像内容自动识别。grid 模式的切片数量由 gridX×gridY 决定。" + "maximum": 256, + "description": "connected-components 模式下可选的目标切片数量(1..256);省略时按图像内容自动识别上限。识别结果与该目标数量不一致、为 0 或超过 256 时返回 422 并给出实际识别数量,不会静默截断。grid 模式的切片数量由 gridX×gridY 决定,不接受该字段。" }, "screenColor": { "type": ["string", "null"], @@ -3630,7 +3648,7 @@ "connected-components", "grid" ], - "description": "实际采用的图集切分模式。" + "description": "本次实际采用的图集切分模式,与请求显式声明的 sliceMode 一致;图集生成入口不回退到任何默认模式。" }, "gridX": { "type": "integer", @@ -3645,7 +3663,7 @@ "sliceCount": { "type": "integer", "minimum": 0, - "maximum": 100, + "maximum": 256, "description": "实际生成的切片数量。" }, "sliceWarning": { diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index feb480ae6..2c1d633cf 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -60,7 +60,10 @@ #### 3.3.2 本体化资源卡交互与性能合同 -- 卡片默认可视区域不再显示文件名、资源名称、来源、路径、任务和媒体类型等详细文本。这些字段继续进入搜索索引和中央详情;卡片“打开详情”入口的可访问名称必须包含稳定可辨识的资源名与类别。 +- 所有资源卡在卡面显示正式资源名称,沿用生成命名和用户重命名后的资源投影;长名称单行省略,实际可交互的卡片入口提供完整名称提示。来源、完整路径、任务和媒体类型等详细字段继续进入搜索索引和中央详情;卡片入口的可访问名称必须包含稳定可辨识的资源名与类别。文档卡仅展示居中文档图标和名称,正文在独立预览中展示,不把正文摘要铺在卡面。 +- 显式“整理画布”重排当前栏目全部资源(包含手动坐标和被筛选隐藏的资源),其他栏目不变;“所有资源”页作用于全部可展示资源。重排可一次撤销,恢复原坐标及手动标记。自动协调仍保留手动坐标,不因新增素材自行重排。 +- 当前画布可见资源框选后可成组移动,保持相对位置;松手统一保存且一次撤销。取消手势还原拖动前布局,切项目清理选择,不将隐藏或跨栏目残留选择带入操作。 +- 多选已登记素材后,可从选中工具栏“编辑标签”或资源面板“批量标签”入口统一追加标签。面板明确实际目标数量,保存对象在打开时冻结,保留每项原标签及素材类型;不把已有标签并集覆盖到每项。混合未登记资源、超过 200 项、任何一项标签越界或项目版本冲突时整批拒绝,不静默跳过。一次保存更新整批素材,失败保留待追加标签;切项目不将迟到结果写入新项目。单素材编辑保留原有增删标签行为。 - 卡片外层是非交互容器;“打开详情”与“播放 / 暂停”必须是可分别键盘聚焦的同级按钮,禁止在 ` ); } diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index ebc0b3b75..d4ffc48d5 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -814,7 +814,7 @@ describe('ImageCanvasWorldView', () => { expect( ( - within(layerButton).getByText('角色') as HTMLElement + within(layerButton).getByText('角色').parentElement as HTMLElement ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( @@ -825,7 +825,9 @@ describe('ImageCanvasWorldView', () => { expect( within(layerButton) .getByRole('button', { name: '查看角色主图图片信息' }) - .style.getPropertyValue('--image-canvas-editor-inverse-scale'), + .parentElement!.style.getPropertyValue( + '--image-canvas-editor-inverse-scale', + ), ).toBe(inverseScale); expect( ( @@ -1118,12 +1120,8 @@ describe('ImageCanvasWorldView', () => { name: '查看角色主图图片信息', }); - expect(badge.className).toContain( - 'image-canvas-editor__kind-badge--beside-info', - ); - expect(metadataButton.className).not.toContain( - 'image-canvas-editor__metadata-corner--beside-kind', - ); + expect(badge.parentElement?.className).toBe('shared-canvas-card-corners'); + expect(badge.nextElementSibling).toBe(metadataButton); }); it('keeps the layer type menu scrollable and closes it when clicking outside', () => { diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 42315907a..a9e1e9622 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -3,13 +3,13 @@ import { LayerRenderer, SelectionOverlay, } from '@genarrative/image-canvas-react'; +import { CanvasCardCornerActions } from '@genarrative/shared/components'; import { AppWindow, Clapperboard, ClipboardList, Grid2X2, ImageIcon, - Info, Megaphone, Mountain, Music, @@ -44,7 +44,6 @@ import { PlatformFloatingMenu, PlatformFloatingMenuItem, } from '../common/PlatformFloatingMenu'; -import { PlatformIconButton } from '../common/PlatformIconButton'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { @@ -367,17 +366,6 @@ function handleLayerKeyboardActivation(event: ReactKeyboardEvent) { event.currentTarget.click(); } -function handleLayerTagKeyboardActivation( - event: ReactKeyboardEvent, -) { - if (event.key !== 'Enter' && event.key !== ' ') { - return; - } - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.click(); -} - function stopMediaControlKeyPropagation( event: ReactKeyboardEvent, ) { @@ -1012,38 +1000,19 @@ const MemoizedCanvasLayerNode = memo(function CanvasLayerNode({ mediaTransform={mediaTransform} /> )} - {kindLabel ? ( - event.stopPropagation()} - onClick={(event) => { - event.stopPropagation(); - handlers.setOpenLayerKindMenuId((currentId) => - currentId === layer.id ? null : layer.id, - ); - }} - onKeyDown={handleLayerTagKeyboardActivation} - > - {kindLabel} - - ) : null} - } + { - event.stopPropagation(); - handlers.onOpenLayerMetadata(layer); - }} - onPointerDown={(event) => event.stopPropagation()} + onKindClick={() => + handlers.setOpenLayerKindMenuId((currentId) => + currentId === layer.id ? null : layer.id, + ) + } + onInfoClick={() => handlers.onOpenLayerMetadata(layer)} /> {isHovered && layer.mediaType !== 'audio' ? ( { 'generated-character-drafts/editor/refs/icon-style.png', ], iconDescriptions: ['返回按钮'], + sliceMode: 'connected-components', }), ); }); diff --git a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx index 6018727b4..e29b81828 100644 --- a/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx +++ b/src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx @@ -2,7 +2,7 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import { type Dispatch, type SetStateAction, useRef, useState } from 'react'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { CanvasGenerationDialogState, @@ -258,6 +258,10 @@ function KeyboardShortcutsHarness({ } describe('useImageCanvasKeyboardShortcuts', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('routes undo and redo while ignoring editable inputs', () => { const undoCanvasChange = vi.fn(); const redoCanvasChange = vi.fn(); diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index e33258343..ddcd5eaa6 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -954,6 +954,7 @@ describe('useImageCanvasProjectPersistence', () => { beforeEach(() => { vi.resetAllMocks(); + window.history.replaceState(null, '', '/editor/canvas'); try { globalThis.sessionStorage?.clear(); } catch { diff --git a/src/hooks/useResolvedAssetReadUrl.test.tsx b/src/hooks/useResolvedAssetReadUrl.test.tsx index a5094abb1..0ffd68ffd 100644 --- a/src/hooks/useResolvedAssetReadUrl.test.tsx +++ b/src/hooks/useResolvedAssetReadUrl.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { RUNTIME_RESOURCE_PENDING_SELECTOR } from '../components/common/RuntimeResourcePendingMarker'; import { ResolvedAssetImage } from '../components/ResolvedAssetImage'; import { clearStoredAccessToken, @@ -215,6 +216,7 @@ describe('useResolvedAssetReadUrl', () => { }); test('refreshKey changes force a fresh signed url request without mutating OSS signature query', async () => { + let requestCount = 0; vi.spyOn(globalThis, 'fetch').mockImplementation( async () => new Response( @@ -224,7 +226,7 @@ describe('useResolvedAssetReadUrl', () => { read: { objectKey: 'generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png', - signedUrl: 'https://signed.example.com/puzzle.png', + signedUrl: `https://signed.example.com/puzzle.png?x-oss-signature=version-${++requestCount}`, expiresAt: '2099-01-01T00:10:00Z', }, }, @@ -255,7 +257,7 @@ describe('useResolvedAssetReadUrl', () => { const firstImage = await screen.findByRole('img', { name: '候选图' }); expect(firstImage.getAttribute('src')).toBe( - 'https://signed.example.com/puzzle.png', + 'https://signed.example.com/puzzle.png?x-oss-signature=version-1', ); rerender( @@ -267,11 +269,11 @@ describe('useResolvedAssetReadUrl', () => { ); await waitFor(() => { - expect(globalThis.fetch).toHaveBeenCalledTimes(2); + expect( + screen.getByRole('img', { name: '候选图' }).getAttribute('src'), + ).toBe('https://signed.example.com/puzzle.png?x-oss-signature=version-2'); }); - expect( - screen.getByRole('img', { name: '候选图' }).getAttribute('src'), - ).toBe('https://signed.example.com/puzzle.png'); + expect(globalThis.fetch).toHaveBeenCalledTimes(2); }); test('generated 私有资源签名失败时保持空图像而不是回退裸路径', async () => { @@ -300,16 +302,22 @@ describe('useResolvedAssetReadUrl', () => { ), ); - render( + const { container } = render( , ); + expect( + container.querySelector(RUNTIME_RESOURCE_PENDING_SELECTOR), + ).not.toBeNull(); await waitFor(() => { - expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect( + container.querySelector(RUNTIME_RESOURCE_PENDING_SELECTOR), + ).toBeNull(); }); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); expect(screen.queryByRole('img', { name: '候选图' })).toBeNull(); }); @@ -339,7 +347,7 @@ describe('useResolvedAssetReadUrl', () => { ), ); - render( + const { container } = render( { expect( screen.getByRole('img', { name: '候选图' }).getAttribute('src'), ).toBe('/creation-type-references/puzzle.webp'); + expect( + container.querySelector(RUNTIME_RESOURCE_PENDING_SELECTOR), + ).not.toBeNull(); await waitFor(() => { - expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect( + container.querySelector(RUNTIME_RESOURCE_PENDING_SELECTOR), + ).toBeNull(); }); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); expect( screen.getByRole('img', { name: '候选图' }).getAttribute('src'), ).toBe('/creation-type-references/puzzle.webp'); diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index b04406338..fcd08330e 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -322,6 +322,7 @@ describe('apiClient', () => { it('emits auth change events when refresh fails on protected requests', async () => { setStoredAccessToken('expired-token', { emit: false }); fetchMock + .mockResolvedValueOnce(createResponseMock({ status: 401 })) .mockResolvedValueOnce(createResponseMock({ status: 401 })) .mockResolvedValueOnce(createResponseMock({ status: 401 })); @@ -330,7 +331,12 @@ describe('apiClient', () => { }); expect(response.status).toBe(401); - expect(fetchMock).toHaveBeenCalledTimes(2); + // 业务 401 + refresh 401 收敛重试 + refresh 仍 401:只有两次刷新都被明确拒绝, + // 才判定登录态权威失效并广播一次全局鉴权变化。 + expect( + fetchMock.mock.calls.filter(([input]) => input === '/api/auth/refresh'), + ).toHaveLength(2); + expect(fetchMock).toHaveBeenCalledTimes(3); expect(dispatchEventMock).toHaveBeenCalledTimes(1); expect(getStoredAccessToken()).toBe(''); }); @@ -405,7 +411,9 @@ describe('apiClient', () => { it('keeps local token when explicit refresh opts out of clearing on failure', async () => { setStoredAccessToken('usable-local-token', { emit: false }); - fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 })); + fetchMock + .mockResolvedValueOnce(createResponseMock({ status: 401 })) + .mockResolvedValueOnce(createResponseMock({ status: 401 })); await expect( refreshStoredAccessToken({ clearOnFailure: false }), @@ -456,7 +464,9 @@ describe('apiClient', () => { it('clears local token when refresh confirms the session is unauthorized', async () => { setStoredAccessToken('expired-local-token', { emit: false }); - fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 })); + fetchMock + .mockResolvedValueOnce(createResponseMock({ status: 401 })) + .mockResolvedValueOnce(createResponseMock({ status: 401 })); await expect(refreshStoredAccessToken()).rejects.toMatchObject({ status: 401, @@ -466,6 +476,32 @@ describe('apiClient', () => { expect(getStoredAccessToken()).toBe(''); }); + it('retries refresh once with the current cookie after a rotation race', async () => { + setStoredAccessToken('expired-local-token', { emit: false }); + fetchMock + // 并发轮换竞争:这一次 refresh 拿到的是被另一个客户端轮换过的旧 cookie。 + .mockResolvedValueOnce(createResponseMock({ status: 401 })) + // 收敛重试使用浏览器当前 cookie,拿到轮换后的新 token。 + .mockResolvedValueOnce( + createResponseMock({ + status: 200, + body: JSON.stringify({ + ok: true, + data: { token: 'converged-token' }, + error: null, + meta: { apiVersion: '2026-06-16' }, + }), + }), + ); + + await expect(refreshStoredAccessToken()).resolves.toBe('converged-token'); + expect( + fetchMock.mock.calls.filter(([input]) => input === '/api/auth/refresh'), + ).toHaveLength(2); + expect(getStoredAccessToken()).toBe('converged-token'); + expect(dispatchEventMock).not.toHaveBeenCalled(); + }); + it('does not clear auth when protected request refresh fails transiently', async () => { setStoredAccessToken('expired-token-during-restart', { emit: false }); fetchMock diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 880e52921..f594ca662 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -775,7 +775,7 @@ async function refreshAccessToken() { return refreshAccessTokenAttempt.promise; } - const promise = (async () => { + const performRefresh = async () => { const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'same-origin', @@ -802,6 +802,23 @@ async function refreshAccessToken() { publishRefreshedAccessToken(nextToken, authStateSnapshot); return nextToken; + }; + const promise = (async () => { + try { + return await performRefresh(); + } catch (error) { + const authoritative = + error instanceof ApiClientError && + (error.status === 401 || error.status === 403); + // 登录态已经变化(换号 / 退出 / 另一个 refresh 已发布新 token)时不要重试: + // 这次 refresh 的归属已经过期,重试只会把旧账号的结论带到新代次上。 + if (!authoritative || !isCurrentAuthState(authStateSnapshot)) { + throw error; + } + // 并发轮换收敛:另一个标签页 / 客户端可能刚刚轮换过 refresh cookie,用当前 + // cookie 再试一次。重试成功则继续使用新凭据;重试仍被明确拒绝才算权威失效。 + return await performRefresh(); + } })(); const attempt: RefreshAccessTokenAttempt = { ...authStateSnapshot, diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index d846e24e3..79e965d9f 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -1112,6 +1112,7 @@ describe('editorProjectClient', () => { referenceId: 'editor-resource-spec', referenceImageSrcs: references.slice(0, 5), iconDescriptions: ['返回按钮'], + sliceMode: 'connected-components', model: 'gpt-image-2', }), ).rejects.toThrow('图标素材参考图最多允许 4 张'); @@ -1162,6 +1163,29 @@ describe('editorProjectClient', () => { ); }); + it('requires an explicit slice declaration and rejects contradictory grid dimensions', async () => { + requestJsonMock.mockResolvedValue({ queueState: { status: 'queued' } }); + + await expect( + generateEditorIconSpritesheet({ + referenceId: 'editor-resource-icon-spec', + iconDescriptions: ['返回按钮'], + sliceMode: 'grid', + }), + ).rejects.toThrow('sliceMode=grid 必须同时提供 gridX 与 gridY'); + await expect( + generateEditorIconSpritesheet({ + referenceId: 'editor-resource-icon-spec', + iconDescriptions: ['返回按钮'], + sliceMode: 'connected-components', + gridX: 2, + gridY: 2, + }), + ).rejects.toThrow('sliceMode=connected-components 不接受 gridX/gridY'); + + expect(requestJsonMock).not.toHaveBeenCalled(); + }); + it('rejects oversized stable video reference fields before submission', async () => { await expect( generateEditorVideo({ @@ -1331,6 +1355,9 @@ describe('editorProjectClient', () => { referenceId: 'editor-resource-spec', referenceImageSrcs: ['/generated-images/editor/icon-ref.png'], iconDescriptions: ['返回按钮', '设置按钮'], + sliceMode: 'grid', + gridX: 3, + gridY: 2, assetLabel: '冒险游戏图标', }); @@ -1348,6 +1375,9 @@ describe('editorProjectClient', () => { referenceId: 'editor-resource-spec', referenceImageSrcs: ['/generated-images/editor/icon-ref.png'], iconDescriptions: ['返回按钮', '设置按钮'], + sliceMode: 'grid', + gridX: 3, + gridY: 2, model: 'gemini-3.1-flash-image-preview', assetLabel: '冒险游戏图标', }), @@ -1365,6 +1395,7 @@ describe('editorProjectClient', () => { generateEditorIconSpritesheet({ referenceId: 'editor-resource-spec', iconDescriptions: ['图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1)], + sliceMode: 'connected-components', }), ).rejects.toThrow(`不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`); @@ -1377,6 +1408,7 @@ describe('editorProjectClient', () => { '图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS), ), ], + sliceMode: 'connected-components', }), ).rejects.toThrow( `合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS} 个字符`, @@ -1388,6 +1420,7 @@ describe('editorProjectClient', () => { iconDescriptions: Array.from({ length: 8 }, () => '😀'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS), ), + sliceMode: 'connected-components', }), ).rejects.toThrow( `合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES} 个 UTF-8 字节`, @@ -1412,6 +1445,7 @@ describe('editorProjectClient', () => { await generateEditorIconSpritesheet({ referenceId: 'editor-resource-spec', iconDescriptions: ['返回按钮'], + sliceMode: 'connected-components', model: 'gpt-image-2', screenColor: '#E6D8FF', segModel: 'anime-seg', @@ -1434,6 +1468,7 @@ describe('editorProjectClient', () => { body: JSON.stringify({ referenceId: 'editor-resource-spec', iconDescriptions: ['返回按钮'], + sliceMode: 'connected-components', model: 'gpt-image-2', screenColor: '#E6D8FF', segModel: 'anime-seg', diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index e84825190..55003d328 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -380,7 +380,8 @@ export type EditorIconSpritesheetGenerationInput = { referenceId: string; referenceImageSrcs?: string[]; iconDescriptions: string[]; - sliceMode?: 'connected-components' | 'grid'; + /** 必填且没有默认值:必须显式声明连通域或网格切分。 */ + sliceMode: 'connected-components' | 'grid'; gridX?: number; gridY?: number; model?: string; @@ -1300,6 +1301,16 @@ export async function generateEditorIconSpritesheet( input.iconDescriptions, ); const model = input.model?.trim() || EDITOR_IMAGE_MODEL_NANOBANANA2; + // 切分模式没有默认值:声明必须自洽,网格尺寸只能与 grid 同时提交。 + if (input.sliceMode === 'grid') { + if (input.gridX === undefined || input.gridY === undefined) { + throw new Error('sliceMode=grid 必须同时提供 gridX 与 gridY'); + } + } else if (input.gridX !== undefined || input.gridY !== undefined) { + throw new Error( + 'sliceMode=connected-components 不接受 gridX/gridY:网格尺寸只能与 grid 同时提交', + ); + } assertStableEditorMediaReferences(input.referenceImageSrcs, '图标素材参考图'); assertEditorReferenceLimit( input.referenceImageSrcs, @@ -1317,9 +1328,13 @@ export async function generateEditorIconSpritesheet( ? { referenceImageSrcs: input.referenceImageSrcs } : {}), iconDescriptions, - ...(input.sliceMode ? { sliceMode: input.sliceMode } : {}), - ...(input.gridX !== undefined ? { gridX: input.gridX } : {}), - ...(input.gridY !== undefined ? { gridY: input.gridY } : {}), + sliceMode: input.sliceMode, + ...(input.sliceMode === 'grid' && input.gridX !== undefined + ? { gridX: input.gridX } + : {}), + ...(input.sliceMode === 'grid' && input.gridY !== undefined + ? { gridY: input.gridY } + : {}), model, ...(input.screenColor ? { screenColor: input.screenColor } : {}), ...(input.segModel ? { segModel: input.segModel } : {}), diff --git a/vitest.config.ts b/vitest.config.ts index 0af75894a..b017131fc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,13 @@ const reactFileManagerPackageRoot = path.dirname( export default defineConfig({ resolve: { alias: [ + { + find: /^@genarrative\/shared\/components$/, + replacement: path.resolve( + __dirname, + 'packages/shared/src/components/index.ts', + ), + }, { find: /^@cubone\/react-file-manager$/, replacement: path.resolve( @@ -22,6 +29,26 @@ export default defineConfig({ }, // Keep shared components' application-root imports resolvable in tests. { find: '@', replacement: path.resolve(__dirname, '.') }, + // worktree 复用依赖时,共享包仍须解析到当前检出的源码。 + { + find: '@genarrative/shared/components/account', + replacement: path.resolve( + __dirname, + 'packages/shared/src/components/account.ts', + ), + }, + { + find: '@genarrative/shared/components', + replacement: path.resolve(__dirname, 'packages/shared/src/components'), + }, + { + find: '@genarrative/shared/lib', + replacement: path.resolve(__dirname, 'packages/shared/src/lib'), + }, + { + find: /^@genarrative\/shared$/, + replacement: path.resolve(__dirname, 'packages/shared/src/index.ts'), + }, { find: '@genarrative/image-canvas-core', replacement: path.resolve( @@ -119,6 +146,7 @@ export default defineConfig({ 'src/components/platform-entry/platformProfile*.test.ts', 'src/components/platform-entry/usePlatformProfileCenterController*.test.tsx', 'src/hooks/useHostNavigationCanGoBack.test.tsx', + 'src/hooks/useResolvedAssetReadUrl.test.tsx', 'apps/admin-web/src/**/*.test.ts', 'apps/admin-web/src/**/*.test.tsx', 'apps/ai-game-creator-shell/tests/**/*.test.ts',