diff --git a/.gitignore b/.gitignore index 2770d44c8..34e0fbde7 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,12 @@ temp*build*/ /apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-resources/ /apps/ai-game-creator-shell/src-tauri/resources/codex/win-x64/codex-package.json /apps/ai-game-creator-shell/src-tauri/resources/plugins/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/bin/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-path/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-resources/ +/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/codex-package.json +/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/manifest.json +/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md /plugins/agc-cocos-editor/native/payload/ /apps/ai-game-creator-shell/logs/ /apps/ai-game-creator-shell/.llm-drafts/ diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 3d56329d7..dffaab261 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -1,7 +1,7 @@ { "name": "@genarrative/ai-game-creator-shell", "private": true, - "version": "0.1.47", + "version": "0.1.67", "type": "module", "scripts": { "dev": "node scripts/start-tauri-dev.mjs", diff --git a/apps/ai-game-creator-shell/scripts/build-release.mjs b/apps/ai-game-creator-shell/scripts/build-release.mjs index fc4bbc8f4..b1fc307f5 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.mjs @@ -14,16 +14,71 @@ const appRoot = fileURLToPath(new URL('..', import.meta.url)); // 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。 const repoRoot = path.resolve(appRoot, '..', '..'); const defaultReleaseTarget = 'x86_64-pc-windows-msvc'; -const releaseTarget = - process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget; -const bundleRoot = path.join( - appRoot, - 'src-tauri', - 'target', - releaseTarget, - 'release', - 'bundle', -); +function defaultTarget() { + return process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget; +} + +function explicitBuildTarget(args) { + let target; + const separator = args.indexOf('--'); + const options = separator < 0 ? args : args.slice(0, separator); + for (let index = 0; index < options.length; index += 1) { + const argument = options[index]; + let value; + if (argument === '--target' || argument === '-t') { + value = options[++index]; + } else if (argument.startsWith('--target=')) { + value = argument.slice('--target='.length); + } else { + continue; + } + if (!value?.trim() || value.startsWith('-')) { + throw new Error('--target 缺少有效目标'); + } + if (target !== undefined) throw new Error('不能重复指定 --target'); + target = value.trim(); + } + return target; +} + +function validateReleaseTarget(target) { + if (target === 'universal-apple-darwin') { + throw new Error( + '内置 Codex 资源仅支持 macOS 单架构构建,请使用 aarch64-apple-darwin 或 x86_64-apple-darwin', + ); + } + if ( + ![ + 'x86_64-pc-windows-msvc', + 'aarch64-apple-darwin', + 'x86_64-apple-darwin', + ].includes(target) + ) { + throw new Error(`不支持的发布目标:${target}`); + } + return target; +} + +/** 在入口冻结目标;所有发布步骤共享同一上下文,不再各自读取默认目标。 */ +export function resolveReleaseContext(args = [], env = process.env) { + const target = validateReleaseTarget( + explicitBuildTarget(args) || + env.AGC_BUILD_TARGET?.trim() || + defaultReleaseTarget, + ); + return Object.freeze({ + target, + channel: resolveReleaseChannel(env, target), + bundleRoot: path.join( + appRoot, + 'src-tauri', + 'target', + target, + 'release', + 'bundle', + ), + }); +} const packageJsonPath = path.join(appRoot, 'package.json'); const rootPackageLockPath = path.resolve(appRoot, '../..', 'package-lock.json'); const tauriConfigPath = path.join(appRoot, 'src-tauri', 'tauri.conf.json'); @@ -99,7 +154,7 @@ export function nextPatchVersion(localVersion, remoteVersion) { return `${major}.${minor}.${patch + 1}`; } -export function resolveReleasePlatform(target = releaseTarget) { +export function resolveReleasePlatform(target = defaultTarget()) { if (target.includes('windows')) return 'windows'; if (target.includes('apple-darwin')) return 'darwin'; if (target.includes('linux')) return 'linux'; @@ -108,7 +163,7 @@ export function resolveReleasePlatform(target = releaseTarget) { export function resolveReleaseChannel( env = process.env, - target = releaseTarget, + target = defaultTarget(), ) { const platform = resolveReleasePlatform(target); const requested = env.AGC_UPDATE_CHANNEL?.trim(); @@ -142,13 +197,10 @@ export function updateManifestUrl(channel = resolveReleaseChannel()) { } /** - * 更新插件按运行时平台键查找清单条目:universal macOS 包同时挂 - * `darwin-aarch64` 与 `darwin-x86_64`,单架构目标只挂对应键。 + * 单架构产物只登记实际目标,不能把同一原生资源映射为另一架构。 */ -export function resolveManifestPlatformKeys(target = releaseTarget) { - if (target === 'universal-apple-darwin') { - return ['darwin-aarch64', 'darwin-x86_64']; - } +export function resolveManifestPlatformKeys(target = defaultTarget()) { + validateReleaseTarget(target); if (target === 'aarch64-apple-darwin') return ['darwin-aarch64']; if (target === 'x86_64-apple-darwin') return ['darwin-x86_64']; if (target.includes('windows')) { @@ -256,8 +308,8 @@ function replaceVersionLine(source, version, pattern, label) { return source.replace(pattern, `$1${version}$3`); } -export async function prepareReleaseVersion() { - const channel = resolveReleaseChannel(); +export async function prepareReleaseVersion(context = resolveReleaseContext()) { + const { channel } = context; const localVersion = parseVersion(readPackageJson().version, '本地版本'); const remoteVersion = await resolveRemoteHighWaterVersion(channel); const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim(); @@ -330,18 +382,14 @@ export async function prepareReleaseVersion() { export function buildTauriBuildArguments( args = [], - target = releaseTarget, + target = defaultTarget(), platform = process.platform, ) { const noBundle = args.includes('--no-bundle'); - const targetIndex = args.indexOf('--target'); - const explicitTarget = - targetIndex >= 0 - ? args[targetIndex + 1] - : args - .find((value) => value.startsWith('--target=')) - ?.slice('--target='.length); + const explicitTarget = explicitBuildTarget(args); const targetArgs = noBundle || explicitTarget ? [] : ['--target', target]; + if (!noBundle || explicitTarget) + validateReleaseTarget(explicitTarget || target); const features = defaultEditorFeatures( explicitTarget || (noBundle ? platform : target), ); @@ -374,18 +422,33 @@ function writeChannelConfigFile(channel) { 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); +export function runTauriBuild( + args = [], + context = resolveReleaseContext(args), + { spawn = spawnSync } = {}, +) { + if ( + explicitBuildTarget(args) && + explicitBuildTarget(args) !== context.target + ) { + throw new Error('构建参数与发布上下文目标不一致'); } + const tauriArguments = buildTauriBuildArguments(args, context.target); + const { channel } = context; + const configPath = writeChannelConfigFile(channel); + console.log( + `[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`, + ); + // 最后合并渠道配置,防止用户配置中的端点与实际发布目标分叉。 + const separator = tauriArguments.indexOf('--'); + tauriArguments.splice( + separator < 0 ? tauriArguments.length : separator, + 0, + '--config', + configPath, + ); const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; - const result = spawnSync( + const result = spawn( npmCommand, ['--prefix', '../..', 'exec', 'tauri', '--', ...tauriArguments], { cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' }, @@ -402,11 +465,11 @@ function listFiles(root) { }); } -function artifactPriority(filePath) { +function artifactPriority(filePath, target) { const name = path.basename(filePath).toLowerCase(); - if (releaseTarget.includes('windows')) return name.endsWith('.exe') ? 0 : 99; + if (target.includes('windows')) return name.endsWith('.exe') ? 0 : 99; // 更新链路要的是 updater 产物(macOS 为 .app.tar.gz),dmg 只作人工分发。 - if (releaseTarget.includes('apple-darwin')) { + if (target.includes('apple-darwin')) { return name.endsWith('.app.tar.gz') ? 0 : 99; } if (name.endsWith('.appimage.tar.gz')) return 0; @@ -416,7 +479,8 @@ function artifactPriority(filePath) { return 99; } -export function selectReleaseArtifact(files) { +export function selectReleaseArtifact(files, target = defaultTarget()) { + validateReleaseTarget(target); const explicit = process.env.AGC_UPDATE_ARTIFACT?.trim(); if (explicit) { const resolved = path.resolve(explicit); @@ -427,9 +491,10 @@ export function selectReleaseArtifact(files) { } return ( [...files] - .filter((filePath) => artifactPriority(filePath) < 99) + .filter((filePath) => artifactPriority(filePath, target) < 99) .sort((left, right) => { - const priority = artifactPriority(left) - artifactPriority(right); + const priority = + artifactPriority(left, target) - artifactPriority(right, target); return priority || left.localeCompare(right); })[0] ?? null ); @@ -450,13 +515,15 @@ function readUpdaterSignature(artifactPath) { export function createUpdateManifest( artifactPath, { - channel = resolveReleaseChannel(), - target = releaseTarget, + target = defaultTarget(), + channel = resolveReleaseChannel(process.env, target), publishedAt = new Date().toISOString(), notes = readReleaseNotes(), commit = readHeadCommit(), } = {}, ) { + validateReleaseTarget(target); + resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }, target); const signature = readUpdaterSignature(artifactPath); const version = readPackageJson().version; const fileName = path.basename(artifactPath); @@ -604,9 +671,11 @@ export function createLegacyUpdateManifest( }; } -export async function generateUpdateManifest() { - const channel = resolveReleaseChannel(); - const artifact = selectReleaseArtifact(listFiles(bundleRoot)); +export async function generateUpdateManifest( + context = resolveReleaseContext(), +) { + const { channel, target, bundleRoot } = context; + const artifact = selectReleaseArtifact(listFiles(bundleRoot), target); if (!artifact) { throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`); } @@ -623,7 +692,7 @@ export async function generateUpdateManifest() { `[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'})`, ); } - const manifest = createUpdateManifest(artifact, { channel, notes }); + const manifest = createUpdateManifest(artifact, { channel, target, notes }); const manifestPath = path.join(bundleRoot, 'latest.json'); fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); const notesPath = path.join(bundleRoot, 'release-notes.txt'); @@ -675,12 +744,24 @@ export async function generateUpdateManifest() { }; } +export async function buildRelease( + args = [], + { + prepareVersion = prepareReleaseVersion, + build = runTauriBuild, + generateManifest = generateUpdateManifest, + } = {}, +) { + const context = resolveReleaseContext(args); + if (!args.includes('--no-bundle')) await prepareVersion(context); + build(args, context); + if (!args.includes('--no-bundle')) return generateManifest(context); +} + if ( process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) ) { const args = process.argv.slice(2); - if (!args.includes('--no-bundle')) await prepareReleaseVersion(); - runTauriBuild(args); - if (!args.includes('--no-bundle')) await generateUpdateManifest(); + await buildRelease(args); } diff --git a/apps/ai-game-creator-shell/scripts/build-release.test.mjs b/apps/ai-game-creator-shell/scripts/build-release.test.mjs index de0134267..8cb9c0214 100644 --- a/apps/ai-game-creator-shell/scripts/build-release.test.mjs +++ b/apps/ai-game-creator-shell/scripts/build-release.test.mjs @@ -14,6 +14,8 @@ import { fileURLToPath } from 'node:url'; import { agcReleasePathPatterns, + buildRelease, + buildTauriBuildArguments, collectRecentReleaseCommits, collectReleaseCommits, compareVersions, @@ -22,11 +24,14 @@ import { createUpdateManifest, formatRecentReleaseNotes, formatReleaseNotes, + generateUpdateManifest, nextPatchVersion, resolveManifestPlatformKeys, resolvePreviousReleaseCommit, resolveReleaseChannel, + resolveReleaseContext, resolveRemoteHighWaterVersion, + runTauriBuild, selectReleaseArtifact, updateManifestUrl, } from './build-release.mjs'; @@ -34,6 +39,21 @@ import { const windowsTarget = 'x86_64-pc-windows-msvc'; const universalTarget = 'universal-apple-darwin'; +test('native sidecar builds reject universal targets and accept each macOS architecture', () => { + assert.throws(() => buildTauriBuildArguments([], universalTarget), /单架构/); + assert.throws( + () => buildTauriBuildArguments(['--target=universal-apple-darwin']), + /单架构/, + ); + for (const target of ['aarch64-apple-darwin', 'x86_64-apple-darwin']) { + assert.deepEqual(buildTauriBuildArguments([], target), [ + 'build', + '--target', + target, + ]); + } +}); + function withEnv(overrides, run) { const previous = new Map(); for (const [key, value] of Object.entries(overrides)) { @@ -132,9 +152,12 @@ test('channel manifest URL and build-time endpoint follow the channel', () => { }); }); -test('universal macOS builds publish one artifact under both platform keys', () => { - assert.deepEqual(resolveManifestPlatformKeys(universalTarget), [ +test('macOS manifests only advertise the architecture actually built', () => { + assert.throws(() => resolveManifestPlatformKeys(universalTarget), /单架构/); + assert.deepEqual(resolveManifestPlatformKeys('aarch64-apple-darwin'), [ 'darwin-aarch64', + ]); + assert.deepEqual(resolveManifestPlatformKeys('x86_64-apple-darwin'), [ 'darwin-x86_64', ]); assert.deepEqual(resolveManifestPlatformKeys(windowsTarget), [ @@ -142,6 +165,218 @@ test('universal macOS builds publish one artifact under both platform keys', () ]); }); +test('release context resolves explicit targets before environment/default and fails closed', () => { + for (const args of [ + ['--target', 'aarch64-apple-darwin'], + ['--target=aarch64-apple-darwin'], + ['-t', 'aarch64-apple-darwin'], + ]) { + for (const env of [{}, { AGC_BUILD_TARGET: windowsTarget }]) { + const context = resolveReleaseContext(args, env); + assert.equal(context.target, 'aarch64-apple-darwin'); + assert.equal(context.channel, 'dev-mac'); + assert.match( + context.bundleRoot.replaceAll('\\', '/'), + /target\/aarch64-apple-darwin\/release\/bundle$/, + ); + assert.ok(Object.isFrozen(context)); + } + assert.throws( + () => resolveReleaseContext(args, { AGC_UPDATE_CHANNEL: 'dev-win' }), + /只能用于 windows/, + ); + } + assert.equal(resolveReleaseContext([], {}).target, windowsTarget); + assert.equal( + resolveReleaseContext([], { AGC_BUILD_TARGET: 'x86_64-apple-darwin' }) + .channel, + 'dev-mac', + ); + for (const args of [ + ['--target'], + ['--target='], + ['--target', '--no-bundle'], + ['--target', windowsTarget, '--target=aarch64-apple-darwin'], + ['--target', universalTarget], + ['--target', 'unknown'], + ]) + assert.throws(() => resolveReleaseContext(args, {})); +}); + +test('explicit macOS target drives version lookup, Tauri endpoint, artifact and manifest together', async () => { + const calls = []; + const seenContexts = []; + await withStubbedFetch( + (url) => { + calls.push(url); + assert.match(url, /\/dev-mac\/latest\.json$/); + return jsonResponse({ version: '0.1.67' }); + }, + () => + withEnv( + { AGC_BUILD_TARGET: undefined, AGC_UPDATE_CHANNEL: undefined }, + () => + buildRelease(['--target', 'aarch64-apple-darwin'], { + prepareVersion: async (context) => { + seenContexts.push(context); + assert.equal( + await resolveRemoteHighWaterVersion(context.channel), + '0.1.67', + ); + }, + build: (args, context) => { + seenContexts.push(context); + runTauriBuild(args, context, { + spawn: (_binary, command) => { + const configIndex = command.lastIndexOf('--config'); + const config = JSON.parse( + readFileSync(command[configIndex + 1], 'utf8'), + ); + assert.match( + config.plugins.updater.endpoints[0], + /\/dev-mac\/latest\.json$/, + ); + assert.ok(command.includes('aarch64-apple-darwin')); + assert.ok( + !command.includes('--features=cocos-editor-execute'), + ); + return { status: 0 }; + }, + }); + }, + generateManifest: (context) => { + seenContexts.push(context); + withSignedArtifact('陶泥儿.app.tar.gz', (artifact) => { + assert.equal( + selectReleaseArtifact( + ['/tmp/win.exe', artifact, '/tmp/mac.dmg'], + context.target, + ), + artifact, + ); + const manifest = createUpdateManifest(artifact, context); + assert.deepEqual(Object.keys(manifest.platforms), [ + 'darwin-aarch64', + ]); + assert.match( + manifest.platforms['darwin-aarch64'].url, + /\/dev-mac\//, + ); + }); + }, + }), + ), + ); + assert.equal(calls.length, 1, 'Mac 不应读取 Windows 迁移指针'); + assert.equal(seenContexts.length, 3); + assert.ok(seenContexts.every((context) => context === seenContexts[0])); +}); + +test('real manifest writer uses the resolved bundle root and does not emit Windows artifacts', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'agc-mac-manifest-')); + try { + const artifact = path.join(root, '陶泥儿.app.tar.gz'); + writeFileSync(artifact, 'mac package'); + writeFileSync(`${artifact}.sig`, 'mac signature'); + writeFileSync(path.join(root, 'windows.exe'), 'wrong platform'); + const context = { + ...resolveReleaseContext(['--target=x86_64-apple-darwin'], {}), + bundleRoot: root, + }; + const result = await withStubbedFetch( + (url) => { + assert.match(url, /\/dev-mac\/latest\.json$/); + return jsonResponse({}, 404); + }, + () => generateUpdateManifest(context), + ); + assert.equal(result.artifact, artifact); + assert.equal(result.manifestPath, path.join(root, 'latest.json')); + assert.equal(result.legacyManifestPath, null); + assert.deepEqual(Object.keys(result.manifest.platforms), ['darwin-x86_64']); + assert.match(result.manifest.platforms['darwin-x86_64'].url, /\/dev-mac\//); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('invalid target or mismatched channel fails before any release side effect', async () => { + let touched = false; + const sideEffects = { + prepareVersion: () => { + touched = true; + }, + build: () => { + touched = true; + }, + generateManifest: () => { + touched = true; + }, + }; + await assert.rejects( + () => buildRelease(['--target', universalTarget], sideEffects), + /单架构/, + ); + await withEnv({ AGC_UPDATE_CHANNEL: 'dev-win' }, () => + assert.rejects( + () => buildRelease(['--target=aarch64-apple-darwin'], sideEffects), + /只能用于 windows/, + ), + ); + assert.equal(touched, false); +}); + +test('Windows remains the default and explicit Windows overrides macOS environment', () => { + const files = ['/tmp/mac.app.tar.gz', '/tmp/windows.exe', '/tmp/mac.dmg']; + for (const context of [ + resolveReleaseContext([], {}), + resolveReleaseContext(['--target', windowsTarget], { + AGC_BUILD_TARGET: 'aarch64-apple-darwin', + }), + ]) { + assert.equal(context.channel, 'dev-win'); + assert.equal( + selectReleaseArtifact(files, context.target), + '/tmp/windows.exe', + ); + runTauriBuild( + ['--target', windowsTarget, '--config', 'user-config.json'], + context, + { + spawn: (_binary, command) => { + assert.ok(command.includes('--features=cocos-editor-execute')); + assert.ok(command.includes('user-config.json')); + const configIndex = command.lastIndexOf('--config'); + const config = JSON.parse( + readFileSync(command[configIndex + 1], 'utf8'), + ); + assert.match( + config.plugins.updater.endpoints[0], + /\/dev-win\/latest\.json$/, + ); + return { status: 0 }; + }, + }, + ); + } +}); + +test('no-bundle smoke skips version writes and manifest generation', async () => { + const steps = []; + await buildRelease(['--no-bundle', '--target=aarch64-apple-darwin'], { + prepareVersion: () => { + steps.push('version'); + }, + build: (_args, context) => { + steps.push(context.channel); + }, + generateManifest: () => { + steps.push('manifest'); + }, + }); + assert.deepEqual(steps, ['dev-mac']); +}); + test('channel manifest carries version, platform keys and signature', () => { withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => { withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => { @@ -345,6 +580,7 @@ test('release upload forces overwrite for artifact, signature and channel pointe ); assert.match(source, /agc\/\$\{channel\}\/latest\.json/u); assert.match(source, /agc\/latest\.json/u); + assert.match(source, /await buildRelease\(process\.argv\.slice\(2\)\)/u); }); test('release notes list client commits with short sha and bound their size', () => { diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index ab7a0270c..fe8943608 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -35,6 +35,12 @@ const windowsTauriConfig = JSON.parse( 'utf8', ), ); +const macosTauriConfig = JSON.parse( + fs.readFileSync( + new URL('../src-tauri/tauri.macos.conf.json', import.meta.url), + 'utf8', + ), +); const cargoManifestSource = fs.readFileSync( new URL('../src-tauri/Cargo.toml', import.meta.url), 'utf8', @@ -1358,6 +1364,37 @@ if (windowsTauriConfig.bundle?.useLocalToolsDir !== true) { 'AI game creator shell Windows Tauri config must cache bundling tools in the project target directory', ); } +assert.deepEqual( + macosTauriConfig.bundle?.resources, + Object.fromEntries([ + ...[ + 'bin/codex', + 'bin/codex-code-mode-host', + 'codex-path/rg', + 'codex-resources/zsh/bin/zsh', + 'codex-package.json', + 'NOTICE.md', + 'manifest.json', + ].map((file) => [ + `resources/codex/mac-native/${file}`, + `coding-agent/mac-native/${file}`, + ]), + ['resources/plugins', 'plugins'], + ]), + 'macOS must bundle the complete native Codex layout and plugin workspace', +); +assert.deepEqual( + macosTauriConfig.plugins?.updater?.endpoints, + [ + 'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/latest.json', + ], + 'macOS local builds must not use the Windows update channel', +); +assert.equal( + macosTauriConfig.bundle?.macOS?.minimumSystemVersion, + '15.0', + 'macOS deployment baseline must cover the bundled native zsh requirement', +); if (tauriConfig.app?.withGlobalTauri !== true) { throw new Error( @@ -1722,7 +1759,7 @@ for (const snippet of [ 'fn append_local_permission_log_at(', '"command.auto"', 'GameCreationAppPermission::Auto', - 'GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH', + 'fn game_creator_bundled_codex_cli_path', 'validate_game_creator_bundled_codex_cli', '内置 Codex CLI 完整性校验失败', ]) { diff --git a/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs b/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs new file mode 100644 index 000000000..78e38fdd9 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs @@ -0,0 +1,222 @@ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// 只操作临时复制品;不启动 GUI、不读取开发机凭据、不访问 Provider。 +assert.equal(process.platform, 'darwin', '此验证必须在 macOS 执行'); +const source = path.resolve(process.argv[2] || ''); +assert.ok( + source.endsWith('.app') && fs.statSync(source).isDirectory(), + '请传入 .app 绝对路径', +); +const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'agc-macos-bundle-')), +); +const app = path.join(root, '陶泥儿 隔离测试.app'); +const home = path.join(root, 'home'); +const config = path.join(root, 'config'); +const tmp = path.join(root, 'tmp'); +const codexHome = path.join(root, 'codex-home'); +for (const directory of [home, config, tmp, codexHome]) { + fs.mkdirSync(directory, { mode: 0o700 }); +} +const env = { + HOME: home, + PATH: '/usr/bin:/bin', + TMPDIR: tmp, + CODEX_HOME: codexHome, +}; + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: root, + env, + encoding: 'utf8', + timeout: 30_000, + maxBuffer: 1024 * 1024, + }); + assert.ifError(result.error); + return result; +} + +async function hashFile(file) { + const hash = createHash('sha256'); + for await (const chunk of fs.createReadStream(file)) hash.update(chunk); + return hash.digest('hex'); +} + +async function handshake(executable) { + const child = spawn(executable, ['app-server'], { + cwd: root, + env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let buffered = ''; + let stderrBytes = 0; + try { + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('app-server 初始化超时')), + 15_000, + ); + const finish = (error) => { + clearTimeout(timer); + if (error) reject(error); + else resolve(); + }; + child.on('error', finish); + child.on('exit', (code) => + finish(new Error(`app-server 提前退出 ${code}`)), + ); + child.stderr.on('data', (chunk) => { + stderrBytes += chunk.length; + if (stderrBytes > 1024 * 1024) + finish(new Error('app-server stderr 超限')); + }); + child.stdout.on('data', (chunk) => { + buffered += chunk.toString('utf8'); + if (buffered.length > 1024 * 1024) + return finish(new Error('app-server stdout 超限')); + let end; + while ((end = buffered.indexOf('\n')) >= 0) { + const line = buffered.slice(0, end); + buffered = buffered.slice(end + 1); + try { + const message = JSON.parse(line); + if (message.id !== 1) continue; + assert.ok(message.result?.userAgent, '初始化必须返回真实服务身份'); + assert.equal(message.error, undefined); + child.stdin.write(`${JSON.stringify({ method: 'initialized' })}\n`); + finish(); + } catch (error) { + finish(error); + } + } + }); + child.stdin.on('error', finish); + child.stdin.write( + `${JSON.stringify({ + id: 1, + method: 'initialize', + params: { + clientInfo: { + name: 'agc_bundle_smoke', + title: 'AGC bundle smoke', + version: '1', + }, + capabilities: { experimentalApi: true }, + }, + })}\n`, + ); + }); + } finally { + if (child.exitCode === null && child.signalCode === null) { + await new Promise((resolve) => { + const timer = setTimeout(() => child.kill('SIGKILL'), 3000); + child.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + child.kill('SIGTERM'); + }); + } + } +} + +try { + fs.cpSync(source, app, { recursive: true }); + const resources = path.join(app, 'Contents/Resources'); + const bundle = path.join(resources, 'coding-agent/mac-native'); + const executable = path.join(bundle, 'bin/codex'); + const main = path.join( + app, + 'Contents/MacOS/genarrative-ai-game-creator-shell', + ); + const manifest = JSON.parse( + fs.readFileSync(path.join(bundle, 'manifest.json'), 'utf8'), + ); + assert.equal(manifest.schemaVersion, 'genarrative-codex-sidecar.v2'); + assert.equal( + manifest.platform, + process.arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64', + ); + assert.equal(manifest.version, 'codex-cli 0.147.0'); + const components = [ + 'bin/codex', + 'bin/codex-code-mode-host', + 'codex-path/rg', + 'codex-resources/zsh/bin/zsh', + 'codex-package.json', + ]; + assert.deepEqual(Object.keys(manifest.files).sort(), [...components].sort()); + for (const component of components) { + const file = path.join(bundle, component); + assert.equal(await hashFile(file), manifest.files[component], component); + if (component !== 'codex-package.json') { + fs.accessSync(file, fs.constants.X_OK); + const arch = run('/usr/bin/lipo', ['-archs', file]); + assert.equal(arch.status, 0, component); + assert.equal( + arch.stdout.trim(), + process.arch === 'arm64' ? 'arm64' : 'x86_64', + component, + ); + } + } + assert.ok(fs.existsSync(path.join(bundle, 'NOTICE.md'))); + const plugin = path.join(resources, 'plugins/agc-cocos-editor'); + for (const file of [ + 'plugin.json', + 'src/entry.mjs', + 'panels/cocos-editor.html', + ]) { + assert.ok(fs.existsSync(path.join(plugin, file)), file); + } + const packageFiles = fs.readdirSync(resources, { recursive: true }); + assert.ok( + !packageFiles.some((file) => + /(^|\/)(\.env[^/]*|auth\.json|node_modules|target|\.git)(\/|$)|\.(exe|dll)$/.test( + file, + ), + ), + ); + assert.equal(run(executable, ['--version']).stdout.trim(), manifest.version); + assert.equal( + run(path.join(bundle, 'codex-path/rg'), ['--version']).status, + 0, + ); + assert.equal( + run(path.join(bundle, 'codex-resources/zsh/bin/zsh'), ['--version']).status, + 0, + ); + + // 使用正式 AGC 查找/校验入口,而非只证明 sidecar 可以独立执行。 + const status = run(main, ['--config-dir', config, '--llm-status']); + const statusText = `${status.stdout}\n${status.stderr}`; + assert.ok(!statusText.includes('Codex CLI 未安装'), statusText); + assert.ok( + statusText.includes('authentication-required'), + '隔离账号应仅被登录门禁拒绝', + ); + await handshake(executable); + + // 临时复制品缺少辅助程序时,正式入口必须拒绝内置程序;PATH 无全局 Codex 可兜底。 + fs.renameSync( + path.join(bundle, 'bin/codex-code-mode-host'), + path.join(root, 'saved-code-mode-host'), + ); + const broken = run(main, ['--config-dir', config, '--llm-status']); + assert.notEqual(broken.status, 0); + assert.match(`${broken.stdout}\n${broken.stderr}`, /Codex CLI 未安装/); + console.log( + 'PASS: 隔离安装包资源、架构、摘要、权限、正式 Codex 查找、app-server 握手及缺组件拒绝', + ); + console.log( + '未验证:GUI、真实登录/Provider 对话、Cocos macOS 原生桥接;插件 Node 仍为外部前提', + ); +} finally { + fs.rmSync(root, { recursive: true, force: true }); +} diff --git a/apps/ai-game-creator-shell/scripts/release-upload.mjs b/apps/ai-game-creator-shell/scripts/release-upload.mjs index 3d1cff921..d39b2fa9f 100644 --- a/apps/ai-game-creator-shell/scripts/release-upload.mjs +++ b/apps/ai-game-creator-shell/scripts/release-upload.mjs @@ -12,8 +12,7 @@ if (!/^[a-z0-9][a-z0-9.-]{1,62}$/u.test(bucket) || /[\r\n\0]/u.test(endpoint)) { process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`; const dryRun = readReleaseDryRun(); -const { generateUpdateManifest, prepareReleaseVersion, runTauriBuild } = - await import('./build-release.mjs'); +const { buildRelease } = await import('./build-release.mjs'); function runOssutil(args) { const binary = process.env.OSSUTIL_BIN?.trim() || 'ossutil'; @@ -51,10 +50,8 @@ function runOssutil(args) { if (result.status !== 0) process.exit(result.status ?? 1); } -await prepareReleaseVersion(); -runTauriBuild([]); const { artifact, channel, legacyManifestPath, manifest, manifestPath } = - await generateUpdateManifest(); + await buildRelease(process.argv.slice(2)); const artifactKey = `agc/${channel}/${manifest.version}/${path.basename(artifact)}`; // Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过; // 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。 diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index be749d083..21f1d6233 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1745,7 +1745,7 @@ dependencies = [ [[package]] name = "genarrative-ai-game-creator-shell" -version = "0.1.47" +version = "0.1.67" dependencies = [ "agent-runtime-core", "axum", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index d97416526..0b4d67035 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "genarrative-ai-game-creator-shell" -version = "0.1.47" +version = "0.1.67" edition = "2021" publish = false diff --git a/apps/ai-game-creator-shell/src-tauri/build.rs b/apps/ai-game-creator-shell/src-tauri/build.rs index e9f6642e0..6bca6d67a 100644 --- a/apps/ai-game-creator-shell/src-tauri/build.rs +++ b/apps/ai-game-creator-shell/src-tauri/build.rs @@ -1,31 +1,18 @@ +#[path = "build_support/codex_bundle.rs"] +mod codex_bundle; #[path = "build_support/frontend_dist_guard.rs"] mod frontend_dist_guard; #[path = "build_support/runtime_prompt_bundle.rs"] mod runtime_prompt_bundle; -#[cfg(windows)] use sha2::{Digest, Sha256}; use std::collections::BTreeSet; use std::env; use std::fs; use std::path::PathBuf; -#[cfg(windows)] use std::io::{BufReader, Read}; -const BUNDLED_CODEX_CLI_VERSION: &str = "codex-cli 0.147.0"; - -#[cfg(windows)] -const BUNDLED_CODEX_FILES: [&str; 6] = [ - "bin/codex.exe", - "bin/codex-code-mode-host.exe", - "codex-path/rg.exe", - "codex-resources/codex-command-runner.exe", - "codex-resources/codex-windows-sandbox-setup.exe", - "codex-package.json", -]; - -#[cfg(windows)] fn sha256_file(path: &std::path::Path) -> Result { let file = fs::File::open(path)?; let mut reader = BufReader::new(file); @@ -42,7 +29,15 @@ fn sha256_file(path: &std::path::Path) -> Result { } fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { - #[cfg(windows)] + let target = env::var("TARGET").expect("Cargo TARGET"); + println!("cargo:rustc-env=AGC_BUILD_TARGET={target}"); + let Some(layout) = codex_bundle::for_target(&target) else { + assert!( + !target.contains("windows") && !target.contains("apple-darwin"), + "不支持的 Codex 随包目标:{target}" + ); + return; + }; { let app_root = manifest_dir .parent() @@ -51,24 +46,23 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { .parent() .and_then(|apps_dir| apps_dir.parent()) .expect("AI 游戏创作应用必须位于仓库 apps 目录下"); - let source_candidates = [ - app_root.join( - "node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc", - ), - app_root.join( - "node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc", - ), - repo_root.join( - "node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc", - ), - repo_root.join( - "node_modules/@openai/codex/node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc", - ), - ]; + let package = layout.npm_package; + let source_candidates = [app_root, repo_root] + .into_iter() + .flat_map(|root| { + [ + root.join(format!("node_modules/@openai/{package}/vendor/{target}")), + root.join(format!( + "node_modules/@openai/codex/node_modules/@openai/{package}/vendor/{target}" + )), + ] + }) + .collect::>(); let source = source_candidates .iter() .find(|path| { - BUNDLED_CODEX_FILES + layout + .files .iter() .all(|relative| path.join(relative).is_file()) }) @@ -83,14 +77,26 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { .join(";") ) }); - let target_dir = manifest_dir.join("resources/codex/win-x64"); + let metadata: serde_json::Value = serde_json::from_slice( + &fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"), + ) + .expect("Codex 原生包元数据无效"); + codex_bundle::validate_package_metadata(&metadata, &target, layout) + .unwrap_or_else(|error| panic!("{error}")); + let target_dir = manifest_dir.join("resources/codex").join(layout.directory); let notice = target_dir.join("NOTICE.md"); + if target.contains("apple-darwin") { + let source_notice = + manifest_dir.join("resources/codex/【声明】Mac内置Codex组件-2026-09-18.md"); + stage_plugin_file(&source_notice, ¬ice); + println!("cargo:rerun-if-changed={}", source_notice.display()); + } if !notice.is_file() { panic!("内置 Codex CLI 第三方声明缺失:{}", notice.display()); } fs::create_dir_all(&target_dir).expect("创建内置 Codex CLI 资源目录失败"); let mut file_hashes = serde_json::Map::new(); - for relative in BUNDLED_CODEX_FILES { + for relative in layout.files { let source_path = source.join(relative); let target_path = target_dir.join(relative); if let Some(parent) = target_path.parent() { @@ -104,15 +110,23 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { if !target_matches_source { fs::copy(&source_path, &target_path).expect("复制内置 Codex CLI 资源失败"); } + // 内容相同但曾被错误 chmod 的 staging 文件也必须恢复执行权限。 + fs::set_permissions( + &target_path, + fs::metadata(&source_path) + .expect("读取组件权限失败") + .permissions(), + ) + .expect("保留内置 Codex CLI 组件权限失败"); file_hashes.insert( relative.to_string(), serde_json::Value::String(source_sha256), ); } let manifest = serde_json::json!({ - "schemaVersion": "genarrative-codex-sidecar.v2", - "platform": "win32-x64", - "version": BUNDLED_CODEX_CLI_VERSION, + "schemaVersion": codex_bundle::SCHEMA, + "platform": layout.platform, + "version": codex_bundle::CLI_VERSION, "files": file_hashes, }); let manifest_path = target_dir.join("manifest.json"); @@ -126,7 +140,7 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { { fs::write(&manifest_path, manifest_payload).expect("写入内置 Codex CLI 清单失败"); } - for relative in BUNDLED_CODEX_FILES { + for relative in layout.files { println!("cargo:rerun-if-changed={}", source.join(relative).display()); } println!("cargo:rerun-if-changed={}", notice.display()); @@ -256,8 +270,11 @@ fn stage_cocos_editor_payload(_manifest_dir: &std::path::Path) {} /// /// 只复制插件运行需要的清单、入口、面板和 native payload,不复制 native 源码、 /// Cargo target 目录或 node_modules。 -#[cfg(windows)] fn stage_plugin_workspace(manifest_dir: &std::path::Path) { + let target = env::var("TARGET").expect("Cargo TARGET"); + if !target.contains("windows") && !target.contains("apple-darwin") { + return; + } let repo_root = manifest_dir .parent() .and_then(|app_root| app_root.parent()) @@ -266,6 +283,10 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) { .to_path_buf(); let workspace = repo_root.join("plugins"); let destination_root = manifest_dir.join("resources/plugins"); + // staging 是专用生成目录;重建清除跨目标 payload 与已删除插件的残留。 + if destination_root.exists() { + std::fs::remove_dir_all(&destination_root).expect("清理插件 staging 失败"); + } std::fs::create_dir_all(&destination_root).expect("创建插件资源目录失败"); let entries = match std::fs::read_dir(&workspace) { Ok(entries) => entries, @@ -273,6 +294,13 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) { }; for entry in entries.flatten() { let plugin_root = entry.path(); + assert!( + !entry + .file_type() + .expect("读取插件目录类型失败") + .is_symlink(), + "插件工作区不允许符号链接" + ); if !plugin_root.is_dir() || !plugin_root.join("plugin.json").is_file() { continue; } @@ -287,17 +315,18 @@ fn stage_plugin_workspace(manifest_dir: &std::path::Path) { std::path::PathBuf::from("panels"), std::path::PathBuf::from("native/payload"), ] { + if relative == std::path::Path::new("native/payload") && !target.contains("windows") { + continue; + } copy_plugin_tree(&plugin_root.join(&relative), &destination.join(&relative)); } println!("cargo:rerun-if-changed={}", plugin_root.display()); } } -#[cfg(windows)] fn stage_plugin_file(source: &std::path::Path, destination: &std::path::Path) { - let Ok(bytes) = std::fs::read(source) else { - return; - }; + let bytes = std::fs::read(source) + .unwrap_or_else(|error| panic!("读取随包资源失败 {}:{error}", source.display())); if std::fs::read(destination).is_ok_and(|existing| existing == bytes) { return; } @@ -307,7 +336,6 @@ fn stage_plugin_file(source: &std::path::Path, destination: &std::path::Path) { std::fs::write(destination, bytes).expect("复制插件资源失败"); } -#[cfg(windows)] fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) { let entries = match std::fs::read_dir(source) { Ok(entries) => entries, @@ -316,10 +344,17 @@ fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) { for entry in entries.flatten() { let target = destination.join(entry.file_name()); let path = entry.path(); + assert!( + !entry + .file_type() + .expect("读取插件文件类型失败") + .is_symlink(), + "插件资源不允许符号链接" + ); if path.is_dir() { let name = entry.file_name(); let name = name.to_string_lossy(); - if matches!(name.as_ref(), "target" | "node_modules" | ".git") { + if name.starts_with('.') || matches!(name.as_ref(), "target" | "node_modules") { continue; } std::fs::create_dir_all(&target).expect("创建插件资源目录失败"); @@ -331,12 +366,14 @@ fn copy_plugin_tree(source: &std::path::Path, destination: &std::path::Path) { if name.contains(".test.") { continue; } + if name.starts_with('.') { + continue; + } stage_plugin_file(&path, &target); } } } -#[cfg(windows)] fn copy_plugin_file(source: &std::path::Path, destination: &std::path::Path) { if !source.is_file() { return; @@ -345,6 +382,3 @@ fn copy_plugin_file(source: &std::path::Path, destination: &std::path::Path) { .expect("创建插件资源目录失败"); std::fs::copy(source, destination).expect("复制插件资源失败"); } - -#[cfg(not(windows))] -fn stage_plugin_workspace(_manifest_dir: &std::path::Path) {} diff --git a/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs b/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs new file mode 100644 index 000000000..1811f6a25 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/build_support/codex_bundle.rs @@ -0,0 +1,133 @@ +//! 构建与运行共用的平台布局;只允许分发锁定原生包里的明确组件。 + +pub const VERSION: &str = "0.147.0"; +pub const CLI_VERSION: &str = "codex-cli 0.147.0"; +pub const SCHEMA: &str = "genarrative-codex-sidecar.v2"; + +#[derive(Clone, Copy, Debug)] +pub struct Layout { + pub platform: &'static str, + pub npm_package: &'static str, + pub directory: &'static str, + pub executable: &'static str, + pub files: &'static [&'static str], +} + +const WINDOWS_FILES: &[&str] = &[ + "bin/codex.exe", + "bin/codex-code-mode-host.exe", + "codex-path/rg.exe", + "codex-resources/codex-command-runner.exe", + "codex-resources/codex-windows-sandbox-setup.exe", + "codex-package.json", +]; +const MAC_FILES: &[&str] = &[ + "bin/codex", + "bin/codex-code-mode-host", + "codex-path/rg", + "codex-resources/zsh/bin/zsh", + "codex-package.json", +]; + +pub fn for_target(target: &str) -> Option { + match target { + "x86_64-pc-windows-msvc" => Some(Layout { + platform: "win32-x64", + npm_package: "codex-win32-x64", + directory: "win-x64", + executable: "bin/codex.exe", + files: WINDOWS_FILES, + }), + "aarch64-apple-darwin" | "x86_64-apple-darwin" => Some(Layout { + platform: if target.starts_with("aarch64") { + "darwin-arm64" + } else { + "darwin-x64" + }, + npm_package: if target.starts_with("aarch64") { + "codex-darwin-arm64" + } else { + "codex-darwin-x64" + }, + directory: "mac-native", + executable: "bin/codex", + files: MAC_FILES, + }), + _ => None, + } +} + +pub fn validate_package_metadata( + metadata: &serde_json::Value, + target: &str, + layout: Layout, +) -> Result<(), String> { + if metadata["layoutVersion"] == 1 + && metadata["version"] == VERSION + && metadata["target"] == target + && metadata["entrypoint"] == layout.executable + && metadata["resourcesDir"] == "codex-resources" + && metadata["pathDir"] == "codex-path" + { + Ok(()) + } else { + Err(format!("Codex 原生包版本、布局或架构不匹配目标 {target}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn platform_layouts_are_explicit_and_preserve_upstream_components() { + let mac = for_target("aarch64-apple-darwin").unwrap(); + assert_eq!(mac.platform, "darwin-arm64"); + assert_eq!(mac.npm_package, "codex-darwin-arm64"); + assert!(mac.files.contains(&"codex-resources/zsh/bin/zsh")); + assert!(mac.files.contains(&"bin/codex-code-mode-host")); + assert!(!mac.files.iter().any(|file| file.ends_with(".exe"))); + let intel = for_target("x86_64-apple-darwin").unwrap(); + assert_eq!(intel.platform, "darwin-x64"); + assert_eq!(intel.npm_package, "codex-darwin-x64"); + let windows = for_target("x86_64-pc-windows-msvc").unwrap(); + assert_eq!(windows.directory, "win-x64"); + assert_eq!(windows.files.len(), 6); + assert!(windows + .files + .contains(&"codex-resources/codex-windows-sandbox-setup.exe")); + assert!(for_target("universal-apple-darwin").is_none()); + assert!(for_target("aarch64-pc-windows-msvc").is_none()); + assert!(for_target("x86_64-unknown-linux-gnu").is_none()); + } + + #[test] + fn metadata_rejects_version_architecture_and_layout_drift() { + let target = "aarch64-apple-darwin"; + let layout = for_target(target).unwrap(); + let valid = serde_json::json!({ + "layoutVersion": 1, + "version": VERSION, + "target": target, + "entrypoint": "bin/codex", + "resourcesDir": "codex-resources", + "pathDir": "codex-path", + }); + assert!(validate_package_metadata(&valid, target, layout).is_ok()); + for (key, value) in [ + ("layoutVersion", serde_json::json!(2)), + ("version", serde_json::json!("0.0.0")), + ("target", serde_json::json!("x86_64-apple-darwin")), + ("entrypoint", serde_json::json!("bin/codex.exe")), + ("resourcesDir", serde_json::json!("../private")), + ("pathDir", serde_json::json!(null)), + ] { + let mut invalid = valid.clone(); + invalid[key] = value; + assert!( + validate_package_metadata(&invalid, target, layout).is_err(), + "{key}" + ); + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md index c9de909a6..b6e070a85 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/SKILL.md @@ -14,6 +14,9 @@ Implement the user's actual game request in the current project as an npm-manage 3. Build with the project's npm script before previewing. The playable entry is the package directory's `dist/index.html`; never report an unbuilt bare-module page as playable. Import assets or configure public assets so all runtime media is included in dist; preview and exports cannot read outside it. 4. Build a complete playable loop: visible objective, responsive input, meaningful state changes, success or failure feedback, and a reliable restart path where the game needs one. 5. Fit the active game scene to desktop and mobile viewports without accidental page scrollbars. Reserve deliberate safe space for HUD elements instead of covering interactive content. + - **画布居中只能由一处负责。** 使用 `Phaser.Scale.FIT` 与 `autoCenter: Phaser.Scale.CENTER_BOTH` 时,canvas 的直接父容器应使用尺寸明确的普通块布局,不再对同一 canvas 叠加 Grid/Flex 居中、`place-items: center`、自动外边距或居中 transform。Phaser 自动计算的 margin 与 CSS 居中叠加会使竖屏画面向右偏移。 + - 若决定由 CSS 居中,则显式使用 `autoCenter: Phaser.Scale.NO_CENTER`,由 CSS 独立完成定位;外围页面可以继续使用 Grid/Flex,限制只针对同一 canvas 的重复定位。 + - 出现偏移先检查游戏自身的 CSS 与 Phaser scale 配置,不添加 AGC 预览容器固定偏移补偿。修改布局后重新构建 dist,在桌面、移动及窗口 resize 后检查 canvas 相对游戏父容器居中(误差不超过 1 CSS px)、画面完整且无意外滚动条;不能仅凭 build 成功宣称布局通过。 6. Invoke `taonier-art-assets` for every new game brief that needs visual assets. First reuse suitable registered Taonier art; when the brief's required visual elements are missing or unsuitable, call the reviewed `agc_tools` generation/edit workflow in the same task. After the tool returns, wire its relative paths into the game and verify the rendered result. A game with unused generated assets or placeholder emoji/CSS where requested art should appear is not complete. Load media defensively only for genuinely optional effects, and never relabel a local placeholder as platform art. 7. Let Phaser own the render loop and input dispatch. Avoid duplicate scenes, stale event listeners, and state that survives restart unintentionally. 8. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md index 77bdf1f9c..5eeea8e90 100644 --- a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md +++ b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/agc-web-game-development/references/game-quality-checklist.md @@ -7,5 +7,7 @@ - Score, steps, health, timer, or other core state updates consistently. - Restart restores all state and does not duplicate timers, animation loops, or event listeners. - Desktop and mobile layouts keep the core scene visible without accidental document scrolling. +- 画布的缩放与居中由 Phaser 或 CSS 中的一方独立负责。`FIT + CENTER_BOTH` 不与同一 canvas 父容器的 Grid/Flex 居中、自动外边距或居中 transform 叠加;使用 CSS 居中时关闭 Phaser 自动居中(`NO_CENTER`)。 +- 在构建后的实际页面检查桌面、移动和 resize:比较 canvas 与游戏父容器的中心,预期居中时水平/垂直误差不超过 1 CSS px,并检查画面没有溢出或意外滚动条。偏移先修游戏 CSS/scale 配置,不用修改 AGC 预览位置掩盖。 - HUD and overlays reserve space and do not cover essential interactive content. - Requested Taonier art is visibly integrated into the core experience when available. diff --git a/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json b/apps/ai-game-creator-shell/src-tauri/resources/agc-skills/manifest.json index ffd9d63b5..0b9f67ba9 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 @@ -80,7 +80,7 @@ "agents/openai.yaml", "references/game-quality-checklist.md" ], - "sha256": "05b5cfbf7a40fd303717491f5cea84ff339a73359c9678b283fd54d2b5c45efd" + "sha256": "e122d8f3a6d986b594b95c971754d68197bf7896912fa8267d44a7aa129a57ba" }, { "name": "agc-browser-playtest", diff --git a/apps/ai-game-creator-shell/src-tauri/resources/codex/【声明】Mac内置Codex组件-2026-09-18.md b/apps/ai-game-creator-shell/src-tauri/resources/codex/【声明】Mac内置Codex组件-2026-09-18.md new file mode 100644 index 000000000..affea34fb --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/resources/codex/【声明】Mac内置Codex组件-2026-09-18.md @@ -0,0 +1,14 @@ +# 内置 Codex CLI + +本安装包包含锁定版本 Codex CLI 0.147.0 的 macOS 原生组件。 + +Codex CLI 按 Apache License 2.0 分发,源码与许可证见 +https://github.com/openai/codex。 + +组件来自项目锁定的 `@openai/codex` 原生 npm 依赖,保留上游的 +`bin/codex`、`bin/codex-code-mode-host`、`codex-path/rg`、 +`codex-resources/zsh/bin/zsh` 和 `codex-package.json` 相对布局。 +原生依赖中的 ripgrep 与 zsh 按各自上游许可证分发: +https://github.com/BurntSushi/ripgrep 和 https://www.zsh.org/。 + +安装包不包含 API Key、登录状态、用户配置或项目数据。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 7bf8b2484..b33ebcf21 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -797,6 +797,16 @@ fn direct_thread_visible_item( direct_thread_event_item(root, item) } +/// AGC 预写的 canonical 用户条目 id:`direct-codex:{clientTurnId}:user`。 +/// +/// 与 `direct_project_history::is_direct_project_codex_user_item` 的判据同一份口径(前缀 + +/// `:user` 后缀)。回合生命周期事件的 `userItemId` 只能来自这里或已落盘条目自身的 id; +/// clientTurnId 缺失时不猜身份,返回 `None` 让前端按"未知归属"处理。 +fn direct_codex_user_item_id_for_client_turn_id(client_turn_id: &str) -> Option { + let client_turn_id = client_turn_id.trim(); + (!client_turn_id.is_empty()).then(|| format!("direct-codex:{client_turn_id}:user")) +} + fn direct_codex_command_is_game_verification(command: &str) -> bool { let command = command.to_ascii_lowercase(); command.contains("game.static_smoke") @@ -2927,7 +2937,7 @@ impl CodexAppServerConnection { None => direct_project_local_message_item( "user", current_prompt, - Some(&format!("direct-codex:{client_turn_id}:user")), + direct_codex_user_item_id_for_client_turn_id(client_turn_id).as_deref(), ) .map_err(platform_llm::LlmError::InvalidRequest)?, }; @@ -3046,13 +3056,30 @@ impl CodexAppServerConnection { }; turn_start_guard.armed = false; let direct_thread_id = direct_thread_id_for_project(history_root); + // 回合边界的阶段时间:Turn 上游只有**秒**级 `startedAt` / `completedAt`,秒级截断 + // 撑不起前端 0.1 秒粒度的展示,也可能让完成时刻落进该轮用户消息的同一秒、落在真实 + // 发送时间之前,被判成无效边界后整轮新回合被吞掉。因此这里只在宿主处理对应阶段时取 + // 毫秒钟(与条目侧"没有原生阶段时间就用宿主钟"同一口径),不再读上游秒字段。 + let direct_turn_started_at_ms = direct_tool_call_now_ms(); + // 本轮开口用户条目的 canonical id:只从已落盘的那条条目上读身份(`id`,工具条目才用 + // `call_id`),不在事件侧重造一份。拿不到就留空,让前端按"归属不可证明"处理。 + let direct_turn_user_item_id = direct_persisted_user_item + .as_ref() + .and_then(direct_thread_item_identity); if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { - append_direct_thread_event(&direct_thread_id, DirectThreadEvent::turn_started()); + append_direct_thread_event( + &direct_thread_id, + DirectThreadEvent::turn_started(direct_turn_started_at_ms) + .with_user_item_id(direct_turn_user_item_id.as_deref()), + ); if let Some(user_item) = direct_persisted_user_item.as_ref() { if let Some(entry_item) = direct_thread_event_item(history_root, user_item) { + // 这里的条目时间可能是启动应答后的观测时间;前端按同一用户条目身份 + // 保留更早的真实发送时间,不用此事件时间覆盖它。 + let user_item_at = entry_item.at(); append_direct_thread_event( &direct_thread_id, - DirectThreadEvent::item_completed(entry_item), + DirectThreadEvent::item_completed(entry_item, user_item_at), ); } } @@ -3191,9 +3218,14 @@ impl CodexAppServerConnection { .map_err(platform_llm::LlmError::InvalidRequest)?; direct_project_history.complete_item(&item); if let Some(entry_item) = entry_item { + // `rawResponseItem/completed` 不带阶段时间,宿主处理到这条 + // 通知的钟就是该阶段唯一可证明的时间。 append_direct_thread_event( &direct_thread_id, - DirectThreadEvent::item_completed(entry_item), + DirectThreadEvent::item_completed( + entry_item, + direct_tool_call_now_ms(), + ), ); } } @@ -3322,9 +3354,19 @@ impl CodexAppServerConnection { if let Some(entry_item) = direct_thread_visible_item(history_root, item) { + // `item/started` 的通知层带 `startedAtMs`:这是工具真正 + // 开始的阶段时间,优先于条目展示时间与宿主钟。 append_direct_thread_event( &direct_thread_id, - DirectThreadEvent::item_started(entry_item), + DirectThreadEvent::item_started( + entry_item, + direct_thread_item_event_at_ms( + ¶ms, + item, + false, + direct_tool_call_now_ms(), + ), + ), ); } } @@ -3367,9 +3409,19 @@ impl CodexAppServerConnection { && matches!(status, "completed" | "interrupted" | "failed") { terminal_recorded = true; + // 终态时间:`durationMs` 与宿主记下的毫秒起点都可靠时才派生, + // 否则取宿主处理这条终态的钟;上游秒级 `completedAt` 一律不用。 append_direct_thread_event( &direct_thread_id, - DirectThreadEvent::turn_completed(status.to_string()), + DirectThreadEvent::turn_completed( + status.to_string(), + direct_thread_turn_completed_at_ms( + turn, + Some(direct_turn_started_at_ms), + direct_tool_call_now_ms(), + ), + ) + .with_user_item_id(direct_turn_user_item_id.as_deref()), ); } match status { @@ -3426,7 +3478,11 @@ impl CodexAppServerConnection { "failed" } .to_string(), - ), + // 这条兜底终态没有对应的 app-server 终态载荷,只能取宿主处理它的钟, + // 不能拿最后一次正文或工具更新时间当回合终点。 + direct_tool_call_now_ms(), + ) + .with_user_item_id(direct_turn_user_item_id.as_deref()), ); } let text = match collect_result { @@ -3624,6 +3680,15 @@ enum DirectCodexTurnCancelTarget { /// 这时显式释放这条守卫并把可读原因返回给界面。释放条件见 /// [`release_stale_direct_taonier_active_invocation`] 的注释;"正在跑的是另一轮"仍然 /// 保持原拒绝语义,什么都不释放。 +/// +/// 兜底终态带 `userItemId`:身份取 `release_stale_direct_taonier_active_invocation` 返回的 +/// clientTurnId(客户端回合身份的唯一来源),与正常路径的开口条目 id 同一份 canonical 口径。 +/// 拿不到 clientTurnId 就留空——这一轮不会再有原生终态,猜一个身份会让前端把边界盖到别人身上。 +fn direct_stale_cancel_turn_completed_event(client_turn_id: &str) -> DirectThreadEvent { + DirectThreadEvent::turn_completed("aborted".to_string(), direct_tool_call_now_ms()) + .with_user_item_id(direct_codex_user_item_id_for_client_turn_id(client_turn_id).as_deref()) +} + pub(crate) fn cancel_direct_codex_turn_at( root: &Path, client_turn_id: Option<&str>, @@ -3680,7 +3745,7 @@ pub(crate) fn cancel_direct_codex_turn_at( // 兜底补一条,否则前端的"最新回合是否在跑"会永远停在运行中。 append_direct_thread_event( &direct_thread_id_for_project(root), - DirectThreadEvent::turn_completed("aborted".to_string()), + direct_stale_cancel_turn_completed_event(&released), ); Ok(DirectTurnCancelView { outcome: DIRECT_TURN_CANCEL_OUTCOME_RELEASED.to_string(), @@ -5006,6 +5071,109 @@ mod tests { )); } + /// 阶段时间取自**通知层**字段,形状照抄 codex-cli 0.147 / 0.155 的 v2 协议 schema: + /// `item/started` 带 `startedAtMs`、`item/completed` 带 `completedAtMs`(毫秒), + /// `turn/completed` 带 `turn.startedAt` / `turn.completedAt`(秒)与 `turn.durationMs`(毫秒)。 + /// 分类函数把 params 原样交给事件级 `at` 的投影函数,所以字段位置必须在这里钉住; + /// 回合边界的秒字段按"不用"锁在这里,避免以后有人再把秒级截断当 0.1 秒精度。 + #[test] + fn direct_lifecycle_stage_times_come_from_notification_params() { + let started = serde_json::json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "startedAtMs": 1_700_000_000_123u64, + "item": {"id": "call-1", "type": "commandExecution", "command": "ls"}, + }); + let completed = serde_json::json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "completedAtMs": 1_700_000_001_500u64, + "item": {"id": "call-1", "type": "commandExecution", "command": "ls"}, + }); + for (method, params, expected_at_ms) in [ + ("item/started", &started, 1_700_000_000_123u64), + ("item/completed", &completed, 1_700_000_001_500u64), + ] { + let Some(CodexTurnEvent::Item { + completed, + params: event_params, + }) = direct_codex_notification_event(method, params, None, None, "turn-1") + else { + panic!("{method} 必须分类成条目生命周期事件"); + }; + let item = event_params.get("item").expect("item payload"); + assert_eq!( + direct_thread_item_event_at_ms(&event_params, item, completed, 9_999), + expected_at_ms, + "{method} 必须用通知层的阶段时间,而不是宿主钟" + ); + } + + let terminal = serde_json::json!({ + "threadId": "thread-1", + "turn": { + "id": "turn-1", + "items": [], + "status": "completed", + "startedAt": 1_700_000_000i64, + "completedAt": 1_700_000_042i64, + }, + }); + let Some(CodexTurnEvent::Terminal(params)) = + direct_codex_notification_event("turn/completed", &terminal, None, None, "turn-1") + else { + panic!("turn/completed 必须分类成终态事件"); + }; + let turn = params.get("turn").unwrap_or(¶ms); + assert_eq!( + direct_thread_turn_completed_at_ms(turn, Some(1_700_000_000_500), 9_999), + 9_999, + "上游只有秒级 completedAt:不采用,取宿主处理终态的毫秒钟" + ); + let with_duration = serde_json::json!({ + "id": "turn-1", + "items": [], + "status": "completed", + "startedAt": 1_700_000_000i64, + "completedAt": 1_700_000_042i64, + "durationMs": 42_500u64, + }); + assert_eq!( + direct_thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999), + 1_700_000_043_000, + "durationMs + 宿主高精度起点才派生结束" + ); + } + + /// 取消兜底终态也要带开口用户条目身份,且身份只有一个来源:release 返回的 clientTurnId + /// 走与正常路径同一份 canonical 口径;拿不到(空 / 空白)就留空,不猜。 + #[test] + fn stale_cancel_terminal_event_keeps_opener_user_item_id_from_client_turn_id() { + let event = direct_stale_cancel_turn_completed_event("turn-0001"); + assert_eq!(event.user_item_id(), Some("direct-codex:turn-0001:user")); + assert!(event.at().is_some(), "兜底终态仍要带宿主观测时间"); + assert!(matches!( + event, + DirectThreadEvent::TurnCompleted { ref status, .. } if status == "aborted" + )); + + for missing in ["", " "] { + let event = direct_stale_cancel_turn_completed_event(missing); + assert_eq!( + event.user_item_id(), + None, + "拿不到 clientTurnId 时不得编造开口条目身份" + ); + } + + // canonical 口径与落盘侧同一份:`direct-codex:{clientTurnId}:user`。 + assert_eq!( + direct_codex_user_item_id_for_client_turn_id(" turn-0001 ").as_deref(), + Some("direct-codex:turn-0001:user") + ); + assert_eq!(direct_codex_user_item_id_for_client_turn_id(""), None); + } + fn test_llm() -> GameCreatorLlmConfig { GameCreatorLlmConfig { custom_enabled: false, @@ -6740,12 +6908,33 @@ done let consumed = crate::agent::consume_direct_thread(&bootstrap.subscription_id) .expect("consume events"); + // 回合起止必须与开口用户条目同源:前端在「只有锚点 + 历史、运行态为空」的回合里靠这个 + // 身份把边界认领给同一条用户条目,缺了它就只能隐藏未知用时。 + let lifecycle_user_item_ids = consumed + .events + .iter() + .filter(|event| { + matches!( + event, + DirectThreadEvent::TurnStarted { .. } | DirectThreadEvent::TurnCompleted { .. } + ) + }) + .map(DirectThreadEvent::user_item_id) + .collect::>(); + assert_eq!( + lifecycle_user_item_ids, + vec![ + Some("direct-codex:turn-0001:user"), + Some("direct-codex:turn-0001:user"), + ], + "turn.started / turn.completed 都要带本轮开口用户条目的 canonical itemId" + ); let mut user_items = Vec::new(); let mut assistant_items = Vec::new(); for event in &consumed.events { let item = match event { - DirectThreadEvent::ItemStarted { item } - | DirectThreadEvent::ItemCompleted { item } => item, + DirectThreadEvent::ItemStarted { item, .. } + | DirectThreadEvent::ItemCompleted { item, .. } => item, _ => continue, }; match item { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs index 863b8cdad..79e3aedd1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs @@ -5,18 +5,10 @@ use std::process::Stdio; use sha2::{Digest, Sha256}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; +#[path = "../../build_support/codex_bundle.rs"] +mod codex_bundle; + const GAME_CREATOR_CODEX_CLI_EXECUTABLE: &str = "codex"; -const GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH: &str = "coding-agent/win-x64/bin/codex.exe"; -const GAME_CREATOR_BUNDLED_CODEX_CLI_MANIFEST_RELATIVE_PATH: &str = - "coding-agent/win-x64/manifest.json"; -const GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES: [&str; 6] = [ - "bin/codex.exe", - "bin/codex-code-mode-host.exe", - "codex-path/rg.exe", - "codex-resources/codex-command-runner.exe", - "codex-resources/codex-windows-sandbox-setup.exe", - "codex-package.json", -]; const GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024; const GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES: usize = 4 * 1024 * 1024; const GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES: usize = 256 * 1024; @@ -38,11 +30,11 @@ fn game_creator_codex_cli_executable_candidates_for( path: Option<&std::ffi::OsStr>, ) -> Vec { let mut candidates = Vec::new(); + if let Some(bundled) = game_creator_bundled_codex_cli_path(resource_dir) { + candidates.push(bundled); + } #[cfg(windows)] { - if let Some(resource_dir) = resource_dir { - candidates.push(resource_dir.join(GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH)); - } fn append_native_npm_candidates(candidates: &mut Vec, npm_root: &Path) { let vendor_root = npm_root .join("node_modules") @@ -109,52 +101,71 @@ fn game_creator_codex_cli_executable_candidates() -> Vec { } fn game_creator_bundled_resource_dir() -> Option { + let executable = std::env::current_exe().ok()?; + game_creator_bundled_resource_dir_for(&executable) +} + +fn game_creator_bundled_resource_dir_for(executable: &Path) -> Option { #[cfg(windows)] { - std::env::current_exe() - .ok() - .and_then(|path| path.parent().map(Path::to_path_buf)) + executable.parent().map(Path::to_path_buf) } - #[cfg(not(windows))] + #[cfg(target_os = "macos")] { + let macos = executable.parent()?; + let contents = macos.parent()?; + // 只接受真正的 app bundle 结构,开发态不从任意相邻目录加载程序。 + if macos.file_name()? != "MacOS" + || contents.file_name()? != "Contents" + || contents.parent()?.extension()? != "app" + { + return None; + } + Some(contents.join("Resources")) + } + #[cfg(not(any(windows, target_os = "macos")))] + { + let _ = executable; None } } fn game_creator_bundled_codex_cli_path(resource_dir: Option<&Path>) -> Option { - resource_dir.map(|resource_dir| resource_dir.join(GAME_CREATOR_BUNDLED_CODEX_CLI_RELATIVE_PATH)) + let layout = codex_bundle::for_target(env!("AGC_BUILD_TARGET"))?; + Some( + resource_dir? + .join("coding-agent") + .join(layout.directory) + .join(layout.executable), + ) } fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result { + let layout = codex_bundle::for_target(env!("AGC_BUILD_TARGET")) + .ok_or_else(|| "当前平台不支持内置 Codex CLI".to_string())?; let bundle_root = executable .parent() .and_then(Path::parent) .ok_or_else(|| "内置 Codex CLI 路径无效".to_string())?; - let manifest_path = bundle_root.join( - Path::new(GAME_CREATOR_BUNDLED_CODEX_CLI_MANIFEST_RELATIVE_PATH) - .file_name() - .expect("bundled Codex manifest file name"), - ); + let manifest_path = bundle_root.join("manifest.json"); let manifest = std::fs::read_to_string(&manifest_path) .map_err(|_| "内置 Codex CLI 缺少完整性清单".to_string()) .and_then(|value| { serde_json::from_str::(&value) .map_err(|_| "内置 Codex CLI 完整性清单无效".to_string()) })?; - if manifest.schema_version != "genarrative-codex-sidecar.v2" - || manifest.platform != "win32-x64" + if manifest.schema_version != codex_bundle::SCHEMA + || manifest.platform != layout.platform || manifest.version.trim().is_empty() - || GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES - .iter() - .any(|relative| { - manifest.files.get(*relative).map_or(true, |hash| { - hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) - }) + || layout.files.iter().any(|relative| { + manifest.files.get(*relative).map_or(true, |hash| { + hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) }) + }) { return Err("内置 Codex CLI 完整性清单不受支持".to_string()); } - for relative in GAME_CREATOR_BUNDLED_CODEX_CLI_REQUIRED_FILES { + for relative in layout.files { let path = bundle_root.join(relative); let bytes = std::fs::read(&path).map_err(|_| { format!( @@ -163,7 +174,7 @@ fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result "game-background", + GameCreationAppAssetKind::IconSpritesheet => "art-spritesheet", + kind => kind.as_str(), + }; + let references_match_contract = + crate::agent::platform_art_runtime_references_match_request_contract( + &identity.reference_resource_ids, + reference_contract_kind, + ); let lineage_matches = match expected_reference_source { Some(source) => direct_taonier_reference_matches_local_source(root, source, &identity), - None => identity.reference_resource_ids.is_empty(), + None => true, }; - lineage_matches.then_some(identity) + (references_match_contract && lineage_matches).then_some(identity) }) } @@ -2598,7 +2610,9 @@ fn direct_taonier_reference_matches_local_source( source: &DirectTaonierArtAssetIdentity, derived: &DirectTaonierArtAssetIdentity, ) -> bool { - let [remote_reference_id] = derived.reference_resource_ids.as_slice() else { + // 派生素材的规范身份只由参考序列首项承担:用户参考按顺序追加在规范图之后, + // 不能让它们顶替或淹没规范引用,也不能因为多出用户参考就判定派生关系不成立。 + let Some(remote_reference_id) = derived.reference_resource_ids.first() else { return false; }; if derived.canvas_project_id == source.canvas_project_id @@ -3419,6 +3433,8 @@ async fn generate_direct_taonier_art_asset_at( .then(|| "connected-components".to_string()), grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, screen_color: None, }; let runtime_context = @@ -6026,6 +6042,13 @@ mod tests { assert!( prompt.contains("完整新游戏或根据策划案实现时必须执行 agc-game-production-workflow") ); + // Canvas 居中责任唯一的合同必须真的进到实际 system prompt:Phaser autoCenter 与 + // CSS 居中二选一,且要求实测中心误差与构建 dist 复验,避免再次出现居中偏移。 + assert!(prompt.contains("Phaser 画布居中责任唯一")); + assert!(prompt.contains("不得在同一个 canvas 父容器上叠加")); + assert!(prompt.contains("必须把 Phaser autoCenter 设为 NO_CENTER")); + assert!(prompt.contains("不得用修改 AGC iframe 偏移来掩盖")); + assert!(prompt.contains("中心误差不超过 1 CSS px")); } #[test] @@ -9849,6 +9872,78 @@ mod tests { ); } + #[test] + fn direct_taonier_art_package_accepts_manifest_user_references() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-art-references", "直连美术参考") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + assert!(direct_taonier_art_package_is_valid(root.path())); + + // 规范图与背景图带用户参考:参考只是风格输入,规范身份仍由参考序列首项承担。 + mutate_manifest_at(root.path(), |manifest| { + let art_spec = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == DIRECT_CODEX_ART_SPEC_ASSET_PATH) + .expect("art spec asset"); + art_spec.source.reference_resource_ids = vec![ + "user-reference-1".to_string(), + "user-reference-2".to_string(), + ]; + let background = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == DIRECT_CODEX_BACKGROUND_ASSET_PATH) + .expect("background asset"); + background + .source + .reference_resource_ids + .push("user-reference-1".to_string()); + Ok(()) + }) + .expect("apply user references to the art base"); + assert!( + direct_taonier_art_package_is_valid(root.path()), + "user references must not invalidate the art package" + ); + + // 图集仍只接受唯一规范引用:多一项用户参考必须失败关闭。 + mutate_manifest_at(root.path(), |manifest| { + let spritesheet = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == DIRECT_CODEX_SPRITESHEET_ASSET_PATH) + .expect("spritesheet asset"); + spritesheet + .source + .reference_resource_ids + .push("user-reference-1".to_string()); + Ok(()) + }) + .expect("add an extra spritesheet reference"); + assert!( + !direct_taonier_art_package_is_valid(root.path()), + "art spritesheet must reject extra user references" + ); + + // 用户参考不能顶替图集的规范前置。 + mutate_manifest_at(root.path(), |manifest| { + let spritesheet = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == DIRECT_CODEX_SPRITESHEET_ASSET_PATH) + .expect("spritesheet asset"); + spritesheet.source.reference_resource_ids = vec!["user-reference-1".to_string()]; + Ok(()) + }) + .expect("replace the spritesheet canonical reference"); + assert!( + !direct_taonier_art_package_is_valid(root.path()), + "a user reference must not replace the art spritesheet canonical spec" + ); + } + #[test] fn direct_output_sync_accepts_a_complete_spritesheet_without_slices() { let root = tempfile::tempdir().expect("temp dir"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index a30816d82..ee3cfcd41 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -427,12 +427,16 @@ mod tests { } } + /// 事件级阶段时间只在重放稳定性用例里逐个指定;其余用例用一个固定值即可, + /// 它们断言的是队列 / 游标语义,不是时间本身。 + const FIXED_AT_MS: u64 = 1_000; + fn item_started(item_id: &str) -> DirectThreadEvent { - DirectThreadEvent::item_started(message(item_id)) + DirectThreadEvent::item_started(message(item_id), FIXED_AT_MS) } fn item_completed(item_id: &str) -> DirectThreadEvent { - DirectThreadEvent::item_completed(message(item_id)) + DirectThreadEvent::item_completed(message(item_id), FIXED_AT_MS) } fn item_delta(item_id: &str) -> DirectThreadEvent { @@ -450,7 +454,7 @@ mod tests { #[test] fn subscribers_have_independent_cursors_on_one_global_queue() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", DirectThreadEvent::turn_started()); + manager.append("thread-1", DirectThreadEvent::turn_started(FIXED_AT_MS)); let first = manager.subscribe("thread-1"); let second = manager.subscribe("thread-1"); manager.append("thread-1", item_started("item-1")); @@ -474,7 +478,7 @@ mod tests { #[test] fn bootstrap_contains_lifecycle_anchor_and_unfinished_events_only() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", DirectThreadEvent::turn_started()); + manager.append("thread-1", DirectThreadEvent::turn_started(FIXED_AT_MS)); manager.append("thread-1", item_started("item-1")); manager.append("thread-1", item_delta("item-1")); manager.append("thread-1", item_completed("item-1")); @@ -484,7 +488,7 @@ mod tests { assert!(matches!( bootstrap.events.as_slice(), [ - DirectThreadEvent::TurnStarted {}, + DirectThreadEvent::TurnStarted { .. }, DirectThreadEvent::ItemStarted { item, .. }, ] if item.item_id() == "item-2" )); @@ -589,15 +593,118 @@ mod tests { let mut manager = DirectThreadManager::with_limits(100, 100_000); manager.append( "thread-1", - DirectThreadEvent::turn_completed("completed".to_string()), + DirectThreadEvent::turn_completed("completed".to_string(), FIXED_AT_MS), ); let bootstrap = manager.subscribe("thread-1"); assert!(matches!( bootstrap.events.as_slice(), - [DirectThreadEvent::TurnCompleted { status }] if status == "completed" + [DirectThreadEvent::TurnCompleted { status, at, .. }] + if status == "completed" && *at == Some(FIXED_AT_MS) )); } + /// 阶段时间必须随事件一起进队列:bootstrap 与重复订阅都拿到**原值**, + /// 重放不得重新取钟(否则每次重连都会把已固定的起止时间改掉)。 + #[test] + fn replayed_events_keep_their_original_stage_time() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + manager.append("thread-1", DirectThreadEvent::turn_started(1_000)); + manager.append( + "thread-1", + DirectThreadEvent::item_started(message("item-1"), 2_000), + ); + + let first = manager.subscribe("thread-1"); + assert_eq!( + first + .events + .iter() + .map(DirectThreadEvent::at) + .collect::>(), + vec![Some(1_000), Some(2_000)] + ); + + // 第二个订阅看到的是同一份事件,时间不因"又取了一次当前时间"而漂移。 + let second = manager.subscribe("thread-1"); + assert_eq!(second.events, first.events); + + manager.append( + "thread-1", + DirectThreadEvent::item_completed(message("item-1"), 3_000), + ); + let completion = manager + .consume(&first.subscription_id) + .expect("consume completion") + .events; + assert_eq!( + completion + .iter() + .map(DirectThreadEvent::at) + .collect::>(), + vec![Some(3_000)] + ); + // 重复消费不产生新事件,也不改写已下发过的时间。 + assert!(manager + .consume(&first.subscription_id) + .expect("empty consume") + .events + .is_empty()); + assert_eq!( + completion + .iter() + .map(DirectThreadEvent::at) + .collect::>(), + vec![Some(3_000)] + ); + } + + /// 生命周期锚点重放时必须带上开口用户条目身份:前端在「只有锚点 + 历史切片、运行态一直空」 + /// 的回合里也要能把边界认领给同一条用户条目,而不是按时间戳猜。 + #[test] + fn bootstrap_replays_opener_user_item_id() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + manager.append( + "thread-1", + DirectThreadEvent::turn_started(1_000) + .with_user_item_id(Some("direct-codex:turn-1:user")), + ); + let bootstrap = manager.subscribe("thread-1"); + assert_eq!( + bootstrap + .events + .iter() + .map(DirectThreadEvent::user_item_id) + .collect::>(), + vec![Some("direct-codex:turn-1:user")] + ); + assert_eq!(bootstrap.events[0].at(), Some(1_000)); + + // 锚点是独立保存的副本:队列里那条事件被回收之后,新订阅仍拿到同一个身份。 + manager + .consume(&bootstrap.subscription_id) + .expect("consume anchor"); + manager.append( + "thread-1", + DirectThreadEvent::item_completed(message("item-1"), 2_000), + ); + manager.append( + "thread-1", + DirectThreadEvent::turn_completed("completed".to_string(), 3_000) + .with_user_item_id(Some("direct-codex:turn-1:user")), + ); + let second = manager.subscribe("thread-1"); + assert_eq!( + second + .events + .iter() + .map(DirectThreadEvent::user_item_id) + .collect::>(), + vec![Some("direct-codex:turn-1:user")], + "起止同源:终态锚点也带同一个开口用户条目身份" + ); + assert_eq!(second.events[0].at(), Some(3_000)); + } + #[test] fn queue_cleanup_only_removes_a_cleanable_prefix() { let mut manager = DirectThreadManager::with_limits(100, 100_000); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs index f6b781218..d5a874c76 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs @@ -157,6 +157,23 @@ impl DirectThreadItem { | Self::Other { item_id, .. } => item_id, } } + + /// 条目展示时间(毫秒)。只用于条目自身的展示,不能当工具的开始 / 完成边界; + /// 那两类边界用事件级 `at`。 + pub(crate) fn at(&self) -> u64 { + match self { + Self::Message { at, .. } + | Self::Reasoning { at, .. } + | Self::FunctionCall { at, .. } + | Self::FunctionCallOutput { at, .. } + | Self::CommandExecution { at, .. } + | Self::FileChange { at, .. } + | Self::McpToolCall { at, .. } + | Self::WebSearch { at, .. } + | Self::ContextCompaction { at, .. } + | Self::Other { at, .. } => *at, + } + } } /// 增量正文属于哪类条目。 @@ -201,18 +218,64 @@ impl DirectThreadRequestKind { /// /// 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,"当前回合是否还在跑"由 /// 生命周期事件在序列中的位置给出,`turn_id` 对前端没有任何额外信息。 +/// +/// 四种生命周期事件(`turn.started` / `turn.completed` / `item.started` / `item.completed`) +/// 额外带事件级 `at`:它是**该阶段本身**的发生时间(毫秒),不是条目展示时间。条目上的 +/// `item.at` 只说明"这条条目什么时候被看到",工具计时不得拿它当开始或完成边界。 +/// 条目阶段优先用通知层的毫秒字段(`startedAtMs` / `completedAtMs`),缺失才用宿主钟; +/// 回合阶段没有可用的毫秒上游字段(Turn 只有秒级 `startedAt` / `completedAt`),一律用宿主 +/// 在该阶段取的毫秒钟——见 `direct_thread_turn_completed_at_ms` 的说明。 +/// `at` 在事件进入 Thread Manager 时就固定:重放(bootstrap / consume)必须沿用原值, +/// 不能在前端收到或重放时重新取当前时间。 +/// +/// `turn.started` / `turn.completed` 额外带可选的 `userItemId`:本轮开口用户条目的 **canonical +/// itemId**(与同轮那条用户条目事件同源,由原生从已落盘条目上读取,不另造身份)。回合事件本身 +/// 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点 +/// + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明 +/// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] #[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] pub(crate) enum DirectThreadEvent { #[serde(rename = "turn.started")] - TurnStarted, + TurnStarted { + /// 本轮开始的阶段时间(毫秒):宿主处理 `turn/start` 的毫秒钟。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + /// 本轮开口用户条目的 canonical itemId;缺失表示身份不可证明。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + user_item_id: Option, + }, #[serde(rename = "turn.completed")] - TurnCompleted { status: String }, + TurnCompleted { + status: String, + /// 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + /// 本轮开口用户条目的 canonical itemId:与同一轮的 `turn.started` 同源;缺失表示不可证明。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + user_item_id: Option, + }, #[serde(rename = "item.started")] - ItemStarted { item: DirectThreadItem }, + ItemStarted { + item: DirectThreadItem, + /// 条目开始执行的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + }, #[serde(rename = "item.completed")] - ItemCompleted { item: DirectThreadItem }, + ItemCompleted { + item: DirectThreadItem, + /// 条目结束的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + }, #[serde(rename = "item.delta")] ItemDelta { item_id: String, @@ -228,20 +291,59 @@ pub(crate) enum DirectThreadEvent { } impl DirectThreadEvent { - pub(crate) fn turn_started() -> Self { - Self::TurnStarted + pub(crate) fn turn_started(at: u64) -> Self { + Self::TurnStarted { + at: Some(at), + user_item_id: None, + } } - pub(crate) fn turn_completed(status: String) -> Self { - Self::TurnCompleted { status } + pub(crate) fn turn_completed(status: String, at: u64) -> Self { + Self::TurnCompleted { + status, + at: Some(at), + user_item_id: None, + } } - pub(crate) fn item_started(item: DirectThreadItem) -> Self { - Self::ItemStarted { item } + /// 附上本轮开口用户条目的 canonical itemId。 + /// + /// 只在构造之后补一次身份,避免 `turn.started` / `turn.completed` 的既有调用点(含各处兜底 + /// 终态)全部改签名。空串按缺失处理:宁可让前端隐藏未知用时,也不写一个假身份。 + pub(crate) fn with_user_item_id(self, user_item_id: Option<&str>) -> Self { + let user_item_id = user_item_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + match self { + Self::TurnStarted { at, .. } => Self::TurnStarted { at, user_item_id }, + Self::TurnCompleted { status, at, .. } => Self::TurnCompleted { + status, + at, + user_item_id, + }, + other => other, + } } - pub(crate) fn item_completed(item: DirectThreadItem) -> Self { - Self::ItemCompleted { item } + /// 本轮开口用户条目的 canonical itemId:只有生命周期事件有,其余返回 `None`。 + /// + /// 只读已存入事件的值,不在读取时重算——重放要用的就是原事件的身份。 + pub(crate) fn user_item_id(&self) -> Option<&str> { + match self { + Self::TurnStarted { user_item_id, .. } | Self::TurnCompleted { user_item_id, .. } => { + user_item_id.as_deref() + } + _ => None, + } + } + + pub(crate) fn item_started(item: DirectThreadItem, at: u64) -> Self { + Self::ItemStarted { item, at: Some(at) } + } + + pub(crate) fn item_completed(item: DirectThreadItem, at: u64) -> Self { + Self::ItemCompleted { item, at: Some(at) } } pub(crate) fn item_delta(item_id: String, kind: DirectThreadDeltaKind, delta: String) -> Self { @@ -256,6 +358,19 @@ impl DirectThreadEvent { Self::Request { kind, request_id } } + /// 事件级阶段时间(毫秒):只有四种生命周期事件有,其余事件返回 `None`。 + /// + /// 只读已存入事件的值,不在读取时取钟——重放要用的就是原事件的时间。 + pub(crate) fn at(&self) -> Option { + match self { + Self::TurnStarted { at, .. } + | Self::TurnCompleted { at, .. } + | Self::ItemStarted { at, .. } + | Self::ItemCompleted { at, .. } => *at, + Self::ItemDelta { .. } | Self::Request { .. } => None, + } + } + /// 事件关联的条目身份:只有 item 事件有。 pub(crate) fn item_id(&self) -> Option<&str> { match self { @@ -392,6 +507,63 @@ fn item_at_ms(item: &Value, observed_at_ms: u64) -> u64 { observed_at_ms } +/// 原生毫秒时间戳:0(协议里的"缺省")与非法值一样按缺失处理。 +fn json_ms(container: &Value, key: &str) -> Option { + container + .get(key) + .and_then(Value::as_u64) + .filter(|value| *value > 0) +} + +/// `item/started` / `item/completed` 的事件级阶段时间(毫秒)。 +/// +/// 字段位置按当前 app-server 协议:通知层带 `params.startedAtMs` / `params.completedAtMs`, +/// 条目自带时用条目里的同名毫秒字段(`direct_tool_calls` 读的是同一处)。完成事件即使同时 +/// 带着开始字段也只取**完成**时间;两者都没有、但 `durationMs` 有可靠起点时按 +/// 起点 + 时长派生结束。都没有就用宿主处理该事件的钟——原生缺阶段时间时这是唯一诚实的值。 +pub(crate) fn direct_thread_item_event_at_ms( + params: &Value, + item: &Value, + completed: bool, + observed_at_ms: u64, +) -> u64 { + let started_ms = || json_ms(params, "startedAtMs").or_else(|| json_ms(item, "startedAtMs")); + if !completed { + return started_ms().unwrap_or(observed_at_ms); + } + if let Some(at) = json_ms(params, "completedAtMs").or_else(|| json_ms(item, "completedAtMs")) { + return at; + } + let duration_ms = json_ms(params, "durationMs").or_else(|| json_ms(item, "durationMs")); + match (started_ms(), duration_ms) { + (Some(started), Some(duration)) => started.saturating_add(duration), + _ => observed_at_ms, + } +} + +/// `turn.completed` 的事件级阶段时间(毫秒)。 +/// +/// Turn 里的 `startedAt` / `completedAt` 是 Unix **秒**(协议 `format: int64`,字段名不带 +/// `Ms` 的都是秒),而 `durationMs` 才是毫秒。秒级截断在这里是不能用的:它既撑不起前端 +/// 0.1 秒粒度的展示(显示出来的小数位是假精度),也可能让"完成时刻"落进该轮用户消息所在的 +/// 同一秒、落在用户真实发送时间之前,前端按"结束早于开始"判成无效边界,于是一轮新回合被 +/// 整轮吞掉。因此这里不采用任何秒字段: +/// - 只有 `durationMs` 与**高精度起点**都可靠时才按 `起点 + 时长` 派生结束; +/// - 否则取宿主处理终态的钟,语义与条目侧"没有原生阶段时间就用宿主钟"完全一致。 +/// +/// `high_precision_started_at_ms` 是本轮开始时宿主记下的那个毫秒起点(即 `turn.started` +/// 事件写入的同一个值),不是从上游秒字段换算出来的,`None` 表示起点也不可证明。 +pub(crate) fn direct_thread_turn_completed_at_ms( + turn: &Value, + high_precision_started_at_ms: Option, + observed_at_ms: u64, +) -> u64 { + match (high_precision_started_at_ms, json_ms(turn, "durationMs")) { + (Some(started), Some(duration)) => started.saturating_add(duration), + _ => observed_at_ms, + } +} + /// 归一身份:工具条目用工具调用 id,其它条目用自己的 `id`;只产出这一个值。 pub(crate) fn direct_thread_item_identity(item: &Value) -> Option { let call_id = item @@ -869,4 +1041,314 @@ mod tests { DirectThreadItem::Other { ref raw_type, .. } if raw_type == "plan" )); } + + /// 事件级 `at` 与条目展示时间 `item.at` 是两件事:前者是本阶段的真实边界, + /// 后者只说明条目什么时候被看到。 + #[test] + fn event_stage_time_is_independent_from_item_display_time() { + let params = json!({ + "completedAtMs": 2_000u64, + "item": { + "id": "call-1", + "type": "commandExecution", + "command": "ls", + "startedAtMs": 1_000u64, + }, + }); + let item = direct_thread_item_from_value(root(), ¶ms["item"], 7_777).expect("item"); + // 条目展示时间不受事件级时间影响,仍按条目自己的字段推导。 + assert_eq!(item.at(), 1_000); + assert_eq!( + direct_thread_item_event_at_ms(¶ms, ¶ms["item"], true, 7_777), + 2_000 + ); + } + + #[test] + fn item_started_event_at_uses_notification_stage_time() { + // 通知层 `startedAtMs` 优先于条目自带的同名字段。 + let params = json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "startedAtMs": 1_700_000_000_123u64, + "item": { + "id": "call-1", + "type": "commandExecution", + "startedAtMs": 1_700_000_000_000u64, + }, + }); + assert_eq!( + direct_thread_item_event_at_ms(¶ms, ¶ms["item"], false, 9_999), + 1_700_000_000_123 + ); + } + + #[test] + fn item_event_at_falls_back_to_nested_item_then_host_clock() { + let nested = json!({ + "item": { + "id": "call-1", + "type": "commandExecution", + "startedAtMs": 1_700_000_000_500u64, + }, + }); + assert_eq!( + direct_thread_item_event_at_ms(&nested, &nested["item"], false, 9_999), + 1_700_000_000_500 + ); + + // 原生没有任何阶段时间:用宿主处理这条事件的钟,不编造。 + let bare = json!({"item": {"id": "call-1", "type": "commandExecution"}}); + assert_eq!( + direct_thread_item_event_at_ms(&bare, &bare["item"], false, 9_999), + 9_999 + ); + assert_eq!( + direct_thread_item_event_at_ms(&bare, &bare["item"], true, 9_999), + 9_999 + ); + } + + #[test] + fn item_completed_event_at_prefers_completion_over_start() { + // 通知层两个字段都在时必须取完成时间,不能退回开始时间。 + let params = json!({ + "startedAtMs": 1_000u64, + "completedAtMs": 2_000u64, + "durationMs": 1_000u64, + "item": {"id": "call-1", "type": "commandExecution"}, + }); + assert_eq!( + direct_thread_item_event_at_ms(¶ms, ¶ms["item"], true, 9_999), + 2_000 + ); + + // 完成时间只在条目里:同样取完成时间。 + let nested = json!({ + "item": { + "id": "call-1", + "type": "commandExecution", + "startedAtMs": 1_000u64, + "completedAtMs": 2_500u64, + }, + }); + assert_eq!( + direct_thread_item_event_at_ms(&nested, &nested["item"], true, 9_999), + 2_500 + ); + } + + #[test] + fn item_completed_event_at_derives_end_only_with_reliable_start() { + let with_start = json!({ + "item": {"id": "call-1", "type": "commandExecution", "startedAtMs": 1_000u64, "durationMs": 250u64}, + }); + assert_eq!( + direct_thread_item_event_at_ms(&with_start, &with_start["item"], true, 9_999), + 1_250 + ); + + // 只有时长不足以证明结束时刻:回落到宿主钟。 + let duration_only = json!({ + "item": {"id": "call-1", "type": "commandExecution", "durationMs": 250u64}, + }); + assert_eq!( + direct_thread_item_event_at_ms(&duration_only, &duration_only["item"], true, 9_999), + 9_999 + ); + } + + /// Turn 上游的 `startedAt` / `completedAt` 是**秒**级:既支撑不了 0.1 秒粒度的展示, + /// 也可能让完成时刻落进该轮用户消息的同一秒、被判成无效边界后吞掉整轮新回合。 + /// 因此秒字段一律不采用,回合边界回落到宿主处理该阶段时的毫秒钟。 + #[test] + fn turn_completed_at_ignores_second_truncated_upstream_fields() { + let seconds_only = json!({ + "id": "turn-1", + "status": "completed", + "startedAt": 1_700_000_000i64, + "completedAt": 1_700_000_042i64, + }); + assert_eq!( + direct_thread_turn_completed_at_ms(&seconds_only, Some(1_700_000_000_500), 9_999), + 9_999, + "没有 durationMs 时用宿主钟,不换算秒字段" + ); + assert_eq!( + direct_thread_turn_completed_at_ms(&seconds_only, None, 9_999), + 9_999 + ); + } + + #[test] + fn turn_completed_at_derives_end_only_from_duration_and_high_precision_start() { + let with_duration = json!({ + "id": "turn-1", + "status": "completed", + "startedAt": 1_700_000_000i64, + "completedAt": 1_700_000_042i64, + "durationMs": 42_500u64, + }); + // 高精度起点(宿主在本轮开始时记下的毫秒值)+ 上游 durationMs:结束严格晚于起点。 + assert_eq!( + direct_thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999), + 1_700_000_043_000 + ); + // 起点不可证明时不派生。 + assert_eq!( + direct_thread_turn_completed_at_ms(&with_duration, None, 9_999), + 9_999 + ); + // 时长为 0 同样按缺失处理。 + let zero_duration = json!({"durationMs": 0u64}); + assert_eq!( + direct_thread_turn_completed_at_ms(&zero_duration, Some(1_000), 9_999), + 9_999 + ); + } + + /// 线上形状:四种生命周期事件带事件级 `at`(number),历史 / 无时间夹具缺该字段时 + /// 反序列化仍成立,且不会序列化出 `at: null`。 + #[test] + fn lifecycle_events_serialize_event_level_at_as_optional_number() { + let started = serde_json::to_value(DirectThreadEvent::turn_started(1_700_000_000_123)) + .expect("serialize turn.started"); + assert_eq!( + started, + json!({"type": "turn.started", "at": 1_700_000_000_123u64}) + ); + assert_eq!( + serde_json::from_value::(started).expect("round trip"), + DirectThreadEvent::turn_started(1_700_000_000_123) + ); + + let completed = serde_json::to_value(DirectThreadEvent::turn_completed( + "completed".to_string(), + 2_000, + )) + .expect("serialize turn.completed"); + assert_eq!( + completed, + json!({"type": "turn.completed", "status": "completed", "at": 2_000u64}) + ); + + let item = DirectThreadItem::CommandExecution { + item_id: "call-1".to_string(), + command: "ls".to_string(), + output: None, + status: Some("completed".to_string()), + exit_code: None, + at: 1_500, + }; + let item_started = + serde_json::to_value(DirectThreadEvent::item_started(item.clone(), 1_000)) + .expect("serialize item.started"); + assert_eq!(item_started["at"], json!(1_000u64)); + // 事件级 `at` 不动条目自己的展示时间。 + assert_eq!(item_started["item"]["at"], json!(1_500u64)); + let item_completed = serde_json::to_value(DirectThreadEvent::item_completed(item, 2_000)) + .expect("serialize item.completed"); + assert_eq!(item_completed["at"], json!(2_000u64)); + + // 历史 / 夹具里的旧事件没有 `at`:反序列化成 `None`,回写时不补 `null`。 + let legacy: DirectThreadEvent = serde_json::from_value(json!({"type": "turn.started"})) + .expect("legacy turn.started without at"); + assert_eq!( + legacy, + DirectThreadEvent::TurnStarted { + at: None, + user_item_id: None, + } + ); + assert_eq!(legacy.at(), None); + assert_eq!(legacy.user_item_id(), None); + assert_eq!( + serde_json::to_value(legacy).expect("serialize legacy"), + json!({"type": "turn.started"}) + ); + assert_eq!( + serde_json::to_value(DirectThreadEvent::request( + DirectThreadRequestKind::RequestResolved, + None, + )) + .expect("serialize request"), + json!({"type": "request", "kind": "request.resolved", "requestId": null}) + ); + } + + /// 回合生命周期事件带可选的开口用户条目身份:线上是 `userItemId`(camelCase 的可选 string), + /// 缺省不写字段,旧事件反序列化仍是 `None`,空白身份按缺失处理(不猜)。 + #[test] + fn lifecycle_events_carry_optional_opener_user_item_id() { + let started = DirectThreadEvent::turn_started(1_000) + .with_user_item_id(Some("direct-codex:turn-1:user")); + assert_eq!(started.user_item_id(), Some("direct-codex:turn-1:user")); + assert_eq!( + serde_json::to_value(&started).expect("serialize turn.started"), + json!({ + "type": "turn.started", + "at": 1_000u64, + "userItemId": "direct-codex:turn-1:user", + }) + ); + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&started).expect("serialize") + ) + .expect("round trip"), + started + ); + + let completed = DirectThreadEvent::turn_completed("interrupted".to_string(), 2_000) + .with_user_item_id(Some("direct-codex:turn-1:user")); + assert_eq!(completed.user_item_id(), Some("direct-codex:turn-1:user")); + assert_eq!( + serde_json::to_value(&completed).expect("serialize turn.completed"), + json!({ + "type": "turn.completed", + "status": "interrupted", + "at": 2_000u64, + "userItemId": "direct-codex:turn-1:user", + }) + ); + // 起止同源:同一轮的两条边界带同一个身份。 + assert_eq!(started.user_item_id(), completed.user_item_id()); + + // 空白 / 空串按缺失处理:不能把 "" 当成一条用户条目的身份发下去。 + for empty in ["", " "] { + let event = DirectThreadEvent::turn_started(1_000).with_user_item_id(Some(empty)); + assert_eq!(event.user_item_id(), None); + assert_eq!( + serde_json::to_value(&event).expect("serialize"), + json!({"type": "turn.started", "at": 1_000u64}) + ); + } + + // 旧事件(没有 `userItemId`)反序列化成 `None`,回写不补 `null`。 + let legacy: DirectThreadEvent = serde_json::from_value(json!({ + "type": "turn.completed", + "status": "completed", + "at": 3_000u64, + })) + .expect("legacy turn.completed without userItemId"); + assert_eq!(legacy.user_item_id(), None); + assert_eq!( + serde_json::to_value(legacy).expect("serialize legacy"), + json!({"type": "turn.completed", "status": "completed", "at": 3_000u64}) + ); + + // 条目事件没有这个字段:身份只在生命周期事件上。 + let item_event = DirectThreadEvent::item_completed( + DirectThreadItem::CommandExecution { + item_id: "call-1".to_string(), + command: "ls".to_string(), + output: None, + status: None, + exit_code: None, + at: 1_500, + }, + 1_600, + ); + assert_eq!(item_event.user_item_id(), None); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index a5ad7dfeb..d422a439c 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 @@ -662,6 +662,19 @@ impl DirectToolBridgeState { ); Ok((operation_id, idempotency_key)) } + + /// 不计费的派生请求(如抠图)不进 `resource_request_ids` 计数,只取回合身份做确定性 id 派生。 + fn active_resource_turn_id(&self) -> Result { + let authorization = self + .turn_authorization + .lock() + .map_err(|_| "AGC 工具桥回合授权状态不可用".to_string())?; + authorization + .active + .as_ref() + .map(|active| active.turn_id.clone()) + .ok_or_else(|| "当前没有客户端签发的资源生成回合身份".to_string()) + } } fn direct_resource_request_uuid(turn_id: &str, domain: &str, request_fingerprint: &str) -> String { @@ -2021,7 +2034,12 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val }) .await? } else { - let (operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?; + // 抠图不计费且服务端秒级完成,不占每回合付费媒体请求的四项额度,不进计数 map; + // id 仍按回合身份与请求指纹确定性派生,同指纹重试与 pending 对账语义不变。 + let turn_id = state.active_resource_turn_id()?; + let operation_id = direct_resource_request_uuid(&turn_id, "operation", &fingerprint); + let idempotency_key = + direct_resource_request_uuid(&turn_id, "idempotency", &fingerprint); let revision = read_game_creator_agent_runtime_project_revision(&state.root)?.revision; let request = DeriveLocalProjectResourceInput { project_path: state.root.to_string_lossy().into_owned(), @@ -2416,6 +2434,8 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) slice_mode, grid_x, grid_y, + reference_asset_ids: Vec::new(), + target_category: None, screen_color, }; let _generation_guard = state.image_generation_gate.lock().await; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index e52bbcfea..4bad4708e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -67,10 +67,11 @@ pub(crate) use canvas_generation::{ generate_platform_art_asset_with_options_at, generate_platform_art_asset_with_required_slices_at, maybe_generate_platform_art_asset_step, needs_platform_art_asset_generation, normalize_platform_art_asset_generation_kind, + normalize_platform_art_reference_asset_ids, normalize_platform_art_target_category, platform_art_asset_art_spec, platform_art_asset_output_extension_matches, - prepare_platform_art_asset_output_path, project_canvas_asset_media_types, - role_has_canvas_assets, suggested_canvas_tool_call, PlatformArtAssetGenerationOptions, - PLATFORM_ART_ASSET_GENERATION_KINDS, + platform_art_runtime_references_match_request_contract, prepare_platform_art_asset_output_path, + project_canvas_asset_media_types, role_has_canvas_assets, suggested_canvas_tool_call, + PlatformArtAssetGenerationOptions, PLATFORM_ART_ASSET_GENERATION_KINDS, }; #[allow(unused_imports)] pub(crate) use draft_validation::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index e2e300f20..f06d1cc8f 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 @@ -420,6 +420,28 @@ pub(crate) struct PlatformArtAssetGenerationOptions { pub(crate) slice_mode: Option, pub(crate) grid_x: Option, pub(crate) grid_y: Option, + /// 本次生成用作参考的**当前项目已登记图片素材 id**(manifest `assets[].id`)。 + /// + /// 只接受当前项目 manifest 身份:路径、远端 `resourceId` / `objectKey` 与跨项目素材都会在 + /// [`resolve_platform_art_generation_references_at`] 解析阶段被拒绝,素材内容再经本地文件、 + /// 图片解码与内容 hash 换成**当前账号**绑定下的远端资源 ID。 + /// + /// 它**刻意不进** standalone 动作指纹([`StandalonePlatformArtGenerationFingerprintMaterial`]): + /// 指纹只用来在同一项目里定位 durable 输出槽,改动会让已在途的计费账本换槽而重复 POST。 + /// 参考集合的身份由账本请求正文里的 `referenceImageSrcs` 快照承担,恢复时必须与本次请求逐项相符。 + pub(crate) reference_asset_ids: Vec, + /// GUI 生成完成时要落盘的**正式功能分类**(前端 `targetCategory`,取值与 + /// [`update_local_project_resource_classification_at`] 同一套词汇)。 + /// + /// 只有 GUI 侧 `start_local_project_asset_generation` / `generate_local_project_asset` 会传值: + /// 入口栏目生成的是 `kind=image` / `icon-spec`,按 kind 派生只会落到 `unclassified` / + /// `document`,与入口栏目不一致,占位无法被原位接管。Agent / Direct 路径保持 `None`, + /// 继续按 kind 派生默认分类。 + /// + /// 它**刻意不进** standalone 动作指纹与 durable 请求快照:指纹只用来定位同一个计费输出槽, + /// 改材料会让升级时在途的账本换槽并重复 POST;分类只影响本地 manifest 落盘,不影响远端 + /// 请求正文。同任务的幂等恢复由调用方继续用同一个栏目提交(与 `reference_asset_ids` 同口径)。 + pub(crate) target_category: Option, /// 抠图纯色背景(auto/省略已归一为 None;Some 时是规范化后的大写 #RRGGBB)。 /// 仅 character 与 icon-spritesheet 链路透传给服务端。 pub(crate) screen_color: Option, @@ -438,11 +460,45 @@ impl Default for PlatformArtAssetGenerationOptions { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, screen_color: None, } } } +/// 收口 GUI 完成登记层的目标分类:只接受 [`GameCreationAppAssetCategory`] 的合法取值 +/// (`ui-interaction` / `character` / `scene` / `audio` / `document` / `unclassified`), +/// 归一成落盘字符串。`version` / `all` 等栏目侧伪值不在枚举里,一律拒绝。 +pub(crate) fn normalize_platform_art_target_category( + target_category: Option<&str>, +) -> Result, String> { + let Some(target_category) = target_category else { + return Ok(None); + }; + let target_category = target_category.trim(); + if target_category.is_empty() { + return Ok(None); + } + let category = game_creation_app_asset_category_from_str(target_category) + .ok_or_else(|| format!("目标分类不是合法素材分类:{target_category}"))?; + // 落盘字符串直接取枚举自己的 kebab-case 序列化,避免再抄一份 vocabulary 出来漂移。 + let value = + serde_json::to_value(category).map_err(|error| format!("目标分类无法序列化:{error}"))?; + let value = value + .as_str() + .ok_or_else(|| "目标分类不是字符串枚举".to_string())?; + Ok(Some(value.to_string())) +} + +/// 普通图片生成合并规范图与用户参考后的**总参考上限**,沿用图片生成 API 已有上限。 +pub(crate) const PLATFORM_ART_MAX_REFERENCE_IMAGES: usize = 5; +/// 有规范图前置时允许的用户参考上限:规范图本身占 1 张,总量仍不超过 +/// [`PLATFORM_ART_MAX_REFERENCE_IMAGES`]。 +pub(crate) const PLATFORM_ART_MAX_USER_REFERENCE_IMAGES_WITH_CANONICAL_SPEC: usize = 4; +/// 单个参考素材 id 的长度上限(manifest 资产 id 是稳定短标识,不是路径)。 +const PLATFORM_ART_REFERENCE_ASSET_ID_MAX_CHARS: usize = 128; + /// 「无源生成图片类素材」参数化通道放行的 kind 目录。 /// /// GUI 侧 `generate_local_project_asset` 与 agent 侧 `agc_generate_image` 共用这一份目录, @@ -471,6 +527,67 @@ pub(crate) fn normalize_platform_art_asset_generation_kind( .find(|candidate| *candidate == parsed) } +/// 需要规范图前置的生成类型:这些请求必须解析出当前账号的规范图引用,用户参考最多 +/// [`PLATFORM_ART_MAX_USER_REFERENCE_IMAGES_WITH_CANONICAL_SPEC`] 张。 +pub(crate) fn platform_art_asset_kind_requires_canonical_spec_reference(asset_kind: &str) -> bool { + matches!( + asset_kind, + "ui-prototype" | "game-background" | "art-spritesheet" + ) +} + +/// 只有单规范引用的图集操作不接受用户参考:非法参考必须在原生提交处**拒绝**,不能静默丢弃。 +pub(crate) fn platform_art_asset_kind_accepts_user_reference_assets(asset_kind: &str) -> bool { + asset_kind != "art-spritesheet" +} + +/// 收口参考素材 id 入参:trim、去重(保持给出顺序),并拒绝路径 / 远端资源 ID / 跨项目身份。 +/// +/// 这里只做**形状与数量**校验;「是不是当前项目已登记图片素材」由 +/// [`manifest_asset_remote_reference_at`] 用 manifest 身份与图片解码证明,不靠命名猜测。 +pub(crate) fn normalize_platform_art_reference_asset_ids( + asset_kind: &str, + asset_ids: &[String], +) -> Result, String> { + if !platform_art_asset_kind_accepts_user_reference_assets(asset_kind) && !asset_ids.is_empty() { + return Err("透明美术图集只接受规范图引用,不接受用户参考素材".to_string()); + } + let mut normalized: Vec = Vec::new(); + for asset_id in asset_ids { + let asset_id = asset_id.trim(); + if asset_id.is_empty() { + continue; + } + if asset_id.chars().count() > PLATFORM_ART_REFERENCE_ASSET_ID_MAX_CHARS + || asset_id.chars().any(char::is_control) + || asset_id.contains('/') + || asset_id.contains('\\') + || asset_id.contains("://") + { + return Err(format!( + "参考素材只接受当前项目已登记素材 ID,不接受路径或远端资源 ID:{asset_id}" + )); + } + if !normalized.iter().any(|existing| existing == asset_id) { + normalized.push(asset_id.to_string()); + } + } + if platform_art_asset_kind_requires_canonical_spec_reference(asset_kind) { + if normalized.len() > PLATFORM_ART_MAX_USER_REFERENCE_IMAGES_WITH_CANONICAL_SPEC { + return Err(format!( + "有规范图前置的生成最多 {} 张用户参考素材", + PLATFORM_ART_MAX_USER_REFERENCE_IMAGES_WITH_CANONICAL_SPEC + )); + } + } else if normalized.len() > PLATFORM_ART_MAX_REFERENCE_IMAGES { + return Err(format!( + "普通图片生成最多 {} 张参考素材", + PLATFORM_ART_MAX_REFERENCE_IMAGES + )); + } + Ok(normalized) +} + pub(in crate::agent) fn recover_persisted_visual_generation_options( root: &Path, pending: &AgentRuntimePendingToolAction, @@ -1604,12 +1721,62 @@ struct CanonicalArtSpecUploadTicket { form_fields: BTreeMap, } +/// 本次生成请求的全部参考资源身份。 +struct PlatformArtGenerationReferences { + /// 规范图前置引用:需要规范图的生成类型必定存在,其余类型为 `None`。 + canonical: Option, + /// 去重后的完整引用顺序:规范图在前,用户参考随后。它就是请求里的 `referenceImageSrcs`。 + ordered: Vec, +} + +/// 参考素材上传到平台时使用的文件名:由 manifest 素材的本地路径基名派生,只保留 ASCII 安全字符。 +/// +/// 同一素材路径不变时文件名稳定,平台对象键不会随重试漂移;无法派生时退回 +/// `reference.<媒体扩展名>`,扩展名由媒体类型决定,保持与 `contentType` 一致。 +fn platform_art_reference_upload_file_name(source: &GameCreationAppAssetManifestEntry) -> String { + let extension = infer_file_extension(Some(&source.local_path), &source.media_type); + let stem = Path::new(&source.local_path) + .file_stem() + .and_then(|stem| stem.to_str()) + .map(|stem| { + stem.chars() + .filter(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_') + }) + .collect::() + }) + .unwrap_or_default(); + if stem.is_empty() { + return format!("reference.{extension}"); + } + format!("{stem}.{extension}") +} + +/// 规范图前置的生成类型解析当前账号的规范图引用。 async fn canonical_art_spec_reference_at( root: &Path, client: &reqwest::Client, access: &ExternalEditorBindingAccess<'_>, expected_canvas_project_id: &str, ) -> Result { + let (manifest_project_id, source) = canonical_art_spec_manifest_entry_at(root)?; + upload_manifest_asset_remote_reference_at( + root, + client, + access, + &manifest_project_id, + expected_canvas_project_id, + &source, + ) + .await +} + +/// 当前项目已登记的规范图清单条目(`assets/art-spec.png` 且 `icon-spec`)。 +/// +/// 提交前预检与实际上传共用这一份归属判据:路径、远端 ID 或其它项目素材都不能冒充规范图。 +fn canonical_art_spec_manifest_entry_at( + root: &Path, +) -> Result<(String, GameCreationAppAssetManifestEntry), String> { let manifest = read_manifest_for_project(root)?; let source = manifest .assets @@ -1623,18 +1790,96 @@ async fn canonical_art_spec_reference_at( .ok_or_else(|| { "派生视觉资产需要先完成并登记 assets/art-spec.png;请等待 art-director 后重试" .to_string() - })?; + })? + .clone(); + Ok((manifest.project_id, source)) +} + +/// 用户参考素材(当前项目 manifest `assets[].id`)的清单归属解析。 +/// +/// 只接受**当前项目**清单里的素材:路径、远端 resourceId、其它项目的素材都不在清单里, +/// 会在这里失败关闭;解析出来的引用只属于当前账号,历史账号遗留的远端 ID 不会被复用。 +fn manifest_asset_reference_entry_at( + root: &Path, + asset_id: &str, +) -> Result<(String, GameCreationAppAssetManifestEntry), String> { + let manifest = read_manifest_for_project(root)?; + let source = manifest + .assets + .iter() + .find(|asset| asset.id == asset_id) + .ok_or_else(|| format!("参考素材不在当前项目已登记清单中:{asset_id}"))? + .clone(); + Ok((manifest.project_id, source)) +} + +/// 参考素材的**纯本地**校验与读取:清单身份由调用方先证明,这里只管受控路径 → 文件存在 → +/// 媒体类型 → 可解码位图。 +/// +/// 不做任何远端调用;提交前预检与实际上传读同一份判据。SVG 等矢量格式必须在**任何上传之前** +/// 明确拒绝:上游 `image/*` 筛选会放进 SVG,而位图解码器必定失败;本次不做隐式转换,也不允许 +/// 「第一张参考已上传、第二张坏图才失败」的半完成副作用。 +fn read_validated_platform_art_reference_at( + root: &Path, + source: &GameCreationAppAssetManifestEntry, +) -> Result<(Vec, image::DynamicImage), String> { + if !source.media_type.starts_with("image/") { + return Err(format!( + "参考素材必须是图片,不能引用 {}:{}", + source.media_type, source.id + )); + } + if platform_art_reference_source_is_vector(source) { + return Err(format!( + "参考素材不支持 SVG 等矢量格式,请改用 PNG/JPEG 位图:{}", + source.id + )); + } let source_path = resolve_local_project_path(root, &source.local_path)?; if !source_path.is_file() { - return Err( - "派生视觉资产的规范图 assets/art-spec.png 不存在;请等待 art-director 后重试" - .to_string(), - ); + return Err(format!( + "参考素材 {} 不存在;请重新登记后再引用", + source.local_path + )); } - let bytes = - fs::read(&source_path).map_err(|error| format!("读取派生视觉资产规范图失败:{error}"))?; + let bytes = fs::read(&source_path).map_err(|error| format!("读取参考素材失败:{error}"))?; let decoded = image::load_from_memory(&bytes) - .map_err(|_| "派生视觉资产规范图不是可解析图片".to_string())?; + .map_err(|_| format!("参考素材不是可解析图片:{}", source.local_path))?; + Ok((bytes, decoded)) +} + +/// SVG 等矢量格式:媒体类型或文件扩展名任一命中都算矢量,避免只靠声明类型漏判。 +fn platform_art_reference_source_is_vector(source: &GameCreationAppAssetManifestEntry) -> bool { + let media_type = source.media_type.trim().to_ascii_lowercase(); + let media_type = media_type.split(';').next().unwrap_or_default().trim(); + if matches!(media_type, "image/svg+xml" | "image/svg") { + return true; + } + Path::new(source.local_path.trim()) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| matches!(extension.to_ascii_lowercase().as_str(), "svg" | "svgz")) +} + +/// 「manifest 素材 → 当前账号远端资源 ID」的唯一通道。 +/// +/// 复用既有 manifest → 安全文件路径 → 图片解码 → 内容 hash → 当前账号 binding/上传 → +/// 远端 resource ID 流程:binding 存在时直接复用,缺失时从本地正式文件重新上传并登记, +/// 绝不用当前 token 探测或发送历史账号的 project/resource ID。 +async fn upload_manifest_asset_remote_reference_at( + root: &Path, + client: &reqwest::Client, + access: &ExternalEditorBindingAccess<'_>, + manifest_project_id: &str, + expected_canvas_project_id: &str, + source: &GameCreationAppAssetManifestEntry, +) -> Result { + // 引用素材要上传到平台账号:与 `upload_local_project_asset` 同口径复用 `asset.upload` 门禁, + // 显式拒绝该命令的项目在本地就失败关闭,不产生远端上传副作用。 + enforce_project_permission_policy(root, "asset.upload")?; + let file_name = platform_art_reference_upload_file_name(source); + // 与提交前预检共用同一份本地判据:受控路径、文件存在、媒体类型(含 SVG 拒绝)与可解码性。 + let (bytes, decoded) = read_validated_platform_art_reference_at(root, source)?; let principal = external_editor_binding_principal(access)?; let source_identity = new_external_editor_source_identity( &source.id, @@ -1644,19 +1889,19 @@ async fn canonical_art_spec_reference_at( )?; if let Some(binding) = read_external_editor_resource_binding_at( root, - &manifest.project_id, + manifest_project_id, &principal, expected_canvas_project_id, &source_identity, )? { return binding .remote_resource_id - .ok_or_else(|| "当前账号的规范图 binding 缺少项目资源 ID,已拒绝伪造引用".to_string()); + .ok_or_else(|| "当前账号的参考图 binding 缺少项目资源 ID,已拒绝伪造引用".to_string()); } let principal_key = - external_editor_project_binding_key_sha256(&manifest.project_id, &principal)?; + external_editor_project_binding_key_sha256(manifest_project_id, &principal)?; let resource_binding_key = external_editor_resource_binding_key_sha256( - &manifest.project_id, + manifest_project_id, &principal_key, expected_canvas_project_id, &source_identity, @@ -1667,14 +1912,14 @@ async fn canonical_art_spec_reference_at( access.validate_frozen_session()?; if let Some(binding) = read_external_editor_resource_binding_at( root, - &manifest.project_id, + manifest_project_id, &principal, expected_canvas_project_id, &source_identity, )? { return binding .remote_resource_id - .ok_or_else(|| "当前账号的规范图 binding 缺少项目资源 ID,已拒绝伪造引用".to_string()); + .ok_or_else(|| "当前账号的参考图 binding 缺少项目资源 ID,已拒绝伪造引用".to_string()); } // manifest 中的远端 ID 只保留生成来源。当前账号没有 binding 时,必须从本地正式 @@ -1697,57 +1942,57 @@ async fn canonical_art_spec_reference_at( "pathSegments": [ "editor", "account-scoped-bindings", - manifest.project_id.as_str(), + manifest_project_id, source_identity.source_sha256.as_str() ], - "fileName": "art-spec.png", + "fileName": file_name.as_str(), "contentType": source.media_type, "access": "private", "maxSizeBytes": bytes.len(), "successActionStatus": 204, })), - "创建当前账号规范图上传凭证", + "创建当前账号参考图上传凭证", ) .await?; access.validate_frozen_session()?; let upload = external_editor_response_data(&ticket_payload) .get("upload") .or_else(|| ticket_payload.pointer("/data/upload")) - .ok_or_else(|| "当前账号规范图上传凭证缺少 upload".to_string())?; + .ok_or_else(|| "当前账号参考图上传凭证缺少 upload".to_string())?; let ticket = CanonicalArtSpecUploadTicket { host: json_string_field(upload, "host") .or_else(|| json_string_field(upload, "endpoint")) - .ok_or_else(|| "当前账号规范图上传凭证缺少 host".to_string())?, + .ok_or_else(|| "当前账号参考图上传凭证缺少 host".to_string())?, bucket: json_string_field(upload, "bucket") - .ok_or_else(|| "当前账号规范图上传凭证缺少 bucket".to_string())?, + .ok_or_else(|| "当前账号参考图上传凭证缺少 bucket".to_string())?, object_key: json_string_field(upload, "objectKey") - .ok_or_else(|| "当前账号规范图上传凭证缺少 objectKey".to_string())?, + .ok_or_else(|| "当前账号参考图上传凭证缺少 objectKey".to_string())?, success_action_status: upload .get("successActionStatus") .and_then(serde_json::Value::as_u64) .and_then(|value| u16::try_from(value).ok()) .filter(|value| matches!(value, 200 | 201 | 204)) - .ok_or_else(|| "当前账号规范图上传凭证 successActionStatus 无效".to_string())?, + .ok_or_else(|| "当前账号参考图上传凭证 successActionStatus 无效".to_string())?, form_fields: upload .get("formFields") .and_then(serde_json::Value::as_object) - .ok_or_else(|| "当前账号规范图上传凭证缺少 formFields".to_string())? + .ok_or_else(|| "当前账号参考图上传凭证缺少 formFields".to_string())? .iter() .map(|(key, value)| { value .as_str() .map(|value| (key.clone(), value.to_string())) - .ok_or_else(|| "当前账号规范图上传凭证 formFields 必须全为字符串".to_string()) + .ok_or_else(|| "当前账号参考图上传凭证 formFields 必须全为字符串".to_string()) }) .collect::, _>>()?, }; let upload_url = validate_external_asset_download_url(&ticket.host, access.api_base_url(), true) - .map_err(|_| "当前账号规范图上传地址不安全".to_string())?; + .map_err(|_| "当前账号参考图上传地址不安全".to_string())?; let upload_client = build_external_asset_download_client(&upload_url, access.api_base_url(), true) .await - .map_err(|_| "无法创建当前账号规范图上传客户端".to_string())?; + .map_err(|_| "无法创建当前账号参考图上传客户端".to_string())?; access.validate_frozen_session()?; let form = ticket .form_fields @@ -1756,18 +2001,20 @@ async fn canonical_art_spec_reference_at( form.text(key.clone(), value.clone()) }); let part = Part::bytes(bytes.clone()) - .file_name("art-spec.png") + // `Part::file_name` 只接受 `'static` 名字:这里必须交出所有权,借用会让临时串在 + // 请求发出前就结束生命周期。 + .file_name(file_name.clone()) .mime_str(&source.media_type) - .map_err(|_| "规范图媒体类型不能用于上传".to_string())?; + .map_err(|_| "参考图媒体类型不能用于上传".to_string())?; let upload_response = upload_client .post(upload_url) .multipart(form.part("file", part)) .send() .await - .map_err(|_| "上传当前账号规范图失败".to_string())?; + .map_err(|_| "上传当前账号参考图失败".to_string())?; if upload_response.status().as_u16() != ticket.success_action_status { return Err(format!( - "上传当前账号规范图失败:HTTP {}", + "上传当前账号参考图失败:HTTP {}", upload_response.status().as_u16() )); } @@ -1789,19 +2036,19 @@ async fn canonical_art_spec_reference_at( "assetKind": source.kind, "accessPolicy": "private", })), - "确认当前账号规范图上传", + "确认当前账号参考图上传", ) .await?; access.validate_frozen_session()?; let asset_object = external_editor_response_data(&confirm_payload) .get("assetObject") .or_else(|| confirm_payload.pointer("/data/assetObject")) - .ok_or_else(|| "当前账号规范图确认响应缺少 assetObject".to_string())?; + .ok_or_else(|| "当前账号参考图确认响应缺少 assetObject".to_string())?; if json_string_field(asset_object, "objectKey").as_deref() != Some(ticket.object_key.as_str()) { - return Err("当前账号规范图确认响应 objectKey 不一致".to_string()); + return Err("当前账号参考图确认响应 objectKey 不一致".to_string()); } let asset_object_id = json_string_field(asset_object, "assetObjectId") - .ok_or_else(|| "当前账号规范图确认响应缺少 assetObjectId".to_string())?; + .ok_or_else(|| "当前账号参考图确认响应缺少 assetObjectId".to_string())?; let resource_payload = external_editor_json_request( client .post(format!( @@ -1830,7 +2077,7 @@ async fn canonical_art_spec_reference_at( "localAssetId": source.id, }, })), - "登记当前账号规范图项目资源", + "登记当前账号参考图项目资源", ) .await?; let post_response_session = access.validate_frozen_session(); @@ -1839,9 +2086,9 @@ async fn canonical_art_spec_reference_at( resource_data.get("resource").unwrap_or(resource_data), "resourceId", ) - .ok_or_else(|| "当前账号规范图项目资源响应缺少 resourceId".to_string())?; + .ok_or_else(|| "当前账号参考图项目资源响应缺少 resourceId".to_string())?; let binding = new_external_editor_resource_binding( - &manifest.project_id, + manifest_project_id, &principal, expected_canvas_project_id, &source_identity, @@ -1857,6 +2104,83 @@ async fn canonical_art_spec_reference_at( Ok(remote_resource_id) } +/// 解析本次生成请求的全部参考资源身份。 +/// +/// 顺序与上限是请求合同的一部分: +/// +/// - 规范图前置的生成类型必须先解析出当前账号的规范图引用; +/// - 用户参考来自 `options.reference_asset_ids`(当前项目 manifest `assets[].id`),按给出顺序 +/// 逐个换成当前账号的远端资源 ID,并按远端 ID 去重; +/// - 图集类型只接受单规范引用,用户参考在这里被**拒绝**而不是静默丢弃; +/// - 合并后总数不超过 [`PLATFORM_ART_MAX_REFERENCE_IMAGES`]。 +async fn resolve_platform_art_generation_references_at( + root: &Path, + client: &reqwest::Client, + access: &ExternalEditorBindingAccess<'_>, + expected_canvas_project_id: &str, + options: &PlatformArtAssetGenerationOptions, +) -> Result { + let user_reference_asset_ids = normalize_platform_art_reference_asset_ids( + &options.asset_kind, + &options.reference_asset_ids, + )?; + let requires_canonical = + platform_art_asset_kind_requires_canonical_spec_reference(&options.asset_kind); + // 预检:本次请求要用到的所有参考(规范图 + 用户参考)先在本地全部验证一遍, + // 任何一个不合格都必须在**任何上传之前**失败,避免「第一张参考已上传、第二张坏图才失败」。 + if requires_canonical || !user_reference_asset_ids.is_empty() { + // 引用素材要上传到平台账号:与 `upload_local_project_asset` 同口径复用 `asset.upload` + // 门禁,显式拒绝该命令的项目在本地就失败关闭,连第一个上传凭证都不会签发。 + enforce_project_permission_policy(root, "asset.upload")?; + } + let canonical_source = requires_canonical + .then(|| canonical_art_spec_manifest_entry_at(root)) + .transpose()?; + if let Some((_, source)) = canonical_source.as_ref() { + let _ = read_validated_platform_art_reference_at(root, source)?; + } + let mut user_reference_sources = Vec::with_capacity(user_reference_asset_ids.len()); + for asset_id in &user_reference_asset_ids { + let (manifest_project_id, source) = manifest_asset_reference_entry_at(root, asset_id)?; + let _ = read_validated_platform_art_reference_at(root, &source)?; + user_reference_sources.push((manifest_project_id, source)); + } + // 预检全部通过后才允许产生远端副作用。 + let canonical = if requires_canonical { + Some( + canonical_art_spec_reference_at(root, client, access, expected_canvas_project_id) + .await?, + ) + } else { + None + }; + let mut ordered = Vec::new(); + if let Some(reference) = canonical.as_ref() { + ordered.push(reference.clone()); + } + for (manifest_project_id, source) in &user_reference_sources { + let reference = upload_manifest_asset_remote_reference_at( + root, + client, + access, + manifest_project_id, + expected_canvas_project_id, + source, + ) + .await?; + if !ordered.iter().any(|existing| existing == &reference) { + ordered.push(reference); + } + } + if ordered.len() > PLATFORM_ART_MAX_REFERENCE_IMAGES { + return Err(format!( + "图片生成参考素材最多 {} 张(含规范图)", + PLATFORM_ART_MAX_REFERENCE_IMAGES + )); + } + Ok(PlatformArtGenerationReferences { canonical, ordered }) +} + fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec { // External Editor validates each description independently (currently at // 200 Unicode characters). Keep the gameplay context short enough that a @@ -2379,6 +2703,47 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_retained_runtime_ .await } +/// 保留账本里的参考集合是否符合该生成类型的**请求合同**。 +/// +/// 这里判的是「形状」,不是具体身份:规范图到底是什么由调用方用当前账号的解析结果单独比对。 +/// 提交侧的顺序合同固定为「规范图前置在最前,用户参考按给定顺序追加在后」,所以: +/// +/// - `art-spritesheet`:只接受唯一规范引用,多一个用户参考都不算同合同; +/// - `ui-prototype` / `game-background`:必须有规范图前置(至少 1 项),总数不超过总上限; +/// - 其余放行 kind(`icon-spec` 等):没有规范前置,可以零参考,也可以全是用户参考; +/// - 不在目录里的 kind 一律判为不符合,失败关闭,不做兜底猜测。 +/// +/// 提交侧的收口在 [`normalize_platform_art_reference_asset_ids`] 与 +/// [`resolve_platform_art_generation_references_at`],这里只回答「已有账本/清单里的这份参考集合, +/// 是不是该 kind 的合法形状」,两边必须共用同一套上限,否则恢复校验会误判合法请求。 +pub(crate) fn platform_art_runtime_references_match_request_contract( + reference_resource_ids: &[String], + expected_asset_kind: impl AsRef, +) -> bool { + let expected_asset_kind = expected_asset_kind.as_ref(); + if reference_resource_ids + .iter() + .any(|reference| reference.trim().is_empty()) + { + return false; + } + // `game-background` 只由 Direct 运行时直接构造 options,不走 kind 归一目录,所以单独放行。 + if expected_asset_kind != "game-background" + && !PLATFORM_ART_ASSET_GENERATION_KINDS + .iter() + .any(|kind| kind.as_str() == expected_asset_kind) + { + return false; + } + if !platform_art_asset_kind_accepts_user_reference_assets(expected_asset_kind) { + return reference_resource_ids.len() == 1; + } + if platform_art_asset_kind_requires_canonical_spec_reference(expected_asset_kind) { + return (1..=PLATFORM_ART_MAX_REFERENCE_IMAGES).contains(&reference_resource_ids.len()); + } + reference_resource_ids.len() <= PLATFORM_ART_MAX_REFERENCE_IMAGES +} + pub(in crate::agent) fn retained_platform_art_generation_runtime_state_matches_direct_stage_at( root: &Path, runtime_context: &PlatformArtGenerationRuntimeContext, @@ -2402,22 +2767,30 @@ pub(in crate::agent) fn retained_platform_art_generation_runtime_state_matches_d GameCreationAppAssetKind::IconSpec => { snapshot.endpoint == "/api/external/v1/editor/images/generations" && snapshot.generation_kind == "spec" - && snapshot.reference_resource_ids.is_empty() - && request_asset_kind.as_deref() - == Some(GameCreationAppAssetKind::IconSpec.as_str()) - && art_spec_asset_type == Some(GameCreationAppAssetKind::IconSpec.as_str()) + && platform_art_runtime_references_match_request_contract( + &snapshot.reference_resource_ids, + "icon-spec", + ) + && request_asset_kind.as_deref() == Some("icon-spec") + && art_spec_asset_type == Some("icon-spec") } GameCreationAppAssetKind::Scene => { snapshot.endpoint == "/api/external/v1/editor/images/generations" && snapshot.generation_kind == "spec" - && snapshot.reference_resource_ids.len() == 1 - && request_asset_kind.as_deref() == Some(GameCreationAppAssetKind::Scene.as_str()) + && platform_art_runtime_references_match_request_contract( + &snapshot.reference_resource_ids, + "game-background", + ) + && request_asset_kind.as_deref() == Some("game-background") && art_spec_asset_type == Some("background") } GameCreationAppAssetKind::IconSpritesheet => { snapshot.endpoint == "/api/external/v1/editor/icon-spritesheets/generations" && snapshot.generation_kind == "icon-spritesheet" - && snapshot.reference_resource_ids.len() == 1 + && platform_art_runtime_references_match_request_contract( + &snapshot.reference_resource_ids, + "art-spritesheet", + ) && request_asset_kind.is_none() && art_spec_asset_type == Some("art") } @@ -2682,25 +3055,23 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前生成意图与已持久化请求快照不一致,已拒绝将旧操作当作本次请求恢复;原生成账本已保留,需要先完成或对账旧操作" )); } - if matches!( - options.asset_kind, - GameCreationAppAssetKind::UiDesign - | GameCreationAppAssetKind::Scene - | GameCreationAppAssetKind::IconSpritesheet - ) { - let current_reference = canonical_art_spec_reference_at( + { + // 恢复的判据是「本次请求解析出的当前账号引用」与账本快照逐一相符:规范图身份漂移 + // 与用户参考变化都必须被识别,不能把上一次请求的参考当成本次请求的参考恢复。 + let current_references = resolve_platform_art_generation_references_at( root, &client, &binding_access, &snapshot.canvas_project_id, + options, ) .await .map_err(|error| { format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 无法验证已持久化派生请求的当前规范图身份:{error}" + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 无法验证已持久化生成请求的当前规范图与参考素材身份:{error}" ) })?; - if snapshot.reference_resource_ids != [current_reference] { + if snapshot.reference_resource_ids != current_references.ordered { if platform_art_generation_runtime_status(&state) == "accepted" { if let Ok(submission) = platform_art_generation_runtime_submission_payload(&state) @@ -2732,7 +3103,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at } } return Err(format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前规范图身份与已持久化派生请求不一致,已拒绝恢复旧操作;原生成账本已保留,需要先完成或对账旧操作" + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前规范图身份或用户参考素材与已持久化请求不一致,已拒绝恢复旧操作;原生成账本已保留,需要先完成或对账旧操作" )); } } @@ -2815,24 +3186,17 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at }; let is_canonical_art_spritesheet = options.asset_kind == GameCreationAppAssetKind::IconSpritesheet; - let canonical_reference = if matches!( - options.asset_kind, - GameCreationAppAssetKind::UiDesign - | GameCreationAppAssetKind::Scene - | GameCreationAppAssetKind::IconSpritesheet - ) { - Some( - canonical_art_spec_reference_at( - root, - &client, - &binding_access, - &canvas_context.project_id, - ) - .await?, - ) - } else { - None - }; + // 参考顺序「规范图在前、用户参考随后」与去重、上限都在这里统一决定, + // 不区分 GUI 与 agent 调用路径;图集类型的用户参考已在解析处被拒绝。 + let references = resolve_platform_art_generation_references_at( + root, + &client, + &binding_access, + &canvas_context.project_id, + options, + ) + .await?; + let canonical_reference = references.canonical.clone(); let (endpoint, request_body) = if is_canonical_art_spritesheet { let reference_id = canonical_reference .as_deref() @@ -2876,7 +3240,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at "generationInputs": { "artSpec": platform_art_asset_art_spec(options), }, - "referenceImageSrcs": canonical_reference.clone().into_iter().collect::>(), + "referenceImageSrcs": references.ordered.clone(), "canvasCompletion": { "title": options.asset_label, "placeholder": external_canvas_placeholder(&options.aspect_ratio), @@ -3065,7 +3429,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at endpoint.to_string(), generation_kind.to_string(), is_canonical_art_spritesheet, - canonical_reference.into_iter().collect::>(), + references.ordered.clone(), generation_prompt.clone(), ) }; @@ -7724,7 +8088,7 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( return Err(error); } } - let registered = match register_local_asset_entry( + let registered = match register_local_asset_entry_with_category( root, &local_path, options.asset_kind, @@ -7742,6 +8106,8 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( generation_kind: Some(generation_kind.clone()), reference_resource_ids: reference_resource_ids.clone(), }, + // GUI 完成登记层的目标栏目;Agent / Direct 路径为 `None`,仍按 kind 派生。 + options.target_category.as_deref(), ) { Ok(registered) => registered, Err(error) => { @@ -8449,6 +8815,8 @@ mod canvas_generation_tests { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, screen_color: None, }; let ordinary = @@ -10500,6 +10868,8 @@ mod canvas_generation_tests { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, screen_color: None, }; let prompt = "生成同一套整包美术"; @@ -11405,6 +11775,8 @@ mod canvas_generation_tests { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, screen_color: None, }; let prompt = "保持同一个生成提示词"; @@ -11870,6 +12242,8 @@ mod canvas_generation_tests { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, screen_color: None, }; let prompt = "恢复已受理视觉规范图"; @@ -12468,6 +12842,324 @@ mod canvas_generation_tests { ); } + /// 参考素材 id 入参只按当前项目清单形状收口:路径、远端资源 ID、控制字符与超限都在这里拒绝。 + #[test] + fn reference_asset_ids_are_normalized_and_rejected_before_any_remote_call() { + let ids = |values: &[&str]| { + values + .iter() + .map(|value| value.to_string()) + .collect::>() + }; + // 去重保持给出顺序,空白项直接丢弃。 + assert_eq!( + normalize_platform_art_reference_asset_ids("icon-spec", &ids(&[" b ", "a", "b", " "])) + .expect("normalize icon-spec references"), + ids(&["b", "a"]) + ); + // 路径、跨项目远端资源 ID 与非法字符都不是可接受的素材身份。 + for rejected in [ + "assets/hero.png", + "..\\hero.png", + "https://example.com/hero.png", + "hero\u{7}", + &"a".repeat(PLATFORM_ART_REFERENCE_ASSET_ID_MAX_CHARS + 1), + ] { + assert!( + normalize_platform_art_reference_asset_ids("icon-spec", &ids(&[rejected])).is_err(), + "{rejected} 不能被当成参考素材 id" + ); + } + // 无规范前置:最多 5 张;有规范前置:用户参考最多 4 张。 + assert_eq!( + normalize_platform_art_reference_asset_ids( + GameCreationAppAssetKind::IconSpec, + &ids(&["a", "b", "c", "d", "e"]) + ) + .expect("five references without a canonical spec") + .len(), + 5 + ); + assert!(normalize_platform_art_reference_asset_ids( + "icon-spec", + &ids(&["a", "b", "c", "d", "e", "f"]) + ) + .is_err()); + assert_eq!( + normalize_platform_art_reference_asset_ids("ui-prototype", &ids(&["a", "b", "c", "d"])) + .expect("four user references with a canonical spec") + .len(), + 4 + ); + assert!(normalize_platform_art_reference_asset_ids( + "ui-prototype", + &ids(&["a", "b", "c", "d", "e"]) + ) + .is_err()); + // 图集只接受单规范引用:额外参考必须被拒绝,不能静默丢弃。 + assert!( + normalize_platform_art_reference_asset_ids("art-spritesheet", &ids(&["a"])).is_err() + ); + assert!( + normalize_platform_art_reference_asset_ids("art-spritesheet", &[]) + .expect("spritesheet without user references") + .is_empty() + ); + } + + /// 恢复侧与提交侧必须共用同一套参考上限,否则合法账本会被判成身份不符。 + #[test] + fn reference_contract_matches_the_submission_limits_per_kind() { + let references = |count: usize| { + (0..count) + .map(|index| format!("reference-{index}")) + .collect::>() + }; + // 根素材(icon-spec):没有规范前置,可以零参考,也可以全是用户参考。 + assert!(platform_art_runtime_references_match_request_contract( + &[], + "icon-spec" + )); + assert!(platform_art_runtime_references_match_request_contract( + &references(5), + "icon-spec" + )); + assert!(!platform_art_runtime_references_match_request_contract( + &references(6), + "icon-spec" + )); + // 有规范前置:规范图必须在场,总量仍不超过 5 张(含规范图)。 + for kind in ["ui-prototype", "game-background"] { + assert!( + !platform_art_runtime_references_match_request_contract(&[], kind), + "{kind} 必须有规范图前置" + ); + assert!(platform_art_runtime_references_match_request_contract( + &references(1), + kind + )); + assert!(platform_art_runtime_references_match_request_contract( + &references(5), + kind + )); + assert!(!platform_art_runtime_references_match_request_contract( + &references(6), + kind + )); + } + // 图集:恰好一项,多一项都不算同一份请求合同。 + assert!(!platform_art_runtime_references_match_request_contract( + &[], + "art-spritesheet" + )); + assert!(platform_art_runtime_references_match_request_contract( + &references(1), + "art-spritesheet" + )); + assert!(!platform_art_runtime_references_match_request_contract( + &references(2), + "art-spritesheet" + )); + // 普通图片类生成与规范图共用总上限;空白项与未知 kind 一律拒绝。 + assert!(platform_art_runtime_references_match_request_contract( + &references(5), + "image" + )); + assert!(!platform_art_runtime_references_match_request_contract( + &references(6), + "image" + )); + assert!(!platform_art_runtime_references_match_request_contract( + &[" ".to_string()], + "icon-spec" + )); + assert!(!platform_art_runtime_references_match_request_contract( + &references(1), + "unknown-kind" + )); + } + + /// 保留账本的恢复校验必须接受与提交同一套参考合同。 + /// + /// 旧实现把 `icon-spec` 写死成「引用必须为空」、把有规范前置的生成写死成「恰好 1 项」, + /// 带用户参考的合法账本会被判成身份不符而恢复失败。这里直接写真实账本再读回校验。 + #[test] + fn retained_stage_recovery_accepts_the_same_reference_contract_as_submission() { + fn write_retained_stage_result( + root: &Path, + run_id: &str, + endpoint: &str, + request_body: serde_json::Value, + ) -> Result { + let context = PlatformArtGenerationRuntimeContext { + agent_id: "manual-canvas-asset-generate".to_string(), + task_id: "retained-reference-contract-task".to_string(), + session_id: "retained-reference-contract-session".to_string(), + run_id: run_id.to_string(), + source: "test".to_string(), + action_id: format!("retained-reference-contract-{run_id}"), + action_fingerprint: format!("retained-reference-contract-v1:{run_id}"), + }; + let (_, _, frozen_platform_session) = resolve_canvas_sync_api_credentials(None, None)?; + let frozen_platform_session = frozen_platform_session + .ok_or_else(|| "保留账本测试必须使用平台账号".to_string())?; + let access = ExternalEditorBindingAccess::for_platform(&frozen_platform_session)?; + let (state, created) = prepare_platform_art_generation_runtime_state( + root, + &context, + endpoint, + "retained-reference-contract-canvas", + "保留账本参考合同", + &request_body, + &access, + )?; + if !created { + return Err("保留账本测试账本已存在".to_string()); + } + mark_platform_art_generation_runtime_accepted(root, state, "test-operation-id", 1_500)?; + Ok(context) + } + + fn write_retained_stage( + root: &Path, + run_id: &str, + endpoint: &str, + request_body: serde_json::Value, + ) -> PlatformArtGenerationRuntimeContext { + write_retained_stage_result(root, run_id, endpoint, request_body) + .unwrap_or_else(|error| panic!("write retained reference contract ledger: {error}")) + } + + let temporary = tempfile::tempdir().expect("create retained reference contract project"); + let root = temporary.path(); + init_local_game_project_at(root, "retained-reference-contract", "参考合同测试") + .expect("init retained reference contract project"); + let _platform_session = crate::platform_session::install_test_platform_session( + "retained-reference-contract-user", + "retained-reference-contract-key", + "http://127.0.0.1:9", + ); + + // 根素材带用户参考:旧实现要求引用为空,这里必须被认成合法账本。 + let icon_spec = write_retained_stage( + root, + "run-icon-spec-user-references", + "/api/external/v1/editor/images/generations", + serde_json::json!({ + "prompt": "保留账本参考合同", + "kind": "spec", + "assetKind": "icon-spec", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "generationInputs": { "artSpec": { "assetType": "icon-spec" } }, + "referenceImageSrcs": ["user-reference-1", "user-reference-2"], + }), + ); + assert!( + retained_platform_art_generation_runtime_state_matches_direct_stage_at( + root, + &icon_spec, + GameCreationAppAssetKind::IconSpec, + ) + .expect("read icon-spec ledger with user references") + ); + + // 有规范前置的生成带规范图加用户参考:旧实现要求恰好 1 项,这里必须被认成合法账本。 + let background = write_retained_stage( + root, + "run-background-canonical-and-user-references", + "/api/external/v1/editor/images/generations", + serde_json::json!({ + "prompt": "保留账本参考合同", + "kind": "spec", + "assetKind": "game-background", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "generationInputs": { "artSpec": { "assetType": "background" } }, + "referenceImageSrcs": ["resource-icon-spec", "user-reference-1"], + }), + ); + assert!( + retained_platform_art_generation_runtime_state_matches_direct_stage_at( + root, + &background, + GameCreationAppAssetKind::Scene, + ) + .expect("read game-background ledger with a canonical and a user reference") + ); + + // 图集仍只接受唯一规范引用。 + let spritesheet = write_retained_stage( + root, + "run-spritesheet-canonical-reference", + "/api/external/v1/editor/icon-spritesheets/generations", + serde_json::json!({ + "prompt": "保留账本参考合同", + "referenceId": "resource-icon-spec", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "generationInputs": { "artSpec": { "assetType": "art" } }, + }), + ); + assert!( + retained_platform_art_generation_runtime_state_matches_direct_stage_at( + root, + &spritesheet, + GameCreationAppAssetKind::IconSpritesheet, + ) + .expect("read art-spritesheet ledger with the canonical reference") + ); + + // 超出总上限的参考集合不能被当成同一份请求合同。 + let over_limit = write_retained_stage( + root, + "run-icon-spec-over-limit", + "/api/external/v1/editor/images/generations", + serde_json::json!({ + "prompt": "保留账本参考合同", + "kind": "spec", + "assetKind": "icon-spec", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "generationInputs": { "artSpec": { "assetType": "icon-spec" } }, + "referenceImageSrcs": [ + "user-reference-1", + "user-reference-2", + "user-reference-3", + "user-reference-4", + "user-reference-5", + "user-reference-6", + ], + }), + ); + assert!( + !retained_platform_art_generation_runtime_state_matches_direct_stage_at( + root, + &over_limit, + GameCreationAppAssetKind::IconSpec, + ) + .expect("read over limit icon-spec ledger") + ); + + // 空白引用连账本都写不进去:写入后的读回校验必须直接失败关闭。 + let blank = write_retained_stage_result( + root, + "run-icon-spec-blank-reference", + "/api/external/v1/editor/images/generations", + serde_json::json!({ + "prompt": "保留账本参考合同", + "kind": "spec", + "assetKind": "icon-spec", + "projectId": "test-canvas-project", + "assetFolderId": "test-asset-folder", + "generationInputs": { "artSpec": { "assetType": "icon-spec" } }, + "referenceImageSrcs": [" "], + }), + ) + .expect_err("blank references must not be persisted into the ledger"); + assert!(blank.contains("引用资源 ID 无效"), "{blank}"); + } + fn replacement_options() -> PlatformArtAssetGenerationOptions { PlatformArtAssetGenerationOptions { output_path: Some("assets/art-spritesheet.png".to_string()), @@ -12480,6 +13172,8 @@ mod canvas_generation_tests { slice_mode: Some("connected-components".to_string()), grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, screen_color: None, } } 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 10b15c630..1968de198 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 @@ -581,6 +581,8 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio slice_mode: (!slice_mode.trim().is_empty()).then_some(slice_mode.clone()), grid_x, grid_y, + reference_asset_ids: Vec::new(), + target_category: None, screen_color: None, }; if let Some(pending) = pending_action { @@ -633,6 +635,9 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio .or_else(|| (!slice_mode.trim().is_empty()).then_some(slice_mode)), grid_x, grid_y, + reference_asset_ids: requested_options.reference_asset_ids, + // Agent 运行时不会指定完成登记的目标栏目,保持调用方给的值(默认 `None`)。 + target_category: requested_options.target_category, screen_color: requested_options.screen_color, } }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs index f74a37d29..f7b1e897d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs @@ -391,6 +391,12 @@ pub(crate) async fn start_local_project_asset_generation( image_size: Option, asset_name: Option, output_path: Option, + // 前端 IPC 字段 `referenceAssetIds`:当前项目 manifest 里的图片素材 id,只做参考输入, + // 不进任务账本(重试由调用方继续用同一份引用提交,账本本身不新增字段)。 + reference_asset_ids: Option>, + // 前端 IPC 字段 `targetCategory`:完成登记时要落盘的正式栏目分类。同样不进任务账本: + // 它与引用一样属于「同一次提交的本地落点」,重试由调用方继续用同一个栏目提交。 + target_category: Option, ) -> Result { let task_id = asset_generation_task_id(&task_id)?; let request = prepare_local_project_asset_generation( @@ -401,6 +407,8 @@ pub(crate) async fn start_local_project_asset_generation( image_size.as_deref(), asset_name.as_deref(), output_path.as_deref(), + reference_asset_ids.as_deref().unwrap_or_default(), + target_category.as_deref(), )?; enforce_project_permission_policy(&request.root, "canvas.asset_generate")?; enforce_project_permission_policy(&request.root, "asset.register")?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index e7db0dc7e..7b2e2e443 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -1,5 +1,6 @@ use super::*; use sha2::{Digest as _, Sha256}; +use shared_contracts::game_creation_app::GameCreationAppAssetCategory; use std::future::Future; const PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX: &str = "external-editor-api-"; @@ -671,6 +672,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result generation_kind: None, reference_resource_ids: Vec::new(), }, + None, )?; changed |= asset_changed; } @@ -680,7 +682,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result pub(crate) fn import_canvas_asset_at( root: &Path, local_path: &str, - kind: &str, + kind: GameCreationAppAssetKind, media_type: &str, canvas_project_id: &str, resource_id: Option, @@ -1884,8 +1886,56 @@ pub(crate) fn register_local_asset_entry( id_prefix: &str, source: GameCreationAppAssetSource, ) -> Result { - register_local_asset_entry_with_change(root, local_path, kind, media_type, id_prefix, source) - .map(|(result, _)| result) + register_local_asset_entry_with_change( + root, local_path, kind, media_type, id_prefix, source, None, + ) + .map(|(result, _)| result) +} + +/// 带**显式目标分类**的登记入口:只给 GUI 生成完成路径用(前端 `targetCategory`)。 +/// +/// 入口栏目与生成 kind 不是同一套词汇(栏目 `character` / `scene` / `ui-interaction`, +/// 生成 kind 的派生分类会把图片落到 `unclassified`、规范图落到 `document`),所以要落回 +/// 入口栏目只能由调用方把目标分类显式交进来。取值必须先过 +/// [`shared_contracts::game_creation_app::game_creation_app_asset_category_from_str`], +/// 非法值失败关闭,绝不回退到 kind 派生;其它调用方继续走 +/// [`register_local_asset_entry`],行为不变。 +pub(crate) fn register_local_asset_entry_with_category( + root: &Path, + local_path: &str, + kind: GameCreationAppAssetKind, + media_type: &str, + id_prefix: &str, + source: GameCreationAppAssetSource, + target_category: Option<&str>, +) -> Result { + let target_category = normalize_asset_category_override(target_category)?; + register_local_asset_entry_with_change( + root, + local_path, + kind, + media_type, + id_prefix, + source, + target_category, + ) + .map(|(result, _)| result) +} + +/// 归一显式目标分类:只接受合法枚举值,返回落盘字符串。 +fn normalize_asset_category_override( + target_category: Option<&str>, +) -> Result, String> { + let Some(target_category) = target_category else { + return Ok(None); + }; + let target_category = target_category.trim(); + if target_category.is_empty() { + return Ok(None); + } + game_creation_app_asset_category_from_str(target_category) + .map(Some) + .ok_or_else(|| format!("非法资源分类:{target_category}")) } fn register_local_asset_entry_with_change( @@ -1895,6 +1945,7 @@ fn register_local_asset_entry_with_change( media_type: &str, id_prefix: &str, source: GameCreationAppAssetSource, + target_category: Option, ) -> Result<(UploadLocalAssetResult, bool), String> { let normalized_path = normalize_relative_path(local_path)?; let absolute_path = resolve_local_project_path(root, &normalized_path)?; @@ -1919,11 +1970,17 @@ fn register_local_asset_entry_with_change( // kind 没变时刻意不动 category——落盘分类是权威值,同 kind 重登记不得抹掉它。 let changed = existing.kind != kind || existing.media_type != media_type - || existing.source != source; + || existing.source != source + || target_category.is_some_and(|category| existing.category != category); if existing.kind != kind { existing.kind = kind; existing.category = game_creation_app_asset_category_for_kind(kind); } + // 调用方显式给出目标分类时它就是权威值:GUI 完成登记必须能落回入口栏目, + // 这也是同路径重新生成时把资产从旧栏目(或 unclassified)原位接管过来的唯一入口。 + if let Some(category) = target_category { + existing.category = category; + } existing.media_type = media_type.to_string(); existing.source = source; Ok((existing.id.clone(), "asset.update", changed)) @@ -1940,7 +1997,8 @@ fn register_local_asset_entry_with_change( local_path: normalized_path.clone(), image_sequence_frames: None, image_sequence_duration_ms: None, - category: game_creation_app_asset_category_for_kind(kind), + category: target_category + .unwrap_or_else(|| game_creation_app_asset_category_for_kind(kind)), tags: Vec::new(), source, }); @@ -2160,6 +2218,7 @@ pub(crate) fn delete_manifest_asset_at( #[cfg(test)] mod tests { use super::*; + use shared_contracts::game_creation_app::GameCreationAppAssetCategory; use std::io::{Read, Write}; #[test] @@ -2183,6 +2242,121 @@ mod tests { assert!(!register_design_artifacts_at(root).expect("register idempotently")); } + /// GUI 完成登记可以显式指定目标栏目:新建条目与已登记条目都按显式值落盘。 + /// + /// 入口栏目(character / scene / ui-interaction)与生成 kind 不是同一套词汇,按 kind 派生 + /// 会把图片落到 unclassified,占位拿不回原位;非法值必须失败关闭,不传时保持 kind 派生。 + #[test] + fn explicit_target_category_overrides_the_kind_derived_category() { + fn canvas_source() -> GameCreationAppAssetSource { + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + } + } + fn category_of(root: &Path, asset_id: &str) -> GameCreationAppAssetCategory { + read_existing_manifest_for_project(root) + .expect("read manifest") + .assets + .into_iter() + .find(|asset| asset.id == asset_id) + .expect("registered asset is present") + .category + } + + let temporary = tempfile::tempdir().expect("tempdir"); + let root = temporary.path(); + crate::project::init_local_game_project_at(root, "target-category-test", "目标栏目登记") + .expect("init project"); + fs::create_dir_all(root.join("assets")).expect("create assets dir"); + fs::write(root.join("assets/hero.png"), b"png-bytes").expect("write asset"); + + let registered = register_local_asset_entry_with_category( + root, + "assets/hero.png", + GameCreationAppAssetKind::Image, + "image/png", + "platform-art", + canvas_source(), + Some("character"), + ) + .expect("register with a target category"); + assert_eq!( + category_of(root, ®istered.id), + GameCreationAppAssetCategory::Character + ); + + // 同 kind 重新生成时显式目标分类仍是权威值:资产要能换栏目原位接管。 + register_local_asset_entry_with_category( + root, + "assets/hero.png", + GameCreationAppAssetKind::Image, + "image/png", + "platform-art", + canvas_source(), + Some("ui-interaction"), + ) + .expect("re-register with another target category"); + assert_eq!( + category_of(root, ®istered.id), + GameCreationAppAssetCategory::UiInteraction + ); + + // 非法值失败关闭,且不动已落盘的分类。 + assert!(register_local_asset_entry_with_category( + root, + "assets/hero.png", + GameCreationAppAssetKind::Image, + "image/png", + "platform-art", + canvas_source(), + Some("version"), + ) + .is_err()); + assert_eq!( + category_of(root, ®istered.id), + GameCreationAppAssetCategory::UiInteraction + ); + + // 不传目标分类时保持原有行为:新条目按 kind 派生(image → unclassified)。 + fs::write(root.join("assets/plain.png"), b"png-bytes").expect("write plain asset"); + let plain = register_local_asset_entry( + root, + "assets/plain.png", + "image", + "image/png", + "platform-art", + canvas_source(), + ) + .expect("register without a target category"); + assert_eq!( + category_of(root, &plain.id), + GameCreationAppAssetCategory::Unclassified + ); + // 已落盘的显式分类在 kind 未变时仍然是权威值:同 kind 重登记不得把它抹掉。 + register_local_asset_entry( + root, + "assets/hero.png", + "image", + "image/png", + "platform-art", + canvas_source(), + ) + .expect("re-register without a target category"); + assert_eq!( + category_of(root, ®istered.id), + GameCreationAppAssetCategory::UiInteraction + ); + } + /// 画板导出推断出的 kind 必须已经是 canonical 值。 #[test] fn canvas_export_asset_kind_is_always_canonical() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs b/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs index d236d77c8..0896b32eb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/builtin_plugins.rs @@ -237,7 +237,7 @@ fn persist(guard: &BuiltinPluginState) -> Result<(), String> { /// Agent 工具面是否可用:编译期 feature 打开且用户没有禁用该内置插件。 pub(crate) fn agent_tool_available(plugin: BuiltinPlugin) -> bool { plugin.exposes_agent_tools() - && cfg!(feature = "cocos-editor-execute") + && cfg!(all(windows, feature = "cocos-editor-execute")) && is_enabled(plugin.id()) } @@ -431,7 +431,7 @@ mod tests { let _guard = test_lock(); let directory = tempdir().expect("temp config"); initialize(directory.path()).expect("initialize"); - let tool_visible_when_enabled = cfg!(feature = "cocos-editor-execute"); + let tool_visible_when_enabled = cfg!(all(windows, feature = "cocos-editor-execute")); set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).expect("enable"); assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 19e0ce650..28c32c6c1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -482,6 +482,43 @@ fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result Result { + let requested = requested.trim(); + let root = Path::new(requested); + if requested.is_empty() || !root.is_absolute() { + return Err("项目创建目录必须是绝对路径".to_string()); + } + if project_path_has_control_chars(root) { + return Err("项目创建目录不能包含控制字符".to_string()); + } + let metadata = fs::symlink_metadata(root) + .map_err(|error| format!("读取项目创建目录失败:{}: {error}", root.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("项目创建目录必须是普通文件夹".to_string()); + } + // 用户选择的外部目录仍走显式的项目根准备:保留 user-selected 范围的一次性修复, + // 同时不放弃 reparse point / 非普通目录的失败关闭。 + prepare_game_creator_project_root_for_read(root, true, "项目创建目录")?; + Ok(root.to_path_buf()) +} + +/// 解析本次建项要使用的根目录:没选就用 AGC 管理的默认目录,选了就用用户指定的目录。 +pub(crate) fn resolve_game_project_creation_root( + app: &tauri::AppHandle, + requested: Option<&str>, +) -> Result { + match requested.map(str::trim).filter(|value| !value.is_empty()) { + Some(requested) => validate_requested_game_project_creation_root(requested), + None => automatic_local_game_projects_root(app), + } +} + pub(crate) fn create_automatic_local_game_project_at( projects_root: &Path, requested_name: Option<&str>, @@ -551,9 +588,10 @@ pub(crate) fn create_automatic_local_game_project( app: tauri::AppHandle, name: Option, planning: Option, + projects_root: Option, ) -> Result { create_automatic_local_game_project_at( - &automatic_local_game_projects_root(&app)?, + &resolve_game_project_creation_root(&app, projects_root.as_deref())?, name.as_deref(), planning.unwrap_or(false), ) @@ -764,13 +802,30 @@ pub(crate) fn recent_game_creator_run_trace(root: &Path) -> Option(&content).ok() } +/// 目录选择器标题:调用方只能给短标题,其余(超长、含控制字符、空白)一律回退默认文案。 +fn pick_project_directory_title(title: Option<&str>) -> &str { + const MAX_TITLE_CHARS: usize = 24; + title + .map(str::trim) + .filter(|value| { + !value.is_empty() + && value.chars().count() <= MAX_TITLE_CHARS + && !value.chars().any(char::is_control) + }) + .unwrap_or("选择游戏项目目录") +} + #[tauri::command] pub(crate) async fn pick_local_project_directory( app: tauri::AppHandle, initial_path: Option, + title: Option, ) -> Result, String> { let (sender, receiver) = tokio::sync::oneshot::channel(); - let mut dialog = app.dialog().file().set_title("选择游戏项目目录"); + let mut dialog = app + .dialog() + .file() + .set_title(pick_project_directory_title(title.as_deref())); if let Some(initial_path) = initial_path .as_deref() .map(str::trim) @@ -2317,6 +2372,27 @@ pub(crate) fn update_local_project_resource_classification( ) } +/// 为一批已登记素材追加标签:整批一次校验、一次 manifest 写入、一次 revision 推进。 +/// +/// 权限位与单素材分类更新同口径取 `asset.register`(命令包装层只做权限门面, +/// 身份 / 写锁 / CAS / 原子写与审计都在 `project/manifest.rs` 内完成)。 +/// 这里刻意**不**循环调用单素材命令:逐项调用会写出多份 manifest、推进多次 revision, +/// 中途失败还会留下"前几个素材改了、后面的没改"的部分写入。 +#[tauri::command] +pub(crate) fn add_local_project_resource_tags( + input: AddLocalProjectResourceTagsInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + add_manifest_asset_tags_at( + root, + &input.expected_project_id, + input.expected_project_revision, + input.asset_ids, + input.tags, + ) +} + #[tauri::command] pub(crate) async fn derive_local_project_resource( input: DeriveLocalProjectResourceInput, @@ -4870,6 +4946,8 @@ pub(crate) fn prepare_local_project_asset_generation( image_size: Option<&str>, asset_name: Option<&str>, output_path: Option<&str>, + reference_asset_ids: &[String], + target_category: Option<&str>, ) -> Result { let project_path = project_path.trim(); if project_path.is_empty() { @@ -4877,6 +4955,13 @@ pub(crate) fn prepare_local_project_asset_generation( } let asset_kind = normalize_platform_art_asset_generation_kind(kind) .ok_or_else(|| format!("素材类型不受支持:{}", kind.trim()))?; + // 参考入参只接受当前项目 manifest 素材 id:路径、远端 resourceId 与超限在这里就被拒绝, + // 不把校验推迟到远端(远端只该收到当前账号绑定下的 resource ID)。 + let reference_asset_ids = + normalize_platform_art_reference_asset_ids(asset_kind.as_str(), reference_asset_ids)?; + // GUI 完成登记层参数:入口栏目与生成 kind 不是同一套词汇,只有调用方显式给出目标分类 + // 才能把产物原位落回入口栏目。非法值(含 `version` / `all` 这类栏目伪值)直接失败关闭。 + let target_category = normalize_platform_art_target_category(target_category)?; Ok(LocalProjectAssetGenerationRequest { root: PathBuf::from(project_path), prompt: local_project_asset_prompt(prompt)?, @@ -4913,6 +4998,8 @@ pub(crate) fn prepare_local_project_asset_generation( .then(|| "connected-components".to_string()), grid_x: None, grid_y: None, + reference_asset_ids, + target_category, screen_color: None, }, }) @@ -4935,6 +5022,10 @@ pub(crate) async fn generate_local_project_asset( image_size: Option, asset_name: Option, output_path: Option, + reference_asset_ids: Option>, + // 前端 IPC 字段 `targetCategory`:本次生成完成登记时要落盘的正式栏目分类, + // 只走 GUI 命令,取值必须是合法素材分类,Agent / Direct 路径不传。 + target_category: Option, ) -> Result { let request = prepare_local_project_asset_generation( &project_path, @@ -4944,6 +5035,8 @@ pub(crate) async fn generate_local_project_asset( image_size.as_deref(), asset_name.as_deref(), output_path.as_deref(), + reference_asset_ids.as_deref().unwrap_or_default(), + target_category.as_deref(), )?; enforce_project_permission_policy(&request.root, "canvas.asset_generate")?; enforce_project_permission_policy(&request.root, "asset.register")?; @@ -4962,7 +5055,17 @@ mod local_project_asset_generation_tests { use super::*; fn prepare(kind: &str, prompt: &str) -> Result { - prepare_local_project_asset_generation("/tmp/project", kind, prompt, None, None, None, None) + prepare_local_project_asset_generation( + "/tmp/project", + kind, + prompt, + None, + None, + None, + None, + &[], + None, + ) } #[test] @@ -4999,6 +5102,8 @@ mod local_project_asset_generation_tests { Some("2K"), Some(" 主角图集 "), Some(" assets/hero.png "), + &[], + None, ) .expect("explicit options"); assert_eq!(explicit.root, PathBuf::from("/tmp/project")); @@ -5029,8 +5134,18 @@ mod local_project_asset_generation_tests { #[test] fn invalid_toolbar_arguments_are_rejected_before_any_generation() { assert_eq!( - prepare_local_project_asset_generation("", "image", "要求", None, None, None, None) - .expect_err("empty project path"), + prepare_local_project_asset_generation( + "", + "image", + "要求", + None, + None, + None, + None, + &[], + None, + ) + .expect_err("empty project path"), "项目路径不能为空" ); assert_eq!( @@ -5041,6 +5156,60 @@ mod local_project_asset_generation_tests { prepare("future-kind", "要求").expect_err("unverified kind"), "素材类型不受支持:future-kind" ); + // 目标分类只接受合法素材分类枚举:栏目侧伪值 `version` / `all` 与任意其它值都失败关闭。 + for rejected in ["version", "all", "bogus", "UI"] { + assert_eq!( + prepare_local_project_asset_generation( + "/tmp/project", + "image", + "要求", + None, + None, + None, + None, + &[], + Some(rejected), + ) + .expect_err("illegal target category"), + format!("目标分类不是合法素材分类:{rejected}") + ); + } + // 合法值归一成落盘字符串(trim + kebab-case),供 manifest `category` 直接使用。 + assert_eq!( + prepare_local_project_asset_generation( + "/tmp/project", + "image", + "要求", + None, + None, + None, + None, + &[], + Some(" ui-interaction "), + ) + .expect("legal target category") + .options + .target_category + .as_deref(), + Some("ui-interaction") + ); + assert_eq!( + prepare_local_project_asset_generation( + "/tmp/project", + "image", + "要求", + None, + None, + None, + None, + &[], + None, + ) + .expect("omitted target category") + .options + .target_category, + None + ); assert_eq!( prepare( "icon-spec", @@ -5057,7 +5226,9 @@ mod local_project_asset_generation_tests { Some("4:3"), None, None, - None + None, + &[], + None, ) .expect_err("unsupported ratio"), "图片比例不受支持:4:3" @@ -5070,7 +5241,9 @@ mod local_project_asset_generation_tests { None, Some("4K"), None, - None + None, + &[], + None, ) .expect_err("unsupported size"), "图片尺寸不受支持:4K" @@ -5083,7 +5256,9 @@ mod local_project_asset_generation_tests { None, None, Some("坏\u{7}名字"), - None + None, + &[], + None, ) .expect_err("control character in asset name"), "素材名称超出安全边界" @@ -5096,7 +5271,9 @@ mod local_project_asset_generation_tests { None, None, None, - Some(&"a".repeat(LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS + 1)) + Some(&"a".repeat(LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS + 1)), + &[], + None, ) .expect_err("oversized output path"), "输出路径超出安全边界" diff --git a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs index ba2949e8b..4c5cf1444 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters.rs @@ -4,10 +4,10 @@ //! 目前由宿主在编译期链接(Cargo path 依赖),再按插件 manifest 的 `adapter` //! 字段注册到通用插件宿主。宿主只认适配器 id,不包含目标编辑器知识。 -#[cfg(feature = "cocos-editor")] +#[cfg(all(windows, feature = "cocos-editor-execute"))] use std::path::PathBuf; -#[cfg(feature = "cocos-editor")] +#[cfg(all(windows, feature = "cocos-editor-execute"))] use tauri::Manager; use crate::plugin_host::PluginHost; @@ -21,20 +21,20 @@ pub(crate) fn register_linked_editor_adapters( app: &tauri::AppHandle, host: &PluginHost, ) -> Result<(), String> { - #[cfg(feature = "cocos-editor")] + #[cfg(all(windows, feature = "cocos-editor-execute"))] { let adapter = cocos_editor_bridge::CocosEditorAdapter::new(cocos_bridge_payload_candidates(app)); host.register_editor_adapter(Box::new(adapter))?; } - #[cfg(not(feature = "cocos-editor"))] + #[cfg(not(all(windows, feature = "cocos-editor-execute")))] { let _ = (app, host); } Ok(()) } -#[cfg(feature = "cocos-editor")] +#[cfg(all(windows, feature = "cocos-editor-execute"))] fn cocos_bridge_payload_candidates(app: &tauri::AppHandle) -> Vec { let mut candidates = Vec::new(); if let Ok(resource_dir) = app.path().resource_dir() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 20a348583..bd69ce008 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2620,6 +2620,7 @@ fn main() { register_local_asset, create_ui_design_resource, update_local_project_resource_classification, + add_local_project_resource_tags, derive_local_project_resource, list_pending_local_project_resource_edits, resume_local_project_resource_edit, diff --git a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs index 51838ea81..389d0486d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs @@ -767,6 +767,22 @@ fn permission_for_method(method: &str) -> Option<&'static str> { } } +fn has_cocos_editor_adapter(editors: &EditorRegistry) -> Result { + Ok(editors + .lock() + .map_err(|_| "编辑器注册表锁已损坏".to_string())? + .contains_key("cocos-editor")) +} + +fn require_plugin_adapter(id: &str, editors: &EditorRegistry) -> Result<(), String> { + if id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID + && !has_cocos_editor_adapter(editors)? + { + return Err("当前客户端不支持 Cocos 编辑器桥接".to_string()); + } + Ok(()) +} + impl PluginHost { pub(crate) fn initialize(&self, config_dir: &Path) -> Result<(), String> { let root = plugin_root(config_dir)?; @@ -948,11 +964,12 @@ impl PluginHost { .flatten() .is_some() }); + let cocos_available = cocos_project && has_cocos_editor_adapter(&state.editors)?; state .plugins .values() .filter(|record| { - record.id != crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID || cocos_project + record.id != crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID || cocos_available }) .map(|record| self.summary_locked(record)) .collect() @@ -1017,6 +1034,7 @@ impl PluginHost { .clone() .ok_or_else(|| "插件宿主尚未初始化".to_string())?; let active_project = state.active_project.clone(); + require_plugin_adapter(id, &state.editors)?; if id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID && !active_project .lock() @@ -1124,6 +1142,7 @@ impl PluginHost { .state .lock() .map_err(|_| "插件宿主锁已损坏".to_string())?; + require_plugin_adapter(id, &state.editors)?; let record = state .plugins .get(id) @@ -1535,6 +1554,7 @@ impl PluginHost { Ok(json!({"path": input.path, "content": content})) } "host.rpc" => { + require_plugin_adapter(&manifest.id, editors)?; let input: EditorRpcInput = descriptor_from_params(params)?; if manifest.id == crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID && !active_project @@ -2122,6 +2142,8 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p let host = PluginHost::default(); crate::builtin_plugins::initialize(directory.path()).expect("builtin plugin state"); host.initialize(directory.path()).expect("initialize"); + host.register_editor_adapter(Box::new(StubCocosAdapter)) + .expect("register adapter"); host.set_plugin_workspace(workspace) .expect("set plugins workspace"); host.set_active_project(Some(directory.path().to_string_lossy().into_owned())) @@ -2223,6 +2245,8 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins"); let host = PluginHost::default(); host.initialize(directory.path()).expect("initialize"); + host.register_editor_adapter(Box::new(StubCocosAdapter)) + .expect("register adapter"); host.set_plugin_workspace(workspace).expect("set workspace"); let project = tempdir().expect("web project"); @@ -2235,4 +2259,53 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p .all(|plugin| plugin.id != "agc-cocos-editor")); assert!(host.start("agc-cocos-editor").is_err()); } + + #[test] + fn cocos_plugin_requires_registered_adapter_even_for_a_cocos_project() { + let _guard = crate::builtin_plugins::test_lock(); + let directory = tempdir().expect("temp config"); + fs::write( + directory.path().join("package.json"), + r#"{"creator":{"version":"3.8.8"}}"#, + ) + .unwrap(); + fs::create_dir(directory.path().join("assets")).unwrap(); + crate::builtin_plugins::initialize(directory.path()).unwrap(); + let host = PluginHost::default(); + host.initialize(directory.path()).unwrap(); + host.set_plugin_workspace(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins")) + .unwrap(); + host.set_active_project(Some(directory.path().to_string_lossy().into_owned())) + .unwrap(); + assert!(host + .list() + .unwrap() + .iter() + .all(|plugin| plugin.id != "agc-cocos-editor")); + assert!(host + .list_extensions() + .unwrap() + .iter() + .all(|plugin| plugin.id != "agc-cocos-editor")); + assert!(host + .start("agc-cocos-editor") + .err() + .expect("unsupported adapter") + .contains("不支持 Cocos")); + assert!(host + .read_panel("agc-cocos-editor", "cocos-editor") + .err() + .expect("unsupported adapter") + .contains("不支持 Cocos")); + assert!(host.state.lock().unwrap().plugins["agc-cocos-editor"] + .running + .is_none()); + host.register_editor_adapter(Box::new(StubCocosAdapter)) + .unwrap(); + assert!(host + .list() + .unwrap() + .iter() + .any(|plugin| plugin.id == "agc-cocos-editor")); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 8ca8f7b9a..5741c12c3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -1137,8 +1137,17 @@ pub(crate) fn validate_manifest_required_visual_asset( } if task_id == "art-director" { - if !asset.source.reference_resource_ids.is_empty() { - return Err("统一视觉规范图不得声明派生资源引用".to_string()); + // 规范图是视觉来源链的根:它自身不派生任何视觉资产,但 icon-spec 生成允许用户参考 + // (没有规范前置,最多总上限),这些参考只是风格输入,不构成派生关系。这里改为验证 + // 参考集合仍符合 icon-spec 请求合同;route / generation kind / canvasProjectId / + // resourceId / PNG 解码等身份判据全部保持不变。 + if !crate::agent::platform_art_runtime_references_match_request_contract( + &asset.source.reference_resource_ids, + expected_kind, + ) { + return Err(format!( + "统一视觉规范图的参考集合不符合请求合同:{expected_path}" + )); } return Ok(()); } @@ -1166,11 +1175,17 @@ pub(crate) fn validate_manifest_required_visual_asset( .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| "统一视觉规范图缺少 resourceId".to_string())?; - let [reference_resource_id] = asset.source.reference_resource_ids.as_slice() else { + // 派生素材的参考合同是「规范图前置在最前,用户参考按顺序追加在后」,图集不接受用户参考: + // 规范身份仍只由首项承担,用户参考不能顶替也不能冒充规范引用。 + if !crate::agent::platform_art_runtime_references_match_request_contract( + &asset.source.reference_resource_ids, + expected_kind, + ) { return Err(format!( "派生视觉资产未精确引用当前统一视觉规范图:{expected_path}" )); - }; + } + let reference_resource_id = asset.source.reference_resource_ids[0].as_str(); let original_provenance_matches = canvas_project_id == art_spec_project_id && reference_resource_id == art_spec_resource_id; let rebound_local_source_matches = if original_provenance_matches { @@ -1337,6 +1352,240 @@ pub(crate) fn update_manifest_asset_classification_at( }) } +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct AddLocalProjectResourceTagsInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) expected_project_revision: u64, + pub(crate) asset_ids: Vec, + #[serde(default)] + pub(crate) tags: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AddLocalProjectResourceTagsResult { + pub(crate) assets: Vec, + pub(crate) committed_project_revision: u64, +} + +/// 一次批量追加的素材上限:与主规范「每批最多 200 个不同素材」一致,按**去重后**数量计算。 +/// 批次越大,锁内要重算的合并结果越多,manifest 也越大;无界批次会把成本摊到之后每一次读写上。 +pub(crate) const ASSET_BATCH_TAG_MAX_ASSETS: usize = 200; + +/// 批量追加标签的素材 ID 归一化:trim、按**首次出现顺序**去重,再在此处收口批次上下界。 +/// +/// 这里是"整句拒绝"的失败关闭口径,不做任何静默容忍: +/// +/// - 空白 `assetId` 直接失败,不 `continue` 跳过。静默跳过会让"请求了 N 个素材"和"实际写了 +/// N-1 个"分叉,而调用方拿到的仍是成功——这正是本合同要排除的静默部分写; +/// - 空批次失败; +/// - 去重后超限立即失败(在扫描到第 201 个不同 ID 时就返回,不对剩余 ID 继续做去重扫描), +/// 更不做"截断到 200 个":截断会让用户以为 250 个素材都加上了标签。 +fn normalize_manifest_batch_asset_ids(asset_ids: &[String]) -> Result, String> { + let mut normalized: Vec = Vec::new(); + for asset_id in asset_ids { + let asset_id = asset_id.trim(); + if asset_id.is_empty() { + return Err("批量标签 assetId 不能为空".to_string()); + } + if !normalized.iter().any(|existing| existing == asset_id) { + normalized.push(asset_id.to_string()); + if normalized.len() > ASSET_BATCH_TAG_MAX_ASSETS { + return Err(format!( + "批量标签最多支持 {ASSET_BATCH_TAG_MAX_ASSETS} 个素材" + )); + } + } + } + if normalized.is_empty() { + return Err("批量标签至少需要一个素材".to_string()); + } + Ok(normalized) +} + +/// 批量追加的标签归一化:沿用主规范的 trim / 去空 / 去重口径(复用 +/// [`normalize_manifest_asset_tags`],其中已含数量与单标签长度收口)。 +/// +/// 只有**归一后为空**才拒绝:请求里全是空白标签时,用户填的东西一个字都不会落盘, +/// 此时若当成"成功且无变化"返回,界面会显示保存成功而素材上什么都没有。 +fn normalize_manifest_batch_tags(tags: &[String]) -> Result, String> { + let normalized = normalize_manifest_asset_tags(tags)?; + if normalized.is_empty() { + return Err("批量标签不能为空".to_string()); + } + Ok(normalized) +} + +/// 追加语义:只把请求里**尚不存在**的标签按请求顺序补到原有标签之后。 +/// 原有标签的顺序、分类、类型、路径与来源都不参与改写——本命令没有删除或替换语义。 +fn merge_manifest_asset_tags(existing: &[String], incoming: &[String]) -> Vec { + let mut merged = existing.to_vec(); + for tag in incoming { + if !merged.iter().any(|current| current == tag) { + merged.push(tag.clone()); + } + } + merged +} + +/// 锁内先算完的整批计划:任何一项缺失或超限都在这里失败,此时 manifest 一个字节都没动。 +struct ManifestAssetTagAppendPlan { + /// 按请求顺序(去重后)返回的素材条目,标签为合并后的完整列表。 + assets: Vec, + /// 真正需要落值的目标:`(assets 下标, 合并后的标签)`。 + updates: Vec<(usize, Vec)>, + /// 确实发生变化的素材 ID,供审计记录使用;空表示整批无变化。 + changed_asset_ids: Vec, +} + +/// 先校验**全部**目标与**全部**合并结果,再决定是否写值。 +/// +/// 顺序是刻意的:第一阶段只读,任一目标不存在、任一合并结果超过标签上界都在写之前返回错误; +/// 只有全部通过,第二阶段才逐项落值。这样"缺任一资产 / 超限"都不可能留下部分写入。 +fn plan_manifest_asset_tag_append( + manifest: &GameCreationAppManifest, + asset_ids: &[String], + tags: &[String], +) -> Result { + let mut assets = Vec::with_capacity(asset_ids.len()); + let mut updates: Vec<(usize, Vec)> = Vec::with_capacity(asset_ids.len()); + let mut changed_asset_ids = Vec::new(); + for asset_id in asset_ids { + let index = manifest + .assets + .iter() + .position(|asset| &asset.id == asset_id) + .ok_or_else(|| format!("项目资源不存在:{asset_id}"))?; + let asset = &manifest.assets[index]; + // 合并结果复用同一个上界函数:已有标签已归一化,这里等价于对整份新列表再收口一次。 + // 上界函数只报"16 个"这种通用口径,200 个素材的批次里看不出是哪一项超了,所以在**调用点** + // 补上目标身份(ID + 可读 localPath)并说明整批未写:用户要能直接定位到那一张素材。 + let merged = normalize_manifest_asset_tags(&merge_manifest_asset_tags(&asset.tags, tags)) + .map_err(|error| { + format!( + "素材 {}({})的标签合并结果不合法:{error};本次未写入任何素材", + asset.id, asset.local_path + ) + })?; + if merged != asset.tags { + changed_asset_ids.push(asset.id.clone()); + } + updates.push((index, merged.clone())); + assets.push(GameCreationAppAssetManifestEntry { + tags: merged, + ..asset.clone() + }); + } + Ok(ManifestAssetTagAppendPlan { + assets, + updates, + changed_asset_ids, + }) +} + +/// 为一批已登记素材追加标签:一次校验、一次 manifest 写入、一次 revision 推进。 +/// +/// 语义与 [`update_manifest_asset_classification_at`] 同源(`asset.register` 权限位、项目身份、 +/// 项目写锁、revision CAS、manifest 原子写、审计在 manifest 落盘之后 / revision 推进之前), +/// 但作用域是**整批**: +/// +/// - 项目身份校验两次(进入前与持锁后各一次),锁内按 `expectedProjectRevision` 做一次 CAS; +/// - 锁内先算完整批计划,任一目标缺失或任一合并结果超限都**不写任何一项**; +/// - 整批无变化时**不写盘、不审计、不推进 revision**,直接返回当前条目与当前 revision; +/// - 真正有变化时才写一次 manifest、追加一条审计、推进一次 revision。 +/// +/// 已落盘之后的审计或 revision 失败照实报"整批已写入",不回滚、也不谎称回滚:manifest 是权威 +/// 真相且已经改变,把错误说成"没写"只会让用户拿错状态去重试。 +pub(crate) fn add_manifest_asset_tags_at( + root: &Path, + expected_project_id: &str, + expected_project_revision: u64, + asset_ids: Vec, + tags: Vec, +) -> Result { + if expected_project_revision + > shared_contracts::game_creation_app::GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + { + return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string()); + } + let expected_project_id = expected_project_id.trim(); + if expected_project_id.is_empty() { + return Err("批量标签 expectedProjectId 不能为空".to_string()); + } + let asset_ids = normalize_manifest_batch_asset_ids(&asset_ids)?; + let tags = normalize_manifest_batch_tags(&tags)?; + + if read_existing_manifest_for_project(root)?.project_id != expected_project_id { + return Err("project-identity-conflict".to_string()); + } + // 锁的 commandId 用本命令自己的动作名(审计/排障时能区分是批量追加还是别的写路径); + // 权限门面仍然是 `asset.register`,见 `commands.rs` 的命令包装层。 + let _lock = acquire_project_write_lock(root, ASSET_BATCH_TAG_AUDIT_RECORD_TYPE)?; + if read_existing_manifest_for_project(root)?.project_id != expected_project_id { + return Err("project-identity-conflict".to_string()); + } + if read_game_creator_agent_runtime_project_revision(root)?.revision != expected_project_revision + { + return Err("project-revision-conflict".to_string()); + } + + // no-op 判定发生在锁内、写盘之前:整批标签都已经存在时,连 manifest 都不必重写一次。 + // 这不是优化洁癖——重写会换掉文件 mtime 与内容字节,让"什么都没做"看起来像一次真实改动。 + let plan = plan_manifest_asset_tag_append( + &read_existing_manifest_for_project(root)?, + &asset_ids, + &tags, + )?; + if plan.changed_asset_ids.is_empty() { + return Ok(AddLocalProjectResourceTagsResult { + assets: plan.assets, + committed_project_revision: expected_project_revision, + }); + } + + let plan = mutate_manifest_at(root, |manifest| { + // 锁内复核:`mutate_manifest_at` 自己重新读盘,所以这里按同一套规则重算一遍再落值。 + // 复核失败会在 `write_manifest_locked` 之前返回错误,仍然零写入;重算也保证不会拿 + // 锁外算出的绝对标签列表去覆盖这份 manifest 上刚出现的新标签。 + let plan = plan_manifest_asset_tag_append(manifest, &asset_ids, &tags)?; + for (index, merged) in &plan.updates { + manifest.assets[*index].tags = merged.clone(); + } + Ok(plan) + })?; + + // 复核阶段才发现"锁外以为有变化、锁内其实已无变化"的极端竞态:这一次写盘写出的就是原内容, + // 不能凭空补一条审计或推进 revision。正常路径不会走到这里——整批目标在此之前已经通过锁内 no-op 判定。 + if plan.changed_asset_ids.is_empty() { + return Ok(AddLocalProjectResourceTagsResult { + assets: plan.assets, + committed_project_revision: expected_project_revision, + }); + } + + append_agent_db_record( + root, + serde_json::json!({ + "recordType": ASSET_BATCH_TAG_AUDIT_RECORD_TYPE, + "assetIds": plan.changed_asset_ids, + "expectedProjectRevision": expected_project_revision, + "appendedTags": tags, + }), + ) + .map_err(|error| format!("批量标签已写入,但审计记录失败:{error}"))?; + let committed_project_revision = advance_agent_runtime_project_revision_locked(root) + .map_err(|error| format!("批量标签已写入,但项目 revision 未能推进:{error}"))?; + Ok(AddLocalProjectResourceTagsResult { + assets: plan.assets, + committed_project_revision, + }) +} + +/// 批量标签写入的审计类型:一次批量追加只留一条记录,装的是"谁被追加了什么"。 +pub(crate) const ASSET_BATCH_TAG_AUDIT_RECORD_TYPE: &str = "asset.tags.append"; + pub(crate) fn create_manifest_task_at( root: &Path, task_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs index b65f7b86a..eb747e2de 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs @@ -559,3 +559,716 @@ fn asset_classification_audit_failure_is_reported_and_never_faked() { assert_eq!(asset.tags, vec!["主舞台"]); fs::remove_dir_all(root).ok(); } + +/// 批量标签测试的公共脚手架:登记素材、读原始字节、数审计记录。 +fn register_batch_tag_asset(root: &Path, local_path: &str, kind: &str, media_type: &str) -> String { + register_local_asset_entry( + root, + local_path, + kind, + media_type, + "asset", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Uploaded, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + ) + .expect("register asset") + .id +} + +fn project_with_batch_tag_assets( + test_name: &str, + specs: &[(&str, &str, &str)], +) -> (PathBuf, Vec) { + let root = classification_test_root(test_name); + init_local_game_project_at(&root, "batch-tag-project", "批量标签测试") + .expect("initialize batch tag project"); + let ids = specs + .iter() + .map(|(local_path, kind, media_type)| { + register_batch_tag_asset(&root, local_path, kind, media_type) + }) + .collect::>(); + (root, ids) +} + +fn batch_tag_project_id(root: &Path) -> String { + read_existing_manifest_for_project(root) + .expect("read manifest") + .project_id +} + +fn batch_tag_revision(root: &Path) -> u64 { + read_game_creator_agent_runtime_project_revision(root) + .expect("read project revision") + .revision +} + +fn batch_tag_manifest_bytes(root: &Path) -> Vec { + fs::read(root.join(".agent/manifest.json")).expect("read manifest bytes") +} + +/// 批量追加的 `recordType`:与 `asset.*` 命名族一致,`.append` 表达"只追加、不替换既有标签"。 +fn batch_tag_audit_records(root: &Path) -> Vec { + let (records, truncated) = + read_agent_db_records_bounded(root, 4 * 1024 * 1024).expect("read agent db records"); + assert!(!truncated, "批量标签测试的 agent.db 不应触达尾窗上限"); + records + .into_iter() + .filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some(ASSET_BATCH_TAG_AUDIT_RECORD_TYPE) + }) + .collect() +} + +fn batch_tag_manifest_entry(root: &Path, asset_id: &str) -> GameCreationAppAssetManifestEntry { + read_existing_manifest_for_project(root) + .expect("read manifest") + .assets + .into_iter() + .find(|asset| asset.id == asset_id) + .expect("manifest asset") +} + +/// 成功路径:只追加,原有标签顺序、分类与其它字段原样保留,未选中的素材一个字节都不变。 +/// 返回顺序按去重后的请求 ID 首次出现顺序;整批只推进一次 revision、只追加一条审计。 +#[test] +fn batch_tags_append_keeps_existing_tags_and_categories() { + let (root, ids) = project_with_batch_tag_assets( + "batch-append", + &[ + ("assets/hero.png", "character", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ("assets/untouched.png", "image", "image/png"), + ], + ); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + // 两份目标素材带着**不同的**原有标签与分类进入:一个已有标签、一个是空标签集。 + update_manifest_asset_classification_at( + &root, + &project_id, + revision_before, + &ids[0], + "scene", + vec!["原甲".to_string(), "原乙".to_string()], + ) + .expect("preset first asset classification"); + let revision_after_preset = batch_tag_revision(&root); + update_manifest_asset_classification_at( + &root, + &project_id, + revision_after_preset, + &ids[1], + "audio", + Vec::new(), + ) + .expect("preset second asset classification"); + let revision_before_batch = batch_tag_revision(&root); + let untouched_before = batch_tag_manifest_entry(&root, &ids[2]); + + let result = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before_batch, + vec![ids[1].clone(), ids[0].clone()], + vec!["新一".to_string(), "新二".to_string()], + ) + .expect("append batch tags"); + + assert_eq!( + result + .assets + .iter() + .map(|asset| asset.id.clone()) + .collect::>(), + vec![ids[1].clone(), ids[0].clone()], + "返回条目必须按去重后请求 ID 的首次出现顺序" + ); + assert_eq!(result.assets[0].tags, vec!["新一", "新二"]); + assert_eq!(result.assets[1].tags, vec!["原甲", "原乙", "新一", "新二"]); + assert_eq!(result.committed_project_revision, revision_before_batch + 1); + assert_eq!(batch_tag_revision(&root), revision_before_batch + 1); + + let first = batch_tag_manifest_entry(&root, &ids[0]); + assert_eq!(first.tags, vec!["原甲", "原乙", "新一", "新二"]); + assert_eq!(first.category, GameCreationAppAssetCategory::Scene); + assert_eq!(first.kind, "character"); + assert_eq!(first.local_path, "assets/hero.png"); + assert_eq!(first.media_type, "image/png"); + let second = batch_tag_manifest_entry(&root, &ids[1]); + assert_eq!(second.tags, vec!["新一", "新二"]); + assert_eq!(second.category, GameCreationAppAssetCategory::Audio); + assert_eq!(second.kind, "background-music"); + assert_eq!(batch_tag_manifest_entry(&root, &ids[2]), untouched_before); + + let audit = batch_tag_audit_records(&root); + assert_eq!(audit.len(), 1, "整批只留一条审计"); + assert_eq!( + audit[0] + .get("assetIds") + .and_then(serde_json::Value::as_array), + Some(&vec![ + serde_json::Value::String(ids[1].clone()), + serde_json::Value::String(ids[0].clone()) + ]) + ); + assert_eq!( + audit[0] + .get("expectedProjectRevision") + .and_then(serde_json::Value::as_u64), + Some(revision_before_batch) + ); + + fs::remove_dir_all(root).ok(); +} + +/// 重复 ID 与重复标签都按第一次出现收口:既不重复写入素材,也不重复追加同一个标签。 +#[test] +fn batch_tags_dedupe_asset_ids_and_tags() { + let (root, ids) = project_with_batch_tag_assets( + "batch-dedupe", + &[ + ("assets/hero.png", "image", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ], + ); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + + let result = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + vec![format!(" {} ", ids[0]), ids[0].clone(), ids[1].clone()], + vec![ + " 标签 ".to_string(), + "标签".to_string(), + " 另一个 ".to_string(), + ], + ) + .expect("append deduped batch tags"); + + assert_eq!( + result + .assets + .iter() + .map(|asset| asset.id.clone()) + .collect::>(), + ids, + "去重后按首次出现顺序返回,两个素材各一次" + ); + for asset in &result.assets { + assert_eq!(asset.tags, vec!["标签", "另一个"]); + } + assert_eq!(result.committed_project_revision, revision_before + 1); + fs::remove_dir_all(root).ok(); +} + +/// 整批无变化:不写盘(manifest 字节不变)、不审计、不推进 revision,返回当前条目与当前 revision。 +#[test] +fn batch_tags_repeat_is_a_noop_without_write_audit_or_revision() { + let (root, ids) = project_with_batch_tag_assets( + "batch-noop", + &[ + ("assets/hero.png", "image", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ], + ); + let project_id = batch_tag_project_id(&root); + let first = add_manifest_asset_tags_at( + &root, + &project_id, + batch_tag_revision(&root), + ids.clone(), + vec!["重复标签".to_string()], + ) + .expect("first batch append"); + let revision_after_first = first.committed_project_revision; + assert_eq!(batch_tag_audit_records(&root).len(), 1); + let bytes_before = batch_tag_manifest_bytes(&root); + + let repeat = add_manifest_asset_tags_at( + &root, + &project_id, + revision_after_first, + vec![ids[0].clone(), ids[1].clone(), ids[0].clone()], + vec!["重复标签".to_string(), " 重复标签 ".to_string()], + ) + .expect("repeating the same batch tags must succeed"); + + assert_eq!(repeat.committed_project_revision, revision_after_first); + assert_eq!(repeat.assets.len(), 2); + for asset in &repeat.assets { + assert_eq!(asset.tags, vec!["重复标签"]); + } + assert_eq!( + batch_tag_manifest_bytes(&root), + bytes_before, + "无变化时不得重写 manifest" + ); + assert_eq!(batch_tag_revision(&root), revision_after_first); + assert_eq!( + batch_tag_audit_records(&root).len(), + 1, + "无变化不得追加假变更审计" + ); + fs::remove_dir_all(root).ok(); +} + +/// 混合批次:一部分目标无变化、一部分目标有变化时,审计只记**实际变化**的素材, +/// 响应仍然按请求顺序返回**全部**目标的最新条目,revision 只推进一次。 +#[test] +fn batch_tags_mixed_noop_and_change_audits_only_changed_assets() { + let (root, ids) = project_with_batch_tag_assets( + "batch-mixed", + &[ + ("assets/hero.png", "image", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ], + ); + let project_id = batch_tag_project_id(&root); + // 第一项已经有目标标签(本批对它无变化),第二项没有(本批真正改动它)。 + let revision_after_preset = update_manifest_asset_classification_at( + &root, + &project_id, + batch_tag_revision(&root), + &ids[0], + "unclassified", + vec!["已有".to_string()], + ) + .expect("preset first asset tags") + .committed_project_revision; + let first_before = batch_tag_manifest_entry(&root, &ids[0]); + let second_before = batch_tag_manifest_entry(&root, &ids[1]); + + let result = add_manifest_asset_tags_at( + &root, + &project_id, + revision_after_preset, + ids.clone(), + vec!["已有".to_string()], + ) + .expect("mixed no-op and change batch"); + + assert_eq!( + result + .assets + .iter() + .map(|asset| asset.id.clone()) + .collect::>(), + ids, + "响应必须按请求顺序返回全部目标素材" + ); + assert_eq!(result.assets[0].tags, vec!["已有"]); + assert_eq!(result.assets[1].tags, vec!["已有"]); + assert_eq!(result.committed_project_revision, revision_after_preset + 1); + assert_eq!( + batch_tag_manifest_entry(&root, &ids[0]), + first_before, + "本批对该素材无变化时不得改写它" + ); + assert_eq!( + batch_tag_manifest_entry(&root, &ids[1]).tags, + vec!["已有"], + "有变化的目标必须真实落盘" + ); + assert_ne!( + batch_tag_manifest_entry(&root, &ids[1]), + second_before, + "第二项应当发生改动" + ); + + let audit = batch_tag_audit_records(&root); + assert_eq!(audit.len(), 1, "混合批次只留一条审计"); + assert_eq!( + audit[0] + .get("assetIds") + .and_then(serde_json::Value::as_array), + Some(&vec![serde_json::Value::String(ids[1].clone())]), + "审计只记实际发生变化的素材" + ); + fs::remove_dir_all(root).ok(); +} + +/// 缺任一目标(含末项非法)时整批零部分写:已有素材的标签、manifest 字节与 revision 都不动。 +#[test] +fn batch_tags_missing_target_writes_nothing() { + let (root, ids) = project_with_batch_tag_assets( + "batch-missing", + &[ + ("assets/hero.png", "image", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ], + ); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + let bytes_before = batch_tag_manifest_bytes(&root); + + let error = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + vec![ids[0].clone(), ids[1].clone(), "asset-missing".to_string()], + vec!["新标签".to_string()], + ) + .expect_err("a missing target must fail the whole batch"); + + assert!( + error.contains("项目资源不存在"), + "unexpected error: {error}" + ); + assert!(error.contains("asset-missing"), "unexpected error: {error}"); + assert_eq!(batch_tag_manifest_bytes(&root), bytes_before); + assert_eq!(batch_tag_revision(&root), revision_before); + assert!(batch_tag_audit_records(&root).is_empty()); + for asset_id in &ids { + assert!( + batch_tag_manifest_entry(&root, asset_id).tags.is_empty(), + "缺目标失败后不得留下部分写入" + ); + } + fs::remove_dir_all(root).ok(); +} + +/// 合并后的标签总量与单标签长度按既有上界失败关闭:都不写盘、不推进 revision。 +#[test] +fn batch_tags_rejects_merged_tag_limit_and_single_tag_length() { + let (root, ids) = project_with_batch_tag_assets( + "batch-limits", + &[ + ("assets/hero.png", "image", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ], + ); + let project_id = batch_tag_project_id(&root); + let sixteen = (0..16) + .map(|index| format!("原标签{index}")) + .collect::>(); + let revision_after_sixteen = update_manifest_asset_classification_at( + &root, + &project_id, + batch_tag_revision(&root), + &ids[0], + "unclassified", + sixteen.clone(), + ) + .expect("preset sixteen tags") + .committed_project_revision; + let fifteen = sixteen[..15].to_vec(); + let revision_after_fifteen = update_manifest_asset_classification_at( + &root, + &project_id, + revision_after_sixteen, + &ids[1], + "audio", + fifteen, + ) + .expect("preset fifteen tags") + .committed_project_revision; + let bytes_before = batch_tag_manifest_bytes(&root); + + // 16 个原有标签 + 1 个新标签 = 17 > 上界:整批失败,两项目标都不动。 + let over_limit = add_manifest_asset_tags_at( + &root, + &project_id, + revision_after_fifteen, + ids.clone(), + vec!["再来一个".to_string()], + ) + .expect_err("a merged tag count above the limit must fail the whole batch"); + assert!(over_limit.contains("最多支持"), "unexpected: {over_limit}"); + // 通用上界文案必须能定位到具体素材:ID + 可读 localPath + 整批零写。 + assert!(over_limit.contains(&ids[0]), "unexpected: {over_limit}"); + assert!( + over_limit.contains("assets/hero.png"), + "unexpected: {over_limit}" + ); + assert!( + over_limit.contains("本次未写入任何素材"), + "unexpected: {over_limit}" + ); + + // 33 个字符的标签:单标签长度上界,同样整批失败。 + let over_chars = add_manifest_asset_tags_at( + &root, + &project_id, + revision_after_fifteen, + ids.clone(), + vec!["像".repeat(33)], + ) + .expect_err("an over-long tag must fail the whole batch"); + assert!(over_chars.contains("不能超过"), "unexpected: {over_chars}"); + // 单标签超长在**输入归一化**阶段就被拒绝,那时还没有任何"目标素材"可归因, + // 因此这里刻意不出现素材 ID;按素材定位是「合并后数量超限」这类锁内合并失败的职责。 + assert!(!over_chars.contains(&ids[0]), "unexpected: {over_chars}"); + + assert_eq!(batch_tag_manifest_bytes(&root), bytes_before); + assert_eq!(batch_tag_revision(&root), revision_after_fifteen); + assert!(batch_tag_audit_records(&root).is_empty()); + assert_eq!(batch_tag_manifest_entry(&root, &ids[0]).tags, sixteen); + + // 边界内必须成功:15 个原有标签 + 1 = 16,单标签 32 个字符。 + let boundary = add_manifest_asset_tags_at( + &root, + &project_id, + revision_after_fifteen, + vec![ids[1].clone()], + vec!["像".repeat(32)], + ) + .expect("merged tag count exactly at the limit must succeed"); + assert_eq!(boundary.assets[0].tags.len(), 16); + assert_eq!(boundary.assets[0].tags[15].chars().count(), 32); + fs::remove_dir_all(root).ok(); +} + +/// 空批次与空标签都不接受:空白 `assetId` 整批拒绝(不静默跳过),全空标签归一后拒绝。 +#[test] +fn batch_tags_rejects_empty_batch_and_empty_tags() { + let (root, ids) = + project_with_batch_tag_assets("batch-empty", &[("assets/hero.png", "image", "image/png")]); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + let bytes_before = batch_tag_manifest_bytes(&root); + let tags = vec!["标签".to_string()]; + + let empty_batch = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + Vec::new(), + tags.clone(), + ) + .expect_err("an empty asset batch must be rejected"); + assert!( + empty_batch.contains("至少需要一个素材"), + "unexpected: {empty_batch}" + ); + + // 空白 assetId 一律拒绝:跳过它会让"请求了 2 个素材"变成"实际写了 1 个",且调用方仍拿到成功。 + for blank in [String::new(), " ".to_string()] { + let error = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + vec![ids[0].clone(), blank], + tags.clone(), + ) + .expect_err("a blank assetId must fail the whole batch"); + assert!(error.contains("assetId 不能为空"), "unexpected: {error}"); + } + + for empty_tags in [Vec::new(), vec![String::new()], vec![" ".to_string()]] { + let error = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + ids.clone(), + empty_tags, + ) + .expect_err("empty tags must be rejected"); + assert!(error.contains("不能为空"), "unexpected: {error}"); + } + + assert_eq!(batch_tag_manifest_bytes(&root), bytes_before); + assert_eq!(batch_tag_revision(&root), revision_before); + assert!(batch_tag_audit_records(&root).is_empty()); + assert!( + batch_tag_manifest_entry(&root, &ids[0]).tags.is_empty(), + "空白 assetId / 空标签失败后不得留下部分写入" + ); + fs::remove_dir_all(root).ok(); +} + +/// 批次上界:去重后 200 个素材成功,201 个失败且不写任何一项。 +#[test] +fn batch_tags_accepts_two_hundred_assets_and_rejects_two_hundred_one() { + let root = classification_test_root("batch-bound-200"); + init_local_game_project_at(&root, "batch-tag-bound-project", "批量标签上界测试") + .expect("initialize batch bound project"); + let ids = (0..=ASSET_BATCH_TAG_MAX_ASSETS) + .map(|index| { + register_batch_tag_asset(&root, &format!("assets/a{index}.png"), "image", "image/png") + }) + .collect::>(); + let project_id = batch_tag_project_id(&root); + + let revision_before = batch_tag_revision(&root); + let bytes_before = batch_tag_manifest_bytes(&root); + let over = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + ids.clone(), + vec!["超限".to_string()], + ) + .expect_err("more than the batch limit must be rejected"); + assert!(over.contains("最多支持 200 个素材"), "unexpected: {over}"); + assert_eq!(batch_tag_manifest_bytes(&root), bytes_before); + assert_eq!(batch_tag_revision(&root), revision_before); + + let boundary = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + ids[..ASSET_BATCH_TAG_MAX_ASSETS].to_vec(), + vec!["批量".to_string()], + ) + .expect("exactly the batch limit must succeed"); + assert_eq!(boundary.assets.len(), ASSET_BATCH_TAG_MAX_ASSETS); + assert_eq!(boundary.committed_project_revision, revision_before + 1); + assert_eq!(boundary.assets[0].tags, vec!["批量"]); + assert_eq!( + batch_tag_manifest_entry(&root, &ids[ASSET_BATCH_TAG_MAX_ASSETS]).tags, + Vec::::new(), + "第 201 个素材不在本批范围内,不得被写入" + ); + fs::remove_dir_all(root).ok(); +} + +/// 项目身份与 revision CAS 沿用单素材口径:身份不符 / 版本冲突都在写之前失败。 +#[test] +fn batch_tags_keeps_project_identity_and_revision_cas() { + let (root, ids) = + project_with_batch_tag_assets("batch-cas", &[("assets/hero.png", "image", "image/png")]); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + let bytes_before = batch_tag_manifest_bytes(&root); + + let identity_error = add_manifest_asset_tags_at( + &root, + "another-project", + revision_before, + ids.clone(), + vec!["新标签".to_string()], + ) + .expect_err("a foreign project identity must fail closed"); + assert_eq!(identity_error, "project-identity-conflict"); + + let revision_error = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before + 1, + ids.clone(), + vec!["新标签".to_string()], + ) + .expect_err("a stale revision must fail closed"); + assert_eq!(revision_error, "project-revision-conflict"); + + assert_eq!(batch_tag_manifest_bytes(&root), bytes_before); + assert_eq!(batch_tag_revision(&root), revision_before); + assert!(batch_tag_audit_records(&root).is_empty()); + fs::remove_dir_all(root).ok(); +} + +/// DTO 必须 camelCase 且拒绝未知字段。 +#[test] +fn batch_tags_input_rejects_unknown_fields() { + let parsed = serde_json::from_value::(serde_json::json!({ + "projectPath": "C:/project", + "expectedProjectId": "project", + "expectedProjectRevision": 1, + "assetIds": ["asset-1"], + "tags": ["标签"], + })) + .expect("camelCase payload must deserialize"); + assert_eq!(parsed.asset_ids, vec!["asset-1"]); + assert_eq!(parsed.tags, vec!["标签"]); + + let rejected = serde_json::from_value::(serde_json::json!({ + "projectPath": "C:/project", + "expectedProjectId": "project", + "expectedProjectRevision": 1, + "assetIds": ["asset-1"], + "tags": ["标签"], + "unexpected": true, + })); + assert!(rejected.is_err()); +} + +/// 权限门面:`asset.register` 被拒绝时命令整体失败,manifest、revision 与审计都不动。 +#[test] +fn batch_tags_command_requires_asset_register_permission() { + let (root, ids) = project_with_batch_tag_assets( + "batch-permission", + &[("assets/hero.png", "image", "image/png")], + ); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + let bytes_before = batch_tag_manifest_bytes(&root); + + let mut policy = crate::ProjectPermissionPolicy::default(); + policy.denied_commands.push("asset.register".to_string()); + write_project_permission_policy_at(&root, policy).expect("write permission policy"); + + let error = + crate::commands::add_local_project_resource_tags(AddLocalProjectResourceTagsInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: project_id.clone(), + expected_project_revision: revision_before, + asset_ids: ids.clone(), + tags: vec!["新标签".to_string()], + }) + .expect_err("a denied asset.register must fail closed"); + + assert_eq!(error, "项目权限策略拒绝执行:asset.register"); + assert_eq!(batch_tag_manifest_bytes(&root), bytes_before); + assert_eq!(batch_tag_revision(&root), revision_before); + assert!(batch_tag_audit_records(&root).is_empty()); + fs::remove_dir_all(root).ok(); +} + +/// 审计失败必须照实报「整批已写入」,不谎称回滚、也不留下假审计。 +#[test] +fn batch_tags_audit_failure_reports_written_batch() { + let (root, ids) = project_with_batch_tag_assets( + "batch-audit-failure", + &[ + ("assets/hero.png", "image", "image/png"), + ("assets/theme.mp3", "background-music", "audio/mpeg"), + ], + ); + let project_id = batch_tag_project_id(&root); + let revision_before = batch_tag_revision(&root); + fs::write( + root.join(".agent/runtime/test-fail-next-agent-db-record"), + ASSET_BATCH_TAG_AUDIT_RECORD_TYPE, + ) + .expect("write audit failure injection marker"); + + let error = add_manifest_asset_tags_at( + &root, + &project_id, + revision_before, + ids.clone(), + vec!["新标签".to_string()], + ) + .expect_err("an audit append failure must surface to the caller"); + + assert!(error.contains("已写入"), "unexpected error: {error}"); + assert!(error.contains("审计记录失败"), "unexpected error: {error}"); + assert!(batch_tag_audit_records(&root).is_empty()); + for asset_id in &ids { + assert_eq!( + batch_tag_manifest_entry(&root, asset_id).tags, + vec!["新标签"], + "manifest 是权威真相:已写入就必须能读回来,不伪报 rollback" + ); + } + assert_eq!( + batch_tag_revision(&root), + revision_before, + "审计失败发生在 revision 推进之前,照既有语义不推进" + ); + fs::remove_dir_all(root).ok(); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs index ee2cea8dc..6b1e7b8b9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -880,12 +880,9 @@ pub(crate) async fn create_automatic_local_game_project_from_template( template_version: String, name: Option, planning: Option, + projects_root: Option, ) -> Result { - let projects_root = app - .path() - .app_data_dir() - .map(|root| root.join("projects")) - .map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))?; + let projects_root = crate::resolve_game_project_creation_root(&app, projects_root.as_deref())?; let cache_root = template_cache_root(&app)?; ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?; let record = 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 39314901e..00c8e9fbe 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 @@ -390,6 +390,78 @@ fn canonical_visual_completion_requires_persisted_route_kind_and_current_spec_re .unwrap_or_else(|error| panic!("{task_id} provenance should pass: {error}")); } + // 规范图与派生素材都允许用户参考(同一项目已登记的图片素材):参考只是风格输入, + // 规范身份仍由参考序列第一项承担,多出的用户参考不能让校验失败。 + let art_spec = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/art-spec.png") + .expect("art spec asset"); + art_spec.source.reference_resource_ids = vec!["user-reference-1".to_string()]; + let ui = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/ui-prototype.png") + .expect("ui prototype asset"); + ui.source.reference_resource_ids = vec![ + "resource-icon-spec".to_string(), + "user-reference-1".to_string(), + ]; + for task_id in ["art-director", "design-foundation"] { + validate_manifest_required_visual_asset(&root, &manifest, task_id) + .unwrap_or_else(|error| panic!("{task_id} must accept user references: {error}")); + } + + // 用户参考不能顶替规范图:首项不是当前规范引用时仍必须失败关闭。 + let ui = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/ui-prototype.png") + .expect("ui prototype asset"); + ui.source.reference_resource_ids = vec![ + "user-reference-1".to_string(), + "resource-icon-spec".to_string(), + ]; + assert!( + validate_manifest_required_visual_asset(&root, &manifest, "design-foundation") + .expect_err("a leading user reference must not replace the canonical spec") + .contains("未绑定当前统一视觉规范图的本地内容身份") + ); + + // 只接受单规范引用的图集不接受额外用户参考。 + let ui = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/ui-prototype.png") + .expect("ui prototype asset"); + ui.source.reference_resource_ids = vec!["resource-icon-spec".to_string()]; + let spritesheet = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/art-spritesheet.png") + .expect("art spritesheet asset"); + spritesheet.source.reference_resource_ids = vec![ + "resource-icon-spec".to_string(), + "user-reference-1".to_string(), + ]; + assert!( + validate_manifest_required_visual_asset(&root, &manifest, "art-asset-plan") + .expect_err("art spritesheet must reject extra user references") + .contains("未精确引用当前统一视觉规范图") + ); + let spritesheet = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/art-spritesheet.png") + .expect("art spritesheet asset"); + spritesheet.source.reference_resource_ids = vec!["resource-icon-spec".to_string()]; + let art_spec = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/art-spec.png") + .expect("art spec asset"); + art_spec.source.reference_resource_ids = Vec::new(); + let art_spec_path = root.join("assets/art-spec.png"); let valid_art_spec = fs::read(&art_spec_path).expect("read valid art spec fixture"); fs::write(&art_spec_path, &valid_art_spec[..valid_art_spec.len() / 2]) @@ -1050,6 +1122,13 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { "canvas-project-1", ); } + if root.join("assets/user-reference.png").is_file() { + bind_canvas_visual_asset_fixture_to_current_editor( + root, + "assets/user-reference.png", + "canvas-project-1", + ); + } request_platform_art_asset_with_options_for_test(root, "原创贪吃蛇视觉", &options) .await .expect("prepare canonical visual request"); @@ -1114,6 +1193,8 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, screen_color: None, }, ) @@ -1122,9 +1203,402 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { assert!(ui_request.contains(r#""kind":"ui-design""#)); assert!(ui_request.contains(r#""referenceImageSrcs":["resource-icon-spec"]"#)); + // 用户参考只接受当前项目已登记图片素材 id,并换成当前账号绑定下的远端资源 ID。 + register_canvas_visual_asset_fixture( + &root, + "assets/user-reference.png", + GameCreationAppAssetKind::Image, + ); + let user_reference_asset_id = manifest_asset_id(&root, "assets/user-reference.png"); + let icon_spec_config_dir = unique_project_path(); + let icon_spec_with_reference = capture_generation_request( + &root, + &icon_spec_config_dir, + PlatformArtAssetGenerationOptions { + output_path: Some("assets/icon-spec-custom.png".to_string()), + asset_kind: GameCreationAppAssetKind::IconSpec, + asset_label: "带用户参考的图标规范".to_string(), + reference_asset_ids: vec![ + user_reference_asset_id.clone(), + user_reference_asset_id.clone(), + ], + ..PlatformArtAssetGenerationOptions::default() + }, + ) + .await; + assert!(icon_spec_with_reference.starts_with("POST /api/editor/images/generations ")); + // 图标规范没有规范前置:用户参考原样提交,且不带任何伪造的规范引用。 + assert!( + icon_spec_with_reference.contains(r#""referenceImageSrcs":["resource-image"]"#), + "{icon_spec_with_reference}" + ); + + let ui_with_reference_config_dir = unique_project_path(); + let ui_with_reference = capture_generation_request( + &root, + &ui_with_reference_config_dir, + PlatformArtAssetGenerationOptions { + output_path: Some("assets/ui-prototype-custom.png".to_string()), + aspect_ratio: "16:9".to_string(), + image_size: "2K".to_string(), + asset_kind: GameCreationAppAssetKind::UiDesign, + asset_label: "带用户参考的界面原型图".to_string(), + reference_asset_ids: vec![user_reference_asset_id.clone()], + ..PlatformArtAssetGenerationOptions::default() + }, + ) + .await; + // 有规范前置的生成:规范图始终是第一项,用户参考按给出顺序追加在后。 + assert!( + ui_with_reference + .contains(r#""referenceImageSrcs":["resource-icon-spec","resource-image"]"#), + "{ui_with_reference}" + ); + fs::remove_dir_all(root).ok(); fs::remove_dir_all(spec_config_dir).ok(); fs::remove_dir_all(ui_config_dir).ok(); + fs::remove_dir_all(icon_spec_config_dir).ok(); + fs::remove_dir_all(ui_with_reference_config_dir).ok(); +} + +/// 参考上传凭证请求:同一路由在 External v1 与平台会话下会落到两种前缀,两边的写操作都要看住。 +fn is_reference_upload_ticket_request(request: &str) -> bool { + request.starts_with("POST /api/assets/direct-upload-tickets ") + || request.starts_with("POST /api/external/v1/assets/direct-upload-tickets ") +} + +/// 图片生成提交:同样两种前缀都要算。 +fn is_image_generation_request(request: &str) -> bool { + request.starts_with("POST /api/editor/images/generations ") + || request.starts_with("POST /api/external/v1/editor/images/generations ") + || request.starts_with("POST /api/editor/icon-spritesheets/generations ") + || request.starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ") +} + +/// 当前项目清单里某条素材的 manifest 资产 id(参考选择只接受这个身份)。 +fn manifest_asset_id(root: &Path, local_path: &str) -> String { + read_manifest_for_project(root) + .expect("read manifest for asset id") + .assets + .into_iter() + .find(|asset| asset.local_path == local_path) + .unwrap_or_else(|| panic!("registered asset is present: {local_path}")) + .id +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reference_selection_rejects_unsupported_inputs_before_any_generation_post() { + let root = unique_project_path(); + let config_dir = unique_project_path(); + let (request_sender, request_receiver) = mpsc::channel(); + // 这条用例要连续跑十几次「画布上下文 + 参考预检」,超过默认 20 次请求预算会被判成 502。 + let canvas_base_url = + spawn_mock_external_canvas_api_server_with_capture(200, Some(request_sender)); + let _platform_session = crate::platform_session::install_test_platform_session( + "reference-guard-user", + "editor-runtime-key", + &canvas_base_url, + ); + fs::create_dir_all(&config_dir).expect("create reference guard config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { "baseUrl": canvas_base_url, "apiKey": "editor-runtime-key" } + }) + .to_string(), + ) + .expect("write reference guard config"); + let _config_guard = use_test_runtime_config_dir(config_dir.clone()); + init_local_game_project_at(&root, "reference-guard", "参考素材门禁") + .expect("init reference guard project"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow reference guard generation"); + let options = |asset_kind: GameCreationAppAssetKind, reference_asset_ids: Vec| { + PlatformArtAssetGenerationOptions { + output_path: Some(format!("assets/reference-guard-{asset_kind}.png")), + asset_kind, + asset_label: "参考素材门禁".to_string(), + reference_asset_ids, + ..PlatformArtAssetGenerationOptions::default() + } + }; + async fn reject(root: &Path, options: PlatformArtAssetGenerationOptions) -> String { + request_platform_art_asset_with_options_for_test(root, "参考素材门禁", &options) + .await + .expect_err("reference selection must fail closed") + } + let canvas_source = || GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }; + + // 只接受单规范引用的图集不接受用户参考,且必须明确拒绝而不是静默丢弃。 + let error = reject( + &root, + options("art-spritesheet", vec!["user-asset".to_string()]), + ) + .await; + assert!(error.contains("透明美术图集只接受规范图引用"), "{error}"); + + // 路径、URL 等形状不是素材身份。 + for rejected in [ + "assets/hero.png", + "..\\hero.png", + "https://example.com/hero.png", + ] { + let error = reject(&root, options("icon-spec", vec![rejected.to_string()])).await; + assert!(error.contains("不接受路径或远端资源 ID"), "{error}"); + } + + // 未登记的 id 不能冒充当前项目素材(历史账号的远端资源 ID 也不在此列)。 + let error = reject( + &root, + options( + "icon-spec", + vec!["editor-resource-from-older-account".to_string()], + ), + ) + .await; + assert!( + error.contains("参考素材不在当前项目已登记清单中"), + "{error}" + ); + + // 非图片素材不能当参考。 + fs::create_dir_all(root.join("assets")).expect("create reference guard asset dir"); + fs::write(root.join("assets/document.json"), b"{}").expect("write non image reference fixture"); + register_local_asset_at( + &root, + "assets/document.json", + GameCreationAppAssetKind::Image, + "application/json", + "canvas", + canvas_source(), + ) + .expect("register non image reference fixture"); + let error = reject( + &root, + options( + "icon-spec", + vec![manifest_asset_id(&root, "assets/document.json")], + ), + ) + .await; + assert!(error.contains("参考素材必须是图片"), "{error}"); + + // 已登记但本地文件缺失的素材不能被引用。 + register_canvas_visual_asset_fixture( + &root, + "assets/missing-reference.png", + GameCreationAppAssetKind::Image, + ); + let missing_asset_id = manifest_asset_id(&root, "assets/missing-reference.png"); + fs::remove_file(root.join("assets/missing-reference.png")) + .expect("remove missing reference fixture file"); + let error = reject(&root, options("icon-spec", vec![missing_asset_id])).await; + assert!(error.contains("不存在;请重新登记后再引用"), "{error}"); + + // 超过总上限:无规范前置最多 5 张。 + let error = reject( + &root, + options( + "icon-spec", + (0..6).map(|index| format!("reference-{index}")).collect(), + ), + ) + .await; + assert!(error.contains("普通图片生成最多 5 张参考素材"), "{error}"); + + // SVG 与坏图同样必须在提交前失败:本次不做 SVG 转换,也不允许把「已登记」当成可解码。 + register_canvas_visual_asset_fixture( + &root, + "assets/plain-reference.png", + GameCreationAppAssetKind::Image, + ); + let plain_reference_asset_id = manifest_asset_id(&root, "assets/plain-reference.png"); + fs::write( + root.join("assets/vector-reference.svg"), + b"", + ) + .expect("write svg reference fixture"); + register_local_asset_at( + &root, + "assets/vector-reference.svg", + GameCreationAppAssetKind::Image, + "image/svg+xml", + "canvas", + canvas_source(), + ) + .expect("register svg reference fixture"); + let vector_asset_id = manifest_asset_id(&root, "assets/vector-reference.svg"); + let error = reject( + &root, + options( + "icon-spec", + vec![plain_reference_asset_id.clone(), vector_asset_id], + ), + ) + .await; + assert!(error.contains("不支持 SVG 等矢量格式"), "{error}"); + + // 声明成位图、实际是矢量扩展名的素材同样要按矢量拒绝。 + fs::write( + root.join("assets/mislabeled-reference.svg"), + valid_test_png_bytes(), + ) + .expect("write mislabeled svg reference fixture"); + register_local_asset_at( + &root, + "assets/mislabeled-reference.svg", + GameCreationAppAssetKind::Image, + "image/png", + "canvas", + canvas_source(), + ) + .expect("register mislabeled svg reference fixture"); + let mislabeled_asset_id = manifest_asset_id(&root, "assets/mislabeled-reference.svg"); + let error = reject( + &root, + options( + "icon-spec", + vec![plain_reference_asset_id.clone(), mislabeled_asset_id], + ), + ) + .await; + assert!(error.contains("不支持 SVG 等矢量格式"), "{error}"); + + fs::write(root.join("assets/broken-reference.png"), b"not-a-png") + .expect("write broken reference fixture"); + register_local_asset_at( + &root, + "assets/broken-reference.png", + GameCreationAppAssetKind::Image, + "image/png", + "canvas", + canvas_source(), + ) + .expect("register broken reference fixture"); + let broken_asset_id = manifest_asset_id(&root, "assets/broken-reference.png"); + let error = reject( + &root, + options( + "icon-spec", + vec![plain_reference_asset_id.clone(), broken_asset_id], + ), + ) + .await; + assert!(error.contains("不是可解析图片"), "{error}"); + + // 以上全部在提交前失败:没有生成 POST,也没有任何参考上传凭证被签发。 + // 合格参考故意不做 binding:旧路径会先为它签发凭证,所以这条断言对半完成上传有实际约束。 + while let Ok(request) = request_receiver.recv_timeout(Duration::from_millis(200)) { + assert!(!is_image_generation_request(&request), "{request}"); + assert!(!is_reference_upload_ticket_request(&request), "{request}"); + } + + // 反证:同一张合格参考单独提交时确实会去签发上传凭证,说明上面的「零上传」不是空断言。 + let differential = request_platform_art_asset_with_options_for_test( + &root, + "参考素材门禁", + &options("icon-spec", vec![plain_reference_asset_id.clone()]), + ) + .await; + let mut upload_ticket_attempted = false; + while let Ok(request) = request_receiver.recv_timeout(Duration::from_millis(300)) { + if is_reference_upload_ticket_request(&request) { + upload_ticket_attempted = true; + } + } + assert!( + upload_ticket_attempted, + "the same single reference must attempt an upload ticket: {differential:?}" + ); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reference_upload_permission_gate_fails_closed_before_any_upload() { + let root = unique_project_path(); + let config_dir = unique_project_path(); + let (request_sender, request_receiver) = mpsc::channel(); + let canvas_base_url = spawn_mock_external_canvas_generation_api_server(Some(request_sender)); + let _platform_session = crate::platform_session::install_test_platform_session( + "reference-permission-user", + "editor-runtime-key", + &canvas_base_url, + ); + fs::create_dir_all(&config_dir).expect("create reference permission config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { "baseUrl": canvas_base_url, "apiKey": "editor-runtime-key" } + }) + .to_string(), + ) + .expect("write reference permission config"); + let _config_guard = use_test_runtime_config_dir(config_dir.clone()); + init_local_game_project_at(&root, "reference-permission", "参考上传门禁") + .expect("init reference permission project"); + // 只拒绝 asset.upload:参考素材要上传到平台账号,这个门禁必须在本地失败关闭。 + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["asset.upload".to_string()], + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("deny reference upload"); + register_canvas_visual_asset_fixture( + &root, + "assets/plain-reference.png", + GameCreationAppAssetKind::Image, + ); + let plain_reference_asset_id = manifest_asset_id(&root, "assets/plain-reference.png"); + + let error = request_platform_art_asset_with_options_for_test( + &root, + "参考素材门禁", + &PlatformArtAssetGenerationOptions { + output_path: Some("assets/reference-permission.png".to_string()), + asset_kind: GameCreationAppAssetKind::IconSpec, + asset_label: "参考素材门禁".to_string(), + reference_asset_ids: vec![plain_reference_asset_id], + ..PlatformArtAssetGenerationOptions::default() + }, + ) + .await + .expect_err("a denied asset.upload must fail closed"); + assert!( + error.contains("项目权限策略拒绝执行:asset.upload"), + "{error}" + ); + + while let Ok(request) = request_receiver.recv_timeout(Duration::from_millis(200)) { + assert!(!is_reference_upload_ticket_request(&request), "{request}"); + assert!(!is_image_generation_request(&request), "{request}"); + } + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -1573,6 +2047,63 @@ fn automatic_local_game_project_allocates_unique_initialized_workspaces() { fs::remove_dir_all(projects_root).ok(); } +#[test] +fn requested_project_creation_root_accepts_only_an_absolute_regular_directory() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("create creation-root fixture"); + let not_a_directory = root.join("not-a-directory.txt"); + fs::write(¬_a_directory, b"x").expect("write file fixture"); + + assert_eq!( + validate_requested_game_project_creation_root(" ").expect_err("blank root is rejected"), + "项目创建目录必须是绝对路径" + ); + assert_eq!( + validate_requested_game_project_creation_root("relative/projects") + .expect_err("relative root is rejected"), + "项目创建目录必须是绝对路径" + ); + assert_eq!( + validate_requested_game_project_creation_root(&format!("{}\\pro\nject", root.display())) + .expect_err("control character is rejected"), + "项目创建目录不能包含控制字符" + ); + assert_eq!( + validate_requested_game_project_creation_root(¬_a_directory.to_string_lossy()) + .expect_err("file root is rejected"), + "项目创建目录必须是普通文件夹" + ); + assert_eq!( + validate_requested_game_project_creation_root(&format!(" {}\n", root.display())) + .expect("trimmed directory root is accepted"), + root + ); + assert!( + validate_requested_game_project_creation_root(&root.join("missing").to_string_lossy()) + .is_err(), + "a not-yet-existing creation root must fail instead of being created silently" + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn automatic_local_game_project_creates_inside_the_requested_creation_root() { + let projects_root = unique_project_path(); + fs::create_dir_all(&projects_root).expect("create creation-root fixture"); + + let requested = validate_requested_game_project_creation_root(&projects_root.to_string_lossy()) + .expect("valid creation root"); + let result = create_automatic_local_game_project_at(&requested, None, false) + .expect("create workspace in requested root"); + + let project_root = PathBuf::from(&result.project_path); + assert_eq!(project_root.parent(), Some(projects_root.as_path())); + assert!(project_root.join(".agent/manifest.json").is_file()); + + fs::remove_dir_all(projects_root).ok(); +} + #[test] fn automatic_local_game_project_accepts_only_a_safe_custom_name() { let projects_root = unique_project_path(); @@ -2733,6 +3264,8 @@ async fn generate_local_project_asset_command_registers_the_requested_toolbar_ki Some("2K".to_string()), Some("工具栏图片".to_string()), None, + None, + None, ) .await .expect("toolbar asset generation"); @@ -2765,6 +3298,99 @@ async fn generate_local_project_asset_command_registers_the_requested_toolbar_ki fs::remove_dir_all(config_dir).ok(); } +#[tokio::test] +async fn generate_local_project_asset_command_lands_the_requested_target_category() { + // 入口栏目与生成 kind 不是同一套词汇:kind=image 按 kind 派生只会落到 unclassified, + // 必须靠 targetCategory 才能落回入口栏目,否则生成完成后占位无法被原位接管。 + let root = unique_project_path(); + let config_dir = unique_project_path(); + let (request_sender, request_receiver) = mpsc::channel(); + let canvas_base_url = spawn_mock_external_canvas_generation_api_server(Some(request_sender)); + let _platform_session = crate::platform_session::install_test_platform_session( + "toolbar-category-user", + "editor-toolbar-category-key", + &canvas_base_url, + ); + fs::create_dir_all(&config_dir).expect("create runtime config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { "baseUrl": canvas_base_url, "apiKey": "editor-toolbar-category-key" } + }) + .to_string(), + ) + .expect("write runtime config"); + let _guard = use_test_runtime_config_dir(config_dir.clone()); + init_local_game_project_at(&root, "project-toolbar-category", "未命名游戏原型") + .expect("init project"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow generation"); + + let asset = generate_local_project_asset( + root.to_string_lossy().into_owned(), + "image".to_string(), + "像素月光厨房主角".to_string(), + Some("1:1".to_string()), + Some("1K".to_string()), + Some("主角图".to_string()), + None, + None, + Some("character".to_string()), + ) + .await + .expect("target category generation"); + let manifest: Value = + serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) + .expect("manifest json"); + let entry = manifest["assets"] + .as_array() + .expect("manifest assets") + .iter() + .find(|entry| entry["id"].as_str() == Some(asset.id.as_str())) + .expect("registered asset with target category"); + // kind 仍是请求的生成 kind,栏目取显式目标分类,而不是 kind 派生的 unclassified。 + assert_eq!(entry["kind"], "image"); + assert_eq!(entry["category"], "character"); + + // 先排掉这一次生成自己的请求,再验证非法栏目值不会带来任何新的生成请求。 + while request_receiver + .recv_timeout(Duration::from_millis(200)) + .is_ok() + {} + for rejected in ["version", "all", "bogus"] { + let error = generate_local_project_asset( + root.to_string_lossy().into_owned(), + "image".to_string(), + "像素月光厨房主角".to_string(), + None, + None, + None, + None, + None, + Some(rejected.to_string()), + ) + .await + .expect_err("illegal target category must fail closed"); + assert!( + error.contains("目标分类不是合法素材分类"), + "{rejected}: {error}" + ); + } + while let Ok(request) = request_receiver.recv_timeout(Duration::from_millis(200)) { + assert!(!is_image_generation_request(&request), "{request}"); + } + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); +} + #[tokio::test] async fn generate_local_project_asset_command_maps_spec_onto_the_verified_icon_spec_channel() { let root = unique_project_path(); @@ -2799,6 +3425,8 @@ async fn generate_local_project_asset_command_maps_spec_onto_the_verified_icon_s None, Some("视觉规范图".to_string()), None, + None, + None, ) .await .expect("toolbar spec generation"); @@ -2865,6 +3493,8 @@ async fn generate_local_project_asset_command_generates_art_spritesheet_from_the None, None, None, + None, + None, ) .await .expect_err("art-spritesheet requires a registered icon-spec"); @@ -2892,6 +3522,8 @@ async fn generate_local_project_asset_command_generates_art_spritesheet_from_the Some("1K".to_string()), Some("游戏首版图集".to_string()), None, + None, + None, ) .await .expect("toolbar spritesheet generation"); @@ -5640,6 +6272,8 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { slice_mode: None, grid_x: None, grid_y: None, + reference_asset_ids: Vec::new(), + target_category: None, screen_color: None, }; let prompt = build_platform_art_asset_prompt( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index dfa7b09c7..dca1fec8e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -6927,6 +6927,22 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() { "sliceCount" ]) ); + let canvas_properties = &canvas_asset.parameters["properties"]["input"]["properties"]; + assert_eq!( + canvas_properties["sliceMode"]["enum"], + serde_json::json!(["connected-components", "grid", null]) + ); + for field in ["gridX", "gridY", "sliceCount"] { + assert_eq!( + canvas_properties[field]["type"], + serde_json::json!(["integer", "null"]) + ); + assert_eq!(canvas_properties[field]["minimum"], 1); + assert_eq!( + canvas_properties[field]["maximum"], + if field == "sliceCount" { 256 } else { 32 } + ); + } assert_eq!( canvas_asset.parameters["properties"]["input"]["properties"]["aspectRatio"]["enum"], serde_json::json!(["1:1", "2:3", "3:2", "9:16", "16:9", null]) 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 2c8833ab9..f52b347b1 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "陶泥儿", - "version": "0.1.47", + "version": "0.1.67", "identifier": "world.genarrative.ai-game-creator", "build": { "beforeDevCommand": "npm --prefix ../.. run agc:serve", diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.macos.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.macos.conf.json new file mode 100644 index 000000000..67695ad22 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/tauri.macos.conf.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "macOS": { + "minimumSystemVersion": "15.0" + }, + "resources": { + "resources/codex/mac-native/bin/codex": "coding-agent/mac-native/bin/codex", + "resources/codex/mac-native/bin/codex-code-mode-host": "coding-agent/mac-native/bin/codex-code-mode-host", + "resources/codex/mac-native/codex-path/rg": "coding-agent/mac-native/codex-path/rg", + "resources/codex/mac-native/codex-resources/zsh/bin/zsh": "coding-agent/mac-native/codex-resources/zsh/bin/zsh", + "resources/codex/mac-native/codex-package.json": "coding-agent/mac-native/codex-package.json", + "resources/codex/mac-native/NOTICE.md": "coding-agent/mac-native/NOTICE.md", + "resources/codex/mac-native/manifest.json": "coding-agent/mac-native/manifest.json", + "resources/plugins": "plugins" + } + }, + "plugins": { + "updater": { + "endpoints": [ + "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/latest.json" + ] + } + } +} diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index e6db6bacf..caf12c2a5 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -243,7 +243,9 @@ import { readDirectHistoryPages } from './features/project-workspace/directHisto import { applyDirectThreadConsumeResult, type DirectThreadChatState, + directThreadTurnMatchesUser, emptyDirectThreadChatState, + finishDirectThreadTurn, mergeDirectHistoryItems, resolveDirectThreadBootstrap, selectDirectChatEntries, @@ -303,7 +305,10 @@ import { currentPlatformSessionGeneration, requestPlatformSessionRefresh, } from './services/platformSession'; -import { setAgcPluginProjectPath, startAgcPlugin } from './services/pluginHost'; +import { + setAgcPluginProjectPath, + startAvailableAgcPlugin, +} from './services/pluginHost'; import { canSubscribeTauriEvents, subscribeTauriEvent, @@ -614,7 +619,7 @@ export function App({ void setAgcPluginProjectPath(nextProjectPath) .then(async () => { if (workspaceProjectKind === 'cocos' && nextProjectPath) { - await startAgcPlugin('agc-cocos-editor'); + await startAvailableAgcPlugin('agc-cocos-editor'); } }) .catch((error) => { @@ -12198,8 +12203,19 @@ export function App({ const message = result?.message?.trim(); if (result?.outcome === 'released') { // 这一轮已经没有人替它收尾(执行进程已退出 / 从没进执行器),Rust 侧强制释放了 - // 守卫并补了终态事件;这里同步把界面复位,不等 IPC 通知。 - setDirectThreadChat((state) => ({ ...state, turnRunning: false })); + // 守卫并补了终态事件;这里按同一个收口函数同步把界面复位,不等 IPC 通知。 + // 时刻取宿主观测到的这一刻:终止返回就是这一轮的终态,原生随后补的事件若先到, + // 收口已经是冻结值,不会被抬高,也不会复活成"永远运行中"。 + // 但取消回包可能晚于新回合的开始:先核对身份(clientTurnId 对应的本轮用户条目), + // 只收口确实是这一轮的那一次,避免在新回合的回调里把旧轮的时间盖上来。 + const cancelledUserItemId = result.clientTurnId + ? directCodexConversationMessageId(result.clientTurnId, 'user') + : ''; + setDirectThreadChat((state) => + directThreadTurnMatchesUser(state, cancelledUserItemId) + ? finishDirectThreadTurn(state, Date.now()) + : state, + ); setChatAgentBusy(false); setProjectSupervisorRuntimeError(''); setChatComposerNotice( @@ -12409,6 +12425,7 @@ export function App({ chatProjectAssets={chatProjectAssets} directCodex={directCodexProductRuntime} directTurnRunning={directCodexProductRuntime && directTurnRunning} + directTurnStartedAt={directThreadChat.turnStartedAt} directEntries={ directCodexProductRuntime ? selectDirectChatEntries(directThreadChat) diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index 90ace801c..99f11e3ea 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -17,10 +17,15 @@ import type { ProjectAgentRuntimeSummary, } from '../../view/project-development'; import type { ProjectManifestSnapshotMetadata } from '../../view/project-development/projectResourceLiveUpdateModel'; -import { isAbsoluteProjectPath } from '../project-summary/projectSummary'; +import { + isAbsoluteProjectPath, + projectPathHasControlCharacter, +} from '../project-summary/projectSummary'; const RECENT_WORKSPACES_STORAGE_KEY = 'genarrative-ai-game-creator.recent-workspaces.v1'; +const PROJECT_CREATION_DIRECTORY_STORAGE_KEY = + 'genarrative-ai-game-creator.project-creation-directory.v1'; const SUPERVISOR_CHAT_DRAFT_STORAGE_PREFIX = 'genarrative.supervisor-chat.draft'; @@ -156,6 +161,57 @@ export function removeRecentWorkspace(path: string) { return recent; } +/** + * 「项目创建目录」偏好:空串表示沿用 AGC 管理的默认位置(应用数据目录下的 projects)。 + * + * 这里只保存用户意图,不是授权凭据:目录授权来自原生目录选择器,并由 Rust 侧私有路径门禁 + * 在每次建项时重新复核,所以存储被改坏最坏只是退回默认位置或拿到一次可见的建项失败。 + */ +export function normalizeProjectCreationDirectory(value: string) { + const trimmed = value.trim(); + if (!trimmed || projectPathHasControlCharacter(trimmed)) { + return ''; + } + const withoutTrailingSeparator = trimmed.replace(/[\\/]+$/, ''); + // `C:\` 这类盘根只去掉分隔符会变成相对路径 `C:`,必须补回来。 + return /^[a-zA-Z]:$/.test(withoutTrailingSeparator) + ? `${withoutTrailingSeparator}\\` + : withoutTrailingSeparator; +} + +export function readProjectCreationDirectory() { + try { + const raw = window.localStorage.getItem( + PROJECT_CREATION_DIRECTORY_STORAGE_KEY, + ); + const parsed: unknown = raw ? JSON.parse(raw) : ''; + if (typeof parsed !== 'string') { + return ''; + } + const directory = normalizeProjectCreationDirectory(parsed); + return isAbsoluteProjectPath(directory) ? directory : ''; + } catch { + return ''; + } +} + +export function writeProjectCreationDirectory(path: string) { + const directory = normalizeProjectCreationDirectory(path); + try { + if (directory) { + window.localStorage.setItem( + PROJECT_CREATION_DIRECTORY_STORAGE_KEY, + JSON.stringify(directory), + ); + } else { + window.localStorage.removeItem(PROJECT_CREATION_DIRECTORY_STORAGE_KEY); + } + } catch { + // WebView storage can be unavailable in restricted test shells. + } + return directory; +} + export function isTransientProjectOpenMessage( message: ChatMessage, projectPath: string, diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index c07d04499..13c16f7f0 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -48,6 +48,7 @@ import { isAbsoluteProjectPath, projectPathHasControlCharacter, } from '../project-summary/projectSummary'; +import { readProjectCreationDirectory } from './model'; import { resolveSessionPreviewOnProjectOpen } from './sessionPreview'; /** 首页输入框当前的纯文本(Lexical 编辑器状态 -> 文本);没有输入就返回空串。 */ @@ -828,6 +829,9 @@ export function useHomeProjectCreation({ { name: suggestedName, planning: startMode === 'planning', + // 用户在首页选过「项目创建目录」就用它;没选传 null,由 Rust 侧回落到 + // AGC 管理的默认位置(应用数据目录下的 projects)。 + projectsRoot: readProjectCreationDirectory() || null, }, ); createdProjectPath = result.projectPath; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useProjectCreationDirectory.ts b/apps/ai-game-creator-shell/src/features/app-shell/useProjectCreationDirectory.ts new file mode 100644 index 000000000..1f8a772b3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/app-shell/useProjectCreationDirectory.ts @@ -0,0 +1,81 @@ +import { useCallback, useRef, useState } from 'react'; + +import { resolveTauriInvoke } from '../../app/tauri'; +import { + readProjectCreationDirectory, + writeProjectCreationDirectory, +} from './model'; + +/** + * 「项目创建目录」用户偏好。 + * + * 默认沿用 AGC 管理的应用数据目录(`/projects`);用户改选时必须走原生目录 + * 选择器,因为只有它构成"用户显式选择"边界:选择结果当场按用户选择范围加固,后续建项 + * 再由 Rust 侧私有路径门禁复核一次。 + */ +export function useProjectCreationDirectory() { + const [projectCreationDirectory, setProjectCreationDirectory] = useState( + readProjectCreationDirectory, + ); + const [projectCreationDirectoryBusy, setProjectCreationDirectoryBusy] = + useState(false); + const [projectCreationDirectoryStatus, setProjectCreationDirectoryStatus] = + useState(''); + const pickInFlightRef = useRef(false); + + const pickProjectCreationDirectory = useCallback(async () => { + if (pickInFlightRef.current) { + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setProjectCreationDirectoryStatus('需要在陶泥儿客户端内运行'); + return; + } + pickInFlightRef.current = true; + setProjectCreationDirectoryBusy(true); + setProjectCreationDirectoryStatus('正在选择项目创建目录'); + try { + const selected = await invoke( + 'pick_local_project_directory', + { + title: '选择项目创建目录', + ...(projectCreationDirectory + ? { initialPath: projectCreationDirectory } + : {}), + }, + ); + if (!selected) { + setProjectCreationDirectoryStatus('已取消'); + return; + } + setProjectCreationDirectory(writeProjectCreationDirectory(selected)); + setProjectCreationDirectoryStatus('已更新项目创建目录'); + } catch (error) { + setProjectCreationDirectoryStatus( + error instanceof Error ? error.message : String(error), + ); + } finally { + pickInFlightRef.current = false; + setProjectCreationDirectoryBusy(false); + } + }, [projectCreationDirectory]); + + const resetProjectCreationDirectory = useCallback(() => { + writeProjectCreationDirectory(''); + setProjectCreationDirectory(''); + setProjectCreationDirectoryStatus('已恢复默认位置'); + }, []); + + return { + projectCreationDirectory, + projectCreationDirectoryBusy, + projectCreationDirectoryStatus, + pickProjectCreationDirectory, + resetProjectCreationDirectory, + }; +} + +export type ProjectCreationDirectoryController = ReturnType< + typeof useProjectCreationDirectory +>; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx index 3018200fb..1273bb017 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; +import { formatElapsedDuration } from '../../../../../packages/shared/src/lib/formatElapsedDuration'; import { resolveTauriInvoke } from '../../app/tauri'; import type { PlanGddDecisionAction, @@ -171,7 +172,7 @@ export function PlanGddStageProgress({ : `当前版本:v${latestVersion}`} {processingSeconds > 0 ? ( - {`处理耗时:${processingSeconds.toFixed(1)} 秒`} + {`处理耗时:${formatElapsedDuration(processingSeconds * 1000) ?? '—'}`} ) : null} {deliveredGdd ? ( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index dca93b24b..4a21a7550 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -1,4 +1,12 @@ -import { ArrowUp, AtSign, Loader2, Settings } from 'lucide-react'; +import { + ArrowUp, + AtSign, + ChevronDown, + Lightbulb, + Loader2, + Settings, + Wrench, +} from 'lucide-react'; import type { ComponentProps, FormEventHandler, @@ -12,6 +20,7 @@ import { AgentMessageContent, type AgentMessageTone, } from '../../../../../packages/shared/src/components/AgentMessageContent'; +import { AgentProcessSummary } from '../../../../../packages/shared/src/components/AgentProcessSummary'; import type { AgentStatusCard, ChatMessage, @@ -41,6 +50,7 @@ import { import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments'; import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation'; import { taskStatusLabels } from '../project-summary/projectSummary'; +import { agentProcessPreview } from './agentProcessPreview'; import type { QueuedChatTurn } from './chatComposerQueue'; import { ComposerPendingAttachments, @@ -81,8 +91,29 @@ import type { ChatComposerDraft, ChatReference } from './resourceReferences'; import { ToolCallGroup } from './ToolCallGroup'; import { formatClockTime, - formatTurnDuration, + resolveToolGroupTiming, + resolveTurnTiming, } from './toolCallGroupPresentation'; +import { useLiveNow } from './useLiveNow'; + +/** + * 运行中整轮总耗时:自带 100ms 时钟的小叶子。 + * + * 时钟只驱动这一行文字(`useLiveNow`),不带着整个对话面板每 100ms 重建。 + * 起点拿不到时不渲染:不编造不能证明的耗时。 + */ +function TurnElapsedTotal({ startedAt }: { startedAt: number }) { + const now = useLiveNow(startedAt > 0); + const timing = resolveTurnTiming({ startedAt, running: true, now }); + if (!timing.durationText) { + return null; + } + return ( + + {`总耗时 ${timing.durationText}`} + + ); +} /** 当前 Agent 和策划 Agent 的实时/历史思考使用同一个折叠入口。 */ function AgentReasoning({ @@ -94,6 +125,9 @@ function AgentReasoning({ label?: string; testId?: string; }) { + const [expanded, setExpanded] = useState(false); + // 折叠态:单行纯文本预览(走 Markdown AST 取文字,链接只留字面文字、不含目标)。 + const preview = agentProcessPreview(text); return ( + setExpanded((event.currentTarget as HTMLDetailsElement).open) + } > - 思考过程 -
{text}
+ + + {/* 展开态复用助手正文的安全 Markdown 链路(内部 skipHtml,不用 innerHTML)。 */} +
); } @@ -125,6 +169,8 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { directEntries?: DirectChatEntry[]; /** 最新回合是否还在跑;只由生命周期事件决定。 */ directTurnRunning?: boolean; + /** 最新回合的原生起点(`turn.started.at`):用户发送时间缺失时兜底,0 = 缺失。 */ + directTurnStartedAt?: number; hiddenConversationCount: number; hasEarlierConversationMessages?: boolean; messagesRef: RefObject; @@ -195,6 +241,7 @@ export function ProjectSupervisorView({ directCodex = false, directEntries = [], directTurnRunning = false, + directTurnStartedAt = 0, hiddenConversationCount, hasEarlierConversationMessages = false, messagesRef, @@ -246,8 +293,6 @@ export function ProjectSupervisorView({ const planningSurfaceActive = planningLane || isPlanningLaneRuntime(runtimePanelProps.runtime); const [settingsOpen, setSettingsOpen] = useState(false); - // 整轮会话的耗时在回合进行中要每秒刷新:用 tick 驱动的 `now` 计算"现在 - 开始"。 - const [turnUsageNow, setTurnUsageNow] = useState(() => Date.now()); // 语音输入的降级/失败提示:不支持时按钮本身就带提示,这里只承载启动失败与权限类错误。 const [voiceNotice, setVoiceNotice] = useState(''); const [approvalOpen, setApprovalOpen] = useState(false); @@ -267,41 +312,65 @@ export function ProjectSupervisorView({ const [modelValidating, setModelValidating] = useState(false); const modelSelectRef = useRef(null); const modelValidateInFlightRef = useRef(false); - useEffect(() => { - if (!directTurnRunning) { - return; - } - setTurnUsageNow(Date.now()); - const timer = setInterval(() => setTurnUsageNow(Date.now()), 1000); - return () => clearInterval(timer); - }, [directTurnRunning]); const runBusy = runtimePanelProps.controlBusy || submitting; const directTurns = directCodex ? buildDirectChatTurns({ entries: directEntries, localMessages: conversationMessages, turnRunning: directTurnRunning, + turnStartedAt: directTurnStartedAt, }) : []; const activeTurnStartedAt = directTurns.find((turn) => turn.active)?.startedAt ?? 0; + const latestDirectTurn = directTurns.at(-1); + // 仅是首个响应到达前的临时提示,不创建聊天条目或第二套回合状态。 + const awaitingFirstResponse = + directCodex && + (runBusy || directTurnRunning) && + !turnCancelling && + !runtimePanelProps.error && + Boolean(latestDirectTurn?.users.length) && + latestDirectTurn?.process.length === 0 && + latestDirectTurn?.finals.length === 0; + // 初始占位气泡只是"最初那条消息还没有正式条目"时的顶位,两种情况下不再渲染: + // - 已经翻出更早的历史(`hasEarlierConversationMessages`):这里不是对话开头,不补占位; + // - Direct 模式已经有了正式用户条目:正式气泡自己会显示,占位再渲染就是同一条消息出现两次。 + // 这里按**结构**判断(存在正式用户条目),不按文本去重真实消息,也不影响合法的连续重复发送。 + const initialSupervisorText = initialSupervisorMessage.trim(); + const initialSupervisorPlaceholderSuperseded = + hasEarlierConversationMessages || + (directCodex + ? directEntries.some( + (entry) => entry.kind === 'message' && entry.role === 'user', + ) || conversationMessages.some((message) => message.role === 'user') + : conversationMessages.some( + (message) => + message.role === 'user' && + message.text.trim() === initialSupervisorText, + )); - const clockTimeWithSeconds = (timestamp: number) => { - const date = new Date(timestamp); - const pad = (value: number) => String(value).padStart(2, '0'); - return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; - }; - + /** + * 回合结束后的一行小结:时间范围与总耗时读同一组边界,两者都取不到就不渲染—— + * 旧历史没有完整边界时不猜"这一轮跑了多久"。终态不会再变,这里不用时钟。 + */ const renderTurnUsage = (turn: DirectChatTurn) => { - if (turn.active || !turn.startedAt) return null; - const endedAt = Math.max(turn.endedAt, turn.startedAt); + if (turn.active) return null; + const timing = resolveTurnTiming({ + startedAt: turn.startedAt, + endedAt: turn.endedAt, + }); + if (!timing.durationText) return null; + const endedLabel = formatClockTime(turn.endedAt, { tenths: true }); return (

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

); }; @@ -435,12 +504,7 @@ export function ProjectSupervisorView({ : '显示更早的对话'} ) : null} - {initialSupervisorMessage.trim() && - !conversationMessages.some( - (message) => - message.role === 'user' && - message.text.trim() === initialSupervisorMessage.trim(), - ) ? ( + {initialSupervisorText && !initialSupervisorPlaceholderSuperseded ? (
block.kind === 'assistant')?.key ?? null) : null; + // 外层"执行过程"汇总**这一轮全部工具调用**(不是过程段落数,也不含思考段): + // 计数 = 所有组的 calls 之和;耗时 = 首工具开始 → 末工具完成(各组的并集跨度, + // 不是整轮总耗时,也不与整轮那处重复);边界不完整时隐藏。 + const turnToolCalls = turn.process.flatMap((block) => + block.kind === 'tools' ? block.calls : [], + ); + const turnToolSpan = resolveToolGroupTiming(turnToolCalls, { + running: false, + }); const renderBlock = ( block: DirectChatBlock, tone: AgentMessageTone = 'body', @@ -465,7 +538,6 @@ export function ProjectSupervisorView({ @@ -524,7 +596,24 @@ export function ProjectSupervisorView({ className="message-turn-process" data-testid="turn-process" > - 执行过程 + +
{turn.process.map((block) => renderBlock(block, 'process'), @@ -573,6 +662,18 @@ export function ProjectSupervisorView({
) : null} + {awaitingFirstResponse ? ( + + + ) : null}
{directCodex ? null : planningSurfaceActive ? (