Compare commits

..

3 Commits

Author SHA1 Message Date
suzmii fc46cabb75 补充Mac Jenkins节点工具链路径
为LaunchAgent构建环境注入Node、npm、Cargo和Homebrew路径
2026-09-18 22:34:49 +08:00
suzmii 762f037150 修复Mac Jenkins节点工作区根目录回退
移除不兼容的节点环境变量配置依赖
在Jenkinsfile中使用专用Agent根目录默认值
避免节点分配阶段环境变量属性解析失败
2026-09-18 21:57:52 +08:00
suzmii 48985d3447 接入Mac通用构建与Jenkins归档管线
补齐macOS universal双架构Codex资源与构建校验
统一发布清单和检查脚本支持universal目标
新增锁定原生依赖完整性校验与隔离构建smoke
新增Mac Jenkins Agent归档构建Job与本机构建接入规范
2026-09-18 19:34:23 +08:00
148 changed files with 2922 additions and 4368 deletions
-3
View File
@@ -23,6 +23,3 @@
*.meta text *.meta text
*.anim text *.anim text
*.controller text *.controller text
# Rust ts-rs 生成的共享契约:保留在仓库中供 TS 消费,但不作为手写源文件统计。
packages/shared/src/contracts/generated/** linguist-generated=true
+2
View File
@@ -47,6 +47,8 @@ temp*build*/
/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/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/manifest.json
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md /apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/NOTICE.md
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/darwin-arm64/
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/darwin-x64/
/plugins/agc-cocos-editor/native/payload/ /plugins/agc-cocos-editor/native/payload/
/apps/ai-game-creator-shell/logs/ /apps/ai-game-creator-shell/logs/
/apps/ai-game-creator-shell/.llm-drafts/ /apps/ai-game-creator-shell/.llm-drafts/
+1 -10
View File
@@ -1,14 +1,5 @@
{ {
"singleQuote": true, "singleQuote": true,
"semi": true, "semi": true,
"trailingComma": "all", "trailingComma": "all"
"overrides": [
{
"files": "packages/shared/src/contracts/generated/**/*.ts",
"options": {
"printWidth": 1000,
"singleQuote": false
}
}
]
} }
@@ -676,11 +676,11 @@ async function runSelfTest() {
designFoundationAssetCall?.arguments?.input?.outputPath === designFoundationAssetCall?.arguments?.input?.outputPath ===
'assets/ui-prototype.png' && 'assets/ui-prototype.png' &&
designFoundationAssetCall.arguments.input.aspectRatio === '16:9' && designFoundationAssetCall.arguments.input.aspectRatio === '16:9' &&
designFoundationAssetCall.arguments.input.assetKind === 'ui-design' && designFoundationAssetCall.arguments.input.assetKind === 'ui-prototype' &&
artAssetPlanAssetCall?.arguments?.input?.outputPath === artAssetPlanAssetCall?.arguments?.input?.outputPath ===
'assets/art-spritesheet.png' && 'assets/art-spritesheet.png' &&
artAssetPlanAssetCall.arguments.input.aspectRatio === '1:1' && artAssetPlanAssetCall.arguments.input.aspectRatio === '1:1' &&
artAssetPlanAssetCall.arguments.input.assetKind === 'icon-spritesheet', artAssetPlanAssetCall.arguments.input.assetKind === 'art-spritesheet',
'self-test-visual-assets-invalid', 'self-test-visual-assets-invalid',
); );
@@ -70,7 +70,7 @@ const requiredFormalArtifactSpecs = [
{ path: 'game/balance.json', kind: 'json' }, { path: 'game/balance.json', kind: 'json' },
{ path: 'assets/manifest.art.json', kind: 'json' }, { path: 'assets/manifest.art.json', kind: 'json' },
{ path: 'assets/manifest.audio.json', kind: 'json' }, { path: 'assets/manifest.audio.json', kind: 'json' },
{ path: 'game/index.html', kind: 'file' }, { path: 'game/index.html', kind: 'game-entry' },
{ path: 'exports/README.md', kind: 'file' }, { path: 'exports/README.md', kind: 'file' },
]; ];
const editorImageArtifactSpecs = [ const editorImageArtifactSpecs = [
@@ -0,0 +1,113 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveReleaseContext, runTauriBuild } from './build-release.mjs';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const repoRoot = path.resolve(appRoot, '../..');
assert.equal(process.platform, 'darwin', '只能在 macOS Agent 执行');
assert.equal(
process.env.JENKINS_URL?.length > 0,
true,
'此入口仅用于 Jenkins 独立工作区',
);
assert.equal(
fs.realpathSync(process.env.WORKSPACE || '.'),
fs.realpathSync(repoRoot),
'必须在 Jenkins workspace 根目录执行',
);
const space = fs.statfsSync(repoRoot);
assert.ok(
space.bavail * space.bsize >= 8 * 1024 ** 3,
'构建前至少需要 8 GiB 可用空间;禁止自动清理开发缓存',
);
// 本入口永不发布,不使用 Agent 用户可能持有的发布或 Apple 认证环境。
for (const key of Object.keys(process.env)) {
if (/^(TAURI_SIGNING_|APPLE_|AGC_OSS_)/u.test(key)) delete process.env[key];
}
process.env.RUSTC_WRAPPER = '';
process.env.CARGO_TARGET_DIR = path.join(appRoot, 'src-tauri/target');
const context = resolveReleaseContext(['--target=universal-apple-darwin']);
const args = [
'--target=universal-apple-darwin',
'--bundles',
'app',
'--ci',
'--no-sign',
'--config',
'{"bundle":{"createUpdaterArtifacts":false}}',
];
const command = (binary, argv, options = {}) =>
execFileSync(binary, argv, { cwd: repoRoot, stdio: 'inherit', ...options });
runTauriBuild(args, context);
const app = path.join(context.bundleRoot, 'macos/陶泥儿.app');
for (const architecture of ['arm64', 'x86_64']) {
command(process.execPath, [
path.join(appRoot, 'scripts/check-macos-bundle.mjs'),
app,
architecture,
'--universal',
]);
}
const version = JSON.parse(
fs.readFileSync(path.join(appRoot, 'package.json'), 'utf8'),
).version;
assert.match(version, /^\d+\.\d+\.\d+$/u);
const artifacts = path.join(repoRoot, 'artifacts');
// 只清理本 Job 的归档输出,不能把上次 DMG 当成本次成功产物。
fs.rmSync(artifacts, { recursive: true, force: true });
fs.mkdirSync(artifacts, { recursive: true });
const dmg = path.join(artifacts, `陶泥儿_${version}_universal.dmg`);
const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'agc-ci-dmg-'));
try {
command('ditto', [app, path.join(stage, '陶泥儿.app')]);
fs.symlinkSync('/Applications', path.join(stage, 'Applications'));
command('hdiutil', [
'create',
'-volname',
'陶泥儿',
'-srcfolder',
stage,
'-format',
'UDZO',
dmg,
]);
command('hdiutil', ['verify', dmg]);
} finally {
fs.rmSync(stage, { recursive: true, force: true });
}
const hash = createHash('sha256');
for await (const chunk of fs.createReadStream(dmg)) hash.update(chunk);
fs.writeFileSync(
`${dmg}.sha256`,
`${hash.digest('hex')} ${path.basename(dmg)}\n`,
);
const commit = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: repoRoot,
encoding: 'utf8',
}).trim();
fs.writeFileSync(
path.join(artifacts, 'build-manifest.json'),
`${JSON.stringify(
{
version,
commit,
target: context.target,
channel: context.channel,
signed: false,
notarized: false,
uploaded: false,
smoke: ['arm64', 'x86_64'],
intelSmoke: process.arch === 'arm64' ? 'Rosetta' : 'native',
},
null,
2,
)}\n`,
);
console.log('[macOS CI] universal 包与校验文件已生成;未发布、未签名或公证');
@@ -42,16 +42,12 @@ function explicitBuildTarget(args) {
} }
function validateReleaseTarget(target) { function validateReleaseTarget(target) {
if (target === 'universal-apple-darwin') {
throw new Error(
'内置 Codex 资源仅支持 macOS 单架构构建,请使用 aarch64-apple-darwin 或 x86_64-apple-darwin',
);
}
if ( if (
![ ![
'x86_64-pc-windows-msvc', 'x86_64-pc-windows-msvc',
'aarch64-apple-darwin', 'aarch64-apple-darwin',
'x86_64-apple-darwin', 'x86_64-apple-darwin',
'universal-apple-darwin',
].includes(target) ].includes(target)
) { ) {
throw new Error(`不支持的发布目标:${target}`); throw new Error(`不支持的发布目标:${target}`);
@@ -197,10 +193,12 @@ export function updateManifestUrl(channel = resolveReleaseChannel()) {
} }
/** /**
* 单架构产物只登记实际目标,不能把同一原生资源映射为另一架构 * universal 主程序与双目录原生资源共用一个更新包;单架构只登记实际目标。
*/ */
export function resolveManifestPlatformKeys(target = defaultTarget()) { export function resolveManifestPlatformKeys(target = defaultTarget()) {
validateReleaseTarget(target); validateReleaseTarget(target);
if (target === 'universal-apple-darwin')
return ['darwin-aarch64', 'darwin-x86_64'];
if (target === 'aarch64-apple-darwin') return ['darwin-aarch64']; if (target === 'aarch64-apple-darwin') return ['darwin-aarch64'];
if (target === 'x86_64-apple-darwin') return ['darwin-x86_64']; if (target === 'x86_64-apple-darwin') return ['darwin-x86_64'];
if (target.includes('windows')) { if (target.includes('windows')) {
@@ -39,13 +39,12 @@ import {
const windowsTarget = 'x86_64-pc-windows-msvc'; const windowsTarget = 'x86_64-pc-windows-msvc';
const universalTarget = 'universal-apple-darwin'; const universalTarget = 'universal-apple-darwin';
test('native sidecar builds reject universal targets and accept each macOS architecture', () => { test('native sidecar builds accept universal and each macOS architecture', () => {
assert.throws(() => buildTauriBuildArguments([], universalTarget), /单架构/); for (const target of [
assert.throws( universalTarget,
() => buildTauriBuildArguments(['--target=universal-apple-darwin']), 'aarch64-apple-darwin',
/单架构/, 'x86_64-apple-darwin',
); ]) {
for (const target of ['aarch64-apple-darwin', 'x86_64-apple-darwin']) {
assert.deepEqual(buildTauriBuildArguments([], target), [ assert.deepEqual(buildTauriBuildArguments([], target), [
'build', 'build',
'--target', '--target',
@@ -152,8 +151,11 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
}); });
}); });
test('macOS manifests only advertise the architecture actually built', () => { test('macOS manifests advertise exactly the architectures actually built', () => {
assert.throws(() => resolveManifestPlatformKeys(universalTarget), /单架构/); assert.deepEqual(resolveManifestPlatformKeys(universalTarget), [
'darwin-aarch64',
'darwin-x86_64',
]);
assert.deepEqual(resolveManifestPlatformKeys('aarch64-apple-darwin'), [ assert.deepEqual(resolveManifestPlatformKeys('aarch64-apple-darwin'), [
'darwin-aarch64', 'darwin-aarch64',
]); ]);
@@ -197,7 +199,6 @@ test('release context resolves explicit targets before environment/default and f
['--target='], ['--target='],
['--target', '--no-bundle'], ['--target', '--no-bundle'],
['--target', windowsTarget, '--target=aarch64-apple-darwin'], ['--target', windowsTarget, '--target=aarch64-apple-darwin'],
['--target', universalTarget],
['--target', 'unknown'], ['--target', 'unknown'],
]) ])
assert.throws(() => resolveReleaseContext(args, {})); assert.throws(() => resolveReleaseContext(args, {}));
@@ -314,8 +315,8 @@ test('invalid target or mismatched channel fails before any release side effect'
}, },
}; };
await assert.rejects( await assert.rejects(
() => buildRelease(['--target', universalTarget], sideEffects), () => buildRelease(['--target', 'unknown'], sideEffects),
/单架构/, /不支持的发布目标/,
); );
await withEnv({ AGC_UPDATE_CHANNEL: 'dev-win' }, () => await withEnv({ AGC_UPDATE_CHANNEL: 'dev-win' }, () =>
assert.rejects( assert.rejects(
@@ -326,6 +327,26 @@ test('invalid target or mismatched channel fails before any release side effect'
assert.equal(touched, false); assert.equal(touched, false);
}); });
test('universal uses the Mac channel and the same signed artifact for both architectures', () => {
const context = resolveReleaseContext(['--target', universalTarget], {
AGC_BUILD_TARGET: windowsTarget,
});
assert.equal(context.channel, 'dev-mac');
assert.ok(context.bundleRoot.includes(universalTarget));
withSignedArtifact('陶泥儿.app.tar.gz', (artifact) => {
const manifest = createUpdateManifest(artifact, context);
assert.deepEqual(Object.keys(manifest.platforms), [
'darwin-aarch64',
'darwin-x86_64',
]);
assert.deepEqual(
manifest.platforms['darwin-aarch64'],
manifest.platforms['darwin-x86_64'],
);
assert.match(manifest.platforms['darwin-aarch64'].url, /\/dev-mac\//);
});
});
test('Windows remains the default and explicit Windows overrides macOS environment', () => { test('Windows remains the default and explicit Windows overrides macOS environment', () => {
const files = ['/tmp/mac.app.tar.gz', '/tmp/windows.exe', '/tmp/mac.dmg']; const files = ['/tmp/mac.app.tar.gz', '/tmp/windows.exe', '/tmp/mac.dmg'];
for (const context of [ for (const context of [
@@ -1367,18 +1367,20 @@ if (windowsTauriConfig.bundle?.useLocalToolsDir !== true) {
assert.deepEqual( assert.deepEqual(
macosTauriConfig.bundle?.resources, macosTauriConfig.bundle?.resources,
Object.fromEntries([ Object.fromEntries([
...[ ...['darwin-arm64', 'darwin-x64'].flatMap((arch) =>
'bin/codex', [
'bin/codex-code-mode-host', 'bin/codex',
'codex-path/rg', 'bin/codex-code-mode-host',
'codex-resources/zsh/bin/zsh', 'codex-path/rg',
'codex-package.json', 'codex-resources/zsh/bin/zsh',
'NOTICE.md', 'codex-package.json',
'manifest.json', 'NOTICE.md',
].map((file) => [ 'manifest.json',
`resources/codex/mac-native/${file}`, ].map((file) => [
`coding-agent/mac-native/${file}`, `resources/codex/mac-native/${arch}/${file}`,
]), `coding-agent/mac-native/${arch}/${file}`,
]),
),
['resources/plugins', 'plugins'], ['resources/plugins', 'plugins'],
]), ]),
'macOS must bundle the complete native Codex layout and plugin workspace', 'macOS must bundle the complete native Codex layout and plugin workspace',
@@ -8,6 +8,13 @@ import path from 'node:path';
// 只操作临时复制品;不启动 GUI、不读取开发机凭据、不访问 Provider。 // 只操作临时复制品;不启动 GUI、不读取开发机凭据、不访问 Provider。
assert.equal(process.platform, 'darwin', '此验证必须在 macOS 执行'); assert.equal(process.platform, 'darwin', '此验证必须在 macOS 执行');
const source = path.resolve(process.argv[2] || ''); const source = path.resolve(process.argv[2] || '');
const architecture =
process.argv[3] || (process.arch === 'arm64' ? 'arm64' : 'x86_64');
assert.ok(
['arm64', 'x86_64'].includes(architecture),
'架构只接受 arm64 / x86_64',
);
const requireUniversal = process.argv.includes('--universal');
assert.ok( assert.ok(
source.endsWith('.app') && fs.statSync(source).isDirectory(), source.endsWith('.app') && fs.statSync(source).isDirectory(),
'请传入 .app 绝对路径', '请传入 .app 绝对路径',
@@ -31,13 +38,19 @@ const env = {
}; };
function run(command, args) { function run(command, args) {
const result = spawnSync(command, args, { // 只强制被测应用切片;本机 Xcode 检查工具可能仅提供宿主架构。
cwd: root, const useSlice = command.startsWith(`${app}${path.sep}`);
env, const result = spawnSync(
encoding: 'utf8', useSlice ? '/usr/bin/arch' : command,
timeout: 30_000, useSlice ? [`-${architecture}`, command, ...args] : args,
maxBuffer: 1024 * 1024, {
}); cwd: root,
env,
encoding: 'utf8',
timeout: 120_000,
maxBuffer: 1024 * 1024,
},
);
assert.ifError(result.error); assert.ifError(result.error);
return result; return result;
} }
@@ -60,7 +73,7 @@ async function handshake(executable) {
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const timer = setTimeout( const timer = setTimeout(
() => reject(new Error('app-server 初始化超时')), () => reject(new Error('app-server 初始化超时')),
15_000, 120_000,
); );
const finish = (error) => { const finish = (error) => {
clearTimeout(timer); clearTimeout(timer);
@@ -129,20 +142,39 @@ async function handshake(executable) {
try { try {
fs.cpSync(source, app, { recursive: true }); fs.cpSync(source, app, { recursive: true });
const resources = path.join(app, 'Contents/Resources'); const resources = path.join(app, 'Contents/Resources');
const bundle = path.join(resources, 'coding-agent/mac-native'); const platform = architecture === 'arm64' ? 'darwin-arm64' : 'darwin-x64';
const bundle = path.join(resources, 'coding-agent/mac-native', platform);
const executable = path.join(bundle, 'bin/codex'); const executable = path.join(bundle, 'bin/codex');
const main = path.join( const main = path.join(
app, app,
'Contents/MacOS/genarrative-ai-game-creator-shell', 'Contents/MacOS/genarrative-ai-game-creator-shell',
); );
const mainArchitectures = run('/usr/bin/lipo', ['-archs', main]);
assert.equal(mainArchitectures.status, 0);
assert.ok(mainArchitectures.stdout.split(/\s+/).includes(architecture));
if (requireUniversal) {
assert.deepEqual(mainArchitectures.stdout.trim().split(/\s+/).sort(), [
'arm64',
'x86_64',
]);
for (const platform of ['darwin-arm64', 'darwin-x64']) {
assert.ok(
fs.existsSync(
path.join(
resources,
'coding-agent/mac-native',
platform,
'manifest.json',
),
),
);
}
}
const manifest = JSON.parse( const manifest = JSON.parse(
fs.readFileSync(path.join(bundle, 'manifest.json'), 'utf8'), fs.readFileSync(path.join(bundle, 'manifest.json'), 'utf8'),
); );
assert.equal(manifest.schemaVersion, 'genarrative-codex-sidecar.v2'); assert.equal(manifest.schemaVersion, 'genarrative-codex-sidecar.v2');
assert.equal( assert.equal(manifest.platform, platform);
manifest.platform,
process.arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64',
);
assert.equal(manifest.version, 'codex-cli 0.147.0'); assert.equal(manifest.version, 'codex-cli 0.147.0');
const components = [ const components = [
'bin/codex', 'bin/codex',
@@ -159,11 +191,7 @@ try {
fs.accessSync(file, fs.constants.X_OK); fs.accessSync(file, fs.constants.X_OK);
const arch = run('/usr/bin/lipo', ['-archs', file]); const arch = run('/usr/bin/lipo', ['-archs', file]);
assert.equal(arch.status, 0, component); assert.equal(arch.status, 0, component);
assert.equal( assert.equal(arch.stdout.trim(), architecture, component);
arch.stdout.trim(),
process.arch === 'arm64' ? 'arm64' : 'x86_64',
component,
);
} }
} }
assert.ok(fs.existsSync(path.join(bundle, 'NOTICE.md'))); assert.ok(fs.existsSync(path.join(bundle, 'NOTICE.md')));
@@ -212,7 +240,7 @@ try {
assert.notEqual(broken.status, 0); assert.notEqual(broken.status, 0);
assert.match(`${broken.stdout}\n${broken.stderr}`, /Codex CLI 未安装/); assert.match(`${broken.stdout}\n${broken.stderr}`, /Codex CLI 未安装/);
console.log( console.log(
'PASS: 隔离安装包资源、架构、摘要、权限、正式 Codex 查找、app-server 握手及缺组件拒绝', `PASS (${architecture}): 隔离安装包资源、架构、摘要、权限、正式 Codex 查找、app-server 握手及缺组件拒绝`,
); );
console.log( console.log(
'未验证:GUI、真实登录/Provider 对话、Cocos macOS 原生桥接;插件 Node 仍为外部前提', '未验证:GUI、真实登录/Provider 对话、Cocos macOS 原生桥接;插件 Node 仍为外部前提',
@@ -290,7 +290,7 @@ function deterministicArtManifest(
{ {
id: 'garden-guardians-spritesheet', id: 'garden-guardians-spritesheet',
path: 'assets/art-spritesheet.png', path: 'assets/art-spritesheet.png',
kind: 'icon-spritesheet', kind: 'art-spritesheet',
usage: ['defenders', 'enemies', 'battlefield-ui'], usage: ['defenders', 'enemies', 'battlefield-ui'],
source: 'canvas', source: 'canvas',
status: 'ready', status: 'ready',
@@ -710,7 +710,7 @@ function canvasAssetCall(agentId) {
outputPath: 'assets/ui-prototype.png', outputPath: 'assets/ui-prototype.png',
aspectRatio: '16:9', aspectRatio: '16:9',
imageSize: '2K', imageSize: '2K',
assetKind: 'ui-design', assetKind: 'ui-prototype',
assetLabel: '游戏横屏界面原型图', assetLabel: '游戏横屏界面原型图',
replaceExisting: false, replaceExisting: false,
}); });
@@ -721,7 +721,7 @@ function canvasAssetCall(agentId) {
outputPath: 'assets/art-spritesheet.png', outputPath: 'assets/art-spritesheet.png',
aspectRatio: '1:1', aspectRatio: '1:1',
imageSize: '1K', imageSize: '1K',
assetKind: 'icon-spritesheet', assetKind: 'art-spritesheet',
assetLabel: '游戏首版核心美术素材', assetLabel: '游戏首版核心美术素材',
replaceExisting: false, replaceExisting: false,
sliceMode: 'connected-components', sliceMode: 'connected-components',
@@ -830,12 +830,12 @@ function missingGeneratedVisualAssetObservation(context, agentId) {
}, },
'design-foundation': { 'design-foundation': {
path: 'assets/ui-prototype.png', path: 'assets/ui-prototype.png',
kind: 'ui-design', kind: 'ui-prototype',
summary: '策划界面原型图尚未按正式视觉流程生成并登记,不能完成任务', summary: '策划界面原型图尚未按正式视觉流程生成并登记,不能完成任务',
}, },
'art-asset-plan': { 'art-asset-plan': {
path: 'assets/art-spritesheet.png', path: 'assets/art-spritesheet.png',
kind: 'icon-spritesheet', kind: 'art-spritesheet',
summary: '首版美术素材图尚未按正式视觉流程生成并登记,不能完成任务', summary: '首版美术素材图尚未按正式视觉流程生成并登记,不能完成任务',
}, },
}[agentId]; }[agentId];
@@ -2667,7 +2667,7 @@ function createDeterministicCanvasFixture(apiKey) {
asset: { asset: {
assetId: `asset-${sliceImageId}`, assetId: `asset-${sliceImageId}`,
assetObjectId: sliceAssetObjectId, assetObjectId: sliceAssetObjectId,
assetKind: 'icon', assetKind: 'art-spritesheet-slice',
projectId, projectId,
taskId, taskId,
}, },
@@ -2707,7 +2707,7 @@ function createDeterministicCanvasFixture(apiKey) {
spritesheetAsset: { spritesheetAsset: {
assetId: `asset-${imageId}`, assetId: `asset-${imageId}`,
assetObjectId, assetObjectId,
assetKind: 'icon-spritesheet', assetKind: 'art-spritesheet',
projectId, projectId,
taskId, taskId,
}, },
@@ -2749,7 +2749,7 @@ function createDeterministicCanvasFixture(apiKey) {
const assetObjectId = `asset-object-${imageId}`; const assetObjectId = `asset-object-${imageId}`;
const resourceId = `resource-${imageId}`; const resourceId = `resource-${imageId}`;
const assetKind = const assetKind =
typeof body?.assetKind === 'string' ? body.assetKind : 'image'; typeof body?.assetKind === 'string' ? body.assetKind : 'game-art';
images.set(imageId, { images.set(imageId, {
...image, ...image,
objectKey, objectKey,
@@ -0,0 +1,134 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const repoRoot = path.resolve(appRoot, '../..');
const platforms = {
arm64: 'aarch64-apple-darwin',
x64: 'x86_64-apple-darwin',
};
export function lockedMacPackage(lock, arch, version) {
assert.ok(Object.hasOwn(platforms, arch), '未知 macOS 架构');
const alias = `@openai/codex-darwin-${arch}`;
const entry = lock.packages?.[`node_modules/${alias}`];
assert.equal(
entry?.version,
`${version}-darwin-${arch}`,
'原生依赖必须与应用锁定版本一致',
);
assert.deepEqual(entry.os, ['darwin']);
assert.deepEqual(entry.cpu, [arch]);
const url = new URL(entry.resolved);
assert.equal(url.protocol, 'https:');
assert.equal(
url.hostname,
'registry.npmjs.org',
'只下载锁定的官方 npm 原生包',
);
assert.equal(url.username + url.password + url.search + url.hash, '');
assert.match(entry.integrity, /^sha512-[A-Za-z0-9+/]+={0,2}$/);
return { alias, target: platforms[arch], ...entry };
}
export function verifyPackageIntegrity(bytes, expected) {
const actual = `sha512-${createHash('sha512').update(bytes).digest('base64')}`;
assert.equal(actual, expected, 'Codex 下载包 lockfile integrity 不匹配');
}
export function validateArchiveListing(listing) {
const files = listing.trim().split(/\r?\n/u);
assert.ok(files.length > 0);
for (const file of files) {
assert.ok(file.startsWith('package/'), '原生包必须只有 package 根目录');
assert.ok(
!file.split('/').includes('..') && !file.includes('\\'),
'压缩包路径不安全',
);
}
}
export async function prepareMacosCodex() {
assert.equal(process.platform, 'darwin', '该入口仅用于 macOS 构建机');
const lock = JSON.parse(
fs.readFileSync(path.join(repoRoot, 'package-lock.json'), 'utf8'),
);
const app = JSON.parse(
fs.readFileSync(path.join(appRoot, 'package.json'), 'utf8'),
);
const version = app.devDependencies['@openai/codex'];
assert.match(version, /^\d+\.\d+\.\d+$/u, 'Codex 必须锁定精确版本');
const cache = path.join(appRoot, 'src-tauri/target/.macos-native-cache');
fs.mkdirSync(cache, { recursive: true });
for (const arch of Object.keys(platforms)) {
const entry = lockedMacPackage(lock, arch, version);
const archive = path.join(cache, `codex-${entry.version}.tgz`);
if (!fs.existsSync(archive)) {
const response = await fetch(entry.resolved, {
signal: AbortSignal.timeout(300_000),
});
assert.ok(response.ok, `原生包下载失败 HTTP ${response.status}`);
const bytes = Buffer.from(await response.arrayBuffer());
verifyPackageIntegrity(bytes, entry.integrity);
const partial = `${archive}.${process.pid}.tmp`;
fs.writeFileSync(partial, bytes);
fs.renameSync(partial, archive);
}
verifyPackageIntegrity(fs.readFileSync(archive), entry.integrity);
validateArchiveListing(
execFileSync('tar', ['-tzf', archive], { encoding: 'utf8' }),
);
// 拒绝链接、设备及其它特殊条目,不能让 tar 在包目录之外写入。
const entries = execFileSync('tar', ['-tvzf', archive], {
encoding: 'utf8',
});
assert.ok(
entries
.trim()
.split(/\r?\n/u)
.every((line) => /^[-d]/u.test(line)),
'原生包禁止链接或特殊文件',
);
const parent = path.join(repoRoot, 'node_modules/@openai');
fs.mkdirSync(parent, { recursive: true });
const stage = fs.mkdtempSync(path.join(parent, '.mac-native-'));
try {
execFileSync(
'tar',
['-xzf', archive, '-C', stage, '--strip-components=1'],
{ stdio: 'pipe' },
);
const metadata = JSON.parse(
fs.readFileSync(
path.join(stage, 'vendor', entry.target, 'codex-package.json'),
'utf8',
),
);
assert.equal(metadata.version, version);
assert.equal(metadata.target, entry.target);
assert.equal(metadata.entrypoint, 'bin/codex');
const destination = path.join(repoRoot, 'node_modules', entry.alias);
assert.ok(
!fs.existsSync(destination) ||
!fs.lstatSync(destination).isSymbolicLink(),
'拒绝覆盖链接依赖',
);
fs.rmSync(destination, { recursive: true, force: true });
fs.renameSync(stage, destination);
} finally {
fs.rmSync(stage, { recursive: true, force: true });
}
console.log(`[macOS Codex] ${entry.version}: lockfile integrity 已验证`);
}
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
await prepareMacosCodex();
}
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import { test } from 'node:test';
import {
lockedMacPackage,
validateArchiveListing,
verifyPackageIntegrity,
} from './prepare-macos-codex.mjs';
const lock = JSON.parse(
fs.readFileSync(new URL('../../../package-lock.json', import.meta.url)),
);
const version = JSON.parse(
fs.readFileSync(new URL('../package.json', import.meta.url)),
).devDependencies['@openai/codex'];
test('both macOS dependencies resolve from the lockfile without floating versions', () => {
assert.equal(
lockedMacPackage(lock, 'arm64', version).target,
'aarch64-apple-darwin',
);
assert.equal(
lockedMacPackage(lock, 'x64', version).target,
'x86_64-apple-darwin',
);
assert.throws(() => lockedMacPackage(lock, 'other', version));
assert.throws(() => lockedMacPackage(lock, 'x64', '0.0.0'));
});
test('native package integrity rejects tampering', () => {
const bytes = Buffer.from('pinned package');
const integrity = `sha512-${createHash('sha512').update(bytes).digest('base64')}`;
verifyPackageIntegrity(bytes, integrity);
assert.throws(() =>
verifyPackageIntegrity(Buffer.from('modified'), integrity),
);
});
test('archive traversal and non-package entries fail closed', () => {
validateArchiveListing(
'package/package.json\npackage/vendor/target/bin/codex\n',
);
for (const listing of [
'',
'/tmp/payload',
'package/../private',
'other/file',
'package/..\\file',
]) {
assert.throws(() => validateArchiveListing(listing));
}
});
test('CI pipeline is manual archive-only and does not reuse a developer workspace', () => {
const pipeline = fs.readFileSync(
new URL(
'../../../jenkins/Jenkinsfile.ai-game-creator-shell-macos-build',
import.meta.url,
),
'utf8',
);
for (const required of [
'genarrative-agc-macos',
'disableConcurrentBuilds()',
'$AGC_AGENT_ROOT',
'StrictHostKeyChecking=yes',
'git merge-base --is-ancestor',
'allowEmptyArchive: false',
]) {
assert.ok(pipeline.includes(required), required);
}
for (const forbidden of [
'triggers {',
'cron(',
'pollSCM(',
'release:upload',
'AgcUpdaterSigningKey',
'AliyunAccessKeyId',
'git clean -fdx',
]) {
assert.ok(!pipeline.includes(forbidden), forbidden);
}
});
-1
View File
@@ -5016,7 +5016,6 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"ts-rs",
] ]
[[package]] [[package]]
@@ -56,7 +56,7 @@ platform-agent = { path = "../../../server-rs/crates/platform-agent" }
portable-pty = "0.9" portable-pty = "0.9"
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] } reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] }
regex = "1" regex = "1"
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] } shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
tauri = { version = "2.11.2", features = [] } tauri = { version = "2.11.2", features = [] }
tauri-plugin-dialog = "2.7.1" tauri-plugin-dialog = "2.7.1"
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] } tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
+18 -2
View File
@@ -31,7 +31,23 @@ fn sha256_file(path: &std::path::Path) -> Result<String, std::io::Error> {
fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) { fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
let target = env::var("TARGET").expect("Cargo TARGET"); let target = env::var("TARGET").expect("Cargo TARGET");
println!("cargo:rustc-env=AGC_BUILD_TARGET={target}"); println!("cargo:rustc-env=AGC_BUILD_TARGET={target}");
let Some(layout) = codex_bundle::for_target(&target) else { if target.contains("apple-darwin") {
// Tauri 的 universal 两次 Cargo 编译共用 resource staging
// 每次都生成完整双架构目录,最终 bundle 不取决于最后编译的切片。
let staging = manifest_dir.join("resources/codex/mac-native");
if staging.exists() {
fs::remove_dir_all(&staging).expect("清理 macOS Codex staging 失败");
}
for target in ["aarch64-apple-darwin", "x86_64-apple-darwin"] {
stage_codex_target(manifest_dir, target);
}
} else {
stage_codex_target(manifest_dir, &target);
}
}
fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) {
let Some(layout) = codex_bundle::for_target(target) else {
assert!( assert!(
!target.contains("windows") && !target.contains("apple-darwin"), !target.contains("windows") && !target.contains("apple-darwin"),
"不支持的 Codex 随包目标:{target}" "不支持的 Codex 随包目标:{target}"
@@ -81,7 +97,7 @@ fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
&fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"), &fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"),
) )
.expect("Codex 原生包元数据无效"); .expect("Codex 原生包元数据无效");
codex_bundle::validate_package_metadata(&metadata, &target, layout) codex_bundle::validate_package_metadata(&metadata, target, layout)
.unwrap_or_else(|error| panic!("{error}")); .unwrap_or_else(|error| panic!("{error}"));
let target_dir = manifest_dir.join("resources/codex").join(layout.directory); let target_dir = manifest_dir.join("resources/codex").join(layout.directory);
let notice = target_dir.join("NOTICE.md"); let notice = target_dir.join("NOTICE.md");
@@ -49,7 +49,11 @@ pub fn for_target(target: &str) -> Option<Layout> {
} else { } else {
"codex-darwin-x64" "codex-darwin-x64"
}, },
directory: "mac-native", directory: if target.starts_with("aarch64") {
"mac-native/darwin-arm64"
} else {
"mac-native/darwin-x64"
},
executable: "bin/codex", executable: "bin/codex",
files: MAC_FILES, files: MAC_FILES,
}), }),
@@ -90,6 +94,9 @@ mod tests {
let intel = for_target("x86_64-apple-darwin").unwrap(); let intel = for_target("x86_64-apple-darwin").unwrap();
assert_eq!(intel.platform, "darwin-x64"); assert_eq!(intel.platform, "darwin-x64");
assert_eq!(intel.npm_package, "codex-darwin-x64"); assert_eq!(intel.npm_package, "codex-darwin-x64");
assert_eq!(mac.directory, "mac-native/darwin-arm64");
assert_eq!(intel.directory, "mac-native/darwin-x64");
assert_ne!(mac.directory, intel.directory);
let windows = for_target("x86_64-pc-windows-msvc").unwrap(); let windows = for_target("x86_64-pc-windows-msvc").unwrap();
assert_eq!(windows.directory, "win-x64"); assert_eq!(windows.directory, "win-x64");
assert_eq!(windows.files.len(), 6); assert_eq!(windows.files.len(), 6);
@@ -63,7 +63,7 @@
"agents/openai.yaml", "agents/openai.yaml",
"references/platform-art-contract.md" "references/platform-art-contract.md"
], ],
"sha256": "6668bf1aa69601bcc65c97fdcd879c699c3139befcd70805d95614997f6e44e9" "sha256": "47ac742d9b88e5d6cd9833484ab212152578e58ae27f7add312fd1d78183385c"
}, },
{ {
"name": "agc-web-game-development", "name": "agc-web-game-development",
@@ -19,13 +19,13 @@ image, UI design image, or publication material; use `agc_edit_image` for an
edit of an existing registered image; use `taonier_prepare_game_art` only for edit of an existing registered image; use `taonier_prepare_game_art` only for
the complete game-art package and its canonical slices. the complete game-art package and its canonical slices.
With `agc_generate_image`, `kind="character"` and `kind="icon-spritesheet"` With `agc_generate_image`, `kind="character"` and `kind="art-spritesheet"`
generate the subject on a solid-colour background and automatically matte it generate the subject on a solid-colour background and automatically matte it
away afterwards, producing transparent-background results; write the prompt away afterwards, producing transparent-background results; write the prompt
for the subject only, never for a scene. `kind="image"` keeps the rendered for the subject only, never for a scene. `kind="image"` keeps the rendered
frame without extra processing. frame without extra processing.
When `agc_generate_image` is used with `kind="icon-spritesheet"`, `sliceMode` is When `agc_generate_image` is used with `kind="art-spritesheet"`, `sliceMode` is
required and has no default, so decide it explicitly: required and has no default, so decide it explicitly:
- Use `sliceMode="grid"` with `gridX` and `gridY` (1-32 each) only when the user - Use `sliceMode="grid"` with `gridX` and `gridY` (1-32 each) only when the user
@@ -1198,7 +1198,7 @@ mod tests {
"tool": "agc_generate_image", "tool": "agc_generate_image",
"arguments": { "arguments": {
"prompt": prompt, "prompt": prompt,
"kind": "icon-spritesheet", "kind": "art-spritesheet",
"sliceMode": "connected-components", "sliceMode": "connected-components",
"sliceCount": 8, "sliceCount": 8,
"screenColor": "#CFEFFF" "screenColor": "#CFEFFF"
@@ -1,10 +1,6 @@
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem}; use super::model::{DirectCodexUserContentPart, DirectCodexUserItem};
use super::validation::validate_direct_codex_user_item; use super::validation::validate_direct_codex_user_item;
use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path}; use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path};
use crate::ui_editor::persistence::{
generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND,
UI_DESIGN_DOC_MEDIA_TYPE,
};
use serde_json::Value; use serde_json::Value;
use std::path::Path; use std::path::Path;
@@ -115,165 +111,29 @@ pub(crate) fn direct_codex_user_item_to_prompt(
item: &DirectCodexUserItem, item: &DirectCodexUserItem,
) -> Result<String, String> { ) -> Result<String, String> {
let wire = direct_codex_user_item_to_wire_input(root, item)?; let wire = direct_codex_user_item_to_wire_input(root, item)?;
let DirectCodexUserItem::Message(message) = item; wire.as_array()
let mut prompt = wire
.as_array()
.ok_or_else(|| "DirectProject user item wire input 不是数组".to_string()) .ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())
.and_then(|parts| { .map(|parts| {
parts parts
.iter() .iter()
.map(|part| { .filter_map(|part| part.get("text").and_then(Value::as_str))
part.get("text") .collect::<String>()
.and_then(Value::as_str) })
.ok_or_else(|| "DirectProject user item wire part 缺少 text".to_string()) .and_then(|prompt| {
}) if prompt.trim().is_empty() {
.collect::<Result<String, String>>() Err("DirectProject user item 不能转换为空 prompt".to_string())
})?; } else {
if let Some(code_context) = render_ui_design_code_context(root, message)? { Ok(prompt)
prompt.push('\n'); }
prompt.push_str(&code_context);
}
if prompt.trim().is_empty() {
return Err("DirectProject user item 不能转换为空 prompt".to_string());
}
Ok(prompt)
}
/// 本轮 prompt 的 UI 设计文档引用上下文:复用 UI Editor 代码导出,把带文档注释的
/// JS 片段路径交给模型;生成失败只追加原始错误,不阻断本轮引用,其它引用继续处理。
///
/// 只在生成本轮 prompt 时展开:历史 item 回读走 `direct_codex_user_item_to_response_item`
/// 的纯投影,不得在这里产生项目写副作用。
fn render_ui_design_code_context(
root: &Path,
message: &DirectCodexUserMessageItem,
) -> Result<Option<String>, String> {
let referenced_ids = message
.content
.iter()
.filter_map(|part| match part {
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
Some(resource_id.trim())
}
_ => None,
}) })
.collect::<Vec<_>>();
if referenced_ids.is_empty() {
return Ok(None);
}
let manifest = read_manifest_for_project(root)?;
let mut lines = Vec::new();
for resource_id in referenced_ids {
let is_ui_design_doc = manifest.assets.iter().any(|asset| {
asset.id == resource_id
&& asset.kind == UI_DESIGN_DOC_ASSET_KIND
&& asset.media_type == UI_DESIGN_DOC_MEDIA_TYPE
});
if !is_ui_design_doc {
continue;
}
lines.push(
match generate_ui_design_code_at(GenerateUiDesignCodeInput {
project_path: root.to_string_lossy().into_owned(),
expected_project_id: manifest.project_id.clone(),
asset_id: resource_id.to_string(),
}) {
Ok(result) => format!("请先阅读生成的带有文档的代码片段: {}", result.relative_path),
Err(error) => format!("生成代码遇到错误{error}"),
},
);
}
if lines.is_empty() {
return Ok(None);
}
Ok(Some(lines.join("\n")))
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item}; use super::direct_codex_user_item_to_response_item;
use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE;
use serde_json::json; use serde_json::json;
use shared_contracts::game_creation_app::{
GameCreationAppAssetKind, GameCreationAppAssetSource, GameCreationAppAssetSourceKind,
};
use std::path::Path; use std::path::Path;
const PROMPT_CONTEXT_PROJECT_ID: &str = "direct-prompt-context-project";
fn prompt_context_project() -> tempfile::TempDir {
let directory = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(
directory.path(),
PROMPT_CONTEXT_PROJECT_ID,
"Direct prompt 上下文测试",
)
.expect("init project");
directory
}
fn register_fixture_asset(
root: &Path,
relative_path: &str,
kind: GameCreationAppAssetKind,
media_type: &str,
) -> String {
let absolute_path = crate::resolve_local_project_path(root, relative_path)
.expect("resolve fixture asset path");
std::fs::create_dir_all(absolute_path.parent().expect("fixture asset parent"))
.expect("create fixture asset parent");
std::fs::write(&absolute_path, b"{}").expect("write fixture asset file");
crate::assets::register_local_asset_at(
root,
relative_path,
kind,
media_type,
"prompt-context-test",
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
resource_id: Some("prompt-context".to_string()),
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
)
.expect("register fixture asset")
.id
}
fn ui_design_doc_fixture(initialize_state: bool) -> (tempfile::TempDir, String) {
let directory = prompt_context_project();
let asset_id = register_fixture_asset(
directory.path(),
"ui/design.json",
GameCreationAppAssetKind::UiDesignDoc,
UI_DESIGN_DOC_MEDIA_TYPE,
);
if initialize_state {
crate::ui_editor::persistence::initialize_ui_design_state_at(
directory.path(),
PROMPT_CONTEXT_PROJECT_ID,
&asset_id,
)
.expect("initialize UI design state");
}
(directory, asset_id)
}
fn user_item_with_resource_reference(asset_id: &str) -> serde_json::Value {
json!({
"type": "message",
"role": "user",
"id": "turn-1:user",
"content": [{"type": "agc_resource_reference", "resourceId": asset_id}],
})
}
#[test] #[test]
fn standard_response_item_passes_through_without_agc_private_parts() { fn standard_response_item_passes_through_without_agc_private_parts() {
let item = json!({ let item = json!({
@@ -314,81 +174,4 @@ mod tests {
.expect_err("history item without type must fail"); .expect_err("history item without type must fail");
assert!(error.contains("缺少 type"), "{error}"); assert!(error.contains("缺少 type"), "{error}");
} }
#[test]
fn ui_design_doc_reference_appends_generated_code_context() {
let (project, asset_id) = ui_design_doc_fixture(true);
let item: super::DirectCodexUserItem =
serde_json::from_value(user_item_with_resource_reference(&asset_id))
.expect("canonical user item");
let prompt =
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
assert!(
prompt.contains(&format!(
"[素材引用 resourceId={asset_id};项目路径=ui/design.json]"
)),
"{prompt}"
);
assert!(
prompt.contains("请先阅读生成的带有文档的代码片段: ui/generated-"),
"{prompt}"
);
}
#[test]
fn history_projection_never_writes_generated_ui_design_code() {
let (project, asset_id) = ui_design_doc_fixture(true);
let item = user_item_with_resource_reference(&asset_id);
direct_codex_user_item_to_response_item(project.path(), &item).expect("history projection");
let generated_root =
crate::resolve_local_project_path(project.path(), "ui").expect("resolve ui directory");
let generated_files = std::fs::read_dir(generated_root)
.expect("read ui directory")
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with("generated-")
})
.count();
assert_eq!(
generated_files, 0,
"历史回读只做纯投影,不得生成 UI 设计代码"
);
}
#[test]
fn ui_design_generation_failure_keeps_reference_and_reports_error() {
let (project, asset_id) = ui_design_doc_fixture(false);
let item: super::DirectCodexUserItem =
serde_json::from_value(user_item_with_resource_reference(&asset_id))
.expect("canonical user item");
let prompt =
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
assert!(prompt.contains("素材引用 resourceId="), "{prompt}");
assert!(prompt.contains("生成代码遇到错误"), "{prompt}");
}
#[test]
fn other_asset_kind_reference_does_not_generate_ui_design_code() {
let project = prompt_context_project();
let asset_id = register_fixture_asset(
project.path(),
"assets/hero.png",
GameCreationAppAssetKind::Character,
"image/png",
);
let item: super::DirectCodexUserItem =
serde_json::from_value(user_item_with_resource_reference(&asset_id))
.expect("canonical user item");
let prompt =
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
assert!(prompt.contains("素材引用 resourceId="), "{prompt}");
assert!(
!prompt.contains("请先阅读生成的带有文档的代码片段"),
"{prompt}"
);
assert!(!prompt.contains("生成代码遇到错误"), "{prompt}");
}
} }
File diff suppressed because it is too large Load Diff
@@ -27,6 +27,7 @@ const DIRECT_TOOL_BRIDGE_SEARCH_URL: &str = "https://www.bing.com/search?format=
const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS: usize = 120; const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_NAME_CHARS: usize = 120;
const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_KIND_CHARS: usize = 80; const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_KIND_CHARS: usize = 80;
const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_PAGE_SIZE: usize = 100; const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_PAGE_SIZE: usize = 100;
const DIRECT_TOOL_BRIDGE_MAX_RESOURCE_CALLS_PER_TURN: usize = 4;
const DIRECT_TOOL_BRIDGE_MAX_ACCOUNT_ASSET_ID_CHARS: usize = 512; const DIRECT_TOOL_BRIDGE_MAX_ACCOUNT_ASSET_ID_CHARS: usize = 512;
const DIRECT_TOOL_BRIDGE_MAX_LOCAL_ASSET_PATH_CHARS: usize = 512; const DIRECT_TOOL_BRIDGE_MAX_LOCAL_ASSET_PATH_CHARS: usize = 512;
@@ -67,6 +68,7 @@ struct DirectToolBridgeActiveTurnAuthorization {
turn_id: String, turn_id: String,
brief_sha256: Option<String>, brief_sha256: Option<String>,
completed_result: Option<Value>, completed_result: Option<Value>,
resource_request_ids: BTreeMap<String, (String, String)>,
} }
enum DirectToolBridgeRegenerationCall { enum DirectToolBridgeRegenerationCall {
@@ -205,6 +207,7 @@ impl DirectToolBridgeState {
turn_id: turn_id.clone(), turn_id: turn_id.clone(),
brief_sha256: None, brief_sha256: None,
completed_result: None, completed_result: None,
resource_request_ids: BTreeMap::new(),
}); });
Ok(DirectToolBridgeTurnGuard { Ok(DirectToolBridgeTurnGuard {
state: Arc::clone(self), state: Arc::clone(self),
@@ -634,17 +637,30 @@ impl DirectToolBridgeState {
Ok(()) Ok(())
} }
/// 取当前回合身份,媒体资源请求按回合身份与请求指纹确定性派生 operation/idempotency id。 fn resource_request_ids(&self, request_fingerprint: &str) -> Result<(String, String), String> {
fn active_resource_turn_id(&self) -> Result<String, String> { let mut authorization = self
let authorization = self
.turn_authorization .turn_authorization
.lock() .lock()
.map_err(|_| "AGC 工具桥回合授权状态不可用".to_string())?; .map_err(|_| "AGC 工具桥回合授权状态不可用".to_string())?;
authorization let active = authorization
.active .active
.as_ref() .as_mut()
.map(|active| active.turn_id.clone()) .ok_or_else(|| "当前没有客户端签发的资源生成回合身份".to_string())?;
.ok_or_else(|| "当前没有客户端签发的资源生成回合身份".to_string()) if let Some(ids) = active.resource_request_ids.get(request_fingerprint) {
return Ok(ids.clone());
}
if active.resource_request_ids.len() >= DIRECT_TOOL_BRIDGE_MAX_RESOURCE_CALLS_PER_TURN {
return Err("单个用户回合最多只能创建四项媒体资源请求".to_string());
}
let operation_id =
direct_resource_request_uuid(&active.turn_id, "operation", request_fingerprint);
let idempotency_key =
direct_resource_request_uuid(&active.turn_id, "idempotency", request_fingerprint);
active.resource_request_ids.insert(
request_fingerprint.to_string(),
(operation_id.clone(), idempotency_key.clone()),
);
Ok((operation_id, idempotency_key))
} }
} }
@@ -1197,9 +1213,9 @@ fn bridge_registered_resource(
"mediaType": asset.media_type, "mediaType": asset.media_type,
// Agent 与 UI 必须看到同一个口径:UI 栏目走 TS 的 `gameCreationAppAssetCategory` // Agent 与 UI 必须看到同一个口径:UI 栏目走 TS 的 `gameCreationAppAssetCategory`
// (落盘值 + 按 kind 派生 + 读时自愈),这里走 Rust 的同构实现。 // (落盘值 + 按 kind 派生 + 读时自愈),这里走 Rust 的同构实现。
// 直接透传落盘 `asset.category` 会让 UI 设计资产在 UI 显示「UI 交互」、 // 直接透传落盘 `asset.category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、
// 在 Agent 侧读到 `unclassified`(真机 55 条分歧)。 // 在 Agent 侧读到 `unclassified`(真机 55 条分歧)。
"category": game_creation_app_asset_effective_category(asset.kind, asset.category), "category": game_creation_app_asset_effective_category(&asset.kind, asset.category),
"tags": asset.tags, "tags": asset.tags,
"canvasProjectId": asset.source.canvas_project_id, "canvasProjectId": asset.source.canvas_project_id,
"resourceId": asset.source.resource_id, "resourceId": asset.source.resource_id,
@@ -1240,9 +1256,7 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value {
arguments, arguments,
"kind", "kind",
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_KIND_CHARS, DIRECT_TOOL_BRIDGE_MAX_RESOURCE_KIND_CHARS,
)? )?;
.map(|kind| bridge_asset_list_kind_filter(&kind))
.transpose()?;
let asset_id = bridge_optional_bounded_string( let asset_id = bridge_optional_bounded_string(
arguments, arguments,
"assetId", "assetId",
@@ -1262,7 +1276,7 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value {
let mut assets = manifest let mut assets = manifest
.assets .assets
.iter() .iter()
.filter(|asset| kind.is_none_or(|kind| asset.kind == kind)) .filter(|asset| kind.as_ref().is_none_or(|kind| asset.kind == *kind))
.filter(|asset| { .filter(|asset| {
asset_id asset_id
.as_ref() .as_ref()
@@ -1328,24 +1342,6 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value {
} }
} }
/// `asset.list` 的 `kind` 过滤:只接受 canonical 值,认不出的值直接报错。
///
/// 以前认不出的值会收口成 `unknown` 再参与等值过滤,结果是**静默返回空列表**:调用方(模型)
/// 会以为项目里没有这类资产,而不是"你传的 kind 不合法",于是继续按错误前提往下走。
/// `unknown` 本身仍是合法输入(确有 kind 未知的资产),只拒绝"既不是 canonical、也不是字面
/// `unknown`"的原值。
fn bridge_asset_list_kind_filter(raw: &str) -> Result<GameCreationAppAssetKind, String> {
let kind = GameCreationAppAssetKind::parse_with_context(raw, "asset.list.kind");
if kind == GameCreationAppAssetKind::Unknown
&& raw.trim() != GameCreationAppAssetKind::Unknown.as_str()
{
return Err(format!(
"kind 不是已知的 manifest 资源 kind{raw};请改用 canonical kind(如 image、scene、character、icon、icon-spritesheet、character-animation、audio、video、document"
));
}
Ok(kind)
}
fn bridge_project_file_class(path: &str) -> (&'static str, Option<&'static str>) { fn bridge_project_file_class(path: &str) -> (&'static str, Option<&'static str>) {
let extension = Path::new(path) let extension = Path::new(path)
.extension() .extension()
@@ -1894,11 +1890,8 @@ async fn bridge_create_or_derive_resource(
)) ))
.await? .await?
} else { } else {
let turn_id = state.active_resource_turn_id()?; let (operation_id, idempotency_key) =
let operation_id = state.resource_request_ids(&request_fingerprint)?;
direct_resource_request_uuid(&turn_id, "operation", &request_fingerprint);
let idempotency_key =
direct_resource_request_uuid(&turn_id, "idempotency", &request_fingerprint);
let revision = read_game_creator_agent_runtime_project_revision(&state.root)?.revision; let revision = read_game_creator_agent_runtime_project_revision(&state.root)?.revision;
let source_resource_id = source_asset let source_resource_id = source_asset
.as_ref() .as_ref()
@@ -1916,7 +1909,7 @@ async fn bridge_create_or_derive_resource(
source_asset_id: source_asset.as_ref().map(|asset| asset.id.clone()), source_asset_id: source_asset.as_ref().map(|asset| asset.id.clone()),
source_path: source_asset.as_ref().map(|asset| asset.local_path.clone()), source_path: source_asset.as_ref().map(|asset| asset.local_path.clone()),
source_media_type: source_asset.as_ref().map(|asset| asset.media_type.clone()), source_media_type: source_asset.as_ref().map(|asset| asset.media_type.clone()),
source_subtype: source_asset.as_ref().map(|asset| asset.kind.to_string()), source_subtype: source_asset.as_ref().map(|asset| asset.kind.clone()),
producer_task_id: source_asset producer_task_id: source_asset
.as_ref() .as_ref()
.and_then(|asset| asset.source.task_id.clone()), .and_then(|asset| asset.source.task_id.clone()),
@@ -2008,11 +2001,7 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
}) })
.await? .await?
} else { } else {
// id 按回合身份与请求指纹确定性派生,同指纹重试与 pending 对账语义不变。 let (operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
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 revision = read_game_creator_agent_runtime_project_revision(&state.root)?.revision;
let request = DeriveLocalProjectResourceInput { let request = DeriveLocalProjectResourceInput {
project_path: state.root.to_string_lossy().into_owned(), project_path: state.root.to_string_lossy().into_owned(),
@@ -2026,7 +2015,7 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
source_asset_id: Some(source_asset_id.clone()), source_asset_id: Some(source_asset_id.clone()),
source_path: Some(source_asset.local_path.clone()), source_path: Some(source_asset.local_path.clone()),
source_media_type: Some(source_asset.media_type.clone()), source_media_type: Some(source_asset.media_type.clone()),
source_subtype: Some(source_asset.kind.to_string()), source_subtype: Some(source_asset.kind.clone()),
producer_task_id: source_asset.source.task_id.clone(), producer_task_id: source_asset.source.task_id.clone(),
source_version_id: None, source_version_id: None,
prompt: "去除背景".to_string(), prompt: "去除背景".to_string(),
@@ -2194,37 +2183,35 @@ async fn bridge_prepare_game_art(state: &DirectToolBridgeState, arguments: &Valu
result result
} }
fn bridge_image_generation_kind(arguments: &Value) -> Result<GameCreationAppAssetKind, String> { fn bridge_image_generation_kind(arguments: &Value) -> Result<String, String> {
let kind = arguments let kind = arguments
.get("kind") .get("kind")
.and_then(Value::as_str) .and_then(Value::as_str)
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.unwrap_or("image"); .unwrap_or("image");
normalize_platform_art_asset_generation_kind(kind).ok_or_else(|| { normalize_platform_art_asset_generation_kind(kind)
format!( .map(str::to_string)
"工具参数 kind 只允许 {}", .ok_or_else(|| {
PLATFORM_ART_ASSET_GENERATION_KINDS format!(
.iter() "工具参数 kind 只允许 {}",
.map(|kind| kind.as_str()) PLATFORM_ART_ASSET_GENERATION_KINDS.join("")
.collect::<Vec<_>>() )
.join("") })
)
})
} }
/// 切分模式没有默认值:图集必须显式声明,且声明必须与 kind 和网格参数自洽。 /// 切分模式没有默认值:图集必须显式声明,且声明必须与 kind 和网格参数自洽。
fn validate_generate_image_slice_declaration( fn validate_generate_image_slice_declaration(
kind: GameCreationAppAssetKind, kind: &str,
slice_mode: Option<&str>, slice_mode: Option<&str>,
grid_x: Option<u32>, grid_x: Option<u32>,
grid_y: Option<u32>, grid_y: Option<u32>,
slice_count: Option<usize>, slice_count: Option<usize>,
) -> Result<(), String> { ) -> Result<(), String> {
if kind == GameCreationAppAssetKind::IconSpritesheet { if kind == "art-spritesheet" {
if slice_mode.is_none() { if slice_mode.is_none() {
return Err( return Err(
"kind=icon-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时传 sliceMode=grid 并提供 gridX/gridY;自由排布、数量不定或只要求一张图集时传 sliceMode=connected-components" "kind=art-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时传 sliceMode=grid 并提供 gridX/gridY;自由排布、数量不定或只要求一张图集时传 sliceMode=connected-components"
.to_string(), .to_string(),
); );
} }
@@ -2237,19 +2224,19 @@ fn validate_generate_image_slice_declaration(
} }
if slice_mode.is_some() || grid_x.is_some() || grid_y.is_some() || slice_count.is_some() { if slice_mode.is_some() || grid_x.is_some() || grid_y.is_some() || slice_count.is_some() {
return Err(format!( return Err(format!(
"工具参数 sliceMode/gridX/gridY/sliceCount 仅对 kind=icon-spritesheet 生效,当前 kind={kind}" "工具参数 sliceMode/gridX/gridY/sliceCount 仅对 kind=art-spritesheet 生效,当前 kind={kind}"
)); ));
} }
Ok(()) Ok(())
} }
/// 抠图纯色背景只服务 character 与 icon-spritesheet 链路;格式校验收口为 /// 抠图纯色背景只服务 character 与 art-spritesheet 链路;格式校验收口为
/// `auto` 或 `#RRGGBB`(服务端另有支持色板,客户端不复制),`auto`/空串归一为 /// `auto` 或 `#RRGGBB`(服务端另有支持色板,客户端不复制),`auto`/空串归一为
/// None(服务端自动决策),hex 统一大写后透传。其它 kind 携带该字段直接拒绝, /// None(服务端自动决策),hex 统一大写后透传。其它 kind 携带该字段直接拒绝,
/// 避免服务端静默忽略造成“已生效”的误解。 /// 避免服务端静默忽略造成“已生效”的误解。
fn normalize_generate_image_screen_color( fn normalize_generate_image_screen_color(
arguments: &Value, arguments: &Value,
kind: GameCreationAppAssetKind, kind: &str,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
let Some(value) = arguments.get("screenColor") else { let Some(value) = arguments.get("screenColor") else {
return Ok(None); return Ok(None);
@@ -2257,12 +2244,9 @@ fn normalize_generate_image_screen_color(
if value.is_null() { if value.is_null() {
return Ok(None); return Ok(None);
} }
if !matches!( if !matches!(kind, "character" | "art-spritesheet") {
kind,
GameCreationAppAssetKind::Character | GameCreationAppAssetKind::IconSpritesheet
) {
return Err(format!( return Err(format!(
"工具参数 screenColor 仅对 kind=character 和 kind=icon-spritesheet 生效,当前 kind={kind}" "工具参数 screenColor 仅对 kind=character 和 kind=art-spritesheet 生效,当前 kind={kind}"
)); ));
} }
let raw = value let raw = value
@@ -2389,18 +2373,18 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
}) })
.transpose()?; .transpose()?;
validate_generate_image_slice_declaration( validate_generate_image_slice_declaration(
kind, kind.as_str(),
slice_mode.as_deref(), slice_mode.as_deref(),
grid_x, grid_x,
grid_y, grid_y,
slice_count, slice_count,
)?; )?;
let screen_color = normalize_generate_image_screen_color(arguments, kind)?; let screen_color = normalize_generate_image_screen_color(arguments, kind.as_str())?;
let options = PlatformArtAssetGenerationOptions { let options = PlatformArtAssetGenerationOptions {
output_path, output_path,
aspect_ratio, aspect_ratio,
image_size, image_size,
asset_kind: kind, asset_kind: kind.clone(),
asset_label: asset_name.clone(), asset_label: asset_name.clone(),
replace_existing: false, replace_existing: false,
slice_count, slice_count,
@@ -2889,19 +2873,14 @@ pub(crate) async fn start_direct_tool_bridge(
mod tests { mod tests {
#[test] #[test]
fn generate_image_slice_declaration_is_explicit_and_self_consistent() { fn generate_image_slice_declaration_is_explicit_and_self_consistent() {
let missing = validate_generate_image_slice_declaration( let missing =
GameCreationAppAssetKind::IconSpritesheet, validate_generate_image_slice_declaration("art-spritesheet", None, None, None, None)
None, .expect_err("art-spritesheet without sliceMode must fail closed");
None,
None,
None,
)
.expect_err("icon-spritesheet without sliceMode must fail closed");
assert!(missing.contains("没有默认值"), "{missing}"); assert!(missing.contains("没有默认值"), "{missing}");
assert!(missing.contains("connected-components"), "{missing}"); assert!(missing.contains("connected-components"), "{missing}");
assert!(validate_generate_image_slice_declaration( assert!(validate_generate_image_slice_declaration(
GameCreationAppAssetKind::IconSpritesheet, "art-spritesheet",
Some("connected-components"), Some("connected-components"),
None, None,
None, None,
@@ -2909,7 +2888,7 @@ mod tests {
) )
.is_ok()); .is_ok());
assert!(validate_generate_image_slice_declaration( assert!(validate_generate_image_slice_declaration(
GameCreationAppAssetKind::IconSpritesheet, "art-spritesheet",
Some("grid"), Some("grid"),
Some(3), Some(3),
Some(2), Some(2),
@@ -2917,7 +2896,7 @@ mod tests {
) )
.is_ok()); .is_ok());
let grid_with_count = validate_generate_image_slice_declaration( let grid_with_count = validate_generate_image_slice_declaration(
GameCreationAppAssetKind::IconSpritesheet, "art-spritesheet",
Some("grid"), Some("grid"),
Some(2), Some(2),
Some(2), Some(2),
@@ -2927,80 +2906,58 @@ mod tests {
assert!(grid_with_count.contains("gridX×gridY"), "{grid_with_count}"); assert!(grid_with_count.contains("gridX×gridY"), "{grid_with_count}");
let wrong_kind = validate_generate_image_slice_declaration( let wrong_kind = validate_generate_image_slice_declaration(
GameCreationAppAssetKind::Image, "image",
Some("connected-components"), Some("connected-components"),
None, None,
None, None,
None, None,
) )
.expect_err("slice declaration must stay scoped to icon-spritesheet"); .expect_err("slice declaration must stay scoped to art-spritesheet");
assert!( assert!(
wrong_kind.contains("仅对 kind=icon-spritesheet 生效"), wrong_kind.contains("仅对 kind=art-spritesheet 生效"),
"{wrong_kind}" "{wrong_kind}"
); );
let wrong_kind_count = validate_generate_image_slice_declaration( let wrong_kind_count =
GameCreationAppAssetKind::Image, validate_generate_image_slice_declaration("image", None, None, None, Some(8))
None, .expect_err("sliceCount-only violation must be rejected");
None,
None,
Some(8),
)
.expect_err("sliceCount-only violation must be rejected");
assert!( assert!(
wrong_kind_count.contains("sliceCount"), wrong_kind_count.contains("sliceCount"),
"sliceCount-only violation must name sliceCount: {wrong_kind_count}" "sliceCount-only violation must name sliceCount: {wrong_kind_count}"
); );
assert!(validate_generate_image_slice_declaration( assert!(validate_generate_image_slice_declaration("image", None, None, None, None).is_ok());
GameCreationAppAssetKind::Image,
None,
None,
None,
None
)
.is_ok());
} }
#[test] #[test]
fn generate_image_screen_color_is_normalized_and_kind_gated() { fn generate_image_screen_color_is_normalized_and_kind_gated() {
// 省略与显式 null 等价,且不触发 kind 门禁。 // 省略与显式 null 等价,且不触发 kind 门禁。
assert_eq!( assert_eq!(
normalize_generate_image_screen_color(&json!({}), GameCreationAppAssetKind::Image) normalize_generate_image_screen_color(&json!({}), "image").expect("omitted"),
.expect("omitted"),
None None
); );
assert_eq!( assert_eq!(
normalize_generate_image_screen_color( normalize_generate_image_screen_color(&json!({"screenColor": null}), "image")
&json!({"screenColor": null}), .expect("null"),
GameCreationAppAssetKind::Image
)
.expect("null"),
None None
); );
// auto 家族归一为 None(服务端自动决策),大小写与空白不敏感。 // auto 家族归一为 None(服务端自动决策),大小写与空白不敏感。
for raw in ["auto", "AUTO", " auto ", ""] { for raw in ["auto", "AUTO", " auto ", ""] {
assert_eq!( assert_eq!(
normalize_generate_image_screen_color( normalize_generate_image_screen_color(&json!({"screenColor": raw}), "character")
&json!({"screenColor": raw}), .expect("auto variants"),
GameCreationAppAssetKind::Character
)
.expect("auto variants"),
None, None,
"{raw}" "{raw}"
); );
} }
// hex 统一大写透传;色板白名单由服务端权威校验,客户端只守格式。 // hex 统一大写透传;色板白名单由服务端权威校验,客户端只守格式。
assert_eq!( assert_eq!(
normalize_generate_image_screen_color( normalize_generate_image_screen_color(&json!({"screenColor": "#cfefff"}), "character")
&json!({"screenColor": "#cfefff"}), .expect("lowercase hex"),
GameCreationAppAssetKind::Character
)
.expect("lowercase hex"),
Some("#CFEFFF".to_string()) Some("#CFEFFF".to_string())
); );
assert_eq!( assert_eq!(
normalize_generate_image_screen_color( normalize_generate_image_screen_color(
&json!({"screenColor": " #A0BBA0 "}), &json!({"screenColor": " #A0BBA0 "}),
GameCreationAppAssetKind::IconSpritesheet "art-spritesheet"
) )
.expect("padded hex"), .expect("padded hex"),
Some("#A0BBA0".to_string()) Some("#A0BBA0".to_string())
@@ -3008,24 +2965,19 @@ mod tests {
// 非 auto/非 hex、非字符串一律拒绝。 // 非 auto/非 hex、非字符串一律拒绝。
for bad in [json!("green"), json!("#GGGGGG"), json!("#FFF"), json!(12)] { for bad in [json!("green"), json!("#GGGGGG"), json!("#FFF"), json!(12)] {
assert!( assert!(
normalize_generate_image_screen_color( normalize_generate_image_screen_color(&json!({"screenColor": bad}), "character")
&json!({"screenColor": bad}), .is_err(),
GameCreationAppAssetKind::Character
)
.is_err(),
"{bad}" "{bad}"
); );
} }
// 其它 kind 携带该字段直接拒绝,即使取值合法。 // 其它 kind 携带该字段直接拒绝,即使取值合法。
let gated = normalize_generate_image_screen_color( let gated =
&json!({"screenColor": "#CFEFFF"}), normalize_generate_image_screen_color(&json!({"screenColor": "#CFEFFF"}), "image")
GameCreationAppAssetKind::Image, .expect_err("screenColor must stay scoped to character/art-spritesheet");
)
.expect_err("screenColor must stay scoped to character/icon-spritesheet");
assert!(gated.contains("kind=character"), "{gated}"); assert!(gated.contains("kind=character"), "{gated}");
assert!(normalize_generate_image_screen_color( assert!(normalize_generate_image_screen_color(
&json!({"screenColor": "auto"}), &json!({"screenColor": "auto"}),
GameCreationAppAssetKind::UiDesign "ui-prototype"
) )
.is_err()); .is_err());
} }
@@ -3794,14 +3746,12 @@ mod tests {
fn bridge_art_resource_exposes_only_the_safe_identity_projection() { fn bridge_art_resource_exposes_only_the_safe_identity_projection() {
let asset = GameCreationAppAssetManifestEntry { let asset = GameCreationAppAssetManifestEntry {
id: "local-art-1".to_string(), id: "local-art-1".to_string(),
kind: GameCreationAppAssetKind::Icon, kind: "art-spritesheet-slice".to_string(),
media_type: "image/png".to_string(), media_type: "image/png".to_string(),
local_path: "assets/art-spritesheet-slices/player.png".to_string(), local_path: "assets/art-spritesheet-slices/player.png".to_string(),
image_sequence_frames: None, image_sequence_frames: None,
image_sequence_duration_ms: None, image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind( category: game_creation_app_asset_category_for_kind("art-spritesheet-slice"),
GameCreationAppAssetKind::IconSpritesheet,
),
tags: Vec::new(), tags: Vec::new(),
source: GameCreationAppAssetSource { source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas, kind: GameCreationAppAssetSourceKind::Canvas,
@@ -3853,7 +3803,7 @@ mod tests {
fn bridge_registered_resource_exposes_formal_sequence_without_private_generation_fields() { fn bridge_registered_resource_exposes_formal_sequence_without_private_generation_fields() {
let asset = GameCreationAppAssetManifestEntry { let asset = GameCreationAppAssetManifestEntry {
id: "animation-1".to_string(), id: "animation-1".to_string(),
kind: GameCreationAppAssetKind::CharacterAnimation, kind: "character-animation".to_string(),
media_type: "video/mp4".to_string(), media_type: "video/mp4".to_string(),
local_path: "assets/edits/animation.mp4".to_string(), local_path: "assets/edits/animation.mp4".to_string(),
image_sequence_frames: Some(vec![ image_sequence_frames: Some(vec![
@@ -3866,9 +3816,7 @@ mod tests {
}, },
]), ]),
image_sequence_duration_ms: Some(4_000), image_sequence_duration_ms: Some(4_000),
category: game_creation_app_asset_category_for_kind( category: game_creation_app_asset_category_for_kind("character-animation"),
GameCreationAppAssetKind::CharacterAnimation,
),
tags: Vec::new(), tags: Vec::new(),
source: GameCreationAppAssetSource { source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas, kind: GameCreationAppAssetSourceKind::Canvas,
@@ -3906,7 +3854,7 @@ mod tests {
fn bridge_registered_resource_keeps_explicit_manifest_classification() { fn bridge_registered_resource_keeps_explicit_manifest_classification() {
let asset = GameCreationAppAssetManifestEntry { let asset = GameCreationAppAssetManifestEntry {
id: "spec-1".to_string(), id: "spec-1".to_string(),
kind: GameCreationAppAssetKind::Spec, kind: "spec".to_string(),
media_type: "application/json".to_string(), media_type: "application/json".to_string(),
local_path: "assets/specs/hero.json".to_string(), local_path: "assets/specs/hero.json".to_string(),
image_sequence_frames: None, image_sequence_frames: None,
@@ -3942,13 +3890,13 @@ mod tests {
/// ///
/// UI 栏目走 TS 的 `gameCreationAppAssetCategory`(落盘值 + 按 kind 派生 + 读时自愈), /// UI 栏目走 TS 的 `gameCreationAppAssetCategory`(落盘值 + 按 kind 派生 + 读时自愈),
/// Agent 走 Rust 的同构实现 `game_creation_app_asset_effective_category`。直接透传落盘 /// Agent 走 Rust 的同构实现 `game_creation_app_asset_effective_category`。直接透传落盘
/// `category` 会让 `ui-design` 的资产在 UI 显示「UI 交互」、在 Agent 侧读到 /// `category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、在 Agent 侧读到
/// `unclassified`,两侧分类口径必须一致 /// `unclassified`——真机 122 条资产里有 55 条这样分叉
#[test] #[test]
fn bridge_registered_resource_projects_effective_category_not_raw_persisted_value() { fn bridge_registered_resource_projects_effective_category_not_raw_persisted_value() {
let asset = GameCreationAppAssetManifestEntry { let asset = GameCreationAppAssetManifestEntry {
id: "ui-design-1".to_string(), id: "ui-1".to_string(),
kind: GameCreationAppAssetKind::UiDesign, kind: "ui".to_string(),
media_type: "application/json".to_string(), media_type: "application/json".to_string(),
local_path: "assets/UI 设计 1.json".to_string(), local_path: "assets/UI 设计 1.json".to_string(),
image_sequence_frames: None, image_sequence_frames: None,
@@ -3971,7 +3919,7 @@ mod tests {
}; };
let projection = bridge_registered_resource(&asset, false); let projection = bridge_registered_resource(&asset, false);
assert_eq!(projection["kind"], "ui-design"); assert_eq!(projection["kind"], "ui");
assert_eq!(projection["category"], "ui-interaction"); assert_eq!(projection["category"], "ui-interaction");
} }
@@ -247,7 +247,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
"type": "string", "type": "string",
"enum": PLATFORM_ART_ASSET_GENERATION_KINDS, "enum": PLATFORM_ART_ASSET_GENERATION_KINDS,
"default": "image", "default": "image",
"description": "image=普通新图(不做额外处理),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),icon-spec=统一视觉规范图ui-design=完整 UI 设计图,icon-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图" "description": "image=普通新图(不做额外处理),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),spec/icon-spec=统一视觉规范图(spec 是服务端同义词,客户端统一登记为 icon-spec),ui-prototype=完整 UI 设计图,art-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图"
}, },
"aspectRatio": { "aspectRatio": {
"type": "string", "type": "string",
@@ -273,7 +273,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
"sliceMode": { "sliceMode": {
"type": "string", "type": "string",
"enum": ["connected-components", "grid"], "enum": ["connected-components", "grid"],
"description": "仅 kind=icon-spritesheet 生效,且必填、没有默认值:需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入来自需求本身的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,需要约束素材张数时用 sliceCount。省略、与 kind 不匹配或与 gridX/gridY 互相矛盾时客户端直接拒绝,不会替你选择" "description": "仅 kind=art-spritesheet 生效,且必填、没有默认值:需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入来自需求本身的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,需要约束素材张数时用 sliceCount。省略、与 kind 不匹配或与 gridX/gridY 互相矛盾时客户端直接拒绝,不会替你选择"
}, },
"gridX": { "gridX": {
"type": "integer", "type": "integer",
@@ -291,11 +291,11 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
"type": "integer", "type": "integer",
"minimum": 1, "minimum": 1,
"maximum": 256, "maximum": 256,
"description": "只与 kind=icon-spritesheet 且 sliceMode=connected-components 同时提供,用于约束目标素材张数;省略时按图像内容自动识别" "description": "只与 kind=art-spritesheet 且 sliceMode=connected-components 同时提供,用于约束目标素材张数;省略时按图像内容自动识别"
}, },
"screenColor": { "screenColor": {
"type": "string", "type": "string",
"description": "抠图纯色背景,仅 kind=character(角色形象)和 kind=icon-spritesheet(图标素材)生效,其它 kind 携带会被拒绝。生成时把主体置于该纯色背景上,回图后据此抠除背景。取值只能是 auto 或下列色板 hex 之一,传值只填 hex 本身、不要附带色名:#CFEFFF(浅雾蓝)、#B0C2E0(浅钢蓝)、#FFD6C2(暖浅桃色)、#E6D8FF(淡薰衣草紫)、#F4D8E8(浅粉灰)、#7FB3FF(中度天蓝)、#FFF2A8(浅柠黄)、#CFFFE1(淡薄荷绿)、#D8DEE8(浅中性灰)、#D8D2E8(淡灰紫)、#A8F7F0(高对比浅青)、#A0BBA0(灰竹绿);auto 时由服务端自动选色。手动指定时不能与角色或素材本体的颜色接近" "description": "抠图纯色背景,仅 kind=character(角色形象)和 kind=art-spritesheet(图标素材)生效,其它 kind 携带会被拒绝。生成时把主体置于该纯色背景上,回图后据此抠除背景。取值只能是 auto 或下列色板 hex 之一,传值只填 hex 本身、不要附带色名:#CFEFFF(浅雾蓝)、#B0C2E0(浅钢蓝)、#FFD6C2(暖浅桃色)、#E6D8FF(淡薰衣草紫)、#F4D8E8(浅粉灰)、#7FB3FF(中度天蓝)、#FFF2A8(浅柠黄)、#CFFFE1(淡薄荷绿)、#D8DEE8(浅中性灰)、#D8D2E8(淡灰紫)、#A8F7F0(高对比浅青)、#A0BBA0(灰竹绿);auto 时由服务端自动选色。手动指定时不能与角色或素材本体的颜色接近"
} }
}, },
"required": ["prompt"], "required": ["prompt"],
@@ -341,7 +341,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
"type": "string", "type": "string",
"minLength": 1, "minLength": 1,
"maxLength": 80, "maxLength": 80,
"description": "可选的 manifest 资源 kind 精确过滤,必须是 canonical kind例如 video、character-animation、sound-effect、background-music、icon-spritesheet);传非 canonical 值会被拒绝并报错,不会静默返回空列表" "description": "可选的 manifest 资源 kind 精确过滤,例如 video、character-animation、sound-effect、background-music 或 art-spritesheet-slice"
}, },
"assetId": { "assetId": {
"type": "string", "type": "string",
@@ -2523,7 +2523,7 @@ mod tests {
// 目录外的 kind 必须在本地拒绝:既不下发 bridge,也不产生任何计费副作用。 // 目录外的 kind 必须在本地拒绝:既不下发 bridge,也不产生任何计费副作用。
let unsupported_kind = call_agc_generate_image(&json!({ let unsupported_kind = call_agc_generate_image(&json!({
"prompt": "像素月光主角", "prompt": "像素月光主角",
"kind": "future-kind" "kind": "game-art"
})) }))
.await; .await;
assert_eq!(unsupported_kind["isError"], json!(true)); assert_eq!(unsupported_kind["isError"], json!(true));
@@ -2533,7 +2533,7 @@ mod tests {
let oversized_prompt = call_agc_generate_image(&json!({ let oversized_prompt = call_agc_generate_image(&json!({
"prompt": "x".repeat(DIRECT_TOOLS_MCP_MAX_IMAGE_PROMPT_CHARS + 1), "prompt": "x".repeat(DIRECT_TOOLS_MCP_MAX_IMAGE_PROMPT_CHARS + 1),
"kind": "icon-spritesheet" "kind": "art-spritesheet"
})) }))
.await; .await;
assert_eq!(oversized_prompt["isError"], json!(true)); assert_eq!(oversized_prompt["isError"], json!(true));
@@ -2897,12 +2897,12 @@ mod tests {
fn tool_chain_image_asset(id: &str, local_path: &str) -> GameCreationAppAssetManifestEntry { fn tool_chain_image_asset(id: &str, local_path: &str) -> GameCreationAppAssetManifestEntry {
GameCreationAppAssetManifestEntry { GameCreationAppAssetManifestEntry {
id: id.to_string(), id: id.to_string(),
kind: GameCreationAppAssetKind::Image, kind: "image".to_string(),
media_type: "image/png".to_string(), media_type: "image/png".to_string(),
local_path: local_path.to_string(), local_path: local_path.to_string(),
image_sequence_frames: None, image_sequence_frames: None,
image_sequence_duration_ms: None, image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Image), category: game_creation_app_asset_category_for_kind("image"),
tags: Vec::new(), tags: Vec::new(),
source: GameCreationAppAssetSource { source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas, kind: GameCreationAppAssetSourceKind::Canvas,
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More