From 1559485b0de8735e63989f24e709ea00e0577337 Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 31 Aug 2026 04:34:17 +0000 Subject: [PATCH] =?UTF-8?q?=E8=AE=A9=20Direct=20=E9=A6=96=E8=BD=AE?= =?UTF-8?q?=E5=B8=A6=E4=B8=8A=E6=9C=AC=E8=BD=AE=E9=99=84=E4=BB=B6=E7=9A=84?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E8=B7=AF=E5=BE=84=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽出 Home/Project 共用的 DirectCodexTurnAttachment 与渲染函数 Direct command 在进 Codex 前拼接有界 sidecar,jsonl 与气泡仍写用户原文 首页建项 latch 把导入附件传给 Direct 首轮,后续手打消息不带 attachments 做方案首轮仍走 Supervisor,不注入 sidecar 补齐 Rust 渲染测试与 home.suite 附件断言 记录路径映射合同与决策 --- .../src-tauri/src/agent.rs | 2 + .../src/agent/direct_codex_attachments.rs | 424 ++++++++++++++++++ .../src-tauri/src/agent/direct_runtime.rs | 130 +----- apps/ai-game-creator-shell/src/App.tsx | 62 ++- apps/ai-game-creator-shell/src/app/types.ts | 1 + .../features/app-shell/WorkspaceLauncher.tsx | 1 + .../app-shell/directCodexTurnAttachments.ts | 38 ++ .../src/features/app-shell/model.ts | 7 +- .../app-shell/useHomeProjectCreation.ts | 2 + .../tests/appSurface/home.suite.ts | 137 ++++++ .../tests/directCodexTurnAttachments.test.ts | 45 ++ docs/README.md | 1 + .../shared-memory/decision-log.md | 8 + .../shared-memory/document-map.md | 9 +- ...】DirectProject本轮附件路径映射-2026-08-31.md | 262 +++++++++++ 15 files changed, 980 insertions(+), 149 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs create mode 100644 apps/ai-game-creator-shell/src/features/app-shell/directCodexTurnAttachments.ts create mode 100644 apps/ai-game-creator-shell/tests/directCodexTurnAttachments.test.ts create mode 100644 docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 78d3e3ee0..27531bafd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -12,6 +12,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; mod codex_app_server; mod codex_cli; mod codex_provider_proxy; +mod direct_codex_attachments; mod direct_runtime; mod direct_tool_bridge; mod direct_tools_mcp; @@ -34,6 +35,7 @@ pub(crate) use codex_cli::{ game_creator_codex_cli_executable_path, game_creator_codex_cli_version_identity, }; pub(crate) use codex_provider_proxy::*; +pub(crate) use direct_codex_attachments::*; pub(crate) use direct_runtime::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tools_mcp::*; 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 new file mode 100644 index 000000000..3a4a7462b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs @@ -0,0 +1,424 @@ +//! Direct Codex 本轮附件 sidecar:Home 与 Project 共用同一 DTO 和渲染函数。 +//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。 + +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; + +const HOME_ATTACHMENT_HEADER: &str = + "[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]"; +const PROJECT_ATTACHMENT_HEADER: &str = + "[本轮用户附件:已复制到当前项目。请用「项目路径」读取;原文件名不是磁盘路径。]"; + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectCodexTurnAttachment { + name: String, + media_type: String, + #[serde(default)] + size: u64, + #[serde(default)] + local_path: Option, + #[serde(default)] + status: Option, +} + +fn sanitize_attachment_name(value: &str) -> String { + let basename = value.rsplit(['/', '\\']).next().unwrap_or_default().trim(); + let sanitized = basename + .chars() + .filter(|character| !character.is_control()) + .take(MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS) + .collect::(); + if sanitized.is_empty() { + "未命名附件".to_string() + } else { + sanitized + } +} + +fn sanitize_attachment_media_type(value: &str) -> String { + let value = value.trim(); + if value.is_empty() + || value.chars().any(|character| { + !(character.is_ascii_alphanumeric() || matches!(character, '/' | '+' | '-' | '.' | '_')) + }) + { + "application/octet-stream".to_string() + } else { + value + .chars() + .take(MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS) + .collect() + } +} + +fn sanitize_attachment_status(value: Option<&str>) -> Option<&'static str> { + match value.map(str::trim) { + Some("imported") => Some("imported"), + Some("failed") => Some("failed"), + _ => None, + } +} + +fn sanitize_attachment_local_path(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() + || trimmed.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS + || trimmed.chars().any(char::is_control) + { + return None; + } + + let normalized = trimmed.replace('\\', "/"); + if normalized.starts_with('/') { + return None; + } + + let mut chars = normalized.chars(); + if let (Some(letter), Some(':')) = (chars.next(), chars.next()) { + if letter.is_ascii_alphabetic() { + return None; + } + } + + let mut segments = Vec::new(); + for segment in normalized.split('/') { + if segment.is_empty() || segment == "." { + continue; + } + if segment == ".." { + return None; + } + segments.push(segment); + } + let first = segments.first()?; + if *first == ".agent" || *first == ".git" { + return None; + } + let path = segments.join("/"); + if path.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS { + return None; + } + Some(path) +} + +fn attachments_use_project_mapping(attachments: &[DirectCodexTurnAttachment]) -> bool { + attachments.iter().any(|attachment| { + attachment + .local_path + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + || attachment + .status + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + }) +} + +fn render_project_attachment_line(attachment: &DirectCodexTurnAttachment) -> String { + let name = sanitize_attachment_name(&attachment.name); + let media_type = sanitize_attachment_media_type(&attachment.media_type); + let raw_path = attachment + .local_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let sanitized_path = raw_path.and_then(sanitize_attachment_local_path); + let path_rejected = raw_path.is_some() && sanitized_path.is_none(); + let status = if path_rejected { + Some("failed") + } else { + sanitize_attachment_status(attachment.status.as_deref()) + }; + + let mut parts = vec![format!("原文件名:{name}")]; + if let Some(path) = sanitized_path { + parts.push(format!("项目路径:{path}")); + } + parts.push(format!("类型:{media_type}")); + parts.push(format!("大小:{} 字节", attachment.size)); + if let Some(status) = status { + parts.push(format!("状态:{status}")); + } + format!("- {}", parts.join(";")) +} + +pub(crate) fn render_direct_codex_user_prompt( + prompt: &str, + attachments: &[DirectCodexTurnAttachment], +) -> Result { + let prompt = prompt.trim(); + if prompt.is_empty() && attachments.is_empty() { + return Err("聊天内容不能为空".to_string()); + } + if attachments.is_empty() { + return Ok(prompt.to_string()); + } + + let mut sections = Vec::new(); + if !prompt.is_empty() { + sections.push(prompt.to_string()); + sections.push(String::new()); + } + if attachments_use_project_mapping(attachments) { + sections.push(PROJECT_ATTACHMENT_HEADER.to_string()); + for attachment in attachments.iter().take(MAX_DIRECT_CODEX_ATTACHMENTS) { + sections.push(render_project_attachment_line(attachment)); + } + } else { + sections.push(HOME_ATTACHMENT_HEADER.to_string()); + for attachment in attachments.iter().take(MAX_DIRECT_CODEX_ATTACHMENTS) { + sections.push(format!( + "- {};类型:{};大小:{} 字节", + sanitize_attachment_name(&attachment.name), + sanitize_attachment_media_type(&attachment.media_type), + attachment.size, + )); + } + } + if attachments.len() > MAX_DIRECT_CODEX_ATTACHMENTS { + sections.push(format!( + "- 另有 {} 个附件未展开", + attachments.len() - MAX_DIRECT_CODEX_ATTACHMENTS + )); + } + Ok(sections.join("\n")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn home_attachment(name: &str, media_type: &str, size: u64) -> DirectCodexTurnAttachment { + DirectCodexTurnAttachment { + name: name.to_string(), + media_type: media_type.to_string(), + size, + local_path: None, + status: None, + } + } + + fn project_attachment( + name: &str, + media_type: &str, + size: u64, + local_path: Option<&str>, + status: Option<&str>, + ) -> DirectCodexTurnAttachment { + DirectCodexTurnAttachment { + name: name.to_string(), + media_type: media_type.to_string(), + size, + local_path: local_path.map(str::to_string), + status: status.map(str::to_string), + } + } + + #[test] + fn plain_prompt_is_trimmed_and_empty_prompt_without_attachments_is_rejected() { + assert_eq!( + render_direct_codex_user_prompt(" 你好 ", &[]).expect("plain prompt"), + "你好" + ); + assert_eq!( + render_direct_codex_user_prompt("", &[]).expect_err("empty prompt"), + "聊天内容不能为空" + ); + } + + #[test] + fn home_user_prompt_preserves_the_message_and_adds_only_bounded_attachment_metadata() { + let attachments = vec![home_attachment( + r"C:\Users\secret\角色参考.png", + "image/png\nBearer secret", + 3, + )]; + + let prompt = render_direct_codex_user_prompt(" 先看看这个附件 ", &attachments) + .expect("home prompt"); + + assert_eq!( + prompt, + "先看看这个附件\n\n[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]\n- 角色参考.png;类型:application/octet-stream;大小:3 字节" + ); + assert!(!prompt.contains("C:\\Users")); + assert!(!prompt.contains("\nBearer secret")); + } + + #[test] + fn home_user_prompt_keeps_plain_messages_plain_and_caps_attachment_count() { + assert_eq!( + render_direct_codex_user_prompt("你好", &[]).expect("plain prompt"), + "你好" + ); + let attachments = (0..MAX_DIRECT_CODEX_ATTACHMENTS + 2) + .map(|index| home_attachment(&format!("asset-{index}.png"), "image/png", index as u64)) + .collect::>(); + let prompt = + render_direct_codex_user_prompt("看看素材", &attachments).expect("bounded attachments"); + assert!(prompt.contains("asset-7.png")); + assert!(!prompt.contains("asset-8.png")); + assert!(prompt.contains("另有 2 个附件未展开")); + assert!(render_direct_codex_user_prompt("", &attachments).is_ok()); + assert!(render_direct_codex_user_prompt("", &[]).is_err()); + } + + #[test] + fn home_json_without_path_or_status_still_deserializes() { + let attachment: DirectCodexTurnAttachment = + serde_json::from_str(r#"{"name":"a.png","mediaType":"image/png","size":3}"#) + .expect("home json"); + assert!(attachment.local_path.is_none()); + assert!(attachment.status.is_none()); + assert_eq!(attachment.size, 3); + } + + #[test] + fn project_prompt_keeps_user_text_and_maps_original_name_to_project_path() { + let attachments = vec![project_attachment( + "fast_gdd.md", + "text/markdown", + 7944, + Some("assets/uploads/upload-1788083777445-fast_gdd.md"), + Some("imported"), + )]; + let prompt = render_direct_codex_user_prompt("请根据附件做游戏", &attachments) + .expect("project prompt"); + + assert_eq!( + prompt, + "请根据附件做游戏\n\n[本轮用户附件:已复制到当前项目。请用「项目路径」读取;原文件名不是磁盘路径。]\n- 原文件名:fast_gdd.md;项目路径:assets/uploads/upload-1788083777445-fast_gdd.md;类型:text/markdown;大小:7944 字节;状态:imported" + ); + assert!(!prompt.contains("GDD")); + assert!(!prompt.contains("规格")); + assert!(!prompt.contains("权威")); + assert!(!prompt.contains("必须读取")); + } + + #[test] + fn project_png_and_markdown_share_the_same_line_shape() { + let attachments = vec![ + project_attachment( + "角色参考.png", + "image/png", + 12, + Some("assets/uploads/upload-1-角色参考.png"), + Some("imported"), + ), + project_attachment( + "notes.md", + "text/markdown", + 80, + Some("assets/uploads/upload-2-notes.md"), + Some("imported"), + ), + ]; + let prompt = + render_direct_codex_user_prompt("看这两个附件", &attachments).expect("mixed types"); + let lines: Vec<_> = prompt + .lines() + .filter(|line| line.starts_with("- 原文件名:")) + .collect(); + assert_eq!(lines.len(), 2); + for line in &lines { + assert!(line.contains(";项目路径:assets/uploads/")); + assert!(line.contains(";类型:")); + assert!(line.contains(";大小:")); + assert!(line.contains(";状态:imported")); + } + assert!(lines[0].contains("角色参考.png")); + assert!(lines[0].contains("image/png")); + assert!(lines[1].contains("notes.md")); + assert!(lines[1].contains("text/markdown")); + } + + #[test] + fn failed_attachment_without_path_has_status_and_no_error_body() { + let attachments = vec![project_attachment( + "lost.bin", + "application/octet-stream", + 2, + None, + Some("failed"), + )]; + let prompt = + render_direct_codex_user_prompt("附件失败了", &attachments).expect("failed prompt"); + assert!(prompt.contains(PROJECT_ATTACHMENT_HEADER)); + assert!(prompt.contains("原文件名:lost.bin")); + assert!(prompt.contains("状态:failed")); + assert!(!prompt.contains("项目路径:")); + assert!(!prompt.contains("error")); + assert!(!prompt.contains("失败原因")); + } + + #[test] + fn illegal_local_paths_are_omitted_and_marked_failed() { + let attachments = vec![ + project_attachment( + "up.md", + "text/markdown", + 1, + Some("../secret.md"), + Some("imported"), + ), + project_attachment( + "agent.md", + "text/markdown", + 1, + Some(".agent/conversations/x.md"), + Some("imported"), + ), + project_attachment( + "abs.md", + "text/markdown", + 1, + Some(r"C:\tmp\abs.md"), + Some("imported"), + ), + project_attachment( + "unix.md", + "text/markdown", + 1, + Some("/tmp/unix.md"), + Some("imported"), + ), + ]; + let prompt = + render_direct_codex_user_prompt("非法路径", &attachments).expect("illegal paths"); + assert!(!prompt.contains("../secret.md")); + assert!(!prompt.contains(".agent/conversations/x.md")); + assert!(!prompt.contains("C:\\tmp\\abs.md")); + assert!(!prompt.contains("/tmp/unix.md")); + assert!(!prompt.contains("项目路径:")); + assert_eq!(prompt.matches("状态:failed").count(), 4); + assert!(!prompt.contains("状态:imported")); + } + + #[test] + fn empty_prompt_with_project_attachments_still_renders() { + let attachments = vec![project_attachment( + "ref.png", + "image/png", + 4, + Some("assets/uploads/upload-1-ref.png"), + Some("imported"), + )]; + let prompt = render_direct_codex_user_prompt(" ", &attachments).expect("empty user text"); + assert!(prompt.starts_with(PROJECT_ATTACHMENT_HEADER)); + assert!(prompt.contains("项目路径:assets/uploads/upload-1-ref.png")); + } + + #[test] + fn unknown_error_field_is_not_forwarded_to_the_model() { + let attachment: DirectCodexTurnAttachment = serde_json::from_str( + r#"{"name":"a.md","mediaType":"text/markdown","size":1,"status":"failed","error":"secret boom"}"#, + ) + .expect("extra error field"); + let prompt = render_direct_codex_user_prompt("x", &[attachment]).expect("render"); + assert!(!prompt.contains("secret boom")); + assert!(!prompt.contains("error")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index e515babc1..188838ccb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -7,9 +7,6 @@ use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024; -const MAX_DIRECT_HOME_ATTACHMENTS: usize = 8; -const MAX_DIRECT_HOME_ATTACHMENT_NAME_CHARS: usize = 160; -const MAX_DIRECT_HOME_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96; const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。"; @@ -3718,14 +3715,6 @@ pub(crate) fn build_direct_codex_home_system_prompt() -> String { .join("\n") } -#[derive(Clone, Debug, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectCodexHomeAttachment { - name: String, - media_type: String, - size: u64, -} - #[derive(Clone, Debug, serde::Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct DirectCodexHomeReply { @@ -3758,78 +3747,11 @@ fn parse_direct_codex_home_reply(reply: String) -> DirectCodexHomeReply { } } -fn direct_codex_home_attachment_name(value: &str) -> String { - let basename = value.rsplit(['/', '\\']).next().unwrap_or_default().trim(); - let sanitized = basename - .chars() - .filter(|character| !character.is_control()) - .take(MAX_DIRECT_HOME_ATTACHMENT_NAME_CHARS) - .collect::(); - if sanitized.is_empty() { - "未命名附件".to_string() - } else { - sanitized - } -} - -fn direct_codex_home_attachment_media_type(value: &str) -> String { - let value = value.trim(); - if value.is_empty() - || value.chars().any(|character| { - !(character.is_ascii_alphanumeric() || matches!(character, '/' | '+' | '-' | '.' | '_')) - }) - { - "application/octet-stream".to_string() - } else { - value - .chars() - .take(MAX_DIRECT_HOME_ATTACHMENT_MEDIA_TYPE_CHARS) - .collect() - } -} - -fn render_direct_codex_home_user_prompt( - prompt: &str, - attachments: &[DirectCodexHomeAttachment], -) -> Result { - let prompt = prompt.trim(); - if prompt.is_empty() && attachments.is_empty() { - return Err("聊天内容不能为空".to_string()); - } - if attachments.is_empty() { - return Ok(prompt.to_string()); - } - - let mut sections = Vec::new(); - if !prompt.is_empty() { - sections.push(prompt.to_string()); - sections.push(String::new()); - } - sections.push( - "[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]".to_string(), - ); - for attachment in attachments.iter().take(MAX_DIRECT_HOME_ATTACHMENTS) { - sections.push(format!( - "- {};类型:{};大小:{} 字节", - direct_codex_home_attachment_name(&attachment.name), - direct_codex_home_attachment_media_type(&attachment.media_type), - attachment.size, - )); - } - if attachments.len() > MAX_DIRECT_HOME_ATTACHMENTS { - sections.push(format!( - "- 另有 {} 个附件未展开", - attachments.len() - MAX_DIRECT_HOME_ATTACHMENTS - )); - } - Ok(sections.join("\n")) -} - pub(crate) async fn run_direct_game_creator_home_turn( prompt: &str, - attachments: &[DirectCodexHomeAttachment], + attachments: &[DirectCodexTurnAttachment], ) -> Result { - let user_prompt = render_direct_codex_home_user_prompt(prompt, attachments)?; + let user_prompt = render_direct_codex_user_prompt(prompt, attachments)?; direct_game_creator_home_codex_chat(build_direct_codex_home_system_prompt(), user_prompt) .await .map(parse_direct_codex_home_reply) @@ -4263,6 +4185,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex( prompt: String, 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())?; @@ -4271,9 +4194,11 @@ 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 user_prompt = + render_direct_codex_user_prompt(&prompt, attachments.as_deref().unwrap_or_default())?; let reply = run_direct_game_creator_turn_at_with_creation_type_and_emitter( root, - &prompt, + &user_prompt, creation_type.as_deref(), Some(&turn_emitter), ) @@ -4293,7 +4218,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex( #[tauri::command] pub(crate) async fn chat_with_game_creator_home_direct_codex( prompt: String, - attachments: Option>, + attachments: Option>, ) -> Result { run_direct_game_creator_home_turn(&prompt, attachments.as_deref().unwrap_or_default()).await } @@ -4495,47 +4420,6 @@ mod tests { assert!(!prompt.contains("game/index.html")); } - #[test] - fn home_user_prompt_preserves_the_message_and_adds_only_bounded_attachment_metadata() { - let attachments = vec![DirectCodexHomeAttachment { - name: r"C:\Users\secret\角色参考.png".to_string(), - media_type: "image/png\nBearer secret".to_string(), - size: 3, - }]; - - let prompt = render_direct_codex_home_user_prompt(" 先看看这个附件 ", &attachments) - .expect("home prompt"); - - assert!(prompt.starts_with("先看看这个附件\n\n[首页附件说明")); - assert!(prompt.contains("角色参考.png")); - assert!(prompt.contains("类型:application/octet-stream")); - assert!(prompt.contains("大小:3 字节")); - assert!(!prompt.contains("C:\\Users")); - assert!(!prompt.contains("\nBearer secret")); - } - - #[test] - fn home_user_prompt_keeps_plain_messages_plain_and_caps_attachment_count() { - assert_eq!( - render_direct_codex_home_user_prompt("你好", &[]).expect("plain prompt"), - "你好" - ); - let attachments = (0..MAX_DIRECT_HOME_ATTACHMENTS + 2) - .map(|index| DirectCodexHomeAttachment { - name: format!("asset-{index}.png"), - media_type: "image/png".to_string(), - size: index as u64, - }) - .collect::>(); - let prompt = render_direct_codex_home_user_prompt("看看素材", &attachments) - .expect("bounded attachments"); - assert!(prompt.contains("asset-7.png")); - assert!(!prompt.contains("asset-8.png")); - assert!(prompt.contains("另有 2 个附件未展开")); - assert!(render_direct_codex_home_user_prompt("", &attachments).is_ok()); - assert!(render_direct_codex_home_user_prompt("", &[]).is_err()); - } - #[test] fn home_create_marker_is_accepted_only_as_the_first_reply_token() { let requested = parse_direct_codex_home_reply(format!( diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index e265d1e82..9c95da141 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -60,6 +60,7 @@ import type { GenerateLocalGameDraftResult, ImportCanvasExportResult, InitLocalProjectResult, + LauncherImportedAttachment, LimitedLocalCommandResult, ListLocalProjectFilesResult, LocalAgentMemoryResult, @@ -127,6 +128,10 @@ import { submitProjectSupervisorRuntimeTask, taskRowsFromManifest, } from './features/agent-runtime'; +import { + type DirectCodexTurnAttachment, + toDirectCodexTurnAttachments, +} from './features/app-shell/directCodexTurnAttachments'; import { isDeveloperMode, isTransientProjectOpenMessage, @@ -407,6 +412,7 @@ type AppProps = { supervisorChatOnly?: boolean; initialSupervisorMessage?: string; initialCreationType?: HomeCreationType | null; + initialAttachments?: LauncherImportedAttachment[]; playRequest?: ProjectSupervisorComponentProps['playRequest']; onPlayRequestHandled?: ProjectSupervisorComponentProps['onPlayRequestHandled']; onManifestChange?: ( @@ -421,6 +427,13 @@ type AppProps = { onAgentResultsChange?: (results: ProjectAgentResultSummary[]) => void; }; +type ExecuteChatAgentReplyInput = { + prompt: string; + clientTurnId?: string; + creationType?: HomeCreationType | null; + attachments?: DirectCodexTurnAttachment[]; +}; + export function App({ initialProjectPath: initialProjectPathOverride = '', initialProjectManifest, @@ -431,6 +444,7 @@ export function App({ supervisorChatOnly = false, initialSupervisorMessage = '', initialCreationType = null, + initialAttachments = [], playRequest = null, onPlayRequestHandled, onManifestChange, @@ -493,6 +507,7 @@ export function App({ projectPath: initialProjectPath, prompt: initialSupervisorMessage.trim(), creationType: initialCreationType, + attachments: toDirectCodexTurnAttachments(initialAttachments), }); const handledPlayRequestRef = useRef(null); @@ -988,11 +1003,7 @@ export function App({ | null >(null); const executeChatAgentReplyRef = useRef< - ( - prompt: string, - directConversationTurnId?: string, - creationType?: HomeCreationType | null, - ) => Promise + (input: ExecuteChatAgentReplyInput) => Promise >(async () => undefined); const agentConversationSavingRef = useRef(false); const agentConversationBackgroundBusyRef = useRef(false); @@ -2751,10 +2762,10 @@ export function App({ return; } recoveredDirectCodexTurnClaimsRef.current.add(claimKey); - void executeChatAgentReply( - unansweredDirectTurn.prompt, - unansweredDirectTurn.turnId, - ); + void executeChatAgentReply({ + prompt: unansweredDirectTurn.prompt, + clientTurnId: unansweredDirectTurn.turnId, + }); }); } } catch (error) { @@ -5223,7 +5234,7 @@ export function App({ return; } - void executeChatAgentReply(prompt); + void executeChatAgentReply({ prompt }); } async function executeLlmConfigStatus() { @@ -5370,11 +5381,12 @@ export function App({ } } - async function executeChatAgentReply( - prompt: string, - directConversationTurnId?: string, - creationType?: HomeCreationType | null, - ) { + async function executeChatAgentReply({ + prompt, + clientTurnId: directConversationTurnId, + creationType, + attachments, + }: ExecuteChatAgentReplyInput) { // Product default: send the conversation directly to Codex app-server. // The legacy Supervisor/harness path remains below for rollback and tests. if (directCodexProductRuntime) { @@ -5484,6 +5496,7 @@ export function App({ prompt: string; clientTurnId: string; creationType?: HomeCreationType; + attachments?: DirectCodexTurnAttachment[]; } = { projectPath: directProjectPath, prompt, @@ -5492,6 +5505,9 @@ export function App({ if (creationType) { directTurnInput.creationType = creationType; } + if (attachments?.length) { + directTurnInput.attachments = attachments; + } const reply = await directInvoke( 'chat_with_game_creator_direct_codex', directTurnInput, @@ -5809,11 +5825,12 @@ export function App({ updatedAt: Date.now(), }, ]); - void executeChatAgentReplyRef.current( - latch.prompt, - directConversationTurnId, - latch.creationType, - ); + void executeChatAgentReplyRef.current({ + prompt: latch.prompt, + clientTurnId: directConversationTurnId, + creationType: latch.creationType, + attachments: latch.attachments, + }); }, [ chatAgentBusy, directCodexProductRuntime, @@ -10824,7 +10841,10 @@ export function App({ updatedAt: Date.now(), }, ]); - void executeChatAgentReply(prompt, directConversationTurnId); + void executeChatAgentReply({ + prompt, + clientTurnId: directConversationTurnId, + }); } const visibleProfessionalAgentCards = agentStatusCards.filter( diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 23474fb71..aaf922b5d 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -48,6 +48,7 @@ export type LauncherImportedAttachment = { localPath?: string; status: 'imported' | 'failed'; error?: string; + size?: number; }; export type LocalProjectKind = 'web' | 'godot'; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 6d63481a4..dda4ed6d3 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -338,6 +338,7 @@ export function WorkspaceLauncherShell({ initialProjectKind={currentProjectContext.projectKind} initialSupervisorMessage={currentProjectContext.initialPrompt} initialCreationType={currentProjectContext.creationType} + initialAttachments={currentProjectContext.attachments} orchestrationMode="single-supervisor" projectSupervisorOnly planningStartMode={ diff --git a/apps/ai-game-creator-shell/src/features/app-shell/directCodexTurnAttachments.ts b/apps/ai-game-creator-shell/src/features/app-shell/directCodexTurnAttachments.ts new file mode 100644 index 000000000..ece47fd13 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/app-shell/directCodexTurnAttachments.ts @@ -0,0 +1,38 @@ +import type { LauncherImportedAttachment } from '../../app/types'; + +export type DirectCodexTurnAttachment = { + name: string; + mediaType: string; + size?: number; + localPath?: string; + status?: 'imported' | 'failed'; +}; + +export function toDirectCodexTurnAttachments( + imported: readonly LauncherImportedAttachment[] | null | undefined, +): DirectCodexTurnAttachment[] { + if (!imported?.length) { + return []; + } + return imported.map((item) => { + const attachment: DirectCodexTurnAttachment = { + name: item.fileName, + mediaType: item.mediaType, + }; + if ( + typeof item.size === 'number' && + Number.isFinite(item.size) && + item.size >= 0 + ) { + attachment.size = Math.trunc(item.size); + } + const localPath = item.localPath?.trim(); + if (localPath) { + attachment.localPath = localPath; + } + if (item.status === 'imported' || item.status === 'failed') { + attachment.status = item.status; + } + return attachment; + }); +} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index f87101410..e7d5c66bc 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -5,7 +5,11 @@ import type { GameCreationAppManifest, GameCreationAppPreviewState, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; -import type { ChatMessage, LocalProjectDirectoryStatus } from '../../app/types'; +import type { + ChatMessage, + LauncherImportedAttachment, + LocalProjectDirectoryStatus, +} from '../../app/types'; import type { HomeCreationType } from '../../view/home'; import type { LauncherView } from '../../view/layout'; import type { @@ -32,6 +36,7 @@ export type ProjectSupervisorComponentProps = { initialProjectKind?: 'web' | 'godot'; initialSupervisorMessage?: string; initialCreationType?: HomeCreationType | null; + initialAttachments?: LauncherImportedAttachment[]; orchestrationMode?: 'single-supervisor' | 'professional-dag'; projectSupervisorOnly?: boolean; planningStartMode?: boolean; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index b5f9e13cd..08f0c4d93 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -145,6 +145,7 @@ export function useHomeProjectCreation({ mediaType, localPath: result.localPath, status: 'imported', + size: attachment.file.size, }); } catch (error) { imported.push({ @@ -152,6 +153,7 @@ export function useHomeProjectCreation({ mediaType, status: 'failed', error: error instanceof Error ? error.message : String(error), + size: attachment.file.size, }); } } 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 6ab9c7a13..896090b5e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1495,11 +1495,44 @@ export function registerHomeProjectCreationTests() { prompt: '按这个角色做游戏', creationType: 'game', clientTurnId: expect.any(String), + 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', expect.anything(), ); + + expect(await screen.findByText('附件已经进入当前项目。')).not.toBeNull(); + const followUpInput = screen.getByLabelText('陶泥儿对话内容'); + fireEvent.change(followUpInput, { target: { value: '再补一句玩法' } }); + fireEvent.submit(followUpInput.closest('form') as HTMLFormElement); + await waitFor(() => { + expect( + invoke.mock.calls.filter( + ([command]) => command === 'chat_with_game_creator_direct_codex', + ), + ).toHaveLength(2); + }); + expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', { + projectPath: automaticProjectPath, + prompt: '再补一句玩法', + clientTurnId: expect.any(String), + }); + const followUpPayload = invoke.mock.calls.find( + ([command, args]) => + command === 'chat_with_game_creator_direct_codex' && + (args as Record | undefined)?.prompt === + '再补一句玩法', + )?.[1] as Record | undefined; + expect(followUpPayload).not.toHaveProperty('attachments'); }); it('keeps the home composer out of chat mode while automatic project creation is pending', async () => { @@ -1629,6 +1662,18 @@ export function registerHomeProjectCreationTests() { } else { expect(startCall?.[1]).not.toHaveProperty('source'); } + expect(startCall?.[1]).not.toHaveProperty('attachments'); + expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain( + '本轮用户附件', + ); + expect(invoke).not.toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'chat_with_game_creator_agent', + expect.anything(), + ); expect( invoke.mock.calls.filter( ([command]) => command === 'create_automatic_local_game_project', @@ -1637,6 +1682,98 @@ export function registerHomeProjectCreationTests() { }, ); + it('keeps 做方案 first turn on Supervisor without a Direct attachment sidecar', async () => { + const projectPath = '/tmp/home-planning-attachment'; + const manifest = createGameCreationAppManifest( + 'home-planning-attachment', + '首页策划附件', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + expectedRunProfile: 'standard', + }); + const fileBytes = Array.from(new TextEncoder().encode('png')); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'create_automatic_local_game_project') { + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'upload_local_asset') { + return { + id: 'asset-upload-plan-1', + localPath: 'assets/uploads/reference.png', + absolutePath: `${projectPath}/assets/uploads/reference.png`, + manifestPath: `${projectPath}/.agent/manifest.json`, + }; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherAt('/?launcher', 'home', true); + + fireEvent.click(screen.getByRole('button', { name: '做方案' })); + const fileInput = + document.querySelector('input[type="file"]'); + expect(fileInput).not.toBeNull(); + const attachment = new File(['png'], '角色参考.png', { + type: 'image/png', + lastModified: 1, + }); + Object.defineProperty(attachment, 'arrayBuffer', { + value: async () => new Uint8Array(fileBytes).buffer, + }); + fireEvent.change(fileInput!, { target: { files: [attachment] } }); + + const promptInput = screen.getByLabelText('创作想法'); + nativeClipboardMock.text = '整理一份可玩原型'; + fireEvent.paste(promptInput); + await waitFor(() => { + expect(promptInput.textContent).toContain('整理一份可玩原型'); + expect(promptInput.textContent).toContain('角色参考.png'); + }); + fireEvent.click(screen.getByRole('button', { name: '进入立项策划' })); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'start_game_creator_supervisor_runtime_task', + expect.objectContaining({ + projectPath, + source: PROJECT_SUPERVISOR_PLAN_SOURCE, + }), + ); + }); + expect(invoke).toHaveBeenCalledWith('upload_local_asset', { + projectPath, + fileName: '角色参考.png', + mediaType: 'image/png', + bytes: fileBytes, + }); + const startCall = invoke.mock.calls.find( + ([command]) => command === 'start_game_creator_supervisor_runtime_task', + ); + expect(startCall?.[1]).not.toHaveProperty('attachments'); + expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain('本轮用户附件'); + expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain( + 'assets/uploads/reference.png', + ); + expect(invoke).not.toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'chat_with_game_creator_agent', + expect.anything(), + ); + }); + it('surfaces the planning clarification card after 做方案 creates the project from home', async () => { // 上面那条只断言到「run 起来了、source 对」。真实故障恰好落在它之后:plan 根 run // 停在 waiting-for-user-input 并带回澄清请求,而工作台一直停在前端本地的占位文案, diff --git a/apps/ai-game-creator-shell/tests/directCodexTurnAttachments.test.ts b/apps/ai-game-creator-shell/tests/directCodexTurnAttachments.test.ts new file mode 100644 index 000000000..7ddc3a0c1 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directCodexTurnAttachments.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { toDirectCodexTurnAttachments } from '../src/features/app-shell/directCodexTurnAttachments'; + +describe('toDirectCodexTurnAttachments', () => { + it('maps imported files and omits error plus empty localPath', () => { + expect( + toDirectCodexTurnAttachments([ + { + fileName: '角色参考.png', + mediaType: 'image/png', + localPath: 'assets/uploads/upload-1-角色参考.png', + status: 'imported', + size: 12, + }, + { + fileName: 'lost.bin', + mediaType: 'application/octet-stream', + status: 'failed', + error: '磁盘不可写', + size: 2, + }, + ]), + ).toEqual([ + { + name: '角色参考.png', + mediaType: 'image/png', + size: 12, + localPath: 'assets/uploads/upload-1-角色参考.png', + status: 'imported', + }, + { + name: 'lost.bin', + mediaType: 'application/octet-stream', + size: 2, + status: 'failed', + }, + ]); + }); + + it('returns an empty list when there is nothing to map', () => { + expect(toDirectCodexTurnAttachments(undefined)).toEqual([]); + expect(toDirectCodexTurnAttachments([])).toEqual([]); + }); +}); diff --git a/docs/README.md b/docs/README.md index 7b24f1cda..067562a40 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,7 @@ ## AI 游戏创作与 Agent Runtime - [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。 +- [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。 - [项目开发工作台 PRD](./prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md):当前工作台页面和验收边界。 - [立项策划 Agent(Fast GDD)](<./technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md>):当前策划入口、审批和恢复合同。 - [GameAgent 资源自由画板与快速编辑](./technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index cdde4db17..9f87b0c18 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-08-31 Direct 本轮附件只映射路径,不灌正文、不区别 GDD + +- 背景:issue #212。首页附件已经复制到 `assets/uploads/` 并登记,但 Direct 首轮只把用户原文发给 Codex,原文件名不是磁盘路径,模型会另起一套玩法。 +- 决策:Home 与 Project 共用 `DirectCodexTurnAttachment`。有项目路径或导入状态时,只在发给 Codex 的 user prompt 末尾附有界 sidecar(原名 → 项目相对路径、类型、大小、状态);无路径且无状态时保持首页元数据文案。不灌正文、不强制读取、不按 GDD 开特例。做成游戏固定 prompt 不改,同一条 Direct 首轮附件链自动吃到 sidecar。jsonl 与工作台气泡仍只写用户原文。 +- 影响范围:`direct_codex_attachments.rs`、Direct command 边界、首页建项 latch、工作台首轮 invoke;Supervisor / 做方案首轮忽略附件 sidecar。 +- 验证方式:Rust 渲染测试(Home 逐字兼容、Project 映射、非法路径);home.suite 附件 Direct invoke 含 `localPath`;无附件不出现 `attachments` 键;做方案首轮仍走 Supervisor 且无 sidecar;后续手打消息不带 attachments。 +- 关联文档:`docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md`、issue #212。 + ## 2026-08-27 `plan.submit_gdd` 拒绝无审批决定的 `user_revision` - 背景:结构校验允许 `round=0 + user_revision + confirmed`,提交闸原先只做结构、身份和 Session CAS。Provider 可在首次 collecting、澄清续跑或提交前质量返工里把未确认项标成用户审批修改,审批卡显示「已确认」。 diff --git a/docs/project-memory/shared-memory/document-map.md b/docs/project-memory/shared-memory/document-map.md index 5fbb8fe37..86289e98a 100644 --- a/docs/project-memory/shared-memory/document-map.md +++ b/docs/project-memory/shared-memory/document-map.md @@ -1,6 +1,6 @@ # 文档地图与阅读索引 -更新时间:`2026-08-25` +更新时间:`2026-08-31` ## 阅读顺序 @@ -24,9 +24,10 @@ AI 游戏创作 / DirectProject / UI workflow: 1. `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 2. `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md` 3. `docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md` -4. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md` -5. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md` -6. UI 编辑器、宿主壳和当前测试专题文档 +4. `docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md` +5. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md` +6. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md` +7. UI 编辑器、宿主壳和当前测试专题文档 图片画布 / 媒体生成: diff --git a/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md b/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md new file mode 100644 index 000000000..c80f6e27e --- /dev/null +++ b/docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md @@ -0,0 +1,262 @@ +# DirectProject 本轮附件路径映射 + +- 日期:2026-08-31 +- 状态:现行合同(已按本文落地) +- 问题:Gitea issue #212(DirectProject 未消费用户上传权威文档) +- 关联入口:PR #210「批准 GDD 回填做游戏入口」(`feat/create_entrance`,未合入时仍按该 PR 的调用链理解) +- 原则:落地后代码简洁可维护,不为了 diff 最小而打补丁;附件一律同等对待,不给 GDD 开协议特例 + +## 0. 一句话 + +首页带进项目的附件已经复制并登记,但 Direct 首轮只拿到用户原文。本方案让 Direct 回合在发给 Codex 的 user prompt 末尾附上**有界路径映射**(原文件名 → 项目相对路径),不灌正文、不强制读取、不改做方案注入。 + +## 1. 目标与非目标 + +### 目标 + +1. Direct 首轮知道本轮用户附件的原文件名、项目相对路径、媒体类型、大小和导入状态。 +2. 用户原文不被改写;路径映射是独立 sidecar。 +3. 图片、Markdown、其它文件走同一条协议。 +4. 做成游戏(PR #210:读 `game/fast_gdd.md` → 当成 `fast_gdd.md` 附件 → `createHomeDraftAutomatically`)自动吃到效果,因为那条链就是「带附件的 Direct 首轮」。 + +### 非目标 + +- 不改 PR #210 的按钮、固定 prompt、`startGameFromApprovedGdd`、读 `game/fast_gdd.md` 的方式。 +- 不改 `approvedGddRef`、审批 receipt、策划项目里的 `game/fast_gdd.md` 投影。 +- 不改上传命名 `assets/uploads/upload--`。 +- 不把附件全文拼进 prompt,不按扩展名决定是否读取。 +- 不把「没读到就阻断」做成门禁。 +- 不扫 manifest 里历史 `kind=uploaded`。 +- 不做 native 读取审计(issue #212 的第二问题,另排期)。 +- 不改 DirectHome 在「无项目路径」时的现有文案和列表格式。 +- 不改 `enterCreatedHomeProject` 的空正文兜底句(与做方案共用)。 + +## 2. 现行断点 + +```text +Home 附件 / 做成游戏 File(fast_gdd.md) + → upload_local_asset + → LauncherProjectContext.attachments // 已有,只给资源画布 + → ProjectSupervisor // 无 attachments 字段 + → chat_with_game_creator_direct_codex + { projectPath, prompt, clientTurnId, creationType? } +``` + +做成游戏的固定 prompt 仍写「附件中的 `fast_gdd.md`」,磁盘文件却是 `assets/uploads/upload--fast_gdd.md`。映射没有进 Direct。 + +DirectHome 已有 `{ name, mediaType, size }` 元数据注入,但标明「尚未打开项目,内容尚不可读取」。项目已落盘后这条元数据被丢掉。 + +## 3. 目标合同 + +### 3.1 唯一 DTO + +Home 与 Project 共用一个附件结构,缺省字段表示 Home 现状: + +```ts +{ + name: string; // 原文件名 + mediaType: string; + size?: number; // 缺省按 0 + localPath?: string; // 仅已落入项目时出现 + status?: 'imported' | 'failed'; +} +``` + +- 不把 `error` 字符串送给模型。 +- 前端 `LauncherImportedAttachment` 继续给画布;invoke 前映射成上述瘦 DTO。 +- `importHomeAttachments` 把 `File.size` 写入可选 `size`,不改 `upload_local_asset` 返回值。 + +Rust: + +```rust +struct DirectCodexTurnAttachment { + name: String, + media_type: String, + #[serde(default)] + size: u64, + #[serde(default)] + local_path: Option, + #[serde(default)] + status: Option, // 只接受 imported | failed,其它忽略 +} +``` + +`DirectCodexHomeAttachment` 删除,Home command 改用同一类型。现有 Home JSON(无 `localPath` / `status`)继续能反序列化。 + +### 3.2 渲染 + +一个函数 `render_direct_codex_user_prompt(prompt, attachments) -> Result`: + +| 输入 | 输出 | +|---|---| +| 无附件 | `prompt.trim()`;若也空则 `Err("聊天内容不能为空")` | +| 附件都没有 `localPath` 且都没有 `status` | 保持现有 Home 文案与行格式,测试须逐字兼容 | +| 任一条有 `localPath` 或 `status` | Project 头 + Project 行格式 | + +Home 行(禁止改字): + +```text +[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取] +- {name};类型:{mediaType};大小:{n} 字节 +``` + +Project 头与行(禁止出现 GDD / 规格 / 权威 / 必须读取): + +```text +<用户原文> + +[本轮用户附件:已复制到当前项目。请用「项目路径」读取;原文件名不是磁盘路径。] +- 原文件名:fast_gdd.md;项目路径:assets/uploads/upload-1788083777445-fast_gdd.md;类型:text/markdown;大小:7944 字节;状态:imported +``` + +规则: + +- 条数上限仍为 8,超出写 `- 另有 N 个附件未展开`。 +- 名字清洗沿用 Home:basename、去掉控制字符、最多 160 字、空则「未命名附件」。 +- 媒体类型清洗沿用 Home。 +- `localPath` 只接受项目相对 POSIX 路径:无 `..`、无盘符/根路径、首段不是 `.agent` / `.git`,`\` 归一为 `/`,最长 512;不合法则该条不输出路径,状态按 `failed`。 +- 有附件时允许原文为空(Home 已如此)。Direct 内层若仍要求非空,在 command 边界先渲染再下传,避免空原文 + 有附件被拒。 + +不在 sidecar 里写「若用户要求按附件实施请先读取」。意图留在用户原文;做成游戏的固定 prompt 已经在说这件事。 + +### 3.3 谁渲染、谁看见 + +- sidecar **只在进 Codex 前由 Rust 拼装**。 +- `.agent/conversations/project.jsonl` 继续写用户原文(现有 `append_local_conversation_message`)。 +- 工作台气泡继续显示 latch / 输入框原文,不把 sidecar 画进 UI。 +- 未完成首轮的 hydration 重放目前只带 prompt:本期不把附件写进 jsonl,进程重启后的未完成首轮可能丢映射。完整成功首轮不受影响。不为此新增会话 schema。 + +## 4. 数据流 + +```text +Home 上传 / 做成游戏 File + → upload_local_asset(已有) + → LauncherProjectContext.attachments(已有) + → ProjectSupervisor.initialAttachments + → 首轮 latch(与 prompt、creationType 同级) + → 仅 chat_with_game_creator_direct_codex.attachments + → render_direct_codex_user_prompt + → 现有 Direct turn(cwd = 项目根) +``` + +Supervisor / 做方案首轮忽略 `attachments`,行为不变。 + +后续工作台手打消息不带 `attachments`。附件是这一轮带来的,不是项目终身上下文。 + +## 5. 代码落地(按最终结构,不按最小补丁) + +### 5.1 Rust + +新增 [`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs): + +- DTO、清洗、上限常量、`render_direct_codex_user_prompt` +- 单元测试(见第 6 节) + +[`agent.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent.rs) 增加 `mod direct_codex_attachments`。 + +[`direct_runtime.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs): + +- 删除 Home 专用 struct / sanitizer / `render_direct_codex_home_user_prompt` +- `run_direct_game_creator_home_turn` 改为调用共享渲染 +- `chat_with_game_creator_direct_codex` 增加 `attachments: Option>`,先渲染再调用现有 `run_direct_game_creator_turn_at_with_creation_type_and_emitter` +- 把现有 Home 渲染测试迁到新文件;本文件不再保留一份平行实现 + +不要把 attachments 顺着 inner turn / emitter / CLI 往下传。CLI `run_direct_game_creator_turn_at` 不变。 + +### 5.2 前端 + +[`model.ts`](../../apps/ai-game-creator-shell/src/features/app-shell/model.ts) `ProjectSupervisorComponentProps` 增加: + +```ts +initialAttachments?: LauncherImportedAttachment[]; +``` + +[`WorkspaceLauncher.tsx`](../../apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx): + +```ts +initialAttachments={currentProjectContext.attachments} +``` + +[`App.tsx`](../../apps/ai-game-creator-shell/src/App.tsx): + +- props / latch 增加 `attachments`(默认 `[]`) +- `executeChatAgentReply` 不要再加第 5 个位置参数,收成: + +```ts +{ + prompt: string; + clientTurnId?: string; + creationType?: HomeCreationType | null; + attachments?: DirectCodexTurnAttachment[]; +} +``` + +- Direct invoke:有 `creationType` 才写该字段(现有);`attachments?.length` 才写 `attachments` +- Supervisor 分支完全不读 `attachments` +- 现有 `executeChatAgentReply(prompt)` / 恢复未完成 turn 的调用改为对象形式,不传 attachments + +瘦映射不要写在 1 万行的 `App.tsx` 里,放到例如 [`apps/ai-game-creator-shell/src/features/app-shell/directCodexTurnAttachments.ts`](../../apps/ai-game-creator-shell/src/features/app-shell/directCodexTurnAttachments.ts):`toDirectCodexTurnAttachments(imported)`,去掉 `error`,空 `localPath` 不输出该键。 + +[`useHomeProjectCreation.ts`](../../apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts):`importHomeAttachments` 写入 `size: attachment.file.size`。不改 `initialPrompt` 兜底句,不改做成游戏(即便本分支尚未合入 PR #210,也不预埋 GDD 字段)。 + +[`types.ts`](../../apps/ai-game-creator-shell/src/app/types.ts):`LauncherImportedAttachment` 增加可选 `size?: number`。 + +### 5.3 文档 + +落地提交时(不是本方案文件自身): + +- 本文件标为现行合同 +- `docs/README.md`、`docs/project-memory/shared-memory/document-map.md` 增加条目 +- `decision-log.md` 记一条:Direct 本轮附件只映射路径,不灌正文、不区别 GDD +- 不把 issue #212 技术说明改写成「已修复」,等代码合入后再改状态 + +## 6. 测试 + +### Rust(新文件) + +1. 无附件:原文 trim 后原样返回;空原文报错。 +2. Home 形态(无 path、无 status):与迁过来的两条现有测试逐字一致(含路径剥离、非法 mediaType、8 条上限)。 +3. Project 形态:原文保留;含原名与 `assets/uploads/...`;**断言不得出现「GDD」「规格」「权威」**。 +4. 同一列表里 png 与 md 行格式相同(只是 name/path/type 不同)。 +5. `status=failed` 且无 path:有状态、无项目路径、无 error 正文。 +6. 非法 `localPath`(`../`、`.agent/x`、绝对路径)不出现在输出中。 +7. 空原文 + 有附件:成功,且含 Project 头。 + +### 前端 + +1. [`home.suite.ts`](../../apps/ai-game-creator-shell/tests/appSurface/home.suite.ts)「imports home attachments…」:Direct invoke 必须带 `attachments`,其中 `name` 为 `角色参考.png`、`localPath` 为 upload 返回路径、`status: 'imported'`。用 png 证明不是 md 特例。 +2. 无附件的 Direct invoke 仍不得出现 `attachments` 键(或等价:不传该字段)。 +3. `planningStartMode` 首轮仍走 Supervisor,`chat_with_game_creator_agent` 的 payload 不含附件 sidecar。 +4. 工作台后发的普通消息:`chat_with_game_creator_direct_codex` 只有 `projectPath/prompt/clientTurnId`(及既有 creationType 规则),不带 attachments。 +5. 若本分支已能跑 PR #210 的 home.suite / plan-gdd 做成游戏用例:只断言它仍调用 `createHomeDraftAutomatically` / 仍使用原固定 prompt;**不要**给做成游戏加第二条附件协议。sidecar 由通用 Direct 断言覆盖。 + +### 不测 + +- 不把「必须生成弹幕射击」写成单测。 +- 不测 native `file.read` 是否进 `agent.db`。 + +## 7. 做成游戏为什么不用改 + +PR #210 `startGameFromApprovedGdd`: + +1. 读策划项目 `game/fast_gdd.md` +2. `new File([content], 'fast_gdd.md')` +3. `createHomeDraftAutomatically({ creationType: 'game', prompt: APPROVED_GDD_BUILD_PROMPT, attachments }, 'direct-build')` + +之后与首页拖一个 md 完全相同。sidecar 见到的是「原文件名 `fast_gdd.md` + 新项目 `assets/uploads/upload-…-fast_gdd.md`」。固定 prompt 继续说「读附件中的 fast_gdd.md」,映射补上真实路径。 + +## 8. 验收 + +1. 首页做游戏:上传任意文本或图片 + 一句话,Direct 首轮 prompt 含原名和 `assets/uploads/...`。 +2. 做成游戏(PR #210 合入后或该分支上):固定 prompt 一字不改,同时出现改写后的项目路径。 +3. 做方案首轮:Supervisor 行为与现在一致,无 sidecar。 +4. 无附件:Direct 入参与现在一致。 +5. `npm run check:encoding`、`git diff --check`、相关 `home.suite` / Direct Rust 测试通过。 + +## 9. 实现顺序 + +1. Rust 共享渲染 + 迁 Home 测试 + Direct command 接 `attachments` +2. 前端 latch / invoke / 映射 / `size` +3. 改 `home.suite` 附件断言 +4. encoding 与定向测试 +5. 合入时补 decision-log 与文档索引