合并 master:运行页入口收口并入 DirectProject 重构
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (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 lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (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
- 保留 master 的 DirectProject / ProjectChat 重构与测试重组:原 appSurface 的 project-commands / project-preview / supervisor-runtime 三个 suite 已被 master 删除,接受删除并把相关断言重写到 tests/previewActivation.test.tsx 等新用例 - 本分支改动重放到 master 结构:运行与预览成功的反馈改走 onRunNotice toast(带 tone),对话区不再写过程提示 - 运行页顶栏统一:预览地址改成「在浏览器打开」按钮,版本入口搬进同一动作区复用同一套按钮皮,状态行与预览地址小字退役 - 生成任务入口、面板与锚点不在运行页渲染,placement 里的 run 档一并删除 - 手工合并 App.tsx / WorkspaceLauncher.tsx / model.ts / check-config.mjs / decision-log / pitfalls,并把两条决策记录与一条排障记录补进 master 版本文档
This commit is contained in:
@@ -81,14 +81,12 @@ test('模板批量导入走 multipart,不预设 JSON Content-Type', async () =
|
||||
},
|
||||
],
|
||||
};
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ ok: true, data: imported }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
const fetchMock = vi.fn().mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ ok: true, data: imported }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const form = new FormData();
|
||||
|
||||
@@ -185,7 +185,12 @@ test('nextVersion 只在 patch 位递增', () => {
|
||||
|
||||
test('ossutil 参数默认使用 v1 签名,并可按需带 region 与 v4', () => {
|
||||
const base = {
|
||||
args: ['cp', '--force', '/tmp/a.json', 'oss://agc-dev/agc/global-version.json'],
|
||||
args: [
|
||||
'cp',
|
||||
'--force',
|
||||
'/tmp/a.json',
|
||||
'oss://agc-dev/agc/global-version.json',
|
||||
],
|
||||
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
|
||||
accessKeyId: 'id',
|
||||
accessKeySecret: 'secret',
|
||||
|
||||
@@ -20,7 +20,10 @@ import { createInterface } from 'node:readline/promises';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { inflateSync } from 'node:zlib';
|
||||
|
||||
export const appIdentifier = 'world.genarrative.ai-game-creator';
|
||||
import { AGC_APP_IDENTIFIER } from './channel-identity.mjs';
|
||||
|
||||
// 联调工具驱动的始终是默认渠道客户端:安装身份取渠道基线,不跟随发布渠道。
|
||||
export const appIdentifier = AGC_APP_IDENTIFIER;
|
||||
export const configFileName = 'game-creator.config.json';
|
||||
export const localConfigFileName = 'game-creator.config.local.json';
|
||||
export const runnerEndpointFileName = 'agent-runner.endpoint.json';
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
resolveReleasePartition,
|
||||
runTauriBuild,
|
||||
} from './build-release.mjs';
|
||||
import { resolveChannelInstallIdentity } from './channel-identity.mjs';
|
||||
import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs';
|
||||
import {
|
||||
readUpdaterPubkey,
|
||||
@@ -39,27 +40,19 @@ const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const repoRoot = path.resolve(appRoot, '../..');
|
||||
|
||||
/**
|
||||
* 产品名只从 Tauri 配置读取:它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。
|
||||
* 写死会在改名后让入口静默找错对象(清理、打包、归档三处一起失效)。
|
||||
* 产品名只从渠道安装身份派生(渠道身份由构建期 `--config` 注入 Tauri 配置):
|
||||
* 它同时决定 `*.app` 目录名、updater 归档名与 DMG 卷名。写死会在改名或换渠道后
|
||||
* 让入口静默找错对象(清理、打包、归档三处一起失效)。
|
||||
*/
|
||||
function readProductName() {
|
||||
const read = (file) =>
|
||||
JSON.parse(fs.readFileSync(path.join(appRoot, 'src-tauri', file), 'utf8'));
|
||||
const base = read('tauri.conf.json');
|
||||
const macosPath = path.join(appRoot, 'src-tauri', 'tauri.macos.conf.json');
|
||||
const productName = fs.existsSync(macosPath)
|
||||
? (read('tauri.macos.conf.json').productName ?? base.productName)
|
||||
: base.productName;
|
||||
function resolveProductName(channel) {
|
||||
const { productName } = resolveChannelInstallIdentity(channel);
|
||||
assert.ok(
|
||||
typeof productName === 'string' && productName.trim().length > 0,
|
||||
'Tauri 配置缺少 productName',
|
||||
'渠道安装身份缺少 productName',
|
||||
);
|
||||
return productName;
|
||||
}
|
||||
|
||||
const productName = readProductName();
|
||||
const appBundleName = `${productName}.app`;
|
||||
const updaterArtifactName = `${productName}.app.tar.gz`;
|
||||
assert.equal(process.platform, 'darwin', '只能在 macOS Agent 执行');
|
||||
assert.equal(
|
||||
process.env.JENKINS_URL?.length > 0,
|
||||
@@ -102,6 +95,9 @@ process.env.CARGO_TARGET_DIR = path.join(appRoot, 'src-tauri/target');
|
||||
const macTarget = 'aarch64-apple-darwin';
|
||||
const context = resolveReleaseContext([`--target=${macTarget}`]);
|
||||
const partition = resolveReleasePartition(context.channel, context.target);
|
||||
const productName = resolveProductName(context.channel);
|
||||
const appBundleName = `${productName}.app`;
|
||||
const updaterArtifactName = `${productName}.app.tar.gz`;
|
||||
const version = await prepareReleaseVersion(context);
|
||||
// 首装包名必须让清单侧的单架构分支唯一匹配:`<产品名>_<版本>_<架构>.dmg`,
|
||||
// 架构段用 Tauri 的 aarch64 口径(不是 updater 平台键的 arm64 / x86_64)。
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
defaultEditorFeatures,
|
||||
withDefaultCargoFeatures,
|
||||
} from './cargo-features.mjs';
|
||||
import {
|
||||
resolveChannelInstallIdentity,
|
||||
resolveReleaseChannel,
|
||||
} from './channel-identity.mjs';
|
||||
import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
|
||||
import { stageNodeRuntime } from './stage-node-runtime.mjs';
|
||||
|
||||
@@ -89,14 +93,7 @@ const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock');
|
||||
const defaultOssBaseUrl =
|
||||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc';
|
||||
|
||||
const reservedChannelNames = new Set([
|
||||
'win',
|
||||
'mac',
|
||||
'windows',
|
||||
'macos',
|
||||
'darwin',
|
||||
'linux',
|
||||
]);
|
||||
export { resolveReleaseChannel } from './channel-identity.mjs';
|
||||
|
||||
/**
|
||||
* 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须
|
||||
@@ -165,21 +162,6 @@ export function resolveReleasePlatform(target = defaultTarget()) {
|
||||
throw new Error(`不支持的发布目标:${target}`);
|
||||
}
|
||||
|
||||
export function resolveReleaseChannel(env = process.env) {
|
||||
const channel = env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev';
|
||||
if (
|
||||
!/^[a-z][a-z0-9-]{0,31}$/u.test(channel) ||
|
||||
channel.endsWith('-') ||
|
||||
reservedChannelNames.has(channel) ||
|
||||
/-(win|mac)$/u.test(channel)
|
||||
) {
|
||||
throw new Error(
|
||||
'发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道',
|
||||
);
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
/** 系统分区延续已发布客户端端点,渠道本身不包含系统。 */
|
||||
export function resolveReleasePartition(
|
||||
channel = resolveReleaseChannel(),
|
||||
@@ -428,12 +410,19 @@ export function buildTauriBuildArguments(
|
||||
];
|
||||
}
|
||||
|
||||
/** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */
|
||||
/**
|
||||
* 渠道端点与安装身份必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道,
|
||||
* 而 `productName` / `identifier` 决定安装目录、卸载项与客户端数据目录,
|
||||
* 不同渠道必须在同一台设备上并存而不是互相顶掉。
|
||||
*/
|
||||
export function createChannelConfig(
|
||||
channel = resolveReleaseChannel(),
|
||||
target = defaultTarget(),
|
||||
) {
|
||||
const { productName, identifier } = resolveChannelInstallIdentity(channel);
|
||||
return {
|
||||
productName,
|
||||
identifier,
|
||||
plugins: {
|
||||
updater: {
|
||||
endpoints: [updateManifestUrl(channel, target)],
|
||||
|
||||
@@ -37,6 +37,11 @@ import {
|
||||
selectReleaseArtifact,
|
||||
updateManifestUrl,
|
||||
} from './build-release.mjs';
|
||||
import {
|
||||
AGC_APP_IDENTIFIER,
|
||||
AGC_PRODUCT_NAME,
|
||||
resolveChannelInstallIdentity,
|
||||
} from './channel-identity.mjs';
|
||||
|
||||
const windowsTarget = 'x86_64-pc-windows-msvc';
|
||||
const universalTarget = 'universal-apple-darwin';
|
||||
@@ -184,6 +189,8 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
|
||||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json',
|
||||
);
|
||||
assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), {
|
||||
productName: AGC_PRODUCT_NAME,
|
||||
identifier: AGC_APP_IDENTIFIER,
|
||||
plugins: {
|
||||
updater: {
|
||||
endpoints: [
|
||||
@@ -204,6 +211,68 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('channel install identity isolates co-installed builds and keeps the default channel stable', () => {
|
||||
// 默认渠道必须保持已发布客户端身份:改身份等于换一个 App,升级链会断。
|
||||
assert.deepEqual(resolveChannelInstallIdentity('dev'), {
|
||||
productName: AGC_PRODUCT_NAME,
|
||||
identifier: AGC_APP_IDENTIFIER,
|
||||
});
|
||||
assert.deepEqual(resolveChannelInstallIdentity('release'), {
|
||||
productName: '陶泥儿 Release',
|
||||
identifier: `${AGC_APP_IDENTIFIER}.release`,
|
||||
});
|
||||
assert.deepEqual(resolveChannelInstallIdentity('beta-2'), {
|
||||
productName: '陶泥儿 Beta-2',
|
||||
identifier: `${AGC_APP_IDENTIFIER}.beta-2`,
|
||||
});
|
||||
|
||||
// 同一台设备上不同渠道的安装目录、卸载项与数据目录必须互不相同。
|
||||
for (const channel of ['release', 'beta-2', 'a'.repeat(32)]) {
|
||||
const identity = resolveChannelInstallIdentity(channel);
|
||||
assert.notEqual(identity.productName, AGC_PRODUCT_NAME);
|
||||
assert.notEqual(identity.identifier, AGC_APP_IDENTIFIER);
|
||||
assert.ok(identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`));
|
||||
}
|
||||
|
||||
for (const channel of ['dev-win', 'Release', 'win', 'beta-']) {
|
||||
assert.throws(
|
||||
() => resolveChannelInstallIdentity(channel),
|
||||
/发布渠道无效/u,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('channel install identity is baked into the same build-time config as the endpoint', () => {
|
||||
withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => {
|
||||
const config = createChannelConfig('release', windowsTarget);
|
||||
assert.equal(config.productName, '陶泥儿 Release');
|
||||
assert.equal(config.identifier, `${AGC_APP_IDENTIFIER}.release`);
|
||||
assert.match(
|
||||
config.plugins.updater.endpoints[0],
|
||||
/\/release-win\/latest\.json$/u,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('channel products keep first-install selection working under the channel product name', () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-channel-dmg-'));
|
||||
try {
|
||||
const { productName } = resolveChannelInstallIdentity('release');
|
||||
const dmg = path.join(root, `${productName}_${packageVersion}_aarch64.dmg`);
|
||||
writeFileSync(dmg, 'channel first installation disk image');
|
||||
writeFileSync(path.join(root, 'windows.exe'), 'wrong platform');
|
||||
assert.equal(
|
||||
selectFirstInstallArtifact([dmg, path.join(root, 'windows.exe')], {
|
||||
target: 'aarch64-apple-darwin',
|
||||
version: packageVersion,
|
||||
}),
|
||||
dmg,
|
||||
);
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('packaged renderer receives the same channel as the updater manifest', () => {
|
||||
const context = resolveReleaseContext([], {
|
||||
AGC_BUILD_TARGET: windowsTarget,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* AGC 渠道 → 安装身份。
|
||||
*
|
||||
* 渠道同时决定两件事:
|
||||
* - 更新端点:OSS 分区 `<channel>-win` / `<channel>-mac` 的清单地址;
|
||||
* - 安装身份:`productName` 与 `identifier`。
|
||||
*
|
||||
* 安装身份决定 Windows 安装目录与卸载项、macOS `.app` 名字与 bundle id、
|
||||
* Windows WebView2 数据目录以及 `%APPDATA%\<identifier>` 客户端数据目录。
|
||||
* 因此不同渠道的包体在同一台设备上并存时互不顶掉,也不会共享登录态、
|
||||
* 本地项目与运行锁。
|
||||
*
|
||||
* 默认渠道 `dev` 保持已发布客户端身份不变:升级链路与既有安装不能断。
|
||||
*/
|
||||
|
||||
export const AGC_DEFAULT_CHANNEL = 'dev';
|
||||
export const AGC_PRODUCT_NAME = '陶泥儿';
|
||||
export const AGC_APP_IDENTIFIER = 'world.genarrative.ai-game-creator';
|
||||
|
||||
const reservedChannelNames = new Set([
|
||||
'win',
|
||||
'mac',
|
||||
'windows',
|
||||
'macos',
|
||||
'darwin',
|
||||
'linux',
|
||||
]);
|
||||
|
||||
/** 校验渠道名:小写字母开头,允许数字与连字符,系统名不属于渠道。 */
|
||||
export function validateReleaseChannel(channel) {
|
||||
if (
|
||||
typeof channel !== 'string' ||
|
||||
!/^[a-z][a-z0-9-]{0,31}$/u.test(channel) ||
|
||||
channel.endsWith('-') ||
|
||||
reservedChannelNames.has(channel) ||
|
||||
/-(win|mac)$/u.test(channel)
|
||||
) {
|
||||
throw new Error(
|
||||
'发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道',
|
||||
);
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
export function resolveReleaseChannel(env = process.env) {
|
||||
return validateReleaseChannel(env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev');
|
||||
}
|
||||
|
||||
/** 安装身份里的展示后缀:`release` → `Release`,`beta-2` → `Beta-2`。 */
|
||||
export function channelDisplaySuffix(channel) {
|
||||
return validateReleaseChannel(channel)
|
||||
.split('-')
|
||||
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
|
||||
.join('-');
|
||||
}
|
||||
|
||||
/**
|
||||
* 渠道对应的安装身份。默认渠道返回基线身份,其它渠道派生渠道后缀,
|
||||
* 保证同一台设备上不同渠道互不覆盖。
|
||||
*/
|
||||
export function resolveChannelInstallIdentity(channel = AGC_DEFAULT_CHANNEL) {
|
||||
validateReleaseChannel(channel);
|
||||
if (channel === AGC_DEFAULT_CHANNEL) {
|
||||
return Object.freeze({
|
||||
productName: AGC_PRODUCT_NAME,
|
||||
identifier: AGC_APP_IDENTIFIER,
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
productName: `${AGC_PRODUCT_NAME} ${channelDisplaySuffix(channel)}`,
|
||||
identifier: `${AGC_APP_IDENTIFIER}.${channel}`,
|
||||
});
|
||||
}
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
appIdentifier,
|
||||
defaultRealSwarmTestTask,
|
||||
} from './agent-swarm-test-chat.mjs';
|
||||
import {
|
||||
AGC_APP_IDENTIFIER,
|
||||
AGC_PRODUCT_NAME,
|
||||
resolveChannelInstallIdentity,
|
||||
} from './channel-identity.mjs';
|
||||
import {
|
||||
askHidden,
|
||||
assertSafeGameCreatorConfigDestination,
|
||||
@@ -102,10 +107,6 @@ const appInvokeSources = readSourceFiles(
|
||||
new URL('../src/', import.meta.url),
|
||||
new Set(['.ts', '.tsx']),
|
||||
);
|
||||
const appEntrypointSource = fs.readFileSync(
|
||||
new URL('../src/main.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const tauriHandlerSource = fs.readFileSync(
|
||||
new URL('../src-tauri/src/main.rs', import.meta.url),
|
||||
'utf8',
|
||||
@@ -130,6 +131,45 @@ const rustSharedContractSource = fs.readFileSync(
|
||||
);
|
||||
const allowedUncalledTauriCommands = [
|
||||
'append_direct_project_conversation_message',
|
||||
// Supervisor 调试窗口、开发者面板、专业 Agent 对话与旧命令聊天的前端调用方已随
|
||||
// Supervisor 前端链路整体删除;命令本身仍注册在 Rust 侧并由 native Runtime、CLI
|
||||
// swarm 与 Rust 测试使用,保留 present,仅不再出现在 App 前端源码里。
|
||||
'answer_game_creator_agent_runtime_user_input',
|
||||
'cancel_game_creator_agent_runtime_task',
|
||||
'chat_with_game_creator_role_agent',
|
||||
'chat_with_game_creator_role_agent_stream',
|
||||
'check_game_creator_llm_config',
|
||||
'confirm_game_creator_agent_runtime_task',
|
||||
'diff_local_project_checkpoint',
|
||||
'get_game_creation_agent_capabilities',
|
||||
'get_limited_local_commands',
|
||||
'list_local_project_export_packages',
|
||||
'read_game_creator_agent_runtime',
|
||||
'read_local_agent_memory',
|
||||
'read_local_game_memory',
|
||||
'reject_game_creator_agent_runtime_task',
|
||||
'retry_game_creator_agent_runtime_task',
|
||||
'schedule_game_creator_agent_ready_tasks',
|
||||
'start_game_creator_agent_runtime_task',
|
||||
'steer_game_creator_agent_runtime_task',
|
||||
'write_local_agent_memory',
|
||||
'write_local_game_memory',
|
||||
'write_local_project_file',
|
||||
// Agent 运行时会话 / 目标 / 协作命令由 native 侧与 CLI swarm 驱动,前端没有调用方。
|
||||
'archive_game_creator_agent_session',
|
||||
'clear_game_creator_agent_goal',
|
||||
'compact_game_creator_agent_runtime_context',
|
||||
'confirm_retry_game_creator_agent_runtime_task',
|
||||
'create_game_creator_agent_session',
|
||||
'edit_game_creator_agent_goal',
|
||||
'fork_game_creator_agent_session',
|
||||
'list_game_creator_agent_sessions',
|
||||
'pause_game_creator_agent_goal',
|
||||
'read_game_creator_agent_goal',
|
||||
'resume_game_creator_agent_goal',
|
||||
'set_active_game_creator_agent_session',
|
||||
'start_game_creator_agent_goal',
|
||||
'start_game_creator_supervisor_runtime_task',
|
||||
// TODO: Remove the retired binding command after the legacy runtime path is removed.
|
||||
'bind_components',
|
||||
'chat_with_game_creator_agent',
|
||||
@@ -159,6 +199,26 @@ const allowedUncalledTauriCommands = [
|
||||
'call_agc_plugin',
|
||||
'read_agc_plugin_panel',
|
||||
'set_agc_plugin_enabled',
|
||||
// 下面这些命令的调用方只有随 Project Supervisor 前端链路一起删除的旧命令聊天入口;
|
||||
// 现在 App 前端、工作台与策划聊天都没有接线(检查点 / 恢复 / 索引 / 导出包 /
|
||||
// 画板同步 / 素材登记 / 权限策略 / 本地草案 / 平台美术),Rust 侧只剩注册与实现,
|
||||
// `*_at` helper 仍由 Rust 用例覆盖。接回新入口还是删除属于 native 能力取舍,先按
|
||||
// native-only 登记,避免孤儿检查一直报错。
|
||||
// 预览不在本清单:`activate_local_game_preview` 已按 ADR 回接到 App 的「运行」入口。
|
||||
'build_local_project_index',
|
||||
'control_agent_run',
|
||||
'create_local_project_checkpoint',
|
||||
'export_local_project_package',
|
||||
'generate_local_game_draft',
|
||||
'generate_platform_art_asset',
|
||||
'import_canvas_asset',
|
||||
'import_canvas_export',
|
||||
'open_canvas_project',
|
||||
'register_local_asset',
|
||||
'restore_local_project_checkpoint',
|
||||
'run_limited_local_command',
|
||||
'sync_canvas_project_assets',
|
||||
'write_project_permission_policy',
|
||||
];
|
||||
const sourceExtensions = new Set([
|
||||
'.json',
|
||||
@@ -1308,7 +1368,8 @@ if (
|
||||
}
|
||||
|
||||
for (const requiredSource of [
|
||||
"export const appIdentifier = 'world.genarrative.ai-game-creator'",
|
||||
"import { AGC_APP_IDENTIFIER } from './channel-identity.mjs'",
|
||||
'export const appIdentifier = AGC_APP_IDENTIFIER',
|
||||
"'--swarm-chat'",
|
||||
"'--autonomous-game-build'",
|
||||
"'--preview-serve'",
|
||||
@@ -1319,14 +1380,38 @@ for (const requiredSource of [
|
||||
}
|
||||
}
|
||||
|
||||
if (tauriConfig.productName !== '陶泥儿') {
|
||||
// 基线配置必须等于默认渠道的安装身份:默认渠道不能改身份,否则已发布客户端
|
||||
// 的升级链路与既有安装目录都会断开。
|
||||
const defaultChannelIdentity = resolveChannelInstallIdentity('dev');
|
||||
if (tauriConfig.productName !== AGC_PRODUCT_NAME) {
|
||||
throw new Error('AI game creator shell productName drifted');
|
||||
}
|
||||
|
||||
if (tauriConfig.identifier !== 'world.genarrative.ai-game-creator') {
|
||||
if (tauriConfig.identifier !== AGC_APP_IDENTIFIER) {
|
||||
throw new Error('AI game creator shell identifier drifted');
|
||||
}
|
||||
|
||||
if (
|
||||
tauriConfig.productName !== defaultChannelIdentity.productName ||
|
||||
tauriConfig.identifier !== defaultChannelIdentity.identifier
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator shell baseline config must match the default channel identity',
|
||||
);
|
||||
}
|
||||
|
||||
// 非默认渠道必须派生出独立安装身份,否则同机安装会互相顶掉。
|
||||
for (const channel of ['release', 'beta-2']) {
|
||||
const identity = resolveChannelInstallIdentity(channel);
|
||||
if (
|
||||
identity.productName === defaultChannelIdentity.productName ||
|
||||
identity.identifier === defaultChannelIdentity.identifier ||
|
||||
!identity.identifier.startsWith(`${AGC_APP_IDENTIFIER}.`)
|
||||
) {
|
||||
throw new Error(`channel install identity not isolated: ${channel}`);
|
||||
}
|
||||
}
|
||||
|
||||
const expectedBundledDesignAgentResources = {
|
||||
'design-agent': 'design-agent',
|
||||
...Object.fromEntries(
|
||||
@@ -1440,13 +1525,7 @@ const eventCapability = JSON.parse(
|
||||
);
|
||||
const eventCapabilityWindows = new Set(eventCapability.windows ?? []);
|
||||
const eventCapabilityPermissions = new Set(eventCapability.permissions ?? []);
|
||||
for (const windowLabel of [
|
||||
'client',
|
||||
'developer',
|
||||
'main',
|
||||
'launcher',
|
||||
'supervisor-chat',
|
||||
]) {
|
||||
for (const windowLabel of ['client', 'main', 'launcher']) {
|
||||
if (!eventCapabilityWindows.has(windowLabel)) {
|
||||
throw new Error(
|
||||
`AI game creator shell event capability missing window: ${windowLabel}`,
|
||||
@@ -1829,20 +1908,6 @@ if (
|
||||
);
|
||||
}
|
||||
|
||||
for (const snippet of [
|
||||
'import.meta.env.DEV',
|
||||
'supervisorChatMode',
|
||||
'supervisorChatOnly',
|
||||
'open_project_supervisor_chat_window',
|
||||
'index.html?supervisor-chat&projectPath=',
|
||||
]) {
|
||||
if (!`${appEntrypointSource}\n${tauriRustSource}`.includes(snippet)) {
|
||||
throw new Error(
|
||||
`AI game creator shell developer window guardrail drifted: ${snippet}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (tauriHandlerSource.includes('open_developer_window(app.handle())?')) {
|
||||
throw new Error(
|
||||
'AI game creator normal startup must not automatically open the developer window',
|
||||
@@ -1875,31 +1940,18 @@ for (const snippet of [
|
||||
'官方账号服务(固定)',
|
||||
'runtime_config.save',
|
||||
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
|
||||
"'activate_local_game_preview'",
|
||||
'已载入客户端运行视图',
|
||||
'async function executeRunLocal',
|
||||
'function needsInitializedChatProject',
|
||||
'function resolvePendingCommandProjectPath',
|
||||
'resolveChatProjectPath(localProject) ?? draftProjectPath',
|
||||
'`permission.cancel ${command.id} missing-project`',
|
||||
"'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'",
|
||||
"'/memory-set [short|long|blackboard] 内容:覆盖保存对应记忆'",
|
||||
'function parseRememberInput',
|
||||
"'/trace 或 /loop:查看最近一次 Agent loop trace'",
|
||||
'async function executeAgentTraceChat',
|
||||
"relativePath: '.agent/logs/command.log'",
|
||||
"'permission.pending'",
|
||||
"'permission.confirm'",
|
||||
"'permission.cancel'",
|
||||
"'command.auto'",
|
||||
"'agent.run_status'",
|
||||
'function summarizeAgentRunTrace',
|
||||
'工具调用:${agentRunTrace.toolCallCount}/${agentRunTrace.maxToolCalls}',
|
||||
'agentRunTrace.error ?',
|
||||
'className="trace-error"',
|
||||
'agentRunTrace.taskGraph.repairRoutes.map',
|
||||
"in: ${step.inputPaths.join(', ') || 'none'}",
|
||||
"out: ${step.outputPaths.join(', ') || 'none'}",
|
||||
]) {
|
||||
if (!appSource.includes(snippet)) {
|
||||
throw new Error(
|
||||
|
||||
@@ -174,8 +174,16 @@ test('macOS release entry and smoke script derive product names from config and
|
||||
new URL('./build-macos-ci.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
// 产品名决定 *.app、updater 归档与 DMG 卷名:写死会在改名后静默找错对象。
|
||||
assert.ok(entry.includes('readProductName'), '入口必须从 Tauri 配置读产品名');
|
||||
// 产品名决定 *.app、updater 归档与 DMG 卷名:它必须从渠道安装身份派生,
|
||||
// 写死会在换渠道或改名后静默找错对象。
|
||||
assert.ok(
|
||||
entry.includes('resolveChannelInstallIdentity'),
|
||||
'入口必须从渠道安装身份派生产品名',
|
||||
);
|
||||
assert.ok(
|
||||
entry.includes('resolveProductName(context.channel)'),
|
||||
'产品名必须按当前发布渠道解析',
|
||||
);
|
||||
assert.ok(!entry.includes('陶泥儿'), 'macOS 发布入口不得写死产品名');
|
||||
assert.ok(
|
||||
entry.includes("const macTarget = 'aarch64-apple-darwin'"),
|
||||
|
||||
@@ -1192,7 +1192,7 @@ async function main() {
|
||||
function isDirectModuleExecution() {
|
||||
return Boolean(
|
||||
process.argv[1] &&
|
||||
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
|
||||
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "developer",
|
||||
"description": "开发窗口允许打开本地素材选择对话框。",
|
||||
"windows": ["developer"],
|
||||
"permissions": ["dialog:allow-open"]
|
||||
}
|
||||
@@ -2,6 +2,6 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "events",
|
||||
"description": "允许客户端窗口订阅并取消订阅 Rust Runtime 事件。",
|
||||
"windows": ["client", "developer", "main", "launcher", "supervisor-chat"],
|
||||
"windows": ["client", "main", "launcher"],
|
||||
"permissions": ["core:event:allow-listen", "core:event:allow-unlisten"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "window-chrome",
|
||||
"description": "自绘标题栏允许执行当前窗口的基础控制和拖拽。",
|
||||
"windows": ["client", "developer", "main", "launcher", "supervisor-chat"],
|
||||
"windows": ["client", "main", "launcher"],
|
||||
"permissions": [
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-is-maximized",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"taonier_prepare_game_art.parameters.brief": "面向当前游戏的简洁视觉需求",
|
||||
"taonier_prepare_game_art.parameters.mode": "缺省安全复用有效美术包;Codex 仅在当前对话需要换一套或重新生成时使用 regenerate",
|
||||
"agc_generate_image.description": "生成一张新图片:普通插画、角色立绘、统一视觉规范图、游戏 UI 设计图或透明游戏素材图集。仅在用户明确要求生成新图时调用。",
|
||||
"agc_generate_image.parameters.prompt": "完整图片描述;普通图片、角色、规范图、UI 设计图或透明图集均可",
|
||||
"agc_generate_image.parameters.prompt": "完整图片描述;普通图片、角色、规范图、UI 设计图或透明图集均可。kind=icon-spritesheet 时,去除首尾空白后的描述须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交;超限拒绝,不截断、不拆条,客户端不追加生图指令",
|
||||
"agc_generate_image.parameters.kind": "image=普通新图(保留生成原图),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),icon-spec=统一视觉规范图,ui-design=完整 UI 设计图,icon-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图",
|
||||
"agc_generate_image.parameters.assetName": "本地素材的人类可读显示名称",
|
||||
"agc_generate_image.parameters.outputPath": "可选项目相对输出路径,必须位于 assets/ 且不能覆盖已有文件",
|
||||
|
||||
@@ -23,8 +23,6 @@
|
||||
"icon_spec_generation": "为这个 Web 小游戏生成一张 1:1 的统一视觉规范图,作为后续 UI 设计图和透明游戏图集的共同权威参考。规范板必须分区展示:玩家主体及其成长形态、核心目标或收集物、场景地块与障碍、HUD/操作图标、得分/受击/胜负反馈、主辅强调色与材质规则。所有元素使用一致的正交视角、轮廓、光照和原创视觉语言,留出清楚间距;不要生成完整游戏截图、海报、黑底图集或纯文字说明。玩法机制只用于理解功能,不授权复刻现有作品。\n\n项目视觉需求:{}",
|
||||
"ui_design_generation": "根据下方当前项目 UI 需求生成一张完整的游戏 UI/UX 原型图,玩法与界面结构以这些需求为准。画面是完整 16:9 桌面端单屏界面,并同时明确移动端重排意图;清楚呈现当前玩法所需的分数/资源/生命/局内状态 HUD、主要可玩区域、玩家与目标/收集物/危险物、开始和主要操作、失败状态与重新开始、键盘和触控提示。使用正视角、清晰分区和可读占位文字,使前端开发可直接据此拆分 HTML/CSS。采用项目已经确定的原创命名、角色轮廓、配色、场景材质和界面视觉语言。\n\n当前项目 UI 需求:{}",
|
||||
"scene_generation": "为 Web 小游戏生成一张可直接作为运行画面底图的原创 16:9 场景背景。严格从下方用户需求提炼自己的游戏主题、地点、季节、材质和氛围;画面要为真实可玩区域留出足够清楚的中部空间,并有前景、中景、远景层次。不得画玩家角色、道具、棋子、障碍、HUD、操作按钮、文字、Logo、完整游戏截图、海报或素材图集;这些元素会从独立透明核心图集中绘制。不得自行假设为塔防或加入玩法合同中不存在的实体;必须原创,不得复刻现有游戏场景、贴图、标志性布局或受保护视觉语言。\n\n用户需求:{}",
|
||||
"default_art_brief": "需要一张可直接用于 Web 小游戏首版原型的核心美术素材。",
|
||||
"spritesheet_generation": "为 Web 小游戏首版原型生成一张可切分的原创透明核心美术素材图集,适合放入本地 assets 并被游戏直接引用。严格从用户需求和美术 brief 提取当前项目自己的标题、玩法实体、目标物、收集物、障碍、状态与反馈,素材类别与数量以当前项目需求为准。所有元素沿用当前规范图的轮廓、配色、材质和光照,分区排布并留出清楚切分间距。角色轮廓、图标排布与配色采用项目原创设计。\n用户需求:{}\n美术资产 brief:{}",
|
||||
"ui_inspection_focus": "请只依据真实可见像素判断这是否是可供前端直接实现的完整游戏 UI 原型,不能依据文件名、生成提示词或图片内自述放行。纯场景图、概念图、地图、海报或只展示角色而没有可玩界面的插画必须判定失败;按当前项目的玩法识别界面结构与关键要素。逐项检查:informationHud=清楚显示当前玩法需要的分数、资源、生命、关卡或局内状态;gameplaySurface=主要可玩区域及空间规则清楚;objectiveEntities=玩家主体、目标/收集/危险物、谜题或文本选项、轨道等当前玩法等价关键要素可辨;primaryControls=当前玩法需要的开始、移动、暂停或操作控件清楚;failureRestartFlow=存在可识别的结束态表现意图或明确重开入口;responsiveLayout=能从可见布局、触控目标和可重排分组判断移动适配意图,实际双视口另由浏览器验证;implementationClarity=分区、层级和文字清楚到可指导 HTML/CSS;originalTheme=原创主题且未复刻现有游戏角色、Logo、贴图或受保护视觉语言。请只返回一个 JSON object,字段必须严格为:{\"checks\":{\"informationHud\":true,\"gameplaySurface\":true,\"objectiveEntities\":true,\"primaryControls\":true,\"failureRestartFlow\":true,\"responsiveLayout\":true,\"implementationClarity\":true,\"originalTheme\":true},\"issues\":[\"未通过项及原因;全部通过时必须为空数组\"],\"summary\":\"500 字以内中文结论\"}。只有八项 checks 全为 true 且 issues 为空才通过。",
|
||||
"default_inspection_focus": "请检查布局、遮挡、裁切、视觉层级、素材一致性,以及桌面与移动视口是否可用。",
|
||||
"custom_inspection_focus": "检查重点:{question}",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"preview.start.description": "启动当前项目的 loopback HTTP 预览。",
|
||||
"preview.validate.description": "用真实浏览器验证桌面和移动预览并保存证据。",
|
||||
"image.inspect.description": "让视觉模型检查一至两张项目内图片。",
|
||||
"canvas.asset_generate.description": "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。",
|
||||
"canvas.asset_generate.description": "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 的 prompt 去除首尾空白后须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交,超限拒绝,不截断、不拆条,客户端不追加生图指令。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。",
|
||||
"ui.workflow.run.description": "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-design 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。",
|
||||
"cocos.editor.execute.description": "在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;Runtime 自动绑定唯一匹配的 Creator 主进程,代码与结果都通过注入 payload 的本机 bridge 返回。",
|
||||
"unity.editor.execute.description": "在当前 Unity 项目已打开的 Windows x64 Mono Editor 中执行 C#。仅提交 code,宿主绑定项目身份;结果待核对时禁止自动重发。",
|
||||
|
||||
@@ -13,7 +13,7 @@ mod codex_app_server;
|
||||
mod codex_cli;
|
||||
mod codex_provider_proxy;
|
||||
mod design_runtime;
|
||||
mod design_tools;
|
||||
pub(crate) mod design_tools;
|
||||
mod direct_codex_attachments;
|
||||
mod direct_codex_audit;
|
||||
mod direct_codex_user_item;
|
||||
|
||||
@@ -1180,7 +1180,6 @@ fn direct_codex_thread_delta_event(
|
||||
) -> DirectThreadEvent {
|
||||
DirectThreadEvent::item_delta(item_id, kind, direct_thread_delta_text(root, delta))
|
||||
}
|
||||
|
||||
/// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。
|
||||
///
|
||||
/// 读取器只负责"必须有 turnId 才处理"的前置条件与节流(活动 / 正文),分类不在这里之外
|
||||
@@ -3358,8 +3357,22 @@ impl CodexAppServerConnection {
|
||||
codex_app_server_text_prompt(&request)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?
|
||||
};
|
||||
let mut input =
|
||||
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?;
|
||||
let mut input = if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
if let Some(item) = direct_user_item {
|
||||
let canonical: DirectCodexUserItem = serde_json::from_value(item.clone())
|
||||
.map_err(|error| platform_llm::LlmError::InvalidRequest(error.to_string()))?;
|
||||
direct_codex_user_item_to_codex_turn_input(
|
||||
&self.inner.workspace_path,
|
||||
&canonical,
|
||||
self.inner._skill_roots.as_deref().unwrap_or_default(),
|
||||
)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?
|
||||
} else {
|
||||
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?
|
||||
}
|
||||
} else {
|
||||
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?
|
||||
};
|
||||
if thread_created && self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject
|
||||
{
|
||||
if let Some(client_turn_id) = direct_client_turn_id {
|
||||
@@ -7684,7 +7697,7 @@ done
|
||||
&temp.path().join("host"),
|
||||
&project,
|
||||
"turn-0001",
|
||||
"fixture-request",
|
||||
&format!("{:x}", Sha256::digest("请创建菜单".as_bytes())),
|
||||
false,
|
||||
&super::super::direct_validation::DirectValidationConfig::default(),
|
||||
)
|
||||
|
||||
@@ -4,10 +4,12 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::Manager;
|
||||
|
||||
const DESIGN_WORKSPACE_ROOT: &str = "design_artifacts";
|
||||
const DESIGN_REFERENCES_ROOT: &str = "references";
|
||||
const SEARCH_HIT_LIMIT: usize = 200;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -547,6 +549,123 @@ pub(crate) fn read_design_workspace_file_at(root: &Path, path: &str) -> Result<S
|
||||
fs::read_to_string(&target).map_err(|error| format!("读取失败:{error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn import_design_workspace_file(
|
||||
app: tauri::AppHandle,
|
||||
project_path: String,
|
||||
file_name: String,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<String, String> {
|
||||
let root = PathBuf::from(project_path.trim());
|
||||
let relative_path = import_design_workspace_file_at(&root, &file_name, &bytes)?;
|
||||
let _ = app.emit(
|
||||
"design-agent-update",
|
||||
serde_json::json!({
|
||||
"projectPath": root.to_string_lossy(),
|
||||
"clientTurnId": "",
|
||||
"kind": "workspace",
|
||||
"messageId": null,
|
||||
"text": null,
|
||||
"view": null,
|
||||
}),
|
||||
);
|
||||
Ok(relative_path)
|
||||
}
|
||||
|
||||
fn import_design_workspace_file_at(
|
||||
root: &Path,
|
||||
file_name: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<String, String> {
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
read_existing_manifest_for_project(root)?;
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"conversation.write",
|
||||
)?;
|
||||
|
||||
if file_name.contains(['/', '\\']) {
|
||||
return Err("附件文件名必须是单个文件名,不能包含目录".to_string());
|
||||
}
|
||||
let normalized_name =
|
||||
normalize_relative_path(file_name).map_err(|error| format!("附件文件名无效:{error}"))?;
|
||||
if normalized_name != file_name {
|
||||
return Err("附件文件名无效".to_string());
|
||||
}
|
||||
|
||||
let (_, references) = resolve_design_workspace_path(root, DESIGN_REFERENCES_ROOT)?;
|
||||
crate::ensure_game_creator_private_directory_tree(&references, "策划参考附件目录")?;
|
||||
crate::prepare_game_creator_private_path_for_read(&references, true, "策划参考附件目录")?;
|
||||
|
||||
let mut sequence = 1_u64;
|
||||
loop {
|
||||
let candidate_name = design_reference_file_name(&normalized_name, sequence);
|
||||
let relative_path = format!("{DESIGN_REFERENCES_ROOT}/{candidate_name}");
|
||||
let (_, target) = resolve_design_workspace_path(root, &relative_path)?;
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.custom_flags(libc::O_NOFOLLOW).mode(0o600);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
|
||||
}
|
||||
let mut file = match options.open(&target) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
sequence = sequence
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| "无法为同名附件分配新序号".to_string())?;
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"创建策划参考附件失败:{}: {error}",
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
};
|
||||
if let Err(error) =
|
||||
crate::harden_new_game_creator_private_path(&target, false, "策划参考附件")
|
||||
{
|
||||
drop(file);
|
||||
let _ = fs::remove_file(&target);
|
||||
return Err(error);
|
||||
}
|
||||
let write_result = file.write_all(bytes).and_then(|_| file.sync_all());
|
||||
drop(file);
|
||||
if let Err(error) = write_result {
|
||||
let _ = fs::remove_file(&target);
|
||||
return Err(format!(
|
||||
"写入策划参考附件失败:{}: {error}",
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
return Ok(relative_path);
|
||||
}
|
||||
}
|
||||
|
||||
fn design_reference_file_name(file_name: &str, sequence: u64) -> String {
|
||||
if sequence == 1 {
|
||||
return file_name.to_string();
|
||||
}
|
||||
let path = Path::new(file_name);
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or(file_name);
|
||||
match path.extension().and_then(|value| value.to_str()) {
|
||||
Some(extension) if !extension.is_empty() => {
|
||||
format!("{stem} ({sequence}).{extension}")
|
||||
}
|
||||
_ => format!("{stem} ({sequence})"),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_design_catalog(root: &Path) -> Result<Vec<DesignCatalogItem>, String> {
|
||||
let catalog_path = root.join("resources/catalog.json");
|
||||
let data: DesignCatalogFile = serde_json::from_str(
|
||||
@@ -756,6 +875,13 @@ mod tests {
|
||||
tempfile::tempdir().expect("tempdir")
|
||||
}
|
||||
|
||||
fn initialized_test_root() -> tempfile::TempDir {
|
||||
let temp = test_root();
|
||||
init_local_game_project_at(temp.path(), "design-import-test", "策划附件导入测试")
|
||||
.expect("init project");
|
||||
temp
|
||||
}
|
||||
|
||||
fn pack_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("design-agent")
|
||||
}
|
||||
@@ -868,6 +994,138 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_reference_is_visible_to_workspace_list_and_read() {
|
||||
let temp = initialized_test_root();
|
||||
let root = temp.path();
|
||||
let manifest_before = read_existing_manifest_for_project(root).expect("read manifest");
|
||||
let revision_before =
|
||||
read_game_creator_agent_runtime_project_revision(root).expect("read revision");
|
||||
|
||||
let relative =
|
||||
import_design_workspace_file_at(root, "玩法构想.txt", "横版解谜\n".as_bytes())
|
||||
.expect("import text reference");
|
||||
|
||||
assert_eq!(relative, "references/玩法构想.txt");
|
||||
assert!(list_design_workspace_files(root)
|
||||
.expect("list workspace")
|
||||
.iter()
|
||||
.any(|entry| entry.path == relative && entry.kind == "file"));
|
||||
assert_eq!(
|
||||
read_design_workspace_file_at(root, &relative).expect("read imported reference"),
|
||||
"横版解谜\n"
|
||||
);
|
||||
assert_eq!(
|
||||
read_existing_manifest_for_project(root).expect("read manifest after import"),
|
||||
manifest_before
|
||||
);
|
||||
assert_eq!(
|
||||
read_game_creator_agent_runtime_project_revision(root)
|
||||
.expect("read revision after import"),
|
||||
revision_before
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_keeps_existing_names_and_accepts_empty_and_binary_bytes() {
|
||||
let temp = initialized_test_root();
|
||||
let root = temp.path();
|
||||
|
||||
let first = import_design_workspace_file_at(root, "brief.md", b"first")
|
||||
.expect("import first reference");
|
||||
let second = import_design_workspace_file_at(root, "brief.md", b"second")
|
||||
.expect("import repeated reference");
|
||||
let empty = import_design_workspace_file_at(root, "empty.bin", b"")
|
||||
.expect("import empty reference");
|
||||
let binary_bytes = [0_u8, 0xff, 0x10, 0x80];
|
||||
let binary = import_design_workspace_file_at(root, "bytes.bin", &binary_bytes)
|
||||
.expect("import binary reference");
|
||||
|
||||
assert_eq!(first, "references/brief.md");
|
||||
assert_eq!(second, "references/brief (2).md");
|
||||
assert_eq!(empty, "references/empty.bin");
|
||||
assert_eq!(binary, "references/bytes.bin");
|
||||
assert_eq!(
|
||||
fs::read(root.join("design_artifacts").join(&first)).expect("read first"),
|
||||
b"first"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(root.join("design_artifacts").join(&second)).expect("read second"),
|
||||
b"second"
|
||||
);
|
||||
assert!(fs::read(root.join("design_artifacts").join(&empty))
|
||||
.expect("read empty")
|
||||
.is_empty());
|
||||
assert_eq!(
|
||||
fs::read(root.join("design_artifacts").join(&binary)).expect("read binary"),
|
||||
binary_bytes
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_rejects_unsafe_names_and_denied_project_permission() {
|
||||
let temp = initialized_test_root();
|
||||
let root = temp.path();
|
||||
for file_name in [
|
||||
"../outside.txt",
|
||||
"nested/file.txt",
|
||||
r"nested\file.txt",
|
||||
"C:stream",
|
||||
] {
|
||||
let error = import_design_workspace_file_at(root, file_name, b"blocked")
|
||||
.expect_err("reject unsafe file name");
|
||||
assert!(error.contains("文件名"), "unexpected error: {error}");
|
||||
}
|
||||
assert!(!root.join("outside.txt").exists());
|
||||
|
||||
let mut policy = ProjectPermissionPolicy::default();
|
||||
policy
|
||||
.denied_commands
|
||||
.push("conversation.write".to_string());
|
||||
write_project_permission_policy_at(root, policy).expect("deny conversation write");
|
||||
let error = import_design_workspace_file_at(root, "denied.txt", b"blocked")
|
||||
.expect_err("respect project permission policy");
|
||||
assert!(error.contains("conversation.write"));
|
||||
assert!(!root.join("design_artifacts/references/denied.txt").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn import_rejects_linked_references_directory() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = initialized_test_root();
|
||||
let root = temp.path();
|
||||
let outside = tempfile::tempdir().expect("outside tempdir");
|
||||
fs::create_dir_all(root.join("design_artifacts")).expect("create workspace");
|
||||
symlink(outside.path(), root.join("design_artifacts/references"))
|
||||
.expect("link references directory");
|
||||
|
||||
let error = import_design_workspace_file_at(root, "escape.txt", b"blocked")
|
||||
.expect_err("reject linked references directory");
|
||||
assert!(error.contains("符号链接") || error.contains("reparse point"));
|
||||
assert!(!outside.path().join("escape.txt").exists());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn import_rejects_windows_linked_references_directory_when_supported() {
|
||||
use std::os::windows::fs::symlink_dir;
|
||||
|
||||
let temp = initialized_test_root();
|
||||
let root = temp.path();
|
||||
let outside = tempfile::tempdir().expect("outside tempdir");
|
||||
fs::create_dir_all(root.join("design_artifacts")).expect("create workspace");
|
||||
if symlink_dir(outside.path(), root.join("design_artifacts/references")).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let error = import_design_workspace_file_at(root, "escape.txt", b"blocked")
|
||||
.expect_err("reject linked references directory");
|
||||
assert!(error.contains("符号链接") || error.contains("reparse point"));
|
||||
assert!(!outside.path().join("escape.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_context_injects_current_skill_only() {
|
||||
let resources = DesignResources::new(pack_root()).expect("pack");
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。
|
||||
|
||||
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8;
|
||||
const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160;
|
||||
const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
|
||||
const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512;
|
||||
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160;
|
||||
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
|
||||
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512;
|
||||
|
||||
const HOME_ATTACHMENT_HEADER: &str = prompt_text!("projectContext.attachments.homeHeader");
|
||||
const PROJECT_ATTACHMENT_HEADER: &str = prompt_text!("projectContext.attachments.projectHeader");
|
||||
|
||||
@@ -5,11 +5,11 @@ mod validation;
|
||||
mod wire;
|
||||
|
||||
pub(crate) use model::{
|
||||
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem,
|
||||
DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
|
||||
DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
|
||||
DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
|
||||
};
|
||||
pub(crate) use validation::validate_direct_codex_user_item;
|
||||
pub(crate) use wire::{
|
||||
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item,
|
||||
direct_codex_user_item_to_wire_input,
|
||||
direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt,
|
||||
direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ use ts_rs::TS;
|
||||
/// DirectProject 本轮 user input 的唯一结构化入口。
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(tag = "type", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||
pub(crate) enum DirectCodexUserItem {
|
||||
#[serde(rename = "message")]
|
||||
Message(DirectCodexUserMessageItem),
|
||||
@@ -12,7 +12,7 @@ pub(crate) enum DirectCodexUserItem {
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||
pub(crate) struct DirectCodexUserMessageItem {
|
||||
pub(crate) role: DirectCodexUserRole,
|
||||
pub(crate) content: Vec<DirectCodexUserContentPart>,
|
||||
@@ -21,26 +21,43 @@ pub(crate) struct DirectCodexUserMessageItem {
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||
pub(crate) enum DirectCodexUserRole {
|
||||
User,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||
pub(crate) enum DirectCodexUserContentPart {
|
||||
#[serde(rename = "input_text")]
|
||||
InputText { text: String },
|
||||
#[serde(rename = "agc_resource_reference")]
|
||||
AgcResourceReference { resource_id: String },
|
||||
#[serde(rename = "agc_skill_reference")]
|
||||
AgcSkillReference { name: String },
|
||||
#[serde(rename = "agc_runtime_region_reference")]
|
||||
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
|
||||
/// Uploaded project attachment kept inline in canonical content.
|
||||
#[serde(rename = "agc_attachment_reference")]
|
||||
AgcAttachmentReference(DirectCodexUserAttachmentReferencePart),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||
pub(crate) struct DirectCodexUserAttachmentReferencePart {
|
||||
pub(crate) name: String,
|
||||
pub(crate) media_type: String,
|
||||
#[ts(type = "number")]
|
||||
pub(crate) size: u64,
|
||||
pub(crate) local_path: String,
|
||||
pub(crate) status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||
pub(crate) struct DirectCodexUserRuntimeRegionPart {
|
||||
pub(crate) label: String,
|
||||
#[serde(default)]
|
||||
|
||||
+249
-14
@@ -4,15 +4,20 @@ use super::model::{
|
||||
};
|
||||
use crate::agent::{
|
||||
read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest,
|
||||
MAX_DIRECT_CODEX_ATTACHMENTS, MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS,
|
||||
MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
|
||||
// Skill 引用也是非文本 part,但没有走 reference_count:它每一条都会触发一次
|
||||
// `root/<name>/SKILL.md` 文件探测并往 turn input 里加一项,所以单独设上限。
|
||||
pub(crate) const MAX_DIRECT_CODEX_SKILL_REFERENCES: usize = 32;
|
||||
|
||||
pub(crate) fn validate_direct_codex_user_item(
|
||||
root: &Path,
|
||||
item: &DirectCodexUserItem,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<GameCreationAppManifest, String> {
|
||||
let DirectCodexUserItem::Message(message) = item;
|
||||
if !matches!(message.role, DirectCodexUserRole::User) {
|
||||
return Err("DirectProject 只接受 user message item".to_string());
|
||||
@@ -20,38 +25,98 @@ pub(crate) fn validate_direct_codex_user_item(
|
||||
if message.id.trim().is_empty() {
|
||||
return Err("DirectProject user item 缺少稳定 id".to_string());
|
||||
}
|
||||
if message.content.is_empty() {
|
||||
// 有效输入只判一整条 content:单个纯空白 `input_text` 是合法 part —— 编辑器里的段落
|
||||
// 分隔、软换行与 chip 后的分隔空格就是这样落进 canonical content 的,前端不为它过滤。
|
||||
if !content_has_meaningful_input(&message.content) {
|
||||
return Err("聊天内容不能为空".to_string());
|
||||
}
|
||||
let manifest = read_manifest_for_project(root)?;
|
||||
let mut reference_count = 0usize;
|
||||
let mut has_effective_content = false;
|
||||
let mut attachment_count = 0usize;
|
||||
let mut skill_count = 0usize;
|
||||
for part in &message.content {
|
||||
match part {
|
||||
DirectCodexUserContentPart::InputText { text } => {
|
||||
if !text.trim().is_empty() {
|
||||
has_effective_content = true;
|
||||
}
|
||||
}
|
||||
DirectCodexUserContentPart::InputText { .. } => {}
|
||||
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
||||
reference_count = reference_count.saturating_add(1);
|
||||
validate_resource_id_and_manifest(&manifest, resource_id)?;
|
||||
has_effective_content = true;
|
||||
}
|
||||
DirectCodexUserContentPart::AgcSkillReference { name } => {
|
||||
skill_count = skill_count.saturating_add(1);
|
||||
if skill_count > MAX_DIRECT_CODEX_SKILL_REFERENCES {
|
||||
return Err(format!(
|
||||
"一次最多引用 {MAX_DIRECT_CODEX_SKILL_REFERENCES} 个 Skill"
|
||||
));
|
||||
}
|
||||
let name = name.trim();
|
||||
if name.is_empty()
|
||||
|| name.chars().count() > 120
|
||||
|| matches!(name, "." | "..")
|
||||
|| name.chars().any(|character| {
|
||||
character.is_control()
|
||||
|| character.is_whitespace()
|
||||
|| matches!(character, '/' | '\\' | ':' | '$')
|
||||
})
|
||||
{
|
||||
return Err("引用的 Skill 名称无效,请移除后重新选择".to_string());
|
||||
}
|
||||
}
|
||||
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
||||
reference_count = reference_count.saturating_add(1);
|
||||
validate_runtime_region_reference(&manifest, reference)?;
|
||||
has_effective_content = true;
|
||||
}
|
||||
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
||||
attachment_count = attachment_count.saturating_add(1);
|
||||
if attachment_count > MAX_DIRECT_CODEX_ATTACHMENTS {
|
||||
return Err(format!(
|
||||
"一次最多携带 {MAX_DIRECT_CODEX_ATTACHMENTS} 个附件"
|
||||
));
|
||||
}
|
||||
if reference.name.trim().is_empty() {
|
||||
return Err("附件缺少文件名".to_string());
|
||||
}
|
||||
let name = reference.name.trim();
|
||||
if name.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS
|
||||
|| name.chars().any(char::is_control)
|
||||
{
|
||||
return Err("附件文件名无效或过长".to_string());
|
||||
}
|
||||
let media_type = reference.media_type.trim();
|
||||
if media_type.is_empty()
|
||||
|| media_type.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS
|
||||
|| media_type.chars().any(|character| {
|
||||
!(character.is_ascii_alphanumeric()
|
||||
|| matches!(character, '/' | '+' | '-' | '.' | '_'))
|
||||
})
|
||||
{
|
||||
return Err("附件媒体类型无效或过长".to_string());
|
||||
}
|
||||
let status = reference.status.trim();
|
||||
if status == "imported" && reference.local_path.trim().is_empty() {
|
||||
return Err("已导入附件缺少项目路径".to_string());
|
||||
}
|
||||
if !reference.local_path.trim().is_empty() {
|
||||
sanitize_attachment_local_path(&reference.local_path)
|
||||
.ok_or_else(|| "附件项目路径无效".to_string())?;
|
||||
}
|
||||
if !matches!(status, "imported" | "failed") {
|
||||
return Err("附件状态无效".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
|
||||
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
|
||||
}
|
||||
if !has_effective_content {
|
||||
return Err("聊天内容不能为空".to_string());
|
||||
}
|
||||
Ok(())
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// 整条 content 是否还有有效输入:任何一段非空白文本、或任何一个非文本 part 都算。
|
||||
pub(crate) fn content_has_meaningful_input(content: &[DirectCodexUserContentPart]) -> bool {
|
||||
content.iter().any(|part| match part {
|
||||
DirectCodexUserContentPart::InputText { text } => !text.trim().is_empty(),
|
||||
_ => true,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn validate_resource_id_and_manifest(
|
||||
@@ -92,3 +157,173 @@ fn validate_runtime_region_reference(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
content_has_meaningful_input, validate_direct_codex_user_item,
|
||||
MAX_DIRECT_CODEX_SKILL_REFERENCES,
|
||||
};
|
||||
use crate::agent::direct_codex_user_item::model::DirectCodexUserContentPart;
|
||||
use serde_json::json;
|
||||
|
||||
fn input_text(text: &str) -> DirectCodexUserContentPart {
|
||||
DirectCodexUserContentPart::InputText {
|
||||
text: text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_all_blank_content_counts_as_empty_input() {
|
||||
// 空数组与「整条只有空白」是同一种空输入。
|
||||
assert!(!content_has_meaningful_input(&[]));
|
||||
assert!(!content_has_meaningful_input(&[input_text(" \n ")]));
|
||||
assert!(!content_has_meaningful_input(&[
|
||||
input_text("\n"),
|
||||
input_text(" "),
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_parts_are_valid_next_to_meaningful_input() {
|
||||
// 段落分隔 / 软换行 / chip 后的分隔空格都是合法的单个 part。
|
||||
assert!(content_has_meaningful_input(&[
|
||||
input_text("\n"),
|
||||
input_text("看"),
|
||||
]));
|
||||
assert!(content_has_meaningful_input(&[
|
||||
input_text("看"),
|
||||
input_text("\n\n"),
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_text_parts_always_count_as_input() {
|
||||
assert!(content_has_meaningful_input(&[
|
||||
DirectCodexUserContentPart::AgcResourceReference {
|
||||
resource_id: "asset-hero".to_string(),
|
||||
},
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_attachment_count_is_bounded_independently() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
|
||||
.expect("init project");
|
||||
let content = (0..=crate::agent::MAX_DIRECT_CODEX_ATTACHMENTS)
|
||||
.map(|index| {
|
||||
json!({
|
||||
"type": "agc_attachment_reference",
|
||||
"name": format!("attachment-{index}.txt"),
|
||||
"mediaType": "text/plain",
|
||||
"size": 1,
|
||||
"localPath": "",
|
||||
"status": "failed"
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let item = serde_json::from_value(json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": content,
|
||||
"id": "turn-1:user"
|
||||
}))
|
||||
.expect("deserialize user item");
|
||||
let error = validate_direct_codex_user_item(root.path(), &item)
|
||||
.expect_err("too many inline attachments must be rejected");
|
||||
assert!(error.contains("最多携带"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_attachment_requires_a_project_path() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
|
||||
.expect("init project");
|
||||
let item = serde_json::from_value(json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "agc_attachment_reference",
|
||||
"name": "attachment.txt",
|
||||
"mediaType": "text/plain",
|
||||
"size": 1,
|
||||
"localPath": "",
|
||||
"status": "imported"
|
||||
}],
|
||||
"id": "turn-1:user"
|
||||
}))
|
||||
.expect("deserialize user item");
|
||||
let error = validate_direct_codex_user_item(root.path(), &item)
|
||||
.expect_err("imported attachment without a project path must fail");
|
||||
assert!(error.contains("缺少项目路径"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_skill_reference_count_is_bounded_independently() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
|
||||
.expect("init project");
|
||||
let content = (0..=MAX_DIRECT_CODEX_SKILL_REFERENCES)
|
||||
.map(|index| json!({ "type": "agc_skill_reference", "name": format!("skill-{index}") }))
|
||||
.collect::<Vec<_>>();
|
||||
let item = serde_json::from_value(json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": content,
|
||||
"id": "turn-1:user"
|
||||
}))
|
||||
.expect("deserialize user item");
|
||||
let error = validate_direct_codex_user_item(root.path(), &item)
|
||||
.expect_err("too many skill references must be rejected");
|
||||
assert!(error.contains("最多引用"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_name_and_media_type_are_bounded_and_well_formed() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
|
||||
.expect("init project");
|
||||
let long_name = "a".repeat(crate::agent::MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS + 1);
|
||||
let cases = [
|
||||
(
|
||||
json!({
|
||||
"name": "bad\nname.txt",
|
||||
"mediaType": "text/plain"
|
||||
}),
|
||||
"文件名",
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"name": "ok.txt",
|
||||
"mediaType": "text/plain\nsecret"
|
||||
}),
|
||||
"媒体类型",
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"name": long_name,
|
||||
"mediaType": "text/plain"
|
||||
}),
|
||||
"文件名",
|
||||
),
|
||||
];
|
||||
for (metadata, expected) in cases {
|
||||
let mut value = metadata;
|
||||
value["type"] = json!("agc_attachment_reference");
|
||||
value["size"] = json!(1);
|
||||
value["localPath"] = json!("");
|
||||
value["status"] = json!("failed");
|
||||
let item = serde_json::from_value(json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [value],
|
||||
"id": "turn-1:user"
|
||||
}))
|
||||
.expect("deserialize user item");
|
||||
let error = validate_direct_codex_user_item(root.path(), &item)
|
||||
.expect_err("invalid attachment metadata must fail");
|
||||
assert!(error.contains(expected), "{error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem};
|
||||
use super::model::{
|
||||
DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
|
||||
DirectCodexUserMessageItem, DirectCodexUserRuntimeRegionPart,
|
||||
};
|
||||
use super::validation::validate_direct_codex_user_item;
|
||||
use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path};
|
||||
use crate::agent::{
|
||||
read_manifest_for_project, sanitize_attachment_local_path, sanitize_attachment_media_type,
|
||||
sanitize_attachment_name, GameCreationAppManifest,
|
||||
};
|
||||
use crate::ui_editor::persistence::{
|
||||
generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND,
|
||||
UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
@@ -55,54 +61,90 @@ fn direct_codex_user_item_to_response_content(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn resource_reference_summary(
|
||||
manifest: &GameCreationAppManifest,
|
||||
resource_id: &str,
|
||||
) -> Result<String, String> {
|
||||
let resource_id = resource_id.trim();
|
||||
let asset = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.id == resource_id)
|
||||
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
|
||||
let path = sanitize_attachment_local_path(&asset.local_path)
|
||||
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
|
||||
Ok(format!(
|
||||
"[素材引用 resourceId={resource_id};项目路径={path}]"
|
||||
))
|
||||
}
|
||||
|
||||
fn runtime_region_summary(reference: &DirectCodexUserRuntimeRegionPart) -> String {
|
||||
let resources = reference
|
||||
.resource_ids
|
||||
.iter()
|
||||
.map(|id| id.trim())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim());
|
||||
if let Some(run_id) = reference.run_id.as_deref() {
|
||||
summary.push_str(&format!("运行标识={} ", run_id.trim()));
|
||||
}
|
||||
if let Some(role) = reference.element_role.as_deref() {
|
||||
summary.push_str(&format!("角色={} ", role.trim()));
|
||||
}
|
||||
if let Some(text) = reference.text.as_deref() {
|
||||
summary.push_str(&format!("文本={} ", text.trim()));
|
||||
}
|
||||
if !resources.is_empty() {
|
||||
summary.push_str(&format!("关联素材={resources}"));
|
||||
}
|
||||
summary.push(']');
|
||||
summary
|
||||
}
|
||||
|
||||
/// 附件引用的安全摘要。
|
||||
///
|
||||
/// turn 输入与 history/prompt 投影共用这一份清洗:文件名取 basename 并去控制字符、
|
||||
/// media type 与项目路径同样过白名单,避免两条路径对同一个引用给出不同摘要。
|
||||
fn attachment_reference_summary(reference: &DirectCodexUserAttachmentReferencePart) -> String {
|
||||
let name = sanitize_attachment_name(&reference.name);
|
||||
let media_type = sanitize_attachment_media_type(&reference.media_type);
|
||||
let mut summary = format!(
|
||||
"[附件:名称={name};类型={media_type};大小={} 字节",
|
||||
reference.size
|
||||
);
|
||||
if let Some(local_path) = sanitize_attachment_local_path(&reference.local_path) {
|
||||
summary.push_str(&format!(";项目路径={local_path}"));
|
||||
}
|
||||
summary.push_str(&format!(";状态={}", reference.status.trim()));
|
||||
summary.push(']');
|
||||
summary
|
||||
}
|
||||
|
||||
/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。
|
||||
/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。
|
||||
pub(crate) fn direct_codex_user_item_to_wire_input(
|
||||
root: &Path,
|
||||
item: &DirectCodexUserItem,
|
||||
) -> Result<Value, String> {
|
||||
validate_direct_codex_user_item(root, item)?;
|
||||
let manifest = read_manifest_for_project(root)?;
|
||||
// validate 已经读过清单并返回它,不要再读一次(seed task 变更也会被重复触发)。
|
||||
let manifest = validate_direct_codex_user_item(root, item)?;
|
||||
let DirectCodexUserItem::Message(message) = item;
|
||||
let mut input = Vec::with_capacity(message.content.len());
|
||||
for part in &message.content {
|
||||
let text = match part {
|
||||
DirectCodexUserContentPart::InputText { text } => text.clone(),
|
||||
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
||||
let asset = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.id == resource_id.trim())
|
||||
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
|
||||
let path = sanitize_attachment_local_path(&asset.local_path)
|
||||
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
|
||||
format!(
|
||||
"[素材引用 resourceId={};项目路径={path}]",
|
||||
resource_id.trim()
|
||||
)
|
||||
resource_reference_summary(&manifest, resource_id)?
|
||||
}
|
||||
DirectCodexUserContentPart::AgcSkillReference { name } => {
|
||||
format!("${}", name.trim())
|
||||
}
|
||||
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
||||
let resources = reference
|
||||
.resource_ids
|
||||
.iter()
|
||||
.map(|id| id.trim())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim());
|
||||
if let Some(run_id) = reference.run_id.as_deref() {
|
||||
summary.push_str(&format!("运行标识={} ", run_id.trim()));
|
||||
}
|
||||
if let Some(role) = reference.element_role.as_deref() {
|
||||
summary.push_str(&format!("角色={} ", role.trim()));
|
||||
}
|
||||
if let Some(text) = reference.text.as_deref() {
|
||||
summary.push_str(&format!("文本={} ", text.trim()));
|
||||
}
|
||||
if !resources.is_empty() {
|
||||
summary.push_str(&format!("关联素材={resources}"));
|
||||
}
|
||||
summary.push(']');
|
||||
summary
|
||||
runtime_region_summary(reference)
|
||||
}
|
||||
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
||||
attachment_reference_summary(reference)
|
||||
}
|
||||
};
|
||||
input.push(serde_json::json!({ "type": "text", "text": text }));
|
||||
@@ -110,6 +152,55 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
|
||||
Ok(Value::Array(input))
|
||||
}
|
||||
|
||||
pub(crate) fn direct_codex_user_item_to_codex_turn_input(
|
||||
root: &Path,
|
||||
item: &DirectCodexUserItem,
|
||||
skill_roots: &[std::path::PathBuf],
|
||||
) -> Result<Value, String> {
|
||||
let manifest = validate_direct_codex_user_item(root, item)?;
|
||||
let DirectCodexUserItem::Message(message) = item;
|
||||
let mut input = Vec::with_capacity(message.content.len());
|
||||
for part in &message.content {
|
||||
match part {
|
||||
DirectCodexUserContentPart::InputText { text } => {
|
||||
input.push(serde_json::json!({ "type": "text", "text": text }));
|
||||
}
|
||||
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
||||
input.push(serde_json::json!({
|
||||
"type": "text",
|
||||
"text": resource_reference_summary(&manifest, resource_id)?,
|
||||
}));
|
||||
}
|
||||
DirectCodexUserContentPart::AgcSkillReference { name } => {
|
||||
let name = name.trim();
|
||||
let path = skill_roots
|
||||
.iter()
|
||||
.map(|root| root.join(name).join("SKILL.md"))
|
||||
.find(|path| path.is_file())
|
||||
.ok_or_else(|| "引用的 Skill 当前不可用,请重新选择".to_string())?;
|
||||
input.push(serde_json::json!({
|
||||
"type": "skill",
|
||||
"name": name,
|
||||
"path": path,
|
||||
}));
|
||||
}
|
||||
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
||||
input.push(serde_json::json!({
|
||||
"type": "text",
|
||||
"text": runtime_region_summary(reference),
|
||||
}));
|
||||
}
|
||||
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
||||
input.push(serde_json::json!({
|
||||
"type": "text",
|
||||
"text": attachment_reference_summary(reference),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Value::Array(input))
|
||||
}
|
||||
|
||||
pub(crate) fn direct_codex_user_item_to_prompt(
|
||||
root: &Path,
|
||||
item: &DirectCodexUserItem,
|
||||
@@ -118,17 +209,10 @@ pub(crate) fn direct_codex_user_item_to_prompt(
|
||||
let DirectCodexUserItem::Message(message) = item;
|
||||
let mut prompt = wire
|
||||
.as_array()
|
||||
.ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())
|
||||
.and_then(|parts| {
|
||||
parts
|
||||
.iter()
|
||||
.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>>()
|
||||
})?;
|
||||
.ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())?
|
||||
.iter()
|
||||
.filter_map(|part| part.get("text").and_then(Value::as_str))
|
||||
.collect::<String>();
|
||||
if let Some(code_context) = render_ui_design_code_context(root, message)? {
|
||||
prompt.push('\n');
|
||||
prompt.push_str(&code_context);
|
||||
@@ -199,8 +283,9 @@ fn render_ui_design_code_context(
|
||||
mod tests {
|
||||
use super::{
|
||||
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item,
|
||||
validate_direct_codex_user_item,
|
||||
direct_codex_user_item_to_wire_input, validate_direct_codex_user_item,
|
||||
};
|
||||
use crate::agent::direct_codex_user_item::model::DirectCodexUserItem;
|
||||
use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE;
|
||||
use serde_json::json;
|
||||
use shared_contracts::game_creation_app::{
|
||||
@@ -283,6 +368,83 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
#[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}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_response_item_passes_through_without_agc_private_parts() {
|
||||
let item = json!({
|
||||
@@ -297,6 +459,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_item_projection_uses_input_text_not_turn_input_text() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
||||
.expect("init project");
|
||||
let item = json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "turn-1:user",
|
||||
"content": [{"type": "input_text", "text": "你好"}]
|
||||
});
|
||||
let projected = direct_codex_user_item_to_response_item(root.path(), &item)
|
||||
.expect("user response item should project");
|
||||
assert_eq!(projected["content"][0]["type"], "input_text");
|
||||
assert_ne!(projected["content"][0]["type"], "text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_projection_preserves_empty_parts_line_breaks_and_trailing_whitespace() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
@@ -445,79 +624,97 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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}"
|
||||
);
|
||||
fn attachment_parts_remain_in_canonical_order_when_projected() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
||||
.expect("init project");
|
||||
let item = json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "turn-1:user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "先看"},
|
||||
{"type": "agc_attachment_reference", "name": "notes.txt", "mediaType": "text/plain", "size": 4, "localPath": "assets/notes.txt", "status": "imported"}
|
||||
]
|
||||
});
|
||||
let projected = direct_codex_user_item_to_response_item(root.path(), &item)
|
||||
.expect("user response item should project");
|
||||
let content = projected["content"].as_array().expect("content array");
|
||||
assert_eq!(content.len(), 2);
|
||||
assert!(content[0]["text"].as_str().unwrap().contains("先看"));
|
||||
assert!(content[1]["text"].as_str().unwrap().contains("notes.txt"));
|
||||
}
|
||||
|
||||
#[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 设计代码"
|
||||
);
|
||||
fn attachment_metadata_is_sanitized_before_prompt_projection() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
||||
.expect("init project");
|
||||
let item = json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "turn-1:user",
|
||||
"content": [{
|
||||
"type": "agc_attachment_reference",
|
||||
"name": "C:\\tmp\\notes.md",
|
||||
"mediaType": "text/plain",
|
||||
"size": 4,
|
||||
"localPath": "assets\\.\\notes.txt",
|
||||
"status": "imported"
|
||||
}]
|
||||
});
|
||||
let wire = super::direct_codex_user_item_to_wire_input(
|
||||
root.path(),
|
||||
&serde_json::from_value(item).expect("deserialize user item"),
|
||||
)
|
||||
.expect("attachment metadata should project");
|
||||
let text = wire[0]["text"].as_str().expect("wire text");
|
||||
assert!(text.contains("名称=notes.md"), "{text}");
|
||||
assert!(text.contains("类型=text/plain"), "{text}");
|
||||
assert!(text.contains("项目路径=assets/notes.txt"), "{text}");
|
||||
}
|
||||
|
||||
#[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}");
|
||||
fn whitespace_only_text_parts_survive_validation() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
||||
.expect("init project");
|
||||
let item: DirectCodexUserItem = serde_json::from_value(json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "turn-1:user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "先看"},
|
||||
{"type": "input_text", "text": "\n"},
|
||||
{"type": "input_text", "text": " "}
|
||||
]
|
||||
}))
|
||||
.expect("deserialize user item");
|
||||
let wire = direct_codex_user_item_to_wire_input(root.path(), &item)
|
||||
.expect("whitespace-only part next to real text must pass");
|
||||
let parts = wire.as_array().expect("wire input array");
|
||||
assert_eq!(parts.len(), 3);
|
||||
assert_eq!(parts[1]["text"].as_str(), Some("\n"));
|
||||
assert_eq!(parts[2]["text"].as_str(), Some(" "));
|
||||
}
|
||||
|
||||
#[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}");
|
||||
fn all_blank_content_is_rejected() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
||||
.expect("init project");
|
||||
let item: DirectCodexUserItem = serde_json::from_value(json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "turn-1:user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "\n"},
|
||||
{"type": "input_text", "text": " "}
|
||||
]
|
||||
}))
|
||||
.expect("deserialize user item");
|
||||
let error = direct_codex_user_item_to_wire_input(root.path(), &item)
|
||||
.expect_err("all-blank content must fail closed");
|
||||
assert!(error.contains("不能为空"), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,9 @@ pub(crate) fn normalize_direct_client_turn_id(
|
||||
#[tauri::command]
|
||||
pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
project_path: String,
|
||||
prompt: String,
|
||||
mut user_item: DirectCodexUserItem,
|
||||
user_item: DirectCodexUserItem,
|
||||
creation_type: Option<String>,
|
||||
client_turn_id: Option<String>,
|
||||
attachments: Option<Vec<DirectCodexTurnAttachment>>,
|
||||
) -> Result<String, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
|
||||
@@ -43,74 +41,33 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
|
||||
})?;
|
||||
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
|
||||
let mut audit = DirectCodexTurnAudit::start(
|
||||
root,
|
||||
&turn_id,
|
||||
&prompt,
|
||||
attachments.as_deref().unwrap_or_default(),
|
||||
);
|
||||
let attachments = attachments.unwrap_or_default();
|
||||
if !attachments.is_empty() {
|
||||
let attachment_context = match render_direct_codex_user_prompt("", &attachments) {
|
||||
Ok(context) => context,
|
||||
Err(error) => {
|
||||
audit.finish(false);
|
||||
audit.flush().await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let DirectCodexUserItem::Message(message) = &mut user_item;
|
||||
message.content.push(DirectCodexUserContentPart::InputText {
|
||||
text: attachment_context,
|
||||
});
|
||||
}
|
||||
if let Err(error) = validate_direct_codex_user_item(root, &user_item) {
|
||||
audit.finish(false);
|
||||
audit.flush().await;
|
||||
return Err(error);
|
||||
}
|
||||
let user_prompt = match direct_codex_user_item_to_prompt(root, &user_item) {
|
||||
Ok(prompt) => prompt,
|
||||
Err(error) => {
|
||||
audit.finish(false);
|
||||
audit.flush().await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
validate_direct_codex_user_item(root, &user_item)?;
|
||||
let user_prompt = direct_codex_user_item_to_prompt(root, &user_item)?;
|
||||
if user_prompt.trim().is_empty() {
|
||||
audit.finish(false);
|
||||
audit.flush().await;
|
||||
return Err("聊天内容不能为空".to_string());
|
||||
}
|
||||
let canonical_user_item =
|
||||
// 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。
|
||||
match crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref()).await {
|
||||
match crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(_) => Some(serde_json::to_value(user_item).map_err(|error| error.to_string())?),
|
||||
Err(error) => {
|
||||
audit.finish(false);
|
||||
audit.flush().await;
|
||||
return Err(redact_agent_runtime_error(root, &error, 1800));
|
||||
}
|
||||
Err(error) => return Err(redact_agent_runtime_error(root, &error, 1800)),
|
||||
};
|
||||
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
root,
|
||||
&user_prompt,
|
||||
creation_type.as_deref(),
|
||||
Some(&turn_emitter),
|
||||
Some(&mut audit),
|
||||
// DirectProject 的完整回合权威已经落在 project.jsonl;不再创建平行审计日志。
|
||||
None,
|
||||
canonical_user_item,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(reply) => reply,
|
||||
Err(error) => {
|
||||
audit.finish(false);
|
||||
audit.flush().await;
|
||||
return Err(error);
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
audit.finish(true);
|
||||
audit.flush().await;
|
||||
turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None);
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user