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/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 650ebf029..442b1342e 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.29", + "version": "0.1.47", "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,6 +58,7 @@ "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", "vite": "^6.2.0", @@ -70,6 +72,7 @@ "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@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..579220319 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 { createHash } from 'node:crypto'; import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -28,14 +29,30 @@ 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', +}; + +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 +83,117 @@ export function nextPatchVersion(localVersion, remoteVersion) { return `${major}.${minor}.${patch + 1}`; } -async function readRemoteVersion() { +export function resolveReleasePlatform(target = releaseTarget) { + 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 = releaseTarget, +) { + 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`; +} + +/** + * 更新插件按运行时平台键查找清单条目:universal macOS 包同时挂 + * `darwin-aarch64` 与 `darwin-x86_64`,单架构目标只挂对应键。 + */ +export function resolveManifestPlatformKeys(target = releaseTarget) { + if (target === 'universal-apple-darwin') { + return ['darwin-aarch64', 'darwin-x86_64']; + } + 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 readManifestVersion(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(); } catch (error) { - throw new Error(`OSS 版本清单不是有效 JSON:${error.message}`); + throw new Error(`${label} 不是有效 JSON:${error.message}`); } - return parseVersion(manifest?.version, 'OSS版本清单 version'); + return parseVersion(manifest?.version, `${label} version`); +} + +/** + * 版本高水位:渠道清单与旧协议迁移指针取较大值。 + * + * 只看渠道清单会在「渠道刚启用、旧指针还停在更高版本」时把版本链改小 —— + * 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) { @@ -94,8 +202,9 @@ function replaceVersionLine(source, version, pattern, label) { } export async function prepareReleaseVersion() { + const channel = resolveReleaseChannel(); 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,8 +267,8 @@ 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; } @@ -187,18 +296,43 @@ export function buildTauriBuildArguments( ]; } +/** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */ +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 = []) { + const tauriArguments = buildTauriBuildArguments(args); + if (!tauriArguments.includes('--config') && !tauriArguments.includes('-c')) { + const channel = resolveReleaseChannel(); + const configPath = writeChannelConfigFile(channel); + console.log( + `[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`, + ); + tauriArguments.push('--config', configPath); + } const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const result = spawnSync( npmCommand, - [ - '--prefix', - '../..', - 'exec', - 'tauri', - '--', - ...buildTauriBuildArguments(args), - ], + ['--prefix', '../..', 'exec', 'tauri', '--', ...tauriArguments], { cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' }, ); if (result.error) throw result.error; @@ -216,10 +350,14 @@ function listFiles(root) { function artifactPriority(filePath) { 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; + // 更新链路要的是 updater 产物(macOS 为 .app.tar.gz),dmg 只作人工分发。 + if (releaseTarget.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; } @@ -242,36 +380,100 @@ export function selectReleaseArtifact(files) { ); } -export function createUpdateManifest(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, + { + channel = resolveReleaseChannel(), + target = releaseTarget, + publishedAt = new Date().toISOString(), + } = {}, +) { + const signature = readUpdaterSignature(artifactPath); + const version = readPackageJson().version; + const fileName = path.basename(artifactPath); + const url = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`; + const platforms = {}; + for (const key of resolveManifestPlatformKeys(target)) { + platforms[key] = { signature, url }; + } + const notes = readReleaseNotes(); + return { + version, + ...(notes ? { notes } : {}), + pub_date: publishedAt, + platforms, + }; +} + +/** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */ +export function createLegacyUpdateManifest( + artifactPath, + { channel = resolveReleaseChannel() } = {}, +) { const bytes = fs.readFileSync(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 notes = readReleaseNotes(); return { version, - downloadUrl: `${baseUrl}/${encodeURIComponent(version)}/${encodedFileName}`, + downloadUrl: `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`, 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 ? { releaseNotes: notes } : {}), }; } export function generateUpdateManifest() { + const channel = resolveReleaseChannel(); const artifact = selectReleaseArtifact(listFiles(bundleRoot)); if (!artifact) { throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`); } - const manifest = createUpdateManifest(artifact); + const manifest = createUpdateManifest(artifact, { channel }); const manifestPath = path.join(bundleRoot, 'latest.json'); fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); - console.log(`[ai-game-creator-shell] 已生成 ${manifestPath}`); + const legacyManifest = + channel === 'dev-win' + ? createLegacyUpdateManifest(artifact, { channel }) + : 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 }; + if (legacyManifestPath) { + console.log( + `[ai-game-creator-shell] 旧协议迁移清单:${legacyManifestPath}`, + ); + } + return { + channel, + artifact, + manifest, + manifestPath, + legacyManifest, + legacyManifestPath, + }; } if ( 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..f085a10d0 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -1,24 +1,78 @@ import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { 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 { compareVersions, + createChannelConfig, + createLegacyUpdateManifest, createUpdateManifest, nextPatchVersion, + resolveManifestPlatformKeys, + resolveReleaseChannel, + resolveRemoteHighWaterVersion, 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'; + +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', () => { @@ -28,47 +82,164 @@ 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.deepEqual(createChannelConfig('dev-mac'), { + plugins: { + updater: { + endpoints: [ + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/latest.json', + ], + }, + }, + }); + }); +}); + +test('universal macOS builds publish one artifact under both platform keys', () => { + assert.deepEqual(resolveManifestPlatformKeys(universalTarget), [ + 'darwin-aarch64', + 'darwin-x86_64', + ]); + assert.deepEqual(resolveManifestPlatformKeys(windowsTarget), [ + 'windows-x86_64', + ]); +}); + +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, ); - assert.equal(manifest.releaseNotes, '第一行\n第二行\r\n第三行'); } finally { - if (previous === undefined) delete process.env.AGC_UPDATE_RELEASE_NOTES; - else process.env.AGC_UPDATE_RELEASE_NOTES = previous; + rmSync(directory, { recursive: true, force: true }); } }); -test('next release version follows the higher local or OSS version', () => { +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 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); }); diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index e32dfb4b7..ab7a0270c 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -121,6 +121,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', 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..08c83c5dd 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,6 +10,7 @@ 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'); @@ -19,6 +22,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] : []; @@ -38,11 +53,40 @@ function runOssutil(args) { await prepareReleaseVersion(); runTauriBuild([]); -const { artifact, manifestPath, manifest } = generateUpdateManifest(); -const artifactKey = `agc/${manifest.version}/${path.basename(artifact)}`; +const { artifact, channel, legacyManifestPath, manifest, manifestPath } = + generateUpdateManifest(); +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/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 7e07a3c9c..42699068e 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.29" +version = "0.1.47" 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 4fe37d25a..ba259ccf3 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.29" +version = "0.1.47" 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"] @@ -47,7 +50,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 +57,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/capabilities/main.json b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json index 43257763a..b62ed9e70 100644 --- a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json +++ b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json @@ -14,13 +14,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..84fb51e53 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 @@ -13,8 +13,8 @@ Let the client derive projections from real disk changes and trusted tool result 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. 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, and returns only bounded queue state. 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..63ddd9a82 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 @@ -14,4 +14,6 @@ Read scopes remain separate: `asset.list` is the current project's local manifes `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 External v1 `/api/external/v1/editor/images/background-removals` call. Mode and colour are part of request identity. 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. 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..385454c47 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.19", "skills": [ { "name": "agc-game-production-workflow", @@ -63,7 +63,7 @@ "agents/openai.yaml", "references/platform-art-contract.md" ], - "sha256": "ff3e1645a35fc9bff1ef255aa7bdc2a9729843d68729589b6f2670c84b8130ec" + "sha256": "c6329c6a3cbd17a237d042349d7fd8adcf240287ef56d23b49329923e976d534" }, { "name": "agc-web-game-development", @@ -123,7 +123,7 @@ "agents/openai.yaml", "references/projection-contract.md" ], - "sha256": "96b5bf9e2ed150bbe934a888867c1bb500b214a131f8b36c4830f51ca30267b6" + "sha256": "0700d4a7a18ee6151811f38786211ad416863f2e425fdc2ded67555a0a1923a1" } ] } 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..c5c036c2b 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,20 @@ 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 +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/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 25d1591e4..457185fc0 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 @@ -131,7 +131,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 +138,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 +195,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 +270,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", @@ -2018,7 +2031,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 +2205,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())), @@ -3560,7 +3580,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 +3607,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 @@ -4483,6 +4503,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] @@ -4743,6 +4776,8 @@ mod tests { 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(), @@ -5528,6 +5563,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() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index ebe6152b8..9e907c2c0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -1,5 +1,6 @@ use super::design_tools::*; use super::*; +use futures::FutureExt; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::fs::File; @@ -11,6 +12,46 @@ use uuid::Uuid; const DESIGN_ACTIVE_LOCK: &str = ".agent/design-agent/active.lock"; +const DESIGN_PANIC_PUBLIC_ERROR: &str = + "策划运行发生内部错误,本轮已中断,可直接重试;若反复出现请反馈。"; + +// 运行段经 task-local 携带项目根,panic hook 据此把位置和负载写进私有 design_debug。 +// task-local 而非 thread-local:多线程 runtime 下 future 会跨 worker 迁移。 +tokio::task_local! { + static DESIGN_PANIC_ROOT: Option; +} + +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_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index 130c01549..ed9e5e576 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 @@ -482,9 +482,38 @@ pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result Result, String> { + read_direct_project_history_entries_filtered_at(root, None, false) +} + +fn is_direct_project_chat_message(item: &Value) -> bool { + matches!( + item.get("role").and_then(Value::as_str), + Some("user" | "assistant") + ) && item + .get("content") + .and_then(Value::as_array) + .is_some_and(|parts| { + parts.iter().any(|part| { + part.get("text") + .and_then(Value::as_str) + .is_some_and(|text| !text.is_empty()) + }) + }) +} + +/// 消息模式逐行丢弃工具输出,只保留聊天正文,避免 40 MiB 工具日志被整表积累或发给 UI。 +fn read_direct_project_history_entries_filtered_at( + root: &Path, + before_item_id: Option<&str>, + messages_only: bool, +) -> Result, String> { let path = history_path(root); if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? { - return Ok(Vec::new()); + return if before_item_id.is_some() { + Err("DirectProject 历史游标对应的文件已不存在".to_string()) + } else { + Ok(Vec::new()) + }; } let file = File::open(&path) .map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?; @@ -519,6 +548,12 @@ fn read_direct_project_history_entries_at(root: &Path) -> Result Result Err(format!("DirectProject 历史中不存在 item:{item_id}")), + None => Ok(items), + } +} + +pub(crate) fn read_direct_project_chat_items_slice_at( + root: &Path, + before_item_id: Option<&str>, + limit: usize, +) -> Result<(Vec, bool, BTreeMap), String> { + let entries = read_direct_project_history_entries_filtered_at(root, before_item_id, true)?; + let mut start = entries.len().saturating_sub(limit.clamp(1, 200)); + // 旧消息可能没有 ID:保留原文,并向前扩到可寻址的已有 ID,不能制造原始消息身份。 + while start > 0 + && entries[start] + .0 + .get("id") + .and_then(Value::as_str) + .is_none_or(str::is_empty) + { + start -= 1; + } + let timestamps = entries[start..] + .iter() + .filter_map(|(item, at)| { + let id = item.get("id").and_then(Value::as_str)?; + (*at > 0).then(|| (id.to_string(), *at)) + }) + .collect(); + Ok(( + entries + .into_iter() + .skip(start) + .map(|(item, _)| item) + .collect(), + start > 0, + timestamps, + )) } pub(crate) fn read_direct_project_history_items_slice_at( @@ -640,6 +713,165 @@ mod tests { const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#; const RESPONSE_ASSISTANT_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"已完成"}]}}"#; + fn write_items(root: &std::path::Path, items: &[Value]) { + let lines = items + .iter() + .enumerate() + .map(|(index, item)| { + json!({"type": "response_item", "payload": item, "recordedAt": 1000 + index}) + .to_string() + }) + .collect::>(); + write_history_lines(root, &lines.iter().map(String::as_str).collect::>()); + } + + #[test] + fn chat_pages_skip_tool_only_tail_and_gaps_without_losing_messages_or_times() { + let root = init_history_project("message-pages"); + let mut raw = Vec::new(); + let mut expected = Vec::new(); + for n in 0..44 { + let item = json!({ + "id": format!("message-{n}"), "type": "message", + "role": if n == 0 || n == 38 { "user" } else { "assistant" }, + "content": [{"type": "output_text", "text": format!("消息 {n}")}], + }); + expected.push(item.clone()); + raw.push(item); + for tool in 0..25 { + raw.push(json!({ + "id": format!("tool-{n}-{tool}"), "type": "function_call_output", + "output": "工具结果不应占聊天页名额", + })); + } + } + write_items(root.path(), &raw); + let path = history_path(root.path()); + let before = std::fs::read(&path).unwrap(); + let (old_page, _, _) = + super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); + assert!(old_page + .iter() + .all(|item| item["type"] == "function_call_output")); + let mut cursor = None; + let mut all = Vec::new(); + let mut sizes = Vec::new(); + loop { + let (mut page, more, timestamps) = + super::read_direct_project_chat_items_slice_at(root.path(), cursor.as_deref(), 20) + .unwrap(); + sizes.push(page.len()); + for item in &page { + let index = raw.iter().position(|raw| raw["id"] == item["id"]).unwrap(); + assert_eq!( + timestamps[item["id"].as_str().unwrap()], + 1000 + index as u64 + ); + } + let next = page + .first() + .and_then(|item| item["id"].as_str()) + .map(str::to_string); + page.append(&mut all); + all = page; + if !more { + break; + } + assert_ne!(next, cursor); + cursor = next; + assert!(sizes.len() < 10); + } + assert_eq!(sizes, vec![20, 20, 4]); + assert_eq!(all, expected); + assert_eq!(std::fs::read(&path).unwrap(), before); + } + + #[test] + fn chat_pages_handle_empty_content_internal_context_and_missing_ids() { + let root = init_history_project("message-page-boundary"); + write_items( + root.path(), + &[ + json!({"id":"u", "role":"user", "content":[{"text":"第一条"}]}), + json!({"role":"assistant", "content":[{"text":"无ID的旧消息"}]}), + json!({"id":"a", "role":"assistant", "content":[{"text":"最后一条"}]}), + json!({"id":"empty", "role":"assistant", "content":[{"text":""}]}), + json!({"id":"internal", "role":"user", "content":[{"text":"内部"}]}), + json!({"id":"reason", "type":"reasoning", "content":[{"text":"推理"}]}), + ], + ); + let (page, more, _) = + super::read_direct_project_chat_items_slice_at(root.path(), None, 1).unwrap(); + assert_eq!(page[0]["id"], "a"); + assert!(more); + let (page, more, _) = + super::read_direct_project_chat_items_slice_at(root.path(), Some("a"), 1).unwrap(); + assert_eq!(page.len(), 2); + assert_eq!(page[0]["id"], "u"); + assert!(page[1].get("id").is_none()); + assert!(!more); + assert!( + super::read_direct_project_chat_items_slice_at(root.path(), Some("missing"), 20) + .is_err() + ); + write_items( + root.path(), + &[json!({"id":"tool", "type":"function_call", "arguments":"{}"})], + ); + let (page, more, _) = + super::read_direct_project_chat_items_slice_at(root.path(), None, 20).unwrap(); + assert!(page.is_empty()); + assert!(!more); + } + + #[test] + #[ignore = "人工只读诊断:通过 AGC_HISTORY_REPLAY_SOURCE 提供原始历史文件"] + fn replay_external_chat_history_pages_without_mutating_source() { + let source = std::env::var_os("AGC_HISTORY_REPLAY_SOURCE").expect("provide replay source"); + let before = std::fs::read(&source).expect("read source"); + let root = init_history_project("external-history-replay"); + let path = history_path(root.path()); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, &before).unwrap(); + let expected = + super::read_direct_project_history_entries_filtered_at(root.path(), None, true) + .expect("read messages"); + let mut cursor = None; + let mut all = Vec::new(); + let mut pages = 0; + loop { + let (mut items, more, _) = + super::read_direct_project_chat_items_slice_at(root.path(), cursor.as_deref(), 20) + .expect("read page"); + let next = items + .first() + .and_then(|item| item["id"].as_str()) + .map(str::to_string); + items.append(&mut all); + all = items; + pages += 1; + if !more { + break; + } + assert!(next.is_some() && next != cursor, "cursor must advance"); + assert!(pages <= expected.len() + 1, "pagination must terminate"); + cursor = next; + } + assert!( + all.iter().eq(expected.iter().map(|(item, _)| item)), + "message order and content must match" + ); + assert!( + std::fs::read(&source).unwrap() == before, + "source must remain unchanged" + ); + eprintln!( + "history replay: messages={}, pages={pages}, users={}", + all.len(), + all.iter().filter(|item| item["role"] == "user").count() + ); + } + #[test] fn history_timestamps_survive_reload_and_idempotent_append_without_changing_raw_items() { let root = init_history_project("history-time"); 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..945b16c1e 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 项目走 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, @@ -3053,14 +3252,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,8 +3382,12 @@ 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, }; @@ -4350,6 +4546,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 +4644,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 +4929,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 +5251,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| { @@ -5329,6 +5551,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 +5980,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 面板")); @@ -5773,6 +5998,108 @@ mod tests { ); } + #[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] fn system_prompt_uses_only_the_reviewed_skill_index() { let root = tempfile::tempdir().expect("temp dir"); @@ -5838,6 +6165,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(); 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..1bec45d0a 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 @@ -59,6 +59,7 @@ pub(crate) struct DirectThreadHistorySlice { pub(crate) items: Vec, pub(crate) has_more: bool, pub(crate) item_timestamps: std::collections::BTreeMap, + pub(crate) oldest_item_id: Option, } #[derive(Clone, Debug)] 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..9e569eaea 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, _) => { @@ -1806,7 +1818,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())); @@ -1887,7 +1899,7 @@ 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"])?; + 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,12 +1908,14 @@ 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()); } @@ -1920,22 +1934,34 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val .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 fingerprint = background_removal_request_fingerprint( + &source_asset_id, + &asset_name, + background_mode, + screen_color, + ); let (_operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?; let route = "/api/external/v1/editor/images/background-removals"; + let mut request_body = 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, + }); + if background_mode == Some("flat") { + request_body["backgroundMode"] = json!("flat"); + } + if let Some(color) = screen_color { + request_body["screenColor"] = json!(color); + } 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, - })), + .json(&request_body), ) .send() .await @@ -1972,6 +1998,20 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val } } +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_safe_queue_state(value: Value) -> Value { let object = value.as_object(); json!({ @@ -2130,6 +2170,36 @@ 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 仅对 kind=art-spritesheet 生效,当前 kind={kind}" + )); + } + Ok(()) +} + async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) -> Value { let result = async { bridge_reject_unknown_fields( @@ -2223,6 +2293,13 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) { return Err("工具参数 gridX/gridY 必须在 1 到 32 之间".to_string()); } + validate_generate_image_slice_declaration( + kind.as_str(), + slice_mode.as_deref(), + grid_x, + grid_y, + None, + )?; let options = PlatformArtAssetGenerationOptions { output_path, aspect_ratio, @@ -2269,6 +2346,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 +2788,78 @@ 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}" + ); + assert!(validate_generate_image_slice_declaration("image", None, None, None, None).is_ok()); + } + + #[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 +2901,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!( 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..f30331490 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!({ @@ -248,20 +273,19 @@ 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 同时提供" } }, "required": ["prompt"], @@ -407,7 +431,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 +442,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 +490,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 +504,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"], @@ -840,25 +905,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 +922,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 +939,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(()) } @@ -1241,7 +1331,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 +1850,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 +1887,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 +1939,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() { @@ -2254,6 +2392,20 @@ mod tests { image_tool["inputSchema"]["properties"]["sliceMode"]["enum"], json!(["connected-components", "grid"]) ); + 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 +2818,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/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 8275aa26c..a3b5be2a0 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); @@ -1510,10 +1514,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 +1522,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 +1564,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, @@ -2491,6 +2470,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( @@ -3129,6 +3133,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 +3201,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 +6533,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 +6587,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 +6627,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 +7174,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 +7398,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 +7409,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 +7425,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, @@ -7711,6 +7785,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 +7797,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, @@ -9812,7 +9892,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 +9916,6 @@ mod canvas_generation_tests { None, "route", "kind", - None, &[], false, false, @@ -9894,7 +9972,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, @@ -10636,7 +10713,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 +10838,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(), @@ -12364,7 +12447,7 @@ 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, } @@ -12397,7 +12480,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 +12806,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/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..02b48e807 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, 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_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index 6f7aa3441..5ba2980c2 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 @@ -695,6 +695,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/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index ca97077f1..739dc5a3f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1939,8 +1939,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 +1949,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 +2009,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 +2047,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 +2060,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 +2090,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) { // 仅同步已有覆盖项;其它字段继续保留原有覆盖语义。 @@ -4231,14 +4265,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()?; @@ -4620,7 +4647,10 @@ 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, }, @@ -5008,7 +5038,21 @@ 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) } #[tauri::command] @@ -5374,19 +5418,29 @@ pub(crate) async fn read_direct_project_history_slice( project_path: String, before_item_id: Option, limit: Option, + messages_only: 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 read_slice = if messages_only.unwrap_or(false) { + read_direct_project_chat_items_slice_at + } else { + read_direct_project_history_items_slice_at + }; + let (items, has_more, item_timestamps) = + read_slice(root, before_item_id.as_deref(), limit.unwrap_or(20))?; + let oldest_item_id = items + .first() + .and_then(|item| item.get("id")) + .and_then(serde_json::Value::as_str) + .filter(|id| !id.is_empty()) + .map(str::to_string); Ok(DirectThreadHistorySlice { items, has_more, item_timestamps, + oldest_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/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index bec55a981..60d3113a3 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,12 +2578,13 @@ 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, @@ -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/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/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index b1f292976..46bb81c24 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 @@ -728,7 +728,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 +748,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 +773,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() @@ -4389,14 +4407,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( @@ -4774,14 +4785,7 @@ pub(crate) fn list_pending_local_project_resource_edits_at( let current_platform_session = current_platform_session(); let _platform_session_lease = current_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) { @@ -5084,12 +5088,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 { @@ -5500,7 +5500,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 +5912,7 @@ mod tests { "source-binding-token-b", api_base_url, *generation, + *generation, ) .expect("switch account after source registration"); } @@ -6925,7 +6927,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 +6954,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 +7021,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 +7035,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 +7451,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 +7519,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 +7546,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 +7651,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 +8272,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 +8402,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"); @@ -9347,7 +9362,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 +9375,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_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..d7ca4b4ba 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 @@ -24,6 +24,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)] @@ -163,6 +166,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, }) } 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..ee2cea8dc --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -0,0 +1,1272 @@ +//! 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, +) -> Result { + let projects_root = app + .path() + .app_data_dir() + .map(|root| root.join("projects")) + .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))?; + 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..a91336e16 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()) 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..5d746c7cc 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 @@ -1233,6 +1233,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() }, )) 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..dd4946ea4 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 @@ -5885,6 +5885,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 +5905,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 +5925,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()), 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/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 c5fa7f96b..2c8833ab9 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.29", + "version": "0.1.47", "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/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..be33bfe93 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -239,10 +239,11 @@ import { DeveloperProjectPanels } from './features/project-workspace/DeveloperPr import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels'; import { type DirectThreadConsumeResult, - directThreadHistoryItemsToMessages, + directThreadHistoryPage, type DirectThreadHistorySlice, type DirectThreadSubscriptionBootstrap, isDirectTurnInProgress, + prependDirectHistoryMessages, } from './features/project-workspace/directThreadEvents'; import { normalizeDirectTimestamp } from './features/project-workspace/directTurnPresentation'; import type { DirectCodexUserContentPart } from './features/project-workspace/generated'; @@ -605,16 +606,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; } @@ -624,7 +626,7 @@ function claimInitialSupervisorMessageForPage(projectPath: string) { * 初始需求是**乐观插入**到 messages 的(latch 命中后先插一条 user 消息,再发起回合), * 而历史回读在 replace 分支里是无条件整体替换 —— 只要回读晚于乐观插入,那条用户消息 * 就会被冲掉(界面上看不到初始需求,但回合其实已经跑起来了)。 - * 这里把当前 messages 里"运行时拥有、且回读结果里没有"的消息保留在末尾(它们是最新的)。 + * 这里只把当前 messages 里"非历史来源、运行时拥有、且回读结果里没有"的消息保留在末尾。 */ function mergeLoadedConversationWithPendingRuntimeMessages( loaded: ChatMessage[], @@ -642,7 +644,7 @@ function mergeLoadedConversationWithPendingRuntimeMessages( loaded.map((message) => `${message.role}\u0000${message.text}`), ); const pending = current.filter((message) => { - if (!message.runtimeOwned) { + if (!message.runtimeOwned || message.fromHistory) { return false; } if (message.messageId) { @@ -683,6 +685,7 @@ type AppProps = { activeVersionId?: ProjectSupervisorComponentProps['activeVersionId']; supervisorChatOnly?: boolean; initialSupervisorMessage?: string; + initialSupervisorMessageClaimScope?: string; initialCreationType?: HomeCreationType | null; initialAttachments?: LauncherImportedAttachment[]; playRequest?: ProjectSupervisorComponentProps['playRequest']; @@ -733,6 +736,7 @@ export function App({ activeVersionId = null, supervisorChatOnly = false, initialSupervisorMessage = '', + initialSupervisorMessageClaimScope = '', initialCreationType = null, initialAttachments = [], playRequest = null, @@ -870,6 +874,7 @@ export function App({ const initialSupervisorMessageLatchRef = useRef({ projectPath: initialProjectPath, prompt: initialSupervisorMessage.trim(), + claimScope: initialSupervisorMessageClaimScope, creationType: initialCreationType, attachments: toDirectCodexTurnAttachments(initialAttachments), }); @@ -1954,7 +1959,7 @@ export function App({ ); const [directHistoryHasMore, setDirectHistoryHasMore] = useState(false); const directHistoryOldestItemIdRef = useRef(null); - const directHistoryLoadingRef = useRef(false); + const directHistoryLoadingRef = useRef(null); const [pendingCommand, setPendingCommand] = useState( null, ); @@ -2147,6 +2152,7 @@ export function App({ function resetProjectSupervisorState() { projectSupervisorHistoryLoadVersionRef.current += 1; + directHistoryLoadingRef.current = null; projectSupervisorRuntimeResumeProjectPathRef.current = null; projectSupervisorSessionIdRef.current = null; projectSupervisorRuntimeRef.current = null; @@ -4005,6 +4011,8 @@ export function App({ } const loadVersion = projectSupervisorHistoryLoadVersionRef.current + 1; projectSupervisorHistoryLoadVersionRef.current = loadVersion; + if (directCodexProductRuntime) + directHistoryLoadingRef.current = loadVersion; try { // V2 projects do not have a Supervisor run or legacy conversation. Probe the // V2 authority first; a missing V2 session returns null and preserves the @@ -4101,6 +4109,7 @@ export function App({ : await readProjectSupervisorActiveSession(invoke, nextProjectPath); let runtimeError = ''; let loadedDirectHistoryHasMore = false; + let loadedDirectHistoryCursor: string | null = null; const projectConversation = directCodexProductRuntime ? (() => { return invoke( @@ -4108,16 +4117,16 @@ export function App({ { projectPath: nextProjectPath, limit: CONVERSATION_INITIAL_VISIBLE_COUNT, + messagesOnly: true, }, ).then((slice) => { - loadedDirectHistoryHasMore = slice.hasMore; + const page = directThreadHistoryPage(slice); + loadedDirectHistoryHasMore = page.hasMore; + loadedDirectHistoryCursor = page.cursor; return { path: nextProjectPath, agentId: null, - messages: directThreadHistoryItemsToMessages( - slice.items, - slice.itemTimestamps, - ), + messages: page.messages, } satisfies LocalConversationResult; }); })() @@ -4193,7 +4202,7 @@ export function App({ const conversationMessages = mergeProjectSupervisorConversation( resolvedProjectConversation.messages, supervisorConversation?.messages ?? [], - ); + ).map((message) => ({ ...message, fromHistory: true })); if ( conversationContainsProjectSupervisorResponseStream( supervisorConversation?.messages ?? [], @@ -4214,9 +4223,7 @@ export function App({ setProjectSupervisorRuntimeError(runtimeError || resumeError); if (directCodexProductRuntime) { setDirectHistoryHasMore(loadedDirectHistoryHasMore); - directHistoryOldestItemIdRef.current = - conversationMessages.find((message) => message.messageId) - ?.messageId ?? null; + directHistoryOldestItemIdRef.current = loadedDirectHistoryCursor; } setMessages((current) => { // replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。 @@ -4277,6 +4284,10 @@ export function App({ : workspaceStatus, ); // Keep the default greeting when history is missing or blocked. + } finally { + if (directHistoryLoadingRef.current === loadVersion) { + directHistoryLoadingRef.current = null; + } } } @@ -4392,6 +4403,9 @@ export function App({ setAgentRunHistoryFiles([]); setAgentRuntimeById({}); setMessages(conversationMessages); + // 只有新项目确实打开后才丢弃旧分页位置;打开失败时旧会话仍可继续翻页。 + directHistoryOldestItemIdRef.current = null; + setDirectHistoryHasMore(false); setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); savedConversationProjectPathRef.current = openedProject.projectPath; savedConversationCountRef.current = conversationMessages.length; @@ -7501,7 +7515,7 @@ export function App({ } if ( chatAgentBusy || - !claimInitialSupervisorMessageForPage(latch.projectPath) + !claimInitialSupervisorMessageForPage(latch.projectPath, latch.claimScope) ) { return; } @@ -12476,48 +12490,63 @@ export function App({ : null; async function showEarlierConversationMessages() { + if (hiddenConversationCount > 0) { + setConversationVisibleCount((current) => + Math.min(messages.length, current + CONVERSATION_VISIBLE_STEP), + ); + return; + } if (directCodexProductRuntime && directHistoryHasMore) { const invoke = resolveTauriInvoke(); const projectPath = localProject?.projectPath; - if (invoke && projectPath && !directHistoryLoadingRef.current) { - directHistoryLoadingRef.current = true; + if (invoke && projectPath && directHistoryLoadingRef.current === null) { + const loadVersion = projectSupervisorHistoryLoadVersionRef.current; + const beforeItemId = directHistoryOldestItemIdRef.current; + directHistoryLoadingRef.current = loadVersion; + const isCurrentLoad = () => + manifestRefreshMountedRef.current && + localProjectPathRef.current === projectPath && + projectSupervisorHistoryLoadVersionRef.current === loadVersion; try { const slice = await invoke( 'read_direct_project_history_slice', { projectPath, - beforeItemId: directHistoryOldestItemIdRef.current, + beforeItemId, limit: CONVERSATION_VISIBLE_STEP, + messagesOnly: true, }, ); - if (localProjectPathRef.current !== projectPath) { + if (!isCurrentLoad()) { return; } - const older = directThreadHistoryItemsToMessages( - slice.items, - slice.itemTimestamps, - ).map((message) => ({ + const page = directThreadHistoryPage(slice, beforeItemId); + const older = page.messages.map((message) => ({ role: message.role === 'user' ? ('user' as const) : ('assistant' as const), text: message.content, runtimeOwned: true, + fromHistory: true, messageId: message.messageId, updatedAt: message.updatedAt, })); - setMessages((current) => [...older, ...current]); + setMessages((current) => + prependDirectHistoryMessages(current, older), + ); setConversationVisibleCount((current) => current + older.length); - setDirectHistoryHasMore(slice.hasMore); - directHistoryOldestItemIdRef.current = - older.find((message) => message.messageId)?.messageId ?? - directHistoryOldestItemIdRef.current; + setDirectHistoryHasMore(page.hasMore); + directHistoryOldestItemIdRef.current = page.cursor; } catch (error) { + if (!isCurrentLoad()) return; setWorkspaceStatus( `读取更早的对话历史失败:${error instanceof Error ? error.message : String(error)}`, ); } finally { - directHistoryLoadingRef.current = false; + if (directHistoryLoadingRef.current === loadVersion) { + directHistoryLoadingRef.current = null; + } } } return; diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 2c9fe3834..fdef7af6f 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -404,7 +404,9 @@ export function AuthenticatedClient({ ); return; } - if (result.status === 'failed') { + // 仅服务端明确否认当前身份时才登出。网络错误、5xx 和网关错误属于刷新暂时 + // 不可用,必须保留既有会话与 access token。 + if (result.status === 'failed' && result.authoritative) { clearStoredAuthAccessToken(); setAuthUser(null); setAuthStatus('unauthenticated'); diff --git a/apps/ai-game-creator-shell/src/app/featureFlags.ts b/apps/ai-game-creator-shell/src/app/featureFlags.ts new file mode 100644 index 000000000..32c8c77f6 --- /dev/null +++ b/apps/ai-game-creator-shell/src/app/featureFlags.ts @@ -0,0 +1,32 @@ +/** + * AGC 客户端构建期特性开关。 + * + * 开关只读取 `VITE_*` 构建期变量,运行时不改变;未显式配置时按运行环境回落: + * 开发态(`npm run agc` / `agc:serve` 的 Vite dev server 提供前端)取 `devValue`, + * 正式包取反。 + */ +function resolveFeatureFlag( + flag: string | undefined, + { devValue, dev }: { devValue: boolean; dev: boolean }, +) { + const value = flag?.trim(); + if (value === '1') return true; + if (value === '0') return false; + return dev ? devValue : !devValue; +} + +/** + * 客户端更新检查(启动时的更新提示与“关于”里的手动检查)总开关。 + * + * 开发态默认关闭:`agc` 启动的客户端不请求 OSS 更新清单,也不显示更新入口。 + * 需要联调更新流程时用 `VITE_AGC_ENABLE_APP_UPDATE_CHECK=1` 显式打开, + * 正式包也可用 `=0` 关闭。 + */ +export function resolveAppUpdateCheckEnabled( + flag: string | undefined = import.meta.env.VITE_AGC_ENABLE_APP_UPDATE_CHECK, + dev: boolean = import.meta.env.DEV, +) { + return resolveFeatureFlag(flag, { devValue: false, dev }); +} + +export const appUpdateCheckEnabled = resolveAppUpdateCheckEnabled(); diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 1a8e48d72..6fb26adc6 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -757,6 +757,8 @@ export type RuntimeAgentLlmProviderPresetId = | RuntimeLlmProviderPresetId; export interface GameCreatorLlmConfig { + customEnabled?: boolean; + visibleModels?: string[]; apiKey: string; baseUrl: string; model: string; @@ -997,6 +999,8 @@ export interface ChatMessage { agentId?: string | null; updatedAt?: number; runtimeOwned?: boolean; + /** 来自历史回读,不作为尚未落盘的实时消息追加到新历史页末尾。 */ + fromHistory?: boolean; } export type DesignAgentInput = 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..a9d4c478e 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,11 +38,24 @@ 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 retryTimerRef = useRef(null); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; + if (retryTimerRef.current !== null) { + window.clearTimeout(retryTimerRef.current); + retryTimerRef.current = null; + } }; }, []); @@ -67,18 +80,23 @@ export function useDirectActiveTurns({ if (!mountedRef.current) { 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 (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); + }); } } } @@ -94,8 +112,10 @@ export function useDirectActiveTurns({ useEffect(() => { if (!enabled || !invoke) { - setActiveTurns([]); - setSnapshotReadFailed(false); + lastSnapshotSignatureRef.current = ''; + // 空态也要保持引用稳定:已经空了就不要再换一个新数组。 + setActiveTurns((current) => (current.length === 0 ? current : [])); + setSnapshotReadFailed((current) => (current ? false : current)); return; } void refreshActiveTurns(); 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' ? ( void; }; -const APPROVED_GDD_BUILD_PROMPT = [ +export const APPROVED_GDD_BUILD_PROMPT = [ '请按照附件中的已批准 GDD 开始建造这款游戏。', '', '这份 GDD 已覆盖游戏定位与一句话概念、类型与美术方向、游戏支柱、核心循环、目标用户、平台与输入事实、MVP 系统、暂不纳入范围、创作者提示和原型验证项。', @@ -74,6 +74,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 +555,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); @@ -1002,6 +1034,7 @@ export function useHomeProjectCreation({ renameProject, pickAndOpenProject, pickAndCreateProject, + enterCreatedTemplateProject, confirmCreateInNonEmptyFolder, cancelCreateInNonEmptyFolder, }; 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/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index d222b1fbd..604b908e0 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 @@ -918,7 +918,8 @@ export function ProjectSupervisorView({ rows={3} value={chatInput} references={chatReferences} - showTriggerButton={!directCodex} + showTriggerButton={!directCodex && !planningSurfaceActive} + showPolishAction={!planningSurfaceActive} placeholder={ directCodex ? '描述你的想法,或 @ 引用素材' diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts index 6c1cad229..32180fcff 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts @@ -1,4 +1,7 @@ -import type { LocalConversationMessageRecord } from '../../app/types'; +import type { + ChatMessage, + LocalConversationMessageRecord, +} from '../../app/types'; export type DirectThreadRawEvent = { seq: number; @@ -22,8 +25,54 @@ export type DirectThreadHistorySlice = { items: unknown[]; hasMore: boolean; itemTimestamps?: Record; + oldestItemId?: string | null; }; +/** 游标取原始响应,而非过滤后的聊天消息;拒绝不能前进的页,避免静默反复回读。 */ +export function directThreadHistoryPage( + slice: DirectThreadHistorySlice, + previousCursor: string | null = null, +) { + const first = slice.items[0]; + const firstId = + first && typeof first === 'object' && 'id' in first + ? (first as { id?: unknown }).id + : null; + const cursor = + slice.oldestItemId ?? + (typeof firstId === 'string' && firstId ? firstId : null); + if (slice.hasMore && (!cursor || cursor === previousCursor)) { + throw new Error('对话历史分页游标未前进,请重新读取项目历史'); + } + return { + messages: directThreadHistoryItemsToMessages( + slice.items, + slice.itemTimestamps, + ), + hasMore: slice.hasMore, + cursor, + }; +} + +/** 保留当前实时/已显示版本;原始身份相同的回读消息不能插入第二次。 */ +export function prependDirectHistoryMessages( + current: readonly ChatMessage[], + older: readonly ChatMessage[], +): ChatMessage[] { + const ids = new Set( + current.flatMap((message) => + message.messageId ? [message.messageId] : [], + ), + ); + const additions = older.filter((message) => { + if (!message.messageId) return true; + if (ids.has(message.messageId)) return false; + ids.add(message.messageId); + return true; + }); + return [...additions, ...current]; +} + export function directThreadHistoryItemsToMessages( items: unknown[], itemTimestamps: Readonly> = {}, diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts index a2c24def2..92ba12136 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasFocusModel.ts @@ -37,6 +37,23 @@ export function isResourceCanvasInteractionTarget( return Boolean(target?.closest(RESOURCE_CANVAS_INTERACTION_SELECTOR)); } +/** 抓手可从卡面发起,但不能抢走输入、媒体控件或画布浮层的交互。 */ +export function isResourceCanvasPanTarget( + target: Element | null | undefined, +): boolean { + if (!target) return false; + if (target.closest('[contenteditable="true"], .game-resource-filter-panel')) { + return false; + } + if (!isResourceCanvasInteractionTarget(target)) return true; + return Boolean( + target.closest('.game-resource-card') && + !target.closest( + 'button:not(.game-resource-card-select), input, textarea, select, a, audio, video', + ), + ); +} + /** * 画布浮层里有自己滚动区的那几个:落在它们里面的滚轮归浮层,画布不得消费。 * diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceDocumentPreviewModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceDocumentPreviewModel.ts index c0bc85c78..390d08c15 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceDocumentPreviewModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceDocumentPreviewModel.ts @@ -1,4 +1,5 @@ import { + isProjectResourceJson, projectResourceCardPreviewKind, projectResourcePathExtension, } from '../../view/project-development/resourceCardPreviewModel'; @@ -65,11 +66,14 @@ export function resourceDocumentPreviewMarkdown( resource: ProjectResource, content: string, ) { - if (projectResourceCardPreviewKind(resource) !== 'code') { + const isJson = isProjectResourceJson(resource); + if (projectResourceCardPreviewKind(resource) !== 'code' && !isJson) { return content; } - const language = - CODE_LANGUAGES[projectResourcePathExtension(resource.path) ?? ''] ?? 'text'; + const language = isJson + ? 'json' + : (CODE_LANGUAGES[projectResourcePathExtension(resource.path) ?? ''] ?? + 'text'); // 围栏长于源码里的任意反引号串,代码生成模板中的 Markdown 不能提前闭合代码块。 const longestRun = (content.match(/`+/g) ?? []).reduce( (length, run) => Math.max(length, run.length), diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/CustomLlmSettings.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/CustomLlmSettings.tsx new file mode 100644 index 000000000..b75938007 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/runtime-config/CustomLlmSettings.tsx @@ -0,0 +1,157 @@ +import { useEffect, useRef, useState } from 'react'; + +import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField'; +import { resolveTauriInvoke } from '../../app/tauri'; +import type { GameCreatorLlmConfig } from '../../app/types'; + +export function CustomLlmSettings({ + llm, + disabled, + onChange, +}: { + llm: GameCreatorLlmConfig; + disabled: boolean; + onChange: (llm: GameCreatorLlmConfig) => void; +}) { + const [available, setAvailable] = useState([]); + const [query, setQuery] = useState(''); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState(''); + const [error, setError] = useState(''); + const requestEpoch = useRef(0); + const selected = llm.visibleModels ?? []; + + useEffect(() => { + requestEpoch.current += 1; + setAvailable([]); + setBusy(false); + setStatus(''); + setError(''); + return () => { + requestEpoch.current += 1; + }; + }, [llm.baseUrl, llm.apiKey]); + + async function discover() { + const invoke = resolveTauriInvoke(); + if (!invoke || busy) return; + const epoch = ++requestEpoch.current; + setBusy(true); + setError(''); + setStatus('正在读取模型列表'); + try { + const models = await invoke( + 'discover_game_creator_llm_models', + { llm }, + ); + if (epoch !== requestEpoch.current) return; + setAvailable(models); + setStatus( + models.length + ? `已读取 ${models.length} 个模型` + : '端点没有返回可用模型', + ); + } catch (error) { + if (epoch !== requestEpoch.current) return; + setStatus(''); + setError(typeof error === 'string' ? error : '模型列表读取失败,请重试'); + } finally { + if (epoch === requestEpoch.current) setBusy(false); + } + } + + const candidates = [...new Set([...available, ...selected])].filter((id) => + id.toLowerCase().includes(query.trim().toLowerCase()), + ); + return ( +
+ + + + {status ?

{status}

: null} + {error ?

{error}

: null} +
+
+

可选模型

+ setQuery(event.currentTarget.value)} + /> +
+ {candidates.map((id) => ( + + ))} +
+
+
+

已勾选模型({selected.length})

+ {selected.length ? ( +
    + {selected.map((id, index) => ( +
  1. + {id} + {index === 0 ? 默认 : null} +
  2. + ))} +
+ ) : ( +

尚未勾选模型

+ )} +
+
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx index 289a0cfbb..48980e2ab 100644 --- a/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx +++ b/apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx @@ -22,6 +22,7 @@ import { closeDialogOnEscape, useEscapeToClose, } from '../../app/dialogs'; +import { appUpdateCheckEnabled } from '../../app/featureFlags'; import { resolveTauriInvoke } from '../../app/tauri'; import { type AgcPluginPanel, @@ -34,6 +35,7 @@ import { gameCreatorLlmReasoningEfforts, } from '../../app/types'; import { checkForAppUpdate } from '../../services/appUpdate'; +import { notifyLlmConfigChanged } from '../../services/llmModelCatalog'; import { listAgcExtensions, reloadAgcPlugin, @@ -43,11 +45,15 @@ import { stopAgcPlugin, } from '../../services/pluginHost'; import { PluginPanelHost } from '../plugins/PluginPanelHost'; +import { reasoningEffortLabel } from '../project-workspace/composerReasoningEffort'; +import { CustomLlmSettings } from './CustomLlmSettings'; const defaultRuntimeConfigDraft: GameCreatorAppConfig = { schemaVersion: 'game-creator-config.v2', agentMode: 'codex_app_server', llm: { + customEnabled: false, + visibleModels: [], apiKey: '', baseUrl: '', model: '', @@ -59,7 +65,7 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = { autoCompactTokenLimit: 64000, toolOutputTokenLimit: 12000, requestTimeoutMs: 180000, - maxRetries: 2, + maxRetries: 10, retryBackoffMs: 500, }, agentLlm: {}, @@ -148,9 +154,9 @@ function normalizeRuntimeConfigDraft( agentMode: 'codex_app_server', llm: { ...config.llm, - apiKey: '', - baseUrl: '', - model: '', + apiKey: config.llm.customEnabled ? config.llm.apiKey : '', + baseUrl: config.llm.customEnabled ? config.llm.baseUrl : '', + model: config.llm.customEnabled ? config.llm.model : '', apiKind: 'openai_responses', reasoningEffort, webSearchEnabled: @@ -182,9 +188,7 @@ function normalizeRuntimeConfigDraft( maxRetries: clampRuntimeConfigNumber(config.llm.maxRetries, 0), retryBackoffMs: clampRuntimeConfigNumber(config.llm.retryBackoffMs, 1), }, - // Official AGC builds have one account-backed route. Drop every legacy - // per-agent override (including non-sensitive tuning) at the UI boundary - // so it cannot be persisted or accidentally re-exposed as a route. + // 客户端统一使用全局连接,设置不保留独立的 Agent 路由覆盖。 agentLlm: {}, editorApi: allowAdvancedExternalEditorConfig ? { ...defaultRuntimeConfigDraft.editorApi, ...config.editorApi } @@ -621,6 +625,7 @@ export function RuntimeConfigDialog({ advancedExternalEditorConfigEnabled, ); setRuntimeConfigDraft(savedConfig); + notifyLlmConfigChanged(); setRuntimeConfigStatus(`已保存:${result.path}`); setRuntimeConfigToast({ tone: 'success', @@ -641,7 +646,13 @@ export function RuntimeConfigDialog({ } function resetRuntimeConfigDraft() { - setRuntimeConfigDraft(defaultRuntimeConfigDraft); + setRuntimeConfigDraft({ + ...defaultRuntimeConfigDraft, + llm: { + ...defaultRuntimeConfigDraft.llm, + customEnabled: runtimeConfigDraft.llm.customEnabled, + }, + }); setRuntimeConfigStatus('已恢复默认配置,保存后生效'); } @@ -785,13 +796,49 @@ export function RuntimeConfigDialog({
工作方式 陶泥儿智能创作(固定) - 需求将由官方智能服务执行 + {!runtimeConfigDraft.llm.customEnabled ? ( + 需求将由官方智能服务执行 + ) : null}
智能服务 - 官方账号服务(固定) - 登录后自动使用当前账号权限。 + + {runtimeConfigDraft.llm.customEnabled + ? '自定义 LLM' + : '官方账号服务(固定)'} + + {!runtimeConfigDraft.llm.customEnabled ? ( + 登录后自动使用当前账号权限。 + ) : null}
+ {runtimeConfigDraft.llm.customEnabled ? ( + <> +
+ 协议 + OpenAI Responses + 自定义端点需兼容该协议。 +
+
+ 推理档 + + {reasoningEffortLabel( + runtimeConfigDraft.llm.reasoningEffort, + )} + + 在对话输入盒的模型旁按回合调整。 +
+ + setRuntimeConfigDraft((current) => ({ + ...current, + llm, + })) + } + /> + + ) : null} {runtimeConfigDraft.agentMode !== 'codex_cli' ? ( <> {/* 推理档已下移到对话输入盒的模型选择器旁(按回合生效), @@ -1286,18 +1333,20 @@ export function RuntimeConfigDialog({
桌面客户端
-
- - - {appUpdateStatus} - -
+ {appUpdateCheckEnabled ? ( +
+ + + {appUpdateStatus} + +
+ ) : null} ) : null}
diff --git a/apps/ai-game-creator-shell/src/features/template-library/templateLibraryGrid.ts b/apps/ai-game-creator-shell/src/features/template-library/templateLibraryGrid.ts new file mode 100644 index 000000000..539c5b7a8 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/template-library/templateLibraryGrid.ts @@ -0,0 +1,96 @@ +/** + * 模板库卡片网格的布局计算(纯函数)。 + * + * 页面用 `react-window` 的 `FixedSizeGrid` 做虚拟滚动:只渲染可视区域的行, + * 因此这里负责把「容器宽度 + 条目数」换算成列数、列宽、行高与行数, + * 保证卡片尺寸、封面比例与行高完全确定(虚拟列表要求固定行高)。 + */ + +import type { GameTemplateEntry } from './templateLibraryModel'; + +/** 卡片最小宽度(与 CSS 里旧的 `minmax(250px,1fr)` 口径一致)。 */ +export const TEMPLATE_CARD_MIN_WIDTH = 250; +/** 卡片之间的水平/垂直间隙。 */ +export const TEMPLATE_CARD_GAP = 14; +/** 封面宽高比:16:9。 */ +export const TEMPLATE_CARD_COVER_RATIO = 9 / 16; +/** 卡片封面以下的文字与按钮区固定高度。 */ +export const TEMPLATE_CARD_TEXT_HEIGHT = 150; +/** 额外预渲染的行数,减小快速滚动时的白屏。 */ +export const TEMPLATE_GRID_OVERSCAN_ROWS = 2; + +export type TemplateGridLayout = { + columnCount: number; + /** FixedSizeGrid 的列宽(含卡片右侧间隙)。 */ + columnWidth: number; + /** FixedSizeGrid 的行高(含卡片下方间隙)。 */ + rowHeight: number; + rowCount: number; +}; + +export function computeTemplateGridColumns(containerWidth: number): number { + if (!Number.isFinite(containerWidth) || containerWidth <= 0) { + return 1; + } + const columns = Math.floor( + (containerWidth + TEMPLATE_CARD_GAP) / + (TEMPLATE_CARD_MIN_WIDTH + TEMPLATE_CARD_GAP), + ); + return Math.max(1, columns); +} + +export function computeTemplateRowHeight(columnWidth: number): number { + const cardWidth = Math.max( + TEMPLATE_CARD_MIN_WIDTH, + Math.round(columnWidth) - TEMPLATE_CARD_GAP, + ); + return ( + Math.ceil(cardWidth * TEMPLATE_CARD_COVER_RATIO) + + TEMPLATE_CARD_TEXT_HEIGHT + + TEMPLATE_CARD_GAP + ); +} + +export function computeTemplateGridLayout({ + containerWidth, + itemCount, +}: { + containerWidth: number; + itemCount: number; +}): TemplateGridLayout { + const columnCount = computeTemplateGridColumns(containerWidth); + const columnWidth = Math.max(1, Math.floor(containerWidth / columnCount)); + return { + columnCount, + columnWidth, + rowHeight: computeTemplateRowHeight(columnWidth), + rowCount: Math.max(0, Math.ceil(Math.max(0, itemCount) / columnCount)), + }; +} + +/** 按行切分,行尾补 `null` 占位,保证虚拟列表的列索引与条目一一对应。 */ +export function buildTemplateRows( + templates: readonly GameTemplateEntry[], + columnCount: number, +): Array> { + if (columnCount <= 0) { + return []; + } + const rows: Array> = []; + for (let index = 0; index < templates.length; index += columnCount) { + const row: Array = []; + for (let column = 0; column < columnCount; column += 1) { + row.push(templates[index + column] ?? null); + } + rows.push(row); + } + return rows; +} + +/** 虚拟列表的稳定 key:行内槽位固定,避免筛选后复用错卡片。 */ +export function templateGridItemKey( + rowIndex: number, + columnIndex: number, +): string { + return `template-cell-${rowIndex}-${columnIndex}`; +} diff --git a/apps/ai-game-creator-shell/src/features/template-library/templateLibraryModel.ts b/apps/ai-game-creator-shell/src/features/template-library/templateLibraryModel.ts new file mode 100644 index 000000000..65f460fb9 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/template-library/templateLibraryModel.ts @@ -0,0 +1,208 @@ +/** + * AGC 模板库的前端模型:清单类型、搜索与筛选的纯函数。 + * + * 真源在 OSS 清单与 Rust 侧(`fetch_game_template_library`);这里只做展示层派生, + * 不缓存业务真相,也不拼远端地址(URL 由 Rust 侧按受信任 OSS 前缀给出)。 + */ + +export type GameTemplateLibrarySource = 'network' | 'cache'; + +export type GameTemplateEntry = { + id: string; + title: string; + summary: string; + tags: string[]; + runtime: string; + engine: string; + engineVersion: string; + templateVersion: string; + updatedAt: string; + entry: string; + zipUrl: string; + zipSizeBytes: number; + zipSha256: string; + coverUrl: string; + coverWidth: number; + coverHeight: number; + installed: boolean; + installedVersion: string | null; + installedAtMillis: number | null; +}; + +export type GameTemplateLibrarySnapshot = { + schemaVersion: string; + library: string; + libraryVersion: number; + updatedAt: string; + fetchedAtMillis: number; + source: GameTemplateLibrarySource; + templates: GameTemplateEntry[]; +}; + +export type InstalledGameTemplate = { + templateId: string; + templateVersion: string; + installedAtMillis: number; + zipSha256: string; + fileCount: number; + projectDir: string; +}; + +export type TemplateLibraryFilters = { + query: string; + tags: readonly string[]; + runtime: string; + installedOnly: boolean; +}; + +export const EMPTY_TEMPLATE_LIBRARY_FILTERS: TemplateLibraryFilters = { + query: '', + tags: [], + runtime: '', + installedOnly: false, +}; + +const RUNTIME_LABELS: Record = { + html: '网页', + unity: 'Unity', + godot: 'Godot', + cocos: 'Cocos', +}; + +export function templateRuntimeLabel(runtime: string): string { + const normalized = runtime.trim().toLowerCase(); + if (!normalized) return '未标注运行时'; + return RUNTIME_LABELS[normalized] ?? runtime.trim(); +} + +/** + * 空白分隔的多个关键词之间是「与」关系:每个词都必须命中标题、简介、标签或引擎, + * 这样「三消 像素」不会退化成命中任意一个就出现的宽泛搜索。 + */ +export function templateMatchesQuery( + template: GameTemplateEntry, + query: string, +): boolean { + const terms = query + .toLowerCase() + .split(/\s+/u) + .filter((term) => term.length > 0); + if (terms.length === 0) { + return true; + } + const haystack = [ + template.title, + template.summary, + template.engine, + template.runtime, + template.tags.join(' '), + ] + .join(' ') + .toLowerCase(); + return terms.every((term) => haystack.includes(term)); +} + +export function filterGameTemplates( + templates: readonly GameTemplateEntry[], + filters: TemplateLibraryFilters, +): GameTemplateEntry[] { + const selectedTags = filters.tags + .map((tag) => tag.trim().toLowerCase()) + .filter((tag) => tag.length > 0); + const runtime = filters.runtime.trim().toLowerCase(); + return templates.filter((template) => { + if (filters.installedOnly && !template.installed) { + return false; + } + if (runtime && template.runtime.trim().toLowerCase() !== runtime) { + return false; + } + if (selectedTags.length > 0) { + const templateTags = template.tags.map((tag) => tag.toLowerCase()); + if (!selectedTags.some((tag) => templateTags.includes(tag))) { + return false; + } + } + return templateMatchesQuery(template, filters.query); + }); +} + +/** 标签按出现次数降序,次数相同按名称排序,保证筛选条顺序稳定。 */ +export function collectGameTemplateTags( + templates: readonly GameTemplateEntry[], +): string[] { + const counts = new Map(); + for (const template of templates) { + for (const tag of template.tags) { + const trimmed = tag.trim(); + if (!trimmed) continue; + counts.set(trimmed, (counts.get(trimmed) ?? 0) + 1); + } + } + return [...counts.entries()] + .sort( + ([leftTag, leftCount], [rightTag, rightCount]) => + rightCount - leftCount || leftTag.localeCompare(rightTag, 'zh-CN'), + ) + .map(([tag]) => tag); +} + +export function collectGameTemplateRuntimes( + templates: readonly GameTemplateEntry[], +): string[] { + const runtimes = new Set(); + for (const template of templates) { + const runtime = template.runtime.trim().toLowerCase(); + if (runtime) runtimes.add(runtime); + } + return [...runtimes].sort((left, right) => + left.localeCompare(right, 'zh-CN'), + ); +} + +export function formatGameTemplateSize(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) { + return '--'; + } + if (bytes < 1024) { + return `${Math.round(bytes)} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function isTemplateLibraryFiltersEmpty( + filters: TemplateLibraryFilters, +): boolean { + return ( + !filters.query.trim() && + filters.tags.length === 0 && + !filters.runtime.trim() && + !filters.installedOnly + ); +} + +export function toggleGameTemplateTag( + filters: TemplateLibraryFilters, + tag: string, +): TemplateLibraryFilters { + const exists = filters.tags.includes(tag); + return { + ...filters, + tags: exists + ? filters.tags.filter((value) => value !== tag) + : [...filters.tags, tag], + }; +} + +/** + * 已安装版本低于清单版本时必须重新下载;已安装且版本一致才算可直接使用。 + */ +export function needsTemplateDownload(template: GameTemplateEntry): boolean { + return ( + !template.installed || + template.installedVersion !== template.templateVersion + ); +} diff --git a/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts b/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts new file mode 100644 index 000000000..aa1ef20a7 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts @@ -0,0 +1,243 @@ +/** + * 模板库状态链路:拉取清单、下载模板、用模板建项目。 + * + * 远端真相全在 Rust 侧命令里(受信任 OSS 前缀 + 摘要校验 + 本机安装记录); + * 这里只维护界面状态,并在下载成功后把对应条目的安装状态就地更新, + * 避免为了一个"已下载"徽标再打一次清单请求。 + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { resolveTauriInvoke } from '../../app/tauri'; +import type { InitLocalProjectResult } from '../../app/types'; +import { + collectGameTemplateRuntimes, + collectGameTemplateTags, + EMPTY_TEMPLATE_LIBRARY_FILTERS, + filterGameTemplates, + type GameTemplateEntry, + type GameTemplateLibrarySnapshot, + type InstalledGameTemplate, + isTemplateLibraryFiltersEmpty, + needsTemplateDownload, + type TemplateLibraryFilters, + toggleGameTemplateTag, +} from './templateLibraryModel'; + +export type TemplateLibraryStatus = 'idle' | 'loading' | 'ready' | 'error'; +export type TemplateLibraryBusyKind = 'download' | 'create'; + +type UseTemplateLibraryOptions = { + /** 项目已建好:由调用方负责进入项目工作区(模板库不碰工作区状态)。 */ + onProjectCreated: (result: InitLocalProjectResult) => Promise | void; +}; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function useTemplateLibrary({ + onProjectCreated, +}: UseTemplateLibraryOptions) { + const [snapshot, setSnapshot] = useState( + null, + ); + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); + const [filters, setFilters] = useState( + EMPTY_TEMPLATE_LIBRARY_FILTERS, + ); + const [busyTemplateId, setBusyTemplateId] = useState(null); + const [busyKind, setBusyKind] = useState( + null, + ); + const loadingRef = useRef(false); + + const refresh = useCallback(async () => { + if (loadingRef.current) { + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setStatus('error'); + setError('需要在陶泥儿客户端内运行'); + return; + } + loadingRef.current = true; + setStatus('loading'); + setError(''); + try { + const next = await invoke( + 'fetch_game_template_library', + ); + setSnapshot(next); + setStatus('ready'); + setNotice( + next.source === 'cache' ? '远端清单暂时读不到,当前展示本机缓存' : '', + ); + } catch (nextError) { + setStatus('error'); + setError(errorMessage(nextError)); + } finally { + loadingRef.current = false; + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const downloadTemplate = useCallback(async (template: GameTemplateEntry) => { + const invoke = resolveTauriInvoke(); + if (!invoke) { + throw new Error('需要在陶泥儿客户端内运行'); + } + setBusyTemplateId(template.id); + setBusyKind('download'); + setError(''); + try { + const installed = await invoke( + 'download_game_template', + { + templateId: template.id, + templateVersion: template.templateVersion, + }, + ); + setSnapshot((current) => + current + ? { + ...current, + templates: current.templates.map((entry) => + entry.id === template.id + ? { + ...entry, + installed: true, + installedVersion: installed.templateVersion, + installedAtMillis: installed.installedAtMillis, + } + : entry, + ), + } + : current, + ); + setNotice(`已下载模板「${template.title}」`); + return installed; + } catch (nextError) { + setError(errorMessage(nextError)); + throw nextError; + } finally { + setBusyTemplateId(null); + setBusyKind(null); + } + }, []); + + const createProjectFromTemplate = useCallback( + async (template: GameTemplateEntry) => { + const invoke = resolveTauriInvoke(); + if (!invoke) { + throw new Error('需要在陶泥儿客户端内运行'); + } + try { + if (needsTemplateDownload(template)) { + await downloadTemplate(template); + } + setBusyTemplateId(template.id); + setBusyKind('create'); + setError(''); + setNotice(`正在用模板「${template.title}」创建项目`); + const result = await invoke( + 'create_automatic_local_game_project_from_template', + { + templateId: template.id, + templateVersion: template.templateVersion, + name: null, + planning: false, + }, + ); + await onProjectCreated(result); + setNotice(`已用模板「${template.title}」创建项目`); + return result; + } catch (nextError) { + setError(errorMessage(nextError)); + throw nextError; + } finally { + setBusyTemplateId(null); + setBusyKind(null); + } + }, + [downloadTemplate, onProjectCreated], + ); + + const templates = useMemo( + () => snapshot?.templates ?? [], + [snapshot?.templates], + ); + const visibleTemplates = useMemo( + () => filterGameTemplates(templates, filters), + [templates, filters], + ); + const tagOptions = useMemo( + () => collectGameTemplateTags(templates), + [templates], + ); + const runtimeOptions = useMemo( + () => collectGameTemplateRuntimes(templates), + [templates], + ); + const installedCount = useMemo( + () => templates.filter((template) => template.installed).length, + [templates], + ); + const filtersActive = !isTemplateLibraryFiltersEmpty(filters); + + const setQuery = useCallback((query: string) => { + setFilters((current) => ({ ...current, query })); + }, []); + + const selectRuntime = useCallback((runtime: string) => { + setFilters((current) => ({ + ...current, + runtime: current.runtime === runtime ? '' : runtime, + })); + }, []); + + const toggleTag = useCallback((tag: string) => { + setFilters((current) => toggleGameTemplateTag(current, tag)); + }, []); + + const setInstalledOnly = useCallback((installedOnly: boolean) => { + setFilters((current) => ({ ...current, installedOnly })); + }, []); + + const clearFilters = useCallback(() => { + setFilters(EMPTY_TEMPLATE_LIBRARY_FILTERS); + }, []); + + return { + snapshot, + status, + error, + notice, + templates, + visibleTemplates, + tagOptions, + runtimeOptions, + installedCount, + filters, + filtersActive, + setQuery, + selectRuntime, + toggleTag, + setInstalledOnly, + clearFilters, + busyTemplateId, + busyKind, + refresh, + downloadTemplate, + createProjectFromTemplate, + clearNotice: useCallback(() => setNotice(''), []), + }; +} + +export type TemplateLibraryController = ReturnType; diff --git a/apps/ai-game-creator-shell/src/services/appUpdate.ts b/apps/ai-game-creator-shell/src/services/appUpdate.ts index f9021f3d0..188909bc8 100644 --- a/apps/ai-game-creator-shell/src/services/appUpdate.ts +++ b/apps/ai-game-creator-shell/src/services/appUpdate.ts @@ -1,124 +1,58 @@ -import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http'; -import { openUrl } from '@tauri-apps/plugin-opener'; +import { + check, + type DownloadEvent, + type Update, +} from '@tauri-apps/plugin-updater'; -import { APP_VERSION } from '../app/appMetadata'; +import { appUpdateCheckEnabled } from '../app/featureFlags'; import { resolveTauriInvoke } from '../app/tauri'; -/** OSS 上的 AGC 更新清单;发布时可覆盖为同一受信任 OSS 域名下的地址。 */ -export const AGC_UPDATE_MANIFEST_URL = - import.meta.env.VITE_AGC_UPDATE_MANIFEST_URL?.trim() || - 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/latest.json'; -export const AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT = - 'agc-update-download-progress'; - -export type AppUpdateManifest = { +/** 更新提示所需的元数据;清单请求、版本比较、下载、校验与安装都由官方更新插件在原生侧完成。 */ +export type AppUpdateInfo = { version: string; - downloadUrl: string; - sha256?: string; - size?: number; + currentVersion: string; releaseNotes?: string; }; -export type AppUpdateInfo = AppUpdateManifest & { - currentVersion: string; +export type AppUpdateProgress = { + downloadedBytes: number; + totalBytes?: number; }; +let pendingUpdate: Update | null = null; let updateCheckPromise: Promise | null = null; const updateListeners = new Set<(update: AppUpdateInfo | null) => void>(); -function parseVersion(value: string) { - const match = value - .trim() - .replace(/^v/iu, '') - .match(/^(\d+)\.(\d+)(?:\.(\d+))?/u); - return match - ? [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)] - : null; -} - -export function isNewerVersion(candidate: string, current: string) { - const next = parseVersion(candidate); - const installed = parseVersion(current); - if (!next || !installed) return false; - for (let index = 0; index < next.length; index += 1) { - const nextValue = next[index] ?? 0; - const installedValue = installed[index] ?? 0; - if (nextValue !== installedValue) return nextValue > installedValue; - } - return false; -} - -export function parseAppUpdateManifest( - value: unknown, -): AppUpdateManifest | null { - if (!value || typeof value !== 'object') return null; - const record = value as Record; - const version = - typeof record.version === 'string' ? record.version.trim() : ''; - const downloadUrl = - typeof record.downloadUrl === 'string' ? record.downloadUrl.trim() : ''; - if (!version || !downloadUrl) return null; - try { - const url = new URL(downloadUrl); - if (url.protocol !== 'https:') return null; - } catch { - return null; - } - const sha256 = - typeof record.sha256 === 'string' - ? record.sha256.trim().toLowerCase() - : undefined; - if (sha256 && !/^[a-f0-9]{64}$/u.test(sha256)) return null; - const size = - typeof record.size === 'number' && - Number.isSafeInteger(record.size) && - record.size > 0 - ? record.size - : undefined; - const releaseNotes = - typeof record.releaseNotes === 'string' - ? record.releaseNotes.trim() - : undefined; +function toAppUpdateInfo(update: Update): AppUpdateInfo { return { - version, - downloadUrl, - ...(sha256 ? { sha256 } : {}), - ...(size ? { size } : {}), - ...(releaseNotes ? { releaseNotes } : {}), + version: update.version, + currentVersion: update.currentVersion, + ...(update.body ? { releaseNotes: update.body } : {}), }; } -async function fetchUpdateManifest() { - const response = - typeof window !== 'undefined' && window.__TAURI__ - ? await tauriHttpFetch(AGC_UPDATE_MANIFEST_URL, { - method: 'GET', - headers: { Accept: 'application/json' }, - }) - : await fetch(AGC_UPDATE_MANIFEST_URL, { - headers: { Accept: 'application/json' }, - }); - if (!response.ok) throw new Error(`更新清单请求失败:${response.status}`); - return parseAppUpdateManifest(await response.json()); +async function runAppUpdateCheck(): Promise { + try { + const update = await check(); + pendingUpdate = update; + const info = update ? toAppUpdateInfo(update) : null; + updateListeners.forEach((listener) => listener(info)); + return info; + } catch { + // 清单 404、渠道缺少当前平台条目、网络或签名错误都按“无更新”收口,不阻塞启动。 + pendingUpdate = null; + return null; + } } -/** 同一客户端生命周期内只请求一次,避免 StrictMode 或多窗口重复检测。 */ +/** 同一客户端生命周期内只请求一次清单;`force` 供「关于」页手动检查使用。 */ export function checkForAppUpdate( options: { force?: boolean } = {}, ): Promise { + // 开发态(`agc` 启动)默认关闭更新检查:不请求清单,也不显示更新入口。 + if (!appUpdateCheckEnabled) return Promise.resolve(null); if (options.force) updateCheckPromise = null; - if (!updateCheckPromise) { - updateCheckPromise = fetchUpdateManifest() - .then((manifest) => { - const update = - manifest && isNewerVersion(manifest.version, APP_VERSION) - ? { ...manifest, currentVersion: APP_VERSION } - : null; - updateListeners.forEach((listener) => listener(update)); - return update; - }) - .catch(() => null); - } + updateCheckPromise ??= runAppUpdateCheck(); return updateCheckPromise; } @@ -129,28 +63,45 @@ export function subscribeToAppUpdate( return () => updateListeners.delete(listener); } -export async function downloadAppUpdate( - downloadUrl: string, - integrity: Pick = {}, +/** + * 下载并安装最近一次检测到的更新。 + * + * Windows 上安装程序接管后客户端退出并由安装程序重启;macOS / Linux 在安装完成后由本函数重启进程。 + */ +export async function installAppUpdate( + onProgress: (progress: AppUpdateProgress) => void = () => undefined, ) { - const url = new URL(downloadUrl); - if (url.protocol !== 'https:') throw new Error('更新下载地址必须使用 HTTPS'); - if (typeof window !== 'undefined' && window.__TAURI__) { - const invoke = resolveTauriInvoke(); - if (invoke) { - return await invoke('download_agc_update', { - downloadUrl: url.toString(), - expectedSha256: integrity.sha256, - expectedSize: integrity.size, - }); - } else { - await openUrl(url.toString()); + const update = pendingUpdate; + if (!update) throw new Error('没有可安装的更新'); + let downloadedBytes = 0; + let totalBytes: number | undefined; + const report = () => + onProgress({ + downloadedBytes, + ...(totalBytes ? { totalBytes } : {}), + }); + await update.downloadAndInstall((event: DownloadEvent) => { + if (event.event === 'Started') { + downloadedBytes = 0; + totalBytes = event.data.contentLength; + } else if (event.event === 'Progress') { + downloadedBytes += event.data.chunkLength; } - return; - } - window.open(url.toString(), '_blank', 'noopener,noreferrer'); + report(); + }); + // 失败时保留待装更新,让「重试」仍能走同一条安装链路。 + pendingUpdate = null; + restartAppAfterUpdate(); +} + +function restartAppAfterUpdate() { + const invoke = resolveTauriInvoke(); + if (!invoke) return; + // Windows 的 install 已在启动安装程序后退出进程,这里只覆盖 macOS / Linux 的重启收敛。 + void invoke('restart_agc_app').catch(() => undefined); } export function resetAppUpdateCheckForTests() { + pendingUpdate = null; updateCheckPromise = null; } diff --git a/apps/ai-game-creator-shell/src/services/clientAuth.ts b/apps/ai-game-creator-shell/src/services/clientAuth.ts index c0b5e0750..1869351e7 100644 --- a/apps/ai-game-creator-shell/src/services/clientAuth.ts +++ b/apps/ai-game-creator-shell/src/services/clientAuth.ts @@ -13,6 +13,8 @@ import type { import { API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION, + isApiResponse, + parseApiErrorMessage, unwrapApiResponse, } from '../../../../packages/shared/src/http'; import { @@ -123,7 +125,13 @@ class ClientAuthRequestError extends Error { } } -function isClientAuthUnauthorizedError(error: unknown) { +/** + * 服务端明确否认当前身份(401/403)才算权威失效。 + * + * 网络错误、5xx、网关错误和响应契约异常都属于"刷新暂时不可用":调用方必须保留既有 + * 会话与 access token,不能把一次瞬时失败放大成登出。 + */ +export function isClientAuthAuthorityFailure(error: unknown) { return ( error instanceof ClientAuthRequestError && (error.status === 401 || error.status === 403) @@ -131,13 +139,25 @@ function isClientAuthUnauthorizedError(error: unknown) { } export function isClientAuthRecoverableCheckError(error: unknown) { - return !isClientAuthUnauthorizedError(error); + return !isClientAuthAuthorityFailure(error); } export function getClientAuthErrorMessage(error: unknown, fallback: string) { return error instanceof Error ? error.message : fallback; } +/** + * 旧形态错误体:未带 `x-genarrative-response-envelope` 时后端返回 + * `{ error: { code, message }, meta }`,没有 `ok` 字段,但 message 同样是给用户看的原因。 + */ +function isLegacyApiErrorBody(value: unknown) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + const record = value as Record; + return 'error' in record || 'message' in record || 'code' in record; +} + async function readAuthErrorMessage(response: Response, fallback: string) { const httpFallback = getClientAuthHttpErrorMessage(response.status, fallback); const text = await readClientHttpResponseText(response, { @@ -150,15 +170,27 @@ async function readAuthErrorMessage(response: Response, fallback: string) { try { parsed = JSON.parse(text) as unknown; } catch { + // 非 JSON(代理错误页、纯文本)不把内部英文原样抛给用户。 return httpFallback; } - try { - unwrapApiResponse(parsed); - } catch (error) { - const message = error instanceof Error ? error.message.trim() : ''; - return message && message !== '请求失败' ? message : httpFallback; + if (isApiResponse(parsed)) { + try { + unwrapApiResponse(parsed); + } catch (error) { + const message = error instanceof Error ? error.message.trim() : ''; + return message && message !== '请求失败' ? message : httpFallback; + } + return httpFallback; } - return httpFallback; + if (!isLegacyApiErrorBody(parsed)) { + return httpFallback; + } + // 旧形态错误体仍按共享契约解析,否则“手机号或密码错误”这类明确原因会退化成固定文案。 + const legacyMessage = parseApiErrorMessage(text, httpFallback).trim(); + // 共享解析器在认不出结构时会回显原始 JSON,这里不允许把它当成用户可见文案。 + return legacyMessage && legacyMessage !== text.trim() + ? legacyMessage + : httpFallback; } async function requestAuthJson( @@ -229,12 +261,24 @@ export async function refreshClientAuthAccessToken( apiBaseUrl, transitionClientOperation(operation, 'network'), ); - const refreshPromise = requestAuthJson( - '/api/auth/refresh', - { method: 'POST' }, - '刷新登录状态失败', - { skipAuth: true, apiBaseUrl }, - ) + const performRefresh = () => + requestAuthJson( + '/api/auth/refresh', + { method: 'POST' }, + '刷新登录状态失败', + { skipAuth: true, apiBaseUrl }, + ); + const refreshWithConvergenceRetry = async () => { + try { + return await performRefresh(); + } catch (error) { + if (!isClientAuthAuthorityFailure(error)) throw error; + // 并发轮换收敛:另一个窗口 / 实例可能刚刚轮换过 refresh cookie,用当前 cookie + // 再试一次。重试成功则继续使用新凭据;重试仍被明确拒绝才算登录态权威失效。 + return await performRefresh(); + } + }; + const refreshPromise = refreshWithConvergenceRetry() .then((response) => { clientAuthRefreshOperations.set( apiBaseUrl, diff --git a/apps/ai-game-creator-shell/src/services/llmModelCatalog.ts b/apps/ai-game-creator-shell/src/services/llmModelCatalog.ts index b4bd4a25d..9601ff2a3 100644 --- a/apps/ai-game-creator-shell/src/services/llmModelCatalog.ts +++ b/apps/ai-game-creator-shell/src/services/llmModelCatalog.ts @@ -1,7 +1,54 @@ +import { resolveTauriInvoke } from '../app/tauri'; +import type { GameCreatorAppConfigView } from '../app/types'; import { type ClientLlmModelCatalog, loadClientLlmModels } from './clientApi'; let cached: ClientLlmModelCatalog | null = null; let inFlight: Promise | null = null; +let source = ''; +let generation = 0; +let localRevision = 0; +export const LLM_CONFIG_CHANGED_EVENT = 'agc-llm-config-changed'; +export class LlmModelCatalogConfigError extends Error {} + +export function notifyLlmConfigChanged() { + generation += 1; + cached = null; + inFlight = null; + source = ''; + window.dispatchEvent(new Event(LLM_CONFIG_CHANGED_EVENT)); +} + +async function loadEffectiveCatalog(epoch: number) { + const invoke = resolveTauriInvoke(); + let config: GameCreatorAppConfigView | undefined; + try { + config = invoke + ? await invoke('read_game_creator_app_config') + : undefined; + } catch (error) { + if (epoch === generation) cached = null; + throw new LlmModelCatalogConfigError('读取客户端配置失败'); + } + if (epoch !== generation) throw new Error('模型配置已更新'); + const llm = config?.config.llm; + const nextSource = llm?.customEnabled + ? JSON.stringify(['custom', llm.baseUrl, llm.visibleModels ?? []]) + : 'official'; + if (source !== nextSource) { + source = nextSource; + cached = null; + } + if (llm?.customEnabled) { + if (cached) return cached; + const ids = llm.visibleModels ?? []; + return { + defaultModelId: ids[0] ?? '', + models: ids.map((id) => ({ id, displayName: id })), + revision: --localRevision, + }; + } + return loadClientLlmModels(); +} /** 最近一次成功读取的模型目录,用于首屏渲染与刷新失败时兜底。 */ export function cachedLlmModelCatalog() { @@ -14,8 +61,10 @@ export function cachedLlmModelCatalog() { */ export function refreshLlmModelCatalog() { if (inFlight) return inFlight; - const request = loadClientLlmModels() + const epoch = generation; + const request = loadEffectiveCatalog(epoch) .then((catalog) => { + if (epoch !== generation) throw new Error('模型配置已更新'); cached = catalog; return catalog; }) @@ -27,6 +76,8 @@ export function refreshLlmModelCatalog() { } export function resetLlmModelCatalogCacheForTest() { + generation += 1; + source = ''; cached = null; inFlight = null; } diff --git a/apps/ai-game-creator-shell/src/services/platformSession.ts b/apps/ai-game-creator-shell/src/services/platformSession.ts index 8f40a9ef6..a28535b03 100644 --- a/apps/ai-game-creator-shell/src/services/platformSession.ts +++ b/apps/ai-game-creator-shell/src/services/platformSession.ts @@ -3,6 +3,7 @@ import { resolveTauriInvoke } from '../app/tauri'; import { getCurrentClientAuthUser, getStoredAuthAccessToken, + isClientAuthAuthorityFailure, refreshClientAuthAccessToken, } from './clientAuth'; import { getClientServerBaseUrl } from './clientHttp'; @@ -14,6 +15,14 @@ import { const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; +function readStoredAccessTokenOrThrow() { + const accessToken = getStoredAuthAccessToken(); + if (!accessToken) { + throw new Error('陶泥儿登录凭据缺失,请重新登录'); + } + return accessToken; +} + type CommittedPlatformSession = { user: AuthUser; accessToken: string; @@ -21,10 +30,25 @@ type CommittedPlatformSession = { generation: number; }; +/** 原生写入:身份代次表达主体归属,revision 只表达写入顺序。 */ +type PlatformNativeSessionWrite = { + identityGeneration: number; + revision: number; +}; + export type PlatformSessionRefreshResult = | { status: 'refreshed'; user: AuthUser; generation: number } | { status: 'stale' } - | { status: 'failed'; error: unknown }; + | { + status: 'failed'; + error: unknown; + /** + * 只有服务端明确否认当前身份(401/403,且收敛重试后仍失败)才为 true。 + * 网络错误、5xx、网关错误和响应契约异常必须保留既有会话与 access token, + * 调用方不得据此把用户登出。 + */ + authoritative: boolean; + }; type PlatformSessionRefreshListener = ( result: PlatformSessionRefreshResult, @@ -33,8 +57,14 @@ type PlatformSessionRefreshListener = ( type PlatformSessionGenerationListener = (generation: number) => void; let platformAuthGeneration = 0; -let platformNativeGeneration = 0; -let platformNativeGenerationFloorPromise: Promise | null = null; +/** 原生写入 revision:每次安装 / 清除都推进,用于拒绝迟到写入。 */ +let platformNativeRevision = 0; +/** 原生身份代次:只在登录、切号、登出或新 authority epoch 推进,续期保持不变。 */ +let platformNativeIdentityGeneration = 0; +let platformNativeSessionFloorPromise: Promise<{ + identityGeneration: number; + revision: number; +}> | null = null; let committedPlatformSession: CommittedPlatformSession | null = null; let desiredPlatformSession: CommittedPlatformSession | null = null; let platformSessionRefreshPromise: Promise | null = @@ -95,7 +125,7 @@ function notifyPlatformSessionGeneration() { async function installNativePlatformSession( session: CommittedPlatformSession, - generation: number, + write: PlatformNativeSessionWrite, ) { const invoke = resolveTauriInvoke(); if (!invoke) return; @@ -103,14 +133,18 @@ async function installNativePlatformSession( userId: session.user.id, accessToken: session.accessToken, apiBaseUrl: session.apiBaseUrl, - generation, + identityGeneration: write.identityGeneration, + revision: write.revision, }); } -async function clearNativePlatformSession(generation: number) { +async function clearNativePlatformSession(write: PlatformNativeSessionWrite) { const invoke = resolveTauriInvoke(); if (!invoke) return; - await invoke('clear_platform_account_session', { generation }); + await invoke('clear_platform_account_session', { + identityGeneration: write.identityGeneration, + revision: write.revision, + }); } function waitForNativeMutationAbandonment( @@ -147,37 +181,60 @@ function enqueuePlatformSessionNativeMutation( async function readNativePlatformSessionGenerationFloor() { const invoke = resolveTauriInvoke(); - if (!invoke) return 0; - const floor = await invoke( - 'read_platform_account_session_generation', - ); + if (!invoke) return { identityGeneration: 0, revision: 0 }; + const state = await invoke<{ + identityGeneration?: unknown; + revision?: unknown; + } | null>('read_platform_account_session_state'); // Browser/unit-test adapters commonly expose a no-op invoke that returns null // for native-only read commands. They have no surviving Rust generation floor. - if (floor === null) return 0; - if (!Number.isSafeInteger(floor) || floor < 0) { - throw new Error('本地运行时登录态 generation 无效,请重启客户端后重试'); + if (state === null || state === undefined) { + return { identityGeneration: 0, revision: 0 }; } - return floor; + const identityGeneration = Number(state.identityGeneration ?? 0); + const revision = Number(state.revision ?? 0); + if ( + !Number.isSafeInteger(identityGeneration) || + identityGeneration < 0 || + !Number.isSafeInteger(revision) || + revision < 0 + ) { + throw new Error('本地运行时登录态写入下限无效,请重启客户端后重试'); + } + return { identityGeneration, revision }; } -async function reserveNativePlatformSessionGeneration() { - platformNativeGenerationFloorPromise ??= +async function reserveNativePlatformSessionWrite(options: { + identityChange: boolean; +}): Promise { + platformNativeSessionFloorPromise ??= readNativePlatformSessionGenerationFloor(); - let nativeGenerationFloor: number; + let floor: { identityGeneration: number; revision: number }; try { - nativeGenerationFloor = await platformNativeGenerationFloorPromise; + floor = await platformNativeSessionFloorPromise; } catch (error) { // 一次瞬时失败(IPC 抖动、Runner 刚重启)不能被缓存成"永久失败":否则本次渲染进程 // 内的后续登录/退出都会在同一个已 reject 的 promise 上失败,用户重试也不会重新读取。 - platformNativeGenerationFloorPromise = null; + platformNativeSessionFloorPromise = null; throw error; } - platformNativeGeneration = Math.max( - platformNativeGeneration + 1, + platformNativeRevision = Math.max( + platformNativeRevision + 1, platformAuthGeneration, - nativeGenerationFloor + 1, + floor.revision + 1, ); - return platformNativeGeneration; + // 同一账号的凭据续期必须复用当前身份代次;只有登录、切号、登出或新 authority epoch + // 才允许推进它,否则在途生成 operation 会被自己的续期判成"旧账号请求"。 + platformNativeIdentityGeneration = options.identityChange + ? Math.max( + platformNativeIdentityGeneration + 1, + floor.identityGeneration + 1, + ) + : Math.max(platformNativeIdentityGeneration, floor.identityGeneration); + return { + identityGeneration: platformNativeIdentityGeneration, + revision: platformNativeRevision, + }; } async function reconcileNativePlatformSessionToCurrentAuthority() { @@ -186,17 +243,20 @@ async function reconcileNativePlatformSessionToCurrentAuthority() { const authoritativeSession = desiredPlatformSession ? { ...desiredPlatformSession } : null; - const reconciliationGeneration = - await reserveNativePlatformSessionGeneration(); + // 只有权威会话与上一次已提交会话不是同一身份时才推进身份代次:同账号续期后的对账 + // 仍然算同一身份,不得让在途 operation 失效。 + const identityChange = + !committedPlatformSession || + !authoritativeSession || + committedPlatformSession.user.id !== authoritativeSession.user.id || + committedPlatformSession.apiBaseUrl !== authoritativeSession.apiBaseUrl; + const write = await reserveNativePlatformSessionWrite({ identityChange }); restoreCurrentRendererAccessToken(); try { if (authoritativeSession) { - await installNativePlatformSession( - authoritativeSession, - reconciliationGeneration, - ); + await installNativePlatformSession(authoritativeSession, write); } else { - await clearNativePlatformSession(reconciliationGeneration); + await clearNativePlatformSession(write); } } catch (error) { if (platformAuthGeneration === authoritativeGeneration) { @@ -229,6 +289,39 @@ function resolvePlatformApiBaseUrl() { return getClientServerBaseUrl(); } +async function commitNativePlatformSession( + candidate: CommittedPlatformSession, + authorityGeneration: number, + options: { identityChange: boolean }, +): Promise { + const write = await reserveNativePlatformSessionWrite({ + identityChange: options.identityChange, + }); + try { + await installNativePlatformSession(candidate, write); + } catch (error) { + if (platformAuthGeneration === authorityGeneration) { + desiredPlatformSession = committedPlatformSession + ? { ...committedPlatformSession } + : null; + } + await reconcileNativePlatformSessionToCurrentAuthority(); + if (platformAuthGeneration !== authorityGeneration) return null; + throw error; + } + if (platformAuthGeneration !== authorityGeneration) { + await reconcileNativePlatformSessionToCurrentAuthority(); + return null; + } + committedPlatformSession = candidate; + desiredPlatformSession = { ...candidate }; + restoreCommittedAccessToken(); + if (options.identityChange) { + notifyPlatformSessionGeneration(); + } + return candidate; +} + async function commitPlatformSession( user: AuthUser, accessToken: string, @@ -251,28 +344,50 @@ async function commitPlatformSession( desiredPlatformSession = { ...candidate }; notifyPlatformSessionGeneration(); restoreCommittedAccessToken(); - const nativeGeneration = await reserveNativePlatformSessionGeneration(); - try { - await installNativePlatformSession(candidate, nativeGeneration); - } catch (error) { - if (platformAuthGeneration === candidate.generation) { - desiredPlatformSession = committedPlatformSession - ? { ...committedPlatformSession } - : null; - } - await reconcileNativePlatformSessionToCurrentAuthority(); - if (platformAuthGeneration !== candidate.generation) return null; - throw error; - } - if (platformAuthGeneration !== candidate.generation) { - await reconcileNativePlatformSessionToCurrentAuthority(); + return commitNativePlatformSession(candidate, candidate.generation, { + identityChange: true, + }); +} + +/** + * 同一身份的凭据续期:只替换 access token 与 native 写入 revision,保持身份代次不变, + * 因此在途生成、编辑、上传、确认和下载 operation 不会被自己的续期判成旧账号请求。 + */ +async function commitPlatformCredentialRefresh( + user: AuthUser, + accessToken: string, + apiBaseUrl: string, + expectedGeneration: number, +): Promise { + if (platformAuthGeneration !== expectedGeneration) { + restoreCurrentRendererAccessToken(); return null; } - committedPlatformSession = candidate; + const current = committedPlatformSession; + if (!current) { + restoreCurrentRendererAccessToken(); + return null; + } + if (current.user.id !== user.id || current.apiBaseUrl !== apiBaseUrl) { + // 身份已经变化:按换号路径重新提交,不能复用旧身份代次。 + return commitPlatformSession( + user, + accessToken, + apiBaseUrl, + expectedGeneration, + ); + } + const candidate: CommittedPlatformSession = { + user, + accessToken, + apiBaseUrl, + generation: current.generation, + }; desiredPlatformSession = { ...candidate }; - restoreCommittedAccessToken(); - notifyPlatformSessionGeneration(); - return candidate; + restoreCurrentRendererAccessToken(); + return commitNativePlatformSession(candidate, expectedGeneration, { + identityChange: false, + }); } export function currentPlatformSessionGeneration() { @@ -283,6 +398,11 @@ export function currentPlatformSessionApiBaseUrl() { return committedPlatformSession?.apiBaseUrl || resolvePlatformApiBaseUrl(); } +/** 仅供测试断言:同一账号续期不得推进这个身份代次。 */ +export function currentPlatformNativeIdentityGenerationForTests() { + return platformNativeIdentityGeneration; +} + export function beginPlatformSessionTransition() { platformAuthGeneration += 1; desiredPlatformSession = committedPlatformSession @@ -304,10 +424,7 @@ export async function commitAuthenticatedPlatformSession( expectedGeneration: number, apiBaseUrl = resolvePlatformApiBaseUrl(), ) { - const accessToken = getStoredAuthAccessToken(); - if (!accessToken) { - throw new Error('陶泥儿登录凭据缺失,请重新登录'); - } + const accessToken = readStoredAccessTokenOrThrow(); const operation = createClientOperation( 'auth-transition', { userId: user.id }, @@ -386,16 +503,21 @@ export function requestPlatformSessionRefresh(expectedUserId?: string) { restoreCurrentRendererAccessToken(); return { status: 'stale' }; } - const committedGeneration = await commitAuthenticatedPlatformSession( - user, - expectedGeneration, - apiBaseUrl, + // 同一账号的续期只更新凭据:身份代次保持不变,因此在途生成 operation 不会被 + // 自己的续期判成旧账号请求。 + const committed = await enqueuePlatformSessionNativeMutation(() => + commitPlatformCredentialRefresh( + user, + readStoredAccessTokenOrThrow(), + apiBaseUrl, + expectedGeneration, + ), ); - if (committedGeneration === null) return { status: 'stale' }; + if (committed === null) return { status: 'stale' }; return { status: 'refreshed', user, - generation: committedGeneration, + generation: committed.generation, }; } catch (error) { if (platformAuthGeneration !== expectedGeneration) { @@ -411,9 +533,12 @@ export function requestPlatformSessionRefresh(expectedUserId?: string) { restoreCurrentRendererAccessToken(); return { status: 'stale' }; } + // 只有服务端明确否认当前身份才算权威失效。网络错误、5xx、网关错误和响应契约 + // 异常必须保留既有会话与 access token,否则一次后台保活抖动就会把用户登出。 + const authoritative = isClientAuthAuthorityFailure(error); if ( - !currentOwnerUserId || - currentOwnerUserId === expectedSessionUserId + authoritative && + (!currentOwnerUserId || currentOwnerUserId === expectedSessionUserId) ) { const clearGeneration = beginPlatformSessionClearTransition(); try { @@ -421,8 +546,10 @@ export function requestPlatformSessionRefresh(expectedUserId?: string) { } catch (clearError) { failure = clearError; } + return { status: 'failed', error: failure, authoritative: true }; } - return { status: 'failed', error: failure }; + restoreCurrentRendererAccessToken(); + return { status: 'failed', error: failure, authoritative: false }; } })().then((result) => { notifyPlatformSessionRefresh(result); @@ -474,9 +601,11 @@ export async function clearCommittedPlatformSession(generation: number) { return; } desiredPlatformSession = null; - const nativeGeneration = await reserveNativePlatformSessionGeneration(); + const write = await reserveNativePlatformSessionWrite({ + identityChange: true, + }); try { - await clearNativePlatformSession(nativeGeneration); + await clearNativePlatformSession(write); } catch { if (platformAuthGeneration === generation) { desiredPlatformSession = null; @@ -516,8 +645,9 @@ export async function clearCommittedPlatformSession(generation: number) { export function resetPlatformSessionStateForTests() { platformAuthGeneration = 0; - platformNativeGeneration = 0; - platformNativeGenerationFloorPromise = null; + platformNativeRevision = 0; + platformNativeIdentityGeneration = 0; + platformNativeSessionFloorPromise = null; committedPlatformSession = null; desiredPlatformSession = null; platformSessionRefreshPromise = null; diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 0f8ee6817..613ec51e4 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -3940,6 +3940,61 @@ h2 { backdrop-filter: blur(6px); } +.runtime-custom-llm { + grid-column: 1 / -1; + display: grid; + gap: 12px; + min-width: 0; +} + +.runtime-custom-llm-discover { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + justify-self: start; + min-height: 34px; + padding: 0 14px; + border: 1px solid var(--platform-surface-border); + border-radius: 9px; + background: var(--platform-button-secondary-fill); + color: var(--platform-button-secondary-text); + font-size: 11px; + font-weight: 700; + cursor: pointer; +} + +.runtime-custom-llm-discover:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.runtime-custom-model-columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); + gap: 16px; +} + +.runtime-custom-model-columns > section { + min-width: 0; + padding: 12px; + border: 1px solid var(--platform-subpanel-border); + border-radius: 12px; +} + +.runtime-custom-model-list { + max-height: 240px; + overflow: auto; + overflow-wrap: anywhere; +} + +.runtime-custom-model-list .settings-checkbox { + display: flex; + align-items: start; + gap: 8px; + margin: 8px 0; +} + .runtime-settings-toast { position: absolute; top: 24px; @@ -4357,6 +4412,34 @@ h2 { color: var(--platform-text-base); } +/* 固定项(工作方式 / 智能服务 / 协议 / 推理档)按「标签 → 取值 → 说明」纵向排列。 */ +.runtime-settings-readonly-field { + display: grid; + gap: 3px; + min-width: 0; + padding: 13px; + border: 1px solid var(--platform-subpanel-border); + border-radius: 12px; + background: var(--runtime-settings-subpanel-fill); +} + +.runtime-settings-readonly-field > span { + color: var(--platform-text-soft); + font-size: 11px; +} + +.runtime-settings-readonly-field > strong { + color: var(--platform-text-strong); + font-size: 14px; + line-height: 1.35; +} + +.runtime-settings-readonly-field > small { + color: var(--platform-text-soft); + font-size: 10px; + line-height: 1.4; +} + .runtime-settings-fields input, .runtime-settings-fields select { border-color: var(--platform-surface-border); @@ -8737,6 +8820,27 @@ iframe.preview-frame { overflow: hidden; } +/* 输入盒的弹层必须能溢出面板:控制排最左侧是「推理档」,它的菜单 + `.conversation-model-menu` 贴着触发钮右缘向左展开,窄布局(视口 ≤1000px 时对话面板 + 只有 280px 宽)下会伸到面板左侧之外;`.game-workbench-chat`、surface、conversation + 这三层 `overflow: hidden` 会沿着各自的溢出边界把它裁掉,档位文字正好落在被裁掉的 + 那半边,于是点开只能看到一个空盒子。所以这里让这三层不再裁切:菜单自身位置、 + 尺寸都不变,只是允许它盖到左侧面板上完整显示。消息列表自带 `overflow-y: auto` + (另一轴按规范计算为 auto),消息内容仍由列表自身裁剪。 */ +.game-workbench-chat:has(.project-supervisor-composer.is-direct-codex) { + overflow: visible; +} + +.game-workbench-chat .project-supervisor-surface.is-direct-codex { + overflow: visible; +} + +.game-workbench-chat + .project-supervisor-surface.is-direct-codex + .project-supervisor-conversation { + overflow: visible; +} + .game-workbench-chat .project-supervisor-message-list { height: 100%; min-height: 96px; @@ -10271,6 +10375,15 @@ button.design-workspace-tree__entry:hover, /* 策划聊天区包含阶段控制卡、消息、Runtime 状态和输入框。GameAgent 资源工作台的 消息列表默认占满整个聊天区,策划模式需要单独恢复五行布局,避免输入框被推到视口外。 */ +/* 策划工作台保留标题行,避免共用跨行规则将标题挤到底部。 */ +.game-workbench-layout--design .game-workbench-chat { + grid-template-rows: auto minmax(0, 1fr); +} + +.game-workbench-layout--design .game-workbench-chat .project-supervisor-surface { + grid-row: 2; +} + .game-workbench-layout--design .project-supervisor-surface { display: block; height: 100%; diff --git a/apps/ai-game-creator-shell/src/view/home/InspirationGallery.tsx b/apps/ai-game-creator-shell/src/view/home/InspirationGallery.tsx deleted file mode 100644 index dc15824e9..000000000 --- a/apps/ai-game-creator-shell/src/view/home/InspirationGallery.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { useEffect, useState } from 'react'; -import { createPortal } from 'react-dom'; - -// TODO: 后续改为由服务器下发灵感资源;届时保留此组件的展示与预览交互,移除本地目录扫描。 -const INSPIRATION_IMAGES = Object.entries( - import.meta.glob('./assets/inspiration/*.{webp,png,jpg,jpeg}', { - eager: true, - import: 'default', - query: '?url', - }), -) - .sort(([left], [right]) => - left.localeCompare(right, undefined, { numeric: true }), - ) - .map(([, image]) => image as string); - -export default function InspirationGallery() { - const [selectedImage, setSelectedImage] = useState(null); - - useEffect(() => { - if (!selectedImage) { - return; - } - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setSelectedImage(null); - } - }; - window.addEventListener('keydown', handleKeyDown); - return () => { - document.body.style.overflow = previousOverflow; - window.removeEventListener('keydown', handleKeyDown); - }; - }, [selectedImage]); - - return ( - <> -
- {INSPIRATION_IMAGES.map((image, index) => ( - - ))} -
- - {selectedImage - ? createPortal( -
setSelectedImage(null)} - > - 放大的灵感图片 event.stopPropagation()} - /> -
, - document.body, - ) - : null} - - ); -} diff --git a/apps/ai-game-creator-shell/src/view/home/TemplateRecommendations.tsx b/apps/ai-game-creator-shell/src/view/home/TemplateRecommendations.tsx new file mode 100644 index 000000000..56c4b1be6 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/home/TemplateRecommendations.tsx @@ -0,0 +1,96 @@ +import { BadgeCheck, Loader2, Package } from 'lucide-react'; + +import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel'; +import { templateRuntimeLabel } from '../../features/template-library/templateLibraryModel'; + +type TemplateRecommendationsProps = { + templates: readonly GameTemplateEntry[]; + loading: boolean; + error: string; + onOpenLibrary: () => void; +}; + +const RECOMMENDATION_LIMIT = 6; + +/** + * 首页模板推荐:只做展示与跳转,下载与建项目都在模板库页面里完成, + * 避免首页的卡片点击直接产生项目副作用。 + */ +export default function TemplateRecommendations({ + templates, + loading, + error, + onOpenLibrary, +}: TemplateRecommendationsProps) { + if (loading && templates.length === 0) { + return ( +
+
+ ); + } + + if (templates.length === 0) { + return ( +
+
+ ); + } + + return ( +
+ {templates.slice(0, RECOMMENDATION_LIMIT).map((template) => ( + + ))} +
+ ); +} diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-01.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-01.webp deleted file mode 100644 index 25821e804..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-01.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-02.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-02.webp deleted file mode 100644 index ce26591db..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-02.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-03.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-03.webp deleted file mode 100644 index 90287a674..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-03.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-04.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-04.webp deleted file mode 100644 index 8fa04357a..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-04.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-05.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-05.webp deleted file mode 100644 index cc657f1ef..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-05.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-06.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-06.webp deleted file mode 100644 index bf922e8d3..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-06.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-07.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-07.webp deleted file mode 100644 index 67048cfb9..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-07.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-08.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-08.webp deleted file mode 100644 index 13f642612..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-08.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-09.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-09.webp deleted file mode 100644 index d98a01ec7..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-09.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-10.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-10.webp deleted file mode 100644 index 4ed427e84..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-10.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-11.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-11.webp deleted file mode 100644 index b147b8ff7..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-11.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-12.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-12.webp deleted file mode 100644 index 084ed36bb..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-12.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-13.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-13.webp deleted file mode 100644 index 7da22fa4f..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-13.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-14.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-14.webp deleted file mode 100644 index 88dcc70cb..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-14.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-15.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-15.webp deleted file mode 100644 index 13315c560..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-15.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-16.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-16.webp deleted file mode 100644 index 4af52bbda..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-16.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-17.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-17.webp deleted file mode 100644 index 87c1ebe09..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-17.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-18.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-18.webp deleted file mode 100644 index cc4bcb6c5..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-18.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-19.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-19.webp deleted file mode 100644 index 413fff72b..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-19.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-20.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-20.webp deleted file mode 100644 index b55594bba..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-20.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-21.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-21.webp deleted file mode 100644 index 31db46017..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-21.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-22.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-22.webp deleted file mode 100644 index 5d2e1fa4e..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-22.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-23.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-23.webp deleted file mode 100644 index f34cd2398..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-23.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-24.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-24.webp deleted file mode 100644 index b23f5e8d1..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-24.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-25.webp b/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-25.webp deleted file mode 100644 index e008db1fc..000000000 Binary files a/apps/ai-game-creator-shell/src/view/home/assets/inspiration/inspiration-25.webp and /dev/null differ diff --git a/apps/ai-game-creator-shell/src/view/home/index.tsx b/apps/ai-game-creator-shell/src/view/home/index.tsx index 16236d779..ed160d88f 100644 --- a/apps/ai-game-creator-shell/src/view/home/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/index.tsx @@ -14,13 +14,14 @@ import { useRef, useState } from 'react'; import BRAND_ICON from '../../../../../packages/shared/src/icons/taonier-product-ip.png'; import type { ProjectStartMode } from '../../app/types'; import { ConversationModelSelect } from '../../features/project-workspace/ConversationModelSelect'; +import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel'; import RichInputArea, { UploadButton } from './components/RichInputArea'; import { richTextToAttachments, richTextToPrompt, } from './components/RichInputArea/richTextToPrompt'; import { resolveHomeStartMode } from './homeStartMode'; -import InspirationGallery from './InspirationGallery'; +import TemplateRecommendations from './TemplateRecommendations'; import { type HomeCreationType, type HomeDraft, @@ -113,6 +114,11 @@ type HomeViewProps = { onProjectsOpen: () => void; onProjectOpen: (path: string) => void; onProjectPick: () => void; + /** 模板库推荐位:清单来自 Rust 侧模板库,首页只负责展示与跳转。 */ + templateRecommendations: readonly GameTemplateEntry[]; + templateLibraryLoading: boolean; + templateLibraryError: string; + onTemplateLibraryOpen: () => void; }; export default function HomeView({ @@ -126,6 +132,10 @@ export default function HomeView({ onProjectsOpen, onProjectOpen, onProjectPick, + templateRecommendations, + templateLibraryLoading, + templateLibraryError, + onTemplateLibraryOpen, }: HomeViewProps) { const homeCreationType = useLauncherHomeDraftStore( (state) => state.creationType, @@ -421,12 +431,12 @@ export default function HomeView({

- 灵感推荐 + 模板库

+
- +
); diff --git a/apps/ai-game-creator-shell/src/view/layout.tsx b/apps/ai-game-creator-shell/src/view/layout.tsx index 9974817f5..4464661ce 100644 --- a/apps/ai-game-creator-shell/src/view/layout.tsx +++ b/apps/ai-game-creator-shell/src/view/layout.tsx @@ -3,6 +3,7 @@ import { CircleHelp, FolderKanban, Home, + LayoutTemplate, Plus, Settings, User, @@ -30,6 +31,7 @@ export type LauncherView = | 'guide' | 'contact' | 'news' + | 'template-library' | 'project-development'; type SidebarUserInfo = { @@ -284,6 +286,19 @@ export function Sidebar({ >