From a5fd25f10a30912d26dcaa02d02ac4e65c8407ab Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:51:53 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E5=88=87=E6=8D=A2=E5=88=B0=E5=AE=98=E6=96=B9=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=8F=92=E4=BB=B6=E5=B9=B6=E6=8C=89=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E5=88=86=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 客户端接入 tauri-plugin-updater:原生侧注册插件,删除自研更新下载命令、下载进度事件与安装器启动逻辑 - 客户端更新服务与更新提示改走官方插件接口,删除自研清单解析、版本比较与下载实现 - 更新能力只授予客户端主窗口,移除只为自研清单放行的 OSS 白名单与 CSP 连接项 - 新增构建期更新检查开关:开发态默认关闭,agc 启动不请求更新清单、不显示更新入口 - 发布脚本按渠道生成官方更新插件清单与签名,universal macOS 产物同时挂两个平台键,缺签名失败关闭 - 发布脚本按渠道上传安装包、签名与渠道清单,并为 dev-win 生成旧协议 sha256 迁移清单 - 构建期按渠道注入更新端点配置,渠道与目标平台不匹配时发布失败关闭 - Jenkins 流水线新增渠道参数与签名凭据注入,归档补充签名与迁移清单 - 新增发布上传 dry-run 开关,只打印 ossutil 命令且不回显凭据 - 更新技术方案与开发运维文档,登记 macOS 渠道落地待办 --- apps/ai-game-creator-shell/package.json | 1 + .../scripts/build-release.mjs | 244 ++++++++++++++--- .../scripts/build-release.test.mjs | 177 ++++++++++--- .../scripts/dev-feature-flags.mjs | 20 ++ .../scripts/release-oss.mjs | 27 ++ .../scripts/release-oss.test.mjs | 44 +++ .../scripts/release-upload.mjs | 52 +++- .../scripts/start-dev-server.mjs | 3 +- .../scripts/start-dev-stack.mjs | 6 +- .../src-tauri/Cargo.lock | 250 +++++++++++++++++- .../src-tauri/Cargo.toml | 2 +- .../src-tauri/capabilities/main.json | 2 +- .../src-tauri/src/main.rs | 232 +--------------- .../src-tauri/tauri.conf.json | 16 +- .../src/app/featureFlags.ts | 32 +++ .../src/components/AppUpdateNotice.tsx | 40 +-- .../src/components/WindowChrome.tsx | 3 +- .../runtime-config/RuntimeConfigDialog.tsx | 27 +- .../src/services/appUpdate.ts | 185 +++++-------- .../tests/appUpdate.test.ts | 164 +++++++++--- .../tests/dev-feature-flags.test.ts | 24 ++ .../tests/featureFlags.test.ts | 23 ++ ...】AGC客户端更新切换到官方更新插件-2026-09-17.md | 37 +++ ...施计划】AGC更新发布管线渠道化-2026-09-17.md | 37 +++ ...里程碑】AGC macOS渠道更新落地-2026-09-17.md | 46 ++++ ...AGC客户端更新切换到官方更新插件-2026-09-17.md | 45 ++++ ...里程碑】AGC更新发布管线渠道化-2026-09-17.md | 47 ++++ ...方案】AGC客户端更新检查与下载-2026-08-31.md | 159 +++++++---- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- .../Jenkinsfile.ai-game-creator-shell-build | 12 +- package-lock.json | 19 ++ 31 files changed, 1411 insertions(+), 567 deletions(-) create mode 100644 apps/ai-game-creator-shell/scripts/dev-feature-flags.mjs create mode 100644 apps/ai-game-creator-shell/scripts/release-oss.mjs create mode 100644 apps/ai-game-creator-shell/scripts/release-oss.test.mjs create mode 100644 apps/ai-game-creator-shell/src/app/featureFlags.ts create mode 100644 apps/ai-game-creator-shell/tests/dev-feature-flags.test.ts create mode 100644 apps/ai-game-creator-shell/tests/featureFlags.test.ts create mode 100644 docs/project-memory/plans/【实施计划】AGC客户端更新切换到官方更新插件-2026-09-17.md create mode 100644 docs/project-memory/plans/【实施计划】AGC更新发布管线渠道化-2026-09-17.md create mode 100644 docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md create mode 100644 docs/project-memory/plans/【里程碑】AGC客户端更新切换到官方更新插件-2026-09-17.md create mode 100644 docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index fcaf78727..442b1342e 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -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", diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index e5f153b68..2bfce8d02 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,87 @@ 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}`); +} + +async function readRemoteVersion(channel = resolveReleaseChannel()) { + const manifestUrl = updateManifestUrl(channel); 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(`读取 OSS 渠道清单失败:${error.message}`); } if (response.status === 404) return null; if (!response.ok) { - throw new Error(`读取 OSS 版本清单失败:HTTP ${response.status}`); + throw new Error(`读取 OSS 渠道清单失败:HTTP ${response.status}`); } let manifest; try { manifest = await response.json(); } catch (error) { - throw new Error(`OSS 版本清单不是有效 JSON:${error.message}`); + throw new Error(`OSS 渠道清单不是有效 JSON:${error.message}`); } - return parseVersion(manifest?.version, 'OSS版本清单 version'); + return parseVersion(manifest?.version, 'OSS渠道清单 version'); } function replaceVersionLine(source, version, pattern, label) { @@ -94,8 +172,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 readRemoteVersion(channel); const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim(); const nextVersion = requestedVersion ? parseVersion(requestedVersion, '指定版本') @@ -158,8 +237,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 +266,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 +320,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 +350,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..904bb079f 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,61 @@ 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, 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 }); + } +} + +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 +65,123 @@ 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('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/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 7dd55a0f6..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" @@ -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 4dd833d85..ba259ccf3 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -50,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 } @@ -58,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/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index cdfafff99..a9b1ccfff 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。 @@ -2556,6 +2385,7 @@ 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()) .manage(game_creator_preview_registry()) .manage(ProjectResourcePreviewReadManager::default()) @@ -2841,7 +2671,7 @@ 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, @@ -3013,50 +2843,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/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index f27f09c66..2c8833ab9 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -24,13 +24,14 @@ } ], "security": { - "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:*" + "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/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/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 b82edbc3f..67d591ffb 100644 --- a/apps/ai-game-creator-shell/src/components/WindowChrome.tsx +++ b/apps/ai-game-creator-shell/src/components/WindowChrome.tsx @@ -9,6 +9,7 @@ import { } 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'; @@ -146,7 +147,7 @@ export function WindowChrome({ children }: WindowChromeProps) { return (
- + {appUpdateCheckEnabled ? : null}
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 16feb1f76..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, @@ -1332,18 +1333,20 @@ export function RuntimeConfigDialog({
桌面客户端
-
- - - {appUpdateStatus} - -
+ {appUpdateCheckEnabled ? ( +
+ + + {appUpdateStatus} + +
+ ) : null} ) : null}
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/tests/appUpdate.test.ts b/apps/ai-game-creator-shell/tests/appUpdate.test.ts index 8bbe1e596..bd89c3a3a 100644 --- a/apps/ai-game-creator-shell/tests/appUpdate.test.ts +++ b/apps/ai-game-creator-shell/tests/appUpdate.test.ts @@ -1,47 +1,137 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { check } from '@tauri-apps/plugin-updater'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -import { - isNewerVersion, - parseAppUpdateManifest, - resetAppUpdateCheckForTests, -} from '../src/services/appUpdate'; +vi.mock('@tauri-apps/plugin-updater', () => ({ check: vi.fn() })); -afterEach(() => resetAppUpdateCheckForTests()); +const checkMock = vi.mocked(check); -describe('AGC update manifest', () => { - it('compares semantic versions and accepts v prefixes', () => { - expect(isNewerVersion('v0.1.13', '0.1.12')).toBe(true); - expect(isNewerVersion('0.1.12', '0.1.12')).toBe(false); - expect(isNewerVersion('0.1.11', '0.1.12')).toBe(false); +type FakeDownloadEvent = + | { event: 'Started'; data: { contentLength?: number } } + | { event: 'Progress'; data: { chunkLength: number } } + | { event: 'Finished' }; + +function fakeUpdate() { + return { + version: '99.0.0', + currentVersion: '0.1.47', + body: '修复与改进', + downloadAndInstall: vi.fn( + async (onEvent: (event: FakeDownloadEvent) => void) => { + onEvent({ event: 'Started', data: { contentLength: 100 } }); + onEvent({ event: 'Progress', data: { chunkLength: 40 } }); + onEvent({ event: 'Progress', data: { chunkLength: 60 } }); + onEvent({ event: 'Finished' }); + }, + ), + }; +} + +function stubTauriWindow() { + const invoke = vi.fn(async () => undefined); + vi.stubGlobal('window', { __TAURI__: { core: { invoke } } }); + return invoke; +} + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.resetModules(); + checkMock.mockReset(); +}); + +describe('AGC 客户端更新', () => { + it('开发态开关关闭时不请求清单', async () => { + vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '0'); + vi.resetModules(); + const { checkForAppUpdate, resetAppUpdateCheckForTests } = await import( + '../src/services/appUpdate' + ); + + await expect(checkForAppUpdate()).resolves.toBeNull(); + expect(checkMock).not.toHaveBeenCalled(); + resetAppUpdateCheckForTests(); }); - it('validates an OSS manifest and rejects non-HTTPS downloads', () => { - expect( - parseAppUpdateManifest({ - version: '0.1.13', - downloadUrl: 'https://oss.example/agc.exe', - }), - ).toMatchObject({ - version: '0.1.13', - downloadUrl: 'https://oss.example/agc.exe', + it('开关打开时把插件返回的更新映射给界面,且同一生命周期只查一次', async () => { + vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1'); + vi.resetModules(); + checkMock.mockResolvedValue(fakeUpdate() as never); + const { checkForAppUpdate, resetAppUpdateCheckForTests } = await import( + '../src/services/appUpdate' + ); + + await expect(checkForAppUpdate()).resolves.toEqual({ + version: '99.0.0', + currentVersion: '0.1.47', + releaseNotes: '修复与改进', }); - expect( - parseAppUpdateManifest({ - version: '0.1.13', - downloadUrl: 'http://oss.example/agc.exe', - }), - ).toBeNull(); + await checkForAppUpdate(); + expect(checkMock).toHaveBeenCalledTimes(1); + resetAppUpdateCheckForTests(); }); - it('preserves multiline release notes', () => { - expect( - parseAppUpdateManifest({ - version: '0.1.13', - downloadUrl: 'https://oss.example/agc.exe', - releaseNotes: '第一行\n第二行\r\n第三行', - }), - ).toMatchObject({ - releaseNotes: '第一行\n第二行\r\n第三行', - }); + it('清单缺失或网络失败时静默按无更新收口', async () => { + vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1'); + vi.resetModules(); + checkMock.mockRejectedValue(new Error('updater: manifest 404')); + const { checkForAppUpdate, resetAppUpdateCheckForTests } = await import( + '../src/services/appUpdate' + ); + + await expect(checkForAppUpdate()).resolves.toBeNull(); + resetAppUpdateCheckForTests(); + }); + + it('安装时按下载事件上报进度并在完成后重启进程', async () => { + vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1'); + vi.resetModules(); + const invoke = stubTauriWindow(); + const update = fakeUpdate(); + checkMock.mockResolvedValue(update as never); + const { checkForAppUpdate, installAppUpdate, resetAppUpdateCheckForTests } = + await import('../src/services/appUpdate'); + + await checkForAppUpdate(); + const progress: Array<{ downloadedBytes: number; totalBytes?: number }> = + []; + await installAppUpdate((value) => progress.push(value)); + + expect(update.downloadAndInstall).toHaveBeenCalledTimes(1); + expect(progress).toEqual([ + { downloadedBytes: 0, totalBytes: 100 }, + { downloadedBytes: 40, totalBytes: 100 }, + { downloadedBytes: 100, totalBytes: 100 }, + { downloadedBytes: 100, totalBytes: 100 }, + ]); + expect(invoke).toHaveBeenCalledWith('restart_agc_app'); + resetAppUpdateCheckForTests(); + }); + + it('没有待安装更新时安装请求失败关闭', async () => { + vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1'); + vi.resetModules(); + const { installAppUpdate, resetAppUpdateCheckForTests } = await import( + '../src/services/appUpdate' + ); + + await expect(installAppUpdate()).rejects.toThrow('没有可安装的更新'); + resetAppUpdateCheckForTests(); + }); + + it('下载失败后仍可重试安装', async () => { + vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1'); + vi.resetModules(); + const update = fakeUpdate(); + update.downloadAndInstall.mockRejectedValue(new Error('下载更新失败')); + checkMock.mockResolvedValue(update as never); + const { checkForAppUpdate, installAppUpdate, resetAppUpdateCheckForTests } = + await import('../src/services/appUpdate'); + + await checkForAppUpdate(); + await expect(installAppUpdate()).rejects.toThrow('下载更新失败'); + // 重试仍能拿到待装更新,而不是报“没有可安装的更新”。 + await expect(installAppUpdate()).rejects.toThrow('下载更新失败'); + expect(update.downloadAndInstall).toHaveBeenCalledTimes(2); + resetAppUpdateCheckForTests(); }); }); diff --git a/apps/ai-game-creator-shell/tests/dev-feature-flags.test.ts b/apps/ai-game-creator-shell/tests/dev-feature-flags.test.ts new file mode 100644 index 000000000..0f93e5095 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/dev-feature-flags.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from 'vitest'; + +import { + agcAppUpdateCheckEnvKey, + withAgcDevFeatureFlags, +} from '../scripts/dev-feature-flags.mjs'; + +describe('AGC dev 特性开关环境', () => { + test('未显式配置时下发关闭检测更新的默认值', () => { + expect(withAgcDevFeatureFlags({ KEEP_ME: 'yes' })).toMatchObject({ + KEEP_ME: 'yes', + [agcAppUpdateCheckEnvKey]: '0', + }); + }); + + test('保留显式配置的开关取值,忽略空白取值', () => { + expect( + withAgcDevFeatureFlags({ [agcAppUpdateCheckEnvKey]: '1' }), + ).toMatchObject({ [agcAppUpdateCheckEnvKey]: '1' }); + expect( + withAgcDevFeatureFlags({ [agcAppUpdateCheckEnvKey]: ' ' }), + ).toMatchObject({ [agcAppUpdateCheckEnvKey]: '0' }); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/featureFlags.test.ts b/apps/ai-game-creator-shell/tests/featureFlags.test.ts new file mode 100644 index 000000000..1ee1092b4 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/featureFlags.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveAppUpdateCheckEnabled } from '../src/app/featureFlags'; + +describe('AGC 客户端特性开关', () => { + it('开发态(agc 启动)默认关闭检测更新', () => { + expect(resolveAppUpdateCheckEnabled('', true)).toBe(false); + }); + + it('正式包默认开启检测更新', () => { + expect(resolveAppUpdateCheckEnabled('', false)).toBe(true); + }); + + it('显式配置的开关优先于环境默认值', () => { + expect(resolveAppUpdateCheckEnabled('1', true)).toBe(true); + expect(resolveAppUpdateCheckEnabled('0', false)).toBe(false); + }); + + it('忽略无法识别的开关取值并回落到环境默认值', () => { + expect(resolveAppUpdateCheckEnabled('2', true)).toBe(false); + expect(resolveAppUpdateCheckEnabled(' ', false)).toBe(true); + }); +}); diff --git a/docs/project-memory/plans/【实施计划】AGC客户端更新切换到官方更新插件-2026-09-17.md b/docs/project-memory/plans/【实施计划】AGC客户端更新切换到官方更新插件-2026-09-17.md new file mode 100644 index 000000000..4e93b3844 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC客户端更新切换到官方更新插件-2026-09-17.md @@ -0,0 +1,37 @@ +# 【实施计划】AGC 客户端更新切换到官方更新插件 + +| 字段 | 值 | +| --------- | ----------------------------------------------------------------------------------- | +| Milestone | `docs/project-memory/plans/【里程碑】AGC客户端更新切换到官方更新插件-2026-09-17.md` | +| Status | ready | +| Owner | Codex | + +## 修改边界 + +- 允许修改:AGC 客户端原生侧(依赖、插件注册、更新相关命令与其测试)、AGC 前端更新服务与更新提示、「关于」页检查入口、capability 与 Tauri 配置、AGC 客户端测试、主规范与开发运维文档。 +- 明确不修改:发布脚本与 Jenkins(渠道化属于下一个里程碑)、OSS 对象布局、SpacetimeDB、`/api/external/v1`、网站与其它 App。 + +## 实现顺序 + +1. 生成发布签名密钥对:私钥落在仓库外 `%USERPROFILE%\.tauri\`,公钥写入客户端配置(公钥发布后不可更换)。 +2. 原生侧:加入官方更新插件依赖并注册;删除自研更新下载命令、下载进度事件、安装器启动逻辑与其专属测试;新增供 macOS 安装后重启的应用命令。 +3. 配置与权限:打开更新产物生成,写入公钥、渠道端点(默认 Windows 渠道)与 Windows 静默安装模式;capability 增加更新权限,并移除只为自研清单放行的 OSS 白名单与 CSP 连接项。 +4. 前端:更新服务改为调用官方插件(检查、下载、进度、安装、重启收敛),删除自研清单解析、版本比较与下载实现;更新提示改用插件进度回调;保留开发态特性开关语义。 +5. 测试:改写更新服务定向用例(开关关闭不发请求、更新元数据映射、失败静默、进度与重启、无待装更新时失败关闭)。 +6. 文档:更新技术方案与开发运维说明,删除自研链路描述。 + +## 验证命令 + +1. `npm --prefix apps/ai-game-creator-shell run typecheck`(含 `check-config.mjs` 与 skill-pack 校验) +2. `npx vitest run apps/ai-game-creator-shell/tests/appUpdate.test.ts apps/ai-game-creator-shell/tests/featureFlags.test.ts apps/ai-game-creator-shell/tests/dev-feature-flags.test.ts` +3. `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml` +4. `npx eslint` / `npx prettier --check`(改动文件) +5. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check` +6. 运行时:`npm run agc` 启动不产生更新清单请求;检索确认自研命令、事件与白名单条目无残留。 + +## 风险与回滚点 + +- 公钥不可更换:密钥已生成但尚未发布任何签名版本,若需要带密码的私钥仍可在首次发布前重新生成。 +- Windows 安装模式由插件配置决定(本里程碑固定 `quiet`,与旧 PowerShell `/S` 一致);若改为 `passive` 会多出安装进度条 UI。 +- 插件在 Windows 上安装成功后自行退出进程,前端不再有机会更新界面;提示面板的完成态只在 macOS / Linux 可见。 +- 回滚点:改动集中在客户端与配置,回滚后即可退回自研链路;旧 OSS `agc/latest.json` 在发布管线渠道化前不删除。 diff --git a/docs/project-memory/plans/【实施计划】AGC更新发布管线渠道化-2026-09-17.md b/docs/project-memory/plans/【实施计划】AGC更新发布管线渠道化-2026-09-17.md new file mode 100644 index 000000000..94fb23092 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC更新发布管线渠道化-2026-09-17.md @@ -0,0 +1,37 @@ +# 【实施计划】AGC 更新发布管线渠道化 + +| 字段 | 值 | +| --------- | ------------------------------------------------------------------------- | +| Milestone | `docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md` | +| Status | ready | +| Owner | Codex | + +## 修改边界 + +- 允许修改:AGC 发布脚本(`apps/ai-game-creator-shell/scripts/build-release.mjs`、`release-upload.mjs` 及其测试)、AGC 发布流水线 `jenkins/Jenkinsfile.ai-game-creator-shell-build`、开发运维与技术方案文档。 +- 明确不修改:客户端插件接入与前端更新服务(上一里程碑已完成)、SpacetimeDB、`/api/external/v1`、网站与其它 App、其它 Jenkins Job。 +- 不执行 OSS 上传:本里程碑只交付脚本、流水线定义与本地可验证产物;真实发布需要单独授权与凭据。 + +## 实现顺序 + +1. 发布脚本:解析并校验渠道(渠道与目标平台绑定,未显式指定时按平台取默认渠道),把渠道写进远端清单地址与构建期端点配置。 +2. 清单生成:按渠道产出官方更新插件清单(版本、发布说明、发布时间、平台键与签名),universal macOS 产物同时挂两个平台键;缺少签名或签名为空时失败关闭。 +3. 迁移桥:Windows 渠道额外产出旧协议 sha256 清单,指向同一渠道的最新安装包,供已发布客户端升级到新协议。 +4. 上传:按渠道写版本目录(安装包与签名)与渠道 latest 指针,旧协议指针单独覆盖写。 +5. 流水线:新增渠道参数与签名凭据注入,归档安装包、签名、渠道清单与 commit。 +6. 测试与文档:更新发布脚本单测(渠道校验、清单结构、签名缺失失败关闭、旧协议清单),同步开发运维与技术方案。 + +## 验证命令 + +1. `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs apps/ai-game-creator-shell/scripts/cargo-features.test.mjs` +2. 本地清单 smoke:伪造 bundle 目录 + 真实签名私钥,断言渠道清单与旧协议清单结构、缺少签名时失败关闭 +3. `npm --prefix apps/ai-game-creator-shell run typecheck` +4. `npm run ai-game-creator-shell:build -- --no-bundle`(渠道端点注入后的构建 smoke;不改版本、不读远端清单、不生成清单) +5. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check`、prettier 与 eslint(改动文件) + +## 风险与回滚点 + +- 版本递增按渠道独立:`dev-win` 与 `dev-mac` 的清单地址不同,互不影响;旧协议指针只由 `dev-win` 写入。 +- 签名缺失即失败关闭:构建机未注入签名私钥时发布中止,不产生半成品清单。 +- 渠道端点写进产物:渠道名一旦发布不可改名(改名等于已发布客户端再也找不到更新)。 +- 回滚点:发布脚本与流水线都在本里程碑内,回滚后客户端仍可用原先的自研清单协议;迁移桥可独立停用。 diff --git a/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md new file mode 100644 index 000000000..b9b84ef2e --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC macOS渠道更新落地-2026-09-17.md @@ -0,0 +1,46 @@ +# 【里程碑】AGC macOS 渠道更新落地 + +| 字段 | 值 | +| ----------- | ------------------------------------------------------------------ | +| Version | 1.0 | +| Status | deferred | +| Date | 2026-09-17 | +| Parent Spec | `docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md` | + +## 目标 + +`dev-mac` 渠道可产出并发布 macOS 更新包,客户端在 macOS 上完成检查、安装与重启接管新版本。 + +## 范围 + +- macOS 更新产物:按 universal 目标构建(Intel 与 Apple Silicon 共用一个包),更新包与其签名按渠道约定生成并上传,清单把同一对象挂到两个 macOS 平台键。 +- macOS 安装后的重启收敛:安装完成后由客户端重启进程运行新版本,不依赖安装程序代为重启。 +- macOS 代码签名与公证依赖的确认与记录:未签名或未公证的产物视为不可发布。 +- macOS 构建执行环境(本机 mac 或新增 macOS 节点)与渠道发布的衔接方式。 + +## 不在范围内 + +- Windows 渠道行为调整。 +- 微软商店或 App Store 分发。 +- 更新包体积优化与增量更新。 + +## 依赖与前置条件 + +- 客户端插件化与发布管线渠道化两个里程碑已验收。 +- macOS 签名证书与公证凭据可用;若不满足,本里程碑只能交付构建与清单能力,并明确标注未验证项。 +- macOS 通用包所需的双架构工具链(两个 darwin 目标)在构建机上可用。 + +本里程碑暂缓执行:macOS 构建机与签名 / 公证凭据尚未就绪,改由后续独立变更承接;暂缓期间 dev-mac 渠道不发布。 + +## 验收标准 + +- [ ] `dev-mac` 渠道清单包含两个 macOS 平台条目且指向同一个 universal 安装包与签名,对象在 OSS 上一致可下载。 +- [ ] macOS 客户端能完成一次真实更新:检查、下载、安装、重启后运行新版本,且升级后产物仍是 universal 包。 +- [ ] 覆盖写渠道 latest 指针后,旧版本 macOS 客户端可升级到新版本;Windows 与 macOS 渠道互不干扰。 +- [ ] 未签名或未公证产物在发布阶段失败关闭,或在不满足条件时明确记录为未验证项而非静默通过。 + +## 证据要求 + +- 自动化:macOS 更新产物选择与清单生成用例、仓库门禁。 +- 运行时:macOS 上一次真实更新闭环(含重启后版本核对),OSS 对象与清单核对。 +- 边界:签名校验失败、公证缺失、渠道缺少 macOS 平台条目、跨架构不匹配时的表现。 diff --git a/docs/project-memory/plans/【里程碑】AGC客户端更新切换到官方更新插件-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC客户端更新切换到官方更新插件-2026-09-17.md new file mode 100644 index 000000000..48930772e --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC客户端更新切换到官方更新插件-2026-09-17.md @@ -0,0 +1,45 @@ +# 【里程碑】AGC 客户端更新切换到官方更新插件 + +| 字段 | 值 | +| ----------- | ------------------------------------------------------------------ | +| Version | 1.0 | +| Status | approved | +| Date | 2026-09-17 | +| Parent Spec | `docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md` | + +## 目标 + +客户端自动更新的检查、下载、签名校验与安装改由 Tauri 官方更新插件承担,前端只保留触发与展示,并按渠道读取清单;开发态继续不检查更新。 + +## 范围 + +- 官方更新插件在客户端两侧接入:原生侧注册与配置,前端调用官方 API 替代自研检查与下载。 +- 渠道作为构建期常量进入客户端:每个渠道的产物只读该渠道清单,运行期不切换渠道。 +- 保留并复核现有开发态特性开关语义:开发态不检查更新、不显示更新入口。 +- 更新能力只授予客户端主窗口。 + +## 不在范围内 + +- 发布管线与 OSS 对象布局的渠道化改造。 +- macOS 产物落地、签名与公证。 +- 旧客户端迁移桥(是否保留旧清单指针)。 + +## 依赖与前置条件 + +- 发布签名公钥可用;公钥写入客户端配置,来源见主规范未决问题。 +- 渠道清单地址与对象布局按主规范约定确定,渠道集合固定为 `dev-win` 与 `dev-mac`。 +- 官方插件版本与当前 Tauri 主版本兼容。 + +## 验收标准 + +- [ ] 正式包走官方更新插件的检查与安装路径;更新包校验失败时必须拒绝安装并清理临时文件。 +- [ ] 客户端只请求本渠道清单,且不因清单缺失、格式错误或网络失败阻塞启动。 +- [ ] 开发态启动不产生任何更新清单请求,也不显示更新入口。 +- [ ] 更新能力只授予客户端主窗口,其它窗口调用被拒绝。 +- [ ] 自研清单解析、下载命令、下载进度事件与相应的 CSP / HTTP 白名单放行整条删除,无残留兼容分支。 + +## 证据要求 + +- 自动化:前端定向用例(渠道映射、开发态开关、失败关闭)、原生侧定向用例、类型检查与仓库门禁。 +- 运行时:`agc` 开发启动无清单请求;使用测试渠道清单完成一次真实检查与安装闭环(含升级后重启)。 +- 边界:签名不匹配、下载中断、清单 404、渠道缺少当前平台条目、非主窗口调用。 diff --git a/docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md b/docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md new file mode 100644 index 000000000..5191b0129 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md @@ -0,0 +1,47 @@ +# 【里程碑】AGC 更新发布管线渠道化 + +| 字段 | 值 | +| ----------- | ------------------------------------------------------------------ | +| Version | 1.0 | +| Status | approved | +| Date | 2026-09-17 | +| Parent Spec | `docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md` | + +## 目标 + +构建与发布管线按渠道产出官方更新插件要求的清单与签名产物并上传到渠道路径,发布入口可通过渠道参数在渠道之间切换。 + +## 范围 + +- 构建期按渠道生成清单:版本按渠道独立递增,清单包含该渠道平台的下载地址与签名;`dev-mac` 的 universal 包按同一地址与签名同时写入 `darwin-aarch64` 与 `darwin-x86_64`。 +- 构建期生成更新产物签名,并在缺少签名私钥或私钥不可用时失败关闭。 +- 渠道参数与目标平台绑定校验:Windows 目标只能发布 `dev-win`,macOS 目标只能发布 `dev-mac`;未显式指定时按目标平台取默认渠道。 +- 上传按渠道落位:安装包与签名进版本目录,清单覆盖写渠道路径的 latest 指针。 +- Jenkins 流水线增加渠道参数与签名凭据注入,凭据不落盘、不进日志、不进归档。 + +## 不在范围内 + +- 客户端侧的更新链路改造。 +- macOS 构建环境建设与 mac 产物签名、公证。 +- 旧客户端迁移桥;若决定保留,作为本里程碑的可选增量单独评审。 + +## 依赖与前置条件 + +- 客户端切换到官方更新插件的里程碑已验收:清单格式、公钥与客户端期望一致。 +- 签名密钥对已生成并进入构建凭据,公钥已写入客户端配置。 +- OSS 上传凭据与既有发布入口可复用。 + +## 验收标准 + +- [ ] 指定渠道发布时该渠道清单版本按渠道独立递增,另一个渠道清单不受影响。 +- [ ] 渠道与目标平台不匹配、缺少签名私钥或私钥密码错误时发布失败关闭,不产生半成品清单。 +- [ ] 发布后 OSS 上安装包、签名与渠道清单三者一致:清单内地址指向已存在的对象,签名与安装包匹配。 +- [ ] universal macOS 产物的两个平台键指向同一对象同一签名,不存在只挂单一架构键或指向不存在对象的情况。 +- [ ] Jenkins 归档与日志中不出现签名私钥内容,凭据只注入构建进程。 +- [ ] 未显式指定渠道时按目标平台取默认渠道,且 `--no-bundle` smoke 路径仍不读远端版本、不改版本、不生成清单。 + +## 证据要求 + +- 自动化:发布脚本单测(渠道解析与校验、版本递增、清单结构、签名缺失失败关闭)、仓库门禁。 +- 运行时:一次真实渠道发布加 OSS 对象核对(清单、安装包、签名),并用该清单触发一次客户端更新闭环。 +- 边界:渠道与平台不匹配、签名密钥缺失、远端清单 404、远端清单格式非法、重复发布时的 latest 覆盖。 diff --git a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md index 29eae7a7f..a20a900f1 100644 --- a/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md +++ b/docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md @@ -1,68 +1,129 @@ # AGC 客户端更新检查与下载 -## 交付范围 +更新时间:`2026-09-17` -AGC 每次启动时由根窗口检查一次公开 OSS 更新清单。清单默认位于 -`https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/latest.json`,构建时可用 -`VITE_AGC_UPDATE_MANIFEST_URL` 覆盖为同一受信任 OSS 域名下的 HTTPS 地址。客户端版本取 -`apps/ai-game-creator-shell/package.json`,通过 `version` 与清单版本比较;只有远端版本更高时显示更新提示。 +本文件是 AGC 客户端自动更新的主规范:更新能力由 Tauri 官方插件 `tauri-plugin-updater` 承担,并按下文渠道分发。 -清单格式: +## 目标 + +- 客户端自动更新改用 Tauri 官方 `tauri-plugin-updater`:清单请求、版本比较、更新包下载、签名校验、安装与退出全部在原生侧完成;前端只负责触发、展示和渠道选择。 +- 更新按渠道分发。当前渠道集合为 `dev-win`(Windows x64)与 `dev-mac`(macOS);构建管线按渠道产出并上传清单,客户端只读取自己渠道的清单。 +- 更新链路的信任来源从「清单里的 sha256 + 受信域名」升级为「发布签名 + 受信域名」:清单里的 `signature` 由构建期私钥生成,客户端用内置公钥校验,校验不过就拒绝安装。 + +## 非目标 + +- 不做灰度放量、分批更新、强制更新和自动回滚;渠道只决定「取哪份清单」。 +- 不做后台静默自动安装:是否下载安装始终由用户在更新提示里确认(仅「是否显示提示」受渠道与开发态开关影响)。 +- 不支持应用商店分发(Microsoft Store / App Store)、移动端更新和企业内网自建更新服务。 +- 不为自研 sha256 清单协议保留长期实现;迁移桥(见「契约与迁移」)只用于把已发布客户端带到新协议,随后整条删除。 + +## 入口与边界 + +- 用户入口: + - 客户端启动时在根窗口检查一次渠道清单,发现新版本时显示更新提示,用户可下载并安装。 + - 运行时设置「关于」页提供手动检查更新(强制刷新)。 +- 涉及模块:AGC 客户端(Rust `src-tauri`、前端 `src/`)、AGC 构建与发布脚本(`apps/ai-game-creator-shell/scripts/`)、Jenkins 发布流水线、OSS 对象布局。 +- 正式状态来源: + - 客户端当前版本以 Tauri app version 为唯一权威来源(`tauri.conf.json`,由发布脚本与 `package.json`、`Cargo.toml`、`Cargo.lock` 同步递增)。 + - 远端最新版本取自当前渠道的 `latest.json`。 +- 信任边界:清单地址在构建期确定并烘焙进产物;客户端不接受用户输入、后端响应或项目文件提供的更新地址,也不回退到其它渠道或旧协议地址。 + +## 必须成立的行为 + +### 正常路径 + +- 正式包启动时检查一次渠道清单;仅当清单版本高于当前版本时显示更新提示,提示包含目标版本与发布说明。 +- 用户确认后下载更新包:下载期间显示进度与已下载字节数;下载完成后按平台安装。 +- Windows 使用静默安装模式(NSIS `quiet`),安装启动成功后客户端退出并由安装程序重启新版本;macOS 由客户端在安装完成后重启进程接管新版本。 +- 渠道在构建期确定并烘焙进产物:`dev-win` 产物只读 `dev-win` 清单,`dev-mac` 产物只读 `dev-mac` 清单,同一份二进制不会在运行期跨渠道切换。 +- 开发态(`npm run agc` / `agc:serve` 由 Vite dev server 提供前端)不检查更新、不显示更新入口,也不下载任何更新包。 + +### 失败、重试与幂等 + +- 清单请求失败均静默忽略,不阻塞客户端启动:网络错误、TLS 错误、404(渠道尚未发布版本)、格式非法、渠道没有当前平台条目、远端版本不高于当前版本。 +- 同一客户端生命周期内只自动检查一次;手动检查可强制刷新。 +- 签名校验失败、下载中断或写入失败必须失败关闭:删除临时文件、不启动安装程序,并给出可读错误文案;不接受「校验失败但继续安装」。 +- 重复点击下载或安装不产生并发安装;安装开始后客户端不再接受新的更新操作。 + +### 权限、归属与数据边界 + +- 更新能力通过 Tauri capability 显式授予客户端主窗口,其它窗口(调试窗口等)不得授予。 +- 客户端只允许访问渠道清单声明的地址,只允许安装清单声明且签名校验通过的对象。 +- 清单与安装包在 OSS 上保持公开可读;签名私钥与 OSS 凭据只存在于构建环境(Jenkins 凭据、本机发布配置),不写入仓库、日志、构建产物或客户端包。 +- 客户端不记录更新地址以外的敏感信息;失败文案不回显凭据、绝对路径或响应正文。 + +## 契约与迁移 + +- 清单格式(Tauri updater v2): ```json { - "version": "0.1.13", - "downloadUrl": "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/0.1.13/Genarrative-AI-Game-Creator.exe", - "sha256": "<64位十六进制摘要>", - "size": 123456789, - "releaseNotes": "修复与改进" + "version": "0.1.48", + "notes": "发布说明,可为空", + "pub_date": "2026-09-17T00:00:00Z", + "platforms": { + "windows-x86_64": { + "signature": "<.sig 文件内容>", + "url": "https:///agc/dev-win/0.1.48/<安装包文件名>" + } + } } ``` -`downloadUrl` 必须是 HTTPS;如提供 `sha256` / `size`,Tauri 下载时会校验摘要和字节数。点击“下载更新”后,客户端将安装包流式写入系统临时目录并显示进度,校验成功后通过 Windows UAC 提权启动 NSIS 静默安装并退出旧客户端。 +- 渠道与平台映射: -## 启动与失败策略 +| 渠道 | 构建目标 | 清单平台键 | 更新包 | 清单地址 | +| --------- | ------------------------ | ---------------------------------------------- | ------------------------ | ------------------------------------ | +| `dev-win` | `x86_64-pc-windows-msvc` | `windows-x86_64` | NSIS `.exe` + `.exe.sig` | `/agc/dev-win/latest.json` | +| `dev-mac` | `universal-apple-darwin` | `darwin-aarch64` + `darwin-x86_64`(同一对象) | `*.app.tar.gz` + `.sig` | `/agc/dev-mac/latest.json` | -- 检查挂在 `WindowChrome` 根组件,覆盖首页、工作台和调试窗口;网络错误、格式错误或版本不高于当前版本均静默忽略,不阻塞客户端启动。 -- 更新请求使用单例 Promise,React StrictMode 或同一窗口重复挂载不会重复请求。 -- Tauri HTTP capability 与 CSP 仅放行默认 OSS 域名;若更换域名,需同步更新 `capabilities/main.json`、`tauri.conf.json` 和发布环境配置。 +- 对象布局:清单固定写成 `agc//latest.json`;安装包与签名写成 `agc///` 与 `.sig`。 +- macOS 使用 universal 包:`dev-mac` 按 universal 目标构建(Intel 与 Apple Silicon 共用一个包),清单把同一个 `.app.tar.gz` 与同一个签名分别写入 `darwin-aarch64` 与 `darwin-x86_64`,升级后仍是 universal 包。这是 Tauri 官方发布工具对 universal 产物的既有写法。 +- 上一条的两个键不能合成单一 `darwin-universal` 键:更新插件按运行时实际架构解析清单键(Apple Silicon 命中 `darwin-aarch64`,Intel 命中 `darwin-x86_64`),不存在自动命中 `darwin-universal` 的情形。将来真要单独发该键,必须在客户端同时设置自定义 target,否则清单里这一项永远不会被读取。 +- 构建期要求:打开 `bundle.createUpdaterArtifacts` 以生成 `.sig`;构建环境提供签名私钥与密码(私钥内容不得入库);公钥写入客户端配置。公钥在首个带更新能力的版本发布后不可更换,更换等于放弃自动更新(只能手动重装)。 +- 版本递增按渠道独立进行:发布脚本读取该渠道远端 `latest.json` 的 `version`,与本地版本取较高者递增 patch;两个渠道的版本号互不影响。 +- 迁移(旧协议 → 渠道清单): + - 迁移起点:已发布客户端(含当前线上版本)内置自研清单地址 `agc/latest.json`(sha256 格式),下载与安装由自研 Rust 命令完成。 + - 迁移策略见「未决问题与决策」。迁移完成后,自研清单解析、下载命令、下载进度事件以及为此放行的 CSP / HTTP 白名单条目按「四不写」整条删除,不留兼容分支与墓碑说明。 -## 发布约定 +## 构建与发布 -当前发布目标固定为 Windows x64 NSIS。执行 `npm run ai-game-creator-shell:build` 会先读取 -`VITE_AGC_UPDATE_MANIFEST_URL`(默认 `https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/latest.json`)的 -`latest.json`,取本地与 OSS 的较高版本并递增一个 patch,然后同步更新 package、Tauri 和 Cargo -版本后再向 Tauri 传入 `--target x86_64-pc-windows-msvc` 构建。OSS 清单首次不存在时按本地版本递增; -OSS 请求失败、清单格式错误或版本无效会终止发布,避免覆盖线上版本。构建完成后自动扫描 `.exe` -安装包,并在 `apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json` -生成包含版本、下载地址、大小和 SHA-256 的清单。可通过 `AGC_BUILD_TARGET` 显式覆盖目标(发布仍应使用 -Windows x64),通过 `AGC_UPDATE_ARTIFACT` 指定要发布的安装包,通过 `AGC_UPDATE_OSS_BASE_URL` 指定 -OSS 前缀,通过 `AGC_RELEASE_VERSION` 指定三段版本号(仅在明确需要复现指定版本时使用),通过 -`AGC_UPDATE_RELEASE_NOTES` 写入发布说明,支持多行文本且保留内部换行;`--no-bundle` smoke 构建不会读取 OSS、修改版本或生成清单。 +- 发布入口:`npm run ai-game-creator-shell:release:upload`(构建 + 按渠道上传);仅构建不发布的 smoke 使用 `--no-bundle` 分支,不读远端版本、不改版本、不生成清单。 +- 渠道由构建参数显式指定,并按目标平台校验:Windows 目标只允许 `dev-win`,macOS 目标只允许 `dev-mac`;未显式指定时按目标平台取默认渠道。 +- 上传:安装包与 `.sig` 上传到 `agc///`,清单以 `--force` 覆盖上传到 `agc//latest.json`,保证 latest 指针与清单内 URL 指向已存在的对象。 +- Jenkins 流水线需要新增渠道参数与签名凭据;签名私钥与密码只以受保护凭据注入当前进程,不写入 workspace、日志或归档产物。 +- 归档证据:安装包、`.sig`、渠道清单与源码 commit。 -每次发布安装包上传完成后,再使用 ossutil 的 `--force` 覆盖上传同一目录生成的 `latest.json`,确保固定的 latest 指针和 `downloadUrl` 指向已存在的 OSS 对象;未显式强制覆盖时,ossutil 在目标已存在时会交互询问并按默认值跳过,不能作为 Jenkins 非交互发布方式。清单和安装包均使用公开可读对象,不在清单中保存凭据、签名或本地路径。构建脚本本身不负责上传 OSS,发布流水线通过 `release:upload` 完成上传。 +## 验收标准与证据 -如需一键构建并上传,可执行 `npm run ai-game-creator-shell:release:upload`。该命令要求本机已安装并配置 `ossutil`, -先按上述规则比较 OSS 版本、递增 patch、构建 Windows x64 NSIS,再上传安装包和 `latest.json`。默认上传到 -`agc-dev` / `oss-rg-china-mainland.aliyuncs.com`,也可用 `AGC_OSS_BUCKET`、`AGC_OSS_ENDPOINT` 和 `OSSUTIL_BIN` -覆盖;本机执行时凭据由 ossutil 本机配置读取,不能写入仓库或命令行参数。 +已获得的证据: -## Jenkins Windows 构建节点 +| 条款 | 验收方式 | 证据 | +| ------------------------ | ----------------------------------------------------------------------- | ----------------------------------------------------------- | +| 渠道与端点映射、渠道校验 | `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs` | 通过(默认渠道、错配失败关闭、未知渠道失败关闭) | +| universal 包挂两个平台键 | 同上 + 本地发布烟测(伪造 bundle) | 通过(两键同 URL 同签名,不生成迁移清单) | +| 缺签名时失败关闭 | 同上 | 通过 | +| 开发态不检查更新 | `vitest run apps/ai-game-creator-shell/tests/appUpdate.test.ts` | 通过(开关关闭时不请求清单) | +| 旧自研链路整条删除 | 代码检索无残留命令、事件与白名单条目 | 通过(`download_agc_update` / 下载事件 / 清单常量均无残留) | -AGC 发布流水线使用 `jenkins/Jenkinsfile.ai-game-creator-shell-build`,当前节点标签为 -`windows && win2022`。节点应为 Windows Server 2022 x64 虚拟机,预装 Node.js 22、npm -10.9.7、Rust 1.96.0、Visual Studio Build Tools(MSVC 与 Windows SDK)、Git 和 ossutil; -Jenkins Agent 服务必须能在同一用户环境中找到这些命令。Tauri Windows bundler 使用 -`tauri.windows.conf.json` 中的 `bundle.useLocalToolsDir: true`,把固定版本的 NSIS 工具缓存到 -`src-tauri/target/.tauri/NSIS`,不依赖 Jenkins 服务账户的 `%LOCALAPPDATA%\tauri` 或 PATH 中的系统 NSIS。 -Jenkins Checkout 的 `git clean -fdx` 会清理该构建目录,因此每次全新工作区可能重新下载 NSIS;这只影响构建耗时,不改变工具来源或执行权限要求。 -流水线参数 `AGC_UPDATE_RELEASE_NOTES` 使用 Jenkins `text` 类型,可直接输入多行发布说明;执行根 workspace 的 `npm ci`,然后调用 -`npm run ai-game-creator-shell:release:upload`,并归档 Windows 安装包、`latest.json` 与源码 commit。 -流水线会将未导出的空参数按空字符串处理:`COMMIT_HASH` 留空时沿用 Jenkins SCM 当前提交,`OSSUTIL_BIN` 留空时使用节点 PATH 中的 `ossutil`,不会因 PowerShell 对空环境变量调用 `.Trim()` 而提前失败。 +待执行证据(首次渠道发布后回填): -Jenkins Job 在“Build and upload”阶段通过受保护凭据 ID `AliyunAccessKeyId` 和 -`AliyunaccessKeySecret` 注入 AccessKey,仅在当前进程运行时传给 ossutil,不写入仓库、workspace 或构建日志; -本机运行仍使用 ossutil 配置。凭据必须具备 `PutObject` 权限;OSS 对客户端保持公共读即可,公共读本身不授予 -Jenkins 上传权限。由于版本号取决于 OSS 当前清单,Job 已关闭并发构建;若 Jenkins -上存在多个 AGC 发布 Job,还应使用同一个 Lockable Resource 串行化发布。Job 参数 -`AGC_RELEASE_VERSION` 留空时自动递增,填写后会使用指定版本并更新对应的 `latest.json`,因此回滚或测试旧版本前应确认不会覆盖线上更新入口。 +| 条款 | 验收方式 | 证据 | +| ---------------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| 清单与对象布局符合渠道约定 | `ossutil ls oss://agc-dev/agc/dev-win//`;`ossutil cat .../dev-win/latest.json` | 待执行:URL 指向已存在安装包,签名与 `.sig` 内容一致 | +| 旧协议迁移桥 | `ossutil cat oss://agc-dev/agc/latest.json` | 待执行:`sha256` / `size` 与同一安装包匹配 | +| 真实更新闭环(含升级后重启) | 0.1.47 客户端升级到新版本,再启动不再提示;`npm run agc` 仍无更新入口 | 待执行 | +| 签名校验失败拒绝安装 | 渠道清单签名与实际安装包不匹配时的表现 | 待执行(需要真实渠道清单) | + +## 未决问题与决策 + +已决策: + +- macOS 采用 universal 包,同一产物同时挂 `darwin-aarch64` 与 `darwin-x86_64` 两个清单键(见「契约与迁移」)。 +- 旧客户端迁移桥:保留一个版本周期。渠道清单上线后,发布管线同时把旧的 `agc/latest.json`(sha256 格式)指向 `dev-win` 最新安装包,让已发布客户端自动升级到新协议;下个周期整条删除。 +- 签名密钥:由本仓库维护者生成并保管,私钥保存在仓库外(`%USERPROFILE%\.tauri\genarrative-agc-updater.key`),只有公钥进入客户端配置;Jenkins 用受保护凭据 `AgcUpdaterSigningKey` 与 `AgcUpdaterSigningKeyPassword` 注入为 Tauri 打包器读取的 `TAURI_SIGNING_PRIVATE_KEY` 与 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`,本机可用 `TAURI_SIGNING_PRIVATE_KEY_PATH` 指向同一私钥。当前密钥不带密码;首次发布前仍可重新生成,首次发布后不可更换。 +- macOS 发布方式:`dev-mac` 产物在本机 mac 上执行发布入口上传,Jenkins 暂不新增 macOS 节点;macOS 代码签名与公证凭据未确认前,相关闭环记为未验证项,不静默通过。 + +待办: + +- macOS `dev-mac` 渠道落地(macOS 构建机、签名与公证、安装后重启验证、是否接入 Jenkins macOS 节点)暂缓,由后续独立变更单独完成;在此之前 `dev-mac` 渠道只有构建与清单能力,不发布。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 0d1af4ab8..38285c41a 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -70,7 +70,7 @@ Linux 本机多用户并发开发时,`npm run dev`、`npm run dev:*` 单模块 后端日志默认写入 `logs/api-server/`,独立 BgFilter worker 日志默认写入 `logs/bgfilter-worker/`。后端 API smoke 使用 `npm run dev:api-server`,先检查 BgFilter worker `/readyz`,再检查 API `/healthz`;需要确认 API 实例可接生产流量时检查 API `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。 -AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。任一配套服务在启动阶段进入 `failed` 时,外层启动器必须立即报告具体服务和退出原因,不能继续等待前端地址超时。端口健康不等于归属正确:复用前还必须证明端口上的监听进程属于当前工作树(Windows 按 `server-rs/target/debug/api-server.exe` 绝对路径与 SpacetimeDB `--data-dir` 校验,探测不可用时退化为旧行为),无法证明归属时一律不复用,改为启动本工作树自己的后端并在需要时端口漂移;否则上个工作树 Ctrl+C 残留的后端会被当成自己的后端复用,改了数据库的工作树会连到旧库。启动器在创建原生窗口前预检最终地址;AGC Vite marker 同时提供 `repoRoot + processId + port`,与 `.app/dev-stack.json` 的 `instanceId` 和 API target 交叉核对;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。 +AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。任一配套服务在启动阶段进入 `failed` 时,外层启动器必须立即报告具体服务和退出原因,不能继续等待前端地址超时。端口健康不等于归属正确:复用前还必须证明端口上的监听进程属于当前工作树(Windows 按 `server-rs/target/debug/api-server.exe` 绝对路径与 SpacetimeDB `--data-dir` 校验,探测不可用时退化为旧行为),无法证明归属时一律不复用,改为启动本工作树自己的后端并在需要时端口漂移;否则上个工作树 Ctrl+C 残留的后端会被当成自己的后端复用,改了数据库的工作树会连到旧库。启动器在创建原生窗口前预检最终地址;AGC Vite marker 同时提供 `repoRoot + processId + port`,与 `.app/dev-stack.json` 的 `instanceId` 和 API target 交叉核对;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。开发态客户端不检查更新:启动器给 AGC Vite 注入 `VITE_AGC_ENABLE_APP_UPDATE_CHECK=0`,客户端不请求 OSS 更新清单、也不显示更新入口;需要联调更新流程时显式传 `VITE_AGC_ENABLE_APP_UPDATE_CHECK=1`。 AGC 开发态还会按后台 Web 的端口约定额外拉起 `apps/admin-web`:Linux 用当前用户端口段的 `start + 3` 槽位,非 Linux 以 `3102` 为兼容首选并允许统一漂移,`ADMIN_WEB_PORT` 可显式指定且必须避开已解析的 AGC Vite 端口;设置 `AGC_DEV_ADMIN_WEB=0` 可关闭。后台 Vite 与 AGC Vite 一样由 `start-dev-stack.mjs` 直接持有并随启动器退出收束,不走 `npm run dev:admin-web`——后者会整体重写 `.app/dev-stack.json`,覆盖本次配套后端的归属状态;后台 Web 的端口解析、启动失败或运行中意外退出都只打印告警,不阻断也不连带停止 AGC 客户端与配套后端。前端与配套后端就绪后,启动器会打印一行 `[ai-game-creator-shell] 启动汇总:`,依次给出前端、后端、后台、数据库与 `bgfilter-worker` 的实际地址;端口漂移或默认端口被其它工作树占用时,以这一行为准。 diff --git a/jenkins/Jenkinsfile.ai-game-creator-shell-build b/jenkins/Jenkinsfile.ai-game-creator-shell-build index 3e304cf63..5bd728af0 100644 --- a/jenkins/Jenkinsfile.ai-game-creator-shell-build +++ b/jenkins/Jenkinsfile.ai-game-creator-shell-build @@ -21,8 +21,9 @@ pipeline { parameters { string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '源码分支') string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit') - string(name: 'AGC_RELEASE_VERSION', defaultValue: '', description: '可选,指定三段版本号;留空则按 OSS 与本地版本自动递增 patch') - text(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,支持多行文本,写入 latest.json 的发布说明') + string(name: 'AGC_RELEASE_VERSION', defaultValue: '', description: '可选,指定三段版本号;留空则按该渠道 OSS 与本地版本自动递增 patch') + choice(name: 'AGC_UPDATE_CHANNEL', choices: ['dev-win', 'dev-mac'], description: 'AGC 发布渠道;dev-win 在 Windows 节点执行,dev-mac 需在 macOS 构建机本地执行') + text(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,支持多行文本,写入渠道清单的发布说明') string(name: 'OSSUTIL_BIN', defaultValue: 'ossutil', description: 'ossutil 或 ossutil.exe 的绝对路径/命令名') } @@ -123,11 +124,14 @@ pipeline { withCredentials([ string(credentialsId: 'AliyunAccessKeyId', variable: 'AGC_OSS_ACCESS_KEY_ID'), string(credentialsId: 'AliyunaccessKeySecret', variable: 'AGC_OSS_ACCESS_KEY_SECRET'), + string(credentialsId: 'AgcUpdaterSigningKey', variable: 'TAURI_SIGNING_PRIVATE_KEY'), + string(credentialsId: 'AgcUpdaterSigningKeyPassword', variable: 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD'), ]) { withEnv([ "PATH=${env.AGC_WINDOWS_PATH}", "OSSUTIL_BIN=${params.OSSUTIL_BIN}", "AGC_RELEASE_VERSION=${params.AGC_RELEASE_VERSION}", + "AGC_UPDATE_CHANNEL=${params.AGC_UPDATE_CHANNEL}", "AGC_UPDATE_RELEASE_NOTES=${params.AGC_UPDATE_RELEASE_NOTES}", ]) { powershell ''' @@ -153,14 +157,14 @@ pipeline { stage('Archive release') { steps { - archiveArtifacts artifacts: 'apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.exe,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json,.jenkins-source-commit', fingerprint: true + archiveArtifacts artifacts: 'apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.exe,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.sig,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/latest.json,apps/ai-game-creator-shell/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/legacy-latest.json,.jenkins-source-commit', fingerprint: true } } } post { success { - echo 'AGC Windows x64 安装包已构建并上传 OSS。' + echo "AGC ${params.AGC_UPDATE_CHANNEL} 渠道安装包、签名与渠道清单已构建并上传 OSS。" } } } diff --git a/package-lock.json b/package-lock.json index 4f7ddc219..ccb4be9ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -108,6 +108,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", @@ -8093,6 +8094,15 @@ "@tauri-apps/api": "^2.11.0" } }, + "node_modules/@tauri-apps/plugin-updater": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.11.0.tgz", + "integrity": "sha512-AE36XkOoSna24G40jZMY15nzAnkXEPL/73tGoseGrtGOHuI/cZwWzHpZFLjKXDPgzYZ435z1gHu28LgrsBwIxQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -26459,6 +26469,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", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.14", @@ -28226,6 +28237,14 @@ "@tauri-apps/api": "^2.11.0" } }, + "@tauri-apps/plugin-updater": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.11.0.tgz", + "integrity": "sha512-AE36XkOoSna24G40jZMY15nzAnkXEPL/73tGoseGrtGOHuI/cZwWzHpZFLjKXDPgzYZ435z1gHu28LgrsBwIxQ==", + "requires": { + "@tauri-apps/api": "^2.11.0" + } + }, "@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", From 1f5b7b250a86b2cab45e12044ad5bdcf3d8cf079 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:03:15 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E6=8E=A8=E8=8D=90=E4=BD=8D=E7=94=A8=E4=BE=8B=E6=96=AD=E8=A8=80?= =?UTF-8?q?=E5=88=B0=E6=A8=A1=E6=9D=BF=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 首页「灵感推荐」已被模板库推荐位替换,用例改为断言模板库推荐卡片、已下载徽标与共 N 个模板计数 - 用例改为断言点击推荐位进入模板库页面,且不触发下载或由模板创建项目 - 保留不请求已退役展示流接口的断言 --- .../tests/appSurface/home.suite.ts | 100 +++++++++++++++--- 1 file changed, 87 insertions(+), 13 deletions(-) diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index a1ab75aad..47e386b36 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -182,24 +182,98 @@ export function registerClientHomeTests() { ); }); - it('shows the built-in inspiration masonry gallery and opens a dismissible preview without requesting the retired feed', async () => { + it('shows the home template recommendations and opens the library without creating a project', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const templateLibrarySnapshot = { + schemaVersion: 'game-template-library.v1', + library: 'genarrative-official', + libraryVersion: 3, + updatedAt: '2026-09-17T00:00:00Z', + fetchedAtMillis: 1, + source: 'remote', + templates: [ + { + id: 'lane-defense', + title: '星际防线', + summary: '塔防原型', + tags: ['塔防'], + runtime: 'phaser', + engine: 'Phaser', + engineVersion: '4.2.1', + templateVersion: '1.0.0', + updatedAt: '2026-09-17T00:00:00Z', + entry: 'index.html', + zipUrl: + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/templates/lane-defense.zip', + zipSizeBytes: 2048, + zipSha256: 'a'.repeat(64), + coverUrl: + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/templates/lane-defense.png', + coverWidth: 320, + coverHeight: 180, + installed: true, + installedVersion: '1.0.0', + installedAtMillis: 2, + }, + { + id: 'cozy-farm', + title: '悠然农场', + summary: '经营原型', + tags: ['经营'], + runtime: 'phaser', + engine: 'Phaser', + engineVersion: '4.2.1', + templateVersion: '1.0.0', + updatedAt: '2026-09-17T00:00:00Z', + entry: 'index.html', + zipUrl: + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/templates/cozy-farm.zip', + zipSizeBytes: 4096, + zipSha256: 'b'.repeat(64), + coverUrl: + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/templates/cozy-farm.png', + coverWidth: 320, + coverHeight: 180, + installed: false, + installedVersion: null, + installedAtMillis: null, + }, + ], + }; + const invoke = vi.fn(async (command: string) => { + if (command === 'read_game_creator_app_config') { + return { config: { selectedModelId: 'quality' } }; + } + if (command === 'fetch_game_template_library') { + return templateLibrarySnapshot; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; renderLauncherAt('/?launcher'); - const inspiration = screen.getByLabelText('灵感推荐'); - const inspirationImages = within(inspiration).getAllByRole('button', { - name: /查看灵感图片/, + // 首页推荐位只展示封面、标题、运行时与已下载徽标,本机灵感图库已随模板库上线删除。 + const recommendations = await screen.findByLabelText('模板库推荐'); + const recommendationCards = within(recommendations).getAllByRole('button', { + name: /^查看模板 /u, }); - expect(inspirationImages.length).toBeGreaterThan(0); - expect(inspiration.closest('.overflow-y-auto')).not.toBeNull(); - expect(screen.queryByText('暂无灵感')).toBeNull(); + expect(recommendationCards.length).toBe(2); + expect(within(recommendationCards[0]!).getByText('已下载')).not.toBeNull(); - fireEvent.click(inspirationImages[0]!); - const preview = screen.getByRole('dialog', { name: '查看灵感图片' }); - fireEvent.click(screen.getByAltText('放大的灵感图片')); - expect(screen.getByRole('dialog', { name: '查看灵感图片' })).not.toBeNull(); - fireEvent.click(preview); - expect(screen.queryByRole('dialog', { name: '查看灵感图片' })).toBeNull(); + fireEvent.click(recommendationCards[0]!); + + // 点击推荐位只进入模板库页面,不在首页直接下载或创建项目。 + const librarySummary = await screen.findByText('共 2 个模板 · 已下载 1 个'); + expect(librarySummary).not.toBeNull(); + expect(screen.getByRole('button', { name: '返回' })).not.toBeNull(); + expect(screen.queryByLabelText('模板库推荐')).toBeNull(); + expect( + invoke.mock.calls.some( + ([command]) => + command === 'download_game_template' || + command === 'create_automatic_local_game_project_from_template', + ), + ).toBe(false); await act(async () => { await Promise.resolve(); From 5e4ff54a9df8675b6dd1422a690a2163dfb6a3dc Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:04:21 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E5=B7=A5=E7=A8=8B=E5=86=85=E7=BD=AE=E7=9A=84=E5=B5=8C=E5=A5=97?= =?UTF-8?q?=20package-lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 模板 fixture 内的 package-lock.json 触发 workspace 守卫的「禁止嵌套 lockfile」规则,导致 master 门禁失败 - 其余五个模板都只提交 project 内容与 package.json,删除该文件与它们保持一致 - 模板 zip 由发布脚本现场打包并重新计算大小与哈希,仓库内没有引用该 lock 的脚本或测试 --- .../project/game/package-lock.json | 1138 ----------------- 1 file changed, 1138 deletions(-) delete mode 100644 apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/package-lock.json diff --git a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/package-lock.json b/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/package-lock.json deleted file mode 100644 index 45ece2543..000000000 --- a/apps/ai-game-creator-shell/template-library/v1/phaser-2d-starter/project/game/package-lock.json +++ /dev/null @@ -1,1138 +0,0 @@ -{ - "name": "agc-game", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "agc-game", - "dependencies": { - "phaser": "4.2.1" - }, - "devDependencies": { - "vite": "^6.2.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@napi-rs/lzma-linux-x64-gnu": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", - "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", - "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", - "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", - "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", - "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", - "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", - "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", - "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", - "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", - "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", - "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", - "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", - "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", - "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", - "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", - "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", - "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", - "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", - "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", - "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", - "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", - "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", - "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", - "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", - "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", - "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/phaser": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/phaser/-/phaser-4.2.1.tgz", - "integrity": "sha512-WUNwCPJpdjvZiuT6SgCfYVW8Qw/3j0jJ4ws7P2QkhFLFu74sbGuyHJcbFueGkY/AYO4Pi47bNQXn1OCJeLX//w==", - "license": "MIT", - "dependencies": { - "eventemitter3": "^5.0.4" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.18", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/rollup": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", - "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.63.1", - "@rollup/rollup-android-arm64": "4.63.1", - "@rollup/rollup-darwin-arm64": "4.63.1", - "@rollup/rollup-darwin-x64": "4.63.1", - "@rollup/rollup-freebsd-arm64": "4.63.1", - "@rollup/rollup-freebsd-x64": "4.63.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", - "@rollup/rollup-linux-arm-musleabihf": "4.63.1", - "@rollup/rollup-linux-arm64-gnu": "4.63.1", - "@rollup/rollup-linux-arm64-musl": "4.63.1", - "@rollup/rollup-linux-loong64-gnu": "4.63.1", - "@rollup/rollup-linux-loong64-musl": "4.63.1", - "@rollup/rollup-linux-ppc64-gnu": "4.63.1", - "@rollup/rollup-linux-ppc64-musl": "4.63.1", - "@rollup/rollup-linux-riscv64-gnu": "4.63.1", - "@rollup/rollup-linux-riscv64-musl": "4.63.1", - "@rollup/rollup-linux-s390x-gnu": "4.63.1", - "@rollup/rollup-linux-x64-gnu": "4.63.1", - "@rollup/rollup-linux-x64-musl": "4.63.1", - "@rollup/rollup-openbsd-x64": "4.63.1", - "@rollup/rollup-openharmony-arm64": "4.63.1", - "@rollup/rollup-win32-arm64-msvc": "4.63.1", - "@rollup/rollup-win32-ia32-msvc": "4.63.1", - "@rollup/rollup-win32-x64-gnu": "4.63.1", - "@rollup/rollup-win32-x64-msvc": "4.63.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - } - } -} From fc14190f584c114cecbf19212a5e8a3b67bae509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AE=B5=E8=88=92=E5=BA=B7?= Date: Thu, 17 Sep 2026 18:10:34 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E5=9B=BE=E9=9B=86=E5=88=87=E7=89=87?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E6=94=B9=E4=B8=BA=E5=BF=85=E9=A1=BB=E6=98=BE?= =?UTF-8?q?=E5=BC=8F=E5=A3=B0=E6=98=8E=E5=B9=B6=E8=A1=A5=E9=BD=90=E5=86=B3?= =?UTF-8?q?=E7=AD=96=E8=A6=81=E6=B1=82=20(#408)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景 切图新增基于连通域的切分后,LLM 仍倾向显式传 `sliceMode=grid`:参数只存在于部分 LLM 可见面、带默认值、没有任何决策规则,生成结果也不回显生效模式。 ## 变更 - 平台:`/api/editor/icon-spritesheets/generations` 与 `/api/external/v1/editor/icon-spritesheets/generations` 把 `sliceMode` 改为必填并移除默认值;缺失、空白或未知取值在引用解析、定价与任何 provider / OSS 副作用之前返回 `400`,错误统一带 `field` 与决策要求。 - 契约:`grid` 必须同时提供 `gridX`/`gridY`,`connected-components` 不接受网格尺寸;`sliceCount` 只约束连通域切分,请求与响应的公开上限统一为 `256`;OpenAPI 去掉默认值并补必填与失败语义。 - AGC:MCP 工具说明去掉默认值并补决策要求,桥接层新增可测试的切分声明校验;原生工具 `canvas.asset_generate` 暴露 `sliceMode/gridX/gridY/sliceCount` 并要求图集显式声明;生成结果回显 `sliceMode/gridX/gridY` 与 `slicePaths`;严格图集在本地提交前校验平台回显与请求声明一致。 - 标准美术包:显式声明 `connected-components` 加 `sliceCount=4`,并在四张 canonical 切片用途映射前校验数量,禁止截断或错位。 - 前端与画板:画板 Agent 工具装配与画板提交计划显式声明连通域切分;前端类型要求显式 `sliceMode` 并在本地校验声明自洽。 - 文档与 Skill:主规范、OpenAPI、AGC Skill、外部编辑器 Skill、里程碑与实施计划、共享决策记录同步更新。 - 测试环境:测试构建对提权 Windows 主机上系统临时目录的所有者偏差做一次性所有者初始化重试,临时目录之外的越权所有者继续失败关闭。 ## 兼容性影响 省略 `sliceMode` 的旧调用方(含已发布但未更新的 AGC 客户端与第三方外部 API 调用方)会在图集生成上收到 `400`;这是本次"不允许默认值"的预期结果,仓库内自有调用方已全部改为显式声明。 ## 验证 - 平台:`slice_mode_must_be_declared_*` 与 OpenAPI 契约测试通过;全量 `cargo test -p api-server` 1043 通过 / 11 失败(`wallet_refund_outbox` 临时文件 `拒绝访问`,已在改动前基线复现,属本机环境)。 - AGC:`slice` 30、`spritesheet` 21、`direct_tools_mcp` 23、`agent_native_tools` 16、`canvas_generation_tests` 83、提示词上限与桥接门禁各 1 条、`cargo check --tests` 全部通过。 - 前端:182 条定向测试与 `typecheck` 通过。 - 门禁:`cargo fmt --check`(两个 workspace)、`check:encoding`、`check:doc-index`、`git diff --check` 通过。 - 未验证:真实 Provider 与浏览器试玩、确定性 e2e 车道;整机全量 AGC 单进程运行在本机受提权 shell 的所有者与时序问题影响,不作为门禁信号。 --------- Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/408 --- .../genarrative-external-editor-api/SKILL.md | 2 +- .../references/api-operations.md | 2 +- .../references/capability-routing.md | 4 +- .../scripts/genarrative_external_api.py | 18 +- .../deterministic-lane-defense-provider.mjs | 3 +- .../resources/agc-skills/manifest.json | 4 +- .../agc-skills/taonier-art-assets/SKILL.md | 17 +- .../references/platform-art-contract.md | 3 +- .../src-tauri/src/agent/direct_runtime/mod.rs | 8 +- .../src-tauri/src/agent/direct_tool_bridge.rs | 94 ++++++++ .../src-tauri/src/agent/direct_tools_mcp.rs | 21 +- .../src/agent/generation/canvas_generation.rs | 122 ++++++++++- .../src/agent/runtime_tools/media.rs | 47 ++++ .../src-tauri/src/agent_native_tools.rs | 10 +- .../src-tauri/src/commands.rs | 5 +- .../src-tauri/src/config.rs | 82 ++++++- .../src-tauri/src/main.rs | 3 + .../src/tests/collaboration/delegation.rs | 1 + .../src-tauri/src/tests/project.rs | 1 + .../genarrative-external-v1.openapi.json | 17 +- ...施计划】图集切片模式显式决策-2026-09-17.md | 39 ++++ ...里程碑】图集切片模式显式决策-2026-09-17.md | 49 +++++ .../shared-memory/decision-log.md | 11 +- .../【编辑器】画布Agent对话面板-2026-07-03.md | 1 + ...辑器】画板图标素材生成入口设计-2026-06-15.md | 15 +- .../api-server/src/editor_agent/tool.rs | 4 +- .../api-server/src/editor_project_icon.rs | 203 +++++++++++++++--- .../api-server/src/external_editor_api.rs | 26 ++- .../crates/api-server/src/external_mcp.rs | 2 +- ...CanvasEditorGenerationIntegration.test.tsx | 1 + ...ageCanvasGenerationSubmissionModel.test.ts | 1 + .../ImageCanvasGenerationSubmissionModel.ts | 3 + ...anvasGenerationSubmissionWorkflow.test.tsx | 1 + .../image-editor/editorProjectClient.test.ts | 35 +++ .../image-editor/editorProjectClient.ts | 23 +- 35 files changed, 796 insertions(+), 82 deletions(-) create mode 100644 docs/project-memory/plans/【实施计划】图集切片模式显式决策-2026-09-17.md create mode 100644 docs/project-memory/plans/【里程碑】图集切片模式显式决策-2026-09-17.md 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/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/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index 1f7b5c124..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.18", + "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", 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/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 619fd4162..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 @@ -3382,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, }; 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 a517f1831..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 @@ -2170,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( @@ -2263,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, @@ -2309,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, @@ -2743,6 +2788,55 @@ 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透明图"; 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 9c769fc5e..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 @@ -273,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"], @@ -2393,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") 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 f545cc279..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 @@ -1564,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, @@ -2468,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( @@ -3106,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])? @@ -3164,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, @@ -6494,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>, @@ -6504,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, @@ -6545,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 @@ -7093,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()); @@ -7308,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, @@ -7317,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(), @@ -7327,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, @@ -7688,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, }), ); @@ -7697,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, @@ -9789,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, @@ -9814,7 +9916,6 @@ mod canvas_generation_tests { None, "route", "kind", - None, &[], false, false, @@ -9871,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, @@ -12347,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, } @@ -12380,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()], @@ -12704,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/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 f5724810b..739dc5a3f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -4647,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, }, 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 95c7726f6..87608cc7d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -1198,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); }; @@ -1720,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) { @@ -1763,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) } @@ -2114,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, 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 a9b1ccfff..7aafd8b45 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1110,6 +1110,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, 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/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/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index a70761fa3..c120926f0 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3363,7 +3363,7 @@ }, "EditorIconSpritesheetGenerationRequest": { "type": "object", - "required": ["referenceId", "iconDescriptions"], + "required": ["referenceId", "iconDescriptions", "sliceMode"], "properties": { "referenceId": { "type": "string", @@ -3395,26 +3395,25 @@ "connected-components", "grid" ], - "default": "connected-components", - "description": "图集切分模式。connected-components 按透明像素 alpha 连通域识别独立素材;grid 按用户提供的 gridX/gridY 划分网格槽。省略时使用 connected-components。" + "description": "必填,没有默认值:必须在引用解析、定价、入队和任何 provider / OSS 副作用之前显式声明切分模式。需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入来自需求本身的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,需要约束素材张数时用 sliceCount。connected-components 不接受 gridX/gridY,grid 必须同时提供 gridX/gridY(各 1..32)。省略、null 或空字符串返回 400(field=sliceMode),模式与网格参数互相矛盾返回 400(field=gridX/gridY),两者都不会产生计费、入队或 provider 调用。响应中的 sliceMode 回显本次实际采用的模式。" }, "gridX": { "type": "integer", "minimum": 1, "maximum": 32, - "description": "grid 模式的横向网格数量。" + "description": "grid 模式的横向网格数量,只能与 sliceMode=grid 同时出现;与 connected-components 同时提交返回 400。" }, "gridY": { "type": "integer", "minimum": 1, "maximum": 32, - "description": "grid 模式的纵向网格数量。" + "description": "grid 模式的纵向网格数量,只能与 sliceMode=grid 同时出现;与 connected-components 同时提交返回 400。" }, "sliceCount": { "type": "integer", "minimum": 1, - "maximum": 100, - "description": "connected-components 模式下可选的目标切片数量;省略时按图像内容自动识别。grid 模式的切片数量由 gridX×gridY 决定。" + "maximum": 256, + "description": "connected-components 模式下可选的目标切片数量(1..256);省略时按图像内容自动识别上限。识别结果与该目标数量不一致、为 0 或超过 256 时返回 422 并给出实际识别数量,不会静默截断。grid 模式的切片数量由 gridX×gridY 决定,不接受该字段。" }, "screenColor": { "type": ["string", "null"], @@ -3649,7 +3648,7 @@ "connected-components", "grid" ], - "description": "实际采用的图集切分模式。" + "description": "本次实际采用的图集切分模式,与请求显式声明的 sliceMode 一致;图集生成入口不回退到任何默认模式。" }, "gridX": { "type": "integer", @@ -3664,7 +3663,7 @@ "sliceCount": { "type": "integer", "minimum": 0, - "maximum": 100, + "maximum": 256, "description": "实际生成的切片数量。" }, "sliceWarning": { diff --git a/docs/project-memory/plans/【实施计划】图集切片模式显式决策-2026-09-17.md b/docs/project-memory/plans/【实施计划】图集切片模式显式决策-2026-09-17.md new file mode 100644 index 000000000..d365f6539 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】图集切片模式显式决策-2026-09-17.md @@ -0,0 +1,39 @@ +# 【实施计划】图集切片模式显式决策 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】图集切片模式显式决策-2026-09-17.md` | +| Status | ready | +| Owner | Codex | + +## 修改边界 + +- 允许修改:`server-rs/crates/api-server`(图标图集生成入口、错误体、画板 Agent 工具装配、OpenAPI 契约测试)、平台画板前端(`src/services/image-editor`、`src/components/image-editor`)、AGC 客户端(`apps/ai-game-creator-shell/src-tauri` 的 MCP 工具说明、桥接校验、原生工具 schema、图集生成选项与调用方、AGC Skill)、`.codex/skills/genarrative-external-editor-api`、`docs/openapi/genarrative-external-v1.openapi.json`、主规范与共享记忆。 +- 明确不修改 `platform-editor-agent`:画板 Agent 的工具参数不变,其链路在装配层固定显式声明 `connected-components`,画板因此不具备网格生成入口。 +- 明确不修改:拆分 / 去背 / 像素规整算法、切片上限、手动拆分入口行为、SpacetimeDB schema、旧版本客户端兼容分支。 + +## 实现顺序 + +1. 平台入口:`sliceMode` 由可选改必填并校验模式自洽性,失败发生在引用解析、定价、入队之前。 +2. 公开契约:OpenAPI 请求体去掉默认值、补必填与失败语义,并补契约测试。 +3. 平台自有调用方显式声明模式:画板 Agent 工具装配(固定连通域)、画板前端提交计划(固定连通域)。 +4. AGC 客户端:MCP 工具说明与桥接校验、原生工具 schema 与观察器、图集生成选项与全部调用方、AGC Skill 与外部 MCP 说明。 +5. 错误可执行性:切片模式按原始字符串接收后逐项校验,统一返回 `field`、允许取值与决策分支;`sliceCount` 契约上限与切片上限对齐。 +6. 反馈闭环:生成结果回显生效声明与切片路径,严格图集在本地提交前校验回显与请求一致。 +7. 标准美术包显式声明 `connected-components` + `sliceCount=4`,用途映射前校验切片数量正好为四。 +8. 测试环境:为提权 Windows 主机上的 `%TEMP%` 所有者偏差补测试构建专用的所有者初始化重试(仅限临时目录内、且失败原因为所有者不匹配)。 +9. 文档与共享记忆同步,最后运行定向验证与编码 / diff 检查。 + +## 验证命令 + +1. `cargo test -p api-server editor_icon_spritesheet`(名称按实际测试筛选) +2. `cargo test -p platform-editor-agent` +3. `npm run test -- src/services/image-editor/editorProjectClient.test.ts`(按仓库既有前端测试入口) +4. `cargo test -p ai-game-creator-shell` 定向筛选 `slice_mode` / `generate_image` +5. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check` + +## 风险与回滚点 + +- 风险 1:已发布的 AGC 客户端与第三方外部 API 调用方在未更新前会因缺失 `sliceMode` 收到 `400`。回滚点为「恢复服务端兜底读取连通域」,但该兜底与本次里程碑目标冲突,需产品确认后再引入过渡期。 +- 风险 2:AGC 原生工具 schema 从“可选”改为“显式声明”,自主运行时可能出现一轮可修复的工具参数失败。回滚点为「保留 schema 字段但收回 description 中的强制措辞」。 +- 风险 3:画板前端显式声明模式后,画板自身不再具备网格生成能力;需要网格时改用外部 API 或后续单独开放画板入口。 diff --git a/docs/project-memory/plans/【里程碑】图集切片模式显式决策-2026-09-17.md b/docs/project-memory/plans/【里程碑】图集切片模式显式决策-2026-09-17.md new file mode 100644 index 000000000..49925b314 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】图集切片模式显式决策-2026-09-17.md @@ -0,0 +1,49 @@ +# 【里程碑】图集切片模式显式决策 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | proposed | +| Date | 2026-09-17 | +| Parent Spec | `docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md` | + +## 目标 + +图标图集生成的切分模式不再具备任何隐式默认:平台入口、AGC 客户端自有流程、画板前端和所有 Agent / 工具说明都必须在请求中显式声明 `sliceMode`,并在同一份决策要求下选择 `connected-components` 或 `grid`。 + +## 范围 + +- `sliceMode` 在图标图集生成入口成为必填;缺失、`null`、空字符串在副作用之前失败关闭。 +- `grid` 与 `connected-components` 的参数自洽性:`grid` 必须带行列数,连通域不得携带网格尺寸。 +- 决策要求写入主规范、公开契约、MCP / Agent 工具说明、Skill 与客户端自有路径,口径一致。 +- 依赖平台默认值的自有调用方全部改为显式声明,且不新增兜底分支。 +- 失败信息可执行:所有拒绝路径都带字段名与决策要求,`sliceCount` 的目标数量与上限语义在契约中写清。 +- 端到端可证明:生成结果回显生效的切分声明与切片路径,严格图集在本地提交前校验回显与请求一致。 +- 标准美术包显式声明四张 canonical 切片的切分声明,并在用途映射前校验切片数量正好为四。 + +## 不在范围内 + +- 不改动图集生成、去背、像素规整、拆分算法本身和切片上限。 +- 不新增切分模式,不恢复已退役的固定网格契约。 +- 不改动手动 `拆分图集` 入口的既有行为。 +- 不为旧版本客户端保留过渡性兜底。 + +## 依赖与前置条件 + +- 无外部依赖;`sliceMode`、`gridX`、`gridY` 契约字段已在现行版本存在。 + +## 验收标准 + +- [ ] 省略 / `null` / 空字符串 `sliceMode` 的图集生成请求在定价、入队、扣费和 provider 调用之前返回 `400`,错误体含 `field=sliceMode`。 +- [ ] `grid` 缺 `gridX` 或 `gridY`、越界、乘积超限时 `400`;`connected-components` 携带 `gridX`/`gridY` 时 `400`。 +- [ ] 公开契约、MCP / Agent 工具说明、Skill 与画板前端类型都要求显式声明,且不再声明任何默认值。 +- [ ] AGC 客户端与画板前端的所有图集生成路径都显式传入模式,不再依赖平台兜底。 +- [ ] 响应回显的 `sliceMode` 与请求声明一致;`grid` 时同时回显行列数。 +- [ ] 拒绝信息包含字段名、允许取值与决策分支;`sliceCount` 契约上限与切片上限一致。 +- [ ] 标准美术包声明 `sliceCount=4`,数量不符时在写入用途清单前失败关闭。 + +## 证据要求 + +- 自动化:平台定向测试(缺失、空串、连通域带网格尺寸、grid 缺维度、正常两种模式)、OpenAPI 契约测试、前端与 AGC 客户端定向测试。 +- 运行时:本地 `api-server` smoke 提交一次缺字段请求,确认返回 `400` 且无扣费 / 入队记录。 +- 边界:确认失败发生在引用解析、定价、入队与 OSS 副作用之前;确认响应字段与请求一致。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 01152bcba..ddb8c3926 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2,7 +2,17 @@ > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 > 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 +## 2026-09-17 图集切分模式改为显式声明 +- 决策:`sliceMode` 在图标图集生成入口成为必填字段且不保留任何默认值。省略、`null` 或空字符串必须在引用解析、定价、入队和 provider / OSS 副作用之前返回 `400`(`field=sliceMode`);`grid` 必须同时提供 `gridX`/`gridY`,`connected-components` 不得携带网格尺寸,二者矛盾同样在副作用前失败关闭。 +- 决策要求:只有用户或需求明确要求等分网格、固定槽位或指定行列数时才使用 `grid`,且行列数必须来自该需求;自由排布、数量不定或只要求一张图集时显式传 `connected-components`,需要约束素材张数时用 `sliceCount`,不得用网格参数表达张数,也不得用固定 `2×2` 表达“四类素材”。 +- 影响面:平台两个图集生成入口(`/api/editor/...` 与 `/api/external/v1/editor/...`)、OpenAPI、画板 Agent 工具、画板前端提交计划、AGC 客户端 MCP 工具说明与桥接校验、AGC 原生工具 schema 与观察器、AGC Skill 与外部编辑器 Skill。 +- 迁移影响:省略 `sliceMode` 的旧调用方(含已发布但未更新的 AGC 客户端和第三方外部 API 调用方)会在图集生成上收到 `400`;本次同时把仓库内自有调用方改为显式声明,不为旧客户端保留兜底分支。 +- 错误可执行性:缺失、空白、未知取值都以 `400` + `field=sliceMode` 返回允许取值和决策分支,`grid` 缺维度提示 `sliceCount` 才是张数约束;`sliceCount` 的公开契约上限与切片上限统一为 `256`(识别数量与目标不一致返回 `422` 并回报实际数量)。 +- 反馈闭环:图集生成结果回显生效的 `sliceMode`/`gridX`/`gridY` 与 `slicePaths`;严格图集提交前必须证明平台回显的模式(`grid` 时含行列数)与请求显式声明一致,缺失或不一致一律失败关闭。 +- 标准美术包:客户端显式声明 `sliceMode=connected-components` + `sliceCount=4`,本地按用途位置写四张 canonical 切片前再次校验数量正好为四,数量不符时失败关闭,禁止截断或补位。 +- 测试环境:在提权 shell 的 Windows 主机上,`%TEMP%` 下新建目录的默认所有者是 `BUILTIN\Administrators` 而不是当前 TokenUser,AGC 的所有者校验会拒绝测试自己创建的项目根;测试构建对该情形(仅限 `%TEMP%` 内、且失败原因为所有者不匹配)先按“本调用创建的对象”初始化所有者后重试,临时目录之外的越权所有者继续失败关闭。 +- 权威合同:[画板图标素材生成入口设计](../../【编辑器】画板图标素材生成入口设计-2026-06-15.md)。 ## 2026-09-17 `agc_tools` 媒体资源提示词上限收敛为单一口径,并按 kind 暴露给模型 - 背景:有人反馈「客户端没法由 agent 调用图片快速编辑功能以及背景音乐生成功能」。核查后工具本身都在(`agc_edit_image` / `agc_create_or_derive_resource`),图片快速编辑在 2026-09-14 的真实项目日志里也有成功记录;但存在三类真实缺陷:① `agc_create_or_derive_resource` 的 `prompt` 在 schema 里只声明 4000,真实上限却是按 kind 分的(背景音乐 140、音效 1900、视频/角色动画 4000、图片 32000),MCP 层还额外写死了一条 140 判断,模型从 schema 与 skill 都看不出 140/1900,写一句正常长度的背景音乐描述就当场被拒;② 客户端 UI 用同一口径但会截断并提示,agent 侧却只有硬拒,形成「UI 能做、agent 调不动」的观感;③ `sourceLocalAssetId` 不是已登记资源时只报「不属于当前项目已登记资源」,模型会原地重试而不会先登记。 @@ -12,7 +22,6 @@ - 影响范围:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`(上限与文案的唯一口径)、`agent/direct_tool_bridge.rs`(按 kind 判定与未登记源资源提示)、`agent/direct_tools_mcp.rs`(schema 与校验)、`resources/agc-skills/agc-client-projection/**` 与清单指纹(version `2026-08-26.18`)。**未改** `/api/external/v1` 契约与 OpenAPI、SpacetimeDB schema、前端 TS 侧 `resourceEditPromptMaxLength` 数字、客户端 UI 行为。 - 验证方式:新增 `tool_prompt_limits_agree_with_the_client_authority`(四个 kind 的 schema 上限、MCP 校验与客户端权威口径同数字,超限文案带真实上限)、`bridge_resource_prompt_limits_follow_the_client_authority`(工具桥侧同类门禁,含图片编辑的 32000 边界)、`edit_image_tool_reaches_the_platform_image_edit_route` 与 `background_music_tool_reaches_the_platform_audio_route`(MCP 工具层 → 真实工具桥 → 假平台,断言 `/api/editor/images/edits` 与 `/api/editor/audios/background-music/generations` 的路径、Bearer、Idempotency-Key、正文与派生资源落盘,图片编辑正文不得回填 assetKind)、`background_music_prompt_over_the_limit_is_rejected_before_any_bridge_call`(超限在桥请求之前失败)、`unregistered_source_reports_the_registration_follow_up_tools`;`agent::direct_tools_mcp` 22 passed、`agent::skill_pack` 4 passed、`agent::direct_tool_bridge` 17 passed(7 条本机既有失败见下)、`npm run agc:skill-pack:check` 与 `skill-pack:test` 通过。本机 `tempfile::tempdir()` 归属校验失败导致的既有用例(`project::resource_editor` 45 条、`agent::direct_tool_bridge` 7 条)在本轮改动前后**同为失败**(stash 基线复跑确认),与本次无关。 - 关联文档:[AI游戏创作智能体App实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)、[踩坑记录](pitfalls.md)。 - ## 2026-09-16 抠图模式与背景色契约 - External v1 抠图和 AGC `agc_remove_background` 支持 `complex`(语义分割识别前景)与 `flat`(纯色背景抠图);明确纯色背景优先 flat,模式缺省仍为 complex,主站前端保持现有行为。 diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index fec3db0e7..b5c244b6e 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -27,6 +27,7 @@ - 下面的工具选择口径属于 Agent 规划 prompt / function-calling 约束,不是侧边栏 UI 说明文案;侧边栏面板不展示这些规则解释。 - 用户要求“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”时,规划默认选择 `generate_image`,并在 prompt 中明确要求生成规范展板,包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等可落地的视觉规范元素。 - 用户要求“角色规范图”且语义是角色的规范展板、风格展板或设定板时,仍走 `generate_image`,不要误分流到 `generate_character`;只有实际生成角色立绘、角色主形象或角色视觉资产时才走 `generate_character`。用户要求多个图标素材、图集或 spritesheet 时才走 `generate_icon_spritesheet`。 +- 画布 Agent 的 `generate-icon-spritesheet` 不暴露切分模式参数,链路固定显式传 `sliceMode=connected-components`;等分网格或固定槽位需求必须由外部 API 调用方显式传 `sliceMode=grid` 与来自需求的 `gridX`/`gridY`,画板工具栏的 `拆分图集` 仍只做连通域拆分。禁止在工具描述、确认卡或回复里承诺按 `2×2` 等网格切分。 - 所有生成必须走 `execute_billable_asset_operation_with_cost` 与模型定价配置,禁止绕过定价收口。 - function-calling 的 JSON Schema 必须与参数默认值和运行时校验保持一致,不能只在 description 中提示会被运行时拒绝的组合。`generate-ui-design` 固定 `gpt-image-2`,因此 `image_size` 只暴露 `1K / 2K`;其它可切换图片模型的工具通过共享条件 schema 在显式选择 `gpt-image-2` 时同样把 `image_size` 限制为 `1K / 2K`,省略模型时仍按默认 nanobanana2 允许 `0.5K`。`generate-video` 省略 `model` 时按默认 `seedance2.0-fast` 约束 `resolution` 为 `480p / 720p`,显式选择其它模型时仍使用其现有分辨率范围。运行时强类型校验继续作为最终防线。 - `generate-sound-effect` 与站内 / External v1 的 SFX V2 契约一致:Prompt 使用 ECMAScript `String.trim()` 等值 canonicalization 且限制 `1–2048` Unicode code points,model 固定 `eleven_text_to_sound_v2`,`duration` 缺省为手动 `5s`、显式 `null` 为自动时长、数值范围为有限 `0.5–30` 小数,`loop` 缺省 false。显式 `duration:null` 必须绕过通用“顶层 null 当缺省”兼容层,不能在 job payload 中变回 `5s`;确认后的 canonical payload 继续进入现有 `editor_sound_effect_generation` Worker,不新增 Agent 专属音频链路。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index ae769d8b3..b362711d4 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -2,7 +2,7 @@ 日期:`2026-06-15` -更新时间:`2026-08-10` +更新时间:`2026-09-17` ## 背景 @@ -40,7 +40,15 @@ ## 生成契约 - 前端提交到 `POST /api/editor/icon-spritesheets/generations`。 -- 图集拆分通过 `sliceMode` 显式选择:`connected-components` 按透明像素连通域切分(默认),`grid` 按用户提供的 `gridX × gridY` 网格切分。 +- 图集拆分模式必须由调用方显式声明,任何入口都不得存在隐式默认值:`sliceMode` 是图标图集生成请求的必填字段,`connected-components` 按透明像素连通域切分,`grid` 按调用方提供的 `gridX × gridY` 网格切分。 +- 请求缺失 `sliceMode`、传 `null` 或空字符串时,`POST /api/editor/icon-spritesheets/generations` 与 `POST /api/external/v1/editor/icon-spritesheets/generations` 都必须在引用解析、定价、入队和任何 provider / OSS 副作用之前返回 `400`,错误体带 `field=sliceMode`,message 复述本节的决策要求;服务端不得用兜底模式继续执行,也不得为该字段保留默认值。 +- `grid` 必须同时提供 `gridX` 与 `gridY`(各 `1..32`,乘积不得超过当时生效的图集切片上限);只提供其中一个、越界或乘积超限同样在副作用之前 `400`。 +- `connected-components` 不得同时携带 `gridX` / `gridY`:连通域切分不接受网格尺寸,二者同时出现时按请求自相矛盾在副作用之前返回 `400`(`field=gridX/gridY`),避免调用方以为网格已生效而实际按连通域执行。 +- 决策要求(服务端、客户端、Agent、工具说明和 Skill 必须一致):只有在用户或需求明确要求等分网格、固定槽位或指定行列数时,才使用 `grid`,并把该行列数作为 `gridX` / `gridY` 传入;行列数必须来自用户或需求本身,不得由生成方自行假定,也不得用固定 `2×2` 表达“四类素材”。自由排布、数量不定或只要求“一张图集”时,显式传 `connected-components`;需要约束素材张数时使用 `sliceCount`,不得用网格参数表达张数。调用方、客户端和 Agent 都不得依赖、补齐或推断省略值。 +- 响应继续回显实际采用的 `sliceMode`,`grid` 时同时回显生效的 `gridX` / `gridY`。 +- 错误必须可执行:缺失、空白和未知取值统一返回 `400` 且带 `field=sliceMode`,`grid` 与网格参数的矛盾带 `field=gridX/gridY`,message 说明允许取值、缺参时该走哪条决策分支,以及 `sliceCount` 才是张数约束;不得只回报通用 JSON 解析错误。 +- `sliceCount` 只约束 `connected-components` 的目标张数,取值 `1..256`;识别结果与该目标不一致、为 `0` 或超过上限时返回 `422` 并回报实际识别数量,`grid` 不接受该字段。 +- 客户端的标准美术包(四类 canonical 素材)必须显式声明 `sliceMode=connected-components` 与 `sliceCount=4`:平台要么给出四张切片,要么以可执行的 `422` 说明实际识别数量;本地按用途位置映射前必须再次校验切片数量正好是四张,数量不符时失败关闭,禁止靠截断或补位写出用途错位的切片清单。 - 图标规范生成在 inline 模式下也必须先建立带稳定请求指纹的 generation operation,并由编辑器生成 durable billing 边界包住共享执行器;不得在 `operation=None` 时调用 provider 后再进入原子结果持久化。 - 图标 spritesheet 的入队与实际执行路径都必须在引用解析、generation input 重建、定价和 provider / OSS 副作用之前预检 owner、项目和最终素材目录,并将返回的 canonical `projectId + assetFolderId` 回写到后续流程;请求省略目录时按实际写入的 owner 默认目录预检,worker 不得只信任入队时的旧校验结果。 - queued 图标规范生成由共享原子结果持久化使用 worker caller 中的 lease 一并完成任务并清理 lease;共享执行器返回成功后 worker 只能返回 `Ok(())`,不得再次调用 job completion。 @@ -89,7 +97,7 @@ - 透明背景处理正常成功时,父流程把带背景原图和经完整解码 / 尺寸守卫验证的透明 spritesheet 写入 OSS、项目资源和账号素材库,再识别 alpha 连通域并执行附加拆分。BgFilter 最终失败或后续 Alpha / 尺寸恢复、原图回读、透明图完整解码失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,不创建透明图集,也不继续拆分,`iconImageSrcs=[]`、`sliceWarning=null`。该收口不捕获 phase 上报、provider 原图持久化或 `canvasCompletion` 写回错误;provider 原图本身解码失败时在首次持久化前失败,不允许用 `512×512` 伪造元数据。 - 自动拆分只在透明图集成功后执行,属于 best-effort 附加动作,不参与图集生成的成功判定。连通域识别或切片持久化失败时,接口仍返回并回填整张透明图集,`iconImageSrcs=[]`,并通过 `sliceWarning.code/reason` 暴露非阻断原因;`sliceWarning` 与透明背景最终失败使用的通用 `warning` 互斥,因为透明背景失败时不会进入拆分,但可与风格归一化或像素规整产生的通用 `warning` 并存。前者只表示透明图集成功但自动拆分失败,`sliceWarning.reason` 原始契约保持不变。前端在 inline、worker 队列完成和刷新恢复三条路径统一显示对应 warning toast,用户可在图集工具栏手动重试。 - 响应通过 `iconImageSrcs` 返回成功切片素材。图标自动拆分、手动 `拆分图集` 和 UI 提取复用同一个 bounded CPU helper 和 platform 实现:全部原始连通域(包括随后过滤的噪点)最多 `4096` 个,辅助部件通过 `64px` 空间网格只检查最大 `48px` 邻域候选;有效输出按视觉阅读顺序命名为 `素材 N`。 -- 三条拆分路径共同限制单边最多 `4096` 像素、总像素最多 `2048×2048`、最多 `64` 个输出;输出限制在排序、裁剪和 PNG 编码前检查。整段图片 CPU 工作在 2 路 semaphore、30 秒本地上限与请求 deadline 共同保护的 `spawn_blocking` 中执行,permit 由 blocking 闭包持有。自动拆分超限以稳定 `sliceWarning` 非阻断降级且不产生切片 PUT、资源或画布切片;手动拆分超限在首次持久化前返回 `422`。 +- 三条拆分路径共同限制单边最多 `4096` 像素、总像素最多 `2048×2048`、最多 `256` 个输出;输出上限与 `grid` 的 `gridX × gridY` 上限、`sliceCount` 上限取同一个值,并在排序、裁剪和 PNG 编码前检查。整段图片 CPU 工作在 2 路 semaphore、30 秒本地上限与请求 deadline 共同保护的 `spawn_blocking` 中执行,permit 由 blocking 闭包持有。自动拆分超限以稳定 `sliceWarning` 非阻断降级且不产生切片 PUT、资源或画布切片;手动拆分超限在首次持久化前返回 `422`。 ## 前端铺放规则 @@ -110,3 +118,4 @@ - 选中透明图集图层时显示 `拆分图集`;点击后源图集显示扫描蒙层与 `拆图中` 状态,工具栏按钮同步切换为旋转图标和 `拆图中` 并禁用重复提交。完成后恢复工具栏,不新增第二张图集,只在 provider 原图右侧追加自动识别的独立素材,并同步写入素材库。 - 把同源派生图层从其它标签改为“图集”时,在项目资源返回新 `resourceId` 前“拆分图集”保持禁用;持久化成功后拆分请求必须指向 `assetKind: "icon-spritesheet"` 的新资源,失败时标签回滚且不发起拆分请求。 - 生成图标素材的提交体不包含 `priceMudPoints`;后端必须按归一化后的模型和尺寸计算价格,不信任客户端声明。queue 任务的计费、退款和结果投影使用入队时冻结的同一价格。 +- 图集生成请求省略 `sliceMode`(或显式传 `null` / 空字符串)时返回 `400` 且 `field=sliceMode`,不产生定价、入队、扣费、provider 调用或 OSS 写入;`sliceMode=connected-components` 同时携带 `gridX`/`gridY` 时同样在副作用之前 `400`;`grid` 缺任一维度时 `400`。响应回显的 `sliceMode` 必须与请求声明一致。 diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs index 0805fb4d4..53d051abc 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -864,7 +864,9 @@ impl EditorAgentTool for GenerateIconSpritesheetTool { reference_image_srcs: Some(reference_image_srcs), icon_descriptions: args.icon_descriptions, slice_count: None, - slice_mode: None, + // 画板 Agent 只生成自由排布的图标表,因此显式声明连通域切分;等分网格或固定 + // 槽位需求必须由调用方在外部 API 显式传 grid + gridX/gridY,不能依赖任何默认值。 + slice_mode: Some("connected-components".to_string()), grid_x: None, grid_y: None, style: None, diff --git a/server-rs/crates/api-server/src/editor_project_icon.rs b/server-rs/crates/api-server/src/editor_project_icon.rs index d0e68e432..c109dab63 100644 --- a/server-rs/crates/api-server/src/editor_project_icon.rs +++ b/server-rs/crates/api-server/src/editor_project_icon.rs @@ -76,13 +76,13 @@ pub(crate) const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS: usize = 2_000; pub(crate) const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES: usize = 6 * 1024; pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_DIMENSION: u32 = 4096; pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_PIXELS: u64 = 2048 * 2048; -const EDITOR_ICON_SPRITESHEET_MAX_SLICES: usize = 256; +pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_SLICES: usize = 256; pub(crate) const EDITOR_ICON_SPRITESHEET_CPU_MAX_CONCURRENCY: usize = 2; pub(crate) const EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY: usize = 2; pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY: usize = 2; pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS: u64 = EDITOR_ICON_SPRITESHEET_MAX_PIXELS * 4; -const EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS: u32 = 32; +pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS: u32 = 32; pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); pub(crate) const EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION: Duration = @@ -255,9 +255,11 @@ pub(crate) struct EditorIconSpritesheetGenerationRequest { /// 用户要求的切片数量;未提供时按图像中的连通素材自动识别。 #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) slice_count: Option, - /// 图集切分模式;省略时使用连通域切分。 + /// 图集切分模式;必填且没有默认值,必须在任何副作用之前由调用方显式声明。 + /// 这里按原始字符串接收,让业务校验能返回带 `field` 和决策要求的 400, + /// 而不是只让 serde 抛一个通用的 JSON 解析错误。 #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) slice_mode: Option, + pub(crate) slice_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) grid_x: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -283,16 +285,57 @@ pub(crate) enum EditorIconSpritesheetSliceMode { Grid, } -impl Default for EditorIconSpritesheetSliceMode { - fn default() -> Self { - Self::ConnectedComponents +/// `sliceMode` 的显式决策要求:该字段没有默认值,缺失即拒绝。 +pub(crate) const EDITOR_ICON_SPRITESHEET_SLICE_MODE_DECISION_GUIDANCE: &str = "切分模式没有默认值,必须显式声明:需求明确要求等分网格、固定槽位或指定行列数时传 sliceMode=grid,并用 gridX/gridY 传入该行列数;自由排布、数量不定或只要求一张图集时传 sliceMode=connected-components,需要约束素材张数时使用 sliceCount。"; + +fn editor_icon_spritesheet_slice_mode_error(message: String) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "field": "sliceMode", + "message": message, + })) +} + +/// 解析显式的切分模式声明:缺失、空白和未知取值都返回可执行的 400。 +fn parse_editor_icon_spritesheet_slice_mode( + slice_mode: Option<&str>, +) -> Result { + let Some(value) = slice_mode.map(str::trim) else { + return Err(editor_icon_spritesheet_slice_mode_error(format!( + "sliceMode 不能省略:{EDITOR_ICON_SPRITESHEET_SLICE_MODE_DECISION_GUIDANCE}" + ))); + }; + match value { + "" => Err(editor_icon_spritesheet_slice_mode_error(format!( + "sliceMode 不能为空字符串:{EDITOR_ICON_SPRITESHEET_SLICE_MODE_DECISION_GUIDANCE}" + ))), + "connected-components" => Ok(EditorIconSpritesheetSliceMode::ConnectedComponents), + "grid" => Ok(EditorIconSpritesheetSliceMode::Grid), + other => Err(editor_icon_spritesheet_slice_mode_error(format!( + "sliceMode 不支持 {other},只允许 connected-components 或 grid:{EDITOR_ICON_SPRITESHEET_SLICE_MODE_DECISION_GUIDANCE}" + ))), } } -fn resolve_editor_icon_spritesheet_slice_mode( - slice_mode: Option, -) -> EditorIconSpritesheetSliceMode { - slice_mode.unwrap_or_default() +/// 解析并校验图集切分声明:缺失模式、模式与网格参数互相矛盾都在此失败关闭。 +fn resolve_editor_icon_spritesheet_slice_request( + slice_mode: Option<&str>, + grid_x: Option, + grid_y: Option, +) -> Result<(EditorIconSpritesheetSliceMode, u32, u32), AppError> { + let slice_mode = parse_editor_icon_spritesheet_slice_mode(slice_mode)?; + if slice_mode == EditorIconSpritesheetSliceMode::ConnectedComponents + && (grid_x.is_some() || grid_y.is_some()) + { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "field": "gridX/gridY", + "message": "sliceMode=connected-components 不接受 gridX/gridY:网格尺寸只能与 sliceMode=grid 同时声明。", + })), + ); + } + let (grid_x, grid_y) = + resolve_editor_icon_spritesheet_grid_dimensions(slice_mode, grid_x, grid_y)?; + Ok((slice_mode, grid_x, grid_y)) } fn resolve_editor_icon_spritesheet_grid_dimensions( @@ -307,7 +350,10 @@ fn resolve_editor_icon_spritesheet_grid_dimensions( return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "field": "gridX/gridY", - "message": "grid 模式必须同时提供 gridX 与 gridY。", + "message": format!( + "sliceMode=grid 必须同时提供 gridX 与 gridY(各 1 到 {}):行列数必须来自需求本身;用网格参数表达素材张数时应改用 sliceMode=connected-components 加 sliceCount。", + EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS + ), })), ); }; @@ -1464,6 +1510,12 @@ pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner( mut payload: EditorIconSpritesheetGenerationRequest, external_idempotency_key: Option<&str>, ) -> Result { + // 切分模式没有默认值:必须在引用解析、定价和入队之前显式声明。 + resolve_editor_icon_spritesheet_slice_request( + payload.slice_mode.as_deref(), + payload.grid_x, + payload.grid_y, + )?; payload.generation_inputs = sanitize_editor_queued_generation_inputs(payload.generation_inputs.take()); payload.icon_descriptions = @@ -1545,6 +1597,12 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( caller: EditorGenerationCaller, mut payload: EditorIconSpritesheetGenerationRequest, ) -> Result, AppError> { + // 切分模式没有默认值:必须在引用解析、定价和任何 provider / OSS 副作用之前显式声明。 + let (requested_slice_mode, grid_x, grid_y) = resolve_editor_icon_spritesheet_slice_request( + payload.slice_mode.as_deref(), + payload.grid_x, + payload.grid_y, + )?; payload.generation_inputs = sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); ensure_editor_reference_image_sources_are_stable( @@ -1647,12 +1705,6 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( .or_else(|| payload.project_id.clone()), ); let http_client = build_openai_image_http_client(&settings)?; - let requested_slice_mode = resolve_editor_icon_spritesheet_slice_mode(payload.slice_mode); - let (grid_x, grid_y) = resolve_editor_icon_spritesheet_grid_dimensions( - requested_slice_mode, - payload.grid_x, - payload.grid_y, - )?; // TODO(legacy-icon-spritesheet-billing-boundary): 该计费边界继承自 master 的历史实现; // Provider 成功后 operation 即提交,后续解码、OSS、资源与画布持久化失败时缺少可对账中间态。 // 调整前需先定义 provider_succeeded/persistence_pending 等状态、稳定幂等键和补偿语义, @@ -3058,19 +3110,97 @@ mod tests { } #[test] - fn slice_mode_defaults_to_connected_components_and_accepts_explicit_modes() { + fn slice_mode_must_be_declared_and_accepts_explicit_modes() { + let missing = resolve_editor_icon_spritesheet_slice_request(None, None, None) + .expect_err("omitted sliceMode must fail closed"); + assert_eq!(missing.status_code(), StatusCode::BAD_REQUEST); assert_eq!( - resolve_editor_icon_spritesheet_slice_mode(None), - EditorIconSpritesheetSliceMode::ConnectedComponents + missing.details().and_then(|details| details.get("field")), + Some(&json!("sliceMode")) + ); + assert!( + missing + .details() + .and_then(|details| details.get("message")) + .and_then(Value::as_str) + .is_some_and(|message| message.contains("没有默认值") + && message.contains("grid") + && message.contains("connected-components")), + "{:?}", + missing.details() + ); + let empty = resolve_editor_icon_spritesheet_slice_request(Some(" "), None, None) + .expect_err("blank sliceMode must fail closed"); + assert_eq!( + empty.details().and_then(|details| details.get("field")), + Some(&json!("sliceMode")) + ); + assert!( + empty + .details() + .and_then(|details| details.get("message")) + .and_then(Value::as_str) + .is_some_and(|message| message.contains("不能为空字符串") + && message.contains("connected-components")), + "{:?}", + empty.details() + ); + let unknown = + resolve_editor_icon_spritesheet_slice_request(Some("grid-2x2"), Some(2), Some(2)) + .expect_err("unknown sliceMode must fail closed with its own message"); + assert_eq!( + unknown.details().and_then(|details| details.get("field")), + Some(&json!("sliceMode")) + ); + assert!( + unknown + .details() + .and_then(|details| details.get("message")) + .and_then(Value::as_str) + .is_some_and( + |message| message.contains("grid-2x2") && message.contains("没有默认值") + ), + "{:?}", + unknown.details() ); assert_eq!( - resolve_editor_icon_spritesheet_grid_dimensions( - EditorIconSpritesheetSliceMode::Grid, - Some(3), - Some(2), + resolve_editor_icon_spritesheet_slice_request( + Some("connected-components"), + None, + None, ) - .expect("grid dimensions should validate"), - (3, 2) + .expect("explicit connected-components mode should validate"), + (EditorIconSpritesheetSliceMode::ConnectedComponents, 0, 0) + ); + assert_eq!( + resolve_editor_icon_spritesheet_slice_request(Some("grid"), Some(3), Some(2),) + .expect("grid dimensions should validate"), + (EditorIconSpritesheetSliceMode::Grid, 3, 2) + ); + let contradictory = resolve_editor_icon_spritesheet_slice_request( + Some("connected-components"), + Some(2), + Some(2), + ) + .expect_err("grid dimensions must not accompany connected-components"); + assert_eq!(contradictory.status_code(), StatusCode::BAD_REQUEST); + assert_eq!( + contradictory + .details() + .and_then(|details| details.get("field")), + Some(&json!("gridX/gridY")) + ); + let grid_without_dimensions = + resolve_editor_icon_spritesheet_slice_request(Some("grid"), None, None) + .expect_err("grid without dimensions must fail closed"); + assert!( + grid_without_dimensions + .details() + .and_then(|details| details.get("message")) + .and_then(Value::as_str) + .is_some_and(|message| message.contains("sliceCount")), + "{:?}", + grid_without_dimensions.details() ); let connected: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({ "referenceId": "spec", @@ -3079,8 +3209,22 @@ mod tests { })) .expect("explicit connected-components mode should deserialize"); assert_eq!( - connected.slice_mode, - Some(EditorIconSpritesheetSliceMode::ConnectedComponents) + connected.slice_mode.as_deref(), + Some("connected-components") + ); + let omitted: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({ + "referenceId": "spec", + "iconDescriptions": ["素材"] + })) + .expect("omitted sliceMode stays deserializable so the route can return its own 400"); + assert_eq!(omitted.slice_mode, None); + assert!( + resolve_editor_icon_spritesheet_slice_request( + omitted.slice_mode.as_deref(), + omitted.grid_x, + omitted.grid_y, + ) + .is_err() ); let grid: EditorIconSpritesheetGenerationRequest = serde_json::from_value(json!({ "referenceId": "spec", @@ -3090,6 +3234,7 @@ mod tests { "gridY": 2 })) .expect("grid mode should deserialize"); + assert_eq!(grid.slice_mode.as_deref(), Some("grid")); assert_eq!(grid.grid_x, Some(3)); assert_eq!(grid.grid_y, Some(2)); } diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 99923530f..6a53d32d8 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -2644,13 +2644,37 @@ mod tests { icon_spritesheet_request["properties"]["sliceCount"]["minimum"], json!(1) ); + assert_eq!( + icon_spritesheet_request["properties"]["sliceCount"]["maximum"], + json!(crate::editor_project_icon::EDITOR_ICON_SPRITESHEET_MAX_SLICES) + ); assert_eq!( icon_spritesheet_request["properties"]["sliceMode"]["enum"], json!(["connected-components", "grid"]) ); + assert!( + icon_spritesheet_request["properties"]["sliceMode"] + .get("default") + .is_none(), + "sliceMode must not advertise a default" + ); + assert!( + icon_spritesheet_request["required"] + .as_array() + .is_some_and(|required| required.contains(&json!("sliceMode"))), + "sliceMode must be required" + ); + assert!( + icon_spritesheet_request["properties"]["sliceMode"]["description"] + .as_str() + .is_some_and(|description| description.contains("没有默认值") + && description.contains("field=sliceMode") + && description.contains("gridX/gridY")), + "sliceMode description must carry the explicit decision requirement" + ); assert_eq!( icon_spritesheet_request["properties"]["gridX"]["maximum"], - json!(32) + json!(crate::editor_project_icon::EDITOR_ICON_SPRITESHEET_MAX_GRID_AXIS) ); let icon_style_schema = &parsed["components"]["schemas"]["EditorIconSpritesheetGenerationRequest"] ["properties"]["style"]; diff --git a/server-rs/crates/api-server/src/external_mcp.rs b/server-rs/crates/api-server/src/external_mcp.rs index 6b6c469f7..2042fa3d6 100644 --- a/server-rs/crates/api-server/src/external_mcp.rs +++ b/server-rs/crates/api-server/src/external_mcp.rs @@ -54,7 +54,7 @@ const SKILL_REQUESTS_AND_OUTPUTS_URI: &str = "genarrative://external-editor/skill/references/requests-and-outputs.md"; const MAX_MCP_REST_RESPONSE_BYTES: usize = 4 * 1024 * 1024; -const MCP_INSTRUCTIONS: &str = r#"陶泥儿外部编辑器工具。先创建或复用画布项目,并创建与画布同名的素材文件夹;生成结果应同时写入画布和素材库。参考本地文件时先走上传票据和对象确认,不要把 Data URL、Blob URL 或临时签名 URL写入生成参数。所有生成工具都是异步提交:必须提供 idempotencyKey,提交后按 pollAfterMs 调用 get_external_editor_generation_job,只有 status=completed 时消费 result;查询超时不能重新提交。图集生成可用 sliceMode=connected-components(默认连通域切分)或 grid(必须同时提供 gridX/gridY)。warning 表示主结果可用但存在降级,sliceWarning 表示完整透明图集可用但切片未完成。详细说明、OpenAPI、Skill 主入口和分主题 references 见 resources/list;需要本地文件编排或不支持 MCP 时再下载 skill.zip。"#; +const MCP_INSTRUCTIONS: &str = r#"陶泥儿外部编辑器工具。先创建或复用画布项目,并创建与画布同名的素材文件夹;生成结果应同时写入画布和素材库。参考本地文件时先走上传票据和对象确认,不要把 Data URL、Blob URL 或临时签名 URL写入生成参数。所有生成工具都是异步提交:必须提供 idempotencyKey,提交后按 pollAfterMs 调用 get_external_editor_generation_job,只有 status=completed 时消费 result;查询超时不能重新提交。图集生成必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求的 gridX/gridY,自由排布或数量不定时用 connected-components(可用 sliceCount 约束张数),connected-components 不接受 gridX/gridY;缺失、越界或自相矛盾在计费前返回 400。warning 表示主结果可用但存在降级,sliceWarning 表示完整透明图集可用但切片未完成。详细说明、OpenAPI、Skill 主入口和分主题 references 见 resources/list;需要本地文件编排或不支持 MCP 时再下载 skill.zip。"#; #[derive(Clone, Debug)] struct McpOperation { diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index 004c9d5bd..cdbef119d 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -2749,6 +2749,7 @@ describe('ImageCanvasEditorView generation integration', () => { expect.objectContaining({ referenceId: 'resource-icon-spec', iconDescriptions: ['返回按钮\n设置按钮'], + sliceMode: 'connected-components', model: 'gemini-3.1-flash-image-preview', aspectRatio: '1:1', imageSize: '1K', diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index 7a7ec1dd8..d6e9d77f9 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -1080,6 +1080,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { referenceId: 'resource-icon-spec', referenceImageSrcs: ['data:image/png;base64,ref'], iconDescriptions: ['返回按钮\n\n设置按钮'], + sliceMode: 'connected-components', model: 'gpt-image-2', screenColor: 'auto', segModel: 'birefnet', diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts index 6c14a925d..b11548253 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts @@ -1136,6 +1136,9 @@ export function buildIconSpritesheetGenerationSubmissionPlan( } : {}), iconDescriptions, + // 切分模式没有默认值:画板链路按自由排布生成图标表,因此显式声明连通域切分; + // 等分网格或固定槽位需求必须由调用方显式传 grid + gridX/gridY。 + sliceMode: 'connected-components', model: rememberImageModel, screenColor, segModel, diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index b481fdb2c..e4e4a1159 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -4249,6 +4249,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { 'generated-character-drafts/editor/refs/icon-style.png', ], iconDescriptions: ['返回按钮'], + sliceMode: 'connected-components', }), ); }); diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index d846e24e3..79e965d9f 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -1112,6 +1112,7 @@ describe('editorProjectClient', () => { referenceId: 'editor-resource-spec', referenceImageSrcs: references.slice(0, 5), iconDescriptions: ['返回按钮'], + sliceMode: 'connected-components', model: 'gpt-image-2', }), ).rejects.toThrow('图标素材参考图最多允许 4 张'); @@ -1162,6 +1163,29 @@ describe('editorProjectClient', () => { ); }); + it('requires an explicit slice declaration and rejects contradictory grid dimensions', async () => { + requestJsonMock.mockResolvedValue({ queueState: { status: 'queued' } }); + + await expect( + generateEditorIconSpritesheet({ + referenceId: 'editor-resource-icon-spec', + iconDescriptions: ['返回按钮'], + sliceMode: 'grid', + }), + ).rejects.toThrow('sliceMode=grid 必须同时提供 gridX 与 gridY'); + await expect( + generateEditorIconSpritesheet({ + referenceId: 'editor-resource-icon-spec', + iconDescriptions: ['返回按钮'], + sliceMode: 'connected-components', + gridX: 2, + gridY: 2, + }), + ).rejects.toThrow('sliceMode=connected-components 不接受 gridX/gridY'); + + expect(requestJsonMock).not.toHaveBeenCalled(); + }); + it('rejects oversized stable video reference fields before submission', async () => { await expect( generateEditorVideo({ @@ -1331,6 +1355,9 @@ describe('editorProjectClient', () => { referenceId: 'editor-resource-spec', referenceImageSrcs: ['/generated-images/editor/icon-ref.png'], iconDescriptions: ['返回按钮', '设置按钮'], + sliceMode: 'grid', + gridX: 3, + gridY: 2, assetLabel: '冒险游戏图标', }); @@ -1348,6 +1375,9 @@ describe('editorProjectClient', () => { referenceId: 'editor-resource-spec', referenceImageSrcs: ['/generated-images/editor/icon-ref.png'], iconDescriptions: ['返回按钮', '设置按钮'], + sliceMode: 'grid', + gridX: 3, + gridY: 2, model: 'gemini-3.1-flash-image-preview', assetLabel: '冒险游戏图标', }), @@ -1365,6 +1395,7 @@ describe('editorProjectClient', () => { generateEditorIconSpritesheet({ referenceId: 'editor-resource-spec', iconDescriptions: ['图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1)], + sliceMode: 'connected-components', }), ).rejects.toThrow(`不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`); @@ -1377,6 +1408,7 @@ describe('editorProjectClient', () => { '图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS), ), ], + sliceMode: 'connected-components', }), ).rejects.toThrow( `合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS} 个字符`, @@ -1388,6 +1420,7 @@ describe('editorProjectClient', () => { iconDescriptions: Array.from({ length: 8 }, () => '😀'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS), ), + sliceMode: 'connected-components', }), ).rejects.toThrow( `合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES} 个 UTF-8 字节`, @@ -1412,6 +1445,7 @@ describe('editorProjectClient', () => { await generateEditorIconSpritesheet({ referenceId: 'editor-resource-spec', iconDescriptions: ['返回按钮'], + sliceMode: 'connected-components', model: 'gpt-image-2', screenColor: '#E6D8FF', segModel: 'anime-seg', @@ -1434,6 +1468,7 @@ describe('editorProjectClient', () => { body: JSON.stringify({ referenceId: 'editor-resource-spec', iconDescriptions: ['返回按钮'], + sliceMode: 'connected-components', model: 'gpt-image-2', screenColor: '#E6D8FF', segModel: 'anime-seg', diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index e84825190..55003d328 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -380,7 +380,8 @@ export type EditorIconSpritesheetGenerationInput = { referenceId: string; referenceImageSrcs?: string[]; iconDescriptions: string[]; - sliceMode?: 'connected-components' | 'grid'; + /** 必填且没有默认值:必须显式声明连通域或网格切分。 */ + sliceMode: 'connected-components' | 'grid'; gridX?: number; gridY?: number; model?: string; @@ -1300,6 +1301,16 @@ export async function generateEditorIconSpritesheet( input.iconDescriptions, ); const model = input.model?.trim() || EDITOR_IMAGE_MODEL_NANOBANANA2; + // 切分模式没有默认值:声明必须自洽,网格尺寸只能与 grid 同时提交。 + if (input.sliceMode === 'grid') { + if (input.gridX === undefined || input.gridY === undefined) { + throw new Error('sliceMode=grid 必须同时提供 gridX 与 gridY'); + } + } else if (input.gridX !== undefined || input.gridY !== undefined) { + throw new Error( + 'sliceMode=connected-components 不接受 gridX/gridY:网格尺寸只能与 grid 同时提交', + ); + } assertStableEditorMediaReferences(input.referenceImageSrcs, '图标素材参考图'); assertEditorReferenceLimit( input.referenceImageSrcs, @@ -1317,9 +1328,13 @@ export async function generateEditorIconSpritesheet( ? { referenceImageSrcs: input.referenceImageSrcs } : {}), iconDescriptions, - ...(input.sliceMode ? { sliceMode: input.sliceMode } : {}), - ...(input.gridX !== undefined ? { gridX: input.gridX } : {}), - ...(input.gridY !== undefined ? { gridY: input.gridY } : {}), + sliceMode: input.sliceMode, + ...(input.sliceMode === 'grid' && input.gridX !== undefined + ? { gridX: input.gridX } + : {}), + ...(input.sliceMode === 'grid' && input.gridY !== undefined + ? { gridY: input.gridY } + : {}), model, ...(input.screenColor ? { screenColor: input.screenColor } : {}), ...(input.segModel ? { segModel: input.segModel } : {}),