Merge branch 'master' into codex/admin-database-table-query
This commit is contained in:
@@ -12,6 +12,8 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
mod codex_app_server;
|
||||
mod codex_cli;
|
||||
mod codex_provider_proxy;
|
||||
mod direct_codex_attachments;
|
||||
mod direct_codex_audit;
|
||||
mod direct_runtime;
|
||||
mod direct_tool_bridge;
|
||||
mod direct_tools_mcp;
|
||||
@@ -34,6 +36,8 @@ 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_codex_audit::*;
|
||||
pub(crate) use direct_runtime::*;
|
||||
pub(crate) use direct_tool_bridge::*;
|
||||
pub(crate) use direct_tools_mcp::*;
|
||||
|
||||
@@ -1905,8 +1905,15 @@ impl CodexAppServerConnection {
|
||||
request: LlmRunRequest,
|
||||
on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
|
||||
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
|
||||
self.run_turn_with_direct_observer(snapshot, llm, request, on_agent_message_delta, None)
|
||||
.await
|
||||
self.run_turn_with_direct_observer(
|
||||
snapshot,
|
||||
llm,
|
||||
request,
|
||||
on_agent_message_delta,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_turn_with_direct_observer(
|
||||
@@ -1916,6 +1923,7 @@ impl CodexAppServerConnection {
|
||||
request: LlmRunRequest,
|
||||
mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
|
||||
mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
|
||||
mut audit: Option<&mut DirectCodexTurnAudit>,
|
||||
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
|
||||
let _turn_guard = self.inner.turn_gate.lock().await;
|
||||
let thread_lease = self.thread_for(snapshot, &request, llm).await?;
|
||||
@@ -2085,6 +2093,11 @@ impl CodexAppServerConnection {
|
||||
completed,
|
||||
¶ms,
|
||||
);
|
||||
if completed {
|
||||
if let Some(audit) = audit.as_mut() {
|
||||
audit.observe_item(¶ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
if item_type == "agentMessage" {
|
||||
if let Some(text) = item
|
||||
@@ -2796,8 +2809,14 @@ pub(crate) async fn direct_game_creator_codex_chat_at(
|
||||
system_prompt: String,
|
||||
user_prompt: String,
|
||||
) -> Result<String, String> {
|
||||
direct_game_creator_codex_chat_at_with_optional_observer(root, system_prompt, user_prompt, None)
|
||||
.await
|
||||
direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
root,
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn direct_game_creator_codex_chat_at_with_observer(
|
||||
@@ -2811,6 +2830,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer(
|
||||
system_prompt,
|
||||
user_prompt,
|
||||
Some(observer),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -2861,11 +2881,12 @@ fn direct_codex_project_identity_digest(path_identity: &[u8], project_id: &[u8])
|
||||
format!("{:x}", digest.finalize())
|
||||
}
|
||||
|
||||
async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
root: &std::path::Path,
|
||||
system_prompt: String,
|
||||
user_prompt: String,
|
||||
observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
|
||||
audit: Option<&mut DirectCodexTurnAudit>,
|
||||
) -> Result<String, String> {
|
||||
// Resolve project authority before deriving the pool/thread identity. A
|
||||
// caller may hold a stable symlink path whose target changes between
|
||||
@@ -2917,7 +2938,7 @@ async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
connection
|
||||
.run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer)
|
||||
.run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer, audit)
|
||||
.await
|
||||
.map(|value| value.text)
|
||||
.map_err(|error| error.to_string())
|
||||
@@ -4245,6 +4266,7 @@ while IFS= read -r line; do :; done
|
||||
tool_request(),
|
||||
Some(&mut on_delta),
|
||||
Some(&mut observer),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("run fake app-server turn");
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
//! Direct Codex 本轮附件 sidecar:Home 与 Project 共用同一 DTO 和渲染函数。
|
||||
//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。
|
||||
|
||||
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8;
|
||||
const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160;
|
||||
const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
|
||||
const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512;
|
||||
|
||||
const HOME_ATTACHMENT_HEADER: &str =
|
||||
"[首页附件说明:当前尚未打开项目,以下仅为附件元数据,附件内容尚不可读取]";
|
||||
const PROJECT_ATTACHMENT_HEADER: &str =
|
||||
"[本轮用户附件:已复制到当前项目。请用「项目路径」读取;原文件名不是磁盘路径。]";
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DirectCodexTurnAttachment {
|
||||
pub(crate) name: String,
|
||||
pub(crate) media_type: String,
|
||||
#[serde(default)]
|
||||
pub(crate) size: u64,
|
||||
#[serde(default)]
|
||||
pub(crate) local_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) status: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) 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
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) 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()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_attachment_status(value: Option<&str>) -> Option<&'static str> {
|
||||
match value.map(str::trim) {
|
||||
Some("imported") => Some("imported"),
|
||||
Some("failed") => Some("failed"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) 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)
|
||||
}
|
||||
|
||||
pub(crate) 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())
|
||||
|| sanitize_attachment_status(attachment.status.as_deref()).is_some()
|
||||
})
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_status_keeps_home_attachment_metadata_shape() {
|
||||
let attachment =
|
||||
project_attachment("pending.md", "text/markdown", 1, None, Some("pending"));
|
||||
let prompt = render_direct_codex_user_prompt("x", &[attachment]).expect("render");
|
||||
assert!(prompt.contains(HOME_ATTACHMENT_HEADER));
|
||||
assert!(!prompt.contains(PROJECT_ATTACHMENT_HEADER));
|
||||
assert!(!prompt.contains("状态:"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。";
|
||||
@@ -2177,9 +2174,7 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec<String> {
|
||||
fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec<String> {
|
||||
let sources = direct_codex_game_outputs(root)
|
||||
.into_iter()
|
||||
.filter_map(|(relative_path, _, _)| {
|
||||
std::fs::read_to_string(root.join(relative_path)).ok()
|
||||
})
|
||||
.filter_map(|(relative_path, _, _)| std::fs::read_to_string(root.join(relative_path)).ok())
|
||||
.collect::<Vec<_>>();
|
||||
let mut available_paths = Vec::new();
|
||||
if direct_taonier_art_base_is_valid(root) {
|
||||
@@ -2267,9 +2262,7 @@ fn direct_browser_evidence_needs_art_repair(
|
||||
fn direct_game_output_completion_error(root: &Path) -> Option<String> {
|
||||
let entry = agent_runtime_game_entry_relative_path(root);
|
||||
if !root.join(entry).is_file() {
|
||||
return Some(format!(
|
||||
"Codex 返回后未找到 {entry},项目未进入可运行状态"
|
||||
));
|
||||
return Some(format!("Codex 返回后未找到 {entry},项目未进入可运行状态"));
|
||||
}
|
||||
if !direct_game_sources_reference_taonier_art_package(root) {
|
||||
return Some(
|
||||
@@ -3718,14 +3711,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 +3743,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)
|
||||
@@ -3859,6 +3777,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type(
|
||||
prompt,
|
||||
creation_type,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -3868,6 +3787,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
prompt: &str,
|
||||
creation_type: Option<&str>,
|
||||
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
|
||||
audit: Option<&mut DirectCodexTurnAudit>,
|
||||
) -> Result<String, String> {
|
||||
if !root.is_absolute() || !root.is_dir() {
|
||||
return Err("当前项目目录不存在或不是绝对路径".to_string());
|
||||
@@ -3883,7 +3803,8 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
if let Some(emitter) = turn_emitter {
|
||||
emitter.emit("accepted", Some("request-accepted"), None);
|
||||
}
|
||||
match run_direct_game_creator_turn_inner(root, prompt, creation_type, turn_emitter).await {
|
||||
match run_direct_game_creator_turn_inner(root, prompt, creation_type, turn_emitter, audit).await
|
||||
{
|
||||
Ok(reply) => Ok(reply),
|
||||
Err(failure) => {
|
||||
let error = record_direct_codex_turn_failure(root, failure);
|
||||
@@ -3900,6 +3821,7 @@ async fn run_direct_game_creator_turn_inner(
|
||||
prompt: &str,
|
||||
creation_type: Option<&str>,
|
||||
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
|
||||
audit: Option<&mut DirectCodexTurnAudit>,
|
||||
) -> Result<String, DirectCodexTurnFailure> {
|
||||
emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息");
|
||||
if let Some(emitter) = turn_emitter {
|
||||
@@ -3928,15 +3850,23 @@ async fn run_direct_game_creator_turn_inner(
|
||||
);
|
||||
}
|
||||
};
|
||||
direct_game_creator_codex_chat_at_with_observer(
|
||||
direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
root,
|
||||
system_prompt,
|
||||
prompt.to_string(),
|
||||
&mut observer,
|
||||
Some(&mut observer),
|
||||
audit,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
direct_game_creator_codex_chat_at(root, system_prompt, prompt.to_string()).await
|
||||
direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
root,
|
||||
system_prompt,
|
||||
prompt.to_string(),
|
||||
None,
|
||||
audit,
|
||||
)
|
||||
.await
|
||||
}
|
||||
.map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?;
|
||||
if let Some(emitter) = turn_emitter {
|
||||
@@ -4263,6 +4193,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,21 +4202,47 @@ 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 reply = run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
let mut audit = DirectCodexTurnAudit::start(
|
||||
root,
|
||||
&turn_id,
|
||||
&prompt,
|
||||
attachments.as_deref().unwrap_or_default(),
|
||||
);
|
||||
let user_prompt = match render_direct_codex_user_prompt(
|
||||
&prompt,
|
||||
attachments.as_deref().unwrap_or_default(),
|
||||
) {
|
||||
Ok(prompt) => prompt,
|
||||
Err(error) => {
|
||||
audit.finish(false);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
root,
|
||||
&user_prompt,
|
||||
creation_type.as_deref(),
|
||||
Some(&turn_emitter),
|
||||
Some(&mut audit),
|
||||
)
|
||||
.await?;
|
||||
persist_direct_codex_assistant_reply_at(root, &turn_id, &reply).map_err(|error| {
|
||||
.await
|
||||
{
|
||||
Ok(reply) => reply,
|
||||
Err(error) => {
|
||||
audit.finish(false);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Err(error) = persist_direct_codex_assistant_reply_at(root, &turn_id, &reply) {
|
||||
audit.finish(false);
|
||||
turn_emitter.emit("failed", Some("none"), None);
|
||||
redact_agent_runtime_error(
|
||||
return Err(redact_agent_runtime_error(
|
||||
root,
|
||||
&format!("Direct 成功回复持久化失败,已拒绝以未落盘状态返回:{error}"),
|
||||
500,
|
||||
)
|
||||
})?;
|
||||
));
|
||||
}
|
||||
audit.finish(true);
|
||||
turn_emitter.emit("completed", Some("none"), Some(reply.clone()));
|
||||
Ok(reply)
|
||||
}
|
||||
@@ -4293,7 +4250,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 +4452,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?: (
|
||||
@@ -422,6 +428,13 @@ type AppProps = {
|
||||
onMakeGameFromApprovedGdd?: (projectPath: string) => Promise<void>;
|
||||
};
|
||||
|
||||
type ExecuteChatAgentReplyInput = {
|
||||
prompt: string;
|
||||
clientTurnId?: string;
|
||||
creationType?: HomeCreationType | null;
|
||||
attachments?: DirectCodexTurnAttachment[];
|
||||
};
|
||||
|
||||
export function App({
|
||||
initialProjectPath: initialProjectPathOverride = '',
|
||||
initialProjectManifest,
|
||||
@@ -432,6 +445,7 @@ export function App({
|
||||
supervisorChatOnly = false,
|
||||
initialSupervisorMessage = '',
|
||||
initialCreationType = null,
|
||||
initialAttachments = [],
|
||||
playRequest = null,
|
||||
onPlayRequestHandled,
|
||||
onManifestChange,
|
||||
@@ -495,6 +509,7 @@ export function App({
|
||||
projectPath: initialProjectPath,
|
||||
prompt: initialSupervisorMessage.trim(),
|
||||
creationType: initialCreationType,
|
||||
attachments: toDirectCodexTurnAttachments(initialAttachments),
|
||||
});
|
||||
const handledPlayRequestRef = useRef<string | null>(null);
|
||||
|
||||
@@ -990,11 +1005,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);
|
||||
@@ -2753,10 +2764,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) {
|
||||
@@ -5225,7 +5236,7 @@ export function App({
|
||||
return;
|
||||
}
|
||||
|
||||
void executeChatAgentReply(prompt);
|
||||
void executeChatAgentReply({ prompt });
|
||||
}
|
||||
|
||||
async function executeLlmConfigStatus() {
|
||||
@@ -5372,11 +5383,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) {
|
||||
@@ -5486,6 +5498,7 @@ export function App({
|
||||
prompt: string;
|
||||
clientTurnId: string;
|
||||
creationType?: HomeCreationType;
|
||||
attachments?: DirectCodexTurnAttachment[];
|
||||
} = {
|
||||
projectPath: directProjectPath,
|
||||
prompt,
|
||||
@@ -5494,6 +5507,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,
|
||||
@@ -5811,11 +5827,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,
|
||||
@@ -10826,7 +10843,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';
|
||||
|
||||
@@ -339,6 +339,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;
|
||||
|
||||
@@ -212,6 +212,7 @@ export function useHomeProjectCreation({
|
||||
mediaType,
|
||||
localPath: result.localPath,
|
||||
status: 'imported',
|
||||
size: attachment.file.size,
|
||||
});
|
||||
} catch (error) {
|
||||
imported.push({
|
||||
@@ -219,6 +220,7 @@ export function useHomeProjectCreation({
|
||||
mediaType,
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
size: attachment.file.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1529,11 +1529,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('starts an automatic game project from the approved GDD', async () => {
|
||||
@@ -1730,6 +1763,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',
|
||||
@@ -1738,6 +1783,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,8 @@
|
||||
## AI 游戏创作与 Agent Runtime
|
||||
|
||||
- [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。
|
||||
- [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。
|
||||
- [Direct 回合行为审计账本](./technical/【技术方案】Direct回合行为审计账本-2026-08-31.md):Direct GUI 回合把 native 读 / MCP / 写文件落成项目内有界时间线,用于判断有没有打开本轮附件。
|
||||
- [项目开发工作台 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)
|
||||
|
||||
@@ -25,6 +25,22 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-31 Direct 回合把 Codex item 落成有界行为账本
|
||||
|
||||
- 背景:sidecar 已让模型看见本轮附件路径,但 native 读 / MCP / 写文件只存在于隔离 `CODEX_HOME` 的瞬时 stdout,回合结束即删。无法判断「没读附件」还是「读了仍走默认收集类」。
|
||||
- 决策:GUI DirectProject 每个 `clientTurnId` 追加 `.agent/runtime/direct-codex/turns/<id>.jsonl`,并在 `agent.db` 写一条 `direct.codex.turn` 摘要。记 sidecar 提供的路径与文件 hash、`item/completed` 的 Read/List/Search/MCP/写文件(不含 stdout、patch、MCP result),以及 `offeredRead` / `firstDesign`。审计 fail-open,不阻断做游戏。Home、CLI、Supervisor 收据模型不接。不灌附件正文,不强制读取,不为 GDD 开特例。
|
||||
- 影响范围:`direct_codex_audit.rs`、Direct GUI command 边界、Codex collect 循环;前端 / jsonl 气泡 / sidecar 文案不变。
|
||||
- 验证方式:Rust fixture 覆盖 turn_start hash、绝对路径相对化、stdout/diff 不落盘、art brief 保留、list/search 不算已读、firstDesign 顺序、256 条截断、写盘失败不 panic;sidecar 渲染与 Direct 活动词测试保持通过。
|
||||
- 关联文档:`docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md`、issue #212。
|
||||
|
||||
## 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-26 运行中自主扩图提案留在编排层
|
||||
|
||||
- 背景:`agent-runtime-orchestration` 已能构造和调度动态 DAG,但 LLM 在执行中发现缺少步骤时没有通用的安全扩图合同。
|
||||
@@ -39,6 +55,7 @@
|
||||
- 产品边界:16 个游戏任务、六组角色、产物/验收条件、Evaluator Markdown 和中文语义路由继续留在 `platform-agent`;AGC 组合根使用公共层校验任务图与 `AgentCatalog`。Runtime store、Runner、Provider、权限、ToolHost、委派 journal、isolated write scope 和 `.agent/runtime/**` 不迁移、不双写。
|
||||
- 验证方式:非游戏 conformance 覆盖并行分支、汇合、repair closure、AgentCatalog 和非法图失败关闭;`platform-agent` 锁定种子 DAG 与现役波次/返工顺序,并验证环拒绝和 catalog 注入。根检查脚本必须执行新 crate 测试。
|
||||
- 关联文档:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md` V1.54。
|
||||
|
||||
## 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,11 @@ 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/【技术方案】Direct回合行为审计账本-2026-08-31.md`
|
||||
6. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md`
|
||||
7. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md`
|
||||
8. 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 读取审计;该项由 [`【技术方案】Direct回合行为审计账本-2026-08-31.md`](./【技术方案】Direct回合行为审计账本-2026-08-31.md) 承接。
|
||||
- 不改 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 与文档索引
|
||||
@@ -0,0 +1,389 @@
|
||||
# Direct 回合行为审计账本
|
||||
|
||||
- 日期:2026-08-31
|
||||
- 状态:现行合同(已按本文落地)
|
||||
- 问题:Gitea issue #212 的第二段(Direct 原生读 / 工具行为无法从项目产物判断);用于分析「附件已映射仍未按文档实施」
|
||||
- 关联:[`【技术方案】DirectProject本轮附件路径映射-2026-08-31.md`](./【技术方案】DirectProject本轮附件路径映射-2026-08-31.md)、[`【技术说明】DirectProject未消费用户上传权威文档-2026-08-30.md`](./【技术说明】DirectProject未消费用户上传权威文档-2026-08-30.md)
|
||||
- 原则:落地后代码简洁可维护;审计是 Direct 行为时间线,不是 GDD 特例,也不替代 sidecar
|
||||
|
||||
## 0. 一句话
|
||||
|
||||
Direct GUI 回合已经能看见 Codex `item/completed`,但只收成 UI 活动词,隔离 `CODEX_HOME` 随后删除。本方案在项目内留下有界、可共享的回合账本:本轮提供了哪些附件路径、按什么顺序做了读/搜/列表/MCP/写文件,以及第一次定玩法的动作是什么。用来区分「没读附件」和「读了仍走默认收集类」,不灌正文、不强制读取、不拷会话目录。
|
||||
|
||||
## 1. 目标与非目标
|
||||
|
||||
### 目标
|
||||
|
||||
一次带 `clientTurnId` 的 DirectProject GUI 回合结束后,只凭项目目录应能回答:
|
||||
|
||||
1. 本轮 sidecar 是否发出,原名映射到哪些项目相对路径,文件当时的 `contentSha256`。
|
||||
2. 模型是否用 native 命令 / 列表 / 搜索 / 看图 / MCP 打开过那些路径(路径 + 当时磁盘 hash,不是 stdout)。
|
||||
3. **顺序**:读附件是在第一次美术 brief / 第一次写 `game/` 之前还是之后。
|
||||
4. 第一次「定玩法」动作是什么(优先 `taonier_prepare_game_art.brief`,否则其它生成类 MCP 或对 `game/` 的写入)。
|
||||
|
||||
覆盖后续手打回合:只要 GUI Direct 有 `clientTurnId` 就记账本,附件可以为空。
|
||||
|
||||
### 非目标
|
||||
|
||||
- 不证明「理解并按 GDD 实施」。那是对照 `game/index.html`、美术产物做的产品判断;账本只提供行为时间线。
|
||||
- 不把 issue #212 原文的 NLP / 「关键内容进入上下文」做成自动判决。
|
||||
- 不灌附件正文进 prompt,不强制先读再继续,不为 GDD 开协议特例。
|
||||
- 不改 sidecar 文案、jsonl 用户原文、工作台气泡。
|
||||
- 不改做成游戏固定 prompt / PR #210 注入。
|
||||
- 不复用 Supervisor `agent.runtime.action_receipt` / `file.read`。
|
||||
- 不拷隔离 `CODEX_HOME`、不落 `auth.json`、不落 `aggregated_output` / MCP `result` / patch `diff` / `FunctionCallOutput` 正文。
|
||||
- 不把原始 item JSON 送进 Tauri 前端事件(现有 `DirectCodexTurnObservation` 仍只允许安全活动词和流式正文)。
|
||||
- 不扫 `kind=uploaded` 历史附件;只记本轮 sidecar 提供的集合。
|
||||
- DirectHome、ToolHost、CLI `--direct-codex-chat`(无 `clientTurnId`)本期不写这份账本。
|
||||
- 本期不改 UI,不在聊天面板展示审计。
|
||||
- 不把 issue #212 标成已修复;sidecar 与本账本是两段工作。
|
||||
|
||||
## 2. 现状
|
||||
|
||||
```text
|
||||
Codex item/completed
|
||||
commandExecution / mcpToolCall / fileChange / imageView / …
|
||||
│
|
||||
├─ 现用:收成 Activity("validation"|"controlled-tool"|…)
|
||||
│ → Tauri 进度,不落盘
|
||||
└─ 不用:隔离 CODEX_HOME session(含 stdout)→ tempdir Drop 删除
|
||||
```
|
||||
|
||||
项目里现有:
|
||||
|
||||
| 产物 | 记下的 | 缺的 |
|
||||
|---|---|---|
|
||||
| `.agent/conversations/project.jsonl` | 用户原文 + 助手终稿 | sidecar、工具调用 |
|
||||
| `.agent/agent.db` | init / upload / 美术登记 / 对话指针 | native 读、MCP 调用、`agc_write_file` |
|
||||
| `.agent/logs/command.log` | 权限确认 | 原生命令 |
|
||||
| `asset.register` / `canvas.asset_generate` | 路径、切片、部分 `source.prompt` | 与读附件的先后 |
|
||||
| 隔离 `CODEX_HOME` | Codex 自己的 session | 回合结束即删 |
|
||||
|
||||
Codex app-server 协议里,`commandExecution.commandActions` 已分类为 `Read | ListFiles | Search | Unknown`,`Read.path` 在协议侧会拼成 cwd 绝对路径。Direct cwd 就是项目根(`resolve_direct_codex_project_authority` 不再强制 `game/` 子目录)。抽取时把绝对路径收回项目相对 POSIX,失败则丢路径,不写宿主绝对路径。
|
||||
|
||||
`agc_write_file` 经 tool bridge 落盘,当前不写 `agent.db`。不给每个 MCP 单独打点;统一在 `item/completed` 抽一次。
|
||||
|
||||
## 3. 分析用判据(相对 issue 收窄)
|
||||
|
||||
落地后,对类似 `gameagent-9baa5293` 的 run,应能三分:
|
||||
|
||||
| 时间线 | 结论 | 下一刀不该打哪 |
|
||||
|---|---|---|
|
||||
| `offeredRead.read=false`,`firstDesign` 已是 `taonier_prepare_game_art` 且 brief 是收集类 | 没打开附件就定了玩法 | 不是「GDD 解析不够」 |
|
||||
| 先 `Read` 且 hash 对上,brief 仍是收集类 | 读了但没用 | sidecar 已够;看四切片 / icon-spec「收集物」/ 完成合同 |
|
||||
| 只有 `ListFiles` / `Search` 命中 uploads,没有 `Read` | 发现了没读正文 | 映射可能够,缺的是读 |
|
||||
| `Read` 的 path 是 `fast_gdd.md` 而不是 `assets/uploads/…` | sidecar 没被当成磁盘路径 | 还是路径合同 |
|
||||
|
||||
不在账本里写「已遵循 GDD」或「未遵循 GDD」布尔。
|
||||
|
||||
## 4. 落点
|
||||
|
||||
两层,都在项目 `.agent/` 控制面内,模型读不到:
|
||||
|
||||
1. **权威时间线**(每回合一个 jsonl,只追加)
|
||||
`.agent/runtime/direct-codex/turns/<clientTurnId>.jsonl`
|
||||
2. **总索引一条摘要**(方便继续翻现有 `agent.db`)
|
||||
`recordType: "direct.codex.turn"`
|
||||
|
||||
`clientTurnId` 沿用现有规则:trim 后 6–160 位 ASCII 字母数字或连字符,首位字母或数字。文件名用规范化后的 id,不再二次编码。
|
||||
|
||||
不升级 `GAME_CREATOR_AGENT_DB_SCHEMA_VERSION`;新 `recordType` 走 Ordinary 追加。`updatedAt` / `schemaVersion` 仍由 `serialize_agent_db_record` 写入。
|
||||
|
||||
jsonl 每条自带 `recordedAtMs`(`unix_millis`)。同一 `clientTurnId` 若再次进入(当前 GUI 运行中互斥,结束后理论上可再来):只追加,不截断;后一次 `turn_start` 视为新 attempt。读摘要时按文件内最后一次 `turn_start` 到对应 `turn_end` 计算 `offeredRead`。`agent.db` 每次 `turn_end` 再追加一条摘要,分析取该 `clientTurnId` 最后一条。
|
||||
|
||||
## 5. 记录合同
|
||||
|
||||
camelCase JSON。禁止出现附件正文、命令 stdout、patch diff、宿主绝对路径、Token、URL 签名。
|
||||
|
||||
### 5.1 `turn_start`
|
||||
|
||||
在 sidecar **已经渲染之后**、Codex turn **启动之前**写入。`promptSha256` 哈希的是 **用户原文**(command 入参 `prompt`),不是带 sidecar 的全文。
|
||||
|
||||
```json
|
||||
{
|
||||
"recordType": "direct.codex.turn_start",
|
||||
"clientTurnId": "Abc123-def",
|
||||
"sidecarPresent": true,
|
||||
"promptSha256": "<sha256 hex of original user text UTF-8>",
|
||||
"promptChars": 120,
|
||||
"attachments": [
|
||||
{
|
||||
"name": "fast_gdd.md",
|
||||
"localPath": "assets/uploads/upload-1788164530559-fast_gdd.md",
|
||||
"mediaType": "text/markdown",
|
||||
"size": 8119,
|
||||
"status": "imported",
|
||||
"contentSha256": "<sha256 hex or omit>",
|
||||
"hashSkipped": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `attachments` 清洗复用 sidecar:`sanitize_attachment_name` / `media_type` / `status` / `local_path`。把这些函数改成 `pub(crate)`,审计模块不要复制一份。
|
||||
- 条数上限仍 8;超出只在 sidecar 文案里写「另有 N 个未展开」,账本 `attachments` 同样只留前 8,另加 `attachmentsOmitted: N`。
|
||||
- `sidecarPresent`:本轮渲染走了 Project 头(任一条有合法 path 或 status)。Home 形态不会出现在本账本(Home 不记账)。
|
||||
- `contentSha256`:对清洗后的 `localPath` 读项目文件做 SHA-256 小写 hex。文件不存在则省略 hash,`hashSkipped: "missing"`。超过 `DIRECT_CODEX_AUDIT_HASH_MAX_BYTES`(2 MiB)则 `hashSkipped: "too-large"`。`.agent` / `.git` / `..` 路径本来就不会出现在 sidecar 输出里。
|
||||
|
||||
无附件:`attachments: []`,`sidecarPresent: false`,仍然写 `turn_start`。
|
||||
|
||||
### 5.2 `item`
|
||||
|
||||
仅 `item/completed`。`item/started` 和 `outputDelta` 不落盘。
|
||||
|
||||
公共字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"recordType": "direct.codex.item",
|
||||
"clientTurnId": "Abc123-def",
|
||||
"seq": 1,
|
||||
"itemId": "item-…",
|
||||
"itemType": "commandExecution",
|
||||
"status": "completed"
|
||||
}
|
||||
```
|
||||
|
||||
`seq` 从 1 起,按成功写入的 item 递增。`itemType` 取 Codex `item.type` 原词;未知类型仍记账 `itemType`,不附带未清洗 payload。
|
||||
|
||||
按类型附加字段:
|
||||
|
||||
| `item.type` | 追加 | 禁止 |
|
||||
|---|---|---|
|
||||
| `commandExecution` | `command` 截断 240 字;`exitCode`;`durationMs`;`actions[]` | `aggregatedOutput` |
|
||||
| `mcpToolCall` | `tool`、`server`(可省略默认 `agc_tools`)、`durationMs`、§5.4 参数 | `result`、`error` 原文(只留 `status` / `errorKind`) |
|
||||
| `fileChange` | `changes: [{ path, kind }]`,`kind` 为 `add` / `delete` / `update` | `diff`、`movePath` 的宿主绝对路径(相对化失败则整条 change 丢 path) |
|
||||
| `imageView` | `path` | 图像字节 |
|
||||
| `functionCallOutput` | `name`、`namespace` | `output` |
|
||||
| `webSearch` | `query` 截断 400 字 | 结果页正文 |
|
||||
| `agentMessage` / `userMessage` / `plan` / `reasoning` / `contextCompaction` / `hookPrompt` | **整类跳过**(终稿已在 jsonl;推理正文不是本账本) | — |
|
||||
| 其它未知 | 只留公共字段 | 原始 `item` 对象 |
|
||||
|
||||
`commandExecution.actions[]`:
|
||||
|
||||
```json
|
||||
{ "type": "read", "path": "assets/uploads/upload-…-fast_gdd.md", "contentSha256": "…", "hashSkipped": null }
|
||||
{ "type": "listFiles", "path": "assets" }
|
||||
{ "type": "search", "query": "fast_gdd", "path": null }
|
||||
{ "type": "unknown" }
|
||||
```
|
||||
|
||||
- `Read.path` 先相对化再清洗;失败则该 action 记 `{ "type": "read", "pathRejected": true }`,不写绝对路径。
|
||||
- 相对化成功后,对磁盘文件按 §5.1 同一套 hash 规则补 `contentSha256`。
|
||||
- `command` 里若相对化失败,把 `command` 整段丢掉,改 `commandRedacted: true`(避免 `type C:\Users\…\fast_gdd.md` 进账本)。
|
||||
|
||||
### 5.3 MCP 参数白名单
|
||||
|
||||
只抄这些键,其它键丢弃。字符串再经 path 清洗或截断。
|
||||
|
||||
| 工具 | 落盘参数 | 正文类字段 |
|
||||
|---|---|---|
|
||||
| `agc_list_project_files` | `path`、`query`(120)、`kind`、`offset`、`limit` | 无 |
|
||||
| `agc_write_file` | `path`、`contentChars`(`content` 的字符数,不是正文) | 不落 `content` |
|
||||
| `taonier_prepare_game_art` | `mode`、`brief`(截断 4000)、`briefChars`、`briefSha256` | **要 brief 原文**(分析定玩法的吸烟枪;上限已是 MCP 合同) |
|
||||
| `agc_generate_image` | `kind`、`aspectRatio`、`imageSize`、`assetName`、`outputPath`、`prompt` 截断 4000、`promptChars`、`promptSha256` | 不落 32k 全文 |
|
||||
| `agc_edit_image` | `sourceLocalAssetId`、`assetName`、`prompt` 截断 4000、`promptChars`、`promptSha256` | 同上 |
|
||||
| `agc_create_or_derive_resource` | `kind`、`mode`、`sourceLocalAssetId`、`assetName`、`prompt` 截断 4000、`promptChars`、`promptSha256` | MCP 上限已是 4000 |
|
||||
| `agc_list_registered_assets` | `kind`、`assetId`、`includeSequenceFrames`、`offset`、`limit` | 无 |
|
||||
| `agc_list_account_assets` | `folderId`、`query`、`offset`、`limit` | 无 |
|
||||
| `agc_import_account_assets` | `assetIds`(最多 8 个 id,超出 `assetIdsOmitted`)、`localPaths`(清洗后相对路径,最多 8) | 无 |
|
||||
| `agc_remove_background` | `sourceLocalAssetId`、`assetName` | 无 |
|
||||
| `agc_browser_playtest` | `attempt` | 无 |
|
||||
| `agc_web_search` | `query` 截断 400、`maxResults` | 无 |
|
||||
| `agc_read_skill_resource` | `skillName`、`relativePath` | 不落 Skill 正文 |
|
||||
| 未知 MCP 名 | 只留 `tool` + `status` | 不落 `arguments` |
|
||||
|
||||
`brief` / 截断后的 `prompt` 是 **模型自己写的设计文本**,不是用户 GDD 转储。这是分析「仍走收集类」的关键,允许进 jsonl。`agent.db` 摘要只留 `briefPreview` 240 字。
|
||||
|
||||
### 5.4 `turn_end`
|
||||
|
||||
派生摘要,不是第二真相。字段必须能从本文件已写入的 `turn_start` + `item` 重算出来。
|
||||
|
||||
```json
|
||||
{
|
||||
"recordType": "direct.codex.turn_end",
|
||||
"clientTurnId": "Abc123-def",
|
||||
"completed": true,
|
||||
"itemCount": 17,
|
||||
"itemsTruncated": false,
|
||||
"offeredRead": [
|
||||
{
|
||||
"localPath": "assets/uploads/upload-1788164530559-fast_gdd.md",
|
||||
"read": false
|
||||
}
|
||||
],
|
||||
"firstDesign": {
|
||||
"kind": "mcp:taonier_prepare_game_art",
|
||||
"seq": 3,
|
||||
"tool": "taonier_prepare_game_art",
|
||||
"briefPreview": "俯视角收集冒险小游戏…"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`offeredRead.read=true` 当且仅当本 attempt 内存在 `actions.type=read` 或 `imageView` 或 MCP 参数里的 `path` / `localPaths`,清洗后与 `localPath` 字符串相等。hash 对不上仍记 `read: true`,另加 `contentSha256Match: false`(读了另一份同路径文件或读时文件已变)。没有 hash 可对则省略 `contentSha256Match`。
|
||||
|
||||
`firstDesign`:本 attempt 第一条满足任一条件的 item:
|
||||
|
||||
1. MCP:`taonier_prepare_game_art` / `agc_generate_image` / `agc_edit_image` / `agc_create_or_derive_resource`
|
||||
2. `agc_write_file` 且 path 以 `game/` 开头或文件名是 `index.html`
|
||||
3. `fileChange` 且任一条 change path 满足 2
|
||||
|
||||
列表、搜索、读、Skill 读取、账户素材查询、playtest、web_search **不算** firstDesign。没有则 `firstDesign: null`。
|
||||
|
||||
`kind` 取值:`mcp:<tool>` / `write:<path>` / `fileChange:<path>`。
|
||||
|
||||
### 5.5 `agent.db` 摘要
|
||||
|
||||
```json
|
||||
{
|
||||
"recordType": "direct.codex.turn",
|
||||
"clientTurnId": "Abc123-def",
|
||||
"turnLog": ".agent/runtime/direct-codex/turns/Abc123-def.jsonl",
|
||||
"sidecarPresent": true,
|
||||
"offeredCount": 1,
|
||||
"offeredRead": [ { "localPath": "assets/uploads/…-fast_gdd.md", "read": false } ],
|
||||
"firstDesign": { "kind": "mcp:taonier_prepare_game_art", "seq": 3, "briefPreview": "…" },
|
||||
"itemCount": 17,
|
||||
"itemsTruncated": false,
|
||||
"completed": true,
|
||||
"auditWriteFailed": false
|
||||
}
|
||||
```
|
||||
|
||||
`turnLog` 必须是项目相对 POSIX。不要把 jsonl 全文复制进 `agent.db`。单条仍受 Ordinary 1 MiB 限制;摘要本身应远小于此。
|
||||
|
||||
### 5.6 上限
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 每回合 item 条数 | 256;超出再写一条 `recordType: "direct.codex.items_truncated"`,之后 item 丢弃但仍把 `turn_end.itemsTruncated=true` |
|
||||
| `command` | 240 字 |
|
||||
| `brief` / 生成类 `prompt` 落盘 | 4000 字 |
|
||||
| `briefPreview` | 240 字 |
|
||||
| 文件 hash | 2 MiB |
|
||||
| 附件条数 | 8(与 sidecar 相同) |
|
||||
| jsonl 单行 | 沿用现有 jsonl 追加上限;超长截断正文类字段,不截断结构 |
|
||||
|
||||
## 6. 调用链
|
||||
|
||||
```text
|
||||
chat_with_game_creator_direct_codex
|
||||
规范化 clientTurnId
|
||||
DirectCodexTurnAudit::start(root, clientTurnId, originalPrompt, attachments)
|
||||
→ 写 turn_start(fail-open)
|
||||
render_direct_codex_user_prompt // 现有 sidecar,不变
|
||||
run_direct_game_creator_turn_at_with_creation_type_and_emitter(..., audit)
|
||||
→ Codex collect 循环在 DirectProject + item/completed 调 audit.observe_item
|
||||
Ok/Err 都 audit.finish(completed)
|
||||
→ 写 turn_end + agent.db 摘要
|
||||
```
|
||||
|
||||
- CLI `run_direct_game_creator_turn_at` **不** 接 audit(无 `clientTurnId`)。
|
||||
- Home command 不接 audit。
|
||||
- 不要把 attachments / audit 顺着 CLI inner、pool、ToolHost 往下传。
|
||||
- `DirectCodexTurnObservation` **不** 增加原始 `params`。审计走独立 `DirectCodexTurnAudit`,避免 stdout 正文进入 Tauri 事件。
|
||||
|
||||
`direct_game_creator_codex_chat_at_with_optional_observer` 增加可选 `audit: Option<&mut DirectCodexTurnAudit>`,再传到 `run_turn_with_direct_observer`。仅 `workspace_mode == DirectProject` 且 `audit` 为 Some 时抽取。
|
||||
|
||||
`run_direct_game_creator_turn_inner` 的 UI observer 保持只处理 `AccumulatedText` / `Activity`。
|
||||
|
||||
回合失败(生成失败、浏览器试玩失败、回复落盘失败):只要 `start` 过就 `finish(false)`,保留已观察到的 item。Codex 尚未启动则 `itemCount=0`。
|
||||
|
||||
## 7. 失败语义
|
||||
|
||||
审计 **不得** 把做游戏打失败。所有写盘包在 sink 内:
|
||||
|
||||
- 单次追加失败:记内存 `audit_write_failed=true`,后续 item 仍尝试写;`finish` 时摘要带 `auditWriteFailed: true`。
|
||||
- 连摘要都写不进去:只在 Direct debug / 现有进度通道能承受的前提下忽略;不新增用户可见报错文案。
|
||||
- 不引入新的 Tauri 事件名。
|
||||
|
||||
与 `conversation.write` 失败不同:助手终稿落盘失败仍按现有逻辑拒绝返回。审计失败不走那条。
|
||||
|
||||
## 8. 安全
|
||||
|
||||
- 路径:与 sidecar 同一套相对 POSIX 清洗;相对化失败不写原绝对路径。
|
||||
- 控制面:hash / 读文件只用 `resolve_local_project_path`;拒绝 `.agent` / `.git` / 敏感文件。这些路径若出现在 commandActions 里,只记 `pathRejected`。
|
||||
- 不把 `aggregated_output`、MCP result、function output、diff 暂存在内存再截断——抽取函数根本不读这些键。
|
||||
- jsonl 位于 `.agent/runtime/**`,现有 Direct 控制面边界禁止模型当普通项目文档读。
|
||||
- 前端观察者和审计 sink 分叉,禁止图省事 `observer(Item { params })`。
|
||||
|
||||
## 9. 代码落地
|
||||
|
||||
新增 [`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_audit.rs):
|
||||
|
||||
- `DirectCodexTurnAudit`
|
||||
- `start` / `observe_item` / `finish`
|
||||
- 相对化、hash、MCP 白名单、`firstDesign` / `offeredRead` 派生
|
||||
- 单元测试(见 §11)
|
||||
|
||||
[`agent.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent.rs):`mod direct_codex_audit` + `pub(crate) use`。
|
||||
|
||||
[`direct_codex_attachments.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_attachments.rs):清洗函数改 `pub(crate)`,行为不变。
|
||||
|
||||
[`codex_app_server.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs):
|
||||
|
||||
- `run_turn_with_direct_observer` / `direct_game_creator_codex_chat_at_with_optional_observer` 增加 `audit: Option<&mut DirectCodexTurnAudit>`
|
||||
- collect 循环 `Item { completed: true, .. }` 且 DirectProject 时 `audit.observe_item(¶ms)`
|
||||
- **不要** 把 `params` 塞进 `DirectCodexTurnObservation`
|
||||
- 现有 Home / ToolHost / 安全活动词测试保持逐字
|
||||
|
||||
[`direct_runtime.rs`](../../apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs):
|
||||
|
||||
- `chat_with_game_creator_direct_codex` 创建 audit(原文 + attachments),Ok/Err 都 `finish`
|
||||
- 把 audit 传入 `run_direct_game_creator_turn_at_with_creation_type_and_emitter`
|
||||
- 该函数和 inner 增加可选 audit;CLI 入口签名不变
|
||||
|
||||
不要给 `agc_write_file` / 每个 MCP handler 再写一份平行审计。
|
||||
|
||||
前端、DTO、sidecar 文案、home.suite 附件断言:本期不改。不新增 UI。
|
||||
|
||||
文档:落地提交时把本文状态改为「现行合同(已按本文落地)」;`decision-log.md` 记一条;不要把 08-30 技术说明改成已修复。
|
||||
|
||||
## 10. 测试
|
||||
|
||||
全部是 Rust 单元测试,用 fixture item JSON,不拉真 Codex。
|
||||
|
||||
1. `turn_start`:原文 hash 稳定;sidecar 路径清洗后出现;非法 `../` 不进 attachments;无附件 `sidecarPresent=false`。
|
||||
2. 附件文件写入临时项目后 `contentSha256` 与直接 hash 一致;缺文件 `hashSkipped=missing`;超过 2 MiB `too-large`。
|
||||
3. `commandExecution` + `commandActions: [{type:read, path: <abs>}]` → 相对路径 + hash;`aggregatedOutput` 即使在 fixture 里也不出现在落盘 JSON。
|
||||
4. `Read` 相对化失败 → `pathRejected`,落盘 JSON 不含 `C:\\` / `Users`。
|
||||
5. `mcpToolCall` `taonier_prepare_game_art`:`brief` 保留;`result` 丢掉。
|
||||
6. `agc_write_file`:有 path 与 `contentChars`,无 content。
|
||||
7. `agc_generate_image`:32k prompt 只留 4000 + `promptChars` + sha256。
|
||||
8. `fileChange`:path + kind,无 diff。
|
||||
9. `offeredRead`:读路径等于 offered → `read=true`;只 list/search → `read=false`。
|
||||
10. `firstDesign`:先 read 再 art → kind 是 mcp art,seq 是 art 那条;只有 read → `null`。
|
||||
11. 第 257 条 item 触发 truncated,`turn_end.itemsTruncated=true`。
|
||||
12. `agent.db` 摘要含 `turnLog` 相对路径、`offeredRead`、`firstDesign.briefPreview`。
|
||||
13. 写盘注入失败:`finish` 不 panic、不返回 Err 给调用方(sink 方法是 `()`)。
|
||||
14. 未知 `item.type` 只留公共字段。
|
||||
15. 现有 DirectHome 活动词测试、sidecar 渲染测试不受影响。
|
||||
|
||||
不测:真模型是否读 GDD、是否生成弹幕射击、浏览器验收文案。
|
||||
|
||||
## 11. 验收(方案落地后的人工分析)
|
||||
|
||||
用一次「上传 md + 做成游戏 / 首页做游戏」的本地项目:
|
||||
|
||||
1. 存在 `.agent/runtime/direct-codex/turns/<clientTurnId>.jsonl`。
|
||||
2. `agent.db` 有对应 `direct.codex.turn`。
|
||||
3. `turn_start.attachments[].localPath` 与 sidecar 项目路径一致。
|
||||
4. jsonl **没有** GDD 正文、没有 `aggregatedOutput`、没有 patch。
|
||||
5. 能根据 `offeredRead` + `firstDesign` 填上 §3 四行表的其中一行,而不用猜隔离 session。
|
||||
6. 工作台气泡仍是用户原文;jsonl 对话仍无 sidecar。
|
||||
7. `npm run check:encoding`、`git diff --check`、相关 Rust 单测通过。
|
||||
|
||||
## 12. 实现顺序
|
||||
|
||||
1. `direct_codex_audit.rs` + 清洗函数 `pub(crate)` + fixture 测试
|
||||
2. `codex_app_server` collect 接 sink;观察者枚举不变
|
||||
3. GUI Direct command 创建 / finish sink
|
||||
4. encoding 与定向 `cargo test`
|
||||
5. 合入时改本文状态、decision-log、sidecar 文档里「native 审计另排期」那一行改为指向本文
|
||||
|
||||
## 13. 与 sidecar / issue 的边界
|
||||
|
||||
- sidecar:让模型 **知道路径**。已落地,合同不变。
|
||||
- 本账本:让人 **看见模型做了什么**。不替代 sidecar,也不在本方案里做强制读取。
|
||||
- issue #212 主问题仍是消费失败;本账本是为下一刀修复提供证据,不是把 212 关单。
|
||||
@@ -206,6 +206,7 @@ Direct app-server 使用 ephemeral thread。stdout 中的 `item/started`、`item
|
||||
- 策划 run ID:`gameagent-cd7f6c81`
|
||||
- 做游戏 run ID:`gameagent-77b5aa31`
|
||||
- 上传 GDD(项目相对路径):`assets/uploads/upload-1788083777445-fast_gdd.md`
|
||||
- 审计账本方案(已落地,不关闭本 Issue):[`【技术方案】Direct回合行为审计账本-2026-08-31.md`](./【技术方案】Direct回合行为审计账本-2026-08-31.md)
|
||||
- 建议随 Issue 附上或引用对应 run 的以下复核材料:
|
||||
- Direct 对话:`.agent/conversations/project.jsonl`
|
||||
- Direct Agent DB:`.agent/agent.db`
|
||||
|
||||
Reference in New Issue
Block a user