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 da7eda167..240489cda 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 @@ -174,41 +174,62 @@ class ResourceMentionOption extends MenuOption { } } -function appendInputText(content: DirectCodexUserContentPart[], text: string) { +/** + * 收集过程中的待写文本。 + * + * 只有紧挨着上一段文本时才能直接追加;上一 part 是引用(chip)时先把文本攒起来,等 + * 下一个文本到来再合并成同一个 part。否则段落分隔符(root 子节点之间补的 `\n`)和软 + * 换行会被整段丢掉,chip 后面的文字会粘成 `@素材下一段`,并原样进 agent 与队列文案。 + * 攒到最后仍然只有空白的(以换行结尾、两个 chip 之间只隔一个换行)不落成 part: + * Rust `validate_direct_codex_user_item` 会拒绝空 `input_text`。 + */ +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 (previous?.type === 'input_text') { + if (collector.pendingText === '' && previous?.type === 'input_text') { previous.text += text; return; } - if (text.trim()) { - content.push({ type: 'input_text', text }); + collector.pendingText += text; + if (!collector.pendingText.trim()) { + return; } + content.push({ type: 'input_text', text: collector.pendingText }); + collector.pendingText = ''; } function collectDraftParts( node: LexicalNode, references: ChatReference[], - content: DirectCodexUserContentPart[], + collector: DraftContentCollector, ) { if ($isTextNode(node)) { - appendInputText(content, node.getTextContent()); + appendInputText(collector, node.getTextContent()); return; } if ($isLineBreakNode(node)) { - appendInputText(content, '\n'); + appendInputText(collector, '\n'); return; } if ($isResourceReferenceNode(node)) { references.push(node.__reference); - content.push(chatReferenceToContentPart(node.__reference)); + collector.content.push(chatReferenceToContentPart(node.__reference)); return; } if ($isElementNode(node)) { node.getChildren().forEach((child, index) => { if (index > 0 && node.getType() === 'root') { - appendInputText(content, '\n'); + appendInputText(collector, '\n'); } - collectDraftParts(child, references, content); + collectDraftParts(child, references, collector); }); } } @@ -221,11 +242,11 @@ type DraftProjection = { /** 仅供编辑器内部派生引用(重建文本草稿时用);对外只暴露 canonical content。 */ function readDraftProjectionFromNodes(): DraftProjection { const references: ChatReference[] = []; - const content: DirectCodexUserContentPart[] = []; - collectDraftParts($getRoot(), references, content); + const collector: DraftContentCollector = { content: [], pendingText: '' }; + collectDraftParts($getRoot(), references, collector); return { references: dedupeChatReferences(references), - content, + content: collector.content, }; } diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index 68ace0833..953d3d391 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -251,6 +251,55 @@ describe('ResourceReferenceInput', () => { expect(screen.getByRole('button', { name: '模拟润色' })).not.toBeNull(); }); + test('引用后面的段落分隔不会被吞掉:读回的 prompt 与编辑器分段逐字一致', async () => { + const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); + function Controlled() { + const composerRef = createRef(); + return ( + <> + + + + ); + } + render(); + await settleComposer(); + + fireEvent.click(screen.getByRole('button', { name: '模拟润色' })); + await settleComposer(); + await settleComposer(); + + const draft = onChange.mock.calls.at(-1)?.[0]; + // chip 与后一段各自成段:段落分隔必须留在 canonical content 里。丢掉它读回的 + // prompt 会粘成 `@hero把这一版改成夜景`,并原样进 agent、队列文案与润色判据。 + expect(draft?.content).toEqual([ + chatReferenceToContentPart(reference), + { type: 'input_text', text: '\n把这一版改成夜景' }, + ]); + 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 () => { const onSubmit = vi.fn(); const user = userEvent.setup(); 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 80b8b6a13..38815e6c1 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,12 +38,13 @@ ## 实现结论 -- 唯一的前端空白过滤留在 Lexical 投影层:`agc_attachment`/`input_text` 之外的纯空白文本不作为 content part,因为 Rust `validate_direct_codex_user_item` 会拒绝空 `input_text`。转换函数不再重复过滤。 +- 唯一的前端空白过滤留在 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` 字段。 - 需要文本草稿的旧入口(`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`(含「引用后面的段落分隔不会被吞掉」)、`chatPromptPolish.test.tsx`、`tests/appSurface/*.suite.ts`:草稿读取、提醒判据、队列与 caller 迁移到 content-only。 - 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 444798e15..26eacc4ae 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5616,3 +5616,11 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - fixture 子进程统一清除 `GIT_*` 环境,并用一次性外层 linked worktree 验证引用、索引、配置不变;原有工程检查规则保持完整,不能用逐项关闭规则修复 fixture 污染。 - Vitest 的 `toHaveBeenCalledWith` 匹配任意一次调用,失败输出会列出其它命令;应先定位相同命令的真实参数差异,不能由其它调用的序号推断时序故障。 - 存在后台轮询的 IPC mock 不应要求目标命令占据全局最后一次调用。验证刷新时先记录调用边界,再筛选该边界之后的目标命令,严格核对其最后一次参数,避免后台查询影响断言,也避免旧调用掩盖刷新未执行。 + +## 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`。