Compare commits

...

44 Commits

Author SHA1 Message Date
k88936 6eed6bd13f 收敛 content→可读文本为单一口径并强制传 manifest(review 第 2、3 条)
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m10s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m43s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 6m28s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m13s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m0s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m58s
Project CI / Frontend tests (pull_request) Failing after 4m24s
Project CI / AI game creator shell web tests (pull_request) Failing after 4m2s
Project CI / Native shell tests (pull_request) Successful in 8m31s
- directCodexContentToPromptText 的 assets 改为必填,去掉 = [] 默认值:漏传素材清单从运行期文案退化变回编译期错误
- queuedChatTurnLabel 的 assets 与 ComposerTurnQueue 的 assets 属性同样必填,队列 chip 与消息正文共用同一个派生(chip 只多做压单行与限长)
- 断言改为显式传 [] 或 manifest:resourceReferences、chat-composer 用例按新签名收敛
- 更新 pitfalls 条目与 decision-log(canonical content 有效性只判整条 content、投影逐字透传、可读文本单一口径必填 assets)
2026-09-17 14:02:21 +08:00
k88936 b543a060b7 放宽 DirectProject content 校验为「整条判定」并按逐字投影(review 第 1 条,① 口径)
- Rust validate_direct_codex_user_item 改为只判整条 content:有一段非空白文本或任意非文本 part 即有效,单个纯空白 input_text(段落分隔 / 软换行 / chip 后的分隔空格)合法,不再逐个 part 拒绝空文本
- 新增 content_has_meaningful_input,并与前端 hasMeaningfulDirectCodexContent 同口径;wire.rs 的「不能转换为空 prompt」只作兜底
- ResourceReferenceInput 的投影层原样透传编辑器节点:不做空白过滤、也不与相邻 part 合并,删掉 appendInputText 与中间版本的「向前合并」收集器
- 按逐字投影更新资源输入、Godot 回合用例的 canonical content 断言
- 同步里程碑实现结论与证据、pitfalls 条目;Rust validation/wire 新增「单个纯空白 part 通过、整条全空白拒绝」用例
2026-09-17 14:01:41 +08:00
k88936 e469831543 修复快速编辑提示词把 @ 引用写成内部 id(review 第 3、4 条)
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m39s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m18s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m8s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m55s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 5m11s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m45s
Project CI / AI game creator shell web tests (pull_request) Successful in 5m10s
Project CI / Native shell tests (pull_request) Successful in 8m37s
Project CI / Frontend tests (pull_request) Successful in 8m46s
- applyResourceQuickEditPrompt 与 applyResourceQuickEditDraft 改用 directCodexContentToPromptText(content, manifest.assets),并在 useCallback 依赖里加上 manifest.assets
- 两处必须同口径:比较用的草稿文本与落进 quickEditPanel.prompt 的文本都按显示名展开,出站 derive_local_project_resource 的 prompt 与聊天 @ 逐字一致,也不会每次输入都重跑 replaceText
- 补充两处注释说明素材清单口径,shared-memory/pitfalls 记录「派生文本漏传 manifest.assets」的现象、原因、处理与验证
2026-09-16 21:26:53 +08:00
k88936 869abad879 修复排队消息 chip 的 @ 引用露出内部 resourceId(review 第 2 条)
- queuedChatTurnLabel 增加 assets 入参并透传给 directCodexContentToPromptText,@ 引用按 manifest 显示名展开,与聊天输入区文案口径一致
- ComposerTurnQueue 增加 assets 属性,ProjectSupervisorView 把 chatProjectAssets 传下去
- chat-composer 用例新增「队列 chip 的 @ 引用按 manifest 显示名展开」,钉住显示名文案与不传清单时的稳定 id 兜底
2026-09-16 21:25:38 +08:00
k88936 280a2dc5d1 修复 DirectProject 输入草稿丢掉引用后的换行(review 第 1 条)
- ResourceReferenceInput 的草稿投影改为「向前合并」:上一 part 是引用 chip 时先把待写文本攒在 pendingText 上,等下一个文本 part 到来再合并,段落分隔符与软换行不再被 trim() 丢掉
- 攒到最后仍只有空白的文本不入 content,避免产出 Rust validate_direct_codex_user_item 会拒绝的空 input_text
- 新增 resourceReferenceInput 用例「引用后面的段落分隔不会被吞掉」,钉住 chip 后一段的 content part、派生文本与「不产生纯空白 part」
- 同步更新 canonical content 严格边界里程碑的实现结论与证据,并在 shared-memory/pitfalls 记录该缺陷的现象、原因、处理与验证
2026-09-16 21:24:41 +08:00
k88936 edfd1f9143 修正 Godot 直接回合用例对 canonical content 的断言
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 2/4 (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 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
- appSurface Godot 用例不再断言被裁剪过的纯文本,改为断言编辑器实际提交的 canonical content(Shift+Enter 与组合态 Enter 插入的换行原样保留)
2026-09-16 18:51:08 +08:00
k88936 423f2d7184 修复独立总控聊天窗口未发送草稿的恢复
- App.tsx 增加 supervisorChatDraftHydratedRef 守卫的挂载水合:独立总控聊天窗口挂载走 loadProjectConversation,不经过 openWorkspace,此前 sessionStorage 里的草稿只写不读,重载后为空
- 仅在 supervisorChatOnly、存在持久化草稿且输入框已挂载时回填一次,避免覆盖用户后续输入
2026-09-16 18:50:55 +08:00
k88936 aa9076c95c 删除 DirectProject 图片专用 content part
- 移除 DirectCodexUserContentPart 的 agc_image_reference 分支与对应 ts-rs 绑定
- Rust 校验与 Codex wire 投影只保留 agc_attachment_reference
- 更新附件顺序投影测试的用例名、输入与断言
- 同步聊天素材功能说明、用户 Response item 里程碑与实施计划
- 在决策日志记录上传文件统一走附件 part、不保留图片兼容分支
2026-09-16 18:29:24 +08:00
k88936 41366dd71d 修复DirectProject canonical内容边界(review 第 8 条)
- 移除 chatComposerDraftToDirectCodexUserItem 对纯空白 input_text 的预过滤,原样传递 draft.content
- ChatComposerDraft 只保留 canonical content,删除 text / references 旧字段
- 新增 directCodexContentToPromptText、hasMeaningfulDirectCodexContent、directCodexUserItemFromContent 供 caller 与展示派生
- 删除 executeChatAgentReply 的 userItem 兜底分支,首页首轮、队列出队、普通提交与策略重试显式构造 canonical user item
- QueuedChatTurn 只保存 clientTurnId 与 userItem,队列 chip 文案由 content 派生
- 旧 Planner / legacy Supervisor caller 显式构造纯文本 item 并留下迁移 TODO
- 资源输入区对外只暴露 canonical content,内部文本草稿改为文本 → content 重建
- 迁移草稿、润色、队列与 appSurface 测试 fixture 到 content-only
- 更新 canonical content 里程碑与实施计划文档结论
2026-09-16 17:46:41 +08:00
k88936 f4b496caca 补充DirectProject canonical内容严格边界计划
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 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
- 明确 content-only 输入与最终空内容校验

- 规划删除 userItem 兜底并修正 Direct caller
2026-09-16 15:28:25 +08:00
k88936 fc0fd846f9 避免快速编辑润色回填重复更新
- 回填前比较编辑器当前文本

- 防止输入区 onChange 与宿主回调形成循环
2026-09-16 13:22:57 +08:00
k88936 fa99d9ad62 避免总控草稿初始化误删
- 编辑器尚未恢复时跳过空草稿持久化

- 保留已有会话草稿等待输入区完成恢复
2026-09-16 13:20:47 +08:00
k88936 64a52005f8 修复清空草稿后润色状态残留
- 在编辑器发出空草稿时重置润色结果

- 清除本轮发送提醒确认标记
2026-09-16 13:20:01 +08:00
k88936 645d806fed 修复空初始草稿吞掉首次编辑
- 仅在初始内容实际非空时跳过初始化回调

- 保证快速编辑首次输入同步到宿主状态
2026-09-16 13:19:13 +08:00
k88936 0b9d16e8f1 修复润色按钮无法感知输入
- 用 React 状态同步编辑器当前文本

- 让按钮禁用态随用户输入即时更新
2026-09-16 13:18:31 +08:00
k88936 d5c4e0e780 修复快速编辑润色结果未回填
- 为快速编辑输入区绑定编辑器句柄

- 润色回调同时更新面板状态与编辑器文本
2026-09-16 13:17:50 +08:00
k88936 30e27abc60 修复建议命令草稿残留引用
- 准备命令草稿前先清空编辑器

- 避免提交用户上一轮选择的素材引用
2026-09-16 13:16:07 +08:00
k88936 b607501916 修复总控草稿恢复后被清空
- 仅在非纯聊天模式清空聊天编辑器

- 保留纯聊天模式恢复的会话草稿
2026-09-16 13:15:08 +08:00
k88936 46e3cd9744 内联 DirectProject 附件与图片内容
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m30s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m35s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m54s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 6m1s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m52s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m41s
Project CI / Frontend tests (pull_request) Failing after 3m54s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m41s
Project CI / Native shell tests (pull_request) Successful in 6m9s
新增附件与图片 canonical content part 及 ts-rs 绑定

移除 DirectProject Tauri 附件 sidecar 追加路径

修正前端引用字段与生成类型适配

补充附件图片顺序投影测试并同步里程碑文档
2026-09-16 13:03:11 +08:00
k88936 5fb3db662b 合并主线并保留 DirectProject 输入行为
合并主线回合队列、工具流展示与活动回合管理

保留 Lexical 单一输入状态及附件图片 sidecar 投影

收敛 DirectProject user item 校验、历史恢复与图片预算

同步输入链路里程碑和历史异常恢复文档
2026-09-16 11:27:09 +08:00
k88936 87b798322e 收敛输入编辑器单一状态并补齐附件投影
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m6s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 1m49s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 1m56s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m2s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m19s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m46s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Failing after 3m54s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m13s
Project CI / Native shell tests (pull_request) Successful in 7m14s
聊天与快速编辑输入统一由 Lexical 编辑器持有唯一当前状态

App 通过输入句柄读取草稿,移除跨视图 EditorState 和字符串镜像

DirectProject 将附件与图片 sidecar 投影纳入 prompt,支持附件-only 输入

迁移润色测试并更新 DirectProject 里程碑验收记录
2026-09-15 22:13:25 +08:00
k88936 ec286b3480 抽取通用富文本输入组件
新增可复用的 RichTextInput Lexical 输入外壳。

让首页输入与资源引用输入共享编辑器、回车提交和禁用状态。

保留首页附件与图片粘贴节点能力,并移除 DirectProject 草稿 legacy fallback。

统一聊天草稿 canonical content 状态并更新资源引用测试。
2026-09-15 19:42:37 +08:00
k88936 35733f0e33 过滤 canonical 草稿中的空白输入 part
在统一 user item 转换入口移除纯空白 input_text,覆盖恢复草稿和所有提交路径。
2026-09-15 17:54:00 +08:00
k88936 3c7b02b9f8 过滤 DirectProject 编辑器的空白内容 part
跳过纯空白文本节点并合并相邻文本,避免引用后的分隔空格和换行触发 Rust 空输入校验。
2026-09-15 17:53:33 +08:00
k88936 f2030a616f 复用 DirectProject 用户输入校验读取的 manifest
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 1m44s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m19s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m19s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m17s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m17s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m26s
Project CI / Frontend tests (pull_request) Failing after 3m5s
Project CI / AI game creator shell web tests (pull_request) Failing after 2m51s
Project CI / Native shell tests (pull_request) Successful in 6m21s
让校验返回已读取的 manifest,wire 投影复用同一份数据,避免一次输入重复读取项目清单。
2026-09-15 17:33:49 +08:00
k88936 b3b5d77990 按运行模式创建 DirectProject 回合输入
仅在 DirectCodex 产品运行时生成 direct clientTurnId 和 user item,避免旧 Supervisor 与策划链路复用错误身份。
2026-09-15 17:32:46 +08:00
k88936 1da106af7d 避免重复校验 DirectProject 用户输入
复用 canonical 到 prompt 投影函数中的校验,删除不可达空 prompt 分支。
2026-09-15 17:29:08 +08:00
k88936 cd2edd6966 移除未使用的 DirectProject 输入信封类型
删除未被调用且未生成到前端绑定的 DirectCodexUserMessageEnvelope,收窄内部 API。
2026-09-15 17:27:42 +08:00
k88936 22e830ff93 避免 DirectProject 回合输入的无必要克隆
在互斥的观察器分支中移动 optional user item,避免深层 JSON 克隆。
2026-09-15 17:26:34 +08:00
k88936 c266ae7b50 清理策划模式提交后的聊天草稿
规划 V2 提交时同步清空资源引用和 canonical content,避免切回 DirectProject 后复用旧输入。
2026-09-15 17:25:15 +08:00
k88936 a543b75cf7 拆开聊天草稿内容分支判断
将资源草稿转换的三种回退路径改为显式分支,保持现有优先级并提升可读性。
2026-09-15 17:24:05 +08:00
k88936 f213987f9a 复用资源引用的统一内容映射
让 Lexical 草稿读取复用 chatReferenceToContentPart,避免引用字段映射分叉。
2026-09-15 17:22:55 +08:00
k88936 2c623bb577 移除 DirectProject 回合输入的重复赋值
删除已在对象初始化时设置的 userItem 重复赋值。
2026-09-15 17:19:17 +08:00
k88936 3353906e6f 修正 Response item content 类型投影
区分 turn/start 的 text 与历史 message 的 input_text

将 AGC 私有引用转换为标准 input_text

新增回归测试避免未知 variant text
2026-09-15 16:57:04 +08:00
k88936 17716347e2 收紧历史测试与注释口径
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m17s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 2m7s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m7s
Project CI / Backend tests (pull_request) Failing after 19s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m19s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m17s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m26s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m43s
Project CI / Frontend tests (pull_request) Failing after 4m6s
Project CI / Native shell tests (pull_request) Successful in 7m29s
移除 legacy 投影描述

将历史测试统一为 Response item 合同
2026-09-15 16:16:03 +08:00
k88936 9ad66a67a3 接入前端 canonical 用户 Response item
按 Lexical 顺序构造 message.content

DirectProject payload 改为发送 userItem

策略重试保留同一 canonical item
2026-09-15 16:12:56 +08:00
k88936 ba3aa3ccdb 更新用户 Response item 里程碑进度
标记 canonical、wire 与失败关闭验收项

同步实施计划为进行中状态
2026-09-15 16:09:28 +08:00
k88936 2b38eaafba 补充前端用户 Response item 契约测试
覆盖 Lexical 文本与引用交错顺序

验证资源引用只发送稳定 resourceId
2026-09-15 16:08:34 +08:00
k88936 7d5b9071e7 删除旧 DirectProject 引用协议
移除 DirectCodexTurnReference 平行 DTO 与渲染路径

将引用数量上限收回 canonical user item 校验模块
2026-09-15 16:01:45 +08:00
k88936 9e83f1d88c 补齐 Response item 严格合同测试
拒绝资源引用额外身份字段与未知 content part

补充历史 item wire 投影边界测试

同步 DirectProject 历史文档为无 fallback 合同
2026-09-15 15:55:15 +08:00
k88936 7e35d7c344 严格使用 DirectProject Response item 历史
移除 game-creator-conversation.v1 读取 fallback

历史注入前逐项投影 canonical user item 为 Codex message

无效 item 在持久化前失败关闭
2026-09-15 15:42:27 +08:00
k88936 5ec40c8b83 抽离 DirectProject 身份解析模块
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 2m16s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 2m20s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 2m4s
Project CI / Backend tests (pull_request) Failing after 18s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 1m54s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m22s
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m20s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m36s
Project CI / Frontend tests (pull_request) Successful in 3m45s
Project CI / Native shell tests (pull_request) Successful in 5m27s
将项目 canonical identity 与 digest 放入独立文件

保持 Codex app-server 线程池调用接口不变
2026-09-15 15:22:39 +08:00
k88936 948a80fc49 重构 DirectProject Agent 深模块
将 direct_runtime 与 codex_app_server 改为目录模块

拆分 user item 的模型、校验与 wire 投影文件

抽离 DirectProject 历史注入载荷构造 seam
2026-09-15 15:19:17 +08:00
k88936 dfd6fadedf 新增 DirectProject 用户 Response item 契约
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 4m35s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 4m34s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m36s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 4m31s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m46s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m32s
Project CI / Frontend tests (pull_request) Successful in 4m0s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m34s
Project CI / Native shell tests (pull_request) Successful in 6m35s
新增 Rust canonical user item 与 Codex wire 转换模块

增加 ts-rs 绑定目录及生成文件检查边界

更新聊天素材引用规范与里程碑实施计划
2026-09-15 14:45:58 +08:00
39 changed files with 1348 additions and 690 deletions
@@ -5,8 +5,9 @@ mod validation;
mod wire; mod wire;
pub(crate) use model::{ pub(crate) use model::{
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageEnvelope, DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart, DirectCodexUserMessageEnvelope, DirectCodexUserMessageItem, DirectCodexUserRole,
DirectCodexUserRuntimeRegionPart,
}; };
pub(crate) use validation::validate_direct_codex_user_item; pub(crate) use validation::validate_direct_codex_user_item;
pub(crate) use wire::{ pub(crate) use wire::{
@@ -36,6 +36,21 @@ pub(crate) enum DirectCodexUserContentPart {
AgcResourceReference { resource_id: String }, AgcResourceReference { resource_id: String },
#[serde(rename = "agc_runtime_region_reference")] #[serde(rename = "agc_runtime_region_reference")]
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart), AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
/// Uploaded project attachment kept inline in canonical content.
#[serde(rename = "agc_attachment_reference")]
AgcAttachmentReference(DirectCodexUserAttachmentReferencePart),
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
pub(crate) struct DirectCodexUserAttachmentReferencePart {
pub(crate) name: String,
pub(crate) media_type: String,
#[ts(type = "number")]
pub(crate) size: u64,
pub(crate) local_path: String,
pub(crate) status: String,
} }
#[derive(Clone, Debug, Deserialize, Serialize, TS)] #[derive(Clone, Debug, Deserialize, Serialize, TS)]
@@ -20,18 +20,16 @@ pub(crate) fn validate_direct_codex_user_item(
if message.id.trim().is_empty() { if message.id.trim().is_empty() {
return Err("DirectProject user item 缺少稳定 id".to_string()); return Err("DirectProject user item 缺少稳定 id".to_string());
} }
if message.content.is_empty() { // 有效输入只判一整条 content:单个纯空白 `input_text` 是合法 part —— 编辑器里的段落
// 分隔、软换行与 chip 后的分隔空格就是这样落进 canonical content 的,前端不为它过滤。
if !content_has_meaningful_input(&message.content) {
return Err("DirectProject user item content 不能为空".to_string()); return Err("DirectProject user item content 不能为空".to_string());
} }
let manifest = read_manifest_for_project(root)?; let manifest = read_manifest_for_project(root)?;
let mut reference_count = 0usize; let mut reference_count = 0usize;
for part in &message.content { for part in &message.content {
match part { match part {
DirectCodexUserContentPart::InputText { text } => { DirectCodexUserContentPart::InputText { .. } => {}
if text.trim().is_empty() {
return Err("DirectProject input_text 不能为空".to_string());
}
}
DirectCodexUserContentPart::AgcResourceReference { resource_id } => { DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
reference_count = reference_count.saturating_add(1); reference_count = reference_count.saturating_add(1);
validate_resource_id_and_manifest(&manifest, resource_id)?; validate_resource_id_and_manifest(&manifest, resource_id)?;
@@ -40,6 +38,18 @@ pub(crate) fn validate_direct_codex_user_item(
reference_count = reference_count.saturating_add(1); reference_count = reference_count.saturating_add(1);
validate_runtime_region_reference(&manifest, reference)?; validate_runtime_region_reference(&manifest, reference)?;
} }
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
if reference.name.trim().is_empty() {
return Err("附件缺少文件名".to_string());
}
if !reference.local_path.trim().is_empty() {
sanitize_attachment_local_path(&reference.local_path)
.ok_or_else(|| "附件项目路径无效".to_string())?;
}
if !matches!(reference.status.trim(), "imported" | "failed") {
return Err("附件状态无效".to_string());
}
}
} }
} }
if reference_count > MAX_DIRECT_CODEX_REFERENCES { if reference_count > MAX_DIRECT_CODEX_REFERENCES {
@@ -48,6 +58,14 @@ pub(crate) fn validate_direct_codex_user_item(
Ok(()) Ok(())
} }
/// 整条 content 是否还有有效输入:任何一段非空白文本、或任何一个非文本 part 都算。
pub(crate) fn content_has_meaningful_input(content: &[DirectCodexUserContentPart]) -> bool {
content.iter().any(|part| match part {
DirectCodexUserContentPart::InputText { text } => !text.trim().is_empty(),
_ => true,
})
}
pub(crate) fn validate_resource_id_and_manifest( pub(crate) fn validate_resource_id_and_manifest(
manifest: &GameCreationAppManifest, manifest: &GameCreationAppManifest,
resource_id: &str, resource_id: &str,
@@ -86,3 +104,48 @@ fn validate_runtime_region_reference(
} }
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::content_has_meaningful_input;
use crate::agent::direct_codex_user_item::model::DirectCodexUserContentPart;
fn input_text(text: &str) -> DirectCodexUserContentPart {
DirectCodexUserContentPart::InputText {
text: text.to_string(),
}
}
#[test]
fn only_all_blank_content_counts_as_empty_input() {
// 空数组与「整条只有空白」是同一种空输入。
assert!(!content_has_meaningful_input(&[]));
assert!(!content_has_meaningful_input(&[input_text(" \n ")]));
assert!(!content_has_meaningful_input(&[
input_text("\n"),
input_text(" "),
]));
}
#[test]
fn whitespace_parts_are_valid_next_to_meaningful_input() {
// 段落分隔 / 软换行 / chip 后的分隔空格都是合法的单个 part。
assert!(content_has_meaningful_input(&[
input_text("\n"),
input_text(""),
]));
assert!(content_has_meaningful_input(&[
input_text(""),
input_text("\n\n"),
]));
}
#[test]
fn non_text_parts_always_count_as_input() {
assert!(content_has_meaningful_input(&[
DirectCodexUserContentPart::AgcResourceReference {
resource_id: "asset-hero".to_string(),
},
]));
}
}
@@ -100,6 +100,20 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
summary.push(']'); summary.push(']');
summary summary
} }
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
let mut summary = format!(
"[附件:名称={};类型={};大小={} 字节",
reference.name.trim(),
reference.media_type.trim(),
reference.size
);
if !reference.local_path.trim().is_empty() {
summary.push_str(&format!(";项目路径={}", reference.local_path.trim()));
}
summary.push_str(&format!(";状态={}", reference.status.trim()));
summary.push(']');
summary
}
}; };
input.push(serde_json::json!({ "type": "text", "text": text })); input.push(serde_json::json!({ "type": "text", "text": text }));
} }
@@ -130,7 +144,8 @@ pub(crate) fn direct_codex_user_item_to_prompt(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::direct_codex_user_item_to_response_item; use super::{direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input};
use crate::agent::direct_codex_user_item::model::DirectCodexUserItem;
use serde_json::json; use serde_json::json;
use std::path::Path; use std::path::Path;
@@ -174,4 +189,70 @@ mod tests {
.expect_err("history item without type must fail"); .expect_err("history item without type must fail");
assert!(error.contains("缺少 type"), "{error}"); assert!(error.contains("缺少 type"), "{error}");
} }
#[test]
fn attachment_parts_remain_in_canonical_order_when_projected() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
.expect("init project");
let item = json!({
"type": "message",
"role": "user",
"id": "turn-1:user",
"content": [
{"type": "input_text", "text": "先看"},
{"type": "agc_attachment_reference", "name": "notes.txt", "mediaType": "text/plain", "size": 4, "localPath": "assets/notes.txt", "status": "imported"}
]
});
let projected = direct_codex_user_item_to_response_item(root.path(), &item)
.expect("user response item should project");
let content = projected["content"].as_array().expect("content array");
assert_eq!(content.len(), 2);
assert!(content[0]["text"].as_str().unwrap().contains("先看"));
assert!(content[1]["text"].as_str().unwrap().contains("notes.txt"));
}
#[test]
fn whitespace_only_text_parts_survive_validation() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
.expect("init project");
let item: DirectCodexUserItem = serde_json::from_value(json!({
"type": "message",
"role": "user",
"id": "turn-1:user",
"content": [
{"type": "input_text", "text": "先看"},
{"type": "input_text", "text": "\n"},
{"type": "input_text", "text": " "}
]
}))
.expect("deserialize user item");
let wire = direct_codex_user_item_to_wire_input(root.path(), &item)
.expect("whitespace-only part next to real text must pass");
let parts = wire.as_array().expect("wire input array");
assert_eq!(parts.len(), 3);
assert_eq!(parts[1]["text"].as_str(), Some("\n"));
assert_eq!(parts[2]["text"].as_str(), Some(" "));
}
#[test]
fn all_blank_content_is_rejected() {
let root = tempfile::tempdir().expect("temp project");
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
.expect("init project");
let item: DirectCodexUserItem = serde_json::from_value(json!({
"type": "message",
"role": "user",
"id": "turn-1:user",
"content": [
{"type": "input_text", "text": "\n"},
{"type": "input_text", "text": " "}
]
}))
.expect("deserialize user item");
let error = direct_codex_user_item_to_wire_input(root.path(), &item)
.expect_err("all-blank content must fail closed");
assert!(error.contains("不能为空"), "{error}");
}
} }
@@ -31,10 +31,9 @@ pub(crate) fn normalize_direct_client_turn_id(
pub(crate) async fn chat_with_game_creator_direct_codex( pub(crate) async fn chat_with_game_creator_direct_codex(
project_path: String, project_path: String,
prompt: String, prompt: String,
mut user_item: DirectCodexUserItem, user_item: DirectCodexUserItem,
creation_type: Option<String>, creation_type: Option<String>,
client_turn_id: Option<String>, client_turn_id: Option<String>,
attachments: Option<Vec<DirectCodexTurnAttachment>>,
) -> Result<String, String> { ) -> Result<String, String> {
let root = Path::new(project_path.trim()); let root = Path::new(project_path.trim());
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
@@ -43,24 +42,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500) redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
})?; })?;
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone()); let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
let mut audit = DirectCodexTurnAudit::start( let mut audit = DirectCodexTurnAudit::start(root, &turn_id, &prompt, &[]);
root,
&turn_id,
&prompt,
attachments.as_deref().unwrap_or_default(),
);
let attachments = attachments.unwrap_or_default();
if !attachments.is_empty() {
let attachment_context =
render_direct_codex_user_prompt("", &attachments).map_err(|error| {
audit.finish(false);
error
})?;
let DirectCodexUserItem::Message(message) = &mut user_item;
message.content.push(DirectCodexUserContentPart::InputText {
text: attachment_context,
});
}
validate_direct_codex_user_item(root, &user_item).map_err(|error| { validate_direct_codex_user_item(root, &user_item).map_err(|error| {
audit.finish(false); audit.finish(false);
error error
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,102 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import {
COMMAND_PRIORITY_HIGH,
type EditorState,
KEY_ENTER_COMMAND,
type Klass,
type LexicalNode,
} from 'lexical';
import type { ReactElement, ReactNode, Ref } from 'react';
import { useEffect } from 'react';
type RichTextInputProps = {
namespace: string;
nodes: Klass<LexicalNode>[];
initialEditorState?: EditorState | null;
contentEditable?: ReactElement<typeof ContentEditable>;
placeholder?: ReactElement;
containerClassName?: string;
containerRef?: Ref<HTMLDivElement>;
disabled?: boolean;
onChange?: (editorState: EditorState) => void;
onEnter?: () => void;
children?: ReactNode;
};
function SubmitOnEnter({ onEnter }: { onEnter?: () => void }) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (!onEnter) return undefined;
return editor.registerCommand(
KEY_ENTER_COMMAND,
(event) => {
if (!event || event.shiftKey || event.isComposing) return false;
event.preventDefault();
onEnter();
return true;
},
COMMAND_PRIORITY_HIGH,
);
}, [editor, onEnter]);
return null;
}
function SetEditorEditable({ disabled }: { disabled: boolean }) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
editor.setEditable(!disabled);
}, [disabled, editor]);
return null;
}
export default function RichTextInput({
namespace,
nodes,
initialEditorState,
contentEditable = <ContentEditable />,
placeholder,
containerClassName,
containerRef,
disabled = false,
onChange,
onEnter,
children,
}: RichTextInputProps) {
return (
<LexicalComposer
initialConfig={{
namespace,
nodes,
editorState: initialEditorState ?? undefined,
onError: (error) => {
throw error;
},
}}
>
<div
ref={containerRef}
className={containerClassName}
data-disabled={disabled ? 'true' : undefined}
>
<RichTextPlugin
contentEditable={contentEditable}
placeholder={placeholder}
ErrorBoundary={LexicalErrorBoundary}
/>
{children}
<SetEditorEditable disabled={disabled} />
<SubmitOnEnter onEnter={onEnter} />
{onChange ? <OnChangePlugin onChange={onChange} /> : null}
</div>
</LexicalComposer>
);
}
@@ -19,6 +19,7 @@ import {
import type { RefObject } from 'react'; import type { RefObject } from 'react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { resolveTauriInvoke } from '../../app/tauri'; import { resolveTauriInvoke } from '../../app/tauri';
import type { import type {
GameCreatorAppConfigView, GameCreatorAppConfigView,
@@ -192,9 +193,12 @@ export function ComposerPendingAttachments({
/** 队列 chip:回合运行中入队的消息,按 FIFO 顺序展示,可单条取消。 */ /** 队列 chip:回合运行中入队的消息,按 FIFO 顺序展示,可单条取消。 */
export function ComposerTurnQueue({ export function ComposerTurnQueue({
turns, turns,
assets,
onCancel, onCancel,
}: { }: {
turns: readonly QueuedChatTurn[]; turns: readonly QueuedChatTurn[];
/** 与聊天消息渲染同源的素材清单:chip 文案里的 `@` 引用按它展开成显示名。 */
assets: readonly GameCreationAppAssetManifestEntry[];
onCancel: (id: string) => void; onCancel: (id: string) => void;
}) { }) {
if (turns.length === 0) { if (turns.length === 0) {
@@ -211,11 +215,11 @@ export function ComposerTurnQueue({
{index + 1} {index + 1}
</span> </span>
<span className="project-supervisor-composer-queue-text"> <span className="project-supervisor-composer-queue-text">
{queuedChatTurnLabel(turn)} {queuedChatTurnLabel(turn, assets)}
</span> </span>
<button <button
type="button" type="button"
aria-label={`取消排队消息 ${queuedChatTurnLabel(turn)}`} aria-label={`取消排队消息 ${queuedChatTurnLabel(turn, assets)}`}
title="取消这条排队消息" title="取消这条排队消息"
onClick={() => onCancel(turn.id)} onClick={() => onCancel(turn.id)}
> >
@@ -79,7 +79,7 @@ import {
ResourceReferenceInput, ResourceReferenceInput,
type ResourceReferenceInputHandle, type ResourceReferenceInputHandle,
} from './ResourceReferenceInput'; } from './ResourceReferenceInput';
import type { ChatComposerDraft, ChatReference } from './resourceReferences'; import type { ChatComposerDraft } from './resourceReferences';
import { ToolCallGroup } from './ToolCallGroup'; import { ToolCallGroup } from './ToolCallGroup';
import { import {
formatClockTime, formatClockTime,
@@ -242,8 +242,6 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
attachments?: DirectCodexTurnAttachment[]; attachments?: DirectCodexTurnAttachment[];
/** 上传/校验附件的提示文案(失败与成功都用它,空串不渲染)。 */ /** 上传/校验附件的提示文案(失败与成功都用它,空串不渲染)。 */
attachmentNotice?: string; attachmentNotice?: string;
chatInput: string;
chatReferences: ChatReference[];
chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[]; chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[];
composerRef?: RefObject<ResourceReferenceInputHandle | null>; composerRef?: RefObject<ResourceReferenceInputHandle | null>;
directCodex?: boolean; directCodex?: boolean;
@@ -327,8 +325,6 @@ export function ProjectSupervisorView({
activeVersionId = null, activeVersionId = null,
attachments = [], attachments = [],
attachmentNotice = '', attachmentNotice = '',
chatInput,
chatReferences,
chatProjectAssets, chatProjectAssets,
composerRef, composerRef,
directCodex = false, directCodex = false,
@@ -893,6 +889,7 @@ export function ProjectSupervisorView({
> >
<ComposerTurnQueue <ComposerTurnQueue
turns={directCodex ? queuedTurns : []} turns={directCodex ? queuedTurns : []}
assets={chatProjectAssets}
onCancel={(id) => onCancelQueuedTurn?.(id)} onCancel={(id) => onCancelQueuedTurn?.(id)}
/> />
<ComposerPendingAttachments <ComposerPendingAttachments
@@ -916,8 +913,6 @@ export function ProjectSupervisorView({
Boolean(designView?.session.pendingClarification) Boolean(designView?.session.pendingClarification)
} }
rows={3} rows={3}
value={chatInput}
references={chatReferences}
showTriggerButton={!directCodex} showTriggerButton={!directCodex}
placeholder={ placeholder={
directCodex directCodex
@@ -55,7 +55,7 @@ import {
ResourceReferenceInput, ResourceReferenceInput,
type ResourceReferenceInputHandle, type ResourceReferenceInputHandle,
} from './ResourceReferenceInput'; } from './ResourceReferenceInput';
import type { ChatComposerDraft, ChatReference } from './resourceReferences'; import type { ChatComposerDraft } from './resourceReferences';
type ProjectWorkspaceChatPaneProps = { type ProjectWorkspaceChatPaneProps = {
activeVersionId?: string | null; activeVersionId?: string | null;
@@ -65,8 +65,6 @@ type ProjectWorkspaceChatPaneProps = {
cancelProjectCreateInNonEmptyFolder: () => void; cancelProjectCreateInNonEmptyFolder: () => void;
cancelUiCommandConfirmation: () => void; cancelUiCommandConfirmation: () => void;
chatAgentBusy: boolean; chatAgentBusy: boolean;
chatInput: string;
chatReferences: ChatReference[];
chatProjectAssets: GameCreationAppAssetManifestEntry[]; chatProjectAssets: GameCreationAppAssetManifestEntry[];
composerRef?: Ref<ResourceReferenceInputHandle>; composerRef?: Ref<ResourceReferenceInputHandle>;
chatInputRef: RefObject<HTMLDivElement | null>; chatInputRef: RefObject<HTMLDivElement | null>;
@@ -215,8 +213,6 @@ export function ProjectWorkspaceChatPane({
cancelProjectCreateInNonEmptyFolder, cancelProjectCreateInNonEmptyFolder,
cancelUiCommandConfirmation, cancelUiCommandConfirmation,
chatAgentBusy, chatAgentBusy,
chatInput,
chatReferences,
chatProjectAssets, chatProjectAssets,
composerRef, composerRef,
chatInputRef, chatInputRef,
@@ -962,8 +958,6 @@ export function ProjectWorkspaceChatPane({
projectPath={projectPath} projectPath={projectPath}
disabled={chatAgentBusy || projectSupervisorNeedsUserInput} disabled={chatAgentBusy || projectSupervisorNeedsUserInput}
multiline={false} multiline={false}
value={chatInput}
references={chatReferences}
placeholder="例如:像素风横版动作小游戏,或输入 @ 选择资源" placeholder="例如:像素风横版动作小游戏,或输入 @ 选择资源"
onChange={onChatComposerChange} onChange={onChatComposerChange}
/> />
File diff suppressed because it is too large Load Diff
@@ -33,7 +33,7 @@ import {
ResourceReferenceInput, ResourceReferenceInput,
type ResourceReferenceInputHandle, type ResourceReferenceInputHandle,
} from './ResourceReferenceInput'; } from './ResourceReferenceInput';
import type { ChatComposerDraft, ChatReference } from './resourceReferences'; import type { ChatComposerDraft } from './resourceReferences';
type RuntimeControlProps = ComponentProps< type RuntimeControlProps = ComponentProps<
typeof ProjectSupervisorRuntimeControls typeof ProjectSupervisorRuntimeControls
@@ -44,8 +44,6 @@ const CHAT_SCROLL_BOTTOM_THRESHOLD = 24;
type SupervisorChatOnlyViewProps = { type SupervisorChatOnlyViewProps = {
activeVersionId?: string | null; activeVersionId?: string | null;
chatAgentBusy: boolean; chatAgentBusy: boolean;
chatInput: string;
chatReferences: ChatReference[];
chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[]; chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[];
composerRef?: Ref<ResourceReferenceInputHandle>; composerRef?: Ref<ResourceReferenceInputHandle>;
directCodex?: boolean; directCodex?: boolean;
@@ -82,8 +80,6 @@ type SupervisorChatOnlyViewProps = {
export function SupervisorChatOnlyView({ export function SupervisorChatOnlyView({
activeVersionId = null, activeVersionId = null,
chatAgentBusy, chatAgentBusy,
chatInput,
chatReferences,
chatProjectAssets, chatProjectAssets,
composerRef, composerRef,
directCodex = false, directCodex = false,
@@ -328,8 +324,6 @@ export function SupervisorChatOnlyView({
projectPath={projectPath} projectPath={projectPath}
disabled={chatAgentBusy || needsUserInput} disabled={chatAgentBusy || needsUserInput}
rows={3} rows={3}
value={chatInput}
references={chatReferences}
placeholder={ placeholder={
directCodex ? '描述你的想法' : '给项目总控 Agent 发消息' directCodex ? '描述你的想法' : '给项目总控 Agent 发消息'
} }
@@ -4,36 +4,33 @@
* 回合运行中用户再次发送时,消息进入 FIFO 队列而不是被丢弃;当前回合结束后按入队顺序 * 回合运行中用户再次发送时,消息进入 FIFO 队列而不是被丢弃;当前回合结束后按入队顺序
* 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。 * 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。
*/ */
import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments'; import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import type { DirectCodexUserContentPart } from './generated'; import type { DirectCodexUserItem } from './generated';
import type { ChatReference } from './resourceReferences'; import { directCodexContentToPromptText } from './resourceReferences';
/** 队列上限:满了以后拒绝入队并给出可读提示,而不是静默丢消息。 */ /** 队列上限:满了以后拒绝入队并给出可读提示,而不是静默丢消息。 */
export const MAX_QUEUED_CHAT_TURNS = 5; export const MAX_QUEUED_CHAT_TURNS = 5;
export type QueuedChatTurn = { export type QueuedChatTurn = {
id: string; id: string;
prompt: string; clientTurnId: string;
attachments: DirectCodexTurnAttachment[]; userItem: DirectCodexUserItem;
references: ChatReference[];
content?: DirectCodexUserContentPart[];
createdAt: number; createdAt: number;
}; };
export function createQueuedChatTurn(input: { export function createQueuedChatTurn(input: {
id: string; id: string;
prompt: string; clientTurnId: string;
attachments?: readonly DirectCodexTurnAttachment[]; userItem: DirectCodexUserItem;
references?: readonly ChatReference[];
content?: readonly DirectCodexUserContentPart[];
createdAt: number; createdAt: number;
}): QueuedChatTurn { }): QueuedChatTurn {
return { return {
id: input.id, id: input.id,
prompt: input.prompt, clientTurnId: input.clientTurnId,
attachments: [...(input.attachments ?? [])], userItem: {
references: [...(input.references ?? [])], ...input.userItem,
content: [...(input.content ?? [])], content: [...input.userItem.content],
},
createdAt: input.createdAt, createdAt: input.createdAt,
}; };
} }
@@ -77,16 +74,30 @@ export function chatQueueFullNotice(): string {
return `队列已满(最多 ${MAX_QUEUED_CHAT_TURNS} 条),请等当前回合结束后再发送`; return `队列已满(最多 ${MAX_QUEUED_CHAT_TURNS} 条),请等当前回合结束后再发送`;
} }
/** 队列 chip 上显示的文字:单行、有长度上限。 */ /**
export function queuedChatTurnLabel(turn: QueuedChatTurn): string { * 队列 chip 上显示的文字:与真实消息同一个派生(`directCodexContentToPromptText`),
const text = turn.prompt.trim().replace(/\s+/gu, ' '); * 再压成单行并限长。
*
* `assets` 与聊天消息渲染同源(`manifest.assets`)且必填:`@` 引用按显示名展开,chip
* 与消息正文逐字一致,不会露出 `@asset:…` 这种内部 id。
*/
export function queuedChatTurnLabel(
turn: QueuedChatTurn,
assets: readonly GameCreationAppAssetManifestEntry[],
): string {
const text = directCodexContentToPromptText(turn.userItem.content, assets)
.trim()
.replace(/\s+/gu, ' ');
if (text) { if (text) {
return text.length > 24 ? `${text.slice(0, 24)}` : text; return text.length > 24 ? `${text.slice(0, 24)}` : text;
} }
if (turn.attachments.length > 0) { const attachment = turn.userItem.content.find(
return `附件 · ${turn.attachments[0]?.name ?? '未命名'}`; (part) => part.type === 'agc_attachment_reference',
);
if (attachment?.type === 'agc_attachment_reference') {
return `附件 · ${attachment.name || '未命名'}`;
} }
if (turn.references.length > 0) { if (turn.userItem.content.some((part) => part.type !== 'input_text')) {
return '素材引用'; return '素材引用';
} }
return '未命名消息'; return '未命名消息';
@@ -1,8 +1,5 @@
import { resolveTauriInvoke } from '../../app/tauri'; import { resolveTauriInvoke } from '../../app/tauri';
import { import type { DirectCodexUserContentPart } from './generated';
type ChatComposerDraft,
chatReferenceListKey,
} from './resourceReferences';
/** /**
* 「不再提醒」偏好存本机 localStorage,不进 manifest、不进后端。 * 「不再提醒」偏好存本机 localStorage,不进 manifest、不进后端。
@@ -61,8 +58,10 @@ export function writeChatPromptPolishReminderDisabled(disabled: boolean) {
} }
/** 草稿指纹:用于判断「本轮草稿」是否已经被润色或确认过。 */ /** 草稿指纹:用于判断「本轮草稿」是否已经被润色或确认过。 */
export function chatPromptDraftKey(draft: ChatComposerDraft) { export function chatPromptDraftKey(
return `${draft.text}\u0000${chatReferenceListKey(draft.references)}`; content: readonly DirectCodexUserContentPart[],
) {
return JSON.stringify(content);
} }
/** /**
@@ -73,25 +72,27 @@ export function chatPromptDraftKey(draft: ChatComposerDraft) {
* 4. 草稿不是以 `/` 开头的命令 —— 命令走直通路径,不参与提醒。 * 4. 草稿不是以 `/` 开头的命令 —— 命令走直通路径,不参与提醒。
*/ */
export function shouldRemindChatPromptPolish({ export function shouldRemindChatPromptPolish({
draft, content,
prompt,
acknowledgedDraftKey, acknowledgedDraftKey,
reminderDisabled, reminderDisabled,
}: { }: {
draft: ChatComposerDraft; content: readonly DirectCodexUserContentPart[];
prompt: string;
acknowledgedDraftKey: string | null; acknowledgedDraftKey: string | null;
reminderDisabled: boolean; reminderDisabled: boolean;
}) { }) {
if (reminderDisabled) { if (reminderDisabled) {
return false; return false;
} }
const text = draft.text.trim(); const text = prompt.trim();
if (text.length < CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH) { if (text.length < CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH) {
return false; return false;
} }
if (text.startsWith('/')) { if (text.startsWith('/')) {
return false; return false;
} }
return chatPromptDraftKey(draft) !== acknowledgedDraftKey; return chatPromptDraftKey(content) !== acknowledgedDraftKey;
} }
/** /**
@@ -0,0 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DirectCodexUserAttachmentReferencePart = {
name: string;
mediaType: string;
size: number;
localPath: string;
status: string;
};
@@ -1,5 +1,5 @@
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DirectCodexUserAttachmentReferencePart } from './DirectCodexUserAttachmentReferencePart';
import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart';
export type DirectCodexUserContentPart = export type DirectCodexUserContentPart =
@@ -7,4 +7,7 @@ export type DirectCodexUserContentPart =
| { type: 'agc_resource_reference'; resourceId: string } | { type: 'agc_resource_reference'; resourceId: string }
| ({ | ({
type: 'agc_runtime_region_reference'; type: 'agc_runtime_region_reference';
} & DirectCodexUserRuntimeRegionPart); } & DirectCodexUserRuntimeRegionPart)
| ({
type: 'agc_attachment_reference';
} & DirectCodexUserAttachmentReferencePart);
@@ -1,5 +1,9 @@
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem';
export type DirectCodexUserItem = DirectCodexUserMessageItem; /**
* DirectProject 本轮 user input 的唯一结构化入口。
*/
export type DirectCodexUserItem = {
type: 'message';
} & DirectCodexUserMessageItem;
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DirectCodexUserItem } from './DirectCodexUserItem';
export type DirectCodexUserMessageEnvelope = { item: DirectCodexUserItem };
@@ -1,11 +1,9 @@
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; import type { DirectCodexUserContentPart } from './DirectCodexUserContentPart';
import type { DirectCodexUserRole } from './DirectCodexUserRole'; import type { DirectCodexUserRole } from './DirectCodexUserRole';
export type DirectCodexUserMessageItem = { export type DirectCodexUserMessageItem = {
type: 'message';
role: DirectCodexUserRole; role: DirectCodexUserRole;
content: DirectCodexUserContentPart[]; content: Array<DirectCodexUserContentPart>;
id: string; id: string;
}; };
@@ -1,3 +1,3 @@
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DirectCodexUserRole = 'user'; export type DirectCodexUserRole = 'user';
@@ -1,13 +1,13 @@
// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DirectCodexUserRuntimeRegionPart = { export type DirectCodexUserRuntimeRegionPart = {
label: string; label: string;
runId?: string; runId: string | null;
versionId?: string; versionId: string | null;
elementTag?: string; elementTag: string | null;
elementRole?: string; elementRole: string | null;
text?: string; text: string | null;
width?: number; width: number | null;
height?: number; height: number | null;
resourceIds: string[]; resourceIds: Array<string>;
}; };
@@ -1,5 +1,7 @@
export type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; export type { DirectCodexUserContentPart } from './DirectCodexUserContentPart';
export type { DirectCodexUserAttachmentReferencePart } from './DirectCodexUserAttachmentReferencePart';
export type { DirectCodexUserItem } from './DirectCodexUserItem'; export type { DirectCodexUserItem } from './DirectCodexUserItem';
export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem';
export type { DirectCodexUserRole } from './DirectCodexUserRole'; export type { DirectCodexUserRole } from './DirectCodexUserRole';
export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart';
export type { DirectCodexUserMessageEnvelope } from './DirectCodexUserMessageEnvelope';
@@ -13,7 +13,6 @@ import {
import type { import type {
DirectCodexUserContentPart, DirectCodexUserContentPart,
DirectCodexUserItem, DirectCodexUserItem,
DirectCodexUserMessageItem,
} from './generated'; } from './generated';
export type ResourceReferenceSource = export type ResourceReferenceSource =
@@ -50,10 +49,8 @@ export type RuntimeRegionReference = {
export type ChatReference = ResourceReference | RuntimeRegionReference; export type ChatReference = ResourceReference | RuntimeRegionReference;
export type ChatComposerDraft = { export type ChatComposerDraft = {
text: string; /** Lexical 顺序对应的 canonical user content;这是唯一草稿真相。 */
references: ChatReference[]; content: DirectCodexUserContentPart[];
/** Lexical 顺序对应的 canonical user content;仅由编辑器读回时提供。 */
content?: DirectCodexUserContentPart[];
}; };
export const RESOURCE_REFERENCE_INSERT_EVENT = 'agc-resource-reference-insert'; export const RESOURCE_REFERENCE_INSERT_EVENT = 'agc-resource-reference-insert';
@@ -89,11 +86,47 @@ export function isResourceReferenceOverlayTarget(target: EventTarget | null) {
} }
export const EMPTY_CHAT_COMPOSER_DRAFT: ChatComposerDraft = { export const EMPTY_CHAT_COMPOSER_DRAFT: ChatComposerDraft = {
text: '',
references: [],
content: [], content: [],
}; };
/**
* canonical content → 可读文本:**消息正文、队列 chip、润色判据、出站 prompt 只有这一个口径**。
*
* `assets` 是当前项目的 manifest 且必填:`agc_resource_reference` 按 `@显示名` 展开,与用户
* 在编辑器里看到、以及聊天消息里渲染出来的字面量逐字一致。没有默认值 —— 漏传素材清单会
* 静默退化成 `@内部 id`(用户看到的文案与真正落盘的 prompt 分叉)。只有素材已不在清单里时
* 才回落到 `resourceId`,那是「引用指向已消失的素材」的兜底而不是常态。
*/
export function directCodexContentToPromptText(
content: readonly DirectCodexUserContentPart[],
assets: readonly GameCreationAppAssetManifestEntry[],
) {
const labels = new Map(
assets.map((asset) => [asset.id, resourceDisplayName(asset)]),
);
return content
.map((part) => {
if (part.type === 'input_text') return part.text;
if (part.type === 'agc_resource_reference') {
return `@${labels.get(part.resourceId) ?? part.resourceId}`;
}
if (part.type === 'agc_runtime_region_reference') {
return `@${part.label}`;
}
return '';
})
.join('')
.trim();
}
export function hasMeaningfulDirectCodexContent(
content: readonly DirectCodexUserContentPart[],
) {
return content.some(
(part) => part.type !== 'input_text' || part.text.trim().length > 0,
);
}
export function chatReferenceToContentPart( export function chatReferenceToContentPart(
reference: ChatReference, reference: ChatReference,
): DirectCodexUserContentPart { ): DirectCodexUserContentPart {
@@ -103,13 +136,13 @@ export function chatReferenceToContentPart(
return { return {
type: 'agc_runtime_region_reference', type: 'agc_runtime_region_reference',
label: reference.label, label: reference.label,
runId: reference.runId, runId: reference.runId ?? null,
versionId: reference.versionId, versionId: reference.versionId ?? null,
elementTag: reference.elementTag, elementTag: reference.elementTag ?? null,
elementRole: reference.elementRole, elementRole: reference.elementRole ?? null,
text: reference.text, text: reference.text ?? null,
width: reference.width, width: reference.width ?? null,
height: reference.height, height: reference.height ?? null,
resourceIds: reference.resourceIds, resourceIds: reference.resourceIds,
}; };
} }
@@ -118,24 +151,19 @@ export function chatComposerDraftToDirectCodexUserItem(
draft: ChatComposerDraft, draft: ChatComposerDraft,
id: string, id: string,
): DirectCodexUserItem { ): DirectCodexUserItem {
const content = draft.content?.length
? draft.content
: draft.references.length > 0
? [
...(draft.text
? [{ type: 'input_text' as const, text: draft.text }]
: []),
...draft.references.map(chatReferenceToContentPart),
]
: draft.text
? [{ type: 'input_text' as const, text: draft.text }]
: [];
return { return {
type: 'message', type: 'message',
role: 'user', role: 'user',
content, content: [...draft.content],
id, id,
} satisfies DirectCodexUserMessageItem; } satisfies DirectCodexUserItem;
}
export function directCodexUserItemFromContent(
content: readonly DirectCodexUserContentPart[],
id: string,
): DirectCodexUserItem {
return chatComposerDraftToDirectCodexUserItem({ content: [...content] }, id);
} }
export function resourceDisplayName(asset: GameCreationAppAssetManifestEntry) { export function resourceDisplayName(asset: GameCreationAppAssetManifestEntry) {
@@ -1,9 +1,5 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { ContentEditable } from '@lexical/react/LexicalContentEditable'; import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { readImage, readText } from '@tauri-apps/plugin-clipboard-manager'; import { readImage, readText } from '@tauri-apps/plugin-clipboard-manager';
import { import {
$createParagraphNode, $createParagraphNode,
@@ -13,13 +9,13 @@ import {
COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_EDITOR,
COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_HIGH,
createCommand, createCommand,
KEY_ENTER_COMMAND,
type LexicalCommand, type LexicalCommand,
PASTE_COMMAND, PASTE_COMMAND,
} from 'lexical'; } from 'lexical';
import { Upload } from 'lucide-react'; import { Upload } from 'lucide-react';
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import RichTextInput from '../../../../components/RichTextInput';
import type { Draft, HomeAttachmentDraft } from '../../useHomeDraftStore'; import type { Draft, HomeAttachmentDraft } from '../../useHomeDraftStore';
import { $createAttachmentNode, AttachmentNode } from './attachmentNode'; import { $createAttachmentNode, AttachmentNode } from './attachmentNode';
@@ -101,29 +97,9 @@ function selectEditableEndWhenNeeded() {
root.selectEnd(); root.selectEnd();
} }
function EditorPlugins({ function EditorPlugins() {
onChange,
onEnter,
}: Pick<RichInputAreaProps, 'onChange' | 'onEnter'>) {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext();
useEffect(
() =>
editor.registerCommand(
KEY_ENTER_COMMAND,
(event) => {
if (!event || event.shiftKey || event.isComposing) {
return false;
}
event.preventDefault();
onEnter();
return true;
},
COMMAND_PRIORITY_HIGH,
),
[editor, onEnter],
);
useEffect( useEffect(
() => () =>
editor.registerCommand( editor.registerCommand(
@@ -188,13 +164,7 @@ function EditorPlugins({
[editor], [editor],
); );
return ( return null;
<OnChangePlugin
onChange={(editorState) => {
onChange(editorState);
}}
/>
);
} }
export function UploadButton() { export function UploadButton() {
@@ -229,34 +199,27 @@ export function UploadButton() {
export default function RichInputArea(props: RichInputAreaProps) { export default function RichInputArea(props: RichInputAreaProps) {
return ( return (
<LexicalComposer <RichTextInput
initialConfig={{ namespace="home-rich-input"
namespace: 'home-rich-input', nodes={[AttachmentNode]}
nodes: [AttachmentNode], initialEditorState={props.value}
editorState: props.value ?? undefined, onChange={props.onChange}
onError: (error) => { onEnter={props.onEnter}
throw error; containerClassName="relative grid min-h-9 gap-2"
}, contentEditable={
}} <ContentEditable
> aria-label="创作想法"
<div className="relative grid min-h-9 gap-2"> className="min-h-9 w-full whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 text-[13px] text-(--platform-text-strong) outline-0"
<RichTextPlugin
contentEditable={
<ContentEditable
aria-label="创作想法"
className="min-h-9 w-full whitespace-pre-wrap wrap-break-word border-0 bg-transparent p-0 text-[13px] text-(--platform-text-strong) outline-0"
/>
}
placeholder={
<span className="pointer-events-none absolute inset-x-0 top-0 text-[13px] text-(--platform-text-muted)">
{props.placeholder}
</span>
}
ErrorBoundary={LexicalErrorBoundary}
/> />
{props.children} }
</div> placeholder={
<EditorPlugins onChange={props.onChange} onEnter={props.onEnter} /> <span className="pointer-events-none absolute inset-x-0 top-0 text-[13px] text-(--platform-text-muted)">
</LexicalComposer> {props.placeholder}
</span>
}
>
<EditorPlugins />
{props.children}
</RichTextInput>
); );
} }
@@ -105,10 +105,13 @@ import {
type LocalGamePreviewInspectSelection, type LocalGamePreviewInspectSelection,
resolveEmbeddedPreviewUrl, resolveEmbeddedPreviewUrl,
} from '../../features/project-workspace/LocalGamePreviewFrame'; } from '../../features/project-workspace/LocalGamePreviewFrame';
import { ResourceReferenceInput } from '../../features/project-workspace/ResourceReferenceInput'; import {
ResourceReferenceInput,
type ResourceReferenceInputHandle,
} from '../../features/project-workspace/ResourceReferenceInput';
import { import {
type ChatComposerDraft, type ChatComposerDraft,
type ChatReference, directCodexContentToPromptText,
dispatchResourceReferenceInsert, dispatchResourceReferenceInsert,
isResourceReferenceOverlayTarget, isResourceReferenceOverlayTarget,
resolveActiveIterationVersion, resolveActiveIterationVersion,
@@ -1555,6 +1558,9 @@ export default function ProjectDevelopmentView({
useState<CanvasLayer | null>(null); useState<CanvasLayer | null>(null);
const [quickEditPanel, setQuickEditPanel] = const [quickEditPanel, setQuickEditPanel] =
useState<QuickEditPanelState | null>(null); useState<QuickEditPanelState | null>(null);
const quickEditPromptInputRef = useRef<ResourceReferenceInputHandle | null>(
null,
);
/** /**
* `@` * `@`
* *
@@ -1563,9 +1569,6 @@ export default function ProjectDevelopmentView({
* `@` * `@`
* *
*/ */
const [quickEditReferences, setQuickEditReferences] = useState<
ChatReference[]
>([]);
/** /**
* *
* *
@@ -6470,7 +6473,6 @@ export default function ProjectDevelopmentView({
setQuickEditSourceLayer(layer); setQuickEditSourceLayer(layer);
const panelDraft = createResourceQuickEditPanelDraft(layer); const panelDraft = createResourceQuickEditPanelDraft(layer);
setQuickEditPanel(panelDraft); setQuickEditPanel(panelDraft);
setQuickEditReferences([]);
resourceQuickEditRequestRef.current = { resourceQuickEditRequestRef.current = {
...createResourceEditRequestIdentity(panelDraft.prompt), ...createResourceEditRequestIdentity(panelDraft.prompt),
sourceLayerId: layer.id, sourceLayerId: layer.id,
@@ -6487,33 +6489,51 @@ export default function ProjectDevelopmentView({
* *
* composer `resetFailedDialogStatus` * composer `resetFailedDialogStatus`
* idle * idle
* 稿稿 `manifest.assets`
* chip `@内部 id` `@显示名`
* `replaceText`
*/ */
const applyResourceQuickEditPrompt = useCallback((text: string) => { const applyResourceQuickEditPrompt = useCallback(
setQuickEditPanel((current) => (text: string) => {
current const currentDraft = quickEditPromptInputRef.current?.getDraft();
? { if (
...current, currentDraft &&
prompt: text, directCodexContentToPromptText(
status: current.status === 'failed' ? 'idle' : current.status, currentDraft.content,
errorMessage: manifest.assets,
current.status === 'failed' ? undefined : current.errorMessage, ) !== text
} ) {
: current, quickEditPromptInputRef.current?.replaceText(text);
); }
}, []); setQuickEditPanel((current) =>
current
? {
...current,
prompt: text,
status: current.status === 'failed' ? 'idle' : current.status,
errorMessage:
current.status === 'failed' ? undefined : current.errorMessage,
}
: current,
);
},
[manifest.assets],
);
/** /**
* 稿 + `@` * 稿 + `@`
* *
* `applyResourceQuickEditPrompt` * `applyResourceQuickEditPrompt`
* chip payload `@` * chip payload `@`
* `@` `manifest.assets`
*/ */
const applyResourceQuickEditDraft = useCallback( const applyResourceQuickEditDraft = useCallback(
(draft: ChatComposerDraft) => { (draft: ChatComposerDraft) => {
setQuickEditReferences(draft.references); applyResourceQuickEditPrompt(
applyResourceQuickEditPrompt(draft.text); directCodexContentToPromptText(draft.content, manifest.assets),
);
}, },
[applyResourceQuickEditPrompt], [applyResourceQuickEditPrompt, manifest.assets],
); );
/** /**
@@ -8128,9 +8148,15 @@ export default function ProjectDevelopmentView({
// 由它拼装,因此出站 payload 与聊天 `@` 一致。 // 由它拼装,因此出站 payload 与聊天 `@` 一致。
<div className="resource-canvas-quick-edit-prompt-input"> <div className="resource-canvas-quick-edit-prompt-input">
<ResourceReferenceInput <ResourceReferenceInput
ref={quickEditPromptInputRef}
key={quickEditSourceLayer?.id}
ariaLabel="快速编辑提示词" ariaLabel="快速编辑提示词"
value={quickEditPanel.prompt} initialContent={[
references={quickEditReferences} {
type: 'input_text',
text: quickEditPanel.prompt,
},
]}
onChange={applyResourceQuickEditDraft} onChange={applyResourceQuickEditDraft}
assets={manifest.assets} assets={manifest.assets}
projectPath={projectPath} projectPath={projectPath}

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