From 948a80fc49ec76ca483294d0f219385939503fd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 15 Sep 2026 15:19:17 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=20DirectProject=20Agent=20?= =?UTF-8?q?=E6=B7=B1=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 direct_runtime 与 codex_app_server 改为目录模块 拆分 user item 的模型、校验与 wire 投影文件 抽离 DirectProject 历史注入载荷构造 seam --- .../direct_project_history_wire.rs | 25 ++ .../mod.rs} | 50 ++-- .../src/agent/direct_codex_user_item.rs | 231 ------------------ .../src/agent/direct_codex_user_item/mod.rs | 12 + .../src/agent/direct_codex_user_item/model.rs | 69 ++++++ .../direct_codex_user_item/validation.rs | 87 +++++++ .../src/agent/direct_codex_user_item/wire.rs | 82 +++++++ .../mod.rs} | 107 ++------ .../src/agent/direct_runtime/user_input.rs | 84 +++++++ 9 files changed, 402 insertions(+), 345 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs rename apps/ai-game-creator-shell/src-tauri/src/agent/{codex_app_server.rs => codex_app_server/mod.rs} (99%) delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs rename apps/ai-game-creator-shell/src-tauri/src/agent/{direct_runtime.rs => direct_runtime/mod.rs} (99%) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs new file mode 100644 index 000000000..b958bd982 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_history_wire.rs @@ -0,0 +1,25 @@ +//! DirectProject 历史注入载荷的单一构造 seam。 +//! +//! 历史读取与大小前置校验集中在这里;调用方只负责线程生命周期与 RPC 传输。 + +use super::super::*; +use super::direct_project_history_injection_oversize_error; +use serde_json::Value; +use std::path::Path; + +pub(super) fn build_direct_project_history_injection_params( + history_root: &Path, + thread_id: &str, +) -> Result { + let items = read_direct_project_history_items_at(history_root) + .map_err(platform_llm::LlmError::InvalidRequest)?; + let params = serde_json::json!({"threadId": thread_id, "items": items}); + let payload_bytes = serde_json::to_vec(¶ms) + .map(|bytes| bytes.len().saturating_add(1)) + .unwrap_or(usize::MAX); + // 注入前的前置校验:失败关闭并指名 itemId 与字节数,**不截断、不摘要、不改写**。 + if let Some(error) = direct_project_history_injection_oversize_error(¶ms, payload_bytes) { + return Err(platform_llm::LlmError::InvalidRequest(error)); + } + Ok(params) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs similarity index 99% rename from apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs rename to apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 48edc498f..9259eaf48 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -10,6 +10,9 @@ use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufR use tokio::sync::{mpsc, oneshot, Mutex, Notify}; use uuid::Uuid; +mod direct_project_history_wire; +use direct_project_history_wire::build_direct_project_history_injection_params; + const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc"; const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY"; const GAME_CREATOR_CODEX_APP_SERVER_REMOTE_CONTROL_DISABLED_ENV: &str = @@ -2620,6 +2623,7 @@ impl CodexAppServerConnection { request, None, None, + None, on_agent_message_delta, direct_observer, audit, @@ -2634,6 +2638,7 @@ impl CodexAppServerConnection { request: LlmRunRequest, direct_history_root: Option<&std::path::Path>, direct_client_turn_id: Option<&str>, + direct_user_item: Option<&serde_json::Value>, 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>, @@ -2649,12 +2654,15 @@ impl CodexAppServerConnection { )); } if let Some(client_turn_id) = direct_client_turn_id { - let user_item = direct_project_local_message_item( - "user", - current_prompt, - Some(&format!("direct-codex:{client_turn_id}:user")), - ) - .map_err(platform_llm::LlmError::InvalidRequest)?; + let user_item = match direct_user_item { + Some(item) => item.clone(), + None => direct_project_local_message_item( + "user", + current_prompt, + Some(&format!("direct-codex:{client_turn_id}:user")), + ) + .map_err(platform_llm::LlmError::InvalidRequest)?, + }; append_direct_project_user_message_at(history_root, &user_item) .map_err(platform_llm::LlmError::InvalidRequest)?; } @@ -2664,24 +2672,14 @@ impl CodexAppServerConnection { let thread_id = thread_lease.thread_id.clone(); if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { if thread_created { - let items = match read_direct_project_history_items_at(history_root) { - Ok(items) => items, - Err(error) => { - self.release_thread(snapshot, &thread_id).await; - return Err(platform_llm::LlmError::InvalidRequest(error)); - } - }; - let params = serde_json::json!({"threadId": thread_id.clone(), "items": items}); - let payload_bytes = serde_json::to_vec(¶ms) - .map(|bytes| bytes.len().saturating_add(1)) - .unwrap_or(usize::MAX); - // 注入前的前置校验:失败关闭并指名 itemId 与字节数,**不截断、不摘要、不改写**。 - if let Some(error) = - direct_project_history_injection_oversize_error(¶ms, payload_bytes) - { - self.release_thread(snapshot, &thread_id).await; - return Err(platform_llm::LlmError::InvalidRequest(error)); - } + let params = + match build_direct_project_history_injection_params(history_root, &thread_id) { + Ok(params) => params, + Err(error) => { + self.release_thread(snapshot, &thread_id).await; + return Err(error); + } + }; if let Err(error) = self.request("thread/inject_items", params).await { self.release_thread(snapshot, &thread_id).await; return Err(platform_llm::LlmError::Transport(error)); @@ -3712,6 +3710,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at( None, None, None, + None, ) .await } @@ -3729,6 +3728,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer( None, Some(observer), None, + None, ) .await } @@ -3786,6 +3786,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( client_turn_id: Option<&str>, observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, audit: Option<&mut DirectCodexTurnAudit>, + direct_user_item: Option, ) -> Result { // Resolve project authority before deriving the pool/thread identity. A // caller may hold a stable symlink path whose target changes between @@ -3849,6 +3850,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( request, Some(&codex_root), effective_client_turn_id, + direct_user_item.as_ref(), None, observer, audit, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs deleted file mode 100644 index 1c042b25f..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item.rs +++ /dev/null @@ -1,231 +0,0 @@ -use super::direct_codex_references::MAX_DIRECT_CODEX_REFERENCES; -use super::*; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::path::Path; -use ts_rs::TS; - -/// DirectProject 本轮 user input 的唯一结构化入口。 -/// -/// 该模块只负责 user item 的 schema、校验和 Codex wire 投影;assistant/raw item -/// 仍由现有 app-server 链路处理,避免把两个方向的协议耦合在一个浅层 DTO 中。 -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(tag = "type")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) enum DirectCodexUserItem { - #[serde(rename = "message")] - Message(DirectCodexUserMessageItem), -} - -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) struct DirectCodexUserMessageItem { - pub(crate) role: DirectCodexUserRole, - pub(crate) content: Vec, - pub(crate) id: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) enum DirectCodexUserRole { - User, -} - -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(tag = "type")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) enum DirectCodexUserContentPart { - #[serde(rename = "input_text")] - InputText { text: String }, - #[serde(rename = "agc_resource_reference")] - AgcResourceReference { resource_id: String }, - #[serde(rename = "agc_runtime_region_reference")] - AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart), -} - -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) struct DirectCodexUserRuntimeRegionPart { - pub(crate) label: String, - #[serde(default)] - pub(crate) run_id: Option, - #[serde(default)] - pub(crate) version_id: Option, - #[serde(default)] - pub(crate) element_tag: Option, - #[serde(default)] - pub(crate) element_role: Option, - #[serde(default)] - pub(crate) text: Option, - #[serde(default)] - pub(crate) width: Option, - #[serde(default)] - pub(crate) height: Option, - #[serde(default)] - pub(crate) resource_ids: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) struct DirectCodexUserMessageEnvelope { - pub(crate) item: DirectCodexUserItem, -} - -pub(crate) fn validate_direct_codex_user_item( - root: &Path, - item: &DirectCodexUserItem, -) -> Result<(), String> { - let DirectCodexUserItem::Message(message) = item; - if !matches!(message.role, DirectCodexUserRole::User) { - return Err("DirectProject 只接受 user message item".to_string()); - } - if message.id.trim().is_empty() { - return Err("DirectProject user item 缺少稳定 id".to_string()); - } - if message.content.is_empty() { - return Err("DirectProject user item content 不能为空".to_string()); - } - let manifest = read_manifest_for_project(root)?; - let mut reference_count = 0usize; - for part in &message.content { - match part { - DirectCodexUserContentPart::InputText { text } => { - if text.trim().is_empty() { - return Err("DirectProject input_text 不能为空".to_string()); - } - } - DirectCodexUserContentPart::AgcResourceReference { resource_id } => { - reference_count = reference_count.saturating_add(1); - validate_resource_id_and_manifest(&manifest, resource_id)?; - } - DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { - reference_count = reference_count.saturating_add(1); - validate_runtime_region_reference(&manifest, reference)?; - } - } - } - if reference_count > MAX_DIRECT_CODEX_REFERENCES { - return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材")); - } - Ok(()) -} - -fn validate_resource_id_and_manifest( - manifest: &GameCreationAppManifest, - resource_id: &str, -) -> Result<(), String> { - let resource_id = resource_id.trim(); - if resource_id.is_empty() - || resource_id.chars().count() > 200 - || resource_id.chars().any(char::is_control) - { - return Err("引用的素材 ID 无效,请移除后重新选择".to_string()); - } - let asset = manifest - .assets - .iter() - .find(|asset| asset.id == resource_id) - .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; - sanitize_attachment_local_path(&asset.local_path) - .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; - Ok(()) -} - -fn validate_runtime_region_reference( - manifest: &GameCreationAppManifest, - reference: &DirectCodexUserRuntimeRegionPart, -) -> Result<(), String> { - if reference.label.trim().is_empty() { - return Err("运行画面区域缺少名称".to_string()); - } - if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES { - return Err(format!( - "运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材" - )); - } - for resource_id in &reference.resource_ids { - validate_resource_id_and_manifest(manifest, resource_id)?; - } - Ok(()) -} - -/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。 -/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。 -pub(crate) fn direct_codex_user_item_to_wire_input( - root: &Path, - item: &DirectCodexUserItem, -) -> Result { - validate_direct_codex_user_item(root, item)?; - let manifest = read_manifest_for_project(root)?; - let DirectCodexUserItem::Message(message) = item; - let mut input = Vec::with_capacity(message.content.len()); - for part in &message.content { - let text = match part { - DirectCodexUserContentPart::InputText { text } => text.clone(), - DirectCodexUserContentPart::AgcResourceReference { resource_id } => { - let asset = manifest - .assets - .iter() - .find(|asset| asset.id == resource_id.trim()) - .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; - let path = sanitize_attachment_local_path(&asset.local_path) - .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; - format!( - "[素材引用 resourceId={};项目路径={path}]", - resource_id.trim() - ) - } - DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { - let resources = reference - .resource_ids - .iter() - .map(|id| id.trim()) - .collect::>() - .join(","); - let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim()); - if let Some(run_id) = reference.run_id.as_deref() { - summary.push_str(&format!("运行标识={} ", run_id.trim())); - } - if let Some(role) = reference.element_role.as_deref() { - summary.push_str(&format!("角色={} ", role.trim())); - } - if let Some(text) = reference.text.as_deref() { - summary.push_str(&format!("文本={} ", text.trim())); - } - if !resources.is_empty() { - summary.push_str(&format!("关联素材={resources}")); - } - summary.push(']'); - summary - } - }; - input.push(serde_json::json!({ "type": "text", "text": text })); - } - Ok(Value::Array(input)) -} - -pub(crate) fn direct_codex_user_item_to_prompt( - root: &Path, - item: &DirectCodexUserItem, -) -> Result { - let wire = direct_codex_user_item_to_wire_input(root, item)?; - wire.as_array() - .ok_or_else(|| "DirectProject user item wire input 不是数组".to_string()) - .map(|parts| { - parts - .iter() - .filter_map(|part| part.get("text").and_then(Value::as_str)) - .collect::() - }) - .and_then(|prompt| { - if prompt.trim().is_empty() { - Err("DirectProject user item 不能转换为空 prompt".to_string()) - } else { - Ok(prompt) - } - }) -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs new file mode 100644 index 000000000..d591f4dad --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs @@ -0,0 +1,12 @@ +//! DirectProject user input 的 canonical Response item 深模块。 + +mod model; +mod validation; +mod wire; + +pub(crate) use model::{ + DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageEnvelope, + DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart, +}; +pub(crate) use validation::validate_direct_codex_user_item; +pub(crate) use wire::{direct_codex_user_item_to_prompt, direct_codex_user_item_to_wire_input}; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs new file mode 100644 index 000000000..f3837c648 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs @@ -0,0 +1,69 @@ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// DirectProject 本轮 user input 的唯一结构化入口。 +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(tag = "type")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectCodexUserItem { + #[serde(rename = "message")] + Message(DirectCodexUserMessageItem), +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserMessageItem { + pub(crate) role: DirectCodexUserRole, + pub(crate) content: Vec, + pub(crate) id: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectCodexUserRole { + User, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(tag = "type")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectCodexUserContentPart { + #[serde(rename = "input_text")] + InputText { text: String }, + #[serde(rename = "agc_resource_reference")] + AgcResourceReference { resource_id: String }, + #[serde(rename = "agc_runtime_region_reference")] + AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart), +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserRuntimeRegionPart { + pub(crate) label: String, + #[serde(default)] + pub(crate) run_id: Option, + #[serde(default)] + pub(crate) version_id: Option, + #[serde(default)] + pub(crate) element_tag: Option, + #[serde(default)] + pub(crate) element_role: Option, + #[serde(default)] + pub(crate) text: Option, + #[serde(default)] + pub(crate) width: Option, + #[serde(default)] + pub(crate) height: Option, + #[serde(default)] + pub(crate) resource_ids: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectCodexUserMessageEnvelope { + pub(crate) item: DirectCodexUserItem, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs new file mode 100644 index 000000000..ed7bf88ee --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs @@ -0,0 +1,87 @@ +use super::model::{ + DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserRole, + DirectCodexUserRuntimeRegionPart, +}; +use crate::agent::direct_codex_references::MAX_DIRECT_CODEX_REFERENCES; +use crate::agent::{ + read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest, +}; +use std::path::Path; + +pub(crate) fn validate_direct_codex_user_item( + root: &Path, + item: &DirectCodexUserItem, +) -> Result<(), String> { + let DirectCodexUserItem::Message(message) = item; + if !matches!(message.role, DirectCodexUserRole::User) { + return Err("DirectProject 只接受 user message item".to_string()); + } + if message.id.trim().is_empty() { + return Err("DirectProject user item 缺少稳定 id".to_string()); + } + if message.content.is_empty() { + return Err("DirectProject user item content 不能为空".to_string()); + } + let manifest = read_manifest_for_project(root)?; + let mut reference_count = 0usize; + for part in &message.content { + match part { + DirectCodexUserContentPart::InputText { text } => { + if text.trim().is_empty() { + return Err("DirectProject input_text 不能为空".to_string()); + } + } + DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + reference_count = reference_count.saturating_add(1); + validate_resource_id_and_manifest(&manifest, resource_id)?; + } + DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + reference_count = reference_count.saturating_add(1); + validate_runtime_region_reference(&manifest, reference)?; + } + } + } + if reference_count > MAX_DIRECT_CODEX_REFERENCES { + return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材")); + } + Ok(()) +} + +pub(crate) fn validate_resource_id_and_manifest( + manifest: &GameCreationAppManifest, + resource_id: &str, +) -> Result<(), String> { + let resource_id = resource_id.trim(); + if resource_id.is_empty() + || resource_id.chars().count() > 200 + || resource_id.chars().any(char::is_control) + { + return Err("引用的素材 ID 无效,请移除后重新选择".to_string()); + } + let asset = manifest + .assets + .iter() + .find(|asset| asset.id == resource_id) + .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; + sanitize_attachment_local_path(&asset.local_path) + .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; + Ok(()) +} + +fn validate_runtime_region_reference( + manifest: &GameCreationAppManifest, + reference: &DirectCodexUserRuntimeRegionPart, +) -> Result<(), String> { + if reference.label.trim().is_empty() { + return Err("运行画面区域缺少名称".to_string()); + } + if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES { + return Err(format!( + "运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材" + )); + } + for resource_id in &reference.resource_ids { + validate_resource_id_and_manifest(manifest, resource_id)?; + } + Ok(()) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs new file mode 100644 index 000000000..c2ab37897 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -0,0 +1,82 @@ +use super::model::{DirectCodexUserContentPart, DirectCodexUserItem}; +use super::validation::validate_direct_codex_user_item; +use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path}; +use serde_json::Value; +use std::path::Path; + +/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。 +/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。 +pub(crate) fn direct_codex_user_item_to_wire_input( + root: &Path, + item: &DirectCodexUserItem, +) -> Result { + validate_direct_codex_user_item(root, item)?; + let manifest = read_manifest_for_project(root)?; + let DirectCodexUserItem::Message(message) = item; + let mut input = Vec::with_capacity(message.content.len()); + for part in &message.content { + let text = match part { + DirectCodexUserContentPart::InputText { text } => text.clone(), + DirectCodexUserContentPart::AgcResourceReference { resource_id } => { + let asset = manifest + .assets + .iter() + .find(|asset| asset.id == resource_id.trim()) + .ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?; + let path = sanitize_attachment_local_path(&asset.local_path) + .ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?; + format!( + "[素材引用 resourceId={};项目路径={path}]", + resource_id.trim() + ) + } + DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + let resources = reference + .resource_ids + .iter() + .map(|id| id.trim()) + .collect::>() + .join(","); + let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim()); + if let Some(run_id) = reference.run_id.as_deref() { + summary.push_str(&format!("运行标识={} ", run_id.trim())); + } + if let Some(role) = reference.element_role.as_deref() { + summary.push_str(&format!("角色={} ", role.trim())); + } + if let Some(text) = reference.text.as_deref() { + summary.push_str(&format!("文本={} ", text.trim())); + } + if !resources.is_empty() { + summary.push_str(&format!("关联素材={resources}")); + } + summary.push(']'); + summary + } + }; + input.push(serde_json::json!({ "type": "text", "text": text })); + } + Ok(Value::Array(input)) +} + +pub(crate) fn direct_codex_user_item_to_prompt( + root: &Path, + item: &DirectCodexUserItem, +) -> Result { + let wire = direct_codex_user_item_to_wire_input(root, item)?; + wire.as_array() + .ok_or_else(|| "DirectProject user item wire input 不是数组".to_string()) + .map(|parts| { + parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::() + }) + .and_then(|prompt| { + if prompt.trim().is_empty() { + Err("DirectProject user item 不能转换为空 prompt".to_string()) + } else { + Ok(prompt) + } + }) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs similarity index 99% rename from apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs rename to apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 2e4cdad61..d72be32c5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -6,6 +6,9 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::{Mutex, OnceLock}; +mod user_input; +pub(crate) use user_input::chat_with_game_creator_direct_codex; + const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 16 * 1024; const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; @@ -4055,6 +4058,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( creation_type, None, None, + None, ) .await } @@ -4065,6 +4069,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( creation_type: Option<&str>, turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>, audit: Option<&mut DirectCodexTurnAudit>, + direct_user_item: Option, ) -> Result { if !root.is_absolute() || !root.is_dir() { return Err("当前项目目录不存在或不是绝对路径".to_string()); @@ -4080,7 +4085,15 @@ 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, audit).await + match run_direct_game_creator_turn_inner( + root, + prompt, + creation_type, + turn_emitter, + audit, + direct_user_item, + ) + .await { Ok(reply) => Ok(reply), Err(failure) => { @@ -4099,6 +4112,7 @@ async fn run_direct_game_creator_turn_inner( creation_type: Option<&str>, turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>, audit: Option<&mut DirectCodexTurnAudit>, + direct_user_item: Option, ) -> Result { emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息"); if let Some(emitter) = turn_emitter { @@ -4152,6 +4166,7 @@ async fn run_direct_game_creator_turn_inner( Some(&client_turn_id), Some(&mut observer), audit, + direct_user_item.clone(), ) .await } else { @@ -4162,6 +4177,7 @@ async fn run_direct_game_creator_turn_inner( None, None, audit, + direct_user_item.clone(), ) .await } @@ -4453,95 +4469,6 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( )) } -fn normalize_direct_client_turn_id(client_turn_id: Option<&str>) -> Result { - let Some(client_turn_id) = client_turn_id else { - return Err("Direct 客户端回合缺少稳定 clientTurnId,已拒绝创建可计费生成身份".to_string()); - }; - let client_turn_id = client_turn_id.trim(); - let valid_length = (MIN_DIRECT_CLIENT_TURN_ID_CHARS..=MAX_DIRECT_CLIENT_TURN_ID_CHARS) - .contains(&client_turn_id.len()); - let mut bytes = client_turn_id.bytes(); - let valid_first = bytes - .next() - .is_some_and(|byte| byte.is_ascii_alphanumeric()); - let valid_rest = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'); - if !valid_length || !valid_first || !valid_rest { - return Err(format!( - "clientTurnId 必须为 {MIN_DIRECT_CLIENT_TURN_ID_CHARS} 到 {MAX_DIRECT_CLIENT_TURN_ID_CHARS} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字" - )); - } - Ok(client_turn_id.to_string()) -} - -#[tauri::command] -pub(crate) async fn chat_with_game_creator_direct_codex( - project_path: String, - prompt: String, - creation_type: Option, - client_turn_id: Option, - attachments: Option>, - references: Option>, -) -> Result { - let root = Path::new(project_path.trim()); - let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; - let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?; - recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| { - redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500) - })?; - let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone()); - let mut audit = DirectCodexTurnAudit::start( - root, - &turn_id, - &prompt, - attachments.as_deref().unwrap_or_default(), - ); - let attachments = attachments.unwrap_or_default(); - let references = references.unwrap_or_default(); - let mut user_prompt = match render_direct_codex_user_prompt(&prompt, &attachments) { - Ok(prompt) => prompt, - Err(_) if !references.is_empty() && prompt.trim().is_empty() => String::new(), - Err(error) => { - audit.finish(false); - return Err(error); - } - }; - if let Some(reference_section) = match render_direct_codex_references_section(root, &references) - { - Ok(section) => section, - Err(error) => { - audit.finish(false); - return Err(error); - } - } { - if !user_prompt.trim().is_empty() { - user_prompt.push_str("\n\n"); - } - user_prompt.push_str(&reference_section); - } - if user_prompt.trim().is_empty() { - audit.finish(false); - return Err("聊天内容不能为空".to_string()); - } - 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 - { - Ok(reply) => reply, - Err(error) => { - audit.finish(false); - return Err(error); - } - }; - audit.finish(true); - turn_emitter.emit("completed", Some("none"), Some(reply.clone())); - Ok(reply) -} - #[tauri::command] pub(crate) async fn chat_with_game_creator_home_direct_codex( prompt: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs new file mode 100644 index 000000000..4188bfd8f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -0,0 +1,84 @@ +//! DirectProject 用户输入命令适配器。 +//! +//! Tauri 只在这里接收前端 item,校验与 canonical→prompt 投影交给 user-item +//! 深模块,回合编排仍由父模块负责。 + +use super::*; + +fn normalize_direct_client_turn_id(client_turn_id: Option<&str>) -> Result { + let Some(client_turn_id) = client_turn_id else { + return Err("Direct 客户端回合缺少稳定 clientTurnId,已拒绝创建可计费生成身份".to_string()); + }; + let client_turn_id = client_turn_id.trim(); + let valid_length = (MIN_DIRECT_CLIENT_TURN_ID_CHARS..=MAX_DIRECT_CLIENT_TURN_ID_CHARS) + .contains(&client_turn_id.len()); + let mut bytes = client_turn_id.bytes(); + let valid_first = bytes + .next() + .is_some_and(|byte| byte.is_ascii_alphanumeric()); + let valid_rest = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'); + if !valid_length || !valid_first || !valid_rest { + return Err(format!( + "clientTurnId 必须为 {MIN_DIRECT_CLIENT_TURN_ID_CHARS} 到 {MAX_DIRECT_CLIENT_TURN_ID_CHARS} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字" + )); + } + Ok(client_turn_id.to_string()) +} + +#[tauri::command] +pub(crate) async fn chat_with_game_creator_direct_codex( + project_path: String, + prompt: String, + user_item: DirectCodexUserItem, + creation_type: Option, + client_turn_id: Option, + attachments: Option>, +) -> Result { + let root = Path::new(project_path.trim()); + let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; + let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?; + recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| { + redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500) + })?; + let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone()); + let mut audit = DirectCodexTurnAudit::start( + root, + &turn_id, + &prompt, + attachments.as_deref().unwrap_or_default(), + ); + let attachments = attachments.unwrap_or_default(); + validate_direct_codex_user_item(root, &user_item).map_err(|error| { + audit.finish(false); + error + })?; + let user_prompt = direct_codex_user_item_to_prompt(root, &user_item).map_err(|error| { + audit.finish(false); + error + })?; + if user_prompt.trim().is_empty() { + audit.finish(false); + return Err("聊天内容不能为空".to_string()); + } + let canonical_user_item = + Some(serde_json::to_value(user_item).map_err(|error| error.to_string())?); + 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), + canonical_user_item, + ) + .await + { + Ok(reply) => reply, + Err(error) => { + audit.finish(false); + return Err(error); + } + }; + audit.finish(true); + turn_emitter.emit("completed", Some("none"), Some(reply.clone())); + Ok(reply) +}