修复游戏智能体输入含空白片段时被误判为空 #449
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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 对话栏,松手即把这次拖动真正参与位移的那批素材整批 `@` 进输入框(框选多选后拖任意一张 = 整批引用;拖未选中的卡 = 只引用它自己)。
|
||||
|
||||
Reference in New Issue
Block a user