Compare commits

..

39 Commits

Author SHA1 Message Date
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
66 changed files with 1626 additions and 3095 deletions
-16
View File
@@ -172,20 +172,6 @@ _Avoid_: 多步骤向导、完整规则编辑器、拖拽编辑器
Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。
_Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
## 项目开发对话(DirectProject
**项目对话历史**:
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
**运行态事件**:
Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。
_Avoid_: 进度通知、快照轮询、第二套历史
**聊天投影**:
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
## Relationships
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**
@@ -220,5 +206,3 @@ _Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
- “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。
- “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。
- “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。
- “回合进度事件”曾同时指 Direct turn update 与 Thread Manager 运行态事件;已解析为 AGC 项目开发对话只保留 **运行态事件**
- “哪些消息可显示”曾可能由后端历史分页判断;已解析为可见性判断属于 **聊天投影**,后端只按原始条目分页,前端负责跳过不可显示条目并推进分页锚点。
@@ -21,7 +21,6 @@ mod direct_project_history;
mod direct_project_turn_history;
mod direct_runtime;
mod direct_thread_manager;
mod direct_thread_wire;
mod direct_tool_bridge;
mod direct_tool_calls;
mod direct_tools_mcp;
@@ -56,7 +55,6 @@ pub(crate) use direct_project_history::*;
pub(crate) use direct_project_turn_history::*;
pub(crate) use direct_runtime::*;
pub(crate) use direct_thread_manager::*;
pub(crate) use direct_thread_wire::*;
pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tool_calls::*;
pub(crate) use direct_tools_mcp::*;
@@ -564,17 +564,6 @@ enum CodexTurnEvent {
item_id: String,
delta: String,
},
/// 思考正文增量:app-server `item/reasoning/summaryTextDelta` 的明文思考文本。
///
/// `item/reasoning/summaryTextDelta`core `ReasoningContentDelta`)与
/// `item/reasoning/textDelta`core `ReasoningRawContentDelta`)都进这条通道:前者是
/// reasoning item 的 `summary`,后者是它的 `content`,两段文本都随 `item/completed`
/// 落进 `project.jsonl`、此前也已经在完成时展示给用户。plan 文本与命令输出仍然只降级为
/// 活动状态,不下发正文。
ReasoningDelta {
item_id: String,
delta: String,
},
IntermediateText(String),
Activity(&'static str),
Item {
@@ -582,7 +571,7 @@ enum CodexTurnEvent {
params: serde_json::Value,
},
Request {
kind: DirectThreadRequestKind,
event_type: &'static str,
params: serde_json::Value,
},
RawItem(serde_json::Value),
@@ -752,14 +741,23 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat
direct_codex_safe_activity_for_item(item_type)
}
/// 运行态事件载荷:与历史切片同形的脱敏原始条目;拿不到身份或类型就整条跳过。
///
/// 这里不生成工具卡片形状:标题、折叠摘要和可见性都是前端投影的职责。
fn direct_thread_event_item(
root: &std::path::Path,
item: &serde_json::Value,
) -> Option<DirectThreadItem> {
direct_thread_item_from_value(root, item, direct_tool_call_now_ms())
/// Project an app-server item into the small public payload carried by the
/// DirectProject event queue. Full item contents are persisted in JSONL and
/// must not be forwarded through the runtime event stream.
fn direct_thread_item_started_payload(item: &serde_json::Value) -> serde_json::Value {
serde_json::json!({
"itemType": item
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown"),
})
}
fn direct_thread_item_id(item: &serde_json::Value) -> Option<String> {
item.get("id")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn direct_codex_command_is_game_verification(command: &str) -> bool {
@@ -976,21 +974,19 @@ fn direct_codex_safe_activity_for_notification(method: &str) -> Option<&'static
}
}
fn direct_codex_request_event_type(method: &str) -> Option<DirectThreadRequestKind> {
fn direct_codex_request_event_type(method: &str) -> Option<&'static str> {
match method {
"item/fileChange/requestApproval"
| "item/commandExecution/requestApproval"
| "item/permissions/requestApproval" => Some(DirectThreadRequestKind::ApprovalRequested),
"item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => {
Some(DirectThreadRequestKind::AskRequested)
}
| "item/permissions/requestApproval" => Some("approval.requested"),
"item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => Some("ask.requested"),
_ => None,
}
}
fn direct_codex_resolution_event_type(method: &str) -> Option<DirectThreadRequestKind> {
fn direct_codex_resolution_event_type(method: &str) -> Option<&'static str> {
match method {
"serverRequest/resolved" => Some(DirectThreadRequestKind::RequestResolved),
"serverRequest/resolved" => Some("request.resolved"),
_ => None,
}
}
@@ -1063,33 +1059,6 @@ fn direct_codex_notification_event(
intermediate_text: Option<String>,
safe_activity: Option<&'static str>,
) -> Option<CodexTurnEvent> {
// 思考正文走独立通道,交给 DirectProject 的运行态事件;它不因为
// "preparing 活动" 的降级规则被丢掉,否则界面只能等 item/completed 才看到思考。
//
// 两条通知都下发正文,不下发活动文本:
// - `item/reasoning/summaryTextDelta`core `ReasoningContentDelta`)→ reasoning item 的 `summary`
// - `item/reasoning/textDelta`core `ReasoningRawContentDelta`)→ reasoning item 的 `content`
// 正是 `project.jsonl` 里保存、并在此前 `item/completed` 已经展示给用户的同一段文本。
// 因此这里只是把"完成时才看到"提前为"边生成边看到",没有放宽可见文本的范围;
// 未识别的 plan 文本与命令输出仍然只降级为活动状态,不下发正文。
if matches!(
method,
"item/reasoning/summaryTextDelta" | "item/reasoning/textDelta"
) {
return params
.get("delta")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
.map(|delta| CodexTurnEvent::ReasoningDelta {
item_id: params
.get("itemId")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(|| "direct-missing-item".to_string()),
delta: delta.to_string(),
});
}
let (activity, intermediate_text) = match (&intermediate_text, safe_activity) {
(Some(_), Some(activity)) if activity == "preparing" => (Some(activity), None),
_ => (safe_activity, intermediate_text),
@@ -2957,7 +2926,18 @@ impl CodexAppServerConnection {
turn_start_guard.armed = false;
let direct_thread_id = history_root.to_string_lossy().into_owned();
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
append_direct_thread_event(&direct_thread_id, DirectThreadEvent::turn_started());
append_direct_thread_event(
&direct_thread_id,
DirectThreadRawEventDraft {
event_type: "turn.started".to_string(),
turn_id: turn_id.clone(),
item_id: None,
payload: serde_json::json!({
"threadId": thread_id,
"turnId": turn_id,
}),
},
);
}
let mut receiver = self.register_turn(&turn_id).await;
let mut direct_project_history = DirectProjectHistoryAccumulator::default();
@@ -3012,13 +2992,12 @@ impl CodexAppServerConnection {
direct_project_history.observe_delta(&item_id, &delta);
append_direct_thread_event(
&direct_thread_id,
// 事件自足:增量自带 item 身份与正文类别(正文 / 思考),
// 前端 reducer 不允许靠猜 itemId 的来源决定 kind。
DirectThreadEvent::item_delta(
item_id.clone(),
DirectThreadDeltaKind::Message,
delta.clone(),
),
DirectThreadRawEventDraft {
event_type: "item.delta".to_string(),
turn_id: turn_id.clone(),
item_id: Some(item_id.clone()),
payload: serde_json::json!({ "delta": delta.clone() }),
},
);
}
streamed_text.push_str(&delta);
@@ -3049,18 +3028,6 @@ impl CodexAppServerConnection {
});
}
}
Some(CodexTurnEvent::ReasoningDelta { item_id, delta }) => {
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::item_delta(
item_id,
DirectThreadDeltaKind::Reasoning,
delta,
),
);
}
}
Some(CodexTurnEvent::IntermediateText(text)) => {
if let Some(observer) = direct_observer.as_deref_mut() {
observer(DirectCodexTurnObservation::IntermediateText(text));
@@ -3073,7 +3040,6 @@ impl CodexAppServerConnection {
"rawResponseItem/completed 缺少 item".to_string(),
));
}
let entry_item = direct_thread_event_item(history_root, &item);
let history_root = history_root.to_path_buf();
let history_item = item.clone();
tokio::task::spawn_blocking(move || {
@@ -3087,15 +3053,19 @@ impl CodexAppServerConnection {
})?
.map_err(platform_llm::LlmError::InvalidRequest)?;
direct_project_history.complete_item(&item);
if let Some(entry_item) = entry_item {
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::item_completed(entry_item),
);
}
let item_id = direct_thread_item_id(&item);
append_direct_thread_event(
&direct_thread_id,
DirectThreadRawEventDraft {
event_type: "item.completed".to_string(),
turn_id: turn_id.clone(),
item_id,
payload: serde_json::json!({}),
},
);
}
}
Some(CodexTurnEvent::Request { kind, params }) => {
Some(CodexTurnEvent::Request { event_type, params }) => {
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
let request_id = params
.get("requestId")
@@ -3105,7 +3075,14 @@ impl CodexAppServerConnection {
.map(str::to_string);
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::request(kind, request_id),
DirectThreadRawEventDraft {
event_type: event_type.to_string(),
turn_id: turn_id.clone(),
item_id: None,
payload: request_id
.map(|id| serde_json::json!({ "requestId": id }))
.unwrap_or_else(|| serde_json::json!({})),
},
);
}
}
@@ -3216,14 +3193,16 @@ impl CodexAppServerConnection {
&& self.inner.workspace_mode
== CodexAppServerWorkspaceMode::DirectProject
{
if let Some(entry_item) =
direct_thread_event_item(history_root, item)
{
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::item_started(entry_item),
);
}
let item_id = direct_thread_item_id(item);
append_direct_thread_event(
&direct_thread_id,
DirectThreadRawEventDraft {
event_type: "item.started".to_string(),
turn_id: turn_id.clone(),
item_id,
payload: direct_thread_item_started_payload(item),
},
);
}
}
}
@@ -3265,7 +3244,12 @@ impl CodexAppServerConnection {
{
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::turn_completed(status.to_string()),
DirectThreadRawEventDraft {
event_type: "turn.completed".to_string(),
turn_id: turn_id.clone(),
item_id: None,
payload: serde_json::json!({ "status": status }),
},
);
}
match status {
@@ -3960,8 +3944,8 @@ async fn read_game_creator_codex_app_server_stdout(
continue;
}
}
let event = if let Some(kind) = direct_codex_resolution_event_type(method) {
CodexTurnEvent::Request { kind, params }
let event = if let Some(event_type) = direct_codex_resolution_event_type(method) {
CodexTurnEvent::Request { event_type, params }
} else if let Some(activity) = safe_activity {
// Preparing notifications may carry private plan/reasoning text;
// expose only the safe activity category. Other categories may
@@ -4013,8 +3997,8 @@ async fn read_game_creator_codex_app_server_stdout(
),
method if direct_codex_request_event_type(method).is_some() => {
CodexTurnEvent::Request {
kind: direct_codex_request_event_type(method)
.expect("request kind checked above"),
event_type: direct_codex_request_event_type(method)
.expect("request event type checked above"),
params,
}
}
@@ -4525,29 +4509,11 @@ mod tests {
"arguments": { "path": "game/index.html", "token": "secret" },
"result": { "content": "large output" }
});
// 运行态事件必须自足:载荷是脱敏原始条目,前端不需要再按 itemId 取快照。
let projected = direct_thread_event_item(std::path::Path::new("."), &item).expect("item");
assert_eq!(projected.item_id(), "item-1");
let payload = serde_json::to_value(&projected).expect("payload");
assert_eq!(direct_thread_item_id(&item).as_deref(), Some("item-1"));
assert_eq!(
payload.get("itemType").and_then(serde_json::Value::as_str),
Some("mcpToolCall")
direct_thread_item_started_payload(&item),
serde_json::json!({ "itemType": "mcpToolCall" })
);
assert_eq!(
payload.get("itemId").and_then(serde_json::Value::as_str),
Some("item-1")
);
// 卡片标题 / 折叠摘要 / kind 属于前端投影:载荷里不得出现这些 UI 语义。
assert!(payload.get("toolCall").is_none(), "{payload}");
assert!(payload.get("title").is_none(), "{payload}");
assert!(payload.get("summary").is_none(), "{payload}");
assert!(payload.get("kind").is_none(), "{payload}");
// 参数里的密钥不得随载荷下发(脱敏占位符可以保留,明文不行)。
let arguments = payload
.get("arguments")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
assert!(!arguments.contains("\"secret\""), "{payload}");
}
#[test]
@@ -5,8 +5,9 @@ mod validation;
mod wire;
pub(crate) use model::{
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageEnvelope,
DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
DirectCodexUserMessageEnvelope, DirectCodexUserMessageItem, DirectCodexUserRole,
DirectCodexUserRuntimeRegionPart,
};
pub(crate) use validation::validate_direct_codex_user_item;
pub(crate) use wire::{
@@ -36,6 +36,21 @@ pub(crate) enum DirectCodexUserContentPart {
AgcResourceReference { resource_id: String },
#[serde(rename = "agc_runtime_region_reference")]
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
/// Uploaded project attachment kept inline in canonical content.
#[serde(rename = "agc_attachment_reference")]
AgcAttachmentReference(DirectCodexUserAttachmentReferencePart),
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
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)]
@@ -40,6 +40,18 @@ pub(crate) fn validate_direct_codex_user_item(
reference_count = reference_count.saturating_add(1);
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 {
@@ -100,6 +100,20 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
summary.push(']');
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 }));
}
@@ -174,4 +188,26 @@ mod tests {
.expect_err("history item without type must fail");
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"));
}
}
@@ -31,10 +31,9 @@ pub(crate) fn normalize_direct_client_turn_id(
pub(crate) async fn chat_with_game_creator_direct_codex(
project_path: String,
prompt: String,
mut user_item: DirectCodexUserItem,
user_item: DirectCodexUserItem,
creation_type: Option<String>,
client_turn_id: Option<String>,
attachments: Option<Vec<DirectCodexTurnAttachment>>,
) -> Result<String, String> {
let root = Path::new(project_path.trim());
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
@@ -43,24 +42,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
})?;
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
let mut audit = DirectCodexTurnAudit::start(
root,
&turn_id,
&prompt,
attachments.as_deref().unwrap_or_default(),
);
let attachments = attachments.unwrap_or_default();
if !attachments.is_empty() {
let attachment_context =
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,
});
}
let mut audit = DirectCodexTurnAudit::start(root, &turn_id, &prompt, &[]);
validate_direct_codex_user_item(root, &user_item).map_err(|error| {
audit.finish(false);
error
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -8,9 +8,11 @@
//! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的
//! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。
use super::direct_thread_wire::sanitize_detail_text;
use crate::agent::redact_secret_tokens;
use crate::agent::sanitize_error_context;
use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file};
use crate::project::{enforce_project_permission_policy, project_append_lock_for};
use crate::redact_absolute_path_tokens;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
@@ -84,6 +86,134 @@ fn tool_calls_path(root: &Path) -> PathBuf {
root.join(".agent/conversations/tool-calls.jsonl")
}
/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。
fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) {
let mut index = start;
let mut relative = String::new();
while index < value.len() {
let character = value[index..].chars().next().unwrap_or_default();
if matches!(character, '/' | '\\') {
if !relative.is_empty() {
relative.push('/');
}
index += character.len_utf8();
continue;
}
if character.is_whitespace()
|| matches!(
character,
'\'' | '"'
| '`'
| ','
| ';'
| '|'
| '&'
| '('
| ')'
| '['
| ']'
| '{'
| '}'
| '<'
| '>'
| ':'
)
{
break;
}
relative.push(character);
index += character.len_utf8();
}
while relative.ends_with('/') {
relative.pop();
}
(index, relative)
}
/// 把项目根目录前缀换成**项目相对路径**`<root>/game/src/x.ts` → `game/src/x.ts`)。
///
/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成
/// `<absolute-path>`,之后就再也认不出哪些路径在项目内了。
/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。
fn relativize_project_root_paths(root: &Path, value: &str) -> String {
let root_text = root.to_string_lossy();
let root_text = root_text.trim_end_matches(['/', '\\']);
if root_text.is_empty() {
return value.to_string();
}
let mut needles = [
root_text.to_string(),
root_text.replace('\\', "/"),
root_text.replace('/', "\\"),
]
.into_iter()
.map(|needle| needle.to_ascii_lowercase())
.filter(|needle| !needle.is_empty())
.collect::<Vec<_>>();
needles.sort();
needles.dedup();
let lower = value.to_ascii_lowercase();
let mut output = String::with_capacity(value.len());
let mut cursor = 0usize;
while cursor < value.len() {
let mut hit: Option<(usize, usize)> = None;
for needle in &needles {
let mut search = cursor;
while let Some(relative) = lower[search..].find(needle.as_str()) {
let start = search + relative;
let end = start + needle.len();
let left_is_boundary = start == 0
|| lower[..start].chars().next_back().is_some_and(|character| {
!character.is_alphanumeric() && character != '_' && character != '-'
});
if left_is_boundary && value[end..].starts_with(['/', '\\']) {
if hit.is_none_or(|(best_start, _)| start < best_start) {
hit = Some((start, end));
}
break;
}
search = end;
}
}
let Some((start, end)) = hit else {
break;
};
output.push_str(&value[cursor..start]);
let (consumed, relative) = project_relative_path_segment(value, end);
if relative.is_empty() {
// 只写了项目根目录本身(没有后续路径段):按占位形状处理。
output.push_str("<absolute-path>");
} else {
output.push_str(&relative);
}
cursor = consumed;
}
output.push_str(&value[cursor..]);
output
}
/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与
/// 错误上下文脱敏。
///
/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录
/// 仍然会留下;这里先归一化路径 token,再处理密钥。
///
/// 复用既有 `agent/generation/prompt_context.rs` 的脱敏组合:`sanitize_error_context`
/// 就是 `redact_secret_tokens` + `redact_error_sensitive_assignments` +
/// `redact_error_bearer_values` + `redact_error_config_names` 的既有组合用法,覆盖
/// `Authorization: Bearer …`、`Cookie: …`、`api_key=…`、`client_secret=…` 这类键值凭据;
/// 含 `--password` / `--token` / `--secret` 这类敏感 CLI 标志的行按既有 fail-closed
/// 约定整行替换成 `[redacted sensitive context]`(与 `sanitize_agent_runtime_text` 一致)。
///
/// `pub(crate)`:回合流(`direct_turn_stream`)的文本段复用同一套脱敏,避免两处口径分叉。
pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String {
let without_project_root = relativize_project_root_paths(root, value);
let without_absolute = redact_absolute_path_tokens(&without_project_root);
let without_secret = redact_secret_tokens(&without_absolute);
sanitize_error_context(&without_secret)
}
/// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。
fn bounded_chars(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
@@ -5383,21 +5383,10 @@ pub(crate) async fn read_direct_project_history_slice(
before_item_id.as_deref(),
limit.unwrap_or(20),
)?;
let first_item_id = items
.first()
.and_then(|item| item.get("id"))
.and_then(serde_json::Value::as_str)
.map(str::to_string);
let items = direct_thread_items_from_history(root, &items, |item| {
item.get("id")
.and_then(serde_json::Value::as_str)
.and_then(|id| item_timestamps.get(id).copied())
.unwrap_or_default()
});
Ok(DirectThreadHistorySlice {
items,
has_more,
first_item_id,
item_timestamps,
})
})
.await
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>
);
}
@@ -79,7 +79,7 @@ import {
ResourceReferenceInput,
type ResourceReferenceInputHandle,
} from './ResourceReferenceInput';
import type { ChatComposerDraft, ChatReference } from './resourceReferences';
import type { ChatComposerDraft } from './resourceReferences';
import { ToolCallGroup } from './ToolCallGroup';
import {
formatClockTime,
@@ -242,8 +242,6 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
attachments?: DirectCodexTurnAttachment[];
/** 上传/校验附件的提示文案(失败与成功都用它,空串不渲染)。 */
attachmentNotice?: string;
chatInput: string;
chatReferences: ChatReference[];
chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[];
composerRef?: RefObject<ResourceReferenceInputHandle | null>;
directCodex?: boolean;
@@ -327,8 +325,6 @@ export function ProjectSupervisorView({
activeVersionId = null,
attachments = [],
attachmentNotice = '',
chatInput,
chatReferences,
chatProjectAssets,
composerRef,
directCodex = false,
@@ -916,8 +912,6 @@ export function ProjectSupervisorView({
Boolean(designView?.session.pendingClarification)
}
rows={3}
value={chatInput}
references={chatReferences}
showTriggerButton={!directCodex}
placeholder={
directCodex
@@ -55,7 +55,7 @@ import {
ResourceReferenceInput,
type ResourceReferenceInputHandle,
} from './ResourceReferenceInput';
import type { ChatComposerDraft, ChatReference } from './resourceReferences';
import type { ChatComposerDraft } from './resourceReferences';
type ProjectWorkspaceChatPaneProps = {
activeVersionId?: string | null;
@@ -65,8 +65,6 @@ type ProjectWorkspaceChatPaneProps = {
cancelProjectCreateInNonEmptyFolder: () => void;
cancelUiCommandConfirmation: () => void;
chatAgentBusy: boolean;
chatInput: string;
chatReferences: ChatReference[];
chatProjectAssets: GameCreationAppAssetManifestEntry[];
composerRef?: Ref<ResourceReferenceInputHandle>;
chatInputRef: RefObject<HTMLDivElement | null>;
@@ -215,8 +213,6 @@ export function ProjectWorkspaceChatPane({
cancelProjectCreateInNonEmptyFolder,
cancelUiCommandConfirmation,
chatAgentBusy,
chatInput,
chatReferences,
chatProjectAssets,
composerRef,
chatInputRef,
@@ -962,8 +958,6 @@ export function ProjectWorkspaceChatPane({
projectPath={projectPath}
disabled={chatAgentBusy || projectSupervisorNeedsUserInput}
multiline={false}
value={chatInput}
references={chatReferences}
placeholder="例如:像素风横版动作小游戏,或输入 @ 选择资源"
onChange={onChatComposerChange}
/>
File diff suppressed because it is too large Load Diff
@@ -33,7 +33,7 @@ import {
ResourceReferenceInput,
type ResourceReferenceInputHandle,
} from './ResourceReferenceInput';
import type { ChatComposerDraft, ChatReference } from './resourceReferences';
import type { ChatComposerDraft } from './resourceReferences';
type RuntimeControlProps = ComponentProps<
typeof ProjectSupervisorRuntimeControls
@@ -44,8 +44,6 @@ const CHAT_SCROLL_BOTTOM_THRESHOLD = 24;
type SupervisorChatOnlyViewProps = {
activeVersionId?: string | null;
chatAgentBusy: boolean;
chatInput: string;
chatReferences: ChatReference[];
chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[];
composerRef?: Ref<ResourceReferenceInputHandle>;
directCodex?: boolean;
@@ -82,8 +80,6 @@ type SupervisorChatOnlyViewProps = {
export function SupervisorChatOnlyView({
activeVersionId = null,
chatAgentBusy,
chatInput,
chatReferences,
chatProjectAssets,
composerRef,
directCodex = false,
@@ -328,8 +324,6 @@ export function SupervisorChatOnlyView({
projectPath={projectPath}
disabled={chatAgentBusy || needsUserInput}
rows={3}
value={chatInput}
references={chatReferences}
placeholder={
directCodex ? '描述你的想法' : '给项目总控 Agent 发消息'
}
@@ -9,7 +9,7 @@ import {
import { useEffect, useId, useState } from 'react';
import { AgentMessageContent } from '../../../../../packages/shared/src/components/AgentMessageContent';
import type { DirectChatToolCard } from './directThreadChat';
import type { GameCreatorDirectToolCall } from '../../app/types';
import {
formatToolCallDuration,
formatTurnDuration,
@@ -38,7 +38,7 @@ export function ToolCallGroup({
active = false,
className,
}: {
calls: DirectChatToolCard[];
calls: GameCreatorDirectToolCall[];
/** 同一回合用户消息的 `updatedAt`;拿不到就传 0,只显示结束时间。 */
userSentAt?: number | null;
/**
@@ -169,7 +169,7 @@ function ToolCallRow({
call,
active,
}: {
call: DirectChatToolCard;
call: GameCreatorDirectToolCall;
active: boolean;
}) {
const [expanded, setExpanded] = useState(false);
@@ -4,36 +4,32 @@
* 回合运行中用户再次发送时,消息进入 FIFO 队列而不是被丢弃;当前回合结束后按入队顺序
* 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。
*/
import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments';
import type { DirectCodexUserContentPart } from './generated';
import type { ChatReference } from './resourceReferences';
import type { DirectCodexUserItem } from './generated';
import { directCodexContentToPromptText } from './resourceReferences';
/** 队列上限:满了以后拒绝入队并给出可读提示,而不是静默丢消息。 */
export const MAX_QUEUED_CHAT_TURNS = 5;
export type QueuedChatTurn = {
id: string;
prompt: string;
attachments: DirectCodexTurnAttachment[];
references: ChatReference[];
content?: DirectCodexUserContentPart[];
clientTurnId: string;
userItem: DirectCodexUserItem;
createdAt: number;
};
export function createQueuedChatTurn(input: {
id: string;
prompt: string;
attachments?: readonly DirectCodexTurnAttachment[];
references?: readonly ChatReference[];
content?: readonly DirectCodexUserContentPart[];
clientTurnId: string;
userItem: DirectCodexUserItem;
createdAt: number;
}): QueuedChatTurn {
return {
id: input.id,
prompt: input.prompt,
attachments: [...(input.attachments ?? [])],
references: [...(input.references ?? [])],
content: [...(input.content ?? [])],
clientTurnId: input.clientTurnId,
userItem: {
...input.userItem,
content: [...input.userItem.content],
},
createdAt: input.createdAt,
};
}
@@ -79,14 +75,19 @@ export function chatQueueFullNotice(): string {
/** 队列 chip 上显示的文字:单行、有长度上限。 */
export function queuedChatTurnLabel(turn: QueuedChatTurn): string {
const text = turn.prompt.trim().replace(/\s+/gu, ' ');
const text = directCodexContentToPromptText(turn.userItem.content)
.trim()
.replace(/\s+/gu, ' ');
if (text) {
return text.length > 24 ? `${text.slice(0, 24)}` : text;
}
if (turn.attachments.length > 0) {
return `附件 · ${turn.attachments[0]?.name ?? '未命名'}`;
const attachment = turn.userItem.content.find(
(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 '未命名消息';
@@ -1,8 +1,5 @@
import { resolveTauriInvoke } from '../../app/tauri';
import {
type ChatComposerDraft,
chatReferenceListKey,
} from './resourceReferences';
import type { DirectCodexUserContentPart } from './generated';
/**
* 「不再提醒」偏好存本机 localStorage,不进 manifest、不进后端。
@@ -61,8 +58,10 @@ export function writeChatPromptPolishReminderDisabled(disabled: boolean) {
}
/** 草稿指纹:用于判断「本轮草稿」是否已经被润色或确认过。 */
export function chatPromptDraftKey(draft: ChatComposerDraft) {
return `${draft.text}\u0000${chatReferenceListKey(draft.references)}`;
export function chatPromptDraftKey(
content: readonly DirectCodexUserContentPart[],
) {
return JSON.stringify(content);
}
/**
@@ -73,25 +72,27 @@ export function chatPromptDraftKey(draft: ChatComposerDraft) {
* 4. 草稿不是以 `/` 开头的命令 —— 命令走直通路径,不参与提醒。
*/
export function shouldRemindChatPromptPolish({
draft,
content,
prompt,
acknowledgedDraftKey,
reminderDisabled,
}: {
draft: ChatComposerDraft;
content: readonly DirectCodexUserContentPart[];
prompt: string;
acknowledgedDraftKey: string | null;
reminderDisabled: boolean;
}) {
if (reminderDisabled) {
return false;
}
const text = draft.text.trim();
const text = prompt.trim();
if (text.length < CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH) {
return false;
}
if (text.startsWith('/')) {
return false;
}
return chatPromptDraftKey(draft) !== acknowledgedDraftKey;
return chatPromptDraftKey(content) !== acknowledgedDraftKey;
}
/**
@@ -1,271 +0,0 @@
/**
* DirectProject 聊天 reducer:把运行态事件与历史切片归并成同一份聊天条目。
*
* 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"。顺序 = 历史文件顺序 +
* 运行态独有条目。这里不做可见性判断(那是投影的事),也不认任何回合身份:DirectProject
* 同一时刻只有一个回合在跑,`turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔。
*/
import type { GameCreatorDirectToolCall } from '../../app/types';
import type {
DirectThreadConsumeResult,
DirectThreadEvent,
DirectThreadHistorySlice,
DirectThreadItem,
DirectThreadSubscriptionBootstrap,
} from './directThreadEvents';
import { projectDirectThreadItem } from './directThreadItemProjection';
export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool';
/** 聊天卡片里的工具形状:持久化卡片去掉回合身份(Rust 侧已经不下发 turn id)。 */
export type DirectChatToolCard = Omit<GameCreatorDirectToolCall, 'turnId'>;
/** 聊天视图里的一条条目;运行态事件与历史切片共用的唯一形状。 */
export type DirectChatEntry = {
itemId: string;
kind: DirectChatEntryKind;
role?: 'user' | 'assistant' | null;
text?: string | null;
toolCall?: DirectChatToolCard | null;
at?: number;
};
export type DirectThreadChatState = {
subscriptionId: string | null;
/** 首屏历史锚点:`subscribe` 给出的最后一条完成条目 id。 */
lastCompletedItemId: string | null;
/** 最新回合是否还在跑;只由生命周期事件的先后决定。 */
turnRunning: boolean;
/** 历史切片条目,保持文件顺序。 */
history: DirectChatEntry[];
/** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */
live: DirectChatEntry[];
};
export function emptyDirectThreadChatState(): DirectThreadChatState {
return {
subscriptionId: null,
lastCompletedItemId: null,
turnRunning: false,
history: [],
live: [],
};
}
function longerText(
left: string | null | undefined,
right: string | null | undefined,
): string | null {
const a = typeof left === 'string' ? left : '';
const b = typeof right === 'string' ? right : '';
// 正文只增不减:增量往同一段落追加,完成快照可能比累计更长(漏过几条 delta)。
return b.length > a.length ? b : a;
}
function mergeToolStatus(
left: DirectChatToolCard['status'] | null | undefined,
right: DirectChatToolCard['status'] | null | undefined,
): DirectChatToolCard['status'] {
// 只有终态才算数:先到的 `running` 允许被后到的完成 / 失败覆盖,反过来不行。
if (left === 'running' || !left) return right ?? left ?? 'running';
return left;
}
function mergeToolCard(
left: DirectChatToolCard | null,
right: DirectChatToolCard | null,
): DirectChatToolCard | null {
if (!left) return right;
if (!right) return left;
return {
...left,
kind: left.kind && left.kind !== 'other' ? left.kind : right.kind,
title: left.title?.trim() ? left.title : right.title,
summary: left.summary?.trim() ? left.summary : right.summary,
status: mergeToolStatus(left.status, right.status),
detail: {
command: left.detail.command ?? right.detail.command,
output: left.detail.output ?? right.detail.output,
changes: left.detail.changes?.length
? left.detail.changes
: right.detail.changes,
},
startedAt: left.startedAt > 0 ? left.startedAt : right.startedAt,
updatedAt: Math.max(left.updatedAt, right.updatedAt),
};
}
/**
* 先到的快照赢,后到的只补空字段。
*
* 三个例外只有"后到信息一定更全"时才成立:正文取更长的一份、工具状态允许从 `running`
* 升级到终态、`updatedAt` 取较新的时间。其余字段一律先到先用,后到的空值不得抹掉它。
*/
export function mergeDirectChatEntry(
existing: DirectChatEntry,
incoming: DirectChatEntry,
): DirectChatEntry {
return {
itemId: existing.itemId || incoming.itemId,
kind:
existing.kind === 'tool' || incoming.kind === 'tool'
? 'tool'
: existing.kind,
role: existing.role ?? incoming.role ?? null,
text: longerText(existing.text, incoming.text),
toolCall: mergeToolCard(
existing.toolCall ?? null,
incoming.toolCall ?? null,
),
at: existing.at || incoming.at,
};
}
function upsertLiveEntry(
state: DirectThreadChatState,
entry: DirectChatEntry,
): DirectThreadChatState {
const index = state.live.findIndex(
(existing) => existing.itemId === entry.itemId,
);
if (index < 0) {
return { ...state, live: [...state.live, entry] };
}
const existing = state.live[index];
if (!existing) {
return { ...state, live: [...state.live, entry] };
}
const live = [...state.live];
live[index] = mergeDirectChatEntry(existing, entry);
return { ...state, live };
}
function appendLiveText(
state: DirectThreadChatState,
event: Extract<DirectThreadEvent, { type: 'item.delta' }>,
): DirectThreadChatState {
const itemId = event.itemId.trim();
if (!itemId || !event.delta) return state;
const reasoning = event.kind === 'reasoning';
const existing = state.live.find((entry) => entry.itemId === itemId);
return upsertLiveEntry(state, {
itemId,
kind: reasoning ? 'reasoning' : 'message',
role: reasoning ? null : 'assistant',
text: `${existing?.text ?? ''}${event.delta}`,
});
}
export function reduceDirectThreadEvent(
state: DirectThreadChatState,
event: DirectThreadEvent,
): DirectThreadChatState {
switch (event.type) {
case 'turn.started':
return { ...state, turnRunning: true };
case 'turn.completed':
// 回合结束:条目已经落盘,运行态并入历史后清空,避免同一条目渲染两次。
return {
...state,
turnRunning: false,
history: mergeHistoryEntries(state.history, state.live),
live: [],
};
case 'item.delta':
return appendLiveText(state, event);
case 'item.started':
case 'item.completed': {
const entry = projectDirectThreadItem(event.item);
return entry ? upsertLiveEntry(state, entry) : state;
}
case 'request':
// 审批 / 提问只影响面板交互,不并入聊天条目。
return state;
default:
return state;
}
}
export function reduceDirectThreadEvents(
state: DirectThreadChatState,
events: readonly DirectThreadEvent[],
): DirectThreadChatState {
return events.reduce(reduceDirectThreadEvent, state);
}
/**
* bootstrap 是运行态的唯一权威:游标已在队尾,返回的事件就是此刻要处理的事件。
*
* 历史窗口保留:bootstrap 不重新回读历史切片,那是 `lastCompletedItemId` 的职责。
*/
export function resolveDirectThreadBootstrap(
state: DirectThreadChatState,
bootstrap: DirectThreadSubscriptionBootstrap,
): DirectThreadChatState {
return reduceDirectThreadEvents(
{
...state,
subscriptionId: bootstrap.subscriptionId,
lastCompletedItemId: bootstrap.lastCompletedItemId ?? null,
},
bootstrap.events,
);
}
/** 事件顺序 = 游标顺序;调用方只需要把 `consume` 的结果喂进来。 */
export function applyDirectThreadConsumeResult(
state: DirectThreadChatState,
result: DirectThreadConsumeResult,
): DirectThreadChatState {
return reduceDirectThreadEvents(state, result.events);
}
/** 同一身份的条目合并,先到者在前:历史在前、运行态在后,运行态只补空。 */
export function mergeHistoryEntries(
leading: readonly DirectChatEntry[],
trailing: readonly DirectChatEntry[],
): DirectChatEntry[] {
const byId = new Map<string, number>();
const entries: DirectChatEntry[] = [];
for (const entry of [...leading, ...trailing]) {
const index = byId.get(entry.itemId);
if (index === undefined) {
byId.set(entry.itemId, entries.length);
entries.push(entry);
continue;
}
const existing = entries[index];
if (existing) entries[index] = mergeDirectChatEntry(existing, entry);
}
return entries;
}
/**
* 历史切片并入:切片是脱敏原始条目,投影规则与运行态完全同一份。
*
* 同一调用的调用与输出在这里按身份合并成一张卡片,而不是在 Rust 侧合并。
*/
export function mergeDirectHistoryItems(
state: DirectThreadChatState,
items: readonly DirectThreadItem[],
): DirectThreadChatState {
const entries = items
.map((item) => projectDirectThreadItem(item))
.filter((entry): entry is DirectChatEntry => Boolean(entry));
return { ...state, history: mergeHistoryEntries(entries, state.history) };
}
export function mergeDirectThreadHistorySlice(
state: DirectThreadChatState,
slice: DirectThreadHistorySlice,
): DirectThreadChatState {
return mergeDirectHistoryItems(state, slice.items);
}
/** 聊天投影输入:历史顺序 + 运行态覆盖;运行态独有条目排在最后。 */
export function selectDirectChatEntries(
state: DirectThreadChatState,
): DirectChatEntry[] {
return mergeHistoryEntries(state.history, state.live);
}
@@ -1,45 +1,59 @@
/**
* DirectProject 运行态事件的线上类型。
*
* 类型由 Rust 侧 ts-rs 导出(改完 Rust 模型后跑 `cargo test export_bindings`),这里只做
* 入口转发:前端不再自己抄一份形状,字段增删必须改 Rust。
*/
import type { LocalConversationMessageRecord } from '../../app/types';
import type { DirectThreadItem } from './generated';
export type {
DirectThreadConsumeResult,
DirectThreadDeltaKind,
DirectThreadEvent,
DirectThreadFileChange,
DirectThreadHistorySlice,
DirectThreadItem,
DirectThreadRequestKind,
DirectThreadSubscriptionBootstrap,
} from './generated';
export type DirectThreadRawEvent = {
seq: number;
type: string;
turnId: string;
itemId?: string;
payload: Record<string, unknown>;
};
export type DirectThreadSubscriptionBootstrap = {
subscriptionId: string;
lastCompletedItemId: string | null;
events: DirectThreadRawEvent[];
};
export type DirectThreadConsumeResult = {
events: DirectThreadRawEvent[];
};
export type DirectThreadHistorySlice = {
items: unknown[];
hasMore: boolean;
itemTimestamps?: Record<string, number>;
};
/**
* 历史条目转聊天消息。
*
* 过渡函数:`App.tsx` 仍按 `LocalConversationMessageRecord` 渲染,切换成 reducer 之后删除。
* 只保留 `role ∈ {user, assistant}` 且有正文的条目;工具卡片与交替顺序由 reducer 投影。
*/
export function directThreadHistoryItemsToMessages(
items: readonly DirectThreadItem[],
items: unknown[],
itemTimestamps: Readonly<Record<string, number>> = {},
): LocalConversationMessageRecord[] {
return items.flatMap((item) => {
if (item.itemType !== 'message') return [];
if (item.role !== 'user' && item.role !== 'assistant') return [];
if (!item.text) return [];
return items.flatMap((raw) => {
if (!raw || typeof raw !== 'object') return [];
const item = raw as Record<string, unknown>;
const role = item.role;
if (role !== 'user' && role !== 'assistant') return [];
const messageRole = role as 'user' | 'assistant';
const content = Array.isArray(item.content)
? item.content
.map((part) =>
part && typeof part === 'object' && 'text' in part
? (part as { text?: unknown }).text
: null,
)
.filter((text): text is string => typeof text === 'string')
.join('')
: '';
if (!content) return [];
const messageId = typeof item.id === 'string' ? item.id : undefined;
return [
{
schemaVersion: 'agc-direct-project-context.v1',
role: item.role,
content: item.text,
role: messageRole,
content,
agentId: null,
messageId: item.itemId,
updatedAt: item.at ?? 0,
messageId,
updatedAt: messageId ? (itemTimestamps[messageId] ?? 0) : 0,
},
];
});
@@ -1,268 +0,0 @@
/**
* DirectProject「原始条目 → 聊天条目」投影。
*
* 输入是 Rust 侧 ts-rs 导出的 `DirectThreadItem`(脱敏后的 Codex 原始条目),工具卡片的
* `kind`、标题、折叠摘要、状态判定和可见性全部在这里完成。运行态事件与历史切片走同一个
* 函数,因此实时与回读不可能出现两套口径。
*/
import type {
GameCreatorDirectToolCallChange,
GameCreatorDirectToolCallDetail,
GameCreatorDirectToolCallKind,
GameCreatorDirectToolCallStatus,
} from '../../app/types';
import type { DirectChatEntry, DirectChatToolCard } from './directThreadChat';
import type { DirectThreadItem } from './directThreadEvents';
/** 折叠态摘要上限,与卡片契约一致。 */
const TOOL_SUMMARY_MAX_CHARS = 120;
const FAILED_ITEM_STATUS = new Set([
'failed',
'declined',
'cancelled',
'canceled',
'aborted',
]);
function firstLine(value: string): string {
const [line = ''] = value.split('\n');
const trimmed = line.trim();
return trimmed.length > TOOL_SUMMARY_MAX_CHARS
? `${trimmed.slice(0, TOOL_SUMMARY_MAX_CHARS)}`
: trimmed;
}
function toolKindFromFunctionName(name: string): GameCreatorDirectToolCallKind {
switch (name) {
case 'exec_command':
case 'shell':
case 'exec':
return 'command';
case 'apply_patch':
case 'write_file':
case 'edit_file':
case 'create_file':
return 'file_change';
case 'web_search':
case 'web_search_preview':
return 'web_search';
default:
return 'mcp_tool';
}
}
function toolStatus(
status: string | null,
exitCode: number | null,
): GameCreatorDirectToolCallStatus {
if (status === 'completed') return 'completed';
if (status && FAILED_ITEM_STATUS.has(status)) return 'failed';
// Codex 的退出码约定:非 0 即失败;缺席时按「已完成」处理。
if (typeof exitCode === 'number') {
return exitCode === 0 ? 'completed' : 'failed';
}
return status ? 'running' : 'completed';
}
function toolTitle(
kind: GameCreatorDirectToolCallKind,
changes: readonly GameCreatorDirectToolCallChange[],
): string {
switch (kind) {
case 'command':
return '执行命令';
case 'file_change': {
const paths = new Set(changes.map((change) => change.path));
return paths.size > 0 ? `编辑 ${paths.size} 个文件` : '编辑文件';
}
case 'web_search':
return '联网检索';
case 'context_compaction':
return '整理上下文';
default:
return '调用工具';
}
}
function fileChanges(
item: Extract<DirectThreadItem, { itemType: 'fileChange' }>,
): GameCreatorDirectToolCallChange[] {
return item.changes
.filter((change) => change.path.trim().length > 0)
.map((change) => ({
path: change.path,
kind: change.kind || 'update',
}));
}
type ToolCardInput = {
itemId: string;
at: number;
kind: GameCreatorDirectToolCallKind;
/** 输出条目只带输出:标题与摘要留空,交给先到的调用快照。 */
outputOnly?: boolean;
tool?: string;
command?: string;
output?: string;
status: GameCreatorDirectToolCallStatus;
changes?: GameCreatorDirectToolCallChange[];
};
function buildToolCard(input: ToolCardInput): DirectChatToolCard | null {
const changes = input.changes ?? [];
const detail: GameCreatorDirectToolCallDetail = {};
if (input.command && !input.outputOnly) detail.command = input.command;
if (input.output) detail.output = input.output;
if (changes.length > 0) detail.changes = changes;
// 没有命令 / 输出 / 文件明细的条目不渲染成卡片:一张空卡片对用户没有信息量。
if (!detail.command && !detail.output && !detail.changes?.length) return null;
const summarySource =
(input.kind === 'mcp_tool' ? (input.tool ?? '') : '') ||
detail.command ||
changes[0]?.path ||
input.tool ||
'';
return {
schemaVersion: 'agc-tool-call.v1',
id: input.itemId,
kind: input.kind,
title: input.outputOnly ? '' : toolTitle(input.kind, changes),
summary: input.outputOnly ? '' : firstLine(summarySource),
status: input.status,
detail,
startedAt: input.at,
updatedAt: input.at,
};
}
function toolCardFromItem(item: DirectThreadItem): DirectChatToolCard | null {
switch (item.itemType) {
case 'function_call':
return buildToolCard({
itemId: item.itemId,
at: item.at,
kind: toolKindFromFunctionName(item.name),
tool: item.name,
command: item.arguments,
status: 'running',
});
case 'function_call_output':
return buildToolCard({
itemId: item.itemId,
at: item.at,
kind: 'other',
outputOnly: true,
output: item.output,
status: 'completed',
});
case 'commandExecution':
return buildToolCard({
itemId: item.itemId,
at: item.at,
kind: 'command',
command: item.command,
output: item.output ?? '',
status: toolStatus(item.status, item.exitCode),
});
case 'fileChange':
return buildToolCard({
itemId: item.itemId,
at: item.at,
kind: 'file_change',
changes: fileChanges(item),
status: 'completed',
});
case 'mcpToolCall':
return buildToolCard({
itemId: item.itemId,
at: item.at,
kind: 'mcp_tool',
tool: item.tool,
command: item.arguments,
output: item.output ?? '',
status: toolStatus(item.status, null),
});
case 'webSearch':
return buildToolCard({
itemId: item.itemId,
at: item.at,
kind: 'web_search',
command: item.query ?? '',
output: item.output ?? '',
status: 'completed',
});
case 'contextCompaction':
return buildToolCard({
itemId: item.itemId,
at: item.at,
kind: 'context_compaction',
command: '整理上下文',
status: 'completed',
});
default:
return null;
}
}
/**
* 原始条目投影成聊天条目;不属于聊天内容的条目返回 `null`。
*
* 可见性判定只在这里:系统 / 开发者 message、无正文的空条目、未识别的 item 类型都不进
* 聊天视图。`other` 是 Rust 原样透传的未知类型,要不要显示属于前端可见性决策,当前不显示。
*/
export function projectDirectThreadItem(
item: DirectThreadItem | null | undefined,
): DirectChatEntry | null {
if (!item) return null;
const itemId = item.itemId.trim();
if (!itemId) return null;
switch (item.itemType) {
case 'message': {
const role =
item.role === 'user'
? 'user'
: item.role === 'assistant'
? 'assistant'
: null;
if (!role || !item.text.trim()) return null;
return {
itemId,
kind: 'message',
role,
text: item.text,
toolCall: null,
at: item.at,
};
}
case 'reasoning': {
if (!item.text.trim()) return null;
return {
itemId,
kind: 'reasoning',
role: null,
text: item.text,
toolCall: null,
at: item.at,
};
}
case 'other':
// TODO(direct-thread): 未识别类型目前不显示;要让它们出现只改这里,别回 Rust 加白名单。
return null;
default: {
const toolCall = toolCardFromItem(item);
if (!toolCall) return null;
return {
itemId,
kind: 'tool',
role: null,
text: null,
toolCall,
at: item.at,
};
}
}
}
@@ -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;
};

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