Compare commits
186 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19e1883f71 | |||
| 4e41bd3e56 | |||
| 5315b233c8 | |||
| a11cdf13d7 | |||
| 085181c795 | |||
| 8968762274 | |||
| 7745264990 | |||
| 295d4af384 | |||
| 6981648796 | |||
| 2d61a4c057 | |||
| e2bdb1105b | |||
| 71f5f36764 | |||
| f71361c292 | |||
| 6e9e7edcf8 | |||
| 480fe995ed | |||
| afd5b8d91f | |||
| c60d116af0 | |||
| 3eb99f5124 | |||
| 34b1ad5d5c | |||
| 4698527025 | |||
| ec408bfafd | |||
| ab52805d3c | |||
| 94471ff191 | |||
| f3097e1096 | |||
| f1294d4d6b | |||
| ba3bd59f24 | |||
| 317c8557f4 | |||
| 7b1f7bdf17 | |||
| abb2988875 | |||
| 912f9afcd2 | |||
| 0224ef7b9d | |||
| 1c830e6fe6 | |||
| 860f3f09d4 | |||
| dc1cac1c15 | |||
| c5a7386465 | |||
| 926fe9ae0f | |||
| e7967cf67b | |||
| 5aeaf38112 | |||
| 6f5ca47385 | |||
| 3e3c3df41f | |||
| 051c21d97f | |||
| 21382926dc | |||
| 62f1ec83bf | |||
| 263588af6a | |||
| 50291a5e77 | |||
| 8aa737e032 | |||
| 9243bb42a1 | |||
| 67252d36b2 | |||
| 97fc1918c4 | |||
| 034c369b88 | |||
| 884aabe6d7 | |||
| f58249a75e | |||
| 38fb9aa7ac | |||
| b5e10f3851 | |||
| c4e2793db1 | |||
| 79cb510891 | |||
| 3160131889 | |||
| c46687f0ef | |||
| 137533484b | |||
| 00063ba882 | |||
| cb3d452a44 | |||
| 257e407df7 | |||
| 0d3ab25499 | |||
| 2a8063a83a | |||
| 37c7477068 | |||
| e259106267 | |||
| 7a89a119d9 | |||
| 57b28ce5cd | |||
| 876529e668 | |||
| 99be1d76fe | |||
| 38ad2ce256 | |||
| 65f8e44a88 | |||
| ade1b7803e | |||
| 71000b6df1 | |||
| c047543825 | |||
| 34b3af1d2a | |||
| f7cba30b6b | |||
| 273f12633c | |||
| 5f5aa17152 | |||
| 3a57d9fbdf | |||
| 382b925ab9 | |||
| c1482012c6 | |||
| c3a17a6efc | |||
| 1bfdc3a760 | |||
| 1e27cd229d | |||
| 774710452f | |||
| aaecb82622 | |||
| 64ad24ac77 | |||
| 75ec3361dc | |||
| bc1dc868a1 | |||
| 50204ff7aa | |||
| 70fa160819 | |||
| 2e78cbf152 | |||
| 840baf208f | |||
| d8af0fa882 | |||
| faed0ec251 | |||
| 6e63a118e0 | |||
| 4ae5ab14fa | |||
| b73a329b2c | |||
| 06004bfeea | |||
| ecea7b4ad3 | |||
| b1e82d64cb | |||
| b7622f4a24 | |||
| cb840c6e8c | |||
| 0a9cdf6395 | |||
| e382d23892 | |||
| 37ae7508b8 | |||
| 1ae6c6cd06 | |||
| 459f8a60e5 | |||
| 63c495dab0 | |||
| b37d91882c | |||
| dd339638cc | |||
| 0caf998227 | |||
| 6eed6bd13f | |||
| b543a060b7 | |||
| 9993b2feb5 | |||
| d348cf9064 | |||
| e640219243 | |||
| da2ad83c3b | |||
| ad19c9475e | |||
| 975a2e577c | |||
| 1b40f030e7 | |||
| 098693636a | |||
| e17af3008e | |||
| 64bd006981 | |||
| 6a4cb2bc18 | |||
| 1b3a3b003f | |||
| ff8e517c8e | |||
| d874a13a92 | |||
| 5eba53a5cc | |||
| 33ddb5de27 | |||
| bf8f96c115 | |||
| e469831543 | |||
| 869abad879 | |||
| 280a2dc5d1 | |||
| 3c6e084a41 | |||
| ed3369a494 | |||
| fdc48fe725 | |||
| 2748468d12 | |||
| 09ad0073fe | |||
| 42b702d362 | |||
| edfd1f9143 | |||
| 423f2d7184 | |||
| 656c89e4b2 | |||
| aa9076c95c | |||
| 0ec1179bf1 | |||
| 2938a49cac | |||
| ae0f9376c9 | |||
| 29d0cbb4df | |||
| 41366dd71d | |||
| 721e45f01b | |||
| f4b496caca | |||
| fc0fd846f9 | |||
| fa99d9ad62 | |||
| 64a52005f8 | |||
| 645d806fed | |||
| 0b9d16e8f1 | |||
| d5c4e0e780 | |||
| 30e27abc60 | |||
| b607501916 | |||
| 46e3cd9744 | |||
| 5fb3db662b | |||
| 87b798322e | |||
| ec286b3480 | |||
| 35733f0e33 | |||
| 3c7b02b9f8 | |||
| f2030a616f | |||
| b3b5d77990 | |||
| 1da106af7d | |||
| cd2edd6966 | |||
| 22e830ff93 | |||
| c266ae7b50 | |||
| a543b75cf7 | |||
| f213987f9a | |||
| 2c623bb577 | |||
| 3353906e6f | |||
| 17716347e2 | |||
| 9ad66a67a3 | |||
| ba3aa3ccdb | |||
| 2b38eaafba | |||
| 7d5b9071e7 | |||
| 9e83f1d88c | |||
| 7e35d7c344 | |||
| 5ec40c8b83 | |||
| 948a80fc49 | |||
| dfd6fadedf |
@@ -174,6 +174,14 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
|
|||||||
|
|
||||||
## 项目开发对话(DirectProject)
|
## 项目开发对话(DirectProject)
|
||||||
|
|
||||||
|
**DirectProject 专属聊天模块**:
|
||||||
|
AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。
|
||||||
|
_Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源
|
||||||
|
|
||||||
|
**项目工作台布局**:
|
||||||
|
承载本地项目的资源工作区、项目级工具和独立聊天产品路径的外层界面;布局拥有跨面板的账户/钱包入口,聊天模块只负责项目对话,不嵌套账户展示。
|
||||||
|
_Avoid_: 把钱包入口塞进聊天设置、让聊天组件拥有工作台级账户状态
|
||||||
|
|
||||||
**项目对话历史**:
|
**项目对话历史**:
|
||||||
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
|
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
|
||||||
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
|
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
|
||||||
|
|||||||
@@ -81,14 +81,12 @@ test('模板批量导入走 multipart,不预设 JSON Content-Type', async () =
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const fetchMock = vi
|
const fetchMock = vi.fn().mockImplementation(
|
||||||
.fn()
|
async () =>
|
||||||
.mockImplementation(
|
new Response(JSON.stringify({ ok: true, data: imported }), {
|
||||||
async () =>
|
status: 200,
|
||||||
new Response(JSON.stringify({ ok: true, data: imported }), {
|
}),
|
||||||
status: 200,
|
);
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
|
|||||||
@@ -185,7 +185,12 @@ test('nextVersion 只在 patch 位递增', () => {
|
|||||||
|
|
||||||
test('ossutil 参数默认使用 v1 签名,并可按需带 region 与 v4', () => {
|
test('ossutil 参数默认使用 v1 签名,并可按需带 region 与 v4', () => {
|
||||||
const base = {
|
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',
|
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
|
||||||
accessKeyId: 'id',
|
accessKeyId: 'id',
|
||||||
accessKeySecret: 'secret',
|
accessKeySecret: 'secret',
|
||||||
|
|||||||
@@ -107,10 +107,6 @@ const appInvokeSources = readSourceFiles(
|
|||||||
new URL('../src/', import.meta.url),
|
new URL('../src/', import.meta.url),
|
||||||
new Set(['.ts', '.tsx']),
|
new Set(['.ts', '.tsx']),
|
||||||
);
|
);
|
||||||
const appEntrypointSource = fs.readFileSync(
|
|
||||||
new URL('../src/main.tsx', import.meta.url),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
const tauriHandlerSource = fs.readFileSync(
|
const tauriHandlerSource = fs.readFileSync(
|
||||||
new URL('../src-tauri/src/main.rs', import.meta.url),
|
new URL('../src-tauri/src/main.rs', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
@@ -135,6 +131,45 @@ const rustSharedContractSource = fs.readFileSync(
|
|||||||
);
|
);
|
||||||
const allowedUncalledTauriCommands = [
|
const allowedUncalledTauriCommands = [
|
||||||
'append_direct_project_conversation_message',
|
'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.
|
// TODO: Remove the retired binding command after the legacy runtime path is removed.
|
||||||
'bind_components',
|
'bind_components',
|
||||||
'chat_with_game_creator_agent',
|
'chat_with_game_creator_agent',
|
||||||
@@ -164,6 +199,26 @@ const allowedUncalledTauriCommands = [
|
|||||||
'call_agc_plugin',
|
'call_agc_plugin',
|
||||||
'read_agc_plugin_panel',
|
'read_agc_plugin_panel',
|
||||||
'set_agc_plugin_enabled',
|
'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([
|
const sourceExtensions = new Set([
|
||||||
'.json',
|
'.json',
|
||||||
@@ -1470,13 +1525,7 @@ const eventCapability = JSON.parse(
|
|||||||
);
|
);
|
||||||
const eventCapabilityWindows = new Set(eventCapability.windows ?? []);
|
const eventCapabilityWindows = new Set(eventCapability.windows ?? []);
|
||||||
const eventCapabilityPermissions = new Set(eventCapability.permissions ?? []);
|
const eventCapabilityPermissions = new Set(eventCapability.permissions ?? []);
|
||||||
for (const windowLabel of [
|
for (const windowLabel of ['client', 'main', 'launcher']) {
|
||||||
'client',
|
|
||||||
'developer',
|
|
||||||
'main',
|
|
||||||
'launcher',
|
|
||||||
'supervisor-chat',
|
|
||||||
]) {
|
|
||||||
if (!eventCapabilityWindows.has(windowLabel)) {
|
if (!eventCapabilityWindows.has(windowLabel)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`AI game creator shell event capability missing window: ${windowLabel}`,
|
`AI game creator shell event capability missing window: ${windowLabel}`,
|
||||||
@@ -1859,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())?')) {
|
if (tauriHandlerSource.includes('open_developer_window(app.handle())?')) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'AI game creator normal startup must not automatically open the developer window',
|
'AI game creator normal startup must not automatically open the developer window',
|
||||||
@@ -1905,31 +1940,17 @@ for (const snippet of [
|
|||||||
'官方账号服务(固定)',
|
'官方账号服务(固定)',
|
||||||
'runtime_config.save',
|
'runtime_config.save',
|
||||||
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
|
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
|
||||||
"'activate_local_game_preview'",
|
|
||||||
'已切换到客户端运行视图',
|
|
||||||
'async function executeRunLocal',
|
'async function executeRunLocal',
|
||||||
'function needsInitializedChatProject',
|
'function needsInitializedChatProject',
|
||||||
'function resolvePendingCommandProjectPath',
|
|
||||||
'resolveChatProjectPath(localProject) ?? draftProjectPath',
|
|
||||||
'`permission.cancel ${command.id} missing-project`',
|
|
||||||
"'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'",
|
"'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'",
|
||||||
"'/memory-set [short|long|blackboard] 内容:覆盖保存对应记忆'",
|
"'/memory-set [short|long|blackboard] 内容:覆盖保存对应记忆'",
|
||||||
'function parseRememberInput',
|
'function parseRememberInput',
|
||||||
"'/trace 或 /loop:查看最近一次 Agent loop trace'",
|
"'/trace 或 /loop:查看最近一次 Agent loop trace'",
|
||||||
'async function executeAgentTraceChat',
|
|
||||||
"relativePath: '.agent/logs/command.log'",
|
|
||||||
"'permission.pending'",
|
"'permission.pending'",
|
||||||
"'permission.confirm'",
|
"'permission.confirm'",
|
||||||
"'permission.cancel'",
|
"'permission.cancel'",
|
||||||
"'command.auto'",
|
"'command.auto'",
|
||||||
"'agent.run_status'",
|
|
||||||
'function summarizeAgentRunTrace',
|
'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)) {
|
if (!appSource.includes(snippet)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -1192,7 +1192,7 @@ async function main() {
|
|||||||
function isDirectModuleExecution() {
|
function isDirectModuleExecution() {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
process.argv[1] &&
|
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",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "events",
|
"identifier": "events",
|
||||||
"description": "允许客户端窗口订阅并取消订阅 Rust Runtime 事件。",
|
"description": "允许客户端窗口订阅并取消订阅 Rust Runtime 事件。",
|
||||||
"windows": ["client", "developer", "main", "launcher", "supervisor-chat"],
|
"windows": ["client", "main", "launcher"],
|
||||||
"permissions": ["core:event:allow-listen", "core:event:allow-unlisten"]
|
"permissions": ["core:event:allow-listen", "core:event:allow-unlisten"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "window-chrome",
|
"identifier": "window-chrome",
|
||||||
"description": "自绘标题栏允许执行当前窗口的基础控制和拖拽。",
|
"description": "自绘标题栏允许执行当前窗口的基础控制和拖拽。",
|
||||||
"windows": ["client", "developer", "main", "launcher", "supervisor-chat"],
|
"windows": ["client", "main", "launcher"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:window:allow-is-maximized",
|
"core:window:allow-is-maximized",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"taonier_prepare_game_art.parameters.brief": "面向当前游戏的简洁视觉需求",
|
"taonier_prepare_game_art.parameters.brief": "面向当前游戏的简洁视觉需求",
|
||||||
"taonier_prepare_game_art.parameters.mode": "缺省安全复用有效美术包;Codex 仅在当前对话需要换一套或重新生成时使用 regenerate",
|
"taonier_prepare_game_art.parameters.mode": "缺省安全复用有效美术包;Codex 仅在当前对话需要换一套或重新生成时使用 regenerate",
|
||||||
"agc_generate_image.description": "生成一张新图片:普通插画、角色立绘、统一视觉规范图、游戏 UI 设计图或透明游戏素材图集。仅在用户明确要求生成新图时调用。",
|
"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.kind": "image=普通新图(保留生成原图),character=角色图(纯色底生成后自动抠图,产出透明背景立绘,prompt 只描述角色主体),icon-spec=统一视觉规范图,ui-design=完整 UI 设计图,icon-spritesheet=透明游戏素材图集(纯色底生成后自动抠图并切片,项目须已有 icon-spec 规范图),publication-material=发布宣传图",
|
||||||
"agc_generate_image.parameters.assetName": "本地素材的人类可读显示名称",
|
"agc_generate_image.parameters.assetName": "本地素材的人类可读显示名称",
|
||||||
"agc_generate_image.parameters.outputPath": "可选项目相对输出路径,必须位于 assets/ 且不能覆盖已有文件",
|
"agc_generate_image.parameters.outputPath": "可选项目相对输出路径,必须位于 assets/ 且不能覆盖已有文件",
|
||||||
|
|||||||
@@ -23,8 +23,6 @@
|
|||||||
"icon_spec_generation": "为这个 Web 小游戏生成一张 1:1 的统一视觉规范图,作为后续 UI 设计图和透明游戏图集的共同权威参考。规范板必须分区展示:玩家主体及其成长形态、核心目标或收集物、场景地块与障碍、HUD/操作图标、得分/受击/胜负反馈、主辅强调色与材质规则。所有元素使用一致的正交视角、轮廓、光照和原创视觉语言,留出清楚间距;不要生成完整游戏截图、海报、黑底图集或纯文字说明。玩法机制只用于理解功能,不授权复刻现有作品。\n\n项目视觉需求:{}",
|
"icon_spec_generation": "为这个 Web 小游戏生成一张 1:1 的统一视觉规范图,作为后续 UI 设计图和透明游戏图集的共同权威参考。规范板必须分区展示:玩家主体及其成长形态、核心目标或收集物、场景地块与障碍、HUD/操作图标、得分/受击/胜负反馈、主辅强调色与材质规则。所有元素使用一致的正交视角、轮廓、光照和原创视觉语言,留出清楚间距;不要生成完整游戏截图、海报、黑底图集或纯文字说明。玩法机制只用于理解功能,不授权复刻现有作品。\n\n项目视觉需求:{}",
|
||||||
"ui_design_generation": "根据下方当前项目 UI 需求生成一张完整的游戏 UI/UX 原型图,玩法与界面结构以这些需求为准。画面是完整 16:9 桌面端单屏界面,并同时明确移动端重排意图;清楚呈现当前玩法所需的分数/资源/生命/局内状态 HUD、主要可玩区域、玩家与目标/收集物/危险物、开始和主要操作、失败状态与重新开始、键盘和触控提示。使用正视角、清晰分区和可读占位文字,使前端开发可直接据此拆分 HTML/CSS。采用项目已经确定的原创命名、角色轮廓、配色、场景材质和界面视觉语言。\n\n当前项目 UI 需求:{}",
|
"ui_design_generation": "根据下方当前项目 UI 需求生成一张完整的游戏 UI/UX 原型图,玩法与界面结构以这些需求为准。画面是完整 16:9 桌面端单屏界面,并同时明确移动端重排意图;清楚呈现当前玩法所需的分数/资源/生命/局内状态 HUD、主要可玩区域、玩家与目标/收集物/危险物、开始和主要操作、失败状态与重新开始、键盘和触控提示。使用正视角、清晰分区和可读占位文字,使前端开发可直接据此拆分 HTML/CSS。采用项目已经确定的原创命名、角色轮廓、配色、场景材质和界面视觉语言。\n\n当前项目 UI 需求:{}",
|
||||||
"scene_generation": "为 Web 小游戏生成一张可直接作为运行画面底图的原创 16:9 场景背景。严格从下方用户需求提炼自己的游戏主题、地点、季节、材质和氛围;画面要为真实可玩区域留出足够清楚的中部空间,并有前景、中景、远景层次。不得画玩家角色、道具、棋子、障碍、HUD、操作按钮、文字、Logo、完整游戏截图、海报或素材图集;这些元素会从独立透明核心图集中绘制。不得自行假设为塔防或加入玩法合同中不存在的实体;必须原创,不得复刻现有游戏场景、贴图、标志性布局或受保护视觉语言。\n\n用户需求:{}",
|
"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 为空才通过。",
|
"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": "请检查布局、遮挡、裁切、视觉层级、素材一致性,以及桌面与移动视口是否可用。",
|
"default_inspection_focus": "请检查布局、遮挡、裁切、视觉层级、素材一致性,以及桌面与移动视口是否可用。",
|
||||||
"custom_inspection_focus": "检查重点:{question}",
|
"custom_inspection_focus": "检查重点:{question}",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"preview.start.description": "启动当前项目的 loopback HTTP 预览。",
|
"preview.start.description": "启动当前项目的 loopback HTTP 预览。",
|
||||||
"preview.validate.description": "用真实浏览器验证桌面和移动预览并保存证据。",
|
"preview.validate.description": "用真实浏览器验证桌面和移动预览并保存证据。",
|
||||||
"image.inspect.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 注入,模型不得传入宿主路径。",
|
"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 返回。",
|
"cocos.editor.execute.description": "在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;Runtime 自动绑定唯一匹配的 Creator 主进程,代码与结果都通过注入 payload 的本机 bridge 返回。",
|
||||||
"unity.editor.execute.description": "在当前 Unity 项目已打开的 Windows x64 Mono Editor 中执行 C#。仅提交 code,宿主绑定项目身份;结果待核对时禁止自动重发。",
|
"unity.editor.execute.description": "在当前 Unity 项目已打开的 Windows x64 Mono Editor 中执行 C#。仅提交 code,宿主绑定项目身份;结果待核对时禁止自动重发。",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ mod codex_app_server;
|
|||||||
mod codex_cli;
|
mod codex_cli;
|
||||||
mod codex_provider_proxy;
|
mod codex_provider_proxy;
|
||||||
mod design_runtime;
|
mod design_runtime;
|
||||||
mod design_tools;
|
pub(crate) mod design_tools;
|
||||||
mod direct_codex_attachments;
|
mod direct_codex_attachments;
|
||||||
mod direct_codex_audit;
|
mod direct_codex_audit;
|
||||||
mod direct_codex_user_item;
|
mod direct_codex_user_item;
|
||||||
|
|||||||
@@ -1180,7 +1180,6 @@ fn direct_codex_thread_delta_event(
|
|||||||
) -> DirectThreadEvent {
|
) -> DirectThreadEvent {
|
||||||
DirectThreadEvent::item_delta(item_id, kind, direct_thread_delta_text(root, delta))
|
DirectThreadEvent::item_delta(item_id, kind, direct_thread_delta_text(root, delta))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。
|
/// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。
|
||||||
///
|
///
|
||||||
/// 读取器只负责"必须有 turnId 才处理"的前置条件与节流(活动 / 正文),分类不在这里之外
|
/// 读取器只负责"必须有 turnId 才处理"的前置条件与节流(活动 / 正文),分类不在这里之外
|
||||||
@@ -3358,8 +3357,22 @@ impl CodexAppServerConnection {
|
|||||||
codex_app_server_text_prompt(&request)
|
codex_app_server_text_prompt(&request)
|
||||||
.map_err(platform_llm::LlmError::InvalidRequest)?
|
.map_err(platform_llm::LlmError::InvalidRequest)?
|
||||||
};
|
};
|
||||||
let mut input =
|
let mut input = if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||||
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?;
|
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 thread_created && self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject
|
||||||
{
|
{
|
||||||
if let Some(client_turn_id) = direct_client_turn_id {
|
if let Some(client_turn_id) = direct_client_turn_id {
|
||||||
@@ -7684,7 +7697,7 @@ done
|
|||||||
&temp.path().join("host"),
|
&temp.path().join("host"),
|
||||||
&project,
|
&project,
|
||||||
"turn-0001",
|
"turn-0001",
|
||||||
"fixture-request",
|
&format!("{:x}", Sha256::digest("请创建菜单".as_bytes())),
|
||||||
false,
|
false,
|
||||||
&super::super::direct_validation::DirectValidationConfig::default(),
|
&super::super::direct_validation::DirectValidationConfig::default(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
const DESIGN_WORKSPACE_ROOT: &str = "design_artifacts";
|
const DESIGN_WORKSPACE_ROOT: &str = "design_artifacts";
|
||||||
|
const DESIGN_REFERENCES_ROOT: &str = "references";
|
||||||
const SEARCH_HIT_LIMIT: usize = 200;
|
const SEARCH_HIT_LIMIT: usize = 200;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[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}"))
|
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> {
|
fn load_design_catalog(root: &Path) -> Result<Vec<DesignCatalogItem>, String> {
|
||||||
let catalog_path = root.join("resources/catalog.json");
|
let catalog_path = root.join("resources/catalog.json");
|
||||||
let data: DesignCatalogFile = serde_json::from_str(
|
let data: DesignCatalogFile = serde_json::from_str(
|
||||||
@@ -756,6 +875,13 @@ mod tests {
|
|||||||
tempfile::tempdir().expect("tempdir")
|
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 {
|
fn pack_root() -> PathBuf {
|
||||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("design-agent")
|
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]
|
#[test]
|
||||||
fn phase_context_injects_current_skill_only() {
|
fn phase_context_injects_current_skill_only() {
|
||||||
let resources = DesignResources::new(pack_root()).expect("pack");
|
let resources = DesignResources::new(pack_root()).expect("pack");
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。
|
//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。
|
||||||
|
|
||||||
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8;
|
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8;
|
||||||
const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160;
|
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160;
|
||||||
const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
|
pub(crate) 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_LOCAL_PATH_CHARS: usize = 512;
|
||||||
|
|
||||||
const HOME_ATTACHMENT_HEADER: &str = prompt_text!("projectContext.attachments.homeHeader");
|
const HOME_ATTACHMENT_HEADER: &str = prompt_text!("projectContext.attachments.homeHeader");
|
||||||
const PROJECT_ATTACHMENT_HEADER: &str = prompt_text!("projectContext.attachments.projectHeader");
|
const PROJECT_ATTACHMENT_HEADER: &str = prompt_text!("projectContext.attachments.projectHeader");
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ mod validation;
|
|||||||
mod wire;
|
mod wire;
|
||||||
|
|
||||||
pub(crate) use model::{
|
pub(crate) use model::{
|
||||||
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem,
|
DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
|
||||||
DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
|
DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
|
||||||
};
|
};
|
||||||
pub(crate) use validation::validate_direct_codex_user_item;
|
pub(crate) use validation::validate_direct_codex_user_item;
|
||||||
pub(crate) use wire::{
|
pub(crate) use wire::{
|
||||||
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item,
|
direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt,
|
||||||
direct_codex_user_item_to_wire_input,
|
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 的唯一结构化入口。
|
/// DirectProject 本轮 user input 的唯一结构化入口。
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||||
#[serde(tag = "type", deny_unknown_fields)]
|
#[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 {
|
pub(crate) enum DirectCodexUserItem {
|
||||||
#[serde(rename = "message")]
|
#[serde(rename = "message")]
|
||||||
Message(DirectCodexUserMessageItem),
|
Message(DirectCodexUserMessageItem),
|
||||||
@@ -12,7 +12,7 @@ pub(crate) enum DirectCodexUserItem {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
#[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) struct DirectCodexUserMessageItem {
|
||||||
pub(crate) role: DirectCodexUserRole,
|
pub(crate) role: DirectCodexUserRole,
|
||||||
pub(crate) content: Vec<DirectCodexUserContentPart>,
|
pub(crate) content: Vec<DirectCodexUserContentPart>,
|
||||||
@@ -21,26 +21,43 @@ pub(crate) struct DirectCodexUserMessageItem {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[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 {
|
pub(crate) enum DirectCodexUserRole {
|
||||||
User,
|
User,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||||
#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)]
|
#[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 {
|
pub(crate) enum DirectCodexUserContentPart {
|
||||||
#[serde(rename = "input_text")]
|
#[serde(rename = "input_text")]
|
||||||
InputText { text: String },
|
InputText { text: String },
|
||||||
#[serde(rename = "agc_resource_reference")]
|
#[serde(rename = "agc_resource_reference")]
|
||||||
AgcResourceReference { resource_id: String },
|
AgcResourceReference { resource_id: String },
|
||||||
|
#[serde(rename = "agc_skill_reference")]
|
||||||
|
AgcSkillReference { name: String },
|
||||||
#[serde(rename = "agc_runtime_region_reference")]
|
#[serde(rename = "agc_runtime_region_reference")]
|
||||||
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
|
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
|
||||||
|
/// Uploaded project attachment kept inline in canonical content.
|
||||||
|
#[serde(rename = "agc_attachment_reference")]
|
||||||
|
AgcAttachmentReference(DirectCodexUserAttachmentReferencePart),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
#[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) struct DirectCodexUserRuntimeRegionPart {
|
||||||
pub(crate) label: String,
|
pub(crate) label: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
+249
-14
@@ -4,15 +4,20 @@ use super::model::{
|
|||||||
};
|
};
|
||||||
use crate::agent::{
|
use crate::agent::{
|
||||||
read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest,
|
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;
|
use std::path::Path;
|
||||||
|
|
||||||
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
|
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(
|
pub(crate) fn validate_direct_codex_user_item(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
item: &DirectCodexUserItem,
|
item: &DirectCodexUserItem,
|
||||||
) -> Result<(), String> {
|
) -> Result<GameCreationAppManifest, String> {
|
||||||
let DirectCodexUserItem::Message(message) = item;
|
let DirectCodexUserItem::Message(message) = item;
|
||||||
if !matches!(message.role, DirectCodexUserRole::User) {
|
if !matches!(message.role, DirectCodexUserRole::User) {
|
||||||
return Err("DirectProject 只接受 user message item".to_string());
|
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() {
|
if message.id.trim().is_empty() {
|
||||||
return Err("DirectProject user item 缺少稳定 id".to_string());
|
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());
|
return Err("聊天内容不能为空".to_string());
|
||||||
}
|
}
|
||||||
let manifest = read_manifest_for_project(root)?;
|
let manifest = read_manifest_for_project(root)?;
|
||||||
let mut reference_count = 0usize;
|
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 {
|
for part in &message.content {
|
||||||
match part {
|
match part {
|
||||||
DirectCodexUserContentPart::InputText { text } => {
|
DirectCodexUserContentPart::InputText { .. } => {}
|
||||||
if !text.trim().is_empty() {
|
|
||||||
has_effective_content = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
||||||
reference_count = reference_count.saturating_add(1);
|
reference_count = reference_count.saturating_add(1);
|
||||||
validate_resource_id_and_manifest(&manifest, resource_id)?;
|
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) => {
|
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
||||||
reference_count = reference_count.saturating_add(1);
|
reference_count = reference_count.saturating_add(1);
|
||||||
validate_runtime_region_reference(&manifest, reference)?;
|
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 {
|
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
|
||||||
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
|
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
|
||||||
}
|
}
|
||||||
if !has_effective_content {
|
Ok(manifest)
|
||||||
return Err("聊天内容不能为空".to_string());
|
}
|
||||||
}
|
|
||||||
Ok(())
|
/// 整条 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(
|
pub(crate) fn validate_resource_id_and_manifest(
|
||||||
@@ -92,3 +157,173 @@ fn validate_runtime_region_reference(
|
|||||||
}
|
}
|
||||||
Ok(())
|
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 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::{
|
use crate::ui_editor::persistence::{
|
||||||
generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND,
|
generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND,
|
||||||
UI_DESIGN_DOC_MEDIA_TYPE,
|
UI_DESIGN_DOC_MEDIA_TYPE,
|
||||||
@@ -55,54 +61,90 @@ fn direct_codex_user_item_to_response_content(
|
|||||||
.collect()
|
.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` 可接受的文本数组。
|
/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。
|
||||||
/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。
|
/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。
|
||||||
pub(crate) fn direct_codex_user_item_to_wire_input(
|
pub(crate) fn direct_codex_user_item_to_wire_input(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
item: &DirectCodexUserItem,
|
item: &DirectCodexUserItem,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
validate_direct_codex_user_item(root, item)?;
|
// validate 已经读过清单并返回它,不要再读一次(seed task 变更也会被重复触发)。
|
||||||
let manifest = read_manifest_for_project(root)?;
|
let manifest = validate_direct_codex_user_item(root, item)?;
|
||||||
let DirectCodexUserItem::Message(message) = item;
|
let DirectCodexUserItem::Message(message) = item;
|
||||||
let mut input = Vec::with_capacity(message.content.len());
|
let mut input = Vec::with_capacity(message.content.len());
|
||||||
for part in &message.content {
|
for part in &message.content {
|
||||||
let text = match part {
|
let text = match part {
|
||||||
DirectCodexUserContentPart::InputText { text } => text.clone(),
|
DirectCodexUserContentPart::InputText { text } => text.clone(),
|
||||||
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
||||||
let asset = manifest
|
resource_reference_summary(&manifest, resource_id)?
|
||||||
.assets
|
}
|
||||||
.iter()
|
DirectCodexUserContentPart::AgcSkillReference { name } => {
|
||||||
.find(|asset| asset.id == resource_id.trim())
|
format!("${}", name.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()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
||||||
let resources = reference
|
runtime_region_summary(reference)
|
||||||
.resource_ids
|
}
|
||||||
.iter()
|
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
||||||
.map(|id| id.trim())
|
attachment_reference_summary(reference)
|
||||||
.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
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
input.push(serde_json::json!({ "type": "text", "text": text }));
|
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))
|
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(
|
pub(crate) fn direct_codex_user_item_to_prompt(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
item: &DirectCodexUserItem,
|
item: &DirectCodexUserItem,
|
||||||
@@ -118,17 +209,10 @@ pub(crate) fn direct_codex_user_item_to_prompt(
|
|||||||
let DirectCodexUserItem::Message(message) = item;
|
let DirectCodexUserItem::Message(message) = item;
|
||||||
let mut prompt = wire
|
let mut prompt = wire
|
||||||
.as_array()
|
.as_array()
|
||||||
.ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())
|
.ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())?
|
||||||
.and_then(|parts| {
|
.iter()
|
||||||
parts
|
.filter_map(|part| part.get("text").and_then(Value::as_str))
|
||||||
.iter()
|
.collect::<String>();
|
||||||
.map(|part| {
|
|
||||||
part.get("text")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.ok_or_else(|| "DirectProject user item wire part 缺少 text".to_string())
|
|
||||||
})
|
|
||||||
.collect::<Result<String, String>>()
|
|
||||||
})?;
|
|
||||||
if let Some(code_context) = render_ui_design_code_context(root, message)? {
|
if let Some(code_context) = render_ui_design_code_context(root, message)? {
|
||||||
prompt.push('\n');
|
prompt.push('\n');
|
||||||
prompt.push_str(&code_context);
|
prompt.push_str(&code_context);
|
||||||
@@ -199,8 +283,9 @@ fn render_ui_design_code_context(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item,
|
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 crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use shared_contracts::game_creation_app::{
|
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]
|
#[test]
|
||||||
fn standard_response_item_passes_through_without_agc_private_parts() {
|
fn standard_response_item_passes_through_without_agc_private_parts() {
|
||||||
let item = json!({
|
let item = json!({
|
||||||
@@ -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]
|
#[test]
|
||||||
fn text_projection_preserves_empty_parts_line_breaks_and_trailing_whitespace() {
|
fn text_projection_preserves_empty_parts_line_breaks_and_trailing_whitespace() {
|
||||||
let root = tempfile::tempdir().expect("temp project");
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
@@ -445,79 +624,97 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ui_design_doc_reference_appends_generated_code_context() {
|
fn attachment_parts_remain_in_canonical_order_when_projected() {
|
||||||
let (project, asset_id) = ui_design_doc_fixture(true);
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
let item: super::DirectCodexUserItem =
|
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
||||||
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
.expect("init project");
|
||||||
.expect("canonical user item");
|
let item = json!({
|
||||||
let prompt =
|
"type": "message",
|
||||||
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
"role": "user",
|
||||||
assert!(
|
"id": "turn-1:user",
|
||||||
prompt.contains(&format!(
|
"content": [
|
||||||
"[素材引用 resourceId={asset_id};项目路径=ui/design.json]"
|
{"type": "input_text", "text": "先看"},
|
||||||
)),
|
{"type": "agc_attachment_reference", "name": "notes.txt", "mediaType": "text/plain", "size": 4, "localPath": "assets/notes.txt", "status": "imported"}
|
||||||
"{prompt}"
|
]
|
||||||
);
|
});
|
||||||
assert!(
|
let projected = direct_codex_user_item_to_response_item(root.path(), &item)
|
||||||
prompt.contains("请先阅读生成的带有文档的代码片段: ui/generated-"),
|
.expect("user response item should project");
|
||||||
"{prompt}"
|
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]
|
#[test]
|
||||||
fn history_projection_never_writes_generated_ui_design_code() {
|
fn attachment_metadata_is_sanitized_before_prompt_projection() {
|
||||||
let (project, asset_id) = ui_design_doc_fixture(true);
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
let item = user_item_with_resource_reference(&asset_id);
|
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
||||||
direct_codex_user_item_to_response_item(project.path(), &item).expect("history projection");
|
.expect("init project");
|
||||||
let generated_root =
|
let item = json!({
|
||||||
crate::resolve_local_project_path(project.path(), "ui").expect("resolve ui directory");
|
"type": "message",
|
||||||
let generated_files = std::fs::read_dir(generated_root)
|
"role": "user",
|
||||||
.expect("read ui directory")
|
"id": "turn-1:user",
|
||||||
.filter_map(|entry| entry.ok())
|
"content": [{
|
||||||
.filter(|entry| {
|
"type": "agc_attachment_reference",
|
||||||
entry
|
"name": "C:\\tmp\\notes.md",
|
||||||
.file_name()
|
"mediaType": "text/plain",
|
||||||
.to_string_lossy()
|
"size": 4,
|
||||||
.starts_with("generated-")
|
"localPath": "assets\\.\\notes.txt",
|
||||||
})
|
"status": "imported"
|
||||||
.count();
|
}]
|
||||||
assert_eq!(
|
});
|
||||||
generated_files, 0,
|
let wire = super::direct_codex_user_item_to_wire_input(
|
||||||
"历史回读只做纯投影,不得生成 UI 设计代码"
|
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]
|
#[test]
|
||||||
fn ui_design_generation_failure_keeps_reference_and_reports_error() {
|
fn whitespace_only_text_parts_survive_validation() {
|
||||||
let (project, asset_id) = ui_design_doc_fixture(false);
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
let item: super::DirectCodexUserItem =
|
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
||||||
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
.expect("init project");
|
||||||
.expect("canonical user item");
|
let item: DirectCodexUserItem = serde_json::from_value(json!({
|
||||||
let prompt =
|
"type": "message",
|
||||||
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
"role": "user",
|
||||||
assert!(prompt.contains("素材引用 resourceId="), "{prompt}");
|
"id": "turn-1:user",
|
||||||
assert!(prompt.contains("生成代码遇到错误"), "{prompt}");
|
"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]
|
#[test]
|
||||||
fn other_asset_kind_reference_does_not_generate_ui_design_code() {
|
fn all_blank_content_is_rejected() {
|
||||||
let project = prompt_context_project();
|
let root = tempfile::tempdir().expect("temp project");
|
||||||
let asset_id = register_fixture_asset(
|
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
||||||
project.path(),
|
.expect("init project");
|
||||||
"assets/hero.png",
|
let item: DirectCodexUserItem = serde_json::from_value(json!({
|
||||||
GameCreationAppAssetKind::Character,
|
"type": "message",
|
||||||
"image/png",
|
"role": "user",
|
||||||
);
|
"id": "turn-1:user",
|
||||||
let item: super::DirectCodexUserItem =
|
"content": [
|
||||||
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
{"type": "input_text", "text": "\n"},
|
||||||
.expect("canonical user item");
|
{"type": "input_text", "text": " "}
|
||||||
let prompt =
|
]
|
||||||
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
}))
|
||||||
assert!(prompt.contains("素材引用 resourceId="), "{prompt}");
|
.expect("deserialize user item");
|
||||||
assert!(
|
let error = direct_codex_user_item_to_wire_input(root.path(), &item)
|
||||||
!prompt.contains("请先阅读生成的带有文档的代码片段"),
|
.expect_err("all-blank content must fail closed");
|
||||||
"{prompt}"
|
assert!(error.contains("不能为空"), "{error}");
|
||||||
);
|
|
||||||
assert!(!prompt.contains("生成代码遇到错误"), "{prompt}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,11 +30,9 @@ pub(crate) fn normalize_direct_client_turn_id(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) async fn chat_with_game_creator_direct_codex(
|
pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||||
project_path: String,
|
project_path: String,
|
||||||
prompt: String,
|
user_item: DirectCodexUserItem,
|
||||||
mut user_item: DirectCodexUserItem,
|
|
||||||
creation_type: Option<String>,
|
creation_type: Option<String>,
|
||||||
client_turn_id: Option<String>,
|
client_turn_id: Option<String>,
|
||||||
attachments: Option<Vec<DirectCodexTurnAttachment>>,
|
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let root = Path::new(project_path.trim());
|
let root = Path::new(project_path.trim());
|
||||||
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
|
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)
|
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
|
||||||
})?;
|
})?;
|
||||||
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
|
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
|
||||||
let mut audit = DirectCodexTurnAudit::start(
|
validate_direct_codex_user_item(root, &user_item)?;
|
||||||
root,
|
let user_prompt = direct_codex_user_item_to_prompt(root, &user_item)?;
|
||||||
&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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if user_prompt.trim().is_empty() {
|
if user_prompt.trim().is_empty() {
|
||||||
audit.finish(false);
|
|
||||||
audit.flush().await;
|
|
||||||
return Err("聊天内容不能为空".to_string());
|
return Err("聊天内容不能为空".to_string());
|
||||||
}
|
}
|
||||||
let canonical_user_item =
|
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())?),
|
Ok(_) => Some(serde_json::to_value(user_item).map_err(|error| error.to_string())?),
|
||||||
Err(error) => {
|
Err(error) => return Err(redact_agent_runtime_error(root, &error, 1800)),
|
||||||
audit.finish(false);
|
|
||||||
audit.flush().await;
|
|
||||||
return Err(redact_agent_runtime_error(root, &error, 1800));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||||
root,
|
root,
|
||||||
&user_prompt,
|
&user_prompt,
|
||||||
creation_type.as_deref(),
|
creation_type.as_deref(),
|
||||||
Some(&turn_emitter),
|
Some(&turn_emitter),
|
||||||
Some(&mut audit),
|
// DirectProject 的完整回合权威已经落在 project.jsonl;不再创建平行审计日志。
|
||||||
|
None,
|
||||||
canonical_user_item,
|
canonical_user_item,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(reply) => reply,
|
Ok(reply) => reply,
|
||||||
Err(error) => {
|
Err(error) => return Err(error),
|
||||||
audit.finish(false);
|
|
||||||
audit.flush().await;
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
audit.finish(true);
|
|
||||||
audit.flush().await;
|
|
||||||
turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None);
|
turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None);
|
||||||
Ok(reply)
|
Ok(reply)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! DirectProject 聊天事件的线上模型与投影。
|
//! DirectProject 聊天事件的线上模型与投影。
|
||||||
//!
|
//!
|
||||||
//! 前端消费的类型由 ts-rs 导出到 `src/features/project-workspace/generated/`,
|
//! 前端消费的类型由 ts-rs 导出到 `src/view/project-development/chat/generated/`,
|
||||||
//! 与 Rust 定义同源:加一个字段不会只改一边。
|
//! 与 Rust 定义同源:加一个字段不会只改一边。
|
||||||
//!
|
//!
|
||||||
//! 本模块只做三件事:挑字段、脱敏、截断。工具卡片的 `kind`、标题、折叠摘要、可见性与
|
//! 本模块只做三件事:挑字段、脱敏、截断。工具卡片的 `kind`、标题、折叠摘要、可见性与
|
||||||
@@ -29,7 +29,7 @@ const DIRECT_THREAD_PATH_MAX_CHARS: usize = 300;
|
|||||||
/// 一条文件变更。
|
/// 一条文件变更。
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
#[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 DirectThreadFileChange {
|
pub(crate) struct DirectThreadFileChange {
|
||||||
pub(crate) path: String,
|
pub(crate) path: String,
|
||||||
/// `add` | `update` | `delete`
|
/// `add` | `update` | `delete`
|
||||||
@@ -45,7 +45,7 @@ pub(crate) struct DirectThreadFileChange {
|
|||||||
/// 而 Tauri 的 JSON 通道传过来的是 `number`,因此统一标 `#[ts(as = "f64")]` 对齐。
|
/// 而 Tauri 的 JSON 通道传过来的是 `number`,因此统一标 `#[ts(as = "f64")]` 对齐。
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||||
#[serde(tag = "itemType", rename_all_fields = "camelCase", deny_unknown_fields)]
|
#[serde(tag = "itemType", 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 DirectThreadItem {
|
pub(crate) enum DirectThreadItem {
|
||||||
#[serde(rename = "message")]
|
#[serde(rename = "message")]
|
||||||
Message {
|
Message {
|
||||||
@@ -179,7 +179,7 @@ impl DirectThreadItem {
|
|||||||
/// 增量正文属于哪类条目。
|
/// 增量正文属于哪类条目。
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[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 DirectThreadDeltaKind {
|
pub(crate) enum DirectThreadDeltaKind {
|
||||||
/// assistant 正文。
|
/// assistant 正文。
|
||||||
Message,
|
Message,
|
||||||
@@ -190,7 +190,7 @@ pub(crate) enum DirectThreadDeltaKind {
|
|||||||
/// 审批 / 提问请求与解决:本轮只透传,不并入聊天状态。
|
/// 审批 / 提问请求与解决:本轮只透传,不并入聊天状态。
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[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 DirectThreadRequestKind {
|
pub(crate) enum DirectThreadRequestKind {
|
||||||
#[serde(rename = "approval.requested")]
|
#[serde(rename = "approval.requested")]
|
||||||
ApprovalRequested,
|
ApprovalRequested,
|
||||||
@@ -235,7 +235,7 @@ impl DirectThreadRequestKind {
|
|||||||
/// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。
|
/// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||||
#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)]
|
#[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 DirectThreadEvent {
|
pub(crate) enum DirectThreadEvent {
|
||||||
#[serde(rename = "turn.started")]
|
#[serde(rename = "turn.started")]
|
||||||
TurnStarted {
|
TurnStarted {
|
||||||
@@ -398,7 +398,7 @@ impl DirectThreadEvent {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
#[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 DirectThreadSubscriptionBootstrap {
|
pub(crate) struct DirectThreadSubscriptionBootstrap {
|
||||||
pub(crate) subscription_id: String,
|
pub(crate) subscription_id: String,
|
||||||
/// 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。
|
/// 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。
|
||||||
@@ -410,14 +410,14 @@ pub(crate) struct DirectThreadSubscriptionBootstrap {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
#[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 DirectThreadConsumeResult {
|
pub(crate) struct DirectThreadConsumeResult {
|
||||||
pub(crate) events: Vec<DirectThreadEvent>,
|
pub(crate) events: Vec<DirectThreadEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
#[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 DirectThreadHistorySlice {
|
pub(crate) struct DirectThreadHistorySlice {
|
||||||
/// 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。
|
/// 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。
|
||||||
pub(crate) items: Vec<DirectThreadItem>,
|
pub(crate) items: Vec<DirectThreadItem>,
|
||||||
|
|||||||
@@ -3407,7 +3407,7 @@ async fn start_tool_bridge_for_source(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(in crate::agent) struct DirectExecutionTestFixture {
|
pub(crate) struct DirectExecutionTestFixture {
|
||||||
execution: Option<super::direct_execution::ExecutionSessionGuard>,
|
execution: Option<super::direct_execution::ExecutionSessionGuard>,
|
||||||
_host: tempfile::TempDir,
|
_host: tempfile::TempDir,
|
||||||
}
|
}
|
||||||
@@ -3426,7 +3426,7 @@ impl Drop for DirectExecutionTestFixture {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(in crate::agent) async fn direct_execution_fixture(
|
pub(crate) async fn direct_execution_fixture(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
turn: &str,
|
turn: &str,
|
||||||
) -> DirectExecutionTestFixture {
|
) -> DirectExecutionTestFixture {
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ pub(crate) use canvas_generation::{
|
|||||||
platform_art_asset_art_spec, platform_art_asset_output_extension_matches,
|
platform_art_asset_art_spec, platform_art_asset_output_extension_matches,
|
||||||
platform_art_runtime_references_match_request_contract, prepare_platform_art_asset_output_path,
|
platform_art_runtime_references_match_request_contract, prepare_platform_art_asset_output_path,
|
||||||
project_canvas_asset_media_types, role_has_canvas_assets, suggested_canvas_tool_call,
|
project_canvas_asset_media_types, role_has_canvas_assets, suggested_canvas_tool_call,
|
||||||
PlatformArtAssetGenerationOptions, PLATFORM_ART_ASSET_GENERATION_KINDS,
|
validate_platform_art_icon_prompt, PlatformArtAssetGenerationOptions,
|
||||||
|
PLATFORM_ART_ASSET_GENERATION_KINDS,
|
||||||
};
|
};
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub(crate) use draft_validation::{
|
pub(crate) use draft_validation::{
|
||||||
|
|||||||
@@ -687,7 +687,7 @@ pub(in crate::agent) fn recover_persisted_visual_generation_options(
|
|||||||
let snapshot = platform_art_generation_runtime_request_snapshot(&state)?;
|
let snapshot = platform_art_generation_runtime_request_snapshot(&state)?;
|
||||||
// 只恢复经过身份校验的已有请求;显式改参和新请求仍走当前合同。
|
// 只恢复经过身份校验的已有请求;显式改参和新请求仍走当前合同。
|
||||||
if snapshot.generation_kind == generation_kind
|
if snapshot.generation_kind == generation_kind
|
||||||
&& snapshot.generation_prompt == build_platform_art_asset_prompt(prompt, &[], &options)
|
&& snapshot.generation_prompt == build_platform_art_asset_prompt(prompt, &options)
|
||||||
&& options.asset_kind == kind
|
&& options.asset_kind == kind
|
||||||
&& options.aspect_ratio == ratio
|
&& options.aspect_ratio == ratio
|
||||||
&& options.image_size == size
|
&& options.image_size == size
|
||||||
@@ -2261,39 +2261,16 @@ async fn resolve_platform_art_generation_references_at(
|
|||||||
Ok(PlatformArtGenerationReferences { canonical, ordered })
|
Ok(PlatformArtGenerationReferences { canonical, ordered })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec<String> {
|
pub(crate) fn validate_platform_art_icon_prompt(prompt: &str) -> Result<(), String> {
|
||||||
// External Editor validates each description independently (currently at
|
// 与 External Editor 的单条 iconDescriptions 合同一致;超限拒绝,不截断或拆条。
|
||||||
// 200 Unicode characters). Keep the gameplay context short enough that a
|
let prompt = prompt.trim();
|
||||||
// long creation request cannot reject the atlas before it is queued.
|
if prompt.is_empty() {
|
||||||
const MAX_DESCRIPTION_CHARS: usize = 200;
|
return Err("图标素材描述不能为空".to_string());
|
||||||
const CONTEXT_PREFIX: &str = ";遵循同一项目视觉规范:";
|
|
||||||
let category =
|
|
||||||
"按当前项目需求生成一组可独立使用的透明素材;数量、类别、排列和切片方式由本次需求决定";
|
|
||||||
let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub(
|
|
||||||
category
|
|
||||||
.chars()
|
|
||||||
.count()
|
|
||||||
.saturating_add(CONTEXT_PREFIX.chars().count()),
|
|
||||||
);
|
|
||||||
let project_context = truncate_inline_bounded(prompt.trim(), context_budget);
|
|
||||||
vec![format!("{category}{CONTEXT_PREFIX}{project_context}")]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn truncate_inline_bounded(value: &str, max_chars: usize) -> String {
|
|
||||||
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
||||||
let actual_chars = normalized.chars().count();
|
|
||||||
if actual_chars <= max_chars {
|
|
||||||
return normalized;
|
|
||||||
}
|
}
|
||||||
if max_chars <= 3 {
|
if prompt.chars().count() > 200 {
|
||||||
return ".".repeat(max_chars);
|
return Err("图标素材描述不能超过 200 个字符".to_string());
|
||||||
}
|
}
|
||||||
let mut output = normalized
|
Ok(())
|
||||||
.chars()
|
|
||||||
.take(max_chars.saturating_sub(3))
|
|
||||||
.collect::<String>();
|
|
||||||
output.push_str("...");
|
|
||||||
output
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decode_platform_art_image_with_limits(
|
fn decode_platform_art_image_with_limits(
|
||||||
@@ -2565,10 +2542,10 @@ pub(in crate::agent) struct AdmittedPlatformArtGeneration {
|
|||||||
pub(in crate::agent) fn admit_platform_art_generation_at(
|
pub(in crate::agent) fn admit_platform_art_generation_at(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
prompt: &str,
|
prompt: &str,
|
||||||
briefs: &[AgentGroupBrief],
|
_briefs: &[AgentGroupBrief],
|
||||||
options: &PlatformArtAssetGenerationOptions,
|
options: &PlatformArtAssetGenerationOptions,
|
||||||
) -> Result<AdmittedPlatformArtGeneration, String> {
|
) -> Result<AdmittedPlatformArtGeneration, String> {
|
||||||
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
|
let generation_prompt = build_platform_art_asset_prompt(prompt, options);
|
||||||
let context =
|
let context =
|
||||||
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
|
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
|
||||||
let guard = super::external_generation_state::acquire_durable_platform_art_generation_guard(
|
let guard = super::external_generation_state::acquire_durable_platform_art_generation_guard(
|
||||||
@@ -2590,7 +2567,7 @@ pub(in crate::agent) async fn generate_admitted_platform_art_asset_at(
|
|||||||
options: &PlatformArtAssetGenerationOptions,
|
options: &PlatformArtAssetGenerationOptions,
|
||||||
admission: AdmittedPlatformArtGeneration,
|
admission: AdmittedPlatformArtGeneration,
|
||||||
) -> Result<GeneratedPlatformArtAsset, String> {
|
) -> Result<GeneratedPlatformArtAsset, String> {
|
||||||
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
|
let generation_prompt = build_platform_art_asset_prompt(prompt, options);
|
||||||
let context =
|
let context =
|
||||||
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
|
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
|
||||||
if admission.context != context
|
if admission.context != context
|
||||||
@@ -2618,7 +2595,7 @@ pub(crate) async fn generate_platform_art_asset_with_options_at(
|
|||||||
briefs: &[AgentGroupBrief],
|
briefs: &[AgentGroupBrief],
|
||||||
options: &PlatformArtAssetGenerationOptions,
|
options: &PlatformArtAssetGenerationOptions,
|
||||||
) -> Result<GeneratedPlatformArtAsset, String> {
|
) -> Result<GeneratedPlatformArtAsset, String> {
|
||||||
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
|
let generation_prompt = build_platform_art_asset_prompt(prompt, options);
|
||||||
let runtime_context =
|
let runtime_context =
|
||||||
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
|
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
|
||||||
generate_platform_art_asset_with_runtime_options_at(
|
generate_platform_art_asset_with_runtime_options_at(
|
||||||
@@ -2645,7 +2622,7 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at(
|
|||||||
if options.asset_kind != GameCreationAppAssetKind::IconSpritesheet {
|
if options.asset_kind != GameCreationAppAssetKind::IconSpritesheet {
|
||||||
return Err("严格游戏切片生成只允许 icon-spritesheet 资产类型".to_string());
|
return Err("严格游戏切片生成只允许 icon-spritesheet 资产类型".to_string());
|
||||||
}
|
}
|
||||||
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
|
let generation_prompt = build_platform_art_asset_prompt(prompt, options);
|
||||||
let runtime_context =
|
let runtime_context =
|
||||||
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
|
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
|
||||||
generate_platform_art_asset_with_runtime_options_at(
|
generate_platform_art_asset_with_runtime_options_at(
|
||||||
@@ -3106,6 +3083,9 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
|||||||
options: &PlatformArtAssetGenerationOptions,
|
options: &PlatformArtAssetGenerationOptions,
|
||||||
runtime_context: Option<&PlatformArtGenerationRuntimeContext>,
|
runtime_context: Option<&PlatformArtGenerationRuntimeContext>,
|
||||||
) -> Result<PreparedPlatformArtAssetGeneration, String> {
|
) -> Result<PreparedPlatformArtAssetGeneration, String> {
|
||||||
|
if options.asset_kind == GameCreationAppAssetKind::IconSpritesheet {
|
||||||
|
validate_platform_art_icon_prompt(prompt)?;
|
||||||
|
}
|
||||||
enforce_project_permission_policy(root, "canvas.asset_generate")?;
|
enforce_project_permission_policy(root, "canvas.asset_generate")?;
|
||||||
let persisted_runtime_state = runtime_context
|
let persisted_runtime_state = runtime_context
|
||||||
.map(|context| {
|
.map(|context| {
|
||||||
@@ -3150,7 +3130,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
|||||||
.timeout(EXTERNAL_GENERATION_SUBMIT_TIMEOUT)
|
.timeout(EXTERNAL_GENERATION_SUBMIT_TIMEOUT)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|error| format!("创建 External Editor 生成提交客户端失败:{error}"))?;
|
.map_err(|error| format!("创建 External Editor 生成提交客户端失败:{error}"))?;
|
||||||
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
|
let generation_prompt = build_platform_art_asset_prompt(prompt, options);
|
||||||
let (
|
let (
|
||||||
generated,
|
generated,
|
||||||
canvas_context,
|
canvas_context,
|
||||||
@@ -3348,7 +3328,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
|||||||
"/api/external/v1/editor/icon-spritesheets/generations",
|
"/api/external/v1/editor/icon-spritesheets/generations",
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"referenceId": reference_id,
|
"referenceId": reference_id,
|
||||||
"iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt),
|
"iconDescriptions": [generation_prompt],
|
||||||
"sliceCount": options.slice_count,
|
"sliceCount": options.slice_count,
|
||||||
"sliceMode": options.slice_mode,
|
"sliceMode": options.slice_mode,
|
||||||
"gridX": options.grid_x,
|
"gridX": options.grid_x,
|
||||||
@@ -8413,7 +8393,6 @@ pub(crate) fn platform_art_asset_art_spec(
|
|||||||
|
|
||||||
pub(crate) fn build_platform_art_asset_prompt(
|
pub(crate) fn build_platform_art_asset_prompt(
|
||||||
prompt: &str,
|
prompt: &str,
|
||||||
briefs: &[AgentGroupBrief],
|
|
||||||
options: &PlatformArtAssetGenerationOptions,
|
options: &PlatformArtAssetGenerationOptions,
|
||||||
) -> String {
|
) -> String {
|
||||||
if options.asset_kind == GameCreationAppAssetKind::Image {
|
if options.asset_kind == GameCreationAppAssetKind::Image {
|
||||||
@@ -8452,20 +8431,8 @@ pub(crate) fn build_platform_art_asset_prompt(
|
|||||||
truncate_prompt_context(prompt.trim())
|
truncate_prompt_context(prompt.trim())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let art_asset_brief = briefs
|
// 图集的工程化生图指令由 API Server 统一添加,客户端仅保留用户原文。
|
||||||
.iter()
|
prompt.trim().to_string()
|
||||||
.flat_map(|brief| brief.role_briefs.iter())
|
|
||||||
.find(|role_brief| {
|
|
||||||
role_brief.group_definition.id == "art" && role_brief.role_definition.id == "asset"
|
|
||||||
})
|
|
||||||
.map(|role_brief| role_brief.markdown.trim())
|
|
||||||
.filter(|markdown| !markdown.is_empty())
|
|
||||||
.unwrap_or(prompt_text!("media.default_art_brief"));
|
|
||||||
format!(
|
|
||||||
prompt_text!("media.spritesheet_generation"),
|
|
||||||
truncate_inline(prompt, 240),
|
|
||||||
truncate_prompt_context(art_asset_brief)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -8533,15 +8500,15 @@ mod canvas_generation_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn generation_kind_catalog_binds_art_spritesheet_to_a_spec_board_prompt() {
|
fn generation_kind_catalog_keeps_art_spritesheet_description_and_art_spec() {
|
||||||
// 放行 icon-spritesheet 必须真的走到图集提示词与图集请求合同,
|
// 图集描述由用户提供,工程化生图提示词由 API Server 添加。
|
||||||
// 否则新 IPC 只是加了一个能通过校验但不生成图集的 kind。
|
|
||||||
let options = PlatformArtAssetGenerationOptions {
|
let options = PlatformArtAssetGenerationOptions {
|
||||||
asset_kind: GameCreationAppAssetKind::IconSpritesheet,
|
asset_kind: GameCreationAppAssetKind::IconSpritesheet,
|
||||||
..PlatformArtAssetGenerationOptions::default()
|
..PlatformArtAssetGenerationOptions::default()
|
||||||
};
|
};
|
||||||
assert!(
|
assert_eq!(
|
||||||
build_platform_art_asset_prompt("原创收集玩法", &[], &options).contains("素材图集")
|
build_platform_art_asset_prompt("原创收集玩法", &options),
|
||||||
|
"原创收集玩法"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
platform_art_asset_art_spec(&options)["assetType"],
|
platform_art_asset_art_spec(&options)["assetType"],
|
||||||
@@ -8945,7 +8912,7 @@ mod canvas_generation_tests {
|
|||||||
asset_label: "失败后可重试素材".to_string(),
|
asset_label: "失败后可重试素材".to_string(),
|
||||||
..PlatformArtAssetGenerationOptions::default()
|
..PlatformArtAssetGenerationOptions::default()
|
||||||
};
|
};
|
||||||
let generated_prompt = build_platform_art_asset_prompt("相同手工请求", &[], &options);
|
let generated_prompt = build_platform_art_asset_prompt("相同手工请求", &options);
|
||||||
let runtime_context =
|
let runtime_context =
|
||||||
standalone_platform_art_generation_runtime_context(&generated_prompt, &options, false)
|
standalone_platform_art_generation_runtime_context(&generated_prompt, &options, false)
|
||||||
.expect("standalone retry context");
|
.expect("standalone retry context");
|
||||||
@@ -9121,8 +9088,8 @@ mod canvas_generation_tests {
|
|||||||
asset_label: "图标规范".to_string(),
|
asset_label: "图标规范".to_string(),
|
||||||
..PlatformArtAssetGenerationOptions::default()
|
..PlatformArtAssetGenerationOptions::default()
|
||||||
};
|
};
|
||||||
let first_prompt = build_platform_art_asset_prompt("第一个手工请求", &[], &options);
|
let first_prompt = build_platform_art_asset_prompt("第一个手工请求", &options);
|
||||||
let second_prompt = build_platform_art_asset_prompt("第二个手工请求", &[], &options);
|
let second_prompt = build_platform_art_asset_prompt("第二个手工请求", &options);
|
||||||
let first =
|
let first =
|
||||||
standalone_platform_art_generation_runtime_context(&first_prompt, &options, false)
|
standalone_platform_art_generation_runtime_context(&first_prompt, &options, false)
|
||||||
.expect("first standalone slot context");
|
.expect("first standalone slot context");
|
||||||
@@ -9556,7 +9523,7 @@ mod canvas_generation_tests {
|
|||||||
asset_label: "图标规范".to_string(),
|
asset_label: "图标规范".to_string(),
|
||||||
..PlatformArtAssetGenerationOptions::default()
|
..PlatformArtAssetGenerationOptions::default()
|
||||||
};
|
};
|
||||||
let prompt = build_platform_art_asset_prompt("旧槽兼容手工请求", &[], &options);
|
let prompt = build_platform_art_asset_prompt("旧槽兼容手工请求", &options);
|
||||||
let context = standalone_platform_art_generation_runtime_context(&prompt, &options, false)
|
let context = standalone_platform_art_generation_runtime_context(&prompt, &options, false)
|
||||||
.expect("current standalone context");
|
.expect("current standalone context");
|
||||||
let legacy_run_id = legacy_standalone_platform_art_generation_run_id(&options, false)
|
let legacy_run_id = legacy_standalone_platform_art_generation_run_id(&options, false)
|
||||||
@@ -9676,8 +9643,7 @@ mod canvas_generation_tests {
|
|||||||
asset_label: "另一个图标规范".to_string(),
|
asset_label: "另一个图标规范".to_string(),
|
||||||
..PlatformArtAssetGenerationOptions::default()
|
..PlatformArtAssetGenerationOptions::default()
|
||||||
};
|
};
|
||||||
let other_prompt =
|
let other_prompt = build_platform_art_asset_prompt("另一个旧槽手工请求", &other_options);
|
||||||
build_platform_art_asset_prompt("另一个旧槽手工请求", &[], &other_options);
|
|
||||||
let other_context = standalone_platform_art_generation_runtime_context(
|
let other_context = standalone_platform_art_generation_runtime_context(
|
||||||
&other_prompt,
|
&other_prompt,
|
||||||
&other_options,
|
&other_options,
|
||||||
@@ -10877,7 +10843,7 @@ mod canvas_generation_tests {
|
|||||||
let current_prompt = "恢复时使用同一个生成意图";
|
let current_prompt = "恢复时使用同一个生成意图";
|
||||||
let current_options = PlatformArtAssetGenerationOptions::default();
|
let current_options = PlatformArtAssetGenerationOptions::default();
|
||||||
let durable_generation_prompt =
|
let durable_generation_prompt =
|
||||||
build_platform_art_asset_prompt(current_prompt, &[], ¤t_options);
|
build_platform_art_asset_prompt(current_prompt, ¤t_options);
|
||||||
let request_body = serde_json::json!({
|
let request_body = serde_json::json!({
|
||||||
"prompt": "持久化且必须原样重发的生成正文",
|
"prompt": "持久化且必须原样重发的生成正文",
|
||||||
"kind": "spec",
|
"kind": "spec",
|
||||||
@@ -11143,7 +11109,7 @@ mod canvas_generation_tests {
|
|||||||
screen_color: None,
|
screen_color: None,
|
||||||
};
|
};
|
||||||
let prompt = "生成同一套整包美术";
|
let prompt = "生成同一套整包美术";
|
||||||
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
|
let generation_prompt = build_platform_art_asset_prompt(prompt, &options);
|
||||||
let request_body = serde_json::json!({
|
let request_body = serde_json::json!({
|
||||||
"prompt": generation_prompt,
|
"prompt": generation_prompt,
|
||||||
"kind": "spec",
|
"kind": "spec",
|
||||||
@@ -11530,8 +11496,7 @@ mod canvas_generation_tests {
|
|||||||
asset_label: "账号隔离测试".to_string(),
|
asset_label: "账号隔离测试".to_string(),
|
||||||
..PlatformArtAssetGenerationOptions::default()
|
..PlatformArtAssetGenerationOptions::default()
|
||||||
};
|
};
|
||||||
let generation_prompt =
|
let generation_prompt = build_platform_art_asset_prompt(current_prompt, ¤t_options);
|
||||||
build_platform_art_asset_prompt(current_prompt, &[], ¤t_options);
|
|
||||||
let request_body = serde_json::json!({
|
let request_body = serde_json::json!({
|
||||||
"prompt": generation_prompt,
|
"prompt": generation_prompt,
|
||||||
"kind": "spec",
|
"kind": "spec",
|
||||||
@@ -11739,7 +11704,7 @@ mod canvas_generation_tests {
|
|||||||
..PlatformArtAssetGenerationOptions::default()
|
..PlatformArtAssetGenerationOptions::default()
|
||||||
};
|
};
|
||||||
let durable_generation_prompt =
|
let durable_generation_prompt =
|
||||||
build_platform_art_asset_prompt(current_prompt, &[], ¤t_options);
|
build_platform_art_asset_prompt(current_prompt, ¤t_options);
|
||||||
let request_body = serde_json::json!({
|
let request_body = serde_json::json!({
|
||||||
"prompt": "持久化的原始生成正文",
|
"prompt": "持久化的原始生成正文",
|
||||||
"kind": "spec",
|
"kind": "spec",
|
||||||
@@ -11883,7 +11848,7 @@ mod canvas_generation_tests {
|
|||||||
};
|
};
|
||||||
let options = PlatformArtAssetGenerationOptions::default();
|
let options = PlatformArtAssetGenerationOptions::default();
|
||||||
let old_generation_prompt =
|
let old_generation_prompt =
|
||||||
build_platform_art_asset_prompt("原来的贪吃蛇美术方向", &[], &options);
|
build_platform_art_asset_prompt("原来的贪吃蛇美术方向", &options);
|
||||||
let binding_access =
|
let binding_access =
|
||||||
ExternalEditorBindingAccess::new(&base_url, "changed-intent-key", None)
|
ExternalEditorBindingAccess::new(&base_url, "changed-intent-key", None)
|
||||||
.expect("prepare changed-intent binding access");
|
.expect("prepare changed-intent binding access");
|
||||||
@@ -12050,7 +12015,7 @@ mod canvas_generation_tests {
|
|||||||
screen_color: None,
|
screen_color: None,
|
||||||
};
|
};
|
||||||
let prompt = "保持同一个生成提示词";
|
let prompt = "保持同一个生成提示词";
|
||||||
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
|
let generation_prompt = build_platform_art_asset_prompt(prompt, &options);
|
||||||
let binding_access =
|
let binding_access =
|
||||||
ExternalEditorBindingAccess::new(&base_url, "changed-reference-key", None)
|
ExternalEditorBindingAccess::new(&base_url, "changed-reference-key", None)
|
||||||
.expect("prepare changed-reference binding access");
|
.expect("prepare changed-reference binding access");
|
||||||
@@ -12313,8 +12278,7 @@ mod canvas_generation_tests {
|
|||||||
.expect("create accepted failure ledger access");
|
.expect("create accepted failure ledger access");
|
||||||
let current_prompt = "失败恢复提示词";
|
let current_prompt = "失败恢复提示词";
|
||||||
let current_options = PlatformArtAssetGenerationOptions::default();
|
let current_options = PlatformArtAssetGenerationOptions::default();
|
||||||
let generation_prompt =
|
let generation_prompt = build_platform_art_asset_prompt(current_prompt, ¤t_options);
|
||||||
build_platform_art_asset_prompt(current_prompt, &[], ¤t_options);
|
|
||||||
let (state, _) = prepare_platform_art_generation_runtime_state(
|
let (state, _) = prepare_platform_art_generation_runtime_state(
|
||||||
root,
|
root,
|
||||||
&runtime_context,
|
&runtime_context,
|
||||||
@@ -12517,7 +12481,7 @@ mod canvas_generation_tests {
|
|||||||
screen_color: None,
|
screen_color: None,
|
||||||
};
|
};
|
||||||
let prompt = "恢复已受理视觉规范图";
|
let prompt = "恢复已受理视觉规范图";
|
||||||
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
|
let generation_prompt = build_platform_art_asset_prompt(prompt, &options);
|
||||||
let (_, _, frozen_platform_session) =
|
let (_, _, frozen_platform_session) =
|
||||||
resolve_canvas_sync_api_credentials(None, None).expect("freeze recovery account");
|
resolve_canvas_sync_api_credentials(None, None).expect("freeze recovery account");
|
||||||
let frozen_platform_session =
|
let frozen_platform_session =
|
||||||
@@ -12831,21 +12795,32 @@ mod canvas_generation_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn canonical_art_spritesheet_request_preserves_project_requirements() {
|
fn canonical_art_spritesheet_request_preserves_description_and_internal_newlines() {
|
||||||
let descriptions = canonical_art_spritesheet_icon_descriptions("原创收集玩法");
|
let options = PlatformArtAssetGenerationOptions {
|
||||||
assert_eq!(descriptions.len(), 1);
|
asset_kind: GameCreationAppAssetKind::IconSpritesheet,
|
||||||
assert!(descriptions[0].contains("原创收集玩法"));
|
..PlatformArtAssetGenerationOptions::default()
|
||||||
assert!(descriptions[0].contains("数量、类别、排列和切片方式由本次需求决定"));
|
};
|
||||||
|
let prompt = "\u{0085} 金币\n木制宝箱\n银色钥匙 \u{0085}";
|
||||||
|
validate_platform_art_icon_prompt(prompt).expect("valid multiline description");
|
||||||
|
assert_eq!(
|
||||||
|
build_platform_art_asset_prompt(prompt, &options),
|
||||||
|
"金币\n木制宝箱\n银色钥匙"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn canonical_art_spritesheet_descriptions_obey_external_editor_item_limit() {
|
fn canonical_art_spritesheet_description_obeys_external_editor_unicode_item_limit() {
|
||||||
let descriptions = canonical_art_spritesheet_icon_descriptions(&"原创玩法需求".repeat(128));
|
let boundary = "🪙".repeat(200);
|
||||||
|
validate_platform_art_icon_prompt(&format!("\u{0085} {boundary}\n"))
|
||||||
assert!(!descriptions.is_empty());
|
.expect("200 Unicode characters after Rust trim must pass");
|
||||||
assert!(descriptions
|
let error = validate_platform_art_icon_prompt(&"🪙".repeat(201))
|
||||||
.iter()
|
.expect_err("201 Unicode characters must be rejected instead of truncated");
|
||||||
.all(|description| description.chars().count() <= 200));
|
assert!(error.contains("200"), "{error}");
|
||||||
|
for blank in ["", " \r\n\t", "\u{0085}\u{2003}"] {
|
||||||
|
assert!(validate_platform_art_icon_prompt(blank).is_err());
|
||||||
|
}
|
||||||
|
validate_platform_art_icon_prompt("\u{feff}")
|
||||||
|
.expect("BOM is not whitespace under the API Server Rust trim contract");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
@@ -143,6 +143,13 @@ struct AgcSkillManifestEntry {
|
|||||||
sha256: String,
|
sha256: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct AgcSkillCatalogEntry {
|
||||||
|
pub(crate) name: String,
|
||||||
|
pub(crate) description: String,
|
||||||
|
}
|
||||||
|
|
||||||
fn is_safe_skill_relative_path(value: &str) -> bool {
|
fn is_safe_skill_relative_path(value: &str) -> bool {
|
||||||
let path = Path::new(value);
|
let path = Path::new(value);
|
||||||
!value.is_empty()
|
!value.is_empty()
|
||||||
@@ -256,6 +263,21 @@ pub(crate) fn agc_skill_pack_fingerprint() -> Result<String, String> {
|
|||||||
Ok(format!("{:x}", Sha256::digest(canonical_manifest.as_ref())))
|
Ok(format!("{:x}", Sha256::digest(canonical_manifest.as_ref())))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 返回当前客户端随 AGC 一起启用的内置 Skill 候选。
|
||||||
|
///
|
||||||
|
/// 前端不得复制审核清单;Skill 名称和描述统一从经过校验的资源 manifest 派生。
|
||||||
|
#[tauri::command]
|
||||||
|
pub(crate) fn list_agc_skill_catalog() -> Result<Vec<AgcSkillCatalogEntry>, String> {
|
||||||
|
Ok(validated_skill_pack_manifest()?
|
||||||
|
.skills
|
||||||
|
.into_iter()
|
||||||
|
.map(|entry| AgcSkillCatalogEntry {
|
||||||
|
name: entry.name,
|
||||||
|
description: entry.purpose,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn render_agc_skill_pack_index() -> Result<String, String> {
|
pub(crate) fn render_agc_skill_pack_index() -> Result<String, String> {
|
||||||
let manifest = validated_skill_pack_manifest()?;
|
let manifest = validated_skill_pack_manifest()?;
|
||||||
let mut lines = vec![format!(
|
let mut lines = vec![format!(
|
||||||
@@ -350,6 +372,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skill_catalog_is_derived_from_the_validated_manifest() {
|
||||||
|
let catalog = list_agc_skill_catalog().expect("skill catalog");
|
||||||
|
assert_eq!(catalog.len(), AGC_SKILL_PACK_EXPECTED_NAMES.len());
|
||||||
|
for expected_name in AGC_SKILL_PACK_EXPECTED_NAMES {
|
||||||
|
let entry = catalog
|
||||||
|
.iter()
|
||||||
|
.find(|entry| entry.name == expected_name)
|
||||||
|
.expect("expected bundled skill");
|
||||||
|
assert!(!entry.description.trim().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn skill_content_digest_is_stable_across_lf_and_crlf() {
|
fn skill_content_digest_is_stable_across_lf_and_crlf() {
|
||||||
fn digest(bytes: &[u8]) -> String {
|
fn digest(bytes: &[u8]) -> String {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user