让 Direct 首轮带上本轮附件的项目路径映射
抽出 Home/Project 共用的 DirectCodexTurnAttachment 与渲染函数 Direct command 在进 Codex 前拼接有界 sidecar,jsonl 与气泡仍写用户原文 首页建项 latch 把导入附件传给 Direct 首轮,后续手打消息不带 attachments 做方案首轮仍走 Supervisor,不注入 sidecar 补齐 Rust 渲染测试与 home.suite 附件断言 记录路径映射合同与决策
This commit is contained in:
@@ -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::*;
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(default)]
|
||||
status: Option<String>,
|
||||
}
|
||||
|
||||
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::<String>();
|
||||
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<String> {
|
||||
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<String, String> {
|
||||
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::<Vec<_>>();
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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::<String>();
|
||||
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<String, String> {
|
||||
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<DirectCodexHomeReply, String> {
|
||||
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<String>,
|
||||
client_turn_id: Option<String>,
|
||||
attachments: Option<Vec<DirectCodexTurnAttachment>>,
|
||||
) -> Result<String, String> {
|
||||
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<Vec<DirectCodexHomeAttachment>>,
|
||||
attachments: Option<Vec<DirectCodexTurnAttachment>>,
|
||||
) -> Result<DirectCodexHomeReply, String> {
|
||||
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::<Vec<_>>();
|
||||
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!(
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
@@ -988,11 +1003,7 @@ export function App({
|
||||
| null
|
||||
>(null);
|
||||
const executeChatAgentReplyRef = useRef<
|
||||
(
|
||||
prompt: string,
|
||||
directConversationTurnId?: string,
|
||||
creationType?: HomeCreationType | null,
|
||||
) => Promise<void>
|
||||
(input: ExecuteChatAgentReplyInput) => Promise<void>
|
||||
>(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<string>(
|
||||
'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(
|
||||
|
||||
@@ -48,6 +48,7 @@ export type LauncherImportedAttachment = {
|
||||
localPath?: string;
|
||||
status: 'imported' | 'failed';
|
||||
error?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export type LocalProjectKind = 'web' | 'godot';
|
||||
|
||||
@@ -338,6 +338,7 @@ export function WorkspaceLauncherShell({
|
||||
initialProjectKind={currentProjectContext.projectKind}
|
||||
initialSupervisorMessage={currentProjectContext.initialPrompt}
|
||||
initialCreationType={currentProjectContext.creationType}
|
||||
initialAttachments={currentProjectContext.attachments}
|
||||
orchestrationMode="single-supervisor"
|
||||
projectSupervisorOnly
|
||||
planningStartMode={
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> | undefined)?.prompt ===
|
||||
'再补一句玩法',
|
||||
)?.[1] as Record<string, unknown> | 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<string, unknown>) => {
|
||||
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<HTMLInputElement>('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 并带回澄清请求,而工作台一直停在前端本地的占位文案,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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、澄清续跑或提交前质量返工里把未确认项标成用户审批修改,审批卡显示「已确认」。
|
||||
|
||||
@@ -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 编辑器、宿主壳和当前测试专题文档
|
||||
|
||||
图片画布 / 媒体生成:
|
||||
|
||||
|
||||
@@ -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-<ts>-<name>`。
|
||||
- 不把附件全文拼进 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-<ts>-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<String>,
|
||||
#[serde(default)]
|
||||
status: Option<String>, // 只接受 imported | failed,其它忽略
|
||||
}
|
||||
```
|
||||
|
||||
`DirectCodexHomeAttachment` 删除,Home command 改用同一类型。现有 Home JSON(无 `localPath` / `status`)继续能反序列化。
|
||||
|
||||
### 3.2 渲染
|
||||
|
||||
一个函数 `render_direct_codex_user_prompt(prompt, attachments) -> Result<String, String>`:
|
||||
|
||||
| 输入 | 输出 |
|
||||
|---|---|
|
||||
| 无附件 | `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<Vec<DirectCodexTurnAttachment>>`,先渲染再调用现有 `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 与文档索引
|
||||
Reference in New Issue
Block a user