Merge remote-tracking branch 'origin/master' into fix/chat-status-lost
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
# Conflicts: # docs/project-memory/shared-memory/decision-log.md # docs/project-memory/shared-memory/pitfalls.md
This commit is contained in:
@@ -47,6 +47,7 @@
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-http": "^2.5.9",
|
||||
"@tauri-apps/plugin-opener": "~2",
|
||||
"@tauri-apps/plugin-updater": "2.11.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"focus-trap-react": "^12.0.3",
|
||||
"lexical": "^0.47.0",
|
||||
@@ -57,6 +58,7 @@
|
||||
"react-colorful": "^5.8.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-window": "^1.8.11",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"vite": "^6.2.0",
|
||||
@@ -70,6 +72,7 @@
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "~5.8.2",
|
||||
"vitest": "^0.34.6"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -28,14 +29,30 @@ const cargoManifestPath = path.join(appRoot, 'src-tauri', 'Cargo.toml');
|
||||
const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock');
|
||||
const defaultOssBaseUrl =
|
||||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc';
|
||||
const updateManifestUrl =
|
||||
process.env.VITE_AGC_UPDATE_MANIFEST_URL?.trim() ||
|
||||
`${process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl}/latest.json`;
|
||||
|
||||
/**
|
||||
* 发布渠道 → 目标平台。渠道名会进入 OSS 路径并烘焙进客户端端点,
|
||||
* 一旦发布就不能改名(改名等于已发布客户端再也找不到更新)。
|
||||
*/
|
||||
const releaseChannels = {
|
||||
'dev-win': 'windows',
|
||||
'dev-mac': 'darwin',
|
||||
};
|
||||
|
||||
function ossBaseUrl() {
|
||||
return (
|
||||
process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl
|
||||
).replace(/\/+$/u, '');
|
||||
}
|
||||
|
||||
function readPackageJson() {
|
||||
return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
}
|
||||
|
||||
function readReleaseNotes() {
|
||||
return process.env.AGC_UPDATE_RELEASE_NOTES?.trim() || '';
|
||||
}
|
||||
|
||||
export function compareVersions(left, right) {
|
||||
const leftParts = left.split('.').map(Number);
|
||||
const rightParts = right.split('.').map(Number);
|
||||
@@ -66,26 +83,87 @@ export function nextPatchVersion(localVersion, remoteVersion) {
|
||||
return `${major}.${minor}.${patch + 1}`;
|
||||
}
|
||||
|
||||
async function readRemoteVersion() {
|
||||
export function resolveReleasePlatform(target = releaseTarget) {
|
||||
if (target.includes('windows')) return 'windows';
|
||||
if (target.includes('apple-darwin')) return 'darwin';
|
||||
if (target.includes('linux')) return 'linux';
|
||||
throw new Error(`不支持的发布目标:${target}`);
|
||||
}
|
||||
|
||||
export function resolveReleaseChannel(
|
||||
env = process.env,
|
||||
target = releaseTarget,
|
||||
) {
|
||||
const platform = resolveReleasePlatform(target);
|
||||
const requested = env.AGC_UPDATE_CHANNEL?.trim();
|
||||
if (requested) {
|
||||
const channelPlatform = releaseChannels[requested];
|
||||
if (!channelPlatform) {
|
||||
throw new Error(
|
||||
`未知发布渠道 ${requested};当前支持:${Object.keys(releaseChannels).join('、')}`,
|
||||
);
|
||||
}
|
||||
if (channelPlatform !== platform) {
|
||||
throw new Error(
|
||||
`渠道 ${requested} 只能用于 ${channelPlatform} 目标,当前构建目标为 ${target}`,
|
||||
);
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
const defaultChannel = Object.entries(releaseChannels).find(
|
||||
([, channelPlatform]) => channelPlatform === platform,
|
||||
)?.[0];
|
||||
if (!defaultChannel) {
|
||||
throw new Error(
|
||||
`目标 ${target} 没有默认发布渠道,请显式设置 AGC_UPDATE_CHANNEL`,
|
||||
);
|
||||
}
|
||||
return defaultChannel;
|
||||
}
|
||||
|
||||
export function updateManifestUrl(channel = resolveReleaseChannel()) {
|
||||
return `${ossBaseUrl()}/${channel}/latest.json`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新插件按运行时平台键查找清单条目:universal macOS 包同时挂
|
||||
* `darwin-aarch64` 与 `darwin-x86_64`,单架构目标只挂对应键。
|
||||
*/
|
||||
export function resolveManifestPlatformKeys(target = releaseTarget) {
|
||||
if (target === 'universal-apple-darwin') {
|
||||
return ['darwin-aarch64', 'darwin-x86_64'];
|
||||
}
|
||||
if (target === 'aarch64-apple-darwin') return ['darwin-aarch64'];
|
||||
if (target === 'x86_64-apple-darwin') return ['darwin-x86_64'];
|
||||
if (target.includes('windows')) {
|
||||
return [
|
||||
target.startsWith('aarch64') ? 'windows-aarch64' : 'windows-x86_64',
|
||||
];
|
||||
}
|
||||
throw new Error(`不支持的发布目标:${target}`);
|
||||
}
|
||||
|
||||
async function readRemoteVersion(channel = resolveReleaseChannel()) {
|
||||
const manifestUrl = updateManifestUrl(channel);
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(updateManifestUrl, {
|
||||
response = await fetch(manifestUrl, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`读取 OSS 版本清单失败:${error.message}`);
|
||||
throw new Error(`读取 OSS 渠道清单失败:${error.message}`);
|
||||
}
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) {
|
||||
throw new Error(`读取 OSS 版本清单失败:HTTP ${response.status}`);
|
||||
throw new Error(`读取 OSS 渠道清单失败:HTTP ${response.status}`);
|
||||
}
|
||||
let manifest;
|
||||
try {
|
||||
manifest = await response.json();
|
||||
} catch (error) {
|
||||
throw new Error(`OSS 版本清单不是有效 JSON:${error.message}`);
|
||||
throw new Error(`OSS 渠道清单不是有效 JSON:${error.message}`);
|
||||
}
|
||||
return parseVersion(manifest?.version, 'OSS版本清单 version');
|
||||
return parseVersion(manifest?.version, 'OSS渠道清单 version');
|
||||
}
|
||||
|
||||
function replaceVersionLine(source, version, pattern, label) {
|
||||
@@ -94,8 +172,9 @@ function replaceVersionLine(source, version, pattern, label) {
|
||||
}
|
||||
|
||||
export async function prepareReleaseVersion() {
|
||||
const channel = resolveReleaseChannel();
|
||||
const localVersion = parseVersion(readPackageJson().version, '本地版本');
|
||||
const remoteVersion = await readRemoteVersion();
|
||||
const remoteVersion = await readRemoteVersion(channel);
|
||||
const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim();
|
||||
const nextVersion = requestedVersion
|
||||
? parseVersion(requestedVersion, '指定版本')
|
||||
@@ -158,8 +237,8 @@ export async function prepareReleaseVersion() {
|
||||
|
||||
console.log(
|
||||
requestedVersion
|
||||
? `[ai-game-creator-shell] 使用指定版本 ${nextVersion}(本地 ${localVersion} / OSS ${remoteVersion ?? '不存在'})`
|
||||
: `[ai-game-creator-shell] 版本 ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`,
|
||||
? `[ai-game-creator-shell] 渠道 ${channel} 使用指定版本 ${nextVersion}(本地 ${localVersion} / OSS ${remoteVersion ?? '不存在'})`
|
||||
: `[ai-game-creator-shell] 渠道 ${channel} 版本 ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`,
|
||||
);
|
||||
return nextVersion;
|
||||
}
|
||||
@@ -187,18 +266,43 @@ export function buildTauriBuildArguments(
|
||||
];
|
||||
}
|
||||
|
||||
/** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */
|
||||
export function createChannelConfig(channel = resolveReleaseChannel()) {
|
||||
return {
|
||||
plugins: {
|
||||
updater: {
|
||||
endpoints: [updateManifestUrl(channel)],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writeChannelConfigFile(channel) {
|
||||
const configPath = path.join(
|
||||
os.tmpdir(),
|
||||
`agc-tauri-channel-${channel}.json`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`${JSON.stringify(createChannelConfig(channel), null, 2)}\n`,
|
||||
);
|
||||
return configPath;
|
||||
}
|
||||
|
||||
export function runTauriBuild(args = []) {
|
||||
const tauriArguments = buildTauriBuildArguments(args);
|
||||
if (!tauriArguments.includes('--config') && !tauriArguments.includes('-c')) {
|
||||
const channel = resolveReleaseChannel();
|
||||
const configPath = writeChannelConfigFile(channel);
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`,
|
||||
);
|
||||
tauriArguments.push('--config', configPath);
|
||||
}
|
||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
const result = spawnSync(
|
||||
npmCommand,
|
||||
[
|
||||
'--prefix',
|
||||
'../..',
|
||||
'exec',
|
||||
'tauri',
|
||||
'--',
|
||||
...buildTauriBuildArguments(args),
|
||||
],
|
||||
['--prefix', '../..', 'exec', 'tauri', '--', ...tauriArguments],
|
||||
{ cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' },
|
||||
);
|
||||
if (result.error) throw result.error;
|
||||
@@ -216,10 +320,14 @@ function listFiles(root) {
|
||||
function artifactPriority(filePath) {
|
||||
const name = path.basename(filePath).toLowerCase();
|
||||
if (releaseTarget.includes('windows')) return name.endsWith('.exe') ? 0 : 99;
|
||||
if (process.platform === 'darwin') return name.endsWith('.dmg') ? 0 : 99;
|
||||
if (name.endsWith('.appimage')) return 0;
|
||||
if (name.endsWith('.deb')) return 1;
|
||||
if (name.endsWith('.rpm')) return 2;
|
||||
// 更新链路要的是 updater 产物(macOS 为 .app.tar.gz),dmg 只作人工分发。
|
||||
if (releaseTarget.includes('apple-darwin')) {
|
||||
return name.endsWith('.app.tar.gz') ? 0 : 99;
|
||||
}
|
||||
if (name.endsWith('.appimage.tar.gz')) return 0;
|
||||
if (name.endsWith('.appimage')) return 1;
|
||||
if (name.endsWith('.deb')) return 2;
|
||||
if (name.endsWith('.rpm')) return 3;
|
||||
return 99;
|
||||
}
|
||||
|
||||
@@ -242,36 +350,100 @@ export function selectReleaseArtifact(files) {
|
||||
);
|
||||
}
|
||||
|
||||
export function createUpdateManifest(artifactPath) {
|
||||
function readUpdaterSignature(artifactPath) {
|
||||
const signaturePath = `${artifactPath}.sig`;
|
||||
if (!fs.existsSync(signaturePath)) {
|
||||
throw new Error(
|
||||
`缺少更新包签名:${signaturePath};需要 bundle.createUpdaterArtifacts 与签名私钥(TAURI_SIGNING_PRIVATE_KEY / TAURI_SIGNING_PRIVATE_KEY_PATH)`,
|
||||
);
|
||||
}
|
||||
const signature = fs.readFileSync(signaturePath, 'utf8').trim();
|
||||
if (!signature) throw new Error(`更新包签名为空:${signaturePath}`);
|
||||
return signature;
|
||||
}
|
||||
|
||||
export function createUpdateManifest(
|
||||
artifactPath,
|
||||
{
|
||||
channel = resolveReleaseChannel(),
|
||||
target = releaseTarget,
|
||||
publishedAt = new Date().toISOString(),
|
||||
} = {},
|
||||
) {
|
||||
const signature = readUpdaterSignature(artifactPath);
|
||||
const version = readPackageJson().version;
|
||||
const fileName = path.basename(artifactPath);
|
||||
const url = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`;
|
||||
const platforms = {};
|
||||
for (const key of resolveManifestPlatformKeys(target)) {
|
||||
platforms[key] = { signature, url };
|
||||
}
|
||||
const notes = readReleaseNotes();
|
||||
return {
|
||||
version,
|
||||
...(notes ? { notes } : {}),
|
||||
pub_date: publishedAt,
|
||||
platforms,
|
||||
};
|
||||
}
|
||||
|
||||
/** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */
|
||||
export function createLegacyUpdateManifest(
|
||||
artifactPath,
|
||||
{ channel = resolveReleaseChannel() } = {},
|
||||
) {
|
||||
const bytes = fs.readFileSync(artifactPath);
|
||||
const version = readPackageJson().version;
|
||||
const fileName = path.basename(artifactPath);
|
||||
const baseUrl = (
|
||||
process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl
|
||||
).replace(/\/+$/u, '');
|
||||
const encodedFileName = encodeURIComponent(fileName).replace(/%2F/giu, '/');
|
||||
const notes = readReleaseNotes();
|
||||
return {
|
||||
version,
|
||||
downloadUrl: `${baseUrl}/${encodeURIComponent(version)}/${encodedFileName}`,
|
||||
downloadUrl: `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`,
|
||||
sha256: createHash('sha256').update(bytes).digest('hex'),
|
||||
size: bytes.length,
|
||||
...(process.env.AGC_UPDATE_RELEASE_NOTES?.trim()
|
||||
? { releaseNotes: process.env.AGC_UPDATE_RELEASE_NOTES.trim() }
|
||||
: {}),
|
||||
...(notes ? { releaseNotes: notes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function generateUpdateManifest() {
|
||||
const channel = resolveReleaseChannel();
|
||||
const artifact = selectReleaseArtifact(listFiles(bundleRoot));
|
||||
if (!artifact) {
|
||||
throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`);
|
||||
}
|
||||
const manifest = createUpdateManifest(artifact);
|
||||
const manifest = createUpdateManifest(artifact, { channel });
|
||||
const manifestPath = path.join(bundleRoot, 'latest.json');
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
console.log(`[ai-game-creator-shell] 已生成 ${manifestPath}`);
|
||||
const legacyManifest =
|
||||
channel === 'dev-win'
|
||||
? createLegacyUpdateManifest(artifact, { channel })
|
||||
: null;
|
||||
const legacyManifestPath = legacyManifest
|
||||
? path.join(bundleRoot, 'legacy-latest.json')
|
||||
: null;
|
||||
if (legacyManifest && legacyManifestPath) {
|
||||
fs.writeFileSync(
|
||||
legacyManifestPath,
|
||||
`${JSON.stringify(legacyManifest, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`,
|
||||
);
|
||||
console.log(`[ai-game-creator-shell] 安装包:${artifact}`);
|
||||
return { artifact, manifestPath, manifest };
|
||||
if (legacyManifestPath) {
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 旧协议迁移清单:${legacyManifestPath}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
channel,
|
||||
artifact,
|
||||
manifest,
|
||||
manifestPath,
|
||||
legacyManifest,
|
||||
legacyManifestPath,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,24 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
compareVersions,
|
||||
createChannelConfig,
|
||||
createLegacyUpdateManifest,
|
||||
createUpdateManifest,
|
||||
nextPatchVersion,
|
||||
resolveManifestPlatformKeys,
|
||||
resolveReleaseChannel,
|
||||
selectReleaseArtifact,
|
||||
updateManifestUrl,
|
||||
} from './build-release.mjs';
|
||||
|
||||
test('selects an explicit release artifact when configured', () => {
|
||||
const artifactPath = new URL('../package.json', import.meta.url).pathname;
|
||||
const previous = process.env.AGC_UPDATE_ARTIFACT;
|
||||
process.env.AGC_UPDATE_ARTIFACT = artifactPath;
|
||||
try {
|
||||
assert.equal(selectReleaseArtifact([]), artifactPath);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.AGC_UPDATE_ARTIFACT;
|
||||
else process.env.AGC_UPDATE_ARTIFACT = previous;
|
||||
const windowsTarget = 'x86_64-pc-windows-msvc';
|
||||
const universalTarget = 'universal-apple-darwin';
|
||||
|
||||
function withEnv(overrides, run) {
|
||||
const previous = new Map();
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
previous.set(key, process.env[key]);
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
for (const [key, value] of previous) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function withSignedArtifact(fileName, run) {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-release-test-'));
|
||||
try {
|
||||
const artifact = path.join(directory, fileName);
|
||||
writeFileSync(artifact, 'installation package');
|
||||
writeFileSync(`${artifact}.sig`, 'signature-content\n');
|
||||
return run(artifact);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('selects an explicit release artifact when configured', () => {
|
||||
const artifactPath = fileURLToPath(
|
||||
new URL('../package.json', import.meta.url),
|
||||
);
|
||||
withEnv({ AGC_UPDATE_ARTIFACT: artifactPath }, () => {
|
||||
assert.equal(selectReleaseArtifact([]), artifactPath);
|
||||
});
|
||||
});
|
||||
|
||||
test('does not select unsupported files', () => {
|
||||
@@ -28,47 +65,123 @@ test('does not select unsupported files', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('manifest contains version, download URL and integrity fields', () => {
|
||||
const manifest = createUpdateManifest(
|
||||
new URL('../package.json', import.meta.url).pathname,
|
||||
test('resolves the channel from the target platform and rejects mismatches', () => {
|
||||
assert.equal(resolveReleaseChannel({}, windowsTarget), 'dev-win');
|
||||
assert.equal(resolveReleaseChannel({}, universalTarget), 'dev-mac');
|
||||
assert.equal(
|
||||
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'dev-mac' }, universalTarget),
|
||||
'dev-mac',
|
||||
);
|
||||
assert.match(manifest.version, /^\d+\.\d+\.\d+$/u);
|
||||
assert.match(
|
||||
manifest.downloadUrl,
|
||||
new RegExp(`/agc/${manifest.version}/package\\.json$`, 'u'),
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'dev-mac' }, windowsTarget),
|
||||
/只能用于 darwin 目标/u,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'beta-win' }, windowsTarget),
|
||||
/未知发布渠道/u,
|
||||
);
|
||||
assert.equal(manifest.sha256.length, 64);
|
||||
assert.equal(typeof manifest.size, 'number');
|
||||
});
|
||||
|
||||
test('manifest preserves multiline release notes', () => {
|
||||
const previous = process.env.AGC_UPDATE_RELEASE_NOTES;
|
||||
process.env.AGC_UPDATE_RELEASE_NOTES = '第一行\n第二行\r\n第三行';
|
||||
try {
|
||||
const manifest = createUpdateManifest(
|
||||
new URL('../package.json', import.meta.url).pathname,
|
||||
test('channel manifest URL and build-time endpoint follow the channel', () => {
|
||||
withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => {
|
||||
assert.equal(
|
||||
updateManifestUrl('dev-win'),
|
||||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json',
|
||||
);
|
||||
assert.deepEqual(createChannelConfig('dev-mac'), {
|
||||
plugins: {
|
||||
updater: {
|
||||
endpoints: [
|
||||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/latest.json',
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('universal macOS builds publish one artifact under both platform keys', () => {
|
||||
assert.deepEqual(resolveManifestPlatformKeys(universalTarget), [
|
||||
'darwin-aarch64',
|
||||
'darwin-x86_64',
|
||||
]);
|
||||
assert.deepEqual(resolveManifestPlatformKeys(windowsTarget), [
|
||||
'windows-x86_64',
|
||||
]);
|
||||
});
|
||||
|
||||
test('channel manifest carries version, platform keys and signature', () => {
|
||||
withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => {
|
||||
withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => {
|
||||
const manifest = createUpdateManifest(artifact, {
|
||||
channel: 'dev-win',
|
||||
target: windowsTarget,
|
||||
publishedAt: '2026-09-17T00:00:00.000Z',
|
||||
});
|
||||
assert.match(manifest.version, /^\d+\.\d+\.\d+$/u);
|
||||
assert.equal(manifest.notes, '修复与改进');
|
||||
assert.equal(manifest.pub_date, '2026-09-17T00:00:00.000Z');
|
||||
assert.deepEqual(Object.keys(manifest.platforms), ['windows-x86_64']);
|
||||
assert.equal(
|
||||
manifest.platforms['windows-x86_64'].signature,
|
||||
'signature-content',
|
||||
);
|
||||
assert.match(
|
||||
manifest.platforms['windows-x86_64'].url,
|
||||
new RegExp(`/agc/dev-win/${manifest.version}/`, 'u'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('missing signature fails the channel manifest closed', () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-release-test-'));
|
||||
try {
|
||||
const artifact = path.join(directory, '陶泥儿_0.1.48_x64-setup.exe');
|
||||
writeFileSync(artifact, 'installation package');
|
||||
assert.throws(
|
||||
() =>
|
||||
createUpdateManifest(artifact, {
|
||||
channel: 'dev-win',
|
||||
target: windowsTarget,
|
||||
}),
|
||||
/缺少更新包签名/u,
|
||||
);
|
||||
assert.equal(manifest.releaseNotes, '第一行\n第二行\r\n第三行');
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.AGC_UPDATE_RELEASE_NOTES;
|
||||
else process.env.AGC_UPDATE_RELEASE_NOTES = previous;
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('next release version follows the higher local or OSS version', () => {
|
||||
test('legacy manifest keeps the sha256 contract of published clients', () => {
|
||||
withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => {
|
||||
const legacy = createLegacyUpdateManifest(artifact, {
|
||||
channel: 'dev-win',
|
||||
});
|
||||
assert.match(legacy.version, /^\d+\.\d+\.\d+$/u);
|
||||
assert.equal(legacy.sha256.length, 64);
|
||||
assert.equal(legacy.size, 'installation package'.length);
|
||||
assert.match(legacy.downloadUrl, /\/agc\/dev-win\/[\d.]+\//u);
|
||||
});
|
||||
});
|
||||
|
||||
test('next release version follows the higher local or channel version', () => {
|
||||
assert.equal(compareVersions('0.1.15', '0.1.12'), 1);
|
||||
assert.equal(nextPatchVersion('0.1.12', '0.1.15'), '0.1.16');
|
||||
assert.equal(nextPatchVersion('0.1.18', '0.1.15'), '0.1.19');
|
||||
assert.equal(nextPatchVersion('0.1.12', null), '0.1.13');
|
||||
});
|
||||
|
||||
test('release upload forces overwrite for versioned artifact and latest pointer', () => {
|
||||
test('release upload forces overwrite for artifact, signature and channel pointers', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./release-upload.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.equal(
|
||||
(source.match(/runOssutil\(\['cp', '--force'/gu) ?? []).length,
|
||||
2,
|
||||
(source.match(/runOssutil\(\[\s*'cp',\s*'--force'/gu) ?? []).length,
|
||||
4,
|
||||
);
|
||||
assert.match(source, /agc\/\$\{channel\}\/latest\.json/u);
|
||||
assert.match(source, /agc\/latest\.json/u);
|
||||
});
|
||||
|
||||
@@ -121,6 +121,10 @@ const allowedUncalledTauriCommands = [
|
||||
'open_game_creator_launcher_window',
|
||||
'open_game_creator_workspace_window',
|
||||
'read_direct_project_conversation',
|
||||
// 项目定时快照上传只在 Rust 侧触发(周期定时器 / 工作区窗口关闭)与排障调用;
|
||||
// 按产品口径不做客户端可见界面,因此同 `open_game_creator_*_window` 一样按 native-only 登记。
|
||||
'read_local_project_snapshot_state',
|
||||
'sync_local_project_snapshot',
|
||||
'reset_design_agent_session',
|
||||
'stop_local_game_preview_if_matches',
|
||||
'start_game_creator_external_mcp',
|
||||
|
||||
@@ -724,6 +724,7 @@ function canvasAssetCall(agentId) {
|
||||
assetKind: 'art-spritesheet',
|
||||
assetLabel: '游戏首版核心美术素材',
|
||||
replaceExisting: false,
|
||||
sliceMode: 'connected-components',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2691,7 +2692,7 @@ function createDeterministicCanvasFixture(apiKey) {
|
||||
'deterministic spritesheet fixture',
|
||||
model: 'deterministic-canvas-v1',
|
||||
provider: 'deterministic-loopback',
|
||||
sliceLayout: 'grid-2x2',
|
||||
sliceMode: 'connected-components',
|
||||
spritesheetResource: {
|
||||
resourceId,
|
||||
projectId,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* `agc` 开发启动下发给 Vite 的客户端特性开关默认值。
|
||||
*
|
||||
* 开发态默认关闭客户端更新检查:`npm run agc` / `agc:serve` 启动的客户端不请求 OSS
|
||||
* 更新清单,也不显示更新入口。需要联调更新流程时显式传
|
||||
* `VITE_AGC_ENABLE_APP_UPDATE_CHECK=1`;此处不覆盖已经显式配置的取值。
|
||||
*/
|
||||
const agcAppUpdateCheckEnvKey = 'VITE_AGC_ENABLE_APP_UPDATE_CHECK';
|
||||
|
||||
function withAgcDevFeatureFlags(env = process.env) {
|
||||
if (String(env[agcAppUpdateCheckEnvKey] ?? '').trim()) {
|
||||
return env;
|
||||
}
|
||||
return {
|
||||
...env,
|
||||
[agcAppUpdateCheckEnvKey]: '0',
|
||||
};
|
||||
}
|
||||
|
||||
export { agcAppUpdateCheckEnvKey, withAgcDevFeatureFlags };
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 发布上传的 OSS 命令行整理:把 ossutil 参数与凭据整理成可执行或可打印的形式,
|
||||
* 便于在 dry-run 下核对将要执行的上传,同时保证任何输出都不回显凭据明文。
|
||||
*/
|
||||
const redactedCredential = '<redacted>';
|
||||
|
||||
export function readReleaseDryRun(env = process.env) {
|
||||
const value = env.AGC_RELEASE_DRY_RUN?.trim().toLowerCase();
|
||||
return value === '1' || value === 'true';
|
||||
}
|
||||
|
||||
function quoteArgument(value) {
|
||||
return /[\s"']/u.test(value) ? JSON.stringify(value) : value;
|
||||
}
|
||||
|
||||
export function formatOssutilCommand({ binary, args, endpoint, credentials }) {
|
||||
const parts = [binary, ...args, '--endpoint', endpoint];
|
||||
if (credentials) {
|
||||
parts.push(
|
||||
'--access-key-id',
|
||||
redactedCredential,
|
||||
'--access-key-secret',
|
||||
redactedCredential,
|
||||
);
|
||||
}
|
||||
return parts.map(quoteArgument).join(' ');
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs';
|
||||
|
||||
test('dry run only accepts explicit truthy values', () => {
|
||||
assert.equal(readReleaseDryRun({}), false);
|
||||
assert.equal(readReleaseDryRun({ AGC_RELEASE_DRY_RUN: '1' }), true);
|
||||
assert.equal(readReleaseDryRun({ AGC_RELEASE_DRY_RUN: ' true ' }), true);
|
||||
assert.equal(readReleaseDryRun({ AGC_RELEASE_DRY_RUN: '0' }), false);
|
||||
assert.equal(readReleaseDryRun({ AGC_RELEASE_DRY_RUN: '' }), false);
|
||||
});
|
||||
|
||||
test('printed upload command keeps arguments and hides credentials', () => {
|
||||
const command = formatOssutilCommand({
|
||||
binary: 'ossutil',
|
||||
args: [
|
||||
'cp',
|
||||
'--force',
|
||||
'陶泥儿 0.1.48.exe',
|
||||
'oss://agc-dev/agc/dev-win/x.exe',
|
||||
],
|
||||
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
|
||||
credentials: true,
|
||||
});
|
||||
assert.match(command, /^ossutil cp --force /u);
|
||||
assert.match(command, /"陶泥儿 0\.1\.48\.exe"/u);
|
||||
assert.match(command, /oss:\/\/agc-dev\/agc\/dev-win\/x\.exe/u);
|
||||
assert.match(
|
||||
command,
|
||||
/--access-key-id <redacted> --access-key-secret <redacted>/u,
|
||||
);
|
||||
});
|
||||
|
||||
test('uploader gates every ossutil call behind the dry run switch', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./release-upload.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(source, /const dryRun = readReleaseDryRun\(\);/u);
|
||||
assert.match(source, /if \(dryRun\) \{/u);
|
||||
assert.match(source, /dry-run:未写入任何 OSS 对象/u);
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs';
|
||||
|
||||
const bucket = process.env.AGC_OSS_BUCKET?.trim() || 'agc-dev';
|
||||
const endpoint =
|
||||
process.env.AGC_OSS_ENDPOINT?.trim() || 'oss-rg-china-mainland.aliyuncs.com';
|
||||
@@ -8,6 +10,7 @@ if (!/^[a-z0-9][a-z0-9.-]{1,62}$/u.test(bucket) || /[\r\n\0]/u.test(endpoint)) {
|
||||
throw new Error('OSS bucket 或 endpoint 配置无效');
|
||||
}
|
||||
process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`;
|
||||
const dryRun = readReleaseDryRun();
|
||||
|
||||
const { generateUpdateManifest, prepareReleaseVersion, runTauriBuild } =
|
||||
await import('./build-release.mjs');
|
||||
@@ -19,6 +22,18 @@ function runOssutil(args) {
|
||||
if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) {
|
||||
throw new Error('OSS AccessKey ID 和 Secret 必须同时提供');
|
||||
}
|
||||
if (dryRun) {
|
||||
// 演练:只打印将要执行的上传,凭据以占位符呈现,不写入 OSS。
|
||||
console.log(
|
||||
`[dry-run] ${formatOssutilCommand({
|
||||
binary,
|
||||
args,
|
||||
endpoint,
|
||||
credentials: Boolean(accessKeyId),
|
||||
})}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const credentialArgs = accessKeyId
|
||||
? ['--access-key-id', accessKeyId, '--access-key-secret', accessKeySecret]
|
||||
: [];
|
||||
@@ -38,11 +53,40 @@ function runOssutil(args) {
|
||||
|
||||
await prepareReleaseVersion();
|
||||
runTauriBuild([]);
|
||||
const { artifact, manifestPath, manifest } = generateUpdateManifest();
|
||||
const artifactKey = `agc/${manifest.version}/${path.basename(artifact)}`;
|
||||
const { artifact, channel, legacyManifestPath, manifest, manifestPath } =
|
||||
generateUpdateManifest();
|
||||
const artifactKey = `agc/${channel}/${manifest.version}/${path.basename(artifact)}`;
|
||||
// Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过;
|
||||
// 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。
|
||||
runOssutil(['cp', '--force', artifact, `oss://${bucket}/${artifactKey}`]);
|
||||
runOssutil(['cp', '--force', manifestPath, `oss://${bucket}/agc/latest.json`]);
|
||||
runOssutil([
|
||||
'cp',
|
||||
'--force',
|
||||
`${artifact}.sig`,
|
||||
`oss://${bucket}/${artifactKey}.sig`,
|
||||
]);
|
||||
runOssutil([
|
||||
'cp',
|
||||
'--force',
|
||||
manifestPath,
|
||||
`oss://${bucket}/agc/${channel}/latest.json`,
|
||||
]);
|
||||
console.log(`[ai-game-creator-shell] 已上传 oss://${bucket}/${artifactKey}`);
|
||||
console.log(`[ai-game-creator-shell] 已上传 oss://${bucket}/agc/latest.json`);
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 已上传 oss://${bucket}/agc/${channel}/latest.json`,
|
||||
);
|
||||
if (legacyManifestPath) {
|
||||
// 迁移桥:让仍走旧 sha256 清单的已发布客户端升级到新协议,一个版本周期后删除。
|
||||
runOssutil([
|
||||
'cp',
|
||||
'--force',
|
||||
legacyManifestPath,
|
||||
`oss://${bucket}/agc/latest.json`,
|
||||
]);
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 已上传迁移指针 oss://${bucket}/agc/latest.json`,
|
||||
);
|
||||
}
|
||||
if (dryRun) {
|
||||
console.log('[ai-game-creator-shell] dry-run:未写入任何 OSS 对象');
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { withAgcDevFeatureFlags } from './dev-feature-flags.mjs';
|
||||
import { resolveAgcDevEndpoint, withAgcDevEndpointEnv } from './dev-port.mjs';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
@@ -104,7 +105,7 @@ const child = spawn(
|
||||
],
|
||||
{
|
||||
cwd: appRoot,
|
||||
env: withAgcDevEndpointEnv(endpoint),
|
||||
env: withAgcDevFeatureFlags(withAgcDevEndpointEnv(endpoint)),
|
||||
stdio: 'inherit',
|
||||
// Node 18.20+/20+/24 on Windows rejects spawning .cmd (npm.cmd) without a shell (EINVAL).
|
||||
shell: true,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
stopWindowsProcessTree,
|
||||
stopWindowsWorktreeProcesses,
|
||||
} from '../../../scripts/dev-windows-process.mjs';
|
||||
import { withAgcDevFeatureFlags } from './dev-feature-flags.mjs';
|
||||
import {
|
||||
agcVitePortEnvKey,
|
||||
readAgcDevEndpoint,
|
||||
@@ -978,7 +979,10 @@ async function startVite(apiTarget, endpoint = readAgcDevEndpoint()) {
|
||||
'--port',
|
||||
String(endpoint.port),
|
||||
],
|
||||
{ cwd: appRoot, env: withAgcDevEndpointEnv(endpoint) },
|
||||
{
|
||||
cwd: appRoot,
|
||||
env: withAgcDevFeatureFlags(withAgcDevEndpointEnv(endpoint)),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+239
-11
@@ -814,6 +814,16 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -837,7 +847,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
@@ -850,7 +860,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -1405,6 +1415,16 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -1747,7 +1767,6 @@ dependencies = [
|
||||
"oxc_parser",
|
||||
"oxc_semantic",
|
||||
"oxc_span",
|
||||
"percent-encoding",
|
||||
"platform-agent",
|
||||
"platform-llm",
|
||||
"portable-pty",
|
||||
@@ -1766,6 +1785,7 @@ dependencies = [
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml 0.8.2",
|
||||
@@ -1776,7 +1796,7 @@ dependencies = [
|
||||
"url",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"zip",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2220,9 +2240,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2506,6 +2528,36 @@ dependencies = [
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-macros",
|
||||
"jni-sys 0.4.1",
|
||||
"log",
|
||||
"simd_cesu8",
|
||||
"thiserror 2.0.18",
|
||||
"walkdir",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-macros"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"simd_cesu8",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.1"
|
||||
@@ -2819,6 +2871,12 @@ dependencies = [
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -3234,6 +3292,18 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-osa-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
@@ -3378,6 +3448,20 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-osa-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "outref"
|
||||
version = "0.5.2"
|
||||
@@ -4360,15 +4444,20 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -4482,6 +4571,33 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
@@ -4609,7 +4725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
@@ -4952,6 +5068,22 @@ version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||
dependencies = [
|
||||
"rustc_version",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "similar"
|
||||
version = "2.7.0"
|
||||
@@ -5175,6 +5307,27 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -5196,7 +5349,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"block2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dbus",
|
||||
@@ -5206,7 +5359,7 @@ dependencies = [
|
||||
"gdkwayland-sys",
|
||||
"gdkx11-sys",
|
||||
"gtk",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
@@ -5239,6 +5392,17 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
@@ -5262,7 +5426,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"mime",
|
||||
@@ -5477,6 +5641,39 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http",
|
||||
"infer",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest 0.13.4",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip 4.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.3"
|
||||
@@ -5487,7 +5684,7 @@ dependencies = [
|
||||
"dpi",
|
||||
"gtk",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"objc2",
|
||||
"objc2-ui-kit",
|
||||
"objc2-web-kit",
|
||||
@@ -5510,7 +5707,7 @@ checksum = "fe41e015bf8fc4d6477ff4926a0ef769dc64ff34c7b0038b6f7cacae892acb5c"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
@@ -6586,6 +6783,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
@@ -7192,7 +7398,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"javascriptcore-rs",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"ndk",
|
||||
"objc2",
|
||||
@@ -7256,6 +7462,16 @@ version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
@@ -7437,6 +7653,18 @@ dependencies = [
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"indexmap 2.14.0",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
@@ -6,6 +6,9 @@ publish = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# 模板库假数据注入(仅本地页面压测/演示用):只有显式开启该 feature 才会编译并在读取清单后
|
||||
# 把条目循环补齐成假数据;计数由 AGC_TEMPLATE_LIBRARY_SYNTHETIC_COUNT 控制(默认 1000)。
|
||||
template-library-fixtures = []
|
||||
cocos-editor = ["cocos-editor-bridge/process-discovery"]
|
||||
cocos-editor-execute = ["cocos-editor", "cocos-editor-bridge/windows-bootstrap"]
|
||||
cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-injection"]
|
||||
@@ -47,7 +50,6 @@ similar = "2.7"
|
||||
platform-llm = { path = "../../../server-rs/crates/platform-llm" }
|
||||
platform-agent = { path = "../../../server-rs/crates/platform-agent" }
|
||||
portable-pty = "0.9"
|
||||
percent-encoding = "2"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] }
|
||||
regex = "1"
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
|
||||
@@ -55,6 +57,7 @@ tauri = { version = "2.11.2", features = [] }
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-updater = "2.11.0"
|
||||
tempfile = "3"
|
||||
toml = "0.8"
|
||||
ttf-parser = "0.25.1"
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
"allow": [
|
||||
{ "url": "https://dev.genarrative.world/api/*" },
|
||||
{ "url": "https://www.genarrative.world/api/*" },
|
||||
{ "url": "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/*" },
|
||||
{ "url": "https://*/api/*" },
|
||||
{ "url": "http://localhost:*/*" },
|
||||
{ "url": "http://127.0.0.1:*/*" }
|
||||
]
|
||||
},
|
||||
"opener:default",
|
||||
"updater:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save"
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": "agc-skill-pack.v1",
|
||||
"version": "2026-08-26.18",
|
||||
"version": "2026-08-26.19",
|
||||
"skills": [
|
||||
{
|
||||
"name": "agc-game-production-workflow",
|
||||
@@ -63,7 +63,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/platform-art-contract.md"
|
||||
],
|
||||
"sha256": "ff3e1645a35fc9bff1ef255aa7bdc2a9729843d68729589b6f2670c84b8130ec"
|
||||
"sha256": "c6329c6a3cbd17a237d042349d7fd8adcf240287ef56d23b49329923e976d534"
|
||||
},
|
||||
{
|
||||
"name": "agc-web-game-development",
|
||||
|
||||
+14
-3
@@ -19,9 +19,20 @@ image, UI design image, or publication material; use `agc_edit_image` for an
|
||||
edit of an existing registered image; use `taonier_prepare_game_art` only for
|
||||
the complete game-art package and its canonical slices.
|
||||
|
||||
When `agc_generate_image` is used with `kind="art-spritesheet"`, pass
|
||||
`sliceMode="connected-components"` (the default alpha-connectivity splitter)
|
||||
or `sliceMode="grid"` with `gridX` and `gridY` (1-32 each). The selected mode is carried
|
||||
When `agc_generate_image` is used with `kind="art-spritesheet"`, `sliceMode` is
|
||||
required and has no default, so decide it explicitly:
|
||||
|
||||
- Use `sliceMode="grid"` with `gridX` and `gridY` (1-32 each) only when the user
|
||||
or brief actually names equal grid cells, fixed slots, or a concrete
|
||||
column/row count; those dimensions must come from that requirement.
|
||||
- Use `sliceMode="connected-components"` for free-form sheets, an open number of
|
||||
subjects, or a request for one sheet; constrain the subject count with
|
||||
`sliceCount` instead of inventing grid dimensions.
|
||||
|
||||
Never assume `2x2` or any other grid to express "four kinds of assets", never
|
||||
pass `gridX`/`gridY` together with `connected-components`, and never pass
|
||||
`sliceMode` for another `kind`. The client rejects a missing, contradictory, or
|
||||
misapplied declaration instead of choosing for you. The selected mode is carried
|
||||
through the client request and returned result; do not infer it from the number
|
||||
of slices.
|
||||
|
||||
|
||||
+2
-1
@@ -15,7 +15,8 @@
|
||||
- On timeout or uncertain delivery, reuse the recorded operation; never create a replacement request.
|
||||
- `postprocess-failed-source-preserved` means the complete provider source remains usable, but the requested transparent derivative is absent.
|
||||
- `sliceWarning` means the complete transparent sheet remains usable, but individual slices are absent.
|
||||
- For direct `agc_generate_image` spritesheet requests, `sliceMode="connected-components"` selects alpha-connectivity detection and `sliceMode="grid"` uses the caller-provided `gridX` and `gridY` (1-32 each). The client preserves the selected mode and grid dimensions in the request identity and result metadata.
|
||||
- For direct `agc_generate_image` spritesheet requests, `sliceMode` is required and has no default: `connected-components` selects alpha-connectivity detection, while `grid` uses the caller-provided `gridX` and `gridY` (1-32 each) and is only correct when the requirement names equal grid cells, fixed slots, or a concrete column/row count. `connected-components` must not carry `gridX`/`gridY`, and `sliceMode` must not be sent for another `kind`; the client rejects a missing, contradictory, or misapplied declaration instead of choosing a mode. The client preserves the selected mode and grid dimensions in the request identity and result metadata.
|
||||
- The client-owned standard art package declares `sliceMode="connected-components"` with `sliceCount=4` because its four canonical slices are mapped to fixed usage paths: the platform must return exactly four slices or fail with an actionable `422` naming the recognized count, and the client refuses to write a usage manifest whose slice count is not exactly four. A `sliceMode` or grid-dimension echo that disagrees with the request also fails closed before local commit.
|
||||
- General and slice warnings can coexist. The tool returns them separately through `warnings` and `sliceWarnings`; callers must preserve every entry and must not downgrade a slice warning into a successful independent-asset claim.
|
||||
- `assetPaths` contains the complete package paths. `slicePaths` contains only slices that the client downloaded, validated, and registered with their platform source identities.
|
||||
- `resources` contains only safe registered identity fields: local asset/path/kind/media type, Canvas project/resource/asset/task IDs, and reference resource IDs. It never exposes prompts, models, provider routes, absolute paths, URLs, tokens, cookies, or API keys.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::design_tools::*;
|
||||
use super::*;
|
||||
use futures::FutureExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs::File;
|
||||
@@ -11,6 +12,46 @@ use uuid::Uuid;
|
||||
|
||||
const DESIGN_ACTIVE_LOCK: &str = ".agent/design-agent/active.lock";
|
||||
|
||||
const DESIGN_PANIC_PUBLIC_ERROR: &str =
|
||||
"策划运行发生内部错误,本轮已中断,可直接重试;若反复出现请反馈。";
|
||||
|
||||
// 运行段经 task-local 携带项目根,panic hook 据此把位置和负载写进私有 design_debug。
|
||||
// task-local 而非 thread-local:多线程 runtime 下 future 会跨 worker 迁移。
|
||||
tokio::task_local! {
|
||||
static DESIGN_PANIC_ROOT: Option<PathBuf>;
|
||||
}
|
||||
|
||||
fn ensure_design_panic_hook() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
if let Ok(Some(root)) = DESIGN_PANIC_ROOT.try_with(Clone::clone) {
|
||||
let location = info
|
||||
.location()
|
||||
.map(|location| {
|
||||
format!(
|
||||
"{}:{}:{}",
|
||||
location.file(),
|
||||
location.line(),
|
||||
location.column()
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| "未知位置".to_string());
|
||||
design_debug(
|
||||
&root,
|
||||
"panic",
|
||||
json!({
|
||||
"location": location,
|
||||
"error": info.payload_as_str().unwrap_or("未知 panic 负载"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
previous(info);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(
|
||||
tag = "type",
|
||||
@@ -421,6 +462,10 @@ fn execute_design_tool(
|
||||
) -> Result<Value, String> {
|
||||
let args: Value = serde_json::from_str(&call.arguments)
|
||||
.map_err(|error| format!("工具参数不是有效 JSON:{error}"))?;
|
||||
#[cfg(test)]
|
||||
if call.name == "design_test__panic" {
|
||||
panic!("注入的策划工具 panic");
|
||||
}
|
||||
match call.name.as_str() {
|
||||
"get_workflow_status" => Ok(design_workflow_status(session)),
|
||||
"list_resources" => resources.list().map(Value::String),
|
||||
@@ -971,7 +1016,20 @@ async fn finish_design_command(
|
||||
Some(design_view(&session, run)),
|
||||
));
|
||||
if run {
|
||||
if let Err(error) = run_design_loop(root, resources, &mut session, &mut emit).await {
|
||||
// panic 边界:运行期 panic 转成普通失败,交给既有错误分支恢复(重读检查点、
|
||||
// 写 last_error、发最终 view)。否则 unwind 会杀死 command task,IPC 永不
|
||||
// 返回(前端停在工作态),会话停在无错误的 pending,用户只能看到无声的重试。
|
||||
ensure_design_panic_hook();
|
||||
let run = DESIGN_PANIC_ROOT.scope(
|
||||
Some(root.to_path_buf()),
|
||||
run_design_loop(root, resources, &mut session, &mut emit),
|
||||
);
|
||||
let outcome = std::panic::AssertUnwindSafe(run).catch_unwind().await;
|
||||
let result = match outcome {
|
||||
Ok(result) => result,
|
||||
Err(payload) => Err(design_panic_error(payload)),
|
||||
};
|
||||
if let Err(error) = result {
|
||||
// 从最后一个持久检查点恢复,防止写后未记结果被误认为已完成。
|
||||
session = read_design_session(root)?.ok_or("策划会话丢失")?;
|
||||
session.last_error = Some(redact_agent_runtime_error(root, &error, 1800));
|
||||
@@ -991,6 +1049,12 @@ async fn finish_design_command(
|
||||
Ok(view)
|
||||
}
|
||||
|
||||
// panic 负载可能包含路径或内容片段,公开文案固定;位置和负载由 panic hook 写进私有
|
||||
// design_debug(task-local 提供项目根),不进入用户可见消息。
|
||||
fn design_panic_error(_payload: Box<dyn std::any::Any + Send>) -> String {
|
||||
DESIGN_PANIC_PUBLIC_ERROR.to_string()
|
||||
}
|
||||
|
||||
pub(crate) async fn continue_design_agent_at(
|
||||
root: &Path,
|
||||
resources: &DesignResources,
|
||||
@@ -1632,6 +1696,121 @@ mod tests {
|
||||
.clone()
|
||||
}
|
||||
|
||||
// 进程级 env 在同一 binary 的并行用例间共享:持锁串行化修改,drop 时恢复原值,
|
||||
// 避免 debug 开关泄漏给并发用例。锁中毒时取内部值继续,不让上游失败放大。
|
||||
static DESIGN_DEBUG_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
struct DesignDebugEnvGuard {
|
||||
previous: Option<String>,
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
impl Drop for DesignDebugEnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match &self.previous {
|
||||
Some(value) => std::env::set_var("GENARRATIVE_AGC_DESIGN_DEBUG", value),
|
||||
None => std::env::remove_var("GENARRATIVE_AGC_DESIGN_DEBUG"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn enable_design_debug_for_test() -> DesignDebugEnvGuard {
|
||||
let lock = DESIGN_DEBUG_ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let previous = std::env::var("GENARRATIVE_AGC_DESIGN_DEBUG").ok();
|
||||
std::env::set_var("GENARRATIVE_AGC_DESIGN_DEBUG", "1");
|
||||
DesignDebugEnvGuard {
|
||||
previous,
|
||||
_lock: lock,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn design_tool_panic_becomes_visible_retryable_error() {
|
||||
let (_temp, root, resources) = init_design_project();
|
||||
let _debug_env = enable_design_debug_for_test();
|
||||
let panic_call = platform_llm::LlmToolCall {
|
||||
id: "call-panic".into(),
|
||||
name: "design_test__panic".into(),
|
||||
arguments: "{}".into(),
|
||||
};
|
||||
let _fake = fake_provider::install(
|
||||
vec![
|
||||
Ok(fake_response("panic-turn", "", vec![panic_call])),
|
||||
Ok(fake_response("recovery", "已恢复", Vec::new())),
|
||||
],
|
||||
0,
|
||||
);
|
||||
let view = continue_design_agent_at(
|
||||
&root,
|
||||
&resources,
|
||||
"turn-panic",
|
||||
DesignInput::Message {
|
||||
text: "需求".into(),
|
||||
},
|
||||
|_| {},
|
||||
)
|
||||
.await
|
||||
.expect("panic 必须转成可恢复视图而不是向上传播");
|
||||
assert!(!view.running);
|
||||
assert!(view.can_retry);
|
||||
assert_eq!(
|
||||
view.session.last_error.as_deref(),
|
||||
Some(DESIGN_PANIC_PUBLIC_ERROR)
|
||||
);
|
||||
let session = read_design_session(&root)
|
||||
.expect("read session")
|
||||
.expect("session exists");
|
||||
assert!(
|
||||
!session.history.iter().any(|item| {
|
||||
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
||||
&& item.get("call_id").and_then(Value::as_str) == Some("call-panic")
|
||||
}),
|
||||
"panic 不得写半个工具输出"
|
||||
);
|
||||
|
||||
// design_debug 经独立线程落盘,轮询等待 panic 记录出现。
|
||||
let debug_dir = root.join(".debug/design-agent");
|
||||
let mut panic_record = None;
|
||||
for _ in 0..100 {
|
||||
panic_record = fs::read_dir(&debug_dir).ok().and_then(|entries| {
|
||||
entries.flatten().find_map(|entry| {
|
||||
let path = entry.path();
|
||||
let name = path.file_name()?.to_string_lossy().into_owned();
|
||||
if name.ends_with("-panic.json") {
|
||||
fs::read_to_string(path).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
});
|
||||
if panic_record.is_some() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
let panic_record = panic_record.expect("panic hook 必须把位置和负载写入 design_debug");
|
||||
assert!(panic_record.contains("design_runtime.rs"));
|
||||
assert!(panic_record.contains("注入的策划工具 panic"));
|
||||
|
||||
let view =
|
||||
continue_design_agent_at(&root, &resources, "turn-retry", DesignInput::Retry, |_| {})
|
||||
.await
|
||||
.expect("panic 后可重试");
|
||||
assert!(!view.running);
|
||||
assert!(!view.can_retry);
|
||||
assert!(view.session.last_error.is_none());
|
||||
let session = read_design_session(&root)
|
||||
.expect("read session")
|
||||
.expect("session exists");
|
||||
assert!(session.turn.as_ref().is_some_and(|turn| !turn.pending));
|
||||
assert!(session.history.iter().any(|item| {
|
||||
item.get("type").and_then(Value::as_str) == Some("function_call_output")
|
||||
&& item.get("call_id").and_then(Value::as_str) == Some("call-panic")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn design_request_enables_reasoning_capture_only_for_design_runtime() {
|
||||
let session = new_design_session("project", "quality");
|
||||
|
||||
@@ -3382,8 +3382,12 @@ async fn generate_direct_taonier_art_asset_at(
|
||||
asset_kind: asset_kind.to_string(),
|
||||
asset_label: asset_label.to_string(),
|
||||
replace_existing: root.join(output_path).is_file(),
|
||||
slice_count: None,
|
||||
slice_mode: None,
|
||||
// 标准美术包必须产出四张 canonical 切片:连通域模式下显式声明目标数量,
|
||||
// 让平台要么给出四张,要么以可执行的 422 说明实际识别数量。
|
||||
slice_count: (asset_kind == "art-spritesheet").then_some(4),
|
||||
// 切分模式没有默认值:陶泥儿标准美术包按自由排布生成核心图集,因此只在
|
||||
// art-spritesheet 阶段显式声明连通域切分。
|
||||
slice_mode: (asset_kind == "art-spritesheet").then(|| "connected-components".to_string()),
|
||||
grid_x: None,
|
||||
grid_y: None,
|
||||
};
|
||||
|
||||
@@ -2170,6 +2170,36 @@ fn bridge_image_generation_kind(arguments: &Value) -> Result<String, String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 切分模式没有默认值:图集必须显式声明,且声明必须与 kind 和网格参数自洽。
|
||||
fn validate_generate_image_slice_declaration(
|
||||
kind: &str,
|
||||
slice_mode: Option<&str>,
|
||||
grid_x: Option<u32>,
|
||||
grid_y: Option<u32>,
|
||||
slice_count: Option<usize>,
|
||||
) -> Result<(), String> {
|
||||
if kind == "art-spritesheet" {
|
||||
if slice_mode.is_none() {
|
||||
return Err(
|
||||
"kind=art-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时传 sliceMode=grid 并提供 gridX/gridY;自由排布、数量不定或只要求一张图集时传 sliceMode=connected-components"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if slice_mode == Some("grid") && slice_count.is_some() {
|
||||
return Err(
|
||||
"sliceMode=grid 的素材张数由 gridX×gridY 决定,不接受 sliceCount".to_string(),
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if slice_mode.is_some() || grid_x.is_some() || grid_y.is_some() || slice_count.is_some() {
|
||||
return Err(format!(
|
||||
"工具参数 sliceMode/gridX/gridY 仅对 kind=art-spritesheet 生效,当前 kind={kind}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value) -> Value {
|
||||
let result = async {
|
||||
bridge_reject_unknown_fields(
|
||||
@@ -2263,6 +2293,13 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
|
||||
{
|
||||
return Err("工具参数 gridX/gridY 必须在 1 到 32 之间".to_string());
|
||||
}
|
||||
validate_generate_image_slice_declaration(
|
||||
kind.as_str(),
|
||||
slice_mode.as_deref(),
|
||||
grid_x,
|
||||
grid_y,
|
||||
None,
|
||||
)?;
|
||||
let options = PlatformArtAssetGenerationOptions {
|
||||
output_path,
|
||||
aspect_ratio,
|
||||
@@ -2309,6 +2346,14 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
|
||||
"resources": resources,
|
||||
"warnings": generated.warning.map(|warning| bridge_safe_warning_messages(&state.root, vec![warning])).unwrap_or_default(),
|
||||
"sliceWarnings": generated.slice_warning.map(|warning| bridge_safe_warning_messages(&state.root, vec![warning])).unwrap_or_default(),
|
||||
"sliceMode": generated.slice_mode,
|
||||
"gridX": generated.grid_x,
|
||||
"gridY": generated.grid_y,
|
||||
"slicePaths": generated
|
||||
.slices
|
||||
.iter()
|
||||
.map(|slice| slice.local_path.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
.to_string(),
|
||||
images,
|
||||
@@ -2743,6 +2788,55 @@ pub(crate) async fn start_direct_tool_bridge(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn generate_image_slice_declaration_is_explicit_and_self_consistent() {
|
||||
let missing =
|
||||
validate_generate_image_slice_declaration("art-spritesheet", None, None, None, None)
|
||||
.expect_err("art-spritesheet without sliceMode must fail closed");
|
||||
assert!(missing.contains("没有默认值"), "{missing}");
|
||||
assert!(missing.contains("connected-components"), "{missing}");
|
||||
|
||||
assert!(validate_generate_image_slice_declaration(
|
||||
"art-spritesheet",
|
||||
Some("connected-components"),
|
||||
None,
|
||||
None,
|
||||
Some(4),
|
||||
)
|
||||
.is_ok());
|
||||
assert!(validate_generate_image_slice_declaration(
|
||||
"art-spritesheet",
|
||||
Some("grid"),
|
||||
Some(3),
|
||||
Some(2),
|
||||
None,
|
||||
)
|
||||
.is_ok());
|
||||
let grid_with_count = validate_generate_image_slice_declaration(
|
||||
"art-spritesheet",
|
||||
Some("grid"),
|
||||
Some(2),
|
||||
Some(2),
|
||||
Some(4),
|
||||
)
|
||||
.expect_err("grid mode must not carry sliceCount");
|
||||
assert!(grid_with_count.contains("gridX×gridY"), "{grid_with_count}");
|
||||
|
||||
let wrong_kind = validate_generate_image_slice_declaration(
|
||||
"image",
|
||||
Some("connected-components"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect_err("slice declaration must stay scoped to art-spritesheet");
|
||||
assert!(
|
||||
wrong_kind.contains("仅对 kind=art-spritesheet 生效"),
|
||||
"{wrong_kind}"
|
||||
);
|
||||
assert!(validate_generate_image_slice_declaration("image", None, None, None, None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_background_identity_preserves_default_and_distinguishes_options() {
|
||||
let legacy = "asset-1\0透明图";
|
||||
|
||||
@@ -273,20 +273,19 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool, _cocos_editor_availab
|
||||
"sliceMode": {
|
||||
"type": "string",
|
||||
"enum": ["connected-components", "grid"],
|
||||
"default": "connected-components",
|
||||
"description": "仅 kind=art-spritesheet 生效:connected-components 按透明像素连通域切分,grid 按 gridX×gridY 网格切分"
|
||||
"description": "仅 kind=art-spritesheet 生效,且必填、没有默认值:需求明确要求等分网格、固定槽位或指定行列数时传 grid,并用 gridX/gridY 传入来自需求本身的行列数;自由排布、数量不定或只要求一张图集时传 connected-components,需要约束素材张数时用 sliceCount。省略、与 kind 不匹配或与 gridX/gridY 互相矛盾时客户端直接拒绝,不会替你选择"
|
||||
},
|
||||
"gridX": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 32,
|
||||
"description": "grid 模式横向网格数量"
|
||||
"description": "grid 模式横向网格数量,只能与 sliceMode=grid 同时提供"
|
||||
},
|
||||
"gridY": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 32,
|
||||
"description": "grid 模式纵向网格数量"
|
||||
"description": "grid 模式纵向网格数量,只能与 sliceMode=grid 同时提供"
|
||||
}
|
||||
},
|
||||
"required": ["prompt"],
|
||||
@@ -2393,6 +2392,20 @@ mod tests {
|
||||
image_tool["inputSchema"]["properties"]["sliceMode"]["enum"],
|
||||
json!(["connected-components", "grid"])
|
||||
);
|
||||
assert!(
|
||||
image_tool["inputSchema"]["properties"]["sliceMode"]
|
||||
.get("default")
|
||||
.is_none(),
|
||||
"sliceMode must not advertise a default"
|
||||
);
|
||||
assert!(
|
||||
image_tool["inputSchema"]["properties"]["sliceMode"]["description"]
|
||||
.as_str()
|
||||
.is_some_and(|description| description.contains("没有默认值")
|
||||
&& description.contains("gridX")
|
||||
&& description.contains("connected-components")),
|
||||
"sliceMode description must carry the explicit decision requirement"
|
||||
);
|
||||
let edit_tool = specs["tools"]
|
||||
.as_array()
|
||||
.expect("tool array")
|
||||
|
||||
@@ -1564,6 +1564,8 @@ pub(in crate::agent) struct PreparedPlatformArtAssetGeneration {
|
||||
slice_warning: Option<String>,
|
||||
slices: Vec<PreparedPlatformArtAssetSlice>,
|
||||
spritesheet_slice_mode: Option<String>,
|
||||
spritesheet_grid_x: Option<u32>,
|
||||
spritesheet_grid_y: Option<u32>,
|
||||
generation_route: String,
|
||||
generation_kind: String,
|
||||
reference_resource_ids: Vec<String>,
|
||||
@@ -2468,6 +2470,31 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at(
|
||||
if require_slices && options.asset_kind != "art-spritesheet" {
|
||||
return Err("严格游戏切片生成只允许 art-spritesheet 资产类型".to_string());
|
||||
}
|
||||
// 切分模式没有默认值:图集生成必须在客户端显式声明,缺失或自相矛盾都在付费提交前失败。
|
||||
if options.asset_kind == "art-spritesheet" {
|
||||
let Some(slice_mode) = options.slice_mode.as_deref() else {
|
||||
return Err(
|
||||
"图集生成必须显式声明 sliceMode:等分网格或固定槽位用 grid 并提供 gridX/gridY,自由排布用 connected-components"
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
if !matches!(slice_mode, "connected-components" | "grid") {
|
||||
return Err(format!("图集切分模式不受支持:{slice_mode}"));
|
||||
}
|
||||
if slice_mode == "grid" && (options.grid_x.is_none() || options.grid_y.is_none()) {
|
||||
return Err("sliceMode=grid 必须同时提供 gridX 与 gridY".to_string());
|
||||
}
|
||||
if slice_mode == "connected-components"
|
||||
&& (options.grid_x.is_some() || options.grid_y.is_some())
|
||||
{
|
||||
return Err(
|
||||
"sliceMode=connected-components 不接受 gridX/gridY:网格尺寸只能与 grid 同时声明"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
} else if options.slice_mode.is_some() || options.grid_x.is_some() || options.grid_y.is_some() {
|
||||
return Err("sliceMode/gridX/gridY 仅对 art-spritesheet 生效".to_string());
|
||||
}
|
||||
if super::external_generation_state::is_standalone_platform_art_generation_runtime_context(
|
||||
runtime_context,
|
||||
) && game_creator_agent_runtime_external_generation_exists(
|
||||
@@ -3106,6 +3133,16 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let spritesheet_grid_x = if is_canonical_art_spritesheet {
|
||||
json_u32_field(generated, "gridX")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let spritesheet_grid_y = if is_canonical_art_spritesheet {
|
||||
json_u32_field(generated, "gridY")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let resource_id = json_string_field(resource, "resourceId");
|
||||
let task_id = if is_canonical_art_spritesheet {
|
||||
consistent_canvas_task_id("External Editor 图集主图", &[generated, resource, asset])?
|
||||
@@ -3164,6 +3201,8 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
||||
slice_warning,
|
||||
slices,
|
||||
spritesheet_slice_mode,
|
||||
spritesheet_grid_x,
|
||||
spritesheet_grid_y,
|
||||
generation_route,
|
||||
generation_kind,
|
||||
reference_resource_ids,
|
||||
@@ -6494,6 +6533,50 @@ impl PlatformArtSliceContractRollback {
|
||||
}
|
||||
}
|
||||
|
||||
fn json_u32_field(value: &serde_json::Value, field: &str) -> Option<u32> {
|
||||
value
|
||||
.get(field)
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
}
|
||||
|
||||
/// 严格图集必须在请求与响应两端证明同一个切分声明:请求显式声明的模式必须被平台
|
||||
/// 原样回显,grid 的行列数也必须一致;否则本地无法判断实际按哪种方式切片。
|
||||
fn validate_platform_art_spritesheet_slice_declaration_matches_response(
|
||||
options: &PlatformArtAssetGenerationOptions,
|
||||
response_slice_mode: Option<&str>,
|
||||
response_grid_x: Option<u32>,
|
||||
response_grid_y: Option<u32>,
|
||||
) -> Result<(), String> {
|
||||
let requested = options
|
||||
.slice_mode
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "图集生成缺少显式 sliceMode 声明,已拒绝提交严格图集".to_string())?;
|
||||
let responded = response_slice_mode
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
"平台图集响应没有回显 sliceMode,无法证明切分方式与请求一致,已在本地落盘前拒绝提交"
|
||||
.to_string()
|
||||
})?;
|
||||
if responded != requested {
|
||||
return Err(format!(
|
||||
"平台图集响应回显的 sliceMode={responded} 与请求 {requested} 不一致,已拒绝提交"
|
||||
));
|
||||
}
|
||||
if requested == "grid"
|
||||
&& (response_grid_x != options.grid_x || response_grid_y != options.grid_y)
|
||||
{
|
||||
return Err(format!(
|
||||
"平台图集响应回显的 gridX/gridY={:?}/{:?} 与请求 {:?}/{:?} 不一致,已拒绝提交",
|
||||
response_grid_x, response_grid_y, options.grid_x, options.grid_y
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_strict_platform_art_spritesheet_contract(
|
||||
slices: &[PreparedPlatformArtAssetSlice],
|
||||
slice_warning: Option<&str>,
|
||||
@@ -6504,7 +6587,6 @@ fn validate_strict_platform_art_spritesheet_contract(
|
||||
task_id: Option<&str>,
|
||||
generation_route: &str,
|
||||
generation_kind: &str,
|
||||
spritesheet_slice_mode: Option<&str>,
|
||||
reference_resource_ids: &[String],
|
||||
has_transparent_pixels: bool,
|
||||
has_visible_pixels: bool,
|
||||
@@ -6545,7 +6627,6 @@ fn validate_strict_platform_art_spritesheet_contract(
|
||||
{
|
||||
return Err("strict spritesheet 图集生成 route/kind 与严格图集合同不一致".to_string());
|
||||
}
|
||||
let _requested_slice_mode = spritesheet_slice_mode;
|
||||
if reference_resource_ids.len() != 1
|
||||
|| reference_resource_ids[0].trim().is_empty()
|
||||
|| reference_resource_ids[0].trim() == resource_id
|
||||
@@ -7093,6 +7174,15 @@ fn commit_strict_platform_art_slices_at(
|
||||
"obstacles-and-scene",
|
||||
"feedback-effects",
|
||||
];
|
||||
// 标准图集按用途位置映射到固定路径;数量不一致时必须失败关闭,不能靠 zip 静默截断
|
||||
// 或写入用途错位的切片清单。
|
||||
if slices.len() != usages.len() {
|
||||
return Err(format!(
|
||||
"标准美术图集必须正好包含 {} 张 canonical 切片,平台返回了 {} 张,已拒绝写入以避免用途错位",
|
||||
usages.len(),
|
||||
slices.len()
|
||||
));
|
||||
}
|
||||
let mut generated = Vec::with_capacity(slices.len());
|
||||
let mut registrations = Vec::with_capacity(slices.len());
|
||||
let mut content_sha256s = Vec::with_capacity(slices.len());
|
||||
@@ -7308,6 +7398,8 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
|
||||
mut slice_warning,
|
||||
slices,
|
||||
spritesheet_slice_mode,
|
||||
spritesheet_grid_x,
|
||||
spritesheet_grid_y,
|
||||
generation_route,
|
||||
generation_kind,
|
||||
reference_resource_ids,
|
||||
@@ -7317,6 +7409,12 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
|
||||
recover_existing_outputs,
|
||||
} = prepared;
|
||||
if require_complete_core_slices {
|
||||
validate_platform_art_spritesheet_slice_declaration_matches_response(
|
||||
options,
|
||||
spritesheet_slice_mode.as_deref(),
|
||||
spritesheet_grid_x,
|
||||
spritesheet_grid_y,
|
||||
)?;
|
||||
validate_strict_platform_art_spritesheet_contract(
|
||||
&slices,
|
||||
slice_warning.as_deref(),
|
||||
@@ -7327,7 +7425,6 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
|
||||
task_id.as_deref(),
|
||||
&generation_route,
|
||||
&generation_kind,
|
||||
spritesheet_slice_mode.as_deref(),
|
||||
&reference_resource_ids,
|
||||
spritesheet_has_transparent_pixels,
|
||||
spritesheet_has_visible_pixels,
|
||||
@@ -7688,6 +7785,9 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
|
||||
})).collect::<Vec<_>>(),
|
||||
"generationRoute": generation_route,
|
||||
"generationKind": generation_kind,
|
||||
"sliceMode": spritesheet_slice_mode.clone(),
|
||||
"gridX": spritesheet_grid_x,
|
||||
"gridY": spritesheet_grid_y,
|
||||
"referenceResourceIds": reference_resource_ids,
|
||||
}),
|
||||
);
|
||||
@@ -7697,6 +7797,9 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook(
|
||||
Ok(GeneratedPlatformArtAsset {
|
||||
asset: registered,
|
||||
slices: generated_slices,
|
||||
slice_mode: spritesheet_slice_mode.or_else(|| options.slice_mode.clone()),
|
||||
grid_x: spritesheet_grid_x.or(options.grid_x),
|
||||
grid_y: spritesheet_grid_y.or(options.grid_y),
|
||||
resource_id,
|
||||
asset_object_id,
|
||||
task_id,
|
||||
@@ -9789,7 +9892,6 @@ mod canvas_generation_tests {
|
||||
Some("spritesheet-task"),
|
||||
"/api/external/v1/editor/icon-spritesheets/generations",
|
||||
"icon-spritesheet",
|
||||
None,
|
||||
&["art-spec-resource".to_string()],
|
||||
true,
|
||||
true,
|
||||
@@ -9814,7 +9916,6 @@ mod canvas_generation_tests {
|
||||
None,
|
||||
"route",
|
||||
"kind",
|
||||
None,
|
||||
&[],
|
||||
false,
|
||||
false,
|
||||
@@ -9871,7 +9972,6 @@ mod canvas_generation_tests {
|
||||
Some("spritesheet-task"),
|
||||
"/api/external/v1/editor/icon-spritesheets/generations",
|
||||
"icon-spritesheet",
|
||||
Some("grid"),
|
||||
&["art-spec-resource".to_string()],
|
||||
true,
|
||||
true,
|
||||
@@ -12347,7 +12447,7 @@ mod canvas_generation_tests {
|
||||
asset_label: "游戏首版核心美术素材".to_string(),
|
||||
replace_existing: true,
|
||||
slice_count: None,
|
||||
slice_mode: None,
|
||||
slice_mode: Some("connected-components".to_string()),
|
||||
grid_x: None,
|
||||
grid_y: None,
|
||||
}
|
||||
@@ -12380,7 +12480,9 @@ mod canvas_generation_tests {
|
||||
warning: None,
|
||||
slice_warning: None,
|
||||
slices: Vec::new(),
|
||||
spritesheet_slice_mode: Some("grid".to_string()),
|
||||
spritesheet_slice_mode: Some("connected-components".to_string()),
|
||||
spritesheet_grid_x: None,
|
||||
spritesheet_grid_y: None,
|
||||
generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(),
|
||||
generation_kind: "icon-spritesheet".to_string(),
|
||||
reference_resource_ids: vec!["art-spec-resource".to_string()],
|
||||
@@ -12704,7 +12806,9 @@ mod canvas_generation_tests {
|
||||
warning: None,
|
||||
slice_warning: None,
|
||||
slices,
|
||||
spritesheet_slice_mode: Some("grid".to_string()),
|
||||
spritesheet_slice_mode: Some("connected-components".to_string()),
|
||||
spritesheet_grid_x: None,
|
||||
spritesheet_grid_y: None,
|
||||
generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(),
|
||||
generation_kind: "icon-spritesheet".to_string(),
|
||||
reference_resource_ids: vec!["art-spec-resource".to_string()],
|
||||
|
||||
@@ -695,6 +695,53 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
// 切分模式没有默认值:图集必须显式声明,且声明必须与 assetKind 和网格参数自洽。
|
||||
if options.asset_kind == "art-spritesheet" {
|
||||
if options.slice_mode.is_none() {
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "canvas.asset_generate".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: "assetKind=art-spritesheet 必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供 gridX/gridY;自由排布时用 connected-components"
|
||||
.to_string(),
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
if options.slice_mode.as_deref() == Some("connected-components")
|
||||
&& (options.grid_x.is_some() || options.grid_y.is_some())
|
||||
{
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "canvas.asset_generate".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary:
|
||||
"sliceMode=connected-components 不接受 gridX/gridY:网格尺寸只能与 grid 同时声明"
|
||||
.to_string(),
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
if options.slice_mode.as_deref() == Some("grid") && options.slice_count.is_some() {
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "canvas.asset_generate".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: "sliceMode=grid 的素材张数由 gridX×gridY 决定,不接受 sliceCount"
|
||||
.to_string(),
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
} else if options.slice_mode.is_some()
|
||||
|| options.grid_x.is_some()
|
||||
|| options.grid_y.is_some()
|
||||
|| options.slice_count.is_some()
|
||||
{
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "canvas.asset_generate".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: format!(
|
||||
"sliceMode/gridX/gridY/sliceCount 仅对 assetKind=art-spritesheet 生效,当前 assetKind={}",
|
||||
options.asset_kind
|
||||
),
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
if !agent_runtime_canvas_asset_kind_is_supported(&options.asset_kind) {
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "canvas.asset_generate".to_string(),
|
||||
|
||||
@@ -1036,7 +1036,7 @@ fn runtime_tool_description(tool: &str) -> &'static str {
|
||||
"preview.validate" => "用真实浏览器验证桌面和移动预览并保存证据。",
|
||||
"image.inspect" => "让视觉模型检查一至两张项目内图片。",
|
||||
"canvas.asset_generate" => {
|
||||
"通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考,也可通过 sliceCount 指定图集切片数量。"
|
||||
"通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=art-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。"
|
||||
}
|
||||
"ui.workflow.run" => {
|
||||
"先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。"
|
||||
@@ -1311,7 +1311,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value {
|
||||
asset_kinds.push(Value::Null);
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["prompt", "outputPath", "aspectRatio", "imageSize", "assetKind", "assetLabel", "replaceExisting"],
|
||||
"required": ["prompt", "outputPath", "aspectRatio", "imageSize", "assetKind", "assetLabel", "replaceExisting", "sliceMode", "gridX", "gridY", "sliceCount"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"prompt": { "type": "string", "minLength": 1, "maxLength": 4000 },
|
||||
@@ -1320,7 +1320,11 @@ fn runtime_tool_input_schema(tool: &str) -> Value {
|
||||
"imageSize": { "type": ["string", "null"], "enum": ["0.5K", "1K", "2K", null] },
|
||||
"assetKind": { "type": ["string", "null"], "enum": asset_kinds },
|
||||
"assetLabel": { "type": ["string", "null"], "maxLength": 80 },
|
||||
"replaceExisting": { "type": "boolean" }
|
||||
"replaceExisting": { "type": "boolean" },
|
||||
"sliceMode": { "type": ["string", "null"], "enum": ["connected-components", "grid", null], "description": "仅 assetKind=art-spritesheet 生效且必填,没有默认值:等分网格或固定槽位用 grid,自由排布用 connected-components" },
|
||||
"gridX": { "type": ["integer", "null"], "minimum": 1, "maximum": 32, "description": "只与 sliceMode=grid 同时提供" },
|
||||
"gridY": { "type": ["integer", "null"], "minimum": 1, "maximum": 32, "description": "只与 sliceMode=grid 同时提供" },
|
||||
"sliceCount": { "type": ["integer", "null"], "minimum": 1, "maximum": 256, "description": "只与 sliceMode=connected-components 同时提供,用于约束目标素材张数" }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4647,7 +4647,10 @@ pub(crate) fn prepare_local_project_asset_generation(
|
||||
.unwrap_or_else(|| LOCAL_PROJECT_ASSET_DEFAULT_ASSET_NAME.to_string()),
|
||||
replace_existing: false,
|
||||
slice_count: None,
|
||||
slice_mode: None,
|
||||
// 切分模式没有默认值:GUI 快速编辑只按自由排布生成图集,因此仅在 art-spritesheet
|
||||
// 时显式声明连通域切分;等分网格或固定槽位需求由外部 API 显式传 grid + gridX/gridY。
|
||||
slice_mode: (asset_kind == "art-spritesheet")
|
||||
.then(|| "connected-components".to_string()),
|
||||
grid_x: None,
|
||||
grid_y: None,
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user