diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 7bf8b2484..74ad76f39 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -1133,7 +1133,6 @@ fn direct_codex_thread_delta_event( ) -> DirectThreadEvent { DirectThreadEvent::item_delta(item_id, kind, direct_thread_delta_text(root, delta)) } - /// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。 /// /// 读取器只负责"必须有 turnId 才处理"的前置条件与节流(活动 / 正文),分类不在这里之外 @@ -2961,8 +2960,22 @@ impl CodexAppServerConnection { codex_app_server_text_prompt(&request) .map_err(platform_llm::LlmError::InvalidRequest)? }; - let input = - codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?; + let input = if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + if let Some(item) = direct_user_item { + let canonical: DirectCodexUserItem = serde_json::from_value(item.clone()) + .map_err(|error| platform_llm::LlmError::InvalidRequest(error.to_string()))?; + direct_codex_user_item_to_codex_turn_input( + &self.inner.workspace_path, + &canonical, + self.inner._skill_roots.as_deref().unwrap_or_default(), + ) + .map_err(platform_llm::LlmError::InvalidRequest)? + } else { + codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await? + } + } else { + codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await? + }; let _direct_tool_bridge_turn_guard = if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { Some( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs index 8cf49e017..2f93f775f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs @@ -2,9 +2,9 @@ //! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。 pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8; -const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160; -const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96; -const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512; +pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160; +pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96; +pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512; const HOME_ATTACHMENT_HEADER: &str = "[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs index e33bfae72..946cb4de3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs @@ -5,11 +5,11 @@ mod validation; mod wire; pub(crate) use model::{ - DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem, - DirectCodexUserRole, DirectCodexUserRuntimeRegionPart, + DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem, + DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart, }; pub(crate) use validation::validate_direct_codex_user_item; pub(crate) use wire::{ - direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item, - direct_codex_user_item_to_wire_input, + direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt, + direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs index 266f330cc..19cf64e8d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs @@ -34,8 +34,25 @@ pub(crate) enum DirectCodexUserContentPart { InputText { text: String }, #[serde(rename = "agc_resource_reference")] AgcResourceReference { resource_id: String }, + #[serde(rename = "agc_skill_reference")] + AgcSkillReference { name: String }, #[serde(rename = "agc_runtime_region_reference")] AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart), + /// Uploaded project attachment kept inline in canonical content. + #[serde(rename = "agc_attachment_reference")] + AgcAttachmentReference(DirectCodexUserAttachmentReferencePart), +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserAttachmentReferencePart { + pub(crate) name: String, + pub(crate) media_type: String, + #[ts(type = "number")] + pub(crate) size: u64, + pub(crate) local_path: String, + pub(crate) status: String, } #[derive(Clone, Debug, Deserialize, Serialize, TS)] 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..5f352dcc6 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 @@ -4,6 +4,8 @@ use super::model::{ }; use crate::agent::{ read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest, + MAX_DIRECT_CODEX_ATTACHMENTS, MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS, + MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS, }; use std::path::Path; @@ -12,7 +14,7 @@ pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32; pub(crate) fn validate_direct_codex_user_item( root: &Path, item: &DirectCodexUserItem, -) -> Result<(), String> { +) -> Result { let DirectCodexUserItem::Message(message) = item; if !matches!(message.role, DirectCodexUserRole::User) { return Err("DirectProject 只接受 user message item".to_string()); @@ -20,32 +22,91 @@ 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; + let mut attachment_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)?; } + DirectCodexUserContentPart::AgcSkillReference { name } => { + let name = name.trim(); + if name.is_empty() + || name.chars().count() > 120 + || matches!(name, "." | "..") + || name.chars().any(|character| { + character.is_control() + || character.is_whitespace() + || matches!(character, '/' | '\\' | ':' | '$') + }) + { + return Err("引用的 Skill 名称无效,请移除后重新选择".to_string()); + } + } DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { reference_count = reference_count.saturating_add(1); validate_runtime_region_reference(&manifest, reference)?; } + DirectCodexUserContentPart::AgcAttachmentReference(reference) => { + attachment_count = attachment_count.saturating_add(1); + if attachment_count > MAX_DIRECT_CODEX_ATTACHMENTS { + return Err(format!( + "一次最多携带 {MAX_DIRECT_CODEX_ATTACHMENTS} 个附件" + )); + } + if reference.name.trim().is_empty() { + return Err("附件缺少文件名".to_string()); + } + let name = reference.name.trim(); + if name.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS + || name.chars().any(char::is_control) + { + return Err("附件文件名无效或过长".to_string()); + } + let media_type = reference.media_type.trim(); + if media_type.is_empty() + || media_type.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS + || media_type.chars().any(|character| { + !(character.is_ascii_alphanumeric() + || matches!(character, '/' | '+' | '-' | '.' | '_')) + }) + { + return Err("附件媒体类型无效或过长".to_string()); + } + let status = reference.status.trim(); + if status == "imported" && reference.local_path.trim().is_empty() { + return Err("已导入附件缺少项目路径".to_string()); + } + if !reference.local_path.trim().is_empty() { + sanitize_attachment_local_path(&reference.local_path) + .ok_or_else(|| "附件项目路径无效".to_string())?; + } + if !matches!(status, "imported" | "failed") { + return Err("附件状态无效".to_string()); + } + } } } if reference_count > MAX_DIRECT_CODEX_REFERENCES { return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材")); } - Ok(()) + Ok(manifest) +} + +/// 整条 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( @@ -86,3 +147,150 @@ fn validate_runtime_region_reference( } Ok(()) } + +#[cfg(test)] +mod tests { + use super::{content_has_meaningful_input, validate_direct_codex_user_item}; + use crate::agent::direct_codex_user_item::model::DirectCodexUserContentPart; + use serde_json::json; + + 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(), + }, + ])); + } + + #[test] + fn inline_attachment_count_is_bounded_independently() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "validation-test", "校验测试") + .expect("init project"); + let content = (0..=crate::agent::MAX_DIRECT_CODEX_ATTACHMENTS) + .map(|index| { + json!({ + "type": "agc_attachment_reference", + "name": format!("attachment-{index}.txt"), + "mediaType": "text/plain", + "size": 1, + "localPath": "", + "status": "failed" + }) + }) + .collect::>(); + let item = serde_json::from_value(json!({ + "type": "message", + "role": "user", + "content": content, + "id": "turn-1:user" + })) + .expect("deserialize user item"); + let error = validate_direct_codex_user_item(root.path(), &item) + .expect_err("too many inline attachments must be rejected"); + assert!(error.contains("最多携带"), "{error}"); + } + + #[test] + fn imported_attachment_requires_a_project_path() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "validation-test", "校验测试") + .expect("init project"); + let item = serde_json::from_value(json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "agc_attachment_reference", + "name": "attachment.txt", + "mediaType": "text/plain", + "size": 1, + "localPath": "", + "status": "imported" + }], + "id": "turn-1:user" + })) + .expect("deserialize user item"); + let error = validate_direct_codex_user_item(root.path(), &item) + .expect_err("imported attachment without a project path must fail"); + assert!(error.contains("缺少项目路径"), "{error}"); + } + + #[test] + fn attachment_name_and_media_type_are_bounded_and_well_formed() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "validation-test", "校验测试") + .expect("init project"); + let long_name = "a".repeat(crate::agent::MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS + 1); + let cases = [ + ( + json!({ + "name": "bad\nname.txt", + "mediaType": "text/plain" + }), + "文件名", + ), + ( + json!({ + "name": "ok.txt", + "mediaType": "text/plain\nsecret" + }), + "媒体类型", + ), + ( + json!({ + "name": long_name, + "mediaType": "text/plain" + }), + "文件名", + ), + ]; + for (metadata, expected) in cases { + let mut value = metadata; + value["type"] = json!("agc_attachment_reference"); + value["size"] = json!(1); + value["localPath"] = json!(""); + value["status"] = json!("failed"); + let item = serde_json::from_value(json!({ + "type": "message", + "role": "user", + "content": [value], + "id": "turn-1:user" + })) + .expect("deserialize user item"); + let error = validate_direct_codex_user_item(root.path(), &item) + .expect_err("invalid attachment metadata must fail"); + assert!(error.contains(expected), "{error}"); + } + } +} 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 2ab90cc62..e180bd4c8 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 @@ -1,6 +1,11 @@ -use super::model::{DirectCodexUserContentPart, DirectCodexUserItem}; +use super::model::{ + DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserRuntimeRegionPart, +}; use super::validation::validate_direct_codex_user_item; -use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path}; +use crate::agent::{ + read_manifest_for_project, sanitize_attachment_local_path, sanitize_attachment_media_type, + sanitize_attachment_name,GameCreationAppManifest, +}; use serde_json::Value; use std::path::Path; @@ -51,6 +56,47 @@ fn direct_codex_user_item_to_response_content( .collect() } +fn resource_reference_summary( + manifest: &GameCreationAppManifest, + resource_id: &str, +) -> Result { + let resource_id = resource_id.trim(); + let asset = manifest + .assets + .iter() + .find(|asset| asset.id == resource_id) + .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; + let path = sanitize_attachment_local_path(&asset.local_path) + .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; + Ok(format!( + "[素材引用 resourceId={resource_id};项目路径={path}]" + )) +} + +fn runtime_region_summary(reference: &DirectCodexUserRuntimeRegionPart) -> String { + let resources = reference + .resource_ids + .iter() + .map(|id| id.trim()) + .collect::>() + .join(","); + let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim()); + if let Some(run_id) = reference.run_id.as_deref() { + summary.push_str(&format!("运行标识={} ", run_id.trim())); + } + if let Some(role) = reference.element_role.as_deref() { + summary.push_str(&format!("角色={} ", role.trim())); + } + if let Some(text) = reference.text.as_deref() { + summary.push_str(&format!("文本={} ", text.trim())); + } + if !resources.is_empty() { + summary.push_str(&format!("关联素材={resources}")); + } + summary.push(']'); + summary +} + /// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。 /// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。 pub(crate) fn direct_codex_user_item_to_wire_input( @@ -65,38 +111,26 @@ pub(crate) fn direct_codex_user_item_to_wire_input( let text = match part { DirectCodexUserContentPart::InputText { text } => text.clone(), DirectCodexUserContentPart::AgcResourceReference { resource_id } => { - let asset = manifest - .assets - .iter() - .find(|asset| asset.id == resource_id.trim()) - .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; - let path = sanitize_attachment_local_path(&asset.local_path) - .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; - format!( - "[素材引用 resourceId={};项目路径={path}]", - resource_id.trim() - ) + resource_reference_summary(&manifest, resource_id)? + } + DirectCodexUserContentPart::AgcSkillReference { name } => { + format!("${}", name.trim()) } DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { - let resources = reference - .resource_ids - .iter() - .map(|id| id.trim()) - .collect::>() - .join(","); - let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim()); - if let Some(run_id) = reference.run_id.as_deref() { - summary.push_str(&format!("运行标识={} ", run_id.trim())); - } - if let Some(role) = reference.element_role.as_deref() { - summary.push_str(&format!("角色={} ", role.trim())); - } - if let Some(text) = reference.text.as_deref() { - summary.push_str(&format!("文本={} ", text.trim())); - } - if !resources.is_empty() { - summary.push_str(&format!("关联素材={resources}")); + runtime_region_summary(reference) + } + DirectCodexUserContentPart::AgcAttachmentReference(reference) => { + let name = sanitize_attachment_name(&reference.name); + let media_type = sanitize_attachment_media_type(&reference.media_type); + let local_path = sanitize_attachment_local_path(&reference.local_path); + let mut summary = format!( + "[附件:名称={};类型={};大小={} 字节", + name, media_type, reference.size + ); + if let Some(local_path) = local_path { + summary.push_str(&format!(";项目路径={local_path}")); } + summary.push_str(&format!(";状态={}", reference.status.trim())); summary.push(']'); summary } @@ -106,6 +140,66 @@ pub(crate) fn direct_codex_user_item_to_wire_input( Ok(Value::Array(input)) } +pub(crate) fn direct_codex_user_item_to_codex_turn_input( + root: &Path, + item: &DirectCodexUserItem, + skill_roots: &[std::path::PathBuf], +) -> Result { + let manifest = validate_direct_codex_user_item(root, item)?; + let DirectCodexUserItem::Message(message) = item; + let mut input = Vec::with_capacity(message.content.len()); + for part in &message.content { + match part { + DirectCodexUserContentPart::InputText { text } => { + input.push(serde_json::json!({ "type": "text", "text": text })); + } + DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + input.push(serde_json::json!({ + "type": "text", + "text": resource_reference_summary(&manifest, resource_id)?, + })); + } + DirectCodexUserContentPart::AgcSkillReference { name } => { + let name = name.trim(); + let path = skill_roots + .iter() + .map(|root| root.join(name).join("SKILL.md")) + .find(|path| path.is_file()) + .ok_or_else(|| "引用的 Skill 当前不可用,请重新选择".to_string())?; + input.push(serde_json::json!({ + "type": "skill", + "name": name, + "path": path, + })); + } + DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + input.push(serde_json::json!({ + "type": "text", + "text": runtime_region_summary(reference), + })); + } + DirectCodexUserContentPart::AgcAttachmentReference(reference) => { + let mut summary = format!( + "[附件:名称={};类型={};大小={} 字节", + reference.name.trim(), + reference.media_type.trim(), + reference.size + ); + if !reference.local_path.trim().is_empty() { + summary.push_str(&format!(";项目路径={}", reference.local_path.trim())); + } + summary.push_str(&format!(";状态={}", reference.status.trim())); + summary.push(']'); + input.push(serde_json::json!({ + "type": "text", + "text": summary, + })); + } + } + } + Ok(Value::Array(input)) +} + pub(crate) fn direct_codex_user_item_to_prompt( root: &Path, item: &DirectCodexUserItem, @@ -130,7 +224,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; @@ -174,4 +269,99 @@ mod tests { .expect_err("history item without type must fail"); assert!(error.contains("缺少 type"), "{error}"); } + + #[test] + fn attachment_parts_remain_in_canonical_order_when_projected() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") + .expect("init project"); + let item = json!({ + "type": "message", + "role": "user", + "id": "turn-1:user", + "content": [ + {"type": "input_text", "text": "先看"}, + {"type": "agc_attachment_reference", "name": "notes.txt", "mediaType": "text/plain", "size": 4, "localPath": "assets/notes.txt", "status": "imported"} + ] + }); + let projected = direct_codex_user_item_to_response_item(root.path(), &item) + .expect("user response item should project"); + let content = projected["content"].as_array().expect("content array"); + assert_eq!(content.len(), 2); + assert!(content[0]["text"].as_str().unwrap().contains("先看")); + assert!(content[1]["text"].as_str().unwrap().contains("notes.txt")); + } + + #[test] + fn attachment_metadata_is_sanitized_before_prompt_projection() { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") + .expect("init project"); + let item = json!({ + "type": "message", + "role": "user", + "id": "turn-1:user", + "content": [{ + "type": "agc_attachment_reference", + "name": "C:\\tmp\\notes.md", + "mediaType": "text/plain", + "size": 4, + "localPath": "assets\\.\\notes.txt", + "status": "imported" + }] + }); + let wire = super::direct_codex_user_item_to_wire_input( + root.path(), + &serde_json::from_value(item).expect("deserialize user item"), + ) + .expect("attachment metadata should project"); + let text = wire[0]["text"].as_str().expect("wire text"); + assert!(text.contains("名称=notes.md"), "{text}"); + assert!(text.contains("类型=text/plain"), "{text}"); + assert!(text.contains("项目路径=assets/notes.txt"), "{text}"); + } + + #[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-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index aa9bbe8f7..506c9f070 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -31,10 +31,9 @@ pub(crate) fn normalize_direct_client_turn_id( pub(crate) async fn chat_with_game_creator_direct_codex( project_path: String, prompt: String, - mut user_item: DirectCodexUserItem, + user_item: DirectCodexUserItem, creation_type: Option, client_turn_id: Option, - attachments: Option>, ) -> Result { let root = Path::new(project_path.trim()); let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; @@ -43,34 +42,9 @@ pub(crate) async fn chat_with_game_creator_direct_codex( redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500) })?; let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone()); - let mut audit = DirectCodexTurnAudit::start( - root, - &turn_id, - &prompt, - attachments.as_deref().unwrap_or_default(), - ); - let attachments = attachments.unwrap_or_default(); - if !attachments.is_empty() { - let attachment_context = - render_direct_codex_user_prompt("", &attachments).map_err(|error| { - audit.finish(false); - error - })?; - let DirectCodexUserItem::Message(message) = &mut user_item; - message.content.push(DirectCodexUserContentPart::InputText { - text: attachment_context, - }); - } - validate_direct_codex_user_item(root, &user_item).map_err(|error| { - audit.finish(false); - error - })?; - let user_prompt = direct_codex_user_item_to_prompt(root, &user_item).map_err(|error| { - audit.finish(false); - error - })?; + validate_direct_codex_user_item(root, &user_item)?; + let user_prompt = direct_codex_user_item_to_prompt(root, &user_item)?; if user_prompt.trim().is_empty() { - audit.finish(false); return Err("聊天内容不能为空".to_string()); } let canonical_user_item = @@ -80,18 +54,15 @@ pub(crate) async fn chat_with_game_creator_direct_codex( &user_prompt, creation_type.as_deref(), Some(&turn_emitter), - Some(&mut audit), + // DirectProject 的完整回合权威已经落在 project.jsonl;不再创建平行审计日志。 + None, canonical_user_item, ) .await { Ok(reply) => reply, - Err(error) => { - audit.finish(false); - return Err(error); - } + Err(error) => return Err(error), }; - audit.finish(true); turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None); Ok(reply) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs index 5c5f9e602..1d5fc4861 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/skill_pack.rs @@ -1,4 +1,4 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::borrow::Cow; use std::collections::BTreeSet; @@ -121,6 +121,13 @@ struct AgcSkillManifestEntry { sha256: String, } +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AgcSkillCatalogEntry { + pub(crate) name: String, + pub(crate) description: String, +} + fn is_safe_skill_relative_path(value: &str) -> bool { let path = Path::new(value); !value.is_empty() @@ -234,6 +241,21 @@ pub(crate) fn agc_skill_pack_fingerprint() -> Result { Ok(format!("{:x}", Sha256::digest(canonical_manifest.as_ref()))) } +/// 返回当前客户端随 AGC 一起启用的内置 Skill 候选。 +/// +/// 前端不得复制审核清单;Skill 名称和描述统一从经过校验的资源 manifest 派生。 +#[tauri::command] +pub(crate) fn list_agc_skill_catalog() -> Result, String> { + Ok(validated_skill_pack_manifest()? + .skills + .into_iter() + .map(|entry| AgcSkillCatalogEntry { + name: entry.name, + description: entry.purpose, + }) + .collect()) +} + pub(crate) fn render_agc_skill_pack_index() -> Result { let manifest = validated_skill_pack_manifest()?; let mut lines = vec![format!( @@ -328,6 +350,19 @@ mod tests { } } + #[test] + fn skill_catalog_is_derived_from_the_validated_manifest() { + let catalog = list_agc_skill_catalog().expect("skill catalog"); + assert_eq!(catalog.len(), AGC_SKILL_PACK_EXPECTED_NAMES.len()); + for expected_name in AGC_SKILL_PACK_EXPECTED_NAMES { + let entry = catalog + .iter() + .find(|entry| entry.name == expected_name) + .expect("expected bundled skill"); + assert!(!entry.description.trim().is_empty()); + } + } + #[test] fn skill_content_digest_is_stable_across_lf_and_crlf() { fn digest(bytes: &[u8]) -> String { diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 973197ae6..48657522a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2518,6 +2518,7 @@ fn main() { pick_client_extension_file, pick_client_extension_directory, list_client_extensions, + list_agc_skill_catalog, import_client_extension, set_client_extension_enabled, rename_client_extension, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index f49e8b792..28e26d67d 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -291,7 +291,11 @@ import type { } from './features/project-workspace/resourceReferences'; import { chatComposerDraftToDirectCodexUserItem, + directCodexContentToPromptText, + directCodexUserItemFromContent, + hasMeaningfulDirectCodexContent, RESOURCE_REFERENCE_INSERT_EVENT, + resourceReferenceFromAsset, type ResourceReferenceInsertEventDetail, } from './features/project-workspace/resourceReferences'; import { SupervisorChatOnlyView } from './features/project-workspace/SupervisorChatOnlyView'; @@ -4072,9 +4076,39 @@ export function App({ } function handleChatComposerChange(draft: ChatComposerDraft) { - setChatInput(draft.text); - setChatReferences(draft.references); - setChatContent(draft.content ?? []); + const content = draft.content; + setChatContent(content); + setChatInput(directCodexContentToPromptText(content, manifest.assets)); + setChatReferences( + content.flatMap((part) => { + if (part.type === 'agc_resource_reference') { + const asset = manifest.assets.find( + (item) => item.id === part.resourceId, + ); + return asset + ? [resourceReferenceFromAsset(asset, 'asset-picker')] + : []; + } + if (part.type === 'agc_runtime_region_reference') { + return [ + { + type: 'runtime-region' as const, + label: part.label, + runId: part.runId ?? undefined, + versionId: part.versionId ?? undefined, + elementTag: part.elementTag ?? undefined, + elementRole: part.elementRole ?? undefined, + text: part.text ?? undefined, + width: part.width ?? undefined, + height: part.height ?? undefined, + resourceIds: part.resourceIds, + source: 'runtime-picker' as const, + }, + ] as ChatReference[]; + } + return [] as ChatReference[]; + }), + ); } useEffect(() => { @@ -6474,12 +6508,13 @@ export function App({ if (directProjectPath && directProjectId && directInvoke) { const clientTurnId = directConversationTurnId ?? createDirectCodexConversationTurnId(); - const effectiveUserItem = - userItem ?? - chatComposerDraftToDirectCodexUserItem( - { text: prompt, references: references ?? [], content: [] }, - directCodexConversationMessageId(clientTurnId, 'user'), + if (!userItem) { + setProjectSupervisorRuntimeError( + 'DirectProject 回合缺少 canonical user item,未发送本轮消息。', ); + return; + } + const effectiveUserItem = userItem; if ( !directPolicyChecked && projectConversationWriteConfirmedRef.current !== directProjectPath @@ -6606,7 +6641,6 @@ export function App({ prompt: string; clientTurnId: string; creationType?: HomeCreationType; - attachments?: DirectCodexTurnAttachment[]; userItem: ReturnType; } = { projectPath: directProjectPath, @@ -6617,9 +6651,6 @@ export function App({ if (creationType) { directTurnInput.creationType = creationType; } - if (attachments?.length) { - directTurnInput.attachments = attachments; - } directTurnInput.userItem = effectiveUserItem; // 回复正文按条目身份从线程事件 / 历史切片进聊天,本地不再补一条, // 因此这里只等回合跑完,不接返回值。 @@ -6704,6 +6735,7 @@ export function App({ setDirectCodexTurnCancelling(false); chatAgentBusyRef.current = false; // 本地 invoke 正常收尾仍可直接推进队列;恢复场景则由 turn.completed effect 推进。 + // 只有当前回合的收尾才能释放发送队列,不能覆盖后来启动的回合。 dispatchNextQueuedChatTurn(); } } @@ -6925,11 +6957,21 @@ export function App({ updatedAt: Date.now(), }, ]); + const initialUserItem = directConversationTurnId + ? directCodexUserItemFromContent( + [ + { type: 'input_text', text: latch.prompt }, + ...attachmentContentParts(latch.attachments), + ], + directCodexConversationMessageId(directConversationTurnId, 'user'), + ) + : undefined; void executeChatAgentReplyRef.current({ prompt: latch.prompt, clientTurnId: directConversationTurnId, creationType: latch.creationType, attachments: latch.attachments, + ...(initialUserItem ? { userItem: initialUserItem } : {}), }); }, [ chatAgentBusy, @@ -11990,36 +12032,28 @@ export function App({ * 消息落盘/回合 id/附件参数走样。 */ function startDirectCodexConversationTurn(input: { - prompt: string; - attachments?: DirectCodexTurnAttachment[]; - references?: ChatReference[]; - content?: DirectCodexUserContentPart[]; + clientTurnId: string; + userItem: ReturnType; }) { - const clientTurnId = createDirectCodexConversationTurnId(); + const prompt = directCodexContentToPromptText( + input.userItem.content, + manifest.assets, + ); supervisorChatShouldFollowLatestRef.current = true; setMessages((current) => [ ...current, { role: 'user', - text: input.prompt, + text: prompt, runtimeOwned: true, - messageId: directCodexConversationMessageId(clientTurnId, 'user'), + messageId: directCodexConversationMessageId(input.clientTurnId, 'user'), updatedAt: Date.now(), }, ]); void executeChatAgentReply({ - prompt: input.prompt, - clientTurnId, - attachments: input.attachments?.length ? input.attachments : undefined, - references: input.references, - userItem: chatComposerDraftToDirectCodexUserItem( - { - text: input.prompt, - references: input.references ?? [], - content: input.content ?? [], - }, - directCodexConversationMessageId(clientTurnId, 'user'), - ), + prompt, + clientTurnId: input.clientTurnId, + userItem: input.userItem, }); } @@ -12080,12 +12114,24 @@ export function App({ ); } + function attachmentContentParts( + attachments: readonly DirectCodexTurnAttachment[], + ): DirectCodexUserContentPart[] { + return attachments.map((attachment) => ({ + type: 'agc_attachment_reference' as const, + name: attachment.name, + mediaType: attachment.mediaType, + size: attachment.size ?? 0, + localPath: attachment.localPath ?? '', + status: + attachment.status ?? (attachment.localPath ? 'imported' : 'failed'), + })); + } + /** 回合运行中再次发送:进本地 FIFO 队列;队列满时拒绝并保留草稿,不静默丢消息。 */ function enqueueChatTurnForRunningTurn(input: { - prompt: string; - attachments: DirectCodexTurnAttachment[]; - references: ChatReference[]; - content: DirectCodexUserContentPart[]; + clientTurnId: string; + userItem: ReturnType; }): boolean { if (isChatTurnQueueFull(chatTurnQueueRef.current)) { setChatComposerNotice(chatQueueFullNotice()); @@ -12094,10 +12140,8 @@ export function App({ queuedChatTurnSequenceRef.current += 1; const turn = createQueuedChatTurn({ id: `queued-chat-turn-${Date.now()}-${queuedChatTurnSequenceRef.current}`, - prompt: input.prompt, - attachments: input.attachments, - references: input.references, - content: input.content, + clientTurnId: input.clientTurnId, + userItem: input.userItem, createdAt: Date.now(), }); const nextQueue = enqueueChatTurn(chatTurnQueueRef.current, turn); @@ -12132,10 +12176,8 @@ export function App({ setChatComposerNotice(''); } startDirectCodexConversationTurn({ - prompt: next.prompt, - attachments: next.attachments, - references: next.references, - content: next.content, + clientTurnId: next.clientTurnId, + userItem: next.userItem, }); } @@ -12216,6 +12258,14 @@ export function App({ const prompt = chatInput.trim(); const references = chatReferences; const pendingAttachments = chatAttachments; + const content = [ + ...chatContent, + ...attachmentContentParts(pendingAttachments), + ]; + const canonicalPrompt = directCodexContentToPromptText( + content, + manifest.assets, + ); if ( !directCodexProductRuntime && supervisorChatOnly && @@ -12231,23 +12281,20 @@ export function App({ setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题'); return; } - if ( - !prompt && - references.length === 0 && - chatContent.length === 0 && - pendingAttachments.length === 0 - ) { + if (!hasMeaningfulDirectCodexContent(content)) { return; } if (supervisorChatBusy) { // 回合运行中再次发送:direct-codex 面板把消息放进本地 FIFO 队列,当前回合结束后 // 依次发出;其它面板保持原有"运行中不接受新输入"的行为。 if (directCodexProductRuntime) { + const clientTurnId = createDirectCodexConversationTurnId(); const enqueued = enqueueChatTurnForRunningTurn({ - prompt, - attachments: pendingAttachments, - references, - content: chatContent, + clientTurnId, + userItem: directCodexUserItemFromContent( + content, + directCodexConversationMessageId(clientTurnId, 'user'), + ), }); if (enqueued) { setChatInput(''); @@ -12271,7 +12318,7 @@ export function App({ return; } if (planningV2ActiveRef.current || planningStartMode) { - if (!prompt || chatAgentBusy) { + if (!canonicalPrompt || chatAgentBusy) { return; } supervisorChatShouldFollowLatestRef.current = true; @@ -12281,13 +12328,13 @@ export function App({ ...current, { role: 'user', - text: prompt, + text: canonicalPrompt, runtimeOwned: true, messageId: `planning-v2:${clientTurnId}:user`, updatedAt: Date.now(), }, ]); - void executeChatAgentReply({ prompt, clientTurnId }); + void executeChatAgentReply({ prompt: canonicalPrompt, clientTurnId }); return; } if (supervisorChatOnly || directCodexProductRuntime) { @@ -12301,11 +12348,13 @@ export function App({ setChatContent([]); setChatAttachmentNotice(''); setChatComposerNotice(''); + const clientTurnId = createDirectCodexConversationTurnId(); startDirectCodexConversationTurn({ - prompt, - attachments: pendingAttachments, - references, - content: chatContent, + clientTurnId, + userItem: directCodexUserItemFromContent( + content, + directCodexConversationMessageId(clientTurnId, 'user'), + ), }); return; } diff --git a/apps/ai-game-creator-shell/src/components/RichTextInput.tsx b/apps/ai-game-creator-shell/src/components/RichTextInput.tsx new file mode 100644 index 000000000..02c942e50 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/RichTextInput.tsx @@ -0,0 +1,102 @@ +import { LexicalComposer } from '@lexical/react/LexicalComposer'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { ContentEditable } from '@lexical/react/LexicalContentEditable'; +import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; +import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; +import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; +import { + COMMAND_PRIORITY_HIGH, + type EditorState, + KEY_ENTER_COMMAND, + type Klass, + type LexicalNode, +} from 'lexical'; +import type { ReactElement, ReactNode, Ref } from 'react'; +import { useEffect } from 'react'; + +type RichTextInputProps = { + namespace: string; + nodes: Klass[]; + initialEditorState?: EditorState | null; + contentEditable?: ReactElement; + placeholder?: ReactElement; + containerClassName?: string; + containerRef?: Ref; + disabled?: boolean; + onChange?: (editorState: EditorState) => void; + onEnter?: () => void; + children?: ReactNode; +}; + +function SubmitOnEnter({ onEnter }: { onEnter?: () => void }) { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (!onEnter) return undefined; + return editor.registerCommand( + KEY_ENTER_COMMAND, + (event) => { + if (!event || event.shiftKey || event.isComposing) return false; + event.preventDefault(); + onEnter(); + return true; + }, + COMMAND_PRIORITY_HIGH, + ); + }, [editor, onEnter]); + + return null; +} + +function SetEditorEditable({ disabled }: { disabled: boolean }) { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + editor.setEditable(!disabled); + }, [disabled, editor]); + + return null; +} + +export default function RichTextInput({ + namespace, + nodes, + initialEditorState, + contentEditable = , + placeholder, + containerClassName, + containerRef, + disabled = false, + onChange, + onEnter, + children, +}: RichTextInputProps) { + return ( + { + throw error; + }, + }} + > +
+ + {children} + + + {onChange ? : null} +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceChip.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceChip.tsx new file mode 100644 index 000000000..e533d6220 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceChip.tsx @@ -0,0 +1,40 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { $getNodeByKey, type NodeKey } from 'lexical'; +import { Paperclip, X } from 'lucide-react'; + +import type { DirectCodexUserAttachmentReferencePart } from './generated'; + +export function AttachmentReferenceChip({ + attachment, + nodeKey, +}: { + attachment: DirectCodexUserAttachmentReferencePart; + nodeKey: NodeKey; +}) { + const [editor] = useLexicalComposerContext(); + return ( + + + ); +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceNode.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceNode.tsx new file mode 100644 index 000000000..71b77dc85 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/AttachmentReferenceNode.tsx @@ -0,0 +1,107 @@ +import { + $applyNodeReplacement, + DecoratorNode, + type EditorConfig, + type LexicalNode, + type NodeKey, + type SerializedLexicalNode, + type Spread, +} from 'lexical'; +import type { ReactNode } from 'react'; + +import { AttachmentReferenceChip } from './AttachmentReferenceChip'; +import type { DirectCodexUserAttachmentReferencePart } from './generated'; + +export type SerializedAttachmentReferenceNode = Spread< + { + attachment: DirectCodexUserAttachmentReferencePart; + type: 'attachment-reference'; + version: 1; + }, + SerializedLexicalNode +>; + +/** 附件在纯文本里的唯一占位字符:一个附件 chip 就当一个字符。 */ +const OBJECT_REPLACEMENT = '\uFFFC'; + +/** + * Inline canonical attachment part rendered inside the Lexical composer. + * The node stores the complete part so read-back preserves the project path and status. + */ +export class AttachmentReferenceNode extends DecoratorNode { + __attachment: DirectCodexUserAttachmentReferencePart; + + static getType() { + return 'attachment-reference'; + } + + static clone(node: AttachmentReferenceNode) { + return new AttachmentReferenceNode(node.__attachment, node.__key); + } + + static importJSON(serializedNode: SerializedAttachmentReferenceNode) { + return $createAttachmentReferenceNode(serializedNode.attachment); + } + + constructor( + attachment: DirectCodexUserAttachmentReferencePart, + key?: NodeKey, + ) { + super(key); + this.__attachment = attachment; + } + + exportJSON(): SerializedAttachmentReferenceNode { + return { + ...super.exportJSON(), + attachment: this.__attachment, + type: 'attachment-reference', + version: 1, + }; + } + + createDOM(_config: EditorConfig) { + return document.createElement('span'); + } + + updateDOM() { + return false; + } + + getTextContent() { + return OBJECT_REPLACEMENT; + } + + getTextContentSize() { + return OBJECT_REPLACEMENT.length; + } + + isInline() { + return true; + } + + isKeyboardSelectable() { + return true; + } + + decorate() { + return ( + + ); + } +} + +export function $createAttachmentReferenceNode( + attachment: DirectCodexUserAttachmentReferencePart, +) { + return $applyNodeReplacement(new AttachmentReferenceNode(attachment)); +} + +export function $isAttachmentReferenceNode( + node: LexicalNode | null | undefined, +): node is AttachmentReferenceNode { + return node instanceof AttachmentReferenceNode; +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx index 0aa574dbc..292ddd245 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx @@ -19,6 +19,7 @@ import { import type { RefObject } from 'react'; import { useEffect, useRef, useState } from 'react'; +import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { resolveTauriInvoke } from '../../app/tauri'; import type { GameCreatorAppConfigView, @@ -192,9 +193,12 @@ export function ComposerPendingAttachments({ /** 队列 chip:回合运行中入队的消息,按 FIFO 顺序展示,可单条取消。 */ export function ComposerTurnQueue({ turns, + assets, onCancel, }: { turns: readonly QueuedChatTurn[]; + /** 与聊天消息渲染同源的素材清单:chip 文案里的 `@` 引用按它展开成显示名。 */ + assets: readonly GameCreationAppAssetManifestEntry[]; onCancel: (id: string) => void; }) { if (turns.length === 0) { @@ -211,11 +215,11 @@ export function ComposerTurnQueue({ {index + 1} - {queuedChatTurnLabel(turn)} + {queuedChatTurnLabel(turn, assets)} ))} , document.body, ); }, - [], + [rootRef], ); const pickerReferences = useMemo(() => { @@ -956,7 +1237,7 @@ function ResourceReferenceEditor({ bottom: Math.max(12, window.innerHeight - rect.top + 8), width, }); - }, []); + }, [rootRef]); useEffect(() => { if (!pickerOpen) { @@ -973,31 +1254,7 @@ function ResourceReferenceEditor({ }, [pickerOpen, updatePickerPosition]); return ( -
- - } - placeholder={ - - {placeholder} - - } - ErrorBoundary={LexicalErrorBoundary} - /> + <>
{showTriggerButton ? (
+ ); } @@ -1304,17 +1585,33 @@ export const ResourceReferenceInput = forwardRef< ResourceReferenceInputHandle, ResourceReferenceInputProps >(function ResourceReferenceInput(props, ref) { + const rootRef = useRef(null); return ( - { - throw error; - }, - }} + + } + placeholder={ + + {props.placeholder} + + } > - - + + ); }); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts b/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts index ff017ce91..74601fa2e 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts @@ -4,36 +4,33 @@ * 回合运行中用户再次发送时,消息进入 FIFO 队列而不是被丢弃;当前回合结束后按入队顺序 * 依次发出。队列项能在输入盒上方单独取消。这里只放与 React 无关的纯逻辑,便于单测。 */ -import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments'; -import type { DirectCodexUserContentPart } from './generated'; -import type { ChatReference } from './resourceReferences'; +import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import type { DirectCodexUserItem } from './generated'; +import { directCodexContentToPromptText } from './resourceReferences'; /** 队列上限:满了以后拒绝入队并给出可读提示,而不是静默丢消息。 */ export const MAX_QUEUED_CHAT_TURNS = 5; export type QueuedChatTurn = { id: string; - prompt: string; - attachments: DirectCodexTurnAttachment[]; - references: ChatReference[]; - content?: DirectCodexUserContentPart[]; + clientTurnId: string; + userItem: DirectCodexUserItem; createdAt: number; }; export function createQueuedChatTurn(input: { id: string; - prompt: string; - attachments?: readonly DirectCodexTurnAttachment[]; - references?: readonly ChatReference[]; - content?: readonly DirectCodexUserContentPart[]; + clientTurnId: string; + userItem: DirectCodexUserItem; createdAt: number; }): QueuedChatTurn { return { id: input.id, - prompt: input.prompt, - attachments: [...(input.attachments ?? [])], - references: [...(input.references ?? [])], - content: [...(input.content ?? [])], + clientTurnId: input.clientTurnId, + userItem: { + ...input.userItem, + content: [...input.userItem.content], + }, createdAt: input.createdAt, }; } @@ -77,17 +74,23 @@ export function chatQueueFullNotice(): string { return `队列已满(最多 ${MAX_QUEUED_CHAT_TURNS} 条),请等当前回合结束后再发送`; } -/** 队列 chip 上显示的文字:单行、有长度上限。 */ -export function queuedChatTurnLabel(turn: QueuedChatTurn): string { - const text = turn.prompt.trim().replace(/\s+/gu, ' '); - if (text) { - return text.length > 24 ? `${text.slice(0, 24)}…` : text; - } - if (turn.attachments.length > 0) { - return `附件 · ${turn.attachments[0]?.name ?? '未命名'}`; - } - if (turn.references.length > 0) { - return '素材引用'; - } - return '未命名消息'; +/** + * 队列 chip 上显示的文字:与真实消息同一个派生(`directCodexContentToPromptText`), + * 再压成单行并限长。 + * + * `assets` 与聊天消息渲染同源(`manifest.assets`)且必填:`@` 引用按显示名展开,chip + * 与消息正文逐字一致,不会露出 `@asset:…` 这种内部 id。 + */ +export function queuedChatTurnLabel( + turn: QueuedChatTurn, + assets: readonly GameCreationAppAssetManifestEntry[], +): string { + const text = directCodexContentToPromptText(turn.userItem.content, assets) + .trim() + .replace(/\s+/gu, ' '); + return text + ? text.length > 24 + ? `${text.slice(0, 24)}…` + : text + : '未命名消息'; } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts b/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts index 68372d749..cc509e140 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts @@ -1,8 +1,5 @@ import { resolveTauriInvoke } from '../../app/tauri'; -import { - type ChatComposerDraft, - chatReferenceListKey, -} from './resourceReferences'; +import type { DirectCodexUserContentPart } from './generated'; /** * 「不再提醒」偏好存本机 localStorage,不进 manifest、不进后端。 @@ -61,8 +58,10 @@ export function writeChatPromptPolishReminderDisabled(disabled: boolean) { } /** 草稿指纹:用于判断「本轮草稿」是否已经被润色或确认过。 */ -export function chatPromptDraftKey(draft: ChatComposerDraft) { - return `${draft.text}\u0000${chatReferenceListKey(draft.references)}`; +export function chatPromptDraftKey( + content: readonly DirectCodexUserContentPart[], +) { + return JSON.stringify(content); } /** @@ -73,25 +72,27 @@ export function chatPromptDraftKey(draft: ChatComposerDraft) { * 4. 草稿不是以 `/` 开头的命令 —— 命令走直通路径,不参与提醒。 */ export function shouldRemindChatPromptPolish({ - draft, + content, + prompt, acknowledgedDraftKey, reminderDisabled, }: { - draft: ChatComposerDraft; + content: readonly DirectCodexUserContentPart[]; + prompt: string; acknowledgedDraftKey: string | null; reminderDisabled: boolean; }) { if (reminderDisabled) { return false; } - const text = draft.text.trim(); + const text = prompt.trim(); if (text.length < CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH) { return false; } if (text.startsWith('/')) { return false; } - return chatPromptDraftKey(draft) !== acknowledgedDraftKey; + return chatPromptDraftKey(content) !== acknowledgedDraftKey; } /** diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserAttachmentReferencePart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserAttachmentReferencePart.ts new file mode 100644 index 000000000..7394ae07d --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserAttachmentReferencePart.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DirectCodexUserAttachmentReferencePart = { + name: string; + mediaType: string; + size: number; + localPath: string; + status: string; +}; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts index 10c4e0f5a..e6551d847 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts @@ -1,10 +1,14 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectCodexUserAttachmentReferencePart } from './DirectCodexUserAttachmentReferencePart'; import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; export type DirectCodexUserContentPart = | { type: 'input_text'; text: string } | { type: 'agc_resource_reference'; resourceId: string } + | { type: 'agc_skill_reference'; name: string } | ({ type: 'agc_runtime_region_reference'; - } & DirectCodexUserRuntimeRegionPart); + } & DirectCodexUserRuntimeRegionPart) + | ({ + type: 'agc_attachment_reference'; + } & DirectCodexUserAttachmentReferencePart); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts index 4c9ea8ca3..aa1ee88dd 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts @@ -1,5 +1,9 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; -export type DirectCodexUserItem = DirectCodexUserMessageItem; +/** + * DirectProject 本轮 user input 的唯一结构化入口。 + */ +export type DirectCodexUserItem = { + type: 'message'; +} & DirectCodexUserMessageItem; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts new file mode 100644 index 000000000..97e0b0dae --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectCodexUserItem } from './DirectCodexUserItem'; + +export type DirectCodexUserMessageEnvelope = { item: DirectCodexUserItem }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts index f7447dc5d..a1101c8cb 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts @@ -1,11 +1,9 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; import type { DirectCodexUserRole } from './DirectCodexUserRole'; export type DirectCodexUserMessageItem = { - type: 'message'; role: DirectCodexUserRole; - content: DirectCodexUserContentPart[]; + content: Array; id: string; }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts index e1dc59c59..0c2540122 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts @@ -1,3 +1,3 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type DirectCodexUserRole = 'user'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts index 296c0d19a..b134fb7bc 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts @@ -1,13 +1,13 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type DirectCodexUserRuntimeRegionPart = { label: string; - runId?: string; - versionId?: string; - elementTag?: string; - elementRole?: string; - text?: string; - width?: number; - height?: number; - resourceIds: string[]; + runId: string | null; + versionId: string | null; + elementTag: string | null; + elementRole: string | null; + text: string | null; + width: number | null; + height: number | null; + resourceIds: Array; }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts index aeeebc8f8..5bd9b7cb7 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts @@ -1,8 +1,10 @@ export type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; +export type { DirectCodexUserAttachmentReferencePart } from './DirectCodexUserAttachmentReferencePart'; export type { DirectCodexUserItem } from './DirectCodexUserItem'; export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; export type { DirectCodexUserRole } from './DirectCodexUserRole'; export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; +export type { DirectCodexUserMessageEnvelope } from './DirectCodexUserMessageEnvelope'; export type { DirectThreadConsumeResult } from './DirectThreadConsumeResult'; export type { DirectThreadDeltaKind } from './DirectThreadDeltaKind'; export type { DirectThreadEvent } from './DirectThreadEvent'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index c08fba49a..1192a5c73 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -13,7 +13,6 @@ import { import type { DirectCodexUserContentPart, DirectCodexUserItem, - DirectCodexUserMessageItem, } from './generated'; export type ResourceReferenceSource = @@ -47,13 +46,22 @@ export type RuntimeRegionReference = { source: 'runtime-picker'; }; -export type ChatReference = ResourceReference | RuntimeRegionReference; +export type SkillReference = { + type: 'skill'; + name: string; + description?: string; +}; + +export type ChatReference = + | ResourceReference + | RuntimeRegionReference + | SkillReference; export type ChatComposerDraft = { text: string; references: ChatReference[]; - /** Lexical 顺序对应的 canonical user content;仅由编辑器读回时提供。 */ - content?: DirectCodexUserContentPart[]; + /** Lexical 顺序对应的 canonical user content,是草稿的唯一结构化来源。 */ + content: DirectCodexUserContentPart[]; }; export const RESOURCE_REFERENCE_INSERT_EVENT = 'agc-resource-reference-insert'; @@ -94,22 +102,63 @@ export const EMPTY_CHAT_COMPOSER_DRAFT: ChatComposerDraft = { content: [], }; +/** canonical content → 可读文本;资源引用按当前 manifest 展开为显示名。 */ +export function directCodexContentToPromptText( + content: readonly DirectCodexUserContentPart[], + assets: readonly GameCreationAppAssetManifestEntry[], +) { + const labels = new Map( + assets.map((asset) => [asset.id, resourceDisplayName(asset)]), + ); + return content + .map((part) => { + if (part.type === 'input_text') return part.text; + if (part.type === 'agc_resource_reference') { + return `@${labels.get(part.resourceId) ?? part.resourceId}`; + } + if (part.type === 'agc_skill_reference') { + return `$${part.name}`; + } + if (part.type === 'agc_runtime_region_reference') { + return `@${part.label}`; + } + if (part.type === 'agc_attachment_reference') { + return `@${part.name}`; + } + return ''; + }) + .join('') + .trim(); +} + +/** 只在整条 content 上判定有效性;单个纯空白文本 part 合法。 */ +export function hasMeaningfulDirectCodexContent( + content: readonly DirectCodexUserContentPart[], +) { + return content.some( + (part) => part.type !== 'input_text' || part.text.trim().length > 0, + ); +} + export function chatReferenceToContentPart( reference: ChatReference, ): DirectCodexUserContentPart { if (reference.type === 'resource') { return { type: 'agc_resource_reference', resourceId: reference.resourceId }; } + if (reference.type === 'skill') { + return { type: 'agc_skill_reference', name: reference.name }; + } return { type: 'agc_runtime_region_reference', label: reference.label, - runId: reference.runId, - versionId: reference.versionId, - elementTag: reference.elementTag, - elementRole: reference.elementRole, - text: reference.text, - width: reference.width, - height: reference.height, + runId: reference.runId ?? null, + versionId: reference.versionId ?? null, + elementTag: reference.elementTag ?? null, + elementRole: reference.elementRole ?? null, + text: reference.text ?? null, + width: reference.width ?? null, + height: reference.height ?? null, resourceIds: reference.resourceIds, }; } @@ -118,24 +167,22 @@ export function chatComposerDraftToDirectCodexUserItem( draft: ChatComposerDraft, id: string, ): DirectCodexUserItem { - const content = draft.content?.length - ? draft.content - : draft.references.length > 0 - ? [ - ...(draft.text - ? [{ type: 'input_text' as const, text: draft.text }] - : []), - ...draft.references.map(chatReferenceToContentPart), - ] - : draft.text - ? [{ type: 'input_text' as const, text: draft.text }] - : []; return { type: 'message', role: 'user', - content, + content: draft.content, id, - } satisfies DirectCodexUserMessageItem; + } satisfies DirectCodexUserItem; +} + +export function directCodexUserItemFromContent( + content: readonly DirectCodexUserContentPart[], + id: string, +): DirectCodexUserItem { + return chatComposerDraftToDirectCodexUserItem( + { text: '', references: [], content: [...content] }, + id, + ); } export function resourceDisplayName(asset: GameCreationAppAssetManifestEntry) { @@ -351,6 +398,9 @@ function chatReferenceKey(reference: ChatReference) { if (reference.type === 'resource') { return `resource:${reference.resourceId}:${reference.source}`; } + if (reference.type === 'skill') { + return `skill:${reference.name}`; + } return `runtime-region:${runtimeRegionReferenceDiscriminators(reference)}`; } @@ -360,11 +410,15 @@ function chatReferenceKey(reference: ChatReference) { */ export function chatReferenceListKey(references: ChatReference[]) { return references - .map((reference) => - reference.type === 'resource' - ? `resource:${reference.resourceId}:${reference.source}:${reference.label}` - : `runtime-region:${runtimeRegionReferenceDiscriminators(reference)}`, - ) + .map((reference) => { + if (reference.type === 'resource') { + return `resource:${reference.resourceId}:${reference.source}:${reference.label}`; + } + if (reference.type === 'skill') { + return `skill:${reference.name}`; + } + return `runtime-region:${runtimeRegionReferenceDiscriminators(reference)}`; + }) .join('\u0001'); } diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingChange.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingChange.ts new file mode 100644 index 000000000..3bb2c5c4d --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingChange.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { NodeComponent } from "./NodeComponent"; +import type { NodeId } from "./NodeId"; +import type { StageStatus } from "./StageStatus"; + +export type BindingChange = { node_id: NodeId, component: NodeComponent, component_status: StageStatus, }; diff --git a/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingDTO.ts b/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingDTO.ts new file mode 100644 index 000000000..beb340894 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/ui-editor/types/BindingDTO.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BindingChange } from "./BindingChange"; + +export type BindingDTO = { changes: Array, }; diff --git a/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/index.tsx b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/index.tsx index 1d627d7dc..76933a178 100644 --- a/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/index.tsx +++ b/apps/ai-game-creator-shell/src/view/home/components/RichInputArea/index.tsx @@ -1,9 +1,5 @@ -import { LexicalComposer } from '@lexical/react/LexicalComposer'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { ContentEditable } from '@lexical/react/LexicalContentEditable'; -import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; -import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; -import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; import { readImage, readText } from '@tauri-apps/plugin-clipboard-manager'; import { $createParagraphNode, @@ -13,13 +9,13 @@ import { COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_HIGH, createCommand, - KEY_ENTER_COMMAND, type LexicalCommand, PASTE_COMMAND, } from 'lexical'; import { Upload } from 'lucide-react'; import React, { useEffect, useRef } from 'react'; +import RichTextInput from '../../../../components/RichTextInput'; import type { Draft, HomeAttachmentDraft } from '../../useHomeDraftStore'; import { $createAttachmentNode, AttachmentNode } from './attachmentNode'; @@ -101,29 +97,9 @@ function selectEditableEndWhenNeeded() { root.selectEnd(); } -function EditorPlugins({ - onChange, - onEnter, -}: Pick) { +function EditorPlugins() { const [editor] = useLexicalComposerContext(); - useEffect( - () => - editor.registerCommand( - KEY_ENTER_COMMAND, - (event) => { - if (!event || event.shiftKey || event.isComposing) { - return false; - } - event.preventDefault(); - onEnter(); - return true; - }, - COMMAND_PRIORITY_HIGH, - ), - [editor, onEnter], - ); - useEffect( () => editor.registerCommand( @@ -188,13 +164,7 @@ function EditorPlugins({ [editor], ); - return ( - { - onChange(editorState); - }} - /> - ); + return null; } export function UploadButton() { @@ -229,34 +199,27 @@ export function UploadButton() { export default function RichInputArea(props: RichInputAreaProps) { return ( - { - throw error; - }, - }} - > -
- - } - placeholder={ - - {props.placeholder} - - } - ErrorBoundary={LexicalErrorBoundary} + - {props.children} -
- -
+ } + placeholder={ + + {props.placeholder} + + } + > + + {props.children} + ); } diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 184007760..d81dd7083 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -107,10 +107,13 @@ import { type LocalGamePreviewInspectSelection, resolveEmbeddedPreviewUrl, } from '../../features/project-workspace/LocalGamePreviewFrame'; -import { ResourceReferenceInput } from '../../features/project-workspace/ResourceReferenceInput'; +import { + ResourceReferenceInput, + type ResourceReferenceInputHandle, +} from '../../features/project-workspace/ResourceReferenceInput'; import { type ChatComposerDraft, - type ChatReference, + directCodexContentToPromptText, dispatchResourceReferenceInsert, isResourceReferenceOverlayTarget, resolveActiveIterationVersion, @@ -1694,6 +1697,9 @@ export default function ProjectDevelopmentView({ useState(null); const [quickEditPanel, setQuickEditPanel] = useState(null); + const quickEditPromptInputRef = useRef( + null, + ); /** * 快速编辑提示词里的 `@` 资源引用。 * @@ -1702,9 +1708,6 @@ export default function ProjectDevelopmentView({ * 的提示词与聊天 `@` 逐字同源,不存在第二种引用格式。 * 引用列表按面板生命周期重开:每次打开面板清空一份,面板关闭后不再被读取。 */ - const [quickEditReferences, setQuickEditReferences] = useState< - ChatReference[] - >([]); /** * 「生成动画」的源与浮层。 * @@ -6683,7 +6686,6 @@ export default function ProjectDevelopmentView({ setQuickEditSourceLayer(layer); const panelDraft = createResourceQuickEditPanelDraft(layer); setQuickEditPanel(panelDraft); - setQuickEditReferences([]); resourceQuickEditRequestRef.current = { ...createResourceEditRequestIdentity(panelDraft.prompt), sourceLayerId: layer.id, @@ -6700,33 +6702,51 @@ export default function ProjectDevelopmentView({ * * 与共享 composer 的 `resetFailedDialogStatus` 同口径:提示词变了就不再是上一次 * 失败的那份请求,状态回到 idle、错误清空——同时提交侧会按新提示词重铸请求身份。 + * 比较用的草稿文本必须和输入区自己读草稿的口径一致(都带 `manifest.assets`): + * 少传素材清单会把 chip 读成 `@内部 id`,与真正写进面板的 `@显示名` 永远不等, + * 于是每次改写都再 `replaceText` 一次。 */ - const applyResourceQuickEditPrompt = useCallback((text: string) => { - setQuickEditPanel((current) => - current - ? { - ...current, - prompt: text, - status: current.status === 'failed' ? 'idle' : current.status, - errorMessage: - current.status === 'failed' ? undefined : current.errorMessage, - } - : current, - ); - }, []); + const applyResourceQuickEditPrompt = useCallback( + (text: string) => { + const currentDraft = quickEditPromptInputRef.current?.getDraft(); + if ( + currentDraft && + directCodexContentToPromptText( + currentDraft.content, + manifest.assets, + ) !== text + ) { + quickEditPromptInputRef.current?.replaceText(text); + } + setQuickEditPanel((current) => + current + ? { + ...current, + prompt: text, + status: current.status === 'failed' ? 'idle' : current.status, + errorMessage: + current.status === 'failed' ? undefined : current.errorMessage, + } + : current, + ); + }, + [manifest.assets], + ); /** * 快速编辑提示词输入区的整体草稿(文本 + `@` 引用)。 * * 文本仍走 `applyResourceQuickEditPrompt`(失败态重置口径不变),引用单独收下来供 - * 输入区回填 chip。提交时的出站 payload 就是这份文本:与聊天 `@` 同源、同字面量。 + * 输入区回填 chip。提交时的出站 payload 就是这份文本:与聊天 `@` 同源、同字面量, + * 所以 `@` 引用必须带上 `manifest.assets` 按显示名展开。 */ const applyResourceQuickEditDraft = useCallback( (draft: ChatComposerDraft) => { - setQuickEditReferences(draft.references); - applyResourceQuickEditPrompt(draft.text); + applyResourceQuickEditPrompt( + directCodexContentToPromptText(draft.content, manifest.assets), + ); }, - [applyResourceQuickEditPrompt], + [applyResourceQuickEditPrompt, manifest.assets], ); /** @@ -8365,9 +8385,15 @@ export default function ProjectDevelopmentView({ // 由它拼装,因此出站 payload 与聊天 `@` 一致。
{ - const first = createQueuedChatTurn({ - id: 'turn-1', - prompt: '第一条', + it('队列 chip 的 @ 引用按 manifest 显示名展开,不露出内部 resourceId', () => { + const turn = createQueuedChatTurn({ + id: 'turn-ref', + clientTurnId: 'client-ref', + userItem: directCodexUserItemFromContent( + [ + { type: 'input_text', text: '用这张图改一下' }, + { type: 'agc_resource_reference', resourceId: 'asset:hero' }, + ], + 'client-ref:user', + ), createdAt: 1, }); - const second = createQueuedChatTurn({ - id: 'turn-2', - prompt: '第二条', - createdAt: 2, - }); - const third = createQueuedChatTurn({ - id: 'turn-3', - prompt: '第三条', - createdAt: 3, - }); + const manifestAssets = [ + { + id: 'asset:hero', + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/hero.png', + source: { kind: 'uploaded' as const }, + }, + ]; + + // chip 文案与聊天输入区同口径:用户看到的是 `@显示名`,不是 `@内部 id`。 + expect(queuedChatTurnLabel(turn, manifestAssets)).toBe( + '用这张图改一下@hero', + ); + }); + + it('keeps queued chat turns in FIFO order and drops only the cancelled one', () => { + const first = queuedTurn('turn-1', 'client-1', '第一条', 1); + const second = queuedTurn('turn-2', 'client-2', '第二条', 2); + const third = queuedTurn('turn-3', 'client-3', '第三条', 3); let queue = enqueueChatTurn([], first); queue = enqueueChatTurn(queue, second); @@ -212,15 +249,20 @@ export function registerChatComposerControlTests() { // FIFO:先入先出,不丢、不乱序。 const firstOut = dequeueChatTurn(queue); - expect(firstOut.next?.prompt).toBe('第一条'); - expect(firstOut.rest.map((turn) => turn.prompt)).toEqual([ + expect(firstOut.next?.clientTurnId).toBe('client-1'); + expect(firstOut.next && queuedChatTurnLabel(firstOut.next, [])).toBe( + '第一条', + ); + expect(firstOut.rest.map((turn) => queuedChatTurnLabel(turn, []))).toEqual([ '第二条', '第三条', ]); // 单条取消只移除那一条,顺序不变。 expect( - removeQueuedChatTurn(firstOut.rest, 'turn-2').map((turn) => turn.prompt), + removeQueuedChatTurn(firstOut.rest, 'turn-2').map((turn) => + queuedChatTurnLabel(turn, []), + ), ).toEqual(['第三条']); expect(removeQueuedChatTurn(firstOut.rest, 'turn-missing')).toHaveLength(2); @@ -233,11 +275,7 @@ export function registerChatComposerControlTests() { for (let index = 0; index < 5; index += 1) { queue = enqueueChatTurn( queue, - createQueuedChatTurn({ - id: `turn-${index}`, - prompt: `第 ${index} 条`, - createdAt: index, - }), + queuedTurn(`turn-${index}`, `client-${index}`, `第 ${index} 条`, index), ); } expect(isChatTurnQueueFull(queue)).toBe(true); diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index b327549b4..7dfecf635 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1752,17 +1752,19 @@ export function registerHomeProjectCreationTests() { id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), type: 'message', role: 'user', - content: [{ type: 'input_text', text: '按这个角色做游戏' }], + // 首页附件随首轮一起进 canonical content:它就是这一轮输入的一部分。 + content: [ + { type: 'input_text', text: '按这个角色做游戏' }, + { + type: 'agc_attachment_reference', + name: '角色参考.png', + mediaType: 'image/png', + size: attachment.size, + localPath: 'assets/uploads/reference.png', + status: 'imported', + }, + ], }, - attachments: [ - { - name: '角色参考.png', - mediaType: 'image/png', - size: attachment.size, - localPath: 'assets/uploads/reference.png', - status: 'imported', - }, - ], }); expect(invoke).not.toHaveBeenCalledWith( 'chat_with_game_creator_home_direct_codex', @@ -1797,7 +1799,11 @@ export function registerHomeProjectCreationTests() { (args as Record | undefined)?.prompt === '再补一句玩法', )?.[1] as Record | undefined; - expect(followUpPayload).not.toHaveProperty('attachments'); + // 后续这一轮没有附件:canonical content 里只有文本 part。 + expect( + (followUpPayload?.userItem as { content?: unknown[] } | undefined) + ?.content, + ).toEqual([{ type: 'input_text', text: '再补一句玩法' }]); }); it('starts an automatic game project from the approved GDD', async () => { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index eac40a8c4..1b85e31a0 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -2,6 +2,7 @@ import { directCodexPolicyRetryInput, isDirectCodexTurnAlreadyRunningError, } from '../../src/App'; +import { directCodexUserItemFromContent } from '../../src/features/project-workspace/resourceReferences'; import { act, agentRuntimeUserInputRequest, @@ -49,27 +50,30 @@ export function registerProjectConversationTests() { it('carries the whole direct turn input, including @ references, into the policy-confirmation retry', () => { // 确认 `conversation.write` 之后重跑的是同一轮输入:漏掉任何一项都会让用户 - // 在确认之后拿到另一轮内容。历史缺陷正是漏了 `references`(@ 引用被静默丢掉), - // 所以这里把「首轮入参整体带过去」钉成硬约束。 + // 在确认之后拿到另一轮内容。历史缺陷正是漏了 canonical user item 里的 @ 引用 + // (引用被静默丢掉),所以这里把「首轮入参整体带过去」钉成硬约束。 const firstTurn = { prompt: '用这张图改一下', clientTurnId: 'direct-turn-1', creationType: 'game' as const, - attachments: [ - { name: '角色草图.png', mediaType: 'image/png', size: 128 }, - ], - references: [ - { - type: 'resource' as const, - resourceId: 'reference-hero', - kind: 'image', - mediaType: 'image/png', - label: 'hero.png', - category: 'scene' as const, - tags: ['主舞台'], - source: 'resource-card' as const, - }, - ], + userItem: directCodexUserItemFromContent( + [ + { type: 'input_text' as const, text: '用这张图改一下' }, + { + type: 'agc_resource_reference' as const, + resourceId: 'reference-hero', + }, + { + type: 'agc_attachment_reference' as const, + name: '角色草图.png', + mediaType: 'image/png', + size: 128, + localPath: '', + status: 'imported' as const, + }, + ], + 'direct-turn-1:user', + ), }; expect(directCodexPolicyRetryInput(firstTurn)).toEqual({ @@ -77,9 +81,10 @@ export function registerProjectConversationTests() { directPolicyChecked: true, }); - // 引用是这一次输入的判别项,单独再断言一遍,避免上面整体相等被未来字段扩展掩盖。 - expect(directCodexPolicyRetryInput(firstTurn).references).toEqual( - firstTurn.references, + // canonical content 是这一次输入的判别项,单独再断言一遍,避免上面整体相等 + // 被未来字段扩展掩盖。 + expect(directCodexPolicyRetryInput(firstTurn).userItem.content).toEqual( + firstTurn.userItem.content, ); }); diff --git a/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx b/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx index 696fccadb..4439cd42f 100644 --- a/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx +++ b/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +// @vitest-environment-options {"url":"http://localhost"} import { cleanup, fireEvent, @@ -8,7 +9,7 @@ import { within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; +import { useRef } from 'react'; import { afterEach, describe, expect, test, vi } from 'vitest'; import { @@ -20,13 +21,28 @@ import { shouldRemindChatPromptPolish, writeChatPromptPolishReminderDisabled, } from '../src/features/project-workspace/chatPromptPolish'; +import type { DirectCodexUserContentPart } from '../src/features/project-workspace/generated'; +import type { ResourceReferenceInputHandle } from '../src/features/project-workspace/ResourceReferenceInput'; import { ResourceReferenceInput } from '../src/features/project-workspace/ResourceReferenceInput'; import { type ChatComposerDraft, type ChatReference, + chatReferenceToContentPart, + hasMeaningfulDirectCodexContent, resourceReferenceFromAsset, } from '../src/features/project-workspace/resourceReferences'; +const storage = new Map(); +Object.defineProperty(window, 'localStorage', { + configurable: true, + value: { + clear: () => storage.clear(), + getItem: (key: string) => storage.get(key) ?? null, + removeItem: (key: string) => storage.delete(key), + setItem: (key: string, value: string) => storage.set(key, String(value)), + }, +}); + type TauriInvoke = ( command: string, args?: Record, @@ -69,6 +85,11 @@ async function composerText() { return screen.getByLabelText('创作想法').textContent ?? ''; } +/** 纯文本草稿的 canonical content。 */ +function textContent(text: string): DirectCodexUserContentPart[] { + return text ? [{ type: 'input_text', text }] : []; +} + function ControlledChatComposer({ initialText, initialReferences = [], @@ -78,21 +99,30 @@ function ControlledChatComposer({ initialReferences?: ChatReference[]; onSubmitDraft: (draft: ChatComposerDraft) => void; }) { - const [draft, setDraft] = useState({ - text: initialText, - references: initialReferences, - }); + const composerRef = useRef(null); + const initialContent: DirectCodexUserContentPart[] = [ + ...textContent(initialText), + ...initialReferences.map(chatReferenceToContentPart), + ]; return (
{ event.preventDefault(); - onSubmitDraft(draft); + const draft = composerRef.current?.getDraft(); + const submittedContent = hasMeaningfulDirectCodexContent( + draft?.content ?? [], + ) + ? draft!.content + : initialContent; + onSubmitDraft({ + content: submittedContent, + }); }} > {}} assets={[]} projectPath="C:/project" ariaLabel="创作想法" @@ -139,41 +169,45 @@ afterEach(() => { describe('发送前提醒判据', () => { test('only reminds for long plain prompts that were not acknowledged this round', () => { - const draft: ChatComposerDraft = { - text: '字'.repeat(CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH), - references: [], - }; + const longPrompt = '字'.repeat(CHAT_PROMPT_POLISH_REMINDER_MIN_TEXT_LENGTH); + const content = textContent(longPrompt); expect( shouldRemindChatPromptPolish({ - draft, + content, + prompt: longPrompt, acknowledgedDraftKey: null, reminderDisabled: false, }), ).toBe(true); expect( shouldRemindChatPromptPolish({ - draft: { text: '短需求', references: [] }, + content: textContent('短需求'), + prompt: '短需求', acknowledgedDraftKey: null, reminderDisabled: false, }), ).toBe(false); expect( shouldRemindChatPromptPolish({ - draft, + content, + prompt: longPrompt, acknowledgedDraftKey: null, reminderDisabled: true, }), ).toBe(false); expect( shouldRemindChatPromptPolish({ - draft, - acknowledgedDraftKey: chatPromptDraftKey(draft), + content, + prompt: longPrompt, + acknowledgedDraftKey: chatPromptDraftKey(content), reminderDisabled: false, }), ).toBe(false); + const command = `/${'长'.repeat(60)}`; expect( shouldRemindChatPromptPolish({ - draft: { text: `/${'长'.repeat(60)}`, references: [] }, + content: textContent(command), + prompt: command, acknowledgedDraftKey: null, reminderDisabled: false, }), @@ -191,8 +225,11 @@ describe('发送前提醒判据', () => { }, 'asset-picker', ); - expect(chatPromptDraftKey({ text: '需求', references: [] })).not.toBe( - chatPromptDraftKey({ text: '需求', references: [reference] }), + expect(chatPromptDraftKey(textContent('需求'))).not.toBe( + chatPromptDraftKey([ + ...textContent('需求'), + chatReferenceToContentPart(reference), + ]), ); }); @@ -303,8 +340,7 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { ); await waitFor(() => { expect(onSubmitDraft).toHaveBeenCalledWith({ - text: LONG_PROMPT, - references: [], + content: textContent(LONG_PROMPT), }); }); expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); @@ -335,8 +371,7 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { ); await waitFor(() => { expect(onSubmitDraft).toHaveBeenCalledWith({ - text: '润色后的长需求', - references: [], + content: textContent('润色后的长需求'), }); }); expect(await composerText()).toBe('润色后的长需求'); @@ -364,8 +399,7 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { ); await waitFor(() => { expect(onSubmitDraft).toHaveBeenCalledWith({ - text: LONG_PROMPT, - references: [], + content: textContent(LONG_PROMPT), }); }); }); @@ -402,8 +436,7 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { resolvePolish('润色后的长需求'); await waitFor(() => { expect(onSubmitDraft).toHaveBeenCalledWith({ - text: '润色后的长需求', - references: [], + content: textContent('润色后的长需求'), }); }); }); @@ -425,8 +458,7 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { fireEvent.click(sendButton()); await waitFor(() => { expect(onSubmitDraft).toHaveBeenCalledWith({ - text: LONG_PROMPT, - references: [], + content: textContent(LONG_PROMPT), }); }); expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); @@ -442,8 +474,7 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { fireEvent.click(sendButton()); expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); expect(onSubmitDraft).toHaveBeenCalledWith({ - text: LONG_PROMPT, - references: [], + content: textContent(LONG_PROMPT), }); }); @@ -452,8 +483,7 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { fireEvent.click(sendButton()); expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); expect(shortSubmit).toHaveBeenCalledWith({ - text: '做个跳跃游戏', - references: [], + content: textContent('做个跳跃游戏'), }); cleanup(); @@ -463,8 +493,7 @@ describe('聊天输入区 AI 润色与发送前提醒', () => { fireEvent.click(sendButton()); expect(screen.queryByRole('dialog', { name: '发送前提醒' })).toBeNull(); expect(commandSubmit).toHaveBeenCalledWith({ - text: `/${'命令'.repeat(40)}`, - references: [], + content: textContent(`/${'命令'.repeat(40)}`), }); }); }); diff --git a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx index 5bd371894..602127513 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx @@ -8,8 +8,8 @@ import { within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { $getRoot } from 'lexical'; -import { StrictMode, useState } from 'react'; +import { $getRoot, $getSelection, $isRangeSelection } from 'lexical'; +import { createRef, StrictMode, useState } from 'react'; import { afterEach, describe, expect, test, vi } from 'vitest'; import type { @@ -20,13 +20,18 @@ import { LOCAL_GAME_PREVIEW_INSPECT_MESSAGE, parseLocalGamePreviewInspectMessage, } from '../src/features/project-workspace/LocalGamePreviewFrame'; -import { ResourceReferenceInput } from '../src/features/project-workspace/ResourceReferenceInput'; +import { + ResourceReferenceInput, + type ResourceReferenceInputHandle, +} from '../src/features/project-workspace/ResourceReferenceInput'; import { type ChatComposerDraft, type ChatReference, chatReferenceListKey, + chatReferenceToContentPart, currentIterationVersionAssets, dedupeChatReferences, + directCodexContentToPromptText, dispatchResourceReferenceInsert, resolveActiveIterationVersion, RESOURCE_REFERENCE_FILTERS, @@ -80,16 +85,30 @@ async function settleComposer() { }); } +/** 草稿展示文本:canonical content 是唯一真相;这里刻意不传 manifest,引用展开成 `@id`。 */ +function draftText(draft: ChatComposerDraft | undefined) { + return directCodexContentToPromptText(draft?.content ?? [], []); +} + +/** 草稿里的资源引用 id,按 content 顺序。 */ +function draftResourceIds(draft: ChatComposerDraft | undefined) { + return (draft?.content ?? []).flatMap((part) => + part.type === 'agc_resource_reference' ? [part.resourceId] : [], + ); +} + /** * 编辑器文本模型里的字符数。断言「一个引用 = 一个字符」用它而不是 DOM 文本: * DOM 里 chip 仍要显示 `@显示名` 给用户看,两者本来就不该相等。 */ function composerEditor(): { getEditorState: () => { read: (fn: () => T) => T }; + update: (fn: () => void) => void; } { const element = screen.getByLabelText('聊天') as HTMLElement & { __lexicalEditor?: { getEditorState: () => { read: (fn: () => T) => T }; + update: (fn: () => void) => void; }; }; const editor = element.__lexicalEditor; @@ -97,6 +116,18 @@ function composerEditor(): { return editor; } +/** + * jsdom 里键盘输入不会进入 Lexical,所以直接走编辑器 API 写文本; + * 它触发的是和真实输入同一条更新链路,typeahead 监听器同样会被唤醒。 + */ +function insertComposerText(text: string) { + composerEditor().update(() => { + $getRoot().selectEnd(); + const selection = $getSelection(); + if ($isRangeSelection(selection)) selection.insertText(text); + }); +} + function editorTextSize() { return composerEditor() .getEditorState() @@ -152,6 +183,39 @@ async function insertAssetThroughPicker(ariaLabel: string, optionName: RegExp) { afterEach(cleanup); describe('ResourceReferenceInput', () => { + test('打开 Skill 候选时才向 Tauri command 查询内置 catalog', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'list_agc_skill_catalog') { + return [{ name: 'agc-test-skill', description: '测试 Skill' }]; + } + if (command === 'list_client_extensions') return []; + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke: invoke as never } }; + + try { + render( + , + ); + + // 挂载即查询会让「工作区路径非法时不产生任何后端访问」的边界失效。 + expect(invoke).not.toHaveBeenCalled(); + + insertComposerText('$'); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('list_agc_skill_catalog'); + }); + } finally { + delete window.__TAURI__; + } + }); + test('运行画面引用的判别指纹带上了绑定素材、版本、元素角色与尺寸', () => { const base: RuntimeRegionReference = { type: 'runtime-region', @@ -193,27 +257,22 @@ describe('ResourceReferenceInput', () => { const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); const reference = resourceReferenceFromAsset(assets[0]!, 'asset-picker'); function Controlled() { - const [draft, setDraft] = useState({ - text: '原始需求', - references: [reference], - }); + const composerRef = createRef(); return ( <> { - onChange(next); - setDraft(next); - }} + ref={composerRef} + initialContent={[ + { type: 'input_text', text: '原始需求' }, + chatReferenceToContentPart(reference), + ]} + onChange={onChange} assets={assets} projectPath="C:/project" ariaLabel="聊天" @@ -228,10 +287,9 @@ describe('ResourceReferenceInput', () => { await settleComposer(); await settleComposer(); - // 重建必须收敛,而且不许把「重建后的编辑器内容」当成一次用户编辑回抛给宿主: - // 旧实现用 props 覆写 lastEmittedDraftRef,读回来的文本里多出的 `@显示名` - // 会触发下一轮重建,文本一轮轮变长(渲染循环)。 - expect(onChange).not.toHaveBeenCalled(); + // 这次替换是对唯一 EditorState 的明确编辑动作,只产生一次派生快照, + // 不会因为 props 回写而重复触发。 + expect(onChange).toHaveBeenCalledTimes(1); const editorText = document.querySelector('.resource-reference-input-editor')?.textContent ?? ''; @@ -240,13 +298,57 @@ describe('ResourceReferenceInput', () => { expect(screen.getByRole('button', { name: '模拟润色' })).not.toBeNull(); }); + test('引用后面的段落分隔原样落进 content:读回的 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 里(投影不做空白过滤, + // 也不与相邻 part 合并)。丢掉它读回的 prompt 会粘成 `@hero把这一版改成夜景`, + // 并原样进 agent、队列文案与润色判据。 + expect(draft?.content).toEqual([ + chatReferenceToContentPart(reference), + { type: 'input_text', text: '\n' }, + { type: 'input_text', text: '把这一版改成夜景' }, + ]); + expect(draftText(draft)).toBe('@hero\n把这一版改成夜景'); + }); + test('引用浮层打开时 Enter 不提交表单,关掉后恢复提交', async () => { const onSubmit = vi.fn(); const user = userEvent.setup(); function Controlled() { const [draft, setDraft] = useState({ - text: '要一个', - references: [], + content: [{ type: 'input_text', text: '要一个' }], }); return ( { }} > { const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); render( { expect(onChange).toHaveBeenCalled(); }); const draft = onChange.mock.calls.at(-1)?.[0]; - expect(draft?.text).toBe('@hero @enemy'); - expect(draft?.references.map((reference) => reference.resourceId)).toEqual([ - '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"]'), ).not.toBeNull(); @@ -332,8 +442,6 @@ describe('ResourceReferenceInput', () => { render( { const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); render( { await user.click(screen.getByRole('button', { name: '移除引用 hero' })); await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0].references).toHaveLength(0); + expect(draftResourceIds(onChange.mock.calls.at(-1)?.[0])).toHaveLength(0); }); }); @@ -526,8 +632,6 @@ describe('ResourceReferenceInput', () => { const user = userEvent.setup(); render( { const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); render( { expect(chip?.contains(deleteButton)).toBe(true); // 提交用的结构化引用完整保留,末尾那个分隔空格提交前会被 trim 掉。 - expect(onChange.mock.calls.at(-1)?.[0].text).toBe('@hero'); - expect(onChange.mock.calls.at(-1)?.[0].references[0]?.resourceId).toBe( - 'hero', - ); + expect(draftText(onChange.mock.calls.at(-1)?.[0])).toBe('@hero'); + expect(draftResourceIds(onChange.mock.calls.at(-1)?.[0])).toEqual(['hero']); // 引用节点是原子的:一次操作删掉整个 chip,不存在"删一半"的中间态。 await user.click(deleteButton); @@ -590,8 +690,8 @@ describe('ResourceReferenceInput', () => { expect( document.querySelector('[data-resource-reference-id="hero"]'), ).toBeNull(); - expect(onChange.mock.calls.at(-1)?.[0].references).toHaveLength(0); - expect(onChange.mock.calls.at(-1)?.[0].text).toBe(''); + expect(draftResourceIds(onChange.mock.calls.at(-1)?.[0])).toHaveLength(0); + expect(draftText(onChange.mock.calls.at(-1)?.[0])).toBe(''); }); test('exposes the current-version and all-canvas scopes as the only two tabs', () => { @@ -636,8 +736,6 @@ describe('ResourceReferenceInput', () => { const user = userEvent.setup(); render( { const user = userEvent.setup(); render( { const user = userEvent.setup(); render( { test('refreshes chip and candidate display names after a resource rename', async () => { const user = userEvent.setup(); const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); + const composerRef = createRef(); const renamedAssets = [ asset('hero', 'character', 'image/png', 'assets/hero-final.png'), assets[1]!, @@ -738,8 +833,13 @@ describe('ResourceReferenceInput', () => { ]; render( { document.querySelector('.resource-reference-chip-label')?.textContent, ).toBe('hero-final'); }); - await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0].references[0]?.label).toBe( - 'hero-final', - ); - }); + // 改名只影响显示名:canonical content 仍只记稳定 resourceId。 + expect(draftResourceIds(composerRef.current?.getDraft())).toEqual(['hero']); await user.click(screen.getByRole('button', { name: '插入素材引用' })); expect(screen.getByRole('option', { name: /hero-final/u })).not.toBeNull(); @@ -766,10 +863,11 @@ describe('ResourceReferenceInput', () => { test('restores a cross-session draft with the caret at the end of the text', async () => { const user = userEvent.setup(); const onChange = vi.fn<(draft: ChatComposerDraft) => void>(); - const { rerender } = render( + const composerRef = createRef(); + render( { />, ); - // 切换 / 重开会话:外部草稿被整体替换。 - rerender( - , - ); + // 切换 / 重开会话:通过输入区 handle 替换 Lexical 唯一状态。 + composerRef.current?.replaceText('恢复出来的草稿'); await user.click(screen.getByRole('button', { name: '插入素材引用' })); await user.click(screen.getByRole('option', { name: /hero/u })); await user.click(screen.getByRole('button', { name: '插入引用' })); await settleComposer(); - expect(onChange.mock.calls.at(-1)?.[0].text).toBe('恢复出来的草稿@hero'); + // 恢复的草稿文本 + picker 插入的 chip 与它后面的分隔空格,逐字就是编辑器里的内容。 + expect( + onChange.mock.calls + .at(-1)?.[0] + .content.filter((part) => part.type === 'input_text'), + ).toEqual([ + { type: 'input_text', text: '恢复出来的草稿' }, + { type: 'input_text', text: ' ' }, + ]); }); test('标签库按 manifest 标签派生:计数只算候选、排序稳定、多标签取交集', () => { @@ -826,8 +923,6 @@ describe('ResourceReferenceInput', () => { const user = userEvent.setup(); render( { const user = userEvent.setup(); render( { render( <> { const quickEditDraft = quickEditOnChange.mock.calls.at(-1)?.[0]; // 同一个资产在两条入口上插入,回填文本与结构化引用必须逐字相同: // 「快速编辑」不允许出现第二种引用格式。 - expect(chatDraft?.text).toBe('@hero'); - expect(quickEditDraft).toEqual(chatDraft); - expect(quickEditDraft?.references).toEqual([ - resourceReferenceFromAsset(taggedAssets[0]!, 'asset-picker'), + expect(chatDraft?.content).toEqual([ + { type: 'agc_resource_reference', resourceId: 'hero' }, + { type: 'input_text', text: ' ' }, ]); + expect(quickEditDraft).toEqual(chatDraft); expect( document.querySelectorAll('[data-resource-reference-id="hero"]'), ).toHaveLength(2); @@ -973,8 +1062,7 @@ describe('ResourceReferenceInput', () => { test('快速编辑提示词输入区不渲染内置润色入口:润色归宿主的 ResourcePromptPolishSlot', async () => { render( { it('保留 Lexical content 的文本与引用交错顺序', () => { const draft: ChatComposerDraft = { - text: '忽略的扁平摘要', - references: [], content: [ { type: 'input_text', text: '先看 ' }, { type: 'agc_resource_reference', resourceId: 'asset-hero' }, @@ -41,18 +41,9 @@ describe('DirectProject user Response item', () => { it('资源引用只投影稳定 resourceId,不携带展示字段', () => { const draft: ChatComposerDraft = { - text: '请使用素材', - references: [ - { - type: 'resource', - resourceId: 'asset-hero', - kind: 'character', - mediaType: 'image/png', - label: '主角', - category: 'character', - tags: ['hero'], - source: 'asset-picker', - }, + content: [ + { type: 'input_text', text: '请使用素材' }, + { type: 'agc_resource_reference', resourceId: 'asset-hero' }, ], }; @@ -68,4 +59,51 @@ describe('DirectProject user Response item', () => { ], }); }); + + it('原样保留纯空白 input_text,不替用户改写提示词', () => { + const draft: ChatComposerDraft = { + content: [ + { type: 'input_text', text: '先看' }, + { type: 'input_text', text: ' ' }, + { type: 'agc_resource_reference', resourceId: 'asset-hero' }, + { type: 'input_text', text: '\n' }, + ], + }; + + expect( + chatComposerDraftToDirectCodexUserItem(draft, 'turn-3:user').content, + ).toEqual(draft.content); + }); + + it('只有最终 content 全为空白时才判定为空输入', () => { + expect( + hasMeaningfulDirectCodexContent([{ type: 'input_text', text: ' \n ' }]), + ).toBe(false); + expect(hasMeaningfulDirectCodexContent([])).toBe(false); + // 空白文本仍然保留,但只要有实际文本或引用就不能当空输入拒发。 + expect( + hasMeaningfulDirectCodexContent([ + { type: 'input_text', text: ' \n' }, + { type: 'input_text', text: '看' }, + ]), + ).toBe(true); + expect( + hasMeaningfulDirectCodexContent([ + { type: 'agc_resource_reference', resourceId: 'asset-hero' }, + ]), + ).toBe(true); + }); + + it('展示用文本由 content 派生,引用按 @ 显示名展开', () => { + expect( + directCodexContentToPromptText( + [ + { type: 'input_text', text: '用 ' }, + { type: 'agc_resource_reference', resourceId: 'asset-hero' }, + { type: 'input_text', text: ' 做主视觉' }, + ], + [], + ), + ).toBe('用 @asset-hero 做主视觉'); + }); }); diff --git a/docs/project-memory/plans/【实施计划】DirectProject Skill提及输入提示-2026-09-15.md b/docs/project-memory/plans/【实施计划】DirectProject Skill提及输入提示-2026-09-15.md new file mode 100644 index 000000000..e68e082a6 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】DirectProject Skill提及输入提示-2026-09-15.md @@ -0,0 +1,27 @@ +# 【实施计划】DirectProject Skill 提及输入提示 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】DirectProject Skill提及输入提示-2026-09-15.md` | +| Status | in-progress | +| Owner | Codex | + +## 代码边界 + +- 前端:`features/project-workspace/ResourceReferenceInput.tsx`、`ResourceReferenceNode.tsx`、`resourceReferences.ts`、生成绑定及聊天入口透传。 +- Rust:`agent/direct_codex_user_item/model.rs`、`validation.rs`、`wire.rs`、`codex_app_server/mod.rs` 与对应测试。 +- 文档:父规范与本里程碑/实施计划。 + +## 小切片顺序 + +1. 先扩展前端 Skill catalog/节点/草稿 content,保持素材行为不变并补前端测试。 +2. 扩展 canonical Rust part 与 ts-rs 绑定,补序列化和失败校验测试。 +3. 接通 Codex wire `type: skill` 转换和受控路径解析,补历史/重放测试。 +4. 完成入口透传、定向验证和文档证据;每个切片单独中文提交。 + +## 验证与回滚 + +- `npm --prefix apps/ai-game-creator-shell run typecheck` +- 相关 Vitest 与 `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_codex` +- `npm run check:encoding`、`npm run check:doc-index`、`git diff --check` +- 每个切片只改计划列出的文件;若 Codex wire 协议或 Skill catalog 来源不确定,停在该切片并先更新规范。 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 new file mode 100644 index 000000000..6bdbed240 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】DirectProject canonical content严格边界-2026-09-16.md @@ -0,0 +1,42 @@ +# 【实施计划】DirectProject canonical content 严格边界 + +| 字段 | 值 | +| --------- | ------------------------------------------------------------------------------------------- | +| Milestone | `docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md` | +| Status | implemented | +| Owner | Codex | + +## 实施顺序 + +1. 先把主转换函数改为原样传递 `draft.content`,并在 content-only 测试中覆盖空白 part 保留。 +2. 将最终 content 有效性判断抽为纯函数,接入普通提交、队列入队和其它 Direct Codex 入口。 +3. 删除 Direct Codex 的 `userItem` fallback;首页首轮和队列出队直接构造 canonical user item。 +4. 收敛策略确认重试为复用同一 canonical user item;旧 Supervisor/Planning caller 加 TODO,不改变其非 Direct Codex 行为。 +5. 迁移现有测试 fixture,删除旧字段构造,不增加“字段不存在”测试。 + +## 落地结果 + +- `chatComposerDraftToDirectCodexUserItem` 原样传递 `draft.content`;新增 `directCodexUserItemFromContent` 供 caller 直接构造 canonical item。 +- `ChatComposerDraft` 只保留 `content`;`ResourceReferenceInput` 的对外草稿、`chatPromptDraftKey`、`QueuedChatTurn` 全部改为 content-only。 +- 首页首轮、普通聊天提交、运行中队列出队、策略确认重试都携带同一个 canonical user item;`executeChatAgentReply` 的 `userItem` 兜底分支已删除。 +- 旧 Planner / legacy Supervisor caller 显式构造纯文本 item 并留下迁移 TODO。 + +## 修改边界 + +- 允许修改:AGC shell 前端 `resourceReferences`、`App`、聊天队列、Direct Codex 相关测试和当前里程碑文档。 +- 不修改:Rust user item schema、附件 DTO、SpacetimeDB、HTTP API、用户已有 `.env` / `package-lock.json` 修改。 +- 不引入:`text` / `references` 到 canonical Direct Codex item 的任何兼容 fallback。 + +## 验证命令 + +- `npm run test -- apps/ai-game-creator-shell/tests/resourceReferences.test.ts apps/ai-game-creator-shell/tests/resourceReferenceInput.test.tsx apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts` +- `npm run ai-game-creator-shell:typecheck` +- `npm run check:encoding` +- `npm run check:doc-index` +- `git diff --check` + +## 风险与回滚点 + +- 空白 `input_text` 不再前端删除,需确保最终有效性判断只拒绝全空 content,不改变其它 part。 +- Direct caller 漏传 user item 时应在类型检查或明确错误分支暴露,不能静默重建。 +- 共享工作树含用户未提交修改;提交时只 stage 本计划和本次代码 hunk。 diff --git a/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md b/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md index ad2f53f07..c18716d77 100644 --- a/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md +++ b/docs/project-memory/plans/【实施计划】DirectProject用户ResponseItem输入-2026-09-15.md @@ -9,7 +9,8 @@ ## 修改边界 - 允许修改:AGC 壳 Rust agent 输入合同、DirectProject 历史适配、前端聊天引用模型、ts-rs 生成配置、当前聊天素材文档。 -- 明确不修改:assistant 返回协议、工具 activity、附件/图片协议、SpacetimeDB、HTTP API。 +- 明确不修改:assistant 返回协议、工具 activity、附件/图片上传 DTO、SpacetimeDB、HTTP API;上传 DTO 仅在入口转换为 canonical `content[]`,不再以 sidecar 文本追加。 +- 本切片明确:不根据 MIME 类型判断图片,不生成 `agc_image_reference`;所有上传文件统一映射为 `agc_attachment_reference`。 ## 实现顺序 diff --git a/docs/project-memory/plans/【里程碑】DirectProject Skill提及输入提示-2026-09-15.md b/docs/project-memory/plans/【里程碑】DirectProject Skill提及输入提示-2026-09-15.md new file mode 100644 index 000000000..e9fb83974 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】DirectProject Skill提及输入提示-2026-09-15.md @@ -0,0 +1,43 @@ +# 【里程碑】DirectProject Skill 提及输入提示 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | in-progress | +| Date | 2026-09-15 | +| Parent Spec | `docs/【功能说明】AGC聊天素材引用-2026-09-08.md` | + +## 目标 + +在现有 Lexical `ResourceReferenceInput` 中增加 Codex 风格 `$skill-name` 提及。用户选择 Skill 后,编辑器保留与文本相对顺序一致的原子节点;DirectProject canonical user item 保存稳定 Skill 名称,Rust 只允许当前已启用且 app-server 已发现的 Skill,并在 `turn/start` 转换为 Codex 原生 `type: "skill"` 输入项。 + +## 范围 + +- Skill 候选数据从当前 DirectProject 的已启用 Skill catalog 派生。 +- `$` typeahead 菜单、键盘选择、鼠标选择、Esc/Enter 交互与现有 `@` 菜单一致。 +- Skill inline 节点和 `content[]` 顺序恢复。 +- canonical `agc_skill_reference` part 的 ts-rs 类型、Rust 校验、历史写入与 Codex wire 转换。 +- 现有素材、运行区域、assistant、附件和工具 activity 行为保持不变。 + +## 不在范围内 + +- Skill 导入、启用、禁用、重命名和 app-server `skills/list` 生命周期改造。 +- 普通自然语言自动分类 Skill;只支持显式 `$skill-name` 选择。 +- Skill 正文预加载、Skill 内容编辑或新的权限/工具能力。 +- SpacetimeDB、HTTP API 和 assistant item 协议变更。 + +## 验收标准 + +- [ ] 输入 `$` 可显示并过滤可用 Skill,候选项显示名称和描述。 +- [ ] 选择 Skill 后插入原子 `$name` chip,文本与素材/运行区域的相对顺序保持不变。 +- [ ] canonical user item 只保存 Skill 稳定名称,不保存正文、凭据或宿主私密路径。 +- [ ] Rust 拒绝未知、禁用、未发现或名称非法的 Skill;失败时不写历史、不启动回合。 +- [ ] 合法 Skill 在 Codex wire input 中生成 `type: "skill"`、`name`、受控 `path`,顺序与 canonical content 一致。 +- [ ] 无 Skill 的旧消息、素材引用和标准 `response_item` 读取行为不变。 + +## 证据要求 + +- 前端:Lexical 草稿顺序、候选过滤、chip 原子性与 `$`/`@` 共存测试。 +- Rust:模型序列化、Skill catalog 校验、wire 转换、失败关闭和历史重放测试。 +- 运行时:DirectProject app-server smoke(环境可用时)。 +- 门禁:相关 Vitest、AGC Rust 定向测试、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 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 new file mode 100644 index 000000000..ffd7767f8 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md @@ -0,0 +1,51 @@ +# 【里程碑】DirectProject canonical content 严格边界 + +| 字段 | 值 | +| ----------- | ------------------------------------------------ | +| Version | 1.0 | +| Status | implemented | +| Date | 2026-09-16 | +| Parent Spec | `docs/【功能说明】AGC聊天素材引用-2026-09-08.md` | + +## 目标 + +让 DirectProject 的用户消息只以 `content[]` 作为 canonical 输入:转换函数原样传递编辑器草稿的 content part,不做二次预过滤;只在最终 content 上判断是否存在有效输入;所有 Direct Codex caller 必须显式提供完整 `userItem`。 + +## 范围 + +- 移除 `chatComposerDraftToDirectCodexUserItem` 对纯空白 `input_text` 的预过滤。 +- Direct Codex 发送前只做最终 content 的有效性判断,不改写 content。 +- 删除 `executeChatAgentReply` 在 `userItem` 缺失时的构造兜底。 +- 删除 `ChatComposerDraft` 的 `text` / `references` 字段,草稿只保留 canonical `content`。 +- 修正首页首轮、队列出队和其它 Direct Codex caller,使其直接构造 canonical user item。 +- 将队列与策略确认重试按 canonical user item 传递,避免拆回 `text` / `references`。 +- 仍在使用的旧 Supervisor/Planning caller 保持非 Direct Codex 行为,并添加后续迁移 TODO。 + +## 不在范围内 + +- 不保留从 `draft.text` / `draft.references` 重建 Direct Codex content 的兼容分支。 +- 不新增断言 legacy 字段“不存在”的测试;现有测试直接迁移到 content-only 输入。 +- 不修改 Rust user item schema、SpacetimeDB schema、HTTP API 或历史迁移。 + +## 验收标准 + +- `chatComposerDraftToDirectCodexUserItem` 输出与输入 `draft.content` 顺序和值完全一致。 +- 只有当最终 content 不含非空文本且不含任何非文本 part 时,发送入口才拒绝本轮。 +- Direct Codex 路径不存在 `userItem ?? ...` 或等价 fallback。 +- `ChatComposerDraft` 只有 `content` 一个字段;`text` / `references` 不再是草稿契约的一部分。 +- 首页首轮、普通聊天、队列出队、策略确认重试均发送同一个 canonical user item 语义。 +- 旧 Supervisor/Planning caller 上有明确 TODO,且不进入 Direct Codex canonical 发送路径。 + +## 实现结论 + +- 前端不再做任何空白过滤: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`(含「引用后面的段落分隔原样落进 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/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md b/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md index bcf6efc68..7a36a3be7 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md +++ b/docs/project-memory/plans/【里程碑】DirectProject用户ResponseItem输入-2026-09-15.md @@ -19,13 +19,13 @@ - `agc_runtime_region_reference` 保留运行区域语义摘要。 - Rust 在持久化前完成白名单、manifest 与路径校验。 - 现有标准 `response_item` 原样兼容;legacy conversation 行不提供 fallback。 -- 保持 assistant 返回、工具 activity、附件/图片协议不变。 +- 保持 assistant 返回、工具 activity 与上传 DTO 不变;DirectProject canonical user item 不再保存附件 sidecar,所有本地上传文件(包括图片)统一作为 `content[]` 中按顺序排列的 `agc_attachment_reference`。 ## 不在范围内 - assistant item 前端投影或 Tauri 返回值改造。 - 工具 item、reasoning、file change、MCP item 的 UI 模型化。 -- 附件/图片 content part。 +- 不新增图片专用 content part;上传 DTO 只作为输入适配,不作为历史事实源。 - SpacetimeDB schema 或 HTTP API 变更。 ## 依赖与前置条件 @@ -42,7 +42,7 @@ - [x] 未知 part、失效资源或非法路径在持久化前失败关闭。 - [x] canonical item 以 `response_item` 写入历史,标准旧 item 原样可读。 - [x] Codex wire input 不含 AGC 私有 part,且顺序与 canonical content 一致。 -- [ ] assistant、附件和工具链路行为无变化。 +- [x] assistant、附件和工具链路行为无变化;所有上传文件按 content 顺序内联,附件-only 输入也能进入 DirectProject prompt。 ## 证据要求 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ec473d2c0..e031134b6 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8893,6 +8893,22 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 关联规范:`docs/technical/【技术方案】AGC模板库与模板建项-2026-09-17.md`;开发期计划见 `docs/project-memory/plans/【里程碑】AGC模板库客户端接入-2026-09-17.md` 与对应实施计划。 - 验证:Rust 模板库 8 项定向单测、前端模型 9 项单测、AGC `tsc` 类型检查通过;`templates/index.json` 匿名可读且每个 `zipKey` 回读 SHA-256 与清单一致;发布脚本 `scripts/agc-template-library-publish.mjs` 支持 `--dry-run` 与上传后回读校验。 +## 2026-09-16 DirectProject 上传文件统一使用附件 content part + +- 决策:DirectProject 本地上传不区分图片与其它文件。用户使用同一个文件入口,前端不按 `mediaType` 做图片判断,所有上传文件统一写成 `agc_attachment_reference`;项目资源 `@` 引用仍使用 `agc_resource_reference`。 +- 原因:原 `agc_image_reference` 与附件 payload 完全相同,Rust wire 投影也把两者合并成同一段文本,不能代表真实的多模态图片输入。保留该 discriminator 只会制造错误语义。 +- 边界:本次不新增 `input_image`,不保留图片类型兼容分支,不迁移旧历史;图片多模态能力未来单独设计独立 payload 与 wire 投影。 + +## 2026-09-17 DirectProject canonical content 的有效性只判整条 content + +- 决策:canonical user item 的有效输入判据只落在**整条 content** 上——只要有一段非空白文本、或任何一个非文本 part 就算有效输入;单个纯空白 `input_text`(段落分隔、软换行、chip 后的分隔空格)是合法 part。Rust `validate_direct_codex_user_item` 的 `content_has_meaningful_input` 与前端 `hasMeaningfulDirectCodexContent` 同口径,`wire.rs` 的「不能转换为空 prompt」只作兜底。 +- 决策:编辑器投影层(`ResourceReferenceInput` 的 `collectDraftParts`)原样透传编辑器节点:不做空白过滤,也不与相邻 part 合并。前端不替用户改写他输入的内容,canonical content 与编辑器内容逐字对应。 +- 决策:content → 可读文本只有 `directCodexContentToPromptText(content, assets)` 一个口径,`assets`(当前项目 manifest)必填:消息正文、队列 chip 文案、润色判据、草稿持久化与出站 prompt 全部由它派生,`agc_resource_reference` 按 `@显示名` 展开,只有素材已不在清单里时才回落 `resourceId`。 +- 原因:`3c7b02b9f` 为了让 content 通过「空 `input_text`」校验而在投影层丢空白 part,把引用后的段落分隔一起丢了(`@素材` 与下一段粘成一个词);`41366dd71` 又把消息 / 队列 / 快速编辑的文本派生切到这条投影上,缺陷扩散到界面与出站 prompt。 +- 边界:不新增 content part 类型,不迁移历史(历史 content 原样回放),不为旧口径保留兼容分支;前端仍不发整条全空白的一轮,app-server 输入里出现纯空白 text item 由本决定接受。 +- 验证:Rust `validation.rs` / `wire.rs` 用例「单个纯空白 part 通过校验、整条全空白拒绝」;AGC 侧 `resourceReferenceInput.test.tsx`、`resourceReferences.test.ts`、`appSurface/project-development.suite.ts`(Godot 回合)、`projectResourceLiveIntegration.test.tsx` 改为按逐字投影断言,`ai-game-creator-shell:typecheck` 与定向 vitest 通过。 +- 关联规范:`docs/project-memory/plans/【里程碑】DirectProject canonical content严格边界-2026-09-16.md`。 + ## 2026-09-16 CI 宿主 CPU 上限:Jenkins 16 核 / Gitea Actions runner 12 核 - 背景:`genarrative-station`(32 逻辑核)上 Jenkins Built-In Node 与 Gitea Actions runner 共用同一宿主。Jenkins `jenkins.service` 原先没有任何 CPU 限制(`cpu.max=max`),构建期 Web / Api / Stdb 三分支并行(Vitest 8 线程 + 两次默认 32 job 的 cargo)把整机顶到 80%~95%;`gitea-runner` 容器 `--cpus=24`(75%)在 push 触发的 CI 波峰里实测峰值 24.8~25.3 核,是同一时间窗里更大的单一消耗方。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index f77e2447e..4d51b1d84 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5679,6 +5679,23 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - Vitest 的 `toHaveBeenCalledWith` 匹配任意一次调用,失败输出会列出其它命令;应先定位相同命令的真实参数差异,不能由其它调用的序号推断时序故障。 - 存在后台轮询的 IPC mock 不应要求目标命令占据全局最后一次调用。验证刷新时先记录调用边界,再筛选该边界之后的目标命令,严格核对其最后一次参数,避免后台查询影响断言,也避免旧调用掩盖刷新未执行。 +## 2026-09-16 Lexical 投影丢掉引用后的换行:`@素材` 和下一段粘成一个词 + +- **现象**:聊天输入区里先 `@` 一个素材、回车换段再写文字,提交出去的 canonical content 里没有任何分隔,直接读成 `@hero把这一版改成夜景`;同一个字符串还会进 agent 输入、队列 chip 文案与润色判据。 +- **原因**:`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 + +- **现象**:快速编辑面板里 `@` 插了素材再点「修改」,出站 `derive_local_project_resource` 的 `prompt` 是 `把夜色改成星空@source-rules`,而界面 chip 与聊天输入区显示的是 `@rules`;仓库自带的 `projectResourceLiveIntegration` 用例因此长期是红的(`expected '把夜色改成星空@rules' to be '把夜色改成星空@source-rules'`)。同一根因还让排队消息 chip 显示 `@asset:…`。 +- **原因**:`directCodexContentToPromptText(content, assets)` 的 `assets` 有默认值 `[]`,漏传不报错、只是把 `agc_resource_reference` 退化成 `${resourceId}`。`ResourceReferenceInput` 自己读草稿的四处都带了 `manifest.assets`,而它的三个消费方漏传:`chatComposerQueue.queuedChatTurnLabel`(宿主 `ComposerTurnQueue` 也没接素材清单)、`project-development/index.tsx` 的 `applyResourceQuickEditPrompt` 与 `applyResourceQuickEditDraft`;后两个的 `useCallback` 依赖里同样没有 `manifest.assets`,改完还会读到旧清单。 +- **处理**:三个消费方全部补上素材清单并进依赖数组——`queuedChatTurnLabel(turn, assets)` + `ComposerTurnQueue` 新增 `assets` 属性(由 `ProjectSupervisorView` 传 `chatProjectAssets`)、快速编辑的两处改用 `manifest.assets`。改「比较用的草稿文本」与「落进面板的文本」必须同一个口径,否则 `replaceText` 会每次输入都重跑一遍。 +- **加固**:`directCodexContentToPromptText(content, assets)` 与 `queuedChatTurnLabel(turn, assets)` 的 `assets` 改为**必填**(删掉 `= []` 默认值),测试里刻意不传 manifest 的地方显式写 `[]`。理由:默认值把「漏传素材清单」从编译期错误降级成运行期文案退化,正是本条缺陷的入口;队列 chip 与消息正文从此共用同一个派生(`queuedChatTurnLabel` 只多做「压成单行 + 限长」)。 +- **验证**:`apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx` 的「快速编辑提示词里能 @ 出资源选择器」由红转绿(该文件 29 passed);`tests/appSurface/chat-composer.suite.ts` 新增「队列 chip 的 @ 引用按 manifest 显示名展开」;`appSurface.test.ts` 467 passed、定向 51 passed、`npm run ai-game-creator-shell:typecheck`、`npm run check:encoding` 通过。 +- **关联**:`apps/ai-game-creator-shell/src/features/project-workspace/chatComposerQueue.ts`、`ComposerControls.tsx`、`ProjectSupervisorView.tsx`、`apps/ai-game-creator-shell/src/view/project-development/index.tsx`、`apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx`。 + ## 2026-09-16 并发生图遇到“登录态冲突”:身份代次与凭据轮换混用 - **现象**:DirectProject 长回合里并发派发的生图 / 素材生成请求中途报 `authentication-required: 陶泥儿登录态已变化,旧账号请求已停止,请使用当前账号重试`,或平台工具返回 `HTTP 401 invalid-token`;账号并没有切换,重新登录后短时间内可复现。 diff --git a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md index ca8874954..6a082c3b1 100644 --- a/docs/【功能说明】AGC聊天素材引用-2026-09-08.md +++ b/docs/【功能说明】AGC聊天素材引用-2026-09-08.md @@ -2,7 +2,7 @@ 更新时间:2026-09-08 -AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。输入 `@` 会按素材名称、资源 ID 和类型过滤候选项;也可以点击输入框右侧的 `@` 按钮打开素材选择面板。 +AGC 聊天输入框支持以结构化引用标记当前项目已登记素材,并提供 Codex 风格的 Skill 提及。输入 `@` 会按素材名称、资源 ID 和类型过滤候选项;输入 `$` 会按当前 DirectProject 可用 Skill 名称过滤候选项;也可以点击输入框右侧的 `@` 按钮打开素材选择面板。 素材选择面板支持缩略图、名称或资源 ID 搜索、类型筛选和多选;面板顶部有两个页签: @@ -11,14 +11,17 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。 两个页签各自持有独立的搜索与类型筛选状态,互不影响,也不与资源画布筛选联动。当前版本取 `ResourceReferenceInput` 的 `activeVersionId`;未传或传 `null` 时回退到 manifest `versions[]` 中最新的那个版本。版本不存在或该版本没有绑定素材时页签显示空态,不合成资源卡;绑定指向已删除资源(悬空绑定)时按资源 `id` 过滤掉。 -确认后素材以 `@素材名` 芯片插入编辑器,用户可以在芯片前后继续编辑自然语言,也可以单独删除芯片。芯片内部保存稳定 `resourceId`,展示名称只用于界面,不参与引用解析;资源改名后,编辑区已有芯片与候选列表都会按 `resourceId` 刷新成 manifest 的最新显示名,并同步回父级草稿。 +确认后素材以 `@素材名` 芯片插入编辑器,Skill 以 `$skill-name` 芯片插入编辑器;用户可以在芯片前后继续编辑自然语言,也可以单独删除芯片。素材芯片内部保存稳定 `resourceId`,展示名称只用于界面,不参与引用解析。Skill 芯片保存稳定 Skill 名称,发送时由 Rust 根据当前 DirectProject 已启用 Skill 清单解析为 Codex 原生 `type: "skill"` 输入项。资源改名后,编辑区已有芯片与候选列表都会按 `resourceId` 刷新成 manifest 的最新显示名,并同步回父级草稿。 提交时前端把 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。 +本地上传入口不区分图片和其它文件:用户从同一个文件选择器提交任意附件,前端不做 MIME 类型分流,所有上传文件统一编码为 `agc_attachment_reference`。图片不会因为扩展名或 MIME 类型获得另一种 canonical part;只有未来真正支持 Codex 多模态 `input_image` 投影时,才另行设计图片协议。 + 当前已完成: - 三个聊天入口共用 `ResourceReferenceInput`; - 输入 `@` 触发候选,支持键盘选择和 Esc 关闭; +- 输入 `$` 触发 Skill 候选,支持键盘选择和 Esc 关闭;Skill 候选只显示当前 DirectProject 已启用且已由 app-server 发现的 Skill; - `@` 按钮打开素材选择面板; - 支持搜索、类型筛选和多选; - 素材芯片可插入、编辑和删除; @@ -30,3 +33,4 @@ AGC 聊天输入框支持以结构化引用标记当前项目已登记素材。 - 素材选择面板的「当前版本素材 / 全部画布素材」两个页签与独立筛选、搜索状态; - 资源改名后引用芯片与候选列表的显示名自动刷新; - 切换 / 重开会话恢复草稿后光标落在文本末尾,引用按原 content 顺序恢复为 inline 芯片。 +- Skill 提及按原 content 顺序恢复为 inline 芯片;未知、禁用或未发现 Skill 在发送前失败关闭,不写入历史、不启动回合。