Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47f7cb2bf1 | |||
| 603650fbf4 | |||
| 5d20f5bfeb | |||
| af38de9fd1 | |||
| 18252e24b8 | |||
| 1ce2c10107 | |||
| 48caa23997 | |||
| ea141d9f17 | |||
| 769740a46d | |||
| b472b9eec9 | |||
| 0d5940433d | |||
| e5fd454bb3 | |||
| 03d7bca9a5 | |||
| 2abad3b537 | |||
| fc5cea43ac | |||
| c2ab9674a0 | |||
| 72cd0747a4 | |||
| 3d4c7929c3 | |||
| 8dce016552 | |||
| 64879cfcdd | |||
| 6853ec5388 | |||
| 796ad7bb51 | |||
| 20b34918d3 | |||
| 04e5512777 | |||
| 0510cd8d9c | |||
| 7a237431e0 | |||
| 6ed3772ff5 | |||
| a86ead93e4 | |||
| b0ce384b8d | |||
| b56566f40e | |||
| 6782d0ea23 | |||
| e6092dda6f | |||
| 5f13698129 | |||
| 29ba9a358e | |||
| 3ab56e6cfa | |||
| 56c4faa8ee | |||
| d23c808376 | |||
| 41930b858e | |||
| 9dccf5318e | |||
| f216381619 | |||
| d454d3fa4b | |||
| a5ee45aa4e | |||
| d333f783fe | |||
| faab318127 | |||
| a4296a4bdd | |||
| 7eee89ded3 | |||
| f9bc2d917c | |||
| b96cd4b7c2 | |||
| f185d21f55 | |||
| a61b0cce81 | |||
| 2a75a2d2c8 | |||
| 512f669f57 | |||
| 21dbbb4c3c | |||
| 55d0c99e2b | |||
| 3907a8bf74 | |||
| 46dfa2cffb | |||
| 97e2b26900 | |||
| 39e33f7a5b | |||
| 040f646480 | |||
| 8b007c637d | |||
| f0a62c6306 | |||
| 30edfee00e | |||
| c2da8e184e | |||
| a19319d815 | |||
| d07ea186fa | |||
| 80caec373a | |||
| 3caefd6716 | |||
| 4d0dd2d721 | |||
| 2befaaef9d | |||
| 20b0bf4dd7 | |||
| b4fd8d9b8b | |||
| fe85fa2a62 | |||
| b14a533b17 | |||
| 42969b2218 | |||
| c4ee75a1af | |||
| 6f2137cbb0 | |||
| 70a2246e8b | |||
| b7e3dac661 | |||
| 2e4a1996c8 | |||
| fe4e952853 | |||
| 18654b6806 | |||
| ee7d00c0b1 | |||
| 9cd1a94369 | |||
| 470e85c0ff |
@@ -23,3 +23,6 @@
|
||||
*.meta text
|
||||
*.anim text
|
||||
*.controller text
|
||||
|
||||
# Rust ts-rs 生成的共享契约:保留在仓库中供 TS 消费,但不作为手写源文件统计。
|
||||
packages/shared/src/contracts/generated/** linguist-generated=true
|
||||
|
||||
@@ -47,8 +47,6 @@ 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/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/darwin-arm64/
|
||||
/apps/ai-game-creator-shell/src-tauri/resources/codex/mac-native/darwin-x64/
|
||||
/plugins/agc-cocos-editor/native/payload/
|
||||
/apps/ai-game-creator-shell/logs/
|
||||
/apps/ai-game-creator-shell/.llm-drafts/
|
||||
|
||||
+10
-1
@@ -1,5 +1,14 @@
|
||||
{
|
||||
"singleQuote": 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 ===
|
||||
'assets/ui-prototype.png' &&
|
||||
designFoundationAssetCall.arguments.input.aspectRatio === '16:9' &&
|
||||
designFoundationAssetCall.arguments.input.assetKind === 'ui-prototype' &&
|
||||
designFoundationAssetCall.arguments.input.assetKind === 'ui-design' &&
|
||||
artAssetPlanAssetCall?.arguments?.input?.outputPath ===
|
||||
'assets/art-spritesheet.png' &&
|
||||
artAssetPlanAssetCall.arguments.input.aspectRatio === '1:1' &&
|
||||
artAssetPlanAssetCall.arguments.input.assetKind === 'art-spritesheet',
|
||||
artAssetPlanAssetCall.arguments.input.assetKind === 'icon-spritesheet',
|
||||
'self-test-visual-assets-invalid',
|
||||
);
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ const requiredFormalArtifactSpecs = [
|
||||
{ path: 'game/balance.json', kind: 'json' },
|
||||
{ path: 'assets/manifest.art.json', kind: 'json' },
|
||||
{ path: 'assets/manifest.audio.json', kind: 'json' },
|
||||
{ path: 'game/index.html', kind: 'game-entry' },
|
||||
{ path: 'game/index.html', kind: 'file' },
|
||||
{ path: 'exports/README.md', kind: 'file' },
|
||||
];
|
||||
const editorImageArtifactSpecs = [
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
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,12 +42,16 @@ function explicitBuildTarget(args) {
|
||||
}
|
||||
|
||||
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',
|
||||
'universal-apple-darwin',
|
||||
].includes(target)
|
||||
) {
|
||||
throw new Error(`不支持的发布目标:${target}`);
|
||||
@@ -193,12 +197,10 @@ export function updateManifestUrl(channel = resolveReleaseChannel()) {
|
||||
}
|
||||
|
||||
/**
|
||||
* universal 主程序与双目录原生资源共用一个更新包;单架构只登记实际目标。
|
||||
* 单架构产物只登记实际目标,不能把同一原生资源映射为另一架构。
|
||||
*/
|
||||
export function resolveManifestPlatformKeys(target = defaultTarget()) {
|
||||
validateReleaseTarget(target);
|
||||
if (target === 'universal-apple-darwin')
|
||||
return ['darwin-aarch64', 'darwin-x86_64'];
|
||||
if (target === 'aarch64-apple-darwin') return ['darwin-aarch64'];
|
||||
if (target === 'x86_64-apple-darwin') return ['darwin-x86_64'];
|
||||
if (target.includes('windows')) {
|
||||
|
||||
@@ -39,12 +39,13 @@ import {
|
||||
const windowsTarget = 'x86_64-pc-windows-msvc';
|
||||
const universalTarget = 'universal-apple-darwin';
|
||||
|
||||
test('native sidecar builds accept universal and each macOS architecture', () => {
|
||||
for (const target of [
|
||||
universalTarget,
|
||||
'aarch64-apple-darwin',
|
||||
'x86_64-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',
|
||||
@@ -151,11 +152,8 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('macOS manifests advertise exactly the architectures actually built', () => {
|
||||
assert.deepEqual(resolveManifestPlatformKeys(universalTarget), [
|
||||
'darwin-aarch64',
|
||||
'darwin-x86_64',
|
||||
]);
|
||||
test('macOS manifests only advertise the architecture actually built', () => {
|
||||
assert.throws(() => resolveManifestPlatformKeys(universalTarget), /单架构/);
|
||||
assert.deepEqual(resolveManifestPlatformKeys('aarch64-apple-darwin'), [
|
||||
'darwin-aarch64',
|
||||
]);
|
||||
@@ -199,6 +197,7 @@ test('release context resolves explicit targets before environment/default and f
|
||||
['--target='],
|
||||
['--target', '--no-bundle'],
|
||||
['--target', windowsTarget, '--target=aarch64-apple-darwin'],
|
||||
['--target', universalTarget],
|
||||
['--target', 'unknown'],
|
||||
])
|
||||
assert.throws(() => resolveReleaseContext(args, {}));
|
||||
@@ -315,8 +314,8 @@ test('invalid target or mismatched channel fails before any release side effect'
|
||||
},
|
||||
};
|
||||
await assert.rejects(
|
||||
() => buildRelease(['--target', 'unknown'], sideEffects),
|
||||
/不支持的发布目标/,
|
||||
() => buildRelease(['--target', universalTarget], sideEffects),
|
||||
/单架构/,
|
||||
);
|
||||
await withEnv({ AGC_UPDATE_CHANNEL: 'dev-win' }, () =>
|
||||
assert.rejects(
|
||||
@@ -327,26 +326,6 @@ test('invalid target or mismatched channel fails before any release side effect'
|
||||
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', () => {
|
||||
const files = ['/tmp/mac.app.tar.gz', '/tmp/windows.exe', '/tmp/mac.dmg'];
|
||||
for (const context of [
|
||||
|
||||
@@ -1367,20 +1367,18 @@ if (windowsTauriConfig.bundle?.useLocalToolsDir !== true) {
|
||||
assert.deepEqual(
|
||||
macosTauriConfig.bundle?.resources,
|
||||
Object.fromEntries([
|
||||
...['darwin-arm64', 'darwin-x64'].flatMap((arch) =>
|
||||
[
|
||||
'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/${arch}/${file}`,
|
||||
`coding-agent/mac-native/${arch}/${file}`,
|
||||
]),
|
||||
),
|
||||
...[
|
||||
'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',
|
||||
|
||||
@@ -8,13 +8,6 @@ import path from 'node:path';
|
||||
// 只操作临时复制品;不启动 GUI、不读取开发机凭据、不访问 Provider。
|
||||
assert.equal(process.platform, 'darwin', '此验证必须在 macOS 执行');
|
||||
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(
|
||||
source.endsWith('.app') && fs.statSync(source).isDirectory(),
|
||||
'请传入 .app 绝对路径',
|
||||
@@ -38,19 +31,13 @@ const env = {
|
||||
};
|
||||
|
||||
function run(command, args) {
|
||||
// 只强制被测应用切片;本机 Xcode 检查工具可能仅提供宿主架构。
|
||||
const useSlice = command.startsWith(`${app}${path.sep}`);
|
||||
const result = spawnSync(
|
||||
useSlice ? '/usr/bin/arch' : command,
|
||||
useSlice ? [`-${architecture}`, command, ...args] : args,
|
||||
{
|
||||
cwd: root,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
timeout: 120_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
},
|
||||
);
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: root,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
assert.ifError(result.error);
|
||||
return result;
|
||||
}
|
||||
@@ -73,7 +60,7 @@ async function handshake(executable) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error('app-server 初始化超时')),
|
||||
120_000,
|
||||
15_000,
|
||||
);
|
||||
const finish = (error) => {
|
||||
clearTimeout(timer);
|
||||
@@ -142,39 +129,20 @@ async function handshake(executable) {
|
||||
try {
|
||||
fs.cpSync(source, app, { recursive: true });
|
||||
const resources = path.join(app, 'Contents/Resources');
|
||||
const platform = architecture === 'arm64' ? 'darwin-arm64' : 'darwin-x64';
|
||||
const bundle = path.join(resources, 'coding-agent/mac-native', platform);
|
||||
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 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(
|
||||
fs.readFileSync(path.join(bundle, 'manifest.json'), 'utf8'),
|
||||
);
|
||||
assert.equal(manifest.schemaVersion, 'genarrative-codex-sidecar.v2');
|
||||
assert.equal(manifest.platform, platform);
|
||||
assert.equal(
|
||||
manifest.platform,
|
||||
process.arch === 'arm64' ? 'darwin-arm64' : 'darwin-x64',
|
||||
);
|
||||
assert.equal(manifest.version, 'codex-cli 0.147.0');
|
||||
const components = [
|
||||
'bin/codex',
|
||||
@@ -191,7 +159,11 @@ try {
|
||||
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(), architecture, component);
|
||||
assert.equal(
|
||||
arch.stdout.trim(),
|
||||
process.arch === 'arm64' ? 'arm64' : 'x86_64',
|
||||
component,
|
||||
);
|
||||
}
|
||||
}
|
||||
assert.ok(fs.existsSync(path.join(bundle, 'NOTICE.md')));
|
||||
@@ -240,7 +212,7 @@ try {
|
||||
assert.notEqual(broken.status, 0);
|
||||
assert.match(`${broken.stdout}\n${broken.stderr}`, /Codex CLI 未安装/);
|
||||
console.log(
|
||||
`PASS (${architecture}): 隔离安装包资源、架构、摘要、权限、正式 Codex 查找、app-server 握手及缺组件拒绝`,
|
||||
'PASS: 隔离安装包资源、架构、摘要、权限、正式 Codex 查找、app-server 握手及缺组件拒绝',
|
||||
);
|
||||
console.log(
|
||||
'未验证:GUI、真实登录/Provider 对话、Cocos macOS 原生桥接;插件 Node 仍为外部前提',
|
||||
|
||||
@@ -290,7 +290,7 @@ function deterministicArtManifest(
|
||||
{
|
||||
id: 'garden-guardians-spritesheet',
|
||||
path: 'assets/art-spritesheet.png',
|
||||
kind: 'art-spritesheet',
|
||||
kind: 'icon-spritesheet',
|
||||
usage: ['defenders', 'enemies', 'battlefield-ui'],
|
||||
source: 'canvas',
|
||||
status: 'ready',
|
||||
@@ -710,7 +710,7 @@ function canvasAssetCall(agentId) {
|
||||
outputPath: 'assets/ui-prototype.png',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '2K',
|
||||
assetKind: 'ui-prototype',
|
||||
assetKind: 'ui-design',
|
||||
assetLabel: '游戏横屏界面原型图',
|
||||
replaceExisting: false,
|
||||
});
|
||||
@@ -721,7 +721,7 @@ function canvasAssetCall(agentId) {
|
||||
outputPath: 'assets/art-spritesheet.png',
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
assetKind: 'art-spritesheet',
|
||||
assetKind: 'icon-spritesheet',
|
||||
assetLabel: '游戏首版核心美术素材',
|
||||
replaceExisting: false,
|
||||
sliceMode: 'connected-components',
|
||||
@@ -830,12 +830,12 @@ function missingGeneratedVisualAssetObservation(context, agentId) {
|
||||
},
|
||||
'design-foundation': {
|
||||
path: 'assets/ui-prototype.png',
|
||||
kind: 'ui-prototype',
|
||||
kind: 'ui-design',
|
||||
summary: '策划界面原型图尚未按正式视觉流程生成并登记,不能完成任务',
|
||||
},
|
||||
'art-asset-plan': {
|
||||
path: 'assets/art-spritesheet.png',
|
||||
kind: 'art-spritesheet',
|
||||
kind: 'icon-spritesheet',
|
||||
summary: '首版美术素材图尚未按正式视觉流程生成并登记,不能完成任务',
|
||||
},
|
||||
}[agentId];
|
||||
@@ -2667,7 +2667,7 @@ function createDeterministicCanvasFixture(apiKey) {
|
||||
asset: {
|
||||
assetId: `asset-${sliceImageId}`,
|
||||
assetObjectId: sliceAssetObjectId,
|
||||
assetKind: 'art-spritesheet-slice',
|
||||
assetKind: 'icon',
|
||||
projectId,
|
||||
taskId,
|
||||
},
|
||||
@@ -2707,7 +2707,7 @@ function createDeterministicCanvasFixture(apiKey) {
|
||||
spritesheetAsset: {
|
||||
assetId: `asset-${imageId}`,
|
||||
assetObjectId,
|
||||
assetKind: 'art-spritesheet',
|
||||
assetKind: 'icon-spritesheet',
|
||||
projectId,
|
||||
taskId,
|
||||
},
|
||||
@@ -2749,7 +2749,7 @@ function createDeterministicCanvasFixture(apiKey) {
|
||||
const assetObjectId = `asset-object-${imageId}`;
|
||||
const resourceId = `resource-${imageId}`;
|
||||
const assetKind =
|
||||
typeof body?.assetKind === 'string' ? body.assetKind : 'game-art';
|
||||
typeof body?.assetKind === 'string' ? body.assetKind : 'image';
|
||||
images.set(imageId, {
|
||||
...image,
|
||||
objectKey,
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
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
@@ -5016,6 +5016,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"ts-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -56,7 +56,7 @@ platform-agent = { path = "../../../server-rs/crates/platform-agent" }
|
||||
portable-pty = "0.9"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] }
|
||||
regex = "1"
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] }
|
||||
tauri = { version = "2.11.2", features = [] }
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
|
||||
|
||||
@@ -31,23 +31,7 @@ fn sha256_file(path: &std::path::Path) -> Result<String, std::io::Error> {
|
||||
fn stage_bundled_codex_cli(manifest_dir: &std::path::Path) {
|
||||
let target = env::var("TARGET").expect("Cargo TARGET");
|
||||
println!("cargo:rustc-env=AGC_BUILD_TARGET={target}");
|
||||
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 {
|
||||
let Some(layout) = codex_bundle::for_target(&target) else {
|
||||
assert!(
|
||||
!target.contains("windows") && !target.contains("apple-darwin"),
|
||||
"不支持的 Codex 随包目标:{target}"
|
||||
@@ -97,7 +81,7 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) {
|
||||
&fs::read(source.join("codex-package.json")).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}"));
|
||||
let target_dir = manifest_dir.join("resources/codex").join(layout.directory);
|
||||
let notice = target_dir.join("NOTICE.md");
|
||||
|
||||
@@ -49,11 +49,7 @@ pub fn for_target(target: &str) -> Option<Layout> {
|
||||
} else {
|
||||
"codex-darwin-x64"
|
||||
},
|
||||
directory: if target.starts_with("aarch64") {
|
||||
"mac-native/darwin-arm64"
|
||||
} else {
|
||||
"mac-native/darwin-x64"
|
||||
},
|
||||
directory: "mac-native",
|
||||
executable: "bin/codex",
|
||||
files: MAC_FILES,
|
||||
}),
|
||||
@@ -94,9 +90,6 @@ mod tests {
|
||||
let intel = for_target("x86_64-apple-darwin").unwrap();
|
||||
assert_eq!(intel.platform, "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();
|
||||
assert_eq!(windows.directory, "win-x64");
|
||||
assert_eq!(windows.files.len(), 6);
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/platform-art-contract.md"
|
||||
],
|
||||
"sha256": "47ac742d9b88e5d6cd9833484ab212152578e58ae27f7add312fd1d78183385c"
|
||||
"sha256": "6668bf1aa69601bcc65c97fdcd879c699c3139befcd70805d95614997f6e44e9"
|
||||
},
|
||||
{
|
||||
"name": "agc-web-game-development",
|
||||
|
||||
+2
-2
@@ -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
|
||||
the complete game-art package and its canonical slices.
|
||||
|
||||
With `agc_generate_image`, `kind="character"` and `kind="art-spritesheet"`
|
||||
With `agc_generate_image`, `kind="character"` and `kind="icon-spritesheet"`
|
||||
generate the subject on a solid-colour background and automatically matte it
|
||||
away afterwards, producing transparent-background results; write the prompt
|
||||
for the subject only, never for a scene. `kind="image"` keeps the rendered
|
||||
frame without extra processing.
|
||||
|
||||
When `agc_generate_image` is used with `kind="art-spritesheet"`, `sliceMode` is
|
||||
When `agc_generate_image` is used with `kind="icon-spritesheet"`, `sliceMode` is
|
||||
required and has no default, so decide it explicitly:
|
||||
|
||||
- Use `sliceMode="grid"` with `gridX` and `gridY` (1-32 each) only when the user
|
||||
|
||||
@@ -1198,7 +1198,7 @@ mod tests {
|
||||
"tool": "agc_generate_image",
|
||||
"arguments": {
|
||||
"prompt": prompt,
|
||||
"kind": "art-spritesheet",
|
||||
"kind": "icon-spritesheet",
|
||||
"sliceMode": "connected-components",
|
||||
"sliceCount": 8,
|
||||
"screenColor": "#CFEFFF"
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem};
|
||||
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem};
|
||||
use super::validation::validate_direct_codex_user_item;
|
||||
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 std::path::Path;
|
||||
|
||||
@@ -111,29 +115,165 @@ pub(crate) fn direct_codex_user_item_to_prompt(
|
||||
item: &DirectCodexUserItem,
|
||||
) -> Result<String, String> {
|
||||
let wire = direct_codex_user_item_to_wire_input(root, item)?;
|
||||
wire.as_array()
|
||||
let DirectCodexUserItem::Message(message) = item;
|
||||
let mut prompt = wire
|
||||
.as_array()
|
||||
.ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())
|
||||
.map(|parts| {
|
||||
.and_then(|parts| {
|
||||
parts
|
||||
.iter()
|
||||
.filter_map(|part| part.get("text").and_then(Value::as_str))
|
||||
.collect::<String>()
|
||||
})
|
||||
.and_then(|prompt| {
|
||||
if prompt.trim().is_empty() {
|
||||
Err("DirectProject user item 不能转换为空 prompt".to_string())
|
||||
} else {
|
||||
Ok(prompt)
|
||||
.map(|part| {
|
||||
part.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| "DirectProject user item wire part 缺少 text".to_string())
|
||||
})
|
||||
.collect::<Result<String, String>>()
|
||||
})?;
|
||||
if let Some(code_context) = render_ui_design_code_context(root, message)? {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::direct_codex_user_item_to_response_item;
|
||||
use super::{direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item};
|
||||
use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE;
|
||||
use serde_json::json;
|
||||
use shared_contracts::game_creation_app::{
|
||||
GameCreationAppAssetKind, GameCreationAppAssetSource, GameCreationAppAssetSourceKind,
|
||||
};
|
||||
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]
|
||||
fn standard_response_item_passes_through_without_agc_private_parts() {
|
||||
let item = json!({
|
||||
@@ -174,4 +314,81 @@ mod tests {
|
||||
.expect_err("history item without type must fail");
|
||||
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,7 +27,6 @@ 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_KIND_CHARS: usize = 80;
|
||||
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_LOCAL_ASSET_PATH_CHARS: usize = 512;
|
||||
|
||||
@@ -68,7 +67,6 @@ struct DirectToolBridgeActiveTurnAuthorization {
|
||||
turn_id: String,
|
||||
brief_sha256: Option<String>,
|
||||
completed_result: Option<Value>,
|
||||
resource_request_ids: BTreeMap<String, (String, String)>,
|
||||
}
|
||||
|
||||
enum DirectToolBridgeRegenerationCall {
|
||||
@@ -207,7 +205,6 @@ impl DirectToolBridgeState {
|
||||
turn_id: turn_id.clone(),
|
||||
brief_sha256: None,
|
||||
completed_result: None,
|
||||
resource_request_ids: BTreeMap::new(),
|
||||
});
|
||||
Ok(DirectToolBridgeTurnGuard {
|
||||
state: Arc::clone(self),
|
||||
@@ -637,30 +634,17 @@ impl DirectToolBridgeState {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resource_request_ids(&self, request_fingerprint: &str) -> Result<(String, String), String> {
|
||||
let mut authorization = self
|
||||
/// 取当前回合身份,媒体资源请求按回合身份与请求指纹确定性派生 operation/idempotency id。
|
||||
fn active_resource_turn_id(&self) -> Result<String, String> {
|
||||
let authorization = self
|
||||
.turn_authorization
|
||||
.lock()
|
||||
.map_err(|_| "AGC 工具桥回合授权状态不可用".to_string())?;
|
||||
let active = authorization
|
||||
authorization
|
||||
.active
|
||||
.as_mut()
|
||||
.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))
|
||||
.as_ref()
|
||||
.map(|active| active.turn_id.clone())
|
||||
.ok_or_else(|| "当前没有客户端签发的资源生成回合身份".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1213,9 +1197,9 @@ fn bridge_registered_resource(
|
||||
"mediaType": asset.media_type,
|
||||
// Agent 与 UI 必须看到同一个口径:UI 栏目走 TS 的 `gameCreationAppAssetCategory`
|
||||
// (落盘值 + 按 kind 派生 + 读时自愈),这里走 Rust 的同构实现。
|
||||
// 直接透传落盘 `asset.category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、
|
||||
// 直接透传落盘 `asset.category` 会让 UI 设计资产在 UI 显示「UI 交互」、
|
||||
// 在 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,
|
||||
"canvasProjectId": asset.source.canvas_project_id,
|
||||
"resourceId": asset.source.resource_id,
|
||||
@@ -1256,7 +1240,9 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value {
|
||||
arguments,
|
||||
"kind",
|
||||
DIRECT_TOOL_BRIDGE_MAX_RESOURCE_KIND_CHARS,
|
||||
)?;
|
||||
)?
|
||||
.map(|kind| bridge_asset_list_kind_filter(&kind))
|
||||
.transpose()?;
|
||||
let asset_id = bridge_optional_bounded_string(
|
||||
arguments,
|
||||
"assetId",
|
||||
@@ -1276,7 +1262,7 @@ fn bridge_list_registered_assets(root: &Path, arguments: &Value) -> Value {
|
||||
let mut assets = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.filter(|asset| kind.as_ref().is_none_or(|kind| asset.kind == *kind))
|
||||
.filter(|asset| kind.is_none_or(|kind| asset.kind == kind))
|
||||
.filter(|asset| {
|
||||
asset_id
|
||||
.as_ref()
|
||||
@@ -1342,6 +1328,24 @@ 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>) {
|
||||
let extension = Path::new(path)
|
||||
.extension()
|
||||
@@ -1890,8 +1894,11 @@ async fn bridge_create_or_derive_resource(
|
||||
))
|
||||
.await?
|
||||
} else {
|
||||
let (operation_id, idempotency_key) =
|
||||
state.resource_request_ids(&request_fingerprint)?;
|
||||
let turn_id = state.active_resource_turn_id()?;
|
||||
let operation_id =
|
||||
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 source_resource_id = source_asset
|
||||
.as_ref()
|
||||
@@ -1909,7 +1916,7 @@ async fn bridge_create_or_derive_resource(
|
||||
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_media_type: source_asset.as_ref().map(|asset| asset.media_type.clone()),
|
||||
source_subtype: source_asset.as_ref().map(|asset| asset.kind.clone()),
|
||||
source_subtype: source_asset.as_ref().map(|asset| asset.kind.to_string()),
|
||||
producer_task_id: source_asset
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.source.task_id.clone()),
|
||||
@@ -2001,7 +2008,11 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
|
||||
})
|
||||
.await?
|
||||
} else {
|
||||
let (operation_id, idempotency_key) = state.resource_request_ids(&fingerprint)?;
|
||||
// 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(),
|
||||
@@ -2015,7 +2026,7 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
|
||||
source_asset_id: Some(source_asset_id.clone()),
|
||||
source_path: Some(source_asset.local_path.clone()),
|
||||
source_media_type: Some(source_asset.media_type.clone()),
|
||||
source_subtype: Some(source_asset.kind.clone()),
|
||||
source_subtype: Some(source_asset.kind.to_string()),
|
||||
producer_task_id: source_asset.source.task_id.clone(),
|
||||
source_version_id: None,
|
||||
prompt: "去除背景".to_string(),
|
||||
@@ -2183,35 +2194,37 @@ async fn bridge_prepare_game_art(state: &DirectToolBridgeState, arguments: &Valu
|
||||
result
|
||||
}
|
||||
|
||||
fn bridge_image_generation_kind(arguments: &Value) -> Result<String, String> {
|
||||
fn bridge_image_generation_kind(arguments: &Value) -> Result<GameCreationAppAssetKind, String> {
|
||||
let kind = arguments
|
||||
.get("kind")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("image");
|
||||
normalize_platform_art_asset_generation_kind(kind)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"工具参数 kind 只允许 {}",
|
||||
PLATFORM_ART_ASSET_GENERATION_KINDS.join("、")
|
||||
)
|
||||
})
|
||||
normalize_platform_art_asset_generation_kind(kind).ok_or_else(|| {
|
||||
format!(
|
||||
"工具参数 kind 只允许 {}",
|
||||
PLATFORM_ART_ASSET_GENERATION_KINDS
|
||||
.iter()
|
||||
.map(|kind| kind.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("、")
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// 切分模式没有默认值:图集必须显式声明,且声明必须与 kind 和网格参数自洽。
|
||||
fn validate_generate_image_slice_declaration(
|
||||
kind: &str,
|
||||
kind: GameCreationAppAssetKind,
|
||||
slice_mode: Option<&str>,
|
||||
grid_x: Option<u32>,
|
||||
grid_y: Option<u32>,
|
||||
slice_count: Option<usize>,
|
||||
) -> Result<(), String> {
|
||||
if kind == "art-spritesheet" {
|
||||
if kind == GameCreationAppAssetKind::IconSpritesheet {
|
||||
if slice_mode.is_none() {
|
||||
return Err(
|
||||
"kind=art-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时传 sliceMode=grid 并提供 gridX/gridY;自由排布、数量不定或只要求一张图集时传 sliceMode=connected-components"
|
||||
"kind=icon-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时传 sliceMode=grid 并提供 gridX/gridY;自由排布、数量不定或只要求一张图集时传 sliceMode=connected-components"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
@@ -2224,19 +2237,19 @@ fn validate_generate_image_slice_declaration(
|
||||
}
|
||||
if slice_mode.is_some() || grid_x.is_some() || grid_y.is_some() || slice_count.is_some() {
|
||||
return Err(format!(
|
||||
"工具参数 sliceMode/gridX/gridY/sliceCount 仅对 kind=art-spritesheet 生效,当前 kind={kind}"
|
||||
"工具参数 sliceMode/gridX/gridY/sliceCount 仅对 kind=icon-spritesheet 生效,当前 kind={kind}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 抠图纯色背景只服务 character 与 art-spritesheet 链路;格式校验收口为
|
||||
/// 抠图纯色背景只服务 character 与 icon-spritesheet 链路;格式校验收口为
|
||||
/// `auto` 或 `#RRGGBB`(服务端另有支持色板,客户端不复制),`auto`/空串归一为
|
||||
/// None(服务端自动决策),hex 统一大写后透传。其它 kind 携带该字段直接拒绝,
|
||||
/// 避免服务端静默忽略造成“已生效”的误解。
|
||||
fn normalize_generate_image_screen_color(
|
||||
arguments: &Value,
|
||||
kind: &str,
|
||||
kind: GameCreationAppAssetKind,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(value) = arguments.get("screenColor") else {
|
||||
return Ok(None);
|
||||
@@ -2244,9 +2257,12 @@ fn normalize_generate_image_screen_color(
|
||||
if value.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
if !matches!(kind, "character" | "art-spritesheet") {
|
||||
if !matches!(
|
||||
kind,
|
||||
GameCreationAppAssetKind::Character | GameCreationAppAssetKind::IconSpritesheet
|
||||
) {
|
||||
return Err(format!(
|
||||
"工具参数 screenColor 仅对 kind=character 和 kind=art-spritesheet 生效,当前 kind={kind}"
|
||||
"工具参数 screenColor 仅对 kind=character 和 kind=icon-spritesheet 生效,当前 kind={kind}"
|
||||
));
|
||||
}
|
||||
let raw = value
|
||||
@@ -2373,18 +2389,18 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
|
||||
})
|
||||
.transpose()?;
|
||||
validate_generate_image_slice_declaration(
|
||||
kind.as_str(),
|
||||
kind,
|
||||
slice_mode.as_deref(),
|
||||
grid_x,
|
||||
grid_y,
|
||||
slice_count,
|
||||
)?;
|
||||
let screen_color = normalize_generate_image_screen_color(arguments, kind.as_str())?;
|
||||
let screen_color = normalize_generate_image_screen_color(arguments, kind)?;
|
||||
let options = PlatformArtAssetGenerationOptions {
|
||||
output_path,
|
||||
aspect_ratio,
|
||||
image_size,
|
||||
asset_kind: kind.clone(),
|
||||
asset_kind: kind,
|
||||
asset_label: asset_name.clone(),
|
||||
replace_existing: false,
|
||||
slice_count,
|
||||
@@ -2873,14 +2889,19 @@ pub(crate) async fn start_direct_tool_bridge(
|
||||
mod tests {
|
||||
#[test]
|
||||
fn generate_image_slice_declaration_is_explicit_and_self_consistent() {
|
||||
let missing =
|
||||
validate_generate_image_slice_declaration("art-spritesheet", None, None, None, None)
|
||||
.expect_err("art-spritesheet without sliceMode must fail closed");
|
||||
let missing = validate_generate_image_slice_declaration(
|
||||
GameCreationAppAssetKind::IconSpritesheet,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect_err("icon-spritesheet without sliceMode must fail closed");
|
||||
assert!(missing.contains("没有默认值"), "{missing}");
|
||||
assert!(missing.contains("connected-components"), "{missing}");
|
||||
|
||||
assert!(validate_generate_image_slice_declaration(
|
||||
"art-spritesheet",
|
||||
GameCreationAppAssetKind::IconSpritesheet,
|
||||
Some("connected-components"),
|
||||
None,
|
||||
None,
|
||||
@@ -2888,7 +2909,7 @@ mod tests {
|
||||
)
|
||||
.is_ok());
|
||||
assert!(validate_generate_image_slice_declaration(
|
||||
"art-spritesheet",
|
||||
GameCreationAppAssetKind::IconSpritesheet,
|
||||
Some("grid"),
|
||||
Some(3),
|
||||
Some(2),
|
||||
@@ -2896,7 +2917,7 @@ mod tests {
|
||||
)
|
||||
.is_ok());
|
||||
let grid_with_count = validate_generate_image_slice_declaration(
|
||||
"art-spritesheet",
|
||||
GameCreationAppAssetKind::IconSpritesheet,
|
||||
Some("grid"),
|
||||
Some(2),
|
||||
Some(2),
|
||||
@@ -2906,58 +2927,80 @@ mod tests {
|
||||
assert!(grid_with_count.contains("gridX×gridY"), "{grid_with_count}");
|
||||
|
||||
let wrong_kind = validate_generate_image_slice_declaration(
|
||||
"image",
|
||||
GameCreationAppAssetKind::Image,
|
||||
Some("connected-components"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect_err("slice declaration must stay scoped to art-spritesheet");
|
||||
.expect_err("slice declaration must stay scoped to icon-spritesheet");
|
||||
assert!(
|
||||
wrong_kind.contains("仅对 kind=art-spritesheet 生效"),
|
||||
wrong_kind.contains("仅对 kind=icon-spritesheet 生效"),
|
||||
"{wrong_kind}"
|
||||
);
|
||||
let wrong_kind_count =
|
||||
validate_generate_image_slice_declaration("image", None, None, None, Some(8))
|
||||
.expect_err("sliceCount-only violation must be rejected");
|
||||
let wrong_kind_count = validate_generate_image_slice_declaration(
|
||||
GameCreationAppAssetKind::Image,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(8),
|
||||
)
|
||||
.expect_err("sliceCount-only violation must be rejected");
|
||||
assert!(
|
||||
wrong_kind_count.contains("sliceCount"),
|
||||
"sliceCount-only violation must name sliceCount: {wrong_kind_count}"
|
||||
);
|
||||
assert!(validate_generate_image_slice_declaration("image", None, None, None, None).is_ok());
|
||||
assert!(validate_generate_image_slice_declaration(
|
||||
GameCreationAppAssetKind::Image,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_image_screen_color_is_normalized_and_kind_gated() {
|
||||
// 省略与显式 null 等价,且不触发 kind 门禁。
|
||||
assert_eq!(
|
||||
normalize_generate_image_screen_color(&json!({}), "image").expect("omitted"),
|
||||
normalize_generate_image_screen_color(&json!({}), GameCreationAppAssetKind::Image)
|
||||
.expect("omitted"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_generate_image_screen_color(&json!({"screenColor": null}), "image")
|
||||
.expect("null"),
|
||||
normalize_generate_image_screen_color(
|
||||
&json!({"screenColor": null}),
|
||||
GameCreationAppAssetKind::Image
|
||||
)
|
||||
.expect("null"),
|
||||
None
|
||||
);
|
||||
// auto 家族归一为 None(服务端自动决策),大小写与空白不敏感。
|
||||
for raw in ["auto", "AUTO", " auto ", ""] {
|
||||
assert_eq!(
|
||||
normalize_generate_image_screen_color(&json!({"screenColor": raw}), "character")
|
||||
.expect("auto variants"),
|
||||
normalize_generate_image_screen_color(
|
||||
&json!({"screenColor": raw}),
|
||||
GameCreationAppAssetKind::Character
|
||||
)
|
||||
.expect("auto variants"),
|
||||
None,
|
||||
"{raw}"
|
||||
);
|
||||
}
|
||||
// hex 统一大写透传;色板白名单由服务端权威校验,客户端只守格式。
|
||||
assert_eq!(
|
||||
normalize_generate_image_screen_color(&json!({"screenColor": "#cfefff"}), "character")
|
||||
.expect("lowercase hex"),
|
||||
normalize_generate_image_screen_color(
|
||||
&json!({"screenColor": "#cfefff"}),
|
||||
GameCreationAppAssetKind::Character
|
||||
)
|
||||
.expect("lowercase hex"),
|
||||
Some("#CFEFFF".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_generate_image_screen_color(
|
||||
&json!({"screenColor": " #A0BBA0 "}),
|
||||
"art-spritesheet"
|
||||
GameCreationAppAssetKind::IconSpritesheet
|
||||
)
|
||||
.expect("padded hex"),
|
||||
Some("#A0BBA0".to_string())
|
||||
@@ -2965,19 +3008,24 @@ mod tests {
|
||||
// 非 auto/非 hex、非字符串一律拒绝。
|
||||
for bad in [json!("green"), json!("#GGGGGG"), json!("#FFF"), json!(12)] {
|
||||
assert!(
|
||||
normalize_generate_image_screen_color(&json!({"screenColor": bad}), "character")
|
||||
.is_err(),
|
||||
normalize_generate_image_screen_color(
|
||||
&json!({"screenColor": bad}),
|
||||
GameCreationAppAssetKind::Character
|
||||
)
|
||||
.is_err(),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
// 其它 kind 携带该字段直接拒绝,即使取值合法。
|
||||
let gated =
|
||||
normalize_generate_image_screen_color(&json!({"screenColor": "#CFEFFF"}), "image")
|
||||
.expect_err("screenColor must stay scoped to character/art-spritesheet");
|
||||
let gated = normalize_generate_image_screen_color(
|
||||
&json!({"screenColor": "#CFEFFF"}),
|
||||
GameCreationAppAssetKind::Image,
|
||||
)
|
||||
.expect_err("screenColor must stay scoped to character/icon-spritesheet");
|
||||
assert!(gated.contains("kind=character"), "{gated}");
|
||||
assert!(normalize_generate_image_screen_color(
|
||||
&json!({"screenColor": "auto"}),
|
||||
"ui-prototype"
|
||||
GameCreationAppAssetKind::UiDesign
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
@@ -3746,12 +3794,14 @@ mod tests {
|
||||
fn bridge_art_resource_exposes_only_the_safe_identity_projection() {
|
||||
let asset = GameCreationAppAssetManifestEntry {
|
||||
id: "local-art-1".to_string(),
|
||||
kind: "art-spritesheet-slice".to_string(),
|
||||
kind: GameCreationAppAssetKind::Icon,
|
||||
media_type: "image/png".to_string(),
|
||||
local_path: "assets/art-spritesheet-slices/player.png".to_string(),
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
category: game_creation_app_asset_category_for_kind("art-spritesheet-slice"),
|
||||
category: game_creation_app_asset_category_for_kind(
|
||||
GameCreationAppAssetKind::IconSpritesheet,
|
||||
),
|
||||
tags: Vec::new(),
|
||||
source: GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Canvas,
|
||||
@@ -3803,7 +3853,7 @@ mod tests {
|
||||
fn bridge_registered_resource_exposes_formal_sequence_without_private_generation_fields() {
|
||||
let asset = GameCreationAppAssetManifestEntry {
|
||||
id: "animation-1".to_string(),
|
||||
kind: "character-animation".to_string(),
|
||||
kind: GameCreationAppAssetKind::CharacterAnimation,
|
||||
media_type: "video/mp4".to_string(),
|
||||
local_path: "assets/edits/animation.mp4".to_string(),
|
||||
image_sequence_frames: Some(vec![
|
||||
@@ -3816,7 +3866,9 @@ mod tests {
|
||||
},
|
||||
]),
|
||||
image_sequence_duration_ms: Some(4_000),
|
||||
category: game_creation_app_asset_category_for_kind("character-animation"),
|
||||
category: game_creation_app_asset_category_for_kind(
|
||||
GameCreationAppAssetKind::CharacterAnimation,
|
||||
),
|
||||
tags: Vec::new(),
|
||||
source: GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Canvas,
|
||||
@@ -3854,7 +3906,7 @@ mod tests {
|
||||
fn bridge_registered_resource_keeps_explicit_manifest_classification() {
|
||||
let asset = GameCreationAppAssetManifestEntry {
|
||||
id: "spec-1".to_string(),
|
||||
kind: "spec".to_string(),
|
||||
kind: GameCreationAppAssetKind::Spec,
|
||||
media_type: "application/json".to_string(),
|
||||
local_path: "assets/specs/hero.json".to_string(),
|
||||
image_sequence_frames: None,
|
||||
@@ -3890,13 +3942,13 @@ mod tests {
|
||||
///
|
||||
/// UI 栏目走 TS 的 `gameCreationAppAssetCategory`(落盘值 + 按 kind 派生 + 读时自愈),
|
||||
/// Agent 走 Rust 的同构实现 `game_creation_app_asset_effective_category`。直接透传落盘
|
||||
/// `category` 会让 `kind:"ui"` 的资产在 UI 显示「UI 交互」、在 Agent 侧读到
|
||||
/// `unclassified`——真机 122 条资产里有 55 条这样分叉。
|
||||
/// `category` 会让 `ui-design` 的资产在 UI 显示「UI 交互」、在 Agent 侧读到
|
||||
/// `unclassified`,两侧分类口径必须一致。
|
||||
#[test]
|
||||
fn bridge_registered_resource_projects_effective_category_not_raw_persisted_value() {
|
||||
let asset = GameCreationAppAssetManifestEntry {
|
||||
id: "ui-1".to_string(),
|
||||
kind: "ui".to_string(),
|
||||
id: "ui-design-1".to_string(),
|
||||
kind: GameCreationAppAssetKind::UiDesign,
|
||||
media_type: "application/json".to_string(),
|
||||
local_path: "assets/UI 设计 1.json".to_string(),
|
||||
image_sequence_frames: None,
|
||||
@@ -3919,7 +3971,7 @@ mod tests {
|
||||
};
|
||||
|
||||
let projection = bridge_registered_resource(&asset, false);
|
||||
assert_eq!(projection["kind"], "ui");
|
||||
assert_eq!(projection["kind"], "ui-design");
|
||||
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",
|
||||
"enum": PLATFORM_ART_ASSET_GENERATION_KINDS,
|
||||
"default": "image",
|
||||
"description": "image=普通新图(不做额外处理),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),spec/icon-spec=统一视觉规范图(spec 是服务端同义词,客户端统一登记为 icon-spec),ui-prototype=完整 UI 设计图,art-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图"
|
||||
"description": "image=普通新图(不做额外处理),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),icon-spec=统一视觉规范图,ui-design=完整 UI 设计图,icon-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图"
|
||||
},
|
||||
"aspectRatio": {
|
||||
"type": "string",
|
||||
@@ -273,7 +273,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
|
||||
"sliceMode": {
|
||||
"type": "string",
|
||||
"enum": ["connected-components", "grid"],
|
||||
"description": "仅 kind=art-spritesheet 生效,且必填、没有默认值:需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入来自需求本身的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,需要约束素材张数时用 sliceCount。省略、与 kind 不匹配或与 gridX/gridY 互相矛盾时客户端直接拒绝,不会替你选择"
|
||||
"description": "仅 kind=icon-spritesheet 生效,且必填、没有默认值:需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入来自需求本身的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,需要约束素材张数时用 sliceCount。省略、与 kind 不匹配或与 gridX/gridY 互相矛盾时客户端直接拒绝,不会替你选择"
|
||||
},
|
||||
"gridX": {
|
||||
"type": "integer",
|
||||
@@ -291,11 +291,11 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 256,
|
||||
"description": "只与 kind=art-spritesheet 且 sliceMode=connected-components 同时提供,用于约束目标素材张数;省略时按图像内容自动识别"
|
||||
"description": "只与 kind=icon-spritesheet 且 sliceMode=connected-components 同时提供,用于约束目标素材张数;省略时按图像内容自动识别"
|
||||
},
|
||||
"screenColor": {
|
||||
"type": "string",
|
||||
"description": "抠图纯色背景,仅 kind=character(角色形象)和 kind=art-spritesheet(图标素材)生效,其它 kind 携带会被拒绝。生成时把主体置于该纯色背景上,回图后据此抠除背景。取值只能是 auto 或下列色板 hex 之一,传值只填 hex 本身、不要附带色名:#CFEFFF(浅雾蓝)、#B0C2E0(浅钢蓝)、#FFD6C2(暖浅桃色)、#E6D8FF(淡薰衣草紫)、#F4D8E8(浅粉灰)、#7FB3FF(中度天蓝)、#FFF2A8(浅柠黄)、#CFFFE1(淡薄荷绿)、#D8DEE8(浅中性灰)、#D8D2E8(淡灰紫)、#A8F7F0(高对比浅青)、#A0BBA0(灰竹绿);auto 时由服务端自动选色。手动指定时不能与角色或素材本体的颜色接近"
|
||||
"description": "抠图纯色背景,仅 kind=character(角色形象)和 kind=icon-spritesheet(图标素材)生效,其它 kind 携带会被拒绝。生成时把主体置于该纯色背景上,回图后据此抠除背景。取值只能是 auto 或下列色板 hex 之一,传值只填 hex 本身、不要附带色名:#CFEFFF(浅雾蓝)、#B0C2E0(浅钢蓝)、#FFD6C2(暖浅桃色)、#E6D8FF(淡薰衣草紫)、#F4D8E8(浅粉灰)、#7FB3FF(中度天蓝)、#FFF2A8(浅柠黄)、#CFFFE1(淡薄荷绿)、#D8DEE8(浅中性灰)、#D8D2E8(淡灰紫)、#A8F7F0(高对比浅青)、#A0BBA0(灰竹绿);auto 时由服务端自动选色。手动指定时不能与角色或素材本体的颜色接近"
|
||||
}
|
||||
},
|
||||
"required": ["prompt"],
|
||||
@@ -341,7 +341,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 80,
|
||||
"description": "可选的 manifest 资源 kind 精确过滤,例如 video、character-animation、sound-effect、background-music 或 art-spritesheet-slice"
|
||||
"description": "可选的 manifest 资源 kind 精确过滤,必须是 canonical kind(例如 video、character-animation、sound-effect、background-music、icon-spritesheet);传非 canonical 值会被拒绝并报错,不会静默返回空列表"
|
||||
},
|
||||
"assetId": {
|
||||
"type": "string",
|
||||
@@ -2523,7 +2523,7 @@ mod tests {
|
||||
// 目录外的 kind 必须在本地拒绝:既不下发 bridge,也不产生任何计费副作用。
|
||||
let unsupported_kind = call_agc_generate_image(&json!({
|
||||
"prompt": "像素月光主角",
|
||||
"kind": "game-art"
|
||||
"kind": "future-kind"
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(unsupported_kind["isError"], json!(true));
|
||||
@@ -2533,7 +2533,7 @@ mod tests {
|
||||
|
||||
let oversized_prompt = call_agc_generate_image(&json!({
|
||||
"prompt": "x".repeat(DIRECT_TOOLS_MCP_MAX_IMAGE_PROMPT_CHARS + 1),
|
||||
"kind": "art-spritesheet"
|
||||
"kind": "icon-spritesheet"
|
||||
}))
|
||||
.await;
|
||||
assert_eq!(oversized_prompt["isError"], json!(true));
|
||||
@@ -2897,12 +2897,12 @@ mod tests {
|
||||
fn tool_chain_image_asset(id: &str, local_path: &str) -> GameCreationAppAssetManifestEntry {
|
||||
GameCreationAppAssetManifestEntry {
|
||||
id: id.to_string(),
|
||||
kind: "image".to_string(),
|
||||
kind: GameCreationAppAssetKind::Image,
|
||||
media_type: "image/png".to_string(),
|
||||
local_path: local_path.to_string(),
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
category: game_creation_app_asset_category_for_kind("image"),
|
||||
category: game_creation_app_asset_category_for_kind(GameCreationAppAssetKind::Image),
|
||||
tags: Vec::new(),
|
||||
source: GameCreationAppAssetSource {
|
||||
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
Reference in New Issue
Block a user