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
37 changed files with 1070 additions and 667 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)]
@@ -40,6 +40,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 {
@@ -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 }));
} }
@@ -174,4 +188,26 @@ 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"));
}
} }
@@ -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>
);
}
@@ -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,
@@ -916,8 +912,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,32 @@
* 回合运行中用户再次发送时,消息进入 FIFO 队列而不是被丢弃;当前回合结束后按入队顺序 * 回合运行中用户再次发送时,消息进入 FIFO 队列而不是被丢弃;当前回合结束后按入队顺序
* 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。 * 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。
*/ */
import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments'; import type { DirectCodexUserItem } from './generated';
import type { DirectCodexUserContentPart } from './generated'; import { directCodexContentToPromptText } from './resourceReferences';
import type { ChatReference } 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,
}; };
} }
@@ -79,14 +75,19 @@ export function chatQueueFullNotice(): string {
/** 队列 chip 上显示的文字:单行、有长度上限。 */ /** 队列 chip 上显示的文字:单行、有长度上限。 */
export function queuedChatTurnLabel(turn: QueuedChatTurn): string { 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) { 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,39 @@ export function isResourceReferenceOverlayTarget(target: EventTarget | null) {
} }
export const EMPTY_CHAT_COMPOSER_DRAFT: ChatComposerDraft = { export const EMPTY_CHAT_COMPOSER_DRAFT: ChatComposerDraft = {
text: '',
references: [],
content: [], content: [],
}; };
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 +128,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 +143,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,
@@ -6489,6 +6491,13 @@ export default function ProjectDevelopmentView({
* idle * idle
*/ */
const applyResourceQuickEditPrompt = useCallback((text: string) => { const applyResourceQuickEditPrompt = useCallback((text: string) => {
const currentDraft = quickEditPromptInputRef.current?.getDraft();
if (
currentDraft &&
directCodexContentToPromptText(currentDraft.content) !== text
) {
quickEditPromptInputRef.current?.replaceText(text);
}
setQuickEditPanel((current) => setQuickEditPanel((current) =>
current current
? { ? {
@@ -6510,8 +6519,9 @@ export default function ProjectDevelopmentView({
*/ */
const applyResourceQuickEditDraft = useCallback( const applyResourceQuickEditDraft = useCallback(
(draft: ChatComposerDraft) => { (draft: ChatComposerDraft) => {
setQuickEditReferences(draft.references); applyResourceQuickEditPrompt(
applyResourceQuickEditPrompt(draft.text); directCodexContentToPromptText(draft.content),
);
}, },
[applyResourceQuickEditPrompt], [applyResourceQuickEditPrompt],
); );
@@ -8128,9 +8138,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}
@@ -4,6 +4,7 @@ import {
dequeueChatTurn, dequeueChatTurn,
enqueueChatTurn, enqueueChatTurn,
isChatTurnQueueFull, isChatTurnQueueFull,
queuedChatTurnLabel,
removeQueuedChatTurn, removeQueuedChatTurn,
} from '../../src/features/project-workspace/chatComposerQueue'; } from '../../src/features/project-workspace/chatComposerQueue';
import { import {
@@ -15,6 +16,7 @@ import {
VOICE_INPUT_UNSUPPORTED_MESSAGE, VOICE_INPUT_UNSUPPORTED_MESSAGE,
} from '../../src/features/project-workspace/chatComposerVoice'; } from '../../src/features/project-workspace/chatComposerVoice';
import { ComposerVoiceButton } from '../../src/features/project-workspace/ComposerControls'; import { ComposerVoiceButton } from '../../src/features/project-workspace/ComposerControls';
import { directCodexUserItemFromContent } from '../../src/features/project-workspace/resourceReferences';
import { import {
act, act,
createGameCreationAppManifest, createGameCreationAppManifest,
@@ -175,23 +177,29 @@ function installFakeSpeechRecognition(): {
}; };
} }
/** 排队回合只持 canonical user item;展示文案由它派生。 */
function queuedTurn(
id: string,
clientTurnId: string,
text: string,
createdAt: number,
) {
return createQueuedChatTurn({
id,
clientTurnId,
userItem: directCodexUserItemFromContent(
[{ type: 'input_text', text }],
`${clientTurnId}:user`,
),
createdAt,
});
}
export function registerChatComposerControlTests() { export function registerChatComposerControlTests() {
it('keeps queued chat turns in FIFO order and drops only the cancelled one', () => { it('keeps queued chat turns in FIFO order and drops only the cancelled one', () => {
const first = createQueuedChatTurn({ const first = queuedTurn('turn-1', 'client-1', '第一条', 1);
id: 'turn-1', const second = queuedTurn('turn-2', 'client-2', '第二条', 2);
prompt: '第条', const third = queuedTurn('turn-3', 'client-3', '第条', 3);
createdAt: 1,
});
const second = createQueuedChatTurn({
id: 'turn-2',
prompt: '第二条',
createdAt: 2,
});
const third = createQueuedChatTurn({
id: 'turn-3',
prompt: '第三条',
createdAt: 3,
});
let queue = enqueueChatTurn([], first); let queue = enqueueChatTurn([], first);
queue = enqueueChatTurn(queue, second); queue = enqueueChatTurn(queue, second);
@@ -201,15 +209,18 @@ export function registerChatComposerControlTests() {
// FIFO:先入先出,不丢、不乱序。 // FIFO:先入先出,不丢、不乱序。
const firstOut = dequeueChatTurn(queue); const firstOut = dequeueChatTurn(queue);
expect(firstOut.next?.prompt).toBe('第一条'); expect(firstOut.next?.clientTurnId).toBe('client-1');
expect(firstOut.rest.map((turn) => turn.prompt)).toEqual([ expect(firstOut.next && queuedChatTurnLabel(firstOut.next)).toBe('第一条');
expect(firstOut.rest.map((turn) => queuedChatTurnLabel(turn))).toEqual([
'第二条', '第二条',
'第三条', '第三条',
]); ]);
// 单条取消只移除那一条,顺序不变。 // 单条取消只移除那一条,顺序不变。
expect( expect(
removeQueuedChatTurn(firstOut.rest, 'turn-2').map((turn) => turn.prompt), removeQueuedChatTurn(firstOut.rest, 'turn-2').map((turn) =>
queuedChatTurnLabel(turn),
),
).toEqual(['第三条']); ).toEqual(['第三条']);
expect(removeQueuedChatTurn(firstOut.rest, 'turn-missing')).toHaveLength(2); expect(removeQueuedChatTurn(firstOut.rest, 'turn-missing')).toHaveLength(2);
@@ -222,11 +233,7 @@ export function registerChatComposerControlTests() {
for (let index = 0; index < 5; index += 1) { for (let index = 0; index < 5; index += 1) {
queue = enqueueChatTurn( queue = enqueueChatTurn(
queue, queue,
createQueuedChatTurn({ queuedTurn(`turn-${index}`, `client-${index}`, `${index}`, index),
id: `turn-${index}`,
prompt: `${index}`,
createdAt: index,
}),
); );
} }
expect(isChatTurnQueueFull(queue)).toBe(true); expect(isChatTurnQueueFull(queue)).toBe(true);

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