From b543a060b75ab17be89a91d025de93eb09b03164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 14:01:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=BE=E5=AE=BD=20DirectProject=20content=20?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E4=B8=BA=E3=80=8C=E6=95=B4=E6=9D=A1=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E3=80=8D=E5=B9=B6=E6=8C=89=E9=80=90=E5=AD=97=E6=8A=95?= =?UTF-8?q?=E5=BD=B1=EF=BC=88review=20=E7=AC=AC=201=20=E6=9D=A1=EF=BC=8C?= =?UTF-8?q?=E2=91=A0=20=E5=8F=A3=E5=BE=84=EF=BC=89=20-=20Rust=20validate?= =?UTF-8?q?=5Fdirect=5Fcodex=5Fuser=5Fitem=20=E6=94=B9=E4=B8=BA=E5=8F=AA?= =?UTF-8?q?=E5=88=A4=E6=95=B4=E6=9D=A1=20content=EF=BC=9A=E6=9C=89?= =?UTF-8?q?=E4=B8=80=E6=AE=B5=E9=9D=9E=E7=A9=BA=E7=99=BD=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E6=88=96=E4=BB=BB=E6=84=8F=E9=9D=9E=E6=96=87=E6=9C=AC=20part?= =?UTF-8?q?=20=E5=8D=B3=E6=9C=89=E6=95=88=EF=BC=8C=E5=8D=95=E4=B8=AA?= =?UTF-8?q?=E7=BA=AF=E7=A9=BA=E7=99=BD=20input=5Ftext=EF=BC=88=E6=AE=B5?= =?UTF-8?q?=E8=90=BD=E5=88=86=E9=9A=94=20/=20=E8=BD=AF=E6=8D=A2=E8=A1=8C?= =?UTF-8?q?=20/=20chip=20=E5=90=8E=E7=9A=84=E5=88=86=E9=9A=94=E7=A9=BA?= =?UTF-8?q?=E6=A0=BC=EF=BC=89=E5=90=88=E6=B3=95=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E9=80=90=E4=B8=AA=20part=20=E6=8B=92=E7=BB=9D=E7=A9=BA?= =?UTF-8?q?=E6=96=87=E6=9C=AC=20-=20=E6=96=B0=E5=A2=9E=20content=5Fhas=5Fm?= =?UTF-8?q?eaningful=5Finput=EF=BC=8C=E5=B9=B6=E4=B8=8E=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=20hasMeaningfulDirectCodexContent=20=E5=90=8C=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=EF=BC=9Bwire.rs=20=E7=9A=84=E3=80=8C=E4=B8=8D?= =?UTF-8?q?=E8=83=BD=E8=BD=AC=E6=8D=A2=E4=B8=BA=E7=A9=BA=20prompt=E3=80=8D?= =?UTF-8?q?=E5=8F=AA=E4=BD=9C=E5=85=9C=E5=BA=95=20-=20ResourceReferenceInp?= =?UTF-8?q?ut=20=E7=9A=84=E6=8A=95=E5=BD=B1=E5=B1=82=E5=8E=9F=E6=A0=B7?= =?UTF-8?q?=E9=80=8F=E4=BC=A0=E7=BC=96=E8=BE=91=E5=99=A8=E8=8A=82=E7=82=B9?= =?UTF-8?q?=EF=BC=9A=E4=B8=8D=E5=81=9A=E7=A9=BA=E7=99=BD=E8=BF=87=E6=BB=A4?= =?UTF-8?q?=E3=80=81=E4=B9=9F=E4=B8=8D=E4=B8=8E=E7=9B=B8=E9=82=BB=20part?= =?UTF-8?q?=20=E5=90=88=E5=B9=B6=EF=BC=8C=E5=88=A0=E6=8E=89=20appendInputT?= =?UTF-8?q?ext=20=E4=B8=8E=E4=B8=AD=E9=97=B4=E7=89=88=E6=9C=AC=E7=9A=84?= =?UTF-8?q?=E3=80=8C=E5=90=91=E5=89=8D=E5=90=88=E5=B9=B6=E3=80=8D=E6=94=B6?= =?UTF-8?q?=E9=9B=86=E5=99=A8=20-=20=E6=8C=89=E9=80=90=E5=AD=97=E6=8A=95?= =?UTF-8?q?=E5=BD=B1=E6=9B=B4=E6=96=B0=E8=B5=84=E6=BA=90=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E3=80=81Godot=20=E5=9B=9E=E5=90=88=E7=94=A8=E4=BE=8B=E7=9A=84?= =?UTF-8?q?=20canonical=20content=20=E6=96=AD=E8=A8=80=20-=20=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E9=87=8C=E7=A8=8B=E7=A2=91=E5=AE=9E=E7=8E=B0=E7=BB=93?= =?UTF-8?q?=E8=AE=BA=E4=B8=8E=E8=AF=81=E6=8D=AE=E3=80=81pitfalls=20?= =?UTF-8?q?=E6=9D=A1=E7=9B=AE=EF=BC=9BRust=20validation/wire=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E3=80=8C=E5=8D=95=E4=B8=AA=E7=BA=AF=E7=A9=BA=E7=99=BD?= =?UTF-8?q?=20part=20=E9=80=9A=E8=BF=87=E3=80=81=E6=95=B4=E6=9D=A1?= =?UTF-8?q?=E5=85=A8=E7=A9=BA=E7=99=BD=E6=8B=92=E7=BB=9D=E3=80=8D=E7=94=A8?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../direct_codex_user_item/validation.rs | 63 +++++++++++++++++-- .../src/agent/direct_codex_user_item/wire.rs | 47 +++++++++++++- .../ResourceReferenceInput.tsx | 56 +++++------------ .../appSurface/project-development.suite.ts | 8 ++- .../tests/resourceReferenceInput.test.tsx | 49 +++++++++------ ...Project canonical content严格边界-2026-09-16.md | 9 +-- docs/project-memory/shared-memory/pitfalls.md | 8 +-- 7 files changed, 165 insertions(+), 75 deletions(-) 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 dfe3dda4d..8de0f9440 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 @@ -20,18 +20,16 @@ pub(crate) fn validate_direct_codex_user_item( if message.id.trim().is_empty() { return Err("DirectProject user item 缺少稳定 id".to_string()); } - if message.content.is_empty() { + // 有效输入只判一整条 content:单个纯空白 `input_text` 是合法 part —— 编辑器里的段落 + // 分隔、软换行与 chip 后的分隔空格就是这样落进 canonical content 的,前端不为它过滤。 + if !content_has_meaningful_input(&message.content) { return Err("DirectProject user item content 不能为空".to_string()); } let manifest = read_manifest_for_project(root)?; let mut reference_count = 0usize; for part in &message.content { match part { - DirectCodexUserContentPart::InputText { text } => { - if text.trim().is_empty() { - return Err("DirectProject input_text 不能为空".to_string()); - } - } + DirectCodexUserContentPart::InputText { .. } => {} DirectCodexUserContentPart::AgcResourceReference { resource_id } => { reference_count = reference_count.saturating_add(1); validate_resource_id_and_manifest(&manifest, resource_id)?; @@ -60,6 +58,14 @@ pub(crate) fn validate_direct_codex_user_item( Ok(()) } +/// 整条 content 是否还有有效输入:任何一段非空白文本、或任何一个非文本 part 都算。 +pub(crate) fn content_has_meaningful_input(content: &[DirectCodexUserContentPart]) -> bool { + content.iter().any(|part| match part { + DirectCodexUserContentPart::InputText { text } => !text.trim().is_empty(), + _ => true, + }) +} + pub(crate) fn validate_resource_id_and_manifest( manifest: &GameCreationAppManifest, resource_id: &str, @@ -98,3 +104,48 @@ fn validate_runtime_region_reference( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::content_has_meaningful_input; + use crate::agent::direct_codex_user_item::model::DirectCodexUserContentPart; + + fn input_text(text: &str) -> DirectCodexUserContentPart { + DirectCodexUserContentPart::InputText { + text: text.to_string(), + } + } + + #[test] + fn only_all_blank_content_counts_as_empty_input() { + // 空数组与「整条只有空白」是同一种空输入。 + assert!(!content_has_meaningful_input(&[])); + assert!(!content_has_meaningful_input(&[input_text(" \n ")])); + assert!(!content_has_meaningful_input(&[ + input_text("\n"), + input_text(" "), + ])); + } + + #[test] + fn whitespace_parts_are_valid_next_to_meaningful_input() { + // 段落分隔 / 软换行 / chip 后的分隔空格都是合法的单个 part。 + assert!(content_has_meaningful_input(&[ + input_text("\n"), + input_text("看"), + ])); + assert!(content_has_meaningful_input(&[ + input_text("看"), + input_text("\n\n"), + ])); + } + + #[test] + fn non_text_parts_always_count_as_input() { + assert!(content_has_meaningful_input(&[ + DirectCodexUserContentPart::AgcResourceReference { + resource_id: "asset-hero".to_string(), + }, + ])); + } +} 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 9c43406e6..a05daef41 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 @@ -144,7 +144,8 @@ pub(crate) fn direct_codex_user_item_to_prompt( #[cfg(test)] mod tests { - use super::direct_codex_user_item_to_response_item; + use super::{direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input}; + use crate::agent::direct_codex_user_item::model::DirectCodexUserItem; use serde_json::json; use std::path::Path; @@ -210,4 +211,48 @@ mod tests { assert!(content[0]["text"].as_str().unwrap().contains("先看")); assert!(content[1]["text"].as_str().unwrap().contains("notes.txt")); } + + #[test] + fn whitespace_only_text_parts_survive_validation() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") + .expect("init project"); + let item: DirectCodexUserItem = serde_json::from_value(json!({ + "type": "message", + "role": "user", + "id": "turn-1:user", + "content": [ + {"type": "input_text", "text": "先看"}, + {"type": "input_text", "text": "\n"}, + {"type": "input_text", "text": " "} + ] + })) + .expect("deserialize user item"); + let wire = direct_codex_user_item_to_wire_input(root.path(), &item) + .expect("whitespace-only part next to real text must pass"); + let parts = wire.as_array().expect("wire input array"); + assert_eq!(parts.len(), 3); + assert_eq!(parts[1]["text"].as_str(), Some("\n")); + assert_eq!(parts[2]["text"].as_str(), Some(" ")); + } + + #[test] + fn all_blank_content_is_rejected() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") + .expect("init project"); + let item: DirectCodexUserItem = serde_json::from_value(json!({ + "type": "message", + "role": "user", + "id": "turn-1:user", + "content": [ + {"type": "input_text", "text": "\n"}, + {"type": "input_text", "text": " "} + ] + })) + .expect("deserialize user item"); + let error = direct_codex_user_item_to_wire_input(root.path(), &item) + .expect_err("all-blank content must fail closed"); + assert!(error.contains("不能为空"), "{error}"); + } } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 240489cda..351f68d0e 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -175,61 +175,39 @@ class ResourceMentionOption extends MenuOption { } /** - * 收集过程中的待写文本。 - * - * 只有紧挨着上一段文本时才能直接追加;上一 part 是引用(chip)时先把文本攒起来,等 - * 下一个文本到来再合并成同一个 part。否则段落分隔符(root 子节点之间补的 `\n`)和软 - * 换行会被整段丢掉,chip 后面的文字会粘成 `@素材下一段`,并原样进 agent 与队列文案。 - * 攒到最后仍然只有空白的(以换行结尾、两个 chip 之间只隔一个换行)不落成 part: - * Rust `validate_direct_codex_user_item` 会拒绝空 `input_text`。 + * 编辑器节点 → canonical content part,**原样透传**:段落分隔(root 子节点之间补的 + * `\n`)、软换行、chip 后的分隔空格都各自成 part,不做空白过滤、不与相邻 part 合并。 + * 有效输入只在整条 content 上判定(`hasMeaningfulDirectCodexContent` 与 Rust + * `validate_direct_codex_user_item` 同口径),前端不替用户改写他输入了什么。 */ -type DraftContentCollector = { - content: DirectCodexUserContentPart[]; - pendingText: string; -}; - -function appendInputText(collector: DraftContentCollector, text: string) { - if (!text) { - return; - } - const { content } = collector; - const previous = content[content.length - 1]; - if (collector.pendingText === '' && previous?.type === 'input_text') { - previous.text += text; - return; - } - collector.pendingText += text; - if (!collector.pendingText.trim()) { - return; - } - content.push({ type: 'input_text', text: collector.pendingText }); - collector.pendingText = ''; -} - function collectDraftParts( node: LexicalNode, references: ChatReference[], - collector: DraftContentCollector, + content: DirectCodexUserContentPart[], ) { if ($isTextNode(node)) { - appendInputText(collector, node.getTextContent()); + const text = node.getTextContent(); + // Lexical 不会留下空 TextNode;这只防「空串 part」落进 app-server 输入。 + if (text) { + content.push({ type: 'input_text', text }); + } return; } if ($isLineBreakNode(node)) { - appendInputText(collector, '\n'); + content.push({ type: 'input_text', text: '\n' }); return; } if ($isResourceReferenceNode(node)) { references.push(node.__reference); - collector.content.push(chatReferenceToContentPart(node.__reference)); + content.push(chatReferenceToContentPart(node.__reference)); return; } if ($isElementNode(node)) { node.getChildren().forEach((child, index) => { if (index > 0 && node.getType() === 'root') { - appendInputText(collector, '\n'); + content.push({ type: 'input_text', text: '\n' }); } - collectDraftParts(child, references, collector); + collectDraftParts(child, references, content); }); } } @@ -242,11 +220,11 @@ type DraftProjection = { /** 仅供编辑器内部派生引用(重建文本草稿时用);对外只暴露 canonical content。 */ function readDraftProjectionFromNodes(): DraftProjection { const references: ChatReference[] = []; - const collector: DraftContentCollector = { content: [], pendingText: '' }; - collectDraftParts($getRoot(), references, collector); + const content: DirectCodexUserContentPart[] = []; + collectDraftParts($getRoot(), references, content); return { references: dedupeChatReferences(references), - content: collector.content, + content, }; } diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index df819a4ab..5f02558a9 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -9342,7 +9342,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(fireEvent.keyDown(composer, { key: 'Enter' })).toBe(false); await waitFor(() => { // canonical content 原样保留编辑器内容:上面的 Shift+Enter 与组合态 Enter - // 在编辑器里各插入一个真实换行,提交时不能被悄悄丢掉。 + // 在编辑器里各插入一个真实换行,提交时不能被悄悄丢掉,也不与相邻文本合并。 expect(invoke).toHaveBeenCalledWith( 'chat_with_game_creator_direct_codex', { @@ -9353,7 +9353,11 @@ export function registerProjectSupervisorSurfaceTests() { id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), type: 'message', role: 'user', - content: [{ type: 'input_text', text: '修改玩家移动脚本\n\n' }], + content: [ + { type: 'input_text', text: '修改玩家移动脚本' }, + { type: 'input_text', text: '\n' }, + { type: 'input_text', text: '\n' }, + ], }, }, ); diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index 953d3d391..b80e18a42 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -85,9 +85,9 @@ async function settleComposer() { }); } -/** 草稿展示文本:canonical content 是唯一真相,引用按稳定 id 展开成 `@id`。 */ +/** 草稿展示文本:canonical content 是唯一真相;这里刻意不传 manifest,引用展开成 `@id`。 */ function draftText(draft: ChatComposerDraft | undefined) { - return directCodexContentToPromptText(draft?.content ?? []); + return directCodexContentToPromptText(draft?.content ?? [], []); } /** 草稿里的资源引用 id,按 content 顺序。 */ @@ -251,7 +251,7 @@ describe('ResourceReferenceInput', () => { expect(screen.getByRole('button', { name: '模拟润色' })).not.toBeNull(); }); - test('引用后面的段落分隔不会被吞掉:读回的 prompt 与编辑器分段逐字一致', async () => { + test('引用后面的段落分隔原样落进 content:读回的 prompt 与编辑器分段逐字一致', async () => { const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); function Controlled() { @@ -285,19 +285,15 @@ describe('ResourceReferenceInput', () => { await settleComposer(); const draft = onChange.mock.calls.at(-1)?.[0]; - // chip 与后一段各自成段:段落分隔必须留在 canonical content 里。丢掉它读回的 - // prompt 会粘成 `@hero把这一版改成夜景`,并原样进 agent、队列文案与润色判据。 + // chip 与后一段各自成段:段落分隔原样留在 canonical content 里(投影不做空白过滤, + // 也不与相邻 part 合并)。丢掉它读回的 prompt 会粘成 `@hero把这一版改成夜景`, + // 并原样进 agent、队列文案与润色判据。 expect(draft?.content).toEqual([ chatReferenceToContentPart(reference), - { type: 'input_text', text: '\n把这一版改成夜景' }, + { type: 'input_text', text: '\n' }, + { type: 'input_text', text: '把这一版改成夜景' }, ]); expect(draftText(draft)).toBe('@hero\n把这一版改成夜景'); - // 换行并进后面那段文本,而不是单独落一个纯空白 part:Rust 会拒绝空 input_text。 - expect( - draft?.content.filter( - (part) => part.type === 'input_text' && !part.text.trim(), - ), - ).toEqual([]); }); test('引用浮层打开时 Enter 不提交表单,关掉后恢复提交', async () => { @@ -365,9 +361,21 @@ describe('ResourceReferenceInput', () => { expect(onChange).toHaveBeenCalled(); }); const draft = onChange.mock.calls.at(-1)?.[0]; - // canonical content 只承载有意义的 part:chip 之间的分隔空格是纯 UI 排版, - // 引用本身由稳定 resourceId 表达。空格也不进内容——Rust 会拒绝纯空白 input_text。 - expect(draftText(draft)).toBe('@hero@enemy'); + // canonical content 是编辑器内容的逐字投影:picker 在每个 chip 后插入的分隔空格 + // 也原样进内容,所以派生文本是 `@hero @enemy`(引用本身仍由稳定 resourceId 表达)。 + const heroPart = chatReferenceToContentPart( + resourceReferenceFromAsset(assets[0]!, 'asset-picker'), + ); + const enemyPart = chatReferenceToContentPart( + resourceReferenceFromAsset(assets[1]!, 'asset-picker'), + ); + expect(draft?.content).toEqual([ + heroPart, + { type: 'input_text', text: ' ' }, + enemyPart, + { type: 'input_text', text: ' ' }, + ]); + expect(draftText(draft)).toBe('@hero @enemy'); expect(draftResourceIds(draft)).toEqual(['hero', 'enemy']); expect( document.querySelector('[data-resource-reference-id="hero"]'), @@ -828,13 +836,15 @@ describe('ResourceReferenceInput', () => { await user.click(screen.getByRole('button', { name: '插入引用' })); await settleComposer(); + // 恢复的草稿文本 + picker 插入的 chip 与它后面的分隔空格,逐字就是编辑器里的内容。 expect( onChange.mock.calls .at(-1)?.[0] - .content.filter((part) => part.type === 'input_text') - .map((part) => (part.type === 'input_text' ? part.text : '')) - .join(''), - ).toBe('恢复出来的草稿'); + .content.filter((part) => part.type === 'input_text'), + ).toEqual([ + { type: 'input_text', text: '恢复出来的草稿' }, + { type: 'input_text', text: ' ' }, + ]); }); test('标签库按 manifest 标签派生:计数只算候选、排序稳定、多标签取交集', () => { @@ -994,6 +1004,7 @@ describe('ResourceReferenceInput', () => { // 「快速编辑」不允许出现第二种引用格式。 expect(chatDraft?.content).toEqual([ { type: 'agc_resource_reference', resourceId: 'hero' }, + { type: 'input_text', text: ' ' }, ]); expect(quickEditDraft).toEqual(chatDraft); expect( diff --git a/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md index 38815e6c1..ffd7767f8 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md @@ -38,13 +38,14 @@ ## 实现结论 -- 唯一的前端空白过滤留在 Lexical 投影层:投影只产出有意义的 `input_text`,因为 Rust `validate_direct_codex_user_item` 会拒绝空 `input_text`。转换函数不再重复过滤。 -- 投影层的过滤口径是「向前合并」而不是「直接丢弃」:上一 part 是引用(chip)时先把待写文本攒住,等下一个文本 part 到来再合并成同一个 part。段落分隔符(root 子节点之间补的 `\n`)与软换行因此不会丢,`@素材` 后面的那一段不会被粘成 `@素材下一段`;攒到最后仍只有空白的(以换行结尾、两个 chip 之间只隔一个换行)不入 content。 -- 显示文本、队列 chip 文案、草稿持久化和润色判据统一由 `directCodexContentToPromptText(content, assets)` 从 content 派生,不再维护并行的 `text` 字段。 +- 前端不再做任何空白过滤:Lexical 投影层原样透传编辑器节点,段落分隔符(root 子节点之间补的 `\n`)、软换行、chip 后的分隔空格都各自成 part,既不丢弃也不与相邻 part 合并 —— 前端不替用户改写他输入的内容。 +- 有效输入只判整条 content:有一段非空白文本或任何一个非文本 part 就算有效输入,单个纯空白 `input_text` 合法。前端 `hasMeaningfulDirectCodexContent` 与 Rust `validate_direct_codex_user_item`(`content_has_meaningful_input`)同口径,Rust 侧不再逐个 part 拒绝空文本;`wire.rs` 的「不能转换为空 prompt」只作兜底。 +- 显示文本、队列 chip 文案、草稿持久化和润色判据统一由 `directCodexContentToPromptText(content, assets)` 从 content 派生,不再维护并行的 `text` 字段;`assets`(当前项目 manifest)必填,`agc_resource_reference` 按 `@显示名` 展开,消息正文与队列 chip 因此逐字一致。 - 需要文本草稿的旧入口(`replaceText`、快速编辑)仍由编辑器把文本 + 引用重建为 content,方向是「文本 → content」,不存在「legacy 字段 → content」的回退。 ## 证据 - `apps/ai-game-creator-shell/tests/resourceReferences.test.ts`:content 原样传递、空白 part 保留、有效性判断、文本派生。 -- `apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx`(含「引用后面的段落分隔不会被吞掉」)、`chatPromptPolish.test.tsx`、`tests/appSurface/*.suite.ts`:草稿读取、提醒判据、队列与 caller 迁移到 content-only。 +- `apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx`(含「引用后面的段落分隔原样落进 content」)、`chatPromptPolish.test.tsx`、`tests/appSurface/*.suite.ts`:草稿读取、提醒判据、队列与 caller 迁移到 content-only,并按逐字投影断言 content。 +- `apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/`:`validation.rs` 的「只有整条 content 全空白才算空输入」与 `wire.rs` 的「单个纯空白 part 通过校验、整条全空白拒绝」用例。 - AGC shell 类型检查、定向 Vitest、`npm run check:encoding`、`git diff --check` 通过。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index a2fa70792..a12abf8b1 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5620,10 +5620,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` ## 2026-09-16 Lexical 投影丢掉引用后的换行:`@素材` 和下一段粘成一个词 - **现象**:聊天输入区里先 `@` 一个素材、回车换段再写文字,提交出去的 canonical content 里没有任何分隔,直接读成 `@hero把这一版改成夜景`;同一个字符串还会进 agent 输入、队列 chip 文案与润色判据。 -- **原因**:`ResourceReferenceInput` 的 `appendInputText` 只认「上一 part 是 `input_text`」这一种可追加情形,其余一律 `if (text.trim())` 才落 part。root 子节点之间补的段落分隔符与 `LineBreakNode` 传进来的都是 `'\n'`,`trim()` 为空 ⇒ 整段丢掉;chip 后那一段文字随后另起一个 part,派生文本用 `''` 直接拼接,于是粘在一起。 -- **处理**:投影层改成「向前合并」——待写文本先攒在 `pendingText` 上,下一个文本到来时合并成同一个 `input_text`(`\n` 因此落在 `\n把这一版改成夜景` 里);攒到最后仍只有空白的(以换行结尾、两个 chip 之间只隔一个换行)不入 content,因为 Rust `validate_direct_codex_user_item` 会拒绝空 `input_text`,直接按 review 的 `text.includes('\n')` 落 part 会让「chip 换行 chip」这种输入整轮发不出去。 -- **验证**:`apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx` 新增「引用后面的段落分隔不会被吞掉」,断言 chip 后一段的 part 为 `{ type: 'input_text', text: '\n把这一版改成夜景' }`、派生文本逐字一致、且不出现纯空白 part;去掉修复后该用例变红(比对失败在 content 相等那一行)。 -- **关联**:`apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx`(`DraftContentCollector` / `appendInputText`)、`docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md`。 +- **原因**:`3c7b02b9f`(2026-09-15)为了让 content 通过 Rust 的「空 `input_text`」校验,在投影层加了 `appendInputText`(`if (text.trim())` 才落 part,并与相邻文本合并)。root 子节点之间补的段落分隔符与 `LineBreakNode` 传进来的都是 `'\n'`,`trim()` 为空 ⇒ 整段丢掉;chip 后那一段文字随后另起一个 part,派生文本用 `''` 直接拼接,于是粘成 `@hero把这一版改成夜景`。`41366dd71` 又把消息正文 / 队列 chip / 快速编辑的文本派生切到这条投影上,缺陷扩散到界面与出站 prompt。 +- **处理(最终口径)**:不保留任何前端过滤,而是去掉规则和它的成因——Rust `validate_direct_codex_user_item` 改成只判整条 content(`content_has_meaningful_input`:有一段非空白文本或任意非文本 part 即有效),单个纯空白 `input_text` 合法;`ResourceReferenceInput` 的投影原样透传编辑器节点,既不丢空白也不与相邻 part 合并。中间版本(把待写文本「向前合并」到下一个 part)已随之删除:它仍会丢掉尾随换行与「两个 chip 之间只隔一个换行」的分隔,也仍要让前端替用户改写内容。 +- **验证**:Rust `validation.rs` / `wire.rs` 新增「单个纯空白 part 通过校验、整条全空白拒绝」用例;`apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx`「引用后面的段落分隔原样落进 content」断言 `[ref, { type: 'input_text', text: '\n' }, { type: 'input_text', text: '…' }]` 与派生文本逐字一致;`tests/appSurface/project-development.suite.ts` 的 Godot 用例断言 Shift+Enter 的两个换行各自成 part。 +- **关联**:`apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx`(`collectDraftParts`)、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs`、`docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md`。 ## 2026-09-16 派生文本漏传 `manifest.assets`:`@引用` 从显示名退化成内部 id