diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs index 01f6a6868..9b410cc60 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs @@ -21,30 +21,36 @@ pub(crate) fn validate_direct_codex_user_item( return Err("DirectProject user item 缺少稳定 id".to_string()); } if message.content.is_empty() { - return Err("DirectProject user item content 不能为空".to_string()); + return Err("聊天内容不能为空".to_string()); } let manifest = read_manifest_for_project(root)?; let mut reference_count = 0usize; + let mut has_effective_content = false; for part in &message.content { match part { DirectCodexUserContentPart::InputText { text } => { - if text.trim().is_empty() { - return Err("DirectProject input_text 不能为空".to_string()); + if !text.trim().is_empty() { + has_effective_content = true; } } DirectCodexUserContentPart::AgcResourceReference { resource_id } => { reference_count = reference_count.saturating_add(1); validate_resource_id_and_manifest(&manifest, resource_id)?; + has_effective_content = true; } DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { reference_count = reference_count.saturating_add(1); validate_runtime_region_reference(&manifest, reference)?; + has_effective_content = true; } } } if reference_count > MAX_DIRECT_CODEX_REFERENCES { return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材")); } + if !has_effective_content { + return Err("聊天内容不能为空".to_string()); + } Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index c718a9ee5..b3d9e8d8d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -197,7 +197,10 @@ fn render_ui_design_code_context( #[cfg(test)] mod tests { - use super::{direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item}; + use super::{ + direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item, + validate_direct_codex_user_item, + }; use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE; use serde_json::json; use shared_contracts::game_creation_app::{ @@ -295,7 +298,7 @@ mod tests { } #[test] - fn response_item_projection_uses_input_text_not_turn_input_text() { + fn text_projection_preserves_empty_parts_line_breaks_and_trailing_whitespace() { let root = tempfile::tempdir().expect("temp project"); crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") .expect("init project"); @@ -303,12 +306,132 @@ mod tests { "type": "message", "role": "user", "id": "turn-1:user", - "content": [{"type": "input_text", "text": "你好"}] + "content": [ + {"type": "input_text", "text": ""}, + {"type": "input_text", "text": "你好"}, + {"type": "input_text", "text": "\n"}, + {"type": "input_text", "text": "第二段"}, + {"type": "input_text", "text": "\n"}, + {"type": "input_text", "text": " "} + ] }); let projected = direct_codex_user_item_to_response_item(root.path(), &item) .expect("user response item should project"); - assert_eq!(projected["content"][0]["type"], "input_text"); - assert_ne!(projected["content"][0]["type"], "text"); + assert_eq!(projected["content"], item["content"]); + let canonical = serde_json::from_value(item).expect("canonical user item"); + assert_eq!( + direct_codex_user_item_to_prompt(root.path(), &canonical).expect("multiline prompt"), + "你好\n第二段\n " + ); + } + + #[test] + fn user_input_rejects_empty_or_whitespace_only_messages() { + let root = prompt_context_project(); + for content in [ + json!([]), + json!([{"type": "input_text", "text": ""}]), + json!([ + {"type": "input_text", "text": ""}, + {"type": "input_text", "text": " \t\r\n\u{3000}"} + ]), + ] { + let item = serde_json::from_value(json!({ + "type": "message", "role": "user", "id": "turn-1:user", "content": content + })) + .expect("canonical user item"); + assert_eq!( + validate_direct_codex_user_item(root.path(), &item), + Err("聊天内容不能为空".to_string()) + ); + } + } + + #[test] + fn valid_references_allow_missing_text_and_whitespace_parts() { + let root = prompt_context_project(); + let asset_id = register_fixture_asset( + root.path(), + "assets/hero.png", + GameCreationAppAssetKind::Character, + "image/png", + ); + for (reference, expected_text) in [ + ( + json!({"type": "agc_resource_reference", "resourceId": asset_id}), + format!("[素材引用 resourceId={asset_id};项目路径=assets/hero.png]"), + ), + ( + json!({"type": "agc_runtime_region_reference", "label": "主画面"}), + "[运行画面区域:名称=主画面 ]".to_string(), + ), + ] { + for (content, expected_prompt) in [ + (json!([reference.clone()]), expected_text.clone()), + ( + json!([ + {"type": "input_text", "text": ""}, + {"type": "input_text", "text": "\n"}, + reference, + {"type": "input_text", "text": " "} + ]), + format!("\n{expected_text} "), + ), + ] { + let item = json!({ + "type": "message", "role": "user", "id": "turn-1:user", "content": content + }); + let canonical = serde_json::from_value(item.clone()).expect("canonical user item"); + assert_eq!( + direct_codex_user_item_to_prompt(root.path(), &canonical) + .expect("reference prompt"), + expected_prompt + ); + let projected = direct_codex_user_item_to_response_item(root.path(), &item) + .expect("reference history projection"); + assert_eq!( + projected["content"].as_array().unwrap().len(), + content.as_array().unwrap().len() + ); + } + } + } + + #[test] + fn nonempty_text_does_not_bypass_invalid_reference_validation() { + let root = prompt_context_project(); + for (reference, expected_error) in [ + ( + json!({"type": "agc_resource_reference", "resourceId": " "}), + "引用的素材 ID 无效,请移除后重新选择", + ), + ( + json!({"type": "agc_resource_reference", "resourceId": "missing"}), + "引用的素材已不存在,请移除后重新选择", + ), + ( + json!({"type": "agc_runtime_region_reference", "label": " "}), + "运行画面区域缺少名称", + ), + ( + json!({"type": "agc_runtime_region_reference", "label": "主画面", "resourceIds": ["missing"]}), + "引用的素材已不存在,请移除后重新选择", + ), + ] { + let item = serde_json::from_value(json!({ + "type": "message", "role": "user", "id": "turn-1:user", + "content": [ + {"type": "input_text", "text": "有真实文字"}, + reference, + {"type": "input_text", "text": " "} + ] + })) + .expect("canonical user item"); + assert_eq!( + validate_direct_codex_user_item(root.path(), &item), + Err(expected_error.to_string()) + ); + } } #[test] diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index 142dc2b26..964e9a4e3 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -639,7 +639,8 @@ describe('ResourceReferenceInput', () => { const deleteButton = screen.getByRole('button', { name: '移除引用 hero' }); expect(chip?.contains(deleteButton)).toBe(true); - // 提交用的结构化引用完整保留,末尾那个分隔空格提交前会被 trim 掉。 + // 草稿 text 投影会 trim,但提交用的 content 仍保留引用节点后的分隔空格; + // 结构化引用完整保留,后端按整条消息判断是否有有效内容。 expect(onChange.mock.calls.at(-1)?.[0].text).toBe('@hero'); expect(onChange.mock.calls.at(-1)?.[0].references[0]?.resourceId).toBe( 'hero', diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 3eb32e228..0205590b1 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5867,3 +5867,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **处理(现行口径)**:① 依赖里只放数据,回调走 ref(`openActiveProjectRef`)——effect 不再因回调换身份而重跑;② `useDirectActiveTurns` 轮询只在快照内容变化时才 `setActiveTurns`(并给空态做引用稳定),避免每 5 秒换一次数组身份去带动下游 effect;③ `WindowChrome` 的 context value 用 `useMemo` 收口。判断类问题的通行判据:**凡是把"每次渲染新生成的函数/对象"写进 effect 依赖的,一律视为 bug**。 - **验证**:修复后同一台机器、同一路径下 35 秒内新增 `Maximum update depth` **0 条**,renderer 工作集 **254 MB**(修复前 4.2–4.4 GB);`apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx` 断言轮询返回值不变时快照引用不变。 - **关联**:`apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx`、`apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts`、`apps/ai-game-creator-shell/src/components/WindowChrome.tsx`、`apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`。 + +## 2026-09-21 DirectProject 结构化消息不能逐个拒绝空白文本片段 + +- **现象**:多行正文、末尾空段落或合法引用前后的分隔空格会让有内容的消息报错;编辑器为了保持结构产生的空白文本片段被误判为“聊天内容为空”。 +- **原因**:校验器对每个 `input_text` 单独执行 `trim().is_empty()` 并立即拒绝,混淆了结构化片段合法性和整条消息是否有实际内容。 +- **处理(现行口径)**:`input_text` 允许空字符串、空格和换行,校验过程保持全部片段的原文、分段与顺序,不做合并或删除;遍历完整条消息后,只在既没有非空白文字、也没有合法 `agc_resource_reference` / `agc_runtime_region_reference` 时返回“聊天内容不能为空”。两类引用仍逐个执行原有校验,消息带正文也不能绕过非法引用。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs`、`docs/【功能说明】AGC聊天素材引用-2026-09-08.md`。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index 27a2ba1ed..cf880126f 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -15,6 +15,8 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。 提交时前端把 Lexical 草稿直接编码为受限 Response API user `message` item:`input_text` 与 AGC 引用 part 按编辑顺序内联在同一个 `content[]` 中。资源引用只携带稳定 `resourceId`;运行画面引用携带区域语义摘要及关联资源 ID。Rust 是唯一 schema source(通过 `ts-rs` 生成 TypeScript 绑定),在发起回合前完成 item 白名单、字段边界、manifest 归属和路径安全校验;校验失败时本轮不持久化、不发送。通过校验的 canonical item 以 `response_item` envelope 写入项目历史,随后由 Rust 将 AGC part 临时转换为 Codex 可接受的 `input_text`,保持原始 content 顺序。已有标准 `response_item` 原样读取与复用;旧 legacy conversation 行不再提供 fallback。 +输入校验按整条消息判断是否有内容:每个 `input_text` 片段都允许是空字符串、空格或换行,不逐片段拒绝,也不合并、删除或改写片段;原始文字、分段和 `content[]` 顺序保持不变。整条消息必须至少包含一段非空白文字,或至少一个通过既有校验的 `agc_resource_reference` / `agc_runtime_region_reference`,否则返回“聊天内容不能为空”。两种引用继续执行原有字段、数量、manifest 归属和路径安全校验;即使消息同时带有正文,非法引用也必须拒绝,不能由正文绕过。 + ## 拖拽引用(2026-09-21) 除了 `@` 输入与「引用」按钮,资源卡还支持**拖到对话**:在资源画布上按住一张卡拖到右侧 Agent 对话栏,松手即把这次拖动真正参与位移的那批素材整批 `@` 进输入框(框选多选后拖任意一张 = 整批引用;拖未选中的卡 = 只引用它自己)。