放宽 DirectProject content 校验为「整条判定」并按逐字投影(review 第 1 条,① 口径)

- Rust validate_direct_codex_user_item 改为只判整条 content:有一段非空白文本或任意非文本 part 即有效,单个纯空白 input_text(段落分隔 / 软换行 / chip 后的分隔空格)合法,不再逐个 part 拒绝空文本
- 新增 content_has_meaningful_input,并与前端 hasMeaningfulDirectCodexContent 同口径;wire.rs 的「不能转换为空 prompt」只作兜底
- ResourceReferenceInput 的投影层原样透传编辑器节点:不做空白过滤、也不与相邻 part 合并,删掉 appendInputText 与中间版本的「向前合并」收集器
- 按逐字投影更新资源输入、Godot 回合用例的 canonical content 断言
- 同步里程碑实现结论与证据、pitfalls 条目;Rust validation/wire 新增「单个纯空白 part 通过、整条全空白拒绝」用例
This commit is contained in:
2026-09-17 14:01:41 +08:00
parent e469831543
commit b543a060b7
7 changed files with 165 additions and 75 deletions
@@ -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(),
},
]));
}
}
@@ -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}");
}
}
@@ -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,
};
}
@@ -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' },
],
},
},
);
@@ -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 只承载有意义的 partchip 之间的分隔空格是纯 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(
@@ -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` 通过。
@@ -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