合并 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

- 保留 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:
2026-09-22 12:50:19 +08:00
300 changed files with 10652 additions and 57627 deletions
+8
View File
@@ -174,6 +174,14 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
## 项目开发对话(DirectProject
**DirectProject 专属聊天模块**:
AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。
_Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源
**项目工作台布局**:
承载本地项目的资源工作区、项目级工具和独立聊天产品路径的外层界面;布局拥有跨面板的账户/钱包入口,聊天模块只负责项目对话,不嵌套账户展示。
_Avoid_: 把钱包入口塞进聊天设置、让聊天组件拥有工作台级账户状态
**项目对话历史**:
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
@@ -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/CSSoriginalTheme=原创主题且未复刻现有游戏角色、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)]
@@ -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}");
}
}

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