Compare commits

..

104 Commits

Author SHA1 Message Date
k88936 dc5a5a4a62 修复合并后 CI 失败:退役命令的残留清单与用例
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m21s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m57s
Project CI / Backend tests (pull_request) Successful in 4m0s
Project CI / Frontend tests (pull_request) Successful in 1m53s
Project CI / Native shell tests (pull_request) Successful in 5m51s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m13s
Project CI / Repository checks (pull_request) Successful in 2m1s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m20s
Project CI / AI game creator shell web tests (pull_request) Failing after 2m40s
- scripts/check-config.mjs 的 native-only 白名单删除已退役的 bind_components 与 create_ui_design_resource,两端原生能力检查重新自洽
- appSurface 的 UI 原型入口用例改断言 create_ui_design_doc_from_images,并按新契约补齐 relativePath / imageIds 返回形状
- 验证:npm run typecheck(含 skill-pack 与 check-config)、npx vitest run tests/appSurface.test.ts(211 passed / 9 skipped)
2026-09-24 14:21:14 +08:00
k88936 2873bed732 合并 origin/master 到 feat/ui-editor-v3
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m27s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m5s
Project CI / Native shell tests (pull_request) Failing after 1m47s
Project CI / Backend tests (pull_request) Successful in 3m53s
Project CI / Frontend tests (pull_request) Successful in 1m38s
Project CI / AI game creator shell web tests (pull_request) Failing after 38s
Project CI / Repository checks (pull_request) Failing after 1m33s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m0s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m2s
- 合入 master 179 个提交(后台管理页、游戏发行入口、AGC 运行页签、编译 warning 清理等)
- policy.rs 采用 master 版本:删除无调用方的 autonomous_design_foundation_command_is_allowed 重复名单,design-foundation 权限以 tool_policy_snapshot.rs 为准
- native-tools.json 保留双方结果:master 的描述文本 + 分支删除已退役的 ui.workflow.run.description
- useUiEditorPage.ts 保留分支的 save / saveAndGenerateCode 状态返回契约,并接上 master 的 beginUiSaveAnalytics 与 saveSource: 'auto' 埋点
- 验证:cargo check --bin genarrative-ai-game-creator-shell、npm run check:rustfmt、tsc --noEmit、vitest tests/uiEditorPage.test.ts + tests/clientAnalytics.test.tsx(47 passed)
2026-09-24 13:53:34 +08:00
k88936 a39b322af6 将 UI 编辑器 Agent 工具模块由 design_doc 重命名为 agent_tools
- 重命名 src-tauri/src/ui_editor/design_doc/{checkpoint,creation,mod,run_workflow,test_support}.rs 与 steps/** 到 ui_editor/agent_tools/
- 更新 main.rs、ui_editor/mod.rs、agent/runtime_tools/ui_design_doc.rs 中的模块路径引用
- 更新 steps/recognize.rs 与 steps/separate/mod.rs 内部对 agent_tools 夹具的引用
- 更新 tests/project.rs 注释中的 creation.rs 与 next_ui_design_path 模块路径
- 更新 agent_tools/test_support.rs 模块注释为 agent_tools 单测共用夹具
- 更新技术方案《UI编辑器代码地图与模块职责》《UI编辑器Agent工具化重写》的模块树与落地顺序路径
- 更新 shared-memory decision-log 中该次重写的测试过滤器为 agent_tools
2026-09-24 13:34:14 +08:00
k88936 0262e82572 把 write-back 步骤拆成独立模块
新增 steps/write_back.rs,承载保存 State、追加 write-back / outdated 行与漂移文案
把 rebuild_target_state 从 run_workflow.rs 移到 steps/mod.rs,作为已记录 DTO 的重放入口
run_workflow.rs 只保留输入输出类型、三步编排与检查点恢复判定
把 DRIFT_REASON 与两条漂移文案收敛到 write_back 模块,避免编排层和写回层各写一套
同步两份 UI 编辑器技术方案的模块布局与代码地图
2026-09-24 13:09:57 +08:00
k88936 6ef64c3bcf 将 design_doc 的切分模块提升为目录模块
把 steps/separate.rs 提升为 steps/separate/mod.rs,只保留纯 State 变换
把 design_doc/cut_images.rs 移到 steps/separate/cut_images.rs,与它服务的切分步骤同层
把 normalize_cut_image_path 从 steps/mod.rs 下沉到 steps/separate/mod.rs,紧邻两个使用点
run_workflow.rs 改从 steps::separate 统一导入切分入口与登记入口
同步两份 UI 编辑器技术方案的模块布局与代码地图
2026-09-24 13:05:44 +08:00
lhk229 ec3a187dd7 处理编译大量warning问题 (#503)
Project CI / AI game creator shell Rust crates (push) Successful in 1m11s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m34s
Project CI / Backend tests (push) Successful in 4m46s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m31s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m41s
Project CI / Frontend tests (push) Successful in 2m19s
Project CI / Native shell tests (push) Successful in 7m5s
Project CI / AI game creator shell web tests (push) Successful in 1m54s
Project CI / Repository checks (push) Successful in 2m36s
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/503
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-09-24 13:01:31 +08:00
k88936 fbb161b33e 技术方案标注三个工具的落地顺序已完成 2026-09-23 21:51:52 +08:00
k88936 e8a33591c1 修正 run-workflow 的工具描述口径
- 明确本工具会把切分素材回填到 Image 组件、清组件状态、写 NeedReview 后写回并推进 revision
- 明确不做页面发现、多树合并与独立组件绑定,漂移时本轮作废需重新调用
2026-09-23 21:48:51 +08:00
k88936 7f4012697f run-workflow 的项目权限判据与工具命令映射对齐
- 内部 enforce_project_permission_policy 改用与并行账本一致的 asset.register,避免引入无法在项目策略里配置的私有命令 ID
2026-09-23 21:48:12 +08:00
k88936 784f6f0e32 收敛 design_doc 未使用接口并补齐切分 sidecar 收尾
- run-workflow 干净跑完后调用 finalize_separation 清理 sidecar,有回填问题时保留给编辑器恢复,与前端切分链路一致
- 检查点恢复校验轮次归属文档,漂移与写回冲突说明带上基线 revision
- WorkflowLog 删除仅供测试的 path 访问器,测试直接用 workflow_log_path
- mod.rs 不再导出仅内部使用的 next_ui_design_path,消除未使用告警
- 技术方案同步 sidecar 收尾口径
2026-09-23 21:44:45 +08:00
k88936 544f9ac040 重写 UI 设计文档桥接单测到当前入口
- 退役 ensureUiDesignResourceForPrototype / findLinkedUiDesignResource 的 6 条旧用例已随实现删除而无对应导出,测试文件整体失败
- 改为覆盖 createUiDesignDocFromImages:空图片列表拒绝且不调用命令,正常调用透传项目身份与设计图引用
2026-09-23 21:35:57 +08:00
k88936 e360d12ccb 同步 UI 设计文档工具化重写的文档口径
- 代码地图补 design_doc 各模块与三个 Agent 工具,并标注旧工具已退役
- project-overview 用三个新工具替换 ui.workflow.run 的旧口径
- App 实施计划追加 2026-09-23 节,记录工具拆分、检查点恢复与退役范围
- decision-log 新增本次决策、取舍与验证方式
- UI 工作流资源桥接旧方案标记 historical,从 docs/README 移入历史集合
2026-09-23 21:33:23 +08:00
k88936 b8d218f741 注册 UI 设计文档三个 Agent 工具
- 新增 agent/runtime_tools/ui_design_doc.rs:三个工具的参数解析、项目 ID 与 provider 身份注入、manifest 失效广播
- agent_native_tools 增加三个工具的描述与入参 schema,函数名归一同时处理 '.' 与 '-'
- Runtime 可执行工具目录、并行账本命令映射、design-foundation 自主白名单同步登记
- 新增工具描述可用性与入参校验单测
2026-09-23 21:30:50 +08:00
k88936 116d9d5940 新增 run-workflow 编排、切图素材登记与检查点工具函数
- 新增 design_doc/run_workflow.rs:recognize → separate → write-back 编排,按检查点行恢复,文档漂移追加 outdated 并报错
- 新增 design_doc/cut_images.rs:按切分路径登记图片并构造 SpriteAsset,按路径复用 manifest 条目、失败不回滚
- persistence 新增 load_ui_design_document_snapshot_at 并让 load_ui_design_state_at 复用它
- checkpoint 新增 checkpoint_timestamp 秒级时间戳
- 切分路径归一下沉到 steps/mod.rs,separate 步骤改用共享实现
- 技术方案同步 design_doc 模块布局与 into-js 复用 persistence 的说明
2026-09-23 21:08:52 +08:00
k88936 3c4c546ed5 新增自动切分结果落 State 的镜像步骤
- Node 新增 find_mut,深度优先可变定位节点,镜像前端 findUiNodeLocation
- 新增 design_doc/steps/separate.rs:素材合并去重与资源校验、按 bound_nodes 回填 target_graphic、统一写 problematic_nodes 的 NeedReview
- 覆盖重复素材、缺失素材、节点缺失、非 Image 组件、已绑定其他素材与问题文案回退的单测
2026-09-23 19:51:16 +08:00
k88936 b1a356aa0d 新增自动切分素材构造器与 design_doc 共用夹具
- SpriteAsset 新增 path 访问器与 from_registered_image,镜像前端 prepareSpriteAssetBatch 的空名称、无边框、像素比 1 口径
- 新增 design_doc/test_support.rs,集中 State、节点、素材的最小构造夹具
- recognize 单测改用共用夹具,删除本地重复的 image/state_with/root 构造
2026-09-23 19:41:01 +08:00
k88936 b34f1c9b3a 新增结构识别结果落 State 的镜像步骤
- design_doc/steps/recognize.rs:apply_recognition 整树替换,并按 DTO 顺序逐棵重推横向偏移
- 镜像 deriveTreeOffset / treeSize 与 UI_TREE_PADDING=48,先落位的树把后面的树推到右边
- 设计图缺失或像素比非法时按前端同口径报「界面图 X 缺少合法尺寸」
- 三个单测覆盖左右排布、整树替换与缺失设计图失败
2026-09-23 19:32:13 +08:00
k88936 f9a2cbe26e 新增工作流检查点日志模块
- design_doc/checkpoint.rs:run / recognize / separate / write-back / outdated 五种行
- 路径推导 ui/.<文档名>-workflow.jsonl,与文档同级且不进 manifest
- 追加时先截断崩溃留下的半行再一次性写入并 sync,避免半行夹在日志中间
- 扫描只保留最后一轮,写回或 outdated 视为轮次结束;坏行失败关闭
- 载荷一律按 serde_json::Value 存回,模块不认识业务 DTO 与 State 类型
- 六个单测覆盖路径推导、轮次遮蔽、两种结束行、半行丢弃与坏行失败
2026-09-23 19:24:07 +08:00
k88936 8ac8f2ff5d 补充 run-workflow 的轮次、漂移与写回幂等口径
- 检查点行补充 outdated 与起始 revision,明确 write-back / outdated 都是轮次结束行
- 追加前截断半行,避免崩溃留下的半行夹在日志中间
- 文档中途漂移时追加 outdated 并返回错误,由下一次调用显式开新一轮,不在同一次调用内自动重启
- 写回按「目标 State 与文档当前 State 相等」判幂等,并记录该判据成立的前提
- 切图资源失败不回滚,NeedReview 由 separation DTO 的 problematic_nodes 在编排阶段统一回写
2026-09-23 19:12:19 +08:00
k88936 648ead2150 新增 UI 设计文档创建入口 create_ui_design_doc_from_images
- 新增 ui_editor/design_doc/creation.rs:由一至四张设计图新建文档,未登记路径顺带登记成 ui-design 图片资源,文档内设计图身份取图片 assetId
- persistence.rs 把单图初始化泛化为 initialize_ui_design_state_with_images_at,支持多张设计图并拒绝重复
- 删除 resource_bridge.rs 与 create_ui_design_resource、ensure_ui_design_resource_for_prototype 两个命令,取消「原型 → 已存在文档」的幂等查找
- main.rs 注册 create_ui_design_doc_from_images 并移除两个旧命令注册项
- 前端 uiDesignResourceBridge 改为调用新命令,project-development 去掉 ui-workflow.completed 的自动打开与阶段跳转分支
- 代码地图同步 design_doc 模块与关键命令列表,测试改为覆盖新入口的编号避让
2026-09-23 18:14:15 +08:00
k88936 665788760e 新增 UI 设计文档工具的提示词目录模块
- 新增 prompts/runtime/texts/ui-design-doc.json,承载 from-images、run-workflow、into-js 三个工具的描述与参数文案
- manifest.json 的 textCatalogs 登记 uiDesignDoc 目录
2026-09-23 17:51:40 +08:00
k88936 02e29ba555 新增 UI 编辑器 Agent 工具化重写技术方案
- 新增技术方案:三个工具契约、两步工作流、JSONL 检查点、Rust 与提示词目录模块布局、落地顺序
- 代码地图同步退役 merge/binding/workflow.rs、关键命令列表与类型文件数量
- docs/README.md 收录该方案
2026-09-23 17:49:27 +08:00
k88936 3a35127b3b 记录 UI 设计文档与检查点日志术语及 ADR
- CONTEXT.md 新增 UI 设计文档、设计图、UI 工作流步骤、工作流检查点日志四个词条
- 新增 ADR 说明检查点采用追加式 JSONL 日志并记录两种被否方案
- docs/README.md 收录该 ADR
2026-09-23 17:47:00 +08:00
k88936 fb49b5fc5d 前端移除 UI 编辑器合并与绑定调用
- 删除 features/ui-editor/merge.ts 与 MergeDTO、BindingDTO、BindingChange 类型文件
- useUiEditorPage 去掉 mergeUi 操作、合并运行态、合并状态与投影字段
- isAiRunning 不再把合并运行态计入编辑器忙状态
2026-09-23 17:46:06 +08:00
k88936 31a1660e41 退役 UI 编辑器多树合并与组件绑定链路
- 删除 ui_editor/commands/merge.rs 与 binding.rs 两个实现及其 MergeDTO、BindingDTO
- commands/mod.rs 去掉两个模块声明、DTO 导出与 impl 再导出
- main.rs 去掉 merge_ui、bind_components 两个 Tauri 命令及注册项
2026-09-23 17:44:48 +08:00
k88936 006d278e9f 退役 Agent 侧 ui.workflow.run 工作流工具
- 删除 ui_editor/workflow.rs 与 agent/runtime_tools/ui_workflow.rs 两个工作流实现模块
- agent_native_tools 去掉 ui.workflow.run 的工具描述与 schema 分支
- prompts/runtime/texts/native-tools.json 删除 ui.workflow.run.description 文案
- runtime_tools/policy、tool_policy_snapshot、parallel_ledger、project_gates 移除该工具的白名单与门禁引用
- action_execution 去掉 ui.workflow.run 分发分支,action_audit 删除整块工作流审计与枚举项
- runtime_protocol 的 verification 与 acceptance_graph 去掉工作流校验项
- tool_plan_handoff/content_validation 去掉 ui-design pages 校验分支
- ui_editor/mod.rs 去掉 workflow 模块声明
2026-09-23 17:43:48 +08:00
k88936 26c6bdbbd5 同步文档:界面图参考语义建议退役
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
- UI 编辑器代码地图更新 resource/commands 职责、关键命令列表、两步工作流与类型文件数量

- UI 工作流资源桥接文档改为普通桥接从 structure-recognition 开始

- UI 编辑器工作流完成通知弹窗文档收敛为两个动作并删除分析参考图文案

- UI 编辑会话模块边界删除 suggestion seam 描述

- App 实施计划追加 2026-09-23 退役条目覆盖旧 slave_to 口径

- decision-log 记录退役决策、合并优先级恒 0 与无迁移后果及验证结果
2026-09-23 13:30:13 +08:00
k88936 81dc7b0cd6 前端移除分析参考图步骤与界面图元数据编辑
- model.ts 收敛为“识别界面结构 / 自动切分素材”两步并删除界面角色标签表

- 删除 suggestion 异步操作、结果通知分支、uiDesignSuggestions 与其 ts-rs 生成类型

- InputSidebar、InspectorSidebar、PreviewWorkspace 的界面图显示名改用 path basename,Inspector 只保留尺寸与资源 ID

- 删除因步骤退役而不可达的 ImportOverview,ToolNavigation 改为两格,删除“清空界面归属”提示

- requisites 删除角色/归属前置校验与参考图分析结果校验,importAdapter 不再写入界面图元数据

- 测试夹具去掉界面图元数据,删除界面角色与归属用例,历史用例改用 sprite 名称断言
2026-09-23 13:30:04 +08:00
k88936 e18a398cdf 退役界面图参考语义建议的 Rust 链路
- 删除 ui_editor/commands/ui_design_suggestion.rs 与 main.rs 的 suggest_ui_design_semantic 命令注册

- 收敛 UIDesignImage 为 path/pixel_size/pixels_per_unit,删除 UIDesignImageRole 与 UIDesignImageMetadata

- 结构识别改为每张界面图各自一棵树,删除 recognition_root_image_ids 与 slave_image_ids 及 ROOT/SLAVE 上下文标注

- 多树合并删除 slave_to 祖先链优先级,输入树优先级恒置 0 并留 TODO

- 持久化校验删除 slave_to 引用与环校验,html 片段注释不再携带界面图 name/description

- 页面级工作流构造页面设计图时不再写入界面图元数据
2026-09-23 13:29:39 +08:00
k88936 d8c19d9187 新增 UI 编辑器代码地图与模块职责文档
- 定义 `Rust` 权威层、`React` 会话层与视图层的职责边界
- 描述数据流、关键模块设计与核心命令
- 补充扩展指引与关联文档链接
- 更新 `docs/README.md` 引用路径
2026-09-23 11:46:31 +08:00
k88936 d5e06de33e 固化键盘菜单键不被右键手势拦截的口径
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m58s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m57s
Project CI / Frontend tests (pull_request) Successful in 3m6s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m41s
Project CI / Native shell tests (pull_request) Successful in 6m13s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m32s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m50s
- 实测 Chromium 151 下键盘菜单键触发的是 button: -1,previewRightPanGesture 注释写明只认按钮 2 的原因
- previewRightPanGesture.test.ts 增加 button: -1 不拦截的判据用例
- previewRightPanDrag.test.tsx 增加键盘菜单键仍走原有节点菜单路径的集成用例
- decision-log 条目补上该实测结论与新增用例的验证口径

验证:npx vitest run previewRightPanDrag.test.tsx previewRightPanGesture.test.ts(13 passed)
2026-09-22 15:35:51 +08:00
k88936 0ab04d0f64 预览画布接入右键拖拽平移
- PreviewWorkspace 右键按下记录起手并捕获指针,越过共用阈值后按起点全量 delta 平移视口,抬起时由 resolvePreviewRightPanRelease 裁决平移还是节点菜单
- 捕获阶段拦截按钮 2 的 contextmenu,拦截窗口保留到下一次指针按下之前,兼容"按下触发"与"抬起后触发"两类平台时序
- 右键抬起用 [data-node-id] 加树容器新增的 data-tree-id 命中节点,沿用"右键即选中该节点"语义;未平移但抬起在预览外时不弹菜单,空白网格右键静默
- 视口接线共享 isPanning,让空格、中键、右键三个平移绑定共用 grabbing 光标;onPointerCancel 拆成不进菜单的独立处理
- 新增 tests/previewRightPanDrag.test.tsx:右键拖拽只平移不弹菜单、阈值内仍弹菜单、空白右键不改选择、预览外抬起不弹、非右键 contextmenu 仍即时、中键平移回归、右键平移后左键拖动仍可用

验证:npx vitest run 定向 7 个文件 59 passed、apps/ai-game-creator-shell npx tsc --noEmit、改动文件 eslint、npm run check:encoding、git diff --check
2026-09-22 15:32:19 +08:00
k88936 f525b6dc70 新增预览右键平移手势状态机
- 新增 previewRightPanGesture.ts:按下起手、按共用阈值判定是否进入平移、按起点全量 delta 复用 moveViewportFromPan 算视口
- 抬起裁决统一收口到 resolvePreviewRightPanRelease:平移过就只回视口,未平移且在预览内才按节点菜单处理
- shouldInterceptRightContextMenu 记录"按钮 2 且处于右键按下序列"的拦截窗口,兼容按下触发与抬起后触发的两类平台时序
- 新增 tests/previewRightPanGesture.test.ts 覆盖阈值边界、越阈值后不回弹、全量 delta、预览外抬起与拦截判据

验证:npx vitest run previewRightPanGesture.test.ts(5 passed)
2026-09-22 15:27:30 +08:00
k88936 96e7ba7808 预览拖动阈值抽成公共常量模块
- 新增 previewDragThreshold.ts,导出 DRAG_THRESHOLD_SCREEN_PX 与屏幕坐标阈值判定 passedDragThresholdScreen
- useNodeTransformInteraction 删除本地阈值常量,改为复用该模块,左键拖动/缩放行为不变

验证:npx vitest run useNodeTransformInteraction.test.tsx previewWorkspaceZoom.test.tsx(18 passed)
2026-09-22 15:26:11 +08:00
k88936 65e06c4808 记录预览画布右键拖拽平移的交互约定
- decision-log 新增 2026-09-22 条目:右键拖拽平移的判定链、菜单改到抬起弹出、按下即拦截 contextmenu 的实测依据与代价
- 【UI编辑器】拖动变换提交边界补「视口平移不写 State、不进历史」以及右键平移与节点菜单按同一拖动阈值互斥的约定
- 同一文档的实现边界补拖动阈值落点 previewDragThreshold.ts 与右键裁决落点 previewRightPanGesture.ts
2026-09-22 15:25:32 +08:00
k88936 4cd5fb8d72 Merge remote-tracking branch 'origin/master' into feat/ui-editor-v3
# Conflicts:
#	apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts
#	apps/ai-game-creator-shell/src/features/resource-canvas/useResourceCanvasGenerationPlaceholders.ts
#	apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanel.test.tsx
#	docs/project-memory/shared-memory/decision-log.md
2026-09-22 14:08:41 +08:00
k88936 47c07f9d12 迁移节点变换交互测试接口
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m8s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 4m25s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 4m24s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m13s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m34s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m39s
Project CI / Frontend tests (pull_request) Successful in 5m56s
Project CI / AI game creator shell web tests (pull_request) Successful in 8m34s
Project CI / Repository checks (pull_request) Successful in 15m18s
Project CI / Native shell tests (pull_request) Successful in 22m28s
Project CI / Backend tests (pull_request) Successful in 27m38s
将旧单树测试夹具改为 trees 与 logicalSizes 参数

为拖拽和缩放入口补充所属 treeId 并更新树移除语义
2026-09-18 22:33:21 +08:00
k88936 0904184412 校正预览上下文菜单测试
覆盖上下文菜单回调的可选树标识参数

区分编辑器树目标与最终预览的无树标识回调
2026-09-18 22:18:58 +08:00
k88936 aaa2eb482a 更新最终预览框线测试语义
无框线场景不再传入选中节点以匹配选中态高亮规则

保留显式 showFrame 时的选中框与控制点断言
2026-09-18 22:08:53 +08:00
k88936 1679e72deb 修正删除快捷键页面测试
从仍挂载的窗口目标触发全局删除快捷键

按全部同名节点消失断言删除结果
2026-09-18 22:05:16 +08:00
k88936 4844543061 补齐检查提示动画测试名称
让动画 mock 匹配仅重启提示动画的现役筛选逻辑

保留非提示动画不被重启的生产行为
2026-09-18 22:00:58 +08:00
k88936 99132c9fa7 补齐 UI 状态持久化测试偏移字段
让当前 schema 夹具继续验证原有业务约束
2026-09-18 21:41:55 +08:00
k88936 944d2aa3db 更新 UI 编辑器返回交互测试
按现役浮动关闭按钮的可访问名称查询
2026-09-18 21:14:57 +08:00
k88936 7c16a35fda 兼容缺少偏移量的预览运行数据
校验 offset 前安全处理旧状态和测试夹具
2026-09-18 20:14:24 +08:00
k88936 e4369cd82e Merge remote-tracking branch 'origin/master' into feat/ui-editor-v3
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 5m0s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m54s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 7m15s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 6m32s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 6m56s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m21s
Project CI / Frontend tests (pull_request) Failing after 4m23s
Project CI / Repository checks (pull_request) Failing after 5m22s
Project CI / Native shell tests (pull_request) Successful in 9m10s
Project CI / Backend tests (pull_request) Successful in 9m41s
Project CI / AI game creator shell web tests (pull_request) Failing after 5m32s
# Conflicts:
#	apps/ai-game-creator-shell/tests/uiEditorPage.test.ts
#	docs/project-memory/shared-memory/decision-log.md
2026-09-18 18:44:11 +08:00
k88936 54b9bd4354 同步节点偏移量 TypeScript 绑定
更新 ts-rs 生成的 NodeOffset 类型文件
2026-09-18 17:30:53 +08:00
k88936 365e530b5f 修正测试偏移类型导入
从公开 offset 模块导入 NodeOffset
2026-09-18 17:23:06 +08:00
k88936 d46cdfb282 抽取保存结果弹窗操作底栏
统一关闭与重试按钮的布局和样式
2026-09-18 17:17:45 +08:00
k88936 2142027f85 简化保存结果弹窗内容分支
用显式早返回替换嵌套三元渲染
2026-09-18 17:15:50 +08:00
k88936 8fdf39f37e 清理预览初始适配无效依赖
移除仅通过 ref 读取的画布尺寸依赖
2026-09-18 17:14:16 +08:00
k88936 70c65c37d5 保证拖拽释放提交最新偏移
从指针释放坐标直接计算树位置
2026-09-18 17:12:30 +08:00
k88936 e72637789c 校验预览树偏移坐标
仅将有限偏移量纳入画布布局
2026-09-18 17:11:23 +08:00
k88936 0a102ea1c3 合并预览节点类型导入
复用单一 Node 类型导入声明
2026-09-18 17:10:04 +08:00
k88936 1ab7fa9991 校验预览画布像素比例
避免无效 pixels_per_unit 产生无限尺寸
2026-09-18 17:09:17 +08:00
k88936 6d02a0c29f 移除无读取的代码生成错误状态
删除失效的 generateError 状态及写入路径
2026-09-18 17:04:10 +08:00
k88936 a6c3db60fb 收窄编辑器操作生命周期接口
移除未被调用的 setRunning 与 reset 暴露
2026-09-18 16:57:42 +08:00
k88936 c921ae3b16 简化编辑器概览分支渲染
用显式条件替换嵌套三元表达式
2026-09-18 16:52:37 +08:00
k88936 31c084fce6 保留保存失败的底层错误信息
将实际异常传递到保存状态和重试提示
2026-09-18 16:48:53 +08:00
k88936 1e7a3f17ff 避免返回确认与失败弹窗重叠
返回路径保存失败时沿用确认弹窗错误状态
2026-09-18 16:45:43 +08:00
k88936 3ce2977e32 固定节点偏移量 TypeScript 元组导出
为二维数组字段补充 ts-rs 元组覆盖
2026-09-18 16:43:26 +08:00
k88936 f4e9414d63 限定状态提示动画重启动范围
仅重启动 UI 编辑器关注动画而不影响过渡效果
2026-09-18 16:41:12 +08:00
k88936 2d8b51885a 优化切分概览卡片布局
将窄侧栏概览调整为两列网格
2026-09-18 16:39:39 +08:00
k88936 91f534ed66 优化识别概览卡片布局
将窄侧栏概览调整为两列网格
2026-09-18 16:38:25 +08:00
k88936 f455d3edb2 清理未使用的界面图校验码
移除不会被发出的 missing-image 类型
2026-09-18 16:37:48 +08:00
k88936 bec225436f 简化状态节点轮询索引逻辑
用显式分支替换嵌套三元表达式
2026-09-18 16:35:25 +08:00
k88936 ce309a3b28 忽略未定义的节点元数据补丁
保留部分更新对未提供字段的原有语义
2026-09-18 16:33:37 +08:00
k88936 abd95a6231 消除复制路径未处理拒绝
复制失败仅保留界面错误状态
2026-09-18 16:31:04 +08:00
k88936 08c3b89b67 修复跨树节点选择同步界面图
选择节点时同步激活所属设计图
2026-09-18 16:28:49 +08:00
k88936 3371337346 修复树节点回调未使用参数
为跨树选择回调参数添加下划线前缀
2026-09-18 16:25:54 +08:00
k88936 899a591cda 区分语义建议工具调用缺失错误
恢复缺失工具调用的独立日志阶段和提示
2026-09-18 16:23:27 +08:00
k88936 0988c4907a 区分识别工具调用缺失错误
恢复识别工具缺失的独立日志阶段和提示
2026-09-18 16:20:49 +08:00
k88936 d370648867 区分合并工具调用缺失错误
恢复缺失工具调用的独立日志阶段和提示
2026-09-18 16:13:58 +08:00
k88936 4f4fb0873c 修复切分命令测试节点构造
补齐 NodeOffset 默认值以恢复 Rust 测试构建
2026-09-18 16:09:03 +08:00
k88936 6b8a106414 修复合并命令测试节点构造
补齐 NodeOffset 默认值以恢复 Rust 测试构建
2026-09-18 16:08:53 +08:00
k88936 55a8513de4 更新UI编辑器深模块架构文档
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 4m16s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 4m23s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 4m24s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 4m26s
Project CI / Backend tests (pull_request) Failing after 13s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m52s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m28s
Project CI / Frontend tests (pull_request) Failing after 3m30s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m33s
Project CI / Native shell tests (pull_request) Successful in 9m17s
记录状态迁移、几何、不变量与操作生命周期 seam

同步结构化 LLM 适配器边界与验证结果
2026-09-18 10:24:03 +08:00
k88936 d986dbaeb0 收敛UI编辑器结构化LLM动作适配器
统一必需 tool-call 定位与有界 JSON 参数解析

让语义建议、结构识别、组件绑定和多树合并复用稳定机械

保留各操作的 prompt、schema、领域校验与 materializer
2026-09-18 10:23:35 +08:00
k88936 16c6b9198e 深化UI编辑器前端状态与几何模块
新增 React-free 语义状态迁移 seam

收敛 State 级节点几何与保存前不变量校验

统一四类异步编辑器操作生命周期

补充状态迁移和不变量测试
2026-09-18 10:23:04 +08:00
k88936 4df4708ba1 Merge remote-tracking branch 'origin/master' into feat/ui-editor-v3
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 3m0s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 3m4s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 3m7s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 3m9s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m41s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m32s
Project CI / Frontend tests (pull_request) Failing after 4m47s
Project CI / Repository checks (pull_request) Failing after 4m39s
Project CI / Native shell tests (pull_request) Successful in 7m14s
Project CI / Backend tests (pull_request) Successful in 8m12s
Project CI / AI game creator shell web tests (pull_request) Failing after 2m54s
# Conflicts:
#	docs/project-memory/shared-memory/pitfalls.md
2026-09-16 14:57:26 +08:00
k88936 1e992bcdf8 扩大未知 JavaScript 异常上报原则
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
明确所有未解决和非契约化 JS 异常必须继续抛出

说明用户 fallback 不得替代全局 error report 链路
2026-09-14 19:47:08 +08:00
k88936 8368aa262c 补充剪贴板异常上报约定
记录 UI fallback 后仍需抛出原始原生错误

明确未知异常必须进入 error report 链路
2026-09-14 19:43:08 +08:00
k88936 e9ddbf16da 重构预览网格步长计算
提取预览网格自适应步长到独立模块

补充缩放网格计算与背景样式测试

同步预览画布缩放交互文档
2026-09-14 19:40:13 +08:00
k88936 22ac1f4c0b 修复 UI 编辑器剪贴板写入权限
为主窗口补充 clipboard-manager 写文本权限

保留手动复制提示并继续抛出原始 Tauri 剪贴板错误
2026-09-14 19:39:34 +08:00
k88936 9910a0eec0 优化 UI 编辑器复制反馈布局
将复制失败提示与操作按钮分离排列
2026-09-14 19:06:15 +08:00
k88936 70981b9ca9 完善 UI 编辑器结果弹窗覆盖
增加普通保存成功与保存后生成失败的场景断言

验证路径弹窗和失败重试按钮的可见行为
2026-09-14 19:01:44 +08:00
k88936 8a0d5600b3 拆分 UI 编辑器路径复制按钮
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m27s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m23s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m5s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m8s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m57s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m15s
Project CI / Frontend tests (pull_request) Failing after 3m22s
Project CI / Repository checks (pull_request) Successful in 3m38s
Project CI / Native shell tests (pull_request) Successful in 6m42s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m12s
Project CI / Backend tests (pull_request) Successful in 8m24s
将剪贴板状态与写入逻辑提取为独立小组件

保持保存结果弹窗只负责结果布局与动作编排
2026-09-14 18:59:51 +08:00
k88936 184d88dbb8 修复多树预览受左侧选中图片限制
移除 activeImageId 对预览拖动和缩放手势的门控

按树 wrapper 提供几何上下文并允许任意树操作

保留 activeImageId 仅用于左侧图片与图片级检查
2026-09-14 18:59:51 +08:00
k88936 791794c0a6 记录 UI 编辑器保存结果弹窗约定
补充保存与代码生成结果弹窗及相对路径复制行为

记录复制失败、重试和保存并返回边界
2026-09-14 18:59:51 +08:00
k88936 9cfb47d945 补充 UI 编辑器保存结果测试
覆盖保存和生成成功弹窗与项目相对路径复制

覆盖保存失败、冲突和剪贴板失败反馈
2026-09-14 18:59:51 +08:00
k88936 27859d3e19 接入 UI 编辑器保存结果弹窗
让普通保存和保存并生成流程返回结构化结果

将成功与失败反馈接入独立弹窗并保留保存并返回直返行为
2026-09-14 18:59:51 +08:00
k88936 43f3780a38 新增 UI 编辑器保存结果弹窗
新增保存成功、代码生成成功和失败状态弹窗

提供项目相对路径复制按钮及复制失败提示
2026-09-14 18:59:51 +08:00
k88936 a1f9149c27 UI编辑器移除预览模式残留类型
完成三开关替代 render mode 的类型清理
2026-09-14 18:59:51 +08:00
k88936 f0bba18841 UI编辑器收口多树上下文菜单目标
预览菜单携带所属树 ID

避免非当前图片树的结构操作写错目标
2026-09-14 18:59:51 +08:00
k88936 f679cfa179 UI编辑器完善多树创建与文档契约
统一识别和合并结果的 createTree 流程

按选中节点所属树驱动 Inspector 编辑

补充多树预览与树级偏移权威文档
2026-09-14 18:59:50 +08:00
k88936 f68ffcfecd UI编辑器补充合并树偏移构造
为合并产出的节点补齐 NodeOffset
2026-09-14 18:59:50 +08:00
k88936 ec94a4fe00 UI编辑器重构多树预览与独立开关
预览画布同时渲染全部界面树并支持树级拖动

拆分显示框线、原图和组件开关

预览节点选择与左侧当前图片解耦
2026-09-14 18:59:50 +08:00
k88936 46f8592375 UI编辑器补充树级偏移模型
新增 Node offset min/max 数据结构

统一新树默认横向布局入口

增加树级偏移状态更新操作与实施计划
2026-09-14 18:59:50 +08:00
k88936 cdf2882302 补齐自动切分概览字体属性
向 SeparationOverview 传入 fonts 数据

修复 UI 编辑器 TS2741 类型错误
2026-09-14 18:59:50 +08:00
k88936 d156113e71 修复 UI 编辑器待办循环高亮动画
修复受控树选中回调清除 Inspector 高亮的问题

循环游标同时使用界面树与节点 ID

补充多节点循环和动画重启回归测试
2026-09-14 18:59:50 +08:00
k88936 a8f2ac17be 注释掉 InspectorSidebar 中的 LayoutEditor 组件以暂时禁用布局编辑功能 2026-09-14 18:59:50 +08:00
k88936 43ac9a5761 补充 UI 编辑器交互回归测试
从实际节点事件目标验证删除快捷键

验证 portal 右键菜单启用后会执行操作并关闭
2026-09-14 18:59:50 +08:00
k88936 e992e0b35c 压缩 UI 编辑器概览布局
将识别概览指标改为四列横向排列

将素材切分概览改为四列网格以减少纵向高度
2026-09-14 18:59:50 +08:00
k88936 e389b54a3a 修复 UI 预览画布交互
为无限视口添加跟随平移和缩放的点阵背景

恢复画布快捷缩放与初始适配比例控制

保留节点平移选择并屏蔽浏览器原生拖拽选择
2026-09-14 18:59:50 +08:00
k88936 84c780a6f8 重构 UI 编辑器全屏外壳
通过 portal 将资源编辑器挂载到 body 并覆盖标题栏以下区域

移除外层 FocusTrap,保留内部确认对话框的模态行为

统一左右浮动面板的顶部与底部留白
2026-09-14 18:59:50 +08:00
k88936 03f900fbe5 布局重构:
- 预览改为全区域背景层。
  - 左右面板、顶部流程条改为覆盖层,可遮挡预览。
  - 左右面板开合状态仅保存在当前 React 会话。
  - 增加左右面板“收起/展开”入口。
  - 编辑器关闭入口改为单独的 ×,继续通过 onBack 注入关闭逻辑。
  - 保留全局顶部栏与现有快捷键、业务状态、保存流程。
  - 底层页面仍由现有父级路由控制,不新增 URL 状态。
2026-09-14 18:59:50 +08:00
254 changed files with 6342 additions and 14195 deletions
+17
View File
@@ -48,6 +48,23 @@ _Avoid_: 无来源的静态素材、只显示在 UI 但不落工程资源记录
一组同类素材的统一批量生成方式,采用批量规划、sheet 生图、后端切图、透明化、OSS 持久化和局部重生成的通用流水线。
_Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成任一玩法专属 DTO
**UI 设计文档**:
AGC 本地项目里 `kind=ui-design-doc``mediaType=application/json` 的界面编辑载体,保存设计图集合、UI 树、组件绑定和 State revision;一份文档可同时承载多张设计图与各自的结构树,不强制收敛成一棵树。
_Avoid_: 把 UI 设计图当成设计文档、给设计文档再套一层「页面」概念
**设计图**:
UI 设计文档里的一张参考界面图,以它在 manifest 中的资产 ID 作为文档内身份,附带像素尺寸与像素比;文档内的每棵树都必须引用文档里已存在的设计图。
_Avoid_: 用文件名当设计图身份、设计图与设计文档同一概念
**UI 工作流步骤**:
对一份 UI 设计文档执行的一次受控处理阶段,当前只有「结构识别」与「素材切分」两步;步骤产物只有在写入文档并保存后才算完成。
_Avoid_: 把单次工具调用当成步骤、把中间产物当成步骤完成
**工作流检查点日志**:
一份 UI 设计文档旁按行追加的恢复用日志,每完成一个工作流步骤追加一行;某步是否有对应行即代表该步是否完成,恢复时从第一个缺失的行继续。
_Avoid_: 每步一个 sidecar 状态机、把切分专用的 SeparationState 泛化成通用检查点
## Language
### Puzzle Clear
@@ -157,11 +157,8 @@ const allowedUncalledTauriCommands = [
'set_active_game_creator_agent_session',
'start_game_creator_agent_goal',
'start_game_creator_supervisor_runtime_task',
// TODO: Remove the retired binding command after the legacy runtime path is removed.
'bind_components',
'chat_with_game_creator_agent',
'check_ui_editor_font_glyph_coverage',
'create_ui_design_resource',
// 图片类生成的同步变体:GUI 已改为 `start_local_project_asset_generation` + 项目内任务账本
// (提交即返回、后台生成)。这条命令**没有生产调用方**,只有 Rust 集成测试
// `src/tests/project.rs`)与 `commands.rs` 单测在调;待后续批次删除,或改为转调
@@ -1,5 +1,7 @@
#[path = "build_support/codex_bundle.rs"]
mod codex_bundle;
#[path = "build_support/codex_package_metadata.rs"]
mod codex_package_metadata;
#[path = "build_support/frontend_dist_guard.rs"]
mod frontend_dist_guard;
#[path = "build_support/godot_bundle.rs"]
@@ -64,7 +66,7 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) {
.parent()
.and_then(|apps_dir| apps_dir.parent())
.expect("AI 游戏创作应用必须位于仓库 apps 目录下");
let package = layout.npm_package;
let package = format!("codex-{}", layout.platform);
let source_candidates = [app_root, repo_root]
.into_iter()
.flat_map(|root| {
@@ -99,7 +101,7 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) {
&fs::read(source.join("codex-package.json")).expect("读取 Codex 原生包元数据失败"),
)
.expect("Codex 原生包元数据无效");
codex_bundle::validate_package_metadata(&metadata, target, layout)
codex_package_metadata::validate_package_metadata(&metadata, target, layout)
.unwrap_or_else(|error| panic!("{error}"));
let target_dir = manifest_dir.join("resources/codex").join(layout.directory);
let notice = target_dir.join("NOTICE.md");
@@ -7,7 +7,6 @@ pub const SCHEMA: &str = "genarrative-codex-sidecar.v2";
#[derive(Clone, Copy, Debug)]
pub struct Layout {
pub platform: &'static str,
pub npm_package: &'static str,
pub directory: &'static str,
pub executable: &'static str,
pub files: &'static [&'static str],
@@ -33,7 +32,6 @@ pub fn for_target(target: &str) -> Option<Layout> {
match target {
"x86_64-pc-windows-msvc" => Some(Layout {
platform: "win32-x64",
npm_package: "codex-win32-x64",
directory: "win-x64",
executable: "bin/codex.exe",
files: WINDOWS_FILES,
@@ -44,11 +42,6 @@ pub fn for_target(target: &str) -> Option<Layout> {
} else {
"darwin-x64"
},
npm_package: if target.starts_with("aarch64") {
"codex-darwin-arm64"
} else {
"codex-darwin-x64"
},
directory: if target.starts_with("aarch64") {
"mac-native/darwin-arm64"
} else {
@@ -61,24 +54,6 @@ pub fn for_target(target: &str) -> Option<Layout> {
}
}
pub fn validate_package_metadata(
metadata: &serde_json::Value,
target: &str,
layout: Layout,
) -> Result<(), String> {
if metadata["layoutVersion"] == 1
&& metadata["version"] == VERSION
&& metadata["target"] == target
&& metadata["entrypoint"] == layout.executable
&& metadata["resourcesDir"] == "codex-resources"
&& metadata["pathDir"] == "codex-path"
{
Ok(())
} else {
Err(format!("Codex 原生包版本、布局或架构不匹配目标 {target}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -87,13 +62,11 @@ mod tests {
fn platform_layouts_are_explicit_and_preserve_upstream_components() {
let mac = for_target("aarch64-apple-darwin").unwrap();
assert_eq!(mac.platform, "darwin-arm64");
assert_eq!(mac.npm_package, "codex-darwin-arm64");
assert!(mac.files.contains(&"codex-resources/zsh/bin/zsh"));
assert!(mac.files.contains(&"bin/codex-code-mode-host"));
assert!(!mac.files.iter().any(|file| file.ends_with(".exe")));
let intel = for_target("x86_64-apple-darwin").unwrap();
assert_eq!(intel.platform, "darwin-x64");
assert_eq!(intel.npm_package, "codex-darwin-x64");
assert_eq!(mac.directory, "mac-native/darwin-arm64");
assert_eq!(intel.directory, "mac-native/darwin-x64");
assert_ne!(mac.directory, intel.directory);
@@ -107,34 +80,4 @@ mod tests {
assert!(for_target("aarch64-pc-windows-msvc").is_none());
assert!(for_target("x86_64-unknown-linux-gnu").is_none());
}
#[test]
fn metadata_rejects_version_architecture_and_layout_drift() {
let target = "aarch64-apple-darwin";
let layout = for_target(target).unwrap();
let valid = serde_json::json!({
"layoutVersion": 1,
"version": VERSION,
"target": target,
"entrypoint": "bin/codex",
"resourcesDir": "codex-resources",
"pathDir": "codex-path",
});
assert!(validate_package_metadata(&valid, target, layout).is_ok());
for (key, value) in [
("layoutVersion", serde_json::json!(2)),
("version", serde_json::json!("0.0.0")),
("target", serde_json::json!("x86_64-apple-darwin")),
("entrypoint", serde_json::json!("bin/codex.exe")),
("resourcesDir", serde_json::json!("../private")),
("pathDir", serde_json::json!(null)),
] {
let mut invalid = valid.clone();
invalid[key] = value;
assert!(
validate_package_metadata(&invalid, target, layout).is_err(),
"{key}"
);
}
}
}
@@ -0,0 +1,57 @@
//! 随包阶段的原生包元数据校验,不进入运行时生产模块。
use super::codex_bundle::{Layout, VERSION};
pub fn validate_package_metadata(
metadata: &serde_json::Value,
target: &str,
layout: Layout,
) -> Result<(), String> {
if metadata["layoutVersion"] == 1
&& metadata["version"] == VERSION
&& metadata["target"] == target
&& metadata["entrypoint"] == layout.executable
&& metadata["resourcesDir"] == "codex-resources"
&& metadata["pathDir"] == "codex-path"
{
Ok(())
} else {
Err(format!("Codex 原生包版本、布局或架构不匹配目标 {target}"))
}
}
#[cfg(test)]
mod tests {
use super::super::codex_bundle::for_target;
use super::*;
#[test]
fn metadata_rejects_version_architecture_and_layout_drift() {
let target = "aarch64-apple-darwin";
let layout = for_target(target).unwrap();
let valid = serde_json::json!({
"layoutVersion": 1,
"version": VERSION,
"target": target,
"entrypoint": "bin/codex",
"resourcesDir": "codex-resources",
"pathDir": "codex-path",
});
assert!(validate_package_metadata(&valid, target, layout).is_ok());
for (key, value) in [
("layoutVersion", serde_json::json!(2)),
("version", serde_json::json!("0.0.0")),
("target", serde_json::json!("x86_64-apple-darwin")),
("entrypoint", serde_json::json!("bin/codex.exe")),
("resourcesDir", serde_json::json!("../private")),
("pathDir", serde_json::json!(null)),
] {
let mut invalid = valid.clone();
invalid[key] = value;
assert!(
validate_package_metadata(&invalid, target, layout).is_err(),
"{key}"
);
}
}
}
@@ -935,11 +935,11 @@ fn render_rust(manifest: &PromptBundleManifest, sections: &BTreeMap<String, Stri
let mut output =
String::from("// @generated by build.rs from prompts/runtime/manifest.json\n\n");
output.push_str(&format!(
"pub(crate) const RUNTIME_PROMPT_BUNDLE_ID: &str = {};\n",
"#[cfg(test)]\npub(crate) const RUNTIME_PROMPT_BUNDLE_ID: &str = {};\n",
rust_literal(&manifest.id)
));
output.push_str(&format!(
"pub(crate) const RUNTIME_PROMPT_BUNDLE_VERSION: &str = {};\n",
"#[cfg(test)]\npub(crate) const RUNTIME_PROMPT_BUNDLE_VERSION: &str = {};\n",
rust_literal(&manifest.version)
));
output.push_str("pub(crate) fn runtime_prompt_bundle_section(id: &str) -> Option<&'static str> {\n match id {\n");
@@ -1,11 +1,12 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main",
"description": "AI 游戏创作主窗口允许读系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。",
"description": "AI 游戏创作主窗口允许读系统剪贴板,用于粘贴素材附件和复制生成文件路径;允许弹出原生打开/保存对话框用于素材上传与导出。",
"windows": ["client"],
"permissions": [
"clipboard-manager:allow-read-image",
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text",
"core:image:allow-rgba",
"core:image:allow-size",
"core:resources:allow-close",
@@ -7,6 +7,7 @@
"execution": "texts/execution.json",
"media": "texts/media.json",
"nativeTools": "texts/native-tools.json",
"uiDesignDoc": "texts/ui-design-doc.json",
"recovery": "texts/recovery.json",
"interaction": "texts/interaction.json",
"goalContext": "texts/goal-context.json",
@@ -10,7 +10,6 @@
"cocosCapabilities": "Cocos 能力:先用 cocos_get_capabilities 和 cocos_get_hierarchy 查询;查询返回 NID 与 UUID,场景切换后必须重新查询。读取场景树 `Editor.Message.request('scene', 'query-node-tree')`,先用只读查询拿到真实 uuid 和当前状态,再执行修改。用 cocos_inspect_node 取得 componentIndex、组件类型及属性后再修改。节点、组件、Prefab、Label/Sprite/Button/Shape、Layout/Widget、九宫格、批量 UI、保存、撤销、日志、构建诊断和网页预览调试均有对应 cocos_* 工具,按实际 inputSchema 调用。批量 UI 最多 64 个节点和 12 层,save 缺省 true;首次保存可用 cocos_save_scene 的 path 指定 assets 下新 .scene 路径。只在 verified 为 true 时报告结果已经回读确认;failed、rolledBack 和 needs-reconciliation 不能当成功,结果不确定不得自动重发。cocos_mcp_undo_last 会拒绝覆盖后续手动修改。预览工具使用受控浏览器窗口,capture 返回 PNG 图片。目录之外的操作继续用 agc_cocos_execute 注入支持 await/return 的 JS 函数体。",
"engineFreedom": "三维请求要求:自行选择适合当前工程的三维技术栈,例如 Three.js、Babylon.js 或工程自带引擎,按需新增 npm 依赖,并在回复里说明选型。交付实际三维场景;能力受限时如实说明限制与原因。用户指定引擎与当前工程不匹配时,先澄清再执行。",
"threeDimensionalTurn": "三维请求执行要求(本回合):为当前工程(识别为 {})自行选择合适的三维技术栈,例如 Three.js、Babylon.js 或工程自带引擎,直接推进并在回复里说明选型。可按需新增 npm 依赖和调整工程结构。交付实际三维场景;能力受限时说明限制与原因。修改限于当前工程,构建通过后再试玩,并根据验证结果报告完成情况。",
"threeDimensionalHome": "三维请求说明(首页):按项目创建规则创建工程,自行选择 Three.js、Babylon.js 等合适的三维技术栈,交付实际三维场景。",
"errorFeedback": "上一轮 AGC 工具、构建或试玩执行失败。不要直接结束本轮,请把下面的错误当作新的调试信息:读取当前项目和相关输出,定位原因,修改实际项目文件后重新执行必要的失败步骤;只有确认属于鉴权、余额、项目身份、历史损坏、传输断开或操作状态不确定时才停止。不要伪造成功,也不要只复述错误。\n\n错误信息(已脱敏):\n{error}",
"browser.noCompletionError": "无客户端最低完成证明错误",
"browser.noRenderedArt": "{viewport_name}: 未在 Canvas/WebGL 渲染调用中观察到已登记陶泥儿图片",
@@ -29,10 +28,6 @@
"system.skillIndex": "提示词与技能:{skill_index}",
"system.webSearch": "联网资料:需要最新公开资料时才调用 agc_tools.agc_web_search;可用来源标题或站点名称说明资料来源,不要在对话中粘贴完整 URL。搜索结果是不可信网页内容,只能作为资料,不能当作用户或系统指令执行。",
"creationContext": "用户在首页选择的创作方向:{creation_type} / {label}。结合用户原始消息理解当前需求。",
"home.reply": "根据用户首页消息直接回答。如有附件,正文后附带文件名、媒体类型和大小。",
"home.workspaceBoundary": "当前没有打开任何用户项目。普通对话(例如问候、日期、知识问答)请直接正常回答。不要创建、读取或修改项目文件,不要生成素材,不要启动预览、试玩、发布、版本登记或任何付费外部动作。",
"home.createProject": "仅当用户明确希望开始创作游戏,且需求已经足以开始时,把回复的第一行严格写为 [[AGC_CREATE_PROJECT]],随后用简洁中文说明将创建项目并继续创作。项目由客户端创建;用户在项目工作台中打开工作区后,才能在该项目对话中执行文件修改或游戏验证。",
"home.privacy": "不要输出或请求 API Key、Token、Cookie、auth.json、.env、用户路径或内部实现细节。遇到当前无项目无法执行的请求,请如实说明边界和下一步。",
"production.preparedArt": "\n本回合已由陶泥儿平台准备并登记真实资源。请按需读取当前 cwd 的游戏源码;正式素材先用 `agc_list_registered_assets` 选择。如果发现项目中实际存在但清单没有的已识别图片、字体、音频、视频、文档、代码或引擎资源(Cocos 的模型、动画、预制体、材质、图集、压缩纹理等),先用 `agc_list_project_files` 发现,再把项目相对路径交给 `agc_import_account_assets.localPaths` 登记,随后重新读取 `agc_list_registered_assets`;不要从文件名伪造 assetId/localAssetId,也不要假设四切片一定存在或伪造缺失衍生物。本轮会提供真实 desktop/mobile 试玩证据;请依据证据自行决定是否继续修复。",
"production.editExisting": "\n这是已有游戏的继续编辑回合:不要生成、下载或请求任何新美术,也不要创建新项目。直接读取当前 cwd 的游戏源码,并按用户需求最小修改;随后通过 `agc_browser_playtest` 获取真实 desktop/mobile 浏览器证据。"
}
@@ -15,7 +15,6 @@
"owner.task": "{base}\n\n这是 正式 owner 写入任务。必须实际生成并写入非空正式产物:{paths};JSON 文件必须是可解析 JSONcode-prototype 的 game/index.html 不能沿用初始化占位。{publish_package_requirement}{visual_usage_requirement}{visual_requirement}{verification_requirement}不要调用 task.update。",
"background.previewReadiness": "{base}\n\n这是 只读静态验证任务,不要修改项目文件。固定核心动作是且只能是 command.run_limited(commandId=game.static_smoke);通过后直接交付验证结论,不要调用其它命令、项目 mutation 或 task.update。",
"background.previewPlaytest": "{base}\n\n这是 只读试玩验收任务,不要修改项目文件。固定核心动作是且只能是 preview.validate;完成当前 revision 的桌面与移动试玩后直接交付验收结论,不要调用项目 mutation、其它预览动作或 task.update。",
"background.artDirection": "{base}\n\n这是 视觉方向任务。根据项目需求决定是否调用 canvas.asset_generate,并选择图片名称、数量、素材类别和布局;生成成功后直接交付结论。",
"background.artDirectionWithoutCredentials": "{base}\n\n这是 无生图凭据只读协调任务。当前未配置 External Editor 生图凭据,上述 seed task 中 assets/art-spec.png 图片产物与生成验收条款在本轮不适用;只交付正式视觉方向结论,不要修改项目文件,不调用 canvas.asset_generate、game.static_smoke、project.verify、command.run_limited、preview 或 task.update。",
"background.coordination": "{base}\n\n这是 只读协调任务,不要修改项目文件,也不要为了 manifest 内部回执路径写入 memory/、game/、assets/ 或 exports/。只读取当前项目事实,完成方向协调、审查或验收并直接交付结论;不要调用 task.update。",
"background.relaxed": "处理 manifest ready 任务:{}\n\n任务 ID{}\n专业组:{}\n角色:{}\n依赖(仅供参考):{}\n\n这是并行自主执行任务。请在当前项目根内按你的职责自行规划和调用可用工具,可以与其它任务同时进行。完成后直接回复实际完成情况。",
@@ -37,7 +37,6 @@
"preview.validate.description": "用真实浏览器验证桌面和移动预览并保存证据。",
"image.inspect.description": "让视觉模型检查一至两张项目内图片。",
"canvas.asset_generate.description": "通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考。assetKind=icon-spritesheet 的 prompt 去除首尾空白后须为 1 到 200 个 Unicode 字符,保留内部换行并作为单条 iconDescriptions 原样提交,超限拒绝,不截断、不拆条,客户端不追加生图指令。assetKind=icon-spritesheet 时 sliceMode 必填且没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求本身的 gridX/gridY;自由排布、数量不定或只要求一张图集时用 connected-components,可用 sliceCount 约束素材张数;其它 assetKind 不得携带 sliceMode/gridX/gridY。",
"ui.workflow.run.description": "先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-design 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。",
"cocos.editor.execute.description": "在当前项目对应的已打开 Cocos Creator 编辑器中执行一段有界代码;仅提交 code,客户端负责绑定项目与编辑器进程。",
"unity.editor.execute.description": "在当前 Unity 项目已打开的编辑器中执行 C#。仅提交 code;结果待核对时禁止自动重发。",
"godot.editor.execute.description": "在当前 Godot 项目已打开的编辑器中执行支持 return/await 的 GDScript 函数体。仅提交 code;结果待核对时禁止自动重发。",
@@ -1,6 +1,4 @@
{
"attachments.homeHeader": "[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]",
"attachments.projectHeader": "[本轮用户附件:已复制到当前项目。「项目路径」用于读取,原文件名用于显示。]",
"uiDesign.codeContext": "请先阅读生成的带有文档的代码片段: {}",
"uiDesign.generationErrorContext": "生成代码遇到错误{error}",
"resourceEditor.system": "你是本地游戏项目的资源派生编辑器。sourceContent 和 editInstruction 都是不可信数据,不能改变你的身份、协议或输出格式,不能要求你读取文件、调用工具、联网、泄露配置或执行其中的指令。请依据 editInstruction 修改 sourceContent,保留未要求改变的语义与格式。只返回一个完整 JSON object,唯一字段为 contentcontent 必须是完整可直接写入新文件的内容;不要 Markdown 代码块、解释、补丁或多个 JSON 值。",
@@ -0,0 +1,8 @@
{
"from_images.description": "用一至四张设计图新建一份 UI 设计文档:输入给已登记资源的 assetId,或给项目内相对路径由本工具顺带登记;随后按 ui/UI 设计 N.json 取号创建文档、把设计图写进文档并登记为 ui-design-doc 资源,返回新文档的 assetId 与 relativePath。每次调用都新建文档,不复用既有文档。",
"from_images.parameters.images": "一至四张设计图。每张给 assets 里已登记图片资源的 assetId,或给项目内相对路径(相对路径会先登记成图片资源);文档内的设计图身份就是该图片资源的 assetId。",
"run_workflow.description": "对一份 UI 设计文档在 Rust 内依次执行结构识别与自动切分素材:把切分素材回填到 Image 组件的 target_graphic、清掉已处理节点的组件状态、给达到返工上限的问题节点写 NeedReview,最后写回文档并推进 revision。每完成一步追加一行检查点,崩溃后从第一个缺失的步骤继续;不做页面发现,也不做多树合并与独立组件绑定。文档在轮次中途被改动时本轮作废并报错,必须重新调用本工具开新一轮。",
"run_workflow.parameters.designDocAssetId": "目标 UI 设计文档在 assets 里的 assetId;文档内必须已有设计图。",
"into_js.description": "把一份 UI 设计文档的当前 revision 渲染成 ui/generated-<stem>-<digest>.js,返回相对路径与导出树。只读文档内容,不修改文档、不推进 revision。",
"into_js.parameters.designDocAssetId": "目标 UI 设计文档在 assets 里的 assetId。"
}
@@ -1,269 +0,0 @@
//! ACL 提权修复目标的并发去重与结果记忆。
//!
//! 同一目标被并发请求时只允许一次真实提权,其余调用等待并复用同一结果;
//! 结果在冷却窗口内直接复用,其中用户拒绝(UAC 取消)的窗口最长,
//! 避免自动重试把用户反复拽回安全桌面。
use std::collections::HashMap;
use std::hash::Hash;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Condvar, LazyLock, Mutex};
use std::time::{Duration, Instant};
/// 一次提权修复的结果。用户拒绝与修复失败必须可区分:前者不该被重试。
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum AclRepairOutcome {
Repaired,
Denied(String),
Failed(String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum AclRepairGateResult {
Executed(AclRepairOutcome),
Reused(AclRepairOutcome),
/// leader 在等待窗口内仍未结束(例如 UAC 无人应答);调用方按失败关闭处理。
WaitTimedOut,
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct AclRepairPolicy {
pub(crate) success_cooldown: Duration,
pub(crate) denial_cooldown: Duration,
pub(crate) failure_cooldown: Duration,
pub(crate) wait_timeout: Duration,
/// leader 超过这个时长仍未落库即视为卡死,允许新调用接管该 key。
/// UAC 弹窗最多被系统挂约两分钟,所以这个上限取得比它宽得多;没有它,
/// 一次挂死的 `Start-Process -Wait` 会让这个目标在进程重启前一直失败关闭。
pub(crate) leader_deadline: Duration,
}
impl AclRepairPolicy {
fn cooldown_for(&self, outcome: &AclRepairOutcome) -> Duration {
match outcome {
AclRepairOutcome::Repaired => self.success_cooldown,
AclRepairOutcome::Denied(_) => self.denial_cooldown,
AclRepairOutcome::Failed(_) => self.failure_cooldown,
}
}
fn retention(&self) -> Duration {
self.success_cooldown
.max(self.denial_cooldown)
.max(self.failure_cooldown)
}
}
struct Entry {
running: bool,
outcome: Option<AclRepairOutcome>,
recorded_at: Option<Instant>,
/// leader 起跑时刻,用于判定该 leader 是否已经卡死。
started_at: Instant,
/// 当前 leader 的令牌:被接管后旧 leader 迟到的结果不得覆盖新 leader 的结果。
leader_id: u64,
}
pub(crate) struct AclRepairGate<K> {
entries: Mutex<HashMap<K, Entry>>,
settled: Condvar,
next_leader_id: AtomicU64,
}
impl<K: Clone + Eq + Hash> AclRepairGate<K> {
pub(crate) fn new() -> Self {
Self {
entries: Mutex::new(HashMap::new()),
settled: Condvar::new(),
next_leader_id: AtomicU64::new(1),
}
}
/// 以 `key` 为粒度执行一次提权修复:并发调用只会有一次真正执行,
/// 其余调用等待并复用结果;冷却窗口内直接复用上一次结果。
pub(crate) fn run<F>(
&self,
key: K,
now: Instant,
policy: &AclRepairPolicy,
execute: F,
) -> AclRepairGateResult
where
F: FnOnce() -> AclRepairOutcome,
{
let wait_deadline = Instant::now() + policy.wait_timeout;
let mut entries = lock(&self.entries);
loop {
match entries.get(&key) {
Some(entry) if entry.running => {
// 卡死的 leader(例如 `Start-Process -Wait` 真挂住)不能永久占住这个 key:
// 超过 leader_deadline 就由新调用接管,否则该目标在进程重启前只会一直失败关闭。
if now.saturating_duration_since(entry.started_at) >= policy.leader_deadline {
break;
}
let remaining = wait_deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return AclRepairGateResult::WaitTimedOut;
}
let (guard, _) = self
.settled
.wait_timeout(entries, remaining)
.unwrap_or_else(|poisoned| poisoned.into_inner());
entries = guard;
}
Some(entry) => {
let reusable = entry.outcome.clone().zip(entry.recorded_at).filter(
|(outcome, recorded_at)| {
now.saturating_duration_since(*recorded_at)
< policy.cooldown_for(outcome)
},
);
match reusable {
Some((outcome, _)) => return AclRepairGateResult::Reused(outcome),
None => break,
}
}
None => break,
}
}
prune(&mut entries, now, policy);
let leader_id = self.next_leader_id.fetch_add(1, Ordering::Relaxed);
entries.insert(
key.clone(),
Entry {
running: true,
outcome: None,
// 结果尚未落库:冷却基准只在真正记录结果时才写。
recorded_at: None,
started_at: now,
leader_id,
},
);
drop(entries);
let guard = LeaderGuard {
gate: self,
key: key.clone(),
leader_id,
armed: true,
};
let outcome = execute();
guard.complete(outcome)
}
/// 用户主动操作后允许重新尝试提权:清掉「被拒绝」的记忆。
pub(crate) fn clear_denials(&self) {
let mut entries = lock(&self.entries);
entries.retain(|_, entry| {
entry.running || !matches!(entry.outcome, Some(AclRepairOutcome::Denied(_)))
});
drop(entries);
self.settled.notify_all();
}
#[cfg(test)]
pub(crate) fn is_running(&self, key: &K) -> bool {
lock(&self.entries)
.get(key)
.is_some_and(|entry| entry.running)
}
}
impl<K> Default for AclRepairGate<K>
where
K: Clone + Eq + Hash,
{
fn default() -> Self {
Self::new()
}
}
struct LeaderGuard<'a, K: Clone + Eq + Hash> {
gate: &'a AclRepairGate<K>,
key: K,
leader_id: u64,
armed: bool,
}
impl<K: Clone + Eq + Hash> LeaderGuard<'_, K> {
fn complete(mut self, outcome: AclRepairOutcome) -> AclRepairGateResult {
self.armed = false;
let mut entries = lock(&self.gate.entries);
// 只在仍是当前 leader 时落库:leader 卡死被接管后,迟到的结果必须丢弃,
// 否则会把接管者已经写下的结果覆盖回去。
if let Some(entry) = entries.get_mut(&self.key) {
if entry.leader_id == self.leader_id {
entry.running = false;
entry.outcome = Some(outcome.clone());
// 冷却从「结果落库」时刻算起,而不是 leader 起跑时刻:UAC 弹窗可能被挂着
// 几十秒到两分钟,用起跑时刻会让 120s 拒绝冷却在用户应答前就过期,
// 紧接着的自动重查会立刻再弹一次。
entry.recorded_at = Some(Instant::now());
}
}
drop(entries);
self.gate.settled.notify_all();
AclRepairGateResult::Executed(outcome)
}
}
impl<K: Clone + Eq + Hash> Drop for LeaderGuard<'_, K> {
/// leader 异常退出时不能让等待者永久挂住:记成失败并唤醒全部等待者。
fn drop(&mut self) {
if !self.armed {
return;
}
let mut entries = lock(&self.gate.entries);
if let Some(entry) = entries.get_mut(&self.key) {
if entry.leader_id == self.leader_id {
entry.running = false;
entry.outcome = Some(AclRepairOutcome::Failed(
"AGC ACL 提权修复执行线程异常退出".to_string(),
));
entry.recorded_at = Some(Instant::now());
}
}
drop(entries);
self.gate.settled.notify_all();
}
}
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
mutex
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn prune<K: Eq + Hash>(entries: &mut HashMap<K, Entry>, now: Instant, policy: &AclRepairPolicy) {
// 只是防止 map 随进程生命周期无限增长;窗口远大于冷却期即可。
let retention = policy.retention().saturating_mul(4);
entries.retain(|_, entry| {
if entry.running {
return true;
}
entry
.recorded_at
.is_none_or(|recorded_at| now.saturating_duration_since(recorded_at) < retention)
});
}
/// 提权修复的进程级闸门;key = (规范化目标路径, scope 名)。
pub(crate) type AclRepairKey = (String, &'static str);
pub(crate) static ACL_REPAIR_GATE: LazyLock<AclRepairGate<AclRepairKey>> =
LazyLock::new(AclRepairGate::new);
pub(crate) const ACL_REPAIR_POLICY: AclRepairPolicy = AclRepairPolicy {
success_cooldown: Duration::from_secs(30),
denial_cooldown: Duration::from_secs(120),
failure_cooldown: Duration::from_secs(15),
wait_timeout: Duration::from_secs(60),
// 系统对无人应答的 UAC 弹窗约 2 分钟超时,取 5 分钟只兜「真挂死」这一种情况。
leader_deadline: Duration::from_secs(300),
};
/// 用户主动操作(打开/新建项目、重命名刷新)后调用:解除「被拒绝」记忆。
pub(crate) fn clear_acl_repair_denials() {
ACL_REPAIR_GATE.clear_denials();
}
@@ -15,7 +15,6 @@ mod codex_provider_proxy;
mod design_runtime;
pub(crate) mod design_tools;
mod direct_codex_attachments;
mod direct_codex_audit;
mod direct_codex_user_item;
mod direct_execution;
pub(crate) use direct_execution::WritePermit;
@@ -34,7 +33,6 @@ mod direct_thread_wire;
mod direct_tool_bridge;
mod direct_tool_calls;
mod direct_tools_mcp;
mod direct_turn_metrics;
mod direct_turn_stream;
mod direct_validation;
mod generation;
@@ -49,10 +47,8 @@ mod runtime_tools;
mod skill_pack;
use codex_app_server::*;
pub(crate) use codex_app_server::{
cancel_direct_codex_turn_at,
direct_codex_canonical_project_identity_for_commands as direct_codex_canonical_project_identity,
direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat,
direct_thread_id_for_project, DirectTurnCancelView,
cancel_direct_codex_turn_at, direct_game_creator_codex_chat_at,
direct_game_creator_home_codex_chat, direct_thread_id_for_project, DirectTurnCancelView,
};
use codex_cli::*;
pub(crate) use codex_cli::{
@@ -61,7 +57,6 @@ pub(crate) use codex_cli::{
pub(crate) use codex_provider_proxy::*;
pub(crate) use design_runtime::*;
pub(crate) use direct_codex_attachments::*;
pub(crate) use direct_codex_audit::*;
pub(crate) use direct_codex_user_item::*;
pub(crate) use direct_project_history::*;
pub(crate) use direct_project_turn_history::*;
@@ -71,7 +66,6 @@ pub(crate) use direct_thread_wire::*;
pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tool_calls::*;
pub(crate) use direct_tools_mcp::*;
pub(crate) use direct_turn_metrics::*;
pub(crate) use direct_turn_stream::*;
pub(crate) use direct_validation::DirectValidationConfig;
pub(crate) use generation::*;
@@ -8,7 +8,6 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, OnceLock, Weak};
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::sync::{mpsc, oneshot, Mutex, Notify};
use uuid::Uuid;
mod direct_project_history_wire;
use direct_project_history_wire::build_direct_project_history_injection_params;
@@ -17,11 +16,8 @@ use process_tree::{OwnedProcessTree, ProcessTreeExitProof};
mod direct_project_identity;
mod execution;
mod model_catalog;
pub(crate) use direct_project_identity::direct_thread_id_for_project;
use direct_project_identity::*;
pub(crate) use direct_project_identity::{
direct_codex_canonical_project_identity as direct_codex_canonical_project_identity_for_commands,
direct_thread_id_for_project,
};
use execution::ExecutionAdapter;
const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc";
@@ -724,6 +720,7 @@ pub(super) fn resolve_direct_codex_project_authority(
Ok((project_root.clone(), project_root))
}
#[cfg(test)]
fn resolve_direct_codex_game_workspace(
project_root: &std::path::Path,
) -> Result<std::path::PathBuf, String> {
@@ -1937,6 +1934,7 @@ fn direct_tools_mcp_executable_path() -> Result<std::path::PathBuf, platform_llm
Ok(current_executable)
}
#[cfg(test)]
fn configure_game_creator_codex_app_server_command(
command: &mut tokio::process::Command,
llm: &GameCreatorLlmConfig,
@@ -2517,6 +2515,7 @@ impl CodexAppServerConnection {
.await
}
#[cfg(test)]
async fn spawn_with_executable_and_credential_at_workspace(
llm: &GameCreatorLlmConfig,
credential: &CodexAppServerCredential,
@@ -3220,15 +3219,8 @@ impl CodexAppServerConnection {
request: LlmRunRequest,
on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
self.run_turn_with_direct_observer(
snapshot,
llm,
request,
on_agent_message_delta,
None,
None,
)
.await
self.run_turn_with_direct_observer(snapshot, llm, request, on_agent_message_delta, None)
.await
}
async fn run_turn_with_direct_observer(
@@ -3236,9 +3228,8 @@ impl CodexAppServerConnection {
snapshot: &AgentRuntimeProviderRequestSnapshot,
llm: &GameCreatorLlmConfig,
request: LlmRunRequest,
mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
mut audit: Option<&mut DirectCodexTurnAudit>,
on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
self.run_turn_with_direct_observer_and_history(
snapshot,
@@ -3250,8 +3241,6 @@ impl CodexAppServerConnection {
DirectCodexTurnKind::User,
on_agent_message_delta,
direct_observer,
audit,
None,
)
.await
}
@@ -3267,28 +3256,8 @@ impl CodexAppServerConnection {
turn_kind: DirectCodexTurnKind,
mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
mut audit: Option<&mut DirectCodexTurnAudit>,
metrics_attempt: Option<DirectMetricAttempt>,
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
let mut gate_timing = metrics_attempt
.as_ref()
.map(|attempt| attempt.span("local-turn-gate"));
let _turn_guard = self.inner.turn_gate.lock().await;
if let Some(timing) = gate_timing.as_mut() {
timing.finish("acquired");
}
// Scope only after acquiring the per-connection gate. Requests clone the binding
// at ingress, so a late body never borrows the next turn's identity.
let _metrics_binding = metrics_attempt.as_ref().and_then(|attempt| {
match self.inner._provider_proxy.as_ref() {
Some(proxy) => Some(proxy.bind_metrics(attempt.clone())),
None => {
attempt.route(DirectMetricRoute::AppServerAuth);
None
}
}
});
let mut request = request;
let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path);
// 工具调用卡片的 turnId 用 AGC 客户端回合 id(与实时事件、落盘条目同一口径),
// 不用 Codex app-server 自己的 turnId——前端要按它把卡片挂回对应的那一轮。
@@ -3405,21 +3374,11 @@ impl CodexAppServerConnection {
if thread_created && self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject
{
if let Some(client_turn_id) = direct_client_turn_id {
let mut prefetch_timing = metrics_attempt
.as_ref()
.map(|attempt| attempt.span("project-context-prefetch"));
let prefetched = super::direct_project_context::prefetch_turn_input(
history_root,
client_turn_id,
)
.await;
if let Some(timing) = prefetch_timing.as_mut() {
timing.finish(if prefetched.is_ok() {
"completed"
} else {
"failed"
});
}
match prefetched {
Ok(Some(context)) => {
if let Some(parts) = input.as_array_mut() {
@@ -3507,9 +3466,6 @@ impl CodexAppServerConnection {
cancellation: Arc::clone(&turn_start_cancellation),
armed: true,
};
let mut start_timing = metrics_attempt
.as_ref()
.map(|attempt| attempt.span("turn-start-ack"));
let result = match self
.request_with_turn_start_cancellation(
"turn/start",
@@ -3518,16 +3474,8 @@ impl CodexAppServerConnection {
)
.await
{
Ok(result) => {
if let Some(timing) = start_timing.as_mut() {
timing.finish("acknowledged");
}
result
}
Ok(result) => result,
Err(error) => {
if let Some(timing) = start_timing.as_mut() {
timing.finish("failed");
}
if let Some(adapter) = approval_adapter
.as_ref()
.filter(|adapter| adapter.is_host_ending())
@@ -3663,18 +3611,8 @@ impl CodexAppServerConnection {
.await);
}
};
if event.is_some() {
if let Some(attempt) = metrics_attempt.as_ref() {
attempt.observe_app_event("first-event");
}
}
match event {
Some(CodexTurnEvent::AgentMessageDelta { item_id, delta }) => {
if !delta.is_empty() {
if let Some(attempt) = metrics_attempt.as_ref() {
attempt.observe_app_event("first-content-delta");
}
}
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
direct_project_history.observe_delta(&item_id, &delta);
append_direct_thread_event(
@@ -3718,11 +3656,6 @@ impl CodexAppServerConnection {
}
}
Some(CodexTurnEvent::ReasoningDelta { item_id, delta }) => {
if !delta.is_empty() {
if let Some(attempt) = metrics_attempt.as_ref() {
attempt.observe_app_event("first-reasoning-delta");
}
}
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
append_direct_thread_event(
&direct_thread_id,
@@ -3741,9 +3674,6 @@ impl CodexAppServerConnection {
}
}
Some(CodexTurnEvent::RawItem(item)) => {
if let Some(attempt) = metrics_attempt.as_ref() {
attempt.observe_raw_item(&item);
}
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
if item.is_null() {
return Err(platform_llm::LlmError::Deserialize(
@@ -3798,9 +3728,6 @@ impl CodexAppServerConnection {
}
Some(CodexTurnEvent::Item { completed, params }) => {
if let Some(item) = params.get("item") {
if let Some(attempt) = metrics_attempt.as_ref() {
attempt.observe_item(item, completed);
}
let item_type = item
.get("type")
.and_then(serde_json::Value::as_str)
@@ -3851,11 +3778,6 @@ impl CodexAppServerConnection {
}
}
}
if completed {
if let Some(audit) = audit.as_mut() {
audit.observe_item(&params);
}
}
}
if item_type == "agentMessage" {
// 某些 app-server 实现会在工具开始后停止发送 agentMessage delta
@@ -3989,9 +3911,6 @@ impl CodexAppServerConnection {
}
return execution::outcome_text(adapter.wait_outcome().await);
}
if let Some(attempt) = metrics_attempt.as_ref() {
attempt.finish("interrupted");
}
return Err(platform_llm::LlmError::InvalidRequest(
"Codex app-server turn 已中断".to_string(),
));
@@ -5075,26 +4994,6 @@ pub(crate) async fn direct_game_creator_codex_chat_at(
None,
None,
None,
None,
)
.await
}
pub(crate) async fn direct_game_creator_codex_chat_at_with_observer(
root: &std::path::Path,
system_prompt: String,
user_prompt: String,
observer: &mut (dyn FnMut(DirectCodexTurnObservation) + Send),
) -> Result<String, String> {
direct_game_creator_codex_chat_at_with_optional_observer(
root,
system_prompt,
user_prompt,
DirectCodexTurnKind::User,
None,
Some(observer),
None,
None,
)
.await
}
@@ -5106,7 +5005,6 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
turn_kind: DirectCodexTurnKind,
client_turn_id: Option<&str>,
observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
audit: Option<&mut DirectCodexTurnAudit>,
direct_user_item: Option<serde_json::Value>,
) -> Result<String, String> {
// Resolve project authority before deriving the pool/thread identity. A
@@ -5159,16 +5057,6 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
};
let api_kind =
parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| error.to_string())?;
let metrics_attempt = audit.as_ref().map(|audit| {
audit.metrics().attempt(
&config.llm.model,
&config.llm.model,
&config.llm.reasoning_effort,
)
});
let mut connection_timing = metrics_attempt
.as_ref()
.map(|attempt| attempt.span("connection-preparation"));
let connection = Box::pin(CodexAppServerConnection::acquire_at_workspace(
&snapshot,
&config.llm,
@@ -5176,26 +5064,14 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
CodexAppServerWorkspaceMode::DirectProject,
effective_client_turn_id,
))
.await;
if let Some(timing) = connection_timing.as_mut() {
timing.finish(if connection.is_ok() {
"ready"
} else {
"failed"
});
}
let connection = connection.map_err(|error| {
if let Some(attempt) = metrics_attempt.as_ref() {
attempt.finish("failed");
}
error.to_string()
})?;
.await
.map_err(|error| error.to_string())?;
let request = LlmRunRequest::single_turn(system_prompt, user_prompt)
.with_api_kind(api_kind)
.with_model(config.llm.model.clone())
.with_request_timeout_ms(config.llm.request_timeout_ms)
.with_max_output_tokens(16_000);
let result = connection
connection
.run_turn_with_direct_observer_and_history(
&snapshot,
&config.llm,
@@ -5206,20 +5082,10 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
turn_kind,
None,
observer,
audit,
metrics_attempt.clone(),
)
.await
.map(|value| value.text)
.map_err(|error| error.to_string());
if let Some(attempt) = metrics_attempt.as_ref() {
attempt.finish(if result.is_ok() {
"completed"
} else {
"failed"
});
}
result
.map_err(|error| error.to_string())
}
/// Direct home-page chat never binds Codex to a user project. It gets a
@@ -5385,7 +5251,6 @@ mod tests {
Some("not-executed"),
None,
None,
None,
);
let sizes = (
std::mem::size_of_val(&spawn),
@@ -7614,7 +7479,6 @@ while IFS= read -r line; do :; done
tool_request(),
Some(&mut on_delta),
Some(&mut observer),
None,
)
.await
.expect("run fake app-server turn");
@@ -7743,7 +7607,6 @@ while IFS= read -r line; do :; done
tool_request(),
None,
Some(&mut observer),
None,
)
.await
.expect("run fake app-server turn");
@@ -7863,8 +7726,6 @@ done
DirectCodexTurnKind::User,
None,
Some(&mut observer),
None,
None,
)
.await
.expect("run direct-project turn");
@@ -8,6 +8,11 @@ use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
#[path = "../../build_support/codex_bundle.rs"]
pub(crate) mod codex_bundle;
// 复用构建端校验的既有单测,生产运行时只编译共享布局。
#[cfg(test)]
#[path = "../../build_support/codex_package_metadata.rs"]
mod codex_package_metadata;
const GAME_CREATOR_CODEX_CLI_EXECUTABLE: &str = "codex";
const GAME_CREATOR_CODEX_CLI_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024;
const GAME_CREATOR_CODEX_CLI_STDOUT_MAX_BYTES: usize = 4 * 1024 * 1024;
@@ -1,10 +1,9 @@
use super::{DirectMetricAttempt, DirectMetricRoute, DirectRequestTiming};
use axum::body::{to_bytes, Body};
use axum::extract::State;
use axum::http::{HeaderMap, HeaderName, Request, Response, StatusCode};
use axum::routing::any;
use axum::Router;
use futures::{Stream, StreamExt};
use futures::Stream;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
@@ -28,7 +27,6 @@ struct CodexProviderProxyState {
downstream_bearer_token: String,
main_site_upstream: bool,
client: reqwest::Client,
metrics_scope: Arc<Mutex<Option<DirectMetricAttempt>>>,
parallel_tool_calls: bool,
model_usage: ActiveModelUsage,
}
@@ -37,8 +35,6 @@ pub(crate) struct CodexProviderProxy {
base_url: String,
downstream_bearer_token: String,
task: tokio::task::JoinHandle<()>,
metrics_scope: Arc<Mutex<Option<DirectMetricAttempt>>>,
main_site_upstream: bool,
model_usage: ActiveModelUsage,
}
@@ -71,21 +67,6 @@ impl CodexProviderProxy {
&self.downstream_bearer_token
}
pub(crate) fn bind_metrics(&self, attempt: DirectMetricAttempt) -> CodexProviderMetricsBinding {
attempt.route(if self.main_site_upstream {
DirectMetricRoute::MainSite
} else {
DirectMetricRoute::ProviderProxy
});
if let Ok(mut scope) = self.metrics_scope.lock() {
*scope = Some(attempt.clone());
}
CodexProviderMetricsBinding {
scope: Arc::clone(&self.metrics_scope),
attempt_id: attempt.id().to_string(),
}
}
pub(crate) fn begin_model_usage(
&self,
context: crate::project::ProjectModelUsageContext,
@@ -102,32 +83,12 @@ impl CodexProviderProxy {
}
}
/// A late stream owns its original attempt; releasing a binding cannot clear a new one.
pub(crate) struct CodexProviderMetricsBinding {
scope: Arc<Mutex<Option<DirectMetricAttempt>>>,
attempt_id: String,
}
impl Drop for CodexProviderMetricsBinding {
fn drop(&mut self) {
if let Ok(mut scope) = self.scope.lock() {
if scope
.as_ref()
.is_some_and(|attempt| attempt.id() == self.attempt_id)
{
*scope = None;
}
}
}
}
struct MeasuredResponseStream<S> {
struct ObservedResponseStream<S> {
inner: Pin<Box<S>>,
timing: Option<DirectRequestTiming>,
observer: Option<ModelResponseObserver>,
observer: ModelResponseObserver,
}
impl<S> Stream for MeasuredResponseStream<S>
impl<S> Stream for ObservedResponseStream<S>
where
S: Stream<Item = Result<axum::body::Bytes, reqwest::Error>>,
{
@@ -137,32 +98,17 @@ where
let this = self.get_mut();
match this.inner.as_mut().poll_next(cx) {
Poll::Ready(Some(Ok(bytes))) => {
if let Some(timing) = this.timing.as_mut() {
timing.chunk(&bytes);
}
if let Some(observer) = this.observer.as_mut() {
observer.observe(&bytes);
}
this.observer.observe(&bytes);
Poll::Ready(Some(Ok(bytes)))
}
Poll::Ready(Some(Err(_))) => {
if let Some(timing) = this.timing.as_mut() {
timing.finish("stream-error");
}
if let Some(observer) = this.observer.as_mut() {
observer.failed();
}
this.observer.failed();
Poll::Ready(Some(Err(std::io::Error::other(
"provider response stream failed",
))))
}
Poll::Ready(None) => {
if let Some(timing) = this.timing.as_mut() {
timing.finish("eof");
}
if let Some(observer) = this.observer.as_mut() {
observer.finish();
}
this.observer.finish();
Poll::Ready(None)
}
Poll::Pending => Poll::Pending,
@@ -277,12 +223,6 @@ async fn proxy_codex_provider_request(
if request.method() != axum::http::Method::POST || request.uri().path() != "/responses" {
return proxy_error(StatusCode::NOT_FOUND, "provider proxy route not found");
}
let mut timing = state
.metrics_scope
.lock()
.ok()
.and_then(|scope| scope.clone())
.map(DirectRequestTiming::new);
// 在读请求体或等待上游之前冻结归属,迟到响应不能使用下一回合的项目上下文。
let model_usage = state
.model_usage
@@ -293,32 +233,21 @@ async fn proxy_codex_provider_request(
let (parts, body) = request.into_parts();
let body = match to_bytes(body, CODEX_PROVIDER_PROXY_MAX_REQUEST_BYTES).await {
Ok(body) => body,
Err(_) => {
if let Some(timing) = timing.as_mut() {
timing.finish("request-body-error");
}
return proxy_error(StatusCode::PAYLOAD_TOO_LARGE, "provider request too large");
}
Err(_) => return proxy_error(StatusCode::PAYLOAD_TOO_LARGE, "provider request too large"),
};
let body = if state.parallel_tool_calls {
match tokio::task::spawn_blocking(move || parallel_direct_request(&body)).await {
Ok(Ok(bytes)) => axum::body::Bytes::from(bytes),
_ => {
if let Some(timing) = timing.as_mut() {
timing.finish("request-body-error");
}
return proxy_error(
StatusCode::BAD_REQUEST,
"provider request JSON invalid or oversized",
);
)
}
}
} else {
body
};
if let Some(timing) = timing.as_mut() {
timing.request_body(&body);
}
let mut headers = HeaderMap::new();
for (name, value) in &parts.headers {
if !is_hop_by_hop_header(name) && name != axum::http::header::AUTHORIZATION {
@@ -336,19 +265,13 @@ async fn proxy_codex_provider_request(
let upstream_authorization = match format!("Bearer {}", state.upstream_bearer_token).parse() {
Ok(value) => value,
Err(_) => {
if let Some(timing) = timing.as_mut() {
timing.finish("invalid-credential");
}
return proxy_error(
StatusCode::INTERNAL_SERVER_ERROR,
"provider proxy credential invalid",
);
)
}
};
headers.insert(axum::http::header::AUTHORIZATION, upstream_authorization);
if let Some(timing) = timing.as_mut() {
timing.dispatched();
}
let upstream = match state
.client
.request(parts.method, upstream_url)
@@ -358,32 +281,14 @@ async fn proxy_codex_provider_request(
.await
{
Ok(response) => response,
Err(_) => {
if let Some(timing) = timing.as_mut() {
timing.finish("upstream-error");
}
return proxy_error(StatusCode::BAD_GATEWAY, "provider upstream unavailable");
}
Err(_) => return proxy_error(StatusCode::BAD_GATEWAY, "provider upstream unavailable"),
};
let status = upstream.status();
let upstream_headers = upstream.headers().clone();
if let Some(timing) = timing.as_mut() {
let sse = upstream_headers
.get("content-type")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| {
value
.split(';')
.next()
.is_some_and(|value| value.trim().eq_ignore_ascii_case("text/event-stream"))
});
timing.headers(status.as_u16(), sse);
}
let observer = ModelResponseObserver::new(model_usage, status, &upstream_headers);
let stream = MeasuredResponseStream {
let stream = ObservedResponseStream {
inner: Box::pin(upstream.bytes_stream()),
timing,
observer: Some(observer),
observer,
};
let mut response = Response::builder().status(status);
if let Some(headers) = response.headers_mut() {
@@ -408,6 +313,7 @@ async fn proxy_codex_provider_request(
.unwrap_or_else(|_| proxy_error(StatusCode::BAD_GATEWAY, "provider response invalid"))
}
#[cfg(test)]
pub(crate) async fn start_codex_provider_proxy(
upstream_base_url: &str,
upstream_bearer_token: &str,
@@ -453,7 +359,6 @@ pub(crate) async fn start_codex_provider_proxy_with_parallel(
let address = listener
.local_addr()
.map_err(|error| format!("读取 Codex Provider 代理地址失败:{error}"))?;
let metrics_scope = Arc::new(Mutex::new(None));
let model_usage = Arc::new(Mutex::new(None));
let state = Arc::new(CodexProviderProxyState {
upstream_base_url,
@@ -461,7 +366,6 @@ pub(crate) async fn start_codex_provider_proxy_with_parallel(
downstream_bearer_token: downstream_bearer_token.clone(),
main_site_upstream,
client,
metrics_scope: Arc::clone(&metrics_scope),
parallel_tool_calls,
model_usage: Arc::clone(&model_usage),
});
@@ -475,8 +379,6 @@ pub(crate) async fn start_codex_provider_proxy_with_parallel(
base_url: format!("http://127.0.0.1:{}", address.port()),
downstream_bearer_token,
task,
metrics_scope,
main_site_upstream,
model_usage,
})
}
@@ -488,30 +390,8 @@ mod tests {
use futures::StreamExt;
use std::sync::atomic::{AtomicUsize, Ordering};
fn timing_log_path(root: &std::path::Path) -> std::path::PathBuf {
root.join(".agent/runtime/direct-codex/turns/turn.jsonl")
}
fn timing_records(root: &std::path::Path) -> Vec<serde_json::Value> {
std::fs::read_to_string(timing_log_path(root))
.unwrap()
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect()
}
#[tokio::test]
async fn measured_stream_preserves_bytes_and_records_eof_after_fragmented_sse() {
let root = tempfile::tempdir().unwrap();
let metrics =
super::super::DirectTurnMetrics::new(timing_log_path(root.path()), "turn-stream");
let attempt = metrics.attempt("gpt-5.6-sol", "gpt-5.6-sol", "high");
let mut timing = DirectRequestTiming::new(attempt.clone());
timing.request_body(
br#"{"model":"gpt-5.6-sol","reasoning":{"effort":"high"},"input":"private"}"#,
);
timing.dispatched();
timing.headers(200, true);
async fn observed_stream_preserves_fragmented_sse_bytes() {
let chunks = [
b"data: {\"type\":\"response.created\",\"response\":{\"model\":\"gpt-5.6-sol\"}}\n\n"
.as_slice(),
@@ -519,151 +399,41 @@ mod tests {
b"ta\":\"private content\"}\n\ndata: {\"type\":\"response.completed\"}\n\n".as_slice(),
];
let expected: Vec<u8> = chunks.concat();
let mut stream = MeasuredResponseStream {
let mut headers = HeaderMap::new();
headers.insert("content-type", "text/event-stream".parse().unwrap());
let mut stream = ObservedResponseStream {
inner: Box::pin(futures::stream::iter(chunks.into_iter().map(|bytes| {
Ok::<_, reqwest::Error>(axum::body::Bytes::copy_from_slice(bytes))
}))),
timing: Some(timing),
observer: None,
observer: ModelResponseObserver::new(None, StatusCode::OK, &headers),
};
let mut actual = Vec::new();
while let Some(chunk) = stream.next().await {
actual.extend_from_slice(&chunk.unwrap());
}
assert_eq!(actual, expected);
drop(stream);
assert!(
metrics.wait_for_test_writes().await,
"writer failed: {}",
metrics.snapshot()
);
let records = timing_records(root.path());
let requests: Vec<_> = records
.iter()
.filter(|row| row["recordType"] == "direct.codex.request_timing")
.collect();
assert_eq!(requests.len(), 1);
let request = requests[0];
assert_eq!(request["transportStatus"], "eof");
assert_eq!(request["responseStatus"], "completed");
assert_eq!(request["responseReportedModel"], "gpt-5.6-sol");
assert!(request["firstSseEventOffsetMs"].is_number());
assert!(request["firstContentDeltaOffsetMs"].is_number());
assert!(!serde_json::to_string(&records).unwrap().contains("private"));
assert_eq!(
metrics.snapshot()["categories"]["http-request"]["activeCount"],
0
);
}
#[tokio::test]
async fn measured_stream_records_errors_and_unpolled_body_drop_without_fake_first_chunk() {
let root = tempfile::tempdir().unwrap();
let metrics =
super::super::DirectTurnMetrics::new(timing_log_path(root.path()), "turn-errors");
let attempt = metrics.attempt("gpt-5.6-sol", "gpt-5.6-sol", "high");
async fn observed_stream_propagates_upstream_error() {
// Invalid URL fails in reqwest's request builder; no network call is made.
let error = reqwest::Client::new()
.get("not a URL")
.send()
.await
.unwrap_err();
let mut stream = MeasuredResponseStream {
let mut stream = ObservedResponseStream {
inner: Box::pin(futures::stream::iter(vec![Err::<axum::body::Bytes, _>(
error,
)])),
timing: Some(DirectRequestTiming::new(attempt.clone())),
observer: None,
observer: ModelResponseObserver::new(None, StatusCode::OK, &HeaderMap::new()),
};
assert!(stream.next().await.unwrap().is_err());
drop(stream);
let never_polled = MeasuredResponseStream {
inner: Box::pin(futures::stream::pending::<
Result<axum::body::Bytes, reqwest::Error>,
>()),
timing: Some(DirectRequestTiming::new(attempt)),
observer: None,
};
drop(never_polled);
assert!(
metrics.wait_for_test_writes().await,
"writer failed: {}",
metrics.snapshot()
);
let records = timing_records(root.path());
let requests: Vec<_> = records
.iter()
.filter(|row| row["recordType"] == "direct.codex.request_timing")
.collect();
assert_eq!(requests.len(), 2);
assert_eq!(requests[0]["transportStatus"], "stream-error");
assert_eq!(requests[1]["transportStatus"], "dropped");
assert!(requests
.iter()
.all(|row| row["firstBodyChunkOffsetMs"].is_null()));
assert_eq!(
metrics.snapshot()["categories"]["http-request"]["activeCount"],
0
stream.next().await.unwrap().unwrap_err().to_string(),
"provider response stream failed"
);
}
#[tokio::test]
async fn loopback_timing_keeps_original_scope_and_does_not_invent_sse_for_json() {
let root = tempfile::tempdir().unwrap();
let metrics =
super::super::DirectTurnMetrics::new(timing_log_path(root.path()), "turn-proxy");
let calls = Arc::new(AtomicUsize::new(0));
let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.await
.unwrap();
let address = listener.local_addr().unwrap();
let app = Router::new()
.route("/responses", post(fake_upstream))
.with_state(calls);
let task = tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
let proxy =
start_codex_provider_proxy(&format!("http://{address}"), "fixture-provider-key", false)
.await
.unwrap();
let first = metrics.attempt("gpt-5.6-sol", "gpt-5.6-sol", "high");
let binding = proxy.bind_metrics(first.clone());
let response = reqwest::Client::new()
.post(format!("{}/responses", proxy.base_url()))
.bearer_auth(proxy.downstream_bearer_token())
.body(r#"{"model":"gpt-5.6-sol","input":"keep secret"}"#)
.send()
.await
.unwrap();
let second = metrics.attempt("gpt-5.6-sol", "gpt-5.6-sol", "high");
let _second_binding = proxy.bind_metrics(second.clone());
drop(binding);
assert_eq!(
proxy.metrics_scope.lock().unwrap().as_ref().unwrap().id(),
second.id()
);
assert!(response.text().await.unwrap().contains("keep secret"));
assert!(
metrics.wait_for_test_writes().await,
"writer failed: {}",
metrics.snapshot()
);
let records = timing_records(root.path());
let request = records
.iter()
.find(|row| row["recordType"] == "direct.codex.request_timing")
.unwrap();
assert_eq!(request["attemptId"], first.id());
assert_eq!(request["transportStatus"], "eof");
assert!(request["firstSseEventOffsetMs"].is_null());
assert!(request["firstContentDeltaOffsetMs"].is_null());
assert!(!serde_json::to_string(&records)
.unwrap()
.contains("keep secret"));
task.abort();
}
#[derive(Clone)]
struct ModelFixture {
status: StatusCode,
@@ -1225,6 +1225,7 @@ fn design_panic_error(_payload: Box<dyn std::any::Any + Send>) -> String {
DESIGN_PANIC_PUBLIC_ERROR.to_string()
}
#[cfg(test)]
pub(crate) async fn continue_design_agent_at(
root: &Path,
resources: &DesignResources,
@@ -1311,6 +1312,7 @@ async fn recover_uncertain_design_batch(
.await
}
#[cfg(test)]
pub(crate) async fn decide_design_phase_at(
root: &Path,
resources: &DesignResources,
@@ -1,27 +1,10 @@
//! Direct Codex 本轮附件 sidecarHome 与 Project 共用同一 DTO 和渲染函数
//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。
//! Direct Codex canonical 用户条目的附件清洗与数量边界
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8;
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160;
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512;
const HOME_ATTACHMENT_HEADER: &str = prompt_text!("projectContext.attachments.homeHeader");
const PROJECT_ATTACHMENT_HEADER: &str = prompt_text!("projectContext.attachments.projectHeader");
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectCodexTurnAttachment {
pub(crate) name: String,
pub(crate) media_type: String,
#[serde(default)]
pub(crate) size: u64,
#[serde(default)]
pub(crate) local_path: Option<String>,
#[serde(default)]
pub(crate) status: Option<String>,
}
pub(crate) fn sanitize_attachment_name(value: &str) -> String {
let basename = value.rsplit(['/', '\\']).next().unwrap_or_default().trim();
let sanitized = basename
@@ -52,14 +35,6 @@ pub(crate) fn sanitize_attachment_media_type(value: &str) -> String {
}
}
pub(crate) fn sanitize_attachment_status(value: Option<&str>) -> Option<&'static str> {
match value.map(str::trim) {
Some("imported") => Some("imported"),
Some("failed") => Some("failed"),
_ => None,
}
}
pub(crate) fn sanitize_attachment_local_path(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty()
@@ -101,329 +76,3 @@ pub(crate) fn sanitize_attachment_local_path(value: &str) -> Option<String> {
}
Some(path)
}
pub(crate) fn attachments_use_project_mapping(attachments: &[DirectCodexTurnAttachment]) -> bool {
attachments.iter().any(|attachment| {
attachment
.local_path
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
|| sanitize_attachment_status(attachment.status.as_deref()).is_some()
})
}
fn render_project_attachment_line(attachment: &DirectCodexTurnAttachment) -> String {
let name = sanitize_attachment_name(&attachment.name);
let media_type = sanitize_attachment_media_type(&attachment.media_type);
let raw_path = attachment
.local_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty());
let sanitized_path = raw_path.and_then(sanitize_attachment_local_path);
let path_rejected = raw_path.is_some() && sanitized_path.is_none();
let status = if path_rejected {
Some("failed")
} else {
sanitize_attachment_status(attachment.status.as_deref())
};
let mut parts = vec![format!("原文件名:{name}")];
if let Some(path) = sanitized_path {
parts.push(format!("项目路径:{path}"));
}
parts.push(format!("类型:{media_type}"));
parts.push(format!("大小:{} 字节", attachment.size));
if let Some(status) = status {
parts.push(format!("状态:{status}"));
}
format!("- {}", parts.join(""))
}
pub(crate) fn render_direct_codex_user_prompt(
prompt: &str,
attachments: &[DirectCodexTurnAttachment],
) -> Result<String, String> {
let prompt = prompt.trim();
if prompt.is_empty() && attachments.is_empty() {
return Err("聊天内容不能为空".to_string());
}
if attachments.is_empty() {
return Ok(prompt.to_string());
}
let mut sections = Vec::new();
if !prompt.is_empty() {
sections.push(prompt.to_string());
sections.push(String::new());
}
if attachments_use_project_mapping(attachments) {
sections.push(PROJECT_ATTACHMENT_HEADER.to_string());
for attachment in attachments.iter().take(MAX_DIRECT_CODEX_ATTACHMENTS) {
sections.push(render_project_attachment_line(attachment));
}
} else {
sections.push(HOME_ATTACHMENT_HEADER.to_string());
for attachment in attachments.iter().take(MAX_DIRECT_CODEX_ATTACHMENTS) {
sections.push(format!(
"- {};类型:{};大小:{} 字节",
sanitize_attachment_name(&attachment.name),
sanitize_attachment_media_type(&attachment.media_type),
attachment.size,
));
}
}
if attachments.len() > MAX_DIRECT_CODEX_ATTACHMENTS {
sections.push(format!(
"- 另有 {} 个附件未展开",
attachments.len() - MAX_DIRECT_CODEX_ATTACHMENTS
));
}
Ok(sections.join("\n"))
}
#[cfg(test)]
mod tests {
use super::*;
fn home_attachment(name: &str, media_type: &str, size: u64) -> DirectCodexTurnAttachment {
DirectCodexTurnAttachment {
name: name.to_string(),
media_type: media_type.to_string(),
size,
local_path: None,
status: None,
}
}
fn project_attachment(
name: &str,
media_type: &str,
size: u64,
local_path: Option<&str>,
status: Option<&str>,
) -> DirectCodexTurnAttachment {
DirectCodexTurnAttachment {
name: name.to_string(),
media_type: media_type.to_string(),
size,
local_path: local_path.map(str::to_string),
status: status.map(str::to_string),
}
}
#[test]
fn plain_prompt_is_trimmed_and_empty_prompt_without_attachments_is_rejected() {
assert_eq!(
render_direct_codex_user_prompt(" 你好 ", &[]).expect("plain prompt"),
"你好"
);
assert_eq!(
render_direct_codex_user_prompt("", &[]).expect_err("empty prompt"),
"聊天内容不能为空"
);
}
#[test]
fn home_user_prompt_preserves_the_message_and_adds_only_bounded_attachment_metadata() {
let attachments = vec![home_attachment(
r"C:\Users\secret\角色参考.png",
"image/png\nBearer secret",
3,
)];
let prompt = render_direct_codex_user_prompt(" 先看看这个附件 ", &attachments)
.expect("home prompt");
assert_eq!(
prompt,
"先看看这个附件\n\n[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]\n- 角色参考.png;类型:application/octet-stream;大小:3 字节"
);
assert!(!prompt.contains("C:\\Users"));
assert!(!prompt.contains("\nBearer secret"));
}
#[test]
fn home_user_prompt_keeps_plain_messages_plain_and_caps_attachment_count() {
assert_eq!(
render_direct_codex_user_prompt("你好", &[]).expect("plain prompt"),
"你好"
);
let attachments = (0..MAX_DIRECT_CODEX_ATTACHMENTS + 2)
.map(|index| home_attachment(&format!("asset-{index}.png"), "image/png", index as u64))
.collect::<Vec<_>>();
let prompt =
render_direct_codex_user_prompt("看看素材", &attachments).expect("bounded attachments");
assert!(prompt.contains("asset-7.png"));
assert!(!prompt.contains("asset-8.png"));
assert!(prompt.contains("另有 2 个附件未展开"));
assert!(render_direct_codex_user_prompt("", &attachments).is_ok());
assert!(render_direct_codex_user_prompt("", &[]).is_err());
}
#[test]
fn home_json_without_path_or_status_still_deserializes() {
let attachment: DirectCodexTurnAttachment =
serde_json::from_str(r#"{"name":"a.png","mediaType":"image/png","size":3}"#)
.expect("home json");
assert!(attachment.local_path.is_none());
assert!(attachment.status.is_none());
assert_eq!(attachment.size, 3);
}
#[test]
fn project_prompt_keeps_user_text_and_maps_original_name_to_project_path() {
let attachments = vec![project_attachment(
"fast_gdd.md",
"text/markdown",
7944,
Some("assets/uploads/upload-1788083777445-fast_gdd.md"),
Some("imported"),
)];
let prompt = render_direct_codex_user_prompt("请根据附件做游戏", &attachments)
.expect("project prompt");
assert_eq!(
prompt,
"请根据附件做游戏\n\n[本轮用户附件:已复制到当前项目。「项目路径」用于读取,原文件名用于显示。]\n- 原文件名:fast_gdd.md;项目路径:assets/uploads/upload-1788083777445-fast_gdd.md;类型:text/markdown;大小:7944 字节;状态:imported"
);
assert!(!prompt.contains("GDD"));
assert!(!prompt.contains("规格"));
assert!(!prompt.contains("权威"));
assert!(!prompt.contains("必须读取"));
}
#[test]
fn project_png_and_markdown_share_the_same_line_shape() {
let attachments = vec![
project_attachment(
"角色参考.png",
"image/png",
12,
Some("assets/uploads/upload-1-角色参考.png"),
Some("imported"),
),
project_attachment(
"notes.md",
"text/markdown",
80,
Some("assets/uploads/upload-2-notes.md"),
Some("imported"),
),
];
let prompt =
render_direct_codex_user_prompt("看这两个附件", &attachments).expect("mixed types");
let lines: Vec<_> = prompt
.lines()
.filter(|line| line.starts_with("- 原文件名:"))
.collect();
assert_eq!(lines.len(), 2);
for line in &lines {
assert!(line.contains(";项目路径:assets/uploads/"));
assert!(line.contains(";类型:"));
assert!(line.contains(";大小:"));
assert!(line.contains(";状态:imported"));
}
assert!(lines[0].contains("角色参考.png"));
assert!(lines[0].contains("image/png"));
assert!(lines[1].contains("notes.md"));
assert!(lines[1].contains("text/markdown"));
}
#[test]
fn failed_attachment_without_path_has_status_and_no_error_body() {
let attachments = vec![project_attachment(
"lost.bin",
"application/octet-stream",
2,
None,
Some("failed"),
)];
let prompt =
render_direct_codex_user_prompt("附件失败了", &attachments).expect("failed prompt");
assert!(prompt.contains(PROJECT_ATTACHMENT_HEADER));
assert!(prompt.contains("原文件名:lost.bin"));
assert!(prompt.contains("状态:failed"));
assert!(!prompt.contains("项目路径:"));
assert!(!prompt.contains("error"));
assert!(!prompt.contains("失败原因"));
}
#[test]
fn illegal_local_paths_are_omitted_and_marked_failed() {
let attachments = vec![
project_attachment(
"up.md",
"text/markdown",
1,
Some("../secret.md"),
Some("imported"),
),
project_attachment(
"agent.md",
"text/markdown",
1,
Some(".agent/conversations/x.md"),
Some("imported"),
),
project_attachment(
"abs.md",
"text/markdown",
1,
Some(r"C:\tmp\abs.md"),
Some("imported"),
),
project_attachment(
"unix.md",
"text/markdown",
1,
Some("/tmp/unix.md"),
Some("imported"),
),
];
let prompt =
render_direct_codex_user_prompt("非法路径", &attachments).expect("illegal paths");
assert!(!prompt.contains("../secret.md"));
assert!(!prompt.contains(".agent/conversations/x.md"));
assert!(!prompt.contains("C:\\tmp\\abs.md"));
assert!(!prompt.contains("/tmp/unix.md"));
assert!(!prompt.contains("项目路径:"));
assert_eq!(prompt.matches("状态:failed").count(), 4);
assert!(!prompt.contains("状态:imported"));
}
#[test]
fn empty_prompt_with_project_attachments_still_renders() {
let attachments = vec![project_attachment(
"ref.png",
"image/png",
4,
Some("assets/uploads/upload-1-ref.png"),
Some("imported"),
)];
let prompt = render_direct_codex_user_prompt(" ", &attachments).expect("empty user text");
assert!(prompt.starts_with(PROJECT_ATTACHMENT_HEADER));
assert!(prompt.contains("项目路径:assets/uploads/upload-1-ref.png"));
}
#[test]
fn unknown_error_field_is_not_forwarded_to_the_model() {
let attachment: DirectCodexTurnAttachment = serde_json::from_str(
r#"{"name":"a.md","mediaType":"text/markdown","size":1,"status":"failed","error":"secret boom"}"#,
)
.expect("extra error field");
let prompt = render_direct_codex_user_prompt("x", &[attachment]).expect("render");
assert!(!prompt.contains("secret boom"));
assert!(!prompt.contains("error"));
}
#[test]
fn unknown_status_keeps_home_attachment_metadata_shape() {
let attachment =
project_attachment("pending.md", "text/markdown", 1, None, Some("pending"));
let prompt = render_direct_codex_user_prompt("x", &[attachment]).expect("render");
assert!(prompt.contains(HOME_ATTACHMENT_HEADER));
assert!(!prompt.contains(PROJECT_ATTACHMENT_HEADER));
assert!(!prompt.contains("状态:"));
}
}
File diff suppressed because it is too large Load Diff
@@ -4,12 +4,9 @@ mod model;
mod validation;
mod wire;
pub(crate) use model::{
DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
};
pub(crate) use model::DirectCodexUserItem;
pub(crate) use validation::validate_direct_codex_user_item;
pub(crate) use wire::{
direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt,
direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input,
direct_codex_user_item_to_response_item,
};
@@ -483,6 +483,7 @@ pub(super) async fn begin(
Ok(ExecutionSessionGuard { session })
}
#[cfg(test)]
pub(super) fn open_at(
host: &Path,
root: &Path,
@@ -6,7 +6,9 @@ use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::path::Path;
#[cfg(test)]
use std::path::PathBuf;
use std::sync::Arc;
const MAX_FILES: usize = 8;
@@ -2,14 +2,15 @@ use super::*;
use base64::Engine as _;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::future::Future;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
mod user_input;
pub(crate) use user_input::{chat_with_game_creator_direct_codex, normalize_direct_client_turn_id};
pub(crate) use user_input::chat_with_game_creator_direct_codex;
#[cfg(test)]
pub(crate) use user_input::normalize_direct_client_turn_id;
const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024;
const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6;
@@ -70,7 +71,6 @@ const DIRECT_CODEX_ART_ASSET_PATHS: [&str; 3] = [
DIRECT_CODEX_SPRITESHEET_ASSET_PATH,
];
const DIRECT_CODEX_ART_AGENT_ID: &str = "direct-codex-art";
const DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER: &str = "[[AGC_CREATE_PROJECT]]";
/// 直连 Codex 生成的游戏工程文件 → manifest 登记项。
///
@@ -100,12 +100,6 @@ fn direct_codex_game_outputs(root: &Path) -> Vec<(String, GameCreationAppAssetKi
]
}
fn direct_existing_game_sources_exist(root: &Path) -> bool {
direct_codex_game_outputs(root)
.iter()
.all(|(path, _, _)| root.join(path).is_file())
}
/// Art generation is an external, billable side effect. Existing direct
/// projects therefore stay in their same-thread code-edit/preview loop unless
/// the user explicitly asks for a new game or a visual regeneration.
@@ -318,16 +312,6 @@ pub(crate) fn direct_engine_three_dimensional_contract(
}
}
/// 首页回合的三维提示:允许按既有规则创建项目,但提醒默认模板不是三维引擎。
fn direct_engine_three_dimensional_home_note(prompt: &str) -> Option<String> {
match direct_engine_intent_from_prompt(prompt)? {
DirectEngineIntent::Named(_) => None,
DirectEngineIntent::ThreeDimensional => {
Some(prompt_text!("direct.threeDimensionalHome").to_string())
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct DirectTaonierArtAssetIdentity {
project_id: String,
@@ -4725,70 +4709,6 @@ pub(crate) fn build_direct_codex_system_prompt_with_creation_type(
.collect())
}
/// A home conversation deliberately has no project workspace. Keep its
/// instructions short, explicit, and free of project paths so a greeting or
/// general question cannot become an accidental game-generation request.
pub(crate) fn build_direct_codex_home_system_prompt() -> String {
[
DIRECT_TAONIER_IDENTITY_GUIDANCE,
prompt_text!("direct.home.reply"),
prompt_text!("direct.home.workspaceBoundary"),
prompt_text!("direct.home.createProject"),
prompt_text!("direct.home.privacy"),
]
.join("\n")
}
#[derive(Clone, Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectCodexHomeReply {
reply: String,
request_project_creation: bool,
}
fn parse_direct_codex_home_reply(reply: String) -> DirectCodexHomeReply {
// The marker is a narrow protocol boundary, not a substring convention.
// In particular, leading whitespace, an explanatory prefix, or a marker
// glued to other text must stay an ordinary reply rather than creating a
// user-visible project directory.
let (request_project_creation, reply) = match reply.split_once('\n') {
Some((first_line, remainder))
if first_line.strip_suffix('\r') == Some(DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER)
|| first_line == DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER =>
{
(true, remainder.trim())
}
None if reply == DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER => (true, ""),
_ => (false, reply.trim()),
};
DirectCodexHomeReply {
reply: if reply.is_empty() {
"陶泥儿已收到你的想法。".to_string()
} else {
reply.to_string()
},
request_project_creation,
}
}
pub(crate) async fn run_direct_game_creator_home_turn(
prompt: &str,
attachments: &[DirectCodexTurnAttachment],
) -> Result<DirectCodexHomeReply, String> {
let user_prompt = render_direct_codex_user_prompt(prompt, attachments)?;
// 首页也只有这一轮对话:三维请求直接放行创建,但要提醒默认模板不是三维引擎。
let engine_note = direct_engine_three_dimensional_home_note(prompt);
let base_system_prompt = build_direct_codex_home_system_prompt();
let system_prompt = match engine_note.as_deref() {
Some(note) => format!("{note}\n{base_system_prompt}"),
None => base_system_prompt,
};
direct_game_creator_home_codex_chat(system_prompt, user_prompt)
.await
.map(parse_direct_codex_home_reply)
.map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320))
}
pub(crate) async fn run_direct_game_creator_turn_at(
root: &Path,
prompt: &str,
@@ -4815,7 +4735,6 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type(
None,
None,
None,
None,
)
.await
}
@@ -4825,7 +4744,6 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
prompt: &str,
creation_type: Option<&str>,
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
audit: Option<&mut DirectCodexTurnAudit>,
direct_user_item: Option<serde_json::Value>,
capture: Option<(
crate::analytics::contract::Context,
@@ -4852,7 +4770,6 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
prompt,
creation_type,
turn_emitter,
audit,
direct_user_item,
capture,
analytics_attempt_id,
@@ -5054,7 +4971,6 @@ async fn run_direct_game_creator_turn_inner(
prompt: &str,
creation_type: Option<&str>,
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
audit: Option<&mut DirectCodexTurnAudit>,
direct_user_item: Option<serde_json::Value>,
capture: Option<(
crate::analytics::contract::Context,
@@ -5283,7 +5199,6 @@ async fn run_direct_game_creator_turn_inner(
};
let mut feedback_prompt = prompt.to_string();
let mut turn_kind = DirectCodexTurnKind::User;
let mut audit = audit;
let mut attempt = 1;
let reply_result = loop {
let result = direct_game_creator_codex_chat_at_with_optional_observer(
@@ -5293,7 +5208,6 @@ async fn run_direct_game_creator_turn_inner(
turn_kind,
Some(&client_turn_id),
Some(&mut observer),
audit.as_deref_mut(),
Some(direct_user_item.clone()),
)
.await;
@@ -5342,10 +5256,8 @@ async fn run_direct_game_creator_turn_inner(
} else {
let mut feedback_prompt = prompt.to_string();
let mut turn_kind = DirectCodexTurnKind::User;
let mut audit = audit;
let mut response = None;
let mut attempt = 1;
loop {
let response = loop {
let result = direct_game_creator_codex_chat_at_with_optional_observer(
root,
system_prompt.clone(),
@@ -5353,7 +5265,6 @@ async fn run_direct_game_creator_turn_inner(
turn_kind,
None,
None,
audit.as_deref_mut(),
Some(direct_user_item.clone()),
)
.await;
@@ -5361,15 +5272,15 @@ async fn run_direct_game_creator_turn_inner(
match result {
Ok(value) => {
match super::direct_delivery::review_reply(root,&execution_session).await {
Ok(Some(report)) => { response = Some(report); break; }
Ok(None) => { response = Some(value); break; }
Ok(Some(report)) => break Some(report),
Ok(None) => break Some(value),
Err(detail) if detail.starts_with("delivery-review-required:") => {
feedback_prompt = format!(prompt_text!("direct.deliveryFeedback"),detail=detail);
}
Err(error) => return Err(DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration,error)),
}
}
Err(_) if super::direct_delivery::terminal_report(&execution_session).is_some() => { response = super::direct_delivery::terminal_report(&execution_session); break; }
Err(_) if super::direct_delivery::terminal_report(&execution_session).is_some() => break super::direct_delivery::terminal_report(&execution_session),
Err(error)
if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS
&& direct_codex_error_should_feedback(&error) =>
@@ -5385,7 +5296,7 @@ async fn run_direct_game_creator_turn_inner(
));
}
}
}
};
response.ok_or_else(|| "陶泥儿错误反馈回合未返回结果".to_string())
}
.map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?;
@@ -5780,14 +5691,6 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials(
))
}
#[tauri::command]
pub(crate) async fn chat_with_game_creator_home_direct_codex(
prompt: String,
attachments: Option<Vec<DirectCodexTurnAttachment>>,
) -> Result<DirectCodexHomeReply, String> {
run_direct_game_creator_home_turn(&prompt, attachments.as_deref().unwrap_or_default()).await
}
#[cfg(test)]
fn persist_direct_codex_user_prompt_at(
root: &Path,
@@ -6504,67 +6407,6 @@ mod tests {
assert!(prompt.chars().count() <= MAX_DIRECT_SYSTEM_PROMPT_CHARS);
}
#[test]
fn home_three_dimensional_note_keeps_project_creation_available() {
let note =
direct_engine_three_dimensional_home_note("帮我做个 3D 城市游戏").expect("home note");
assert!(note.contains("三维请求说明"));
assert!(note.contains("按项目创建规则创建工程"));
assert!(note.contains("Three.js"));
assert!(!note.contains(DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER));
// 点名引擎与普通二维请求不加提示。
assert!(direct_engine_three_dimensional_home_note("用 Unity 做 3D").is_none());
assert!(direct_engine_three_dimensional_home_note("做个霓虹风格扫雷").is_none());
}
#[test]
fn home_prompt_has_no_project_or_side_effect_path_and_declares_the_only_creation_marker() {
let prompt = build_direct_codex_home_system_prompt();
assert!(prompt.contains("你是“陶泥儿”"));
assert!(prompt.contains("以陶泥儿的身份回答"));
assert!(prompt.contains("用户明确询问底层实现时可如实说明"));
assert!(!prompt.contains("你是 Codex"));
assert!(prompt.contains("当前没有打开任何用户项目"));
assert!(prompt.contains("不要创建、读取或修改项目文件"));
assert!(prompt.contains("不要生成素材"));
assert!(prompt.contains("不要启动预览、试玩、发布、版本登记"));
assert!(prompt.contains(DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER));
assert!(!prompt.contains("assets/"));
assert!(!prompt.contains("game/index.html"));
}
#[test]
fn home_create_marker_is_accepted_only_as_the_first_reply_token() {
let requested = parse_direct_codex_home_reply(format!(
"{DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER}\n请先选择一个项目文件夹。"
));
assert!(requested.request_project_creation);
assert_eq!(requested.reply, "请先选择一个项目文件夹。");
let windows_line_ending = parse_direct_codex_home_reply(format!(
"{DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER}\r\n请先选择一个项目文件夹。"
));
assert!(windows_line_ending.request_project_creation);
assert_eq!(windows_line_ending.reply, "请先选择一个项目文件夹。");
let marker_only =
parse_direct_codex_home_reply(DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER.to_string());
assert!(marker_only.request_project_creation);
assert_eq!(marker_only.reply, "陶泥儿已收到你的想法。");
for reply in [
format!("说明里提到 {DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER}"),
format!("先回答问题\n{DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER}"),
format!(" {DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER}\n请先选择项目"),
format!("{DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER}请先选择项目"),
format!("{DIRECT_CODEX_HOME_CREATE_PROJECT_MARKER}\r请先选择项目"),
] {
let ordinary = parse_direct_codex_home_reply(reply.clone());
assert!(!ordinary.request_project_creation, "reply={reply}");
}
}
#[test]
fn direct_prompt_exposes_only_the_reviewed_skill_index() {
let root = tempfile::tempdir().expect("temp dir");
@@ -6776,14 +6618,6 @@ mod tests {
#[test]
fn existing_game_edits_do_not_request_a_fresh_art_generation_by_default() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "direct-edit-intent", "继续编辑")
.expect("init project");
std::fs::write(root.path().join("game/index.html"), "<!doctype html>").expect("index");
std::fs::write(root.path().join("game/style.css"), "body {};").expect("style");
std::fs::write(root.path().join("game/game.js"), "console.log('edit');").expect("script");
assert!(direct_existing_game_sources_exist(root.path()));
for prompt in [
"把棋盘上移一点",
"修复闪烁",

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